简介:面向STM32嵌入式开发者的IAP引导加载程序资料包,围绕YModem协议实现串口在线升级,适合需要了解应用内编程机制、编写Bootloader或设计固件更新方案的工程师与学习者。资源包内含完整Keil工程,共475个文件,约9.71MB。文件类型覆盖c/h源码、uvprojx工程配置、o目标文件、hex与bin烧录文件,以及map、lst、crf等编译中间产物,便于从源码到镜像生成全过程追踪。其中“1_IAP”与“2_APP程序”两部分清晰区分引导程序与待更新应用,可对照分析上电初始化、YModem分包接收、CRC校验与重传、Flash擦写、跳转地址修改等核心流程。已有2377人学习下载。配套生成的dbgconf、sct等链接脚本文件,也能帮助理解STM32内存布局和启动文件配置。整体结构完整、层次清楚,既适合初步接触IAP的读者逐步梳理实现思路,也可作为实际产品中串口升级方案的参考模板。
1. 为什么我最后把所有STM32升级都换成了YMODEM
做STM32 IAP升级,最容易被忽略的不是协议,而是“谁会去按那个升级键”。产品发到客户现场,没有J-Link,拆壳又麻烦,唯一可靠的就是串口。串口升级里XMODEM听起来更简单,但实际做量产和现场维护时,我发现YMODEM才是更适合的那个选择——它自带文件名和文件大小,传一次就知道固件对不对,传输效率也比XMODEM高,编译出来的bootloader代码量却几乎不增加。这篇笔记就把我从“bootloader收不到数据”到“APP跳转跑飞”一路踩过的坑写清楚,覆盖内存划分、协议帧格式、状态机实现和现场排障。适合打算自己动手写STM32 bootloader的开发者,也适合正在被YMODEM传输“玄学”折磨、想借一个完整方案脱身的人。
2. 先把IAP的地基打好:bootloader与APP的内存划分和跳转机制
2.1 内存划分:给APP的“新家”留出足够空间
IAP的第一件事不是写协议,而是决定bootloader和APP各自住在Flash的哪一段。划分错了,后面全白搭。以最常见的STM32F103C8为例,内置64KB Flash,我的习惯是给bootloader分16KB,从0x08000000到0x08003FFF;给APP分48KB,从0x08004000开始。为什么是16KB而不是8KB?YMODEM接收代码、串口驱动、Flash驱动、加上一小段升级失败回退逻辑,紧凑优化后大概在10KB到12KB之间,16KB留了余量,不逼自己做代码裁剪。而APP分区留48KB,对绝大多数应用固件是够用的。如果你的芯片是256KB或512KB的,按同样思路划分即可,比例不用照搬。
划分完成后有个容易忽略的副作用:APP里所有绝对地址都要偏移。中断向量表、链接脚本、编译时的ROM起始地址,三处必须同步改。改漏一个,APP能烧进去但跑不起来。这里我一般会画一张分区表放在工程根目录的README里,防止三个月后自己都忘了当时为什么把APP起始地址定在0x08004000而不是0x08008000。
| Flash区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 16KB | IAP升级逻辑、串口驱动、YMODEM协议 |
| APP | 0x08004000 | 48KB | 用户应用、中断向量表、业务代码 |
| 末尾预留 | 0x0800F000 | 4KB | 升级标志、版本信息、配置参数 |
表格里末尾预留的4KB不是必须的,但建议留。升级标志可以放在内部Flash末尾,这样bootloader一启动就能判断“上次升级是成功了还是断到一半”,不需要外挂EEPROM。这个设计在断电中断升级的场景里特别有价值。
2.2 跳转函数:不关中断源,APP必跑飞
跳转是IAP里最考验细节的一步。很多人以为跳转就是“取复位向量,然后调用”,实际上不做中断清理工作,APP起来后很快会HardFault,这是YMODEM传输成功后最常见的翻车点。原因在于:bootloader里如果开了串口中断、定时器中断,跳转时这些中断源还处于使能状态,而APP的向量表已经挪走了,中断一来CPU找不到对应处理函数,直接跑飞。
我一般把跳转逻辑写成独立函数,放进bootloader的main.c里,代码如下:
typedef void (*pFunction)(void); void JumpToApp(uint32_t app_addr) { uint32_t app_sp = *(volatile uint32_t *)app_addr; /* 取APP栈顶地址 */ uint32_t app_pc = *(volatile uint32_t *)(app_addr + 4); /* 取复位向量 */ pFunction jump; /* 1. 校验栈顶地址范围,防止烧录了损坏的固件 */ if ((app_sp & 0xFFF00000) != 0x20000000) { return; /* 栈顶不在RAM范围,直接放弃跳转 */ } /* 2. 关闭全局中断,清理SysTick */ __disable_irq(); SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; /* 3. 重新设置中断向量表地址 */ SCB->VTOR = app_addr; /* 4. 设置主栈指针并跳到APP复位向量 */ __set_MSP(app_sp); jump = (pFunction)app_pc; jump(); }这段代码有四个关键动作。第一步先校验栈顶地址,如果固件没烧写或者烧坏了,地址不在0x20000000开头的RAM范围内,直接放弃跳转,避免跳到垃圾地址。第二步关闭全局中断并停掉SysTick,这一步很多人会漏掉;SysTick如果还在跑,跳转后它的中断如果没有被APP正确处理,整个系统会被频繁打断。第三步把VTOR设置成APP的起始地址,这是Cortex-M3/M4核向量表重映射的标准做法。第四步先设主栈指针再取复位向量调用,顺序不能反,因为调用复位向量本身需要栈可用。
2.3 APP侧三个改动:链接脚本、向量表偏移和编译地址
bootloader只负责“跳”,APP也得配合把地址偏移到0x08004000。否则bootloader跳过去了,APP的中断向量表还在0x08000000,串口一中断就乱。三个地方必须同步改。
第一个是编译地址。如果用的Keil,在Options for Target的Target页里,把IROM1的起始地址从0x08000000改成0x08004000,大小改成0xBF00或0x3C000。记住这里填的是“剩余空间”,不是总Flash大小。填大了链接器不报错,但生成的固件可能超过实际分区,烧录时不会覆盖bootloader吗?会,而且大概率把bootloader区踩掉。
第二个是向量表偏移。在APP的main函数最开头,第一时间执行:
SCB->VTOR = 0x08004000;如果你用的是HAL库,初始化流程里SystemInit函数会先跑,之后再在main开头设一次。必须在任何外设中断使能之前设置,否则中断一开就乱。
第三个是生成文件格式。如果bootloader通过YMODEM升级,APP需要生成bin文件而不是hex文件。Keil里可以在User页加一条fromelf命令,把axf转成bin。bin文件没有地址信息,bootloader收到的就是纯固件内容,直接写进0x08004000就好。
GCC工具链的用户,改的是链接脚本:
FLASH (rx) : ORIGIN = 0x08004000, LENGTH = 48K这里LENGTH填48K是给你自己看的,实际以芯片型号为准。链接脚本改错会导致编译器把代码排布到错误的位置,跳转后一样跑飞。改完编译,看map文件确认入口地址和向量表地址都与预期一致,再往下做协议部分。
3. YMODEM协议的帧与字节:把每一次握手都拆开看
3.1 三种帧的结构:起始帧、数据帧、结束帧怎么区分
YMODEM最大的特点是“带文件信息”,发送方会先把文件名和文件大小告诉接收方,接收方据此判断要不要接收、还剩多少数据。协议里一共三种帧:起始帧、数据帧、结束帧。三种帧共用一套帧头规则,靠块号区分。
| 帧类型 | 帧头 | 块号 | 块号反码 | 数据区长度 | CRC16 |
|---|---|---|---|---|---|
| 起始帧 | SOH(0x01) | 0x00 | 0xFF | 128字节 | 2字节 |
| 数据帧 | SOH/STX | 0x01~0xFF | 按位取反 | 128/1024字节 | 2字节 |
| 结束帧 | SOH | 0x00 | 0xFF | 128字节(全为0x00) | 2字节 |
起始帧看起来很像块号为0的数据帧,区别在于数据区内容不是固件数据,而是ASCII编码的文件名加文件大小,尾部用0x00补齐到128字节。比如要传“app.bin”,文件大小是42700字节,数据区开头就是“app.bin\0”加“42700\0”,后面补零。结束帧更特殊,块号也是0,但数据区是128个0x00,接收方看到块号为0且数据全空,就认为传输结束。
有个细节在这里特别容易踩坑:起始帧和结束帧的块号都是0,怎么区分?我的做法是看数据区内容。起始帧数据区里有非零的ASCII字符,结束帧全零。结合文件大小字段判断,如果还没开始接收固件就来了块号0,那就是起始帧;如果已经收够了文件大小字段声明的字节数,来的块号0就是结束帧。
3.2 一次完整传输的时序:谁先发C,谁回ACK
整个传输过程有严格的会话时序,必须按这个顺序走,否则发送方工具会直接报错退出。第一步,bootloader上电后初始化串口,然后向发送端发出一个字符C(0x43),表示“我准备好了,用CRC16校验模式”。发送方收到C后,发送起始帧。bootloader接收并解析起始帧,确认文件名和文件大小都合法后,回ACK(0x06)。
从第二个包开始进入数据帧循环。每收到一个数据帧,先检查块号是否连续,再对数据区做CRC16校验。校验通过回ACK,发送方接着发下一块;校验失败回NAK(0x15),发送方重传当前块。如果连续重试多次仍失败,或者接收方主动发两个0x18(CAN),发送方会取消传输。数据传输完成后,发送方发送结束帧,bootloader再回一个ACK,然后发送方发一个C,等待bootloader回应C或直接进入下一个文件。大多数串口终端工具传单个文件时,这个尾巴由工具内部处理,但bootloader端要能兼容“收到结束帧后再收到一个C”的情况。
这里有个我早期理解错的点:接收方回复ACK后,不代表本帧已经安全写入Flash。正确做法是先校验完整个数据帧、再写入Flash、最后回ACK。如果在写Flash之前先回了ACK,而Flash写入失败,发送方根本不知道,还会继续发下一块,最终固件缺了一段,升级“成功”了但APP跑不起来。
3.3 起始帧里的文件大小字段:用处比你想的大
文件大小是YMODEM和XMODEM最核心的区别之一,不只是给接收方看的“信息”,更是判断传输结束的依据。整个传输中,数据帧的块号从1递增,最后一个数据块可能不足128字节或1024字节,所以不能靠“数据区不满一块”来判断传输结束,必须依赖起始帧里声明的文件大小。
我实际项目里的做法是:解析起始帧时把文件大小存到一个全局变量,每收到一个有效数据帧,就把已接收字节数累加,写Flash时也按这个累计值控制写入长度。当已接收字节数大于等于文件大小声明的值时,下一帧如果来了块号为0的结束帧,就正常收尾结束。如果实际收到的数据持续超过文件大小,多半是发送方和接收方对“该传多少字节”的理解不一致,直接发CAN取消升级,不要硬写。
文件大小还有另一个用处:提前发现固件超分区。如果声明的大小大于APP分区可用的空间,bootloader应该在起始帧解析完成后就直接拒绝,发CAN并提示“固件过大”,省得传一半写不进去。
4. 用状态机把YMODEM收下来:一份能直接搬进工程的C代码
4.1 为什么用状态机而不是阻塞收串口
YMODEM接收最大的难点在于:帧长度不固定,块号要校验,CRC要计算,数据要写Flash,而串口数据是一个字节一个字节异步到达的。如果用阻塞方式,在主循环里等串口数据,一次最多等128字节还好,如果发送方用1024字节的STX帧,接收期间做不了任何其他事,而且只要一个字节没接住,整帧就废了。
我习惯把所有接收逻辑做成一个状态机,串口中断每收到一个字节就喂给状态机一次。状态机内部根据当前状态决定这个字节该填到帧结构体的哪个位置。整个过程中不丢字节、不占主循环,数据处理(比如写Flash)放在主循环里做。这套结构也是我后来做其他串口协议时一直复用的模板。
4.2 核心代码:帧解析状态机
下面这段状态机代码是精简版,但结构可以直接搬进工程。定义三个状态:等帧头、收数据、收校验,外加一个“等待开始”的初始状态。
typedef enum { YM_WAIT_START, /* 等待发送方的起始帧 */ YM_RECV_HEAD, /* 接收帧头+块号+块号反码 */ YM_RECV_DATA, /* 接收数据区 */ YM_RECV_CRC /* 接收CRC16 两个字节 */ } YM_State_t; typedef struct { YM_State_t state; uint8_t block; /* 实际数据块号 */ uint8_t data[1024]; /* 数据区缓冲,最大1024字节 */ uint16_t len; /* 当前帧累计接收长度 */ uint16_t crc_recv; /* 接收到的CRC */ uint16_t crc_calc; /* 本地计算的CRC */ uint8_t frame_type; /* 当前帧类型:起始/数据/结束 */ } YM_Context_t; void YM_UartByteHandler(YM_Context_t *ctx, uint8_t byte) { switch (ctx->state) { case YM_WAIT_START: if (byte == 'C') { /* 对方要求我们用CRC模式 */ UART_SendByte(0x43); } else if (byte == 0x01) { /* SOH */ ctx->state = YM_RECV_HEAD; ctx->len = 0; ctx->frame_type = YM_FRAME_START; } break; case YM_RECV_HEAD: /* 第一个字节是块号,第二个是块号反码 */ if (ctx->len == 0) ctx->block = byte; if (ctx->len == 1) { if ((ctx->block ^ byte) != 0xFF) { /* 块号校验失败,要求重发 */ UART_SendByte(0x15); /* NAK */ ctx->state = YM_WAIT_START; break; } ctx->len = 0; ctx->state = YM_RECV_DATA; } ctx->len++; break; case YM_RECV_DATA: /* 128字节或1024字节数据区 */ ctx->data[ctx->len++] = byte; if (ctx->len >= 128) { ctx->state = YM_RECV_CRC; } break; case YM_RECV_CRC: /* 两个CRC字节,先收高字节再收低字节 */ if (ctx->len <= 128) { ctx->crc_recv = (uint16_t)byte << 8; ctx->len++; } else { ctx->crc_recv |= byte; /* 整帧收完,做CRC校验并处理 */ ctx->crc_calc = YM_CalcCRC16(ctx->data, 128); if (ctx->crc_calc != ctx->crc_recv) { UART_SendByte(0x15); } else { YM_ProcessFrame(ctx); UART_SendByte(0x06); /* ACK */ } ctx->state = YM_WAIT_START; } break; } }这段代码的逻辑很直接:每个字节进来,先判断当前状态,决定它是帧头、块号、数据还是校验字节。帧头只认SOH(0x01),完整支持STX(0x02)需要再加一个状态分支,逻辑一样。收到完整帧后先算CRC16,再决定回ACK还是NAK。YM_ProcessFrame是自留的处理函数,在那里区分这是起始帧还是数据帧,起始帧就解析文件名和文件大小,数据帧就写Flash。
CRC16的实现我直接用的标准CRC-16/CCITT算法,多项式0x1021,初始值0x0000。常见串口工具用的就是这套,算出来的值和发送方比对,能匹配上就说明算法选对了。
4.3 把固件块写入Flash:解锁、擦除、编程三步走
数据帧来了之后,最终目的是写进APP分区。STM32的内部Flash写入有三步不能省:解锁、擦除、编程。
void YM_WriteFlash(uint32_t addr, uint8_t *data, uint16_t len) { /* 1. Flash解锁 */ if (HAL_FLASH_Unlock() != HAL_OK) { YM_AbortTransmit(); /* 解锁失败直接取消 */ return; } /* 2. 按页擦除,每页偏移对齐到2KB */ uint32_t page_addr = addr & ~(2 * 1024 - 1); FLASH_EraseInitTypeDef erase_cfg; erase_cfg.TypeErase = FLASH_TYPEERASE_PAGES; erase_cfg.PageAddress = page_addr; erase_cfg.NbPages = 1; uint32_t page_err = 0; if (HAL_FLASHEx_Erase(&erase_cfg, &page_err) != HAL_OK) { HAL_FLASH_Lock(); return; } /* 3. 按32位字写入 */ for (uint16_t i = 0; i < len; i += 4) { uint32_t word = data[i] | (data[i+1] << 8) | (data[i+2] << 16) | (data[i+3] << 24); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + i, word) != HAL_OK) { HAL_FLASH_Lock(); return; } } HAL_FLASH_Lock(); }注意这里有个隐含条件:Flash擦除是按页的,写之前必须把整页擦掉。我的代码里每次只擦当前帧对应的那一页,但实际升级过程中,一页可能跨多个数据帧,所以擦除动作不能每帧都做。正确做法是记录当前已擦除到的页地址,只有需要写新页时才擦除。上面这段示例代码做了简化,实际使用时建议在每次YMODEM传输启动时,先一次性擦除整个APP分区,再边收边写。这样逻辑最简单,擦除时间可以放在等待起始帧的过程中,让用户感知不到。
还有一点,Flash写入时要求数据宽度对齐,用HAL库的WORD编程时必须保证地址是4的倍数。YMODEM的数据块是128字节或1024字节,天然对齐,但如果哪次用了SPL库的halfword编程,就要注意偶数对齐的坑。
5. YMODEM升级的常见翻车点与排查:现象、原因、解法
5.1 bootloader一直发C却收不到起始帧
现象:上电后串口终端疯狂打印字符“C”,选了文件点发送,bootloader还是没有反应,C继续往外蹦。 原因:最直接的原因是收发出错,但优先级最高的通常是接线或配置。一是TX和RX交叉接反,bootloader的TX接到对方TX上了;二是波特率不一致,终端设115200而bootloader初始化成了9600,两边各自“看到空气”;三是串口工具打开了硬件流控,RTS/CTS信号不存在导致数据发不出来。 解决:第一步用回环测试,把TX和RX短接自发自收,确认串口驱动没问题。第二步核对波特率,我的习惯是bootloader固定用115200,8N1,不做自适应波特率,省掉一堆麻烦。第三步检查终端软件设置,关闭流控,确认“发送”操作选的不是XMODEM而是YMODEM。还有一招特别实用:先用简单工具把一根固定线拉高看终端能否收到,再连上你的板子看是否收到bootloader发出的C,逐个排查很快。
5.2 固件写到一半卡死或看门狗复位
现象:YMODEM传输进行到一半,bootloader不再回复ACK,接着整个板子复位,重新进入升级等待状态。 原因:这种卡死大概率发生在Flash操作期间。如果时钟配置里Flash等待周期不足,Flash写入时CPU取指可能出错;或者传输期间打开了看门狗,而Flash擦除时间超过了看门狗喂狗周期;又或者写地址越界,写到了保留区,flash操作直接HardFault。 解决:先确认Flash等待周期与系统时钟匹配,72MHz主频通常是2个等待周期。传输数据期间不要在中断里做重活,擦除Flash时也是阻塞的。如果开了看门狗,在进入升级流程后要么暂停看门狗,要么在擦除Flash前先喂一次狗。我用的一种稳妥做法是:升级模式下直接关闭看门狗,等跳转到APP后由APP重新初始化看门狗。升级本来就是一个受控过程,不需要看门狗在里面搅局。
5.3 跳转后APP死机,复位也无法恢复
现象:YMODEM传输顺利完成,bootloader打印升级成功,跳转后APP黑屏或跑飞。只有通过调试器重新烧一次才能恢复。 原因:九成是中断遗留问题。bootloader里使能了串口中断或定时器中断,跳转时没有关闭,中断请求在跳转瞬间触发,CPU去取异常向量,但向量表还没切到APP,或者APP的向量表偏移还没来得及设置。 解决:跳转前先把外设中断全部清掉,不止是关中断,还要把中断挂起位清掉。SysTick停掉,DMA如果有在用的也要停。然后设置VTOR、设MSP、最后跳转。APP侧main函数第一行再设一次VTOR,双重保险。我也见过一种情况是APP编译时没把启动文件里的向量表定义到0x08004000,导致即使你设置了SCB->VTOR,向量表数据本身还在0x08000000跑,这种情况需要去看map文件确认__Vectors的地址。
5.4 传输中途反复NAK,最终工具报错退出
现象:前几个数据帧都正常,传到一半,bootloader连续发NAK,发送方重试几次后宣布传输失败。 原因:最常见的是串口接收缓冲溢出。128字节连续到达,如果中断处理不及时,一个字节没接住,整帧就作废。另外定时器中断优先级如果高于串口中断,串口中断会被长时间打断,导致丢字节。还有一个隐蔽问题:接收缓冲数组只有128字节,而发送方配置了1024字节的STX帧,你的代码按128字节接收,一帧内容根本装不下。 解决:确认YMODEM数据帧类型,发送工具一般可以通过配置选择1024字节还是128字节,默认一般是1024。bootloader两种都要支持。接收缓冲区按1024字节设计。中断里只做字节收集,把数据搬到环形缓冲区后就退出中断,解析放主循环做。串口中断优先级设成最高档之一,压过所有业务定时器。用DMA加空闲中断方式接收,可以彻底绕开单字节中断带来的压力,但代价是代码复杂度上去了。对于大部分YMODEM升级场景,1KB缓冲加环形队列已经足够。
6. 把YMODEM升级方案做扎实:三个实用改进
6.1 升级完回读校验,给“成功”两个字加个保险
YMODEM本身自带CRC16校验,但这只能证明“传输过程中数据没坏”,不能证明“数据写进了正确的位置”。我自己的习惯是传输完成后,bootloader不立即跳转,而是让用户确认。确认之前做一次Flash回读,把APP分区内容按32位字读出,重新算一遍CRC,与收到的固件CRC比对。两边一致才允许跳转,不一致就报错留在bootloader。这样即使Flash写入有异常位翻转,也能在跳转前抓住。回读校验会增加几百毫秒时间,但换来的是不会再出现“升级成功但跑不起来”的尴尬。
6.2 升级失败自动回退:给旧固件留一个“后悔药”
更完善的做法是双备份:APP分区分成A区和B区,各占一半。bootloader在起始帧里解析出固件版本号,与当前运行版本比对,决定写A区还是B区,然后更新跳转地址指向新版本。升级完成后如果APP运行异常,看门狗超时自动复位回bootloader,bootloader发现新版本没跑起来,自动回退到旧版本。这个方案的代价是可用Flash减半,但对现场维护价值巨大。我做过一个小批量产设备,就是靠这个回退机制,把一次“升级到一半断电”的事故从现场返修变成了远程重试。
6.3 传输过程加状态日志,排障不再靠猜
盲调YMODEM是最难受的。我会在bootloader里加一个调试串口,把状态机的每次状态切换都打印出来:发C、收到起始帧、文件名、文件大小、当前块号、CRC校验结果、写Flash地址、跳转动作。现场出问题时,对方只要把升级过程中终端里的日志贴回来,我就能定位问题出在“握手没成功”还是“写到一半崩了”。日志别用printf,直接用串口发字符串宏,关闭优化选项编译,bootloader体积会变大,但留到YMODEM协议稳定后再开优化裁剪也不迟。
我的经验是,YMODEM bootloader完成到“能收、能写、能跳、能回退”,才算真正的可用。功能调通以后先把升级测试脚本跑上几十遍,每次传输中随机断电,观察回退机制是否可靠。我早期就吃过一次亏,电断了三次才暴露出回退标志位的存储时序问题。从那以后,不管板子Flash多紧张,我都会留出那4KB存升级标志。希望帮到你。
本文还有配套的精品资源,点击获取