4. 链接脚本精讲:GCC与Tasking链接脚本语法、内存布局与段重定位
链接脚本,说白了就是告诉编译器:「你的代码和数据,应该放在硬件的哪个位置」。很多做应用层开发的同事觉得这东西可有可无,但在车载ECU开发里,链接脚本写错了,轻则程序跑飞,重则直接变砖。我刚开始做Bootloader时,就因为RAM区地址算错了一位,导致整车上电后ECU死锁,最后只能拆壳子用编程器救回来。嗯,从那以后我再也不敢轻视链接脚本了。
4.1 为什么需要链接脚本?
你想想看,一个MCU里既有Flash(存放代码和常量),又有RAM(存放变量和堆栈),还有EEPROM(存放标定参数和故障码)。编译器生成的.o目标文件里,每个段(Section)都只是相对地址。链接脚本的作用,就是把这些相对地址「映射」到真实的物理地址上。
核心任务: 告诉链接器——.text段放Flash起始地址0x8000000,.data段初始值放Flash,运行时搬到RAM,.bss段在RAM清零。
4.2 GCC链接脚本语法(LD脚本)
GCC用的链接脚本,扩展名通常是.ld或.lds。我习惯把脚本分成三块:内存布局定义、段定义、以及符号导出。
4.2.1 内存布局:MEMORY命令
MEMORY命令用来描述目标芯片的物理存储资源。每个区域都要指定名字、起始地址、长度和属性。
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1M
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
EEPROM (rw) : ORIGIN = 0x08080000, LENGTH = 16K
}
| 属性 | 含义 | 常见用途 |
|---|---|---|
| r | 可读 | 所有区域基本都有 |
| w | 可写 | RAM、EEPROM |
| x | 可执行 | Flash中的代码段 |
| ! | 取反 | 如!x表示不可执行 |
个人习惯: 我会在MEMORY块里把EEPROM单独列出来,而不是塞进Flash末尾。这样链接器能自动检查EEPROM区是否溢出,避免标定数据把代码覆盖掉。
4.2.2 段定义:SECTIONS命令
SECTIONS是链接脚本的核心。它定义了每个段放在哪个内存区域,以及段之间的排列顺序。
SECTIONS
{
. = ORIGIN(FLASH);
.text : {
KEEP(*(.isr_vector)) /* 中断向量表,必须放在最前面 */
*(.text*) /* 所有代码 */
*(.rodata*) /* 只读数据 */
. = ALIGN(4);
} > FLASH
. = ORIGIN(RAM);
.data : {
_sdata = .; /* 数据段起始地址(RAM中) */
*(.data*)
_edata = .; /* 数据段结束地址 */
} > RAM AT > FLASH /* 加载地址在Flash,运行地址在RAM */
.bss : {
_sbss = .;
*(.bss*)
*(COMMON)
_ebss = .;
} > RAM
}
这里有个关键点:> RAM AT > FLASH。意思是.data段运行时在RAM,但初始值存储在Flash里。启动代码必须把这段数据从Flash拷贝到RAM。我曾经见过一个项目,工程师忘了加AT > FLASH,结果.data段直接烧在RAM里,一断电全丢了。
4.2.3 符号导出与启动代码配合
链接脚本里定义的符号(如_sdata、_edata),可以在C代码或汇编启动代码里直接引用:
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;
void startup(void)
{
uint32_t *src, *dst;
// 拷贝.data段
src = &_sidata; // Flash中的加载地址
dst = &_sdata; // RAM中的运行地址
while (dst < &_edata) *dst++ = *src++;
// 清零.bss段
dst = &_sbss;
while (dst < &_ebss) *dst++ = 0;
main();
}
避坑指南: 我曾经在某个项目里,把_sidata和_sdata的定义搞反了,结果启动时.data段拷贝到了错误的位置,全局变量全部乱掉。排查了整整两天才发现是链接脚本里符号名写错了。建议你在链接脚本里给符号加注释,比如/* Flash中数据起始 */。
4.3 Tasking链接脚本语法(LSL脚本)
Tasking编译器在英飞凌TC3xx系列里用得很多。它的链接脚本叫LSL(Linker Script Language),语法和GCC差别挺大。我第一次从GCC切到Tasking时,光看语法就懵了半天。
4.3.1 内存与总线描述
Tasking的LSL用memory关键字定义物理存储,用bus定义总线映射:
memory flash
{
mau = 8;
size = 1M;
type = rom;
map (dest=bus:tc_bus, dest_offset=0x80000000, size=1M);
}
memory ram
{
mau = 8;
size = 128K;
type = ram;
map (dest=bus:tc_bus, dest_offset=0x70000000, size=128K);
}
注意这里的map语句,它把物理内存映射到系统总线的地址空间。TC3xx的Flash物理地址和CPU访问地址可能不同,LSL通过map来建立映射关系。
4.3.2 段定义与分组
Tasking用section_setup和section来管理段:
section_setup :: ".text"
{
group (ordered, contiguous, run_addr=mem:flash)
{
section "*.text" (attributes=rx);
section "*.rodata" (attributes=r);
}
}
section_setup :: ".data"
{
group (ordered, contiguous, run_addr=mem:ram, copy)
{
section "*.data" (attributes=rw);
}
}
copy关键字告诉链接器:这个段需要从加载地址拷贝到运行地址。Tasking会自动生成拷贝表,启动代码里调用__init_copy_table即可完成搬运。
4.3.3 中断向量表与保留区
在TC3xx里,中断向量表(BTV)必须放在特定地址。LSL里可以这样保留空间:
section_setup :: ".inttab"
{
group (ordered, contiguous, run_addr=0x80000000)
{
section "*.inttab" (attributes=rx, size=0x200);
}
}
我的经验: Tasking的LSL脚本里,group的ordered和contiguous属性一定要加。不加ordered的话,链接器可能打乱段顺序,导致中断向量表不在最前面。不加contiguous,段之间可能插入填充,浪费Flash空间。
4.4 内存布局实战:一个典型的车载ECU分区
下面是我在一个实际项目里用过的Flash分区方案。这个ECU需要支持Bootloader和APP双区启动:
MEMORY
{
/* Bootloader区:64KB */
BOOT_FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K
/* APP区:896KB */
APP_FLASH (rx) : ORIGIN = 0x08010000, LENGTH = 896K
/* EEPROM模拟区:16KB,用Flash模拟 */
EEPROM_FLASH (rw): ORIGIN = 0x080F0000, LENGTH = 16K
/* RAM区:128KB */
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
对应的段分配策略:
- Bootloader代码:放在BOOT_FLASH,只包含启动、校验、跳转逻辑
- APP代码:放在APP_FLASH,包含应用层和标定数据
- EEPROM数据:放在EEPROM_FLASH,用Flash模拟EEPROM,按页擦写
- 变量和堆栈:放在RAM,注意Bootloader和APP共用RAM时要避开冲突区
重要提醒: Bootloader和APP的链接脚本里,RAM的起始地址和大小必须一致。我曾经见过一个项目,Bootloader的RAM用了0x20000000-0x20001FFF,APP的RAM却从0x20000000-0x20003FFF,结果跳转后堆栈指针指向了未初始化的区域,直接HardFault。
4.5 段重定位:让代码在任意地址运行
段重定位是Bootloader的核心能力之一。说白了,就是让一段代码能在Flash的不同位置执行。最常见的场景是:Bootloader把APP从CAN总线收到的固件数据,写入到APP_FLASH区域,然后跳转过去。
GCC里实现重定位,主要靠位置无关代码(PIC)和链接脚本符号:
/* 在APP的链接脚本中,定义APP的起始地址 */
APP_START = 0x08010000;
SECTIONS
{
.text APP_START : {
*(.text*)
} > APP_FLASH
/* 导出一个跳转地址符号 */
_app_entry = APP_START + 4; /* 跳过中断向量表的前4字节(栈指针) */
}
Bootloader跳转时,只需要读取APP的中断向量表,取出栈指针和复位地址:
typedef void (*app_func_t)(void);
void jump_to_app(uint32_t app_addr)
{
uint32_t msp = *(uint32_t*)app_addr; // 栈指针
uint32_t reset = *(uint32_t*)(app_addr + 4); // 复位地址
__set_MSP(msp); // 切换栈
app_func_t app = (app_func_t)reset;
app(); // 跳转
}
关键点: 跳转前必须关闭全局中断、禁用外设时钟、清理外设状态。否则APP启动时可能因为外设处于异常状态而卡死。我习惯在跳转前调用一个DeInitAllPeripherals()函数。
4.6 链接脚本常见错误与调试技巧
| 错误现象 | 可能原因 | 排查方法 |
|---|---|---|
| 程序跑飞,PC指向0xFFFFFFFF | 中断向量表地址不对,或跳转地址错误 | 查看.map文件,确认.text段起始地址 |
| 全局变量值不对 | .data段拷贝未执行,或拷贝源地址错误 | 检查_sidata和_sdata的值是否匹配 |
| 链接时报"region overflow" | 段大小超过内存区域长度 | 优化代码大小,或调整分区大小 |
| 函数指针调用异常 | 函数被链接到了错误的内存区域 | 用__attribute__((section(".text")))强制指定 |
调试技巧: 每次修改链接脚本后,第一件事就是看生成的.map文件。我通常会搜索"__text_start"、"__data_start"等符号,确认地址和预期一致。另外,用objdump -h查看elf文件的段信息,也能快速定位问题。
4.7 本章知识体系
下面这张图总结了链接脚本的核心要素和它们之间的关系:
链接脚本是嵌入式开发的「地图」。地图画错了,代码跑得再快也没用。我建议你每次新建项目时,先花半小时把链接脚本写清楚,把.map文件看懂。这半小时,能帮你省下后面几天的调试时间。