0%

重读《程序员的自我修养》

总述

编译链接把代码翻译成机器可以执行的机器码,把编译得到的数据copy的内存上,把CPU
PC写成第一条指令的地址,机器就可以执行这些机器码。了解编译链接的过程,会使得我们
更加透彻的理解计算机的行为。

编译链接的过程,基本上可以分为:编译预处理,编译,汇编,链接,其中编译、链接最
复杂,编译又可以分为词法分析、语法分析和语意分析,链接又分为静态链接和动态链接。
上面的每个步骤都有专门工具来做,像gcc这样的编译器只是把这所有的相关工具打包在一起。

要分析编译链接的整个过程,就要逐个分析上面提到的每一个点。汇编文件以及最终的可执行
文件的格式也是我们重点要专注的,需要了解分析可执行文件的工具。

预处理、编译和汇编

编译预处理做头文件展开和宏的替换,单独执行可以:gcc -E test.c -o test.i

编译是把高级语言的文本转换成汇编语言的文本,只执行编译可以gcc -S test.c -o test.s
编译得到的文件是汇编语言的文本,还是人可以直接阅读的程序,不是机器码。编译针对
一个文件进行,当这个文件里要访问其他文件里定义的函数和变量时,编译阶段无法的到
相关的地址。

汇编是把汇编语言翻译成机器码,从高级语言到汇编完成可以:gcc -c test.c -o test.o
得到的目标文件中的其他文件中定义的函数和变量的地址还是不能确定。

链接是把多个目标文件,最终整合成机器可以执行的机器码。包括所有地址的确定,链接器
的行为可以被链接脚本或者链接参数控制,比如,可以指定程序的起始地址,可以执行各个
段的地址,gcc有默认的链接脚本,程序也可以提供自己的链接脚本,比如,risv内核的链接
脚本在kernel/arch/riscv/kernel/vmlinux.lds

我们配合一段小程序看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#define FUNC add

int add(int a, int b)
{
int c;

c = a + b;

return c;
}

int main()
{
int d;

d = FUNC(2, 5);

return 0;
}

如下这样编译,nostartfiles是告诉gcc不要链接标准的startup相关的目标文件,-e main
是告诉gcc把main函数的地址地址作为程序的入口地址。riscv64-linux-gnu-objdump -d test > test.s
反汇编可以看出test的二进制只有main和add函数。

1
riscv64-linux-gnu-gcc test.c -nostartfiles -e main -o test --static
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
test:     file format elf64-littleriscv


Disassembly of section .text:

000000000001010c <add>:
1010c: 7179 addi sp,sp,-48
1010e: f422 sd s0,40(sp)
10110: 1800 addi s0,sp,48
10112: 87aa mv a5,a0
10114: 872e mv a4,a1
10116: fcf42e23 sw a5,-36(s0)
1011a: 87ba mv a5,a4
1011c: fcf42c23 sw a5,-40(s0)
10120: fdc42703 lw a4,-36(s0)
10124: fd842783 lw a5,-40(s0)
10128: 9fb9 addw a5,a5,a4
1012a: fef42623 sw a5,-20(s0)
1012e: fec42783 lw a5,-20(s0)
10132: 853e mv a0,a5
10134: 7422 ld s0,40(sp)
10136: 6145 addi sp,sp,48
10138: 8082 ret

000000000001013a <main>:
1013a: 1101 addi sp,sp,-32
1013c: ec06 sd ra,24(sp)
1013e: e822 sd s0,16(sp)
10140: 1000 addi s0,sp,32
10142: 4595 li a1,5
10144: 4509 li a0,2
10146: fc7ff0ef jal ra,1010c <add>
1014a: 87aa mv a5,a0
1014c: fef42623 sw a5,-20(s0)
10150: 4781 li a5,0
10152: 853e mv a0,a5
10154: 60e2 ld ra,24(sp)
10156: 6442 ld s0,16(sp)
10158: 6105 addi sp,sp,32
1015a: 8082 ret

用qemu-risv64运行它会segment fault, 运行的命令是qemu-riscv64 -d in_asm,cpu -D ./log test

查看日志看看程序到底是怎么运行的,这里直接在必要的地方加上注释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
----------------
IN: main
0x000000000001013a: 1101 addi sp,sp,-32 // 可以看到程序直接是从main开始跑的
0x000000000001013c: ec06 sd ra,24(sp) // 当然如果链接gcc startup,main就不是程序入口了
0x000000000001013e: e822 sd s0,16(sp)
0x0000000000010140: 1000 addi s0,sp,32
0x0000000000010142: 4595 addi a1,zero,5
0x0000000000010144: 4509 addi a0,zero,2
0x0000000000010146: fc7ff0ef jal ra,-58 # 0x1010c

pc 000000000001013a
x0/zero 0000000000000000 x1/ra 0000000000000000 x2/sp 0000004000800390 x3/gp 0000000000000000
x4/tp 0000000000000000 x5/t0 0000000000000000 x6/t1 0000000000000000 x7/t2 0000000000000000
x8/s0 0000000000000000 x9/s1 0000000000000000 x10/a0 0000000000000000 x11/a1 0000000000000000
x12/a2 0000000000000000 x13/a3 0000000000000000 x14/a4 0000000000000000 x15/a5 0000000000000000
x16/a6 0000000000000000 x17/a7 0000000000000000 x18/s2 0000000000000000 x19/s3 0000000000000000
x20/s4 0000000000000000 x21/s5 0000000000000000 x22/s6 0000000000000000 x23/s7 0000000000000000
x24/s8 0000000000000000 x25/s9 0000000000000000 x26/s10 0000000000000000 x27/s11 0000000000000000
x28/t3 0000000000000000 x29/t4 0000000000000000 x30/t5 0000000000000000 x31/t6 0000000000000000
----------------
IN: add
0x000000000001010c: 7179 addi sp,sp,-48 // 程序是跑到add了
0x000000000001010e: f422 sd s0,40(sp)
0x0000000000010110: 1800 addi s0,sp,48
0x0000000000010112: 87aa mv a5,a0
0x0000000000010114: 872e mv a4,a1
0x0000000000010116: fcf42e23 sw a5,-36(s0)
0x000000000001011a: 87ba mv a5,a4
0x000000000001011c: fcf42c23 sw a5,-40(s0)
0x0000000000010120: fdc42703 lw a4,-36(s0)
0x0000000000010124: fd842783 lw a5,-40(s0)
0x0000000000010128: 9fb9 addw a5,a5,a4
0x000000000001012a: fef42623 sw a5,-20(s0)
0x000000000001012e: fec42783 lw a5,-20(s0)
0x0000000000010132: 853e mv a0,a5
0x0000000000010134: 7422 ld s0,40(sp)
0x0000000000010136: 6145 addi sp,sp,48
0x0000000000010138: 8082 ret

pc 000000000001010c
x0/zero 0000000000000000 x1/ra 000000000001014a x2/sp 0000004000800370 x3/gp 0000000000000000
x4/tp 0000000000000000 x5/t0 0000000000000000 x6/t1 0000000000000000 x7/t2 0000000000000000
x8/s0 0000004000800390 x9/s1 0000000000000000 x10/a0 0000000000000002 x11/a1 0000000000000005 // add执行前的a0寄存器是2,a1是5
x12/a2 0000000000000000 x13/a3 0000000000000000 x14/a4 0000000000000000 x15/a5 0000000000000000 // qemu这里显示的本次执行前cpu的状态
x16/a6 0000000000000000 x17/a7 0000000000000000 x18/s2 0000000000000000 x19/s3 0000000000000000
x20/s4 0000000000000000 x21/s5 0000000000000000 x22/s6 0000000000000000 x23/s7 0000000000000000
x24/s8 0000000000000000 x25/s9 0000000000000000 x26/s10 0000000000000000 x27/s11 0000000000000000
x28/t3 0000000000000000 x29/t4 0000000000000000 x30/t5 0000000000000000 x31/t6 0000000000000000
----------------
IN: main
0x000000000001014a: 87aa mv a5,a0
0x000000000001014c: fef42623 sw a5,-20(s0)
0x0000000000010150: 4781 mv a5,zero
0x0000000000010152: 853e mv a0,a5
0x0000000000010154: 60e2 ld ra,24(sp)
0x0000000000010156: 6442 ld s0,16(sp)
0x0000000000010158: 6105 addi sp,sp,32
0x000000000001015a: 8082 ret

pc 000000000001014a
x0/zero 0000000000000000 x1/ra 000000000001014a x2/sp 0000004000800370 x3/gp 0000000000000000
x4/tp 0000000000000000 x5/t0 0000000000000000 x6/t1 0000000000000000 x7/t2 0000000000000000
x8/s0 0000004000800390 x9/s1 0000000000000000 x10/a0 0000000000000007 x11/a1 0000000000000005 // a0是7, add的返回值
x12/a2 0000000000000000 x13/a3 0000000000000000 x14/a4 0000000000000002 x15/a5 0000000000000007
x16/a6 0000000000000000 x17/a7 0000000000000000 x18/s2 0000000000000000 x19/s3 0000000000000000
x20/s4 0000000000000000 x21/s5 0000000000000000 x22/s6 0000000000000000 x23/s7 0000000000000000
x24/s8 0000000000000000 x25/s9 0000000000000000 x26/s10 0000000000000000 x27/s11 0000000000000000
x28/t3 0000000000000000 x29/t4 0000000000000000 x30/t5 0000000000000000 x31/t6 0000000000000000
// 后面就没有log了,这里也是segment fault的地方。这个地方出错是显然的,gcc的startup
// 会在main函数后用一个exit系统调用告诉内核自己退出,这里没有,程序一定会出错。

ELF文件

ELF文件的格式大体上是,ELF head + section headers + progam headers + 各个段的内容。
readelf, objdump, nm,strings等都可以解析ELF文件里的信息。progam headers描述的
是segment的信息,我们可以自定义section,程序中是直接可以引用section相关的符号的
地址的,比如内核就把初始化的函数放到一个.init.text section,内核初始化的时候统一
执行一遍,不同的section可能有相同的读写执行的属性,可以把多个属性相同的section
放到一个segment里。

riscv64-linux-gnu-readelf -h test 显示的ELF的头信息如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: RISC-V
Version: 0x1
Entry point address: 0x1013a // 程序的入口在main
Start of program headers: 64 (bytes into file)
Start of section headers: 896 (bytes into file)
Flags: 0x5, RVC, double-float ABI
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 3
Size of section headers: 64 (bytes)
Number of section headers: 7
Section header string table index: 6

最长看的ELF信息应该是符号表了,程序的符号信息一般放到符号表里,符号表是一个section。
readefl -s 或者nm都可以打出符号表,可以查看这个符号的地址,这个地址是编译生成的
符号的地址,对于用户态程序,就是符号的虚拟地址,运行起来也是一样值,对于内核,
是符号的虚拟地址,当内核运行在mmu打开之前,符号的实际运行地址和符号表里的地址是
不一样的,比如,riscv内核的链接脚本把_start这个符号的地址配置成了0xffffffff80000000,
而qemu把内核加载到了0x80200000的物理地址上,_start是内核的入口,实际运行时还没有
开mmu,_start的地址运行的地址是0x80200000。

静态链接

静态链接把库函数直接链接到了二进制中,增大了用户程序, 实际上不同用户态程序对应
的库函数的代码只要有一套就好。

静态库生成。

动态链接

(todo)

动态库加载,动态库查找。

内核链接

内核使用自己的链接脚本链接,riscv下在arch/riscv/kernel/vmlinux.ld.S,这个脚本
主要就是指定符号以及section的链接地址,还指定了程序入口。

比如用ENTRY指定程序的入口是_start,为_start这个符号分配的链接地址是LOAD_OFFSET,
riscv上是PAGE_OFFSET,一般这个值是0xffffffff80000000,不同的配置这个值是不一样
的。

链接地址和CPU取指时发出来来的地址可能是不一样,从硬件的角度看,CPU只时从PC寄存器
指定的地方取指令,如果MMU时开着就翻译下,用翻译得到的地址去取指令,如果MMU没有
开,那就直接用PC地址取指令执行了。一开始BIOS把内核Image加载到一段内存上执行,_start
就直接放到那段内存的起始地址,比如用qemu+opensbi的化,这个地址是0x80200000,一直到
MMU开始前是无法用链接得到的虚拟地址直接寻址的,编译器和内核需要用地址无关的指令
去寻址,具体可以参考《riscv head.S分析》。