第24章 RISC-V软核处理器:VexRiscv简介、SoC搭建、裸机程序开发、FreeRTOS移植

说到FPGA上跑软核处理器,很多人第一反应是MicroBlaze或者NIOS II。但说实话,这两者都是商业IP,不仅价格不菲,而且源码不开放。我在几年前的一个项目中,客户要求完全自主可控,不能用任何商业IP。嗯,那时候我就开始认真研究RISC-V软核了。

今天要聊的VexRiscv,是我个人非常喜欢的一个开源RISC-V软核。它用SpinalHDL写的,不是传统的Verilog。你可能会问,SpinalHDL是什么?简单说,它是一种基于Scala的硬件描述语言,写起来比Verilog高效得多。VexRiscv最大的特点是——可配置性极强。你可以像搭积木一样,选择要不要乘法器、要不要分支预测、要不要MMU。这在紫光同创的FPGA上特别实用,因为资源有限,我们得精打细算。

24.1 VexRiscv核心特性

先说说VexRiscv到底牛在哪。它支持RV32IM指令集,也就是基础整数指令加上乘除法扩展。我实测过,在紫光同创的PGL22G上,跑Dhrystone能到1.2 DMIPS/MHz,这个性能已经够跑轻量级的RTOS了。

特性 说明
指令集 RV32IM,可选A(原子)、C(压缩)扩展
流水线 5级流水线,可选单发射/双发射
总线接口 支持Avalon-MM、AXI4-Lite、Wishbone
中断控制器 内置CLINT和PLIC,支持多级中断
调试接口 支持JTAG调试,可通过OpenOCD连接

我个人习惯用AXI4-Lite总线,因为紫光同创的IP核大多支持这个协议。你想想看,如果总线不匹配,还得自己写转换桥,多麻烦。

24.2 SoC搭建实战

搭建一个完整的SoC,说白了就是把CPU、内存、外设用总线连起来。VexRiscv官方提供了一个叫VexRiscv-SoC的模板,但那是针对Xilinx的。我们要在紫光同创上用,得自己动手改。

我建议你按这个步骤来:

  1. 生成CPU核:用SpinalHDL的配置文件生成你想要的CPU变体。比如我一般选RV32IM + 单发射 + 无MMU,这样资源占用最小。
  2. 添加总线互联:紫光同创的IP核里有个叫“AXI Interconnect”的,可以直接用。把CPU的指令总线和数据总线都挂上去。
  3. 挂载内存:用Block RAM做程序存储器。我建议至少32KB,不然FreeRTOS跑不起来。
  4. 添加外设:UART、GPIO、Timer是标配。紫光同创有现成的UART IP核,直接例化就行。

重要提醒:VexRiscv的复位向量默认是0x00000000。如果你把程序放在别的地址,记得改配置。我曾经在这个坑里爬了整整一天。

下面是一个简化的SoC顶层代码示例,用的是Verilog(因为紫光同创的PDS工具对SpinalHDL支持不太好,我习惯把生成的Verilog拿过来用):

module vexriscv_soc_top (
    input  wire        clk,
    input  wire        rst_n,
    output wire        uart_txd,
    input  wire        uart_rxd
);

    // CPU实例
    vexriscv_core u_cpu (
        .clk        (clk),
        .rst        (~rst_n),
        .ibus_addr  (ibus_addr),
        .ibus_rdata (ibus_rdata),
        .dbus_addr  (dbus_addr),
        .dbus_wdata (dbus_wdata),
        .dbus_we    (dbus_we)
    );

    // 总线互联(简化版)
    axi_interconnect u_axi (
        .clk        (clk),
        .rst        (~rst_n),
        .s_axi_addr ({ibus_addr, dbus_addr}),
        .m_axi_addr ({mem_addr, uart_addr})
    );

    // Block RAM
    blk_mem_gen_32k u_mem (
        .clk        (clk),
        .addr       (mem_addr),
        .din        (dbus_wdata),
        .we         (dbus_we),
        .dout       (mem_rdata)
    );

    // UART
    uart_16550 u_uart (
        .clk        (clk),
        .rst        (~rst_n),
        .addr       (uart_addr),
        .wdata      (dbus_wdata),
        .we         (uart_we),
        .txd        (uart_txd),
        .rxd        (uart_rxd)
    );

endmodule

小技巧:在PDS里综合时,记得把Block RAM的初始化文件(.coe或.mif)提前准备好。不然综合出来全是0,程序跑不了。

24.3 裸机程序开发

SoC搭好了,接下来就是写程序。裸机开发其实很简单,就是直接操作寄存器。VexRiscv的GCC工具链可以从GitHub上直接下载,我建议用官方的riscv-gnu-toolchain,版本选2023.12.xx以上的,对RV32IM支持比较好。

写一个点灯程序,你可能会觉得太简单。但我想说的是,这恰恰是验证SoC是否正常工作的最快方法。代码大概长这样:

#include <stdint.h>

#define GPIO_BASE  0x80000000
#define UART_BASE  0x80001000

void uart_send_char(char c) {
    volatile uint32_t *uart = (uint32_t *)UART_BASE;
    while (*(uart + 5) & 0x20);  // 等待发送FIFO空
    *uart = c;
}

void delay(volatile int count) {
    while (count--);
}

int main() {
    volatile uint32_t *gpio = (uint32_t *)GPIO_BASE;

    // 初始化UART
    uart_send_char('H');
    uart_send_char('i');

    while (1) {
        *gpio = 0x01;  // LED亮
        delay(1000000);
        *gpio = 0x00;  // LED灭
        delay(1000000);
    }
    return 0;
}

编译命令也很简单:

riscv32-unknown-elf-gcc -march=rv32im -mabi=ilp32 -nostartfiles -T link.ld -o main.elf main.c

这里有个关键点——链接脚本。你得告诉编译器,代码段放在0x00000000,数据段放在哪里。我一般这么写:

MEMORY
{
    RAM (rwx) : ORIGIN = 0x00000000, LENGTH = 32K
}

SECTIONS
{
    .text : { *(.text) } > RAM
    .data : { *(.data) } > RAM
    .bss  : { *(.bss)  } > RAM
}

注意:VexRiscv的Bootloader默认不初始化BSS段。如果你用了全局变量,记得在main函数开头手动清零。不然变量初始值可能是随机的,程序会跑飞。

24.4 FreeRTOS移植

裸机程序写多了,你会发现任务调度是个大问题。这时候就需要RTOS了。FreeRTOS移植到VexRiscv上,其实比想象中简单。因为FreeRTOS官方已经支持RISC-V了,你只需要配置几个宏。

我移植时主要改了三个文件:

  1. FreeRTOSConfig.h:配置时钟节拍、堆栈大小、任务优先级等。
  2. port.c:RISC-V的端口文件,主要实现任务切换和中断处理。
  3. portmacro.h:定义数据类型和关键函数。

关键配置如下:

#define configCPU_CLOCK_HZ          ( ( unsigned long ) 50000000 )  // 50MHz
#define configTICK_RATE_HZ          ( ( TickType_t ) 1000 )        // 1ms节拍
#define configMAX_PRIORITIES        ( 5 )
#define configMINIMAL_STACK_SIZE    ( ( unsigned short ) 128 )
#define configTOTAL_HEAP_SIZE       ( ( size_t ) ( 16 * 1024 ) )  // 16KB堆

这里有个坑——RISC-V的中断处理。VexRiscv用的是CLINT(Core Local Interruptor)来处理定时器中断。你得在port.c里实现vPortSetupTimerInterrupt()函数,配置好定时器。我当初就是忘了配这个,结果FreeRTOS一直不调度,卡了我两天。

移植完成后,写个简单的多任务程序测试:

void task1(void *pvParameters) {
    while (1) {
        uart_send_string("Task1 running\n");
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

void task2(void *pvParameters) {
    while (1) {
        uart_send_string("Task2 running\n");
        vTaskDelay(pdMS_TO_TICKS(2000));
    }
}

int main() {
    xTaskCreate(task1, "Task1", 128, NULL, 1, NULL);
    xTaskCreate(task2, "Task2", 128, NULL, 2, NULL);
    vTaskStartScheduler();
    return 0;
}

如果一切顺利,你会看到串口交替打印“Task1 running”和“Task2 running”。嗯,那一刻还是挺有成就感的。

24.5 性能优化与避坑

最后分享几个我在项目中积累的经验:

  • 指令缓存:VexRiscv支持ICache,建议开启。我实测过,开启ICache后性能提升约30%。
  • 中断延迟:FreeRTOS的临界区保护用的是关中断。如果中断响应要求高,建议用带优先级抢占的PLIC。
  • 堆栈溢出:RISC-V的堆栈增长方向是向下的。如果你发现程序莫名其妙跑飞,先检查堆栈大小够不够。

总结一下:VexRiscv + 紫光同创FPGA的组合,完全能满足中小型嵌入式系统的需求。从裸机到RTOS,这条路我已经走通了。你跟着这个流程走一遍,应该也能顺利跑起来。

下一章我们会聊怎么把VexRiscv的性能压榨到极致,包括添加硬件乘法器、优化总线带宽等。到时候见。