STM32+W5500网络Bootloader实现:远程固件升级方案与踩坑总结
2026/9/9 18:05:47 网站建设 项目流程

1. 项目背景:为什么偏偏要做带网络的Bootloader

先交代一下背景。我手上有个设备用的STM32F103C8T6,主控通过SPI挂了一颗W5500做以太网通信。之前的固件升级方式是现场开盖、飞线接串口、用ISP工具烧写——这套流程在实验室里没问题,可一旦设备部署到客户现场,麻烦就来了。客户那里没有调试工具,也不会操作烧录软件,每次升级都要派工程师出差,成本高不说,来回折腾还容易把排线搞坏。

后来被逼着做了版远程升级方案,就是这篇博文要说的STM32 + W5500 Bootloader。做完之后实测升级一次固件不到30秒,完全不需要开盖,客户在网页上点一下按钮就行。这个方案的核心思路不复杂:Bootloader跑在芯片里,通过W5500从网络服务器拉取固件,写入内部Flash,然后跳转到App执行。听完是不是觉得也就那么回事?但真正落地的时候,坑比想象中多得多。

这篇东西适合谁看?手里有STM32 + W5500类似组合、想实现网络远程升级的朋友,或者正在琢磨Bootloader原理、想自己写一版Bootloader的开发者。我会把从方案选型、分区规划、协议设计到代码实现的完整过程都讲一遍,尤其是那些“不踩一次根本不知道”的细节。

2. 整体方案设计:从“能用”到“好用”的关键决策

2.1 为什么选W5500而不是直接上以太网协议栈

做远程升级,第一个要回答的问题是:用什么样的网络接入方式?方案其实有好几条路:STM32 + 以太网MAC + PHY芯片(比如LAN8720)跑LWIP、用ESP8266这类WiFi模组、或者直接上W5500这种带硬件TCP/IP协议栈的以太网控制器芯片。

我最终选了W5500,核心原因是它把TCP/IP协议栈做进了硬件里。STM32F103C8T6这颗芯片只有64KB Flash、20KB RAM,跑LWIP虽然也能跑,但占用的资源不少,还要处理一堆协议细节。W5500通过SPI接口跟MCU通信,TCP、UDP、ICMP这些协议都在芯片内部处理完了,MCU只需要读写Socket寄存器、收发缓冲区就够了。这对资源紧张的F103来说是巨大解放。

另外一个现实因素是,设备本身就要用W5500做业务通信,把Bootloader建立在同一套硬件抽象上,代码复用程度高,不用额外增加硬件成本。这里有个经验:Bootloader尽量跟业务代码共用一套底层驱动,但底层驱动本身要写得足够精简、稳定,因为Bootloader空间有限,容不下臃肿的代码库。

2.2 Flash分区规划:一次规划,长期受益

Bootloader最基础也最容易出错的地方就是Flash分区。规划不合理,后面升级功能扩展就会很被动。

我的F103C8T6有64KB Flash,起始地址0x08000000,页大小1KB。分区如下:

分区地址范围大小用途
Bootloader0x08000000 - 0x08003FFF16KB引导程序 + 网络升级功能
App0x08004000 - 0x0800BFFF32KB业务应用程序
Flag/参数区0x0800C000 - 0x0800FFFF16KB升级标志、版本信息、备份区域

Bootloader放16KB是够用的,W5500驱动加上HTTP客户端、Flash擦写、校验逻辑,编译出来大概11KB左右,留了点余量。App占32KB,对于我这个业务逻辑不算太复杂的设备来说够用。后面16KB是参数区和预留的备份区。

这里有个重要决策:Bootloader的链接脚本必须把Flash起始地址设为0x08000000,而App的链接脚本要设为0x08004000,同时中断向量表也要重映射。App编译出来的固件是带地址信息的,但下载到Flash时位置固定,所以固件文件本身不需要做额外处理。

2.3 主动升级与被动升级:到底选择哪种模式

远程升级按触发方式大致分两类:主动升级(设备主动去服务器拉取固件)和被动升级(服务器下发指令,设备被动接收固件)。我这边需求是“客户在后台点一下,设备就能升级”,所以我做了主被动结合:设备上电时先跑Bootloader,检查有没有升级请求标志;没有就直接进App。平时App收到服务器的升级指令后,通过写入特定标志位并复位,触发Bootloader进入升级流程。

这个设计的好处是,即使App因为意外崩溃跑不起来了,设备上电仍然会先进入Bootloader,只要Bootloader区没被破坏,就能继续通过网络恢复系统。这就是业界常说的“A/B分区”思路的简化版——我没有做双备份,但通过Bootloader提供了最基本的安全网。

3. Bootloader核心机制详解

3.1 升级标志位的设计:最简单的状态机

标志位是整个升级流程的“指挥棒”。我把标志位放在Flash的Flag区,用一个独立的16位变量来表示当前状态。为什么要用16位而不是8位?因为8位变量在意外写入时容易产生歧义,而16位可以用0xAA55 / 0x55AA这种双字节模式来降低误判概率。

定义几种状态:

  • 0x0000:无升级请求,直接跳转App
  • 0xAA55:有升级请求,进入网络升级流程
  • 0x55AA:App已通过完整性校验,可以拉起App

这里有一个关键操作:写Flash前必须先擦除。F103的Flash按页擦除(每页1KB),擦除后全页为0xFF。所以写入标志位前,要把整个Flag页擦掉再写。而且要注意,写Flash时如果恰好在执行中断服务程序,可能导致总线错误——所以写入操作前要关闭中断,写完再恢复。这是很多新手容易忽略的地方。

#define FLAG_APP_OK 0x0000 #define FLAG_UPDATE_REQ 0xAA55 #define FLAG_UPDATE_DONE 0x55AA void set_update_flag(uint16_t flag) { __disable_irq(); FLASH_Unlock(); FLASH_ErasePage(FLAG_PAGE_ADDR); FLASH_ProgramHalfWord(FLAG_PAGE_ADDR, flag); FLASH_Lock(); __enable_irq(); }

3.2 跳转App的关键:中断向量表重映射

Bootloader跳转App时,核心动作是修改栈顶指针和复位向量,然后跳转过去。但光跳转还不够,如果中断向量表还指向Bootloader区域,App里的任何中断调用都会跳到Bootloader的向量表里执行,结果就是程序跑飞或者进入HardFault。

STM32F103提供了两种中断向量表重映射方式:一种是通过SYSCFG_MEMRM寄存器把向量表SRAM映射,另一种是直接操作VTOR寄存器(Cortex-M3内核支持)。F103属于Cortex-M3内核,可以直接用VTOR。

typedef void (*pFunction)(void); void jump_to_app(void) { uint32_t app_address = APP_START_ADDR; pFunction app_entry; if (((*(volatile uint32_t *)app_address) & 0x2FFE0000) == 0x20000000) { app_entry = (pFunction)(*(volatile uint32_t *)(app_address + 4)); __disable_irq(); SCB->VTOR = app_address; __set_MSP(*(volatile uint32_t *)app_address); app_entry(); } }

注意那个((*(volatile uint32_t *)app_address) & 0x2FFE0000) == 0x20000000的判断,这是在检查App起始地址处的栈顶指针是否合法。STM32的RAM地址范围是0x20000000开始,所以栈顶值必须在RAM范围内,否则说明Flash里没有有效的App代码,不能跳转。这个检查是保险丝,不要省。

另外,进入App前要把外设恢复到复位状态。比如我在Bootloader里初始化了W5500、串口、定时器等,如果不做DeInit,App启动后这些外设的状态可能还是Bootloader残留的配置,导致初始化冲突。

3.3 网络升级协议:简单可靠才是硬道理

网络升级协议我设计得很朴素,核心就三步:查询版本、下载固件、校验重启。

查询版本:设备向服务器发一个GET请求,携带当前App版本号,服务器返回“有新版/无新版”的响应。这一步不是必须的,但能避免重复下载相同版本。

下载固件:设备向服务器发HTTP Range请求,从指定偏移位置拉取固件数据。这里有个经验:不要一次性把整个固件都拉进RAM再写Flash,因为F103的RAM只有20KB,而固件可能有30KB,放不下。正确做法是边下载边写入,把Flash按页擦除,每收到一块数据就写入对应的Flash页。

因为W5500的Socket接收缓冲区和F103的RAM都有限,我每次拉取1KB数据(擦除1页、写入1页),然后请求下一个1KB。这样做虽然请求次数多了点,但逻辑简单,不容易出缓冲区溢出的问题。

校验:固件下载完成后,对整个App区做CRC32校验,跟服务器发送的CRC值比对。不一致就标记升级失败,清掉标志位,尝试回滚。因为我这边没做完整的A/B分区备份,回滚的策略是:如果App校验失败,就继续保持Bootloader状态,等待重新下载,而不是跳转到一个可能半残的App。

提示:网络协议层一定不要图省事省略超时重传机制。实测中,路由器偶发丢包、网线接触不良都会导致TCP连接中断。我的实现里,每个下载块如果5秒内没有收到响应,就重试3次,3次都失败则中止升级,把标志位复位,防止设备卡死在升级状态。

4. 实操过程:从零搭建完整升级链路

4.1 硬件准备与接线验证

硬件材料清单:

  • STM32F103C8T6最小系统板(或者你自己的板子)
  • W5500以太网模块
  • 网络服务器(一台PC装HTTP服务器软件就行,开发阶段可以这样搞)
  • USB转TTL串口工具(调试用,看日志输出)
  • ST-Link V2(烧录Bootloader用)

接线方面,W5500通过SPI连接。我的用法是:SPI1用作W5500通信,PA4作为片选,PB0作为W5500的中断引脚。接线表如下:

W5500模块STM32F103
SCLKPA5 (SPI1_SCK)
MOSIPA7 (SPI1_MOSI)
MISOPA6 (SPI1_MISO)
SCSPA4 (片选,软件控制)
RSTPB1 (复位引脚,软件控制)
INTPB0 (中断引脚,可不用)

有一个坑先提醒:W5500的供电一定要做好,这芯片对电源纹波比较敏感。我最初用面包板飞线的时候,经常出现芯片一会儿能初始化一会儿不能,后来查到是供电问题。建议用独立的3.3V LDO供电,并且MCU和W5500的电源分别加100nF去耦电容。WiFi/以太网芯片这种高频器件,供电不稳会出现各种诡异问题。

验证硬件是否OK的简易方法:给W5500上电后,读它的版本寄存器(地址0x0039),正常会返回0x04。如果读出来是0xFF或0x00,多半是SPI通信没通或者芯片供电有问题。

uint8_t w5500_read_version(void) { uint8_t ver = 0; w5500_read_register(0x0039, &ver, 1); return ver; } // 正常返回值:0x04

4.2 工程搭建:Bootloader与App的工程配置

工程我是基于标准外设库(SPL)搭的,因为个人用习惯了,HAL库也行,区别不大,但要注意几个关键配置点。

Bootloader工程:

  • Flash起始地址:0x08000000
  • 编译优化:-O1(兼顾性能和体积)
  • 需要启用串口(调试日志)、SPI(通信)、定时器(超时管理)

App工程:

  • Flash起始地址:0x08004000
  • 编译优化:-O2(业务代码优先性能)
  • 中断向量表偏移:在SystemInit里或main开头设置SCB->VTOR = 0x08004000

App工程里必须在main函数最开始设置VTOR,否则中断会跑飞。我见过有人把VTOR设置放在外设初始化之后,结果UART中断一开启就死机——CPU收到中断,去向量表找入口,向量表还是Bootloader的,于是跳到错误的地方。这个教训被我记了整整一个晚上。

还有一点,App编译生成的.hex文件包含了从0x08004000开始的地址信息,烧录Bootloader时不会覆盖App区域,两者互不干扰。升级时,服务器托管这个.hex文件即可,不需要做额外转换。但如果用的.bin文件,要注意下载到Flash的地址偏移。

4.3 W5500初始化与Socket配置

W5500的初始化相对固定,但有几个细节直接决定了网络通信的稳定性。

void w5500_init_network(void) { uint8_t mac[6] = {0x00, 0x08, 0xDC, 0x01, 0x02, 0x03}; uint8_t ip[4] = {192, 168, 1, 100}; uint8_t gw[4] = {192, 168, 1, 1}; uint8_t mask[4] = {255, 255, 255, 0}; w5500_soft_reset(); w5500_set_mac(mac); w5500_set_ip(ip); w5500_set_gateway(gw); w5500_set_subnet_mask(mask); }

三个要点:

第一,软复位后要等待芯片稳定。W5500的软复位是写MR寄存器的RST位,复位后建议延时10ms以上再进行寄存器配置,否则可能写不进去。

第二,Socket的发送/接收缓冲区大小要按需配置。W5500每个Socket的收发缓冲区通过Sn_RXBUF_SIZE和Sn_TXBUF_SIZE寄存器配置,单位是KB。默认是2KB,我把它改成了发送2KB、接收4KB。为什么接收要大一点?因为HTTP响应可能包含头部和正文,如果缓冲区太小,一次收不下完整数据,就得处理分包,逻辑会复杂很多。缓冲区大一点,一次读到的数据多,协议处理简单。

第三,Socket超时时间很关键。W5500的Socket有一个超时寄存器(Sn_RTIMR/Sn_RETR),控制TCP重传的超时和重试次数。默认值偏保守,如果网络环境良好,可以把重试次数调小一点,这样握手失败能快速反馈,不会傻等半分钟。我实测在局域网环境,把重试次数从8次调到3次,连接失败从肉眼可见的卡顿变成了秒级反馈,用户体验提升明显。

4.4 HTTP客户端实现:比想象中简单

HTTP客户端这块不用上复杂的库,Bootloader里手动拼HTTP报文就行。因为功能就两个:发GET请求下载固件、发GET请求查版本。

// 拼HTTP GET请求 char http_request[] = "GET /firmware/app.bin HTTP/1.1\r\n" "Host: 192.168.1.50\r\n" "Connection: close\r\n" "\r\n";

把这段字符串通过W5500的Socket发送出去,然后等待接收响应。收到响应后,先解析HTTP头,找到Content-Length字段,确定固件总长度。然后跳过HTTP头(以\r\n\r\n为界),剩下的就是固件二进制数据。

这里分享一个解析HTTP头的小技巧:将收到的数据存在一个局部缓冲区里,用strstr函数查找\r\n\r\n,找到后记录这个位置的偏移,固件数据从这个偏移之后开始。注意HTTP头可能跨多个TCP段,所以要先完整接收头部,再开始处理固件数据,不要边收边找,容易乱。

固件下载我用的Range请求方式:

char http_range_request[] = "GET /firmware/app.bin HTTP/1.1\r\n" "Host: 192.168.1.50\r\n" "Range: bytes=0-1023\r\n" "Connection: close\r\n" "\r\n";

每次只请求1KB,服务器返回206 Partial Content,附带这个范围的固件数据。收到后写入Flash,然后请求下一个Range。这么做的优点是完全不怕缓冲区不够,缺点是需要连续发很多次TCP请求,耗时稍长。实测32KB固件大概发32次请求,局域网内总耗时2-3秒,完全可以接受。

4.5 Flash写入的时序问题

Flash写入是升级流程里最容易出事的地方。STM32F103的Flash写入有几点要特别注意:

  • 写入前必须擦除,擦除以页为单位(1KB)
  • Flash编程时间有限制,一般几十微秒到几毫秒,期间CPU要等待
  • 写Flash时不能处理中断,否则可能导致总线错误或者写入失败
  • Flash擦写次数有限,典型寿命是1万次,Bootloader里不要频繁擦写Flag区

我上面的实现里,每下载1KB数据到内部缓冲区,就执行一次“擦除1页 + 写入1页”的操作。写入完成后再发送下一个Range请求。这样串行处理,不容易出错。

void write_firmware_block(uint32_t address, uint8_t *data, uint16_t len) { uint16_t i; FLASH_Unlock(); FLASH_ErasePage(address); for (i = 0; i < len; i += 2) { FLASH_ProgramHalfWord(address + i, *(uint16_t *)(data + i)); } FLASH_Lock(); }

有个细节:FLASH_ProgramHalfWord要求数据按半字(16位)对齐,而W5500收到的数据是字节流,可能不是偶数长度,所以处理时要考虑边界。我的做法是缓冲区固定1024字节,最后一块数据如果不满足1024字节,用0xFF补齐后再写入,因为0xFF正好是Flash擦除后的状态,不影响实际固件内容。

5. 实际调试中踩过的坑与排查思路

5.1 跳转App后死机,怎么回事

第一次实现跳转,App打印了启动日志后直接HardFault。排查了一圈,发现两个问题:一是App工程里VTOR设置得太晚,在main函数里设置VTOR之前,系统时钟初始化(SystemInit)已经执行完,但此时如果有中断发生(比如SysTick),就会走错向量表。二是Bootloader里没有把用到的外设DeInit,App初始化UART时,UART的状态还是Bootloader残留的,导致配置冲突。

解决方式:App的main函数第一行就设置VTOR,然后立即DeInit所有外设。Bootloader跳转前,先做外设DeInit,把时钟和引脚恢复到默认状态,再跳转。这样两边都干净了。

5.2 W5500连接服务器超时

开发阶段经常遇到W5500发出连接请求后,迟迟得不到服务器响应,最后返回超时。排查思路从硬件到软件一步步来:

先检查网络线路:W5500的Link灯是否亮起,网线通不通。再检查IP配置:服务器和设备必须在同一网段,网关配置是否正确,子网掩码是否匹配。然后检查服务器防火墙:HTTP服务端口(默认80)是否被防火墙拦截,局域网内其他设备能不能正常访问。最后看代码:Socket的配置是否正确,Sn_CR寄存器命令是否正常触发。

我遇到的一个典型案例:服务器用的Windows防火墙默认拦截了来自外部的HTTP请求,我能ping通设备,但设备连不上服务器。关掉防火墙后一切正常。这个问题排查了快两个小时,说多了都是泪。

5.3 下载到一半失败,Flash擦写占用了太多时间

升级过程偶尔会在中途失败,表现为某个Range请求超时。后来定位到原因:Flash擦写时,CPU忙于等待Flash操作完成,导致W5500的Socket接收缓冲区溢出,丢掉了服务器下发的数据。

这个问题的本质是:写Flash时,W5500还在接收网络数据,MCU处理不过来。解决办法两个:一是调大W5500的Socket接收缓冲区,给网络数据更多的缓存空间;二是在写Flash之前,先暂停Socket接收,等Flash写完再恢复。我采用了后者——写Flash前调用w5500_socket_close(socket)关闭Socket,写完后重新连接、重新发起Range请求。虽然每次写Flash都要断开重连,增加了连接次数,但整体可靠性提升了非常多,实测从偶尔失败变成从未失败。

5.4 调试工具与日志:没有日志寸步难行

Bootloader这种“裸机”程序,调试手段比App少很多。我的经验是:串口日志一定要有,这是Bootloader开发的生命线。在Bootloader里通过UART输出调试信息,比如当前状态、收到的数据长度、校验结果、错误码,能把开发调试效率提升好几倍。

硬件上,用USB转TTL模块接UART1即可,波特率115200,8N1。Bootloader启动时先打印一段信息,然后打印当前状态,比如:

[BOOT] Bootloader v1.0 [BOOT] Check flag: 0x0000 [BOOT] Jump to App @ 0x08004000

有这行日志,跳转是否成功一目了然。升级过程中打印:

[BOOT] Start update, total size: 32768 [BOOT] Download block 0, offset 0 [BOOT] Download block 1, offset 1024 ... [BOOT] CRC check OK, jump to App

每条日志对应一个关键节点,哪里卡住了一眼就能看出来。

还有个工具推荐:STM32 ST-LINK Utility。它不仅可以烧录程序,还能读取Flash内容。调试时如果怀疑App区写入不正确,可以用它读出来跟原始固件比对,定位是哪个字节写错了。另外,升级失败后设备无法启动,也可以用它在Boot模式下擦除整个Flash,重新烧录Bootloader,恢复正常状态。

6. 常见问题速查表

问题现象可能原因排查/解决方法
跳转App后死机VTOR未设置或设置太晚App的main第一行设置SCB->VTOR = 0x08004000
跳转App后死机Bootloader外设未DeInit跳转前HAL/SPL的DeInit所有外设
W5500初始化失败SPI接线错误检查SPI引脚连接,读版本寄存器确认通信
W5500初始化失败供电不足/纹波过大加LDO供电 + 去耦电容
TCP连接超时防火墙拦截检查服务器防火墙,放行HTTP端口
TCP连接超时网段/网关配置错误检查IP、网关、子网掩码配置
下载中途失败Flash写期间Socket缓冲区溢出写Flash前关闭Socket,写完重连
下载完成后校验失败App固件不完整检查服务器固件文件是否正常,确认Range请求偏移正确
升级后App无法运行App分区被破坏用ST-LINK Utility重新烧录Bootloader+App
Flag区误写中断导致Flash写入不安全写Flash前关中断,写完恢复

7. 优化空间与扩展建议

把基础版本跑通之后,如果想进一步把方案做扎实,有几个方向是可以考虑的。

第一个是A/B双分区。我用的是单App区 + 标志位方案,升级过程中如果下载失败,设备会停在Bootloader等待重新下载。A/B分区则是同时存两份App,升级时写入非活动分区,写完校验通过后切换启动分区,失败就继续用原来的。这个方案的好处是升级失败几乎不影响设备运行,代价是Flash占用翻倍,对F103这种只有64KB Flash的芯片来说很捉襟见肘,更适合F103RC(256KB)或者F4系列。

第二个是增加TFTP或FTP支持。HTTP协议做升级够用,但有的场景下服务器端更愿意用TFTP,因为TFTP实现更简单、不用维护HTTP状态。W5500本身支持UDP,TFTP就是基于UDP的,底层实现反而更容易。缺点是TFTP没有加密和认证,安全性差,只能在可信内网使用。

第三个是固件加密。现在固件文件明文放在HTTP服务器上,任何人都能下载分析。如果产品对安全性有要求,可以在固件里加签名,Bootloader在下载完成后先验签再决定是否写入。具体做法是,服务器在固件末尾附加一段RSA签名,Bootloader使用内置公钥验证签名。代码空间和计算时间都会增加,但安全性提升明显。

第四个是看门狗。Bootloader运行过程中,一旦发生死循环或者异常卡住,设备就彻底变砖了,只能开盖重新烧录。在Bootloader里启用独立看门狗(IWDG),主循环定期喂狗,一旦卡在某个步骤超过设定时间,芯片自动复位重来,这是一种低成本的安全兜底。

我个人体会是,Bootloader这种模块属于“做的时候觉得没什么,出问题了才意识到重要”的东西。它不产生业务价值,但直接决定设备能不能安全、稳定地升级。技术含量不在于“知道怎么写跳转代码”或者“知道W5500怎么联网”,而在于把边界情况想全:网络断了怎么办、Flash写坏了怎么办、App校验失败怎么办——把这些场景逐一处置好,这个Bootloader才算真正能用。

后面如果时间允许,我打算把A/B分区和固件签名加上,再把上位机管理平台完善一下,做成一套能自动检测新版本、批量推送升级的完整系统。到时有新的踩坑经验,再来跟大家分享。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询