摘 要

本文分析了一个hello程序从生成到回收的全过程,包括源文件的创建、预处理、编译、汇编、链接为可执行程序,以及进程的创建、在操作系统上的运行和回收等过程。同时研究了hello程序在计算机系统中的进程管理、存储管理、I/O管理等内容。

关键词:hello;P2P;Linux;编译;汇编;链接;进程管理;I/O管理;

目 录

第1章 概述… – 5 –

1.1 Hello简介… – 5 –

1.1.1 P2P(From Program to Process)… – 5 –

1.1.2 020(From Zero-0 to Zero-0)… – 5 –

1.2 环境与工具… – 5 –

1.2.1 硬件环境:… – 5 –

1.2.2 软件环境:… – 5 –

1.2.3 开发工具:… – 5 –

1.3 中间结果… – 6 –

1.4 本章小结… – 6 –

第2章 预处理… – 7 –

2.1 预处理的概念与作用… – 7 –

2.1.1概念:… – 7 –

2.1.2 作用:… – 7 –

2.2在Ubuntu下预处理的命令… – 7 –

2.3 Hello的预处理结果解析… – 7 –

2.4 本章小结… – 8 –

第3章 编译… – 9 –

3.1 编译的概念与作用… – 9 –

3.1.1概念:… – 9 –

3.1.2 作用:… – 9 –

3.2 在Ubuntu下编译的命令… – 9 –

3.3 Hello的编译结果解析… – 9 –

3.3.1 文件信息:… – 9 –

3.3.2 局部变量处理与赋值操作:… – 10 –

3.3.3 字符串常量的处理:… – 10 –

3.3.4 算术操作:… – 11 –

3.3.5 关系操作:… – 11 –

3.3.6 数组操作:… – 12 –

3.3.7 控制转移:… – 13 –

3.3.8 参数传递:… – 14 –

3.3.9 函数调用与返回:… – 14 –

3.4 本章小结… – 15 –

第4章 汇编… – 16 –

4.1 汇编的概念与作用… – 16 –

4.1.1概念:… – 16 –

4.1.2 作用:… – 16 –

4.2 在Ubuntu下汇编的命令… – 16 –

4.3 可重定位目标elf格式… – 16 –

4.3.1典型的ELF可重定位目标文件格式:… – 16 –

4.3.2 ELF头:… – 17 –

4.3.3 节头部表:… – 18 –

4.3.4 可重定位节和符号表:… – 18 –

4.4 Hello.o的结果解析… – 19 –

4.4.1 机器语言的构成:… – 19 –

4.4.2 分支转移:… – 20 –

4.4.3 函数调用:… – 21 –

4.5 本章小结… – 22 –

第5章 链接… – 23 –

5.1 链接的概念与作用… – 23 –

5.1.1 概念:… – 23 –

5.1.2 作用:… – 23 –

5.2 在Ubuntu下链接的命令… – 23 –

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

5.3.1 经典的可执行目标文件的ELF格式:… – 23 –

5.3.2 ELF头:… – 24 –

5.3.3 段头部表:… – 24 –

5.3.4 节头部表:… – 25 –

5.4 hello的虚拟地址空间… – 26 –

5.5 链接的重定位过程分析… – 27 –

5.1.1 hello与hello.o的差异:… – 27 –

5.5.2重定位过程:… – 29 –

5.6 hello的执行流程… – 29 –

5.7 Hello的动态链接分析… – 30 –

5.8 本章小结… – 30 –

第6章 hello进程管理… – 31 –

6.1 进程的概念与作用… – 31 –

6.1.1 概念:… – 31 –

6.1.2 作用:… – 31 –

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

6.2.1 作用:… – 31 –

6.2.2 处理流程:… – 31 –

6.3 Hello的fork进程创建过程… – 31 –

6.4 Hello的execve过程… – 32 –

6.5 Hello的进程执行… – 32 –

6.6 hello的异常与信号处理… – 32 –

6.6.1可能的异常与信号:… – 32 –

6.6.2 hello执行中可能出现的异常:… – 33 –

6.7本章小结… – 35 –

第7章 hello的存储管理… – 36 –

7.1 hello的存储器地址空间… – 36 –

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

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

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

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

7.6 hello进程fork时的内存映射… – 39 –

7.7 hello进程execve时的内存映射… – 40 –

7.8 缺页故障与缺页中断处理… – 40 –

7.9动态存储分配管理… – 41 –

7.10本章小结… – 41 –

第8章 hello的IO管理… – 42 –

8.1 Linux的IO设备管理方法… – 42 –

8.2 简述Unix IO接口及其函数… – 42 –

8.2.1 Unix I/O接口:… – 42 –

8.2.2 函数:… – 42 –

8.3 printf的实现分析… – 43 –

8.4 getchar的实现分析… – 44 –

8.5本章小结… – 44 –

结论… – 45 –

附件… – 46 –

参考文献… – 47 –

第1章 概述

1.1 Hello简介

1.1.1 P2P(From Program to Process)

P2P指的是hello.c从源程序到进程的过程。首先编写代码得到源文件hello.c,之后通过一系列过程转化为可执行文件。首先hello.c经过C预处理器(cpp)的编译预处理,翻译成一个ASCII码的中间文件hello.i;接下来,驱动程序运行C编译器(ccl)将hello.i翻译成一个ASCII汇编语言文件hello.s;然后驱动程序运行汇编器(as),将hello.s翻译成一个可重定位目标文件hello.o;最后,运行连接器程序(ld),将hello.o与其他一些必要的系统目标文件组合起来,创建一个可执行目标文件,即hello。

执行hello程序时,系统会创建一个进程再将hello程序加载进入,最终实现了从程序到进程的整个过程。

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

程序运行时,shell创建一个新进程,在进程中调用execve函数将hello程序加载到相应的上下文中,并将程序内容载入物理内存。然后调用main函数。程序运行结束后,子进程成为僵死进程,父进程调用waitpid回收进程,释放虚拟内存空间,删除相关内容。这就是hello.c的O2O过程。

1.2 环境与工具

1.2.1 硬件环境:

x64 CPU;3.20GHz;16GRAM;

1.2.2 软件环境:

windows10 64位;VMware Workstation Pro16.2.2;Ubuntu 23.04

1.2.3 开发工具:

gcc;vim;edb;objdump;CodeBlocks

1.3 中间结果

文件名

作用

hello.c

hello的C源文件

hello.i

hello.c预处理后得到的预编译文本文件

hello.s

hello.i经过编译后得到的汇编语言文本文件

hello.o

hello.s经过汇编得到的可重定位目标文件

hello

hello.o经过链接得到的可执行目标文件

hello_o.elf

hello.o的ELF格式文件

hello_o_asm.txt

hello.o反汇编后的文件

hello.elf

hello的ELF格式文件

hello_asm.txt

hello反汇编后的文件

1.4 本章小结

本章概括介绍了hello的P2P和O2O的过程。列举了过程中的中间文件与操作。同时介绍了本实验用到的硬软件环境和开发调试工具。

第2章 预处理

2.1 预处理的概念与作用

2.1.1概念:

预处理是编译过程的一部分,它发生在代码实际编译之前。预处理器是编译器的一个组成部分,它会对代码进行一些预处理操作,以生成可以交给编译器的新代码。预处理主要包括宏替换、文件包含、条件编译、符号定义和编译器指令几个方面。

2.1.2 作用:

  1. 通过宏替换可以避免代码中的重复,提高代码的可读性。
  2. 通过文件包含可以将代码模块化,降低代码的复杂度,提高代码的可维护性。
  3. 通过条件编译可以根据不同的编译条件生成不同的代码,实现跨平台适配或调试控制。
  4. 通过符号定义可以提高代码的灵活性和可维护性。
  5. 通过编译器指令可以向编译器传递信息,指定编译器的优化级别、错误处理方式等。

2.2在Ubuntu下预处理的命令

在终端命令行输入gcc -E hello.c -o hello.i,生成预编译文本文件hello.i。

2.3 Hello的预处理结果解析

hello.i是hello.c经过预处理得到的文本文件,可以看到其行数由24行扩展至3106行。前面的部分主要是一些系统头文件的引入和宏定义的展开,我们能看到大量的typedef,结构体,以及一些系统头文件的所在地址等内容。最后我们能看到原来的hello.c文件的代码位于末尾处。

2.4 本章小结

本章首先介绍了预处理的概念与作用,接着在Ubuntu下演示了hello.c的预处理,并对得到的预编译文件hello.i进行分析。

第3章 编译

3.1 编译的概念与作用

3.1.1概念:

编译是指编译器将经过预处理的文件进行分析优化,并生成汇编语言程序的过程。

3.1.2 作用:

编译过程可将不同的高级语言程序都转化为汇编语言程序,在之后的过程中,汇编语言将被转化为对应的机器指令,这有利于程序在不同设备平台上的移植。

3.2 在Ubuntu下编译的命令

在终端命令行输入gcc -S hello.i -o hello.s,生成汇编语言文本文件hello.s。

3.3 Hello的编译结果解析

3.3.1 文件信息:

hello.s中最开始记录的是文件相关的信息,如图所示

其中包含的信息有:.file对应着源文件,.text表示以下代码是代码段的内容,用于存放程序的指令和函数代码,.section .rodata表示以下内容是只读数据段,.align 8表示当前内容在内存中按8字节对齐,.LC0、.LC1、.string表示字符串常量的标签和内容,.globl main和.type main, @function表示main函数的全局性和类型为函数。

3.3.2 局部变量处理与赋值操作:

当进入main函数时,为了存储局部变量,会根据需求在栈上申请一段空间供其使用。在hello.c中,i在for循环开始时被赋值为0,在汇编语言中可以看到在.L2中,栈顶指针%rbp减4,并将立即数0存储在这个位置。

3.3.3 字符串常量的处理:

在进入main函数之前,字符串常量已在.L0和.L1处被存储,且被标记为只读类型。需要使用字符串常量时,将对应的存储地址计算后加载到%rax中,供后续使用。

3.3.4 算术操作:

在hello.c中的for循环中,每经过一次循环,i都会加1,在汇编代码中,可以看到在for循环模块的末尾,对栈顶指针%rbp减4的位置存储的变量执行addl $1,即加1并存回该位置,对应的就是hello.c中的i++。

3.3.5 关系操作:

关系操作主要通过cmp指令与一些其他指令结合而实现,cmp指令与sub指令行为一样,但只根据两数之差设置条件码而不更新寄存器。在hello.c中包含着argc与4的比较,以及i和8的比较。在汇编代码中就体现为对应参数或局部变量减去比较的数,再根据设置的条件(结果等于0、小于0、小于等于0等)进行后续操作。在hello.s中argc与4相比,相等则跳转,i与7相比,小于等于则跳转。

3.3.6 数组操作:

对于数组的访问是通过首地址+偏移量的方式实现的。hello.c中三次访问数组argv[]的元素,都是先将-32(%rbp),即数组的首地址传入寄存器%rax中,之后根据要访问的元素加上对应的偏移量。在hello.s中访问argv[1]时就加上偏移量8,访问argv[2]时就加上偏移量16,访问argv[3]时就加上偏移量24。

3.3.7 控制转移:

程序中的控制转移主要是指条件、循环等控制程序执行顺序方式的操作。hello.c中判断argc是否等于4,i是否小于8,在hello.s中,利用cmpl比较argc与4,等于则je跳转至.L2,.L2中将i初始化后jmp跳转至.L3,在.L3中cmpl比较i,若i小于等于7,则jle跳转至.L4,执行循环体中的内容。

3.3.8 参数传递:

调用函数之前,编译器会将参数存储在寄存器中,方便调用的函数使用。

当函数参数个数不大于6个时,按照优先级顺序使用寄存器传递参数:rdi,rsi,rdx,rcx,r8,r9;当参数个数大于6个时,前六个参数使用寄存器存放,其他参数压入栈中存储。由之前的图可知,前文中的argc存储在rdi中,argv[]存储在rsi中,在调用时,参数被从寄存器中取出存入已为其分配内存的栈中,等待之后的使用。

3.3.9 函数调用与返回:

函数调用时通过指令call,将当前指令运行的地址压入栈中,并执行该函数。当调用的函数执行完后弹出当前地址,继续之后的程序。函数调用时若需要参数,则前6个参数通过寄存器传递,其他参数通过栈传递。函数的返回值则存在寄存器%rax中。最后通过ret指令返回函数。本程序中的函数调用与返回如上图所示。1,2,3分别调用了printf、atoi、sleep函数,4将0存入%eax代表return 0,最后5通过ret将main函数返回。

3.4 本章小结

本章首先介绍了编译的概念和作用,然后以Ubuntu下的hello.s为例,逐步分析所得到的汇编代码,理解编译器是如何处理C语言的各种数据与操作的。

第4章 汇编

4.1 汇编的概念与作用

4.1.1概念:

汇编过程将把编译得到的汇编程序指令逐条翻译为机器语言指令,然后把这些指令打包成可重定位目标程序的格式,并将结果保存在一个二进制文件中。

4.1.2 作用:

将汇编指令转化为机器可以直接读取分析的机器指令。

4.2 在Ubuntu下汇编的命令

在终端命令行输入gcc -c hello.s -o hello.o,生成可重定位目标文件hello.o。

4.3 可重定位目标elf格式

4.3.1典型的ELF可重定位目标文件格式:

一个经典的ELF可重定位目标文件格式如图所示,主要包括ELF头、节头部表、.text节等。

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

2、.text:已编译程序的机器代码。

3、.rodata:只读数据。

4、.data:已初始化的全局和静态C变量。

5、.bss:未初始化的全局和静态C变量。

6、.symtab:一个符号表,存放着程序中定义和引用的函数和全局变量的信息。

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

8、.rel.data:被模块引用或定义的所有全局变量的重定位信息。

9、.debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。

10、.line:原始C源程序中的行号和.text节中机器指令之间的映射。

11、.strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节的名字。

12、节头部表:描述不同节的位置和大小。

4.3.2 ELF头:

hello.o中的ELF头如下图所示:

4.3.3 节头部表:

hello.o的节头部表如下图所示:

4.3.4 可重定位节和符号表:

hello.o的可重定位节和符号表如下图所示:

4.4 Hello.o的结果解析

4.4.1 机器语言的构成:

通过对比hello.s及hello.o的反汇编文件可以发现,两者在汇编指令上无显著差异,但反汇编文件中包含着每条语句的机器指令,由操作码和地址码构成,且每条机器指令前还有指令的相对地址。

4.4.2 分支转移:

通过对比可知hello.s中,分支转移是通过段名指定转移目标,而反汇编文件中是通过相对函数首地址的地址偏移量来指定转移目标的。

4.4.3 函数调用:

在hello.s中,函数调用是通过call指令,以函数名为目标实现调用的,而在反汇编文件中,函数调用同样是使用call指令,但调用目标是以地址偏移量给出的,且能发现目标地址是该命令的下一条命令所在的地址。说明此时函数调用并不知道函数的具体位置,需要经过链接才能确定调用函数的准确位置。

4.5 本章小结

本章首先介绍了汇编的概念和作用。之后以hello.o为例,分析了可重定位目标的ELF格式。之后通过分析反汇编得到的hello_o_ams.txt文件,展示了机器语言的构成,并在和hello.s的对比中,展现了机器语言与汇编语言的联系与差异。

第5章 链接

5.1 链接的概念与作用

5.1.1 概念:

链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行。

5.1.2 作用:

链接可以使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解成更小、更好管理的模块,可以独立修改和编译这些模块。当我们改变这些模块中的其中一个时,只需简单重新编译它,并重新链接应用,而不必重新编译其他文件。

5.2 在Ubuntu下链接的命令

在终端命令行输入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 /usr/lib/x86_64-linux-gnu/crtn.o /usr/lib/x86_64-linux-gnu/libc.so hello.o

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

利用readelf -a hello > hello.elf就可以得到hello.elf文件。

5.3.1 经典的可执行目标文件的ELF格式:

可执行目标文件的格式类似于可重定位目标文件的格式。一个经典的可执行目标文件的ELF格式如下图,包括ELF头、段头部表、节头部表,多出了.init节等。

5.3.2 ELF头:

查看hello的ELF头,发现hello的ELF头中类型处显示的是EXEC,表示是可执行目标文件,这与hello.o不同

5.3.3 段头部表:

ELF可执行文件被设计得很容易加载到内存,可执行文件的连续的片被映射到连续的内存段。段头部表(又称程序头部表)描述了这种映射关系,主要包括扩所指段的类型、段的读写权限、对齐方式等。

5.3.4 节头部表:

hello的节头部表如下图所示,主要描述文件的节,包括名称、类型、大小、地址、偏移量等信息。

5.4 hello的虚拟地址空间

使用edb加载hello,可在Data Dump查看本进程的虚拟地址空间各段信息。

从图中可以看出hello的虚拟空间从0x401000开始,且是.init的地址。对比程序头部表可知结果符合。

在节头部表中可以找到.text节的起始地址为0x4010f0,也可以找到

5.5 链接的重定位过程分析

5.1.1 hello与hello.o的差异:

首先,使用objdump -d -r hello对hello进行反汇编,得到反汇编文件hello_asm.txt,接着比较hello与hello.o。

在图中可以看出hello.o只有main函数中的指令,而hello的反汇编代码中包括.init等多个节中的代码指令。且在hello.o中,跳转和函数调用的目标是用地址偏移量表示的,且机器代码中并没有指向实际的函数,而在hello中,函数调用的目标是用该函数在虚拟内存中的地址来表示的,且在机器代码中的操作码后,即是调用函数的实际地址。

5.5.2重定位过程:

这里以sleep函数为例,未重定位之前call命令的地址码均为0,而在hello中调用sleep函数的call指令所在地址为0x4011a2,而下一条命令的地址为0x4011a7,由图可知sleep函数所在的首地址为0x4010e0,将其减去原本的下一条命令的地址即可得到0xffffff39,按小端法排列即为call指令中的地址码。从而实现了重定位的过程。

5.6 hello的执行流程

子程序名

子程序地址

_init

0x0000000000401000

_start

0x00000000004010f0

__libc_start_main

0x0000000000403fd8

__gmon_start__

0x0000000000403fe0

_dl_relocate_static_pie

0x0000000000401120

puts@plt

0x0000000000401090

exit@plt

0x00000000004010d0

printf@plt

0x00000000004010a0

atoi@plt

0x00000000004010c0

sleep@plt

0x00000000004010e0

getchar@plt

0x00000000004010b0

_fini

0x00000000004011c0

5.7 Hello的动态链接分析

动态的链接器在正常工作时采取了延迟绑定的策略,将过程地址的绑定推迟到第一次调用该过程时,使用偏移量表GOT和过程链接表PLT的协同工作实现函数的动态链接。如图所示hello程序调用了6个共享库的函数,.plt节中就有6处跳转指令,它们跳转到了同一个地址0x401020。

5.8 本章小结

本章首先介绍了链接的概念和作用,接着以hello.o为例链接生成了hello,并对可执行目标文件进行了分析,介绍了虚拟空间地址。同时分析了重定位过程、动态链接等过程。

第6章 hello进程管理

6.1 进程的概念与作用

6.1.1 概念:

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

6.1.2 作用:

1.为每个程序提供了一种假象,好像程序在独占地使用处理器。

2.为每个程序提供一种假象,好像它独占地使用系统地址空间。

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

6.2.1 作用:

shell是一种交互型的应用级程序。它能够接收用户命令,然后调用相应的应用程序,代表用户运行其他程序。

6.2.2 处理流程:

当用户在命令行界面输入命令后,shell会解析用户输入,识别出命令本身和可能的参数或选项。如果命令是一个内置命令则立即执行,如果是其他程序,则会用fork函数创建一个新的子进程并运行。之后在父进程中执行waitpid函数等待子进程运行结束,并将其回收。

6.3 Hello的fork进程创建过程

父进程通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程fork时,子进程可以读取父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。

以运行hello为例,当输入./hello时,父进程为shell,首先对输入的命令进行解析,由于不是内置命令,shell要执行该可执行文件,故调用fork函数创建一个新的子进程。

6.4 Hello的execve过程

execve函数会在当前进程的上下文中加载并运行一个新程序,它加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。execve加载了filename后,调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数。

6.5 Hello的进程执行

一个进程和其它进程轮流进行的概念称为多任务,一个进程执行它的控制流的一部分的每一时间段叫做该进程的时间片,操作系统内核使用一种称为上下文切换的异常控制流实现多任务。内核为每一个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需要的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、用户栈、内核栈等。

上图展示了进程A和进程B的相互切换过程。而用户模式和内核模式的切换则依赖于处理器提供的特殊机制。处理器通常是用某个控制寄存器中的一个模式位来提供这种功能。该寄存器描述了进程当前享有的特权。当设置了模式位时,进程运行在内核模式中,此时进程可以执行指令集中的任何指令,且可以访问系统中的任何内存位置。没有设置模式位时,进程就运行在用户模式中。

6.6 hello的异常与信号处理

6.6.1可能的异常与信号:

hello执行过程中可能出现四类异常:中断、陷阱、故障和终止。具体如下图:

可能产生的信号有SIGINT、SIGQUIT、SIGKILL、SIGTERM、SIGALRM、SIGCHLD、SIGSTOP等。

6.6.2 hello执行中可能出现的异常:

1.乱按包括回车

输入回车对程序处理并不影响,即程序未因回车出现异常,最终乱按的命令和回车只会显示在终端上。

2.运行时输入Ctrl-C

运行时输入Ctrl-C会向hello进程发送信号SIGINT使程序立即终止。

3.运行时输入Ctrl-Z

运行时输入Ctrl-Z会发送一个SIGTSTP信号给hello进程,使当前进程暂停挂起。

4.输入ps

输入ps命令可以监视后台其它的进程。

5.输入jobs

输入jobs可显示当前在后台运行的作业列表。

6.输入pstree

输入pstree命令,会将所有进程以树状图显示。

7.输入fg

输入fg,可向停止的进程发送SIGCONT信号,使其重新在前台运行。

8.输入kill

输入kill,-9表示给进程9183发送9号信号,即SIGKILL,杀死进程。

6.7本章小结

本章首先介绍了进程的概念和作用,然后介绍了shell的作用与处理流程,接着分析了hello的fork进程创建过程、execve过程、进程执行等过程。最后在Ubuntu上尝试了hello在执行过程中的异常与不同信号的处理。

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:逻辑地址是程序经过编译后出现在汇编代码中的地址,用来指定一个操作数或者是一条指令的地址。

线性地址:线性地址也称为虚拟地址,是指经过逻辑地址到物理地址转换之后得到的地址。

虚拟地址:虚拟地址是一个抽象的地址,它提供给进程使用,不代表真实的物理存储器中的位置。虚拟地址是相对于进程地址空间的,它由逻辑地址或线性地址生成。虚拟地址在内存管理单元(MMU)中被转换为物理地址,然后才能访问实际的物理存储器。

物理地址:物理地址是计算机系统中实际的存储器地址。它是指计算机中存储设备中某个特定位置的物理位置。当处理器通过地址转换机制将逻辑地址或线性地址转换为物理地址后,才能在物理存储器中访问对应的数据或指令。

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

段式管理是一种存储器管理方案,将内存分为多个段(segment),每个段都有一个段描述符(segment descriptor)来描述它的属性和基地址。每个段的大小可以不同,并且可以重叠。

逻辑地址由两部分组成:段选择子(Segment Selector)和偏移量(Offset)。段选择子包含了段描述符的索引和权限信息,偏移量指定了在段内的偏移量。以下是逻辑地址到线性地址的变换过程:

1.根据段选择子索引,从全局描述符表(GDT)或局部描述符表(LDT)中获取对应的段描述符。

2.检查段描述符的合法性和访问权限。如果段描述符无效或访问权限不符合要求,会触发异常。

3.根据段描述符中的段基地址(Segment Base Address)和偏移量,计算出线性地址:虚拟地址(VA) = 段基地址(BA) + 段内偏移量(S)

4. 根据线性地址进行内存访问操作(如读取或写入数据)。

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

页式管理将线性地址空间分割为固定大小的虚拟页(VP),将物理地址空间分割为相同大小的物理页(PP)。在页式管理中,线性地址到物理地址的变换是通过页表(Page Table)来实现的。具体过程如图所示,首先线性地址(虚拟地址)可以分成虚拟页号(VPN)和虚拟页偏移量(VPO)两部分,而物理地址经过分页管理也分成物理页号(PPN)和物理页偏移量(PPO)两部分。其中MMU可以利用VPN找到对应的PPN,而VPO和PPO是相同的。故将找到的PPN与PPO结合就得到了物理地址。

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

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

为了解决单独一个页表在虚拟地址空间引用后存在的页表占用内存过多的问题,常通过使用层次结构的页表来压缩页表,即使用多级页表。以四级页表为例,将虚拟地址分为4个VPN和1个VPO,每个VPN都是到对应级的页表的索引,而每一级页表中的每个PTE都会指向下一级的某个页表的基址。第四级页表中的每个PTE都包含某个物理页的PPN或者一个磁盘块的地址。根据VPN依次访问第1-4级页表就可以找到对应的PPN,找到对应物理地址。

两者相结合后VA到PA的变换过程如下图所示:

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

目前常见CPU有三级Cache,即L1、L2、L3,且与主存多采用组相联映射。高速缓存是一个高速缓存组的数组。每个组包含一个或多个行,每个行包含一个有效位,一些标记位,以及一个数据块。高速缓存的结构将m个地址位划分成了t个标记位、s个组索引位和b个块偏移位,如下图所示。

物理地址首先会访问L1 cache,如果命中就访存成功,如果没命中会再访问L2 cache,如果还没命中则会访问L3 cache,如果三级cache都没命中则会直接访问主存,如果没有缺页则访存成功,如果发生缺页会调用缺页异常处理程序把数据从硬盘调入主存再次访问主存。

7.6 hello进程fork时的内存映射

在shell中输入命令./hello后,内核调用fork函数创建子进程,为hello程序的运行创建上下文,并分配一个与父进程不同的唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

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

7.7 hello进程execve时的内存映射

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

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

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

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

DRAM缓存不命中称为缺页(page fault)。当地址翻译硬件从内存中读取一个虚拟地址时,若访问合法,但对应页表的有效位为0,则代表该页并未保存在主存中,触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,修改该牺牲页的页表条目,接着内核从磁盘复制需要的页到先前主存中的访问位置,重新启动导致缺页的指令,把导致缺页的虚拟地址重发送到地址翻译硬件。此时目标页已经缓存在主存中了,页命中也能由地址翻译硬件正常处理了。

7.9动态存储分配管理

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

分配器将堆视为一组不同大小的块(clock)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。

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

显式分配器:要求应用显式地释放任何已分配的块。例如C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。

隐式分配器:另一方面,要求分配器检测一个已分配块何时不再被程序使用,那么就释放这个块。

7.10本章小结

本章主要介绍了hello的存储管理。包括从虚拟地址到物理地址的变换过程,TLB与四级页表支持下的VA到PA的变换,运行hello进程fork和execve时的内存映射,缺页故障与缺页中断处理,最后介绍了动态存储的分配管理。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

一个Linux文件就是一个m个字节的序列:B0,B1,…,Bk,…,Bm-1。

所有的I/O设备都被模型化为文件,而所有的输入输出都被当作对相应的文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,成为Unix I/O。

8.2 简述Unix IO接口及其函数

8.2.1 Unix I/O接口:

Unix I/O使得所有的输入和输出都能以一种统一且一致的方式来执行:

  1. 打开文件。应用程序会通过要求内核打开对应的文件,来宣告它想要访问一个I/O设备,而内核会返回一个描述符,用来在后续操作中标识这个文件。
  2. 改变当前的文件位置。对于每个打开的文件,内核保持一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
  3. 读写文件。一个读操作就是从文件文件复制n > 0个字节到内存,从当前文件位置k开始,然后将k增加到k + n。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
  4. 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

8.2.2 函数:

1.open函数:int open(char *filename,int flags,mode_t node);

将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件,此外,flags参数也可以是一个或多更多位掩码的或,为写提供一些额外的指示。mode参数指定了新文件的访问权限位。

2.close函数:int close(int fd);

关闭一个打开的文件,当关闭已关闭的描述符会出错。

3.read函数:ssize_t read(int fd,void *buf,size_t n);

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

4.write函数:ssize_t write(int fd,const void *buf,size_t n);

从内存位置buf复制至多n个字节到描述符fd的当前文件位置。

5. lseek函数:off_t lseek(int fd, off_t offset, int whence);

应用程序显示地修改当前文件的位置。

8.3 printf的实现分析

printf函数的函数体:

在形参列表里有这么一个token:…

这个是可变形参的一种写法。当传递参数的个数不确定时,就可以用这种方式来表示。(char*)(&fmt) + 4) 表示的是…中的第一个参数,该语句即令字符指针arg指向可变形参的第一个参数。vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。返回的是要打印出来的字符串的长度,并将其赋值给i。最后调用write函数把buf中的i个元素的值输出到终端。

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

8.4 getchar的实现分析

getchar函数的函数体:

当程序调用getchar时。程序就等着用户按键。getchar等调用read系统函数,通过系统调用读取按键ascii码,用户输入的字符被存放在键盘缓冲区中。直到接受到回车键才返回。当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符。

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

8.5本章小结

本章主要介绍了Linux的I/O设备管理方法、Unix I/O接口及其相关函数,并分析了printf函数和getchar函数的实现。

结论

首先,经过程序员的编写,得到了源程序hello.c。

接着,hello.c经过预处理,得到了hello.i,在这个过程中预处理器会对代码进行一些预处理操作,以生成可以交给编译器的新代码。主要包括宏替换、文件包含、条件编译、符号定义和编译器指令几个方面。

之后hello.i经过编译器编译得到汇编语言程序hello.s,

紧接着,hello.s经过汇编器将汇编语言程序逐条翻译成机器指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在一个二进制文件中,得到hello.o。

最后hello.o在链接器的作用下,与其他所需要调用的库函数通过动态链接生成可执行目标文件hello,至此完成了从源程序hello.c到可执行程序hello的蜕变。

在运行可执行程序hello的过程中,先在shell中输入命令./hello,接着shell解析输入的命令并调用fork生成一个新的子进程,接着用execve将进程映射到虚拟内存中。在执行一条条指令的过程中,CPU发送的逻辑地址经过段式管理、页式管理后变换为物理地址,并在三级cache中实现访存。

在程序的执行过程中,可能会触发异常或接收信号,进而使程序调用异常处理或信号处理函数来解决问题。

最后当hello程序执行结束后,子进程变为僵死进程,已调用waitpid函数父进程将子进程回收,并删除相关内容。hello程序的一生就此结束。

在探索hello程序的一生的过程中,我深刻了解了一个程序从无到有再到无的一系列处理与过程,并借此对计算机系统的相关知识有了更深入的理解。包括源程序的预处理、编译、汇编、链接,以及进程、异常与信号、存储管理、I/O管理等。计算机系统这门课让我学到了很多,认识到计算机世界在丰富多彩的同时也是高深莫测的,还有更多的知识等待着我学习,更多的奥秘等待着我探索。在今后的学习中,我也将抱持活跃的思考,多动手,不断增强自己的能力。

附件

文件名

作用

hello.c

hello的C源文件

hello.i

hello.c预处理后得到的预编译文本文件

hello.s

hello.i经过编译后得到的汇编语言文本文件

hello.o

hello.s经过汇编得到的可重定位目标文件

hello

hello.o经过链接得到的可执行目标文件

hello_o.elf

hello.o的ELF格式文件

hello_o_asm.txt

hello.o反汇编后的文件

hello.elf

hello的ELF格式文件

hello_asm.txt

hello反汇编后的文件

参考文献

[1] 《深入理解计算机系统》Randal E. Bryant David R.O`Hallaron

[2] https://www.cnblogs.com/pianist/p/3315801.html

[3] https://zhuanlan.zhihu.com/p/458932445

[4] https://blog.csdn.net/Nod_Mouse/article/details/114654965