这几天做MIT操作系统实验的时候看到了.ld还有.s和.c文件,由此对看懂这些代码起了执念。
一个源代码文件(如.c文件)变成可执行文件的过程涉及几个关键的编译和链接阶段。这个过程通常包括预处理、编译、汇编和链接四个主要步骤。
预处理: 处理#include
编 译: 将源代码转换成汇编指令。
汇 编: 将汇编指令转换为机器码,生成目标文件main.o。
链 接: 链接main.o文件与C标准库和其他必需的库文件,生成最终的可执行文件main。
在这个过程中,开发者使用命令行工具如gcc(GNU Compiler Collection)或clang来执行以上所有步骤,通常通过单一命令同时处理编译和链接步骤,如使用gcc main.c -o main
命令。
汇编: 使用汇编器(如as或nasm)处理example.s文件,生成目标文件example.o。这可以通过命令如as example.s -o example.o来完成。
链接: 使用链接器(如ld)处理example.o文件,生成最终的可执行文件example。这可以通过命令如ld example.o -o example来完成。
在整个过程中,.s文件因其已经是汇编代码,所以直接从汇编阶段开始,这使得从源代码到最终可执行文件的路径较短。这种直接控制硬件的能力使得汇编语言在需要高性能或低级硬件操作的应用程序中非常有用,例如在嵌入式系统或操作系统的开发中。
链接器脚本用于 指导链接器如何将多个编译后的目标文件组合成一个单一的可执行文件或库。 这些脚本可以精确控制输出文件中各个段(如代码段、数据段)的布局、对齐和地址,这对于满足特定的硬件要求、优化性能或保证运行时安全至关重要。
例如:在嵌入式系统中,可能需要将初始化代码放在非易失性存储器(如ROM)中,而将可变数据放在易失性存储器(如RAM)中。链接器脚本允许开发者明确指定哪些代码和数据应该放在哪个物理地址上。
链接器脚本(linker script)是链接器的配置文件,它用来指定如何将对象文件中的各个部分映射到输出文件中。链接器脚本主要由以下几部分构成:
定义程序的入口点。这通常是主程序的main
函数,或者在嵌入式系统中可能是启动代码的入口。例如如果你有一个标准的C程序,其入口通常是main函数。在链接器脚本中,可以像下面这样设置入口点:
ENTRY(main)
在链接器脚本中,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 可能是指向特定硬件配置寄存器的内存映射区域。*/
定义程序中各个段(sections)的布局。段是链接器处理的主要单元,一个段通常包含某种类型的信息,例如代码(.text
段),只读数据(.rodata
段),需要初始化的数据(.data
段)或者未初始化的数据(.bss
段)。
在链接器脚本中,. 表示的是当前的位置计数器,也就是说,它代表的是当前内存的地址。在链接器脚本中,你可以将它看作是一个变量,它的值会随着链接器处理各个段而自动改变。可以用它来设置或引用当前的地址。
. = ALIGN(16);
这是一条对齐语句。ALIGN(16) 的意思是将当前的内存地址调整为 16 的倍数。如果当前地址已经是 16 的倍数,则不会有任何改变;否则,链接器会填充一些空位,使得下一个地址为 16 的倍数。对内存地址进行对齐操作,一般是因为对齐的地址能被硬件更快地访问。
例如,. = ALIGN(0x1000); 将当前位置调整为最近的 4096 字节(0x1000 十六进制表示 4096)的倍数。这通常用于页面对齐
ASSERT(expression, message);
这是一条断言语句。如果 expression 表达式的值为 false,那么链接器会用 message 报告一个错误并终止链接过程。在你给出的例子中,断言确保跳板代码的大小不超过一页。
PROVIDE(symbol = expression);
这是一条提供语句。这条语句会定义一个符号,并将它的值设置为 expression 表达式的值。但是,如果该符号已经在其他地方定义过了,那么这条语句就会被忽略。例如,PROVIDE(etext = .); 提供了一个全局符号 etext 来表示 .text 段的结束位置。
.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 段的结束位置,这可以用来计算执行代码的大小或者作为程序其他部分的参考。
*/
.rodata
段.text
段后面,也是只读的。.rodata : {
. = ALIGN(16);
*(.srodata .srodata.*) /* do not need to distinguish this from .rodata */
. = ALIGN(16);
*(.rodata .rodata.*)
}
.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 段中。
*/
.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 数据仍然保持正确的对齐。
*/
在链接器脚本(.ld文件)中,除了ENTRY
, MEMORY
, 和SECTIONS
这三个关键部分之外,还可能包含以下结构:
OUTPUT_ARCH("riscv")
elf32-i386
、elf64-x86-64
等,确保生成的二进制文件符合特定的格式要求。OUTPUT_FORMAT("elf64-x86-64")
OUTPUT("myprogram.elf")
SEARCH_DIR("/usr/lib");
GROUP(libgcc.a libc.a libm.a)
INCLUDE "common_settings.ld"
VERSION {
1 { global: foo; local: *; };
}
下面提供一个经典的链接器脚本示例,适用于某个假设的嵌入式系统,看看你能看懂不
/* 指定输出文件的架构,确保与目标硬件兼容 */
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)
main
函数。.text
、.rodata
、.data
、和.bss
。每个段的起始地址都进行了对齐,确保数据结构对齐,提高访问效率。.text
和.rodata
段被放置在只读的FLASH内存中,.data
和.bss
段则放在可读写执行的SRAM中。.data
段使用了“AT > FLASH”语法,表示虽然.data
段在运行时位于SRAM,但其初始内容需要从FLASH中复制过来。/DISCARD/
段来丢弃不需要的节,如.comment
节,这有助于减小最终的二进制文件大小。、