摘要
Hello是程序员的“初恋”,是每个程序员的起点。而仅仅实现打印出hello,对于一个合格的程序员来说,这往往是不够的。此次将从更为底层的角度入手,来了解Hello的一生。
本文将展示Linux系统,x86-64架构下的hello程序P2P和O2O的过程,从编译、进程管理和内存管理三个方面来具体阐述,以此来揭示计算机系统的主要工作机制。
关键词:Linux;预处理;编译;汇编;链接;进程管理;内存管理
第1章 概述
1.1 Hello简介
Hello的P2P :From Program to Process,指Hello从一个程序转变到一个进程的过程。其中,Hello.c经过编译器gcc的预处理、编译、汇编、链接后,变成一个可执行文件Hello.out。进一步,在shell中执行Hello.out,shell会调用fork()函数为其生成一个子进程,然后调用execve()函数加载该程序,最后完成打印“Hello”。
Hello的O2O:From Zero-0 to Zero-0,指Hello从最开始什么都没有,到经过编辑器编写生成.c文件,再经过上述P2P过程,生成.out文件,从磁盘中加载到内存,再到最后执行完毕,被父进程回收,由内核删除子进程的所有信息,包括分配的空间,一切又变成0。
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;2.80GHz;16.0G RAM;476G HD Disk;
1.2.2 软件环境
Windows10 64位;Vmware16;Ubuntu22.10 64位;
1.2.3 开发工具
Visual Studio Code 1.76.1;CodeBlocks 64位;vi/vim/gredit+gcc;
1.3 中间结果
文件名 | 说明 |
---|---|
hello.i | 由预处理器生成的文件 |
hello.s | 由编译器生成的文件 |
hello.o | 由汇编器生成的文件 |
hello.out | 由链接器生成的文件 |
o_ans.txt | Hell.o反汇编生成的文件 |
out_ans.txt | Hello.out反汇编生成的文件 |
1.4 本章小结
本章简单描述了Hello的一生,从被编写,到编译,再到加载运行,再到最后被回收,重新归0的过程。也列出了此次实验的环境,还有实验过程中生成的中间文件。
第2章 预处理
2.1 预处理的概念与作用
预处理就是预处理器(cpp)将预处理指令(以字符#开头的命令)转换为实际代码中的内容,从而生成.i文件。
作用:cpp扩展源代码,插入所有用#include命令指定的文件,并扩展所有用#define声明指定的宏。
2.2在Ubuntu下预处理的命令
指令:gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
可以看到在Hello.i文件中,相比于Hello.c文件,增加了好多代码段。全是从头文件stdio.h、unistd.h、stdlib.h中插入的,包含了许多外部变量,结构体,枚举等等。
2.4 本章小结
本章介绍了编译系统中的预处理阶段,包括什么是预处理,即预处理有什么作用。还介绍了在Linux系统下如何对源文件进行预处理操作。
第3章 编译
3.1 编译的概念与作用
编译指由编译器(ccl)对预处理完的文件hello.i进行一系列的词法分析、语法分析、语义分析和优化,翻译成文件hello.s,它包含一个汇编语言程序。
编译的作用:生成汇编语言,以便后续汇编器的转换。
(注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序)
3.2 在Ubuntu下编译的命令
指令:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1 数据类型
- 常量
比如源文件中的常量4,编译器ccl用立即数(前面添加$)进行表示;还有字符串,如”用法: Hello 学号 姓名 秒数!”,则对应于.rodata的.string中。 - 局部变量
对于主函数main的两个形式参数argc和argv,最开始存储在寄存器%edi和%rsi,后来,便转移到main所分配的栈帧中。对于主函数中的int类型的变量i,也是存储在main函数的栈帧中-4(%rbp)。
3.3.2 赋值
编译器ccl使用mov指令给局部变量i赋初值0
3.3.3 类型转换
通过调用函数atoi()将字符串转换为int整数。
3.3.3 算术操作
编译器ccl使用add指令对局部变量i进行加1操作,即M(-4+%rbp)++
3.3.4 关系操作
编译器使用cmpl对argc和4进行比较,设置相应的条件码。如果相等(即零标志ZF=1),则跳转到.L2。
编译器ccl对i和7进行比较,并设置相应的条件码。如果i <= 7(即(SF^OF) | ZF=1),则跳转到.L2。
3.3.5 数组/指针操作
实际上就是通过栈指针%rsp加上字节偏移来对argv数组进行访问。
3.3.6 控制转移
- if语句条件控制。
实际上就和刚才讲过的关系操作时一致的。当满足argc==4时,便会发生跳转。
2) for循环
.L2是对i进行初始化。
.L4是循环体,里面是输出printf,还有调用函数sleep。
.L3是循环条件的判断,当i<=7时,则跳转到.L4执行循环体内语句,否则,执行.L3后续指令。
3.3.7 函数操作
编译器ccl使用call对函数进行调用。该程序中调用了exit()、printf()、sleep()、atoi()和getchar()
5个函数。
3.4 本章小结
本章介绍了编译的概念和作用,以及如何在Linux系统下对.i文件进行编译。还对编译后的.s文件中的数据和操作进行了具体的分析。
第4章 汇编
4.1 汇编的概念与作用
汇编指将hello.s翻译成机器语言指令,并把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。
作用:将汇编语言转换成机器语言。
(注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。)
4.2 在Ubuntu下汇编的命令
指令:gcc -c hello.s -o hello.o
(注意:-c是小写的c!)
4.3 可重定位目标elf格式
4.3.1 ELF头
从ELF头中可以获取该文件的基本信息,包括文件类型、机器类型、以及节头部表的大小等等。
4.3.2 节头部表(Section Headers)
节头部表定义了文件中的所有sections,包括其大小、类型、地址和偏移。
4.3.3 符号表
符号表则是存放程序中定义和引用的函数和全局变量的信息。从Bind字段便可以分辨出该符号是全局符号还是本地符号(部分还会显示为弱符号)。
4.3.4 重定位节
重定位节放置的是重定位条目。其中,offset是节内偏移,symbol表示所绑定的符号,Type则是重定位类型。
4.4 Hello.o的结果解析
通过指令objdump -d -r hello.o
对hello.o进行反汇编。与第3章的 hello.s进行对照分析,发现,最大的不同就是反汇编后的文件多了机器代码。每条汇编语句都对应着一条机器代码。而两者的区别如下:
- 反汇编后的机器代码用十六进制表示操作数,而且字节顺序也存在差别;而.s文件中的则是使用十进制来表示立即数。
- 反汇编后的指令的操作码并没有指明具体的字节数(即没有后缀b、w、l、q);而.s文件中都具体指明了处理的数据长度
- 对于分支转移,反汇编后的跳转指令则是使用具体地址,后面跟着的则是相对于主函数的偏移,例:
jmp 86<main+0x86>
;而在.s文件中,则是使用助记符来进行跳转,例:jmp .L3
。 - 对于函数调用,反汇编后的call指令的操作数则是跳转的具体地址,例:
call 68<main+0x68>
;而在.s文件中,则是直接使用函数名作为跳转目标,例:call printf@PLT
。
4.5 本章小结
本章介绍了汇编的概念和作用,以及如何在Linux系统下进行汇编,并对可重定位目标文件的ELF格式进行分析,还对反汇编后的文件与原先的hello.s文件进行比较。
第5章 链接
5.1 链接的概念与作用
链接指将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。
(注意:这儿的链接是指从 hello.o 到hello生成过程。)
5.2 在Ubuntu下链接的命令
指令如下:
ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/12/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/12/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello.out
(其中,数字12为gcc的版本号)
5.3 可执行目标文件hello的格式
5.3.1 ELF头
5.3.2 节头部表(Section Headers)
5.3.3 程序头部表(Program Headers)
程序头部表用来表示可执行文件的连续的片与内存段的映射关系。
5.3.4 符号表
对比发现,hello.o经过链接后得到hello.out,其符号数量经过链接后也增多了。
5.3.5 重定位节
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
根据程序节头表可以看出,程序从0x0000000000400040
开始存储PHDR,其保存的是程序头表;从0x00000000004002e0
则存储INTERP的内容,其保存的是必须要调用的解释器;而从0x0000000000401000
到0x0000000000402000
则对应LOAD初始段的内容。
5.5 链接的重定位过程分析
上图经过指令objdump -d -r hello.out
反汇编得到的。我们可以看到与hello.o的不同之处在于
- hello.o的反汇编文件中只有main函数的汇编代码,并没有其他函数或者其他节的代码。而hello.out的反汇编文件中不仅包含了main函数的汇编代码,还链接了其他函数的汇编代码,还有其他段的信息,如_init函数,用来初始化代码的等等。
- hello.o的反汇编文件中并没有使用虚拟内存地址,而是以main函数为起点,仅使用0000000000000000来代表main函数的地址;在hello.out的反汇编文件中,则标明了每段代码的虚拟内存地址,这都是通过链接完成的。
而整个重定位过程,实际上是由链接器先将多个单独的代码节和数据节合并为单个节,就好比如.text节,经过前后对比就可以发现,hello.out的.text节明显比hello.o的内容更多。其次,链接器便根据hello.o中.rel.text和.rel.data中的重定位条目,将代码和数据重定位到具体的虚拟地址。
5.6 hello的执行流程
- 启动edb,程序最开始位于共享库的位置,地址为
0x00007f24b5fff880
- 跳转到_start函数,地址为
0x00000000004010f0
- 跳转到_init,地址为
0x0000000000401000
- 跳转到frame_dummy,地址为
0x00000000004011d0
- 跳转到register_tm_clones,地址为
0x0000000000401160
- 跳转到main函数,地址为
00000000004011d6
- 跳转到printf函数,地址为
0x0000000000401040
- 跳转到atoi函数,地址为
0x0000000000401060
5.7 Hello的动态链接分析
对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。
由于edb调试难以找到dl_init,所以调试这部分暂时跳过。
5.8 本章小结
本章介绍了链接的概念和作用,还讲解如何在Linux系统中进行链接。以及分析了hello.out的elf文件格式和虚拟空间地址。还介绍了hello程序的执行过程及动态链接。
第6章 hello进程管理
6.1 进程的概念与作用
进程指一个执行中程序的实例。
作用:它提供两个假象,即好像程序在独占地使用处理器和内存系统。
6.2 简述壳Shell-bash的作用与处理流程
shell是一个交互型的应用级程序,它代表用户运行其他程序。而Bash是shell的一个早期版本,其作用是读取用户输入的每一行指令,并且依照指令调用不同的程序。
处理流程:通过执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行,求值步骤解析命令行,并代表用户运行程序。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创新一个新的运行的子进程。
定义:pid_t fork (void)
;
说明:子进程得到与父进程用户级虚拟地址空间相同的一份副本,并且继承父进程所有打开的文件。但是子进程与父进程的pid不一样,通常位于同一个进程组。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。
定义:int execve (const char *filename, const char *argv[], const char *envp[])
;
说明:execve函数加载并运行可执行目标文件hello.out,且带参数列表argv和环境变量列表envp。而且execve会覆盖当前进程的代码、数据、栈。但是拥有和原进程一样的pid,继承已打开的文件描述符和上下文。并且只调用一次,从不返回,除非出现hello.out找不到等其他错误。
6.5 Hello的进程执行
进程提供了两种抽象:独立的逻辑控制流和私有的地址空间。在shell使用fork创建了hello进程,并用execve将其加载到内存之后,控制权便转交给了用户。而当hello进程执行到sleep函数时(即进行系统调用),则需要进行上下文切换,也就是先保存当前hello进程的上下文,然后将控制转移给内核。在内核模式下执行相应的异常处理程序,然后再将控制权转移给hello进程,此时需要恢复hello的上下文。然后循环往复进行执行。所以说,在整个过程中,hello进程的控制流实际上并不是连续的,而是被分片了,但由于时间极短,所以给人的错觉就是hello进程在不断运行中。
6.6 hello的异常与信号处理
1) 乱输
由于hello进程是前台进程,于是当前输入的所有命令都不会立马执行,只是被输入到了缓冲区中。而只有前台进程终止后,才会去读取缓冲区的内容,并以换行符为标志,作为一条条命令进行执行。最开始随机输入的“niko”之所以没有执行,是因为被getchar()读走了。而后续的随机输入则保留在缓冲区中,等待hello进程终止后,被当作命令行进行执行。
2) Ctrl-C
输入Ctrl-C后,会导致内核发送一个SIGINT
信号到前台进程组中的每个进程,于是hello进程便被终止了。
3) Ctrl-Z
输入Ctrl-Z后,会导致内核发送一个SIGTSTP
信号到前台进程组中的每个进程,于是hello进程便被挂起了。
4) ps
将hello进程挂起后,执行ps命令,将显示当前的进程状态。可以看到hello进程虽然被挂起了,但还没被终止,仍然保留着,而且pid为4484。
5) jobs
将hello进程挂起后,执行jobs命令,将显示当前的作业状态。上图表示此时有一个作业,而该作业处于停止的状态。
6) pstree
使用pstree命令可以看到整个进程的树结构。其中就可以找到gnome-terminal中的bash进程中的子进程:hello.out进程和pstree进程。
7) fg
使用fg指令,将对应序号的作业放到前台运行。如上图,可以看到将序号1的作业hello放到前台运行,此时随机输入命令仍然无法响应。
8) bg
使用bg指令,将对应序号的作业放到后台执行。在此过程中,hello照常输出,同时输入的命令也可以得到响应,并不影响后台作业的运行。对于hello进程来说,循环8次后,还需要getchar才能结束进程。而又因为hello进程是后台作业,所以此时输入的任何字符串并不会被getchar读取,所以hello进程仍然存在。只有当把该后台作业切换到前台时,再输入字符串,才能结束该hello进程。
9) kill
在发送Ctrl-Z将hello进程挂起后,便可以使用kill -18 4686
来发送SIGCONT给hello进程,让其进行运行。但是由于此时hello进程时后台作业,所以想要终止hello进程,还需要使用kill -9 4686
(或者时切换到前台,然后随机输入字符串进行终止)。
6.7本章小结
本章介绍了进程的概念和作用,以及shell时如何调用fork和execve来生成和加载hello进程的。同时,还分析了hello进程的执行过程,以及一些异常和信号时如何处理的。
第7章 hello的存储管理
7.1 hello的存储器地址空间
- 逻辑地址:指由程序产生的段内偏移地址。
- 线性地址:指虚拟地址到物理地址变换的中间层,是处理器可寻址的内存空间(称为线性地址空间)中的地址。程序代码会产生逻辑地址,或者说段中的偏移地址,加上相应段基址就成了一个线性地址。
- 虚拟地址:由程序产生的由段选择符和段内偏移地址组成的地址。也就是hello程序中链接后得到的数据代码地址。
- 物理地址:用于内存芯片级内存单元寻址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
对于一个以“段地址+偏移地址”形式给出的逻辑地址,CPU将会通过其中的16位段选择子定位到GDT/LDT中的段描述符,通过这个段描述符得到段的基址,与段内偏移地址相加得到的64位整数就是线性地址。其中,段的划分(即GDT和LDT都)是由操作系统内核控制的。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟空间被划分成若干页,采用分页机制进行管理。CPU会通过线性地址的高位和内存中的页表去查询其对应的页表条目。然后将线性地址的低位(即偏移量)与所对应的页表条目中所记录的物理页号进行拼接,便能得到物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
CPU采用多级页表来压缩页表的大小,采用TLB来增快访问页表条目的速度。所以当CPU产生一个虚拟地址时,首先会由MMU到TLB中进行查询,若TLB命中,则能直接得到页表条目,于是便能由MMU直接将虚拟地址转换为物理地址;若TLB不命中,则需要先通过VPN1到主存中去寻找一级页表,然后再通过一级页表去寻找二级页表(若二级页表不在主存中,则发生缺页故障,将从磁盘中将其调入),然后由VPN2在二级页表中寻找三级页表的地址,以此类推,直到找到四级页表所对应条目的PPN,最后便完成VA到PA的变换。
7.5 三级Cache支持下的物理内存访问
CPU利用程序局部性原理,采用Cache机制来增快访问主存的速度。而CPU得到物理地址后,会先访问一级Cache,若命中,则直接将相应的数据块调给CPU既可;若不命中,则在访问二级Cache,同理,若仍不命中,则再访问三级Cache,直到全不命中的时候,再去访问主存,并将相应的数据块调入到L1、L2、L3 Cache中。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一 的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct, vm_area_struct和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
Execve函数会先删除已存在的用户区域,然后新程序的代码、数据、bss和栈区域创建新的区域结构。其中,代码和初始化数据映射到.text和.data区(目标文件提供),而.bss和栈映射到匿名文件。另外。还需要映射共享区域。最后,需要设置当前进程上下文的PC,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
当CPU执行某条指令的内存访问时,如果页表中的PTE表明这个地址对应的页不在物理内存中,那么就会引发缺页故障。此时需要进行上下文切换,将控制权传递给缺页处理程序,然后该程序将相应的页从磁盘调入到内存中,处理完毕后,便会返回,进行上下文切换,重新执行当前的指令。
7.9动态存储分配管理
系统通过动态内存分配器来管理内存。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
通过隐式空闲链表,分配器可以通过对于链表的操作以完成在堆上放置已分配的块、分割空闲块、获取额外内存、合并空闲块等操作。于是应用程序就可以动态地在堆上分配额外内存空间了。
7.10本章小结
本章主要介绍了hello的存储地址空间,包括段式管理和页式管理,以及VA到PA的转换过程和Cache的访问流程。还讲述了fork和execve的内存映射,以及CPU是如何处理缺页故障的。最后还简单介绍里动态存储分配管理机制。
结论
hello.c被程序员使用编辑器编写出来,这就是hello的起点。接着经过预处理处理,插入了所有用#include命令指定的文件后,变成了hello.i文件。之后被编译器翻译为汇编语言,变成了hello.s文件。然后,继续被汇编器转化为机器语言,变成hello.o文件。但是此时还不能被执行。因为还需要进行符号解析和重定位(也就是确定代码和数据的地址)。所以还需要经过链接器进行链接,最后才能得到可执行目标文件hello.out。
然后,在bash中,被由操作系统调用fork和execve为hello.out生成一个进程,并把它加载到内存。此时,控制权已经转交给了hello进程,hello进程可以尽情地进行printf,直到hello进程接收到进程终止的信号。然后,被父进程bash回收掉。此刻,hello便结束了自己的一生。
通过学习计算机系统这门课,我对计算机有了一个全新的认识。第一次了解了一个源文件是如何被编译成可执行文件的,没有想到这其间还有如此之多的工序。也是第一次了解一个程序是如何被加载运行的。尤其是学习了进程、作业、上下文等等这些概念,使我对CPU的任务管理有了更加底层的了解。同时,也是第一次学习到计算机的内存管理,包括Cache机制和虚拟内存技术,这一切感觉都是那么的新奇有趣。
总之,非常喜欢这门课,它带给计算机初学者更底层更详细的见解,也会后续课程的进阶打下了坚实基础。
附件
文件名 | 说明 |
---|---|
hello.i | 由预处理器生成的文件 |
hello.s | 由编译器生成的文件 |
hello.o | 由汇编器生成的文件 |
hello.out | 由链接器生成的文件 |
o_ans.txt | Hell.o反汇编生成的文件 |
out_ans.txt | Hello.out反汇编生成的文件 |
参考文献
[1] http://csapp.cs.cmu.edu/
[2] https://zhuanlan.zhihu.com/p/476697014
[3] https://blog.csdn.net/yfldyxl/article/details/81566279