哈工大计算机系统大作大作业
题 目 程序人生-Hello’s P2P
专 业 计算机与电子通信
学 号 **********
班 级 **********
学 生 张馨心
指 导 教 师 刘宏伟
计算机科学与技术学院
2024年5月
摘 要
本篇论文旨在阐述C语言程序从源代码到可执行文件的转换过程。以“hello.c”程序为例,本文深入剖析了计算机生成“hello”可执行文件的全过程,包括预处理、编译、汇编、链接以及进程管理等关键环节。程序从最初的C语言源代码开始,首先经历预处理阶段,生成扩展名为“.i”的中间文件;随后,该文件被进一步转换为汇编语言文件(.s),使其更接近机器可理解的形式;经过汇编和链接等一系列复杂处理后,最终形成可执行文件,标志着程序进入新的运行阶段。在运行阶段,操作系统为其分配资源,包括虚拟内存、独立地址空间以及时间片,确保程序能够顺利执行。随着进程的结束,程序的生命周期也告一段落。
本文不仅从理论层面探讨了程序在各个阶段的具体变化,还通过实际操作演示了各阶段的详细过程及结果,深入剖析了计算机系统的工作原理和体系结构,帮助读者更好地理解C语言程序的编译与执行机制。
关键词:计算机系统;计算机体系结构;汇编语言
目 录
第1章 概述
1.1 Hello简介
HelloWorld无疑是每一位程序员的启蒙程序。在诸如PyCharm、CodeBlocks等集成开发环境(IDE)中,当我们创建新项目时,项目文件中默认的程序便是输出“Hello,world!”。然而,这个看似简单的程序,其实是早期实现的“From Program to Process”(P2P)过程的典型示例。
P2P:从程序到进程
P2P并不是“Peer-to-peer”的简称,而是指从源代码程序(Program)转变为运行时进程(Process)。要让一个C语言程序(如hello.c)运行起来,需要经过四个关键阶段:预处理、编译、汇编和链接。具体过程如下:
首先,编写完成的hello.c文件会经过预处理器处理,生成预处理后的文件hello.i;接着,编译器将hello.i文件编译成汇编代码文件hello.s;然后,汇编器将hello.s文件翻译成可重定位的目标文件hello.o;最后,链接器将一个或多个可重定位目标文件组合起来,形成最终的可执行目标文件hello(通常也可以指定为.out文件)。经过这一系列步骤,我们便得到了可执行文件,随后可以在shell中运行它,shell会为其分配进程空间。
020:从无到无
“020”则描述了程序从无到运行再到结束的完整生命周期,即“From Zero to Zero”。最初,内存中并无与hello文件相关的任何内容。当shell调用execve函数启动hello程序时,内核会将虚拟内存映射到物理内存,并从程序入口开始加载和运行。程序进入main函数后,开始执行目标代码。运行结束后,shell父进程会回收hello进程,内核则删除与hello文件相关的所有数据结构。在execve函数执行过程中,内核为程序映射虚拟内存、分配物理内存,并为其分配时间片以执行逻辑控制流。当hello程序运行结束时,shell负责回收hello进程,内核则清理相关资源,完成从无到无的全过程。
1.2 环境与工具
硬件环境:处理器:13th Gen Intel(R) Core(TM) i9-13900HX 2.20 GHz
机带RAM:16.0GB
系统类型:64位操作系统,基于x64的处理器
软件环境:Windows11 64位,VMware,Ubuntu 64位
开发与调试工具:Visual Studio Code;vim,gidit ,objdump,edb,gcc,readelf等开发工具
1.3 中间结果
hello.c:原始hello程序的C语言代码
hello.i:预处理过后的hello代码
hello.s:由预处理代码生成的汇编代码
hello.o:二进制目标代码
hello:进行链接后的可执行程序
hello_asm.txt:反汇编hello.o得到的反汇编文件
hello1_asm.txt:反汇编hello可执行文件得到的反汇编文件
1.4 本章小结
本章首先对Hello的P2P(从程序到进程)和020(从无到无)流程进行了全面介绍,涵盖了流程的设计思路与实现方法。随后,详细阐述了本实验所需的硬件配置、软件平台以及开发工具,并对实验过程中生成的各个中间结果文件的名称及其功能进行了说明。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
从字面上理解,预处理就是在源程序被编译器正式处理之前,由预处理器(cpp)根据源文件中的宏定义、条件编译等指令对源文件进行预先的修改和处理。这一阶段会执行诸如宏替换、头文件包含、条件编译以及注释删除等预处理命令。需要特别强调的是,预处理过程发生在源代码被转换为二进制代码之前。
2.1.2 预处理作用
预处理阶段的核心作用是为编译器提供一个经过初步处理的源代码文件,以便顺利开展后续的编译工作。这一过程并不对程序的源代码进行语义解析,而是将源代码进行分割或处理,形成特定的单元,为后续编译步骤奠定基础。
预处理指令是以#号开头的代码行。#号必须是该行除了任何空白字符外的第一个字符。#后是指令关键字,在关键字和#号之间允许存在任意个数的空白字符。整行语句构成了一条预处理指令,该指令将在编译器进行编译之前对源代码做某些转换。下面是部分预处理指令:
指令 | 用途 |
# | 空指令,无任何效果。 |
#include | 包含一个源代码文件。 |
#define | 定义宏。 |
#undef | 取消已定义的宏。 |
#error | 停止编译并显示错误信息。 |
#if | 如果给定条件为真,则编译下面代码。 |
#elif | 如果前#if条件不为真,当前条件为真,则编译下面代码。 |
表2.1.2-1预处理指令
2.2在Ubuntu下预处理的命令
在终端输入:
gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i,这样便在原目录生成了hello.i文件。
图2.2-1生成hello.i的终端截图
2.3 Hello的预处理结果解析
在Linux下我使用gedit打开hello.i文件,对比了源程序和预处理后的程序。观察发现,预处理生成文件hello.i相对于hello.c,以#开头的预处理命令被扩展成了三千多行,在main函数代码出现之前的大段代码源自于的头文件<stdio.h> <unistd.h> <stdlib.h> 的依次展开。程序的最后一部分与hello.c中的main函数完全相同。
图2.3-1 hello.i文件内容
2.4 本章小结
本章介绍了预处理的概念、作用以及在Ubuntu下通过gcc指令进行预处理的方法,通过对hello.c进行预处理,将hello.c与hello.i进行对比说明了预处理阶段的作用。
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
编译是将预处理后的源代码(如以.i为扩展名的文件)转换为汇编语言程序(以.s为扩展名的文件)的过程。这一过程由编译器完成,编译器的前端负责进行词法分析和语法分析等工作,随后由优化器对代码进行优化。最终,后端将优化后的代码翻译成汇编代码。编译过程涵盖翻译、优化、检查等多个步骤,并生成可执行文件,以确保程序的正确性和性能。
3.1.2 编译的作用
编译后生成的.s文本文件是汇编语言程序,更容易让计算机理解,编译是将程序转换为机器指令的中间过程。
3.2 在Ubuntu下编译的命令
在终端中输入:
gcc -m64 -no-pie -fno-PIC -S -o hello.s hello.i,将hello.i编译生成hello.s。
图3.2-1生成hello.s的终端截图
3.3 Hello的编译结果解析
3.3.1 字符串常量操作
在main函数之前前,在.rodata处的.LC0和.LC1已经存储了字符串常量。
"用法: Hello 学号 姓名 手机号 秒数!\n"
"Hello %s %s %s\n"
图3.3.1-1 hello.s截图
在main函数中使用字符串时,得到字符串的首地址(movl相当于转移操作)。
图3.3.1-2 hello.s截图
3.3.2局部变量操作
hello中涉及局部变量操作的地方主要是源程序中的int i。在对应的汇编语言中,局部变量会被存储在栈上。在下图红框中,栈指针向下移动了32个字节,在栈中为局部变量i保留了空间。
图3.3.2-1 hello.s截图
i在栈中使用,地址为 -4(%rbp)。
图3.3.2-2 局部变量i地址
3.3.3 赋值操作
由程序源代码可知,赋值操作出现了1次,在for循环中对局部变量i的赋值。
图3.3.3-1 hello.s截图
3.3.4 参数传递操作
在main函数的开始部分,因为后面还会使用到%rbp数组,所以先将%rbp(帧指针)压栈保存起来。如图,代码通过mov指令将栈指针减少32位,然后分别将%edi和%rsi的值存入栈中。%rbp-20和%rbp-32的位置分别存了argv数组和argc的值。
图3.3.4-1 hello.s截图
3.3.5 数组操作
数组为main()函数的传入参数中的argv[],如图,argv地址存放在%rsi中。
图3.3.5-1 hello.s截图
对数组的操作,都是先找到数组的首地址,然后加上偏移量即可。例如在main中,调用了argv[1]和argv[2],在汇编代码中,每次将%rbp-32的的值即数组首地址传%rax,然后将%rax分别加上偏移量24和16,得到了argv[1]和argv[2],再分别存入对应的寄存器%rsi和%rdx作为第二个参数和第三个参数,之后调用printf函数时使用。
图3.3.5-2 hello.s截图
3.3.6 函数操作
由程序源代码可知,存在对函数的调用和返回。
(1)printf函数
printf函数调用参数argv[1],argv[2],汇编代码如下图:
图3.3.6-1 hello.s截图
(2)exit函数
图3.3.6-2 hello.s截图
(3)atoi函数
图3.3.6-3 hello.s截图
atoi函数将参数argv[3]放入寄存器%rdi中用作参数传递,简单使用call指令调用。atoi函数用于将字符串转换为整数。
(4)sleep函数
图3.3.6-4 hello.s截图
代码将转换完成的秒数从%eax传递到%edi中,edi存放sleep的参数,再使用call调用。Sleep函数可以让程序休眠一段时间。
(5)getchar函数
图3.3.6-5 hello.s截图
getchar函数用于获取单个字符。
(6)puts函数
图3.3.6-6 hello.s截图
在Og选项下,单独输出固定字符串的printf函数被编译器优化成了puts函数。
3.3.7 运算操作
由程序源代码可知,在for(i=0;i<10;i++)对i进行运算操作,汇编语句中采用了addl和cmpl指令进行操作。
图3.3.7-1 hello.s截图
3.3.8 关系判断
由程序源代码可知,关系判断出现了2次,分别在if(argc!=5)和for(i=0;i<10;i++)。
if(argc!=5):
图3.3.8-1 hello.s截图
for(i=0;i<10;i++):
图3.3.8-2 hello.s截图
3.3.9 for循环
图3.3.9-1 hello.s截图
程序重复图中步骤,获取了argv[2]和argv[3]的值,分别存储在%rdx和%rsi寄存器中。在这之后,函数继续执行for循环内部的内容。
addl $1, -4(%rbp):将循环计数器加1,通常是通过修改存储在-4(%rbp)处的值来实现。接着,函数回到.L4标记处,继续执行循环体。这些汇编指令重复执行循环体,直到循环条件不再满足(即i < 10)。
3.4 本章小结
这一章详细地介绍了C编译器如何把hello.c文件转换成hello.s文件的过程,简要说明了编译的含义和功能,演示了编译的指令,并通过分析生成的hello.s文件中的汇编代码,探讨了数据处理,函数调用,赋值、算术、关系等运算以及控制跳转、类型转换等方面,比较了源代码和汇编代码分别是怎样实现这些操作的。
第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编是指汇编器(assemble)将包含汇编语言的.s文件翻译为机器语言指令,并把这些指令打包成为一个可重定位目标文件的格式,生成目标文件.o文件。
4.1.2 汇编的作用
计算机只能识别处理机器指令程序,汇编过程将汇编语言程序翻译为了机器指令,进一步向计算机能够执行操作的形式迈进,便于计算机直接进行分析处理。
4.2 在Ubuntu下汇编的命令
在终端中输入:
gcc -m64 -no-pie -fno-PIC -c -o hello.o hello.s,将hello.s编译生成hello.o。
图4.2-1 生成hello.o的终端截图
4.3 可重定位目标elf格式
在终端中输入:
readelf -a hello.o>hello_elf.txt,可查看hello.o的ELF格式。有如下部分:
4.3.1 ELF头
图4.3.1-1 hello.o的elf文件头
ELF头以一个16字节的序列(Magic,魔数)开始,这个序列描述了生成文件的系统的字的大小和字节顺序。ELF头剩下部分的信息包含帮助连接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型和机器类型等。例如上图中,数据表示了系统采用小端序,文件类型为REL(可重定位文件),节头数量Number of section headers为14个等信息。
4.3.2节头
图4.3.2-1 hello.o的节头
ELF文件中有很多段,节头就是保存这些段的基本属性的结构。段表是ELF文件中出文件头以为最重要的结构,它描述了ELF的各个段的信息,如每个段的名字,段的长度,在文件中的偏移,读写权限等。ELF文件的段结构就是由段表决定的,编译器,链接器和装载器都是通过段表来定位和访问各个段的属性的。
4.3.3重定位表
图4.3.3-1 hello.o的重定位表
链接器在处理目标文件时,需要对目标文件中某些部位进行重定位,及代码段和数据段中那些对绝对地址引用的位置。这些重定位的信息都记录在ELF文件的重定位表里面,对于每个需要重定位的代码段或数据段,都会有相应的重定位表。
4.3.4符号表
图4.3.4-1 hello.o的符号表
ELF文件中用到了很多符号,比如段名,变量名等。因为字符串的长度往往不是固定的,故难以用固定的结构表示。常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。
4.4 Hello.o的结果解析
在终端中输入:
objdump -d -r hello.o,分析hello.o的反汇编,与hello.s对比发现:
(1)hello.s前没有相应的机器码,反汇编代码前面有对应的机器码。
(2)hello.s中列出了把每个段的段名,反汇编代码则没有。
(3)hello.s中,函数调用之后跟随其函数名,反汇编代码则没有。
(4)hello.s中操作数采用的是十进制,反汇编代码中采用的是十六进制
图4.4-1 hello.o的反汇编结果
4.5 本章小结
本章介绍了汇编的概念、作用以及在Ubuntu下通过gcc指令对.s文件进行汇编的方法,通过对hello.s进行预处理得到hello.o,分析hello.o的ELF格式,并将hello.o与hello.s进行对比说明区别。
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念
链接是指将可重定位文件中的各种符号引用和符号定义转换为可执行文件中合适信息(通常是虚拟内存地址)的过程。根据链接的时机和方式,链接可以分为静态链接和动态链接。静态链接是在程序开发阶段,由程序员使用静态链接器(如ld,gcc在后台也会调用ld)进行手动链接的过程;而动态链接则是在程序运行期间,由系统调用动态链接器(如ld-linux.so)自动完成的链接过程。
5.1.2链接的作用
链接的作用是将汇编语言代码彻底转化为机器代码。它以可重定位目标文件和命令行参数作为输入,最终生成可执行目标文件。
5.2 在Ubuntu下链接的命令
在终端中输入:
ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o,链接生成可执行文件hello。
图4.2-1 生成hello的终端截图
5.3 可执行目标文件hello的格式
5.3.1 ELF头
在终端中输入:
readelf -h hello,查看hello文件的ELF头。
图5.3.1-1 hello的elf头
hello的ELF头中类型显示的是EXEC,表示时可执行目标文件。
5.3.2节头
在终端中输入:
readelf -S hello并回车,查看hello文件的节头。
图5.3.2-1 hello的节头
节头表对hello中所有信息进行了声明,包括了大小、偏移量、起始地址以及数据对齐方式等信息。根据始地址和大小,我们就可以计算节头部表中的每个节所在的区域。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析。
图5.4-1 edb软件的显示界面
由下图可知,虚拟空间从0x401000开始。
图5.4-2 hello的虚拟地址空间
通过“左上方选项卡 - Plugins - SymbolViewer”查看本进程的虚拟地址空间各段信息,与5.3对照分析可知一致。
图5.4-3 hello的虚拟地址空间各段信息
5.5 链接的重定位过程分析
在终端中输入:
objdump -d -r hello,对可执行目标文件hello进行反汇编。
图5.5-1 hello的反汇编结果
对比发现,hello 和 hello.o 反汇编生成的代码在结构和语法上是基本相同的,只不过hello的反汇编代码多了非常多的内容,我们通过比较不同来看一下区别:
(1)虚拟地址不同,hello.o的反汇编代码虚拟地址从0开始,而hello的反汇编代码虚拟地址从0x401000开始。这是因为hello.o在链接之前只能给出相对地址,而hello在链接之后得到的是绝对地址。
hello反汇编 | hello.o反汇编 |
![]() | ![]() |
表5.5-1 hello与hello.o的反汇编结果比较
(2)反汇编节数不同,hello.o只有.text节,里面只有main函数的反汇编代码。而hello在main函数之前加上了链接过程中重定位而加入的各种在hello中被调用的函数、数据,增加了.init,.plt,.plt.sec等节的反汇编代码。
5.6 hello的执行流程
当使用edb运行hello程序时,其执行过程如下:
(1)程序的入口地址位于0x7a6a:4119f540处,这里是动态链接库ld-2.2.27.so的入口点_dl_start。
图5.6-1 hello使用动态链接库
(2)程序从_dl_start开始执行,随后跳转到_dl_init。在_dl_init中完成一系列初始化操作后,程序跳转到hello程序的入口点_start。
(3)接着,程序通过call指令跳转到动态链接库ld-2.27.so中的_libc_start_main函数。该函数负责进行一些必要的初始化工作,并调用main函数。
(4)在_libc_start_main函数执行过程中,程序会调用动态链接库中的__cxa_atexit函数。该函数用于设置程序结束时需要调用的函数表。
(5)然后,程序返回到_libc_start_main函数继续执行,接着调用hello可执行文件中的__libc_csu_init函数。该函数是由静态库引入的,用于完成一些初始化工作。
(6)程序再次返回到_libc_start_main函数,紧接着调用动态链接库中的_setjmp函数,用于设置一些非本地跳转。
(7)在这之后,程序继续返回到_libc_start_main函数,并正式开始调用main函数。
(8)由于在edb运行hello时未提供额外的命令行参数,因此程序在main函数的第一个if语句处通过exit(1)直接结束程序。
(9)程序退出。
5.7 Hello的动态链接分析
当程序调用一个由共享库定义的函数时,由于共享库在内存中的加载位置是不确定的,编译器无法在编译时预测该函数在运行时的具体地址。为了解决这一问题,编译系统采用了延迟绑定机制,将函数地址的绑定推迟到首次调用该函数时。延迟绑定的实现依赖于全局偏移表(GOT)和过程链接表(PLT)这两个关键数据结构。
PLT 是一个代码数组,每个条目占用 16 字节。其中,PLT[0]是一个特殊条目,用于跳转到动态链接器。每个被可执行程序调用的库函数在 PLT 中都有一个对应的条目,负责调用该函数。GOT 是一个地址数组,每个条目占用 8 字节。在与 PLT 协同工作时,GOT[0]和 GOT[1]包含动态链接器在解析函数地址时需要使用的信息,GOT[2]是动态链接器在ld-linux.so模块中的入口点,其余的每个条目对应一个被调用的函数,其地址需要在运行时解析,且每个条目都与一个 PLT 条目相对应。
当程序首次调用某个函数时,程序不会直接调用目标函数,而是调用该函数对应的 PLT 条目。PLT 条目中的第一条指令通过 GOT 进行间接跳转。初始时,每个 GOT 条目指向其对应的 PLT 条目的第二条指令,因此这条间接跳转只是将控制权传回 PLT 条目的下一条指令。随后,PLT 条目将函数的 ID 压入栈中,并跳转到 PLT[0]。PLT[0]通过 GOT[1]间接地将动态链接器的一个参数压入栈中,然后通过 GOT[2]间接跳转到动态链接器。动态链接器利用栈中的两个条目(函数 ID 和参数)来确定函数的运行时位置,并将控制权传递给该函数。首次调用完成后,动态链接器会将目标函数的实际地址写入对应的 GOT 条目中。因此,后续对该函数的调用可以直接通过 GOT 进行间接跳转,而无需再次经过动态链接器,从而提高调用效率。
对于动态链接的程序hello,在动态链接器加载之前,其重定位信息是不完整的。只有在动态链接器加载并解析函数地址之后,才会完成重定位。因此,动态链接的重定位过程是在运行时完成的,而不是在编译时。
图5.5-1 got段源代码截图
5.8 本章小结
本章聚焦于链接的原理和实践。首先介绍了链接的概念及其在程序构建中的重要性,接着通过在Ubuntu系统中使用 ld 指令将 hello.o 文件链接为可执行文件 hello,展示了链接的具体操作过程。此外,本章还对生成的 hello 文件的ELF格式进行了分析,并深入剖析了动态链接的实现机制。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念
进程是程序运行的实例,是操作系统分配资源和调度的基本单位。
6.1.2进程的作用
进程为程序的执行提供独立的运行环境,确保程序能够正确地获取和使用系统资源,同时与其他进程隔离,避免相互干扰。通过进程管理,操作系统能够有效地调度多个程序并发运行,提高系统的资源利用率和运行效率。
6.2 简述壳Shell-bash的作用与处理流程
Shell(如bash)作为用户与操作系统之间的交互界面,主要负责接收用户输入的命令并将其传递给操作系统执行。它的作用包括命令解析、命令执行、环境管理以及提供交互式操作等功能。Shell的处理流程大致如下:首先从终端或脚本文件中读取输入,然后将输入的命令字符串分解为命令名和参数,接着在系统的可执行路径中查找对应的命令程序,通过系统调用(如fork()和exec())创建子进程并加载命令程序运行,最后将命令的执行结果输出到终端或指定的文件中,并将命令的执行状态返回给用户,以便进行进一步的操作或判断。
6.3 Hello的fork进程创建过程
当Shell执行hello程序时,会通过fork()系统调用创建子进程。新创建的子进程与父进程几乎完全相同,但存在关键区别。子进程获得父进程用户级虚拟地址空间的独立副本,包括代码段、数据段、堆、共享库和用户栈等内存区域。此外,子进程还继承了父进程所有打开的文件描述符,使得子进程能够直接访问父进程已打开的文件资源。父子进程之间最显著的区别在于它们具有不同的进程ID(PID)。
fork()调用的一个独特特性是它被调用一次却会返回两次:在父进程中返回子进程的PID,在子进程中则返回0。调用完成后,父子进程开始并发执行。在运行hello程序时,新创建的子进程在前台执行,而父进程Shell会暂时挂起,等待子进程执行完毕后才继续运行。这种机制既保证了进程间的独立性,又实现了资源的合理共享。
6.4 Hello的execve过程
当Shell通过fork创建子进程后,execve函数会在该子进程的上下文中加载并运行新的hello程序。execve函数接收三个关键参数:可执行目标文件名、参数列表和环境变量列表,这些都由Shell预先构造并传递。值得注意的是,除非找不到指定的可执行文件,否则execve调用不会返回,这意味着它是一次性调用且正常情况下永远不会返回。
execve的执行过程会彻底替换当前进程正在运行的程序。首先,它会清除进程原有的用户区域,包括所有数据和代码;接着为hello程序创建新的私有内存区域,包括代码段、数据段、.bss段和栈区域,这些区域都采用写时复制机制;然后映射必要的共享区域;最终将控制权转交给新程序的入口点,完成程序的完全替换。这个过程确保了新程序能够在一个干净的环境中启动运行。
6.5 Hello的进程执行
在现代计算机系统中,程序的执行涉及多个关键概念,包括逻辑控制流、并发与多任务、用户模式与内核模式切换,以及进程上下文切换。逻辑控制流是由程序计数器(PC)值组成的序列,这些值与可执行文件或动态链接的共享对象中的指令一一对应。进程通过时间分片的方式轮流使用处理器,每个进程执行一部分逻辑流后被抢占,以便其他进程运行。这种轮流执行的方式被称为多任务,而多个逻辑流在时间上重叠执行则称为并发。
操作系统通过用户模式和内核模式来保护内核的完整性。处理器通过控制寄存器中的模式位来区分这两种模式。内核模式的代码可以访问所有处理器指令、内存和I/O空间,而用户模式的代码则受到限制,需要通过系统调用请求内核模式的特权操作。当程序运行时,初始状态为用户模式,但在发生中断、故障或系统调用时,进程会从用户模式切换到内核模式。内核处理程序在内核模式中运行,处理完成后,处理器会将模式切换回用户模式。
进程上下文切换是操作系统调度进程时的关键操作。上下文是内核重新启动被抢占进程所需的状态,包括通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和内核数据结构等。当内核决定抢占当前进程并恢复先前被抢占的进程时,会保存当前进程的上下文,并恢复目标进程的上下文,然后将控制权传递给新恢复的进程。
6.6 hello的异常与信号处理
6.6.1异常类型
(1)运行时异常
(2)资源异常
(3)输入异常
6.6.2. 产生的信号
SIGINT:当用户按下Ctrl+C时发送,通常用于中断程序。
SIGTSTP:当用户按下Ctrl+Z时发送,用于暂停程序。
SIGTERM:请求程序终止的正常信号。
6.6.3具体信号处理
(1)乱按字
图6.6.3-1 乱按字的结果
在键盘中乱打字并没有改变printf的输出,不影响程序的正常运行。
(2)按Ctrl+Z
图6.6.3-2 输入Ctrl+Z的结果
Ctrl+Z的功能是向进程发送SIGSTP信号,进程接收到该信号之后会将该作业挂起,但不会回收。如图,PID为3865的hello进程仍然在运行中,hello的后台job id=1。
调用fg命令,fg命令用于将后台作业(在后台运行的或者在后台挂起的作业)放到前台终端运行。运行结果如下。我们发现,挂起前后总共的输出次数仍为10次。
图6.6.3-3 调用fg命令
(3)Ctrl+C
图6.6.3-4 输入Ctrl+C的结果
在键盘中输入Ctrl+C,Ctrl+C命令内核向前台发送SIGINT信号,终止前台作业。
(4)一直按回车
图6.6.3-5 一直按回车的结果
观察发现,在hello执行过程中不停按回车,不仅会在printf输出时会显示出回车,在hello进程执行完毕后,回车的信息也同样被发送到了shell中,使shell进行了若干次的刷新换行。
6.7本章小结
本章介绍了进程的概念和作用,分析了操作系统通过shell调用fork函数以及execve函数的过程,以及hello 程序的上下文切换、异常与信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
• 逻辑地址:逻辑地址是程序中使用的地址,也称为相对地址。它是程序员在编写程序时使用的地址,与程序的代码段、数据段等有关。例如在hello程序中,函数的地址、变量的地址等都是逻辑地址。这些地址是相对于程序的代码段或数据段的起始地址的偏移量。比如一个变量在数据段中,其逻辑地址就是它在数据段中的偏移位置。
• 线性地址:线性地址是逻辑地址经过段式管理后得到的地址。在32位系统中,线性地址是一个32位的地址,它是在逻辑地址的基础上,通过段基址与偏移量的计算得到的。线性地址是连续的地址空间,为后续的页式管理提供了基础。
• 虚拟地址:虚拟地址与逻辑地址类似,它是在现代操作系统中,程序所看到的地址空间。虚拟地址空间是由操作系统管理的,它允许程序使用比实际物理内存更大的地址空间。虚拟地址通过页表等机制映射到物理地址。对于hello程序来说,它在运行时看到的地址空间就是虚拟地址空间,操作系统会负责将虚拟地址转换为物理地址。
• 物理地址:物理地址是实际存在于内存中的地址,是CPU访问内存时使用的地址。物理地址是由虚拟地址经过页式管理等转换得到的。物理地址与内存的实际硬件有关,它直接对应于内存中的存储单元。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在Intel架构中,段式管理是通过段选择符和段描述符来实现逻辑地址到线性地址的转换的。
• 段选择符:段选择符是一个16位的寄存器,它包含了段描述符表的索引、TI标志(指示是全局描述符表还是局部描述符表)和RPL(请求特权级别)。段选择符用于选择段描述符表中的一个段描述符。
• 段描述符:段描述符是一个8字节的数据结构,它包含了段的基地址、段的长度、访问权限等信息。段描述符表可以是全局描述符表(GDT)或局部描述符表(LDT)。
• 转换过程:当程序访问一个逻辑地址时,CPU会根据段选择符找到对应的段描述符,然后将逻辑地址中的偏移量加上段描述符中的基地址,得到线性地址。例如,如果逻辑地址是‘段选择符:偏移量’的形式,CPU会先找到段选择符对应的段描述符,取出其中的基地址,然后将偏移量加上基地址,得到线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是将线性地址空间划分为固定大小的页面,每个页面对应一个物理内存块。
• 页面大小:页面大小通常是2的幂,如4KB、2MB等。在hello程序运行时,操作系统会根据页面大小将程序的线性地址空间划分为多个页面。
• 页表:页表是一个数据结构,用于记录线性地址空间中的页面与物理内存块之间的映射关系。每个页表项包含了页面的物理地址、访问权限等信息。
• 转换过程:当程序访问一个线性地址时,CPU会根据线性地址计算出对应的页面号和页面内的偏移量。然后通过页表找到页面号对应的物理地址,将页面内的偏移量加上物理地址,得到最终的物理地址。例如,如果线性地址是‘页面号×页面大小+页面内偏移量’的形式,CPU会先通过页表找到页面号对应的物理地址,然后将页面内偏移量加上物理地址,得到物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
• TLB(Translation Lookaside Buffer):TLB是一种高速缓存,用于缓存虚拟地址到物理地址的映射关系。当程序访问虚拟地址时,CPU会先在TLB中查找是否存在对应的映射关系。如果存在,就可以快速得到物理地址,避免了访问页表的开销。如果TLB中没有命中,CPU才会访问页表进行地址转换,并将新的映射关系存入TLB。
• 四级页表:在现代操作系统中,为了支持更大的地址空间,通常会使用多级页表。四级页表是一种常见的页表结构,它将虚拟地址空间分为多个层次,每个层次对应一个页表。例如,一个虚拟地址可以表示为‘PML4索引:PDP索引:PD索引:PT索引:页面内偏移量’的形式。CPU会依次访问PML4表、PDP表、PD表、PT表,最终找到页面的物理地址。TLB与四级页表结合使用,可以提高地址转换的效率。当程序访问虚拟地址时,CPU会先在TLB中查找,如果TLB未命中,才会逐级访问四级页表进行地址转换。
7.5 三级Cache支持下的物理内存访问
• Cache(高速缓存):Cache是一种高速存储器,位于CPU和内存之间,用于缓存频繁访问的数据和指令。三级Cache是指在系统中存在L1、L2、L3三个层次的Cache。L1 Cache速度最快,容量最小,通常集成在CPU内部;L2 Cache速度次之,容量稍大;L3 Cache速度相对较慢,但容量更大。
• 物理内存访问过程:当CPU访问物理地址时,会先在L1 Cache中查找是否存在对应的缓存块。如果命中,可以直接从L1 Cache中读取数据,访问速度快。如果L1 Cache未命中,会继续在L2 Cache中查找。如果L2 Cache也未命中,再在L3 Cache中查找。如果三级Cache都未命中,才会访问物理内存。三级Cache的使用可以显著提高内存访问的速度,减少CPU等待时间。
7.6 hello进程fork时的内存映射
• fork系统调用:fork系统调用用于创建一个新进程,新进程是调用进程的副本。在hello程序中,当调用fork时,操作系统会创建一个新的进程,该进程的内存空间与原进程的内存空间相似。
• 写时复制(Copy-On-Write,COW):在fork过程中,操作系统通常会采用写时复制技术来优化内存映射。在创建新进程时,操作系统不会立即复制原进程的内存内容,而是将原进程和新进程的内存页映射到相同的物理页面,并将这些页面标记为只读。当新进程或原进程试图写入这些页面时,操作系统才会复制相应的页面内容到新的物理页面,并更新页表。这样可以减少内存的使用,提高fork的效率。
7.7 hello进程execve时的内存映射
• execve系统调用:execve系统调用用于加载并运行一个新的程序。在hello程序中,当调用execve时,操作系统会加载hello程序的可执行文件,并将其映射到进程的内存空间中。
• 内存映射过程:操作系统会根据hello程序的可执行文件的段信息,将代码段、数据段等映射到进程的虚拟地址空间。每个段会被分配一个或多个页面,并通过页表映射到物理内存。同时,操作系统还会设置页面的访问权限,例如代码段通常是只读的,数据段是可以读写的。在execve过程中,操作系统还会清理进程的原有内存空间,确保新的程序能够正常运行。
7.8 缺页故障与缺页中断处理
• 缺页故障:当程序访问的虚拟地址对应的物理页面不在内存中时,就会发生缺页故障。例如,程序试图访问一个未被加载到内存的页面,或者访问的页面被换出到了磁盘。
• 缺页中断处理:当发生缺页故障时,CPU会触发缺页中断。操作系统会处理缺页中断,将缺失的页面加载到内存中。具体步骤如下:
(1)操作系统会根据页表找到缺失页面的磁盘位置。
(2)选择一个合适的内存页面进行替换。如果内存已满,操作系统会根据页面置换算法(如LRU算法)选择一个页面换出到磁盘。
(3)将缺失的页面从磁盘加载到内存中,并更新页表。
(4)恢复程序的执行,继续访问原来的虚拟地址。
7.9动态存储分配管理
• 动态内存管理的基本方法:
(1)malloc/free:malloc函数用于动态分配内存,它会从堆中分配一块指定大小的内存,并返回指向该内存的指针。free函数用于释放之前分配的内存,将内存归还给堆。在hello程序中,如果使用printf函数时需要动态分配内存,就会调用malloc。
(2)内存分配算法:常见的内存分配算法有首次适应算法、最佳适应算法、最坏适应算法等。这些算法用于在堆中找到合适的内存块进行分配。
• 动态内存管理的策略:
(1)内存池:内存池是一种预先分配一块较大的内存空间,然后从中分配小块内存的策略。它可以减少频繁的内存分配和释放的开销,提高内存分配的效率。
(2)垃圾回收:在一些高级语言中,如Java,会使用垃圾回收机制来自动管理动态内存。垃圾回收器会定期扫描内存,回收不再使用的内存块。在C语言中,程序员需要手动管理动态内存,使用malloc和free等函数进行内存分配和释放。
7.10本章小结
本章主要介绍了hello程序的存储管理相关知识。首先,详细解释了逻辑地址、线性地址、虚拟地址和物理地址的概念及其之间的转换关系。然后,分别介绍了Intel逻辑地址到线性地址的段式管理、线性地址到物理地址的页式管理、TLB与四级页表支持下的地址转换、三级Cache支持下的物理内存访问等存储管理机制。接着,探讨了hello进程在fork和execve时的内存映射过程,以及缺页故障与缺页中断处理的机制。最后,简述了动态存储分配管理的基本方法与策略。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
Linux将所有的IO设备都模型化为文件,允许用户以操作文件的方式访问设备。这种统一的接口简化了设备管理,用户可以使用标准的文件操作函数如open、read、write和close来与设备交互。
设备管理在Unix/Linux系统中通过统一的IO接口实现。这些接口隐藏了底层设备的细节,为用户提供了简洁的操作方式。无论是硬盘、键盘还是显示器,都被视为文件,使得程序设计更加通用和灵活。
8.2 简述Unix IO接口及其函数
Unix IO接口提供了一组标准的函数用于设备的输入输出操作,主要包括以下函数:
(1)open:打开一个文件或设备,返回文件描述符。
(2)read:从文件或设备读取数据。
(3)write:向文件或设备写入数据。
(4)close:关闭文件或设备,释放资源。
(5)lseek:移动文件指针位置。
(6)ioctl:设备特定的控制操作。
这些函数提供了抽象层,使得应用程序不需要关心底层硬件的具体实现细节。所有IO操作都通过文件描述符进行,标准输入、输出和错误分别对应文件描述符0、1和2。
8.3 printf的实现分析
printf函数的实现涉及多个层次的操作:
(1)vsprintf函数负责处理格式字符串,将各种类型的参数转换为格式化的字符串。
(2)格式化后的字符串被传递给write系统调用。
(3)通过陷阱指令(如int 0x80或syscall)进入内核模式。
(4)内核中的字符显示驱动子程序执行以下操作:
- 将ASCII字符转换为字模库中的点阵图案
- 将点阵信息写入显示内存(VRAM),存储每个像素点的RGB颜色值
(5)显示控制器按照刷新频率逐行读取VRAM内容。
(6)通过信号线将像素数据传输到显示器,最终呈现给用户。
8.4 getchar的实现分析
getchar函数的实现涉及键盘输入处理机制:
(1)当用户按下键盘时,触发硬件中断(异步异常)。
(2)键盘中断处理子程序执行以下操作:
- 读取键盘扫描码
- 将扫描码转换为ASCII码
- 将ASCII码存入系统键盘缓冲区
(3)getchar调用read系统函数读取输入。
(4)read通过系统调用接口访问键盘缓冲区。
(5)函数通常等待用户输入回车键后才返回整行输入。
8.5本章小结
本章介绍了Linux系统中的IO管理机制,重点分析了Unix风格的IO接口及其实现原理。通过printf和getchar两个常见函数的实现分析,展示了从用户空间调用到底层硬件交互的完整过程。Linux将设备抽象为文件的模型大大简化了IO操作,统一的接口使得应用程序可以以一致的方式处理各种不同类型的设备。
结论
在计算机的世界里,一个看似简单的"Hello, World"程序从诞生到结束的生命周期,展现了现代计算机系统精妙绝伦的设计哲学。这个微小的程序从源代码hello.c开始其旅程,首先经过预处理阶段的宏展开和头文件合并,转变为hello.i文件;随后编译器将其翻译为汇编语言hello.s,再经汇编器转换为机器指令的目标文件hello.o;最终链接器将其与系统库动态链接,生成可执行文件hello。当用户在shell中输入运行命令时,系统通过fork创建新进程,execve加载程序,加载器精心构建虚拟内存空间,CPU开始按照指令周期有条不紊地执行。
程序运行时,内存管理单元默默完成虚拟地址到物理地址的转换,printf函数调用引发一系列复杂的系统调用和硬件交互。当用户按下Ctrl+c或Ctrl+z时,内核的信号处理机制立即响应,优雅地处理进程的中断或挂起。程序结束时,父进程负责回收子进程资源,内核清理所有相关数据结构,完成这个程序生命的最后仪式。
现代集成开发环境让初学者只需点击按钮就能看到"Hello, World"的输出,但这简单结果背后凝聚着从晶体管到集成电路、从ENIAC到现代处理器的整个计算机发展史。每一行代码的执行都建立在冯·诺依曼体系结构、操作系统原理、编译技术等基础理论之上,是无数工程师智慧的结晶。这个微小程序的生命周期提醒我们,计算机科学中每一个"理所当然"的功能,都蕴含着前人非凡的创造力和严谨的工程思维。
作为计算机工作者,我们既是运用科学原理解决问题的工程师,也是推动技术进步的先驱者。当我们理解并欣赏这个简单程序背后复杂的系统协作时,就能真正体会到计算机科学的深邃与美妙,也更能以谦逊而自豪的心态继续在这条探索之路上前行。Hello程序短暂而精彩的一生,正是对整个计算机体系最精炼而生动的诠释。
附件
文件名 | 作用 |
hello.c | 源程序 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello_elf.txt | 完整记录hello.o的ELF格式信息 |
hello | 可执行文件 |
参考文献
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
[7] 王爽. 汇编语言(第4版)[M]. 北京: 清华大学出版社,2021.
[8] 张晨曦,王志英. 计算机系统结构实验教学改革与实践J. 计算机教育,2019(10):45-48.