本文围绕hello程序的生命周期,梳理hello程序从高级C语言源程序hello.c经过预处理、编译、汇编、链接等阶段生成可执行目标文件的过程,并结合相关理论详细阐述计算机系统对hello程序进行进程管理、存储管理和系统I/O管理的基本方法与策略,通过对hello程序生命周期的探索,深化对计算机系统领域知识的理解与认识。
关键词:计算机系统;hello程序;预处理;编译;汇编;链接;进程;存储;I/O
目 录
2.2在Ubuntu下预处理的命令.......................................................................... - 7 -
3.2 在Ubuntu下编译的命令............................................................................. - 9 -
4.2 在Ubuntu下汇编的命令........................................................................... - 16 -
5.2 在Ubuntu下链接的命令........................................................................... - 25 -
5.3 可执行目标文件hello的格式.................................................................. - 25 -
5.5 链接的重定位过程分析............................................................................... - 33 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 40 -
6.3 Hello的fork进程创建过程..................................................................... - 41 -
6.6 hello的异常与信号处理............................................................................ - 43 -
第7章 hello的存储管理............................................................................... - 48 -
7.1 hello的存储器地址空间............................................................................ - 48 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................ - 48 -
7.3 Hello的线性地址到物理地址的变换-页式管理...................................... - 49 -
7.4 TLB与四级页表支持下的VA到PA的变换............................................. - 51 -
7.5 三级Cache支持下的物理内存访问.......................................................... - 52 -
7.6 hello进程fork时的内存映射.................................................................. - 52 -
7.7 hello进程execve时的内存映射.............................................................. - 53 -
7.8 缺页故障与缺页中断处理........................................................................... - 54 -
8.1 Linux的IO设备管理方法.......................................................................... - 59 -
8.2 简述Unix IO接口及其函数....................................................................... - 59 -
第1章 概述
1.1 Hello简介
Hello也许是每一个编程者写下的第一个程序。在计算机系统的世界里,从它的诞生到它的运行结束,这个过程可以用“P2P”和“020”来概括。
“P2P”,即From Progaram to Process的缩写。Program即为编程者编写的C语言程序源代码hello.c,Process即为系统最终运行该程序时创建的进程。P2P即为一个高级语言程序经过一系列过程最终转化为系统内部运行的进程的过程。在Linux中,编程者完成编程后,源代码经过预处理器cpp的预处理、编译器ccl的编译、汇编器as汇编、链接器ld的链接等过程,生成一个可执行目标文件Hello。在bash中键入启动命令后,操作系统调用进程管理程序调用fork函数为其生成一个子进程(Process),并调用execve函数在该子进程上下文中运行Hello程序,调用mmap函数为该程序创建虚拟内存映射,还将该进程的执行分为若干个时间片调度运行,让其能够在CPU\RAM\IO等设备上完成取指译码执行等流水线操作,hello便从程序代码变成了进程。
“020”,即From Zero to Zero的简称。第一个zero指代编程者编写代码之前,该程序还不存在;第二个zero意指在经过预处理、编译、汇编、链接等一系列过程之后,代码完成了从零到系统可执行文件的转换。而后,在shell中启动该程序运行时,操作系统OS为该程序分配子进程,映射虚拟内存,进入程序入口后程序载入物理内存,然后进入main函数执行目标代码,操作系统与CPU等低层硬件密切配合,为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构,系统完成程序运行,释放相应资源,对hello这个程序来说重新回到“零”的状态。
1.2 环境与工具
1.2.1 硬件环境:
Intel(R) Core(TM) i5-8265U CPU @ 1.60GHz 1.80 GHz
8.00 GB RAM;476.92GHD Disk
1.2.2 软件环境
Windows10 64位;Vmware Workstation Pro 16; Ubuntu 20.04 LTS 64位;
GDB/OBJDUMP;EDB;KDD
1.2.3 开发工具
Visual Studio 2019 64位;CodeBlocks; vim; gedit+gcc;
1.3 中间结果
文件名称 | 文件作用 |
hello.i | hello.c预处理之后的文本文件 |
hello.s | hello.i编译后的汇编文件 |
hello.o | hello.s汇编之后的可重定位目标文件 |
hello | hello.o与其他组件链接之后的可执行目标文件 |
helloExe.d | Hello反汇编文件 |
hello.d | Hello.o反汇编文件 |
Hello_elf.txt | ELF格式下的hello文件 |
Hello_o_elf.txt | ELF格式下的hello.o文件 |
1.4 本章小结
本章简要介绍了hello的P2P,020过程,列出了本次实验的主要信息:开发与运行环境、中间文件,大致介绍了hello程序从C语言程序hello.c到可执行目标文件hello的大致经过。
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
在计算机科学中,预处理器是程序中处理输入数据,产生能用来输入到其他程序的数据的程序。输出被称为输入数据预处理过的形式,常用在之后的程序比如编译器中。所作处理的数量和种类依赖于预处理器的类型,一些预处理器只能够执行相对简单的文本替换和宏展开,而另一些则有着完全成熟的编程语言的能力。
一个来自计算机编程的常见的例子是在进行下一步编译之前,对源代码执行处理。在一些计算机语言(例如:C语言)中有一个叫做预处理的翻译阶段。
C预处理器是C语言、C++语言的预处理器。用于在编译器处理程序之前预扫描源代码,完成头文件的包含, 宏扩展, 条件编译, 行控制(line control)等操作。
C语言标准规定,预处理是指前4个编译阶段(phases of translation)。
- 三字符组与双字符组的替换
- 行拼接(Line splicing): 把物理源码行(Physical source line)中的换行符转义字符处理为普通的换行符,从而把源程序处理为逻辑行的顺序集合。
- 单词化(Tokenization): 处理每行的空白、注释等,使每行成为token的顺序集。
- 扩展宏与预处理指令(directive)处理。
2.2.2 预处理的作用
C语言预处理一般保包含宏扩展,文件包含,条件编译三方面内容。
宏定义的使用可提高程序的通用性和易读性,减少不一致性,减少输入错误和便于修改。例如:数组大小常用宏定义。
文件包含处理在程序开发中会给我们的模块化程序设计带来很大的好处,通过文件包含的方法把程序中的各个功能模块联系起来是模块化程序设计中的一种非常有利的手段。
程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。条件编译指令将决定哪些代码被编译,而哪些不被编译的。可以根据表达式的值或者某个特定的宏是否被定义来确定编译条件。
2.2在Ubuntu下预处理的命令
命令:gcc hello.c -E -o hello.i
图2.1:Ubuntu下hello.c预处理生成hello.i
2.3 Hello的预处理结果解析
图2.2:hello.i文件部分内容
hello.c文件预处理后生成hello.i文件。打开hello.i文件,可以发现文件内容依然为可阅读C语言程序文本,但文件内容增加,且注释内容删除。预处理器对hello.c文件中#define定义的宏进行了宏展开,#include指定的头文件中的程序内容被包含进该文件中,如函数声明、结构体定义、变量定义、宏定义等内容。此外,预处理器还会根据代码中#define命令作对应的符号替换。
2.4 本章小结
本章以hello.c程序的预处理为例介绍了预处理的相关概念以及部分处理过程,
即宏定义符号替换、头文件包含、条件编译、注释删除等。
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
编译的概念:编译器将.i后缀文本文件hello.i 翻译成.s后缀文件hello.s,它包含一个汇编语言程序。其以高级程序设计语言书写的源程序作为输入,而以汇编语言或机器语言表示的目标程序作为输出。
3.1.2编译的作用
编译程序的基本功能是把源程序(高级语言)翻译成目标程序。除了基本功能之外,编译程序还具备语法检查、调试措施、修改手段、覆盖处理、目标程序优化、不同语言合用以及人际联系等重要功能。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
图3.1:Ubuntu下hello.i编译为hello.s
3.3 Hello的编译结果解析
3.3.1 数据
3.3.1.1 字符串常量
hello.s中包含两个字符串,存放在只读数据段.rodata中,如图3.2所示:
图3.2:.rodata段内保存的两个字符串
main函数代码段中,这两个字符串.LC0与.LC1作为printf函数的参数被使用,如图3.3所示:
图3.3:两处printf分别调用两个字符串
C语言编译器将字符串中的中文按照UTF-8编码规则编码为每个汉字对应三个字节,英文、数字、空格、换行等按照ASCII规则编码为一个字节,但在汇编文件中原样显示。
3.3.3.2 局部变量
hello.c中的局部变量有整型变量i、整型变量agrc,字符串数组argv[]。
整型局部变量i作为循环变量,初始值为0,每次循环迭代加1并与10比较。编译器将i存放在栈上的局部变量存储区,如图3.4所示:
图3.4:局部变量i作为循环变量
变量argc作为main函数参数之一,表示程序运行时命令行参数个数,编译器将其保存在栈上,地址为-20(%rbp),如图3.5所示:
图3.5:局部变量argv保存于栈段
变量argv作为main函数参数之二,表示程序运行时命令行参数,编译器将其首地址保存在栈上,地址为-32(%rbp),如图3.6所示:
图3.6:局部变量argv[]保存于栈段
除此之外,argv[1],argv[2]还作为printf函数参数被使用:
图3.7:argv[1], argv[2]作为printf参数
3.3.3.3 全局变量
hello.c中的全局变量为整型变量sleepsecs, 编译器将其保存于静态存储区,并在.s文件头部位置指明其符号类型为.globl(全局符号),数据大小为4字节,数值为2,如图3.8:
图3.8:全局变量sleepsecs的描述
Sleepsecs还作为sleep函数的参数被引用,如图3.9所示:
图3.9:sleepsecs作为sleep函数参数被使用
3.3.2赋值
Hello.c文件中出现“=”赋值语句如下:1)对全局变量sleepsecs在定义时的赋值赋值;2)对局部变量i在for循环开始时的赋值。如图3.10, 3.11所示:
图3.10:对sleepsecs的初值赋予
图3.11:对i的初值赋予
在.s文件中,编译器对上述两个变量采用不同的赋值方式。对于sleepsecs的赋值在全局变量定义时完成。对i的赋值通过双字数据传送指令movl完成,如下图:
图3.12:i的赋值通过数据传送指令完成
3.3.3 类型转换
Hello.s对变量sleepsecs采用隐式类型转换。.c文件中初始定义时,sleepsecs数据类型为int,但为其赋值2.5为浮点类型数据。为了使赋值语句等号“=”前后类型一致,编译器进行隐式类型转换,将sleepsecs赋值为2。如图3.8所示。
3.3.4 算术操作
.c文件中的算数操作为对i的增一操作“++”。在.s文件中通过addl指令实现,如图3.13所示:
图3.13:.s文件中对i的自增操作
3.3.5 关系操作
.c文件中关系操作符有“!=”和“<”,分别为对argc与3的大小判断和for循环内对i与10的大小比较,如图3.14所示:
图3.14:.c文件中的关系操作
.s文件中,对数值大小比较的关系运算通过比较指令cmpl与条件跳转指令配合实现,cmpl指令根据两个操作数之差来设置条件码,且不更新目的寄存器;条件跳转指令根据条件码的某种组合,或者跳转,或者继续执行代码序列中下一条指令。如图3.15,3.16所示:
图3.15:关系操作“!=”的编译结果
图3.16:关系操作“<”的编译结果
3.3.6 数组操作
.c文件中的数组操作为对argc[1], argv[2]的引用。字符串数组argv[]作为main函数的参数传入,printf在main函数中调用,且使用argv[1],argv[2]作为一部分参数。在.s文件中,编译器对数组下标引用采用偏移量寻址的方式实现,即在argv首地址的基础上利用寄存器保存目标数据对应的下标偏移量完成寻址。如图3.17所示:
图3.17:对argv[1],argv[2]的引用
3.3.7 控制转移
.c文件包含两处控制转移:1)对argc是否为3的if/else判断;2)对i的for循环。在.s文件中,编译器使用cmp指令与条件跳转完成if/else控制转移,如图3.18所示;使用“jump to middle”方式将for循环转化为while循环实现,如图3.19所示。
图3.18:if/else控制转移的汇编指令序列
图3.19:for控制转移的汇编指令序列
3.3.8 函数操作
3.3.8.1 参数传递
hello.c文件中一共有5处参数传递:main函数的参数为argc的值与argv的首地址,exit函数的参数为1,两处printf函数参数不同:一处取.LC0字符串为参数,另一处取.LC1与argv[1], argv[2]为参数;sleep函数取全局变量sleepsecs为参数。
在.s文件中,对main函数参数的传递在进入main函数之前完成,argc参数存入寄存器%edi中,argv数组首地址存入寄存器%rsi中,如图3.5所示;对exit函数参数的传递通过movl指令向寄存器%edi中传送立即数1后调用call语句完成,如图3.20所示:
图3.20:对exit的参数传递
对两处printf的参数传递分别见图3.3和图3.7;
对sleep函数的参数传递通过相对PC寻址完成,编译器将sleepsecs变量地址存入寄存器%edi,如图3.21所示:
图3.21:sleep函数的参数传递
3.3.8.2 函数调用
调用函数时有以下操作:(假设函数P调用函数Q)
(1)传递控制:进行过程 Q 的时候,程序计数器必须设置为 Q 的代码的起始 地址,然后在返回时,要把程序计数器设置为 P 中调用 Q 后面那条指令的 地址。
(2)传递数据:P 必须能够向 Q 提供一个或多个参数,Q 必须能够向 P 中返回 一个值。
(3) 分配和释放内存:在开始时,Q 可能需要为局部变量分配空间,而在返回 前,又必须释放这些空间。
1中所述的5次带参函数调用均采用参数传递完成后调用call指令的方式完成。但.c文件中还包括一次无参函数调用:getchar()的调用。该函数在.s文件中直接执行对getchar函数的call指令,如图3.22所示:
图3.22:对getchar的直接调用
getchar是读入函数的一种。它从标准输入里读取下一个字符,相当于getc(stdin)。返回类型为int型,为用户输入的ASCII码或EOF。
3.3.8.3 函数返回return
.c文件中仅在main函数结束位置出现return返回。在.s文件中,编译器调用leave,ret汇编指令以及对main函数进行返回,返回值0保存在寄存器%eax中。其中leave的作用相当于mov esp,ebp和pop ebp。.cfi开头指令为编译器生成的调试信息。.cfi_endproc标志程序结束。如图3.23所示:
图3.23:main函数返回指令序列
3.4 本章小结
本章主要讲述了编译阶段中编译器如何处理各种数据和操作,以及C语言中各种类型和操作所对应的的汇编代码。通过理解了这些编译器编译的机制,我们可以很容易的将汇编语言翻译成C语言。
第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编是指汇编程序把汇编语言书写的程序(.s文件)翻译成与之等价的机器语言程序(.o文件)翻译过程。汇编程序输入用汇编语言书写的源程序,输出用机器语言表示的目标程序。它通常用于编写系统的核心部分程序,或编写需要耗费大量运行时间和实时性要求较高的程序段。
4.1.2 汇编的作用
汇编器(as)将汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o 目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。汇编过程将汇编代码转换为计算机能够理解并执行的二进制机器代码,这个二进制机器代码是程序在本机器上的机器语言的表示。
4.2 在Ubuntu下汇编的命令
命令:gcc -c -m64 -no-pie -fno-PIC hello.s -o hello.o
图4.1:Ubuntu下hello.s汇编结果
4.3 可重定位目标elf格式
首先使用readelf命令查看hello.o的ELF格式,指令如下:
readelf -a hello.o > hello_o_elf.txt
图4.2:hello.o的ELF格式的查看
4.3.1 ELF头(ELF header)
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标问价的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
如图4.3所示,hello.oELF头相关信息如下:
图4.3:hello.oELF头相关信息
Hello.o的ELF头以一个16字节的序列开始,序列如下:
7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
这个序列描述了生成该文件的系统的字的大小为8字节和字节顺序为小端序。ELF头的大小为64字节,目标文件的类型为REL(可重定位文件)、机器类型为Advanced Micro Devices X86-64即AMD X86-64、节头部表的文件偏移为1176,以及节头部表中条目的大小,其数量为14。
4.3.2 节头部表(section header table)
如图4.4所示,hello.o文件包含下面几个节:
.text:已编译的机器代码。类型为PROGBITS,意为程序数据,旗标为AX,即权限为分配内存、可执行。
.rel.text:一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。
.data:已初始化的全局和静态C变量。类型为PROGBITS,意为程序数据,旗标为WA,即权限为可分配可写。
.bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0。类型为NOBITS,意为暂时没有存储空间,旗标为WA,即权限为可分配可写。
.rodata:只读数据,比如printf语句中的格式串和开关语句的跳转表。类型为PROGBITS,意为程序数据,旗标为A,即权限为可分配。
.comment节:包含版本控制信息。
.note.GNU_stack节:用来标记executable stack(可执行堆栈)。
.note.gnu.propert节:记录GNU的特有属性信息。
.eh_frame节:处理异常。
.rela.eh_frame节: .eh_frame的重定位信息。
.symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
.strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。
.shstrtab节:该区域包含节区名称。
图4.4:hello.o文件中的节头
4.3.3 .rela.text节和.rela.eh_frame节
如图4.5所示,重定位条目包含以下内容:
图4.5:ELF重定位条目。每个条目表示一个必须被重定位的引用,并指明如何计算被修改的引用
各部分分析如下:
Offset:需要被修改的引用节的偏移Info:包括symbol和type两个部分,symbol在前面四个字节,type在后面四个字节,
symbol:标识被修改引用应该指向的符号,
type:重定位的类型
Type:告知链接器应该如何修改新的应用
Attend:一个有符号常数,一些重定位要使用它对被修改引用的值做偏移调整Name:重定向到的目标的名称。
ELF定义了两种最基本的重定位类型:
- R_X86_64_32。重定位绝对引用。重定位时使用一个32位的绝对地址的引用,通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
- R_X86_64_PC32:重定位PC相对引用。重定位时使用一个32位PC相对地址的引用。一个PC相对地址就是据程序计数器的当前运行值的偏移量。
除此之外,新版本GCC添加了一种新的重定位条目类型:
- R_X86_64_PLT32: 过程链接表延迟绑定。当汇编器遇到对最终位置未定义的目标引用时,就会生成一个重定位条目,告诉链接器在合并目标文件时如何修改这个引用,重定位的条目代码段放在.rel.text中。
如图4.6所示,第一、第四条重定位条目类型为R_X86_64_32,第六、第九条类型为R_X86_64_PC32,其余五条类型为R_X86_64_PLT32。
图4.6:hello.o中.rela.text节和.rela.eh_frame节
4.3.4 符号表
符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。.symtab节中包含ELF符号表。这张符号表包含一个条目的数组。图4.7展示了每个条目的格式。
图4.7:ELF符号表条目。type和binding字段每个都是4位
如图4.8所示,hello.o文件的符号表包含Num, Value, Size, Type, Bind, Vis, Ndx, Name等字段,基本与图4.7中格式符合。该符号表共17个符号,其中hello.c为文件,ABS表示不该被重定位的符号;sleepsecs是一个位于.data节(Ndx=3)偏移量(value)为0,大小为4个字节的全局符号,类型为变量;main是一个位于.text节(Ndx=1)偏移量(value)为0,大小为125个字节的全局符号,类型为函数。puts、exit、printf、sleep、getchar为NOTYPE未知类型,未定义(UND)符号。
图4.8:hello.o的符号表
4.4 Hello.o的结果解析
首先使用objdump -d -r hello.o > hello.d命令分析hello.o的反汇编。以下使用hello.d文件内容对hello.o反汇编文本进行讨论。
图4.9:反汇编hello.o
查看hello.o反汇编文件hello.d内容可以发现,机器语言是用二进制代码表示的一种机器指令的集合。一条指令就是机器语言的一个语句,它是一组有意义的二进制代码,指令的基本格式一般为操作码字段和地址码字段,其中操作码指明了指令的操作性质及功能,地址码则给出了操作数或操作数的地址。每一条机器指令都与一条汇编指令一一对应。
将hello.d文件与第3章的 hello.s进行对照分析可以发现,hello.o反汇编结果文件包含与hello.s相同的汇编指令, 但其余内容上与hello.s文件相比有若干差异。现将二者具体差异陈述如下:
- 文件内容
hello.s作为汇编文件,其内容中没有机器指令,但hello.o文件由hello.s文件汇编而来,内容反汇编后包含机器指令,即hello.d中汇编指令之前的二进制序列。除此之外,hello.d文件中不包含hello.s中含有的以.cfi_开头的汇编指示符(CFI 即 Call Frame Information,是 DWARF 2.0 定义的函数栈信息,DWARF 即 Debugging With Attributed Record Formats ,是一种调试信息格式),如图4.10,图4.11所示。
- 操作数表示
立即数在hello.s文件中以十进制表示,在hello.d中以十六进制表示。例如在main函数内申请栈空间时,hello.s中语句如图4.10所示,hello.d中语句如图4.11所示:
图4.10:hello.s中立即数以十进制表示
图4.11:hello.d中立即数以十六进制表示
二者申请语句语义相同,均为在栈空间中申请十进制32字节大小的栈空间,但在hello.s中,操作数32表示为十进制32,在hello.d中,操作数表示为十六进制0x20。
当操作数为数据段中保存的数据时,hello.s中采用内存操作数的寻址方法,例如hello.s中对全局变量sleepsecs的寻址方式如图4.12所示:
图4.12:hello.s中对全局变量sleepsecs的寻址
而在hello.d中,对同一变量的寻址方式变为由一个重定向条目指定地址的方式,如图4.13所示:
图4.13:hello.d中对sleepsecs寻址采用重定向条目指定
在图4.13所示案例中,sleepsecs的重定向条目指示链接器在代码段偏移量0x60处填充sleepsecs的运行时64位相对PC偏移量,作为数据转移指令mov的操作数地址。
- 分支转移
hello.s的无条件跳转语句和条件跳转语句后以代码段助记符为跳转目标,如图4.14所示:
图4.14:hello.s文件中以助记符为跳转目标
但在hello.d文件中,汇编指令中的无条件跳转语句和条件跳转语句后的跳转目标改用目的代码段的相对.text偏移量来表达,机器指令中的跳转目标对应字节采用相对PC偏移量来表达,如图4.15所示:
图4.15: hello.d文件中机器指令采用64位PC相对寻址进行分支跳转
在图4.15所示例子中,第0x17字节开始的机器指令中,0x74为条件跳转语句je的编码,目标的地址0x2d与当前PC值0x19差值正好为机器指令中的0x14。
- 函数调用
hello.s汇编代码中的函数调用方式为all指令后直接加助记符作为调用目标,如图4.16所示:
图4.16:hello.s中采用助记符为函数调用目标
而在hello.d中,汇编指令部分函数调用语句变为callq,且callq之后如果跳转目标函数地址未定,则在callq指令下方给出目标函数的重定向条目,函数调用指令根据重定向条目对链接器的指示进行函数跳转。且在该条汇编指令对应的机器指令部分,函数调用目标地址偏移量暂时为0(将由链接器根据重定向条目填充)。如图4.17所示:
图4.17:hello.d中由重定向条目指定函数调用目标地址
在图4.17所示案例中,对sleep函数调用的地址偏移量由过程链接表延时绑定,机器指令中后四个字节(操作数地址)暂时为0。
hello.d文件内容如图4.18:
图4.18:hello.o反汇编代码
4.5 本章小结
本章对hello.s进行了汇编,生成了hello.o可重定位目标文件,并且分析了可重定位文件的ELF头、节头部表、符号表和可重定位节,比较了hello.s和hello.o反汇编代码的不同之处,分析了从汇编语言到机器语言的一一映射关系。
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接执行符号解析、重定位过程。
5.1.2 链接的作用
把可重定位目标文件和命令行参数作为输入,产生一个完全链接的,可以加载运行的可执行目标文件。使得分离编译成为可能。
5.2 在Ubuntu下链接的命令
命令:ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
图5.1:Ubuntu下链接hello.o生成hello
5.3 可执行目标文件hello的格式
使用readelf -a hello > hello_elf.txt命令列出hello的各段信息,如图5.2所示:
图5.2: 使用readelf命令查看helloELF格式信息
5.3.1 ELF头(ELF Header)
如图5.3所示:
图5.3:hello的ELF头
与hello.o文件ELF头有所不同的是,hello的文件类型为EXEC(Executable fild),即可执行文件;且hello中给出了程序入口点地址为0x4010d0;hello中节头部表中有26个条目,且节头部表文件偏移为14200字节处。
5.3.2 节头部表
如图5.4所示:
图5.4:hello的节头部表
其中比hello.o节头部表多出的部分介绍如下:
.interp段:动态链接器在操作系统中的位置不是由系统配置决定,也不是由环境参数指定,而是由ELF文件中的 .interp段指定。该段里保存的是一个字符串,这个字符串就是可执行文件所需要的动态链接器的位置,常位于/lib/ld-linux.so.2。(通常是软链接)
.dynamic段:该段中保存了动态链接器所需要的基本信息,是一个结构数组,可以看做动态链接下 ELF文件的“文件头”。存储了动态链接会用到的各个表的位置等信息。
.dynsym段:该段与 “.symtab”段类似,但只保存了与动态链接相关的符号,很多时候,ELF文件同时拥有 .symtab 与 .synsym段,其中 .symtab 将包含 .synsym 中的符号。该符号表中记录了动态链接符号在动态符号字符串表中的偏移,与.symtab中记录对应。
.dynstr段:该段是 .dynsym 段的辅助段,.dynstr 与 .dynsym 的关系,类比与 .symtab 与 .strtab的关系 hash段:在动态链接下,需要在程序运行时查找符号,为了加快符号查找过程,增加了辅助的符号哈希表,功能与 .dynstr 类似
.rel.dyn段:对数据引用的修正,其所修正的位置位于 “.got”以及数据段(类似重定位段“rel.data”)。
.rel.plt段:对函数引用的修正,其所修正的位置位于 “.got.plt”。
5.3.3 程序头表(Program Headers Table)
如图5.5所示:
图5.5:hello的程序头表
程序头使用结构体 Elf32_Phdr/Elf64_Phdr 来定义,如图5.6所示:
图5.6:结构体Elf64_Phdr
各字段含义如下:
p_type :
该字段指明该程序头所描述的内存段的类型,或者如何解析该程序头的信息;
p_offset :
该字段指明该段中内容在文件中的位置,即,段中内容的起始位置相对于文件开头处的偏移量;
p_vaddr :
该字段指明该段中内容的起始位置在进程地址空间中的虚拟地址;
p_memsz :
该字段指明该段中内容在内存镜像中的大小,也可以是0;单位是字节;
p_flags :
该字段指明该段中内容的属性;
p_align :
该字段指明该段中内容如何在内存和文件中对齐;对于可装载的段来说,其p_vaddr和p_offset的值至少要向内存页面大小对齐;如果值为0或1,则表明没有对齐要求,否则,p_align应该是一个正整数,并且是2的幂次数;p_vaddr和p_offset在对p_align取模后应该相等;
5.3.4 节段映射(Section to Segment mapping)
如图5.7所示:
图5.7:hello中的节段映射
5.3.5 动态节(Dynamic section)
如图5.8所示:
图5.8:hello的动态节(Dynamic section)
5.3.6 重定位节(动态链接库、过程链接表)
如图5.9所示:
图5.9:hello的重定位节
5.3.7 符号表(包括动态链接库符号表)
如图5.10,5.11所示:
图5.10:动态链接库(.dynamic)符号表
图5.11:hello符号表
5.3.8 版本信息
如图5.12所示:
图5.12: hello版本与属性信息
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息。如图5.13所示:
图5.13:使用edb查看hello进程虚拟地址信息
由图5.13可知,hello进程的虚拟地址空间从0x400000开始。
查看图5.4可知:
.interp段从地址0x4002e0开始,偏移量为0x2e0,大小为0x1c,对齐要求为1,查看虚拟地址0x4002e0处的值,发现共享库/lib64/ld-linux-x86-64.so.2信息。如图5.14所示:
图5.14:.interp段虚拟地址内容
.dynstr段从地址0x400458开始,偏移量为0x458,大小为0x57,对齐要求为1,查看虚拟地址0x400458处内容,发现为动态链接库符号表内容,如图5.15所示:
图5.15: .dynstr段虚拟地址内容
.text段从地址0x4010d0开始,偏移量为0x10d0,大小为0x135,对齐要求为16,查看虚拟地址0x4010d0处的值,发现二进制序列与反汇编文件中代码符合,如图5.16:
图5.16:.text段虚拟地址内容
.data段从地址0x404040开始,偏移量为0x3040,大小为0x8,对齐要求为4,查看虚拟地址0x404040内容,发现其内容为sleepsecs的值2,如图5.17所示:
图5.17:.data段虚拟地址内容
.rodata段从地址0x402000开始,偏移量为0x2000,大小为0x2f,对齐要求为4,查看虚拟地址0x402000内容,发现其中保存printf的格式字符串,如图5.18所示:
图5.18:.rodata段虚拟地址内容
5.5 链接的重定位过程分析
使用objdump -d -r hello > helloExe.d分析hello与hello.o的不同。
图5.19: 对hello反汇编命令与生成文件
5.5.1 hello.o与hello的不同
- hello.o反汇编文本.text段的内容只有main函数的反汇编代码,而hello反汇编文本.text段还包括了大量动态链接函数。除此之外,hello反汇编文本还包括除.text段之外的大量动态链接库代码和数据。
- hello.o反汇编文本中main函数首地址为0(未重定向),而hello反汇编文本中各部分代码都分配了虚拟地址,如图5.20, 图5.21所示:
-
图5.20:hello.o中main函数未重定向
图5.21:hello中各部分代码已分配虚拟地址
- hello.o反汇编文本中对未定地址的操作数和函数调用目标列出重定位条目,hello反汇编文本反映了链接器对重定位条目进行处理,填充待定的机器指令序列。如图5.22, 5.23所示:
图5.22:hello.o反汇编代码中的重定向条目
图5.23:hello反汇编代码中重定向条目得到处理
5.5.2. hello对hello.o中重定位项目的处理
图5.24展示了链接器的重定位算法的伪代码。第1行和第2行在每个节s以及与每个节相关联的重定位条目r上迭代执行。每个节s是一个字节数组,每个重定向条目r是一个类型未Elf64-Rela的结构,如图4.5所示。当算法运行时,链接器已经为每个节(用ADDR(s)表示)和每个符号都选择了运行时地址(用ADDR(r.symbol)表示)。第3行计算需要被重定位的4字节引用的数组s中的地址。如果这个引用使用的是PC相对寻址,那么它就用第5-9行来重定位。如果该引用使用的是绝对寻址,它就通过第11-13行来重定位。
图5.24:重定位算法
图5.25给出hello.o反汇编代码中main函数对全局符号sleepsecs的引用。
图5.25:hello.o反汇编文本中对sleepsecs引用的代码和重定位条目
如图5.26,图5.27所示,由hello.oELF格式文件重定位条目信息、helloELF格式文件符号表得到关于sleepsecs重定向信息:
r.offset = 0x60
r.symbol = sleepsecs
r.type = R_X86_64_PC32
r.addend = -4
ADDR(s) = ADDR(.text_main) = 0x401105
ADDR(r.symbol) = ADDR(sleepsecs) = 0x404044
图5.26:hello.oELF格式文件部分重定位节
图5.27:helloELF格式文件部分符号表
可以得到引用的运行时地址:
refaddr = ADDR(s) + r.offset = 0x401105 + 0x60 = 0x401165
然后,更新该引用,使得它在运行时指向sleepsecs:
*refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr)
= (unsigned) (0x404044 + (-4) – 0x401165)
= (unsigned) (0x2edb)
如图5.28所示,其与机器代码的反汇编结果中的0x2edb(%rip)一致:
图5.28:sleepsecs重定位
图5.29给出hello.o反汇编代码中对printf格式字符串(.rodata)的第一次引用:
图5.29:hello.o反汇编代码中对.rodata的引用
如图5.30,5.31所示,由hello.oELF格式文件.rela.text节内容、helloELF文件节头部表相关条目,可以获得.rodata重定向条目信息:
r.offset = 0x1a
r.symbol = .rodata
r.type = R_X86_64_32
r.addend = 0
ADDR(s) = ADDR(.text_main) = 0x401105
ADDR(r.symbol) = ADDR(.rodata) = 0x402004
图5.30:hello.oELF重定向节对.rodata的描述
图5.31:helloEFL文件对.rodata的符号表描述
可以得到引用的运行时地址:
refaddr = ADDR(s) + r.offset = 0x401105 + 0x1a = 0x40111f
然后,更新该引用,使得它在运行时指向.rodata有关字符串:
*refptr = (unsigned) (ADDR(r.symbol) + r.addend)
= (unsigned) (0x402004 + 0)
= (unsigned) (0x402004)
如图5.32所示,其与机器代码的反汇编结果中的0x402004一致:
图5.32:.rodata重定位
同理可以得到其他重定位条目。
5.6 hello的执行流程
函数调用如下表格所示:
函数名称 | 函数地址 |
ld_2.31.so!_dl_start | 0x00007ffe387e7750 |
ld_2.31.so!_dl_init | 0x00007fc038ecbc10 |
hello!_start | 0x00000000004010d0 |
ld-2.31.so!_libc_start_main | 0x00000000004010f8 |
libc-2.31.so!__cxa_atexit | 0x00007f54deb3ff60 |
libc-2.31.so!__new_exitfn | 0x00007f54deb3fd00 |
hello!__libc_csu_init | 0x0000000000401190 |
hello!_init | 0x0000000000401000 |
libc-2.31.so!_setjmp | 0x00007f54deb3be00 |
libc-2.31.so!__sigsetjmp | 0x00007f54deb3bd30 |
libc-2.31.so!__sigjmp_save | 0x00007f54deb3bdb0 |
hello!main | 0x0000000000401105 |
hello!printf@plt | 0x0000000000401040 |
hello!sleep@plt | 0x0000000000401070 |
libc-2.31.so!printf | 0x00007f3515b9ae10 |
libc-2.31.so!sleep | 0x00007f3515c1bf40 |
libc-2.31.so!exit | 0x00007f3515b7fbc0 |
5.7 Hello的动态链接分析
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定(lazybinding),将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过GOT(全局偏移量表)和PLT(过程链接表)实现的。GOT是数据段的一部分,而PLT是代码段的一部分。
- 过程链接表(PLT)。PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。PLT[1]调用系统启动函数(__libc_start_main),它初始化执行环境。
- 全局偏移量表(GOT)。GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在ld-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
根据hello ELF文件可知, GOT表起始位置为0x404000,如图5.33;
图5.33:.got条目信息
GOT表位置在调用dl_start之前0x404008开始的16字节均为0:
图5.34:动态链接相关:dl_init前
图5.35:动态链接相关:dl_init后
在dl_init调用之后,0x404008开始的16字节发生改变。
在之后对动态链接库中的函数调用时,首先跳转到PLT中对应条目首条指令,借助对应GOT条目进行间接跳转。由于初始时GOT条目指向其对应PLT条目第二条指令,所以此次间接跳转只是简单地把控制传送回PLT条目的下一条指令。PLT条目第二条指令将该被调用函数ID压入栈中,之后PLT跳转到PLT[0]。PLT[0]通过GOT[1]间接跳转进动态链接器中。动态链接器使用两个栈条目来确定该被调用函数的运行时位置,用这个地址重写对应GOT条目,再把控制传递给被调用函数。此后再调用该函数时,于前述相同,控制传递到对应PLT条目。不过这次通过GOT条目的间接跳转会将控制直接转移到被调用函数。
5.8 本章小结
本章主要介绍了链接的概念与作用,阐述了hello.o是怎么链接成为一个可执行目标文件的过程,介绍了hello.o的ELF格式和各个节的含义,分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
进程的经典定义是一个执行中程序的实例,系统的每个程序都运行在某个进程的上下文。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存里的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。
6.1.2 进程的作用
通过进程,我们会得到一种假象,好像我们的程序是当前唯一运行的程序,我们的程序独占处理器和内存,我们程序的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell-bash的作用
shell是系统的用户界面,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核去执行。
实际上Shell是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。不仅如此,Shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。Shell编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等,用这种编程语言编写的Shell程序与其他应用程序具有同样的效果。
6.2.2 Shell-bash的处理流程
基本的执行步骤如下:
读取用户由键盘输入的命令行;
分析命令,以命令名作为文件名,并将其它参数改造为系统调用execve( )内部处理所要求的形式;
终端进程调用fork( )建立一个子进程;
终端进程本身调用wait4()来等待子进程完成(如果是后台命令,则不等待)。当子进程运行时调用execve(),子进程根据文件名到目录中查找有关文件,调入内存,执行这个程序;
如果命令末尾有&,则终端进程不用执行系统调用wait4(),立即发提示符,让用户输入下一条命令;否则终端进程会一直等待,当子进程完成工作后,向父进程报告,此时中断进程醒来,作必要的判别工作后,终端发出命令提示符,重复上述处理过程。
6.3 Hello的fork进程创建过程
我们向shell输入./hello运行程序时,这不是一个内置的shell命令,故shell就会通过调用某个驻留在存储器中被称为加载器的操作系统代码来运行它。shell创建一个新进程,然后在这个新进程的上下文中运行Hello。Hello也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
父进程通过调用fork函数创建一个新的运行的子进程。
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间的最大区别在于它们有不同的PID。
6.4 Hello的execve过程
execve函数在shell为Hello分配的进程的上下文中加载并运行一个新程序。
execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到hello,execve才会返回到调用程序。
加载器将hello文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。
当加载器运行时,它创建类似于图6.1所示的内存映像。在程序头部表的引导下,加载器将hello的片复制到代码段和数据段。接下来,加载器跳转到程序的入口点,也就是_start函数的地址。这个函数系统目标文件ctrl.o中定义的,对所有的C程序都是一样的。_start函数调用系统启动函数__libc_start_main,该函数定义在libc.so中。它初始化执行环境,调用用户层的main函数,处理main函数的返回值,并且在需要的时候把控制返回给内核。
图6.1:Linux x86-64运行时内存映像
6.5 Hello的进程执行
用调试器运行Hello时,一系列程序计数器PC的值的序列叫做逻辑控制流。进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占 (暂时挂起),然后轮到其他进程。
一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为 并发地运行。流X和Y互相并发,当且仅当X在Y开始之后和Y结束前开始,或者在Y在X开始之后和X结束前开始。
多个流并发的执行的一般现象被称为并发。一个进程和其他进程轮流运行的概念称为多任务。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫做时间分片。
操作系统内核使用一种被称为上下文切换的较高层形式的异常控制流来实现多任务。
内核为每个进程维护一个上下文。上下文就是内核重启一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。
hello程序与操作系统其他进程通过操作系统的调度,切换上下文,拥有各自的时间片从而实现并发运行。hello调用sleep时操作系统就进行这样的切换。Hello中的sleep系统调用显式地请求让Hello休眠。此时,内核代表用户执行系统调用,发生上下文切换。
Hello调用sleep之前,如果hello程序进程不被抢占则顺序执行,如果内核调度了一个新进程,内核进行上下文切换:1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
Hello初始运行在用户模式,在hello进程执行sleep系统调用后陷入内核。内核不会选择什么都不做等待sleep函数调用结束,而是处理休眠请求主动挂起当前进程,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时发送一个中断信号,此时进入内核状态执行中断处理,重新设置Hello上下文,恢复其为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
以上过程如图6.2所示:
图6.2:进程上下文切换的分析
6.6 hello的异常与信号处理
(1) 异常和信号异常可以分为四类:中断、陷阱、故障、终止,各自的属性如下表格:
类别 | 原因 | 异步 / 同步 | 返回行为 |
中断 | 来自I/O的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
Hello程序可能出现的异常有:
中断:在hello程序执行的过程中可能会出现外部I/O设备引起的异常。
陷阱:陷阱是有意的异常,是执行一条指令的结果,hello执行sleep函数的时候会出现这个异常。
故障:在执行hello程序的时候,可能会发生缺页故障。
终止:终止时不可恢复的错误,在hello执行过程可能会出现DRAM或者SRAM位损坏的奇偶错误。
在发生异常时会发出信号,比如缺页故障会导致OS发生SIGSEGV信号给用户进程,而用户进程以段错误退出。常见信号种类如下表所示。
ID | 名称 | 默认行为 | 相应事件 |
2 | SIGINT | 终止 | 来自键盘的中断 |
9 | SIGKILL | 终止 | 杀死程序(该信号不能被捕获不能被忽略) |
11 | SIGSEGV | 终止 | 无效的内存引用(段故障) |
14 | SIGALARM | 终止 | 来自alarm函数的定时器信号 |
17 | SIGCHILD | 忽略 | 一个子进程停止或者终止 |
(2) 键盘上各种操作导致的异常
1.不停乱按,包括回车
运行结果如图6.3:
图6.3:不停乱按包括回车的运行结果
若按键不包括回车,这个时候输入字符串还在键盘缓冲区,没有进入stdin缓冲区;若乱序字符输入最后为回车,则输出完毕全部结果后,getchar读回车,并将回车前的字符串作为输入的命令处理。
2.输入Ctrl+Z
输入Ctrl+Z后内核向前台进程组中的每个进程(Hello)发送一个SIGTSTP信号使其停止并转入后台。如图6.4所示:
图6.4:键入ctrl+Z将使Hello停止并转入后台
- 输入ctrl+C
输入Ctrl+C后内核向前台进程组中的每个进程(Hello)发送一个SIGINT信号使其终止运行。如图6.5所示:
图6.5:键入ctrl+C将使Hello直接终止
4.输入Ctrl+Z后可以运行ps , jobs, pstree, fg, kill等命令
键入Ctrl+Z后Hello进程收到内核发送的SIGTSTP信号停止运行,转入后台;运行ps命令列出当前所有进程。如图6.6所示:
图6.6:键入Ctrl+Z后运行ps命令
键入Ctrl+Z后Hello进程停止运行,运行jobs命令列出当前所有作业(只有hello)。如图6.7所示:
图6.7:键入Ctrl+Z后运行jobs命令
键入Ctrl+Z后Hello进程停止运行,运行ps命令查得bash进程PID为12404,再使用pstree 12404命令显式bash进程所拥有的进程,如图6.8所示。
图6.8:键入Ctrl+Z后运行pstree命令
键入Ctrl+Z后Hello进程收到内核发送的SIGTSTP信号停止运行,转入后台;再运行fg命令,内核向后台因键入Ctrl+Z停止的hello进程发送SIGCONT信号使其称为前台进程继续输出剩余的字符串。如图6.9所示:
图6.9:键入Ctrl+Z后运行fg命令
键入Ctrl+Z后运行kill -9 %1命令,内核向1号作业(hello)发送9号信号(SIGINT),无条件终止hello进程运行,此时shell打印hello被终止前的状态为停止(Stopped),使用fg %1命令发现hello进程已经被杀死(Killed)。
图6.10:键入Ctrl+Z后运行kill命令
6.7本章小结
本章阐述了进程的定义与作用,介绍了 Shell 的一般处理流程和作用,并且着重分析了调用 fork 创建新进程,调用 execve函数 执行hello,hello的进程执行,以及hello 的异常与信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:在有地址变换功能的计算机中,访内指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。逻辑地址由两个16位的地址分量构成,一个为段基值,另一个为偏移量。两个分量均为无符号数编码。如Hello中sleepsecs这个操作数的地址。
线性地址:线性地址是一个32位无符号整数,可以用来表示高达4GB的地址,也就是,高达4294967296个内存单元。线性地址通常用十六进制数字表示,值的范围从0x00000000到0xffffffff。程序代码会产生逻辑地址,通过逻辑地址变换就可以生成一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址。如果没有启用分页机制,那么线性地址直接就是物理地址。
在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
物理地址:在存储器里以字节为单位存储信息,为正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址,称为物理地址(Physical Address),又叫实际地址或绝对地址。
虚拟地址:CPU启动保护模式后,程序运行在虚拟地址空间中。注意,并不是所有的“程序”都是运行在虚拟地址中。CPU在启动的时候是运行在实模式的,Bootloader以及内核在初始化页表之前并不使用虚拟地址,而是直接使用物理地址的。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部分组成,段标识符和段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。
索引号,可以理解为数组的下标,这个数组由多个“段描述符”构成,称为“段描述符表”。这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
每一个段描述符由8个字节组成,这里只关心Base字段,它描述了一个段的开始位置的线性地址。
Intel设计的本意是,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的段描述符,例如每个进程自己的,就放在“局部段描述符表(LDT)”中。那究竟什么时候该用GDT,什么时候该用LDT呢?这是由段选择符中的T1字段表示的,T1 == 0表示用GDT,T1 == 1表示用LDT。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
1、看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
2、拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
3、把Base + offset,就是要转换的线性地址了。
7.3 Hello的线性地址到物理地址的变换-页式管理
MMU利用页表实现一个N元素的虚拟地址空间中的元素和一个M元素的物理地址空间中元素之间的映射。
图7.1展示了MMU如何利用页表来实现这种映射。
图7.1:使用页表的地址翻译
CPU中的一个控制寄存器,页表基址寄存器指向当前页表。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO)和一个(n-p)位的虚拟页号(VPN)。MMU利用VPN来选择适当的PTE。例如,VPN 0选择PTE 0, VPN 1选择PTE 1,以此类推。将页表条目中物理页号(PPN)和虚拟地址中的VPO串联起来,就得到相应的物理地址。注意,因为物理和虚拟页面都是P字节的,所以物理页面偏移(PPO)和VPO是相同的。
图7.2a展示了当页面命中时,CPU硬件执行的步骤。
- 第1步:处理器生成一个虚拟地址,并把它传送给MMU。
- 第2步:MMU生成PTE地址,并从高速缓存/主存请求得到它。
- 第3步:高速缓存/主存向MMU返回PTE。
- 第4步:MMU构造物理地址,并把它传送给高速缓存/主存。
- 第5步:高速缓存/主存返回所请求的数据字给处理器。
-
图7.2:页面命中和缺页的操作图(VA:虚拟地址。PTEA:页表条目地址。PTE:页表条目。PA:物理地址)
页面命中完全是由硬件来处理的,与之不同的是,处理缺页要求硬件和操作系统内核协作完成,如图7.2b所示。
- 第1步到第3步:和图7.2a中的第1步到第3步相同。
- 第4步:PTE中的有效位是零,所以MMU触发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
- 第5步:缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到硬盘。
- 第6步:缺页处理程序页面调入新的页面,并更新内存中的PTE.
- 第7步:缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中,在MMU执行了图7.1b中的步骤之后,主存就会将所请求字返回给处理器。
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1 利用TLB加速地址翻译
每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降1或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。如图7.3所示,用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果TLB有T=2^t个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,而TLB标记(TLBT)是由VPN中剩余的位组成的。
因为所有的地址翻译都是在芯片上的MMU中进行的,因此非常快。
图7.3:虚拟地址中用以访问TLB的组成部分
7.4.2 多级页表
图7.4描述了使用k级页表层次结构的地址翻译。虚拟地址被划分成k个VPN和1个VPO。每个VPN i都是一个到第i级页表的索引,其中1<=i<=k。第j级页表中的每个PTE,1<=j<=k-1,都指向第j+1级的某个页表的基址。第k级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问k个PTE。对于只有一级的页表结构,PPO和VPO是相同的。
图7.4:使用k级页表的地址翻译
这里TLB能够起作用,正是通过将不同层次上页表的PTE缓存起来。
7.5 三级Cache支持下的物理内存访问
开始时,MMU从虚拟地址中抽取出VPN,并且检查TLB,看它是否因为前面的某个内存引用缓存了对应PTE的一个副本。TLB从VPN中抽取出TLB索引和TLB标记,如果TLB命中,则缓存的PPN返回给MMU;如果TLB不命中,那么MMU就需要结合多级页表,得到物理地址去cache中找,到了L1里面以后,寻找物理地址又要检测是否命中,不命中则紧接着寻找下一级cache L2,接着L3。这里就是使用到CPU的高速缓存机制了,一级一级往下找,直到找到对应的内容。
7.6 hello进程fork时的内存映射
当fork函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给这hello进程创建虚拟内存,它创建了hello进程mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此也就为每个进程保持了私有地址空间的抽象概念。
图7.6:一个私有的写时复制对象
7.7 hello进程execve时的内存映射
虚拟内存和内存映射在将程序加载到内存的过程中也扮演着关键的角色。假设hello执行了如下的execve调用:
execve(“a.out”,NULL,NULL);
execve函数在hello进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效地替代了当前程序。加载并运行a.out需要以下几个步骤:
- 删除已存在地用户区域。删除hello进程虚拟地址地用户部分中地已存在的区域结构。
- 映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为a.oiut文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的,初始长度为零。如7.7概括了私有区域的不同映射。
- 映射共享区域。如果a.out程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC)。execve做的最后一件事情是设置hello进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
图7.7:加载器是如何映射用户地址空间的区域的
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页。如图7.8,CPU引用了VP 3中的一个字,VP 3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE 3,从有效位推断出VP 3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在次为存放在PP 3中的VP 4。如果VP 4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP 4的页表条目,反映出VP 4不再缓存在主存中这一事实。
图7.8:VM缺页。对VP 3中的字的引用会不命中,从而触发缺页
接下来,内核从磁盘复制VP 3到内存中的PP 3,更新PTE 3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP 3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。图7.9展示了在缺页之后示例页表的状态。
图7.9:VM缺页(之后)。缺页处理程选择VP 4作为牺牲页,并从磁盘上用VP 3的副本取代它。在缺页程序重新启动导致缺页的指令之后,该指令将从内存中正常地取字,而不会再产生异常
7.9动态存储分配管理
7.9.1 动态内存分配器的基本原理
动态内存分配器维护一个进程的虚拟内存区域,称为堆。不失一般性地,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向更高的地址生长。对于每个进程,内核维护一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显示执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
- 显示分配器,要求应用显式的释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。
- 隐式分配器,另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。注入Lisp、ML以及Java之类的高级语言就依赖垃圾收集来是释放已分配的块。
7.9.2 带边界标签的隐式空闲链表分配器原理
对于带边界标签的隐式空闲链表分配器,一个块是由一个字的头部、有效载荷、可能的一些额外的填充,以及在块的结尾处的一个字的脚部组成的。头部编码了这个块的大小(包括头部、脚部和所有的填充),以及这个块是已分配的还是空闲的。如果强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低三位总是零。因此只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,用其中的最低位(已分配位)来指明这个快是已分配的还是空闲的。
头部后边就是应用调用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。
我们将堆组织为一个连续的已分配块和空闲块的序列,这种结构称为隐式空间链表,空闲块通过头部中的大小字段隐含地连接着。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。我们需要某种特殊标记的结束块,可以是一个设置了已分配位而大小为零的终止头部。
关于分配器对合并的实现,我们称想要释放的块为当前快。合并(内存中的)下一个空闲块简单而高效。关于合并前面的块,Knuth提出了一种通用的技术,叫做边界标记,允许在常数时间内进行对前面块的合并。如图所示:
图7.10:使用边界标记的堆块的格式
在每个块的结尾处添加一个脚部(footer,边界标记),其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。
考虑当分配器释放当前块时所有可能存在的情况:
- 前面的块和后面的块都是已分配的。
- 前面的块是已分配的,后面的块是空闲的。
- 前面的块是空闲的,而后面的块是已分配的。
- 前面的后后面的块都是空闲的。
在情况1中,两个邻接的块都是已分配的,不可能合并。当前块的状态简单地从已分配变成空闲。情况2中,当前块与后面的块合并。用当前块和后面块的大小的和来更新当前块的头部和后面块的脚部。在情况3中,前面的块和当前块合并。用两个块大小的和来更新前面块的头部和当前块的脚部。在情况4中,要合并所有三个块形成一个单独的空闲块,用三个块大小的和来更新前面块的头部和后面块的脚部。在每种情况中,合并都是在常数时间内完成的。
图7.11展示了如何用隐式空闲链表来组织堆:
图7.11:用隐式空闲链表来组织堆。阴影部分是已分配块,没有引用的部分是空闲块。头部标记为(大小(字节)/已分配位)
假设块的组织形式如图7.10所示,将堆组织为一个连续的已分配块和空闲块的序列,如图7.11所示。
第一个字是一个双字边界对齐的不使用的填充字。填充后面紧跟着一个特殊的序言块(prologue block),这是一个8字节的已分配块,只由一个头部和一个脚部组成。序言块是在初始化时创建的,并且永不释放。在序言块后紧跟的是零个或者多个由malloc或者free调用创建的普通块。
我们称这种结构为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。我们需要某种特殊标记的结束块,即一个设置了已分配位而大小为零的终止头部。
隐式空闲链表的优点是简单。显著缺点是任何操作的开销,例如放置分配的块,要求对空闲链表进行搜索,该搜索所需时间与堆中已分配块和空闲块的总数呈线性关系。
7.9.3 显示空间链表的基本原理
一种比隐式空间链表更好的方法是将空闲块组织为某种形式的显式数据类型。根据定义,程序不需要一个空闲块的主题,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,如下图:
图7.12:使用双向空闲链表的堆块的格式
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。
7.10本章小结
本章主要梳理了hello运行过程有关的存储管理的知识。阐述了hello的存储器地址空间的有关概念,讨论了逻辑地址、线性地址、虚拟地址、物理地址的相关知识,并结合hello的运行介绍了Intel的段式内存管理与页式内存管理,特别是TLB与四级页表支持下的虚拟地址到物理地址的变换,以及三级缓存支持下的物理内存访问。对hello进程fork与execve时的内存映射以及缺页故障处理进行了具体讨论,并简要介绍了动态存储分配管理的有关内容。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
一个Linux文件就是一个m个字节的序列:
B0,B1,…,Bk,…,B_(m-1)
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O接口操作
Linux利用Unix I/O接口对所有的输入和输出已一种统一且一致的方式来执行:
- 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回以恶搞小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个操作符。
- Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符位0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
- 改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够执行seek操作,显式地设置文件的当前位置为k。
- 读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k>=m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF信号”。
类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。组为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2 Unix I/O接口函数
1. 进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的,函数声明如下:
int open(char *filename, int flags, mode_t mode);
返回:若成功则为新文件描述符,若出错为-1。
open函数将filename转化为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件:
- O_RDONLY: 只读
- O_WRONLY:只写
- O_RDWR:可读可写
flags参数也可以是一个或者更多位掩码的或,为写提供给一些额外的指示:
- O_CREAT:如果文件不存在,就创建它的一个截断的(空)文件。
- O_TRUNC:如果文件已经存在,就截断它。
- O_APPEND:在每次写操作前,设置文件位置到文件的结尾处。
mode参数指定了新文件的访问权限位。作为上下文的一部分,每个进程都有一个umask,它是通过调用umask函数来设置的。当进程通过带某个mode参数的open函数调用来创建一个新文件时,文件的访问权限位被设置为mode & ~umask。
2. 进程通过调用close函数关闭一个打开的文件。close函数声明如下:
int close(int fd);
返回:若成功则为0,若出错则为-1。
关闭一个已关闭的描述符会出错。
- 应用程序通过分别调用read和write函数来执行输入和输出。函数声明如下:
ssize_t read(int fd, void *buf, size_t n);
返回:若成功则为读的字节数,若EOF则为0,若出错为-1.
ssize_t write(int fd, const void *buf, size_t n); ‘
返回:若成功则为写的字节数,若出错则为-1.
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的时实际传送的字节数量。
write函数从内存位置buf复制之多n个字节到描述符fd的当前文件位置。
4. 通过调用lseek函数,应用程序能够显式地修改当前文件的位置。函数声明如下:
off_t lseek(int handle, off_t offset, int fromwhere);
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; } |
形参列表里有一个token:...这个是可变形参的一种写法。当传递参数的个数不确定时,就可以用这种方式来表示。很显然,我们需要一种方法,来让函数体可以知道具体调用时参数的个数。
va_list的定义:typedef char *va_list, 这说明它是一个字符指针。其中的: (char*)(&fmt) + 4) 表示的是...中的第一个参数。
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
write函数将buf中的i个元素写到终端。s
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
研究printf的实现,首先来看看printf函数的函数体:
int getchar(void) { static char buf[BUFSIZ]; static char* bb=buf; static int n=0; if(n==0) { n=read(0,buf,BUFSIZ); bb=buf; } return(--n>=0)?(unsigned char)*bb++:EOF; } |
getchar函数返回值为一个int类型的整数,为用户输入的第一个字符ASCII码,如出错返回-1, 且将用户输入的字符重新显示到屏幕。
当某个程序调用getchar函数时,程序等待用户按键,用户输入的字符一直存放在键盘缓冲区直到用户按下回车(回车符同样保存到键盘缓冲区)。之后,getchar从标准输入流STDIO中读入一个字符,并据此设定返回值,决定是否将用户输入的字符回显到屏幕。如果用户在键入回车符前输入多个字符,其余字符仍然保留在键盘缓冲区中,等待后续处理。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章围绕hello的Linux IO管理简要介绍了Unix I/O接口及其部分函数,并对printf和getchar函数的具体实现做了简要分析。
结论
从编程者写下hello.c程序的第一行代码,到操作系统与低层硬件协同调度在屏幕上打印出编程者期望的“hello”字符串,hello在计算机系统中经历了短暂而精彩的一段旅途。
hello最初是一个C语言源程序hello.c,既一个编程者完成的program。
预处理阶段,完成对hello.c中带#的指令解析,将声明的头文件包含进来,将宏定义展开,进行条件编译、行控制等操作,生成hello.i文件。
编译阶段,编译器根据C语言程序到汇编指令的翻译规则将hello.i文件中的语句翻译为汇编代码,得到汇编文件hello.s。
汇编阶段,汇编器将hello.s中的汇编指令一一翻译为对应的二进制机器级指令,为各个符号引用生成所需的重定向信息,得到可重定向目标hello.o文件。
链接阶段,链接器解析hello.o中引用的内部、外部符号,处理重定位信息,为hello.o找到它需要的文件模块和外部链接库,生成可执行目标文件hello。
到了这里,hello完成了从一个C语言程序文件到可执行目标文件的华丽蜕变。接下来,随着shell命令行中./hello运行指令的敲入,hello开始了它真正的表演!
作为父进程,shell-bash进程调用fork函数,为hello创建进程——这将是它接下来的绽放舞台。随后在这个创建出的“舞台“进程里,execve函数被调用,操作系统删除原来的进程内容,加载器将hello文件中的代码和数据从磁盘复制到内存中,hello进程得到自己的虚拟内存空间,然后通过跳转到程序的第一条指令或入口点来运行该程序。
运行hello时,内存管理单元MMU、翻译后备缓冲器TLB、多级页表机制、三级cache同舟共济,完成对地址的请求。
异常处理机制保证了hello对异常信号的处理,使程序平稳运行;Unix I/O让程序能够与文件进行交互。
结束阶段,当hello运行完毕,shell父进程回收hello进程,内核删除为hello进程创建的所有数据结构,hello最终结束了它的演出。
Hello程序的运行历程,既是一个Program-to-process的过程,也是一个Zero-to-Zero的过程。而这正如计算机系统的发展历史:从无到有,从0到1。如果没有计算机低层硬件系统,软件层面的逻辑和设计就无从谈起;如果没有操作系统联合低层硬件提供的抽象,程序员在开发软件的时候就必须陷入复杂的硬件实现细节。这将是一件可怕的事情,而且大量的精力花费在这个重复的、没有创造性的工作上也使得程序员无法集中精力在更具有创造性的程序设计工作上去。操作系统将硬件细节与程序员隔离开来,使得计算机成为一种简单的,高度抽象的可以与之打交道的设备。
通过对计算机系统的初步学习,我深刻体会到计算机系统设计之精巧,考虑之全面。为了解决速度快的设备存储小、存储大的设备慢的不平衡,计算机系统的设计者们设计了高速缓存来作为更底层的存储设备的缓存,大大提高了CPU访问主存的速度;为了应对一切可能出现的实际情况,工程师们设计出一系列的满足不同情况的策略,比如写回和直写,写分配和非写分配,直接映射高速缓存和组相连高速缓存等等。计算机系统的设计与实现凝聚着无数聪明大脑的智慧,其中的奥秘值得我们每个人深入地探寻。
列出所有的中间产物的文件名,并说明其作用。
中间产物文件名称 | 文件作用 |
hello.i | hello.c预处理之后的文本文件 |
hello.s | hello.i编译后的汇编文件 |
hello.o | hello.s汇编之后的可重定位目标文件 |
hello | hello.o与其他组件链接之后的可执行目标文件 |
helloExe.d | Hello反汇编文件 |
hello.d | Hello.o反汇编文件 |
Hello_elf.txt | ELF格式下的hello文件 |
Hello_o_elf.txt | ELF格式下的hello.o文件 |
参考文献
[1] https://blog.csdn.net/u011210147/article/details/54092405
[2] https://segmentfault.com/a/1190000016664025
[3] https://blog.csdn.net/weixin_28761455/article/details/112864431
[4] https://blog.csdn.net/weixin_42048417/article/details/80358390
[5] https://blog.csdn.net/guo_guo_cai/article/details/78499477
[6] https://blog.csdn.net/mzjmzjmzjmzj/article/details/84713351
[7] https://www.cnblogs.com/pianist/p/3315801.html
[8] 深入理解计算机系统 第三版 机械工业出版社