Picorv32 是一个只用 3000 行 Verilog 代码实现的 RISC-V CPU。
之前的文章介绍了 RISC-V 指令集设计 (CPU),LiteX 定制 SoC,这篇文章会介绍 CPU 启动流程 (裸机程序),最后一步则是移植操作系统 RT-Thread Nano。
这样分成4步,我们就在 FPGA 上定制了一个 SoC (RISC-V 软核),并移植了实时系统 (RTOS)。
所以这篇文章在前两篇的基础上,假定大家已经熟悉了 RISC-V 指令集,并且在 IceSugar 开发板 (或者其他支持 LiteX 的 FPGA) 上运行了 Picorv32 SoC,接下来要做的是给这个 FPGA 上的 SoC 编写固件程序。
顺便一提,第二篇文章还介绍了如何在 LiteX 定制的 SoC 上使用 C 和 Rust,不过当时 CPU 用的 VexRiscv,这篇文章着重于 Picorv32 的 CPU 启动流程。不过它们都是 RISC-V CPU,只是 Picorv32 为了硬件设计简单,没有遵循 RISC-V 的中断标准,而是自定义了 R-type 的中断指令。
先放上这篇文章的最终效果,就是在 LiteX 定制的 SoC 上运行一个裸机程序 (没有操作系统),利用定时器每隔1秒打印 Hello World,并且响应来自串口的中断(当然计数也用到了定时器中断),下一篇则是移植 RT-Thread Nano 实时系统 (项目源码在 GitHub 上)。
**我们首先要知道 CPU 上电后从哪里加载程序。**我用的 IceSugar 开发板有一个 SPI Flash,所有的程序都保存在这个 SPI Flash 里。
最初开发板上电后,FPGA 首先从 SPI Flash 起始地址 (0x00000000) 读取 FPGA 的固件,这样 FPGA 就被设置成了软核 RISC-V CPU;接下来软核 CPU 开始读取 BIOS 程序,也就是打印第一张图里面大大的 LiteX Logo 和 SoC 的信息。
因此,我们可以在 LiteX SoC 的配置程序 (muselab_icesugar.py) 里面看见,我们首先把 FPGA 的固件烧录到了 0x00000 的位置,接下来把 BIOS 烧录到了 0x40000 的位置。
# FPGA 固件偏移地址 0x00000
prog.flash(0x00000000, "build/muselab_icesugar/gateware/muselab_icesugar.bin")
# BIOS 偏移地址 0x40000,可以自定义
parser.add_target_argument("--bios-flash-offset", default="0x40000", help="BIOS offset in SPI Flash.")
prog.flash(bios_flash_offset, "build/muselab_icesugar/software/bios/bios.bin")
这里的 BIOS 是 LiteX 提供的一个默认裸机程序,为了让我们测试 FPGA 顺利被设置成了 LiteX SoC。当然, 我们也可以覆盖这个默认的 BIOS,换成我们自己的程序(这也是后面我们要做的),所以每次烧录程序我们都覆盖了 SPI Flash 地址 0x40000 的程序。
# 烧录到 0x40000 覆盖 BIOD
icesprog -w demo.bin -o 0x40000
顺便一提,视力异常好的朋友可能注意到开发板上,在 FPGA 芯片左边,居然有个 STM32,这个 STM32 是 ICELink 帮助烧录程序到 SPI Flash,所以我们才可以在电脑上调用 iceprog 下载程序。
于是我们清楚了,Picorv32 开机后会从 SPI Flash 的 0x40000 位置执行程序。在 LiteX SoC 的设计里,我们也告诉了 SoC 在偏移地址 0x40000 的位置,有一个 32KB 的 ROM,开机后请从这里执行程序。
# Add ROM linker region --------------------------------------------------------------------
# BIOS 偏移地址 0x40000,可以自定义
self.bus.add_region("rom", SoCRegion(
origin = self.bus.regions["spiflash"].origin + bios_flash_offset,
size = 32*kB,
linker = True)
)
self.cpu.set_reset_address(self.bus.regions["rom"].origin)
**不过前面提到的都是物理地址,**我们在写固件程序的时候,还需要给 GCC 编译器提供链接脚本 linker.ld,告诉编译器应该把代码 (code) 和 数据 (data),分别放在哪里。于是我们需要在 linker.ld 文件里指定 ROM (固件) 和 SRAM (内存) 的位置。
MEMORY {
sram : ORIGIN = 0x00000000, LENGTH = 0x00010000
spiflash : ORIGIN = 0x00800000, LENGTH = 0x00800000
rom : ORIGIN = 0x00840000, LENGTH = 0x00008000
csr : ORIGIN = 0x82000000, LENGTH = 0x00010000
}
**奇怪的是,上面 ROM 的地址是 0x840000,我们前面不是说固件放在了 SPI Flash 地址 0x40000 的位置吗?**这是因为 CPU 是通过总线 (BUS) 和外部设备,例如 SPI Flash 通信的。在 CPU 的内部总线上,SPI Flash 被分配到的地址是 0x800000,所以 CPU 总线位置 0x800000 + SPI Flash 物理偏移地址 0x40000 就得到了 CPU 视角下固件代码的位置 0x840000。
LiteX 的好处在于,前面我提到的链接脚本 linker.ld 是自动生成的,我们并不需要手动去计算,在生成 SoC 的时候,这些地址 LiteX 都会帮我们计算好,所以这里我只是解释一下 LiteX 是怎么计算程序加载地址的。
现在我们知道了,只要把固件烧录到 SPI Flash 地址 0x40000 的位置,CPU 开机就会从那里加载程序。我们还要告诉编译器,把我们的入口函数放在那个位置,这样开机就会加载入口函数 _start。
# 程序入口位置
ENTRY(_start)
SECTIONS
{
.text :
{
_ftext = .;
/* Make sure start.S files come first, and then the isr */
/* don't get disposed of by greedy optimisation */
*start*(.text)
KEEP(*start*(.text))
KEEP(*(.text.irq))
*(.text .stub .text.* .gnu.linkonce.t.*)
_etext = .;
} > rom
}
这样我们就可以开始写固件程序了,我们首先需要用汇编初始化 CPU 状态,才能跳转到 C 程序。在 start.S 汇编代码里定义了 _start 入口函数,接下来一行跳转指令到 _crt0 函数,在那里完成 CPU 的初始化。
// 包含 picorv32 自定义的 R-type 指令
#include "custom_ops.S"
.global _start
_start:
.org 0x00000000 # Reset
j _crt0
.org 0x00000010 # IRQ
_irq_vector:
中断处理函数
_crt0:
CPU 初始化
有人可能会问,为什么要跳转到 _crt0 呢?直接在这里初始化 CPU 不就可以了吗?这是因为 picorv32 规定了在代码的 0x10 位置 (16 字节),放置中断处理函数,每次 CPU 发生中断 (定时器,串口) 就会自动执行 0x10 位置的代码。
0 字节:
上电后,自动执行这里的代码
这里代码太长,就会覆盖下面的中断处理
16字节:
中断处理
放在这里的代码,每次 CPU 中断会自动执行;
主程序:
CPU 初始化,写多长都可以,不用担心覆盖中断函数
如果我们开始的时候不跳转,那就最多只能写 16 字节的代码,再写就覆盖中断函数了,所以我们把实际的初始化代码跳转到中断函数后面,就不用担心覆盖中断函数了。
顺便一提,_crt0 代表 C Runtime,意思是这段代码执行完就跳转到 C 环境
那么 CPU 初始化要做些什么呢?我们首先把寄存器用 0 初始化,RISC-V 一共有 32 个通用寄存器,所以我们首先把它们都复位成 0;接下来我们要先禁用全部中断,因为 CPU 还没初始化,我们不希望代码跳转到其他位置;然后就是设置栈空间,这样 CPU 就知道数据要保存在哪里;最后一步就是跳转到 main 函数了,从此我们就可以用 C 写 main() 了。
_crt0:
/* 初始化 32 个通用寄存器 */
addi x1, zero, 0
addi x2, zero, 0
... 省略 ...
addi x30, zero, 0
addi x31, zero, 0
/* 暂时关闭全部中断 */
li t0, 0xffffffff
picorv32_maskirq_insn(zero, t0)
/* reflect that in _irq_mask */
la t1, _irq_mask
sw t0, 0(t1)
... 省略 ...
/* set main stack */
la sp, _fstack
/* 设置中断函数 */
la t0, _irq
picorv32_setq_insn(q2, t0)
/* jump to main */
jal ra, main
1:
/* loop forever */
j 1b
在跳转 main 函数之前,我们还设置了中断函数的位置 _irq,这一步其实并不是必要的。这个主要是因为我想把中断处理函数放在一个单独的 interrupt_gcc.S 文件里,不然 start.S 文件就太长了,我只希望把 CPU 初始化相关的代码放在 start.S 里面,看起来比较整洁,后面我会详细介绍中断。
这里还有一个好消息,其实 CPU 初始化的 start.S 代码 LiteX 也自动生成了,不同 CPU 的汇编初始化代码可以在 LiteX 仓库的 cpu 目录下找到,毕竟 CPU 初始化代码一般也都是由 CPU 的设计者提供,自己根据需要略微修改就可以了。
利用汇编初始化 CPU 后,我们就可以用 C 编写 main 函数了,感觉一下就轻松了不少。
但是在实际写 C 代码前,我们还需要提供一个最小的 C 环境,也就是 libc。这个可以由编译器提供,但是 riscv64-gcc 编译器默认只提供 64 位的 libc,虽然 64 位的 riscv-gcc 支持编译生成 32 位的 riscv 代码,但是并不提供 32 位的 libc,直接编译就会报错不支持。所以 LiteX 甚至非常贴心地为我们准备了 libc,在生成 SoC 后可以在 build 目录下找到需要的 libc。
不光 libc,LiteX 还无微不至地提供了驱动函数,例如定时器,i2c, SPI 等等,所以我们在使用了 LiteX 提供的这些 C 函数后,甚至可以直接调用 printf()。
下面的 main.c 看起来非常清晰,先包含 LiteX 提供的 libc 和 驱动函数,接下来打开中断,初始化串口,就可以反复打印 Hello World 了。
#include <stdio.h>
#include <irq.h>
#include <libbase/uart.h>
int main(void)
{
// 打开中断
irq_setmask(0);
irq_setie(1);
// 串口初始化
uart_init();
... 省略定时器中断 ...
// 打印 Hello World
printf("Hello World \n");
while(1)
{
if(timeup) {
printf("Hello World %d\n", count);
timeup = 0;
}
};
return 0;
}
相信上面的 main.c 不需要过多的解释,不过我省略了定时器中断相关的代码,因为这是唯一需要特别小心的地方,我在下一个部分单独介绍。
如果我们不使用定时器和中断,上面的代码就能反复打印 Hello World 了,但是嵌入式的乐趣也就在各种硬件资源 (timer, i2c, spi, irq),所以我在这个部分单独介绍 picorv32 的定时器中断。
前面我们提到,在程序的 0x10 (16 字节) 处放置的是 picorv32 默认的中断处理函数,但是我不希望 start.S 函数太长,所以在这个中断处理函数里,没有实际的中断处理,只是让它跳转到 interrupt_gcc.S 里定义的中断函数。
// 0x10 位置保存中断函数
.org 0x00000010 # IRQ
_irq_vector:
// 保存栈和寄存器
addi sp, sp, -16
sw t0, 4(sp)
sw ra, 8(sp)
// 跳转到其他文件里的中断函数
picorv32_getq_insn(t0, q2)
sw t0, 12(sp)
jalr t0 // Call the true IRQ vector.
// 恢复栈和寄存器
lw t0, 12(sp)
picorv32_setq_insn(q2, t0) // Restore the true IRQ vector.
lw ra, 8(sp)
lw t0, 4(sp)
addi sp, sp, 16
// 中断返回
picorv32_retirq_insn() // return from interrupt
因此,也可以在 start.S 的初始化代码里看到这样的内容,这样就可以在 interrupt_gcc.S 里面定义 _irq 函数了:
// 设置实际的中断函数名为 _irq
la t0, _irq
picorv32_setq_insn(q2, t0)
另一方面,我们也不希望纯用汇编来写中断函数,那就太痛苦了,所以在 interrupt_gcc.S 里我们的目的是再一次跳转到 C 函数的中断处理。为了跳转到 C 中断处理,由于 picorv32 中断不会自动保存现场(寄存器状态),所以我们需要在汇编里手动保存寄存器和 stack 的状态,调用 C 的中断函数,在 C 函数返回后再手动恢复。
这也就是之前的文章里提到的,picorv32 硬件设计很简单,但是软件就要做很多处理了。
#include "custom_ops.S"
// 中断处理函数
.global _irq
_irq:
// 保存寄存器
picorv32_setq_insn(q2, x1)
picorv32_setq_insn(q3, x2)
/* use x1 to index into irq_regs */
lui x1, %hi(irq_regs)
addi x1, x1, %lo(irq_regs)
/* use x2 as scratch space for saving registers */
/* q0 (== x1), q2(== x2), q3 */
picorv32_getq_insn(x2, q0)
sw x2, 0*4(x1)
picorv32_getq_insn(x2, q2)
sw x2, 1*4(x1)
picorv32_getq_insn(x2, q3)
sw x2, 2*4(x1)
/* save x3 - x31 */
sw x3, 3*4(x1)
sw x4, 4*4(x1)
...
sw x30, 30*4(x1)
sw x31, 31*4(x1)
// 更新需要处理的中断在 _irq_pending
picorv32_getq_insn(t0, q1)
la t1, (_irq_pending)
sw t0, 0(t1)
/* prepare C handler stack */
lui sp, %hi(_irq_stack)
addi sp, sp, %lo(_irq_stack)
// 调用 C 中断函数
jal ra, irq
// 恢复寄存器
lui x1, %hi(irq_regs)
addi x1, x1, %lo(irq_regs)
/* restore q0 - q2 */
lw x2, 0*4(x1)
picorv32_setq_insn(q0, x2)
lw x2, 1*4(x1)
picorv32_setq_insn(q1, x2)
lw x2, 2*4(x1)
picorv32_setq_insn(q2, x2)
/* restore x3 - x31 */
lw x3, 3*4(x1)
lw x4, 4*4(x1)
...
lw x30, 30*4(x1)
lw x31, 31*4(x1)
/* restore x1 - x2 from q registers */
picorv32_getq_insn(x1, q1)
picorv32_getq_insn(x2, q2)
// 中断返回
ret
我们首先在 main.c 主函数里初始化定时器和中断,至于定时器相关寄存器的配置,我们需要阅读 LiteX 生成的文档,在编译 SoC 的时候会在 build/doc 目录生成文档,例如这个页面就定义了各个中断位:
在 Timer0 的文档页面,我们可以看到应当如何设置定时器0相关的寄存器,因为这部分寄存器配置并没有遵循 RISC-V 的标准,但也比较简单,文档描述也非常清晰,所以就不详细介绍了。
这样我们就可以 照着文档配置定时器,我生成的 SoC 时钟是 CONFIG_CLOCK_FREQUENCY= 24_000_000 = 24MHz,所以把定时器的数值设置为 CONFIG_CLOCK_FREQUENCY 就是定时 1 秒,超时后会进入中断函数,并自动重装定时器的值。
// 打开全局中断
irq_setmask(0);
irq_setie(1);
// 打开定时器0中断
timer0_ev_enable_write(1);
irq_setmask(irq_getmask() | (1 << TIMER0_INTERRUPT));
// 设置定时器0计时1秒
timer0_en_write(0);
timer0_load_write(0);
timer0_reload_write(CONFIG_CLOCK_FREQUENCY);
timer0_en_write(1);
初始化完了定时器,我们终于可以用 C 写实际的中断函数了,用 C 写中断函数就非常简单,只是清除了对应的中断位,告诉 CPU 我们处理完中断了。
#include <stdio.h>
#include <irq.h>
#include <libbase/uart.h>
extern int volatile timeup;
extern int volatile count;
void irq(void)
{
if(timer0_ev_pending_zero_read())
{
// 清除定时器中断处理位
timer0_ev_pending_zero_write(1);
// 主函数打印的数值 ++
timeup = 1;
count++;
}
if(uart_ev_pending_rx_read() || uart_ev_pending_tx_read())
{
// 串口中断处理
printf("UART Interrupt \n");
uart_isr();
}
return;
}
程序编译完后,我们就可以看到每隔1秒打印一个 Hello World,并且可以响应串口中断。
虽然我们只是实现了一个定时打印 Hello World 和 串口中断,用现成的 MCU 和 SDK 可能分分钟就实现了,但是这个 MCU 是一个可以自定义的 SoC,我们可以根据需要给这个 SoC 灵活添加 i2c,SPI,UART 各种外设,甚至也可以给这个 SoC 换一个 CPU,反正 LiteX 会自动生成 CPU 的初始化代码,以及 libc 和基本的驱动代码。
另一方面,LiteX 这一套流程走下来,也对 CPU 指令集的设计,SoC 的综合,CPU 启动初始化,C 运行环境 和中断有了更深的理解,最后一步就是给这个 SoC 移植实时系统 RT-Thread Nano 了。
其实我三年前移植过了 picorv32 到 RT-Thread Nano,还可以使用 msh 控制台。不过一方面当时没有用 LiteX,而是 picorv32 的 Verilog 代码直接烧录到 FPGA,整个 SoC 不是那么灵活;另一方面,当时 picorv32 的 Verilog 配置部分没有打开硬件中断,全部用的软件中断。感兴趣的话下面链接是三年前不灵活的 picorv32,不过它的 RT-Thread 已经移植完成了。
所以最近决定更进一步,用 LiteX 定制一个更灵活的 SoC,还能运行 RT-Thread。