ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

5000行Verilog写RISC-V软核,在FPGA上跑通Linux的做减法哲学

5000行Verilog写RISC-V软核,在FPGA上跑通Linux的做减法哲学 我记得很清楚第一次在串口终端里看到那个login:提示符时我的第一反应是去确认手里这块板子是不是接错了电脑——因为眼前这套Linux系统的“心脏”只是一颗用约5000行Verilog代码写出来的RISC-V软核。编译器产出的Linux内核镜像有十几MB而实现这颗处理器的硬件代码只有5000行两者差了整整三个数量级。把这种“小马拉大车”变成现实的不是玄学而是对指令集、内存管理、外设需求的系统性做减法。这篇内容围绕这颗能在FPGA上跑起轻量级Linux的RISC-V内核展开它凭什么能用5000行代码完成这项任务从零构建一个最小SoC需要哪些部件如何在Vivado里把RTL最终变成能看到登录提示符的完整系统以及跑通Linux之后还能做什么——尤其是很多人在搜的“FPGA在线升级”与Linux结合的玩法。如果你正在考虑在FPGA里塞一颗能跑操作系统的处理器又不想引入MicroBlaze、Nios II或者ARM硬核这篇文章应该能给你一份可以直接上手的参考。1. 砍掉什么换来Linux一颗5000行软核的减法哲学很多工程师第一次听到“软核跑Linux”时脑子里浮现的是Rocket Chip那种几万行Chisel的大型SoC。确实规模大的处理器IP跑Linux很常见但一颗代码量压缩到5000行级别的纯Verilog内核能做到同样的事靠的不是堆代码而是精准识别“哪些硬件复杂度和Linux运行目标无关”然后果断砍掉。1.1 指令集做减法RV32IMC是怎么定下来的RISC-V指令集最大的特点就是模块化。完整应用处理器需要的G扩展包含了整数、乘除法、原子操作、浮点、压缩指令等一堆内容但对一个目标是“跑裁剪版Linux”的软核来说浮点完全可以不要——内核本身用不到硬件浮点用户态程序的浮点需求可以交给编译器的软浮点库代价只是慢一些。真正不能省的是这几样基础整数指令I、乘除法M、压缩指令C以及原子操作A。这里有个容易被新手忽略的点Linux内核在RISC-V架构上默认要求支持A扩展因为内核里大量使用自旋锁、引用计数这类原子操作如果处理器完全没有lr/sc指令内核连加锁都成问题。所以很多号称“精简到极致”的RISC-V软核实际都会在某个角落悄悄实现一个简化的原子操作模块——可以做得不完整但lr/sc和几条常用AMO指令是跑Linux的底线。最终的指令集组合通常是RV32IMAC也就是I加M加A加C。C扩展不改变指令数量级但能显著降低代码体积对FPGA上宝贵的Block RAM容量来说这是一种低成本收益极高的选择。1.2 没有MMU的Linux照样能跑这是整个方案成立的最关键一笔。传统观点认为Linux必须要有MMU内存管理单元但Linux内核很早就支持了NOMMU模式——RISC-V架构下同样有对应的配置。没有MMU意味着什么呢直接省掉的东西是页表、TLB、缺页异常处理、虚拟地址到物理地址的映射逻辑以及进程间隔离所需要的硬件机制。对硬件工程师来说这几项恰恰是处理器里最容易消耗代码量和验证时间的部分。换成NOMMU之后内核和所有用户进程共享同一个物理地址空间跳过MMU的应刷连fork()系统调用的实现都可以简化成类似vfork()的形式。代价是用户程序之间没有内存隔离一个进程写越界可能踩掉另一个进程的数据这对通用服务器是不可接受的但在嵌入式控制、工业现场、教学实验这类场景下完全够用。而且用户态进程仍然运行在U模式至少能挡住mstatus这类特权指令的越权访问针对于需要可靠性的裸机环境来说这层保护聊胜于无。1.3 分支预测、Cache、乱序执行能省的统统省掉5000行代码做不了乱序执行也做不了复杂的分支预测但实际上也不需要。现代处理器把大量晶体管花在“猜分支猜得准”上换来的是十几个周期的无效停顿被折叠掉而对一颗运行在FPGA上、主频几十到一百多兆赫兹的软核来说分支停顿两个周期并不会让用户体验产生本质差异。真正影响性能的是取指带宽。很多5000行级别的软核干脆不设计数据Cache或者只做一个极简的两路组相联指令Cache数据通路直接连着总线。这样做的直接好处是时序收敛容易——FPGA上大型Cache的RAM部署、替换算法状态机、写回缓冲都是时序噩梦。Linux在无MMU模式下本身就不追求极致性能指令Cache稍微给点力就已经能跑得比很多人预期的流畅。这类软核的哲学是把“Linux能跑起来”作为设计目标的下限而把“高性能执行”视为可牺牲的上限。说到底FPGA上跑Linux的场景本来就不指望它一秒钟算出PI的一百万位而是要Linux的多进程调度、文件系统和网络协议栈能力。2. 跑Linux不是单核的事最小SoC清单与地址布局一颗处理器光有CPU核心是启动不了Linux的。Linux内核启动早期要完成时钟初始化、中断控制器注册、串口控制台输出、内存探测然后才轮到进程调度器运转。这些能力全部来自CPU外围的SoC组件。把必要条件列出来你会发现名单意外的短。2.1 处理器之外的“生命维持系统”我在这套系统里最终保留了以下外设每一个都有不可替代的职责外设作用缺失后的结果UART16550兼容内核打印日志、登录终端完全无法观测启动状态CLINT定时器提供mtime机器定时器和软件中断内核时钟节拍停止调度器瘫痪PLIC中断控制器分发外部中断给CPU串口、定时器中断都无法处理DDR控制器提供大容量运行内存NOMMU Linux连最小的rootfs都塞不下Boot ROM/Flash控制器上电加载引导程序和内核镜像芯片上电后无处开始执行这里面最容易踩坑的是CLINT定时器。很多人一开始只写了UART觉得串口有输出就算打通了结果内核启动到Calibrating delay loop...或者调度器初始化阶段就卡死原因就是Linux核心时钟节拍没有信号源。RISC-V Linux要求必须有一个机器模式的定时器这是刚需不是可选项。PLIC也是容易被低估的组件。虽然轮询UART理论上也能输出日志但Linux的串口驱动在接收路径和部分流程上用中断没有PLIC分发外部中断整个系统会处在一个“半身不遂”的状态表现为能启动但一有交互就卡顿或死锁。2.2 地址映射外设和数据住得整整齐齐RISC-V的地址空间就是一个扁平的大数组地址映射完全由SoC设计者决定。我用的布局参考了常见的低端SoC组织方式保证所有关键外设都在低地址段方便访问0x0000_0000 - 0x0000_FFFF Boot ROM16KB上电复位入口 0x0001_0000 - 0x0000_???? 预留 0x1000_0000 - 0x1FFF_FFFF DDR内存256MB 0x4000_0000 - 0x4000_0FFF UART16550 0x4001_0000 - 0x4001_FFFF CLINT 0x4002_0000 - 0x4002_FFFF PLIC 0x6000_0000 - 0x6000_FFFF SPI Flash控制器这个地址布局的意义在于复位后CPU从0x0000_0000取指执行Boot ROM里一段极小的引导程序引导程序把DDR初始化后把存放在Flash里的内核镜像搬运到0x10000000附近的物理地址然后跳过去。整个过程不需要复杂的分区表几个memcpy级别的操作就能完成。2.3 从复位向量到内核入口最小的引导链条因为目标是“代码量最小”这套系统不建议上U-Boot这类完整的引导程序。U-Boot确实功能强大但它的RISC-V支持度参差不齐而且对一个没有MMU的软核来说U-Boot动辄几百KB的镜像体积本身就有点滑稽。我最终采用的方式是Boot ROM里放一段手写的启动汇编完成三件事——设定栈指针、把Flash里偏移位置的内核镜像拷贝到内存、跳转到Linux入口地址。整个引导代码加起来不到2KB直接塞进FPGA的Block ROM里。DDR控制的初始化放在MIG IP生成时的初始化逻辑里这样Boot ROM的代码甚至不用关心DDR时序细节上电后MIG自己训练完内存就处于可用状态了。2.4 设备树让Linux认出这台“怪机器”RISC-V Linux高度依赖设备树DTB来发现硬件。CPU有几核、内存多大、CLINT和PLIC在哪个地址、串口是什么型号全部通过设备树描述。设备树写得不对内核不是panic就是找不到控制台。一个最小的设备树文件大概长这样/dts-v1/; / { #address-cells 1; #size-cells 1; compatible myfpga,riscv-soc; chosen { bootargs consolettyS0,115200 earlyconuart8250,mmio32,0x40000000 init/init; }; memory10000000 { device_type memory; reg 0x10000000 0x10000000; // 256MB }; cpus { #address-cells 1; #size-cells 0; cpu0 { device_type cpu; reg 0; compatible riscv; riscv,isa rv32imac; }; }; soc { clint40010000 { compatible riscv,clint0; reg 0x40010000 0x10000; interrupts-extended cpu0 3 cpu0 7; }; plic40020000 { compatible riscv,plic0; reg 0x40020000 0x10000; riscv,ndev 32; interrupts-extended cpu0 11; }; uart40000000 { compatible ns16550a; reg 0x40000000 0x1000; interrupt-parent plic; interrupts 1; clock-frequency 50000000; }; }; };设备树里最容易犯错的地方是interrupts-extended的写法它直接决定了Linux的中断子系统能不能把外部中断路由到正确的位置。我调试头几天系统一直收不到串口中断最后发现是PLIC节点的riscv,ndev数值比实际支持的中断源数量小中断号被内核判定为无效直接丢弃。3. Vivado里从RTL到Linux登录提示符的落地流程前面讲完了原理这里进入实际操作。以下流程参考的环境是Vivado 2021.2加一块Artix-7开发板板载DDR3和USB-UART逻辑资源规模在2万LUT上下。整套流程我在两种不同型号的Artix上验证过步骤基本一致。3.1 先把RTL和SoC在Vivado里变成比特流第一步不是写代码而是把工程骨架搭好。打开Vivado创建RTL工程选定板卡型号后先把RISC-V处理器的所有Verilog源文件加进去。这部分代码量不大但目录要整理清楚处理器核心、总线接口、CLINT、PLIC、UART、Boot ROM每个模块一个目录。接下来用IP Catalog生成DDR控制器。Artix-7对应MIG 7 Series IP配置时选择AXI4接口模式内存型号和引脚约束从板卡的原理图抄下来。这里有个典型坑MIG生成的引脚约束文件里包含一组SYS_CLK和DDR引脚约束如果芯片型号选错或者内存拓扑选错综合能过但上板绝对抓瞎。DDR控制器之外还需要一个AXI Interconnect或者AXI SmartConnect把CPU的AXI主接口和MIG的从接口以及低速外设的AXI4-Lite从接口连接起来。低速外设挂Lite接口DDR挂Full接口内部连接用SmartConnect自动处理位宽和协议转换。约束文件方面只需要手动写三样东西系统时钟引脚、复位按键引脚、UART发送和接收引脚。其他引脚约束全部由MIG生成。综合、布局布线跑完后生成比特流先用JTAG下载验证处理器在FPGA里能跑起来这一步可以通过ILA抓UART发送引脚的波形来确认核心是否有访问外设的动作。3.2 交叉编译工具链与Linux内核裁剪RISC-V的交叉编译工具链不能用系统自带的通用编译器必须针对目标架构编译一套。最简单的方式是用Buildroot它在配置里选RISC-V 32位取消MMU支持然后让它自动构建工具链、内核和根文件系统。内核源码建议用Linux 5.10以后的版本对RISC-V NOMMU的支持已经比较成熟。配置内核时有几个选项必须注意CONFIG_MMUn CONFIG_SERIAL_8250y CONFIG_SERIAL_8250_CONSOLEy CONFIG_SERIAL_8250_DWy可选 CONFIG_RISCV_SBIn CONFIG_INITRAMFS_SOURCE/path/to/rootfs.cpioCONFIG_MMUn是整件事的前提它直接决定内核编译出不依赖页表的版本。CONFIG_SERIAL_8250对应前面部署的16550兼容UART使能控制台支持后才能看到内核启动日志。CONFIG_RISCV_SBI在没有SBI固件的系统里必须关掉否则内核启动时会尝试调用SBI的早期控制台输出卡死在那个环节。3.3 rootfs、DTB与镜像打包根文件系统我用Buildroot加BusyBox方式构建目标文件系统格式选cpio。BusyBox提供/init、sh、ls、cat这些基础命令足够登录后在控制台里做基础操作。Buildroot默认会生成rootfs.cpio.gz这个文件就是内核的initramfs来源。设备树编译需要单独处理。在内核源码树的arch/riscv/boot/dts目录下放一个板级DTS文件内容参考前面给出的列表编译内核时用make dtbs生成对应的DTB文件。需要注意DTB的地址和内核Image的加载地址必须和Boot ROM里的跳转逻辑一致。镜像打包这一步我把内核Image、DTB、rootfs.cpio.gz整合进一个自定义的Flash镜像文件里排列方式是头部放一个2KB的引导程序然后依次是DTB、内核Image、rootfs。这样Boot ROM只需要把整块Flash拷贝到内存再按固定偏移量跳转就行省去了解析文件系统的复杂度。3.4 把镜像弄进板子并看到登录提示符镜像准备好之后用JTAG加载比特流把Boot ROM预置成初始化状态然后用调试器把Flash镜像写进板载QSPI Flash。整个过程在Vivado Hardware Manager里完成。上电复位后UART终端设置为115200、8N1理论上看到的第一个输出应该是内核早期打印Linux version 5.10.xx (userhost) (riscv32-buildroot-linux-gnu-gcc ...) ...之后是内存探测、时钟源初始化、设备树遍历最后BusyBox的init进程接管出现login:提示符。BusyBox默认不设置密码直接回车就能进入shell。到这一步“5000行Verilog的RISC-V内核跑Linux”就已经从概念变成现实了。4. 串口没输出、启动卡死这些坑是这样定位的从RTL到登录提示符中间不会一路绿灯。我在这套系统上踩过的坑主要分四类每一类都代表一种常见的“软硬协同”问题。这里把排查链路完整记下来给后来者一个可以直接抄的排查顺序。4.1 故障一串口完全无输出优先级最高的问题通常是“CPU根本跑起来”或者“跑起来但根本没执行我们的代码”。排查顺序是先确认FPGA比特流加载成功再看复位引脚是否释放最后用ILA抓UART TX引脚的波形。ILA是定位这类问题的利器。把ILA的探针挂在UART发送模块的移位寄存器输出端触发条件设为发送使能信号上升沿抓一轮就能判断CPU有没有尝试向UART写数据。如果ILA长时间不触发说明执行流没走到UART驱动如果ILA有触发但终端还是空白那问题大概率在UART模块本身比如波特率分频算错或者发送状态机死锁。我遇到的一次“无输出”是Boot ROM的复位向量地址写错了。RISC-V复位后从0x00000000取指但我把Boot ROM在地址空间里分配到了高地址结果CPU从头到尾取到的都是全零指令不执行任何有效操作。4.2 故障二输出乱码或首字符丢弃能看到输出但内容是乱码基本可以锁定波特率问题。系统时钟如果是50MHz而UART模块按100MHz计算分频输出必然错乱。排查方法很直接用示波器或逻辑分析仪抓UART TX引脚测量一帧数据的位宽反推实际波特率再回头检查时钟树配置。还有一类情况是首字符被截断。很多串口助手在检测到线路上有下拉信号时才开始计时而CPU上电后第一条日志如果发送得太早串口助手还没来得及把波特率参数应用到接收逻辑上就会丢掉第一个字符。解决方法是让Boot ROM在执行早期主动延时几百毫秒或者把内核的earlycon参数去掉等console初始化完成后再打印。4.3 故障三内核启动到一半panic内核能打印前面的日志但到某个阶段突然panic或挂起这是最常见的疑难杂症。挂起位置很有参考价值挂在Calibrating delay loop附近大概率是定时器中断没工作查CLINT节点和内核时钟事件驱动配置。挂在Freeing unused kernel memory后面通常是/init执行失败rootfs没有正确打包。挂在Waiting for root device且没有initramfs那就是内核的initramfs支持没开或者CONFIG_INITRAMFS_SOURCE路径写错。我经历的panic是在setup_arch阶段访问非法地址。后来定位到是设备树里reg 0x10000000 0x10000000的内存区间和DDR实际可用大小不匹配——FPGA板上的DDR3只有256MB但地址分配上预留了512MB内核从设备树里读到的物理内存超出实际范围访问到一段没有任何设备响应的空洞地址总线直接挂死。4.4 故障四DDR相关的随机崩溃这种问题隐蔽性最强系统偶尔能启动偶尔启动到一半崩溃或者运行一段时间后进程报段错误。随机性故障在FPGA调试里几乎都指向时序问题尤其是DDR控制器。MIG IP默认生成的DDR时钟频率是根据板卡选型计算的如果板卡走线质量一般或PCB层叠不规范默认时序裕量可能不足。排查步骤是先降频产生差异把DDR时钟从800Mbps降到667Mbps或者更低跑同样的压力测试如果崩溃消失就说明时序余量不够。解决方案包括调整MIG配置里的读写延迟参数或者在PCB布局上优化DDR走线长度匹配。实战建议在RTOS或者Linux中跑一个内存压力测试程序持续对DDR读写不连续地址比漫无目的的日志观察高效一百倍。一旦压力测试稳定DDR相关随机崩溃的概率会大幅下降。5. 把业务逻辑放进Linux软核系统的在线升级方案标题里提到了“FPGA在线升级”这其实是这套软核方案真正体现出工程价值的地方。传统的FPGA只有硬件逻辑升级位流必须通过JTAG或者外部控制器触发重配置。而当FPGA里有一颗能跑Linux的软核后FPGA自己就成了升级的执行者整个在线升级链路可以完全由Linux用户态程序控制。5.1 ICAPFPGA自己改自己的入口Xilinx FPGA内部有一个ICAPInternal Configuration Access Port原语它允许内部逻辑读取和写入配置寄存器甚至可以触发IPROG指令跳转到新的配置地址。用Verilog例化ICAPE2原语再用AXI-Lite接口包一层寄存器就得到了一个可以被CPU访问的“配置端口”。Xilinx官方提供了AXI HWICAP IP做的事情正是把ICAP封装成AXI外设。Linux端可以通过/dev/mem映射访问这个IP的寄存器空间向配置寄存器写入新位流数据或者直接发送IPROG命令让FPGA重新加载启动。在Artix-7上IPROG可以指定一个看门狗定时器和Warm Boot Start Address也就是“下次启动时从哪个地址加载位流”。5.2 双镜像与回滚升级翻车也能自救在线升级最容易翻车的场景是新位流写到一半断电FPGA里只剩半份配置数据上电后既起不来Golden版本也进不了新版本。解决思路是双镜像Golden镜像存在Flash的前半段内容永远锁定为出厂可用的最基础版本只负责系统启动和恢复。Update镜像存在后半段是每次升级的目标区域。Boot ROM或ICAP的IPROG逻辑在启动时根据一个“启动计数”寄存器判断如果连续两次启动都失败就回退到Golden镜像。这个思路在Linux下落地很简单用户态升级程序先把新位流下载到Update分区写入一个“待验证”标志然后触发IPROG重启。新系统启动成功并运行稳定后应用层程序再写入“升级成功”标志。如果新系统起不来硬件看门狗或Boot ROM的计数逻辑会在下一次启动时发现标志不对自动回退。5.3 一套可落地的在线升级流程以这套软核系统为例完整升级流程可以这样组织Linux应用通过FTP/TFTP或者串口把新update.bin拿到本地存入/tmp。应用对镜像做CRC32或者SHA256校验确保下载过程没有损坏。通过Spansion Flash驱动的/dev/mtd设备节点把镜像写入Update分区的/dev/mtd1。设置升级状态寄存器为待验证状态。调用ICAP的IPROG触发FPGA重配置系统复位后从新镜像启动。新系统用户态程序在启动脚本里检查状态寄存器正常运行5分钟后把标志改为成功。这套方案的优越性在于所有升级逻辑都在Linux用户态用C语言或Shell脚本就能实现完全不需要专门的升级芯片或外部控制器。而且Linux的文件系统天然提供了日志和状态记录能力让升级过程可审计、可回滚这是纯FPGA逻辑很难做到的。6. 这类软核的边界在哪里性能、对比与扩展想象把这套系统跑通之后自然会问一个问题它到底适合干什么不适合干什么我的判断是这类软核的定位介于“单周期教学核”和“高性能应用处理器”之间是一个性价比极高的中间选项。6.1 和主流软核方案对比先想清楚你图什么方案代码量/语言能否跑Linux适用场景PicoRV32约1000行Verilog不行教学、裸机控制VexRiscv约10000行SpinalHDL受限中等复杂度SoC本文这类RV32IMC软核约5000行Verilog可以NOMMU教学Linux嵌入式控制Rocket Chip数万行Chisel完全支持高性能SoC研究MicroBlaze/Nios II闭源IP有Linux但繁琐商业项目依赖厂商生态这个表想表达的核心观点是如果你的目标只是“在FPGA里跑一个可控、可改、可教学的Linux系统”那5000行Verilog级别的软核提供的可读性和可维护性远胜于数万行的Chisel项目也比闭源IP更透明。它牺牲了性能换来了理解和修改的自由度。6.2 这套方案的现实定位与继续折腾的方向主频方面在Artix-7的-1速度等级上这套系统综合后能跑到60到80MHz整体性能大致和一颗Cortex-M级别的处理器相当。跑BusyBox、跑简单的网络服务、控制GPIO和串口外设完全够用但别指望用它跑复杂的桌面级应用。真要继续深化有几个方向值得探索。首先是自定义指令RISC-V的指令编码空间本身预留了custom区在流水线里增加一条自定义加速指令让Linux用户态程序直接调用相当于给处理器装了一个硬件加速器。其次是接入网络给SoC增加一个以太网控制器IPLinux就能用标准socket接口开发网络应用再配合前面的在线升级流程就变成一个能远程升级固件的完整设备。如果你想用这套思路做自己的项目我建议从一块能买到、资料全的Artix-7板卡开始先按最小系统跑通一次再逐步加功能。过程中始终保持“做减法”的思维每次加模块前先问自己这个东西真的需要在硬件层实现吗能不能用Linux的软件机制解决最后分享一个我个人的经验软核跑Linux的调试节奏和裸机完全不同。裸机时你关注的是每个时钟周期的信号翻转而跑Linux时你会慢慢把关注点移到设备树、内核配置、驱动模型上。要学会信任内核的日志输出把更多时间花在分析启动流程的分段位置而不是抱着逻辑分析仪挨个信号去查。当终端里出现那个熟悉的login:时你会觉得之前调通DDR、校准UART波特率、改设备树花掉的那些夜晚全都值了。
返回列表