计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 120L022007
班 级 2003006
学 生 林宇航
指 导 教 师 吴锐
计算机科学与技术学院
2021年5月
每一个程序员都编写过许多程序,这些代码经历一系列变化最终才被执行。在本文中,我们以一个Hello.c文件为例,深入跟踪一个程序,体验并理解它的一生:预处理,编译,汇编,最后链接生成可执行文件Hello, 以及计算机系统是如何对Hello程序进行进程管理,存储管理,I/O管理的,通过探索案例程序的一生和它与计算机系统之间的工作配合,结合本课程学习内容,总结并深化对计算机系统的理解。
关键词:程序,代码,计算机系统,预处理,编译,汇编,链接,进程,存储,I/O;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
Hello的P2P(From Program to Process):在Linux系统中,hello.c经过cpp的预处理生成hello.i文件,再经ccl的编译生成hello.s文件,之后由as汇编生成hello.o文件,最后通过链接器ld的链接生成可执行文件hello。在shell命令行中输入运行程序的命令之后,shell解析命令,初始化环境,为其fork一个子进程,再调用execve运行程序。程序最终转变成一个进程。
Hello的020(From Zero to Zero):shell为子进程调用execve,为其映射虚拟内存并在程序开始运行时载入物理内存,之后运行函数。CPU为程序分配时间片执行逻辑控制流,在程序结束之后,父进程回收并释放子进程使用的数据与空间。这样,程序从无到有最终又回归到无,即为020。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk以上
软件环境:Windows10 64位;VirtualBox;Ubantu 16.04 LTS 64位
开发与测试工具:gcc,vim,edb/gdb,readelf,HexEdit
1.3 中间结果
hello.i:预处理之后得到的文本文件
hello.o:编译得到的汇编语言文件
hello.s:汇编得到的可重定位目标文件
hello: 链接得到的可执行目标文件
hello_elf:.o文件对应的elf格式文件
hello_elf2:可执行目标文件对应的elf格式文件
asm.txt:.o文件反汇编得到的汇编语言的文本文件
asm2.txt:可执行文件反汇编得到的汇编语言的文本文件
1.4 本章小结
本章节简要概述了hello的演变过程,并列举了其中的中间文件以及实验的环境与工具,是本次实验的一个概述,后续的内容将依此展开详细探讨。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:预处理顾名思义是预先的处理工作,预处理器(cpp)根据以字符‘#’开头的命令,在程序正式编译前修改原始的C程序,得到一个新的C程序,通常是.i文件。
预处理的作用:
- 宏定义:
将#define删除并展开宏定义,形如#define A B则在程序文件中出现的A变量替换为B,需要注意的是宏定义并非值的直接传送,在字符(串)或是其他变量/函数名中出现的A并不会替换成B,只有单独A作为一个参数时才会替代。为区分开上面说到的几种意外情况,宏定义字符串应该加括号。
- 文件包含:
处理掉#include指令,读取系统读取以其后字符串为文件名的头文件,并将之直接插入到程序文本当中。这里的头文件一般是一些非常常用的定义代码,为了方便它们被程序调用,把它们打包成一个头文件。引用自己定义的头文件需用双引号引出文件名,内部文件则用<>。
- 条件编译:
程序中可以使用一些伪指令,使得源程序只有部分代码会被编译。预处理过程中会识别这些伪指令,例如#ifdef、#ifndef等等,然后按照预定规则只保留部分代码可被编译,从而减少空间占用,提高效率。
除此之外,预处理过程中还会将程序员的注释过滤掉(识别//以及/**/)
2.2在Ubuntu下预处理的命令
Linux系统中,命令行输入gcc -E -o hello.i hello.c实现预处理,如图所示
2.3 Hello的预处理结果解析
如图可见转化后的hello.i中源程序的main函数部分
我们发现结果正如上分析,.i文件相较于.c文件,把头文件中的代码全部添加进去以及去注释、宏定义代换等其他处理,使简单的程序内容变得繁多。
2.4 本章小结
本章总结分析了程序的预处理概念及其作用,并对案例程序实现了预处理,然后对结果进行简单的对比分析
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译的概念:对预处理得到的.i文件进行一系列词法分析、语法分析、语义分析以及优化后生成相应的汇编代码文件(机器可识别的二进制文件)。
编译的作用:
- 词法分析:
使用一种叫做lex的程序实现词法扫描,它会按照用户之前描述好的词法规则将输入的字符串分割成一个个记号。产生的记号一般分为:关键字、标识符、字面量(包含数字、字符串等)和特殊符号(运算符、等号等),然后他们放到对应的表中。
- 语法分析:
语法分析器根据用户给定的语法规则,将词法分析产生的记号序列进行解析,然后将它们构成一棵语法树。对于不同的语言,只是其语法规则不一样。
- 语义分析:
语义分静态与动态两种,静态语义指的是在编译期就可以确定的语义。通常包括声明与类型的匹配、类型的转换,例如不同类型变量的赋值float->int,内含一个隐式的类型转换操作,语义分析就需要完成它。(值得一提的是如果这两个类型不能发生正常的转换赋值,那么编译器会报错)另外我们也需要知道,动态语义指的是在程序运行过程才能确定的语义,例如除以0的运算。 语法分析完成了对表达式语法层面的分析,但是它不了解这个语句是否真正有意义。有的语句在语法上是合法的,但是却是没有实际的意义,编译器在这里实现的就是如上所述判断语句是否有意义。
- 中间代码生成:
对于一些比较简单的值,编译器可以直接将之计算得出结果,但是对于大部分比较复杂的语句,编译过程只能将之对应的语法树初步简化成中间代码,需要后续步骤的进一步优化简化。同时,编译器可以依此分为前端后端,编译器前端负责产生于机器无关的中间代码,编译器后端将中间代码换成机器代码。
- 目标代码生成与优化:
代码生成器将中间代码转成机器代码,这个过程是依赖于目标机器的,因为不同的机器有着不同的字长、寄存器、数据类型等。
最后目标代码优化器对目标代码进行优化,比如选择合适的寻址方式、使用唯一来代替乘除法、删除出多余的指令等。
3.2 在Ubuntu下编译的命令
Linux系统中,命令行输入gcc -S hello.i -o hello.s实现编译,如图所示
3.3 Hello的编译结果解析
3.3.1 汇编初始/结尾部分节名称与其对应含义
如上图所示,.s文件的初始部分有许多声明,其含义如下:
①.file 声明此.s对应的源文件
②.text 表示代码段
③.section / .rodata 只可读的数据块,存放只可读变量
④.align(8) 数据/地址的对其方式(8字节对齐)
⑤.string/ .long 声明字符串/long型整数
⑥.global 声明全局变量
⑦.type 声明一个字符(串)的类型是函数/数据
⑧.size ELF格式下隐含标识一个段的结束,该指令设置与符号名称关联的大小。
⑨.ident 记录系统环境
3.3.2数据
①字符串
它们的使用在程序如下图所示位置,作为print的参数使用
②全局函数main
从.s文件12行开始全是main函数的内容
③局部变量
在main函数中出现,存放在寄存器中
如上图划线所示,相当于C语言中的循环变量i,它存放在%rax中,这就是一个循环变量,当它不再需要被使用时,该空间会被之后需要占用空间的其他变量占掉,这就是一个局部变量的使用与最终释放。
④立即数
即在函数中各部分都有出现的$+数字的格式,相当于常数,可以参与计算、赋值比较等。
3.3.3操作
①赋值
赋值是C语言中的说法,在汇编当中它表现为把某一个值存放到寄存器当中,利用movX指令实现(其中X表示传送操作的位数,b对应1位,w对应2位,l表示4位,q表示8位),如下图所示
可以将任意值存放到寄存器中,如19.22.23行实现的是将地址存放到寄存器中;28.31是将立即数存放到寄存器中;36.39将寄存器里的值复制到另一个寄存器中,在AT&T格式中,前为源操作数,后为目的操作数。
②数学/逻辑运算
将多个数据操作完之后,结果存放在某一寄存器中,这里的数据可以是立即数,也可以是寄存器中的值。值得一提的是,为了使代码更简洁,内存分配更合理,更新寄存器中的值这一操作是非常常见的,即某一寄存器中的值运算之后结果放回到此寄存器。数学运算可以通过addX、subX、mulX、divX、xorX、shl/rX等多种算术/逻辑运算指令实现,也可以在赋值的时候以形如m(%esp)实现地址的加。
如上所示即为本函数涉及到的数学运算,本程序没有逻辑运算,形式类似,只是指令不同
③类型转换
即实现不同类型数据之间的转换,但是不是所有的数据类型都能做到。本程序涉及到的只有atoi函数,将字符串转化成整型数据
④数组操作
因为数据在内存中是连续存放的,所以只要对指针进行加减运算即可实现下标切换访问数组。
如上图中34-38行,44-46行,均是函数里常见的数组操作,将栈指针减少,即为即将填进来的数据腾出空间,相当于是建立数组,然后根据数据大小±size*n实现模拟下标访问。
⑤条件转移
即根据标识符的值或是比较结果进行跳转,比较由指令cmpX实现(此处的X同赋值movX一致表示操作位数),跳转由指令jXX实现,XX即所需条件,如l,le等大小关系比较结果或者cz等标识符为1/0的条件。本函数只涉及到大小比较的跳转,如下图所示:
Je为相等跳转,不满足条件则不跳转,顺序执行下一条指令;jle表示目的数小于等于源操作数时跳转,否则顺序执行下一条指令,条件转移常用于循环和分支的实现。
⑥函数调用
顾名思义,就是调用系统内部库的函数/用户定义的函数,调用具体需要如下几个步骤:
- 传递控制
P调用Q时,在调用处有跳转至Q代码地址的指令,将程序计数器中下一条执行指令更新为Q代码的地址,Q结尾有ret至调用处的下一行指令的地址的指令,将程序计数器下一条执行指令更新为P中调用Q的下一行地址。
- 传递数据
函数需要有参数,在调用函数之前需要把被调用函数需要用到的参数按照顺序分配到寄存器当中(参数依此存放在%rdi、%rsi、%rdx、%rcx、%r8、%r9当中,第七个往后存放在栈空间)。被调用函数的返回值存放在寄存器%rax中。
- 分配/释放内存
被调用的函数执行过程中可能需要使用局部变量等,因此需要为之分配内存空间,在调用结束之后,释放内存。
如上图,调用以call指令实现,需要的参数在调用前movX进寄存器中。
3.4 本章小结
本章先概述了程序编译的概念和作用,然后实现案例程序的编译并对编译结果进行详细分析。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中,此文件是一个二进制文件且一般可分为两个段:
代码段:存放程序的指令,一般可读可执行但不可写
数据段:存放程序用到的全局变量和静态数据,一般可读可执行也可写
汇编的作用:将汇编代码翻译成机器可以执行的机器指令
4.2 在Ubuntu下汇编的命令
Linux在命令行中输入gcc hello.s -c -o hello.o实现汇编,如图所示:
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
在Linux系统命令行输入readelf -a hello.o > ./hello_elf.txt将hello的elf格式文本导入到hello_elf.txt文件当中,如图:
然后分析ELF文件内容如下:
①:ELF头
如上图所示,ELF头中以16B大小的Magic开头,Magic描述了生成该文件的系统的字的大小和字节顺序,除此之外还有包含帮助链接器进行语法分析和解解释目标文件的信息(包括ELF 头的大小、目标文件的类型、机器类型、 字节头部表的文件偏移,以及节头部表中条目的大小和数量等信息)。
可知此文件有14个节,我们可以再下一点中看到其相关信息
②节头部表:
如下图所示即为本文件中出现的总共14个节的具体信息,包含节的名称、类型、位置、大小、权限标识(依照key to flag可知部分节的读写执行权限)等,值得一提的是由于是可重定位目标文件,每个节都从0开始,便于重定位。
系统依据Address字段定位节加载到虚拟内存中的位置,再根据字节偏移信息定位虚拟地址到物理地址的映射关系。
③重定位节:
重定位节记录了程序各部分中引用到的外部变量的信息。在链接时,系统根据重定位节中的信息对这些引用的地址进行重定位,即根据基址和偏移计算正确地址。信息的含义如下所述:
Offset:需要被修改的引用节的偏移Info:包括symbol和type两个部分,symbol在前面四个字节,type在后面四个字节
symbol:标识被修改引用应该指向的符号,
type:重定位的类型
Type:告知链接器应该如何修改新的应用
Attend:一个有符号常数,一些重定位要使用它对被修改引用的值做偏移调整 Name:重定向到的目标的名称。
本程序需要重定位的引用符号如下图所示
④符号表:
符号表用于存放在本程序中定义的、引用的函数信息还有定义的全局变量的信息(包括名称、类型、大小、偏移等)。本函数涉及到的如下:
4.4 Hello.o的结果解析
根据提示将反汇编导入到asm.txt中进行下一次分析
反汇编得到的代码如下图所示
通过对比不难发现,反汇编代码不仅有汇编语言,还有部分机器语言(纯粹二进制数据表示的指令,能够被机器真正识别),汇编指令和其操作数据都可以按照一定规则与二进制数串形成映射关系,因此机器代码与汇编代码可以相互转换,而且,反汇编代码与.s文件大部分语句是一样的,如赋值、计算、比较等,此处不做重复的分析,只对不同部分展开探讨。
①条件转移
反汇编代码中,实现转移的指令与hello.s一样,但是hello.s文件中的操作数或者说跳转地址是例如.L4这种段名称作为跳转目的,但是在反汇编代码中,跳转的地址是一个具体地址。
②函数调用
在hello.s与反汇编文件当中,调用都是利用call指令实现。但是hello.s文件的call后接的操作数是函数名,而显然机器无法识别这个函数名,所以机器语言代码中,调用的对象应该是地址而非函数名,即call的操作数是地址。值得一提的是,可能会出现需要链接之后才能确定调用地址的情况,这个时候在此阶段看到的call后的地址会先置0,在动态链接外部函数之后就能将之更新为正确的地址。
③全局变量的调用
在hello.s中,调用全局变量的方法是全局变量所在段名称(如rodata)+%rip访问该段。而正如函数调用中所说,机器无法识别段名称,反汇编文件中,0+%rip开始,设置重定位信息进行访问相关段。
④数据
反汇编代码中,立即数是十六进制,而在hello.s文件当中操作数都是十进制。
4.5 本章小结
本章对程序的汇编的概念、作用进行简述,并对案例程序进行汇编操作和结果,以及程序的ELF格式文件的内容进行分析,重点比较了反汇编文件和hello.s文件。通过这些步骤来增进对汇编过程、机器代码和汇编代码的理解。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接的概念:
通过链接器(ld)将源程序代码和它引用的各个外部模块合并到一起得到可执行目标文件(Windows系统为.exe文件,Linux一般是无后缀名的文件),该文件可以被加载到内存中,由系统执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。
链接的作用:
将程序使用到的各个模块链接起来合并成一个巨大的源文件。链接使程序的各个模块的分离编译成为可能,我们可以依此将一个程序分成若干个模块,以便于分配和管理。
5.2 在Ubuntu下链接的命令
在Linux系统中输入命令: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
5.3 可执行目标文件hello的格式
如上图所示,在Linux系统命令输入readelf -a hello > hello_elf2.txt生成elf格式文件并分析其基本信息如下:
①ELF头:
与.o文件的ELF格式文件的内容相同,ELF头中以16B大小的Magic开头,Magic描述了生成该文件的系统的字的大小和字节顺序,除此之外还有包含帮助链接器进行语法分析和解解释目标文件的信息(包括ELF 头的大小、目标文件的类型、机器类型、 字节头部表的文件偏移,以及节头部表中条目的大小和数量等信息)。
由于链接进引用模块,文件扩充至27节。
②节头部表:
如下图所示,节头部表中存放文件中出现的所有节的基本信息,包括名称、类型、大小、地址基址和偏移量、执行权限等。与.o文件的ELF格式文件一样。
③程序头表:
如下所示信息,程序头表是一个结构数组,其中存放组成最终可执行程序的几个段的基本信息,包括各段的虚拟地址、物理地址、大小、标识符、访问权限、对齐格式。.o文件的ELF格式文件中则没有这部分的信息。
④动态链接段表
存放一系列和动态链接的信息,包含名称/值、类型。
⑤重定位表:
.text节中需要重定位的信息,在链接时依此进行重定位。包含地址、重定位时所需的符号表索引、重定位类型、重定位基础地址和偏移量等
⑥符号表
如下图所示,符号表中存放着定位、重定位时符号定义与引用的信息,包括序号、值、大小、类型、全局变量/局部变量、名称。
5.4 hello的虚拟地址空间
使用edb加载hello文件,在data dump中可以看到hello程序的虚拟内存分配情况。在节头表中可查到各节的虚拟地址入口和大小,例如.text节的虚拟地址入口为0x4010f0,大小0x145 B。(PHDR保存的是程序头表;INTERP保存了程序执行前需要调用的解释器;LOAD记录程序目标代码和常量信息;DYNAMIC储存了动态链接器所使用的信息;NOTE记录的是一些辅助信息;GNU_EH_FRAME保存异常信息;GNU_STACK使用系统栈所需要的权限信息;GNU_RELRO保存在重定位之后只读信息的位置。)
5.5 链接的重定位过程分析
在Linux命令行输入objdump -d -r hello >asm2.txt将hello文件反汇编并将结果保存至asm2.txt中。详见附件,分析如下:
- hello与hello.o的不同:
①相比于hello.o,hello文件经过链接多了一些函数,例如sleep函数和新增节中出现的函数
②多了.init节和.plt节
③调用函数等各种访存情况下,call/jmpX等指令后接的是相应的虚拟地址
- 链接过程:
(1)重定位节和符号定义链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
(2)重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。执行这一步,链接器依赖于可重定位目标模块中称为的重定位条目的数据结构。
(3)重定位条目当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目,代码的重定位条目放在.rel.txt中
5.6 hello的执行流程
开始先执行_start、_libc_start_main;之后执行主函数过程:_main、_printf、_exit、_sleep、_getchar;最后退出。
子程序及其地址如下:
_start 0x4010f0
_libc_start_main 0x2f12271d
main 0x401125
_printf 0x401040
_exit 0x401070
_sleep 0x401080
_getchar 0x401050
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在ld-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
由节头部表可知,GOT起始位置是0x404000
在edb中的data dump找到相应位置
然后在_start函数前设置断点,再次查看(此时为执行过_init函数),可以发现0x404008和0x404010处的两个8字节的数据发生改变,出现了两个地址0x7f445f3cf190和0x7f445f3b8ae0,这就是GOT[1]和GOT[2]的地址。
同样查看一下两处地址的内容如下
GOT[1]:
GOT[2]:
5.8 本章小结
本章介绍了链接的概念与作用,并对案例程序进行链接操作生成了可执行文件,分析了hello可执行文件的ELF格式文件、hello的重定位过程、执行过程、动态链接过程,加深了链接、重定位等知识模块的理解。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:进程是操作系统对正在运行的程序的一种抽象,进程的经典定义就是一个执行中程序的实例,即具有独立功能的一个程序关于某个数据集合的一次运行活动,是操作系统对一个正在运行的程序的一种抽象。进程会提供一种系统上好像只有某一个程序在运行,该程序看上去是独占地使用处理器、主存、I/O设备,该程序的代码和数据是系统内存中唯一的对象的假象。
进程的作用:给应用程序提供两个关键抽象:
1.一个独立的逻辑控制流,提供一个假象,好像程序独占地使用处理器
2.一个私有地址空间,提供一个假象,好像程序独占地使用内存系统
6.2 简述壳Shell-bash的作用与处理流程
作用:读取用户的命令行并解析,串联用户与系统内核
处理流程:
1.将命令行分成由元字符分隔的记号:
元字符包括 SPACE, TAB, NEWLINE, ; , (,), <, >, |, &
记号的类型包括 单词,关键字,I/O 重定向符和分号。
2.检测每个命令的第一个记号,看是否为不带引号或反斜线的关键字。如果是一个 开放的关键字,如 if 和其他控制结构起始字符串,function,{或 (,则命令实际上为一复合命令。shell 在内部对复合命令进行处理,读取下一个命 令,并重复这一过程。如果关键字不是复合命令起始字符串,而是如 then 等一个控制结构中间出现的关键字,则给出语法错误信号。
3.依据别名列表检查每个命令的第一个关键字。如果找到相应匹配,则替换其别名定义,并退回第一步;否则进入第 4 步。
4.执行大括号扩展,例如 a {b,c} 变成 ab ac
5. 如果~位于单词开头,用 $HOME 替换~。使用 usr 的主目录替换~user。
6. 对任何以符号 $ 开头的表达式执行参数 (变量) 替换
7. 对形如 $(string) 或者 `string` 的表达式进行命令替换
这里是嵌套的命令行处理。
8. 计算形式为 $((string)) 的算术表达式
9. 把行的参数替换,命令替换和算术替换 的结果部分再次分成单词,这次它使用 $IFS 中的字符做分割符而不是步骤 1 的元字符集。
10. 对出现 *, ?, [ ] 对执行路径名扩展,也称为通配符扩展
11. 按命令优先级表 (跳过别名),进行命令查寻。先作为一个特殊的内建命令,接着是作为函数,然后作为一般的内建命令,最后作为查找 $PATH 找到的第一个文件。
12. 设置完 I/O 重定向和其他操作后执行该命令。
6.3 Hello的fork进程创建过程
父进程调用fork函数创建一个新的子进程,这个子进程几乎但不完全与父进程相同:子进程得到与父进程用户及虚拟地址空间相同但是独立的一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别是二者的PID不同。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的 逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
6.4 Hello的execve过程
创建子进程之后,子进程调用execve函数在当前的上下文空间运行hello程序,具体步骤如下:
(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
(2)映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
(3)映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
(4)设置程序计数器。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
值得一提的是execve不会返回。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
hello程序的执行是依赖于进程所提供的如下抽象的基础上的:
①逻辑控制流::一系列程序计数器 PC 的值的序列叫做逻辑控制流,进程是轮流 使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占 (暂时挂起),然后轮到其他进程。
②并发流:一个逻辑流的执行时间与另一个流重叠,成为并发流,这两个流成为并发的运行。多个流并发的执行的一般现象成为并发。
③时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
④私有地址空间:进程为每个流都提供一种假象,好像它是独占的使用系统地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,在这个意义上,这个地址空间是私有的。
⑤用户模式和内核模式::处理器通常使用一个寄存器提供两种模式的区分,该寄 存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中, 用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的 代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任 何命令,并且可以访问系统中的任何内存位置。
⑥上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由 通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内 核数据结构等对象的值构成。
⑦上下文切换:当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程:
1.保存以前进程的上下文
2.恢复新恢复进程被保存的上下文,
3.将控制传递给这 个新恢复的进程 ,来完成上下文切换。
结合我们的案例程序来分析,在命令行运行hello程序,shell为此fork一个子进程,子进程调用execve函数,分配了新的虚拟的地址空间,并且已经将hello的.txt和.data节分配虚拟地址空间的代码区和数据区。程序在用户模式执行完输出功能之后,调用sleep函数,内核将该进程移除运行队列放入等待队列中,定时器开始计时,内核将控制权交给其他进程,当定时器计数至输入要求时,发出中断信号,内核处理此中断信号,将控制权重新赋给hello所在进程。最终实现周期输出相应信息的功能。
当hello调用getchar的时候,实际落脚到执行输入流是stdin的系统调用read,hello之前运行在用户模式,在进行read调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回hello进程。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
异常共有如下几种:
类型 | 原因 | 异步/同步 | 默认行为 |
中断 | I/O设备传送信号 | 异步 | 返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 返回到下一条指令 |
故障 | 潜在的可修复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不返回 |
在hello程序中能出现的异常有:
1.中断:外部I/O设备引起的异常
2.调用sleep函数
3.缺页故障
4.DRAM、SRAM位损坏的奇偶错误
以下展示各种情况:
①正常运行:
每个进程以回车结束
②不停乱按:
在程序执行过程中乱按所造成的输入均缓存到stdin,当getchar的时候读出一个’\n’结尾的字串(作为一次输入),hello结束后,stdin中的其他字串会当做Shell的命令行输入。因此,键入内容会出现在每个进程输出指定内容之前,但不影响内容输出。
③回车键:
如图所示,每次键入一个回车键就会多空出一行且会引起当前子进程结束,增加一次回到shell请求用户输入命令的进程
④Ctrl-c:
键入Ctrl-c会让程序停下,输入ps命令查看发现程序已终止
⑤Ctrl-z:
如图可见,键入Ctrl-z会停止当前进程但不是终止,即hello程序进程被挂起,此时可以输入各种命令查看工作信息/杀死进程,此进程仍可恢复运行:
6.7本章小结
本章介绍了进程的概念和创建、终止以及进行了案例程序的进程管理实操,以及相应的异常和信号处理。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:即程序经过编译之后出现在汇编代码之中的地址,由段标识符:偏移量构成。(在案例程序中asm.txt、asm2.txt文件中出现的相对偏移地址)
线性地址:逻辑地址进一步经段转换之后的地址,相比逻辑地址更接近物理地址,表现为描述符:偏移量,分页机制中,线性地址作为输入,hello根据线性地址确定需要在内存的哪些数据块上运行。
虚拟地址:与线性地址等价。
物理地址:CPU地址总线传来的地址,由硬件电路控制的真实的地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在 Intel 平台下,逻辑地址(logical address)是 selector:offset 这种形式,selector 是 CS 寄存器的值,offset 是 EIP 寄存器的值。如果用 selector 去 GDT( 全局描述符表 ) 里拿到 segment base address(段基址) 然后加上 offset(段内偏移),这就得到了 linear address。我们把这个过程称作段式内存管理。
一个逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段(段选择符)。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
全局的段描述符,放在全局段描述符表中,一些局部的段描述符,放在局部段描述符表中。
给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是全局段描述符中的段,还是局部段描述符中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。Base + offset = 线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
CPU为所需单位分配一个虚拟地址,该虚拟地址转换成物理地址之后送往内存寻址。转换规则如下:
每次将虚拟地址转换为物理地址,都会查询页表来判断一个虚拟页是否缓存在DRAM的某个地方,如果不在DRAM的某个地方,通过查询页表条目可以知道虚拟页在磁盘的位置。页表将虚拟页映射到物理页。页表就是一个页表条目的数组,每一个页表条目是由一个有效位和一个n为地址字段组成。有效位表明虚拟页是否缓存在DRAM中,n位地址字段是物理页的起始地址或者虚拟页在次胖的起始地址。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO),一个n-p位的虚拟页号(VPN),MMU利用VPN选择适当的PTE,例如VPN 0选择PTE 0。根据PTE,我们知道虚拟页的信息,如果虚拟页是已缓存的,那直接将页表条目的物理页号和虚拟地址的VPO串联起来就得到一个相应的物理地址。这里的VPO和PPO是相同的。如果虚拟页是未缓存的,会触发一个缺页故障。调用一个缺页处理子程序将磁盘的虚拟页重新加载到内存中,然后再执行这个导致缺页的指令。
7.4 TLB与四级页表支持下的VA到PA的变换
我们以Intel Core i7 CPU作为研究对象,它的一些基本参数为:虚拟地址空间 48 位,物理地址空间 52 位,页表大小 4KB,4 级页表。TLB 4 路 16 组相联。CR3 指向第一级页表的起始位置(上下文一部分)。 解析前提条件:由一个页表大小 4KB,一个 PTE 条目8B,共 512 个条目,使 用 9 位二进制索引,一共 4 个页表共使用 36 位二进制索引,所以 VPN 共 36 位, 因为 VA 48 位,所以 VPO 12 位;因为 TLB 共 16 组,所以 TLBI 需 4 位,因为 VPN 36 位,所以 TLBT 32 位。
如课程PPT中配图所示,CPU 产生虚拟地址 VA,VA 传送给 MMU,MMU 使用前 36 位 VPN 作为 TLBT(前 32 位)+TLBI(后 4 位)向 TLB 中匹配,如果命中,则得到 PPN (40bit)与 VPO(12bit)组合成 PA(52bit)。 如果 TLB 中没有命中,MMU 向页表中查询,CR3 确定第一级页表的起始地 址,VPN1(9bit)确定在第一级页表中的偏移量,查询出 PTE,如果在物理内存 中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查 询到 PPN,与 VPO 组合成 PA,并且向 TLB 中添加条目。如果查询 PTE 的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
7.5 三级Cache支持下的物理内存访问
各级cache的工作原理相同,在此我们只对L1Cache做讨论。
(1) 组选择取出虚拟地址的组索引位,将二进制组索引转化为一个无符号整数,找到相应的组
(2) 行匹配把虚拟地址的标记为拿去和相应的组中所有行的标记位进行比较,当虚拟地址的标记位和高速缓存行的标记位匹配时,而且高速缓存行的有效位是1,则高速缓存命中。
(3) 字选择一旦高速缓存命中,我们就知道我们要找的字节在这个块的某个地方。因此块偏移位提供了第一个字节的偏移。把这个字节的内容取出返回给CPU即可
(4)不命中如果高速缓存不命中,那么需要从存储层次结构中的下一层取出被请求的块,然后将新的块存储在组索引位所指示的组中的一个高速缓存行中。一种简单的 放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块, 产生冲突(evict),则采用最近最少使用策略进行替换。
7.6 hello进程fork时的内存映射
之前我们谈到,在Linux系统命令行输入执行文件命令之后,父进程(shell)调用fork函数创建子进程,为子进程分配几乎与父进程完全一样的环境,并为之创建新的数据结构,分配独特的PID。为了给该子进程分配虚拟内存地址,系统内核创建此子进程的mm_struct、区域结构、页表的原样副本,并将父进程与子进程的页表都标记为只读,将两个进程的每个区域结构标记为私有的写时复制。这样,当fork从新进程返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。
7.7 hello进程execve时的内存映射
execve加载并运行hello程序的过程中内存映射如下:
①删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
②映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结 构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
③映射共享区域,hello 程序与共享对象 libc.so 链接,libc.so 是动态链 接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
④设置程序计数器,execve 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页故障:当某一指令引用某一个虚拟地址时,若此地址对应的物理地址不在内存中,会触发缺页故障。
处理:系统查询页表得到引用虚拟地址对应的物理地址,并找到一个牺牲页面,从该位置加载牺牲页面到物理内存中,更新PTE,将控制权交还给原先指令再次执行,此时页面已在内存之中,因此不会再发生缺页故障。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存分配器维护着一个进程的虚拟内存区域,称之为堆。分配器将堆视为一组不同的大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。分配器有两种基本风格。两种风格都是要求显示的释放分配块:
(1) 显式分配器:要求应用显示的释放任何已分配的块。例如C标准库提供一个叫做malloc程序包的显示分配器。
(2) 隐式分配器:要求分配器检测一个已分配块何时不再被程序使用,那么就释放这个块。隐式分配器也叫垃圾收集器。
隐式空闲链表块的格式:
(1)放置已分配的块当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。分配器搜索方式的常见策略是首次适配、下一次适配和最佳适配。
(2)分割空闲块一旦分配器找到一个匹配的空闲块,就必须做一个另策决定,那就是分配这个块多少空间。分配器通常将空闲块分割为两部分。第一部分变为了已分配块,第二部分变为了空闲块。
(3)获取额外堆内存如果分配器不能为请求块找到空闲块,一个选择是合并那些在物理内存上相邻的空闲块,如果这样还不能生成一个足够大的块,分配器会调用sbrk函数,向内核请求额外的内存。
(4)合并空闲块合并的情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四种情况分别进行空闲块合并,我们只需要通过改变头部的信息就能完成合并空闲块。
显示空闲链表块的格式:
显示空闲链表是将空闲块组织为某种形式的显示数据结构。堆被组织为一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继的指针。
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线 性时间减少到了空闲块数量的线性时间。
一种方法使用后进先出的顺序维护链表,将新释放的块在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过 的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。
按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要 线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比LIFO排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。
7.10本章小结
本章详细介绍分析了hello的储存地址空间、段式管理与页式管理、虚拟内存到物理内存的转换、物理内存访问、fork与execve时的内存映射、缺页故障及其处理以及动态储存分配管理,将程序进程与内存地址空间联系起来。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
一个Linux文件就是一个m个字节的序列:B0~Bm-1。所有的I/O设备(例如网络、磁盘、终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:打开文件、改变当前文件位置、读写文件、关闭文件。
8.2 简述Unix IO接口及其函数
Unix IO接口:
1.打开文件:内核返回一个非负整数的文件描述符,通过对此文件描述符对文件进行所有操作。
2.改变当前的文件位置,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。
3.读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k。
4.关闭文件:当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中。
Unix IO函数:
1. open()函数
功能描述:用于打开或创建文件,在打开或创建文件时可以指定文件的属性及用户的权限等各种参数。
函数原型:int open(const char *pathname,int flags,int perms)
参数:pathname:被打开的文件名(可包括路径名如"dev/ttyS0")flags:文件打开方式,
返回值:成功:返回文件描述符;失败:返回-1
2. close()函数
功能描述:用于关闭一个被打开的的文件
所需头文件: #include <unistd.h>
函数原型:int close(int fd)
参数:fd文件描述符
函数返回值:0成功,-1出错
3. read()函数
功能描述: 从文件读取数据。
所需头文件: #include <unistd.h>
函数原型:ssize_t read(int fd, void *buf, size_t count);
参数:fd:将要读取数据的文件描述词。buf:指缓冲区,即读取的数据会被放到这个缓冲区中去。count: 表示调用一次read操作,应该读多少数量的字符。
返回值:返回所读取的字节数;0(读到EOF);-1(出错)。
4. write()函数
功能描述: 向文件写入数据。
所需头文件: #include <unistd.h>
函数原型:ssize_t write(int fd, void *buf, size_t count);
返回值:写入文件的字节数(成功);-1(出错)
5. lseek()函数
功能描述: 用于在指定的文件描述符中将将文件指针定位到相应位置。
所需头文件:#include <unistd.h>,#include <sys/types.h>
函数原型:off_t lseek(int fd, off_t offset,int whence);
参数:fd;文件描述符。offset:偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负(向前移,向后移)
返回值:成功:返回当前位移;失败:返回-1
8.3 printf的实现分析
printf函数如下所示,可以看到该函数可以发现printf的输入参数是fmt,但是后面是不定长的参数(函数无法知道长度因而有缓冲区溢出风险),同时在printf内存调用了两个函数,一个是vsprintf,一个是write。
vsprintf:
write:
由此可见,printf调用vsprintf函数将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。write函数将buf中的i个元素写到终端。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
首先了解getchar函数,如下图所示。getchar是读入函数的一种。它从标准输入里读取下一个字符,相当于getc(stdin)。返回类型为int型,为用户输入的ASCII码或EOF。getchar可用宏实现:#define getchar() getc(stdin)。getchar有一个int型的返回值。当程序调用getchar时.程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键 的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子 程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码 转换成 ASCII 码,保存到系统的键盘缓冲区之中。
getchar 函数落实到底层调用了系统函数 read,通过系统调用 read 读取存储在 键盘缓冲区中的 ASCII 码直到读到回车符然后返回整个字串,getchar 进行封装, 大体逻辑是读取字符串的第一个字符然后返回。
8.5本章小结
本章节介绍了Linux的I/O设备管理方法和I/O接口、函数,分析了printf、getchar的实现,hello程序最终走到输出环节。
(第8章1分)
结论
hello历程:
首先由程序员编写该程序,这样,hello.c生成;然后cpp对其进行预处理,将头文件插入,按全局定义完成代换并处理一些无关文本(注释等),生成hello.i文件;再然后,编译器通过词法分析语法分析,将指令翻译成汇编代码,生成hello.s文件,再将这些汇编代码翻译成机器语言代码,打包存放在可重定位目标文件格式的hello.o中;之后,链接器将hello的程序编码与动态链接库等收集整理成为一个单一文件,生成完全链接的可执行的目标文件hello;之后我们打开命令行输入./hello 120L022007林宇航 1,shell为其fork一个子进程,并通过execve把代码和数据加载入虚拟内存空间,程序开始执行;在该进程被调度时,CPU为hello其分配时间片,在一个时间片中,hello享有CPU全部资源,PC寄存器一步一步地更新,CPU不断地取指,顺序执行自己的控制逻辑流;与此同时,内存管理单元MMU将逻辑地址,一步步映射成物理地址,进而通过三级高速缓存系统访问物理内存/磁盘中的数据,printf 会调用malloc 向动态内存分配器申请堆中的内存,并且进程时刻等待着信号,如果运行途中键入ctrl-c ctrl-z则调用shell 的信号处理函数分别进行停止、挂起等操作,当然,对于其他信号也有相应的操作;等到程序完全结束后,Shell父进程等待并回收hello子进程,内核删除为hello进程创建的所有数据结构,至此hello的一生落幕。
对计算机系统的感悟:
计算机系统的设计思想和实现都是基于抽象实现的。从最底层的信息的表 示用二进制表示抽象开始,到实现操作系统管理硬件的抽象:进程是对处理器、 主存和I/O设备的抽象。虚拟内存是对主存和磁盘设备的抽象。文件是对I/O 设备的抽象。
计算机系统的设计精巧:为了解决快的设备存储小、存储大的设备慢的不 平衡,设计了高速缓存来作为更底层的存储设备的缓存,大大提高了CPU访 问主存的速度。
计算机系统的设计考虑全面:计算机系统设计考虑一切可能的实际情况, 设计出一系列的满足不同情况的策略。比如写回和直写,写分配和非写分配, 直接映射高速缓存和组相连高速缓存等等。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
hello.c:程序源代码
hello.i:预处理之后得到的文本文件
hello.o:编译得到的汇编语言文件
hello.s:汇编得到的可重定位目标文件
hello: 链接得到的可执行目标文件
hello_elf:.o文件对应的elf格式文件
hello_elf2:可执行目标文件对应的elf格式文件
asm.txt:.o文件反汇编得到的汇编语言的文本文件
asm2.txt:可执行文件反汇编得到的汇编语言的文本文件
(附件0分,缺失 -1分)
参考文献
[1] 深入理解计算机系统 第三版
[2] 预处理的功能_bluedogcolan888的博客-CSDN博客_预处理作用
[3] https://blog.csdn.net/guaiguaihenguai/article/details/81160310?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522165294807716782390596914%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=165294807716782390596914&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~top_positive~default-1-81160310-null-null.142^v10^pc_search_result_control_group,157^v4^control&utm_term=%E7%BC%96%E8%AF%91&spm=1018.2226.3001.4187.
[4] https://rtoax.blog.csdn.net/article/details/123476231?spm=1001.2014.3001.5502
[5] [转]printf 函数实现的深入剖析 - Pianistx - 博客园
- https://blog.csdn.net/qq_31865983/article/details/89885741?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522165319737516781432991543%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=165319737516781432991543&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~baidu_landing_v2~default-1-89885741-null-null.142^v10^pc_search_result_control_group,157^v4^control&utm_term=Linux%E7%9A%84IO%E7%AE%A1%E7%90%86&spm=1018.2226.3001.4187
(参考文献0分,缺失 -1分)