第二节:固件架构基础——Bootloader与Application分区设计、内存映射与启动流程
好,咱们今天聊点实在的。协议转换器这东西,说白了就是个“翻译官”,把一种协议的数据包转成另一种。但翻译官要是自己死机了怎么办?所以固件架构里,最核心的就是怎么让设备“死不了”,甚至能远程救活。这就得靠Bootloader和Application的分区设计。
2.1 为什么非要分两个区?
我刚开始做嵌入式那会儿,也犯过懒——直接把应用程序写死在Flash里,不搞什么Bootloader。结果有一次现场升级,写到一半断电了,设备直接变砖。客户电话打过来,我连夜坐火车去现场用JTAG重新烧……那滋味,真不好受。
所以,分区设计的核心目的就两个:
- 安全升级:升级失败还能回滚,不至于变砖。
- 启动自检:上电先跑Bootloader,检查Application是否完整,不完整就等着你给它喂固件。
一句话总结:Bootloader是“救生员”,Application是“运动员”。救生员先下水检查,确认运动员状态OK,才让他上场。
2.2 内存映射:Flash与RAM怎么分?
咱们以常见的STM32F4系列为例,它的Flash通常是1MB,RAM是192KB。我个人习惯这样划分:
| 区域 | 起始地址 | 大小 | 内容 |
|---|---|---|---|
| Bootloader | 0x0800 0000 | 64KB | 启动代码、升级协议栈 |
| Application | 0x0801 0000 | 896KB | 主业务逻辑 |
| 参数存储区 | 0x080F 0000 | 64KB | 配置参数、校准数据 |
你可能会问:“为什么Bootloader只给64KB?”嗯,因为Bootloader的功能其实很单纯——就是检查、跳转、接收新固件。代码量不会太大。我曾经见过有人给Bootloader分了256KB,结果里面塞了一堆调试打印和图形库……那纯粹是浪费空间。
RAM的分配也类似,我一般这样安排:
- 0x2000 0000 ~ 0x2000 7FFF:Bootloader专用RAM(32KB),用于升级过程中的临时缓冲。
- 0x2000 8000 ~ 0x2002 FFFF:Application专用RAM(160KB),运行时堆栈、全局变量。
注意:Bootloader和Application的RAM区域绝对不能重叠!否则你升级到一半,Bootloader把Application的全局变量覆盖了,系统立马崩溃。我曾经在调试一个Modbus转Profinet的协议转换器时,就踩过这个坑——Bootloader的接收缓冲区刚好和Application的Modbus寄存器映射区冲突,导致每次升级完第一次通信就死机。查了三天才找到原因。
2.3 启动流程详解:从复位到跑业务
好,现在咱们看看上电后到底发生了什么。说白了,就是三步走:
- 硬件复位:CPU从0x0800 0000取出栈指针,从0x0800 0004取出复位向量,跳转到Bootloader入口。
- Bootloader自检:检查Application的CRC校验、版本号、升级标志位。
- 跳转或等待:如果Application有效,就跳过去;否则进入升级模式,等待上位机发固件。
代码实现上,跳转那一步最讲究。我贴一段我常用的跳转函数:
typedef void (*pFunction)(void);
void JumpToApplication(uint32_t appAddr)
{
uint32_t stackPtr = *(volatile uint32_t*)appAddr;
uint32_t resetAddr = *(volatile uint32_t*)(appAddr + 4);
pFunction jumpToApp = (pFunction)resetAddr;
// 关闭所有中断,防止跳转后中断向量表混乱
__disable_irq();
for (int i = 0; i < 8; i++) {
NVIC->ICER[i] = 0xFFFFFFFF;
NVIC->ICPR[i] = 0xFFFFFFFF;
}
// 设置主栈指针
__set_MSP(stackPtr);
// 跳转
jumpToApp();
}
这里有个细节:跳转前一定要关中断。为什么?因为Application的中断向量表在0x0801 0000,而Bootloader的中断向量表在0x0800 0000。如果你不关中断,跳转过程中突然来了个定时器中断,CPU会跑到Bootloader的中断向量表里去,结果发现根本找不到对应的处理函数——直接HardFault。
我的小技巧:在Application的启动文件里,第一件事就是重新设置中断向量表偏移:
SCB->VTOR = 0x08010000;
这样Application自己就能正确处理中断了。这个操作必须在进入main()之前完成,所以我一般放在汇编启动文件里。
2.4 升级标志位:怎么知道该不该升级?
你想想看,Bootloader怎么知道用户想升级?总不能每次上电都傻等着吧。所以我们需要一个升级标志位,通常放在备份寄存器或者Flash的最后一个扇区。
我常用的做法是:
- 在Application中,收到升级指令后,往备份寄存器写一个魔数(比如0xA5A5A5A5)。
- 然后软件复位。
- Bootloader启动后,检查备份寄存器。如果等于魔数,就进入升级模式;否则正常跳转。
代码示例:
// Application中触发升级
void TriggerFirmwareUpdate(void)
{
// 写魔数到备份寄存器
RTC->BKPDR0 = 0xA5A5A5A5;
// 软件复位
NVIC_SystemReset();
}
// Bootloader中检查
uint32_t flag = RTC->BKPDR0;
if (flag == 0xA5A5A5A5) {
// 进入升级模式
RTC->BKPDR0 = 0; // 清除标志
EnterUpdateMode();
} else {
JumpToApplication(APP_ADDR);
}
注意:备份寄存器在掉电后数据会丢失(除非有VBAT供电)。如果你的设备没有后备电池,建议把标志位写在Flash的最后一个扇区。不过写Flash比较慢,而且有擦写寿命限制,所以一般只在确实需要升级时才写一次。
2.5 避坑指南:我踩过的几个雷
最后,分享几个我实际项目中遇到的坑,希望能帮你省点时间:
- CRC校验别偷懒:我曾经为了省事,只检查了固件大小,没做CRC。结果有一次串口传输丢了一个字节,固件大小没变,但代码跑起来就死机。从那以后,我每次升级完都会对整个Application区做CRC32校验。
- 跳转地址要对齐:ARM Cortex-M要求中断向量表必须256字节对齐。如果你把Application放在0x0801 0100,那跳转后第一个中断就会跑飞。我一般直接按扇区对齐(比如0x0801 0000),省心。
- 升级过程中别断电:虽然Bootloader设计上能应对断电,但最好还是在升级前给用户一个明确的提示:“升级中请勿断电”。我在一个工业协议转换器项目里,就因为没加这个提示,客户在升级时直接拔了电源……虽然最后靠Bootloader的恢复机制救回来了,但过程很折腾。
嗯,关于Bootloader和Application的分区设计,今天就聊到这儿。下一节咱们会深入讲讲升级协议的具体实现——怎么把固件从PC端可靠地传过来。到时候再细聊。