3. Bootloader核心概念

链接脚本,说白了就是告诉链接器:你的代码该往哪里放。很多刚入行的朋友觉得这玩意儿就是抄个模板,改改地址就行。我刚开始做 Bootloader 时也这么想,结果有一次芯片死活跑不起来,折腾了两天,最后发现是 .bss 段没清零——链接脚本里少写了一行。

嗯,从那以后我再也不敢小看 .lds 了。今天咱们就把这东西彻底讲透。

3.1 链接脚本的结构骨架

一个典型的 .lds 文件,其实就三大部分:

  • 内存布局定义(MEMORY 命令)
  • 段映射规则(SECTIONS 命令)
  • 符号导出(PROVIDE、ASSERT 等辅助命令)

我个人习惯把链接脚本比作一张「房产证」——MEMORY 告诉你有几块地(RAM、Flash),SECTIONS 告诉你每间房(代码段、数据段)分给谁。

核心要点:链接脚本的最终产物是一个 ELF 文件,里面包含了每个段的起始地址、长度和内容。Bootloader 的启动代码就是靠这些地址来初始化内存的。

3.2 MEMORY 命令:定义你的「地皮」

先看一个实际项目中我常用的内存定义:

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

这里每个字段的含义:

字段含义我踩过的坑
ORIGIN起始地址一定要和芯片手册的 memory map 对齐,差一个字都不行
LENGTH可用大小别写满,留点余量给堆栈
(rx)/(rwx)访问属性Flash 别写 w,否则链接器会报错

我曾经犯过的错:把 FLASH 的 LENGTH 写成了 128K,但芯片实际只有 64K。链接时没报错,下载后程序跑飞。后来加了 ASSERT 才抓住这个问题。

3.3 SECTIONS 命令:段映射的核心

这是链接脚本里最复杂的部分。咱们拆开来看。

3.4 .text 段:代码的归宿

SECTIONS
{
    .text :
    {
        . = ALIGN(4);
        _stext = .;        /* 代码段起始符号 */
        *(.isr_vector)     /* 中断向量表,必须放在最前面 */
        *(.text*)          /* 所有 .text 子段 */
        *(.rodata*)        /* 只读数据,比如字符串常量 */
        . = ALIGN(4);
        _etext = .;        /* 代码段结束符号 */
    } > FLASH
}

你想想看,为什么中断向量表要放最前面?因为芯片复位后,CPU 会从 0x08000000(或你设置的起始地址)取前 4 字节作为栈指针,再取 4 字节作为复位向量。顺序错了,系统直接死机。

个人经验:我习惯在 .text 段开头和结尾都放一个符号(_stext / _etext),这样启动代码里可以计算代码段大小,方便做校验和。

3.5 .data 段:初始化的全局变量

.data : AT (ADDR(.text) + SIZEOF(.text))
{
    . = ALIGN(4);
    _sdata = .;
    *(.data*)
    . = ALIGN(4);
    _edata = .;
} > RAM AT > FLASH

这里有个关键点:AT 指令。它表示 .data 段在运行时位于 RAM,但它的初始值存储在 Flash 里(紧跟在 .text 后面)。启动代码必须把这些初始值从 Flash 拷贝到 RAM。

我记得第一次看到这个语法时,完全没理解「> RAM AT > FLASH」是什么意思。说白了就是:运行地址在 RAM,加载地址在 Flash。

3.6 .bss 段:未初始化的全局变量

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

.bss 段不需要从 Flash 拷贝初始值,因为它的值就是 0。但启动代码必须把它清零。我见过太多人忘了这一步,结果全局变量一上电就是随机值,程序行为完全不可控。

避坑指南:如果你的芯片有多个 RAM 区域(比如 CCMRAM),记得给每个区域单独定义 .bss 段,否则链接器会把所有 .bss 塞到默认 RAM 里,导致溢出。

3.7 完整的段映射关系图

下面这张图是我做培训时必画的,它把整个内存布局讲得清清楚楚:

Bootloader 内存布局与段映射 Flash (0x08000000) .isr_vector (中断向量表) .text (代码 + .rodata) _stext ~ _etext .data 初始值 (LMA) 剩余 Flash 空间 (可用于固件升级存储) RAM (0x20000000) .data (运行时 VMA) _sdata ~ _edata .bss (清零区域) _sbss ~ _ebss 堆 (Heap) 栈 (Stack) _estack = 0x20010000 启动时拷贝 LMA = 加载地址(Flash 中的存储位置),VMA = 运行地址(RAM 中的实际位置) 启动代码负责:① 拷贝 .data ② 清零 .bss ③ 设置堆栈指针

3.8 链接脚本中的符号导出

除了段定义,链接脚本还经常用 PROVIDE 导出符号,供启动代码使用:

PROVIDE(_estack = ORIGIN(RAM) + LENGTH(RAM));
PROVIDE(_heap_start = _ebss);
PROVIDE(_heap_end = ORIGIN(RAM) + LENGTH(RAM) - 4K);

这些符号在 C 代码里用 extern 声明就能直接引用:

extern uint32_t _estack;
extern uint32_t _sdata, _edata;
extern uint32_t _sbss, _ebss;

void startup(void)
{
    uint32_t *src, *dst;
    
    // 拷贝 .data
    src = &_sdata;  // 实际是 Flash 地址?不对!
    // 这里需要 __etext 符号指向 Flash 中的初始值
}

注意:上面代码有个陷阱。_sdata 是 RAM 地址,不是 Flash 地址。要获取 .data 在 Flash 中的位置,需要额外定义一个符号,比如 __etext,放在 .text 段末尾。我习惯这样写:

.text :
{
    *(.text*)
    *(.rodata*)
    . = ALIGN(4);
    _etext = .;  /* 这就是 .data 初始值在 Flash 中的起始地址 */
} > FLASH

3.9 多区域内存布局的实战技巧

有些芯片有多个 RAM 区域,比如 STM32 的 CCMRAM。这时候需要为每个区域单独定义段:

.ccm_data :
{
    *(.ccm_data*)
} > CCMRAM

.ccm_bss (NOLOAD) :
{
    *(.ccm_bss*)
} > CCMRAM

然后在 C 代码里用属性指定:

__attribute__((section(".ccm_data"))) uint32_t fast_buffer[1024];
__attribute__((section(".ccm_bss")))  uint32_t temp_flag;

我个人习惯把中断处理函数用的变量放到 CCMRAM,因为它的访问延迟更低。但要注意,CCMRAM 不支持 DMA 访问,别把 DMA buffer 放进去。

3.10 链接脚本的调试与验证

写完链接脚本后,怎么确认它是对的?我一般用这几个方法:

  • 查看 .map 文件:链接器生成的 map 文件会列出每个段的地址和大小,仔细检查起始地址对不对
  • 用 readelf 或 objdump:arm-none-eabi-objdump -h firmware.elf 可以看段信息
  • 加 ASSERT:在链接脚本里加断言,比如 ASSERT(_ebss <= ORIGIN(RAM) + LENGTH(RAM), "BSS overflow!")

我曾经踩过的坑:有一次 .data 段和 .bss 段之间有 4 字节的间隙,导致拷贝后 .bss 的起始地址不对。后来我在每个段之间都加了 . = ALIGN(4);,再也没出过问题。

链接脚本这东西,看着简单,但细节特别多。你只要把 MEMORY、SECTIONS、符号导出这三块搞明白,再配合 map 文件反复验证,基本就能驾驭了。下次遇到芯片跑不起来,先别急着查代码,看看链接脚本——说不定问题就出在这里。