本论文以Hello程序的生命周期为跟踪对象,深入分析了Hello程序P2P与020的全过程。一个hello.c文件经预处理、编译、汇编与链接操作后成为可执行目标文件hello,完成了P2P的过程;而一个可执行目标文件hello经加载、运行、IO管理到最后终止进程被回收,完成了020的过程。对Hello程序生命周期的探索也是对计算机系统的深入理解,以Hello的一生为影射,也可以剖析计算机系统是如何运行一个程序的,进而在程序编写与调试中基于计算机系统做出更好优化。
关键词:计算机系统;Hello程序;P2P;020;
目 录
第1章 概述
1.1 Hello简介
Hello是每个程序员成长的必经程序,其从编写到运行呈现需要经历P2P与020的过程,继而从一个文本文件变为最终我们所看到的程序呈现效果。
所谓P2P,即From Program to Process,从程序到进程,是指Hello从一个文本文件到一个可供运行的进程的过程。该过程包括预处理阶段、编译阶段、汇编阶段、链接阶段,大致过程如图1-1所示。在预处理阶段,预处理器(cpp)根据以字符#开头的命令,修改原始c程序最终得到另一个c程序hello.i;在编译阶段,编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,其包含一个汇编语言程序;在汇编阶段,汇编器(as)将hello.s翻译成机器语言指令并打包成可重定位目标程序保存到hello.o文件;最后在链接阶段将标准c库函数对应的目标文件printf.o等在链接器(ld)的处理下与hello.o合并为可执行目标程序hello,该程序可被加载到内存,由系统执行,到此实现从程序到进程的转变。
所谓020,即From 0 to 0,从初始的无到初始的有再到结束的无,涵盖的是整个hello程序的运行过程。首先在shell命令行中输入命令$./hello,shell命令行解释器会构造argv和envp;之后shell调用fork函数创建子进程,其地址空间与shell父进程完全相同,包括只写数据段等,并调用execve函数在当前进程中的上下文中加载运行hello程序,将hello中的.text节等内容加载到当前进程的虚拟地址空间;接着调用hello程序的main函数,这时hello程序开始在一个进程上下文中运行,到现在完成了从初始的无到初始的有;在程序运行结束后,子进程会向shell发送SIGCHLD信号,表示子进程的回收,shell回收该进程并等待下一条命令,这时候就从初始的有到结束的无,到此hello程序整个生命周期结束。
从程序到进程,从无到有再到无,hello程序经历如此波折,完成了自己的一次生命周期。
图1-1-1 P2P过程
1.2 环境与工具
软件环境:Windows11 64位;VMware Workstation Pro 17;Ubuntu 20.04
开发和调试工具:gcc;objdump;edb;CodeBlocks;Visual Studio 2022;VScode
1.3 中间结果
中间结果及作用如下:
1.hello.i——hello.c经预处理后产生的文件,用于分析预处理过程。
2.hello.s——hello.i经编译后产生的汇编语言文件,用于分析编译过程。
3.hello.o——hello.s经汇编产生的可重定位目标文件,用于分析汇编过程。
4.hello——hello.o经链接产生的可执行目标文件,用于分析链接过程及hello进程加载运行分析。
5.hello.txt——hello.o反汇编产生的文件,用于对比汇编前后区别,分析汇编对代码产生的影响。
6.hellox.txt——hello反汇编产生的文件,用于对比链接前后区别,分析链接对代码产生的影响。
1.4 本章小结
本章整体介绍了hello程序P2P与020的过程,即从程序到进程的生成过程,从无到有再到无的运行过程,实现对hello程序整个生命周期的白描。同时也介绍了本论文编写所用的软硬件环境工具与中间产生结果,是整个论文的概述。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理概念
预处理即预编译,是指于预处理器(cpp)进行的在进入程序编译阶段之前根据字符#开头的命令对源代码或数据进行的一系列处理与转化操作,进而产生程序编译阶段所使用的文件后缀为.i的文件的过程。
2.1.2预处理作用
预处理作用包括如下几点:
1.处理宏定义指令,根据#define指令定义的宏,预处理器会在编译阶段前对源代码中的宏进行替换。
2.处理条件编译指令,预处理器会通过对#ifdef、#ifndef、#else、#elif和#endif等条件编译指令的处理,实现对代码的选择编译等过程。
3.包含头文件,即将头文件内容插入到源文件中,例如hello.c中第1行中的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容。
4.处理特殊符号与预定义宏,预处理器会识别特殊的符号和预定义宏,并在预处理阶段进行替换或处理,例如LINE和FILE预定义宏会替换成当前行号与当前文件名号。
5.删除注释,预处理器会将本意是帮助程序员阅读的注释部分去掉,从而减少程序整体大小。
这些作用本质都是服务于之后的编译阶段的进行,将源程序文本预处理生成.i作为文件后缀名的文件。
2.2在Ubuntu下预处理的命令
在Ubuntu下采用如图2-2-1所示预处理命令gcc -m64 -no-pie -fno-PIC hello.c -E -o hello.i,此命令会基于hello.c产生hello.i文本文件,文件部分内容如图2-2-2所示。
图2-2-1 预处理命令
图2-2-2 hello.i部分内容
2.3 Hello的预处理结果解析
在经过预处理后得到的hello.i文件从原有的24行内容(如图2-3-1)拓展到了3892行内容(如图2-3-2),其中包括了上述的一系列如头文件stdio.h,unistd.h与stdlib.h直接插入到源程序等操作,同时也可以看出预处理器将源文件的注释部分进行去除,从而得到了图2-3-2的整个文本。
图2-3-1 预处理前源文件hello.c
图2-3-2 预处理后hello.i文件
2.4 本章小结
本章描述了程序编译过程中的预处理操作的概念与作用,同时阐述如何进行对源文件的预处理,并结合预处理操作的内容进行预处理前后结果对比与分析,体预处理在整个编译过程中的作用。
3.1 编译的概念与作用
3.1.1编译概念
编译是指编译器(ccl)将后缀名.i的文本文件翻译成后缀名为.s的文本文件的过程,该文本文件包含了一个汇编语言程序,相比于源文件语言,汇编语言更偏向于机器语言。
3.1.2编译作用
编译的目的在于将原来由人编写的偏向于人类语言的文本文件转换成偏向于机器语言的汇编语言文本,用一条或多条汇编指令实现原来文本文件中程序的目的操作。另外编译阶段也将不同语言种类如c,c++等转换成相同的语言种类--汇编语言,为后续处理提供便利。
3.2 在Ubuntu下编译的命令
在Ubuntu下编译命令如图3-2-1所示,为gcc -m64 -no-pie -fno-PIC hello.i -S -o hello.s,此命令基于hello.i产生hello.s文本文件,部分内容如图3-2-2所示。
图3-2-1 编译命令
图3-2-2 hello.s部分内容
3.3 Hello的编译结果解析
在汇编文件hello.s中采用汇编指令实现了诸如数据赋值、控制转移等一系列源文件要求操作并在最终实现源文件目的。在hello.s的文本展示开头中可以看到该文件内容的一系列相关说明,如图3-3-1所示,包括该源程序名称、对齐方式、格式串内容、全局变量名称与类型等等,接下来对hello.s进行具体分析。
图3-3-1 hello.s开头内容
3.3.1数据分析
1.常量分析
该汇编文本中的常量包括数据常量与字符串常量,如图3-3-2。数据常量即立即数,如第21行所示$32,即表示立即数0x32;字符串常量会存储在.rodata只读数据段中,如图3-3-2中第5,6行中的.LC0与.LC1,继而在整个程序中有所调用,例如第26行的movl $.LC0,%edi,将.LC0传递到%edi寄存器中进而用于后续的puts函数调用。
图3-3-2 hello.s常量内容
2.变量分析
变量包括全局变量,局部变量与静态变量。全局变量与静态变量会存储在.data与.bss节中,但本程序并未涉及全局变量与静态变量;局部变量一般存储在寄存器与栈中,如图3-3-3所示,subq $32,%rsp通过栈指针的上移为局部变量保留空间,并在之后通过如22行movl %edi,-20(%rbp)、movq %rsi,-32(%rbp)方式将局部变量值传送到栈中对应位置,上述分别对应argc与argv,另外局部变量i存储在%rbp-4的位置上,例如第31行修改%rbp-4位置的值对应将i赋值为0。
图3-3-3 局部变量存储
3.3.2赋值操作
赋值操作采用数据传送指令实现,如movl与movq,mov的字母决定传送数据大小,b对应1字节,w对应2字节,l对应4字节,q对应8字节,mov A,B即将A传送给B,A与B是操作数,包括立即数、寄存器与内存引用。例如图3-3-4中第31行对%rbp-4位置的赋值为0,对应源程序第18行中循环开始前的i=0。
图3-3-4 赋值操作
3.3.3算数操作
算数操作在本程序中主要是加法i++实现循环累加计数(源程序第31行),在汇编文本中用add命令实现,与mov指令类似,add后的字母表示操作大小。如图3-3-5第54行所示,对%rbp-4的位置内容加1,即i=i+1(i++)。
图3-3-5 算数操作
3.3.4类型转换
类型转换在本程序中主要是atoi函数,如图3-3-6所示第51行,对应源程序第20行(sleep(atoi(argv[4]));),该函数会将字符串转换为整数类型(但不是强制转换,并不通用)。
图3-3-6 atoi函数
3.3.5关系操作
关系操作在本程序中主要是一次在条件跳转前的等于比较(源程序argc!=5)和多次循环中每次循环前循环计数i与常数9的比较(源程序i<10,这里优化为与9的比较,即i<=9)。如图3-3-7所示,在第24行的cmpl对应源程序第14行,如果相等于5则跳转,不相等则继续执行;在第56行的cmpl对应源程序第18行,如果小于等于9则跳转到循环内容,反之执行对应源程序22行的操作。
图3-3-7 关系操作
3.3.6数组/指针/结构操作
数组操作是通过连续地址的访问实现的,数组在地址空间中是以连续的方式存储的,如图3-3-8所示,在第35,38与第41行通过对%rax存储的基址(argv[0]存储地址)加上24,16与8构造数组元素地址实现对argv[3]、argv[2]与argv[1]的内存访问并传到相应的参数寄存器中用于printf函数调用,对应源程序第19行。
图3-3-8 数组操作
3.3.7控制转移
本程序实现中存在两种控制转移,分别是if-else语句与for循环语句,如图3-3-9所示,第24行比较i与5的大小,等于则跳转到.L2,一直执行到第32行进行无条件跳转至.L3,即循环开始处;反之继续进行,对应源程序的if语句(源程序第14行到第17行)。第56行.L3循环开始处比较i与9的大小,小于等于则跳转到.L4允许继续循环,一直执行到第56行并再次判断;反之脱离循环,对应for循环语句(源程序第18行到第21行)。
图3-3-9 控制转移
3.3.8函数操作
1.参数传递
在本程序中每次函数调用前会将需要的参数存储在对应的寄存器中,其中第一个参数会存到%rdi中,之后分别是%rsi、%rdx、%rcx、%r8、%r9,若不够则会用栈传递,但在本程序的函数调用参数传递中并不涉及,如图3-3-10所示,在调用printf函数之前将相应参数按序存储到对应寄存器中。
图3-3-10 参数传递
2.函数调用
在本程序中共涉及到六次函数调用,均用call命令实现,分别为一次puts调用(第27行,源程序为printf,这里是编译器-o优化实现的),一次exit调用(第29行),一次printf调用(第46行),一次atoi调用(第51行)、一次sleep调用(第53行)和一次getchar调用(第58行),在调用前均将相关参数传到参数寄存器中以实现对应参数传递,并在调用前将返回地址压入运行栈中后跳转到对应函数代码段执行。
图3-3-11 函数调用
3.局部变量
本程序中局部变量存储在寄存器与栈上,以此实现参数传递与变量修改,如将i存储在%rbp-4的位置,如图3-3-12所示,修改%rbp-4位置值就是修改局部变量i的值。
图3-3-12 局部变量
4.函数返回
在本程序中函数调用结束时若需要返回则会用指令ret从调用程序中返回,将返回值保存到%eax中,如图3-3-13,对应main函数最后return 0,函数调用的ret指令会恢复栈帧并跳转到先前保存在栈中的返回地址中继续运行。
图3-3-13 函数返回
3.4 本章小结
本章介绍了编译操作的概念、作用与编译操作对应指令,并详细分析了编译结果hello.s中的汇编代码与c语言数据与操作对应内容的联系,感受编译器是如何通过汇编语言精准实现源程序目的的,包括赋值操作、算数操作、函数操作等等,深入理解了汇编代码的运行原理。
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编概念
汇编是指将编译阶段产生的.s为后缀的文本翻译成机器语言指令,并把这些指令打包成一种叫可重定位目标程序的格式,将结果保存在.o为后缀的二进制目标文件中的过程。
4.1.2汇编作用
将汇编文件转换成机器语言的二进制01文件,使机器可以执行,将汇编语言文件转化成为可重定位目标文件。
4.2 在Ubuntu下汇编的命令
在Ubuntu下汇编命令如图4-2-1所示,为gcc -m64 -no-pie -fno-PIC hello.s -C -o hello.o,此命令会基于hello.s产生二进制文件hello.0,如图4-2-2所示。
图4-2-1 汇编命令
图4-2-2 产生结果
4.3 可重定位目标elf格式
可重定位文件ELF格式包括ELF头、节头部表、以及夹在两者之间的一些节组成,包括.text、.rodata、.data、.bss、.symtab、.rel.text、.rel.data、.debug、.line与.strtab组成。可重定位elf格式可以通过readelf一系列命令进行查看,接下来依次分析ELF头、可重定位节、符号表与节头部表等相关内容。
4.3.1ELF头
ELF头相关内容采用readelf -h hello.o命令查看,如图4-3-1所示,可以看出ELF头是以16字节的序列开始,即Magic,该字节序列描述了生成该文件的系统的字的大小和字节顺序。另外在ELF头中剩下部分包含帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小(64 bytes)、目标文件类型(REL可重定位文件)、机器类型(UNIX)、节头部表的文件偏移、节头部表中条目的大小和数量、版本号、程序入口点位置(0x0)、程序头起点(0 bytes)以及数据在文件中的存放方式(补码且小端序)等等。
图4-3-1 ELF头对应信息
4.3.2可重定位节
重定位节可通过命令readelf -r hello.o查看,重定位节中的重定位条目可以协助链接器修改引用进行重定位,如图4-3-2所示,本程序中可重定位节包括.rela.text与.rela.eh_frame,在每个节的名字下面列出了每个重定位节的偏移量(节偏移)、信息、类型、符号值、符号名称、加数(用来辅助定位),这些信息会在形成可执行文件的时候辅助进行对对应符号的重定位,其中类型决定了重定位方式是R_X86_64_32的绝对寻址(该定位方式会直接将相应地址作为有效地址)还是R_X86_64_PLT32的相对寻址(该定位方式一般会根据地址的相对指令下一条的偏移作为有效地址),同时在图中也可以看出并没有出现rela.data节,因为本程序中并没有初始化的全局变量。
图4-3-2 可重定位节对应信息
4.3.3符号表
符号表通过命令readelf -s hello.o查看,如图4-3-3所示,在符号表中可以得知该模块定义和引用的符号的信息,其中Type表示符号类型,Bind代表是全局符号还是本地符号,Name是符号名字,Ndx代表符号对应到哪个节。
图4-3-3 符号表对应信息
4.3.4节头部表
节头部表通过命令readelf -S hello.o查看,描述了每个节的地址、偏移量、大小、访问权限、对齐方式等信息,如图4-3-4所示。
图4-3-4 节头部表对应信息
4.4 Hello.o的结果解析
采用objdump -d -r hello.o对其进行反汇编可得如图4-4-1的结果,将该结果与第三章中的汇编文本比较可以发现大部分的汇编指令是相同的,反汇编代码中的机器语言指令与原汇编指令基本一一对应,同时也可得到如下几点不同:
1.反汇编指令中mov、add等指令没有传递字节大小的表示,但汇编指令中有如blwq的字节大小表示。
2.反汇编代码相比于汇编代码缺少大量伪指令,更为简洁,更偏向于机器语言。
3.跳转转移与函数调用不同,在反汇编代码中像跳转命令、函数调用命令中会加上跳转偏移地址或调用的函数偏移地址,汇编指令中跳转指令后仅仅是跳转的标记如.L2等或者是调用的函数。
4.立即数进制不同,反汇编代码中是十六进制,而汇编代码中是十进制表示。
图4-4-1 反汇编结果
图4-4-2 原汇编结果
4.5 本章小结
本章介绍了汇编操作的概念与作用,使用gcc相关命令对hello.s文件进行汇编,并运用readelf命令辅助对于汇编产生的.o可重定位目标文件进行了深入的分析,同时也借助反汇编命令得到hello.o的反汇编文件,比较了反汇编代码与汇编代码的不同之处,对于机器语言与汇编语言有了更深的理解。
第5章 链接
5.1 链接的概念与作用
5.1.1链接概念
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,该文件可被加载到内存并执行。链接可以执行于编译时、加载时与运行时。
5.1.2链接作用
链接使分离编译成为可能,使得我们在开发中不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立修改和编译这些模块,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
在Ubuntu下链接如图5-2-1所示,为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-2-2所示。
图5-2-1 链接命令
图5-2-2 链接结果
5.3 可执行目标文件hello的格式
可执行目标文件hello同样是ELF格式,其包括ELF头、段头部表、.init节、.text节、.rodata节、.data节、.bss节、.symtab节、.debug节、.line节、.strtab节与节头部表。其中ELF头、段头部表、.init节、.text节、.rodata节为只读内存段,.data节、.bss节为读/写内存段,.symtab节、.debug节、.line节、.strtab节与节头部表不加载到内存。接下来分别来看各段情况。
5.3.1ELF头
ELF头相关内容采用readelf -h hello命令查看,如图5-3-1所示,具体包含信息种类与可重定位目标文件类似,但入口地址等不再为0,类型为EXEC(可执行文件)。
图5-3-1 ELF头信息
5.3.2段头部表信息
段头部表信息采用readelf -l hello命令查看,该表包含了每段的偏移量、内存地址、对齐要求、大小等信息,如图5-3-2所示。
图5-3-2 段头部表信息
5.3.3节头部表
节头部表信息采用readelf -S hello命令查看,如图5-3-3所示,展示了节头部表的各项信息。
图5-3-3 节头部表信息
5.3.4符号表信息
符号表信息采用readelf -s hello命令查看,如图5-3-4所示,从中可以得出各符号的基本信息。
图5-3-4 符号表信息
5.3.5重定位节信息
采用readelf -r hello命令可以查看重定位节信息,如图5-3-5所示,可以看出并没有原来的.rel.data,因为在链接中已经对该节数据进行了重定位,同时增加了辅助动态链接的重定位节。
图5-3-5 重定位节信息
5.4 hello的虚拟地址空间
在edb中加载hello,得到如图5-4-1所示虚拟地址空间信息。从图5-4-1可以看出hello的每节地址与图5-3-3中的每节地址基本相同,例如.plt都是0x401020。另外,hello的可执行代码起始地址为0x401000(如图5-4-2所示),这与图5-3-3中.init节中的起始地址相同。
图5-4-1 edb加载hello
图5-4-2 可执行代码起始地址
5.5 链接的重定位过程分析
采用objdump -d -r hello得到hello的反汇编结果,部分如图5-5-1所示,与hello.o反汇编结果对比可以看出主函数main的指令大体相同,但有如下几点不同;
1.hello反汇编结果包含了链接的各类文件,所以内容量要比hell.o反汇编结果压要多,节数要多。
2.hello反汇编结果为每条指令分配虚拟地址,hello反汇编结果中每条指令都有对应的虚拟地址,而hello.o中却没有,每条指令前仅有字节数。
3.符号引用上hello反汇编结果对于每个符号引用都有确切的虚拟地址,而hell.o中仅仅还是用0做占位符同时在下方标注引用符号与引用类型。
4.函数调用上hello反汇编结果在调用语句中如果调用跳转方式是绝对地址则在机器码中写绝对地址,如果是相对地址则在机器码中以相对地址做有效地址;而hello.o的反汇编结果中还是0做占位符,仅有函数调用标记。
从这些不同中可以分析出重定位的基本过程:1.符号解析,即将每个引用与它输入的可重定位目标文件的符号表中一个确定的符号定义关联起来,从符号表中就可以得到该引用的基本信息。2.重定位,在符号解析完成后首先重定位节和符号定义,将所有相同类型的节合并成同一类型的新的聚合节,这也就是为什么hello的反汇编结果要比hello.o大得多,接着根据之前符号解析的结果重定位节中的符号引用,根据不同的重定位类型(是绝对还是相对的)来选择不同重定位算法,修改代码节和数据节中对每个符号的引用使它们指向正确的运行地址。
图5-5-1 hello部分反汇编结果
5.6 hello的执行流程
使用gdb对hello进行执行分析,如图5-6-1所示,通过单步执行得到从加载hello到_start,到call main,以及程序终止的所有过程,其调用与跳转的各个子程序名或程序地址如下:1._dl_start(0x7f6cc94f3290),2._dl_init(0x7f6cc94f4030);3._start(0x4010f0);4.lib_start_main(0x7ffff7de2f90);5._init(0x401000);6.main(0x401125);7.printf@plt(0x401040);8.atoi@plt(0x401060);9.sleep@plt(0x401080);10.getchar@plt(0x40105d0)。
图5-6-1 gdb分析
5.7 Hello的动态链接分析
当程序调用共享库中的函数时,由于这些函数在运行时可能加载到内存中的任意位置,编译器在编译时无法直接知道其地址。GNU编译系统使用延迟绑定技术,通过全局偏移表(GOT)和过程链接表(PLT)来在函数首次被调用时解析其实际地址。GOT存储函数地址,而PLT包含跳转指令,这些指令会在首次调用时触发动态链接器来解析地址并存储在GOT中。我们可以在节头部表中获得GOT与PLT的相关信息,如图5-7-1。在EDB中对hello进行运行,发现hello中在_dl_init后(即动态链接后)0x404050等地方会存储相应地址,如图5-7-2与5-7-3。
图5-7-1 GOT与PLT的相关信息
图5-7-2 动态链接前
图5-7-3 动态链接后
5.8 本章小结
本章介绍了链接阶段概念和作用,通过命令行输入命令进行链接操作,深入分析具体链接过程与可执行文件的ELF格式,比对链接前后hello.o与hello的相同与不同之处,对于链接作用有了更深理解,同时也深入跟进hello的执行流程,实践动态链接操作,通过链接前后对比观察动态链接过程。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程概念
进程的经典定义就是一个执行中程序的实例,系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存中的程序的代码和数据、它的栈、通用目的寄存器的内容,程序计数器、环境变量,以及打开文件描述的集合。
6.1.2进程作用
进程提供给应用程序两个关键抽象:1.一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器;2.一个私有的地址空间,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1Shell-bash作用
shell是一种命令行解释器,它为应用程序的执行提供一个界面,用户通过这个界面访问操作系统内核的服务,shell读取用户输入的字符解释并执行。
6.2.2Shell-bash处理流程
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量。
(3)检查第一个命令行参数判断是否是一个内置的shell命令。
(3)如果不是内部命令,调用 fork()创建新进程/子进程。
(4)在子进程中,用步骤 2 获取的参数,调用execve( )执行指定程序。
(5)如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait) 等待作业终止后返回。
(6)如果用户要求后台运行(如果命令末尾有&号),则shell返回,等待下一条命令。
6.3 Hello的fork进程创建过程
在终端输入./hello命令后,shell分析命令行字符串,因不是内置命令,shell调用 fork()函数创建一个子进程。该子进程与父进程并发执行,其地址空间是与 shell 父进程完全相同但是独立的一份副本,包括只读代码段、读写数据段、堆、共享库及用户栈等,子进程同时也继承了父进程所有的打开文件,可以读写父进程中打开的任何文件。
6.4 Hello的execve过程
exexve函数调用一次且一般不返回,在子进程中,用之前获取的命令行参数,调用 execve()函数在当前进程(新创建的子进程)的上下文中加载并运行 hello 程序并将hello中的.text节、.data节、.bss节等内容加载到当前进程的虚拟地址空间。
6.5 Hello的进程执行
进程提供给应用程序的关键抽象之一就是一个独立的逻辑控制流,但事实上一般进程执行是与其他进程的逻辑控制流并发的,其中一个进程执行它的控制流的一部分的每一时间段叫做时间片。
操作系统使用上下文切换的异常控制流来实现多任务,内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态。它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。而在进程执行的某些时刻,内核可以进行名为调度的决策,即决定抢占当前进程,并重新开始一个先前被抢占了的进程,该决策由内核中成为调度器的代码处理,在内核调度了一个新的进程运行后它就抢占当前进程并使用上下文切换的机制(分三步1.保存之前进程的上下文;2.恢复某个先前被抢占的进程被保存的上下文;3.把控制转让给新恢复的进程)来控制转移到新的进程。
处理器提供用户模式与内核模式这种机制来限制一个应用可以执行的指令以及它可以访问的地址空间范围,设置一个寄存器的模式位来提供方这种功能,当设置时就运行在内核模式(控制权在内核),该进程就可以执行指令集中的任何指令,而不设置时就运行在用户模式(控制权在进程),进程不允许执行特权指令。
那么根据上述对上下文切换、调度与用户模式内核模式机制的阐述就可以基本分析hello的进程执行过程,在进程执行中会发生多次用户态与核心态的转换。开始运行hello时是在用户态(即用户模式)下进行,如果是多个进程同时进行则实施并发执行。如果在运行hello时出现中断等异常则会在核心态(即内核模式)下完成上下文切换并转到其他进程的用户模式,控制权到其他进程。当hello进程处理完异常后发出信号,内核判定在当前进程运行足够长时间了就执行回到hello进程的上下文切换(在核心态下),将控制返回到hello进程。在hello进程执行中有sleep函数调用,sleep会显式地请求进程休眠,此时进行核心态下的上下文切换,执行其他进程,一段时间后hello休眠结束,此时再次进行核心态下的上下文切换,恢复休眠前的上下文信息,控制权回到hello并继续执行。接着执行到循环结束后会执行getchar函数,该函数等待用户输入,此时会进行核心态下的上下文切换至其他进程,在输入后会发送信号,再次进行核心态下的上下文切换至hello进程,控制权重新回到hello进程上。
6.6 hello的异常与信号处理
6.6.1异常
hello在执行中可能会出现四类异常,即中断、陷阱、故障与终止,其中中断为异步异常,陷阱、故障与终止为同步异常,接下来分别阐释解决方案。
1.中断:中断是来自处理器外部的I/O设备(如网络适配器、磁盘控制器等)的信号的结果,在中断处理中会在当前指令完成后控制传递给处理程序,并在中断处理程序执行后返回到下一条指令中,如图6-6-1。
图6.6.1 中断处理
2.陷阱:陷阱是有意的异常,是执行一条指令的结果,与中断类似,也是在当前指令完成后控制传递给处理程序,并在陷阱处理程序执行后返回到下一条指令中。
图6.6.2 陷阱处理
3.故障:故障由错误情况引起,可被故障处理程序修正,当故障发生时,处理器将控制转移给故障处理程序,如果错误可修正则控制返回到引起故障指令并重新执行它,否则处理程序返回到内核中abort例程,该例程回终止引起故障的应用程序。
图6.6.3 故障处理
4.终止:终止是不可恢复的致命错误造成的结果,终止处理程序从不将控制返回给应用程序。
图6.6.4 终止处理
6.6.2信号处理
hello程序执行中可能会产生这些信号,括号内是处理方式:SIGINT(终止)、SIGKILL(终止)、SIGSEGV(终止并转储内存)、SIGTSTP(停止直到下一个SIGCONT)、SIGCHLD(忽略)。
6.6.3具体演示
1.正常情况程序正常运行中没有任何操作下如图6-6-5所示,每秒打印一行,共十次循环,最后需要输入任意符号终止(因为有getchar),否则会一直等待输入而不终止。
图6-6-5 正常情况
2.运行中按Ctrl+C产生中断,结果如图6-6-6所示,在按第一个的时候该操作会发送一个SIGINT至前台进程,导致其终止
图6-6-6 Ctrl+C结果
3.运行中按Ctrl+Z产生中断,结果如图6-6-7所示,该操作会向前台进程发送SIGTSTP信号导致其挂起。此时我们可以输入其他命令分析该进程信息,如ps命令会打印各进程基本信息(如PID),如图6-6-8;jobs命令可以输出停止的hello进程相关信息,如图6-6-9;pstree可以输出进程数,如图6-6-10;fg会继续进行挂起的进程,直至加上之前打印次数完成十次循环如图6-6-11;kill会杀死该进程,使用kill -9 helloPID的命令,helloPID可用ps查到,kill后再次ps查询发现没有hello进程,如图6-6-12。
图6-6-7 Ctrl+Z结果
图6-6-8 ps结果
图6-6-9 jobs结果
图6-6-10 pstree结果
图6-6-11 fg结果
图6-6-12 kill结果
4.在运行中按回车会导致空行出现,但不影响进程进行,如图6-6-13所示。
图6-6-13 运行中按回车结果
5.不停乱按会在打印中打印自己按的键位,除此之外不影响正常进程进行,如图6-6-14所示。
图6-6-14 不停乱按结果
6.7本章小结
本章介绍了进程的概念和作用,分析shell的处理流程以及在进行hello程序中如何调用fork与execve函数,剖析hello的进程执行流程,阐述了hello进程中可能的异常与信号处理,并实践了在hello执行中异常与信号的处理情况。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1.逻辑地址:在计算机体系结构中,从应用程序角度看到的内存单元、存储单元或网络主机的地址,即在汇编代码hello.s中看到的偏移地址。
2.线性地址:逻辑地址向物理地址转换时的中间产物,hello.s中的段内偏移地址(逻辑地址)加上相应段的基地址就是线性地址。
3.虚拟地址:CPU启动保护模式后,程序访问存储器所使用的地址。
4.物理地址:物理地址是放在寻址总线上的地址,用于直接访问物理内存或网络设备的实际地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在Intel平台下,逻辑地址的格式为段标识符:段内偏移量。段标识符是由一个16位长的字段组成,即段选择符,其中前13位是一个索引号。后面3位与硬件有关。
逻辑地址到线性地址的变换分为如下几步:首先,段选择符用于在全局描述符表(GDT)或局部描述符表(LDT)中定位段描述符,其中段选择符的TI字段决定使用哪个描述符表,TI=0则选择GDT,为1则选择LD;接着利用段选择符对段的访问权限和范围进行检验,以确保该段可访问,如果可以访问则计算段描述符的地址,最后将逻辑地址中的偏移量与段描述符中的段基地址相加得到线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
在页式内存管理中,当程序尝试访问一个线性地址时,系统会首先根据该地址的页号查找页表。页表是一个数据结构,它存储了线性地址的页号与物理内存中的物理页框号之间的映射关系,由多个PTE构成,每个PTE由一个有效位加物理页号或磁盘地址组成,一旦找到对应的PTE且有效位为1,系统就会将其物理页号与线性地址的页内偏移量结合,计算出实际的物理地址,如果没找到或有效位为0表示这个虚拟页还未被分配,会触发缺页异常进行处理,如图7-3-1所示。
图7-3-1 线性地址到物理地址的变换-页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块,而多级页表则用于减少内存要求。
以下是TLB与四级页表支持下的VA到PA的变换过程:
1.CPU产生一个虚拟地址VA。
2.MMU根据VA的VPN访问TLB,如果命中则取出相应的PTE;如果不命中则根据VPN划分的四块VPN1到VPN4来逐层访问多级页表,根据VPN1访问一级页表获得二级页表的基地址,再加上VPN2访问二级页表获得三级页表的基地址重复上述步骤直至最后在四级页表中得到相应PTE,若缺页则触发相应缺页异常处理程序。
3.MMU根据PTE中的PPN与VA中的VPO相加得到相应物理地址PA。
7.5 三级Cache支持下的物理内存访问、
三级Cache分别为L1,L2,L3高速缓存,其支持下的物理内存访问如下:
1.MMU将物理地址PA(分为标记(CT)、组索引(CI)、块偏移(CO))发给L1Cache,判断是否命中,如果命中则将对应数据发给CPU。
2.如果L1不命中则将PA发给L2Cache,判断是否命中,如果命中则将对应块传到L1中同时将对应数据发给CPU。
3.如果L2还不命中则将PA发给L3Cache,判断是否命中,如果命中则将对应块传到L2至L1中同时将对应数据发给CPU。
4.如果L3还不命中则将PA发给主存,并将对应块传Cache中同时将对应数据发给CPU。
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 缺页故障与缺页中断处理
当发生缺页异常时会进行相应缺页处理,大致如图7-8-1所示,分为如下几步:
1.在访问页表时发现PTE有效位是零, MMU触发缺页异常,传递CPU中的控制到操作系统内核的缺页异常处理程序。
2.缺页处理程序确定出物理内存中的牺牲页,如果页面已经被修改,则把它换出到磁盘。
3.缺页处理程序调入新的页面,并更新内存中的PTE。
4.缺页处理程序返回到原进程,再次执行导致缺页的指令,CPU将引起缺页的虚拟地址重新发送给MMU重新页表访问,此时因为虚拟页面现在缓存到物理内存中了,故不会发生缺页,继续执行。
图7-8-1 缺页异常处理
7.9动态存储分配管理
动态存储分配管理由动态内存分配器进行,它维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。
分配器有两种基本风格,分别是显示分配器,要求应用显式地释放任何已分配的块,例如mallo程序包;隐式分配器,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块,隐式分配器也叫做垃圾收集器。
7.10本章小结
本章介绍了hello的存储管理基本信息,包括虚拟地址与物理地址间的转换、基于物理地址在Cache支持下的访问、hello程序的fork与execve的内存映射、缺页异常及处理以及动态存储分配管理,对存储系统工作原理有大致描画。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:所有IO设备都被模型化为文件,所有的输入输出均可作为文件的读写来执行。
设备管理:将设备映射为文件的方式允许Linux内核引出一个简单低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以统一且一直的方式来执行。
8.2 简述Unix IO接口及其函数
Unix IO接口是一个简单、低级的应用接口,用于文件输入输出,其基本操作包括打开文件、读写文件与关闭文件,也可以改变当前文件位置。另外,当应用程序通过Unix IO打开文件操作要求内核打开对应文件时内核返回一个非负整数,称为文件描述符,后续所有的操作都基于这个文件描述符。
Unix IO函数有:open函数(打开文件)、read函数(读取文件)、write函数(写入文件)、lseek(修改当前文件位置)、close函数(关闭文件)。
8.3 printf的实现分析
printf函数用于在屏幕上打印格式化字符串,当调用printf时,它首先将格式化字符串和参数转换为内存中的字符串表示,并存储在输出缓冲区中。当输出缓冲区需要刷新时(比如当字符串太长或程序显式调用fflush时),write系统调用会被触发,将缓冲区中的数据发送到内核。而内核中有write系统调用处理程序会处理这些数据,并将其发送到与输出文件描述符(通常是标准输出stdout)相关联的设备驱动程序,进行终端输出。
对于终端输出,字符显示驱动子程序会接管从内核传递来的字符数据。它将这些ASCII字符转换为屏幕上的像素表示(如果显示设备是像素基的),并将这些像素数据存储在视频RAM(VRAM)中。随后,显示芯片会按照设定的刷新频率逐行从VRAM中读取像素数据,并通过信号线将每一个点(RGB分量)传输到液晶显示器。液晶显示器根据接收到的数据控制每个像素的亮度和颜色,从而在屏幕上显示出用户通过printf函数打印的文本。
8.4 getchar的实现分析
在操作系统中,键盘中断是异步事件的一种,当用户按下键盘上的某个键时,硬件会产生一个中断信号通知操作系统。操作系统中的键盘中断处理子程序会响应这个中断,读取按键的扫描码,并将其转换成ASCII码。转换后的ASCII码会被保存到系统的键盘缓冲区中,供后续的程序读取。这个过程是异步的,意味着按键的输入不会阻塞正在执行的程序,而是会在按键事件发生时由操作系统进行处理。
getchar函数的实现依赖于上述的操作系统提供的键盘中断处理和系统调用机制,其用于从标准输入(通常是键盘)读取一个字符,且无需关心底层的硬件细节。当用户调用getchar时,该函数会调用底层的read系统函数来读取键盘缓冲区中的数据。read系统函数会等待键盘缓冲区中有数据可读,一旦有数据(即用户按下某个键后,键盘中断处理子程序将ASCII码保存到缓冲区中),read就会读取这些数据并返回给getchar。getchar会一直阻塞调用它的程序,直到读取到回车键(ASCII码为\n)为止,然后返回读取到的字符。
8.5本章小结
本章简要分析了hello的IO管理,理解Unix IO接口的基本原理,另外也对hello程序中与输入输出有关的printf函数和getchar函数基本原理实现有了进一步的理解,对系统级IO进行更深一步学习分析。
结论
hello的一生短暂而富有意义,它的P2P过程与020过程往往也代表着无数程序从诞生到结束要走的路。在编译系统中它从hello.c经预处理、编译、汇编与链接,成为可执行目标文件hello,并在系统中运行,经历加载运行与多个异常处理,成为屏幕输出的一行行文字。
仔细观察hello经历的过程,可以总结出如下几步:
1.编写得到源程序hello.c;编写者在文本编辑器或者编译器中编写hello成雪的代码,构造出最原始的源程序文本hello.c。
2.预处理得到修改后的源程序hello.i;hello.c经预处理器(cpp)处理,根据以字符#开头的命令修改原始的c程序得到hello.i。
3.编译得到汇编程序hello.s;hello.i经编译器(ccl)处理,将c语言代码转换为贴近机器语言的汇编指令,得到hello.s。
4.汇编得到可重定位目标程序hello.o;hello.s经汇编器(as)处理,被翻译成机器语言指令,打包成可重定位目标程序得到二进制文件hello.o。‘
5.链接得到可执行目标程序hello;hello.o在链接器(ld)中经符号解析与重定位等操作与其他可重定位目标文件(如printf.o)一同链接成为可执行目标文件hello,该文件可以进行加载运行。
6.调用fork创建子进程;shell根据命令./hello 2022113355 wjs 17831999172 2调用fork函数创建子进程,其地址空间与shell父进程完全相同,包括只写数据段等,也是一个独立的逻辑控制流。
7.调用execve函数进行加载运行;调用execve函数在当前进程中的上下文中加载运行hello程序,将hello中的.text节等内容加载到当前进程的虚拟地址空间。
8.运行中内存管理;在运行hello程序中TLB、多级页表、Cache等元件协助CPU进行虚拟地址翻译与物理地址访问,系统与硬件共同为hello程序维护一个私有的虚拟地址空间,完成内存管理。
9.运行中信号与异常处理;在运行hello程序中会遇到如sleep函数等作为系统调用的陷阱异常,也会遇到我们自己在键盘端输入导致的异常,系统会根据异常类型在内核模式下进行不同的异常处理。
10.运行中IO管理;在运行hello程序中用户可以通过IO端与程序进行交互,hello程序中的getchar函数与printf函数的目的之一就是实现交互,用户也可以输入Ctrl+C向进程发送信号等方式对进程进行相应操作。
11.进程终止与回收;在hello进程结束后会发送SIGCHLD信号到shell并等待shell回收,到此hello的生命周期结束。
关于感悟,在走遍hello完整的生命周期后我对于计算机系统如何工作有了更深的理解,计算机系统的有序运转需要软件系统与硬件组件的完美配合才能达成,
附件
1.hello.i——hello.c经预处理后产生的文件,用于分析预处理过程。
2.hello.s——hello.i经编译后产生的汇编语言文件,用于分析编译过程。
3.hello.o——hello.s经汇编产生的可重定位目标文件,用于分析汇编过程。
4.hello——hello.o经链接产生的可执行目标文件,用于分析链接过程及hello进程加载运行分析。
5.hello.txt——hello.o反汇编产生的文件,用于对比汇编前后区别,分析汇编对代码产生的影响。
6.hellox.txt——hello反汇编产生的文件,用于对比链接前后区别,分析链接对代码产生的影响。
参考文献
[1].兰德尔E.布莱恩特,大卫R.奥哈拉伦. 深入理解计算机系统[M]. 机械工业出版社. 2016.7.
[2].Pianistx. pr
摘 要
本论文以Hello程序的生命周期为跟踪对象,深入分析了Hello程序P2P与020的全过程。一个hello.c文件经预处理、编译、汇编与链接操作后成为可执行目标文件hello,完成了P2P的过程;而一个可执行目标文件hello经加载、运行、IO管理到最后终止进程被回收,完成了020的过程。对Hello程序生命周期的探索也是对计算机系统的深入理解,以Hello的一生为影射,也可以剖析计算机系统是如何运行一个程序的,进而在程序编写与调试中基于计算机系统做出更好优化。
关键词:计算机系统;Hello程序;P2P;020;
目 录
第1章 概述
1.1 Hello简介
Hello是每个程序员成长的必经程序,其从编写到运行呈现需要经历P2P与020的过程,继而从一个文本文件变为最终我们所看到的程序呈现效果。
所谓P2P,即From Program to Process,从程序到进程,是指Hello从一个文本文件到一个可供运行的进程的过程。该过程包括预处理阶段、编译阶段、汇编阶段、链接阶段,大致过程如图1-1所示。在预处理阶段,预处理器(cpp)根据以字符#开头的命令,修改原始c程序最终得到另一个c程序hello.i;在编译阶段,编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,其包含一个汇编语言程序;在汇编阶段,汇编器(as)将hello.s翻译成机器语言指令并打包成可重定位目标程序保存到hello.o文件;最后在链接阶段将标准c库函数对应的目标文件printf.o等在链接器(ld)的处理下与hello.o合并为可执行目标程序hello,该程序可被加载到内存,由系统执行,到此实现从程序到进程的转变。
所谓020,即From 0 to 0,从初始的无到初始的有再到结束的无,涵盖的是整个hello程序的运行过程。首先在shell命令行中输入命令$./hello,shell命令行解释器会构造argv和envp;之后shell调用fork函数创建子进程,其地址空间与shell父进程完全相同,包括只写数据段等,并调用execve函数在当前进程中的上下文中加载运行hello程序,将hello中的.text节等内容加载到当前进程的虚拟地址空间;接着调用hello程序的main函数,这时hello程序开始在一个进程上下文中运行,到现在完成了从初始的无到初始的有;在程序运行结束后,子进程会向shell发送SIGCHLD信号,表示子进程的回收,shell回收该进程并等待下一条命令,这时候就从初始的有到结束的无,到此hello程序整个生命周期结束。
从程序到进程,从无到有再到无,hello程序经历如此波折,完成了自己的一次生命周期。
图1-1-1 P2P过程
1.2 环境与工具
硬件环境:X64 CPU;3.2GHz;16G RAM
软件环境:Windows11 64位;VMware Workstation Pro 17;Ubuntu 20.04
开发和调试工具:gcc;objdump;edb;CodeBlocks;Visual Studio 2022;VScode
1.3 中间结果
中间结果及作用如下:
1.hello.i——hello.c经预处理后产生的文件,用于分析预处理过程。
2.hello.s——hello.i经编译后产生的汇编语言文件,用于分析编译过程。
3.hello.o——hello.s经汇编产生的可重定位目标文件,用于分析汇编过程。
4.hello——hello.o经链接产生的可执行目标文件,用于分析链接过程及hello进程加载运行分析。
5.hello.txt——hello.o反汇编产生的文件,用于对比汇编前后区别,分析汇编对代码产生的影响。
6.hellox.txt——hello反汇编产生的文件,用于对比链接前后区别,分析链接对代码产生的影响。
1.4 本章小结
本章整体介绍了hello程序P2P与020的过程,即从程序到进程的生成过程,从无到有再到无的运行过程,实现对hello程序整个生命周期的白描。同时也介绍了本论文编写所用的软硬件环境工具与中间产生结果,是整个论文的概述。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理概念
预处理即预编译,是指于预处理器(cpp)进行的在进入程序编译阶段之前根据字符#开头的命令对源代码或数据进行的一系列处理与转化操作,进而产生程序编译阶段所使用的文件后缀为.i的文件的过程。
2.1.2预处理作用
预处理作用包括如下几点:
1.处理宏定义指令,根据#define指令定义的宏,预处理器会在编译阶段前对源代码中的宏进行替换。
2.处理条件编译指令,预处理器会通过对#ifdef、#ifndef、#else、#elif和#endif等条件编译指令的处理,实现对代码的选择编译等过程。
3.包含头文件,即将头文件内容插入到源文件中,例如hello.c中第1行中的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容。
4.处理特殊符号与预定义宏,预处理器会识别特殊的符号和预定义宏,并在预处理阶段进行替换或处理,例如LINE和FILE预定义宏会替换成当前行号与当前文件名号。
5.删除注释,预处理器会将本意是帮助程序员阅读的注释部分去掉,从而减少程序整体大小。
这些作用本质都是服务于之后的编译阶段的进行,将源程序文本预处理生成.i作为文件后缀名的文件。
2.2在Ubuntu下预处理的命令
在Ubuntu下采用如图2-2-1所示预处理命令gcc -m64 -no-pie -fno-PIC hello.c -E -o hello.i,此命令会基于hello.c产生hello.i文本文件,文件部分内容如图2-2-2所示。
图2-2-1 预处理命令
图2-2-2 hello.i部分内容
2.3 Hello的预处理结果解析
在经过预处理后得到的hello.i文件从原有的24行内容(如图2-3-1)拓展到了3892行内容(如图2-3-2),其中包括了上述的一系列如头文件stdio.h,unistd.h与stdlib.h直接插入到源程序等操作,同时也可以看出预处理器将源文件的注释部分进行去除,从而得到了图2-3-2的整个文本。
图2-3-1 预处理前源文件hello.c
图2-3-2 预处理后hello.i文件
2.4 本章小结
本章描述了程序编译过程中的预处理操作的概念与作用,同时阐述如何进行对源文件的预处理,并结合预处理操作的内容进行预处理前后结果对比与分析,体预处理在整个编译过程中的作用。
第3章 编译
3.1 编译的概念与作用
3.1.1编译概念
编译是指编译器(ccl)将后缀名.i的文本文件翻译成后缀名为.s的文本文件的过程,该文本文件包含了一个汇编语言程序,相比于源文件语言,汇编语言更偏向于机器语言。
3.1.2编译作用
编译的目的在于将原来由人编写的偏向于人类语言的文本文件转换成偏向于机器语言的汇编语言文本,用一条或多条汇编指令实现原来文本文件中程序的目的操作。另外编译阶段也将不同语言种类如c,c++等转换成相同的语言种类--汇编语言,为后续处理提供便利。
3.2 在Ubuntu下编译的命令
在Ubuntu下编译命令如图3-2-1所示,为gcc -m64 -no-pie -fno-PIC hello.i -S -o hello.s,此命令基于hello.i产生hello.s文本文件,部分内容如图3-2-2所示。
图3-2-1 编译命令
图3-2-2 hello.s部分内容
3.3 Hello的编译结果解析
在汇编文件hello.s中采用汇编指令实现了诸如数据赋值、控制转移等一系列源文件要求操作并在最终实现源文件目的。在hello.s的文本展示开头中可以看到该文件内容的一系列相关说明,如图3-3-1所示,包括该源程序名称、对齐方式、格式串内容、全局变量名称与类型等等,接下来对hello.s进行具体分析。
图3-3-1 hello.s开头内容
3.3.1数据分析
1.常量分析
该汇编文本中的常量包括数据常量与字符串常量,如图3-3-2。数据常量即立即数,如第21行所示$32,即表示立即数0x32;字符串常量会存储在.rodata只读数据段中,如图3-3-2中第5,6行中的.LC0与.LC1,继而在整个程序中有所调用,例如第26行的movl $.LC0,%edi,将.LC0传递到%edi寄存器中进而用于后续的puts函数调用。
图3-3-2 hello.s常量内容
2.变量分析
变量包括全局变量,局部变量与静态变量。全局变量与静态变量会存储在.data与.bss节中,但本程序并未涉及全局变量与静态变量;局部变量一般存储在寄存器与栈中,如图3-3-3所示,subq $32,%rsp通过栈指针的上移为局部变量保留空间,并在之后通过如22行movl %edi,-20(%rbp)、movq %rsi,-32(%rbp)方式将局部变量值传送到栈中对应位置,上述分别对应argc与argv,另外局部变量i存储在%rbp-4的位置上,例如第31行修改%rbp-4位置的值对应将i赋值为0。
图3-3-3 局部变量存储
3.3.2赋值操作
赋值操作采用数据传送指令实现,如movl与movq,mov的字母决定传送数据大小,b对应1字节,w对应2字节,l对应4字节,q对应8字节,mov A,B即将A传送给B,A与B是操作数,包括立即数、寄存器与内存引用。例如图3-3-4中第31行对%rbp-4位置的赋值为0,对应源程序第18行中循环开始前的i=0。
图3-3-4 赋值操作
3.3.3算数操作
算数操作在本程序中主要是加法i++实现循环累加计数(源程序第31行),在汇编文本中用add命令实现,与mov指令类似,add后的字母表示操作大小。如图3-3-5第54行所示,对%rbp-4的位置内容加1,即i=i+1(i++)。
图3-3-5 算数操作
3.3.4类型转换
类型转换在本程序中主要是atoi函数,如图3-3-6所示第51行,对应源程序第20行(sleep(atoi(argv[4]));),该函数会将字符串转换为整数类型(但不是强制转换,并不通用)。
图3-3-6 atoi函数
3.3.5关系操作
关系操作在本程序中主要是一次在条件跳转前的等于比较(源程序argc!=5)和多次循环中每次循环前循环计数i与常数9的比较(源程序i<10,这里优化为与9的比较,即i<=9)。如图3-3-7所示,在第24行的cmpl对应源程序第14行,如果相等于5则跳转,不相等则继续执行;在第56行的cmpl对应源程序第18行,如果小于等于9则跳转到循环内容,反之执行对应源程序22行的操作。
图3-3-7 关系操作
3.3.6数组/指针/结构操作
数组操作是通过连续地址的访问实现的,数组在地址空间中是以连续的方式存储的,如图3-3-8所示,在第35,38与第41行通过对%rax存储的基址(argv[0]存储地址)加上24,16与8构造数组元素地址实现对argv[3]、argv[2]与argv[1]的内存访问并传到相应的参数寄存器中用于printf函数调用,对应源程序第19行。
图3-3-8 数组操作
3.3.7控制转移
本程序实现中存在两种控制转移,分别是if-else语句与for循环语句,如图3-3-9所示,第24行比较i与5的大小,等于则跳转到.L2,一直执行到第32行进行无条件跳转至.L3,即循环开始处;反之继续进行,对应源程序的if语句(源程序第14行到第17行)。第56行.L3循环开始处比较i与9的大小,小于等于则跳转到.L4允许继续循环,一直执行到第56行并再次判断;反之脱离循环,对应for循环语句(源程序第18行到第21行)。
图3-3-9 控制转移
3.3.8函数操作
1.参数传递
在本程序中每次函数调用前会将需要的参数存储在对应的寄存器中,其中第一个参数会存到%rdi中,之后分别是%rsi、%rdx、%rcx、%r8、%r9,若不够则会用栈传递,但在本程序的函数调用参数传递中并不涉及,如图3-3-10所示,在调用printf函数之前将相应参数按序存储到对应寄存器中。
图3-3-10 参数传递
2.函数调用
在本程序中共涉及到六次函数调用,均用call命令实现,分别为一次puts调用(第27行,源程序为printf,这里是编译器-o优化实现的),一次exit调用(第29行),一次printf调用(第46行),一次atoi调用(第51行)、一次sleep调用(第53行)和一次getchar调用(第58行),在调用前均将相关参数传到参数寄存器中以实现对应参数传递,并在调用前将返回地址压入运行栈中后跳转到对应函数代码段执行。
图3-3-11 函数调用
3.局部变量
本程序中局部变量存储在寄存器与栈上,以此实现参数传递与变量修改,如将i存储在%rbp-4的位置,如图3-3-12所示,修改%rbp-4位置值就是修改局部变量i的值。
图3-3-12 局部变量
4.函数返回
在本程序中函数调用结束时若需要返回则会用指令ret从调用程序中返回,将返回值保存到%eax中,如图3-3-13,对应main函数最后return 0,函数调用的ret指令会恢复栈帧并跳转到先前保存在栈中的返回地址中继续运行。
图3-3-13 函数返回
3.4 本章小结
本章介绍了编译操作的概念、作用与编译操作对应指令,并详细分析了编译结果hello.s中的汇编代码与c语言数据与操作对应内容的联系,感受编译器是如何通过汇编语言精准实现源程序目的的,包括赋值操作、算数操作、函数操作等等,深入理解了汇编代码的运行原理。
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编概念
汇编是指将编译阶段产生的.s为后缀的文本翻译成机器语言指令,并把这些指令打包成一种叫可重定位目标程序的格式,将结果保存在.o为后缀的二进制目标文件中的过程。
4.1.2汇编作用
将汇编文件转换成机器语言的二进制01文件,使机器可以执行,将汇编语言文件转化成为可重定位目标文件。
4.2 在Ubuntu下汇编的命令
在Ubuntu下汇编命令如图4-2-1所示,为gcc -m64 -no-pie -fno-PIC hello.s -C -o hello.o,此命令会基于hello.s产生二进制文件hello.0,如图4-2-2所示。
图4-2-1 汇编命令
图4-2-2 产生结果
4.3 可重定位目标elf格式
可重定位文件ELF格式包括ELF头、节头部表、以及夹在两者之间的一些节组成,包括.text、.rodata、.data、.bss、.symtab、.rel.text、.rel.data、.debug、.line与.strtab组成。可重定位elf格式可以通过readelf一系列命令进行查看,接下来依次分析ELF头、可重定位节、符号表与节头部表等相关内容。
4.3.1ELF头
ELF头相关内容采用readelf -h hello.o命令查看,如图4-3-1所示,可以看出ELF头是以16字节的序列开始,即Magic,该字节序列描述了生成该文件的系统的字的大小和字节顺序。另外在ELF头中剩下部分包含帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小(64 bytes)、目标文件类型(REL可重定位文件)、机器类型(UNIX)、节头部表的文件偏移、节头部表中条目的大小和数量、版本号、程序入口点位置(0x0)、程序头起点(0 bytes)以及数据在文件中的存放方式(补码且小端序)等等。
图4-3-1 ELF头对应信息
4.3.2可重定位节
重定位节可通过命令readelf -r hello.o查看,重定位节中的重定位条目可以协助链接器修改引用进行重定位,如图4-3-2所示,本程序中可重定位节包括.rela.text与.rela.eh_frame,在每个节的名字下面列出了每个重定位节的偏移量(节偏移)、信息、类型、符号值、符号名称、加数(用来辅助定位),这些信息会在形成可执行文件的时候辅助进行对对应符号的重定位,其中类型决定了重定位方式是R_X86_64_32的绝对寻址(该定位方式会直接将相应地址作为有效地址)还是R_X86_64_PLT32的相对寻址(该定位方式一般会根据地址的相对指令下一条的偏移作为有效地址),同时在图中也可以看出并没有出现rela.data节,因为本程序中并没有初始化的全局变量。
图4-3-2 可重定位节对应信息
4.3.3符号表
符号表通过命令readelf -s hello.o查看,如图4-3-3所示,在符号表中可以得知该模块定义和引用的符号的信息,其中Type表示符号类型,Bind代表是全局符号还是本地符号,Name是符号名字,Ndx代表符号对应到哪个节。
图4-3-3 符号表对应信息
4.3.4节头部表
节头部表通过命令readelf -S hello.o查看,描述了每个节的地址、偏移量、大小、访问权限、对齐方式等信息,如图4-3-4所示。
图4-3-4 节头部表对应信息
4.4 Hello.o的结果解析
采用objdump -d -r hello.o对其进行反汇编可得如图4-4-1的结果,将该结果与第三章中的汇编文本比较可以发现大部分的汇编指令是相同的,反汇编代码中的机器语言指令与原汇编指令基本一一对应,同时也可得到如下几点不同:
1.反汇编指令中mov、add等指令没有传递字节大小的表示,但汇编指令中有如blwq的字节大小表示。
2.反汇编代码相比于汇编代码缺少大量伪指令,更为简洁,更偏向于机器语言。
3.跳转转移与函数调用不同,在反汇编代码中像跳转命令、函数调用命令中会加上跳转偏移地址或调用的函数偏移地址,汇编指令中跳转指令后仅仅是跳转的标记如.L2等或者是调用的函数。
4.立即数进制不同,反汇编代码中是十六进制,而汇编代码中是十进制表示。
图4-4-1 反汇编结果
图4-4-2 原汇编结果
4.5 本章小结
本章介绍了汇编操作的概念与作用,使用gcc相关命令对hello.s文件进行汇编,并运用readelf命令辅助对于汇编产生的.o可重定位目标文件进行了深入的分析,同时也借助反汇编命令得到hello.o的反汇编文件,比较了反汇编代码与汇编代码的不同之处,对于机器语言与汇编语言有了更深的理解。
第5章 链接
5.1 链接的概念与作用
5.1.1链接概念
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,该文件可被加载到内存并执行。链接可以执行于编译时、加载时与运行时。
5.1.2链接作用
链接使分离编译成为可能,使得我们在开发中不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立修改和编译这些模块,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
在Ubuntu下链接如图5-2-1所示,为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-2-2所示。
图5-2-1 链接命令
图5-2-2 链接结果
5.3 可执行目标文件hello的格式
可执行目标文件hello同样是ELF格式,其包括ELF头、段头部表、.init节、.text节、.rodata节、.data节、.bss节、.symtab节、.debug节、.line节、.strtab节与节头部表。其中ELF头、段头部表、.init节、.text节、.rodata节为只读内存段,.data节、.bss节为读/写内存段,.symtab节、.debug节、.line节、.strtab节与节头部表不加载到内存。接下来分别来看各段情况。
5.3.1ELF头
ELF头相关内容采用readelf -h hello命令查看,如图5-3-1所示,具体包含信息种类与可重定位目标文件类似,但入口地址等不再为0,类型为EXEC(可执行文件)。
图5-3-1 ELF头信息
5.3.2段头部表信息
段头部表信息采用readelf -l hello命令查看,该表包含了每段的偏移量、内存地址、对齐要求、大小等信息,如图5-3-2所示。
图5-3-2 段头部表信息
5.3.3节头部表
节头部表信息采用readelf -S hello命令查看,如图5-3-3所示,展示了节头部表的各项信息。
图5-3-3 节头部表信息
5.3.4符号表信息
符号表信息采用readelf -s hello命令查看,如图5-3-4所示,从中可以得出各符号的基本信息。
图5-3-4 符号表信息
5.3.5重定位节信息
采用readelf -r hello命令可以查看重定位节信息,如图5-3-5所示,可以看出并没有原来的.rel.data,因为在链接中已经对该节数据进行了重定位,同时增加了辅助动态链接的重定位节。
图5-3-5 重定位节信息
5.4 hello的虚拟地址空间
在edb中加载hello,得到如图5-4-1所示虚拟地址空间信息。从图5-4-1可以看出hello的每节地址与图5-3-3中的每节地址基本相同,例如.plt都是0x401020。另外,hello的可执行代码起始地址为0x401000(如图5-4-2所示),这与图5-3-3中.init节中的起始地址相同。
图5-4-1 edb加载hello
图5-4-2 可执行代码起始地址
5.5 链接的重定位过程分析
采用objdump -d -r hello得到hello的反汇编结果,部分如图5-5-1所示,与hello.o反汇编结果对比可以看出主函数main的指令大体相同,但有如下几点不同;
1.hello反汇编结果包含了链接的各类文件,所以内容量要比hell.o反汇编结果压要多,节数要多。
2.hello反汇编结果为每条指令分配虚拟地址,hello反汇编结果中每条指令都有对应的虚拟地址,而hello.o中却没有,每条指令前仅有字节数。
3.符号引用上hello反汇编结果对于每个符号引用都有确切的虚拟地址,而hell.o中仅仅还是用0做占位符同时在下方标注引用符号与引用类型。
4.函数调用上hello反汇编结果在调用语句中如果调用跳转方式是绝对地址则在机器码中写绝对地址,如果是相对地址则在机器码中以相对地址做有效地址;而hello.o的反汇编结果中还是0做占位符,仅有函数调用标记。
从这些不同中可以分析出重定位的基本过程:1.符号解析,即将每个引用与它输入的可重定位目标文件的符号表中一个确定的符号定义关联起来,从符号表中就可以得到该引用的基本信息。2.重定位,在符号解析完成后首先重定位节和符号定义,将所有相同类型的节合并成同一类型的新的聚合节,这也就是为什么hello的反汇编结果要比hello.o大得多,接着根据之前符号解析的结果重定位节中的符号引用,根据不同的重定位类型(是绝对还是相对的)来选择不同重定位算法,修改代码节和数据节中对每个符号的引用使它们指向正确的运行地址。
图5-5-1 hello部分反汇编结果
5.6 hello的执行流程
使用gdb对hello进行执行分析,如图5-6-1所示,通过单步执行得到从加载hello到_start,到call main,以及程序终止的所有过程,其调用与跳转的各个子程序名或程序地址如下:1._dl_start(0x7f6cc94f3290),2._dl_init(0x7f6cc94f4030);3._start(0x4010f0);4.lib_start_main(0x7ffff7de2f90);5._init(0x401000);6.main(0x401125);7.printf@plt(0x401040);8.atoi@plt(0x401060);9.sleep@plt(0x401080);10.getchar@plt(0x40105d0)。
图5-6-1 gdb分析
5.7 Hello的动态链接分析
当程序调用共享库中的函数时,由于这些函数在运行时可能加载到内存中的任意位置,编译器在编译时无法直接知道其地址。GNU编译系统使用延迟绑定技术,通过全局偏移表(GOT)和过程链接表(PLT)来在函数首次被调用时解析其实际地址。GOT存储函数地址,而PLT包含跳转指令,这些指令会在首次调用时触发动态链接器来解析地址并存储在GOT中。我们可以在节头部表中获得GOT与PLT的相关信息,如图5-7-1。在EDB中对hello进行运行,发现hello中在_dl_init后(即动态链接后)0x404050等地方会存储相应地址,如图5-7-2与5-7-3。
图5-7-1 GOT与PLT的相关信息
图5-7-2 动态链接前
图5-7-3 动态链接后
5.8 本章小结
本章介绍了链接阶段概念和作用,通过命令行输入命令进行链接操作,深入分析具体链接过程与可执行文件的ELF格式,比对链接前后hello.o与hello的相同与不同之处,对于链接作用有了更深理解,同时也深入跟进hello的执行流程,实践动态链接操作,通过链接前后对比观察动态链接过程。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程概念
进程的经典定义就是一个执行中程序的实例,系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存中的程序的代码和数据、它的栈、通用目的寄存器的内容,程序计数器、环境变量,以及打开文件描述的集合。
6.1.2进程作用
进程提供给应用程序两个关键抽象:1.一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器;2.一个私有的地址空间,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1Shell-bash作用
shell是一种命令行解释器,它为应用程序的执行提供一个界面,用户通过这个界面访问操作系统内核的服务,shell读取用户输入的字符解释并执行。
6.2.2Shell-bash处理流程
(1)终端进程读取用户由键盘输入的命令行。
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量。
(3)检查第一个命令行参数判断是否是一个内置的shell命令。
(3)如果不是内部命令,调用 fork()创建新进程/子进程。
(4)在子进程中,用步骤 2 获取的参数,调用execve( )执行指定程序。
(5)如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait) 等待作业终止后返回。
(6)如果用户要求后台运行(如果命令末尾有&号),则shell返回,等待下一条命令。
6.3 Hello的fork进程创建过程
在终端输入./hello命令后,shell分析命令行字符串,因不是内置命令,shell调用 fork()函数创建一个子进程。该子进程与父进程并发执行,其地址空间是与 shell 父进程完全相同但是独立的一份副本,包括只读代码段、读写数据段、堆、共享库及用户栈等,子进程同时也继承了父进程所有的打开文件,可以读写父进程中打开的任何文件。
6.4 Hello的execve过程
exexve函数调用一次且一般不返回,在子进程中,用之前获取的命令行参数,调用 execve()函数在当前进程(新创建的子进程)的上下文中加载并运行 hello 程序并将hello中的.text节、.data节、.bss节等内容加载到当前进程的虚拟地址空间。
6.5 Hello的进程执行
进程提供给应用程序的关键抽象之一就是一个独立的逻辑控制流,但事实上一般进程执行是与其他进程的逻辑控制流并发的,其中一个进程执行它的控制流的一部分的每一时间段叫做时间片。
操作系统使用上下文切换的异常控制流来实现多任务,内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态。它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。而在进程执行的某些时刻,内核可以进行名为调度的决策,即决定抢占当前进程,并重新开始一个先前被抢占了的进程,该决策由内核中成为调度器的代码处理,在内核调度了一个新的进程运行后它就抢占当前进程并使用上下文切换的机制(分三步1.保存之前进程的上下文;2.恢复某个先前被抢占的进程被保存的上下文;3.把控制转让给新恢复的进程)来控制转移到新的进程。
处理器提供用户模式与内核模式这种机制来限制一个应用可以执行的指令以及它可以访问的地址空间范围,设置一个寄存器的模式位来提供方这种功能,当设置时就运行在内核模式(控制权在内核),该进程就可以执行指令集中的任何指令,而不设置时就运行在用户模式(控制权在进程),进程不允许执行特权指令。
那么根据上述对上下文切换、调度与用户模式内核模式机制的阐述就可以基本分析hello的进程执行过程,在进程执行中会发生多次用户态与核心态的转换。开始运行hello时是在用户态(即用户模式)下进行,如果是多个进程同时进行则实施并发执行。如果在运行hello时出现中断等异常则会在核心态(即内核模式)下完成上下文切换并转到其他进程的用户模式,控制权到其他进程。当hello进程处理完异常后发出信号,内核判定在当前进程运行足够长时间了就执行回到hello进程的上下文切换(在核心态下),将控制返回到hello进程。在hello进程执行中有sleep函数调用,sleep会显式地请求进程休眠,此时进行核心态下的上下文切换,执行其他进程,一段时间后hello休眠结束,此时再次进行核心态下的上下文切换,恢复休眠前的上下文信息,控制权回到hello并继续执行。接着执行到循环结束后会执行getchar函数,该函数等待用户输入,此时会进行核心态下的上下文切换至其他进程,在输入后会发送信号,再次进行核心态下的上下文切换至hello进程,控制权重新回到hello进程上。
6.6 hello的异常与信号处理
6.6.1异常
hello在执行中可能会出现四类异常,即中断、陷阱、故障与终止,其中中断为异步异常,陷阱、故障与终止为同步异常,接下来分别阐释解决方案。
1.中断:中断是来自处理器外部的I/O设备(如网络适配器、磁盘控制器等)的信号的结果,在中断处理中会在当前指令完成后控制传递给处理程序,并在中断处理程序执行后返回到下一条指令中,如图6-6-1。
图6.6.1 中断处理
2.陷阱:陷阱是有意的异常,是执行一条指令的结果,与中断类似,也是在当前指令完成后控制传递给处理程序,并在陷阱处理程序执行后返回到下一条指令中。
图6.6.2 陷阱处理
3.故障:故障由错误情况引起,可被故障处理程序修正,当故障发生时,处理器将控制转移给故障处理程序,如果错误可修正则控制返回到引起故障指令并重新执行它,否则处理程序返回到内核中abort例程,该例程回终止引起故障的应用程序。
图6.6.3 故障处理
4.终止:终止是不可恢复的致命错误造成的结果,终止处理程序从不将控制返回给应用程序。
图6.6.4 终止处理
6.6.2信号处理
hello程序执行中可能会产生这些信号,括号内是处理方式:SIGINT(终止)、SIGKILL(终止)、SIGSEGV(终止并转储内存)、SIGTSTP(停止直到下一个SIGCONT)、SIGCHLD(忽略)。
6.6.3具体演示
1.正常情况程序正常运行中没有任何操作下如图6-6-5所示,每秒打印一行,共十次循环,最后需要输入任意符号终止(因为有getchar),否则会一直等待输入而不终止。
图6-6-5 正常情况
2.运行中按Ctrl+C产生中断,结果如图6-6-6所示,在按第一个的时候该操作会发送一个SIGINT至前台进程,导致其终止
图6-6-6 Ctrl+C结果
3.运行中按Ctrl+Z产生中断,结果如图6-6-7所示,该操作会向前台进程发送SIGTSTP信号导致其挂起。此时我们可以输入其他命令分析该进程信息,如ps命令会打印各进程基本信息(如PID),如图6-6-8;jobs命令可以输出停止的hello进程相关信息,如图6-6-9;pstree可以输出进程数,如图6-6-10;fg会继续进行挂起的进程,直至加上之前打印次数完成十次循环如图6-6-11;kill会杀死该进程,使用kill -9 helloPID的命令,helloPID可用ps查到,kill后再次ps查询发现没有hello进程,如图6-6-12。
图6-6-7 Ctrl+Z结果
图6-6-8 ps结果
图6-6-9 jobs结果
图6-6-10 pstree结果
图6-6-11 fg结果
图6-6-12 kill结果
4.在运行中按回车会导致空行出现,但不影响进程进行,如图6-6-13所示。
图6-6-13 运行中按回车结果
5.不停乱按会在打印中打印自己按的键位,除此之外不影响正常进程进行,如图6-6-14所示。
图6-6-14 不停乱按结果
6.7本章小结
本章介绍了进程的概念和作用,分析shell的处理流程以及在进行hello程序中如何调用fork与execve函数,剖析hello的进程执行流程,阐述了hello进程中可能的异常与信号处理,并实践了在hello执行中异常与信号的处理情况。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1.逻辑地址:在计算机体系结构中,从应用程序角度看到的内存单元、存储单元或网络主机的地址,即在汇编代码hello.s中看到的偏移地址。
2.线性地址:逻辑地址向物理地址转换时的中间产物,hello.s中的段内偏移地址(逻辑地址)加上相应段的基地址就是线性地址。
3.虚拟地址:CPU启动保护模式后,程序访问存储器所使用的地址。
4.物理地址:物理地址是放在寻址总线上的地址,用于直接访问物理内存或网络设备的实际地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在Intel平台下,逻辑地址的格式为段标识符:段内偏移量。段标识符是由一个16位长的字段组成,即段选择符,其中前13位是一个索引号。后面3位与硬件有关。
逻辑地址到线性地址的变换分为如下几步:首先,段选择符用于在全局描述符表(GDT)或局部描述符表(LDT)中定位段描述符,其中段选择符的TI字段决定使用哪个描述符表,TI=0则选择GDT,为1则选择LD;接着利用段选择符对段的访问权限和范围进行检验,以确保该段可访问,如果可以访问则计算段描述符的地址,最后将逻辑地址中的偏移量与段描述符中的段基地址相加得到线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
在页式内存管理中,当程序尝试访问一个线性地址时,系统会首先根据该地址的页号查找页表。页表是一个数据结构,它存储了线性地址的页号与物理内存中的物理页框号之间的映射关系,由多个PTE构成,每个PTE由一个有效位加物理页号或磁盘地址组成,一旦找到对应的PTE且有效位为1,系统就会将其物理页号与线性地址的页内偏移量结合,计算出实际的物理地址,如果没找到或有效位为0表示这个虚拟页还未被分配,会触发缺页异常进行处理,如图7-3-1所示。
图7-3-1 线性地址到物理地址的变换-页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块,而多级页表则用于减少内存要求。
以下是TLB与四级页表支持下的VA到PA的变换过程:
1.CPU产生一个虚拟地址VA。
2.MMU根据VA的VPN访问TLB,如果命中则取出相应的PTE;如果不命中则根据VPN划分的四块VPN1到VPN4来逐层访问多级页表,根据VPN1访问一级页表获得二级页表的基地址,再加上VPN2访问二级页表获得三级页表的基地址重复上述步骤直至最后在四级页表中得到相应PTE,若缺页则触发相应缺页异常处理程序。
3.MMU根据PTE中的PPN与VA中的VPO相加得到相应物理地址PA。
7.5 三级Cache支持下的物理内存访问、
三级Cache分别为L1,L2,L3高速缓存,其支持下的物理内存访问如下:
1.MMU将物理地址PA(分为标记(CT)、组索引(CI)、块偏移(CO))发给L1Cache,判断是否命中,如果命中则将对应数据发给CPU。
2.如果L1不命中则将PA发给L2Cache,判断是否命中,如果命中则将对应块传到L1中同时将对应数据发给CPU。
3.如果L2还不命中则将PA发给L3Cache,判断是否命中,如果命中则将对应块传到L2至L1中同时将对应数据发给CPU。
4.如果L3还不命中则将PA发给主存,并将对应块传Cache中同时将对应数据发给CPU。
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 缺页故障与缺页中断处理
当发生缺页异常时会进行相应缺页处理,大致如图7-8-1所示,分为如下几步:
1.在访问页表时发现PTE有效位是零, MMU触发缺页异常,传递CPU中的控制到操作系统内核的缺页异常处理程序。
2.缺页处理程序确定出物理内存中的牺牲页,如果页面已经被修改,则把它换出到磁盘。
3.缺页处理程序调入新的页面,并更新内存中的PTE。
4.缺页处理程序返回到原进程,再次执行导致缺页的指令,CPU将引起缺页的虚拟地址重新发送给MMU重新页表访问,此时因为虚拟页面现在缓存到物理内存中了,故不会发生缺页,继续执行。
图7-8-1 缺页异常处理
7.9动态存储分配管理
动态存储分配管理由动态内存分配器进行,它维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。
分配器有两种基本风格,分别是显示分配器,要求应用显式地释放任何已分配的块,例如mallo程序包;隐式分配器,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块,隐式分配器也叫做垃圾收集器。
7.10本章小结
本章介绍了hello的存储管理基本信息,包括虚拟地址与物理地址间的转换、基于物理地址在Cache支持下的访问、hello程序的fork与execve的内存映射、缺页异常及处理以及动态存储分配管理,对存储系统工作原理有大致描画。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:所有IO设备都被模型化为文件,所有的输入输出均可作为文件的读写来执行。
设备管理:将设备映射为文件的方式允许Linux内核引出一个简单低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以统一且一直的方式来执行。
8.2 简述Unix IO接口及其函数
Unix IO接口是一个简单、低级的应用接口,用于文件输入输出,其基本操作包括打开文件、读写文件与关闭文件,也可以改变当前文件位置。另外,当应用程序通过Unix IO打开文件操作要求内核打开对应文件时内核返回一个非负整数,称为文件描述符,后续所有的操作都基于这个文件描述符。
Unix IO函数有:open函数(打开文件)、read函数(读取文件)、write函数(写入文件)、lseek(修改当前文件位置)、close函数(关闭文件)。
8.3 printf的实现分析
printf函数用于在屏幕上打印格式化字符串,当调用printf时,它首先将格式化字符串和参数转换为内存中的字符串表示,并存储在输出缓冲区中。当输出缓冲区需要刷新时(比如当字符串太长或程序显式调用fflush时),write系统调用会被触发,将缓冲区中的数据发送到内核。而内核中有write系统调用处理程序会处理这些数据,并将其发送到与输出文件描述符(通常是标准输出stdout)相关联的设备驱动程序,进行终端输出。
对于终端输出,字符显示驱动子程序会接管从内核传递来的字符数据。它将这些ASCII字符转换为屏幕上的像素表示(如果显示设备是像素基的),并将这些像素数据存储在视频RAM(VRAM)中。随后,显示芯片会按照设定的刷新频率逐行从VRAM中读取像素数据,并通过信号线将每一个点(RGB分量)传输到液晶显示器。液晶显示器根据接收到的数据控制每个像素的亮度和颜色,从而在屏幕上显示出用户通过printf函数打印的文本。
8.4 getchar的实现分析
在操作系统中,键盘中断是异步事件的一种,当用户按下键盘上的某个键时,硬件会产生一个中断信号通知操作系统。操作系统中的键盘中断处理子程序会响应这个中断,读取按键的扫描码,并将其转换成ASCII码。转换后的ASCII码会被保存到系统的键盘缓冲区中,供后续的程序读取。这个过程是异步的,意味着按键的输入不会阻塞正在执行的程序,而是会在按键事件发生时由操作系统进行处理。
getchar函数的实现依赖于上述的操作系统提供的键盘中断处理和系统调用机制,其用于从标准输入(通常是键盘)读取一个字符,且无需关心底层的硬件细节。当用户调用getchar时,该函数会调用底层的read系统函数来读取键盘缓冲区中的数据。read系统函数会等待键盘缓冲区中有数据可读,一旦有数据(即用户按下某个键后,键盘中断处理子程序将ASCII码保存到缓冲区中),read就会读取这些数据并返回给getchar。getchar会一直阻塞调用它的程序,直到读取到回车键(ASCII码为\n)为止,然后返回读取到的字符。
8.5本章小结
本章简要分析了hello的IO管理,理解Unix IO接口的基本原理,另外也对hello程序中与输入输出有关的printf函数和getchar函数基本原理实现有了进一步的理解,对系统级IO进行更深一步学习分析。
结论
hello的一生短暂而富有意义,它的P2P过程与020过程往往也代表着无数程序从诞生到结束要走的路。在编译系统中它从hello.c经预处理、编译、汇编与链接,成为可执行目标文件hello,并在系统中运行,经历加载运行与多个异常处理,成为屏幕输出的一行行文字。
仔细观察hello经历的过程,可以总结出如下几步:
1.编写得到源程序hello.c;编写者在文本编辑器或者编译器中编写hello成雪的代码,构造出最原始的源程序文本hello.c。
2.预处理得到修改后的源程序hello.i;hello.c经预处理器(cpp)处理,根据以字符#开头的命令修改原始的c程序得到hello.i。
3.编译得到汇编程序hello.s;hello.i经编译器(ccl)处理,将c语言代码转换为贴近机器语言的汇编指令,得到hello.s。
4.汇编得到可重定位目标程序hello.o;hello.s经汇编器(as)处理,被翻译成机器语言指令,打包成可重定位目标程序得到二进制文件hello.o。‘
5.链接得到可执行目标程序hello;hello.o在链接器(ld)中经符号解析与重定位等操作与其他可重定位目标文件(如printf.o)一同链接成为可执行目标文件hello,该文件可以进行加载运行。
6.调用fork创建子进程;shell根据命令./hello 2022113355 wjs 17831999172 2调用fork函数创建子进程,其地址空间与shell父进程完全相同,包括只写数据段等,也是一个独立的逻辑控制流。
7.调用execve函数进行加载运行;调用execve函数在当前进程中的上下文中加载运行hello程序,将hello中的.text节等内容加载到当前进程的虚拟地址空间。
8.运行中内存管理;在运行hello程序中TLB、多级页表、Cache等元件协助CPU进行虚拟地址翻译与物理地址访问,系统与硬件共同为hello程序维护一个私有的虚拟地址空间,完成内存管理。
9.运行中信号与异常处理;在运行hello程序中会遇到如sleep函数等作为系统调用的陷阱异常,也会遇到我们自己在键盘端输入导致的异常,系统会根据异常类型在内核模式下进行不同的异常处理。
10.运行中IO管理;在运行hello程序中用户可以通过IO端与程序进行交互,hello程序中的getchar函数与printf函数的目的之一就是实现交互,用户也可以输入Ctrl+C向进程发送信号等方式对进程进行相应操作。
11.进程终止与回收;在hello进程结束后会发送SIGCHLD信号到shell并等待shell回收,到此hello的生命周期结束。
关于感悟,在走遍hello完整的生命周期后我对于计算机系统如何工作有了更深的理解,计算机系统的有序运转需要软件系统与硬件组件的完美配合才能达成,
附件
1.hello.i——hello.c经预处理后产生的文件,用于分析预处理过程。
2.hello.s——hello.i经编译后产生的汇编语言文件,用于分析编译过程。
3.hello.o——hello.s经汇编产生的可重定位目标文件,用于分析汇编过程。
4.hello——hello.o经链接产生的可执行目标文件,用于分析链接过程及hello进程加载运行分析。
5.hello.txt——hello.o反汇编产生的文件,用于对比汇编前后区别,分析汇编对代码产生的影响。
6.hellox.txt——hello反汇编产生的文件,用于对比链接前后区别,分析链接对代码产生的影响。
参考文献
[1].兰德尔E.布莱恩特,大卫R.奥哈拉伦. 深入理解计算机系统[M]. 机械工业出版社. 2016.7.
[2].Pianistx. printf函数实现的深入剖析[EB/OL]. 2013.9.11. https://www.cnblogs.com/pianist/p/3315801.html.
[3].程序猿编码. GDB调试指南[EB/OL]. CSDN网站. 2020.3.25. https://blog.csdn.net/chen1415886044/article/details/105094688/?ops_request_misc=&request_id=&biz_id=102&utm_term=gdb&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduweb~default-1-105094688.nonecase&spm=1018.2226.3001.4187.
摘 要
本论文以Hello程序的生命周期为跟踪对象,深入分析了Hello程序P2P与020的全过程。一个hello.c文件经预处理、编译、汇编与链接操作后成为可执行目标文件hello,完成了P2P的过程;而一个可执行目标文件hello经加载、运行、IO管理到最后终止进程被回收,完成了020的过程。对Hello程序生命周期的探索也是对计算机系统的深入理解,以Hello的一生为影射,也可以剖析计算机系统是如何运行一个程序的,进而在程序编写与调试中基于计算机系统做出更好优化。
关键词:计算机系统;Hello程序;P2P;020;
目 录
第1章 概述
1.1 Hello简介
Hello是每个程序员成长的必经程序,其从编写到运行呈现需要经历P2P与020的过程,继而从一个文本文件变为最终我们所看到的程序呈现效果。
所谓P2P,即From Program to Process,从程序到进程,是指Hello从一个文本文件到一个可供运行的进程的过程。该过程包括预处理阶段、编译阶段、汇编阶段、链接阶段,大致过程如图1-1所示。在预处理阶段,预处理器(cpp)根据以字符#开头的命令,修改原始c程序最终得到另一个c程序hello.i;在编译阶段,编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,其包含一个汇编语言程序;在汇编阶段,汇编器(as)将hello.s翻译成机器语言指令并打包成可重定位目标程序保存到hello.o文件;最后在链接阶段将标准c库函数对应的目标文件printf.o等在链接器(ld)的处理下与hello.o合并为可执行目标程序hello,该程序可被加载到内存,由系统执行,到此实现从程序到进程的转变。
所谓020,即From 0 to 0,从初始的无到初始的有再到结束的无,涵盖的是整个hello程序的运行过程。首先在shell命令行中输入命令$./hello,shell命令行解释器会构造argv和envp;之后shell调用fork函数创建子进程,其地址空间与shell父进程完全相同,包括只写数据段等,并调用execve函数在当前进程中的上下文中加载运行hello程序,将hello中的.text节等内容加载到当前进程的虚拟地址空间;接着调用hello程序的main函数,这时hello程序开始在一个进程上下文中运行,到现在完成了从初始的无到初始的有;在程序运行结束后,子进程会向shell发送SIGCHLD信号,表示子进程的回收,shell回收该进程并等待下一条命令,这时候就从初始的有到结束的无,到此hello程序整个生命周期结束。
从程序到进程,从无到有再到无,hello程序经历如此波折,完成了自己的一次生命周期。
图1-1-1 P2P过程
1.2 环境与工具
硬件环境:X64 CPU;3.2GHz;16G RAM
软件环境:Windows11 64位;VMware Workstation Pro 17;Ubuntu 20.04
开发和调试工具:gcc;objdump;edb;CodeBlocks;Visual Studio 2022;VScode
1.3 中间结果
中间结果及作用如下:
1.hello.i——hello.c经预处理后产生的文件,用于分析预处理过程。
2.hello.s——hello.i经编译后产生的汇编语言文件,用于分析编译过程。
3.hello.o——hello.s经汇编产生的可重定位目标文件,用于分析汇编过程。
4.hello——hello.o经链接产生的可执行目标文件,用于分析链接过程及hello进程加载运行分析。
5.hello.txt——hello.o反汇编产生的文件,用于对比汇编前后区别,分析汇编对代码产生的影响。
6.hellox.txt——hello反汇编产生的文件,用于对比链接前后区别,分析链接对代码产生的影响。
1.4 本章小结
本章整体介绍了hello程序P2P与020的过程,即从程序到进程的生成过程,从无到有再到无的运行过程,实现对hello程序整个生命周期的白描。同时也介绍了本论文编写所用的软硬件环境工具与中间产生结果,是整个论文的概述。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理概念
预处理即预编译,是指于预处理器(cpp)进行的在进入程序编译阶段之前根据字符#开头的命令对源代码或数据进行的一系列处理与转化操作,进而产生程序编译阶段所使用的文件后缀为.i的文件的过程。
2.1.2预处理作用
预处理作用包括如下几点:
1.处理宏定义指令,根据#define指令定义的宏,预处理器会在编译阶段前对源代码中的宏进行替换。
2.处理条件编译指令,预处理器会通过对#ifdef、#ifndef、#else、#elif和#endif等条件编译指令的处理,实现对代码的选择编译等过程。
3.包含头文件,即将头文件内容插入到源文件中,例如hello.c中第1行中的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容。
4.处理特殊符号与预定义宏,预处理器会识别特殊的符号和预定义宏,并在预处理阶段进行替换或处理,例如LINE和FILE预定义宏会替换成当前行号与当前文件名号。
5.删除注释,预处理器会将本意是帮助程序员阅读的注释部分去掉,从而减少程序整体大小。
这些作用本质都是服务于之后的编译阶段的进行,将源程序文本预处理生成.i作为文件后缀名的文件。
2.2在Ubuntu下预处理的命令
在Ubuntu下采用如图2-2-1所示预处理命令gcc -m64 -no-pie -fno-PIC hello.c -E -o hello.i,此命令会基于hello.c产生hello.i文本文件,文件部分内容如图2-2-2所示。
图2-2-1 预处理命令
图2-2-2 hello.i部分内容
2.3 Hello的预处理结果解析
在经过预处理后得到的hello.i文件从原有的24行内容(如图2-3-1)拓展到了3892行内容(如图2-3-2),其中包括了上述的一系列如头文件stdio.h,unistd.h与stdlib.h直接插入到源程序等操作,同时也可以看出预处理器将源文件的注释部分进行去除,从而得到了图2-3-2的整个文本。
图2-3-1 预处理前源文件hello.c
图2-3-2 预处理后hello.i文件
2.4 本章小结
本章描述了程序编译过程中的预处理操作的概念与作用,同时阐述如何进行对源文件的预处理,并结合预处理操作的内容进行预处理前后结果对比与分析,体预处理在整个编译过程中的作用。
第3章 编译
3.1 编译的概念与作用
3.1.1编译概念
编译是指编译器(ccl)将后缀名.i的文本文件翻译成后缀名为.s的文本文件的过程,该文本文件包含了一个汇编语言程序,相比于源文件语言,汇编语言更偏向于机器语言。
3.1.2编译作用
编译的目的在于将原来由人编写的偏向于人类语言的文本文件转换成偏向于机器语言的汇编语言文本,用一条或多条汇编指令实现原来文本文件中程序的目的操作。另外编译阶段也将不同语言种类如c,c++等转换成相同的语言种类--汇编语言,为后续处理提供便利。
3.2 在Ubuntu下编译的命令
在Ubuntu下编译命令如图3-2-1所示,为gcc -m64 -no-pie -fno-PIC hello.i -S -o hello.s,此命令基于hello.i产生hello.s文本文件,部分内容如图3-2-2所示。
图3-2-1 编译命令
图3-2-2 hello.s部分内容
3.3 Hello的编译结果解析
在汇编文件hello.s中采用汇编指令实现了诸如数据赋值、控制转移等一系列源文件要求操作并在最终实现源文件目的。在hello.s的文本展示开头中可以看到该文件内容的一系列相关说明,如图3-3-1所示,包括该源程序名称、对齐方式、格式串内容、全局变量名称与类型等等,接下来对hello.s进行具体分析。
图3-3-1 hello.s开头内容
3.3.1数据分析
1.常量分析
该汇编文本中的常量包括数据常量与字符串常量,如图3-3-2。数据常量即立即数,如第21行所示$32,即表示立即数0x32;字符串常量会存储在.rodata只读数据段中,如图3-3-2中第5,6行中的.LC0与.LC1,继而在整个程序中有所调用,例如第26行的movl $.LC0,%edi,将.LC0传递到%edi寄存器中进而用于后续的puts函数调用。
图3-3-2 hello.s常量内容
2.变量分析
变量包括全局变量,局部变量与静态变量。全局变量与静态变量会存储在.data与.bss节中,但本程序并未涉及全局变量与静态变量;局部变量一般存储在寄存器与栈中,如图3-3-3所示,subq $32,%rsp通过栈指针的上移为局部变量保留空间,并在之后通过如22行movl %edi,-20(%rbp)、movq %rsi,-32(%rbp)方式将局部变量值传送到栈中对应位置,上述分别对应argc与argv,另外局部变量i存储在%rbp-4的位置上,例如第31行修改%rbp-4位置的值对应将i赋值为0。
图3-3-3 局部变量存储
3.3.2赋值操作
赋值操作采用数据传送指令实现,如movl与movq,mov的字母决定传送数据大小,b对应1字节,w对应2字节,l对应4字节,q对应8字节,mov A,B即将A传送给B,A与B是操作数,包括立即数、寄存器与内存引用。例如图3-3-4中第31行对%rbp-4位置的赋值为0,对应源程序第18行中循环开始前的i=0。
图3-3-4 赋值操作
3.3.3算数操作
算数操作在本程序中主要是加法i++实现循环累加计数(源程序第31行),在汇编文本中用add命令实现,与mov指令类似,add后的字母表示操作大小。如图3-3-5第54行所示,对%rbp-4的位置内容加1,即i=i+1(i++)。
图3-3-5 算数操作
3.3.4类型转换
类型转换在本程序中主要是atoi函数,如图3-3-6所示第51行,对应源程序第20行(sleep(atoi(argv[4]));),该函数会将字符串转换为整数类型(但不是强制转换,并不通用)。
图3-3-6 atoi函数
3.3.5关系操作
关系操作在本程序中主要是一次在条件跳转前的等于比较(源程序argc!=5)和多次循环中每次循环前循环计数i与常数9的比较(源程序i<10,这里优化为与9的比较,即i<=9)。如图3-3-7所示,在第24行的cmpl对应源程序第14行,如果相等于5则跳转,不相等则继续执行;在第56行的cmpl对应源程序第18行,如果小于等于9则跳转到循环内容,反之执行对应源程序22行的操作。
图3-3-7 关系操作
3.3.6数组/指针/结构操作
数组操作是通过连续地址的访问实现的,数组在地址空间中是以连续的方式存储的,如图3-3-8所示,在第35,38与第41行通过对%rax存储的基址(argv[0]存储地址)加上24,16与8构造数组元素地址实现对argv[3]、argv[2]与argv[1]的内存访问并传到相应的参数寄存器中用于printf函数调用,对应源程序第19行。
图3-3-8 数组操作
3.3.7控制转移
本程序实现中存在两种控制转移,分别是if-else语句与for循环语句,如图3-3-9所示,第24行比较i与5的大小,等于则跳转到.L2,一直执行到第32行进行无条件跳转至.L3,即循环开始处;反之继续进行,对应源程序的if语句(源程序第14行到第17行)。第56行.L3循环开始处比较i与9的大小,小于等于则跳转到.L4允许继续循环,一直执行到第56行并再次判断;反之脱离循环,对应for循环语句(源程序第18行到第21行)。
图3-3-9 控制转移
3.3.8函数操作
1.参数传递
在本程序中每次函数调用前会将需要的参数存储在对应的寄存器中,其中第一个参数会存到%rdi中,之后分别是%rsi、%rdx、%rcx、%r8、%r9,若不够则会用栈传递,但在本程序的函数调用参数传递中并不涉及,如图3-3-10所示,在调用printf函数之前将相应参数按序存储到对应寄存器中。
图3-3-10 参数传递
2.函数调用
在本程序中共涉及到六次函数调用,均用call命令实现,分别为一次puts调用(第27行,源程序为printf,这里是编译器-o优化实现的),一次exit调用(第29行),一次printf调用(第46行),一次atoi调用(第51行)、一次sleep调用(第53行)和一次getchar调用(第58行),在调用前均将相关参数传到参数寄存器中以实现对应参数传递,并在调用前将返回地址压入运行栈中后跳转到对应函数代码段执行。
图3-3-11 函数调用
3.局部变量
本程序中局部变量存储在寄存器与栈上,以此实现参数传递与变量修改,如将i存储在%rbp-4的位置,如图3-3-12所示,修改%rbp-4位置值就是修改局部变量i的值。
图3-3-12 局部变量
4.函数返回
在本程序中函数调用结束时若需要返回则会用指令ret从调用程序中返回,将返回值保存到%eax中,如图3-3-13,对应main函数最后return 0,函数调用的ret指令会恢复栈帧并跳转到先前保存在栈中的返回地址中继续运行。
图3-3-13 函数返回
3.4 本章小结
本章介绍了编译操作的概念、作用与编译操作对应指令,并详细分析了编译结果hello.s中的汇编代码与c语言数据与操作对应内容的联系,感受编译器是如何通过汇编语言精准实现源程序目的的,包括赋值操作、算数操作、函数操作等等,深入理解了汇编代码的运行原理。
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编概念
汇编是指将编译阶段产生的.s为后缀的文本翻译成机器语言指令,并把这些指令打包成一种叫可重定位目标程序的格式,将结果保存在.o为后缀的二进制目标文件中的过程。
4.1.2汇编作用
将汇编文件转换成机器语言的二进制01文件,使机器可以执行,将汇编语言文件转化成为可重定位目标文件。
4.2 在Ubuntu下汇编的命令
在Ubuntu下汇编命令如图4-2-1所示,为gcc -m64 -no-pie -fno-PIC hello.s -C -o hello.o,此命令会基于hello.s产生二进制文件hello.0,如图4-2-2所示。
图4-2-1 汇编命令
图4-2-2 产生结果
4.3 可重定位目标elf格式
可重定位文件ELF格式包括ELF头、节头部表、以及夹在两者之间的一些节组成,包括.text、.rodata、.data、.bss、.symtab、.rel.text、.rel.data、.debug、.line与.strtab组成。可重定位elf格式可以通过readelf一系列命令进行查看,接下来依次分析ELF头、可重定位节、符号表与节头部表等相关内容。
4.3.1ELF头
ELF头相关内容采用readelf -h hello.o命令查看,如图4-3-1所示,可以看出ELF头是以16字节的序列开始,即Magic,该字节序列描述了生成该文件的系统的字的大小和字节顺序。另外在ELF头中剩下部分包含帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小(64 bytes)、目标文件类型(REL可重定位文件)、机器类型(UNIX)、节头部表的文件偏移、节头部表中条目的大小和数量、版本号、程序入口点位置(0x0)、程序头起点(0 bytes)以及数据在文件中的存放方式(补码且小端序)等等。
图4-3-1 ELF头对应信息
4.3.2可重定位节
重定位节可通过命令readelf -r hello.o查看,重定位节中的重定位条目可以协助链接器修改引用进行重定位,如图4-3-2所示,本程序中可重定位节包括.rela.text与.rela.eh_frame,在每个节的名字下面列出了每个重定位节的偏移量(节偏移)、信息、类型、符号值、符号名称、加数(用来辅助定位),这些信息会在形成可执行文件的时候辅助进行对对应符号的重定位,其中类型决定了重定位方式是R_X86_64_32的绝对寻址(该定位方式会直接将相应地址作为有效地址)还是R_X86_64_PLT32的相对寻址(该定位方式一般会根据地址的相对指令下一条的偏移作为有效地址),同时在图中也可以看出并没有出现rela.data节,因为本程序中并没有初始化的全局变量。
图4-3-2 可重定位节对应信息
4.3.3符号表
符号表通过命令readelf -s hello.o查看,如图4-3-3所示,在符号表中可以得知该模块定义和引用的符号的信息,其中Type表示符号类型,Bind代表是全局符号还是本地符号,Name是符号名字,Ndx代表符号对应到哪个节。
图4-3-3 符号表对应信息
4.3.4节头部表
节头部表通过命令readelf -S hello.o查看,描述了每个节的地址、偏移量、大小、访问权限、对齐方式等信息,如图4-3-4所示。
图4-3-4 节头部表对应信息
4.4 Hello.o的结果解析
采用objdump -d -r hello.o对其进行反汇编可得如图4-4-1的结果,将该结果与第三章中的汇编文本比较可以发现大部分的汇编指令是相同的,反汇编代码中的机器语言指令与原汇编指令基本一一对应,同时也可得到如下几点不同:
1.反汇编指令中mov、add等指令没有传递字节大小的表示,但汇编指令中有如blwq的字节大小表示。
2.反汇编代码相比于汇编代码缺少大量伪指令,更为简洁,更偏向于机器语言。
3.跳转转移与函数调用不同,在反汇编代码中像跳转命令、函数调用命令中会加上跳转偏移地址或调用的函数偏移地址,汇编指令中跳转指令后仅仅是跳转的标记如.L2等或者是调用的函数。
4.立即数进制不同,反汇编代码中是十六进制,而汇编代码中是十进制表示。
图4-4-1 反汇编结果
图4-4-2 原汇编结果
4.5 本章小结
本章介绍了汇编操作的概念与作用,使用gcc相关命令对hello.s文件进行汇编,并运用readelf命令辅助对于汇编产生的.o可重定位目标文件进行了深入的分析,同时也借助反汇编命令得到hello.o的反汇编文件,比较了反汇编代码与汇编代码的不同之处,对于机器语言与汇编语言有了更深的理解。
第5章 链接
5.1 链接的概念与作用
5.1.1链接概念
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,该文件可被加载到内存并执行。链接可以执行于编译时、加载时与运行时。
5.1.2链接作用
链接使分离编译成为可能,使得我们在开发中不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立修改和编译这些模块,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
在Ubuntu下链接如图5-2-1所示,为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-2-2所示。
图5-2-1 链接命令
图5-2-2 链接结果
5.3 可执行目标文件hello的格式
可执行目标文件hello同样是ELF格式,其包括ELF头、段头部表、.init节、.text节、.rodata节、.data节、.bss节、.symtab节、.debug节、.line节、.strtab节与节头部表。其中ELF头、段头部表、.init节、.text节、.rodata节为只读内存段,.data节、.bss节为读/写内存段,.symtab节、.debug节、.line节、.strtab节与节头部表不加载到内存。接下来分别来看各段情况。
5.3.1ELF头
ELF头相关内容采用readelf -h hello命令查看,如图5-3-1所示,具体包含信息种类与可重定位目标文件类似,但入口地址等不再为0,类型为EXEC(可执行文件)。
图5-3-1 ELF头信息
5.3.2段头部表信息
段头部表信息采用readelf -l hello命令查看,该表包含了每段的偏移量、内存地址、对齐要求、大小等信息,如图5-3-2所示。
图5-3-2 段头部表信息
5.3.3节头部表
节头部表信息采用readelf -S hello命令查看,如图5-3-3所示,展示了节头部表的各项信息。
图5-3-3 节头部表信息
5.3.4符号表信息
符号表信息采用readelf -s hello命令查看,如图5-3-4所示,从中可以得出各符号的基本信息。
图5-3-4 符号表信息
5.3.5重定位节信息
采用readelf -r hello命令可以查看重定位节信息,如图5-3-5所示,可以看出并没有原来的.rel.data,因为在链接中已经对该节数据进行了重定位,同时增加了辅助动态链接的重定位节。
图5-3-5 重定位节信息
5.4 hello的虚拟地址空间
在edb中加载hello,得到如图5-4-1所示虚拟地址空间信息。从图5-4-1可以看出hello的每节地址与图5-3-3中的每节地址基本相同,例如.plt都是0x401020。另外,hello的可执行代码起始地址为0x401000(如图5-4-2所示),这与图5-3-3中.init节中的起始地址相同。
图5-4-1 edb加载hello
图5-4-2 可执行代码起始地址
5.5 链接的重定位过程分析
采用objdump -d -r hello得到hello的反汇编结果,部分如图5-5-1所示,与hello.o反汇编结果对比可以看出主函数main的指令大体相同,但有如下几点不同;
1.hello反汇编结果包含了链接的各类文件,所以内容量要比hell.o反汇编结果压要多,节数要多。
2.hello反汇编结果为每条指令分配虚拟地址,hello反汇编结果中每条指令都有对应的虚拟地址,而hello.o中却没有,每条指令前仅有字节数。
3.符号引用上hello反汇编结果对于每个符号引用都有确切的虚拟地址,而hell.o中仅仅还是用0做占位符同时在下方标注引用符号与引用类型。
4.函数调用上hello反汇编结果在调用语句中如果调用跳转方式是绝对地址则在机器码中写绝对地址,如果是相对地址则在机器码中以相对地址做有效地址;而hello.o的反汇编结果中还是0做占位符,仅有函数调用标记。
从这些不同中可以分析出重定位的基本过程:1.符号解析,即将每个引用与它输入的可重定位目标文件的符号表中一个确定的符号定义关联起来,从符号表中就可以得到该引用的基本信息。2.重定位,在符号解析完成后首先重定位节和符号定义,将所有相同类型的节合并成同一类型的新的聚合节,这也就是为什么hello的反汇编结果要比hello.o大得多,接着根据之前符号解析的结果重定位节中的符号引用,根据不同的重定位类型(是绝对还是相对的)来选择不同重定位算法,修改代码节和数据节中对每个符号的引用使它们指向正确的运行地址。
图5-5-1 hello部分反汇编结果
5.6 hello的执行流程
使用gdb对hello进行执行分析,如图5-6-1所示,通过单步执行得到从加载hello到_start,到call main,以及程序终止的所有过程,其调用与跳转的各个子程序名或程序地址如下:1._dl_start(0x7f6cc94f3290),2._dl_init(0x7f6cc94f4030);3._start(0x4010f0);4.lib_start_main(0x7ffff7de2f90);5._init(0x401000);6.main(0x401125);7.printf@plt(0x401040);8.atoi@plt(0x401060);9.sleep@plt(0x401080);10.getchar@plt(0x40105d0)。
图5-6-1 gdb分析
5.7 Hello的动态链接分析
当程序调用共享库中的函数时,由于这些函数在运行时可能加载到内存中的任意位置,编译器在编译时无法直接知道其地址。GNU编译系统使用延迟绑定技术,通过全局偏移表(GOT)和过程链接表(PLT)来在函数首次被调用时解析其实际地址。GOT存储函数地址,而PLT包含跳转指令,这些指令会在首次调用时触发动态链接器来解析地址并存储在GOT中。我们可以在节头部表中获得GOT与PLT的相关信息,如图5-7-1。在EDB中对hello进行运行,发现hello中在_dl_init后(即动态链接后)0x404050等地方会存储相应地址,如图5-7-2与5-7-3。
图5-7-1 GOT与PLT的相关信息
图5-7-2 动态链接前
图5-7-3 动态链接后
5.8 本章小结
本章介绍了链接阶段概念和作用,通过命令行输入命令进行链接操作,深入分析具体链接过程与可执行文件的ELF格式,比对链接前后hello.o与hello的相同与不同之处,对于链接作用有了更深理解,同时也深入跟进hello的执行流程,实践动态链接操作,通过链接前后对比观察动态链接过程。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程概念
进程的经典定义就是一个执行中程序的实例,系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存中的程序的代码和数据、它的栈、通用目的寄存器的内容,程序计数器、环境变量,以及打开文件描述的集合。
6.1.2进程作用
进程提供给应用程序两个关键抽象:1.一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器;2.一个私有的地址空间,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1Shell-bash作用
shell是一种命令行解释器,它为应用程序的执行提供一个界面,用户通过这个界面访问操作系统内核的服务,shell读取用户输入的字符解释并执行。
6.2.2Shell-bash处理流程
(1)终端进程读取用户由键盘输入的命令行。
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量。
(3)检查第一个命令行参数判断是否是一个内置的shell命令。
(3)如果不是内部命令,调用 fork()创建新进程/子进程。
(4)在子进程中,用步骤 2 获取的参数,调用execve( )执行指定程序。
(5)如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait) 等待作业终止后返回。
(6)如果用户要求后台运行(如果命令末尾有&号),则shell返回,等待下一条命令。
6.3 Hello的fork进程创建过程
在终端输入./hello命令后,shell分析命令行字符串,因不是内置命令,shell调用 fork()函数创建一个子进程。该子进程与父进程并发执行,其地址空间是与 shell 父进程完全相同但是独立的一份副本,包括只读代码段、读写数据段、堆、共享库及用户栈等,子进程同时也继承了父进程所有的打开文件,可以读写父进程中打开的任何文件。
6.4 Hello的execve过程
exexve函数调用一次且一般不返回,在子进程中,用之前获取的命令行参数,调用 execve()函数在当前进程(新创建的子进程)的上下文中加载并运行 hello 程序并将hello中的.text节、.data节、.bss节等内容加载到当前进程的虚拟地址空间。
6.5 Hello的进程执行
进程提供给应用程序的关键抽象之一就是一个独立的逻辑控制流,但事实上一般进程执行是与其他进程的逻辑控制流并发的,其中一个进程执行它的控制流的一部分的每一时间段叫做时间片。
操作系统使用上下文切换的异常控制流来实现多任务,内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态。它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。而在进程执行的某些时刻,内核可以进行名为调度的决策,即决定抢占当前进程,并重新开始一个先前被抢占了的进程,该决策由内核中成为调度器的代码处理,在内核调度了一个新的进程运行后它就抢占当前进程并使用上下文切换的机制(分三步1.保存之前进程的上下文;2.恢复某个先前被抢占的进程被保存的上下文;3.把控制转让给新恢复的进程)来控制转移到新的进程。
处理器提供用户模式与内核模式这种机制来限制一个应用可以执行的指令以及它可以访问的地址空间范围,设置一个寄存器的模式位来提供方这种功能,当设置时就运行在内核模式(控制权在内核),该进程就可以执行指令集中的任何指令,而不设置时就运行在用户模式(控制权在进程),进程不允许执行特权指令。
那么根据上述对上下文切换、调度与用户模式内核模式机制的阐述就可以基本分析hello的进程执行过程,在进程执行中会发生多次用户态与核心态的转换。开始运行hello时是在用户态(即用户模式)下进行,如果是多个进程同时进行则实施并发执行。如果在运行hello时出现中断等异常则会在核心态(即内核模式)下完成上下文切换并转到其他进程的用户模式,控制权到其他进程。当hello进程处理完异常后发出信号,内核判定在当前进程运行足够长时间了就执行回到hello进程的上下文切换(在核心态下),将控制返回到hello进程。在hello进程执行中有sleep函数调用,sleep会显式地请求进程休眠,此时进行核心态下的上下文切换,执行其他进程,一段时间后hello休眠结束,此时再次进行核心态下的上下文切换,恢复休眠前的上下文信息,控制权回到hello并继续执行。接着执行到循环结束后会执行getchar函数,该函数等待用户输入,此时会进行核心态下的上下文切换至其他进程,在输入后会发送信号,再次进行核心态下的上下文切换至hello进程,控制权重新回到hello进程上。
6.6 hello的异常与信号处理
6.6.1异常
hello在执行中可能会出现四类异常,即中断、陷阱、故障与终止,其中中断为异步异常,陷阱、故障与终止为同步异常,接下来分别阐释解决方案。
1.中断:中断是来自处理器外部的I/O设备(如网络适配器、磁盘控制器等)的信号的结果,在中断处理中会在当前指令完成后控制传递给处理程序,并在中断处理程序执行后返回到下一条指令中,如图6-6-1。
图6.6.1 中断处理
2.陷阱:陷阱是有意的异常,是执行一条指令的结果,与中断类似,也是在当前指令完成后控制传递给处理程序,并在陷阱处理程序执行后返回到下一条指令中。
图6.6.2 陷阱处理
3.故障:故障由错误情况引起,可被故障处理程序修正,当故障发生时,处理器将控制转移给故障处理程序,如果错误可修正则控制返回到引起故障指令并重新执行它,否则处理程序返回到内核中abort例程,该例程回终止引起故障的应用程序。
图6.6.3 故障处理
4.终止:终止是不可恢复的致命错误造成的结果,终止处理程序从不将控制返回给应用程序。
图6.6.4 终止处理
6.6.2信号处理
hello程序执行中可能会产生这些信号,括号内是处理方式:SIGINT(终止)、SIGKILL(终止)、SIGSEGV(终止并转储内存)、SIGTSTP(停止直到下一个SIGCONT)、SIGCHLD(忽略)。
6.6.3具体演示
1.正常情况程序正常运行中没有任何操作下如图6-6-5所示,每秒打印一行,共十次循环,最后需要输入任意符号终止(因为有getchar),否则会一直等待输入而不终止。
图6-6-5 正常情况
2.运行中按Ctrl+C产生中断,结果如图6-6-6所示,在按第一个的时候该操作会发送一个SIGINT至前台进程,导致其终止
图6-6-6 Ctrl+C结果
3.运行中按Ctrl+Z产生中断,结果如图6-6-7所示,该操作会向前台进程发送SIGTSTP信号导致其挂起。此时我们可以输入其他命令分析该进程信息,如ps命令会打印各进程基本信息(如PID),如图6-6-8;jobs命令可以输出停止的hello进程相关信息,如图6-6-9;pstree可以输出进程数,如图6-6-10;fg会继续进行挂起的进程,直至加上之前打印次数完成十次循环如图6-6-11;kill会杀死该进程,使用kill -9 helloPID的命令,helloPID可用ps查到,kill后再次ps查询发现没有hello进程,如图6-6-12。
图6-6-7 Ctrl+Z结果
图6-6-8 ps结果
图6-6-9 jobs结果
图6-6-10 pstree结果
图6-6-11 fg结果
图6-6-12 kill结果
4.在运行中按回车会导致空行出现,但不影响进程进行,如图6-6-13所示。
图6-6-13 运行中按回车结果
5.不停乱按会在打印中打印自己按的键位,除此之外不影响正常进程进行,如图6-6-14所示。
图6-6-14 不停乱按结果
6.7本章小结
本章介绍了进程的概念和作用,分析shell的处理流程以及在进行hello程序中如何调用fork与execve函数,剖析hello的进程执行流程,阐述了hello进程中可能的异常与信号处理,并实践了在hello执行中异常与信号的处理情况。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1.逻辑地址:在计算机体系结构中,从应用程序角度看到的内存单元、存储单元或网络主机的地址,即在汇编代码hello.s中看到的偏移地址。
2.线性地址:逻辑地址向物理地址转换时的中间产物,hello.s中的段内偏移地址(逻辑地址)加上相应段的基地址就是线性地址。
3.虚拟地址:CPU启动保护模式后,程序访问存储器所使用的地址。
4.物理地址:物理地址是放在寻址总线上的地址,用于直接访问物理内存或网络设备的实际地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在Intel平台下,逻辑地址的格式为段标识符:段内偏移量。段标识符是由一个16位长的字段组成,即段选择符,其中前13位是一个索引号。后面3位与硬件有关。
逻辑地址到线性地址的变换分为如下几步:首先,段选择符用于在全局描述符表(GDT)或局部描述符表(LDT)中定位段描述符,其中段选择符的TI字段决定使用哪个描述符表,TI=0则选择GDT,为1则选择LD;接着利用段选择符对段的访问权限和范围进行检验,以确保该段可访问,如果可以访问则计算段描述符的地址,最后将逻辑地址中的偏移量与段描述符中的段基地址相加得到线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
在页式内存管理中,当程序尝试访问一个线性地址时,系统会首先根据该地址的页号查找页表。页表是一个数据结构,它存储了线性地址的页号与物理内存中的物理页框号之间的映射关系,由多个PTE构成,每个PTE由一个有效位加物理页号或磁盘地址组成,一旦找到对应的PTE且有效位为1,系统就会将其物理页号与线性地址的页内偏移量结合,计算出实际的物理地址,如果没找到或有效位为0表示这个虚拟页还未被分配,会触发缺页异常进行处理,如图7-3-1所示。
图7-3-1 线性地址到物理地址的变换-页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块,而多级页表则用于减少内存要求。
以下是TLB与四级页表支持下的VA到PA的变换过程:
1.CPU产生一个虚拟地址VA。
2.MMU根据VA的VPN访问TLB,如果命中则取出相应的PTE;如果不命中则根据VPN划分的四块VPN1到VPN4来逐层访问多级页表,根据VPN1访问一级页表获得二级页表的基地址,再加上VPN2访问二级页表获得三级页表的基地址重复上述步骤直至最后在四级页表中得到相应PTE,若缺页则触发相应缺页异常处理程序。
3.MMU根据PTE中的PPN与VA中的VPO相加得到相应物理地址PA。
7.5 三级Cache支持下的物理内存访问、
三级Cache分别为L1,L2,L3高速缓存,其支持下的物理内存访问如下:
1.MMU将物理地址PA(分为标记(CT)、组索引(CI)、块偏移(CO))发给L1Cache,判断是否命中,如果命中则将对应数据发给CPU。
2.如果L1不命中则将PA发给L2Cache,判断是否命中,如果命中则将对应块传到L1中同时将对应数据发给CPU。
3.如果L2还不命中则将PA发给L3Cache,判断是否命中,如果命中则将对应块传到L2至L1中同时将对应数据发给CPU。
4.如果L3还不命中则将PA发给主存,并将对应块传Cache中同时将对应数据发给CPU。
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 缺页故障与缺页中断处理
当发生缺页异常时会进行相应缺页处理,大致如图7-8-1所示,分为如下几步:
1.在访问页表时发现PTE有效位是零, MMU触发缺页异常,传递CPU中的控制到操作系统内核的缺页异常处理程序。
2.缺页处理程序确定出物理内存中的牺牲页,如果页面已经被修改,则把它换出到磁盘。
3.缺页处理程序调入新的页面,并更新内存中的PTE。
4.缺页处理程序返回到原进程,再次执行导致缺页的指令,CPU将引起缺页的虚拟地址重新发送给MMU重新页表访问,此时因为虚拟页面现在缓存到物理内存中了,故不会发生缺页,继续执行。
图7-8-1 缺页异常处理
7.9动态存储分配管理
动态存储分配管理由动态内存分配器进行,它维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。
分配器有两种基本风格,分别是显示分配器,要求应用显式地释放任何已分配的块,例如mallo程序包;隐式分配器,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块,隐式分配器也叫做垃圾收集器。
7.10本章小结
本章介绍了hello的存储管理基本信息,包括虚拟地址与物理地址间的转换、基于物理地址在Cache支持下的访问、hello程序的fork与execve的内存映射、缺页异常及处理以及动态存储分配管理,对存储系统工作原理有大致描画。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:所有IO设备都被模型化为文件,所有的输入输出均可作为文件的读写来执行。
设备管理:将设备映射为文件的方式允许Linux内核引出一个简单低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以统一且一直的方式来执行。
8.2 简述Unix IO接口及其函数
Unix IO接口是一个简单、低级的应用接口,用于文件输入输出,其基本操作包括打开文件、读写文件与关闭文件,也可以改变当前文件位置。另外,当应用程序通过Unix IO打开文件操作要求内核打开对应文件时内核返回一个非负整数,称为文件描述符,后续所有的操作都基于这个文件描述符。
Unix IO函数有:open函数(打开文件)、read函数(读取文件)、write函数(写入文件)、lseek(修改当前文件位置)、close函数(关闭文件)。
8.3 printf的实现分析
printf函数用于在屏幕上打印格式化字符串,当调用printf时,它首先将格式化字符串和参数转换为内存中的字符串表示,并存储在输出缓冲区中。当输出缓冲区需要刷新时(比如当字符串太长或程序显式调用fflush时),write系统调用会被触发,将缓冲区中的数据发送到内核。而内核中有write系统调用处理程序会处理这些数据,并将其发送到与输出文件描述符(通常是标准输出stdout)相关联的设备驱动程序,进行终端输出。
对于终端输出,字符显示驱动子程序会接管从内核传递来的字符数据。它将这些ASCII字符转换为屏幕上的像素表示(如果显示设备是像素基的),并将这些像素数据存储在视频RAM(VRAM)中。随后,显示芯片会按照设定的刷新频率逐行从VRAM中读取像素数据,并通过信号线将每一个点(RGB分量)传输到液晶显示器。液晶显示器根据接收到的数据控制每个像素的亮度和颜色,从而在屏幕上显示出用户通过printf函数打印的文本。
8.4 getchar的实现分析
在操作系统中,键盘中断是异步事件的一种,当用户按下键盘上的某个键时,硬件会产生一个中断信号通知操作系统。操作系统中的键盘中断处理子程序会响应这个中断,读取按键的扫描码,并将其转换成ASCII码。转换后的ASCII码会被保存到系统的键盘缓冲区中,供后续的程序读取。这个过程是异步的,意味着按键的输入不会阻塞正在执行的程序,而是会在按键事件发生时由操作系统进行处理。
getchar函数的实现依赖于上述的操作系统提供的键盘中断处理和系统调用机制,其用于从标准输入(通常是键盘)读取一个字符,且无需关心底层的硬件细节。当用户调用getchar时,该函数会调用底层的read系统函数来读取键盘缓冲区中的数据。read系统函数会等待键盘缓冲区中有数据可读,一旦有数据(即用户按下某个键后,键盘中断处理子程序将ASCII码保存到缓冲区中),read就会读取这些数据并返回给getchar。getchar会一直阻塞调用它的程序,直到读取到回车键(ASCII码为\n)为止,然后返回读取到的字符。
8.5本章小结
本章简要分析了hello的IO管理,理解Unix IO接口的基本原理,另外也对hello程序中与输入输出有关的printf函数和getchar函数基本原理实现有了进一步的理解,对系统级IO进行更深一步学习分析。
结论
hello的一生短暂而富有意义,它的P2P过程与020过程往往也代表着无数程序从诞生到结束要走的路。在编译系统中它从hello.c经预处理、编译、汇编与链接,成为可执行目标文件hello,并在系统中运行,经历加载运行与多个异常处理,成为屏幕输出的一行行文字。
仔细观察hello经历的过程,可以总结出如下几步:
1.编写得到源程序hello.c;编写者在文本编辑器或者编译器中编写hello成雪的代码,构造出最原始的源程序文本hello.c。
2.预处理得到修改后的源程序hello.i;hello.c经预处理器(cpp)处理,根据以字符#开头的命令修改原始的c程序得到hello.i。
3.编译得到汇编程序hello.s;hello.i经编译器(ccl)处理,将c语言代码转换为贴近机器语言的汇编指令,得到hello.s。
4.汇编得到可重定位目标程序hello.o;hello.s经汇编器(as)处理,被翻译成机器语言指令,打包成可重定位目标程序得到二进制文件hello.o。‘
5.链接得到可执行目标程序hello;hello.o在链接器(ld)中经符号解析与重定位等操作与其他可重定位目标文件(如printf.o)一同链接成为可执行目标文件hello,该文件可以进行加载运行。
6.调用fork创建子进程;shell根据命令./hello 2022113355 wjs 17831999172 2调用fork函数创建子进程,其地址空间与shell父进程完全相同,包括只写数据段等,也是一个独立的逻辑控制流。
7.调用execve函数进行加载运行;调用execve函数在当前进程中的上下文中加载运行hello程序,将hello中的.text节等内容加载到当前进程的虚拟地址空间。
8.运行中内存管理;在运行hello程序中TLB、多级页表、Cache等元件协助CPU进行虚拟地址翻译与物理地址访问,系统与硬件共同为hello程序维护一个私有的虚拟地址空间,完成内存管理。
9.运行中信号与异常处理;在运行hello程序中会遇到如sleep函数等作为系统调用的陷阱异常,也会遇到我们自己在键盘端输入导致的异常,系统会根据异常类型在内核模式下进行不同的异常处理。
10.运行中IO管理;在运行hello程序中用户可以通过IO端与程序进行交互,hello程序中的getchar函数与printf函数的目的之一就是实现交互,用户也可以输入Ctrl+C向进程发送信号等方式对进程进行相应操作。
11.进程终止与回收;在hello进程结束后会发送SIGCHLD信号到shell并等待shell回收,到此hello的生命周期结束。
关于感悟,在走遍hello完整的生命周期后我对于计算机系统如何工作有了更深的理解,计算机系统的有序运转需要软件系统与硬件组件的完美配合才能达成,
附件
1.hello.i——hello.c经预处理后产生的文件,用于分析预处理过程。
2.hello.s——hello.i经编译后产生的汇编语言文件,用于分析编译过程。
3.hello.o——hello.s经汇编产生的可重定位目标文件,用于分析汇编过程。
4.hello——hello.o经链接产生的可执行目标文件,用于分析链接过程及hello进程加载运行分析。
5.hello.txt——hello.o反汇编产生的文件,用于对比汇编前后区别,分析汇编对代码产生的影响。
6.hellox.txt——hello反汇编产生的文件,用于对比链接前后区别,分析链接对代码产生的影响。
参考文献
[1].兰德尔E.布莱恩特,大卫R.奥哈拉伦. 深入理解计算机系统[M]. 机械工业出版社. 2016.7.
[2].Pianistx. printf函数实现的深入剖析[EB/OL]. 2013.9.11. https://www.cnblogs.com/pianist/p/3315801.html.