大作业要求
根据Hello的自白,按照“HITICS-2018大作业模板”的要求编写。
我是Hello,我是每一个程序猿¤的初恋(羞羞……)
却在短短几分钟后惨遭每个菜鸟的无情抛弃(呜呜……),他们很快喜欢上sum、sort、matrix、PR、AI、IOT、BD、MIS……,从不回头。
只有我自己知道,我的出身有多么高贵,我的一生多么坎坷!
多年后,那些真懂我的大佬(也是曾经的菜鸟一枚),才恍然感悟我的伟大!
……………………想当年: 俺才是第一个玩 P2P的: From Program to Process
懵懵懂懂的你笨笨磕磕的将我一字一键敲进电脑存成hello.c(Program),无意识中将我预处理、编译、汇编、链接,历经艰辛,我-Hello一个完美的生命诞生了。
你知道吗?在壳(Bash)里,伟大的OS(进程管理)为我fork(Process),为我execve,为我mmap,分我时间片,让我得以在Hardware(CPU/RAM/IO)上驰骋(取指译码执行/流水线等);
你知道吗?OS(存储管理)与MMU为VA到PA操碎了心;TLB、4级页表、3级Cache,Pagefile等等各显神通为我加速;IO管理与信号处理使尽了浑身解数,软硬结合,才使我能在键盘、主板、显卡、屏幕间游刃有余, 虽然我在台上的表演只是一瞬间、演技看起来很Low、效果很惨白。
感谢 OS!感谢 Bash!在我完美谢幕后为我收尸。 我赤条条来去无牵挂!
我朝 CS(计算机系统-Editor+Cpp+Compiler+AS+LD + OS + CPU/RAM/IO等)挥一挥手,不带走一片云彩! 俺也是 O2O: From Zero-0 to Zero-0。
历史长河中一个个菜鸟与我擦肩而过,只有CS知道我的生、我的死,我的坎坷,“只有 CS 知道……我曾经……来…………过……”————未来一首关于Hello的歌曲绕梁千日不绝 !!
参考:C语言的数据与操作
数据:常量、变量(全局/局部/静态)、表达式、类型、宏
赋值 = ,逗号操作符,赋初值/不赋初值
类型转换(隐式或显式)
Sizeof
算术操作:+ - * / % ++ – 取正/负± 复合“+=”等
逻辑/位操作:逻辑&& || ! 位 & | ~ ^ 移位>> << 复合操作如 “|=” 或“<<=”等
关系操作:== != > < >= <=
数组/指针/结构操作:A[i] &v *p s.id p->id
控制转移:if/else switch for while do/while ?: continue break
函数操作:参数传递(地址/值)、函数调用()、函数返回 return
摘 要
为对计算机系统有更好的理解和认识,本文对简单的hello程序从代码到运行再到最后终止过程的底层实现进行了分析,描述了与之相关的计算机组成与操作系统的相关内容。基于Linux平台,通过gcc、objdump、gdb、edb等工具对一段程序代码预处理、编译、汇编、链接与反汇编的过程进行分析与比较,并且通过shell及其他Linux内置程序对进程运行过程进行了分析。经过研究,较为深入的研究了一个C语言程序生命周期内包括缓存、虚拟内存、信号在内的各种操作系统与硬件实现的机制,对汇编语言及程序编译的各个阶段都有所讨论。研究内容对理解底层程序与操作系统的实现原理具有一定指导作用。
关键词:操作系统;计算机组成原理;编译;汇编;虚拟内存
目 录
第1章 概述
1.1 Hello简介
hello程序由用户通过键盘输入,根据高级语言的语法规范形成编译器能够读懂的代码。完成后的hello.c文件依次经过编译器的预处理对源代码进行转换、编译得到汇编语言代码、汇编再将汇编语言转换为机器语言,最后与库函数进行链接并进行重定位,形成可执行文件。
hello的可执行文件可以通过shell运行并传入命令行参数。shell同样是一个程序,它会通过fork函数为hello创建一个进程,再通过execve执行hello。操作系统的并发机制让hello程序能够与其他程序分片运行。
作为一个进程,hello是由操作系统将其的各类信息包括代码、数据等从磁盘加载到内存得以有效的执行。CPU读取其代码并以流水线的方式执行,通过高速缓存高效的读取指令,将程序的各个指令在硬件上实现。
当hello对数据进行处理时,其空间在内存上申请。操作系统提供的虚拟内存机制为每个进程维护了自己的空间,从而不会相互干扰。计算机存储结构层层递进,下一级作为上一级的缓存,让hello的数据能够从磁盘传输到CPU寄存器。
而TLB、分级页表等机制又为数据在内存中的高效访问提供了支持,合理的信号机制又让hello程序能够应对执行中的各种情况。
操作系统将I/O设备都抽象为了文件,将底层与应用层隔离,将用户态与内核态隔离,通过描述符与接口,让hello程序能够间接调用硬件进行输入输出,例如从键盘读入字符,向屏幕输出内容。
hello程序终止后,父进程shell与操作系统一同将其回收,释放其运行过程中占用的内存空间。
1.2 环境与工具
1.硬件环境
Intel Core i5 X64 CPU;2.5GHz;8G RAM;128G SSD + 1T HDD
2.软件环境
Windows 10 64位; VMware 14;Ubuntu 16.04 LTS 64位
3.开发与调试工具
GDB;EDB;OBJDUMP;READELF;CodeBlocks 64位;vim/gedit+gcc
1.3 中间结果
hello.i: hello.c预编译的结果,用于研究预编译的作用以及进行编译器的下一步编译操作。
hello.s: hello.i编译后的结果,用于研究汇编语言以及编译器的汇编操作,可以与hello.c对应,分析底层的实现。
hello.o: hello.s汇编后的结果,可重定位目标程序,没有经过链接,用于链接器或编译器链接生成最终可执行程序。
hello.out: hello.o链接后生成的可执行目标文件,可以用来反汇编或者通过EDB、GDB等工具分析链接过程以及程序运行过程,包括进入main函数前后发生的过程。
hello: 同hello.out,由gcc -m64 -no-pie -fno-PIC hello.c -o
hello命令直接生成。
hello.o.s: 对可重定位目标文件反汇编得到,可以与对可执行目标文件反汇编得到的代码对比来分析链接过程。
asm.s: 对可执行目标文件反汇编得到,可以用来分析链接过程与寻址过程。
1.4 本章小结
本章简要介绍了hello程序从代码到编译生成,到执行,再到终止的过程与操作系统发生的事件。
除此之外还有研究过程中用到的软硬件环境以及开发调试工具,包括研究过程中系统的配置和使用的工具。
最后介绍了研究过程中的中间结果,以及其用途和对研究过程产生的作用。
第2章 预处理
2.1 预处理的概念与作用
1.概念
预处理是指预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,比如#include <stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。其他常见的预处理指令还有#define(定义宏)、#if、#ifdef、#endif等。
预处理的结果就得到了另外一个C程序,通常是以.i作为文件扩展名。
2.作用
预处理实现了在编译器进行编译之前对源代码做某些转换。例如将头文件中的代码插入到程序代码中、通过宏对符号进行替换等。
2.2 在Ubuntu下预处理的命令
预处理命令:gcc -E hello.c -o hello.i
图2.2.1预处理命令
图2.2.2预处理结果
2.3 Hello的预处理结果解析
图2.3 预处理结果代码(部分)
查看hello.i文件可以看到,在原有代码的基础上,将头文件stdio.h的内容引入,例如声明函数、定义结构体、定义变量、定义宏等内容。
通过结果分析可以发现,预处理实现了在编译前对代码的初步处理,对源代码进行了某些转换。
另外,如果代码中有#define命令还会对相应的符号进行替换。
2.4 本章小结
本章介绍了编译器预处理的相关概念。预处理操作是在编译前对代码进行某些转换与处理,从而使编译得以成功进行。例如实现将定义的宏进行符号替换、引入头文件的内容、根据指令进行选择性编译等。
经过预处理,代码为下一步编译做好了准备。
第3章 编译
3.1 编译的概念与作用
1.概念
编译是指编译器(ccl)将文本文件hello.i翻译成文本文件hello.s的过程,这个文本文件内包含了一个汇编语言程序。
2.作用
编译过程编译器实现了经过词法分析、语法分析、语义分析等过程,在检查无错误后将代码翻译成汇编语言。得到的汇编语言代码可供编译器进行生成机器代码、链接等操作。
3.2 在Ubuntu下编译的命令
编译命令:gcc -S hello.i -o hello.s
图3.2.1 编译命令
图3.2.2编译结果
3.3 Hello的编译结果解析
图3.3 编译得到的汇编代码
注意:为了更好的分析理解编译器的汇编跳转地址实现,采用链接后的汇编进行分析,实际GCC生成的汇编代码以图3.3为准。
3.3.1 常量
经过编译,常量例如hello.c中的字符串“Usage: Hello 学号 姓名!\n”被存储只读代码区的.rodata节,在程序运行时会直接通过寻址找到常量。
例如hello程序中调用printf函数前,会将该字符串常量从.rodata节读出并存储在寄存器中然后作为参数传给函数。为了更好的说明该过程,将最终链接后的代码反汇编可以得到以下代码(地址仅供参考):
图3.3.1.1 常量被存储在.rodata节
图3.3.1.2调用函数前读取常量字符串
3.3.2 变量
程序运行时变量存储在内存中,编译器通过对内存中的某个地址进行读写操作从而对变量进行操作。而编译器对变量的处理则是将其编译为相关的内存操作指令。
不同类型的变量申请对应的内存大小,不同类型的变量在不同位置定义。例如,局部变量在堆上进行定义和释放;初始化的全局变量和静态变量定义在只读代码区的.bss节;已初始化的全局和静态变量定义在只读代码区的.data节。
在hello程序中定义了初始化的全局整型变量sleepsecs,在汇编代码(为了更好的体现其实现,采用链接后的汇编代码)中体现为:
图3.3.2.1 sleepsecs定义在.data节
图3.3.2.2 读取全局变量 sleepsecs
局部变量通过栈进行操作。例如hello程序中的整型变量i,其汇编实现如下:
图3.3.2.3变量i的初始化与自增
这段汇编代码对应着for(i=0;i<10;i++)中i的初始化与自增。可以看到,“movl $0x0,-0x4(%rbp)”指令将-0x4(%rbp)位置的内存赋值为0;“addl $0x1,-0x4(%rbp)”的指令对内存的值加1;“cmpl $0x9,-0x4(%rbp)”将内存中的值与9进行比较。
经过上面的分析,局部变量的存储在内存中,通过%rbp相对寻址进行读写。
3.3.3 类型
编译器对类型的体现通过申请不同大小的空间,进行不同的操作等方式。例如char大小为1个字节,那么在堆栈中只会申请1个字节的空间,而int大小为4字节,则会申请4个字节的空间。
不同类型的数据存储方式也不同,当然这是计算机底层存储方式实现的原因,编译器会依照其存储方式对其进行操作。例如浮点数和整数都有自己的操作方式、char类型数据会依据ASCII码。
3.3.4 赋值
编译器对赋值操作的处理是将其编译为汇编指令MOV。根据不同大小的数据类型有movb、movw、movl、movq、movabsq。
hello程序代码中的i = 0在汇编代码中体现为“movl $0x0,-0x4(%rbp)”(图3.3.2.3),通过mov操作把常数0赋值给i。int数据类型的大小是4个字节,movl很好的体现了这一点。
3.3.5 算术操作
以hello程序里的“i++”为例,通过ADD操作实现。汇编代码为“addl $0x1,-0x4(%rbp)”(图3.3.2.3),结果是i的值加1。ADD操作根据不同的数据大小也有不同的操作,根据其后缀可以分辨。
其他常见的算术操作指令还有SUB(减)、IMUL(乘)、DIV(除)等。另外对于除法,例如除2,编译器可能优化为逻辑右移从而提高效率而不是使用DIV指令。
3.3.6 关系操作
以hello程序里的逻辑关系式“i < 10”为例,编译器通常通过将其编译为CMP指令实现。根据不同的数据大小,有cmpb、cmpw、cmpl和cmpq。在通过CMP指令比较后,在通过jmp指令跳转。例如“小于等于则跳转”的汇编指令是jle。
Hello程序中涉及到i与10进行比较,其实现代码如下:
4006ad: 83 45 fc 01 addl $0x1,-0x4(%rbp)
4006b1: 83 7d fc 09 cmpl $0x9,-0x4(%rbp)
4006b5: 7e c1 jle 400678 <main+0x32>
根据上面的分析就能很容易理解其逻辑过程。比较关系,如果小于等于9也就是关系式“i <10”为真则跳转。其他关系操作与不同的JMP指令有关,例如jne(不相等则跳转)、je(相等则跳转),这里不再赘述。
3.3.7 数组操作
所谓数组,其实就是一段数据类型相同的物理位置相邻的变量集合。例如有5个元素的char数组,地址分为为1、2、3、4、5(仅为了说明相对位置,真实情况地址不会为0x1)。
所以对数组的索引实际上就是在第一个元素地址的基础上通过加索引值乘以数据大小来实现。例如整型数组a[0]的地址是address_1,那么a[2]即address_1+ 4 * 2。
有了这些分析,就能很好的理解数组在内存中是怎样操作的。以hello程序中的命令行参数数组的访问涉及到函数参数传递以及命令行参数的相关知识,较为特殊也比较复杂,但仍能看到相关数组操作的思想:
图3.3.7命令行参数数组索引
从栈中400678获得一个地址,然后通过add指令加某个数值进行索引。可以发现,argv数组作为一个char* 类型的数组,char* 的大小是8个字节,所以argv[1]加0x8,而argv[2]加0x10(十进制数为16,即8 × 2)。
3.3.8 控制转移
C语言中的“if/else switch for while do/while ?: continue
break”等都涉及控制转移,以hello程序中的if语句及for循环为例进行分析。
图3.3.8.1 hello程序if语句跳转实现
这段汇编代码对应的C语言代码如下:
if(argc!=3)
{
printf("Usage: Hello 学号 姓名!\n");
exit(1);
}
编译器编译的执行逻辑首先和3进行比较,如果相等则不满足argc!=3,那么通过je跳过下面的操作。如果不相等,则执行大括号内部的操作。
图3.3.8.2 hello程序for循环跳转实现
这段代码对应的C语言代码如下:
for(i=0;i<10;i++)
{
printf("Hello %s %s\n",argv[1],argv[2]);
sleep(sleepsecs);
}
编译器实现for循环同样是通过JMP跳转。以hello程序的汇编代码为例,可以看到,首先给i赋值为0,然后跳转到cmpl指令,与9比较,如果小于等于9也就是小于10则跳转到“mov
-0x20(%rbp),%rax”,并依次执行下面的指令。执行完成后通过ADD指令实现i的自增,再次与9比较,如果仍然小于等于9则跳转继续执行循环,否则不跳转,顺序执行循环外的指令。
3.3.9 函数操作
在hello程序中的函数操作包括main函数本身的参数读取、printf的调用以及sleep和getchar函数的调用。
分析编译器对函数操作的实现就不得不介绍栈的结构。
图3.3.9.1 通用栈桢结构
函数调用的之前,调用者会将其参数压入栈中,及其返回地址,也就是在函数运行完后回到调用者代码指令的地址。
图3.3.9.2 寄存器与函数参数
注意到,main函数中有如下的代码:
400646: 55 push %rbp
400647: 48 89 e5 mov %rsp,%rbp
40064a: 48 83 ec 20 sub $0x20,%rsp
40064e: 89 7d ec mov %edi,-0x14(%rbp)
400651: 48 89 75 e0 mov %rsi,-0x20(%rbp)
400655: 83 7d ec 03 cmpl $0x3,-0x14(%rbp)
…… ……
4006bc: b8 00 00 00 00 mov $0x0,%eax
4006c1: c9 leaveq
4006c2: c3 retq
这段代码可以在一定程度上反应函数调用的基本过程。首先将%rbp通过push指令存储在栈中,并将%rsp栈指针赋值给%rbp。通过sub指令减小栈指针地址从而为局部变量分配空间。然后将%edi和%rsi寄存器中的值存储在栈中。从图3.3.9.2可以看到,%edi和%rsi分别代表着第一个参数和第二个参数,这样就读取了命令行参数。
在main函数的最后,leave指令等价于movl %rbp %rsp和pop
%rbp,也就是将%rsp和%rbp恢复。然后ret指令就返回到了调用者。
以上便是一个函数调用的大体过程。可以看到,在通过call指令调用printf前程序将参数存储在寄存器中,从而实现对参数传递。
3.4 本章小结
本章介绍了编译器进行编译操作的相关内容。编译器实现将C语言代码转换成汇编代码,从而最终转换为机器代码。生成汇编代码的这个过程需要对数据、操作都进行对应转换,数据包括常量、变量(全局/局部/静态)、表达式、宏等,操作包括算术、逻辑、位、关系、函数等操作。
汇编代码搭建了从高级语言到底层机器语言的桥梁,实现了对内存数据的各种操作。编译器通过语法分析、语义分析等编译C语言代码到汇编代码,为接下来生成机器代码奠定了基础。
第4章 汇编
4.1 汇编的概念与作用
1.概念
汇编指的是汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,包含hello程序执行的机器指令。
2.作用
实现将汇编代码转换为机器指令,使之在链接后能够被计算机直接执行。
4.2 在Ubuntu下汇编的命令
汇编命令: gcc -c hello.s -o hello.o
图4.2.1汇编命令
图4.2.2汇编结果
图4.2.3二进制文件用文本编辑器打开后乱码
4.3 可重定位目标elf格式
ELF头 | 描述生成该文件的系统字的大小和字节顺序 |
---|---|
.text | 已编译程序的机器代码 |
.rodata | 只读数据 |
.data | 已初始化的全局和静态C变量 |
.bss | 未初始化的全局和静态C遍历 |
.symtab | 存放程序中定义和引用的函数和全局变量信息 |
.rel.text | 一个.text节中位置的列表,链接时修改 |
.rel.data | 被模块引用或定义的所有全局变量的重定位信息 |
.debug | 条目是局部变量、类型定义、全局变量及C源文件 |
.line | C源程序中行号和.text节机器指令的映射 |
.strtab | .symtab和.debug中符号表及节头部中节的名字 |
节头部表 | 描述目标文件的节 |
图4.3.1 可重定位的目标文件ELF格式
图4.3.2 hello.o的ELF头
图4.3.3 hello.o的节头
图4.3.4 hello.o的重定位节
从图4.3.4可以得到hello.o的重定位节信息,可以看到.rodata、puts、exit、printf、sleep、getchar等符号的偏移。链接器会依据重定向节的信息对可重定向的目标文件进行链接得到可执行文件。
图4.3.5 hello.o的”.symtab“节
4.4 Hello.o的结果解析
图4.4 汇编代码对比
(上:gcc编译结果 下:objdump 反汇编结果)
经过对比可以发现,编译与反汇编得到代码都包含程序实现基本的汇编语言代码。编译生成的代码跳转目标通过例如.L2表示,而反汇编的代码通过一个地址表示。
另外,在反汇编的结果中包含了供对照的来自可重定位目标文件中的机器语言代码及相关注释,包括一些相对寻址的信息与重定位信息,尽管没有经过链接不是最终结果,但是能很大程度上反应机器执行的过程。
机器语言由二进制代码构成(图中反汇编结果用16进制表示),是计算机能够直接识别和执行的一种机器指令的集合,是与处理器紧密相关的。机器语言由操作码和操作数组成,操作码与汇编语言符号存在的对应关系。由于操作数类型、寻址方式等的不同,同一个汇编语言符号可能对应着不同的机器语言操作码。
绝对地址或常数情况下,操作数与汇编语言的描述有很明显的直接对应,而相对寻址则需要经过处理。
至于分支与函数调用,机器语言均广泛采取相对寻址,通常是下一条指令的地址加偏移量,从而得到绝对地址。
4.5 本章小结
本章介绍了程序生成过程中编译器汇编的相关内容。汇编过程将汇编语言转换为机器代码,生成可重定位的目标文件,使机器能够直接处理与执行。可以通过readelf读取其elf信息与重定位信息,得到其符号表的相关信息。另外,可以通过objdump反汇编目标文件,从中可以得到机器代码与汇编代码的对照。
作为机器可以直接执行的语言,机器语言与汇编语言存在映射关系,能够反映机器执行程序的逻辑。当然,生成最终的可执行文件还需要经过链接这一步。
第5章 链接
5.1 链接的概念与作用
1.概念
链接是指将文件中调用的各种函数跟静态库及动态库链接,并将它们打包合并形成目标文件,即可执行文件。
2.作用
通过链接可以实现将头文件中引用的函数并入到程序中。
例如hello程序中用到的printf函数,该函数存在于一个名为printf.o的单独预编译目标文件,必须将其并入到hello.o的程序中,链接器实现的便是将两者并入,得到最终的hello文件内容。
5.2 在Ubuntu下链接的命令
链接命令:
ld -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
/usr/lib/gcc/x86_64-linux-gnu/5/crtbegin.o hello.o -lc
/usr/lib/gcc/x86_64-linux-gnu/5/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z
relro -o hello.out
图5.2 通过ld命令链接
5.3 可执行目标文件hello的格式
ELF头 | 描述生成该文件的系统字的大小和字节顺序 |
---|---|
段头部表 | 将连续的文件节映射到运行时内存段 |
.init | 程序初始化代码需要调用的函数 |
.text | 已编译程序的机器代码 |
.rodata | 只读数据 |
.data | 已初始化的全局和静态C变量 |
.bss | 未初始化的全局和静态C遍历 |
.symtab | 存放程序中定义和引用的函数和全局变量信息 |
.debug | 条目是局部变量、类型定义、全局变量及C源文件 |
.line | C源程序中行号和.text节机器指令的映射 |
.strtab | .symtab和.debug中符号表及节头部中节的名字 |
节头部表 | 描述目标文件的节 |
图5.3.1 可执行目标文件ELF格式
图5.3.2 ELF头
图5.3.3 各节起始地址、大小
5.4 hello的虚拟地址空间
根据图5.3.3得到的各节起始地址,可以在EDB中查找得到对应内容,以下举例说明:
图5.4.1 EDB查找部分节起始地址
图5.4.2 .interp节
图5.4.3 .init节
图5.4.4 .plot节
图5.4.5 .text节
图5.4.6 .data节
图5.4.7 .bss节
5.5 链接的重定位过程分析
图5.5.1 hello.o反汇编的结果
图5.5.2 hello反汇编的结果(1)
图5.5.3 hello反汇编的结果(2)
经过对比可以发现,hello.o没有经过链接,所以main的地址从0开始,并且不存在调用的如printf这样函数的代码。另外,很多地方都有重定位标记,用于后续的链接过程。hello.o反汇编代码的相对寻址部分的地址也不具有参考性,没有经过链接并不准确。
而链接后的hello程序地址已经生成(Ubuntu
16.04下的GCC编译结果仍采用绝对地址,在Ubuntu
18.04的GCC中生成相对地址),main的地址也不再是0。从汇编代码可以看到,库函数的代码都已经链接到了程序中,程序各个节变得更加完整,跳转的地址也具有参考性。相对寻址的地址例如“je
29 <main+0x29>”在链接后变成了“je 40066f <main+0x29>”。
总结一下,经过链接,函数的个数增加,头文件的函数加入至代码中;各类相对寻址确定,动态库函数指向PLT;函数的起始地址也得到了确定。
5.6 hello的执行流程
加载程序 | ld-2.23.so!_dl_start ld-2.23.so!_dl_init LinkAddress!_start libc-2.23.so!_libc_start_main libc-2.23.so!_cxa_atexit LinkAddress!_libc_csu.init libc-2.23.so!_setjmp |
---|---|
call main | LinkAddress!main |
程序终止 | libc-2.23.so!exit |
图5.6 hello执行过程中的子程序
5.7 Hello的动态链接分析
动态链接库中的函数在程序执行的时候才会确定地址,所以编译器无法确定其地址,在汇编代码中也无法像静态库的函数那样体现。
hello程序对动态链接库的引用,基于数据段与代码段相对距离不变这一个事实,因此代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量。
GNU编译系统采用延迟绑定技术来解决动态库函数模块调用的问题,它将过程地址的绑定推迟到了第一次调用该过程时。
延迟绑定通过全局偏移量表(GOT)和过程链接表(PLT)实现。如果一个目标模块调用定义在共享库中的任何函数,那么就有自己的GOT和PLT。前者是数据段的一部分,后者是代码段的一部分。
进一步介绍,PLT是一个数组,其中每个条目是16字节代码。每个库函数都有自己的PLT条目,PLT[0]是一个特殊的条目,跳转到动态链接器中。从PLT[2]开始的条目调用用户代码调用的函数。
GOT同样是一个数组,每个条目是8字节的地址,和PLT联合使用时,GOT[2]是动态链接在ld-linux.so模块的入口点,其余条目对应于被调用的函数,在运行时被解析。每个条目都有匹配的PLT条目。
当某个动态链接函数第一次被调用时先进入对应的PLT条目例如PLT[2],然后PLT指令跳转到对应的GOT条目中例如GOT[4],其内容是PLT[2]的下一条指令。然后将函数的ID压入栈中后跳转到PLT[0]。PLT[0]通过GOT[1]将动态链接库的一个参数压入栈中,再通过GOT[2]间接跳转进动态链接器中。动态链接器使用两个栈条目来确定函数的运行时位置,用这个地址重写GOT[4],然后再次调用函数。经过上述操作,再次调用时PLT[2]会直接跳转通过GOT[4]跳转到函数而不是PLT[2]的下一条地址。
图5.7.1 动态链接库函数的延迟绑定
通过EDB调试,能够看出这个变化。先观察调用dl_init前,动态库函数指向的地址。从图5.3.3中能够读取到GOT表的起始位置,即0x601000。在dl_init调用之前可以查看其值,发现均为0。调用dl_init后再次查看,根据GOT表的每一项为8字节,可以推得GOT[2]也就是存放动态链接器入口的地址为0x601010。经过dl_init的调用,这里已经有了一段地址,为0x7f906bbf2870。
图5.7.2 dl_init调用前后GOT的变化
跳转到这个地址,发现正式动态链接库的入口地址。
图5.7.3 GOT[2]指向的地址
为了验证延迟绑定的实现,可以查看printf调用前后printf@plt的指令跳转地址,也就是对应GOT中的值,可以发现,调用后确实链接到了动态库。
图5.7.4 printf调用前
图5.7.5 printf调用后
5.8 本章小结
本章讨论了链接过程中对程序的处理。Linux系统使用可执行可链接格式,即ELF,具有.text,.rodata等节,并且通过特定的结构组织。
经过链接,ELF可重定位的目标文件变成可执行的目标文件,链接器会将静态库代码写入程序中,以及动态库调用的相关信息,并且将地址进行重定位,从而保证寻址的正确进行。静态库直接写入代码即可,而动态链接过程相对复杂一些,涉及共享库的寻址。
链接后,程序便能够在作为进程通过虚拟内存机制直接运行。
第6章 hello进程管理
6.1 进程的概念与作用
1.概念
进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
2.作用
进程作为一个执行中程序的实例,系统中每个程序都运行在某个进程的上下文中,上下文是由程序正确运行所需的状态组成的。
这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
6.2 简述壳Shell-bash的作用与处理流程
1.作用
shell是一种交互型的应用级程序。它能够接收用户命令,然后调用相应的应用程序,即代表用户运行其他程序。
2.处理流程
shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。
6.3 Hello的fork进程创建过程
shell作为父进程通过fork函数为hello创建一个新的进程,供其执行。通过fork函数,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本。
shell通过fork进行进程创建可用以下C语言代码模拟:
if ((pid = fork()) < 0)
unix_error("fork error");
/*
* Child process
*/
if (pid == 0)
{
/* Child unblocks signals */
sigprocmask(SIG_UNBLOCK, &mask, NULL);
/* Each new job must get a new process group ID
so that the kernel doesn't send ctrl-c and ctrl-z
signals to all of the shell's jobs */
if (setpgid(0, 0) < 0)
unix_error("setpgid error");
/* Now load and run the program in the new job */
if (execve(argv[0], argv, environ) < 0)
{
printf("%s: Command not found\n", argv[0]);
exit(0);
}
}
6.4 Hello的execve过程
创建进程后,在子进程中通过判断pid即fork()函数的返回值,判断处于子进程,则会通过execve函数在当前进程的上下文中加载并运行一个新程序。execve加载并运行可执行目标文件,且带参数列表argv和环境变量列表envp。只有当出现错误时,execve才会返回到调用程序。
在execve加载了可执行程序之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,即可执行程序的main函数。此时用户栈已经包含了命令行参数与环境变量,进入main函数后便开始逐步运行程序。
6.5 Hello的进程执行
多个流并发地执行的一般现象被称为并发。一个进程和其他进轮流运行的概念称为多任务。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫做时间分片。
hello程序执行过程中同样存储时间分片,与操作系统的其他进行并发运行。并发执行涉及到操作系统内核采取的上下文交换策略。内核为每个进程维持一个上下文,上下文就是内核重新启动一个先前被抢占的进程所需的状态。
在执行过程中,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,这个过程称为调度。
在此基础上,hello程序与操作系统其他进程通过操作系统的调度,切换上下文,拥有各自的时间片从而实现并发运行。
程序在涉及到一些操作时,例如调用一些系统函数,内核需要将当前状态从用户态切换到核心态,执行结束后再及时改用户态,从而保证系统的安全与稳定。
图6.5 上下文切换与并发
6.6 hello的异常与信号处理
hello执行过程中可能出现四类异常:中断、陷阱、故障和终止。
中断是来自I/O设备的信号,异步发生,中断处理程序对其进行处理,返回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。
终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程序会将控制返回给一个abort例程,该例程会终止这个应用程序。
hello执行过程中,可能会遇到各种异常,信号则是一种通知用户异常发送的机制。例如较为底层的硬件异常以及较高层的软件事件,比如Ctrl-Z和Ctrl-C,分别触发SIGCHLD和SIGINT信号。
收到信号后进程会调用相应的信号处理程序对其进行处理。
图6.6.1 Ctrl-C终止进程
图6.6.2 Ctrl-Z暂停进程
图6.6.3 暂停后执行其他命令
图6.6.4 fg命令在前台恢复进程
6.7 本章小结
本章介绍了程序在shell执行及进程的相关概念。程序在shell中执行是通过fork函数及execve创建新的进程并执行程序。进程拥有着与父进程相同却又独立的环境,与其他系统进并发执行,拥有各自的时间片,在内核的调度下有条不紊的执行着各自的指令。
程序运行中难免遇到异常,异常分为中断、陷阱、故障和终止四类,均有对应的处理方法。操作系统提供了信号这一机制,实现了异常的反馈。这样,程序能够对不同的信号调用信号处理子程序进行处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址空间的格式为“段地址:偏移地址”,例如“23:8048000”,在实模式下可以转换为物理地址:逻辑地址CS:EA = 物理地址CS × 16 + EA。保护模式下以段描述符作为下标,通过在GDT/LDT表获得段地址,段地址加偏移地址得到线性地址。
线性地址空间是指一个非负整数地址的有序集合,例如{0,1,2,3……}。在采用虚拟内存的系统中,CPU从一个有N = 2n个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间。
而对应于物理内存中M个字节的地址空间{0, 1, 2, 3, …, M-1}则称为物理地址空间。
Intel处理器采用段页式存储管理,前者将逻辑地址转换为线性地址从而得到虚拟地址,后者将虚拟地址转换为物理地址。
以hello程序为例,反汇编可以得到这样一段汇编代码“mov $0x400772,%edi”,其中0x400772其实是逻辑地址的偏移地址,必须加上隐含的DS数据段的基地址才能构成线性空间地址,或者说0x400772是当前任务DS数据段的偏移。
这样得到的线性地址其实是数据存储的虚拟地址,还需要经过MMU转换为物理地址,转换为其物理内存的地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel处理器从逻辑地址到线性地址的变换通过段式管理,介绍段式管理就必须了解段寄存器的相关知识。段寄存器对应着内存不同的段,有栈段寄存器(SS)、数据段寄存器(DS)、代码段寄存器(CS)和辅助段寄存器(ES/GS/FS)。其大体对应关系如下图:
图7.2.1 段寄存器
段寄存器用于存放段选择符,通过段选择符可以得到对应段的首地址。段选择符分为三个部分,分别是索引、TI(决定使用全局描述符表还是局部描述符表)和RPL(CPU的当前特权级)。
图7.2.2 段选择符
这样,Intel处理器在通过段式管理寻址时,首先通过段描述符得到段基址,然后与偏移量结合得到线性地址,从而得到了虚拟地址。至于偏移量,基址寄存器还是变址寄存器有不同的计算方法,后者需要经过乘比例因子等处理。
7.3 Hello的线性地址到物理地址的变换-页式管理
Intel处理器从线性地址到物理地址的变换通过页式管理实现。
由计算机系统处理器层次的相关知识可知,内存其实是磁盘的缓存。磁盘上的数据被分割成块,作为其主存之间的传输单元。虚拟内存系统将虚拟内存分割称为虚拟页,物理内存分割称为物理页。
虚拟页存在未分配的、缓存的、未缓存的三种状态。其中缓存的页对应于物理页。
图7.3.1 虚拟页与物理页
页表是这类地址转换的另一个重要概念,它将虚拟页映射到物理页,其每一项称为页表条目(PTE),由有效位和一个n位的地址字段组成。如果设置有效位说明该页已缓存,否则未缓存,在地址字段不为空的情况下指向虚拟页在磁盘上的起始地址。
从虚拟地址到物理地址的翻译通过MMU(内存管理单元),它通过虚拟地址索引到对应的PTE,如果已缓存则命中,否则不命中称为缺页。发生缺页时,MMU会选择一个牺牲页,在物理内存将之前缺页的虚拟内存对应的数据复制到它的位置,并更新页表,然后重新触发虚拟地址翻译事件。
通过页表,MMU可以实现从虚拟地址到物理地址的映射。
图7.3.2 使用页表的地址翻译
CPU中的页表基址寄存器指向当前页表,n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO)和一个n- p位的虚拟页号(VPN)。
MMU利用VPN选择适当的PTE,然后将页表条目中的物理页号(PPN)与虚拟地址中的VPO串联起来,得到物理地址。
翻译的过程如果命中则完全由硬件处理,如果缺页需要操作系统内核与硬件合作完成。
图7.3.3 页面命中和缺页的操作图
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虚拟地址,MMU就必须查阅相应的PTE,这显然造成了巨大的时间开销,为了消除这样的开销,MMU中存在一个关于PTE的小的缓存,称为翻译后备缓冲器(TLB)。
图7.4.1 虚拟地址中用以访问TLB的组成部分
TLB通过虚拟地址VPN部分进行索引,分为索引(TLBI)与标记(TLBT)两个部分。这样,MMU在读取PTE时会直接通过TLB,如果不命中再从内存中将PTE复制到TLB。
图7.4.2 TLB命中与不命中的操作图
在以上机制的基础上,如果所使用的仅仅是虚拟地址空间中很小的一部分,那么仍然需要一个与使用较多空间相同的页表,造成了内存的浪费。所以虚拟地址到物理地址的转换过程中还存在多级页表的机制:上一级的页表映射到下一级也表,直到页表映射到虚拟内存,如果下一级内容都未分配,那么页表项则为空,不映射到下一级,也不存在下一级页表,当分配时再创建相应页表,从而节约内存空间。
图7.4.3 使用k级页表的地址翻译
具体来讲,页表条目的格式如下:
图7.4.4 第1~3级页表条目格式
图7.4.5 第4级页表条目格式
7.5 三级Cache支持下的物理内存访问
处理器对物理内存中数据的访问,同样需要经过缓存,即Cache,主流的处理器通常采用三级Cache。层与层之间按照以下原则进行读与写:
读取数据时,首先在高速缓存中查找所需字w的副本。如果命中,立即返回字w给CPU。如果不命中,从存储器层次结构中较低层次中取出包含字w的块,将这个块存储到某个高速缓存行中(可能会驱逐一个有效的行),然后返回字w。
下面具体三类Cache进行分析:
(1)直接映射高速缓存
直接映射高速缓存每个组只有一行,当CPU执行一条读内存字w的指令,它会向L1高速缓存请求这个字。如果L1高速缓存中有w的一个缓存副本,那么就会得到L1高速缓存命中,高速缓存会很快抽取出w,并将它返回给CPU。否则就是缓存不命中,当L1高速缓存向主存请求包含w的块的一个副本时,CPU必须等待。当被请求块最终从内存到达时,L1高速缓存将这个块存放在它的一个高速缓存行里,从被存储的块中抽取出字w,然后将它返回给CPU。确定是否命中然后抽取的过程分为三步:1)组选择;2)行匹配;3)字抽取。
图7.5.1 直接映射高速缓存中的组选择
组选择即从w的地址中间抽取出s个索引位,将其解释为一个对应组号的无符号整数,从而找到对应的组;行匹配即对组内的唯一一行进行判断,当有效位为1且标记位与从地址中抽取出的标记位相同则成功匹配,否则就得到不命中;而字选择即在行匹配的基础上通过地址的后几位得到块偏移,从而在高速缓存块中索引到数据。
图7.5.2直接映射高速缓存中的行匹配
(2)组相联高速缓存
组相联高速缓存每个组内可以多于一个缓存行,总体逻辑类似于直接映射高速缓存,不同之处在于行匹配时每组有更多的行可以尝试匹配,遍历每一行。如果不命中,有空行时也就是冷不命中则直接存储在空行;如果没有空行也就是冲突不命中,则替换已有行,通常有LFU(最不常使用)、LRU(最近最少使用)两者替换策略。
(3)全相联高速缓存
全相联高速缓存只有一个组,且这个组包含所有的高速缓存行(即E =
C/B)。对于全相联高速缓存,因为只有一个组,组选择变的十分简单。地址中不存在索引位,地址只被划分为一个标记位和一个块偏移。行匹配和字选择同组相联高速缓存。
写入数据时,假设我们要写一个已经缓存了的字w,在高速缓存中更新了它的w的副本之后,有两种方法来更新w在层次结构中紧接着低一层中的副本。分别是直写和写回,在这里分别介绍:
(1)直写
立即将w的高速缓存块写回到紧挨着的低一层中。优点是简单,缺点则是每次写都会引起总线流量。其处理不命中的方法是非写分配,即避开高速缓存,直接将这个字写到低一层去。
(2)写回
尽可能地推迟更新,只有当替换算法要驱逐这个更新过的块时,才把它写到紧接着的低一层中。优点是能显著地减少总线流量,缺点是增加了复杂性,必须为每个高速缓存行增加一个额外的修改位,表明是否被修改过。写回处理不命中的方法是写分配,加载相应低一层中的块到高速缓存中,然后更新这个高速缓存块,利用了写的空间局部性,但会导致每次不命中都会有一个块从低一层传到高速缓存。
通过这样的Cache读写机制,实现了从CPU寄存器到L1高速缓存,再到L2高速缓存,再到L3高速缓存,再到物理内存的访问,有效的提高了CPU访问物理内存的速度。
7.6 hello进程fork时的内存映射
shell通过fork为hello创建新进程。当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给hello进程唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和样表的原样副本。它将两个进程中的每个页面都标记为只读,并将每个进程中的每个区域结构都标记为写时复制。
图7.6 私有的写时复制对象
当fork在新进程中返回时,新进程现在的虚拟内存刚好的和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就是为每个进程保持了私有地址空间的概念。
7.7 hello进程execve时的内存映射
execve函数在shell中加载并运行包含在可执行文件hello中的程序,用hello程序有效地替代了当前程序。加载hello的过程主要步骤如下:
首先删除已存在的用户区域,也就是将shell与hello都有的区域结构删除。然后映射私有区域,即为新程序的代码、数据、bss和栈区域创建新的区域结构,均为私有的、写时复制的。下一步是映射共享区域,将一些动态链接库映射到hello的虚拟地址空间,最后设置程序计数器,使之指向hello程序的代码入口。
经过这个内存映射的过程,在下一次调度hello进程时,就能够从hello的入口点开始执行了。
7.8 缺页故障与缺页中断处理
Linux将虚拟内存组织成段的集合。内核为每个进程维护一个单独的任务结构,这个任务结构的第一个条目指向mm_struct,它描述了虚拟内存的当前状态,其中的pgd字段又会指向一个区域结构的链表,每个区域结构都描述了当前虚拟地址的一个区域,或者称为一个段。一个具体的区域结构包括vm_start和vm_end等字段,记录区域的相关信息。
图7.8.1 Linux虚拟内存组织结构
假设MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:
首先判断虚拟地址A是否合法,缺页处理程序会搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end做比较。如果指令不合法则触发段错误,从而终止该进程。
然后处理程序会判断试图进行的内存访问是否合法,也就是进程是否有读写这个区域内页面的权限。如果访问不合法,那么处理程序会触发一个保护异常,终止这个进程。
最后,确保了以上两点的合法性后,根据页式管理的规则,牺牲一个页面,并赋值为需要的数据,然后更新页表并再次触发MMU的翻译过程。
图7.8.2 Linux缺页处理
7.9 动态存储分配管理
动态存储分配管理由动态内存分配器完成。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。堆是一个请求二进制零的区域,它紧接在未初始化的数据区后开始,并向上生长(向更高的地址)。分配器将堆视为一组不同大小的块的集合来维护。
每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显示地被应用程序所分配。
一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
动态内存分配器从堆中获得空间,将对应的块标记为已分配,回收时将堆标记为未分配。而分配和回收的过程中,往往涉及到分割、合并等操作。
动态内存分配器的目标是在对齐块的基础上,尽可能地提高吞吐率及空间占用率,即减少因为内存分配造成的碎片。其实现常见的数据结构有隐式空闲链表、显式空闲链表、分离空闲链表,常见的放置策略有首次适配、下一次适配和最佳适配。
为了更好的介绍动态存储分配的实现思想,以隐式空闲分配器的实现原理为例进行介绍:
图7.9.1 隐式空闲链表堆块结构
隐式空闲链表分配器的实现涉及到特殊的数据结构。其所使用的堆块是由一个子的头部、有效载荷,以及可能的一些额外的填充组成的。头部含有块的大小以及是否分配的信息。有效载荷用来存储数据,而填充块则是用来对付外部碎片以及对齐要求。
基于这样的基本单元,便可以组成隐式空闲链表。
图7.9.2 隐式空闲链表结构
通过头部记录的堆块大小,可以得到下一个堆块的大小,从而使堆块隐含地连接着,从而分配器可以遍历整个空闲块的集合。在链表的尾部有一个设置了分配位但大小为零的终止头部,用来标记结束块。
当请求一个k字节的块时,分配器搜索空闲链表,查找足够大的空闲块,其搜索策略主要有首次适配、下一次适配、最佳适配三种。
一旦找到空闲块,如果大小匹配的不是太好,分配器通常会将空闲块分割,剩下的部分形成一个新的空闲块。如果无法搜索到足够空间的空闲块,分配器则会通过调用sbrk函数向内核请求额外的堆内存。
当分配器释放已分配块后,会将释放的堆块自动与周围的空闲块合并,从而提高空间利用率。为了实现合并并保证吞吐率,往往需要在堆块中加入脚部进行带边界标记的合并。
7.10 本章小结
程序的实现涉及到从磁盘到主存,从主存到高速缓存,从高速缓存再到寄存器的层级存储。
操作系统将主存抽象为虚拟内存,作为磁盘的缓存。在程序执行时从磁盘加载到主存,并将其主存的物理地址映射为虚拟地址,这样,便可以通过虚拟地址对主存进行访问,从而防止了各个进程之间的冲突与错误。操作系统通过MMU将虚拟地址转换为物理地址,利用TLB和多级页表提高其访问速度和内存利用率,从而实现对主存的有效访问。另外,在发生缺页时,操作系统通过信号处理程序能够很好的解决。
CPU对主存的访问同样采用缓存, 通过三级Cache高效的对数据进行读写。
程序运行过程中常常涉及到动态内存分配,动态内存分配通过动态内存分配器完成,能够对堆空间进行合理的分配与管理,分割与合并。现代使程序内存分配器采取了多种策略来提高吞吐量以及内存占用率,从在灵活使用内存的基础上保证了效率。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m个字节的序列,所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这个设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得输入和输出都能以一种统一且一致的方式的来执行。
一个应用程序通过要求内核打开相应的文件来宣告它想访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,而文件的相关信息由内核记录,应用程序只需要记录这个描述符。
Linux shell创建的每个进程开始时都包含标准输入、标准输出、标准错误三个文件,供其执行过程中使用。
对于每个打开的文件,内核保持着一个文件位置k,初始为0,即从文件开头起始的字节偏移量,应用程序能够通过执行seek操作来显式的改变其值。
至于读操作,就是从文件复制n个字节到内存,并将文件位置k增加为k + n。当k大于等于文件大小时,触发EOF条件,即读到文件的尾部。
最后,在结束对文件的访问后,会通过内核关闭这个文件,内核将释放打开这个文件时创建的数据结构,并将描述符恢复到可用的描述符池中。
8.2 简述Unix IO接口及其函数
Linux以文件的方式对I/O设备进行读写,将设备均映射为文件。对文件的操作,内核提供了一种简单、低级的应用接口,即Unix I/O接口。
Unix I/O接口提供了以下函数供应用程序调用:
打开文件:int open(char *filename, int flags, mode_t mode);
关闭文件:int close(int fd);
读文件:ssize_t read(int fd, void *buf, size_t n);
写文件:ssize_t write(int fd, const void *buf, size_t n);
8.3 printf的实现分析
要分析printf函数的实现那么就需要了解Linux下printf函数的实现:
static int printf(const char *fmt, ...)
{
va_list args;
int i;
va_start(args, fmt);
write(1,printbuf,i=vsprintf(printbuf, fmt, args));
va_end(args);
return i;
}
其中*fmt是格式化用到的字符串,而后面省略的则是可变的形参,即printf(“%d”, i)中的i,对应于字符串里面的缺省内容。
va_start的作用是取到fmt中的第一个参数的地址,下面的write来自Unix I/O,而其中的vsprintf则是用来格式化的函数。这个函数的返回值是要打印出的字符串的长度,也就是write函数中的i。该函数会将printbuf根据fmt格式化字符和相应的参数进行格式化,产生格式化的输出,从而write能够打印。
在Linux下,write函数的第一个参数为fd,也就是描述符,而1代表的就是标准输出。查看write函数的汇编实现可以发现,它首先给寄存器传递了几个参数,然后调用syscall结束。write通过执行syscall指令实现了对系统服务的调用,从而使内核执行打印操作。
内核会通过字符显示子程序,根据传入的ASCII码到字模库读取字符对应的点阵,然后通过vram(显存)对字符串进行输出。显示芯片将按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),最终实现printf中字符串在屏幕上的输出。
8.4 getchar的实现分析
getchar的实现大体如下:
int getchar(void)
{
char c;
return (read(0,&c,1)==1)?(unsigned char)c:EOF
}
可以看到,getchar函数通过调用read函数返回字符。其中read函数的第一个参数是描述符fd,0代表标准输入。第二个参数输入内容的指针,这里也就是字符c的地址,最后一个参数是1,代表读入一个字符,符号getchar函数读一个字符的设定。read函数的返回值是读入的字符数,如果为1说明读入成功,那么直接返回字符,否则说明读到了buf的最后。
read函数同样通过sys_call中断来调用内核中的系统函数。键盘中断处理子程序会接受按键扫描码并将其转换为ASCII码后保存在缓冲区。然后read函数调用的系统函数可以对缓冲区ASCII码进行读取,直到接受回车键返回。
这样,getchar函数通过read函数返回字符,实现了读取一个字符的功能。
8.5 本章小结
Linux将I/O输入都抽象为了文件,并提供Unix I/O接口。通过这个接口,程序能够进行输入与输出,只需要直到描述符,底层硬件实现操作系统就可以实现。Linux本身提供的一些系统函数已经实现了对底层的调用,例如write函数。printf函数正是通过它间接向标准输出这个文件输出内容,它会调用syscall触发中断以内核模式对硬件进行操作。
有了I/O接口与文件这个抽象,应用程序能够很方便的调用底层,对输入与输出设备进行操作。
结论
经过研究,可以大体概括hello程序所经历的过程:
-
生成阶段:预处理→编译→汇编→链接;
-
加载阶段:shell fork子进程→execve;
-
执行阶段:磁盘读取、虚拟内存映射、CPU执行指令、内核调度、缓存加载数据、信号处理、Unix I/O输入与输出;
-
终止阶段:进程终止、shell与内核对其进行回收。
可以发现,一个简单的hello程序涉及一系列复杂的编译器、操作系统、硬件实现机制。程序的运行与内核、硬件的多方面协调工作密不可分。简单的一条指令需要成千上万条底层步骤,无论是内部处理还是输入输出。
硬件系统的设计贯彻了冯诺依曼的构想,又经过数十年的迭代更新变得精巧与复杂。操作系统的设计体现了多方面程序设计思想,从底层出发让软件与应用层面能够调度硬件设备。抽象与系统的思想在计算机系统的实现过程中得到了深入的体现。
为了提高性能,硬件层在设计与制造工艺角度不断进阶,软件层则在时空效率上处处考虑。提高运行速度,降低资源占用,保证系统安全,操作系统为了程序能够高效的运行在设计上令人惊叹。通过文件对I/O设备进行抽象,通过处理器层级关系实现缓存从而提高运行速度,命中与缺页均有不同的策略。通过虚拟内将进程隔离,防止程序直接互相干扰以及影响内核安全。通过信号对异常进行有效的反馈,应对系统运行中的各类问题。
系统的思想将一切软硬件设备组织的恰到好处,交互的过程有一种协调的美感,程序运行的背后是无数二进制码在硬件层面的流动,一个简单的hello world程序背后也充满着思想。
附件
hello.i: hello.c预编译的结果,用于研究预编译的作用以及进行编译器的下一步编译操作。
hello.s: hello.i编译后的结果,用于研究汇编语言以及编译器的汇编操作,可以与hello.c对应,分析底层的实现。
hello.o: hello.s汇编后的结果,可重定位目标程序,没有经过链接,用于链接器或编译器链接生成最终可执行程序。
hello.out: hello.o链接后生成的可执行目标文件,可以用来反汇编或者通过EDB、GDB等工具分析链接过程以及程序运行过程,包括进入main函数前后发生的过程。
hello: 同hello.out,由gcc -m64 -no-pie -fno-PIC hello.c -o
hello命令直接生成。
hello.o.s: 对可重定位目标文件反汇编得到,可以与对可执行目标文件反汇编得到的代码对比来分析链接过程。
asm.s: 对可执行目标文件反汇编得到,可以用来分析链接过程与寻址过程。
参考文献
-
兰德尔E.布莱恩特 大卫R.奥哈拉伦. 深入理解计算机系统(第3版).
机械工业出版社. 2018.4. -
百度百科. 预处理命令.
https://baike.baidu.com/item/预处理命令/10204389.
2018-08-14. -
spfLinux. Ubuntu系统预处理、编译、汇编、链接指令.
https://blog.csdn.net/spflinux/article/details/54427494. 2017-1-14. -
不游泳的鱼. 编译入门:传说中的编译是在做什么.
http://www.cnblogs.com/li--chao/p/9229927.html. 2018-09-01. -
andyhzw. 汇编中 mov和movl 有什么区别.
http://blog.chinaunix.net/uid-28458801-id-3552409.html. 2013-03-28. -
wilcohuang. Linux下逻辑地址、线性地址、物理地址详细总结.
https://blog.csdn.net/freeelinux/article/details/54136688. 2017-1-6 -
Pianistx. [转]printf 函数实现的深入剖析.
https://www.cnblogs.com/pianist/p/3315801.html. 2013-09-11. -
ZK的博客. read和write系统调用以及getchar的实现.
https://blog.csdn.net/ww1473345713/article/details/51680017. 2016-6-15.