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

资讯详情

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

深入理解链接脚本:从ld错误到嵌入式内存布局实战

深入理解链接脚本:从ld错误到嵌入式内存布局实战 1. 从“ld returned 1 exit status”说起为什么需要链接脚本如果你写过C/C程序大概率见过这个报错[error] ld returned 1 exit status。这个错误本身只是一个笼统的“链接失败”信号但背后隐藏的原因五花八门可能是函数未定义可能是库文件找不到也可能是内存布局冲突。很多时候我们通过调整编译命令、添加库路径就能解决。但当你开始接触嵌入式开发、操作系统内核、Bootloader或者需要精细控制程序在内存中的排布时你会发现常规的编译链接参数不够用了。这时一个名为linker script链接脚本在GNU工具链中通常指ld链接器使用的脚本文件后缀常为.ld或.lds的东西就登场了。简单来说链接脚本就是链接器的“蓝图”或“施工图纸”。编译器如gcc将你的源代码.c/.cpp文件编译成一个个独立的目标文件.o文件这些目标文件里包含了代码.text、已初始化的数据.data、未初始化的数据.bss等片段我们称之为“节”Section。链接器如ld的工作就是把这些散落的“节”按照一定的规则拼装成一个完整的可执行文件或库。这个“拼装规则”默认由链接器内置的一套规则决定它适用于绝大多数桌面和服务器应用程序。然而当规则不满足需求时我们就需要自己编写或修改链接脚本来告诉链接器“请把我的代码段放在内存地址0x80000000开始的地方”“把这块特殊的数据段放在紧挨着代码段后面”“这个符号的值就是程序加载的起始地址”。所以当你下次再看到链接错误并且常规手段无法解决或者你的项目需求涉及特定内存布局、地址映射时理解链接脚本就不再是选修课而是必修课了。它直接决定了你的程序能否在目标硬件上正确运行。2. 链接脚本的核心语法结构拆解一个链接脚本的本质是描述如何将输入文件Input Files中的各个节Sections映射到输出文件Output File中并定义一些相关的符号Symbols。它的语法虽然独特但结构清晰。我们可以将其类比为一份产品组装说明书先定义内存区域类似工作台的不同区域然后指定各个零件输入节应该放到工作台的哪个位置最后可能还需要标记一些关键参考点符号。2.1 基本构成命令、符号与表达式链接脚本由一系列命令Commands组成。命令可以是设置一个符号的值也可以是指定一个节的存放位置。命令之间通常用分号;分隔但换行也常常被视作分隔符为了清晰建议每个命令单独成行。符号Symbol在链接脚本中至关重要。你可以将一个符号定义为一个绝对地址、一个相对地址或者一个复杂的表达式。符号一旦定义就可以在程序中被引用通常需要声明为extern。例如在脚本中定义_stack_top 0x20010000;在C代码中就可以通过extern uint32_t _stack_top;来获取栈顶地址。表达式Expressions的语法与C语言非常相似。支持基本的算术运算,-,*,/、位运算、比较运算以及一些内置函数。表达式的值在链接时计算。一个关键概念是位置计数器Location Counter用点号.表示。它代表了当前输出节Output Section的当前位置地址。你可以通过赋值给.来移动它例如. 0x10000;就是将当前位置设置到地址0x10000。2.2 核心命令详解SECTIONSSECTIONS命令是链接脚本的心脏它描述了输入节到输出节的映射关系。其基本结构如下SECTIONS { . 0x10000; /* 设置起始地址 */ .text : { *(.text) } /* 输出节.text包含所有输入文件的.text节 */ . 0x20000; .data : { *(.data) } .bss : { *(.bss) } }输出节描述像.text,.data这样的名字就是输出节的名字。链接器会按照它们在SECTIONS命令中出现的顺序依次安排它们在输出文件中的位置。输入节描述花括号{}内指定了哪些输入节会被放入这个输出节。*(.text)是一个通配模式意思是“所有输入文件*中的.text节”。你可以指定具体的文件名如startup.o(.text)表示只包含startup.o文件中的.text节。位置计数器.的使用在节定义前对.赋值可以指定该输出节的起始地址。在节定义内部.会随着该节内容的大小自动递增。你也可以在节内部显式地调整.但这通常用于特殊的对齐或填充需求。2.3 内存区域定义MEMORY当目标平台尤其是嵌入式MCU具有多块物理内存如片上Flash、SRAM、外部SDRAM时MEMORY命令就非常有用。它可以定义这些内存区域的名称、起始地址、长度和访问属性如r只读w可写x可执行a可分配。MEMORY { ROM (rx) : ORIGIN 0x00000000, LENGTH 256K RAM (rwx) : ORIGIN 0x20000000, LENGTH 64K }定义了MEMORY之后在SECTIONS命令中就可以通过REGION语法将输出节显式地放入特定内存区域链接器会自动在该区域内分配地址如果区域空间不足则会报错。SECTIONS { .text : { *(.text*) } ROM .data : { *(.data*) } RAM AT ROM .bss : { *(.bss*) } RAM }上面例子中.text节被放入ROM区域。.data节已初始化的全局/静态变量比较特殊它的运行时地址VMA, Virtual Memory Address在RAM中但初始值需要存储在非易失存储器如ROM中。AT ROM就指定了它的加载地址LMA, Load Memory Address在ROM里。系统启动时需要一段启动代码通常由链接脚本生成的符号指引将.data节的初始值从ROM复制到RAM。3. 实战为ARM Cortex-M MCU编写一个基础链接脚本理论说得再多不如动手写一个。我们以常见的ARM Cortex-M系列微控制器比如STM32为例它通常包含一块Flash作为ROM和一块SRAM。我们的目标是编写一个链接脚本将代码和只读数据放入Flash将可读写数据放入SRAM并正确设置栈顶位置。3.1 确定内存布局首先我们需要查阅芯片的数据手册Datasheet或参考手册Reference Manual找到Flash和SRAM的起始地址与大小。假设我们有以下配置Flash: 起始地址0x08000000 大小512KB(0x80000)SRAM: 起始地址0x20000000 大小128KB(0x20000)对于Cortex-M中断向量表必须放在Flash起始位置或某个可配置的偏移地址通常就是起始位置。栈通常放置在SRAM的顶端向下生长。3.2 编写链接脚本STM32Fxxx.ld/* 定义内存区域 */ MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K } /* 定义栈顶位置位于RAM末尾1因为栈是向下生长的满栈 */ _stack_top ORIGIN(RAM) LENGTH(RAM); /* 输出节定义 */ SECTIONS { /* 中断向量表必须放在最前面 */ .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) /* KEEP确保即使该节未被引用也不会被优化掉 */ . ALIGN(4); } FLASH /* 代码段 (.text) 和只读数据段 (.rodata) */ .text : { . ALIGN(4); *(.text) /* .text sections (code) */ *(.text*) /* .text* sections (code, 包含C函数名等) */ *(.glue_7) /* glue arm to thumb code */ *(.glue_7t) /* glue thumb to arm code */ *(.eh_frame) KEEP (*(.init)) KEEP (*(.fini)) . ALIGN(4); _etext .; /* 定义一个符号标记代码段结束地址 */ } FLASH .rodata : { . ALIGN(4); *(.rodata) /* .rodata sections (constants, strings, etc.) */ *(.rodata*) /* .rodata* sections */ . ALIGN(4); } FLASH /* 用于静态构造函数初始化的地址数组ARM工具链特有 */ .preinit_array : { PROVIDE_HIDDEN (__preinit_array_start .); KEEP (*(.preinit_array*)) PROVIDE_HIDDEN (__preinit_array_end .); } FLASH .init_array : { PROVIDE_HIDDEN (__init_array_start .); KEEP (*(SORT(.init_array.*))) KEEP (*(.init_array*)) PROVIDE_HIDDEN (__init_array_end .); } FLASH .fini_array : { PROVIDE_HIDDEN (__fini_array_start .); KEEP (*(SORT(.fini_array.*))) KEEP (*(.fini_array*)) PROVIDE_HIDDEN (__fini_array_end .); } FLASH /* .data段已初始化的全局/静态变量。 运行时地址(VMA)在RAM初始值存储在Flash(LMA)。 AT指定了加载地址。 */ _sidata LOADADDR(.data); /* 获取.data段加载地址在Flash中的符号 */ .data : { . ALIGN(4); _sdata .; /* 创建全局符号标记.data段在RAM中的起始地址(VMA) */ *(.data) /* .data sections */ *(.data*) /* .data* sections */ . ALIGN(4); _edata .; /* 标记.data段在RAM中的结束地址(VMA) */ } RAM AT FLASH /* .bss段未初始化的全局/静态变量或初始化为0。 运行时地址在RAM不需要在Flash中占用空间存储初始值全0。 */ .bss : { . ALIGN(4); _sbss .; /* 定义全局符号标记.bss段起始 */ *(.bss) *(.bss*) *(COMMON) /* COMMON段用于未初始化的全局变量某些编译器 */ . ALIGN(4); _ebss .; /* 定义全局符号标记.bss段结束 */ } RAM /* 用户堆空间可选 */ ._user_heap_stack : { . ALIGN(8); PROVIDE ( end . ); PROVIDE ( _end . ); . . _Min_Heap_Size; /* _Min_Heap_Size可在别处定义如0x200 */ . . _Min_Stack_Size; /* _Min_Stack_Size如0x400 */ . ALIGN(8); } RAM /* 移除调试信息减小最终文件体积可选 */ /DISCARD/ : { libc.a ( * ) libm.a ( * ) libgcc.a ( * ) *(.note.GNU-stack) *(.gnu_debuglink) *(.gnu.lto_*) } }3.3 关键点解析与启动代码配合这个脚本定义了多个对启动代码至关重要的符号_stack_top 在Cortex-M的启动文件如startup_stm32fxxx.s中第一个条目就是初始栈指针SP的值这个值通常就设置为_stack_top。_sidata,_sdata,_edata 用于.data段的搬运。系统上电后在进入main()函数之前需要一段初始化代码通常在启动文件的Reset_Handler中将位于Flash地址为_sidata的.data段初始值复制到RAM中的实际位置从_sdata到_edata。_sbss,_ebss 用于.bss段的清零。同样在启动代码中需要将_sbss到_ebss之间的内存区域清零。如果你使用像STM32CubeIDE或ARM GCC这样的工具链启动文件通常是预置的并且已经包含了使用这些符号进行数据搬运和BSS清零的汇编代码。你的工作就是确保链接脚本中定义的这些符号名与启动文件中引用的名字一致。如果不一致要么修改脚本要么修改启动文件。注意ALIGN(4)或ALIGN(8)用于地址对齐这对于ARM架构尤其是Cortex-M的性能和稳定性至关重要。未对齐的访问可能导致硬件错误。4. 高级技巧与常见问题排查掌握了基础之后我们来看看一些更深入的用法和那些让人头疼的链接问题。4.1 控制特定函数或变量的绝对地址有时出于硬件交互、协议约定或调试的目的我们需要将某个函数或变量放在一个绝对固定的地址。这可以通过在SECTIONS中创建特殊输出节来实现。SECTIONS { ... /* 其他标准节 */ /* 将特定变量放在0x20001000 */ .my_special_var 0x20001000 : { KEEP(*(.my_special_var_section)) } /* 将特定函数放在Flash的某个保留区域 */ .my_critical_func 0x0800F000 : { some_file.o(.text.my_critical_function) } }在C代码中你需要通过编译器扩展如GCC的__attribute__将变量或函数放到指定的节中// 变量 uint32_t my_register __attribute__((section(.my_special_var_section))) 0xDEADBEEF; // 函数 void __attribute__((section(.text.my_critical_function))) my_critical_function(void) { // ... }4.2 处理未使用节的垃圾回收--gc-sections在链接时使用-gc-sections或-Wl,--gc-sections选项可以极大地优化代码体积。它会移除未被引用的节函数、变量。但这有时会“误伤”一些通过指针或汇编间接引用的内容比如中断向量表、用于动态初始化的函数指针数组如.init_array。解决方案在链接脚本中用KEEP()关键字包裹住你不想被回收的节。正如我们在前面脚本中对.isr_vector和.init_array等节所做的那样。KEEP命令告诉链接器“即使这个节看起来没有被引用也请保留它。”4.3 解决“section .xxx will not fit in region yyy”错误这是最常见的链接错误之一意思是某个输出节比如.data或.bss的大小超过了为其分配的内存区域在MEMORY命令中定义的容量。排查步骤检查链接器生成的Map文件在GCC链接命令中添加-Wl,-Mapoutput.map选项。生成的output.map文件会详细列出每个节的大小、地址和所属区域。这是最强大的调试工具。分析罪魁祸首在Map文件中找到超标的输出节如.data查看它由哪些输入文件构成。通常问题出在某个全局数组定义得过大。核对MEMORY定义确认你为区域如RAM定义的长度LENGTH是否与芯片实际容量一致。有时数据手册标注的是“字节”而脚本里错误地写成了“KB”。优化策略如果确实是数据太多考虑优化算法减少缓冲区大小或将部分只读数据移到Flash.rodata段。检查是否开启了调试信息-g导致体积膨胀发布版本可以去掉。确认是否使用了--gc-sections来移除无用代码。如果有多块RAM可以通过MEMORY精细划分将部分非关键数据移到另一块RAM中。4.4 理解VMA与LMA的区别这是链接脚本中最容易混淆的概念之一但对理解程序如何运行至关重要。VMA (Virtual Memory Address)虚拟内存地址也称为运行时地址Runtime Address。这是程序在执行时该段数据/代码被访问的地址。例如.data段的VMA在RAM中因为CPU在运行时需要读写这些变量。LMA (Load Memory Address)加载内存地址。这是该段数据/代码初始存放的地址。对于嵌入式系统程序通常从非易失存储器如Flash中启动。因此.text和.rodata的LMA和VMA通常相同都在Flash。但.data段不同它的初始值LMA存储在Flash中但运行时VMA必须在RAM里。链接器负责在最终的可执行文件如.elf或.hex中记录这些信息。上电后Bootloader或启动代码的责任就是根据这些信息把LMA处的内容如.data的初始值搬运到VMA处RAM并对.bss段进行清零。这个过程就是“运行时初始化”C Runtime Initialization。在Map文件中你可以清晰地看到每个节的VMA和LMA。如果某个节的VMA和LMA不同你就必须在启动代码中为其实现搬运逻辑否则程序运行会出错变量没有正确的初始值。5. 从链接脚本到Makefile与IDE集成理解了链接脚本怎么写最后一步就是把它用起来。5.1 在GCC/Makefile中使用链接脚本在命令行或Makefile中使用-T选项指定自定义的链接脚本arm-none-eabi-gcc -mcpucortex-m4 -T STM32Fxxx.ld -Wl,-Mapoutput.map -o project.elf startup.o main.o system.o ...-T STM32Fxxx.ld 指定使用的链接脚本文件。-Wl,-Mapoutput.map 告诉链接器生成Map文件。-o project.elf 指定输出的可执行文件格式ELF。5.2 在IDE中配置以VS Code Cortex-Debug为例如果你使用VS Code进行ARM开发通常会在项目根目录下有一个.vscode文件夹里面包含launch.json和tasks.json。在tasks.json的编译任务如build中确保在gcc的命令行参数里包含了-T your_script.ld。链接脚本通常放在项目根目录或一个专门的ldscripts文件夹中。确保编译命令的工作目录正确或者使用相对/绝对路径正确指向脚本文件。对于Keil MDK或IAR Embedded Workbench这类商业IDE它们有各自的图形化或项目文件.uvprojx,.ewp来配置链接器。你通常需要在项目设置中找到“Linker”或“Link”选项卡然后指定使用自定义的链接脚本文件或分散加载文件Scatter File其原理与GNU的链接脚本相通但语法不同。5.3 调试Map文件是你的最佳伙伴无论遇到什么链接问题第一反应都应该是生成并查看Map文件。Map文件会告诉你最终的内存布局每个输出节的起始地址VMA、大小、所属区域。符号表所有全局符号函数、变量的最终地址。输入节的贡献每个.o文件对最终各个输出节的大小贡献是多少帮你定位是哪个文件导致了体积膨胀。垃圾回收详情如果使用了--gc-sections可以看到哪些节被移除了。学会阅读Map文件是驾驭链接过程解决复杂内存布局问题的关键技能。当你下次再面对ld returned 1 exit status时就不会再感到迷茫而是能胸有成竹地打开Map文件像侦探一样找到问题的根源。链接脚本不再是黑魔法而是你控制程序底层布局的得力工具。
返回列表