CSAPP 大作业

计算机系统

大作业

题 目 程序人生-Hello’s P2P

专 业 计算学部

学 号 120L020721

班 级 2003005

学 生 张贺

指 导 教 师 吴锐

计算机科学与技术学院

2022年5月

摘 要

本文主要针对hello程序从hello.c文件开始,经过预处理、编译、汇编、链接四个阶段生成可执行文件,到在Linux下执行时的进程管理、存储管理以及IO管理进行了全面的阐述。针对每一部分中的细节,结合hello具体实例进行了详细的展开。总体上看,全文涵盖了课程中大部分知识点,针对一些部分有适当的扩展,对hello的一生有较为详细的陈述。

关键词:hello程序;P2P;存储管理;IO管理

目 录

第1章 概述

1.1 Hello简介

1.2 环境与工具

1.2.1 硬件环境

1.2.2 软件环境

1.2.3 开发工具

1.3 中间结果

1.4 本章小结

第2章 预处理

2.1 预处理的概念与作用

2.2在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

2.4 本章小结

第3章 编译

3.1 编译的概念与作用

3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.3.1 总体流程图

3.3.2 数据

3.3.3 关系操作

3.3.4 算术操作

3.3.5 控制转移

3.3.6 数组/指针操作

3.3.7 函数操作

3.3.8 其他

3.4 本章小结

第4章 汇编

4.1 汇编的概念与作用

4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

4.3.1 ELF头

4.3.2 节头部表

4.3.3 重定位条目

4.3.4 符号表

4.4 Hello.o的结果解析

4.4.1 机器语言的构成

4.4.2 与汇编语言的相同点

4.4.3 与汇编语言的区别

4.5 本章小结

第5章 链接

5.1 链接的概念与作用

5.2 在Ubuntu下链接的命令

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

5.3.1 ELF头

5.3.2 节头部表

5.3.3 程序头部表

5.3.4 Section to Segment mapping

5.3.5 重定位条目

5.3.6 符号表

5.4 hello的虚拟地址空间

5.5 链接的重定位过程分析

5.5.1 重定位节和符号定义

5.5.2 重定位节中的符号引用

5.6 hello的执行流程

5.7 hello的动态链接分析

5.7.1 分析GOT位置内存

5.7.2 分析运行时GOT[2]之后的条目

5.7.3 分析过程链接表PLT

5.7.4 分析共享库函数的调用步骤

5.8 本章小结

第6章 Hello进程管理

6.1 进程的概念与作用

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

6.3 Hello的fork进程创建过程

6.4 Hello的execve过程

6.5 Hello的进程执行

6.5.1 逻辑控制流与时间片

6.5.2 上下文切换

6.5.3 用户态与内核态转换

6.6 Hello的异常与信号处理

6.6.1 hello执行过程中的产生的异常

6.6.2 运行时输入带来的异常和异常与信号的处理

6.7本章小结

第7章 hello的存储管理

7.1 hello的存储器地址空间

7.1.1 逻辑地址

7.1.2 线性地址

7.1.3 虚拟地址

7.1.4 物理地址

7.1.5 四种地址之间的关系

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

7.2.1 段式管理相关概念

7.2.2 逻辑地址到线性地址变换的总体流程

7.2.3 示例

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

7.3.1 页式管理相关概念

7.3.2 线性地址到物理地址的变换

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

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

7.6 hello进程fork时的内存映射

7.7 hello进程execve时的内存映射

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

7.9动态存储分配管理

7.9.1 动态内存分配器

7.9.2 显式分配器:malloc程序包

7.9.3 隐式分配器

7.10本章小结

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

8.2 简述Unix IO接口及其函数

8.2.1 打开文件

8.2.2 关闭文件

8.2.3 读文件

8.2.4 写文件

8.3 printf的实现分析

8.4 getchar的实现分析

8.5本章小结

结论

附件

参考文献

第1章 概述

1.1 Hello简介

Hello的P2P过程:

P2P,From program to process.

Hello从 hello.c C语言文件开始,经过cpp预处理得到中间文件hello.i,经过ccl编译得到汇编语言代码文件hello.s,再经过as汇编得到可重定位目标文件hello.o,最终经过ld进行链接生成了可执行目标文件hello。

在Bash中输入,Bash调用fork创建子进程hello,调用execve加载并执行hello程序。至此,hello变成了进程(process)。

Hello的O2O过程:

O2O,From Zero-0 to Zero-0.

hello开始时仅是磁盘上的一个文件,在内存中程序的具体内容并不存在。Bash调用fork函数创建新的hello进程,为其分配虚拟空间。execve函数对其进行加载时,通过存储管理机制,hello内容被加载到物理内存中并确定了从hello进程虚拟内存到物理内存的映射,init完成后,控制被传递给新程序的主函数main,hello真正的内容开始执行。hello进程执行过程中,内核控制hello进程与其他进程并行,处理异常以及信号;hello通过IO打印输出并接收输入,与外界进行交互。子进程hello执行完成后,父进程shell将其回收。hello对应的物理页在物理内存中会被替换。

1.2 环境与工具

1.2.1 硬件环境

Intel® Core™ i5-9300H CPU x64 ; 2.40GHz ; 8G RAM ; 512G SSD

1.2.2 软件环境

Windows11 64位 ; Vmware 16 ; Ubuntu 20.04.4 LTS 64位

1.2.3 开发工具

Visual Studio 2022 ; CodeBlocks ; GCC ; GDB ; EDB ; Objdump ; readelf ; ld

1.3 中间结果

文件名对应章节文件作用
hello.cALL源程序,进行测试与后续修改
hello.i2执行gcc预处理与结果解析
hello.s3执行gcc编译与结果解析
hello_Og.s3.3与优化等级O0产生的汇编代码做对比
hello.o3.3.8/43.3反汇编用/第四章解释汇编
obj_hello.o.txt4.4Objdump反汇编结果,与hello.s比较
hello5/6解读链接阶段,分析进程管理
obj_hello.txt5.5比较hello与hello.o,说明链接的过程
/hello7.2在Win x86下运行,查看段寄存器等,分析Intel段式管理

1.4 本章小结

本章概述了hello的P2P,020的整个过程,列出了本次大作业使用的环境与工具。中间结果的表格中列出了整个分析过程中产生的中间结果文件名,其对应的章节与作用。

第2章 预处理

2.1 预处理的概念与作用

概念:预处理指在对源程序进行编译之前,先对源程序中以字符#开头的预处理命令进行处理,修改原始的C程序,主要进行文本替换工作。预处理器(cpp)按预处理命令对 .c 文件进行文本修改,常见的预处理命令包括#include文本包含命令、#define宏定义命令等。

作用:预处理是GCC将源程序文件翻译成可执行目标文件的第一个阶段。对源程序 .c 文件做处理得到 .i 文件中的另一个C程序,为下一步编译阶段做准备。

2.2在Ubuntu下预处理的命令

Ubuntu下GCC预处理命令为gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i,结果保存到hello.i文件中。添加-C选项可以让GCC保留源文件和头文件中的注释。下图为执行GCC预处理命令并用cat hello.i命令查看hello.i文件内容的截图,文件内容大部分省略。

图2.2.1 Ubuntu下预处理

2.3 Hello的预处理结果解析

使用cat hello.i命令查看hello.i文件内容,发现文本语句包括两种形式:

1.以#开头的linemarker[1]:

其具体格式为。首位linenum标志着linemarkers的下一行文本语句来自于filename文件中的linenum行,flags标志着该linemarker标志的语句的一些特殊属性(具体含义参照GCC/The C Preprocessor/Preprocessor Output [1])。如果linemarker标志的文本语句仍是预处理命令,那么下一行文本仍以此格式出现(filename文件中#if、#define等语句可能会被跳过,一些语句和注释会以空行形式被省略)。

2.C语言代码:

C语言代码是从头文件中插入进来的。如果linemarker标志的文本语句不是C语言的预处理命令,则该文本语句会被完整地复制进来(注释语句会以空行形式被跳过),复制完成后转到下一行,如果仍不是C语言的预处理命令,继续进行复制,直到filename文件中不能被忽略的预处理指令出现。

下面两图是截取的hello.i文件中的部分内容与types.h头文件内容的比较。hello.i中的文本正式以上述形式组织的。

在这里插入图片描述

图2.3.1 hello.i文件中的部分内容

在这里插入图片描述

图2.3.2 types.h头文件29行开始的部分内容

hello.i文件中最后位置是hello.c源文件中除预处理命令外的代码文本。

2.4 本章小结

预处理是GCC将源程序文件翻译成可执行目标文件的第一步,是对后续阶段的准备。在Ubuntu下执行预处理的命令并对预处理结果hello.i进行解析能够确认,预处理只是针对预处理命令进行的,hello.i中只是将hello.c中头文件按头文件中指令和头文件之间跳转的顺序将除预处理命令外的C语言代码完整复制了过来,也包括了标志访问顺序的linemarkers。实际上,预处理也会对其他一些预处理命令进行操作,包括宏定义替换等等。

第3章 编译

3.1 编译的概念与作用

**概念:**编译是使用编译器(ccl)将预处理后得到的 .i 文件翻译成 .s 文本文件的过程,.s 文本文件中包含一个汇编语言程序,以供下一阶段汇编器使用。编译器基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例,经过一系列阶段生成所需的机器代码。GCC C语言编译器以汇编代码的形式产生 .s 文件输出,后续步骤中汇编器、链接器根据该汇编代码生成可执行的机器代码。

作用:

1.语句识别与错误检查:

编译过程中编译器主要做词法分析、语法分析、语义分析等工作,过程中识别单词等,确定整个输入串能否构成语法正确的程序并检查源程序上有无语义错误。

2.代码优化:

编译器进行错误检查后会进行一步中间代码生成,然后对产生的中间代码进行优化处理,使生成的目标代码更为高效,节省时间和空间。

3.2 在Ubuntu下编译的命令

Ubuntu下编译的命令为gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s。

可以加入-O、-Og等优化等级选项,不添加则默认优化等级-O0。

在这里插入图片描述

图3.2.1 Ubuntu下编译hello.i与部分输出结果

3.3 Hello的编译结果解析

3.3.1 总体流程图

在这里插入图片描述

图3.3.2.1 总体流程图

3.3.2 数据

1.常量:

代码中的常量是代码中printf要输出的的两个字符串*“用法: Hello 学号 姓名 秒数!\n""Hello %s %s\n”*。在hello.s汇编语言中5~8行出现并分别以.LC0,.LC1进行标记。

在这里插入图片描述

图3.3.2.1 字符串常量

2.变量:

代码中的变量包括传递给main函数的两个参数int argc、char *argv[]和for循环的循环环变量i,变量被保存在栈中。汇编代码19~21行设置了帧指针%rbp并在栈中开辟了32字节的空间。22行将传递给main函数的%edi中的第一个参数argc保存在%rbp-20位置,将保存在%rsi中的第二个参数即char*[]指针数组的首地址保存到%rbp-32位置即栈顶位置。

在这里插入图片描述

图3.3.2.2 栈空间与变量保存

.L2标志汇编代码中for循环开始位置,代表循环变量i被保存在%rbp-4位置,在刚进入循环时被初始化为0。for循环过程在.L4标记的部分中,该部分以结束,对应C语言中i++。

在这里插入图片描述

图3.3.2.3 初始化循环变量i

3.3.3 关系操作

hello.c中涉及到比较关系有argc!=4和i<8,在hello.s中对应的关系操作部分为24行和53行。

argc!=4对应表达式~ZF为真,25行je指令执行。i<8编译时转换为i≤7,对应表达式ZF|(SF^OF)为真,54行jle指令执行。

图3.3.3.1 argc!=4比较关系

图3.3.3.1 i<8比较关系

3.3.4 算术操作

hello.s中出现的算数操作指令有add和sub。

1.add指令:

①addl:汇编代码51行对应C代码中循环变量i的i++自增操作。i为整型数,编译时对应双字,指令为addl。

②addq:汇编代码35、38、45行addq指令,在访问栈中保存的字符串数组时确定每一项在栈中的地址时使用。对应着C语言代码中向调用的printf、atoi函数传递argv[2]、argv[1]、argv[3]时的一步。

图3.3.5.1 加法操作

2.sub指令:

只在汇编代码21行出现一次,subq指令在栈上开辟32字节的空间。

在这里插入图片描述

图3.3.5.2 减法操作

3.3.5 控制转移

1.条件跳转:

C语言代码中的条件跳转if(argc!=4)对应汇编代码中24~25行。24行指令将栈中%rbp-20位置的值与立即数4进行比较。3.3.2中提到,%rbp-20位置保存着argc。如果相等(argc==4),则跳转到.L2的部分,否则继续下面的26~29行,打印"用法: Hello 学号 姓名 秒数!\n"字符串并调用exit函数。

图3.3.5.1 条件判断与跳转

2.for循环:

.L2部分是初始化循环变量i,进入for循环的部分。for循环的判断部分在.L3部分,循环体在.L4部分。可以看到,编译器编译时使用了guarded-do翻译方法。初始化部分在3.3.2处提到。下面解析.L3循环判断部分。

.L3中53行将%rbp-4位置的值与立即数7比较,使用cmpl指令是因为循环变量i为整型对应双字大小。54行进行条件跳转,i-7≤0时即cmpl结果始条件码表达式SF^OF|ZF为真时,跳转到.L4部分。否则执行55行及后面的指令,调用getchar函数然后返回0,退出main函数。

图3.3.5.2 循环变量初始化

图3.3.5.3 for循环条件判断

3.3.6 数组/指针操作

main函数的第二个参数为char *argv[0],是一个保存char*指针的数组。对数组/指针的操作集中在.L4 for循环的循环体中。

3.3.2中提到第二个参数即指针数组的首地址保存到%rbp-32位置,for循环体中需要按顺序获取argv[2]、argv[1]、argv[3]的值。以argv[2]为例,大致过程如下。

使用movq指令,将对%rbp-32位置内存引用得到的值传送到寄存器%rax中。此时%rax中保存的是指针数组的首地址。指令将%rax的值更新为原来的地址+16字节,因指针大小为8字节,现在%rax中保存的值为指向argv[2]的地址。movq指令将对%rax内存引用得到的值传送到%rdx中,作为调用printf函数的第三个参数,此时%rdx中保存的值是argv[2]的内容,即一个char*类型的指针。

获取argv[1]、argv[3]的值过程与上面类似,区别在于需要的栈中的地址不同和最终结果被保存到的寄存器不同。

图3.3.6.1 .L4部分的for循环体

3.3.7 函数操作

hello.s中函数操作体现在对函数printf (puts)、atoi和sleep的调用与参数传递以及返回值和对返回值的处理。

1.参数传递

①printf(“用法: Hello 学号 姓名 秒数!\n”);部分:

调用printf函数时,参数传递对应hello.s中的26行。使用leaq指令将.LC0位置保存的字符串常量赋给%edi,按规定,%edi是调用函数时穿第的第一个参数的寄存器。leaq执行完调用puts函数(优化处理)。

②exit(1);部分:

指令将立即数保存在%edi中,作为传递的第一个参数调用exit函数。

图3.3.7.1 puts函数参数传递

③printf(“Hello %s %s\n”,argv[1],argv[2]);部分:

调用printf函数时,参数传递对应hello.s中的34~47行,根据3.3.8的分析,执行48行call指令前,作为传递的第1、2、3个参数的寄存器%rdi,%rsi,%rdx中分别保存这.LC1(%rip)位置的字符串、argv[1]和argv[2]。

④sleep(atoi(argv[3]));部分:

对应hello.s中的44~49行。同样按3.3.8中相同的步骤将argv[3]的值保存在%rdi中,作为第一个参数调用atoi函数。atoi函数返回后,将%eax中返回值传送到%rdi中,同样作为第一个参数调用sleep函数。

图3.3.7.2 sleep(atoi(argv[3]))对应的汇编代码

2.函数调用

函数调用使用call指令,在参数传递准备完成后进行。hello.s中函数调用出现6次:27行、29行、43行、48行、50行和55行。

3.函数返回

函数返回值保存在%rax寄存器中,从sleep(atoi(argv[3]));在hello.s中对应的48~51行代码中可以看到。调用atoi函数结束后保存在%eax中的返回值被传送到%edi寄存器中,作为调用sleep函数的第一个参数。

图3.3.7.3 %eax中的返回值

主函数要求返回0。return 0;语句对应hello.s中56~59行。先将立即数0传送给%eax,然后执行leave、ret指令。

图3.3.7.2 主函数返回部分

3.3.8 其他

1.%rbp为被调用者保存寄存器,hello.s中16行pushq %rbp体现了这一属性。

2.hello.s中57行的leave指令是汇编语言中用来关闭栈帧的指令,位于函数末尾,将%rbp帧指针的值传送给%rsp,回收分配的栈空间然后从栈中弹出%rbp的旧值。

3.解读和指令。生成可重定位目标程序后使用objdump进行反汇编,得到下面一行指令对应hello.s中leaq指令。

图3.3.8.1 反汇编得到的mov指令

由于这行代码要引用一个全局变量,在编译阶段位置仍未确定。所以汇编时为其分配占位符,等待链接器为其重定位。而使用gcc -S命令从 .i 文件生成 .s 文件时,使用形式进行表示。

4.与-Og优化等级生成的汇编代码作比较,发现-Og优化等级下只开辟了8字节的栈空间而且过程中并没有被利用。与3.3.2中关于变量的操作不同,-Og优化等级下汇编代码使用寄存器保存了传递给main函数的argc和argv两个参数。其他部分两者大致相同。

图3.3.8.2 -Og优化等级下的主要不同点

3.4 本章小结

编译是生成可执行目标文件的第二步,将预处理后得到的hello.c文件编译得到hello.s文件,文件中是汇编代码表示的机器代码。本章着重解析hello.s文件中的汇编代码,分析汇编代码表示的总体流程,对关系操作、算术操作、控制转移等指令进行解读。分析了寄存器的一些特殊属性的具体体现,对栈的操作进行基本解读。.与-Og优化等级生成的汇编代码作比较,从汇编代码上看优化方式。实际上,hello.s中对汇编代码知识的覆盖还不够全面,一些逻辑操作、显/隐式类型转换、算术操作的特殊情况、结构体操作、对齐等没有明显地体现出来。

第4章 汇编

4.1 汇编的概念与作用

**概念:**汇编指由汇编器将 .s 文件翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在 .o 目标文件中的一个过程。 .o文件是一个二进制文件,包含着函数的指令编码,不能直接在文本编辑器中读取。

**作用:**将第二阶段生成的汇编代码翻译成机器可以执行的机器语言,产生 .o 二进制文件供下一阶段进行链接处理。

4.2 在Ubuntu下汇编的命令

Ubuntu下汇编命令:gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o

图4.2.1 Ubuntu下汇编及其中部分内容

4.3 可重定位目标elf格式

ELF可重定位目标文件格式基本基于以下格式:

ELF头
.text
.rodata
.data
.bss
.symtab
.rel.text
.rel.data
.debug
.line
.strtab
节头部表

以下是通过readelf -a hello.o指令列出的ELF可重定位目标文件的基本信息。

4.3.1 ELF头

ELF头以magic魔数:16字节开始。magic中最开始4个字节是所有ELF都必须相同的标识码;第5个字节表示ELF文件所运行环境的字长,0x02表示64bit,0x01表示32bit;第6个字节表示字节顺序,1为小端法,2为大端法。第7个字节为ELF的版本号,其余9个字节没有标准定义。

ELF头其余部分包含帮助链接器进行语法分析和解释目标文件的信息,包括ELF头大小、目标文件类型、机器类型、节头部表的文件偏移、节头的大小和数量等等。

图4.3.1 ELF头信息

4.3.2 节头部表

如图4.3.2所示,节头部表中包含了14个节以及每个节的名称、类型、大小、偏移量等信息。不同ELF文件包含的节不完全相同。查阅得到以下各节中保存的内容。

.text已编译程序的机器代码
.rela.text.rel.text,一个.text节中位置的列表
.data已初始化的全局和静态C变量
.bss未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量
.rodata只读数据
.comment包含版本控制信息
.note.GNU-stack可执行堆栈信息
.note.gnu.propert待查
.eh_frame包含异常解除和源语言信息
.rela.eh_frame包含针对.eh_frame的指标和二进制搜索表
.symtab符号表,存放在程序中定义和引用的函数和全局变量的信息
.strtab字符串表,内容包括.symtab节中的符号表以及节头部中的节名字
.shstrtab段表字符串表

分析节头表:

1.可重定位目标文件中,每个可装入节的起始地址总是0。

2.标志中A代表节将会分配存储空间,此节头表中分配存储空间的有:.text(可执行),.data/.bss(可读可写),.rodata(可读),.note.gnu.propert(可读),.eh_frame(可读)。

在这里插入图片描述

图4.3.2 节头部表

4.3.3 重定位条目

重定位条目中包含说明如何修改其节内容的信息。

偏移量Offset:对于可重定位文件,该值表示节偏移,重定位偏移会在第二节中指定一个存储单元。

信息Info:Info指定必须对其进行重定位的符号表索引以及要应用的重定位类型。重定位项的重定位类型或符号表索引是将宏定义的函数应用于Info所得的结果。

重定位类型Type:Type标注重定位类型,决定重定位计算的不同方式。

Addend:有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。

图4.3.3 重定位条目

4.3.4 符号表

.symtab节中存放的符号表。最后Name栏是符号名,Ndx栏表示符号被分配到目标文件的节的序号,即各节在节头部表中的序号,除序号外,ABS代表不该被重定位的符号,UNDEF代表未定义的符号,COMMOM表示还未被分配位置的未初始化的数据目标。Vis在C/C++中暂未使用,忽略。Bind栏表示该符号是局部LOCAL的还是全局GLOBAL的,Type栏列出各符号的类型,FILE文件、FUNC函数、SECTION节或是NOTYPE。Size给出符号代表的内容的大小,Value表示目标在所处节中的偏移量。

图4.3.4 符号表

4.4 Hello.o的结果解析

4.4.1 机器语言的构成

从反汇编结果中可以看到,机器语言中包括指令指示符、指令集对应的功能码、寄存器标识符、地址码、常数字以及为链接阶段准备的占位符。所有的数值都是以16进制表示的。Objdump根据这些16进制编码进行解读,结合不同节的信息反汇编得到汇编语言表示的机器代码。

图4.4.1.1 部分截图示例

4.4.2 与汇编语言的相同点

总体上看,hello.o的反汇编与-S编译产生的汇编语言大体上相同。hello.s文件中的汇编语言指令在反汇编结果中都能够找到一一对应的指令。两文件中的指令顺序相同,指令使用的寄存器不变。

在这里插入图片描述

图4.4.2.1 指令的一一对应

4.4.3 与汇编语言的区别

4.4.3.1 操作数

机器语言的操作数以16进制表示,而-S产生的汇编语言以10进制表示,换算后实际值仍然相同,下图标注出了一些对应的指令。

图4.4.3.1.1 进制比较

4.4.3.2 分支转移

-S产生的汇编语言中如涉及到分支转移,会以.L1、.L2等标号来标记跳转位置,跳转指令为、。反汇编得到的汇编代码表示的机器语言中,跳转指令是PC相对的编码形式。跳转时执行PC相对寻址。

下图标注出的指令是一个典型例子。左侧为hello.s中的跳转指令,右侧为反汇编中对应的指令。跳转目标.L4在反汇编中对应0x36位置。在执行相对寻址时,程序计数器的值为下一条指令的地址0x82。将0x82加上jle指令的目标编码0xb4,保留2位得到要跳转到的地址0x36。

图4.4.3.2.1 跳转指令

4.4.3.3 函数调用

hello.s文件中的汇编语言对于头文件中的函数以 形式表示函数调用,因为编译阶段并不清楚被调用函数的地址。机器语言中同样不明确地址,需要等待重定位,但汇编阶段会为其分配占位符,并在重定位节中产生一个重定位条目,为链接阶段做准备。

图4.4.3.3.1 函数调用指令比较

Objdump工具将节中重定位条目和指令放在了一起,标明了重定位Type和函数名+ Addend,便于阅读。

4.5 本章小结

本章主要分析产生成可执行目标文件的第三步——汇编,归纳了汇编过程的一些关键点。在Ubuntu下执行汇编命令,使用readelf -a命令展示hello.o文件信息,根据信息具体解读了可重定位目标文件ELF格式,理解其中的一些信息。使用Objdump对hello.o文件进行反汇编,将反汇编结果与hello.s汇编语言内容做比较,解读两者之间的相同点与差异,理解汇编阶段所做的一些工作,结合书中Y86-64指令集体系结构的内容,解读机器语言中的16进制指令编码。

第5章 链接

5.1 链接的概念与作用

**概念:**链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时、加载时或运行时。在现代系统中,链接是由叫做连接器的程序自动执行的。

**作用:**链接使得分离编译成为可能。在软件开发时,不需要将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当这些模块中的一个发生改变时,只需对改变的模块进行重新编译并重新链接应用,而不必重新编译其他文件。在链接基础上发展的共享库和动态链接等概念的重要性正日益加强。

5.2 在Ubuntu下链接的命令

在Ubuntu下链接的命令:ld -o hello -dynamic-linker /usr/lib64/ld-linux-x86-64.so.2 hello.o /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o

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

可执行文件hello的格式如下表:

ELF头
段头部表
.init
.text
.rodata
.data
.bss
.symtab
.debug
.line
.strtab
节头部表

使用readelf -a hello命令列出各段的基本信息,对其中的一部分分析如下。

5.3.1 ELF头

ELF头中的内容与可重定位目标文件hello.o中ELF头内容大致相同。对具体信息来说:

1.Type由REL(可重定位目标文件)变为EXEC(可执行目标文件)。

2.分配了入口点地址0x401180

3.更新了产生的程序头部表(Program Headers) 的信息

4. .text、.rodata、.data节被重定位到它们最终的运行时内存地址,节的位置和所占空间大小发生改变。

5.节的数目增加,部分可重定位目标文件中的节被删去。

图5.3.1.1 可执行目标文件ELF头

5.3.2 节头部表

对比可重定位目标文件中的节头部表,可执行目标文件中段数目更多,大多数已被分配地址空间。相应地,各段在段头部表中出现的顺序做出了调整。

图5.3.2.1 节头部表

5.3.3 程序头部表

程序头部表描述了可执行文件连续的片被映射到连续的内存段这种映射关系。Offset是段在目标文件中的偏移,VirtAddr/PhysAddr是段的起始地址,FileSiz是目标文件中的段大小,MemSiz是内存中的段大小,Flags是段的运行时访问权限,Align是段的对其要求。

MemSiz与FileSiz可能会有所不同,.bss节中未被初始化的全局或静态变量在目标文件中不占空间,而运行时会被初始化为0。

Align是针对内存传送的一种优化,对于任何段S,都需要满足:

图5.3.3.1 程序头部表

5.3.4 Section to Segment mapping

Section to Segment mapping中描述了段的数目和每个段中所包含的节。

图5.3.4.1 段节匹配

5.3.5 重定位条目

重定位条目中观察到偏移量、类型、符号名称和加数的变化。.rela.dyn中的条目表示链接的库中引入的符号。

图5.3.5.1 重定位条目

5.3.6 符号表

符号表的最大变化是加入了链接得到的符号,明确了各条目在所处节中的偏移量Value。

在这里插入图片描述

图5.3.6 .symtab符号表的部分内容

5.4 hello的虚拟地址空间

使用edb加载hello,通过View中Memory Regions查看hello对应的虚拟内存部分,在Data Dump中查看0x401000~0x402000、0x402000~0x403000、0x403000~0x405000三段虚拟内存的具体内容。

在Data Dump中查看5.3.3中程序头部表中每一段,虚拟地址空间中从VirtAddr开始的MemSize字节中保存着每一段的信息。

以下图中的LOAD段为例,LOAD段在虚拟地址空间中的起始地址为0x401000,大小245字节,即到0x401245地址为止。,在Data Dump中查看,在0x401000~0x401245空间中确实保存着段信息,0x401245后到下一段开始位置被闲置。

图5.4.1 LOAD段

图5.4.1 0x401000~0x401270虚拟地址空间中LOAD段

5.5 链接的重定位过程分析

在链接器完成符号解析后,就把代码中的每个符号引用和符号表中的一个符号定义条目关联起来,此时链接器知道了它所在的输入目标模块中的代码节和数据节的确切大小,然后开始重定位。

重定位由两步组成:重定位节和符号定义、重定位节中的符号引用。

5.5.1 重定位节和符号定义

在这一步中,链接器将所有相同类型的节合并为同一类型新的聚合节,新的聚合节成为可执行目标文件中的对应类型的节。

合并相同类型的节直接体现在节的大小变化。下图中第一组是可重定位目标文件中的.text节,其大小为0x8e字节;第二组是可执行目标文件中的.text节,在与其他可重定位目标文件和静态/动态链接共享库完成链接后,其大小增大到0x145字节。

图5.5.1.1 .text节链接前后大小对比

由于.text字节处在只读内存段,保存着已编译程序的机器代码,对于.text节的合并,也可以从对hello进行反汇编的结果obj_hello.txt中看到。

与hello.o文件进行反汇编的到的结果(obj_hello.o.txt)进行比较,链接后的hello的机器代码除自身<main>函数外,还包括<_start>函数、<_dl_relocate_static_pie>函数、<__libc_csu_init>函数、<__libc_csu_fini>函数的代码。同时反汇编结果中也有运行需要的<_fini>函数、初始化用<_init>函数等,只是被保存在不同节中。Objdump以形式标注了出来。

图5.5.1.2 链接后.text节中除main函数外其他的部分函数代码

对相同类型的节处理完成后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。这一步完成时,程序中的每条指令和全局变量都有唯一的运行内存地址。

obj_hello.txt与obj_hello.o.txt比较来说明显的区别是每行指令的地址不再是从0x0开始。main函数首条指令的地址被分配为虚拟地址空间中0x4010f0。后续指令地址以0x4010f0为起始依次增加。分析这一步也可以看到条件跳转指令相对寻址的优点,指令地址发生改变但条件跳转指令跳转位置编码没有变化。

图5.5.1.3 obj_hello.o.txt中main函数的起始

图5.5.1.4 obj_hello.txt中main函数的起始

5.5.2 重定位节中的符号引用

在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。执行这一步时链接器依赖于4.3.3 中提到的重定位条目。

图5.5.2.1 可重定位目标文件中重定位条目

下面对.rela.text中不同的重定位类型分别进行分析。由于在链接前的预处理、编译和汇编三个步骤的命令中都包含-fno-PIC,在这里暂时可以不考虑位置无关代码情况。书中将节s看作一个字节数组,因为这里符号引用的重定位都发生在节 .text中,所以这里认为s=ADDR(.text)=0x4010f0,*s=f3。

图5.5.2.2 s数组首地址与对应的值

5.5.2.1 R_X86_64_32类型

重定位类型是R_X86_64_32的条目为第一行和第四行的条目,需要重定位一个使用32位绝对地址的引用。查看obj_hello.o.txt中反汇编的内容,发现这两个.rodata是调用printf(puts)函数时作为第一个参数的字符串。

对于第一行的条目,先确定需要被重定位的四字节引用的地址refptr,即汇编阶段针对条目一设置的占位符的地址。占位符在.text节中,由图5.3.2.1节头部表,ADDR(.text)=0x4010f0。则refptr=0x4010f0+0x1a(Offset)=0x40110a。

重定位绝对引用。针对条目1,r.symbol=.rodata,r.addend=0, ADDR(.rodata)=0x402000。则*refptr= ADDR(r.symbol)+r.addend=0x402000。

针对条目2,使用同样的方法。通过计算,将refptr=0x401140位置的符号引用重定位到0x402026位置。

在反汇编结果中确认,两个引用位置确实被重定位到计算出的结果。

图5.5.2.1.1 汇编结果中条目1对应位置

图5.5.2.1.2 链接后条目1位置被重定位的结果

通过EDB检查0x402000和0x402026(箭头指向)位置保存的数据,确定这两个位置保存的是待传递的字符串,右侧栏里中文以乱码形式出现。

图5.5.2.1.3 EDB中查看虚拟内存位置数据

5.5.2.2 R_X86_64_PLT32类型

除前述两个条目外,其余条目的重定位类型均是R_X86_64_PLT32。这几个条目的符号是puts、printf等在main函数中调用的外部库中的函数名。这些函数在libc.so共享库中。

由于链接的库中有libc.so共享库,这里使用链接器(ld)的指令得到的可执行目标文件为部分链接的可执行目标文件,如果想要得到完全链接的可执行文件(内存中),需要借助加载器和动态链接器,经过一个完整的动态链接过程才能实现。

也正因为此,在5.3分析可执行目标文件ELF格式时,发现仍然有在5.3.5中展示的重定位条目,各重定位条目的符号是调用的共享库中的函数名+@GLIBC_2.2.5。

图5.5.2.2.1 ld链接后的重定位条目

不过在使用链接器(ld)创建可执行目标文件时静态执行了一些链接。从Objdump反汇编的结果中可以看到,和共享库有关的 .plt 节、.plt.sec节被加入了可执行目标文件中,与共享库有关的<__libc_csu_init>等函数被加入到 .text 节中。

图5.5.2.2.2 .plt节

图5.5.2.2.3 .text节中的<__libc_csu_init>函数

查看反汇编的结果,发现main函数中对puts、printf等符号引用已经进行了重定位,但并不是直接重定位到对应的puts、printf函数的机器代码部分。这并不影响对重定位过程的分析。

在一篇对PLT32的解读[4]中提到,R_X86_64_PLT32的寻址方式与R_X86_64_PC32寻址方式大体相同,唯一区别是在PLT32类型下,链接器计算相对引用的值时,计算公式中的ADDR(<r.symbol>)项被替换成ADDR(<r.symbol>@plt)。

下面以puts符号引用重定位为例进行分析。

在图5.5.2.1中得到关于puts的重定位条目的信息:r.offset=0x1f,r.symbol=puts,r.addend=-4。首先确定需要被重定位的四字节引用的地址refptr,四字节引用的地址在节 .text 中,refptr=s+r.offset=0x40110f。查看图5.3.2.1 节头部表得到ADDR(.text)=0x4010f0,则refaddr=ADDR(.text)+ r.offset=0x40110f。查看反汇编结果得知,链接器为puts@plt分配的地址ADDR(puts@plt)=0x401090,在 .plt.sec 节中。

根据重定位算法计算,*refptr=(unsigned)(ADDR(puts@plt)+r.addend-refaddr)

=0x401090-4-0x40110f=0xffffffd7。即将原来在refptr= 0x40110f位置的四字节占位符替换成相对引用0xffffffd7。

在反汇编结果中上述计算得到了验证。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kDDYVTnE-1653146754964)(https://gitee.com/twinblade/picture/raw/master/img/bb4a0da370dc36c414b7f43bc41a335c.png)]

图5.5.2.2.4 验证puts相对引用计算结果

5.6 hello的执行流程

使用edb执行hello,在每个子程序处设置断点,观察从加载hello到_start,到call main,以及程序终止的所有过程。下标按顺序列出了其调用与跳转的各个子程序名或程序地址。连续的重复调用被合并,重复次数记录在最后一栏。

子程序名程序地址重复调用次数
ld-2.31.so!__tunable_get_val0x00007f3dd0f305d06
ld-2.31.so!calloc0x00007f3dd0f335b02
ld-2.31.so!malloc0x00007f3dd0f33490
ld-2.31.so!malloc0x00007f3dd0f334901
ld-2.31.so!__tunable_get_val0x00007f3dd0f305d01
ld-2.31.so!malloc0x00007f3dd0f334903
ld-22.31.so!_dl_debug_state0x00007f3dd0f281d01
ld-2.31.so!_dl_catch_exception0x00007f3dd0f33c401
ld-2.31.so!__tunable_get_val0x00007f3dd0f305d01
ld-2.31.so!malloc0x00007f3dd0f334901
ld-2.31.so!free0x00007f3dd0f335f01
ld-2.31.so!calloc0x00007f3dd0f335b01
ld-2.31.so!malloc0x00007f3dd0f33490
ld-2.31.so!malloc0x00007f3dd0f334901
ld-2.31.so!_dl_catch_exception0x00007f3dd0e6e7b01
ld-2.31.so!malloc0x00007f3dd0f334902
ld-2.31.so!calloc0x00007f3dd0f335b05
ld-2.31.so!malloc0x00007f3dd0f33490
ld-2.31.so!malloc0x00007f3dd0f334901
ld-2.31.so!calloc0x00007f3dd0f335b01
ld-2.31.so!malloc0x00007f3dd0f33490
ld-2.31.so!_dl_allocate_tls_init0x00007f3dd0f2a7701
ld-2.31.so!_dl_debug_state0x00007f3dd0f281d01
hello!_start0x00000000004011801
libc-2.31.so!__libc_start_main0x00007f33bcafefc01
hello!__libc_csu_init0x00000000004011c01
hello!_init0x00000000004010001
hello!main0x00000000004010f01
hello!printf@plt0x00000000004010401循环8次
libc-2.31.so!printf0x00007f3dd0d6fcc01
libc-2.31.so!@plt0x00007f3dd0d301e01
ld-2.31.so!_dl_find_dso_for_object0x00007f3dd0f2b4801
libc-2.31.so!@plt0x00007f3dd0d301201
ld-2.31.so!__tunable_get_val0x00007f3dd0f305d012
hello!atoi@plt0x00000000004010601
libc-2.31.so!atoi0x00007f3dd0d525e01
hello!sleep@plt0x00000000004010801
libc-2.31.so!sleep0x00007f3dd0df0df01
libc-2.31.so!nanosleep0x00007f3dd0df0ee01
hello!getchar@plt0x00000000004010501
libc-2.31.so!getchar0x00007f3dd0d995901
libc-2.31.so!exit0x00007f3dd0d54a701
libc-2.31.so!exit0x00007f3dd0d54a701
hello!_fini0x00000000004012381

5.7 hello的动态链接分析

在5.5.2.2中说过,动态链接最终需要借助动态链接器来完成。动态链接器通过执行下面的重定位完成链接任务:重定位共享库libc.so的文本和数据到某个内存段、重定位hello中对由libc.so定义的符号的引用。完成后动态链接器将控制传给应用程序。从这个时刻开始共享库的位置固定,并且在程序执行的过程中不会改变。

图5.7.1 部分符号截图

在dl_init结束后,刷新EDB的Symbolviewer,发现出现libc.so定义的符号,内存位置已经确定。上图为部分符号的截图,在后续调试直至程序退出后,符号的位置不再改变。

5.7.1 分析GOT位置内存

hello调用puts等共享库中的函数,这是对全局变量的PIC引用,涉及到全局偏移量表GOT和过程链接表PLT。查看图5.3.2.1 节头部表,找到GOT地址为0x404000,PLT地址为0x401020。

dl_init前,在EDB查看内存位置内容,如下图所示,此时GOT中内容为初始化的值,GOT[0]为 .dynamic 节的地址,GOT[1]、GOT[2]、GOT[3]为空,后续条目对应被调用函数在代码段中的地址,还未被映射到共享库。

图5.7.1.1 dl_init前GOT内容

dl_init结束后GOT内容如下图。此时看到GOT[0]、GOT[1]包含动态链接器在解析函数地址时会使用的信息,GOT[2]是动态链接器的入口点,查看入口点位置的代码,如图5.7.4所示。GOT后续条目保存的调用的函数代码段中的地址在运行过程中被实时更改,图5.7.3截取时已经调用过printf函数,可以看到地址0x404020位置值的变化。后面会对这个做更详细的分析。

图5.7.1.2 dl_init后GOT内容

图5.7.1.3 动态链接器的入口点位置的代码

5.7.2 分析运行时GOT[2]之后的条目

解析前先明确一些地址对应的代码部分:

0x401020.plt节
0x401030puts@plt函数
0x401040printf@plt函数
0x401050getchar@plt函数
0x401060atoi@plt函数
0x401070exit@plt函数
0x401080sleep@plt函数
0x401090.plt.sec节

GOT中的条目是在运行过程中被动态实时更改的,查看图5.7.2,在内存位置0x404020位置是printf@plt函数的地址0x401040。图5.7.5已经执行过libc.so中的printf函数,对应GOT中0x404020位置的值被改写为0x7f0102e50cc,在EDB/Symbolview中查找,改写后的地址正是printf函数的入口点。

图5.7.2.1 调用了部分函数后GOT中的值

图5.7.2.2 printf

对于GOT[2]后的其他条目都可以用上述方法解释。下图为hello运行结束后GOT中的值,注意到要调用的6个函数对应的GOT条目有4个被改写。特别地,位于0x404018、0x404038位置的GOT条目没有被更改,此两条目内容作为地址分别对应puts@plt函数和exit@plt函数,3.3中解释过,在hello运行时,先检查命令行输入参数,如果参数数目不等于4,会调用这两个函数。EDB调试时,输入参数为4,正常运行,这两个函数没有被调用,故对应的两个GOT条目没有被改写。从这里也可以看出共享库函数调用在运行时动态完成链接过程的特点。

图5.7.2.3 程序运行结束后GOT中的值

5.7.3 分析过程链接表PLT

过程链接表PLT位于代码段,地址为0x401020,在代码中可见,左侧箭头指向处为PLT表起始点。这里分析的PLT结构与书中讲述的略有不同,下面的分析中会提到。

根据书中讲述,PLT[0]是一个特殊条目,它跳转到动态连接器里,每个被可执行程序调用的库函数都有它自己的PLT条目,每个条目负责调用一个具体的函数。PLT[0]的跳转指令在下图橙黄色方框内,指令指定跳转位置为0x2fe3+%rip位置保存的地址。此处%rip=0x40102d,计算得%rip+0x2fe3=0x404010,即GOT[2]条目的地址,根据5.7.1中表述,GOT[2]保存动态链接器的入口点。与书中讲述相同。

图5.7.3.1 PLT表部分截图

从PLT[1]开始之后的条目与书中讲述不同。书中的示例中从PLT[1]开始,每个条目包括两个相邻的跳转指令,一个负责跳转到GOT对应条目中保存的地址,一个负责跳转到PLT[0]。

在实际分析中,这两个跳转指令是分开的。书中提到的每个条目负责跳转到PLT[0]的指令被保存在0x401020开始的 .plt节中,完整的PLT[0]条目也在这里,如上图所示。负责跳转到GOT对应条目中保存的地址的指令被保存在0x401090开始的 .plt.sec 节中,如下图所示。下图为.plt.sec节的完整内容,可以看到里面有6条跳转指令。

橙黄色方框中框出的是 .plt.sec 的第一部分,分析其中的jmpq指令。%rip=0x40109b,0x2f7d+%rip=0x404018,为GOT[3]条目的地址,对应的函数为puts函数。.plt.sec 中类似的部分总共有6个,每个部分有16字节。而查看每部分的jmpq指令跳转相对地址,发现相邻两部分中后一部分jmpq跳转相对地址较前一部分少8。计算下来跳转到的绝对地址相邻两部分中后一部分比前一部分多8,正好对应GOT中一个条目的大小。

这样便得知,真正负责main中函数调用的PLT条目在 .plt.sec 节中。猜测分析的PLT与书中示例不同的原因是跳转指令代码和相对地址长度不同,16字节无法容纳两条jmp指令和push等所需的其他指令。

在下面对PIC函数调用的步骤分析中,为了能够按书中所给运行步骤理解,这里将 .plt 节和 .plt.sec 节共同视为PLT,PLT[0]条目位于 .plt 节起始位置0x401020处,PLT[1]及后续的共6个条目位于0x401090开始的 .plt.sec 节中。为了PLT[1]及之后的条目符合书中的描述,将 .plt节中含有负责跳转到PLT[0]的指令的部分视为 .plt.sec 节中对应的PLT条目的一部分。

图5.7.3.2 完整的 .plt.sec 节

5.7.4 分析共享库函数的调用步骤

下面结合书中对PIC函数调用讲解和上面5.7.3中分析的实际PLT的与书中的区别,对main函数中调用共享库libc.so中printf函数的过程进行分析。

在dl_init前,GOT[3]以及之后的条目保存着PLT[3] 以及之后的条目中跳转到PLT[0]的代码部分的首地址。

在这里插入图片描述

图5.7.4.1 dl_init前GOT

main函数调用printf的call指令调用的地址总是0x4010a0,即 .plt.sec 中的PLT[2]条目。

图5.7.4.2 main函数中调用printf的指令

当第一次调用printf函数时,进程调用进入PLT[2],执行PLT[2]中的代码。PLT[2]中的跳转指令(下图框出的部分)通过GOT[4]进行间接跳转,此时参考图5.7.4.1,GOT[4]=0x401040。0x401040是 .plt 节中PLT[2]中跳转到PLT[0]的指令的部分。

图5.7.4.3 PLT[2]中的跳转指令

图5.7.4.4 .plt 节中PLT[2]中跳转到PLT[0]的指令的部分

执行0x401040部分的指令,将printf的ID(0x1)压入栈后,经jmp指令,PLT[2]跳转到PLT[0]。PLT[0]通过GOT[1]间接地将动态链接器的一个参数压入栈中,然后通过GOT[2]间接跳转进动态链接器中。动态链接器使用两个栈条目确定printf地运行地址,用这个地址重写GOT[4],再把控制传递给printf。

图5.7.4.5 重写后的GOT[4]

图5.7.4.6 Symbolviewer中查找的printf对应地址

如5.6函数执行流程调用表中记录,后续多次调用printf函数。后续调用时,仍是在main函数中将控制传递到PLT[2],PLT[2]中的跳转指令通过GOT[4]进行间接跳转。但此时GOT[4]已被重写为共享库中printf函数在内存中的地址,则可以从PLT[2]中直接跳转到printf函数并执行。

5.8 本章小结

本章分析了产生成可执行目标文件的最后一步——链接,明确了链接的概念和过程,对链接产生的可执行目标文件的格式和内容做了比较详细的分析,使用EDB具体查看了虚拟地址空间。5.5中详细分析了链接的重定位过程,包括重定位节和符号定义、重定位节中的符号引用,重定位节中的符号引用中有具体讨论了两种典型的重定位类型。本章的重点放在了5.6~5.7中,对可执行目标文件的执行过程进行了解读。5.6中使用EDB设置断点具体跟踪hello的执行流程,5.7节中使用EDB读取内存中的内容,结合前述节头部表等已有信息,参考书中位置无关代码的内容,详细分析解读了GOT、PLT和调用共享库函数的动态链接过程。

第6章 Hello进程管理

6.1 进程的概念与作用

**概念:**进程是一个执行中程序的实例,每一个进程都有自己的地址空间。系统中的每个程序都运行在某个进程的上下文中。

**作用:**进程是系统进行资源分配和调度的基本单位,是操作系统结构的基础。进程提供给应用程序关键抽象:一个独立的逻辑控制流,它提供一个假象,好像程序独占地使用处理器;一个私有的地址空间,它提供一个假象,好像程序独占地使用内存系统。通过进程概念可以有效管理和调度进入计算机系统主存储器运行的程序。

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

Shell-Bash的作用:

Shell 是一种命令行解释器,是一个交互型应用级程序,介于系统调用/库与应用程序之间,代表用户运行其他程序。Bash(Bourne Again SHell)是是一些类 UNIX 系统与多数 Linux 发行版的默认 Shell。

Shell-Bash在执行时会有一系列的读/求值步骤,步骤完成后终止。交互式的Shell读步骤读取来自用户的一个命令行,读步骤完成后求值步骤解析命令行,然后代表用户/系统脚本运行程序。

就具体功能来说,Shell-Bash的作用包括执行内置命令、进行作业控制、文件重定向、通过管道连接实现复杂功能处理等[7]。

Shell-Bash处理流程:

针对交互式Shell-Bash的处理流程,Shell-Bash先打印一个命令行提示符,等待用户输入命令行,用户输入后对这个命令行求值。对命令行求值时先切割命令行为token,之后会对第一个 token 进行别名检查,若其为别名则进行展开, 并对展开的字符串继续进行切割。之后会进行一系列展开操作,过程中也会考虑对双引号、单引号等的处理。最后进行命令查找,判断命令是函数,内部命令还是可执行文件。如果键入的命令在列表中没有找到,会显示对应的错误信息与一些提示信息。如果查找成功,则会在特定环境中执行命令。

6.3 Hello的fork进程创建过程

在Shell-Bash中输入命令行,Bash作为父进程调用fork函数创建一个新的子进程hello。hello作为Bash的子进程,几乎但不完全与父进程相同。子进程hello得到了与父进程用户级虚拟地址空间相同但是独立的一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得了与父进程任何打开文件描述符相同的副本,这意味着子进程hello可以读写父进程中打开的任何文件。

hello的fork进程创建过程中,fork函数只被父进程Bash调用一次,返回两次。在hello子进程中,fork返回0,在Bash父进程中,fork返回Hello子进程的PID。下图为hello的进程图。

图6.3.1 进程图

下面给出了一个示例。输入命令行,让hello进程在后台执行,过程中使用ps命令查看当前进程的PID,hello进程的PID就是Bash调用fork创建后台执行的hello子进程时打印的ID,这符合上面fork给父进程返回子进程PID的描述。

图6.3.2 fork进程创建与ps查看PID

在这个示例中,也可以看到fork函数的另外两个特征:

①并发执行:父进程和子进程是并发运行的独立进程。在后台运行hello进程时,Bash进程仍然正常执行,创建了ps进程。

②共享文件:执行测试时,可以看到hello后台进程输出的结果和ps进程输出的结果都被显示在屏幕上。原因是hello子进程和ps子进程继承了Bash父进程所有的打开文件,其中包括stdout文件。stdout文件指向屏幕,hello、ps两个子进程继承了这个文件,两个进程连同Bash进程的输出都是指向屏幕的。

6.4 Hello的execve过程

execve函数在当前进程的上下文中加载并运行一个新程序。

在输入命令行并回车后,Bash会先解析以空格分隔的命令行参数,构造最终会传递给execve的argv向量。在fork进程创建完成后,execve函数被调用,在当前进程的上下文中加载并运行一个新程序。

execve函数在头文件unistd.h中,其定义如下:

Bash调用execve时调用代码为 ,其中environ是在unistd.h头文件里已声明的环境变量,argv指向一个以null结尾的指针数组,其中每个指针都指向一个字符串,argv[0]=“./hello”。

图6.4.1 argv结构

图6.4.2 envp结构

图6.4.3 部分环境变量截图

execve函数加载并运行可执行目标文件hello,在找到并加载了hello之后,execve调用hello!_start启动代码。hello!_start函数调用libc-2.31.so!__libc_start_main系统启动函数,系统启动函数调用hello!__libc_csu_init等函数初始化执行环境,设置栈并将控制传递给新程序的主函数main。下表为5.6 中hello的执行流程表的部分截取,这展示了execve的部分加载过程和加载完成后调用启动代码直到控制传递给新程序的主函数的流程。

函数名函数地址调用次数
ld-2.31.so!calloc0x00007f3dd0f335b05
ld-2.31.so!malloc0x00007f3dd0f33490
ld-2.31.so!malloc0x00007f3dd0f334901
ld-2.31.so!calloc0x00007f3dd0f335b01
ld-2.31.so!malloc0x00007f3dd0f33490
ld-2.31.so!_dl_allocate_tls_init0x00007f3dd0f2a7701
ld-2.31.so!_dl_debug_state0x00007f3dd0f281d01
hello!_start0x00000000004011801
libc-2.31.so!__libc_start_main0x00007f33bcafefc01
hello!__libc_csu_init0x00000000004011c01
hello!_init0x00000000004010001
hello!main0x00000000004010f01

这里可以解读主函数main的原型。hello.c中给出的main函数原型如下:

argc为argv[]中非空指针的数量,argv指向argv[]数组的第一个条目。这里没有通常main主函数原型中的参数,envp参数指向envp[]数组中的第一个条目。

main开始执行时,用户栈的组织结构如下图所示。使用EDB在main开始处设置断点,运行到main时停止,查看Register值如图6.4.5所示。根据栈组织结构,参数argc在%rdi中,argv在%rsi中,envp在%rdx中。

从图中可以看到,%rdi值为4,因为命令行“./hello hello 1 10”中有四个参数。%rsi=0x00007ffd457df268,%rdx=0x00007ffd457df290分别是argv、envp在栈中的地址。查看Stack中内容,%rsi=0x00007ffd457df268地址保存argv[]数组,%rdx=0x00007ffd457df290位置开始保存envp[]数组,内容即是图6.4.3中使用env指令查看的环境变量。

图6.4.4 用户栈组织结构

图6.4.5 Registers

图6.4.6 Stack从0x00007ffd457e0309开始部分内容截取

6.5 Hello的进程执行

hello进程执行过程中,系统中通常有许多其他程序正在运行。进程可以向每个程序提供一种假象,好像程序在独占地使用处理器并独占地使用系统地址空间。下面分几个部分按顺序阐述阐述hello进程调度的过程。

6.5.1 逻辑控制流与时间片

由于hello进程执行时,系统在同一时段会有多个进程在运行,每个进程对应一个逻辑控制流。系统控制多个逻辑流并发地运行,进程轮流地使用处理器,这使得hello进程由多个时间片组成。在多个流并发地执行时,hello进程执行控制流的一部分后,到了执行sleep函数等时刻时,内核可以决定抢占hello进程。如果hello被抢占,即hello进程暂时挂起,内核通过调度器进行调度,重新开始一个先前被抢占了的其他进程。

在这里插入图片描述

图6.5.1.1 逻辑控制流示例

6.5.2 上下文切换

在内核抢占当前进程并调度新的进程时,内核会使用上下文切换机制将控制从hello进程转移到调度的新的进程。上下文切换会保存当前进程的上下文,恢复某个先前被抢占的进程被保存的上下文并将控制传递给调度的新恢复的进程。上下文信息中包含内核重新启动一个被抢占的进程所需的状态,包括被抢占进程在被抢占时寄存器的值、用户栈、内核栈、页表、进程表、文件表等信息。

有几种情况会引发上下文切换:

①内核代表用户执行系统调用:如果系统调用因为等待某个时间而发生阻塞,那么内核可以让当前进程休眠,切换到另一个进程。一般而言,即使系统调用没有阻塞,内核也可以选择执行上下文切换。

②中断可能引发上下文切换:中断时内核可以判断当前进程是否已经运行了足够长的时间,并由判断结果决定是否引发上下文切换。

对hello进程调度,分析hello.c代码,可能引发上下文切换的有以下几点:

①printf函数调用:printf函数在Bash交互界面打印出相应信息,6.3中提到,hello进程与父进程Bash共享stdout文件,printf打印信息时需要将字符串写入stdout中,过程中进行了write系统调用。write系统调用需要访问磁盘,内核可以决定在数据访问磁盘的时间段内执行上下文切换,运行另外一个进程,直到磁盘发出一个中断信号,表示数据传输完成,内核判断新进程运行了足够长的时间,进行上下文切换,将控制返回给hello中调用write后的指令。

图6.5.2.1 printf函数调用

②sleep系统调用:sleep函数显式地请求让hello进程休眠。休眠时系统执行上下文切换,休眠结束后将控制返回给hello进程。

图6.5.2.2 sleep系统调用

③getchar函数调用:getchar函数执行过程中进行了read系统调用,内核可以根据read系统调用情况决定是否进行上下文切换。

图6.5.2.3 getchar函数调用

要注意的是,上下文切换过程中,没有单独的内核进程。如下图所示:上下文切换过程中,在切换点前(第一部分),内核代表hello进程在内核模式执行指令;在某一时刻(切换点),内核开始代表进程A在内核模式执行指令;上下文切换完成后,内核代表新进程在用户模式执行指令。

图6.5.2.4 上下文切换示例

6.5.3 用户态与内核态转换

运行hello时初始时处于用户模式中,没有设置模式位。处于用户态的hello进程不被允许执行特权指令(停止处理器、发起I/O操作、直接引用进程地址空间中内核区内的代码和数据等),hello程序必须通过系统调用接口间接地访问内核代码和数据。

hello进程从用户态转换为内核态地唯一方法是产生中断、陷阱或故障异常。异常产生后,控制被传递到异常处理程序,处理器设置模式位,从用户态转换为内核态,在内核态运行异常处理程序。异常处理程序返回后,处理器又会把模式从内核态转换回用户态。

hello进程中用户态与内核态转换常发生在系统调用产生陷阱异常和6.5.2中描述的上下文切换的过程中。如上文所述,hello中的系统调用为write、sleep和read系统函数调用。

图6.5.3.1 陷阱处理与用户态内核态转换

6.6 Hello的异常与信号处理

6.6.1 hello执行过程中的产生的异常

hello执行过程中会产生中断、陷阱异常。

**中断异常:**中断异常异步发生,是来自处理器外部I/O设备的信号的结果,包括在运行时输入CTRL-Z/CTRL-C导致内核向hello进程发送SIGSTP/SIGINT信号。对于中断异常,在当前指令完成后,处理器注意到中断引脚电压变高,就从系统总线读取异常号,然后调用异常处理程序,如果异常处理程序返回,则将控制返回给进程中下一条指令。

图6.6.1.1 中断处理

**陷阱异常:**陷阱异常同步发生,是hello进程执行一条指令的结果,hello进程产生陷阱异常的主要原因是系统调用,处理过程与中断异常大致相同。

图6.6.1.2 陷阱处理

**故障异常:**故障异常同步发生,是由进程执行过程中的错误情况引起的,它可能能够被故障处理程序修正。hello进程中经典的故障是缺页异常,缺页异常产生后,控制被传递给缺页处理程序。缺页处理程序从磁盘加载适当的页面,然后将控制返回给引发缺页异常的指令,该指令重新执行,缺页异常被修复。如果缺页处理过程中访问的地址内存非法,内核会发送一个SIGSEGV信号,CPU注意到该信号,会导致hello进程终止。缺页处理会在7.8中具体陈述。

图6.6.1.3 故障处理

6.6.2 运行时输入带来的异常和异常与信号的处理

6.6.2.1 Ctrl+Z

输入命令行并按回车执行后,输入Ctrl+Z。此时内核发送一个SIGTSTP信号到前台的hello进程导致hello产生中断异常,hello进程被挂起,Bash中输出了一行信息。此时运行pstree命令查看进程树,部分截图如下:

图6.6.2.1.1 进程树部分截图

Ctrl+Z后,运行ps命令,列出了当前的进程信息,可以看到hello进程没有终止。运行jobs指令,列出当前尚未完成的作业,Bash显示了信息,表明当前作业hello被挂起并放在后台。

图6.6.2.1.1 Ctrl+Z后运行ps、jobs命令

然后运行fg命令。因hello作业处于停止状态,fg命令给进程组的每个进程发SIGCONT信号,恢复hello进程运行并将其提至前台。如下图所示,运行fg命令行,hello进程恢复运行,打印了剩下的7个字符串,键入回车后终止。

图6.6.2.1.2 运行fg命令后的结果

再次输入命令行并按回车执行,输入Ctrl+Z。运行ps命令查看hello进程PID=14412后,运行命令。将SIGKILL信号发送给hello进程,hello进程收到信号产生中断异常。特别的是,由于信号是SIGKILL信号,中断异常处理程序没有返回。

下图是Ctrl+Z后运行命令后的截图。运行ps命令,发现hello作业已经被终止,ps命令打印出了一条信息。

图6.6.2.1.3 运行命令后的结果

6.6.2.2 Ctrl+C

运行命令后,键入Ctrl+C。Ctrl+C导致内核发送一个SIGINT信号到前台进程组中的每个进程,因为键入Ctrl+C时前台进程只有hello,hello进程被终止。同样的,内核发送SIGINT信号后,hello进程产生中断异常,进入对应终端异常处理程序后程序被终止没有返回。

图6.6.2.2.1 Ctrl+C后的结果

6.6.2.3 运行过程中不停输入情况

hello进程运行时乱输入一些字符和回车,输入回车的位置在下图中用箭头标出。

在hello进程执行时,乱按的输入存在输入缓冲区中。由于hello程序在执行8次printf、sleep循环时没有读取指令,这时存在输入标准输入stdin的输入被忽略。当hello调用getchar函数时, stdin流第一行的(“adfasfsaf\r\n”)被读取到缓冲区中,getchar函数调用结束,返回缓冲区中的第一个字符。hello程序退出,缓冲区被清空。hello进程结束返回到父进程Bash后,Bash读取标准输入stdin中第一行(输入的第二行)字符串(“afsasfk\r\n”),解析后发现无法执行,然后再次读入,由于最后一次读入的字符串不是以回车“\r\n”结尾,Bash将其显示在Terminal中,没有执行。

图6.6.2.3 运行过程中输入与运行结束后情况

6.7本章小结

本章解读了Hello的进程管理。从进程的概念开始,结合壳Shell-Bash,对hello的fork进程创建过程、execve过程、hello进程的执行过程和hello进程执行过程中的异常与信号处理进行了简单的分析。本章结合书中知识,比较全面地覆盖了异常、逻辑控制流、并发、时间片、上下文切换、用户模式与内核模式、子进程fork创建、execve加载与运行以及信号等内容。

第7章 hello的存储管理

7.1 hello的存储器地址空间

7.1.1 逻辑地址

在有地址变换功能的计算机中,访问指令给出的地址(操作数)叫做逻辑地址,也叫相对地址。逻辑地址由两个地址分量构成,一个为段标识符(段基址),另一个为段内偏移量(偏移地址),通常表示为[段标识符:段内偏移量]的形式,也可表示为[CS/ES/SS/DS/FS/GS:Offset]形式,前者为CS/ES/SS/DS/FS/GS段寄存器中的值。

7.1.2 线性地址

线性地址是逻辑地址到物理地址变换之间的中间层。将其称之为线性是因为其构成的地址空间中的整数是连续的。线性地址由逻辑地址变换生成,由逻辑地址的段标识符确定段基地址,然后加上逻辑地址的段内偏移量即得到线性地址。

如果没有启用分页机制,线性地址直接就是物理地址。如果启用了缓存分页机制,那么线性地址可以再次经过变换产生一个物理地址,因此把它称为“中间层”。

7.1.3 虚拟地址

CPU启动保护模式后,程序运行在虚拟地址空间中,虚拟地址空间是对主存的一种抽象的概念,它为每个进程提供了一个大的、一致的和私有的地址空间。此时运行在虚拟地址空间中的程序访问主存所使用的地址称为虚拟地址。虚拟地址也可以写为[段标识符:偏移量]的形式。

在hello反汇编中看到的jmp指令/call指令要跳转到/调用的地址即是虚拟地址。实际上,应用程序中所用的地址,包括指针等指向的地址、代码地址等都是虚拟地址。

图7.1.3.1 虚拟地址示例

7.1.4 物理地址

从内存寻址方式上来看,物理地址与地址总线相对应,在地址总线上以电子形式存在,数字表示的物理地址只是硬件提供给软件的抽象。物理地址是从逻辑地址、虚拟地址、线性地址进行地址变换的最终结果。

从物理内存上看,存储器中信息以字节为单位进行存储,为了正确地存放或取得信息,每一个字节单元被赋予了一个唯一的存储器地址,这个地址称为物理地址。

7.1.5 四种地址之间的关系

1. x86段页式内存管理机制

从虚拟地址转换到物理地址的流程:逻辑地址线性地址物理地址。

在这里虚拟地址可以被当作与物理地址(实地址)相对的概念。

图7.4.5.1 x86 CPU 段页式内存管理机制

2. x86 Linux内核

Linux内核只需要页式内存管理即可完成Linux内核需要的所有功能,于是Linux内核将所有段重合,所有段内空间与整个线性空间重合。于是上面提到的段基地址被统一设置为0,在这种情况下逻辑地址、线性地址、虚拟地址是一致的。

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

7.2.1 段式管理相关概念

首先看逻辑地址到线性地址变换过程中涉及到的一些概念[8]。

逻辑地址由两部分组成[段标识符:段内偏移量],段标识符由一个16位长的字段组成,称为段选择符。段选择符的构成如下图,前13位是索引号,后3位为TL和RPL字段。

(img-9lZtEMag-1653146754979)(https://gitee.com/twinblade/picture/raw/master/img/ad6731a69953dcb9cae059bab160507c.jpeg)]

图7.2.1.1 段选择符结构

索引号对应着段描述符表中的一个条目,段描述符表中每一个条目具体描述了一个段。段描述符表分为全局段描述符表(GDT)和局部段描述符表(LDT),在逻辑地址到线性地址变换时,访问GDT/LDT由段选择符中TL字段决定。TL字段为0时访问GDT,为1时访问LDT。

对GDT/LDT的访问需要借助CPU中gdtr/ldtr寄存器。gdtr寄存器中存放GDT在内存中的地址和大小,ldtr寄存器中存放LDT在内存中地址和大小。GDT/LDT地址在hello每次运行时是不同的。下图为使用VS在hello中加入几行代码,在x86下运行输出的gdtr/ldtr寄存器中的内容。

图7.2.1.2 gdtr/ldtr寄存器内容

段描述符具体组成如下图。

图7.2.1.3 段描述符具体组成

段描述符占64为空间,大小为8字节。分析逻辑地址到线性地址变换时,这里只考虑它的Base字段。Base字段描述了一个段的开始位置的线性地址。

7.2.2 逻辑地址到线性地址变换的总体流程

下面是逻辑地址到线性地址变换的总体流程:

1.看段选择符的T1字段为0还是1,由此知道当前要转换时需要访问GDT还是LDT,再根据相应寄存器,得到段描述符表的地址和大小。

2.取段选择符中前13位索引号,在选择的段描述符表中查找对应的段描述符。

3.取查找到的段描述符中的Base字段和逻辑地址中的段偏移量Offset,Base+Offset即是要转换的线性地址。

图7.2.2.1 逻辑地址到线性地址变换的总体流程[8]

7.2.3 示例

在Windows下使用VS对hello进行调试,查看输出的gdtr/ldtr寄存器信息和调试窗口中段寄存器信息。

图7.2.3.1 gdtr/ldtr寄存器信息

图7.2.3.2 段寄存器信息

对逻辑地址[CS:Offset]进行分析:

①CS=0x0023=0000 0000 0010 0011B,此为段标识符。

②前13位索引号为0000 0000 0010 0B,值为4(十进制)。

③段标识符中TL字段值为0,代表需要访问GDT。从输出的gdtr寄存器的值可以看到,GDT地址为0x46d1ffb0,大小0x0057字节。

④段索引号为4,意味着查找的是GDT中第4个条目。GDT中每条目大小8字节,则索引号*8+GDTR为待查找条目的地址。

⑤取查找到的条目的Base字段的值,Base+Offset即为转换后的线性地址。

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

如7.1.5中提到的,在Linux下逻辑地址、线性地址、虚拟地址是一致的,在x86段页式内存管理机制中虚拟地址与物理地址相对,范围包括了线性地址,所以在这里将线性地址和提到的虚拟地址视为等同。

7.3.1 页式管理相关概念

1.虚拟页与物理页

虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组,每字节都有一个唯一的虚拟地址,作为到数组的索引。由于磁盘上的数组的内容被分割成块,块作为磁盘和主存之间的传输单元,为处理这个问题,VM系统通过将虚拟内存分割成大小固定的虚拟页(VP),每个虚拟页的大小为P=2p字节。

物理内存类似地被分割为物理页(PP),大小也是P=2p字节。物理页也被称为页帧。

2.页表与页表条目

页表是一个常驻内存的数据结构,是一个页表条目PTE的数组,它将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容。操作系统为每个进程提供了一个独立的页表,对应着一个独立的虚拟地址空间。

虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。页表条目由有效位、许可位和n位地址字段组成。有效位表明该页表条目对应的虚拟页是否已被缓存到DRAM中。许可位控制用户进程对内存系统的访问。

图7.3.1.1 页表、页条目的组成与映射关系

7.3.2 线性地址到物理地址的变换

线性地址到物理地址的变换,也即地址翻译,是从一个N元素的虚拟地址空间到一个M元素的物理地址空间的映射。地址翻译主要由MMU(内存管理单元)中的地址翻译硬件进行处理。

7.3.2.1 单级页表情况

先考虑最简单的使用单级页表、中途不借助其他辅助的从线性地址到物理地址的变换,变换流程如下图。CPU的一个控制寄存器,页表基址寄存器(PTRB)指向当前页表。n位线性地址(虚拟地址)包含两部分:p位的虚拟页面偏移(VPO)和(n-p)位的虚拟页号(VPN)。MMU利用VPN选择适当的PTE,将页表条目中的物理页号(PPN)和虚拟地址中的VPO串联起来就得到了相应的物理地址,对于缺页的情况会在7.8中描述。

在这里插入图片描述

图7.3.2.1.1 线性地址到物理地址的变换

7.3.2.2 单级页表+TLB辅助情况

下面分析在TLB辅助、单级页表情况下MMU进行的从线性地址到物理地址的变换。为加速地址翻译,许多系统在MMU中包括了一个翻译后备缓冲器TLB。

TLB是一个小的、虚拟地址的全相联的高速缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常具有高度的相连度。借助TLB实现从线性地址到物理地址的变换时,虚拟地址被解读如下:

图7.3.2.2.1 虚拟地址字段部分

用于组选择和行匹配的索引和标记字段是从VPN中提取出来的。TLB有T=2t个组时,VPN的t个最低位组成了TLB索引(TLBI),VPN中剩余的位组成了TLB标记(TLBT)。

在虚拟地址转换为物理地址的过程中,虚拟地址按以上方式被解读。MMU根据解读出的TLB索引与标记在TLB对应的组、组内对应的行中读取PTE。然后将PTE中的PPN与虚拟地址中的PPO串联起来就得到了物理地址。

7.3.2.3 多级页表情况

下面分析k级页表层次结构的地址翻译。具体流程如下图。

进行k级页表层次结构的地址翻译时,虚拟地址被划分成k个VPN和1个VPO,VPO仍占据虚拟地址的最低p位。k个VPN从左到右被编号为VPN 1,···,VPN k。除VPN k外每个VPN i都是一个到第i级页表的索引,最后一级页表中的每个PTE包含对应的PPN。

图7.3.2.3.1 使用k级页表的地址翻译

地址翻译的过程中,先根据k个VPN逐级访问页表获得对应的PTE,在访问第k级页表前,在第i (1≤i<k)级页表中获得的PTE均是指向下一级页表的基址。访问k级页表时,获得包含物理地址的PTE,将PTE中的PPN与虚拟地址中的PPO串联起来就得到了物理地址,在这里PPO仍然与VPO相同。

7.3.2.4 各情况总结

总的来说,所有不同种类的线性地址到物理地址的变换的过程大体上都可以用图7.3.2.1.1来解读。变换过程的不同体现在图中标记出的MMU利用VPN选择PTE的过程当中。

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

在上面7.3.2中对单级页表+TLB辅助情况、多级页表情况的VA到PA的变换分别进行了分析。TLB与四级页表支持下的VA到PA的变换是上面两种情况组合起来的实例。

下图展示了Core i7/Linux内存系统在TLB与四级页表支持下的VA到PA的变换流程,不同底层体系结构在包括TLB组数/行数、各字段的位数等细节上可能会有差异,但总体流程大致相同。下面针对该图进行分析。

在这里插入图片描述

图7.4.1 TLB与四级页表支持下的VA到PA的变换过程

在上图示例中Core i7支持48位虚拟地址空间和52位物理地址空间。Linux使用的是4KB=212Bytes的页,则虚拟页偏移量VPO和物理页偏移量PPO均为12位。PTE大小为64Bits=8Bytes,页大小4KB=212Bytes,则每级页表中含有29=512个PTE,对应VPN i为9位。此处TLB虚拟寻址,4路组相联,共有16组,故TLBI=4,TLBT=36-4=32。CR3作为控制寄存器,指向第1级页表的起始部分,CR3的值是每个进程上下文的一部分,每次上下文切换时,CR3的值都会被恢复。

图7.4.2 第1/2/3级页表与第四级页表的PTE结构

VA到PA变换流程如下:CPU生成虚拟地址后,MMU根据解读出的TLB索引与标记在TLB对应的组、组内对应的行中寻找PTE,如果命中且PTE有效位为1,则将PTE中的PPN与VA中的PPO串联得到物理地址。如果TLB不命中,则通过4级页表进行地址转换。CR3包含第1级页表的物理地址,根据CR3的值,从第1级页表开始,MMU根据4个VPN逐级访问页表获得对应的PTE。第4级页表获得的PTE中含有PPN,将PPN与VA中的VPO串联得到物理地址,同时会更新TLB。整个过程中若读取到的PTE有效位为0,则会进行缺页处理,更新PTE后再次执行产生缺页异常的指令。

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

三级Cache支持下的物理内存访问大体流程如下图:

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

示例中对三级Cache有条件限定:L1、L2和L3高速缓存是物理寻址的,块大小为64字节。L1是8路组相联的,每组中有8行。L2是8路组相联的,L3是16路组相联的。物理地址PA是7.4中的VA到PA转换的结果,大小52位。针对L1,因块大小为64字节,则块偏移CO为最低6位;L1中共有64个组,组索引CI占中间6位;剩余40位为标记CT。

物理内存访问时,先从L1开始。在L1 Cache中,根据组索引CI进行组选择,然后搜索该组中的每一行,寻找一个有效的行使其标记与PA中的标记CT相匹配。如果找到了这样一个行,那么内存访问命中,然后根据块偏移CO选择起始字节,返回结果。如果缓存不命中,那么会到L2中取出包含访问对象的块到L1中并返回访问对象。如果L2或更低级的第K层缓存也发生不命中,则从第K+1层缓存中匹配取出包含访问对象的块。如果取出时第K层已经满了,会由缓存的替换策略决定替换一个牺牲块,如果始终不命中则会返回信号给CPU。

事实上缓存不命中处理后返回数据的方式对不同CPU架构会有差异。书中描述若发生不命中,会从下一层缓存中取回包含访问对象的块,然后重新执行导致异常的指令,返回新取出的高速缓存行中的访问对象。现代CPU Cache会在把缺失数据写入Cache的同时,把数据直接提交给CPU核心,两个动作一般是并行的。除此之外还有“关键字优先”等技术,根本目的都是要减少等待时间[12]。

7.6 hello进程fork时的内存映射

Bash作为父进程调用fork函数创建一个新的子进程hello 时,内核为新进程hello创建各种数据结构并分配给它一个唯一的PID。为了给新的进程hello创建虚拟内存,它创建了当前进程Bash的mm_struct、区域结构和页表的原样副本,此时Bash进程和hello进程都映射了同一个私有的写时复制对象。为保证两个进程虚拟地址空间的私有性,内核将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

当fork在新进程hello中返回时,新进程hello的虚拟内存刚好和调用fork时存在的虚拟内存相同,物理内存中也只保存有私有对象的一份副本。当这两个进程中的任意一个后来进行写操作时,写时复制机制会创建新的页面。父进程Bash和子进程hello以此保持私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

从区域结构上看,通过指令调用execve加载并执行hello的步骤如下:

①删除已存在的用户区域

execve删除hello进程虚拟地址的用户部分中已存在的数据结构。

②映射私有区域

为新程序hello的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区被映射为hello文件中的 .text和 .data区。bss区域是请求二进制0的,映射到匿名文件,其大小包含在hello文件中,映射到匿名文件过程中在磁盘和内存之间并没有实际的数据传送。栈和堆区域也是请求二进制0的,初始长度为0。

③映射共享区域

hello程序需要与共享库libc.so进行链接,具体过程在5.7中有陈述。共享库libc.so是动态链接到hello这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

图7.7.1 execve时的内存映射

④设置程序计数器PC:

execve设置当前hello进程上下文中的程序计数器,使之指向代码区域的入口点。在下一次调度这个进程时,它将从这个入口点开始执行。Linux根据需要换入代码和数据页面。

从fork时和execve时内存映射的过程中可以看到,fork根据父进程Bash创建了一个带有自己独立虚拟地址空间的新进程hello并分配给hello唯一的PID。execve函数在新创建的hello进程的上下文加载并运行一个新的程序。execve过程中进行内存映射会覆盖当前hello进程的地址空间,但并没有创建一个新的进程,因此在hello进程中运行的hello程序PID仍为fork为hello进程分配的PID,继承了调用evecve函数时已打开的所有文件的描述符。

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

缺页故障(page fault):在虚拟内存习惯的说法中,DRAM缓存不命中称为缺页。

缺页故障发生在下述情况:CPU向MMU提供虚拟地址,MMU的地址翻译硬件从页表中读取相应的PTE,从PTE的有效位推断出对应的虚拟页未被缓存,此时触发了一个缺页异常。

缺页异常调用内核中的缺页异常处理程序,内核处理程序先判断CPU向MMU提供的VA是否合法。为此缺页处理程序搜索区域结构的链表,把VA和每一个区域结构中的vm_start和vm_end作比较,如果VA指向了区域结构以外的位置,缺页处理程序会触发段错误,内核会发送一个SIGSEGV信号,CPU注意到该信号,会导致进程终止。为降低查找的时间复杂度,Linux在链表中构建了一棵树,缺页处理程序在这棵树上进行查找。

图7.8.1 Linux区域结构链表

VA合法的条件下,缺页异常处理程序要判断内存访问方式是否合法。缺页异常处理程序会根据PTE中的许可位检查进程是否有读/写/执行这个区域内页面的权限,如果引发缺页异常的指令与页面访问权限发生冲突,缺页处理程序会触发保护异常,终止该进程。

在这里插入图片描述

图7.8.1 缺页处理程序合法判断

在内核确定了这个缺页是对合法的虚拟地址进行合法的操作所造成的情况下,该程序会选择保存在物理内存中的一个牺牲页用来进行替换。如果选择的牺牲页已经被修改过,内核就会将它复制回磁盘。选择牺牲页后,内核会修改指向该牺牲页的PTE,反映出该牺牲页已经不再缓存在主存中这一事实。

以上处理完成后,内核会从磁盘复制引发缺页故障的PTE对应的虚拟页的内容来替换牺牲页的内容,然后从缺页异常处理程序返回。异常处理程序返回时,会重新启动导致缺页的指令,该指令将原先导致缺页故障的VA重新发送给MMU进行地址翻译。由于地址翻译硬件读取出的PTE表示对应的虚拟页已被缓存,那么原来导致缺页的指令可以正常被执行。

在这里插入图片描述

图7.8.2 缺页异常处理流程

7.9动态存储分配管理

printf函数会调用malloc,C标准库中提供的malloc程序包是一种显式分配器。下面简述动态内存管理的基本方法与策略。

7.9.1 动态内存分配器

动态内存分配器维护这一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护,每个块是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块保持已分配状态供程序使用,直到被显式或隐式地释放变成空闲块;空闲块保持空闲,直到它显式地被应用所分配。

分配器有两种基本风格:

①显示分配器:要求应用显式地分配块、应用显式地释放任何已分配的块。

②隐式分配器:要求应用显式地分配块、分配器自身检测并释放不再被程序所使用的块。

对于分配器的要求有:

①处理任意请求序列:分配器不可以假设分配和释放请求的顺序。

②立即响应请求:不允许分配器为了提高性能重新排列或者缓冲请求。

③只使用堆:分配器使用的任何非标量数据结构必须保存在堆里。

④对齐块:对齐使得块可以保存任何类型的数据对象。

⑤不修改已分配的块:分配器只能操作或改变空闲块。

分配器的目标:

①最大化吞吐率:单位时间内完成的请求数最多。

②最大化内存利用率:最高效地使用内存空间。

动态内存管理的基本方法与策略需要在两个目标间找到平衡。

7.9.2 显式分配器:malloc程序包

7.9.2.1 内存管理函数

1.malloc函数:

malloc函数返回指向大小为size字节的内存块的指针,如果内存分配遇到问题,则返回NULL并设置errno。

基于malloc函数有一些瘦包装函数:calloc函数将分配的内存初始化为0;recalloc函数可以改变一个以前已分配的块的大小。

2.sbrk函数:

sbrk函数通过将内核的brk指针增加incr来扩展或收缩堆,如果成功则返回brk的旧值,若出错则返回-1并将errno设置为ENOMEM。

3.free函数:

ptr参数指向一个malloc/calloc/recalloc获得的已分配块的起始位置,free函数释放这些已分配的堆块。

4. 动态内存分配器也可以使用mmap/munmap函数显式地分配和释放堆内存。

7.9.2.2 隐式空闲链表基础堆块格式

分配器基础的堆块格式如下图,一个块由一个字的头部、有效载荷以及可能的填充组成,头部编码了块的大小以及这个块是分配的还是空闲的。如果要求块是双字(8字节)/单字(4字节)对齐,则块大小低3/2位总是0,那么可以释放低3/2位来编码其他信息。下图中头部最后一位作为当前块是否已分配的标志。

图7.9.2.2.1 分配器基础堆块格式

为降低free函数的时间复杂度,堆块格式可以使用边界标记技术进行改进。使用边界标记的堆块的格式如下图,每个块的结尾处添加了一个脚部,脚部是头部的一个副本。

在这里插入图片描述

图7.9.2.2.2 使用边界标记的堆块的格式

为节省内存空间,在使用边界标记的堆块的基础上可以进一步地进行优化:在已分配的块中不再需要脚部,空闲块仍需要脚部。在每一个块中,把前面块的已分配/空闲标志位放在当前块中剩余的多出来的低位中,这样已分配的块不需要脚部,节省出来的空间可以作为有效载荷。

在这里插入图片描述

图7.9.2.2.3 优化边界标记的堆块

7.9.2.3 隐式空闲链表组织堆

由基础的堆块格式构成的隐式空闲链表如下图。堆被组织成一个连续的已分配块和空闲块的序列。其中的空闲块是通过头部中的大小字段隐含地连接着的。隐式空闲链表末端是特殊标记的结束块。

从图中虚线标识双字对齐可以看到系统对齐要求,对齐要求上述分配器对块格式的选择会对分配器上的最小块大小有强制的要求。

图7.9.2.3.1 隐式空闲链表组织堆

7.9.2.4 放置策略

应用请求k字节的块时,分配器会搜索空闲链表,查找一个足够大可以放置所请求块的空闲块。分配器执行搜索的方式由“放置策略”决定。

常见的策略有首次适配、下一次适配和最佳适配。

①首次适配:从开头搜索空闲链表,选择第一个合适的空闲块。

优点:趋向于将大的空闲块保留在链表的后面。

缺点:靠近链表起始处会留下小空闲块的碎片,增加对较大块的搜索时间。

②下一次适配:第二次空闲块搜索从上一次查询结束的地方开始。

优点:小空闲块的碎片对搜索时间的影响更小。

缺点:内存利用率更低。

③最佳适配:检查每个空闲块,选择适合所需请求量大小的最小空闲块。

优点:内存利用率更高。

缺点:需要对堆进行彻底的搜索。

7.9.2.5 分割策略

分配器找到一个匹配的空闲块后,需要根据分割策略决定要分配这个空闲块中多少空间。选择使用整个空闲块会导致产生内部碎片,如果放置策略匹配较好,内部碎片也可以接受。如果匹配不好,那么分割策略就要决定将匹配到的空闲块分割成两部分。

7.9.2.6 合并策略

分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲块相邻,相邻的空闲块可能引起假碎片的现象,假碎片会导致空闲空间总容量足够但无法匹配所请求的容量较大的块。为解决这个问题,任何实际的分配器都必须合并相邻的空闲块。

分配器的合并策略关注何时执行合并,包括立即合并与推迟合并。

①立即合并:每次一个块被释放时就合并所有的相邻块。

优点:简单明了,可以在常数时间内完成。

缺点:对于某些请求模式,这种方式会产生抖动,块会反复合并然后马上被分割。

②推迟合并:等到稍晚的时候再合并空闲块。

实际中快速的分配器通常选择某种形式的推迟合并。

合并方法取决于堆块格式:

对于合并下一个空闲块,针对不同的堆块格式构成的隐式空闲链表使用的方法一般相同,将下一个块的大小简单地加到当前块头部的大小上即可,如果下一个块存在脚部,则合并后根据头部信息更新脚部信息,这种方法简单而且高效。

而对于合并前面的空闲块,不同的堆块格式对应方法不同,性能会有很大差异。

①对于基础的堆块格式构成的隐式空闲链表来说,需要搜索整个链表,记住前面块的位置,直到到达当前块。合并所需时间与堆的大小成线性关系。

②对于边界标记的块结构来说(优化的边界标记的块结构同样如此),由于块脚部的存在,在常数时间内即可完成与前面块的合并。合并时用两个块的大小更新前面块的头部和当前块的脚部即可。下图展示了当前块被释放并与前面块合并的过程,这里可以看作使用了立即合并策略。

图7.9.2.6.1 边界标记的块结构当前块与前面块合并

7.9.2.7 显式空闲链表与排序策略

在隐式空闲链表中块分配与堆块的总数呈线性关系,对于通用的分配器,隐式空闲链表是不适合的。更好的方法是将空闲块组织成某种形式的显式数据结构。下图展现了将堆组织成双向空闲链表,在边界标记的块结构的基础上,在每个空闲块中包含一个pred(前驱)和succ(后继)指针。

图7.9.2.7.1 双向空闲链表堆块格式

使用双向链表使首次适配放置策略的分配时间从块总数线性时间减少到了空闲块数量的线性时间。

释放一个块的时间取决于空闲链表中块的排序策略:

①后进先出(LIFO)顺序:将新释放的块放在链表的开始处。

使用LIFO排序策略和首次适配的放置策略,释放一个块可以在常数时间内完成。如果使用了边界标记块结构,合并也可以在常数时间内完成。

②地址顺序:链表中每个块的地址都小于它的后继地址。

释放一个块需要线性时间来搜索定位合适的前驱,但在地址顺序排序策略下的首次适配比在LIFO排序策略下有更高的内存利用率。

7.9.2.8 分离的空闲链表与分离存储方法

使用单项空闲块链表的分配器需要与空闲块数量呈线性关系的时间来分配块,分离存储是一种流行的减少分配时间的方法。分离存储即维护着多个空闲链表,将所有可能的块大小分离成一些等价类,每个等价类对应一个空闲数组。

分离存储有很多方法,其中最基本的方法为简单分离存储和分离适配

①简单分离方法:每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小。

②分离适配:每个空闲链表和一个大小类相关联并且被组织成某种类型的显式或隐式链表

7.9.3 隐式分配器

前面提到隐式分配器与显式分配器的差别在于隐式分配器可以自身检测并释放不再被程序所使用的块。垃圾收集器是一种动态内存分配器,它自动释放程序不再需要的已分配块,满足上面的要求,是一种隐式分配器。

垃圾收集器的原理是将内存视为一张有向可达图,图中的节点被分为一组根节点和一组堆节点。若存在一条从根节点触发到一个堆节点的有向路径,则该堆节点是可达的,否则是不可达的,即垃圾。垃圾收集器即针对不可达结点进行处理。

Mark&Sweep垃圾收集器是典型的垃圾收集器,由标记阶段和清除阶段组成,具体细节不再陈述。

7.10本章小结

本章从解读存储器地址空间概念开始,详细描述了段式管理、页式管理的方式与逻辑地址到线性地址再到物理地址的变换,分析在TLB与四级页表支持下的从VA到PA的变换流程。结合书中存储器方面的知识,分析了三级Cache支持下的物理内存访问流程。结合存储管理,重新看6.3、6.4中的fork和execve。后两部分总结了前面部分经常谈到的缺页故障与缺页中断处理流程,对动态存储分配管理有简单的描述。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

1.设备的模型化:文件

一个Linux文件就是一个m个字节的序列:B0, B1,···, Bk, ···, Bm-1。所有的I/O设备都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。

2.设备管理:Unix I/O接口

Unix I/O接口是Linux内核在将设备映射为文件的方式下引出的一个简单、低级的应用接口,这使得所有的输入和输出都能以一种统一且一致的方式来执行。

8.2 简述Unix IO接口及其函数

8.2.1 打开文件

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

①open函数将filename转换为一个文件描述符并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。

②flags参数指明进程如何访问这个文件:

O_RDONLY只读
O_WRONLY只写
O_RDWR可读可写

flags参数也可以是一个或者更多位掩码的或,为写提供额外的指示:

O_CREAT如果文件不存在,就创建它的一个截断的(空)文件
O_TRUNC如果文件已经存在,就截断它
O_APPEND在每次写操作前,设置文件位置到文件的结尾处

③mode参数指定了新文件的访问权限位

S_IRUSR S_TWUSR S_IXUSR拥有者能够读这个文件 拥有者能够写这个文件 拥有者能够执行这个文件
S_IRGRP S_TWGRP S_IXGRP拥有者所在组的成员能够读这个文件 拥有者所在组的成员能够写这个文件 拥有者所在组的成员能够执行这个文件
S_IROTH S_TWOTH S_IXOTH任何人能够读这个文件 任何人能够写这个文件 任何人能够执行这个文件

每个进程都有自己的umask,当进程通过带某个mode参数的open函数调用来创建一个新文件时,文件的访问权限被设置为mode&~mask。

8.2.2 关闭文件

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

close函数若成功返回0,若出错则为-1。关闭一个已关闭的描述符会出错。

8.2.3 读文件

应用程序调用read函数执行输入。

read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。若成功函数返回读的字节数,若是EOF则返回0,若失败则返回-1。

在读时遇到EOF、从终端读文本行或读网络套接字时,read传送的字节可能比应用程序要求的要少。这些不足值不表示有错误。

8.2.4 写文件

应用程序调用write函数执行输出。

write函数从内存位置buf复制最多n个字节到描述符fd的当前文件位置。若成功返回写的字节数,若出错返回-1。

在写网络套接字时,write传送的字节可能比应用程序要求的要少。这些不足值不表示有错误。

8.3 printf的实现分析

printf函数原型为:

printf函数的函数体:

1 2 3 4 5 6 7 8 9int 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; }

printf函数的参数fmt是可变形参。函数体中定义局部变量i,用于保存write函数返回的写的字节数;定义char型数组buf作为缓冲区,是传递给vsprintf函数和write函数的第一个参数。第5行中的va_list被定义为char*,是一个字符指针,(char*)(&fmt) + 4是…中的第一个参数的地址,将其强转为va_list类型用arg变量保存。

然后通过调用vsprintf函数。vsprintf函数生成显示信息并返回要打印出来的字符串的长度,函数体如下(大体示例,具体细节并不完整):

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31int 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格式化参数fmt对应的字符串,将格式化后的字符串保存在printf为其提供的缓冲区buf中。具体流程是:先定义字符指针p,后续步骤中p指向buf中待写入的位置,定义char型数组temp,作为整型数转字符串后写入buf过程中的缓冲。定义完局部变量后进入for循环,循环中遍历fmt中字符,不是‘%’的字符会被直接按顺序写入buf中,p指向buf中该字符写入的位置,字符写入后p自增1。当遇到‘%’时,进入for循环中的switch部分。switch部分判断‘%’后的字符,有效的字符对应着后面参数中要转换成字符串的变量值的类型。给出的代码中,若‘%’后的字符为‘x’,则将p_next_arg指针指向的整型参数通过调用atoi函数转成字符串后经过tmp临时保存拷贝到buf缓冲区中指针p指向的位置。整型参数大小4字节,p_next_arg+=4使得p_next_arg指向…中下一个参数。p+=strlen(tmp),p指向buf加入tmp字符串后下一个要写入的位置。switch部分处理完成后,继续按相同方式在for循环中读取并缓冲fmt中字符。for循环结束后,返回p-buf,即要打印出来的字符串的长度。

vsprintf返回后printf函数体中执行write(buf, i);打印buf中字符串,write汇编语言代码如下图:

图8.3.1 write汇编语言代码

write函数更新了%eax、%ebx、%ecx寄存器的值,然后通过int INT_VECTOR_SYS_CALL指令产生陷阱异常,进陷阱异常处理程序,调用中断门,通过中断门实现特定的系统服务。

int INT_VECTOR_SYS_CALL指令要通过系统调用sys_call函数,sys_call函数的功能是将buf缓冲的字符串中的字节通过总线以ASCII形式复制到显卡的显存中,采用直接写缓存的方法显示字符串,直到遇到‘\0’结束。

显示时需要字符显示驱动子程序,字符显示驱动子程序将字符信息进行一个转换,从ASCII到字模库到显示vram存储每一个点的RGB颜色信息。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),在显示器上得到输出。

8.4 getchar的实现分析

首先看键盘字符输入:键盘输入时调用键盘中断处理子程序进行异步异常-键盘中断的处理,接受按键扫描码转成ascii码,保存到系统的键盘缓冲区标准输入stdin中。

getchar函数的作用是从stdin流中读入一个字符,返回值为读入的字符的ASCII码。getchar大概实现流程是:调用read系统函数,系统函数read读取按键ascii码,直到接受到回车键才返回,读取的字符串保存在缓冲区中,如果缓冲区中有字符,则从缓冲区中读取。

getchar函数具体内容如下:

1 2 3 4 5 6 7 8 9 10 11 12int getchar(void) { static char buf[BUFSIZ]; static char* bb=buf; static int n=0; if(n==0) { n=read(0,buf,BUFSIZ); bb=buf; } return (–n>=0)?(unsigned char)*bb++:EOF; }

getchar函数先定义静态字符数组buf,作为调用read函数的第二个参数用来保存read函数从文件中复制的字节。定义静态字符指针型变量 bb=buf,用于函数返回时计算读取字符的个数。定义静态整型变量n=0,后期作为调用read函数的判断条件并保存read函数的返回值,在getchar函数返回时作为判断变量,决定返回值。

将三个变量定义为静态的原因是在getchar函数退出后,由于静态局部变量被保存在全局数据区而不是栈中,static变量能够保持上次调用后的结果直到下次赋新值。

定义完局部变量后,getchar函数先判断n值是否为0,如果n值为0,则代表缓冲中没有输入字符或输入的字符串已经被读完,那么需要执行n=read(0,buf,BUFSIZ);语句调用系统函数read。调用read函数第一个参数为0,0是进程开始时已经打开的文件标准输入stdin的描述符。在Win11 x64下,BUFSIZ宏定义为512,代表read函数从标准输入中最多复制512个字节到内存位置buf,不同环境BUFSIZ的值可能不同。read系统函数返回读入的字符数给n,然后令bb指针指向buf开始位置地址。

图8.4.1 BUFSIZ宏定义

当n≠0或if条件部分执行完成后,三元运算(–n>=0)?(unsigned char)*bb++:EOF;决定getchar函数的返回值。此时buf作为输入缓冲区,每次先判–n后n是否大于等于0,大于零意味着buf缓冲区中仍有字符,那么从buf中取走bb指向的字符,getchar函数返回该字符,bb指针自增1。

从这里可以理解buf、bb、n被定义为静态局部变量的意义。buf作为输入缓冲区,保存着getchar调用read读取的字符串,以供多次调用getchar函数使用。bb是指向buf中要被getchar返回的字符的指针,每次返回后bb自增1,指向下一次getchar被调用时getchar函数要返回的字符。n保存着buf中剩余没被返回的字符个数,供下一次getchar函数被调用时检查buf缓冲区中是否还有可被返回的字符。

继续看三元运算。如果–n<0,意味着到return语句前n=0。唯一的可能是进入getchar函数时n=0,并且调用read系统函数时没有从stdin中读取进任何字符,read返回值为0。此时三元运算决定返回EOF,代表getchar读入的字符为空。同样的,Win11 x64下,EOF宏定义为-1,不同环境可能有所不同。

图8.4.2 EOF宏定义

8.5本章小结

本章简单叙述了最基本的hello的IO管理。从Linux的IO设备管理方法开始,简述了Unix打开文件、关闭文件、读文件和写文件的接口及其函数。8.3和8.4对printf、getchar实现进行了简单分析,包括对C语言函数体的解读、对具体流程和逻辑的描述和对系统函数调用的分析。通过本章对IO管理的概念及应用会有更好的理解。

**
**

结论

总结hello所经历的过程如下:

①从hello.c文件开始,经过预处理器(cpp)修改原始的C程序,生成hello.i文件。

②hello.i文件通过编译器(ccl)被翻译成hello.s文本文件,内容为汇编语言指令。过程中进行了语句识别、错误检查与代码优化。

③hello.s文件经汇编器(as)进行汇编生成了hello.o可重定位目标文件,内容为二进制机器语言指令,为最终阶段的链接做准备。

④hello.o文件进入了链接过程,链接器(ld)将各种代码和数据片段收集并组合成为一个可执行目标文件hello。hello可被加载到内存并执行。

⑤在Bash中输入运行hello的指令,Bash调用fork函数为hello生成了新的进程,调用execve函数在hello进程的上下文中加载并运行hello程序。hello进程执行过程中,通过内核控制,与多个进程并行地运行。过程中产生的异常、信号交由内核处理。

⑥hello在被加载执行过程中,在物理内存、虚拟内存中经历了从无到有,更新替换的过程。针对这个过程的存储管理机制是抽象与复杂的,内部有地址翻译、物理内存访问、内存映射等多个流程。过程中的故障与内存分配的原理也需要关注。

⑦hello不满足于仅在机器上执行,它需要通过IO接口,利用相应函数与外界进行交互。printf打印信息让外界看到hello的存在与运行,getchar让hello接收外界的响应。

⑧至此hello的P2P,O2O流程结束。

对计算机系统的设计与实现的体会:

逐步分析hello的生命周期感觉到,计算机系统设计一般是从一个最基础的结构理论开始,在实现时发现问题,针对问题在已有结构基础上提供改进方案,不断发展得到一个高效、复杂的系统。改进方案可能是综合现有系统进行的优化,也可能是结合现有系统的优点与不足,针对问题提出的新的理论概念。总之计算机系统的设计与实现始终是在针对现有问题进行改进,改进后不断发现新的问题,提出新的思路的过程中进行的。

附件

列出所有的中间产物的文件名,并予以说明其作用。

文件名对应章节文件作用
hello.cALL源程序,进行测试与后续修改
hello.i2执行gcc预处理与结果解析
hello.s3执行gcc编译与结果解析
hello_Og.s3.3与优化等级O0产生的汇编代码做对比
hello.o3.3.8/43.3反汇编用/第四章解释汇编
obj_hello.o.txt4.4Objdump反汇编结果,与hello.s比较
hello5/6解读链接阶段,分析进程管理
obj_hello.txt5.5比较hello与hello.o,说明链接的过程
/hello7.2在Win x86下运行,查看段寄存器等,分析Intel段式管理

参考文献

[1] https://gcc.gnu.org/onlinedocs/cpp/Preprocessor-Output.html#Preprocessor-Output

[2] https://docs.oracle.com/cd/E38902_01/html/E38861/chapter6-54839.html

[3] https://www.linux.org/docs/man1/ld.html

[4] https://sourceware.org/git/?p=binutils-gdb.git;a=commitdiff;h=bd7ab16b4537788ad

53521c45469a1bdae84ad4a;hp=80c96350467f23a54546580b3e2b67a65ec65b66

[5] https://ost.51cto.com/posts/8891

[6] GNU Bash manual

[7] https://mp.weixin.qq.com/s?__biz=MzA3NDQ1ODM5Mw==&mid=2247483966&

idx=1&sn=5f8d80423434386f012163ef7ddda8b9&chksm=9f7e3648a809bf5ee2377

da5695bf5e8e9d70eab236d619bf4adbcfe6ef60efd6b6c161b6d27&scene=178&cur_a

lbum_id=1587858998484140033#rd

[8] https://blog.csdn.net/weixin_30039311/article/details/116676261

[9] https://blog.csdn.net/ludongguoa/article/details/121226353

[10] http://bbs.chinaunix.net/thread-2083672-1-1.html

[11] 深入理解计算机系统: a programmer’s perspective

[12] https://www.zhihu.com/question/419394566

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值