摘 要
本文基于64位的Ubuntu系统,沿着hello程序的P2P(Program to Process)过程,从hello的程序文本,到它的预处理、编译、汇编、链接,到它在进程中运行、结束,剖析计算机系统是如何完成其中的每一步的。通过分析hello程序的“一生”,把计算机系统的知识,包括汇编指令、链接、缓存、进程管理、信号与异常处理、内存管理、系统I/O等,串联起来。
关键词:Ubuntu;P2P;编译;进程管理;系统I/O
目录
第1章 概述
1.1 Hello简介
程序员将程序Program输入hello.c,经由预处理器预处理得到修改了的源程序hello.i,再由编译器编译得到汇编程序hello.s,再由汇编器汇编得到可重定位目标程序hello.o,再由链接器链接得到可执行目标程序hello。
当hello在shell中执行时,它被execve函数加载到内存。hello运行在进程的上下文中,好像自己在独占处理器和内存一样。hello的指令寻址使用逻辑地址,由CPU和MMU处理得到物理地址再寻址。
hello在运行时可能会执行一些系统调用,这是由陷阱这种有意的异常来完成的,此时hello的权限临时提升,控制交给系统内核,完成系统调用后恢复。
hello终止时,进程被父进程shell回收,内核会抹除它所有的痕迹,即所谓的020(zero to zero)。
1.2 环境与工具
Win10下,使用VirtualBox 6.1运行虚拟机Ubuntu 21.10。Ubuntu中使用gcc (Ubuntu 11.2.0-7ubuntu2) 11.2.0,GNU Emacs 27.1,edb,readelf,objdump。
1.3 中间结果
hello.i,由hello.c预处理所得。
hello.s,由hello.i编译所得。
hello.o,由hello.s汇编所得。
hello,由hello.o链接所得。
1.4 本章小结
hello程序从hello.c,到hello.i,hello.s,hello.o,最后到hello,再到作为一个进程执行,就是所谓的P2P(program to process)的过程。hello加载到内存中,运行结束由父进程回收,内核抹除它运行留下的痕迹,这就是所谓的020(zero to zero)。
第2章 预处理
2.1 预处理的概念与作用
C语言程序的预处理是指预处理器把C程序的文本文件修改成可以传递给编译器的文本文件的过程。
具体而言,预处理器对文件中所有#开头的内容进行预处理。符合以下格式的一行内容称为预处理指令[1]:
· # 字符
· 预处理指令(define、undef、include、if、ifdef、ifndef、else、elif、elifdef、elifndef (C23 起)、endif、line、error、pragma 之一,或非标准定义的扩展指令)
· 实参(取决于指令)
· 换行符
允许空指令(跟随换行符的 # ),而它无效果。
预处理器对这些预处理指令做出处理,其作用包括条件编译、文本替换宏、包含其他文件等等。
2.2 在Ubuntu下预处理的命令
cpp hello.c > hello.i 或 gcc -E hello.c -o hello.i
图 1 预处理
2.3 Hello的预处理结果解析
预处理后的hello.i的main函数部分和原本的hello.c是一致的,没有什么变化。
图 2 hello.c与hello.i的main对比
但hello.i的前面多了很多东西。有大量#开头的内容,是给接下来编译用的。有很多hello.c没有的函数,这是包含在原本的#include中的,预处理之后都被放到了同一个文件里。
图 3 预处理后多出的内容
2.4 本章小结
C语言程序的预处理是C语言编译系统的第一步,是指预处理器把C程序的文本文件修改成可以传递给编译器的文本文件的过程。该过程中,预处理器执行#开头的预处理指令,生成一个.i文件。该文件将用于编译器编译。
第3章 编译
3.1 编译的概念与作用
编译器将.i文本文件翻译成.s文本文件的过程叫做编译,生成的.s文本文件包含一个汇编语言程序,可以被汇编器汇编。编译的作用是把C指令翻译为目标平台的汇编指令。
3.2 在Ubuntu下编译的命令
/usr/lib/gcc/x86_64-linux-gnu/11/cc1 hello.i -o hello.s(其中11应替换成机器上gcc相应的版本号)或 gcc -S hello.c -o hello.s,可以向其中加入-Og,-m64等参数。
图 4 用cc1 -Og -m64编译hello.i(左)生成hello.s(右)
3.3 Hello的编译结果解析
3.3.1 常量数据
常量数据存储在rodata(read-only data)段。hello.c中有两个字符串常量,在汇编中对应如下的两个rodata段。
图 5 字符串常量
除此之外的几个小整型字面值常量在汇编中以立即数的形式表示如下。其中argv的下标在汇编中乘以8,因为-m64编译,指针占8个字节。
图 6 小整型字面值常量
3.3.2 变量
hello.c的变量只有int argc, char **argv, int i这三个局部变量。argc和argv是main函数的参数,按照汇编的规则,它们分别存储在寄存器%edi,%rsi(-m64编译,指针占8个字节)中。i是循环变量,循环中调用了其它函数,所以要放在被调用者保存寄存器中,这里存储到%ebp。
3.3.3 赋值运算
只有一个整型赋值。整型赋值在汇编中直接用mov指令完成。由于整型int只有4字节,且movl会把高位4个字节置0,所以用movl即可。
图 7 赋值运算
3.3.4 算术运算
只有一个对int类型的自增运算,汇编中用addl指令实现。
图 8 自增运算
3.3.5 数组访问
设数组a的地址存储在寄存器%r中,数组a中的元素有c个字节,则访问a[n](n是常数)在汇编中表达为n*c(%r)。这里由于n是常数,所以n*c可以在编译期被计算出,在汇编中表示为一个立即数。
图 9 数组访问
3.3.6 比较操作与控制转移
hello.c包含一个if和一个for,分别如下。
图 10 if及其比较操作
图 11 for及其比较操作
可见,if和for在汇编中可以用cmp指令和jmp指令组合实现。稍特别的是,这里的i<8被编译器转换成了i<=7,对应上面的cmpl $7, %ebp。
3.3.7 函数调用
hello.c调用了6个函数,如下。函数调用在汇编中使用call指令完成。
图 12 调用的6个函数
这里发现,第一个printf在汇编中对应的是puts。这可能是因为编译器发现了这个printf的调用只是输出了一个以\n结尾的常量字符串,完全可以用puts代替,且puts的效率更高,所以换成了puts。
传入的参数若是基本类型,则在汇编中依次保存在寄存器%rdi, %rsi, %rdx, ...中。函数的返回值保存在寄存器%rax中。
图 13 每个函数的参数
这里除了atoi,其他函数的返回值都没有被使用。atoi的返回值存储于%eax,然后被移动到%edi作为sleep的参数。
3.3.8 函数返回
函数返回时,除了要把返回值存入%rax中,还要把之前压入栈的被调用者保存寄存器弹出到原本的寄存器中。具体过程如下。
图 14 函数返回
3.4 本章小结
编译器将.i文本文件翻译成.s文本文件的过程叫做编译,生成的.s文本文件包含一个汇编语言程序,可以被汇编器汇编。
编译的作用是把C指令翻译为目标平台的汇编指令。程序中的常量数据存储于rodata段中,或作为立即数;局部变量存于栈中或寄存器中。C语言的各类运算被转化成汇编的各类指令;条件控制和循环控制用汇编中的cmp和jmp组合实现。函数的调用使用call指令,传参和返回值存储于寄存器内。函数返回时,汇编指令需要还原栈和被调用者保存寄存器。
第4章 汇编
4.1 汇编的概念与作用
汇编器从.s汇编文件生成.o机器语言二进制程序的过程称为汇编。汇编的作用是把汇编指令翻译成机器语言指令,把这些指令打包成可重定位目标文件的格式,保存于.o机器语言二进制程序。
4.2 在Ubuntu下汇编的命令
as hello.s -o hello.o 或 gcc -c hello.c -o hello.o
图 15 汇编
4.3 可重定位目标elf格式
hello.o是可重定位目标程序,其ELF格式首先是一个ELF头,描述了系统字的大小、字节顺序、是否采用补码、机器类型、ELF头的大小、节头部表的偏移等等信息。
图 16 ELF头
ELF头之后是各个节(section)。
图 17 各个节的基本信息
根据readelf,hello.o包含14个节。除了第一个大小为0的空节,剩下的13个sections分别如下。
.text节:已编译程序的机器代码。
.rela.text节:对.text节的所有重定位条目。
图 18 .rela.text的8个重定位条目
可以看到,这些重定向条目包括汇编代码中call指令调用的各个函数puts, exit, printf, atoi, sleep, getchar;也包括对两次printf第一个参数的常量字符串的引用.LC0. .LC1。这些引用的最终位置未知,所以需要重定向;除此之外没有需要重定向的。
.data节:已初始化的全局和静态C变量。
.bss节:未初始化或初始化为0的全局和静态C变量。
可以看到,hello.o的.data和.bss的size都是0,因为hello.c中没有全局或静态变量。
.rodata.str1.8节与.rodata.str1.1节:只读的字符串数据。
这里有些特殊,并不是只有一个.rodata节,而是分成了两个。这可能是因为,这两个节的对齐要求不同[2],可以发现.rodata.str1.8的align属性是8,而.rodata.str1.1的则是1。
.comment节:保存关于生成这个ELF的注释,如编译器版本和运行平台[3][4]。
图 19 .comment节的内容
.note.GNU-stack节:一个空节,指示GNU链接器目标文件需要一个可执行栈[4]。
.eh_frame节与.rela.eh_frame:与异常处理相关的节[5]。
.symtab节:一个符号表,存放在程序中定义、引用的函数和全局变量的信息。
图 20 .symtab节的内容
.strtab节:保存与.symtab节相关的名称的字符串[4]。
图 21 .symtab节的内容
.shstrtab节:保存节的名字[4]。
图 22 .shstrtab节的内容
4.4 Hello.o的结果解析
机器语言把汇编指令翻译成字节码,每个指令都有对应的唯一编码,编码后接操作数的对应格式的字节码。根据这样的唯一编码来解析执行指令。但反汇编得到的结果仍与汇编文件稍有不同。
可以发现,汇编文件中很多“.”开头的伪指令,它们是用于指导汇编器和链接器工作的,在反汇编得到的文件中找不到了;反之,反汇编文件中多出了一些非汇编指令,是ELF文件内的重定位条目,这在汇编文件中是没有的。
这些重定位条目用于寻址和函数调用。原本在汇编中的函数调用是call <function_name>的形式,反汇编中却不包含函数名。可以看到反汇编中,call对应的字节码是e8,后面的参数全是0。指示调用的函数的部分在重定位条目中,如下图中圈出的puts。
图 23 hello.s与反汇编的伪指令不同(例)
另外,汇编语言中惯用10进制表示操作数,而反汇编由于是字节码,得到的是用16进制表示的数。
由于已经被翻译成机器指令的字节码,反汇编中的jmp系列跳转的目标不是汇编中.L6等标记,而是具体的字节码中的位置。具体的区别如下图。
图 24 jmp指令的区别
4.5 本章小结
汇编器从.s汇编文件生成.o机器语言二进制程序的过程称为汇编。汇编的作用是把汇编指令翻译成机器语言指令,把这些指令打包成可重定位目标文件的格式,保存于.o机器语言二进制程序。
可重定位目标文件的ELF格式由ELF头、各个节、节头部表组成。这些可以用readelf指令查看。
使用objdump可以反汇编。反汇编得到的文件与原本的.s文件有一定区别,在于伪指令、操作数、函数调用、条件跳转等。
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载(复制)到内存并执行。链接使得分离编译成为可能,我们可以把大型的应用程序分解成更小、更好管理的模块,可以独立修改、编译这些模块。当我们改变这些模块中的一个的时候,只需要简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
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/10/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/10/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
图 25 使用ld链接
5.3 可执行目标文件hello的格式
hello的ELF格式由若干段组成。
图 26 readelf -l hello
用readelf -l显示段的信息,可见有12个段,每个段的起始地址(Offset,VirtAddr,PhysAddr)和大小(FileSiz,MemSiz)均已在表中列出,每个段的节sections在下方列出。
5.4 hello的虚拟地址空间
使用edb加载hello,在data dump中查看本进程的虚拟地址空间各段信息。
图 27 INTERP
按照5.3部分,这里是INTERP的内容,保存了程序解释器的路径名[4]。这是hello运行时需要的动态链接共享库。
图 28 NOTE
这里是两个NOTE的内容。
图 29 .dynstr节
这里是.dynstr节,属于第一个LOAD段。
图 30 .text节
这里是.text节,属于第二个LOAD段。
图 31 .rodata节
这里是.rodata节,属于第三个LOAD段。
其他段几乎没有人可以阅读的信息,故不一一把所有用到的内存截图展示。在edb中查看的内存数据均可以与readelf所得信息对应。其中可阅读的信息(如.rodata)与之前的.rodata也一致。
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
图 32 hello(左)和hello.o(右)的一处不同
上图是两者的一处不同,传送给puts的字符串经过链接后完成了重定位。
我们分析重定位的过程。先用readelf -r hello.o看hello.o的重定位条目。
图 33 readelf -r
上面要分析的那个重定位,根据符号的位置(偏移)可知对应到重定位条目中的第一条,重定位的类型是R_X86_64_PC32,即PC相对引用。偏移r.addend = -4。
在上一节我们已经用edb看到,该符号的位置在.rodata节,地址是0x402008。objdump的注释也符合这个地址。
根据重定位条目,main函数起偏移18的位置(即0x40118e)处的数据应该被重定位的结果替换(refaddr = 0x40118e)。而重定位的结果根据R_X86_64_PC32类型对应的算法,应为ADDR(r.symbol)+r.addend-refaddr=0x402008+(-4)-0x40118e=0xe76。符合hello中的结果。
然后是另一处。是R_X86_64_PLT32类型的重定位。
图 34 puts在hello(左)和hello.o(右)中
它对应重定位条目中的第二条,偏移量为1d的那条。这个重定位使用PLT结合GOT实现的。
图 35 puts的重定位过程
如上图。hello执行call 401030,转移到0x401030处,正是puts在PLT处的条目。第一次执行这里的指令时,*0x2fe2(%rip)对应GOT内的0x401036,于是jmp简单地跳转到下一条指令。下一条指令是push,把puts的ID入栈,然后再jmp到第0条PLT条目,之后由动态链接器完成后续的工作,包括重写*0x2fe2(%rip)对应的GOT数组元素为puts运行时对应的地址,和把控制传递给puts。第二次执行这里的指令时,*0x2fe2(%rip)对应GOT已经变成puts的地址,于是jmp跳转到puts的位置,执行puts的内容。
剩余的重定位过程,和上面展示的两个重定位过程都一致,要么是R_X86_64_PC32类型的,要么是R_X86_64_PLT32类型的。
5.6 hello的执行流程
调用过程如下。
_start 0x401090
__libc_start_main 0x7f1bf07c4000
main 0x401176
puts 0x401030
exit 0x401070
或
_start 0x401090
__libc_start_main 0x7f1bf07c4000
main 0x401176
printf 0x401040
atoi 0x401060
sleep 0x401080
getchar 0x401050
5.7 Hello的动态链接分析
调用_init前,.got部分(0x403ff0)为0,.got.plt的第1和第2项(从0开始)也为0。
图 36 _init前的.got
图 37 _init前的.got.plt
调用之后,.got处的值被动态链接器修改为0x7f1bf07c4000,这就是__libc_start_main的调用地址,.got.plt的第1和第2项也被填充。
图 38 _init后
图 39 _init后的.got.plt
5.8 本章小结
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载(复制)到内存并执行。
ELF格式的可执行目标文件可以通过readelf查看内容,包含若干个段segments,每个段中有若干节sections。
链接器会执行符号解析和重定位两步工作。重定位依赖重定位条目进行。
链接器可以通过PLT和GOT结合生成位置无关代码,这种技术使得我们可以使用动态共享链接库。
第6章 hello进程管理
6.1 进程的概念与作用
进程的定义是一个正在运行的程序的实例。系统中每个程序都运行在某个进程的上下文中,上下文由程序正确运行所需的状态组成,包括程序的代码和数据、栈、通用目的寄存器的内容、程序计数器、环境变量、打开文件描述符的集合。在运行一个程序时,我们会得到一个假象,好像这个程序是系统中唯一运行的程序一样,独占地使用处理器和内存,这样的假象就是进程提供的。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一个命令行解释器,它输出一个提示符,等待输入一个命令行,然后执行这个命令。Bash是shell的一种,全称为Bourne Again Shell。
如果输入的命令行的第一个单词是一个内置的shell命令,那么shell就会执行它。否则shell就会假设这是一个可执行文件的名字,它将创建一个新的子进程,在这个新进程的上下文中调用execve函数加载并运行这个可执行目标文件。若它也不是一个可执行文件的名字,则shell会做出相应的报错提示,如command not found.
6.3 Hello的fork进程创建过程
在shell中,在hello所在的目录下输入./hello(后接所需参数)后,shell判定输入的命令行不是内置命令,此时调用fork函数创建一个shell的子进程。这个子进程与shell几乎完全相同,它具有和shell一致的上下文(相同但独立的用户级虚拟地址空间,包括代码、数据段、堆、共享库以及用户栈),还获得与父进程任何打开文件描述符相同的副本,但是具有和父进程不同的PID。
6.4 Hello的execve过程
shell通过fork创建子进程之后,在子进程中调用execve函数。execve函数具有下面的函数原型[6]。
int execve(const char *pathname, char *const argv[], char *const envp[]);
它执行execve处的程序,在此处就是hello。execve函数会导致当前运行的shell被替换为新的程序hello,并且具有新的初始化栈、堆、与初始化的和未初始化的数据段,然后执行hello。
6.5 Hello的进程执行
hello在进程中执行时,表现得像是独占处理器和内存一样。过程中,系统内核中的调度器对进程进行调度,hello进程可能被抢占暂时挂起,通过上下文切换将控制转移到别的进程。上下文切换首先保存当前进程的上下文,然后恢复某个先前被抢占的进程的上下文,最后将控制传递给这个进程。所以在进程调度中,hello的上下文会被保存完好,等到hello进程继续时,上下文被恢复,好像没有发生中断一样。
hello进程最初是在用户模式下运行,此时不能执行特权指令,如停止处理器,改变模式位,发起I/O操作,访问内核区的数据等。通过诸如中断、故障或陷入系统调用这样的异常,hello进程可以从用户模式进入内核模式,控制传递到异常处理程序。等到异常处理完毕,返回到应用程序代码时,再从内核模式返回用户模式。
6.6 hello的异常与信号处理
hello执行时可能出现4类异常,中断、陷阱、故障、终止。
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果,由中断处理程序处理。
陷阱是有意的异常,是执行一条指令的结果。陷阱最重要的用途是在用户程序和系统内核之间提供一个像过程一样的接口,叫做系统调用。当程序进行exit等系统调用时,就出现了陷阱。
故障由错误情况引起,如缺页异常。故障发生时,控制转移给故障处理程序,如缺页处理程序等。若能修正这个错误,则控制返回到引起错误的指令,重新执行;否则返回到内核的abort例程,终止引起故障的应用。
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误。终止处理程序永远不会将控制返回到应用程序,而是返回到内核的abort例程,终止这个应用。
hello执行时可能遇到如下的几个信号。
1. SIGINT,来自键盘的中断,如Ctrl-c
图 40 Ctrl-c
由于hello中没有SIGINT的handler,所以执行其默认行为,直接终止。
2. SIGTSTP,来自终端的停止信号,如Ctrl-z
图 41 Ctrl-z
由于hello中没有SIGTSTP的handler,所以执行其默认行为,停止直到下一个SIGCONT信号的到来。
此时执行ps和jobs命令的结果如下。
图 42 ps和jobs
执行pstree得到一个巨大的进程树,所以只截取包含hello的一部分。
图 43 pstree
由于我是在bash下sudo emacs,在emacs中打开shell运行hello,所以进程树表示为bash-sudo-emacs-bash-hello.
此时执行kill指令,向挂起的hello发送SIGKILL。
图 44 SIGKILL
或者在挂起(未kill)时,执行命令fg。
图 45 fg
hello就会转到前台继续执行。
在hello执行过程中乱按键盘不影响hello的执行。
图 46 乱按键盘
6.7本章小结
进程的定义是一个正在运行的程序的实例,是计算机科学中最深刻成功的概念之一。系统中每个程序都运行在某个进程的上下文中。
Shell是一个命令行解释器,它输出一个提示符,等待输入一个命令行,然后执行这个命令。Bash是shell的一种。
在shell中,在hello所在的目录下输入./hello(后接所需参数)后,shell判定输入的命令行不是内置命令,此时调用fork函数创建一个shell的子进程,之后在子进程中调用execve函数。当前运行的shell被替换为新的程序hello,并且具有新的初始化栈、堆、与初始化的和未初始化的数据段,然后执行hello。
hello在进程中执行时,表现得像是独占处理器和内存一样,而进程的调度由内核负责。hello进程最初在用户模式下运行,不能执行特权指令;通过异常,hello进程可以从用户模式进入内核模式,控制传递到异常处理程序。等到异常处理完毕,返回到应用程序代码时,再从内核模式返回用户模式。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址是应用程序(即hello)中的指令使用的地址,其形式为segment:offset。
线性地址是CPU根据逻辑地址得到的。当hello执行的指令访问逻辑地址时,CPU根据segment去查描述符表,得到段基址segment base,加上原本的offset,就得到了线性地址。
虚拟地址通常和线性地址是一回事。
物理地址是计算机系统的主存中每个字节的真实地址。每个字节的物理地址都是唯一的。CPU通过内存管理单元MMU,把虚拟地址分成虚拟页号VPN和虚拟页面偏移VPO两部分,根据VPN找到对应的页表条目PTE,得到物理页号,和VPO组合起来,就得到了物理地址。
hello用-m64编译,在64位下运行,此时内存的段式管理实际上只有一个段,段基址和段寄存器总是0[7][8]。所以也几乎可以认为,逻辑地址和线性地址是一样的。
7.2 Intel逻辑地址到线性地址的变换-段式管理
对于采用段式管理的内存,逻辑地址被表达为segment:offset的形式,其中segment是segment selector,即段选择符,存储于段寄存器;offset是段内偏移量。
逻辑地址转换成线性地址时,根据段选择符查全局描述符表GDT或局部描述符表LDT获得段描述符segment descriptor,再根据段描述符获得段基址segment base,与段内偏移量相加就得到线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟地址(线性地址)转换到物理地址的过程是由内存管理单元MMU完成的。虚拟地址分成虚拟页号VPN和虚拟页面偏移VPO两部分,MMU根据VPN找到对应的页表条目PTE,得到物理页号PPN,和VPO组合串联起来,就得到了物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
虚拟地址仍然分成虚拟页号VPN和虚拟页面偏移VPO两部分。然后首先,VPN分为VPNT(tag)和VPNI(index)两部分,用VPNI找到在TLB(translation lookaside buffer,关于PTE的缓存)中对应的组,在这一组中根据VPNT找对应的PTE。
如果找到,即TLB命中,那么根据PTE立刻得到物理页号,然后算出物理地址。
如果未找到,则TLB不命中,则根据多级(此处共有四级)页表来寻找PTE。此时,VPN被按照四级页表的格式分成四部分,VPN1,VPN2,VPN3,VPN4。用VPN1找到1级页表中对应的指向2级页表的表项,然后依次找,直到到最后的4级页表。这中间可能遇到缺页异常,在后面的7.8部分说明。假如没有缺页异常,那么最后就得到了PTE,返回给MMU。MMU如前计算出物理地址。
这里的页表不一定在内存中,也可能在各级高速缓存中。TLB未命中的后续具体行为十分复杂,超出了大作业的范围[9]。
7.5 三级Cache支持下的物理内存访问
在有三级Cache的计算机系统下根据物理地址寻址访问内存时,首先在L1缓存查找是否有对应地址的内容存储在其中,且有效位标记为有效。若有(即缓存命中),则返回这个数据。
若没有(即缓存不命中),则如法炮制,按照L2-L3-内存的顺序向下查找。直到在某一级处命中,则把这个数据作为访问内存的结果,并将其(实际上是连续的若干块,取决于写入的缓存的行有多少块)写入前面的所有缓存。过程中可能发生行的替换,这涉及到缓存自己的替换策略。
7.6 hello进程fork时的内存映射
fork创建子进程时,并不是直接把hello的上下文完全复制一份。而是创建hello的mm_struct、区域结构和页表的原样副本。再将两个进程的每个页都标记为只读,并将每个区域结构标记为私有的写时复制。当其中一个进程试图写某个页时,会触发保护故障,处理程序这时才会创建一个该页面的新副本,给它写权限,然后重新执行引发故障的写指令。
7.7 hello进程execve时的内存映射
在shell(原shell经fork得到的子进程)中用execve执行hello时,进行了以下几个步骤。
· 删除shell进程虚拟地址的用户部分中已存在的区域结构。
· 为hello的代码、数据、bss(hello的bss为空)和栈区域创建新的区域结构,它们是私有的写时复制的。代码和数据区域被映射到hello的.text和.data,堆和栈区域都是请求二进制零的,即映射到匿名文件。
· 标准C库libc.so等共享对象动态链接到hello,再映射到用户虚拟地址空间中的共享区域内。
· 设置程序计数器PC为hello的入口点。
7.8 缺页故障与缺页中断处理
当MMU试图将某个虚拟地址翻译为物理地址时,若地址对应的对象不在内存中,则发生了缺页故障。若发生了缺页故障,则控制转移到内核的缺页处理程序。
处理程序首先判断该虚拟地址是否合法,即,是否在某个区域结构定义的区域内。这会和每个区域结构的vm_start和vm_end比较以确定。若都不在,则不合法,触发一个段错误,终止进程。
若合法,则判断它的内存访问是否合法,即,这个读/写是否有相应的权限。假如没有,则触发一个保护异常,通常会终止这个进程。
若有权限,那么这个操作是合法的,此时内核会处理缺页。它选择一个牺牲页面准备替换。若这个牺牲页面被修改过,则执行写回,把它换出去。然后换入新的页面并更新页表。
之后处理程序返回,CPU重新执行引起缺页的指令。这时要访问的页已经在内存中,不会缺页了。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显示地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显示地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显示执行的。要么是内存分配器自身隐式执行的。
分配器有两种基本风格,两种风格都要求应用显示地分配块。他们的不同之处在于由哪个实体来负责释放已分配的块,所以分别称之为显示分配器和隐式分配器。
其中,显示分配器要求应用显示的释放任何已分配的块;而隐式分配器检测到一个已分配块不再被程序使用时就释放这个块。隐式分配器也叫垃圾收集器。
C提供了称为malloc程序包的显示分配器,printf就会调用malloc。malloc返回一个指针,指向大小至少为size(参数)的内存块。若malloc遇到问题(如内存不够),则返回NULL,并设置errno。malloc不会初始化它返回的内存;若想要初始化,则可以使用calloc。想要改变分配的块的大小,可以用realloc。
分配器可以使用隐式空闲链表这样的数据结构,区别块的边界、区别已分配块和空闲块。优点是简单,缺点是操作的开销大。
7.10本章小结
地址有逻辑地址、虚拟地址(线性地址)、物理地址这些概念,在程序访问内存时会依次翻译。
从逻辑地址翻译到虚拟地址涉及到内存的段式管理,根据段选择符与段内偏移量得到虚拟地址。
从虚拟地址翻译到物理地址涉及到内存的页式管理,由MMU完成。这里可能引发缺页故障,由缺页处理程序处理。MMU的翻译过程可以被TLB利用局部性加快。
为了防止页表占据过多内存,通常采用多级页表的方式节省不必要的页表。
程序在fork、execve等时,通常采取写时复制的技术,尽量延缓复制的进行,避免不必要的复制。
动态内存常用动态内存分配器维护。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有I/O设备,例如网络、磁盘、终端,都被模型化为文件,所有的输入和输出都被当做对文件的读写来进行。
这种模型化允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
Unix I/O接口如下:
· 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
· Linux Shell创建的每个进程都有三个打开的文件:标准输入(描述符为0),标准输出(描述符为1),标准错误(描述符为2)。头文件unistd.h定义了常量STDIN_FILENO,STDOUT_FILENO,STDERR_FILENO,可以用来代替显式的描述符值。
· 改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek操作,显式地将改变当前文件位置k。
· 读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k>=m时,触发end-of-file(EOF)条件,可以被应用程序检测到。类似,一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
· 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O函数部分如下:
· open
函数原型
int open(char* filename,int flags,mode_t mode);
进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件,包括只读、只写、可读可写,或与其他掩码取或以提供更多提示。mode参数指定了新文件的访问权限位。
· close
函数原型
int close(int fd);
关闭一个打开的文件
· read
函数原型
ssize_t read(int fd, void *buf, size_t n);
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF。否则,返回值表示的是实际传送的字节数量。
· write
函数原型
ssize_t wirte(int fd, const void *buf, size_t n);
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
printf的一个实现是这样的[10]。
int printf(const char *fmt, ...) {
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
其中va_list被typedef为char*,代码第4行的arg指向变参的第一个字节,变参中是printf格式化输出的参数。然后把arg作为参数传到下面的vsprintf。
int vsprintf(char *buf, const char *fmt, va_list args) {
char* p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) {
if (*fmt != '%') {
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case 'x':
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
vsprintf进行格式化输出(到字符串buf),把所有的%x和%s替换成后面的参数,最后返回格式化后的字符串的长度。
然后回到printf函数,调用write把得到的字符串输出。write中进行了一个system call,用陷阱的方式有意地陷入异常,把控制交给系统内核。
最后字符的显示依赖于字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
然而,在hello运行的64位Ubuntu系统上,printf并不是这样的实现。上述的printf实现是32位的。
在glibc 2.34中[11],printf的实现并不是采用vsprintf+write,而是直接地vfprintf到stdout中,vfprintf又调用outstring,outstring又调用__IO_sputn,等等,最后应当仍然归于write。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar的一个实现如下[12]。
int getchar (void) {
int result;
if (!_IO_need_lock (stdin))
return _IO_getc_unlocked (stdin);
_IO_acquire_lock (stdin);
result = _IO_getc_unlocked (stdin);
_IO_release_lock (stdin);
return result;
}
而其中[13]
#define _IO_getc_unlocked(_fp) __getc_unlocked_body (_fp)
又有[14]
#define __getc_unlocked_body(_fp) \
(__glibc_unlikely ((_fp)->_IO_read_ptr >= (_fp)->_IO_read_end) \
? __uflow (_fp) : *(unsigned char *) (_fp)->_IO_read_ptr++)
这里就是说,如果read缓冲区里不为空,则直接返回缓冲区内的字符,否则调用__uflow。
而__uflow经过一系列调用会到达_IO_file_underflow(),这里会调用read,读取用户输入的一行字符串至缓冲区,再返回缓冲区的第一个字符[15]。
8.5本章小结
Linux中,所有的I/O被模型化为文件,所以I/O操作实际上是文件的读写操作。这允许Linux内核引出一个简单低级的应用接口——Unix I/O。
Unix I/O提供了一系列函数,如read,write等。较高级别的I/O函数,如printf,scanf等,是通过Unix I/O函数实现的。
结论
hello首先被编写成C语言文本程序hello.c,经过预处理器的预处理得到文本程序hello.i,再经过编译器的编译得到汇编语言文本程序hello.s,再由汇编器汇编得到可重定位目标程序hello.o,再由链接器链接所需的库得到hello这个可执行目标程序。
当hello在shell中执行时,它被execve函数加载到内存。这个过程并没有真的把hello的代码、数据等复制到内存中,而是采用虚拟内存技术,执行按需页面调度的策略,待访问时再复制。
hello运行在进程的上下文中,好像自己在独占处理器和内存一样。实际上计算机系统同时运行很多个进程,这是由内核进行调度的。hello进程可能会被其它进程抢占,这时要保存hello的上下文,等到以后hello进程恢复时,把上下文复原。hello进程可能接收到各种信号,如键盘输入的Ctrl-z和Ctrl-c等带来的SIGTSTP和STGINT等,从而被挂起停止或终止。
hello运行时调用的printf参与了系统I/O。Linux中所有的I/O被模型化为文件,因此本质仍然是文件读写。
hello在运行时可能会执行一些系统调用,这是由陷阱这种有意的异常来完成的,此时hello的权限临时提升,控制交给系统内核,完成系统调用后恢复。
hello终止时,进程被父进程shell回收,内核会抹除它所有的痕迹。
附件
hello.i,由hello.c预处理所得。
hello.s,由hello.i编译所得。
hello.o,由hello.s汇编所得。
hello,由hello.o链接所得。
参考文献
[1] C语言预处理器. https://zh.cppreference.com/w/c/preprocessor
[2] what does `.rodata.str1.8` section mean in elf file. https://stackoverflow.com/a/47346848
[3] What is the significance of ".comment" section in ELF?. https://www.quora.com/What-is-the-significance-of-comment-section-in-ELF/answer/Anirban-Ghoshal-1
[4] elf(5) -- Linux manual page. https://man7.org/linux/man-pages/man5/elf.5.html
[5] Chapter 8. Exception Frames. https://refspecs.linuxfoundation.org/LSB_3.0.0/LSB-PDA/LSB-PDA/ehframechpt.html
[6] execve(2) - https://man7.org/linux/man-pages/man2/execve.2.html
[7] Linux 线性地址,逻辑地址和虚拟地址的关系? - Hao Lee的回答 - 知乎
https://www.zhihu.com/question/29918252/answer/163114415
[8] x86内存分段 - https://zh.wikipedia.org/zh-cn/X86%E8%A8%98%E6%86%B6%E9%AB%94%E5%8D%80%E6%AE%B5
[9] What happens after a L2 TLB miss? - https://stackoverflow.com/a/32258855
[10] [转]printf 函数实现的深入剖析 - https://www.cnblogs.com/pianist/p/3315801.html
[11] printf.c - https://sourceware.org/git/?p=glibc.git;a=blob;f=stdio-common/printf.c;h=1a98662f9321fae6b04735b99e06a89bef931d51;hb=refs/heads/release/2.34/master
[12] getchar.c - https://sourceware.org/git/?p=glibc.git;a=blob;f=libio/getchar.c;h=0d6225853fda2d26c25461f785bd4b78cbdb3101;hb=refs/heads/release/2.34/master
[13] libio.h - https://sourceware.org/git/?p=glibc.git;a=blob;f=libio/libio.h;h=cebdc6576356e87b4b0a7ab256ac8e680b545544;hb=refs/heads/release/2.34/master
[14] struct_FILE.h - https://sourceware.org/git/?p=glibc.git;a=blob;f=libio/bits/types/struct_FILE.h;h=ff8aef58d1f05879dea658e7972e747f4e1e122f;hb=refs/heads/release/2.34/master
[15] scanf源码分析 - https://blog.csdn.net/chennbnbnb/article/details/109601710