RISC-V技术william hill官网
直播中

dskal

2年用户 39经验值
擅长:可编程逻辑
私信 关注
[经验]

从零开始写RISC-V处理器之四 软件篇

RISC-V汇编语言

汇编语言属于低级语言,这里的低级是相对于C、C++等高级语言而言的,并不是说汇编语言很“low”。汇编语言与具体的CPU架构(ARM、X86、RISC-V等)紧密关联,每一种CPU架构都有其对应的汇编语言。

汇编语言作为连接底层软件和处理器硬件(数字逻辑)的桥梁,要求做硬件和做底层软件的人都必须掌握的,只是要求掌握的程度不一样而已。有不少同学在数字方面很强,甚至整个处理器都写出来了,但是却不知道怎么写汇编语言或者C语言程序在上面跑。

虽然我对RISC-V汇编语言不是很熟悉,但我个人觉得RISC-V汇编语言还是很好掌握的(容易理解)。

RV32I有32个通用寄存器(x0至x31),PC寄存器不在这32个寄存器里面,其中x0为只读寄存器,其值固定为0。在RISC-V汇编语言程序里,我们一般看到的不是x0、x1、x2等这些名字,而是zero、ra、sp等名字,是因为这里的x0至x31有其对应的ABI(application
binary interface)名字,如下表所示:

寄存器 ABI 寄存器 ABI 寄存器 ABI
x0 zero x11 a1 x22 s6
x1 ra x12 a2 x23 s7
x2 sp x13 a3 x24 s8
x3 gp x14 a4 x25 s9
x4 tp x15 a5 x26 s10
x5 t0 x16 a6 x27 s11
x6 t1 x17 a7 x28 t3
x7 t2 x18 s2 x29 t4
x8 s0或者fp x19 s3 x30 t5
x9 s1 x20 s4 x31 t6
x10 a0 x21 s5

在汇编程序里,寄存器名字和ABI名字是可以直接互换的。

下面是一些汇编指令,注意这些指令不是RISC-V特有的,而是GCC编译器都有的指令。

.align :2的N次方个字节对齐,比如.align 3,表示8字节对齐。

.globl :声明全局符号,比如.globl mytest,声明一个mytest的全局符号,这样在其他文件里就可以引用该符号。

.equ :常量定义,比如.equ MAX 10。

.macro :宏定义。

.endm :宏定义结束,与.macro配套使用。

.section :段定义,比如.section .text.start,定义.text.start段。

下面是一些常用的RISC-V整数指令

  1. lui指令
    语法:lui rd, imm,作用是将imm的低12位置0,结果写入rd寄存器。
  2. auipc指令
    语法:auipc rd, imm,作用是将imm的高20位左移12位,低12位置0,然后加上PC的值,结果写入rd寄存器。
  3. jal指令
    语法:jal rd, offset或者jal offset,作用是将PC的值加上4,结果写入rd寄存器,rd默认为x1,同时将PC的值加上offset。
  4. jalr指令
    语法:jalr rd, rs1或者jalr rs1,作用是将PC的值加上4,结果写入rd寄存器,rd默认为x1,同时将PC的值加上符号位扩展之后的rs1的值。
  5. beq指令
    语法:beq rs1, rs2, offset,作用是如果rs1的值等于rs2的值,则将PC设置为符号位扩展后的offset的值。
  6. bne指令
    语法:bne rs1, rs2, offset,作用是如果rs1的值不等于rs2的值,则将PC设置为符号位扩展后的offset的值。
  7. blt指令
    语法:blt rs1, rs2, offset,作用是如果rs1的值小于rs2的值(rs1和rs2均视为有符号数),则将PC设置为符号位扩展后的offset的值。
  8. bge指令
    语法:bge rs1, rs2, offset,作用是如果rs1的值大于等于rs2的值(rs1和rs2均视为有符号数),则将PC设置为符号位扩展后的offset的值。
  9. bltu指令
    语法:bltu rs1, rs2, offset,作用是如果rs1的值小于rs2的值(rs1和rs2均视为无符号数),则将PC设置为符号位扩展后的offset的值。
  10. bgeu指令
    语法:bgeu rs1, rs2, offset,作用是如果rs1的值大于等于rs2的值(rs1和rs2均视为无符号数),则将PC设置为符号位扩展后的offset的值。
  11. lb指令
    语法:lb rd, offset(rs1),作用是从rs1加上offset的地址处读取一个字节的内容,并将该内容经符号位扩展后写入rd寄存器。
  12. lh指令
    语法:lh rd, offset(rs1),作用是从rs1加上offset的地址处读取两个字节的内容,并将该内容经符号位扩展后写入rd寄存器。
  13. lw指令
    语法:lw rd, offset(rs1),作用是从rs1加上offset的地址处读取四个字节的内容,结果写入rd寄存器。
  14. lbu指令
    语法:lbu rd, offset(rs1),作用是从rs1加上offset的地址处读取一个字节的内容,并将该内容经0扩展后写入rd寄存器。
  15. lhu指令
    语法:lhu rd, offset(rs1),作用是从rs1加上offset的地址处读取两个字节的内容,并将该内容经0扩展后写入rd寄存器。
  16. sb指令
    语法:sb rs2, offset(rs1),作用是将rs2的最低一个字节写入rs1加上offset的地址处。
  17. sh指令
    语法:sh rs2, offset(rs1),作用是将rs2的最低两个字节写入rs1加上offset的地址处。
  18. sw指令
    语法:sw rs2, offset(rs1),作用是将rs2的值写入rs1加上offset的地址处。
  19. addi指令
    语法:addi rd, rs1, imm,作用是将符号扩展的立即数imm的值加上rs1的值,结果写入rd寄存器,忽略算术溢出。
  20. slti指令
    语法:slti rd, rs1, imm,作用是将符号扩展的立即数imm的值与rs1的值比较(有符号数比较),如果rs1的值更小,则向rd寄存器写1,否则写0。
  21. sltiu指令
    语法:sltiu rd, rs1, imm,作用是将符号扩展的立即数imm的值与rs1的值比较(无符号数比较),如果rs1的值更小,则向rd寄存器写1,否则写0。
  22. xori指令
    语法:xori rd, rs1, imm,作用是将rs1与符号位扩展的imm按位异或,结果写入rd寄存器。
  23. ori指令
    语法:ori rd, rs1, imm,作用是将rs1与符号位扩展的imm按位或,结果写入rd寄存器。
  24. andi指令
    语法:andi rd, rs1, imm,作用是将rs1与符号位扩展的imm按位与,结果写入rd寄存器。
  25. slli指令
    语法:slli rd, rs1, shamt,作用是将rs1左移shamt位,空出的位补0,结果写入rd寄存器。
  26. srli指令
    语法:srli rd, rs1, shamt,作用是将rs1右移shamt位,空出的位补0,结果写入rd寄存器。
  27. srai指令
    语法:srai rd, rs1, shamt,作用是将rs1右移shamt位,空出的位用rs1的最高位补充,结果写入rd寄存器。
  28. add指令
    语法:add rd, rs1, rs2,作用是将rs1寄存器的值加上rs2寄存器的值,然后将结果写入rd寄存器里,忽略算术溢出。
  29. sub指令
    语法:sub rd, rs1, rs2,作用是将rs1寄存器的值减去rs2寄存器的值,然后将结果写入rd寄存器里,忽略算术溢出。
  30. sll指令
    语法:sll rd, rs1, rs2,作用是将rs1左移rs2位(低5位有效),空出的位补0,结果写入rd寄存器。
  31. slt指令
    语法:slt rd, rs1, rs2,作用是将rs1的值与rs2的值比较(有符号数比较),如果rs1的值更小,则向rd寄存器写1,否则写0。
  32. sltu指令
    语法:sltu rd, rs1, rs2,作用是将rs1的值与rs2的值比较(无符号数比较),如果rs1的值更小,则向rd寄存器写1,否则写0。
  33. xor指令
    语法:xor rd, rs1, rs2,作用是将rs1与rs2按位异或,结果写入rd寄存器。
  34. srl指令
    语法:srl rd, rs1, rs2,作用是将rs1右移rs2位(低5位有效),空出的位补0,结果写入rd寄存器。
  35. sra指令
    语法:sra rd, rs1, rs2,作用是将rs1右移rs2位(低5位有效),空出的位用rs1的最高位补充,结果写入rd寄存器。
  36. or指令
    语法:or rd, rs1, rs2,作用是将rs1与rs2按位或,结果写入rd寄存器。
  37. and指令
    语法:and rd, rs1, rs2,作用是将rs1与rs2按位与,结果写入rd寄存器。
  38. ecall指令
    语法:ecall,作用是进入异常处理程序,常用于OS的系统调用(上下文切换)。
  39. ebreak
    语法:ebreak,作用是进入调试模式。

以下是CSR指令。

  1. csrrw指令
    语法:csrrw rd, csr, rs1,作用是将csr寄存器的值读入rd,然后将rs1的值写入csr寄存器。
  2. csrrs指令
    语法:csrrs rd, csr, rs1,作用是将csr寄存器的值读入rd,然后将rs1的值与csr的值按位或后的结果写入csr寄存器。
  3. csrrc指令
    语法:csrrc rd, csr, rs1,作用是将csr寄存器的值读入rd,然后将rs1的值与csr的值按位与后的结果写入csr寄存器。
  4. csrrwi指令
    语法:csrrwi rd, csr, imm,作用是将csr寄存器的值读入rd,然后将0扩展后的imm的值写入csr寄存器。
  5. csrrsi指令
    语法:csrrsi rd, csr, imm,作用是将csr寄存器的值读入rd,然后将0扩展后的imm的值与csr的值按位或后的结果写入csr寄存器。
  6. csrrci指令
    语法:csrrci rd, csr, imm,作用是将csr寄存器的值读入rd,然后将0扩展后的imm的值与csr的值按位与后的结果写入csr寄存器。

我们都知道,学习一门程序语言时如果单单学习语法的话会觉得很枯燥,所以下面就以tinyriscv的启动文件start.S里的汇编程序来实战分析一下。完整的代码如下:

.section .init;
    .globl _start;
    .type _start,@function

_start:
.option push
.option norelax
	la gp, __global_pointer$
.option pop
	la sp, _sp
#ifdef SIMULATION
    li x26, 0x00
    li x27, 0x00
#endif

	/* Load data section */
	la a0, _data_lma
	la a1, _data
	la a2, _edata
	bgeu a1, a2, 2f
1:
	lw t0, (a0)
	sw t0, (a1)
	addi a0, a0, 4
	addi a1, a1, 4
	bltu a1, a2, 1b
2:

	/* Clear bss section */
	la a0, __bss_start
	la a1, _end
	bgeu a0, a1, 2f
1:
	sw zero, (a0)
	addi a0, a0, 4
	bltu a0, a1, 1b
2:

    call _init
    call main

#ifdef SIMULATION
    li x26, 0x01
#endif

loop:
    j loop

第1行,定义.init段。

第2行,声明全局符号_start。

第3行,_start是一个函数。

第5行,_start标签,用来指示start的地址。

第8行,la是伪指令,对应到RISC-V汇编里是auipc和lw这两条指令,这里的作用是将__global_pointer标签的地址读入gp寄存器。

第10行,将_sp的地址读入sp寄存器,sp寄存器的值在这里初始化。

第12行,li是伪指令,对应到RISC-V汇编里是lui和addi这两条指令(或者只有lui这一条指令),这里是将x26寄存器的值清零。

第13行,将x27寄存器的值清零。

第17行,加载_data_lma的地址(数据段的数据在flash的起始地址)到a0寄存器。

第18行,加载_data的地址(数据段的数据在ram的起始地址)到a1寄存器。

第19行,加载_edata的地址(数据段的结束地址)到a2寄存器。

第20行,比较a1和a2的大小,如果a1大于等于a2,则跳转到第27行,否则往下执行。

第22行,从a0地址处读4个字节到t0寄存器。

第23行,将t0寄存器的值存入a1地址处。第22行、第23行的作用就是将一个word的数据从flash里搬到ram。

第24行,a0的值加4,指向下一个word。

第25行,a1的值加4,指向下一个word。

第26行,比较a1和a2的大小,如果a1小于a2,则跳转到21行,否则往下执行。到这里就可以知道,第22行~第26行代码的作用就是将存在flash里的全部数据搬到ram里。

第30行,将__bss_start的地址(bss段的起始地址)读到a0寄存器。

第31行,将_end的地址(bss段的结束地址)读到a1寄存器。

第32行,比较a0和a1的大小,如果a0大于等于a1,则跳转到第37行,否则往下执行。

第34行,将a0地址处的内容清零。

第35行,a0的值加4,指向下一个地址。

第36行,比较a0和a1的大小,如果a0小于a1,则跳转到第33行,否则往下执行。到这里就知道,第33行~第36行的作用就是将bss段的内容全部清零。

第39行,call是伪指令,语法:call rd, symbol。在这里会转换成在RISC-V汇编里的auipc和jalr这两条指令,作用是将PC+8的值保存到rd寄存器(默认为x1寄存器),然后将PC设置为symbol的值,这样就实现了跳转并保存返回地址。这里是调用_init函数。

第40行,调用main函数,这里就进入到C语言里的main函数了。

第43行,设置x26寄存器的值为1,表示仿真结束。

第46~47行,死循环,原地跳转。

在这里要说明一下,上面启动代码里的从flash搬数据到ram和清零bss段这两块代码是嵌入式启动代码里非常常见的,也是比较通用的,必须要理解并掌握。

Makefile

用过make命令来编译程序的应该都知道Makefile。Makefile文件里包含一系列目标构建规则,当我们在终端里输入make命令然后回车时make工具就会在当前目录下查找Makefile(或者makefile)文件,然后根据Makefile文件里的规则来构建目标。可以说,学习Makefile就是学习这些构建规则。

Make可以管理工程的编译步骤,这样就不需要每次都输入一大串命令来编译程序了,编写好Makefile后,只需要输入make命令即可自动完成整个工程的编译、构建。可以这么说,是否掌握Makefile,从侧面反映出你是否具有管理代码工程的能力。

关于Makefile的详细介绍网上已有不少,因此这里只作简单介绍。

1.Makefile文件规则

Makefile文件由一系列规则组成,每条规则如下:

<target>:<prerequisites>
[tab]<commands>

第一行里的target叫做目标,prerequisites叫做依赖。

第二行以tab键缩进,后面跟着一条或多条命令,这里的命令是shell命令。

简单来说就是,make需要生成对应的目标时,先查找其依赖是否都已经存在,如果都已经存在则执行命令,如果不存在则先去查找生成依赖的规则,如此不断地查找下去,直到所有依赖都生成完毕。

1.1 目标

在一条规则里,目标是必须要有的,依赖和命令可有可无。

当输入make命令不带任何参数时,make首先查找Makefile里的第一个目标,当然也可以指定目标,比如:

make test

来指定执行构建test目标。

如果当前目录下刚好存在一个test文件,这时make不会构建Makefile文件里的test目标,这时就需要使用.PHONY来指定test为伪目标,例如:

.PHONY: test
test:
	ls

1.2 依赖

依赖可以是一个或者多个文件,又或者是一个或多个目标。如果依赖不存在或者依赖的时间戳比目标的时间戳新(依赖被更新过),则会重新构建目标。

1.3命令

命令通常是用来表示如何生成(更新)目标的,由一个或者多个shell命令组成。每行命令前必须有一个tab键(不是空格)。

2.Makefile语法

2.1注释

Makefile中的注释和shell脚本中的注释一样,使用#符号表示注释的开始,注意Makeifle中只有单行注释,好比C语言中的//,如果需要多行注释则需要使用多个#号。

2.2变量和赋值

Makefile中可以使用=、?=、:=、+=这4种符号对变量进行赋值,这四种赋值的区别为:

=  表示在执行时再进行赋值
:= 表示在定义时就进行赋值
?= 表示在变量为空时才进行赋值
+= 表示将值追加到变量的尾部

对变量进行引用时使用$(变量)形式,比如:

VAR = 123
test:
    echo $(VAR)

2.3内置变量

make工具提供了一些内置变量,比如CC表示当前使用的编译器,MAKE表示当前使用的make工具,这些都是为了跨平台使用的。

2.4自动变量

make工具提供了一些自动变量,这些变量的值与当前的规则有关,即不同的规则这些变量的值可能就会不一样。

$@:表示完整的目标名字,包括后缀
$<:表示第一个依赖
$^:表示所有依赖,每个依赖之间以空格隔开
$?:表示比目标更新的所有依赖,每个依赖之间以空格隔开

2.5内置函数

make工具提供了很多内置函数可以直接调用,这里列举以下一些函数。

2.5.1wildcard函数

扩展通配符函数,用法如下:

cfiles := $(wildcard *.c)

作用是匹配当前目录(不包含子目录)下所有.c文件,每个文件以空格隔开,然后赋值给cfiles变量。

2.5.2patsubst函数

替换通配符函数,结合wildcard函数用法如下:

objs := $(patsubst %.c,%.o,$(wildcard *.c))

作用是将当前目录(不包含子目录)下所有的.c文件替换成对应的.o文件,即将后缀为.c的文件替换为后缀为.o的文件,每个文件以空格隔开,然后赋值给objs变量。

2.5.3abspath函数

文件绝对路径函数,用法如下:

path := $(abspath main.c)

作用是获取当前目录下main.c文件的绝对路径(含文件名,结果比如:/work/main.c),然后赋值给path变量。

Makefile的内容就介绍到这里,下面以tinyriscv项目里的tests/example/simple例程来具体分析。

tests/example/simple/Makefile文件内容如下:

RISCV_ARCH := rv32im
RISCV_ABI := ilp32
RISCV_MCMODEL := medlow

TARGET = simple

CFLAGS += -DSIMULATION
#CFLAGS += -O2
#ASM_SRCS +=
#LDFLAGS +=
#INCLUDES += -I.

C_SRCS := \
	main.c \

COMMON_DIR = ../../bsp
TOOLCHAIN_DIR = ../../..
include ../../bsp/common.mk

可以看到都是一些变量赋值操作,需要注意的是第7行,这里的作用是定义SIMULATION这一个宏,对应C语言里的代码为:

#define SIMULATION

第18行,包含common.mk文件,类似于C语言里的#include操作。

下面看一下common.mk文件:

RISCV_TOOLS_PATH := $(TOOLCHAIN_DIR)/tools/gnu-mcu-eclipse-riscv-none-gcc-8.2.0-2.2-20190521-0004-win64/bin
RISCV_TOOLS_PREFIX := riscv-none-embed-

RISCV_GCC     := $(abspath $(RISCV_TOOLS_PATH)/$(RISCV_TOOLS_PREFIX)gcc)
RISCV_AS      := $(abspath $(RISCV_TOOLS_PATH)/$(RISCV_TOOLS_PREFIX)as)
RISCV_GXX     := $(abspath $(RISCV_TOOLS_PATH)/$(RISCV_TOOLS_PREFIX)g++)
RISCV_OBJDUMP := $(abspath $(RISCV_TOOLS_PATH)/$(RISCV_TOOLS_PREFIX)objdump)
RISCV_GDB     := $(abspath $(RISCV_TOOLS_PATH)/$(RISCV_TOOLS_PREFIX)gdb)
RISCV_AR      := $(abspath $(RISCV_TOOLS_PATH)/$(RISCV_TOOLS_PREFIX)ar)
RISCV_OBJCOPY := $(abspath $(RISCV_TOOLS_PATH)/$(RISCV_TOOLS_PREFIX)objcopy)
RISCV_READELF := $(abspath $(RISCV_TOOLS_PATH)/$(RISCV_TOOLS_PREFIX)readelf)

.PHONY: all
all: $(TARGET)

ASM_SRCS += $(COMMON_DIR)/start.S
ASM_SRCS += $(COMMON_DIR)/trap_entry.S
C_SRCS += $(COMMON_DIR)/init.c
C_SRCS += $(COMMON_DIR)/trap_handler.c
C_SRCS += $(COMMON_DIR)/lib/utils.c
C_SRCS += $(COMMON_DIR)/lib/xprintf.c
C_SRCS += $(COMMON_DIR)/lib/uart.c

LINKER_SCRIPT := $(COMMON_DIR)/link.lds

INCLUDES += -I$(COMMON_DIR)

LDFLAGS += -T $(LINKER_SCRIPT) -nostartfiles -Wl,--gc-sections -Wl,--check-sections

ASM_OBJS := $(ASM_SRCS:.S=.o)
C_OBJS := $(C_SRCS:.c=.o)

LINK_OBJS += $(ASM_OBJS) $(C_OBJS)
LINK_DEPS += $(LINKER_SCRIPT)

CLEAN_OBJS += $(TARGET) $(LINK_OBJS) $(TARGET).dump $(TARGET).bin

CFLAGS += -march=$(RISCV_ARCH)
CFLAGS += -mabi=$(RISCV_ABI)
CFLAGS += -mcmodel=$(RISCV_MCMODEL) -ffunction-sections -fdata-sections -fno-builtin-printf -fno-builtin-malloc

$(TARGET): $(LINK_OBJS) $(LINK_DEPS) Makefile
	$(RISCV_GCC) $(CFLAGS) $(INCLUDES) $(LINK_OBJS) -o $@ $(LDFLAGS)
	$(RISCV_OBJCOPY) -O binary $@ $@.bin
	$(RISCV_OBJDUMP) --disassemble-all $@ > $@.dump

$(ASM_OBJS): %.o: %.S
	$(RISCV_GCC) $(CFLAGS) $(INCLUDES) -c -o $@ $<

$(C_OBJS): %.o: %.c
	$(RISCV_GCC) $(CFLAGS) $(INCLUDES) -c -o $@ $<

.PHONY: clean
clean:
	rm -f $(CLEAN_OBJS)

第2~12行,作用是定义交叉工具链的路径,如果你的工具链路径跟这里的不一致,那就需要修改这几行。

第14~15行,定义all目标,为默认(第一个)目标。

第17~23行,把公共的C语言文件和汇编文件添加进来。

第25行,指定链接脚本。

第27行,指定头文件路径。

第29行,指定链接参数。

第31行,将ASM_SRCS变量里所有的.S文件替换成对应的.o文件。

第32行,将C_SRCS变量里所有的.c文件替换成对应的.o文件。

第39行,指定-march参数的值,这里为rv32im,即tinyriscv处理器支持的指令类型为整形(必须支持)和乘除(M扩展)。

第40行,指定-mabi参数的值,这里为ilp32,即整型、长整型、指针都为32位。

第43~46行,all目标的生成规则。

第44行,编译生成目标的elf文件,即生成simple文件。

第45行,根据elf文件生成bin文件,即生成simple.bin文件。

第46行,将elf文件反汇编,即生成simple.dump文件。

第48~49行,这个规则的作用是根据ASM_OBJS变量里的.o文件找到对应的.S文件,然后将该.S文件使用第49行的命令进行编译。

第5152行,与第4849行类似,这个规则的作用是根据C_OBJS变量里的.o文件找到对应的.c文件,然后将该.c文件使用第52行的命令进行编译。

第54~56行,定义clean目标,当在命令行输入make clean时就会执行这条规则,作用是删除所有的.o文件。

common.mk是公共文件,所有的例程都会用到它。

链接脚本

我们所编写的代码最终要能被处理器执行,一般需要经过编译、汇编和链接这3个过程。其中链接这个过程是链接器(比如riscv32-unknown-elf-ld程序)做的,链接器在链接过程中需要一个文件来告诉自己需要将输入的代码、数据等内容如何输出到可执行文件(比如elf文件)中。这个文件就是链接脚本(linker script),链接脚本定义了内存布局和控制输入内容如何映射到输出文件。链接脚本文件一般以ld或者lds作为后缀。

链接脚本与具体的处理器息息相关,每一家公司、个人开发的处理器所用到的链接脚本都有可能是不一样的。幸运的是,对于具体的处理器架构(ARM、RISC-V等),它们的链接脚本是大同小异的。如果你要设计一款处理器,那么链接脚本是必须要掌握的一门知识。

链接脚本可以说是比较冷门的技术了,除了官方文档外几乎找不到更好的参考资料,因此要掌握好这门技术,这里建议是多多阅读不同处理器的链接脚本,多看看别人的链接脚本是怎么写的。掌握链接脚本可以让你对程序地址空间、加载和启动有更深的理解。

这里并不会介绍链接脚本的全部内容,只会针对tinyriscv处理器的链接脚本涉及到的内容进行说明。

链接脚本里有两个比较重要关键字,分别是MEMORY和SECTIONS。其中MEMORY用于描述内存(比如ROM、RAM、Flash等)布局,包括每一块内存的起始地址、大小和属性。SECTIONS用于描述输入段(input section)如何映射到输出段(output section)等。

下面先看MEMORY的语法:

MEMORY
{
  <name> [(<attr>)] : ORIGIN = <origin>, LENGTH = <len>
  ...
}

name是内存块的名字,比如rom、ram、flash等名字。

attr是该块内存的属性,有r(读)、w(写)、x(执行)等属性。

origin是该块内存的起始地址,比如0x10000000。ORIGIN可以缩写成org。

len是该块内存的大小,比如128K、1M等。LENGTH可以缩写成l。

比如tinyriscv链接脚本的MEMORY是这样的:

MEMORY
{
  flash (wxa!ri) : ORIGIN = 0x00000000, LENGTH = 32K
  ram (wxa!ri) :   ORIGIN = 0x10000000, LENGTH = 16K
}

下面看SECTIONS的语法:

SECTIONS
{
  <sections−command>
  <sections−command>
  ...
}

很简单,关键是里面的sections−command的语法:

section [address] [(type)] :
  [AT(lma)]
  [ALIGN(section_align) | ALIGN_WITH_INPUT]
  [SUBALIGN(subsection_align)]
  [constraint]
  {
    output-section-command
    output-section-command
    …
  } [>region] [AT>lma_region] [:phdr :phdr …] [=fillexp] [,]

section:输出段名字,常见的有.text、.data、.bss等。

address:输出段的虚拟内存地址(virtual memory address,VMA),即运行地址。

AT(lma):输出段的加载内存地址(load memory address,LMA),即存储地址。

ALIGN:输出段对齐,以字节为单位。

region:指定VMA地址。

AT>:指定LMA地址。

这里说一下什么是VMA地址和LMA地址。通常情况下VMA地址等于LMA地址,有玩过ARM或者说STM32的应该知道,通过调试器是可以将程序下载到STM32的RAM里直接跑的,而不需要下载到Flash,这种在RAM里直接跑的程序在链接过程时VMA就等于LMA。这种直接在RAM里运行方式一般只适用于前期程序调试,当掉电后程序就会消失。当程序调试完毕后,此时就需要将程序固化到Flash里,这种需要固化到Flash里的程序在链接过程中就会有些数据(比如全局初始化不为零的data)的VMA地址不等于LMA地址,在程序启动的时候需要将这部分数据从Flash搬到RAM里。

接下来结合实际的链接脚本来分析。看看tinyriscv链接脚本里的SECTIONS是怎样的,这里只列出一部分代码:

SECTIONS
{
   __stack_size = DEFINED(__stack_size) ? __stack_size : 8K;

  .init           :
  {
    KEEP (*(SORT_NONE(.init)))
  } >flash AT>flash 

  .text           :
  {
    *(.text.unlikely .text.unlikely.*)
    *(.text.startup .text.startup.*)
    *(.text .text.*)
    *(.gnu.linkonce.t.*)
  } >flash AT>flash 

  . = ALIGN(4);

  PROVIDE (__etext = .);
  PROVIDE (_etext = .);
  PROVIDE (etext = .);
...
  .data          :
  {
    *(.rdata)
    *(.rodata .rodata.*)
    *(.gnu.linkonce.r.*)
    *(.data .data.*)
    *(.gnu.linkonce.d.*)
    . = ALIGN(8);
    PROVIDE( __global_pointer$ = . + 0x800 );
    *(.sdata .sdata.*)
    *(.gnu.linkonce.s.*)
    . = ALIGN(8);
    *(.srodata.cst16)
    *(.srodata.cst8)
    *(.srodata.cst4)
    *(.srodata.cst2)
    *(.srodata .srodata.*)
  } >ram AT>flash 
...
  .bss            :
  {
    *(.sbss*)
    *(.gnu.linkonce.sb.*)
    *(.bss .bss.*)
    *(.gnu.linkonce.b.*)
    *(COMMON)
    . = ALIGN(4);
  } >ram AT>ram
...
}

第3行,定义__stack_size变量,并将其赋值为8K。

第5行,定义.init输出段。

第7行,.init段里包含.init输入段。*号是通配符,KEEP的作用是告诉链接器保留这些输入段,不要优化掉。.init段在start.S文件中定义,从这里可以知道,启动代码放在了flash里的0x00000000地址处。这也知道tinyriscv的程序是从0x0地址开始运行的。

第8行,这里的flash就是前面在MEMORY里定义的内存块,这里指定VMA地址在flash里,LMA地址也是flash里。

第10~16行,应该比较好理解了,定义.text输出段,里面主要放的是代码,同样VMA和LMA地址也是在flash里。

第18行,.符号在链接脚本里加做位置计数器,这个位置计数器只能向后移动,不能向前移动。这行代码的作用就是将位置计数器进行4字节对齐。

第20~22行,PROVIDE的作用是导出全局符号,这里分别导出了3个符号,这些符号的值就等于当前位置计数器的值,这些符号可以被汇编、C语言代码引用。

比如在链接脚本里PROVIDE了3个符号,分别是start_of_ROM、end_of_ROM、start_of_FLASH,在汇编程序里可以这样引用:

la a0, start_of_ROM
la a1, end_of_ROM
la a2, start_of_FLASH

在C语言程序里可以这样引用:

extern char start_of_ROM, end_of_ROM, start_of_FLASH;
memcpy (&start_of_FLASH, &start_of_ROM, &end_of_ROM - &start_of_ROM);

或者这样引用:

extern char start_of_ROM[], end_of_ROM[], start_of_FLASH[];

memcpy (start_of_FLASH, start_of_ROM, end_of_ROM - start_of_ROM);

第24~41行,定义.data段,这里的data段的LMA地址不等于VMA地址,VMA地址在ram里,LMA地址在flash里。

第43~51行,定义.bss段,bss段包含一些在程序里全局定义但没有初始化的变量。LMA地址等于VMA地址,都在ram里。

启动代码

启动代码在RISC-V汇编语言那一节已经分析过了,这里就不再重复了。

异常和中断

在RISC-V里,异常(exception)和中断(interrupt)统称为陷阱(trap),这里的异常又可以称作同步中断,而中断是指异步中断。说到异常和中断,就不得不提RISC-V的特权级别(Privilege Levels)了,RISC-V架构目前一共定义了3种特权级别,由低到高分别是用户、监督者和机器级别(模式)。其中机器模式是必须要实现的,监督者和用户模式根据不同的软件系统需求来实现。一般来说,如果是简单的嵌入式系统,则只需要实现机器模式,如果是安全系统,则需要实现机器和监督者模式,如果是类Unix系统,则这3种模式都要实现。每一种特权级别都有其对应的指令集扩展和CSR寄存器(Control and Status Registers)。由于tinyriscv处理器只实现了机器模式,因此这里只介绍机器模式相关的内容。

先看一些跟中断和异常相关的比较重要的CSR寄存器。注意,机器模式相关的CSR寄存器都是以m字母开头的。

mstatus(Machine Status Register)

mstatus[3]:MIE,全局中断使能位,可读可写,该位决定了整个核的中断(异常)是否使能。该位对一些不可屏蔽的中断(NMI)是无效的,比如一些会引起硬件错误的中断(异常)。

mie(Machine Interrupt Enable Register)

mie[3]:MSIE,软件中断使能位,可读可写。

mie[7]:MTIE,定时器中断使能位,可读可写。

mie[11]:MEIE,外部中断使能位,可读可写。

mip(Machine Interrupt Pending Register)

mip[3]:MSIP,软件中断pending位,只读。

mip[7]:MTIP,定时器中断pending位,只读。

mip[11]:MEIP,外部中断pending位,只读。

mtvec(Machine Trap-Vector Base-Address Register)

mtvec[31:2]:中断入口基地址,可读可写,必须4字节对齐。

mtvec[1:0]:中断向量模式,可读可写,当mtvec[1:0]=00时为直接模式,此时所有的异常和中断入口地址都为mtvec[31:2]的值。当mtvec[1:0]=01时为向量模式,所有异常的入口地址为mtvec[31:2]的值,而所有中断的入口地址为mtvec[31:2] + causex4,其中cause为中断号。tinyriscv实现的是直接模式。

mcause(Machine Cause Register)

mcause[31]:中断位,可读可写,表示当trap发生时,该trap中断还是异常,1表示中断,0表示异常。

mcause[30:0]:中断号,可读可写,表示trap发生时所对应的中断(异常)号。比如定时器中断号为7,外部中断号为11,非法指令异常号为2等等。

在中断入口函数里通过读这个寄存器的值就可以知道当前发生的是哪个中断或异常。

mepc(Machine Exception Program Counter)

该寄存器保存中断(异常)返回时PC指针的值,即MCU处理完中断(异常)后从该寄存器所指的地址处继续执行。

中断(异常)代码分析

下面看一下tinyriscv中断处理相关的代码。

首先是中断初始化相关的,启动代码start.S文件里有这么一行代码:

call _init

意思是调用_init()函数,这个函数在init.c文件里定义:

extern void trap_entry();


void _init()
{
    // 设置中断入口函数
    write_csr(mtvec, &trap_entry);
    // 使能CPU全局中断
    // MIE = 1, MPIE = 1, MPP = 11
    write_csr(mstatus, 0x1888);
}

第7行,通过写mtvec寄存器来设置中断入口函数地址,这里的中断入口函数为trap_entry。

第10行,写mstatus的bit3来使能全局中断。

接下来看中断处理相关的,中断处理函数trap_entry定义在trap_entry.S文件里:

#define REGBYTES  4
#define STORE     sw
#define LOAD      lw


    .section      .text.entry	
    .align 2
    .global trap_entry
trap_entry:

    addi sp, sp, -32*REGBYTES

    STORE x1, 1*REGBYTES(sp)
    STORE x2, 2*REGBYTES(sp)
    STORE x3, 3*REGBYTES(sp)
    STORE x4, 4*REGBYTES(sp)
    STORE x5, 5*REGBYTES(sp)
    STORE x6, 6*REGBYTES(sp)
    STORE x7, 7*REGBYTES(sp)
    STORE x8, 8*REGBYTES(sp)
    STORE x9, 9*REGBYTES(sp)
    STORE x10, 10*REGBYTES(sp)
    STORE x11, 11*REGBYTES(sp)
    STORE x12, 12*REGBYTES(sp)
    STORE x13, 13*REGBYTES(sp)
    STORE x14, 14*REGBYTES(sp)
    STORE x15, 15*REGBYTES(sp)
    STORE x16, 16*REGBYTES(sp)
    STORE x17, 17*REGBYTES(sp)
    STORE x18, 18*REGBYTES(sp)
    STORE x19, 19*REGBYTES(sp)
    STORE x20, 20*REGBYTES(sp)
    STORE x21, 21*REGBYTES(sp)
    STORE x22, 22*REGBYTES(sp)
    STORE x23, 23*REGBYTES(sp)
    STORE x24, 24*REGBYTES(sp)
    STORE x25, 25*REGBYTES(sp)
#ifndef SIMULATION
    STORE x26, 26*REGBYTES(sp)
    STORE x27, 27*REGBYTES(sp)
#endif
    STORE x28, 28*REGBYTES(sp)
    STORE x29, 29*REGBYTES(sp)
    STORE x30, 30*REGBYTES(sp)
    STORE x31, 31*REGBYTES(sp)

    csrr a0, mcause
    csrr a1, mepc
test_if_asynchronous:
	srli a2, a0, 31		                /* MSB of mcause is 1 if handing an asynchronous interrupt - shift to LSB to clear other bits. */
	beq a2, x0, handle_synchronous		/* Branch past interrupt handing if not asynchronous. */

    call interrupt_handler
    j asynchronous_return

handle_synchronous:
    call exception_handler
    addi a1, a1, 4
    csrw mepc, a1

asynchronous_return:
    LOAD x1, 1*REGBYTES(sp)
    LOAD x2, 2*REGBYTES(sp)
    LOAD x3, 3*REGBYTES(sp)
    LOAD x4, 4*REGBYTES(sp)
    LOAD x5, 5*REGBYTES(sp)
    LOAD x6, 6*REGBYTES(sp)
    LOAD x7, 7*REGBYTES(sp)
    LOAD x8, 8*REGBYTES(sp)
    LOAD x9, 9*REGBYTES(sp)
    LOAD x10, 10*REGBYTES(sp)
    LOAD x11, 11*REGBYTES(sp)
    LOAD x12, 12*REGBYTES(sp)
    LOAD x13, 13*REGBYTES(sp)
    LOAD x14, 14*REGBYTES(sp)
    LOAD x15, 15*REGBYTES(sp)
    LOAD x16, 16*REGBYTES(sp)
    LOAD x17, 17*REGBYTES(sp)
    LOAD x18, 18*REGBYTES(sp)
    LOAD x19, 19*REGBYTES(sp)
    LOAD x20, 20*REGBYTES(sp)
    LOAD x21, 21*REGBYTES(sp)
    LOAD x22, 22*REGBYTES(sp)
    LOAD x23, 23*REGBYTES(sp)
    LOAD x24, 24*REGBYTES(sp)
    LOAD x25, 25*REGBYTES(sp)
#ifndef SIMULATION
    LOAD x26, 26*REGBYTES(sp)
    LOAD x27, 27*REGBYTES(sp)
#endif
    LOAD x28, 28*REGBYTES(sp)
    LOAD x29, 29*REGBYTES(sp)
    LOAD x30, 30*REGBYTES(sp)
    LOAD x31, 31*REGBYTES(sp)

    addi sp, sp, 32*REGBYTES

    mret


.weak interrupt_handler
interrupt_handler:
1:
    j 1b

.weak exception_handler
exception_handler:
2:
    j 2b

第11行,将sp往低地址移动32个word,腾出来的栈空间用来保存通用寄存器的值。

第1345行,将x1x31寄存器的值保存到栈里面,即保护现场。

第47行,把mcause的值读到a0寄存器里面。

第48行,把mepc的值读到a1寄存器里面。

第50行,将a0寄存器的值逻辑右移31位,然后将右移后的值存到a2寄存器。

第51行,判断a2的值是否等于0,即判断当前trap是中断还是异常,如果等于0(是异常)则跳转到第56行,否则(是中断)继续往下执行。

第53行,调用interrupt_handler()函数,该函数在trap_handler.c文件里定义:

void interrupt_handler(uint32_t mcause, uint32_t mepc)
{
    // we have only timer0 interrupt here
    timer0_irq_handler();
}

因为目前tinyriscv只有定时器0外设这个中断,所以这里面直接调用timer0_irq_handler()函数。timer0_irq_handler()函数在timer_int这个例程的main.c里定义:

void timer0_irq_handler()
{
    TIMER0_REG(TIMER0_CTRL) |= (1 << 2) | (1 << 0);  // clear int pending and start timer

    count++;
}

回到trap_entry.S文件。

第54行,跳转操作,跳转到第61行。

第6296行,从栈里恢复x1x31寄存器的值,也就是进入中断前这些寄存器的值。

第98行,中断返回指令。

目前tinyriscv的中断处理流程就是这样的了,下面再看一下异常处理流程。

前面说到当进入trap_entry()函数时,如果mcause的bit31等于0,则会跳转到第56行。

第57行,调用exception_handler()函数:

void exception_handler(uint32_t mcause, uint32_t mepc)
{
    if ((mcause != TRAP_BREAKPOINT) && (mcause != TRAP_ECALL_M))
        while (1);
}

第3行,判断当前异常是否是由ebreak或者ecall指令导致的,如果是则什么都不处理,直接返回,否则调用第4行,进入死循环。

第58行,将a1寄存器的值加4。

第59行,将a1寄存器的值写入mepc寄存器,即将中断(异常)返回地址加4,指向下一条指令。

到这里,中断和异常的部分就分析完了。

最后说一下,进入中断(异常)时,硬件会把全局中断使能关了,因此在处理中断(异常)过程中默认是不会响应其他中断的,在中断(异常)返回时硬件才会重新使能全局中断。如果要实现中断嵌套(抢占)功能,则需要在中断处理函数里使能全局中断,并且硬件上要实现中断优先级功能。

回帖(1)

infortrans

2022-9-19 18:18:40
学习学习基础知识。
举报

更多回帖

发帖
×
20
完善资料,
赚取积分