尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

嵌入式开发必备:GNU链接脚本核心语法与实战应用详解

嵌入式开发必备:GNU链接脚本核心语法与实战应用详解 1. 从一次诡异的“未定义引用”错误说起如果你在嵌入式开发或者系统级编程中摸爬滚打过一段时间大概率遇到过这样的场景你写的代码逻辑清晰编译也顺利通过但到了链接阶段却报出一堆莫名其妙的“undefined reference toxxx”错误。你检查了头文件包含确认了库文件路径甚至把库文件在链接命令里来回添加了好几遍问题依旧。或者你精心编写的启动代码明明在仿真器里运行得好好的一旦烧录到真实的硬件板卡上程序却跑飞了或者某些全局变量的初始值完全不对。这些问题十有八九根源都指向一个平时我们很少直接打交道却又至关重要的文件——链接脚本。链接脚本尤其是GNU工具链下的链接脚本通常以.ld为后缀是连接编译器输出一堆.o目标文件与最终可执行映像如.elf,.bin文件之间的“总设计师”和“施工蓝图”。它不关心你代码的具体逻辑但它决定了你代码的“物理存在”变量被放在内存的哪个地址代码段、数据段、堆栈段如何排布只读数据和可读写数据如何分离甚至如何为特定的硬件内存布局比如片上Flash、SRAM、外部SDRAM定制程序布局。很多人对链接脚本的态度是“敬而远之”觉得这是工具链或芯片厂商提供的“黑盒”直接用就好。直到某天你需要优化程序体积把某些函数放到快速RAM中执行以提升性能或者需要实现自定义的固件引导和升级机制时才发现不深入理解这张“蓝图”很多想法根本无法落地。网上能找到的教程要么过于简略只讲几个简单指令要么是GNU官方手册的直译充满了晦涩的术语让人望而却步。今天我们就抛开那些照本宣科从一个实际开发者的角度彻底拆解GNU链接脚本。我会结合自己踩过的坑不仅告诉你每个指令“是什么”更重点解释“为什么”要这么设计以及在实际项目中“怎么用”。你会发现读懂并驾驭链接脚本是迈向底层系统开发高手之路的必经关卡。2. 链接脚本的本质内存空间的“城市规划师”在深入语法细节之前我们必须建立正确的认知模型。你可以把整个程序的内存空间想象成一座待开发的城市。编译器如gcc产生的每个目标文件.o就像是一批批预制好的建筑构件函数、变量。链接器如ld的任务就是把这些构件搬运到这座城市里并按照一定的规则摆放、组装起来形成最终可运行的“城市系统”。链接脚本就是这座城市的“总体规划图”和“建设规范”。它定义了内存区域划分这座城市有哪些可用的“地块”比如Flash区只读存放程序代码和常量、SRAM区可读写存放变量、外部RAM区等。每个地块的起始地址和大小是多少段Section的安置不同类型的“建筑构件”应该放在哪个地块代码.text必须放在可执行的Flash区已初始化的全局变量.data需要先在Flash中存储初始值上电后再拷贝到RAM中未初始化的全局变量.bss只需要在RAM中预留出空间并清零。符号地址的解析当一栋“建筑”函数A需要找到另一栋“建筑”函数B的位置进行调用时链接器需要根据它们最终的摆放地址计算出准确的“门牌号”函数地址并修改调用指令中的地址部分。这个过程就是“重定位”。特殊布局要求比如中断向量表必须放在Flash的起始地址堆heap和栈stack的区域需要在RAM中预留并指定方向为了满足某些硬件启动要求镜像文件的头部可能需要特定的数据结构。没有链接脚本链接器就不知道如何规划这座城市结果要么是构件堆叠在一起冲突要么是放错了地方导致无法运行。默认情况下链接器会使用一个内置的、通用的链接脚本这对于在操作系统上运行的普通应用程序通常足够了。但在裸机Bare-metal嵌入式、操作系统内核、Bootloader开发中内存布局千差万别我们必须提供自定义的链接脚本告诉链接器“请严格按照我的这张规划图来施工。”注意一个常见的误解是链接脚本“生成”了内存布局。实际上内存布局是由硬件决定的芯片手册会明确给出Flash和RAM的地址范围。链接脚本的作用是“描述”这个硬件布局并指挥链接器将程序内容适配到这个布局中。它是描述者而非创造者。3. 核心语法拆解读懂“规划图”的图例一份典型的链接脚本看起来可能很吓人但它的语法结构是模块化和层次化的。我们将其分解为几个核心部分来理解。3.1 内存区域定义划定可用的“地块”这是链接脚本的开篇使用MEMORY命令来声明目标系统有哪些内存区域以及它们的属性。MEMORY { /* 定义一块名为 FLASH 的区域属性为 rx (只读可执行)起始地址 0x08000000长度 512K */ FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K /* 定义一块名为 RAM 的区域属性为 rwx (可读可写可执行)起始地址 0x20000000长度 128K */ RAM (rwx) : ORIGIN 0x20000000, LENGTH 128K }区域名如FLASH,RAM自定义的标识符后续在安置段时会用到。属性括号内的字母表示此区域的访问权限。r- 可读w- 可写x- 可执行a- 可分配通常区域都是可分配的i/l- 初始化链接器内部使用通常忽略。属性主要用于链接时检查。例如如果你试图把一个标记为w可写的段如.data放入只有rx属性的FLASH区域链接器会报错。这有助于提前发现配置错误。ORIGIN (或缩写为 org, o)该内存区域的起始物理地址。这个地址必须与硬件设计完全一致通常由芯片数据手册给出。LENGTH (或缩写为 len, l)该内存区域的长度。实操心得在定义LENGTH时我习惯性地会留一点余量。比如芯片手册说SRAM是128K我可能会在链接脚本里定义为126K或124K。这不是因为手册错了而是为了给调试工具如J-Link或可能存在的硬件保留区域如DMA缓冲区、特殊功能寄存器映射区腾出空间避免链接器把程序塞满导致运行时冲突。这是一个保守但安全的策略。3.2 段Section安置把构件放到指定地块这是链接脚本最核心的部分使用SECTIONS命令。它定义了各个输入段来自.o文件如何组合、排序并输出到最终映像的特定位置。SECTIONS { /* . 是特殊的位置计数器‘.’代表当前输出地址。这里将其设置为FLASH区域的起始地址 */ . ORIGIN(FLASH); /* 输出段 .isr_vector中断向量表必须放在最开头 */ .isr_vector : { /* 将所有输入文件中的 .isr_vector 段内容放在这里 */ KEEP(*(.isr_vector)) /* KEEP 指令至关重要它告诉链接器即使这个段没有被任何代码引用也不要优化掉它。 对于启动代码、中断向量表这类必须存在的段必须使用KEEP。 */ } FLASH /* ‘FLASH’ 指定这个输出段放置在 FLASH 内存区域 */ /* 输出段 .text存放所有代码和只读数据 */ .text : { *(.text) /* 所有 .text (代码) 段 */ *(.text*) /* 所有以 .text 开头的段如 .text.function_name (某些编译器优化生成) */ *(.rodata) /* 只读数据 */ *(.rodata*) /* 所有只读数据 */ *(.glue_7) /* ARM/Thumb 交互胶合代码编译器生成 */ *(.glue_7t) *(.eh_frame) /* 下面这行定义了代码段结束的符号常用于计算代码大小 */ . ALIGN(4); _etext .; /* 全局符号在C代码中可声明为 extern char _etext; 来获取地址 */ } FLASH /* 关键概念.data 段的 VMA 和 LMA */ /* .data 段包含已初始化的全局/静态变量。它们在运行时必须位于 RAM (VMA) 但其初始值必须存储在非易失的 Flash 中 (LMA)。 因此我们需要指定两个地址加载地址(LMA)和运行地址(VMA)。 */ _sidata LOADADDR(.data); /* 在C中可引用表示.data段初始值在Flash中的加载地址 */ .data : AT ( _sidata ) /* AT() 指定加载地址(LMA)为 _sidata (即紧跟.text段之后) */ { . ALIGN(4); _sdata .; /* 在C中可引用表示.data段在RAM中的起始运行地址(VMA) */ *(.data) *(.data*) . ALIGN(4); _edata .; /* 在C中可引用表示.data段在RAM中的结束运行地址 */ } RAM /* ‘RAM’ 指定运行地址(VMA)在 RAM 区域 */ /* .bss 段未初始化的全局/静态变量运行时在RAM中但无需在镜像中占用空间只需预留地址并清零 */ .bss : { . ALIGN(4); _sbss .; __bss_start__ _sbss; /* 有时启动文件会使用不同的符号名 */ *(.bss) *(.bss*) *(COMMON) /* COMMON 段用于未初始化的全局变量某些编译器的行为 */ . ALIGN(4); _ebss .; __bss_end__ _ebss; } RAM /* 堆和栈的区域预留。注意这里只是‘预留’地址空间并不存放实际数据。 具体的堆栈初始化由启动代码完成。 */ ._user_heap_stack : { . ALIGN(8); PROVIDE ( end . ); PROVIDE ( _end . ); . . _Min_Heap_Size; /* _Min_Heap_Size 是在链接时通过选项传递的符号 */ . . _Min_Stack_Size; /* _Min_Stack_Size 同理 */ . ALIGN(8); } RAM /* 其他可能用到的段 */ .ARM.attributes 0 : { *(.ARM.attributes) } /* ARM架构属性信息 */ /DISCARD/ : { *(.note.GNU-stack) } /* 丢弃某些不需要的段 */ }关键概念解析位置计数器 ‘.’这是一个特殊的变量代表当前输出地址。你可以读取它_etext .也可以设置它. ALIGN(4);或. ORIGIN(FLASH);。ALIGN(n)确保当前位置按 n 字节对齐这对许多处理器访问内存的效率至关重要。输入段描述*(.text)中的*是通配符匹配所有输入文件。.text是输入段名。*(.text*)匹配所有以.text开头的段。输出段地址FLASH指定该输出段的**运行地址VMA**在FLASH区域。链接器会计算该段在FLASH中的具体位置。AT() 与加载地址LMA.data : AT ( _sidata )是理解链接脚本的难点和重点。:前面的.data是输出段名。指定的RAM是它的运行地址VMA即程序运行时这些变量所在的地址。AT()里面指定的是它的加载地址LMA即这些变量的初始值在最终镜像文件如.bin中存储的位置。上电后启动代码需要将_sidataLMA 到_sidata size_of_.data的内容拷贝到_sdataVMA 到_edata的RAM空间中。PROVIDE 关键字用于定义一个符号仅当该符号未被任何目标文件定义时才会被定义。这常用于提供默认值比如PROVIDE(_estack ORIGIN(RAM) LENGTH(RAM));来定义栈顶如果用户代码自己定义了_estack则以用户定义的为准。符号导出像_etext,_sdata,_edata,_sbss,_ebss这样的符号在链接脚本中赋值后就成为了全局符号。在C代码中你可以通过extern声明它们从而在启动代码或应用程序中获知关键内存区域的边界用于数据拷贝、内存管理或调试。3.3 常用内建函数与表达式链接脚本支持简单的表达式和函数增强了灵活性。ALIGN(align)/ALIGN(exp, align)对齐函数。ALIGN(4)将当前位置计数器对齐到4字节边界。LOADADDR(section)获取指定输出段的加载地址LMA。ADDR(section)获取指定输出段的运行地址VMA。SIZEOF(section)获取指定输出段的大小。MAX(exp1, exp2)/MIN(exp1, exp2)取最大/最小值。DEFINED(symbol)检查符号是否已定义。例如在预留堆栈空间时我们经常需要从外部传入参数/* 在链接命令行中用 -Wl,--defsym_Min_Heap_Size0x200 传递参数 */ ._user_heap_stack : { ... . . _Min_Heap_Size; . . _Min_Stack_Size; ... } RAM4. 实战案例为自定义内存映射编写链接脚本假设我们有一个更复杂的芯片内存布局如下Boot ROM: 0x0000_0000 - 0x0000_3FFF (16KB只读厂家固化代码)Flash: 0x0800_0000 - 0x0807_FFFF (512KB用户程序)SRAM1: 0x2000_0000 - 0x2001_7FFF (96KB通用)SRAM2: 0x2001_8000 - 0x2001_FFFF (32KB带ECC适合存放关键数据或堆栈)External SDRAM: 0xC000_0000 - 0xC07F_FFFF (8MB)我们的需求是程序主体放在Flash。将频繁访问的全局变量和某个关键函数如中断服务例程放到更快的SRAM2中运行。将一个大数组放到外部SDRAM中。堆栈放在SRAM2的末尾。对应的链接脚本设计如下MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K SRAM1 (rwx) : ORIGIN 0x20000000, LENGTH 96K SRAM2 (rwx) : ORIGIN 0x20018000, LENGTH 32K SDRAM (rwx) : ORIGIN 0xC0000000, LENGTH 8M } SECTIONS { .isr_vector : { KEEP(*(.isr_vector)) } FLASH .text : { *(.text) *(.text*) *(.rodata) *(.rodata*) . ALIGN(4); _etext .; } FLASH /* 将名为 .ramfunc 的段放到 SRAM2 中执行 */ .ramfunc : { . ALIGN(4); _sramfunc .; *(.ramfunc) /* 将所有输入文件中的 .ramfunc 段收集到这里 */ *(.ramfunc*) . ALIGN(4); _eramfunc .; } SRAM2 AT FLASH /* VMA在SRAM2, LMA在FLASH */ /* 计算 ramfunc 的加载地址供启动代码拷贝 */ _siramfunc LOADADDR(.ramfunc); .data : { . ALIGN(4); _sdata .; *(.data) *(.data*) . ALIGN(4); _edata .; } SRAM1 AT FLASH /* 普通数据放SRAM1 */ _sidata LOADADDR(.data); /* 将名为 .sdram 的段放到 SDRAM 中 */ .sdram : { . ALIGN(4); _ssdram .; *(.sdram) *(.sdram*) . ALIGN(4); _esdram .; } SDRAM AT FLASH /* VMA在SDRAM, LMA在FLASH */ _sisdram LOADADDR(.sdram); .bss : { . ALIGN(4); _sbss .; *(.bss) *(.bss*) *(COMMON) . ALIGN(4); _ebss .; } SRAM1 /* 堆栈放在 SRAM2 的末尾向下生长 */ .stack (NOLOAD) : /* NOLOAD 表示该段不占用镜像文件空间仅预留地址 */ { . ALIGN(8); _sstack .; . . _Min_Stack_Size; . ALIGN(8); _estack .; /* 栈顶地址启动代码需将 MSP 或 PSP 设置为此值 */ } SRAM2 .heap (NOLOAD) : { . ALIGN(8); _sheap .; . . _Min_Heap_Size; . ALIGN(8); _eheap .; } SRAM2 /DISCARD/ : { *(.note.GNU-stack) } }对应的C代码操作指定函数/变量到自定义段/* GCC/Clang 使用 __attribute__((section(.ramfunc))) */ void __attribute__((section(.ramfunc))) critical_isr_handler(void) { // 这个函数会被链接到 SRAM2 } /* 指定变量到 SDRAM 段 */ uint8_t __attribute__((section(.sdram))) large_buffer[1024*1024]; // 1MB 数组在启动代码中完成数据搬运/* 伪代码展示拷贝逻辑 */ extern char _siramfunc, _sramfunc, _eramfunc; extern char _sidata, _sdata, _edata; extern char _sisdram, _ssdram, _esdram; extern char _sbss, _ebss; void SystemInit(void) { // 1. 拷贝 .data 段 (SRAM1) char *src _sidata; char *dst _sdata; while (dst _edata) { *dst *src; } // 2. 拷贝 .ramfunc 段 (SRAM2) src _siramfunc; dst _sramfunc; while (dst _eramfunc) { *dst *src; } // 3. 拷贝 .sdram 段 (SDRAM) src _sisdram; dst _ssdram; while (dst _esdram) { *dst *src; } // 4. 清零 .bss 段 (SRAM1) dst _sbss; while (dst _ebss) { *dst 0; } // 5. 初始化堆栈指针 // __set_MSP(_estack); // 对于Cortex-M }5. 高级技巧与避坑指南5.1 处理重叠区域与灵活内存分配有时两块内存区域在物理上是重叠的例如同一块RAM被映射到两个不同的地址空间或者用于内存重映射。链接脚本需要小心处理。MEMORY { FLASH (rx) : ORIGIN 0x00000000, LENGTH 256K /* 启动时重映射到0地址 */ RAM (rwx) : ORIGIN 0x10000000, LENGTH 64K /* 假设0x20000000开始也有64K RAM但和0x10000000是同一块物理内存的别名 */ ALT_RAM (rwx) : ORIGIN 0x20000000, LENGTH 64K } SECTIONS { /* 关键确保不要将不同的段分配到同一块物理内存的不同别名地址上否则会导致数据覆盖。 通常只使用其中一个别名。 */ .text : { ... } FLASH .data : { ... } RAM /* 只使用 RAM 区域 */ /* .data2 : { ... } ALT_RAM */ /* 危险可能与 .data 冲突 */ }更高级的用法是使用OVERLAY但这在嵌入式开发中较少见常用于复杂的系统软件。5.2 链接脚本的调试与验证编写或修改链接脚本后如何验证它是否正确工作生成映射文件Map File在链接命令中加入-Wl,-Mapoutput.map选项。这个.map文件详细列出了所有段、符号的最终地址、大小和所属文件。这是调试链接问题的最重要工具。仔细检查各输出段的起始和结束地址是否符合预期。关键符号如_etext,_estack的地址是否正确。是否有段因为空间不足而被放置到了错误区域。使用 objdump 和 readelfarm-none-eabi-objdump -h your_firmware.elf # 查看ELF文件段头信息 arm-none-eabi-readelf -S your_firmware.elf # 类似查看段信息 arm-none-eabi-readelf -s your_firmware.elf | grep _estack # 查看特定符号这些命令可以帮你确认程序的内存布局。在IDE中查看大多数嵌入式IDE如STM32CubeIDE, Keil, IAR在编译链接后都能在生成报告或特定的视图中展示内存占用情况直观地看到Flash和RAM的使用量。5.3 常见问题排查**“regionFLASH’ overflowed by … bytes”**这是最常见的错误表示分配给某个段的内存区域容量不足。解决方案优化代码体积或者检查链接脚本是否将本应放到RAM的大数组错误地放到了Flash.rodata段或者调整内存区域划分。变量值在运行时被改变通常是.data段拷贝或.bss段清零的启动代码没有执行或执行错误。检查启动文件中数据搬运的代码并确认链接脚本中提供的符号_sdata,_edata等地址正确。函数指针调用导致硬件错误HardFault如果函数被链接到了RAM如.ramfunc段但启动代码忘记将这部分代码从Flash拷贝到RAM那么通过函数指针调用它时PC会跳转到Flash中的地址去执行位于RAM中的代码必然导致错误。务必确保拷贝完成。使用-gc-sections优化后某些必要代码被删除链接时使用-ffunction-sections -fdata-sections配合-Wl,--gc-sections可以删除未使用的函数和数据极大优化体积。但这也可能误删被汇编代码、函数指针或链接脚本引用的代码/数据。务必用KEEP()关键字在链接脚本中保护关键段如中断向量表、启动代码等。驾驭链接脚本本质上是在理解你的硬件内存地图和程序运行需求之间建立精确的映射。它不再是一个神秘的黑盒而是你优化性能、管理资源、实现复杂启动流程的得力工具。当你下次再遇到那些诡异的链接错误或运行时内存问题时希望你能自信地打开链接脚本开始你的“城市规划”调试。
返回列表