链接脚本(Linker Script)深入:内存布局定义、段管理、符号解析与模板实战

大家好,我是老周。今天咱们聊聊链接脚本——这个在RISC-V固件开发里绕不开,却又经常被忽视的“幕后功臣”。

说实话,我刚开始做嵌入式那会儿,对链接脚本的态度就是“能用就行”。直到有一次,一个看似简单的内存越界问题,硬是让我调了三天。最后发现,是链接脚本里一个段地址没对齐。嗯,从那以后,我再也不敢小看这玩意儿了。

为什么链接脚本这么重要?

你想想看,我们写的C代码、汇编代码,最终都要变成二进制文件烧到芯片里。但芯片的内存就那么点地方,代码放哪、数据放哪、堆栈放哪,总得有个规矩吧?链接脚本就是干这个的。

在RISC-V的启动代码里,链接脚本尤其关键。因为RISC-V的启动流程,说白了就是从复位向量开始,一步步把各个段搬到正确的位置。如果链接脚本写错了,轻则程序跑飞,重则芯片直接“死机”。

核心观点:链接脚本不是“配菜”,它是固件工程的“骨架”。骨架歪了,肉长得再好也没用。

内存布局定义:给芯片画一张“地图”

我们先从最基础的开始。链接脚本的第一件事,就是告诉链接器:芯片的内存长什么样。

以我常用的RISC-V芯片为例,它通常有这几个区域:

  • Flash(或ROM):存放代码和只读数据,掉电不丢失
  • RAM:存放变量、堆栈,掉电就没了
  • MMIO区域:外设寄存器,直接映射到内存地址空间

在链接脚本里,我们用 MEMORY 命令来定义这些区域:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x20000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x80000000, LENGTH = 128K
    STACK (rw)  : ORIGIN = 0x80020000, LENGTH = 16K
}

这里每个区域都有三个属性:起始地址(ORIGIN)、长度(LENGTH)、访问权限(r/w/x)。我个人习惯把RAM和STACK分开定义,这样能更精细地控制栈的位置,避免栈溢出时踩到变量区。

小技巧:我在项目中遇到过,有些芯片的RAM起始地址不是0x80000000,而是0x00000000。这时候一定要仔细看芯片手册,别想当然。

段(Section)管理:把代码和数据“分门别类”

内存地图画好了,接下来就是把代码和数据放到对应的位置。这就是 SECTIONS 命令的活。

常见的段有这些:

段名 内容 存放位置
.text 可执行代码 Flash
.rodata 只读数据(字符串、常量) Flash
.data 已初始化的全局变量 RAM(初始值在Flash)
.bss 未初始化的全局变量 RAM
.stack 栈空间 RAM

一个典型的段定义长这样:

SECTIONS
{
    . = ORIGIN(FLASH);
    .text : {
        *(.text.init)    /* 启动代码放最前面 */
        *(.text)
        *(.text.*)
    } > FLASH

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

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

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

注意看 .data 段后面的 AT> FLASH。这表示:这个段运行时在RAM,但初始值存在Flash里。启动代码需要把这些初始值从Flash拷贝到RAM。这就是为什么启动代码里总有一段“搬运”逻辑。

避坑指南:我曾经犯过一个低级错误——忘了给 .bss 段清零。结果全局变量初始值全是随机的,程序跑起来各种诡异。后来我养成了习惯:在启动代码里,一定要用 _sbss_ebss 这两个符号来清零 .bss 段。

符号解析:链接器怎么“找人”

链接脚本里经常会出现一些“符号”,比如 _sdata_ebss。这些符号不是C代码里定义的,而是链接脚本“造”出来的。它们的作用,就是告诉程序:某个段的起始地址在哪、结束地址在哪。

举个例子,启动代码里要清零 .bss 段,代码会这么写:

extern uint32_t _sbss, _ebss;
uint32_t *p = &_sbss;
while (p < &_ebss) {
    *p++ = 0;
}

这里 _sbss_ebss 就是链接脚本里定义的符号。链接器在链接时,会把它们的值替换成实际的地址。

我个人习惯在链接脚本里定义这些符号:

  • _sdata / _edata:.data段的起始和结束
  • _sbss / _ebss:.bss段的起始和结束
  • _stack_top:栈顶地址
  • _heap_start / _heap_end:堆的起始和结束

注意:链接脚本里的符号,在C代码里要用 extern 声明,并且取地址时要加 &。因为链接器把符号当成地址,而不是变量。

链接脚本模板编写实战

光说不练假把式。下面我给出一个完整的RISC-V链接脚本模板,你可以直接拿去用:

/* RISC-V 链接脚本模板 - 适用于标准启动流程 */
OUTPUT_ARCH(riscv)
ENTRY(_start)

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

SECTIONS
{
    /* 中断向量表放在最前面 */
    . = ORIGIN(FLASH);
    .vectors : {
        *(.vectors)
    } > FLASH

    /* 代码段 */
    .text : {
        *(.text.init)
        *(.text)
        *(.text.*)
        *(.gnu.linkonce.t.*)
    } > FLASH

    /* 只读数据 */
    .rodata : {
        *(.rodata)
        *(.rodata.*)
        *(.gnu.linkonce.r.*)
    } > FLASH

    /* 初始化数据 - 运行时在RAM,初始值在Flash */
    . = ORIGIN(RAM);
    .data : ALIGN(4) {
        _sdata = .;
        *(.data)
        *(.data.*)
        *(.gnu.linkonce.d.*)
        _edata = .;
    } > RAM AT> FLASH

    /* BSS段 - 未初始化数据 */
    .bss : ALIGN(4) {
        _sbss = .;
        *(.bss)
        *(.bss.*)
        *(.gnu.linkonce.b.*)
        *(COMMON)
        _ebss = .;
    } > RAM

    /* 栈空间 - 从RAM末尾向下生长 */
    .stack : ALIGN(16) {
        _stack_bottom = .;
        . += 16K;  /* 栈大小 */
        _stack_top = .;
    } > RAM

    /* 堆空间 - 从BSS末尾到栈底 */
    .heap : ALIGN(4) {
        _heap_start = .;
        . = _stack_bottom;
        _heap_end = .;
    } > RAM

    /* 定义Flash中.data初始值的结束地址 */
    _data_loadaddr = LOADADDR(.data);
}

这个模板有几个关键点:

  • ALIGN(4):强制4字节对齐,避免RISC-V的未对齐访问异常
  • ENTRY(_start):指定入口点,通常是复位向量
  • LOADADDR:获取.data段在Flash中的加载地址
  • 栈向下生长:栈顶在RAM末尾,栈底在堆上方

实战经验:我建议你在链接脚本里加上 ASSERT 断言,比如检查栈大小是否超过RAM。这样链接时就能发现问题,而不是等到运行时崩溃。

知识体系总览

下面这张图,是我自己总结的链接脚本知识体系。你可以把它当成一个“思维导图”来看:

链接脚本 内存布局定义 MEMORY命令 ORIGIN/LENGTH 段(Section)管理 .text / .data .bss / .rodata AT> 加载地址 符号解析 _sdata / _ebss extern声明 模板编写实战 ALIGN对齐 ENTRY入口

这张图把链接脚本的四个核心模块串起来了。你写链接脚本时,就按这个思路来:先定义内存,再安排段,然后定义符号,最后套用模板。

写在最后

链接脚本这东西,说难不难,说简单也不简单。我见过很多工程师,写C代码一把好手,但一碰到链接脚本就头疼。其实你只要记住:链接脚本就是给芯片画地图、给代码分房间、给变量贴标签。搞懂了这三件事,你就掌握了链接脚本的精髓。

好了,今天就聊到这儿。下次咱们聊聊启动代码里的“搬运工”——那个把.data段从Flash搬到RAM的循环。嗯,那也是个容易踩坑的地方。


专注资料整理