程序人生-Hello’s P2P

摘 要
本文介绍了在Linux操作系统下hello的整个生命周期。借助gcc,objdump等工具,对hello的预处理、编译、汇编、链接等过程进行分析。并对程序hello运行过程中的动态链接库调用、内存管理、系统级I/O等进行介绍。
关键词:预处理;编译;汇编; 链接;进程;内存管理;IO;

目 录

第1章 概述 - 4 -
1.1 Hello简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 5 -
1.4 本章小结 - 5 -
第2章 预处理 - 6 -
2.1 预处理的概念与作用 - 6 -
2.2在Ubuntu下预处理的命令 - 6 -
2.3 Hello的预处理结果解析 - 6 -
2.4 本章小结 - 7 -
第3章 编译 - 8 -
3.1 编译的概念与作用 - 8 -
3.2 在Ubuntu下编译的命令 - 8 -
3.3 Hello的编译结果解析 - 8 -
3.4 本章小结 - 14 -
第4章 汇编 - 15 -
4.1 汇编的概念与作用 - 15 -
4.2 在Ubuntu下汇编的命令 - 15 -
4.3 可重定位目标elf格式 - 15 -
4.4 Hello.o的结果解析 - 19 -
4.5 本章小结 - 20 -
第5章 链接 - 21 -
5.1 链接的概念与作用 - 21 -
5.2 在Ubuntu下链接的命令 - 21 -
5.3 可执行目标文件hello的格式 - 21 -
5.4 hello的虚拟地址空间 - 26 -
5.5 链接的重定位过程分析 - 27 -
5.6 hello的执行流程 - 29 -
5.7 Hello的动态链接分析 - 30 -
5.8 本章小结 - 30 -
第6章 hello进程管理 - 31 -
6.1 进程的概念与作用 - 31 -
6.2 简述壳Shell-bash的作用与处理流程 - 31 -
6.3 Hello的fork进程创建过程 - 31 -
6.4 Hello的execve过程 - 32 -
6.5 Hello的进程执行 - 33 -
6.6 hello的异常与信号处理 - 33 -
6.7本章小结 - 35 -
第7章 hello的存储管理 - 36 -
7.1 hello的存储器地址空间 - 36 -
7.2 Intel逻辑地址到线性地址的变换-段式管理 - 36 -
7.3 Hello的线性地址到物理地址的变换-页式管理 - 37 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 42 -
7.5 三级Cache支持下的物理内存访问 - 43 -
7.6 hello进程fork时的内存映射 - 44 -
7.7 hello进程execve时的内存映射 - 45 -
7.8 缺页故障与缺页中断处理 - 46 -
7.9动态存储分配管理 - 47 -
7.10本章小结 - 50 -
第8章 hello的IO管理 - 51 -
8.1 Linux的IO设备管理方法 - 51 -
8.2 简述Unix IO接口及其函数 - 51 -
8.3 printf的实现分析 - 52 -
8.4 getchar的实现分析 - 53 -
8.5本章小结 - 54 -
结论 - 14 -
附件 - 15 -
参考文献 - 16 -

第1章 概述
1.1 Hello简介
P2P:C语言源代码文件(Program)hello.c被GCC编译器的驱动程序读取,通过四个步骤被翻译为可执行文件hello:
预处理阶段:C预处理器根据#include命令和#define声明拓展源代码,生成了另一个C程序hello.i;
编译阶段:编译器(ccl)产生源文件的汇编代码hello.s;
汇编阶段:接下来,汇编器(as)会将汇编代码hello.s转化成二进制目标代码hello.o;
链接阶段:最后,链接器(ld)将目标代码文件hello.o与实现库函数的代码合并,并生成最终的可执行代码文件hello。
之后执行该文件会将该文件加载在内存中,由系统执行。这时,在shell中运行它,OS(进程管理)会通过fork来为其创建一个新的进程(Process)。
这样就完成了P2P(From Program to Process)过程。

O2O:可执行文件hello运行时shell会为其分配对应的虚拟内存空间。在开始运行进程的时候分配并载入物理内存,开始执行hello的程序,在CPU的帮助下,它的指令被一步步执行,成功在屏幕上输出程序运行完成后系统会回收hello进程并且删除内存中对应的数据,完成O2O(From Zero-0 to Zero-0)过程。
1.2 环境与工具
1.2.1 硬件环境
联想拯救者y7000
CPU: Intel®_Core™i7-9750HF_CPU@_2.60GHz
内存(RAM): 16GB
硬盘: WDC WDS100T2B0C-00PXH0
1.2.2 软件环境
Windows 10 家庭中文版
Ubuntu 18.04 LTS 64位
1.2.3 开发工具
gcc (GCC) 9.2.0
GNU Make 4.2.1
GNU Emacs 26.3
1.3 中间结果
hello.i 预处理后修改了的源程序
hello.s 汇编生成的hello的汇编程序
hello.o 编译生成的hello的可重定位目标程序
hello 链接生成的hello的可执行目标程序
asm.txt hello.o的反汇编文件
1.4 本章小结
本章简述了hello的P2P,O2O过程,介绍了编写本文时的工作环境。

第2章 预处理
2.1 预处理的概念与作用
概念:预处理就是预处理器根据#标识的命令(头文件、宏定义、条件编译等),修改原始c代码,将包含的头文件插入到c代码中,并将宏定义进行替换,去除注释等,形成一个.i文本文件。
作用:1.程序的预处理过程就是将预处理指令(可以简单理解为#开头的正确指令)转换为实际代码中的内容(替换) 2.#include<stdio.h>,这里是预处理指令,包含头文件的操作,将所包含头文件的指令替代 3.如果头文件中包含了其他头文件,也需要将头文件展开包含
2.2在Ubuntu下预处理的命令
cpp hello.c > hello.i

图21 hello.c的预处理
2.3 Hello的预处理结果解析

图2-2 hello.c

图2-3 hello.i
可以观察到,预处理后文件变大了很多,原本的#include全部消失,变为stdio.h等头文件的代码。但主体代码缺没有任何改变。
2.4 本章小结
本章节简单介绍了c语言在编译前的预处理过程,简单介绍了预处理过程的概念和作用,对预处理过程进行演示,并举例说明预处理的结果还有解析预处理的过程。

第3章 编译
3.1 编译的概念与作用
编译:即编辑器将某种编程语言转化为汇编代码的过程。在本例中就是将hello.i转化为hello.s。
作用:将高级语言源程序翻译成等价的目标程序,并且进行语法检查、调试措施、修改手段、覆盖处理、目标程序优化等步骤。
3.2 在Ubuntu下编译的命令
gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s

图3-1 编译指令
3.3 Hello的编译结果解析
3.3.1.数据
本例中,hello.c中数据类型有:整型,字符串和数组。
3.3.1.1.整型
C程序的全局变量,已初始化的存放在.data节,未初始化的或初始化为0的存放在.bss节。hello中没有全局变量。
对于局部变量,程序要么存在寄存器中,要么存在用户栈中,函数返回时恢复栈帧。例如,在hello.s中,循环变量i采用了存放在用户栈中的方法(如图3-2所示),-4(%rbp)就是循环变量i。

图3-2 临时变量i
3.3.1.2.字符串
hello.c中有两个字符串,即:
1.printf(“Usage: Hello 学号 姓名!\n”);
2.printf(“Hello %s %s\n”, argv[1], argv[2]);
字符串都声明在.section与.rodata中,在hello.s中以如下形式出现:

图3-3 字符串在汇编代码的存储
3.3.1.3.数组
main函数中的第二个参数char *argv存储在栈上。在汇编代码中,存放在-32(%rbp)。

图3-4 argv在汇编代码的体现
3.3.2.赋值
赋值操作一般用movq指令实现。如hello.s中,就将循环变量i的初始值设为0(图3-3的第二个红框)。
已初始化全局变量的初始值直接保存在.data段内,无需mov指令。
3.3.3.算术操作
常见的算数操作指令如图3-5所示:

图3-5 算数操作汇编指令

hello.s中只有一处算数操作,即i++运算。在汇编代码中用addl指令实现。

图3-6 算数操作汇编指令
hello.s中无逻辑运算操作。
3.3.4.关系操作
hello.c中有用到的关系操作有:“!=”和“<”。
(1)“!=”运算 比较通过cmp来实现,指令根据两个操作数之间的差值来设置条件码。如果两个操作数相等,则标记条件码ZF=1,表示两个数是相等的。如果第一个操作数比第二个小,则设置条件码SF=1,表示比较结果为负数,计算机会根据这些条件码来决定跳转。所以“!=”通过如下代码实现:

图3-7 不等语句在汇编代码的体现
(2)“<”运算 类比与“!=”实现:

图3-8 小于语句在汇编代码的体现
3.3.5.数组操作
hello.c中,argv是char*型的数组。argv作为hello的第二个参数,其首元素地址存放在寄存器%rdi中,之后被放进栈空间中的-32(%rbp)位置。引用数组元素时,用“基地址加偏移量”的方式寻址,如图3-4所示。

图3-9 hello中argv数组的引用方式
3.3.6.控制转移
第一处是判断argv是否等于3,若不等于,则继续执行,若等于,则跳转至L2处(循环前对i初始化)继续执行。

图3-10 第一处控制转移
第二处是对i初始化为0后的无条件跳转,以跳到L4,即循环部分代码。

图3-11 第二处控制转移
第三处是判断是否达到循环终止条件(i<10),这里用i与9进行比较,若小于等于则跳回L4重复循环,否则执行循环外的下一步。这里将i<10的比较改为了与其等价的i<=9。

图3-12 第三处控制转移
3.3.7.函数操作
hello.c中涉及函数main,printf,exit,sleep,atoi和getchar。
(1)main函数
main是整个程序的入口,系统传入参数argc,argv,通过寄存器%rdi(%edi)和%rsi传入,在开始时将这两个参数圧入栈中便于使用寄存器,由系统调用。如下:

图3-13 main函数将参数压入栈在汇编代码的体现
最后main函数返回值为0,通过%rax(%eax)。

图3-14 main函数返回语句在汇编代码的体现
(2)prinft函数
第一次调用及其汇编代码:

图3-16 第一次调用printf语句及其汇编代码
其中$.LC0就是字符串"Usage: Hello 学号 姓名!\n"对应的地址。
由于原字符串以’\n’结束,且printf没有其他参数用于格式化,所以编译器优化为puts函数。
第二次调用及其汇编代码:

图3-17 第二次调用printf语句及其汇编代码
由于argv已经被圧入栈了,所以要通过-32(%rbp)+bias来访问。然后我们就可以将参数放入%rdi(%edi)、%rsi和%rdx三个寄存器中。
(3)exit函数
exit的作用是直接结束程序,同样通过%edi传入参数,就是整个程序的返回值。

图3-18 调用exit语句的汇编代码
(4)sleep函数
sleep传入的参数是atoi的返回值,通过%edi传入。

图3-19 调用sleep语句的汇编代码
(5)getchar函数
getchar可以直接调用,会返回输入的字符。其结果保存在寄存器%eax中。。

图3-20 调用getchar语句的汇编代码
3.4 本章小结
本章显示简述了编译的概念和作用,具体分析了一个c程序是如何被编译器编译成一个汇编程序的过程,还详细分析了不同的c语句和翻译成汇编语句之后的表示方法。

第4章 汇编
4.1 汇编的概念与作用
概念:汇编器as将汇编程序hello.s文件转化为二进制机器码文件hello.o的过程叫做汇编,它把汇编指令打包成一种叫做可重定位目标程序的格式。
作用:利用汇编器将汇编指令翻译成二进制字节码,形成可执行可链接格式文件(即ELF文件),使之在链接后能够被机器执行。
4.2 在Ubuntu下汇编的命令
as hello.s -o hello.o

图4-1 汇编指令
4.3 可重定位目标elf格式
4.3.1.ELF文件信息
输入如下命令可以查看hello.o的ELF信息:
readelf -a hello.o
ELF是一种Unix二进制文件,它可能是可链接文件,也可能是可执行文件。图4-2概括了一个典型的ELF文件中的各类信息。

图4-2 ELF可执行文件的结构

4.3.2.ELF头
ELF头的信息如图4-3所示。
可以看到,ELF头以一个16字节的序列(7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00)开始,描述了使该文件得以运行的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器.语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移、以及节头部表的大小和数目。

图4-3 ELF头信息
4.3.3.节头部表
节头部表如图4-4所示。
描述了ELF文件中各节的基本信息,包括位置和大小等。此外,节的一般属性和功能,由旗标描述。为了保持内存对齐,各节往往还需要一定大小的对齐填充,这个大小也在表中。

图4-4 hello.o的节头部表
4.3.4.重定位节
接下来是重定位条目表,如图4-5所示。汇编器每遇到一个对最终位置的目标的引用,就会生成一个重定位条目。表有五列,分别是偏移量、信息、类型、符号值和符号名称+加数。

图45 重定位条目表
(1)偏移量,是指所引用的符号的相对偏移,或者说符号应该填在程序的哪个位置。例如,第二行中,puts的偏移量为0x00000000001b。这就相当于告诉链接器,需要修改开始于偏移量0x1b处的32位PC相对引用,使它在运行时指向puts函数。
(2)信息,包括符号和类型两部分,共占8个字节。其中,前4个字节表示符号,后4个字节表示类型。符号代表重定位到的目标在.symtab节中的偏移量,类型则包括相对地址引用和绝对地址应用。
(3)类型,就是对第二列中类型信息的翻译。
(4)符号值,就是符号代表的值。
(5)第五列分为两部分。符号名称是重定位目标的名字,可能是节名、变量名、函数名等;加数则是用于对被修改的引用值做偏移调整。
最后是.symtab节,即符号表。它保存了程序中所用的各种符号的信息,包括文件名、函数名、全局变量名、静态(私有)变量名等,如图4-6所示。

图46 符号表
4.4 Hello.o的结果解析
使用objdump反汇编的结果如下:

图4-7 hello.o的反汇编代码
与第3章的 hello.s进行对照我们会发现:
(1)伪指令消失
原本hello.s中的许多以’.'开始的伪指令,在反汇编代码中都消失了。
(2)条件分支变化
我们可以发现,在hello.s中跳转到的目标位置都是用.L3/.L4来表示的,在hello.o反汇编之后,这些目标被用具体的地址位置代替。
(3)函数调用变化
反汇编代码中的函数调用call指令是用相对地址寻址的,而在汇编码中中则是用函数名(符号)。这是因为hello.c源代码中调用的函数是共享库中的函数(如printf、getchar等),在动态链接器链接之前无法确定函数运行时的实际地址。所以,对于这些地址不确定的函数调用,在编译时要用符号占位,汇编时则要使用相对地址(偏移)。
(4)数据访问变化
汇编码中,访问全局变量时,使用段名称+%rip,在反汇编代码中则是X+%rip(其中X显示为全0,实际上是重定位条目)。这是因为.rodata段的地址也是在运行时方能确定,所以对.rodata中数据的访问也需要重定位。而设置重定位条目的工作是在汇编阶段完成,从而造成汇编码和返回编码的不同。
4.5 本章小结
本章介绍了汇编。汇编器(as)将汇编代码hello.s转化为可重定位目标文件hello.o,得到一个可以用于链接的二进制文件。通过readelf我们可以查看hello.o的elf信息和重定位信息。通过对比hello.o的反汇编和第3章的hello.s,对汇编过程有更深的理解。

第5章 链接
5.1 链接的概念与作用
概念:链接是将各种代码和数据片段收集并组合称为一个单一文件的过程。
作用:链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能。
5.2 在Ubuntu下链接的命令
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

图5-1 链接指令
5.3 可执行目标文件hello的格式
输入如下命令可以查看hello的ELF信息:
readelf -a hello
ELF头信息如图5-2所示

图52 hello的ELF头
各节信息如图5-3所示。信息包括各节的名称、大小、属性和相对偏移量等。

图53 hello的ELF各节信息
程序头部表如图54所示。

图54 hello的程序头部表
段映射和动态节项目信息如图55所示。

图55 hello的段映射和动态节项目
重定位条目信息如图56所示。

图56 hello的重定位条目
符号表信息如图57所示。

图57 hello的符号表(部分)
5.4 hello的虚拟地址空间
用edb打开hello。如图58所示,从Data Dump窗口中不难看出,hello隔断的虚拟地址空间被限制在0x400000到0x401000之间。

图5-8 hello的虚拟内存
不难发现,Data Dump中展示的虚拟内存内容和readelf展示的节表是相对应的,根据图52得到的各节起始地址,可以在edb中查找得到对应内容,如图59所示。

图59 ELF节信息与Data Dump对照展示
5.5 链接的重定位过程分析
输入objdump -d -r hello得到反汇编代码。

图5-10 hello的反汇编代码
对比与反汇编hello.o得到的代码可以发现:
(1)hello的反汇编代码含有更多的函数。hello.o的反汇编代码中只有一个函数main,而在hello的反汇编代码中还出现了_init,.plt,puts@plt等函数。很多外部的被hello.c调用的函数以及一些初始函数(如_init)都被链接到hello中。
(2)函数调用变化。在hello.o中函数的地址是不确定的,但是在hello中外部函数调用的地址确定,不再是0。如puts的地址就明确为0x401030。

图5-11 函数调用对比
(3)数据引用变化。和函数类似,hello.o中一些数据的地址是不确定的,但是在hello中它们的地址确定了。如.rodata+0x22的地址就明确为0x40202e。
链接主要分为两个过程:符号解析和重定位。
1)符号解析:目标文件定义和引用符号,符号解析将每个符号引用和一个符号定义关联起来。
2)重定位:编译器和汇编器生成从0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执行这样的重定位。
通过上面的分析可以得出在重定位过程中,链接器在完成符号解析以后,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。然后就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时的地址。
5.6 hello的执行流程
使用edb执行hello,观察从加载hello到_start,到main,以及程序终止的所有过程。下面列出调用与跳转的子程序名和子程序地址,如表51所示。
程序名 程序地址
加载hello
ld-linux-x86-64.so!_dl_start 0x00007ffff7fd4d30
ld-linux-x86-64.so!_dl_init 0x00007ffff7fe27b0
hello!_start 0x0000000000401090
hello!__libc_csu_init 0x00000000004010d0
hello!_init 0x0000000000401000
libc.so!_setjmp 0x00007ffff7e08b10
程序运行
hello!main 0x0000000000401149
hello!puts@plt 0x0000000000401030
ld-linux-x86-64.so!_dl_runtime_resolve_xsave 0x00007ffff7fe87a0
ld-linux-x86-64.so!_dl_fixup 0x00007ffff7fe1de0
ld-linux-x86-64.so!_dl_lookup_symbol_x 0x00007ffff7fdd610
退出程序
hello!exit@plt 0x0000000000401070
libc.so!exit 0x00007ffff7e0b840
hello!_fini 0x00000000004011d4
表51 hello从加载到终止过程中调用或跳转的主要子程序
5.7 Hello的动态链接分析
函数调用一个由共享库定义的函数时,编译器无法预先判断出函数的地址,因为定义它的共享模块在运行时可以加载到任意位置。GNU编译系统使用延迟绑定的方式解决该问题,在运行时动态载入。
现代编译器会将共享模块的代码段编译为位置无关代码(PIC)。这种代码可以加载到内存的任何位置而无需连接器修改。这样一来,多个进程就可以共同使用共享模块的同一副本。
延迟绑定是通过两个数据结构之间简洁但乂有些复杂的交互来实现的,这两个数据结构是:全局偏移量表(GOT)和过程链接表(PLT)。如果一个目标模块调用了定义在共享库中地函数,那么它就有自己的GOT和PLT。GOT是数据段的一部分,而PLT是代码段的一部分。
通过readelf获得的节头部表可以知道.got.plt的起始位置为0x0000000000404000。在调用_dl_start之前对应的内存为:

图5-12 _dl_start调用前.got.plt对应内存
对应的内存全为0,而_dl_start之后:

图5-13 _dl_start调用后.got.plt对应内存
对应的内存有了动态链接器在解析函数地址时会使用的信息。
5.8 本章小结
本章展示了hello.o经过链接成为hello可执行文件的全过程,着重介绍了重定位和动态链接的流程,深入学习了hello.o 可重定位文件到hello可执行文件的流程,和链接的各个过程介绍了链接器如何将hello.o可重定向文件与动态库函数链接起来。

第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是一个具有独立功能的程序关于某个数据集合的一次运行活动。它可以申请和拥有系统资源,是一个动态的概念,是一个活动的实体。它不只是程序的代码,还包括当前的活动,通过程序计数器的值和处理寄存器的内容来表示。
作用:进程是计算机科学中最深刻、最成功的概念之一。它令我们得到一种假象:我们的程序好像是系统中当前运行的唯一的程序一样;我们的程序好像是独占地使用处理器和内存;处理器好像是不间断地执行着指令;我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
作用:Shell 是系统的用户界面,它为用户提供了一种与内核进行交互操作的接口,即接收用户输入的命令并把它送入内核执行。
处理流程:Shell首先读取用户的输入。然后分析输入内容,获得输入参数。如果是内核命令则直接执行,否则调用相应的程序执行命令。在程序运行期间,shell需要监视键盘的输入内容,并且做出相应的反应。
6.3 Hello的fork进程创建过程
父进程可以通过fork函数创建一个新子进程。函数原型为
pid_t fork(void);
新创建的子进程与父进程几乎完全相同。子进程得到与父进程用户级虚拟地址空间相同(但是独立的)一份副本,包括代码段和数据段、堆、共享库以及用户栈。子进程还会获得父进程所打开的文件描述符的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的差别在于它们有不同的 PID。
函数返回值分两种情形,父进程内返回子进程的PID,子进程内返回0。
fork函数有一些微妙的地方。
(1)调用一次,返回两次。父进程调用一次fork,有一次是返回到父进程,而另一次是返回到子进程的。
(2)并发执行。父进程和子进程是并发运行的独立进程,内核可以以任意方式交替执行它们的逻辑控制流中的指令。我们不能对不同进程中指令的交替执行做任何假设。
(3)相同但独立的地址空间。两个进程有相同的用户栈、运行时堆和本地变量值等,但它们对各自内存空间的修改是相互独立的。事实上,在物理内存中,一开始,两个进程指向的地址确实是相同的;但是,一旦一方对部分共享空间做了修改,这部分空间就会被拷贝出去,不再共享。这种技术被称作写时复制。写时复制会在7.6 节中详细阐述
(4)共享文件。子进程会继承父进程打开的所有文件。

6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。其原型为:

图62 execve函数原型
execve函数加载并运行可执行目标文件filename,带上参数列表argv和环境变量列表envp。函数返回值,只有出现错误返回-1,否则不返回。
execve加载了filename之后,程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段并重新初始化栈空间和堆空间。接着,CPU为新进程进程分配时间片执行逻辑控制流,跳转到程序的入口点,也就是_start函数的地址。_start函数调用系统启动函数__libc_start_main,该函数定义在libc.o中。它初始化执行环境,调用main函数,处理main函数的返回值。
main开始执行时,用户栈的组织结构如图62所示。可以看到,低地址部分有环境变量和参数字符串数组等,栈顶就是系统启动函数__libc_start_main。

图63 新程序开始时用户栈的典型组织结构

6.5 Hello的进程执行
系统中每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
内核为每个进程维护了一个上下文。当内核选择的一个新的进程运行时,我们说内核调度了这个进程。所以当内核调度了hello这个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换会首先保存当前进程的上下文,然后恢复新恢复进程被保存的上下文,最后控制传递给这个新恢复的进程 ,来完成上下文切换。过程如下:

图6-4 进程间切换
上图是对于上下文切换的剖析的一个实例。hello调用sleep和getchar函数时 都会有类似的上下文切换。
6.6 hello的异常与信号处理
hello执行时,可能产生如表61所示的四种类别的异常:
类别 原因 同步/异步 返回行为
中断 来自I/O设备的信号 异步 总是返回到下一条指令
陷阱 有意的异常 同步 总是返回到下一条指令
故障 潜在可恢复的错误 同步 可能返回到当前指令
终止 不可恢复的错误 同步 不会返回
表61 系统异常的四个类别
hello执行时,还可以发送或接收信号。信号是一种系统消息,它用于通知进程系统中发生了某种类型的事件,是一种更高层的软件形式的异常。不同的事件对应不同的信号类型。信号传送到目的进程由发送和接收两个步骤组成。信号的发送者一般是内核,接收者是进程。
发送信号可以有如下两种原因:
(1)内核检测到一个系统事件(如除零错误或者子进程终止);
(2)一个进程调用了kill函数,显式地要求内核发送一个信号给目的进程。
接收信号是内核强迫目的进程做出的反应。进程可以以默认方式做出反应,也可以通过信号处理程序捕获这个信号。每个信号只会被处理一次。
待处理信号指的是已经发送而没有接收的信号。任何时候,一种信号类型至多有一个待处理信号,即信号不会排队。
进程可以有选择性地阻塞接收某种信号。被阻塞的信号仍可以发出,但不会被目标进程接收。
信号种类繁多,现列举如表62所示。
编号 信号名称 默认行为 说明
1 SIGHUP 终止 终止控制终端或进程
2 SIGINT 终止 键盘产生的中断
3 SIGQUIT dump 键盘产生的退出
4 SIGILL dump 非法指令
5 SIGTRAP dump debug中断
6 SIGABRT/SIGIOT dump 异常中止
7 SIGBUS/SIGEMT dump 总线异常/EMT指令
8 SIGFPE dump 浮点运算溢出
9 SIGKILL 终止 强制进程终止
10 SIGUSR1 终止 用户信号,进程可自定义用途
11 SIGSEGV dump 非法内存地址引用
12 SIGUSR2 终止 用户信号,进程可自定义用途
13 SIGPIPE 终止 向某个没有读取的管道中写入数据
14 SIGALRM 终止 时钟中断(闹钟)
15 SIGTERM 终止 进程终止
16 SIGSTKFLT 终止 协处理器栈错误
17 SIGCHLD 忽略 子进程退出或中断
18 SIGCONT 继续 如进程停止状态则开始运行
19 SIGSTOP 停止 停止进程运行
20 SIGSTP 停止 键盘产生的停止
21 SIGTTIN 停止 后台进程请求输入
22 SIGTTOU 停止 后台进程请求输出
23 SIGURG 忽略 socket发生紧急情况
24 SIGXCPU dump CPU时间限制被打破
25 SIGXFSZ dump 文件大小限制被打破
26 SIGVTALRM 终止 虚拟定时时钟
27 SIGPROF 终止 剖析定时器期满
28 SIGWINCH 忽略 窗口尺寸调整
29 SIGIO/SIGPOLL 终止 I/O可用
30 SIGPWR 终止 电源异常
31 SIGSYS/SYSUNUSED dump 系统调用异常
表62 信号的种类

图6-5 Ctrl-Z
Ctrl-Z操作向进程发送了一个SIGTSTP信号,让进程暂时挂起,输入jobs、ps指令可以发现hello进程在后台挂起,通过fg指令可以恢复运行。

图6-6 Ctrl-C
Ctrl-C操作向进程发送了一个SIGINT信号,让进程终止,输入jobs、ps指令可以发现hello进程已经被回收。
6.7本章小结
为了描述程序运行,进程的概念不得不提,它是计算机科学中最深刻、最成功的概念之一。进程为程序提供的抽象环境,使得进程可以同时地、并发地执行。
为了高效地描述系统中发生的各类事件,则需要用到信号,这是一种更高层级的软件形式的异常。利用信号,内核和进程之间得以高效地传递信息并对各类事件做出相应的反应。

第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:在计算机体系结构中逻辑地址是指应用程序角度看到的内存单元、存储单元、网络主机的地址,即hello.o里面的相对偏移地址。在x86系统中,有一种被称作“段式内存管理”的内存映射方式。在x86架构下,访问指令给出的地址(操作数)叫逻辑地址,也叫相对地址、有效地址。逻辑地址往往不同于物理地址,通过地址翻译器或映射函数可以把逻辑地址转化为物理地址。
线性地址:即虚拟地址。
虚拟地址:虚拟地址是程序用于访问物理内存的逻辑地址,即线性地址,在hello中为虚拟地址,等于逻辑地址加上基地址。逻辑地址可转化为线性地址,其地址空间是一个非负整数地址的有序集合,如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间。
物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有唯一的物理地址,它是指在地址总线上、以电子形式存在的、使得数据总线可以访问主存的某个特定存储单元的内存地址。在hello程序中,他就表示了这个程序运行时的一条确切的指令在内存地址上的具体哪一块进行执行。
7.2 Intel逻辑地址到线性地址的变换-段式管理
内存分段是为了支持多任务并发执行,每一个任务对应各自的段空间,段之间支持保护访问限制,实现了程序和数据从物理地址空间到虚拟地址空间的重映射,从而达到隔离的效果。
如上所述,在段式内存管理中,程序的地址空间被划分为若干段,每个进程都有一个“二维”的地址空间。系统为每个段分配一个连续分区,而进程中的各个段可以不连续地存放在内存的各个分区中。程序加载时,操作系统为所有段分配其所需内存,这些段不必连续,物理内存的管理采用动态分区的管理方法。
程序过来一个逻辑地址,使用其段标识符(也即段选择符)的Index字段去索引段描述符表,若TI=0,索引全局段描述符表,TI=1,索引局部段描述符表,表的地址在相应的寄存器中。通过Index字段和段描述符表的位置能找到某项具体的段描述符。将段描述符中的base字段和逻辑地址中的offset字段合并即得到了线性地址。
按照Intel的本意,全局的用GDT,每个进程自己的用LDT——不过Linux则对所有的进程都使用了相同的段来对指令和数据寻址。即用户数据段,用户代码段,对应的,内核中的是内核数据段和内核代码段。[1]中有介绍,四个段的基地址全为0。这样,给定一个段内偏移地址,按照前面转换公式,0 + 段内偏移,转换为线性地址,可以得出重要的结论,“在Linux下,逻辑地址与线性地址总是一致的,即逻辑地址的偏移量字段的值与线性地址的值总是相同的。”所以如果做linux下内核开发,对于上述的x86的段式管理可以完全不用理会,我们可以认为linux根本没有用intel弄出来的这个段式管理,而是以页式管理完成了所有的内存管理工作。

图71 段式内存管理的地址变换
需要特别注意的是,段式内存管理是Intel x86系统的产物。amd64架构虽然支持x86所有形式的段,但在64位模式下,段式的设计已被取消,转而使用平坦内存模型。在这种模型下,分段机制虽仍然存在,但所有的段基址都是0,段大小被忽略。这就使得逻辑地址可以访问处理器支持的所有虚拟内存空间。也就是说,amd64架构中,段式地址转换形同虚设,逻辑地址(相对地址、有效地址)与虚拟地址(线性地址)是相同的。为了表述方便,接下来针对amd64架构的叙述,全部使用“虚拟地址”一词。
7.3 Hello的线性地址到物理地址的变换-页式管理
7.3.1 基本原理
虚拟内存系统将程序的虚拟地址空间划分为固定大小的虚拟页(每个大小为P=2^p),物理内存被划分为同样大小的物理页(也被称作页帧,大小也为P字节)。在页式储存管理方式中地址结构由两部构成,前一部分是虚拟页号(VPN),后一部分为虚拟页偏移量(VPO)。
在任意时刻,虚拟页面的集合都分为三个不相交的子集:
(1)未分配页。虚拟内存系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间
(2)缓存页。当前已缓存在物理内存中的已分配页
(3)未缓存页。未缓存在物理内存中的已分配页
从存储位置上看,物理内存存储在主存中,而虚拟内存存储在磁盘中。
7.3.2 页表
同任何缓存一样,虚拟内存系统必须有某种方法来判定一个虚拟页是否缓存在DRAM中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM中,替换这个牺牲页。
这些功能是由软硬件联合提供的,包括操作系统软件、MMU(内存管理单元)中的地址翻译硬件和一个存放在物理内存中叫做页表的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页。图72展示了一个页表的基本组织结构。

图72 页表
页表就是一个页表条目的数组。虚拟地址空间中的每个页在页表中的各固定偏移量处都有一个PTE。都有一个为方便理解,我们假设每个页表条目都是由一个有效位和一个n位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位为真,那么地址字段就表示DRAM中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。
注意,因为DRAM缓存是全相联的,所以任意物理页都可以包含任意虚拟页。
7.3.3 页命中
为了访问物理页,CPU中的地址翻译硬件会先把虚拟地址作为索引去访问页表。若对应的表项有效位为真,那么,该项中的地址字段就是我们所要的物理页的起始地址。以图7-3中的虚拟页2为例,由于对应页表条目有效位是1,所以发生页命中,即这个页已经被缓存到主存中了。于是,直接访问地址字段指向的物理内存物理页2即可。

图73 页命中
7.3.4 缺页
在虚拟内存的习惯说法中,DRAM缓存不命中被称为缺页(page fault)。图7-4展示了在缺页之前我们的示例页表的状态。CPU引用了VP 3中的一个字,VP 3并未缓存在 DRAM中。地址翻译硬件从内存中读取PTE 3,从有效位推断出VP 3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP 3中的VP 4。如果虚拟页4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改虚拟页4的页表条目,反映出VP 4不再缓存在主存中这一事实。
接下来,内核从磁盘复制VP 3到内存中的PP 3,更新PTE 3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在VP 3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了,图75展示了在缺页后我们的示例页表的状态。

图74 缺页后的页表状态
7.3.5 地址翻译
从形式上说,地址翻译要做的是把N元素的虚拟地址空间(VAS)映射到M元素的物理地址空间(PAS)中。如果虚拟地址A处的数据在物理地址A’处,那么映射值就是A’;否则,若A处的数据不在物理内存中,映射值就是空。

图75 地址翻译流程
图75展示了MMU如何利用页表来实现这种映射。CPU中的一个控制寄存器,页表基址寄存器指向当前页表。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO)和一个(n - p)位的虚拟页号(VPN)。MMU利用VPN来选择对应的页表项目。将页表条目中物理页号(PPN)和虚拟地址中的VPO串联起来,就得到相应的物理地址。注意,因为物理和虚拟页面都是p字节的,所以物理页面偏移(PPO)和VPO是相同的。
图76a展示的是页命中时CPU硬件执行的步骤:
(1)处理器生成一个虚拟地址,并把它传送给MMU
(2)MMU生成对应的页表项目地址,并访问高速缓存或主存获得其内容
(3)高速缓存或主存向MMU返问页表项目
(4)有效位为真,MMU构造物理地址,并把它传送给高速缓存或主存
(5)高速缓存或主存返冋所请求的数据字给处理器
页命中完全由硬件处理,而缺页则需要硬件和操作系统内核协作完成。如图76b所示:
(1)与页命中的第1步操作相同
(2)与页命中的第2步操作相同
(3)与页命中的第3步操作相同
(4)页表项目的有效位是0,MMU触发异常,CPU的控制传送到操作系统内核中的缺页异常处理程序
(5)缺页处理程序确定出物理内存中的牺牲页。如果这个页被修改过,还需要把它换出到磁盘
(6)缺页处理程序页面调入新的页,并更新内存中的页表项目
(7)缺页处理程序返回到原来的进程,再次执行那个导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。现在,所需的虚拟页已经缓存在物理内存中了,所以会发生页命中。在MMU执行了图7-6a中的步骤之后,主存就会将所请求字返回给处理器

图76 页命中和缺页的操作图
7.4 TLB与四级页表支持下的VA到PA的变换
36位VPN 被划分成四个9 位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含Ll页表的物理地址。VPN 1提供到一个Ll PET的偏移量,这个PTE包含L2页表的基地址。VPN 2 提供到一个L2 PTE 的偏移量,以此类推。

图7-7 四级页表支持下的VA到PA的变换
7.5 三级Cache支持下的物理内存访问
我们通过MMU获得对应的物理内存地址后,就需要在物理内存中得到对应的数据。此时我们会通过高速缓存来加速数据读取。首先L1高速缓存会通过地址解析出缓存的索引和偏移,对缓存进行访问,匹配标记查找是否含有相关的字,如果命中,则将数据发送给CPU,如果没有命中,则访问L2缓存,依次类推,直到主存,然后取出这个字,存入高一级缓存,最后返回数据给CPU。

图7-8 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
当fork函数被shell调用时,内核为hello创建各种数据结构,并分配一个唯一的PID。创建当前进程的mm_struct,vm_area_struct和页表的原样副本。两个进程的每个页面都标记为只读页面。两个进程的每个vm_area_struct都标记为私有,这样就只能在写入时复制。
当fork在hello进程中返回时,hello现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新的页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

图7-9 私有的写时复制对象
7.7 hello进程execve时的内存映射
虚拟内存和内存映射在将程序加载到内存的过程中也扮演着关键的角色。假设运行在当前进程中的程序执行了如下的execve调用
execve(“hello”, NULL, NULL);
正如6.4 节所说,execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2)映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text 和.data区。图717概括了私有区域的不同映射。
(3)映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C库libc. so,那么这些对象都先是动态链接到程序,再映射到用户虚拟地址空间中的共享区域内的。
(4)设置程序计数器PC。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的人口点。
下一次调度这个进程时,它将从这个人口点开始执行。Linux将根据需要换人代码和数据页面。

图710 加载器映射用户地址空间区域的方法
7.8 缺页故障与缺页中断处理
假设MMU在试图翻译某个虚拟地址A时,触发了缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤(如图711所示):
(1)虚拟地址A是合法的吗?换句话说,A在某个区域结构定义的区域内吗?缺页处理程序会搜索区域结构的链表,把A和每个区域结构中的vm_start和 vm_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。这个情况在图711标识为“1”。
(2)试图进行的内存访问是否合法?换句话说,进程是否有读、写或者执行这个区域内页面的权限?例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。这个情况在图711标识为“2”。
(3)若不满足以上两种情形,内核明白这个缺页是由于对合法的虚拟地址进行合法的操作造成的。接下来,内核会遵循7.3.3 节中的步骤进行处理,这里不再重复。

图711 Linux缺页处理
7.9动态存储分配管理
7.9.1动态内存分配器
虽然可以使用低级的mmap和munmap函数创建和删除虚拟内存的区域,但使用动态内存分配器(dynamic memory allocator)更方便,也有更好的移植性。
动态内存分配器用于分配和维护一个进程的虚拟内存区域,称为堆(如图712所示)。堆在系统内存中向上生长。对于每个进程,系统维护着一个堆顶指针brk。

图712 虚拟内存区域——堆
分配器将堆视为不同大小的块(block)组成的集合。每个块就是一段连续的虚拟内存片(chunk),要么是已分配的(allocated),要么是空闲的(free)。已分配的块显式地保留给应用程序使用,空闲块留待分配。
1.显式分配器。显式分配器要求程序显式地释放已分配块。例如,C标准库就提供了malloc库这一显式分配器。它需要满足如下的约束条件:能够处理任意请求序列;立即响应请求;仅使用堆;块对齐;不修改已分配块。在满足这些限制条件的前提下,其分配吞吐速率越大、内存使用率越高,其性能越优秀。然而,这两个要求是冲突的。为了把握好平衡,就需要分配器有效地组织空闲块,精心设计放置、分割和合并的处理方式。
2.隐式分配器(也叫垃圾收集器)能够检测已分配块何时不再被程序使用,并将其自动释放。这种分配器将内存视为一张有向可达图,凡不能从根到达的节点都是垃圾节点,代表程序无法再访问使用的内存空间。垃圾收集器能够以某种方式维护这张图,释放不可达节点并将其返还给空闲链表,从而达到定期回收内存的目的。
7.9.2隐式空闲链表
我们可以将堆组织为一个连续的已分配块和空闲块的序列。一个块是由一个字的头部、有效载荷、可能的填充和一个字的脚部,其中脚部就是头部的一个副本。头部和脚部包含这个块的大小和是否空闲(1为已分配,0为空闲)。
空闲块是通过头部中的大小字段隐含地连接着的,这种结构为隐式空闲表。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块地集合。

图7-13 隐式空闲链表的一个块
7.9.3显式空闲链表
将堆组成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针。
一种方法是用后进先出(LIFO)的顺序来维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用。
一般而言,显式链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小快大小,也潜在的提高了内部碎片的程度。

图7-14 显式空闲链表的一个块
7.9.4分离空闲链表
一般的单向空闲链表,其查找和分配所需时间都是线性的。为了减少分配时间,可以按块大小对空闲块分类,每个类都单独维护一个链表,类中的块大小大致相同,这被称为“分离”的显式空闲链表。例如,可以按照2的幂来划分块大小:
{1}, {2}, {3, 4}, {5~8}, …, {1025~2048}, {2049~4096}, {4097~∞}
链表的头元素存储在静态数组中。链表内部的元素以块大小按升序排列。当分配器需要一个对齐后大小为n的块时,它就会搜索相应的空闲链表。如果不能找到合适的块与之相匹配,就搜索下一个链表,以此类推。
7.10本章小结
本章具体分析了存储管理。包括逻辑地址、线性地址、虚拟地址和物理地址、其中的转化过程、三级高速缓存、fork与execve和动态内存分配等。
其中,虚拟内存是重中之重,是计算机系统最重要的概念之一,可以为每个进程分配一个独立的虚拟内存空间而不不会受到其他进程影响,而动态内存分配器则可以提高内存的利用率和效率。

第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m个字节的序列:
B0, B1, …, Bk, …, Bm-1
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
所有的输入和输出都能以一种统一且一致的方式来执行:
(1)打开文件。一个应用程序要求通过内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个标识符。
(2)Linux shell创建的每个进程开始时都有三个打开的文件:标准输入、标准输出和标准错误。
(3)改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开始的字节偏移量。应用程序能够通过执行seek操作,现显式地设置文件的位置为k。
(4)读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of-file的条件,应用程序能够检测到这个条件。在文件结尾处并没有明确的“EOF符号”。
(5)关闭文件。当应用程序完成了对文件的访问后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
Unix I/O函数原型有:
(1)int close(int fd)
该函数可以关闭一个打开的文件,其中fd 是需要关闭的文件的描述符。
(2)int open(char* filename, int flags, mode_t mode)
进程通过调用 open 函数来打开已存在的文件或是创建新文件。
(3)size_t write(int fd, const void *buf,size_t n)
该函数从内存位置复制至多n个字节到描述符为fd的当前文件位置。
(4)size_t read(int fd, void *buf, size_t n)
该函数从描述符为fd的文件位置赋值最多n个字节到内存位置buf。返回-1表示出现错误,0表示EOF;否则返回值表示的是实际传送的字节数量。
8.3 printf的实现分析
printf函数实现如下:
1.int printf(const char *fmt, …) {
2. int i;
3. char buf[256];
4.
5. va_list arg = (va_list)((char *)(&fmt) + 4);
6. i = vsprintf(buf, fmt, arg);
7. write(buf, i);
8.
9. return i;
10.}

用vsprintf函数生成显示信息,实现如下:
1.int vsprintf(char *buf, const char *fmt, va_list args) {
2. char *p;
3. char tmp[256];
4. va_list p_next_arg = args;
5. for (p = buf; *fmt; fmt++) {
6. if (*fmt != ‘%’) {
7. *p++ = *fmt;
8. continue;
9. }
10. fmt++;
11. switch (*fmt) {
12. case ‘x’:
13. itoa(tmp, *((int *)p_next_arg));
14. strcpy(p, tmp);
15. p_next_arg += 4;
16. p += strlen(tmp);
17. break;
18. case ‘s’:
19. break;
20. default:
21. break;
22. }
23. }
24. return (p - buf);
25.}

write的汇编代码:
1.mov eax, _NR_write
2. mov ebx, [esp + 4]
3. mov ecx, [esp + 8]
4. int INT_VECTOR_SYS_CALL

write 函数中,%ecx中存储字符个数,%ebx中存储字符串首地址,int INT_VECTOR_SYS_CALL的意思是通过系统调用 sys_call。这个函数的功能就是不断地打印出字符,直到遇到\0。
追踪sys_call,得到其汇编实现如下:
1.sys_call:
2.call save
3. push dword [p_proc_ready]
4. sti
5. push ecx
6. push ebx
7. call [sys_call_table + eax * 4]
8. add esp, 4 * 3
9. mov [esi + EAXREG - P_STACKBASE], eax
10. cli
11. ret

接着,字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar函数的声明在stdio.h头文件中。一种实现如下:
1.int getchar() {
2. char c;
3. return (read(0, &c, 1) == 1) ? c : EOF;
4.}

这个getchar每次从标准输入中读取一个字符。具体来说,若当前I/O未被锁定,它就调用系统_IO_getc_unlocked内置宏,读取一个字符。
简单地说,我们用一个指针_IO_read_ptr指向缓冲区,用另一个指针_IO_read_end指向缓冲区的末尾。调用_IO_getc_unlocked时,先检查指针是否越界。如果没有,就返回_IO_read_ptr所指向的字符并自增_IO_read_ptr。若已越界,就调用_uflow(内部使用了系统read),用这个函数重新填充缓冲区并返回重新读入的字符。
对于异步异常和键盘中断的处理:键盘中断处理子程序;接受按键扫描码转成ASCII码,保存到系统的键盘缓冲区。也就是说,getchar等调用read系统函数,通过系统调用读取按键ASCII码,直到接受到回车键才返回。

8.5本章小结
本章主要介绍了Linux的IO设备管理方法、Unix IO接口及其函数,分析了printf函数和getchar函数。

结论
hello的一生包含如下阶段:
(1)预处理:将hello.c根据以字符#开头命令,修改原始c程序,得到hello.i。
(2)编译:将hello.i翻译为hello.s的汇编程序,中间对代码进行语法检查和优化。
(3)汇编:将hello.s翻译为二进制机器码,得到可重定位目标文件hello.o。
(4)链接:将hello.o同等动态库等连接,生成可执行目标文件hello。
(5)创建进程:通过shell运行hello程序。shell通过fork创建子进程,通过execve运行hello。
(6)访问内存:通过MMU将hello中的虚拟地址转换为实际的物理地址,再通过多级缓存读取数据。
(7)异常:程序执行过程中,如果从键盘输入Ctrl-C等命令,会给进程发送一个异常信号,然后通过信号处理函数对信号进行处理。
(8)结束:hello运行完后会由父进程(shell进程)回收,内核会删除对应的数据结构。
至此,我们对于hello的分析已经完成。
通过写大作业,将本学期大部分所学内容串联了起来。从一个hello.c的到详细的进程执行。这样,我们才能够真正理解硬件、操作系统和编译系统对应用程序的性能和正确性的影响,为将来设计和构造大型软件产品提供更基础、更底层的角度和思路。

附件
文件名称 文件作用
hello.c hello的C语言源代码
hello.i hello.c预处理生成的代码
hello.s hello.i编译生成的汇编代码
hello.o hello.s汇编生成的可重定位目标文件
hello hello.o链接生成的可执行文件
hello_o_objdump.asm hello.o的反汇编代码
hello_objdump.asm hello的反汇编代码
hello_o_elf.txt hello.o的ELF信息概述
hello_elf.txt hello的ELF信息概述

参考文献
[1] 兰德尔E.布莱恩特. 大卫R.奥哈拉伦.等 深入理解计算机系统[M]. 北京:机械工业出版社.2019.
[2] printf 函数实现的深入剖析 https://www.cnblogs.com/pianist/p/3315801.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值