HITCSAPP Hello的一生

计算机系统

大作业

题     目  程序人生-Hello’s P2P   

专       业    物联网工程         

学     号    *********          

班     级       *******        

学       生      凌佳            

指 导 教 师      吴锐             

计算机科学与技术学院

2024年5月

摘  要

本文详细介绍了一个简单的C程序“Hello”在计算机系统中的生命周期,包括预处理(cpp)、编译(cc1)、汇编(as)、链接(ld)、进程管理、存储管理和输入输出管理等各个方面。通过分析程序在Ubuntu系统下的实际运行过程,从代码编写到最终执行,逐步剖析了每一个步骤的概念、作用和实现细节,还利用了edb等调试工具逐步观察程序运行,同时关注了程序在操作系统中的执行过程,包括进程创建、内存管理、系统调用等关键技术。通过对Hello程序的全面解析,揭示了计算机系统工作原理的复杂性和高效性,使得我更加深入地理解了计算机系统的内在逻辑和运行机制。

关键词:计算机系统;Linux;程序;处理;执行                      

目  录

第1章 概述... - 4 -

1.1 Hello简介... - 4 -

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

1.3 中间结果... - 5 -

1.4 本章小结... - 5 -

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

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

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

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

2.4 本章小结... - 7 -

第3章 编译... - 8 -

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

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

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

3.3.1初始部分... - 8 -

3.3.2数据部分... - 9 -

3.3.3赋值操作... - 10 -

3.3.4类型转换... - 10 -

3.3.5算数操作... - 10 -

3.3.6关系操作... - 10 -

3.3.7控制转移... - 11 -

3.3.8函数操作... - 11 -

3.3.9结尾部分... - 12 -

3.4 本章小结... - 12 -

第4章 汇编... - 13 -

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

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

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

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

4.5 本章小结... - 16 -

第5章 链接... - 17 -

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

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

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

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

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

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

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

5.8 本章小结... - 24 -

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

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

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

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

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

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

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

6.6.1异常分类... - 27 -

6.6.2异常处理... - 27 -

6.6.3程序运行及结果... - 28 -

6.7本章小结... - 30 -

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

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

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

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

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

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

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

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

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

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

7.10本章小结... - 35 -

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

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

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

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

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

8.5本章小结... - 37 -

结论... - 39 -

附件... - 41 -

参考文献... - 42 -

第1章 概述

1.1 Hello简介

程序员利用高级C语言使用键盘鼠标等I/O设备编写hello.c,经过一系列处理过程,最终变成计算机可以理解的语言。下面分别从P2P和020两个角度看这个过程。

P2P,即From Program to Process,从程序到进程,主要经过四个阶段。源C程序hello.c经过预处理器(cpp)处理,变成hello.i;经过编译器(ccl)转变为汇编代码hello.s,此时仍为文本文件;再经过汇编器(as)变成可重定位的目标二进制文件hello.o;随后通过链接器(ld) 将目标文件与所需的库文件链接起来,变成可执行文件hello.out(Linux中.out省略,直接为hello)。

020,即从零开始到零结束。初始时hello文件相关内容不在内存中,用户在shell中输入执行命令,shell解析命令以后调用fork创建子进程,然后子进程通过execve系统调用加载hello程序。execve将虚拟内存映射到物理内存,加载程序的各个段(如代码段、数据段、堆和栈)。即使hello程序很短,但过程中还是会出现很多缺页故障,处理完缺页故障后hello被一步步载入物理内存中,分配完物理内存,然后开始执行main函数中的代码。程序运行结束,shell作为父进程将其回收,内核删除相关的数据结构,释放之前被占据的资源,又重新回到0。

1.2 环境与工具

硬件环境:

处理:12th Gen Intel(R) Core(TM) i5-12500H   2.50 GHz

   机带RAM:16.0 GB

系统类型:64位操作系统,基于x64的处理器

软件环境:

      Windows11 64位;Vmware 17.0;Ubuntu 22.04;

开发与调试工具:

      Visual Studio 2022 64位;CodeBlocks 64位;vi/vim/gedit+gcc/edb/objdump

1.3 中间结果

hello.i:cpp预处理后得到的文本文件;

hello.s:cc1编译后得到的汇编语言文件;

hello.o:as汇编后得到的可重定位目标文件;

hello:ld链接得到的可执行文件;

hello.txt:objdump反汇编hello.o得到的反汇编文件;

hello_ld.txt:objdump反汇编hello得到的反汇编文件;

1.4 本章小结

本章从P2P和020两个角度对hello程序的生命历程做了一个简单介绍,还介绍了该实验的软硬件环境和开发调试工具,最后列出了过程中间结果的各个文件及其介绍。

第2章 预处理

2.1 预处理的概念与作用

预处理是编译过程中的第一个阶段,由预处理器(cpp)来完成,它在编译器处理源代码之前,对源代码进行初步处理。预处理的任务主要是处理以“#”开头的预处理指令(如宏定义、文件包含、条件编译等)。

预处理可以进行宏定义与替换,即使用#define指令定义宏,在预处理阶段,宏名会被替换为宏定义的内容;可以进行文件包含,即使用#include指令包含头文件,预处理器会将头文件的内容插入到包含指令的位置;也可控制条件编译,使用#ifdef、#ifndef、#endif等指令进行条件编译,控制哪些代码块被编译。通过上述这些方面,预处理为后续编译阶段做好准备。

2.2在Ubuntu下预处理的命令

使用gcc -E hello.c -o hello.i对hello.c进行预处理。

图2.2-1 预处理命令执行与结果

2.3 Hello的预处理结果解析

由图2.2-1可以看到,第一行# 0 “hello.c”表明接下来的代码来自源文件hello.c,是预处理器的行控制指令,用于跟踪源代码位置。# 1 “headerfile.h” 表示正在包含指定的头文件,并且这个头文件是第一次被包含。例如,# 1“/usr/include/stdio.h” 1 3 4,表明正在第一次包含stdio.h,1 3 4是预处理器特定的标记。

观察hello.i可知预处理时删除了源程序中的注释,并且main函数在hello.i文件的最下方,如下图2.3-1所示:

图2.3-1 预处理结果

2.4 本章小结

本章介绍了进行预处理的命令与过程,包含头文件包含、宏定义替换和条件编译等,比如将#include的文件替换为实际内容,只进行简单替换但不处理,方便后续编译。

第3章 编译

3.1 编译的概念与作用

编译是将高级语言程序翻译成低级语言(通常是汇编语言或机器语言)的过程,这里是指从 hello.i文件到 hello.s文件即预处理后的文件到生成汇编语言程序的过程。

编译将高级语言转换为计算机更易理解的低级语言,使得相同的高级语言程序可以在不同的操作系统和硬件平台上运行,提高了程序的执行效率、可移植性和可维护性。

3.2 在Ubuntu下编译的命令

使用gcc -S hello.i -o hello.s对hello.i进行编译,将其转换为汇编代码。

图3.2-1 编译命令执行与结果

3.3 Hello的编译结果解析

3.3.1初始部分

.file "hello.c"表明这是从名为hello.c的源文件编译来的。.text指示编译器将接下来的代码放在程序的代码段中。.section和.rodata指示编译器将接下来的内容放在只读数据段中,通常用于存储字符串和常量。.align 8指示编译器对接下来的数据进行8字节对齐,可以提高数据访问的效率。再次使用.text指示后面的代码属于可执行指令部分。.globl main指示main函数是一个全局符号,其他文件可以引用它。.type main, @function指示main是一个函数。

图3.3-1 汇编初始部分

3.3.2数据部分

(1).LC0和.LC1用于标记字符串常量(比如我们的学号、姓名、电话号码)的位置。标签之后的.string指示将后面的字符串存储为一个字符串常量。

图3.3-2 字符串常量和整数常量

(2)argc的值(存储在%edi中)保存到栈中位置-20(%rbp)。argv的值(存储在%rsi中)保存到栈中位置-32(%rbp),然后程序比较%rbp-20(%edi)这个位置的值,即argc是否是5,此处与源程序中图3.3-4所示语句对应。

图3.3-3 argc

图3.3-4 源程序部分C代码

(3)局部变量

源程序中局部变量只有循环变量i,阅读汇编代码可知i是被放在了栈上$rbp-4的位置。

图3.3-5 局部变量i

3.3.3赋值操作

for循环中将i赋成0,汇编代码中使用mov指令赋值,将常量0赋值给局部变量-4(%rbp),同上图3.3-5。

3.3.4类型转换

atoi函数将字符串转换为整数,方便为sleep函数提供参数。

图3.3-6 类型转换

3.3.5算数操作

for循环单次循环结束之后将i++,即将i+1赋给i。addl指令将局部变量-4(%rbp)也就是i加1。

图3.3-7 算术操作i++

3.3.6关系操作

源C程序中存在两个关系操作,分别为if条件判断语句中argc!=5以及for循环中i<10。

argc!=5:比较-20(%rbp)也就是argc的值与5,若相等则跳到.L2,否则执行下面的语句。

图3.3-8 argc!=5对应汇编

i<10:比较i与9,当i < =9的时候,程序跳转到.L4,程序继续,否则程序执行call getchar等操作,退出循环。

图3.3-9 i<10对应汇编

3.3.7控制转移

根据条件码来进行控制转移,同上述3.3.6。

3.3.8函数操作

call指令调用函数,参数通过寄存器传递。main函数的返回值存储在寄存器%eax中。如下图3.3-10所示,使用call调用printf,atoi,sleep等函数。

图3.3-10 函数调用示例

3.3.9结尾部分

.LFE6部分是一些关于编译器gcc的版本信息、栈的不可执行信息、程序的特定属性信息等。

图3.3-11 汇编结尾部分

3.4 本章小结

本章介绍了编译的概念、作用及其在Ubuntu下的具体操作流程,具体分析了编译结果,包括初始部分、数据部分、赋值操作、类型转换、算术操作、关系操作、控制转移、函数操作以及结尾部分等多方面,深入理解了汇编代码的结构和各类操作的实现细节。

第4章 汇编

4.1 汇编的概念与作用

汇编语言仍然是机器无法理解的语言,因此,汇编要使用汇编器(as)将包含汇编语言的hello.s文件翻译为二进制机器语言,生成可重定位目标文件的格式,即hello.o文件。

汇编使得汇编语言转换成机器可以直接识别的二进制语言。

4.2 在Ubuntu下汇编的命令

使用gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o命令,生成可重定位目标文件hello.o

4.2-1 编译指令及结果

4.3 可重定位目标elf格式

使用readelf -a hello.o命令读取hello.o文件内容。根据教材,elf文件[1]一般包含文件头(记载机器文件的状态信息),.rodata(只读节),.data(已初始化的全局和静态C变量),.bss(未初始化的全局或静态变量或定义为0的数据),.text(代码节),.symtab(符号表,有汇编器产生,记载所有的重定向函数,全局变量),.reltext/.reldata(需要重定向的代码和数据节),.debug,.line.strtab(字符串表,包括.symtab和.debug中的符号表)。

ELF头信息如下:

图4.3-1 可重定位目标文件ELF头

节头信息如下:

图4.3-2 可重定位目标文件节头信息

重定位信息如下:

图4.3-3 可重定位目标文件重定位信息

任意函数调用都需进行重定位,符号名称包括内置函数和调用的某些函数,后面的加数是偏移量。比如4.3-3第5个条目,表示在代码段中的某个位置,有一个指向printf函数的32位相对引用,需要在链接时进行重定位,并且偏移量需要调整为-4。

符号表如下:

图4.3-4 可重定位目标文件Symtab

符号表是多个条目组成的数组,存放程序定义和引用的全局变量和函数的信息。

4.4 Hello.o的结果解析

使用objdump -d -r hello.o > hello.txt,反汇编hello.o并输出为hello.txt。

图4.4-1 反汇编指令执行及结果

对比hello.s和本章生成的hello.txt,代码变化不大,但hello.txt中每条指令前都有都有对应指令的机器码(16进制表示)。

并且hello.s中,分支转移都是通过.L2之类的助记符实现的,但在hello.txt中都变成了相对main函数的偏移量。hello.txt是反汇编得到的,内里所有符号都换成了它们在内存中的实际地址,因此,跳转(jump)和调用(call)的目标位置直接以地址或相对于某个基准点(如main函数)的偏移量来表示。

4.5 本章小结

本章介绍了汇编,即将.s文件通过汇编器as生成二进制.o文件(elf格式),研究了elf文件的主要组成,重点讨论了重定位节。然后通过objdump反汇编,观察了反汇编.o文件和.s文件的区别,进一步了解了汇编。

5章 链接

5.1 链接的概念与作用

链接(Link)就是一个“打包”的过程,它将所有二进制形式的可重定位目标文件(也就是.o文件)和系统组件组合成一个可执行文件[2]。链接的过程是链接器(Linker)来完成的,可以在编译时、加载时、执行时执行链接,主要分为符号解析和重定位两个步骤

编译器针对一个源文件[3],每个源文件编译一次,而链接将.o文件和标准库、动态链接库等结合起来。链接使得我们无需将一个大应用组织成一个庞大的源文件,而是多个易于管理的小模块,每次修改时无需重新编译全部文件,只需单独重新编译被修改的模块并重新链接即可,简单快捷。

5.2 在Ubuntu下链接的命令

使用如下命令进行链接,得到可执行文件hello,如下图所示:

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.2-1 链接命令执行及结果

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

使用readelf -a hello命令查看hello的ELF格式。

ELF头信息如下:

图5.3-1 可执行文件ELF头

接下来是节头表,描述各个节的大小、偏移量和其他属性,与图4.3-2可重定位目标文件的节头表对比可知该ELF增加了一些新的项,如.interp、.init等。

图5.3-2 可执行文件节头表部分截图

程序头表信息如下。程序头表包含多个程序头,每个程序头描述一个段或其他需要操作系统处理的信息,每个程序头包含以下信息:Type:段的类型;Offset:段在文件中的偏移;VirtAddr:段在内存中的虚拟地址;PhysAddr:段在内存中的物理地址(对于系统加载程序,此字段通常未使用);FileSiz:段在文件中的大小;MemSiz:段在内存中的大小;Flags:段的标志(如读、写、执行权限);Align:段在文件和内存中的对齐方式。例如第二个条目INTERP,表示程序解释器段,指示要使用的动态链接器。在文件偏移0x2e0处,加载到内存地址0x4002e0。段大小为0x1c字节,对齐为1字节。

图5.3-3 可执行文件程序头表

同样,可执行文件ELF头还包含符号表等信息,此处不再一一赘述。

5.4 hello的虚拟地址空间  

使用edb加载hello,发现开始地址为0x400000,如下图5.4-1。

图5.4-1 hello的虚拟地址空间

将使用edb的结果与5.3节使用readelf查看的结果对比发现两者虚拟内存空间分布相同,如fini段都在0x4011c8。

图5.4-2 对比分析

5.5 链接的重定位过程分析

使用objdump -d -r hello > hello_ld.txt 命令将链接后的可执行文件hello反汇编,并将结果输出到hello_ld.txt中。

图5.5-1 反汇编可执行文件hello

将hello.o的反汇编hello.txt与hello的反汇编hello_ld.txt对比,如图28所示,可以发现hello_ld.txt中多了.plt,.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数对应的代码,这是因为动态链接器将程序要用到的函数加入到了可执行文件中。

图5.5-2 hello_ld.txt与hello.txt对比

此外,函数调用方式也发生了改变。下图5.5-3左侧为hello_ld.txt中的内容,发现使用plt表来进行跳转调用,右侧为hello.txt中的内容,函数调用则是采用<main+offset>的方式。

图5.5-3 函数调用方式对比

链接分为符号解析和重定位两个步骤。

符号解析时链接器确定每个符号(如变量和函数)的定义和引用之间的关系的过程。在这一阶段,链接器从所有输入文件中收集所有的符号定义和符号引用;为每个符号引用找到相应的符号定义。

重定位将目标文件中相对地址转换为可执行文件中绝对地址,比如将每个输入文件中的段(如代码段、数据段)按照一定的规则排列,并分配内存地址;根据段的分配,更新符号表中每个符号的地址;根据符号的新地址,修改目标文件中的代码和数据,使得它们在新的内存布局中能够正确运行。

最后,链接器会进行一些合并和优化工作,比如删除冗余段、合并相同段、 优化指令等。

5.6 hello的执行流程

使用edb调试工具,endbr64指示这是函数的开始[4],每次跳转都会出现。

首先依次调用_start, _libc_start_main,进行一些初始化操作。运行,程序进入main函数。虽然由于已经链接过的程序没有明确的函数名标识,但是通过查符号表可知0x401125为main函数的位置。

图5.6-1 函数开始执行时

接下来继续单步执行,发现下一个call会跳转到0x401090,观察可知此处为PLT(Procedure Linkage Table)表。

图5.6-2 PLT表

之后根据TLB跳转到0x401030处,查符号表知此处为puts函数。

图5.6-3 跳转到puts函数

接下来继续单步执行,发现程序会调用不同子程序,地址如下图所示:

图5.6-4 子程序名及地址

5.7 Hello的动态链接分析

在Hello程序的加载过程中,系统会自动加载所需的动态链接库,如ld-2.31.so,程序中可能会引用这些动态链接库中的符号(如函数调用)。这种机制是通过PLT(Procedure Linkage Table)和GOT(Global Offset Table)实现的。每个条目对应动态链接库中的一个符号引用。我们可以使用readelf查看节表,了解PLT和GOT的信息。下图是节表中PLT和GOT的信息,可以得知PLT表地址是0x401020,GOT表的地址是0x403ff0。

图5.7-1 PLT

图5.7-2 GOT

在程序一开始,先执行_dl_start和_dl_init,_dl_init能够修改PLT和GOT,这一过程相当于“注册”动态链接库的符号,使得hello在后面的正常运行中能够引用它们(实现诸如间接跳转等行为)。我们查看你0x404000处的内容,发现调用_dl_init前后该位置的内容发生了变化。

图5.7-3 调用_dl_init前0x404000

图5.7-4 调用_dl_init后0x404000

5.8 本章小结

本章主要介绍了链接的概念及其在生成可执行文件中的关键作用。通过详细的步骤解析,我们了解了在Ubuntu下如何使用命令行工具进行链接,并探讨了ELF文件格式和虚拟地址空间的分布情况。此外,通过edb调试工具,我们深入分析了hello程序的执行流程和动态链接过程,验证了PLT和GOT的工作机制。通过实际操作和对比分析,进一步巩固了对符号解析、重定位及动态链接的理解,为后续的程序调试和优化打下了坚实基础。

6章 hello进程管理

6.1 进程的概念与作用

进程是操作系统中程序的一次执行实例。它是一个独立的运行环境,包括程序代码、数据、文件描述符、栈、寄存器、以及程序计数器等信息。每个进程在操作系统中有唯一的进程ID(PID),并且在多任务操作系统中,进程是资源分配和调度的基本单位。

进程提供了一个独立的执行环境,使程序能够运行,并拥有独立的资源,如打开的文件、环境变量、当前工作目录等,使得我们的程序好像是系统中当前运行的唯一的程序,给我们独占CPU和内存的假象。每个进程在自己的虚拟地址空间中运行,这种隔离保护了进程间的内存不被其他进程干扰,提升了系统的稳定性和安全性。

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

Shell是一个交互型应用级程序,尤其是Bash(Bourne Again Shell),作为命令行接口,接收用户输入的命令并加以解释,构造传递给 execve 的 argv 向量,然后传递给操作系统内核执行。作为脚本执行器,Shell不仅能执行单条命令,还能运行包含多条命令的脚本,支持复杂的逻辑操作(如循环、条件判断等)。Shell还提供了对进程的启动、暂停、终止等控制功能,使用户能够管理和调度系统资源。同时,Shell具备强大的文件处理能力,用户可以通过Shell命令进行文件的创建、删除、移动、复制等操作。

当用户打开一个终端窗口或通过SSH连接到远程服务器时,系统会启动一个Shell进程,通常是Bash。Shell准备好接受用户输入后,会显示一个提示符(如Ubuntu中的$),用户在提示符后输入命令并按回车键,Shell会读取这条命令并解析,将输入分解为命令名和参数,并检查命令是否存在及其执行权限。然后判断命令是否为内置命令,若是,Shell会直接执行;否则,如果命令是外部程序,Shell会搜索系统路径(由$PATH环境变量指定)找到该程序并启动一个子进程来执行。如果用户没要求后台运行(命令末尾没有&号),Shell 使用 waitpid(或 wait...)等待作业终止后返回;如果用户要求后台运行(如果命令末尾有&号),则Shell等待命令执行完成后收集执行结果(返回码、输出信息等),将其返回给用户,显示在终端上。如果是脚本执行,Shell会继续执行脚本中的下一条命令,直到脚本结束。

6.3 Hello的fork进程创建过程

用户输入 ./hello 2022112476 凌佳 15053962361 4,Shell判断出该命令不是内置命令,于是父进程调用fork函数创建一个新的子进程,该子进程与父进程PID不同、进程空间独立,但剩下的包括内存副本完全一样。fork在父进程中返回子进程的PID,在子进程中返回0,以此辨别是父进程还是子进程。

6.4 Hello的execve过程

调用fork函数创建完新的子进程之后,子进程中调用 execve,以便加载并执行新的程序。根据《深入理解计算机系统》,execve函数包含以下四个步骤:I)删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。II)映射私有区域。为新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。III)映射共享区域。包括加载共享库、建立共享内存、设置共享库的重定位表等步骤,确保了新程序能够正确使用共享库和共享内存。VI)设置程序计数器(PC),跳转到_start开始执行。

6.5 Hello的进程执行

进程调度决定在任何给定时间由哪个进程使用 CPU。调度的过程涉及进程上下文切换、时间片分配、用户态与核心态转换等多个方面。

6.4中子进程创建完毕后等待 CPU 调度,调度器分配时间片给hello进程,进程从程序的_start入口点开始运行。程序运行过程中进行系统调用,此时要从用户态切换到内核态,操作完成之后要返回到用户态继续执行程序,如下图6.5-1所示。

图6.5-1 进程的上下文切换

如果 hello 进程在时间片耗尽前没有完成,调度器会将其挂起,将 CPU 分配给其他就绪进程。进程的上下文信息(包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表等信息)被保存,以便下次恢复执行。hello 程序执行完毕后,调用 exit 进入核心态,操作系统进行资源清理(释放内存、关闭文件等),然后将进程状态标记为终止。父进程(Shell)通过 wait 系统调用获取子进程的终止状态。

6.6 hello的异常与信号处理

6.6.1异常分类

异常分为中断、陷阱、故障、终止。

中断(Interrupt)是由硬件引起的异步事件,例如 I/O 完成中断。中断通常由外部设备(如键盘、硬盘)产生,通过 CPU 的中断线发送到处理器。例如SIGINT信号(由Ctrl-C引起)。

陷阱(Trap)是由用户程序引起的同步事件,例如系统调用、断点指令。陷阱是由程序中的特殊指令触发的,用于请求操作系统服务或调试程序。例如SIGTRAP(调试陷阱)信号。

故障(Fault)是潜在可恢复的错误,例如缺页故障。当 CPU 发现指令或数据访问异常时,会触发故障异常,操作系统可以尝试恢复。例如SIGSEGV(段错误)信号。

终止(Abort)是不可恢复的致命错误,例如除零错误。终止异常通常是由于程序的严重错误导致的,操作系统会终止进程。例如SIGABRT(异常终止)信号。

6.6.2异常处理

由《深入理解计算机系统》可知上述四种异常的几种处理如下:

图6.6-1中断处理

图6.6-2 陷阱处理

图6.6-3 故障处理

图6.6-4 终止处理

6.6.3程序运行及结果

正常运行:

图6.6-5 正常运行

在运行过程中按下Ctrl-C:

图6.6-6 Ctrl-C

在运行过程中按下Ctrl-Z,发现是将进程暂停挂起,但没有终止:

图6.6-7 Ctrl-Z

Ctrl-Z后使用ps命令查看当前进程:

图6.6-8 ps

使用jobs查看当前作业:

图6.6-9 jobs

使用pstree,以树形结构显示进程的父子关系:

图6.6-10 pstree

使用fg将暂停的任务恢复到前台继续运行:

图6.6-11 fg

不停乱按,发现键盘内容显示在终端上,但程序正常输出:

图6.6-12 不停乱按

使用kill命令杀死指定子进程:

图6.6-13 kill

6.7本章小结

本章主要介绍了进程的概念与作用,分析了hello程序的进程由shell创建、执行到回收的过程,还介绍了几种异常,以及他们对应的异常处理程序。还亲手实践了一下各种命令,使我们对进程、信号、异常这部分的内容有了更加深入的了解。

7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址是程序在编译时生成的、从应用程序角度看到的内存单元或存储单元地址。它是相对于某个段基地址的偏移量。在x86架构中,逻辑地址由段选择符和段内偏移组成。在hello程序的汇编代码中,指令和数据的地址是逻辑地址。这些地址由编译器生成,并由链接器处理。

线性地址是由段选择符和段内偏移通过段寄存器和段描述符表转换而来的地址。它是逻辑地址经过段机制转换后的结果。在hello程序运行时,如果某个指令需要访问内存中的数据,CPU首先将逻辑地址转换为线性地址。假设main函数的逻辑地址是0x401000,经过段选择和段基址的加成,可能得到的线性地址仍然是0x401000,但在某些情况下可能会有所不同,具体取决于段基址。

虚拟地址是由线性地址经过页表映射后得到的地址。虚拟地址空间是操作系统为每个进程提供的独立地址空间,允许每个进程认为自己占用整个内存。hello程序在运行时被操作系统加载到内存中,假设main函数的逻辑地址是0x401000,对应的线性地址也是0x401000。操作系统通过页表将这个线性地址映射到某个虚拟地址空间。对于hello程序而言,0x401000是它的虚拟地址。

物理地址是最终实际的内存地址,与内存条上的具体存储位置相对应,CPU通过内存控制器访问内存芯片的地址。虚拟地址通过页表映射到物理地址。

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

段式管理是将逻辑地址转换为线性地址的机制。逻辑地址由段选择符和段内偏移组成,段选择符通过段描述符表(GDT 或 LDT)指向一个段描述符。段描述符包含段的基址、段界限和访问权限等信息。

首先解析逻辑地址,获取段选择符和段内偏移;然后查找段描述符,根据段选择符索引从 GDT 或 LDT 中查找段描述符;最后将段基址与段内偏移相加,得到线性地址。

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

现代操作系统中内存管理通常采用分页机制。分页机制将线性地址转换为物理地址,确保进程可以使用虚拟内存而无需关心实际的物理内存布局。

首先解析线性地址,获取 PDE、PTE 和 Offset;然后访问页目录表:通过 PDE 获取页表基址;再访问页表,通过 PTE 获取物理页框地址;最后将物理页框地址与 Offset 相加,得到物理地址。过程中如果缺页,就要执行缺页处理程序,需要系统从磁盘中读相应数据。下图为页式管理示意图(摘自《深入理解计算机系统》)。

图7.3-1 页式管理示意图

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

Intel Core i7处理器采用了四级页表的分层结构来管理虚拟地址到物理地址的转换过程。当 CPU 生成一个虚拟地址 (VA) 时,该地址首先被传送给内存管理单元 (MMU)。MMU 利用虚拟地址的虚拟页号 (VPN) 的高位部分作为翻译后备缓冲区 (TLB) 的标签 (TLBT) 和索引 (TLBI),在 TLB 中查找匹配项。

如果 TLB 中存在匹配项,MMU 直接从 TLB 获取相应的物理地址 (PA)。这种方式可以大幅加快地址转换速度,因为它避免了访问多级页表的开销。然而,如果 TLB 中没有找到匹配项(即发生 TLB 未命中),MMU 必须查询页表来完成地址转换。在页表查询过程中,MMU 利用四级页表结构逐级查找对应的页表项。一旦 MMU 从第四级页表项中获取到物理页号 (PPN),它将虚拟地址的页内偏移量 (VPO) 与物理页号组合在一起,生成完整的物理地址 (PA)。这个物理地址被用于实际的内存访问。

图 7.4-1 Intel Core i7地址翻译的概况

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

高速缓存是存储在处理器内部的高速存储器(SRAM),用于临时存储频繁访问的数据和指令,以减少对主内存(DRAM)的访问延迟。当CPU需要读取或写入数据时,它首先生成一个内存访问请求,经地址翻译可生成物理地址(PA)。

高速缓存机制将m个地址位划分成了t个标记位,s个组索引位和b位块内偏移,当选中的组存在一行有效位为1,且标记位与地址中的标记位相匹配,我们就得到了一个缓存命中,否则就称为缓存不命中。然后依次检查三级Cache是否命中,如果数据在L3缓存中仍然未命中,则内存请求最终会被传递到主内存。此时数据从主内存中读取并传送到CPU,同时该数据也会被缓存到L3、L2、L1缓存中,以备后续访问时加快速度。

7.6 hello进程fork时的内存映射

fork 系统调用会创建一个子进程,该子进程是父进程的一个副本。子进程拥有与父进程相同的虚拟内存空间布局。所有的代码段、数据段、堆、栈以及内存映射文件在子进程中与父进程具有相同的起始地址和大小。

为了提高效率,现代操作系统通常使用写时复制技术(Copy-On-Write, COW)。在初始阶段,父进程和子进程共享相同的物理内存页。这些页被标记为只读。如果父进程或子进程尝试写入共享页,操作系统会在执行写入操作之前为该页创建一个新的物理副本。这种机制避免了不必要的内存复制,从而提高了性能。

同时操作系统为子进程创建新的页表。页表中的每个条目指向的物理页与父进程相同,并且这些页被标记为只读。也就是在 fork 之后,子进程的页表指向与父进程相同的物理内存地址。

除了代码段和数据段,使用 mmap 映射的文件区域通常在父进程和子进程中共享。父进程和子进程若使用了共享内存区域,这些区域在 fork 之后仍然是共享的。

7.7 hello进程execve时的内存映射

由csapp教材[1],execve需要以下几个步骤:

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

(2)映射私有区域:为新程序的代码、数据、.bss和栈区域创建新的区域结构,这些区域都是私有的、采用写时复制机制。代码和数据区域分别映射到hello文件中的.text和.data段,.bss段是请求零初始化的,映射到匿名内存,其大小在hello中定义,栈和堆也是请求零初始化的,初始长度为零。

(3)映射共享区域:hello程序与共享库libc.so链接,libc.so作为动态链接库被映射到用户虚拟地址空间中的共享区域。

(4)设置程序计数器:execve做的最后一件事情是将当前进程上下文的程序计数器设置为代码区域的入口点位置。

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

缺页故障指要用到的页不在物理内存中,程序第一次运行时通常都会发生冷不命中。具体流程如下:

(1) 处理器生成一个虚拟地址,并将其传送给 MMU;

(2) MMU 生成 PTE 地址(PTEA),并从高速缓存/主存请求得到 PTE;

(3) 高速缓存/主存向 MMU 返回 PTE;

(4) PTE 的有效位为零, 因此 MMU 触发缺页异常;

(5) 缺页处理程序确定物理内存中的牺牲页(若页面被修改,则换出到磁盘);

(6)缺页处理程序调入新的页面,并更新内存中的 PTE;

(7)缺页处理程序返回到原来进程,再次执行导致缺页的指令。

流程图如下所示:

图7.8-1 缺页处理流程

7.9动态存储分配管理

动态内存管理是指在程序运行时根据需要分配和释放内存空间。堆其实就是程序虚拟地址空间的一块连续的线性区域,它由低地址向高地址方向增长,可以提供动态分配的内存,主要通过 malloc、calloc 和 realloc 等函数在堆上分配内存,使用 free 函数释放不再需要的内存。

使用时要注意及时释放内存以避免内存泄漏,采取措施减少内存碎片化,以及检查内存溢出问题。这些方法和策略有助于提高程序的性能和可靠性。

7.10本章小结

本章介绍了存储器地址空间及其管理机制,涵盖了逻辑地址、线性地址、虚拟地址和物理地址的概念及其转换过程,了解了逻辑地址到线性地址的段式管理方法,接着介绍了线性地址到物理地址的页式管理机制,并解释了四级页表和TLB在地址转换中的作用。

此外,本章还介绍了三级缓存的设计及其在内存访问中的应用,通过fork和execve系统调用解释了进程内存映射的变化及其机制,还详细讨论了缺页故障及其处理流程,以及动态内存管理的基本方法和策略。

8章 hello的IO管理

8.1 Linux的IO设备管理方法

在Linux系统中,所有设备都被抽象成文件。这种抽象使得设备的操作与普通文件的操作统一起来,提供了简单一致的接口。

Linux使用UNIX IO接口来进行设备管理。

8.2 简述Unix IO接口及其函数

Unix IO接口提供了一组系统调用,用于对文件和设备进行操作。以下是几个主要函数:

  1. open打开一个文件或设备,并返回一个文件描述符。

int open(const char *pathname, int flags, mode_t mode);

  1. close关闭文件描述符fd,释放相关资源。

int close(int fd);

  1. read从文件描述符fd读取最多count个字节的数据到缓冲区buf中。

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

  1. write将buf中最多count个字节的数据写入文件描述符fd。

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

  1. lseek重新定位文件描述符 fd 的读写位置。

off_t lseek(int fd, off_t offset, int whence);

  1. ioctl控制设备的特定操作。

int ioctl(int fd, unsigned long request, ...);

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;

}

vsprintf 是格式化字符串生成的核心函数,它根据格式说明符解析变长参数列表,并生成最终的格式化字符串。例如,格式说明符 %d 会从参数列表中获取一个整数,并将其转换为字符串表示。生成的格式化字符串需要通过系统调用 write 输出到标准输出设备,write 系统调用通过软中断或系统调用指令进入内核态执行,内核的显示驱动程序会通过这些字符串及其字体生成要显示的像素数据,将它们传到屏幕上对应区域的显示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 从标准输入读取数据,最多读取 BUFSIZ 字节,并返回下一个字符或 EOF 表示结束。它的实现简洁有效,适用于多种输入场景,如从键盘获取用户输入。

键盘中断处理子程序通常负责处理硬件层面的键盘输入,将按键扫描码转换为 ASCII 码,并保存到系统的键盘缓冲区。当调用 getchar 等函数时,实际上会调用系统调用 read,从键盘缓冲区读取 ASCII 码数据,直到接收到回车键(Enter)才返回。

8.5本章小结

Linux将所有设备都视为文件进行管理,这种抽象化使得对设备的操作与对文件的操作可以通过相同的接口进行,包括读取、写入、定位等操作。然后介绍了一些主要的Unix IO接口及其函数,并详细介绍了printf和getchar的实现。通过本章,我更加深入理解了Linux系统中文件和设备的操作,对于printf和getchar也不再只停留在使用,而是了解了更多原理。

结论

回顾hello的一生,首先是程序员编写了一个简单的程序 "hello.c",里面只有几行代码,输出 "Hello 2022112476 凌佳 15053962361" 到终端。处理过程如下:

预处理:编译器首先对 "hello.c" 进行预处理,包括展开头文件、宏替换等,生成了经过预处理的代码文件。

编译:预处理后的代码被编译器翻译成汇编语言,这是一种更接近计算机硬件的低级语言。

汇编:汇编器将汇编代码转换成机器语言,生成可执行的二进制文件。

链接:链接器将生成的二进制代码与必要的库函数链接,生成最终可执行文件 "hello"。

加载与执行:操作系统负责加载 "hello" 程序到内存中,并为其分配资源。在程序执行期间,操作系统通过进程管理功能控制其运行状态,包括分配时间片、处理信号等。

进程管理:程序在操作系统中作为一个进程运行,操作系统负责管理其生命周期,包括进程的创建(fork)、程序加载和执行(execve)、内存映射(mmap)等。在命令行中输入./hello 2022112476 凌佳 15053962361 4,bash进程调用fork函数,生成子进程;execve函数加载运行当前进程的上下文中加载并运行新程序hello。

存储管理:程序通过操作系统的存储管理功能,将虚拟地址(VA)转换为物理地址(PA),包括使用MMU、TLB、页表等进行地址映射,确保程序能够正常访问内存。

IO管理:程序通过操作系统提供的IO管理功能与外部设备进行交互,包括读写文件、处理键盘输入输出等。

硬件层面:程序在硬件层面利用CPU、内存(RAM)、IO设备(键盘、显示器等)进行运算和数据交换,操作系统通过硬件抽象层(HAL)使得不同硬件能够统一操作接口。

性能优化:系统设计与实现中,使用了缓存、页面置换、多级缓存等技术来优化程序的运行效率和资源利用率。

结束与回收:程序运行结束后,操作系统负责回收其占用的资源,包括内存和其他系统资源,确保系统的稳定性和资源的有效利用。

通过完成本次大作业涉及到的过程分析,我深刻体会到在计算机系统的设计与实现过程中,每个程序的诞生和运行背后,都有无数复杂的技术与系统支持。每一个程序都是计算机系统中的一个独特角色,无论大小,都是技术与创造力的结晶。

同时我还想到,在程序设计与实现中,应重视系统的整体性能与稳定性,探索新的设计与实现方法,如并行计算、分布式系统、高效算法等,以提升系统的处理能力和用户体验。

而作为计算机专业学生的我们,更要理解计算机系统的深层原理和多方面技术,如操作系统、编译器、硬件架构等,不断拓展技术广度,提升解决问题的能力。面对计算机领域快速的更新换代,我们应该持续学习新技术、探索新思路,始终保持创新的活力和进步的动力。

附件

hello.c          C语言源文件

hello.i          经过预处理的文件

hello.s          经过编译的文件

hello.o          经过汇编的文件

hello           可执行文件

hello.txt         hello.o的反汇编文件

hello_ld.txt      hello 的反汇编文件

图-附件

参考文献

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

[2]  链接器、链接过程及相关概念解析-CSDN博客

[3]  C语言编译和链接详解(通俗易懂,深入本质)

[4]  https://blog.csdn.net/clh14281055/article/details/117446588

  • 14
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值