很多人以为MCU和FPGA是两条不太相交的技术路线:MCU跑C代码、做控制,FPGA写Verilog、做并行逻辑。但我在实际项目中见过不少“两边都要沾”的需求——既要像MCU那样用C语言写好状态机、协议栈、界面逻辑,又想要FPGA那样的自定义硬件接口和高速并行处理。以前的标准做法是“一颗MCU+一片FPGA”两颗芯片协同,但板子面积、启动时序、通信带宽各种麻烦。最近我完整调通了一套开源软核MCU工程,真正体会到了把MCU做成FPGA里的软核是什么体验。
这篇文章不讲虚的,直接拆解这类项目的设计思路、系统架构、完整实验过程和踩坑记录。如果你是做嵌入式、FPGA、或者正在犹豫要不要上“MCU+FPGA”方案的人,这篇内容应该能帮你节省不少试错时间。
1. 为什么要把MCU做成FPGA里的软核
很多人问的第一个问题是:FPGA里放一个MCU软核,性能肯定不如外挂一颗独立MCU吧?真不一定。要理解这个问题,得看应用到底吃哪部分性能。
1.1 不是所有场景都需要硬核CPU
FPGA里到底需不需要一颗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_0FFF | Boot ROM | 启动入口,做基本初始化 |
| 0x4000_0000-0x4000_00FF | GPIO控制器 | 方向寄存器和输出寄存器 |
| 0x4000_1000-0x4000_10FF | UART控制器 | 发送、接收、状态寄存器 |
| 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(32'h0000_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 >= 32'h0000_0000) && (mem_addr < 32'h0001_0000); wire gpio_sel = (mem_addr >= 32'h4000_0000) && (mem_addr < 32'h4000_0100); wire uart_sel = (mem_addr >= 32'h4000_1000) && (mem_addr < 32'h4000_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 -march=rv32im -mabi=ilp32 -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 <= 32'hFFFFFFFF; start <= 1'b0; end else if (wr_en) begin case (addr[3:2]) REG_CTRL: if (wdata[0]) crc <= 32'hFFFFFFFF; REG_DATA: begin data_buf <= wdata[7:0]; start <= 1'b1; end endcase end else begin start <= 1'b0; 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指令集