1.链接的作用
通过前文从零开始写一个操作系统 —— 2.5 从c语言到机器码 - 掘金 (juejin.cn)我们已经知道如何把c文件编译成可以被cpu识别的机器码,但是这个机器码离真正能够运行还存在一个对变量的定位问题。如下列文件main.c:
int i = 3;
const j = 4;
void func()
{
//something here;
}
int main()
{
func();
return 0;
}
对上述代码进行编译gcc -c main.c之后,生成的main.o二进制文件如下:
$objdump -d main.o
main.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <func>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 90 nop
9: 5d pop %rbp
a: c3 retq
000000000000000b <main>:
b: f3 0f 1e fa endbr64
f: 55 push %rbp
10: 48 89 e5 mov %rsp,%rbp
13: b8 00 00 00 00 mov $0x0,%eax
18: e8 00 00 00 00 callq 1d <main+0x12>
1d: b8 00 00 00 00 mov $0x0,%eax
22: 5d pop %rbp
23: c3 retq
我们可以看到在18行中调用func函数的二进制代码是e8 00 00 00 00,也就是call了一个保留的地址,并未指向func函数,而这个地址的确认实际上是在链接过程中。
2.链接脚本
在日常的程序编译过程中,c源文件经过gcc编译之后生成了ELF(Executable and Linkable Format)文件。在上述文件中,二进制文件采用分段储存。比如代码相关数据,全部储存至名为.text的section中。定义的全局变量,则储存至名为.data的section中。而链接的过程,就是给各个section一个确定的地址,这样的话就可以让程序在运行的过程中正确定位到函数以及变量。至于这个确定的地址,则是由链接脚本给出,如main.lds:
SECTIONS
{
. = 0x1000;
.text : { *(.text) }
.data : { *(.data) }
.rodata : { *(.rodata) }
}
.代表着对当前地址的定位,上述例子将section分布的起始位置定位为0x1000,因为.text是第一个section,则.textsection的第一个数据地址为0x1000。.text : { *(.text) }它代表着链接过程将会把所有目标文件的.text段融合到输出文件的.text段中。紧接着的是.datasection,因为没有再次设定地址,则当最后一个属于.textsection的数据结束后,下一个数据就是属于.datasection的了,以此类推。
对于刚刚我们提到的main.c文件进行特定编译脚本的编译后gcc -c main.c && ld main.o -T main.lds -o main && objdump -s main:
gcc -c main.c && ld main.o -T main.lds -o main && objdump -s main
main: file format elf64-x86-64
Contents of section .text:
1000 f30f1efa 554889e5 905dc3f3 0f1efa55 ....UH...].....U
1010 4889e5b8 00000000 e8e3ffff ffb80000 H...............
1020 00005dc3 ..].
Contents of section .data:
1024 03000000 ....
Contents of section .rodata:
1028 04000000 ....
......
可以看到contents of section .text就是从0x1000开始,并且后面紧接着contents of section .data,起始于0x1024,然后是.rodata。这与我们在链接脚本中规定的排列方式是一样的。接下来要做的就是提取完整的二进制文件了,如我们之前提到的那样从零开始写一个操作系统 —— 2.5 从c语言到机器码 - 掘金 (juejin.cn),到目前我们已经解决了从c源文件到内核文件的问题了。