程序人生-Hello’s P2P
阅读本文,可以对计算机系统课程有更全面的认识。可用于复习。
本文从计算机系统的角度,以hello为例,描述了从C语言程序到可执行目标程序的预处理、编译、汇编、链接的过程,阐述了进程管理机制和程序存储管理机制,简述了Linux的I/O管理机制。结合本学期计算机系统课程所学知识,详细地阐述了hello程序精彩的“一生”。
关键词:计算机系统;编译;进程
目 录
2.2在Ubuntu下预处理的命令.......................................................................... - 7 -
3.2 在Ubuntu下编译的命令............................................................................. - 9 -
4.2 在Ubuntu下汇编的命令........................................................................... - 14 -
5.2 在Ubuntu下链接的命令........................................................................... - 21 -
5.3 可执行目标文件hello的格式.................................................................. - 21 -
5.5 链接的重定位过程分析............................................................................... - 24 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 29 -
6.3 Hello的fork进程创建过程..................................................................... - 29 -
6.6 hello的异常与信号处理............................................................................ - 32 -
第7章 hello的存储管理............................................................................... - 36 -
7.1 hello的存储器地址空间............................................................................ - 36 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................ - 36 -
7.3 Hello的线性地址到物理地址的变换-页式管理...................................... - 36 -
7.4 TLB与四级页表支持下的VA到PA的变换............................................. - 37 -
7.5 三级Cache支持下的物理内存访问.......................................................... - 38 -
7.6 hello进程fork时的内存映射.................................................................. - 39 -
7.7 hello进程execve时的内存映射.............................................................. - 39 -
7.8 缺页故障与缺页中断处理........................................................................... - 40 -
8.1 Linux的IO设备管理方法.......................................................................... - 42 -
8.2 简述Unix IO接口及其函数....................................................................... - 42 -
第1章 概述
1.1 Hello简介
P2P(From Program to Process):程序员编写原始代码,形成最初的hello.c;由C预处理器扩展源代码形成预编译后的hello.i;然后编译器产生源文件的汇编代码hello.s;汇编器会将汇编代码转化成二进制目标代码文件hello.o;最后链接器将目标代码文件与实现库函数的代码合并,产生最终的可执行代码文件hello。在bash中OS调用fork创建子进程运行hello,至此完成从原始C程序到进程的过程。
O2O(From Zero-0 to Zero-0):由shell通过execve在fork产生的子进程中加载hello,进行内存映射,分配时间片进入逻辑控制流,CPU进行取址、译码、计算(并入流水线工作),通过IO管理来输入输出。程序结束后,shell回收hello进程,内核删除相关结构。至此程序完成运行过程中从零又到无的过程。
1.2 环境与工具
硬件环境:Intel(R) Core(TM) i7-10710U CPU @ 1.10GHz,16G RAM,500GHD Disk
软件环境:Windows 10 64位,Vmware 16,Ubuntu 20.04 LTS 64位
开发调试工具:x86_64-linux-gnu gcc
1.3 中间结果
hello.c:源代码
hello.i:预处理后的文本文件
hello.s:编译之后的汇编文件
hello.o:汇编之后的可重定位目标执行文件
hello:链接之后的可执行文件
hello.elf:hello.o的ELF格式
hello1.elf:hello的ELF格式
hello1.txt:hello.o反汇编代码
hello2.txt:hello的反汇编代码
1.4 本章小结
根据Hello的自白,利用计算机系统的术语,简述了Hello的P2P,020的整个过程。列出了我为编写本论文,研究Hello的整个过程中,使用的软硬件环境、开发与调试工具。列出了我为编写本论文,生成的中间结果文件的名字,文件的作用。
第2章 预处理
2.1 预处理的概念与作用
预处理指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。由预处理器对程序的源代码文本进行处理,C预处理器扩展源代码,插入所有用#include命令指定的文件,并扩展所有用#define声明指定的宏。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——使用预处理记号用来支持相关语言特性。结果是得到另一个C程序,通常是以“.i”作为文件扩展名。
图表 1 预处理命令
2.2在Ubuntu下预处理的命令
命令:gcc -E -o hello.i hello.c
图表 2 命令图
得到hello.i文件
图表 3 结果图
2.3 Hello的预处理结果解析
图表 4 预处理代码
从文本中可以看出预处理程序将#include中<stdio.h> <unistd.h> <stdlib.h>三个文件的源码内容添加到预处理文件中,最后的一部分则直接复制源文件的内容。如果遇到头文件中包含预处理命令,则递归地将内容复制进去,直到预处理文件中不包含任何预处理命令为止。添加了行号信息和文件名信息,删除了所有的注释,定义了相关基础变量和数据类型。
图表 5 预处理代码的末段
2.4 本章小结
本章介绍了预处理的概念、作用,以及在ubuntu下使用预处理的命令。也通过Hello的例子解析了预处理结果。
第3章 编译
3.1 编译的概念与作用
注意:此处的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
编译是指将一个经过预处理的高级语言程序文本(.i文件)翻译成能执行相同操作的等价ASCII码形式汇编语言文件(.s文件)的过程。
编译程序的工作过程一般也可以划分为五个阶段:词法分析、语法分析、语义分析与中间代码产生、优化、汇编代码生成。词法分析:输入源程序,对构成源程序的字符串进行扫描和分解,识别出关键字、标识符、常数、运算符和界符。语法分析:在词法分析的基础上,根据语言的语法规则,把单词符号分解成各类语法单位,通过语法分析确定整个输入串是否构成语法上正确的“程序”。语义分析与中间代码产生:对语法分析所识别出的各类语法范畴,分析其含义,并进行初步翻译(产生中间代码)。优化:优化主要针对公共子表达式的提取、循环优化、删除无用代码等,对前段产生的中间代码进行加工变换,以在最后阶段能产生出更为高效的目标代码。汇编代码生成:把处理后的中间代码变换成特定机器上的低级语言代码,有赖于硬件系统结构和机器指令含义,通常生成可重定位的指令代码。
3.2 在Ubuntu下编译的命令
命令:gcc -S -o hello.s hello.i
图表 6 命令图
得到hello.s文件
图表 7 结果图
3.3 Hello的编译结果解析
3.3.1 数据类型
(1)字符串
图表 8 字符串段
汇编文件中共有两个字符串,分别代表"用法: Hello 学号 姓名 秒数!\n"和"Hello %s %s\n",存储在.rodata段中。可以看出中文字符是utf-8编码,占三个字符。
(2)整型
图表 9 整型段
循环变量i存放在内存空间(-4(%rbp))中,.L3中完成了hello.c中的循环判断,.L4实现了循环内容。argc是传入main函数的参数,存储在%edi中。数字常量和定义的主函数都存储在.text节中。
(3)数组
代码见.L4。在程序中有数组argv[],argv[0]指向输入程序的路径和名称,argv[1]和argv[2]分别表示两个字符串,char* 数据类型占8个字节。argv[1]和argv[2]作为循环中printf的参数。argv的首地址是-32(%rbp)。argv[2]作为printf函数的第三个参数,存于寄存器%rdx中,地址为-16(%rbp);argv[1]作为printf第二个参数,存于寄存器%rsi中,地址为-24(%rbp)。
3.3.2数据和控制操作
(1)“i++”操作
在循环操作中,使用了++操作符,在每次循环执行的内容结束后,将栈上存储变量i的值加1。
图表 10 “i++”操作用addl加法指令
(2)大小判断
程序中判断argc是否等于4,用cmpl指令改变条件码,结合je指令判断进行跳转。
图表 11 “==”判断
程序中的循环部分须进行循环变量i的条件判断,用cmpl指令改变条件码,结合jle指令判断进行跳转。
图表 12 “>””<”判断
(3)加载有效地址
leaq指令实际上是movq指令的变形,它的指令形式是从内存读数据到寄存器,但实际上根本就没有引用内存,可以利用该特性用于算术运算。“.LC0”为本地标签,代指字符串常量。
图表 13 有效地址“leaq”指令
3.3.3函数调用
(1)main函数
传入参数argc和argv[],分别用寄存器%rdi和%rsi存储,被系统启动函数调用,设置%eax为0并且返回。最开始将%rsp减去32,开辟足够的空间。如果是通过exit函数结束main函数则不会释放内存,会造成内存泄露,但是程序如果通过return正常返回则是由指令leave恢复栈空间。
图表 14 main函数
(2)printf函数
call puts时传入了字符串参数首地址,for循环中call printf时传入了argv[1]和argv[2]的地址。把.L0段的立即值传入%rdi,然后call跳转到puts。
图表 15 printf函数
(3)exit函数
传入参数1,再执行退出命令,在if判断条件满足后被调用。把立即数1传入到%edi中,然后call跳转到exit。
图表 16 exit函数
(4)sleep函数
传入参数atoi(argv[3]),即已经数字化的argv参数,在for循环下被调用。
图表 17 sleep函数
(5)getchar函数
最后在main函数中被调用,getchar()函数返回值类型为int,如果成功返回用户输入的ASCII码,出错返回-1。
图表 18 getchar函数
(6)其他
代码中“.cfi”开头的汇编指示符用来告诉汇编器生成相应的 DWARF调试信息,“.cfi_startproc ”定义函数开始,“.cfi_endproc”定义函数结束。对于”@PLT”,这是一种获取代码修正的方法(根据代码在虚拟内存中的位置调整地址,这可能因不同进程而异),而无需为每个进程维护单独的代码副本。PLT是过程链接表,是使动态加载和链接更易于使用的结构之一。<函数名>@PLT实际上是一个小存根,它(最终)调用真正的函数,在途中进行修改以使后续调用更快。“endbr64”指令确保了间接分支实际到达有效位置,保护控制流完整性,是Intel CPU的特性。
3.4 本章小结
本章说明了编译器是如何处理C语言的各个数据类型以及各类操作的,分析了编译后的汇编代码,解释了编译的概念和作用。
第4章 汇编
4.1 汇编的概念与作用
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
汇编器将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件hello.o中的过程称为汇编。汇编器会将汇编代码转化成二进制目标代码文件hello.o,目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填入全局值的地址。
4.2 在Ubuntu下汇编的命令
命令:“gcc -c hello.s -o hello.o”
图表 19 命令图
得到hello.o文件。
图表 20 结果图
4.3 可重定位目标elf格式
命令:“readelf –a hello.o”,查看ELF格式的可重定位目标文件。
4.3.1 ELF文件头
图表 21 ELF头
ELF头位于elf文件的头部,存储着机器和该elf文件的基本信息。首先以长度为16字节的e_ident开头(即Magic码),包含着文件和操作系统信息,前4个字节包含一个magic number表示该文件是一个elf文件。第5个字节指示文件类型,是ELF32还是ELF64位,该文件为“02”是ELF64位。第6个字节指示文件的编码方式,是大端法还是小端法,该文件为小端法。第7个字节标识ELF Version,该值等于EV_CURRENT,目前为1。第8个字节标示该文件运行的操作系统,该环境下为UNIX – System V。第9个字节标识ABI(应用二进制接口)的版本,当前为0。后7位为填充位,用零填充用以对齐,可预留给未来使用。
Type对应2字节的e_type参数,指示文件类型,该文件为Relocatable file即可重定位文件。Machine对应2字节的e_machine参数,指示机器类型,该机器为Advanced Micro Devices X86-64。Version对应4字节的e_version参数,指示文件版本,当前版本为1。Entry point address对应8字节的e_entry参数,表示进程开始的虚拟地址,当前为0。Start of program headers对应8字节的e_phoff参数,指向程序头部表的开始,当前为0。Start of section headers对应8字节的e_shoff参数,指向节头部表的开始,当前为1240。Flags对应4字节的e_flags参数,意义取决于目标架构,当前为0。Size of this headers对应2字节的e_ehsize参数,表示该文件头部的大小,当前为64bytes。Size of program headers对应2字节的e_phentsize参数,表示程序头部的大小,当前为0bytes。Number of program headers对应2字节的e_phnum参数,表示程序头部的条目数,当前为0。Size of section headers对应2字节的e_shentsize参数,表示节头部表的大小,当前为64bytes。Number of section headers对应2字节的e_shnum参数,表示节头部表的条目数,当前为14。
Section header string table index对应2字节的e_shstrndx参数,表示节头部表的条目和其位置(idx)的对应关系,当前为13。
4.3.2 节头部表
图表 22 节头部表
节头部表:记录了每个节的名称、类型、属性(读写权限)、在ELF文件中占的度、对齐方式和偏移量。
.text节:已编译的机器代码。
.rela.text节:一个.text节中位置的列表。
.data节:已初始化的静态和全局C变量。
.bss节:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。
.rodata节:存放只读数据,例如printf中的格式串和开关语句中的跳转表。
.comment节:包含版本控制信息。
.note节:注释节详细描述。
.eh_frame节:处理异常。
.rela.eh_frame节:一个.eh_frame节中位置的列表。
.shstrtab节:该区域包含节区名称。
.symtab节:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
.strtab节:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部的节名字。
.symtab节:本节用于存放符号表。
4.3.3 重定位节
图表 23 重定位节
重定位节.rela.text ,一个.text 节中位置的列表,包含.text 节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。图中 8 条重定位信息分别是对.L0(第一个 printf 中的字符串)、puts 函数、exit 函数、.L1(第二个 printf 中的字符串)、printf 函数、sleepsecs、sleep 函数、getchar 函数进行重定位声明。重定位条目告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。Offset表示引用节需修改的偏移量,Sym.表示修改引用应该指向的符号,Type告知链接器如何修改新的引用,Addend是一个有符号常数,一些类型的重定位要用它对被修改引用的值做偏移调整。Info字段表示重定位入口的类型和符号,该值的低8位表示重定位入口的类型,高24位表示重定位入口的符号在符号表重的下标。存在两种最基本的重定位类型,即重定位一个使用32位PC相对地址的引用——R_X86_64_PC32和重定位一个使用32位绝对地址的引用——R_X86_64_32。
4.3.4 符号表
图表 24 符号表
.symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。其中Name是字符串表中的字节偏移,指向以NULL结尾的字符串名字。Value是符号的地址,对于可重定位的模块来说,Value是距定义目标的节的起始位置的偏移。Size是目标的大小(以字节为单位)。Type通常要么是数据,要么是函数。符号表还可以包含各个节的条目,以及对应原始源文件的路径名的条目,所以这些目标的类型也有所不同。Bind字段表示符号是本地的还是全局的,每个符号都被分配到目标文件的某个节,由Section字段表示,该字段也是一个到节头部表的索引。有三个特殊的伪节, 它们在节头部表中是没有条目的:ABS代表不该被重定位的符号;UNDEF代表未定义的符号,也就是在本目标模块中引用,但是却在其他地方定义的符号;COMMON表示还未被分配位置的未初始化的数据目标。对于COMMON符号,Value字段给出对齐要求,而Size给出最小的大小。只有可重定位目标文件中才有这些伪节,可执行目标文件中是没有的。
4.4 Hello.o的结果解析
命令“objdump -d -r hello.o”,得到反汇编代码。对比发现如下。
机器语言中每条机器指令都有对应的操作码或操作数(十六进制表示),汇编语言中只有自然语言而没有机器可执行的机器指令,都以十进制表示数据。机器语言中每条指令都会对应一个地址。如main函数的地址偏移为0x0。与机器代码的二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示。hello.s中还包括与程序没有直接关系的文件信息和系统信息,而机器代码没有,指令的表示方法基本相同。
对于分支转移,hello.s中分支是直接跳转到标签处,而反汇编的文件,则是跳转到main的某个偏置处(相对地址)。
图表 25 分支转移1
图表 26 分支转移2
对于函数调用,hello.s中使用“call <函数名>@PLT”的形式,由于没有重定位条目,在调用外部库函数时无法给出地址,以函数名称作为引用的目标。机器语言中由于存在重定位条目,可以通过重定位对相关的符号进行解析,直接给出函数调用的地址。
图表 27 函数调用1
图表 28 函数调用2
对于数据引用,hello.s中第一处对字符串常量的引用在汇编语言中以段标识与寄存器的组合.LC0(%rip)表示,而在机器语言中以0x0(%rip)表示,并且有明确的重定位信息,以相对寻址的方式获取在制度数据段.rodata偏移量为0x4处的数据。
图表 29 数据引用1
图表 30 数据引用2
4.5 本章小结
本章阐述了汇编的概念和作用,分析hello.o的ELF格式,用readelf等列出其各节的基本信息。分析hello.o的反汇编代码,与第3章的 hello.s进行了对照分析。说明了机器语言的构成,与汇编语言的映射关系。
第5章 链接
5.1 链接的概念与作用
注意:此处的链接是指从 hello.o 到hello生成过程。
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行。链接可以在编译时由静态编译器来完成,也可以在加载时和运行时由动态链接器来完成。链接器处理成为目标文件的二进制文件,它有3中不同的形式:可重定位的、可执行的和共享的。可重定位的目标文件由静态链接器合并成一个可执行的目标文件,它可以加载到内存中并执行。链接器的两个主要任务是符号解析和重定位,符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义,而重定位确定每个符号的最终内存地址,并修改对那些目标的引用。静态链接器是由像GCC这样的编译驱动程序调用的。它们将多个可重定位目标文件合并成一个单独的可执行目标文件。多个目标文件可以定义相同的符号,而链接器用来解析这些多重定义的规则可能在用户程序中引入微妙的错误。
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”
图表 31 命令图
图表 32 结果图
5.3 可执行目标文件hello的格式
命令:“readelf -a hello > hello1.elf”
可执行目标文件中的elf包含信息更多,明确了各函数与变量的地址。
1. ELF文件头:含义与可重定向目标文件基本一致,新填入了地址和条目信息。
图表 33 ELF头
2. 节头(未列全):描述了各个节的大小、偏移量和其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
图表 34 节头
3. 程序头:条目中的虚拟地址已经确定。不同的Type有如下含义。PHDR:保存程序头表;INTERP:动态链接器的路径;LOAD:可加载的程序段;DYNAMIC:保存了由动态链接器使用的信息;NOTE保存辅助信息;GNU_STACK:标志栈是否可执行;GNU_RELRO:指定重定位后需被设置成只读的内存区域。
图表 35 程序头
4. 符号表(未列全):从符号表中可以得到程序中调用的各个函数的绝对运行时地址。此时的符号表条目更多,原因是包括了局部变量和链接库内的变量。
图表 36 符号表
5.4 hello的虚拟地址空间
使用edb加载hello,Data Dump窗口可以查看加载到虚拟地址中的hello程序。与可执行目标文件elf的程序头进行对照,具有一一对应的关系。
图表 37 edb查看data dump
图表 38 elf程序头
可以发现Load从400000从开始到403e50保存着程序头,段头部表,init,text,.radata等内容。init起始于400010,.radata起始于402000。保存着只读数据的DYNAMIC开始于403e50,PHDR开始于400040,INTERP开始于400270,.text开始于401080。
5.5 链接的重定位过程分析
未链接的hello.o地址是从0开始的,而hello使用虚拟内存空间地址,从401000开始。最初在hello.o中设置的重定位条目,例如被调用函数没有明确的调用地址(全为0),这时,链接完成后,将其填充到虚拟内容空间中的显式地址中,hello.o中只有一个.text节,并且只有一个主要函数。在hello中还链接了在hello.c中用到的库函数,如exit、printf、sleep、getchar等函数。hello中增加了.init(程序初始化),.plt(动态链接表),.fini(程序终止时需要执行的指令)节。hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。hello中的外部函数可以用来链接命令的链接系统对象文件(crti.o,crtend.o等),.plt节即进程链接表,该表与GOT(全局偏移表)结合使用,以通过延迟绑定机制在共享库中动态重定位功能。
图表 39 重定位后增加函数和段
5.6 hello的执行流程
载入:_dl_start、_dl_init
开始执行:_start、_libc_start_main
执行main:_main、_printf、_exit、_sleep、_getchar
ld-2.31.so!_dl_catch_exception@plt <0x00007f6d8051b010>
ld-2.31.so!malloc@plt <0x00007f6d8051b020>
ld-2.31.so!_dl_signal_exception@plt <0x00007f6d8051b030>
ld-2.31.so!calloc@plt <0x00007f6d8051b040>
ld-2.31.so!realloc@plt <0x00007f6d8051b050>
ld-2.31.so!_dl_signal_error@plt <0x00007f6d8051b060>
ld-2.31.so!_dl_catch_error@plt <0x00007f6d8051b070>
ld-2.31.so!_dl_rtld_di_serinfo <0x00007f6d80525090>
ld-2.31.so!_dl_debug_state <0x00007f6d8052c1d0>
ld-2.31.so!_dl_mcount <0x00007f6d8052de00>
ld-2.31.so!_dl_get_tls_static_info <0x00007f6d8052e680>
ld-2.31.so!_dl_allocate_tls_init <0x00007f6d8052e770>
ld-2.31.so!_dl_allocate_tls <0x00007f6d8052e9a0>
ld-2.31.so!_dl_deallocate_tls <0x00007f6d8052ea10>
ld-2.31.so!_dl_make_stack_executable <0x00007f6d8052f130>
ld-2.31.so!_dl_find_dso_for_object <0x00007f6d8052f480>
ld-2.31.so!_dl_exception_create <0x00007f6d80532ca0>
ld-2.31.so!_dl_exception_create_format <0x00007f6d80532da0>
ld-2.31.so!_dl_exception_free <0x00007f6d80533250>
ld-2.31.so!__tunable_get_val <0x00007f6d805345d0>
ld-2.31.so!__tls_get_addr <0x00007f6d80534da0>
ld-2.31.so!__get_cpu_features <0x00007f6d80534df0>
ld-2.31.so!malloc <0x00007f6d80537490>
ld-2.31.so!calloc <0x00007f6d805375b0>
ld-2.31.so!free <0x00007f6d805375f0>
ld-2.31.so!realloc <0x00007f6d805377e0>
ld-2.31.so!_dl_signal_exception <0x00007f6d80537a70>
ld-2.31.so!_dl_signal_error <0x00007f6d80537ac0>
ld-2.31.so!_dl_catch_exception <0x00007f6d80537c40>
ld-2.31.so!_dl_catch_error <0x00007f6d80537d30>
hello!_init <0x0000000000401000>
hello!puts@plt <0x0000000000401030>
hello!printf@plt <0x0000000000401040>
hello!getchar@plt <0x0000000000401050>
hello!atoi@plt <0x0000000000401060>
hello!exit@plt <0x0000000000401070>
hello!sleep@plt <0x0000000000401080>
hello!_start <0x00000000004010f0>
hello!_dl_relocate_static_pie <0x0000000000401120>
hello!main <0x0000000000401125>
hello!__libc_csu_init <0x00000000004011c0>
hello!__libc_csu_fini <0x0000000000401230>
hello!_fini <0x0000000000401238>
5.7 Hello的动态链接分析
对于动态共享链接库中的PIC函数,编译器无法预测该函数的运行时地址,因此您需要添加重定位记录并等待动态链接器处理。 为避免在运行时修改调用模块的代码段,链接器使用延迟绑定策略。在dl_init之前,每一条动态共享链接库中的PIC函数的调用,由于编译器无法确定函数运行时的地址,所以需要进行重定位。此时对每一条IC的调用过,目标地址都是PLT的代码逻辑,GOT存放PLT中函数调用的下一条地址。plt是一个数组,每个条目是个16字节的代码,每个可被执行的库函数都有自己的PLT条目,每个条目负责调用一个具体的函数。
图表 40 动态链接寻址
在对.plt的反汇编中,地址0x401040的printf@plt共有3条语句,执行完第一条语句后会跳转到地址0x404020,这个地址位于.got.plt中。从图中可以看到got.plt起始于0x404000,终止于0x404047。并且0x404020的内容为0x401046,即为printf@plt的第二条语句的地址,此指令将0x1(当前函数的id)压入栈中。之后执行printf@plt中的第三条语句,跳转到0x401020 <.plt>。这之后的工作可以视为系统在运行时填充地址0x404020的过程。也就是在延迟绑定机制下,第一次执行时,0x404020的内容是0x401046,第二次及之后的内容就会修改为printf函数的实际地址0x7fdd0aa2e830。
图表 41 edb查看实际地址
5.8 本章小结
阐述了链接的概念和作用,分析了hello与hello.o的不同,说明了链接的过程。结合hello.o的重定位项目,分析了hello中重定位的方法。使用edb加载hello,查看了本进程的虚拟地址空间各段信息,并与5.3对照分析说明。分析了hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。使用了edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程,列出了其调用与跳转的各个子程序名或程序地址。分析了hello程序的动态链接项目,通过edb调试,分析了dl_init前后,这些项目的内容变化。
第6章 hello进程管理
6.1 进程的概念与作用
6.2 简述壳Shell-bash的作用与处理流程
处理流程:(1)读取输入(2)处理输入内容,获得输入参数(3)如果是内置命令则直接执行,否则执行系统调用(4)程序运行时,shell监视用户输入并响应,可以异步接收来自I/O设备的信号,并对这些中断信号进行处理子进程(5)执行完毕,shell将对进程进行回收。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
6.4 Hello的execve过程
execve函数加载并运行可执行文件hello,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。在execve加载了filename之后,加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。运行时,创建一个内存映像,在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段,接下来,加载器跳转到程序的入口,_start函数的地址,这个函数是在系统目标文件ctrl.o中定义的,对所有的c程序都一样。_start函数调用系统启动函数,_libc_start_main,该函数定义在libc.so里,初始化环境,调用用户层的main函数,处理main函数返回值,并且在需要的时候返回给内核。
6.5 Hello的进程执行
6.5.1 逻辑流
即使在系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果想要调试器单步执行程序,我们会看到一系列的程序计数器的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称逻辑流。
—个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地运行。更准确地说,流X和Y互相并发,当且仅当X在Y开始之后和Y结束之前开始,或者Y在X开始之后和X结束之前开始。多个流并发地执行的一般现象被称为并发。一个进程和其他进程轮流运行的概念称为多任务。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫做时间分片。
6.5.2 用户模式和内核模式
处理器通常是用某个控制寄存器中的一个模式位描述进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
6.5.3 上下文
内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。(1)保存当前进程的上下文(2)恢复某个先前被抢占的进程被保存的上下文(3)将控制传递给这个新恢复的进程。
6.5.4 控制
每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。父进程可以通过调用fork函数创建一个新的运行的子进程。
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,该进程就不存在了。如果父进程没有回收它的僵死子进程就终止了,那么内核会安排init进程去回收它们。
6.6 hello的异常与信号处理
正常运行程序,运行完后内核会回收进程:
图表 42 正常运行
当进程在前台运行时,键入Ctrl+Z,内核发送一个SIGTSTP信号给这个前台进程组中的每个进程,默认情况下,结果是停止(挂起)前台作业。此时在ps中仍能查看到当前进程,jobs查看任务列表。因为只是挂起还未回收,再输入fg继续运行前台作业,可使进程继续进行。fg用于将一些停止的进程恢复到前台运行,向处于停止状态的进程发送SIGCONT信号,恢复这些进程,并在前台开始运行。
图表 43 键入Ctrl+Z
图表 44 查看jobs
当进程在前台运行时,键入Ctrl+C,内核会发送一个SIGINT信号(号码2)给这个前台进程组中的每个进程,此时进程会被终止,ps中查看不到该进程了。
图表 45 键盘键入Ctrl+C
如果在进程运行过程中乱按,乱按的字符会实时显示。由于程序本身只含一个getchar函数,则可以在输入第一个回车之前读入任意字符串至该进程中。输入第一个回车后,此后再输入字符串会被shell接受,在进程正常结束后,根据输入回车符的间隔进行命令处理。如图,先输入了“hello”字符串后回车被getchar读入,再输入“ps”回车,再输入“jobs”回车,后两个字符串会被视为命令在进程结束后被shell处理。
图表 46 键盘乱按
如果乱按之后,程序结束之前被SIGTSTP挂起,则乱按的字符串会被清空。停止后shell也不会对之前输入的冗余字符进行处理,再次继续运行进程不会保留之前输入的字符串,stdin已清空,需重新输入。
图表 47 键盘乱按(含挂起)
pstree命令展示进程树(未列全):
图表 48 主进程树
图表 49 bash进程树
利用kill命令杀死进程。如果pid大于零,则kill函数发送信号号码sig给进程pid;如果pid等于零,那么kill发送信号sig给调用进程所在进程组中的每个进程,包括调用进程自己;如果pid小于零,kill发送信号sig和进程组|pid|中的每个进程:
图表 50 程序中断后使用kill
6.7本章小结
阐述了进程的概念和作用,简述了壳Shell-bash的作用与处理流程、Hello的fork进程创建过程和Hello的execve过程。结合进程上下文信息、进程时间片,阐述了进程调度的过程,用户模式与内核模式转换等。分析了hello执行过程中可能出现的异常、信号以及处理方式。通过在程序运行过程中按键盘(回车,Ctrl-Z,Ctrl-C等),在Ctrl-Z后运行ps、jobs、pstree、fg、kill等命令,给出了各命令运行结果,说明了异常与信号的处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:程序代码经过编译后出现在汇编程序中的地址,逻辑地址由选择符和偏移量组成,是由程序产生的与段相关的偏移地址部分,主要在编程中使用和解释。在这里是hello.o里的相对偏移地址。
线性地址:地址空间是一个非负整数地址的有序集合,如果地址空间中的整数是连续的,那么它是一个线性地址空间。CPU在保护模式下,“段基址+段内偏移地址”叫做线性地址。保护模式下段基址寄存器中存储的不是真正的段基值,而是“段选择子”,通过段选择子在GDT(全局描述表)中找到真正的段基值。在这里是hello里的虚拟内存地址。
虚拟地址:使用虚拟寻址,CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被送到内存之前先转换成适当的物理地址。在这里也是hello里的虚拟内存地址。
物理地址:用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。计算机系统的主存被组织成一个由M 个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。在这里是hello在运行时虚拟内存地址对应的物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段寄存器(16位)用于存放段选择符,段选择符有3个部分:(1)TI=0,选择全局描述符表(GDT);TI=1,选择局部描述符表(LDT)。(2)RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级。(3)高13位为索引,用来确定当前使用的段描述符在描述符表中的位置。根据一个段描述符可以找到一个段的首地址,若干段描述符就可以组成段描述符表。再通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,进而确定一个段。被选中的段描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
通过操作系统软件、MMU中的地址翻译硬件和一个存放在物理内存中称为页表的数据结构,来判定一个虚拟页是否缓存在DRAM中的某个地方,如果是,还能确定这个虚拟页存放在哪个物理页中,如果不命中,还能判断这个虚拟页存放在硬盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM中,替换这个虚拟页,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页。
页表就是一个页表条目(PTE)的数组,虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。PTE是由一个有效位和一个n位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。
图表 51 虚拟内存
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE,以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会要求从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就下降到1个或2个周期。可以在MMU中包括一个关于PTE的小缓存,称为快表(TLB),消除这样的开销。TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度,用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。
虚拟地址被划分为4个虚拟页号(VPN)和1个虚拟页面偏移(VPO)。每个VPN i都是一个到第i级页表的索引,其中1≤i≤4。第j级页表中的每个PTE,1≤j≤3,都指向第j+1级的某个页表的基址。第4级页表中的每个PTE包含某个物理页面的物理页号(PPN),或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问4个PTE。和只有一级到页表结构一样,PPO和VPO是相同的。
图表 52 多级页表寻址
7.5 三级Cache支持下的物理内存访问
MMU将物理地址发给L1缓存,缓存从物理地址中取出缓存偏移CO、缓存组索引CI以及缓存标记CT。若缓存中CI所指示的组有标记与CT匹配的条目且有效位为1,则检测到一个命中,读出在偏移量CO处的数据字节,并把它返回给MMU,随后MMU将它传递给CPU。若不命中,则需到低一级Cache(若L3 cache中找不到则到主存)中取出相应的块将其放入当前cache中,重新执行对应指令,访问要找的数据。
对于我的电脑上的CPU,L1缓存为8路64组组相连,故组索引需要6位二进制。又因为块大小为64字节,故块偏移量也为6位2进制。其余40位位标记位。得到物理地址后,我们先使用CI找到对应的组,对组中的每一个有效值为1的块的标记值与物理地址中的CT进行匹配,如果有相同的,则查找命中(hit),按照块偏移CO取出数据。如果没有找到符合要求的项,则发生不命中,需要向小一级存储L2继续查找。如果查询到数据,再将其写回L1。对于写回,如果L2中的数据映射在L1中的位置没有有效数据,则发生冷不命中,直接将L2的数据写入并修改有效位即可;如果对应位置有有效的数据,则发生冲突不命中,需要用L2中的数据替换L1中的数据。如果L1中的数据的脏位为1,则表示此数据已经被修改,还需要先将L1中的数据写回L2。
L2->L3同理。
图表 53 VA到PA的变换
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行目标文件中的程序,用它替代当前程序。加载并运行hello.out文件需要以下几个步骤:1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。2.映射私有区:为Hello的代码、数据、.bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的。代码和数据区域被映射为hello.out文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello.out中。栈和堆区域也是请求二进制零的,初始长度为零。3.映射共享区:比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。4.设置PC:exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页。假设CPU引用了(虚拟页)VP 3中的一个字,VP 3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE 3,从有效位推断VP 3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在(物理页)PP 3中的VP 4。如果VP 4已经被修改了,那么内核就会将它复制回磁盘。无论哪种结果,内核都会修改VP 4的页表条目,反映出VP 4不再缓存在主存中这一事实。接下来,内核从磁盘复制VP 3到内存中的PP 3,更新PTE 3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP 3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。
图表 54 缺页处理
7.9动态存储分配管理
动态内存分配器维护进程的虚拟内存区域,称为堆。不同系统之间的细节有所不同,但不会失去多功能性。假定堆是一个请求二进制零的区域,该区域在未初始化的数据区域之后立即开始并向上增长(到更高的地址)。对于每个进程,内核都维护一个变量brk,该变量指向堆的顶部。
分配器将堆维护为不同大小的块的集合。每个块都是已分配或可用的连续虚拟内存。分配的块已明确保留供程序使用。空闲块可用于分配。空闲块将保持空闲状态,直到由应用程序明确分配为止。分配的块将保持分配状态,直到被释放。此版本由应用程序明确执行,由内存分配器本身隐式地执行。
分配器有两种基本样式。两种样式都要求应用程序显式分配块。它们之间的区别是哪个实体负责释放分配的块。对于显式分配器,需要应用程序显式释放所有分配的块。例如,C标准库提供了一个称为malloc包的显式分配器。 C程序通过调用malloc函数分配一个块,并通过调用free函数释放一个块。C++中的new和delete运算符等效于C中的malloc和free。对于隐式分配器,要求分配器检测程序何时不再使用分配的块,然后释放该块。隐式分配器也称为垃圾收集器,自动释放未使用的已分配块的过程称为垃圾收集。例如,Lisp,ML和Java等高级语言都依赖垃圾回收来释放分配的块。
图表 55 动态内存分配栈
7.10本章小结
结合hello说明了逻辑地址、线性地址、虚拟地址、物理地址的概念。阐述了Intel逻辑地址到线性地址的变换的段式管理机制,Hello的线性地址到物理地址的变换的页式管理机制。分析了TLB与四级页表支持下的VA到PA的变换以及三级Cache支持下的物理内存访问方法。说明了hello进程fork、execve时的内存映射情况,分析了缺页故障与缺页中断处理、动态存储分配管理。考虑到printf会调用malloc,简述了动态内存管理的基本方法与策略。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:Unix I/O接口
一个Linux文件就是一个m个字节的序列:B0, B1,…,Bk,…,Bm-1。
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:
(1)打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/0设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
(2)Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STOOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
(3)改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移最。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
(4)读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的"EOF符号”。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
(5)关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2 简述Unix IO接口及其函数
(1)int open(char *filename, int flags, mode_t mode)
进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的,open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件。mode参数指定了新文件的访问权限位。
(2)int close(int fd)
进程通过调用close函数关闭一个打开的文件。
(3)ssize_t read(int fd, void *buf, size_t n)
应用程序是通过分别调用read和write函数来执行输入和输出的。read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
(4)ssize_t write(int fd, const void *buf, size_t n)
write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
8.3 printf的实现分析
研究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;
}
在形参列表里的“...”,是可变形参的一种写法,当传递参数的个数不确定时,就可以用这种方式来表示。我们需要一种方法,来让函数体可以知道具体调用时参数的个数。这句:va_list arg = (va_list)((char*)(&fmt) + 4),根据va_list的定义: typedef char *va_list,这说明它是一个字符指针。其中的(char*)(&fmt) + 4)表示的是“...”中的第一个参数。
考查函数vsprintf,vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt,用格式字符串对变化的参数进行格式化,产生格式化输出。上述代码中的vsprintf只实现了对16进制的格式化。返回要打印出来的字符串的长度。
考查函数write,使用int INT_VECTOR_SYS_CALL通过系统来调用sys_call。sys_call会调用字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar由宏实现:#define getchar() getc(stdin)。getchar有一个int型的返回值。当程序调用getchar时,程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止。当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾则返回-1(EOF),且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。
8.5本章小结
简述了Linux的IO设备管理方法,Unix IO接口及其函数。简单分析了printf和getchar的实现原理。
结论
hello程序的生命周期是从一个高级C语言程序开始的。GCC编译器驱动程序读取源程序文件hello.c,经过预处理、编译、汇编、链接后变成了一个可执行目标文件hello,完成逻辑地址到线性地址的变换,存放在磁盘上。初始时,shell程序执行它的指令,等待我们输入一个命令。当我们在键盘上键入字符串“./hello”后,shell程序将字符逐一读入寄存器,再把它存放到内存中。当我们在键盘上敲回车键时,shell程序就知道我们已经结束了命令的输入。shell会fork子进程去execve这个程序,完成线性地址到物理地址的变换,进行内存映射,占有私有地址空间,进入CPU的逻辑流。在这个过程中,它会动态申请内存(printf会调用malloc向动态内存分配器申请堆中的内存),会对外界信号做出反应(异常控制流),用I/O管理输入输出。最后hello会被shell回收,进程消失。
hello程序从编程层面是一个非常简单的程序,但他底层依然需要有非常多的支持和依赖。让我深刻认识到在一个高级的抽象层面,一个看似简单的实例,在底层实现起来也很繁琐。让我思考计算机系统的发展史,后人的每一步都是在前人的基础上搭建起来的,计算机庞杂的运行机制和硬件结构,很难说由一个人就能全部掌握,更多的都是为后继者提供黑盒般的调用和更贴近人类的抽象。我们也许不需要完全掌握计算机系统的所有知识,也能很自如的使用计算机,利用计算机来完成工作和编写程序,但就难以真正从计算思维的角度去优化程序、理解程序。hello程序里的调用只是C语言编程里的冰山一角,但对它的研究鼓励了我去对计算机的运行机制进行不断的探索。在实验的过程中,我感受到了现代计算机的进步,书本上的有些知识已经显得有些局限了。我学会了一些从底层观察程序的命令,能利用工具查看高级程序的运行方式,这也为我今后的学习打下了基础。
计算机的世界是如此秩序而美妙,我坚信计算机系统这门课只是我窥探这个伟大世界的一个伟大开始!
附件
hello.c:源代码
hello.i:预处理后的文本文件
hello.s:编译之后的汇编文件
hello.o:汇编之后的可重定位目标执行文件
hello:链接之后的可执行文件
hello.elf:hello.o的ELF格式
hello1.elf:hello的ELF格式
hello1.txt:hello.o反汇编代码
hello2.txt:hello的反汇编代码
参考文献
[1] Randal E. Bryant, David R. O’Hallaron. Computer Systems A Programmer’s Perspective [Third Edition]. 2016
[2] https://blog.csdn.net/ANooice/article/details/106320848