简介面向Zynq 7020开发板的裸机UART在线升级方案可运行源码适合嵌入式开发者、FPGA工程师以及需要在Zynq平台实现固件远程升级的工程师参考。方案重点解决升级失败导致的板砖问题完整覆盖Multiboot启动回退机制、升级标志位判定、CRC16数据校验、串口初始化与中断处理以及Flash写入与校验流程可帮助读者搭建一套健壮的串口升级框架。压缩包共3个文件包含代码工程入口、HTML说明文档及gitignore过滤配置整体仅6KB属于轻量级参考实现。资源目前已有333人学习适合具备一定Zynq裸机开发基础的读者直接查看源码并移植到自己的工程中。除了代码外还提供了升级前后开机校验的两种方式并后续可衔接网口在线升级方向具备较好的扩展参考价值。1. Zynq裸机UART升级没有JTAG时固件更新全靠这条串口做Zynq裸机开发的人迟早会遇上同一个尴尬样机到手JTAG下载线只焊了一块SD卡槽也没引出FPGA侧的镜像和Arm侧的裸机程序却要频繁改。某次调试中我直接把Zynq的Boot Mode拨到UART用一根USB转串口线就把整个BOOT.bin灌进了板子省掉了来回插拔下载器的动作。这就是Zynq裸机UART升级方案的实用场景通过BootROM内置的UART启动模式把FSBL、应用程序和FPGA位流打包成一份镜像走串口完成脱机升级。它解决的是没有JTAG、不想拆机、又需要更新固件的痛点。适合正在做Zynq-7000系列裸机开发、想给产品加一个可靠升级通道的软硬件工程师。这套源码包不是玄学BootROM对UART的支持是芯片出厂自带的关键是搞清楚启动链路上每个环节的职责。2. 为什么选UART升级通道的四种方案与BootROM启动链路2.1 四条升级通道的横向对比JTAG、SD卡、QSPI、UARTZynq裸机环境的固件升级通道不是越多越好是越符合现场条件越好。我习惯先把四条候选通道放在同一张表里对比再做决定通道硬件成本现场可操作性速度典型瓶颈JTAG需下载器成本约几十到几百要拆机接排针不适合批量快接口占用、线缆接触不良SD卡板上有卡槽硬件几乎零成本需要拔卡读写外壳密封场景直接出局中卡座寿命、文件系统兼容性QSPI NOR Flash板上已有零额外成本自更新有风险需引导程序支持中擦写寿命、坏块管理UART一根USB转串口线只要引出TX/RX/GND即可外壳开孔小慢波特率、流控、帧校验看完表就明白UART不是性能最好的方案而是“从任何角度都能摸得到”的方案。做工业设备时面板上往往只有一个调试串口JTAG头被金属壳盖住SD卡槽也完全封死反过来看串口是调试阶段必然引出的信号。升级速度慢的问题可以在51200bps以上波特率加帧压缩缓解而升级失败导致的“变砖风险”则要靠分区回滚来兜底这两件事后面都会讲到。2.2 BootROM的UART启动过程与BOOT.bin三段镜像Zynq芯片上电后Cortex-A9核从片内BootROM开始执行。BootROM做的事情非常固定采样Boot Mode引脚确定启动源然后从该源读取启动镜像的前8KB到片内OCM校验头部后跳转。当Boot Mode被配置为UART时BootROM会等待串口端发来的启动数据读取到完整镜像后放入DDR内存并跳转执行。整条启动链路涉及三段镜像缺一不可FSBLFirst Stage Boot Loader初始化DDR、PLL、MIO引脚转发BootROM传入的镜像句柄然后加载后续分区。FSBL是升级方案里真正要改代码的部分。FPGA位流可选的FPGA配置数据FSBL通过PCAP接口写入配置寄存器。应用裸机程序用户AppFSBL把它从启动介质拷到DDR指定地址然后移交控制权。三者被bootgen工具按分区方式打包成BOOT.binBootROM只认这个格式。镜像头部有一个固定偏移的image header table里面的分区起始地址和长度字段决定了FSBL能不能正确找到App。网上很多人改完FSBL后App起不来十有八九是分区地址对不上后面避坑章节会专门展开。2.3 定制FSBL而非写独立Bootloader裸机场景下的务实选择有人会问既然要做UART升级为什么不直接写一个裸机Bootloader替换掉整个FSBL我一开始也这么想实际做下来发现没必要。FSBL源码是Xilinx以库形式提供的里面已经覆盖了寄存器初始化、DDR训练、PCAP下载位流这些繁琐且板级相关的逻辑自己重写一套光DDR参数调稳定就要耗掉数周。正确做法是保留FSBL的启动初始化流程在跳转App之前插入一段“升级命令检测”逻辑FSBL完成DDR和时钟初始化。轮询串口指定时间比如2秒等待上位机发送升级握手帧。如果收到合法握手帧则进入UART升级模式接收新镜像数据。校验通过后擦写QSPI Flash写完后复位重启。如果超时未收到握手帧按正常流程引导App。这套逻辑不是颠覆FSBL而是给它加一个旁路分支。裸机环境没有操作系统调度FSBL里插入的这段代码只要不修改原有的初始化顺序风险就可控。我一般会把升级代码单独放到一个目录用编译宏控制是否编入这样量产固件和调试固件可以共用一份FSBL工程。3. 工程结构与关键实现从镜像解析到串口写Flash3.1 源码包目录布局与构建入口拿到这套UART升级源码包后首先要把工程结构摸清楚。目录设计遵循“引导程序、传输协议、烧写驱动、上位机”四层分离原则避免所有代码堆在main.c里uart_upgrade/ ├── fsbl_src/ # 定制FSBL源码 │ ├── main.c # FSBL入口增加升级分支 │ ├── uart_loader.c # 串口帧解析与握手状态机 │ ├── qspi_writer.c # QSPI擦写驱动含flash ID识别 │ ├── crc32.c # 校验算法对应上位机 │ └── xparameters.h # 板级地址映射按实际硬件修改 ├── app_demo/ # 裸机应用示例仅用于验证跳转 ├── host_tool/ # 上位机Python脚本 │ ├── uart_upgrade.py # 主控脚本负责发包和状态显示 │ └── pack_image.py # 将FSBLbitapp打包为升级格式 ├── scripts/ │ ├── build_fsbl.sh # 一键编译定制FSBL │ └── gen_boot.bat # Windows下生成BOOT.bin的批处理 └── doc/ └── 分区规划.md # QSPI地址映射与升级流程说明目录分好后构建顺序也就定了先用SDK编译定制FSBL得到fsbl.elf然后用bootgen把fsbl.elf、bitstream、app.elf打包为BOOT.bin再通过pack_image.py给BOOT.bin加上帧序号和CRC生成最终的升级包。工程里QSPI分区规划通常是起始地址0x0放恢复用FSBL0x200000放主分区BOOT.bin0x400000放App区最后留一个升级缓存区。这个布局的好处是即使升级中断复位后还能从恢复FSBL启动进入升级模式回滚有后路。3.2 定制FSBL中升级命令队列与状态机代码FSBL里新增的UART升级代码核心是一个带超时窗口的命令状态机。代码逻辑不复杂但状态转移必须严格空闲态收到握手帧进入接收态接收态收满一帧进入写Flash态写完后回到握手等待状态。下面这段是uart_loader.c中状态机的主循环骨架/* uart_loader.c - FSBL内的UART升级状态机 */ typedef enum { LOADER_IDLE, /* 等待握手帧 */ LOADER_RECEIVING, /* 接收升级数据帧 */ LOADER_WRITING, /* 擦写QSPI */ LOADER_VERIFY, /* 回读校验 */ LOADER_DONE /* 升级完成复位 */ } loader_state_t; int uart_loader_poll(u32 uart_base, u32 flash_base) { loader_state_t state LOADER_IDLE; u32 frame_count 0; u8 buffer[512]; u32 timeout 0; while (1) { switch (state) { case LOADER_IDLE: /* 上位机连续发送5字节握手信号 UART_UP */ if (uart_rx_bytes(uart_base, buffer, 5) 5 buffer[0]U buffer[1]A buffer[2]R buffer[3]T buffer[4]0x55) { uart_send_ack(uart_base, 0xA5); state LOADER_RECEIVING; frame_count 0; } if (timeout SYSTEM_TICKS_2S) return 0; /* 超时走正常启动 */ break; case LOADER_RECEIVING: /* 帧格式: 2字节帧头 2字节长度 数据 4字节CRC */ if (uart_rx_frame(uart_base, buffer, sizeof(buffer)) FRAME_OK) { qspi_buffer_write(flash_base frame_count * SECTOR_SIZE, buffer, uart_frame_payload_len); frame_count; uart_send_ack(uart_base, 0x5A); /* 每帧应答 */ } if (uart_rx_eof(uart_base)) { state LOADER_VERIFY; } break; case LOADER_VERIFY: /* 回读比较防止擦写过程中的位翻转 */ if (qspi_verify(flash_base, expected_crc) 0) { uart_send_ack(uart_base, 0xF0); state LOADER_DONE; } else { uart_send_nack(uart_base, 0x0F); state LOADER_IDLE; /* 校验失败重回握手 */ } break; case LOADER_DONE: /* 触发软件复位走QSPI启动新镜像 */ arm_soft_reset(); break; } } return 0; }这段代码里有几个参数需要根据硬件调整uart_base是UART1或UART2的寄存器基址在xparameters.h里已经定义flash_base是QSPI的起始地址要和bootgen打包时设置的分区地址严格一致否则FSBL会跳到空白区域变成“跑飞”buffer数组大小512字节对应一帧最大负载上位机发包时也要切成同样的尺寸。uart_rx_frame和qspi_buffer_write是工程内封装好的驱动函数内部处理了超时重传和Flash忙等待。这里没有用中断接收是因为FSBL运行阶段中断向量表还没完全配好轮询加超时反而是最可控的。3.3 上位机打包与下载脚本帧格式对齐是契约上位机Python脚本承担两个职责打包升级镜像和维护传输协议。传输协议必须和FSBL里的帧解析严格一致任何一位偏差都会导致握手失败或CRC报错。下面是pack_image.py的核心片段#!/usr/bin/env python3 # pack_image.py - 把BOOT.bin封装为升级帧序列 import struct import zlib import sys import os # 帧格式: 0xAA55 2字节长度 payload 4字节CRC32(整个帧) def pack_frame(payload: bytes) - bytes: header b\xAA\x55 length len(payload).to_bytes(2, little) crc zlib.crc32(header length payload) 0xFFFFFFFF return header length payload struct.pack(I, crc) def main(): boot_path sys.argv[1] # 输入BOOT.bin out_path sys.argv[2] # 输出升级包 with open(boot_path, rb) as f: data f.read() # 分割为最大512字节帧 chunk_size 512 frames [data[i:ichunk_size] for i in range(0, len(data), chunk_size)] with open(out_path, wb) as f: # 先写帧总数(4字节)再顺序写各帧 f.write(struct.pack(I, len(frames))) for frame in frames: f.write(pack_frame(frame)) print(f生成{len(frames)}帧总大小{len(data)}字节) if __name__ __main__: main()这段脚本有两点要说明帧头固定为0xAA55FSBL端通过这个模式判断一帧开始CRC32用zlib计算覆盖范围从帧头到payload不包括CRC本身两端校验算法必须一致。分帧大小512字节并不是随意定的QSPI Flash的页大小通常是256字节512是以页为单位的整数倍写驱动时可以做跨页优化同时串口缓冲区开512字节也不会撑爆OCM内存。实际使用时文件分片后的最后一帧往往不足512字节长度字段处理了这个边界FSBL端解析时要特别判断否则会多读空数据进Flash。4. 从烧录到验证完整复现UART升级流程4.1 硬件准备与Boot Mode引脚拨法开始操作前板级接线必须先确认。你需要USB转UART模块、杜邦线三根、以及确认板上Boot Mode引脚的电平定义。Zynq的Boot Mode由MIO[5:0]引脚控制不同板卡丝印标注不同但逻辑一致——要进入UART启动模式一般设置Boot Mode为00110或类似组合对应UART1。常见做法是把BOOT_MODE引脚从SD/QSPI模式拨到UART模式然后复位板卡。拨法没有统一标准必须查自己板卡的电路图这步如果错了BootROM根本不会进串口接收状态下面所有操作都白搭。我一般习惯在拨完Boot Mode后先不接串口线给板上电用示波器或者逻辑分析仪看一眼UART TX引脚有没有输出——BootROM在等待握手时不会主动发数据但进入UART模式后TX可能会有一个电平变化。这里只做参考真正可靠的验证手段还是接上串口线给上位机脚本发送握手帧看板卡是否回复ACK。4.2 一键编译定制FSBL并生成BOOT.bin源码包里的build_fsbl.sh是给Linux环境用的Windows下用批处理调SDK命令也行。核心构建步骤就三步编译FSBL的C源码为elf文件编译App为elf文件用bootgen将三者打包。对应脚本如下#!/usr/bin/env bash # build_fsbl.sh - 编译定制FSBL并打包BOOT.bin export XILINX_SDK/opt/Xilinx/SDK/xxxx # 替换为自己的SDK安装路径 export ARCHaarch64 export CROSS_COMPILE${XILINX_SDK}/gnu/aarch32/nt/gcc-arm-none-eabi/bin/arm-none-eabi- # 1. 编译定制FSBL arm-none-eabi-gcc -c uart_loader.c -o uart_loader.o -I./include \ -mcpucortex-a9 -mthumb -O2 arm-none-eabi-gcc -c main.c -o main.o -I./include \ -mcpucortex-a9 -mthumb -O2 arm-none-eabi-ld -T fsbl.ld main.o uart_loader.o qspi_writer.o \ crc32.o -o fsbl.elf # 2. 编译裸机应用App arm-none-eabi-gcc -c app_demo.c -o app_demo.o -I./include \ -mcpucortex-a9 -mthumb -O2 arm-none-eabi-ld -T app.ld app_demo.o -o app.elf # 3. bootgen打包fsbl bitstream app bootgen -image boot.bif -o BOOT.bin -w on命令里的关键点-mcpucortex-a9必须匹配Zynq-7000的CPU核否则生成的指令在A9上执行会有未定义行为-mthumb与否要看SDK默认设置FSBL官方工程默认使用ARM模式如果混用会在链接时报错或运行时异常。boot.bif文件里除了列出三个分区文件还要写明每个分区的起始地址例如the_ROM_image: { [bootloader] fsbl.elf bitstream.bit [offset 0x200000] app.elf }这个[offset 0x200000]就是App在BOOT.bin里的偏移QSPI烧写时App区间会对应到Flash地址0x200000必须和uart_loader.c里的flash_base设计一致。如果这里不一致FSBL升级写Flash时把App写到了别的地址重启后BootROM按分区表找不到有效镜像板子就“静默变砖”了。4.3 一次完整升级过程的操作序列假设硬件已经接好、Boot Mode已经拨到UART模式。升级操作总共六步复位板卡按住复位键不放。在PC端运行上位机脚本先发握手信号等待板卡ACK。看到ACK后松开复位键板卡进入UART升级等待状态。上位机脚本开始逐帧发送升级包每帧等待ACK超时重发。全部发完后板卡回读QSPI校验校验通过返回0xF0完成码。板卡自动复位走QSPI启动新镜像正常运行。这一步里最容易被忽略的是“按住复位键等握手”的时序。BootROM上电后只在极短窗口内等待UART数据如果先复位后打脚本BootROM可能已经超时退出UART模式了。实操中我用脚本先发送握手帧再通过脚本控制的串口DTR引脚去触发复位时序完全自动对齐。源码包里host_tool/uart_upgrade.py就实现了这个联动# uart_upgrade.py - 先发握手后拉复位避免时序竞争 import serial import time ser serial.Serial(COM3, 115200, timeout1) # 1. 先置低复位引脚(DTR)让板卡停在复位状态 ser.setDTR(True) time.sleep(0.1) # 2. 发送握手帧等待板上进入UART升级状态 ser.write(bUART\x55) ack ser.read(1) if ack ! b\xA5: raise RuntimeError(握手失败检查Boot Mode和接线) # 3. 释放复位(DTR拉高)BootROM此刻已经开始接收数据 ser.setDTR(False) # 4. 逐帧发送升级包 # ...省略后续帧发送和ACK等待逻辑...这段脚本里的时序设计值得抄作业DTR先拉高、发完握手再释放复位是为了让BootROM从复位沿之后的第一个串口字符开始接收保证握手信号不会因为复位时序错过。实际操作中不同板卡的复位逻辑可能高低电平相反脚本里用setDTR(True)只是示意要根据串口模块的RTS/DTR定义调整极性。5. 避坑指南与常见问题排查五个典型翻车现场5.1 现象Boot Mode拨到UART后上位机发握手帧无任何ACK原因排查过很多次绝大多数不是代码问题而是Boot Mode引脚根本没被采样到。某块板子把Boot Mode引脚接到一个三态缓冲器上上电瞬间缓冲器未使能引脚呈高阻态BootROM采样到错误电平直接走了QSPI启动。解决方法是检查板上Boot Mode引脚是否被外部器件驱动或拉低必要时用万用表量一下引脚电平同时确认复位键按下时电源域是否稳定如果复位期间电源毛刺导致FSBL没来得及进串口状态也会出现无ACK。5.2 现象升级包发送完成回读校验也通过但板卡重启后卡死在DDR初始化这个问题第一次遇到时很头大因为校验通过意味着Flash里的数据是对的那问题就出在FSBL本身参数与目标板DDR颗粒不匹配。FSBL的DDR初始化参数比如CAS延迟、突发长度、时序参数是在SDK里根据特定DDR颗粒生成的。如果板卡换了DDR型号但没重新生成FSBLDDR训练失败就会导致FSBL卡死在内存测试环节。解决方法是重新在SDK里为当前板卡生成FSBL参数或者使用源码包里默认的DDR配置时先和板卡DDR芯片丝印对照确认频率和容量一致。这套升级方案在硬件更换DDR型号后一定要连带更新定制FSBL不能只换App。5.3 现象传输大镜像文件时发送到一半就报CRC错误UART升级传大文件容易在传输中段翻车最常见原因是串口发送端与接收端波特率存在微小偏差。当传输数据量超过几MB后累积误差导致采样点偏移某一帧的CRC就开始不过。另外USB转串口模块的缓冲区溢出也会丢字节表现为CRC报错而不是ACK超时。我解决的办法是上位机脚本里每帧发送后等待ACK连续三次无响应就主动降波特率重连同时把串口模块的FIFO缓冲区调到最大关闭发送端的硬件流控。如果发的镜像超过8MB我一般会先把镜像压缩一下Zynq的App通常没有必须达到这个量级的理由。5.4 现象升级过程中误触复位板卡彻底没有任何启动打印这是最危险的场景升级文件只写了一半QSPI里的BootROM分区表指向了一个不完整的镜像复位后BootROM尝试从QSPI加载但失败没有任何输出。要解决这个变砖风险必须在方案设计阶段就规划双分区容错。具体做法是在QSPI地址0x0处放一个固定的恢复FSBL它不依赖任何升级数据永远优先从UART等待握手主分区从0x200000开始每次升级只写主分区不动恢复分区。这样即使主分区写坏复位后恢复FSBL仍能进入UART升级模式就有后悔药可吃。源码包的分区规划MD里就是按这种布局写的建议不要改。5.5 现象QSPI擦写后回读校验失败但Flash芯片型号确实在支持列表里这类问题多半是W25Q系列与Micron N25Q系列在状态寄存器指令上的差异QSPI驱动里识别JEDEC ID后要分别调用对应的擦写命令序列。如果驱动里只实现了标准4字节地址模式而实际Flash是3字节地址模式高地址区域擦写就会出错。我建议在uart_loader.c的Flash初始化函数里加一个JEDEC ID打印先确认驱动匹配再跑升级。源码包里qspi_writer.c已经包含了常见几家Flash的ID表只要确认自己板上的型号落在表内基本不会有擦写问题。6. 再进一步给升级链路加上帧序号与双bank回滚6.1 帧序号防乱序与ACK超时重传基础版升级协议每帧独立发送一旦上位机多线程处理时序抖动或者串口驱动缓冲乱序接收端帧顺序会错乱。一个低成本加固手段是在帧头后加2字节帧序号FSBL端维护一个期望序号收到帧先比对序号等于期望值才写入Flash大于期望值表示中间丢帧发送NAK要求重发小于期望值说明重复帧直接丢弃但ACK。下面是FSBL端接收帧的序号判断核心/* uart_loader.c - 帧序号校验 */ #define EXPECT_SEQ(seq) ((seq) loader_expected_seq) int on_frame_received(u8 *buf, int len) { u16 seq (buf[2] 8) | buf[3]; /* 帧序号 */ if (seq loader_expected_seq) { qspi_buffer_write(flash_addr, buf 4, len - 4); loader_expected_seq; uart_send_ack(uart_base, seq 0xFF); /* 回显帧号低字节 */ } else if (seq loader_expected_seq) { uart_send_nack(uart_base, 0xE1); /* 丢帧请求重发 */ } /* seq expected: 重复帧静默丢弃 */ }上位机收到NAK会倒退到期望帧号重新发送这样乱序问题就在协议层解决了。这里的ACK携带帧号还有一个用处上位机能实时知道板上写到哪一帧配合进度条显示长文件传输时操作者不会因为“看起来卡住”而误判。6.2 预留回滚分区与启动计数双bank回滚不只是“备份一份旧镜像”还要解决“如何判断新镜像能正常启动”这件事。常见做法是在QSPI里单独划一个状态区存放启动计数和状态标志。App启动后主动将状态标志置为成功如果App跑飞或卡死状态区里计数不更新恢复FSBL在下次启动时判定升级失败自动从备份bank加载旧镜像。代码示意/* app_demo.c - 应用启动成功后标记成功 */ int boot_status_update(int success) { u32 addr QSPI_BOOT_STATUS_ADDR; if (success) { qspi_write_word(addr, BOOT_STATUS_SUCCESS); } else { qspi_write_word(addr, BOOT_STATUS_FAIL); } return 0; }严格来说这个方案需要App里在main函数最开始就调用标记函数且不能依赖外部硬件。实际应用中还可以加一个看门狗App在正常运行过程中定期喂狗如果升级后新镜像起不来看门狗超时复位恢复FSBL检测到状态区不是SUCCESS就回滚。UART本身没有这个能力但裸机程序里做这些只需要几十行代码比起售后拆机成本这笔投入完全值得。有一次我在现场升级手滑把电源线踢掉了板上电后一点反应没有冷汗直接下来。后来靠着恢复FSBL 双bank回滚的机制重新连串口等了两分钟旧镜像就回来了。从那以后我每次给客户做固件升级方案都强制自己先把回滚路径验证一遍再谈速度优化。希望这套UART升级源码包能帮你少走那段我最开始走过的弯路拿到手后先按文档把恢复分区烧进去再折腾其他功能。本文还有配套的精品资源点击获取