目录

  • 写在前面
  • 摘要
  • 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.4 本章小结
  • 4.汇编
    • 4.1 汇编的概念与作用
    • 4.2 在Ubuntu下汇编的命令
    • 4.3 可重定位目标elf格式
    • 4.4 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.6.1 乱按普通字符或回车
      • 6.6.2 Ctrl+C
      • 6.6.3 Ctrl+Z及后续
    • 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 本章小结
  • 结论
  • 参考文献

写在前面

哈工大开设的计算机系统课程,我个人的体验确实很好,这个大作业我也很喜欢,通过一个简单的输出hello的C语言程序来揭示一个linux x86-64计算机系统的复杂机制,确实是一个不错的idea呢~


摘要

本文通过对一个简单的C语言程序hello从编写完代码到进程结束的生命周期全过程的分析,展示了linux下一般程序的Program to Process过程(P2P)和zero to zero过程(020),揭示了一个较为完整的x86-64计算机系统的主要工作机制以及沟通顶层程序员与底层机器的原理。

关键词: 预处理;编译;汇编;链接;进程;内存管理;I/O管理;


1.概述

1.1 Hello简介

这个hello程序本身就是一个带有延时的打印hello信息的简单程序而已。然而,接下来我们会看到它并不简单。

Hello的P2P:程序员先编写出hello的原始C语言代码,然后经过预处理、编译、汇编、链接得到可执行文件,再由shell为hello创建进程并加载可执行文件,得到一个运行中的hello进程,于是hello便从代码变成了运行着的进程(from program to process)。

Hello的020:shell为hello创建进程并加载hello的可执行文件,为其提供了虚拟地址空间等进程上下文,实现了hello的从无到有的过程。
Hello在运行时会经历诸多的异常与信号,以及对存储器的访问也会涉及诸多机制,以及通过中断和IO端口与外设交互,等等。最终,hello正常退出或收到信号后终止,都会使得操作系统结束hello进程,释放其占用的一切资源,返回shell,这便是hello的从无到有再到无的过程(from zero to zero)。

1.2 环境与工具

硬件环境: CPU:AMD Ryzen7 5800H,16GB内存。
系统环境: 虚拟机:Ubuntu 20.04.4 LTS,VMWare Workstation 16
工具: 文本编辑器gedit,反汇编工具edb 1.3,反汇编工具objdump,编译环境gcc等。

1.3 中间结果

①原始代码hello.c。
②预处理后的代码hello.i。
③编译后的汇编语言代码hello.s。
④可重定位目标文件hello.o。
⑤hello.o的objdump结果hello_o_disasm.txt。
⑥可执行文件hello。
⑦hello的objdump结果hello_disasm.txt。

1.4 本章小结

本章主要介绍了hello.c程序P2P,020的过程。列出了本次实验所需的环境和工具以及过程中所生成的中间结果。


2.预处理

2.1 预处理的概念与作用

C语言编译器的预处理是将原始的代码按照带有“#”号的预处理语句进行扩展,例如在#include处插入文件,把#define的宏进行替换,根据条件选择#if内的代码等。

2.2 在Ubuntu下预处理的命令

在终端内输入命令gcc -E hello.c,在屏幕上得到hello.c的预处理结果(如图)。为方便起见我们重定向gcc的输出,将结果保存到hello.i文件内。

2.3 Hello的预处理结果解析

查看hello.i文件,在最开头,是hello.c涉及到的所有头文件的信息。

然后,先是这些头文件用typedef定义的诸多类型别名(其实它们跟#define真的没多大区别,只是预处理器不处理它们而已……)。

然后的内容是被include的头文件们的主体内容,包括大量的函数声明和少部分struct定义等。它们都完全经过预处理器的宏展开。

在文件的最后,才是真正的hello.c的内容(其实并没有被预处理器处理多少):

2.4 本章小结

从hello.c的预处理后的结果我们可以看出,预处理器确实对源代码进行了大量的展开操作,预处理后的结果仍然是合法的C语言源文件,但它比原先要扩充了很多被include来的东西(尽管我们可以看出其中的大部分内容实际上都是对程序本身没有任何用处的声明,程序只用到了其中声明的一小部分函数)。因此我们从预处理步骤就可以初步发现,一个小小的hello world程序,其背后隐藏的东西要多得多……


3.编译

3.1 编译的概念与作用

当C语言源程序经过预处理后,编译器会将原始的C语言语法格式的代码转换为面向CPU的机器指令,转换后的结果以汇编语言的文本形式保存。这一过程在hello.c到可执行文件的转换中意义最为重要,因为它直接决定了程序的机器级表示。

3.2 在Ubuntu下编译的命令

在终端里输入命令gcc -S hello.i -o hello.s -fno-PIC -no-pie -m64,得到编译结果hello.s。如下图所示:

3.3 Hello的编译结果解析

我们来看hello.s中main函数的汇编语言代码。接下来我们分类研究编译器hello.c原始C语言代码的处理:

3.3.1 函数调用与参数传递

首先,在x86-64平台下,C语言函数调用规范规定了,当参数不超过6个时,按rdi,rsi,rdx,rcx,r8,r9的顺序传递。我们来对照C语言源代码看汇编代码中所有涉及到函数调用的地方:

如图,我将反汇编结果与原始hello.c的代码的关键位置用相同颜色的标记做了对照。这里有6个地方值得我们关注:
①main被调用后,需要取参数argc和argv。由于该程序在编译时未开O2优化,包括参数在内的所有局部变量都会存在堆栈帧上,因此它会把edi中的32位整数argc与rsi中的64位地址argv分别放在堆栈帧的rbp-20和rbp-32处,视作局部变量。(见标黄处)
②调用printf(“用法:……”),仅一个参数,为字符串地址(在只读数据节的LC0位置,详情见后面),它会把地址LC0放在rdi中,然后call puts。值得注意的是这里gcc会自动把只输出一个字符串的printf处理为puts。(见标白处)
③调用exit(1),仅一个参数,它会把1放在edi中,然后call exit。(见标粉处)
④调用printf(“Hello %d %d\n”,argv[1],argv[2]),3个参数,它会把第一个参数字符串地址放在rdi中,第二个参数argv[1]放在rsi中,第三个参数argv[2]放在rdx中,然后call printf。(见标绿处)
⑤调用atoi(argv[3]),仅1个参数,它会把参数argv[3]放在rdi中,然后call atoi。(见标蓝处)
⑥调用sleep(atoi(argv[3])),仅1个参数,它会把atoi的返回值(此时在eax中)作为sleep的参数放在edi中,然后call sleep。(见标棕处)

3.3.2 数据存储

Hello程序中涉及到的数据主要有两种存储方式,一种是作为局部变量存储在堆栈帧中(由于hello.c编译时未开优化,因此,一种存储在只读数据节中。
在main函数中,有3个局部变量,其中两个是传递进来的参数argc(存在rbp-20处)和argv(存在rbp-32处),另一个是局部变量i(存在rbp-4处),所以main的堆栈帧总共需要分配32字节的空间(在最开头用subq $32,%rsp),堆栈帧如图所示:

而hello程序在调用printf的时候使用了两个常量字符串,它们存在hello程序的只读数据节,在hello.s中是这样的(中文字符用8进制3字节utf-8编码表示):

3.3.3 数据运算

Hello程序中有4处基于运算符的数据运算操作:
①比较,argc!=4,它被编译为cmp指令:

②局部变量赋值,i=0,它被编译为mov指令:


③比较,i<8,它被编译为cmp指令(编译器用的是等价的i<=7比较):

④加法,i++,它被编译为add指令:

3.3.4 数组操作

这个程序里唯一涉及到的数组操作就是对argv里的取元素,我们以第二个printf的传入argv[1]和argv[2]的过程为例说明:


上面标蓝的部分将argv[2]加载到rdx,标绿的部分将argv[1]加载到rax。它都通过rax算地址,数组中一个元素大小8字节,&argv[2]=argv+2*8=argv+16,&argv[1]=argv+8,然后再通过[rax]取数。这样取一个数就需要3条指令,效率很低,毕竟程序没用编译优化。

3.3.5 条件判断

该程序里开头使用了一个条件判断if(argc!=4),它会被编译器翻译为cmp加上条件转移指令的形式:

它使用cmp判断argc是否为4,如果是,那么状态寄存器的ZF位将会被置位,下面的条件转移指令je会直接跳转到.L2位置,从而跳过不执行中间的语句块(即对于printf和exit的调用),否则je不会跳转,中间的语句块将被执行。

3.3.6 循环

该程序里有一个for循环,将循环变量i从0循环到7,它也会被编译器翻译为cmp加上条件转移指令的形式:

以上标绿的指令是与循环控制有关的指令,首先它先为i赋初值0,每轮循环开始的时候先执行底部标绿的指令,用cmp将i与7比较,若i<=7则跳到循环体开头处继续这轮循环,否则不再继续。循环体结束后,将i++。

3.3.7 函数返回

函数返回前,会恢复一开始的堆栈状态(通过leave释放堆栈帧),将返回值存在rax中,并通过ret返回,对应main中的return 0语句,汇编代码如下:

3.4 本章小结

我们会发现,编译后生成的汇编代码hello.s的长度相比于预处理后的hello.i大大缩减了,这是因为hello程序的真正有用的部分仅为main函数,hello.s中的汇编代码基本上都是main的。这些汇编代码已经开始为程序的机器级表示定型,它们已经能够在指令级层面控制CPU了,并且还能够被人类所理解,可以说是介于程序员与机器之间的桥梁。


4.汇编

4.1 汇编的概念与作用

当编译器完成编译后,实际上就已经知道了hello.c中的C语言语句应该对应什么机器指令,但编译的结果仍然是文本形式。这个时候就需要汇编器将汇编语言直接翻译成CPU能够直接执行的机器码形式。也就是说,从这一步开始,hello程序将正式由文本形式转入二进制形式。

4.2 在Ubuntu下汇编的命令

在终端中对hello.s使用命令gcc -c,如图所示:

4.3 可重定位目标elf格式

使用readelf解析汇编器生成的可重定位目标文件hello.o,结果如下:



可以发现,hello.o中一共有13个节(包括代码.text、只读数据.rodata等),8个重定位条目,7个全局符号(都是函数声明,强符号)。在这些重定位条目中,有两个对应rodata节中的数据地址,显然它们是printf使用的那两个字符串地址。另外6个重定位条目都是被call指令调用过的函数地址,它们实际上还是空的,需要与对应的库链接。

4.4 Hello.o的结果解析

在终端中输入命令objdump -d -r hello.o,得到hello.o中可执行代码的反汇编结果:


可以看到,这个反汇编的结果和前面所分析过的hello.s的内容差不多,但我们已经能够在左侧看到汇编指令对应的字节形式的机器码,一条汇编指令恰好完全对应长若干字节的机器码,机器码的开头部分字节描述了这条机器指令的类型,之后的字节数据表示操作数。值得注意的是,上方标黄的8处指令中的操作数都不是0,但在hello.s中这些操作数应该是数据节或代码节中的一个地址,这是因为可重定位目标文件中的地址暂时还不是真正的地址,还未经过链接,链接后才能得到真正的地址,才能得到那些需要地址的指令的操作数,因此汇编器在目标文件中暂时把那些操作数设为0,并对应一个重定位条目(如图所示,有标注)。等到链接后再通过重定位条目重新设置那些指令操作数。

4.5 本章小结

可以看出,汇编这一步骤使得hello程序真正开始从文本状态转化为二进制状态,但我们一定要明白这并不是简简单单地翻译为真正的机器码,而是生成“可重定位的机器码”,重定位这一奇妙的机制是为了供链接使用的。


5.链接

5.1 链接的概念与作用

链接是指将各种代码和数据片段收集并组合为一个单一的可执行文件的过程。链接可以执行于编译时,也可以执行于加载时,或者执行于运行时。链接这一过程再软件开发中至关重要,因为它使得分离编译成为可能。我们可以对于同一个程序分割为若干独立模块,为其编写不同的源代码,分别独自编译为目标文件或库,最终将其链接起来。

5.2 在Ubuntu下链接的命令

在终端内输入命令

ld  -dynamic-linker /lib64/ld-linux-x86-64.so.2  /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/9/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello

将hello.o与其它的必要静态库链接,得到可执行文件hello。

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

使用readelf解析hello的ELF格式,得到hello的节信息和段信息:



5.4 hello的虚拟地址空间

使用edb加载hello,查看进程的虚拟地址空间各段信息:


图中标绿的4个段来自hello可执行文件本身,是hello主要的代码与数据,对应5.3中readelf查看的hello的program header的4个LOAD段。标黄的4个段来自hello加载的动态链接库ld-2.31.so,剩下的若干个段是栈段和内核相关段。

5.5 链接的重定位过程分析

在终端里输入命令objdump -d -r hello,得到可执行文件hello的反汇编结果。这个与可重定位目标文件hello.o的反汇编的结果相比,主要有两个地方不同,一是扩充了很多函数代码,包括程序加载后执行main前的一些准备工作,以及hello需要用到的一些库函数的定义等;二是在main中,原本在hello.o中等待重定位而暂时置0的地址操作数被设置为了虚拟地址空间中真正的地址。





所以,我们不难发现,链接器首先会将所有模块的节都组织起来,为可执行文件的虚拟地址空间定型,再根据这个虚拟地址空间将那些存在hello.o里的.rel.text和.rel.data节的重定位条目指向的位置的操作数都设置为正确的地址。

5.6 hello的执行流程

使用edb执行hello,首先,最初的程序地址会在0x7f2433995100处,这里是hello使用的动态链接库ld-2.31.so的入口点_dl_start:


然后,程序跳转到_dl_init,在经过了一系列初始化后,跳到hello的程序入口点_start:


然后程序通过一个间接call指令跳到动态链接库ld-2.31.so的__libc_start_main处,这个函数会进行一些初始化,并负责调用main函数:


然后它会调用动态链接库中的__cxa_atexit函数,它会设置在程序结束时需要调用的函数表:


然后返回到__libc_start_main继续,然后调用hello可执行文件中的__libc_csu_init函数,这函数是由静态库引入的,也是做一些初始化的工作:


然后返回到__libc_start_main继续,然后调用动态链接库里的_setjmp函数,应该是设置一些非本地跳转:


然后返回到__libc_start_main继续,我们就要开始调用main函数了!


现在我们其实不再关心main本身的程序,只是想看看main结束后的事情。由于我们在edb运行hello的时候并未给出额外的命令行参数,因此它会在第一个if处通过exit(1)直接结束程序:

通过hello本身携带的exit函数,程序会跳转到地址0x7f24339acbb0处,这一地址在动态链接库中:


之后,在进行了若干操作后,程序退出。
总结一下,hello程序执行的大概流程是这样的:

5.7 Hello的动态链接分析

Hello程序在加载可执行文件时,自动加载了动态链接库ld-2.31.so。hello程序的一些地方可能会引用这个动态链接库里的符号(比如函数调用),这一机制是通过PLT表和GOT表实现的,它们的每一个条目都对应动态链接库中的符号引用。在readelf中我们可以在节表里看到关于PLT和GOT的信息(它们对应两个节):


在程序一开始,先执行_dl_start和_dl_init,_dl_init能够修改PLT和GOT,这一过程相当于“注册”动态链接库的符号,使得hello在后面的正常运行中能够引用它们(实现诸如间接跳转等行为)。
我们使用edb来验证这一过程,监测0x404000地址处PLT的数据变化。这是调用_dl_init之前的:


这是调用_dl_init后的,可以发现PLT表发生了变化:


因此这样我们就通过实践领会了动态链接的机制。

5.8 本章小结

通过以上操作,我们了解了hello.o、静态库、动态链接库这三者是如何通过链接机制组合在一起的,同时也初步探索了一个C语言程序从被加载到程序退出的全过程,我们会发现静态库和动态链接库的部分在我们看不见的地方起到了很大的作用,所以hello这一程序背后确实比表面上要复杂的多。这些都要归功于链接机制,我们才能十分方便地借助这些库来编写我们的程序,使其正常地在操作系统提供的平台上运行。


6. Hello进程管理

6.1 进程的概念与作用

进程是程序在执行中的一个实例,系统的每个程序都运行在某个进程的上下文中。上下文是由程序运行时的一些状态组成的,这些状态包括存放在内存里的程序的代码、数据、栈、寄存器、所占用的资源等。进程这一机制使得每个程序都似乎独占CPU和内存,但实际上同一时刻可能有很多进程在逻辑上并发运行,这是现代多任务操作系统的基础。

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

shell是一种交互型应用级程序,它可以代表用户运行其它程序。Shell是信号处理的代表,负责各进程创建于程序加载运行及前后台控制,作业调用,信号发送与管理等。Shell不断读入用户输入的命令行,然后解析并处理,将这个命令行视作一个新的作业,创建关于这个作业的子进程,将其置于前台或后台运行。

6.3 Hello的fork进程创建过程

在shell接受到./hello这个命令行后,它会对其进行解析,发现是加载并运行一个可执行文件的命令,于是它会先创建一个对应./hello的作业,再用fork()创建一个子进程,这个子进程与父进程几乎与父进程完全相同,它们有着相同的代码段、数据段、堆、共享库以及栈段,但它们的pid与fork的返回值是不同的,因此可以进行区分。然后,父进程(即shell主进程)会将新创建的子进程用setpgid()放在一个新的进程组中,这样这个进程组就对应./hello这个作业,shell可以通过向进程组中的所有进程发信号的方式管理作业。

6.4 Hello的execve过程

在这个子进程创建出来后,子进程会去调用execve()来加载可执行文件到当前进程,这样,子进程原来的用户区域将会被删除,然后通过虚拟内存机制将可执行文件hello中的各个段映射到对应的代码段、数据段等地址空间,这样就加载了hello的新的用户区域。然后,execve会加载hello用到的共享库(比如上面提到过的ld-2.31.so),也是通过虚拟内存映射的方式。最后,子进程的程序将直接跳转到hello的入口点,进行hello的执行。

6.5 Hello的进程执行

进程正常运行是依赖于其上下文的,上下文是由程序正确运行的状态组成的,这些状态包括存放在内存里的程序的代码、数据、栈、寄存器、所占用的资源等,总之,在程序正常运行的时候,这些上下文状态绝不能被异常破坏。
然而,进程是需要不断进行切换的。当前运行在CPU的进程每隔一段时间就需要切换至其它进程。假设hello进程现在正在运行,突然发生了由主板上的时钟芯片引发的时钟中断(属于异步异常),然后处理器会从用户态立刻转入到内核态(拥有最高的管理特权级),控制流转入操作系统内核程序,内核会将hello进程目前的上下文暂时保存起来,然后通过进程调度程序找到要切换的进程B,加载B的被保存的上下文,将控制流交给进程B,处理器重新转入到用户态。
并且,操作系统会给每个进程分配时间片,它决定了当前进程能够执行它的控制流的连续一段时间。
在hello程序被执行的时候,初始时正常运行,然后hello调用sleep函数,这时sleep通过syscall引发异常(陷阱),转入内核态,内核保存hello的上下文,然后将hello进程置于休眠态,切换到其它进程。等到休眠时间到了的时候,此时时钟中断使得控制流从其它进程跳到内核,内核发现hello进程的休眠时间到了,就把hello解除休眠状态。之后在应当进行进程切换的时候,恢复hello的上下文,控制流转入hello进程,处理器切换到用户态。

6.6 hello的异常与信号处理

6.6.1 乱按普通字符或回车

在hello正在运行/休眠的过程中,我在中间不断乱按,发现字符可以正常地被打印在屏幕上,但没有任何作用。这里应该是按下普通按键,产生键盘中断(异步异常),然后切换到内核态,这样内核就知道了按下了某个字符。同时,在hello进程(shell的前台作业)sleep时,shell应该有一个通过内核进行屏幕输入的机制,这时内核就可以把对应字符输出到屏幕上了。

6.6.2 Ctrl+C

在hello正在运行/休眠的过程中,我按下了Ctrl+C,然后hello程序直接终止,shell返回到接受命令行的状态。在按下按键时,产生键盘中断(异步异常),转入内核,内核发现是Ctrl+C,它分析Ctrl+C的接收方应当是shell,然后内核对shell发送SIGINT信号,shell在用户态接受到SIGINT信号时执行它的信号处理程序,然后把信号转发到当前运行的前台作业./hello…对应的进程,hello进程收到SIGINT信号后,它没有对应的信号处理程序,直接进行SIGINT的默认操作即结束hello的运行。

6.6.3 Ctrl+Z及后续

首先我在hello运行过程中按下Ctrl+Z,这和刚才的Ctrl+C机制类似,唯一不同的就是它会使得hello进程接收到信号SIGTSTP,然后hello进程暂停,shell发现它的前台作业暂停了,就重新进入接受用户命令行的状态。然后我输入命令jobs,终端打印出当前的所有作业,发现了暂停的作业./hello…。然后我用命令fg使得暂停的作业hello继续作为前台作业运行,此时进程hello收到信号SIGCONT,继续运行。然后我重新用Ctrl+Z暂停它,用命令ps和pstree打印出所有进程的状态,然后用命令kill终止处于暂停中的hello作业,对其发送一个SIGINT信号,用jobs命令显示其确实已终止。

6.7 本章小结

通过上面的分析与实验,我们搞清楚了hello作为一个进程在运行中的各种机制。这仍然是一个表面简单但背后机制复杂的过程。我们需要明白,hello在运行的时候绝对不是表面那样的独占CPU与内存,而是通过操作系统的进程调度机制与其它进程并发地运行。同时,在hello运行地时候会发生一些异常,收到一些信号,它们都或多或少地有异步的行为,这些异常与信号实现了操作系统中的控制流转移与消息传递,是十分重要的机制。


7.hello的存储管理

7.1 hello的存储器地址空间

逻辑地址是程序直接使用的地址,它表示为“段:偏移地址”的形式,由一个段选择子(一般存在段寄存器中)再加上段内的偏移地址构成。
线性地址(或者叫虚拟地址)是虚拟内存空间内的地址,它对应着虚拟内存空间内的代码或数据,表示为一个64位整数。
物理地址是真正的内存地址,CPU可以直接将物理地址传送到与内存相连的地址信号线上,对实际存在内存中的数据进行访问。物理地址决定了数据在内存中真正存储在何处。

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

对于一个以“段:偏移地址”形式给出的逻辑地址,CPU将会通过其中的16位段选择子定位到GDT/LDT中的段描述符,通过这个段描述符得到段的基址,与段内偏移地址相加得到的64位整数就是线性地址。这就是CPU的段式管理机制,其中,段的划分,也就是GDT和LDT都是由操作系统内核控制的。

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

虚拟地址空间会被分为若干页,即分页机制。CPU对于一个线性地址会取它的高若干位,通过它们去存储在内存中的页表里查询对应的页表条目,得到这个线性地址对应的物理页起始地址,然后与线性地址的低位(页中的偏移)相加就是物理地址。

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

(以下借用了课本上关于Core i7地址翻译的例子)

实际上,为了节省页表的空间,CPU一般采用多级页表的机制,即上一级页表中的条目指向下一级页表。在一般的x86-64模式下,CPU采用四级页表,线性地址被按位划分为5部分,前4个部分分别作为该级页表的索引,最低的12位作为页的偏移地址。CPU会逐级地查找到对应物理页的PTE,从而得到物理地址。
并且,为了优化对于页表的查找效率,CPU提供了专门用于页表的缓存TLB,即翻译后备缓存器,这样CPU可以将页表的PTE缓存到TLB中,从而减少对内存的访问。

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

由于CPU对内存的访问较慢,CPU为内存访问提供了更快的三级Cache缓存,之前访问过的内存块将被暂存在缓存中。当CPU对一个物理地址进行访问的时候,首先去看L1 Cache里是否有对应内存块,若有则直接访问L1 Cache,否则去看L2 Cache……若三级Cache里都没有对应内存块,那么CPU将会直接访问物理内存,并将物理内存中的块加载到L3 L2 L1 Cache中,并使用最近最少访问策略替换掉Cache中的某个内存块。

7.6 hello进程fork时的内存映射

当shell使用fork创建子进程时,内核为新的子进程创建各种数据结构,并分配给子进程一个唯一的PID,为了给它创建虚拟内存空间,内核创建了当前进程的mm_struct、区域结构和页表的原样副本,将两个进程的页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。这样,在新进程里,最开始的时候它的虚拟内存和原进程的虚拟内存映射相同,但当这两个进程中的任意一个进行写操作时,写时复制机制就会创建新页面,这样两个进程的地址空间就在逻辑上私有了。

7.7 hello进程execve时的内存映射

上一章已经提到过,新进程执行execve加载可执行文件hello,先将当前进程虚拟内存空间中的用户部分的已存在的区域结构删除,再为新程序hello的代码、数据、bss段和栈段区域创建新的区域结构,这些新的区域都是私有且写时复制的。代码和数据区域被映射为hello可执行文件中的.text和.data节,bss区域请求二进制0故映射到匿名文件,栈和堆被初始化为空。然后execve会将hello链接的动态链接库(共享对象)映射到虚拟地址空间的共享区域内。最终跳转到hello的入口点。下图为课本上的一个典型例子:

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

当CPU执行某条指令的内存访问时,如果页表中的PTE表明这个地址对应的页不在物理内存中,那么就会引发缺页故障,这使得跳转入内核态,执行操作系统提供的缺页中断处理程序,然后缺页中断处理程序能够将存在磁盘上的页使用一定的替换策略加载到物理内存,并更新页表。缺页故障处理完毕后,CPU重新执行该条指令,如下图所示:

7.9动态存储分配管理

在hello程序中使用的printf,而printf会使用由动态内存分配器动态内存分配机制。动态内存分配器维护进程虚拟地址空间中的的堆区域,它将堆视作一组不同大小的块的集合来维护,每个块是一段连续的虚拟内存碎片,要么是已分配的,要么是空闲的。空闲块保持空闲直至被应用程序分配,以已分配块保持已分配状态直至被释放。
分配器需要一些数据结构维护堆块来区分块边界以及区分已分配块和空闲块,这些可以被标识在块的头部,那么分配器可以将堆组织为一个连续的已分配块和未分配块的序列(称为隐式空闲链表),如下图所示:


这样的话,通过隐式空闲链表,分配器可以通过对于链表的操作以完成在堆上放置已分配的块、分割空闲块、获取额外内存、合并空闲块等操作。于是应用程序就可以动态地在堆上分配额外内存空间了。

7.10 本章小结

通过上述分析,我们揭示了hello的看似简单的内存访问背后的复杂机制,尤其是极度重要的基于页式管理的虚拟内存机制。对于某个地址处的数据访问,要涉及到基于段描述符的逻辑地址到线性地址的转换、基于分页机制的线性地址到物理地址的转换、TLB与Cache、缺页故障等机制,而虚拟内存空间能够使得程序在表面上独占整个内存。


8.hello的IO管理

8.1 Linux的IO设备管理方法

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

8.2 简述Unix IO接口及其函数

Unix IO接口可以实现4种基本操作:
①打开文件,应用程序要求内核打开相应的文件,来宣告它想要访问一个IO设备,内核返回这个文件的描述符以标识这个文件。Shell创建的每个进程开始时都有3个打开的文件:标准输入(stdin)、标准输出(stdout)和标准错误(stderr)。
②改变当前的文件位置,应用程序通过执行seek操作,显式地设置文件的当前位置为k。
③读写文件,读操作就是从当前位置k开始,从文件复制n个字节到内存,然后将k增加到k+n,当k超出文件长度时应用程序能够通过EOF检测到。而写操作则是从内存复制n个字节到一个文件,从当前文件位置k开始,然后更新k。
④关闭文件,当应用完成了对文件的访问之后,它就通知内核关闭这个文件,内核释放文件打开时创建的数据结构和内存资源。

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

首先,printf开辟一块输出缓冲区,然后用vsprintf在输出缓冲区中生成要输出的字符串。之后通过write将这个字符串输出到屏幕上。而write会通过syscall陷阱跳到内核,内核的显示驱动程序会通过这些字符串及其字体生成要显示的像素数据,将它们传到屏幕上对应区域的显示vram中。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

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

首先,getchar会开辟一块静态的输入缓冲区,若输入缓冲区为空,则调用read向输入缓冲区中读入一行字符串。而read会通过syscall陷阱跳到内核,内核会使得调用方不断等待。当按下键盘后,键盘中断处理程序执行,向输入缓冲区中放入由键盘端口读入的扫描码转换成的字符,直到按下回车后调用方不再等待。那么getchar所做的事情其实就是不断地从输入缓冲区中取下一个字符,如果没有则等待输入。

8.5本章小结

通过以上分析,我们从底层的输入输出机理揭示了hello是如何在屏幕上打印出信息的,又是如何接受键盘输入的,它们背后的机制是软件通过底层IO端口或中断与外部硬件的交互。


结论

通过以上8章的分析,我们可以总结以下这个“简单”的hello world程序是如何实现p2p的:
①程序员用文本编辑器编写最原始的hello.c。
②预处理器通过#include和#define等对hello.c的代码进行预处理展开。
③编译器将C语言代码转换为汇编语言代码,程序开始从程序员角度进入机器层面。
④汇编器将文本形式的汇编语言代码转换为二进制形式的可重定位目标文件hello.o,程序中使用的绝对地址将暂时保留为重定位条目,程序开始进入二进制形态。
⑤链接器将hello.o与其它必要的库进行链接并进行重定位,得到可执行文件hello。
⑥shell通过fork创建进程,execve加载可执行文件hello及其所需的动态链接库,通过虚拟内存机制将可执行文件中的节映射到内存空间中。
⑦在hello进程运行时,会产生诸多的异常与信号,例如键盘中断、SIGTSTP、SIGINT等。
⑧在程序hello运行时,它将使用一个属于自己的虚拟地址空间,通过分段机制和分页机制进行内存访问。
⑨在程序hello运行时,要通过中断与IO端口等与外部硬件设备交互。
⑩最终,hello正常退出,或者进程收到信号后终止,都会使得操作系统结束并回收hello的进程。

总而言之,我们会发现一个小小的hello world的背后隐藏的是一大堆复杂的机制支撑着它,这便是“计算机系统”的真正含义。感谢若干年来这个系统各部分机制的设计者与构造者们,正是他们的工作,为顶层的应用程序员和底层的机器之间搭建了一个坚不可摧的桥梁,这个桥梁是如此地巧妙与便利,以至于我们一般不会发现其内部的细节。


参考文献

[1] Randal E,Brynant, David R. O’Hallaron. 深入理解计算机系统(原书第三版). 北京:机械工业出版社,2016.
[2] Stallings.计算机组成与体系结构:性能设计(原书第8版). 北京:机械工业出版社,2011.