您好,欢迎访问代理记账网站
移动应用 微信公众号 联系我们

咨询热线 -

电话 15988168888

联系客服
  • 价格透明
  • 信息保密
  • 进度掌控
  • 售后无忧

2021csapp大作业

摘 要

本文重点关注hello.c从c语言程序到可执行目标文件hello的转换过程,及可执行目标文件hello作为进程运行的过程。本文旨在通过了解hello进程的诞生,以及从诞生到执行结束后被回收的全过程,分析理解计算机系统在此过程中发挥的作用,并据此探讨它的原理。

关键词:P2P;O2O;进程;计算机系统;

目 录

  • 摘 要
  • 第1章 概述
    • 1.1 Hello简介
    • 1.2 环境与工具
    • 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 hello.o的ELF格式
      • 4.3.2 hello.o的各节基本信息
      • 4.3.3 hello.o的重定位项目分析
    • 4.4 Hello.o的结果解析
      • 4.4.1 hello.o的反汇编分析
      • 4.4.2 hello.o的机器语言构成、与汇编语言的映射关系分析
    • 4.5 本章小结
  • 第5章 链接
    • 5.1 链接的概念与作用
    • 5.2 在Ubuntu下链接的命令
    • 5.3 可执行目标文件hello的格式
    • 5.4 hello的虚拟地址空间
    • 5.5 链接的重定位过程分析
    • 5.6 hello的执行流程
    • 5.7 Hello的动态链接分析
    • 5.8 本章小结
  • 第6章 hello进程管理
    • 6.1 进程的概念与作用
    • 6.2 简述壳Shell-bash的作用与处理流程
    • 6.3 Hello的fork进程创建过程
    • 6.4 Hello的execve过程
    • 6.5 Hello的进程执行
    • 6.6 hello的异常与信号处理
    • 6.7本章小结
  • 第7章 hello的存储管理
    • 7.1 hello的存储器地址空间
    • 7.2 Intel逻辑地址到线性地址的变换-段式管理
    • 7.3 Hello的线性地址到物理地址的变换-页式管理
    • 7.4 TLB与四级页表支持下的VA到PA的变换
    • 7.5 三级Cache支持下的物理内存访问
    • 7.6 hello进程fork时的内存映射
    • 7.7 hello进程execve时的内存映射
    • 7.8 缺页故障与缺页中断处理
    • 7.9动态存储分配管理
    • 7.10本章小结
  • 第8章 hello的IO管理
    • 8.1 Linux的IO设备管理方法
    • 8.2 简述Unix IO接口及其函数
    • 8.3 printf的实现分析
    • 8.4 getchar的实现分析
    • 8.5本章小结
  • 结论
  • 附件
  • 参考文献

第1章 概述

1.1 Hello简介

Hello的P2P过程:P2P即program to process的过程。首先hello.c源程序(文本)通过预处理器(cpp)被预处理为hello.i文本,然后通过编译器(ccl)成为汇编程序hello.s文本,然后通过汇编器(as)成为可重定位目标程序hello.o二进制文件,之后通过链接器(ld)与printf.o等需要的可重定位目标程序进行链接成为可执行目标程序hello二进制文件。此时,OS(进程管理)通过fork为可执行目标程序hello创建进程、加载程序的内容,至此完成了program to process的转换。
Hello的020过程:020即Zero-0 to Zero-0的过程。一开始,hello.c只是一个源程序文本文件,存储在外存中,在内存中可以说是占用资源为0,在OS(进程管理)通过fork为可执行目标程序hello创建进程后,OS使用execve加载并运行hello进程,使用mmap函数来为hello进程的代码、数据、bss和栈区域创建新的内存区域结构,并将对象映射到这些区域中。接下来,CPU为hello进程分配时间片,通过缺页中断将数据从磁盘加载到物理内存中进行调用,使用TLB进行提速。在进程运行过程中,IO管理处理通过键盘、鼠标等的输入,生成的信号被信号处理子程序进行处理,同时IO管理将hello进程的输出打印在屏幕上。最后,hello进程运行结束,由父进程回收hello进程。至此,hello的运行正式结束。

1.2 环境与工具

硬件环境:X64 CPU;2.80GHz;8.00GB RAM;1TB HD Disk
软件环境:Windows10 64位;Vmware 16;Ubuntu 20.04.2.0 LTS 64位
开发与调试工具:CodeBlocks 20.03;vi/vim/gedit+gcc;GDB/OBJDUMP;EDB

1.3 中间结果

hello.i:展示hello.c经过预处理的结果。
hello.s:展示hello.i经过编译的结果。
hello.o:展示hello.s经过汇编的结果。
hello:使用gcc -m64 -Og -no-pie -fno-PIC hello.c -o hello命令将hello.c编译成可执行目标程序hello,或者使用ld指令将hello.o与其他可重定位目标文件链接生成hello。用于展示hello.o经过链接的结果、使用objdump查看反汇编代码、在shell中执行该进程并理解进程的执行机制。

1.4 本章小结

本章介绍了实验环境与工具,简要概括了本文关注的hello.c的P2P和020过程,为接下来的仔细分析拟定了大纲。

第2章 预处理

2.1 预处理的概念与作用

概念:预处理器(cpp)根据以字符#开头的命令,修改原始的c程序,比如hello.c中第一行的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h中的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名。
作用:初步处理所有#开头的命令。
1)对于#define,定义常量。
2)对于#include,把头文件和当前源文件连接成一个源文件。
3)对于#ifdef、#ifndef和#endif,实现if else分支。
4) #pragma设定编译器的状态或指示编译器完成一些特定的动作。[1]
5)删除所有注释。

2.2在Ubuntu下预处理的命令

使用指令:gcc -E hello.c -o hello.i或cpp hello.c > hello.i均可。

图2-1

2.3 Hello的预处理结果解析

原来程序只有16行左右,经过预处理之后变成了3060行左右。分析生成的hello.i文件:首先看到hello.i文件的开头包含了文件标识。

图2-2
对于用到的库,标出了它们的地址。

图2-3
接下来看到hello.c的main函数代码被放在了hello.i文件的末尾,注释被删除了,头文件的内容都被插入到了main函数之前。

图2-4

2.4 本章小结

预处理阶段并没有开始对程序的代码执行部分内容进行处理,而是对#开头的命令进行解析,将要用到的头文件直接包含进当前文件,将要使用的库标明地址包含进当前文件,同时删除注释,即对hello.c来说仅保留了文件中的代码部分。

第3章 编译

3.1 编译的概念与作用

概念:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。每条语句都以一种文本格式描述了一条低级机器语言指令。
作用:汇编语言为不同高级语言的不同编译器提供了通用的输出语言。它将高级语言翻译成汇编语言,能够进一步翻译成机器语言被计算机识别、执行。

3.2 在Ubuntu下编译的命令

指令:gcc -S hello.c -o hello.s

图3-1

3.3 Hello的编译结果解析

首先,编译产生的hello.s的内容如下所示。

图3-2

3.3.1 字符串常量

编译器将字符串常量预放置到.rodata段中,按照8字节对齐,汉字转换成UTF-8编码,每个字节用’'开头,每个汉字占3个字节,字母和半角字符不做处理。

图3-3
后续调用字符串时,使用上图中对应的编号进行相对寻址,可见上述中编号(.L0和.L1)之后存放的会是字符串的相对偏移量。

图3-4

图3-5

3.3.2 局部变量

使用栈来存储局部变量,使用栈寄存器rbp相对寻址来访问存储局部变量的地址。对于图3-6中hello.c的语句,在hello.s中对应的语句如图3-7和图3-8所示,对于局部变量i,它可以使用图中红色框出的语句表示。

图3-6

图3-7

图3-8

3.3.3 赋值

如图3-6和3-7所示,对i赋值0的操作,使用movl $0, -4(%rbp)语句来完成。其中,movl指令表示赋值,将其后的第一个参数值赋给第二个参数。

3.3.4 算术操作

如图3-6和3-8所示,对i++的操作,使用addl $1, -4(%rbp)语句来完成。其中,addl指令表示相加,让其后的第二个参数值加上第一个参数。

3.3.5 关系操作

如图3-6和3-8所示,对i<8的判断,使用cmpl $7, -4(%rbp)语句来完成。其中,cmpl指令表示比较,让其后的第二个参数与第一个参数比较大小,以第二个参数为基准储存比较结果(设置标志寄存器)。比如若i<=7成立的话,我们执行接下来的指令jle .L4,返回循环的开头进行新一轮循环。jle是条件跳转指令,当且仅当标志寄存器(SF^OF)|ZF为1时进行跳转。而若cmpl得到小于等于的结果,它设置的标志寄存器值恰好满足这个跳转条件。
对于下图3-9和3-10中体现的"!= "关系(不等于)判断,依旧采用cmpl指令进行比较,若不成立,则它设置的标准寄存器值恰好满足je的跳转条件,它就会进行跳转。

图3-9

图3-10

3.3.6 数组操作

图3-11

图3-12

图3-13

图3-14
如上图3-11,3-12和3-13所示,由于argv是一个指针数组,他的每一个元素都是一个指针,且大小均为8个字节(64位下指针大小是8个字节),而它是main函数的第二个参数所以存在rsi寄存器中,由于图3-14中把它存储在了栈中,可以用rbp减去32来访问它,因此如图3-11的第一行代码所述,-32(%rbp)存储的就是argv数组的起始地址。因此它访问数组的第i个元素的地址,只需要将数组起始地址加上8*i即可,之后使用括号取地址指向的值就是argv[i]的值。因此,可见标[1]的行将数组起始地址加上了16 = 8 * 2,故访问的是argv[2]的地址,接着标[2]的语句将该地址处的值取出来,就得到argv[2]的值。接下来一行将这个值保存在rdx中,我们知道这个寄存器通常用来保存调用函数的第三个参数,由标[6]的行可以知道要调用printf函数,这个函数的第一个参数是格式串,对照图3-12中的代码发现第三个参数确实就是argv[2]的值。同理,标[3][4]的语句得到argv[1]的值,标[7]的语句得到argv[3]的地址,对照图3-12中的代码发现切实如此。

3.3.7 控制转移操作

如图3-9和3-10所示,对于if语句,若不满足判断条件(有的代码是满足判断条件跳转,都可以完成功能),进行跳转,即执行else语句内容(若无else,则表示不执行if里的代码,接着执行if之后的代码),否则继续执行(执行if里的代码)。
如图3-6,3-7和3-8所示,对于for循环语句,首先对i赋值0的操作,使用movl $0, -4(%rbp)语句来完成。然后我们立即执行对i<8的判断,使用cmpl $7, -4(%rbp)语句来完成。即若i<=7成立的话,我们执行接下来的指令jle .L4,返回循环的开头进行新一轮循环,否则循环结束,接着执行for循环之后的代码。对i++的操作,使用addl $1, -4(%rbp)语句来完成,它在每次循环的最后一个语句执行。然后我们又要开始下一轮循环了,先判断i是否小于8,若成立则跳转到.L4进行新一轮循环,否则循环结束,接着执行for循环之后的代码。

3.3.8 函数操作

如3.3.6中分析的,如图3-11,3-12和3-13所示,对于要调用的printf函数,格式串是第一个参数,存储在rdi中。先读取格式串,了解需要打印的内容还需要多少参数,比如这里printf就还需要两个参数,所以用rsi和rdx分别保存第二个和第三个参数。
参数传递结束之后,我们调用函数。先将函数的返回地址压栈,然后跳转到函数的代码处执行。函数代码执行结束之后,我们要返回调用这个函数的下一条语句继续执行,这也就是之前压栈的返回地址,因此我们执行ret操作,将栈中的返回地址pop出来,就能跳转到返回地址处了。因为printf是库里的函数,在hello.s中看不到它的代码,因此我们看看hello.s中main的return操作。图3-14中是main函数执行结束的返回操作,二者的操作是一样的。

图3-14
除此之外,sleep、exit、atoi、getchar等函数均同理。

3.4 本章小结

编译阶段将高级语言转换成了汇编语言,每条语句都以一种文本格式描述了一条低级机器语言指令,它们接下来能够进一步翻译成机器语言被计算机直接识别、执行,完成指定功能。这一步旨在让cpu读懂高级语言希望它执行的指令。

第4章 汇编

4.1 汇编的概念与作用

概念:汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o是一个二进制文件,它包含的字节是源程序的指令编码。如果在文本编辑器中打开hello.o文件,会看到一堆乱码。
作用:将汇编指令进一步转换成机器指令,能被cpu直接读取、执行相应操作。

4.2 在Ubuntu下汇编的命令

指令:as hello.s -o hello.o或gcc -c hello.s -o hello.o

图4-1

4.3 可重定位目标elf格式

4.3.1 hello.o的ELF格式

图4-2
ELF头:包含文件结构说明信息
.text节:目标代码部分。
.rodata节:只读数据部分。
.data节:已初始化的全局变量。
.bss节:未初始化的全局变量。
.symtab节:符号表。
.rel.text节:.text节相关的可重定位信息。
.rel.data节:.data节相关的可重定位信息。
.debug节:调试用符号表。
.line节:C源程序中的行号和.text节中机器指令之间的映射。
.strtab节:字符串表。
节头部表:其中的表项用来描述相应的一个节的节名、在文件中的偏移、大小、访问属性、对齐方式等。

4.3.2 hello.o的各节基本信息

图4-3
上图中用readelf -S指令查看节头表,即查看各节的基本信息。其中offset为起始地址,size为大小。因为没有生成可执行文件,address处暂时没有地址。

4.3.3 hello.o的重定位项目分析

可以看到图4-3的rela.text的描述中,link=11,info=1,link表示被重定位的符号所在的符号表的section index,info表示需要被重定位的section的index,通俗点讲就是,将来有朝一日我知道了该符号的地址,我该把这个地址写到哪个section里面去,这里是.text。
使用指令readelf -r hello.o可以看到rel section里的详细信息。

图4-4
offset表示该符号在被重定位的section中的偏移,info的高4个字节表示该符号在.symtab中的index,低4字节表示重定位的类型,不同的类型计算目标地址的方法不一样。[3]
阅读type部分信息,我们知道R_X86_64_PC32重定位类型代表着重定位一个使用32位PC相对地址的引用,R_X86_64_PLT32重定位类型代表着使用PLT进行重定位。由于PC值通常是下一条指令在内存的地址,因此我们在Addend的位置都减去了4(因为32位的指针为4字节),这样就准确获取了它们的地址。比如,第一行其实指向了.rodata段的首地址,第四行其实指向了.rodata+0x26的地址,其他行都指向了共享库函数对应的PLT表项的首地址。
综上所述,我们可以得出符号puts,exit,printf,atoi,sleep和getchar的各种信息:
puts的重定位地址是在.text的偏移为0x21处,将来的链接过程中,链接器要将puts的地址写到这个位置上来,puts在.symtab中的index为0xc。
exit的重定位地址是在.text的偏移为0x2b处,将来的链接过程中,链接器要将exit的地址写到这个位置上来,exit在.symtab中的index为0xd。
其他函数的分析和信息同理。

4.4 Hello.o的结果解析

4.4.1 hello.o的反汇编分析

使用指令objdump -d -r hello.o,得到hello.o的反汇编代码如下图4-5所示。

图4-5
与第3章的 hello.s进行对照分析,发现:
1)它按照节对代码进行划分(比如图中蓝框框出的.text);
2)它多出了很多有关重定位的信息,也就是我们用readelf查看到的重定位信息的一部分内容(红框框出了前三条重定位信息);
3)它多出了汇编代码对应的机器码(绿框框出了部分内容);
4)它有了具体的地址,不像hello.s中使用.L0等来表示地址的相对偏移量(黄框框出了部分内容)。
5)机器码中采用补码存储数值(橙框框出了部分内容)

4.4.2 hello.o的机器语言构成、与汇编语言的映射关系分析

对于操作指令(比如je,cmpl,movl等)对应于一条机器码的高8位,其中高4位是代码部分,低4位是功能部分(比如图4-5中粉框框出部分,jle的机器码是7e,je的机器码是74,他们都是跳转指令,因此高4位相同,又由于功能不同而低4位不同)。
对于不同的操作指令,机器码长度不同,比如hello.o中je指令后续只跟了一个操作数,就只需要保存这一个操作数数的相应数值即可,这里用了1个字节;call指令也只有一个操作数,但他的操作数保存用了4个字节;addl指令后续需要两个操作数,就需要相对多的空间来保存相应数值,这里是3个字节。
每个寄存器都有对应的机器码数值,相对寻址、取值等操作也有对应的机器码数值。
对于跳转,它在机器码中保存的是相对偏移量而不是汇编代码中写的确切地址。比如图4-5中橙色框框出的部分,目标跳转地址是0x38,当前语句地址为0x84,因此相对偏移量为0x38-0x84=-0x4c,0x4c的二进制表示为01001100,故-0x4c的补码为10110010,恰为b2,也就是橙框内的数值,证明了机器码存储的是相对偏移量。

4.5 本章小结

汇编阶段生成hello.o,它是可重定位文件,包含ELF头,里面包含了程序的重定位信息、各节基本信息等。它在hello.s的基础上,将汇编指令进一步转换成机器指令,能被cpu直接读取、执行相应操作。

第5章 链接

5.1 链接的概念与作用

概念:链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件是可执行目标文件,可被加载到内存并执行。
作用:链接将多个可重定位文件整合,生成可执行文件。它使得分离编译成为可能,即后续需要修改时不用修改所有代码。

5.2 在Ubuntu下链接的命令

由于要求采用ld链接命令,复习了lab1中的内容后,得到指令如下图5-1所示。

图5-1

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

图5-2
ELF头:包含文件结构说明信息
段头表:描述具有相同访问属性的代码和数据段映射到存储空间的映射关系。
.init节:用于可执行目标文件开始执行时的初始化工作。
.text节:目标代码部分。
.rodata节:只读数据部分。
.data节:已初始化的全局变量。
.bss节:未初始化的全局变量。
.symtab节:符号表。
.debug节:调试用符号表。
.line节:C源程序中的行号和.text节中机器指令之间的映射。
.strtab节:字符串表。

使用readelf -S hello指令查看各段基本信息,如图5-3所示。

图5-3

5.4 hello的虚拟地址空间

.text段如下(因为图5-3中看出它的起始地址为0x4010f0):

图5-4
.init段,.plt段,.plt.sec段如下(因为图5-3中看出它的起始地址分别为0x401000,0x401020,0x401090):

图5-5
.fini段如下(因为图5-3中看出它的起始地址为0x4012e8):

图5-6

5.5 链接的重定位过程分析

使用objdump -d -r hello指令,获得的反汇编代码部分截图如下:

图5-7

图5-8

图5-9
与hello.o进行对比,发现:
1)它多出了很多内容,多出了很多节;
2)它的每一条指令、每一个函数都有了确定的虚拟地址,比如main的起始地址不再是0,而是0x4011d6;
3)main函数的反汇编里没有了重定位信息,因为已经将需要重定位的函数加载到了图5-8中的.plt.sec节中。
4)原先hello.o的反汇编中call指令,凡是调用需要重定位的函数,地址的机器码都暂时填充的是0、汇编代码都没填写地址,现在机器码填写了确切的偏移量、汇编代码填写了具体的地址。
因此,可以举例说明链接的过程,这里描述puts函数的链接过程,其他函数同理:
1)首先根据图4-4得知puts在.symtab中的index为0xc,且puts的重定位地址是在.text的偏移为0x21处,所以链接器在符号表中找到第0xc个位置恰好是puts,再读取相应信息,将puts的地址写到.text的偏移为0x21处,此时也就能计算得到puts函数的绝对地址了。
2)得到绝对地址之后,计算相对地址,采用小端存储填入机器码(call指令处)。
简而言之,就是链接器复制静态库里被应用程序引用的目标模块。

5.6 hello的执行流程

从开始到_start:
1)_dl_start
2)_dl_init
后面我采用的是gdb进行调试。先在_start函数设置断点,然后单步调试。

图5-10

图5-11
由图5-10和5-11看出,从_start到call main的过程调用与跳转的各个子程序名如下:
1)__libc_start_main
2)__GI___cxa_atexit
3)__internal_atexit
4)__lll_cas_lock
5)__new_exitfn
6)_setjmp
7)__sigsetjmp
8)__sigjmp_save

图5-12

图5-13
由图5-12和5-13(还有很多过程就没有截图了)看出,从call main到程序终止的过程调用与跳转的各个子程序名如下:
1)printf
2)___printf_chk
3)__vfprintf_internal
4)atoi
5)sleep
6)getchar
7)__GI__IO_puts
8)__lll_cas_lock
9)IO_validate_vtable
10)_IO_new_file_xsputn
11)_IO_new_file_overflow
12)__GI__IO_doallocbuf
13)__GI__IO_file_doallocate
14)__GI__IO_file_stat
15)__GI___fxstat
16)__gnu_dev_major

5.7 Hello的动态链接分析

GOT,即全局偏移量表(global offset table),是一个数组,其中每个条目是8字节地址。由于链接器采用延迟绑定,GOT[0]为.dynamic节首地址,GOT[1]为动态链接器的标识信息,GOT[2]为动态链接器延迟绑定代码的入口地址。
由图5-3得知.got.plt在0x404000地址处。在执行dl_init前该节如下(红框框出的是GOT[1]和GOT[2]):

图5-14
执行dl_init后该节如下(红框框出的是GOT[1]和GOT[2],发生了变化,意味着定位成功):

图5-14

5.8 本章小结

链接器将hello.o和多个可重定位文件链接,生成了可执行文件hello。链接将多个可重定位文件整合,它完成了重定位、虚拟地址分配等工作,使得hello接下来可以被加载进入内存进行运行。

第6章 hello进程管理

6.1 进程的概念与作用

概念:一个执行中程序的实例。
作用:shell会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。os就可以通过逻辑控制流对进程进行管理,进而让我们得到一个假象,就好像我们的程序独占地使用处理器和内存。

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

1)shell的作用:shell代表用户运行其他程序。shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。
2)shell的处理流程:shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,解析了命令行之后,调用函数检查第一个命令行参数是否是一个内置的shell命令。如果是,它就立即解释这个命令,并代表用户运行程序。若用户要求在后台运行程序,那么shell直接等待下一个命令行;否则,shell使用waitpid函数等待作业终止。当作业终止时,shell开始下一轮迭代。

6.3 Hello的fork进程创建过程

用户通过向shell输入可执行目标文件的名字和必要的参数(如:./hello 1180100406 袁文宇 1),运行程序时,shell就会通过fork函数创建一个新的进程(hello的进程),新创建的hello子进程几乎但不完全与父进程(shell)相同,它得到与父进程用户级虚拟地址空间相同(但独立,即修改子进程不会修改父进程)的一个副本,包括代码和数据段、堆、共享库以及用户栈。它还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。子进程与父进程直接最大的区别在于他们有不同的PID。
fork函数调用一次,返回两次:一次在调用进程(父进程shell)中,一次是在新创建的hello子进程中。在父进程shell中,fork返回hello子进程的PID;在hello子进程中,fork返回0。
父进程shell和hello子进程是并发执行的。

6.4 Hello的execve过程

shell通过fork函数创建一个新的进程(hello的进程)之后,调用execve函数在这个新进程的上下文中运行这个可执行目标文件。
execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量envp。只当出现错误时,例如找不到hello,execve才会返回到调用程序。所以, execve调用一次从不返回。其中,参数列表和环境变量列表的组织结构分别如下图所示。

图6-1

6.5 Hello的进程执行

上下文信息:内核重新启动一个被抢占的进程所需要的状态。它由一些对象组成,包括目的寄存器、辅导检测器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫调度。当内核选择一个新的进程运行时,我们称内核调度了这个进程。在内核调度了一个新的进程运行之后,它就抢占当前资源,并使用上下文切换的机制来将控制转移到新的进程。上下文切换指:1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。

图6-2
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如hello执行的sleep函数,它显式地请求让调用进程hello休眠。
中断也可能引发上下文切换。比如,所有系统都有某种产生周期性定时器中断的机制,通常为每1毫秒或每10毫秒。每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。
关于用户态与核心态:处理器通常是用某个控制寄存器中的一个模式为来提供这种功能。当设置了模式位时,进程就运行在内核模式(核心态)中,即该进程可以执行指令集中的任何指令,可以访问系统中的任何内存位置;没有设置模式位时,进程运行在用户模式(用户态)中,不允许进程执行特权指令。
运行hello进程初始时是在用户模式(用户态)中的,但一旦遇到诸如中断、故障或者陷入系统调用这样的异常,就会变成内核模式(核心态),控制传递到异常处理程序,处理程序返回到应用程序代码时,处理器又把模式改回到用户态。

6.6 hello的异常与信号处理

6.6.1异常
1)类型:中断、陷阱、故障和终止。

图6-2
2)处理:调用异常处理子程序。对于中断、陷阱、故障和终止,它们的处理方式分别如下面四张图所示。

图6-3

图6-4

图6-5

图6-6
6.6.2信号
信号的类型与默认处理方式如下图所示。当然,用户也可以自己定义信号处理程序,但hello并不涉及相应操作,这里就不再赘述。

图6-7
6.6.3 hello运行过程中键入命令结果截屏与说明
1)回车:没有影响,只是在输出中间多了一个换行符而已。

图6-8
2)Ctrl-Z:程序被挂起,停止运行。shell捕捉SIGTSTP信号,停止直到下一个SIGCONT。

图6-9
3)Ctrl-C:hello进程直接终止。shell捕捉SIGINT信号,终止进程。

图6-10
4)Ctrl-Z后运行fg:程序重新开始执行,接着上次停止的地方开始。shell捕捉SIGCONT信号,继续被停止的进程。

图6-11
5)Ctrl-Z后运行ps:打印后台进程信息。

图6-12
6)Ctrl-Z后运行jobs:打印停止进程信息。

图6-13
7)Ctrl-Z后运行pstree:用树状结构打印当前所有进程。由于该结构过于庞大,这里只截了一部分图。

图6-14
8)Ctrl-Z后运行kill:用kill -9 pid可以将PID为pid的进程终止。shell捕捉通过kill函数发送的SIGINT信号,终止进程。我们可以通过ps来获取hello的PID,可以看到kill之后再ps会显示hello已经终止了。

图6-15

6.7本章小结

本章介绍了hello通过shell创建、执行的过程、方法,以及通过shell对hello进程进行控制、查看的方法和原理(键入命令等),也介绍了hello执行过程中会遇到的异常、信号。前面几章的工作正是为了hello此时的执行做准备。

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:段地址的偏移地址。机器语言中一般为逻辑地址。
线性地址:段基址+逻辑地址。32位中由32位组成,64位中由48位组成。
虚拟地址:与线性地址相同。
物理地址:CPU中的真实地址。32位中由32位组成,64位中由52位组成。

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

1.首先介绍段寄存器(16位):

图7-1
首先,段寄存器的各字段含义如上图7-1所示:
1)CS寄存器中的RPL字段表示CPU的当前特权级(Current Privilege Level,CPL),RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级;
2)TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT);
3)高13位-8K个索引用来确定当前使用的段描述符在描述符表中的位置。

图7-2
各段寄存器的含义如上图7-2所示:
1)CS(代码段):程序代码所在段;
2)SS(栈段):栈区所在段;
3)DS(数据段):全局静态数据区所在段;
4)其他3个段寄存器ES、GS和FS可指向任意数据段。

  1. 接下来介绍GDT:[4]
    GDTR存着描述符表的起始地址。
    GDT实际上是一个数组,如图7-3所示:

图7-3
我们通过GDT的下标找到对应的段描述符,如下图7-4所示:

图7-4
其中的各个参数解释如下:
B31~B0:32位基地址
L19~L0:20位限界,表示段中最大页号(长度)
G:粒度。G=1则L以页(4KB)为单位;G=0则L以字节为单位。因为界限为20位,故当G=0时最大的段为1MB;当G=1时,最大段为4KB×220 =4GB
D:D=1表示段内偏移量为32位宽,D=0表示段内偏移量为16位宽
P:P=1表示存在,P=0表示不存在。Linux总把P置1,不会以段为单位淘汰
DPL:访问段时对当前特权级的最低等级要求。因此,只有CPL为0(内核态)时才可访问DPL为0的段,任何进程都可访问DPL为3的段(0最高、3最低)
S:S=0系统控制描述符,S=1普通的代码段或数据段描述符
TYPE:段的访问权限或系统控制描述符类型
A:A=1已被访问过,A=0未被访问过(通常A包含在TYPE字段中)

综上所述,首先通过段寄存器(16位)DS/CS/SS获取段选择符(高13位),接着从第14位确定是找GDT还是LDT。若第14位为1,也就是找的GDT,从GDTR寄存器找到GDT,获得基地址,获得基地址之后加上IP长度,看有没有超过最大值(取决于G的值),如果没有,就获得了线性地址。至此,完成了逻辑地址到线性地址的变换。

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

我们采用多级页表来实现从线性地址到物理地址的变换。
用两级页表为例进行叙述。假设32位虚拟地址空间被分为4KB的页,而每个页表条目都是4字节,采用二级页表,那么一级页表中的每个PTE负责映射虚拟地址空间中一个4MB的片,这里每个片都是由1024个连续的页面组成的。假设地址空间是4GB,那么1024个PTE就能覆盖整个空间了(若片i中每个页面都未分配,那么一级PTEi为空)。二级页表中每个PTE都负责映射一个4KB的虚拟内存页面,就像我们查看只有一级的页表一样。如图7-5所示。

图7-5
需要注意的是,只有一级页表才需要总是在主存中;虚拟内存系统可以在需要时创建、页面调入和调出二级页表,这就减少了主存的压力;只有经常使用的二级页表才需要缓存在主存中。
与两级页表同理,k级页表图示如下。虚拟地址被分为k个VPN和1个VPO。每个VPNi都是一个到第i级页表的索引(1≤i≤k),第j级页表中的每个PTE(1≤i≤k-1)都指向第k+1级的某个页表的基址。第k级页表中的每个PTE包含某个物理页面的PPN或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问k个PTE。对于只有一级的页表结构,PPO和VPO是相同的。

图7-6
访问k个PTE通过TLB进行加速,它将不同层级上页表的PTE进行缓存。因此,带多级页表的地址翻译并不比单级页表慢很多。

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

1.首先,介绍TLB:
TLB是一个小的、虚拟寻址的缓存,其结构如图7-8所示,它辅助MMU查阅PTE,其中每一行都保存着一个有单个PTE组成的块,它通常具有高度的相联度。当TLB不命中时,MMU从L1缓存中取出相应的PTE(可能覆盖一个已存在的条目);但若命中,直接从TLB中取出PTE,能大量节省时间。如图7-7所示。

图7-7

图7-8
2. TLB与四级页表支持下的VA到PA的变换:
由于采用四级页表,如图7-9所示,36位的VPN被分成四个9位的片,每个片被作用到一个页表的偏移量,CR3寄存器包含L1页表的物理地址。VPNi提供一个Li PTE的偏移量,这个PTE包含L(i+1)页表的基地址(1≤i≤3)。第4级页表中的每个PTE包含某个物理页面的PPN或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问4个PTE。

图7-9
其中,TLB被用于辅助MMU查阅PTE。
比如,Core i7地址翻译的概况如下图7-10所示。(不包括i-cache、i-TLB和L2统一TLB)。

图7-10

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

存储器层级结构的中心思想是,对于每个k(k=1,2),位于第k层的更快更小的存储设备作为位于k+1层的更大更慢的存储设备的缓存。换句话说,层次结构中的每一层缓存都来自较低一级的数据对象。例如,在L1 cache中miss的话,需要到L2 cache中取数据,在L2 cache中miss的话,需要到L3 cache中取数据。

7.6 hello进程fork时的内存映射

当fork函数被hello进程调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。如下图7-11所示。

图7-11

7.7 hello进程execve时的内存映射

1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
2)映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区域。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为0。图7-12概括了私有区域的不同映射。
3)映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间的共享区域内。
4)设置程序计数器(PC)。execve做到最后一件事就是设置当前上下文中的PC,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。

图7-12

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

1.缺页故障:虚拟内存中称DRAM缓存不命中为缺页。CPU引用了VP i中的一个字,而VP i并没有缓存在DRAM中。地址翻译硬件从内存中读取PTE m,从有效位推测出VP i未被缓存,并且触发一个缺页异常。
2.缺页中断处理:缺页异常会调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,放在PP j里的VP k。若VP k已经被修改了,那么内核就会把它复制回磁盘(写时复制)。无论哪种情况,内核都会修改VP k的条目,反映出VP k不再缓存在主存中这一事实。接下来,内核从磁盘复制VP i到内存中的PP j,更新PTE m,随后返回。当异常处理程序返回时,他会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP i已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。

7.9动态存储分配管理

动态内存管理的基本方法与策略:动态内存分配器维护着一个进程的虚拟内存区域,称为堆。假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk,它指向堆的顶部(如图7-13所示)。分配器将堆视为一组大小不同的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

图7-13

7.10本章小结

本章对于hello进程的存储管理进行了讨论,对于机器内存储的管理方式、步骤等进行了详细解释,展现了hello进程在运行过程中存储空间的变化过程,解释了hello的信息是如何从外存中一步步加载进入内存并处理的。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

(以下格式自行编排,编辑时删除)
1.设备的模型化:文件。一个Linux文件就是一个m字节的序列:
B0, B1, …, Bk, …, Bm-1
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件。每个Linux文件都有一个类型来表明它在系统中的角色:
1)普通文件:包含任意数据。其中文本文件通常只含ASCII或Unicode字符;二进制文件是所有其他的文件。
2)目录:包含一组链接的文件,其中每个链接都将一个文件名映射到一个文件。Linux内核将所有文件都组织成一个目录层级结构,由名为/的根目录确定。系统中每个文件都是根目录的直接或间接的后代。图8-1显示了Linux系统的目录层次结构的一部分。
3)套接字:用来与另一个进程进行跨网络通信的文件。
4)其他文件类型:命名通道、符号链接,字符和块设备等。

图8-1
2.设备管理:unix io接口。
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行:
1)打开文件:一个应用程序通过要求内核打开相应的文件来宣告它要访问一个I/O设备。内核返回一个非负整数类型的描述符,用于对此文件进行标识。
2)Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可以用来代替显式的描述符值。
3)改变当前的文件位置。内核为每个打开的文件保持一个文件位置k(初始为0),k是从文件开头起始的字节偏移量。
4)读写文件:一个读操作就是从文件赋值n>0个字节到内存,当文件位置k≥文件大小会触发EOF。写文件同理。
5)关闭文件:应用完成了对文件的访问,内核释放文件打开时创建的数据结构,并将该描述符恢复到可用的描述符池中。

8.2 简述Unix IO接口及其函数

  1. Unix I/O接口:所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行(如8.1中所述)。
  2. Unix I/O函数:
    1)open函数:打开一个已存在的文件或创建一个新文件。它将filename转换为文件描述符,并返回描述符值(进程中当前未打开的最小描述符)。flag参数表示不同功能,如只读、只写等。mode参数制定了新文件的访问权限位。函数的声明如图8-2所示。

图8-2
2)close函数:进程调用它来关闭一个打开的文件,关闭一个已关闭的描述符会出错。函数声明如图8-3所示。

图8-3
3)read函数:应用程序通过调用它来执行输入。函数声明如图8-4所示。它从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1代表出错,0代表EOF,否则代表实际传送字节数。
4)write函数:应用程序通过调用它来执行输出。函数声明如图8-4所示。它从内存位置buf复制至多n个字节到描述符fd的当前文件位置。

图8-4
5)stat函数和fstat函数:应用程序通过调用它来检索关于文件的信息(文件元数据)。函数声明如图8-5所示。stat函数以一个文件名作为输入,并填写一个stat数据结构中的各个成员。fstat类似,但以文件描述符作为输入。

图8-5

8.3 printf的实现分析

首先,打开stdio.h,找到printf的源码如图8-6所示。

图8-6
查阅资料[5]后得知,它可以写成图8-7中的形式,更便于理解。

图8-7
上图中,va_list与char*等价,因此红框部分的语句旨在让argv指向一个字符串;蓝框部分调用vsprintf函数生成显示信息,它接受确定输出格式的格式字符串fmt,用格式字符串对个数变化的参数进行格式化,产生格式化输出,它的返回值i是要打印出来的字符串的长度,其实也就是printf函数的返回值;绿色部分,调用write函数(UNIX I/O的一个函数)执行写操作,把buf中的i个元素的值写到终端。
综上,printf函数的功能可以总结为以下几个步骤:
1)从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等。
2)字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
3)显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

C 库函数 int getchar(void) 从标准输入 stdin 获取一个字符(一个无符号字符)。它返回的是ASCII码,所以只要是ASCII码表里有的字符它都能读取出来。在调用getchar()函数时,编译器会依次读取用户键入缓存区的一个字符(注意这里只读取一个字符,如果缓存区有多个字符,那么将会读取上一次被读取字符的下一个字符),如果缓存区没有用户键入的字符,那么编译器会等待用户键入并回车后再执行下一步 (注意键入后的回车键也算一个字符,输出时直接换行)。[6]
综上,getchar函数的行为可以总结为以下两个步骤:
1)异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
2)getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

本章对于UNIX I/O进行了展示,主要是它的组织形式、函数和功能等内容,解释了进程是怎么与I/O设备连接并调用他们完成功能的。

结论

首先hello.c源程序(文本)通过预处理器(cpp)被预处理为hello.i文本,然后通过编译器(ccl)成为汇编程序hello.s文本,然后通过汇编器(as)成为可重定位目标程序hello.o二进制文件,之后通过链接器(ld)与printf.o等需要的可重定位目标程序进行链接成为可执行目标程序hello二进制文件。此时,hello诞生了。
可是现在,hello依旧只是一个文本文件,存储在外存中,在内存中可以说是占用资源为0,在我们通过I/O设备(比如键盘)在shell bash命令行中键入:./hello之后,OS(进程管理)通过fork为可执行目标程序hello创建进程,使用execve加载并运行hello进程,使用mmap函数来为hello进程的代码、数据、bss和栈区域创建新的内存区域结构,并将对象映射到这些区域中。接下来,CPU为hello进程分配时间片,通过缺页中断将数据从磁盘加载到物理内存中进行调用,使用TLB进行提速。在进程运行过程中,IO管理处理通过键盘、鼠标等的输入,生成的信号被信号处理子程序进行处理,同时IO管理将hello进程的输出打印在屏幕上。最后,hello进程运行结束,由父进程回收hello进程。至此,hello的运行正式结束。
通过对hello的一生的了解,我对于计算机底层工作有了近乎全新的认识,以前模糊的概念也变得更加清晰了一些。曾经不知道计算机底层是如何完成这么多复杂的操作,经过学习,我惊叹于各个组件的完美配合,才能够为用户提供良好的使用体验、为hello谱写辉煌的一生。
在今后,我会继续学习计算机系统知识,基于这些精巧的设计,编写面向CPU友好的代码,充分发挥它们的作用。

附件

hello.i:展示hello.c经过预处理的结果。
hello.s:展示hello.i经过编译的结果。
hello.o:展示hello.s经过汇编的结果。
hello:使用gcc -m64 -Og -no-pie -fno-PIC hello.c -o hello命令将hello.c编译成可执行目标程序hello,或者使用ld指令将hello.o与其他可重定位目标文件链接生成hello。用于展示hello.o经过链接的结果、使用objdump查看反汇编代码、在shell中执行该进程并理解进程的执行机制。

参考文献

[1] Richard M. Stallman, Zachary Weinberg. GCC 11.1 manuals: The C Preprocessor[M/OL]. America: Free Software Foundation, Inc, 2021. https://gcc.gnu.org/onlinedocs/gcc-11.1.0/cpp/
[2] 兰德尔•E. 布莱恩特(Randal E. Bryant)等著; 龚奕利, 贺莲译. 深入理解计算机系统[M]. 第三版, 北京: 机械工业出版社, 2016. 1-726.
[3] 博客园. 实例分析ELF文件静态链接[EB/OL]. https://www.cnblogs.com/fengyv/p/3775992.html, 2014-06-08.
[4] CSDN. Linux下的虚拟地址映射详解(一)逻辑地址到线性地址的映射 [EB/OL].https://blog.csdn.net/qq_33225741/article/details/71982274, 2017-05-14.
[5] 博客园. [转]printf 函数实现的深入剖析[EB/OL]. https://www.cnblogs.com/pianist/p/3315801.html, 2013-09-11.
[6] CSDN. C语言 getchar()函数详解[EB/OL]. https://blog.csdn.net/Huang_WeiHong/article/details/109455150, 2020-11-02.


分享:

低价透明

统一报价,无隐形消费

金牌服务

一对一专属顾问7*24小时金牌服务

信息保密

个人信息安全有保障

售后无忧

服务出问题客服经理全程跟进