计算机科学与技术学院
2024年5月
本文根据Hello的自白,以追踪Hello程序的生命周期为切入点,探讨了计算机系统底层的基本内容与原理。通过对程序经历预处理、编译、汇编、链接生成可执行文件,最终在系统上运行和结束的过程进行详细描述,展现了程序运行的各个阶段,以及操作系统和硬件之间的紧密配合。从简单的Hello程序中汲取无数计算机科学家的思想精华,并体现了程序在计算机中的重要性和复杂性。本文旨在通过追踪程序生命周期的方式,深入理解计算机系统的内部运行过程,并凸显程序对计算机科学的意义和价值。
关键词:计算机系统;编译;预处理;汇编;链接;
目 录
第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 -
参考文献....................................................................................... - 16 -
第1章 概述
1.1 Hello简介
P2P,即Program to Process,指Hello从一个程序变成进程的过程。
首先通过C语言编写代码,得到hello.c文件,创建了Hello程序。再进行预处理,在编译前,hello.c经过预处理器(cpp),进行宏展开、头文件包含等操作,得到中间文件hello.i。接下来,通过ccl编译器hello.i会被编译成汇编代码,这个过程会产生一个汇编文件,比如hello.s。然后,汇编器(as)将汇编代码转换成机器可读的二进制目标文件hello.o。最后,链接器(ld)将程序的目标文件(hello.o)与所需的库文件链接在一起,创建一个可执行文件hello。这个可执行文件包含了程序的机器代码和所需的系统库,可以被加载到内存中执行。
当我们在命令行中运行./hello时,操作系统会为该可执行文件创建一个进程,并将其加载到内存中执行。此时,程序变成了一个进程,它将在计算机中运行并完成其任务,这就是 P2P的过程。
020,即From Zero to Zero,指程序从零开始到零结束的完整生命周期。在Shell中通过fork()函数创建子进程,然后使用execve()函数加载可执行程序hello,这时操作系统为其分配虚拟内存并映射到物理内存,使得程序完成了从无到有的过程。在程序执行的过程中,内存管理器和CPU调用缓存、TLB、内存等进行物理内存上的数据访问,同时通过I/O系统进行输入输出操作。当程序运行结束后,子进程会向父进程发送SIGCHLD信号,父进程收到信号后会回收子进程,将其从系统中清除,完成了从有到无的过程,也就是020的过程。
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;3.2GHz;32G RAM;953GHD Disk
1.2.2 软件环境
Windows11 64位;Vmware 17;Ubuntu 22.04 LTS 64位
1.2.3 开发工具
Visual Studio 2022 64位;CodeBlocks64位;gedit+gcc
1.3 中间结果
1.hello.c:C语言源代码文件。
2.hello.i:预处理后的文本文件。
3.hello.s:编译后的汇编文件。
4.hello.o:汇编得到的可重定位文件。
5.hello:链接产生的的可执行文件。
6.elf.txt:hello.o的elf文件。
7.asm.txt:hello.o反汇编得到的结果文件。
8.elf1.txt:hello的elf文件。
9.asm1.txt:hello反汇编得到的结果文件。
1.4 本章小结
本章详细介绍了Hello程序的P2P过程和020过程,以及完成大作业所使用的软件环境、硬件环境、开发与调试工具,并列出了中间结果文件及其作用。
第2章 预处理
2.1 预处理的概念与作用
预处理是指的是预处理器(cpp)根据以字符#开头的命令(如#include、#define、#pragma等),修改原始的C程序,获得一个后缀为.i的文本文件。
预处理器的作用包括:
1.预处理器会根据源代码中定义的宏来进行替换操作。宏可以是简单的文本替换,也可以是带有参数的宏函数。
2.预处理器会根据#include指令将其他源文件中的内容包含到当前源文件中,形成一个单一的源文件。
3.预处理器会根据条件编译指令(如#ifdef、#if、#else)来选择性地编译源代码的不同部分,以适应不同的编译环境或编译选项。
4.预处理器会移除源代码中的注释,使得注释不会出现在编译后的中间代码中。
5.预处理器还可以执行其他一些指令,如#error、#pragma等,用于向编译器传递额外的信息或指示。
2.2在Ubuntu下预处理的命令
预处理的命令为:gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i
图1 预处理命令及生成文件截图
2.3 Hello的预处理结果解析
对比hello.c和hello.i,我们可以发现hello.i程序中注释被删除;并且源代码包括注释仅有24行,但hello.i有3092行。其中包括许多源文件中没有的内容,头文件被换成了相应代码,它们使代码数量大大增加。其中包括大量的typedef语句,枚举(enum)类型以及标准输入输出和错误定义;而源程序代码在了.i文包括件的最后。
图2源代码 hello.c的内容
图3 hello.i文件的部分内容
2.4 本章小结
本章介绍了预处理的概念和作用和对hello.c进行预处理的过程,得到了预处理后的结果文件hello.i,并对hello.i文件进行比较分析。通过对源代码与hello.i文件的比较分析,我们可以发现预处理过程会删除源文件的注释内容,并替换头文件引用的代码文件。
第3章 编译
3.1 编译的概念与作用
编译:将预处理后的文本文件.i翻译成计算机可识别的汇编语言代码,产生的文件通常以.s为扩展名。
作用:将高级语言源代码转换为目标机器可执行的汇编语言,使得计算机可以执行这些代码来完成特定的任务。
3.2 在Ubuntu下编译的命令
命令:gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s
图4 编译命令及生成文件
3.3 Hello的编译结果解析
3.3.1数据
1.常量
本程序中的常量包括字符串常量以及立即数常量。在第三行中,.rodata表示以下两个字符串常量是只读的,.string后的即为字符串常量。在第21行的$32以及24行的$5都是立即数常量。
图5 字符串常量及数字常量
2.局部变量
本程序中的变量只包括局部变量。其中共有3个局部变量,i、argc、argv。以argc为例,第22行表示要将寄存器%edi的值传入栈中,地址为-20(%rbq),可以得知argc是存放在栈中基址%rbp下的20个字节处。其中%rbp为栈指针,采用偏移量寻址的方法。
图6 局部变量
3.3.2赋值
在汇编语言中,赋值一般采取mov类指令,操作的数据大小不同对应不同的后缀,包括b、w、l 等,分别代表8、16、32位的字长值。如23行的movq把寄存器%rsi的值赋值给-32(%rbq)的地址处,即基址%rbp下的32个字节处。
图7 赋值操作
3.3.3类型转换
如第51行中,调用了atoi函数把字符串转换为int类型。
图8 类型转换
3.3.4算术操作
本程序中的算术操作主要为加法操作,如第54行用了addl指令,对应源代码中的i++。
图9 加法操作
3.3.5关系操作
本程序中如第56行运用cmpl指令进行判断,比较$9和-4(%rbq)中值的大小,完成关系操作,对应源代码中i<10。
图10 关系操作
3.3.6数组/指针/结构操作
本程序中含有对数组的操作,如第34行首先将argv[0]存放在-32(%rbq),将值存放在%rax后,对其进行地址偏移,实现对数组的访问。
图11 数组访问操作
3.3.7控制转移
如第57行中,通过cmpl指令判断后,用jle实现条件跳转,即小于10时跳转到.L4。
图12 控制转移
3.3.8函数操作
在源代码中共有6次函数调用,如下图中用call指令调用了printf,atoi,sleep函数。
图13 函数调用
3.4 本章小结
本章主要介绍了编译的概念,并结合在Ubuntu下将hello.i编译生成的hello.s文件,分析了各类C语言的数据与操作的汇编表示。其中介绍了数据类型如常量、变量;操作如赋值操作、算数操作、控制转移等等。
第4章 汇编
4.1 汇编的概念与作用
汇编:汇编器(as)将hello.s翻译为机器语言指令,把这些指令打包为可重定位目标程序的格式,保存在hello.o文件中。
作用:将汇编语言翻译为机器能执行的机器语言,将ascii格式的汇编代码翻译为机器码。
4.2 在Ubuntu下汇编的命令
命令:gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
图14 汇编命令及生成文件
4.3 可重定位目标elf格式
用readelf将hello.o输出到elf.txt 中。
图15 生成elf文件命令及生成的文件
4.3.1ELF头
ELF头以一个16字节的序列Magic开始,这个序列描述了该文件的系统的字的大小和字节顺序。ELF头其余部分包含帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。具体内容如下。我们可以看到类型为REL(可重定位文件),其中还包含入口点地址、程序头起点等信息。
图16 ELF头
4.3.2节头部表
节头部表中描述了文件中每节的名称、类型、地址、偏移量、大小等信息。
图17 节头部表
4.3.3重定位节
重定位节包括.rela.text与.rela.eh_frame节,.rela.text节是一个.text节中位置的列表,当链接器将这个目标文件组合时,需要修改的位置;.rela.eh_frame节包含了重定位信息。其中,offset偏移量表示可被修改的引用偏移;type为重定位的类型,包括R_X86_64_PC32、R_X86_64_32、 R_X86_64_PLT32;addend是进行偏移调整的加数。重定位项目是为了当链接器将目标文件与其他文件组合时可以修改最终位置位置的目标引用。
图18 重定位节
4.3.4符号表
符号表中列出了所有定义和引用的变量、函数等信息,包括符号距定义的偏移、大小、类型、节头部表的索引等。
图19 符号表
4.4 Hello.o的结果解析
通过objdump -d -r hello.o>asm.txt指令获得文件asm.txt。
图20 反汇编指令及结果文件
1.机器语言的构成
机器语言是用二进制代码表示的计算机能直接识别和执行的一种机器指令的集合,机器语言由二进制码构成。指令就是机器语言的语句,基本格式包括操作码字段和地址码字段,其中操作码指明了指令的操作性质及功能,地址码则给出了操作数或操作数的地址。
2.与汇编语言的映射关系
汇编指令是机器指令便于记忆的书写格式,操作码与汇编指令对应。如mov指令对应机器码是48,%rsp与%rbp寄存器对应机器码分别是89和e5。
3.操作数
反汇编代码中的立即数是十六进制数,而 hello.s文件中的数是十进制的。
寄存器寻址两者相同。内存引用 hello.s 中会用伪指令(如.LC0)代替,而反汇编则是基址加偏移量寻址:0x0(%rip)。
4.分支转移
在汇编程序中,分支跳转的目标位置通过段名.L1、.L2 来实现的,而 在反汇编程序中,则是直接给出地址值。
5.函数调用
在hello.s中,用call指令后直接加引用函数名称进行,而在反汇编文件中,call指令后会跟着的是函数通过重定位条目指引的信息,由于调用的这些函数都是未在当前文件中定义的,所以一定要与外部链接才能够执行。在链接时,链接器将依靠这些重定位条目对相应的值进行修改,以保证每一条语句都能够跳转到正确的运行时位置。
图21 两种文件的对比
4.5 本章小结
4.5 本章小结
本章介绍了汇编的概念和作用,并实际操作进行汇编,将汇编程序hello.s转化成可重定位目标文件hello.o。并通过readelf命令分析了hello.o的ELF格式,列出了ELF头、节头表、重定位节以及符号表的功能与包含的信息。最后使用objdump反汇编生成asm.txt文件,并与汇编程序文件进行比较与分析。
第5章 链接
5.1 链接的概念与作用
链接:将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
链接器在软件开发中扮演着关键角色,因为它使得分离编译成为可能,可以将软件模块化设计,可以独立修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接·,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
图22 链接命令及生成文件截图
5.3 可执行目标文件hello的格式
用readelf -a hello >elf1.txt生成elf1.txt文件。
图23 指令及生成文件
如下图所示,在可执行目标文件中,各类信息与可重定位目标文件中基本一致。其中最左侧一列为节名,第二列为节类型,第三列为起始地址,第四列是节偏移量。
图24 hello的elf格式文件
5.4 hello的虚拟地址空间
使用edb加载hello,可以查看本进程的虚拟地址空间各段信息。通过与5.3节对照分析可以发现edb中显示的虚拟地址和elf文件中看到的虚拟地址是对应相同的。此外,我们还可以在edb的Data Dump界面中进行地址查找并找到对应地址存储的数据。
图25 在edb中加载hello
5.5 链接的重定位过程分析
通过objdump -d -r hello > asm1.txt将反汇编内容写入asm1.txt。
图26 指令及结果文件
1.指令分配虚拟地址
在hello.o反汇编文件中(左),函数语句前没有被分配虚拟地址;经过链接后(右),每一条指令都被分配了虚拟地址。
图27 虚拟地址的分配
2.函数调用和跳转
在函数调用时,由于hello是重定位后的可执行程序,因此其调用函数时,所调用的地址就是函数所在的地址,而hello.o则是利用偏移量。链接后的跳转指令也使用虚拟地址进行跳转。
图28 函数的调用
3. 新增节
我们可以看到hello明显多了很多其他的节,比如.init .plt等。其中init是为了初始化程序,而plt是由于共享库链接时生成的。
图29 新增节
综上,链接的过程主要分为符号解析和重定位。符号解析对目标文件定义和引用符号解析,并建立每个符号引用和符号定义之间的关联。重定位时先重定位节和符号定义,把相同类型的节合并,并为其分配内存。接下来进行符号引用的重定位,修改代码和数据中对符号的引用,指向正确地址。
5.6 hello的执行流程
00000000004010f0 <_start>
0000000000401000 <_init>
0000000000401020 <.plt>
0000000000401120 <_dl_relocate_static_pie>
0000000000401130 <deregister_tm_clones>
0000000000401160 <register_tm_clones>
00000000004011a0 <__do_global_dtors_aux>
00000000004011d0 <frame_dummy>
00000000004011d6 <main>
0000000000401090 <puts@plt>
00000000004010d0 <exit@plt>
00000000004010a0 <printf@plt>
00000000004010c0 <atoi@plt>
00000000004010e0 <sleep@plt
00000000004010b0 <getchar@plt>
0000000000401270 <_fini>
5.7 Hello的动态链接分析
(以下格式自行编排,编辑时删除)
首先使用readelf读取hello,查看.got.plt的地址如图所示:
使用使用edb对于调用_init前后PLT的数据变化如下所示:
调用前:
调用后:
5.8 本章小结
本章介绍了链接的概念以及作用,并在Ubuntu下完成了链接的过程,并分析了hello可执行文件的elf格式文件的信息。使用edb查看了hello的虚拟地址空间,发现各节都与相应的一段虚拟地址相对应,同时查看了各节的起始位置与大小。使用objdump对可执行目标文件hello进行反汇编,并与hello.o的反汇编程序进行比较,发现经过链接后,hello的反汇编程序代码量增加,插入了C标准库中的函数代码,各条指令都分配了虚拟地址,字符串常量的引用、函数调用以及跳转指令的地址都替换为了虚拟地址。此外,介绍了链接的过程并分析了符号解析和重定位,介绍分析了hello程序动态链接的过程,通过edb调试,分析了在dl_init前后,.got.plt节的的内容变化,这是由动态链接的延迟绑定造成的。
第6章 hello进程管理
6.1 进程的概念与作用
进程是操作系统进行资源分配和调度的基本单位,代表了一个正在执行的程序。它包括程序代码、当前活动、进程堆栈、数据段和堆等组成部分。进程在计算机系统中起着至关重要的作用,它使得多个程序可以并发执行,提高了系统的效率和性能。此外,进程之间可以进行通信,共享数据,进一步提高了程序的执行效率。操作系统通过为每个进程分配独立的内存空间,保证了进程之间的独立性,提高了系统的稳定性。总的来说,进程是实现并发执行,资源分配,程序交互和错误隔离等功能的关键机制。
6.2 简述壳Shell-bash的作用与处理流程
Shell,特别是Bash(Bourne Again SHell),是用户与操作系统内核交互的接口,它接收用户输入的命令,解释并执行这些命令,从而控制计算机的行为。处理流程通常包括:读取用户输入的命令行,解析命令行以识别命令和参数,查找并加载要执行的程序,设置环境变量和重定向输入输出,最后执行程序并等待其完成,然后显示结果或处理错误信息。Bash还支持脚本编程,允许用户编写一系列命令的脚本文件,实现自动化任务。
6.3 Hello的fork进程创建过程
当一个进程调用 execve() 系统调用时,它用一个新的程序替换了当前进程的代码、数据、堆和栈,从而执行一个全新的程序。这个过程包括加载新程序的二进制文件,解析其依赖的库,设置程序的运行时环境,以及传递命令行参数和环境变量。execve() 执行成功后,原进程的PID保持不变,但其内存映像完全被新程序替换,原进程的执行立即被新程序的执行所取代,因此 execve() 不会返回,除非发生错误。
6.4 Hello的execve过程
参数解析:execve() 的第一个参数是新程序的路径名,这里是 hello 程序的路径。第二个参数是一个指向字符串数组的指针,这些字符串构成了新程序的命令行参数,通常至少包含一个元素(程序名本身)。第三个参数是一个指向环境变量字符串数组的指针,每个字符串都是 name=value 的形式。
程序加载:操作系统加载 hello 程序的二进制代码和数据到当前进程的内存空间中,替换掉调用 execve() 的进程的原有代码和数据。
内存映射:操作系统可能会使用内存映射技术来加载程序和库,确保程序的代码和数据被映射到正确的地址空间。
重置寄存器:操作系统会重置寄存器,包括程序计数器(PC),指向新程序的入口点。
环境变量设置:操作系统将传递给 execve() 的环境变量数组中的环境变量设置到新程序的环境中。
执行新程序:一旦新程序的代码和数据被加载,环境变量和命令行参数被设置好,操作系统就开始执行新程序。此时,原进程的代码和数据已经被完全替换,新程序开始执行其 main 函数,并使用传递给 execve() 的命令行参数和环境变量。
返回值:如果 execve() 调用成功,它不会返回,因为调用进程已经被新程序完全替换。如果 execve() 调用失败(例如,因为找不到指定的程序文件),它会返回-1,并设置相应的错误码
在 hello 程序的上下文中,execve() 调用将导致 hello 程序被加载并开始执行,接管调用 execve() 的进程的控制权,从而输出 "Hello, World" 或任何其他由 hello 程序定义的输出。
6.5 Hello的进程执行
(1)上下文信息:上下文信息是操作系统内核重新启动一个挂起的进程所需要恢复的状态。它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的信息构成。
(2)时间片;一个进程执行它的控制流的一部分的每一时间段叫做时间片。
(3)进程调度:在进程执行过程中,操作系统内核可以决定抢占当前进程,并重新开始一个先前被挂起的进程,这样的一种决策叫做进程调度。当抢占进程时,要完成以下三个任务:保存之前进程的上下文;恢复要执行的新进程的上下文;把控制转让给新恢复的进程完成上下文切换。
(4)用户模式和内核模式:处理器通常使用一个寄存器来区分两种模式,这个寄存器描述了当前进程的权限情况。简单来说,两种模式有不同的“权限”,用户模式权限较低,不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;内核模式权限较高,可以执行任何命令,并且可以访问系统中的任何内存位置。
(5)进程执行与用户态核心态转换:
当开始运行hello时,内存为hello分配时间片,若一个系统同时运行多个进程,则它们轮流使用处理器,物理控制流被划分成多个交错的逻辑控制流,存在并发执行的现象。然后在用户态下执行并保存上下文。如果在此期间内发生了异常或系统中断,则内核会休眠该进程,并在核心态中进行上下文切换,把控制权让给其他进程。当hello进程执行到sleep时,hello会进入休眠状态,此时再次进行上下文切换,控制交付给其他进程,一段时间后hello休眠结束,此时再次完成上下文切换,恢复休眠前的上下文信息,此时控制权送回hello并继续执行。循环结束后,程序调用 getchar() ,hello从用户模式进入内核模式,并再次上下文切换,控制交付给其他进程。最后,内核会从其他进程回到 hello 进程。
6.6 hello的异常与信号处理
6.6.1异常
1.中断
中断一般是来自处理器外部的I/O设备的信号的结果。比如在 hello 运行过程中,我们敲击键盘,那么就会触发中断,系统调用内核中的中断处理程序执行,然后返回,返回到hello的下一条指令继续执行。
2.陷阱
陷阱是有意的异常,一般是用来提供用户程序和内核之间的接口,即系统调用。我们的 hello 运行在用户模式下,无法直接运行内核中的程序,比如像 fork,exit 这样的系统调用。于是就通过陷阱的方式,执行 systemcall 指令,内核调用陷阱处理程序来执行系统调用。
3.故障
故障也是一种同步异常,它不是有意的,同时也是可能被修复的,如访存时的缺页故障是可恢复的,但保护故障是不可恢复的。
4. 终止
hello 在运行时,也有可能遇到硬件错误,比如说DRAM或者SRAM位被损坏时发生的奇偶错误,这时处理程序会终止hello程序。
6.6.2信号
乱摁键盘:
当你在shell中随意按键盘时,通常不会对正在运行的程序产生直接影响。shell会尝试解释你输入的字符,但如果没有形成有效的命令,shell通常会忽略这些输入。如果输入了有效的命令,那么这些命令会被执行,可能会对当前环境产生影响,但不会直接终止或暂停正在运行的程序,除非这些命令是专门用来控制程序的。
Ctrl+C是一个常用的中断信号,通常用于终止当前在前台运行的程序。当用户在shell中按下Ctrl+C时,shell会发送一个SIGINT信号给前台进程组中的所有进程。默认情况下,这个信号会导致程序终止。如果你正在运行一个程序,按下Ctrl+C通常会立即停止该程序的执行。
Ctrl+Z用于将当前前台运行的程序暂停并放到后台。当用户在shell中按下Ctrl+Z时,shell会发送一个SIGTSTP信号给前台进程组中的所有进程。默认情况下,这个信号会导致程序暂停执行。在输入kill指令后,会彻底停止运行。
6.7本章小结
本章了解了hello进程的执行过程。在hello运行过程中,内核对其调度,异常处理程序为其将处理各种异常。每种信号都有不同的处理机制,对不同的shell命令,hello也有不同的响应结果。可以说,进程管理就是为了约束程序的运行而存在的。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1.逻辑地址(Logical Address):由程序产生的段内偏移地址,是程序员编写代码时使用的地址。在程序执行之前,逻辑地址经过分段机制转换成线性地址。
2.线性地址(Linear Address):也称为虚拟地址,是处理器可寻址的内存空间中的地址。逻辑地址经过分段机制转换成线性地址。线性地址空间通常比物理地址空间要大,因为它是虚拟化的,由操作系统和硬件共同管理。
3.虚拟地址(Virtual Address):由段选择符和段内偏移地址组成的地址,是逻辑地址的一部分。在使用分页机制时,虚拟地址还会被转换成页表索引和页内偏移,进而映射到物理地址。
4.物理地址(Physical Address):内存中实际的存储单元地址。操作系统通过页表将虚拟地址映射到物理地址,使得程序可以在物理内存中正确地执行和访问数据。7.2 Intel逻辑地址到线性地址的变换-段式管理
机器语言指令中出现的内存地址,都是逻辑地址,需要转换成线性地址,再经过MMU(CPU中的内存管理单元)转换成物理地址才能够被访问到。
一个逻辑地址由两部分组成,段标识符和段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。
在x86保护模式下,段的信息(段基线性地址、长度、权限等)即段描述符占8个字节,段信息无法直接存放在段寄存器中(段寄存器只有2字节)。Intel的设计是段描述符集中存放在GDT或LDT中,而段寄存器存放的是段描述符在GDT或LDT内的索引值(index)。
这种转换过程需要依赖于段描述符和段选择符,并且需要考虑到段的类型(在GDT还是LDT中)以及相应的权限等信息。这种机制在保护模式下确保了操作系统对内存的安全管理和隔离。
在处理器执行指令时,它会根据逻辑地址,首先将其转换为线性地址,然后再经过页表的映射,最终得到物理地址。这一系列的地址转换是操作系统和硬件共同完成的,确保了程序的正常执行和数据的安全性。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是一种常见的虚拟内存管理技术,通过将虚拟内存空间划分成固定大小的页,然后建立页表来映射虚拟页地址到物理页地址,实现了虚拟内存与物理内存之间的映射关系。
在页式管理中,每个进程拥有自己的页表,用于将其虚拟地址空间映射到实际的物理内存地址上。当程序访问一个虚拟地址时,操作系统会根据页表将其转换为对应的物理地址。这个过程通常由硬件中的内存管理单元(MMU)来完成,它会根据页表的内容将虚拟地址转换为物理地址。
页式管理技术的优点之一是可以实现内外存的统一管理,即将磁盘上的部分数据按页加载到内存中,需要时再进行调度和置换,从而实现了更大的虚拟内存空间。另外,页式管理还支持请求调页和预调页等技术,以优化内存访问的效率和响应速度。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是一种用于加速虚拟地址到物理地址转换的高速缓存。由于常规的地址翻译过程需要查询页表条目,而页表通常存储在内存中,因此每次访问内存都会涉及到内存访问,这可能导致较长的访问延迟。
TLB的作用就是将最近访问过的页表条目缓存到高速存储器中,通常是在处理器内部或者与处理器相近的地方。这样,当处理器需要转换虚拟地址时,首先会查询TLB,如果TLB中存在对应的页表条目,就可以直接得到物理地址,而无需访问主存。这样可以大大减少地址转换的时间,提高系统的整体性能。
当TLB未命中时,处理器会按照正常的流程访问页表,并将结果存储到TLB中,以供以后使用。由于TLB是一个有限大小的缓存,因此当TLB已满时,需要进行TLB替换算法来选择被替换的条目。常见的替换算法包括最近最少使用(LRU)和随机替换等。
7.5 三级Cache支持下的物理内存访问
高速缓存作为CPU与主存之间的缓冲区域,通过在更快速的存储设备中保存最近或频繁访问的数据,可以大大提高数据访问速度和系统性能。
在具体的实现中,高速缓存通常被划分为多个缓存块(cache line),每个缓存块包含数据以及相应的标签(tag)、有效位(valid bit)等元数据。当CPU访问内存时,首先会在高速缓存中查找是否存在所需的数据。如果存在命中,即在高速缓存中找到了与所需数据相关的缓存块,则可以直接从高速缓存中获取数据,这就是缓存命中。如果未命中,则需要从更慢的主存或其他更大的缓存中获取数据,这就是缓存未命中。
在高速缓存中,数据通常以组的形式组织,每个组包含多个缓存块。当CPU需要访问数据时,首先会根据物理地址中的组索引定位到相应的组,然后比较标签位以确定是否命中。如果命中,CPU就可以直接从该组中获取数据;否则,需要逐级向更高级别的缓存或主存中进行访问,直到命中或者访问到主存为止。
对于写回操作,当CPU需要写入数据时,如果缓存中有空闲的缓存块,则直接写入缓存;如果没有空闲的缓存块,则需要根据相应的替换算法选择一个缓存块进行替换,并将其写回到主存中,然后再写入新的数据。
7.6 hello进程fork时的内存映射
当调用fork()函数创建新进程时,操作系统处理虚拟内存的过程如下。在fork()函数被调用时,操作系统内核会创建新的进程,并为其分配一个唯一的进程标识符(PID)。然后,为了创建新进程的虚拟内存空间,通常会执行以下步骤:
复制父进程的内存管理结构(mm_struct)、区域结构(region structures)和页表(page tables)。这些结构描述了进程的虚拟内存布局,包括代码段、数据段等。
将新进程和父进程中的每个页面都标记为只读。这意味着新进程和父进程共享相同的物理内存页,但是它们都只能读取这些页,不能写入。
将新进程和父进程中的每个区域结构都标记为私有的写时复制。这意味着在新进程写入某个页面之前,操作系统不会真正复制页面,而是共享相同的页面。只有当其中一个进程试图写入共享页面时,写时复制机制才会生效,创建一个新的页面并将修改写入其中,从而保持了每个进程的私有地址空间。
当fork()函数在新进程中返回时,新进程的虚拟内存布局与父进程完全相同。但是由于采用了写时复制机制,实际上只有在其中一个进程尝试修改页面内容时,才会真正复制页面,从而保持了进程间的内存隔离。这种机制可以节省内存开销,并且为每个进程提供了私有的地址空间,使得进程间的数据隔离得以实现。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行文件hello中的程序,用hello替代了当前bash中的程序。加载并运行hello需要如下几个步骤:
删除已存在的用户区域:首先,当前进程中的虚拟地址空间的用户部分中可能存在着一些区域结构,这些结构描述了之前加载的程序的内存布局。在执行新程序之前,需要删除这些已存在的用户区域,以确保新程序能够在一个干净的内存环境中运行。
映射私有区域:接着,为新程序(如"hello")的代码、数据、bss(未初始化的数据)、栈等区域创建新的区域结构。这些区域都是私有的,并且采用写时复制的机制。代码和数据区域从"hello"文件的.text和.data段中映射而来,bss区域则被初始化为二进制零,并映射到一个匿名文件,其大小包含在"hello"中。栈和堆区域也被初始化为二进制零,但初始长度为零。
映射共享区域:如果"hello"程序与动态链接到其中的共享对象(如libc.so)有关联,那么需要将这些共享对象映射到用户虚拟地址空间中的共享区域内。这样,多个进程可以共享相同的内存页,从而节省内存空间。
设置程序计数器(PC):最后,需要设置程序计数器(PC)指向新程序代码区域的入口点,以确保下一次调度该进程时,它从正确的位置开始执行。
7.8 缺页故障与缺页中断处理
缺页故障是指CPU想要读取虚拟内存中的某个数据,但是该数据页还没有缓存,此时就引发缺页中断,系统会调用缺页中断处理程序对其进行处理。
发送虚拟地址:当CPU需要访问某个虚拟地址中的数据时,它将该虚拟地址发送给内存管理单元(MMU)。
生成PTE地址:MMU根据虚拟地址生成页表项(PTE)的地址(PTEA),然后请求从高速缓存或主存获取该页表项。
获取PTE:高速缓存或主存返回相应的页表项。
触发缺页异常:如果返回的页表项的有效位为0,表示所需的页面不在主存中,MMU将触发缺页异常,将控制权传递给操作系统内核的缺页异常处理程序。
确定牺牲页:缺页处理程序确定出要牺牲的物理页面。如果该页面被修改过(脏页),则需要将其写回到磁盘中。
调入新页面:缺页处理程序将所需的新页面调入主存,并更新页表项。
返回原进程:缺页处理程序返回到原始进程,并重新执行导致缺页的指令。此时,CPU将重新发送虚拟地址给MMU,并执行相应的访问操作。由于已经调入了所需的页面,不会再发生缺页情况。
7.9动态存储分配管理
动态内存分配器通过维护堆来管理进程的虚拟内存区域,而显示空闲链表管理则是一种优化的方式,通过维护多个空闲链表来管理不同大小的空闲块,以提高分配和释放内存的效率。
在这种管理方式下,分配器可以更快地找到合适大小的空闲块来满足应用程序的内存需求,而不需要在整个堆中搜索。每个空闲链表中的块大小大致相等,这有助于降低搜索的复杂度和时间开销。
这种显示空闲链表管理的方法在实际的操作系统中得到了广泛应用,因为它能够有效地管理内存,并提高内存分配和释放的性能。
7.10本章小结
本章涵盖了程序存储管理的多个方面,包括逻辑地址、线性地址、虚拟地址和物理地址的概念以及它们之间的转换过程。通过段式管理和页式管理,详细介绍了地址转换的过程,包括逻辑地址到线性地址,再到物理地址的变换过程。还介绍了在TLB(Translation Lookaside Buffer)和四级页表的支持下,虚拟地址如何转换为物理地址,以及在三级cache的支持下,物理内存的访问操作。
此外,还涉及了进程调用fork和execve时的内存映射,以及缺页故障和缺页中断处理的方式。最后,还介绍了动态存储分配管理,包括动态内存分配器和显示空闲链表管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
Linux的设备管理的主要任务是控制设备完成输入输出操作,所以又称输入输出(I/O)子系统。它的任务是把各种设备硬件的复杂物理特性的细节屏蔽起来,提供一个对各种不同设备使用统一方式进行操作的接口。Linux把设备看作是特殊的文件,系统通过处理文件的接口—虚拟文件系统VFS来管理和控制各种设备。
8.2 简述Unix IO接口及其函数
在Unix系统中,输入输出(IO)操作是通过一系列系统调用函数来实现的,这些函数提供了一个统一的接口来处理文件和设备。以下是一些基本的Unix IO接口函数:
open():用于打开一个文件或设备。它接受两个参数:文件名和打开模式(如只读、只写或读写)。如果文件不存在,可以选择创建文件。函数返回一个文件描述符,这是一个非负整数,用于后续的IO操作。
close():用于关闭一个已经打开的文件或设备。它接受一个文件描述符作为参数,并释放相关的系统资源。
read():从已打开的文件或设备中读取数据。它接受三个参数:文件描述符、存储读取数据的缓冲区地址和要读取的字节数。函数返回实际读取的字节数。
write():向已打开的文件或设备写入数据。它接受三个参数:文件描述符、要写入的数据缓冲区地址和要写入的字节数。函数返回实际写入的字节数。
lseek():用于改变文件的当前读写位置。它接受三个参数:文件描述符、偏移量和起始位置(如文件开始、当前位置或文件结束)。函数返回新的文件位置。
ioctl():用于对设备进行控制操作。它接受三个参数:文件描述符、控制命令和可选的参数。这个函数通常用于设备特定的操作,如设置设备参数等。
fcntl():用于文件控制操作,如复制文件描述符、获取或修改文件状态标志等。它接受两个或三个参数:文件描述符、操作命令和可选的参数。
这些函数构成了Unix系统中基本的IO操作接口,允许程序员以统一的方式处理文件和设备,而不需要关心底层的实现细节。通过这些接口,程序可以实现对文件的读写、定位、控制等操作,以及对设备的控制和数据传输。
8.3 printf的实现分析
void simple_printf(const char* fmt, ...) {
va_list args;
va_start(args, fmt);
const char* p;
int i;
char* s;
for (p = fmt; *p != '\0'; p++) {
if (*p != '%') {
write_string(p, 1);
continue;
}
switch (*++p) {
case 'd':
i = va_arg(args, int);
// 将整数转换为字符串
char buffer[20]; // 假设整数不超过19位
sprintf(buffer, "%d", i);
write_string(buffer);
break;
case 's':
s = va_arg(args, char*);
write_string(s);
break;
default:
// 未知格式说明符,输出%和后面的字符
write_string(p - 1, 2);
break;
}
}
函数通过接收一个格式字符串和可变数量的参数来工作。在函数内部,使用va_list类型的变量args来存储和访问这些可变参数。代码通过遍历格式字符串,并对每个字符进行检查来实现。如果字符不是%,则直接输出。如果是%,则检查紧随其后的字符来确定是需要输出整数还是字符串。对于整数和字符串的处理,分别使用printf内置函数来进行输出。最后,使用va_end宏清理变量args。
8.4 getchar的实现分析
getchar 由宏实现:#define getchar() getc(stdin)。
getchar 有一个int型的返回值.当程序调用getchar时.程序就等着用户按键.用户输入的字符被存放在键盘缓冲区中.直到用户按回车为止(回车字符也放在缓 冲区中).当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符.getchar函数的返回值是用户输入的第一个字符的ASCII 码,如出错返回-1,且将用户输入的字符回显到屏幕.如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用 读取.也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
hello 程序调用 getchar 后,它就等着键盘输入。当我们输入时,会发生异常,内核中的键盘中断处理子程序来进行处理。而异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar通过陷阱调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。此时getchar 开始从read返回的字符串中读入其第一个字符。
getchar 函数的返回值是用户输入的字符的 ASCII 码,若文件结尾则返回 -1(EOF),且将输入的字符回显到屏幕
8.5本章小结
本章介绍了linux系统下的IO的基本知识,讨论了IO在linux系统中的形式以及实现的模式。然后对printf和getchar两个函数的实现进行了深入的探究。
结论
- hello经历的过程
Hello程序的生命周期可以概括如下:
1. 编写源代码:使用C语言编写源代码文件,得到源程序hello.c。
2. 预处理:源代码文件通过预处理器处理,展开宏、包含头文件等操作,生成预处理后的文件hello.i。
3. 编译:预处理后的文件经过编译器编译,生成汇编代码,得到汇编文件hello.s。
4. 汇编:汇编器将汇编代码转换成机器可读的二进制代码,生成可重定位目标文件hello.o。
5. 链接:链接器将hello.o与其他目标文件等链接,生成可执行文件hello。
6. 运行程序:通过shell或终端输入可执行文件的名称,运行程序。
7. 创建进程:shell调用fork函数创建一个新的进程来运行Hello程序。
8. 加载到内存:MMU将需要访问的虚拟地址转化为物理地址,程序被加载到内存中,并分配相应的资源。
9. 执行程序:CPU执行程序的指令,将输出信息发送到标准输出设备。
10. 程序结束:程序执行完毕,操作系统回收相关资源,进程结束。
- 收获与感悟
通过本次大作业重新温习了课程中的知识,并将其应用到实际项目中。其中不仅加深了对计算机系统底层运行原理的理解,还提升了自己的编程能力和优化技巧。从Hello程序开始逐步深入了解并应用CSAPP中的内容,帮助我建立坚实的基础,并将理论知识转化为实际应用能力。特别是在优化程序性能的章节中,学习如何从程序员的角度思考、分析和改进程序性能,对于提高代码质量和效率至关重要。此外,通过接触Linux系统和汇编代码也为自己开启了更广阔的学习和探索之路。
附件
1.hello.c:C语言源代码文件。
2.hello.i:预处理后的文本文件。
3.hello.s:编译后的汇编文件。
4.hello.o:汇编得到的可重定位文件。
5.hello:链接产生的的可执行文件。
6.elf.txt:hello.o的elf文件。
7.asm.txt:hello.o反汇编得到的结果文件。
8.elf1.txt:hello的elf文件。
9.asm1.txt:hello反汇编得到的结果文件。
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] Randal E. Bryant & David R. O’Hallaron. 深入理解计算机系统[M]. 北京:机械工业出版社,2019.
[2] 静态链接(下)——重定位 http://t.csdn.cn/BpL7H
[3] 【Linux系统编程】——剖析shell运行原理 http://t.csdn.cn/1mcfr
[4] 逻辑地址,线性地址和物理地址转换 http://t.csdn.cn/2FJ5Y