前段时间我在 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/VHDL | C 逻辑 + 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 - 0x40000FFF | GPIO 控制器 | 管脚读写寄存器 |
| 0x40001000 - 0x40001FFF | UART | 收发数据与状态位 |
| 0x40002000 - 0x40002FFF | SPI | SPI 主机控制器 |
| 0x40003000 - 0x40003FFF | I2C | I2C 主机控制器 |
| 0x40004000 - 0x40004FFF | 定时器 | 定时/计数/PWM |
| 0x40005000 - 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 用 Vivado,Intel 用 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 = 32'h40005000 )( 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] == 16'h00) ? ctrl_reg : (axi_araddr[15:0] == 16'h04) ? status_reg : data_reg; assign axi_rvalid = axi_arvalid; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin ctrl_reg <= 32'h0; status_reg <= 32'h0; data_reg <= 32'h0; end else begin if (axi_awvalid && axi_wvalid) begin case (axi_awaddr[15:0]) 16'h00: ctrl_reg <= axi_wdata; 16'h04: status_reg <= axi_wdata; 16'h08: data_reg <= axi_wdata; endcase end status_reg[0] <= ctrl_reg[0] && data_reg[15:0] != 16'h0; end end endmodule这个例子虽然简化了 AXI 握手逻辑,但足以说明一种思路:把用户自定义模块的寄存器映射到总线地址空间,CPU 就能像操作普通外设一样操作你写的电路。你把状态标志放在status_reg[0],C 代码里轮询它,就能知道硬件逻辑是不是处理完了。这种方式可以让 CPU 和硬件加速模块协同工作:CPU 配置启动参数,硬件并行计算,CPU 查询完成状态,再读取结果。
4.3 实测资源占用与性能参考
项目跑通之后,我评估了资源占用,以下是一组常见的数据。注意,不同厂商、不同器件和不同工具版本,结果会有些差异,但数量级可以参考:
| 配置 | LUT | FF | BRAM | 最高频率 |
|---|---|---|---|---|
| 仅 CPU + UART + GPIO | 3500 | 2100 | 4 块 | 150 MHz |
| 加 SPI + I2C + 定时器 | 5200 | 3100 | 6 块 | 120 MHz |
| 再加自定义加速模块 | 8200 | 4800 | 8 块 | 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 <= 2'b00; 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 调试里摔过无数次才学乖的规矩。只要遵守这一条,后面就算有坑,你也能稳稳地绕过去。