FPGA远程升级实践:ZYNQ平台QSPI Flash固件更新与回滚设计

发布时间:2026/9/15 12:43:11
FPGA远程升级实践:ZYNQ平台QSPI Flash固件更新与回滚设计 简介面向FPGA设备远程固化升级场景这套开发资料基于ZYNQ平台通过网口实现固件远程升级在ALINX固件固化例程基础上二次开发并集成CRC校验机制保障传输与固化过程的数据完整性解决设备维护不便、升级数据安全难以保障等工程问题。整体约10.32MB共31个文件以C/H源码为主体涵盖网络通信、固件解析、固件固化、CRC校验等核心模块并附PC端升级工具、BIN固件、硬件描述与链接脚本以及Word说明文档和测试结果截图工程结构完整。内容对网络通信、固件解析、固件固化等模块的功能划分与协同工作做了阐述清晰呈现PC端与ZYNQ处理器系统完成固件升级的完整流程还包含在线升级耗时测算和CRC32校验码等实测结果便于调试与验证。已有94人学习适合需要快速上手ZYNQ远程固件升级开发的工程师学习参考。1. 为什么FPGA设备要做网口远程固化升级FPGA设备的固件更新一直是个麻烦事。配置逻辑烧在QSPI Flash里版本要变就得拿JTAG线去现场设备在机柜、户外或产线上时拆盖接下载器不仅费时还可能影响生产。ZYNQ平台把ARM处理器和FPGA放在一颗芯片里等于给FPGA加了一个“管家”可以由ARM接收网口数据再写入Flash从而把远程升级做成一条常规运维链路。这种方案的典型实现路径是PC端通过网口把固件包发给ZYNQ的处理器系统PSPS先做CRC校验再通过QSPI控制器把数据固化到Flash最后触发重启加载新镜像。ALINX提供的固化例程已经解决了FSBL、QSPI读写和BOOT.bin生成这些最麻烦的部分二次开发时主要工作是设计通信协议、接入CRC校验以及做好异常回滚。下面按这条路线讲清楚每一步的工程细节和参数取舍。如果产品已经跑了完整Linux也可以把接收固件这一层放到应用里但底层写Flash的驱动和回滚策略通用理解裸机实现有助于排查问题。这个方案适合设备数量不多、网络可达但对成本敏感的嵌入式产品如果你需要大规模并发推送还需要加上服务端管理但ZYNQ侧的固件接收逻辑不变。2. 升级链路设计启动镜像布局、数据包格式与ALINX例程的取舍远程升级不是简单地把文件写到Flash。ZYNQ的启动固件、应用分区和在线升级逻辑必须提前设计好否则遇到一次丢包或断电设备就再也起不来。这一章先解决“固件放在哪、数据怎么传输、例程怎么改”三个前置问题。2.1 ZYNQ启动顺序与QSPI Flash镜像布局ZYNQ-7000上电后BootROM是固化在片内的ROM它根据Boot Mode引脚决定从QSPI、SD还是JTAG读取第一步引导镜像。QSPI启动模式下BootROM会将存储在Flash起始位置的启动头加载到OCM然后跳到FSBL执行。FSBL是Xilinx提供的C代码它初始化DDR、MIO、时钟再根据BOOT.bin里的分区表加载bitstream到PL最后把控制权交给应用或U-Boot。ALINX的固化例程里FSBL源码通常是开放的我们可以修改它的加载地址从而实现双镜像回滚。这种“BootROM FSBL 应用”的层级关系决定了基于ZYNQ的Bootloader在线升级设计必须把FSBL视为一个小系统它既要足够小又要能识别“当前应该加载哪个分区”。ALINX例程默认是固定地址加载所以二次开发的第一个任务就是把“固定”改成“可配置”。BOOT.bin由Bootgen工具打包它把fsbl.elf、bitstream.bit和app.elf按顺序合成。提前理解这个布局很重要因为远程升级时我们不能把整个Flash擦掉再写否则升级中途断电会让设备变砖。常见做法是给Flash分三个区Boot区存放BOOT.bin、App-A区当前版本、App-B区备用版本通过某个状态标志决定从哪个区启动。这里用一个典型分区表说明分区名QSPI偏移地址示例大小存放内容Boot区0x00000002MBBOOT.binFSBL 固定位流 小引导程序App-A区0x02000008MB主应用固件含FPGA位流App-B区0x0A000008MB备用应用固件状态标志区0x120000064KB启动计数、升级状态标志、CRC记录偏移地址要根据实际Flash容量调整。1Gbit128MB的镜像通常按32MB对齐预留如果是256Mbit32MB的Flash就要把App区缩小。分区表写死在代码里升级程序按地址跳转不擦Boot区这样Flash里永远有一个能引导的镜像。可能有人问为什么不把整个BOOT.bin作为升级对象因为BOOT.bin里的FSBL和bitstream与PS应用一起打包后如果升级过程只更新应用部分没必要让Boot区承受擦写风险。ALINX的例程里常用做法是FPGA位流存放在QSPI固定地址启动时由FSBL直接加载该地址的bitstream远程升级时只更新位流区或整个应用区。QSPI Flash擦除的最小单位是扇区4KB写的最小单位是页256B。因此设计升级流程时先按扇区擦除再按页写入。Flash型号不同擦除命令可能支持4KB、32KB或64KB但驱动层最终会把大命令拆成扇区命令。后面代码里需要注意这一点。2.2 固件下载协议包头、长度、CRC和应答远程升级要有协议。TCP还是UDP我会选UDP加应用层确认。原因是升级场景在局域网内UDP实现简单避免TCP握手和重传影响实时性固件包分片发送每片都带序号和CRC接收方每收到一片就回ACKPC端没收到ACK就重发。这样即使单个UDP包丢了也不需要整个文件重传。协议基本格式可以用一个C结构体定义typedef struct { uint32_t magic; // 魔数 0xC0DEBA5E识别固件包 uint16_t seq; // 分片序号从0递增 uint8_t cmd; // 命令0x10开始升级0x11数据分片0x12结束 uint8_t flags; // 标志bit0最后一个分片bit1要求回读校验 uint32_t offset; // 该分片在固件文件中的偏移 uint32_t length; // payload长度最大1024 uint32_t crc32; // 本片payload的CRC32 } __attribute__((packed)) fw_header_t;这个结构体里magic用于过滤无效包seq用于检测丢包和乱序offset让写入Flash时可以随机写crc32校验的是payload不是整个结构体因为包头本身在传输中如果错了应用层会直接丢弃。开始升级时offset字段作为目标分区编号length字段作为固件总长度payload为空。应答包则只需要seq和状态码typedef struct { uint16_t seq; uint8_t status; // 0成功1CRC错2Flash错误3重复包 uint16_t crc16; // 对应答包自身的校验 } __attribute__((packed)) fw_ack_t;参数说明payload最大长度取1024而不是以太网MTU的1472是为了给IP分片留余量同时每片小一点收到后很快能写完Flash页。ALINX的裸机例程里如果把lwIP的PBUF缓冲开得比较大也可以把长度提到2048但百兆网下区别不大。PC端发送流程是先发一个开始升级命令收到确认后再逐片发送数据。ZYNQ端在开始命令时解析分区编号对应到Flash地址之后每片数据直接写到对应偏移。结束命令时ZYNQ会做一次整文件CRC校验并回读Flash前几个字节确认写入完成。整个交互时序可以简化为四步开始升级、发送数据、结束校验、重启。实际工程中建议加一个小窗口例如PC端连续发4个分片后才等待ACK避免每包等待增加RTT开销。ZYNQ端收到重复seq直接回重复包状态不写Flash这样PC端可以安全重发。2.3 基于ALINX固化例程的二次开发边界ALINX提供的固化例程一般包含FSBL源码、QSPI读写驱动和简单的网络例程。直接拿它做产品会遇到几个问题例程只证明了“能读写Flash”没有协议、没有断点续传、没有升级失败回滚。所以二次开发时要把例程当底层驱动库而不是整体。我一般会在拿到的例程上做四件事第一把QSPI基地址和Flash容量宏替换成目标板卡参数第二把网络例程中的UDP端口改为自定义端口绑定的IP设为固定IP避免DHCP超时第三把原来阻塞式写Flash的逻辑改成分包写入因为远程升级必须允许每包之间停顿第四加入自己的状态机把“接收-校验-写入-回滚”串起来。ALINX例程中常见的宏定义如下#define QSPI_BASEADDR XPAR_XQSPIPS_0_BASEADDR #define FLASH_TOTAL_SIZE 0x08000000 // 128MB #define APP_PART_OFFSET 0x0200000 #define SECTOR_SIZE 0x1000其中XPAR_XQSPIPS_0_BASEADDR在SDK的xparameters.h里生成Flash容量要根据实际芯片写否则擦除地址会超出物理范围。SECTOR_SIZE是4KB和驱动擦除粒度一致。这里的关键是不要直接调用例程里的PageProgram写全片而是先擦除再写并打开QSPI线性模式LQSPI来验证写入结果。QSPI线性模式允许把Flash映射到内存地址空间读起来像读RAM。升级完成后把跳转地址指向App-A区或App-B区起始地址就可以运行新固件。ALINX的FSBL源码里通常已经有从QSPI加载bitstream的代码我们只需要传入正确的偏移地址。这一步做不干净后面所有升级都会踩“写进去了但起不来”的坑。3. 在PS端用lwIP收固件并计算CRC3.1 SDK工程配置UDP和QSPI驱动在Vivado硬件工程里使能以太网GEM和QSPI外设然后Export Hardware到SDK。注意ZYNQ的MIO配置要保证以太网引脚和QSPI引脚没有冲突ALINX开发板一般使用固定的MIO组具体以板卡原理图为准。硬件导出后在SDK里创建裸机工程BSP中勾选lwIP Library。版本选lwIP v1.4.1或v2.0.1。v1.4.1更稳定占内存小v2.0.1有更多特性但需要更大的堆栈。远程升级这种固定功能用v1.4.1即可。BSP配置里需要修改几个关键参数参数名推荐值说明lwip_dhcpfalse固定IP便于PC端连接lwip_static_ip192.168.1.10按实际网段lwip_static_netmask255.255.255.0MEM_SIZE64*1024lwIP内存堆大小单位字节PBUF_POOL_SIZE20可用PBUF数量PBUF_POOL_BUFSIZE1200单个PBUF数据区大小需大于payload头部这几个参数直接影响丢包率。MEM_SIZE太小协议栈在突发时申请pbuf失败PBUF_POOL_SIZE太小回调里可能没有空闲缓冲。对于1024字节payload20个PBUF足够应付短时突发配合UDP应用层重传不需要把内存堆开得很大。3.2 UDP接收回调与固件写入队列lwIP使用UDP回调模式每次收到UDP包就会触发回调。回调函数里不能做耗时操作否则中断处理时间太长会丢包。正确做法是只把指针扣住然后由主循环里完成CRC和写Flash。下面是一个简化示例static void udp_recv_callback(void *arg, struct udp_pcb *upcb, struct pbuf *p, struct ip_addr *addr, u16_t port) { fw_header_t *hdr NULL; if (p-len sizeof(fw_header_t)) { hdr (fw_header_t *)p-payload; if (hdr-magic 0xC0DEBA5E) { if (rx_queue_full 0) { rx_pending p; packet_valid 1; return; } } } pbuf_free(p); }逻辑说明回调里只检查魔数如果接收队列空闲就“扣住”这个pbuf等主循环处理否则立即释放。这里没有用完整的FIFO是为了展示最小逻辑。实际生产中建议使用环形队列将“待解析包”和“已解析数据”分离。注意在lwIP回调里返回时不要释放被扣住的pbuf否则主循环会读到野指针。主循环中的处理流程while (1) { if (packet_valid) { process_fw_packet(rx_pending); // 解析、CRC、写Flash pbuf_free(rx_pending); packet_valid 0; } }process_fw_packet会做三件事先看seq是否等于上一次1跳过重复包再计算payload的CRC最后调用QSPI驱动按offset写数据。每处理完一片都发ACK这样PC端的重传逻辑很简单。这里有一个容易被忽略的点lwIP的回调发生在中断上下文或low_level_input中具体看BSP的netif配置。如果回调里直接调用QSPI写函数Flash忙等待期间网络中断会被阻塞最后表现为PC端超时、ZYNQ端偶尔收到错误包。所以“不耗时”是一条硬约束。3.2.1 处理函数的最小实现void process_fw_packet(struct pbuf *p) { fw_header_t *hdr (fw_header_t *)p-payload; uint8_t *data (uint8_t *)p-payload sizeof(fw_header_t); uint32_t crc crc32_update(0, data, hdr-length); if (hdr-seq ! expected_seq || crc ! hdr-crc32) { send_ack(hdr-seq, 1); return; } if (write_to_flash(hdr-offset, data, hdr-length) ! 0) { send_ack(hdr-seq, 2); return; } expected_seq; send_ack(hdr-seq, 0); }这段代码把“重复包”和“CRC错误”合并处理实际工程中最好分开计数。write_to_flash返回Flash驱动层的错误码如果返回非零PC端可以从下一片继续重试。3.3 CRC32查表算法与校验失败时的处理CRC校验有很多种远程升级用CRC32最普遍。CRC32的多项式为0x04C11DB7初值为0xFFFFFFFF结果还要异或0xFFFFFFFF。下面是查表法实现static uint32_t crc_table[256]; void crc32_init(void) { for (uint32_t i 0; i 256; i) { uint32_t crc i; for (int j 0; j 8; j) { crc (crc 1) ? (crc 1) ^ 0xEDB88320 : crc 1; } crc_table[i] crc; } } uint32_t crc32_update(uint32_t crc, const uint8_t *data, uint32_t len) { crc ~crc; for (uint32_t i 0; i len; i) { crc (crc 8) ^ crc_table[(crc ^ data[i]) 0xFF]; } return ~crc; }参数说明这里用了反射多项式0xEDB88320对应CRC-32/ISO-HDLC也就是标准zip校验。调用时传入0作为初始crc函数内部先取反等价于标准初值0xFFFFFFFF。查表表大小256个uint32占用1KB内存对ZYNQ不值一提。CRC计算放在主循环还是回调里放在主循环。每个包最坏要算1024字节在667MHz Cortex-A9上大约几十微秒不是瓶颈。如果放在中断回调里会导致lwIP的pbuf管理阻塞反而会丢包。这个结论在千兆网口同样成立但从千兆口使用减帧模式再切到百兆时回调时间极限会变短更要注意。校验失败时的处理要明确丢弃当前包不更新Flash增加错误计数并回复status1。如果连续失败超过20次退出升级状态机并向PC发送错误码。不要收到坏包就擦除Flash因为坏包可能是旧的重复包。安全边界在结束命令时才执行“整包核对”之前任何坏包都可重发。4. 固化到QSPI Flash擦写、回读和双镜像回滚4.1 QSPI驱动初始化与Flash分区分配ALINX的裸机例程提供了QspiPs驱动初始化但通常需要自己补分区地址。初始化代码如下XQspiPs QspiInstance; XQspiPs_Config *QspiConfig; int QspiFlashInit(void) { QspiConfig XQspiPs_LookupConfig(XPAR_XQSPIPS_0_DEVICE_ID); XQspiPs_CfgInitialize(QspiInstance, QspiConfig, QspiConfig-BaseAddress); XQspiPs_SetOptions(QspiInstance, XQSPIPS_MASTER_MODE | XQSPIPS_MANUAL_START); XQspiPs_SetClkPrescaler(QspiInstance, XQSPIPS_CLK_PRESCALE_8); return 0; }说明MASTER_MODE让QSPI控制器作为主设备MANUAL_START表示每次传输需要手动触发。时钟分频系数8将PS的QSPI参考时钟通常为100MHz降到12.5MHz。Flash最高支持50MHz但为了信号完整性保留余量。如果板卡走线较短可以改分频系数4但建议先按8跑通。分区分配需要在头文件里集中定义#define FLASH_BOOT_OFFSET 0x0000000 #define FLASH_APP_A_OFFSET 0x0200000 #define FLASH_APP_B_OFFSET 0x0A00000 #define FLASH_STATE_OFFSET 0x1200000 #define IMAGE_MAX_SIZE 0x0800000 // 8MB这里的偏移必须和Bootgen生成BOOT.bin时的分区起始地址一致。如果Bootgen里SPL分区占用的地址比这里大会互相覆盖。4.2 擦除写入与回读校验流程写入一个分片的完整过程分三步擦除扇区、页写入、回读校验。擦除不能每次写一片都做应该在开始升级命令时一次性擦除目标分区所有扇区因为按扇区擦除最快按小片擦除会导致Flash磨损且速度慢。下面是简化实现int ErasePartition(uint32_t offset, uint32_t size) { uint32_t sector_count (size SECTOR_SIZE - 1) / SECTOR_SIZE; for (uint32_t i 0; i sector_count; i) { if (XQspiPs_EraseSector(QspiInstance, offset i * SECTOR_SIZE) ! XST_SUCCESS) { return -1; } } return 0; } int WriteFirmwareArea(uint32_t offset, const uint8_t *data, uint32_t len) { return XQspiPs_PageProgram(QspiInstance, offset, data, len); } int VerifyFirmwareArea(uint32_t offset, const uint8_t *data, uint32_t len) { uint8_t cmp_buf[256]; uint32_t verify_len 0; for (uint32_t i 0; i len; i 256) { verify_len (len - i) 256 ? 256 : (len - i); memset(cmp_buf, 0, sizeof(cmp_buf)); XQspiPs_Read(QspiInstance, offset i, cmp_buf, verify_len); if (memcmp(cmp_buf, data i, verify_len) ! 0) { return -1; } } return 0; }这段代码的逻辑说明PageProgram的len必须为32的倍数QSPI数据宽度4字节且不超过256字节否则内部FIFO会错位。所以上层必须保证写长度是32对齐如果不是应用层要在最后一个分片补零。擦除后Flash内容全为0xFF补零不影响校验。每次写完一片建议做“轻量回读”只读该片首16字节判断是否写入成功。全量回读只放在结束命令后做。因为如果每片都全量回读千兆/百兆网口都会卡在Flash读取上升级时间翻倍。擦除整个8MB分区的时间也要提前算给PC端。按4KB扇区、每扇区擦除约50ms估算8MB需要约2048次擦除耗时超过100秒。所以开始升级命令发完后ZYNQ必须回一个“正在擦除”的忙碌状态PC端等到该状态后才开始发数据否则最早发出的几片会因为没有QSPI空闲而被丢在缓冲区外。4.3 双镜像启动与掉电安全升级中途掉电是远程升级最大的风险。只写一个应用区掉电后可能卡在半个镜像上。双镜像方案要求Boot区的小引导程序先读状态标志区决定加载App-A还是App-B。具体状态机如下升级前标记App-B为“正在写入”写入完成后回读校验通过标记其为“有效”重启时引导程序如果发现App-B有效就跳转App-B否则跳App-A。ALINX的FSBL默认只会加载一个固定地址的bitstream要改成从状态标志读取动态地址。常见的做法是在FSBL里加一段C代码QSPI读几个字节判断后修改加载地址。这个改动不大但属于例程之外的自研部分。状态码含义开机动作0x5A当前使用App-A从App-A加载0xA5当前使用App-B从App-B加载其他值上次升级中断回退到App-A并给出警告切换状态码也要做CRC。严格做法是状态标志区保存两套数据当前启动地址和回退地址外加CRC。每次写入一个32字节结构体小于一个扇区断电时最多丢这个标志不会破坏整个镜像。还可以加一个启动计数器每次上电时如果发现目标镜像校验失败就把计数器加一连续三次失败后强制回退到另一个分区。这样即使传输过程引入偶发坏块也能在三次尝试后恢复。这个机制在工业设备上很常见能覆盖大多数“写进去但起不来”的情况。4.4 Linux下的mtd方式写入补充如果ZYNQ跑的是PetaLinux远程升级可以直接用mtd设备控制不必重新编译裸机程序。先确认mtd分区cat /proc/mtd然后根据分区号写入flashcp BOOT.bin /dev/mtd0或者用 dd 加 flash_eraseflash_erase /dev/mtd0 0 0 dd ifBOOT.bin of/dev/mtd0 bs4096 convfsync说明flashcp 会自动擦除并校验比 dd 更可靠。但要注意mtd0对应哪个分区由设备树决定。生产环境建议给每个分区建好mtdpart不要让BOOT.bin占据整个Flash。同样在Linux里升级时CRC校验可以用cksum或crc32命令提前校验传输后的文件。如果PetaLinux 2025.1生成的BOOT.bin、boot.scr、image.ub都放到QSPI里需要单独划分分区不能压到裸机镜像上。PetaLinux的BOOT.bin由FSBL、PMUFW和U-Boot组成启动链与裸机不同覆盖后大概率起不来。5. PC端与联调三个缩短验证周期的技巧5.1 用Python模拟PC端发送固件PC端上位机不一定要在联调阶段完成Python脚本可以快速验证协议。下面是一个发送UDP分片的示例import socket, struct, zlib PC_IP, PC_PORT 192.168.1.100, 5000 ZYNQ_IP, ZYNQ_PORT 192.168.1.10, 6000 CHUNK 1024 HEADER IHBBIII # magic, seq, cmd, flags, offset, length, crc sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((PC_IP, PC_PORT)) def send_fw(path, partition0): addr (ZYNQ_IP, ZYNQ_PORT) data open(path, rb).read() total len(data) sock.sendto(struct.pack(HEADER, 0xC0DEBA5E, 0, 0x10, 0, partition, total, 0), addr) for seq, i in enumerate(range(0, total, CHUNK)): payload data[i:iCHUNK] crc zlib.crc32(payload) 0xFFFFFFFF pkt struct.pack(HEADER, 0xC0DEBA5E, seq, 0x11, (i CHUNK total), i, len(payload), crc) sock.sendto(pkt payload, addr)这段脚本要点pack用的头格式必须和C结构体逐个字段对齐因为C端声明了__attribute__((packed))Python用前缀强制小端1字节对齐即可。最后一个包payload长度可能小于1024CRC仍然只覆盖有效字节。要处理丢包需要在recvfrom上加超时收到ACK后再发下一片否则文件会缺块。5.2 CRC错误注入测试校验逻辑做远程升级必须故意发错包验证ZYNQ不会写坏Flash。在Python脚本里把某个分片的数据改成0x00再发一遍然后观察ZYNQ的串口日志是否打印“CRC error”且没有写Flash。注意测试时要确保改的是数据部分不是包头CRC否则接收端会因魔数不对直接丢弃测不出校验逻辑。if seq 10: payload b\x00 * len(payload)错误注入通过后再检查Flash里该分片位置仍是旧数据确认回滚机制有效。这个测试每轮升级前都应该跑一遍两分钟就能完成。5.3 用串口日志和看门狗定位写Flash卡死最后要注意的是应用看门狗。写Flash时如果Flash芯片响应异常XQspiPs驱动可能长时间忙等待整个升级进程卡死。裸机环境下可以在主循环里喂WDT看门狗定时器若升级状态机超过一定时间没有推进就强制复位。复位后引导程序会因状态标志未完成而回退到旧版本。串口日志设计成带时间戳的等级输出[ upgrade ] seq128 offset0x084000 len512 crcOK wbuf256 timeout3序号、偏移、CRC结果、写缓冲剩余量一目了然。看到offset连续不变但timeout在增加基本可以断定QSPI驱动卡在忙等待。此时优先检查时钟分频和设备ID而不是怀疑网络链路。升级成功后写一个“即将重启”的UDP报文给PC端让上位机自动开始下一轮测试这样联调流程就能无缝循环。本文还有配套的精品资源点击获取