U-Boot分析【学习笔记】(7)
8. 链接脚本分析在U-Boot分析【学习笔记】(6)中我们通过追踪 U-Boot 的顶层目标 _all 的依赖链理清了生成 u-boot.bin 所需的全部原材料。我们已经知道libs-y 收集了各个子目录的零件。u-boot-main 汇总了这些零件。u-boot (ELF) 最终将它们缝合。但这里隐藏着一个关键问题所有的二进制零件.o 文件虽然被链接在了一起但它们在生成的 16 进制镜像里谁排在第 1 个字节谁排在第 1024 个字节 这就是所谓的内存摆放。对于 i.MX6ULL 这种 ARM 处理器上电后它会从固定地址读取指令。如果启动代码被排在了镜像的中间或末尾CPU 读到的就是一堆乱码。链接脚本 (u-boot.lds) 的唯一任务就是给 Makefile 收集到的所有逻辑零件安排一个绝对的物理位置坐标。8.1 u-boot.lds 分析在主 Makefile 中发现 u-boot (ELF) 的生成规则里除了刚才提到的 u-boot-main还有一个至关重要的依赖项u-boot:$(u-boot-init)$(u-boot-main)u-boot.lds FORCE$(call if_changed,u-boot__)在 ARM 架构中它通常位于arch/arm/cpu/u-boot.lds为什么要研究它Makefile 的依赖链告诉我们‘要编什么’而链接脚本则告诉链接器‘怎么排序’。理解了 .lds才真正理解了程序是如何在 i.MX6ULL 的 512MB DDR 内存中分配位置的。入口与格式设定OUTPUT_FORMAT(elf32-littlearm,elf32-littlearm,elf32-littlearm)OUTPUT_ARCH(arm)ENTRY(_start)分析这里规定了输出文件的格式为小端模式的 32 位 ARM ELF 文件。架构意义ENTRY(_start) 是整份文件的核心它指定了程序的 物理入口点。当 CPU 完成基础自检后指针跳向的第一个符号就是 _start。置计数器.与起始地址.0x00000000;.ALIGN(4);分析. 是当前位置计数器它代表链接器当前正准备摆放数据的“内存地址指针”。可以把它想象成电脑屏幕上的光标光标在哪字就打在哪链接脚本里的 . 在哪代码就从哪个地址开始排。0x00000000相对于链接起始地址CONFIG_SYS_TEXT_BASE的偏移。ALIGN(4)“接下来的内容必须从能被 4 整除的内存地址开始摆放。”举例假设链接器刚刚放完了一段数据现在的指针 . 停在了地址 0x87800003。执行 ALIGN(4)计算发现比 0x87800003 大、且能被 4 整除的最近地址是 0x87800004。执行赋值 .指针跳到 0x87800004。结果中间那个 0x87800003 的位置就被空出来了填充为 0 或空下一段代码将从稳妥的 0x87800004 开始排放。架构意义在 i.MX6ULL 中这个起始地址通常被配置为 0x87800000。ARM 指令集通常要求指令地址必须是对齐的例如 32 位 ARM 指令必须 4 字节对齐。如果不对齐CPU 根本无法取指运行。链接器以此为原点开始计算后续所有代码和数据的绝对物理坐标。8.2 .text 代码段代码段.text的强制排序.text:{*(.__image_copy_start)*(.vectors)CPUDIR/start.o(.text*)*(.text*)}要理解这段代码说了什么要先知道什么是代码段代码段.text 段是程序在内存中专门用来存放动作指令的物理区域它是“指令”而非“数值”代码段里存的是 MOV搬移、ADD加法、B跳转等告诉 CPU 该干什么的二进制命令。它是“只读”的为了安全硬件通常不允许程序在运行时修改代码段。它是“连续”的链接器会把散落在各个 .o 文件里的代码块首尾相连。最外层 .text : { … } 代表什么在链接脚本的语境下这是一个“段定义”。.text (段名)这是给输出文件生成的 u-boot定义的段名称。正如我们之前讨论的它代表“代码段”用来存放所有的机器指令。冒号 :这是一个赋值声明告诉链接器“要开始定义这个段的内部组成和存放规则”。{ … } 它是一个逻辑容器。它告诉链接器“凡是放在这对花括号里的东西在最终的二进制镜像中物理上必须是连续排列在一起的”。它也代表了地址空间。花括号内的所有内容都会共享 .text 这个大段的起始地址属性。语法说明*(.text*)外层的 * 文件通配符 (File Wildcard)含义代表 “所有输入文件”。原理链接器会扫描工程编译出的所有 .o 目标文件.o 文件是链接器的标准零件。架构作用它告诉链接器把整个项目里所有满足括号内条件的段都找出来。括号 ( ) 作用域定界符含义括号将 “目标文件” 和 “目标段” 进行逻辑关联。原理括号左边是文件这里是通配符 *括号内部是这些文件里的具体段。架构作用这是一种筛选语法结构为 文件名 (段名)。内部的 .text* 段名匹配 (Section Pattern)这里要拆成两部分看.text代表 代码段。这是编译器约定的名字存放的是 CPU 可以执行的机器指令。末尾的 *代表 后缀匹配。为什么要加这个当编译器开启优化选项如 -ffunction-sections时它为了减小最终体积会把每个函数单独放进一个段名字叫 .text.函数名例如 .text.main。结果如果只写 (.text)链接器可能漏掉那些带后缀的函数段。写成 (.text) 就能确保把所有的指令段 “一网打尽”。完整含义是扫描 所有输入文件 ()从中提取出 所有以 .text 开头的代码段 (.text)并将它们按照链接顺序整齐地摆放在当前输出文件的这个位置。*(.__image_copy_start)含义这是一个特殊的符号标记段它不包含任何指令不占实际空间但它是一个锚点。把它放在 .text 的最顶端是为了让 __image_copy_start 这个标签的地址正好指向 U-Boot 在内存中存储的首个字节。架构作用当 U-Boot 运行到一半需要换位置Relocation时从这个符号标记的起始位置开始拷贝数据。*(.vectors)含义由汇编语言定义的中断向量表对于 ARM 处理器如 i.MX6ULL硬件规定在发生复位Reset、未定义指令或中断时CPU 会强制跳转到内存的最起始偏移量通常是 0x00去寻找指令。架构作用如果这里放的不是向量表而是普通代码当系统发生中断时CPU 会跳到一段乱码中去执行导致直接死机CPUDIR/start.o(.text*)CPUDIR 是一个变量在编译时由 Makefile 确定对于 i.MX6ULL 来说它指向 arch/arm/cpu/armv7含义这是指定的 start.o 目标文件由 start.S 编译而来里的代码段。这是整个系统的入口点。它包含了关闭看门狗、初始化 DDR、设置堆栈等裸机最基础的操作。架构作用虽然 Makefile 收集了成千上万个 .o但 LDS 规定start.o 必须排在通用代码之前。逻辑联系因为 start.S 包含了 U-Boot 的第一行指令 _start。如果它不排在前面CPU 跳进来执行的就是其他的函数系统直接崩溃。*(.text*)含义这是工程中剩下所有 .o 文件里的代码段。这里汇聚了写的所有 C 语言逻辑、网络协议栈、文件系统、驱动程序等语法外层代表“所有文件”。内层 .text匹配所有以 .text 开头的标签包括编译器为了优化而贴上的 .text.delay、.text.main 等花名。架构作用这些代码依赖前面的 start.o 搭建好的硬件环境如内存已经初始化好、栈已经设好。只有前面的基础打好了后续代码才能正常运行。text 的关联最外层的 .text (段名)它是输出段Output Section。它代表最终生成的 u-boot 二进制镜像中那块被标记为“代码”的巨大区域。内部的 CPUDIR/start.o(.text*) 和(.text*)**它们是输入段Input Section。它们是分散在各个 .o 零件里的碎块。为什么都叫 text这其实是一种“标签匹配”的契约对编译器汇编器而言它把代码翻译成二进制时默认贴上 .text 的标签。所以 start.o 里面有 .textmain.o 里面也有 .text。对链接脚本而言它通过内部的 (.text*) 这种语法像吸铁石一样寻找所有带 .text 标签的碎块。对输出段名最外层而言可以把它改成 .my_code但为了遵循行业标准ELF 规范我们通常也把它命名为 .text。总结最外层叫 .text 是为了告诉操作系统/硬件“这一块是代码”内部写 .text* 是为了告诉链接器“去把那些叫 .text 的零件找回来”。8.3 镜像资源布局与物理边界控制.rodata存放“只读数据”.ALIGN(4);.rodata:{*(SORT_BY_ALIGNMENT(SORT_BY_NAME(.rodata*)))}什么是 .rodata (Read-Only Data)它存放的是程序中不可修改的常量。例子在 C 代码里写的字符串 printf(“Hello World”)这个 “Hello World” 就会被编译器贴上 .rodata 标签存放在这里。SORT_BY_NAME 和 SORT_BY_ALIGNMENT这是链接器的“强迫症”功能。它会对所有收集到的 .rodata 碎块先按名字排序再按字节对齐排序。架构作用将常量与代码指令分开。虽然它们都是只读的但在物理上分开存放有利于链接器进行管理和空间压缩。.data存放“已初始化全局变量”的仓库.ALIGN(4);.data:{*(.data*)}什么是 .data (Data)它存放的是你定义的初始值不为 0 的全局变量。例子int a 100;全局变量。这个 100 必须实实在在地存在二进制文件里。对比理解代码段 (.text)告诉 CPU “如何加法”。数据段 (.data)告诉 CPU “被加的数起始值是 100”。架构作用这是镜像中占用体积的一部分。U-Boot 启动时会将这部分数据随代码一起从 Flash 拷贝到 RAM 中因为程序运行过程中会修改这些变量的值比如计数器、状态标志等。u_boot_listU-Boot 的“特殊功能名单”.u_boot_list:{KEEP(*(SORT(.u_boot_list*)));}这是什么 (最具 U-Boot 特色)这是 U-Boot 用来实现 “命令自动发现” 等功能的黑科技。KEEP 关键字链接器有个习惯如果发现某段代码没被调用就会把它删掉。但 .u_boot_list 里的数据往往是给底层框架用的看似没有用。KEEP 强制链接器禁止删除。架构作用当你使用 U_BOOT_CMD 定义一个命令比如 ls 或 nfs时编译器会把命令结构体丢进这个段。结果U-Boot 运行起来后只需要遍历这个内存段就能知道当前系统支持哪些命令。它实现了模块之间的 “解耦”——增加一个命令文件不需要去修改主代码链接器会自动把它缝合进这个名单里。EFI 代码与数据定义的是 U-Boot 中专门用于 UEFI 运行时服务的特殊区域.ALIGN(4);.__efi_runtime_start:{*(.__efi_runtime_start)}.efi_runtime:{*(efi_runtime_text)*(efi_runtime_data)}.__efi_runtime_stop:{*(.__efi_runtime_stop)}.efi_runtime_rel_start:{*(.__efi_runtime_rel_start)}.efi_runtime_rel:{*(.relefi_runtime_text)*(.relefi_runtime_data)}.efi_runtime_rel_stop:{*(.__efi_runtime_rel_stop)}1.EFI 代码与数据核心内容.__efi_runtime_start:{*(.__efi_runtime_start)}// 起点围墙开头.efi_runtime:{*(efi_runtime_text)// UEFI 专用代码指令*(efi_runtime_data)// UEFI 专用全局变量}.__efi_runtime_stop:{*(.__efi_runtime_stop)}// 终点围墙结尾什么是 EFI Runtime当 U-Boot 引导操作系统如 Linux后U-Boot 大部分代码都会从内存中消失。但有一部分代码需要继续留在内存里供操作系统调用比如设置主板时间、修改启动项这就是“运行时服务”。为什么要单独放因为它和普通 U-Boot 代码的“寿命”不同。普通代码引导完就没用了而这块区域必须在 Linux 运行时依然保持有效。.__efi_runtime_start 和.__efi_runtime_stop定义在链接脚本LDS中这种带有 _start 和 _stop 后缀的写法是嵌入式底层开发中极其经典且重要的“边界定义”技术。简单来说这两行代码并不包含具体的业务逻辑它们是为 EFI 运行时服务Runtime Services 划定的 “物理围墙”。作用精准定位大小程序在运行时可以通过 .__efi_runtime_stop - .__efi_runtime_start 计算出这块特区到底占了多少字节。整块操作搬运/保护在 C 语言代码里我们无法直接知道一个“段”到底占用了内存的哪些地址。但通过这两行定义的符号Symbols我们就可以在代码里直接引用2.EFI 重定位表.efi_runtime_rel_start:{*(.__efi_runtime_rel_start)}// 起点.efi_runtime_rel:{*(.relefi_runtime_text)*(.relefi_runtime_data)}.efi_runtime_rel_stop:{*(.__efi_runtime_rel_stop)}// 终点为什么需要“Rel” (Relocation)因为操作系统加载后可能会把这块 EFI 特区移动到内存的其他位置。作用这段区域存放的是“修正手册”。如果特区移动了它告诉系统如何修正代码里的地址比如某个变量原来在 A现在搬到了 B代码里的指针得跟着改。例子假设在 EFI 特区efi_runtime_data 段里定义了一个全局变量并在函数里引用它// 1. 数据段里的变量假设原地址在 0x8780_1000intefi_status1;// 2. 代码段里的引用voidcheck_status(void){if(efi_status1){...}}如果没有重定位编译器翻译 check_status 时的机器码逻辑是“去内存地址 0x8780_1000 取出那个数看看是不是 1。”移动后EFI 特区整体移动操作系统整个特区搬到了 0x9000_1000。变量 efi_status 现在实际在0x9000_1000。致命问题代码里的指令依然硬编码着“去 0x8780_1000 取数”。后果CPU 去老地址取数取到的是一堆垃圾数据程序直接崩溃。与起点的__image_copy_start遥相呼应.ALIGN(8);.image_copy_end:{*(.__image_copy_end)}. ALIGN(8); —— 为什么是 8 字节对齐硬件性能要求现代 64 位 CPU 或某些高性能 32 位总线如 ARM 的 LDRD/STRD 指令一次操作 64 位数据要求内存地址必须能被 8 整除。拷贝效率U-Boot 搬家时通常会使用优化过的汇编拷贝函数。这些函数为了追求极致速度往往会以 8 字节双字 为一组进行强制搬运。如果没有 8 字节对齐最后一次搬运可能会跨越边界甚至漏掉几个字节或者导致 CPU 触发“对齐异常”。架构作用它在镜像的末尾强行插入一些“填充字节确保整个 U-Boot 镜像的大小是 8 的倍数。.image_copy_end:它是一个空段不存放任何指令只存放了一个在汇编通常是 sections.c 或相关汇编文件中定义的符号标签 __image_copy_end作用在 U-Boot 的链接脚本开头分析过一个 __image_copy_start。现在这个 __image_copy_end 就是它的另一半。划定搬家范围U-Boot 的重定位逻辑非常简单。它会计算KaTeX parse error: Expected group after _ at position 8: 搬运长度 _̲_image_copy_end…只要在这个范围内的东西代码、只读数据、已初始化变量、EFI特区通通都会被搬到 DDR 的高地址去。避开非搬运区在 __image_copy_end 之后通常紧跟着的是 .rel.dyn重定位表和 .bss未初始化变量区。BSS 段不需要拷贝因为 BSS 里全是 0拷贝它纯属浪费时间。U-Boot 搬完家后会直接手动把 BSS 区清零。8.4 U-Boot 的重定位表.rel_dyn_start:{*(.__rel_dyn_start)}.rel.dyn:{*(.rel*)}.rel_dyn_end:{*(.__rel_dyn_end)}之前分析的 efi_runtime_rel 看作是某个特区的重定位表那么这里的 .rel.dyn 就是整个 U-Boot 镜像的重定位表。什么是 Rel.dyn (Dynamic Relocation)?U-Boot 是一段可以在内存中任意位置运行的代码位置无关代码。但在 C 语言里很多全局变量、函数指针的地址在编译时是写死的。意义当 U-Boot 决定从 Flash 搬移到 DDR 的高地址运行Relocation时它会查阅这张表。表里记录了镜像中所有存放“绝对地址”的位置。例子镜像里 0x100 处存了一个地址 0x87800000。搬家后U-Boot 会遍历这张表找到 0x100 这个位置把里面的值加上搬家的位移量使其变成新地址。8.5 .end.end:{*(.__end)}这是 U-Boot 镜像逻辑上的最后一个段。物理意义它标志着所有“有实质内容”的代码和数据到此全部结束。通常紧跟在重定位表后面。在某些内存管理逻辑中它被用来计算整个 U-Boot 运行所需的最小内存颗粒度。范围界定利用 _start 和 _end 标签U-Boot 成功地将这份庞大的修正清单封装成一个可识别的整体确保了搬家过程中的‘地址对齐’与‘数据完整’。8.6 BSS段.bss_start__rel_dyn_start(OVERLAY):{KEEP(*(.__bss_start));__bss_base.;}.bss__bss_base(OVERLAY):{*(.bss*).ALIGN(4);__bss_limit.;}.bss_end__bss_limit(OVERLAY):{KEEP(*(.__bss_end));}什么是 BSS 段无中生有的艺术定义BSS 段Block Started by Symbol存放的是程序中未初始化或初始化为 0 的全局变量。例子int global_array[1024];。这个数组很大但全是 0。特性不占 Flash 空间既然全是 0没必要在 u-boot.bin 文件里存 1024 个零。运行时占内存当程序跑起来后必须在 RAM 里给它留出位置并由程序手动清零。结论BSS 是一个“虚”段它在烧录文件里不存在只在运行版图中存在。OVERLAY覆盖机制这是这段代码的核心.bss_start __rel_dyn_start (OVERLAY)。为什么可以重叠逻辑闭环阶段 1重定位。U-Boot 刚跳到 DDR 时第一件事是读取 重定位表 (.rel.dyn)。这时候重定位表是“宝库”必须存在。阶段 2重定位完成。一旦地址修正好了重定位表就成了“废纸”占着内存纯属浪费。阶段 3清零 BSS。此时U-Boot 需要一块干净的内存给全局变量BSS。U-Boot 的做法直接把 BSS 段的起始地址设在重定位表的起始地址__rel_dyn_start上。_start、_base 和 _limit 来确保清零操作的精确性.bss_start锚定重定位表的起点作为 BSS 的逻辑开头。.bss收集所有 .o 文件里的未初始化变量。.bss_end标记 BSS 的物理终点。架构作用在 U-Boot 的 board_init_f 阶段会有一段简单的循环代码通常在 crt0.S 中“从 __bss_start 开始一直到 __bss_end把这块内存全部抹成 0。”这一抹就正式宣告了重定位表的彻底消失和 BSS 段的正式启用。