4、链接脚本与启动文件:链接脚本(.ld)结构解析、启动文件(startup.s)详解、堆栈设置与中断向量表、代码重定位基础
好,咱们进入第四讲。这一章可以说是 Bootloader 的骨架——链接脚本和启动文件。很多新手写 Bootloader,代码逻辑没问题,但一跑就死,十有八九是这两块没搞对。
我个人习惯,每做一个新项目,第一件事就是先把链接脚本和启动文件搭好。这就像盖房子打地基,地基歪了,后面装修再漂亮也没用。
4.1 链接脚本(.ld)结构解析
链接脚本,说白了就是告诉编译器:你的代码该放哪儿,数据该放哪儿。对于嵌入式系统来说,这尤其重要——因为我们的程序不是跑在操作系统上,而是直接跑在裸金属上。
一个典型的 .ld 文件长这样:
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} > FLASH
.text :
{
. = ALIGN(4);
*(.text)
*(.text*)
*(.glue_7)
*(.glue_7t)
*(.rodata)
*(.rodata*)
. = ALIGN(4);
} > FLASH
.data :
{
. = ALIGN(4);
_sdata = .;
*(.data)
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM AT> FLASH
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss)
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
}
这里有几个关键点,我重点说一下:
- ENTRY(Reset_Handler):指定程序入口。芯片上电后,第一条指令就是 Reset_Handler。
- MEMORY 区域:定义物理存储。FLASH 是只读的,RAM 是可读可写的。地址别搞错,我见过有人把 STM32F103 的 FLASH 地址写成 0x08000000 以外的值,结果程序死活跑不起来。
- .isr_vector 段:中断向量表。必须放在 FLASH 的最开头,这是硬件规定的。
- .data 段:已初始化的全局变量。注意它有个
AT> FLASH,意思是运行时在 RAM,但初始值存在 FLASH 里。启动文件需要把这些值从 FLASH 拷贝到 RAM。 - .bss 段:未初始化的全局变量。启动文件需要把这部分清零。
重要提示:链接脚本中的符号(如 _sdata、_edata、_sbss、_ebss)是给启动文件用的。启动文件通过访问这些符号,才知道要拷贝多少数据、清零多大区域。
4.2 启动文件(startup.s)详解
启动文件,就是那个 .s 文件。它负责在 main() 函数执行之前,把芯片环境准备好。我刚开始做嵌入式时,总觉得启动文件是编译器自动搞定的,直到有一次调试一个诡异的死机问题,才发现是启动文件里堆栈设置错了。
一个精简的启动文件核心部分:
.syntax unified
.cpu cortex-m3
.fpu softvfp
.thumb
.global g_pfnVectors
.global Default_Handler
/* 栈顶地址 */
.equ _estack, 0x20002000
.section .isr_vector, "a"
.type g_pfnVectors, %object
g_pfnVectors:
.word _estack
.word Reset_Handler
.word NMI_Handler
.word HardFault_Handler
/* ... 其他中断向量 ... */
.word 0
.word 0
.word 0
.word 0
.word WWDG_IRQHandler
/* ... 更多中断 ... */
g_pfnVectorsEnd:
.section .text.Reset_Handler
.weak Reset_Handler
.type Reset_Handler, %function
Reset_Handler:
/* 设置栈指针 */
ldr sp, =_estack
/* 拷贝 .data 段 */
ldr r0, =_sdata
ldr r1, =_edata
ldr r2, =_sidata
movs r3, #0
b LoopCopyDataInit
CopyDataInit:
ldr r4, [r2, r3]
str r4, [r0, r3]
adds r3, r3, #4
LoopCopyDataInit:
adds r4, r0, r3
cmp r4, r1
bcc CopyDataInit
/* 清零 .bss 段 */
ldr r0, =_sbss
ldr r1, =_ebss
movs r2, #0
b LoopFillZerobss
FillZerobss:
str r2, [r0]
adds r0, r0, #4
LoopFillZerobss:
cmp r0, r1
bcc FillZerobss
/* 跳转到 main */
bl main
b .
这段代码做了三件大事:
- 设置栈指针:把 SP 指向我们定义的栈顶地址。这个地址必须在 RAM 范围内,而且要给栈留够空间。
- 拷贝 .data 段:把 FLASH 中的初始值搬到 RAM 里。注意这里用到了链接脚本定义的 _sdata、_edata、_sidata。
- 清零 .bss 段:把未初始化的全局变量区域全部置零。
小技巧:我习惯在启动文件里加一个 SystemInit() 调用,放在拷贝 .data 之前。这样可以在早期就把系统时钟配置好,避免后续操作因为时钟不对而出问题。
4.3 堆栈设置与中断向量表
堆栈设置,嗯,这里要注意。很多 Bootloader 崩溃,都是因为栈溢出了。
栈的大小怎么定?我一般这样估算:
- 函数调用深度:每个函数调用压栈约 8-16 字节(看参数个数)
- 中断嵌套:最坏情况下,所有中断同时发生,每个中断需要 64-128 字节
- 局部变量:特别是大数组,千万别在函数里定义
char buf[1024],那会瞬间吃掉栈空间
我曾经在一个项目里,把栈设成了 512 字节,结果程序跑着跑着就莫名其妙复位。查了两天才发现,是一个中断服务函数里用了递归调用,栈直接爆了。从那以后,我至少给栈留 2KB 的空间。
中断向量表的结构很简单:
| 偏移地址 | 内容 | 说明 |
|---|---|---|
| 0x0000 | 栈顶地址 | 芯片上电后自动加载到 SP |
| 0x0004 | Reset_Handler 地址 | 上电后第一条指令 |
| 0x0008 | NMI_Handler | 不可屏蔽中断 |
| 0x000C | HardFault_Handler | 硬件错误,这个很重要 |
| ... | ... | 其他外设中断 |
警告:中断向量表的地址必须 4 字节对齐。如果你在链接脚本里把 .isr_vector 放在非对齐地址,芯片会直接 HardFault。我刚开始学的时候踩过这个坑,折腾了一整天。
4.4 代码重定位基础
代码重定位,说白了就是把程序从 FLASH 搬到 RAM 里跑。为什么要这么做?
- 速度更快:RAM 的访问速度通常比 FLASH 快很多
- 支持在线升级:Bootloader 需要擦写 FLASH,但自己又在 FLASH 里跑,这就矛盾了。把 Bootloader 搬到 RAM 里,就可以放心擦写 FLASH 了
重定位的基本思路:
- 在链接脚本里,把要重定位的段放在 RAM 区域
- 启动文件里,把这些段从 FLASH 拷贝到 RAM
- 修改向量表偏移寄存器(SCB->VTOR),指向 RAM 中的新向量表
代码示例:
/* 在链接脚本中定义重定位段 */
.relocate :
{
. = ALIGN(4);
_srelocate = .;
*(.relocate)
*(.relocate*)
. = ALIGN(4);
_erelocate = .;
} > RAM AT> FLASH
/* 在启动文件中拷贝 */
ldr r0, =_srelocate
ldr r1, =_erelocate
ldr r2, =_srelocate_load
bl CopyData
/* 设置向量表偏移 */
ldr r0, =_srelocate
ldr r1, =0xE000ED08 /* SCB->VTOR 地址 */
str r0, [r1]
你想想看,为什么重定位后要修改 VTOR?因为中断发生时,硬件会从 VTOR 指向的地址读取中断向量。如果不改,中断来了还是去 FLASH 找向量表,那就乱套了。
核心要点:重定位不是简单的 memcpy,还要考虑中断向量表的更新、链接脚本的配合、以及代码中绝对地址引用的处理。我建议你在做重定位之前,先用调试器单步跑一遍启动文件,确认每个段的位置都正确。
好了,这一章的内容就这些。链接脚本和启动文件是 Bootloader 的基石,花时间把它们搞透,后面写应用代码会顺畅很多。下一章我们讲 Flash 驱动与分区管理,到时候会用到今天学的重定位知识。