开始之前,我先要告诉你的是,无论你打开这篇文章的目的为何,我都希望你可以认真看完,因为当你真正了解了程序从何产生,又向何处去的时候,计算机系统对于你而言已经不在话下。如果你是0基础的小白,在读这篇文章会有一定的压力,希望可以收藏起来,先了解一下linux的基本指令和一些基础的汇编语言后在阅读会有一定帮助。最后再次说明本文为个人发布,一切解释权归本人所有。
哈尔滨工业大学计算机科学与技术学院
2023年12月19日
本文通过对hello.c程序的详细分析,从程序的预处理,编译,汇编,链接,存储,执行,IO输入输出等共计8个方面详细分析了程序从编写到执行的每个步骤。
一份代码文件从编写好后,会首先经过cpp/ccl/as/ld四个步骤,最终生成一份可执行文件,之后操作系统为其创建进程,分配虚拟内存,建立到磁盘的映射,之后开始执行,执行过程中可能会触发异常控制流,从而引发异常服务例程,并改变进程的信号;可能会使用不同IO接口,使用UNIX,RIO,STDIO的IO管理函数对不同控制流管理。最后将结果打印到控制台中,程序执行结束后,此时操作系统会使父进程回收僵尸子进程,结束hello的一生。
关键词:预处理,编译,汇编,链接,虚拟内存,进程,IO,异常等
目 录
2.2在Ubuntu下预处理的命令............................................................................. - 5 -
5.3 可执行目标文件hello的格式..................................................................... - 19 -
6.2 简述壳Shell-bash的作用与处理流程........................................................ - 24 -
6.3 Hello的fork进程创建过程........................................................................ - 25 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................... - 28 -
7.3 Hello的线性地址到物理地址的变换-页式管理......................................... - 28 -
7.4 TLB与四级页表支持下的VA到PA的变换................................................. - 28-
7.5 三级Cache支持下的物理内存访问............................................................. - 29 -
7.6 hello进程fork时的内存映射..................................................................... - 30 -
7.7 hello进程execve时的内存映射.................................................................. -30 -
7.8 缺页故障与缺页中断处理.............................................................................. - 30 -
8.2 简述Unix IO接口及其函数.......................................................................... - 32 -
第1章 概述
1.1 Hello简介
编译源文件的过程中,gcc通过调用cpp/cc1/as/ld,将C语言源文件进行预处理、编译、汇编、链接,最终形成可执行目标文件hello,由存储器保存在磁盘中。运行进程时,操作系统为其分配虚拟地址空间,随着一连串的缺页故障,hello被逐渐地载入物理内存。操作系统提供异常控制流等强大的工具,不断对系统中运行着的进程进行调度。Unix I/O为其提供与程序员和系统文件交互的方式,让它不再孤单。当程序从main中返回,意味着程序的终止。之后,shell作为其父进程会负责将其回收,操作系统内核删除相关数据结构,释放其占据的资源,hello的一生就此结束。
1.2 环境与工具
开发环境:使用linux(ubantu)系统,windows11系统
调试工具:Visual Studio,objdump反汇编,edb调试
1.3 中间结果
hello.i:使用cpp hello.c > hello.i 使用该命令执行预处理命令并生成预处理文件
hello.s:使用gcc命令只编译不汇编和链接,生成一个编译文本文件
hello.o:使用as命令生成的可重定向文件
hello.exe:经由链接得到的可执行文件
hello_txt:使用objdump获得hello.exe的反汇编文件(elf格式)
1.4 本章小结
本章对hello的一生进行了简要的介绍和描述,介绍了P2P的整个过程,介绍了本计算机的硬件环境、软件环境、开发工具,介绍了为编写本论文的中间文件的名称和其作用。
第2章 预处理
2.1 预处理的概念与作用
1、预处理概念:预处理是程序成为可执行文件的第一步,其主要目的是将源文件中包含的所有”include”的文件(如,studio.h,stdlib.h,string.h等)添加到预处理文件。同时,预处理还会对源程序的定义的宏重新编排,并删除源文件中的注释。预处理文件以.i为后缀,是一份文本文件
2、预处理作用:在预处理概念中我们给出了预处理的3个主要用处,其中
- 添加所有’include’的文件,便于后续链接;
- 重新定义源程序的宏便于提高代码的可读性;
- 删除源文件中注释便于减少代码大小;
2.2在Ubuntu下预处理的命令
Ubantu下gcc将所有指令进行了集合,但我们可以使用cpp(预处理器指令)单独命令预处理器进行处理,使用
cpp hello.c > hello.i
得到预处理的文本文件。
2.3 Hello的预处理结果解析
下面对上面获得的hello.i文件解析,看一下是否执行了2.1中的预处理步骤,完整文件见附录中。
hello.i文件如下(部分):
首先,开始部分可以看到的是预处理对一堆.h文件进行处理,并伴有对应的代码在后面,这里简单介绍一下,以下面为例介绍
#1 “/usr/include/stdio.h” 1 3 4
首先#1表示正在处理stdio.h文件的第1行,后面的1 3 4是处理中对应的状态信息,cpp记录下这些信息方便以后调试或者发现错误。
这个就可以看出,预处理器先是处理了types.h文件并将该文件导入,就是下面这一堆的typedef文件,随意通过上述介绍可以看出预处理器首先完成了对头文件的导入。而我们写的main文件则是在最后面(3065-3072),下面的图片可以看到其删除了原先保留的注释。
2.4 本章小结
通过上述分析,我们可以得到,预处理其会将导入的头文件的代码与源文件做拼接,同时重新编排宏定义并删除原有的注释,使程序便于后续链接等工作的实现
第3章 编译
3.1 编译的概念与作用
1、编译的概念:编译位于预处理的下一步,编译往往会将预处理的.i文件中的c语言翻译为汇编语言,这里汇编语言的格式和OS有关,linux下是AT&T格式,Windows下是intel格式,所以同样的文件经由编译器之后得到的编译文件不同。编译文件以.s结尾,是一份文本文件
2、编译的作用:我们的程序最后是要在机器(电脑)上面运行的,c语言这些高级语言是为了让我们编写出易懂但又不太困难转化为机器语言的程序,而如果直接编写汇编机器语言,就过于复杂了,所以我们将这部分转化工作交由编译器处理。
3.2 在Ubuntu下编译的命令
为了得到编译结果,使用下面这个指令:
gcc -S hello.i -o hello.s
这里特别注意,必须是-S,不可以小写,对于gcc命令而言,一旦和其定义的命令不同,gcc都会将其视为执行一套完整的预处理编译汇编链接程序知道生成一个可执行程序。
3.3 Hello的编译结果解析
下面我们对hello.s编译文件进行分析,主要看一下编译器是如何将原有的c语言程序翻译为汇编语言
- 首先是这个rodata节
这个节中存储的是hello.c中定义的只读数据,包括字符串等。在hello.c中出现了
printf("用法: Hello 学号 姓名 秒数!\n");printf("Hello %s %s\n",argv[1],argv[2]);这两个字符串,所以第一个便是保存的第一个字符串,当然一连串的数字就是汉字的排版。关于汉字在计算机中的存储详见附录;
除了字符串之外,.rodata还存除了一个数字8,8这个数字在源程序中作为循环的终止条件,应该是不会变的,故计算机将8设置为只读数据
- 代码区.text
首先,在对main函数分析前,我们先对程序执行的过程简单回顾一下,操作系统为每个程序的执行维护一个独立的进程,对于任何一个新的执行程序,操作系统会重新创建一个子进程(fork),并执行对应的程序(execuve),而创建的子进程会和父进程几乎一样(除了pid和虚拟地址空间不同),此时在程序执行之前其实操作系统已经为其准备好了堆栈,寄存器等上下文信息,而execute函数会调用加载器,加载器根据映射关系将程序从磁盘中取出并复制到内存中。程序运行时,任何一个函数的调用(包含main函数)都是利用栈,调用函数前,将函数栈帧保护,同时分配函数调用空间,之后保护现场(将函数调用需要使用的寄存器入栈),参数入栈,之后调用结束后依次出栈即可。
这就是上述过程的汇编代码。
程序启动后,BIU单元取指令,向将rbp入栈,后分配rsp,给函数分配空间。之后将参数(rdi,rsi)入栈,对于main函数而言,参数就是argc和argv了,而这里可以看到rbp的空间是32个字节,所以必然是数组规模是4。
- 之后源程序中首先比较了参数个数是否为4,
对应在汇编语言中则是对应为一个条件跳转,
如果相等则继续执行L2部分代码,否则继续向下执行
继续向下执行则会调用puts函数打印出对应的字符串,之后调用exit终止进程
- 如果是4个参数则,源程序则会按照输入的参数给定程序休眠时间,进入一个循环
对应在汇编代码中则是进入一个条件跳转判断i和7的大小关系,
这里先给i赋值为0,并跳转到L3
L3首先判断i和7的大小,如果比7小说明循环没有结束,则跳转的L4执行循环,如果执行完循环时,则退出程序即可
下面重点看一下L4,循环内部的内容
首先先来看第一个printf语句的汇编代码:
从c语言中我们知道这条printf语句需要3个参数,一个是字符串,一个是argv[1],一个是argv[2],而按照linux下参数传递的规则,6个参数以内用寄存器传递,况且这3个参数均是字符串,保存的是地址,故需要使用内存取值格式。可以使用寄存器传递,而在前面main函数调用时argv的地址是-32(%rbp),这里4个参数的地址分别加8即可得到,所以第一个参数hello的字符串,选用
而第二个参数,argv[1],则是-32(%rbp)+8即可
第三个参数,argv[2],则是32(%rbp)+16即
之后要让进程挂起,调用sleep函数,但sleep的时间是argv[3]故需要取出并将字符串换为整数,调用atoi函数,之后,将%rax中的返回值传递给%rdi作为参数调用sleep函数挂起进程
3.4 本章小结
本章介绍了编译的概念以及过程。通过hello函数分析了c语言如何转换成为汇编代码。介绍了汇编代码如何实现变量、常量、传递参数以及分支和循环。
第4章 汇编
4.1 汇编的概念与作用
1、汇编的概念:
在预处理将头文件加入后,编译翻译为汇编语言文件后,此时我们获得了以机器语言编程的程序,但还有一点之前的所有文件均为文本文件,而机器只能看懂二进制文件,所以汇编的任务就是将汇编语言的文本文件翻译为elf格式的二进制文件,供机器使用。这里的二进制文件就是可重定向文件
- 汇编的作用:
根据汇编的概念,汇编将由编译产生的汇编语言的文本文件翻译为二进制文件。在产生二进制文件时,汇编器将按照elf格式生成,这里将会生成一个重要的符号表用于接下来的链接步骤。
4.2 在Ubuntu下汇编的命令
Ubantu下可以使用as指令让虚拟机执行汇编命令:
as hello.s -o hello.o
在使用objdump对hello.o进行反汇编到hello文件中
4.3 可重定位目标elf格式
下面分析一下这个hello.o的elf文件中格式:
首先先回顾一下elf格式,一般的elf格式有以下内容:文件头(记载机器文件的状态信息),.rodata(只读节),.data(已定义的数据),.bss(未定义的数据或定义为0的数据),.text(代码节),.symbol(符号表,有汇编器产生,主要记载所有的重定向函数,全局变量),.reltext/.reldata(需要重定向的代码和数据节)
使用readelf -a hello.o读取hello.o文件中的内容:
首先是elf头,如下,主要包含一些机器和程序的状态信息
之后是节头的定义地址,主要有.text;.relatext;.data;.bss;.rodata;.symtab等定义了每个节的大小和节头地址
下面是重定向代码节,我们说过所有的函数调用全部都要重定向,所以这个节就是将所有需要重定向的代码放入,有如下函数需要调用,注意这里的函数既包括自定义的函数,又包括库函数。这里的偏移量是指的到.relatext头的偏移量
下面是符号表.symtab,记录了所有需要重定向的内容,包括全局变量和函数
4.4 Hello.o的结果解析
4.3对hello.o的文件有了初步了解,接下来我们对hello.o进行反汇编看看里面具体有什么,使用objdump -d -r hello.o >hello将反汇编得到的内容放入到hello文件中。(简单说明一下这个指令的含义,-d是disassemable反汇编,-r是只反汇编main函数,如果你想要看看所有内容可以直接-D就行)
接下来我们看看hello文件中的main函数和第三章编译出来的main函数有什么不同,可以看到基本上汇编语言没有什么变化,除了一个地方就是编译时对函数的调用直接使用call就ok,这里面则什么都没有,只是将调用的函数写出来。这样写的原因是因为汇编器对于前面符号表中定义的符号真实地址是不知道的,而编译器只是负责将其翻译为汇编语言,而到底每个调用的函数在哪,每个用的全局变量到底是在那个文件中定义的,这些写入符号表中交由链接器,而此时汇编器只能暂时将使用这些符号的地方占用空出来。
4.5 本章小结
本章介绍了汇编。经过汇编器,汇编语言转化为机器语言,hello.s文件转化为hello.o可重定位目标文件。我们研究了可重定位目标文件elf格式,接触了了readelf命令、elf头、节头部表、重定位节、符号表。我们对比hello.s和hello.o,分析了汇编语言到机器语言的变化。现在,hello.c已经成为了能够被CPU理解的指令,距离能够在程序中运行只差一步之遥了!
第5章 链接
5.1 链接的概念与作用
1、链接的概念:经由汇编之后得到的二进制文件还差最后一步,即对于定义的符号的重定向,这里的符号经由汇编器定义好后会放在.symtab节中供链接器链接。链接主要分为两步:符号解析和重定向
2、链接的作用:将符号解析并重定向地址,保证程序可以正常执行
5.2 在Ubuntu下链接的命令
Ubantu下链接器为ld,可以使用链接器将可重定向文件链接为可执行文件
使用如下指令:
ld -o hello -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 hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o生成hello的可执行文件
5.3 可执行目标文件hello的格式
和第四章一样,因为汇编器最后导出的结果是一个elf格式的二进制文件,所以链接后的文件格式仍为二进制。使用命令readelf -a hello看一下hello的elf文件格式
首先上来还是一个elf文件头(如上图);
接下来是一个各个节头地址和大小的一个表格,和第四章对比可以发现新增了很多其他的表头我们对比分析一下:
新增表头如下:
.interp:这个表头主要用于动态链接中使用,动态链接是一个现代的链接技术,会在需要链接的地点放置一个.interp节点,当程序运行到这里的时候,进程会挂起并启动动态链接器在动态链接库中寻找是否有对应的符号定义,如果有,则加入到程序中
.init:在 ELF 格式中,.init 节是一个特殊的节,用于存储程序的初始化代码。这些代码在程序启动时被执行,用于执行全局变量的初始化、动态链接器的初始化等操作。
.plt:动态链接节,这个节掌管着动态链接库的跳转,当需要动态链接时,启动动态链接器并找到对应符号定义的代码并将这些符号定义代码存储到.plt节中,这样后面运行程序还调用前面解析过的符号时,就可以直接从plt节中调用
5.4 hello的虚拟地址空间
接下来我们具体看一下hello链接后的elf文件,仍然是使用objdump -D hello >hello_txt,打开hello_txt文件;
.interp节:
.init节:
.plt节:
这里其实可以看到本身plt节中记录了后面调用的代码,这样如果后面调用了这些符号则不需要再链接了,直接从.plt中取即可
5.5 链接的重定位过程分析
对于链接器而言,链接分为2部分符号解析和重定向,具体而言重定向又分为两个部分,符号定义重定向和符合引用重定向。接下来我们一个一个看:
5.5.1链接的符号解析
从第四章我们可以知道,汇编器产生的elf文件同时产生了一个.symtab表(符号表),记录了所有汇编器无法解析具体地址的符号,有所有的函数调用和全局符号定义。
到链接器后,链接器会分为两个部分,一个是未确定的符号引用D,一个是已确定的符号引用R。对于ld后面所有的文件,如果有未确定的符号引用,则加入到D,反之,如果有符号定义可以解析其他符号引用,则从D中取出放入R,如此循环往复,直到所有链接全部文件。如果此时,D中仍有为解析的符号,说明链接错误,反之执行重定向。
5.5.2链接的重定向
对于符号解析后的重定向,其主要依赖于重定向条目。重定向条目是对于已有解析的符号,链接器会产生对应的重定向条目,链接器会依照这个重定向条目将符号的地址重新定位,具体而言,代码的重定向条目放在.relatext,数据的重定向条目放在.reladata中。条目中有2个主要内容:符号到节头的偏移量(确定具体位置),还有操作数偏移量(这个主要对应操作数),这里
操作数=符号定义地址-符号引用地址+操作数偏移量
符号地址=节头地址+节偏移量
当链接器将所有的重定向条目全部重定向后,链接完成
5.6 hello的执行流程
加载器在fork创建子进程后,将原有子进程中的父进程的内容删除后,按照虚拟内存到物理内存的映射将虚拟页一一映射到物理磁盘中,之后跳转到.start节,并将pc寄存器指向.start节开始执行程序。需要注意的是,此时物理内存中尚未有物理页,必须在发生冷不命中时才会执行缺页中断服务例程,此时OS会将对应的物理页副本复制到内存中,之后继续执行。关于执行过程中调用的函数和代码如下:
.init
.main
.401090 <puts@plt>
.4010a0 <printf@plt>
.4010c0 <atoi@plt>
.4010e0 <sleep@plt>
.4010b0 <getchar@plt>
5.7 Hello的动态链接分析
由于才最初汇编时,汇编器无法确定动态链接函数的具体地址,而对此,链接器采取的是延迟链接的目的。将所有的相同的动态链接全部推迟到第一次调用时链接,这样第一次链接成功后后续对于相同的链接则不再需要重新链接。
5.8 本章小结
本章主要研究了链接过程的内容,其中重点研究了链接后的hello文件与hello.o文件的区别。利用链接器,可以做到文件的分离编译。经过链接,已经得到了一个可执行文件,接下来只需要在shell 中调用命令就可以为这一文件创建进程并执行该文件。
第6章 hello进程管理
6.1 进程的概念与作用
1、进程概念:一个程序执行一次便会创建一个子进程。这里需要和shell中的内置指令区分开,对于shell(bash)而言,内置指令属于内部代码,不是其他程序代码。因而无需创建一个新的进程
2、进程作用:进程是基于虚拟内存存在的,进程的存在使得每个程序的执行好像是单独占用cpu和内存,
- 进程的存在使得程序的执行独立化,即任何程序的执行不会影响到其他程序的执行。
- 进程的存在使得程序的执行规范化和标准化,进程为程序规定了不同节,段的首地址,这样使用程序减少了地址规范
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 cpu和内核
首先我们必须先了解一下什么事cpu,什么又是内核。
cpu作为计算机的核心硬件,负责执行计算机中所有程序,指令,并管理计算机的所有硬件系统,按硬件种类分为cpu,寄存器文件,总线接口,功能单元。按功能分为2块,EU(指令执行单元),BIU(总线接口单元),顾名思义,BIU负责通过系统总线获取指令并将指令传输到指令队列交由EU译码执行。
而内核作为操作系统,或者软件级别的核心程序,其往往存储在物理内存的高地址处,其任务在于维护操作系统和一系列与硬件交互的系统级程序。借由内核我们可以轻松控制任何硬件,但这样显然有些不合理,对于intel和linux而言用户可能会随意操控内核导致硬件受损,所以就出现了shell用于代表内核和用户的桥梁
6.2.2 shell和bash简述
6.2.1叙述了shell的地位和目的,即作为和内核交互的程序,方便用户可以借由shell控制硬件,shell允许内部指令和外部指令使用,外部指令的允许使得shell的指令集更加丰富。
Bash则是shell的一个实际实现结果,,bash是基于GPU的shell程序,bash添加了shell图像化的功能,从而出现了我们现在开机有的图像。
6.2.3 shell的处理流程
当我们进行操作时或者输入指令时,shell会先检查其指令是否是内部指令,如果是则直接执行,如果不是则会在path中检查是否是外部程序。如果有,则执行对应的指令,否则就会报错command not found。接下来就会将指令交由cpu执行。
6.3 Hello的fork进程创建过程
6.3.1 fork创建进程简述
执行一个程序,如果该程序不是内部的,则内核会先调用fork函数创建子进程。这个子进程基于父进程,除了进程PID和进程空间独立之外,剩下所有的内容全部一样,包括代码段,数据段,内核,页表,寄存器内容等,并且在创建了进程后不一定立刻执行该子进程,具体是否执行该子进程取决于cpu对于父进程设置的时间片。
上图,首先我们使用ps指令获得当前的进程标号,此时没有hello,之后执行hello并在执行中使用ctrl-z挂起进程,让后ps此时发现创建了一个新进程hello
6.4 Hello的execve过程
首先在6.3的进程创建完成后一半就会执行execve执行该程序,那么这个系统函数到底干了什么?
- 清空子进程除了内核的所有私有化区域,因为6.3讨论了fork创建的子进程和父进程几乎一模一样,故在添加子进程之前必须清理掉父进程的内容
- 设置虚拟内存和物理磁盘的映射,这里的映射主要是设置虚拟内存为每个进程保留的一个区域的结构体链表,加载器会设置这个链表,将其中一些限制量(包括读取权限,私有权限,写时复制等),这里要注意,程序仍然在磁盘上还没有进入物理内存,只有到之后执行程序时发生缺页中断后才会将程序映射,复制到内存
- 之后加载器设定pc会跳转到.start处,开始取指令,译码,执行指令
6.5 Hello的进程执行
这里经过了fork函数和execve函数调用后,程序已经做好了执行的准备,此时pc指针寄存器指向start处的指令开始执行代码,首先pc传回一个虚拟地址,经由MMU转换为物理地址,会触发冷不命中,缺页中断,之后加载器将缺的页加载到内存,高速缓存,继续重新执行原指令。
6.6 hello的异常与信号处理
接下来,我们仔细分析一下hello程序在运行时所有可能的异常和信号处理。
首先,加载器进入.start节,此时pc寄存器取出指令地址交由MMU译码为物理地址,之后cpu前往cache和内存中一级一级寻找,此时如果没有,此时并不会立刻引发中断,而是首先将改写进程的信号位,为其传送缺页中断信号。此时当cpu再次执行该进程时,则会根据信号执行对应的中断服务例程,将缺少的内容从磁盘中复制到物理内存中(当然如果物理内存满了,则还要触发驱逐机制,向内核发送驱逐信号,并执行驱逐程序)。
其次,当如果在程序执行的时候,在键盘中随意敲打任何指令,你会发现有些指令可以执行,有些不可以,这是为什么呢?按照我们对于中断的理解,任何对于硬件上直接触发的事件都会引发异常控制流,从而引发中断。这个和bash的规定有关,bash将计算机上运行的进程分为前台作业和后台作业,我们一般可以在terminal上看见的只是前台进程,bash允许同时在前台运行一个进程组,所以你的可以执行的命令其实是bash的内部指令,内部指令的执行不需要重新创建进程,而对于其他指令作为外部指令,要执行他们就如同我们前面介绍的那样,必须创建新的进程,此时就不属于同一个进程组了(要判断是否是一个进程组可以看其ppid号),所以会被阻塞无法执行,如果一定要执行,就必须让正在执行的进程进入后台(sleep &)或者让执行的指令在后台执行。
这里要求我们对于cpu和操作系统有一定的初步了解,现代计算机为多核cpu的同时,cpu采用超线程技术,对于一个单独的cpu可以并发的执行多个线程,同时对于多个cpu核,既可以并发的执行线程,也可以并行的(同时的)执行不同线程,操作系统为cpu分配进程,cpu执行线程任务。每个线程保有唯一的tpid标识符,进程作为线程组保有唯一的线程组ttpid标识符。这里的验证很简单,可以通过cpu-z看到电脑的cpu核数和线程数,同时可以打开任务管理器看到现在正在执行的进程和线程。
下面是一些指令执行后的结果:
pstree:导出进程数
ps:查看进程;kill:杀死进程;jobs查看所有前台作业
6.7本章小结
本章介绍了进程的概念与作用,分析了hello程序的进程由shell创建,执行,通过异常控制流调度到结束回收等一系列过程。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:任何在虚拟内存中的地址均为逻辑地址,作为到段地址的偏移,前面介绍了虚拟内存维护了一个标准,独立的地址空间,任何在虚拟内存中看到的地址均为虚拟地址。
线性地址:由段地址和虚拟地址(段地址和偏移组成)
虚拟地址:虚拟地址作为一种线性地址的抽象,可以认为虚拟地址和线性地址一样
物理地址:真实物理中的内存地址
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.2.1逻辑地址到线性地址的变换
前面已然确定虚拟内存中的地址为逻辑地址,所以其实pc寄存器在取得指令地址是一个逻辑地址,并不是真实的物理内存地址。而要变换为物理内存地址,首先要从逻辑地址变换为线性地址(虚拟地址)。虚拟内存上下文为每个进程维护了所有段的首地址,而逻辑地址正是到首地址的偏移量,所有实现变换只需要一个地址加法器即可
7.3 Hello的线性地址到物理地址的变换-页式管理
7.3.1线性地址到物理地址的变换
接下来介绍从虚拟地址到物理地址的变换,在经由逻辑地址到虚拟地址的变换后,下面要得到物理地址。计算机对于这个变换维护了一个映射,从虚拟到物理的映射,维护这个映射的是一个数据结构-----页表
页表是一个数组,其中每一项由物理地址和数组项组成。当取出一个虚拟地址后,会根据页表大小确定描述页表的位数,取出后就可以得到具体的一个数组项,之后就可以得到一个对应的物理地址。注意这个地址在程序第一次执行时尚保存在磁盘上
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1 TLB与四级页表支持下的VA到PA的变换
为了可以快速访问页表取出其中的地址,计算机实现了基于页表的快表(TLB)技术和多级页表技术。
快表作为页表的高速缓存,每个进程维护一个页表的首地址(执行时传入CR3寄存器),之后根据不同缓存的大小维护多级缓存。这样在得到了虚拟地址后,MMU会现在TLB中寻找是否有符合的页表项,如果没有,则会进入内存中寻找。
这里在内存中的寻找是基于多级页表实现,由于页表大小过于庞大,故intel和linux系统通过实现多级页表做到压缩存储页表。多级页表只有最后一级才会有PTE(page table element页表项)其余级的页表维护一个嵌套页表,所有等级页表的大小一致,除最后一级外其余级别页表保存下一级页表的首地址。多级页表的访问体现在对于虚拟地址的划分,有几个多级页表,就会将虚拟地址进行几层划分,这样就可以得到页表大小一致的多级页表。
7.5 三级Cache支持下的物理内存访问
7.5.1 三级Cache支持下的物理内存访问
通过给予地址加法器实现的从逻辑地址到虚拟地址的变换和基于页表实现的从虚拟地址到物理地址的变换,得到物理地址后传回MMU(memory mapping unit)经由MMU传回cpu,再由cpu取出对应物理地址的指令。此时cpu会先去L1 cache data/instruction中寻找,如果没有,到L2,L3中一次寻找,如果都没有,再到内存中寻找。如果内存中也没有,则会引发缺页中断,说明此物理页尚未分配,此时会从磁盘中加载进入物理内存中。
7.6 hello进程fork时的内存映射
在调用fork函数创建新进程时,fork函数会基于父进程创建子进程,此时父进程和子进程唯一不同的只有pid和时间片。二者到物理内存(磁盘)中的映射完全一样。
7.7 hello进程execve时的内存映射
现在介绍完了从虚拟内存到物理内存的步骤后,我们重新看一下execve函数的调用时处理:
- 首先清空新创建的子进程的所有上下文,因为此时这个上下文依然是父进程的。
- 清除后创建子进程中各个段节到磁盘的映射,同时建立对应的页表存储在内存中。
- 之后将pc寄存器的值放在start节开始出取指令,这样再以此译码地址,取指令,执行指令
7.8 缺页故障与缺页中断处理
在了解了虚拟内存后,接下来重新分析一下缺页故障和缺页中断的处理,补充一下前面对于缺页中断的阐述。
首先便是可能引发缺页中断的情况,事实上,前面我们一直表述的是缺页中断发生于取指令时指令并不在物理内存中,事实上缺页中断可能发生的更早,当需要依据页表取物理地址的时候,最初会发生冷不命中,因为此时页表仍位于磁盘中,尚未复制到内存中,引发缺页中断之后的过程和先前一样,会像当前程序发送信号,之后执行对应的中断处理程序。
7.9动态存储分配管理
动态储存分配管理使用动态内存分配器(如malloc)来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合。每个块就是一个连续的虚拟内存页,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。动态内存分配主要有两种基本方法与策略:
7.9.1 带边界标签的隐式空闲链表分配器管理
带边界标记的隐式空闲链表的每个块是由一个字的头部、有效载荷、(可能的)额外填充以及一个字的尾部组成。
隐式空闲链表:空闲块通过头部的大小字段隐含地连接着。分配器遍历堆中所有的块,间接地遍历整个空闲块的集合。
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配和最佳适配。分配器在面对释放一个已分配块时,可以合并相邻的空闲块,其中一种简单的方式,是利用隐式空闲链表的边界标记来进行合并。
7.9.2 显式空间链表管理
显式空闲链表是将堆的空闲块组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。进行内存管理。在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
本章介绍了存储器地址空间、段式管理、页式管理,VA 到 PA 的变换、物理内存访问, hello 进程fork时和execve 时的内存映射、缺页故障与缺页中断处理、包括隐式空闲链表和显式空闲链表的动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
Linux中将所有的IO设备抽象为文件,这种抽象带来的好处是架构师对于IO设备接口的数据传输操作可以抽象为对文件的读写操作,这样对于代码的编写上会节省很多麻烦。
常见的IO管理主要分为3类:
- UNIX的I/O设备管理,这类IO管理是linux的核心IO管理
- RIO的I/O设备管理,这类IO管理是基于UNIX的IO管理产生的具有健壮性的IO管理方法,其解决了UNIX的IO管理方法中对于返回不足值的处理,增加了程序的健壮性。同时增加了带有缓存和不带有缓存的读写方法
- 基于C语言的标准IO设备管理,这类IO管理同样是基于UNIX IO设备管理产生的(即至少调用了相关函数),提供了fopen,fclose,fgets,fputs,fread,fwrite一类标准函数,是程序员调用最多的一类IO管理
8.2 简述Unix IO接口及其函数
基础的Unix IO主要功能是:打开关闭文件,读写文件,改变文件当前位置。其对应的IO函数为
int open(char *filename,int n) |
Filename为读取文件,open函数调用内核建立从filename到文件标识符的映射,文件标识符是系统内核包含该文件的所有信息的集合地,函数只需要知道文件标识符即可知道文件的所有内容 |
void close(int fd) |
fd是文件标识符,作为filename的映射,给定fd后关闭对应的文件流。 |
void read(char *filename, size_t n,void* ptr) |
从filename的文件位置处读取n个字节到内存的ptr地址处,如果n大于文件大小,则返回已读字节数,此时返回的便是不足值。遇到EOF是为0,错误返回-1。 |
void write(char *filename , void* ptr, size_t n) |
从内存的ptr处写n个字节到filename处。 |
8.3 printf的实现分析
下面我们以printf函数为例对系统级IO进行分析。Printf函数作为标准IO函数,其在c语言中的实现函数如下:
Printf.c |
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; } |
从实现函数可以看到其首先是形式参数为fmt和…,这其实是两个参数,首先fmt作为c语言中占位符的参数,而…作为动态参数,意味着这个函数无法确定参数个数因而使用动态参数。
接下来是va_list arg = (va_list)((char*)(&fmt) + 4);这一行的目的在于取参数,首先va_list被定义为typedef char *va_list,说明其主要用于存储地址。我们知道多于6个参数之后的参数用栈传递时,其顺序与参数顺序一致,但函数的栈是从高地址向低地址生长,所以后面的参数的地址大于前面的参数地址,而fmt作为printf的第一个参数,所以地址加4则可以取到第二个参数。
之后printf调用了两个函数,这两个函数是printf的重要内核,其完成了printf对变量的对应和字符串的打印。
首先是vsprintf,前面我们说了arg作为printf函数第二个参数的地址,而buf为目标字符串的地址。我们回想一下printf日常使用的情况,一般第一个参数是一个字符串其中包含一些字符占位符,而这些占位符占位对应的变量在后面的参数中,所以arg就是这样的参数。这里其实就已经可以猜到vsprintf的目的,将arg参数替换掉所有目标字符串中所有对应的占位符,其实现逻辑如下:
vsprintf.c |
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); } |
这里我一一解释这里程序的意义,首先一个for循环查找所有符合条件的fmt占位符,如果有符合的,则根据不同占位符将对应参数使用系统函数替换为对应类型的变量,这就是后面switch的作用,这里说明一下,本代码只实现了2个占位符的逻辑。vsprintf返回的是从上一次占位符结束点到此次占位符结束点,也就说明printf的打印其实不是连续的。
之后便是write函数,我们前面说过UNIX IO的write函数,当然这里不同,单看变量就不对,UNIX IO的write参数包含3个,文件符,写入地址和写入的字节数,这里write(buf, i);只有两个参数,buf作为目标字符串,i作为写入字节数,由vsprintf返回的。具体可以看看write的实现逻辑,如下:
write,c |
mov eax, _NR_write |
这里NR_write为系统write函数,将打印的字符串的首地址传送到ebx,而打印的字节数传送到ecx,之后调用了INT_VECTOR_SYS_CALL,这个函数的视线具体与系统中断相关,只需要知道一个int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。好了,再来看看sys_call的实现:
sys_call |
sys_call: xor si,si |
将目标字符串传入和待写入的字节数得到后,会不断循环写入直到“\0”
之后再由字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
下面介绍第二个IO函数getchar():
一样的还是先上getchar()的底层实现逻辑:
int getchar(void) |
#include "sys/syscall.h" #include <stdio.h> int getchar(void) { char c; return (read(0,&c,1)==1)?(unsigned char)c:EOF //EOF定义在stdio.h文件中 } |
我们知道getchar函数的作用是读取一个字符,但要注意这里的读取和scanf不太一样,二者均调用read函数(说明是带有缓存的IO函数)。
带有缓存的输入函数,说明getchar会首先在stdin标准输入流中监测是否有缓存,如果有则从中读取一个字符,如果没有则会强制唤醒界面请求输入。而易错的点便在于回车符,每次输入完后,按住回车符,此时在缓存中的内存除了输入的字符还有回车符,而我们说了scanf也是一个缓存的函数,如果后面还有scanf则会直接读取回车符(为什么前面不读取呢?这是因为scanf和getchar不同,不是任何字符都读取,而是只读取到”\0”结尾,遇到回车,空格都会停止读取,而如果单独的回车符或者空格则会读取),此时就会发现自己输入的字符没有读入而是读入了一个回车,从而造成错误。
具体再聊聊getchar这个函数,这里看到调用饿了read函数,从0(标准输入流文件中读入),读入保存到c这个变量中,1是指只读入一个字节,如果缓存没有则会触发一个中断,请求用户输入。
8.5本章小结
本章介绍了 Linux 的 I/O 设备的基本概念和管理方法,以及Unix I/O 接口及其函数。最后分析了printf 函数和 getchar 函数的工作过程。
第九章 结论
至此,我们结束了hello的一生,回顾这段旅程,可谓历经千难万险:
- hello.c预处理到hello.i文本文件
- hello.i编译到hello.s汇编文件
- hello.s汇编到二进制可重定位目标文件hello.o
- hello.o链接生成可执行文件hello
- bash进程调用fork函数,生成子进程;
- execve函数加载运行当前进程的上下文中加载并运行新程序hello
- hello的运行需要地址的概念,虚拟地址是计算机系统最伟大的抽象。
- hello的输入输出与外界交互,与linux I/O息息相关
- hello最终被shell父进程回收,内核会收回为其创建的所有信息
让我们感谢伟大的计算机系统设计师们,他们为我们二十一世纪的年轻人打开了一个不可思议的精彩世界!
附件一:文件内容描述
文件的作用 | 文件名 |
预处理后的文件 | hello.i |
编译之后的汇编文件 | hello.s |
汇编之后的可重定位目标文件 | hello.o |
链接之后的可执行目标文件 | Hello |
Hello.o 的 ELF 格式 | elf__hello_o.txt |
Hello.o 的反汇编代码 | dump__hello_o.txt |
hello的ELF 格式 | elf__hello.txt |
hello 的反汇编代码 | dump__hello.txt |
参考文献
[1] [转]printf 函数实现的深入剖析 - Pianistx - 博客园 (cnblogs.com)
[2] read和write系统调用以及getchar的实现_牛客博客 (nowcoder.net)
[3] x64 调用约定 | Microsoft Learn
[4] Intel® 64 and IA-32 Architectures Software Developer’s Manual, Volume 3A: System Programming Guide, Part 1