程序人生——Hello P2P

第1章 概述
1.1 Hello简介
1.1.1 From Program to Progress
hello的源文件高级C语言程序hello.c在创建后被保存磁盘上,为了能在系统上运行hello.c程序,每条C语言语句被一系列编译器(cpp;ccl;as;ld)驱动程序转化为一系列低级的机器语言指令,这些指令接照可执行目标程序的格式(elf)打好包,并以二进制磁盘文件(a.out)的形式存放起来。至此hello.c已经被编译成可执行目标文件hello。执行hello文件,在Unix shell(Bash)内,操作系统内核OS中的进程管理函数调用fork函数为其生成子进程,调用execve函数加载进程。
1.1.2 From Zero to Zero
hello程序的生命周期是从一个源程序开始的,即程序员通过编辑器创建并保存的文本文件,文件名是hello.c,经过P2P过程后操作系统为hello的进程分配虚拟内存,CPU执行其请求的指令,在程序执行结束后,父进程通过wait/waitpid函数回收子进程,操作系统内核删除其在内存中的数据。
1.2 环境与工具
1.2.1硬件环境
1)Intel(R)Core(TM)i7-8550U CPU @ 1.80GHz 1.99GHz
2)RAM:20.0GB(19.9GB可用)
1.2.2 软件环境
1)Windows 10 家庭中文版 版本号:1909
2)64位操作系统,基于x64的处理器
3)Ubuntu 64-2019ICS
1.2.3 开发工具
1)Visual Studio 2019
2)Ubuntu edb
3)Ubuntu readelf
4)Ubuntu objdump
1.3 中间结果
1.3.1 hello.i
经预处理器(cpp)预处理后的文本。预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中第1行的#include 命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插人程序文本中。
1.3.2 hello.s
编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。其中的每条语旬都以一种文本格式描述了一条低级机器语言指令。
1.3.3 hello.o
汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,它包含的17个字节是函数main的指令编码。
1.3.4 hello
链接器(1d)把hello.c中调用的库函数与hello.o合并。结果就得到hello文件,它是一个可执行目标文件(或者简称为可执行文件),可以被加载到内存中,由系统执行。
1.4 本章小结
hello.c的表示方法说明了一个基本思想:系统中所有的信息一包括磁盘文件、内存中的程序、内存中存放的用户数据以及网络上传送的数据,都是由一串比特表示的。区分不同数据对象的唯一方法是我们读到这些数据对象时的上下文。比如,在不同的上下文中,一个同样的字节序列可能表示一个整数、浮点数、字符串或者机器指令。

第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理概念
预处理阶段。预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中第1行的#include 命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插人程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名。
2.2.2 预处理作用
预处理主要由预处理器完成。这一阶段一共完成4件事:
(1)头文件的展开:将程序中所用的头文件用其内容来替换头文件名。
(2)宏替换:扫描程序中的符号,将其替换成宏所定义的内容。
(3)去掉注释:去掉程序中的注释。
(4)条件编译:在程序中难免会有文件的重复引用,如果每次引用都要重新调用文件中的内容,这样就会增加许多不必要的开销 。所以为了防止这种情况的发生,我们在文件中使用条件编译符号来防止这种情况的发生。
2.2在Ubuntu下预处理的命令

图2-1 使用gcc –E对hello.c预处理
2.3 Hello的预处理结果解析
对照预处理的作用,可以发现hello.i与hello.c相比有如下区别:
1)头文件被展开:stdio.h、unistd.h、stdlib.h的头文件内容被插入hello.i中,正因此hello.i内容远多于hello.c。并且头文件可能用到的头文件也都被插入其中

图2-2 头文件展开

图2-3 头文件调用展开

2)注释删除:比较hello.c和hello.i中的main函数段,可以发现源文件中的中的注释被删除

图2-4 预处理注释删除
2.4 本章小结
通过观察hello.c的预处理过程,可以初步了解程序运行所需要的前期处理,即将可读性强的高级语言向机器可以执行的机器语言慢慢过渡。

第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
将某一种程序设计语言写的程序翻译成等价的另一种语言的程序的程序, 称之为编译程序(compiler)。
编译语言是一种以编译器来实现的编程语言。它不像直译语言一样,由解释器将代码一句一句运行,而是以编译器,先将代码编译为机器码,再加以运行。
在整个编译过程中,编译器会完成大部分的工作,将把用C语言提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。汇编代码表示非常接近于机器代码。与机器代码的二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示。
3.1.2 编译的作用
具备语法检查、调试措施、修改手段、覆盖处理、目标程序优化、不同语言合用以及人-机联系等重要功能。
1)语法检查:检查源程序是否合乎语法。如果不符合语法,编译程序要指出语法错误的部位、性质和有关信息。编译程序应使用户一次上机,能够尽可能多地查出错误。
2)调试措施:检查源程序是否合乎设计者的意图。为此,要求编译程序在编译出的目标程序中安置一些输出指令,以便在目标程序运行时能输出程序动态执行情况的信息,如变量值的更改、程序执行时所经历的线路等。这些信息有助于用户核实和验证源程序是否表达了算法要求。
3)修改手段:为用户提供简便的修改源程序的手段。编译程序通常要提供批量修改手段(用于修改数量较大或临时不易修改的错误)和现场修改手段(用于运行时修改数量较少、临时易改的错误)。
4)覆盖处理:主要是为处理程序长、数据量大的大型问题程序而设置的。基本思想是让一些程序段和数据公用某些存储区,其中只存放当前要用的程序或数据;其余暂时不用的程序和数据,先存放在磁盘等辅助存储器中,待需要时动态地调入。
5)目标程序优化:提高目标程序的质量,即占用的存储空间少,程序的运行时间短。依据优化目标的不同,编译程序可选择实现表达式优化、循环优化或程序全局优化。目标程序优化有的在源程序级上进行,有的在目标程序级上进行。
6)不同语言合用:其功能有助于用户利用多种程序设计语言编写应用程序或套用已有的不同语言书写的程序模块。最为常见的是高级语言和汇编语言的合用。这不但可以弥补高级语言难于表达某些非数值加工操作或直接控制、访问外围设备和硬件寄存器之不足,而且还有利于用汇编语言编写核心部分程序,以提高运行效率。
7)人-机联系:确定编译程序实现方案时达到精心设计的功能。目的是便于用户在编译和运行阶段及时了解内部工作情况,有效地监督、控制系统的运行。
3.2 在Ubuntu下编译的命令

图3-1 使用gcc –S进行编译
3.3 Hello的编译结果解析
3.3.1数据
1)常量
①在只读数据段.rodata内储存字符串常量

图3-2 hello.c中的字符串常量

图3-3 hello.s中的字符串常量
②局部常量

图3-4 hello.c中的局部常量

图3-5 hello.s中的局部常量
2)局部变量
通过与源程序比较,可以发现局部变量argc被储存在M[-20(%rbp)]中,而%rbp指向栈顶,故局部变量被储存在栈帧里。

图3-6 hello.s中的局部变量
3.1.2 赋值操作
通过与源程序比较,可以发现对局部变量i的赋初值操作在hello.s中由movl(i为4字节int类型变量)对-4(%rbp)操作实现

图3-7 hello.c中的赋值语句

图3-8 hello.s中的赋值语句
3.1.3 算术操作
累加运算i++在hello.s中由addl实现

图3-9 hello.c中的累加语句

图3-10 hello.s中的累加语句
3.1.4 控制转移
hello.c中存在判断语句if,argc存放在M[-20(%rbp)]中,使用cmpl命令会使argc减去4,若此时零标志位寄存器ZF等于1,则指令je会使程序跳转到.L2
,即for语句;否则调用函数exit,退出main函数

图3-11 hello.c中的判断语句

图3-12 hello.s中的条件跳转
3.1.5函数操作
1)参数传递
通过movl、movq、leaq等指令向参数寄存器%rdi、%rsi、%rdx进行赋值

图3-13 hello.s中的参数传递(1)

图3-14 hello.s中的参数传递(2)

2)函数调用
使用call语句调用函数

图3-15 hello.s中的函数调用
3)函数返回
使用ret从main函数返回,返回值被存放在%rax中(return 0)

图3-16 hello.s中的函数返回

3.4 本章小结
在本章中,我们了解到C语言提供的抽象层下面的东西,以了解机器级编程。通过让编译器产生机器级程序的汇编代码表示,我们了解了编译器和它的优化能力,以及机器、数据类型和指令集。当编写能有效映射到机器上的程序时,了解编译器的特性会有所帮助。

第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
1)汇编语言
汇编语言(assembly language)是任何一种用于电子计算机、微处理器、微控制器,或其他可编程器件的低级语言。在不同的设备中,汇编语言对应着不同的机器语言指令集。一种汇编语言专用于某种计算机系统结构,而不像许多高级语言,可以在不同系统平台之间移植。
2)汇编过程
使用汇编语言编写的源代码,然后通过相应的汇编程序将它们转换成可执行的机器代码。这一过程被称为汇编过程。
4.1.2 汇编的作用
驱动程序运行汇编器(as),将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件hello.o中。可重定位目标文件有多种格式,Windows使用可移植可执行(Portable Executable,PE),Linux和Unix系统使用可执行可链接格式(Executable and Linkable Format, ELF)。ELF文件可被链接器链接生成可执行目标文件
4.2 在Ubuntu下汇编的命令

图4-1 使用汇编器as对hello.s汇编

图4-2 使用编译器gcc对hello.c汇编
4.3 可重定位目标elf格式
使用Linux> readelf –a hello.o,可以查看ELF格式的可重定位目标文件

图4-3 典型的ELF可重定位目标文件
4.3.1 ELF头
ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中的每个节都有一个固定大小的条目(entry)

图4-4 ELF头
4.3.2 节头部表
节头部表包含ELF文件中各个节的相关信息,如名称、类型、起始地址和偏移量。

图4-5 ELF节头部表
4.3.3 重定位条目.rela. text
一个.text 节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。

图4-6 ELF重定位条目格式
ELF定义了32种不同的重定位类型,其中存在两种最基本的重定位类型,即重定位一个使用32位PC相对地址的引用——R_X86_64_PC32和重定位一个使用32位绝对地址的引用——R_X86_64_32。其各自的重定位算法如图4-7。

图4-7 ELF重定位算法
从.rela.text节可以看出,hello.o代码段中需要重定位的部分的相关信息都被列出。这些条目的信息包括重定位目标在代码段内的的偏移量、重定位的类型(直接寻址/间接寻址等)、重定位的名称和addend值等。

图4-8 ELF重定位节(.rela.text)
4.3.4 符号表
.symtab 节(符号表)包括以下三种类型的数据:
1)由模块m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数和全局变量。
2)由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号, 对应于在其他模块中定义的非静态C函数和全局变量。
3)只被模块m定义和引用的局部符号。它们对应于带static属性的C函数和全局变量。这些符号在模块m中任何位置都可见,但是不能被其他模块引用。
ELF符号表格式如图4-9,其中name是字符串表中的字节偏移,指向符号的以null结尾的字符串名字。value是符号的地址。对于可重定位的模块来说,value是距定义目标的节的起始位置的偏移。对于可执行目标文件来说,该值是一个绝对运行时地址。size是目标的大小(以字节为单位)。type通常要么是数据,要么是函数。符号表还可以包含各个节的条目,以及对应原始源文件的路径名的条目。所以这些目标的类型也有所不同。binding字段表示符号是本地的还是全局的。每个符号都被分配到目标文件的某个节,由section字段表示,该字段也是一个到节头部表的索引。有三个特殊的伪节(pseudosection), 它们在节头部表中是没有条目的:ABS代表不该被重定位的符号;UNDEF代表未定义的符号,也就是在本目标模块中引用,但是却在其他地方定义的符号;COMMON表示还未被分配位置的未初始化的数据目标。对于COMMON符号,value字段给出对齐要求,而size给出最小的大小。注意,只有可重定位目标文件中才有这些伪节,可执行目标文件中是没有的。

图4-9 ELF符号表条目格式

图4-10 hello.o符号表条目
4.4 Hello.o的结果解析
使用Linux>objdump -d -r hello.o命令反汇编hello.o文件,分析汇编语言和机器语言的区别和联系
4.4.1 格式
机器语言中每条机器指令都有对应的操作码或操作数(十六进制表示),汇编语言中只有自然语言而没有机器可执行的机器指令。机器语言中每条指令都会对应一个地址。如main函数的地址偏移为0x0。与机器代码的二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示。

图4-11 机器语言与汇编语言对比(1)
4.4.2 数据表示
机器语言中所有的操作数都以十六进制表示,而汇编语言中以可读性更强的十进制表示

图4-12 机器语言与汇编语言对比(2)

说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
4.4.3 重定位符号引用
1)数据引用
可以发现,hello.c中第一处对字符串常量的引用在汇编语言中以段标识与寄存器的组合.LC0(%rip)表示,而在机器语言中以0x0(%rip)表示,并且有明确的重定位信息(以相对寻址的方式获取在制度数据段.rodata偏移量为0x4处的数据)

图4-13 重定位符号引用(1)
2)分支转移
可以发现,汇编语句中代码被分为若干段,跳转目标用段标识符表示;机器语言中直接用相对于对应代码段偏移地址表示

图4-14 重定位符号引用(2)
3) 函数调用
在汇编语言中,由于没有重定位条目,在调用外部库函数时无法给出地址,以函数名称作为引用的目标;机器语言中由于存在重定位条目,可以通过重定位对相关的符号进行解析

图4-15 重定位符号引用(3)
4.5 本章小结
本章我们了解到了汇编器(as)对hello.s的处理,汇编器将hello.s翻译为二进制机器语言,将其转化为了可重定位目标文件(ELF),这是链接时使用的重要文件类型。ELF文件的格式固定,其中的文件头和节提供之后链接时需要的各种种类的信息,包括数据、代码、符号以及重定位等。

第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程, 这个文件可被加载(复制)到内存并执行。链接可以执行于编译时(compile time), 也就是在源代码被翻译成机器代码时;也可以执行于加载时(load time),也就是在程序被加载器(load­er)加载到内存并执行时,甚至执行于运行时(run time), 也就是由应用程序来执行。在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。
链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译( separate com­pilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
5.1.2 链接的作用
为了构造可执行目标文件,链接器需要完成两个任务
1)符号解析。目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量(即C语言中任何以static属性声明的变量)。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
2)重定位。编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目(relocation entry)的详细指令,不加甄别地执行这样的重定位。
5.2 在Ubuntu下链接的命令
使用Linux> gcc –no-pie –o hello hello.o命令链接hello.o文件

图5-1 使用gcc对hello.o链接
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
5.3.1 ELF头
与可重定位目标文件hello.o相比,可执行目标文件hello的ELF头内的信息明显增多,条目入口的地址也从0x0变为0x401090

图5-2 hello的ELF头
5.3.2 节头部表
可以发现,hello中的各个节的起始地址已经确定

图5-3 hello的节头部表
5.3.3 Program Headers
可以发现程序头中条目的虚拟地址已经确定。

图5-4 hello的Program Headers
5.3.4 符号表
从符号表中可以得到程序中调用的各个函数的绝对运行时地址。

图5-5 hello符号表
5.4 hello的虚拟地址空间
使用edb加载hello,可以发现Program Headers中条目的虚拟地址在data dump中存在一一对应。其中虚拟地址0x400000处的值为ELF。

图5-6 hello虚拟地址映射
同时,符号表中函数的value是程序运行时函数的绝对地址。可以在symbol viewer中一一找到。

图5-7 hello运行时symbol viewer内容
5.5 链接的重定位过程分析
由hello的反汇编,可以发现hello.o调用的外部函数的地址已经初步确定(在程序链接表.plt)。同时main函数的运行时地址已经确定,值为0x401172,与符号表中main条目的value相同。通过在4.3.3中介绍的重定位算法,即可得到外部函数的跳转地址(在hello.o的反汇编文件中跳转地址仍为00 00 00 00)。

图5-8 hello重定位条目

图5-9 hello反汇编( main函数)

图5-10 hello反汇编(.plt节)
5.6 hello的执行流程
hello从开始执行到退出程序经过了如下的的函数调用
1)ld-2.29.so!_dl_catch_error@plt

图5-11 hello执行流程(1)
2)

图5-12 hello执行流程(2)
3)

图5-13 hello执行流程(3)
4)

图5-14 hello执行流程(4)
5)

图5-15 hello执行流程(5)
5.7 Hello的动态链接分析
以对函数printf@plt的调用为例分析hello的动态链接机制
首先,从对hello的反汇编中可以看出,main函数在执行到“callq 401040 printf@plt”语句时跳转到地址0x401040。

图5-16 hello动态链接(1)
在对.plt的反汇编中,地址0x401040的printf@plt共有3条语句,执行完第一条语句后会跳转到地址0x404020,这个地址位于.got.plt中。从图中可以看到got.plt起始于0x404000,终止于0x404047。并且0x404020的内容为0x401046,即为printf@plt的第二条语句的地址,此指令将0x1(当前函数的id)压入栈中。之后执行printf@plt中的第三条语句,跳转到0x401020 <.plt>。

图5-17 hello动态链接(2).plt

图5-18 hello动态链接(3).got.plt
这之后的工作可以视为系统在运行时填充地址0x404020的过程。也就是在延迟绑定机制下,第一次执行时,0x404020的内容是0x401046,第二次及之后的内容就会修改为printf函数的实际地址0x7fdd0aa2e830。

图5-19 <.plt>第一次执行前.got.plt的值

图5-20 <.plt>执行后.got.plt的值

图5-20 printf函数运行时地址

5.8 本章小结
链接可以在编译时由静态编译器来完成,也可以在加载时和运行时由动态链接器来完成。链接器处理称为目标文件的二进制文件,它有3种不同的形式:可重定位的、可执行的和共享的。可重定位的目标文件由静态链接器合并成一个可执行的目标文件,它可以加载到内存中并执行。共享目标文件(共享库)是在运行时由动态链接器链接和加载的,或者隐含地在调用程序被加载和开始执行时,或者根据需要在程序调用dlopen库的函数时。

第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
异常是允许操作系统内核提供进程(process)概念的基本构造块,进程是计算机科学中 最深刻、最成功的概念之一。
在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内 存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
6.1.2 进程的作用
进程提供给应用程序关键抽象:
1)一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
2)一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 壳Shell-bash的作用与处理流程
6.2.1 shell简介
一般我们是用图形界面和命令去控制计算机,真正能够控制计算机硬件(CPU、内存、显示器等)的只有操作系统内核(Kernel),由于安全、复杂、繁琐等原因,用户不能直接接触内核,需要另外再开发一个程序,让用户直接使用这个程序;该程序的作用就是接收用户的操作(点击图标、输入命令),并进行简单的处理,然后再传递给内核,内核和用户之间就多了一层“中间代理”,Shell 其实就是一种脚本语言,也是一个可以用来连接内核和用户的软件,我们编写完源码后不用编译,直接运行源码即可。
6.2.2 bash的命令执行
对于命令的执行,bash会执行如下操作:
1)bash执行fork()系统调用创建子进程(如果命令已经处于子shell内,则不会再次fork())
2)执行重定向
3)执行execve()系统调用,控制权移交给操作系统。
4)内核判断该文件是否是操作系统能够处理的可执行格式(如ELF格式的可执行二进制文件或开头顶格写#!的可执行文本文件)
5)如果操作系统能够处理该文件,则调用相应的函数(二进制文件)或解释器(脚本文件)进行执行。
6)如果文件不具备操作系统的可执行格式(如文本文件但没有顶格写的#!),execve()失败,此时,bash会判断该文件,如果该文件有可执行权限并且不是一个目录,则认为该文件是一个脚本,于是调用默认解释器解释执行该文件的内容。
7)执行完毕后,bash收集命令的返回值。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载(覆盖当前进程的地址空间)并运行一个新程序。
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不filename, execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并从不返回。

图6-1 execve函数声明
6.5 Hello的进程执行
6.5.1 逻辑控制流
即使在系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果想用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应千包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称逻辑流。

图6-2 逻辑控制流
6.5.2 并发流
—个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地运行。更准确地说,流X和Y互相并发,当且仅当X在Y开始之后和Y结束之前开始,或者Y在X开始之后和X结束之前开始。多个流并发地执行的一般现象被称为并发。一个进程和其他进程轮流运行的概念称为多任务。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫做时间分片。
6.5.3 用户模式和内核模式
处理器通常是用某个控制寄存器中的一个模式位(modebit)来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个I/0操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
6.5.4 上下文切换
内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,如果一个read系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据从磁盘到达。另一个示例是sleep系统调用,它显式地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。

图6-3 上下文切换
6.6 hello的异常与信号处理
6.6.1 pstree
Linux pstree命令将所有行程以树状图显示,树状图将会以 pid (如果有指定) 或是以 init 这个基本行程为根 (root),如果有指定使用者id,则树状图会只显示该使用者所拥有的行程。

图6-4 pstree查看所有进程
6.6.2 程序执行
在bash中正常运行hello,不停乱按键盘不会影响程序的正常执行,因为程序会阻塞这些信号。最后不需要再输入字符结束程序,程序会读入之前输入的字符。

图6-5 hello进程正常执行
6.6.3 从键盘发送信号
1)Ctrl+C——SIGINT:终止
如果当进程在前台运行时,键入Ctrl+C,那么内核就会发送一个SIGINT信号(号码2)给这个前台进程组中的每个进程。一个进程可以通过向另一个进程发送一个SIGKILL信号(号码9)强制终止它。当一个子进程终止或者停止时,内核会发送一个SIGCHLD信号(号码17)给父进程。

图6-6 ctrl+c终止进程
2)Ctrl+Z——SIGTSTP:停止
如果当进程在前台运行时,键入Ctrl+Z,那么内核就会发送一个SIGTSTP信号给这个前台进程组中的每个进程,默认情况下,结果是停止(挂起)前台作业。可以通过kill函数杀死被挂起的进程,或者通过继续运行被挂起的进程。

图6-7 ctrl+z挂起进程和kill杀死进程

图6-8 ctrl+z挂起进程和fg运行被挂起的进程
6.7本章小结
在操作系统层,内核用ECF提供进程的基本概念。进程提供给应用两种重要的抽象:1)逻辑控制流,它提供给每个程序一个假象,好像它是在独占地使用处理器;2)私有地址空间,它提供给每个程序一个假象,好像它是在独占地使用主存。

第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1物理地址
加载到内存地址寄存器中的地址,内存单元的真正地址。在前端总线上传输的内存地址都是物理内存地址,编号从0开始一直到可用物理内存的最高端。这些数字被北桥(Nortbridge chip)映射到实际的内存条上。物理地址是明确的、最终用在总线上的编号,不必转换,不必分页,也没有特权级检查(no translation, no paging, no privilege checks)。
7.1.2 逻辑地址
CPU所生成的地址。逻辑地址是内部和编程使用的、并不唯一。例如,你在进行C语言指针编程中,可以读取指针变量本身值(&操作),实际上这个值就是逻辑地址,它是相对于你当前进程数据段的地址(偏移地址),不和绝对物理地址相干。
7.1.3 线性地址和虚拟地址
CPU在保护模式下,“段基址+段内偏移地址”叫做线性地址,注意,保护模式下段基址寄存器中存储的不是真正的段基值(和实模式的含义不一样),而是“段选择子”,通过段选择子在GDT(全局描述表)中找到真正的段基值。另外,如果CPU在保护模式下没有开启分页功能,则线性地址就被当做最终的物理地址来用,若开启了分页功能,则线性地址就叫虚拟地址(在没开启分页功能的情况下线性地址和虚拟地址就是一回事)。但是,如果开启分页功能,虚拟地址(或线性地址)还要通过页部件电路转换成最终的物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.2.1 段寄存器
段寄存器(16位),用于存放段选择符。其中有三种存在特殊用法:
1)CS(代码段):程序代码所在段
2)SS(栈段):栈区所在段
3)DS(数据段):全局静态数据区所在段
其他3个段寄存器ES、GS和FS可指向任意数据段。
7.2.2 段选择符
段选择符各字段含义如下:
1)TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT)。
2)RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级。
3)高13位-8K个索引用来确定当前使用的段描述符在描述符表中的位置。

图7-1 段选择符
7.2.3 段描述符
段描述符是一种数据结构,实际上就是段表项,分为用户的代码段&数据段描述符和系统控制段描述符两类。其中系统控制段描述符又分两种:
1)特殊系统控制段描述符,包括:局部描述符表(LDT)描述符和任务状态段(TSS)描述符。
2)控制转移类描述符,包括:调用门描述符、任务门描述符、中断门描述符和陷阱门描述符。

图7-2 段描述符定义
7.2.4 逻辑地址到线性地址的转换
被选中的段描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址。

图7-3 逻辑地址到线性地址的转换

7.3 Hello的线性地址到物理地址的变换-页式管理
7.3.1 控制寄存器
1)CR0:控制寄存器

图7-4 控制寄存器CR0
2)CR2:页故障(page fault)线性地址寄存器
存放引起页故障的线性地址。只有在CR0中的 PG=1 时,CR2才有效。
3)CR3:页目录基址寄存器
保存页目录表的起始地址。只有当CR0中的PG=1 时,CR3才有效。

图7-5 页目录基址寄存器CR3
7.3.2 地址转换
1)从CR3寄存器中读取页目录所在物理页面的基址(即所谓的页目录基址),从线性地址的第一部分获取页目录项的索引,两者相加得到页目录项的物理地址。

2)第一次读取内存得到pgd_t(页全局目录)结构的目录项,从中取出物理页基址取出(具体位数与平台相关,如果是32系统,则为20位),即页上级页目录的物理基地址。
3)从线性地址的第二部分中取出页上级目录项的索引,与页上级目录基地址相加得到页上级目录项的物理地址。
4)第二次读取内存得到pud_t(页上级目录)结构的目录项,从中取出页中间目录的物理基地址。
5)从线性地址的第三部分中取出页中间目录项的索引,与页中间目录基址相加得到页中间目录项的物理地址。
6)第三次读取内存得到pmd_t(页中间目录)结构的目录项,从中取出页表的物理基地址。
7)从线性地址的第四部分中取出页表项的索引,与页表基址相加得到页表项的物理地址。
8)第四次读取内存得到pte_t(页面表)结构的目录项,从中取出物理页的基地址。
9)从线性地址的第五部分中取出物理页内偏移量,与物理页基址相加得到最终的物理地址。
10)第五次读取内存得到最终要访问的数据。
整个过程是比较机械的,每次转换先获取物理页基地址,再从线性地址中获取索引,合成物理地址后再访问内存。不管是页表还是要访问的数据都是以页为单位存放在主存中的,因此每次访问内存时都要先获得基址,再通过索引(或偏移)在页内访问数据,因此可以将线性地址看作是若干个索引的集合

图7-6 页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1 页表翻译
图7-7展示了MMU如何利用页表来实现这种映射。CPU中的一个控制寄存器,页表基址寄存器(Page Table Base Register, PTBR)指向当前页表。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(Virtual Page Offset, VPO)和一个(n-p)位的虚拟页号(Virtual Page Number, VPN)。MMU利用VPN来选择适当的PTE。例如,VPN0选择PTEO,和虚拟VPN1选择PTE1,以此类推。将页表条目中物理页号(Physical Page Number, PPN)和地址中的VPO串联起来,就得到相应的物理地址。地址中的VPO 串联起来,就得到相应的物理地址。注意, 因为物理和虚拟页面都是P字节,所以物理页面偏移(Physical Page Offset,PPO) 和 VPO 是相同的。

图7-7 页表翻译
7.4.2 四级页表翻译
36位VPN被划分成四个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN1提供到一个L1PET的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2PTE的偏移量,以此类推。

图7-8 四级页表翻译
7.5 三级Cache支持下的物理内存访问
7.5.1 Intel处理器系统

图7-9 Intel处理器系统
7.5.2 高速缓存和虚拟内存
在任何既使用虚拟内存又使用SRAM高速缓存的系统中,都有应该使用虚拟地址还是使用物理地址来访问SRAM高速缓存的问题。使用物理寻址,多个进程同时在高速缓存中有存储块和共享来自相同虚拟页面的块成为很简单的事情。而且,高速缓存尤需处理保护问题,因为访问权限的检查是地址翻译过程的一部分。图9-14展示了一个物理寻址的高速缓存如何和虚拟内存结合起来。主要的思路是地址翻译发生在高速缓存查找之前。页表条目可以缓存,就像其他的数据字一样。

图7-10 高速缓存与虚拟内存
7.6 hello进程fork时的内存映射
7.6.1 内存映射
Linux通过将一个虚拟内存区域与一个磁盘上的对象(object)关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射(memory mapping)。虚拟内存区域可以映射到两种类型的对象中的一种:

  1. Linux文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分,例如一个可执行目标文件。文件区(section)被分成页大小(4KB)的片,每一片包含一个虚拟页面的初始内容。因为按需进行页面调度,所以这些虚拟页面没有实际交换进入物理内存,直到CPU第一次引用到页面(即发射一个虚拟地址,落在地址空间这个页面的范围之内)。如果区域比文件区要大,那么就用零来填充这个区域的余下部分。hello即是此种文件。
  2. 匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零。CPU第一次引用这样一个区域内的虚拟页面时,内核就在物理内存中找到一个合适的牺牲页面,如果该页面被修改过,就将这个页面换出来,用二进制零覆盖牺牲页面并更新页表,将这个页面标记为是驻留在内存中的。注意在磁盘和内存之间并没有实际的数据传送。因为这个原因,映射到匿名文件的区域中的页面有时也叫做请求二进制零的页(demand-zeropage)。
    无论在哪种情况中,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件(swap file)之间换来换去。交换文件也叫做交换空间(swap space)或者交换区域(swap area)。需要意识到的很重要的一点是,在任何时刻,交换空间都限制着当前运行着的进程能够分配的虚拟页面的总数。
    7.6.2 fork函数
    当fork函数被当前进程(bash)调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
    当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

图7-11 bash调用fork函数创建hello进程
7.7 hello进程execve时的内存映射
当运行Linux> ./hello 1180801123 gezhe 1,当前进程中的程序执行了如下的execve调用:
execve(”hello”, 1180801123 gezhe 1,NULL);
execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2)映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
3)映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4)设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。

图7-12 加载器映射用户区域

7.8 缺页故障与缺页中断处理
7.8.1 页命中
当CPU读取包含在VP2中的虚拟内存的一个字时,VP2被缓存在DRAM中。地址翻译硬件将虚拟地址作为一个索引来定位PTE2,并从内存中读取它。因为设置了有效位,那么地址翻译硬件就知道VP2是缓存在内存中的了。所以它使用PTE中的物理内存地址(该地址指向PP1中缓存页的起始位置),构造出这个字的物理地址。

图7-13 VM页命中
7.8.2 缺页
DRAM缓存不命中称为缺页(page fault)。CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在pp3中的VP4。如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。

图7-14 VM缺页(之前)
接下来,内核从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。

图7-15 VM缺页(之后)
7.9动态存储分配管理
7.9.1 动态内存分配器
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk(读做"break"),它指向堆的顶部。
分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

图7-16 堆
7.9.2 malloc函数
malloc函数返回一个指针,指向大小为至少size字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。实际中,对齐依赖于编译代码在32位模式(gee-m32)还是64位模式(默认的)中运行。在32位模式中,malloe返回的块的地址总是8的倍数。在64位模式中,该地址总是16的倍数。
动态内存分配器,例如malloc,可以通过使用mmap和munmap函数,显式地分配和释放堆内存,或者还可以使用sbrk函数。
当程序请求4字的块时,malloc的响应是:从空闲块的前部切出一个4字(保持空闲块是双字边界对齐的)的块,并返回一个指向这个块的第一字的指针。
7.10本章小结
虚拟内存是对主存的一个抽象。支持虚拟内存的处理器通过使用一种叫做虚拟寻址的间接形式来引用主存。处理器产生一个虚拟地址,在被发送到主存之前,这个地址被翻译成一个物理地址。从虚拟地址空间到物理地址空间的地址翻译要求硬件和软件紧密合作。专门的硬件通过使用页表来翻译虚拟地址,而页表的内容是由操作系统提供的。
虚拟内存提供三个重要的功能。第一,它在主存中自动缓存最近使用的存放磁盘上的虚拟地址空间的内容。虚拟内存缓存中的块叫做页。对磁盘上页的引用会触发缺页,缺页将控制转移到操作系统中的一个缺页处理程序。缺页处理程序将页面从磁盘复制到主存缓存,如果必要,将写回被驱逐的页。第二,虚拟内存简化了内存管理,进而又简化了链接、在进程间共享数据、进程的内存分配以及程序加载。最后,虚拟内存通过在每条页表条目中加入保护位,从而了简化了内存保护。

第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m个字节的序列:
B0, B1,…,Bk,…,Bm-1
所有的I/0设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为UnixI/0,这使得所有的输入和输出都能以一种统一且一致的方式来执行:
1)打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/0设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2)Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件定义了常量STDIN_FILENO、STOOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
3)改变当前的文件位置
对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移最。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
4)读写文件
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的"EOF符号”。
类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5)关闭文件
当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2 简述Unix IO接口及其函数
Unix I/O模型是在操作系统内核中实现的。应用程序可以通过诸如open、close、lseek、read、write和stat这样的函数来访问Unix I/O。较高级别的RIO和标准I/0函数都是基千(使用)UnixI/O函数来实现的。RIO函数是专为本书开发的read和write的健壮的包装函数。它们自动处理不足值,并且为读文本行提供一种高效的带缓冲的方法。标准I/O函数提供了UnixI/O函数的一个更加完整的带缓冲的替代品,包括格式化。

图8-1 I/O接口
8.3 printf的实现分析
研究printf的实现,首先观察printf函数的函数体:
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;
}
在形参列表里token:…这个是可变形参的一种写法。当传递参数的个数不确定时,就可以用这种方式来表示。很显然,我们需要一种方法,来让函数体可以知道具体调用时参数的个数。
对于va_list arg = (va_list)((char*)(&fmt) + 4);va_list的定义:typedef char va_list。这说明它是一个字符指针。其中的:(char)(&fmt) + 4) 表示的是…中的第一个参数。
对于i = vsprintf(buf, fmt, arg),观察
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返回要打印出来的字符串的长度。所以说:vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
对于write(buf, i),观察:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
write函数的作用便是将字符数组buf中的i个元素打印到终端。而这个步骤与硬件有关,这就需要限制程序执行的权限。而write中的最后一行int INT_VECTOR_SYS_CALL便是通过系统来调用函数sys_call来执行下一步操作。
syscall.通过调用字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
8.4.1工作原理
getchar()是stdio.h中的库函数,它的作用是从stdin流中读入一个字符,也就是说,如果stdin有数据的话不用输入它就可以直接读取了,第一次getchar()时,确实需要人工的输入,但是如果你输了多个字符,以后的getchar()再执行时就会直接从缓冲区中读取了。
实际上是 输入设备→内存缓冲区→getchar(),你按的键是放进缓冲区了,然后提供给程序getchar()
键盘输入的字符都存到缓冲区内,一旦键入回车,getchar就进入缓冲区读取字符,一次只返回第一个字符作为getchar函数的值,如果有循环或足够多的getchar语句,就会依次读出缓冲区内的所有字符直到’\n’。
之所以输入的一系列字符被依次读出来,是因为循环的作用使得反复利用getchar在缓冲区里读取字符,而不是getchar可以读取多个字符,事实上getchar每次只能读取一个字符。如果需要取消’\n’的影响,可以用getchar();来清除,这里getchar();只是取得了’\n’但是并没有赋给任何字符变量,所以不会有影响,相当于清除了这个字符。
8.4.2 作用
1)从缓冲区读走一个字符,相当于清除缓冲区。
2)前面的scanf()在读取输入时会在缓冲区中留下一个字符’\n’(输入完按回车键所致),所以如果不在此加一个getchar()把这个回车符取走的话,接下来的scanf()就不会等待从键盘键入字符,而是会直接取走这个“无用的”回车符,从而导致读取有误。
8.5本章小结
Linux提供了少量的基于UnixI/O模型的系统级函数,它们允许应用程序打开、关闭、读和写文件,提取文件的元数据,以及执行I/O重定向。正式程序间的交互和通信使得hello能够通过printf显示在屏幕,通过getchar()结束自己的生命。
结论
hello从创建到结束经历了如下过程:
1)预处理器cpp将hello.c进行预处理,生成文本文件hello.i。
2)编译器ccl将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。
3)汇编器as将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序hello.o(ELF)。
4)链接器1d把hello.c中调用的库函数与hello.o合并。得到二进制文件hello,它是一个可执行目标文件。
5)bash接收到命令,操作系统通过fork为hello创建新进程。
6)操作系统通过execve加载并执行hello进程,期间把hello的数据映射到虚拟内存,cpu执行hello中的指令。
7)hello程序执行完毕后bash回收进程。
可以看出,hello的整个程序的生命周期中各个环节紧密配合,不仅仅涉及hello.c程序本身,同时需要有操作系统、共享库、编译器甚至底层硬件驱动的完美协作才能正确的执行。体现出计算机系统的复杂、精密和智能。

附件
1)hello.i:经预处理器(cpp)预处理后的文本。
2)hello.s:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。其中的每条语旬都以一种文本格式描述了一条低级机器语言指令。
3)hello.o:汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序(ELF)的格式,并将结果保存在目标文件hello.o中。
4)hello:链接器(1d)把hello.c中调用的库函数与hello.o合并。结果就得到hello文件,它是一个可执行目标文件,可以被加载到内存中,由系统执行。
5)a.out:由编译器GCC生成的可执行目标文件(gcc –m64 –no-pie –fno-PIC)
参考文献
[1] https://blog.csdn.net/softee/article/details/41256595 GOT和PLT原理简析
[2] https://blog.csdn.net/weixin_42432281/java/article/details/88392219 Linux下shell脚本:bash的介绍和使用(详细)
[3] https://segmentfault.com/a/1190000008215772 SHELL(bash)脚本编程六:执行流程
[4] https://blog.csdn.net/genghaihua/article/details/89450057 物理地址和逻辑地址
[5] https://blog.csdn.net/mzjmzjmzjmzj/article/details/84713351 通俗理解CPU中物理地址、逻辑地址、线性地址、虚拟地址、有效地址的区别
[6] https://www.jianshu.com/p/c78cdf6214b5 段页式访存——线性地址到物理地址的转换
[7] https://blog.csdn.net/weixin_30908649/article/details/99150371 浅析线性地址到物理地址的转换
[8] https://blog.csdn.net/weixin_44551646/article/details/98076863 C语言 getchar()原理及易错点解析
[9] https://www.cnblogs.com/pianist/p/3315801.html printf 函数实现的深入剖析
[10] 深入理解计算机系统(原书第3版)/(美)兰德尔·E.布莱恩特(RandalE.Bryant)等著;龚奕利,贺莲译.北京:机械工业出版社,2016

你可能感兴趣的:(程序人生——Hello P2P)