freestanding 运行时环境 示例程序:
1 2 3 4 5 6 7 #include <stdint.h> void _start() { volatile uint8_t * p = (uint8_t *)(uintptr_t )0x10000000 ; *p = 'A' ; while (1 ) ; }
编译:rv32gcc -ffreestanding -nostdlib -Wl,-Ttext=0x80000000 -O2 a.c
:
命令解析:
-ffreestanding
指示编译器,代码是在“自由环境”(freestanding environment)下运行的,这意味着标准库可能不完全可用,程序不能依赖标准启动环境。这常用于系统软件开发,如操作系统内核。
-nostdlib
禁止链接器链接标准启动文件和标准库。这个选项确保不会将标准库的任何部分链接到最终的可执行文件中,这对于需要直接控制程序运行环境的系统级编程至关重要。
-Wl,-Ttext=0x80000000
这是一个向链接器传递参数的选项。-Wl,option
表示将 option
传递给链接器。这里的 -Ttext=0x80000000
设置程序的入口地址(即 .text
段开始的位置)为 0x80000000
。这通常用于指定程序在内存中的加载位置,是嵌入式系统和操作系统内核中常见的需求。
-O2
启用优化级别为 2。这是一种中等程度的优化,能够在不影响编译时间太多的前提下,提高程序的运行效率。这种优化级别会尝试不改变程序行为的前提下,提高程序的执行速度和/或减少占用的内存空间。
运行:qemu-system-riscv32 -nographic -M virt -bios none -kernel a.out
:
qemu-system-riscv32
这是 QEMU 的一个变体,专门用于模拟 32位的 RISC-V 架构。
-nographic
这个选项指示 QEMU 在没有图形界面的情况下运行,所有的输出都会发送到控制台。这对于运行服务器或无头系统(没有显示器、键盘等外围设备的系统)非常有用。
-M virt
-M
参数用于指定模拟的机器类型。在这里,virt
是一个为各种客户端操作系统提供足够支持的通用 RISC-V 系统。这种类型的虚拟机提供了基本的设备和是进行各种测试的理想选择。
-bios none
此参数指定不使用任何 BIOS。这意味着 QEMU 不会尝试从任何传统的 BIOS 固件启动,这通常用于直接启动裸机应用或特定的操作系统,这些操作系统不依赖于传统 BIOS 启动。
-kernel a.out
-kernel
用于直接加载并启动一个内核或可执行文件。这里的 a.out
是要被加载的文件,通常是一个编译好的内核或应用程序。这使得可以直接在模拟环境中测试编译后的程序。
这个程序是往0x10000000中写入了一个字符,这是因为0x10000000 是 virt 机器串口地址,因此想要打印出这个字符,必须加上参数-M virt。
怎么结束程序? 在qemu-system-riscv32中的virt机器模型中, 往一个特殊的地址写入一个特殊的字符即可结束QEMU的运行。
1 2 3 4 5 6 7 8 9 #include <stdint.h> void _start() { volatile uint8_t * p = (uint8_t *)(uintptr_t )0x10000000 ; *p = 'A' ; volatile uint32_t * exit = (uint32_t *)(uintptr_t )0x100000 ; *exit = 0x5555 ; _start(); }
1 2 $ qemu-system-riscv32 -nographic -M virt -bios none -kernel a.out A%
自制 freestanding 运行时环境
程序从地址0开始执行
只支持两条指令
addi指令
ebreak指令
ABI Mnemonic
示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 static void ebreak (long arg0, long arg1) { asm volatile ( "addi a0, x0, %0;" "addi a1, x0, %1;" "ebreak" : : "i" (arg0), "i" (arg1)) ; }static void putch (char ch) { ebreak(0 , ch); }static void halt (int code) { ebreak(1 , code); while (1 ) ; }void _start() { putch('A' ); halt(0 ); }
编译、查看反汇编:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 rv32gcc -ffreestanding -nostdlib -static -Wl,-Ttext=0 -O2 -o prog a.c rvobjdump -M no-aliases -d prog prog: file format elf32-littleriscv Disassembly of section .text: 00000000 <_start>: 0: 00000513 addi a0,zero,0 4: 04100593 addi a1,zero,65 8: 00100073 ebreak c: 00100513 addi a0,zero,1 10: 00000593 addi a1,zero,0 14: 00100073 ebreak 18: 0000006f jal zero,18 <_start+0x18>
可以看到确实只有两种汇编指令addi、ebreak。事实上,这一点都不神奇,因为 gcc 支持在 c 中插入汇编代码,上面的程序除了汇编逻辑,没有任何其他逻辑。展开之后,自然就只有那两种汇编指令了(jal 是跳转指令,不用考虑)。
怎么让这个程序运行呢? 我们可以找一个真实的 RISCV CPU 直接在裸机上运行,也可以在虚拟机比如 qemu-system-riscv32 上运行(事实上,这是刚做的事情,现在自己写一个程序来运行它),还可以自己写一个模拟器,来运行它。
如果想要在qemu-system-riscv32 运行它,必须对源程序做一些改造,比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 static void ebreak (long arg0, long arg1) { asm volatile ( "addi a0, x0, %0;" "addi a1, x0, %1;" "ebreak" : : "i" (arg0), "i" (arg1)) ; }static void putch (char ch) { ebreak(0 , ch); }static void halt (int code) { ebreak(1 , code); while (1 ) ; }void _start() { char * ptr = (char *)0x10000000 ; *ptr = 'A' ; putch('A' ); halt(0 ); }
1 2 3 4 5 $ rv32gcc -ffreestanding -nostdlib -static -Wl,-Ttext=0x80000000 -O2 -o prog b.c $ qemu-system-riscv32 -nographic -M virt -bios none -kernel prog A
YEMU 可以读取指令,然后分析指令,然后运行指令,顺便将 PC++。以上全部用 C语言进行语义模拟,就得到了一个模拟器,看起来很简单。这有点像用22世纪的 CPU 模拟 20 世界 80 年代的 8086,效率非常低,但是却能说明模拟器工作的方式。
ISA 状态机
状态集合S={<R,M>}:
RISC-V手册 -> 2.1 Programmers’ Model for Base Integer ISA
激励事件E={指令}
状态转移规则next:S×E→S
初始状态S0=<R0,M0>
换句话说,就是初始状态、状态转移(具体状态转移函数)、状态集合。
如果要用 C语言来模拟,那就得:
用C语言变量实现寄存器和内存;
用C语言语句实现指令的语义:
指令采用编码表示 -> 传统的(二进制)指令集模拟器
实现寄存器和内存 1 2 3 #include <stdint.h> uint32_t R[32 ], PC; uint8_t M[64 ];
这里内存为 64 字节的无符号数,至于为什么,这个和 pa1 中用无符号数作表达式一样,因为无符号数没有溢出的概念,如果换成有符号数,会 UB。
用语句实现指令的语义 指令周期(instruction cycle): 执行一条指令的步骤:
取指(fetch): 从PC所指示的内存位置读取一条指令;
译码(decode): 按手册解析指令的操作码(opcode)和操作数(operand);
执行(execute): 按解析出的操作码, 对操作数进行处理;
更新PC: 让PC指向下一条指令;
RTFM 之后,发现这两条指令的定义为:
1 2 3 4 5 6 7 31 20 19 15 14 12 11 7 6 0 +---------------+-----+-----+-----+---------+ | imm[11:0] | rs1 | 000 | rd | 0010011 | ADDI +---------------+-----+-----+-----+---------+ +---------------+-----+-----+-----+---------+ | 000000000001 |00000| 000 |00000| 1110011 | EBREAK +---------------+-----+-----+-----+---------+
因此,首先得判断指令,然后执行指令,一个不成熟的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 void inst_cycle () { uint32_t inst = *(uint32_t *)&M[PC]; if (((inst & 0x7f ) == 0x13 ) && ((inst >> 12 ) & 0x7 ) == 0 ) { if (((inst >> 7 ) & 0x1f ) != 0 ) { R[(inst >> 7 ) & 0x1f ] = R[(inst >> 15 ) & 0x1f ] + (((inst >> 20 ) & 0x7ff ) - ((inst & 0x80000000 ) ? 4096 : 0 )); } } else if (inst == 0x00100073 ) { if (R[10 ] == 0 ) { putchar (R[11 ] & 0xff ); } else if (R[10 ] == 1 ) { halt = true ; } else { printf ("Unsupported ebreak command\n" ); } } else { printf ("Unsupported instuction\n" ); } PC += 4 ; }
判断是哪一条指令,要看这个指令的特征,比如 addi 的特征就是最低 7 位为 0x13 并且 12~14 位 为 0x0。判断出来后,就要取出 rd,这是目标寄存器,不能为 0;接着就是计算了,这里需要注意,(inst & 0x80000000) ? 4096 : 0
:这里检查指令的最高位(第 31 位),即立即数的符号位。在 RISC-V 中,立即数使用的是补码表示。如果符号位为 1(即 inst & 0x80000000 为真),表示这是一个负数,那么需要进行符号扩展。在这种情况下,将整个立即数扩展为 32 位负数,而 -4096(二进制为 1111 0000 0000 0000,补码表示的 -4096)正是扩展这 12 位为全 32 位所需的值。这个数值补充了高位的 1 们,完成从 12 位到 32 位的扩展。
至于 ebreak,R[10]其实就是 a0, R[11]就是a1。a0 为 0,输出a1的低八位;a0为 1,终止程序。可以看到以上所有语义都是由 C 语言进行模拟的。至于初始状态的定义,自然就是:
1 2 3 R[0 ] = 0 ; PC = 0 ; M = 程序;
因此就有了第一版的 YEMU:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 #include <stdbool.h> #include <stdint.h> #include <stdio.h> uint32_t R[32 ], PC;uint8_t M[64 ] = { 0x13 , 0x05 , 0x00 , 0x00 , 0x93 , 0x05 , 0x10 , 0x04 , 0x73 , 0x00 , 0x10 , 0x00 , 0x13 , 0x05 , 0x10 , 0x00 , 0x93 , 0x05 , 0x00 , 0x00 , 0x73 , 0x00 , 0x10 , 0x00 , 0x6f , 0x00 , 0x00 , 0x00 , };bool halt = false ;void inst_cycle () { uint32_t inst = *(uint32_t *)&M[PC]; if (((inst & 0x7f ) == 0x13 ) && ((inst >> 12 ) & 0x7 ) == 0 ) { if (((inst >> 7 ) & 0x1f ) != 0 ) { R[(inst >> 7 ) & 0x1f ] = R[(inst >> 15 ) & 0x1f ] + (((inst >> 20 ) & 0x7ff ) - ((inst & 0x80000000 ) ? 4096 : 0 )); } } else if (inst == 0x00100073 ) { if (R[10 ] == 0 ) { putchar (R[11 ] & 0xff ); } else if (R[10 ] == 1 ) { halt = true ; } else { printf ("Unsupported ebreak command\n" ); } } else { printf ("Unsupported instuction\n" ); } PC += 4 ; }int main () { PC = 0 ; R[0 ] = 0 ; while (!halt) { inst_cycle(); } return 0 ; }
可以看到:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 $ rvobjdump -M no-aliases -d prog prog: file format elf32-littleriscv Disassembly of section .text: 00000000 <_start>: 0: 00000513 addi a0,zero,0 4: 04100593 addi a1,zero,65 8: 00100073 ebreak c: 00100513 addi a0,zero,1 10: 00000593 addi a1,zero,0 14: 00100073 ebreak 18: 0000006f jal zero,18 <_start+0x18>
1 2 3 4 5 uint8_t M[64 ] = { 0x13 , 0x05 , 0x00 , 0x00 , 0x93 , 0x05 , 0x10 , 0x04 , 0x73 , 0x00 , 0x10 , 0x00 , 0x13 , 0x05 , 0x10 , 0x00 , 0x93 , 0x05 , 0x00 , 0x00 , 0x73 , 0x00 , 0x10 , 0x00 , 0x6f , 0x00 , 0x00 , 0x00 , };
之所以 uint32_t 取的字符和原来的指令逆序,是因为计算机遵从小端序列:低地址内存中的数据,放在数据(uint32_t、数组等)的低位。对于内存中的指令:0x00000513,0x13位于高地址,放在数组高位,这里需要注意。
执行 1 2 3 4 5 $ gcc yemu.c -o yemu $ ./yemu prog A%
可以看见,工作很正常。
改进程序,从文件读入程序 这样才是一个正常的工作流程,谁都不可能有把一个二进制文件一个一个写入数组的精力:
1 2 3 4 5 6 7 8 9 10 11 12 ...int main (int argc, char * argv[]) { PC = 0 ; R[0 ] = 0 ; FILE* fp = fopen(argv[1 ], "r" ); fread(M, 1 , 1024 , fp); fclose(fp); while (!halt) { inst_cycle(); } return 0 ; }
在运行 prog 之前,必须将里面的指令序列抽取到 prog.bin,毕竟:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 $ riscv64-linux-gnu-objcopy -j .text -O binary prog prog.bin $ ll total 56K -rw-rw-r-- 1 luyoung luyoung 224 Aug 9 10:31 a.c -rwxrwxr-x 1 luyoung luyoung 9.4K Aug 9 10:31 a.out -rw-rw-r-- 1 luyoung luyoung 351 Aug 9 11:48 b.c -rwxrwxr-x 1 luyoung luyoung 5.1K Aug 9 11:48 prog -rwxrwxr-x 1 luyoung luyoung 28 Aug 9 11:58 prog.bin -rwxrwxr-x 1 luyoung luyoung 17K Aug 9 11:57 yemu -rw-rw-r-- 1 luyoung luyoung 1.2K Aug 9 11:57 yemu.c $ ./yemu prog.bin A%
可以看到,prog 和 prog.bin 的大小差距很大。
运行更复杂的程序 我们用 yemu 执行的程序只能输出一个字符,我们可以尝试输出得更多一点:
1 2 3 4 5 6 void _start() { putch('H' ); putch('e' ); putch('l' ); putch('l' ); putch('o' ); putch(',' ); putch(' ' ); putch('R' ); putch('I' ); putch('S' ); putch('C' ); putch('-' ); putch('V' ); putch('!' ); putch('\n' ); halt(0 ); }
重新编写 prog,编译,抽取指令序列,运行 yemu:
1 2 3 4 5 6 7 8 9 luyoung at luyoungUbt in ~/ysyx_details/预学习阶段/slides/程序的执行和模拟器 (main●●) $ rv32gcc -ffreestanding -nostdlib -static -Wl,-Ttext=0 -O2 -o prog b.c luyoung at luyoungUbt in ~/ysyx_details/预学习阶段/slides/程序的执行和模拟器 (main●●) $ riscv64-linux-gnu-objcopy -j .text -O binary prog prog.bin luyoung at luyoungUbt in ~/ysyx_details/预学习阶段/slides/程序的执行和模拟器 (main●●) $ ./yemu prog.bin Hello, RISC-V!
至于其它编程技巧,用宏增强代码复用,就不累赘了。