关注

程序人生-Hello’s P2P

计算机系统原理

大作业

题    目  程序人生-Hellos P2P  

专    业     AI先进技术领军班    

学 号        

班    级        

学    生        

指 导 教 师           史先俊          

计算学部

2025年9月

摘  要

本文以C语言程序hello.c为载体,深入研究分析了计算机系统内部的运行机制。报告详细追踪了hello程序从“代码”到“程序”,再从“程序”到“进程”(P2P,Program to Process)直至结束的完整生命周期(020,Zero to Zero)。本文分章节阐述了预处理、编译、汇编、链接的一整套构建过程,以及进程管理、存储管理和I/O管理的运行时机制。通过对GCC、GDB、Objdump等工具的实际操作与分析,揭示了Linux系统下程序执行的底层原理,展示了硬件与软件、用户态与内核态的协同工作逻辑。本文通过具体的实例,亲身“经历”了hello程序的一生,将计算机系统抽象的概念具体化,有助于深入理解计算机系统的设计与实现。

关键词:P2P;020;预处理;编译;汇编;链接;进程管理;虚拟内存;I/O管理                            

目  录

第1章 概述

1.1 Hello简介

1.2 环境与工具

1.3 中间结果

1.4 本章小结

第2章 预处理

2.1 预处理的概念与作用

2.2在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

2.4 本章小结

第3章 编译

3.1 编译的概念与作用

3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.4 本章小结

第4章 汇编

4.1 汇编的概念与作用

4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

4.4 Hello.o的结果解析

4.5 本章小结

第5章 链接

5.1 链接的概念与作用

5.2 在Ubuntu下链接的命令

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

5.4 hello的虚拟地址空间

5.5 链接的重定位过程分析

5.6 hello的执行流程

5.7 Hello的动态链接分析

5.8 本章小结

第6章 hello进程管理

6.1 进程的概念与作用

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

6.3 Hello的fork进程创建过程

6.4 Hello的execve过程

6.5 Hello的进程执行

6.6 hello的异常与信号处理

6.7本章小结

第7章 hello的存储管理

7.1 hello的存储器地址空间

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

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

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

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

7.6 hello进程fork时的内存映射

7.7 hello进程execve时的内存映射

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

7.9动态存储分配管理

7.10本章小结

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

8.2 简述Unix IO接口及其函数

8.3 printf的实现分析

8.4 getchar的实现分析

8.5本章小结

结论

附件

参考文献


第1章 概述

1.1 Hello简介

  1. P2P:即Program to Process,指hello.c源程序经过预处理(Pre-processing)、编译(Compiling)、汇编(Assembling)、链接(Linking)四个阶段,最终生成可执行目标文件hello,并在shell中被加载执行成为进程的过程。
  2. 020:即Zero to Zero,指hello程序“从无到有,再回到无”的生命周期。起初,内存中并没有hello。Shell通过fork创建子进程,并由execve加载hello程序,将其映射到虚拟内存空间,为其分配时间片和硬件资源;程序执行完毕后,调用exit终止运行。父进程Shell回收其僵尸进程,内核删除其相关的数据结构,释放内存,hello进程彻底消失。

1.2 环境与工具

  1. 硬件环境:x64CPU;Intel(R) Core(TM) i7-14650HX 2.20 GHz;16.0 GB RAM。
  2. 软件环境:Windows11 64位,VMware WorkstationUbuntu 24.04 LTS 64位。
  3. 调试工具:vim,objump,gdb,readelf等。

1.3 中间结果

中间文件名

说明

hello.i

预处理后的源程序

hello.s

编译后得到的汇编语言程序

hello.o

汇编后得到的可重定位目标文件

hello

链接后得到的可执行目标文件

hello.elf

用readelf读取hello.o得到的ELF格式信息

hello2.elf

用readelf读取hello得到的ELF格式信息

hello.asm

hello.o的反汇编结果

hello2.asm

hello的反汇编结果

1.4 本章小结

首先,本章从P2P,020两个方面简单介绍了一下hello;其次说明了进行本次实验所用的软硬件环境和所需的调试工具;最后列出了本实验生成的各个中间结果文件的名称和功能。总的来说本章是为下文正式开始实验做准备。


第2章 预处理

2.1 预处理的概念与作用

  1. 概念:预处理是预处理器在源代码被编译之前对其执行的简单的文本加工过程。预处理器扫描原代码并根据#开头的指令对代码进行文本替换、插入或删除。
  2. 作用:预处理阶段只是单纯地处理文本,主要有宏定义、头文件包含和条件编译等。宏定义是将标识符替换为实际代码中的内容;头文件包含是将另一个文件中的全部内容直接复制粘贴到当前文件中#include所在的位置;条件编译是根据特定条件决定某段代码是否参与编译。

2.2在Ubuntu下预处理的命令

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

图 1 Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

在Linux下打开hello.i文件,对比源程序和预处理后的程序,可以看出后者除了预处理的部分(主要是头文件)被展开成了几千行之外,源程序的其他部分都保持不变(如果有宏标识符会被替换为具体的值)。

当预处理器遇到例如#include<stdio.h>等头文件时,它会在系统的头文件路径下查找该文件,然后把文件中的内容复制到源文件中。头文件中可能还有其他的#include指令,这些头文件的内容也会被递归地展开到源文件中。预处理器不会对头文件的内容做改动,只是简单的复制粘贴来替换。

图 2 hello.i文件部分内容

2.4 本章小结

本章简单介绍了程序预处理的概念和作用,以及如何在Linux下实现对C程序的预处理。然后以hello.c到hello.i为例,通过对比分析了与处理的结果,发现预处理后的文件除了替换宏定义、展开头文件以及一些条件编译指令外其余部分保持不变,证明了预处理的本质就是对预处理指令进行替换、插入或删除。


第3章 编译

3.1 编译的概念与作用

  1. 概念:编译是将高级程序设计语言翻译成等价的汇编语言的过程。
  2. 作用:编译器主要对预处理后的程序进行语言翻译和优化,其主要作用有以下三个方面:
  • 语法语义分析:编译器会检查代码是否符合C语言的规则,以及检查语义是否合理,如果有错误就会停止编译并报错;
  • 优化代码:编译器会对代码进行优化,以加快代码的运行速度,减少不必要的空间占用。比如删除不会运行的代码、折叠常量、进行循环展开等;
  • 生成汇编指令:将优化后的中间表示转换为目标机器的汇编指令。

3.2 在Ubuntu下编译的命令

命令:gcc -S hello.i -o hello.s

图 3 Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.3.1数据:字符串常量

在C语言中,字符串常量被视为只读数据,编译器将其放置在.rodata节中,并根据包含的内容字符集分配了标签.LC0(UTF-8编码的中文字符串)和.LC1(格式化字符串)。

图 4 字符串常量对应的汇编代码

3.3.2数据:局部变量、赋值

在C语言中,局部变量通常储存在栈上。对于局部变量int i,编译器在栈帧中为其分配了空间-4(%rbp)。Int类型占四个字节,对应汇编指令后缀l,故用movl将立即数0移动到栈地址中。

图 5 局部变量对应的汇编代码

3.3.3 算术操作

Hello.c第18行i++自增操作被编译器翻译为加法指令,直接对应汇编的add指令。这里直接在内存操作数上(-4(%rbp))进行运算。

图 6 算术操作++对应的汇编代码

3.3.4 关系操作

Hello.c第14行if (argc!=5)、第18行i<10:

编译器对于关系运算通过cmp指令完成,它执行减法但不保存结果,只更新标志寄存器。编译器将!=或<转化为“比较+条件跳转”的组合,设置条件码,并通过条件码判断跳转到什么位置

图 7 关系操作对应的汇编代码

3.3.5 数组与指针操作

Hello.c程序中,指针类型的数组char *argv[]在作为参数传递时会自动退化成char **argv。argv是char **类型,也就是指向指针的指针。编译器在处理时,因为所处的是64位系统,指针大小是8字节,要访问argv[x]就要先取出argv的基地址然后加上8*x字节的偏移,再解引用取出argv[x]指向的字符串地址。

图 8 数组与指针操作对应的汇编代码

3.3.6 控制转移

在此hello程序中,控制转移主要体现在if和for循环中。

(1)if语句:使用je条件跳转指令。如果上面的cmp指令比较的两个值相等,则跳转到指定位置;

(2)for循环:结合标签和条件跳转指令。首次进入循环后跳转到判断条件部分.L3,这里使用jle跳转指令,满足跳转条件(小于或等于就跳转)就跳回循环体.L4,直到不满足条件时跳出。

图 9 控制转移对应的汇编代码

3.3.7 函数操作

对于程序中的函数调用,编译器在Linux系统下处理时遵循System V AMD64 ABI调用约定。编译器使用call指令来调用函数,并在call之前提前准备好寄存器中的参数。根据约定,前六个整型或者指针参数依次存放在寄存器%rdi,%rsi,%rdx,%rcx,%r8,%r9中。

  1. main函数:

参数传递与函数调用:main函数的参数为int argc,char* argv[]。argc被存放在寄存器%edi中并被压入栈中;由上文可知char* argv[]起始地址存放在栈中-32(%rbp)的位置,被两次调用作为参数传到printf中。

  1. printf函数:

参数传递与函数调用:第一个参数固定为格式字符串,第2~4个参数为argv[1]~argv[3],分别通过%rdi,%rsi,%rdx,%rcx传递。使用call指令调用函数。

图 10 printf函数的调用

  1. exit函数:

参数传递与函数调用:

%edi设置为1,再使用call指令调用函数。

图 11 exit函数的调用

  1. atoi函数、sleep函数

参数传递与函数调用:atoi函数将参数argv[3]放入寄存器%rdi中用作参数传递,简单使用call指令调用。然后,将转换完成的秒数从%eax传递到%edi中,edi存放sleep的参数,再使用call调用。

然后,将转换完成的秒数从%eax传递到%edi中,edi存放sleep的参数,再使用call调用。

图 12 atoi、sleep函数的调用

  1. getchar函数:无参数传递,直接使用call调用

3.3.8类型转换

atoi函数将字符转换为sleep函数需要的整型参数。

3.4 本章小结

本章简单介绍了编译的概念和作用,以及如何在Linux下实现对.i文件的编译生成.s文件。然后通过分析hello的编译结果,从数据、赋值、类型转换、算术操作、关系操作、数组/指针/结构操作、控制转移和函数操作等方面清晰地展示了编译器如何将高级程序设计语言映射到底层的机器指令序列。


第4章 汇编

4.1 汇编的概念与作用

(1)概念:汇编是指汇编器将.s文件(汇编代码文件)的每一条汇编指令翻译成CPU能够直接执行的二进制机器指令生成.o文件(可重定位文件)的过程。

(2)作用:汇编的作用主要有以下四个方面:

  • 翻译:将汇编指令翻译成二进制操作码;
  • 打包成ELF格式:将代码和数据按照系统规定的格式(Linux下是ELF格式)进行封装分类,放入不同的节(.text、.data、.rodata、.bss节等)中;
  • 生成符号表:汇编器会扫描代码,生成符号表来记录程序定义的内容以及需要的内容,比如标准库里的内容。
  • 生成重定位条目:.o文件没有真实的跳转地址,汇编器会将这些位置标记为代填,生成一个重定位表以便链接器使用。

4.2 在Ubuntu下汇编的命令

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

图 13 Ubuntu下汇编的命令

4.3 可重定位目标elf格式

在终端输入命令readelf -a hello.o > hello.elf得到对应的elf文件hello.elf,打开此文件可以看到可重定位目标elf的格式和内容,包括ELF头、节头表、符号表。

(1)ELF头:

ELF头以一个叫作Magic的序列开头,作为ELF文件的标准标识。剩下的部分是目标文件的信息,比如文件类型为REL(可重定位文件)、系统架构为Advanced Micro Devices X86-64、入口点地址、ELF头的大小、节头表的位置、节头表条目的大小和数量等。

图 14 ELF头的内容

(2)节头:

节头表包含了文件包含的各个节的信息,有名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。

图 15 节头的内容

可以看到,hello.o有14个节,主要的节有:代码节.data,存放的是可执行的机器代码;可重定位节.rela.text,包含了链接阶段需要修改的代码的位置;只读数据节.rodata,存放只读数据;等等。

(3)重定位项目分析:

Readelf输出显示.rala.text节偏移量为0x2f0,包含八个条目。这些条目表示代码中引用了外部的东西,需要链接器进行补充。从elf文件可以看出,这八个条目分为两类:

  • R_X86_64_PC32数据引用重定位):这种类型用于引用定义在当前或其他模块的数据,使用的是PC相对寻址;
  • R_X86_64_PLT32函数调用重定位):这种类型用于调用外部函数,使用PLT(过程链接表)跳转。

图 16 重定位项目

(4)符号表分析:

通过查看elf文件,可以得知.symtab包含了11个条目,展示了符号的定义与引用情况:

图 17 符号表

4.4 Hello.o的结果解析

在shell中输入 objdump -d -r hello.o > hello.asm 指令,输出hello.o的反汇编文件,并与hello.s文件进行对照分析。

4.4.1 机器语言的构成和映射

机器语言是机器语言是CPU唯一能直接读懂的语言,由操作码(Opcode)和操作数(Operands)组成。

汇编指令与机器指令的映射如下:

汇编指令(hello.s)

机器指令(hello.asm)

分析

pushq%rbp

55

55push%rbp的专用操作码

movq %rsp%rbp

48 89 e5

48(表示64位)+89(MOV)+e5(表示rsp目标rbp)

leave

c9

相当于mov%rbp,%rsp+pop%rbp的组合指令

ret

c3

函数返回

……

……

……

简单的寄存器操作通常一一映射,机器码已经确定。

4.4.2 与hello.s的对照分析

(1)操作数进制改变:反汇编文件中的所有操作数都改为十六进制;

(2)外部函数调用:在汇编源码中,函数调用是直接call函数名,但是在机

器码中,地址被清零。因为hello.o只是一个中间文件,还没有进行链接,编译器不知道函数在内存里的真实地址,所以暂时填0占位,然后利用重定位条目来指引信息。

图 18 机器码中的函数调用

(3)全局数据访问:在反汇编文件中,地址仍然被清零,这是因为字符串放在.rodata段,虽然它们在同一个文件里,但最终合并成可执行文件时,代码段和数据段之间的距离可能会变,所以还是要利用重定位条目来指引信息。

图 19 机器码中的全局数据访问

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

图 20 分支转移

操作码74代表je跳转指令,19是偏移量。机器语言使用的是PC相对寻址,跳转通常是相对偏移量机器码里的19并不是地址19,而是当前指令(je)地址向前跳0x19个字节的意思。

4.5 本章小结

本章简单介绍了汇编的概念和作用,以及如何在Linux下实现对.i文件的汇编生成.s文件,并生成ELF格式的文件。将可重定位目标文件改为ELF格式并观察文件内容,对文件中的每个部分进行了简单的解析,重点了解分析了其中的重定位项目。然后通过对照分析hello.o的反汇编代码和原汇编文件hello.s的区别,理解了汇编语言到机器语言的转换过程,以及机器为了下一步链接生成可执行文件所做的准备工作。


5链接

5.1 链接的概念与作用

  1. 概念:链接就是将编译出来的目标文件和涉及到的库文件等分散在不同文件中的代码和数据收集起来,组合成一个单一的文件,并解决它们之间的引用依赖,生成可执行文件的过程。
  2. 作用:链接主要有以下三方面的作用:
  • 合并相同节:将所有输入文件的相同类型的节合并到一起;
  • 符号解析:hello.o中只有函数的调用指令,没有函数体。链接器扫描系统库,找到对应函数的定义,将main函数中的引用与库里的定义关联起来;
  • 重定位:链接器决定最终运行时各个函数等在内存的位置,根据这些确定的地址回到代码中,把之前预留的call指令和lea指令后的00替换成真实的内存地址偏移量。

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\ /usr/lib/gcc/x86_64-linux-gnu/11/crtbegin.o hello.o -lc\ /usr/lib/gcc/x86_64-linux-gnu/11/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o

图 21 Ubuntu下链接的命令

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

在终端输入命令readelf -a hello.o > hello2.elf得到对应的elf文件hello2.elf。打开hello2.elf,可以查看可执行目标文件hello的elf格式的内容信息。

  1. ELF头:

Hello2.elf中的ELF头与hello.elf中的ELF头包含的信息种类基本相同,以标识ELF文件的字节序列Magic开始,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。与hello.elf相比,hello2.elf中类型发生改变,程序头大小和节头数量增加,并且获得了入口地址。它拥有确定的内存地址,可以直接被操作系统加载运行。

图 22 hello2.elf ELF头的内容

  1. 节头:

与hello.elf相比hello2.elf节头仍然描述了各个节的大小、偏移量等属性。不同的是,链接器将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。

图 23 hello2.elf节头的内容

  1. 程序头:

程序头表描述了系统准备程序执行所需的段或其他信息告诉操作系统如何将文件映射到内存中

图 24 hello2.elf程序头的内容

  1. 动态链接信息:

由于使用了共享库,ELF 文件中包含了动态链接器所需的信息

图 25 Dynamic section

  1. 动态符号表和符号表:

动态符号表.dynsym只包含程序运行时必须解析的符号符号表.symtab包含所有符号:包括.dynsym里的所有内容,加上局部的、用于调试的、源文件名等

图 26 Symbol table

5.4 hello的虚拟地址空间

在终端中依次输入gdb./hello、start,会启动gdb并让程序运行起来,但停在main函数的第一行然后输入info proc mappings查看当前进程的虚拟地址空间。与ELF文件对比分析:

  1. 代码段和只读数据:

GDB输出:

ELF文件:

GDB里的起始地址和ELF里的VirtAddr是完全一样的这说明操作系统完全按照ELF文件的指示,把文件内容存到了指定的内存地址。

  1. 数据段:

GDB输出:

ELF文件:

ELF中显示该段起始于0x403de8。但内存映射通常以页为单位对齐。所以操作系统映射了0x403000开始的内存页

  1. 动态链接库:

GDB输出:

ELF文件ELF文件里没有这些具体地址因为这些地址是随机的每次运行动态链接器都会将其加载到不同的高位内存地址

  1. 栈:

GDB输出:

ELF文件:对应GNU_STACK程序头只是告诉系统需要一个可读可写的栈,但具体栈在内存的哪里,是由操作系统在程序启动瞬间决定的,ELF文件里没有硬编码栈的地址。

5.5 链接的重定位过程分析

5.5.1 分析hello和hello.o的区别

在终端中输入命令,objdump -d -r hello>hello2.asm生成hello2.asm,与第四章hello.o生成的hello.asm对比分析:

  1. 地址空间变化:

hello.o地址从0开始,还不知道各个部分将来会被放在内存的哪个位置;hello中程序中的所有指令等都有了唯一的、确定的运行时的虚拟地址;

  1. 内容扩充:

在hello2.asm中,除了原本的main函数,还多出了很多hello.o中没有的代码段,比如_start、.init、.plt等;

图 27 hello和hello.o的区别举例

5.5.2 重定位的过程分析

  1. 数据引用重定位(R_X86_64_PC32):

链接前机器码中操作数是00 00 00 00,并用R_X86_64_PC32告诉链接器将来要计算出.rodata段相对于当前指令的偏移量填在这里;链接器确定.rodata段要放在内存的什么区域,确定目标字符串的地址和当前指令的下一条地址,通过计算偏移确定机器码中的操作数到底是什么,完成重定位;

  1. 函数调用重定位(R_X86_64_PLT32):

链接前call后面仍然是00 00 00 00。编译器不知道外部函数在哪里,甚至不知道它在哪个库里。它标记PLT32,意思是指向过程链接表(PLT)中的项。以puts函数为例,链接器发现puts是动态库函数。它在.plt节中为puts生成了一个存根。计算偏移,目标地址-下一条指令地址。链接器把00 00 00 00修改为偏移量,完成重定位。程序运行时,call会跳到PLT表,再由PLT表跳到动态库里的真实puts函数。

5.6 hello的执行流程

5.6.1 执行流程

  1. 启动并设置关键断点;
  2. 停在_start处,反汇编查看,可知_start的核心任务是把main的地址(0x4011d6)作为参数传给即将调用的__libc_start_main;
  3. 跳转到__libc_start_main处。它负责初始化线程、安全性检查,并调用_init(地址0x401000)来完成全局变量初始化;
  4. 进入main,反汇编查看可以看到代码段的汇编指令;
  5. 程序执行完后,会调用exit或者从main返回;
  6. exit内部会调用_fini(地址0x40127c)进行收尾工作,最后调用内核的exit_group系统调用彻底杀死进程。

5.6.2 子程序名或地址

_start

0x4010f0

__libc_start_main

0x2f12271d

_init

0x401000

main

0x4011d6

puts@plt

0x401090

printf@plt

0x4010a0

exit

0x4010d0 (PLT)

_fini

0x40127c

5.7 Hello的动态链接分析

动态链接库里的函数在程序刚开始运行时,其真实地址是未知的。只有当你第一次调用它时,动态链接器才会去查找它的真实地址并填入GOT表。、

以puts为例进行分析:

  1. 根据hello.elf的重定位表.rela.plt,puts的重定位偏移量是0x404000;

  1. 启动gdb调试,在main处设置断点,不带参数运行程序(故意犯错误错误,puts函数才会被调用);

  1. 观察链接前的状态,查看GOT表中的值。输入以下命令查看地址0x404000的内容:

这个值0x401030指向的是.plt节中的存根代码。也就是说这个值指向hello程序内部的代码段,而不是外部的libc。这说明此时puts的地址尚未解析;

  1. 输入ni,不断回车单步执行执行指令,直到GDB显示刚执行完call puts@plt这一行。此时puts已经被调用过了,动态链接器应该已经工作完成。再次输入命令查看GOT表中的值:

这个地址是一个很大的数,位于共享库映射区。它就是libc.so中puts函数在内存里的真实物理地址。

5.8 本章小结

本章介绍了链接的概念和作用,以及如何在Linux下实现对.o文件的链接生成可执行文件,并生成ELF格式的文件。然后我们分析了elf文件的格式和内容,并借此对比了hello和hello.o文件的区别。通过GDB的调试观察了hello的虚拟地址空间并和elf文件内容对比。最后通过对连接的重定位过程分析、hello的执行流程分析、动态链接分析,更深入地理解了链接的工作和作用。


6hello进程管理

6.1 进程的概念与作用

  1. 概念:进程是正在运行的程序的实例。
  2. 作用:进程为程序提供了一种假象,程序好像是独占地使用处理器和内存,处理器好像是无间断地一条接一条地执行程序中的指令。进程的作用主要有以下几点:
  • 进程是程序的执行载体;
  • 操作系统通过进程来管理和分配计算机资源;
  • 进程为程序提供了一个独立的运行环境;
  • 进程的存在使得计算机可以同时处理多项任务。

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

  1. 作用:为用户提供了一个界面,接收用户输入的程序名并运行它。
  2. 处理流程:
  • 从标准输入读取命令;
  • 分析输入的字符串,识别出命令名称、参数、特殊符号等;
  • 进行扩展/替换,在执行命令前先处理特殊字符;
  • 判断命令是内置命令还是外部命令:如果是内置命令,直接调用shell内部函数;如果是外部命令,则调用fork()创建一个子进程,在子进程中调用exce()系列函数,加载并运行用户指定的程序。
  • 父进程(Shell)调用wait()挂起,等待子进程执行完毕;一旦子进程结束,Shell醒来,打印提示符(Prompt),开始下一轮循环。

6.3 Hello的fork进程创建过程

在shell界面输入命令:./hello 3 都鹏涛 2024111667 2,按下回车,shell经过判断发现这不是内置指令,于是调用fork函数创建一个新的子进程。该子进程得到与父进程用户级虚拟地址空间相同的一份副本,包括用户ID和组ID、代码和数据段、堆、共享库以及用户栈。子进程与父进程最大的区别就是具有不同的PID。在父进程中,fork返回子进程的PID,而在子进程中fork返回0,以此来分辨程序是父进程还是在子进程中执行。

6.4 Hello的execve过程

当子进程在fork返回后调用execve("./hello",...)时,进程陷入内核态。内核首先读取磁盘上hello可执行文件的ELF头,解析其格式并验证合法性,随后彻底清空子进程原本继承自父进程(Shell)的旧用户空间内存映射,并依据hello文件中的程序头表,通过内存映射机制将新的代码段、数据段加载到虚拟内存的指定位置。同时,内核初始化新的堆栈区以填充命令行参数与环境变量,并根据.interp段的信息加载动态链接器。然后内核将CPU的指令指针寄存器(%rip)设置为ELF文件头中定义的入口点地址(_start),控制权从内核交还回用户态,子进程替换完成。

6.5 Hello的进程执行

hello程序的执行并非连续的独占过程,而是由操作系统内核通过异常控制流(ECF)进行管理的交错执行流。程序通过syscall指令(如调用sleep)或硬件中断(如定时器)从用户态陷入核心态,期间发生堆栈切换和特权级提升。当内核调度器决定剥夺hello的CPU使用权(主动阻塞或因时间片耗尽)时,会触发上下文切换机制。内核将hello的寄存器状态、PC指针等保存至其PCB,并加载下一个进程的上下文。通过极高频率的上下文切换,操作系统在宏观上营造了hello与其他进程同时运行的假象,而微观上CPU只是在不同进程的时间片之间快速跳跃。

6.6 hello的异常与信号处理

6.6.1异常与信号的种类

  1. 硬件层面的异常:中断(异步)、陷阱(同步)、故障(同步)、终止(同步);

图 28 异常的种类

  1. 软件层面的信号:

信号是操作系统发送给进程的“高级软件中断”,用于通知进程发生了某种系统事件。在hello运行过程中,可能产生以下信号:

图 29 信号的种类

6.6.2 异常的处理方式

  1. 中断处理:

  1. 陷阱处理:

  1. 故障处理:

  1. 终止处理:

6.6.3 各命令及运行结果

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

  1. Ctrl+C:运行时按下Ctrl + C,shell进程收到SIGINT信号,shell结束并回收hello进程。

  1. Ctrl+Z:运行时按下Ctrl + Z,shell进程收到SIGSTP信号,shell显示屏幕提示信息并挂起hello进程。

  1. Ctrl+Z后ps、jobs:进程被挂起后可以用ps和jobs命令查看

  1. Pstree:在shell中输入pstree命令,可以将所有进程以树状图显示:

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

  1. Ctrl+Z后kill再ps查看:发现指定的进程已经被杀死。

6.7本章小结

本章主要研究了计算机系统中的进程管理。首先简要介绍了进程的概念和作用、shell的作用和处理流程,然后详细分析了hello程序的进程创建、启动和执行过程。最后,本章对hello程序可能出现的异常情况和信号,以及运行结果中的各种输入进行了解释和说明。


7hello的存储管理

7.1 hello的存储器地址空间

  1. 逻辑地址:是程序代码中写死的、或者CPU寄存器中直接生成的地址。在x86体系结构中,逻辑地址由段选择符(Segment Selector)和段内偏移量(Offset)组成,表示为段:偏移。在hello2.asm反汇编文件中看到的地址实际上就是逻辑地址中的偏移量部分。
  2. 线性地址:是逻辑地址经过分段单元(Segmentation Unit)处理后得到的地址。计算公式为线性地址=段基址(Segment Base)+段内偏移(Offset)。现代 Linux操作系统为了简化内存管理,把所有的段的段基址强制设置为0。以hello为例,在objdump和readelf中看到的VirtAddr,既是逻辑偏移,也是线性地址。
  3. 虚拟地址:hello程序访问存储器所使用的逻辑地址称为虚拟地址。虚拟地址经过地址翻译得到物理地址。
  4. 物理地址:内存条上真实的硬件存储单元地址,是hello的实际地址或绝对地址。CPU通过地址总线发送给内存条的电信号就是物理地址。MMU通过查询页表,将虚拟地址翻译成物理地址。

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

在Intel x86-64保护模式下,逻辑地址指的是程序指令中直接使用的地址。逻辑地址由段选择符(Segment Selector)和段内偏移量(Offset)两部分组成。

逻辑地址变换到线性地址,首先CPU使用段寄存器(如CS,DS,SS)中的段选择符,在全局描述符表(GDT)或局部描述符表(LDT)中找到对应的段描述符(Segment Descriptor),然后从段描述符中提取段基地址,计算线性地址(段基地址+段内偏移量)。

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

线性地址(即虚拟地址VA)需要通过分页机制(Paging)转化为物理地址(PA)才能访问真实的内存单元。CPU中的内存管理单元(MMU)负责这一转换。

hello是64位程序,虚拟地址空间非常大,转换过程将64位的虚拟地址划分为两部分:虚拟页号(VPN)和虚拟页偏移(VPO)。MMU利用VPN查询页表(Page Table),如果页表项(PTE)有效,则取出其中的物理页框号(PPN)。最终物理地址构造为PA=PPN||VPO,即二者的拼接。

图 30 页式管理图示

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

现代x86-64处理器采用4级页表结构来稀疏地管理巨大的虚拟内存空间。

为了加速翻译,MMU首先使用VA的部分位作为标记(Tag)和组索引(Set Index)去查询CPU内部的高速缓存TLB。如果TLB中有该VPN的映射,直接得到PPN,省去访存时间;不命中则需要访问内存中的页表。当TLB不命中时,CR3寄存器指向第一级页表(PML4)的基址。虚拟地址被划分为由4个9位的索引段(VPN1-VPN4)。用VPN1索引PML4,得到PDPT的基址;用VPN2索引PDPT,得到Page Directory(PD)的基址;用VPN3索引PD,得到Page Table(PT)的基址;用VPN4索引PT,得到最终的页表项(PTE)。从PTE中提取PPN,与VPO组合得到物理地址PA,并将该映射缓存到TLB中。

图 31 Core i7的翻译情况

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

一旦MMU获取了物理地址(PA),CPU就会使用PA去访问各级高速缓存或主存。物理地址PA被分解为标记(CT)、组索引(CI)和块偏移(CO)。CPU首先使用CI在L1 Cache中定位对应的组,对比组内各行的Tag与CT是否匹配,并检查有效位。如果命中,直接从Cache中读取数据传给CPU;如果L1不命中,则继续使用PA在L2 Cache中查找,以此类推,最后才会通过内存总线访问主存。

对于hello程序,常用的指令和数据(如main函数代码、printf缓冲区)通常会驻留在L1或L2 Cache中,以实现较快的访问速度。

图 32 Cache的结构

7.6 hello进程fork时的内存映射

当Shell进程调用fork()函数创建子进程(后续将通过execve变为hello)时,内核采用写时复制(Copy-On-Write,COW)的方法。

内核为新进程创建核心数据结构,并分配唯一的PID。内核不复制父进程的物理内存数据,而是完全复制父进程的页表给子进程。此时,父子进程的虚拟地址空间完全相同,且映射到同一组物理页面。内核将两个进程中所有私有区域(如数据段、堆、栈)的页面权限都标记为只读。当fork返回后,任意一个进程试图修改这些页面,会触发保护故障。内核捕获异常,将该页面复制一份新的物理页给写入者,并更新页表权限为“可写”。

7.7 hello进程execve时的内存映射

当子进程执行execve("./hello",...)加载程序时,加载器解除当前进程用户地址空间中已有的所有内存映射,为hello程序创建新的内存区域结构,包括代码段、数据段、BSS、堆栈等。如果hello链接了动态库,内核将这些共享对象的动态链接库映射到共享区域;然后将进程的入口地址跳转到ELF头中定义的_start地址,准备开始执行hello程序。

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

在hello运行过程中,访问合法的虚拟地址但数据不在物理内存中时,会触发缺页故障(Page Fault)。CPU检测到页表项的有效位为0,触发异常,将控制权交给内核的缺页处理程序。如果地址超出了定义的虚拟内存区域,触发段错误Segmentation Fault(SIGSEGV),终止hello;如果试图写只读页面,同样触发SIGSEGV;如果地址合法但未缓存,内核在物理内存中选择一个牺牲页,将其换出到磁盘,从磁盘读取所需的数据,加载到新的物理页中,然后缺页处理程序返回,CPU重新执行那条导致缺页的指令,此时数据已在内存,指令成功执行。

图 33 缺页故障与缺页中断处理

7.9动态存储分配管理

以下格式自行编排,编辑时删除

Printf会调用malloc,请简述动态内存管理的基本方法与策略。(此节课堂没有讲授,选做,不算分)

7.10本章小结

本章深入分析了hello程序在运行过程中与计算机存储系统交互的底层机制。通过对Intel x86-64架构及Linux操作系统内存管理策略的研究,我们可以看出,地址转换机制是虚拟内存的核心;多级存储体系提升了访问效率;进程创建与加载依赖于高效的内存映射;缺页故障的处理机制实现了按照需求分配页,提高了物理内存的利用率。


8hello的IO管理

8.1 Linux的IO设备管理方法

在Linux下“一切皆文件”。Linux将所有的I/O设备(如硬盘、键盘、显示器、网络接口等)都抽象为文件。

设备的模型化:文件。Linux将每个设备都映射到文件系统中的一个文件,例如标准输入对应/dev/stdin,标准输出对应/dev/stdout。这使得所有的输入输出操作都可以通过统一的文件操作接口来完成。

设备管理:Unix IO接口。Linux内核提供了一套系统级函数,用于对文件进行打开、关闭、读写等操作。用户程序不需要关心底层硬件的具体实现细节,只需调用这些标准接口即可。

8.2 简述Unix IO接口及其函数

Unix I/O接口是实现I/O操作的一组底层系统调用,主要函数包括:

  1. open():应用程序通过调用open函数来打开一个文件或设备,内核返回一个小的非负整数,称为文件描述符(File Descriptor)。
  2. close():应用程序通过调用close函数通知内核关闭文件,释放相关资源。
  3. read():应用程序通过调用read函数从当前文件偏移量处读取数据字节到内存中。若读取字节数少于请求字节数,可能表示遇到了EOF。
  4. write():应用程序通过调用write函数将内存中的数据字节写入到文件中。
  5. lseek():应用程序通过调用lseek函数显式地修改文件的当前读写位置(文件偏移量)。

8.3 printf的实现分析

printf 函数是 C 标准库提供的带缓冲输出函数,其底层实现流程如下:

  1. 格式化处理:printf内部首先调用vsprintf(或类似函数),解析格式化字符串,将各种类型的参数转换为ASCII字符串,并写入内部的一个输出缓冲区;
  2. 系统调用:当缓冲区满或遇到换行符\n时,printf调用Linux的write系统调用;
  3. 陷入内核:CPU执行syscall指令,触发异常(陷阱),从用户态切换到内核态。内核执行sys_write服务例程;
  4. 字符显示驱动:内核的终端驱动程序接收数据,查找字模库,将ASCII码转换为字模数据(像素点阵);
  5. 硬件显示:驱动程序将像素点的RGB颜色信息写入显存(VRAM)。显示芯片按照刷新频率逐行读取VRAM,生成视频信号传输给显示器,最终在屏幕上显示出字符。

8.4 getchar的实现分析

getchar函数是C标准库提供的输入函数,其底层实现依赖于异步中断机制:

  1. 异步异常-键盘中断:当用户按下键盘按键时,键盘控制器产生一个扫描码,并向CPU发送中断请求(IRQ);
  2. 中断处理:CPU暂停当前进程,执行键盘中断处理程序。该程序读取I/O端口获取扫描码,将其转换为ASCII码,并存入系统的键盘缓冲区;
  3. 系统调用:hello程序调用getchar,底层调用read系统调用读取标准输入stdin;
  4. 阻塞与唤醒:如果键盘缓冲区为空,read调用会导致hello进程被挂起(阻塞)。当用户按下回车键后,内核唤醒进程,将缓冲区的一行数据复制到用户空间,getchar返回读到的第一个字符。

8.5本章小结

本章分析了Linux系统的I/O管理机制。通过了解Unix I/O设备管理方法和Unix IO接口及其函数,明白了操作系统如何屏蔽硬件差异。然后分析了printf和getchar的实现,串联了系统调用、中断处理、驱动程序和硬件交互的全过程,揭示了简单的输入输出背后复杂的软硬件协同逻辑。

结论

  1. hello所经历的过程
  1. 预处理:hello.c经过预处理器扩展为hello.i,头文件被展开,宏定义被替换等;
  2. 编译:编译器将hello.i翻译为汇编代码hello.s,完成了从高级程序设计语言到汇编语言的映射;
  3. 汇编:汇编器将hello.s翻译为机器指令hello.o,生成了ELF可重定位目标文件;
  4. 链接:链接器将hello.o与系统库合并,解析符号并重定位地址,生成最终的可执行文件hello;
  5. 进程管理:Shell通过fork和execve创建进程,hello获得了独立的PID和执行环境;
  6. 存储管理:操作系统通过虚拟内存机制,利用分页和地址翻译(MMU/TLB),为hello提供了私有的线性地址空间,实现了物理内存的高效映射;
  7. I/O管理:hello通过系统调用与外设交互,实现了与用户的动态信息交换。 
  1. 感悟

计算机系统的设计是一个化繁为简、层层抽象的过程。虽然我们编译运行一个简单的hello.c文件只需要短短的几行指令或者点击两下鼠标,但其背后的原理和过程却是复杂而又精细的。从硬件的电信号到操作系统的系统调用,再到高级语言的各个函数库,每一层都封装了底层的复杂性,提供给上层简洁“美好”的一面。我个人认为,计算机系统中这种分层设计、抽象化、虚拟化、简洁化,还有并发控制的思想,是计算机科学与技术中最为美妙,最为宝贵的智慧结晶


附件

中间文件名

说明

hello.c

源代码

hello.i

预处理后的源程序

hello.s

编译后得到的汇编语言程序

hello.o

汇编后得到的可重定位目标文件

hello

链接后得到的可执行目标文件

hello.elf

用readelf读取hello.o得到的ELF格式信息

hello2.elf

用readelf读取hello得到的ELF格式信息

hello.asm

hello.o的反汇编结果

hello2.asm

hello的反汇编结果


参考文献

[1]  Randal E.Bryant, David R.O'Hallaron. 深入理解计算机系统[M]. 北京:机械工业出版社,2016.

[2]  get_set. CSAPP之一:程序生命周期漫谈[EB/OL]. (2015-12-06)[2025-01-02]. https://blog.csdn.net/get_set/article/details/50197631.

[3]  PWL999. Linux mem 1.1 用户态进程空间的创建--- execve() 详解[EB/OL]. (2020-10-26)[2025-01-02]. https://blog.csdn.net/pwl999/article/details/109289451.

[4]  极致的死磕. 进程管理(八)--创建进程fork[EB/OL]. (2021-05-17)[2025-01-02]. https://blog.csdn.net/u012489236/article/details/116900021.

转载自CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2403_88415275/article/details/156515782

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--