开源RISC-V软核MCU在FPGA上的实现与实战解析
前段时间我在 GitHub 上刷到一个有意思的开源项目标题一句话就戳中了我“MCU 做到 FPGA 的实力”。乍看像营销话术但点进去之后我发现它其实是在做一件很有价值的事把一个可编程的 MCU 软核完整跑在 FPGA 里然后用 FPGA 的并行资源去补齐 MCU 在实时性、接口灵活性上的短板。说白了就是用开源的方式把“单片机”和“可编程逻辑”这两套思维拧在了一起。这个项目适合谁我觉得有三类人特别值得看一是 FPGA 入门者想找个能跑 C 程序的工程模板而不是从点亮 LED 开始一点点熬二是嵌入式工程师手里有闲置 FPGA 板子想低成本验证“硬件加速 软件调度”的架构三是做产品的朋友在评估 MCU FPGA 双芯片方案和 SoC 方案时想先拿开源平台做原型验证。这篇文章我会从设计思路、架构拆解、实操步骤、常见坑点四个方面把这个项目能“吃透”的部分都捋一遍。重点是给你一套可以直接抄作业的路径少走我走过的弯路。1. MCU与FPGA为什么两个物种会产生交集1.1 一个像“按步骤做事”一个像“同时开多条流水线”先别急着看代码我们先把底层逻辑理清楚。MCU 的核心特征是顺序执行CPU 一条一条把指令从 Flash 里取出来经过译码、执行再去改写寄存器或者外设。它的优势是“逻辑复杂但顺序清晰”哪怕你要写一个几万行的网络协议栈编译器也能把它翻译成一条条指令老老实实跑完。MCU 的外设比如 UART、SPI、I2C、定时器本质上都是“硬件给你封装好的模块”你只需要配置寄存器不用关心内部时序。但这种设计的代价是所有任务都在抢同一颗 CPU实时性再高也有极限。FPGA 则完全不一样。它没有“指令”的概念只有查找表和寄存器组成的逻辑电路。你写一段 Verilog综合之后它就变成了实实在在的硬件电路多个模块天然并行运行。你可以在同一个时钟节拍里同时完成图像采集、像素处理、结果输出互不干扰。但它的短板也很明显如果让你用 Verilog 去写一个复杂的 TCP/IP 协议栈或者写一套任务调度器那工作量会让人崩溃。这个开源项目做的事就是把两者的优势结合起来。它在 FPGA 内部例化一个软核 MCU这个 CPU 可以跑 C 代码同时又留好了总线接口让你自己写的 RTL 逻辑可以作为外设挂上去。这样一来你写软件做顶层控制和协议处理让硬件逻辑去处理数据量大、时序敏感的部分两边各干各擅长的活。1.2 为什么是“软核 MCU”而不是“硬核 ARM”这里有个常见的疑问很多高端 FPGA 芯片里本身就集成了 ARM 硬核比如 Xilinx 的 Zynq 系列那还要软核干什么硬核的优势是性能强ARM 核跑在 800MHz 甚至 1GHz 以上能跑 Linux。但它的劣势同样明显一是贵二是引脚和 Bank 划分固定三是如果你只是想验证一个小逻辑把整个 Zynq 平台跑起来有点“杀鸡用牛刀”。更重要的是硬核你动不了它的源码出了问题只能看手册猜。软核 MCU 就灵活得多。它完全是一个 RTL 模块你可以随便改随便裁剪。不需要中断控制器就去掉不需要 FPU 就省掉甚至你可以修改指令总线的位宽和数据通路的流水线深度。这个项目走的路线就是基于开源 RISC-V 指令集搭一个轻量级 MCU 内核再用一套标准化总线把外设组织起来。RISC-V 的好处是设计开放、生态已经比较成熟GCC 工具链可以直接用不需要像早年软核那样拿着专用 IDE 写汇编。我还想强调一点这类项目特别适合做“架构预演”。比如你未来产品打算用“ARM 主控 FPGA 协处理”那在这种开源软核上先用 C 代码把算法流程跑通把数据接口定下来后面移植到正式芯片上逻辑基本可以直接复用只是把寄存器基地址改一下。这个价值用过的人都知道有多大。维度传统 MCU传统 FPGA开源软核 MCU FPGA编程方式C/汇编Verilog/VHDLC 逻辑 Verilog 硬件并行处理弱强强硬件部分复杂协议实现强弱强软件部分外设灵活性固定完全可定制可定制成本低中高中调试难度低高中2. 项目架构与数据通路拆解2.1 整体框架处理器、总线、外设三层结构没有实践过的人第一次看这个工程的 RTL 列表可能会蒙一堆名字很接近的模块到底谁管谁我结合这个项目的常见结构给你画一个清晰的分层模型。最底层是 CPU 核它负责取指令、译码、执行。一般情况下它包含一个 RISC-V 整数流水线可能还带乘除法单元和中断控制器。CPU 核本身不含任何业务逻辑它只做一件事访问地址读写数据。中间层是总线网络。这个项目把外设分成了两条总线高性能的 AXI 总线和低性能的 APB 总线。CPU 访问高速设备就直接走 AXI访问低速外设比如 UART、GPIO就通过一个 AXI-to-APB 的桥接器转过去。为什么要分开因为从电磁干扰和时序收敛的角度看统一用 AXI 当然省事但低速外设挂在 AXI 上会拖慢总线性能而且会让布局布线变得困难。分开之后CPU 访问 UART 的延时高一点没关系反正 UART 本来就慢访问 SRAM 或者自研加速模块时走 AXI 能享受更高带宽和更低的随机访问延迟。最外层是外设集合包括定时器、UART、SPI、I2C、GPIO 控制器等。这个项目开源的好处就在这里每一个外设都是 RTL 源码你可以打开看它到底怎么工作也可以拿掉不用的外设来节省逻辑资源。2.2 核心模块与地址映射任何一个基于总线的 MCU最关键的信息就是“地址映射表”。跑软件的人必须知道哪个外设对应哪段地址不然写寄存器就是瞎猜。以下是一份典型的映射表不同项目可能有所改动但思路完全一样地址区间模块说明0x00000000 - 0x0000FFFF片上 SRAM存放数据和堆栈0x00010000 - 0x0001FFFF指令 ROM存放固件代码0x40000000 - 0x40000FFFGPIO 控制器管脚读写寄存器0x40001000 - 0x40001FFFUART收发数据与状态位0x40002000 - 0x40002FFFSPISPI 主机控制器0x40003000 - 0x40003FFFI2CI2C 主机控制器0x40004000 - 0x40004FFF定时器定时/计数/PWM0x40005000 - 0x40005FFF自定义加速器用户 RTL 模块这张表非常重要。调试的时候先用仿真或者逻辑分析仪查看 CPU 实际发出的地址再和这张表对比就能快速定位是“软件访问地址错”还是“硬件译码错”。我实际操作时踩过的一个坑就是把自研模块挂在了 APB 总线上结果写代码时还按 AXI 总线的地址去访问读写永远没有响应卡了半天最后翻到地址映射文档才发现格式是 0x4008xxxx 开头又看了看代码才知道 APB 外设的基地址是 0x40000000偏移再往上加。所以当你接入新模块时第一件事就是把它的基地址写进一个头文件里之后只用宏定义别在 C 代码里写裸地址。2.3 为什么选 RISC-V 而不是 ARM Cortex-M这里要展开说一句很长一段时间FPGA 里的软核处理器基本都是 ARM Cortex-M1/M3 的天下但商业授权费用和可修改性限制了它们的普及。RISC-V 能够在近期火起来核心就是开放。使用开源 RISC-V 软核还有个隐藏优势你可以自己改指令集。比如你想做一个“单周期乘法”的定制指令直接在译码阶段插一段逻辑再在 C 代码里通过内联汇编调用完全可控。这在 ARM 生态里是不敢想的因为你拿不到 CPU 的 RTL 源码也就没有改的资格。从工具链角度来看RISC-V 现在可以直接用标准 GCC 编译器甚至有模拟器可以在 PC 上先验证固件逻辑等仿真通过后再下载到 FPGA 里。这个流程大大降低了排错难度——纯软件问题根本不用开 EDA 工具在终端里就能发现。不过说实话RISC-V 软核的绝对性能通常不如硬核 ARM毕竟软核要考虑可综合资源的限制流水线深度普遍比较浅。但如果你看重的不是跑分而是“能不能改、能不能裁、能不能看懂”RISC-V 优势就非常突出了。3. 完整实操从零把这个项目跑起来3.1 环境准备与工具链安装先把环境搭好我按 Windows/Linux 都能用的路径来讲。第一步是拿到 FPGA 工程克隆项目之后先看 README 里的目录结构搞清楚三个目录rtl/放处理器和外设源码fpga/放对应厂商工程的约束文件和顶层模块sw/放固件例程。我习惯先看sw/里的例程因为它是理解整个系统最快的入口。第二步是安装 RISC-V 工具链。如果是在 Ubuntu 上可以直接从官方发布的预编译包下载 riscv64-unknown-elf-gcc装好后在命令行敲riscv64-unknown-elf-gcc --version验证。Windows 用户我建议用 WSL 或者装一个嵌入式开发常用的工具链包不建议自己编译 binutils 和 GCC除非你有一整天时间折腾。这里有个小经验一定要确保工具链的目标三元组是riscv64-unknown-elf或者riscv32-unknown-elf不能把 Linux 版的工具链拿来做裸机开发否则链接脚本和启动代码都会出问题。第三步是安装 FPGA 厂商 IDE。Xilinx 用 VivadoIntel 用 Quartus。如果是国产 FPGA也用同样的流程但约束文件后缀和工程向导会有差异。需要注意这个开源项目一般会提供多个厂商的工程文件你先找到对应自己板卡的版本双击生成工程再往下走可以减少很多移植工作量。3.2 综合实现前先理清顶层端口不要急着点 Run Synthesis。先打开顶层模块看看例化时有哪些端口需要连接。常见端口包括系统时钟sys_clk、异步复位rst_n、UART 的 TX/RX、一个 JTAG 调试接口还有可能引出的 GPIO 总线。其中系统时钟你要特别留意频率很多软核默认是 12MHz 或者 50MHz。我建议先把rst_n接成按键或者电源域复位保证上电后有一个干净的复位沿。时钟来源一般有两种直接用板载晶振或者通过 PLL 倍频。如果软核内部要求 50MHz而你把 100MHz 时钟直接怼进去表现往往是仿真没问题但上板之后随机死机。这是因为 CPU 内部的超时计数器、总线仲裁逻辑在过高的时钟下无法满足时序约束。正确做法是把时钟约束写在 XDC 文件里再在实现报告里看一眼 Setup 和 Hold 的 slack确保是正值。3.3 编译固件并合成位流固件编译流程有两步先编译 C 代码再把它转成 FPGA 可用的初始化文件。在sw/目录下执行make会生成一个.elf文件然后通过工具链的objcopy把它转成.hex或.coe文件。这个文件就是指令 ROM 的内容。你需要在 FPGA 工程里将 ROM 的初始化文件替换掉重新综合实现生成 bit 流下载到板子。千万注意ROM 的位宽和固件编译时用的链接脚本必须匹配如果链接脚本指定的是 32 位指令而 ROM 的端口位宽是 64 位地址对齐错乱程序大概率会跑飞。更现代的做法是FPGA 工程里保留一块可读写的 SRAM然后通过调试器把固件动态加载进去这样调试循环就快很多。这个项目如果提供了调试接口优先用这种方式因为每次改 C 代码重新综合 FPGA 工程真的很消耗耐心一次综合就等十分钟我是不太想等的。下载 bit 流之后打开串口助手波特率设为例程里的默认值通常是 115200按一下复位键如果串口打印出了 “Hello World” 或者系统提示符恭喜你软核 MCU 已经跑起来了。到这一步你已经完成了“MCU 跑进 FPGA”的最小闭环。3.4 烧一个基础 UART 例程我把这个最小例程的关键代码写出来方便你对比理解。硬件上软核的 UART 模块就三个寄存器数据寄存器、状态寄存器和控制寄存器。C 代码里这样写#define UART_BASE 0x40001000 #define UART_DATA 0x00 #define UART_STATUS 0x04 #define UART_TX_READY (1 0) void uart_putc(char c) { while ((*(volatile uint32_t *)(UART_BASE UART_STATUS) UART_TX_READY) 0); *(volatile uint32_t *)(UART_BASE UART_DATA) c; } void uart_puts(const char *s) { while (*s) { uart_putc(*s); } } int main(void) { uart_puts(MCU in FPGA OK\r\n); while (1); }这段代码没有任何库函数直接操作寄存器地址目的就是让你看清楚“软件访问硬件”的本质所谓外设控制就是往特定地址写数据。这也提醒你所有外设驱动的关键都在地址映射和对状态位的轮询理解了这两个点什么外设都能写出来。4. 移植到自己的板子时怎么下手才是最稳的4.1 适配时钟与引脚约束拿到任何开源 FPGA 工程第一件事永远是看约束文件。很多项目默认的引脚编号是某款开发板特定的你不改约束就下载轻则端口悬空重则把板子某个关键信号线给驱动起来导致核心板工作异常。建议的做法先查自己板子的原理图确定 UART 管脚、板载时钟管脚、LED 管脚然后逐个修改 XDC 或 QSF 约束里的物理位置。时钟约束尤其要写清楚周期比如 100MHz 就写create_clock -period 10.000 -name sys_clk [get_ports sys_clk]这样综合工具才会以这个周期为目标做时序收敛。一个很容易忽略的细节是引脚的电平标准。很多开发板使用的 FPGA Bank 电压是 3.3V但个别 Bank 可能是 1.8V你如果直接沿用原工程的LVCMOS33可能不会报错但信号质量会很差。最好加上-lvcmos33或者-lvcmos18这样的约束匹配板子实际电压域。4.2 自研逻辑怎么挂到总线上这是整个项目最有魅力的地方也就是让 MCU 通过总线访问你自己写的 Verilog 模块。最简单的方式是使用 AXI-Lite 接口。AXI-Lite 是 AXI 协议的轻量版支持单次读写恰好符合“寄存器访问”的需求。你只需要实现四个信号写地址通道 AW、写数据通道 W、写响应通道 B、读地址与读数据通道 AR/R。为了方便理解我给你写一个最小内存映射外设的例子module my_accel #( parameter BASE_ADDR 32h40005000 )( input wire clk, input wire rst_n, // AXI-Lite slave interface input wire [31:0] axi_awaddr, input wire axi_awvalid, output wire axi_awready, input wire [31:0] axi_wdata, input wire axi_wvalid, output wire axi_wready, input wire [31:0] axi_araddr, input wire axi_arvalid, output wire axi_arready, output wire [31:0] axi_rdata, output wire axi_rvalid, input wire axi_rready ); reg [31:0] ctrl_reg; reg [31:0] status_reg; reg [31:0] data_reg; assign axi_awready axi_awvalid; assign axi_wready axi_wvalid; assign axi_arready axi_arvalid; assign axi_rdata (axi_araddr[15:0] 16h00) ? ctrl_reg : (axi_araddr[15:0] 16h04) ? status_reg : data_reg; assign axi_rvalid axi_arvalid; always (posedge clk or negedge rst_n) begin if (!rst_n) begin ctrl_reg 32h0; status_reg 32h0; data_reg 32h0; end else begin if (axi_awvalid axi_wvalid) begin case (axi_awaddr[15:0]) 16h00: ctrl_reg axi_wdata; 16h04: status_reg axi_wdata; 16h08: data_reg axi_wdata; endcase end status_reg[0] ctrl_reg[0] data_reg[15:0] ! 16h0; end end endmodule这个例子虽然简化了 AXI 握手逻辑但足以说明一种思路把用户自定义模块的寄存器映射到总线地址空间CPU 就能像操作普通外设一样操作你写的电路。你把状态标志放在status_reg[0]C 代码里轮询它就能知道硬件逻辑是不是处理完了。这种方式可以让 CPU 和硬件加速模块协同工作CPU 配置启动参数硬件并行计算CPU 查询完成状态再读取结果。4.3 实测资源占用与性能参考项目跑通之后我评估了资源占用以下是一组常见的数据。注意不同厂商、不同器件和不同工具版本结果会有些差异但数量级可以参考配置LUTFFBRAM最高频率仅 CPU UART GPIO350021004 块150 MHz加 SPI I2C 定时器520031006 块120 MHz再加自定义加速模块820048008 块100 MHz可以看到一个完整软核 MCU 加标准外设只占一片中等规模 FPGA 三成左右的逻辑资源剩下的资源完全可以继续放图像处理、接口转换、电机控制等硬件逻辑。一般来说FPGA 片内软核适合跑控制频率不超过 100MHz 的任务如果你想在软核里跑复杂的浮点算法还是建议额外挂一个硬件 FPU 协处理器。5. 调试实录与常见问题速查5.1 启动失败先查复位和时钟这是最常遇到的“上电后没有任何输出”问题。我的排查顺序是固定的先用逻辑分析仪抓内部复位信号确认复位释放时间和时钟稳定时间是不是满足设计要求再抓 CPU 的取指总线看 CPU 有没有从地址 0 开始取指令最后抓 ROM 的输出数据看读出来的指令是不是 0xFFFFFFFF。如果是全 1说明 ROM 内容初始化失败重新检查.hex文件的格式和位宽。一个我印象很深的案例当时把一串自研外设全部例化之后上电程序就是跑不起来逻辑分析仪抓到的信号看起来也正常最后发现是系统上电后 3.3V 电源轨的爬坡时间太长复位信号太早释放CPU 在时钟还没有稳定时就开始了取指。解决办法很简单在复位模块里加上一个计数器保证上电后至少等待 512 个时钟周期再释放复位问题就消失了。5.2 总线读写异常与地址对齐另一个高频问题CPU 写某个外设寄存器明明代码看起来没问题但硬件就是没反应。排查手段是开 Vivado 的逻辑分析仪核ILA加到总线上或者直接包一个仿真脚本监视写地址和写数据通道。最常见的根因就两个一是访问地址超出了外设的译码段比如只写了偏移没写基地址二是数据总线位宽和外设寄存器宽度不匹配比如 CPU 用的是 32 位总线但某个外设只实现了低 16 位你写一个 32 位数值高 16 位被硬件丢弃了。解决这类问题的技巧是养成用宏定义地址的习惯并且在硬件里给每个外设的寄存器加一个“幻数”寄存器写入和读出都应该能返回一个固定的数值。这个技巧在验证总线通路是否正常时极其好用相当于给外设做了一次握手测试。5.3 跨时钟域FPGA 与外部接口的经典坑软核 MCU 跑在系统时钟域自研模块跑在你的业务时钟域两者之间必然存在跨时钟域问题。很多新手直接把一个信号从时钟域 A 送到时钟域 B结果抓到完全是随机的亚稳态值。解决跨时钟域最稳妥的方法是用两级同步器打拍reg [1:0] sync_ff; always (posedge clk_b or negedge rst_n) begin if (!rst_n) sync_ff 2b00; else sync_ff {sync_ff[0], signal_from_clk_a}; end assign signal_in_clk_b sync_ff[1];这个配合同步器处理“单个事件信号”是足够的。但如果传递的是多位数据就必须用握手或者异步 FIFO不能只靠打拍。我在项目里加自定义模块时一开始图省事直接用打拍同步一个 16 位的计数器结果数据经常错得离谱。后来全部改成异步 FIFO 后再没有出现过数据错乱。5.4 固件更新与调试接口开发过程中最影响效率的就是改完 C 代码后如何快速把新固件跑起来。如果每次都要重新综合生成 bit 流一个改动半小时起步我试过一次就受不了。所以我的经验是早期把 FPGA 工程里的指令 ROM 换成一块双端口 BRAM一个端口给 CPU另一个端口通过调试桥比如 JTAG 或 UART bootloader写入。这样改完 C 代码直接编译生成.bin再通过串口命令加载到 SRAM就能立刻运行新固件整个迭代速度能提升一个数量级。这个流程也让日常开发更接近传统 MCU 的习惯你不用每次都在综合工具和串口工具之间来回切换大部分调试工作停留在 C 代码层面只有改硬件逻辑时才去动 FPGA 工程。6. 这类项目还能往哪个方向扩展吃透了 MCU 软核和总线挂在逻辑你会发现“MCU FPGA”的组合远不止跑个 UART 这么简单。我给自己定过一条扩展路线供你参考第一步把系统里某个耗时函数改写成 RTL 加速器。这个函数可以是平均值滤波、CRC 校验、FFT 运算的前级处理。比如你做 8 路 ADC 采集用 CPU 轮询 8 个通道再算均值花费时间很长如果做一个并行采样和均值计算的硬件模块CPU 只管读结果性能提升非常可观这就是“硬件加速”的本体。第二步给软核接一个自定义指令。RISC-V 代码里用内联汇编嵌入一条自定义指令CPU 译码时识别到这条指令直接触发一个硬件模块的执行。这个能力很多商业 MCU 不具备但开源软核可以实现做完之后你会对“指令集”有更深的理解。第三步把系统移植到更大的 SoC 架构比如 Linux 跑在一个硬核 ARM 上RISC-V 软核跑实时控制任务两个 CPU 通过共享内存和中断通信。这也是很多工业产品的常见形态。这个开源项目给了你一个低成本的实验平台却可以提前演练一套真实产品的软件架构。我个人的体会是这个项目的真正价值不在于“能跑一个软核 MCU”本身而在于它逼着你同时从软件和硬件两个维度思考问题。当你调试一条总线读写异常的时候你既会怀疑 C 代码的地址写入也会怀疑 RTL 的译码逻辑这种双重思维一旦建立起来再回头做纯 MCU 或者纯 FPGA 的工程视角都会完全不一样。最后再分享一个实用小技巧如果你刚拿到这个项目第一天别急着改任何逻辑先把原始工程综合出来烧到板子上确认串口通了再一点点加自己的东西。每次只改一个变量这是我在 FPGA 调试里摔过无数次才学乖的规矩。只要遵守这一条后面就算有坑你也能稳稳地绕过去。
