Csapp-程序人生

目录

摘要

本文通过hello程序实例对《深入了解计算机系统》知识主线进行总结梳理,阐释分析了Linux系统下hello.c程序从 Program到Process,从无到有再到无的过程。运用了终端命令行和edb,从计算机系统的角度解释了Linux系统对典型C语言程序的处理机制。
gcc将C语言源文件进行预处理、编译、汇编、链接,最终形成可执行目标文件hello,由存储器保存在磁盘中。运行进程时,操作系统为其分配虚拟地址空间,提供异常控制流等强大的工具,Linux I/O为其提供与程序员和系统文件交互的方式。
本文通过分析Hello程序从代码编辑器到运行进程的过程,对计算机系统编译源文件、运行进程等机制进行较深入的分析和介绍。有助于加深初学者对于计算机软硬件系统的初步认识。

关键词:计算机系统;Linux;程序生命周期;C语言底层实现

第1章 概述

1.1 Hello简介

hello.c的程序是linux环境下,使用文本编辑器,利用C语言编写的程序。

1.1.1 P2P(From Program to Process)

用户使用高级语言通过文本编辑器编写易读的代码,得到一个hello.c程序为了在系统上运行hello.c程序,每条C语旬都必须被其他程序转化为一系列的低级机器语言指令。然后这些指令接照一种称为可执行目标程序的格式打好包,并以二进制磁盘文件的形式存放起来。目标程序也称为可执行目标文件。
用户在Ubuntu 终端中键入./hello启动此目标程序(可执行文件),shell调用fork函数为其产生子进程,实现hello.c(program)成为进程(process)

1.1.2 O2O(From Zero-0 to Zero-0)

描述进程从产生到结束的过程。shell调用fork、vfork系统调用,得到父进程的一个复制品——子进程;为了让子进程运行main程序代码,父进程shell调用execve,操作系统为该进程分配一定虚拟空间,当程序加载到虚拟空间映射的物理内存空间中时,可执行目标文件hello中的代码段和数据段从磁盘复制到物理内存。在start_thread()内核函数完成后,结束execve()回到用户态时,内核已为hello进程分配时间片执行逻辑控制流,CPU此时已被系统调用切换至main程序的运行上下文(即main二进制程序对应的汇编代码的第一条指令start处)开始运行。
后续的运行都按照main的汇编进行,主要由函数栈对esp寄存器值、ebp寄存器值、函数返回地址的压栈和退栈来实现;main函数结束前return 0;并使用ret 函数栈中弹出函数返回地址,并jump跳转到这个地址。
ret指令最终使得程序返回到C程序例程C程序例程将调用exit系统调用,shell父进程回收此僵尸子进程。切换到系统内核态,结束进程,并且发送运行结束的状态码给父进程。shell调用的子进程执行完毕,准备处理下一条指令。

1.3 中间结果

文件的作用 文件名
文件的作用 文件名
源程序 hello.c
预处理后的文件 hello.i
编译之后的汇编文件 hello.s
汇编之后的可重定位目标文件 hello.o
链接之后的可执行目标文件 hello
hello.o 的 ELF 格式 hello.o-elf
hello.o 的反汇编代码 hello.o-objdump
hello的ELF 格式 hello-elf
hello 的反汇编代码 hello-objdump

1.4 本章小结

本章简要介绍了大作业的背景——针对hello程序的探究。简述了探究的流程:P2P和O2O,并介绍了大作业的环境和工具,以及中间结果文件。

第2章 预处理

2.1 预处理的概念与作用

**概念:**预处理过程是编译之前进行的处理,预处理器(cpp)根据以字符#开头的命令(#include头文件和#define宏定义等),修改原始的C程序,将引用的所有库展开合并成为一个完整的文本文件,得到了另一个C程序,通常是以.i作为文件扩展名。
**作用:**主要作用有宏定义、文件包含、条件编译

  1. 将所有的#define删除,并且展开所有的宏定义,将宏名替换为文本。.
  2. 处理所有条件预编译指令,根据#if以及#endif和#ifdef以及#ifndef来判断执行编译的条件。
  3. 预处理程序中的#include,将头文件的内容插入到该命令所在的位置,从而把头文件和当前源文件连接成一个源文件。

2.2在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

  1. 代码行数以及文件大小增加,进行了头文件的展开,有大量对结构的定义,诸如typedef、struct、enum等等,对外部变量的引用,诸如extern。
  2. 预处理的结果在C源程序中插入所有用#include命令指定的文件,并扩展所有用#define声明指定的宏,它们扩充程序设计的环境。
  3. main函数在hello.i的最后。

2.4 本章小结

本章主要介绍预处理的概念和应用功能,以及linux下预处理的指令,对比hello.c文件和预处理结果hello.i文本文件解析,分析了头文件展开,宏替换,条件编译,去除注释等功能。详细了解了预处理的细节(插入所有用#include命令指定的文件,并扩展所有用#define声明指定的宏)过程和结果。

第3章 编译

3.1 编译的概念与作用

**概念:**编译器(ccl)将预处理得到的ASCII码的中间文件hello.i翻译成汇编语言文件hello.s的过程。预处理后的文件到生成汇编语言程序。
**作用:**主要作用有语法分析,中间代码,优化,目标代码生成。

  1. 语法分析:分析代码的词法、语法、语义。词法分析对输入的字符串进行分析和分割,形成所使用的源程序语言所允许的记号,同时标注不规范记号,产生错误提示信息。语法分析词法分析得到的记号序列,并按一定规则识别并生成中间表示形式,以及符号表。同时将不符合语法规则的记号识别出其位置并产生错误提示语句。语义分析即静态语法检查,分析语法分析过程中产生的中间表示形式和符号表,以检查源程序的语义是否与源语言的静态语义属性相符合。
  2. 中间代码:源程序的一种内部表示,或称中间语言。使编译程序的结构在逻辑上更为简单明确,特别是可使目标代码的优化比较容易实现中间代码。
  3. 代码优化:指对程序进行多种等价变换,转换为功能等价但是运行时间更短或占用资源更少的等价中间代码。
  4. 目标代码:生成是编译的最后一个阶段。目标代码生成器把语法分析后或优化后的中间代码变换成目标汇编语言代码。相比机器代码的二进制格式可读性更好。可见一些通常对C语言程序员隐藏的处理器状态。例如程序计数器,整数寄存器,条件码寄存器。

3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

**指示(Directives):**以‘. ’开头的行都是指导汇编器和链接器工作的伪指令。我们通常可以忽略这些行。
**标签(Labels):**以‘ :’结尾,用来把标签名和标签出现的位置关联起来。例如,标签.LC0:表示紧接着的字符串的名称是 .LC0。按照惯例,以点号开始的标签都是编译器生成的临时局部标签,其它标签则是用户可见的函数和全局变量名称。
指令(Instructions): 实际的汇编代码 (pushq %rbp), 一般都会缩进,以便和指示及标签区分开来。

3.3.1 数据操作

C语言的数据类型有常量,变量(全局/局部/静态),表达式,类型,宏。

1.常量(字符串)

两个字符串都被存放在.section 的.rodata节(只读数据段)

  1. "用法: Hello 学号 姓名 秒数!n"编译后上图。在hello.s,字符串使用UTF-8的格式编码的,一个汉字在UTF-8中占三个字节。
  2. “Hello %s %sn”,仍然是由printf函数传入的格式化参数
    main函数中对字符串引用为26行:leaq.LC1(%rip), %rdi,直接使用伪指令.LC1代指字符串。

2.局部变量

变量 argc(传入main函数的参数个数) :main函数的第一个形式参数(22-24行),由寄存器%edi传入,进而保存在堆栈中,其中argc自增加一(argv是指向指针的指针,main函数的第二个参数“char *argv[]“也可以替换为 “char **argv“,两者是等价的)。
int i:i在c程序里面被声明为局部变量,而且没有被初始化。它不占用文件的实际节空间,只有当运行时才在寄存器或者栈上分配空间进行操作。

3.全局变量(数组argv[])

程序中涉及到的数组为字符串数组,程序中涉及到的数组为字符串数组(字符数组指针),即main函数的第二个参数char *argv[]。首先,它将数组的首元素地址存入栈中,采用寄存器寻址的方法。

3.3.2 赋值

源程序中的赋值操作有i=0;i++;对应汇编代码mol
mov指令的后缀:b/w/l/q 对应1/2/4/8字节。

3.3.3类型转换

利用atoi函数将字符类型转换为整数类型,先取出argv[3],将其存入rdi。

3.3.4 算术操作

  1. 加法操作add:在对计数器加一时addq,对应C语言“i+1”
  2. 减法操作sub:为main函数开辟栈帧是将栈顶指针-0x32。
  3. 地址的运算:整个字符串首地址±相对地址进行寻址。
  4. 加载有效地址leaq:计算LC0 的段地址%rip+.LC0并传递给%rdi。

3.3.5关系操作

使用cmpl设置条件码,jxx根据条件码选择是否进行相应的跳转

3.3.6 函数操作

进行过程A 时,程序计数器必须设置为A 的代码的起始地址
返回时,要把程序计数器设置为调用A后面那条指令的地址。
在开始时,A可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。

1.main函数

被调用call 才能执行(被系统启动函数__libc_start_main 调用),call 指令将下一条指令的地址dest 压栈,然后跳转到main 函数。
结束时,用eax返回0,leave相当于mov %rbp,%rsp, pop %rbp,恢复栈空间为调用之前的状态,然后ret返回,将下一条要执行指令的地址设置为dest

2.printf函数

在.L4中,通过argv+8 和argv+16分别指向了argv[1],argv[2],再由argv[1],argv[2]指向的参数字符串得到了%rsi,%rdx两个参数,并和%rdi所保存的字符串一起作为printf函数的三个参数。

3.atoi函数

取出arg[3]放入rdi传入atoi函数

4.sleep函数

5.getchar函数

6.exit函数

3.4 本章小结

本章把hello.i文件编译成了hello.s,并对源程序中的数据和操作做了解析,阐述了和汇编代码的对应关系。

第4章 汇编

4.1 汇编的概念与作用

**概念:**汇编器(as)将hello.s文件翻译成二进制机器语言指令,把这些指令转化成一种叫做可重定位目标程序的格式,并将结果保存到二进制文件目标文件hello.o中。
**作用:**汇编过程将汇编代码转换为计算机能够理解并执行的二进制机器代码。

4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

**ELF头:**以一个16字节的序列开始,描述了生成该文件的系统的字的大小和字节顺序。还包含帮助链接器语法分析和解释目标文件的信息。包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
**节头:**记录每个节的名称,偏移量,大小,位置等信息

.text节:已编译程序的机器代码以编译的机器代码。
.rela.text节:.text节中的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
.data节:已初始化的静态和全局C变量。
.bss节:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量,在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。
.rodata节:存放只读数据。
.comment节:包含版本控制信息。
.note : 该节中包含注释;
.symtab:一个符号表,存放在程序中定义和引用的函数和全局变量的信息。
.strtab节:字符串表,其内容包括.symtab和.debug节中的符号表,节头部的节名字。
.shstrtab节:该区域包含节的名称。

4.4 Hello.o的结果解析

Hello.o反汇编与hello.s的区别可知:

  1. 数据显示:立即数在hello.s这一汇编语言文本文件中为十进制,而在反汇编代码中为十六进制。
  2. 跳转方式:在汇编代码中,对于分支转移函数调用,通过助记符如.LC0,.LC1来进行跳转。在反汇编代码是依据地址跳转的。
  3. 函数调用:函数调用:在.s 文件中,函数调用之后直接跟着函数名称,而在反汇编序中,call 的目标地址是当前下一条指令。因为 hello.c 中调用的函数 都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执 行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text 节中为其添加重定位条目,等待静态链接的进一步确定,其地址还需要重定位地址。
  4. 字符串常量:汇编中字符串常量在.rodata中存储,反汇编中字符串常量使用0x0暂时代替,等待重定位。
    机器指令由操作码和操作数构成,汇编语言是人们比较熟悉的词句直接表述CPU动作形成的语言,是最接近CPU运行原理的语言。每一条汇编语言操作码都可以用机器二进制数据来表示,进而可以将所有的汇编语言(操作码和操作数)和二进制机器语言建立一一映射的关系,因此可以将汇编语言转化为机器语言。

4.5 本章小结

本章介绍了hello 从hello.s 到hello.o 的汇编过程、阅读了程序的ELF条目,了解了汇编、反汇编这两种相近而不相同的程序表现形式,了解到从汇编语言映射到机器语言汇编器需要实现的转换。

第5章 链接

5.1 链接的概念与作用

**概念:**链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行
**作用:**链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能。

5.2 在Ubuntu下链接的命令

crt1.o, crti.o, crtbegin.o, crtend.o, crtn.o 等目标文件和hello.o链接成一个执行文件。
这5个目标文件的作用分别是启动、初始化、构造、析构和结束,在标准的linux平台下,link的顺序是:ld crt1.o crti.o [user_objects] [system_libraries] crtn.o
ld -o hello -dynamic-linker
/lib64/ld-linux-x86-64.so.2
/usr/lib/x86_64-linux-gnu/crt1.o 
/usr/lib/x86_64-linux-gnu/crti.o hello.o 
/usr/lib/x86_64-linux-gnu/libc.so 
 /usr/lib/x86_64-linux-gnu/crtn.o

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

  1. ELF头:对文件类型、程序入口地址、头起点地址、各节的大小进行修改,得到了相应的节大小。
    在之前的.o文件中,所有的地址位都是零,而在可执行文件中,这些节都有了自己的地址。可重定位说明还未定位,而在可执行文件中,这些代码都已经定位到最终要执行的地址,涉及定位的节像rel.text等就没有了。同时出现程序表。
  2. 节头部表:给出各节的大小,地址(虚拟地址)和偏移量等。
  3. 程序头表:相当于一个目录,描述了各节的分布,地址的数值范围图 29 hello 程序头表
程序包含**PHDR,INTERP,LOAD,DYNAMIC,NOTE,GNU_STACK,GNU_RELRO**几个部分。
其中PHDR 保存程序头表。
INTERP 指定在程序已经从可执行文件映射到内存之后,必须调用的解释器。
LOAD 表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据、程序的目标代码等。
DYNAMIC 保存了由动态链接器使用的信息。
NOTE 保存辅助信息。
GNU_STACK:权限标志,用于标志栈是否是可执行。
GNU_RELRO:指定在重定位结束之后哪些内存区域是需要设置只读。

5.4 hello的虚拟地址空间

  1. edb的Data Dump窗口显示虚拟地址0x401000开始,0x402000结束与hello的Program Header对应
  2. edb中查看Symbols选项,并与5.3中列出的虚拟地址各段信息做对比(以12-14为例),可以看到匹配的相同地址。
  3. 该两处操作中0x402008与0x40202e分别为两个字符串首地址,与节头部表中.rotata对应

5.5 链接的重定位过程分析

链接过程主要包括了地址和空间分配、符号决议和重定向
符号决议:有时候也被叫做符号绑定、名称绑定、名称决议、或者地址绑定,其实就是指用符号来去标识一个地址。
重定位:重新计算各个目标的地址过程叫做重定位。
hello-o-objdump中的地址是相对偏移地址,hello-objdump中的地址是虚拟地址,而且跳转和函数调用都以虚拟地址。因为hello反汇编的结果中,由于链接过程中重定位而加入进来各种函数、数据,许多开始的函数和调用的函数填充在main函数之前,main函数的位置发生改变。而这些call函数引用全局变量,和跳转模块值时地址也有所变化。
最基本的链接叫做静态链接,(Linux:.o)目标文件和库一起链接形成最后的可执行文件。库其实就是一组目标文件的包,就是一些最常用的代码变异成目标文件后打包存放。最常见的库就是运行时库,它是支持程序运行的基本函数的集合。
hello-objudmp中增加了许多外部链接的共享函数库,例如printf@plt以getchar@plt等等。
可知链接器把 hello.o 中的偏移量加上程序在虚拟内存中的起始地址得到了可直接访问的地址。

5.6 hello的执行流程

名称 地址
_init 0x401000
.plt 0x401020
puts@plt 0x401030
exit@plt 0x401060
printf_chk@plt 0x4010b0
sleep@plt 0x401070
getc@plt 0x4010e0
_start 0x4010f0
main 0x4011d6
_libc_csu_init 0x401260
_libc_csu_fini 0x4012d0
_fini 0x4012d8

5.7 Hello的动态链接分析

动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而非静态链接一样把所有程序模块都链接成一个单独的可执行文件。(注意形成可执行文件和执行程序,生成可执行文件,在执行程序时仍会修改)
plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次plt就能跳转到正确的区域。延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。

5.8 本章小结

本章介绍了链接的概念、作用以及在Ubuntu下通过ld指令进行链接的方法,通过对hello.o进行链接得到可执行目标文件hello,分析hello的ELF格式,并动态链接的实现进行分析。

第6章 hello进程管理

6.1 进程的概念与作用

**概念:**进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
**作用:**进程提供给应用程序的关键抽象:一个独立的逻辑控制流,如同程序独占处理器;一个私有的地址空间,如同程序独占内存系统。可以说,如果没有进程,体系如此庞大的计算机不可能设计出来。

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

Shell是用户级的应用程序,代表用户控制操作系统中的任务。处理流程如下:
①在shell命令行中输入命令:$./hello
② shell命令行解释器构造argv和envp;
③ 调用fork()函数创建子进程,其地址空间与shell父进程完全相同,包括只读代码段、读写数据段、堆及用户栈等
④ 调用execve()函数在当前进程(新创建的子进程)的上下文中加载并运行hello程序。将hello中的.text节、.data节、.bss节等内容加载到当前进程的虚拟地址空间
⑤ 调用hello程序的main()函数,hello程序开始在一个进程的上下文中运行。

6.3 Hello的fork进程创建过程

在命令行键入hello运行命令,shell判断该命令是可执行文件,于是调用fork函数给hello程序创建一个进程并分配一个标识符,子进程得到与父进程用户级虚拟地址空间和任何打开文件描述符相同的副本,但他们有不同的PID。

6.4 Hello的execve过程

fork函数创建完进程后,调用execve函数到我们输入的目标路径中寻找hello文件,找到后创建一个内存映像,为该程序的栈区域创建新的区域结构将可执行文件的片复制到代码段和数据段等。然后为共享库建立映射空间,整个内存映射空间的结构如下图所示。最后设置当前进程上下文的程序计数器,将其指向入口函数,并将控制传递给新程序的主函数。

6.5 Hello的进程执行

6.5.1 概念介绍

  1. 时间片:分时操作系统分配给每个正在运行的进程微观上的一段CPU时间;
  2. 进程上下文:用户进程传递给内核的参数以及内核要保存的变量和寄存器值和当时的环境等;
  3. 中断上下文:硬件通过触发信号,导致内核调用中断处理程序,进入内核空间的过程中需要将一些参数传递给内核,内核通过这些参数进行中断处理,而硬件传递过来的参数和内核需要保存的一些其他环境即为中断上下文;
  4. 内核态:在CPU高执行级别下,代码可以访问任意的物理地址;
  5. 用户态:在CPU相应的低级别执行状态下,代码的掌控范围会受到限制,只能在对应级别允许的范围内活动;

从用户态到内核态切换可以通过三种方式:

  1. 系统调用;
  2. 异常:如果当前进程运行在用户态,如果这个时候发生了异常事件,就会触发切换;
  3. 外设中断:当外设完成用户的请求时,会向CPU发送中断信号;

处理器总处于以下状态中的一种:

  1. 内核态,运行于进程上下文,内核代表进程运行于内核空间;
  2. 内核态,运行于中断上下文,内核代表硬件运行于内核空间;
  3. 用户态,运行于用户空间。

6.5.2 hello的执行:

在execve将程序加载完毕之后,hello处于用户态,打印字符串”Hello 7203610720 王越琛”,然后调用sleep函数,该函数为系统函数,因此会产生用户态到内核态的切换,此时hello进程被移除运行队列,加入到等待队列,计时器计时开始,hello进程的上下文被保存,内核进行上下文的切换,将控制转移给其他进程,该进程恢复上下文开始继续运行。当计时时间达到预定时间后,会发送中断信号中断当前进程,进而又触发上下文转换,切换回hello进程的上下文。
当hello调用getchar函数时,实际是调用的系统函数read,因此进入内核态,内核中的陷阱处理程序请求来自键盘缓冲区的直接存储器访问。此时进行上下文的切换,执行其他进程,在键盘缓冲区到内存的传输完成之后,引发中断信号,切换回hello进程的上下文继续执行。

6.6 hello的异常与信号处理

  1. 正常执行
  2. 不停乱按
  3. Ctrl-Z
  4. Ctrl-C

6.7本章小结

阐明了进程的定义与作用,介绍了Shell 的一般处理流程,并探讨了hello进程运行过程中可能的异常和信号处理。

第7章 hello的存储管理

7.1 hello的存储器地址空间

1.逻辑地址(Logical Address):由程序产生的与段相关的偏移地址部分。在这里指的是hello.o中的内容。
2.线性地址(Linear Address):逻辑地址到物理地址变换之间的中间层。程序hello的代码会产生段中的偏移地址,加上相应段的基地址就生成了一个线性地址。
3.虚拟地址:CPU 启动保护模式后,程序运行在虚拟地址空间中。与物理地址相似,虚拟内存被组织为一个存放在磁盘上的N 个连续的字节大小的单元组成的数组,其每个字节对应的地址成为虚拟地址。
4.物理地址:放在寻址总线上的地址。放在寻址总线上,如果是读,电路根据这个地址每位的值就将相应地址的物理内存中的数据放到数据总线中传输。如果是写,电路根据这个地址每位的值就在相应地址的物理内存中放入数据总线上的内容。物理内存是以字节(8位)为单位编址的。

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

段式内存管理:把虚拟地址空间中的虚拟内存组织成一些长度可变的称为段的内存单元。每个段有三个参数定义:段基地址,指定段在线性地址空间中的开始地址。段偏移量:是虚拟地址空间中段内最大可用偏移地址。段属性:指定段的特性。如该段是否可读、可写或可作为一个程序执行,段的特权级等。在此基础上,处理器有两种寻址模式:实模式与保护模式。线性地址等于基地址+偏移量。
一个逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段(段选择符)。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。
给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。Base + offset = 线性地址。

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

页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。

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

MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单一PTE组成的块。TLB通常有高的相联度,从虚拟地址中的页号提取出组选择和行匹配的索引和标记字段。TLB结构如下:
翻译时,首先将虚拟地址的VPN划分为TLBT(TLB标记)和TLBI(TLB索引)。利用TLBI,访问TLB中的某一组。遍历该组中的所有行,若找到一行的标志等于TLBT,且有效位为有效,,则缓存命中,该行存储的即为PPN;若未找到一行的标签等于TLBT,或找到但该行无效,则缓存不命中。进而需要到页表中找到被请求的块,用以替换原TLB表项中的数据。
在此基础之上,计算机还采取了多级页表的形式。它主要处理TLB不命中的情况,用来翻译虚拟地址。此时VPN被解释为4各=个段,从高地址开始,第一段VPN部分作为第一级页表的索引,用以确定第二级页表的基地址,以此类推直到在第四级页表里取到所需要的PPN,在整个过程中,一旦有一级的页表有效位为0,则下一级页表就不存在,产生缺页故障。

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

64位下,物理地址有52位,0到5位时CO偏移量,6到11位是CI组索引,12到51位是CT标记。
CPU发送一条虚拟地址,随后MMU按照7.4所述的操作获得了物理地址PA。根据cache大小组数的要求,将PA分为CT(标记位)CI(组索引),CO(块偏移)。根据CI寻找到正确的组,依次与每一行的数据比较,有效位有效且标记位一致则命中。如果命中,直接返回想要的数据。如果不命中,就依次去L2,L3,主存判断是否命中,命中时将数据传给CPU同时更新各级cache的储存。

7.6 hello进程fork时的内存映射

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

7.7 hello进程execve时的内存映射

execve 函数在shell中加载并运行包含在可执行目标文件hello中的程序。需要以下几个步骤:

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

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

虚拟内存中,DRAM缓存不命中称为缺页。缺页后,执行如下处理步骤:

  1. 处理器生成一个虚拟地址,并将它传送给MMU
  2. MMU生成PTE地址,并从高速缓存/主存请求得到它
  3. 高速缓存/主存向MMU返回PTE
  4. PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
  5. 缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。
  6. 缺页处理程序页面调入新的页面,并更新内存中的PTE
  7. 缺页处理程序返回到原来的进程,再次执行导致缺页的命令。

7.9动态存储分配管理

动态储存分配管理使用动态内存分配器(如malloc)来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合。每个块就是一个连续的虚拟内存页,状态为已分配或空闲。
动态内存分配主要有两种基本方法与策略,分配器的类型:
1.显式分配器: 要求应用显式地释放任何已分配的快
将堆的空闲块组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。进行内存管理。在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱
2.隐式分配器: 应用检测到已分配块不再被程序所使用,就释放这个块
空闲块通过头部的大小字段隐含地连接着。分配器遍历堆中所有的块,间接地遍历整个空闲块的集合。

7.10本章小结

本章我们讨论了intel的段式管理、页式管理,以i7为例介绍了VA到PA的变换、物理内存访问,还介绍了hello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

Linux将文件所有的I/O设备都模型化为文件,甚至内核也被映射为文件。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。Linux就是基于Unix I/O实现对设备的管理。

8.2 简述Unix IO接口及其函数

  1. 打开文件——open():open函数将file那么转换为一个文件描述符并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。
  2. 关闭文件——close():当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
  3. 读取文件——read():read函数从描述符为fd 的当前文件位置复制最多n个字节到内存位置buf。返回值一1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
  4. 写入文件——write():write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。

8.3 printf的实现分析

https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。用户输入的字符被存放在键盘缓冲区中直到用户按回车(回车也在缓冲区中)。
如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
**异步异常-键盘中断的处理:**当用户按键时触发键盘终端,操作系统将控制转移到键盘中断处理子程序,中断处理程序执行,接受按键扫描码转成ascii码,保存到系统的键盘缓冲区,显示在用户输入的终端内。当中断处理程序执行完毕后,返回到下一条指令运行。

8.5本章小结

本章介绍了 Linux 的 I/O 设备的基本概念和管理方法,以及Unix I/O 接口及其函数。最后分析了printf 函数和 getchar 函数的工作过程。

结论

hello从Program到Process的P2P过程很快,但他经历了:

  1. 预处理,对预处理指令进行解释,生成hello.i文件;
  2. 编译,将C语言代码转换成汇编语言代码,生成hello.s;
  3. 汇编,把汇编语言转换成机器代码,生成hello.o;
  4. 链接,将hello.o与可重定位目标文件及动态库链接,生成可执行程序hello;
  5. 创建进程:通过shell运行hello,调用fork为hello创建子进程;
  6. 运行程序:通过execve和加载器,加载hello到虚拟内存;
  7. 执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流
  8. 访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。
  9. 异常信号:如果进程途中出现异常信号,则调用信号处理函数进行停止或挂起进程的操作。
  10. Unix I/O:完成程序与文件之间的交互操作
  11. 进程结束:程序运行结束时,父进程回收子进程,CPU删除hello进程创建以来的数据。
    要成为合格的程序员或工程师,应该更加了解程序的底层实现,了解C语言如何从一个.c文件到可执行文件以及文件如何在计算机中运行和交互的。分析Hello程序从代码编辑器到运行进程的过程,对计算机系统编译源文件、运行进程等机制进行较深入的分析和介绍。加深我们对于计算机软硬件系统的初步认识。