大目熊 · 2 天前 · 广东

MCU GNU链接脚本(一)

STM32 链接脚本入门:从 Sections 到可执行文件的地址规划

前言

在 MCU 裸机开发中,绝大多数时候我们都在和 C 语言、寄存器、外设驱动打交道,很少有人会主动去碰链接脚本(Linker Script)。毕竟,厂商提供的默认链接文件通常就能跑,Keil、STM32CubeMX 帮你生成好一切,main 函数写完就能点灯。

但当你开始接触 XIP(就地执行)、自定义内存布局、将关键代码或数据放到特定地址、做 OTA 升级,或者需要优化 Flash 占用时,你就会发现:链接脚本不再是可选的附加知识,而是必须掌握的基本功

本文从一个 STM32F103 工程出发,带你从目标文件的 sections 开始,一步步搞清楚:

  • 一个 ELF 文件里到底有哪些 sections,它们是怎么来的;
  • 链接器是怎么把一堆 .o.a 整合成一个可执行文件的;
  • 为什么 MCU 开发必须关注地址规划,而 Linux 应用却几乎不用管;
  • 如何手写一个面向裸机程序的 GNU ld 链接脚本;
  • 运行地址、加载地址、.data 拷贝、.bss 清零、中断向量表这些概念如何串在一起。

完整视频流程:
【MCU 链接脚本写法 (一)】 https://www.bilibili.com/vide...


一、从一个工程说起:Sections 是链接的原料

1.1 编译的产物是很多目标文件

一个代码工程在编译过程中,会生成一堆目标文件(.o静态库文件(.a。每一个目标文件里,几乎都包含了若干 sections:

Section用途
.text存放代码(指令)
.data存放已初始化的全局变量或静态变量
.bss存放未初始化的全局变量或静态变量
.rodata存放常量(如字符串字面量)

除此之外还有很多其他的 sections,比如调试信息、异常/中断相关的 section 等,后面会逐一提到。

1.2 链接器做的事情,本质上是"重新洗牌"

一般情况下,链接器会把每个目标文件里独立的 .text.data.bss.rodata 整合到一起,形成一个统一的 .text.data.bss.rodata,最后生成一个整合好的可执行文件(ELF)。

这种"自动整合"的方式,对大部分情况是 OK 的。特别是 Windows、Linux 应用程序——由于有 MMU,程序跑在虚拟地址上,你基本不用担心链接器是怎么安排地址的,操作系统和加载器帮你兜了底。

1.3 但 MCU 不一样

MCU 是裸机环境,没有 MMU,没有操作系统帮你分配内存。RAM 和 Flash 都是有限的物理资源,程序跑在哪个地址、数据放在哪里,全靠链接脚本说得算。

所以:MCU 程序如何安排地址,是一个必须认真对待的问题。

当然,对于一般的 MCU 开发,芯片厂商会提供默认的链接文件,开发者确实可以不关注链接问题。但当涉及到 XIP、特殊内存区域、IAP/OTA、性能优化时,几乎都绕不开修改链接脚本。了解链接脚本,是底层开发的必备能力。

二、目标文件里到底有什么:用 readelf 看真相

为了讲清楚后面的内容,我们基于之前视频构建的 STM32F103 工程,先看看编译生成的目标文件里到底有哪些 sections。

2.1 先看 main.o 里有什么

readelf 工具查看 main.o

readelf -S main.o

可以看到有 .text section,同时还有很多 .text 前缀的 sections。为什么会这样?

2.2 关键编译选项:-ffunction-sections

这是因为编译时带了 -ffunction-sections 标志位。这个标志位的作用是:把每一个函数单独放到一个独立的 section 里,而不是把所有函数的代码都塞进同一个 .text

举个例子,假设你有三个函数 func_afunc_bfunc_c,正常情况下它们会合并在同一个 .text 里。开启 -ffunction-sections 后,它们会分别变成:

  • .text.func_a
  • .text.func_b
  • .text.func_c

这样做的意义是什么??

配合链接时的 --gc-sections(Garbage Collection,垃圾回收)标志位,链接器就能把没有被用到的函数代码段直接丢弃,从而减小最终程序的体积。

2.3 做个实验验证一下

我们把 -ffunction-sections--gc-sections 去掉,重新编译,看看结果:

  • Flash 占用空间变大了
  • 生成的可执行文件里不再有 .text 前缀的 sections,所有函数又合并回了同一个 .text

MCU 对程序体积非常敏感,所以这两个标志位在生产环境中应当保留

2.4 .data.bss 在哪?

回过头看,main.o 里好像没有 .data.bss?那是因为当前代码里还没有定义全局/静态变量。

我们在 main.c 里添加:

int g_init_val = 100;       // 已初始化的全局变量 -> .data
int g_uninit_val;           // 未初始化的全局变量 -> .bss

重新编译后再看,main.o 里就出现了 .data section 和 .bss section。

2.5 .rodata 又是什么?

readelf 的输出里还有一个 .rodata section。回头看 main.c,里面串口输出的字符串常量,就存放在这里。

2.6 剩下的那些 sections

除了上面这些,还有一些调试相关的 sections(如 .debug_info.debug_line 等),它们供调试器使用,程序运行时完全不需要,暂时不关注。


三、链接脚本的基本框架:SECTIONS 命令

了解了目标文件的 sections 之后,就可以开始编写链接脚本了。

3.1 几个基本概念

  • Input Sections:目标文件里的 sections,就是链接脚本的"输入"。
  • Output Sections:链接脚本整合后,生成的可执行文件里的 sections,就是"输出"。

一般的逻辑就是:把分散在各个 .o 文件里的 .text.text 开头的 input sections,统一放到 output 的 .text section 里。

3.2 一个最简单的写法

SECTIONS
{
    .text :
    {
        *(.text*)
    }

    .rodata :
    {
        *(.rodata*)
    }

    .data :
    {
        *(.data*)
    }

    .bss :
    {
        *(.bss*)
    }
}

关键语法解释:

  • SECTIONS 命令:所有 section 的描述都必须放在这个命令里面,它是链接脚本的"主体"。
  • *(通配符):表示所有目标文件
  • *(.text*):匹配所有目标文件里的 .text 以及所有 .text 前缀的 sections(比如 .text.func_a)。因为 * 能匹配空字符串,所以也覆盖了纯 .text 的情况,可以只写这一条。
  • 外层的 .text 就是 output section 的名字。

其它 sections 照葫芦画瓢,写法完全一致。


四、运行地址 vs 加载地址:理解 MCU 的内存模型

这是链接脚本里最核心、也最容易让人困惑的概念。

4.1 每个 section 其实有两个地址

地址类型别名含义
运行地址VMA(Virtual Memory Address)程序运行时,section 实际所在的地址
加载地址LMA(Load Memory Address)section 在存储介质上的存放位置
注意:运行地址在很多文档里也叫"虚拟地址(VMA)"。在 MCU 裸机环境下没有真正的虚拟地址概念,这里的"虚拟"是 ELF 规范里的历史术语,实际就是"程序认为自己在哪运行"的地址。

4.2 默认情况下两者一致

MCU 代码一般存在 Nor Flash 中,运行时也直接从 Nor Flash 取指执行。因为 Nor Flash 支持 XIP(就地执行),所以 .text 的加载地址和运行地址是一致的

4.3 什么时候不一致?——以 .data 为例

.data 里存放的是已初始化的全局/静态变量,这些变量在程序运行过程中随时可能改变值。

但是,Flash 是不能随便写入的。所以:

  1. 编译时:链接器把初始值(比如 g_init_val = 100 里的 100)存在 Flash 上 → 这是加载地址
  2. 运行时:启动代码必须把 Flash 里的初始值拷贝到 RAM → 这是运行地址

这就导致了 .data加载地址 ≠ 运行地址

4.4 在链接脚本中指定

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}
  • FLASH:可执行、可读、不可写(rx
  • RAM:可读、可写、可执行(rwx

定义好内存布局后,就可以在 SECTIONS 里指定每个 section 放到哪块内存:

SECTIONS
{
    .text :
    {
        *(.text*)
    } > FLASH
    /* 默认情况下加载地址 = 运行地址,无需额外指定 */

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

    .data :
    {
        *(.data*)
    } > RAM AT > FLASH
    /* 运行地址在 RAM,加载地址在 FLASH */

    .bss :
    {
        *(.bss*)
    } > RAM
}

语法要点:

  • > FLASH:指定运行地址(VMA)在 FLASH。
  • > RAM AT > FLASH:指定运行地址在 RAM,同时显式指定加载地址(LMA)在 FLASH。
  • .data 是唯一需要同时指定两者的常见 section,因为它要从 Flash "搬家" 到 RAM。

五、深入理解 .bss:为什么不需要加载地址

.bss 存放的是未初始化的全局/静态变量

5.1 为什么单独划分?

关键在于:未初始化的变量,在 Flash 中不需要分配空间去存储具体的值。

想一想,int g_uninit_val; 的初始值是什么?C 标准规定必须是 0。既然所有 .bss 变量的初始值都是 0,那完全没必要在 Flash 里存一堆 0 浪费空间。

正确做法是:

  1. 只在链接阶段记录 .bss 总共需要多大空间;
  2. 程序启动时,由启动代码在 RAM 中分配这段空间,并全部清零。

所以 .bss 不需要从 Flash 加载,只需要指定运行地址即可。

5.2 NOLOAD 属性

一般我们还会在 .bss 上加一个 NOLOAD

.bss (NOLOAD) :
{
    *(.bss*)
} > RAM

NOLOAD 的作用是移除 PT_LOAD 属性PT_LOAD 表示这段数据需要被加载到内存。在裸机程序中,加上 NOLOAD 主要是为了防止调试器把 .bss 当成需要加载的数据,错误地用 0 填充或映射,导致仿真调试异常。


六、启动代码与链接脚本的配合:符号约定

MCU 的启动流程需要建立 C 语言运行环境。这要求程序在 main 函数之前,就必须完成:

  1. .data 拷贝:把初始值从 Flash 搬到 RAM;
  2. .bss 清零:把 .bss 对应的 RAM 区域全部清零。

这部分工作由启动文件(startup_*.s)完成,但启动文件需要链接脚本提供相关地址

以 STM32CubeMX 默认生成的工程为例,启动文件里会引用一些符号,比如:

  • _sidata.data 在 Flash 中的起始地址(加载地址)
  • _sdata.data 在 RAM 中的起始地址(运行地址)
  • _edata.data 在 RAM 中的结束地址
  • _sbss.bss 起始地址
  • _ebss.bss 结束地址

这些符号必须由链接脚本提供。方法是在对应 section 里用 . 获取当前地址:

.data :
{
    _sdata = .;          /* .data 运行地址起始 */
    *(.data*)
    _edata = .;          /* .data 运行地址结束 */
} > RAM AT > FLASH

_bss_start = .;          /* .bss 起始地址 */
.bss (NOLOAD) :
{
    *(.bss*)
} > RAM
_bss_end = .;            /* .bss 结束地址 */

对于 .data 的加载地址(Flash 端起始位置),用 LOADADDR() 命令获取:

/* 在 .data 之前定义 */
_data_load_addr = LOADADDR(.data);

这样,启动文件就能通过这些符号,正确地完成数据搬运和清零工作。


七、收尾工作:ENTRY、栈指针、中断向量表

主干写完之后,还有三个关键点需要处理。

7.1 ENTRY:指定程序入口

ENTRY(Reset_Handler)

把入口点设为复位中断函数

⚠️ 一个重要提醒:ENTRY 命令的行为只是把入口地址写进 ELF 文件头,供调试器、加载器这类软件工具使用,它并不影响 MCU 启动时进入复位中断函数

换句话说:就算没有这条 ENTRY 命令,MCU 也能正常启动并进入复位中断函数。 MCU 的上电/复位行为由 ARM 架构决定——只要正确提供了向量表,就能顺利启动。

7.2 指定栈的起始位置

C 程序的函数调用依赖栈,所以需要指定栈的起始位置。

Cortex-M 架构有一个特点:将中断向量表的第一个 32 位字作为主栈指针(MSP)的初始值。

这个位置通常在启动文件里定义,是一个符号(常见命名为 _estack)。我们需要在链接脚本里为它赋值:

_estack = ORIGIN(RAM) + LENGTH(RAM);    /* 栈顶 = RAM 的末端 */

因为 Cortex-M 的栈是满递减(Full Descending)的,从高地址向低地址增长,所以栈的起始位置放在 RAM 的最末端。

7.3 中断向量表必须放在 Flash 最开始

为什么?因为 MCU 上电后,硬件会从 Flash 地址 0x08000000 开始读取两个东西:

  1. 第一个字:初始栈指针(MSP);
  2. 第二个字:复位异常向量的入口地址。

如果向量表不在最前面,MCU 就找不到复位函数,启动直接失败。

启动文件里通常把向量表放在 .isr_vector section:

.text :
{
    KEEP(*(.isr_vector))
    *(.text*)
    *(.text*)
} > FLASH

要点:

  • .isr_vector 必须放在最前面,这样它的地址就是 Flash 的起始地址;
  • 这里用了 KEEP 命令:--gc-sections 会回收没有被引用的 sections,而中断向量表里的函数(如各种 IRQ Handler)是由硬件机制调用的,不会被普通代码引用,如果不加 KEEP,它们会被误删。

八、编译验证

链接脚本写完后,在 app/CMakeLists.txt 里指定新的链接脚本:

set(LINKER_SCRIPT ${CMAKE_SOURCE_DIR}/STM32F103C8Tx_FLASH.ld)

删掉 build 产物,重新编译。

遇到一个典型错误:链接脚本报格式错误。原因是 output section 后面的冒号前面必须有空格

/* 错误写法 */
.text: {

/* 正确写法 */
.text :
{

修正后重新编译,成功。

打开 map 文件,查看内存布局,与链接脚本中指定的布局完全一致。下载到开发板运行,一切正常。


九、一个重要的反思

对于这个简单的程序,使用这个链接脚本运行是没问题的。但事实上,在实际开发中,这么草率地编写链接脚本是不推荐的

回想一下最开始我们用 readelf 查看目标文件时,除了 .text.data.bss.rodata,还有很多其它的 sections。而在我们写的链接脚本里,大部分都没有被显式安排

比如:

  • .ARM.exidx / .ARM.extab(ARM 异常索引表、异常展开表)—— C++ 异常和栈回溯相关;
  • .init_array / .fini_array—— C++ 全局构造函数/析构函数;
  • .eh_frame—— 异常处理帧信息;
  • .comment.note.*—— 编译器版本等元信息;
  • .debug_*—— 调试信息。

这些 sections 没有被显式处理,链接器虽然会按默认规则放置,但这种"放任不管"的做法在生产环境中可能会带来隐患。

下一节,我们将继续探究这些"漏网之鱼"该怎么处理,并进一步完善这个链接脚本,让它达到工程可用的标准。

附录:关键知识点速查表

概念一句话解释
SectionELF 文件中按用途划分的数据块
Input Section目标文件里的 section,链接脚本的输入
Output Section链接脚本整合后生成的 section,最终 ELF 的输出
VMA(运行地址)程序运行时 section 所在的地址
LMA(加载地址)section 在存储介质上的存放地址
-ffunction-sections将每个函数放到独立的 .text.xxx section
--gc-sections链接时回收未被引用的 sections
KEEP()防止 --gc-sections 删除指定的 section
> RAM AT > FLASH运行地址在 RAM,加载地址在 FLASH
LOADADDR()获取某个 section 的加载地址
NOLOAD移除 PT_LOAD 属性,数据不会被加载器处理
ENTRY()设置 ELF 入口点,仅影响调试/加载工具
MEMORY描述目标设备的内存布局
SECTIONS描述如何把 input sections 映射到 output sections

下一篇预告:《完善链接脚本:处理那些你忽略了的 sections》

推荐阅读
关注数
3
文章数
7
业余开发者
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息