打印
[RISC-V MCU 应用开发]

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

[复制链接]
1131|6
手机看帖
扫描二维码
随时随地手机跟帖
跳转到指定楼层
楼主
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名字是可以直接互换的。
下面是一些汇编指令,注意这些指令不是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整数指令。
  • lui指令
    语法:lui rd, imm,作用是将imm的低12位置0,结果写入rd寄存器。
  • auipc指令
    语法:auipc rd, imm,作用是将imm的高20位左移12位,低12位置0,然后加上PC的值,结果写入rd寄存器。
  • jal指令
    语法:jal rd, offset或者jal offset,作用是将PC的值加上4,结果写入rd寄存器,rd默认为x1,同时将PC的值加上offset。
  • jalr指令
    语法:jalr rd, rs1或者jalr rs1,作用是将PC的值加上4,结果写入rd寄存器,rd默认为x1,同时将PC的值加上符号位扩展之后的rs1的值。
  • beq指令
    语法:beq rs1, rs2, offset,作用是如果rs1的值等于rs2的值,则将PC设置为符号位扩展后的offset的值。
  • bne指令
    语法:bne rs1, rs2, offset,作用是如果rs1的值不等于rs2的值,则将PC设置为符号位扩展后的offset的值。
  • blt指令
    语法:blt rs1, rs2, offset,作用是如果rs1的值小于rs2的值(rs1和rs2均视为有符号数),则将PC设置为符号位扩展后的offset的值。
  • bge指令
    语法:bge rs1, rs2, offset,作用是如果rs1的值大于等于rs2的值(rs1和rs2均视为有符号数),则将PC设置为符号位扩展后的offset的值。
  • bltu指令
    语法:bltu rs1, rs2, offset,作用是如果rs1的值小于rs2的值(rs1和rs2均视为无符号数),则将PC设置为符号位扩展后的offset的值。
  • bgeu指令
    语法:bgeu rs1, rs2, offset,作用是如果rs1的值大于等于rs2的值(rs1和rs2均视为无符号数),则将PC设置为符号位扩展后的offset的值。
  • lb指令
    语法:lb rd, offset(rs1),作用是从rs1加上offset的地址处读取一个字节的内容,并将该内容经符号位扩展后写入rd寄存器。
  • lh指令
    语法:lh rd, offset(rs1),作用是从rs1加上offset的地址处读取两个字节的内容,并将该内容经符号位扩展后写入rd寄存器。
  • lw指令
    语法:lw rd, offset(rs1),作用是从rs1加上offset的地址处读取四个字节的内容,结果写入rd寄存器。
  • lbu指令
    语法:lbu rd, offset(rs1),作用是从rs1加上offset的地址处读取一个字节的内容,并将该内容经0扩展后写入rd寄存器。
  • lhu指令
    语法:lhu rd, offset(rs1),作用是从rs1加上offset的地址处读取两个字节的内容,并将该内容经0扩展后写入rd寄存器。
  • sb指令
    语法:sb rs2, offset(rs1),作用是将rs2的最低一个字节写入rs1加上offset的地址处。
  • sh指令
    语法:sh rs2, offset(rs1),作用是将rs2的最低两个字节写入rs1加上offset的地址处。
  • sw指令
    语法:sw rs2, offset(rs1),作用是将rs2的值写入rs1加上offset的地址处。
  • addi指令
    语法:addi rd, rs1, imm,作用是将符号扩展的立即数imm的值加上rs1的值,结果写入rd寄存器,忽略算术溢出。
  • slti指令
    语法:slti rd, rs1, imm,作用是将符号扩展的立即数imm的值与rs1的值比较(有符号数比较),如果rs1的值更小,则向rd寄存器写1,否则写0。
  • sltiu指令
    语法:sltiu rd, rs1, imm,作用是将符号扩展的立即数imm的值与rs1的值比较(无符号数比较),如果rs1的值更小,则向rd寄存器写1,否则写0。
  • xori指令
    语法:xori rd, rs1, imm,作用是将rs1与符号位扩展的imm按位异或,结果写入rd寄存器。
  • ori指令
    语法:ori rd, rs1, imm,作用是将rs1与符号位扩展的imm按位或,结果写入rd寄存器。
  • andi指令
    语法:andi rd, rs1, imm,作用是将rs1与符号位扩展的imm按位与,结果写入rd寄存器。
  • slli指令
    语法:slli rd, rs1, shamt,作用是将rs1左移shamt位,空出的位补0,结果写入rd寄存器。
  • srli指令
    语法:srli rd, rs1, shamt,作用是将rs1右移shamt位,空出的位补0,结果写入rd寄存器。
  • srai指令
    语法:srai rd, rs1, shamt,作用是将rs1右移shamt位,空出的位用rs1的最高位补充,结果写入rd寄存器。
  • add指令
    语法:add rd, rs1, rs2,作用是将rs1寄存器的值加上rs2寄存器的值,然后将结果写入rd寄存器里,忽略算术溢出。
  • sub指令
    语法:sub rd, rs1, rs2,作用是将rs1寄存器的值减去rs2寄存器的值,然后将结果写入rd寄存器里,忽略算术溢出。
  • sll指令
    语法:sll rd, rs1, rs2,作用是将rs1左移rs2位(低5位有效),空出的位补0,结果写入rd寄存器。
  • slt指令
    语法:slt rd, rs1, rs2,作用是将rs1的值与rs2的值比较(有符号数比较),如果rs1的值更小,则向rd寄存器写1,否则写0。
  • sltu指令
    语法:sltu rd, rs1, rs2,作用是将rs1的值与rs2的值比较(无符号数比较),如果rs1的值更小,则向rd寄存器写1,否则写0。
  • xor指令
    语法:xor rd, rs1, rs2,作用是将rs1与rs2按位异或,结果写入rd寄存器。
  • srl指令
    语法:srl rd, rs1, rs2,作用是将rs1右移rs2位(低5位有效),空出的位补0,结果写入rd寄存器。
  • sra指令
    语法:sra rd, rs1, rs2,作用是将rs1右移rs2位(低5位有效),空出的位用rs1的最高位补充,结果写入rd寄存器。
  • or指令
    语法:or rd, rs1, rs2,作用是将rs1与rs2按位或,结果写入rd寄存器。
  • and指令
    语法:and rd, rs1, rs2,作用是将rs1与rs2按位与,结果写入rd寄存器。
  • ecall指令
    语法:ecall,作用是进入异常处理程序,常用于OS的系统调用(上下文切换)。
  • ebreak
    语法:ebreak,作用是进入调试模式。

使用特权

评论回复

相关帖子

沙发
两只袜子|  楼主 | 2022-9-6 10:12 | 只看该作者
以下是CSR指令。
  • csrrw指令
    语法:csrrw rd, csr, rs1,作用是将csr寄存器的值读入rd,然后将rs1的值写入csr寄存器。
  • csrrs指令
    语法:csrrs rd, csr, rs1,作用是将csr寄存器的值读入rd,然后将rs1的值与csr的值按位或后的结果写入csr寄存器。
  • csrrc指令
    语法:csrrc rd, csr, rs1,作用是将csr寄存器的值读入rd,然后将rs1的值与csr的值按位与后的结果写入csr寄存器。
  • csrrwi指令
    语法:csrrwi rd, csr, imm,作用是将csr寄存器的值读入rd,然后将0扩展后的imm的值写入csr寄存器。
  • csrrsi指令
    语法:csrrsi rd, csr, imm,作用是将csr寄存器的值读入rd,然后将0扩展后的imm的值与csr的值按位或后的结果写入csr寄存器。
  • csrrci指令
    语法:csrrci rd, csr, imm,作用是将csr寄存器的值读入rd,然后将0扩展后的imm的值与csr的值按位与后的结果写入csr寄存器。
我们都知道,学习一门程序语言时如果单单学习语法的话会觉得很枯燥,所以下面就以tinyriscv的启动文件start.S里的汇编程序来实战分析一下。完整的代码如下:
第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段这两块代码是嵌入式启动代码里非常常见的,也是比较通用的,必须要理解并掌握。

使用特权

评论回复
板凳
两只袜子|  楼主 | 2022-9-6 10:13 | 只看该作者
本帖最后由 两只袜子 于 2022-9-6 10:15 编辑

Makefile
用过make命令来编译程序的应该都知道Makefile。Makefile文件里包含一系列目标构建规则,当我们在终端里输入make命令然后回车时make工具就会在当前目录下查找Makefile(或者makefile)文件,然后根据Makefile文件里的规则来构建目标。可以说,学习Makefile就是学习这些构建规则。
Make可以管理工程的编译步骤,这样就不需要每次都输入一大串命令来编译程序了,编写好Makefile后,只需要输入make命令即可自动完成整个工程的编译、构建。可以这么说,是否掌握Makefile,从侧面反映出你是否具有管理代码工程的能力。
关于Makefile的详细介绍网上已有不少,因此这里只作简单介绍。
1.Makefile文件规则
Makefile文件由一系列规则组成,每条规则如下:
第一行里的target叫做目标,prerequisites叫做依赖。
第二行以tab键缩进,后面跟着一条或多条命令,这里的命令是shell命令。
简单来说就是,make需要生成对应的目标时,先查找其依赖是否都已经存在,如果都已经存在则执行命令,如果不存在则先去查找生成依赖的规则,如此不断地查找下去,直到所有依赖都生成完毕。

156316acae6c205.png (13.64 KB )

156316acae6c205.png

使用特权

评论回复
地板
两只袜子|  楼主 | 2022-9-6 10:21 | 只看该作者
1.1 目标
在一条规则里,目标是必须要有的,依赖和命令可有可无。
当输入make命令不带任何参数时,make首先查找Makefile里的第一个目标,当然也可以指定目标,比如:
make test
来指定执行构建test目标。
如果当前目录下刚好存在一个test文件,这时make不会构建Makefile文件里的test目标,这时就需要使用.PHONY来指定test为伪目标,例如:
1.2 依赖
依赖可以是一个或者多个文件,又或者是一个或多个目标。如果依赖不存在或者依赖的时间戳比目标的时间戳新(依赖被更新过),则会重新构建目标。
1.3命令
命令通常是用来表示如何生成(更新)目标的,由一个或者多个shell命令组成。每行命令前必须有一个tab键(不是空格)。
2.Makefile语法
2.1注释
Makefile中的注释和shell脚本中的注释一样,使用#符号表示注释的开始,注意Makeifle中只有单行注释,好比C语言中的//,如果需要多行注释则需要使用多个#号。
2.2变量和赋值
Makefile中可以使用=、?=、:=、+=这4种符号对变量进行赋值,这四种赋值的区别为:
对变量进行引用时使用$(变量)形式,比如:
2.3内置变量
make工具提供了一些内置变量,比如CC表示当前使用的编译器,MAKE表示当前使用的make工具,这些都是为了跨平台使用的。
2.4自动变量
make工具提供了一些自动变量,这些变量的值与当前的规则有关,即不同的规则这些变量的值可能就会不一样。

使用特权

评论回复
5
两只袜子|  楼主 | 2022-9-6 10:22 | 只看该作者
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文件内容如下:
可以看到都是一些变量赋值操作,需要注意的是第7行,这里的作用是定义SIMULATION这一个宏,对应C语言里的代码为:
#define SIMULATION
第18行,包含common.mk文件,类似于C语言里的#include操作。
下面看一下common.mk文件:
下面看一下common.mk文件:
RISCV_TOOLS_PATH := $(TOOLCHAIN_DIR)/tools/gnu-mcu-eclipse-riscv-none-gcc-8.2.0-2.2-20190521-0004-win64/binRISCV_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: allall: $(TARGET)ASM_SRCS += $(COMMON_DIR)/start.SASM_SRCS += $(COMMON_DIR)/trap_entry.SC_SRCS += $(COMMON_DIR)/init.cC_SRCS += $(COMMON_DIR)/trap_handler.cC_SRCS += $(COMMON_DIR)/lib/utils.cC_SRCS += $(COMMON_DIR)/lib/xprintf.cC_SRCS += $(COMMON_DIR)/lib/uart.cLINKER_SCRIPT := $(COMMON_DIR)/link.ldsINCLUDES += -I$(COMMON_DIR)LDFLAGS += -T $(LINKER_SCRIPT) -nostartfiles -Wl,--gc-sections -Wl,--check-sectionsASM_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).binCFLAGS += -march=$(RISCV_ARCH)CFLAGS += -mabi=$(RISCV_ABI)CFLAGS += -mcmodel=$(RISCV_MCMODEL) -ffunction-sections -fdata-sections -fno-builtin-printf -fno-builtin-malloc

使用特权

评论回复
6
两只袜子|  楼主 | 2022-9-6 10:23 | 只看该作者
第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是公共文件,所有的例程都会用到它。

使用特权

评论回复
7
两只袜子|  楼主 | 2022-9-6 10:24 | 只看该作者
链接脚本
我们所编写的代码最终要能被处理器执行,一般需要经过编译、汇编和链接这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的语法:
下面看SECTIONS的语法:

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



使用特权

评论回复
发新帖 我要提问
您需要登录后才可以回帖 登录 | 注册

本版积分规则

1890

主题

6539

帖子

8

粉丝