哈尔滨工业大学 CSAPP 大作业

计算机系统

大作业

题     目  程序人生-Hello’s P2P 

专       业         信息安全      

学     号         2022111961    

班     级          2203201      

学       生          吕润东    

指 导 教 师           史先俊       

计算机科学与技术学院

2024年5月

摘  要

Hello是一个简单的程序,是初学者就可以读的懂的,也是每个程序员编程的开始。它具有着简单的外表,没有复杂的功能,却有丰富的内涵。本文以hello.c为载体,在Linux系统环境下,从其预处理、编译、链接、汇编、进程存储管理直到IO管理的整个过程进行分析;同时结合《深入理解计算机系统》一书,介绍了“Hello的一生”,即对其中涉及到与计算机系统相关知识和内容的部分进行分析,同时深入阐述过程中的一些机制。

关键词:hello,CSAPP,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简介

噼里啪啦的敲击,我们编织出一行行代码,这些代码汇聚成为C语言的源文件,今天的主角hello.c登场了!

这份源文件随后踏上了它的变身之旅,经历了预处理器cpp的洗礼,编译器cc1的雕琢,汇编器as的打磨,以及链接器ld的串联,最终蜕变为一个可在内存中加载并执行的可执行文件——hello。

在Shell的指令下,我们输入“./hello 2022111961 吕润东 3727307 1”,Shell利用fork函数孕育出一个新的进程,随后在子进程中,execve函数将hello程序嵌入到内存之中。虚拟内存系统通过mmap为hello进程划定了一片专属的虚拟空间,调度器则为它分配了执行的时间份额,确保它能够公平地与其他进程共享CPU和内存资源。至此,hello完成了从程序代码到进程的华丽转变,这就是原始的P2P(From Program to Process)。

CPU开始从hello的.text段一条条提取指令,寄存器的数值随着程序的推进而不断演算,异常处理机制时刻监控着键盘的动态。在hello内部,syscall系统调用触发了陷阱,将控制权交给了内核,write函数得以执行,将"Hello 2022111961 吕润东 3727307"这串字符传递给了屏幕I/O的映射文件。

映射文件解析传入的数据,提取视频存储器VRAM中的信息,最终在屏幕上绘制出一行行清晰的字符串。当hello程序履行完它的使命,Shell通过waitpid函数向内核发出信号,回收了hello进程的资源,hello进程随之消散。它走完了自己从无到有,再回归于无的一生,完美诠释了O2O(Out of Nothing, Into Nothing)的哲学。

1.2 环境与工具

硬件环境:

9th Gen Intel(R) Core(TM) i7-9750H   2.60 GHz

RAM 16.0 GB

NVIDIA GeForce GTX 1660 Ti

软件环境:

Windows 11 家庭中文版 22H2

VMware Workstation 16.0

Linux version 5.15.0-107-generic

Ubuntu 20.04.3

gcc version 9.4.0

调试工具:

GNU gdb 9.2

edb 1.0.0

1.3 中间结果

hello.c

Hello的C源文件

hello.i

Hello的C预处理文件

hello.s

Hello的汇编语言文件,由hello.i编译得到

hello.o

Hello的可重定位目标文件,由hello.s汇编得到

hello

Hello的可执行文件,由hello.o链接得到

hello.txt

objdump生成的关于hello.o的反汇编信息

hello_elf.txt

readelf生成的关于hello.o的ELF信息

hello_exe_elf.txt

readelf生成的关于hello的ELF信息

hello_exe.txt

objdump生成的关于hello的反汇编信息

1.4 本章小结

本章介绍了首先对hello从出现到消失做了简介,介绍了P2P和O2O,接着指明了本人在做实验时用到的工具和相应的环境,最后对产生的中间结果进行了描述。

第2章 预处理

2.1 预处理的概念与作用

2.1.1 预处理的概念

预处理,就是讲预处理器根据预处理器指令,修改C源程序的过程。C预处理器不是编译器的组成部分,但是它是编译过程中一个单独的步骤。能够对源程序. c文件中出现的以字符“#”开头的命令进行处理,我们将C预处理器叫做CPP。

2.1.2 预处理的作用

正如预处理的概念中描述,预处理器命令是以“#”开头的,对于不同的分类,下面逐一举例:

所有的预处理器命令都是以井号(#)开头,其大致有以下种类:

1、#define,#undef

定义宏,进行单纯的字符串替换,以及取消已定义的宏

2、#include

包含一个源代码文件,将包含文件的内容插入源代码中

3、#ifdef,#ifndef,#if,#else等

条件编译,根据条件的真假,选择性地编译随后的代码

4、#error

生成编译错误提示消息,并停止编译

5、#pragma      

设置编译器的状态,或者指示编译器完成特定动作

6、#endif:

用来结束一个块

7、#line:

用来修改编译器的行号计数器和/或文件名。

2.2在Ubuntu下预处理的命令

使用gcc -m64 hello.c -E -o hello.i命令进行预处理

过程如图所示

图2.2-1 在Ubuntu下预处理的过程

2.3 Hello的预处理结果解析

用文本编辑器打开预处理生成的hello.i文件,与源文件作对比。我们不难发现,变长了很多,源文件只有24行,而hello.i文件足足有3061行。

同时,我们发现,“hello.c”的main函数原封不动的保留在了hello.i文件的末尾。

图2.3-1 hello.i中的main函数

但是,源文件中的注释却不见了

图2.3-2 hello.i中无注释

同时我们可以发现, hello.i中没有hello.c的#include部分,取而代之的是相应的头文件的内容。

图2.3-3 hello.i中的原有的头文件

除了这三个在hello.c里显式引用的头文件外,hello.i中还包含了大量没有在源文件里直接引用的文件,也插入了文件中

图2.3-4 hello.i中被隐式插入的部分头文件信息

2.4 本章小结

本章对预处理的概念进行了介绍,并且举例说明了预处理器的几个分类,有#define #pragma等。接下来介绍了ubuntu下预处理的指令,通过指令得到了.i文件,并且对预处理结果(hello,i)和源文件(hello.c)进行对比分析,发现了长度以及内容上的不同。

第3章 编译

3.1 编译的概念与作用

3.1.1 编译的概念

编译指的是cc1将预处理好的.i文件翻译到.s文件的过程,形成一个汇编语言程序。

3.1.2 编译的作用

在编译的过程中,编译器cc1检查语法错误后进行翻译,将.i翻译成汇编语言,同时可以进行选择优化选项,做出一定优化。

3.2 在Ubuntu下编译的命令

使用以下命令进行编译:

gcc -m64 hello.i -S -o hello.s

图3.2-1 在Ubuntu下编译的命令

之后,本文使用gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC hello.i -S -o hello.s生成的结果进行分析。

3.3 Hello的编译结果解析

3.3.1 数据

1、整型常量

      在hello.c里出现的整形常量在hello.s中都有对应出现,下面一一举例。

      首先是argc与整型常量4的比较,其中的整型常量4以立即数$4的形式出现

 

图3.3.1-1.1 比较中的整型常量翻译为立即数

接着是exit(1)的调用,其中的整型常量1以立即数$1的形式出现

图3.3.1-1.2 传参中的整型常量翻译为立即数

接着是对整型变量i的赋值i=0,其中的整型常量0以立即数$0的形式出现

图3.3.1-1.3 赋值中的整型常量翻译为立即数

2、字符串常量

编译器将字符串常量存入了 .rodata节之中,下面具体分析。

图3.3.1-2.1 存储在 .rodata节的字符串常量

字符串实际上存储在某地址当中,当取出字符串时,需要加载字符串所在地址

图3.3.1-2.2 printf传参的字符串常量

图3.3.1-2.3 puts传参的字符串常量

3、局部变量

hello.c中仅有一个局部变量i。其被编译器翻译为了对寄存器 %ebp的相应操作。

图3.3.1-3.1  %ebp对应i的指令

3.3.2 赋值

赋值即上文提到的局部变量第一步,使用movl语句进行赋值。

图3.3.2 对局部变量i进行赋值

3.3.3 类型转换

这里以        sleep(atoi(argv[4]));   一句为例,由于atoi返回类型为int,但是sleep接收类型为unsigned int的参数,显然不匹配,这里发生了隐式类型转换。

但是通过代码分析,发现什么也没有做,只是call了atoi后就给edi又call sleep了。

图3.3.3  类型转换

3.3.4算术运算

hello.c中只有局部变量i自增的算术运算i++,到汇编为ebp的加1。

图3.3.4 算术运算

3.3.5 关系操作

关系操作,即大于小于等于不等于等等。在汇编时会变成comp或test,并且结合条件码寄存器和jg,je,jle等跳转指令进行操作,下面举例。

首先是进行不等比较,比较参数和5是否相等

图3.3.5-1 不等比较翻译为cmp + jne

还有对i和10的比较,在上文已经介绍过,这里体现在是否大于9

图3.3.5-2 小于比较翻译为cmp + jle

3.3.6 数组操作

对数组的操作被编译后体现在对地址的加减操作,一般是取首地址,然后+4,+8等等,这与数据结构有关。

这里介绍argv数组,源代码为

for(i=0;i<10;i++){

             printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);

             sleep(atoi(argv[4]));

      }

翻译成汇编代码后,可以理解为将%rbx的值作为argv数组的首地址,进行引用(间接引用)。同时,根据寄存器的顺序,rsi,rdx,rcx分别取出三个参数,这里的8,16,24是因为argv数组中元素的类型为char*,在64位中占8字节,故上述对%rbx的操作中的增量为8。

图3.3.6-2 hello.s中对数组argv的操作(从rsi到atoi)

3.3.7 控制转移

所谓控制转移,就是if、for、while、do while之类,下面对源代码中有的if和for进行分析。

3.3.7.1 if

图3.3.7-1.1 hello.s中的分支控制

分支控制流程图如下图所示

图3.3.7-1.2 hello.s中分支控制流程图

3.3.7.2 for

在hello.c中,for循环应用在重复执行打印操作,通过跳转来实现。

图3.3.7-2hello.s中的for循环

for循环的流程较长,主要为ebp先和9比大小,再执行指令,使ebp自增,再循环到L2开始处,自增后的ebp和9比大小。

3.3.8 函数操作

在hello.c中若不算循环的重复调用,共有六次函数调用,printf、exit、printf、atoi、sleep、getchar,下面逐一分析。

3.3.8.1 printf

这里的printf是if循环里的,在编译时被优化为puts,只有一个参数edi。

图3.3.8.1 puts

3.3.8.2 exit

exit紧跟上面的puts,只有一个参数edi。

图3.3.8.2 exit

3.3.8.3 printf

这里的printf是for循环里的printf,对数组中的元素进行输出。

这里参数1为rsi,参数2为rdx,参数3为rcx。

图3.3.8.3 printf

3.3.8.4 atoi

这里将字符串转化为int型。只有一个参数rdi,但对返回值进行了操作,给sleep。

图3.3.8.4 atoi

3.3.8.5 sleep

sleep对程序进行等待,同时传入上面atoi的参数

图3.3.8.5 sleep

3.3.8.6 getchar

使用getchar,清理缓冲区,不传参。

图3.3.8.6 getchar

3.4 本章小结

本章介绍了编译的概念和作用,作用为汇编并优化。

并且对hello.i进行了编译,使用了gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC hello.i -S -o hello.s命令。

最后对hello.s汇编代码进行了详细分析,从数据、赋值、类型转换、算术运算、关系操作、数组操作、控制转移、函数操作这几个角度进行分析。需要注意的是,源代码中没有体现到逻辑/位操作。

第4章 汇编

4.1 汇编的概念与作用

4.1.1 汇编的概念

汇编指的是通过as将.s文件翻译成.o可重定位文件的过程。其中.o是机器语言二进制程序,无法直接查看。具体到hello中,就是将hello.s变成hello.o。

4.1.2 汇编的作用

汇编将人尚能看懂,机器无法识别的汇编语言翻译为了人看不懂,但机器可以直接识别的机器语言,面向机器,面向执行。

4.2 在Ubuntu下汇编的命令

使用以下命令进行汇编:

as hello.s -o hello.o

图4.2 在Ubuntu下汇编的过程

4.3 可重定位目标elf格式

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

使用readelf -a hello.o命令,可在终端查看hello.o的ELF格式。也可以输出到一个txt文件里,可使用readelf -a hello.o > hello_elf.txt。

4.3.1 ELF

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

图4.3.1  hello.o的ELF头

4.3.2 节头部表

可以使用命令readelf -S hello.o查看,也存在在之前输出的文件里。

ELF文件中含有众多的节,这些节携带了ELF文件的所有信息。每一个节又对应有一个节头,节头组织在一起就构成节头部表。节头部表描述了 hello.o 中文件中各个节的语义,包括节的类型、位置和大小等信息。

图4.3.2  hello.o的节头表

4.3.3 重定位节

重定位是连接符号引用与符号定义的过程。例如,程序调用函数时,关联的调用指令必须在执行时将控制权转移到正确的目标地址。可重定位文件必须包含说明如何修改其节内容的信息。重定位节即包含了这些用于重定位的数据信息。

在这里重定位节有两个,分别是.rela.text和.rela.eh_frame。

图4.3.3  hello.o的重定位节

4.3.4 符号表

所谓符号表,就是一种用于语言翻译器(例如编译器和解释器)中的数据结构。在符号表中,程序源代码中的每个标识符都和它的声明或使用信息绑定在一起,比如其数据类型、作用域以及内存地址。

图4.3.4  hello.o的符号表

可以看到函数的对应,main,printf,atoi,sleep,puts(优化的printf),exit,getchar。

4.4 Hello.o的结果解析

objdump -d -r hello.o  分析hello.o的反汇编,这样会显示在终端上,也可以通过 > hello.txt 重定向至文本文件txt里。这里选择后者。

图4.4.0  hello.txt的展示

经过hello.txt与hello.s二者之间的对比,发现了以下的不同:

1、没有了汇编指示符

在hello.s中时常出现的汇编指示符.cfi_没有在hello.txt中出现。

图4.4.1  汇编指示符的消失

        2、操作数的进制不同

       hello.s中操作数是十进制的,而在反汇编中,操作数是十六进制的。

图4.4.2  操作数进制的不同

3、字符常量的表示方式不同

对于hello.s,引用字符常量时是通过$.LC来引用的,而反汇编中地址值全为0。

图4.4.3  字符常量的表示方式不同

4、分支转移寻址方式不同

对于hello.s的分支转移条件跳转,是通过直接跳转(jump类指令),而反汇编中则是相对寻址,即<main+0x ..>这样寻址。

图4.4.4  不同分支转移寻址方式的对比

5、函数调用不同

对于hello.s,call后面只跟函数名;对于反汇编,使用相对地址。

图4.4.4  函数调用不同对比

4.5 本章小结

本章首先介绍了汇编的概念与作用。汇编语言程序经过编译器转化为机器语言,并且打包为.o文件,这样使得人能看懂的代码变成了机器能看懂的代码。并且使用as hello.s -o hello.o命令,在Ubuntu下对hello.s进行汇编,得到了hello.o文件。

形成.o文件后,通过 readelf -a hello.o > hello_elf.txt命令,生成了elf的txt格式。对ELF格式从ELF头、节头部表、重定位头、符号表等四个内容进行了简单的介绍。

最后,通过objdump -d -r hello.o > hello.txt命令,得到了反汇编文件,并将其与hello.s进行对比,从汇编指示符、操作数进制、字符常量表示、分支转移寻址方式、函数调用等五个角度进行了不同之处的分析。

第5章 链接

5.1 链接的概念与作用

5.1.1 链接的概念

链接是编译过程的一个阶段,它涉及将编译器生成的一个或多个目标文件(object files),如hello.o,与程序所需的库文件(libraries)或其它目标文件结合起来,以生成一个单一的可执行文件(executable file),如hello。

5.1.2 链接的作用

1、整合代码:链接器(Linker)将多个目标文件中的代码和数据整合到一起。

2、解决外部引用:链接器处理程序中的外部符号引用,如函数调用和全局变量,确保它们在最终的可执行文件中得到正确的解析。

3、优化空间:通过链接,可以移除多个目标文件中重复的代码和数据,优化最终程序的大小。注意:这儿的链接是指从 hello.o 到hello生成过程。

5.2 在Ubuntu下链接的命令

使用ld的链接命令,命令如下

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 在Ubuntu下链接的过程

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

和上文生成hello.o的elf头一样,使用readelf -a hello > hello_exe_elf.txt命令,在文本编辑器中查看。

1、ELF头

图5.3-1  hello的ELF头

2、节头部表

图5.3-2  hello的节头部表

5.4 hello的虚拟地址空间

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

使用 edb --run hello 命令来打开hello,如图所示。

图5.4-1  在edb中加载hello

通过edb的Memory Regions可以查看每块内存区域的读写权限:

图5.4-2  读写权限

在edb中,我们可以通过Data Dump来调转到某个地址,从而查看原始数据。和之前头部表信息结合起来,就能看到指定的某个段的信息。

下面以.dynstr段为例,由节头部表,从0x400470开始长度为5c。

图5.4-3  在edb中查看.dynstr段

再以.rodata段为例,从0x402000开始,长度为0x48,如下图所示。

图5.4-4  在edb中查看.dynstr段

5.5 链接的重定位过程分析

这里还是采用objdump -d -r hello > hello_exe.txt,在文本编辑器中分析。

图5.5.0-1  查看反汇编代码命令

图5.5.0-2 hello_exe.txt展示

5.5.1分析hello与hello.o的不同

1、行数不同

在hello.txt中,只有47行,但是hello_exe.txt中,有186行。

图5.5.1-1 行数不同

2、内容不同

在hello.txt中,只有main函数,而hello_exe.txt中有包括_init,_start在内的更多函数。

图5.5.1-2 内容不同

3、基地址不同

在hello.txt里,地址是从0开始分配的,而在hello_exe.txt里,从401000开始,分配了虚拟地址。

图5.5.1-3 基地址不同

4、控制转移方式不同

在hello.txt中,控制转移是通过main+偏移量调用,且没有函数名,而在hello_exe.txt里,不但有,而且分配了虚拟地址

图5.5.1-4 控制转移方式不同

5、函数引用的方式不同

如图所示,地址从原来的全0变成了具体的地址。

图5.5.1-5 函数引用方式不同

5.5.2链接的过程

链接包含符号解析和重定位。

所谓符号解析,就是让每一个符号对应一个定义,对于本地符号,很简单,只需要一个一个对应即可;而对于全局符号,当编译器遇到一个不是在当前模块定义的符号时,会假设该符号时在其他某个模块中定义的,生成一个链接器符号表条目,并交给链接器处理,若到处都没有,则报错终止(触发异常)。

而重定位则是包括重定位节和符号定义、重定位节中的符号引用。

5.5.3重定位过程分析

这里以重定位R_X86_64_32为例。

方式也很简单。只要确定了该符号的虚拟地址,那么,对该符号的引用就是其虚拟地址。

图5.5.3 重定位过程分析

5.6 hello的执行流程

函数名包括:

_start

_libc_start_main

_cxa_atexit

_init

frame_dummy

register_tm_clones

main

下面进入分支流程

未正确输入命令行参数:

puts@plt

exit@plt

exit

正确输入命令行参数:

_printf_chk@plt

strtol@plt:

sleep@plt

进行重复

getc@plt

exit

5.7 Hello的动态链接分析

通过观察.got.plt节的变化,就能观察到动态链接的过程。

图5.7-1  节头部表中.got.plt节内容

由之前生成的节头部表,发现.got.plt段从404000开始,大小为0x48。

进行前后对比

前:

图5.7-2  运行前的.got.plt节内容

后:

图5.7-3  运行后的.got.plt节内容

可以发现,动态链接(运行前后),有一些变化,具体体现在404000一行的后半段和404010的前半段里。

5.8 本章小结

本章首先介绍了链接的概念和作用,链接主要包括符号解析和重定向,链接的功能是使一些.o文件和库文件结合起来形成可执行文件,或者宽泛的说,多个文件结合成一个文件。

接着,在Ubuntu下使用链接命令生成了可执行目标文件hello。

之后,使用readelf并输出到txt文件里,分析了各个节的详细信息。

再之后,用edb调试查看了虚拟地址,同时查看了各个节的起始位置和大小。

然后,通过objdump指令进行反汇编,并且输出到txt中得到了反汇编的文本文件hello_exe.txt,与先前的hello.txt进行了详细对比。

接着,分析了hello的执行流程。

最后,对hello进行了动态链接分析,通过分析.got.plt段在执行前后的变化,展示了动态链接的过程。

第6章 hello进程管理

6.1 进程的概念与作用

6.1.1 进程的概念

进程(Process)指的是在操作系统中正在执行的程序的实例。它是一个动态实体,代表了程序在执行时的一系列活动。

6.1.2 进程的作用

进程可以进行资源管理、程序执行、隔离、并发、模块化。

资源管理:进程提供了一种机制,使得操作系统可以有效地管理和分配系统资源,如CPU时间、内存、I/O设备等。

程序执行:进程是程序执行的载体,它允许程序在操作系统的控制下运行。

隔离性:每个进程拥有独立的地址空间,这为进程提供了隔离性,使得一个进程的执行不会干扰到其他进程。

并发性:进程允许多个程序同时运行,提高了系统资源的利用率和系统的吞吐量。

模块化:进程可以被视为系统资源的一个模块,有助于系统的模块化设计和维护。

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

6.2.1 Shell-bash的作用

Shell 是一个命令行解释器,它为用户提供了一个与操作系统交互的接口。Bash(Bourne Again SHell)是UNIX和Linux系统中广泛使用的Shell之一。Shell-bash有命令解释、文件操作、程序执行、管道和重定向、环境变量管理和权限管理等作用。其中命令解释比较重要,Shell 接受用户输入的命令,并将其转换为操作系统可以理解的格式执行。

6.2.2 Shell-bash的处理流程

处理流程如下:

启动:当用户打开一个终端窗口或启动一个新的Shell会话时,Bash Shell开始运行。

读取命令:Shell 等待用户输入命令。

命令解释:用户输入命令后,Shell 解析命令及其参数。

执行命令:

如果是内置命令,Shell 直接执行。

如果是外部命令,Shell 在系统的可执行文件路径中搜索命令,并执行。

环境设置:Shell 根据用户的配置文件(如.bashrc、.bash_profile)设置环境变量和Shell选项。

错误处理:如果命令执行失败,Shell 会显示错误信息,并等待用户输入新的命令。

管道和重定向:Shell 支持将一个命令的输出作为另一个命令的输入(管道),以及将输出重定向到文件或从文件重定向输入。

脚本执行:如果用户运行一个Shell脚本,Shell 会读取脚本文件并顺序执行其中的命令。

历史和自动补全:Shell 维护命令历史,支持历史命令的快速检索和自动补全功能。

退出:用户可以通过输入exit命令或关闭终端窗口来结束Shell会话。

6.3 Hello的fork进程创建过程

首先介绍fork进程的概念。fork() 系统调用用于创建一个新的进程,这个新进程是调用进程的一个副本。

当在终端输入命令 ./hello 时,Shell先判断这个参数是不是内置已有的命令,发现不是,则将其当做一个可执行程序的名字尝试着执行。

当Shell运行一个程序时,父进程(这里是shell)通过fork函数生成这个程序的进程(子进程)。这个子进程就是副本,在代码,数据段,堆,共享库以及用户栈等方面和父进程完全相同,并且和父进程共享文件。但是PID二者不同,这也是区分的方式。

6.4 Hello的execve过程

在父进程执行 fork() 函数后,父进程会继续执行它自己的任务,而子进程则会通过 execve() 加载并运行用户指定的程序,在这里就是hello。

execve() 函数负责加载并执行一个可执行的目标文件。通常情况下,execve() 一旦成功执行,它就会替换当前进程的映像为新程序的映像,并且不会返回到调用它的程序。只有当 execve() 调用过程中出现错误时,它才会返回错误信息给调用程序。

当 execve() 加载了 hello 程序之后,它会触发内核的启动代码。内核将负责将当前进程的上下文替换为 hello 程序的上下文,并将控制权转交给 hello 程序的入口点。

需要注意,execve() 仅仅是替换了当前进程的执行上下文,它并没有改变进程的 PID(进程标识符),也没有改变进程的父子关系。也就是说,子进程的 PID 保持不变,它仍然是原来的父进程的子进程。

简单来说,execve() 使得子进程获得了一个全新的身份和功能,但它在操作系统中的标识和层级关系保持原样。这种机制允许父进程在子进程执行完毕后,通过其 PID 来管理和通信。

6.5 Hello的进程执行

当操作系统创建 hello 进程时,它会为该进程分配一个时间片,这是进程能够使用CPU执行的时间单元。在多进程操作系统中,单个物理CPU的控制流被操作系统管理成多个逻辑控制流,这些逻辑流代表不同的进程,它们会交替获得CPU时间来执行。

这些进程的执行是并发的,它们在时间上是交错进行的。每个进程在它的时间片内执行一部分操作,这个过程称为时间片轮转。当一个进程的时间片用完,或者它执行了某些需要等待的操作(如 sleep 函数),或者操作系统决定给其他进程运行机会时,当前进程的执行会被暂停。

这时,操作系统会进行上下文切换。上下文切换是一个复杂的操作,它涉及以下步骤:

将当前进程的状态(包括程序计数器、寄存器等)保存到内存中,通常是进程控制块(PCB)。

从内核(操作系统的核心部分)中选择另一个就绪状态的进程来执行。

加载这个新进程的上下文到CPU中,包括恢复它的程序计数器和寄存器状态。

将控制权交给这个新进程,让它继续执行。

这个过程会不断重复,直到 hello 进程完成它的任务并结束运行。通过这种方式,操作系统能够高效地管理和调度多个进程,确保它们都能获得CPU时间来执行,从而实现多任务处理和资源的合理分配。

进程上下文恢复如图所示。

图6.5  上下文切换

6.6 hello的异常与信号处理

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

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

下面对./hello 2022111961 吕润东 3727307 1 进行执行,并且分类观察。

6.6.1 正常运行

在正常运行时,程序每隔一秒输出一次“2022111961 吕润东 3727307”,并且换行,一共输出十次。打印结束后,到达getchar()部分,等待用户输出后退出。

图6.6.1  正常运行的hello

6.6.2 不停乱按(除Ctrl-Z,Ctrl-C等)

不停乱按,按什么显示什么。程序还是按规定的时间正常执行,二者互不影响。

图6.6.2  被不停乱按的hello

6.6.3 Ctrl-C

按Ctrl-C之后,程序停止执行,输入Ctrl-C会发送 SIGINT 信号给Shell,再由Shell将信号转发给前台进程组中的所有进程,终止前台进程组。

图6.6.3-1  按Ctrl-C的hello

通过ps命令我们可以发现,进程确实消失了。

图6.6.3-2  ps

6.6.4 Ctrl-Z

按下 Ctrl-Z会导致 hello 进程以及与它在同一前台进程组中的所有进程进入挂起状态。Ctrl-Z 操作实际上会向当前的Shell发送 SIGTSTP,然后Shell将这个信号传播给其所管理的前台进程组中的所有成员。

但是这时使用ps还能看见hello进程。

简单来说,Ctrl-Z 就是一个告诉Shell和前台进程组“请暂停执行”的信号,使得可以在不终止进程的情况下,临时中止它们的运行。

图6.6.4-1  Ctrl-Z综合分析

我们可以发现,执行Ctrl-Z之后,仍有hello进程存在。

Ctrl-z后可以运行ps  jobs  pstree  fg  kill 等命令,ps命令。

首先展示ps命令,这里重新运行了一次,因此进程号与上次不同。

图6.6.4-2  ps命令

在挂起后,使用jobs命令可以看到jid和状态。

图6.6.4-3  job命令

挂起后,使用pstree命令可以看到继承关系,我们也可以发现hello进程的继承关系为systemd→systemd→gnome-terminal-→bash→hello,具体如图所示。

图6.6.4-4  pstree命令

图6.6.4-4  hello的继承关系

在挂起后,使用fg命令加之前在jobs得到的jid(1)可以让进程回到前台,继续执行。

图6.6.4-5  fg命令

为了演示kill功能,我们再次使用Ctrl-Z命令将运行暂停。使用kill -9 +PID号可以彻底杀死选中的PID号对应的进程,kill -9 4835向hello进程发送SIGKILL信号。具体展示如图。

图6.6.4-6  kill命令

6.7本章小结

在本章,我们首先介绍了进程的概念和作用,进程指的是在操作系统中正在执行的程序的实例;进程有资源管理、程序执行、隔离、并发、模块化等作用,同时介绍了Shell-bash的作用和处理流程。

接着,我们深入探讨了 hello 程序的创建和执行机制,这涉及到 fork() 和 execve() 这两个关键的系统调用。fork() 用于复制当前进程,创建一个新的子进程,而 execve() 则用于在新创建的子进程中加载并运行指定的程序,本例中即 hello 程序。同时,我们讨论了进程的上下文信息、时间片分配、以及用户态和核心态之间的转换,这些是进程正常运行的关键要素。

此外,我们还分析了在 hello 程序执行期间,用户如果随意按键,特别是 Ctrl-C 和 Ctrl-Z,这些操作会引发特定的信号,如 SIGINT 和 SIGTSTP,它们会导致进程挂起或终止。在 Ctrl-Z 导致 hello 进程挂起后,我们探讨了使用 ps、jobs、pstree、fg 和 kill 等命令来管理和控制进程的不同方式,这些命令帮助我们观察和处理进程的异常状态。

通过这种方式,我们不仅理解了 hello 程序的执行流程,还学习了如何在进程遇到异常信号时进行有效的管理和响应。

第7章 hello的存储管理

7.1 hello的存储器地址空间

这里存储器的地址空间主要分为逻辑地址、线性地址、虚拟地址和物理地址。

7.1.1 逻辑地址

逻辑地址,也称为程序地址,是程序在执行时由程序产生的地址。它是相对于程序本身的,分为段基址和段偏移量两部分。逻辑地址使得程序在编写时不需要关心物理内存的具体布局。它允许程序的不同段(代码段、数据段等)被分开处理和保护。

具体到程序中,这里的$0x402038就是逻辑地址。

图7.1.1  逻辑地址

7.1.2 线性地址

通俗来说,段地址+偏移地址=线性地址,这里的+不是简单地拼接。线性地址是处理器可寻址的内存空间(称为线性地址空间)中的地址。

在Linux中,所有的段(用户代码段、用户数据段、内核代码段、内核数据段)的线性地址都是从0开始,长度为4G,这样 线性地址 = 逻辑地址 + 0,也就是说,逻辑地址在数值上等同于线性地址了。

7.1.3 虚拟地址

虚拟地址是指由程序产生的由段选择符和段内偏移地址组成的地址。经过CPU页部件转换成具体的物理地址,进而通过地址总线访问内存。在Linux中,虚拟地址在数值上等同于线性地址。

所以,在这里,逻辑地址、线性地址、虚拟地址大小等同。

7.1.4 物理地址

物理地址是内存中实际存储数据的地址。它是实际存储设备(如RAM)中的位置,是硬件直接使用的地址,用于访问和控制内存,它与实际的内存硬件紧密相关,是数据存储和检索的最终目的地。物理地址对应于系统中物理内存的M 个字节

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

从逻辑地址到线性地址的变换,需要三步:

1、计算你有效地址EA;

2、取出段寄存器DS对应的描述符cache重点段基址;

3、线性地址LA = 段基址 + EA (在这里段基址为0)。

介绍逻辑地址。逻辑地址由16位端选择符和32位段内偏移量组成。这里面,段内偏移量指的是指令地址相对于基地址的偏移量,若有了基地址,那么通过段内偏移量就能找到指令的地址,而段选择符的用途是找到基地址。

端选择符由三部分组成,分别是索引、TI、RPL。

图7.2.1 端选择符

根据段选择符,首先根据TI判断应该选择全局描述符表还是局部描述符表,从GDT与LDT所对应的寄存器GTDR和LDTR获取GDT与LDT的首地址,将段选择符的索引字段的值乘8,加上GDT或LDT的首地址,就能得到当前段描述符的地址。

得到段描述符的地址后,可以通过段描述符中BASE字段获得段的基地址。将其与段偏移量相加,即可得到线性地址。

图7.2.2 逻辑地址转线性地址过程

而所谓的“段式管理”,指的是这个过程中都是“段”:段地址、段偏移。

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

线性地址一共 32 位。前 10 位是页目录索引,中间 10 位是页表索引,最后 12 位是页内偏移量。

下面介绍虚拟地址,n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移VPO和n-p位的虚拟页号VPN,MMU利用VPN寻找PTE号,得到PPN物理页号,物理页号和虚拟地址中的VPO串联起来,就得到了物理地址。

由虚拟地址到物理地址翻译过程如图。

图7.3-1  虚拟地址到物理地址的转换(图来自ppt)

引用内容时,MMU先从线性地址里拿到虚拟页号,检查Cache和贮存,看是否在其中有缓存,若有(命中),则返回;否则产生缺页中断,从虚拟内存所给出对应磁盘中拿到并替换。

图7.3-2  页面命中的地址翻译流程

图7.3-3  缺页的地址翻译流程

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

首先介绍TLB(Translation Lookaside Buffer),TLB称为快表,是在MMU中包含的一个关于PTE的小缓存,是一个小的、虚拟寻址的缓存,其中每一行都保存着一个单个PTE组成的块。

TLB若命中,则所有地址翻译步骤都是在MMU中执行,无需去到内存或高速缓存里,所以快;

图7.4-1 TLB命中的地址翻译流程

TLB若不命中,MMU则从L1缓存里取出相应的PTE,新区出的PTE放在TLB里,可能会覆盖,这与之前提到的各种缓存的替换方式类似。

图7.4-2 TLB不命中的地址翻译流程

接下来介绍多级页表的概念,为了减少页表在内存中驻留的开销,采用层次结构的页表来压缩页表。为什么多级页表能压缩呢?一方面,若一级页表中的某个PTE是空的,那么相应的二级页表不存在;另一方面,只有一级页表才需要总是在主存里。

在四级页表的参与之下,当TLB不命中时,将根据VPN1、VPN2…一层层的计算出下一级页表的索引,最后在L4页表中找到相应的PTE,计算出对应的PA,并将其添加至TLB之中。

图7.4-3  多级页表示意图

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

当MMU完成了将虚拟地址转换为物理地址的操作后,这个物理地址会被发送到CPU的第一级缓存(L1 Cache)。缓存系统会分析这个物理地址,从中提取出用于缓存匹配的关键信息,包括标记和组索引。过程大体分为以下几步:

缓存匹配:如果缓存中存在对应的标记和组索引与物理地址相匹配的缓存行,并且该缓存行的有效位被设置为1,这表明缓存行是有效的,那么就会发生缓存命中。

数据返回:在缓存命中的情况下,CPU会根据块偏移从匹配的缓存行中取出所需的数据,并继续执行指令。

缓存未命中:如果L1缓存中没有找到匹配的缓存行,即发生缓存未命中,CPU将按照L1到L2,再到L3,最后到主存储器的顺序,继续查找所需的数据。

数据加载与缓存:一旦在较低级别的缓存或主存储器中找到数据,它会被加载到CPU,并根据缓存的替换策略,将该数据块存储到当前级别的缓存中,以便将来可能的再次访问。

图7.5  读Cache示意图

7.6 hello进程fork时的内存映射

我们首先需要理解内存映射。

内存映射是将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容,可以映射到普通文件和匿名文件。

一个对象可以被映射到虚拟内存的一个区域,要么作为共享对象,要么做一为私有对象。如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到他们虚拟内存的其他进程而言也是可见的,这些变化会反映在磁盘上的原始对象。对于私有对象则相反,对其他进程不可见,也不会反映在磁盘的对象中。同理可以定义共享区域和私有区域,这两个区域都是在虚拟内存区域定义的。

接下来介绍写时复制,在物理内存中保存私有对象的一个副本,只要没有进程试图写私有区域,进程就可以一直共享物理内存中对象的一个单独副本,且每个进程私有区域的页表条目都被标记为只读,区域结构被标记为私有写时复制。若有进程试图写私有区域的某个页面,会触发一个保护故障,它会在内存中创建这个被写页面的新副本,然后更新页表条目指向新副本,并恢复这个页面的可写权限。简单来说,还是为了节约物理内存。

图7.6  写时复制示意图

当Shell调用fork函数创建hello进程时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。

为了给hello进程创建虚拟内存,内核创建了当前进程的mm_struct、区域结构和页表的原样副本,将两个进程中的每个页面都标记为只读,并且把两个进程中的每个区域结构都标记为私有的写时复制。

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

7.7 hello进程execve时的内存映射

当Shell在fork出的hello进程中使用execve,在hello进程中加载并运行包含在可执行目标文件hello中的程序时,需要执行以下几个步骤:

1、删除已存在的用户区域

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

2、映射私有区域

为hello程序的代码、数据、bss和栈区创建新的区域结构。所有这些新的区域都是私有的、写时复制的。

代码和数据区域被映射为 hello文件中的.text和.data区。

bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。

栈和堆区域也是请求二进制零的,初始长度为零。图7.7概括了这私有区域的不同映射。

3、映射共享区域

如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

4、设置程序计数器

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

图7.7  execve执行后的内存映射

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

DRAM缓存不命中称为缺页,以下图为例,当CPU引用VP3中的一个字时,VP3并未缓存在DRAM里,地址翻译硬件从内存读取PTE3,从有效位推断出没有被缓存,并且触发一个缺页异常,缺页异常调用内核程序,使得选择一个牺牲页,就是图里的VP4,如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。

图7.8-1  缺页处理之前

接下来,内核从磁盘复制VP3到内存中的PP3,更新PTE3并且返回。当程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了,如下图所示。

图7.8-2  缺页处理之后

7.9动态存储分配管理

7.9.1 动态内存分配器的基本原理

动态内存分配器是操作系统中负责管理堆内存的一个组件。堆是一块可以动态增长的内存区域,它位于数据段之后,并向上扩展。每个进程都有一个名为 brk 的特殊变量,该变量指向堆的当前顶端。

在这个堆中,内存被划分为不同大小的块,每个块都是一块连续的内存区域,可以是已分配的,也可以是空闲的。已分配的块被应用程序占用,而空闲的块则保留用于将来的分配。块的状态在被显式释放之前一直保持不变。

内存分配器分为两种基本类型:显式分配器和隐式分配器。显式分配器要求应用程序明确地分配和释放内存块,例如C语言的 malloc 和 free,以及C++中的 new 和 delete。隐式分配器,也就是垃圾收集器,则会自动检测不再使用的内存块并释放它们。

7.9.2 隐式空闲链表分配器原理

隐式空闲链表分配器通过在内存块的头部中编码大小信息和状态信息来管理空闲内存。每个块由头部、有效载荷和可能的填充组成。头部包含了块的大小(包括头部和填充)以及一个标记,指示块是已分配还是空闲。

填充的存在可能是为了满足内存对齐要求或为了处理外部碎片。堆被组织为已分配块和空闲块的序列,空闲块通过头部的大小字段隐含地连接起来,形成一条隐式的空闲链表。

图7.9.2  隐式空闲链表中堆块格式

这种结构的优点在于它的简单性,但缺点是分配和释放操作的开销相对较高,因为它们可能需要对整个堆中的块进行搜索,这与堆中块的总数成线性关系。

7.9.3 显式空闲链表分配器原理

与隐式空闲链表分配器相比,显式空闲链表分配器使用更高级的数据结构来组织空闲块。例如,堆可以被组织为一个双向链表,每个空闲块都包含指向前一个和后一个空闲块的指针。

图7.9.3  显式空闲链表中的堆块格式

有两种方式来维护这个链表:

使用后进先出(LIFO)策略,新释放的块被放置在链表的前端,这样可以快速地进行释放和合并操作。

按照地址顺序维护链表,每个块的地址都小于其后继的地址,这有助于提高内存的利用率,但释放操作可能需要线性时间来定位前驱块。

显式空闲链表分配器的缺点是每个空闲块必须足够大以容纳额外的指针、头部和可能的尾部信息,这可能导致更大的最小块大小和更高的内部碎片。

7.10本章小结

本章详细探讨了 hello 程序在存储器地址空间方面的运作。

首先,通过 hello 程序的实例,阐述了逻辑地址、线性地址(虚拟地址)、虚拟地址和物理地址的定义,以及它们之间的区别和联系,还有它们互相转换的过程。

其次,描述了在段式内存管理机制中,逻辑地址是如何被转换成线性地址(也就是虚拟地址)的。同样,也解释了在分页内存管理机制下,线性地址是如何被转换成物理地址的。

接着,我们进一步分析了在TLB和四级页表支持下,虚拟地址到物理地址的转换过程。以四级页表为例,讨论了多级页表的结构、操作流程,以及它在节省空间方面的优势。同时,为了解决页表访问速度的问题,引入了高速缓存TLB。

然后,我们介绍了在三级缓存支持下,物理内存访问的整个流程。并以 hello 进程为例,深入分析了在 fork 和 execve 系统调用过程中内存映射的变化。

之后,我们还讨论了缺页异常和缺页中断的处理机制,并用一个简单的例子,展示了缺页中断处理的步骤和流程。

最后,深入分析了动态存储分配的管理。从动态内存管理的基本方法和策略两个维度,对动态内存管理进行了较为全面的介绍和讨论。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

在Linux操作系统,所有I/O设备都被抽象为文件,所有的输入和输出都被当作对相应文件的读和写来执行。

Linux内核引出了一个简单、低级的应用接口,即Unix I/O,使得所有输入和输出都能以一种统一且一致的方法执行。

8.2 简述Unix IO接口及其函数

8.2.1 简述Unix I/O接口

通过Unix I/O接口,所有的输入和输出都能以统一一致的方式来执行:

1、打开文件

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

2、每个进程开始时都打开的三个文件

Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)

3、改变当前的文件位置

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

4、读写文件

一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时,执行读操作会触发一个称为EOF的条件,应用程序可以对文件进行检测。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。

5、关闭文件

应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。

8.2.1 简述Unix I/O函数

这里进行简单举例。

open():打开一个文件,并返回一个文件描述符。这个函数通常需要两个参数:文件路径和打开模式(如只读、写入等)。

示例:

int fd = open("example.txt", O_RDONLY);

close():关闭一个通过文件描述符标识的文件,释放与该文件相关的资源。

示例:

close(fd);

read():从指定的文件描述符中读取数据,并将其存储在提供的缓冲区中。返回实际读取的字节数。

示例:

ssize_t bytes_read = read(fd, buffer, sizeof(buffer));

write():向指定的文件描述符写入数据。通常包含要写入的缓冲区和要写入的字节数。

示例:

ssize_t bytes_written = write(fd, buffer, sizeof(buffer));

lseek():在文件中移动文件位置指示器至指定位置。可以设置为从文件开头、当前位置或文件末尾开始偏移。

示例:

off_t offset = lseek(fd, 10, SEEK_SET); // 从文件开头偏移10字节

fstat():获取打开文件的状态信息,如文件大小、块大小、权限等,并将其存储在stat结构体中。

示例:

struct stat file_stats;

fstat(fd, &file_stats);

isatty():检查给定的文件描述符是否指向一个终端设备。

示例:

if (isatty(fd)) {

    // 文件描述符指向终端

}

dup() 和 dup2():复制文件描述符。dup() 返回新的文件描述符,而 dup2() 可以将一个文件描述符复制到另一个指定的文件描述符。

示例:

int new_fd = dup(fd);

// 或者

dup2(fd, new_fd);

unlink():删除文件系统中的一个文件。

示例:

unlink("example.txt");

access():检查文件系统中的文件是否存在,并检查是否具有特定的访问权限。

示例:

if (access("example.txt", R_OK) == -1) {

    // 文件不存在或没有读取权限

}

8.3 printf的实现分析

以下是printf函数的函数体:

  1. int printf(const char *fmt, ...)  
  2. {  
  3.     int i;  
  4.     char buf[256];  
  5.   
  6.     va_list arg = (va_list)((char *)(&fmt) + 4);  
  7.     i = vsprintf(buf, fmt, arg);  
  8.     write(buf, i);  
  9.   
  10.     return i;  
  11. }  

在printf的形参列表中,可以看到有一个“...”的表示方式。这是可变形参的一种写法,当传递参数的个数不确定时,就可以用这种方式来表示。

我们怎么让函数体知道参数的个数呢?

在printf的函数体中,有句:

va_list arg = (va_list)((char *)(&fmt) + 4);  

其中,va_list定义为:

typedef char *va_list;  

(char*)(&fmt) + 4) 表示的是“…”中的第一个参数的地址。这是因为,在C语言中,参数压栈的方向是从右往左的。第一个参数fmt将在栈顶的位置,而栈顶是往地址减小的方向增加的。在32位中,第一个参数const char *fmt的大小为4字节,将fmt的地址加上4后,指针向栈底方向移动,指向“…”中的第一个参数。

之后的下一句:

i = vsprintf(buf, fmt, arg);  

调用了vsprintf函数,其简单实现为:

  1. int vsprintf(char *buf, const char *fmt, va_list args)  
  2. {  
  3.     char *p;  
  4.     char tmp[256];  
  5.     va_list p_next_arg = args;  
  6.   
  7.     for (p = buf; *fmt; fmt++)  
  8.     {  
  9.         if (*fmt != '%')  
  10.         {  
  11.             *p++ = *fmt;  
  12.             continue;  
  13.         }  
  14.   
  15.         fmt++;  
  16.   
  17.         switch (*fmt)  
  18.         {  
  19.         case 'x':  
  20.             itoa(tmp, *((int *)p_next_arg));  
  21.             strcpy(p, tmp);  
  22.             p_next_arg += 4;  
  23.             p += strlen(tmp);  
  24.             break;  
  25.         case 's':  
  26.             break;  
  27.         default:  
  28.             break;  
  29.         }  
  30.     }  
  31.   
  32.     return (p - buf);  
  33. }  

在执行过程中,首先会检查格式化字符串 fmt,如果字符串中没有包含格式占位符(如 %),那么字符串中的字符将直接被复制到缓冲区 buf 中。当遇到格式占位符 % 时,程序会根据其后跟随的字符来识别所需的数据类型,并确定如何格式化相应的参数。

以 %d 这个格式占位符为例,它指示需要一个整数类型的参数。此时,程序会将指向参数的指针 p_next_arg(char*)强制转换为 int* 类型,然后通过解引用操作取得实际的整数值。接着,程序会调用一个函数(如 itoa,即整数转字符串的函数),将这个整数转换成字符串形式,并把结果拼接到缓冲区 buf 中。

完成一个参数的格式化输出后,p_next_arg 指针会根据该参数的数据类型大小进行更新,向前移动到下一个参数的位置,以便准备输出下一个参数。在获得格式化字符串buf后,printf调用write进行输出:

    write(buf, i);  

其中的i是buf中格式化字符串的长度,由vsprintf返回。

接下来我们看一下write的实现:

  1. write:  
  2.      mov eax, __NR_write  
  3.      mov ebx, [esp + 4]  
  4.      mov ecx, [esp + 8]  
  5.      int INT_VECTOR_SYS_CALL  

在write中,给寄存器传递了参数,之后int INT_VECTOR_SYS_CALL,通过系统来调用sys_call这个函数。

最后,我们看一下陷阱-系统调用sys_call的实现:

  1. sys_call:  
  2.      call save  
  3.      push dword [p_proc_ready]  
  4.      sti  
  5.      push ecx  
  6.      push ebx  
  7.      call [sys_call_table + eax * 4]  
  8.      add esp, 4 * 3  
  9.      mov [esi + EAXREG - P_STACKBASE], eax  
  10.      cli  
  11.      ret  

这里的call [sys_call_table + eax*4](调用的是sys_call_table[eax])中, sys_call_table是一个函数指针数组,每一个成员都指向一个函数,用以处理相应的系统调用。在这个实例中,此时的eax为4(即__NR_write的系统调用号),从而对内核中的write进行调用。

接下来,系统会根据每个符号所对应的ASCII码,从字模库中提取出每个符号的VRAM信息。

显卡使用的内存分为两部分,一部分是显卡自带的显存,称为VRAM内存,另外一部分是系统主存,称为GTT内存。在嵌入式系统或者集成显卡上,显卡通常是不自带显存的,而是完全使用系统内存。通常显卡上的显存访存速度数倍于系统内存,因而对于许多数据,如果是放在显卡自带显存上,其速度将明显高于使用系统内存的情况。

显示芯片按照刷新频率逐行读取VRAM,并通过信号线向液晶显示器传输每一个点(RGB分量),这样,printf函数显示在我们眼前。

8.4 getchar的实现分析

当程序执行到 getchar 函数时,它会暂停执行并等待用户输入。操作系统此时会监听键盘动作。一旦用户按下键盘上的任意键,就会触发一个异步中断信号。

这个中断信号会中断当前的进程执行,操作系统会响应这个中断,并根据用户按下的键生成相应的ASCII码值,然后将这个值放入到键盘输入缓冲区中。

随后,getchar 函数通过调用 read 函数来实际地从缓冲区中提取输入。read 函数会触发一个系统调用,它从键盘缓冲区中读取数据,并将其作为字符串返回给 getchar。

getchar 函数接收到字符串后,会将其存储在一个内部的静态缓冲区内,并只返回字符串的第一个字符。当再次调用 getchar 时,它会首先检查静态缓冲区是否还有剩余的字符。如果有,就直接从缓冲区中返回下一个字符。只有当缓冲区为空时,getchar 才会再次调用 read 从键盘缓冲区中读取新的输入。

简而言之,getchar 函数通过监听键盘中断、使用 read 函数读取缓冲区数据,以及维护一个内部缓冲区来管理字符输入,确保了输入的效率和连续性。

8.5本章小结

本章首先介绍了Linux IO的设备管理方法,引出了Unix I/O的概念,其次介绍了 Unix I/O的基本概念并且对函数做出举例,最后对printf函数和getchar函数的实现做了详细的分析。

结论

噼里啪啦的敲击,我们编织出一行行代码,这些代码汇聚成为C语言的源文件,今天的主角hello.c登场了!

这份源文件随后踏上了它的变身之旅,经历了预处理器cpp的洗礼,编译器cc1的雕琢,汇编器as的打磨,以及链接器ld的串联,最终蜕变为一个可在内存中加载并执行的可执行文件——hello。

在Shell的指令下,我们输入“./hello 2022111961 吕润东 3727307 1”,Shell利用fork函数孕育出一个新的进程,随后在子进程中,execve函数将hello程序嵌入到内存之中。虚拟内存系统通过mmap为hello进程划定了一片专属的虚拟空间,调度器则为它分配了执行的时间份额,确保它能够公平地与其他进程共享CPU和内存资源。至此,hello完成了从程序代码到进程的华丽转变,这就是原始的P2P(From Program to Process)。

CPU开始从hello的.text段一条条提取指令,寄存器的数值随着程序的推进而不断演算,异常处理机制时刻监控着键盘的动态。在hello内部,syscall系统调用触发了陷阱,将控制权交给了内核,write函数得以执行,将"Hello 2022111961 吕润东 3727307"这串字符传递给了屏幕I/O的映射文件。

映射文件解析传入的数据,提取视频存储器VRAM中的信息,最终在屏幕上绘制出一行行清晰的字符串。当hello程序履行完它的使命,Shell通过waitpid函数向内核发出信号,回收了hello进程的资源,hello进程随之消散。它走完了自己从无到有,再回归于无的一生,完美诠释了O2O(Out of Nothing, Into Nothing)的哲学。

好一个波澜壮阔!好一幅诗情画意!程序员的浪漫在hello里体现。我们以hello开始,又转向各种各样的程序。

随着hello程序的生命周期画上句点,我们的计算机探索之旅才刚刚启程。透过hello的诞生、成长到最终的结束,我们不仅获得了宝贵的知识财富,更激发了我们对未知领域的好奇与渴望。

这些由hello程序所引发的疑惑和好奇,将成为我们继续前行的动力。它们像是一盏盏明灯,照亮我们探索编程世界的每一个角落。让我们以终为始,用一生的时间去追寻、去探索、去发现,将每一个问号转变为知识的宝库。

在这个过程中,我们会遇到挑战,也会收获成就;我们会感到困惑,更会体验顿悟。每一次的尝试和探索,都将丰富我们的计算机科学之旅,让我们对这个世界的理解更加深刻。

所以,让我们带着对hello一生的回忆,以及对未来无限的憧憬,勇敢地迈出下一步。在计算机科学的浩瀚宇宙中,每一颗星辰都等着我们去点亮,每一个奥秘都等着我们去揭晓。这将是一场精彩纷呈的冒险,而我们,正是这场冒险的主角。

一起踏上这段旅程吧,让我们在计算机的世界里,书写属于自己的传奇!

附件

hello.c

Hello的C源文件

hello.i

Hello的C预处理文件

hello.s

Hello的汇编语言文件,由hello.i编译得到

hello.o

Hello的可重定位目标文件,由hello.s汇编得到

hello

Hello的可执行文件,由hello.o链接得到

hello.txt

objdump生成的关于hello.o的反汇编信息

hello_elf.txt

readelf生成的关于hello.o的ELF信息

hello_exe_elf.txt

readelf生成的关于hello的ELF信息

hello_exe.txt

objdump生成的关于hello的反汇编信息

参考文献

  1. 《深入理解计算机系统》第3版
  2. printf 函数实现的深入剖析

https://www.cnblogs.com/pianist/p/3315801.html

  1. 程序详细编译过程(预处理、编译、汇编、链接)

https://blog.csdn.net/restore_1/article/details/136000496

  1. bss、data和rodata区别与联系

https://blog.csdn.net/laiqun_ai/article/details/8528366

  1. 逻辑地址、虚拟地址、物理地址以及内存管理

https://www.cnblogs.com/lemaden/articles/10460757.html

  1. 程序的链接的三种方式

https://blog.csdn.net/qq_36946274/article/details/81463522

  1. 虚拟地址、逻辑地址、线性地址、物理地址的区别

https://blog.csdn.net/qiuchaoxi/article/details/79616220

  1. Linux的内存寻址——浅谈分段和分页机制

https://blog.csdn.net/hellonerd/article/details/80537517

  1. 深入理解TLB原理

https://blog.csdn.net/lianhunqianr1/article/details/124811987

  1. 揭秘CPU与内存之间的三级缓存:原理、作用与优化

https://developer.baidu.com/article/detail.html?id=3300126

  1. Linux下fork与写时拷贝技术(COW)详解

https://blog.csdn.net/Ternence_zq/article/details/105234058

  1.  [转]printf 函数实现的深入剖析

https://www.cnblogs.com/pianist/p/3315801.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值