{"code":1,"msg":"成功","time":"1790227047","data":{"title":"MCU GNU链接脚本","content":"# STM32 链接脚本入门：从 Sections 到可执行文件的地址规划\r\n\r\n## 前言\r\n\r\n在 MCU 裸机开发中，绝大多数时候我们都在和 C 语言、寄存器、外设驱动打交道，很少有人会主动去碰链接脚本（Linker Script）。毕竟，厂商提供的默认链接文件通常就能跑，Keil、STM32CubeMX 帮你生成好一切，main 函数写完就能点灯。\r\n\r\n但当你开始接触 XIP（就地执行）、自定义内存布局、将关键代码或数据放到特定地址、做 OTA 升级，或者需要优化 Flash 占用时，你就会发现：**链接脚本不再是可选的附加知识，而是必须掌握的基本功**。\r\n\r\n本文从一个 STM32F103 工程出发，带你从目标文件的 sections 开始，一步步搞清楚：\r\n\r\n- 一个 ELF 文件里到底有哪些 sections，它们是怎么来的；\r\n- 链接器是怎么把一堆 `.o` 和 `.a` 整合成一个可执行文件的；\r\n- 为什么 MCU 开发必须关注地址规划，而 Linux 应用却几乎不用管；\r\n- 如何手写一个面向裸机程序的 GNU ld 链接脚本；\r\n- 运行地址、加载地址、`.data` 拷贝、`.bss` 清零、中断向量表这些概念如何串在一起。\r\n\r\n完整视频流程：  \r\n【MCU 链接脚本写法 (一)】 https:\/\/www.bilibili.com\/video\/BV19Bhe6wENd\/?share_source=copy_web&vd_source=79b2c8de19b1377f95a5ada384ced7f2\r\n\r\n---\r\n\r\n## 一、从一个工程说起：Sections 是链接的原料\r\n\r\n### 1.1 编译的产物是很多目标文件\r\n\r\n一个代码工程在编译过程中，会生成一堆**目标文件（`.o`）**和**静态库文件（`.a`）**。每一个目标文件里，几乎都包含了若干 sections：\r\n\r\n| Section | 用途 |\r\n|---|---|\r\n| `.text` | 存放代码（指令） |\r\n| `.data` | 存放**已初始化**的全局变量或静态变量 |\r\n| `.bss` | 存放**未初始化**的全局变量或静态变量 |\r\n| `.rodata` | 存放常量（如字符串字面量） |\r\n\r\n除此之外还有很多其他的 sections，比如调试信息、异常\/中断相关的 section 等，后面会逐一提到。\r\n\r\n### 1.2 链接器做的事情，本质上是\"重新洗牌\"\r\n\r\n一般情况下，链接器会把每个目标文件里独立的 `.text`、`.data`、`.bss`、`.rodata` 整合到一起，形成一个统一的 `.text`、`.data`、`.bss`、`.rodata`，最后生成一个整合好的可执行文件（ELF）。\r\n\r\n这种\"自动整合\"的方式，对大部分情况是 OK 的。特别是 Windows、Linux 应用程序——由于有 MMU，程序跑在虚拟地址上，你基本不用担心链接器是怎么安排地址的，操作系统和加载器帮你兜了底。\r\n\r\n### 1.3 但 MCU 不一样\r\n\r\nMCU 是裸机环境，**没有 MMU，没有操作系统帮你分配内存**。RAM 和 Flash 都是有限的物理资源，程序跑在哪个地址、数据放在哪里，全靠链接脚本说得算。\r\n\r\n所以：**MCU 程序如何安排地址，是一个必须认真对待的问题。**\r\n\r\n> 当然，对于一般的 MCU 开发，芯片厂商会提供默认的链接文件，开发者确实可以不关注链接问题。但当涉及到 XIP、特殊内存区域、IAP\/OTA、性能优化时，几乎都绕不开修改链接脚本。了解链接脚本，是底层开发的必备能力。\r\n\r\n---\r\n\r\n## 二、目标文件里到底有什么：用 readelf 看真相\r\n\r\n为了讲清楚后面的内容，我们基于之前视频构建的 **STM32F103** 工程，先看看编译生成的目标文件里到底有哪些 sections。\r\n\r\n### 2.1 先看 main.o 里有什么\r\n\r\n用 `readelf` 工具查看 `main.o`：\r\n\r\n```bash\r\nreadelf -S main.o\r\n```\r\n\r\n可以看到有 `.text` section，同时还有**很多 `.text` 前缀**的 sections。为什么会这样？\r\n\r\n### 2.2 关键编译选项：-ffunction-sections\r\n\r\n这是因为编译时带了 **`-ffunction-sections`** 标志位。这个标志位的作用是：**把每一个函数单独放到一个独立的 section 里**，而不是把所有函数的代码都塞进同一个 `.text`。\r\n\r\n举个例子，假设你有三个函数 `func_a`、`func_b`、`func_c`，正常情况下它们会合并在同一个 `.text` 里。开启 `-ffunction-sections` 后，它们会分别变成：\r\n\r\n- `.text.func_a`\r\n- `.text.func_b`\r\n- `.text.func_c`\r\n\r\n**这样做的意义是什么？？**\r\n\r\n配合链接时的 **`--gc-sections`**（Garbage Collection，垃圾回收）标志位，链接器就能把**没有被用到的函数代码段直接丢弃**，从而减小最终程序的体积。\r\n\r\n### 2.3 做个实验验证一下\r\n\r\n我们把 `-ffunction-sections` 和 `--gc-sections` 去掉，重新编译，看看结果：\r\n\r\n- Flash 占用空间**变大了**；\r\n- 生成的可执行文件里**不再有 `.text` 前缀的 sections**，所有函数又合并回了同一个 `.text`。\r\n\r\nMCU 对程序体积非常敏感，所以这两个标志位在生产环境中**应当保留**。\r\n\r\n### 2.4 `.data` 和 `.bss` 在哪？\r\n\r\n回过头看，`main.o` 里好像没有 `.data` 和 `.bss`？那是因为当前代码里还没有定义全局\/静态变量。\r\n\r\n我们在 `main.c` 里添加：\r\n\r\n```c\r\nint g_init_val = 100;       \/\/ 已初始化的全局变量 -> .data\r\nint g_uninit_val;           \/\/ 未初始化的全局变量 -> .bss\r\n```\r\n\r\n重新编译后再看，`main.o` 里就出现了 `.data` section 和 `.bss` section。\r\n\r\n### 2.5 `.rodata` 又是什么？\r\n\r\n`readelf` 的输出里还有一个 `.rodata` section。回头看 `main.c`，里面串口输出的字符串常量，就存放在这里。\r\n\r\n### 2.6 剩下的那些 sections\r\n\r\n除了上面这些，还有一些调试相关的 sections（如 `.debug_info`、`.debug_line` 等），它们供调试器使用，**程序运行时完全不需要**，暂时不关注。\r\n\r\n---\r\n\r\n## 三、链接脚本的基本框架：SECTIONS 命令\r\n\r\n了解了目标文件的 sections 之后，就可以开始编写链接脚本了。\r\n\r\n### 3.1 几个基本概念\r\n\r\n- **Input Sections**：目标文件里的 sections，就是链接脚本的\"输入\"。\r\n- **Output Sections**：链接脚本整合后，生成的可执行文件里的 sections，就是\"输出\"。\r\n\r\n一般的逻辑就是：把分散在各个 `.o` 文件里的 `.text` 和 `.text` 开头的 input sections，统一放到 output 的 `.text` section 里。\r\n\r\n### 3.2 一个最简单的写法\r\n\r\n```ld\r\nSECTIONS\r\n{\r\n    .text :\r\n    {\r\n        *(.text*)\r\n    }\r\n\r\n    .rodata :\r\n    {\r\n        *(.rodata*)\r\n    }\r\n\r\n    .data :\r\n    {\r\n        *(.data*)\r\n    }\r\n\r\n    .bss :\r\n    {\r\n        *(.bss*)\r\n    }\r\n}\r\n```\r\n\r\n**关键语法解释：**\r\n\r\n- `SECTIONS` 命令：所有 section 的描述都必须放在这个命令里面，它是链接脚本的\"主体\"。\r\n- `*`（通配符）：表示**所有目标文件**。\r\n- `*(.text*)`：匹配所有目标文件里的 `.text` 以及所有 `.text` 前缀的 sections（比如 `.text.func_a`）。因为 `*` 能匹配空字符串，所以也覆盖了纯 `.text` 的情况，可以只写这一条。\r\n- 外层的 `.text` 就是 output section 的名字。\r\n\r\n其它 sections 照葫芦画瓢，写法完全一致。\r\n\r\n---\r\n\r\n## 四、运行地址 vs 加载地址：理解 MCU 的内存模型\r\n\r\n这是链接脚本里最核心、也最容易让人困惑的概念。\r\n\r\n### 4.1 每个 section 其实有两个地址\r\n\r\n| 地址类型 | 别名 | 含义 |\r\n|---|---|---|\r\n| **运行地址** | VMA（Virtual Memory Address） | 程序运行时，section 实际所在的地址 |\r\n| **加载地址** | LMA（Load Memory Address） | section 在存储介质上的存放位置 |\r\n\r\n> 注意：运行地址在很多文档里也叫\"虚拟地址（VMA）\"。在 MCU 裸机环境下没有真正的虚拟地址概念，这里的\"虚拟\"是 ELF 规范里的历史术语，实际就是\"程序认为自己在哪运行\"的地址。\r\n\r\n### 4.2 默认情况下两者一致\r\n\r\nMCU 代码一般存在 **Nor Flash** 中，运行时也直接从 Nor Flash 取指执行。因为 Nor Flash 支持 XIP（就地执行），所以 `.text` 的加载地址和运行地址是**一致的**。\r\n\r\n### 4.3 什么时候不一致？——以 `.data` 为例\r\n\r\n`.data` 里存放的是**已初始化的全局\/静态变量**，这些变量在程序运行过程中随时可能改变值。\r\n\r\n但是，Flash 是不能随便写入的。所以：\r\n\r\n1. **编译时**：链接器把初始值（比如 `g_init_val = 100` 里的 `100`）存在 Flash 上 → 这是**加载地址**；\r\n2. **运行时**：启动代码必须把 Flash 里的初始值**拷贝到 RAM** → 这是**运行地址**。\r\n\r\n这就导致了 `.data` 的**加载地址 ≠ 运行地址**。\r\n\r\n### 4.4 在链接脚本中指定\r\n\r\n```ld\r\nMEMORY\r\n{\r\n    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K\r\n    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 20K\r\n}\r\n```\r\n\r\n- `FLASH`：可执行、可读、不可写（`rx`）\r\n- `RAM`：可读、可写、可执行（`rwx`）\r\n\r\n定义好内存布局后，就可以在 SECTIONS 里指定每个 section 放到哪块内存：\r\n\r\n```ld\r\nSECTIONS\r\n{\r\n    .text :\r\n    {\r\n        *(.text*)\r\n    } > FLASH\r\n    \/* 默认情况下加载地址 = 运行地址，无需额外指定 *\/\r\n\r\n    .rodata :\r\n    {\r\n        *(.rodata*)\r\n    } > FLASH\r\n\r\n    .data :\r\n    {\r\n        *(.data*)\r\n    } > RAM AT > FLASH\r\n    \/* 运行地址在 RAM，加载地址在 FLASH *\/\r\n\r\n    .bss :\r\n    {\r\n        *(.bss*)\r\n    } > RAM\r\n}\r\n```\r\n\r\n**语法要点：**\r\n\r\n- `> FLASH`：指定运行地址（VMA）在 FLASH。\r\n- `> RAM AT > FLASH`：指定运行地址在 RAM，同时显式指定加载地址（LMA）在 FLASH。\r\n- `.data` 是唯一需要同时指定两者的常见 section，因为它要从 Flash \"搬家\" 到 RAM。\r\n\r\n---\r\n\r\n## 五、深入理解 `.bss`：为什么不需要加载地址\r\n\r\n`.bss` 存放的是**未初始化的全局\/静态变量**。\r\n\r\n### 5.1 为什么单独划分？\r\n\r\n关键在于：**未初始化的变量，在 Flash 中不需要分配空间去存储具体的值。**\r\n\r\n想一想，`int g_uninit_val;` 的初始值是什么？C 标准规定必须是 0。既然所有 `.bss` 变量的初始值都是 0，那完全没必要在 Flash 里存一堆 0 浪费空间。\r\n\r\n正确做法是：\r\n\r\n1. 只在链接阶段记录 `.bss` 总共需要多大空间；\r\n2. 程序启动时，由启动代码在 RAM 中分配这段空间，并全部清零。\r\n\r\n所以 `.bss` **不需要从 Flash 加载**，只需要指定运行地址即可。\r\n\r\n### 5.2 NOLOAD 属性\r\n\r\n一般我们还会在 `.bss` 上加一个 `NOLOAD`：\r\n\r\n```ld\r\n.bss (NOLOAD) :\r\n{\r\n    *(.bss*)\r\n} > RAM\r\n```\r\n\r\n`NOLOAD` 的作用是**移除 `PT_LOAD` 属性**。`PT_LOAD` 表示这段数据需要被加载到内存。在裸机程序中，加上 `NOLOAD` 主要是为了防止调试器把 `.bss` 当成需要加载的数据，错误地用 0 填充或映射，导致仿真调试异常。\r\n\r\n---\r\n\r\n## 六、启动代码与链接脚本的配合：符号约定\r\n\r\nMCU 的启动流程需要建立 C 语言运行环境。这要求程序在 `main` 函数之前，就必须完成：\r\n\r\n1. **`.data` 拷贝**：把初始值从 Flash 搬到 RAM；\r\n2. **`.bss` 清零**：把 `.bss` 对应的 RAM 区域全部清零。\r\n\r\n这部分工作由**启动文件（startup_*.s）**完成，但启动文件需要**链接脚本提供相关地址**。\r\n\r\n以 STM32CubeMX 默认生成的工程为例，启动文件里会引用一些符号，比如：\r\n\r\n- `_sidata`：`.data` 在 Flash 中的起始地址（加载地址）\r\n- `_sdata`：`.data` 在 RAM 中的起始地址（运行地址）\r\n- `_edata`：`.data` 在 RAM 中的结束地址\r\n- `_sbss`：`.bss` 起始地址\r\n- `_ebss`：`.bss` 结束地址\r\n\r\n这些符号必须由链接脚本提供。方法是在对应 section 里用 `.` 获取当前地址：\r\n\r\n```ld\r\n.data :\r\n{\r\n    _sdata = .;          \/* .data 运行地址起始 *\/\r\n    *(.data*)\r\n    _edata = .;          \/* .data 运行地址结束 *\/\r\n} > RAM AT > FLASH\r\n\r\n_bss_start = .;          \/* .bss 起始地址 *\/\r\n.bss (NOLOAD) :\r\n{\r\n    *(.bss*)\r\n} > RAM\r\n_bss_end = .;            \/* .bss 结束地址 *\/\r\n```\r\n\r\n对于 `.data` 的加载地址（Flash 端起始位置），用 `LOADADDR()` 命令获取：\r\n\r\n```ld\r\n\/* 在 .data 之前定义 *\/\r\n_data_load_addr = LOADADDR(.data);\r\n```\r\n\r\n这样，启动文件就能通过这些符号，正确地完成数据搬运和清零工作。\r\n\r\n---\r\n\r\n## 七、收尾工作：ENTRY、栈指针、中断向量表\r\n\r\n主干写完之后，还有三个关键点需要处理。\r\n\r\n### 7.1 ENTRY：指定程序入口\r\n\r\n```ld\r\nENTRY(Reset_Handler)\r\n```\r\n\r\n把入口点设为**复位中断函数**。\r\n\r\n> ⚠️ 一个重要提醒：`ENTRY` 命令的行为**只是把入口地址写进 ELF 文件头**，供调试器、加载器这类软件工具使用，**它并不影响 MCU 启动时进入复位中断函数**。\r\n>\r\n> 换句话说：**就算没有这条 `ENTRY` 命令，MCU 也能正常启动并进入复位中断函数。** MCU 的上电\/复位行为由 ARM 架构决定——只要正确提供了向量表，就能顺利启动。\r\n\r\n### 7.2 指定栈的起始位置\r\n\r\nC 程序的函数调用依赖栈，所以需要指定栈的起始位置。\r\n\r\nCortex-M 架构有一个特点：**将中断向量表的第一个 32 位字作为主栈指针（MSP）的初始值。**\r\n\r\n这个位置通常在启动文件里定义，是一个符号（常见命名为 `_estack`）。我们需要在链接脚本里为它赋值：\r\n\r\n```ld\r\n_estack = ORIGIN(RAM) + LENGTH(RAM);    \/* 栈顶 = RAM 的末端 *\/\r\n```\r\n\r\n因为 Cortex-M 的栈是**满递减（Full Descending）**的，从高地址向低地址增长，所以栈的起始位置放在 RAM 的最末端。\r\n\r\n### 7.3 中断向量表必须放在 Flash 最开始\r\n\r\n为什么？因为 MCU 上电后，硬件会从 Flash 地址 `0x08000000` 开始读取两个东西：\r\n\r\n1. **第一个字**：初始栈指针（MSP）；\r\n2. **第二个字**：复位异常向量的入口地址。\r\n\r\n如果向量表不在最前面，MCU 就找不到复位函数，启动直接失败。\r\n\r\n启动文件里通常把向量表放在 `.isr_vector` section：\r\n\r\n```ld\r\n.text :\r\n{\r\n    KEEP(*(.isr_vector))\r\n    *(.text*)\r\n    *(.text*)\r\n} > FLASH\r\n```\r\n\r\n**要点：**\r\n\r\n- `.isr_vector` 必须放在**最前面**，这样它的地址就是 Flash 的起始地址；\r\n- 这里用了 **`KEEP`** 命令：`--gc-sections` 会回收没有被引用的 sections，而中断向量表里的函数（如各种 IRQ Handler）是**由硬件机制调用的**，不会被普通代码引用，如果不加 `KEEP`，它们会被误删。\r\n\r\n---\r\n\r\n## 八、编译验证\r\n\r\n链接脚本写完后，在 `app\/CMakeLists.txt` 里指定新的链接脚本：\r\n\r\n```cmake\r\nset(LINKER_SCRIPT ${CMAKE_SOURCE_DIR}\/STM32F103C8Tx_FLASH.ld)\r\n```\r\n\r\n删掉 build 产物，重新编译。\r\n\r\n**遇到一个典型错误**：链接脚本报格式错误。原因是 output section 后面的**冒号前面必须有空格**：\r\n\r\n```ld\r\n\/* 错误写法 *\/\r\n.text: {\r\n\r\n\/* 正确写法 *\/\r\n.text :\r\n{\r\n```\r\n\r\n修正后重新编译，成功。\r\n\r\n打开 **map 文件**，查看内存布局，与链接脚本中指定的布局**完全一致**。下载到开发板运行，一切正常。\r\n\r\n---\r\n\r\n## 九、一个重要的反思\r\n\r\n对于这个简单的程序，使用这个链接脚本运行是没问题的。但事实上，在实际开发中，**这么草率地编写链接脚本是不推荐的**。\r\n\r\n回想一下最开始我们用 `readelf` 查看目标文件时，除了 `.text`、`.data`、`.bss`、`.rodata`，还有很多其它的 sections。而在我们写的链接脚本里，**大部分都没有被显式安排**。\r\n\r\n比如：\r\n\r\n- `.ARM.exidx` \/ `.ARM.extab`（ARM 异常索引表、异常展开表）—— C++ 异常和栈回溯相关；\r\n- `.init_array` \/ `.fini_array`—— C++ 全局构造函数\/析构函数；\r\n- `.eh_frame`—— 异常处理帧信息；\r\n- `.comment`、`.note.*`—— 编译器版本等元信息；\r\n- `.debug_*`—— 调试信息。\r\n\r\n这些 sections 没有被显式处理，链接器虽然会按默认规则放置，但这种\"放任不管\"的做法在生产环境中可能会带来隐患。\r\n\r\n> **下一节**，我们将继续探究这些\"漏网之鱼\"该怎么处理，并进一步完善这个链接脚本，让它达到工程可用的标准。\r\n\r\n---\r\n\r\n## 附录：关键知识点速查表\r\n\r\n| 概念 | 一句话解释 |\r\n|---|---|\r\n| Section | ELF 文件中按用途划分的数据块 |\r\n| Input Section | 目标文件里的 section，链接脚本的输入 |\r\n| Output Section | 链接脚本整合后生成的 section，最终 ELF 的输出 |\r\n| VMA（运行地址） | 程序运行时 section 所在的地址 |\r\n| LMA（加载地址） | section 在存储介质上的存放地址 |\r\n| `-ffunction-sections` | 将每个函数放到独立的 `.text.xxx` section |\r\n| `--gc-sections` | 链接时回收未被引用的 sections |\r\n| `KEEP()` | 防止 `--gc-sections` 删除指定的 section |\r\n| `> RAM AT > FLASH` | 运行地址在 RAM，加载地址在 FLASH |\r\n| `LOADADDR()` | 获取某个 section 的加载地址 |\r\n| `NOLOAD` | 移除 `PT_LOAD` 属性，数据不会被加载器处理 |\r\n| `ENTRY()` | 设置 ELF 入口点，仅影响调试\/加载工具 |\r\n| `MEMORY` | 描述目标设备的内存布局 |\r\n| `SECTIONS` | 描述如何把 input sections 映射到 output sections |\r\n\r\n---\r\n\r\n*下一篇预告：《完善链接脚本：处理那些你忽略了的 sections》*","updatetime":"2026-09-22 08:35:54","author":"大目熊"}}