flag
mode_edit

奋战一星期,造个计算机;调试两三晚,玩转开发板;虐我千百遍,搞定流水线。

做了什么?

使用 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,我们在硬件中可以给每一个设备都指定一组内存地址。对该地址的访存操作都会被视为对设备的操作。

例如,我将 0xfbadbeef0xfbadbeee 分别设置为键盘 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 模拟器上调试时,由于键盘相关函数运行很快,也没有遇到这样的极端情况。

就像这样,程序的每一部分都是正确的,在模拟器上也工作正确,但是最终的程序工作却不正常,甚至只会概率性出现错误。这种类型的错误非常难以发现和调试。

这也警示我们在系统软件中,除了要对每一部分做完备的测试之外,还需要考虑系统整合过程中引入的所有可能的错误。

navigate_next