4. 固件升级基础
说实话,很多做嵌入式开发的朋友,写了几年C代码,却从来没碰过链接脚本。这其实挺可惜的。链接脚本就像是给编译器的一张“地图”,告诉它你的代码该放哪儿,数据该搁哪儿。尤其在Bootloader和App共存的场景下,这张地图画错了,程序跑起来就是灾难。
我个人习惯,拿到一个新MCU平台,第一件事不是写代码,而是先把链接脚本和内存布局图画清楚。磨刀不误砍柴工嘛。
4.1 链接脚本(.ld文件)到底在干什么?
简单说,链接脚本就是告诉链接器:“你的输入段(section)要放到输出文件的哪个位置”。你写的每个函数、每个全局变量,最终都会被归类到不同的段里。链接脚本决定了这些段在Flash和RAM里的具体地址。
一个典型的.ld文件,核心结构就这几块:
/* 入口点 */
ENTRY(Reset_Handler)
/* 内存区域定义 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
/* 段布局 */
SECTIONS
{
.text :
{
*(.isr_vector) /* 中断向量表 */
*(.text) /* 代码 */
*(.rodata) /* 只读数据 */
_etext = .; /* 代码段结束地址 */
} > FLASH
.data :
{
_sdata = .; /* 数据段起始地址 */
*(.data)
_edata = .; /* 数据段结束地址 */
} > RAM AT> FLASH
.bss :
{
_sbss = .; /* BSS段起始地址 */
*(.bss)
_ebss = .; /* BSS段结束地址 */
} > RAM
}
这里有个关键点:.data段用了> RAM AT> FLASH。什么意思?就是运行时它在RAM里,但初始值存在Flash里。启动代码需要把这段数据从Flash拷贝到RAM。我曾经见过一个同事忘了处理这个拷贝,结果全局变量全是乱的,查了两天bug……嗯,从那以后我每次都会检查启动文件里的__main或_start流程。
4.2 代码段、数据段、BSS段——它们到底怎么分布的?
你想想看,一个嵌入式程序编译出来,最终在芯片里是怎么放的?我画了张图,帮你理清这个关系。
Flash中的布局(烧录后):
- .isr_vector:中断向量表,必须放在起始地址
- .text:你的代码指令
- .rodata:字符串常量、const修饰的变量
- .data (初始值):全局/静态变量的初始值备份
RAM中的布局(运行时):
- .data:可读写的全局/静态变量(从Flash拷贝过来)
- .bss:未初始化或初始化为0的全局/静态变量(启动时清零)
- 堆(Heap):动态内存分配区域
- 栈(Stack):函数调用和局部变量区域
为什么要把.bss单独拎出来?因为它在Flash里不占空间。启动时只需要在RAM里把对应区域清零就行。省Flash空间,这是嵌入式开发的常识。
我建议你在链接脚本里把_sdata、_edata、_sbss、_ebss这些符号都定义清楚。启动代码要靠它们来完成数据拷贝和BSS清零。少了任何一个,程序都可能跑飞。
4.3 Bootloader与App的内存分区设计——这才是重头戏
做固件升级,核心就是Bootloader和App要物理隔离。它们不能互相踩踏。怎么隔离?靠链接脚本把各自的地址空间划清楚。
我一般这样划分:
| 区域 | 起始地址 | 大小 | 内容 |
|---|---|---|---|
| Bootloader Flash | 0x08000000 | 32KB | Bootloader代码、向量表 |
| App Flash | 0x08008000 | 448KB | App代码、向量表 |
| 参数存储区 | 0x0807E000 | 8KB | 升级标志、版本号等 |
| Bootloader RAM | 0x20000000 | 16KB | Bootloader运行时数据 |
| App RAM | 0x20004000 | 112KB | App运行时数据 |
注意看,Bootloader和App的Flash地址是连续的,但互不重叠。App的起始地址是0x08008000,也就是Bootloader结束的地方。RAM也是同理。
这里有个坑:中断向量表的重映射。App的向量表必须放在它自己的起始地址。在STM32上,你需要调用SCB->VTOR = APP_VECTOR_ADDR;来告诉内核:“嘿,中断向量表搬家了,别找错地方。”我刚开始做Bootloader时忘了这步,一进中断就死机,查了整整一个下午……
4.4 实战:为Bootloader和App分别编写链接脚本
Bootloader的链接脚本,内存区域要限制在它自己的地盘:
/* bootloader.ld */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 32K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 16K
}
App的链接脚本,起始地址要偏移:
/* app.ld */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 448K
RAM (rwx) : ORIGIN = 0x20004000, LENGTH = 112K
}
你可能会问:“App的RAM为什么也要偏移?”因为Bootloader和App是独立编译的,它们各自管理自己的RAM空间。如果App用了Bootloader的RAM区域,Bootloader在跳转前可能正在用那块内存存数据,一覆盖就乱套了。
我的小技巧:在App的链接脚本里,把_estack(栈顶地址)设置为App RAM区域的末尾。这样栈就不会往Bootloader的RAM里长了。安全第一。
4.5 一张图看懂整体布局
说了这么多,不如一张SVG图来得直观。下面这张图展示了Bootloader和App在Flash和RAM中的完整分区关系:
我曾经踩过的坑:有一次做OTA升级,Bootloader把新固件写到了Flash里,然后跳转到App。结果App死活跑不起来。查了半天,发现是Bootloader的链接脚本里RAM定义太大了,占用了App的RAM起始地址。Bootloader在跳转前用过的变量,正好落在App的.data段位置。App一启动,数据全乱了。从那以后,我强制要求Bootloader和App的RAM区域必须物理隔离,中间留个4KB的“缓冲区”作为安全间隔。
4.6 链接脚本中的符号导出——让启动代码找到关键地址
链接脚本里定义的_sdata、_edata这些符号,在C代码里怎么用?用extern声明就行:
/* 在启动文件或C代码中 */
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;
extern uint32_t _estack;
void Reset_Handler(void)
{
/* 拷贝.data段 */
uint32_t *src = &_sidata; /* Flash中的初始值地址 */
uint32_t *dst = &_sdata;
while (dst < &_edata) {
*dst++ = *src++;
}
/* 清零.bss段 */
dst = &_sbss;
while (dst < &_ebss) {
*dst++ = 0;
}
/* 设置栈指针 */
__set_MSP((uint32_t)&_estack);
/* 跳转到main */
main();
}
这里有个细节:_sidata是.data段在Flash里的初始值地址。它通常由链接脚本自动生成,指向LOADADDR(.data)。如果你在链接脚本里没定义这个符号,启动代码就不知道从哪儿拷贝数据。嗯,这个坑我也踩过。
4.7 总结一下关键点
- 链接脚本是内存布局的“宪法”——所有段的位置都由它决定
- Bootloader和App必须物理隔离——Flash和RAM都要划清界限
- 中断向量表重映射不能忘——App的VTOR要指向自己的起始地址
- 符号导出要完整——启动代码需要的
_sdata、_sbss、_estack一个都不能少 - RAM分区要留安全间隔——防止Bootloader的残留数据污染App
链接脚本这东西,看着枯燥,但它是整个固件系统的地基。地基没打好,上面盖的楼再漂亮也白搭。我每次做新项目,都会花至少半天时间反复确认内存布局,确保每个段都待在它该待的地方。这个习惯,帮我省了无数调试时间。