摘 要
本研究聚焦于解释C语言程序如何从源代码转换为可执行文件。在Linux系统下,以简单的C语言文件hello.c为对象,全面深入地探讨了该程序的完整生命周期。从最初的原始程序出发,系统性地研究了编译、链接、加载、运行、终止和回收等关键阶段,以揭示hello.c文件整个的生命过程。此文不仅理论上探讨了这些工具的原理和方法,还实际演示了它们的操作和结果,阐述了计算机系统的工作原理和体系结构,意在助读者深入理解和掌握C语言程序的编译和执行过程,赋予冰冷的指令以浪漫的色彩。
关键词:计算机系统;C语言;程序生命周期;底层原理
目 录
第1章 概述
1.1 Hello简介
P2P:即From Program to Process。指从hello.c(Program)变为运行时进程(Process)。要让hello.c这个C语言程序运行起来,需要先把它变成可执行文件,这个变化过程有四个阶段:预处理,编译,汇编,链接,完成后就得到了可执行文件,然后就可以在shell中执行它,shell会给它分配进程空间。
020:即From Zero-0 to Zero-0。指最初内存并无hello文件的相关内容,shell用execve函数启动hello程序,把虚拟内存对应到物理内存,并从程序入口开始加载和运行,进入main函数执行目标代码,程序结束后,shell父进程回收hello进程,内核删除hello文件相关的数据结构。
1.2 环境与工具
硬件环境:
处理器:12th Gen Intel(R) Core(TM)i5-12500H 2.50 GHz
机带RAM:16.0GB
系统类型:64位操作系统,基于x64的处理器
软件环境:Windows11 64位,VMware,Ubuntu 20.04 LTS
开发与调试工具:Visual Studio 2021 64位;vim objump edb gcc readelf等工具
1.3 中间结果
hello.i 预处理后得到的文本文件
hello.s 编译后得到的汇编语言文件
hello.o 汇编后得到的可重定位目标文件
hello.elf 用readelf读取hello.o得到的ELF格式信息
hello.asm 反汇编hello.o得到的反汇编文件
hello1.elf 由hello可执行文件生成的.elf文件
hello1.asm 反汇编hello可执行文件得到的反汇编文件
1.4 本章小结
本章首先介绍了hello的P2P,020流程,包括流程的设计思路和实现方法;然后,详细说明了本实验所需的硬件配置、软件平台、开发工具以及本实验生成的各个中间结果文件的名称和功能。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
预处理步骤是指预处理器在程序运行前,对源文件进行简单加工的过程。预处理过程主要进行代码文本的替换工作,用于处理以#开头的指令,还会删除程序中的注释和多余的空白字符。预处理指令可以简单理解为#开头的正确指令,它们会被转换为实际代码中的内容(替换)。
2.1.2预处理的作用
预处理过程中并不直接解析程序源代码的内容,而是对源代码进行相应的分割、处理和替换,主要有以下作用:
头文件包含:将所包含头文件的指令替代。
宏定义:将宏定义替换为实际代码中的内容。
条件编译:根据条件判断是否编译某段代码。
其他:如注释删除等。
简单来说,预处理是一个文本插入与替换的过程预处理器。
2.2在Ubuntu下预处理的命令
预处理的命令:gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
在Linux下打开hello.i文件,我们对比了源程序和预处理后的程序。结果显示,除了预处理指令被扩展成了3000行之外,源程序的其他部分都保持不变,说明.c文件的确是被修改过了。
在代码中, main 函数之前的大段代码源自于头文件 <stdio.h>, <unistd.h>, <stdlib.h> 的展开。下面以 stdio.h 的展开为例进行说明:
在预处理过程中,#include 指令的作用是将指定的头文件内容包含到源文件中。<stdio.h> 是标准输入输出库的头文件,它包含了用于读写文件、标准输入输出的函数原型和宏定义等内容。
当预处理器遇到 #include<stdio.h> 时,它会在系统的头文件路径下查找 stdio.h 文件(一般位于 /usr/include 目录下),然后将 stdio.h 文件中的内容复制到源文件中。stdio.h 文件中可能还有其他的 #include 指令,比如 #include<stddef.h> 或 #include<features.h> 等,这些头文件也会被递归地展开并包含到源文件中。
预处理器不会对头文件中的内容进行任何计算或转换,只是简单地进行复制和替换。
预处理器的作用:预处理器在编译过程中首先处理 #include 指令,它通过在系统的头文件路径中查找指定的头文件并将其内容包含到源文件中来展开这些指令。
头文件的作用:头文件通常包含函数原型、宏定义和其他必要的声明,这些声明允许在多个源文件中共享代码和定义。
递归展开:当 stdio.h 包含其他头文件时,比如 stddef.h 或 features.h,这些头文件也会被递归地展开,确保所有必要的定义和声明都包含在最终的源文件中。
简单替换:预处理器只是简单地复制和替换头文件内容,不进行任何形式的计算或逻辑操作。
通过这种方式,所有相关的头文件内容都被包含到源文件中,从而使编译器在后续的编译阶段可以正确解析和编译代码。
2.4 本章小结
本章讲述了在linux环境中,如何用命令对C语言程序进行预处理,以及预处理的含义和作用。然后用一个简单的hello程序演示了从hello.c到hello.i的过程,并用具体的代码分析了预处理后的结果。通过分析,我们可以发现预处理后的文件hello.i包含了标准输入输出库stdio.h的内容,以及一些宏和常量的定义,还有一些行号信息和条件编译指令。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
计算机程序编译的概念是指将用高级程序设计语言书写的源程序,翻译成等价的汇编语言格式程序的翻译过程。
3.1.2编译的作用
计算机程序编译的作用是使高级语言源程序变为汇编语言,提高编程效率和可移植性。计算机程序编译的基本流程包括词法分析、语法分析、语义分析、中间代码生成、代码优化和目标代码生成等阶段。
3.2 在Ubuntu下编译的命令
编译的命令:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1汇编初始部分
在main函数前有一部分字段展示了节名称:
.file 声明出源文件
.text 表示代码节
.section .rodata 表示只读数据段
.align 声明对指令或者数据的存放地址进行对齐的方式
.string 声明一个字符串
.globl 声明全局变量
.type 声明一个符号的类型
3.3.2 数据部分
hello.c中唯一的数组是main函数中的第二个参数(即char**argv),数组的每个元素都是一个指向字符类型的指针。由知数组起始地址存放在栈中-32(%rbp)的位置,被两次调用作为参数传到printf中。
如图,分别将rax设置为两个字符串的起始地址:
(2)参数argc
参数argc是main函数的第一个参数,被存放在寄存器%edi中,由语句
可见寄存器%edi地址被压入栈中,而语句
可知该地址上的数值与立即数5判断大小,从而得知argc被存放在寄存器并被压入栈中。
(3)局部变量
程序中的局部变量只有i,我们根据
可知局部变量i是被存放在栈上-4(%rbp)的位置。
3.3.3全局函数
hello.c中只声明了一个全局函数int main(int arge,.char*argv[]),通过汇编代码
我们可以得知。
3.3.4赋值操作
hel1o.c中的赋值操作贝有for循环开头的i-0,该赋值操作体现在汇编代码上,则是用mov指令实现,如图:
。由于int型变量i是一个32位变量,使用movl传递双字实现。
3.3.5算术操作
hello.c中的算术操作为for循环的每次循环结束后i++,该操作体现在汇编代码则使用指令add实现,问样,由丁变量i为32位,使用指令addl。指令如下:
3.3.6关系操作
hello.c中存在两个关系操作,分别为:
- 条件判断语句if(argc!=5):汇编代码将这条代码翻译为:
使用了cmp指令比较立即数5和参数argc大小,并且设置了条件码。根据条件码,如果不相等则执行该指令后面的语句,否则跳转到.L2。
- 在for循环每次循环结束要判断一次i<=9,判断循环条件被翻译为:
同(1),设置条件码,并通过条件码判断跳转到什么位置。
3.3.7控制转移指令
设置过条件码后,通过条件码来进行控制转移,在本程序中存在两个控制转移:
(1)
判断argc是否为5,如果不为5,则执行if语句,否则执行其他语句,在汇编代码中则表现为如果条件码为1,则跳到.L2,否则执行cmpl指令后的指令。
(2)
在for循环每次结束判断一次i<10,翻译为汇编语言后,通过条件码判断每次循环是否跳转到.L4。而在for循环初始要对i设置为0,如下:
然后直接无条件跳转到.L3循环体。
3.3.8函数操作
(1)main函数
参数传递:该函数的参数为int argc,,char*argv[]。具体参数传递地址和值都在前面阐述过。
函数调用:通过使用call内部指令调用语句进行函数调用,并且将要调用的函数地址数据写入栈中,然后自动跳转到这个调用函数内部。main函数里调用了printf、exit、sleep函数。
局部变量:使用了局部变量i用于for循环。具体局部变量的地址和值都在前面阐述过。
(2)printf函数
参数传递:printf函数调用参数argv[1],argv[2]。
函数调用:该函数调用了两次。第一次将寄存器%rdi设置为待传递字符串"用法:Hello学号姓名 秒数!\n"的起始地址;第二次将其设置为“Hello %s %s\n”的起始地址。具体已在前面讲过。使用寄存器%rsi完成对argv[1]的传递,用%rdx完成对argv[2]的传递。
(3)exit函数
参数传递与函数调用:
将rdi设置为1,再使用call指令调用函数。
(4)atoi、sleep函数
参数传递与函数调用:
可见,atoi函数将参数argv[3]放入寄存器%rdi中用作参数传递,简单使用call指令调用。
然后,将转换完成的秒数从%eax传递到%edi中,edi存放sleep的参数,再使用call调用。
(5)getchar函数
无参数传递,直接使用call调用即可。
3.3.9类型转换
atoi函数将零字符中转换为sleep函数需要的整型参数.
3.4 本章小结
这一章介绍了C编译器如何把hello.i文件转换成hello.s文件的过程,简要说明了编译的含义和功能,演示了编译的指令,并通过分析生成的hello.s文件中的汇编代码,探讨了数据处理,函数调用,赋值、算术、关系等运算以及控制跳转和类型转换等方面,比较了源代码和汇编代码分别是怎样实现这些操作的。
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编的概念
汇编是指汇编器(as)将包含汇编语言的.s文件翻译为机器语言指令,并把这些指令打包成为一个可重定位目标文件的格式,生成目标文件.o文件。.o文件是一个二进制文件,包含main函数的指令编码。
4.1.2汇编的作用
汇编就是将高级语言转化为机器可直接识别执行的代码文件的过程,汇编器将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式。 .o 文件是一个二进制文件,它包含程序的指令编码。
4.2 在Ubuntu下汇编的命令
gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
4.3 可重定位目标elf格式
在shell中输入readelf -a hello.o > hello.elf 指令获得 hello.o 文件的 ELF 格式:
4.3.1生成ELF格式的可重定位目标文件
典型的ELF格式的可重定位目标文件的结构如下:
4.3.2查看ELF格式文件的内容
(1)ELF头
ELF头(ELF header)以一个l6字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。ELF头展示如下:
(2)节头(section header)
记录各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。
(3)重定位节
.rel.text节是一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改,而调用本地函数的指令不需修改。可执行目标文件中不包含重定位信息。如图,需要重定位的内容如下:
(4)符号表
.symtab节中包含ELF符号表,这张符号表包含一个条目的数组,存放一个程序定义和引用的全局变量和函数的信息。该符号表不包含局部变量的信息。符号表如下:
4.4 Hello.o的结果解析
4.4.1命令
在shell中输入 objdump -d -r hello.o > hello.asm 指令输出hello.o的反汇编文件,并与第3章的hello.s文件进行对照分析。
4.4.2与hel1o.s的对照分析
(1)增加机器语言
每一条指令增加了一个十六进制的表示,即该指令的机器语言。例如,在hello.s中的一个cmpl指令表示为
而在反汇编文件中表示为
(2)操作数进制
反汇编文件中的所有操作数都改为十六进制。如(1)中的例子,立即数由hello.s中的$4变为了$0x4,地址表示也由-20(%rbp)变为-0x14(%rbp)。可见只是进制表示改变,数值未发生改变。
(3)分支转移
反汇编的跳转指令中,所有跳转的位置被表示为主函数+段内偏移量这样确定的地址,而不再是段名称(例如.L3)。例如下面的jmp指令,反汇编文件中为
而hello.s文件中为
(4)函数调用
反汇编文件中对函数的调用与重定位条目相对应。观察下面两个call指令调用函数,在hello.s中为
而在反汇编文件中调用函数为
在可重定位文件中call后面不再是函数名称,而是一条重定位条目指引的信息。
4.5 本章小结
这一章介绍了汇编语言的含义和功能。以 Ubuntu 系统下的 `hello.s` 文件为例,详细说明了如何将其汇编成 `hello.o` 文件,并生成 ELF 格式的可执行文件 `hello.elf`。将可重定位目标文件转换为 ELF 格式后,观察文件内容,对文件中的每个节进行简单解析。通过分析 `hello.o` 的反汇编代码(保存在 `hello.asm` 中)和 `hello.s` 的区别和相似之处,帮助读者清楚地理解了从汇编语言到机器语言的转换过程,以及机器在链接过程中所做的准备工作。
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念
链接(linkng)是将各种代码和数据片段收集并组合为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行与编译时(compile time),也就是在源代码被翻译为机器代码时;也可以执行与加载时(load time),也就是程序被加载器加载到内存并执行时:甚至执行于运行时。
5.1.2链接的作用
在现代系统中,链接是由叫做链接器(1iker)的程序自动执行的,它们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用。
5.2 在Ubuntu下链接的命令
在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.3 可执行目标文件hello的格式
使用readelf解析hello的ELF格式,得到hello的节信息和段信息:
(1)ELF头(ELF Header)
hello1.elf中的ELF头与hello.elf中的ELF头包含的信息种类基本相同,以描述了生成该文件的系统的字的大小和字节顺序的16字节序列Magic开始,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。与hello.elf相比较,hello1.elf中的基本信息未发生改变(如Magic,类别等),而类型发生改变,程序头大小和节头数量增加,并且获得了入口地址。
(2)节头
描述了各个节的大小、偏移量和其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
(3)程序头
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
(4)动态部分
(5)符号表
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
5.4 hello的虚拟地址空间
观察程序头的LOAD可加载的程序段的地址为0x400000。如图:
使用edb打开hello从Data Dump窗口观察hello加载到虚拟地址的情况,查
看各段信息。如图:
程序从地址0x400000开始到0x401000被载入,虚拟地址从0x4000000x400f0结束,根据5.3中的节头部表,可以通过edb找到各段的信息。
如.interp节,在hello.elf文件中能看到开始的虚拟地址:
在edb中找到对应的信息:
同样的,我们可以找到如.text节的信息:
5.5 链接的重定位过程分析
5.5.1分析helo与helo.o区别
在Shell中使用命令objdump -d -r hello > hello1.asm生成反汇编文件hello1.asm
与第四章中生成的hello.asm文件进行比较,其不同之处如下:
(1)链接后函数数量增加
链接后的反汇编文件hello1.asm中,多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为动态链接器将共享库中hello.c用到的函数加入可执行文件中。
(2)函数调用指令call的参数发生变化
在链接过程中,链接器解析了重定位条目,call之后的字节代码被链接器直接修改为目标地址与下一条指令的地址之差,指向相应的代码段,从而得到完整的反汇编代码。
(3)跳转指令参数发生变化
在链接过程中,链接器解析了重定位条目,并计算相对距离,修改了对应位置的字节代码为PLT 中相应函数与下条指令的相对地址,从而得到完整的反汇编代码。
5.5.2重定位过程
重定位由两步组成:
(1)重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的聚合节。然后链接器将运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。至此程序中每条指令和全局变量都有唯一的运行内存地址。
(2)重定位节中的符号引用。这一步中链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目的数据结构。
大致代码如下:
- #include <stdint.h>
- #define R_X86_64_PC32 2
- #define R_X86_64_32 10
- // 假设 ADDR 是一个宏或函数,用于获取符号或节的地址
- #define ADDR(x) ((uintptr_t)(x))
- // 重定位条目结构
- typedef struct {
- uint32_t type; // 重定位类型
- uintptr_t offset; // 重定位偏移
- uintptr_t symbol; // 符号地址
- int64_t addend; // 加数
- } RelocationEntry;
- // 节结构
- typedef struct {
- uintptr_t baseAddress; // 节的基地址
- RelocationEntry *relocations; // 重定位条目数组
- size_t relocationCount; // 重定位条目数量
- } Section;
- // 重定位函数
- void relocate(Section *sections, size_t sectionCount) {
- for (size_t i = 0; i < sectionCount; ++i) {
- Section *s = §ions[i];
- for (size_t j = 0; j < s->relocationCount; ++j) {
- RelocationEntry *r = &s->relocations[j];
- uintptr_t *refptr = (uintptr_t *)(s->baseAddress + r->offset); /* ptr to reference to be relocated */
- /* Relocate a PC-relative reference */
- if (r->type == R_X86_64_PC32) {
- uintptr_t refaddr = ADDR(s->baseAddress) + r->offset; /* ref's run-time address */
- *refptr = (unsigned)(ADDR(r->symbol) + r->addend - refaddr);
- }
- /* Relocate an absolute reference */
- if (r->type == R_X86_64_32) {
- *refptr = (unsigned)(ADDR(r->symbol) + r->addend);
- }
- }
- }
- }
- // 示例用法
- int main() {
- // 示例重定位条目和节
- RelocationEntry relocations[] = {
- { R_X86_64_PC32, 0x1000, (uintptr_t)0x2000, 0 },
- { R_X86_64_32, 0x2000, (uintptr_t)0x3000, 4 }
- };
- Section sections[] = {
- { (uintptr_t)0x400000, relocations, 2 }
- };
- // 执行重定位
- relocate(sections, sizeof(sections) / sizeof(sections[0]));
- return 0;
- }
5.6 hello的执行流程
5.6.1过程
通过edb的调试,一步一步地记录下call命令进入的函数。
使用edb执行hello,持续单步执行,观察遇到的jmp指令和call指令及其操作数,发现子程序则step into进入,该过程中发现了如下图中的子程序
(I)开始执行:_start、_libe_start_main
(2)执行main:_main、printf、_exit、_sleep、getchar
(3)退出:exit
5.6.2子程序名或地址
5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。延迟绑定是通过GOT和PLT实现的,根据hello.elf文件可知,GOT起始表位置为:0x404000:
GOT表位置在调用dl_init之前0x404008后的16个字节均为0:
调用了dl_init之后字节改变了:
对于变量而言,利用代码段和数据段的相对位置不变的原则去计算正确地址。
对于库函数而言,需要plt、got合作。plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。接下来执行程序的过程中,就可以使用过程链接表plt和全局偏移量表got进行动态链接。
5.8 本章小结
本章首先阐述了链接的基本概念和作用,展示了使用命令链接生成hello可执行文件,观察了hello文件ELF格式下的内容,利用edb观察了hello文件的虚拟地址空间使用情况,最后以hello程序为例对重定位过程、执行过程和动态链接进行分析。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念
进程的经典定义就是一个执行中程序的实例。进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
6.1.2进程的作用
进程为程序提供了一种假象,程序好像是独占的使用处理器和内存,处理器好像是无间断地一条接一条地执行我们程序中的指令。进程作为一个执行中程序的实例,系统中每个程序都运行在某个进程的上下文中。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell-bash的作用
Shell是一个交互型应用级程序,也被称为命令解析器,它为用户提供一个操作界面,接受用户输入的命令,并调度相应的应用程序。
6.2.2 Shell-bash的处理流程
首先从终端读入输入的命令,对输入的命令进行解析,如果该命令为内置命令,则立即执行命令,否则调用fork创建一个新的子进程,在该子进程的上下文中执行指定的程序。判断该程序为前台程序还是后台程序,如果为前台程序则等待程序执行结束,若为后台程序则将其放回后台并返回。在过程中shell可以接受从键盘输入的信号并对其进行处理。
6.3 Hello的fork进程创建过程
首先用户再shel1界面输入指令:./hel1o 2022112887 181***3 卫**
Shell判断该指令不是内置命令,于是父进程调用fork函数创建一个新的子进程,该子进程得到与父进程用户级虚拟地址空间相同的一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程与父进程最大的区别就是具有不同的PID。在父进程中,fork返回子进程的PID,而在子进程中fork返回0,返回值提供一个明确的方法来分辨程序是父进程还是在子进程中执行。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个程序。函数声明如下:
int execve(const char *filename, const char *argv[], const char *envp[]);
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并不返回。main函数运行时,用户栈的结构如图所示:
6.5 Hello的进程执行
hello程序在运行时,进程提供给应用程序的抽象有:(1)一个独立的逻辑控制流,它提供一个假象,好像我们的进程独占地使用处理器;(2)一个私有的地址空问,它提供一个假象,好像我们的程序独占地使用CPU内存。
操作系统提供的抽象有:
(1)逻辑控制流。如果想用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称为逻辑流。一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地运行。
(2)上下文切换。操作系统内核使用一种称为上下文切换的叫高层形式的异常控制流来实现多任务。内核为每一个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需状态。
(3)时间片。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫做时间分片。
(4)用户模式和内核模式。处理器通常使用某个控制寄存器中的一个模式位来提供这种功能。当设置了模式位时,进程就运行在内核模式里。一个运行在内核模式的进程可以执行指令集中的所有指令且可以访问系统中的任何内存位置。没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,也不能直接引用地址空间中内核区内的代码和数据。
(5)上下文信息。上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。hello程序执行过程中,在进程调用execve函数后,进程就为hello程序分配新的虚拟地址空间,开始时程序运行在用户模式中,调用printf函数输出“Hello 2022112887 181***3 卫**”,之后调用sleep函数,进程进入内核模式,运行信号处理程序,再返回用户模式,运行过程中,cpu不断切换上下文,使运行过程被切分成时间片,与其他进程交替占用cpu,实现进程的调度。
6.6 hello的异常与信号处理
6.6.1异常的分类
6.6.2异常的处理方式
6.6.3运行结果及相关命令
(1)正常运行状态
在程序正常运行时,打印8次提示信息,以输入回车为标志结束程序,并回收进程。
(2)运行时按下Ctrl + C
按下Ctrl + C,Shell进程收到SIGINT信号,Shell结束并回收hello进程。
(3)运行时按下Ctrl + Z
按下Ctrl + Z,Shell进程收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程。
(4)对hello进程的挂起可由ps和jobs命令查看,可以发现hello进程确实被挂起而非被回收,且其job代号为1。
(5)在Shell中输入pstree命令,可以将所有进程以树状图显示:
(6)输入kill命令,则可以杀死指定(进程组的)进程:
(7) 输入fg 1则命令将hello进程再次调到前台执行,可以发现Shell首先打印hello的命令行命令,hello再从挂起处继续运行,打印剩下的语句。程序仍然可以正常结束,并完成进程回收。
(8)乱按
在程序执行过程中乱按所造成的输入均缓存到stdin,当getchar的时候读出一个’\n’结尾的字串,整个结束后,stdin中的其他字串会当做Shell的命令行输入。
6.7本章小结
本章的主要内容是探讨计算机系统中的进程和shell,首先通过一个简单的hello程序,简要介绍了进程的概念和作用、shell的作用和处理流程,还详细分析了hello程序的进程创建、启动和执行过程,最后,本章对hello程序可能出现的异常情况,以及运行结果中的各种输入进行了解释和说明。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1逻辑地址
在有地址变换功能的计算机中,访问指令给出的地址(操作数)叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。逻辑地址是由一个段标识符加上一个指定段内相对地址的偏移量,由程序hello产生的与段相关的偏移地址部分
7.1.2线性地址
线性地址是逻辑地址到物理地址变换之间的一步,程序hello的代码会产生逻辑地址,在分段部件中逻辑地址是段中的偏移地址,加上基地址就是线性地址。
7.1.3虚拟地址
程序访问存储器所使用的逻辑地址称为虚拟地址。虚拟地址经过地址翻译得到物理地址。与实际物理内存容量无关,是hello中的虚拟地址
7.1.4物理地址
在存储器里以字节为单位存储信息,每一个字节单元给一个唯一的存储器地址,这个地址称为物理地址,是hello的实际地址或绝对地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理是指把一个程序分成若干个段进行存储,每个段都是一个逻辑实体。段式管理是通过段表进行的,包括段号(段名)、段起点、装入位、段的长度等。程序通过分段划分为多个块,如代码段、数据段、共享段等。
一个逻辑地址是两部分组成的,包括段标识符和段内偏移量。段选择符是一个16位的值,它由一个索引和一个表(通常是全局描述符表或局部描述符表)标识。这个选择符用来指定一个段的起始地址和访问权限。其中前13位是一个索引号,后3位为一些硬件细节。索引号即是“段描述符”的索引,段描述符具体地址描述了一个段,很多个段描述符就组成了段描述符表。通过段标识符的前13位直接在段描述符表中找到一个具体的段描述符。
整个系统只有一个全局描述图表,它包含:
- 操作系统使用的代码段、数据段、堆栈段的描述符
(2)各任务、程序的LDT(局部描述符表)段。
每个任务程序有一个独立的LDT,包含:
- 对应任务/程序私有的代码段、数据段、堆栈段的描述符
(2)对应任务/程序使用的门描述符:任务门、调用门等。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。VM系统将虚拟内存分割,称为虚拟页,类似地,物理内存也被分割成物理页。利用页表来管理虚拟页,页表就是一个页表条目(PTE)的数组,每个PTE由一个有效位和一个位地址字段组成,有效位表明了该虚拟页当前是否被缓存在DRAM中,如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置,如果发生缺页,则从磁盘读取。
MMU利用页表来实现从虚拟地址到物理地址的翻译。
下面为页式管理的图示:
7.4 TLB与四级页表支持下的VA到PA的变换
Core i7采用四级页表的层次结构。CPU产生虚拟地址VA,虚拟地址VA传送给MU,MMU使用VPN高位作为TLBT和TLBI,向TLB中寻找匹配。如果命中,则得到物理地址PA。如果TLB中没有命中,MMU查询页表,CR3确定第一级页表的起始地址,VPN1确定在第一级页表中的偏移量,查询出PTE,以此类推,最终在第四级页表中找到PPN,与VPO组合成物理地址PA,添加到PLT。工作原理如下:
多级页表的工作原理展示如下:
7.5 三级Cache支持下的物理内存访问
如图为高速缓存存储器组织结构:
高速缓存的结构将m个地址位划分成了t个标记位,s个组索引位和b个块偏移位。
如果选中的组存在一行有效位为1,且标记位与地址中的标记位相匹配,我们就得到了一个缓存命中,否则就称为缓存不命中。如果缓存不命中,那么它需要从存储器层次结构的下一层中取出被请求的块,然后将新的块存储在组索引位指示组中的一个高速缓存行中,具体替换哪一行取决于替换策略,例如LRU策略会替换最后一次访问时间最久远的那一行。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_ struct、.区域结构和页表的原样副本。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任何一个。后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。
加载并运行hello需要以下几个步骤:
(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2)映射私有区域。为新程序的代码、数据、.bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,.bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
(3)映射共享区域。hello程序与共享对象1ibc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器。execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。如图所示:
7.8 缺页故障与缺页中断处理
如果程序执行过程中发生了缺页故障,则内核调用缺页处理程序。处理程序执行如下步骤:
- 检查虚拟地址是否合法,如果不合法则触发一个段错误,终止这个进程。
以某个虚拟地址A为例:缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。这个情况在图中标识为“1”。
因为一个进程可以创建任意数量的新虚拟内存区域,所以顺序搜索区域结构的链表花销可能会很大。因此在实际中,Linux使用某些我们没有显示出来的字段,Linux在链表中构建了一棵树,并在这棵树上进行查找。
- 检查进程是否有读、写或执行该区域页面的权限,内存访问是否合法。如果不具有则触发保护异常,程序终止。如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。这种情况在图中标识为“2”。
- 两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表。然后将控制转移给进程,再次执行触发缺页故障的指令。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了。
7.9动态存储分配管理
动态内存分配器维护着一个称为堆的进程的虚拟内存区域。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放可以由应用程序显式执行或内存分配器自身隐式执行。
具体而言,分配器分为两种基本风格:显式分配器、隐式分配器。
前者为要求应用显式地释放任何已分配的块。后者为要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
下面介绍动态存储分配管理中较为重要的概念:
1. 隐式链表
堆中的空闲块通过头部中的大小字段隐含地连接,分配器通过遍历堆中所有的块,从而间接遍历整个空闲块的集合。
对于隐式链表,其结构如下:
隐式链表通过在内存块头部存储块大小和状态信息来管理内存。它通过顺序遍历内存块来进行分配和释放操作,避免了使用显式指针来链接内存块。虽然隐式链表实现简单,但在处理大量内存块时可能效率较低,因为每次操作都需要遍历整个链表。
2. 显式链表
在每个空闲块中,都包含一个前驱(pred)与后继(succ)指针,从而减少了搜索与适配的时间。
显式链表的结构如下:
显式链表通过在每个空闲内存块中增加指针来显式链接这些块,从而提高内存管理效率。它避免了隐式链表中需要遍历整个内存区域的问题,但也增加了实现的复杂度和内存开销。通过显式链表,可以更高效地进行内存分配和释放操作,减少内存碎片。
3. 带边界标记的合并
采取使用边界标记的堆块的格式,在堆块的末尾为其添加一个脚部,其为头部的副本。添加脚部之后,分配器就可以通过检查前面一个块的脚部,判断前面一个块的起始位置和状态。从而实现快速合并,减小性能消耗。
4. 分离存储
维护多个空闲链表,其中,每个链表的块具有相同的大小。将所有可能的块大小分成一些等价类,从而进行分离存储。
7.10本章小结
详细阐述了 hello 程序的存储器地址空间及其管理过程。首先介绍了逻辑地址、线性地址、虚拟地址和物理地址的概念及其转换关系。然后讲解了 Intel 逻辑地址到线性地址的变换过程,通过段式管理和全局描述符表(GDT)以及局部描述符表(LDT)进行管理。接着介绍了 hello 程序的线性地址到物理地址的变换,采用页式管理和多级页表结构,通过页表实现从虚拟地址到物理地址的翻译。随后解释了三级缓存支持下的物理内存访问机制,缓存命中的概念以及缓存不命中的处理。还描述了 hello 进程在 fork 和 execve 时的内存映射过程,详述了写时复制机制和程序替换的过程。最后讲解了缺页故障的处理机制以及动态内存分配管理,包括隐式链表、显式链表、带边界标记的合并和分离存储的技术,展示了内存分配器如何高效地进行内存管理,减少内存碎片和性能消耗。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行:打开文件、改变当前的文件位置、读写文件、关闭文件。
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O 接口:
1. 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2. Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件< unistd.h>定义了常量STDIN_FILENO、STDOUT_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 I/O 函数:
1. int open(char* filename, int flags, mode_t mode)
进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
2. int close(fd)
fd是需要关闭的文件的描述符,close返回操作结果。
3. ssize_t read(int fd, void *buf, size_t n)
read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
4. ssize_t wirte(int fd, const void *buf, size_t n)
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
8.3.1. printf函数分析
先看一下提供的 printf 实现代码:
```c
int printf(const char *fmt, ...)
{
int i;
char buf[256];
va_list arg = (va_list)((char *)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
```
这个 printf 函数实现了基本的格式化输出,其工作流程如下:
1. 初始化变量:定义了一个字符数组 buf 来存储格式化后的字符串,定义了一个 va_list 变量 arg 来获取变长参数。
2. 变长参数处理:通过 (va_list)((char *)(&fmt) + 4) 获取变长参数列表。
3. 格式化字符串:使用 vsprintf 函数将格式化后的字符串写入 buf。
4. 输出字符串:使用 write 函数将 buf 中的内容输出到标准输出。
5. 返回值:返回写入的字符数。
8.3.2. 系统响应
write 函数是一个系统调用,用于向文件描述符写入数据。对于标准输出,文件描述符是 1。
伪代码示例:
```c
ssize_t write(int fd, const void *buf, size_t count);
```
系统调用过程通常涉及陷阱指令,比如 int 0x80 或 syscall,在 x86 架构中,这些指令触发 CPU 进入内核模式并调用内核中的相应处理函数。
8.3.3. 显示驱动程序子例程
字符显示驱动子程序负责将 ASCII 字符转换为点阵,并在视频内存(vram)中设置相应的像素。流程如下:
1. 字符编码转换:将 ASCII 字符转换为相应的点阵(字符的位图表示)。
2. 位图存储:将位图存储到视频内存中。每个像素有相应的 RGB 值。
3. 显示更新:驱动程序将视频内存中的内容刷新到显示屏上。
示例伪代码:
```c
void putchar(char c, int x, int y) {
// 获取字符的点阵数据
const uint8_t *bitmap = get_font_bitmap(c);
// 将点阵数据写入到视频内存
for (int row = 0; row < FONT_HEIGHT; ++row) {
for (int col = 0; col < FONT_WIDTH; ++col) {
if (bitmap[row] & (1 << col)) {
set_pixel(x + col, y + row, RGB_COLOR);
}
}
}
}
```
8.3.4. 显示芯片的工作原理
显示芯片(如 GPU)负责将视频内存(vram)中的内容传输到显示屏上。主要步骤如下:
1. 刷新率:显示芯片以固定的刷新率读取 vram。
2. 逐行扫描:逐行读取 vram 的内容,并通过信号线传输每个像素的 RGB 值。
3. 显示屏:液晶显示器接收到这些信号后,将每个像素的 RGB 值显示出来。
显示芯片读取 vram 并刷新显示的流程:
```c
void refresh_display() {
for (int y = 0; y < SCREEN_HEIGHT; ++y) {
for (int x = 0; x < SCREEN_WIDTH; ++x) {
RGB pixel = vram[y * SCREEN_WIDTH + x];
send_to_display(pixel);
}
}
}
```
总结:通过以上分析,从 printf 的实现到系统调用 write,再到显示驱动子程序以及显示芯片工作原理,完整地展示了从格式化输出到字符显示的整个过程:
1. printf 函数:格式化字符串并调用 write。
2. write 系统调用:通过陷阱指令将数据写入标准输出。
3. 字符显示驱动:将字符转换为点阵并写入 vram。
4. 显示芯片:逐行扫描 vram 并将像素传输到显示屏上。
这样完整地展示了从调用 printf 到最终在显示器上看到字符的整个流程。
8.4 getchar的实现分析
(以下格式自行编排,编辑时删除)
getchar源代码如下:
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar调用系统函数read,发送一个中断信号,内核抢占这个进程,用户输入字符串,键入回车后(字符串和回车都保存在缓冲区内),再次发送信号,内核重新调度这个进程,getchar从缓冲区读入字符。
8.5本章小结
本章主要介绍了Linux的IO设备管理方法和及其接口和函数,对printf函数和getchar函数的底层实现有了基本了解。
结论
首先由程序员将hello代码从键盘输入,依次要经过以下步骤:
1、预处理(cpp)。将hello.c进行预处理,将文件调用的所有外部库文件合并展开,生成一个经过修改的hello.i文件。
2、编译(ccl)。将hello.i文件翻译成为一个包含汇编语言的文件hello.s。
3、汇编(as)。将hello.s翻译成为一个可重定位目标文件hello.o。
4、链接(ld)。将hello.o文件和可重定位目标文件和动态链接库链接起来,生成一个可执行目标文件hello。
5、运行。在shel1中输入./hello 2022112887 181***3 卫**。
6、创建进程。终端判断输入的指令不是shell内置指令,于是调用fork函数创建一个新的子进程。
7、加载程序。shell调用execve函数,启动加载器,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入main函数。
8、执行指令:CPU为进程分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流。
9、访问内存:MU将程序中使用的虚拟内存地址通过页表映射成物理地址。
10、信号管理:当程序在运行的时候我们输入Ctrl+c,内核会发送SIGINT信号给进程并终止前台作业。当输入Ctrl+z时,内核会发送SIGTSTP信号给进程,并将前台作业停止挂起。
11、终止:当子进程执行完成时,内核安排父进程回收子进程,将子进程的退出状态传递给父进程。内核删除为这个进程创建的所有数据结构。
感悟:
通过本次实验,能深切使人感受到计算机系统的精细和强大,每一个简单的任务都需要计算机的各种复杂的操作来完成,体现出严谨的逻辑和现代工艺的精巧。通过hello的一生,从运行到汇编、从存储到进程管理,每一步精细的调试,赋予了这一csapp梦开始的地方某种确幸的意义。
附件
文件名 | 功能 |
hello.c | 源程序 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | 用readelf读取hello.o得到的ELF格式信息 |
hello.asm | 反汇编hello.o得到的反汇编文件 |
hello1.asm | 反汇编hello可执行文件得到的反汇编文件 |
hello | 可执行文件 |
参考文献
[1] Randal E.Bryant, David O'Hallaron. 深入理解计算机系统[M]. 机械工业出版
社.2018.4
[2] printf 函数实现的深入剖析
https://www.cnblogs.com/pianist/p/3315801.html
[3] 内存管理:物理地址、虚拟地址、逻辑地址