ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Zynq裸机UART固件升级方案:Bootloader+QSPI双镜像设计

Zynq裸机UART固件升级方案:Bootloader+QSPI双镜像设计 简介针对Zynq 7020裸机环境下的在线升级需求这份可运行源码包提供了基于UART的完整升级方案适合FPGA与嵌入式开发者参考。方案重点讲解了Multiboot防板砖机制升级失败时能够安全回退到旧固件同时通过标志位判断升级是否成功并在接收数据时引入CRC16校验确保固件传输的完整性与准确性。代码部分覆盖串口初始化、中断处理、Flash写入与校验流程配合开机后的两种校验方式帮助开发者建立一套从下载到启动验证的闭环升级链路。资源共3个文件以inscode工程文件、html说明文档和gitignore配置文件为主整体仅6KB结构精简可直接导入工程查看便于快速定位核心逻辑。目前已有298人学习对于希望避免升级“板砖”风险、深入理解Zynq裸机固件更新机制的开发者来说这份源码具有直接的参考价值也为后续转向网口在线升级提供了基础。 做Zynq裸机开发的兄弟应该都有同感调板子的时候JTAG一路调试别提多爽可一旦板子发到现场、或者到了产线要批量烧录JTAG线就不是那么回事了。现场工程师手边最不缺的就是USB转串口而Zynq的PS端UART又是每个板子都会引出来的口所以裸机下用UART做固件升级几乎是量产和售后绕不开的一条路。这篇文章我直接把我自己跑通的一套Zynq裸机UART升级方案拿出来讲包括整体链路怎么设计、串口协议怎么定、裸机端代码怎么写、上位机用什么工具发镜像还有我实际踩过的一些坑。方案核心思路是在Zynq上跑一个精简的裸机bootloader通过UART接收新的BOOT.BIN镜像写入QSPI Flash校验通过后复位启动。配套的关键代码和上位机脚本都能直接跑适合做量产烧录工具、现场固件更新这类场景。1. 为什么裸机场景也要搞UART升级1.1 现场升级的真实痛点先说个很现实的情况Zynq的启动镜像一般放在QSPI Flash或者SD卡里。开发阶段用SDK/Vitis的下载功能或者用JTAG连Xilinx的Platform Cable都能轻松把BOOT.BIN灌进去。可到了产线一台一台用JTAG下载线去烧效率就是灾难到了客户现场基本没人会扛着一台装了Vivado的电脑去给板子升级。就算让用户自己插SD卡很多设备是密封外壳根本没地方给你抠卡槽。相比之下UART基本是嵌入式设备最后兜底的外设。一个USB转串口模块几块钱任何工程师电脑上都有串口终端软件而且Zynq的PS UART不需要额外配置PL逻辑就能工作。所以一套稳定、带校验、带容错的UART升级方案在裸机项目里非常实用。1.2 Zynq启动链路决定升级方案的边界要设计升级方案得先搞清楚Zynq上电之后干了什么。简单说Zynq-7000的启动分三个阶段BootROM芯片内部固化上电先检查MIO引脚上的boot mode配置决定从QSPI、SD、NAND还是JTAG加载镜像FSBL第一阶段引导程序负责初始化DDR、PLL等然后加载应用镜像裸机应用我们自己的程序跑在DDR或者OCM里。所以我们常说的“升级Zynq固件”本质上就是把QSPI Flash或者SD卡里的BOOT.BIN替换成新版本。BootROM本身不支持“从UART写Flash”它只在特定boot mode下支持从UART把镜像加载到RAM里跑掉电就没了不能作为正式升级手段。真正落地的方案是让运行在板子上的裸机程序或者一个常驻bootloader自己去通过UART接收新镜像然后写进Flash。这个思路STM32玩IAP的人都懂Zynq裸机只是把“写Flash”的驱动换成了QSPI控制器驱动而已。2. 升级链路设计与通信协议约定2.1 方案选型应用内自升级还是独立Bootloader升级代码放在哪里是个需要先想清楚的问题。我见过两种做法应用代码自己带升级功能裸机应用里放一个串口命令解析收到升级指令后切换状态把后续数据写到Flash。优点是省事不用额外搞一个启动引导程序缺点是如果应用本身跑飞了、或者Flash里的镜像被写坏系统就没法自救。独立Bootloader分区把QSPI划分为Bootloader区、App当前区、App备份区。Bootloader启动时先检查升级标志和镜像合法性决定直接启动App还是进入升级状态。这种做法更接近商用产品的可靠性要求。我推荐第二种尤其是产品化项目。这篇文章后面讲的方案就是“常驻Bootloader 升级标志 备份区回滚”的组合代价是Flash多占一块备份空间但值得。2.2 串口帧格式和校验策略UART升级如果不定义协议基本等于裸奔。串口传输出错概率比很多人想象的高尤其波特率拉高之后随便一根劣质杜邦线就能造成随机丢字节。我的帧格式定义如下字段长度说明MAGIC4字节帧头标记固定0xA5A55A5A用于字节同步SEQ2字节帧序号从0递增用于丢帧检测LEN2字节数据区长度最大1024字节CRC324字节对SEQLEN数据区做CRC32校验DATAn字节镜像数据块帧头用4字节魔数是为了在乱序字节流里快速找回同步点。CRC32相比简单累加校验能检出更多类型的错误比如字节丢失、错序、连续多位翻转。在裸机端算CRC32开销完全可接受数据区最长1024字节约一微秒级别如果CPU跑到几百兆。接收端每收到一帧校验通过就回复一个ACK否则回复NAK上位机收到NAK或者超时没收到ACK就重发连续3次失败则终止升级。2.3 双镜像分区和升级标志设计QSPI Flash容量按16MB来举例分区可以这样切区域起始地址大小内容Bootloader区0x00000001MB常驻裸机升级程序App当前区0x1000006MB当前运行的应用镜像App备份区0x7000006MB上一次运行正常的镜像备份标志区0xD0000016KB升级标志、镜像CRC、启动计次每次升级流程是Bootloader收到完整的新镜像先写入App备份区写完后读回校验。校验通过把标志区的“待切换标志”置位然后复位。下一次Bootloader启动时发现待切换标志先擦除App当前区把备份区的新镜像搬到当前区再搬运后的镜像做CRC校验通过后清除标志引导启动。如果清了标志却发现当前区镜像依然非法就回到备份区启动。这种结构看着绕但好处是任何一步掉电最坏情况只是“当前区镜像损坏备份区依然是旧版本”机器还能跑。真正的产品不能接受一次升级失败就变砖需要返厂。3. 裸机端代码实现3.1 串口和QSPI外设初始化要点裸机端我用的是Xilinx Vitis里自带的驱动库UART用XUartPs驱动QSPI用XQspiPs驱动。初始化UART的代码骨架如下#include xuartps.h #include xparameters.h #include xqspips.h XUartPs UartInst; XQspiPs QspiInst; static int uart_init(void) { XUartPs_Config *cfg; cfg XUartPs_LookupConfig(XPAR_XUARTPS_0_DEVICE_ID); if (!cfg) return -1; if (XUartPs_CfgInitialize(UartInst, cfg, cfg-BaseAddress) ! XST_SUCCESS) { return -1; } XUartPs_SetBaudRate(UartInst, 115200); XUartPs_SetOperMode(UartInst, XUARTPS_OPER_MODE_NORMAL); return 0; }两个细节容易踩坑。第一个是PS的UART时钟频率硬件工程里设置的UART输入时钟会影响波特率分频芯片手册要求的波特率误差窗口很小如果发现串口输出乱码先查时钟源配置而不是马上怀疑代码。第二个是中断方式115200波特率下1字节约86微秒PS UART的FIFO有64字节如果每收一个字节就进一次中断CPU被中断打死的概率很大。我建议使用FIFO触发中断设置成FIFO满16字节或者超时未满才触发接收。我在工程里已经预留了这种批量读取接口比逐字节处理稳定太多。3.2 升级主状态机裸机端不能像上位机那样用阻塞循环简单收发因为还要同时处理看门狗、超时计时、Flash等待这类事情。我的状态机如下typedef enum { STATE_IDLE, // 空闲等待升级指令 STATE_READY, // 已进入升级模式 STATE_RECV_FRAME, // 接收帧 STATE_WRITE_FLASH, // 写入QSPI STATE_VERIFY, // 回读校验 STATE_DONE // 完成复位 } upgrade_state_t; static upgrade_state_t state STATE_IDLE;整个逻辑大致是Bootloader串口收到特定的握手命令比如“UPGRADE\r\n”后进入STATE_READY之后每个循环从UART FIFO读数据拼帧、解帧、校验CRC校验通过后再根据帧序号组装成完整镜像存入DDR里的临时缓冲区。这里我用的是“先收完整包到DDR缓冲区再统一写Flash”的策略而不是边收边写Flash。原因是Flash频繁擦写会显著拖慢接收速度而且如果中途出现CRC错误、需要重传某一段已写坏的Flash还得重新擦得不偿失。Zynq外部DDR通常有256MB以上一个几MB的BOOT.BIN镜像放缓冲区毫无压力。3.3 QSPI正确擦写姿势写到Flash之前必须明确QSPI Flash的几个底层约束。绝大多数SPI NOR Flash扇区擦除的最小单位是4KBsub sector或64KBsector擦除只能按扇区来不能按字节。写入则受限于页大小通常是256字节一页。跨页写不行必须把数据按页切好每页发一次Page Program指令。我用XQspiPs驱动操作的代码逻辑如下简化展示核心步骤static int qspi_write_flash(u32 addr, u8 *buf, u32 len) { u32 sector_size 0x10000; // 64KB sector u32 page_size 0x100; // 256B page // 1. 按sector擦除 u32 start_sector addr / sector_size; u32 end_sector (addr len - 1) / sector_size; for (u32 s start_sector; s end_sector; s) { XQspiPs_EraseSector(QspiInst, s * sector_size, sector_size); } // 2. 按page写入 u32 off 0; while (off len) { u32 page_remain page_size - (addr off) % page_size; u32 chunk len - off; if (chunk page_remain) chunk page_remain; XQspiPs_PageProgram(QspiInst, addr off, buf[off], chunk); off chunk; } return 0; }擦写过程中一定要轮询Flash的状态寄存器等上一次擦除或写入的WIP位清零再发下一条命令。XQspiPs驱动内部虽然有轮询逻辑但某些老型号Flash或者4字节地址模式下时序要求会比较苛刻建议驱动初始化时就把模式配置成需要的地址宽度。地址宽度尤其要注意16MB以内的Flash通常3字节地址够用一旦超过16MB必须切换到4字节地址模式否则写高地址区域时会乱套。3.4 升级完成后的复位和启动镜像校验通过后Bootloader要做的最后一件事是清掉升级标志然后触发一次系统复位。Zynq的裸机复位最简单的方法是写复位控制寄存器或者直接调用XScuGic的软复位接口。更粗暴的做法是读取DDR中的启动引导镜像然后跳转过去执行但这种方式在Zynq上不如直接复位干净。我在工程里用了系统复位复位后BootROM重新走一遍启动流程Bootloader再检查标志位决定引导哪个分区这样链路最清晰。4. 上位机烧写工具实现4.1 Python串口脚本上位机工具我用Python写依赖pyserial库代码量不大但很好用。核心逻辑是读入BOOT.BIN文件按帧切片逐帧发送并等待ACKimport serial import struct import zlib import sys MAGIC 0xA5A55A5A CHUNK_SIZE 1024 ACK b\x06 NAK b\x15 def send_frame(ser, seq, data): header struct.pack(IHHI, MAGIC, seq, len(data), zlib.crc32(data) 0xffffffff) ser.write(header data) def upgrade(port, bin_file, timeout0.5, retry3): ser serial.Serial(port, 115200, timeouttimeout) with open(bin_file, rb) as f: image f.read() total_frames (len(image) CHUNK_SIZE - 1) // CHUNK_SIZE for seq in range(total_frames): chunk image[seq * CHUNK_SIZE:(seq 1) * CHUNK_SIZE] for attempt in range(retry): send_frame(ser, seq, chunk) resp ser.read(4) if resp ACK: break else: print(fframe {seq} failed after {retry} retries) sys.exit(1) if seq % 100 0: print(fprogress: {seq}/{total_frames}) ser.close() print(upgrade complete)脚本里有两个细节可以优化。第一是ACK设计成4字节定长目的就是让上位机read(4)能快速完成不会因为粘包或者TTL没对齐导致超时。第二是ACK只回一个固定字节不带帧序号有人会问这样能确认是哪一帧的ACK吗能因为UART是半双工方向上严格有序的不回复错帧确实有可能但配合超时重发机制实际测试基本不会出问题。如果追求更严格把ACK改成4字节同步字帧序号道理是一样的。4.2 波特率选型和升级耗时实测很多朋友上来就把波特率调到921600结果跑不通就怀疑方案有问题。UART的稳定性和波特率、线缆、USB转串芯片都强相关。以2MB的BOOT.BIN为例我实测的耗时大致如下波特率纯串口传输时间加上QSPI写入总时间稳定性115200约182秒约3.5分钟很稳定460800约46秒约1.2分钟稳定921600约22秒约50秒看线材和USB芯片我在自己板子上实测USB转串芯片用FT232也就是FT231X/FT232R这类921600基本没问题如果用的是十几块钱的劣质CH340模块或者板子走线特别长921600会出现偶发丢帧。现场批量烧录建议460800速度和稳定性的平衡点最好。如果非要冲921600记得把RS232收发芯片如果有的电容参数检查一遍负载电容配不对高速率眼图就会很差。5. 升级失败场景与排查实录5.1 升级到一半断电还能不能救这是我最想强调的一点。UART升级方案里“断电”是最常见也最危险的故障源。如果采用“先收完整包再写Flash”的策略传输阶段断电对Flash毫无影响只是镜像没到下次重来。最危险的是写Flash的过程中断电这时候当前区可能被擦了一半、写了一半。这就是为什么我在第2章反复强调双镜像。如果你的Flash容量和成本允许一定要留备份区。只有单镜像的情况下我的建议是不要边收边写所有数据收完、CRC全对之后再一次性擦除并写入把危险窗口压缩到最短。5.2 QSPI擦写失败和校验失败实际测试中成功写入却回读校验失败一般从这几个方向排查擦除不彻底有些Flash对扇区擦除指令要求先发写使能Write Enable0x06XQspiPs驱动通常已经处理了但如果你自己实现SPI时序千万别漏。写保护引脚QSPI Flash的WP引脚如果被拉低芯片会拒绝写入。很多板子上WP默认接了上拉但也有板子设计失误直接把WP接到了地表现为擦除、写入命令都“成功”但读回来全是0xFF。遇到这种情况用万用表量一下WP引脚电平。地址宽度前面说过的4字节地址模式有些Flash默认是3字节地址访问超过16MB地址空间时高位地址会出错。校验区域弄错上位机发的是BOOT.BINBootloader写入时偏移算错一位读回来当然对不上。我建议在帧结构里也带一个绝对地址字段而不是让Bootloader自己猜这样上位机可以灵活指定写入地址方便调试。5.3 升级后复位起不来升级成功后复位板子却没有任何反应这个问题的排查路径比较固定。第一步确认QSPI地址0处烧进去的确实是BootROM能识别的镜像格式。BOOT.BIN不是简单的裸二进制它包含Boot Header、Image Header和分区数据BootROM启动时会按固定结构去解析。如果你用bootgen生成BOOT.BIN时指定了错误的FSBL镜像或者上位机工具发送时动了字节序起不来很正常。第二步检查boot mode引脚配置。很多开发板boot mode是拨码开关如果拨到了JTAG或者SD启动模式QSPI里烧再多东西也不会从QSPI引导。第三步开机后串口如果打印了BootROM的字符比如在UART上出现乱码或者ASCII字符说明BootROM活着问题出在FSBL阶段十有八九是FSBL里DDR初始化时序和新板子不匹配。5.4 串口乱码和丢帧老生常谈的问题但我还是把它列出来因为几乎每次帮人排查都会遇到。串口乱码首先查波特率其次是查硬件收发脚是否接反再查共地问题。UART是异步通信收发双方必须共地否则电平参考点不一致高速率下必然出错。丢帧问题要重点看中断处理。我前面提到过FIFO批量读取如果代码里是每字节中断一次波特率上到460800之后肯定有丢帧因为中断响应和进出栈的代价已经接近一个字节的传输时间了。把接收改成FIFO触发加批量读取丢帧问题基本消失。6. 一些个人心得这套方案我最早是在一个量产项目上落地的前后调了大概两周。回头看最核心的设计决策其实是“先收完整包再写Flash”和“双镜像备份”这两点直接把变砖概率降到了极低。调试过程中我还养成了一个习惯每次升级前先通过串口打印当前固件的版本号和CRC和上位机上要发的BOOT.BIN互相对一遍避免拿错镜像文件浪费二十分钟。另外如果产品后续有安全需求可以在协议层加上AES加密和签名校验上位机加密后再发送Bootloader端解密写入这样即使镜像被截获也不怕被逆向。现在的方案已经给现场同事用了大半年没有出现过一例升级失败返修的情况说明整个链路的设计是站得住脚的。本文还有配套的精品资源点击获取
返回列表