12月29, 2019

程序人生-Hello’s P2P

摘要

本文从计算机底层角度,阐述了hello.c经过一系列处理,从程序变为进程,从零开始创建,最后经过回收又归于零的一生。通过gcc等工具的应用,具体地还原、分析了各个阶段的具体细节,将计算机系统的各个部分有机地结合起来,使读者更加深入地理解计算机系统。

关键词:P2P;020;编译;汇编;链接;进程;内存管理;

第1章 概述

1.1 hello简介

P2P: From Program(hello.c) to Process(fork),hello.c经过预处理器生成hello.i,再经过编译器翻译成汇编语言hello.s,再经过汇编器生成可重定位目标文件hello.o,再与调用函数的预编译文件经过链接器链接生成可执行目标程序hello。在shell中运行,通过fork()产生进程。

020: From Zero to Zero,shell通过execve加载执行hello,将hello的代码和数据从磁盘复制到主存,又将欲输出的字符串从主存复制到寄存器文件,再复制到显示设备,显示在屏幕上。运行完成后,shell回收hello进程,并删除相应数据。

1.2 环境与工具

硬件环境:Intel Core i5-7300U X64 CPU 2.71GHz; 8G RAM; 128G SSD;

软件环境:Windows10 64位; Kali-linux 4.4.0;

开发、调试工具:VS Code; gdb-peda; edb-debugger;

1.3 中间结果

hello.i: 预处理得到的中间结果,用于编译

hello.s: 预处理结果经过编译形成的汇编语言文件,用于汇编

hello.o: 编译结果经汇编后形成的可重定位目标文件,可与其他目标文件链接

helloobj.s: hello.o的反汇编文件,用于与hello.s对照

hello: 汇编结果经过与标准库的链接形成的可执行目标文件

helloexe.s: hello的反汇编文件,用于与helloobj.s对照

1.4 本章小结

本章大致介绍了hello一生的各个阶段历程、刨析hello所用到的工具以及得到的中间结果。

第2章 预处理

2.1 预处理的概念与作用

预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中第一行的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名。

作用:处理关于#的指令

  1. 删除#define,展开所有宏定义。
  2. 处理条件预编译 #if #ifdef #else #elif #endif
  3. 处理#include预编译指令,将包含的.h文件插入对应位置。这是可递归进行的,文件内可能包含其他.h文件。
  4. 删除所有注释。
  5. 添加行号和文件标识符,用于显示错误信息,错误或警告的位置。
  6. 保留#pragma编译器指令。(1)设定编译器状态 (2)指示编译器完成一些特定的动作。

2.2 在Ubuntu下预处理的命令

图 2-1 使用gcc进行预处理

图 2-2 使用cpp进行预处理

2.3 Hello的预处理结果解析

经过预处理,将代码中的宏展开,将头文件的内容递归添加到了文件中。

图 2-3 hello.i中引用的部分头文件

使用了一些typedef,定义了一些数据结构

图 2-4 hello.i中定义的部分数据结构

图 2-5 hello.i中定义的部分数据结构

引用库函数

图 2-6 hello.i中的部分库函数

最后,源程序位于.i文件最下方

图 2-7 hello.i中的源程序

2.4 本章小结

本阶段完成了源程序的预处理工作,将.c文件转换成了.i文件,解析了.i文件,理解了预处理的过程。

第3章 编译

3.1 编译的概念与作用

编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。该程序中的每条语句都以一种文本格式描述了一条低级机器语言指令。

作用:

  1. 扫描:将.i文件输入扫描器,将源字符序列分割成一系列记号。
  2. 语法分析:基于词法分析得到的一系列记号,生成语法树。
  3. 语义分析:由语义分析器完成,指示判断是否合法,并不判断对错。
  4. 源代码优化(中间语言生成):中间代码(语言)使得编译器分为前端和后端,前端产生与机器(或环境)无关的中间代码,编译器的后端将中间代码转换为目标机器代码,目的:一个前端对多个后端,适应不同平台。
  5. 代码生成,目标代码优化。

3.2 在Ubuntu下编译的命令

图 3-1 使用gcc进行编译

3.3 Hello的编译结果解析

开始部分:

.file:文件名

.text

3.3.1 数据

hello.s中数据包括:全局变量(sleepsecs),局部变量(i,argc,argv),只读数据(printf中的字符串)

sleepsecs

.global: 说明该变量是一个全局符号
.data: 数据段
.align: 4字节对齐
.type: 该符号是一个对象
.size: 大小为四字节

image.png

只读数据 printf中字符串

.section: 只读数据段
.string: 字符串内容

image.png

局部变量 i argc argv

i

存储在 -4(%rbp) 中,大小为4字节

image.png

argc

存储在 -20(%rbp) 中,大小为4字节

image.png

argv

存储在 -32(%rbp) 中,大小为8字节

image.png

3.3.2 赋值

全局变量的初始化

直接在数据段中体现

image.png

局部变量i的赋值

for中的初始化将i赋值为0

image.png

3.3.3 类型转换

sleepsecs的隐式类型转换

sleepsecs定义为int,而赋值为2.5,故发生隐式类型转换

3.3.4 算数操作

i++

在循环体的末尾,i=i+1(i存储在 -4(%rbp) 中)

image.png

3.3.5 关系操作

argc!=3

cmp比较argc(存放在 -20(%rbp) 中)与立即数3的大小

若相等则正常执行(跳转到.L2),不相等则终止

image.png

i<10

因为i为int型,编译器将i<10替换成了i<=9

若i<=9,则继续执行循环

image.png

3.3.6 数组/指针/结构操作

argv数组

在main函数开始,将argc 和 argv分别送到了栈中

之后,39行将argv移到rax中,并加16(char *类型大小为8字节)

这时rax中存放的是argv2,指向第三个参数字符串

41行取内存,移送到rdx中,rdx中存放的就是输入的第三个参数

42行又将argv移到rax中,43行加8

这时rax中存放的是argv[1] (类型:char *),指向第二个参数字符串

44 45行取内存,移送到rsi中,rsi中存放的就是输入的第二个参数

image.png

3.7 控制转移

if

判断argc是否等于3(argc与3比较,设置ZF标识)

若是(ZF=1),则跳转到.L2正常执行

若不是,则终止执行

image.png

for循环

首先在36行将i赋值为0,接着跳转到.L3即条件判断区域

若满足条件(i<=9) 则跳转到.L4即循环体区域

image.png

3.3.8 函数操作

main

参数: argc和argv分别存放在edi rsi中 image.png 调用: 由系统调用

返回: 将rax置为0,返回,即返回0 image.png

printf

参数: 格式化字符串、argv[1]、argv[2]

调用: 在main函数中调用

格式化字符串在rdi中,argv[1]在rsi中,argv[2]在rdx中

返回: 返回打印的字符数

image.png

puts

参数: 字符串

调用: 在main函数中调用(由printf优化而来)

返回: 返回字符串长度

image.png

exit

参数: 1

调用: 在main函数中,argc不等于3时调用

返回: 无返回值

sleep

参数: sleepsecs

调用: 在main函数中的for循环中调用

返回: 若无信号中断,返回0

若有信号中断,返回剩余秒数

image.png

getchar

参数: 无

调用: 在main函数中调用

返回: 返回输入字符的ascii码,若出错,返回-1

image.png

3.4 本章小结

在编译阶段,编译器分析hello.i中的语法,将其从c语言翻译成对应的的汇编语言。对汇编结果的解析,深刻地理解了c语言指令在汇编下的体现。

第4章 汇编

4.1 汇编的概念与作用

汇编器将编译阶段生成的hello.s翻译成机器语言指令,生成一个可重定位目标文件hello.o,以二进制机器代码的方式体现。

4.2 在Ubuntu下汇编的命令

image.png

image.png

4.3 可重定位目标elf格式

elf头描述了生成该文件的系统的字的大小和字节顺序,包含帮助连接器语法分析和解释目标文件的信息,包括elf头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。

image.png

节头部表描述了不同节的名字、类型、地址、偏移、大小、读写权限等信息

image.png

.rela.text节是一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。

重定位条目结构为

typedef struct{
    long offset;
    long type:32,
         symbol:32;
    long addend;
} Elf64_Rela;

其中,offset时需要被修改的引用的节偏移,symbol标识被修改引用应指向的符号,type告知链接器如何修改新的引用,addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。

.rela.text中,.rodata节和全局变量sleepsecs使用的是PC相对寻址,而调用的函数puts, exit, printf, sleep, getchar使用的是PLT。

image.png

符号表包含hello.o定义和引用的符号的信息

符号表条目结构为

typedef struct{
    int name;
    char type:4,
         binding:4;
    char reserved;
    short section;
    long value;
    long size;
} Elf64_Symbol;

其中name是字符串表中的字节偏移,指向符号的以null结尾的字符串名字。value是符号的地址。size是目标的大小。type通常要么是数据,要么是函数。binding字段表示符号是本地的还是全局的。

image.png

4.4 Hello.o的结果解析

使用命令: objdump -d -r hello.o >helloobj.s

区别:

  1. hello.s中数字表示为十进制,helloobj.s中为十六进制
  2. hello.s中jmp跳转使用的是.L2这样的段名,而helloobj.s中使用的是PC相对寻址
  3. hello.s中函数调用直接call函数,而helloobj.s中使用的是PLT寻址
  4. hello.s中访问全局变量和只读字符串使用的是sleepsecs/.LC0(%rip) 而helloobj.s中也是需要在链接时重定位

    image.png

4.5 本章小结

经过汇编,hello.s中的汇编语言转换成了hello.o中的机器语言,生成了可重定位目标文件,为后面的链接做准备。通过对比hello.s和反汇编helloobj.s的区别,深刻地理解了汇编器是如何将汇编语言转换成机器语言的。

第5章 链接

5.1 链接的概念与作用

链接器将目标文件与一些必要的系统目标文件、调用函数所在的目标文件、共享库、静态库等组合起来,创建一个可执行目标文件。

作用:当程序调用函数库(如标准C库)中的一个函数printf,printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个函数必须通过链接器(ld)将这个文件合并到hello.o程序中,结果得到hello文件,它是一个可执行目标文件,可以被加载到内存中,由系统执行。另外,链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能。

5.2 在Ubuntu下链接的命令

image.png

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

可执行目标文件的格式类似于可重定位目标文件的格式。elf头描述文件的总体格式,它还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。.text、.rodata和.data节与可重定位目标文件中的节时相似的,除了这些节已经被重定位到它们最终的运行时内存地址以外。.init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。因为可执行文件是完全链接的,所以它不需要.rela节

image.png

image.png

5.4 hello的虚拟地址空间

.text段位于虚拟地址空间的0x401080处 .rodata段位于虚拟地址空间的0x402000处 .data段位于虚拟地址空间的0x404040处

image.png image.png

image.png image.png

image.png image.png

5.5 链接的重定位过程分析

objdump -d -r hello > helloexe.s

不同:helllo中增加了.init, .fini和.plt节,以及引用的库函数; hello.o中的重定位信息已经被重定位成了运行时地址

重定位算法为*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr)

其中refaddr = ADDR(s) + r.offset (若重定位方式为绝对引用,则无这项)

refptr指向需要修改地址的起始点,r.symbol即为目标

从另外一个角度理解,r.addend – refaddr即为-PC

hello.o中,需要重定位的语句下保留了一条重定位信息,

image.png

该信息表明了需要修改的首地址(18),相对/绝对/PLT寻址(相对),寻址目标(.rodata)以及调整常数-0x4。

在重定位时,运用上面的算法,算得应重新写入的地址为

0x402004 – 0x4 - 0x4010c9 = 0xf37

与hello中结果相同

image.png

其他与之类似

image.png

5.6 hello的执行流程

加载时调用ld-2.27.so!_dl_start 和 ld-2.27.so!_dl_init

其中,ld-2.27.so!_dl_start函数返回了_start函数的地址,将其保存到%r12中

然后跳转到了_start函数

_start函数中使用了一个相对寻址的call

跳转到了ld-2.27.so!__libc_start_main

该函数中又调用了ld-2.27.so!__cxa_atexit

之后是ld-2.27.so!__new_exitfn

加载完成,开始执行main函数

然后调用了libc-2.27.so!exit

之后又是一层一层的call

最后,调用_fini函数

然后调用_exit

至此,hello的执行流程结束

5.7 Hello的动态链接分析

动态链接项目:GOT和PLT

dl_start和dl_init前:

plt image.png

got

image.png

dl_start和dl_init后:

plt

image.png

got

image.png

可以看到,在dl_start和dl_init前后,got[1]和got[2]的值改变了,而plt没有改变

5.8 本章小结

本章讲述了hello.o与其他库文件经过链接器链接后,生成了可执行目标文件,通过与hello.o的反汇编代码比较,理解了连接过程中重定位的方式,并通过edb查看了hello的执行流程。

第6章 hello进程管理

6.1 进程的概念与作用

进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础,是处理器、主存、I/O设备的抽象。程序是指令、数据及其组织形式的描述,进程是程序的实体。进程是一个具有独立功能的程序关于某个数据集合的一次运行活动。它可以申请和拥有系统资源,是一个动态的概念,是一个活动的实体。它不只是程序的代码,还包括当前的活动,通过程序计数器的值和处理寄存器的内容来表示。

作用:提供给应用程序的关键抽象:一个独立的逻辑控制流,好像程序独占地使用处理器;一个私有的地址空间,好像程序独占地使用内存系统。

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

shell是操作系统(内核)与用户之间的桥梁,充当命令解释器的作用,将用户输入的命令翻译给系统执行。

处理流程:

  1. 终端进程读取用户由键盘输入的命令行
  2. 分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量
  3. 检查第一个命令行参数是否是一个内置的shell命令
  4. 如果不是内部命令,调用fork()创建子进程
  5. 在子进程中,用步骤2获取的参数,调用execve()执行指定程序
  6. 如果用户没要求后台运行(命令末尾没有&号),shell使用waitpid(或wait)等待作业终止后返回
  7. 如果用户要求后台运行(命令末尾由&号),则shell返回

6.3 Hello的fork进程创建过程

shell调用fork时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

6.4 Hello的execve过程

在shell中输入./hello后,shell创建子进程并且加载hello

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

下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

6.5 Hello的进程执行

进程的上下文包括:内存中的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合,浮点寄存器,用户占,状态寄存器,内核栈和内核数据结构。

时间片是分时操作系统分配给每个正在运行的进程微观上的一段CPU时间。

用户模式和内核模式:处理器提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。进程运行在内核模式时,它可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。进程运行在用户模式时,进程不允许执行特权指令,也不允许进程直接引用地址空间中的内核区内的代码和数据。

进程调度是指在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度。

hello最开始运行在用户模式下,当执行到sleep语句时,进程因为等待sleep而阻塞,那么内核可以让当前进程休眠,通过上下文切换切换到另外一个进程,当sleep函数结束后,内核再通过上下文切换切换回hello进程。

6.6 hello的异常与信号处理

hello执行过程中会出现的异常:

- 中断(SIGTSTP): 按下Ctrl-Z 默认行为是停止直到下一个SIGCONT
- 终止(SIGINT): 按下Ctrl-C 默认行为是终止
- 终止(SIGKILL): shell的kill指令向进程发送信号,杀死进程

演示:

  1. 不停乱按

image.png

  1. Ctrl-C向进程发送一个SIGINT信号,终止进程

image.png

  1. Ctrl-Z向进程发送一个SIGTSTP信号,暂停进程的执行,使用ps/jobs/pstree可以看到该进程,而fg向进程发送一个SIGCONT信号,使进程继续前台进行,kill向进程发送SIGKILL信号,杀死进程

image.png

6.7本章小结

本章通过解析shell的处理流程,fork和execve函数作用,深入了解了hello在shell中的创建、加载、运行和终止流程,并且分析了异常与信号对hello的影响,了解了信号的作用。

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址是指在计算机体系结构中是指应用程序角度看到的内存单元、存储单元、网络主机的地址。逻辑地址往往不同于物理地址,通过地址翻译器或映射函数可以把逻辑地址转化为物理地址。每一个逻辑地址都由一个段和偏移量组成,偏移量指明了从段开始的地方到实际地址之间的距离,就是hello.o里相对偏移地址。

线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址,是hello中的虚拟内存地址。

虚拟地址:一个带虚拟内存的系统中,CPU从一个有N=2^n个地址空间中生成虚拟地址。虚拟地址其实就是线性地址。

物理地址:用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。地址翻译会将hello的一个虚拟地址转化为物理地址。

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

一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图:

image.png

索引号是段描述符表的索引,而表中的每一个条目——段描述符描述了虚拟内存中的一个段。通过这13位的索引号,可以直接再段描述符表中找到一个具体的段描述符,每一个段描述符由8个字节组成,如下图:

image.png

这里我们只关心Base字段,它描述了一个段的开始位置的线性地址。

Intel设计的本意是,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。这是由段选择符中的T1字段表示的,T1=0,表示用GDT,T1=1表示用LDT。

GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。

由逻辑地址到线性地址的转换流程如下图:

image.png

首先,给定一个逻辑地址

  1. 根据T1的值选择相应的寄存器(gdtr/ldtr),得知全局/局部段描述符表的地址和大小
  2. 取逻辑地址的前13位,在表中查找对应的段描述符,再取其中的Base段,得到基址
  3. 再将Base与Offset合并,得到线性地址。

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

CPU的页式内存管理单元,负责把一个线性地址,最终翻译为一个物理地址。从管理和效率的角度出发,线性地址被分为以固定长度为单位的组,称为页,例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址——对应的页的地址,如图所示:

image.png

  1. 分页单元中,页目录是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的开始点。
  2. 每一个活动的进程,因为都有其独立的对应的虚似内存(页目录也是唯一的),那么它也对应了一个独立的页目录地址。——运行一个进程,需要将它的页目录地址放到cr3寄存器中。
  3. 每一个32位的线性地址被划分为三部份,面目录索引(10位):页表索引(10位):偏移(12位)

依据以下步骤进行转换:

  1. 从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器)
  2. 根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址,页的地址被放到页表中去了。
  3. 根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址
  4. 将页的起始地址与线性地址中最后12位相加,得到最终想要的物理地址

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

  1. 首先,CPU向MMU发送一个虚拟地址
  2. 根据虚拟地址中的VPN在TLB中查找,若命中,则取出PPN,与VPO合并,得到物理地址
  3. 若TLB不命中,则将VPN分为四段,CR3指向了第一级页表的首地址,VPN1为偏移量,取出第二级页表地址,以此类推,最后通过VPN4取出的就是PPN,与VPO合并即为物理地址。

image.png

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

在得到物理地址后,将物理地址分为40位CT:6位CI:6位CO(块大小为64B,共64组,八路组相联),根据组索引找到对应的组,再搜索组中每行,若有效位为1且标记与CT相同,则在块偏移为CO的位置取出内容。其他情况为不命中,向L2/L3中请求该块,根据类似流程得到内容。

image.png

7.6 hello进程fork时的内存映射

当shell调用fork时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

在shell调用fork之后,在子进程中加载hello,步骤如下:

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

下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

image.png

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

发生缺页异常时,导致控制转移到内核的缺页处理程序,执行以下步骤:

  1. 判断该虚拟地址是否合法:处理程序将搜索区域结构的链表vm_area_struct,与链表中每个结点的vm_start和vm_end比较,若这个地址不合法,就会触发一个段错误,从而终止进程。
  2. 判断试图进行的访问是否合法:比如,这个缺页是不是由一条试图堆这个代码段里的只读页面进行写操作的存储指令造成的?是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?若访问不合法,处理程序就会触发一个保护异常,从而终止进程。
  3. 此时,内核知道了这个缺页时由于对合法的虚拟地址进行合法的操作造成的,那么处理程序会选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这次MMU就能正常翻译地址,而不会再产生缺页中断了。

image.png

7.9动态存储分配管理

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

  1. 显示分配器:要求应用显式地释放任何已分配的块。例如C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。
  2. 隐式分配器:要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配块的过程叫做垃圾收集。

分配器最开始向内核申请一段内存区域,后续调用malloc时就从这个区域内分配空间给应用,若没有合适的块,则扩展堆区域。

image.png

任何实际的分配器都需要一些数据结构,允许它来区别块边界,以及区别已分配块和空闲块,可以使用隐式空闲链表或者显示空闲链表,将块信息嵌入块的头部(和脚部)。下图左边是隐式空闲链表块的结构,右边是显式空闲链表块的结构。

image.png image.png

在malloc时,分配器搜索链表中的块进行分配,这时就有了不同的放置策略:

  1. 首次适配:搜索到合适大小的块就分配
  2. 下一次适配:从上次搜索结束位置开始,搜索到大小合适的块就分配
  3. 最佳适配:遍历链表中所有的块,寻找最合适的块进行分配

现在,分配器找到了一个大小合适的块,可以选择分割/不分割该块,若分割,将块分割成两个块,其中一个响应malloc的请求,而将另外一个添加回空闲链表。下面两图显示了分配器在收到一个3个字的请求后如何分割空闲块。

image.png image.png

当我们无法找到满足malloc要求的块时,可能是出现了假碎片的情况,假碎片就是两个相邻的空闲块,如下图,当malloc请求5个字的空间时,分配器就无法找到合适的块进行分配。

image.png

这时我们就需要合并相邻的空闲块,我们可以在块的脚部也添加上块的信息,这样可以方便寻找前置块。

image.png

此时,我们可以判断前后块是否空闲,若空闲则合并。

image.png

这就是内存分配器的基本原理。

7.10本章小结

本章从逻辑地址、线性地址、虚拟地址和物理地址的转换以及内存访问流程开始,刨析了hello是如何映射到虚拟内存空间上的,又分析了动态内存分配器的基本原理,深入理解了它的工作方式。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

一个Linux文件就是一个字节序列,所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。

8.2 简述Unix IO接口及其函数

Linux将设备映射为文件,允许Linux内核引出一个简单、低级的应用接口,即为Unix I/O接口。主要的函数有:

  1. 打开文件:内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。函数格式为:
int open(char *filename, int flags, mode_t mode);
  1. 关闭文件:进程通过调用close函数关闭一个打开的文件。
int close(int fd);
  1. 读文件:read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。
ssize_t read(int fd, void *buf, size_t n);
  1. 写文件:write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
ssize_t write(int fd, const void *buf, size_t n);

8.3 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; 
}

参数中fmt是格式化用的字符串,...是可变形参。函数中通过(char)(&fmt) + 4语句指向后面参数中的第一个参数。vsprintf函数接收确定输出格式的格式化字符串fmt,用格式化字符串对个数变化的参数进行格式化,产生格式化输出buf,然后返回输出字符长度i。

之后,printf函数通过调用write函数将buf中的内容输出到显示器上。write函数通过执行syscall指令实现了对系统服务的调用,从而使内核执行打印操作。内核会通过字符显示驱动子程序,根据传入的ASCII码到字模库读取字符对应的点阵,然后通过vram(显存)对字符串进行输出。显示芯片将按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),最终实现printf中字符串在屏幕上的输出。

8.4 getchar的实现分析

getchar函数原型为:

int 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;
}

异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。

getchar底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,读取字符串的第一个字符然后返回。

8.5本章小结

本章讲述了Linux的I/O设备管理方法,并了解了Unix I/O的接口,又刨析了printf和getchar的底层实现方式,理解了它们的流程。

结论

hello经历的过程:

  • hello的一生从编辑器开始,通过I/O设备被编写为hello.c文件
  • 然后通过cpp预处理,生成了hello.i
  • 接着,ccl将其编译成了汇编语言文件hello.s
  • 再经过汇编器,将汇编语言翻译成机器语言,生成可重定位目标文件hello.o
  • 链接器将hello.o与其他目标文件链接,生成了可执行目标文件hello
  • shell又为它fork了一个子进程,加载hello并执行
  • 到此,hello的P2P结束了,也开始了它的020
  • 在hello的执行过程中,内核分给它时间片,不断地对它调度
  • 执行完之后,shell对其进行回收,hello的一生又重归于零

感悟:

  • 通过对一个简简单单的hello的分析,深入地理解了hello的一生中计算机底层之间的协作与复杂的运作方式。不仅是hello,其他程序的运作方式也是类似的。计算机系统的设计其实是一个不断抽象的过程,比如将I/O设备抽象为文件,将主存和I/O设备抽象为虚拟内存,将处理器抽象为指令集架构,将处理器、主存和I/O设备抽象为进程等。这门课程让我深入了解了计算机系统的各个方面,受益匪浅,也让我在一次次挫折中不断坚持,一步步提升。

附件

  • hello.i: 预处理得到的中间结果,用于编译
  • hello.s: 预处理结果经过编译形成的汇编语言文件,用于汇编
  • hello.o: 编译结果经汇编后形成的可重定位目标文件,可与其他目标文件链接
  • helloobj.s: hello.o的反汇编文件,用于与hello.s对照
  • hello: 汇编结果经过与标准库的链接形成的可执行目标文件
  • helloexe.s: hello的反汇编文件,用于与helloobj.s对照

参考文献

为完成本次大作业你翻阅的书籍与网站等

本文链接:http://blog.zireaels.com/post/hellosp2p.html

-- EOF --

Comments