资讯动态

嵌入式开发必备:深入理解链接脚本(Linker Script)的内存布局与数据段处理

发布时间:2026/8/7 15:23:47 来源:尧图企业网站定制
1. 从“链接”说起为什么我们需要一个脚本如果你写过C语言编译过“Hello World”你可能觉得从源代码到可执行文件的过程很简单gcc hello.c -o hello然后运行。编译器Compiler把.c文件变成.o文件目标文件链接器Linker把多个.o文件合并成一个可执行文件。听起来链接器的工作就是“合并”那它凭什么需要一份专门的“脚本”来指导呢这恰恰是很多开发者尤其是刚开始接触嵌入式或系统底层开发的工程师最容易忽略的一个关键环节。想象一下你正在设计一个智能手表的固件。你的代码里有初始化硬件的函数、处理触摸屏事件的逻辑、更新屏幕显示的驱动还有记录睡眠数据的算法。编译后你会得到一堆.o文件。链接器的工作就是把这些“零件”组装成一块完整的手表。但问题来了组装顺序是什么硬件初始化代码是不是必须放在最开头一上电就执行屏幕驱动的数据比如字库应该放在哪里是靠近CPU访问快的区域还是放在便宜但慢的大容量存储区中断向量表这个极其关键的结构必须放在内存的哪个绝对地址这些“组装规则”就是链接脚本Linker Script要定义的事情。链接脚本通常以.ld为后缀是写给链接器如GNUld的“工程图纸”。它不关心代码逻辑的对错只负责两件核心事内存布局Memory Layout和符号地址分配Symbol Placement。没有它链接器就只能使用默认的、通用的布局规则这对于资源受限、地址空间固定的嵌入式系统或者有特殊启动要求的操作系统内核来说是绝对行不通的。默认规则可能把你的关键代码丢到RAM里但你的芯片上电后首先执行的是ROM里的代码也可能把只读的数据段放到了可写的区域导致程序崩溃。因此理解并掌握链接脚本是打通“程序能编译”到“程序能在目标板上正确运行”这最后一公里的必备技能。2. 链接脚本的核心语法定义“内存”与“段”一个链接脚本主要由两个核心概念构成内存区域MEMORY和段SECTIONS。你可以把MEMORY理解为芯片数据手册上那张“内存映射图”它告诉你这块芯片上有哪些物理存储资源比如FLASH从0x08000000开始大小为512KBSRAM从0x20000000开始大小为128KB。而SECTIONS则是你如何规划使用这些资源告诉链接器“把代码放到FLASH里把全局变量放到SRAM里并且把中断向量表固定在FLASH的开头”。2.1 MEMORY命令描述物理资源MEMORY命令用于定义目标平台上的物理内存区域。它的语法像一个声明列表MEMORY { /* 名称 (属性) : ORIGIN 起始地址, LENGTH 长度 */ ROM (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (rwx) : ORIGIN 0x20000000, LENGTH 128K CCMRAM (rw) : ORIGIN 0x10000000, LENGTH 64K /* 核心耦合内存更快 */ }这里有几个关键点名称如ROM、RAM你可以任意起名方便在SECTIONS里引用。属性括号内的字母定义了该区域的访问权限。r表示可读Readable。w表示可写Writable。x表示可执行eXecutable。a表示可分配Allocatable。链接器只会将输出段放入具有a属性的区域通常默认都有。ORIGIN或缩写为org内存区域的起始物理地址。这是绝对地址必须与硬件设计严格对应。LENGTH或缩写为len内存区域的大小。注意MEMORY命令并非必需。如果你不定义链接器会使用一个假设的、覆盖整个地址空间的通用内存模型。但对于嵌入式开发显式定义MEMORY是强制性的最佳实践它能防止链接器把内容放到不存在的地址上同时也让内存规划一目了然。2.2 SECTIONS命令进行空间规划SECTIONS命令是链接脚本的灵魂它定义了输出文件如.elf,.bin中各个“段”的存放位置。一个段就是一批具有相同属性如代码、只读数据、已初始化数据、未初始化数据的输入段来自.o文件的集合。SECTIONS { /* . 是特殊的位置计数器‘.’代表当前输出地址 */ . 0x08000000; /* 将当前位置计数器设置为FLASH起始地址 */ /* 输出段名 : { 输入段描述 } 存放的内存区域 AT 加载地址 */ .isr_vector : /* 中断向量表段 */ { . ALIGN(4); /* 4字节对齐 */ KEEP(*(.isr_vector)) /* 收集所有输入文件中的.isr_vector段 */ . ALIGN(4); } ROM /* 存放在ROM区域 */ .text : /* 代码段 */ { . ALIGN(4); *(.text) /* 所有.text段 */ *(.text*) /* 所有以.text开头的段如.text.function_name */ *(.glue_7) /* 为ARM/Thumb交互生成的胶合代码 */ *(.glue_7t) *(.eh_frame) . ALIGN(4); } ROM .rodata : { /* 只读数据段常量字符串、全局const变量等 */ . ALIGN(4); *(.rodata) *(.rodata*) . ALIGN(4); } ROM }这段脚本做了以下几件事设置起始点. 0x08000000;将当前位置计数器定位到FLASH开始处。定义.isr_vector段这个段存放中断向量表。KEEP指令至关重要它告诉链接器即使这个段没有被任何代码显式引用也必须保留在输出文件中。因为中断向量表是由硬件直接寻址的如果链接器在优化时把它删掉了芯片上电后将无法找到第一条指令。定义.text段存放所有代码。*(.text)是一个通配符模式匹配所有输入文件.o中的.text段。*(.text*)则匹配所有以.text开头的段例如编译器可能为某些函数生成特殊的.text.function_name段用于优化。定义.rodata段存放只读数据。指定存放区域ROM指示链接器将这些段输出到MEMORY中定义的ROM区域。2.3 关键符号与操作符解析位置计数器 ‘.’这是一个非常特殊的变量代表当前输出地址。你可以读取它_flash_start .;来记录一个地址也可以设置它. ALIGN(8);来对齐。ALIGN(align)将位置计数器.推进到下一个align字节对齐的地址。内存访问尤其是ARM Cortex-M系列通常要求数据按字长4字节对齐未对齐的访问会导致硬件错误或性能损失。几乎在每个段的开始和结束都进行ALIGN是一个好习惯。KEEP( )防止链接器在--gc-sections垃圾回收未使用段优化时删除括号内的段。中断向量表、启动代码等关键段必须用KEEP保护。*(.section_name)通配符匹配所有输入文件中的指定段。region和AT lma_region这是理解嵌入式程序运行的关键。region指定了段的虚拟内存地址VMA即程序运行时这个段应该被访问的地址。AT lma_region指定了段的加载内存地址LMA即这个段的内容在程序镜像中实际存放的地址。在典型的嵌入式系统中如STM32代码.text,.rodata的VMA和LMA通常相同都放在FLASHROM中因为FLASH本身就是可寻址的、非易失的。但是对于已初始化的全局变量.data段呢它的初始值需要存储在非易失的FLASH中LMA但程序运行时变量必须位于可读写的RAM中VMA。这就引出了链接脚本中最精妙的部分。3. 数据段的处理.data与.bss的“搬运”艺术程序中的全局变量和静态变量分为两类已初始化的和未初始化的。链接脚本需要分别处理它们。3.1 .data段从Flash到RAM的搬运.data段存放所有已初始化为非零值的全局/静态变量。例如int my_var 42;。这个初始值42必须永久保存所以它被编译进FLASH里。但变量my_var本身在运行时必须位于RAM因为程序可能会修改它。链接脚本是这么处理的SECTIONS { /* ... 之前的代码段、只读数据段 ... */ /* 用于在链接时计算.data段在FLASH中的加载地址LMA和大小 */ _sidata LOADADDR(.data); /* .data段内容在FLASH中的起始地址 */ .data : ALIGN(4) /* VMA: 运行时地址在RAM中 */ { _sdata .; /* 记录.data段在RAM中的起始地址 */ *(.data) *(.data*) . ALIGN(4); _edata .; /* 记录.data段在RAM中的结束地址 */ } RAM AT ROM /* VMA RAM, LMA AT ROM */ /* ... 其他段 ... */ }这里的关键是RAM AT ROM。它声明.data段的VMA运行地址在RAM区域。变量_sdata和_edata标记了它在RAM中的起止地址。.data段的LMA加载地址在ROM区域。LOADADDR(.data)函数可以获取到这个地址并赋值给符号_sidata。这样在生成的二进制镜像.bin或.hex中.data段的初始值被存放在FLASH的某个位置紧跟在.rodata之后。当芯片启动时启动代码Startup Code的责任就是利用_sidata、_sdata、_edata这三个链接器提供的地址符号将.data段的内容从FLASHLMA复制到RAMVMA中。这是一个必须由程序员或启动文件完成的“数据搬运”过程。3.2 .bss段与.sbss/.bss/.sbss2.bss段存放所有未初始化或初始化为零的全局/静态变量。例如int buffer[100];或int flag 0;。为了节省宝贵的FLASH空间编译器不会在镜像中存储一大堆零。链接脚本只需要在RAM中为它们预留空间并在启动时将其清零。SECTIONS { /* ... */ .bss (NOLOAD) : ALIGN(4) /* NOLOAD表示该段不占用镜像文件空间 */ { _sbss .; /* BSS段在RAM中的起始地址 */ *(.bss) *(.bss*) *(COMMON) /* COMMON段存放未初始化的全局变量旧式编译器行为 */ . ALIGN(4); _ebss .; /* BSS段在RAM中的结束地址 */ } RAM /* 对于某些架构或编译器可能还有更细分的BSS段如.sbss小数据区的BSS */ .sbss (NOLOAD) : ALIGN(4) { _ssbss .; *(.sbss) *(.sbss*) . ALIGN(4); _esbss .; } RAM /* ... */ }(NOLOAD)属性告诉链接器这个段的内容不需要被加载到目标板即不占用最终的.bin文件空间只需要在内存映射中分配地址空间。启动代码需要利用_sbss和_ebss以及可能的_ssbss,_esbss这些地址符号将对应区域的RAM全部写入零。3.3 启动代码中的对应操作理解了链接脚本的定义后启动文件如startup_stm32fxxx.s中的汇编部分或system_init.c中的C函数里的数据初始化操作就一目了然了/* 伪代码展示在系统初始化函数中 */ extern uint32_t _sidata; /* 在Flash中的.data段初始值起始地址 */ extern uint32_t _sdata; /* 在RAM中的.data段起始地址 */ extern uint32_t _edata; /* 在RAM中的.data段结束地址 */ extern uint32_t _sbss; /* .bss段起始地址 */ extern uint32_t _ebss; /* .bss段结束地址 */ void SystemInit(void) { // 1. 初始化.data段 (从Flash复制到RAM) uint32_t *src _sidata; uint32_t *dst _sdata; while (dst _edata) { *dst *src; } // 2. 清零.bss段 dst _sbss; while (dst _ebss) { *dst 0; } // ... 其他硬件初始化 ... }如果没有链接脚本正确生成_sdata、_edata等符号或者启动代码没有正确执行这些复制和清零操作那么全局变量将无法获得正确的初始值程序行为将是完全随机的、不可预测的。这是嵌入式系统启动失败的一个非常常见而又隐蔽的根源。4. 高级技巧与实战避坑指南掌握了基础语法后链接脚本的威力还体现在一些高级用法和细节处理上这些地方往往是实战中的“坑点”。4.1 自定义段与特定函数/变量的绝对定位有时你需要将某个关键函数或变量放到一个特定的地址。例如你可能有一个需要极快执行的中断服务程序ISR或者一个需要放在内存中特定位置如共享内存区的通信缓冲区。方法一使用链接脚本通配符与输入段描述在C代码中使用GCC的属性语法// 将一个函数放到名为 .fast_code 的自定义段中 void __attribute__((section(.fast_code))) critical_isr(void) { // ... } // 将一个数组放到名为 .shared_mem 的自定义段中 uint8_t __attribute__((section(.shared_mem))) comm_buffer[1024];然后在链接脚本中为这些自定义段指定位置SECTIONS { /* ... 标准段 ... */ /* 将.fast_code段放在RAM中一个对齐的地址以实现最快访问 */ .fast_code : ALIGN(32) { /* 32字节对齐可能用于缓存行优化 */ *(.fast_code) *(.fast_code*) } RAM ATROM /* 如果代码需要在RAM中运行可能需要一个加载地址 */ /* 将.shared_mem段固定在RAM中的一个绝对地址例如共享内存区起始于0x2000C000 */ .shared_mem 0x2000C000 : { KEEP(*(.shared_mem)) } }方法二在链接脚本中直接定义符号并赋值你甚至可以在链接脚本中直接“创造”变量并赋予它一个地址然后在C代码中声明为extern来使用。SECTIONS { /* ... */ .my_special_area 0x2000F000 : { KEEP(*(.my_special_area)) } /* 定义一个在链接时计算的符号作为某个区域的结束标记 */ _heap_start .; /* 当前位置比如.bss段之后作为堆的起始 */ . . 0x4000; /* 预留16KB的堆空间 */ _heap_end .; /* 堆的结束 */ }在C代码中extern uint32_t _heap_start; extern uint32_t _heap_end; // 你的内存分配器可以使用这两个地址4.2 内存区域重叠与分页管理在一些复杂的存储架构中比如有外部SDRAM、QSPI Flash映射到内存地址空间或者内存区域有重叠例如同一块物理RAM可以通过两个不同的地址总线访问以获得性能差异链接脚本的MEMORY定义需要精确描述。MEMORY { /* 内部Flash执行速度快 */ ITCM_FLASH (rx) : ORIGIN 0x00200000, LENGTH 256K /* 通过AXI总线访问的Flash速度稍慢 */ AXIM_FLASH (rx) : ORIGIN 0x08000000, LENGTH 256K /* 紧耦合数据RAM (DTCM)速度最快 */ DTCM_RAM (rwx) : ORIGIN 0x20000000, LENGTH 128K /* 通用SRAM */ SRAM1 (rwx) : ORIGIN 0x24000000, LENGTH 512K /* 外部SDRAM */ SDRAM (rwx) : ORIGIN 0xC0000000, LENGTH 32M }然后在SECTIONS里你可以根据性能需求将不同的代码段和数据段分配到不同的内存区域。例如将中断向量表和最核心的、对延迟敏感的中断服务程序放到ITCM_FLASH将大容量的只读数据如图片、字库放到AXIM_FLASH或SDRAM将需要极速访问的数据如实时控制循环中的变量放到DTCM_RAM。4.3 常见踩坑点与调试技巧未使用KEEP导致关键段被垃圾回收这是最经典的错误。如果你的启动文件或汇编文件里定义了一个名为.isr_vector或.vectors的段但在链接脚本中没有用KEEP(*(.isr_vector))保护当启用链接器垃圾回收(-Wl,--gc-sections)时这个段可能会因为“没有被引用”而被删除。结果就是芯片无法启动。务必检查所有硬件直接寻址或启动必需的段是否被KEEP。对齐ALIGN问题ARM Cortex-M内核要求许多访问特别是对栈、对某些外设是4字节对齐的。如果你的.data或.bss段起始地址未对齐在启动代码进行内存操作如批量复制/清零时如果使用了要求对齐的指令如STMIA可能会触发硬件错误。在每个输出段的开始和结束都加上. ALIGN(4);是稳妥的做法。VMA与LMA混淆导致数据未初始化如果你为.data段只指定了RAM而忘记了AT ROM链接器会把.data段的内容也放到RAM的地址上。但RAM是易失性的你的程序镜像烧录到Flash的文件里将不包含这些初始值。上电后.data段对应的RAM区域是随机值。务必确保已初始化数据段有正确的AT加载地址指向非易失存储。堆栈空间分配不足或冲突链接脚本通常也负责定义栈Stack和堆Heap的地址。栈通常从RAM的末尾向低地址生长堆则从.bss段之后向高地址生长。如果分配的空间太小或者堆栈区域发生重叠会导致内存越界引发各种难以调试的随机崩溃。仔细计算你的内存布局图确保堆栈有足够的空间且互不侵犯。调试使用链接器生成的Map文件当程序链接出现“区域内存不足”或地址冲突或者你想验证某个符号/段是否被放到了正确的位置时链接器生成的Map文件通过-Wl,-Mapoutput.map链接选项生成是无价之宝。Map文件详细列出了所有内存区域的定义和使用情况。每个输入段被放置到了哪个输出段、哪个地址。所有全局符号函数、变量的最终地址。内存使用率的统计。 学会阅读Map文件是进行复杂内存布局调试和优化的必备技能。5. 从理论到实践剖析一个真实的MCU链接脚本让我们以一份简化的、针对ARM Cortex-M内核的典型链接脚本STM32F407VG.ld为例串联起所有知识点/* 定义内存区域 */ MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 1024K RAM (xrw) : ORIGIN 0x20000000, LENGTH 192K CCMRAM (rw) : ORIGIN 0x10000000, LENGTH 64K } /* 定义堆栈大小 */ _Min_Heap_Size 0x200; /* 512字节的最小堆 */ _Min_Stack_Size 0x400; /* 1KB的最小栈 */ /* 入口点符号通常指向复位中断服务程序 */ ENTRY(Reset_Handler) SECTIONS { /* 中断向量表必须放在FLASH最开始 */ .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) /* 必须KEEP! */ . ALIGN(4); } FLASH /* 代码和只读数据 */ .text : { . ALIGN(4); *(.text) *(.text*) *(.glue_7) *(.glue_7t) *(.eh_frame) KEEP (*(.init)) KEEP (*(.fini)) . ALIGN(4); _etext .; /* 代码段结束地址用于初始化.data段时计算LMA */ } FLASH .rodata : { . ALIGN(4); *(.rodata) *(.rodata*) . ALIGN(4); } FLASH /* .data段VMA在RAMLMA在FLASH紧接在.rodata之后 */ .data : ALIGN(4) { _sdata .; /* 记录RAM中的开始地址 */ *(.data) *(.data*) . ALIGN(4); _edata .; /* 记录RAM中的结束地址 */ } RAM AT FLASH /* 关键VMA在RAMLMA在FLASH */ /* 用于启动代码的符号.data段在FLASH中的加载地址 */ . ALIGN(4); _sidata LOADADDR(.data); /* .bss段未初始化数据只占VMA在RAM中不占LMA */ .bss (NOLOAD) : ALIGN(4) { _sbss .; *(.bss) *(.bss*) *(COMMON) . ALIGN(4); _ebss .; } RAM /* 用户堆空间 */ ._user_heap_stack (NOLOAD) : ALIGN(8) { . ALIGN(4); PROVIDE ( end . ); PROVIDE ( _end . ); . . _Min_Heap_Size; . ALIGN(4); . . _Min_Stack_Size; . ALIGN(4); } RAM /* 移除调试信息减小体积可选 */ /DISCARD/ : { libc.a ( * ) libm.a ( * ) libgcc.a ( * ) *(.note.GNU-stack) *(.gnu_debuglink) *(.gnu.lto_*) } }这份脚本清晰地展示了从向量表到代码、只读数据再到需初始化的数据、未初始化数据最后到堆栈空间的完整内存布局。启动文件会利用_sidata、_sdata、_edata、_sbss、_ebss这些链接器导出的符号来完成数据的搬运和清零从而搭建起C语言程序运行的正确环境。6. 超越MCU链接脚本在其他领域的应用链接脚本并非嵌入式MCU的专属。只要你使用GNU链接器ld或兼容的工具链在需要精细控制输出文件布局的场景下它都会出现。操作系统内核开发比如Linux内核的链接脚本vmlinux.lds.S它定义了内核代码、数据、初始化调用表、内存映射表等在内核地址空间中的复杂布局这直接关系到内核能否正确引导和运行。Bootloader开发Bootloader通常有严格的阶段划分和地址要求。第一阶段可能运行在芯片内部的ROM或SRAM第二阶段加载到外部DRAM。链接脚本需要精确控制每个阶段的代码和数据地址确保跳转和加载的正确性。动态链接库Shared Library虽然动态链接库的地址在加载时才最终确定位置无关代码PIC但其内部的相对偏移、全局偏移表GOT、过程链接表PLT等结构的位置仍然需要通过链接脚本来规划。特殊格式固件例如为了满足安全启动Secure Boot的要求固件镜像可能需要包含特定的头部信息、证书、哈希值并且这些内容必须位于镜像的固定偏移处。这可以通过链接脚本将特定的输入段如一个包含头部的.o文件绝对定位到输出文件的开头来实现。理解链接脚本本质上是理解程序如何从一堆离散的代码和数据片段被组织成一个能够在特定硬件环境中有序运行的有机整体。它是一座连接高级语言抽象与底层硬件现实的桥梁。虽然日常应用开发中可能很少直接修改它但当你需要优化性能、节省空间、实现特殊功能或者仅仅是为了解决一个诡异的运行时bug时深入链接脚本的世界往往能给你带来“拨云见日”的透彻感。下次当你再面对一个链接错误或程序跑飞的问题时不妨先打开项目的.ld文件看看或许答案就在其中。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价