〇前言
这几天做MIT操作系统实验的时候看到了.ld还有.s和.c文件,由此对看懂这些代码起了执念。
一、预备知识
1.源代码文件(.c)➡️ 可执行文件
一个源代码文件(如.c文件)变成可执行文件的过程涉及几个关键的编译和链接阶段。这个过程通常包括预处理、编译、汇编和链接四个主要步骤。
预处理: 处理#include <stdio.h>指令,将标准输入输出库的内容包含进来。
编 译: 将源代码转换成汇编指令。
汇 编: 将汇编指令转换为机器码,生成目标文件main.o。
链 接: 链接main.o文件与C标准库和其他必需的库文件,生成最终的可执行文件main。
在这个过程中,开发者使用命令行工具如gcc(GNU Compiler Collection)或clang来执行以上所有步骤,通常通过单一命令同时处理编译和链接步骤,如使用gcc main.c -o main
命令。
2.汇编文件(.s)➡️ 可执行文件
汇编: 使用汇编器(如as或nasm)处理example.s文件,生成目标文件example.o。这可以通过命令如as example.s -o example.o来完成。
链接: 使用链接器(如ld)处理example.o文件,生成最终的可执行文件example。这可以通过命令如ld example.o -o example来完成。
在整个过程中,.s文件因其已经是汇编代码,所以直接从汇编阶段开始,这使得从源代码到最终可执行文件的路径较短。这种直接控制硬件的能力使得汇编语言在需要高性能或低级硬件操作的应用程序中非常有用,例如在嵌入式系统或操作系统的开发中。
3.链接器脚本(.ld)
链接器脚本用于 指导链接器如何将多个编译后的目标文件组合成一个单一的可执行文件或库。 这些脚本可以精确控制输出文件中各个段(如代码段、数据段)的布局、对齐和地址,这对于满足特定的硬件要求、优化性能或保证运行时安全至关重要。
例如:在嵌入式系统中,可能需要将初始化代码放在非易失性存储器(如ROM)中,而将可变数据放在易失性存储器(如RAM)中。链接器脚本允许开发者明确指定哪些代码和数据应该放在哪个物理地址上。
二、基本语法
链接器脚本(linker script)是链接器的配置文件,它用来指定如何将对象文件中的各个部分映射到输出文件中。链接器脚本主要由以下几部分构成:
1. ENTRY(程序的入口点)
定义程序的入口点。这通常是主程序的main
函数,或者在嵌入式系统中可能是启动代码的入口。例如如果你有一个标准的C程序,其入口通常是main函数。在链接器脚本中,可以像下面这样设置入口点:
ENTRY(main)
2. MEMORY(系统的内存布局)
在链接器脚本中,MEMORY 命令用于定义不同内存区域的大小和属性。然而,并不是所有的链接器脚本都必须显式定义MEMORY。如果省略,链接器将按照默认的方式处理内存布局,通常是基于目标架构和系统默认的内存配置。
例如:
MEMORY
{
ROM (rx) : ORIGIN = 0x08000000, LENGTH = 1M
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 256K
EEPROM (rw) : ORIGIN = 0x08080000, LENGTH = 4K
CONFIG (rw) : ORIGIN = 0x40000000, LENGTH = 1K
}
/*
ROM 是主程序存储区,存放可执行代码。
RAM 是普通的读写执行存储。
EEPROM 是可擦写的非易失性存储,用于储存频繁更新的配置数据。
CONFIG 可能是指向特定硬件配置寄存器的内存映射区域。*/
3. SECTIONS(各个段的布局)
定义程序中各个段(sections)的布局。段是链接器处理的主要单元,一个段通常包含某种类型的信息,例如代码(.text
段),只读数据(.rodata
段),需要初始化的数据(.data
段)或者未初始化的数据(.bss
段)。
o.前置语法讲解
.的含义
在链接器脚本中,. 表示的是当前的位置计数器,也就是说,它代表的是当前内存的地址。在链接器脚本中,你可以将它看作是一个变量,它的值会随着链接器处理各个段而自动改变。可以用它来设置或引用当前的地址。
ALIGN语句
. = ALIGN(16);
这是一条对齐语句。ALIGN(16) 的意思是将当前的内存地址调整为 16 的倍数。如果当前地址已经是 16 的倍数,则不会有任何改变;否则,链接器会填充一些空位,使得下一个地址为 16 的倍数。对内存地址进行对齐操作,一般是因为对齐的地址能被硬件更快地访问。
例如,. = ALIGN(0x1000); 将当前位置调整为最近的 4096 字节(0x1000 十六进制表示 4096)的倍数。这通常用于页面对齐
ASSERT语句
ASSERT(expression, message);
这是一条断言语句。如果 expression 表达式的值为 false,那么链接器会用 message 报告一个错误并终止链接过程。在你给出的例子中,断言确保跳板代码的大小不超过一页。
PROVIDE语句
PROVIDE(symbol = expression);
这是一条提供语句。这条语句会定义一个符号,并将它的值设置为 expression 表达式的值。但是,如果该符号已经在其他地方定义过了,那么这条语句就会被忽略。例如,PROVIDE(etext = .); 提供了一个全局符号 etext 来表示 .text 段的结束位置。
a. .text
段
- 作用:这个段通常包含程序的可执行代码。它通常位于程序的开头,并且是只读的,因此代码不能被程序自身修改。
- 示例:
.text : { *(.text .text.*) . = ALIGN(0x1000); _trampoline = .; *(trampsec) . = ALIGN(0x1000); ASSERT(. - _trampoline == 0x1000, "error: trampoline larger than one page"); PROVIDE(etext = .); } /* 通过 *(.text .text.*) 和 *(trampsec),它收集了所有的执行代码和特殊的跳板代码(假设trampsec包含这类代码)。 通过 ALIGN(0x1000) 指令,它确保了代码段在内存中的对齐,这对于性能优化和满足某些硬件或操作系统的内存对齐要求是重要的。 _trampoline 标签用于标记跳板代码的开始位置,这样程序就可以知道跳板代码在哪里,或者其他段可以引用这个位置。 通过 ASSERT(. - _trampoline == 0x1000, "error: trampoline larger than one page") 断言,它确保跳板代码的大小不超过一页。这是一种错误检查机制,用于确保代码不会超过预定的空间限制。 PROVIDE(etext = .); 提供了一个全局符号来表示 .text 段的结束位置,这可以用来计算执行代码的大小或者作为程序其他部分的参考。 */
b. .rodata
段
- 作用:这个段包含只读数据,比如常量或字符串字面量。它通常放在
.text
段后面,也是只读的。 - 示例:
.rodata : { . = ALIGN(16); *(.srodata .srodata.*) /* do not need to distinguish this from .rodata */ . = ALIGN(16); *(.rodata .rodata.*) }
c. .data
段
- 作用:这个段包含程序的已初始化数据。这些数据在程序加载时会被从可执行文件中复制到内存中。
data
段通常是可读写的,但不可执行。 - 示例:
.data : {
. = ALIGN(16);
*(.sdata .sdata.*) /* do not need to distinguish this from .data */
. = ALIGN(16);
*(.data .data.*)
}
/*
. = ALIGN(16);:这条指令用于对齐段的起始地址。ALIGN(16) 确保了当前地址是 16 字节的倍数。这种对齐操作通常是为了优化性能或满足特定硬件或操作系统的要求。
*(.sdata .sdata.*):这是一种通配符表达式,用于从不同的输入文件中选择所有名称为 .sdata 或以 .sdata. 开头的段。在某些情况下,.sdata 段被用于存储小的全局和静态变量。
*(.data .data.*):这是另一种通配符表达式,用于从不同的输入文件中选择所有名称为 .data 或以 .data. 开头的段。这确保了所有已初始化的数据都包含在 .data 段中。
*/
d. .bss
段
- 作用:这个段包含程序的未初始化数据。它在程序加载时会被清零。
.bss
段也是可读写的,但不可执行。 - 示例:
.bss : {
. = ALIGN(16);
*(.sbss .sbss.*) /* do not need to distinguish this from .bss */
. = ALIGN(16);
*(.bss .bss.*)
}
/*
初次对齐:. = ALIGN(16); 在段的开始确保整个 .bss 段在内存中的起始地址是16字节对齐的。这是通常出于性能优化的目的,因为许多硬件平台访问对齐的内存地址时更加高效。
中间对齐:在 *(.sbss .sbss.*) 后再次进行 . = ALIGN(16); 的目的可能是为了确保在 .sbss 小段数据后,接下来的 .bss 数据也是16字节对齐的。尽管 .sbss 和 .bss 数据通常被看作是连续的,但中间的对齐确保了无论 .sbss 段数据结束于何种状态,接下来的 .bss 数据仍然保持正确的对齐。
*/
4. 其他
在链接器脚本(.ld文件)中,除了ENTRY
, MEMORY
, 和SECTIONS
这三个关键部分之外,还可能包含以下结构:
a. OUTPUT_ARCH(输出架构)
- 作用:指定目标架构,确保生成的代码与目标硬件兼容。
- 示例:
OUTPUT_ARCH("riscv")
b. OUTPUT_FORMAT(输出格式)
- 作用:定义输出文件的格式,如
elf32-i386
、elf64-x86-64
等,确保生成的二进制文件符合特定的格式要求。 - 示例:
OUTPUT_FORMAT("elf64-x86-64")
c. OUTPUT(输出文件名)
- 作用:指定链接器输出文件的名称。
- 示例:
OUTPUT("myprogram.elf")
d. SEARCH_DIR(搜索目录)
- 作用:添加一个或多个目录到库搜索路径,让链接器知道在哪些位置查找需要的库文件。
- 示例:
SEARCH_DIR("/usr/lib");
e. GROUP(库文件组)
- 作用:定义一组库文件,链接器将在需要时从中提取目标文件。
- 示例:
GROUP(libgcc.a libc.a libm.a)
f. INCLUDE(包含其他链接脚本)
- 作用:允许包含其他链接脚本文件,这通常用于复用已经定义好的通用配置。
- 示例:
INCLUDE "common_settings.ld"
g. VERSION(版本控制)
- 作用:用于定义符号的版本信息,主要用于共享库的符号版本控制。
- 示例:
VERSION { 1 { global: foo; local: *; }; }
h. 补充:它的注释用/* */
三、实例分析
下面提供一个经典的链接器脚本示例,适用于某个假设的嵌入式系统,看看你能看懂不👀
/* 指定输出文件的架构,确保与目标硬件兼容 */
OUTPUT_ARCH("arm")
/* 定义输出文件的格式,确保生成的二进制文件符合特定格式要求 */
OUTPUT_FORMAT("elf32-littlearm")
/* 指定链接器输出文件的名称 */
OUTPUT("firmware.elf")
/* 指定程序的入口点,此例中为main函数 */
ENTRY(main)
/* 定义系统的内存布局 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
/* 定义各个段的布局和特性 */
SECTIONS
{
/* 定义.text段,包含程序的可执行代码 */
.text : {
. = ALIGN(4);
*(.text) /* 包含所有.text节 */
*(.text*) /* 包含所有以.text开头的节 */
. = ALIGN(4);
_etext = .; /* 定义一个全局符号_etext标记.text段的结束 */
} > FLASH
/* 定义.rodata段,包含只读数据 */
.rodata : {
. = ALIGN(4);
*(.rodata) /* 包含所有.rodata节 */
*(.rodata*) /* 包含所有以.rodata开头的节 */
} > FLASH
/* 定定义.data段,包含初始化的数据 */
.data : {
. = ALIGN(4);
_sdata = .; /* 定义一个全局符号_sdata标记.data段的开始 */
*(.data) /* 包含所有.data节 */
*(.data*) /* 包含所有以.data开头的节 */
. = ALIGN(4);
_edata = .; /* 定义一个全局符号_edata标记.data段的结束 */
} > SRAM AT > FLASH
/* 定义.bss段,包含未初始化的数据 */
.bss : {
. = ALIGN(4);
_sbss = .; /* 定义一个全局符号_sbss标记.bss段的开始 */
*(.bss) /* 包含所有.bss节 */
*(.bss*) /* 包含所有以.bss开头的节 */
. = ALIGN(4);
_ebss = .; /* 定义一个全局符号_ebss标记.bss段的结束 */
} > SRAM
/* 最后,确保所有未分配的节都被处理掉 */
/DISCARD/ : {
*(.comment) /* 忽略所有.comment节 */
}
}
/* 添加搜索目录,让链接器知道在哪里查找库文件 */
SEARCH_DIR("/usr/lib/arm-linux-gnueabihf");
/* 定义一组库文件,链接器将在需要时从中提取目标文件 */
GROUP(libgcc.a libc.a libm.a)
脚本分析
- 该脚本首先设置了输出的体系结构、格式和文件名。
- 使用ENTRY指令指定程序入口是
main
函数。 - MEMORY命令定义了两个内存区域:FLASH和SRAM,分别用于存储代码和数据。
- SECTIONS命令描述了四个主要的段:
.text
、.rodata
、.data
、和.bss
。每个段的起始地址都进行了对齐,确保数据结构对齐,提高访问效率。 .text
和.rodata
段被放置在只读的FLASH内存中,.data
和.bss
段则放在可读写执行的SRAM中。.data
段使用了“AT > FLASH”语法,表示虽然.data
段在运行时位于SRAM,但其初始内容需要从FLASH中复制过来。- 使用SEARCH_DIR和GROUP指令来指定库文件的搜索路径和链接的库。
- 最后,使用
/DISCARD/
段来丢弃不需要的节,如.comment
节,这有助于减小最终的二进制文件大小。
通过上述方式,链接器脚本确保了程序的各个组成部分正确地映射到指定的内存区域,同时还控制了内存的权限和属性,保证了程序的执行效率和安全性。