第1章:链接脚本与内存布局

大家好,我是你们的Bootloader讲师。今天咱们聊聊链接脚本和内存布局——这玩意儿说难不难,说简单吧,我见过太多人在上面栽跟头了。

先问个问题:你写好的C代码,编译后是怎么变成二进制文件的?代码放哪儿?数据放哪儿?未初始化的全局变量又去哪儿了?嗯,答案就在链接脚本里。

1.1 链接脚本(.lds)语法详解

链接脚本,说白了就是告诉链接器:你的程序各部分该往内存的哪个地址放。我刚开始做嵌入式时,觉得这玩意儿可有可无,直到有一次程序跑飞了,查了两天才发现是链接脚本里地址写错了……从那以后,我再也不敢小看它了。

一个典型的链接脚本长这样:

ENTRY(Reset_Handler)

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

SECTIONS
{
    .text :
    {
        *(.isr_vector)
        *(.text)
        *(.text.*)
        *(.rodata)
        *(.rodata.*)
        _etext = .;
    } > FLASH

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

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

咱们拆开来看:

  • ENTRY:指定程序入口点。我习惯用Reset_Handler,也就是上电后第一条指令的位置。
  • MEMORY:定义可用的内存区域。Flash是只读的,RAM是可读写的。
  • SECTIONS:定义各段怎么放。这是核心部分。

小技巧:ORIGIN和LENGTH这两个参数,一定要和芯片的数据手册对上。我曾经见过有人把Flash起始地址写成了0x08001000,结果程序死活跑不起来——因为中断向量表放错位置了。

1.2 代码段/数据段/BSS段布局

咱们的程序编译后,主要分成三个段:

段名 内容 存放位置 特点
.text 代码、常量、中断向量表 Flash 只读,掉电不丢失
.data 已初始化的全局/静态变量 运行时在RAM,加载时在Flash 需要从Flash拷贝到RAM
.bss 未初始化的全局/静态变量 RAM 启动时需清零

你想想看,为什么.data段要搞这么复杂?因为变量初始值得保存在Flash里,但运行时变量是要读写的,所以得搬到RAM里。这个搬运工作,就是Bootloader启动代码要干的第一件事。

重点来了:我在项目中遇到过一个问题——.data段拷贝没做好,结果全局变量初始值全是乱的。排查了半天,最后发现是链接脚本里AT> FLASH这个标记写漏了。没有这个标记,链接器会把.data段也放在RAM地址上,但Flash里根本没存初始值。

1.3 运行时地址与加载地址

这两个概念,我当年学的时候也绕了好久。简单说:

  • 加载地址(LMA):程序烧录时放在哪。通常是Flash地址。
  • 运行时地址(VMA):程序执行时实际在哪。代码段一般在Flash里直接跑,数据段得搬到RAM里跑。

为什么会这样?因为Flash读取速度慢,而且不能写。所以:

  1. 代码段(.text)直接在Flash里执行——反正只读,不用改。
  2. 数据段(.data)必须搬到RAM里——因为要读写。
  3. BSS段在RAM里,启动时清零——因为初始值都是0,不用从Flash拷。

看看这个例子就明白了:

// 假设全局变量
int g_counter = 100;    // 这个在.data段
int g_buffer[1024];     // 这个在.bss段

// 启动代码里要做的:
// 1. 从Flash拷贝g_counter的初始值到RAM
// 2. 把g_buffer所在的RAM区域全部清零

注意:我曾经踩过一个坑——Bootloader里用了一个大数组做缓存,放在.bss段。结果启动时清零花了太长时间,导致看门狗超时复位了。后来我改成在链接脚本里把这个数组放到一个单独的段,启动时不自动清零,手动按需初始化。

1.4 链接脚本中的符号与变量

链接脚本里定义的符号,C代码可以直接用。比如前面脚本里的_sdata、_edata、_sbss、_ebss,还有_etext。这些符号在C代码里要这么声明:

extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;
extern uint32_t _etext;

// 然后就可以用了
void startup_handler(void)
{
    // 拷贝.data段
    uint32_t *src = &_etext;
    uint32_t *dst = &_sdata;
    while (dst < &_edata) {
        *dst++ = *src++;
    }
    
    // 清零.bss段
    dst = &_sbss;
    while (dst < &_ebss) {
        *dst++ = 0;
    }
}

嗯,这里要注意:链接脚本里的符号,取地址时得到的是链接地址,不是运行地址。不过对于.data段来说,_sdata和_edata是运行时地址(VMA),而_etext是加载地址(LMA)——因为代码段后面紧跟着数据段的初始值。

个人习惯:我一般会在链接脚本里多定义几个辅助符号,比如_heap_start和_heap_end,方便管理堆空间。这样C代码里malloc的实现就简单多了。

1.5 常见问题与避坑指南

最后,分享几个我实际项目中遇到的坑:

  • 段对齐问题:ARM芯片要求中断向量表必须4字节对齐,有些还要求256字节对齐。链接脚本里用. = ALIGN(4)可以搞定。
  • 内存重叠:两个段不小心放到了同一个地址范围,程序跑起来会互相覆盖。我建议每次改链接脚本后,都看看生成的.map文件确认一下。
  • 栈位置:栈一般放在RAM的末尾,向下生长。链接脚本里要预留足够的栈空间,不然函数调用深了就栈溢出。

好了,这一章就到这里。链接脚本这东西,说白了就是个地址分配器。你给它说清楚「代码放哪、数据放哪」,它就能帮你生成正确的二进制文件。下一章咱们聊聊启动代码的具体实现,到时候会用到今天讲的这些符号和段布局。