HITICS2019大作业论文
注:这是无图的markdown版,完整PDF版请访问我的github仓库
计算机系统
大作业
题 目 程序人生 - Hello’s P2P
计算机科学与技术学院
2019年12月
摘 要
本文以程序员的视角,以hello从C源代码编译为可执行文件并加载执行的过程为线索,较全面地阐述了计算机系统编译和链接C程序的流程和原理,剖析了进程的概念和系统管理进程的策略,介绍了局部性原理、存储器金字塔的细节和系统I/O管理等内容。本文既是一篇体例完整的计算机系统主干知识综述,又是一部短小精悍的软硬件系统漫游指南
关键词:计算机系统;C语言;ELF文件;进程管理;计算机存储器
目 录
6.2 简述壳Shell-bash的作用与处理流程 - 38 -
7.2 Intel逻辑地址到线性地址的变换-段式管理 - 48
-
7.3 hello的线性地址到物理地址的变换-页式管理 - 49
-
7.4 TLB与四级页表支持下的VA到PA的变换 - 54
-
7.7 hello进程execve时的内存映射 - 62 -
概述
hello简介
- P2P - From Program to Process
在阐述“从程序到进程”之前,需要明确“程序”和“进程”的概念。
程序是指一组指示计算机或其他具有消息处理能力设备每一步动作的指令,通常用某种程序设计语言编写,运行于某种目标体系结构上。对于大部分现代程序员来说,初始的程序形态是用高级程序设计语言编写的代码文本,如C语言程序。
像C语言这样,要经过编译和链接成为计算机可解读的数字格式,然后才能加载运行的程序语言,叫作编译语言。未经编译就可运行的程序,通常称之为脚本程序(如各类Shell)或解释型语言(如Python)。本文主要阐述C语言程序在基于x86-64架构的Linux系统中的漫游过程。
进程是指计算机中被执行的程序实例。它包括程序代码及其运行状态。进程本身不是程序,而是一种把程序运行抽象化的描述方式。一个系统上可以同时运行多个进程,而每个进程都好像在独立地占用内存等硬件资源。
打个比方,程序就像是一个执政官(程序员)为城市(计算机系统)编制的众多施政计划(程序)。计划本身仅是纸上的字而已,只有当计划实施起来,看到实施计划的实际过程(进程),才能让计划发挥作用、评价计划的实际效能。
在Linux系统中,C语言程序需要先经过编译获得Unix可执行文件(编译还分为预处理、编译、汇编和链接等四个阶段),再用Shell命令将可执行文件加载到内存和系统中,实现从程序到进程P2P的转变。
- 020 - From Zero-0 to Zero-0
进程从开始执行到终止,可以说是从“零”始,到“零”终。
为执行hello程序,系统Shell首先fork一个子进程,再用execve函数加载目标程序,并用目标程序的进程取代当前进程。在这之前,在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段,栈空间和堆空间也都被重新初始化,其余各段也被替代。接着,CPU为新进程进程分配时间片执行逻辑控制流,系统为进程映射虚拟内存,然后跳转到程序的入口点,也就是_start函数的地址。这个函数是在系统目标文件ctrl.o中定义的,对所有C程序来说都是如此。_start函数调用系统启动函数__libc_start_main,该函数定义在libc.o中。它初始化执行环境,调用main函数,处理main函数的返回值,并在及程序结束后把控制返回给内核。
进程结束后,父进程将其回收,避免内存等资源可重定位目标的浪费。于是,系统进程状态恢复为hello执行前的模样,好像它不曾来过。这就是020。
环境与工具
硬件环境
神舟战神Z7-KP7GZ,Intel Core i7-8750H,GTX1060,8G。
软件环境
Windows:Microsoft Windows 10 家庭中文版 10.0.18362
Linux(虚拟机):Ubuntu 18.04.3 LTS amd64
Linux(双系统):Ubuntu 18.04.3 LTS amd64
开发工具
Windows:Notepad, Sublime Text 3 + gcc(MinGW) 8.2.0-3, Visual Studio 2019
Linux:Vim, Sublime Text 3, gcc, cpp, ld, as, ar, gdb, edb, readelf, objdump,
gprof
中间结果
文件名称 | 文件作用 |
---|---|
hello.c | hello的C语言源代码 |
hello.i | hello.c预处理生成的代码 |
hello.s | hello.i编译生成的汇编代码 |
hello.o | hello.s汇编生成的可重定位目标文件 |
hello | hello.o链接生成的可执行文件 |
表1‑1 中间结果文件名称及其作用说明
本章小结
本章阐述了程序和进程的概念,概述了hello进程诞生、运行、终止的过程,提供了本文实验中所使用的软硬件环境和开发工具等信息,还罗列了附加文件的名称及其作用说明。
预处理
预处理的概念与作用
正如其名,预处理指的是C语言代码在编译之前所做的准备工作(如图2-1所示),这些工作往往由预处理器完成。
图2‑1 预处理阶段示意图
预处理器处理预处理指令,这些指令总以#开头。预处理指令主要有四种,如表2‑1所示。
指令类别 | 例子 | 预处理器动作 |
---|---|---|
宏 | #define N (1 << 10) | 做替换 |
文件包含指令 | #include <stdio.h> | 将对应的头文件加载到目标文件中 |
条件编译指令 | #ifndef ONLINE_JUDGE | 根据判断条件加载或舍弃特定部分的代码 |
其他指令 | #undef N | 取消宏定义,指定启动/结束函数等 |
表2‑1 预处理指令表
预处理之后,会生成以.i为后缀名的文件。
在Ubuntu下预处理的命令
在实验目录下输入命令
$ cpp hello.c > hello.i |
---|
或
$ gcc –E hello.c –o hello.i |
---|
可得到如图2-2所示的预处理文件hello.i。
图2‑2 hello.c的预处理
Hello的预处理结果解析
查看hello.i,不难发现hello.c源代码中的main函数原封不动地挪到了hello.i的末尾(如图2-3所示),之前的三千余行则把hello.c包含的三个头文件完整地囊括了进来,这说明hello.i仍是一个C语言程序。
图2‑3 预处理文件(左)与源代码文件(右)对比1
经过额外测试,cpp预处理器对宏定义、条件编译指令也都符合表2-1中的说明,如图2-4所示。
图2‑4 预处理文件(左)与源代码文件(右)对比2
注意右边源代码的第1~2行和第6~8行。由于定义了TEST,所以 x = N
被纳入编译;又因为N是宏定义的1024,所以最终呈现在hello.i中的是 x = 1024。
本章小结
本章主要阐述了预处理的概念和作用,明确了各种预处理指令的处理方式,给出了预处理C程序的Linux命令,验证了预处理器的各项功能。
编译
编译的概念与作用
编译是指编译器将经过预处理之后的程序转换成特定汇编代码的过程。
编译的作用在于,它逐行检查源代码的语法规范,并将其转换为更低一层的汇编语言程序,汇编程序中的每条语句都以某种格式标准确切地描述了一条低级机器语言指令。
编译形成的汇编代码是平台特异的,即不同架构的系统平台下产生的代码可能不同。但另一方面,不同于机器字节码,汇编码对于人类又是可读的。因此,编译过程相当于在“机器运行”和“人类读写”之间架设了一座中间桥梁。
在Ubuntu下编译的命令
在实验目录下输入命令
$ gcc -S hello.i -o hello.s |
---|
可得如图3-1所示的汇编代码文件hello.s。
图3‑1 hello.c的编译
Hello的编译结果解析
常量
程序中有两处字符串常量,它们保存在.rodata节,如图3-2所示。
图3-2 .rodata节中的字符串常量
整型常量则被当作操作数,直接存储在.text节中,如图3-3所示。
变量
C程序的全局变量,已初始化的存放在.data节,未初始化的或初始化为0的存放在.bss节。hello中没有全局变量。
对于局部变量,程序要么存在寄存器中,要么存在用户栈中,函数返回时恢复栈帧。例如,在hello.s中,循环变量i采用了存放在用户栈中的方法(如图3-3所示),-4(%rbp)就是循环变量i。
图3-3 main函数的汇编码
数组
hello.c中,argv是char*型的数组。argv作为hello的第二个参数,其首元素地址存放在寄存器%rdi中,之后被放进栈空间中的-32(%rbp)位置。引用数组元素时,用“基地址加偏移量”的方式寻址,如图3-4所示。
图3-4 hello中argv数组的引用方式
赋值
赋值操作一般用movq指令实现。如hello.s中,就将循环变量i的初始值设为0(图3-3的第二个红框)。
已初始化全局变量的初始值直接保存在.data段内,无需mov指令。
算数操作
常见的算数操作指令如表3‑1所示。
指令 | 效果 | 描述 | |
---|---|---|---|
INC | D | D←D + l | 加1 |
DEC | D | D←D - l | 减1 |
NEG | D | D←-D | 取负 |
NOT | D | D←~D | 取补 |
ADD | S, D | D←D + S | 加 |
SUB | S, D | D←D - S | 减 |
IMUL | S, D | D←D * S | 乘 |
XOR | S, D | D←D ^ S | 异或 |
OR | S, D | D←D | S | 或 |
AND | S, D | D←D & S | 与 |
SAL | k, D | D←D << k | 左移 |
SHL | k, D | D←D << k | 左移(等同于SAL) |
SAR | k, D | D←D <<A k | 算术右移 |
SHR | k, D | D←D <<L k | 逻辑右移 |
lea | S, D | D←&S | 加载有效地址 |
表3‑1 算术操作汇总表
hello.s中只有一处算数操作,即循环变量i的自增运算,用addl指令实现,见图3-3的第三个红框。
hello.s中无逻辑运算操作。
关系操作和控制转移
hello.c中,关系操作用于if条件语句的判断和循环终止条件的检测(如图3-3中的第四个红框)。汇编指令中,cmp和test指令用于关系判断,如表3‑2所示。
指令 | 基于 | 描述 | |
---|---|---|---|
cmpb | Si, S2 | S2 - Si | 比较字节 |
cmpw | Si, S2 | 比较字 | |
cmpl | Si, S2 | 比较双字 | |
cmpq | Si, S2 | 比较四字 | |
testb | Si, S2 | S2 & Si | 测试字节 |
testw | Si, S2 | 测试字 | |
testl | Si, S2 | 测试双字 | |
testq | Si, S2 | 测试四字 |
表3‑2 关系操作汇总表
cmp指令和test指令只设置条件码(OF、ZF、CF等),不改变寄存器的值。利用设置后的条件码,即可进执行控制跳转jmp系列指令,如所示
指令 | 同义名 | 跳转条件 | 描述 | |
---|---|---|---|---|
jmp | Label | 1 | 直接跳转 | |
jmp | *Operand | 1 | 间接跳转 | |
je | Label | jz | ZF | 相等/零 |
jne | Label | jnz | ~ZF | 不相等/非零 |
js | Label | SF | 负数 | |
jns | Label | *SF | 非负数 | |
jg | Label | jnle | ~ (SF ^ OF) & ~ZF | 大于(有符号> ) |
jge | Label | jnl | - (SF ^ OF) | 大于或等于(有符号>=) |
jl | Label | jnge | SF ^ OF | 小于(有符号< ) |
jle | Label | jng | (SF ^ OF) | ZF | 小于或等于(有符号<=) |
ja | Label | jnbe | ~CF & ~ZF | 超过(无符号> ) |
jae | Label | jnb | ~CF | 超过或相等(无符号>=) |
jb | Label | jnae | CF | 低于(无符号< ) |
jbe | Label | jna | CF | ZF | 低于或相等(无符号<=) |
表3‑3 跳转指令汇总表
从表中可以看出,除jmp直接跳转指令以外的其他指令都是有条件的,它们根据条件码的某种组合,或者跳转,或者继续执行代码序列中下一条指令。
jmp跳转一般是PC相对的,也就是说,汇编码汇编为机器字节码时,它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。这些地址偏移量可以编码为l、
2或4个字节。
以图3-3的第四个红框里的cmpl指令及其后面的jle指令为例。程序用cmpl指令比较了常数7和循环变量i的大小关系,如果i≤7,就返回.L4标号处继续执行循环体,否则跳出循环,执行最后的getchar调用。
参数构造与过程调用
这里的过程调用主要指函数调用。hello.c中,除main函数本身以外,发生了4次函数调用,分别调用了printf、exit、sleep、getchar。
过程P调用过程Q,需遵循如下步骤:
-
传递控制。在进入过程 Q 的时候,程序计数器必须被设置为 Q
的代码的起始地址;返回时,要把程序计数器设置为 P 中调用 Q
后面那条指令的地址。 -
传递数据。P 必须能够向 Q 提供一个或者多个参数,Q 必须能够向 P 返回一个值。
-
分配和释放内存。在开始时,Q
可能需要为局部变量分配空间,而在返回前,又必须释放掉这些空间。这些空间往往是运行时栈。
以main函数本身为例,分析截图如图3-5所示。
图3-5 main函数调用分析
本章小结
本章阐述了编译的概念与作用,给出了Linux下将C源代码转化为汇编码的命令。本章用大量篇幅解析了hello的编译结果,涵盖了许多常见的指令。
汇编代码是平台特异的,但对于人类又是可读的。哪怕是语义极其相近的C语言代码,所编译出的汇编代码也可能大相径庭,从而可能使得它们的运行效率相差很大。许多程序优化策略,都是在这一步骤完成的。
迈过编译这道“桥梁”,接下来的任务是把汇编码翻译为机器二进制字节码。
汇编
汇编的概念与作用
汇编是指将编译后的汇编指令翻译成二进制字节码指令,并把保存在可重定向目标程序(后缀名为.o)中。
因此,汇编的作用就是利用汇编器将汇编指令翻译成二进制字节码,形成可执行可链接格式文件(即ELF文件),使之在链接后能够被机器执行。
在Ubuntu下汇编的命令
在实验目录下输入命令
$ as hello.s -o hello.o |
---|
或
$ gcc hello.s -c -o hello.o |
---|
可得到如图4-1所示的可重定向目标文件文件hello.o。
图4‑1 hello.s的汇编
可重定位目标ELF格式
ELF文件信息
输入如下命令可以查看hello.o的ELF信息:
$ readelf -a hello.o |
---|
ELF是一种Unix二进制文件,它可能是可链接文件,也可能是可执行文件。图4-2概括了一个典型的ELF文件中的各类信息。
图4‑2 ELF可执行文件的结构
ELF头
ELF头的信息如图4-3所示。可以看到,它以一个16字节的序列(7f 45 4c 46 02 01 01 00
00 00 00 00 00 00 00
00)开始,描述了使该文件得以运行的系统的字的大小和字节顺序。接下来是ELF头的大小、目标文件的类型、机器类型、字节头部表、如厚点、程序头起点以及节头部表中条目数量等信息,它们可以帮助链接器进行语法分析和解释目标文件等。
图4‑3 ELF头的信息
节头部表
如图4-4所示的节头部表描述了ELF文件中各节的基本信息,包括位置和大小等。此外,节的一般属性和功能,由旗标描述。为了保持内存对齐,各节往往还需要一定大小的对齐填充,这个大小也在表中。
图4‑4 节头部表
重定位条目表
接下来是重定位条目表,如图4-5所示。汇编器每遇到一个对最终位置的目标的引用,就会生成一个重定位条目。表有五列,分别是偏移量、信息、类型、符号值和符号名称+加数。
图4‑5 重定位条目表
-
偏移量,是指所引用的符号的相对偏移,或者说符号应该填在程序的哪个位置。例如,第二行中,puts的偏移量为0x00000000001b。这就相当于告诉链接器,需要修改开始于偏移量0x1b处的32位PC相对引用,使它在运行时指向puts函数。
-
信息,包括符号和类型两部分,共占8个字节。其中,前4个字节表示符号,后4个字节表示类型。符号代表重定位到的目标在.symtab节中的偏移量,类型则包括相对地址引用和绝对地址应用。
-
类型,就是对第二列中类型信息的翻译。
-
符号值,就是符号代表的值。
-
第五列分为两部分。符号名称是重定位目标的名字,可能是节名、变量名、函数名等;加数则是用于对被修改的引用值做偏移调整。
最后是.symtab节,即符号表。它保存了程序中所用的各种符号的信息,包括文件名、函数名、全局变量名、静态(私有)变量名等,如图4-6所示。
图4‑6 符号表
Hello.o的结果解析
机器字节码指令
机器语言的字节码指令也有严格的标准和格式。为了尽可能地提高信息密度,字节级编码需要用短短的几个字节映射到各种汇编语言指令。
以课本上的Y86-64架构为例(如图4‑7所示)。字节级指令的第一个字节表明了指令的类型。这个字节分为两部分,高4位是代码部分,低4位是功能部分。有的指令还有第二个字节,它代表指令操作的目标寄存器,每个寄存器(包括“无寄存器”,即空参数)都有唯一对应的寄存器标识符。少数指令在第一个或第二个字节后还会带有一个8字节操作数,代表目标地址或立即数。
图4‑7 Y86-64架构中汇编指令与机器字节码之间的映射
反汇编码与汇编码的对比
图4‑8 反汇编码(左)与汇编码(右)的对比
反汇编得到的代码(即hello.objdump,下称“反汇编码”)和由编译器生成的代码(即hello.s,下称“汇编码”)在指令内容上基本一致,但它们之间也有区别如下(代码对比如图4-8所示):
-
反汇编码中带有具体十六进制地址(在汇编指令左侧显示的部分),或者说相对于程序头的字节偏移量。这是汇编器汇编后根据二进制机器指令的字节数确定的,在这之前无法得知
-
反汇编码中的负数表示是由补码转换而来,因为负数转换为补码的过程发生在汇编阶段
-
反汇编代码中的函数调用call指令是用相对地址寻址的,而在汇编码中中则是用函数名(符号)。这是因为hello.c源代码中调用的函数是共享库中的函数(如printf、getchar等),在动态链接器链接之前无法确定函数运行时的实际地址。所以,对于这些地址不确定的函数调用,在编译时要用符号占位,汇编时则要使用相对地址(偏移)。
-
反汇编码中,分支转移控制使用相对地址寻址的,而在汇编码中则是使用符号。如(1)中所述,汇编之前无从得知目标指令的地址,因此,在汇编码中只能用符号代替。可是符号不能代替地址(这可是计算机系统中最基础最重要的概念之一,只有用地址才能有效寻址并生成可执行文件,离开地址99%的计算机活动都成了空中楼阁)。于是,在汇编之后,各指令的相对地址都已确定,就换用相对地址了。
-
汇编码中,访问全局变量时,使用段名称+%rip,在反汇编代码中则是X+%rip(其中X显示为全0,实际上是重定位条目)。这是因为.rodata段的地址也是在运行时方能确定,所以对.rodata中数据的访问也需要重定位。而设置重定位条目的工作是在汇编阶段完成,从而造成汇编码和返回编码的不同。
本章小结
本章概括了汇编的概念与作用,给出了Linux下将汇编代码文件汇编为ELF文件的命令和查看ELF文件基本信息的命令,分析了ELF文件的基本构成和功能,阐述了机器字节指令的构成方式,并对汇编码与反汇编码的不同之处做了辨析。
汇编是C源代码走向运行的重要一步。它的内容直接面向机器,不再考虑可读性、可维护性等“人的感受”。汇编的结果是ELF文件,它的全称是Executable
and Linkable
Format,也就是“可执行可链接格式”。类Unix系统把可执行和可链接格式化为一体,暗示了它们在系统层面的高度统一性。若把可运行文件像是一座可以自主运作的大型机器,那么C源代码就像是机器零件的图纸,编译器、汇编器就像是3D打印机,而可链接文件就是根据图纸打印出零件实体。接下来,就得把零件组装起来了,这也是链接器要做的工作。
链接
链接的概念与作用
链接是C源代码编译的最后一步,它是指为了生成可执行文件,将有关的目标文件连接起来,使得这些目标文件成为操作系统可以装载执行的统一整体的过程。例如,在A文件中引用的符号将同该符号在B文件中的定义连接起来,从而形成一个可执行的整体。
链接分为两种模式:
-
静态链接。静态链接时,外部函数的代码将从其所在的静态链接库中拷贝到最终的可执行程序中。这样,程序执行时,这些代码就会被装入到对应进程的虚拟内存空间里。这里的静态链接库实际上是一个目标文件的集合,其中的每个文件含有库中的一个或者一组相关函数的代码。
-
动态链接。动态链接中,外部函数的代码被放到动态链接库或共享对象的某个目标文件中(通常以.so为后缀名)。链接器在链接时所做的只是在生成的可执行文件中记下共享对象的名字等少量信息。在可执行文件运行行时,动态链接库的全部内容将被映射到相应进程的虚拟内存空间。动态链接程序将根据可执行程序中记录的信息找到相应的函数代码。
在Ubuntu下链接的命令
在实验目录下输入命令(框内为一行)
$ ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o |
---|
/usr/lib/x86_64-linux-gnu/crti.o
/usr/lib/gcc/x86_64-linux-gnu/7/crtbegin.o
hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/7/crtend.o
/usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
可得到如图5‑1所示的可重定向目标文件文件hello。
图5‑1 hello的链接
可执行目标文件hello的格式
输入如下命令可以查看hello的ELF信息:
$ readelf -a hello |
---|
ELF头信息如图5-2所示
图5‑2 hello的ELF头
各节信息如图5-3所示。信息包括各节的名称、大小、属性和相对偏移量等。
图5‑3 hello的ELF各节信息
程序头部表如图5‑4所示。
图5‑4 hello的程序头部表
段映射和动态节项目信息如图5‑5所示。
图5‑5 hello的段映射和动态节项目
重定位条目信息如图5‑6所示。
图5‑6 hello的重定位条目
符号表信息如图5‑7所示。
图5‑7 hello的符号表(部分)
hello的虚拟地址空间
用edb调试hello。如图5‑8所示,从Data
Dump窗口中不难看出,hello隔断的虚拟地址空间被限制在0x400000到0x401000之间。
图5‑8 hello的地址范围
不难发现,Data
Dump中展示的虚拟内存内容和readelf展示的节表是相对应的,根据图5‑2得到的各节起始地址,可以在edb中查找得到对应内容,如图5‑9所示。
图5‑9 ELF节信息与Data Dump对照展示
链接的重定位过程分析
hello与hello.o的区别
图5‑10 hello的main函数反汇编码
hello与hello.o有较大不同,现将主要区别列举如下
-
hello.o只有代码段.text,而hello经过链接成为可执行文件,多出了.init(初始化)、.plt(过程链接表)和.fini(结束段)等段
-
hello.o的指令“地址”仅仅是一个偏移量,而非真正的虚拟地址。链接之后,各指令就都具有了0x400开头的虚拟地址0x400666
-
不仅是指令有了地址,分支转移指令的操作数也由偏移量变成了地址
-
hello.o中待重定位的部分(如callq指令的操作数)全部换成了像函数地址这样的具体地址,如图5‑10所示
链接的过程
hello的链接由两部分构成,静态链接和动态链接。
静态链接。像 Linux LD
程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。输人的可重定位目标文件由各种不同的代码和数据节组成,每一节都是一个连续的字节序列。指令在一节中,初始化了的全局变量在另一节中,而未初始化的变量又在另外一节中。为了构造可执行文件,链接器必须完成两个主要任务:符号解析和重定位。
什么是符号,如何解析?目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量(即
C
语言中任何static属性声明的变量)。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
重定位是指什么?编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执行这样的重定位。
对于链接器来说,目标文件纯粹是字节块的集合。这些块中,有些包含程序代码,有些包含程序数据,而其他的则包含引导链接器和加载器的数据结构。链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。链接器对目标机器了解甚少。产生目标文件的编译器和汇编器已经完成了大部分工作。
动态链接与共享库。共享库是一种目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接,是由一个叫做动态链接器的程序来执行的。共享库在Linux系统中通常用.so后缀来表示。
共享库是以两种不同的方式来“共享”的。首先,在任何给定的文件系统中,一个库只有一个.so文件。所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中。其次,在内存中,共享库.text节的副本可以被不同的进程共享。在本文第7章中,会详细地讨论这个问题。
重定位的过程
链接器完成符号解析之后,就把代码中的每个符号引用和正好一个符号定义
(即它的某个输入目标模块中的一个符号表条目)关联起来。此时,链接器已经知道输入目标模块中代码节和数据节的确切大小,可以开始重定位了。重定位由两步组成:
-
节和符号定义的重定位。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输入模块的.
data节被全部合并成一个节,这个节成为输出的可执行目标文件的.
data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节、每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。 -
符号引用的重定位。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中的重定位条目。
下面,以printf为例,说明重定位后地址的算法。
图5‑11 符号printf在hello.o符号表中的信息
如图5-11所示,我们看到printf函数在hello.o中的偏移量是0x5a,类型是R_X86_64_PLT32,加数是4。
根据重定位PC相对引用算法,我们先计算引用的运行时地址
ADDRref = ADDRmain + bias = 0x400637 + 0x5a = 0x400691
图5‑12 printf的PLT表项
查看printf链接后的运行时PLT表项地址(如图5-12所示),再更新引用处的地址操作数
ADDRrefop = ADDRprintf + adder - ADDRref = 0x400500 - 0x4 - 0x400691 = -0x195
-0x195写成二进制补码,就是0xfffffe6b。对照hello的反汇编码,发现printf的callq指令的操作数,正是0xfffffe6b的小端形式(见图5-13的下方红框)。
其余的重定位条目也都类似,这里不再赘述。
图5‑13 printf的callq指令
hello的执行流程
使用edb执行hello,观察从加载hello到_start,到main,以及程序终止的所有过程。下面列出调用与跳转的子程序名和子程序地址,如表5‑1所示。
子程序名 | 子程序地址 | 备注 |
---|---|---|
ld-2.27.so!_dl_start | 0x7ffff7a05aa0 | 动态链接库初始化 |
ld-2.27.so!_dl_init | 0x7ffff7de5630 | |
hello!_start | 0x400550 | hello程序入口点 |
libc-2.27.so!__libc _start_main | 0x7ffff7a05ab0 | 系统启动函数 初始化执行环境 调用main 处理main的返回值 |
libc-2.27.so!_exit | 0x7ffff7df2030 | 终止进程(动态链接器) |
hello!_libc_csu_init | 0x400670 | |
hello!main | 0x40063b | hello主函数 |
hello!puts@plt | 0x4004f0 | hello调用 |
hello!exit@plt | 0x400530 | hello调用 |
hello!printf@plt | 0x400500 | hello调用 |
hello!atoi@plt | 0x400520 | hello调用 |
hello!sleep@plt | 0x400540 | hello调用 |
hello!getchar@plt | 0x400510 | hello调用 |
libc-2.27.so!_exit | 0x7ffff7df2030 | 终止进程(hello) |
表5‑1 hello从加载到终止过程中调用或跳转的主要子程序
Hello的动态链接分析
现代编译器会将共享模块的代码段编译为位置无关代码(PIC)。这种代码可以加载到内存的任何位置而无需连接器修改。这样一来,多个进程就可以共同使用共享模块的同一副本。
假设程序调用一个由共享库定义的函数。编译器没有办法预测这个函数的运行时地址,
因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。不过,这种方法并不是PIC,因为它需要链接器修改调用模块的代码段。GNU编译系统使用延迟绑定技术解决这个问题,——它将过程地址的绑定推迟到第一次调用该过程时。
使用延迟绑定的动机是对于一个像libc.so这样的共享库输出的成百上千个函数中,一个典型的应用程序只会使用其中很少的一部分。把函数地址的解析推迟到它实际被调用的地方,能避免动态链接器在加载时进行成百上千个其实并不需要的重定位。第一次调用过程的运行时开销很大,但是其后的每次调用都只会花费一条指令和一个间接的内存引用。
延迟绑定是通过两个数据结构之间简洁但乂有些复杂的交互来实现的,这两个数据结构是:全局偏移量表(GOT)和过程链接表(PLT)。如果一个目标模块调用了定义在共享库中地函数,那么它就有自己的GOT和PLT。GOT是数据段的一部分,而PLT是代码段的一部分。
表5‑1所示的第一项_dl_start函数会调用_dl_init_paths,初始化动态库搜索路径并加载之;再调用_dl_start_user,后者调用_dl_init_internal,初始化各个已加载的动态库。
用edb调试hello可执行文件。在_dl_start和_dl_init执行前,0x601000位置处的GOT表内容如图5-14所示。注意红框内的两个四字都是0。
图5-14 _dl_start和_dl_init执行前的GOT表
_dl_start和_dl_init执行后,红框内的两个四字值发生了改变。
图5-15 _dl_start和_dl_init执行后的GOT表
这两个四字代表什么呢?我们用objdump反编译hello,发现这他俩的地址被PLT[0]项引用了。
图5-16 PLT[0]的内容
对照PIC函数调用流程,我们不难看出,第一个四字,即GOT[1],代表动态链接器的参数,被压入栈中;第二个四字,即GOT[2],用于跳转进入动态链接器。
因此,
_dl_start和_dl_init做的主要是动态链接的准备工作。下面讨论进入main函数后,PIC函数调用的做法。
以puts函数为例。puts对应PLT表的第2项,即PLT[1]。它引用了GOT表的第四项GOT[3],地址为0x601018,如图5-17所示。
图5-17 puts函数对应的PLT[1]项内容
在第一次调用puts之前,GOT[3]是图5-17的第二行指令地址,如图5-18所示。也就是说,第一次调用时,程序将利用动态链接器绑定puts的运行时地址。
图5-18 puts调用前的GOT表
第一次调用结束后,GOT[3]的值就变成了48位的puts运行时地址,如图5-19所示。
图5-19 puts调用后的GOT表
此后程序再调用puts,就能通过GOT[3]直接跳转到动态库中的puts函数地址处了。
本章小结
本章展示了hello.o经过链接成为hello可执行文件的全过程,着重介绍了重定位和动态链接的流程。从历史上看,链接经历了从无到有、从静态到动态的发展过程。链接技术可以允许我们把大项目分解成小模块、小功能。当我们改变某个小模块时,只需简单地重新编译它并重新链接应用,而无需编译其他文件。利用共享库和动态链接,许多软件产品在运行时会使用共享库来升级压缩后的二进制程序,大大简化了分发流程。
hello进程管理
进程的概念与作用
进程就是“一个执行中的程序的实例”。
系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈空间、通用目的寄存器、程序计数器PC、环境变量以及打开文件描述符的集合等。进程给应用程序提供了关键的抽象,独立的逻辑控制流,和“每个程序都独占处理器和系统内存”的假象。
简述壳Shell-bash的作用与处理流程
Shell的定义和功能
Shell
是系统的用户界面,如图6‑1所示。它为用户提供了一种与内核进行交互操作的接口,即接收用户输入的命令并把它送入内核执行。Shell本身也是一个用C语言编写的程序,它是用户使用Linux的桥梁。因此,Shell
既是一种命令语言,又是一种程序设计语言。
图6‑1 Shell界面
Shell也是一个大家族,包括sh、csh、bash、zsh等许多种。大多数Linux发行版的默认Shell是bash。
Shell的功能在于解释命令,因此它也是一个命令解释器。Shell解析由用户输入的命令并且把它们送到内核。不仅如此,Shell本身也是一种程序设计语言,具有其他普通语言的很多特点(如也有分支结构、循环结构等),功能十分强大。Shell允许用户编写和运行由Shell命令组成的程序。
Shell的处理流程
Shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序。如果命令指定了程序的路径,Shell就会按图索骥,定位程序;否则,Shell会在搜索路径(由环境变量PATH给出)里寻找程序。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,就会显示错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。
hello的fork进程创建过程
父进程可以通过fork函数创建一个新子进程。函数原型为
pid_t fork(void);
函数返回值分两种情形,父进程内返回子进程的PID,子进程内返回0。
新创建的子进程与父进程几乎完全相同。子进程得到与父进程用户级虚拟地址空间相同(但是独立的)一份副本,包括代码段和数据段、堆、共享库以及用户栈。子进程还会获得父进程所打开的文件描述符的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的差别在于它们有不同的
PID。
fork函数既有趣又常常令人迷惑。
-
调用一次,返回两次。父进程调用一次fork,有一次是返回到父进程,而另一次是返回到子进程的。
-
并发执行。父进程和子进程是并发运行的独立进程,内核可以以任意方式交替执行它们的逻辑控制流中的指令。我们不能对不同进程中指令的交替执行做任何假设。
-
相同但独立的地址空间。两个进程有相同的用户栈、运行时堆和本地变量值等,但它们对各自内存空间的修改是相互独立的。事实上,在物理内存中,一开始,两个进程指向的地址确实是相同的;但是,一旦一方对部分共享空间做了修改,这部分空间就会被拷贝出去,不再共享。这种技术被称作写时复制。写时复制会在7.6
节中详细阐述 -
共享文件。子进程会继承父进程打开的所有文件。
hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。其原型为
int execve( | const char *filename, |
---|---|
const char *argv[], | |
const char *envp[]) |
execve函数加载并运行可执行目标文件filename,带上参数列表argv和环境变量列表envp。函数返回值,出现错误返回-1,否则不返回。
execve加载了filename之后,程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段并重新初始化栈空间和堆空间。接着,CPU为新进程进程分配时间片执行逻辑控制流,跳转到程序的入口点,也就是_start函数的地址。_start函数调用系统启动函数__libc_start_main,该函数定义在libc.o中。它初始化执行环境,调用main函数,处理main函数的返回值。
main开始执行时,用户栈的组织结构如图6‑2所示。可以看到,低地址部分有环境变量和参数字符串数组等,栈顶就是系统启动函数__libc_start_main。
图6‑2 新程序开始时用户栈的典型组织结构
hello的进程执行
用户模式和内核模式
为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制应用可以执行的指令以及它可以访问的地址空间范围。
处理器通常是用某个控制寄存器中的模式位来提供这种功能的。该寄存器描述了进程当前享有的特权。
设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,如停止处理器、改变模式位或者发起I/O操作等。系统也不允许用户模式屮的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
运行应用程序代码的进程,初始时在用户模式中。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
逻辑控制流与上下文切换
即使在系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果想用调试器单步执行程序,我们会看到一系列的程序计数器PC的值,这些值唯一地对应于包含在程序的可执行B文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,简称逻辑流,如图6‑3所示。
图6‑3 逻辑控制流
图6-3的关键点在于进程是轮流使用处理器的。每个进程执行它的流的一部分,然后被抢占,暂时挂起,然后轮到其他进程。对于单个进程来说,它看上去就像是在独占地使用处理器。然而,如果我们精确地检测最每条指令使用的时间,会发现在一些指令之间,
CPU好像会周期性地停顿。不过,停顿之后,它会继续执行程序,且不改变内存位置或寄存器的内容。
那么,CPU是如何分配时间片,实现多进程控制的呢?操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。
具体来说,内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态,由一些对象的值组成,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表等。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始执行一个先前被抢占了的进程。这种决策就被称为调度,是由内核中的调度器处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个
新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。上下文切换分为三个步骤,
-
保存当前进程的上下文
-
恢复某个先前被抢占的进程被保存的上下文
-
将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某
个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,如果一个read系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据从磁盘到达,如图6‑4所示。另一个例子是
sleep系统调用,它显式地请求调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。
中断也可能引发上下文切换。比如,所有的系统都有某种产生周期性定时器中断的机制,通常为每隔1毫秒或10毫秒。每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。
图6‑4 上下文切换图示
hello的异常与信号处理
hello执行时,可能产生如表6‑1所示的四种类别的异常:
类别 | 原因 | 同步/异步 | 返回行为 |
---|---|---|---|
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
表6‑1 系统异常的四个类别
hello执行时,还可以发送或接收信号。信号是一种系统消息,它用于通知进程系统中发生了某种类型的事件,是一种更高层的软件形式的异常。不同的事件对应不同的信号类型。信号传送到目的进程由发送和接收两个步骤组成。信号的发送者一般是内核,接收者是进程。
发送信号可以有如下两种原因:
-
内核检测到一个系统事件(如除零错误或者子进程终止);
-
一个进程调用了kill函数,显式地要求内核发送一个信号给目的进程。
接收信号是内核强迫目的进程做出的反应。进程可以以默认方式做出反应,也可以通过信号处理程序捕获这个信号。每个信号只会被处理一次。
待处理信号指的是已经发送而没有接收的信号。任何时候,一种信号类型至多有一个待处理信号,即信号不会排队。
进程可以有选择性地阻塞接收某种信号。被阻塞的信号仍可以发出,但不会被目标进程接收。
信号种类繁多,现列举如表6‑2所示。
编号 | 信号名称 | 默认行为 | 说明 |
---|---|---|---|
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 | 系统调用异常 |
表6‑2 信号的种类
用键盘输入Ctrl + C组合键,向hello发送SIGINT信号,hello立即终止(如图7‑5所示)
图6-5 向hello进程发送键盘中断信号
在hello执行的同时,使用ps命令查看当前进程,发现hello的存在,如图6-6所示。
图6-6 在hello执行时使用ps命令
在hello的执行命令末尾加上&号,令其在后台运行。输入jobs命令查看当前Shell的作业,发现了hello的存在;输入bg命令,Shell提示当前作业已经在后台运行;输入fg,Shell将后台的hello提至前台,如图6-7所示。
图6-7 在后台执行hello
在hello执行时键盘输入Ctrl +
Z令其停止(挂起),用ps命令发现它仍在进程表中。用kill命令向hello发送SIGCONT信号让它继续运行,发现它重又输出了“Hello”信息。最后用kill命令向hello发送SIGKILL信号令其终止,进程表中就找不到hello了,如图6-8所示。
图6-8 停止、继续最后终止hello
本章小结
先前的章节一直在研究C语言代码的编译和可执行文件的生成过程。从这一章起,开始讨论程序的运行。为了描述程序运行,进程的概念不得不提,它是计算机科学中最深刻、最成功的概念之一。进程为程序提供的抽象环境,使得进程可以同时地、并发地执行。
为了高效地描述系统中发生的各类事件,则需要用到信号,这是一种更高层级的软件形式的异常。利用信号,内核和进程之间得以高效地传递信息并对各类事件做出相应的反应。
hello的存储管理
hello的存储器地址空间
计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有唯一的物理地址,它是指在地址总线上、以电子形式存在的、使得数据总线可以访问主存的某个特定存储单元的内存地址。利用物理地址寻址,是CPU最自然的访问内存的方式。
接下来的要阐述概念均以amd64架构为基础;以x86架构为基础的,会作特殊说明。
现代处理器则采用虚拟寻址的寻址形式,如图7‑1所示。CPU通过生成虚拟地址来访问主存,这个虚拟地址在被送到内存之前先转换成适当的物理地址。将一个虚拟地址转换为物理地址的任务叫做地址翻译。就像异常处理一样,地址翻译需要CPU硬件和操作系统之间的紧密合作。CPU芯片上有一个叫做内存管理单元
(MMU)的专用硬件,它利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容由操作系统管理。
图7‑1 使用虚拟寻址的系统
下面继续明确若干概念。
地址空间是一个非负整数的有序集合,如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间。
在有虚拟内存(VM)的系统中,CPU从一个有N =
2n个地址的地址空间中生成虚拟地址,这个地址空间成为虚拟地址空间,理论上,它的范围由CPU的位数决定。相对应地,还有物理地址空间,它的范围由内存大小决定,不妨设为M。大多数时候,系统所具备的物理地址空间只是虚拟地址空间的一个子集。
为了简化讨论,我们认为物理地址空间和虚拟地址空间都是线性地址空间。
至于逻辑地址、相对地址和线性地址,则是x86系统的产物。在x86系统中,有一种被称作“段式内存管理”的内存映射方式。在x86架构下,访问指令给出的地址(操作数)叫逻辑地址,也叫相对地址、有效地址。逻辑地址经过一番计算和映射,可以获得线性地址,这部分的转换被称作段式内存管理;线性地址再经过一番计算和变换才得到内存储器中的物理地址,这部分转换被称作页式内存管理。而x86系统下,虚拟地址就是线性地址的别名。
用edb调试64位程序hello(编译时未加-no-pie参数)时,无论是主窗口左侧的指令地址,还是寄存器、内存堆栈中所保存的地址,凡是我们所能看到的,通常都是64位的虚拟地址(如图7-2所示),而物理地址是不可见的。有一个细节是,虚拟地址的高16位均为0,这是因为当前的标准规定64位系统的地址只有低48位可用。
图7‑2 hello寄存器和内存的虚拟地址
Intel逻辑地址到线性地址的变换-段式管理
内存分段是为了支持多任务并发执行,每一个任务对应各自的段空间,段之间支持保护访问限制,实现了程序和数据从物理地址空间到虚拟地址空间的重映射,从而达到隔离的效果。
如上所述,在段式内存管理中,程序的地址空间被划分为若干段,每个进程都有一个“二维”的地址空间。系统为每个段分配一个连续分区,而进程中的各个段可以不连续地存放在内存的各个分区中。程序加载时,操作系统为所有段分配其所需内存,这些段不必连续,物理内存的管理采用动态分区的管理方法。
为了实现段式管理,系统需要进程段表、系统段表和空闲段表等数据结构,来实现进程的地址空间到物理内存空间的映射,并跟踪物理内存的使用情况,以便在装入新的段的时候,合理地分配内存空间。
在段式管理系统中,整个进程的地址空间是“二维”的,逻辑地址由段号和段内地址两部分组成。为了完成进程逻辑地址到物理地址的映射,处理器会查找内存中的段表,由段号得到段的首地址,加上段内地址,得到实际的物理地址(如图7‑3所示)。这个过程也是由处理器的硬件直接完成的,操作系统只需在进程切换时,将进程段表的首地址装入处理器的特定寄存器当中。这个寄存器一般被称作段表地址寄存器。
图7‑3 段式内存管理的地址变换
需要特别注意的是,段式内存管理是Intel
x86系统的产物。amd64架构虽然支持x86所有形式的段,但在64位模式下,段式的设计已被取消,转而使用平坦内存模型。在这种模型下,分段机制虽仍然存在,但所有的段基址都是0,段大小被忽略。这就使得逻辑地址可以访问处理器支持的所有虚拟内存空间。也就是说,amd64架构中,段式地址转换形同虚设,逻辑地址(相对地址、有效地址)与虚拟地址(线性地址)是相同的。为了表述方便,接下来针对amd64架构的叙述,全部使用“虚拟地址”一词。
hello的线性地址到物理地址的变换-页式管理
虚拟内存系统将程序的虚拟地址空间划分为固定大小的虚拟页,物理内存被划分为同样大小的物理页(也被称作页帧)。在页式存储管理中,虚拟地址由两部构成,高位部分是页号,低位部分是页内地址(偏移量)。
在任意时刻,虚拟页面的集合都分为三个不相交的子集:
-
未分配页。虚拟内存系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间
-
缓存页。当前已缓存在物理内存中的已分配页
-
未缓存页。未缓存在物理内存中的已分配页
物理内存对应存储器金字塔的DRAM(主存)一级,而虚拟页则是存储在磁盘上。与读写高速缓存一样,从虚拟内存和主存之间也存在着缓存关系,因而也拥有类似的命中、不命中的概念。
页表
同任何缓存一样,虚拟内存系统必须有某种方法来判定一个虚拟页是否缓存在DRAM中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM中,替换这个牺牲页。这些功能是由软硬件联合提供的,包括操作系统软件、内存管理单元中的地址翻译硬件和一个存放在物理内存中叫做页表的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页。图7‑4展示了一个页表的基本组织结构。
图7‑4 页表
页表就是一个页表条目的数组。虚拟地址空间中的每个页在页表中的各固定偏移量处都有一个页表条目。都有一个为方便理解,我们假设每个页表条目都是由一个有效位和一个n位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位为真,那么地址字段就表示DRAM中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。注意,因为DRAM缓存是全相联的,所以任意物理页都可以包含任意虚拟页。
页命中
为了访问物理页,CPU中的地址翻译硬件会先把虚拟地址作为索引去访问页表。若对应的表项有效位为真,那么,该项中的地址字段就是我们所要的物理页的起始地址。以图7-3中的虚拟页2为例,由于对应页表条目有效位是1,所以发生页命中,即这个页已经被缓存到主存中了。于是,直接访问地址字段指向的物理内存物理页2即可。
缺页
DRAM缓存不命中被称为缺页。图7-3展示了在缺页之前我们的示例页表的状态。CPU引用了虚拟页3中的一个字,虚拟页3并未缓存在
DRAM中。地址翻译硬件从内存中读取页表条目3,从有效位推断出虚拟页3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在物理页3中的虚拟页4。如果虚拟页4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改虚拟页4的页表条目,反映出虚拟页4不再缓存在主存中这一事实。
接下来,内核从磁盘复制虚拟页3到内存中的物理页3,更新页表条目3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在虚拟页3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了,如图7‑5所示。
图7‑5 缺页后的页表状态
可以看出,DRAM缓存对于页命中和缺页(页不命中)的处理和高速缓存对于命中、不命中的处理是非常相似的。
地址翻译
从形式上说,地址翻译要做的是把N元素的虚拟地址空间映射到M元素的物理地址空间中。如果虚拟地址A处的数据在物理地址A’处,那么映射值就是A’;否则,若A处的数据不在物理内存中,映射值就是空。
图7‑6 地址翻译流程
图7‑6展示了MMU如何利用页表来实现这种映射。CPU中的一个控制寄存器,页表基址寄存器指向当前页表。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO)和一个(n
p)位的虚拟页号(VPN)。MMU利用VPN来选择对应的页表项目。将页表条目中物理页号(PPN)和虚拟地址中的VPO串联起来,就得到相应的物理地址。注意,因为物理和虚拟页面都是p字节的,所以物理页面偏移(PPO)和VPO是相同的。
图7‑7a展示的是页命中时CPU硬件执行的步骤:
-
处理器生成一个虚拟地址,并把它传送给MMU
-
MMU生成对应的页表项目地址,并访问高速缓存或主存获得其内容
-
高速缓存或主存向MMU返问页表项目
-
有效位为真,MMU构造物理地址,并把它传送给高速缓存或主存
-
高速缓存或主存返冋所请求的数据字给处理器
页命中完全由硬件处理,而缺页则需要硬件和操作系统内核协作完成。如图7‑7b所示:
-
与页命中的第1步操作相同
-
与页命中的第2步操作相同
-
与页命中的第3步操作相同
-
页表项目的有效位是0,MMU触发异常,CPU的控制传送到操作系统内核中的缺页异常处理程序
-
缺页处理程序确定出物理内存中的牺牲页。如果这个页被修改过,还需要把它换出到磁盘
-
缺页处理程序页面调入新的页,并更新内存中的页表项目
-
缺页处理程序返回到原来的进程,再次执行那个导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。现在,所需的虚拟页已经缓存在物理内存中了,所以会发生页命中。在MMU执行了图7-7a中的步骤之后,主存就会将所请求字返回给处理器
图7‑7 页命中和缺页的操作图
TLB与四级页表支持下的VA到PA的变换
利用TLB加速地址翻译
需要注意到,7.3节中介绍的缺页操作策略,效率是比较低的。一旦表项在DRAM主存中,计算物理地址的效率就会百十倍地下降,缺页所带来的额外开销难以接受。为了尽可能消除这些开销,许多系统在MMU中集成了一个关于页表项目的小缓存,被称为翻译后备缓冲器(TLB)。
TLB是用于虚拟寻址的小型缓存,其中每一行都保存着一个由单个页表项目组成的块。TLB通常有较高的相联度。如图7‑9所示,用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果TLB有T
= 2t个组,那么TLB索引(TLBI)是由
VPN的t个最低位组成的,而TLB标记(TLBT)是由VPN中剩余的位组成的(如图7-8所示)。由于所有的地址翻译步骤都是在CPU上的MMU中执行的,所以速度非常快。
图7‑8 虚拟地址中用以访问TLB的部分
图7‑9a展示了当TLB命中时所包括的步骤:
-
CPU产生一个虚拟地址
-
MMU向TLB发送取列表项目的请求
-
MMU从TLB中取出相应的页表项目
-
MMU将这个虚拟地址翻译成物理地址,并将它发送到高速缓存或主存
-
高速缓存或主存将所请求的数据字返回给CPU
当TLB不命中时,MMU必须从L1缓存中取出相应的页表项目,如图7‑9b所示。新取出的页表项目存放在TLB中,可能会覆盖一个已经存在的条目。
图7‑9 TLB命中和不命中的操作图
多级页表
对于64位系统来说,要把页表整体存下来是不可能的——体积过大。另一方面,应用程序所引用的地址只是虚拟地址空间中的一小部分,绝大部分虚拟内存空间时未分配的,这就为压缩页表带来了可能。
压缩页表的常用方法是把页表组织成树状层次结构。图7‑10是一个两级页表的示例。一级页表有1K项,第0项不为空,它指向第0个二级页表的表头;每个二级页表又有1K项,每项直接指向对应的虚拟内存。一级页表的第2~7项均为空,它们没有对应的二级页表,这样就节省了大量的内存空间,极大地压缩了页表的体积。另一方面,只有以及页表才需要总是存放在主存中,二级页表可以在系统需要时随时创建、调入和调出,这就大大减轻了主存的压力。
图7‑10 两级页表层次结构
现代处理器(如实验中所使用的Intel Core i7 Coffe
Lake)一般采用四级页表。与二级页表类似,四级页表的第一级总是存放在主存,第二、三、四级只有需要时才会创建,其中第四级直接映射虚拟页。如何使用k级页表进行地址翻译呢?
一般,系统将虚拟地址划分为k个VPN和1个VPO。每个VPN
i都是到第i级页表的索引。系统从第i级页表中取出第i +
1级页表的地址,再根据VPN i +
1访问对应的页表项。像这样依级访问下去,就可以在最后的第k级页表中获得物理地址的PPN了。物理地址低位部分的PPO,与VPO相同,如图7‑11所示。
图7‑11 使用k级页表的地址翻译
Core i7页表翻译具体过程如图7‑12所示。
图7‑12 四级页表地址翻译
在多级页表地址翻译中,TLB仍然能够发挥作用,将不同层级上页表的页表项目缓存起来。实际上,多级页表地址翻译并不比单级页表慢很多。
三级Cache支持下的物理内存访问
存储器金字塔
图7‑13 计算机系统存储器金字塔
为了平衡成本和效能,计算机系统逐渐演化出了如图7‑13所示的存储器金字塔。越往塔尖,速度越快,但成本也随之水涨船高,且容量也越来越小。金字塔的第二、三、四层是一个相对统一的整体,分别对应于L1、L2、L3三级高速缓存(cache)。拿到物理地址之后,就需要借助三级缓存访问内存中的数据了。
高速缓存读写策略
高速缓存读策略分两种情形讨论:
-
命中。从cache中读相应数据到CPU或上一级cache中
-
不命中。从下一级cache或主存中读取数据,并替换(驱逐)出一行数据。对于被替换者的选择,常采用LRU算法
高速缓存写策略也分两种情形讨论:
(1) 命中。分“写回”和“直写”两种策略。
a.
写回。只写本级cache,暂时不写数据到下一级cache或主存。直到该行被替换出去时,才将数据写回到主存或下一级cache。
b. 直写。写本级cache,同时数据写到下一级cache或主存。
(2) 不命中。分“按写分配”和两种策略。
a.
写分配。即加载相应的低一级的块到本级cache中,然后更新这个高速缓存块。写分配试图利用写的局部性,但缺点是每次不命中都会导致一个块从低一级传送到高一级。
b.
非写分配。避开本层,直接写数据到下一级cache或主存,并且不从低一级中的读取被改写的数据。
利用物理地址访问高速缓存
高速缓存的结构可以用元组(S, E, B,
m)来描述(如图7‑14a所示)。髙速缓存的大小(或容量)C指的是所有块的大小的和。标记位和有效位不包括在内。因此,C
= S×E×B。
当一条加载指令指示CPU从主存地址A中读一个字时,它将地址A发送到高速缓存。如果高速缓存正保存着地址A处那个字的副本,它就立即将那个字发回给CPU。那么高速缓存如何知道它是否包含地址A处那个字的副本的呢?高速缓存的结构使得它能通过简单地检查地址位,找到所请求的字,类似于使用极其简单的哈希函数的哈希表。
图7‑14 高速缓存与物理地址
参数S和B将M个物理地址位分为了三个字段:标记、组索引和块偏移,如图7‑14b所示。A中的s个组索引位是到S个组的数组的索引。第一个组是组0,第二个组是组1,依此类推。组索引位是一个无符号整数,它告诉我们这个字存储在哪个组中。一旦我们知道了这个字存放在哪个组中,A中的z个标记位就告诉我们这个组中的哪一行包含这个字(如果有的话)。当且仅当设置了有效位并且该行的标记位与地址A中的标记位相匹配时(类似与哈希表查找时,哈希值的匹配),组中的这一行才包含这个字。一旦我们在由组索引标识的组中定位了由标号所标识的行,那么就可以由6个块偏移位确定所需的字在该行数据块中的字偏移量。
Intel Core i7总的地址翻译流程如图7‑15所示。
图7‑15 Core i7地址翻译概况
hello进程fork时的内存映射
Linux通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这
个虚拟内存区域的内容,这个过程称为内存映射。
内存映射的概念来源于这样一个发现:如果虚拟内存系统可以集成到传统的文件系统中,那么就能提供一种简单高效的把程序和数据加载到内存中的方法。
正如第6章所叙,进程这一抽象能够为每个进程提供自己私有的虚拟地址空间,可以免受其他进程的错误读写。不过,许多进程有同样的只读代码区域,还有许多程序需要访问同一库代码的相同副本。那么,如果每个进程都在物理内存中保持这些常用代码的副本,那就是极端的浪费了。幸运的是,内存映射给我们提供了一种清晰的机制,利用它,系统得以控制多个进程共享对象。
一个对象(要么是共享的,要么是私有的)可以被映射到虚拟内存的某个区域。一个映射到共享对象的虚拟内存区域叫做共享区域。类似地,也有私有区域。
对于共享对象来说,如果进程将共享对象映射到该进程的虚拟地址空间的某个区域内,那么进程对这个区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟内存空间的其他进程而言,也是可见的。而且,这些变化也会反映在磁盘上的原始对象中。
另一方面,对于一个映射到私有对象的区域做的改变,对于其他进程来说是不可见的,并且进程对这个区域所做的任何写操作都不会反映在磁盘上的对象中。
私有对象使用写时复制技术映射到虚拟内存中。私有对象开始生命周期的方式基本上与共享对象的一样,在物理内存中只保存有私有对象的一份副本。比如,
图7‑16a展示了一种情况,其中两个进程将同一个私有对象映射到它们虚拟内存的不同区域,但是共享这个对象同一个物理副本。对于每个映射该私有对象的进程,相应私有区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时复制。只要没有进程试图写它自己的私有区域,它们就可以继续共享物理内存中对象的同一物理副本。然而,只要有进程试图写私有区域内的某个页面,那么这个写操作就会触发一个保护故障。
图7‑16 私有的写时复制对象
当故障处理程序注意到保护异常是由于进程试图写私有的写时复制区域中的一个页面
而引起的,它就会在物理内存中创建这个页面的一个新副本,更新页表条目指向这个新的
副本,然后恢复这个页面的可写权限,如图7‑16b所示。当故障处理程序返回时,CPU重新执行这个写操作,现在在新创建的页面上这个写操作就可以正常执行了。一句话说,写时复制的原则就是“能拖就拖”,只要进程不写,这块私有区域就是共享状态。这样,稀有的物理内存就得以充分利用了。
根据写时复制的原理,hello父进程fork新的子进程后,内核为子进程创建各种数据结构,拷贝父进程的内存映射、mm_struct和页表的原样副本,然后给它分配唯一的PID,但暂时不会拷贝内存数据页本身。接着,系统将两个进程中的每个页面都标记位只读、每个区域结构都标记为私有的写时复制,但它们实际上共享着物理内存中的同一块区域。之后,若父子进程中的任何一个对私有区域进行写操作,写时复制机制就会拷贝一份新物理页。新页面的属性不再是写时复制,进程可以对它进行写操作。
hello进程execve时的内存映射
虚拟内存和内存映射在将程序加载到内存的过程中也扮演着关键的角色。假设运行在当前进程中的程序执行了如下的execve调用
execve("hello", NULL, NULL);
正如6.4
节所说,execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
-
删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构
-
映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text
和.data区。图7‑17概括了私有区域的不同映射 -
映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C库libc.
so,那么这些对象都先是动态链接到程序,再映射到用户虚拟地址空间中的共享区域内的 -
设置程序计数器PC。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的人口点。
下一次调度这个进程时,它将从这个人口点开始执行。Linux将根据需要换人代码和数据页面。
图7‑17 加载器映射用户地址空间区域的方法
缺页故障与缺页中断处理
假设MMU在试图翻译某个虚拟地址A时,触发了缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤(如图7‑18所示):
-
虚拟地址A是合法的吗?换句话说,A在某个区域结构(区域就是已分配的虚拟内存的连续片)定义的区域内吗?缺页处理程序会搜索区域结构的链表,把A和每个区域结构中的vm_start和
vm_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。 -
试图进行的内存访问是否合法?换句话说,进程是否有读、写或者执行这个区域内页面的权限?例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程
-
若不满足以上两种情形,内核明白这个缺页是由于对合法的虚拟地址进行合法的操作造成的。接下来,内核会遵循7.3.3
节中的步骤进行处理,这里不再重复
图7‑18 Linux缺页处理
动态存储分配管理
动态内存和动态内存分配器
动态内存分配器用于分配和维护一个进程的虚拟内存区域,称为堆(如图7‑19所示)。堆在系统内存中向上生长。对于每个进程,系统维护着一个堆顶指针brk。
图7‑19 虚拟内存区域——堆
分配器将堆视为不同大小的块组成的集合。每个块就是一段连续的虚拟内存片(chunk),要么是已分配的(allocated),要么是空闲的(free)。已分配的块显式地保留给应用程序使用,空闲块留待分配。
显式分配器。显式分配器要求程序显式地释放已分配块。例如,C标准库就提供了malloc库这一显式分配器。它需要满足如下的约束条件:能够处理任意请求序列;立即响应请求;仅使用堆;块对齐;不修改已分配块。在满足这些限制条件的前提下,其分配吞吐速率越大、内存使用率越高,其性能越优秀。然而,这两个要求是冲突的。为了把握好平衡,就需要分配器有效地组织空闲块,精心设计放置、分割和合并的处理方式。
隐式分配器(也叫垃圾收集器)能够检测已分配块何时不再被程序使用,并将其自动释放。这种分配器将内存视为一张有向可达图,凡不能从根到达的节点都是垃圾节点,代表程序无法再访问使用的内存空间。垃圾收集器能够以某种方式维护这张图,释放不可达节点并将其返还给空闲链表,从而达到定期回收内存的目的。
显式空闲链表的基本原理
图
7‑20a中的分配块主要由四部分构成:头部、有效载荷、对齐填充和脚部。其中,头部和脚部是完全相同的,高29位用于存储块大小(由于块对齐,块大小的最后3位均为0,无需存储),最低位用于标记这个块是否已被分配。
显式空闲链表的“显式”体现在空闲块中多出的两个指针,如图 7‑20b所示。
图 7‑20 显示空闲链表的块结构
前驱后继指针允许我们将链表存储为更复杂的形式。隐式链表由于只在头部脚部中存储了块大小,所以其遍历顺序仍是地址顺序。而在显式链表中,我们可以利用这两个指针按任意顺序组织链表。例如,我们可以按块大小顺序储存、按分配时间储存,也可以仍按照地址顺序储存。此外,还可以储存多个链表(如分离的空闲链表)。
显式链表的缺点在于,空闲块需要足够大(需要存储头部脚部和两个指针),这就导致最小块变大,潜在地提高了内部碎片化的程度。
系统malloc函数采用的就是分离空闲链表和分离适配的方法,它们就是基于显式空闲链表的。
需要注意的是,处理释放请求时,由于当前块被释放,所以可能出现“假碎片”。为了将其合并,需要先通过块头部和块脚部的信息,获知前后块的大小和分配状态。显然,前后块的分配状态共有四种组合(如所示),分类处理之即可。
图 7‑21 释放分配块时,利用边界标记合并
分离空闲链表和分离适配
malloc采用了“分离显式空闲链表”的存储方式和“分离适配”的分配策略。
一般的单向空闲链表,其查找和分配所需时间都是线性的。为了减少分配时间,可以按块大小对空闲块分类,每个类都单独维护一个链表,类中的块大小大致相同,这被称为“分离”的显式空闲链表。我按照2的幂来划分块大小:
{1}, {2}, {3, 4}, {5~8}, …, {1025~2048}, {2049~4096}, {4097~∞}
链表的头元素存储在静态数组中。链表内部的元素以块大小按升序排列。当分配器需要一个对齐后大小为n的块时,它就会搜索相应的空闲链表。如果不能找到合适的块与之相匹配,就搜索下一个链表,以此类推。
若能找到合适的块(大于n的最小块),就将这个块分配出去。对于剩余部分,如果过小,就不再分割;否则将剩余部分重新插入对应大小的空闲链表中。如果没有合适的块,就像操作系统申请额外的堆内存,从这个新的堆内存中分配出一个块执行合并,并将结果放置到相应的空闲链表中。释放某个块时,就按照一般显式链表的方法,“瞻前顾后”地合并。
利用这些算法,malloc不再需要搜索所有空闲块,而只需要搜索某个部分。这样,就能够提高分配的效率。
本章小结
本章讲述了amd64架构下的内存管理内容,包括物理内存、虚拟内存系统、三级高速缓存、fork与execve和动态内存分配等。
和程序优化一样,系统内存也在想方设法地利用“局部性原理”:系统在临近时间内访问的内存地址也是临近的,或者说,对存储器的访问不是均匀的。因此,系统可以把把最常访问地存储单元拿出来,放到更快(但也更小、更贵)的存储器中,级级上调,形成存储器金字塔。无论是磁盘(虚拟内存)到主存、主存到三级缓存和寄存器,还是MMU中的TLB,都是基于这个原理。
hello的IO管理
Linux的IO设备管理方法
一个Linux文件就是一个m个字节的序列:
B0, B1, …, Bk, …, Bm-1
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix
I/O。
简述Unix IO接口及其函数
Unix I/O使得所有输入和输出都能以一种统一的方式来执行:
-
打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个
I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操
作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。 -
Linux Shell创建的每个进程开始时都有三个打开的文件:标准输入、标准
输出和标准错误。头文件unistd.h定义了常量STDIN_
FILENO、STDOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。 -
改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
-
读写文件。读操作就是从文件复制n >
0个字节到内存,从当前文件位置k开始,然后将k增加到给定一个大小为m字节的文件,当k≥m时执行读操作会触发EOF条件,应用程序能检测到这个条件。类似地,写操作就是从内存复制n
> 0个字节到文件,从当前文件位置k开始,然后更新k。 -
关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响
应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O函数原型及功能罗列如下:
-
int close(fd)
该函数可以关闭一个打开的文件,其中fd 是需要关闭的文件的描述符。 -
int open(char* filename, int flags, mode_t mode)
进程通过调用 open 函数来打开已存在的文件或是创建新文件。 -
size_t write(int fd, const void *buf,size_t n)
该函数从内存位置复制至多n个字节到描述符为fd的当前文件位置。 -
size_t read(int fd, void *buf, size_t n)
该函数从描述符为fd的文件位置赋值最多n个字节到内存位置。返回-1表示出现错误,0表示EOF;否则返回值表示的是实际传送的字节数量。
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; } |
---|
用vsprintf函数生成显示信息,实现如下:
int vsprintf(char *buf, const char *fmt, va_list args) { char *p; char tmp[256]; va_list p_next_arg = args; for (p = buf; *fmt; fmt++) { if (*fmt != '%') { *p++ = *fmt; continue; } fmt++; switch (*fmt) { case 'x': itoa(tmp, *((int *)p_next_arg)); strcpy(p, tmp); p_next_arg += 4; p += strlen(tmp); break; case 's': break; default: break; } } return (p - buf); } |
---|
在printf中调用write(buf, i),输出长度为i的buf。追踪write,得到其汇编实现如下:
write: mov eax, _NR_write mov ebx, [esp + 4] mov ecx, [esp + 8] int INT_VECTOR_SYS_CALL |
---|
write 函数中,%ecx中存储字符个数,%ebx中存储字符串首地址,int
INT_VECTOR_SYS_CALL的意思是通过系统调用
sys_call。这个函数的功能就是不断地打印出字符,直到遇到\0。
追踪sys_call,得到其汇编实现如下:
sys_call: call save push dword [p_proc_ready] sti push ecx push ebx call [sys_call_table + eax * 4] add esp, 4 * 3 mov [esi + EAXREG - P_STACKBASE], eax cli ret |
---|
接着,字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
getchar的实现分析
getchar函数的声明在stdio.h头文件中。一种实现如下:
#include "libioP.h" #include "stdio.h" #undef getchar int getchar(void) { int result; if (!_IO_need_lock(stdin)) return _IO_getc_unlocked(stdin); _IO_acquire_lock(stdin); result = _IO_getc_unlocked(stdin); _IO_release_lock(stdin); return result; } #ifndef _IO_MTSAFE_IO #undef getchar_unlocked weak_alias(getchar, getchar_unlocked) #endif |
---|
这个getchar每次从标准输入中读取一个字符。具体来说,若当前I/O未被锁定,它就调用系统_IO_getc_unlocked内置宏,读取一个字符。
把_IO_getc_unlocked写成等价的函数形式如下:
inline int _IO_getc_unlocked(FILE *fp) { if (_fp->_IO_read_ptr >= _fp->_IO_read_end) return __uflow(_fp); else return *(unsigned char *)(_fp->_IO_read_ptr++); } |
---|
简单地说,我们用一个指针_IO_read_ptr指向缓冲区,用另一个指针_IO_read_end指向缓冲区的末尾。调用_IO_getc_unlocked时,先检查指针是否越界。如果没有,就返回_IO_read_ptr所指向的字符并自增_IO_read_ptr。若已越界,就调用_uflow(内部使用了系统read),用这个函数重新填充缓冲区并返回重新读入的字符。
对于异步异常和键盘中断的处理:键盘中断处理子程序;接受按键扫描码转成ASCII码,保存到系统的键盘缓冲区。也就是说,getchar等调用read系统函数,通过系统调用读取按键ASCII码,直到接受到回车键才返回。
本章小结
Linux系统中,所有的I/O设备都被模型化为文件,通过文件的读写来实现I/O操作。这体现了“一切皆文件”的Unix哲学。
Unix
I/O接口和函数可以统一了系统I/O操作。我们还剖析了printf和getchar的源码,介绍了系统I/O的基本操作,从中感受到系统I/O函数与底层交互的严谨性,直观地体会到到它们为防范缓冲区溢出等错误而做的工作,可谓用心良苦。
结论
至此,hello的P2P、020两段旅程全部圆满结束。它主要经历了如下过程:
阶段 | 过程 | 说明 |
---|---|---|
P2P | 预处理 | 处理预处理指令 将hello.c引用的所有外部的库合并到hello.i 中 |
编译 | 将 hello.i件编译为汇编码hello.s | |
汇编 | 将hello.s文件汇编为可重定位目标文件hello.o | |
链接 | 将hello.o与和外部库链接为可执行程序hello | |
020 | Shell输入 | 在Shell中键盘输入运行hello的指令 |
进程fork | Shell 调用fork,为其创建一个子进程 | |
进程execve | Shell调用execve 重新映射进程内存空间 启动加载器 进入程序入口点 | |
执行 | 进程执行时,系统为其提供独占处理器和内存空间的假象 | |
回收 | Shell父进程回收子进程 内核清理hello进程占用的资源 |
表 hello经历的过程
至此,我们完整地回顾了hello从P2P到020的历程。
计算机系统是由硬件和系统软件组成的。为了运行应用程序,它们需要共同协作。计算机内部的信息被表示为一组组的位,它们依据上下文有不同的解释方式。程序被其他程序翻译成不同的形式,开始时是ASCII文本,然后被编译器和链接器翻译成二进制可执行文件。
处理器读取并解释存放在主存里的二进制指令。因为计算机花费了大量的时间在内存、
I/O设备和CPU寄存器之间复制数据,且多数计算机程序具有时间和空间的局部性,系统得以将存储设备划分成层次结构——CPU寄存器在顶部,接着是多层的硬件高速缓存存储器Cache、
DRAM主存和磁盘。在层次模型中,位于更高层的存储设备比低层的存储设备要更快,成本也更昂贵。层次结构中较高层次的存储设备可以作为较低层次设备的高速缓存。通过理解和运用这种存储层次结构的知识,程序员可以优化C程序的性能。
操作系统内核是应用程序和硬件之间的媒介。它提供三个基本的抽象:
-
文件是对I/O设备的抽象;
-
虚拟内存是对主存和磁盘的抽象;
-
进程是处理器、主存和I/O设备的抽象。
这些抽象为计算机系统的开发者和使用者都提供了极大的便利。
学ICS课之前,计算机系统对我们而言更像是一个黑箱:我们能够在计算机上用IDE编写、编译、运行和调试高级语言程序,知道“用临时变量代替内存引用”等黑科技能提高程序效率,还会用系统终端和Shell做简单的指令操作,但并不明白其中的原因和道理。
经过一个学期的学习、思考、实验和作业,我们逐渐对计算机系统的运行机制有了比较深入的认识。虽然绝大多数学生今后可能不会从事系统编程或底层开发等工作,但从程序员的视角,了解程序的机器级表示、存储器结构、虚拟内存等主题,是必不可少的。这样,我们才能够真正理解硬件、操作系统和编译系统对应用程序的性能和正确性的影响,为将来设计和构造大型软件产品提供更基础、更底层的角度和思路。
附件
文件名称 | 文件作用 |
---|---|
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] 刘迪望. 2015CMU 15-213 CSAPP 深入理解计算机系统[J/OL]. Science, 2019,
https://www.bilibili.com/video/av24540152
[3] Randal E. Bryant. David R. O'Hallaron. Computer Systems: A Programmer's
Perspective, 3/E (CS:APP3e) [J/OL]. 2019,http://www.csapp.cs.cmu.edu
[4] Thomas H. Cormen, Charles. E. Liserson.等 算法导论(原书第3版)[M].
北京:机械工业出版社.2013
[5] Noname EXECVE(2) Linux Programmer's Manual[J/OL]. 2019,
http://man7.org-/linux/man-pages/man2/execve.2.html
[6] Noname Acronyms relevant to Executable and Linkable Format (ELF)[J/OL],
2009, https://stevens.netmeister.org/631/elf.html