目录快速链接

  • 摘要
  • 第一章 概述
    • 1.1Hello简介
    • 1.2环境与工具
    • 1.3中间结果
    • 1.4本章小结
  • 第二章 预处理
    • 2.1预处理的概念和作用
    • Ubuntu下的预处理命令
    • 2.3Hello的预处理结果分析
    • 2.4本章小结
  • 第三章 编译
    • 3.1编译的概念和作用
    • 3.2Ubuntu下编译的命令
    • 3.3Hello的编译结果分分析
      • 3.3.1工作伪指令
      • 3.3.2数据格式和寄存器结构
      • 3.3.3数据
      • 3.3.4数据传送指令
      • 3.3.5压入和弹出栈数据
      • 3.3.6算术操作
      • 3.3.7逻辑操作
      • 3.3.8条件控制
      • 3.3.9跳转语句
      • 3.3.10函数调用
    • 3.4 本章小结
  • 第四章 汇编
    • 4.1汇编的概念与作用
    • 4.2在Ubuntu下汇编的命令
    • 4.3可重定位目标ELF格式
      • 4.3.1ELF头
      • 4.3.2Section头
      • 4.3.3符号表
      • 4.3.4可重定位段信息
    • 4.4Hello.o的结果解析
    • 4.4本章小结
  • 第五章 链接
    • 5.1链接的概念与作用
    • 5.2在Ubuntu下链接的命令
    • 5.3可执行目标文件hello的ELF格式
      • 5.3.1ELF头
      • 5.3.2Section头
      • 5.3.3符号表
      • 5.3.4可重定位段信息
    • 5.4hello虚拟地址空间
    • 5.5连接的重定位过程分析
    • 5.6Hello的执行流程
    • 5.7Hello的动态链接分析
    • 5.8本章小结
  • 第六章 hello进程管理
    • 6.1进程的概念与作用
    • 6.2简述壳Shell-bash的作用与处理流程
    • 6.3Hello的fork进程创建过程
    • 6.4Hello的execve过程
    • 6.5Hello的进程执行
      • 6.5.1逻辑控制流
      • 6.5.2并发流与时间片
      • 6.5.3内核模式和用户模式
      • 6.5.4上下文切换
      • 6.5.5Hello的执行
    • 6.6hello的异常与信号处理
      • 6.6.1异常
      • 6.2.2信号
    • 6.7本章小结
  • 第七章 hello的存储管理
    • 7.1hello的存储器地址空间
    • 7.2Intel逻辑地址到线性地址的变换-段式管理
    • 7.3Hello的线性地址到物理地址的变换-页式管理
    • 7.4TLB与四级页表支持下的VA到PA的变换
      • 7.4.1TLB加速地址翻译
      • 7.4.2四级页表支持下缓存
    • 7.5三级Cache支持下的物理内存访问
    • 7.6 hello进程fork时的内存映射
    • 7.7hello进程execve时的内存映射
    • 7.8缺页故障与缺页中断处理
      • 7.8.1缺页故障
      • 7.8.2缺页故障处理
    • 7.9动态存储分配管理
      • 7.9.1堆
      • 7.9.2隐式空闲链表
      • 7.9.2显式空闲链表
    • 7.10本章小结
  • 第八章 hello的IO管理
    • 8.1Linux的IO设备管理方法
    • 8.2 简述Unix IO接口及其函数
      • 8.2.1函数open()和opennat()
      • 8.2.2creat()函数
      • 8.3.3lseek()函数
      • 8.3.4read()函数
      • 8.3.5write()函数
    • 8.3 printf的实现分析
    • 8.4 getchar的实现分析
    • 8.5本章小结
  • 结论
  • 附件

摘要

HelloWorld是每一个程序员梦开始的地方,而这篇文章就跟踪采访了Hello的程序人生。Hello从最开始的C语言源代码,会先经过他人生的第一步——预处理;接着会继续变化,从一个青涩的.i文件变化成更能让机器理解的.s汇编文件;随着Hello的一步一步成长,他会经过汇编、链接等一系列的动作处理,变成一个可执行文件。这也标志着它即将迈入机生的一个新阶段。
在下一个阶段中,它会和操作系统进行交谈,操作系统像它的伯乐一样,给他开辟进程,提供虚拟内存和独立的地址空间;给它划分时间片、逻辑控制流来让它操作系统上畅游,最后随着进程的结束,停止这短暂而辉煌的机生。
本文从一个hello.c源代码开始,跟随hello的脚步,详细说明Hello成长生涯的每一步变化。

关键词:预处理,编译,汇编,链接,进程管理,虚拟地址,存储管理,IO操作

第一章 概述


本章描述的Hello程序的机生剪影,在Hello寿终正寝前它脑海中的跑马灯


1.1Hello简介

HelloWorld想必是每一个程序员的启蒙小怪,当我们打开VSCode,输入一行行代码,按下编译运行后,我们便完成了第一次代码编写,向世界问好。这个看似非常简单的程序实则是早先第一个实现的P2P。

P2P: 全称为From Program to Progress,从项程序到进程。这个看似简单的过程需要经过预处理、编译、汇编、链接等一系列的复杂动作才可以生成一个可执行目标文件。在运行时,我们打开Shell,等待我们输入指令。通过输入./hello,使Shell创建新的进程用来执行hello。 操作系统会使用fork产生子进程,然后通过execve将其加载,不断进行访存、内存申请等操作。最后,在程序结束返回后,由父进程或祖先进程进行回收,程序结束。

图1 hello的编译运行过程

020:全称为From 0 to 0,从无到终。Hello的出生是由操作系统进行存储管理、地址翻译、内存访问,通过按需页面调度来开始这段生命。父进程或祖先进程的回收也标志着它生命的结束。

这两个部分便是Hello从无到有,从始到终的白描。

1.2环境与工具

1.2.1硬件环境
\qquad 处理器:Intel CORE i7 10th GEN
\qquad 系统类型:X64 CPU; 2GHz; 16G RAM; 256G HD Disk
1.2.2软件环境
\qquad Windows10家庭和学生版
\qquad VMware Workstation pro2022
\qquad Ubuntu20.04
1.2.3开发与调试工具
\qquad Visual Stdio 2019; ClodeBlocks; gedit+gcc;VSCode

1.3中间结果

编写这篇文章生成的中间结果文件以及文件内容如下表格所示

文件名称作用
hello.c储存hello程序源代码
hello.i源代码经过预处理产生的文件(包含头文件等工作)
hello.shello程序对应的汇编语言文件
hello.o可重定位目标文件
hello_o.shello.o的反汇编语言文件
hello.elfhello.o的ELF文件格式
hello二进制可执行文件
hello.elf可执行文件的ELF文件格式
hello.s可执行文件的汇编语言文件

1.4本章小结

本章简述了Hello程序的一生,概括了从P2P到020的整个过程,可以发现整个计算机系统的学习和Hello的生命轨迹基本重合一致。本章还简要说明了实验的软、硬件环境以及编译处理工具,是整体文章的布局脉络。

第二章 预处理


本章是介绍的是Hello出生后走的第一个里程碑事件,前进一小步


2.1预处理的概念和作用

预处理的概念:
预处理顾名思义预先处理,是指在程序代码被翻译为目标代码的过程中,生成二进制文件之前的过程。这个过程一般包括包含头文件等工作。

预处理的作用:
预处理为编译做准备工作,主要进行代码的文本替换工作,它会根据预处理指令来修改源代码。在源代码中,以#开头的代码段即为预处理工作的对象。有以下几个功能:

  • 头文件包含:例如 #include ,即为包含标准输入输出头文件。
  • 条件编译指令:相当于一个选择装置,可以让程序员通过定义不同的宏(宏定义)来决定对哪些代码进行处理,而那些代码要被忽略。以下为一些条件编译指令简要介绍:
指令名称功能
#if如果判断条件为真,则编译下面的代码
#ifdef判断是否宏定义,若是,则编译下面的代码
#ifndef判断是否宏定义,妥否,则编译下面的代码
#elifelse语句,若前置条件判断为假此条为真,则编译下面的代码
#endif结束一段if…else的条件编译指令判断
  • 特殊符号处理:预编译程序可以识别一些特殊的符号。 例如在源程序中出现的LINE标识将被解释为当前行号(十进制数),FILE则被解释为当前被编译的C源程序的名称。

Ubuntu下的预处理命令

Linux系统中使用如下指令进行预处理工作

gcc hello.c -E -o hello.i        ##-E为预处理 -o为对命令产生文件命名

图1 .i文件生成

2.3Hello的预处理结果分析

.i文件可以作为一个文本文档被打开,我选择使用Vim编辑器查看这个生成文件

图2 .i文件内容

可以看到.i文件相比于.c源文件多了超级多的内容,乍一看迷迷糊糊,但仔细阅读还是可以分辨出这些都是可阅读的C语言语句。对源文件中定义的宏进行了展开,将头文件中的内容包含到这个文件中。例如上图中getsubopt、getloadavg等函数的定义,以及一些结构体类型的声明。

2.4本章小结

这一章主要介绍了hello.c程序预处理方面的内容,包括预处理的概念和作用,以及进行了hello.c文件的预处理和结果展示。预处理作为编译运行的第一步是非常重要的一部分,查看.i文件会让我们更加直观的感受到预处理前后源文件的变化。

第三章 编译


本章可以说非常重要,因为接触了新的语言汇编语言,这种语言对Hello的机生也是重要一步。


3.1编译的概念和作用

编译的概念:
编译是利用编译程序从源语言编写的源程序产生目标程序的过程,也是用编译程序产生目标程序的动作。也就是说编译器会将通过预处理产生的.o文件翻译成一个后缀为.s的汇编语言文件,编译就是从高级程序设计语言到机器能理解的汇编语言的转换过程。

编译的功能:
其实从概念中也可以直接提炼出,编译的功能就是产生汇编语言文件,并交给机器。除此之外,编译器还有一些其他功能,例如语法检查等。

3.2Ubuntu下编译的命令

Linux中使用如下指令进行编译

gcc hello.i -S -o hello.s        ##-S为编译,-o为命令产生的文件命名

图1 hello.s文件

3.3Hello的编译结果分分析

同样,使用Vim文本编辑器打开hello.s文件,乍一看是一堆看不懂的东西,实际这就是汇编代码,接下来将对hello.s中出现的汇编指令进行介绍。

图2 hello.s文件内容

3.3.1工作伪指令

我们阅读hello.s文件,发现第一部分的汇编代码有一部分是以.作为开头的代码段。这些代码段是指导汇编器和连接器工作的伪指令。这段代码对我们来说没有什么意义,通常可以忽略这些代码,但对汇编器和连接器缺是十分重要的。这些指导伪代码的含义如下表

图3 伪代码段

伪指令含义
.file声明源文件(此处为hello.c)
.text声明代码节
.section文件代码段
.rodataRead-only只读文件
.align数据指令地址对齐方式(此处为8对齐)
.string声明字符串(此处声明了LC0和LC1)
.globl声明全局变量
.type声明变量类型(此处声明为函数类型)

3.3.2数据格式和寄存器结构

在解析下面的汇编代码之前,我们需要先了解数据存储的格式以及寄存器的存储结构,Intel数据类型令16bytes为字,21bytes为双字,各种数据类型的大小一级寄存器的结构如下所示:

变量类型Intel数据类型汇编代码后缀大小(字节)
char字节b1
shortw2
int双字l4
long四字q8
char *四字q8
float单精度s4
double双精度l8

图4 寄存器结构

3.3.3数据

立即数:
立即数在汇编代码中的呈现形式最为简单易辨认。即数顾名思义,是直接显式使用的一类数据,在汇编代码中通常以$美元符号作为标识。例如下图中的例子,表示的含义是比较(cmp compare)寄存器中的值和4,根据结果设置条件码。

寄存器变量:
在汇编代码中,指令后面出现过许许多多的形如-32(%rbp)形式的代码声明,其实这些就是寄存器存储的变量,通过特定的寻址方式进行引用。例如下图中的例子,表示的就是将寄存器%edi中存储的值,加载到以现在栈指针%rbp指向的位置基础上,减去32所对应的地址中去。类似的加载使用的例子在这里面不胜枚举,就不一一赘述了。

字符串:
.LC0和.LC1作为两个字符串变量被声明。而在.LC0中出现的\347\224等是因为中文字符没有对应的ASCII码值无法直接显式显示,所以这样的字符方式显示。而且这两个字符串都在.rodata下面,所以是只读数据。随后有两句leaq指令,这个指令为加载有效地址,相当于转移操作。

图5 字符串数据

3.3.4数据传送指令

数据传送指令无疑是整个程序运行过程中使用的最频繁的指令。汇编代码中数据移动使用MOV类,这些指令把数据从源位置复制到目的位置,不做任何变化。MOV类中最常用的有四条指令:movb、movw、movl、movq,这些指令执行相同的操作,区别在于他们所移动的数据大小不同,如下表所示。指令的最后一个限制字符必须和寄存器所对应的数据大小保持一致。

指令效果描述
MOV \qquad S,DD←S传送
movb传送字节
movw传送字
movl传送双字
movq传送四字

例如下图中第一句汇编代码,表示意思为将寄存器%rax中存储的值传送到寄存器%rdx中。

图6 数据传送指令

除此之外,还有一些指令会先将数据进行零扩展或者符号扩展之后再进行传送。典型实例就是MOVZ(零扩展)和MOVS(符号扩展),因为比价少见并且在hello.s中么有相应的体现,就不展开说明了。

3.3.5压入和弹出栈数据

压入数据使用指令pushq,弹出数据使用指令popq,他理解起来其实可以看做一个由两句指令组成的结合体。我们拿popq指令作为例子来说明。

popq %rax 等价于 addq $8 %rsp + movq %rbp, (%rsp)

指令效果描述
pushq SR[%rsp]←R[%rsp] – 8;
M[R[%rsp]]←S
将四字压入栈
popq DD←M[R[%rsp]];
R[%rsp]←R[%rsp] + 8
将四字弹出栈

3.3.6算术操作

算术运算也是十分常用的一些指令类,同样的,每种算术运算指令的末尾也有b、w、l、q(例如addb)来限制数据的大小。

指令效果描述
INC D
DEC D
NEG D
NOT D
D←D + 1
D←D – 1
D← -D
D← ~D
加1
减1
取负
取补
ADD S, D
SUB S, D
IMUL S, D
D←D + S
D←D – S
D←D * S


addq$16, %rax        ##加法操作

3.3.7逻辑操作

逻辑操作常见的有两类,一类是加载有效地址,一类是位移操作。加载有效地址指令类似于MOV类指令,它的作用是将有效地址写入到目的操作数,相当于C语言中大家所熟知的取址操作,可以为以后的内存引用产生指针。位移操作顾名思义就是将二进制数进行整体左移或者右移。

leaq.LC1(%rip), %rdi        ##加载有效地址

3.3.8条件控制

汇编语言中,一些指令会改变条件码,例如cmpl指令和setl指令。这种指令一般不会单独使用,会根据比较结果进行后续操作。例如下图,将寄存器中存储的值和立即数4进行比较,设置条件码,然后进行跳转或者其他操作。

cmpl$4, -20(%rbp)        ##比较,设置条件码

3.3.9跳转语句

跳转指令会根据条件码当前的值来进行相应的跳转。比较常见的是直接跳转,在hello.s中也有体现,如下图所示。cmpl指令判断寄存器中的值和立即数4的大小关系,设置条件码,再进行je。je的含义是jump if equal,也就是说,如果此时的条件码所表示含义为相等,则会跳转到相应的.L2指令行。因此,跳转指令用来实现条件分支。

3.3.10函数调用

call指令用来进行函数的调用。如下图所示的示例,call调用了getchar函数。它会先将函数的返回地址压入运行时栈中,之后跳转到相应的函数代码段进行执行。执行结束通过ret指令返回。

3.4 本章小结

本章对汇编指令做了以下简单的介绍,以及查看了Hello的机器级实现。经过简单的思考,我们便可以发现这些汇编指令和C语言代码语句之间的对应关系。同时,根据一个程序的汇编代码我们也可以翻译出相应的C语言程序的大致样貌。

第四章 汇编


本章介绍了Hello是怎么让大家更能理解它,以及如何找到小伙伴的一些事


4.1汇编的概念与作用

汇编的概念: 汇编程序是指把汇编语言书写的程序翻译成与之等价的机器语言程序的翻译程序。汇编程序输入的是用汇编语言书写的源程序,输出的是用机器语言表示的目标程序。也就是说,汇编器会把输入的汇编指令文件重新打包成可重定位目标文件,并将结果保存成.o文件。它是一个二进制文件,包含程序的指令编码。

汇编的作用: 它的作用也很明晰了,就是完成从汇编语言文件到可重定位目标文件的转化过程。

4.2在Ubuntu下汇编的命令

Linux系统中使用如下命令进行汇编

gcc hello.s -c -o hello.o        ##-c为汇编,-o为命令产生的文件命名

图1 hello.o文件产生

4.3可重定位目标ELF格式

4.3.1ELF头

.o文件为目标文件,相当于Windows中的.obj后缀文件,因此直接使用Vim或者其他文本编辑器查看会出现一大堆乱码。那么我们选择查看可重定位目标文件的elf形式,使用命令readelf -h hello.o查看ELF头,结果如下

图2 ELF头信息

ELF头以一个16字节的序列(Magic,魔数)开始,这个序列描述了生成文件的系统的字的大小和字节顺序。ELF头剩下部分的信息包含帮助连接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型和机器类型等。例如上图中,Data表示了系统采用小端法,文件类型Type为REL(可重定位文件),节头数量Number of section headers为13个等信息。

4.3.2Section头

使用命令readelf -S hello.o查看节头,结果如下

图3 Section头信息

夹在ELF头和节头部表之间的都为节,包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。各部分含义如下:

名称包含内容含义
.text已编译程序的机器代码
.rodata只读数据
.data已初始化的全局和静态C变量
.bss未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量
.symtab一个符号表,存放一些在程序中定义和引用的函数和全局变量的信息
.rel.text一个.tex节中位置的列表
.rel.data被模块引用或定义的所有全局变量的重定位信息
.debug一个调试符号表
.line原始C源程序中的行号和.text节中机器指令之间的映射
.strtab一个字符串表(包括.symtab和.debug节中的符号表)

4.3.3符号表

使用命令readelf -s hello.o查看符号表,结果如下

图4 符号表信息

这其中,Num为某个符号的编号,Name是符号的名称。Size表示他是一个位于.text节中偏移量为0处的146字节函数。Bind表示这个符号是本地的还是全局的,由上图可知main函数名称这个符号变量是本地的。

4.3.4可重定位段信息

使用readelf -r hello.o查看可重定位段信息,结果如下

图5 可重定位段信息

offset是需要被修改的引用的节偏移,Sym.标识被修改引用应该指向的符号。Type告知连接器如何修改新的引用,Addend是一个有符号常数,一些类型的重定位要使用它对被修改的引用的值做偏移调整。

4.4Hello.o的结果解析

使用objdumo -d -r hello.o命令对hello.o可重定位文件进行反汇编,得到的反汇编结果如下图

图6 hello.o的反汇编结果

看到hello.o的反汇编文件我们很是熟悉,它使用的汇编代码和hello.s汇编文件的汇编代码是一样的,但是在这反汇编文件的字里行间中,也混杂着一些我们相对陌生的面孔,也就是机器代码

这些机器代码是二进制机器指令的集合,每一条机器代码都对应一条机器指令,到这儿才是机器真正能识别的语言。每一条汇编语言都可以用机器二进制数据来表示,汇编语言中的操作码和操作数以一种相当于映射的方式和机器语言进行对应,从而让机器能够真正理解代码的含义并且执行相应的功能。机器代码与汇编代码不同的地方在于:

  1. 分支跳转方面:汇编语言中的分支跳转语句使用的是标识符(例如je .L2)来决定跳转到哪里,而机器语言中经过翻译则直接使用对应的地址来实现跳转。

  2. 函数调用方面:在汇编语言.s文件中,函数调用直接写上函数名。而在.o反汇编文件中,call目标地址是当前指令的下一条指令地址。这是因为hello.c中调用的函数都是共享库中的函数,需要等待链接之后才能确定响应函数的地址。因此,机器语言中,对于这种不确定地址的调用,会先将下一条指令的相对地址设置为0,然后再.rela.text节中为其添加重定位条目,等待链接时确定地址。

4.4本章小结

本章介绍了Hello从hello.s到hello.o的过程。这一节中对hello.o的ELF头,Section头以及符号表进行了分析,可以看到Hello的跟深处的信息。本节还对hello.o的反汇编文件进行了解析,比较了相对于hello.s文件.o文件是怎么让机器更加理解的。

第五章 链接


本站介绍Hello先生是如何结实与他志同道合的狐朋狗友的


5.1链接的概念与作用

连接的概念:
链接是将各种代码和数据片段和搜集并组成成为一个但以文件的过程,这个文件可被夹在到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被记载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。

连接的作用:
将程序调用的各种静态链接库和动态连接库整合到一起,完善重定位目录,使之成为一个可运行的程序。

5.2在Ubuntu下链接的命令

Linux系统hello.c使用如下指令进行链接

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

图1 hello文件产生

5.3可执行目标文件hello的ELF格式

同上一节一样,我们分别查看hello文件的ELF头,节头部表,符号表。

5.3.1ELF头

使用命令readeld -h hello查看hello的ELF头

图2 ELF头

可以看到文件的Type发生了变化,从REL变成了EXEC(Executable file可执行文件),节头部数量也发生了变化,变为了27个。

5.3.2Section头

使用命令readeld -S hello查看节头部表信息

图3 节头部表信息

节头部表对hello中所有信息进行了声明,包括了大小(Size)、偏移量(Offset)、起始地址(Address)以及数据对齐方式(Align)等信息。根据始地址和大小,我们就可以计算节头部表中的每个节所在的区域。

5.3.3符号表

使用命令readelf -s hello查看符号表信息

图4 符号表信息

可以发现经过链接之后符号表的符号数量陡增,说明经过连接之后引入了许多其他库函数的符号,一并加入到了符号表中。

5.3.4可重定位段信息

使用命令readeld -r hello查看可重定位段信息

图5 可重定位段信息

5.4hello虚拟地址空间

在edb中打开可执行文件hello,可以看到hello虚拟地址空间的起始地址为0x401000,结束地址为0x401ff0。

图6 hello起止虚拟地址

根据5.3.2节里面的Section头部表,我们可以找到对应的节的其实空间对应位置,例如.init初始化节,起始位置地址为0x401000在edb中有其对应位置

图7 .init和其对应

5.5连接的重定位过程分析

使用命令objdump -d -r hello查看hello可执行文件的反汇编条目

图8 hello反汇编(截取)

可以观察到hello的反汇编代码与hello.s的返沪编代码在结构和语法上是基本完全相同的,只不过hello的反汇编代码多了非常多的内容,我们通过比较不同来看一下区别:

  1. hello反汇编代码中函数调用时不再仅仅储存call当前指令的下一条指令,而是已经完成了重定位,调用的相应函数已经有对应的明确的虚拟地址空间
  2. hello反汇编代码中肉眼可见的多了很多节,这些陌生的节都是经过链接之后加入进来的。例如.init节就是程序初始化需要执行的代码所在的节,.dynamic节是存放被ld.so调用过的 动态链接信息的节等等。

重定位的过程分为两大步:

  1. 重定位节和符号定义:在这一步中,连接器将所有相同类型的节合并成为同一类型的新的聚合节。例如,来自所有输入模块的.data节全部被合并成一个节,这个节成为输出的可执行目标文件的.data节。
    2.** 重定位节中的符号引用:**在这一步中,连接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时地址。要执行这一步,连接器依赖于可重定位条目,及5.3节中分析的那些数据。

5.6Hello的执行流程

程序名称程序地址
_start0x4010f0
_libc_start_main0x7ffff7de2f90
__GI___cxa_atexit0x7ffff7e05de0
__new_exitfn0x7ffff7e05b80
__libc_csu_init0x4011c0
_init0x401000
_sigsetjump0x7ffff7e01bb0
main0x401125
do_lookup_x0x7ffff7fda4c9
dl_runtime_resolve_xsavec0x7ffff7fe7bc0
_dl_fixup0x7ffff7fe00c0
_dl_lookup_symbol_x0x7ffff7fdb0d0
check_match0x7ffff7fda318
strcmp0x7ffff7fee600

5.7Hello的动态链接分析

动态共享库是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,兵和一个程序链接起来,这个过程就是动态链接。

把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。

.plt:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。

.got:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。

hello在动态连接器加载前后的重定位是不一样的,在加载之后才进行重定位

图9 重定位后.init

5.8本章小结

本章介绍了连接的过程。解释了程序是如何进行重定位的操作,把相同类型的数据放在同一个节的过程,同时也说明了链接的工作原理。

第六章 hello进程管理


本章讲述的是Hello已经可以独当一面,独自在CPU中游走了


6.1进程的概念与作用

进程的概念:
进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。

进程的作用:
在运行一个进程时,我们的这个程序好像是系统当中唯一一个运行的程序,进程的作用就是提供给程序两个关键的抽象。一分别是独立的逻辑控制流和私有的地址空间。

6.2简述壳Shell-bash的作用与处理流程

首先首先我们需要了解一下这个Shell是什么:
Shell是命令语言解释器。他的本质是一个应用程序,它连接了用户和 Linux 内核,让用户能够更加高效、安全、低成本地使用 Linux 内核。它在操作系统的最外层,负责直接与用户进行对话,把用户的输入解释给操作系统,并处理各种各样的操作系统的输出结果,输出到屏幕反馈给用户。例如我们经常使用的Windows下的cmd命令行和Bash以及Linux中的Shell。

Shell工作时的处理流程如下:
当用户提交了一个命令后,Shell首先判断它是否为内置命令,如果是就通过Shell内部的解释器将其解释为系统功能调用并转交给内核执行;若是外部命令或实用程序就试图在硬盘中查找该命令并将其调入内存,再将其解释为系统功能调用并转交给内核执行。

6.3Hello的fork进程创建过程

在Linux系统中,用户可以通过 ./ 指令来执行一个可执行目标文件。在程序运行时,Shell就会创建一个新的进程,并且新创建的进程更新上下文,在这个新建进程的上下文中便可以运行这个可执行目标文件。fork()函数拥有一个int型的返回值。子进程中fork返回0,在父进程中fork返回子进程的Pid。新创建的进程与父进程几乎相同但有细微的差别。子进程得到与父进程虚拟地址空间相同的(但是独立的)一份副本(代码、数据段、堆、共享库以及用户栈),并且紫禁城拥有与父进程不同的Pid。

图1 进程的地址空间

6.4Hello的execve过程

当Hellol的进程被创建之后,他会调用execve函数加载并调用程序。exevce函数在被调用时会在当前进程的上下文中加载并运行一个新程序。它被调用一次从不返回,执行过程如下:

  1. 删除已存在的用户区域
  2. 映射私有区:为 hello 的代码、数据、.bss 和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的
  3. 映射共享区:比如 hello 程序与共享库 libc.so 链接
  4. 设置 PC:exceve() 做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点
  5. execve() 在调用成功的情况下不会返回,只有当出现错误时,例如找不到需要执行的程序时,execve() 才会返回到调用程序

6.5Hello的进程执行

Hello在执行过程中涉及到几个十分重要的概念,如果不提前阐述就无法很好地理解Hello在运行中的状态。

6.5.1逻辑控制流

逻辑控制流是进程给运行程序的第一个假象,它让程序看起来独占整个CPU在运行,但实际上的情况当然不会是这样的,如下图。这三个进程的运行时间不是连续的,也就是说每个进程会交替的轮流使用处理器来进行处理。每个进程执行它的流的一部分,之后可能就会被抢占,如果被抢占了的话就会被挂起进行其他流的处理。

图2 逻辑控制流

6.5.2并发流与时间片

两个流如果在执行的时间上有所重叠,那么我们就说这两个流是并发流,每个流执行一部分的时间就叫做时间分片。例如上图中,我们可以说进程A和进程B并发,进程A也和进程C并发,但是进程B和进程C就不是并发的。

6.5.3内核模式和用户模式

用户模式:
在用户模式中,进程不允许执行特权指令,例如发起一个I/O操作等,更重要的是不允许直接引用地址空间中内核区内的代码和数据。如果在用户模式下进行这样的非法命令执行,会引发致命的保护故障。

内核模式:
在内核模式下,进程的指令执行相对没有限制,这有点类似于在Linux操作系统中,是否使用sudo(SuperUser do)作为指令的前缀一样。而在内核模式下运行的进程相当于获得了超级管理员的许可。

6.5.4上下文切换

进程在运行时会依赖一些信息和数据,包括通用目的寄存器、浮点寄存器等的状态,这些进程运行时的依赖信息成为进程的上下文。而在进程进行的某些时刻,操作系统内核可以决定抢占当前的进程,并重新开始一个新的或者之前被抢占过的进程,这一过程成为调度。而抢占进程前后由于进程发生改变依赖信息也变得不同,这个过程就是上下文切换。

6.5.5Hello的执行

从Shell执行hello程序时,会先处于用户模式运行。在运行过程中,由内核不断进行上下文切换,配合调度器,与其他进程交替运行。如果在运行过程中收到了信号,那么就会陷入到内核中进入内核模式运行信号处理程序,之后再进行返回。

6.6hello的异常与信号处理

6.6.1异常

异常可以分为四类:中断、陷阱、故障和终止,各类异常产生原因和一些行为总结成下表:

类别原因异步/同步返回行为
中断来自I/O设备的信号异步总是返回到下一条指令
陷阱有意的异常同步总是返回到下一条指令
故障潜在可恢复的错误同步可能返回到当前指令
终止不可恢复的错误同步不会返回

中断:
在进程运行的过程中,我们施加一些I/O输入,比如说敲键盘,就会触发中断。系统会陷入内核,调用中断处理程序,然后返回。

图3 中断

陷阱:

陷阱和系统调用是一码事,用户模式无法进行的内核程序,便通过引发一个陷阱,陷入内核模式下再执行相应的系统调用。

图4 陷阱

故障:
常见的故障就是缺页故障。在hello中如果我们使用的虚拟地址相对应的虚拟页面不在内存中,就会发生此类缺页故障。故障是可能会被修复的,例如缺页故障触发的故障处理程序,会按需调动页面,再返回到原指令位置重新执行。但对于无法恢复的故障则直接报错退出。

图5 故障

终止:

如果遇到一个硬件错误,那对于幼小的hello来说是相当致命的,导致结果就是触发致命错误,终止hello的运行。

图6 终止

6.2.2信号

信号可以被理解为一条小消息,他通知进程系统中发生了一个某种类型的事件。每种信号类型都对应于某种系统事件,它提供了一种机制,通知用户进程发生了这些异常。Linux中信号有如下种类:

图7 Linux信号

SIGSTP:当hello在运行时我们在键盘上按下Ctrl-z,这个操作就会向进程发送SIGSTP信号,进入信号处理程序。可以从后台工作信息看到hello被暂时挂起,进程号为3。如果想继续这个进程可以使用命令fg %3

SIGINT:输入Ctrl-z进程就会被终止。

6.7本章小结

本章介绍了Hello进程如何运行,以及进程相关一些知识和概念。更加清晰地了解了进程对于程序运行所提供的重要假象:逻辑控制流和私有地址空间。

第七章 hello的存储管理


在这一章中,Hello怎么和它的伯乐——操作系统进行交互交流


7.1hello的存储器地址空间

在CPU中当然不是仅仅只有hello一个进程,而是许多进程共享CPU和主存资源。那么为了使内存管理更加高效,操作系统提出了一种十分重要的抽象,即虚拟内存。它有几处有点:1)可以有效使用主存;2)可以简化内存管理;3)提供独立的地址空间。下面介绍几个概念

逻辑地址:逻辑地址是指在计算机体系结构中是指应用程序角度看到的内存单元、存储单元、网络主机的地址。逻辑地址往往不同于物理地址,通过地址翻译器或映射函数可以把逻辑地址转化为物理地址。
线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
虚拟地址:CPU启动保护模式后,程序运行在虚拟地址空间中。保护模式下,hello 运行在虚拟地址空间中,它访问存储器所用的逻辑地址。
物理地址:它是在地址总线上,以电子形式存在的,使得数据总线可以访问 主存的某个特定存储单元的内存地址。

7.2Intel逻辑地址到线性地址的变换-段式管理

Intel平台下,逻辑地址的格式为段标识符:段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。分段机制将逻辑地址转化为线性地址的步骤如下:

  1. 使用段选择符中的偏移值在GDT或LDT表中定位相应的段描述符。
  2. 利用段选择符检验段的访问权限和范围,以确保该段可访问。
  3. 把段描述符中取到的段基地址加到偏移量上,最后形成一个线性地址。
    图1 段式管理

7.3Hello的线性地址到物理地址的变换-页式管理

概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续字节大小的单元组成的数组。如下图,虚拟内存被分为一些固定大小的块,这些块称为虚拟页块。这些页块根据不同的映射状态也被划分为三种状态:未分配、为缓存、已缓存。
未分配:虚拟内存中未分配的页
未缓存:已经分配但是还没有被缓存到物理内存中的页
已缓存:分配后缓存到物理页块中的页

图2 页式管理

7.4TLB与四级页表支持下的VA到PA的变换

页表是 PTE(页表条目)的数组,它将虚拟页映射到物理页,每个 PTE 都有一个有效位和一个 n 位地址字段,有效位表明该虚拟页是否被缓存在 DRAM 中。虚拟地址分为两个部分,虚拟页号(VPN)和虚拟页面偏移量(VPO)。其中VPN需要在PTE中查询对应,而VPO则直接对应物理地址偏移(PPO)。

图3 PTE寻页

7.4.1TLB加速地址翻译

既然要经常访问页表条目,不如直接将页表条目缓存到高速缓存中,这就是TLB的基本思想。TLB译为翻译后备缓冲器,也就是页表的缓存。TLB是一个具有较高相连度的缓存,如下图。根据VPN中的TLB索引找到缓存中相应的组,根据标记(tag)找到相应的缓存行,根据设置的有效位找到对应的位置。

图4 TLB加速地址翻译

7.4.2四级页表支持下缓存

下图为Core i7使用的四级页表地址翻译

图5 Core i7四级页表

同一级页表一样,若缓存页命中,则返回PPN,以VPO作为页便宜的到地址;若未命中,则经过四级页表查询,直到找到最终的PTE,查询,返回PPE。下图为4级页表目录格式:

图6 四级页表目录格式

7.5三级Cache支持下的物理内存访问

在寻找一个虚拟地址时,CPU会优先到TLB中寻找,查看VPN是否已经缓存。如果页命中的话,就直接获取PPN;如果没有命中的话就需要查询多级页表,得到物理地址PA,之后再对PA进行分解,将其分解为标记(CT)、组索引(CI)、块便宜(CO),之后再检测物理地址是否在下一级缓存中命中。若命中,则将PA对应的数据内容取出返回给CPU;若不命中,则重复上述操作,直到找到。过程图示如下:

图7 TLBcache地址翻译

7.6 hello进程fork时的内存映射

当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。通过 fork 创建的子进程拥有父进程相同的区域结构、页表等的一份副本,同时子进程也可以访问任何父进程已经打开的文件。当 fork 在新进程中返回时,新进程现在的虚拟内存刚好和调用 fork 时存在的虚拟内存相同,当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间。

7.7hello进程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缺页故障

首先明确一下页命中的概念:虚拟内存中的一个字存在于物理内存中,即缓存命中。那不难理解,缺页故障就是虚拟内存中的字不在物理内存中,发生页不命中。例如下图中,对VP3的访问即发生缺页故障

图8 缺页故障

7.8.2缺页故障处理

如果发生了缺页故障,则触发缺页故障处理程序,这个程序会选择一个牺牲页,例如下图中的P4,将其在物理内存中删除,加入所需要访问的VP3。随后返回重新执行原指令,则页命中。这种策略称为按需页面调度

图9 缺页故障处理

7.9动态存储分配管理

首先明确动态存储分配管理的概念:在程序运行时程序员使用动态内存分配器,例如malloc获得虚拟内存。分配器将堆视为一组不同大小的 块(blocks)的集合来维护每个块要么是已分配的,要么是空闲的。

7.9.1堆

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。

图10 堆

在内存中的碎片和垃圾被回收之后,内存中就会有空余的空间被闲置出来。这些空间有时会比较小,但是积少成多,操作系统不知道怎么利用这些空间,就会造成很多的浪费。为了记录这些空闲块,采用隐式空闲链表和显式空闲链表的方法实现这一操作。

7.9.2隐式空闲链表

首先了解几个概念:
首次适配 (First fit): 从头开始搜索空闲链表,选择第一个合适的空闲块: 搜索时间与总块数(包括已分配和空闲块)成线性关系。在靠近链表起始处留下小空闲块的“碎片”。
下一次适配 (Next fit): 和首次适配相似,只是从链表中上一次查询结束的地方开始。比首次适应更快: 避免重复扫描那些无用块。一些研究表明,下一次适配的内存利用率要比首次适配低得多。
最佳适配 (Best fit): 查询链表,选择一个最好的空闲块;适配,剩余最少空闲空间。保证碎片最小——提高内存利用率,通常运行速度会慢于首次适配。

在隐式空闲链表工作时,如果分配块比空闲块小,可以把空闲块分为两部分,一部分用来承装分配块,这样可以减少空闲部分无法使用而造成的浪费。隐式链表采用边界标记的方法进行双向合并。脚部与头部是相同的,均为 4 个字节,用来存储块的大小,以及表明这个块是已分配还是空闲块。同时定位头部和尾部,是为了能够以常数时间来进行块的合并。无论是与下一块还是与上一块合并,都可以通过他们的头部或尾部得知块大小,从而定位整个块,避免了从头遍历链表。但与此同时也显著的增加了额外的内存开销。他会根据每一个内存块的脚部边界标记来选择合并方式,如下图:

图11 隐式空闲链表合并方法

7.9.2显式空闲链表

显式空闲链表只记录空闲块,而不是来记录所有块。它的思路是维护多个空闲链表,每个链表中的块有大致相等的大小,分配器维护着一个空闲链表数组,每个大小类一个空闲链表,当需要分配块时只需要在对应的空闲链表中搜索。

7.10本章小结

本章介绍了Hello和操作系统之间的交流方式。介绍了计算机系统中非常重要的一个概念:虚拟内存,以及关于它的内容。介绍了Hello是如何经过地址翻译从而找到最终的物理地址。阐释了TLB加速地址翻译、多级缓存以及动态内存管理相关的要点。

第八章 hello的IO管理

8.1Linux的IO设备管理方法

设备的模型化:文件
设备管理:unix io接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。

8.2 简述Unix IO接口及其函数

8.2.1函数open()和opennat()

int open(const char* path, int oflag, .../*mode_t mode*/); int openat(int fd, const char* path, int oflag, .../*mode_t mode*/);

若文件打开失败则返回-1,失败原因可以通过errno查看;若成功将返回最小的未用的文件描述符的值。其中参数path为要打开的文件的文件路径,oflag为文件打开模式。打开模式如下:

oflag含义
O_RDONLY只读权限
O_WRONLY只写权限
O_RDWR读写权限
O_EXEC可执行权限
O_SEARCH搜索权限(针对目录)
O_APPEND每次写都追加到文件的末端
O_CLOEXEC把close_on_exec设置为文件描述符标识
O_CREATE若文件不存在,则创建它。使用此选项的时候,需要使用第三个参数指定该新文件的访问权限位
O_DIRECTORY如果path不是目录则出错
O_EXCL若同时执行了O_CREATE,若文件存在则出错,可以用此选项测试文件是否存在

8.2.2creat()函数

int create(const char *path, mode_t mode);

若文件创建失败返回-1;若创建成功返回当前创建文件的文件描述符。参数与open中对应的参数含义相同。create(path, mode)函数功能为创建新文件,与open(path, O_CREATE|O_TRUNC|O_WRONLY)功能相同。

8.3.3lseek()函数

int lseek(int fd, off_t offset, int whence);

成功则返回新的文件的偏移量;失败则返回-1。使用lseek()函数显式的为一个打开的文件设置偏移量。lseek仅将文件的偏移量记录在内核中,并不引起IO开销。

8.3.4read()函数

#include ssize_t read(int fd, void *buf, size_t nbytes); 

若读取成功,读到文件末尾返回0,未读到文件末尾返回当前读的字节数。若读取失败,返回-1。fd为要读取文件的文件描述符。buf为读取文件数据缓冲区,nbytes为期待读取的字节数,通常为sizeof(buf)。

8.3.5write()函数

#include ssize_t write(int fd, const void* buf, size_t ntyes);

若写入成功则返回写入的字节数;失败返回-1。buf为写入内容的缓冲区,ntyes为期待写入的字节数,通常为sizeof(buf)。一般情况下返回值与ntypes相等,否则写入失败。

8.3 printf的实现分析

printf函数的具体实现如下:

int printf(const char *fmt, ...){int i;char buf[256];va_list arg = (va_list)((char*)(&fmt) + 4);i = vsprintf(buf, fmt, arg);write(buf, i);    return i;}

printf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。printf用了两个外部函数,一个是vsprintf,还有一个是write。

vsprintf函数如下:

int vsprintf(char *buf, const char *fmt, va_list args) {     char* p;     char tmp[256];     va_list p_next_arg = args;     for (p=buf;*fmt;fmt++) {         if (*fmt != '%') {             *p++ = *fmt;             continue;         }         fmt++;         switch (*fmt) {         case 'x':             itoa(tmp, *((int*)p_next_arg));             strcpy(p, tmp);             p_next_arg += 4;             p += strlen(tmp);             break;         case 's':             break;         default:             break;         }     } return (p - buf); } 

vsprintf函数作用是接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。write函数将buf中的i个元素写到终端。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.字符显示驱动子程序:从ASCII到字模库到显示vram。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点。

8.4 getchar的实现分析

getchar()函数的具体实现如下:

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;}

异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

本章简单总结了Linux函数IO设备的管理方法,以及UnixIO函数在使用时的用法,参数含义和函数功能。

结论

总结hello所经历的过程如下:

  • 首先需要有一个Hello的胚胎,也就是说先要完成hello.c的C语言源程序的编写。这就是Hello的起点。
  • 下一步使用命令gcc -E进行预处理,Hello完成从hello.c到hello.i的成长。
  • 下一步使用命令gcc -S进行编译,Hello完成从hello.i到hello.s的成长。
  • 下一步使用命令gcc -c进行汇编,Hello完成从hello.s到hello.o的成长。此时的Hello已经变成一个二进制文件了。
  • 对Hello进行链接,将Hello的好伙伴们和Hello联系起来,把它和其他可重定位二进制文件合体,变成一个可以在计算机上运行的二进制文件。
  • 打开Shell,输入命令./hello 120L020629 石博文 99来运行Hello程序。
  • Shell进行第六章中讲述的一系列判断:首先判断输入命令是否为内置命令。经过检查后发现其不是内置命令,则Shell将其当作程序执行。
  • 随机Shell调用Fork()函数为Hello创建一个进程。
  • shell调用execve函数,execve函数会将新创建的子进程的区域结构删除,然后将其映射到hello程序的虚拟内存,然后设置当前进程上下文中的程序计数器,使其指向hello程序的入口点。
  • 运行hello时,内存管理单元、TLB、多级页表机制、三级cache协同工作,完成对地址的翻译和请求。
  • 当Hello运行到printf这一步时,操作系统会调用malloc函数从堆中申请内存。
  • 当Hello执行时,可以通过IO输入等操作向进程发送信号。例如我们从键盘输入Ctrl-c,就会发送一个SIGINT信号,使当前前台进程的作业中断;同样哦们可以使用命令jobs来查看被抢占的进程,使用命令fg %来恢复对应ID的进程。
  • 当进程执行结束后,由父进程对子进程进行回收。至此,Hello的一生结束。

纵观Hello的一生,多么简介而短暂,朴素而辉煌!我们也应如此,生当作Hello,死亦为鬼雄!

附件

文件名称作用
hello.c储存hello程序源代码
hello.i源代码经过预处理产生的文件(包含头文件等工作)
hello.shello程序对应的汇编语言文件
hello.o可重定位目标文件
hello_o.shello.o的反汇编语言文件
hello.elfhello.o的ELF文件格式
hello二进制可执行文件
hello.elf可执行文件的ELF文件格式
hello.s可执行文件的汇编语言文件