第3章:链接脚本(.ld)精讲:内存布局、段定义、符号导出、链接脚本与HAL的关联
链接脚本,说白了就是告诉编译器你的芯片内存长什么样、代码该放哪、数据该搁哪的一张地图。很多做应用层开发的同事觉得这玩意儿是编译工具链的事,跟自己没关系。但做底层移植,你要是看不懂 .ld 文件,那基本等于盲人摸象。
我记得刚入行那会儿,接手一个 STM32F4 的项目,Bootloader 死活跳不到应用程序。折腾了两天,最后发现是链接脚本里向量表偏移没设对。从那以后,我养成了一个习惯——拿到一个新平台,第一件事就是先把它的链接脚本通读一遍。
3.1 内存布局:芯片的"房产证"
每个 MCU 都有它的内存映射。Flash 多大、RAM 多少、外设寄存器在哪个地址段,这些信息都在芯片参考手册的 Memory Map 章节里。链接脚本的第一件事,就是把这些物理信息翻译成编译器能懂的语法。
看一个典型的 Cortex-M 系列链接脚本开头:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
CCMRAM (rw) : ORIGIN = 0x10000000, LENGTH = 64K
}
这里定义了三个内存区域:
- FLASH:只读+可执行,起始地址 0x08000000,大小 512KB。这是存放代码和只读数据的地方。
- RAM:读写+可执行,起始地址 0x20000000,大小 128KB。这是程序运行时的主战场。
- CCMRAM:内核紧耦合内存,速度更快,但有些型号的 DMA 访问不了。这个坑我踩过——把 DMA 缓冲区放 CCMRAM 里,结果数据死活传不过来。
3.2 段定义:代码和数据的"房间分配"
有了"房产证",接下来就是怎么分配房间了。链接脚本里的 SECTIONS 块,就是干这个活的。
一个标准的段定义结构长这样:
SECTIONS
{
.text :
{
. = ALIGN(4);
_stext = .;
*(.text*)
*(.rodata*)
_etext = .;
} > FLASH
.data :
{
. = ALIGN(4);
_sdata = .;
*(.data*)
_edata = .;
} > RAM AT> FLASH
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss*)
*(COMMON)
_ebss = .;
} > RAM
}
这里面有几个关键点:
- .text 段:放代码和只读常量。注意那个
. = ALIGN(4);,这是强制 4 字节对齐。ARM Cortex-M 的指令大部分是 2 或 4 字节,不对齐的话,轻则性能下降,重则直接 HardFault。 - .data 段:放已初始化的全局变量。这里有个坑——
> RAM AT> FLASH的意思是运行时在 RAM,但初始值存在 Flash 里。启动代码必须把这段数据从 Flash 拷贝到 RAM,不然变量值全是乱的。 - .bss 段:放未初始化的全局变量。启动代码需要把这部分清零。
--print-map 选项生成 map 文件,仔细检查每个段的地址范围。
3.3 符号导出:给启动代码的"路标"
你想想看,启动代码怎么知道 .data 段要从 Flash 的哪个位置拷贝?.bss 段要从哪开始清零?答案就是链接脚本里导出的符号。
上面代码里的 _stext、_etext、_sdata、_edata、_sbss、_ebss,这些就是链接器自动生成的符号。启动代码里用 extern 声明一下就能用:
extern uint32_t _sdata, _edata, _sbss, _ebss;
extern uint32_t _sidata; // .data 在 Flash 中的起始地址
void __attribute__((naked)) reset_handler(void)
{
// 拷贝 .data 段
uint32_t *src = &_sidata;
uint32_t *dst = &_sdata;
while (dst < &_edata) *dst++ = *src++;
// 清零 .bss 段
dst = &_sbss;
while (dst < &_ebss) *dst++ = 0;
// 跳转到 main
main();
}
这里有个细节:_sidata 并没有在 SECTIONS 里显式定义,它是通过 AT> FLASH 这个语法隐式生成的。链接器会自动计算 .data 段在 Flash 中的加载地址,并赋值给 LOADADDR(.data),我们通常用 _sidata 或 _la_data 这样的名字来引用它。
extern 引用。这个机制在 Bootloader 里特别有用——比如定义应用程序的起始地址、Bootloader 的版本号等。
3.4 链接脚本与HAL的关联:不只是"编译通过"
很多人觉得 HAL 库跟链接脚本没关系,直接拿官方的例程改改就能用。但实际做移植的时候,你会发现链接脚本直接影响 HAL 库的行为。
举个例子,STM32 的 HAL 库里有这么一段:
void HAL_IncTick(void)
{
uwTick++;
}
__weak void HAL_Delay(uint32_t Delay)
{
uint32_t tickstart = HAL_GetTick();
while ((HAL_GetTick() - tickstart) < Delay);
}
这个 uwTick 变量,如果链接脚本没有正确分配它的位置,或者被优化掉了,整个延时函数就废了。我遇到过有人把 uwTick 定义在某个特定的内存段里,结果链接脚本没包含这个段,变量根本没分配空间。
再比如中断向量表:
__attribute__((section(".isr_vector")))
void (* const g_pfnVectors[])(void) = {
&_estack,
Reset_Handler,
NMI_Handler,
// ...
};
这个 .isr_vector 段必须放在 Flash 的起始地址(0x08000000)。如果链接脚本里没有正确处理这个段,或者地址偏移设错了,芯片上电后直接跑飞。
| HAL 特性 | 链接脚本影响 | 常见问题 |
|---|---|---|
| 中断向量表 | 必须位于 Flash 起始地址 | Bootloader 跳转后中断不响应 |
| 系统滴答定时器 | uwTick 变量需在 RAM 中 | HAL_Delay 卡死 |
| DMA 缓冲区 | 需在 DMA 可访问的内存区域 | 数据传输异常 |
| MPU 配置 | 内存区域属性需与 MPU 设置一致 | 访问违例导致 HardFault |
__attribute__((section(...))) 全部搜一遍,看看它们都用了哪些自定义段。然后在链接脚本里为这些段预留位置。这一步做好了,后面能省 80% 的调试时间。
3.5 实战:为Bootloader定制链接脚本
Bootloader 的链接脚本和普通应用程序不一样。它需要:
- 把代码放在 Flash 的起始区域(比如前 32KB)
- 定义应用程序的起始地址符号
- 预留升级标志位和版本信息
一个典型的 Bootloader 链接脚本片段:
MEMORY
{
FLASH_BOOT (rx) : ORIGIN = 0x08000000, LENGTH = 32K
FLASH_APP (rx) : ORIGIN = 0x08008000, LENGTH = 480K
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.boot_header :
{
KEEP(*(.boot_header))
. = ALIGN(4);
} > FLASH_BOOT
.text :
{
_stext = .;
*(.text*)
*(.rodata*)
_etext = .;
} > FLASH_BOOT
/* 导出应用程序起始地址 */
_app_start = ORIGIN(FLASH_APP);
_app_vector = ORIGIN(FLASH_APP);
/* ... 其他段定义 ... */
}
这里 _app_start 和 _app_vector 就是给 Bootloader 用的路标。跳转时直接读这两个符号:
void jump_to_app(void)
{
extern uint32_t _app_vector;
uint32_t msp = *(uint32_t *)_app_vector;
void (*app_entry)(void) = (void (*)(void))(*(uint32_t *)(_app_vector + 4));
__set_MSP(msp);
app_entry();
}
3.6 本章小结:链接脚本是底层开发的"宪法"
链接脚本定义了整个固件的内存布局规则。它不像 C 代码那样有语法检查,写错了编译器不会报错,但程序跑起来就是各种诡异问题。
我个人习惯是:每拿到一个新平台,先花半小时把链接脚本从头到尾读一遍,把每个段的作用、每个符号的含义都搞清楚。然后在工程目录下放一个 memory_map.txt,把关键地址记下来。这个习惯帮我省了无数调试时间。
最后送大家一句话:链接脚本里没有"差不多",地址差一个字节,程序就是两个世界。