程序人生-Hello’s P2P

摘  要

本实验从hello.c文件出发,探讨其预处理、编译、汇编、链接生成可执行文件的全过程,分析每次处理前后的变化,详细展示其中间态变化;在此基础上,也讨论了hello程序的进程管理,存储管理,IO管理方式方法,逐个分析其中的关键点,揭示hello从键入shell到打印最终结果这一完整的运行周期中发生的变化。

关键词:计算机系统;Linux;编译;链接;进程;                           

目  录

第1章 概述................................................................................... - 4 -

1.1 Hello简介.................................................................................. - 4 -

1.2 环境与工具.............................................................................. - 4 -

1.3 中间结果.................................................................................. - 4 -

1.4 本章小结.................................................................................. - 4 -

第2章 预处理............................................................................... - 5 -

2.1 预处理的概念与作用.............................................................. - 5 -

2.2在Ubuntu下预处理的命令..................................................... - 5 -

2.3 Hello的预处理结果解析.......................................................... - 5 -

2.4 本章小结.................................................................................. - 5 -

第3章 编译................................................................................... - 6 -

3.1 编译的概念与作用.................................................................. - 6 -

3.2 在Ubuntu下编译的命令........................................................ - 6 -

3.3 Hello的编译结果解析.............................................................. - 6 -

3.4 本章小结.................................................................................. - 6 -

第4章 汇编................................................................................... - 7 -

4.1 汇编的概念与作用.................................................................. - 7 -

4.2 在Ubuntu下汇编的命令........................................................ - 7 -

4.3 可重定位目标elf格式............................................................ - 7 -

4.4 Hello.o的结果解析................................................................... - 7 -

4.5 本章小结.................................................................................. - 7 -

第5章 链接................................................................................... - 8 -

5.1 链接的概念与作用.................................................................. - 8 -

5.2 在Ubuntu下链接的命令........................................................ - 8 -

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

5.4 hello的虚拟地址空间.............................................................. - 8 -

5.5 链接的重定位过程分析.......................................................... - 8 -

5.6 hello的执行流程...................................................................... - 8 -

5.7 Hello的动态链接分析.............................................................. - 8 -

5.8 本章小结.................................................................................. - 9 -

第6章 hello进程管理................................................................ - 10 -

6.1 进程的概念与作用................................................................ - 10 -

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

6.3 Hello的fork进程创建过程................................................... - 10 -

6.4 Hello的execve过程.............................................................. - 10 -

6.5 Hello的进程执行.................................................................... - 10 -

6.6 hello的异常与信号处理........................................................ - 10 -

6.7本章小结................................................................................. - 10 -

第7章 hello的存储管理............................................................. - 11 -

7.1 hello的存储器地址空间......................................................... - 11 -

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

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

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

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

7.6 hello进程fork时的内存映射................................................ - 11 -

7.7 hello进程execve时的内存映射........................................... - 11 -

7.8 缺页故障与缺页中断处理..................................................... - 11 -

7.9动态存储分配管理.................................................................. - 11 -

7.10本章小结............................................................................... - 12 -

第8章 hello的IO管理.............................................................. - 13 -

8.1 Linux的IO设备管理方法..................................................... - 13 -

8.2 简述Unix IO接口及其函数................................................. - 13 -

8.3 printf的实现分析.................................................................... - 13 -

8.4 getchar的实现分析................................................................. - 13 -

8.5本章小结................................................................................. - 13 -

结论............................................................................................... - 14 -

附件............................................................................................... - 15 -

参考文献....................................................................................... - 16 -

第1章 概述

1.1 Hello简介

From Program to Process:将一行行代码敲进文本编辑器Editor,存为hello.c文件,使用cpp预处理器处理hello.c生成hello.i文件,使用ccl编译器编译hello.i生成hello.s文件,使用as汇编器汇编hello.s生成hello.o文件,使用ld链接器将hello.o与printf.o文件链接生成hello可执行文件,如图1-1;

图1-1 From Program to Process[1]

From Zero-0 to Zero-0:输入./hello至shell,shell调用fork()生成子进程,调用execve()加载hello程序代码,os内核为其分配虚拟地址空间,MMU根据CPU传送的VA虚拟地址查询TLB与内存获得PA物理地址,根据PA访问内存获得指令。运行完毕后,shell回收子程序,释放内存空间。

1.2 环境与工具

硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上

软件环境:Windows7/10 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位 以上;

开发工具:Visual Studio Code 64位;edb/vi/vim/gedit+gcc

1.3 中间结果

文件名

作用

hello.c

初始C代码文件

hello.i

预处理后文件

hello.s

编译后汇编语言文件

hello.o

汇编后二进制文件

hello_obj.s

hello.o反汇编文件

hello.out

链接后可执行程序

hello_obj2.s

hello.out反汇编文件

1.4 本章小结

本章介绍了hello程序P2P与020的大致过程,实验中的软硬件环境与开发工具,以及实验中生成的中间文件,为之后的详细讨论提供了良好的开端。

第2章 预处理

2.1 预处理的概念与作用

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

作用[3]:

1:将头文件中的内容(源文件之外的文件)插入到源文件中

2:进行了宏替换的过程,定义和替换了由#define指令定义的符号

3:删除掉注释的过程,注释是不会带入到编译阶段

4:条件编译

2.2在Ubuntu下预处理的命令

预处理命令:gcc -E hello.c -o hello.i #预处理

图2-1 预处理

2.3 Hello的预处理结果解析

hello.c共24行代码,hello.i共3000余行代码,观察到预处理程序将hello.c中#include的文件代码添加至hello.i开头,并且删除了hello.c中原有的注释,如图2-2.

图2-2-a  hello.c文件

图2-2-b  hello.i文件

2.4 本章小结

本章介绍了hello.c至hello.i的预处理过程,该过程简化了程序员编写代码的过程,使得初始代码更加简洁、易读,也为接下来的编译奠定了基础。

第3章 编译

3.1 编译的概念与作用

将C语言代码转换成CPU能够识别的二进制指令的工具叫做编译器(Compiler),编译器能够识别代码中的词汇、句子以及各种特定的格式,并将他们转换成计算机能够识别的二进制形式,这个过程称为编译(Compile)[4]。

3.2 在Ubuntu下编译的命令

编译命令:gcc -S hello.i -o hello.s

图3-1 编译

3.3 Hello的编译结果解析

3.3.1数据

hello.s中包含常量、局部变量、全局变量。

字符串常量:

"用法: Hello 学号 姓名 手机号 秒数!\n",观察到文字被编码为3个\***;

"Hello %s %s %s\n",不变。

图3-2 字符串常量

局部变量以立即数的形式表示,例$5,$1;

图3-3 局部变量argc存于%edi(判断argc!=5)

全局变量只有一个,即main;

图3-4 全局变量main

3.3.2赋值

图3-5 为argc赋初值并比较

图3-6 为i赋初值

3.3.3算术操作

图3-7 每次循环i+1并判断条件

3.3.4关系操作

argc与5比较如图3-5;

循环中i与9比较(源代码为i<10,此处等价转化为i<=9)如图3-7。

3.3.5数组/指针/结构操作

字符串作为变量由%rsi传递至-32(%rbp),如图3-3;之后依次将指针指向的值存至%rdx,%rsi,%rdi,它们为printf输入的三个参数。

图3-8 指针传递字符串

3.3.6控制转移

图3-9 判断if(argc!=5)

图3-10 for循环条件判断

3.3.7函数操作

初始输入值argc与argv传入main如图3-3;

printf参数传递与调用如图3-8;

此外,还调用了atoi、sleep、getchar函数,如图3-11。

图3-11 call调用函数

3.4 本章小结

本章分析了hello.i文件至汇编指令文件hello.s的编译过程,该过程实现了C语言代码向低级机器语言指令的转化,实现了数据、赋值、各种运算、条件判断与函数操作的代码转化,为不同高级语言的不同编译器提供了通用的输出语言,也为下一步计算机更好的处理该程序打下基础。

第4章 汇编

4.1 汇编的概念与作用

汇编是指把汇编语言翻译成二进制机器语言的过程。

诞生过程:对于人类来说,二进制程序是不可读的,无法理解机器干了什么。为了解决可读性的问题,以及偶尔的编辑需求,就诞生了汇编语言,为了将汇编语言转化为二进制机器指令,就需要增加汇编这一步骤。[5]

4.2 在Ubuntu下汇编的命令

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

图4-1 汇编

4.3 可重定位目标elf格式

ELF 头(ELF header)以一个 16 字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括 ELF 头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如 X86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量,如图4-2;

ELF头部数据结构如下:

ELF头部数据结构

typedef struct

{

  unsigned char  e_ident[EI_NIDENT];      /* Magic number and other info */

  Elf64_Half      e_type;                /* Object file type */

  Elf64_Half      e_machine;         /* Architecture */

  Elf64_Word    e_version;           /* Object file version */

  Elf64_Addr     e_entry;               /* Entry point virtual address */

  Elf64_Off e_phoff;                     /* Program header table file offset */

  Elf64_Off e_shoff;                      /* Section header table file offset */

  Elf64_Word    e_flags;               /* Processor-specific flags */

  Elf64_Half      e_ehsize;             /* ELF header size in bytes */

  Elf64_Half      e_phentsize;        /* Program header table entry size */

  Elf64_Half      e_phnum;            /* Program header table entry count */

  Elf64_Half      e_shentsize;        /* Section header table entry size */

  Elf64_Half      e_shnum;            /* Section header table entry count */

  Elf64_Half      e_shstrndx;          /* Section header string table index */

} Elf64_Ehdr;

图4-2 ELF头

不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry),hello程序共13个节,如图4-3。

图4-3 节头部表

.symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息,和编译器中的符号表不同,.symtab 符号表不包含局部变量的条目,观察到其中的函数有main、puts、exit、printf等,如图4-4。

图4-4 .symtab符号表

.rel.text:一个 .text 节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。[1]

当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在 .rel.text 中,如图4-5。

图4-5 .rel.text重定位节

4.4 Hello.o的结果解析

objdump -d -r hello.o  分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。

说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。

运行命令:objdump -d -r hello.o > hello_obj.s生成hello_obj.s反汇编文件。

与第3章的 hello.s进行对照分析:

  1. 在hello.s文件中,跳转指令目的地址使用.L2或.L3形式标示,如图4-6a,而在hello_obj.s中,跳转指令使用相对地址寻址方式标示,如图4-6b;

 

图4-6a hello.s跳转指令

图4-6b hello_obj.s跳转指令

  1. 关于调用函数:hello.s中call紧跟被调函数的函数名,而在hello_obj.s中call调用的是当前指令的下一条指令,如图4-7。这是由于被调用的函数为共享库中的函数,需要通过动态链接器才能确定正确的函数地址,在汇编成为机器语言时,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址为下一条指令),并在.rela.text 节中为其添加重定位条目,等待静态链接的进一步确定。

图4-7 hello_obj.s调用函数

  1. 常量表示的形式不同:hello.s中,常量以十进制形式表示,而在hello_obj.s中,常量均以十六进制形式表示。

4.5 本章小结

本章讨论了hello程序在汇编过程中发生的变化,分析了其ELF表信息,比较了在编译后的代码与汇编后再反汇编的代码之间的区别。此时,计算机已经完成了程序运行过程中的绝大部分任务,但此时该程序还处于孤立无援的状态,需要链接器来辅助实现真正完整的程序。

第5章 链接

5.1 链接的概念与作用

链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。[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.out

图5-1 链接

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

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

图5-2 ELF头

节头部表包含各段的基本信息,包括各段的起始地址,大小等信息,如图5-3。

图5-3 节头部表

5.4 hello的虚拟地址空间

使用命令:edb –run hello.out

观察到程序从0x401000开始,如图5-4,与图5.3中.init偏移量相同;

图5-4 edb加载hello.out

根据图5.3,可以找到各段信息。例如:.init_array位于0x403e00,.fini_array位于0x403e08,./dynamic位于0x403e10,如图5-5;

图5-5 edb查看段信息

5.5 链接的重定位过程分析

objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。

结合hello.o的重定位项目,分析hello中对其怎么重定位的。

运行命令:objdump -d -r hello.out > hello_obj2.s生成hello_obj2.s文件

分析hello_obj.s与hello_obj2.s的不同:

  1. 新增加节:再hello_obj.s中只有.test节,而在hello_obj2.s中新增加了.init、.plt、.plt.sec、.fini四个节;
  2. 函数有了具体地址:汇编指令不再从0开始,而是从0x401000开始。在hello_obj.s中调用的函数只有函数名,没有地址,call只能够指向下一条代码的地址;而在hello_objs中,每一个调用的函数都有在.plt.sec节中具体的地址,如图5-6;

图5-6 函数具体地址

链接:链接器根据汇编器在重定位文件中提供的重定位条目就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。

重定位分为两步:重定位节和符号定义、重定位节中的符号引用。

第一步将来自所有重定位文件的相同类型节合并,使程序中的每条指令和全局变量拥有唯一的运行时内存地址;第二步,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。

重定位符号引用举例:在hello_obj2.s中,exit函数的地址为0x4010d0,如图5-7,对exit函数的调用如图5-8,callq的二进制命令为e8,此处相对地址偏移量为ff ff fe cb,即-0x00 01 35,相对地址为0x401205,则exit的地址为0x401205 – 0x0135得0x4010d0。

图5-7 exit函数地址

图5-8 调用exit函数

5.6 hello的执行流程

调用的函数及其地址如下:

0x0401000 <_init>

0x0401020 <.plt>

0x0401090 <puts@plt>

0x04010a0 <printf@plt>

0x04010b0 <getchar@plt>

0x04010c0 <atoi@plt>

0x04010d0 <exit@plt>

0x04010e0 <sleep@plt>

0x04010f0 <_start>

0x0401120 <_dl_relocate_static_pie>

0x0401130 <deregister_tm_clones>

0x0401160 <register_tm_clones>

0x04011a0 <__do_global_dtors_aux>

0x04011d0 <frame_dummy>

0x04011d6 <main>

0x0401280 <__libc_csu_init>

0x04012f0 <__libc_csu_fini>

0x04012f8 <_fini>

5.7 Hello的动态链接分析

分析hello程序的动态链接项目,通过edb/gdb调试,分析在动态链接前后,这些项目的内容变化。要截图标识说明。

动态链接是将共享库加载到任意的内存地址,并和一个在内存中的程序链接起来的过程,它是由一个叫做动态链接器(dynamic linker)的程序来执行的。链接过程中需要用到全局偏移表GOT与过程链接表PLT。在加载时,动态链接器会调用PLT中的函数,重定位GOT中的每个条目,使它包含正确的绝对地址。使用edb分析动态链接过程:

首先,在ELF文件中寻找.got与.got.plt的起始位置,分别为0x403ff0与0x404000,在运行init前,如图5-9,运行之后如图5-10,可以观察到.got变化。

图5-9 运行init前.got条目

图5-10 运行init后.got条目

5.8 本章小结

本章主要讨论了hello.o由重定位目标文件生成可执行文件的过程,分析了在此链接过程中hello.out的ELF表信息、虚拟地址空间、重定位与动态链接的过程,较完整的展示了其在链接中发生的变化。此时,我们已经见证了一个程序完整运行的过程,然而,对于程序之外的事情我们还没有讨论,程序与进程的关系,存储空间的分配,以及IO的管理。hello走完了自己的路,开始回忆沿途的风景。

第6章 hello进程管理

6.1 进程的概念与作用

概念:进程就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。

作用:进程提供一种假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。

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

shell是一个交互型应用级程序,代表用户运行其他程序。如Windows下的命令行解释器,cmd、powershell,图形界面的资源管理器。Linux下的Terminal/tcsh、bash等等,当然也包括图形化的GNOME桌面环境。

处理流程:

  1. shell等待用户输入并读取用户输入的命令;
  2. 判断是否为shell内置命令,是则运行该命令,否则认为是一个可执行文件;
  3. shell构建参数和环境变量;
  4. shell通过fork创建子进程,再通过execve函数加载可执行文件。
  5. 回收创建的子进程,回到第一步;

6.3 Hello的fork进程创建过程

首先通过fork()函数创建一个shell程序的子进程,该子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。父进程和新创建的子进程之间最大的区别在于它们有不同的 PID。子进程运行时父进程可以等待其运行,也可以将其转到后台,并等待用户下一条指令。子进程运行完毕后会向父进程发送SIGCHLD信号,父进程接收信号后回收子进程。

6.4 Hello的execve过程

execve 函数加载并运行可执行目标文件hello.out,且带参数列表 argv 和环境变量列表 envp。在加载hello.out之后,它调用启动代码设置栈,在当前进程的上下文中加载并运行一个新的程序,它会覆盖当前进程的地址空间。之后将控制传递给新程序的主函数main,该主函数有如下形式的原型:

int main(int argc, char **argv, char **envp);

主函数按照代码顺序依次执行,最后结束。

6.5 Hello的进程执行

结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。

如图6-1,假设有2进程A和B,A初始运行在用户模式下,直到通过执行系统调用read陷入到内核,内核调用磁盘读取程序,此过程需要很长一段时间。为了提高运行效率,内核开始执行进程B。则进程A运行的那段时间为进程A的时间片。内核切换进程AB时,需要保存进程A的上下文信息,并开始运行进程B的上下文信息,此过程在内核模式中运行,切换至进程B后,重新在用户模式运行。

图6-1 进程上下文切换的剖析

进程B运行过程中,当磁盘读取至内存后,磁盘发送一个中断信号,内核接收信号,并打算重新运行进程A。此过程与之前切换过程基本相同,由用户模式转换至内核模式,并完成进程B的上下文保存与进程A的上下文切换,再换回用户模式,将控制交回进程A中紧随read之后的指令,继续运行进程A。

6.6 hello的异常与信号处理

hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。

程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps  jobs  pstree  fg  kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。

会出现的异常有:陷阱、终止;

会出现的信号:ctrl+z发送SIGSTP使进程暂停,如图6-3、ctrl+c发送SIGINT使进程终止,如图6-4,SIGCONT由fg发送,SIGKILL由kill发送;

不停乱按会改变输出的光标位置,回车同理,并且乱按的字母会再进程运行完后被shell当作命令或可执行程序;

输入ps,会显示当前全部进程,包括hello.out、shell与ps;

图6-5 输入ps

输入jobs也会显示进程,但只显示hello.out;

输入pstree,可以显示进程树;

图6-6a 输入pstree

图6-6b 输入pstree

图6-6c 输入pstree

输入ctrl+z再输入fg + 进程号,可以发送信号SIGCONT使停止的进程回到前台继续运行;输入ctrl+z再输入kill,可以向指定进程发送某信号,如此处发送9号SIGKILL信号,杀死该程序;

6.7本章小结

本章讨论了hello程序运行时的进程管理,分析说明了shell创建子进程的过程、执行程序调用execve()函数的过程,还有程序运行过程中内核的动作与触发的异常和信号,展示了进程从开始运行程序被父进程创建到程序结束被回收的过程。

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:包含在机器语言指令中用来指定一个操作数或一条指令的地址。由一个段标识符加上一个指定段内相对地址的偏移量组成,表示为 [段标识符:段内偏移量]。

线性地址(虚拟地址):n位地址空间对应2n个地址,可以远大于物理地址。

物理地址:用于内存芯片级内存单元寻址。

转化关系为:

逻辑地址 -> [分段单元] ->线性(虚拟)地址 -> [分页单元] -> 物理地址

由于Linux没有段式管理,所以逻辑=线性=虚拟地址。所以hello程序中初始运行地址0x401000为虚拟地址,需要经过计算与变换才能得到实际物理地址。

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

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

图7-1 段标识符

通过段标识符中的索引号从GDT或者LDT找到该段的段描述符,段描述符中的base字段是段的起始位置的线性地址。一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中[7]。最终计算:

段起始地址+ 段内偏移量 = 线性地址

首先,给定一个完整的逻辑地址[段选择符:段内偏移地址]:

1、看段选择符的T1=0还是1,知道当前要转换的是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就拥有了一个数组。

2、由段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它的Base,即基地址就知道了。

3、Base + offset = 线性地址。

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

形式上来说,由虚拟地址到物理地址的翻译是一个 N 元素的虚拟地址空间(VAS)中的元素和一个 M 元素的物理地址空间(PAS)中元素之间的映射。

图 7-2 展示了 MMU 如何利用页表来实现这种映射。CPU 中的一个控制寄存器,页表基址寄存器(Page Table Base Register,PTBR)指向当前页表。n 位的虚拟地址包含两个部分:一个 p 位的虚拟页面偏移(Virtual Page Offset,VPO)和一个(n−p)位的虚拟页号(Virtual Page Number,VPN)。MMU 利用 VPN 来选择适当的 PTE。例如,VPN 0 选择 PTE 0,VPN 1 选择 PTE 1,以此类推。将页表条目中物理页号(Physical Page Number,PPN)和虚拟地址中的 VPO 串联起来,就得到相应的物理地址。注意,因为物理和虚拟页面都是 P 字节的,所以物理页面偏移(Physical Page Offset,PPO)和 VPO 是相同的。[1]

图 9-12 使用页表的地址翻译

如图7-3,此处只假设页面命中情况,CPU 硬件执行以下步骤:

第 1 步:处理器生成一个虚拟地址,并把它传送给 MMU。

第 2 步:MMU 生成 PTE 地址,并从高速缓存/主存请求得到它。

第 3 步:高速缓存/主存向 MMU 返回 PTE。

第 4 步:MMU 构造物理地址,并把它传送给高速缓存/主存。

第 5 步:高速缓存/主存返回所请求的数据字给处理器。

图7-3 页面命中

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

图 7-4 给出了 Core i7 MMU如何使用四级的页表来将虚拟地址翻译成物理地址。36 位 VPN 被划分成四个 9 位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含 L1 页表的物理地址。VPN1提供到一个 L1 PTE 的偏移量,这个 PTE 包含 L2 页表的基地址。VPN2提供到一个 L2 PTE 的偏移量,以此类推。

图7-4 Corei7地址翻译[1]

(PT:页表,PTE:页表条目,VPN:虚拟页号,VPO:虚拟页偏移,PPN:物理页号,PPO:物理页偏移量。图中还给出了这四级页表的 Linux 名字)

TLB 是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个 PTE 组成的块。TLB 通常有高度的相联度。如图 9-15 所示,用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果 TLB 有T=2t个组,那么 TLB 索引(TLBI)是由 VPN 的 t 个最低位组成的,而 TLB 标记(TLBT)是由 VPN 中剩余的位组成的,如图7-5。

图 7-5 虚拟地址中用以访问 TLB 的组成部分

图 7-6a 展示了当 TLB 命中时(通常情况)所包括的步骤。这里的关键点是,所有的地址翻译步骤都是在芯片上的 MMU 中执行的,因此非常快。

第 1 步: CPU 产生一个虚拟地址。

第 2 步和第 3 步: MMU 从 TLB 中取出相应的 PTE。

第 4 步: MMU 将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。

第 5 步:高速缓存/主存将所请求的数据字返回给 CPU。

图 7-6 TLB 命中和不命中的操作图

当 TLB 不命中时,MMU 必须从 L1 缓存中取出相应的 PTE,如图 9-16b 所示。新取出的 PTE 存放在 TLB 中,可能会覆盖一个已经存在的条目。[1]

图 7-7 总结了完整的 Core i7 地址翻译过程,从 CPU 产生虚拟地址的时刻一直到来自内存的数据字到达 CPU。Core i7 采用四级页表层次结构。每个进程有它自己私有的页表层次结构。当一个 Linux 进程在运行时,虽然 Core i7 体系结构允许页表换进换出,但是与已分配了的页相关联的页表都是驻留在内存中的。CR3 控制寄存器指向第一级页表(L1)的起始位置。CR3 的值是每个进程上下文的一部分,每次上下文切换时,CR3 的值都会被恢复。

图 7-7 Core i7 地址翻译的概况。为了简化,没有显示 i-cache、i-TLB 和 L2 统一 TLB

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

如图7-7,根据7.4节的讨论,当查找到52位物理地址PA后,可以计算出L1每个块64B=26,所以块偏移CO为6位;由于64组,所以CI为6位,剩下为标记位。

根据CI查找组,CT查找缓存标记,CO查看有效位,有效位为1代表命中;否则不命中,需要查找下一级缓存L2,以此类推。

7.6 hello进程fork时的内存映射

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

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

7.7 hello进程execve时的内存映射

假设在当前进程中的程序执行了如下的 execve 调用:

execve("hello.out", NULL, NULL);

execve 函数在当前进程中加载并运行包含在可执行目标文件 hello.out 中的程序,用 hello.out 程序有效地替代了当前程序。加载并运行 hello.out 需要以下几个步骤:

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

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

图 7-8 加载器是如何映射用户地址空间的区域的

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

DRAM 缓存不命中称为缺页(page fault),其会触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,如果该牺牲页已经被修改了,那么内核就会将它复制回磁盘。接下来,内核从磁盘复制将调用的页到内存中牺牲页的位置,更新页表条目,随后返回。

当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。此时,将调用的页已经在主存中了,那么页命中也能由地址翻译硬件正常处理了。

7.9动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量 brk(读做 “break”),它指向堆的顶部。

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

分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。

  1. 显式分配器(explicit allocator),要求应用显式地释放任何已分配的块。例如:C标准库提供的malloc程序包中的分配器;
  2. 隐式分配器(implicit allocator),另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集(garbage collection)。例如:Java中的垃圾收集。

显式分配器的两种重要数据结构:

隐式空闲链表:空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。其优点是简单。显著的缺点是任何操作的开销,例如放置分配的块,要求对空闲链表进行搜索,该搜索所需时间与堆中已分配块和空闲块的总数呈线性关系。

显式空闲链表:将堆组织成一个双向空闲链表,在每个空闲块中,都包含一个 pred(前驱)和 succ(后继)指针。可以使用后进先出或按照地址顺序来维护链表。其优点是使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间;缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。

7.10本章小结

本章讨论了计算机对hello程序的存储管理与分配机制,分析了其不同地址之间的变换过程,TLB、多级页表与Cache的寻址与访问,fork与execve函数的内存映射过程,缺页处理,动态地址的分配管理。见证了hello从诞生在内存中,又最终离去的全部过程。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件。一个 Linux 文件就是一个 m 个字节的序列:B0,B1,B2…,Bm,所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。

设备管理:unix io接口。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。

8.2 简述Unix IO接口及其函数

8.2.1打开与关闭文件

打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。

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

#include <sys/types.h>

#include <sys/stat.h>

#include <fcntl.h>

int open(char *filename, int flags, mode_t mode);

// 返回:若成功则为新文件描述符,若出错为 -1。

关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

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

#include <unistd.h>

int close(int fd);

// 返回:若成功则为 0,若出错则为 -1。

8.2.2读写文件

一个读操作就是从文件复制 n > 0 个字节到内存,从当前文件位置 k 开始,然后将 k 增加到 k+n。给定一个大小为 m 字节的文件,当k ⩾ m时执行读操作会触发一个称为 end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的 “EOF 符号”。

应用程序是通过分别调用 read 和 write 函数来执行输入和输出的。

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t n);

// 返回:若成功则为读的字节数,若 EOF 则为0,若出错为 -1。

ssize_t write(int fd, const void *buf, size_t n);

// 返回:若成功则为写的字节数,若出错则为 -1。

read 函数从描述符为 fd 的当前文件位置复制最多 n 个字节到内存位置 buf。返回值 -1 表示一个错误,而返回值 0 表示 EOF。否则,返回值表示的是实际传送的字节数量。write 函数从内存位置 buf 复制至多 n 个字节到描述符 fd 的当前文件位置。

8.2.3改变当前的文件位置。

对于每个打开的文件,内核保持着一个文件位置 k,初始为 0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行 seek 操作,显式地设置文件的当前位置为 k。

8.3 printf的实现分析

从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.

首先观察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;

 }

其中,“...” 是可变形参的一种写法;va_list的定义:typedef char *va_list。

(char*)(&fmt) + 4) 表示的是...中的第一个参数。

接下来可以再看vsprintf(buf, fmt, arg),如图8-1:

图8-1 vsprintf函数

该函数的功能是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。

再来追踪write函数:

    write:

     mov eax, _NR_write

     mov ebx, [esp + 4]

     mov ecx, [esp + 8]

     int INT_VECTOR_SYS_CALL

此处为几个寄存器传递了几个参数,然后以int结束。

INT_VECTOR_SYS_CALL的实现:

init_idt_desc(INT_VECTOR_SYS_CALL, DA_386IGate, sys_call, PRIVILEGE_USER);即通过系统来调用sys_call这个函数。

观察sys_call的实现:

图8-2 sys_call函数

其中call save,是为了保存中断前进程的状态。此处实现很复杂,若只是理解printf的实现的话,可以这样实现sys_call:

图8-3 sys_call简洁实现

字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

getchar可用宏实现:#define getchar() getc(stdin);它从标准输入里读取下一个字符,返回类型为int型,为用户输入的ASCII码或EOF。

当getchar被调用时,程序等待用户输入。输入字符被存放于键盘缓冲区中。直到输入回车为止(回车字符也放在缓冲区中)。键入回车之后,getchar开始从stdin流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(End-Of-File)则返回-1(EOF),并将用户输入的字符回显到屏幕。

异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

本章分析了hello程序的IO管理过程,介绍了Linux的IO设备管理方法,基本的Unix IO函数,分析了printf()与getchar()函数的系统级实现过程。从本章的分析可以还观察到程序输入输出与内核、硬件的巧妙配合。

结论

从被输入编辑器中存为hello.c,经过预处理生成hello.i,被编译生成汇编语言文件hello.s,再通过汇编生成二进制可重定位目标文件hello.o,最后链接器将多个可重定位文件(.o)组合生成最终的可执行文件hello.out,hello经历了自己的一生。

它这一生并不孤独,虽然一开始被菜鸟无情抛弃,但却遇到好心的IO与shell,从键盘输入命令,调用fork()与execve()函数为hello生成进程、加载执行代码文件;还有热心的os(内核),从虚拟地址转换到系统异常与中断处理,一路为其保驾护航;TLB、多级页表与Cache三兄弟联合为程序内存分配推波助澜,使其顺利抵达程序圣地——CPU;当它拿着运行结果再次找到IO管理时,已经历无数坎坷,此时再次遇到当年那个菜鸟,他也屡屡遭遇风雨,看着一行行输出,终于领悟到hello的真谛。

从菜鸟到现在略有所得,再次回顾hello程序,发现其中的种种细节均不是只用代码就可以解决的,内存分配,异常处理,以及从初始代码到最终可执行程序的过程,都蕴藏着庞大的细节,需要各个环节的配合与协调;此外,还有进程、虚拟地址、动态链接等概念的提出,它们为程序打开了新的大门,使得程序间彼此独立又相互关联,实现了更高级的抽象,这也是计算机真正的迷人之处。

写在公共平台:由于平台限制未能在格式上做到赏心悦目,且本文仅为提交课程大作业需要,水平仍有不足,望指正!

附件

文件名

作用

hello.c

初始C代码文件

hello.i

预处理后文件

hello.s

编译后汇编语言文件

hello.o

汇编后二进制文件

hello_obj.s

hello.o反汇编文件

hello.out

链接后可执行程序

hello_obj2.s

hello.out反汇编文件

参考文献

  1. Randal E.Bryant, David O'Hallaron. 深入理解计算机系统[M]. 机械工业出版社.2016.11
  2. Pianistx.printf 函数实现的深入剖析[EB/OL]. 2013[2021-6-9]. https://www.cnblogs.com/pianist/p/3315801.html.
  3. ^_^ 小小码nong.C语言中如何去理解预处理阶段[EB/OL]. 2017. https://blog.csdn.net/qq_29924041/article/details/54917521
  4. C语言编译和链接详解 [EB/OL]. https://c.biancheng.net/view/1736.html
  5. 阮一峰. 汇编语言入门教程[EB/OL]. 2018. https://www.ruanyifeng.com/blog/2018/01/assembly-language-primer.html
  6. 不会写代码的丝丽. ELF格式解读-(1) elf头部与节头[EB/OL]. 2022. https://blog.csdn.net/qfanmingyiq/article/details/124295287#t13
  7. 闫晟. 逻辑地址、物理地址、虚拟地址[EB/OL]. 2020. https://blog.csdn.net/TYUTyansheng/article/details/108148566
  8. hello程序运行过程综述(IA-32版本)[EB/OL]. https://ysyx.oscc.cc/slides/hello-x86.html
  • 14
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值