引子
最近开了一门名为“计算机系统”的课,以卡内基梅隆大学的《深入理解计算机系统》为教材,讲解偏向底层的一些知识:浮点数具体的表示方法、预处理编译汇编链接具体发生什么……这门课的大作业要求我们跟随“预处理-编译-汇编-链接-进程管理”的路线,写一下“Linux下Hello World是如何输出的”。
然而在写大作业的时候,我还没有复习,上课也没有认真听,东拼西凑写出来了一篇文章。事到如今,差不多把教材里的知识点都掌握了,那就认真写一遍吧。
上课挺没意思的。说实话。
0. 源程序
源程序
1 |
|
1. 预处理
概念
预处理阶段,编译器主要对宏定义进行一些处理。比如,引入一些头文件、定义一些宏变量等。
后缀为.c的源程序文件,通过预处理,可以生成后缀名为.i的预处理文件。
命令
1 | gcc -E hello.c -o hello.i |
解释
.i文件部分输出如下:
1 | # 1 "/usr/include/bits/types/__mbstate_t.h" 1 3 4 |
如您所见,预处理后的文件引入了许多结构体、许多函数。我们编写的主函数,则位于这个文件的末尾。
此外,有很多,类似于这样的语句:
1 | # 885 "/usr/include/stdio.h" 3 4 |
这些语句标记了源文件和代码行来自于哪个头文件、哪一行。您可以参考gcc的官方文档获取更多信息。
2. 编译
概念
将程序语言转换为汇编语言。
通过编译,.i文件可以生成.s文件。
命令
1 | gcc -S hello.i -o hello.s |
这两种生成hello.s
的方法是等价的,最后的结果没有任何差异。您可以通过diff
命令来检查。
解释
hello.s
文件输出如下:
1 | .file "hello.c" |
该文件涉及到的知识点很多。
.file
,表明了该.s文件的源程序文件名。.text
,表明代码节的开启,每个节的作用,在2.5节将进行解释。.section .rodata
,代表定义一个只读的数据节。.LC0
,也就是local constant 0
,局部常量。.string
,声明了一个字符串。.globl main
,声明了main是一个全局符号。.type A, B
,声明了A
为B
类型。.LFB0 .LFE0
,全称分别为Local Function Begins
,Local Function Ends
,代表局部函数的开始和结束。.size
,表明main符号的尺寸。.ident
,表明编译器版本。.section .note.GNU-stack,"",@progbits
,意思是这个segment会被标记为 “非可执行栈”,引用自简书。cfi
,即Call Frame Information
,以之开头的一些指令和栈相关。
2.5 汇编与内存相关知识
我们现在所使用的是AT&T汇编指令系统。
该指令系统的部分特点是:
立即数,也就是汇编指令中的数字,前面需要加上美元符号。比如
$1
、$5
等。寄存器,需要在前面加上百分号。比如
%rbp
,%rax
等。如果寄存器加上括号,比如(%rax)
代表间接寻址,也就是先从rax
里取一个地址,在去内存中根据该地址寻找数据。指令后需要加一个标记,比如
pushq
、movq
最后的q
,和movl
最后的l
。标记有四种b,w,l,q,分别代表操作的数据长度为1,2,4,8字节。向函数中传递参数,前六个参数分别存入
rdi
、rsi
、rdx
、rcx
、r8
、r9
寄存器中,其他参数入栈。返回值存入rax
寄存器中。
当程序运行时,内存布局如下:
- 栈:存储局部变量、函数参数等。
- 堆:存储动态分配的内容。
- 数据段:存储全局变量、static变量、字符串。
- 代码段:存储只读、可执行的机器指令。
当程序编译时,各内容布局如下:
.text
节:存储代码。.rodata
节:存储只读数据(printf
的格式串等)。.data
节:存储数据和已经初始化的全局与静态变量。.bss
节:存储为初始化的全局变量。.symtab
节:存储符号表。- …
3. 汇编
概念
将汇编语言转换为机器语言。
通过汇编,.s文件会生成.o文件。
命令
1 | gcc -c hello.s -o hello.o |
解释
最终生成一个二进制文件,而这个文件直接通过cat
输出,没办法被阅读。需要反汇编,命令如下:
1 | objdump -D hello.o |
输出如下:
1 |
|
第一行说明了该文件为ELF文件,ELF即Executable and Linkable Format,可执行可链接文件格式。和上一步输出得到的文件相比,本文件有如下几个特点:
call
指令后面是具体的地址,而非抽象的文件名。- 开始使用十六进制操作数。
- 出现了很多节,起始地址都为0。
- …
4. 链接
概念
一个程序可能有很多个模块,比如假设一个程序由三个源文件组成,其中第一个文件中的很多变量可能在第二个文件中定义。和#include
引入头文件不同,链接一般是将多个.o
文件拼凑起一个完整的程序。
可以在编译时链接,也可以在运行时通过dlopen()
等函数动态链接一些其他文件,在Windows下,动态链接的是动态链接表.dll
文件,在Linux下,动态链接的是共享库文件.so
。
链接分两个步骤:符号解析和重定位,前者解释所有的符号,将每一个符号与一个解释关联起来,所谓符号,要么是函数名,要么是变量;后者则给这些符号一个安身之所,定位到一个绝对内存位置。
命令
通过链接器ld
链接即可。本例可以通过:
1 | ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/crt1.o /usr/lib/crti.o hello.o /usr/lib/libc.so /usr/lib/crtn.o |
进行编译时链接。
解释
通过反编译,链接后的文件部分输出如下:
1 | 0000000000400458 <.gnu.version_r>: |
最大的变化是,文件变长了,除此之外也有一个显著的新特点:每一个节的开始位置不再是0,而是一个具体的地址。
5. 进程管理、信号与内存管理
进程管理与信号
当我们在Shell中运行程序时,会经历几个步骤:
- 读取命令。
- 判断命令的准确性。
- 使用
fork()
创建子进程,在子进程中调用execve()
启动程序。 - Shell等待子进程完成。
其中,用户可以给进程发出一些“信号”,比如按下C-z
键,就会向进程输出SIGSTOP信号,按下C-c
键,就会向进程输出SIGINT信号。当接收到这些信号时,程序可能有如下几种反应:
- 忽略。
- 调用相应的信号处理函数处理信号。
- 执行默认的信号处理方式。
内存管理
一定程度上,进程之间彼此独立。在创建子进程后,在父进程中修改全局变量,子进程中的对应变量不会被更改。二者的内存区域指向不同的物理空间。
若想要在进程中通信,可以通过管道进行数据传输。
6. 结论
源程序首先经过预处理,处理掉一些宏定义,经过编译,将之转换为汇编语言文件,经过汇编,将之转换为二进制文件,最后经过链接,定义符号,将程序各段重定位到绝对内存地址。
之后,通过Shell,读取命令,创建子进程,输出。