计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能领域方向(2+x模式)
学 号 2022110829
班 级 22WL022
学 生 杨明达
指 导 教 师 刘宏伟
计算机科学与技术学院
2024年5月
本论文将运用CSAPP课程的知识,介绍在Linux下c语言源文件、可执行目标程序、进程、进程结束的完整生命周期,即预处理、编译、汇编、链接、进程管理、存储管理、I/O管理。本文以Ubutnu下的hello.c程序为例,进行细致的历程分析,展现计算机系统复杂精巧的设计。
关键词:计算机系统;Linux;hello程序;
目 录
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
- Hello的P2P过程
P2P,即From Program to Process。用户通过编写代码,得到hello.c程序;在Linux操作系统下,调用C预处理器cpp得到ASCII码的中间文件hello.i;接着调用C编译器ccl得到ASCII汇编语言文件hello.s;然后运行汇编器as得到可重定位目标文件hello.o;最后通过链接器ld得到可执行目标文件hello。用户在shell键入./hello启动此程序,shell调用fork函数为其产生子进程,hello便成为了进程process。
2.Hello的020过程
020,即From 0 to 0。程序的进程通常有在主存中从无到有和从有到无的过程。OS的进程管理调用fork函数产生子进程process,调用execve函数,并进行虚拟内存映射mmp,并为运行的hello分配时间片以执行取指译码流水线等操作;OS的储存管理以及MMU解决VA到PA的转换,cache、TLB、页表等加速访问过程,IO管理与信号处理综合软硬件对信号等进行处理;程序结束时,shell回收hello进程,内核将其所有痕迹从系统中清除。
1.2 环境与工具
1. 硬件环境:
处理器:11th Gen Intel(R) Core(TM) i5-11260H @ 2.60GHz 2.61 GHz
机带RAM 16.0 GB (15.7 GB 可用)
系统类型 64 位操作系统, 基于 x64 的处理器
GPU :NVIDIA GeForce RTX 3050 Laptop GPU
- 软件环境:
Windows 11,Ubuntu 20.04LTS双系统
2.开发与调试工具:
Vim,Gcc,Code::Blocks 20.04,Visual Studio 2022,Gdb,objdump等
1.3 中间结果
中间结果文件 | 文件作用 | |
预处理 | hello.i | 预处理产生的 ASCII码的文本文件,是编译的输入 |
编译 | hello.s | ASCII汇编语言文件,是汇编的输入 |
汇编 | hello.o | as得到可重定位目标文件,是链接的输入 |
汇编 | hello.objdump | 反汇编得到的文本文件,用于观察代码中需要重定位的区域 |
汇编 | hello.elf | hello.o的elf文件,用于了解可重定位文件的结构 |
链接 | hello | ld得到可执行目标文件,用于运行程序,观察动态链接过程 |
链接 | hello_obj.objdump | hello的反汇编文件,用于观察代码中需要重定位的区域 |
链接 | hello_elf.elf | hello的elf文件 |
1.4 本章小结
本章首先简要介绍了hello.c的P2P过程与O2O过程,然后对整篇论文写作过程中对hello文件操作的所有需要的硬件环境、软件环境、开发与调试工具进行说明,最后对实验过程中用到的所有中间文件及其作用和使用时期进行展现。
第2章 预处理
2.1 预处理的概念与作用
1. 预处理的概念
预处理是在编译之前使用预处理器cpp进行的处理,一般指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。预处理中会展开以#起始的行,试图解释为预处理指令。预处理过程扫描源代码,对其进行初步的转换,产生新的源代码提供给编译器。预处理过程读入源代码,检查包含预处理指令的语句和宏定义,并对源代码进行响应的转换。预处理过程还会删除程序中的注释和多余的空白字符。
2. 预处理的作用
a.使用#define和#if等符号,实现代码的快速切换。
b.检查宏变量,当与处理器读到#error时,就会报错。
c.根据#if以及#endif和#ifdef以及#ifndef来判断执行编译的条件。
d.预处理程序中的#include,通过此命令省去手动复制头文件操作。预处理器自动把该命令从文本中删除,将头文件的内容插入到该命令所在的位置,从而把头文件和当前源文件连接成一个源文件。
2.2在Ubuntu下预处理的命令
预处理命令用两种方式。
第一种命令:cpp hello.c > hello.i
第二种命令:gcc -E hello.c -o hello.i
图2-1 预处理命令1
图2-2 预处理命令2
图2-3 hello.i文本文件
2.3 Hello的预处理结果解析
预处理后的hello.i文本文件的部分截图如下图所示。
图2-4 hello.i代码a
图2-5 hello.i代码b
通过观察hello.i文本文件,可以发现24行的hello.c文件经过预处理,变成了3061行的hello.i的ASCII码中间文本文件。出现这种变化的原因是预处理器完成预处理工作,即头文件的展开、宏替换、去注释、条件编译。
通过观察hello.i文本文件,发现hello.i文件的行数增加大部分是因为预处理工作进行了头文件的展开,将include的头文件中的内容添加到hello.i中。预处理对头文件stdio的展开开始于14行,结束于729行;类似的发现头文件unistd的展开开始于732行,结束于1967行;头文件stdlib的展开开始于1971行,结束于3042行。除此之外,头文件里有大量的宏定义和条件编译语句存在,预处理阶段同样需要对这些语句进行相应的宏替换和条件编译处理。
程序主体段开始于第3048行,结束于3061行,预处理删除了我们的注释信息。除了注释部分以及头文件部分,预编译文件与源文件无太大差别。
图2-6 hello.i与stdio.h比较
图2-7 hello.i与unistd.h比较
图2-8 hello.i与stdlib.h比较
图2-9 头文件的宏定义与条件编译
图2-10 头文件目录
2.4 本章小结
本章主要介绍了预处理的概念和作用,列举Ubuntu下预处理的两个指令,对hello.c文件的预处理结果hello.i文本文件进行解析,发现预处理主要由预处理器完成,并实现头文件的展开、宏替换、去掉注释、条件编译。
第3章 编译
3.1 编译的概念与作用
1. 编译的概念
编译是利用编译器cc1将预处理得到的ASCII码的中间文件hello.i翻译成ASCII汇编语言文件hello.s的过程;是将源语言经过词法分析、语法分析、语义分析以及经过一系列优化后生成汇编代码的过程。
2. 编译的作用
a.将中间代码文件分析转换成等价且时间空间资源更少的代码文件,具有优化功能,提高程序的性能。
b.将高级语言的源程序转换为汇编代码。
c.进行语法分析,对输入的字符串进行分析分割,形成允许的记号,并标注不规范记号,产生错误提示信息,帮之程序员找到bug。
3.2 在Ubuntu下编译的命令
编译命令:gcc -S hello.i -o hello.s
图3-1 编译命令
图3-2 hello.s文本文件
3.3 Hello的编译结果解析
3.3.1 hello.s伪指令分析
指令 | 含义 |
.file | 声明源文件 |
.text | 声明以下是代码段 |
.section | 声明以下是rodata节 |
.rodata | 声明以下是rodata节 |
.align | 声明对指令或者数据的存放地址进行对齐的 方式 |
.string | 声明一个string类型 |
.globl | 声明一个全局变量 |
.type | 声明是函数类型还是对象类型 |
.size | 声明大小 |
.long | 声明一个long类型 |
.local | 声明一个局部变量 |
.data | 指示下面的指令发在代码段 |
.word | 定义一个字数据 |
.byte | 定义一个字节数据 |
.skip | 在数据段中分配制定字节数的空间,且不初始化 |
表3-1 hello.s伪指令
hello.s中包含的数据有全局变量、局部变量、表达式,数据类型有整数、数组、字符串。
3.3.2数据——整数
hello程序中用到的整数数据有int i, int argc,常数立即数5,0,10。
- int i
对于int i,局部C变量在运行时被保存在栈或寄存器中。针对局部变量int i,在hello.s的54行中,编译器将i存储在-4(%rbp)的栈空间中,并且i在栈中占据4个字节空间。
图3-3 int i分析
2. int argc
对于int argc,即main函数的第一个形式参数。在hello.s的22行中,%edi是第一个参数,将int argc赋值給-20(%rbp),所以第一个形式参数页储存在栈上,并在栈空间-20(%rbp)处。
图3-4 int argc分析
3. 常数立即数
对于常数立即数,因为汇编代码允许立即数以$常数形式存在,所以源程序中的0,1,10等常数在hello.s汇编代码文件的45,54,56行。
图3-5 立即数分析
3.3.3数据——数组
对于数组char *argv[],即main函数的第二个形式参数和存放char指针的数组,来源于终端输入的数据(本文输入./hello 2022110829 杨明达 18204327279 4)。根据hello.s的23行,判断argc数组中一个元素为8个字节大小。在main函数中,对argv[1],argv[2],argv[3],argv[4]的访问来自数组首地址argv进行加法计算得到的地址。在hello.s的36、39、42、49行,发现在代码文件中执行movq (%rax), %rig,用来取出终端输入的命令参数。
图3-6 数组分析a
图3-7 数组分析b
3.3.4数据——字符串
通过观察代码文件,发现字符串有"用法: Hello 学号 姓名 手机号 秒数!\n",和终端输入的储存在argv[]为地址的数组中的"Hello %s %s %s\n"。除此之外,在hello.s中,发现字符串
\347\224\250\346\263\225 \345\255\246\345\217\267 \345\247\223\345\220\215 \346\211\213\346\234\272\345\217\267 \347\247\222\346\225\260\357\274\201,这些字符串是用法、学号、姓名、手机号、秒数的UTF-8格式。
对于字符串"Hello %s %s %s\n",即第二个printf传入的输出格式化参数,是在.rodata声明的。
图3-8 字符串分析
3.3.5赋值操作
源程序的赋值操作有i=0、i++
- i=0
对于i=0,在hello.s的31行中,通过汇编语句movl $0, -4(%rbp)将立即数0赋值給局部变量i。其中局部变量i为int型,占4个字节大小,用movl赋值。
图3-9 i=0分析
2.i++
对于i++,在hello.s的54行中,通过addl $1, -4(%rbp),对-4(%rbp),即原来的i=0进行加1操作,实现每次循环加1的功能。其中局部变量i为int型,占4个字节大小,用addl运算。
图3-10 i++分析
3.3.6类型转换
在hello.c中,sleep的参数类型时unsigned int,而atoi的返回值是int,在此处会进行隐式类型转换,即从signed的变成unsigned,且位数没有改变。在C语言中,会用有符号数的方式解释这些位。而解释这些位在sleep函数内,故汇编代码中只是将数传给了%edi,没有做额外的操作。
图3-11 hello.c代码
图3-12 类型转换分析
3.3.7算术运算与逻辑操作
常见的算术运算与逻辑操作有:
指令 | 效果 | 描述 |
1eaq S,D | D←&S | 加载有效地址 |
INC D DEC D NEG D NOT D | D←D十1 D←D-1 D←一D D←一D | 加1 减l 取负 取补 |
ADD s,D | D←D十s | 加 |
SUB S,D | D←D-S | 减 |
IMUL S,D | D←D*S | 乘 |
XOR S,D | D←D^S | 异或 |
OR S,D | D←D|S | 或 |
AND S,D | D←D&S | 与 |
SAL k,D | D←D<<k | 左移 |
SHL k,D | D←D<<k | 左移 |
SAR k,D | D←D>>Ak | 算术右移 |
SHR k,D | D←D<<Lk | 逻辑右移 |
表3-2 常见的算术运算与逻辑操作
- i++
对于i++,在hello.s的54行中,通过addl $1, -4(%rbp),对-4(%rbp),即原来的i=0进行加1操作,实现每次循环加1的功能。其中局部变量i为int型,占4个字节大小,用addl运算。
图3-13 i++分析
2.addq $16, %rax ; addq $8, %rax ; leaq .LC1(%rip), %rdi
addq $16, %rax和addq $8, %rax取出argv数组中的指针指向的内容。
leaq .LC1(%rip), %rdi计算LC2的段地址:%rip+.LC2,同时将此地址送给%rdi。
图3-14 addq和leaq分析
3.subq $32, %rsp
在hello.s中,%rsp总是指向栈顶元素,所以在21行对栈指针进行减法操作,用来开辟一段栈空间。栈顶地址自高处向低处变化。
图3-15 subq分析
4.leaq .LC0(%rip), %rdi
在hello.s中,这行指令用来加载有效地址,计算LC1的段地址:%rip+.LC1,同时将此地址送给%rdi。
图3-16 leaq分析
3.3.8关系操作
常见的关系操作指令有:
指令 | 效果 | 描述 |
CMP S1,S2 | S2-Sl | 比较-设置条件码 |
TEST S1, S2 | S1&S2 | 测试-设置条件码 |
SET** D | D==**对应的条件码的值 | 按照**用条件码设置D |
J** | —— | —— |
表3-3 常见的关系操作指令
- cmpl $5, -20(%rbp)和je .L2
cmpl $5, -20(%rbp)语句计算20(%rbp)-5,并设置条件码,随之je利用这些条件码,进行相应的跳转处理。在hello.c中是argc!=5,则执行提示输出并退出语句;对于汇编代码hello.s,是如果==5(je .L2)则跳转执行相关语句。这是因为编译器将!=3时执行,优化为==3,跳转。
图3-17 cmpl和je分析
图3-18 hello.c部分代码
2.cmpl $9, -4(%rbp)和jle .L4
cmpl $9, -4(%rbp)计算-4(%rbp)-9,并设置条件码,随之jle语句利用这些条件码,进行相应的跳转处理。在hello.c中是判断i<10,则执行,对于汇编代码hello.s,编译器将其优化为i<=9,则执行。
图3-19 cmpl和jle分析
图3-20 hello.c部分代码
3.3.9数组/指针/结构操作
在汇编语句hello.s中,数组/指针/结构操作大多数是通过数据传送mov指令实现的。
- movl %edi, -20(%rbp)和movq %rsi, -32(%rbp)。
movl %edi, -20(%rbp)将寄存器%edi的内容赋值给-20(%rbp)指针指向的地址,movq %rsi, -32(%rbp)将寄存器%rsi的内容赋值给-32(%rbp)指针指向的内容。这些汇编语句对应源程序中main函数形参的传入部分。
图3-21 movl和movq分析
2.输出argv[1]、argv[2]、argv[3]、argv[4]
在编译器的处理下变成了如下所示部分。%rax、%rcx、%rdx代表取出指针所指的内存中的内容。
图3-22 argv[]分析
3.3.10控制转移
控制转移通常是配合指令CMP和TEST存在的。
- 分支结构
cmpl $5, -20(%rbp)和je .L2实现分支功能,计算20(%rbp)-5,并设置条件码。随之je利用这些条件码,发现等于0的话,则跳转到.L2段;若不等于0,则继续向下执行,调用puts函数输出命令行要求,并调用exit函数退出。
图3-23 分支结构分析
图3-24 hello.c中的分支结构
2.循环结构
cmpl $9, -4(%rbp)和jle .L4实现循环功能,计算-4(%rbp)-9,并设置条件码。随之jle语句利用这些条件码,若小于等于0,则跳转到.L4段;而若大于0,则继续向下执行,结束程序。即循环执行10次.L4段,然后退出,表现在终端上,就是10次输出字符串。
图3-25 循环结构分析
图3-26 hello.c中的循环结构
3.3.11函数操作
函数是过程的一种,过程提供了一种封装代码的方式,用一组指定的参数和可选的返回值实现某种功能。P中调用函数Q包含以下动作:
1.控制传递:要执行过程Q,就要在开始调用Q后将程序计数器PC设置为Q的代码的起始地址;过程Q结束之后,要把控制权转移给过程P,于是在返回后,要把程序计数器设置为P中调用Q的指令的下一条指令的地址。
2.传递数据:P要能够向Q提供0个、一个或多个参数,Q通常会给P返回一个值(通常通过%rax/%eax/%ax%al寄存器返回)。
3.分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
对hello.c源代码分析,有main函数,printf函数,sleep函数,getchar函数和exit函数。
- 参数传递
对于main函数,函数形参有2个,在hello.s中分别是用这两条语句(22和23行)达到传送参数的功能的。函数将要传入的参数(值和地址)储存在%edi和%rsi中,然后在栈上保存。
图3-27 main函数传递形式参数
对于printf函数,在汇编代码中被优化为puts函数。在hello.s中,首先将rdi赋值为字符串"用法: Hello 学号 姓名 手机号 秒数!\n"字符串的首地址,然后调用了puts函数,即将第一处字符串参数传入。对于第二个,在汇编代码中首先将rdi赋值为字符串"Hello %s %s %s\n"的首地址,此处没有被优化为puts函数,而是直接调用printf函数。同时将四个argv[]数据用寄存器保存,可以根据控制字符串,直接输出终端键入的命令行。
图3-28 第一次调用的printf函数
图3-29 第二次调用的printf函数
对于atoi函数,将%rdi设置为argv[3],接着call atoi@PLT。
图3-30 atoi函数的参数传递
对于sleep函数,发现hello.s首先传递数据,将%edi设置为%eax,即atoi函数返回的值,接着进行控制传递call sleep@PLT。
图3-31 sleepf函数的参数传递
对于getchar函数,在hello.s中进行控制传递call getchar@PLT。
图3-32 getchar函数的参数传递
对于exit函数,通过汇编语句movl$1, %edi将%edi寄存器内容设置为1,之后进行call exit@PLT。
图3-33 exit函数的参数传递
2.函数调用
对于main函数。main函数被调用即call才能执行(被系统启动函数 __libc_start_main调用)。对于call指令,它将下一条指令的地址dest压栈, 然后跳转到main 函数,即完成对main函数的调用。
对于printf函数。在main函数内部,通过汇编语句call puts@PLT调用(第一次),通过汇编语句call printf@PLT调用(第二次)。
图3-34 第一次调用的printf函数
图3-35 第二次调用的printf函数
对于sleep函数,在main函数内部,被多次调用(在for循环内部),调用了10次,通过汇编语句call sleep@PLT除法此调用。
对于getchar函数,在main函数内部,最后被调用,调用它的汇编语句是:call getchar@PLT。
对于exit函数,在main函数内部被调用,调用它的汇编语句是call exit@PLT。
3. 函数返回
对于main函数,程序结束时,调用leave指令,恢复栈空间为调用之前的状态,然后 ret 返回。
图3-36 main函数返回
3.4 本章小结
本章介绍了编译的概念与作用,在Linux下编译的指令,并针对hello.c源文件的编译文件hello.s进行数据类型(整数,字符串,数组)和赋值操作、类型转换、算术和逻辑操作、关系操作、指针数组结构操作、控制转移和函数操作进行分析。
第4章 汇编
4.1 汇编的概念与作用
1. 汇编的概念
驱动程序运行汇编器as,将汇编语言翻译成机器语言的过程称为汇编,同时这个机器语言文件也是可重定位目标文件。
2. 汇编的作用
汇编就是将高级语言转化为机器可直接识别执行的代码文件的过程,汇编器将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位 目标程序的格式,并将结果保存在.o 目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。
4.2 在Ubuntu下汇编的命令
汇编命令用两种方式。
第一种命令:as hello.s -o hello.o
第二种命令:gcc -c hello.s -o hello.o
图4-1 汇编命令1
图4-2 汇编命令2
图4-3 hello.o文本文件
4.3 可重定位目标elf格式
4.3.1读取可重定位目标文件
在终端输入readelf -a hello.o > hello.elf,将elf可重定位目标文件输出定向到文本文件hello.elf中。
图4-4 可重定位文件生成命令
图4-5 可重定位文件
4.3.2典型的可重定位目标ELF格式
ELF头以16字节的序列开始,描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小、目标文件的类型(如可执行、可重定位或者共享的)、机器类型、节头部表的文件偏移以及节头部表中条目的大小和数量。
.text:已编译程序的机器代码。
.rodata:只读数据。
.data:已初始化的非0的全局变量和静态变量。
.bss:未初始化的全局变量和局部静态变量,以及所有被初始化为0的全局变量和静态变量(仅是占位符,该节在目标文件中不占据实际的空间)。
.symtab:符号表(与编译器中的符号表不同),存放存放程序中定义和引用的函数和全局变量信息,不包括局部变量。
.rel.text:代码的重定位条目的列表,用于在重定位的时候,重新修改代码段的指令中的地址信息。
.rel.data:已初始化数据的重定位条目的列表(比如,有个全局变量a,它的初值在汇编时无法确定,.rel.data就会有一个与a有关的重定位条目)
.debug:调试符号表,只有以-g方式调用编译器驱动程序时,才会得到这张表。
.line:原始C源程序中的行号和.text节中机器指令之间的映射。
.strtab节:字符串表,包括.symtab和.debug节中的符号表以及节头部表的节名。
节头部表:节头部表描述了这个elf文件中每个 节的位置与大小。
ELF头 | 节 |
.text | |
.rodata | |
.data | |
.bss | |
.symtab | |
.rel.text | |
.rel.data | |
.debug | |
.line | |
.strtab | |
节头部表 | 描述目标 文件的节 |
表4-1 可重定位目标ELF格式
4.3.3 hello.oELF格式分析
1. 分析节头部表
节头表包括节名称,节的类型,节的属性(读写权限),节在ELF文件中所占的长度以及节的对齐方式和偏移量,可以使用终端指令readelf -S hello.o来查看节头表。
图4-6 节头表
2. 分析符号表
用readelf -s Hello.o查看符号表,Name为符号名称,Value是符号相对于目标节的起始位置偏移,Size为目标大小,Type是类型,数据或函数,Bind表示是本地还是全局。
图4-7 符号表
- 分析文件头
用readelf -h hello.o指令可以查看Hello.o的ELF头信息。
类别:64位ELF文件格式
数据:数据表示形式是二进制补码,且是小端字节序(低位字节存储在低内存地址)。
Version:版本为1
OS/ABI:操作系统为UNIX SYSTEM V
类型:REL表明这是一个可重定位文件
系统架构:这个ELF文件是为AMD的64位x86架构(X86-64)而编译的。
入口点地址:程序的入口地址为0x0
程序头起点:0表示没有程序头表
Start of section headers:节头部表的起始位置为1264字节处
Size of section headers:每个表项64个字节
Number of section headers:该可重定位文件共14个表
Section header string table index:13为.strtab在节头表中的索引
图4-8 ELF头
2.分析重定位条目
使用readelf -r hello.o查看重定位条目。
.rela.eh_frame节通常是与异常处理框架相关的重定位条目。
.rela.text中,“偏移量”表示要修改的引用的地址与.text节的首地址的差。“信息”的前部分表示在符号表中的索引,后部分表示重定位的类型。“类型”是将重定位的类型编码翻译成对程序员比较友好的字符串。“符号值”和“符号名称”是用在符号表中查找得到的。“加数”是为了修正PC的误差,即由于历史原因,PC的更新会在每条指令执行之前进行。
.rela.data / .rel.data 节在本程序中没有出现。
图4-9 重定位条目
4.4 Hello.o的结果解析
命令行输入:objdump -d -r hello.o
- 分析hello.o与hello.s区别
与hello.s相比,发现伪指令、标准库中的puts,exit,printf,atoi,sleep消失,被用全0的值替代。因为汇编器还无法计算出这些量和函数的运行时地址,两个字符常量的引用被改成了一个有效地址计算,立即数都被设置成全0。分支转移不再以标签为操作数,改成使用长度为一个字节的PC相对地址。原本十进制的立即数都变成了二进制,输出的文件是二进制的,对于objdump来说,直接将二进制转化为十六进制比较方便,也有利于以字节为单位观察代码。代码中还存在一些重定位条目的信息。
图4-10 hello.o的反汇编代码a
图4-11 hello.o的反汇编代码b
2.分析机器语言
通过上述比较,发现机器语言是二进制的机器指令的集合;机器指令是由操作码和操作数构成的;机器语言灵活、直接执行和速度快;汇编语言主体是汇编指令,是机器指令便于记忆的表示形式,为了方便程序员读懂和记忆的语言指令。汇编指令和机器指令在指令的表示方法上有所不同。
4.5 本章小结
本章介绍了汇编的概念与作用,以及在Linux下汇编的命令,对可重定位目标elf格式进行了分析。最后对hello.o文件进行反汇编,将反汇编后的代码文件与之前生成的hello.s文件进行了对比。
第5章 链接
5.1 链接的概念与作用
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 链接命令
图5-2 hello文件
5.3 可执行目标文件hello的格式
5.3.1典型的ELF可执行目标文件的结构
可执行目标文件与可重定位文件在格式上有不同之处,可执行目标文件中ELF头中Entry Piont Address给出执行程序时的第一条指令的地址,而在可重定位文件中,此值没有意义也无法确定,此值被设为0。可执行目标文件的ELF头中的程序头部表有了实际的意义,是一个结构体数组,记录着将程序加载到主存中要用到的信息。可执行目标文件还多了一个.init节,用于定义init函数,该函数用来执行可执行目标文件开始执行时的初始化工作。没有动态链接的可执行目标文件不需要重定位,可执行目标文件少了.rel节。
ELF头 | 只读内存段(代码段) | |
将连续的文件映射到运行时内存段 | 段头部表 | |
.init | ||
.text | ||
.rodata | ||
.data | 读/写内存段(数据段) | |
.bss | ||
.symtab | 不加载到内存的符号表和调试信息 | |
.debug | ||
.line | ||
.strtab | ||
描述目标文件的节 | 节头部表 |
表5-1 ELF可执行目标文件的结构
5.3.2分析ELF可执行目标文件
获取hello的elf格式文件:readelf -a hello > hello_elf
图5-3 获取hello的elf格式文件命令
各节的基本信息均在节头表,即描述目标文件的节中进行了声明。节头表(包括名称,大小,类型,全体大小,地址,旗标,偏移量,对齐等信息)。
图5-4 节头表a
图5-5 节头表b
5.4 hello的虚拟地址空间
使用edb加载hello,观察edb的Data Dump窗口,发现虚拟地址由0x400000开始。并且在该处的字节与Magic完全相同。
图5-6 虚拟地址起点
图5-7 Magic
进程中的.interp的首地址和节头表显示的0x4002e0一样。
图5-8 .interp首地址
进程中.plt的首地址和5节头表显示的0x401020一样
图5-9 .plt首地址
进程中.text的首地址和节头表显示的0x4010f0一样
图5-10 .text首地址
5.5 链接的重定位过程分析
终端输入:objdump -d -r hello
图5-11 hello的反汇编代码a
图5-12 hello的反汇编代码b
图5-13 hello的反汇编代码c
图5-14 hello的反汇编代码d
图5-15 hello的反汇编代码e
图5-16 hello的反汇编代码f
针对链接的重定位过程,发现要合并相同的节,确定新节中所有定义符号在虚拟地址空间中的地址,还要对引用符号进行重定位(确定地址),修改.text节和.data节中对每个符号的引用(地址),而这些需要用到在.rel_data和.rel_text节中保存的重定位信息。
通过比较,发现hello反汇编文件与hello.o反汇编文件有几处不同。第一,hello反汇编文件比hello.o反汇编文件多了许多文件节(.init节和.plt节)。第二,hello反汇编文件中的地址是虚拟地址,而hello.o反汇编文件中的是相对偏移地址。第三,hello反汇编文件中增加了许多外部链接的共享库函数(puts@plt共享库函数,printf@plt共享库函数以及getchar@plt函数等)。第四,跳转和函数调用的地址在hello反汇编文件中是虚拟内存地址(都以main函数内部调用puts函数和exit函数为例)。
5.6 hello的执行流程
使用edb执行hello,从加载hello到_start,到call main,以及程序终止的所有过程。列出入口点,即 _start 函数的地址。这个函数是在系统目标文件 ctrl.o 中定义的。_start 函数调用系统启动函数 __libc_start_main,该函数定义在 libc.so 中。它初始化执行环境,调用用户层的 main 函数,处理 main 函数的返回值,并且在需要的时候把控制返回给内核。
函数 | 地址 |
ld-2.31.so!_dl_catch_exception@plt | <0x00007f9fddc6d010> |
ld-2.31.so!malloc@plt | <0x00007f9fddc6d020> |
ld-2.31.so!_dl_signal_exception@plt | <0x00007f9fddc6d030> |
ld-2.31.so!calloc@plt | <0x00007f9fddc6d040> |
ld-2.31.so!realloc@plt | <0x00007f9fddc6d050> |
ld-2.31.so!_dl_signal_error@plt | <0x00007f9fddc6d060> |
ld-2.31.so!_dl_catch_error@plt | <0x00007f9fddc6d070> |
ld-2.31.so!_dl_rtld_di_serinfo | <0x00007f9fddc77090> |
ld-2.31.so!_dl_debug_state | <0x00007f9fddc7e1d0> |
ld-2.31.so!_dl_mcount | <0x00007f9fddc7fe00> |
ld-2.31.so!_dl_get_tls_static_info | <0x00007f9fddc80680> |
ld-2.31.so!_dl_allocate_tls_init | <0x00007f9fddc80770> |
ld-2.31.so!_dl_allocate_tls | <0x00007f9fddc809a0> |
ld-2.31.so!_dl_deallocate_tls | <0x00007f9fddc80a10> |
ld-2.31.so!_dl_make_stack_executable | <0x00007f9fddc81130> |
ld-2.31.so!_dl_find_dso_for_object | <0x00007f9fddc81480> |
ld-2.31.so!_dl_exception_create | <0x00007f9fddc84ca0> |
ld-2.31.so!_dl_exception_create_format | <0x00007f9fddc84da0> |
ld-2.31.so!_dl_exception_free | <0x00007f9fddc85250> |
ld-2.31.so!__tunable_get_val | <0x00007f9fddc865d0> |
ld-2.31.so!__tls_get_addr | <0x00007f9fddc86da0> |
ld-2.31.so!__get_cpu_features | <0x00007f9fddc86df0> |
ld-2.31.so!malloc | <0x00007f9fddc89490> |
ld-2.31.so!calloc | <0x00007f9fddc895b0> |
ld-2.31.so!free | <0x00007f9fddc895f0> |
ld-2.31.so!realloc | <0x00007f9fddc897e0> |
ld-2.31.so!_dl_signal_exception | <0x00007f9fddc89a70> |
ld-2.31.so!_dl_signal_error | <0x00007f9fddc89ac0> |
ld-2.31.so!_dl_catch_exception | <0x00007f9fddc89c40> |
ld-2.31.so!_dl_catch_error | <0x00007f9fddc89d30> |
hello!_init | <0x0000000000401000> |
<0x0000000000401030> | |
hello!printf@plt | <0x0000000000401040> |
hello!getchar@plt | <0x0000000000401050> |
hello!atoi@plt | <0x0000000000401060> |
hello!exit@plt | <0x0000000000401070> |
hello!sleep@plt | <0x0000000000401080> |
hello!_start | <0x00000000004010f0> |
hello!_dl_relocate_static_pie | <0x0000000000401120> |
hello!main | <0x0000000000401125> |
hello!__libc_csu_init | <0x00000000004011c0> |
hello!__libc_csu_fini | <0x0000000000401230> |
hello!_fini | <0x0000000000401238> |
表5-2 hello的执行流程
5.7 Hello的动态链接分析
在进行hello程序的动态链接前,首先进行静态链接,生成部分链接的可执行目标文件 hello。此时共享库中的代码和数据没有被合并到 hello 中。只有在加载 hello 时,动态链接器才对共享目标文件中的相应模块内的代码和数据进行重定位,加载共享库,生成完全链接的可执行目标文件。
对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,所以需要为其添加重定位记录,并等待动态链接器处理。为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT和全局偏移量表GOT实现函数的动态链接。其中GOT 中存放函数目标地址,PLT使用 GOT中地址跳转到目标函数。
以查看_GLOBAL_OFFSET_TABLE 的内容为例:
运行前:
图5-17 运行前查看
运行dl_init后:
图5-18 运行dl_init后查看
5.8 本章小结
本章介绍了链接的概念及作用,在Linux下链接的命令行;对hello的elf格式进行的分析对比,介绍hello的虚拟地址空间知识;并通过对比反汇编hello文件与hello.o反汇编文件,详细了解了重定位过程;之后分析整个hello的执行过程,最后对hello进行了动态链接分析。
第6章 hello进程管理
6.1 进程的概念与作用
1. 进程的概念
进程是操作系统对一个正在运行的程序的一种抽象。进程是程序的基本执行实体;在面向线程设计的系统中,进程本身不是基本执行单位,而是线程的容器。程序本身只是指令、数据及其组织形式的描述,相当于一个名词,进程才是程序的真正执行实例。
2. 进程的作用
hello 在运行时,操作系统会提供一种假象,就好像系统上只有这个程序在运行。程序看上去是独占地使用处理器、主存和 I/O 设备。处理器看上去就像在不间断地一条接一条地执行程序中的指令,即该程序的代码和数据是系统内存中唯一的对象。这些假象就是通过进程来实现的。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 shell-bash的作用
Shell是用户与操作系统之间完成交互式操作的一个接口程序,它为用户提供简化了的操作。而NU组织发现sh是比较好用的又进一步开发Borne Again Shell,简称bash,它是Linux系统中默认的shell程序。
6.2.2 shell-bash的处理流程
1. 将用户输入的命令行进行解析,分析是否是内置命令;
2. 若是内置命令,直接执行;若不是内置命令,则bash在初始子进程的上下文中加载和运行它。
3. 本质上就是shell在执行一系列的读和求值的步骤,在这个过程中,他同时可以接受来自终端的命令输入。
6.3 Hello的fork进程创建过程
执行中的进程调用fork()函数,就创建了一个子进程。其函数原型为pid_t fork(void);对于返回值,若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1。
首先对于hello进程。终端的输入被判断为非内置命令,然后shell在硬盘上查找该命令(即hello可执行程序),并将其调入内存,然后shell将其解释为系统功能调用并转交给内核执行。shell执行fork函数,创建一个子进程。这时候我们的hello程序就开始运行了。hello子进程是父进程的副本,它将获得父进程数据空间、堆、栈等资源的副本。但是子进程持有的是上述存储空间的“副本”,这意味着父子进程间不共享这些存储空间。同时Linux将复制父进程的地址空间给子进程,因此,hello进程就有了独立的地址空间。
图6-1 fork示意图
6.4 Hello的execve过程
execve() 函数加载并运行可执行目标文件,且带参数列表 argv 和环境变量列表 envp,execve() 函数调用一次从不返回。hello中execve()函数执行过程如下:
- 删除已存在的用户区域。
- 映射私有区:为 hello 的代码、数据、.bss 和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的。
- 映射共享区:比如 hello 程序与共享库 libc.so 链接。
- 设置 PC:exceve() 做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
- execve() 在调用成功的情况下不会返回,只有当出现错误时,例如找不到需要执行的程序时,execve() 才会返回到调用程序。
6.5 Hello的进程执行
6.5.1.逻辑控制流
一系列程序计数器PC的值的序列叫做进程的逻辑控制流,PC的值要么对应可执行目标文件中的指令,要么对应运行时动态链接到程序的共享对象中的指令。进程的机制是的进程似乎是独占CPU的。从逻辑上看,逻辑控制流是连续没有中断的。
6.5.2时间片
一个进程执行它的逻辑控制流的一部分的每一时间段叫做时间片。进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行一段时间后会被抢占(暂时挂起),然后轮到其他进程。
6.5.3 用户模式与内核模式
为了限制一个应用可以执行的指令以及它可以访问的地址空间范围,处理器用一个控制寄存器中的一个模式位来描述进程当前的特权。用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个 I/O 操作。也不允许用户模式的进程直接引用地址空间中内核区内的代码和数据。用户程序必须通过系统调用接口间接地访问内核代码和数据。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
6.5.4 上下文切换
操作系统内核为每个进程维护一个上下文。所谓上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表,包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
6.5.5 hello的执行
从 Shell 中运行 hello 时,它运行在用户模式,运行过程中,内核不断切换上下文,使运行过程被切分成时间片,与其他进程交替占用执行,实现进程的调度。如果在运行过程中收到信号等,那么就会进入内核模式,运行信号处理程序,之后再返回用户模式。
6.6 hello的异常与信号处理
6.6.1hello的异常
hello在执行时可能会出现四类异常:中断、陷阱、故障、终止
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
表6-1 四类异常
中断:在 hello 运行过程中,敲击键盘,那么就会触发中断,系统调用内核中的中断处理程序执行,然后返回,hello 继续执行。
图6-2 中断示意图
陷阱:陷阱就是系统调用,hello 运行在用户模式下,无法直接运行内核中的程序,如 fork,exit 等系统调用。可以通过陷阱的方式,执行 systemcall 指令,内核调用陷阱处理程序来执行系统调用。
图6-3 陷阱示意图
故障:hello 运行时,当某一条指令引用一个虚拟地址,而地址相对应的物理页面不在内存中,就会发生故障。内核调用故障处理程序(这里是缺页处理程序),缺页处理程序从磁盘中加载适当的页面,然后将控制返回给引起故障的指令,该指令就能顺畅地执行了。
图6-4 故障示意图
终止:hello 在运行时,也有可能遇到硬件错误,这时需要终止 hello 的运行。
图6-5 终止示意图
6.6.2信号处理
对于ctrl+c或者ctrl+z,键盘键入后,内核就会发送SIGINT或者SIGSTP。SIGINT信号默认终止前台job即程序hello,SIGSTP默认挂起前台hello作业。对于fg信号。内核发送SIGCONT信号,我们刚刚挂起的程序hello重新在前台运行。对于kill -9 90878,内核发送SIGKILL信号给我们指定的pid(hello程序),结果是杀死了hello程序。
下图是正常执行hello程序的结果,当进程终止之后,进程被回收。
图6-6 正常执行hello程序
下图是在程序输出2行之后按下CTRL-Z的结果,当按下CTRL-Z之后,shell父进程收到SIGSTP信号,信号处理函数的逻辑是打印屏幕回显、将hello进程挂起。通过ps命令我们可以看出hello进程没有被回收,此时他的后台job号是1。调用fg将其调到前台,此时shell程序首先打印hello的命令行命令,hello继续运行打印剩下的8条info,之后输入字串,进程终止,进程被回收。
图6-7 按下CTRL-Z
下图是在程序输出3行之后按下CTRL-C的结果,当按下CTRL-C之后,shell父进程收到SIGINT信号,信号处理函数的逻辑是终止前台作业,此处即终止hello进程,并回收hello进程。
图6-8 按下CTRL-C
下图当发送SIGKILL给hello进程时,杀死hello程序。
图6-9 kill杀死程序
下图是在程序运行中途乱按的结果,可以发现,乱按只是将屏幕的输入缓存到stdin,当getchar的时候读出一个\n结尾的字串(作为一次输入),其他字串会当做shell命令行输入。
图6-10 程序运行时乱输入
下图是终端输入CTRL-Z后查看pstree。
图6-11 pstree a
图6-12 pstree b
图6-13 pstree c
图6-14 pstree d
图6-15 pstree e
图6-16 pstree f
6.7本章小结
本章主要介绍了程序如何从可执行文件到进程的过程。介绍了进程的概念与作用,分析shell的处理流程和作用,fork函数和execve函数,及上下文切换机制等。除此之外,本章还介绍hello的进程执行以及程序的异常(中断,故障,终止和陷阱)与信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1. 逻辑地址
程序代码经过编译后出现在汇编程序中地址。逻辑地址由选择符(在实模式下是描述符,在保护模式下是用来选择描述符的选择符)和偏移量(偏移部分)组成。
2. 线性地址
线性地址空间是一个非负整数的集合。逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合形式。在调试hello时,gdb中查看到的就是线性地址,或者虚拟地址。
3.虚拟地址
虚拟地址空间是0到N的所有整数的集合(N是正整数),是线性地址空间的有限子集。分页机制以虚拟地址为桥梁,将硬盘和物理内存联系起来。
4.物理地址
CPU通过地址总线的寻址,找到真实的物理内存对应地址。CPU对内存的访问是通过连接着CPU和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel在最初8086处理器引入了段寄存器,8086 地址总线的位宽是20bit,通过将段寄存器的数值左移4位加上偏移地址就可得到20位地址。上述逻辑地址到线性地址的计算方法是在实模式下的计算方法。现在大多数非单片机计算机是运行在保护模式的。该模式下,段寄存器每个元素不再只是地址。
一个段选择符有16位,低2位是当前特权级,第3位TI说明查哪一个段描述符表,高13位是索引。索引说明该段在描述符表中的下标。TI为0时,选择全局描述符表,为1时选择局部描述符表。RPL=00,说明当前位于内核态,有内核的权限。RPL=11,说明当前位于用户态。段描述符表中的每一项记录着一个段的段基址,段限(由于说明段的最大大小)和存取权限。
15 14 13 12 11 10 9 8 7 6 5 4 | 3 2 | 1 0 |
索引 | TI | RPL |
表7-1 段选择符的数据结构
被选中的段描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址。
图7-1 intel存储器寻找
图7-2 逻辑地址到线性地址的转换示意图
7.3 Hello的线性地址到物理地址的变换-页式管理
VM 系统将虚拟内存分割为成为虚拟页的大小固定的快,物理内存也被分割为物理页,成为页帧。虚拟页面就可以作为缓存的工具,被分为三个部分:未分配的:VM 系统还未分配的页。已缓存的:当前已缓存在物理内存中的已分配页。未缓存的:未缓存在物理内存的已分配页
图7-3 页表示意图
7.4 TLB与四级页表支持下的VA到PA的变换
页表是 PTE(页表条目)的数组,它将虚拟页映射到物理页,每个 PTE 都有一个有效位和一个 n 位地址字段,有效位表明该虚拟页是否被缓存在 DRAM 中,地址字段表明 DRAM 中相应物理页的起始位置,它分为两个部分:VPO(虚拟页面偏移)和 VPN(虚拟页号)。
图7-4 K级页表的地址翻译
7.4.1 TLB加速地址翻译
为了优化 CPU 产生一个虚拟地址后,MMU 查阅 PTE的过程,在 MMU 中设置一个关于 PTE 的小缓存,称为 TLB(翻译后备缓冲器)。像普通的缓存一样,TLB 的索引和标记是从 PTE 中的 VPN 提取出来的。
图7-5 TLB加速地址翻译
7.4.2四级页表翻译
每次 CPU 产生一个虚拟地址后,通过它的 VPN 部分看 TLB 中是否缓存,如果命中,直接得到 PPN,将虚拟地址中的 VPO 作为物理页偏移,这样就能得到物理地址;如果 TLB 未命中,则经过四级页表的查找得到最终的PTE,从而得到 PPN,进而得到物理地址。
图7-6 四级页表翻译
7.4.3 Core i7页表翻译
图7-7 Core i7页表翻译
7.5 三级Cache支持下的物理内存访问
首先得到了物理地址VA,首先使用物理地址的CI进行组索引(每组8路),对8路的块分别匹配 CT进行标志位匹配。如果匹配成功且块的valid标志位为1,则命中hit。然后根据数据偏移量 CO取出数据并返回。若没找到相匹配的或者标志位为0,则miss。那么cache向下一级cache,这里是二级cache甚至三级cache中寻找查询数据。然后逐级写入cache。在更新cache的时候,需要判断是否有空闲块。若有空闲块(即有效位为0),则写入;若不存在,则进行驱逐一个块(LRU策略)。
图7-8 三级Cache示意图
7.6 hello进程fork时的内存映射
当fork函数被shell进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
7.7 hello进程execve时的内存映射
execve() 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。加载并运行 hello 需要以下几个步骤:
- 删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的 .text 和 .data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
- 映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC),execv() 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
页面命中完全是由硬件完成的,而处理缺页是由硬件和操作系统内核协作完成的。其处理流程为处理器生成一个虚拟地址,并将它传送给MMU;MMU生成PTE地址,并从高速缓存/主存请求得到它;高速缓存/主存向MMU返回PTE;PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序;缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘;缺页处理程序页面调入新的页面,并更新内存中的PTE;缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。
图7-9 缺页中断处理
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。而分配器分为两种基本风格:显式分配器和隐式分配器。
图7-10 堆
显式分配器必须在严格的约束条件下工作。必须处理任意请求序列;立即响应请求;只使用堆;对齐块;不修改已分配的块。
分配器的编写应该实现:吞吐率最大化;内存使用率最大化(两者相互冲突)。需要注意这几个问题:空闲块组织方式;放置策略;分割策略;合并策略。
带边界标记的隐式空闲链表可以提高空闲块合并效率;显式空闲链表可以有效地实现空闲块的快速查找与合并等操作;分离空闲链表采用大小类的方式标记空闲块;分离适配方法快速而且内存使用效率较高。
图7-11 空闲块的处理方式
图7-12 空闲块的处理方式
适配块策略:首次适配或下一次适配或最佳适配。首次适配利用率较高;下一次适配时间较快;最佳适配可以很好的减少碎片的产生。我们在分离适配的时候采取的策略一般是首次适配,因为对分离空闲链表的简单首次适配的内存利用效率近似于整个堆的最佳适配的利用效率。
malloc就是采用的是分离适配的方法。
31 3 | 210 |
块大小(头部) | a/f |
pred(祖先) | |
succ(后继) | |
填充(可选) | |
块大小(脚部) | a/f |
表7-2 空闲链表结构
块大小(头部) | a/f |
有效载荷 (只包括已分配的块) | |
填充(可选) | |
块大小(脚部) | a/f |
表7-3 带边界标签的隐式空闲链表
7.10本章小结
本章介绍了hello的存储器地址空间(逻辑地址、线性地址、虚拟地址、物理地址);介绍了Intel逻辑地址到线性地址的转化(段式管理),Intel线性地址与物理地址之间的转化(页式管理);分析了TLB与四级页表支持下的VA到PA的变换和三级Cache支持下的物理内存访问;讲述hello进程fork和execve中的内存映射;分析缺页故障及其中断处理,介绍动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
1. 设备的模型化:文件
所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行.
2. 设备管理:unix io接口
这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
8.2.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. 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源.
8.2.2 Unix IO函数
1. 打开和关闭文件。
打开文件函数原型:int open(char* filename,int flags,mode_t mode)
返回值:若成功则为新文件描述符,否则返回-1;
flags:O_RDONLY(只读),O_WRONLY(只写),O_RDWR(可读写)
mode:指定新文件的访问权限位。
关闭文件函数原型:int close(fd)
返回值:成功返回0,否则为-1
2. 读和写文件
读文件函数原型:ssize_t read(int fd,void *buf,size_t n)
返回值:成功则返回读的字节数,若EOF则为0,出错为-1
描述:从描述符为fd的当前文件位置复制最多n个字节到内存位置buf
写文件函数原型:ssize_t wirte(int fd,const void *buf,size_t n)
返回值:成功则返回写的字节数,出错则为-1
描述:从内存位置 buf 复制至多 n 个字节到描述符为 fd 的当前文件位置
8.3 printf的实现分析
通过观察printf函数体,发现va_list的定义被定义为字符指针,并且函数体内部调用了函数vsprintf。
- 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;
- }
vsprintf的作用是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
- 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);
- }
反汇编追踪write函数,发现反汇编语句中的int INT_VECTOR_SYS_CALL,表示要通过系统来调用sys_call这个函数。
- write:
- mov eax, _NR_write
- mov ebx, [esp + 4]
- mov ecx, [esp + 8]
- int INT_VECTOR_SYS_CALL
sys_call函数显示格式化的字符串。将要输出的字符串从总线复制到显卡的显存中。
- 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
字符显示驱动子程序,从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了LINUX的IO设备管理方法,简述UNIX IO接口及其函数,对printf和getchar的实现进行分析。
结论
hello历程:hello.c经过预编译器得到hello.i文本文件;hello.i经过编译器得到汇编代码hello.s汇编文件;hello.s经过汇编器,得到二进制可重定位目标文件hello.o;hello.o由链接器得到可执行文件hello。接着hello加载到内存,内存管理单元MMU、翻译后备缓冲器TLB、多级页表机制、三级cache协同工作,完成对地址的翻译和数据的读写,信号与异常约束它的行为。hello最终被shell父进程回收,内核会收回为其创建的所有信息。
感悟:经过本学期计算机系统课程的学习,我发现hello程序这样表面上简单的程序,其底层的实现过程却十分复杂。通过完成hello程序的一生,我对计算机系统的相关知识的理解进一步加深,将一个程序的预处理、编译、汇编、链接、进程管理、存储管理、IO管理过程进行系统的掌握。
附件
hello | 最终生成的可执行文件 |
hello.c | 源程序 |
hello.elf | 由hello.o生成的elf文件 查看各节信息 |
hello_elf.elf | 由hello生成的elf文件 查看各节信息 |
hello.i | hello.c预编译得到的文件 |
hello.o | 汇编生成的可重定位目标文件 |
hello.s | 由hello.c编译生成的汇编语言代码 |
hello.objdump | hello.o的反汇编代码文件 |
hello_obj.objdump | hello的反汇编代码文件 |
参考文献
- Randal E.Bryant / David O'Hallaron.深入理解计算机系统(原书第3版):机械工业出版社,2016
- https://www.aliyun.com/zixun/wenji/1246586.html
- https://blog.csdn.net/u012491514/article/details/24590467
- https://hansimov.gitbook.io/csapp/
- https://www.doc88.com/p-13647136102967.html
- https://www.cnblogs.com/pianist/p/3315801.html
- https://blog.csdn.net/yueyansheng2/article/details/78860040
- https://docs.oracle.com/cd/E38902_01/html/E38861/chapter6-54839.html#gentextid-15180
- https://blog.csdn.net/langchibi_zhou/article/details/5744922