程序人生-Hello’s P2P

摘  要

        为了深入了解计算机系统在一个程序从无到有到执行到终止的过程中的所作所为,本文通过对hello.c文件预处理、编译、汇编和链接阶段进行分析,探寻在这四个阶段里C语言程序发生了什么样的变化。同时,本文通过在shell里运行hello程序,分析操作系统和硬件是怎么互相协调配合执行hello的。在此过程中,深入体会了进程、虚拟内存以及信号这些概念的作用。最终,通过这次大作业,增强了笔者对于计算机系统的认识,也让笔者领略到了计算机系统冰山一角的风采。

关键词:计算机系统;进程;虚拟内存;编译;信号;

第1章 概述

1.1 Hello简介

        程序员在编辑器里一个字符一个字符敲出了我们的源程序hello.c。之后通过预处理,编译,汇编和链接成为了可执行程序hello。在shell里,通过fork创建进程、execve加载到内存以及操作系统的进程调度。hello可以在它的时间片里,在CPU中取指、译码、执行。通过操作系统的存储管理以及MMU内存管理单元,使得hello进程中的虚拟地址能够成功变换成物理地址;通过IO管理和信号处理,使得hello可以进行输入输出。最终,当hello进程终止,shell回收hello进程。至此,hello的演出圆满结束。

1.2 环境与工具

硬件环境:12th Gen Intel(R) Core(TM) i7-1260P x64 ; 2.10 GHz ; 16G RAM;

软件环境:Windows 11 64位,VMware 17.0.0 build-20800274,Ubuntu 22.04.03 LTS

开发与调试工具:vim、gcc、gdb、edb-debugger

1.3 中间结果

  1. hello.c   作为源程序
  2. hello.i  预处理后的结果
  3. hello.s   编译后的结果
  4. hello.o   汇编后的结果
  5. hello_o_obj.txt   hello.o的反汇编结果
  6. hello_o_elf.txt    readelf查看hello.o的结果
  7. hello      链接后的结果,可执行文件
  8. hello_obj.txt hello反汇编的结果
  9. hello_elf.txt readelf查看hello的结果

1.4 本章小结

       本章简单介绍了hello在计算机中从生到死的整个过程。介绍了环境与开发工具以及过程中的中间结果。一个关于hello的故事,正在计算机里面上演。

第2章 预处理

2.1 预处理的概念与作用

2.1.1 预处理的概念

       预处理是编译器实际编译之前的一个步骤,由C预处理器(C Preprocessor)完成。在预处理过程中,预处理器会根据预处理指令(常见的有#include、#define、#ifndef、#endif等等)来处理源程序的文本。

       有学者[1]对于预处理的指令主要分成了以下三个类别:“文件包含”、“宏替换”和“条件编译”。这三种主要的预处理指令也反映了预处理过程中主要完成的几项工作。

       文件包含:在编写C语言代码时,我们常需要#include来包含头文件,在预处理过程中,就会将#include包含的文件的源代码嵌入到被处理程序的相应位置。

       宏替换:我们在C语言中可以通过宏定义#define来用一个标识符(宏名)替换一个字符串。进行了宏定义之后,我们就可以在程序中用宏名来表示这样一个字符串。在预处理过程中,就需要完成将我们在程序中使用的宏名展开成它所替换的字符串这样一个工作。(注:关于宏替换与宏展开,网络上说法不一,笔者这里没有深究)

       条件编译:在预处理过程中,根据#ifndef、#endif 、#if、#elif等预处理指令限定的条件,预处理器会保留满足条件的那一部分代码,同时删去不满足条件的那一部分代码。

2.1.2 预处理的作用

        预处理是编译器开始编译的准备阶段。一方面,它将#include包含的文件里的代码加入到了被处理的程序中,保证了之后的编译过程能够正确执行,这种机制可以大幅度减少程序员的工作量;又一方面,宏定义和宏替换的机制能够在保证程序正确编译的基础上,通过使用宏名来代替一段字符串,方便程序员编写程序,有利于代码的简洁性和可读性;另一方面,条件编译的机制能够有效删去一些无用代码,从而减少编译过程的工作量。同时,通过巧妙运用条件编译,可以防止头文件被重复包含,也可以提升程序的兼容性,让程序能够移植在不同的系统中。

2.2在Ubuntu下预处理的命令

        在Ubuntu下,使用GCC编译器可以对C语言源文件进行预处理。通过gcc -E +源程序+一些参数,可以对C语言源程序进行预处理(见图 1)。

图 1 gcc预处理指令

        之后,我们就可以得到预处理后的文件(由于文件有点大,只截取了main函数部分,见图 2)。

图 2 预处理后的文件

2.3 Hello的预处理结果解析

        使用文本编辑器分别打开预处理后的文件和C语言源文件进行比较(见图 3)我们可以发现以下几点特别:

  1. 预处理后的文件明显比我们的源文件要大很多,一个是3091行,一个是23行。而且,我们可以看到预处理后的文件中没有了#include这些代码。综上可以说明预处理过程中的确有将我们#include包含的文件加入到了源程序中。
  2. 我们在源程序里的那些注释在预处理后的文件里都消失了,从而可以发现预处理过程中会去除源程序里的注释内容。
  3. 观察两个文件里main函数的内容,由于之前我们没有用#define进行宏定义,同时也没有在main函数内部使用条件编译的预处理指令,因此main函数内部的内容没有发生任何变化,甚至是第13行的空行都保留了下来。因此可以做一个简单的结论:预处理过程中除了根据预处理指令(#开头的指令)进行宏替换、文件包含、条件编译以及删除注释等操作外。不会做其他多余的事。
    图 3 源程序与预处理后文件的比较

        回到预处理后的hello.i文件的开头,再进一步进行观察(见图 4),我们可以发现:1)前面主要是头文件里的那些声明,这些声明被拷贝到了hello.i文件中。除此以外还有#开头的一些.h文件地址,这部分内容的作用笔者不是很清楚。2)我们可以看到原来头文件里的那些#ifndef、#endif等预处理指令都消失不见了,因此预处理过程中除了对我们自己的源程序进行了处理,同时也对我们包含添加进来的头文件也进行了处理。(所以,在头文件里加入一些条件编译指令能够有效地防止头文件被重复包含的情况。)

图 4 hello.i文件

2.4 本章小结

        预处理过程实质上来说是文本增加、删除和替换的过程。经过了预处理的C语言源程序才真正准备好了交给编译器进行编译以及之后的过程。总的来说,预处理是C语言源程序迈向可执行文件的简单却又重要的第一步。

第3章 编译

3.1 编译的概念与作用

3.1.1 编译的概念

       在袁春风老师编写的《计算机系统基础》[3]中是这样介绍编译的:“C编译器在进行具体的程序翻译之前,会先对源程序进行词法分析和语法分析,然后根据分析的结果进行代码优化和存储分配,最终把C语言源程序翻译成汇编语言程序。”总的说来,编译是指将预处理后的.i文件翻译成汇编语言程序.s文件的过程,这一过程由C编译器(ccl)完成。

3.1.2 编译的作用

       编译是指将预处理后的.i文件翻译成汇编语言程序.s文件的过程。如果把这个过程展开来看,我们可以发现这个过程主要由两个部分组成:分析部分和综合部分[4]。

        分析部分会把源程序分解成为多个组成要素,并在这些要素上加上语法结构。然后使用这个结构来创建该源程序的一个中间表示。同时,分析部分会对源程序的语法进行检查,如果分析部分检查出源程序没有按照正确的语法构成,或者语义上不一致,它就会提供有用的信息[4](这可以联想到我们平时编译不通过时的那些报错和警告)。除此以外,分析部分还会收集有关源程序的信息,并把信息存放在一个称为符号表的数据结构中。(符号表将和中间表示形式一起传送给综合部分)

        综合部分根据中间表示和符号表中的信息来构造用户期待的目标程序。

        以上内容均摘自“龙书”---《编译原理》,由于笔者功力不够,无法进行深入讲解,下面附上书中关于一个编译器的各个步骤的图片:

图 5 一个编译器的各个步骤

       以笔者目前的水平来看,在编译过程中,编译器会对预处理后的.i文件进行词法分析、语法分析和语义分析,判断源程序的语法是否正确,语义是否一致(检查源程序是否有语法、语义上的错误,其中语义分析的一个重要部分是类型检查),同时会收集有关源程序的信息,并存放到符号表中。最终,经过编译之后,我们可以得到.s的汇编语言程序。但是关于如何实现这些分析,笔者还并未了解具体的内容。

3.2 在Ubuntu下编译的命令

  在Ubuntu下可以采用“gcc+.i文件或.c文件+-S+一些参数”的形式进行编译,并得到生成的.s文件(见图 6)。

图 6 gcc -S指令进行编译

3.3 Hello的编译结果解析

3.3.1 格式串

       在hello.c中printf函数括号内的格式串"用法: Hello 学号 姓名 秒数!\n"以及"Hello %s %s\n"(见图 7),在hello.s文件中是以图 8中的形式进行表示。其中汉字的表示是采用UTF-8编码,并用8进制表示出来。比如说,“用”的UTF-8编码是E7 94 A8(16进制下),然后将其16进制表示成8进制就成为了\347 \224 \250。而英语字符的表示在hello.s中是正常表示的。根据所学知识,这些格式串都位于.rodata节。

图 7 printf格式串

图 8 hello.s中的格式串

3.3.2 局部变量

       在hello.c里有局部变量int i(见图 9);该局部变量在hello.s中是通过使用寄存器ebp来表示的(见图 10)。由于int类型只有4个字节,因此只需要使用rbp寄存器的低32位(即ebp)。同时由于在编译的时候,没有加入参数-fno-omit-frame-pointer,因此没有使用寄存器rbp来表示栈。关于i的具体内容,将在后面关于for循环的部分详细介绍。

图 9 hello.c源代码

图 10 用寄存器表示局部变量i

3.3.3 数组

       在hello.c中有对数组的访问(如argv[1],argv[2]),而数组的访问在hello.s中是以下图的方式实现的:通过间接寻址,访问内存地址为%rbx+16和%rbx+8的8个字节的内容并将数据分别传输给寄存器rcx和rdx,从而访问数组中的元素。

图 11 数组访问

3.3.4 操作符

       在hello.c里面主要有运算符=、++、<和!=,由于这些运算符要么是出现在if条件语句里面,要么是出现在for循环语句里面,因此关于这些操作符的内容将在之后的if条件分支和for循环里面详细介绍。

3.3.5 main函数

       main函数的参数:在hello.c中,main函数的参数有两个,其一为int argc,其二为char*argv[](见图 9),通过对hello.s进行分析,可以判断这两个参数分别通过寄存器edi和rsi传递表示。

       由于在hello.c中,argc的值要与4进行比较,通过在hello.s中寻找相应的汇编语句可以发现如图 12所示的汇编语句与之相符,因此可以知道是使用了寄存器edi来表示参数argc。

图 12 argc与4比较

       由于在hello.c中多次引用了数组argv[]里的元素,因此同样在hello.s中进行匹配,我们可以通过图 13的代码找到参数char*argv[]在hello.s中的表示形式。由于参数char*argv[]表示的是一个字符指针数组,而且是在参数-m64下进行编译,因此每一个指针的大小应该为8个字节,这与图12中的索引方式一致。由此我们可以知道这个指针数组的首地址的值(即参数char*argv[])应该是由寄存器rsi保存。

图 13 寻找参数argv

       main函数的返回值:在hello.c中main函数的返回值是通过代码return 0实现,在hello.s中(见图 14),是通过设置eax寄存器实现。通过movl语句,将eax寄存器的值设置为0,从而设置了main函数的返回值。(其他函数也是一样,在函数返回的时候通过设置寄存器eax的值,从而设置了函数的返回值)

       main函数的调用:main函数是被系统函数调用执行的。

      

图 14 main函数返回值

3.3.6 普通函数

        函数传参:根据所学知识,我们可以知道在64位Linux系统下,函数的传参应该是这样的:第一个参数用寄存器rdi,第二个参数用寄存器rsi,第三个参数用寄存器rdx,第四个参数用寄存器rcx,第五个参数用寄存器r8,第六个参数用寄存器r9,其他参数通过栈传参。

        函数的返回值通过寄存器eax保存

        函数调用通过call指令实现。

       拿hello.c(图 9)中的“printf("Hello %s %s\n",argv[1],argv[2])”举例。在hello.s中,这行代码是由图 15所示的汇编代码表示的。

       编译器编译后选择采用__printf_chk函数来进行输出。这个函数的声明是这样的:int __printf_chk(int flag, const char * format...)[5]; 它的基本作用和printf是一样的,不过多了一个参数flag。因此,在汇编代码中用edi寄存器表示了参数flag,并传参为1,用rsi寄存器表示了.LC1标签关联的格式串的地址,并且通过movq指令将数组argv[1]和argv[2]的值传给了寄存器rdx和rcx(传递了第三、四个参数)。在传参完毕后,通过call指令,调用了函数__printf_chk,以此来实现printf的格式化输出。(尽管这里初始化了寄存器eax的值为0,不过这一步似乎不是必须的)

图 15 printf函数的汇编语句

       在hello.c中还有函数嵌套“sleep(atoi(argv[3])”,编译器关于函数嵌套的处理与一般的处理是类似的,即可以先处理内层函数,再将内层函数的返回值作为外层函数的参数,最后再处理外层函数。如图 16所示,其中call strtol之前是处理内层函数atoi(这里通过调用strtol函数来实现atoi函数的功能),call strtol之后是处理外层函数sleep。

图 16 函数嵌套

       其他的函数如getchar函数和exit函数的调用过程与上面是一样的。(不过有意思的是,getchar函数在hello.s中是通过getc函数实现的,因此需要将stdin的值作为第一个参数传给寄存器rdi,然后通过call指令调用getc函数,如图 17所示。

图 17 getchar函数的汇编语句

3.3.7 if分支语句

       在hello.c文件中有一段if分支语句“if(argc!=4)” (见图 9),在hello.s中,这段语句是如图 18所示这样实现的。由于edi寄存器里存储了main函数的参数argc的值,因此按照hello.c文件中的逻辑,通过cmpl语句判断edi寄存器的值与常量4的值是否相等(此时常量4位于.text节)。

        具体执行是这样的:cmpl语句将edi寄存器的值和立即数4比较(实际上就是计算%edi-4),如果结果等于0(相等),就将ZF标志设为1;如果不等于0(不相等),就将ZF标志设为0。根据ZF的值进行条件跳转,从而就实现了if语句的条件分支。根据jne .L6语句,当ZF≠1时,即argc不等于4的时候,就会执行.L6标签内的代码,也就是“if(argc!=4)”中{}内的代码。

        至于关于操作符!=,如何判断这两个数不相等呢?正如上面所写,当用cmpl语句对这两个数进行比较的时候,如果将ZF标志设为0,那么这两个数就不相等。

图 18 if分支语句的汇编实现

3.3.8 for循环

       在hello.c中有一段for循环语句(见图 19),而for循环在汇编语言中是如何实现的,我们从for循环的各个部分分别解析。

图 19 hello.c中的for循环

       for循环的循环体({}内部的代码)是由图 20所示的.L3标签内的代码实现的。

图 20 for循环的循环体

       for循环的初始条件(i=0),是由图 10所示的movl指令,将表示局部变量i的寄存器ebp的值直接赋为0。此时赋值操作符“=”的实现就是movl语句的数据传送。

       for循环的判断条件(i<8),当i<8的时候程序会执行for循环的循环体。在汇编语句中,这样的判断条件的实现(如图 21所示)与之前if分支的实现类似。通过cmpl语句比较寄存器ebp的值和常量7(常量7此时也位于.text节),此时cmpl会根据%ebp-7的结果修改多个标志的值。如果ebp的值小于7时,cmpl会将OF标志的值设为1,将SF标志的值设为1;如果ebp的值等于7,cmpl会将ZF标志的值设为1。由于jle条件跳转的跳转条件是(SF^OF|ZF),因此当ebp的值小于等于7的时候,jle的跳转条件成立,从而会执行for循环的循环体(.L3内的代码)。

图 21 for循环的判断条件

       for循环的迭代(i++),每一次for循环执行完毕后都会进行i++的迭代操作。关于i++的迭代操作是如图 22所示实现的。在循环体执行完了后,会通过addl语句实现寄存器ebp的值加1(也就是实现了i++),之后再继续执行.L2内的代码,判断ebp的值是否小于等于7,如果是的话继续执行循环体(.L3内的代码),如果不是的话就会执行之后的语句,跳出循环。

图 22 for循环的迭代操作

3.4 本章小结

        在编译的过程中,C语言源程序实现了华丽的转身,发生了巨大的变化。由之前的C语言程序,转变成了汇编语言程序。同时,在编译的过程中,通过词法分析、语法分析和语义分析等分析,编译器会对C语言源程序进行检查,判断是否会有类型不匹配等错误,此时就会产生报错和警告让程序员抓脑疯狂。作为一名合格的程序员,我们不应该畏惧报错和警告,而应该主动拥抱报错和警告。因为那些报错和警告是编译器在告诉我们应该如何完善自己的程序。

第4章 汇编

4.1 汇编的概念与作用

4.1.1汇编的概念

       汇编是将编译后产生的.s汇编文件翻译成机器语言指令的过程。这一过程由汇编器(as)完成。

4.1.2 汇编的作用

       汇编的作用是将汇编语言代码转换成机器语言代码,并将结果保存在可重定位目标文件(.o二进制文件)中。

4.2 在Ubuntu下汇编的命令

        在Ubuntu中,可以使用gcc+-c+.s文件(+一些参数)的形式进行汇编,得到可重定位目标文件(.o文件)

图 23 gcc -c进行汇编

4.3 可重定位目标elf格式

       在《深入理解计算机系统》一书中,有一张图片描述了ELF可重定位目标文件的典型格式(见图 24)。通过readelf -a hello.o >hello.txt指令查看hello.o的ELF格式并保存到hello.txt文件中。下面将基于hello.o的ELF格式对hello.o文件进行具体分析。

图 24 典型的ELF可重定位目标文件

4.3.1 ELF

       在ELF头(图 25)里开始于一个16字节的序列(Magic),该序列描述了生成hello.o文件的系统的字的大小和字节顺序(根据网络文章[6]介绍这里描述了字的大小是64位,以及是采用小端法存储)。除此以外还描述了hello.o文件的类型为可重定位文件,系统架构为x86-64,节头部表的文件偏移为1216字节,elf头的大小为64字节,节头部表中条目的大小为64字节,数量为15个等等。

图 25 ELF头信息

4.3.2 节头部表

       在节头部表(见图 26)里描述了不同节的名称、类型、地址、偏移量和大小等信息。

图 26 节头部表

4.3.3.symtab节

       .symtab节(见图 27)中描述了一个符号表,存放着程序中定义和引用的函数和全局变量的信息。其中包括了出现了的外部函数puts、sleep等以及格式串等信息。

图 27 .symtab节

4.3.4 重定位节

       当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置,它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用[4]。

        如图 28所示,描述了重定位节.rela.text和.rela.eh_frame的内容。重定位节.rela.text存放着代码的重定位条目。在.rela.text节中,描述的是一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。其中根据重定位条目中的不同类型,链接器会执行不同的操作。比如,对于类型R_X86_64_PC32,它表示重定位一个使用32位PC相对地址的引用 [4]。因此当链接器在修改这种类型的重定位条目所对应的引用时,就是按照PC相对寻址的方式进行修改。同时,在.rela.text中的偏移量表示这些符号在.text节中的偏移位置。通过objdump -d -r hello.o,我们可查看反汇编后.text节的信息(见 图 29),可以看到这与.rela.text中的重定位条目的信息相符。

        关于重定位节.rela.eh_frame的内容,由于笔者功力不够以及在网络上搜索不到相关资料,因此无法深入分析。

图 28 重定位节

 图 29 hello.o反汇编(未列全)

4.4 Hello.o的结果解析

       通过objdump -d -r hello.o,可以得到hello.o的反汇编(如 图 29所示)。与hello.s相比我们可以得到以下结论:

(1)与hello.s相比,hello.o反汇编中大部分的指令都是保持不变的。只不过有点不同的是,相同语义的 mov、add、push等指令在hello.s中都加入了相应的后缀q、l等,而在hello.o反汇编中却都没有后缀(见图 30、图 31)。

图 30 hello.o 反汇编(1)

图 31 hello.s(1)

(2)在hello.o的反汇编中,数字都是采用十六进制处理的;而在hello.s中,数字都是采用十进制处理的,比如hello.s中的16(%rbx)在hello.o的反汇编中是0x10(%rbx)(见图 32、图 33)。

图 32 hello.o反汇编(2)

图 33 hello.s(2)

(3)在hello.s中进行跳转,都是跳转到某一个标签(比如.L2)所关联的代码,使用格式串的时候,也是使用标签(见图 34)。而在hello.o的反汇编中,不再使用标签,而是用地址来代替(只不过由于需要重定位,因此hello.o的反汇编中还没有修改这些位置,而是用占位符代替)(如图 35所示)。

                                  

图 34 hello.o反汇编(3)

图 35 hello.s(3)

(4)在hello.s中调用函数,是直接call +函数名就可以了(见图 36)。而在hello.o反汇编中调用函数是通过PC相对寻址的方式,定位函数的地址(比如 call 2f <main+0x2f>见图 37)。只不过由于还没有重定位,函数的地址还没有确定,因此操作码后面用占位符代替。

图 36 hello.s(4)

图 37 hello.o反汇编(4)

       机器语言是用二进制代码表示的计算机能直接识别和执行的一种机器指令的集合[7]。一条机器语言指令由操作码+操作数构成。操作码规定了指令的操作,是指令中的关键字,不能缺省。操作数表示该指令的操作对象[7]。

       机器语言与汇编语言的映射关系:对于汇编语言的每一个指令如movq、leaq、popq等在机器语言中都有操作码与之对应,而且对于操作的寄存器不同,操作码也会有不同(同样的mov指令,在将立即数放入eax寄存器和放入esi寄存器,它们的操作码不同)。总的来说,对于汇编语言中的一条指令,针对其操作、操作对象和目标对象不同,机器语言都会进行调整,发生变化。

       机器语言中的操作数与汇编语言并不一致,如果汇编语言中是绝对地址或者是立即数,那么机器语言的操作数是该绝对地址的小端表示(大端小端表示与操作系统有关)。如果汇编语言中是PC相对寻址,那么机器语言的操作数是下一条指令地址与目标地址的差值(即相对值)。如下图所示,“0x0a+0xf=0x19”。

图 38 PC相对寻址的操作数

4.5 本章小结

        通过汇编,我们之前的C语言代码已经成为了机器语言代码,同时能够被机器识别了,这是C语言程序向可执行程序转化的一个里程碑。经过了汇编的C语言程序,现在有着十分清晰的结构,那就是ELF格式,距离能够被机器执行只有一步之遥。要让机器能够执行我们的程序,这就需要我们最后一步的进化——链接。

5章 链接

5.1 链接的概念与作用

5.1.1 链接的概念

       链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行[4]。这一过程由链接器完成。

5.1.2 链接的作用

       链接使我们的程序能够成功访问到它引用的所有目标模块,从而保证了我们的可执行程序可以在机器上顺利执行。

5.2 在Ubuntu下链接的命令

       参考blog[8],可以使用ld命令进行链接。

图 39 使用ld命令进行链接

5.3 可执行目标文件hello的格式

5.3.1 ELF

       查看hello的ELF格式,在ELF头(见图 40)中,我们可以看到它标识了目标文件类型是可执行文件,程序入口点的地址为0x401160(即_start函数的地址,见图 41),节头部表的文件偏移13560字节,程序头表的文件偏移为64字节,大小为56字节,程序头表条目数量为12等等。

图 40 hello的elf头

图 41 _start函数地址

5.3.2 程序头表

       程序头表(见图 42)里描述了不同段的类型(LOAD说明该段是可加载的),段在文件中的偏移,段在虚拟内存中的地址,段在物理内存里的地址,段在文件中的大小,段在内存中的大小,段的属性(可读、可写、可执行)以及对齐这些信息。具体内容可以参考Blog文章[10][11]。

图 42 程序头表

5.3.3 节头部表

              在节头部表(见图 43)里描述了节的名称、类型、地址、偏移量、大小等信息。

图 43 节头部表

5.4 hello的虚拟地址空间

        根据5.3中的节头部表,我们可以知道.rodata节开始于虚拟地址0x402000处,因此在edb中通过查看地址0x402000的内容(见图 44)可以找到位于.rodata节的格式串。

图 44 虚拟内存中.rodata节

       根据《深入理解计算机系统》[4]一书中介绍了Linux x86_64运行时内存映像(如下图所示,书中图7-15),我们可以知道只读代码段中包括了.init节、.text节和.rodata节。刚才寻找到了.rodata节在内存中的信息,下面继续通过edb进行寻找。

图 45 Linux x86-64运行时内存映像

       在节头部表中没有找到.init节的相关信息,笔者也不知道是什么原因。

       通过节头部表,可以知道.text节位于虚拟内存0x4010d0处。通过运行edb,0x4010d0处正是main函数的起始位置。

图 46 .text节在虚拟内存中

       由于hello.c中没有定义全局变量和静态变量,因此不再去寻找读/写段在内存中的映射。不过根据节头部表,.data节在内存中有4个字节的空间,这一点笔者并不清楚。

5.5 链接的重定位过程分析

      通过objdump -d -r hello 对hello文件进行反汇编,将反汇编后的结果与hello.o反汇编后的结果进行比对,如图 47所示。我们可以看到,与hello.o反汇编相比,那些引用了重定位条目中出现的符号的位置,操作码后面不再是占位符,而是具体的地址(如图 48所示)。

图 47 hello反汇编

图 48 hello与hello.o反汇编对比

        除此以外,hello反汇编中还多了一些节如.plt节等、多了外部函数的PLT信息、以及_start入口的代码(见下方图49/50/51)。

图 49 .plt节内容

图 50 外部函数的PLT信息

图 51 _start入口

       链接的过程:根据《深入理解计算机系统》[4]中的内容,链接的过程主要包括符号解析和重定位两个步骤。在符号解析的过程中,链接器将每一个符号引用与它输入的可重定位目标文件中的符号表中的一个确定的符号定义关联起来。一旦链接器完成了符号解析这一步,就会开始重定位步骤。在重定位步骤里,链接器将所有相同类型的节合并为同一类型的新的聚合节,然后链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每一个节,以及赋给输入模块定义的每个符号。之后,链接器依赖重定位条目,针对重定位条目中的每个符号,修改代码节和数据节对这些符号的引用,使它们指向正确的运行时的地址。

       关于重定位:一开始,链接器会进行节的合并以及将运行时内存地址赋给聚合节,赋给输入模块定义的每一个节,以及赋给输入模块定义的每个符号。完成了这一步的时候,代码中引用的每个符号都有了唯一的运行时的内存地址。之后根据重定位条目中的内容,修改代码节和数据节中对每个符号的引用。由于在重定位条目中有描述符号的类型(是PC相对引用或是绝对引用),也描述了重定位符号的偏移量(引用重定位符号的相对于.text节或者.data节的位置)以及一个特别的加数。用offset表示重定位符号的偏移量,用addend表示这个特别的加数。

        如果符号是PC相对引用的,由于已经知道每一个节在运行时的内存地址(ADDRs)。那么引用这个重定位符号的位置refaddr,可以通过refadder=offset+ADDRs计算得到,同时由于该重定位符号运行时内存地址已知(设为ADDRb),那么修改后的地址应该为ADDRb-refaddr+addend。如果符号是绝对引用,那么修改后的地址应该就是这个重定位符号运行时的内存地址。(关于链接器如何修改重定位符号的引用,详细内容可以参照《深入理解计算机系统》[4]中479页内容)

        通过一个实例进行计算(由于重定位条目中全是PC相对引用,因此只展示PC相对引用的过程):

        由图 52中可以知道exit函数运行时在内存中的地址为0x4010a0.在第四章的重定位条目图 28中知道exit的偏移量为0x2b,加数为-4(因为32位地址,占了4个字节)。由第五章之前的节头部表图 43知道.text节运行时在内存的地址为0x4010d0,从而可以计算得需要修改对exit的引用的位置为0x4010d0+0x2b=0x4010fb,从而可以计算得修改后的结果为0x4010a0-0x4010fb+(-0x4)=0xffffffa1,从而重定位后修改的结果为0xffffffa1,在hello的反汇编中是小端表示,因此操作码后面应该是a1 ff ff ff,如图 53所示,果然如此。

图 52 exit函数运行时的内存地址

图 53 hello中调用exit

5.6 hello的执行流程

        在节头部表里没有看到.init节的信息,可能是由于在编译的时候加入了一些参数导致的。

程序的调用顺序是:

  1. 调用了动态链接库linux-x86-64.so.2、libc.so中的几个函数
  2. _start(0x401160)
  3. __libc_start_main(0x7f2be0029dc0)
  4. __cxa_atexit(0x7f2be00458c0)
  5. libc.so中几个函数(0x7f2be00456d0)
  6. 动态链接库libc.so.6里的函数(包括_setjmp等)
  7. hello中main函数(0x4010d0)
  8. __printf_chk@plt(0x401090)
  9. strtol@plt(0x401080)
  10. sleep@plt(0x4010b0)
  11. 重复8/9/10步骤7次
  12. getc@plt(0x4010c0)
  13. libc.so.6!exit

5.7 Hello的动态链接分析

如下图,在节头部表中有如下信息:

在dl_init前:.got节.got.plt节的内容如下所示:

在dl_init后,.got节.got.plt节的内容如下所示:

.got节、.got.plt节在内存里的内容都发生了改变。根据下图的GOT表知道,它更改了stdin在GOT表中对应的值。

5.8 本章小结

        本章通过对链接的过程进行分析,介绍了可执行文件的ELF格式、hello的虚拟地址空间、以及关于链接是如何重定位的,可执行程序是怎么执行的,以及动态链接过程中做了一些什么事情。至此,一个完完整整的可执行程序就已经诞生了。但是,故事并没有结束。

6章 hello进程管理

6.1 进程的概念与作用

      进程的经典定义是一个可执行中程序的实例。系统中的每个程序都运行在某个进程上下文中。上下文是由程序正确运行所需的状态组成的,包括存放在内存中的程序的代码和数据、它的栈、通用目标寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。

      进程这一概念的提出,使得计算机中会出现这样的假象:我们的程序好像是系统中当前运行的唯一的程序,我们的程序好像是独占地使用处理器和内存,我们程序中的代码和数据好像是内存中唯一的对象。进程概念的提出,可以使得多个程序在我们的计算机中并发地运行。

6.2 简述壳Shell-bash的作用与处理流程

        shell是一个交互型应用级程序,代表用户运行其他程序。它通过执行一系列的读/求值步骤,读取用户的命令行,解析命令,然后代表用户运行程序。Shell的功能主要有负责各进程创建与程序加载运行及前后台控制,作业调用,信号发送与管理等。

Shell的处理流程大体上是:

1、用户输入命令行命令(比如./hello)

2、shell会根据用户输入到命令行中的字符串,处理和解释用户输入的命令(详细内容可以查看参考文献[12])。之后根据用户输入的命令的不同,会采取不同的措施。

3、如果是要运行其他的程序,那么shell就会通过fork创建一个子进程(如果是内部命令,那么shell会直接执行,而不会创建新的进程)。

4、之后,在子进程中通过execve函数加载并运行目标程序。为新程序的代码段、数据段、bss段和栈段进行新的映射。

6.3 Hello的fork进程创建过程

       通过阅读参考文章[13]以及思维导图[14],笔者简单描述一下自己对于fork创建进程的过程的理解。首先fork函数是一个系统调用,在用户程序和内核之间提供了一个接口。当shell在调用fork函数的时候,会通过syscall这条陷阱指令,进行fork的系统调用。之后会调用相应的系统调用服务例程sys_fork()执行,sys_fork()最终会创建一个子进程,其内存映射与父进程完全相同(子进程完全复制了父进程的mm_structvm_area_struct数据结构和页表,同时它将父进程和子进程中每个私有页面都标记为只读,并将两个进程中每个区域结构都标记为私有的写时复制)。这样就完成了子进程的创建,之后父进程会返回陷阱指令syscall的下一条指令,继续执行下去。

6.4 Hello的execve过程

        在shell创建的子进程中,会通过execve函数来加载运行我们的hello程序。当子进程调用execve函数的时候,也会通过syscall这条陷阱指令,进行execve系统调用。在陷阱处理程序中,会调用相应的系统调用服务例程sys_execve()执行。sys_execve()会首先回收或重新初始化当前进程的资源(删除当前进程虚拟地址的用户部分中已存在的区域结构,重新初始化进程控制块等资源...)。之后会调用操作系统的加载器,将hello程序加载到当前进程的虚拟空间(映射新程序的代码段、数据段等私有区域、映射共享区域、设置程序计数器)。需要注意的是,execve成功调用的时候不会返回到调用程序,只有出现错误了,才会返回到调用程序。

6.5 Hello的进程执行

        结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。

        内核为每个进程维持一个上下文,上下文是内核重新启动一个被抢占的进程所需的状态,包括通用目的寄存器的值、程序计数器的值、用户栈、内核栈、各种内核数据结构等信息。

        当系统调度hello进程的时候,会发生上下文切换。上下文切换会进行1)保存先前进程的上下文,2)恢复hello进程的上下文,3)将控制传递给hello进程。由于在execve过程中设置了程序计数器,因此在execve后,当系统第一次调度hello进程的时候,程序会从程序计数器存储的程序入口点处开始执行。之后,每一次调度hello进程的时候,程序都会从程序计数器存储的地址处开始执行。

        为了实现多个进程轮流运行,操作系统会给进程分配时间片。当操作系统调度hello进程的时候,系统会给hello进程分配时间片,在这一时间片里,hello进程可以独占地使用处理器。

        进程可以在两种模式下运行:用户模式和内核模式。用户模式中的进程不允许执行特权指令,内核模式下的进程可以执行任何指令,访问系统中任何的内存位置。而一个进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷阱这样的异常。

        需要注意的是:上下文切换一定发生在内核模式下。因此关于时间分片的实现大体上是这样的:由于每个系统都有某种产生周期性定时器中断的机制(一般为1ms或10ms),当hello进程在它的时间片(具体来说应该就是定时器的单个周期)里执行的时候,如果发生了定时器的中断,hello进程由于中断异常会变为内核模式,此时内核能够判断hello进程已经运行了足够长的时间,并会通过上下文切换切换到新的进程。

        除了定时器中断,在内核代表用户进行系统调用的时候,也可能会发生上下文切换,切换到新的进程。 《深入理解计算机系统》一书中图8-14很好地描述了进程上下文切换的过程,见下图。

图 54 进程上下文切换的剖析

6.6 hello的异常与信号处理

6.6.1 正常执行

       在正常运行的过程中,主要会发生陷阱异常以及故障异常。当程序刚开始运行_start时,此时内存中并没有hello程序的代码和数据,当cpu读取hello中第一条指令的时候会发生缺页故障。此时会进入内核模式,运行相应的缺页异常处理程序。缺页处理程序会根据得到的物理地址,将该物理页缓存到内存中,并更新相应页表条目中的内容。

       除此以外,在正常运行过程中,还会由于进行系统调用发生陷阱异常(比如调用exit函数等)。在程序执行syscall指令的时候,会导致一个到异常处理程序的陷阱。这个处理程序会解析参数,并调用适当的内核程序。

       会产生SIGCHLD信号,当hello程序终止的时候,会给shell发送SIGCHLD信号,shell收到信号后会回收子进程。

图 55 正常运行状态

6.6.2 乱按键盘

       在按键盘的时候会发生中断异常,hello进程会进入内核模式,将控制转移给中断异常处理程序。键盘的中断处理程序,会从键盘控制器的寄存器读取扫描码并翻译成ASCII码,并存入键盘缓冲区。(摘自文章[15]及评论)。通过几次试验,在按了回车键的时候,输入的字符串会被shell识别为命令。

图 56 乱按键盘后运行结果

6.6.3 Ctrl+C

       在按下Ctrl+C时除了会发生键盘的中断异常,还会使hello进程收到一个SIGINT的信号,结果是使hello进程终止。在hello进程终止了之后又会给shell进程发送SIGCHLD信号,处理方法与前文描述一样。

图 57 Ctrl+C

6.6.4 Ctrl+Z

       在按下了Ctrl+Z时,hello进程会收到一个SIGTSTP的信号,结果是使hello进程停止。此时hello进程不再是前台,从而shell没有前台作业需要等待。我们可以在shell中继续输入命令。用ps命令可以查看当前的所有进程的进程号,用jobs命令查看所有的作业,用fg命令可以将指定的作业放在前台运行,此时会给指定的进程组发送SIGCONT信号,让挂起的进程重新运行。用kill命令可以向指定的进程组发送信号,kill -9表示发送SIGINT信号,会让进程组内每一个进程终止。

图 58 Ctrl+Z

6.7本章小结

       本章简单讲述了hello可执行文件的进程管理过程,介绍了fork、execve和执行的过程,以及简单讲述了hello执行时可能会发生异常和信号。援引一句话来总结本章:进程是计算机科学中最深刻、最成功的概念之一。

7章 hello的存储管理

7.1 hello的存储器地址空间

        逻辑地址:一个逻辑地址由一个段和偏移量组成。表示为[CS:EA]。在实模式下:物理地址=CS*16+EA;在保护模式下:以段描述符作为下标,到GDT/LDT表查表获得段地址,段地址+偏移地址=线性地址。如下图所示,4010d0,4010d4这些就是逻辑地址,不过由于Linux下代码段和数据段的基地址都是0x0,因此0x0+0x4010d4=0x4010d4就是线性地址。(这一块不太确定,请指正)

        线性地址空间:非负整数地址的有序集合。在保护模式下,线性地址=段地址+偏移地址=线性地址。如上图所示,0x0+0x4010d0=0x4010d0就为线性地址。

        虚拟地址空间:虚拟地址空间和线性地址空间是相同的

        物理地址空间:对于系统中物理内存的M个字节,都有{0,1,2,...,M-1}中的一个数与之一一对应,这个数就是该字节的物理地址。

7.2 Intel逻辑地址到线性地址的变换-段式管理

        在保护模式下,段寄存器中存放着段选择符。16位段选择符(由13位索引,1位TI,2位RPL组成)。TI位表示索引的描述符表类别(TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT))。高13位索引可以用来确定当前使用的段描述符在描述符表中的位置。RPL表示特权级别。

        在实际转换的时候,通过TI位决定去访问GDT还是LDT,然后根据13位索引去查找选定段的段描述符。通过段描述符中的内容可以得到段基址,将段基址与EA(偏移量)相加就得到了线性地址。

图 59 逻辑地址向线性地址转换

7.3 Hello的线性地址到物理地址的变换-页式管理

        假设虚拟页和物理页的大小均为4k(12位),通过存储在物理内存中的一个数据结构页表,可以实现将线性地址转化成物理地址。页表是页表条目的数组,每一个页表条目(PTE),都由一个有效位和一个物理页号(PPN)组成。

        对于一个n位的虚拟地址,其低12位是页内偏移(VPO),其余位表示着其虚拟页号(VPN)。

        CPU里面有一个控制寄存器PTBR(页表基址寄存器),指向当前页表。通过PTBR找到页表的首地址,再根据VPN的值可以得到对应的页表条目的地址(PTEA)。PTEA=%PTBR+VPN*页表条目大小。

        找到了页表条目后,如果有效位=1,说明该虚拟页缓存进了内存,从而根据PTE可以找到该虚拟页对应的物理页号。由于虚拟页和物理页大小相等,物理页中的页内偏移PPO=VPO。从而物理地址由PPN与VPO组合而成。(具体过程可见《深入理解计算机系统》图9-12,见下图)。如果有效位=0,则会发生缺页故障。

图 60 利用页表进行地址翻译

7.4 TLB与四级页表支持下的VA到PA的变换

       之后的变换过程均假设虚拟页已经缓存到了内存,即有效位为1.(若有效位为0,则会发生缺页故障,缺页故障的内容在之后提及)

一、快表

  1. CPU产生一个虚拟地址(VPN和VPO的组合)如下图。

  1. 根据TLB索引(TLBI)去选择快表(TLB)中对应的组号,再根据TLB标记去匹配相应的路,如果匹配成功了,那么可以得到页表条目PTE,之后将PTE中的PPN与VPO组合,从而可以得到物理地址(PA)。
  2. 如果匹配失败,快表中没有缓存这一页表条目,那么需要按照7.3中的步骤,从页表基址寄存器中得到页表的首地址,然后根据VPN的值,可以得到页表条目的地址PTEA=%PTBR+VPN*页表条目大小。
  3. 之后根据PTEA的值,在高速缓存或者内存中取出PTE的值,并将新取出的PTE存放在快表中(若快表中没有空闲的空间,则可能会驱逐已经存在的一个条目)。得到了PTE后,就可以按照同样的方法得到PA(如下图所示)

二、四级页表

  1. CPU产生一个虚拟地址,如下图所示。虚拟地址由VPN1,VPN2,VPN3,VPN4和VPO组合而成
  2. 由于PTBR页表基址寄存器存放了一级页表的首地址,因此通过VPN1可以查看一级页表中,存放的相应的二级页表的地址,再通过VPN2查看二级页表中,存放的相应的三级页表的地址,再通过VPN3查看三级页表中,存放的相应的四级页表的地址,最后通过VPN4得到四级页表中相应的页表条目(PTE)
  3. 根据PTE可以得到物理页号PPN,通过将PPN与VPO组合从而得到了物理地址PA

       《深入理解计算机系统》中介绍:TLB通过将不同层次上页表的PTE缓存起来,从而加快多级页表的地址翻译。因此,依照笔者的想法:对于四级页表来说,可以通过四层TLB进行缓存。对于第一层的TLB,用VPN1来拆分成TLBT1与TLBI1,保存着二级页表的地址;对于第二层的TLB,用VPN1和VPN2的组合来拆分成TLBT2和TLBI2,保存着三级页表的地址;对于第三层的TLB,用VPN1、VPN2和VPN3来拆分成TLBT3和TLBI3,保存着四级页表的地址;对于第四层的TLB,用VPN1、VPN2、VPN3和VPN4(即VPN)来拆分成TLBT4和TLBI4,保存着页表条目(PTE,1个有效位+物理页号PPN)。通过这四个缓存,每一次查找第i级页表的时候,先查找TLBi,如果没命中再去查找高速缓存/内存。这样可以加快多级页表的地址翻译。

7.5 三级Cache支持下的物理内存访问

       对于一个物理地址PA,将其分成三部分:标记、组索引、块偏移;首先根据组索引在L1cache里选择相应的组,判断有效位是否为1,如果为1,则通过将标记与该组中的每一行的标记进行匹配,如果命中了,就将根据块偏移在命中块中读出数据;如果没有命中,则去访问L2cache。访问方式与之前一样:先组索引,再有效位,最后匹配标记。如果命中了,就读出数据;如果没命中就去访问下一级的存储器,以此类推,直到最后在三个cache都没命中,那么就要从内存中读出数据,并将内存中的块加入到L3cache中。

7.6 hello进程fork时的内存映射

        当fork函数被shell调用的时候,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。同时它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork在新进程中返回的时候,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。

7.7 hello进程execve时的内存映射

       当hello进程通过execve加载运行hello程序的时候,execve函数用hello程序有效替代了当前程序。execve函数对内存映射做了这些事:1、删除了当前进程虚拟地址的用户部分已存在的区域结构。2、为hello程序的代码、数据、bss和栈区域创建了新的区域结构。代码和数据区域被映射成hello可执行文件中的.text和.data节,bss区域是请求二进制零的,映射到匿名文件,栈区域和堆区域也是请求二进制零的,初始长度为0,映射到匿名文件。3、映射共享区域,由于hello程序与共享对象(比如libc.so共享库)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间的共享区域内。

7.8 缺页故障与缺页中断处理

       若程序想要访问某个虚拟页中的数据的时候,会产生一个虚拟地址。当MMU(内存管理单元)在试图翻译这个虚拟地址的时候,会发现该地址所在的虚拟页没有缓存进内存(即PTE中有效位为0),必须从磁盘中取出,这时候就会发生缺页故障。

       缺页中断处理会依次执行以下步骤:

1)判断虚拟地址是否合法。缺页处理程序搜索区域结构的链表,把该虚拟地址与每个区域结构的始末地址进行比较。如果该地址不在这些区域结构中,那么就会触发段错误,并终止这个进程。(实际过程中,Linux在链表中构造了一棵树,在树上进行查找)

2)判断进行的内存访问是否合法,判断进程是否有读、写或者执行这个区域内界面的权限,如果没有权限,那么就会触发保护异常,并终止这个进程。

3)选择一个牺牲页面(如果这个牺牲页面被修改过,那就将它交换出去),换入新的页面并更新页表。

4)缺页处理程序返回时,CPU重新执行引起缺页故障的指令,这时候该虚拟页已经缓存进了内存,不会产生缺页中断了。

7.9动态存储分配管理

        动态内存分配器维护着一个进程的虚拟内存区域(称为堆)。分配器将堆视为一组不同大小的块的集合来维护。每个块是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用,空闲块可用来分配,而且空闲块如果没被分配器显式地分配那么会一直保持空闲。

关于分配器有两种风格,主要取决于怎么释放已分配的块。

(1)一种是显式分配器,要求应用显式地释放任何已分配的块。比如C语言中的malloc和free

(2)一种是隐式分配器,要求分配器检查一个已分配的块何时不再被程序使用,那么释放这个块。隐式分配器也叫做垃圾收集,在Java语言中就依赖垃圾收集释放已分配的块。

关于实现显式分配器时,如何组织、放置、分割和合并空闲块,有着如下方法和策略:

(1)用隐式空闲链表的方式来组织空闲块(单向链表)。

分配器的放置策略有:首次适配、下一次适配和最佳适配等

分割策略:不分割,使用整个空闲块;或者将空闲块分成两部分,第一部分变成分配块,剩下部分变成空闲块

获取额外的堆内存得到合适的空闲块。

合并空闲块的策略:立即合并(每次释放一个块的时候就合并)、推迟合并、带边界标记的合并

(2)用显式空闲链表的方式来组织空闲块(可以组织成双向空闲链表)

             维护链表的顺序:后进先出的顺序、按照地址顺序

(3)分离的空闲链表:维护多个空闲链表。有两种基本方法:简单分离存储,分离适配。还有很多其他的方法.....

7.10本章小结

       本章主要介绍了,hello的存储管理,包括了存储器的地址空间,地址变换、内存访问、内存映射和缺页故障等内容。总的说来,笔者有一点感叹:内存真是个稀缺的东西,为了能够充分利用它,为了能够好好管理它,学者们想了各种各样的方法。果不其然,珍稀的东西都会让人惦记......

8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

        一个Linux文件就是一个m个字节的序列,所有的I/O设备(例如网络、磁盘和终端都被模型化为文件),而所有的输入和输出都被当做 对相应文件的读和写来执行。

8.2 简述Unix IO接口及其函数

        这种将设备优雅地映射成文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:(1)打开文件 (2)Linux shell创建的每个进程开始时都有三个打开的文件:标准输入,标准输出和标准错误。(3)改变当前的文件位置 (4)读写文件 (5)关闭文件

8.2.1 打开文件

              进程通过调用open函数来打开一个已存在的文件或者创建一个新文件的:

       open函数将filename转换为一个文件描述符,并且返回描述符数字。flags参数指明了进程打算如何访问这个文件:O_RDONLY(只读)、O_WRONLY(只写)、O_RDWR(可读可写)等

8.2.2 关闭文件

       进程通过调用close函数关闭一个打开的文件。

       关闭一个已关闭的描述符会出错

8.2.3 读写文件

       应用程序是通过分别调用read和write函数来执行输入和输出的

      

       read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量

       write函数从内存buf复制至多n个字节到描述符fd的当前文件位置。

8.2.4 修改当前文件的位置

       通过调用lseek函数,应用程序能够显示地修改当前文件的位置

       fd是文件描述符,offset是偏移量,正数是向后偏移,负数是向前偏移,第三个参数有三个选项:SEEK_SET(从文件头开始偏移)、SEEK_CUR(从当前位置开始偏移)、SEEK_END(从文件末尾开始偏移)

8.3 printf的实现分析

printf函数的示例如下图所示:

      在printf函数里,用va_list 类型(char*)的变量arg指向了...中的第一个参数。之后调用了vsprintf函数:

      在vsprintf函数里,针对fmt指向的格式串里的每一个字符,如果该字符不是%,则将该字符放入buf字符数组里;如果是%,则对%后跟着的字符进行判断,进行格式化处理。(拿%x举例,则要求将参数以16进制的方式输出,则通过itoa函数,实现将该int型数转化成16进制)。通过vsprintf处理完之后,就得到了需要输出的字符串(存放在buf数组中)以及字符串的长度(vsprintf的返回值)。之后通过调用write系统函数实现字符串的输出。

        在write函数运行过程中,会通过syscall指令进入陷阱处理程序,在处理程序里会根据输入的参数,调用内核程序,从而可以将字符串写入标准输出文件。

        而之后,根据笔者的猜测,字符显示驱动子程序会从标准输出文件中一个字符一个字符地读取,并将字符的ASCII经过字模库显示vram,然后显示芯片按照刷新率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。这样就可以看到一个一个字符出现在屏幕上了。

(字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。)

8.4 getchar的实现分析

        在我们按键盘的时候,会发生硬件中断异常,hello进程会进入内核模式,将控制转移给中断异常处理程序。键盘的中断处理程序,接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。而getchar函数调用read系统函数,在运行过程中会通过syscall指令进行系统调用,触发陷阱异常,进程进入内核模式,并执行陷阱处理程序。处理程序通过解析参数,调用内核程序,能够读取ascii码值,直到接受到回车键才返回。

(异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。)

8.5本章小结

       本章介绍了Linux的IO设备管理方法,以及Unix IO接口即函数,同时简单分析了一下printf函数和getchar函数的实现方法,一言以蔽之,那就是:hello world的诞生真的不是一见简单的事啊。简单的printf函数却需要系统的大费周章,在内核态和用户态之间进行切换,需要硬件与操作系统的紧密配合。所以说计算机真的很精巧、很精妙啊!

结论

用计算机系统的语言,逐条总结hello所经历的过程。

你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。

1、hello.c,由程序员在编辑器里一个字符一个字符地敲打出来。

2、经过预处理,通过宏替换、文件包含等步骤,使得源文件变得完整但又庞大。

3、经过编译,通过词法分析、语法分析和语义分析等向程序员提出各种可能的报错和警告。经过多次修改、反复编译之后,实现了向汇编文件的转变。

4、经过汇编,实现了汇编语言文件向可重定位目标文件的转变。这时候,hello.o已经是一个机器语言文件了,能够被机器识别但是由于不完整无法被执行。

5、最后经过链接,通过符号解析与重定位,将可重定位目标文件与所依赖的模块链接起来,这样就形成了可以被机器执行的可执行文件。

6、shell为hello的执行创建了新的进程,在子进程中通过execve将hello加载到了内存。

7、通过操作系统进行进程调度,hello可以在CPU上一条指令一条指令地运行。

8、经过异常处理程序,hello进程可以进入内核模式,通过调用内核程序进行输入输出,与我们交互。

9、最终,当hello运行结束,进程终止,它向shell发送一个SIGCHLD信号后子进程被shell回收。

        计算机系统是一个庞大、复杂同时又精巧无比的设计。在编写这次的大作业的过程中,我领会到了进程、虚拟内存、信号这些概念的魅力,同时也感受到了自己面对这个复杂系统的无力。最后以一句话作结:Hello,world!我们的故事才刚刚开始。

附件

  1. hello.c   作为源程序
  2. hello.i  预处理后的结果
  3. hello.s   编译后的结果
  4. hello.o   汇编后的结果
  5. hello_o_obj.txt   hello.o的反汇编结果
  6. hello_o_elf.txt    readelf查看hello.o的结果
  7. hello      链接后的结果,可执行文件
  8. hello_obj.txt hello反汇编的结果
  9. hello_elf.txt readelf查看hello的结果

参考文献

  1. 王秀芳,孙承爱 &路燕. (2010).  C语言中编译预处理命令的解读与应用.  电脑编程技巧与维护  (22),  22-24.  doi:10.16184/j.cnki.comprg.2010.22.019.
  2. Preprocessor | Microsoft Learn
  3. 袁春风. 计算机系统基础. 北京:机械工业出版社,2014
  4. Aho,Lam,Sethi,et al. Compilers : principles, techniques and tools.赵建华,郑涛,戴新宇,译.北京:机械工业出版社,2009
  5. 格式化字符串漏洞___printf_chk-CSDN博客
  6. ELF 文件解析 1-前述+文件头分析 - 知乎 (zhihu.com)
  7. 机器语言_百度百科 (baidu.com)
  8. ld(1) command_ld命令-CSDN博客
  9. ELF entry point和装载地址_entry point address-CSDN博客
  10. ELF 文件解析 3-段 - 知乎 (zhihu.com)
  11. Elf第二讲,ELF程序头 - Android_IBinary - 博客园 (cnblogs.com)
  12. shell 命令执行顺序 一_shell脚本的运行顺序-CSDN博客
  13. 用户态--fork函数创建进程_fork创建进程-CSDN博客
  14. Markmap (oscc.cc)
  15. 键盘敲入 A 字母时,操作系统期间发生了什么.... - 知乎 (zhihu.com)
  16. 小白通俗易懂,什么是逻辑地址、线性地址和物理地址_逻辑地址与线性地址-CSDN博客
  17. Linux文件编程常用函数详解——lseek()函数_linux lseek-CSDN博客
  18. c/c++中printf函数的底层实现_printf底层实现-CSDN博客

  • 13
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值