20、链接器脚本深入:MEMORY命令,SECTIONS命令,定位符号

说实话,很多C语言开发者写了好几年代码,对链接器脚本还是一头雾水。我刚开始做嵌入式开发时也是这样——编译器能跑就行,管它什么链接脚本?直到有一次,我需要在指定地址放一个bootloader,结果程序死活不按我的想法来。嗯,从那以后,我老老实实把链接器脚本啃了一遍。

今天咱们就聊聊链接器脚本里最核心的三个东西:MEMORY命令SECTIONS命令定位符号。说白了,它们就是告诉链接器:你的代码该放哪儿,数据该放哪儿,以及怎么找到它们。

20.1 MEMORY命令:定义你的硬件内存布局

先问个问题:你的芯片里有哪些内存区域?Flash?RAM?地址范围是多少?

MEMORY命令就是干这个的。它让你把目标硬件的物理内存描述清楚。我个人习惯,写任何嵌入式项目的第一步,就是先把MEMORY段定义好。

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
    CCRAM (rw)  : ORIGIN = 0x10000000, LENGTH = 64K
}

这里每个内存区域都有三个属性:

  • 名字:你自己起的,比如FLASH、RAM
  • 属性:r=读,w=写,x=执行
  • 起始地址和长度:硬件手册上查来的
我的小技巧:属性别乱写。比如Flash你写成rwx也没用,硬件上它就是只读的。我曾经见过有人把RAM写成rx,结果代码段放进去,调试时发现程序跑飞了——因为那片RAM根本不能执行代码。

你可能会问:为什么要有CCRAM这种区域?我在项目中遇到过,有些MCU有紧耦合内存(TCM),访问速度比普通RAM快很多。把中断向量表或者实时性要求高的数据放进去,性能提升很明显。

20.2 SECTIONS命令:把内容装进容器

MEMORY定义了「容器」,SECTIONS就是决定「什么东西装进哪个容器」。这是链接器脚本里最复杂、也最灵活的部分。

SECTIONS
{
    .text :
    {
        *(.text)
        *(.text.*)
        *(.rodata)
        *(.rodata.*)
    } > FLASH

    .data :
    {
        _sdata = .;
        *(.data)
        *(.data.*)
        _edata = .;
    } > RAM AT> FLASH

    .bss :
    {
        _sbss = .;
        *(.bss)
        *(.bss.*)
        *(COMMON)
        _ebss = .;
    } > RAM
}

这段脚本干了三件事:

  1. .text段:放代码和只读数据,放到FLASH里
  2. .data段:放初始化的全局变量,运行时在RAM里,但初始值存在FLASH里(AT> FLASH就是这个意思)
  3. .bss段:放未初始化的全局变量,只占RAM空间
重点来了.data段的AT> FLASH是什么意思?它表示:这个段的加载地址在FLASH,但运行地址在RAM。启动代码需要把数据从FLASH拷贝到RAM。这就是嵌入式系统里经典的「数据段重定位」。

我刚开始学的时候,一直搞不懂为什么.data要占两份空间。直到有一次做OTA升级,发现固件大小超了——原来.data段在FLASH里也占了一份,算总大小时得算上。嗯,这个坑我踩过。

20.3 定位符号:链接器给C代码的暗号

定位符号,就是那些以.开头的特殊变量。比如上面的_sdata_edata_sbss_ebss。它们不是真正的变量,而是链接器计算出来的地址值。

在C代码里怎么用?看这个启动代码片段:

extern uint32_t _sdata, _edata, _sbss, _ebss;
extern uint32_t _sidata;  // .data在FLASH里的起始地址

void 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();
}

你看,链接器脚本里定义的_sdata,在C代码里用extern声明一下就能用了。链接器在链接阶段会把它们的值填好。这就是链接器给C代码传递信息的「暗号」。

注意:定位符号的声明一定要用extern uint32_t,不能用extern uint8_textern uint16_t。因为链接器处理的是地址,地址永远是32位的(在32位系统上)。我曾经用extern uint8_t _sdata,结果取地址时只取了低8位,程序直接跑飞。

20.4 一个完整的例子:从脚本到运行

咱们把上面的东西串起来,画个图看看整个流程:

链接器脚本工作流程 链接器脚本 MEMORY { FLASH: 0x08000000, 512K RAM: 0x20000000, 128K } SECTIONS { .text → FLASH .data → RAM (AT FLASH) .bss → RAM } 定位符号: _sdata, _edata _sbss, _ebss 链接器 根据脚本分配地址 生成定位符号值 输出可执行文件 运行时内存布局 FLASH (0x08000000) .text: 代码 + 只读数据 .data初始值 (_sidata) RAM (0x20000000) .data: 已初始化全局变量 (_sdata ~ _edata) .bss: 未初始化全局变量 (_sbss ~ _ebss) 堆 + 栈 启动时拷贝

这个图展示了整个流程:链接器脚本定义内存布局 → 链接器根据脚本分配地址 → 运行时启动代码完成数据拷贝和BSS清零。每一步都离不开MEMORY、SECTIONS和定位符号的配合。

20.5 实战中的几个坑

最后分享几个我踩过的坑,希望能帮你省点调试时间:

坑1:定位符号的值是地址,不是内容
很多人写uint32_t data_start = _sdata;,以为拿到了.data段第一个字节的内容。错了!_sdata本身就是地址值,你应该用uint32_t *p = &_sdata;来取地址。
坑2:AT> 和 > 的区别
> RAM指定运行地址,AT> FLASH指定加载地址。如果你只写> RAM,链接器会认为数据本来就在RAM里——那上电时数据从哪来?全乱套了。
坑3:段顺序影响地址
SECTIONS里段的顺序,就是它们在内存里的顺序。如果你把.bss写在.data前面,那BSS段就会占据RAM的低地址。有些启动代码写死了地址偏移,一改顺序就崩。

嗯,链接器脚本这东西,说难不难,说简单也不简单。关键是要理解它背后的逻辑:你告诉链接器硬件长什么样,它帮你把程序安排得明明白白。下次遇到链接错误,别急着怀疑编译器,先看看你的链接脚本对不对。

我的建议:刚开始接触时,从芯片厂商提供的默认脚本改起。别自己从头写,容易漏东西。我早期就犯过这个错——自己写了个脚本,忘了加*(.text.*),结果某些函数被优化掉,程序莫名其妙少功能。

好了,关于MEMORY、SECTIONS和定位符号,今天就聊到这儿。这些东西看着枯燥,但用好了,你能精确控制程序的每一字节放在哪。在嵌入式、操作系统开发、甚至一些性能优化场景下,这都是必备技能。