做FPGA的同学迟早会碰上这么一件事:你在Vivado里搭好MicroBlaze软核,仿真调通,板上跑通,然后老板说“把程序固化到Flash里,上电自己跑”。这时候如果你直接把应用写到BRAM里固化,会发现下次改一个串口打印都要重新烧整个bit,折腾得想骂人。正确思路是给MicroBlaze配一个Bootloader:一小段固化在BRAM里的引导代码,上电后帮你从外部Flash把真正的应用搬到内存里运行。以后每次只更新应用分区就行,完全不用碰bit流。
这篇东西就是写给第一次搞MicroBlaze Bootloader的人看的。我会用Vivado 2021.1作为操作环境,从启动原理、Block Design配置、Bootloader代码逻辑、链接脚本、BOOT.bin生成到烧写调试,把整条链路完整走一遍。其中大部分坑都是我自己踩过的,比如QSPI读回全FF、固化后上电没反应、跳转后直接跑飞之类,基本都能在这篇文章里找到原因和结论。内容不依赖特定开发板,只要板子上带SPI Nor Flash、有UART就能跟着做。
1. 先搞清楚MicroBlaze Bootloader要解决什么问题
1.1 上电后MicroBlaze从哪里取指令
先理解一个基础问题:MicroBlaze上电后执行的代码在哪?MicroBlaze是软核,内部没有类似STM32那样的片上Flash,它在FPGA里的“程序存储器”只能是你通过Block Design挂上去的BRAM(Local Memory),或者外部DDR。而BRAM的初始内容来自bit流文件——也就是说,FPGA配置完成后,BRAM里是什么内容,CPU就跑什么代码。
这就带来一个非常现实的问题:如果你把整个应用代码都放进BRAM,那么应用代码的体积就被BRAM容量卡死(一般MicroBlaze系统给到64KB已经算大),而且每次改代码都要重新生成bit流、重新固化FPGA配置,效率极低。如果你把代码放在DDR里,DDR本身是掉电丢失的,上电后DDR里空空如也。
Bootloader就是夹在中间的那个搬运工。它的思路很简单:
- 把一小段引导代码放在BRAM里,跟着bit流一起固化到Flash。
- 上电后MicroBlaze从BRAM执行这段引导代码。
- 引导代码去外部QSPI Flash的某个偏移地址,把真正的应用镜像读出来。
- 把镜像写到DDR(或BRAM)里,然后跳转过去,开始执行应用。
以后你想更新应用,只需要把应用镜像写到Flash的指定偏移区域,其他什么都不用动。这跟电脑的BIOS加载操作系统的思路完全一样,只是规模小得多。
1.2 Bootloader和“直接固化应用”到底差在哪
有些新手会问:我不搞Bootloader,直接在SDK里把应用通过Program Flash烧进去,上电不也能跑吗?能跑,但这里有几个隐藏问题。
第一种情况,你在Vivado的MicroBlaze配置里开启了Boot Loop(默认行为),BRAM里的初始内容是一段死循环跳转到0地址的代码。它存在的目的是方便JTAG调试,因为调试器可以先配置FPGA,再把真正的程序加载到内存里,然后复位CPU执行。如果你把它当成品固化进Flash,上电后CPU只会不停复位循环,你的应用根本不会启动。
第二种情况,你把应用elf直接作为bootloader分区打进BOOT.bin,Bootgen确实会把应用倒腾进BRAM初始化数据。但应用一旦超过BRAM容量,编译链接直接失败。而且每次修改都要整体重来,完全没有“升级”的灵活性。
所以说,Bootloader看似多了一层工作,实际上是给后续开发换来了极大的自由:应用可以放在DDR里跑,可以分版本,可以远程升级,甚至可以做A/B双备份回滚。这笔账怎么算都划算。
1.3 地址规划和Flash分区设计
动手之前必须有清晰的地址规划。我用一个典型设计做演示,假设硬件参数如下:
- BRAM基地址:0x00000000,容量0x20000(128KB),放Bootloader。
- AXI Quad SPI控制器地址:由Vivado自动分配,这里不展开。
- DDR基地址:0x80000000,容量1GB,放应用运行时镜像。
Flash侧的分区规划如下:
| Flash偏移地址 | 内容 | 说明 |
|---|---|---|
| 0x000000 | 配置bit流 + Bootloader镜像 | 由Bootgen打包,固化到Flash起始位置 |
| 0x200000 | 应用镜像(app.bin) | Bootloader从这个偏移读取,加载到DDR |
| 0x800000 | 备用升级区/数据区 | 如果做OTA,这里可以放新版本应用 |
Flash偏移的选择看你的Flash容量和应用大小。一般应用镜像读取长度固定,或通过头部信息指定长度。新手阶段用固定长度最简单:Bootloader里直接定义APP_IMAGE_LENGTH,不要搞动态解析,先跑通再优化。
2. Vivado 2021.1硬件工程:Block Design这样搭才对
2.1 最小系统需要哪些模块
一个能跑Bootloader的MicroBlaze最小系统需要以下组件:
- MicroBlaze处理器核
- LMB BRAM(本地存储),容量建议128KB,至少不低于64KB
- AXI Quad SPI控制器,连接板载SPI Nor Flash
- UART控制器(AXI UART Lite),用于打印Bootloader运行日志
- DDR控制器(如果应用跑在DDR里),配套Clock Wizard
- Processor System Reset模块,保证上电复位时序稳定
如果你的板子没有DDR,也可以用BRAM跑应用,但应用大小受限于BRAM总容量。本文以DDR方案为例展开。
2.2 MicroBlaze处理器配置的关键项
在Vivado里双击MicroBlaze,进入配置界面,有几个选项必须注意。
首先是Local Memory配置,在Processor Configuration的Memory标签下,把Local Memory Base Address设为0x0,Size设为128KB。这个BRAM容量必须能装下Bootloader代码、数据、堆栈。我用默认设置编译Bootloader,优化等级-O2,体积大概30KB出头,128KB余量充足。
其次是Reset Vector和Exception Vector设置。在MicroBlaze配置里,这两个向量在Interrupt/Exception标签下配置,Reset Vector设为0x00000000,Exception Vector设为0x00000008。它决定了CPU复位后从哪个地址取第一条指令,以及异常发生时跳到哪个地址。记住:这两个地址必须落在BRAM范围内,否则上电就跑飞。
还有一个容易忽略的Boot Loop选项。如果你的MicroBlaze版本里有Enable Boot Loop勾选项,做Bootloader方案时建议取消勾选,因为Bootgen打包时会用真正的Bootloader初始化BRAM,不再需要bootloop占位。如果保留,部分工具链可能会用bootloop覆盖BRAM初始内容,导致固化后跑的不是你的Bootloader。
2.3 AXI Quad SPI与时钟频率设置
AXI Quad SPI是Bootloader访问Flash的唯一通道,配置不当后面会非常痛苦。以1.0版本IP为例,勾选FIFO模式,默认FIFO深度16,接口类型选SPI模式。Flash型号常见的是Winbond W25Q系列(比如W25Q128),或者Macronix、ISSI的对应型号。
SPI时钟频率是一个关键参数。频率太高可能导致Flash读取不稳定,特别是高速PCB布线不好或线缆较长时。我的建议是起步阶段把SPI时钟设为25MHz或更低,跑通后再尝试提升。很多新手一上来就想用极限频率,结果读回来的数据全是FF,排查半天发现是时序裕量不足。
连接关系上,把AXI Quad SPI的SPI输出引脚分配到FPGA引脚,对应板卡的Flash MISO、MOSI、SCK、CS,FPGA引脚约束不要搞错,否则读Flash ID都不对。导出硬件后,打开SDK界面之前,在Sources窗口右键Block Design选择Generate Output Products和Create HDL Wrapper,然后菜单File > Export Hardware,这一步别忘了勾选Include bitstream。不勾这个,后面Create Boot Image时没有bit文件可用,Bootloader没法跟配置流打包。
3. Bootloader代码这样写才不容易翻车
3.1 代码总体逻辑
Bootloader的代码逻辑并不复杂,核心就三件事:初始化外设、读Flash、跳转。下面是一段可以直接参考的简化C代码,跑在SDK裸机环境里:
#include "xilisf.h" #include "xparameters.h" #include "xil_printf.h" #define FLASH_APP_OFFSET 0x00200000 #define FLASH_READ_LENGTH 0x00400000 #define APP_LOAD_ADDRESS 0x80000000 typedef void (*app_entry_fn)(void); int main(void) { volatile int status; app_entry_fn app_entry; xil_printf("MicroBlaze Bootloader Start...\r\n"); status = XIsf_Initialize(XPAR_CPU_MICROBLAZE_CLOCK_FREQ_HZ, 25000000, 0); if (status != XST_SUCCESS) { xil_printf("Flash init failed: %d\r\n", status); return -1; } xil_printf("Flash init success, loading app...\r\n"); status = XIsf_Read(FLASH_APP_OFFSET, (u32 *)APP_LOAD_ADDRESS, FLASH_READ_LENGTH); if (status != XST_SUCCESS) { xil_printf("Flash read failed: %d\r\n", status); return -1; } xil_printf("App loaded, jumping to 0x%08x...\r\n", APP_LOAD_ADDRESS); app_entry = (app_entry_fn)APP_LOAD_ADDRESS; app_entry(); return 0; }这里用SDK自带的xilisf库操作QSPI Flash,XIsf_Initialize负责初始化SPI控制器和Flash,XIsf_Read从指定偏移读取指定长度的数据到目标内存。这个库的好处是内部已经处理了Flash命令序列,不用你自己拼SPI指令。需要注意:不同SDK版本中,XIsf的函数签名可能略有差异,编译报错时去xilisf.h里对照一下原型即可。
3.2 跳转前的关键准备动作
很多人在读Flash这步都顺利,最后挂在“跳转”上。跳转这事有几个细节值得单独拿出来说。
第一,如果应用跑在DDR里,跳转前必须确保DDR已经被正确初始化。DDR初始化通常由SDK的BSP启动代码完成,但问题是:Bootloader工程和应用工程是独立的两个elf,应用工程上电后并没有重新初始化DDR的权利——如果Bootloader跳转前不把DDR跑好了,应用一执行就访问非法内存,立刻跑飞。
解决方案:在Bootloader里手动初始化DDR,或者更简单的方式是用复用设计——Bootloader工程里include DDR的初始化代码。Xilinx提供了内存测试代码,也可以直接在Bootloader里调用XDdrps_Init等DDR控制器驱动。如果使用MIG IP,则调用DDR3_Init,具体函数名以生成的IP驱动为准。这个小动作能避免大量莫名崩溃。
第二,跳转前要关中断。如果Bootloader使能了全局中断,跳转时中断向量表、中断控制器状态可能残留,应用还没来得及重定向中断向量,一个定时器中断就能让系统挂掉。跳转前调用microblaze_disable_interrupts()和microblaze_disable_exceptions(),等应用自己做中断初始化。
第三,如果开启了指令Cache或数据Cache,跳转前要确保Cache干净。一般建议在跳转前直接Xil_ICacheDisable()和Xil_DCacheDisable(),省得脏数据把应用踩了。简单粗暴,但有效。
3.3 链接脚本:Bootloader和应用的地址边界
链接脚本是Bootloader方案里最容易搞乱的地方。我用两张表总结一下两个工程的段地址要求:
| 工程 | 运行位置 | 向量表地址 | 堆栈地址 |
|---|---|---|---|
| Bootloader | BRAM 0x0 | 0x0 | 0x1F000(BRAM顶部往下) |
| Application | DDR 0x80000000 | 0x80000000 | DDR内部 |
Bootloader的链接脚本,默认就会生成在BRAM范围,通常不需要大改。你要确认的是.text、.rodata、.data、.bss这些段都落在那一段BRAM地址区间内。
Application的链接脚本需要主动修改。SDK生成的lscript.ld默认把代码放在0x0(和Bootloader冲突),必须改成DDR基地址。打开应用的lscript.ld,找到内存区域定义:
PROCESSOR "microblaze_0" _Stack_start = __stack; _MEMORY_BASE = 0x80000000; _MEMORY_SIZE = 0x10000000; MEMORY { DDR : ORIGIN = 0x80000000, LENGTH = 0x10000000 }同时把_vectors_start、_vectors_end和向量段也指向DDR首地址:
_vectors_start = 0x80000000; _vectors_end = 0x80000000 + 0x400;这样应用被Bootloader加载到DDR后,复位向量、异常向量和普通代码都在一个连续地址空间里,跳转过去直接开跑,不会因为向量表残留跑飞。
4. 生成BOOT.bin并烧写固化的完整流程
4.1 编译Bootloader与应用工程
在Vivado里导出硬件并启动SDK(2021.1里叫Vitis IDE)以后,先新建两个BSP工程和一个应用工程。我的习惯是建一个空的应用工程作为Bootloader,再建另一个应用工程作为Application。
Bootloader工程把3.1节那段代码直接放进去编译,Application工程编译出你的实际业务代码。两个工程都要保证编译零错误,二进制体积检查一下:Bootloader elf如果超过BRAM容量,链接时会有明确报错。
这里有个实用小技巧:Application的BSP配置里可以关闭stdin/stdout,减少不需要的外设驱动,防止链接时把用不到的库函数也编进去,尽量缩小镜像体积。Bootloader侧则不要开太多额外驱动,够用就好。
4.2 使用Vitis Create Boot Image打包
在Vitis里选择菜单Xilinx > Create Boot Image,打开创建启动镜像向导。首先要选择Boot Mode,不同类型Flash对应不同模式,常见的SPI Nor Flash选qspi-x1或qspi-x4。第一次做建议选qspi-x1,四模式等x1跑通后再优化。
在Boot Image Partitions列表里,按顺序添加:
- Bootloader分区:选择
bootloader.elf,这是BRAM里跑的引导代码。 - 配置分区(可选):如果设计里没有独立配置源,把
system.bit一起打进去。 - 应用分区:选择
application.elf,这个是Bootloader要加载到DDR的目标程序。
生成后得到BOOT.bin。如果不想用图形界面,也可以直接写BIF文件用bootgen命令行:
the_ROM_image: { [bootloader]C:/projects/boot/export/boot/bootloader.elf [configuration]C:/projects/board/board.runs/impl_1/system.bit [destination_cpu=primary]C:/projects/app/export/app/application.elf }命令行生成:
bootgen -image boot.bif -o BOOT.bin -w onBootgen会自动把bootloader放进BRAM初始化数据,把配置流和应用数据按分区写到对应偏移。如果你的Flash里还需要保留其他数据区,可以额外添加[offset]分区,或者后续用单独方式烧写。
4.3 把BOOT.bin烧进SPI Flash
有几种烧写方式,我推荐用Vitis的Xilinx > Program Flash Memory向导。按照向导选择BOOT.bin,Flash偏移填0x0,Flash类型选择对应型号,其余保持默认即可。
如果你的板卡需要把MCS文件交给产线烧录,也可以用Vivado的write_cfgmem命令在Tcl Console里生成MCS:
write_cfgmem -format mcs -size 64 -interface spix4 \ -loadbit "up 0x0 ./BOOT.bin" \ -file ./boot_flash.mcs烧录完成以后,不要急着庆祝。把板卡的启动模式跳线切换到SPI Flash启动,断开JTAG,重新上电,用串口观察Bootloader是否打印启动信息。正常情况下串口会输出“MicroBlaze Bootloader Start...”,然后“Flash init success, loading app...”,最后“App loaded, jumping to 0x80000000...”。看到这些,说明整条链路已经通了。
4.4 固化失败后的排查思路
如果上电后串口完全没有输出,第一步检查FPGA配置本身是否成功。用示波器观察Flash CS引脚,上电瞬间应该有一系列片选脉冲,如果没有,说明FPGA根本没在读取Flash,问题出在启动模式跳线或bit配置阶段。
如果FPGA配置成功但CPU没有跑Bootloader,多半是BRAM里没有正确加载Bootloader数据。这时用Vivado Hardware Manager回读Flash内容,检查Flash起始地址是不是有有效数据,甚至直接检查bit流里BRAM初始化部分是不是空的。也可以把BOOT.bin重新下载到板卡里用JTAG辅助排查。
5. 常见问题与避坑实录:来自真实踩坑现场
5.1 典型问题速查表
| 现象 | 根本原因 | 处理办法 |
|---|---|---|
| 上电串口无输出 | FPGA没从Flash配置成功 | 检查启动模式跳线、Flash CS引脚时序 |
| 串口只打印Bootloader Start,后续无输出 | Flash初始化失败或读取失败 | 检查SPI时钟频率、Flash型号、Flash偏移 |
| Flash读取全是0xFF | SPI通信异常 | 降低SPI时钟、检查MISO/MOSI接线、确认Flash供电 |
| 加载完成但跳转后跑飞 | DDR未初始化、向量表地址错、Cache脏数据 | 跳转前初始化DDR、检查应用链接脚本地址 |
| JTAG调试正常,固化后就跑不了 | Bootloop覆盖BRAM初始内容 | 关闭MicroBlaze的Boot Loop选项,重新打包 |
| 更新Flash后旧程序还在 | 烧写偏移覆盖错了分区 | 确认-loadbit/烧写偏移是0x0,不是应用偏移 |
5.2 讲几个我亲身掉进去的坑
先说Flash读回全FF这个。我最早做Bootloader时,板子上的Flash是W25Q128,我按默认设置把SPI时钟跑到了50MHz,结果XIsf_Read读回来的全是FF。排查了大半天,最后发现是Flash数据手册上最快支持50MHz的Quad模式,但在x1模式下最高频率要低一些,去掉余量后25MHz非常稳定。从那以后,SPI时钟我一律从低开始往上试,绝不跟Flash时序较劲。
第二个坑是跳转后跑飞的问题。那会儿我用的是带DDR的设计,Bootloader把应用从Flash读到了DDR,但跳转前完全没有做DDR验证。应用一跑起来,访问DDR里的全局变量就挂。后来在Bootloader里加了一段简单的DDR读写测试,比如往某个地址写一组数再读回来比对,确认DDR没问题再跳转,问题立刻消失。
第三个坑跟向量表有关。应用本来是能跑的,但我把它的lscript.ld改完地址以后,忘了同时修改_vectors_start,结果应用代码在DDR里,向量表却还留在残留的BRAM区。跳转过去以后,一旦发生中断,CPU直接去旧的向量表取地址,取出来一个随机地址,立刻崩。确认过眼神,绝对是链接脚本漏改向量段。
5.3 一个帮助你快速定位问题的小技巧
我建议在Bootloader里多打印几个关键信息:Flash ID、待加载长度、目标地址,加载完成后再打印一个校验值。比如每次加载完应用,把数据区的前32字节和最后一行的16字节用十六进制打印出来,跟生成的app.bin对比,能立刻判断是Flash内容写错,还是读取过程丢了数据。不要嫌打印啰嗦,Bootloader阶段的可见性是整个开发链条里最稀缺的。
这个习惯帮我节省了至少半天调试时间。有一次打印发现前半段数据正确、后半段全是FF,一查是FLASH_READ_LENGTH填大了,超出了实际影音长度,读到了Flash的空闲区域。改成实际大小后一切正常。
6. 从Bootloader到OTA:这不是终点,是起点
6.1 为什么搞定了Bootloader就等于拿到OTA的门票
Bootloader真正的价值在OTA(在线升级)。一旦你有了一个稳定的Bootloader,升级应用就变成“把新镜像写到Flash的指定分区,复位让Bootloader加载新镜像”这么简单,不需要接JTAG,不需要重新烧FPGA配置,甚至可以通过网络或串口传镜像。
我见过的FPGA产品升级方案大都是这个套路:运行中的应用(AppA)收到完整的新版本镜像后,先写入Flash的空闲区域(比如0x800000之后的AppB分区),写入完成后通过标志位标记“下一次启动使用新镜像”,然后软复位。Bootloader在启动时检查标志位,决定加载AppA还是AppB。这种A/B分区方案还带来一个额外好处:如果新镜像启动失败,Bootloader可以检测到超时自动回滚到旧版本,产品升级风险大大降低。
6.2 给新手的扩展建议
如果你打算把Bootloader扩展成OTA系统,有几点要提前计划:
- 建立一个统一的镜像头格式,把镜像版本、长度、CRC32校验放在前面固定位置,这样Bootloader可以先校验再加载,避免启动坏镜像。
- Flash分区要留足够余量。A/B分区方案下至少需要三个区域:Bootloader区、AppA区、AppB区,每区大小要大于最大镜像体积。
- 保证每次写入Flash的动作是原子的。写一半掉电怎么办?建议先写入临时区,整体写完以后通过一个很小的标志区域原子切换启动目标,别让启动标志跟着镜像一起写。
6.3 最后再说一点经验
我第一次调通MicroBlaze Bootloader的时候,最长的一件事不是写代码,而是把“Bootloop占位”“BOOT.bin分区”“BRAM初始化数据”“链接脚本地址”“Flash偏移”这五件事在脑子里彻底串起来。串起来以后你会发现,这套东西跟单片机圈常说的STM32 Bootloader本质是一个套路,只是FPGA这边多了bit流的参与,看着复杂,实际也就那么回事。
新手照着本文做的时候,建议遵循三条原则:第一,SPI时钟从低调起;第二,改链接脚本前先备份;第三,每次只改一个变量,确认稳定后再动下一个。能把这三条守住,MicroBlaze Bootloader这条路你走起来会顺畅很多。