【2024年哈工大计算机系统课程大作业】程序人生-Hello‘s P2P

摘 要

本文介绍C语言程序设计课程中的经典案例——hello.c程序,从计算机系统的角度阐述其从源程序到可执行程序的转变,并探讨其在计算机系统中作为进程的运行过程。首先,源程序需要经过预处理、编译、汇编、链接等步骤,才能生成二进制可执行目标程序。其次,在运行程序的过程中,计算机系统的硬件组件,如处理器、I/O设备、主存等,与程序密切配合。同时在此过程中,也涉及到操作系统的进程调度和管理。本文将以hello.c为例,深入探讨其从程序到进程的演变过程,为读者提供对计算机系统运行程序的全面理解。

关键词:计算机系统;预处理;编译;汇编;链接;进程;存储;

目 录

第1章 概述 - 4 -

1.1 Hello简介 - 4 -

1.2 环境与工具 - 4 -

1.3 中间结果 - 4 -

1.4 本章小结 - 4 -

第2章 预处理 - 5 -

2.1 预处理的概念与作用 - 5 -

2.2在Ubuntu下预处理的命令 - 5 -

2.3 Hello的预处理结果解析 - 5 -

2.4 本章小结 - 6 -

第3章 编译 - 7 -

3.1 编译的概念与作用 - 7 -

3.2 在Ubuntu下编译的命令 - 7 -

3.3 Hello的编译结果解析 - 7 -

3.4 本章小结 - 10 -

第4章 汇编 - 11 -

4.1 汇编的概念与作用 - 11 -

4.2 在Ubuntu下汇编的命令 - 11 -

4.3 可重定位目标elf格式 - 11 -

4.4 Hello.o的结果解析 - 14 -

4.5 本章小结 - 17 -

第5章 链接 - 18 -

5.1 链接的概念与作用 - 18 -

5.2 在Ubuntu下链接的命令 - 18 -

5.3 可执行目标文件hello的格式 - 19 -

5.4 hello的虚拟地址空间 - 21 -

5.5 链接的重定位过程分析 - 22 -

5.6 hello的执行流程 - 24 -

5.7 Hello的动态链接分析 - 25 -

5.8 本章小结 - 26 -

第6章 hello进程管理 - 27 -

6.1 进程的概念与作用 - 27 -

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

6.3 Hello的fork进程创建过程 - 28 -

6.4 Hello的execve过程 - 28 -

6.5 Hello的进程执行 - 28 -

6.6 hello的异常与信号处理 - 30 -

6.7本章小结 - 32 -

第7章 hello的存储管理 - 33 -

7.1 hello的存储器地址空间 - 33 -

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

7.3 Hello的线性地址到物理地址的变换-页式管理 - 34 -

7.4 TLB与四级页表支持下的VA到PA的变换 - 34 -

7.5 三级Cache支持下的物理内存访问 - 35 -

7.6 hello进程fork时的内存映射 - 37 -

7.7 hello进程execve时的内存映射 - 37 -

7.8 缺页故障与缺页中断处理 - 38 -

7.9动态存储分配管理 - 38 -

7.10本章小结 - 39 -

第8章 hello的IO管理 - 40 -

8.1 Linux的IO设备管理方法 - 40 -

8.2 简述Unix IO接口及其函数 - 40 -

8.3 printf的实现分析 - 41 -

8.4 getchar的实现分析 - 42 -

8.5本章小结 - 42 -

结论 - 42 -

附件 - 44 -

参考文献 - 45 -

第1章 概述

1.1 Hello简介

1.1.1 P2P(Program to Process)

运行hello.c程序时,由编译器驱动程序启动,读取hello.c文件,然后进行预处理:得到预处理后的hello.i文件;之后由编译器对hello.i进行编译:得到一个汇编语言程序,即hello.s。之后将汇编程序交由汇编器进行汇编:得到一系列机器语言指令,并将这些机器语言指令打包成一种“可重定位目标程序”,并将其存入到hello.o(二进制)文件中。最后由链接器进行链接:结果就得到了可执行目标文件:hello。接下来计算机就可以运行这个hello文件了。之后在计算机的Bash(shell)中,OS会为hello创建子进程(fork),这样,在计算机系统中,hello就有了自己独一无二的进程(Process),在这个进程中hello便可以运行。

1.1.2 从零到零(020)

程序从无到有,通过编写、编译、链接等步骤最终形成可执行文件(从零到一),执行后进程终止,资源被回收(从一回到零),整个过程再次回到初始状态。

1.2 环境与工具

1.2.1硬件环境

X64 CPU; 2GHz; 16G RAM; 512GHD Disk

1.2.2 软件环境

Windows10 64位; Vmware 14; Ubuntu20.04

1.2.3 开发工具

Visual Studio Code; vi/vim/gpedit+gcc

1.3 中间结果

hello.c源程序
hello.i预处理后的修改的C程序
Hello.s汇编程序
Hello.o可重定位目标文件
hello可执行目标文件
Objdump_hello.ohello.o反汇编文件
Objdump_hellohello的反汇编文件
elf_hellohello的ELF格式
elf_hello.ohello.o的ELF格式

1.4 本章小结

对P2P和020的有了大概了解,同时大致了解了后续需要做的工作。

第2章 预处理

2.1 预处理的概念与作用

2.1.1 概念

预处理指的是程序在编译之前进行的处理,是计算机在处理一个程序时所进行的第一步处理,可以进行代码文本的替换工作,但是不做语法检查。 预处理是为编译做的准备工作,能够对源程序.c 文件中出现的以字符“#”开头的命令进行处理,包括宏定义# define 、文件包含# include 、条件编译# if def 等,最后将修改之后的文本进行保存,生成.i 文件,预处理结束。

2.1.2 作用

对代码进行文本替换和宏扩展,从而生成最终的源代码文件,然后再由编译器进行编译。提高代码的复用性,使得代码更加清晰,便于维护和调试。

比如,通过#define指令定义宏。例如,#define PI 3.14,宏定义在预处理的过程中会进行宏替换。预处理器在编译前会将所有出现的宏名称替换为其定义的值或代码。使用#include指令可以将另一个文件的内容插入到当前文件中。可用于包含头文件(.h文件),从而共享函数声明、宏定义等代码片段。#include <stdio.h>会将标准输入输出库的头文件内容插入到当前文件中。#if、#ifdef、#ifndef、#else、#elif和#endif指令,可以有选择地编译代码的某些部分。

2.2在Ubuntu下预处理的命令

cpp hello.c >hello.i

2.3 Hello的预处理结果解析

相对于源程序,hello.i中的代码量剧增。这种剧增是因为#预处理命令将头文件的程序、宏变量、特殊符号等插入到代码中。原来的C代码在文本的最末端。

在main函数之前,预处理器就分别读取stdio.h、unistd.h、stdlib.h中的内容,并且根据读入的顺序依次进行内容的展开。如果头文件中仍然有以字符“#”开头的内容,则预处理器继续对其进行处理,最终的hello.i文件中没有宏定义。

2.4 本章小结

本节阐述了预处理的概念和作用。并亲手在Ubuntu执行了预处理命令,生成了hello.i文件,查看hello.i文件,更好地理解了预处理的概念和作用。

第3章 编译

3.1 编译的概念与作用

3.1.1 编译的概念

编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。

3.1.2 编译的作用

在编译阶段中,gcc首先要检查代码的规范性、是否有语法错误等,以确定代码的实际要做的工作,在检查无误后,gcc把代码翻译成汇编语言。在编译阶段,编译器还能起到优化的作用,优化处理是编译系统中一项比较艰深的技术。它涉及到的问题不仅同编译技术本身有关,而且同机器的硬件环境也有很大的关系。

3.2 在Ubuntu下编译的命令

gcc -S hello.i -o hello.s

3.3 Hello的编译结果解析

3.1.1 数据

3.1.1.1常量

字符串常量,位于只读数据段(.rodata)

3.1.1.2变量(全局/局部/静态)

无全局与静态变量。

局部变量

局部变量通常使用栈指针(%rsp)和基址指针(%rbp)进行访问。在此段汇编代码中,可以看到通过基址指针(%rbp)来访问局部变量。

-20(%rbp):此偏移量用于存储从main函数参数argc传入的值。汇编代码中通过movl %edi, -20(%rbp)存储参数argc的值。

-32(%rbp):此偏移量用于存储从main函数参数argv传入的值。汇编代码中通过movq %rsi, -32(%rbp)存储参数argv的值。

-4(%rbp):存储局部变量i,用于在循环中计数。汇编代码中通过movl $0, -4(%rbp)初始化该变量。

通过addl $1, -4(%rbp)递增该变量。

3.1.1.3表达式

.c中的表达式argc!=5,在.s文件中表示为

i<10表示为

3.1.1.4 类型

类型的解析通过汇编指令的选择和操作数的大小来体现。根据变量类型使用不同的mov指令。

3.1.1.5 宏

无宏,若有宏,所有的宏替换也都在预处理阶段完成。

3.1.2赋值

将参数的值从寄存器(%edi 和 %rsi)移动到了相对于基址指针 %rbp 的栈上的位置(偏移 -20 和 -32 处)。

赋值0给i

3.1.3算术操作

i++由addl $1,-4(%rbp)完成.

3.1.4关系操作

i<10: 并没有用10,而是用9在比较,若等于九后则跳转到下部分代码不再循环。

3.1.5数组/指针/结构操作

main接收的argv[]数组。

3.1.6控制转移

if:若i不满足小于等于9,则跳出循环(leave)。

3.1.7函数操作

3.1.7.1 参数传递(地址/值)

3.1.7.2 函数调用

通过call指令调用,如图,分别调用了头文件提供的printf, sleep, getchar函数。

3.1.7.3 函数返回

ret 指令用于将程序的控制权返回到调用该函数的位置,并且通常在函数的结尾处使用。指令 .cfi_endproc 表示这是一个函数结束的标记,它用于通知调试器和其他工具函数的结束位置。

3.4 本章小结

本章hello.i -> hello.s,直观地看到了编译的结果,并将起与C源程序的代码结合起来,理解汇编语言发挥的作用,以过往的实验经历,也可以很熟练地将汇编代码与对应的C语言代码对照。 第4章 汇编

4.1 汇编的概念与作用

4.1.1 概念

汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在.o文件中。.o是一个二进制文件,它包含的17个字节是函数main的指令编码。

4.1.2 作用

1.底层控制:汇编语言可以直接操作计算机的硬件,包括CPU、内存、输入输出设备等。因此,它在对计算机进行底层控制和操作时非常有用。

2.性能优化:由于汇编语言直接操作硬件,可以更加精细地控制程序的执行过程,从而实现对程序的性能优化。

4.2 在Ubuntu下汇编的命令

as -o hello.o hello.s

4.3 可重定位目标elf格式

ELF(Executable and Linkable Format)文件是一种常见的可执行文件和可链接文件格式,用于在Unix和类Unix系统上存储程序、库和其他相关数据。包括文件头、程序头表、节表、节、重定位表、符号表。

4.3.1 文件头

首先查看文件头(ELF Header)。其包含了描述ELF文件整体结构和属性的信息,包括ELF标识、目标体系结构、节表偏移、程序头表偏移等。

4.3.2 程序头表

对于目标文件,程序头表(Program Header Table)可能为空。查询后确实如此。

4.3.3 节表(Section Header Table)

节表描述了ELF文件中各个节的信息,包括节的名称、类型、偏移、大小等。ELF文件的数据和代码通常存储在各个节中,比如.text节存储代码段、.data节存储数据段等。其中.text和.data是我们在汇编程序中声明的Section,而其它Section是汇编器自动添加的。

之后用readelf -a hello.o探查ELF文件中能探查的其他节,

.rela.text 包含 8 个条目。以下是这些条目的详细信息:

重定位节 .rela.eh_frame 记录了需要进行地址重定位的异常处理框架(exception handling frame)信息。以下是查看 .rela.eh_frame 的输出。

符号表 .symtab 是目标文件中非常重要的一部分,它列出了所有定义的符号,包括函数、变量和节。符号表在链接和调试过程中起着至关重要的作用。以下是符号表 .symtab 的具体内容和解释。

.note.gnu.property 节包含了一些描述目标文件属性的注释,特别是与特定平台或编译器相关的属性。

4.4 Hello.o的结果解析

objdump -d -r hello.o

结果:

对照分析:

每行代码末尾指令基本相同,但在每条指令前面都会有一串十六进制的编码。hello.s是由汇编语言组成的,相对于计算机能识别的机器级指令,汇编代码仍是抽象语言;而反汇编得到的代码不仅仅有汇编代码,还有机器语言代码。机器语言代码是计算机可识别执行的,是一种纯粹的二进制编码。

分支转移时,.s文件中会跳转到诸如.L4的代码段,像这样。

而反汇编文件中,则是直接跳转到当前过程的起始地址加上偏移量得到的直接目标代码地址。

函数调用时,汇编文件中,call指令直接调用函数,call后紧跟函数的名字

反汇编文件中,86: 重定位条目所影响的地址偏移量,这是 call 指令中地址字段的起始位置。R_X86_64_PLT32: 重定位类型,这里是 R_X86_64_PLT32,表示这是一个 32 位的 PLT(Procedure Linkage Table)重定位。PLT 用于延迟绑定函数调用,在运行时解析函数地址。atoi-0x4: 表示重定位的目标符号是 atoi,加数是 -4。call 指令执行时,会跳转到由重定位条目所指示的目标地址。重定位类型 R_X86_64_PLT32 指示链接器,地址字段需要被填充为指向 PLT 表项的偏移,这个表项在运行时会解析为 atoi 函数的实际地址。atoi-0x4 中的 -0x4 是因为 call 指令的目标地址是相对于下一条指令的,所以需要减去 4 个字节来调整指令位置。

4.5 本章小结

本章通过将.s汇编为.o文件,了解从汇编程序到可重定位目标程序(二进制)的过程。同时通过查看ELF表,查看了其中的各项内容。又将.o反汇编,通过和汇编程序相对比,了解了他们的不同,也了解了机器代码的逻辑。

第5章 链接

5.1 链接的概念与作用

5.1.1 概念

链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以用于编译时,即将源代码翻译为机器码时,加载时,即程序被加载器加载到内存并执行时,还可执行于运行时,也就是用应用程序执行。现代系统中,链接是由叫做链接器(linker)程序自动执行的。

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

5.3.1 ELF头信息

5.3.2 节头

描述了各个节的大小、起始位置和其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。

命令:readelf -S hello

5.3.3 程序头

5.4 hello的虚拟地址空间

打开edb,通过 data dump 查看加载到虚拟地址的程序代码。查看 ELF 格式文件中的程序头,它告诉链接器运行时加载的内容,并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的各方面的信息。在下面可以看出,程序包含PHDR,INTERP,LOAD ,DYNAMIC,NOTE ,GNU_STACK几个部分,PHDR 保存程序头表。INTERP 指定在程序已经从可执行文件映射到内存之后,必须调用的解释器。LOAD 表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据、程序的目标代码等。DYNAMIC 保存了由动态链接器使用的信息。NOTE 保存辅助信息。GNU_STACK:权限标志,用于标志栈是否是可执行。与5.3对照,我们可以根据5.3中每一节对应的起始地址在edb中找到响应信息。如下图所示:

5.5 链接的重定位过程分析

hello反汇编文件中,每行指令都有唯一的虚拟地址,而hello.o的反汇编没有,只是相对于代码段(通常是 .text 段)的偏移地址。这是因为目标文件只是一个中间产物,还没有被链接到最终的内存地址空间。这是因为hello经过链接,已经完成重定位,每条指令分配了唯一的虚拟地址,每条指令的地址关系已经确定;

Hello:

Hello.o:

Hello:

Hello已经完成链接,故其反汇编的地址关系已经确定,直接给出即可。

Hello.o

Hello.o并没有链接,所以需要告诉链接器(linker)在链接时需要执行的动作。

例如,6f: R_X86_64_PLT32 printf-0x4告诉链接器(linker)在链接时需要执行的动作。6f 是一个字节偏移量,指示了在某个特定位置发生了重定位动作。

R_X86_64_PLT32 是一个重定位类型(relocation type),表示这是一个32位的重定位项。printf-0x4 表示需要修改的目标符号是 printf,并且要在链接时将其地址减去 0x4。

5.6 hello的执行流程

使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址。

edb查看如下:

程序地址程序名
0x0000000000401100hello!start
0x0000000000401125hello!main
0x0000000000401000hello!_init
0x0000000000401140hello!_fini

5.7 Hello的动态链接分析

在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定(lazybinding),将过程地址的绑定推迟到第一次调用该过程时。延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:

PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。

GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。[1]

.got节用于动态链接。它是一个包含了所有全局变量和函数的地址表。当程序运行时,动态链接器会更新这个表,以便程序可以正确地访问这些全局变量和函数。它的主要目的是支持位置无关代码(Position-Independent Code, PIC),使得代码可以在不同内存地址加载而不需要重新编译。

.got.plt节与.got类似,但专门用于延迟绑定(Lazy Binding)的函数调用。延迟绑定是一种优化技术,只有在函数第一次调用时才进行符号解析和重定位。在函数第一次调用后,.got.plt中的条目会更新为实际的函数地址,后续调用会直接跳转到该函数,提高了运行时性能。

5.8 本章小结

本章通过分析ELF文件和edb调试以及各种比较,逐步探寻了链接的过程,分析了链接前后程序的异同,在动态链接分析中,我也更熟悉了延迟绑定和其具体作用机理。使我更深入地了解了链接的作用以及其重要性。

第6章 hello进程管理

6.1 进程的概念与作用

6.1.1 概念

一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。

6.1.2 作用

进程提供给应用程序的关键抽象:一个独立的逻辑控制流;一个私有的地址空间。通过逻辑控制流和私有地址空间的抽象,进程提供给用户一种假象:就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接着一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。

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

6.2.1 作用

shell是一个交互型应用级程序,代表用户运行其他程序。如Windows下的命令行解释器,cmd、powershell,图形界面的资源管理器。Linux下的Terminal/tcsh、bash等等,也包括图形化的GNOME桌面环境。Shell是信号处理的代表,负责各进程创建与程序加载运行及前后台控制,作业调用,信号发送与管理等。

6.2.2 处理流程

1. Shell首先从命令行中找出特殊字符(元字符),在将元字符翻译成间隔符 号。元字符将命令行划分成小块tokens。Shell中的元字符如下所示:

SPACE , TAB , NEWLINE , & , ; , ( , ) ,< , > , |

2.程序块tokens被处理,检查看他们是否是shell中所引用到的关键字。

3.当程序块tokens被确定以后,shell根据aliases文件中的列表来检查命令 的第一个单词。如果这个单词出现在aliases表中,执行替换操作并且处理过程 回到第一步重新分割程序块tokens。

4.Shell对~符号进行替换。

5.Shell对所有前面带有$符号的变量进行替换。

6. Shell将命令行中的内嵌命令表达式替换成命令;他们一般都采用$(command) 标记法。

7.Shell计算采用$(expression)标记的算术表达式。

8.Shell将命令字符串重新划分为新的块tokens。这次划分的依据是栏位分割 符号,称为IFS。缺省的IFS变量包含有:SPACE , TAB 和换行符号。

9.Shell执行通配符* ? [ ]的替换。

10.shell把所有从处理的结果中用到的注释删除,并且按照下面的顺序实行命令的检查:

A.内建的命令

B. shell函数(由用户自己定义的)

C.可执行的脚本文件(需要寻找文件和PATH路径)

11.在执行前的最后一步是初始化所有的输入输出重定向。

12.执行命令。

6.3 Hello的fork进程创建过程

输入合法命令,如./hello 李满园 2022113064 6,解析后判断为执行程序,父进程就通过fork函数创建一个新的运行的子进程;子进程得到与父进程用户级虚拟地址空间相同的一份副本,包括代码段、数据段、堆、共享库和用户栈。子进程中,fork返回0,父进程中,返回子进程的PID;

新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程虚拟地址空间相同的但是独立的一份副本,子进程获得与父进程任何打开文件描述符相同的副本,最大区别是子进程有不同于父进程的PID;

当我们运行hello程序时,在shell中输入./hello,此时OS就会fork创建一个子进程来运行这一程序。

6.4 Hello的execve过程

在子进程中,操作系统会调用 execve() 系统调用,将 hello 程序加载到子进程的地址空间中。 int execve(const char * filename, char * const argv[ ], char * const envp[ ])。execve() 系统调用需要提供三个参数:可执行文件的路径、命令行参数数组和环境变量数组。该函数成功运行正确运行时不返回。逻辑控制流交给要运行的程序,即操作系统根据路径加载 hello 程序的可执行文件到子进程的地址空间中。

6.5 Hello的进程执行

逻辑进程如图所示。

当在 shell 中运行./hello 程序时,操作系统会经历一系列步骤来创建和调度新进程。

1. 解析路径

操作系统解析./hello的路径,定位到可执行文件。

2. 创建新进程

父进程通过fork()系统调用创建一个新的子进程。此时,父进程fork() 返回子进程的 PID。子进程fork()返回 0。

3. 进程上下文信息

每个进程都有自己的上下文信息,包括寄存器状态、进程控制块(PCB)、虚拟内存映射等。新创建的子进程继承了父进程的上下文信息,但它们是独立的副本。

4. 从用户态到核心态的转换

fork()调用导致从用户态到核心态的转换,因为fork()是一个系统调用,需要操作系统内核的参与来创建新进程。

5. 加载程序

子进程调用execve()系统调用,加载hello程序。此时,子进程的地址空间被新程序的代码、数据等覆盖。操作系统将新程序的入口点设置为新进程的下一条指令。

6. 进程调度

操作系统的调度器决定哪个进程应该运行。调度器根据调度算法和策略(如时间片轮转、优先级调度等)分配 CPU 时间。每个进程在 CPU 上运行一段时间称为一个时间片。时间片结束后,调度器可能会切换到另一个进程。当时间片结束或有更高优先级的进程需要运行时,调度器保存当前进程的上下文,并加载下一个要运行的进程的上下文。

7. 用户态与核心态转换

进程在执行用户代码(如hello程序的代码)时运行在用户态。当进程执行系统调用(如execve()、read()等)时,会从用户态切换到核心态。时间片结束或 I/O 事件发生时,中断会触发从用户态到核心态的切换,操作系统处理中断并可能进行进程调度。

8. 执行hello程序

一旦execve()成功执行,子进程开始运行hello程序的代码:

hello程序开始在用户态执行。如果hello程序进行I/O操作(如打印输出),会通过系统调用进入核心态。

9. 程序执行完成

当hello程序执行完毕(如main函数返回),进程会通过exit()系统调用通知操作系统它已执行完成。exit()系统调用从用户态切换到核心态。操作系统清理子进程的资源,并向父进程发送终止信号。

10. 父进程处理子进程终止

父进程可以通过wait()或waitpid()系统调用等待子进程结束,并获取子进程的终止状态。

6.6 hello的异常与信号处理

hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。

按下Crtl+Z,进程收到SIGSTP信号,hello进程挂起并向父进程发送SIGCHLD。

运行ps命令查看进程运行状态。

运行jobs命令:

可以看到停止的作业。

Pstree:

显示所有运行中的进程的树状图。

Kill:

可以看到进程被终止。

Ctrl+C发送SIGINT信号,Hello进程被终止。

6.7本章小结

本章介绍了进程的概念和作用,观察了hello进程的创建,执行,终止以及各个命令的执行,如进程树,ps等。

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:由程序产生的与段相关的偏移地址部分。一个逻辑地址由两部份组成,段标识符和段内偏移量。页式存储器的逻辑地址由两部分组成:页号和页内地址。[段标识符 : 段内偏移地址] 的表示形式,其中的段内偏移地址就是指逻辑地址;当我们调用printf函数时,编译器会生成一个调用printf函数的指令,这个指令中的地址就是一个逻辑地址。

线性地址:是逻辑地址到物理地址变换之间的中间层。hello的代码产生的段中的偏移地址,加上相应段的基地址构成一个线性地址。Hello.o反汇编中每个函数可见这种表示方式。

虚拟地址是线性地址经过分页机制转换后得到的地址。操作系统使用页表将线性地址映射到虚拟地址。虚拟地址空间允许每个进程有自己独立的地址空间,从而提高了安全性和稳定性。当hello程序运行时,操作系统为其分配一个虚拟地址空间。Hello反汇编中可以看到都有固定的地址。

物理地址:物理地址是最终的内存地址,即实际的硬件内存地址。虚拟地址通过页表映射到物理地址,CPU通过内存管理单元(MMU)完成这个转换。例如,当hello程序调用printf函数时,虚拟地址通过页表转换为物理地址,CPU最终访问这个物理地址来执行函数。

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

被选中的段描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址。

在x86保护模式下,段的信息(段基线性地址、长度、权限等)即段描述符占8个字节,段信息无法直接存放在段寄存器中(段寄存器只有2字节)。Intel的设计是段描述符集中存放在GDT或LDT中,而段寄存器存放的是段描述符在GDT或LDT内的索引值(index)即段选择符。

在CPU中,跟段有关的CPU寄存器一共有6个:cs,ss,ds,es,fs,gs,它们保存段选择符。而同时这六个寄存器每个都有一个对应的非编程寄存器,保存的对应段描述符。

段描述符就是保存在全局描述符表或者局部描述符表中,当某个段寄存器试图通过自己的段选择符获取对于的段描述符时,会将获取到的段描述符放到自己的非编程寄存器中,这样就不用每次访问段都要跑到内存中的段描述符表中获取。

包括数据段描述符、代码段描述符等。

分段机制将逻辑地址转化为线性地址的步骤:

1)使用段选择符中的偏移值(段索引)在GDT或LDT表中定位相应的段描述符.(仅当一个新的段选择符加载到段寄存器中是才需要这一步)

2)利用段选择符检验段的访问权限和范围,以确保该段可访问。

3)把段描述符中取到的段基地址加到偏移量(也就是上述汇编语言汇中直接出现的操作地址)上,最后形成一个线性地址。

7.3 Hello的线性地址到物理地址的变换-页式管理

7.3.1 分页机制

分页机制将虚拟地址空间(也称为线性地址空间)划分为固定大小的页(通常是4KB)。操作系统维护一个页表,将虚拟地址空间中的页映射到物理内存中的页框。

7.3.2 地址转换过程

(1)地址分解

分页机制将线性地址分解为页表项(Page Table Entry, PTE)和页内偏移量。

对于一个32位的地址(假设页大小为4KB),地址分为三部分:目录项、页表项和页内偏移量。目录项为高10位,页表项为中间10位,页内偏移量为低12位。

(2)页目录和页表查找

操作系统维护一个页目录,每个进程有一个独立的页目录。页目录包含指向多个页表的指针。根据目录项查找页目录,得到对应的页表地址。在对应的页表中,根据页表项查找,得到物理页框地址。

(3)页框和页内偏移量

物理页框地址结合页内偏移量,得到物理地址。

7.4 TLB与四级页表支持下的VA到PA的变换

在现代操作系统中,地址转换过程中除了页表机制外,还使用了转换后备缓冲(Translation Lookaside Buffer, TLB)来加速虚拟地址(VA)到物理地址(PA)的转换。下面我们详细说明TLB和四级页表机制下虚拟地址到物理地址的转换过程。

7.4.1 TLB

TLB是一种高速缓存,用于存储最近使用的虚拟地址到物理地址的映射。通过TLB,可以避免每次地址转换都进行多级页表查找,从而加速地址转换过程。

7.4.2 四级页表机制

四级页表机制将虚拟地址转换为物理地址时,通过四级页表结构进行映射。假设我们使用64位地址空间,地址分解如下:

Page Map Level 4 (PML4):PML4是四级页表的顶层,每个进程有一个PML4表。

Page Directory Pointer Table (PDPT):PDPT是第二级页表,它的每个条目指向一个Page Directory (PD)。

Page Directory (PD):PD是第三级页表,它的每个条目指向一个Page Table (PT)。

Page Table (PT)::PT是第四级页表,它的每个条目指向一个物理页框。

每一级页表大小为512项,每项指向下一级页表或物理页。

64位虚拟地址分为以下几部分:PML4 索引高9位,PDPT 索引下一个9位,PD 索引再下一个9位,PT 索引最后一个9位,页内偏移12位

7.4.3 TLB和四级页表结合的地址转换过程

(1)从虚拟地址(VA)提取各级索引和页内偏移

假设虚拟地址为 VA,PML4 索引位于高9位,PDPT 索引位于下一个9位,PD 索引位于再下一个9位,PT 索引为最后一个9位,页内偏移& 0xFFF为低12位。

(2)CPU首先在TLB中查找虚拟地址的映射。如果命中(TLB hit),则直接使用缓存的物理地址。如果未命中(TLB miss),则需要进行页表查找。

(3)四级页表查找(在TLB miss的情况下):

使用PML4索引在PML4表中查找,找到对应的PDPT表地址。使用PDPT索引在PDPT表中查找,找到对应的PD表地址。使用PD索引在PD表中查找,找到对应的PT表地址。使用PT索引在PT表中查找,找到物理页框地址。

(4)计算物理地址:

物理地址 = 物理页框地址 + 页内偏移

7.5 三级Cache支持下的物理内存访问

在现代计算机系统中,为了提高内存访问的速度,通常会使用多级缓存(Cache)。

7.5.1 缓存层级结构

(1)一级缓存(L1 Cache)

位置:最接近CPU核心,通常分为两个部分:指令缓存(L1i)和数据缓存(L1d)。

大小:通常较小(几KB到几十KB)。

速度:非常快,延迟通常在1到3个时钟周期。

(2)二级缓存(L2 Cache)

位置:紧接L1缓存,可能是每个CPU核心独有,也可能是每两个核心共享。

大小:比L1大(几百KB到几MB)。

速度:稍慢于L1缓存,延迟通常在10到20个时钟周期。

(3)三级缓存(L3 Cache)

位置:通常为整个处理器共享,所有核心都可以访问。

大小:较大(几MB到几十MB)。

速度:慢于L2缓存,延迟通常在几十到上百个时钟周期。

7.5.2 缓存访问过程

当CPU需要访问某个物理地址时,三级缓存架构的访问过程如下:

(1)CPU发出内存访问请求

CPU生成一个物理地址来访问数据(假设地址为PA)。

(2)L1缓存查找

CPU首先在L1缓存中查找PA。如果命中(hit),L1缓存返回数据给CPU,访问结束。如果未命中(miss),请求发送到L2缓存。

(3)L2缓存查找

在L2缓存中查找PA。如果命中(hit),L2缓存返回数据给CPU,并且可能将数据复制到L1缓存。如果未命中(miss),请求发送到L3缓存。

(3)L3缓存查找

在L3缓存中查找PA。如果命中(hit),L3缓存返回数据给CPU,并且可能将数据复制到L2和L1缓存。如果未命中(miss),请求发送到主内存(DRAM)。

(4)内存访问

在L3缓存未命中的情况下,访问请求发送到主内存。主内存返回数据给L3缓存,并且可能复制到L2和L1缓存。最终,数据从L1缓存返回给CPU。

7.5.3 缓存一致性

为了确保多核处理器中所有核心对内存的一致视图,通常采用缓存一致性协议(如MESI、MOESI)。这些协议管理缓存之间的数据一致性,确保当一个核心修改缓存中的数据时,其他核心能够看到最新的数据。

7.6 hello进程 fork时的内存映射

当一个进程调用fork()时,操作系统会创建一个新的进程,这个新进程被称为子进程。子进程几乎完全复制了父进程的虚拟地址空间,包括代码段、数据段、堆、栈等。现代操作系统使用“写时复制”(Copy-On-Write, COW)技术来优化这个过程。下面我们详细讨论fork()时内存映射的过程。

7.6.1 fork()时的内存映射

(1)创建子进程:操作系统为子进程创建一个新的进程控制块(PCB),并将父进程的所有资源(文件描述符、内存映射等)复制到子进程。

(2)虚拟地址空间复制:子进程获得父进程的完整虚拟地址空间的副本,但是这时候并不立即复制实际的物理内存。

(3)写时复制(COW):父进程和子进程共享相同的物理内存页,直到有一个进程试图修改某个页。只有在此时,操作系统才会复制该页。

7.6.2 写时复制(COW)具体过程

fork()调用时,子进程会继承父进程的虚拟地址空间,但并不会立即复制所有物理内存。通过写时复制技术,父子进程共享相同的物理页,直到有写操作发生时,才会分配新的物理页。这样可以高效地管理内存,并减少 `fork()` 的开销。这种机制对于创建新进程和高效利用内存资源非常重要。

(1)写操作触发页错误:尝试写入只读页会触发页错误。

(2)操作系统处理页错误:操作系统检测到这是写时复制情况。

(3)复制物理页:操作系统为写入操作分配一个新的物理页,并将旧页的数据复制到新页。

(4)更新页表:更新进程的页表,使得该虚拟页映射到新的物理页,并将页标记为可写。

(5)完成写操作:进程继续执行写操作。

7.7 hello进程execve时的内存映射

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

pathname新程序的路径。

argv[]:传递给新程序的命令行参数。

envp[]:传递给新程序的环境变量。

1、 输入 ./hello lmy 2022113064 3

2、 execve加载hello程序后,设置栈,将控制传递给hello程序的主函数。

3、 删除已存在的用户区域

4、 映射新的私有区域。代码和初始化数据映射到.text和.data区(执行可执行文件提供),.bss映射到匿名文件,共享对象由动态链接映射到本进程共享区域,设置PC,指向代码区域的入口点。栈中从栈底到栈顶是参数和环境字符串,再往上是指针数组,每个指针指向刚才的环境变量和参数字符串。栈顶是系统启动函数libc_start_main的栈帧和预留的未来函数的栈帧。

当hello进程调用execve()时,整个进程的内存映射发生了彻底的变化。旧的地址空间被清除,新的程序被加载到地址空间中。通过这种方式,execve()可以在当前进程的上下文中运行一个新的程序,而不需要创建新的进程。这样不仅节省了资源,还允许新程序继承当前进程的许多属性,如进程ID、环境变量等。该函数成功运行正确运行时不返回。逻辑控制流交给要运行的程序

7.8 缺页故障与缺页中断处理

7.8.1 缺页故障(Page Fault)

虚拟内存在DRAM缓存不命中即为缺页故障。

7.8.2 缺页中断处理

缺页中断处理:触发缺页异常时启动缺页处理程序

1、缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。

2、缺页处理程序页面调入新的页面,并更新内存中的PTE

3、缺页处理程序返回到原来的进程,再次执行导致缺页的命令。

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的具体例子中查看了程序执行的内存管理,查看了存储器从逻辑地址到线性地址到物理地址的变换。也了解了cache、动态存储分配管理等机制。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

一个Linux文件就是一个m个字节的序列:

B0, B1, ..., Bk, ..., Bm-1

设备管理:unix io接口

所有的I/O 设备(例如网络、磁盘和中断)都被模型化为文件,而所有的输入和输出都被当作相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。

8.2 简述Unix IO接口及其函数

8.2.1 Unix I/O接口

Unix I/O接口,使得所有的输入和输出都能以一种统一且一致的方式来执行:

(1)打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件;Linux shell创建的每个进程开始时都有三个打开的文件,标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2);

(2)改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0;

(3)读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n;类似地,写操作就是从内存复制n>0个字节到文件,从当前文件位置k开始,然后更新k;

(4)关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。

8.2.2 Unix I/O函数

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

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

open函数将filename转换为一个文件描述符,并且返回描述符数字;flags参数也可以是一个或者更多位掩饰的或,为写提供给一些额外的指示;mode参数指定了新文件的访问权限位。

  1. close函数

进程通过调用close函数关闭一个打开的文件。

(3)read函数

应用程序是通过分别调用read来执行输入,

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

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

  1. write函数

    应用程序是通过调用write函数来执行输出。

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

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

(4)lseek函数

通过调用lseek函数,应用程序能都显示地修改当前文件的位置。

8.3 printf的实现分析

printf函数为va_list arg = (va_list)((char*)(&fmt) + 4);

typedef char *va_list是一个字符指针。(char*)(&fmt) + 4) 表示的是...中的第一个参数。因为 fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。

(1)从vsprintf生成显示信息,

i = vsprintf(buf, fmt, arg); vsprintf返回的是要打印出来的字符串的长度。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。

(2)write系统函数

write(buf, i);write函数的功能就是执行一个写操作。以我们学过的知识可知,写操作是计算机的底层操作,是对计算机硬件进行的操作。通过中断门,来实现特定的系统服务。

write:

mov eax, _NR_write

mov ebx, [esp + 4]

mov ecx, [esp + 8]

int INT_VECTOR_SYS_CALL

  1. 陷阱-系统调用

    init_idt_desc(INT_VECTOR_SYS_CALL,DA_386IGate,sys_call,PRIVILEGE_USER);调用sys_call显示格式化了的字符串。

  2. 字符显示驱动子程序

    从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

(6)显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

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

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。getchar 调用 fgetc(stdin),fgetc 尝试从 stdin 的缓存区读取一个字符。如果缓存区为空,则调用 _uflow。__uflow 调用 _underflow 来填充缓存区。

__underflow 调用 read 系统调用,从标准输入读取数据到缓存区。数据被读入缓存区后,返回给 fgetc,然后 getchar 返回读取的字符。

8.5本章小结

本章简述IO设备管理方法及Unix I/O函数。同时分析了printf和getchar两个函数的实现,对Linux环境下函数的执行进行了详细叙述。

(第8章1分)

结论

(1)hello.c在预处理之后,将头文件的内容插入到程序文本中,得到hello.i;

(2)编译器对hello.i进行编译,从而得到汇编文件hello.s;

(3)经过汇编器汇编,得到与汇编语言一一对应的机器语言指令,在汇编之后,得到了可重定位目标文件hello.o,其是一个二进制文件;

(4)链接器对hello.o中调用函数的指令进行重定位,将调用的系统函数如printf.o等链接到hello.o,得到可执行目标文件hello;

(5)在计算机运行hello。首先在shell-Bash中输入符合要求的语句,运行hello的命令行./hello lmy 2022113064 3,OS就fork()为hello创建一个子进程,hello就在这个进程当中运行;

(6)运行时,首先,在hello中的地址为虚拟地址,要经历虚拟地址映射为线性地址,线性地址映射到物理地址,才能对该地址进行操作;

(7)hello程序正常运行,输出结果,过程中通过文件管理I/O设备;

(8)最后由shell父进程回收终止的hello进程。

感想:hello是学习c语言时接触的第一个程序,在没有了解计算机系统时,觉得他非常简单。但在做这次大作业时,从编译到汇编到链接到之后种种,一个小小的hello居然要经过这么多道关卡,最后才能变成最后呈现给我们在屏幕上的短短一行,再结合平时课上所学,让我不禁感叹计算机系统的精妙。在未来的学习与工作中,我也将不忘在计算机系统学到的知识,继续探索,更深入的了解计算机。 附件

列出所有的中间产物的文件名,并予以说明起作用。

hello.c 源程序

hello.i 预处理后的修改的C程序

hello.s 汇编程序

hello.o 可重定位目标文件

hello 可执行目标文件

Objdump_hello.o hello.o的反汇编文件

Objdump_hello hello的反汇编文件

elf_hello hello的ELF格式

elf_hello.o hello.o的ELF格式

参考文献

[1] Bryant, Randal E., and David R. O'Hallaron. "Computer Systems: A Programmer's

Perspective." Pearson, 2016.

[2] https://github.com.

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

  • 33
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值