计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号 2022113582
班 级 2203102
学 生 张彩菲
指 导 教 师 史先俊
计算机科学与技术学院
2023年4月
摘要是论文内容的高度概括,应具有独立性和自含性,即不阅读论文的全文,就能获得必要的信息。摘要应包括本论文的目的、主要内容、方法、成果及其理论与实际意义。摘要中不宜使用公式、结构式、图表和非公知公用的符号与术语,不标注引用文献编号,同时避免将摘要写成目录式的内容介绍。
关键词:计算机系统、Hello、进程、处理器
目 录
2.2在Ubuntu下预处理的命令.......................................................................... - 5 -
3.2 在Ubuntu下编译的命令............................................................................. - 6 -
4.2 在Ubuntu下汇编的命令............................................................................. - 7 -
5.2 在Ubuntu下链接的命令............................................................................. - 8 -
5.3 可执行目标文件hello的格式.................................................................... - 8 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 10 -
6.3 Hello的fork进程创建过程..................................................................... - 10 -
6.6 hello的异常与信号处理............................................................................ - 10 -
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 -
8.1 Linux的IO设备管理方法.......................................................................... - 13 -
8.2 简述Unix IO接口及其函数....................................................................... - 13 -
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P,即From Program to Process。在生成hello可执行文件的过程中,hello.c源文件通过预处理器(cpp)预处理获得hello.i文本文件;hello.i经过编译器(ccl)编译获得汇编程序(文本)hello.s;汇编器(as)将hello.s翻译成机器语言执行,获得可重定位目标程序hello.o; 最后hello.o通过链接器(ld)最终生成可执行目标程序hello。接下来计算机可以运行这个hello文件,运行时OS会为hello创建子进程(fork), 使得hello拥有自己独立的进程,在该进程中hello即可运行。
图1.1:编译系统
020即From Zero to Zero,指hello运行实例的生命周期。程序运行前一开始为0,即不存在于内存空间,OS会为hello fork一个子进程。Shell调用execve将程序映射到虚拟内存,并调用mmap为程序申请内存空间。当程序执行完毕后OS会回收该程序,同时为该程序开辟的内存空间也会被回收,此时回归0。
1.2 环境与工具
硬件环境:
处理器 12th Gen Intel(R) Core(TM) i7-12700H 2.30 GHz
机带 RAM 16.0 GB (15.7 GB 可用)
系统类型 64 位操作系统, 基于 x64 的处理器
软件环境:Windows 11, Vmware 17.0.2, Ububtu 20.04
开发工具:VIM、GCC、GDB、OBJDUMP、VS2022 64位、EDB
1.3 中间结果
- hello.i: hello.c预处理后生成的文本文件
- hello.s: hello.i经过编译器编译生成的汇编文件
- hello.o: hello.s经过汇编器翻译生成的可重定位目标文件
- hello: hello.o链接后生成的可执行文件
- hello_elf.txt: hello.o的elf格式文件
- hello_o.elf.txt: hello的elf格式文件
- hello_asm.txt: hello的反汇编文本文件
- hello_o_asm.txt: hello.o的反汇编文件
1.4 本章小结
本章解释hello的P2P、020过程,同时介绍完成大作业使用的开发环境与工具以及由hello.c生成的中间文件。
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
预处理器根据以字符#开头的指令(命令)修改原始的C语言程序。预处理器读取系统头文件内容,并将其直接插入程序文本,结果是得到另一个C程序,通常以.i作为文件扩展名。
常见的预处理器指令有以下几种:
-
- 宏定义:#define用于定义宏,在编译时会进行文本替换;#undef:用于取消定义的宏。
- 文件包含:#include:用于包含头文件,将指定的头文件内容插入到当前位置。
- 条件编译指令:只编译指定的C代码。#ifdef / #ifndef:用于根据条件判断是否编译某段代码(防止同一个文件被重复包含);#if / #elif / #else / #endif可以根据表达式的结果决定是否编译某段代码。
- #pragma:用于向编译器发送特定的指令或者设置。不同编译器支持的pragma指令不同。
2.1.2 预处理的作用
预处理阶段根据已放置在文件中的预处理指令来修改源文件的内容。比如#include就是一个预处理指令,它把头文件的内容添加到.cpp文件中。这种预处理的机制提高了源文件的灵活性,能适应不同的计算机和操作系统;而且通过预处理指令,可以使用已经封装好的库函数,极大地提高了编程效率。
2.2在Ubuntu下预处理的命令
图2.1 预处理命令
2.3 Hello的预处理结果解析
首先发现代码量相对源代码显著增加。这种增加是因为cpp将头文件、宏变量等插入至hello.c,同时源文件基本没有改变(此处没有define定义的宏)
图2.2 hello.c预处理结果
图2.3 hello.i部分展示
2.4 本章小结
本章阐述了预处理概念与作用,并且在ubuntu上执行预处理语句,生成hello.i文件,并通过查看hello.i文件验证内容。
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
编译是指编译器(ccl)将预处理生成的后缀.i的文件进行编译,生成后缀.s文件的过程。主要包含五个阶段:词法分析、语法分析、语义分析及中间代码生成、代码优化、目标代码生成
- 词法分析。对构成源程序的字符串从左到右进行扫描和分解,根据语言的词法规则识别出一个个具有独立意义的单词,确定单词的类型,将识别出的单词转换成统一的机内表示——词法单元(token)形式语法分析。
- 语法分析。根据语言的语法规则从词法分析器输出的token序列中识别出各类短语,并构造语法分析树。
- 语义分析及中间代码生成。对每种语法单位进行静态的语义审查,然后分析其含义,并用另一种语言形式(比源语言更接近于目标语言的一种中间代码或直接用目标语言)来描述这种语义。
- 代码优化。对前阶段产生的中间代码进行等价变换或改造,以期获得更为高效(省时间省空间)的目标代码。
- 目标代码生成。将中间代码变换成特定机器上的绝对指令代码或可重定位的指令代码或汇编指令代码。
图3.1 编译流程
3.1.2 编译的作用
在编译阶段中,gcc首先要检查代码的规范性、是否有语法错误等,以确定代码的实际要做的工作,在检查无误后,gcc把代码翻译成汇编语言。同时在编译阶段编译器还能起到优化代码的作用
3.2 在Ubuntu下编译的命令
图3.2 ubuntu下编译命令截图
3.3 Hello的编译结果解析
3.1.1 汇编文件头部声明
- .file源文件(指文件从hello.i编译而来)
- .text 代码节
- .rodata 只读数据
- .align 代码对齐方式
- .global 全局变量
- .type 符号类型(为数据类型或函数类型)
- .string 字符串
3.1.2 数据
1. 数字常量。编译器将hello.c源文件出现的数字常量在hello.s中以立即数的形式表示。如下示例,在hello.c中将argc与4比较,相应hello.s中汇编代码为
cmpl $4, -20(%rbp) (argc存储在-20(%rbp)位置)
图3.3 hello.c文件数字有关常量部分截图1
图3.4 hello.s文件中有关立即数部分截图1
再比如hello.c在循环中将i与8比较,而hello.s对应为cmpl $7, -4(%rbp)。(整型i < 8 即i <= 7)
图3.5 hello.c文件数字有关常量部分截图2
图3.6 hello.s文件中有关立即数部分截图2
2 字符串常量
hello.c中有两个字符串,都作为printf的参数。编译器处理时将其放入.rodata节只读数据区中。
图3.7 hello.c文件有关字符串部分截图
图3.6 hello.s文件中有关字符串部分截图
当调用printf函数打印时,编译器先在%rdi寄存器中加载字符串放置地址,将其作为参数调用相应打印函数。
图3.7 hello.s加载.rodata中字符串数据
图3.8 hello.s加载.rodata中字符串数据
- 局部变量
局部变量一般存放在寄存器或栈中。传入参数argc存放在寄存器%edi中,而后存放在栈中-20(%rbp)的位置,而*argv[]存放在栈中,首地址存放在-32(%rbp)中。
图3.9 hello.s部分局部变量1
而int类型变量i存储在栈中-4(%rbp)的位置。
图3.10 hello.s部分局部变量2
-
-
- 赋值
-
将寄存器中数据或立即数加载至寄存器或内存中。其中movl赋值四个字节大小数据(例如int),而movq赋值八个字节大小的数据(例如char *)。
将argc赋值为寄存器edi的值,而指针argv[0]则存储%rsi中存储的地址
图3.11 利用movl及movq赋值
将i赋值为0
图3.12 利用movl赋值
-
-
- 算术操作
-
利用相应的指令,如add, sub, mul, div,neg,not等完成。
图3.13 利用addl 实现对i++
-
-
- 关系操作
-
主要利用cmp比较指令进行比较,设置条件码,再根据条件码判断下一步操作。
图3.13 将i与7比较
图3.14 将argc与4比较
-
-
- 数组/指针/结构操作
-
编译器对数组的操作往往翻译为对地址的加减。在hello.c中,存在对数组argv的访问。
图3.15 hello.c中对数组argv访问部分截图
编译器将首地址存放在栈中-32(%rbp),数组在内存中的存储为连续的,argv[1]的地址为-32(%rbp)+$8,argv[2]地址为-32(%rbp)+$16,argv[3]地址为-32(%rbp)+$24,即进行了地址的加减操作以访问数组。
-
-
- 控制转移
-
控制转移类指令用于实现分支、循环、过程等程序结构,是仅次于传送指令的最常用指令,通常通过jmp语句实现。
将4与argc比较,实现if语句判断。
图3.16 hello.s中部分控制转移的实现1
若i < 8(即I <= 7),则跳转至.L4,执行if内命令。
图3.17 hello.s中部分控制转移的实现2
3.3.7函数调用
通过call指令进行。调用时需要若传参,在64位系统中参数依次保存在寄存器rdi, rsi, rdx, rcx, r8, r9中,若参数数量超过六位,则保存在栈中。
如下为调用printf,其中字符串地址作为参数存储至寄存器%rdi中。
图3.18 hello.s中部分调用函数指令1
图3.18 hello.s中部分调用函数指令2
图3.18 hello.s中部分调用函数指令3
3.4 本章小结
本章总结了hello.i的编译过程,同时简要对部分编译结果进行了详细解释。
第4章 汇编
4.1 汇编的概念与作用
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.1.1 编译的概念
汇编是指将汇编语言程序经过编译器(as)转化为二进制的机器语言指令,并把这些指令打包成可重定位目标程序的格式,并保存在目标文件.o中。
4.1.2 编译的作用
汇编的作用是把汇编语言翻译成机器语言,用二进制码0、1代替汇编语言中的符号,即让它成为机器可以直接识别的程序。最后把这些指令打包成可重定位目标程序的格式,并保存在目标文件.o中。
4.2 在Ubuntu下汇编的命令
as hello.s -o hello.o
图4.1 ubuntu下汇编指令
4.3 可重定位目标elf格式
利用readelf -a hello.o命令查看各节信息,这里采用readelf -a hello.o > hello_o_elf.txt将输入重定位至hello_o_elf.txt文件中。
4.3.1 什么是ELF格式
ELF的英文全称是The Executable and Linking Format,最初是由UNIX系统实验室开发、发布的ABI(Application Binary Interface)接口的一部分,也是Linux的主要可执行文件格式。
从使用上来说,主要的ELF文件的种类主要有三类:
- 可执行文件(.out):Executable File,包含代码和数据,是可以直接运行的程序。其代码和数据都有固定的地址 (或相对于基地址的偏移 ),系统可根据这些地址信息把程序加载到内存执行。
- 可重定位文件(.o文件):Relocatable File,包含基础代码和数据,但它的代码及数据都没有指定绝对地址,因此它适合于与其他目标文件链接来创建可执行文件或者共享目标文件。
- 共享目标文件(.so):Shared Object File,也称动态库文件,包含了代码和数据,这些数据是在链接时被链接器(ld)和运行时动态链接器(ld.so.l、libc.so.l、ld-linux.so.l)使用的。
elf文件是有一定的格式的,从文件的格式上来说,分为汇编器的链接视角与程序的执行视角两种。在汇编器与链接器看来,ELF文件时由Section Header Table描述的一系列section的集合;而执行一个ELF文件时在加载器(Loader)看来它是由Program Header Table描述的一系列Segment的集合。
图4.2 两种视角下ELF文件对比
4.3.2 ELF头
ELF Header保存了hello.oELF格式的一些基本信息。 它以一个描述生成该可执行文件的系统的字的大小和字节顺序的16字节的序列开始,还包含ELF头大小,目标文件的类型(可重定位、可执行或共享的),机器类型(如x86-64),节头部表的文件偏移,节头部表中条目的大小与数量等。
图4.3 ELF头
4.3.3 节头部表
在节头部表中我们可以看见各节的描述信息,包括不同节的类型、地址、偏移量等。
图4.4 节头部表
4.3.4 重定位节
ELF文件格式中重定位节包含两个部分:.rela.text和.rela.eh_frame。
当链接器链接.o文件时,会根据重定位节的信息计算正确的地址。其中.rela.text包含.text中所需的重定位操作信息;.rela.eh_frane包含en_frame节的重定位信息。
图4.5 重定位节
4.3.5 符号表
.symtab节中包含ELF符号表。它包含一个条目的数组,存放程序中定义和引用的函数和全局变量的信息。
图4.6 符号表
4.4 Hello.o的结果解析
区别如下
- 数字进制不同:Hello.s中数字为十进制立即数,而反汇编代码中数字以十六进制表示。
图4.7 hello.s中数字 图4.8 反汇编中数字
- 对字符串常量引用不同:在hello.s中为全局变量所在段加上%rip,而反汇编中为0x0(%rip)。
图4.9 hello.s中加载字符串地址 图4.10 反汇编中加载字符串地址
- 分支转移的表示不同:在hello.s中通过指令jmp或je, jne, ... ,等直接跳转到某一段代码,而在反汇编中不存在代码段地址,跳转直接在下一语句的起始地址加上偏移量得到目标代码地址。
图4.11 hello.s中分支转移 图4.12反汇编中分支转移
- 调用函数不同:在汇编文件中,call后紧跟函数名,而反汇编中在call指令后加上下一条指令的地址来表示,并且机器语言中操作数都为0(在链接生成可执行文件后确定具体地址)。
图4.12 hello.s中调用函数
图4.13 反汇编中调用函数
objdump -d -r hello.o的结果如下:
图4.7 反汇编结果
本章主要介绍了汇编的概念以及汇编的作用,分析了hello.o的ELF格式,同时对hello.o二进制文件进行反汇编,得到了反汇编程序,并分析了该反汇编程序与汇编语言程序hello.s中语句的对应关系。从数字进制、字符串常量的引用、分支转移以及函数调用的不同四个方面分析了二者的关系。
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接(linking)是指将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
-
-
- 链接的作用
-
在现代系统中,链接由链接器完成。链接器使得分离编译成为可能。我们不需要将一个大型应用程序组织为一个巨大的源文件,而是可以把它分解为更小的、更好管理的模块,可以独立地修改和编译这些模块。当改变这些模块中的一个时,只需简单重新编译它并重新链接即可,不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
命令:ld -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 /usr/lib/gcc/x86_64-linux-gnu/9/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
图5.1 链接命令
5.3 可执行目标文件hello的格式
执行readelf -a hello > hello_elf.txt,将hello的ELF格式保存至文件hello_elf.txt中。查看文件即可得到各段信息。
图5.2 helloELF头
图5.3 部分hello节头部表信息1
图5.4 部分hello节头部表信息1
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
首先用edb加载hello。
图5.5 使用edb加载hello
从ELF开始,起始地址为0x400000;与5.3节对照,我们可以根据5.3节中每一节对应的起始地址在edb中找到对应信息。例如:
图5.6 helloELF格式中.init信息
对应起始地址为0x401000
图5.7 edb中.init对应信息
5.5 链接的重定位过程分析
5.5.1 hello 与hello.o反汇编的不同:
- 代码量增加。将hello.o与hello反汇编重定位至txt文件中,发现Hello反汇编中有223行,而hello.o反汇编只有52行。原因是hello中插入了需要运行程序所需的代码以及被调用的标准库代码。
图5.8 hello反汇编中插入的部分代码
- Hello反汇编文件中,每行指令都有唯一确定的虚拟地址,而hello.o反汇编中没有。因为hello经过了链接过程,完成了重定位,每条指令的地址已确定。而hello.o反汇编程序中语句前地址是从main函数为起点,从0开始。
图5.9 hello.o反汇编部分代码示意1 图5.10 hello反汇编部分代码示意1
- 函数调用以及字符串常量引用相应的机器代码中添加对应的位置。如字符串常量引用从0x0(%rip)变成0xe76(%rip);而call 语句对应位置从00 00 00 00 变为具体值。
图5.10 hello反汇编部分代码示意2
5.5.2 链接过程
链接过程主要分为符号解析与重定位两个步骤。
在符号解析这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置,也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以当汇编器遇到对最终位置未知的目标饮用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用(重定位依赖于重定位条目中的可重定位目标模块中的数据结构。)。代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。
编译器和汇编器生成从0开始的代码和数据节,而链接器通过把每个符号定义与一个虚拟内存地址相关联,从而将这些代码和数据节重定位,然后链接器会修改对所有这些符号的引用,使得它们指向这个虚拟内存地址。
5.5.3 重定位过程
对于hello来说,链接器修改hello.o代码节与数据节中对每个符号的引用,使它们指向正确的运行时地址。并在之后对符号的引用中把它们指向重定位后的地址。hello中每条指令都对应了一个虚拟地址,在函数调用,全局变量的引用,以及跳转等操作时都通过虚拟地址来进行,从而执行这些指令。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
- _dl_start
- _dl_init
- _start
- _lib_start_main
- _cxa_atexit
- _libc_csu_init
- _setjump
- _sigsetjmp
- _sigjmp_save
- main
- printf
- sleep
- getchar
- _dl_runtime_resolve_xsave
- _dl_fixup
- _uflow
- exit
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定(lazybinding),将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
首先通过readelf找到.got.plt节在地址处0x404000开始,大小为0x48,故而其结束地址为0x404047。
图5.10 helloELF格式关于got信息
图5.11 _dl_init前.got.plt信息
图5.11 _dl_init后.got.plt信息
5.8 本章小结
本章主要介绍了链接的概念及作用,以及hello的ELF格式文件信息、如何利用EDB查看hello的虚拟地址空间及信息,以及链接的过程。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
进程就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器地内容、程序计数器、环境变量以及打开文件描述符的集合。
6.1.2进程的作用
进程为用户提供了这样的假象,我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断地执行我们程序中地指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell-bash的作用
Bash Shell是一个命令解释器,它在操作系统的最外层,负责用户程序与内核进行交互操作的一种接口,将用户输入的命令翻译给操作系统,并将处理后的结果输出至屏幕。
通过xshell连接,就是打开了一个bash程序的窗口,不能点鼠标,只能输入命令
当我们使用远程连接工具连接linux服务,系统则会给打开一个默认的shell,我们可在这个界面执行命令、比如:获取系统当前时间,创建一个用户等等…
6.2.2 Shell-bash的处理流程
- 命令解析: 当您在Bash中输入命令并按下回车键时,Bash首先会对输入的命令进行解析。这包括对命令行进行分词,将命令和参数分开。
- 命令查找: Bash会查找要执行的命令的路径。这通常包括内置命令、函数和在系统的PATH环境变量指定的目录中的可执行文件。
- 命令执行: 一旦找到要执行的命令,Bash会启动一个子进程,并在该子进程中执行命令。如果命令是一个脚本文件,Bash将启动一个新的Bash实例来执行脚本。
- I/O 重定向: Bash支持输入(stdin)和输出(stdout、stderr)重定向。您可以使用<将文件内容作为输入,使用>将输出写入文件,使用|进行管道操作等。
- 变量替换: Bash支持变量,您可以在命令中使用变量。变量会在命令执行之前被替换为其值。
- 通配符扩展: Bash支持通配符(如*和?),在执行命令之前会将它们扩展为匹配的文件列表。
- 管道: 您可以使用|将一个命令的输出传递给另一个命令,形成管道。这使得多个命令能够协同工作。
- 控制结构: Bash支持各种控制结构,如条件语句(if)、循环语句(for、while)等,这些结构允许根据条件执行不同的命令。
- 退出状态: 每个命令在执行完毕后都会返回一个退出状态码。通常,0表示成功,非零值表示出现了错误。您可以使用$?来获取上一个命令的退出状态。
- 作业控制: Bash允许在前台和后台运行命令,并支持作业控制,可以使用bg、fg等命令来操纵作业。
- Hello的fork进程创建过程
在shell中执行命令,如./hello, 此时shell将调用fork函数为hello创建一个shell的子进程。
父进程通过调用fork()函数可以创建一个新的运行的子进程。调用fork()函数后,新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程虚拟地址空间相同的但独立的一份副本,包括代码、数据段、堆、共享库以及用户栈,子进程获得与父进程任何打开文件描述符相同的副本,这意味着当父进程调用fork()函数时,子进程可以读写父进程中打开的任何文件。子进程有不同于父进程的PID,fork()被调用一次,返回两次。子进程返回0,父进程返回子进程的PID。
6.4 Hello的execve过程
子进程创建后,shell调用execve函数加载并在进程的上下文中加载并运行hello,且带参数列表argv和环境变量列表envp。之后当出现错误时,例如找不到hello,execve才会返回到调用程序。
在execve加载了hello后,它调用启动代码_start,_start创建新的且被初始化为0的栈等,随后将控制给主函数main,并传入参数列表和环境变量列表.此时用户栈已经包含了命令行参数和环境变量,进入main函数后开始逐步运行程序。
6.5 Hello的进程执行
6.5.1 一些概念
时间片:一个进程执行它的控制流的一部分的每一个时间段。
- 调度:在执行过程中,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。
- 用户态:进程运行在用户模式中时,不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个I/O操作,也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。
- 核心态:进程运行在内核模式中时,可以执行指令集中的任何指令,并且可以访问内存中的任意位置。
- 用户态与核心态转换:程序在涉及到一些操作时,例如调用一些系统函数,内核需要将当前状态从用户态切换到核心态,执行结束后再改回用户态。
6.5.2 进程执行
当开始运行hello时,内存为hello分配时间片,如一个系统运行着多个进程,那么处理器的一个物理控制流就被分成了多个逻辑控制流,逻辑流的执行是交错的,它们轮流使用处理器,会存在并发执行的现象。其中,一个进程执行它的控制流的一部分的每一时间段叫做时间片。然后在用户态下执行并保存上下文。
如果在此期间内发生了异常或系统中断,则内核会休眠该进程,并在核心态中进行上下文切换,控制将交付给其他进程。
当hello 执行到 sleep时,hello 会休眠,再次上下文切换,控制交付给其他进程,一段时间后再次上下文切换,恢复hello在休眠前的上下文信息,控制权回到 hello 继续执行。
hello在循环后,程序调用 getchar() 函数, hello 从用户态进入核心态,并再次上下文切换,控制交付给其他进程。最终,内核从其他进程回到 hello 进程,在return后进程结束。
6.6 hello的异常与信号处理
6.6.1 正常运行
正常执行时,hello程序每隔argv[3]秒打印一次hello argv[1] argv[2],然后等待输入任意字符;输入后返回0;程序退出。由于输入为行缓冲,需要输入换行符才能被程序接收到,则可以直接输换行符。
图6.1 正常执行
6.6.2不停乱按
按下的字符会直接显示在屏幕上,但不干扰程序的运行。
可以看出运行中回车则直接打印一个换行行。
图6.2 不停乱按
6.6.3 运行时Ctrl-C
在hello执行时按Ctrl-C时,shell父进程会收到内核发送的SIGINT信号,父进程收到该信号后会向hello子进程发送SIGKILL来终止hello子进程并回收。
图6.3 Ctrl-C
6.6.4 运行时Ctrl-Z
在hello执行时按Ctrl-Z时,shell父进程会收到内核发送的SIGSTP信号,hello将被挂起并打印相关信息。
图6.4 Ctrl-Z
- ps
ps 命令是 Linux 操作系统中最为常用的进程查看工具,主要用于显示包含当前运行的各进程完整信息的静态快照。PID为进程在该系统中数字ID,TTY表明在那个终端上运行,TIME表示该进程占用的CPU时间,CMD为启动该进程命令的名称。
图6.5 ps
- jobs
需要查看当前终端中在后台运行的进程任务时,可以使用 jobs 命令,结合“-l”选项可以同时显示该进程对应的 PID 号。在 jobs 命令的输出结果中,每一行记录对应一个后台进程的状态信息,行首的数字表示该进程在后台的任务编号。若当前终端没有后台进程,将不会显示任何信息。
图6.6 jobs
- pstree
pstree可以输出linux系统中各进程的树形结构,方便更直观判断各进程之间的相互关系。
图6.7 pstree结果部分截图
- fg
- 使用 bg(BackGround,后台)命令,可以将后台中暂停执行(如按 Ctrl+Z 组合键挂起)的任务恢复运行,继续在后台执行操作
- 使用 fg 命令(ForeGround,前台),可以将后台任务重新恢复到前台运行
- 除非后台中的任务只有一个,否则 bg 和 fg 命令都需要指定后台进程的任务编号作为参数。
图6.7 fg结果
通过 kill 命令终止进程时,需要使用进程的 PID 号作为参数。无特定选项时,kill 命令将给该进程发送终止信号并正常退出运行,若该进程已经无法响应终止信号,则可以结合“-9” 选项强行终止进程。强制终止进程时可能会导致程序运行的部分数据丢失。
图6.7 kill结果
6.7本章小结
本章主要介绍了进程的概念与作用,同时介绍了Shell-Bash的作用与处理流程、hello的fork创建于execve过程、进程的执行过程,以及分析了Hello的异常与信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1 逻辑地址
逻辑地址是用户编程时使用的地址,分为段地址和偏移地址两部分。表示为 [段标识符:段内偏移量]。在CPU保护模式下,需要经过寻址方式的计算和变换才可以得到内存中的有效地址。Hello的反汇编代码中地址即为逻辑地址,需要加上相应段基址才能得到真正的地址。
7.1.2 线性地址
跟逻辑地址类似,它也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件页式内存的转换前地址。hello反汇编代码中的偏移地址(逻辑地址)与基地址相加后,即得到了对应内容的线性地址。
7.1.3 虚拟地址
虚拟地址是指程序访问存储器所使用的逻辑地址。使用虚拟寻址时,CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被送至内存前先转换成适当的物理地址。
在hello的ELF格式文件中,程序头的VirtAddr即为各节的虚拟地址。
7.1.4 物理地址
物理地址是用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。计算机的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。
在hello的运行过程中,hello内的虚拟地址经过地址翻译后得到的即为物理地址,并在机器中通过物理地址来访问数据。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由段选择符与段内偏移量组成。段选择符是由一个16位长的字段组成:前13位为索引号,通过段选择符中的索引号从全局段描述符(GDT)或局部段描述符(LDT)中找到该段的段描述符,而段描述符中的base字段是段的起始地址;后三位包含一些硬件细节。
其中GDT在内存中的地址和大小放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。段起始地址加段内偏移量即为线性地址。
- 看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
- 拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
- 把Base + offset,就是要转换的线性地址了。
图7.1 段选择符示意
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(虚拟地址)由虚拟页号(VPN)以及虚拟页偏移(VPO)组成。页式管理将物理内存和线性地址空间划分为固定大小的页。而页表是一个页表条目 (Page Table Entry, PTE)的数组,将虚拟页地址映射到物理页地址(包含虚拟页号到物理页框号的映射关系)。
- 虚拟地址生成: 当一个程序访问内存时,它使用虚拟地址。这些虚拟地址是由程序中的指针生成的。每个进程都有自己的虚拟地址空间,从0到某个最大地址。
- 地址翻译(地址映射): 虚拟地址首先被传递到MMU。MMU通过使用页表(Page Table)或段表(Segment Table)来将虚拟地址翻译为物理地址。翻译的方式取决于使用的内存管理方案(分页系统或分段系统)。
- 页内偏移: 虚拟地址中的低位部分指定了页面内的偏移量。在分页系统中,虚拟地址由虚拟页号和页内偏移组成。MMU使用页内偏移来计算物理地址中的具体位置。
- 物理地址生成: 通过将虚拟地址的页号与页内偏移转换为物理地址的页框号和页内偏移,可以计算出最终的物理地址。物理地址由内存硬件用于访问实际的RAM。
图7.2 页表管理示意
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是一个小的、虚拟寻址的缓存。其中的每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相连度,用于组选择和行匹配的索引和标记字段是从虚拟地址的虚拟页号中提取出来的。如果TLB有T=2^t个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,而TLB标记(TLBT)是由VPN中的剩余位组成的。
图7.3 多级页表管理示意
虚拟地址通常被分为几个部分,每个部分用于不同的目的,例如页表索引、页内偏移等。在一个使用四级页表的系统中,虚拟地址可能会被划分为四个部分,对应于四级页表的层级结构。
7.4.1 TLB查找
当一个程序使用虚拟地址进行内存访问时,首先检查TLB,看是否已经存在对应的物理地址。如果存在,这个TLB命中,可以直接从TLB中获取物理地址,跳过后续的页表查找过程。如果没有命中,则进入下一步。经过四级页表支持下的VA到PA的变换,虽然所经历的步骤更多,但如果一级页表的一个PTE是空的,对应的二级页表就不会存在,因此可以节省大量未被使用的空间。
TLB命中时步骤:
- CPU产生一个虚拟地址;
- MMU从TLB中取出相应的PTE;
- MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存;
- 高速缓存/主存将所请求的数据字返回给CPU。
7.4.2 四级页表查找
如果TLB未命中,系统将使用虚拟地址的各个部分(Level 4、Level 3、Level 2、Level 1索引)来查找四级页表。每个级别的页表都存储了对应级别的物理页框号。
一级页表: 通过虚拟地址的一级列表索引找到对应的二级页表的物理地址。
二级: 通过虚拟地址的二级索引找到对应的三级页表的物理地址。
三级: 通过虚拟地址的三级索引找到对应的四级页表的物理地址。
四节: 通过虚拟地址的四节索引找到对应的页框号。
7.4.3 TLB更新
虚拟地址的最低部分是页内偏移,它表示在页内的具体位置。将页框号和页内偏移组合,得到最终的物理地址。当页表查找完成后,将获得的物理地址及相应的虚拟地址存储到TLB中,以便未来的快速访问。
缓存的层次结构提供了更快速的存储访问,通过层次化的缓存结构,系统在处理内存访问时能够充分利用局部性原理,提高数据的访问速度。如果数据在高层次的缓存中找到,就能够避免更慢的存储层次的访问。
- L1 Cache(一级缓存):
- Cache查找: 当CPU访问内存时,首先会检查L1 Cache中是否存在所需数据。L1 Cache是最小且最快速的缓存,通常分为指令缓存(L1i Cache)和数据缓存(L1d Cache)。
- 命中: 如果数据在L1 Cache中找到(L1 Cache命中),CPU直接从这里获取数据,无需进一步的内存访问。这是最快速的情况。
- L2 Cache(二级缓存):
- L1 Cache未命中: 如果数据未在L1 Cache中找到(L1 Cache未命中),则CPU会继续查找L2 Cache。
- Cache查找: L2 Cache通常比L1 Cache更大但相对较慢。如果数据在L2 Cache中找到(L2 Cache命中),CPU将从这里获取数据。
- L3 Cache(三级缓存):
- L2 Cache未命中: 如果数据未在L2 Cache中找到(L2 Cache未命中),则CPU会继续查找L3 Cache。
- Cache查找: L3 Cache是一个更大但相对更慢的缓存层。如果数据在L3 Cache中找到(L3 Cache命中),CPU将从这里获取数据。
- 主存(RAM):
- L3 Cache未命中: 如果数据未在L3 Cache中找到(L3 Cache未命中),CPU将直接访问主存(RAM)。
- 内存访问: 主存是最慢的存储层次,因此如果数据在L3 Cache未命中,将会产生较高的访问延迟。CPU将请求的数据从主存读取到L3 Cache,并且通常还会同时加载到更低层次的缓存中,以便未来的快速访问。
写操作的处理:
- 对于写操作,如果发生在L1 Cache中,通常会在L1 Cache中直接写入,然后在适当的时机写回到L2 Cache、L3 Cache以及主存。这是因为L1 Cache的写入是相对较快的。
- 如果写操作发生在L2 Cache或更低级别的缓存中,会依次写回到更高级别的缓存和主存
当fork()函数被父进程调用时,内核创建一个子进程,为新的子进程创建各种数据结构,并分配给子进程一个唯一的pid。具体流程如下:
- 复制页表项: 在fork开始时,父进程的虚拟地址空间中的所有页表项将被复制到子进程中。这包括页表的所有级别,因为fork操作创建了一个几乎相同的进程,子进程应该拥有相同的地址空间。
- 创建子进程: 操作系统会为子进程分配一个新的进程控制块(Process Control Block,PCB),并为其分配一个唯一的进程标识符(PID)。子进程的状态(寄存器、程序计数器等)将被初始化为父进程的状态。
- 分配物理内存: 操作系统为子进程分配与父进程相同大小的物理内存。这是由于子进程通常与父进程共享代码段、数据段等。分配的物理内存是以页为单位的。
- 共享内存段: 父进程和子进程通常会共享相同的内存段,例如代码段和只读数据段。这是通过设置页表项来实现的,使得这些页表项指向相同的物理内存页。这样,当父进程或子进程修改这些共享的页时,会影响到另一个进程。
- 写时复制(Copy-on-Write,COW): 为了提高效率,实际的页复制在需要写入时才会发生。这意味着,如果父进程或子进程尝试修改已共享的内存页,操作系统将为其中一个进程创建一个新的物理页,并更新相应的页表项,使得两个进程分别拥有各自的副本。
- 更新页表: 操作系统需要更新子进程的页表,以反映新的物理页分配和共享内存段的情况。这可能涉及到递归更新多级页表。
- 设置返回值: 在父进程和子进程中,fork函数返回不同的值。在父进程中,fork返回子进程的PID,而在子进程中,fork返回0。这样,程序可以通过检查fork的返回值来确定当前是在父进程还是子进程中执行。
为了给hello进程创建虚拟内存,fork函数创建了当前进程的mm_struct、页表等副本,hello进程的虚拟内存刚好与调用fork时存在的虚拟内存相同且相互独立,映射至同一个物理内存。
7.7 hello进程execve时的内存映射
在一个典型的execve系统调用中,一个进程用一个新的程序替换其当前的程序。这个过程涉及到新程序的加载和执行。
- 清理旧进程资源: 在execve开始时,操作系统会清理掉旧进程的一些资源,例如关闭不需要的文件描述符,释放动态内存等。
- 加载新程序: 操作系统会从磁盘中加载新的可执行文件,这个文件包含了新程序的二进制代码、数据、堆栈等信息。
- 创建新的地址空间: 旧的地址空间将被丢弃,为新程序创建一个新的地址空间。这意味着旧的页表将被废弃,而新的页表将被创建。
- 建立新的栈: 为新程序建立一个新的用户栈,用于保存函数调用和局部变量。这通常是在地址空间的顶部(高地址)。
- 内存映射: 操作系统将新程序的各个部分映射到新的地址空间中:
- 文本段(Text Segment): 包含程序的机器指令。通常是只读的,可以共享。
- 数据段(Data Segment): 包含程序的全局和静态变量。通常包括可读写的数据。
- 堆(Heap): 用于动态内存分配。execve并不直接影响堆,而是取决于新程序的内存管理需求。
- 栈(Stack): 包含函数调用和局部变量。在新地址空间的顶部创建一个新的用户栈。
- 更新进程状态: 操作系统更新进程的状态,包括程序计数器、寄存器等。这将使新程序从main函数或其它入口点开始执行。
- 执行新程序: 进程现在开始执行新加载的程序,跳转到新程序的入口点。
7.8 缺页故障与缺页中断处理
7.8.1 缺页故障
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页。
缺页错误的分类:
- 硬件缺页(Hard Page Fault): 此时物理内存中没有对应的页帧,需要CPU打开磁盘设备读取到物理内存中,再让MMU建立VA和PA的映射
- 软缺页(Soft Page Fault): 此时物理内存中存在对应的页帧,只不过可能是其他进程调入,发生缺页异常的进程不知道,此时MMU只需要重新建立映射即可,无需从磁盘写入内存,一般出现在多进程共享内存区域
- 无效缺页(Invalid Page Falut): 比如进程访问的内存地址越界访问,空指针引用就会报段错误等
7.8.2 缺页中断处理
- 保存当前上下文: 当缺页中断发生时,处理器会保存当前程序的上下文信息,包括程序计数器(PC)、寄存器状态等,以便在缺页处理完毕后能够正确地恢复执行。
- 查找页表: 操作系统内核会根据缺页的地址,查找相应的页表来确定所需的页面是否在物理内存中。如果页面在内存中,但是没有正确映射到当前进程的页表中,也会引发缺页中断。
- 判断缺页原因: 缺页中断处理过程中,需要判断缺页的原因:
- 未分配页面: 页面确实没有被分配。
- 页面在磁盘上: 页面在磁盘上,需要从磁盘加载到内存。
- 权限错误: 程序试图在只读页面上执行写操作等。
- 处理缺页: 根据判断的缺页原因,执行相应的处理:
- 未分配页面: 通常会导致进程终止或分配新的页面。
- 页面在磁盘上: 操作系统将从磁盘上读取相应的页面数据到物理内存中,并更新页表。
- 权限错误: 操作系统可能会分配新的页面或修改页表以满足程序的访问需求。
- 更新页表: 如果页面在磁盘上,读取完数据后,需要更新页表,将该页面正确映射到物理内存中的地址。
- 恢复上下文: 恢复之前保存的程序上下文,包括程序计数器等,以便程序能够继续执行。
- 重新执行引发缺页的指令: 由于缺页异常导致的指令无法正常执行,处理完缺页中断后,需要重新执行引发缺页的指令,确保程序正常继续执行。
- 继续执行程序: 处理完缺页中断后,程序将继续执行,访问所需的内存页。
7.9动态存储分配管理
7.9.1 动态内存管理的基本方法
动态内存管理是指在程序运行时进行内存分配与释放,以满足程序的动态需求。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向更高的地址延申。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将内存视为一组不同大小的块的集合来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块保留供应用程序使用,空闲块可用来分配。
分配器有两种基本风格:显式分配器与隐式分配器。其中显式分配器要求应用显式地释放任何已分配的块,如C语言中调用malloc函数来分配一个块,然后调用free函数来释放一个块。隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,就释放这个块。因此,隐式分配器也叫垃圾分配器。
Printf函数会调用malloc,用于在堆上动态分配一块指定大小的内存,并返回指向该内存块起始地址的指针。而free函数用于释放先前通过malloc分配的内存。
7.9.2 动态内存管理的策略
以下为一些常见的动态内存管理策略:
- 手动管理(Manual Management): 开发人员显式地使用malloc、free(在C中)或new、delete(在C++中)等函数来进行内存的分配和释放。这种方式灵活,但容易导致内存泄漏、悬挂指针和段错误等问题,因为开发人员需要手动追踪和管理每块内存的生命周期。
- 引用计数(Reference Counting): 在每个动态分配的内存块中维护一个引用计数,表示有多少个指针指向该内存块。当引用计数减为零时,释放内存。这种策略简单,但难以处理循环引用的情况。
- 垃圾回收(Garbage Collection): 通过垃圾回收器自动检测和释放不再使用的内存。常见的垃圾回收算法有标记-清除、复制、标记-整理等。垃圾回收能够自动处理循环引用,但可能会引入暂停和性能开销。
- 智能指针(Smart Pointers): 使用智能指针类(如std::shared_ptr和std::unique_ptr)来管理动态内存。智能指针通过 RAII(资源获取即初始化)机制,在对象生命周期结束时自动释放内存。std::shared_ptr使用引用计数,而std::unique_ptr采用独占所有权的方式。
- 内存池(Memory Pool): 通过预先分配一块固定大小的内存块,将其划分为小块,然后在程序运行时分配和释放这些小块。内存池可以减少内存碎片和提高分配效率。
- TLSF(Two-Level Segregated Fit): 是一种内存分配算法,将内存块按大小分类存储,以提高内存分配和释放的效率。
- 区域内存管理(Region-Based Memory Management): 将内存分为多个区域,每个区域具有相同的分配和释放策略。这种策略适用于有固定生命周期的对象。
- 内存分配器(Memory Allocator): 自定义内存分配器,通过覆写new和delete操作符或使用malloc和free函数,实现特定的内存分配策略,例如固定大小内存块的分配器。
7.10本章小结
本章结合hello,说明了逻辑地址、线性地址、虚拟地址和物理地址的概念、区别以及转化方法。分析了三级Cache下的物理内存访问流程和hello进程的fork与execve的内存映射以及动态内存管理的基本方法与策略。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
一个Linux文件就是一个m字节的序列:B0, B1, …, Bm-1, 所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对应的文件读和文件写来执行。这种将设备优雅地映射为文件的方式允许Linux内核引出一个简单的、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
- 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
函数:int open(char *filename, int flags, mode_t mode); open()函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
- 每个进程开始时三个打开的文件。标准输入、标准输出和标准错误,描述符分别为0、1、2。头文件<unistd.h>定义了常量STDIN_FILENO 、STOOUT_FILENO 和STDERR_FILENO, 它们可用来代替显式的描述符值。
- 改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为k。
- 读写操作。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n。给定一个大小为m 字节的文件,当k>=m 时执行读操作会触发EOF (end of file) 条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号”。
类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k。
函数:ssize_t read(int fd, void *buf, size_t n); read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。若成功返回读的字节数,若EOF 则返回0, 若出错返回-1。
ssize_t write(int fd, const void *buf, size_t n); write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。若成功则返回写的字节数,若出错则返回-1。
- 关闭文件。当一个应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
函数:int close(int fd); 返回:若成功则为0, 若出错则为-1。
8.3 printf的实现分析
首先查看printf函数的函数体:
int printf(const char *fmt, ...)
{
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
可以看出printf的第一个参数为const char * 类型fmt, 后面参数用…代替,这个是可变形参的一种写法。当传递参数的个数不确定时,就可以用这种方式来表示。故而需要确定具体调用时参数个数。
我们查看这行代码:va_list arg = (va_list)((char*)(&fmt) + 4));其中va_list 定义为typedef char * va_list,说明它时字符指针。而(char*)(&fmt) + 4)表示的是…中的第一个参数,这是因为C语言中参数压栈方向是自右向左(即当调用printf时,最右边的参数先入栈)。
接着查看下一行代码:i = vsprintf(buf, fmt, arg);我们需要查看vsprintf函数体,如下:可以得到vsprintf返回要打印字符串的长度。
int vsprintf(char *buf, const char *fmt, va_list args)
{
char* p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) {
if (*fmt != '%') {
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case 'x':
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
下一行代码:write(buf, i);为写操作,将buf中i个元素写到终端。Write实现如下:write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
这里将几个参数传递给寄存器,并以int INT_VECTOR_SYS_CALL结束。我们可以找到INT_VECTOR_SYS_CALL实现:
init_idt_desc(INT_VECTOR_SYS_CALL, DA_386IGate, sys_call, PRIVILEGE_USER); 一个int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数
接下来查看sys_call实现:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
这里,sys_call实现了显示格式化了的字符串,也就是ASCII到字模库到显示vram的信息。从而实现了字符串的显示。
https://zhuanlan.zhihu.com/p/508593219
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar()是stdio.h中的库函数,它的作用是从stdin流中读入一个字符,也就是说,如果stdin有数据的话不用输入它就可以直接读取了,第一次getchar()时,确实需要人工的输入,但是如果输了多个字符,以后的getchar()再执行时就会直接从缓冲区中读取了。实际上是 输入设备->内存缓冲区->getchar()
这可以看做一个异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar函数调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了hello的I/O管理,介绍了Linux中所有I/O设备都被模型化为文件,而所有输入输出都被当作对应文件的读与写。同时分析了Unix I/O接口及函数,printf函数和getchar()函数的实现。
结论
- 首先在编辑器上编写hello.c源文件
- Hello.c经过预处理器,将头文件插入程序文本,同时替换define定义的宏
- 编译器对预处理结果hello.i进行编译,得到hello.s
- 汇编器对hello.s进行处理,生成机器语言指令构成的可重定位文件hello.o
- 链接器对hello.o进行重定位,得到可执行目标文件hello
- Hello可以在shell上运行,当执行命令./hello时,OS会为hello创建一个子进程。
- Hello中地址为虚拟地址,需要mmu等配合将虚拟地址翻译为物理地址。
- 正常执行程序需要输入输出,本文以hello为例详细介绍了Unix I/O管理。
- Shell需要回收终止的子进程,至此程序结束。
附件
文件名 | 作用 |
hello.c | 源程序 |
hello.i | 预处理结果 |
hello.s | 编译结果 |
hello.o | 汇编结果 |
hello_o_asm.txt | hello.o反汇编对应文本 |
hello_o_elf.txt | hello.o的elf格式文本 |
hello | 链接结果(可执行文件) |
hello_elf.txt | hello的elf格式文本 |
hello_asm.txt | hello的反汇编对应文本 |
参考文献
[1]Randal E.Bryant等.深入理解计算机系统(原书第3版)[M]. 北京:机械工业出版社,2016.7:2.
[7] linux中进程管理(ps、top、pstree、pgrep、jobs、&、kill、fg)-优快云博客
[8] 逻辑地址、物理地址、虚拟地址_虚拟地址 逻辑地址-优快云博客
[10] https://zhuanlan.zhihu.com/p/103941006
[11] 一个虚拟地址到物理地址的过程 - 知乎
[12] 内存管理——缺页异常概述 - 知乎