奋战一星期,造个计算机;调试两三晚,玩转开发板;虐我千百遍,搞定流水线。
做了什么?
使用 verilog 写了一个很垃圾的单周期 RV32IM CPU,没有中断。支持了键盘,UART, VGA 等外部设备。并在此之上运行我们自己编写的俄罗斯方块游戏,支持基于串口通信的联机对战。
仓库链接:
硬件部分
由于 RV 的简洁性,CPU 部分的代码量甚至只有 500 行不到。以下分析一些比较有意思的部分。
指令解码
没什么好解的,RV 的设计太简洁了,把各个字段拉出来就行了。
assign funct3[2:0] = IR[14:12];
assign funct7[6:0] = IR[31:25];
assign rs1[4:0] = IR[19:15];
assign rs2[4:0] = IR[24:20];
assign rd[4:0] = IR[11:7];
assign i_imm[31:0] = { {21{IR[31]}}, IR[30:25], IR[24:21], IR[20] };
assign s_imm[31:0] = { {21{IR[31]}}, IR[30:25], IR[11:8], IR[7] };
assign b_imm[31:0] = { {20{IR[31]}}, IR[7], IR[30:25], IR[11:8], 1'b0 };
assign u_imm[31:0] = { IR[31], IR[30:20], IR[19:12], 12'b0 };
assign j_imm[31:0] = { {12{IR[31]}}, IR[19:12], IR[20], IR[30:25], IR[24:21], 1'b0 };
wire [4:0] opcode;
assign opcode[4:0] = IR[6:2];
assign load = opcode[4:0] == 5'b00000 ? 1'b1 : 1'b0; // lh lu lw ...
assign store = opcode[4:0] == 5'b01000 ? 1'b1 : 1'b0; // sw sh ...
assign branch = opcode[4:0] == 5'b11000 ? 1'b1 : 1'b0; // bge ...
assign jalr = opcode[4:0] == 5'b11001 ? 1'b1 : 1'b0;
assign jal = opcode[4:0] == 5'b11011 ? 1'b1 : 1'b0;
assign lui = opcode[4:0] == 5'b01101 ? 1'b1 : 1'b0;
assign auipc = opcode[4:0] == 5'b00101 ? 1'b1 : 1'b0;
assign op_imm = opcode[4:0] == 5'b00100 ? 1'b1 : 1'b0; // addi xori ...
assign op = opcode[4:0] == 5'b01100 ? 1'b1 : 1'b0; // add xor ...
assign system = opcode[4:0] == 5'b11100 ? 1'b1 : 1'b0; // ecall csrxxx ebreak ...
也不要搞什么控制器,直接用 opcode 就可以了。对于具体的模块(例如 ALU,分支判定),可以将 funct3, funct7 一并传入。例如,不同的分支指令只有 funct3 有区别,可以据此在分支模块内部进行处理。例如:
always @(*) begin
if (branch == 1) begin
case (funct3[2:0])
3'b000: begin
taken = eq;
end
3'b001: begin
taken = !eq;
end
3'b100: begin
taken = less;
end
// ...
外设
只让代码在芯片里面跑是不好玩的,我们需要各种外设来完成各种炫酷的操作。由于我没有实现中断,因此使用了硬件 FIFO + 轮询的方式来处理外设,以下选取典型的输入设备(PS2 键盘)和典型的输出设备(VGA 显示器)来讲解。
键盘驱动
PS2 键盘有一个自己的时钟和一个自己的信号线。每当时钟下降沿的时候,都会在信号线上传输一个 bit. 这个时钟通常情况下只有 10kHz 左右。而我们的 CPU 工作在 10MHz 甚至更高。应怎么让他们进行信息交换呢?答案是 FIFO. 因为 FIFO 只需要一个数组和两个指针。由于读者只需要动读指针,写者只需要动写指针。因此可以让不同时钟域的组件相互通信。这样一来,键盘会不断地移动写指针,往 FIFO 中写入数据,而读者会不断地移动读指针,同时读取数据。
由于我的访存部件全部使用的是异步读的策略,因此我希望进行 MMIO 的时候,也能使用异步读。因此我们可以将 FIFO 的队头直接接出来,并给出一个 ready 信号,表示 FIFO 是否为空。而当用户将 read_enable 拉高一个周期时,读指针就会向后移动。
always @(posedge clk) begin
if (rst == 1) begin
r_ptr <= 0;
end else if (read_enable && ready) begin
r_ptr <= r_ptr + 1;
end
end
assign ready = (w_ptr != r_ptr);
assign data = fifo[r_ptr];
显存双缓冲
由于板上的资源有限,我希望能使用板上的 Block RAM 来实现显存,因而只能使用同步读。显存还有一点比较特殊,显存是一个双端口的 RAM: CPU 会在一个端口写,而 VGA 驱动会在另一个端口进行读操作,而且两个端口的时钟速率还是不同的。不过好在 vivado 可以自动将同步读的双端口 RAM 给推理成由 Block RAM 实现的内存。
同步读是比较麻烦的,不过好在显存只会被 VGA 驱动读取。为了适应同步读,我们需要对 VGA 驱动进行一些修改:让 VGA 驱动模块给出它希望访问的下一个显存地址,这样就可以适应同步读的需要了。
为了节省空间,我们选择将屏幕上的 8*8 的一块区域作为基本像素,对应显存中的一个字节。进行输出时,解码模块会将这一个字节解码成对应的颜色和纹理,输出给 VGA 模块。
在此之上运行程序, 发现程序运行的时候,屏幕会发生严重的闪烁。怎么会是呢?
这是因为我们在画界面时,会先将屏幕上的内容清空,然后再画新的内容,这个“中间过程”被显示模块给捕捉到了,因而产生了闪烁的情况。
该怎么办呢?其实也很简单,我们可以做两份显存,一份用于展示,一份用于临时写入;写入完成之后,再交换两份显存即可。这就是双缓冲。
测试
良好的软件需要有一定的测试,这样才能保证程序的正确性。那硬件大概更需要测了,不然写出来的都是什么狗屎。
我在编写硬件代码的过程中,对每一个关键的模块都编写了 test bench,通过 iverilog 来进行行为仿真,确保每一个模块的正确性。
软件部分
如何在自制 CPU 上运行自己的程序?
在裸机上运行的程序和操作系统上的程序不一样——当我们面对裸机的时候,几乎每一行代码都是自己编写的,包括库函数,甚至连帮我们加载 main 函数的代码都需要自己写。因此,怎样让 C 程序运行起来,是难题之一。
- 我们直接使用 gcc 编译出来的程序只能在 x86 机器上运行,怎么编译 RISC-V 程序?
- 二进制文件假设代码、数据存在于地址空间的指定位置。那么是谁来完成这件事?
- main 在二进制文件中的地址是不固定的。是谁调用的 main()?
- 我们的 CPU 认识 ELF 吗?怎么把 ELF 加载到我们的 CPU 里面运行?
- 我们的 CPU 区分了 ROM 和 RAM,应该怎么处理 ROM 和 RAM 的地址空间的冲突?
对于交叉编译到 RISC-V,可以直接使用 clang 进行代码的编译,clang --target=riscv32-unknown-unknown -march=rv32im -ffreestanding -fno-builtin -nostdlib -mno-relax -fno-PIE -G=0
.
对编译选项的解释:
--target=riscv32-unknown-unknown -march=rv32im
指示编译器生成 rv32im 的代码-ffreestanding -fno-builtin -nostdlib
指示不要内建函数,也不要标准库-mno-relax
不是很懂,不开不给过编译-G=0
指示不要生成 rv 特色的 small data 段,影响后续的加载
通过这样一顿操作,我们可以得到一个 ELF 文件。我们可以把它的 text 段和 data 段 dump 出来,加载到 ROM 和 RAM 中。
llvm-objcopy ./tetris_rv --dump-section .text=rom.bin --dump-section .data=ram.bin
od -w4 -An --endian little -v -t x4 ./rom.bin > rom.mem
od -w4 -An --endian little -v -t x4 ./ram.bin > ram.mem
启动行为仿真!发现程序竟然直接跑飞了?怎么会是呢?
首先我们回忆一下,在我们自己的电脑上是如何执行一个程序的。
执行 exec
时,操作系统会解析 ELF,把 ELF 中的各个段加载到指定的位置。例如 text
, data
, rodata
, bss
等等。随后,找到入口地址,并设置一个合理的栈指针,然后将控制权转移给用户程序。这样一来,用户程序就能愉快的运行了!
这样看来,我们刚才的操作存在很多问题:
- 只 dump 了
.data
段。.bss
,.rodata
怎么办? - 没有设置 sp,如果某个函数中执行
addi sp, sp, -16
,就会直接把 sp 变成了负数,,, - 没有设置入口地址,所以程序就直接跑飞了。
- ROM 和 RAM 的地址空间不会冲突吗?
Linker Script
我们可以使用 Linker Script,将 .rodata
, .data
, .bss
都放到 RAM 的地址空间中,这样以来,我们只需要加载一个 data 段就可以了。RAM 中剩下的空间留给栈。
.text :
{
. = ALIGN(4);
*(.text.start);
*(.text .text.*);
} > rom
.data :
{
. = ALIGN(4);
*(.data .data*);
*(.rodata .rodata*);
*(.bss .bss.*)
} > ram
.stack (NOLOAD):
{
. = ALIGN(4);
. = . + STACK_SIZE;
. = ALIGN(4);
__stacktop = .;
} > ram
_start
函数
为了设置 sp,我们可以考虑设置一个初始化函数,该函数会设置 sp ,然后跳转到主函数。在上一部分中,我们在 linker script 中定义了一个 __stacktop
变量,用于指示栈的最高处。我们可以写一点内联汇编,将 sp 设置为这个值。由于我的 CPU 默认从 ROM 地址为为 0x0
的位置开始取指令,我们需要把这个初始化函数放到地址为 0x0
的位置。
这里依旧需要一些 linker script 杂技。我们可以将这个函数放到一个特殊的段中,然后在 linker script 中指定这个段的地址。
extern unsigned __stacktop;
// initial stack pointer is first address of program
__attribute__((section(".stack"), used)) unsigned* __stack_init = &__stacktop;
extern int main();
__attribute__((section(".text.start"))) __attribute__((naked)) void _start() {
asm("mv sp, %0\n\t" ::"r"(&__stacktop));
asm("j %0\n\t" ::"i"(&main));
}
经过一顿操作,我们终于得到了一个 ELF 文件,可以查看一下它的状态。
❯ llvm-readelf -S build/tetris_rv
There are 16 section headers, starting at offset 0x19370:
Section Headers:
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 00000000 001000 002fdc 00 AX 0 0 4
[ 2] .data PROGBITS 20000000 004000 000455 00 WAM 0 0 4
[ 3] .stack NOBITS 20000458 004455 001504 00 WA 0 0 4
可以查看一下 0x0
位置的函数是什么:
tetris_rv: file format elf32-littleriscv
Disassembly of section .text:
00000000 <_start>:
; asm("mv sp, %0\n\t" ::"r"(&__stacktop));
0: 37 25 00 20 lui a0, 131074
4: 13 05 c5 91 addi a0, a0, -1764
8: 13 01 05 00 mv sp, a0
; asm("j %0\n\t" ::"i"(&main));
c: 6f 00 c0 66 j 0x678 <.Lline_table_start0+0x678>
还真是我们的代码,没有任何问题。
ROM 和 RAM 的地址空间
为了实现方便,我们的 CPU 使用了哈佛架构,将 ROM 和 RAM 分别放在两个不同的存储器中。但是这样会存在一个问题,ROM 和 RAM 的地址空间是存在冲突的,该怎么办呢?我们可以使用 linker script,把 ROM 和 RAM 的起始地址错开。
ROM_SIZE = 0x4000;
RAM_SIZE = 0x2000;
STACK_SIZE = 0x1500;
MEMORY {
rom (rx) : ORIGIN = 0x00000000, LENGTH = ROM_SIZE
ram (rwx) : ORIGIN = 0x20000000, LENGTH = RAM_SIZE
}
至此,我们的程序就可以在我们自己的 CPU 上运行了。
生成汇编
为了方便调试(实际上一直都没有用上),我们可以在脚本中顺手生成一下汇编程序,以供调试使用。
llvm-objdump -S tetris_rv > tetris.asm
开启 LTO
板子上的 ROM 资源寸土寸金,只有 16k 的大小,应该尝试尽量减少 ROM 的使用。LTO 优化是一种链接时优化的技术,可以跨翻译单元进行优化操作,进行内联,消除未引用的代码,可以减小代码的体积。为了开启 LTO,我们可以在编译选项中添加 -flto
参数。
但是 LTO 操作会对 text 段进行奇妙的更改,每一个函数都会被放到一个独立的段中,因此这需要我们把这些额外的段都合并起来。为此,我们可以在 linker script 中加上一行,把所有 .text.*
的段都合并到 text 段中。
开启 LTO 前:
❯ ll build/*.bin
.rw-r--r-- 1.1k mgt 24 3 月 21:21 build/ram.bin
.rw-r--r-- 16k mgt 24 3 月 21:21 build/rom.bin
开启 LTO 后:
❯ ll build/*.bin
.rw-r--r-- 1.1k mgt 23 3 月 16:58 build/ram.bin
.rw-r--r-- 12k mgt 23 3 月 16:58 build/rom.bin
可以发现 LTO 显著减小了体积。
如何使用硬件提供的外设?
在前面提到,我们的硬件提供了各种外设的接口:
- UART 串口
- VGA 显示器
- PS2 键盘
- 7 段数码管
那么我们应该如何在软件中使用这些外设呢?答案是 MMIO,我们在硬件中可以给每一个设备都指定一组内存地址。对该地址的访存操作都会被视为对设备的操作。
例如,我将 0xfbadbeef
和 0xfbadbeee
分别设置为键盘 ready 和 键盘数据的地址。
#define KBD_READY_ADDR ((volatile uint8_t*)0xfbadbeee)
#define KBD_DATA_ADDR ((volatile uint8_t*)0xfbadbeef)
uint8_t keyboard_ready() { return *KBD_READY_ADDR; }
uint8_t keyboard_data() { return *KBD_DATA_ADDR; }
这样就可以通过 C 语言的函数来获取键盘的数据了。
而在硬件的实现中,每当用户程序尝试 load ready 地址,我会直接将键盘缓冲区的状态情况作为结果返回。而用户尝试 load 数据地址时,我会将键盘缓冲区的读指针返回,同时向后移动键盘缓冲区的读指针。
如何进行软件的开发?
直接写代码不就行了吗?为什么还会有这种问题?
这是因为每次希望在板子上运行代码时,都需要重新综合,生成比特流,并刷入板子。这个过程非常缓慢,甚至可以长达 20 分钟。如果每次修改软件代码都需要进行这样的一套操作,效率会非常低下。同时,板子上没有任何的调试设备,出问题只能靠猜,因此这样做是完全不现实的。
我们基于 SDL,编写了在 x86 上运行的显存和键盘模拟器,能在 x86 设备上模拟显存和键盘的行为,这样我们就可以在电脑上编写,测试,调试,快速验证自己的想法。得益于模拟器,我们得以让软件和硬件同步开发,而且我们在上板的过程中,软件部分几乎没有遇到任何特别的问题。
同时,为了保证代码质量,我们还打开了编译器的 address sanitizer 和 undefined behavior sanitizer。这样我们就可以提前发现野指针访问,未初始化内存,访存越界,整数溢出等问题,从而避免在板子上遇到玄学情况。
定点数
我们只实现了 RV32im,并不支持浮点数的运算,但是我们很多时候又不可避免的希望用一下小数。难道没有办法了吗?
其实是有的,我们可以选择使用定点数,将 32 位整数的高 16 位作为整数部分,低 16 位作为小数部分,这样就能进行各种运算了。加减法操作可以直接进行,乘除法操作只需要进行一下移位即可。
#define cfixed_mul(x, y) ((cfixed)((((int64_t)(x)) * (y)) >> 16))
#define cfixed_div(x, y) ((cfixed)((((int64_t)(x)) << 16) / (y)))
typedef int32_t cfixed;
在计算玩家的平均每分钟的攻击行数时,我们便使用了定点数。
static inline uint32_t get_apm(struct GameStatistics* t, int32_t now) {
cfixed minutes =
cfixed_div(cfixed_from_int((now - t->game_start_time) / (1000 * 100)),
cfixed_from_int(60 * 10));
if (minutes == 0) {
return 0;
}
cfixed apm = cfixed_div(cfixed_from_int(t->total_attack), minutes);
return cfixed_to_int(apm);
}
印象深刻的 Bug
PS2 Scan Code 解析的原子性
每当我们在键盘上按下或者松开一个键,PS2 键盘都会发送一个 scan code 给我们。这个 scan code 短则一个字节,多则四五个字节。这就需要我们搓一个状态机,来对我们收到的 scan code 进行解析。我采用了以下操作:
- 在每一帧开始的时候,把键盘缓冲区读空,解析上一帧中的所有键盘操作,同时忽略不认识的 scan code
while (kbd_ready()) {
uint8_t code = kbd_data();
// ... parse scan code
}
在把玩了几个 demo 后,这看起来工作了!于是我们遗忘了这部分代码,开始在这之上构建我们的应用程序。然而,当我们在板子上运行我们写的方块的时候,却遇到了奇怪的问题:偶尔会出现吃键、双击、按键无法正常弹起等情况。由于缺乏调试工具,我甚至一度怀疑是键盘本身的问题,,直到后来我完成了串口的实现,我才有机会继续来研究这个问题。
我将每一帧的键盘 scan code 都通过串口发送到电脑上,并使用特殊的字节分割不同帧,每当出现奇怪的操作的时候,我都会去 check 一下收到的 log。在这里,我发现了奇怪的现象:某些多字节的键码会被分割开:第一帧收到一个字节,第二帧处理剩下的字节。
例如,按下空格键的键码是 0x29
,而空格键弹起的键码是 0xf0 0x29
。如果现在发生了空格键弹起的事件,我们在第一帧收到了 0xf0
,由于这不是完整的键码,它会被忽略掉。而第二帧收到了 0x29
————这不是空格吗?于是双击就产生了。解决方案也很简单,只需要持久一下状态机的状态即可,而不是每次都从初态开始。在修了这个问题后,所有的键盘毛病都无缝自愈了,,
回顾这个问题,我们可以发现:软件的对于 PS2 scan code 的解析状态机没有任何问题,硬件的 FIFO 队列实现也没有任何问题。在我们的 x86 模拟器上调试时,由于键盘相关函数运行很快,也没有遇到这样的极端情况。
就像这样,程序的每一部分都是正确的,在模拟器上也工作正确,但是最终的程序工作却不正常,甚至只会概率性出现错误。这种类型的错误非常难以发现和调试。
这也警示我们在系统软件中,除了要对每一部分做完备的测试之外,还需要考虑系统整合过程中引入的所有可能的错误。