本文借助hello.c跌宕起伏的一生——P2P(From Program To Process)、020(From Zero-0 to Zero-0)从源代码到可执行程序以及和计算机系统硬件的配合,从计算机系统的角度阐述从源代码到可执行程序的转变,以及在计算机系统当中作为众多进程中的一员的运行过程。源程序首先经过预处理、编译、汇编以及链接等步骤成为二进制可执行文件;然后在运行的过程中,需要计算机的硬件(处理器、I/O设备等)和操作系统的进程调度和管理的密切配合,才能被顺利执行。本文以hello.c为例子,深入研究了以上这些过程,以提供对计算机系统运行程序的全面理解。
关键词:计算机系统;C语言;程序到进程;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
P2P——从程序到进程。指的是从hello.c变成运行时的进程。要使hello.c这个C语言程序运行起来,需要先将其转化为二进制的可执行文件,这个转化过程包括四个阶段:预处理、编译、汇编和链接。完成这些阶段后,就会得到一个可执行文件,然后可以在shell中执行它。执行时,shell会为它分配进程空间。
020——从零开始到零结束。指的是最初内存中没有hello文件的相关内容,shell使用execve函数启动hello程序,将虚拟内存映射到物理内存,并从程序入口开始加载和运行,执行main函数中的目标代码。程序结束后,shell的父进程回收hello进程,内核删除hello文件相关的数据结构。
1.2 环境与工具
硬件:
处理器:12th Gen Intel® CoreTM i7-12700h
RAM:32GB
系统类型:64位操作系统,基于x64的处理器
软件环境:Windows 11 64位,ubuntu 18.04LTS 64位
开发与调试工具:Visual Studio,gcc,edb等
1.3 中间结果
hello.i预处理之后得到的文本文件
hello.s编译后的汇编语言文件
hello.o 汇编后得到的可重定位目标文件
hello.asm 反汇编hello.o得到的反汇编文件
hello1.asm 反汇编hello可执行文件得到的反汇编文件
1.4 本章小结
本章主要介绍了Hello程序的从编写到执行的全过程。首先解释了P2P(从程序到进程)和020(从零开始到零结束)的概念。P2P部分讲述了hello.c程序是如何经过预处理、编译、汇编和链接这四个阶段,最终生成可执行文件并在shell中执行的过程;而020部分则描述了在内存中最初没有hello文件的情况下,shell如何使用execve函数启动hello程序,将其加载到物理内存并运行,最终在程序结束后由shell父进程回收hello进程,并删除相关数据结构。
然后,介绍了实验所需的硬件和软件环境。
最后,还列出了编译过程中生成的中间文件。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
2.1.1概念
·预处理指的是程序在编译之前进行的处理,是计算机在处理一个程序时所进行的第一步处理,可以进行代码文本的替换工作,但是不做语法检查。
·预处理是为编译做的准备工作,能够对源程序文件中出现的以字符“#”开头的命令进行处理,包括宏定义#define、文件包含#include条件编译等,最后将修改之后的文本进行保存,生成.i文件,预处理结束。而在hello.c中会进行的处理如图1所示:
图1
2.1.2作用
·在预处理的过程中,计算机利用预处理器(cpp)进行处理。而预处理主要有3个方面的内容,分别是根据字符“#”后所跟的具体语句进行不同处理。它们分别为宏定义、文件包含、条件编译。
·宏定义在预处理的过程中会进行宏替换。在具体语句中表现为#define。宏定义具体而言又分为两种,在不带参数的宏定义中,要用实际值替换用#define定义的字符或字符串;而在带参数的宏定义中,不仅仅要进行实际值的替换,还要将参数进行代换。在宏替换中,仅仅只是做替换,不做计算和表达式求值。
·文件包含指的是对代码中出现的#include语句进行处理。#include指令能够告诉预处理器读取源程序中所引用的系统的源文件,并且将这一段代码直接插入到程序文件中,最终保存为.i文件中。
·条件编译指的是针对#ifdef、#ifndef等语句进行的处理。条件编译能够根据#if的不同条件决定需要进行编译的代码,#endif是结束这些语句的标志。使用条件编译可以使目标程序变小,在满足条件之后才会进行编译。
2.2在Ubuntu下预处理的命令
预处理指令:gcc -E hello.c -o hello.i
输出文件名为hello.i
图2
2.3 Hello的预处理结果解析
使用命令vim hello.i打开预处理结果文件,与原先的hello.c文件比较,发现main函数主体没变,但是前面的三条#include语句被替换为更加具体的代码,这里截取部分予以展示:
图3 main函数没有发生变化
图4
图5
比较hello.c和hello.i的大小,发现预处理之后的文件比原文件大得多得多,这是因为在预处理的过程中,预处理器扫描到第一个#include<stdio.h>后,预处理器会去在系统的头文件路径下查找stdio.h文件,然后将stdio.h文件直接复制到代码中,需要注意的是,复制过后的文件中本身可能也有#include语句,例如#include<features.h>,则预处理器会递归地处理他们。
对于后面两个#include命令,预处理器会重复以上操作。
注意,预处理器只进行简单的复制和替换,不会对头文件中的内容进行任何计算和处理。
2.4 本章小结
程序预处理的过程主要包括头文件包含、宏定义替换、条件编译和注释删除这几个部分。在Ubuntu下,使用 gcc -E 命令可以生成预处理后的文件,观察结果显示头文件的 #include 指令被替换为实际内容,且预处理器只进行简单的复制和替换操作,不对头文件中的内容进行计算和处理。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
3.1.1概念
计算机程序编译是将高级程序设计语言编写的源程序翻译成相同意义的汇编格式语言程序的过程。编译器通过词法分析、语法分析、语义分析等步骤,逐步将源代码转换为目标代码,通常包括以下几个阶段:
- 词法分析:扫描源代码,将其分解成一个个记号(token),每个记号代表源代码中的基本元素,如关键字、标识符、操作符和分隔符。
- 语法分析:检查记号序列是否符合语言的语法规则,生成语法树(或抽象语法树),表示程序的结构。
- 语义分析:在语法树的基础上,进行上下文相关的检查,确保程序的语义正确,如类型检查和作用域检查。
- 中间代码生成:将语法树转换为一种中间表示(IR),这种表示介于高级语言和机器语言之间,便于进行优化。
- 代码优化:对中间代码进行各种优化处理,提升程序的执行效率和减少资源消耗。
- 目标代码生成:将优化后的中间代码转换为目标机器的汇编代码或机器码。
- 汇编和链接:将汇编代码转换为机器代码,并将多个目标文件和库文件链接成一个可执行文件。
3.1.2作用
编译的作用在于将人类易于理解的高级语言转换为计算机可以执行的机器语言,还在于通过各阶段的分析和优化,发现和修正代码中的错误,提高程序的运行效率和稳定性。
3.2 在Ubuntu下编译的命令
编译指令:gcc -S hello.i -o hello.s
输出文件名为hello.s
图6
3.3 Hello的编译结果解析
在这个部分中,为了方便查看汇编文件,我使用了vscode远程连接到了虚拟机上。
3.3.1汇编文件的初始部分
图7
.file——源文件的文件名
.text——代码节
.section, .rodata——只读数据段
.align——声明对指令或者数据的存放地址进行对齐的方式
.string——声明一个字符串
.globl——声明全局变量
.type——声明一个符号类型
3.3.2汇编的数据部分
常量:
字符串.LC0和.LC1存放在只读数据段当中
图8
其中,.LC0对应的源程序当中的这个字符串,其中相应中文字符已经被转换成对应的编码格式显示。
图9
两个字符串的起始地址都在恰当的时刻存放到%rax当中,然后在必要时刻放到%rdi当中,便于调用printf()函数。
图10-11
变量:
hello.c有两个int类型的变量,为main函数传递进来的参数个数argc和main函数当中定义的计数变量i(i为局部变量)。
hello.c有唯一数组,为main函数传递进来的char *argv[]。
argc被存放在寄存器edi当中,随后被保存到栈当中,位置是-20(%rbp)
而argv被存放在寄存器rsi当中,随后被保存到栈中,位置是-32(%rbp),然后将立即数与-20(%rbp)中的值比较,也就是和argc比较,由此进行条件判断,分支跳转
图12
局部变量i,通过汇编代码可得知,i被存放到了栈上-4(%rbp)的位置
图13
函数:
hello.c只有一个函数main,且为全局函数,通过相应的代码可以知道,main被标记为globl,即全局可见的,而.type那一行则告诉汇编器main是一个函数。这么做是为了在编译的时候确保正确的符号类型。
图14
3.3.3赋值操作
hello.c当中只有for循环当中对i有一个赋初值的操作,对应汇编代码如下:
图15
直接将立即数0赋值给i在栈中的位置
3.3.4类型转换
hello.c当中有一个显式转换,使用了atoi函数,这个是一个标准库函数,在汇编代码中为调用call的形式,在链接部分会将这个相应的代码链接上去。
图16
3.3.5 sizeof
在hello.c当中,没有sizeof函数
3.3.6算数操作
hello.c当中,在for循环的每次循环末尾,将计数变量i++,对应的汇编代码如下:
图17
使用add指令,将i+1,然后将i+1放在i在栈中的位置,完成i++
3.3.7逻辑/位操作
在hello.c当中,没有逻辑/位操作
3.3.8关系操作
在hello.c当中,有两个关系操作:
一个是将argc与5比较,如果等于5,那么进入for循环,如果不等于5,那么打印用法提示,然后退出程序,在汇编代码当中,这里直接将立即数5和argc比较,如果等于,那么跳转到.L2,否则执行下面的语句。
图18
另一个是每次for循环之后将计数变量i和10比较,在汇编代码当中,这个<10的条件被等价改成了≤9,当≤9成立的时候,跳转到.L4,for循环继续,否则退出循环,执行后面的代码。
图19
3.3.9数组/指针/结构操作
hello.c当中对*argv[]进行了引用,集中在for循环当中。
图20
argv的起始地址被保存到栈中,位置是-32(%rbp),在按数组下标访问的时候被存放到rax当中,随后被加上不同的偏移量,并将访问到的数组元素地址存放到rcx(对应argv[3],偏移量为24)、rdx(对应argv[2],偏移量为16)和rsi(对应argv[1],偏移量为8)当中,调用atoi之前,将argv[4]移动到rdi当中。
3.3.10控制转移
hello.c的编译文件当中,所有的控制转移操作均通过cmp指令执行,修改对应的条件码来进行控制转移,有两个转移操作,在3.3.8关系操作中已经解释清楚,这里就不再赘述了。
3.3.11函数操作
hello.c当中除了main函数之外,还调用了这几个函数:printf、getchar、atoi、sleep、exit
但是在编译过程中,这个printf函数被替换为了puts函数,原因是puts函数只有一个参数,即对应的字符串地址,由于需要输出的字符串当中并没有别的变量,所以编译器将其改为了调用puts函数。
图21
其余的函数调用均未有调整,列举如下:
exit()函数:
传入参数:1,放在edi当中
图22
printf()函数:
传入参数:字符串.LC1的起始地址,放在rdi当中;argv[1],放在rsi当中;argv[2],放在rdx当中;argv[3],放在rcx当中。
图23
sleep和atoi函数:
将argv[4]传入atoi函数,并将返回值作为参数传入sleep函数
图24
getchar函数:
无参数,直接调用
图25
3.4 本章小结
本章介绍了C编译器如何把hello.i文件转换成hello.s文件的过程,通过分析生成的hello.s文件中的汇编代码,了解了数据,赋值、类型转换、sizeof、算术操作、逻辑/位操作、关系操作、数组/指针/结构操作、控制转移以及函数操作,比较了源代码和汇编代码分别是怎样实现这些操作的。
这里面存在一些编译器对源代码的变换和调整。
第一个是当agrc不是5的时候,源代码是printf函数,但是在编译过程中直接变成了puts函数。
第二个是for循环退出条件的检查,将<10改为≤9。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编是指汇编器(as)将包含汇编语言的.s文件翻译为机器语言指令,并把这些指令打包成为一个可重定位目标文件的格式,生成目标文件.o文件。.o文件是一个二进制文件,包含main函数的指令编码。
汇编就是将高级语言转化为机器可直接识别执行的代码文件的过程,汇编器将.s汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式。.o文件是一个二进制文件,它包含程序的指令编码。
4.2 在Ubuntu下汇编的命令
按照hello.c文件的注释,汇编指令需要做出一些改动。
图26
汇编指令:gcc -m64 -Og -no-pie -fno-PIC -fno-stack-protector -c hello.s -o hello.o
图27
4.3 可重定位目标elf格式
elf格式文件的获取:readelf -a hello.o > hello.elf
图28
ELF文件头通常由以下几个字段组成:
- 标识字段: 这是一个16字节的字段,用于标识文件类型以及文件的结构。包括文件的魔数、文件类别、字节顺序、ELF版本等信息。
- 文件类型: 指定了ELF文件的类型,例如可执行文件、共享对象文件、目标文件等。
- 机器类型: 指定了ELF文件所运行的目标机器的架构类型,比如x86、ARM、PowerPC等。
- 版本: 指定了ELF文件的版本号。
- 入口地址: 对于可执行文件,指定了程序执行的入口地址。
- 程序头表偏移量: 指定了程序头表在文件中的偏移量。程序头表包含了描述ELF文件中各个段的信息。
- 节头表偏移量: 指定了节头表在文件中的偏移量。节头表包含了描述ELF文件中各个节的信息。
- 标志: 包含了一些标志位,用于描述文件的特性。
- ELF头的大小: 指定了ELF文件头的大小。
- 程序头表中每个条目的大小: 指定了程序头表中每个条目的大小。
- 程序头表中条目的数量: 指定了程序头表中的条目数量。
- 节头表中每个条目的大小: 指定了节头表中每个条目的大小。
- 节头表中条目的数量: 指定了节头表中的条目数量。
- 字符串表的节头索引: 指定了节头表中字符串表节的索引。字符串表用于存储节名和程序头表中的段名。
程序中的ELF头内容如下:
图29
节头:
图30
重定位节:
图31
符号表:
图32
4.4 Hello.o的结果解析
objdump -d -r hello.o > hello.asm,获取反汇编的结果,输出到hello.asm当中
图33
与第三章的hello.s对照分析:
- hello.asm当中增加了机器语言,即每条指令之前的一串16进制数,表示该条指令对应的16进制机器语言。
图34
2. 将所有的立即数都改为了16进制
图35
3. 分支转移和函数调用均发生了变化:原始汇编代码采用的是段名称跳转,反汇编采用的是主函数+段内偏移量表示跳转位置;原始汇编代码采用的是直接使用函数名称,而反汇编采用的是主函数位置+偏移量。
图36
4.5 本章小结
这一章介绍了汇编语言的基本含义和功能,以在Ubuntu 64位系统下创建一个名为 hello.s 的汇编文件为例,说明了如何将其汇编成可重定位目标文件 hello.o,然后将其链接成 ELF 格式的可执行文件 hello.elf。
在将汇编文件转换为可执行文件的过程中,通过观察生成的目标文件的内容,了解其中每个节的作用和含义。通过分析 hello.o 文件的反汇编代码(被输出到了 hello.asm 中),以及原始汇编文件 hello.s 的区别和相同点,可以清晰地理解汇编语言到机器语言的转换过程,以及为链接而做的准备工作。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
5.1.1概念
链接是将多个目标文件(可重定位目标文件)合并成一个可执行文件或共享库的过程。在编译和构建软件项目时,通常会产生多个目标文件,它们包含了程序的不同部分的机器代码和数据。链接的主要目的是解决程序中各个模块之间的引用关系,将它们正确地组合在一起,形成最终可执行文件或共享库。
链接的作用包括:
- 符号解析: 在编译多个源文件时,可能会涉及到函数、变量等符号的引用。链接器负责解析这些符号的引用,将它们正确地连接到定义处,以确保程序能够正确地执行。
- 地址重定位: 可重定位目标文件中的代码和数据通常是相对于各自段(section)的起始地址来编写的,而链接器需要将这些相对地址转换为绝对地址,以便在最终的可执行文件中正确地定位代码和数据。
- 合并代码和数据: 链接器将多个目标文件中的代码段、数据段等合并成一个文件,并对其进行布局,以确保程序执行时能够正确地访问到各个部分的内容。
- 生成可执行文件或共享库: 链接器最终生成一个包含了所有链接目标的可执行文件或共享库,这个文件可以被操作系统加载并执行,或者被其他程序引用和链接。
- 优化和压缩: 链接器在链接过程中可能会对代码进行优化和压缩,以减少最终可执行文件的大小,提高执行效率。
5.1.2作用
在现代系统中,链接是由叫做链接器(linker)的程序自动执行的,它们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用。
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
图37
输出的文件名为hello
5.3 可执行目标文件hello的格式
先获取ELF文件,命令:readelf -a hello > hello1.elf
图38
- ELF头(左hello.elf右hello1.elf)
图39
hello1.elf的ELF头和hello.elf的头包含的信息种类基本相同,但是类型发生改变(可重定位文件→可执行文件),程序头大小(0→56)、起点(0→64)和节头数量(13→25)增加,并且获得了入口地址(0x0→0x400550)。
2. 节头(左hello.elf右hello1.elf)
图40
链接器链接时会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
3. 程序头(hello.elf没有程序头)
图41
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
4. 段节(hello.elf没有段节)
图42
5. Dynamic section(hello.elf没有Dynamic section)
图43
6. 重定位节(左hello.elf右hello1.elf)
图44
在链接的时候链接器重新改写了重定位类型,并重新计算了偏移量
7. Symbol table(左hello.elf右hello1.elf)
图45
图46
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明,链接的时候符号的数量明显上升,并且
5.4 hello的虚拟地址空间
打开edb,看栈当中的内容,当前栈中内容标记出了入口点,位置是0x400550,这与ELF头当中的入口点地址一致。
图47
图48
再看看堆当中的内容,堆顶存放着ELF的魔数:
图49
可以再更具ELF文件当中节的地址去找对应节,比如找找.init节,在0x4004c0:
图50
5.5 链接的重定位过程分析
objdump -d -r hello > hello1.asm
图51
和前面的hello.asm相比较
图52
- 最直接明显的一个点就是函数的数量增加了:新增了<_init>、<.plt>、<puts@plt>等函数的代码。这是因为动态链接器将hello.c当中用到的库函数加入到了hello当中。
- 同样明显的点在于,hello1.asm当中,在每条机器指令之前都含有其所在地址,这个是hello可执行文件被执行时,对应内容存放的真正的虚拟地址。
- 调用函数的call指令的代码也发生了变化:原来调用时参考的是相对于main的地址+一个偏移量,而链接完成后,可以直接找到puts@plt等表进行跳转
图53
图54
5.6 hello的执行流程
使用gdb调试执行hello,命令starti执行第一步,一步一步调试获得执行流程如下表:(假设输入参数符合要求)
函数名+地址 |
_start+0x00007ffff7dd4090 |
_dl_start+0x7fffffffe0d0 |
_dl_start_user+0x00007ffff7dd4098 |
_dl_init+0x7ffff7ffe170 |
_dl_start_user+0x00007ffff7dd40ca |
_start+0x0000000000400550 |
main+0x0000000000400586 |
__printf |
atoi |
sleep |
getchar |
5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立的部分,在程序运行时才将他们链接在一起(延迟绑定,lazy binding),再调用共享库函数,编译器没法预测这个函数的运行地址,因为定义它的共享模块在运行时可以加载到任意位置。
延迟绑定是通过GOT表和PLT表实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
.got节用于动态链接。它是一个包含了所有全局变量和函数的地址表。当程序运行时,动态链接器会更新这个表,以便程序可以正确地访问这些全局变量和函数。它的主要目的是支持位置无关代码(Position-Independent Code, PIC),使得代码可以在不同内存地址加载而不需要重新编译。
.got.plt节与.got类似,但专门用于延迟绑定(Lazy Binding)的函数调用。延迟绑定是一种优化技术,只有在函数第一次调用时才进行符号解析和重定位。在函数第一次调用后,.got.plt中的条目会更新为实际的函数地址,后续调用会直接跳转到该函数,提高了运行时性能。
图55
可以得知
.got的起始地址为0x600ff0
.got.plt的起始地址为0x601000
.plt的起始地址为0x4004e0
使用gdb调试,在init函数执行前后查看.got.plt的内容,GOT[1]从0变成重定位表,GOT[2]从0变成动态链接器运行地址。
图56
图57
5.8 本章小结
链接是将多个目标文件(可重定位目标文件)合并成一个可执行文件或共享库的过程。在编译和构建软件项目时,通常会产生多个目标文件,它们包含了程序的不同部分的机器代码和数据。链接的主要目的是解决程序中各个模块之间的引用关系,将它们正确地组合在一起,形成最终可执行文件或共享库。
链接的作用包括:
- 符号解析
- 地址重定位
- 合并代码和数据
- 生成可执行文件或共享库
- 优化和压缩
在现代系统中,链接由链接器自动执行,使得分离编译成为可能。开发者可以将大型应用程序分解为更小的模块,独立修改和编译这些模块。Ubuntu系统下的链接命令为 ld,可以生成包含所有必要链接的可执行文件或共享库。
通过利用 readelf 和 objdump 等工具,可以深入分析生成的可执行文件的结构,如ELF头、节头和程序头,理解重定位过程和虚拟地址空间的分布。调试工具edb、gdb能够帮助观察程序执行流程,并分析动态链接的过程。
动态链接的基本思想是将程序模块化,并在程序运行时进行链接。延迟绑定(Lazy Binding)通过GOT表和PLT表实现,在第一次调用共享库函数时解析其实际地址,并将结果缓存以供后续调用使用。这种机制显著提高了程序启动速度和执行效率。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1概念
进程(Process)是计算机中运行程序的实例,是操作系统进行资源分配和调度的基本单位。每个进程包含程序代码、数据、堆栈、处理器状态以及操作系统为其分配的资源(如内存、文件描述符、I/O设备等)。进程可以分为以下几个部分:
- 代码段(Text Segment):存储程序的可执行代码。
- 数据段(Data Segment):存储程序的全局变量和静态变量。
- 堆(Heap):用于动态分配内存。
- 栈(Stack):存储函数调用时的参数、本地变量和返回地址。
6.1.2作用
进程为程序提供了一种假象,程序好像是独占的使用处理器和内存,处理器好像是无间断地一条接着一条执行我们程序中的指令。进程作为一个执行中程序的实例,系统中每个程序都运行在某个进程的上下文中,
- 资源管理和分配:操作系统通过进程来管理和分配资源。每个进程有自己的独立地址空间,可以独立运行和访问资源,确保了程序的安全性和稳定性。
- 并发执行:进程使得多个程序可以并发执行,提高了系统的利用率和效率。操作系统通过进程调度策略(如时间片轮转、多级反馈队列等)来控制进程的执行顺序和时间。
- 隔离和保护:进程间的隔离保证了一个进程的错误不会影响其他进程的正常运行。操作系统通过虚拟内存技术和权限控制机制保护进程的内存空间和资源。
- 通信与同步:进程间通信(Inter-Process Communication, IPC)和同步机制使得进程可以相互协作完成复杂任务。常见的进程间通信方式包括管道(Pipe)、消息队列(Message Queue)、共享内存(Shared Memory)和信号量(Semaphore)等。
- 多任务处理:进程是实现多任务处理的基础。操作系统通过创建和管理多个进程,实现多任务处理,使用户可以同时运行多个应用程序。
- 故障恢复:进程的独立性使得操作系统可以在一个进程发生故障时,终止该进程并回收资源,而不会影响其他进程的正常运行,从而提高了系统的稳定性和可靠性。
6.2 简述壳Shell-bash的作用与处理流程
Shell自身也是一个交互型程序,它为用户提供一个操作界面,接收用户指令,然后调用相应的应用程序。
Shell首先会从终端读入输入的命令,然后解析输入的命令,如果这个命令是内置命令,那么就立即执行这个命令,否则调用fork创建一个新的子进程,在该子进程的上下文中执行指定的程序。
判断该程序为前台程序还是后台程序,如果为前台程序则等待进程结束,否则将其放在后台并返回。
在这个过程中shell可以接收键盘的信号并对其进行处理,例如Bash可以处理各种信号(如 SIGINT,SIGTERM 等),允许用户通过如 Ctrl+C、Ctrl+Z等组合键中断命令的执行。
6.3 Hello的fork进程创建过程
输入./hello 2022110900 yuqingfang 17372154543 3
Shell判断出该命令不是内置命令,于是父进程调用fork函数创建一个新的子进程,该子进程得到与父进程一样的内存副本但是拥有不同的PID(进程编号)。
在父进程中,fork返回子进程的PID,在子进程中返回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函数运行时,用户栈的结构如图所示(来自上课ppt):
图58
6.5 Hello的进程执行
在运行hello程序时,进程为应用程序提供了以下抽象:(1)一个独立的逻辑控制流,给人一种错觉,仿佛进程独占地使用处理器;(2)一个私有的地址空间,使我们的程序看起来像是独占地使用内存。
操作系统提供的抽象包括:
- 逻辑控制流:当我们使用调试器逐步执行程序时,会看到一系列的程序计数器(PC)值,这些值唯一地对应于程序的可执行文件中的指令或动态链接的共享对象中的指令。这个PC值的序列称为逻辑控制流,或逻辑流。当一个逻辑流与另一个流在时间上重叠执行时,这些流被称为并发流,并且它们是并发运行的。
- 上下文切换:操作系统内核通过一种称为上下文切换的高级异常控制流机制来实现多任务处理。内核为每个进程维护一个上下文,上下文是内核重新启动被抢占的进程所需的状态。
- 时间片:进程在每一时间段内执行其控制流的一部分,这个时间段称为时间片。因此,多任务处理也称为时间分片。
- 用户模式和内核模式:处理器通常通过控制寄存器中的模式位提供这种功能。当模式位设置时,进程运行在内核模式下,此时进程可以执行所有指令,并访问系统中的任何内存位置。当模式位未设置时,进程运行在用户模式下,此时进程不能执行特权指令,也不能直接访问内核区域的代码和数据。
- 上下文信息:上下文是内核重新启动一个被抢占的进程所需的状态信息,包括通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构的值。在hello程序执行过程中,当进程调用 execve 函数时,会为hello程序分配新的虚拟地址空间。程序开始在用户模式下运行,调用 printf 函数输出“Hello 2022110900 yuqingfang 17372154543”。随后,程序调用 sleep 函数,进程进入内核模式,运行信号处理程序,之后返回用户模式。在执行过程中,CPU不断进行上下文切换,将执行过程划分为多个时间片,与其他进程交替使用CPU,从而实现进程调度。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.6.1异常的分类
类别 | 原因 | 异步or同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在的可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
6.6.2异常的处理方式(来自上课ppt)
中断处理:
图59
陷阱处理:
图60
故障处理:
图61
终止处理:
图62
6.6.3各命令及运行结截屏
1. 正常运行:
图63
2.运行时按下CTRL+C
图64
shell收到SIGINT信号,shell结束并回收hello进程。
3. 运行时按下CTRL+Z
图65
shell收到SIGSTP信号,shell显示屏幕提示信息并挂起hello进程。
(1) 对hello进程的挂起可以由ps和jobs命令查看,可以发现hello进程是被挂起了,但是没有回收。
图66
(2)在shell中输入pstree命令,可以将所有进程以树状图显示:
图67
图68
(后面还有很多,就不截上来了)
(3)使用kill可以杀死指定进程
图69
(4)输入fg 1,则命令将hello进程再次调到前台执行,可以发现shell首先打印hello的命令行命令,hello再从挂起处继续运行,打印剩下语句,然后正常结束,并被进程管理器完成进程回收
图70
4. 不停乱按
在程序执行过程中乱按所造成的输入都会缓存到stdin,当getchar的时候就会读出一个\n的字符串作为一次输入在hello结束后,shell会将刚才stdin的所有缓存的字符都当成命令执行
图71
6.7本章小结
本章介绍进程的概念和作用,说明了shell-bash的作用和执行流程。对hello程序的执行进行研究,探讨了fork函数创建子进程的过程、execve函数的执行过程,以及各种异常和信号处理的结果。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
7.2 Intel逻辑地址到线性地址的变换-段式管理
对于一个以“段:偏移地址”形式给出的逻辑地址,CPU将会通过其中的16位段选择子定位到GDT/LDT中的段描述符,通过这个段描述符得到段的基址,与段内偏移地址相加得到的64位整数就是线性地址。这就是CPU的段式管理机制,其中,段的划分,也就是GDT和LDT都是由操作系统内核控制的。
图72(网上找的)
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟地址空间会被分为若干页,即分页机制。CPU对于一个线性地址会取它的高若干位,通过它们去存储在内存中的页表里查询对应的页表条目,得到这个线性地址对应的物理页起始地址,然后与线性地址的低位(页中的偏移)相加就是物理地址。(图来自课上ppt)
图73
7.4 TLB与四级页表支持下的VA到PA的变换
Core i7采用了四级页表的分层结构。当CPU生成虚拟地址VA时,该地址被传送给内存管理单元(MMU)。MMU利用虚拟地址的VPN高位作为TLB标签(TLBT)和TLB索引(TLBI),在TLB中查找匹配项。如果TLB中存在匹配项,则直接获取物理地址PA。如果TLB中没有匹配项,MMU将查询页表。
CR3寄存器确定第一级页表的起始地址,而VPN1确定在第一级页表中的偏移量。通过这种方式,MMU依次查询每个页表,直到在第四级页表中找到物理页号(PPN),然后将虚拟页偏移量(VPO)与物理页号组合成物理地址PA,并将其添加到页表缓冲器(PLT)中。
整个过程的工作原理如下:(图来自课上ppt)
图74
多级页表的工作原理如下:(图来自课上ppt)
图75
7.5 三级Cache支持下的物理内存访问
参考缓存部分的ppt:高速缓存储存器组织结构:
图76
如果想要的数据确实存在高速缓存当中(缓存命中),那么应该满足如下条件:
- 有效位为1
- 标记位与地址中的标记位匹配
如果发生缓存不命中,则访问主存,同时进行块的驱逐和替换。
7.6 hello进程fork时的内存映射
fork 是一个用于创建新进程的系统调用,新进程是调用进程的副本。fork 调用时,操作系统会为子进程创建一个新的进程空间,并将父进程的内容复制到子进程中。具体内存映射如下:
- 代码段:包含程序的可执行代码。在 fork 过程中,代码段是共享的,不需要复制。
- 数据段:包含全局变量和静态变量。这部分内容在 fork 后被复制到子进程。
- 堆:是动态分配内存的区域,使用 malloc 等函数进行分配。在 fork 之后,堆内存也被复制到子进程。
- 栈:用于存储局部变量和函数调用信息。在 fork 之后,栈内存同样被复制到子进程。
- 文件描述符:父进程打开的文件描述符在子进程中会被继承,文件描述符的引用计数会增加。
fork 时的写时复制
初始状态:在 fork 之后,父子进程共享相同的物理内存页,并将这些页标记为只读。
写入操作:当父或子进程尝试写入内存时,操作系统会创建该页的副本,并将写操作应用于副本。这确保了父子进程在写入时拥有独立的内存页。
这种技术提高了 fork 的效率,因为只有在写入时才会进行实际的内存复制,从而减少了不必要的内存开销。(图来自上课ppt)
图77
图78
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核中的启动加载器代码,在当前进程中加载并运行包含在可执行文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello程序需要以下几个步骤:
- 删除已存在的用户区域:删除当前进程虚拟地址空间的用户部分中的已有区域结构。
- 映射私有区域:为新程序的代码、数据、.bss和栈区域创建新的区域结构,这些区域都是私有的、采用写时复制机制。代码和数据区域分别映射到hello文件中的.text和.data段,.bss段是请求零初始化的,映射到匿名内存,其大小在hello中定义,栈和堆也是请求零初始化的,初始长度为零。
- 映射共享区域:hello程序与共享库libc.so链接,libc.so作为动态链接库被映射到用户虚拟地址空间中的共享区域。
- 设置程序计数器:execve做的最后一件事情是将当前进程上下文的程序计数器设置为代码区域的入口点位置,如图所示。(图来自课上ppt)
图79
7.8 缺页故障与缺页中断处理
7.8.1 缺页故障(Page Fault)
虚拟内存在DRAM缓存不命中即为缺页故障。
7.8.2 缺页中断处理
缺页中断处理:
- 缺页处理程序确定物理内存中牺牲页 (若页面被修改,则换出到磁盘)。
- 缺页处理程序页面调入新的页面,并更新内存中的PTE。
- 缺页处理程序返回到原来的进程,再次执行导致缺页的命令。
图80(来自课上ppt)
7.9动态存储分配管理
7.9.1 动态内存管理的基本方法
虽然可以使用低级的mmap和munmap函数来创建和删除虚拟内存区域,但是C程序员还是会觉得当运行时需要额外虚拟内存时,用动态内存分配器更方便,也有更好的可移植性。
(1)显式分配器
要求应用显式地释放任何已分配的块。例如,c标准库提供一种叫做malloc程序包的显式分配器。c程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。c++中的new和delete操作符与c中的malloc和free相当。
(2)隐式分配器
要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集,例如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
7.9.2 动态内存管理的策略
(1)带边界标签的隐式空闲链表
带边界标签的隐式空闲链表使用边界标签(boundary tags)来管理内存块,内存块之间没有显式的指针链接。每个内存块包含头部和尾部的边界标签,这些标签存储块的大小和状态(分配或空闲)。
(2)显示空间链表
显式空闲链表使用链表来维护所有空闲块,链表中的每个节点都包含指向下一个和上一个空闲块的指针。这种方式提供了更高效的空闲块管理。
7.10本章小结
本章主要介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理,以及TLB与四级页表支持下的VA到PA的变换、三级cache支持下的物理内存访问,分析了hello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理。同时简单讲述了动态内存管理的基本方法与策略。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
在Linux中,所有的IO设备(网络、磁盘、终端等)都被模型化为文件,所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,使得所有的输入和输出都能以一种统一且一致的方式来执行。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O接口
Unix I/O接口,使得所有的输入和输出都能以一种统一且一致的方式来执行:
- 打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件;Linux shell创建的每个进程开始时都有三个打开的文件,标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2);
- 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0;
- 读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n;类似地,写操作就是从内存复制n>0个字节到文件,从当前文件位置k开始,然后更新k;
- 关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。
8.2.2 Unix I/O函数
(1) 进程通过调用open函数来打开一个已存在的文件或者创建一个新文件的:int open(char *filename, int flags, mode_t mode)
open函数将filename转换为一个文件描述符,并且返回描述符数字;flags参数也可以是一个或者更多位掩饰的或,为写提供给一些额外的指示;mode参数指定了新文件的访问权限位。
1. close函数
进程通过调用close函数关闭一个打开的文件。
2. read函数
应用程序是通过分别调用read来执行输入,ssize_t read(int fd, void *buf, size_t n);
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0比怕是EOF。否则返回值表示的是实际传送的字节数量。
3. write函数
应用程序是通过调用write函数来执行输出,ssize_t write(int fd, const void *buf, size_t n);
write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
4. lseek函数
通过调用lseek函数,应用程序能都显示地修改当前文件的位置。
8.3 printf的实现分析
printf函数的函数体,可以发现在printf当中,还调用了vprintf、write这两个函数。
图81
1. vprintf函数
用于处理格式化字符串,vprintf会根据格式字符串和可变参数列表生成最终的输出字符串,具体分为两步:
格式字符串解析:vsprintf 会解析格式字符串中的占位符(如 %d、%s 等),并将对应的参数替换到字符串中。
缓冲区处理:生成的字符串会被存储在一个缓冲区中,以便后续的输出操作。
2. write函数
将生成的字符串通过系统调用传递到内核,以便输出到终端的其他设备。
write函数的接口:ssize_t write(int fd, const void *buf, size_t count)
fd——文件描述符,指向标准输出(通常为1)。
buf——指向待输出的缓冲区。
count——要输出的字节数。
3. 陷阱-系统调用
write 函数内部会使用系统调用 int 0x80 或 syscall将数据从用户空间传递到内核空间。这一步涉及到陷阱指令,将控制权交给操作系统内核。
陷阱指令:常见的系统调用机制有 int 0x80(在32位Linux中)或 syscall(在64位Linux中)。
int 0x80:通过中断向量 0x80 触发系统调用。
syscall:使用更高效的 syscall 指令触发系统调用。
内核会根据系统调用号和参数执行相应的操作,将数据写入到指定的输出设备。
4. 字符显示驱动子程序
一旦内核接收到数据,它会通过字符显示驱动子程序将数据传递给硬件进行显示。这包括以下步骤:
A.从ASCII到字模库:
字模库存储每个ASCII字符的点阵图,即字符的图形表示。
内核会查找每个字符对应的点阵图。
B.写入显示VRAM:
字模库中的点阵图会被转换为显示数据,并写入到显存(VRAM)。
显存存储每一个像素点的RGB颜色信息。
C.刷新频率和显示输出:
显示芯片根据设定的刷新频率逐行读取VRAM中的数据。
读取的数据通过信号线传输到显示器。
D.显示芯片和液晶显示器
显示芯片负责将显存中的数据转换为显示器能够理解的信号
读取显存:显示芯片以固定的刷新频率逐行读取显存中的像素数据。
信号传输:读取的数据通过信号线传输到液晶显示器。
显示器处理:液晶显示器接收信号并控制每个像素的RGB值,最终在屏幕上显示出字符。
8.4 getchar的实现分析
异步异常 - 键盘中断的处理
键盘中断是典型的异步异常,发生在用户按下键盘上的按键时。处理键盘中断涉及硬件和操作系统多个层次的协作,以下是键盘中断处理程序的主要步骤:
键盘中断触发:
当用户按下键盘上的按键时,键盘控制器会生成一个中断请求。
键盘中断处理子程序:
保存上下文
读取扫描码
转换为ASCII码
保存到键盘缓冲区
发送中断结束信号
恢复上下文
getchar函数
getchar通过调用read函数,通过系统调用读取按键ascii码,直到接受到回车键才返回,如此完成执行。
read主要分为以下几个步骤:
- 等待输入:如果键盘缓冲区为空,read 调用会阻塞,直到有按键数据可读。
- 读取数据:从键盘缓冲区读取数据,逐个字符返回给用户程序。
- 处理特殊字符:read 会处理特殊字符,如回车键。当遇到回车键时,read 会返回读取的完整行数据。
8.5本章小结
本章主要简述了Linux的IO设备管理方法、接口及其函数,并对printf和getchar这两个函数做了深度解析。
(第8章1分)
结论
到这儿,也算是走完了hello.c传奇的一生了。
这条路比坐在电脑前面敲几下键盘的我们所看的到的远多了。
hello从被我们写出来被保存在hello.c开始,将经历以下几个关键步骤:
- 预处理cpp:处理#include,将所有被调用的库文件内容加入进来,产生hello.i文件
- 编译cc:将hello.i文件编译成汇编语言的hello.s文件
- 汇编as:将hello.s文件汇编成可重定位目标文件hello.o
- 链接ld:将hello.o文件和动态链接库连接起来,生成可执行文件hello
- 用户输入运行指令./hello 2022110900 yuqingfang 17372154543 3
- shell判断不是内置指令,所以调用fork创建一个新的子进程
- 加载程序:shell调用execve,经过启动加载器、映射虚拟内存等一系列操作之后,进入main函数,开始执行
- 执行指令、访存
- 信号接受和处理:比如ctrl+c,内核会发送SIGINT给进程并终止前台作业。
- 终止:当执行完毕的时候,成为僵死进程,被shell回收。
这个大作业,让我对P2P这一个过程有了更为深刻的认识,深刻感受到了计算机系统组织和设计上的严密和精妙。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
hello——hello的可执行文件
hello.asm——hello.o的反汇编文件
hello1.asm——hello的反汇编文件
hello.c——hello源文件
hello.i——hello.c预处理产生的文件
hello.s——hello.i编译产生的文件
hello.o——hello.s汇编产生的文件
hello.elf——从hello.o中读取的ELF文件
hello1.elf——从hello中读取的ELF文件
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
(参考文献0分,缺失 -1分)
标签:文件,CSAPP,HIT,函数,Process,程序,链接,进程,hello From: https://blog.csdn.net/weixin_73757883/article/details/139665501