计算机系统
大作业
题 目 程序人生
目录
计算机科学与技术学院
2022年5月
本文通过一个程序hello.c 从产生到死亡的一生,分析了Linux 系统下从hello.c源代码文件经过预处理、编译、汇编、链接、执行以至终止的全过程。通过过 gcc、objdump、gdb、edb 等工具对一段程序代码预处理、编译、汇编、 链接与反汇编的过程进行分析与比较,并且通过 shell 及其他 Linux 内置程序对进 程运行过程进行了分析,说明了Linux操作系统如何对Hello程序进行进程管理、存储管理和I/O管理。 全文内容也是对计算机系统学习的总结和回顾。
关键词:编译;链接;进程;shell-bash;IO;操作系统管理;计算机系统
目 录
6.2 简述壳Shell-bash的作用与处理流程... - 29 -
6.3 Hello的fork进程创建过程... - 30 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 35 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 36 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 37 -
7.5 三级Cache支持下的物理内存访问... - 38 -
7.6 hello进程fork时的内存映射... - 39 -
7.7 hello进程execve时的内存映射... - 40 -
第1章 概述
1.1 Hello简介
1. P2P(from program to process)
在Linux系统中,源代码文件hello.c先经cpp预处理生成文本文件hello.i,hello.i经cc1编译生成汇编文件hello.s,hello.s经ld链接生成可执行程序文件hello。最后在shell中键入命令(./hello)后,操作系统(OS)的进程管理为其fork创建子进程 ,使用 execve 来加载运行 hello,为其分配虚拟内存空间,构建虚拟内存映射,MMU 组织各级页表与 cache给予 hello 想要的所有信息,CPU 给予hello时间片并控制逻辑流。
2. 020(from zero to zero)
Hello程序执行前,不占用内存空间--0。P2P过程后,子进程首先调用execve,依次进行虚拟内存映射、物理内存载入;随后进入主函数执行程序代码,程序调用各种系统函数实现屏幕输出信息等功能;最终程序结束,shell父进程回收此子进程,其相关的所有内存中的状态信息和数据结构被清除--0。整个流程构成了020的过程。
1.2 环境与工具
1.硬件环境
Windows
处理器:AMD Ryzen 7 4800H with Radeon Graphics, 2900 Mhz ,8个内核,16个逻辑处理器
RAM:8.00GB
2.软件环境
Windows 10 家庭中文版
ubuntu-20.04.4-desktop-amd64
3.工具
Visual Studio Code
cpp(预处理器)
gcc(编译器)
as(汇编器)
ld(链接器)
GNU readelf
GNU gdb
EDB
1.3 中间结果
- hello.i-- hello.c经预处理得到的ASCII文本文件
- hello.s-- hello.i经编译得到的汇编代码ASCII文本文件
- hello.o-- hello.s经汇编得到的可重定位目标文件
- hello.elf-- hello.o经readelf分析得到的文本文件
- hello_dis.s-- hello.o经objdump反汇编得到的文本文件
- hello-- hello.o经链接得到的可执行文件
- hello_1.elf-- hello经readelf分析得到的文本文件
- hello_1_dis.txt-- hello经objdump反汇编得到的文本文件
1.4 本章小结
本章首先分析了P2P、O2O的概念和流程,随后查看了使用的环境及工具的相关信息,给出了完成本论文过程中用到的所有中间文件。
第2章 预处理
2.1 预处理的概念与作用
1. 预处理的概念
1)从GCC编译过程分解图中可以看到预编译是整个编译过程中最开始的步骤
2)预编译的过程可以概括为就是处理源代码文件中以”#”开头的预编译指令
3)处理预编译指令主要遵循以下规则:
a. 将所有的“#define”删除,并且展开所有的宏定义
b. 处理所有条件预编译指令,比如“#if”、“#ifdef'”、“#elif”、“#else”、“#endif'”。
c.处理“include”预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件。
d.删除所有的注释“∥”和“/**/”。
e.添加行号和文件名标识,比如#2“hllo.c”2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
f.保留所有的抑#pragma编译器指令,因为编译器须要使用它们。
2. 预处理的作用:
1)预处理的行为是由指令控制的。这些指令是由#字符开头的一些命令。
2)例如常见的预编译指令(#define和#include)
#define指令定义了一个宏---用来代表其他东西的一个命令,通常是某一个类型的常量。预处理会通过将宏的名字和它的定义存储在一起来响应#define指令。当这个宏在后面的程序中使用到时,预处理器”扩展”了宏,将宏替换为它所定义的值。
#include指令告诉预处理器打开一个特定的文件,将它的内容作为正在编译的文件的一部分“包含”进来。例#include<stdio.h>指示预处理器打开一个名字为stdio.h的文件,并将它的内容加到当前的程序中。
3)预处理器的输入是一个C语言程序,程序可能包含指令。预处理器会执行这些指令,并在处理过程中删除这些指令。预处理器的输出是另外一个程序:原程序的一个编辑后的版本,不再包含指令。预处理器的输出被直接交给编译器,编译器检查程序是否有错误,并经程序翻译为目标代码。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i:
通过vim查看hello.i 文件(截取部分):
2.3 Hello的预处理结果解析
hello.c源程序主要包括三部分:注释,#include预编译指令,main函数,可以观察对照这三部分来解析预处理结果:
1)在前面的预处理的作用中我们说过预处理就是输入一个程序然后输出另一个程序,对照查看hello.i文件发现其中并不包含注释部分,与前面提到的预处理的规则:d.删除所有的注释“∥”和“/**/”。相符和。
2)查看hello.i文件发现# linenum filename flags的代码行格式
a. 源文件名和行号信息通过格式为# linenum文件名标志的行来传递。这些被称为行标记。它们会根据需要插入到输出中(但绝不会在字符串或字符常量中)。他们的意思是,下一行起源于文件filename中的linenum行。
b. 文件名后面有零个或多个标志,分别是' 1 '、' 2 '、' 3 '或' 4 '。如果有多个标志,则用空格隔开。' 1 '表示新文件的开始。' 2 '表示返回到一个文件(在包含了另一个文件之后)。' 3 '这表示以下文本来自系统头文件,因此某些警告应该被抑制。' 4 '表明下面的文本应该被包装在隐式extern“C”块中。
3)预处理对于预编译指令的处理情况(截取了其实和结尾部分):
a.#include <stdio.h>
b. #include <unistd.h>
c. #include <stdlib.h>
d.main函数部分保留:
2.4 本章小结
本章主要分析了GCC编译过程中的第一个环节--预处理部分,分析了预处理的概念和作用,并详细说明了预处理过程中处理预编译指令所遵循的规则,并且从整体和局部分析了预处理的功能。接着实际操作了Ubantu下的预处理指令,然后依据前面的说明,对照分析了hello.c到hello.i预处理所完成的工作,并且了解了hello.i文件中的# linenum filename flags的代码行格式,以及它所表示的含义。由此对整个预处理的过程有了一个全面深入的认识。
第3章 编译
3.1 编译的概念与作用
1. 编译的概念:
1)编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件,这个过程也是整个程序构建的核心部分,直观上就是从 .i 文件到 .s 文件即预处理后的文件到生成汇编语言程序的过程。
2)编译过程:
a.词法分析:
源代码程序被输入到扫描器(Scanner),它负责进行词法分析,运用一种类似于有限状态机(Finite State Machine)的算法可以很轻松地将源代码的字符序列分割成一系列的记号(Tokn)。
b.语法分析:
语法分析器(Grammar Parser)对由扫描器产生的记号进行语法分析,从而产生语法树(Syntax Tree)。整个分析过程采用了上下文无关语法(Context--free Grammar)的分析手段,简单来说,由语法分析器生成的语法树就是以表达式(Expression)为节点的树。对应地我们知道,C语言的一个语句是一个表达式,而复杂的语句是很多表达式的组合。
c.语义分析:
语义分析由语义分析器(Semantic Analyzer)来完成。语法分析仅仪是完成了对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义。编译器所能分析的语义是静态语义(StaticSemantic),所谓静态语义是指在编译期可以确定的语义,与之对应的动态语义(Dynamic Semantic)就是只有在运行期才能确定的语义。静态语义通常包括声明和类型的匹配,类型的转换。
经过语义分析阶段以后,整个语法树的表达式都被标识了类型,如果有些类型需要做隐式转换,语义分析程序会在语法树中插入相应的转换节点。
d.中间语言生成:
现编译器有着很多层次的优化,在源代码级别会有一个优化过程--源码级优化器(Source Code Optimizer)。源代码级优化器会在源代码级别进行优化,但是直接在语法树上作优化比较困难,所以源代码优化器往往将整个语法树转换成中间代码(Intermediate Code),它是语法树的顺序表示,其实它已经非常接近目标代码了。但是它一般跟目标机器和运行时环境是无关的,比如它不包含数据的尺寸、变量地址和寄存器的名字等。中间代码有很多种类型,在不同的编译器中有着不同的形式。
中间代码使得编译器可以被分为前端和后端。编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。
e.目标代码生成与优化:
在经过中间代码生成后,目标代码的生成与优化属于编辑器后端。编译器后端主要包括代码生成器(Code Generator)和目标代码优化器(Target Code Optimizer)。代码生成器将中间代码转换成目标机器代码,这个过程依赖于目标机器,因为不同的机器有着不同的字长、寄存器等。最后目标代码优化器对上述的目标代码进行优化。
2.编译的作用
1)从最直观的角度来讲,编译器就是将高级语言翻译成机器语言的一个工具。
2)这个过程也是整个程序构建的核心部分,将 .i 文件转化为 .s 文件即预处理后的文件到生成汇编语言程序的过程。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
编译命令执行截图:
3.3 Hello的编译结果解析
3.3.1数据
1)字符串:
hello.c用到的格式字符串、输出字符串被保存在.rodata段
输出地两个字符串:
源程序:
汇编代码:
2)整型常量:条件判断值、循环终止条件值在.text段,运行时使用
源程序:
汇编:
3)整型局部变量:
hello.c中定义了整型局部变量:局部变量i(4字节int型)在运行时保存在栈中
源程序:
汇编代码:
初始化:
循环递增与比较:
3.3.2 数组,指针
main()的第二个参数是char *argv[](字符串数组指针),在argv数组中,argv[0]为输入程序的路径和名称字符串起始位置,argv[1]和argv[2]为其后的两个参数字符串的起始位置。汇编代码中相关的指令如下:
这条指令将main()的第二个参数从寄存器写到了栈空间中。
上述指令从栈上取这一参数,并按照基址-变址寻址法访问argv[1]和argv[2](由于指针char*大小为8字节,分别偏移8、16字节来访问)。
3.3.3 算术操作
hello.c在for循环中用到了自增运算,对应的汇编代码的实现通过addl指令,”l”代表int型为4字节:
源程序:
汇编代码:
3.3.4 关系操作与条件转移
hello.c中分别在if条件判断和for循环中涉及到关系操作与条件转移:
源程序1:
汇编代码1:
je使用cmpl设置的条件码(ZF),若ZF = 0,说明argc等于4,条件不成立,控制转移至.L2(for循环部分,程序主体功能);若ZF = 1,说明argc不等于4(即执行程序时传入的参数个数不符合要求),继续执行输出提示信息并退出。
源程序2:
汇编代码2:
源程序中判断i<8,而编译器将其调整为判断i<=7。
jle使用cmpl设置的条件码(ZF SF OF),若(SF^OF) | ZF = 1,说明循环终止条件不成立控制转移至.L4,继续执行循环体;若(SF^OF) | ZF = 0,则循环终止条件成立(变量i的值达到10),不再跳转至循环体开始位置,继续向后执行直至退出。
3.3.5 函数操作
源程序中有关的的函数:main()函数,printf()函数exit()函数,sleep()函数,getchar()函数,分别看一下编译过程中是如何处理的
3.4 本章小结
本章说明了GCC编译过程中的编译部分,由hello.i经编译器处理得到hello.s,并对生成的汇编程序中涉及到的C语言各种数据类型和各类操作做了说明,具体分析了hello程序的编译结果。编译的过程是编译器将预处理后得到的文件进一步翻译为汇编语言的过程。编译阶段分析检查源程序,确认所有的语句都符合语法规则后将其翻译成等价的汇编代码。
第4章 汇编
4.1 汇编的概念与作用
1. 汇编的概念:
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译即可。
2. 汇编的作用:
汇编器将编译生成的.s后缀文件翻译成机器语言指令,
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
执行截图:
4.3 可重定位目标elf格式
4.3.1 使用readelf命令读取elf文件
readelf -a hello.o > hello.elf
4.3.2 ELF Header
- Magic表示一个16个 字节的序列作为ELF头的开始
- 剩下部分的信息包括ELF头的大小(64bytes),目标文件类型(这里是可重定位),机器类型,节头部表的文件偏移(1240),节头部表中条目的大小(64bytes)和数量(14)等。
4.3.3 节头部表中的条目
在目标文件中,不同节的位置,大小等信息是由节头部表描述的,每个节都有一个固定大小的条目(entry):
4.3.4 重定位条目
当汇编器遇到对位置未知的目标引用时,就会产生一个重定位条目,由此告诉链接器在生成可执行文件的时候如何修改这个引用:
1)观察可发现有来自两个重定位节的条目
2)包含的信息主要有:
a)Offset(需要被修改的引用的节偏移):要修改的位置在对应节的偏移量
b)Info():重定位类型和符号索引。前面两个字节对应的是在符号表中的索引,后面的四个字节对应的是重定位的类型。
比如上面的getchar对应的info(001100000004),0011对应符号表中的17,如图所示:
而00000004则对应重定位类型即Type(R_X86_64_PLT32).
c)Type():R_X86_64_PC32(PC相对寻址),R_X86_64_PLT32(使用PLT表寻址),R_X86_64_32(绝对寻址)。
d)Sym.Value:与符号表中的Value表示的意思相同,即距定义目标的节的起始位置的偏移
e)Sym.Name:符号名称
f)Append:符号常数,可用于做偏移调整
4.3.5 符号表
每个可重定位目标文件在.symtab中都有一张符号表,它包含该模块定义和应用的符号的信息,但是不包含局部变量的条目:
1)符号表中由18个条目组成,每个条目包含8种属性值
2)各个属性对应的含义:
A)Num:字符串表中的字节偏移
B)Value:距定义目标的节的起始位置的偏移
C)Bind:用于标识本地的还是全局的
D)Ndx:整数索引来标识每个节(1表示.text节)
4.4 Hello.o的结果解析
反汇编生成hello_dis.s:
生成的反汇编代码:
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 20 sub $0x20,%rsp
c: 89 7d ec mov %edi,-0x14(%rbp)
f: 48 89 75 e0 mov %rsi,-0x20(%rbp)
13: 83 7d ec 04 cmpl $0x4,-0x14(%rbp)
17: 74 16 je 2f <main+0x2f>
19: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 20 <main+0x20>
1c: R_X86_64_PC32 .rodata-0x4
20: e8 00 00 00 00 callq 25 <main+0x25>
21: R_X86_64_PLT32 puts-0x4
25: bf 01 00 00 00 mov $0x1,%edi
2a: e8 00 00 00 00 callq 2f <main+0x2f>
2b: R_X86_64_PLT32 exit-0x4
2f: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
36: eb 48 jmp 80 <main+0x80>
38: 48 8b 45 e0 mov -0x20(%rbp),%rax
3c: 48 83 c0 10 add $0x10,%rax
40: 48 8b 10 mov (%rax),%rdx
43: 48 8b 45 e0 mov -0x20(%rbp),%rax
47: 48 83 c0 08 add $0x8,%rax
4b: 48 8b 00 mov (%rax),%rax
4e: 48 89 c6 mov %rax,%rsi
51: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 58 <main+0x58>
54: R_X86_64_PC32 .rodata+0x22
58: b8 00 00 00 00 mov $0x0,%eax
5d: e8 00 00 00 00 callq 62 <main+0x62>
5e: R_X86_64_PLT32 printf-0x4
62: 48 8b 45 e0 mov -0x20(%rbp),%rax
66: 48 83 c0 18 add $0x18,%rax
6a: 48 8b 00 mov (%rax),%rax
6d: 48 89 c7 mov %rax,%rdi
70: e8 00 00 00 00 callq 75 <main+0x75>
71: R_X86_64_PLT32 atoi-0x4
75: 89 c7 mov %eax,%edi
77: e8 00 00 00 00 callq 7c <main+0x7c>
78: R_X86_64_PLT32 sleep-0x4
7c: 83 45 fc 01 addl $0x1,-0x4(%rbp)
80: 83 7d fc 07 cmpl $0x7,-0x4(%rbp)
84: 7e b2 jle 38 <main+0x38>
86: e8 00 00 00 00 callq 8b <main+0x8b>
87: R_X86_64_PLT32 getchar-0x4
8b: b8 00 00 00 00 mov $0x0,%eax
90: c9 leaveq
91: c3 retq
与hello.s的比较分析:
- 数的进制表示方式发生了改变,hello.s中数的表示用的是十进制,在反汇编的hello_dis.s中用的是十六进制
hello.s:
hello_dis.s:
- 分支转移的不同表示:分支转移地址表示,hello.s上为.L1,.LC1等段名称,而反汇编代码中跳转指令跳转的位置是相对于main函数起始位置偏移的地址
hello.s:
hello_dis.s:
- 函数调用时,hello.s中call指令使用的是函数名,在反汇编代码中call指令后是调用函数的相对偏移地址。并且对应的函数引用后面的一行,都显示有一个重定位条目,也就是在.rela.text节中为其添加了重定位条目,它们告诉链接器对函数符号的引用如何进行重定位
hello.s:
hello_dis.s:
- 字符串等符号被替换成了待重定位的地址;hello.s上是通过.LC1(%rip)的形式访问,在反汇编的hello_dis.s中以0x0(%rip)的形式访问,并在后面一行添加了重定位条目:
hello.s:
hello_dis.s:
4.5 本章小结
本章主要分析了汇编的概念、作用,说明了可重定向目标文件的结构,及对反汇编代码与汇编前的代码做了比较来分析汇编如何选择产生重定位条目来为链接器的工作打下基础。整体上看,汇编将汇编语言代码转化为机器语言,生成可重定位目标文件为链接阶段做好准备。
第5章 链接
5.1 链接的概念与作用
1. 链接的概念:
整体上看,链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,链接器将多个可重定位目标文件合并,生成可执行目标文件,这个文件可以被加载到内存并执行。局部上看,链接包含链接器为构造可执行文件所完成的两个主要任务:符号解析和重定位。
2.链接的作用:
1)链接是指将可重定向目标文件组合起来形成一个可执行目标文件。
2)链接器还在软件开发中扮演了一个关键的角色,它使分离编译成为了可能。
5.2 在Ubuntu下链接的命令
使用命令:ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o hello.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o -o hello
5.3 可执行目标文件hello的格式
使用命令:readelf -a hello > hello_1.elf.
读取可执行文件hello的ELF格式相关信息输出至文件hello_1.elf
分析:
- ELF头:总体与可重定位目标文件的类似,值得注意的便是Entry point address也就是程序的入口,运行时第一条指令的地址
- 各段的基本信息由节头部表给出,一共包含27个条目:
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
5.4 hello的虚拟地址空间
通过edb执行hello如图(Data Dump窗口中显示的就是hello的虚拟空间内容):
1)Symbols中的hello!main与Data Dump对照截图:
2)Symbols中的hello! init与Data Dump对照截图:
其对应的在elf中:
3)Symbols中的hello! .rodadata与Data Dump对照截图:
其对应的在elf中:
4)在Memory Regions找到hello最开始的起始地址,并在Dump中打开,可以看到开头就是ELF头如图:
其对应的在elf中:
5.5 链接的重定位过程分析
使用命令:objdump -d -r hello > hello_1_dis.txt
比较分析可重定位目标文件的反汇编hello_dis.s和可执行目标文件的返回编hello_1_dis.txt,来说明链接器如何基于重定位条目进行反汇编:
- 字符串符号:
A)首先观察hello_dis.s中汇编产生的重定位条目(如图):
“1c: R_X86_64_PC32 .rodata-0x4”,其中1c表示offset,与之前分析过的重定位条目中的属性表达的意思一致,后一部分表示重定位类型(此处为32位PC相对地址),之后是type和addend。
B)然后观察hello_1_dis.txt重定位之后的表示:
可看出字符串符号引用所在的节的运行时地址ADDR(s) = 0x401125,由此链接器首先计算出应用的运行时地址:refaddr = ADDR(s) + r.offset = 0x401125 + 0x1c = 0x401141, 而字符串符号所指向的地址为ADDR(r.symbol) = ADDR(.rodata) = 0x402008,为了使字符串符号引用在运行时指向字符串所在的地址,则*refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr) = (unsigned)( 0x402008 + (-0x4) – 0x401141) = 0x0ec3,由于是小端法表示对应的应该是c3 0e,与实际情况符合。
- 函数调用
过程链接表延迟绑定,在第一次被调用时延迟解析它的运行地址
5.6 hello的执行流程
使用EDB跟踪程序执行过程,按顺序记录如下:
5.7 Hello的动态链接分析
动态链接库中的函数在程序执行的时候才会确定地址,所以编译器无法确定其地址。为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。延迟绑定通过全局偏移量表(GOT)和过程链接表(PLT)的协同工作实现函数的动态链接,其中GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
过程链接表(PLT):PLT是一个数组,其中每个条目是16字节代码。PLT [0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。每个条目都负责调用一个具体的函数。
全局偏移量表(GOT):GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT [0]和GOT [1]包含动态链接器在解析函数地址时会使用的信息。GOT [2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
程序调用共享库函数时,会首先跳转到PLT执行指令,第一次跳转时,GOT条目为PLT下一条指令,将函数ID压栈,然后跳转到PLT[0],在PLT[0]再将重定位表地址压栈,然后转进动态链接器,在动态链接器中使用两个栈条目确定函数运行时地址,重写GOT,再将控制传递给目标函数。以后如果再次调用同一函数,则通过间接跳转将控制直接转移至目标函数。
下图为调用dl_init之前.got.plt段的内容:
下图为调用dl_init之后.got.plt段的内容:
5.8 本章小结
本章描述的是可重定位目标文件hello.o通过链接器生成可执行目标文件的过程,展示了链接的符号解析和重定位两项主要的任务。查看了hello的虚拟空间与节头部表信息的对应关系,通过可执行重定位目标文件的反汇编代码和可执行目标文件的反汇编代码对比分析链接器如何根据重定位条目来进行重定位,对重定位的过程了解更加深入。之后分析了hello的执行流程,最后进行了动态链接分析。
第6章 hello进程管理
6.1 进程的概念与作用
1. 进程的概念
进程是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
2. 进程的作用
1)进程为用户提供了这样的假象,我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断地执行我们程序中地指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
2)进程提供给应用程序的关键抽象:独立的逻辑控制流;私有的地址空间。
3)每次用户通过shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
6.2 简述壳Shell-bash的作用与处理流程
1. 作用
Shell--壳,是指"为使用者提供操作界面"的软件。同时它又是一种程序设计语言。作为命令语言,它交互式解释和执行用户输入的命令或者自动地解释和执行预先设定好的一连串的命令。Shell-bash是传统的来实现shell命令的交互级应用程序,代表用户运行其他程序。它是系统的用户界面,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核去执行。
2. 处理流程
(1) 从终端读入输入的命令;
(2) 将输入字符串切分获得所有的参数;
(3) 如果是内置命令则立即执行;
(4) 若不是则调用相应的程序执行;
(5) 在程序执行期间始终接受键盘输入信号,并对输入信号做相应处理。
6.3 Hello的fork进程创建过程
键入运行可执行文件的命令,根据前面对于bash的处理流程,首先shell-bash会解析命令后判断出不是一个内置命令,然后通过fork函数创建子进程。fork函数被调用一次会返回两次,在父进程中,fork函数返回子进程的PID,在子进程中,fork函数返回0。通过fork函数,子进程得到与父进程用户级虚拟地址空间相同但独立的一份副本,包括代码和数据段、堆、共享库、用户栈。hello进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程还可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
使用ps查看进程:
使用pstree查看进程之间的关系:
6.4 Hello的execve过程
execve函数:
int execve(const char *filename , const char *argv[], const char *envp[])
子进程创建后,shell调用execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp。之后当出现错误时,例如找不到hello,execve才会返回到调用程序。
在execve加载了hello后,它调用启动代码,启动代码设置栈,并将控制转移给新程序的主函数main,此时用户栈已经包含了命令行参数和环境变量,进入main函数后开始逐步运行程序。
execve函数在当前进程的上下文中加载并运行程序,执行过程会覆盖当前进程的地址空间,但并没有创建一个新进程。新的程序仍然有相同的PID,并且继承了调用execve函数时已打开的所有文件描述符。
6.5 Hello的进程执行
1.有关进程执行的一些概念:
1)上下文:
内核重新启动一个被抢占的进程所需要恢复的原来的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象构成。
2)上下文切换:
在内核调度了一个新的进程运行时,它就抢占当前进程,通过:a.保存当前进程的上下文;b.恢复某个先前被抢占的进程被保存的上下文;c.将控制传递给这个新恢复的进程;这三个步骤将控制转移到新的进程,这个过程便是进程的上下文切换
3)进程时间片:
一个进程执行它的控制流的一部分的每一个时间段叫做时间片(time slice),多任务也叫时间分片(time slicing)。
4)进程调度:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,这种决策称为调度,是由内核中的调度器代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。
5)用户态:
进程运行在用户模式中时,不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个I/O操作,也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。
6)核心态:
进程运行在内核模式中时,可以执行指令集中的任何指令,并且可以访问内存中的任意位置。
7)用户态与核心态的转换:
程序在涉及到一些操作时,例如调用一些系统函数,内核需要将当前状态从用户态切换到核心态,执行结束后再改回用户态。
为了保证系统安全,需要限制应用程序所能访问的地址空间范围处理器使用一个寄存器作为模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,一定程度上保证了系统的安全性。
8)sllep()函数的原理:
Linux中并没有提供系统调用sleep(),sleep()是在库函数中实现的,它是通过调用alarm()来设定报警时间,调用sigsuspend()将进程挂起在信号SIGALARM上,然后调用pause()函数使该进程暂停。
alarm(time):执行之后告诉内核,让内核在time秒时间之后向该进程发送一个定时信号,然后该进程捕获该信号并处理;
pause()函数:使该进程暂停让出CPU,但是该函数的暂停是可被中断的
实现流程:
1)挂起进程(或线程)并修改其运行状态
2)用sleep()提供的参数来设置一个定时器。
3)当时间结束,定时器会触发,内核收到中断后修改进程的运行状态。
2.hello进程执行的过程
进程hello执行中有对sleep的调用,内核中的调度器将hello进程挂起,进入内核模式,在执行结束后,内核会恢复hello被抢占时的上下文,回到用户模式。
6.6 hello的异常与信号处理
1. 异常及处理
1)中断:自I/O设备的信号,异步发生,中断处理程序对其进行处理,返回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
2)陷阱:有意的异常,是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
3)故障:由错误情况引起的,它可能能够被故障处理程序修正。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。
4)终止:不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程序会将控制返回给一个abort例程,该例程会终止这个应用程序
2. 信号及处理
1)SIGINT:终止程序(可通过键入Ctrl-C后内核向hello进程发送)
2) SIGSTP:停止直到接收到下一个SIGCONT(可通过键入Ctrl-Z后内核向hello进程发送)
3)SIGCONT:若进程停止则继续执行(键入fg后内核向hello进程发送)
4)SIGKILL:终止程序(可通过键入kill -9 <PID>后内核向hello进程发送)
3. 程序运行过程,命令输入截图:
1)运行过程中随意输入指令:
2)运行中回车,会将按键操作的输入放到缓存区中,程序运行结束执行:
3)运行中按ctrl-c,hello进程被回收并终止
4)运行中按ctrl-z,hello进程挂起,再用ps命令查看,然后用fg命令恢复进程执行
5)kill命令,杀死进程,ps命令查看
6)jobs命令查看已启动的任务状态;pstree命令输出进程间的树状关系。
6.7本章小结
本章首先介绍了进程的概念和作用,通过fork和execve函数说明了hello进程的执行过程,主要阐述了hello的进程管理,包括进程创建、加载、执行以至终止的全过程,之后分析了进程执行过程中异常和信号的处理问题。进一步理解了异常控制流的相关内容,使我对进程、内核的进程调度的有了进一步的认识。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1.逻辑地址:
是指由程序产生的与段相关的偏移地址部分,是相对应用程序而言的,如hello.o中代码与数据的相对偏移地址。
2.线性地址:
指虚拟地址到物理地址变换的中间层,是处理器可寻址的内存 空间(称为线性地址空间)中的地址。程序代码会产生逻辑地址,或者说段中 的偏移地址,加上相应段基址就成了一个线性地址。如果启用了分页机制,那 么线性地址可以再经过变换产生物理地址。若是没有采用分页机制,那么线性 地址就是物理地址。
3. 虚拟地址:
现代系统提供了一种对主存的抽象概念,叫做虚拟内存。使用虚拟寻址,CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被送到内存之前先转换为适当的物理地址。将一个虚拟地址转换为物理地址的任务叫地址翻译。
4. 物理地址:
是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址(hello程序运行时代码、数据等对应的可用于直接在内存中寻址的地址)。如果启用了分页机制(当今绝大多数计算机的情况),那么线性地址会使用页目录和页表中的项变换成物理地址;如果没有启用分页机制,那么线性地址就直接成为物理地址了
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理是实现逻辑地址到线性地址转换机制的基础,段的特征有段基址、段限长、段属性。这三个特征存储在段描述符中,用以实现从逻辑地址到线性地址的转换。
段描述符存储在段描述符表中,通常,我们使用段选择符定位段描述符在这个表中的位置。每个逻辑地址由16位的段选择符和32位的偏移量组成。
段基址规定了线性地址空间中段的开始地址。在保护模式下,段基址长32位。因为基址长度和寻址地址的长度相同,所以段基址可以是0-4GB范围内的任意地址。和一个段有关的信息需要8个字节来描述,这就是段描述符。为了存放这些描述符,需要在内存中开辟出一段空间。在这段空间里所有的描述符都在一起集中存放,这就构成了一个描述符表,描述符表分为两种,GDT和LDT。
一些全局的段描述符,就放在"全局段描述符表(GDT)"中,一些局部的,例如每个进程自己的段描述符,就放在的"局部段描述符表(LDT)"中。
1)保护模式:
寻址方式为:以段选择符作为下标,到GDT/LDT表(全局段描述符表(GDT)和局部段描述符表(LDT))中查到段地址,段地址+偏移地址=线性地址。
2)实模式:
段寄存器含有段值,访问存储器形成物理地址时,处理器引用相应的某个段寄存器并将其值乘以16,形成20位的段基地址,段基地址·段偏移量=线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
分页机制是实现虚拟存储的关键,位于线性地址与物理地址的变换之间设置。
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘上的数据被分割成块,这些块作为磁盘和主存之间的传输单元。VM系统通过将虚拟内存分割为称为虚拟页为大小固定的块来处理这个问题。每个虚拟页的大小固定。类似地,物理内存被分割为物理页,大小与虚拟页相同。
页表是一个存放在物理内存中的数据结构,将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时读取页表。操作系统负责维护页表中的内容,以及再磁盘与DRAM之间来回传送页。
内存分页管理的基本原理是将整个内存区域划分成固定大小的内存页面。程序申请使用内存时就以内存页位单位进行分配。转换通过两个表,页目录表PDE(也叫一级目录)和二级页表PTE。进程的虚拟地址需要首先通过其局部段描述符变换为CPU整个线性地址空间中的地址,然后再使用页目录表和页表PTE映射到实际物理地址上。
虚拟地址(线性地址)到物理地址的变换过程:
n位的线性地址包含两部分:一个p位的虚拟页面偏移(VPO)和一个(n-p)位的虚拟页号。MMU利用虚拟页号(VPN)来选择适当的PTE(页表项),若PTE有效位为1,则说明其后内容为物理页号(PPN),否则缺页。而物理地址中低p位的物理页偏移量(PPO)与虚拟页偏移量(VPO)相同,PPN与PPO连接即得物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
1. 多级页表:
多级页表为层次结构,用于压缩页表。这种方法从两个方面减少了内存要求。第一,如果一级页表中的一个PTE是空的,那么相应的二级页表就根本不会存在;第二,只有一级页表才需要总是在主存中,虚拟内存系统可以在需要时创建、页面调出或调入二级页表,最经常使用的二级页表才缓存在主存中,减少了主存的压力。
2. TLB:
每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE,以便将虚拟地址翻译为物理地址。为了降低时间开销,MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓冲器。TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。
3. 从TLB中获取物理地址的过程:
(1)CPU产生一个虚拟地址。
(2)MMU从TLB中取出相应的PTE。
(3)MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
(4)高速缓存/主存将所请求的数据字返回给CPU。
4. VA到PA的变换过程:
TLB四路组联,共有16组。对于四级页表,虚拟地址(VA)被划分为4个VPN和1个VPO。CPU产生虚拟地址VA,VA传送给MMU,MMU通过TLBT+TLBI向TLB中匹配,如果命中,则构造物理地址。如果没有命中,MMU向页表中查询,CR3确定第一级页表的起始地址,每个VPN i都是一个到第i级页表的索引。对于前3级页表,每级页表中的每个PTE都指向下一级某个页表的基址。最后一级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问k个PTE。和只有一级的页表结构一样,PPO和VPO是相同的。
7.5 三级Cache支持下的物理内存访问
三级Cache构造:
Cache组织结构示意图:
得到物理地址PA后,通过其访问物理内存,物理地址由CI(组索引)、CT(标记位)、CO(偏移量)组成。
物理内存访问流程:
(1)使用CI进行组索引,对组中每行的标记与CT进行匹配。如果匹配成功且块的valid标志位为1,则命中,然后根据CO取出数据并返回数据给CPU。
(2)若未找到相匹配的行或有效位为0,则L1未命中,继续在下一级高速缓存(L2)中进行类似过程的查找。若仍未命中,还要在L3高速缓存中进行查找。三级Cache均未命中则需访问主存获取数据。
(3)若有高速缓存未命中,则需在得到数据后更新未命中的Cache。,放置策略是首先判断其中是否有空闲块,若有空闲块(有效位为0),则直接将数据写入;若不存在,则需根据替换策略(如LRU、LFU策略等)驱逐一个块再写入。
7.6 hello进程fork时的内存映射
在hello的运行命令被执行时,当ork函数被父进程bash调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有空间地址的抽象概念。
7.7 hello进程execve时的内存映射
hello进程调用execve后,execve在当前进程中加载并运行包含在可执行目标文件中的程序,用hello程序有效地代替了当前程序。
当加载并运行可执行目标文件时,经过以下几个步骤:
(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2)映射私有区域。为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。
(3)映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。下图给出了私有区域和共享区域在内存映射时的位置。
(4)设置程序计数器PC。execve做的最后一件事是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页。假设CPU引用了磁盘上的一个字,而这个字所属的虚拟页并没有缓存在DRAM中。地址翻译硬件会从内存中读取虚拟页对应的页表,说明这个虚拟页没有被缓存,触发一个缺页故障。
缺页异常调用内核中的缺页异常处理程序,处理程序首先判断虚拟地址A是否合法,如果不合法则触发段错误终止进程。如果合法则判断试图进行的内存访问是否合法,如果不合法则出发保护异常终止进程。
如果合法则根据页式管理的规则,该程序会选择一个牺牲页,如果牺牲页已经被修改了,内核会将其复制回磁盘。随后内核从磁盘复制引发缺页异常的页面至内存,更新对应的页表项指向这个页面,随后返回。
缺页异常处理程序返回后,内核会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件,此次页面会命中。
缺页异常处理过程:
7.9动态存储分配管理
分配器分为两种:显式分配器和隐式分配器。
显式分配器要求应用显式地释放人设已分配地块。malloc使用的是显式分配器,通过free函数释放已分配的块。
显式分配器必须在一些相当严格的约束条件下工作:处理任意请求序列;立即响应请求;只使用堆;对齐块;不修改已分配的块。在以上限制条件下,分配器要最大化吞吐率和内存使用率。
常见的放置策略:
首次适配:从头开始搜索空闲链表,选择第一个合适的空闲块。
下一次适配:类似于首次适配,但从上一次查找结束的地方开始搜索。
最佳适配:选择所有空闲块中适合所需请求大小的最小空闲块。
隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾回收器,而自动释放未使用的已经分配的块的过程叫做垃圾收集。
分别介绍两种分配器:
(1)隐式空闲链表分配器。我们可以将堆组织为一个连续的已分配块和空闲块的序列,空闲块是通过头部中的大小字段隐含地连接着的,这种结构为隐式空闲表。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块地集合。一个块是由一个字的头部、有效载荷、可能的填充和一个字的脚部,其中脚部就是头部的一个副本。头部编码了这个块的大小以及这个块是已分配还是空闲的。分配器就可以通过检查它的头部和脚部,判断前后块的起始位置和状态。
(2)显示空闲链表分配器。将堆组成一个双向空闲链表,在每个空闲块中,都包含一个pred和succ指针。一种方法是用后进先出(LIFO)的顺序来维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用。一般而言,显式链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在的提高了内部碎片的程度。
7.10本章小结
本章中心内容是hello程序的存储管理,介绍了hello的存储地址和存储空间,重点是虚拟内存,以及内存映射,在此基础上我们能对在进程执行过程中,cpu是如何获取数据的,以及内核的进程调度又是如何完成的过程有了进一步的认识,此外也分析了缺页故障和缺页故障处理,动态内存分配器的执行过程。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
1)设备的模型化:所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,
2)设备管理:这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
1. Unix IO中对于文件输入输出的统一操作步骤:
1)打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息,应用程序只需要记住这个描述符。
2)Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件< unistd.h> 定义了常量STDIN_FILENO、STOOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
3)改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
4)读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测这个条件。在文件末尾处并没有明确的“EOF符号”。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5)关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
2. Unix IO的函数
(1)open():int open(char *filename, int flags, mode_t mode);
进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的。open将filename转换为一个文件描述符,并且放回描述符数字,并且总是进程中未打开的最小描述符。flags参数指明进程如何访问文件,mode参数指定新文件的访问权限位。
(2)close:int close(int fd);
进程通过调用close函数关闭一个打开的文件。关闭一个已关闭的描述符会出错。
(3)read:ssize_t read(int fd, void *buf, size_t n);
应用程序通过read函数来执行输入。read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误;返回值0表示EOF;否则,返回值表示的是实际传扫的字节数量。
(4)write:ssize_t write(int fd, const void *buf, size_t n);
应用程序通过write函数来执行输出。write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
8.3 printf的实现分析
1. printf()的函数实现
1)函数体:
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;
}
2)分析:
a)"..."表示参数的个数不确定
b)va_list是字符指针类型
c)C语言中参数的压栈方式为从右到左,而printf的第一个参数是一个字符型指针,而栈上分配的是该指针变量的地址,由此便可推出(char*)(&fmt) + 4) 表示的是"..."中的第一个参数的地址。
d)printf函数调用了vsprintf函数,最后通过系统调用函数write进行输出;
2. printf()调用的vsprintf()函数:
1)函数体:
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);
}
2)分析:
vsprintf()函数的作用是格式化,按照格式fmt结合参数生成格式化之后的字符串,产生格式化输出写入buf供系统调用。返回生成字符串的长度。
3. printf()调用的write():
1)
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
2)分析:
首先通过几个寄存器进行传参,重要的是后面的系统调用,int表示要调用中断门了。通过中断门,来实现特定的系统服务,在这里表示调用中断门int INT_VECTOR_SYS_CALL,即通过系统来调用sys_call实现输出这一系统服务。
4. write()中的sys_cal:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
通过分析整个printf()的实现流程。我们不难发现最后的sys_call部分的目的就是:显示格式化了的字符串。由此有等效的sys_call如下:通过逐个字符直接写至显存,输出格式化的字符串。
/*
ecx中是要打印出的元素个数
ebx中的是要打印的buf字符数组中的第一个元素
这个函数的功能就是不断的打印出字符,直到遇到:'\0'
[gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串
*/
sys_call:
xor si,si
mov ah,0Fh
mov al,[ebx+si]
cmp al,'\0'
je .end
mov [gs:edi],ax
inc si
loop:
sys_call
.end:
Ret
5. 字符显示驱动子程序:
从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)
8.4 getchar的实现分析
当程序运行至getchar函数时,程序通过系统调用read等待用户键入字符并按回车键
getchar函数在stdio.h中声明,代码如下:
int getchar(void)
{
static char buf[BUFSIZ];
static char *B=buf;
static int n=0;
if(n==0)
{
n=read(0,buf,BUFSIZ);
B=buf;
}
return (-n>=0)?(unsigned char)*B++:EOF;
}
B是缓冲区的开始,int变量n初始化为0,只有在n为0的情况下从缓冲区读入BUFSIZ个字节。返回时如果n大于0,那么返回缓冲区的第一个字符。否则返回EOF。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章的内容是Linux系统中的I/O管理,首先分析了Linux操作系统的IO设备管理方法,然后简述了Unix IO 接口及其函数,最后探究了printf函数和getchar函数的实现。此章的内容中我理解了Linux系统的设备管理方法,以及在此基础上实现的具体的与文件输入输出相关的库函数。
结论
- hello经历的过程
(1)预处理(hello.c->hello.i):hello.c预处理器(cpp)将修改源程序,生成hello.i文件。
(2)编译(hello.i->hello.s):编译器(ccl)将hello.i文件翻译为汇编文件hello.s。
(3)汇编(hello.s->hello.o):汇编器(as)将hello.s文件翻译为二进制机器语言,生成可重定位目标文件hello.o。
(4)链接(hello.0->hello):链接器(ld)将可重定位目标文件hello.o和其他目标文件链接成为可执行文件hello。
(5)执行:用户在shell-bash中键入执行hello程序的命令后,shell-bash解释用户的命令,找到hello可执行目标文件
(6)创建进程:shell进程调用fork函数为hello创建新进程,并调用execve函数在新进程的上下文中运行hello。
(7)进程调度:hello作为一个进程运行,接受内核的进程调度,调用sleep暂停后,内核进行上下文切换,调度其他进程执行)
(8)访问内存:通过MMU将需要访问的虚拟地址转化为物理地址,并通过缓存系统访问内存。
(9)动态申请内存:hello运行过程中可能会通过malloc函数动态申请堆中的内存。
(10)异常和信号处理:hello运行过程中可能会产生各种异常和信号,系统会针对出现的异常和收到的信号做出反应。
(11)运行结束:hello运行结束后被父进程shell-bash会进行回收,内核也会清除在内存中为其创建的各种数据结构和信息。
2. 计算机系统的设计与实现的感悟
通过跟随hello的p2p和020的历程,充分感受了计算机系统整体的美妙,但同样也提醒了我们,在学习相关知识的时候应该建立起一种整体的思考体系,同时对于每一个细节背后的原理都应该追问下去,不应该止步于计算机系统的复杂性之外,应该尽可能地让计算机地运行在我们买年前保持一种通透感,这样才能进一步穷尽计算机地可能性,并且发现新的突破契机。
附件
- hello.i-- hello.c经预处理得到的ASCII文本文件
- hello.s-- hello.i经编译得到的汇编代码ASCII文本文件
- hello.o-- hello.s经汇编得到的可重定位目标文件
- hello.elf-- hello.o经readelf分析得到的文本文件
- hello_dis.s-- hello.o经objdump反汇编得到的文本文件
- hello-- hello.o经链接得到的可执行文件
- hello_1.elf-- hello经readelf分析得到的文本文件
- hello_1_dis.txt-- hello经objdump反汇编得到的文本文件
参考文献
- 深入理解计算机系统(原书第三版).机械工业出版社, 2016.
- 逻辑地址、线性地址与物理地址. GitHub Blog.
- 深入理解计算机系统-之-内存寻址(三)--分段管理机制(段描述符,段选择子,描述符表). CSDN博客.
深入理解计算机系统-之-内存寻址(三)--分段管理机制(段描述符,段选择子,描述符表)_CHENG Jian的博客-CSDN博客_段选择子
- printf函数实现的深入剖析. 博客园.
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
- read和write系统调用以及getchar的实现. CSDN博客.
read和write系统调用以及getchar的实现_Vincent's Blog的博客-CSDN博客_getchar实现
[7] 预编译之行号和文件名标识.
[8] getchar()函数机制
(52条消息) C语言 getchar()函数详解_Huang_WeiHong的博客-CSDN博客_c语言getchar
[9] 程序员的自我修养—链接,装载与库.电子工业出版社.2009