哈工大计算机系统大作业(2023秋)——程序人生-Hello’s P2P

摘  要

本论文研究了hello程序在Linux系统下的整个生命周期。从源代码hello.c开始,依次深入实践了预处理、编译、汇编、链接、运行、回收的完整过程,从而对hello.c文件的“一生”有了更详细的认识。本论文以hello.c文件为研究对象,结合《深入理解计算机系统》书中的内容与课堂内容,在Ubuntu系统下对hello程序的整个生命周期进行了研究,同时梳理回顾了学习内容,加深了对计算机系统的了解。

关键词:计算机系统;程序的生命周期;编译过程;深入理解计算机系统

第1章 概述

1.1 Hello简介

此程序为一个延时打印“Hello+姓名+学号”的程序。

P2P:程序通过程序员的编码形成程序代码文件(Program),经过预处理、编译、汇编、链接等一系列过程,得到二进制程序文件,从而将其变成了一个运行的进程(Process)。这就是程序的P2P。

020:程序一开始不在内存空间中,OSfork出一个子进程,然后execve执行hello程序,OS会为他开一块虚拟内存,同时将程序加载到虚拟内存映射到的物理内存中。当程序执行完,OS回收这一程序,同时为该程序的开辟的内存空间也会被回收,此时又变为0。这就是程序的020。

1.2 环境与工具

硬件环境:Intel x64 CPU 2.50GHz

软件环境:Windows 11 64位; VirtualBox; Ubuntu 22.04 LTS 64位

工具:编辑器VS Code、反汇编工具EDB、编译环境GCC

1.3 中间结果

列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。

文件名

作用

hello.c

源代码

hello.i

预处理后得到的文本文件

hello.s

编译后得到的汇编语言文件

hello.o

汇编后得到的可重定位目标文件

hello_dump.s

反汇编hello.o得到的反汇编文件

hello_o.elf

用readelf读取hello.o得到的ELF格式信息

hello

可执行文件

hello_exec.s

反汇编hello可执行文件得到的反汇编文件

hello.elf

由hello可执行文件生成的.elf文件

表 1 中间结果

1.4 本章小结

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

第2章 预处理

2.1 预处理的概念与作用

预处理即在编译之前对代码进行的处理,为后续的编译工作带来便利。 C语言的预处理主要有以下几个方面的内容:

  1. 宏定义替换:如#define,将宏展开为实际的代码片段。
  2. 文件包含:如#include,将指定的头文件内容放入源文件中。
  3. 条件编译:如#if、#endif、#elif等,根据条件删除或包含部分代码块。
  4. 注释:会将注释删除。
  5. 其他预编译器提示字:如#error,确保特定的编译选项已经被定义。

2.2在Ubuntu下预处理的命令

使用gcc -E hello.c -o hello.i或cpp hello.c -o hello.i都可以对hello.c进行预处理。

根据作业要求,此处使用gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i ,带参数进行预处理。

图 1 预处理

2.3 Hello的预处理结果解析

经过预处理后,文件明显变长,由原本的25行变长至3093行。原本的代码位于预处理文件的末尾。

图 2 预处理文件中的原代码

预编译文件前面的部分对包含的头文件进行进行包含,依次是stdio.h、unistd.h、stdlib.h。

图 3 对stdio.h的包含(部分)

图 4 对unistd.h的包含(部分)

图 5 对stdlib.h的包含

此外,原本代码文件中的注释被删去了,预处理会忽略C代码文件当中的注释。

2.4 本章小结

本章介绍了预处理的概念和作用,尝试对代码进行了预处理,分析了代码预处理的结果,对预处理过程有了更加深入的认识。

第3章 编译

3.1 编译的概念与作用

编译是指使用编译器将预处理后的代码文件转换为汇编语言文件的过程。

3.2 在Ubuntu下编译的命令

gcc -S hello.i -o hello.s可以实现对预处理文件的编译。根据作业要求,使用gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s对预处理文件进行编译。

图 6 对预处理文件进行编译

编译得到汇编语言文件hello.s

3.3 Hello的编译结果解析

3.3.1数据和赋值操作

(1)常量:

对于if或for语句中的立即数,他们的值直接保存在.text中。

图 7 if中的常量储存

图 8 for中的常量储存

对于printf中字符串类型的常量,他们被保存在.rodata中

图 9 字符串型常量

(2)变量:

程序若有已初始化的全局变量,会存储在.data中。

1.变量i:

图 10 变量i的赋值

在进入循环前将i赋值为0,可以看到i存储的位置是%rbp-4,保存在栈中。

2.变量argc与*argv[]。

图 11 argc和*argv[]的存储位置

argc存储在%rbp-20;*argv[]存储在%rbp-32。

(3)赋值:赋值使用mov指令,根据数据的大小决定使用哪一条赋值语句。

指令

movb

movw

movl

movq

数据大小(字节)

1

2

4

8

表 2 mov的类型

3.3.2算术操作

图 12 addl指令进行算术操作

在此程序中,每一轮循环结束后,i加上1。

3.3.3数组/指针/结构操作

用户所输入的参数存储在*argv[]指向的字符串数组中,由上文知*argv[]存储在%rbp-32。

图 13 数组操作

可以观察到argv[1]位于(%rbp-32)+8; argv[2]位于(%rbp-32)+16; argv[2]位于(%rbp-32)+24。

3.3.4关系操作

使用cmp比较两个值的关系,将结果存放到条件码寄存器中。

图 14 判断argc!=4

图 15 判断i<8

3.3.5控制转移

图 16 if判断argc!=4

je用于判断cmpl比较的结果,若两个操作数的值不相等则跳转到指定地址。

图 17 for循环

For循环首先初始化i的值,之后进行循环条件判定,使用cmpl和jle进行分支操作,如果i小于等于7则可以继续循环,否则不能。每次循环体内代码执行完毕后,i自加1。

3.3.6函数操作

函数操作首先需要为参数赋值,然后调用参数。

图 18 寄存器表

(1)main函数:

参数传递:传入参数argc和*rgv[],分别用寄存器%rdi和%rsi存储。

函数调用:被系统启动函数调用。

函数返回:设置%eax为0并且返回,对应return 0。

图 19 main函数的参数获取

图 20 设置返回值为0

(2)printf函数

参数传递:call puts只传入字符串首地址置于%rdi;call printf中,字符串首地址置于%rdi,argv[1],argv[2]分别放在%rsi、%rdx。

函数调用:call puts;call printf。

图 21 call puts

图 22 call printf

(3)exit函数

参数传递:将1置于%rdi。

函数调用:call exit

图 23 call exit

(4)atoi函数

参数传递:将argv[3]置于%rdi。

函数调用:call atoi

函数返回:返回值存储在%eax

图 24 call atoi

(5)sleep函数

参数传递:将atoi的返回值置于%rdi。

函数调用:call sleep

图 25 call sleep

(6)getchar函数

函数调用:call getchar

图 26 call getchar

3.4 本章小结

本章介绍了编译的概念和作用,尝试对预处理后的代码进行了编译,分析了编译的结果,对编译过程和汇编语言有了更加深入的认识。

第4章 汇编

4.1 汇编的概念与作用

汇编是使用汇编器将汇编语言代码转换成机器语言目标文件的工具。它读取汇编语言源文件,将每条汇编指令翻译成对应的机器指令,并生成可重定位目标文件,目标文件中包含了机器指令的二进制表示以及其他相关的信息。为下一步进行链接准备条件。

4.2 在Ubuntu下汇编的命令

as hello.s -o hello.o和gcc -c hello.s -o hello.o都可以实现对hello.s的汇编。

根据作业要求,使用 gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o进行汇编。

图 27 汇编

4.3 可重定位目标elf格式

    分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。

  1. ELF头:ELF头记录了整个ELF文件的基本信息,包含其类别,目标体系结构等。

图 28 ELF头

  1. 节头:ELF 文件中,每个节都有一个对应的节头表,用于描述和定位各个节的信息,通过节头表,可以获取关于每个节的详细信息,如名称、偏移、大小等。

图 29 节头

  1. 重定位节:重定位节记录了需要在链接时修正的位置信息,它包含了与链接器相关的信息,以便在最终可执行文件中正确地安排符号的地址。

图 30 重定位节

  1. 符号表:.symtab存放在程序中定义和引用的函数和全局变量的信息。

图 31 符号表

4.4 Hello.o的结果解析

4.4.1 数字进制表示不同

       hello.s中数字用10进制表示,反汇编中数字用16进制表示。

图 32 数字进制不同

4.4.2 分支转移

       hello.s中的分支转移中用段的名字表示跳转目标,因为hello.o已经是可重定位文件,反汇编文件中每一行已经分配了地址,跳转命令后接的是跳转的目标地址

图 33 分支转移不同

4.4.3 函数调用

Hello.s中call后接的是函数名称,而反汇编文件中接的是相对main函数的偏移地址,同时在反汇编代码中调用函数的操作数都为0,这是因为在链接后才会生成确定的地址,故此处暂时用0填充。

图 34 函数调用不同

4.5 本章小结

本章介绍了汇编的概念和作用。经过汇编器之后,生成了一个可重定位的文件hello.o,为下一步链接做好了准备。通过hello.o的反汇编代码与hello.s的比较,对汇编作用的理解更加深刻。

第5章 链接

5.1 链接的概念与作用

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

其作用在于把预编译好了的若干目标文件合并成为一个可执行目标文件。使得分离编译称为可能,不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为可独立修改和编译的模块。当改变这些模块中的一个时,只需简单重新编译它并重新链接即可,不必重新编译其他文件。

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 hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o -no-pie

图 35 链接

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

    分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。

  1. ELF头:ELF头记录了整个ELF文件的基本信息,包含其类别,目标体系结构等。

图 36 ELF头

  1. 节头:ELF 文件中,每个节都有一个对应的节头表,用于描述和定位各个节的信息,通过节头表,可以获取关于每个节的详细信息,如名称、偏移、大小等。

图 37 节头

  1. 程序头:用于描述如何将文件的各个段加载到内存中。每个程序头表项提供了有关一个段在内存中的布局和属性的信息,以便操作系统的加载器正确加载可执行文件。

图 38 程序头

  1. 动态区段:用于存储程序在运行时所需的动态链接信息。它包含一系列的动态入口,这些入口提供了程序在运行时获取共享库和其他动态链接信息的方式。

图 39 动态区段

  1. 重定位节:用于存储关于程序中需要在运行时进行地址重定位的信息。重定位节包含了对符号的引用以及如何修改这些引用的信息,以便程序在加载到内存时能够正确执行。

图 40 重定位节

  1. 符号表:用于存储程序中使用的符号信息。符号表记录了程序中定义和引用的变量、函数以及其他符号的相关信息,包括符号的名称、类型、大小、地址等。

图 41 符号表

5.4 hello的虚拟地址空间

    使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。

图 42 虚拟内存区域表

可以根据节头表查看各段的起始位置,如对于.rodata段,地址为0x0000000000402000。

图 43 节头表(部分)

在edb中转到相应地址,可以看到虚拟地址空间中的内容。

图 44 EDB查找.rodata

其余各段以此类推。

5.5 链接的重定位过程分析

图 45 反汇编hello

(1)Hello的反汇编相较于hello.o的反汇编,每行指令都有唯一的虚拟地址,这是因为hello经过链接,已经完成重定位,每条指令的地址关系已经确定。

(2)扩充了很多函数代码,增加了.init段和.plt段,包括程序加载后执行main前的一些准备工作,以及hello需要用到的一些库函数的定义,这是因为动态链接器将共享库中hello.c用到的函数加入可执行文件中。

(3)原本在hello.o中等待重定位而暂时置0的地址操作数,成功进行了重定位,并计算了偏移量,被设置为了虚拟地址空间中的地址。

图 46 链接前后反汇编代码的比较

链接器在重定位步骤中,合并输入模块并将运行时地址赋给输入模块定义的每个节、符号。当这一步完成时,程序中的每条指令和全局变量才拥有唯一的运行时内存地址。

5.6 hello的执行流程

在正确输入的情况下hello会依照以下顺序调用模块

地址

子程序名

0x0000004010f0

_start

0x7f8d32029dc0

__libc_start_main

0x000000401000

_init

0x000000401125

main

0x000000401040

printf@plt

0x000000401060

atoi@plt

0x000000401080

sleep@plt

0x000000401050

getchar@plt

3 hello的执行顺序

图 47 使用分析模块调用

5.7 Hello的动态链接分析

在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。延迟绑定是通过GOT和PLT实现的。根据ELF文件,可知GOT节和PLT节的起始地址。

图 48 GOT节和PLT节的起始地址

图 49 GOT与PLT的作用过程

执行_init前:

图 50 执行_init前的内容

执行_init后:

图 51 执行_init后的内容

5.8 本章小结

本章中介绍了链接的概念与作用。观察了hello文件ELF格式下的内容。利用edb观察了hello的虚拟地址空间使用情况并以hello为例对重定位过程、执行过程和动态链接过程进行分析。

第6章 hello进程管理

6.1 进程的概念与作用

(1)进程的概念:

狭义定义:进程是正在运行的程序的实例

广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。

(2)进程的作用:

进程是对正在运行的程序过程的抽象;实现角度上,进程是一种数据结构,目的在于清晰地刻画动态系统的内在规律,有效管理和调度进入计算机系统主存储器运行的程序。

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

含义:Shell是操作系统的最外层,是一个用户跟操作系统之间交互的命令解释器,让用户能够更加高效、安全、低成本地使用 Linux 内核。

作用:Shell 应用程序提供了一个界面,用户通过这个界面访问操作系统内核的服务。

处理流程:

(1)从终端读入输入的命令;

(2)将输入字符串切分获得所有的参数;

(3)如果是内置命令则立即执行;

(4)否则调用相应的程序执行;

(5)shell 应该接受键盘输入信号,并对这些信号进行相应处理。

6.3 Hello的fork进程创建过程

当Hello父进程调用fork 时,操作系统会复制当前进程的副本,包括代码、数据和堆栈等,从而生成一个新的子进程。这两个进程几乎是相同的,但它们有着不同的PID。父子进程之间的执行是并发的,它们在fork调用之后分别继续执行。fork的返回值在父进程中是子进程的PID,而在子进程中是0,这样可以通过返回值的不同来区分执行流。因为进程的地址空间是独立的,之后父子进程可以独立地执行各自的任务,互不干扰。

6.4 Hello的execve过程

execve 是一个在 Unix 系统中的系统调用,用于在当前进程中加载并执行一个新调用execve时,操作系统会用指定的可执行文件替换当前进程的地址空间,包括代码、数据和堆栈等。新程序的执行从其main函数开始,完全取代了原始进程的执行。这个过程包括(1)打开指定的可执行文件;(2)加载其代码和数据到内存;(3)设置新程序的堆栈和参数;(4)最终将控制权转交给新程序。

6.5 Hello的进程执行

6.5.1 相关信息

(1)进程上下文信息:进程上下文信息包括了进程的状态、寄存器值、程序计数器等。当操作系统决定切换到另一个进程时,它需要保存当前进程的上下文信息,以便稍后能够恢复到该进程的执行状态。

图 52 使用系统调用时的上下文切换

(2)用户态与核心态:大多数操作系统将处理器的执行分为用户态和核心态。在用户态下,进程只能执行受限的指令集,而在核心态下,进程可以执行更多的特权指令。进程从用户态切换到核心态需要进行特权级的切换,通常通过系统调用或异常来实现。

(3)每个进程在系统中分配到的执行时间被称为时间片。当一个进程的时间片用尽时,操作系统可以选择切换到另一个进程,以便为其他进程提供执行机会。时间片轮转是一种常见的调度算法,确保每个进程都有机会执行。

6.5.2 进程调度的过程

(1)就绪队列:操作系统维护一个就绪队列,其中包含了所有准备好被执行的进程。这些进程通常已经加载到内存中,但由于某些原因而暂时没有执行。

(2)选择下一个执行的进程:调度器根据调度算法(如时间片轮转)从就绪队列中选择下一个要执行的进程。

(3)保存当前进程的上下文:如果当前有正在执行的进程,其上下文信息将被保存,以便稍后能够继续执行。

(4)切换到选定进程的上下文:调度器加载下一个进程的上下文信息,将控制权转移到该进程。

(5)执行进程:被选中的进程开始执行,运行一段时间,直到它主动释放 CPU,或者它的时间片用尽,或者它被更高优先级的进程抢占。

(6)循环:这个过程不断循环,根据调度算法选择下一个执行的进程,切换上下文,执行进程,直到所有进程完成执行。

6.5.3 用户态与核心态转换

(1)用户态到核心态的转换:当进程需要执行特权操作(例如访问硬件设备、进行文件操作、执行系统调用等)时,它必须切换到核心态。这通常通过系统调用触发。在这种情况下,操作系统会保存当前进程的用户态上下文,切换到核心态,并执行相应的内核代码。

(2)核心态到用户态的转换:一旦核心态的工作完成,操作系统将恢复先前保存的用户态上下文,将控制权返回给用户态的进程。这个过程确保了用户程序无法直接操作系统内核,同时保护了系统的稳定性和安全性。

6.6 hello的异常与信号处理

(1)hello执行过程中可能出现的异常、产生的信号和处理方式。

图 53 异常列表

Linux定义的信号主要有如下几种:

图 54 Linux内核定义的信号

而在Hello程序的执行过程中比较可能出现的主要有SIGINT、SIGQUIT、SIGCHLD、SIGSEGV、SIGALRM、SIGTSTP、SIGKILL、SIGTERM、SIGCONT等几种。

程序对异常的处理方法如下图所示:

图 55 中断处理方式

图 56 陷阱处理方式

图 57 故障处理方式

图 58 终止处理方式

(2)各种状况下的运行结果

1. 正常运行:

图 59 正常运行

2. 不停乱按:不影响程序正常输出

图 60 不停乱按时输出

3.按回车:不影响程序正常工作,但回车会填充进输入缓冲区

图 61 按回车时输出

4. 按Ctrl+Z:发送SIGTSTP信号,进程停止

图 62 按下Ctrl+Z

5. 按Ctrl+C:发送SIGINT信号,进程终止

图 63 按下Ctrl+C

(3)Ctrl+Z后运行命令

1. ps命令:

图 64 Ctrl+Z后使用ps

2.jobs命令:

图 65 Ctrl+Z后使用jobs

3.pstree命令:

图 66 Ctrl+Z后使用pstree

4.fg命令:程序回到前台继续运行

图 67 Ctrl+Z后使用fg

5.kill命令(默认发送SIGTERM):

图 68 kill不带参数(SIGTERM)

图 69 指定发送SIGKILL

6.7本章小结

本章主要介绍了hello可执行文件的执行过程,包括进程创建、加载和终止,还探讨了键盘输入对进程产生的影响。从创建进程到回收进程,这一整个过程中需要各种各样的异常和信号。通过对hello进程异常与信号处理的分析,我们对计算机系统的进程管理有了更加深刻的认识。

第7章 hello的存储管理

7.1 hello的存储器地址空间

(1)逻辑地址:逻辑地址是程序中使用的地址,由程序员或编译器定义。在程序中,逻辑地址是相对于程序自身的地址空间的,它是程序员编写代码时使用的地址。

(2)线性地址:线性地址是逻辑地址经过分段机制转换之后得到的地址。在分段机制下,逻辑地址被分为多个段,每个段有一个基地址,线性地址是通过将逻辑地址的偏移量与相应段的基地址相加而得到的。

(3)虚拟地址:虚拟地址是在程序执行时由CPU生成的地址,它是逻辑地址或线性地址到物理地址的中间层。虚拟地址空间是一个抽象的地址范围,程序认为自己独占整个地址空间,而实际上是与其他程序共享的。

(4)物理地址:理地址是最终在RAM中实际存在的地址。操作系统通过内存管理单元将虚拟地址映射到物理地址,从而使程序能够访问实际的硬件内存。

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

Intel处理器从逻辑地址到线性地址的变换通过段式管理的方式实现。内存地址由两个部分组成:段选择符和偏移量。逻辑地址通过这两个部分经过一系列的变换得到线性地址。以段选择符为索引,在GDT或LDT中找到对应的段描述符。

图 70 段选择符各字段含义

将段描述符中的基地址与偏移量相加,得到线性地址。如果启用了分页机制,则线性地址可能还需要进一步转换为物理地址,否则线性地址就是物理地址。

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

线性地址到物理地址之间的转换通过对虚拟地址内存空间进行分页的分页机制完成。虚拟地址可被分为两个部分:VPN(虚拟页号)和VPO(虚拟页偏移量)。

图 71 32位系统线性地址向物理地址变换

线性地址的高位部分经过页表的查找得到相应的页表项,当页表项有效位为 1 时,操作系统或硬件使用该页表项进行虚拟地址到物理地址的映射。页表项中包含物理页框号以及一些控制信息。将页表项中的物理页框号与线性地址的低位部分(偏移量)相加,得到最终的物理地址。

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

图 72 TLB与四级页表支持下的VA到PA的变换(左半部分)

如上图左半部分所示,CPU产生虚拟地址VA,并将其传送至MMU,MMU使用前36位VPN作为TLBT(前32位)+TLBI(后4位)在TLB中进行匹配,若命中,则得到PPN(40位)与VPO(12位)组合成物理地址PA(52位)。若TLB没有命中,则MMU向页表中查询,由CR3确定第一级页表的起始地址,VPN1(9位)确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,则执行下一步确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成PA,并向TLB中添加条目。

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

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

7.6 hello进程fork时的内存映射

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

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

7.7 hello进程execve时的内存映射

(1)删除已存在的用户区域:删除当前进程虚拟地址的用户部分中的已存在的区域结构。

(2)映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为a.out文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈堆区域也是请求二进制零的,初始长度为零。

(3)映射共享区域:如果a.out程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

(4)设置程序计数器(PC):execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。

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

图 73 加载器映射用户地址空间区域的方式

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

概念:

缺页故障是计算机操作系统中的一种情况,指的是程序试图访问在物理内存中不存在的页面。当出现缺页故障时,操作系统需要进行相应的缺页中断处理。

缺页中断的处理:

(1)缺页异常的产生:当程序尝试访问一个虚拟地址,而对应的页面不在物理内存中时,就会发生缺页故障。地址翻译硬件从内存中读取CPU引用信息对应的PTE,从有效位推断出对应的页未被缓存,触发缺页异常。 在进入异常处理程序之前,硬件会自动保存一些寄存器的值,以便在中断处理结束后能够正确地恢复执行。

(2)缺页异常处理程序:缺页异常调用缺页异常处理程序,该程序会选择一个牺牲页,若此页已被修改,内核会将其复制会磁盘。无论哪种情况,内核都会修改相应页表条目,反映牺牲页不再缓存在主存中。异常处理程序将缺页对应的页面从磁盘加载到主存中,更新页表,随后返回。

(3)正常继续运行:恢复之前保存的寄存器值,以便继续执行用户程序。此时,用户程序能够重新访问之前导致缺页故障的虚拟地址。由于页面已经加载到物理内存,重新执行引起缺页故障的指令。这次访问将成功完成。

7.9动态存储分配管理

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

分配器有两种基本风格,显式分配器和隐式分配器。显示分配器要求应用显式地释放任何已分配的块;隐式分配器要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。

7.10本章小结

本章主要介绍了hello 的存储器地址空间、段式管理、页式管理, VA 到PA 的变换、物理内存访问,fork、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理的相关内容,让我们对hello的存储管理有了较为深入的讲解。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件;

设备管理:unix io接口。

Linux中所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行:打开文件、改变当前的文件位置、读写文件、关闭文件。

8.2 简述Unix IO接口及其函数

一、描述符管理

(1)打开文件:

int open(const char *path, int flags, mode_t mode): 打开文件并返回文件描述符。

(2)关闭文件:

int close(int fd): 关闭文件描述符。

二、读写操作

(1)读取数据:

ssize_t read(int fd, void *buf, size_t count): 从文件描述符中读取数据。

(2)写入数据:

ssize_t write(int fd, const void *buf, size_t count): 向文件描述符写入数据。

三、文件控制

(1)设置文件指针位置:

off_t lseek(int fd, off_t offset, int whence): 设置文件描述符的读/写位置。

(2)读取文件元数据:

int stat(const char *path, struct stat *buf): 获取文件的详细信息。

8.3 printf的实现分析

printf在Linux内核中的定义如下:

图 74 printf实现

其首先声明一个长度1024的缓冲区,之后建立可变参数表,随后调用vsprintf将可变参数填入格式字符串进行格式化,放到缓冲区中,随后打印缓冲区内信息。

vsprintf的实现如下:

图 75 vfprintf的实现

其主要作用为格式化字符串,它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出并返回字符串长度。

之后调用puts函数,其实现方式如下:

图 76 puts等函数的实现

对于每个字符,puts调用putchar,putchar再根据是否处于早期通过串口输出调试信息判断是否向串口输出信息,在bios_putchar中,程序设置寄存器,并将设置好的寄存器值传递给BIOS中断0x10,以执行字符输出操作。也可以通过write系统函数,系统调用 int 0x80或syscall以实现该过程。

字符显示驱动子程序从字符的ASCII编码到字模库中查询,将字模信息转移到到显示vram中,此时显示VRAM中存储了每一个点的RGB颜色信息。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

Linux内核中getchar的实现如下:

图 77 getchar的实现

首先实例化两个寄存器结构体,对一个进行初始化,之后传递给BIOS中断0x16。BIOS的中断0x16用于处理键盘输入。这个中断是基本的键盘输入服务例程,允许程序从键盘获取用户输入。

图 78 BIOS中断0x16功能

通常,这个功能返回时,AH 寄存器中包含了按下按键的扫描码,而 AL 寄存器中则包含了按下按键的ASCII码。操作系统内,getchar等调用read系统函数,通过系统调用读取按键ascii码。

8.5本章小结

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

结论

hello所经历的过程:

(1)预处理

对hello.c进行宏替换、头文件包含和条件编译等操作。预处理器将生成一个经过处理的源代码文件hello.i。

(2)编译

编译阶段包括词法分析、语法分析、语义分析、优化和代码生成等步骤。编译器将预处理后的文件转换为汇编代码。通过编译过程,编译器将hello.i 翻译成汇编语言文件 hello.s。

(3)汇编

汇编阶段将汇编代码翻译成机器语言,并生成可重定位目标文件hello.o。

(4)链接

通过链接器,将hello的程序编码与动态链接库等收集整理成为一个单一文件,生成完全链接的可执行的目标文件hello。

(5)加载运行

打开Shell,在通过IO设备在其中输入./hello 2022112XXX XXX 2,终端fork新建进程,并通过execve把代码和数据加载入虚拟内存空间,程序开始执行。

(6)执行指令

CPU按照程序计数器(PC)指向的指令地址,从内存中读取指令并执行。程序的控制逻辑流通过CPU的执行来实现。

(7)访存

内存管理单元(MMU)负责将程序使用的逻辑地址映射到物理地址,从而访问实际的内存。

(8)信号处理

信号处理允许程序在运行时对外部事件作出响应,如Ctrl+C(SIGINT)和Ctrl+Z(SIGTSTP)。

(9)终止并被回收

当程序执行完毕或因错误而终止时,操作系统会回收程序使用的资源,包括关闭文件、释放内存等。

你对计算机系统的设计与实现的深切感悟:

计算机系统的设计与实现是一个复杂而又深刻的过程,它不仅仅是硬件和软件的结合,更是对计算机科学原理、算法、数据结构、操作系统、编程语言等多个领域深入理解的体现。抽象是计算机科学的重要思想,通过适当的抽象,可以简化问题、提高系统的灵活性,并使得不同层次的组件更容易协同工作。对抽象的理解使得系统设计更加灵活、可扩展。

附件

列出所有的中间产物的文件名,并予以说明起作用。

文件名

作用

hello.c

源代码

hello.i

预处理后得到的文本文件

hello.s

编译后得到的汇编语言文件

hello.o

汇编后得到的可重定位目标文件

hello_dump.s

反汇编hello.o得到的反汇编文件

hello_o.elf

用readelf读取hello.o得到的ELF格式信息

hello

可执行文件

hello_exec.s

反汇编hello可执行文件得到的反汇编文件

hello.elf

由hello可执行文件生成的.elf文件

表 4 中间结果

参考文献

[1]  兰德尔 E.布莱恩特, 大卫 R.奥哈拉伦.深入理解计算机系统:a programmer's perspective[M].机械工业出版社.2016

[2] Pianistx.printf 函数实现的深入剖析[EB/OL].(2013-09-11)[2023-12-24]. https://www.cnblogs.com/pianist/p/3315801.html

[3] Bootlin.printf.c - arch/x86/boot/printf.c - Linux source code (v6.6.8) - Bootlin[EB/OL].(2023-12-20)[2023-12-24]printf.c - arch/x86/boot/printf.c - Linux source code (v6.6.8) - Bootlin

  • 28
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值