CSAPP大作业:程序人生-Hello‘s P2P

第1章 概述

1.1 Hello简介

P2P:From Program to Process

指从 hello.c(Program)转变为运行时进程(Process)的过程。为了使 hello.c 这个C语言编写的程序能够运行,需要将其转化为可执行文件。这一过程包含四个主要阶段:1. 预处理(Preprocessing):预处理器(如 cpp)处理所有的预处理指令,如宏定义、文件包含和条件编译等。这一步生成一个扩展名为 .i 的中间文件。2. 编译(Compilation):编译器(如 gcc)将预处理后的代码转换为汇编代码,这一步生成一个扩展名为 .s 的汇编文件。3. 汇编(Assembly):汇编器(如 as)将汇编代码转换为机器代码,生成目标文件(扩展名为 .o)。4. 链接(Linking):链接器(如 ld)将多个目标文件和库文件链接在一起,生成最终的可执行文件(如 hello)。然后就可以在shell中执行该文件,shell会给它分配进程空间。

O2O:From Zero-0 to Zero-0

指从内存中最初没有 hello 程序的内容,到程序加载、执行,再到程序结束和内存回收的过程。具体步骤如下:1. 程序加载(Program Loading):在命令行(shell)中输入命令启动 hello 程序时,shell 调用 execve 函数启动 hello 程序。操作系统为 hello 程序分配虚拟内存地址空间,将程序的各个段(代码段、数据段等)映射到物理内存。2. 程序执行(Program Execution):程序从入口地址开始执行,通常是 main 函数。操作系统从入口点开始加载和运行程序指令。程序按设计逻辑逐行执行代码,完成预定的任务。3. 程序结束(Program Termination):程序运行结束后,通过 exit 系统调用通知操作系统进程已完成。操作系统回收该进程所占用的资源。shell 父进程(通常是 shell 本身)通过等待子进程(hello 进程)终止,并回收该进程的内存和其他系统资源。内核删除与 hello 程序相关的所有数据结构,释放内存。

1.2 环境与工具

       硬件环境:13th Gen Intel(R) Core(TM) i9-13905H

       软件环境:Windows11 64位;VMware;Ubuntu 22.04

       开发和调试工具:Visual Studio Code;vim、objump、edb、gcc、readelf

1.3 中间结果

hello:链接之后的可执行目标文件

hello.c:源文件

hello.elf:hello的ELF格式

hello.i:预处理产生的文本文件

hello.o:汇编产生的可重定位目标文件

hello.objdmp:汇编产生的可重定位目标文件

hello.s:汇编产生的汇编文件

hello.txt:hello.o的ELF格式

1.4 本章小结

本章首先介绍了hello的P2P和020流程,包括其设计思路和实现方法;接着,详细描述了本实验所需的硬件配置、软件平台、开发工具以及实验生成的各个中间结果文件的名称和功能。

第2章 预处理

2.1 预处理的概念与作用

2.1.1 预处理的概念

预处理步骤是指在程序运行前,预处理器对源文件进行初步加工的过程。这个过程主要是进行代码文本替换,处理以#开头的指令,同时删除注释和多余的空白字符。预处理指令可以简单理解为以#开头的指令,这些指令在预处理过程中被转换为实际代码内容。

预处理可以帮助一个程序可以在不同环境中被各种编译器方便的进行编译。预处理的主要作用如下:

(1)删除“#define”并展开所定义的宏;

(2)处理所有条件预编译指令;

(3)插入头文件到“#include”处,这个过程往往是递归的(插入头文件,头文件还有需要插入的头文件,故称之为递归)

(4)删除所有的注释;

(5)添加行号和文件名标识,以便编译时编译器产生调试用的行号信息。

因为编译器需要用,所以会保留所有#pragma编译指令。

2.2在Ubuntu下预处理的命令

命令:gcc -E hello.c -o hello.i

2.3 Hello的预处理结果解析

在打开 .i 文件后,可以发现文件变得非常多行,而 hello.c 的代码会在最后才出现。这是因为头文件如 stdio.h、unistd.h 和 stdlib.h 都进行了展开。打开 /usr/include/stdio.h 文件,可以看到其中依然使用了 #define 语句,从而递归展开。此外,预处理器会根据 #if 语句后的条件决定哪些代码需要编译。

在 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> 等,这些头文件也会被递归地展开到源文件中。

预处理器不会对头文件中的内容进行任何计算或转换,只是简单地进行复制和替换。

2.4 本章小结

       本章讲述了在linux环境中,如何用命令对C语言程序进行预处理,用一个简单的hello程序演示了从hello.c到hello.i的过程。并结合预处理之后的程序hello.i,对预处理的过程和结果进行了解析。预处理是将程序在编译之前进行的一步处理,实则是一个通过宏展开、宏替换、插入头文件等操作,使得程序中的宏引用被递归地替换掉的过程。

第3章 编译

3.1 编译的概念与作用

   编译的概念:编译器把预处理后的 .i文件进行一系列语法分析及优化后生成相应的汇编语言文件.s文件的过程。

    编译的作用:使高级语言源程序变为汇编语言,提高编程效率和可移植性。计算机程序编译的基本流程包括词法分析、语法分析、语义分析、中间代码生成、代码优化和目标代码生成等阶段。

3.2 在Ubuntu下编译的命令

   编译的命令:gcc -S hello.i -o hello.s

3.3 Hello的编译结果解析

3.3.1 汇编文件开始部分

hello.s文件开始部分:

      .file: 声明出源文件

      .text: 表示代码节

      .section:区段

      .rodata:表示只读数据段

      .align: 声明对指令或者数据的存放地址进行对齐的方式

      .string: 声明一个字符串

      .globl: 声明全局变量

      .type: 声明一个符号的类型

3.3.2 数据部分

      1.参数argc:

      由上述汇编指令可知:mian函数的第一个参数argc被存放在寄存器%edi中,且%edi的地址被压入栈中。之后执行该地址上的数值与立即数5判断大小,可知argc被存放在寄存器中且同时被压入栈中。

     

      2.局部变量i:

    由hello.c可知程序中的局部变量只有i,由上述语句可知局部变量i被存放在栈上-4(%rbp)中,并被初始化为0。

     

      3.字符串

      在hello.c中使用的字符串类型,是函数根据参数的数量决定打印到屏幕上的字符串。此处输入的三个字符串的信息都放在了.rodata节中。且根据原c程序对比可知,\xxx(x为数字)为汉字在utf-8中编码而成。

3.3.3 赋值操作

      eg:在循环开始时将i赋值为0,于int型变量i是一个32位变量,使用movl传递双字实现。

3.3.4 算术操作

             eg:此处算术操作对应原c程序的for循环,每次循环结束i++。

3.3.5 关系与控制操作

      1.je条件判断跳转:判断argc的大小,由前述可知,因为argc为main函数的第一个参数,所以存放在%edi中,结合前面汇编分析存放再-20(%rdp)中。此处使用cmpl指令比较立即数4和argc的大小,并设置相应的条件码。如果不相等,则跳转至L2。

     

      2.jle条件判断跳转:判断i与9的大小,由前述可知-4(%rbp)存放i,如果i小于等于9,则跳转至L4。

3.3.6 函数操作

  根据hello.c程序可知,调用的函数有:main()、printf()、atoi()、sleep()、getchar()。

1.main()

参数传递:第一个为argc(类型int),第二个为argv[](类型char*),分别存放在%r

di和%rsi中。程序通过使用call内部指令调用语句进行函数调用,并且将要调用的函数地址数据写入栈中,然后自动跳转到这个调用函数内部。main函数里调用了printf、exit、sleep函数。

      2.printf()

      参数传递:printf调用了参数argv[1]、argv[2]、argv[3]。printf调用了两次,第一c次将寄存器%rdi设置为待传递字符串“用法: Hello 学号 姓名 手机号 秒数!\n”的起始地址;第二次将其设置为“Hello %s %s %s”的起始地址。使用寄存器%rsi、%rdx、%rcx分别完成对argv[1]、argv[2]、argv[3]的传递。

      两次call调用的指令如下图所示。

      3.exit()

      参数传递:将1赋给了%edi。

      函数调用:通过如下图call指令进行函数调用,并从exit返回。

      

  4.sleep()

      参数传递:将atoi的返回值%eax通过%edi传递传递给sleep

      函数调用:通过如下call指令调用sleep函数,并从sleep中返回

      5.atoi()

      参数传递:atoi将参数argv[4]放入%rdi中作为参数传递

      函数调用:通过如下call指令进行函数调用,并从atoi中返回

      6.getchar()

      无参数传递,直接使用如下call调用,并从getchar中返回

3.4 本章小结

      本章主要说明了编译器是如何处理C语言的各个数据类型以及各类操作的,介绍了C编译器如何把hello.i文件转换成hello.s文件的过程,简要说明了编译的含义和功能,演示了编译的指令,并通过分析生成的hello.s文件中的汇编代码,探讨了数据处理,函数调用,赋值、算术、关系等运算以及控制跳转和类型转换等方面,比较了源代码和汇编代码分别是怎样实现这些操作的。经过编译之后,我们的hello自C语言转换为了更加低级的汇编语言。

第4章 汇编

4.1 汇编的概念与作用

      概念:是指汇编器(as)将包含汇编语言的.s文件翻译为机器语言指令,并把这些指令打包成为一个可重定位目标文件的格式,生成目标文件.o文件的过程

      作用:翻译生成机器语言,机器语言是计算机能直接识别和执行的一种语言。

4.2 在Ubuntu下汇编的命令

命令:gcc -c hello.s -o hello.o

4.3 可重定位目标elf格式

      命令:readelf -a hello.o > hello.elf 指令获得 hello.o 文件的 ELF 格式如下图所示:

     

      ELF格式的可重定位目标文件的结果如下图所示:

4.3.1 ELF头

      如上图所示,ELF头(ELF header)以一个l6字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。

4.3.2 节头

      如上图所示,节头部表描述了不同节的位置和大小,其中目标文件中每个节都有一个固定大小的条目。具体的描述包括节的名称、类型、地址和偏移量等。

4.3.3 重定位节

      如上图所示,.rel.text节是一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改,而调用本地函数的指令不需修改。可执行目标文件中不包含重定位信息。

4.3.4 符号表

      如上图所示,.symtab节中包含ELF符号表,这张符号表包含一个条目的数组,存放一个程序定义和引用的全局变量和函数的信息。该符号表不包含局部变量的信息。

4.4 Hello.o的结果解析

命令:objdump -d -r hello.o > hello.asm 得到hello.asm反汇编文件

反汇编代码如下:

hello.s文件如下:

4.4.2 反汇编文件与hello.s对比分析

1.操作数

hello.s中的操作数为十进制,反汇编代码中的操作数为十六进制。

(反汇编)

(hello.s)

2.分支转移

反汇编的跳转指令中,所有跳转的位置被表示为主函数+段内偏移量这样确定的地址,而不再是段名称(例如.L3)。

(反汇编)

(hello.s)

3.函数调用

hello.s中,call指令之后是函数名称。因为函数只有在链接之后才能确定运行执行的地址,在.rela.text节中添加了重定位条目,所以反汇编中call指令之后是相对偏移地址。

(反汇编)

(hello.s)

4.指令

汇编中mov、push、sub等指令都有表示操作数大小的后缀(b : 1字节、w :2 字节、l :4 字节、q :8字节),反汇编得到的代码中则没有。
汇编代码中有很多“.”开头的伪指令用来指导汇编器和链接器工作,反汇编得到的代码中则没有。

(反汇编)

(hello.s)

4.5 本章小结

本章节我们查看并总结了hello.o的elf文件格式的信息。通过查看反汇编代码,对比hello.s,看到了在函数调用、条件转移等过程中的区别。通过分析hello.o的反汇编代码(保存在hello.asm中)和hello.s的区别和相同点,让人清楚地理解了汇编语言到机器语言的转换过程,以及机器为了链接而做的准备工作。

5章 链接

5.1 链接的概念与作用

概念:是将各种代码和数据片段收集并组合为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。

作用:在现代系统中,链接是由叫做链接器(1iker)的程序自动执行的,它们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。

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.3 可执行目标文件hello的格式

    命令:readelf -a hello > hello1.elf  得到helllo的ELF格式文件,接下来开始分析。

5.3.1 ELF头:

 hello1.elf中的ELF头与hello.elf中的ELF头包含的信息种类基本相同,以描述了生成该文件的系统的字的大小和字节顺序的16字节序列Magic开始,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。与hello.elf相比较,hello1.elf中的基本信息未发生改变(如Magic,类别等),而类型发生改变,程序头大小和节头数量增加,并且获得了入口地址。

5.3.2 节头

Section Headers对hello中所有的节信息进行了声明,其中包括大小Size以及在程序中的偏移量Offset。根据节头中的信息我们就可以用定位各个节所占的区间(起始位置,大小)。

5.3.3 程序头

程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。

5.3.4 段节

5.3.5 重定位节

 其中,符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。

5.4 hello的虚拟地址空间

      使用edb打开hello从Data Dump窗口观察hello加载到虚拟地址的情况,查看各段信息如下图所示:

可见,ELF被映射到了0x401000,其余各段一句前面给出的程序头表依次映射,即:

5.5 链接的重定位过程分析

5.5.1 hello与hello.o反汇编代码比较:

      命令:objdump -d -r hello > hello1.asm 得到反汇编文件hello.asm

      1.函跳转指令发生变化

      在链接过程中,链接器解析了重定位条目,并计算相对距离,修改了对应位置的字节代码为PLT 中相应函数与下条指令的相对地址,从而得到完整的反汇编代码。

2.函数数量增加

链接后的反汇编文件hello2.asm中,多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为动态链接器将共享库中hello.c用到的函数加入可执行文件中。

3.函数调用call指令参数发生变化

hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。

5.5.2 hello的重定位过程分析

重定位过程合并输入模块,并为每个符号分配运行时地址,将多个单独的代码节和数据节合并为单个节,将符号从它们的在.o文件的相对位置重新定位到可执行文件的最终绝对内存位置,更新所有对这些符号的引用来反映它们的新位置。

重定义由以下两步组成:

(1)重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的聚合节。然后链接器将运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。至此程序中每条指令和全局变量都有唯一的运行内存地址。

(2)重定位节中的符号引用。这一步中链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目的数据结构。

5.6 hello的执行流程

      利用edb的symbol以及debug查看如下图所示:

      可知各个子程序名对应的地址如下:

      (1)hello!_start:0x4010f0

      (2)hello!main:0x401125

      (3)hello!puts@plt:0x401030

      (4)hello!printf@plt:0x401040

      (5)hello!getchar@plt:0x401050

      (6)hello!atoi@plt:0x401060

      (7)hello!exit@plt:0x401070

      (8)hello!sleep@plt:0x401080

5.7 Hello的动态链接分析 

5.7.1 GOT表

每一个外部定义的符号在全局偏移表(Global offset Table)中有相应的条目,GOT位于ELF的数据段中,叫做GOT段。GOT把位置无关的地址计算重定位到一个绝对地址。程序首次调用某个库函数时,运行时连接编辑器(rtld)找到相应的符号,并将它重定位到GOT。之后每次调用这个函数都会将控制权直接转向那个位置,而不再调用rtld。

5.7.2 结果分析

根据hello.elf文件,查找到got地址:

GOT表位置在调用dl_init之前0x404008后的16个字节均为0:

调用了dl_init之后字节改变为:

对于变量而言,利用代码段和数据段的相对位置不变的原则去计算正确地址。

对于库函数而言,需要pltgot合作。plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。接下来执行程序的过程中,就可以使用过程链接表plt和全局偏移量表got进行动态链接。

5.8 本章小结

本章主要介绍了链接的概念与作用、hello的ELF格式,分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。ld在软件开发中扮演着一个关键的角色,因为它使得分离编译成为可能。当分离编译时,我们可以考虑把程序分解为更小、更好管理的模块,可以独立地修改和编译。这有助于我们解耦合的编程思想的思想。

6章 hello进程管理

6.1 进程的概念与作用

概念:进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。

作用:进程为用户提供以下假象:程序好像是系统中当前运行的唯一程序一样,独占地使用处理器和内存,CPU仿佛无间断的执行程序中的指令,程序中的代码和数据仿佛是系统内存中唯一的对象。

6.2 简述壳Shell-bash的作用与处理流程

作用:Shell是一个交互型应用级程序,也被称为命令解析器,它为用户提供一个操作界面,接受用户输入的命令,并调度相应的应用程序。

处理流程:

1)从终端读入输入的命令。

        2)将输入字符串切分获得所有的参数

        3)如果是内置命令则立即执行

        4)否则调用相应的程序为其分配子进程并运行

        5Shell应该接受键盘输入信号(ctrl+cctrl+z),并对这些信号进行相应处理

6.3 Hello的fork进程创建过程

首先用户再shel1界面输入指令,Shell判断该指令不是内置命令,于是父进程调用fork函数创建一个新的子进程,该子进程得到与父进程用户级虚拟地址空间相同的一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程与父进程最大的区别就是具有不同的PID。在父进程中,fork返回子进程的PID,而在子进程中fork返回0,返回值提供一个明确的方法来分辨程序是父进程还是在子进程中执行。

6.4 Hello的execve过程

int execve(char *filename, char *argv[], char *envp[]);

execve函数在当前进程的上下文中加载并运行一个程序。加载并运行可执行目标文件filename(char *), 且带参数列表argv (char *)和环境变量列表envp (char *)。只有当出现错误时,例如找不到filename时, execve 才会返回到调用程序。fork 一次调用返回两次不同, 而execve 调用一次并从不返回。

execve会删除已存在的用户区域–>映射私有区域–>映射共享区域–>设置程序计数器。

调用驻留在内存中的被称为启动加载器的操作系统代码来执行程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。

新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。

最后加载器设置PC指向_start地址,_start最终调用main函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。

直到CPU引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。

6.5 Hello的进程执行

hello程序在运行时,进程提供给应用程序的抽象有:(1)一个独立的逻辑控制流,它提供一个假象,好像我们的进程独占地使用处理器;(2)一个私有的地址空问,它提供一个假象,好像我们的程序独占地使用CPU内存。

操作系统提供的抽象有:

(1)逻辑控制流。如果想用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称为逻辑流。一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地运行。

(2)上下文切换。操作系统内核使用一种称为上下文切换的叫高层形式的异常控制流来实现多任务。内核为每一个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需状态。

(3)时间片。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫做时间分片。

(4)用户模式和内核模式。处理器通常使用某个控制寄存器中的一个模式位来提供这种功能。当设置了模式位时,进程就运行在内核模式里。一个运行在内核模式的进程可以执行指令集中的所有指令且可以访问系统中的任何内存位置。没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,也不能直接引用地址空间中内核区内的代码和数据。

(5)上下文信息。上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。hello程序执行过程中,在进程调用execve函数后,进程就为hello程序分配新的虚拟地址空间,开始时程序运行在用户模式中,调用printf函数输出指定内容,之后调用sleep函数,进程进入内核模式,运行信号处理程序,再返回用户模式,运行过程中,cpu不断切换上下文,使运行过程被切分成时间片,与其他进程交替占用cpu,实现进程的调度。

6.6 hello的异常与信号处理

6.6.1 异常分类

6.6.2 处理方法

      1.中断:中断是异步发生的,是来自处理器外部的 I/O 设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序。

      2.陷阱:陷阱是有意的,是执行一条指令后的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。

      3.故障:不是有意的,但可能被修复。Examples: 缺页(可恢复),保护故障(protection faults,不可恢复), 浮点异常(floating point exceptions,不恢复)。处理程序要么重新执行引起故障的指令(已修复),要么终止

      4.终止:终止是不可恢复的致命错误造成的结果,Examples: 非法指令,奇偶校验错误(parity error),机器检查(machine check),会中止当前程序。

6.6.3 运行结果

1.正常运行

在程序正常运行时,打印10次提示信息,以输入回车为标志结束程序,并回收进程。

2.运行时按Ctrl+C

按下Ctrl + C,Shell进程收到SIGINT信号,Shell结束并回收hello进程。

3. 运行时按Ctrl+Z

按下Ctrl + Z,Shell进程收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程。

4.ps命令:

5.jobs命令:

6.pstree命令:

以树状图显示进程间的关系

7.kill命令:

杀死指定(进程组的)进程:

8.fg 1命令:

将hello进程再次调到前台执行,可以发现Shell首先打印hello的命令行命令,hello再从挂起处继续运行,打印剩下的语句。程序仍然可以正常结束,并完成进程回收。

9.不停乱按

6.7本章小结

本章介绍了进程管理的一些简要信息,比如进程的概念作用,Shell的基本原理,Shell调用fork和execve,进程在执行时会遇到不同情况的不同反应(包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z,运行ps jobs pstree fg kill 等命令的处理)。还介绍了一些常见异常和其信号处理方法。

7章 hello的存储管理

7.1 hello的存储器地址空间

      1.逻辑地址:程序代码经过编译后出现在汇编程序中地址。逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,由程序hello产生的与段相关的偏移地址部分。

      2.线性地址:线性地址是逻辑地址到物理地址变换之间的一步,程序hello的代码会产生逻辑地址,在分段部件中逻辑地址是段中的偏移地址,加上基地址就是线性地址。

      3.虚拟地址:程序访问存储器所使用的逻辑地址称为虚拟地址。虚拟地址经过地址翻译得到物理地址。与实际物理内存容量无关,是hello中的虚拟地址。

      4.物理地址:在存储器里以字节为单位存储信息,每一个字节单元给一个唯一的存储器地址,这个地址称为物理地址,是hello的实际地址或绝对地址。

7.2 Intel逻辑地址到线性地址的变换-段式管理

段式管理是指把一个程序分成若干个段进行存储,每个段都是一个逻辑实体。段式管理是通过段表进行的,包括段号(段名)、段起点、装入位、段的长度等。程序通过分段划分为多个块,如代码段、数据段、共享段等。

一个逻辑地址是两部分组成的,包括段标识符和段内偏移量。段标识符是由一个16位长的字段组成的,称为段选择符。其中前13位是一个索引号,后3位为一些硬件细节。索引号即是“段描述符”的索引,段描述符具体地址描述了一个段,很多个段描述符就组成了段描述符表。通过段标识符的前13位直接在段描述符表中找到一个具体的段描述符。

全局描述符表(GDT)整个系统只有一个,它包含:(1)操作系统使用的代码段、数据段、堆栈段的描述符(2)各任务、程序的LDT(局部描述符表)段。

每个任务程序有一个独立的LDT,包含:(1)对应任务/程序私有的代码段、数据段、堆栈段的描述符(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。工作原理如下:

多级页表的工作原理展示如下:

高速缓存的结构将m个地址位划分成了t个标记位,s个组索引位和b个块偏移位:

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 缺页故障与缺页中断处理

Step1:先确认是不是一个合法的地址——不断将这个地址与每个区域的vm_start&vm_end进行比对。

Step2:确认访问权限是不是正确的——如果这一页是只读页,但是却要做出写这个动作,那明显是不行的。

Step3:确认了是合法地址并且是符合权限的访问,那么就用某个特定算法选出一个牺牲页。如果该页被修改了,就将此页调出并且调入那个被访问的页,并将控制传递到触发缺页中断的那条指令,这条指令继续执行的时候就不会触发缺页中断,这样就可以继续执行下去。找到了要存储的页后,内核会从磁盘中将需要访问的内存VP3放入到之前已经操作过的PP3中,并且将PTE中的信息更新,这样就成功的将一个物理地址缓存在了页表中。当异常处理返回的时候,CPU会重新执行访问虚拟内存的操作,这个时候就可以正常的访问,不会发生缺页现象了。

7.9动态存储分配管理

1.基本方法:

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。堆在未初始化的数据区域后开始,并向更高的地址生长。对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。

分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用,空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

2.策略:

(1)首次适配:从头搜索,遇到第一个合适的块就停止;

(2)下次适配:从链表中上一次查询结束的地方开始,遇到下一个合适的块停止;

(3)最佳适配:全部搜索,选择合适的块停止。

7.10本章小结

本章主要介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理,以intel Core i7在指定环境下介绍了虚拟地址VA到物理地址PA的转换、物理内存访问,分析了hello进程fork时的内存映射、hello进程、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理等内容。这些内容对应用程序的性能有着巨大的影响。

8章 hello的IO管理

8.1 Linux的IO设备管理方法

一个Linux文件就是一个m个字节的序列,所有的I/O设备都被模型化为文件,所有的输入输出都被当作是文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,被称为是Unix I/O,这使得所有的输入和输出都能够以一种统一且一致的方式来执行。

8.2 简述Unix IO接口及其函数

1.Unix接口:

(1)打开文件:一个应用程序通过内核打开文件,来宣告它想访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。

(2)I/O设备:内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。

(3)改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。

(4)读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。

(5)关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件,作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放他们的内存资源。

2.Unix I/O函数:

(1)int open(char *filename, int flags, mode_t mode);

              进程通过调用open函数打开一个已存在的文件或者创建一个新文件:

Open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件:O_RDONLY:只读、O_WRONLY:只写和O_RDWR可读可写。mode参数指定了新文件的访问权限位。

(2)int close(fd):

              进程调用close函数关闭一个打开的文件,fd是需要关闭的文件的描述符。

(3)ssize_t read(int fd, void *buf, size_t n);

      read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。

(4)ssize_t write(int fd, const void *buf, size_t n);

      write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。

8.3 printf的实现分析

1.printf函数的函数体:

参数采用了可变参数的定义, *fmt是一个char 类型的指针,指向字符串的起始位置。这个指针指向第一个const参数(const char *fmt)中的第一个元素。fmt也是个变量,它的位置,是在栈上分配的,它也有地址。

2.printf调用的外部函数vsprintf

这个函数接受一个格式化的命令,并把指定的匹配的参数格式化输出。

i = vsprintf(buf, fmt, arg):由此句可知,vsprintf返回的是一个长度,就是要打印出来的字符串的长度。

write(buf, i):由此句可知,write即写操作,会把buf中的i个元素的值写到终端。

vsprintf的作用就是格式化,接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。

3.write函数

在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall。

4.syscall函数

ecx中是要打印出的元素个数 ,ebx中的是要打印的buf字符数组中的第一个元素,这个函数的功能就是不断的打印出字符,直到遇到:’\0’ 停止。

综合分析,printf函数实现过程:

1)vsprintf的作用是格式化,接受确定输出格式的格式字符串fmt,用格式字符串对个数变化的参数进行格式化,产生格式化输出。

2)vsprintf的输出到write函数中。在Linux下,write通过执行syscall指令实现了对系统服务的调用,从而使内核执行打印操作。内核会通过字符显示子程序,根据传入的ASCII码到字模库读取字符对应的点阵,然后通过vram(显存)对字符串进行输出。

3)显示芯片将按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),最终实现printf中字符串在屏幕上的输出。

8.4 getchar的实现分析

  getchar函数调用了read函数,通过系统调用read读取存储在键盘缓冲区的ASCII码,直到读到回车符才返回。不过read函数每次会把所有内容读进缓冲区,如果缓冲区本来非空,则不会调用read函数,而是简单的返回缓冲区最前面的元素。

异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

本章主要介绍了hello的IO管理机制,先简述了IO设备被抽象为文件的现象,随后介绍了IO的设备管理方法——unix IO接口,随后对unixIO接口做了介绍之后,给出了Unix IO接口的相关函数,并在此基础上,对printf和getchar的视线分析做了介绍。

结论

1.hello所经历的过程:

首先由程序员将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 2021113211 郑文翔。

6)创建进程。终端判断输入的指令不是shell内置指令,于是调用fork函数创建一个新的子进程。

7)加载程序。shell调用execve函数,启动加载器,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入main函数。

8)执行指令:CPU为进程分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流。

9)访问内存:MU将程序中使用的虚拟内存地址通过页表映射成物理地址。

10)信号管理:当程序在运行的时候我们输入Ctrl+c,内核会发送SIGINT信号给进程并终止前台作业。当输入Ctrl+z时,内核会发送SIGTSTP信号给进程,并将前台作业停止挂起。

11)终止:当子进程执行完成时,内核安排父进程回收子进程,将子进程的退出状态传递给父进程。内核删除为这个进程创建的所有数据结构。

2.感悟

我的感悟是,计算机虽然让人难以理解,但是这正是它的迷人之处。究其原因,我们若对计算机的组成、程序的过程实现一知半解,计算机的谜团就越显复杂。但越难得到的,当真正得到了之后总是越发让人振奋,这正是学习计算机的乐趣,同样也是研读CSAPP和学习这门课程的乐趣所在。

不仅如此,服务于学习与生活的各种程序最终还是要靠底层的各个程序配合实现,尝试去理解这些底层对于编写出最适合所用机器的代码,进而完成更优质的应用程序来说必不可少。

附件

hello.c:C语言编写的源程序文件。

hello.i:hello.c经预处理得到的ASCII码的中间文件。

hello.s:hello.i编译之后得到的一个ASCII汇编语言文件。

hello.o:hello.s汇编之后得到的一个可重定位目标文件。

hello:hello.s和标准的C库进行链接得到的可执行目标文件。

hello.asm:hello.o反汇编得到的一个反汇编文件。

hello1.asm:hello反汇编得到的一个反汇编文件。

hello.elf:hello.o的elf格式文件。

hello1.elf:hello的elf格式文件。

参考文献

[1]   Randal E.Bryant David R.O'Hallaron.深入理解计算机系统(第三版).机械工业出版社,2016.

[2]   https://www.cnblogs.com/buddy916/p/10291845.html

[3]   https://www.cnblogs.com/pianist/p/3315801.html

[4]   https://www.cnblogs.com/fanzhidongyzby/p/3519838.html.

[5]   https://www.cnblogs.com/diaohaiwei/p/5094959.html

[6]  月光下的麦克 readelf指令使用.2023-0201 http://t.csdnimg.cn/mpcVG

[7]  长路漫漫2021 Shell和Bash的区别和联系 http://t.csdnimg.cn/VXp25

[8]  C汇编Linux手册 http://docs.huihoo.com/c/linux-c-programming

[9]  CMU的实验参考 http://csapp.cs.cmu.edu/3e/labs.html 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值