计算机系统
大作业
题 目 程序人生-Hello’s P2P
指 导 教 师 史先俊
计算机科学与技术学院
2023年4月
本文结合计算机系统所学知识,以hello.c程序为例,阐述它在从编写到运行终止的一生历程,主要包括预处理、编译、汇编、链接、进程管理、存储管理、IO管理,掌握计算机的信息表示及处理、程序的机器级表示、处理器体系结构、存储器层次结构、链接过程、异常控制流、虚拟内存等知识。
关键词:预处理,编译,汇编,链接;
目 录
目录
1.1.1 P2P(From Program to Process):
1.1.2 020(From Zero-0 to Zero-0)
第1章 概述
1.1 Hello简介
1.1.1 P2P(From Program to Process):
Hello程序的生命周期是从一个源程序开始的,即程序员通过编译器创建并保存文本文件,文件名是hello.c。Hello从源文件转化为目标文件是由编译器驱动程序完成的,在此过程中经历了预处理、编译、汇编和链接才最终转化为可执行目标程序,并保存在磁盘中。之后在运行阶段,在壳shell中输入命令(./hello)后,操作系统(OS)的进程为其调用fork,创建一个新的子进程,这样hello就是实现了从程序到进程的转变,该过程就是P2P。如图1-1所示
图1-1 hello的编译过程
以下为各阶段简介
- 预处理:预处理器(cpp)根据以字符#开头的命令,修改原始的C文件。将对应得系统头文件内容插进程序文本中,得到hello.i;
- 编译:编译器(cc1)将文本文件hello.i翻译成文本文件hello.s,包含一个汇编程序语言;
- 汇编:汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序,并将结果保存在目标文件hello.o中,其中hello.o是二进制文件;
- 链接:链接器(ld)将hello调用的标准C库中的一个函数以某种方式与hello.o文件进行合并,得到可执行目标文件(hello),该文件可被加载到内存中,有系统执行。
1.1.2 020(From Zero-0 to Zero-0)
- 在shell进程中输入程序的名称。
- 在操作系统进程管理下,父进程shell通过fork函数产生子进程,通过execve函数加载并运行程序,进行虚拟内存的映射
- 通过mmap分配时间片,最终在内存中存储指令和数据。
- CPU在.text段中读取指令,通过取指,译码,执行,访存,写回,更新 PC 的操作逐条执行指令。
- 运行结束后,shell 父进程回收 hello进程,之后shell将不会存储此进程的任何相关信息
1.2 环境与工具
- 硬件环境:11th Gen Intel(R) Core(TM) i5-11300H @ 3.10GHz 3.11 GHz
64 位操作系统, 基于 x64 的处理器
RAM:16.0 GB
- 软件环境:Windows 11 64位 Ubuntu20.04
- 开发工具:
1.3 中间结果
hello.c | 编写的hello.c代码文件 |
hello.i | hello.c经过预处理得到的文件 |
hello.s | hello.i经过编译得到的文件 |
hello.o | hello.s经过汇编得到的二进制可重定位目标文件 |
hello | hello.o经过链接得到的可执行文件 |
1.4 本章小结
本章对hello进行了一个总体的概括,首先介绍了P2P、020的意义和过程,介绍了作业中的硬件环境、软件环境和开发工具,最后简述了从.c文件到可执行文件中间经历的过程。
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
预处理简单来说,就是在编译之前源文件进行简单加工的过程。更细致的来说,预处理指的是程序在编译之前进行的处理,是计算机在处理一个程序时所进行的第一步处理。
2.1.2预处理命令
- 宏定义
- 无参数的宏:#define 宏名 字符串
- 带参数的宏:#define 宏名(形参表) 字符串
- 文件包含
当一个C语言程序由多个文件模块组成时,主模块中一般包含main函数和一些当前程序专用的函数。程序从main函数开始执行,在执行过程中,可调用当前文件中的函数,也可调用其他文件模块中的函数。如果在模块中要调用其他文件模块中的函数,首先必须在主模块中声明该函数原型。一般都是采用文件包含的方法,包含其他文件模块的头文件。格式如下:格式如下:
#include< 文件名>或#include“文件名”
- 条件编译
表 1 常见的条件编译指令
条件编译指令 | 说 明 |
#if | 如果条件为真,则执行相应操作 |
#elif | 如果前面条件为假,而该条件为真,则执行相应操作 |
#else | 如果前面条件均为假,则执行相应操作 |
#endif | 结束相应的条件编译指令 |
#ifdef | 如果该宏已定义,则执行相应操作 |
#ifndef | 如果该宏没有定义,则执行相应操作 |
2.1.3 预处理的作用
- 将源文件中用#include 形式声明的文件复制到新的程序中。
- 用实际值替换用#define 定义的字符串
- 根据#if 后面的条件决定需要编译的代码
- 特殊符号,预编译程序可以识别一些特殊的符号, 预编译程序对于在源程序中出现的这些串将用合适的值进行替换。
2.2在Ubuntu下预处理的命令
2.2.1 预处理指令
2.2.2结果展示
如图2-1所示
图2-1 hello.c预处理过程
生成了hello.i文件,如图2-2所示
2.3 Hello的预处理结果解析
- 经过预编译过程后,文件从23行拓展至3060行。在其中,文件中所有的注释已经消失。完成了对头文件的展开,对宏定义的替换等内容
- 如图2-3我们可以看出cpp将读取了<stdio.h>,<unistd.h>,<stdlib.h>中的内容,并将其直接插入了源文件中。
- 如图2-4 extern引用外部变量和符号
- 如图2-5所示,3040行开始为文件原始的内容,其中完成了注释的删除与宏定义的替换过程,main函数在预处理前后没有发生改变
图2-3 hello.i头文件引用
图2-4 hello.i 外部变量引用
2.4 本章小结
本章主要介绍了程序预编译的处理流程与处理内容,介绍了程序从源文件到预处理程序的指令与对应的宏内容替换、#if的处理、无关项的去除等处理内容。
第3章 编译
3.1编译的概念与作用
3.1.1编译的概念
编译是把通常为高级语言的源代码(这里指经过预处理而生成的 hello.i)到能直接被计算机或虚拟机执行的目标代码(这里指汇编文件 hello.s)的翻译过程。
3.1.2编译的作用
- 语法分析:编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,方法分为两种:自上而下分析法和自下而上分析法。
- 中间代码:源程序的一种内部表示,或称中间语言。中间代码的作用是可使编译程序的结构在逻辑上更为简单明确,特别是可使目标代码的优化比较容易实现中间代码。
- 代码优化:指对程序进行多种等价变换,使得从变换后的程序出发,能生成更有效的目标代码。
- 目标代码:生成是编译的最后一个阶段。目标代码生成器把语法分析后或优化后的中间代码变换成目标代码。此处指汇编语言代码,须经过汇编程序汇编后,成为可执行的机器语言代码。
3.2 在Ubuntu下编译的命令
3.2.1 常见的编译指令
- gcc -S hello.c -o hello.s
- cc1 main.i -Og -o -main.s
/usr/lib/gcc/x86_64-linux-gnu/9/cc1 hello.i -o hello.s
3.2.2编译过程
如图3-1所示
图3-1 hello.i >hello.s
3.3Hello的编译结果解析
表3-1 hello.s文件中的标记符号解析
.file | 文件命名 |
.text | 代码段 |
.section .rodata | 只读数据段 |
.align | 对齐方式 |
.global | 全局变量 |
.type | 类型 |
.long | long类型变量 |
.string | 字符串类型变量 |
3.3.1数据
- 常量
如图3-2所示
file表示源文件为hello.i;
.text下面是代码段;
.section .rodata节;
.align声明对指令或数据的存放地址进行对齐的方式为8。
hello.s中的printf打印的字符串被存储.rodata节的.LC0和.LC1中,可以看出汉字被编码成了三个字节值,而英文字符仍保留其原本格式;数字常量仍为十进制整型数;
图3-2 hello.s .rodata节
2.变量
对于程序而言,初始化的全局变量存储在.data中,没有被初始化的全局变量存储在.bss中。而局部变量一般存储在寄存器或者栈中。
如图3-3所示i被存储在距离栈帧4字节的部分,并跳转到.L3开始循环,可知变量保存在栈中
3.3.2赋值
hello.c中的赋值操作是对for循环中的i的操作。其实实现的主要方法是利用mov传送传送指令。move后缀含义b(传送一字节),w(传送两字节),l(传送四个字节),q(传送八个字节),如图3-4所示,可知赋值i为0;
图3-4 赋值操作
3.3.3类型转换
在程序中用到了C标准库的atoi函数,把argv[3]中的字符串内容转换为一个整型数,如图3-5所示
图3-5 类型转换
3.3.4算术操作
在for循环中,局部变量i的每次i++,这个运算由addl来完成,如图3-6所示
3.3.5关系操作
- 比较argc和4的大小,如果相等,则跳转到.L2,如图3-7所示;
图3-7 关系操作
2.比较i和4的大小,如果小于等于,则跳转到.L4
图3-8 关系操作
3.3.6 数组
数组访问方式如下:
图 3-10 表示argv[1]的地址
图3-11表示argv[2]的地址
图3-12表示argv[3]的地址
3.3.7控制转移
- 第一处:if,如图3-13所示,比较argc和4的大小,如果相等,则跳转到.L2,
图3-13 if转移
- 第二处:for,如图3-14所示,比较i和4的大小,如果小于等于,则跳转到.L4
图3-14 for转移
3.3函数操作
- 函数调用:通过call+函数名实现,call指令会把调用该指令的下一个地址压入栈中,压入栈中的地址为返回地址。
- 函数返回:通过ret指令,首先将栈帧+8,从栈中弹出返回地址,将PC设置为该值,从而可以进行函数返回。
- 参数传递:通过寄存器%rdi, %rsi, %rdx, %rcx, %r8, %r9保存,多余的参数通过栈来传递函数的参数,返回值用rax来保存
main函数:
main函数的传入参数为argc与argv。这两个变量从shell的输入中获得,通过shell程序的解析得到两个参数的值,在main函数结束的时候,通过return 0;得到0的返回值。
printf函数,
leap指令将.LC1(%rip)的值传给首地址%rdi, printf函数的第一个参数为一个字符串,用来存放打印信息,而后续的参数是在字符串中所有要打印的变量的值。如图3-15所示:
图3-15 printf函数调用
atoi函数和sleep函数
如图3-16所示
将一个字符串的首地址给%rdi调用,函数返回这个字符串所转成的整数值
存放在%eax中。
将%eax的内容传给%rdi,调用sleep函数
图3-16 atoi和sleep函数
exit函数
传递参数的过程就是将寄存器%edi的值赋为1,然后调用函数。如图3-17所示
图3-17 exit函数
3.4 本章小结
本章从hello.i到hello.s,对程序进行汇编操作,对于常量,编译器将其存放到特定的位置,记录一些信息。程序中的语句,编译器通过寄存器、栈的结构进行赋值,分支语句通过je jle等进行操作,每种语句都有对应的实现方法,程序中的函数,如果不是库函数,就会对函数进行逐句的语法分析和解析,如果是库函数,则会直接进行call调用。汇编语言相对于高级语言更靠近底层机器,直接面对硬件,汇编语言具有机器相关性、高速度和高效率,编写和调试的复杂性等特性。
第4章 汇编
4.1汇编的概念与作用
4.1.1汇编的概念
汇编是指汇编器(as)将hello.s翻译为机器语言,并产生可重定位目标文件(hello.o)的过程。hello.o是一个二进制文件,它将hello.s中的用文本表述的机器指令大体上一对一地翻译为由0、1组成的机器指令。
4.1.2作用
将汇编语言文本文件转换为机器代码的二进制文件,汇编的结果是一个可重定位目标文件(如hello.o)其中包含的是不可读的二进制代码。
4.2在Ubuntu下汇编的命令
4.2.1汇编的命令
- gcc -c hello.s -o hello.o
- as -o hello.o hello.s
4.2.2汇编结果
如图4-1所示,得到了hello.s文件
图4-1 汇编结果
4.3可重定位目标elf格式
4.3.1ELF可重定位目标文件格式
如图4-2所示
4.3.2ELF中各节的解析
- ELF头
- ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。剩下部分如图23所示,包含了帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小,目标文件的类型(如可重定位、可执行或者共享的),机器类型(如 X86-64 AMD),节头部表的文件偏移,以及节头部表中条目的大小和数量。不同的节位置和大小都是由节头部表描述的。
- .text节
- 已编译的机器代码
- rodata
- 只读数据,比如printf语句中的格式串和开关语句的跳转表,立即数
- .data
- 已初始化的全局和静态C变量。
- .bss
- 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际空间,它仅仅是一个占位符。运行时,在内存中分配这些变量,初始值为0。
- .symtab
- 一个符号表,存放在程序中定义和引用的函数和全局变量信息。
- 注意:与编译器中的符号表不同,该符号表不含局部变量的条目。
- .rel.text(可重定位代码)
- 一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般来说。任何调用外部函数或者引用全局变量的指令都需要修改,调用本地函数的指令则不需要修改。
- .rel.data
- 被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
- .debug
- 一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量以及原始的C源文件
- .line
- 原始C源程序中的行号和.text节中机器指令之间的映射。
- .strtab
- 一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的接名字。字符串表就是以null结尾的字符串的序列。
-
4.3.3hello中各节信息
- ELF头
- 如图4-3所示
- 包括ELF头的大小,目标文件的类型(如可重定位、可执行或者共享的),机器类型(如 X86-64 AMD),节头部表的文件偏移,以及节头部表中条目的大小和数量。
- 图4-3 ELF头
- hello.o各节
- 如图4-4所示
- 详细标识了每个节的名称、类型、地址、偏移量、大小、读取权限、对齐方式
- 图4-4 hello.o中各节信息
- hello.o符号表
- 如图4-5所示
- 图4-5 hello.o符号表
- hello.o可重定位
如图4-6所示,在ELF表中有两个.rel节,分别是.rela.text和.rela.eh_frame。内容有偏移量、信息、类型、符号值、符号名称等等。
4.4 Hello.o的结果解析
反汇编hello.o结果,如图4-7所示
图4-7 反汇编结果
4.4.1机器语言的构成
机器语言由计算机直接识别的二进制代码构成,不同型号的计算机其机器语言是不相通的,按着一种计算机的机器指令编制的程序,不能在另一种计算机上执行。器语言全为0、1序列表示数据和指令。
4.4.2 机器代码与汇编语言的映射关系
机器语言与汇编语言是一一对应的关系,一条机器语言对应一条汇编指令。机
4.4.3 hello.o反汇编和hello.s的对比
- 操作数进制不同:.o反汇编文件操作数是十六进制,.s文件操作数是十进制,如图4-8所示
图4-8 操作数对比
2.跳转指令不同:.o反汇编文件按照所在节起始地址+地址偏移量进行的,还给出了可重定位条目,.s的文件是按照.L2等进行跳转,如图4-9所示
图4-9 跳转指令
3.函数调用不同:.o文件call+函数起始地址,.s文件call+函数名,如图4-10所示:
4.5 本章小结
本章着重介绍了汇编的概念和作用,并且以hello.s到hello.o为例,介绍并分析了课冲定位目标文件的ELF格式,以及对hello.o进行了解析,将编译的结果hello.s与对hello.o的反汇编代码进行了比较,了解了汇编代码和反汇编代码一些结构和内容上的区别。通过这些讨论,增强了对汇编过程的理解。
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行干编译时(compiletime),也就是在源代码被翻译成机器代码时;也可以执行干加载时(loadtime),也就是在程序被加载器(loader)加载到内存并执行时;甚至执行于运行时(runtime),也就是由应用程序来执行。
5.1.2 链接的作用
链接可以将各种代码和数据片段手机并组合策划归纳成一个可以加载到内存并执行的单一文件。它使得分离编译成为可能,可以将一个大型的应用程序分解为更小,更好管理的模块,便于独立修改和编译,链接让程序员能够利用共享库,通过动态链接为程序提供动态的内容。
5.2在Ubuntu下链接的命令
5.2.1链接的命令
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.2.2连接结果(如图5-1)
图5-1 链接过程
5.3可执行目标文件hello的格式
5.3.1 hello的ELF文件格式
ELF头 | 字段e_entry给出执行程序时第一条指令的地址 | 只读代码段 |
程序头表 | 结构数组 | |
.init节 | 用于定义_init函数,该函数用来进行可执行目标文件开始执行的初始化工作 | |
.text节 | 编译后的代码部分 | |
.rodata节 | 只读数据 | |
.data节 | 已初始化的全局和静态C变量 | 读写数据段 |
.bss节 | 未初始化的全局和静态C变量 | |
.symtab节 | 符号表,存放在程序中定义和引用的函数和全局变量的信息 | 无需装入到存储空间的信息 |
.debug节 | 一个调试符号表,条目是程序中定义的局部变量和类型定义 | |
.strtab节 | 一个字符串表,内容包括.symtab和.debug节中的符号表,以及节头部中的节名字 | |
.line节 | 原始C源程序中的行号和.text节中机器指令之间的映射 | |
节头表 | 每个节的节名、偏移和大小 |
5.3.2 hello中ELF各节
- ELF头(如图5-2)
图5-2 hello的ELF头
2.各节(如图5-3)
图5-3 各节信息
连接后增加了许多节
- .hash/gun.hash:对应的符号哈希
- .dynsym段:动态符号表,存储与动态链接相关的导入导出符号,不包括模块内部的符号;
- .dynstr段:存储.dynsym段符号对应的符号名;
- .dynamic段:保存动态链接所需要的基本信息,存储动态链接会用到的所有表的位置信息;
- .gotplt:全局偏移表-过程链接表
- .got:.got节保存了全局偏移表。
5.3.3符号表(图5-4)
图5-4符号表
5.3.4程序头(图5-5)
图5-5 程序头
5.3.5 段节(图5-6)
图5-6 段节
5.3.6 动态节(图5-7)
图5-7 动态节
5.3.7 重定位节(如图5-8)
图5-8 可重定位节
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。如图5-9所示,可知程序的地址为0x00401000-0x00402000段中,
如图5-10所示,看出hello的虚拟地址空间开始于0x400000,结束与0x400ff0
图5-9 hello的Data Dump
图5-10 hello的虚拟地址空间
- .init节起始地址0x401000,大小为0x1b
2.plt节起始地址为0x401020,大小为0x70
3.text节起始地址0x401090,大小0x145
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
5.5.1 hello和hello.o的不同
- 如图5-11所示,hello比hello.o多出了init、plt、fini这几节
- init:包含程序初始化时需要的代码
- plt:节也称为 过程链接表(Procedure Linkage Table) , 其包含了动态链接器调用从共享库导入的函数所必需的相关代码 。 由于.plt 节保存了代码,所以节类型为 SHT_PROGBITS 。
- fini:包含进程终止时要执行的指令代码少了.rel.text 和.rel.data 节等重定位信息节。多了一个程序头表也叫作段头表
图5-11 init、plt、fini节
- 重定位:将多个代码段和数据段分别合并为一个完整的代码段和数据段,计算每一个定义 的符号在虚拟地址空间的绝对地址而不是相对偏移量,将可执行文件中的符号引用处修改为重定位后的地址信息。如图5-12所示
图5-12 重定位地址
5.5.2 链接的过程
- 符号解析:链接器将每个符号引用与一个确定的符号定义关联起来。
在编译时,编译器向汇编器输出每个全局符号,或者是强(strong)或者是弱(weak),而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
根据强弱符号的定义,Linux链接器使用下面的规则来处理多重定义的符号名:
规则1:不允许有多个同名的强符号;
规则2:如果有一个强符号和多个弱符号同名,那么选择强符号;
规则3:如果有多个弱符号同名,那么从这些弱符号中任意选择一个。
- 重定位:编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。
- 重定位节和符号定义:链接器将所有相同类型的节合并为同一类型的新的聚合节
- 重定位节中的符号引用:在该步中,链接器修改代码节和数据节中对每个符号的引用,使它们指向正确的运行地址
5.5.3 hello的重定位
hello主要依靠重定位条目进行修改。主要采用两种方式R_x86_64_PC32和R_x86_64_32
- R_x86_64_PC32: 重定位一个使用32位PC相对地址的引用。一个PC相对地址就是距程序计数器(PC)的当前运行时值的偏移量。当CPU执行一条使用PC相对寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行时值,得到有效地址(如call指令的目标),PC值通常是下一条指令在内存中的地址。
- R_x86_64_32: 重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
例如:调用atoi函数
可重定位条目如图5-13所示
可知r.offset=0x6d r.symbol=atoi raddend=-4
由hello反汇编代码可知ADDR(s)=0x4010c5 ADDR(atoi)=0x401060
refaddr=ADDR(s)+r.offset=0x4010c5+0x6d=0x401132
*refptr=(unsigned)(ADDR(r.symbol)+r.addend-refaddr)
=(unsigned)(0x401060-4-0x4010c5)
=ffff ff2a
5.6 hello的执行流程
地址 |
0x00000000401000 |
0x00000000401020 |
0x00000000401030 |
0x00000000401040 |
0x00000000401050 |
0x00000000401060 |
0x00000000401070 |
0x00000000401080 |
0x000000004010c0 |
0x00007fg5f7bde00 |
0x00007f5b70fb8df |
0x0007f5b70fc8c10 |
0x00007f5b70fcc550 |
0x00007f5b70fb8222 |
0x00007f5b70fb8230 |
0x00007f5b70fb821a |
5.7Hello的动态链接分析
hello的调用
程序调用由共享库定义的函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。GNU 编译系统使用了延迟绑定(lazy binding),将过程地址的绑定推迟到第一次调用该过程时。在GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数,在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。
在dl_init调用之前,编译器产生对调用函数的直接PC相对引用,并增加一个重定位,让链接器在构造共享模块时解析它。PLT[1]调用系统启动函数初始化执行环境,调用main并处理其返回值,每个GOT条目都对应一条PLT条目,初始时,每个GOT条目都指向对应的PLT条目的第二条指令。
由elf的节头信息易知,got.plt的地址
dl_init前:
dl_init后:
动态链接器会重定位GOT中的每个条目,使它包含正确的绝对地址,而PLT中的每个函数负责调用不同函数。
- 不直接调用 dl_init,程序调用进入 PLT[2],这是 dl_init 的 PLT条目。
- 第一条 PLT指令通过 GOT[4]进行间接跳转。因为每个 GOT 条目初始时都指向 它对应的 PLT 条目的第二条指令,这个间接跳转只是简单地把控制传送回 PLT[2]中的下一条指令。
- 在把 dl_init 的 ID压入栈中之后,PLT[2]跳转到 PLT[0]。
- PLT[0]通过GOT[1]间接地把动态链接器的一个参数压入栈中,然后通过GOT[2] 间接跳转进动态链接器中。动态链接器使用两个栈条目来确定 dl_init 的运行时位置,用这个地址重写GOT[4],再把控制流传递给 dl_init。
5.8 本章小结
本章介绍了链接的概念和作用,以及以hello为例,分析了可执行文件的ELF格式、虚拟地址空间、将hello的反汇编文件进行比较,并具体计算了重定位的过程与动态链接的过程。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念
进程就是一个执行中程序的实例。是计算机中的程序关于某数据集合 上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
6.1.2进程的作用
提供给应用程序两个关键抽象:一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器;一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell-bash作用
shell 最重要的功能是命令解释。shell 是一个命令解释器。用户提交了一个命令后,shell 首先判断它是否为内置命令,如果是就通过 shell 内部的解释器将其解 释为系统功能调用并转交给内核执行;若是外部命令或使用程序就试图在硬盘中 查找该命令并将其调入内存,再将其解释为系统功能调用并转交给内核执行。
6.2.2 处理流程
- 从终端读入输入的命令,将输入字符串切分获得所有的参数。
- 对用户输入的命令进行解析,判断命令是否为内置命令,如果为内置命令,调用内置命令处理函数;如果不是内置命令,就创建一个子进程,将程序在该子进程的上下文中运行。
- 判断为前台程序还是后台程序,如果是前台程序则直接执行并等待执行结束,如果是后台程序则将其放入后台并返回。其他的输入信号有其对应的信号处理。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程。
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。fork被调用一次,返回两次:一次是在调用进程(公进程)中,一次是在新创建的子进程中,在父进程中,fork返回子进程的PID。在子进程中,fork返回0。
6.4 Hello的execve过程
6.4.1 execve函数
如图6-1所示
图6-1 execve函数
6.4.2 过程
- execve 函数加载并运行可执行目标文件 filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到 filename,execve 才会返回到调用程序
- 与 fork一次调用返回两次不同,execve 调用一次并从不返回。参数列表是用图6-2中的数据结构表示的。argv 变量指向一个以 null 结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例,argv[0]是可执行目标文件的名字。环境变量的列表是由一个类似的数据结构表示的,如图 6-2所示。envp 变量指向一个以null 结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如name=value”的名字-值对。
- 在execve 加载了 filename 之后,它调启动代码。启动代码设置并将控制传递给新程序的主函数,该主函数有如下形式的原型栈,int main(int argc, char **argv, char **envp);或者等价的int main(int argc, char *argv[], char *envp[]);
图6-2 环境变量列表组织
当 main开始执行时,用户栈的组织结构如图6-3所示。让我们从栈底(高地址)往栈顶(低地址)依次看一看。首先是参数和环境字符串。栈往上紧随其后的是以null结尾的指针数组,其中每个指针都指向栈中的一个环境变量字符串。全局变量environ指向这些指针中的第一个envp[0]。紧随环境变量数组之后的是以null结尾的argv[ ]数组,其中每个元素都指向栈中的一个参数字符串。在栈的顶部是系统启动函数libc_start_main的栈帧。
图6-3 新程序开始时,用户栈的典型情况
6.5 Hello的进程执行
6.5.1 用户态和核心态
用户态:提供应用程序运行的空间,为了使应用程序访问到内核管理的资源,例如CPU,内存,I/O等。
核心态:本质是内核,一种特殊的软件程序,用于控制计算机的硬件资源,例如协调CPU资源,分配内存资源,并且提供稳定的环境供应用程序运行。
6.5. 2上下文/时间片
上下文:是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数 器、环境变量以及打开文件描述符的集合。
时间片:一个进程执行它的控制流的一部分的每一个时间段叫做时间片。
6.5.3 调度
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调,是由内核中称为调度的代码处理的。
当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换
- 保存当前进程的上下文
- 恢复某个先前被抢占的进程被保存的上下文
- 将控制传递给这个新恢复的进程、
6.5.4 进程执行过程
图6-4展示了一对进程A和B之间上下文切换的示例。在这个例子中,进程A初始运行在用户模式中,直到它通过执行系统调用 read 陷入到内核。内核中的陷阱处理程序请求来自磁盘控制器的DMA传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器。
图6-4 进程的上下文切换剖析
磁盘取数据要用一段相对较长的时间(数量级为几十毫秒),所以内核执行从进程A到进程B的上下文切换,而不是在这个间歇时间内等待,什么都不做。注意在切换之前,内核正代表进程A在用户模式下执行指令(即没有单独的内核进程)。在切换的第一部分中,内核代表进程A在内核模式下执行指令。然后在某一时刻,它开始代表进程B(仍然是内核模式下)执行指令。
在切换之后,内核代表进程B在用户模式下执行指令。随后,进程B在用户模式下运行一会儿,直到磁盘发出一个中断信号,表示数据已经从磁盘传送到了内存。内核判定进程B已经运行了足够长的时间,就执行一个从进程B到进程A的上下文切换,将控制返回给进程A中紧随在系统调用read之后的那条指令。进程A继续运行,直到下一次异常发生,依此类推。
6.6 hello的异常与信号处理
(以下格式自行编排,编辑时删除)
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.6.1 常见异常
6.6.2 hello执行过程中异常
- 正常运行
2.ctrl+z
在hello在前台运行的时候,按下ctrl+z会向其发送SIGTSTP信号,这个进程就会暂时挂起
3.回车+乱按
乱输入字符会输出,回车会进行保存在进程结束后输出
4.fg
会恢复前台运行
5.ps
6.jobs
7.pstree
8.kill
6.7本章小结
这一章主要应用异常控制流与信号控制对应用程序进行操作,主要讲述应用程如何与操作系统进行交互,这些交互都是围绕着异常控制流与信号处理。异常位于硬件和操作系统交接的部分。系统调用是为应用程序提供到操作系统的入口点的异常。还有进程和信号,它们位于应用和操作系统的交界之处
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
- 逻辑地址:逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。在 hello 中,生成的 hello.o 文件中的地址即偏移量,都是逻辑地址。
- 线性地址:CPU加载程序后,会为这个程序分配内存,所分配内存又分为代码段内存和数据段内存。代码段内存的基址保存在CS中,数据段内存的基址保存在DS中。段基址+逻辑地址=线性地址。
- 虚拟地址:虚拟地址是一个抽象的地址空间,虚拟地址对应虚拟页,虚拟页会映射磁盘空间的一页,如果要使用该页上的数据,则会将该页载入内存,虚拟地址就对应了物理地址。
- 物理地址:用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应
7.2Intel逻辑地址到线性地址的变换-段式管理
- T1=0选择全局描述符表(GDT),TI=1,选择描述符表(LDT)
- RPL=00为第0级,位于最高级的内核态,RPL=11,为第三级,位于最低级的用户态,第0级高于第3级
- 全局描述符表GDT:只有一个,用来存放系统内每个任务都可能 访问的描述符,例如,内核代码段、内核数据段、用户代码段、 用户数据段以及TSS(任务状态段)等都属于GDT中描述的段
- 局部描述符表LDT:存放某任务(即用户进程)专用的描述符
- 中断描述符表IDT:包含256个中断门、陷阱门和任务门描述符
- 起始位置加上偏移量即为线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
从虚拟地址到物理地址的转变,计算机中是通过页表和MMU来实现的。如图7-1所示,CPU中的一个控制寄存器,页表基址寄存器(Page Table Base Register,PTBR)指向当前页表。n位的虚拟地址包含两个部分∶一个p位的虚拟页面偏移(Virtual Page Offset,VPO)和一个(n—p)位的虚拟页号(Virtual Page Number,VPN)。MMU利用VPN来选择适当的PTE。例如,VPN 0选择PTE 0,VPN1选择PTE1,以此类推。将页表条目中物理页号(Physical Page Number,PPN)和虚拟地址中的VPO串联起来,就得到相应的物理地址。注意,因为物理和虚拟页面都是P字节的,所以物理页面偏移(Physical Page Offset,PPO)和VPO)是相同的。
图7-1 页表的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。如图7-2所示用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果TLB有T=2t个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,而TLB标记(TLBT)是由VPN中剩余的位组成的。
图7-2 TLB组成
图 7-3 展示了当 TLB 命中时(通常情况)所包括的步骤。这里的关键点是,所有的地址翻译步骤都是在芯片上的 MMU 中执行的,因此非常快。
第1步:CPU 产生一个虚拟地址。
第2步和第3步:MMU从TLB 中取出相应的PTE
第4步:MMU将这个虚地址翻译成一个物理地址,并且将它发送到高速缓存/主存
第5步:高速缓存/主存将所请求的数据字返回给 CPU。
当TLB不命中时,MMU 必须从 L1 缓存中取出相应的 PTE,如图7-4 所示。新取出的 PTE 存放在 TLB 中,可能会覆盖一个已经存在的条目。
图7-3
图7-4
7.5 三级Cache支持下的物理内存访问
L1cache 有64组,八路组相连,每块64字节。所以块偏移CO是 位, 组索引 CI 是6位,剩下的 40 位为标记 CT。现有物理地址52位,低6位是 CO,CO的左边高 6 位是CI,剩余的是CT。根据组索引CI,定位到 L1cache 中的某一组,遍历这一组中的每一行,如果某一行的有效位为1且标记位等于CT,则命中,根据块偏移CO取出数据。如果未命中,则向下一级 cache 寻找数据。更新 cache 时,首先判断是否有空闲块。如果有,则写入这个块,否则根据替换算法驱逐一个块后再写入。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在进程中加载并运行hello需要以下几个步骤∶
- 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello文件中的.text 和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。图9-31概括了私有区域的不同映射。
- 映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C库libc. so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
DRAM缓存不命中称为缺页(page fault)。图7-5展示了在缺页之前我们的示例页表的状态。CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4。如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。
接下来,内核从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。图7-6展示了在缺页之后我们的示例页表的状态。
图7-5 缺页前
图7-6 缺页时状态
7.9 动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)(如图7-5)。系统之间细节不同但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量 brk(读做“break”),它指向堆的顶部
分配器将堆视为一组不同小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
- 显式分配器(explicit allocator),要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做 mallo 程序包的显式分配器。C程序通过调用 malloc 函数来分配一个块,并通过调用 free 函数来释放一个块。C++ 中的 new 和 delete 操作符与C中的 malloc和 free 相当。
- 隐式分配器(implicit allocator),另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集(garbage collection)。例如,诸如 Lisp、ML 以及 Java 之类的高级语言就依赖垃圾收集来释放已分配的块。
图7-5 堆
C标准库提供了一个称为malloc程序包的显式分配器。程序通过调用malloc函数来从堆中分配块。malloc函数返回一个指针,指向大小为至少size字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐
如果 malloc 遇到问题(例如,程序要求的内存块比可用的虚拟内存还要大),那么它就返回NULL,并设置errno。malloc不初始化它返回的内存。那些想要已初始化的动态内存的应用程序可以使用calloc,calloc是一个基于malloc的瘦包装函数,它将分配的内存初始化为零。想要改变一个以前已分配块的大小,可以使用 realloc 函数。
7.10 本章小结
本章主要介绍了hello的物理地址、存储地址、线性地址、逻辑地址的概念以及相互转变的步骤与操作。同时介绍了cache的寻址,fork、execve函数的内存映像,缺页故障及管理以及动态内存分配。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件。所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。
设备管理:unix io接口,这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行
8.2 简述Unix IO接口及其函数
- open()函数
open函数将 filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件ORDONLY:只读;OWRONLY:只写;ORDWR:可读可写
mode参数指定了新文件的访问权限位。
2.close()
3.read()
读文件从当前文件位置复制字节到内存位置,然后更新文件位置,返回值表示的是实际传送的字节数量。
4.write()
写文件从内存复制字节当前文件位置,然后更新文件位置,返回值表示的是从内存向文件fd实际传送的字节数量nbytes<0表明发生错误。
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;
}
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
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生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
- 字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
- 显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
int getchar(void)
{
static char [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 设备管理方法,Unix IO 接口及其函数,以及 printf,getchar 的实现和工作过程。
结论
- hello的一生
- 程序员在IDE中编写hello的代码,将之存储为hello.c文件
- 预处理阶段:预处理器(cpp)处理hello.c中的预处理命令,将hello.c中涉及的内容扩展到文件中,转换为hello.i文件。
- 编译阶段:编译器(cc1)首先进行词法分析和语法分析,检查代码的规范性、是否有语法错误等。接下来对代码进行优化,最后生成汇编代码,将hello.i文件转变为hello.s文件,简化了将高级语言转化为计算机可执行的二进制文件时的操作。
- 汇编阶段:汇编器(as)将hello.s中用文本语言描述的指令逐一地翻译为二进制标识地机器指令,转化为hello.o。
- 链接阶段:链接器(ld)将hello所用到的静态链接库、动态链接库与hello.o链接形成可执行目标文件。
- 创建进程:操作系统控制shell调用fork函数为hello创建一个子进程。
- 加载程序:操作系统通过加载器,调用execve函数,将hello的代码、数据等信息加载到新进程中,并为其创建自己的虚拟内存空间。
- 运行:CPU,顺序执行hello的逻辑控制流中的指令,响应其需求。
- 异常:在运行过程中,OS同时接受和检测异常信号,并调用相关的异常处理程序,对各种异常信号进行处理。
- hello的进程结束后,父进程回收子进程,hello的一生结束。
hello是我们在接触计算机时敲入的第一个代码,虽然看似简单,但是其中间经历的过程确是丰富多彩的,历经底层硬件到预处理阶段,编译阶段,汇编阶段,加载进程,链接器,体现着程序每一步的发展进程,也体现着计算机的魅力,通过对hello的分析我们看到了计算机背后的神奇,鼓励着我们进一步学习了解计算机
附件
列出所有的中间产物的文件名,并予以说明起作用。
hello.c | 编写的hello.c代码文件 |
hello.i | hello.c经过预处理得到的文件 |
hello.s | hello.i经过编译得到的文件 |
hello.o | hello.s经过汇编得到的二进制可重定位目标文件 |
hello | hello.o经过链接得到的可执行文件 |
objhello | hello的反汇编代码 |
Objhelloo | hello.o的反汇编代码 |
参考文献
为完成本次大作业你翻阅的书籍与网站等
[4] 深入理解计算机系统
[5] ppt