FEATURED · 精选文章

MCU软核嵌入FPGA:用RISC-V在可编程逻辑中跑C程序

发布时间 / 2026/9/9 7:06:24
来源 / 创域科博编辑部
栏目 / 资讯中心
MCU软核嵌入FPGA:用RISC-V在可编程逻辑中跑C程序 很多人以为MCU和FPGA是两条不太相交的技术路线MCU跑C代码、做控制FPGA写Verilog、做并行逻辑。但我在实际项目中见过不少“两边都要沾”的需求——既要像MCU那样用C语言写好状态机、协议栈、界面逻辑又想要FPGA那样的自定义硬件接口和高速并行处理。以前的标准做法是“一颗MCU一片FPGA”两颗芯片协同但板子面积、启动时序、通信带宽各种麻烦。最近我完整调通了一套开源软核MCU工程真正体会到了把MCU做成FPGA里的软核是什么体验。这篇文章不讲虚的直接拆解这类项目的设计思路、系统架构、完整实验过程和踩坑记录。如果你是做嵌入式、FPGA、或者正在犹豫要不要上“MCUFPGA”方案的人这篇内容应该能帮你节省不少试错时间。1. 为什么要把MCU做成FPGA里的软核很多人问的第一个问题是FPGA里放一个MCU软核性能肯定不如外挂一颗独立MCU吧真不一定。要理解这个问题得看应用到底吃哪部分性能。1.1 不是所有场景都需要硬核CPUFPGA里到底需不需要一颗CPU需要区分场景来谈。纯数据通路类需求比如视频缩放、PCIe DMA搬数据、以太网帧过滤这些任务用硬核状态机或流水线更合适塞一颗CPU进去反而是累赘。但另一类需求完全反过来系统初始化、寄存器配置、运行控制策略、处理异常分支、和外部交互协议比如上电后对ADC做校准、协商PD快充协议、I2C读取光模块EEPROM、根据温度调整风扇策略。这类需求逻辑分支多、状态多、后期改动频繁用纯Verilog状态机写起来非常痛苦而且每次改一两个时序细节就要重新综合一遍迭代效率很低。最典型的矛盾就在这类“控制密集型”场景状态机的并行能力用不上但代码维护量却很大。与其在FPGA里维护一堆复杂的FSM不如塞一个软核MCU进去用C语言写控制逻辑FPGA里的其他逻辑模块仍然保持并行工作。这就是软核MCU存在的最大理由——它解决的不是算力问题而是“控制逻辑的工程化问题”。1.2 软核MCU到底解决什么痛点软核MCU方案最直接的价值是把MCU生态带的开发效率搬到了FPGA里。举个例子我在一个项目里需要实现“握手启动”逻辑上位机通过UART下发一组参数MCU校验后给FPGA内部的数据通路模块写寄存器再根据传感器反馈调整数据通路的启停。如果是纯Verilog做这套东西代码量可能不大但涉及串口解析、CRC校验、参数表管理、异常重传机制整套状态机跑起来Bug率很高。用软核MCU之后这部分直接写成C函数串口中断、CRC查表、状态迁移都是MCU开发里最成熟的东西几乎不用调试就能跑通。更关键的是软核MCU和FPGA里的其他逻辑天然在同一个芯片内部。MCU访问硬件模块不再是“通过外部总线发起读写”而是直接访问片上地址空间。FPGA里加了一个图像滤波模块软核MCU只需要像操作寄存器一样往某个地址写参数。做了一个高速并行CRC校验加速器MCU丢一段数据进去就能立刻拿结果。这种一体化程度是外部两颗芯片方案很难比拟的。如今RISC-V处理器核的流行也让软核MCU不再受制于专利授权。随便一个开发者在FPGA里集成一颗PicoRV32或者NeoRV32软件侧用开源的GCC工具链编译C程序整个链路完全透明可控。相比之下商业硬核MCU虽然性能更好、外设更全但可裁剪性远不如开源软核想加一条自定义指令、想扩展一个片上外设、想精简处理器面积到极限开源软核都能做到这也是这个方向在开源社区越来越活跃的主要原因。2. 架构拆解一颗CPU核如何变成可用MCU把一个裸的CPU核组装成“能跑C程序”的MCU系统中间还要补齐好几个关键部件。很多人第一版工程失败往往就是漏了存储映射或者中断处理。2.1 CPU、总线和存储映射软核MCU系统里最核心的三块是CPU核、存储器和总线。CPU核负责执行指令但这个指令从哪儿来、数据往哪儿写都得靠存储映射表来定义。以PicoRV32为代表的RISC-V软核通常只提供一个简单的“内存接口”而不是完整的AXI总线。它输出mem_valid/mem_addr/mem_wdata等信号外部需要自己做一个地址译码器来生成不同外设的片选信号。这时地址规划就尤其重要。一个比较典型的映射如下地址段功能说明0x0000_0000-0x0000_FFFF片上RAM放程序代码、栈和全局数据0x2000_0000-0x2000_0FFFBoot ROM启动入口做基本初始化0x4000_0000-0x4000_00FFGPIO控制器方向寄存器和输出寄存器0x4000_1000-0x4000_10FFUART控制器发送、接收、状态寄存器0x6000_0000-0x6000_0FFF自定义外设空间CRC加速、自定义协处理器片上RAM一般直接用FPGA里的BRAM实现。代码量小的工程8KB到32KB就已经足够跑FreeRTOS或者裸机控制逻辑。这里有个容易踩的坑复位向量。CPU上电后的第一条指令要从某个固定地址取这个地址必须能在综合后的BRAM初始值里找到内容否则处理器一上电就跑飞。有些设计会把Boot ROM放在低地址然后通过跳转指令把用户代码复制到RAM里再执行这种设计灵活但需要额外写搬移代码。简单项目也可以直接把复位向量指向RAM起始地址软件编译时把代码链接到对应位置综合时用初始文件填充。总线层面如果CPU核本身不带AHB/AXI接口就需要自己写一个“地址译码握手仲裁”模块。这里不需要搞得很复杂核心是三个事情按地址区间生成片选信号、处理读返回数据选通、对外设的wait状态做超时保护。一旦某个外设没有返回ready信号CPU就会永远卡在访问上这点在调试时尤其需要留意。2.2 外设集成与中断有了CPU、存储器和总线已经能跑“裸奔”程序了但还称不上MCU。一个正经MCU必须有外设和中断机制否则只能用轮询方式处理串口、定时器等事件CPU效率非常低。软核MCU常用的外设包括GPIO、UART、SPI、I2C、定时器、看门狗等。这些外设模块可以自己写也可以直接用开源IP。建议第一版从GPIO和UART起步这两种外设最简单也最能验证系统是否跑通。GPIO模块内部至少要有“方向寄存器”和“输出/输入寄存器”。很多新手写GPIO外设时只做了输出寄存器结果读引脚状态时永远读不到数据因为输入路径没连到引脚上。方向寄存器设计成每bit控制一个引脚的方向写1为输出、写0为输入这在FPGA里仅需一个寄存器和多个三态缓冲器逻辑不复杂。中断设计往往是软核MCU项目里最容易出问题的地方。RISC-V架构下通用软核比如PicoRV32提供一组irq输入信号每个bit对应一个中断源。CPU核收到中断后会跳转到内部定义的异常入口地址同时记录中断状态。开发者需要维护一张中断向量表并在中断服务程序里区分是哪个外设触发的中断。外设到CPU的中断路径常见设计是“每个外设拉高自己的中断请求线CPU的中断控制器汇总后产生一个向量号”。这里建议把所有中断统一接入一个中断控制器而不是让每个外设直接连CPU核的irq引脚。因为直接相连时外设数量一旦增加CPU核的中断逻辑就会变得复杂而且中断优先级、使能屏蔽都不好管理。用中断控制器之后每个外设占一个bit软件可以独立使能/屏蔽/查询状态调试起来非常直观。2.3 为什么这类方案适合开源社区软核MCU之所以能在开源社区里越做越成熟和RISC-V架构的开放性分不开。过去想在FPGA里放一颗MCU软核要么用ARM的M1/M3授权方案成本高要么用Xilinx MicroBlaze这种需要商业许可证的核而RISC-V软核直接把门槛拉到了零随便什么FPGA板子都能跑工具链也是现成的GCC。这类项目另一大优势是“可裁剪性”。商业软核往往集成了大量固定外设你不用也得占资源开源软核则可以把不需要的乘法器、除法器、压缩指令扩展等全部关掉只保留最基本的指令集。对于低成本、低密度FPGA板子这种裁剪往往省出来的逻辑资源够放好几个自定义模块。另外遇到工具链Bug或者需要软件配合改协议时整个CPU核代码和编译器都是开源的可以直接查源码定位问题这在商业IP下基本不可能做到。所以从工程角度讲现在对一个中小规模FPGA项目来说先在内部塞一颗可裁剪的软核MCU已经是一个性价比很高、稳定性也可控的基础架构选择。3. 实操从源码到FPGA板完整跑通前面讲了不少概念这部分直接进入实战。我以一套基于PicoRV32软核的最小MCU系统为例从工程搭建到点灯和串口输出完整跑一遍。3.1 工程准备与目录规划软核MCU项目通常分为三部分CPU及片上系统RTL代码、外设设备代码、C语言固件程序。建议从第一天就按目录分好不然后面综合和调峰会非常混乱。我习惯的目录结构是project_root/ rtl/ soc_top.v cpu/ picorv32.v mem/ ram_wrapper.v periph/ gpio.v uart.v software/ main.c startup.s link.ld scripts/ build.shRTL部分负责生成硬件系统software部分负责生成可以被BRAM初始化的固件。综合时通常需要先编译C代码得到hex文件然后把这个hex文件作为初始化数据传给RAM模块。工具链方面FPGA综合布局布线可以用厂商IDE也可以用开源工具链。逻辑设计代码建议保持可综合的通用Verilog方便迁移到不同FPGA平台。软件部分需要安装RISC-V的GCC交叉编译器常见前缀是riscv-none-elf-或者riscv64-unknown-elf-本质上都是同一个GCC套件只是target不一样。3.2 例化CPU核并挂接简单外设以一个最小系统为例。顶层模块soc_top完成CPU核、RAM、GPIO和UART的连接。下面是一个省略了细节的示例主要展示CPU核与外设的关系module soc_top #( parameter CLK_HZ 50_000_000 )( input wire clk, input wire rst_n, output wire uart_tx, input wire uart_rx, output wire [7:0] gpio_o, input wire [7:0] gpio_i ); wire [31:0] mem_addr; wire [31:0] mem_rdata; wire [31:0] mem_wdata; wire [3:0] mem_wstrb; wire mem_valid; wire mem_ready; wire mem_instr; // CPU核例化此处为节选参数 picorv32 #( .ENABLE_MUL(1), .ENABLE_IRQ(1), .PROGADDR_RESET(32h0000_0000) ) cpu_inst ( .clk (clk), .resetn (rst_n), .mem_valid(mem_valid), .mem_instr(mem_instr), .mem_addr (mem_addr), .mem_wdata(mem_wdata), .mem_wstrb(mem_wstrb), .mem_rdata(mem_rdata), .mem_ready(mem_ready) ); // 地址译码与总线逻辑 wire ram_sel (mem_addr 32h0000_0000) (mem_addr 32h0001_0000); wire gpio_sel (mem_addr 32h4000_0000) (mem_addr 32h4000_0100); wire uart_sel (mem_addr 32h4000_1000) (mem_addr 32h4000_1100); // 此处省略读写数据选通和ready信号处理 // 各外设模块的例化省略 endmoduleCPU核的mem_valid信号表明当前周期CPU产生了一次总线访问mem_addr是访问地址mem_wdata是写数据mem_wstrb是写字节使能。外设侧要做的基本工作是当片选有效且当前没有其他总线占用时把读写操作完成信号拉给CPU的ready端。如果设计不当比如外设和RAM两个模块同时返回数据总线就会冲突CPU读到的数据会是未知值。GPIO外设我会做两个寄存器方向寄存器和输出寄存器输入信号通过旁路直接连到输入寄存器或者CPU读数据线上。UART外设则分发送和接收两个子模块。发送侧核心是移位寄存器和波特率分频计数器接收侧要注意异步信号的同步一般需要把rx信号打两拍再采样避免跨时钟域的亚稳态问题。一个常见的问题是CPU访问UART可能要等若干周期这期间CPU会一直等待ready信号。如果UART正在发送一个字节CPU又试图写入下一个字节发送模块需要在芯片内部做一个缓冲区否则就会丢数据。最简单的做法是用一个1字节或2字节的FIFO配合状态寄存器里的“忙”标志位。软件侧发送前先读状态寄存器忙则等待不忙则写入数据。这种方式虽然牺牲了一点CPU效率但逻辑简单稳定对于初版系统来说足够用了。3.3 软件侧启动文件、链接脚本与裸机程序硬件系统完成后接下来是让C程序在上面跑起来。MCU启动时需要初始化栈指针、全局变量等这些工作由启动文件和链接脚本完成。RISC-V裸机启动文件一般这么写.section .text.start .globl _start _start: la sp, _stack_top call main 1: j 1b代码先加载栈指针再跳转到C语言的main函数。如果不设置栈指针C语言一调用函数就会写内存目标地址很可能是未初始化的随机地址程序必跑飞。链接脚本则负责告诉链接器代码段和数据段分别放在哪里MEMORY { RAM (RWX) : ORIGIN 0x00000000, LENGTH 0x8000 } SECTIONS { .text : { *(.text.start); *(.text*); } RAM .data : { *(.rodata*); *(.sdata*); *(.data*); } RAM .bss : { *(.sbss*); *(.bss*); } RAM }这里把复位向量直接放在了RAM起始地址。综合时RAM模块通过$readmemh加载固件hex文件。这个方法适合程序量不大的裸机项目省去了Boot ROM的设计。固件点灯和串口输出程序非常直接。下面是一段简单例程#define GPIO_BASE 0x40000000 #define UART_BASE 0x40001000 #define UART_STATUS (*(volatile unsigned int *)(UART_BASE 0x00)) #define UART_DATA (*(volatile unsigned int *)(UART_BASE 0x04)) #define GPIO_DIR (*(volatile unsigned int *)(GPIO_BASE 0x00)) #define GPIO_OUT (*(volatile unsigned int *)(GPIO_BASE 0x04)) static void uart_putc(char c) { while ((UART_STATUS 1) 0); UART_DATA c; } void delay(void) { volatile int i; for (i 0; i 1000000; i); } int main(void) { GPIO_DIR 0xFF; GPIO_OUT 0x00; while (1) { GPIO_OUT 0xAA; uart_putc(H); uart_putc(i); delay(); GPIO_OUT 0x55; delay(); } return 0; }编译命令大致是riscv-none-elf-gcc -marchrv32im -mabiilp32 -nostartfiles \ -T link.ld -o firmware.elf software/main.c software/startup.s riscv-none-elf-objcopy -O verilog firmware.elf firmware.hex生成firmware.hex后再供RTL仿真或综合使用。注意这里用了“-nostartfiles”因为我们自己的startup.s已经承担了启动初始化任务不需要再链接系统的标准启动文件。有关编译的优化选项我建议调试阶段用“-O0 -g”功能确认后再改“-O2”。不同优化级别下编译器对volatile变量、内存访问顺序的处理会有差别有时代码在O0正常、O2却异常这种问题排查起来往往比逻辑Bug更耗时间。3.4 综合下载与基础驱动验证软核MCU的综合比普通FPGA逻辑慢原因是CPU核相关路径较长尤其是复位网络和局部RAM接口。综合前建议先做一次RTL仿真用Verilator或者Icarus Verilog都行至少确认main函数能跑起来、GPIO能翻转、UART能输出字节再上板调试。省掉仿真直接上板不是不行只是定位问题的手段少很多。上板之后第一个验证点是波形层面的“程序是否真的在跑”。建议在顶层预留两个调试信号比如把CPU核的mem_valid和一个访问计数器的输出引出来用逻辑分析仪或示波器观察是否有周期性的总线活动。很多人把程序下载到板子上之后LED不闪第一反应是改了C代码其实更可能是CPU根本没跑起来。此时查复位信号和时钟是否到达CPU核是最直接的。如果GPIO和UART都能正常工作说明整条“CPU核-总线-外设”链路已经打通接下来就可以在这个基础上添加更多实际功能模块。到这里一套最小的“MCU嵌入式到FPGA”系统就算是真正跑起来了。4. 把FPGA的实力真正用起来硬件加速扩展系统能跑C程序只是第一步这类架构真正的价值在于FPGA的逻辑资源可以随时被“添加”到CPU的世界里做MCU做不到的并行和高速处理。4.1 寄存器映射型协处理器最简单、也最实用的硬件扩展方式是把自定义模块当作“寄存器映射设备”挂到总线上。比如做一个32位CRC32计算模块module crc32_core ( input wire clk, input wire rst_n, input wire [31:0] addr, input wire wr_en, input wire [31:0] wdata, output reg [31:0] rdata ); reg [31:0] crc; reg [7:0] data_buf; reg start; localparam REG_CTRL 0; localparam REG_DATA 1; localparam REG_CRC 2; always (posedge clk) begin if (!rst_n) begin crc 32hFFFFFFFF; start 1b0; end else if (wr_en) begin case (addr[3:2]) REG_CTRL: if (wdata[0]) crc 32hFFFFFFFF; REG_DATA: begin data_buf wdata[7:0]; start 1b1; end endcase end else begin start 1b0; end end // 此处仅展示数据通路框架实际CRC迭代逻辑需补充 endmoduleC程序里可以直接操作这个模块#define CRC_BASE 0x60000000 #define REG_CRC_CTRL 0x00 #define REG_CRC_DATA 0x04 #define REG_CRC_OUT 0x08 unsigned int crc_compute(unsigned char *buf, int len) { unsigned int crc; *(volatile unsigned int *)(CRC_BASE REG_CRC_CTRL) 1; for (int i 0; i len; i) { *(volatile unsigned int *)(CRC_BASE REG_CRC_DATA) buf[i]; } crc *(volatile unsigned int *)(CRC_BASE REG_CRC_OUT); return crc; }这个模块吞吐率可能不如并行展开版本但和纯软件CRC查表相比已经省去了大量的位运算和循环CPU只需按字节写入数据即可。用FPGA做硬件加速的好处在于一旦模块接口固定下来后续想改成并行CRC、多个通道同时算都不用改C代码只改RTL逻辑然后重新综合下载即可。这种硬件迭代方式在传统MCU上是完全不可能的。4.2 自定义指令扩展RISC-V寄存器映射式协处理器的优点是通用、易调试但每次访问都要走一次总线读或写操作中间有地址译码延迟和握手开销。如果想做更精细的硬件加速可以考虑扩展RISC-V自定义指令实现“CPU一条指令直接算一个复杂操作”。比如在PicoRV32这类开源核中可以通过reg_insn和reg_rd信号接入自定义指令逻辑。当CPU执行某条预留的自定义指令时内核把当前指令编码输出到一个用户逻辑模块该模块解析出要执行的操作将结果写回reg_rd指定的通用寄存器。这种方式把硬件模块集成到了处理器的数据通路内部省去了外部总线访问的时间。自定义指令的核心优势是代码简洁和响应快。比如信号处理里需要用Cordic算一次三角函数传统方式可能是调库函数执行上千条指令如果做成自定义指令硬件在几十个纳秒内就能返回结果。RISC-V指令集
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻