计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能
学 号 2022112040
班 级 2203601
学 生 郄东昕
指 导 教 师 吴锐
计算机科学与技术学院
2024年5月
摘 要
本篇论文的目的是解释C语言程序如何从源代码转换为可执行文件。以hello.c程序为例,本文详细分析了计算机在生成hello可执行文件的预处理、编译、汇编、链接、进程管理等整个生命周期。Hello从最开始的C语言源代码,会先经过他“机生”的第一步——预处理;接着会继续变化,从一个青涩的.i文件变化成更能让机器理解的.s汇编文件;随着Hello的一步一步成长,他会经过汇编、链接等一系列的动作处理,变成一个可执行文件。这也标志着它即将迈入“机生”的一个新阶段。在下一个阶段中,它会和操作系统进行交谈,操作系统像它的伯乐一样,给他开辟进程,提供虚拟内存和独立的地址空间;给它划分时间片、逻辑控制流来让它操作系统上畅游,最后随着进程的结束,停止这短暂而辉煌的机生。
本文不仅理论上探讨了Hello一生中所经历的具体阶段,还实际演示了它们的操作和结果,阐述了计算机系统的工作原理和体系结构,帮助读者更深入地理解和掌握C语言程序的编译和执行过程。
关键词:计算机系统;计算机体系结构;汇编语言;链接
目 录
第1章 概述
1.1 Hello简介
HelloWorld想必是每一个程序员的启蒙程序,在诸如Pycharm,CodeBlocks这样的IDE中。我们创建新项目时,项目文件中默认的程序便是输出“Hello,world!”。这个看似非常简单的程序,其实是早先第一个实现的P2P。
P2P:并不是Peer-to-peer的简称哦!
这里面的P2P指的是From Program to Process。指从hello.c(Program)变为运行时进程(Process)。要让hello.c这个C语言程序运行起来,需要先把它变成可执行文件,这个变化过程有四个阶段:预处理,编译,汇编,链接,编写完成的hello.c文件,首先经过预处理器预处理生成hello.i文件;再经过编译器编译,生成汇编代码文件hello.s;再经过汇编器翻译成一个重定位目标文件hello.o;最后使用链接器将多个可重定位目标文件组合起来,形成一个可执行目标文件hello。(可指定为.out文件)
完成后就得到了可执行文件,然后就可以在shell中执行它,shell会给它分配进程空间。
020:即From Zero to Zero。指最初内存并无hello文件的相关内容,shell用execve函数启动hello程序,把虚拟内存对应到物理内存,并从程序入口开始加载和运行,进入main函数执行目标代码,程序结束后,shell父进程回收hello进程,内核删除hello文件相关的数据结构。在execve函数执行hello程序后,内核为其映射虚拟内存、分配物理内存;程序开始执行,内核为程序分配时间片执行逻辑控制流。当hello运行结束,由shell回收hello进程,删除有关的数据结构。
1.2 环境与工具
硬件环境:
处理器:12th Gen Intel(R) Core(TM)i7-12700H 2.70 GHz
机带RAM:16.0GB
系统类型:64位操作系统,基于x64的处理器
软件环境:Windows11 64位 版本号24H2,VMware,Ubuntu 18.04 LTS
开发与调试工具:Visual Studio 1.89.0;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预处理作用
作用:总的来说,预处理阶段的作用是为编译器提供一个经过处理的源代码文件,以便进行后续的编译工作。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位。
2.2在Ubuntu下预处理的命令
在终端输入gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i ,回车,这样便在原目录生成了hello.i文件。
2.3 Hello的预处理结果解析
在Linux下我们使用gedit打开hello.i文件,我们对比了源程序和预处理后的程序。结果显示,观察发现,其中的注释已经消失,前一部分的代码为被加载到程序中的头文件;程序的最后一部分与hello.c中的main函数完全相同。除了预处理指令被扩展成了三千多行之外,源程序的其他部分都保持不变,说明.c文件的确是被修改过了。
图2.3-1 hello.i文件内容一览
在main函数代码出现之前的大段代码源自于的头文件<stdio.h> <unistd.h> <stdlib.h> 的依次展开。
以 stdio.h 的展开为例:#include指令的作用是把指定的头文件的内容包含到源文件中。stdio.h是“standard input & output”(标准输入输出库)的头文件,它包含了用于读写文件(scanf())、标准输入输出(printf())的函数原型和宏定义等内容。
当预处理器遇到#include<stdio.h>时,它会在系统的头文件路径下查找stdio.h文件,一般在/usr/include目录下,然后把stdio.h文件中的内容复制到源文件中。stdio.h文件中可能还有其他的#include指令,比如#include<stddef.h>或#include<math.h>等,这些头文件也会被递归地展开到源文件中。
预处理器不会对头文件中的内容做任何计算或转换,只是简单地复制和替换。
2.4 本章小结
本章讲述了在linux环境中,如何用命令对C语言程序进行预处理,以及预处理的含义和作用,接着以hello.c为例,演示了在Ubuntu下如何预处理程序,并对结果进行分析。通过分析,我们可以发现预处理后的文件hello.i包含了标准输入输出库stdio.h的内容,以及一些宏和常量的定义,还有一些行号信息和条件编译指令。
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
编译是指将人类可读的源代码翻译成汇编代码的过程。编译器通过前端进行词法分析、语法分析等,然后经过优化器进行代码优化,最终由后端将代码翻译汇编代码。编译的过程包括翻译、优化、检查和生成可执行文件等步骤,以确保程序的正确性和性能。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序。
3.1.2 编译的作用
编译的作用是产生汇编语言文件,并交给机器执行。除此之外,编译器还有一些其他功能,例如语法检查等。
3.2 在Ubuntu下编译的命令
打开终端,输入 gcc -m64 -no-pie -fno-PIC -S hello.c -o hello.并回车,结果如下:
图3.2-1 生成hello.s的终端截图
3.3 Hello的编译结果解析
3.3.0先备知识——数据结构与算术操作
在解析下面的汇编代码之前,我们需要先了解数据存储的格式以及寄存器的存储结构,Intel数据类型令16bytes为字,32bytes为双字,各种数据类型的大小一级寄存器的结构如下所示:
变量类型 | Intel数据类型 | 汇编代码后缀 | 大小(字节) |
char | 字节 | b | 1 |
short | 字 | w | 2 |
int | 双字 | l | 4 |
long | 四字 | q | 8 |
char * | 四字 | q | 8 |
float | 单精度 | s | 4 |
double | 双精度 | l | 8 |
表3.3.0-1 数据类型表
图3.3.0-2 寄存器字长示意图
此外,对于算术运算,汇编语言的规定如下:
INC D DEC D NEG D NOT D | D←D + 1 D←D - 1 D← -D D← ~D | 加1 减1 取负 取补 |
ADD S, D SUB S, D IMUL S, D | D←D + S D←D - S D←D * S | 加 减 乘 |
表3.3.0-2 汇编语言基本算术操作
例如下图,下图的操作便是将rsp中的值减去立即数32。至于立即数则会在后文详细介绍。
跳转指令会根据条件码当前的值来进行相应的跳转。比较常见的是直接跳转,在hello.s中也有体现,如下图所示。cmpl指令判断寄存器中的值和立即数4的大小关系,设置条件码,再进行je。je的含义是jump if equal,也就是说,如果此时的条件码所表示含义为相等,则会跳转到相应的.L2指令行。因此,跳转指令用来实现条件分支。
call指令用来进行函数的调用。如下图所示的示例,call调用了puts函数和exit函数。call指令会先将函数的返回地址压入运行时栈中,之后跳转到相应的函数代码段进行执行。执行结束通过ret指令返回。
图3.3.0-4 call指令调用实例
编译过程是整个过程构建的核心部分,编译成功之后,源代码会从文本形式转换为机器语言。
我们使用vim打开hello.s,使用vim而不使用系统自带gedit的原因是,vim能够自动高亮不同种类的汇编代码。接下来我们将对hello.s中出现的汇编指令进行详细的介绍。
图3.3.0-6 使用vim打开hello.s
3.3.1 文件信息记录
图3.3.1-1 hello.s开头信息
首先是记录文件相关信息的汇编代码。第一部分的汇编代码有一部分是以.作为开头的代码段。这些代码段是指导汇编器和连接器工作的伪指令。这段代码对我们来说没有什么意义,通常可以忽略这些代码,但对汇编器和连接器缺是十分重要的,为之后链接过程使用。伪代码的具体含义见下表。
伪代码 | 含义 |
.file | 声明源文件(此处为hello.c) |
.text | 声明代码节 |
.section | 文件代码段 |
.rodata | 只读文件(Read-only) |
.align | 数据指令地址对齐方式(此处为8对齐) |
.string | 声明字符串(此处声明了LC0和LC1) |
.globl | 声明全局变量 |
.type | 声明变量类型(此处声明为函数类型) |
表3.3.1-2 hello.s伪代码具体含义
3.3.2 对局部变量的操作
hello中涉及局部变量操作的地方主要是源程序中的第10行int i。
图3.3.2-1 hello.c截图
在对应的汇编语言中,局部变量会被存储在栈上。当进入主函数main时,会在栈上申请一段空间来供局部变量使用。当局部变量使用完毕之后,这段申请空间会被释放。如下图所示,在红框中,栈指针向下移动了32个字节,在栈中为局部变量i保留了空间。
3.3.3 对字符串常量的操作
在main函数之前前,在.rodata处的.LC0和.LC1已经存储了字符串常量,标记该位置是代码是只读的。在main函数中使用字符串时,得到字符串的首地址(leaq相当于转移操作),如下图红框。
3.3.4 对立即数的操作
在汇编语言中,立即数用“$”后加数字表示,如下图所示。
3.3.5 赋值操作
赋值操作很简单,用movq指令即可,例如将a寄存器的值赋值给b寄存器,用movq a b(以8字节为例)。比如对局部变量i的赋值:
3.3.6 参数传递——对main的参数argv的传递
在main函数的开始部分,因为后面还会使用到%rbp数组,所以先将%rbp(帧指针)压栈保存起来。在下图红框区域,代码通过mov指令将栈指针减少32位,然后分别将%rdi和%rsi的值存入栈中。由此我们知道,%rbp-20和%rbp-32的位置分别存了argv数组和argc的值。
3.3.7 对数组的操作
对数组的操作,都是先找到数组的首地址,然后加上偏移量即可。例如在main中,调用了argv[1]和argv[2],在汇编代码中,每次将%rbp-32的的值即数组首地址传%rax,然后将%rax分别加上偏移量24和16,得到了argv[1]和argv[2],再分别存入对应的寄存器%rsi和%rdx作为第二个参数和第三个参数,之后调用printf函数时使用。调用完printf后同样,在偏移量为32时,取得argv[3]并存入%rdi作为第一个参数在调用函数atoi使用。
3.3.8 对函数的调用与返回
(1)main函数
参数传递:该函数的参数为int argc,,char*argv[]。
函数调用:通过使用call内部指令调用语句进行函数调用,并且将要调用的函数地址数据写入栈中,然后自动跳转到这个调用函数内部。main函数里调用了printf、exit、sleep函数。
局部变量:使用了局部变量i用于for循环。具体局部变量的地址和值都在前面阐述过。
(2)printf函数
汇编代码如下图:
参数传递:printf函数调用参数argv[1],argv[2]。
函数调用:该函数调用了两次。第一次将寄存器%rdi设置为待传递字符串"用法:Hello学号 姓名 秒数!\n"的起始地址;第二次将其设置为“Hello %s %s\n”的起始地址。具体已在前面讲过。使用寄存器%rsi完成对argv[1]的传递,用%rdx完成对argv[2]的传递。
(3)exit函数
汇编代码如下图:
(4)atoi函数
汇编代码如下图:
atoi函数将参数argv[3]放入寄存器%rdi中用作参数传递,简单使用call指令调用。atoi函数用于将字符串转换为整数。
(5)sleep函数
汇编代码如下图:
代码将转换完成的秒数从%eax传递到%edi中,edi存放sleep的参数,再使用call调用。Sleep函数可以让程序休眠一段时间。
(6)getchar函数
汇编代码如下图:
getchar函数无参数,用于获取单个字符。
(7)puts函数
汇编代码如下图:
在Og选项下,单独输出固定字符串的printf函数被编译器优化成了puts函数。leaq .LC0(%rip), %rdi这一行将.LC0的地址加载到%rdi寄存器中。.LC0是一个包含要输出的字符串的位置,它使用相对地址寻址。具体可见下图的伪代码段。
3.3.9 for循环
movq -32(%rbp), %rax:这一行将存储在-32(%rbp)的变量的值加载到%rax寄存器中。
addq $24, %rax:这一行将%rax寄存器中的值增加24,用于获取arg[1]的地址。
movq (%rax), %rcx:这一行argv[1]加载到%rcx寄存器中。
movq -32(%rbp), %rax:这一行重复了第2步的操作,重新加载数组的指针到%rax寄存器中。
addq $16, %rax:这一行增加了%rax中的值,通常用于访问数组中的另一个元素。
movq (%rax), %rdx:这一行将数组中的另一个元素加载到%rdx寄存器中。
紧接着,程序重复了上述步骤,获取了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文件。.o文件是一个二进制文件,包含main函数的指令编码。
4.1.2 汇编的作用
计算机只能识别处理机器指令程序,汇编过程将汇编语言程序翻译为了机器指令,进一步向计算机能够执行操作的形式迈进,便于计算机直接进行分析处理。
简单的来说,汇编之后我们能从汇编代码得到一个可重定位目标文件,以便后续进行链接。
4.2 在Ubuntu下汇编的命令
在hello.s的目录下打开终端,输入gcc -c hello.s -o hello.o并回车,这样便可以生成hello.o目标文件。
4.3 可重定位目标elf格式
4.3.1 elf头
由于hello.o文件是一个目标文件,因此无法直接使用vim打开。我们在终端输入readelf -h hello.o来解析elf文件头,结果如下。
ELF头以一个16字节的序列(Magic,魔数)开始,这个序列描述了生成文件的系统的字的大小和字节顺序。ELF头剩下部分的信息包含帮助连接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型和机器类型等。例如上图中,Data表示了系统采用小端法,文件类型Type为REL(可重定位文件),节头数量Number of section headers为13个等信息。
4.3.2 section头
在终端输入readelf -S hello.o查看节头,结果如下。
可以发现,hello.o中一共有13个节,8个重定位条目,7个全局符号。在这些重定位条目中,有两个对应rodata节中的数据地址,显然它们是printf使用的那两个字符串地址。另外6个重定位条目都是被call指令调用过的函数地址。
夹在ELF头和节头部表之间的都为节,包含了文件中出现的各个节的语义,包括节类型、位置和大小等信息。各部分含义如下表格所示:
名称 | 包含内容含义 |
.text | 已编译程序的机器代码 |
.rodata | 只读数据,比如printf语句中的格式串和开关语句的跳转表。ro是Read-only(只读)的简称。 |
.data | 已初始化的全局变量和静态C变量 |
.bss(Block Started by Symbol) | 未初始化的全局变量和静态C变量,以及所有初始化为0的全局变量和静态C变量。在目标文件中,这个节并不会占据实际空间,只是一个占位符,未初始化变量并不需要占据任何实际的磁盘空间,在运行时再从内存分配变量,初始值为0。这种延迟分配的机制可以节省大量内存空间,尤其是当程序中存在大量全局或静态变量时。 |
.symtab | 符号表,存放了程序中定义和引用的函数和全局变量的信息。 |
.rel.text | 一个.tex节中位置的列表 |
.rel.data | 被模块引用或定义的所有全局变量的重定位信息 |
.debug | 一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用编译器驱动程序时,才能够得到这张表。 |
.line | 原始C源程序中的行号和.text节中机器指令之间的映射 |
.strtab和.shstrtab | 一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字(保存在shstrtab中)。字符串表就是以null结尾的字符串序列。 |
表4.3.2-2 各节信息
4.3.3 符号表
在终端输入命令readelf -s hello.o并回车,结果如下。
在符号表中,Num为某个符号的编号,Name是符号的名称。Size表示他是一个位于.text节中偏移量为0处的146字节函数。Bind表示这个符号是本地的还是全局的,由上图可知main函数名称这个符号变量是本地的。
4.3.4 可重定位段信息
在终端输入readelf -r hello.o并回车可查看可重定位段信息,结果如下:
在列出的信息中,偏移量表示需要被修改的引用的节偏移,符号值标识被修改引用应该指向的符号。类型告知连接器如何修改新的引用,加数是一个有符号常数,一些类型的重定位要使用它对被修改的引用的值做偏移调整。
4.4 Hello.o的结果解析
在终端输入readelf -d -r hello.o 并回车可对hello.o文件进行反汇编,得到结果如下:
我们不难发现,反汇编得到的结果与hello.s中的汇编代码基本一致,但是还是存在一些出入:在每条指令的前面出现了一组组由16进制数字组成的代码,这就是机器代码。机器代码才是计算机真正可以识别的语言。
这些机器代码是二进制机器指令的集合,每一条机器代码都对应一条机器指令。每一条汇编语言都可以用机器二进制数据来表示,汇编语言中的操作码和操作数以一种相当于映射的方式和机器语言进行对应,从而让机器能够真正理解代码的含义并且执行相应的功能。机器代码与汇编代码不同的地方在于:
- 分支跳转方面
在汇编语言中使用跳转指令只需要在后面加上标识符便可以跳转到标识符所在的位置,而机器语言经过翻译直接通过长度为一个字节的PC(Program Counter,程序计数器)相对地址进行跳转。
![]() | ![]() |
跳转至.L3的代码对比 左侧为反汇编代码,右侧为汇编代码 |
2.函数调用方面
在汇编代码hello.s中,函数调用直接标上了函数的名称。。而在反汇编代码中,call目标地址是当前指令的下一条指令地址。这是因为hello.c中调用的函数都是共享库(如stdio.h,stdlib.h)中的函数,如puts,exit,printf,atoi,sleep等,需要等待链接器进行链接之后才能确定响应函数的地址。因此,机器语言中,对于这种不确定地址的调用,会先将下一条指令的相对地址全部设置为0,然后在.rel.text节中为其添加重定位条目,等待链接时确定地址。
![]() | ![]() |
调用getchar函数的对比 左侧为反汇编代码,右侧为汇编代码 |
3.伪指令部分
反汇编代码中,原来出现在汇编代码开头的伪指令全部消失了。
![]() | ![]() |
代码开头对比 左侧为反汇编代码,右侧为汇编代码 |
4.立即数部分
原本十进制的立即数都变成了二进制。这个很好理解,输出的文件是二进制的,对于objdump来说,直接将二进制转化为十六进制比价方便,也有利于程序员以字节为单位观察代码。
![]() | ![]() |
左侧为反汇编代码,右侧为汇编代码 |
4.5 本章小结
本章对汇编的概念、作用、可重定向目标文件的结构及对应反汇编代码等进行了较为详细的介绍。经过汇编阶段,汇编语言代码转化为机器语言,生成的可重定位目标文件(hello.o)为随后的链接阶段做好了准备。完成本章内容的过程加深了我对汇编过程、ELF格式以及重定位的理解。
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接是将各种代码和数据片段和搜集并组成成为一个单一文件的过程,这个文件可被加载(复制 )到内存并执行。链接可以在编译时,也就是在源代码被翻译成机器代码时直接执行;也可以执行于加载时,也就是在程序被记载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
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并回车,结果如下图所示:
5.3 可执行目标文件hello的格式
可执行目标文件的格式类似于可重定位目标文件(hello.o)的格式,但稍有不同。ELF头中字段e_entry给出执行程序时的第一条指令的地址,而在可重定位文件中,此字段为0。可执行目标文件多了一个程序头表,也称为段头表,是一个结构数组。可执行目标文件还多了一个.init节,用于定义_init函数,该函数用来执行可执行目标文件开始执行时的初始化工作。因为可执行目标文件不需要重定位,所以比可重定位目标文件少了两个.rel节。
与第四章使用的方法相似,我们依旧可以使用readelf查看hello文件的ELF头,节头部表,符号表。
5.3.1 ELF头
在终端中输入readelf -h hello并回车,查看hello文件的ELF头,如下图所示:
我们不难发现,hello的ELF头中Type处显示的是EXEC,表示时可执行目标文件,这与hello.o不同。hello中的节的数量为30个。
5.3.2 Section头
在终端中输入readelf -S hello并回车,查看hello文件的Section头,如下图所示:
Section表对hello中所有信息进行了声明,包括了大小、偏移量、起始地址以及数据对齐方式等信息。根据始地址和大小,我们就可以计算节头部表中的每个节所在的区域。
5.4 hello的虚拟地址空间
我们在终端中输入edb并回车,打开edb界面,在File-Open中选择hello文件,点击open,界面显示如下:
我们可以看到,hello的可执行部分(代码段)起始地址为0x400000,结束地址为0x400ff0。
由5.3节我们又可以得知,.interp段的起始地址为400200。使用edb查询可得到如下结果。
由5.3节我们可得知,.init的起始地址为0x4004c0,在edb中查询地址可以得到如下图的结果:
由5.3节我们可得知,.text的起始地址为0x400550,在edb中查询地址可以得到如下图的结果:
由5.3节我们可得知,.rodata的起始地址为0x4006a0,在edb中查询地址可以得到如下图的结果:
由5.3节我们可得知,.eh_frame的起始地址为0x4006a0,在edb中查询地址可以得到如下图的结果:
5.5 链接的重定位过程分析
在终端输入命令objdump -d -r hello并回车,查看hello可执行文件的反汇编条目,结果如下:
我们可以观察到,hello的反汇编代码与hello.o的返汇编代码在结构和语法上是基本相同的,只不过hello的反汇编代码多了非常多的内容,我们通过比较不同来看一下区别:
1.虚拟地址不同,hello.o的反汇编代码虚拟地址从0开始,而hello的反汇编代码虚拟地址从0x400000开始。这是因为hello.o在链接之前只能给出相对地址,而hello在链接之后得到的是绝对地址。
![]() | ![]() |
hello的反汇编结果 | hello.o的反汇编结果 |
2.反汇编节数不同,hello.o只有.text节,里面只有main函数的反汇编代码。而hello在main函数之前加上了链接过程中重定位而加入的各种在hello中被调用的函数、数据,增加了.init,.plt,.plt.sec等节的反汇编代码。
3.跳转指令不同,hello.o中的跳转指令后加的主要是汇编代码块前的标号,而hello中的跳转指令后加的则是具体的地址,但相对地址没有发生变化。
5.6 hello的执行流程
1.使用edb执行hello,首先,最初的程序地址会在0x7fa4:917dc090处,这里是hello使用的动态链接库ld-2.2.27.so的入口点_dl_start:
2.然后,程序跳转到_dl_init,在经过了一系列初始化后,跳到hello的程 序入口点_start;
3.然后程序通过call指令跳到动态链接库ld-2.27.so的_libc_start_main 处,这个函数会进行一些必要的初始化,并负责调用main函数;
4. 下一步,程序调用动态链接库中的__cxa_atexit函数,它会设置在程序结束时需要调用的函数表;
5. 然后返回到__libc_start_main继续,然后调用hello可执行文件中的__libc_csu_init函数,这函数是由静态库引入的,也是做一些初始化的工作;
6. 然后程序返回到__libc_start_main继续,紧接着程序调用动态链接库里的_setjmp函数,设置一些非本地跳转;
7.然后返回到__libc_start_main继续,正式开始调用main函数;
8. 由于我们在edb运行hello的时候并未给出额外的命令行参数,因此它会在第一个if处通过exit(1)直接结束程序;
9. 通过hello本身携带的exit函数,程序会跳转;
10. 之后,在进行了若干操作后,程序退出。
5.7 Hello的动态链接分析
程序调用一个有共享库定义的函数时,编译器无法预测函数在运行时的具体地址,因为定义这个函数的共享模块可能可以被加载到任何位置。因此,编译系统采用延迟绑定,将过程地址的绑定推迟到第一次调用该过程的时候。
延迟绑定需要用到两个数据结构:GOT(Global Offset Table,全局偏移表)和PLT(Procedure Linkage Table,过程链接表)。
.plt:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
.got:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
一次调用某个函数时,程序不是直接调用,而是调用进入函数所在的PLT条目,第一条PLT指令通过GOT进行间接跳转,每个GOT条目初始时都指向其对应的PLT条目的第二条指令,这个间接跳转只是简单将控制传送回函数所在的PLT条目的下一条指令。之后将函数的ID压入栈中之后,函数所在的PLT条目跳转到PLT[0],最后PLT[0]通过GOT[1]间接地把动态链接器的一个参数压入栈中,然后通过GOT[2]简介跳转进入动态链接器。动态链接器通过使用两个栈条目来确定函数的运行时位置,再将控制传递给函数。
后续调用时,则可以不用通过GOT[4]的跳转将控制给到函数。
hello在动态连接器加载前后的重定位是不一样的,在加载之后才进行重定位。
5.8 本章小结
本章节简要介绍了链接的相关过程,首先简要阐述了链接的概念和作用,给出了链接在Ubuntu系统下的指令。之后研究了可执行目标文件hello的ELF格式,并通过edb调试工具查看了虚拟地址空间和几个节的内容,之后依据重定位条目分析了重定位的过程,并借助edb调试工具,研究了程序中各个子程序的执行流程,最后则借助edb调试工具通过对虚拟内存的查取,分析研究了动态链接的过程。通过链接,hello.o与它依赖的所有的库结合在一起形成了一个可执行文件,在这个可执行文件中,所有的运行时位置都已经确定,可以被复制到内存里并运行了。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
进程的作用:在运行一个进程时,我们的这个程序看似是系统当中唯一一个运行的程序,进程的作用就是提供给程序两个关键的抽象:1.独立的逻辑控制流,即程序独占使用处理器的假象。2.私有的地址空间,即程序独占使用内存系统的假象。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一种交互型程序,用于代表用户运行其他程序。
Shell的处理流程如下:首先对命令行参数求值,判断命令行是否为空,如果不为空则判断第一个命令行参数是不是一个内置的命令,如果是一个内置命令则直接执行,否则检查是否是一个应用程序。之后在搜索路径里寻找这些应用程序,如果键入的命令不是一个内部命令并且路径里没有找到这个可执行文件,则会显示一条错误信息。如果能够成功找到命令,那么该内部命令或者应用程序将会被分解为系统调用并传递给linux内核。
6.3 Hello的fork进程创建过程
Shell调用fork创建子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,因此fork后子进程可以读写父进程中打开的任意文件。父进程和创建的子进程最大的区别在于其PID不同。
fork会被父进程调用一次,返回两次,父进程与创建的子进程并发执行。执行hello时,fork后的进程在前台执行,因此创建它的父进程shell暂时挂起等待hello进程执行完毕。
6.4 Hello的execve过程
shell通过fork创建一个子进程后,execve函数在当前进程的上下文中加载并运行一个新程序即hello。
Execve需要三个参数:可执行目标文件名filename、参数列表argv、环境变量列表envp。这些都由shell构造并传递。除非找不到filename,否则execve不会返回。(调用一次,(正常情况下)从不返回)
调用execve会将这个进程执行的原本的程序完全替换,它会删除已存在的用户区域,包括数据和代码;然后,映射私有区:为Hello的代码、数据、.bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的;之后映射共享区;最后把控制传递给当前的进程的程序入口。
6.5 Hello的进程执行
6.5.1逻辑控制流
逻辑控制流是一个PC值的序列,PC值就是程序计数器的值,这些值与可执行目标文件的指令或者包含在运行时动态链接到程序的共享对象中的指令一一对应。
6.5.2 时间分片
在现代计算机体系中,进程是轮流使用处理器的,每个进程都执行它的流的一部分,然后被抢占(暂时挂起),再轮到其它进程。一个逻辑流的执行在时间上与另一个流重叠被称为并发流,这两个流并发运行。
多个流并发执行的概念被称为并发。一个进程与其他进程轮流运行的概念称为多任务。一个进程执行其控制流一部分的每一个时间段叫做时间片,多任务也就被称作是时间分片。
6.5.3 用户模式与内核模式
为了保护操作系统内核,处理器在某一个控制寄存器中的一个模式位,设置模式位时,进程就运行在内核模式中,否则运行在用户模式。内核模式的代码可以无限制地访问所有处理器指令集以及全部内存和 I/O 空间。如果用户模式的进程要享有此特权,它必须通过系统调用向设备驱动程序或其他内核模式的代码发出请求。另外,用户模式的代码允许发生缺页,而内核模式的代码则不允许。
运行程序代码初始时都是在用户模式中的,当发生中断故障或系统调用的异常时,进程从用户模式转变为内核模式。当异常发生时,控制传递到异常处理程序,处理器将模式转变为内核模式。内核处理程序运行在内核模式中,当它返回到应用程序代码时,处理器把模式从内核模式改回到用户模式。
6.5.4 进程上下文切换
上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这样的决策叫做调度,由内核中的调度器的代码处理。在这个抢占过程中需要用到上下文切换,上下文切换保存当前进程的上下文,恢复先前某个被抢占的上下文,并将控制传递给新恢复的进程。
6.6 hello的异常与信号处理
6.6.1异常类型
运行时异常:如除以零、空指针引用等,这类异常会导致程序崩溃。
资源异常:如文件未找到、内存不足等,这类异常通常需要程序进行适当的错误处理。
输入异常:用户输入了不符合程序要求的数据。
6.6.2. 产生的信号
SIGINT:当用户按下Ctrl+C时发送,通常用于中断程序。
SIGTSTP:当用户按下Ctrl+Z时发送,用于暂停程序。
SIGTERM:请求程序终止的正常信号。
6.6.3具体信号处理与命令
1.乱按字
可以看到,在键盘中乱打字并没有改变printf的输出,不影响程序的正常运行。
2.按Ctrl+Z
Ctrl+Z的功能是向进程发送SIGSTP信号,进程接收到该信号之后会将该作业挂起,但不会回收。下图显示了,PID为4826的hello进程仍然在运行中。
运行jobs指令,我们可以得知hello的后台job id=1。
我们再调用fg命令。fg命令用于将后台作业(在后台运行的或者在后台挂起的作业)放到前台终端运行。运行结果如下。我们发现,挂起前后总共的输出次数仍为10次。
3.Ctrl-C
在上一步之后,我们在键盘中输入Ctrl+C,Ctrl-C命令内核向前台发送SIGINT信号,终止了前台作业。
4.不停按回车
我们发现,在hello执行过程中不停按回车,不仅在printf输出时会显示出回车,在hello进程执行完毕后,我们可以看出回车的信息也同样发送到了shell中,使shell进行了若干次的刷新换行。
6.7本章小结
本章概述了hello进程大致的执行过程,阐述了进程、shell、fork、execve等相关概念,之后从逻辑控制流、时间分片、用户模式/内核模式、上下文切换等角度详细分析了进程的执行过程。并在运行时尝试了不同形式的命令和异常,每种信号都有不同处理机制,针对不同的shell命令,hello会产生不同响应。
第7章 hello的存储管理
7.1 hello的存储器地址空间
物理地址:CPU通过地址总线的寻址,找到真实的物理内存对应地址。CPU对内存的访问是通过连接着CPU和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。
逻辑地址:程序代码经过编译后出现在汇编程序中地址。逻辑地址由选择符(在实模式下是描述符,在保护模式下是用来选择描述符的选择符)和偏移量(偏移部分)组成。
线性地址:线性地址空间是一个非负整数的集合。逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合形式。在调试hello时,gdb中查看到的就是线性地址,或者虚拟地址。
虚拟地址:虚拟地址空间是0到N的所有整数的集合(N是正整数),是线性地址空间的有限子集。分页机制以虚拟地址为桥梁,将硬盘和物理内存联系起来。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在 Intel 平台下,逻辑地址是 selector:offset 这种形式,selector 是 CS 寄存器的值,offset 是 EIP 寄存器的值。
CS寄存器(代码段寄存器): CS寄存器存储了当前执行的指令所在的代码段的起始地址。它是一个16位寄存器,指示了代码在内存中的位置。CS寄存器的值与代码段的段基址相关,形成了代码段的起始物理地址。
EIP寄存器:用来存储CPU要读取指令的地址,CPU通过EIP寄存器读取即将要执行的指令。每次CPU执行完相应的汇编指令之后,EIP寄存器的值就会增加。
如果用 selector 去 GDT( Global Descriptor Table,全局描述符表 ) 里拿到 segment base address(段基址) 然后加上 offset(段内偏移),这就得到了 linear address(线性地址)。这个过程就称作段式内存管理。
逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段(段选择符),可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
全局的段描述符,放在GDT中,一些局部的段描述符,放在“LDT(Local Descriptor Table,局部段描述符表)”中。
给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。再由基地址加上偏移量的值,便得到了线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(VA)到物理地址(PA)之间的转换通过分页机制完成。分页机制类似主存和Cache之间的分块机制,分页机制对虚拟地址和物理内存进行分页,页的大小通常是4KB到2M(因时而异,时过境迁,页的大小有所不同)。在x86-64机器上,虚拟地址空间的N是2的48次方,有256TB,比正常的硬盘大得多。
在分页机制中,硬盘空间的每个字节到虚拟地址空间的每个字节存在映射关系,且这个映射是单射。虚拟地址空间和硬盘空间都以字节为单位,从0开始编地址号。设硬盘空间为H,虚拟地址空间为V,设他们之间的映射关系为,f是单射,则
于是,我们知道了物理地址中某个地址所在页与虚拟空间的页的对应关系,也就知道了物理地址中某个地址所在页与硬盘中某个页的对应关系。
物理地址中某个地址所在页与虚拟空间的页的对应关系要通过什么来记录呢?分页机制中使用一个叫做页表的数据结构来记录这些关系,页表也是存储在内存中的,是由操作系统维护的。其实DRAM到Cache中也是类似机制,只不过DRAM到Cache的高速缓存机制是用硬件实现的。
每个进程都有一个页表,页表中的每一项,即PTE(页表条目),记录着该对应的虚拟地址空间的那一页是否有效(即是否有对应的物理内存上的页),物理页的起始位置或磁盘地址,访问权限等信息。PTE根据不同的映射状态也被划分为三种状态:未分配、未缓存、已缓存。
未分配:虚拟内存中未分配的页
未缓存:已经分配但是还没有被缓存到物理内存中的页
已缓存:分配后缓存到物理页块中的页
7.4 TLB与四级页表支持下的VA到PA的变换
页表是 PTE(页表条目)的数组,它将虚拟页映射到物理页,每个 PTE 都有一个有效位和一个 n 位地址字段,有效位表明该虚拟页是否被缓存在 DRAM 中。虚拟地址分为两个部分,虚拟页号(VPN,Virtual Page Number)和虚拟页面偏移量(VPO,Virtual Page Offset)。其中VPN需要在PTE中查询对应,而VPO则直接对应物理地址偏移(PPO)。
TLB(translation lookaside buffer,地址转换后备缓冲器,习惯称之为“快表”)是一个位于MMU(Memory Management Unit,内存管理单元)中,关于PTE的一个缓存。TLB是一个小的、虚拟寻址的缓存,其中每一行均保存了一个由单个PTE组成的块。TLB有高度的相联性,能够加速地址翻译,而多级页表能够对页表进行压缩,便于大量存储。
在从VA翻译得到PA的过程中,MMU首先用VPN向TLB申请请求对应的PTE,如果命中,那么直接跳过后面的步骤;之后MMU生成PTE地址,从高速主存请求得到PTE,高速缓存或主存会向MMU返回PTE。若PTE有效位为0,说明缺页,MMU触发缺页异常,缺页处理程序确定物理内存中的牺牲页(若页面修改,则换出到磁盘)。之后缺页处理程序调入新的页面,并更新PTE。之后却也处理程序返回原进程,并重新执行导致缺页的指令。
四级页表是一种多级页表,多级页表的主要目的是用于压缩页表。在地址翻译过程中,虚拟的地址页号VPN被分为了k个,每一个VPNi都是一个指向第i级页表的索引。当
时,都是指向第j+1级的某个页表。第k级页表中的每个PTE包含某个物理页面的PPN,或者时一个磁盘块的地址。为构造物理地址,MMU需要访问k个PTE,之后才能确定PPN。Intel Core i7采用的是一个四级页表层次结构,每个VPNi有9位,当未命中时,36位的VPN被分为VPN1、VPN2、VPN3、VPN4,每个VPNi被用作到一个页表的偏移量。CR3寄存器包含L1 页表的物理地址,VPN1提供到一个L1 PTE的偏移量,这个PTE包含某个L2页表的基址。VPN2提供到这个L2页表中某个PTE的偏移量,以此类推。最后得到的L4 PTE包含了需要的物理页号,和虚拟地址中的VPO连接起来就得到相应的物理地址。
7.5 三级Cache支持下的物理内存访问
通过内存地址的组索引获得值,如果对应的值是data则像L1 d-cache对应组中查找,如果是指令,则向L1 i-cache对应组中查找。将L1对应组中的每一行的标记位进行对比,如果相同并且有效位为1则命中,获得偏移量,取出相应字节,否则不命中,向下一级cache寻找,直到向内存中寻找。
7.6 hello进程fork时的内存映射
当fork函数被调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本,并将两个进程中的每个界面都标记为只读,将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存搞好和调用fork时存在的虚拟内存相同。这两个进程中的任意一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。 加载并运行 hello 需要以下几个步骤:
1.删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结 构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名 文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长 度为零。
3.映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链 接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。execve 做的最后一件事情就是设置当前进程 上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页故障:一个虚拟页没被缓存在DRAM中,即DRAM缓存不命中被称为缺页。当CPU引用了一个页表条目中的一个字,而该页表条目并未被缓存在DRAM中,地址翻译硬件从内存中读取该页表条目,从有效位为0可以判断尚未被缓存,进而触发缺页异常。
缺页中断处理:缺页异常调用缺页异常处理程序,该程序会选择一个牺牲页,如果这个牺牲页在DRAM中已被修改,那么就将他写回磁盘,之后将引用的虚拟页复制到内存中的原来牺牲页所在位置,并对页表条目进行更新,随后返回。当异常处理程序返回时,它会重新启动缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。此时需要调用的虚拟页已经缓存到主存中了,则页命中可以由地址翻译硬件正常处理。
7.9动态存储分配管理
首先,我们需要明确一下动态存储分配管理的概念:在程序运行时程序员使用动态内存分配器,例如调用malloc函数从而获得虚拟内存。分配器将堆(heap)视为一组不同大小的块(blocks)的集合来维护。每个块要么是已分配的,要么是空闲的。
7.9.1堆
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。
在内存中的碎片和垃圾被回收之后,内存中就会有空余的空间被闲置出来。这些空间有时会比较小,但是积少成多,操作系统不知道怎么利用这些空间,就会造成很多的浪费。为了记录这些空闲块,采用隐式空闲链表和显式空闲链表的方法实现这一操作。
7.9.2隐式空闲链表
首先了解几个概念:
首次适配 (First fit): 从头开始搜索空闲链表,选择第一个合适的空闲块: 搜索时间与总块数(包括已分配和空闲块)成线性关系。在靠近链表起始处留下小空闲块的“碎片”。
下一次适配 (Next fit): 和首次适配相似,只是从链表中上一次查询结束的地方开始。比首次适应更快: 避免重复扫描那些无用块。一些研究表明,下一次适配的内存利用率要比首次适配低得多。
最佳适配 (Best fit): 查询链表,选择一个最好的空闲块;适配,剩余最少空闲空间。保证碎片最小——提高内存利用率,通常运行速度会慢于首次适配。
在隐式空闲链表工作时,如果分配块比空闲块小,可以把空闲块分为两部分,一部分用来承装分配块,这样可以减少空闲部分无法使用而造成的浪费。隐式链表采用边界标记的方法进行双向合并。脚部与头部是相同的,均为 4 个字节,用来存储块的大小,以及表明这个块是已分配还是空闲块。同时定位头部和尾部,是为了能够以常数时间来进行块的合并。无论是与下一块还是与上一块合并,都可以通过他们的头部或尾部得知块大小,从而定位整个块,避免了从头遍历链表。但与此同时也显著的增加了额外的内存开销。他会根据每一个内存块的脚部边界标记来选择合并方式,如下图:
7.9.3 显式空闲链表
显式空闲链表只记录空闲块,而不是来记录所有块。它的思路是维护多个空闲链表,每个链表中的块有大致相等的大小,分配器维护着一个空闲链表数组,每个大小类一个空闲链表,当需要分配块时只需要在对应的空闲链表中搜索。
7.10本章小结
本章介绍了Hello和操作系统之间的交流方式。介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理,介绍了Hello是如何经过地址翻译从而找到最终的物理地址。阐释了TLB加速地址翻译、多级缓存以及动态内存管理相关的要点。最后,本章介绍了动态存储分配管理机制。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m个字节的序列,所有的I/O设备都被模型化为文件,所有的输入输出都被当作是文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,被称为是Unix I/O,这使得所有的输入和输出都能够以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
1.Unix接口:
(1)打开文件:一个应用程序通过内核打开文件,来宣告它想访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
(2)I/O设备:内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
(3)改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
(4)读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
(5)关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件,作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放他们的内存资源。
2.Unix I/O函数:
(1)int open(char *filename, int flags, mode_t mode);
进程通过调用open函数打开一个已存在的文件或者创建一个新文件。open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件:O_RDONLY:只读、O_WRONLY:只写和O_RDWR可读可写。mode参数指定了新文件的访问权限位。
(2)int close(fd):
进程调用close函数关闭一个打开的文件,fd是需要关闭的文件的描述符。
(3)ssize_t read(int fd, void *buf, size_t n);
read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF(End Of File),否则返回值表示的是实际传送的字节数量。
(4)ssize_t write(int fd, const void *buf, size_t n);
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
printf的具体函数体实现过程如下:
printf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。printf用了两个外部函数,一个是vsprintf,还有一个是write。
vsprintf函数体如下:
vsprintf函数作用是接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。write函数将buf中的i个元素写到终端。在unistd.h头文件中,我们可以找到write函数的声明:
write()函数有三个参数:
fd: 文件描述符,标识待写入的文件或者套接字。
buf: 指向要写入的数据的缓冲区。
count: 要写入的字节数。
返回值为实际写入的字节数,错误时返回-1,并设置errno。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar()函数的具体实现如下:
getchar函数调用了read函数,通过系统调用read读取存储在键盘缓冲区的ASCII码,直到读到回车符才返回。不过read函数每次会把所有内容读进缓冲区,如果缓冲区本来非空,则不会调用read函数,而是简单的返回缓冲区最前面的元素。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了hello的I/O管理机制,先简述了I/O设备被抽象为文件的现象,随后介绍了I/O的设备管理方法——unix IO接口,随后对unixIO接口做了介绍之后,给出了Unix IO接口的相关函数,并在此基础上,对printf和getchar的实现原理进行了介绍。
结论
hello这个计算机世界最基本的程序,从出生(编写完成)到死亡(进程被回收)总共经历了如下几个步骤:
1、预处理(cpp)。将hello.c进行预处理,将文件调用的所有外部库文件合并展开,生成一个经过修改的hello.i文件。
2、编译(ccl)。将hello.i文件翻译成为一个包含汇编语言的文件hello.s。
3、汇编(as)。将hello.s翻译成为一个可重定位目标文件hello.o。
4、链接(ld)。将hello.o文件和可重定位目标文件和动态链接库链接起来,生成一个可执行目标文件hello。
5、运行。在shel1中输入./hello 2022112040 qdx 15845895165 4 并回车。
6、创建进程。终端判断输入的指令不是shell内置指令,于是调用fork函数创建一个新的子进程。
7、加载程序。shell调用execve函数,启动加载器,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入main函数。
8、执行指令:CPU为进程分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流。
9、访问内存:MU将程序中使用的虚拟内存地址通过页表映射成物理地址。
10、信号管理:当程序在运行的时候我们输入Ctrl+c,内核会发送SIGINT信号给进程并终止前台作业。当输入Ctrl+z时,内核会发送SIGTSTP信号给进程,并将前台作业停止挂起。
11、终止:当子进程执行完成时,内核安排父进程回收子进程,将子进程的退出状态传递给父进程。内核删除为这个进程创建的所有数据结构。
当下学习计算机的初学者,在集成IDE环境中输入几行代码,点击运行便可以完成“hello,world”的输出。看似是弹指一挥间的事情,在计算机的角度看却需要经历数十个步骤,完成多个模块的并行调用。从晶体管式计算机到集成芯片,从ENIAC大型机到如今随处可见的X86-64架构、arm架构计算机,从汇编语言到如今的C,python,java......无数计算机工程师前赴后继地完善着计算机的体系结构,使计算机真正成为了一门执因索果的科学。同时,也为信息革命的深化进一步添砖加瓦。
Hello的一生告诉我们,计算机科学的领域,没有顺理成章,没有理所当然,一切看似容易的操作都建立在前人伟大而巧妙的构思之上。计算机领域的学习需要潜心深入、止于至善。
请骄傲地抬起头吧,程序员们。你们不仅是工程师,更是科学家。同时,你们也是人类进步与发展的伟大探路者。
附件
文件名 | 功能 |
hello.c | 源程序 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello_asm.txt | 反汇编hello.o得到的反汇编文件 |
hello1_asm.txt | 反汇编hello可执行文件得到的反汇编文件 |
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] Randal E.Bryant David R.O'Hallaron.深入理解计算机系统(第三版).机械工业出版社,2016.
[8] https://www.cnblogs.com/pianist/p/3315801.html
[9] https://www.csd.cs.cmu.edu