摘  要

针对现代计算机系统软硬件协同工作的底层机制,以经典的“Hello World”程序为研究载体,深度剖析了高级语言源程序演化为运行中进程的完整生命周期。研究综合运用GCC编译器、GDB调试器及二进制分析工具,系统追踪了预处理、编译、汇编与静态链接的构建流程,精准解构了可执行目标文件的内部组织与地址重定位细节。在程序执行层面,全面解析了Shell外壳的进程调度与加载机制、基于延迟绑定的动态链接策略、利用多级页表与TLB实现的虚拟内存地址翻译,以及进程对异常控制流与系统硬件信号的响应机制。研究通过翔实的底层反汇编与动态调试数据,将抽象的存储管理和Unix I/O设备模型具象化,直观揭示了操作系统、体系结构与上层应用之间的软硬件交互过程。整体探索不仅实现了对系统底层运作逻辑的闭环验证,更为后续开展高效的代码优化、系统级安全开发及底层架构创新提供了坚实的理论支撑与实践范式。

关键词:计算机系统;程序生命周期;编译与链接;进程管理;虚拟内存;异常控制流                            

(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分

目  录

第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简介

P2P (From Program to Process):Hello 的生命周期始于一段高级 C 语言代码,它以 ASCII 文本文件的形式存储在磁盘上,即 hello.c(Program)。为了让机器能够理解并执行,它经历了预处理器(cpp)、编译器(cc1)、汇编器(as)和链接器(ld)的翻译,最终被转换为可执行目标文件 hello。当我们在 Shell 命令行中输入执行指令 ./hello 2024111842 黄邱与 13036578390 0 后,Shell 通过调用 fork 函数为程序创建一个新的子进程,并调用 execve 函数将可执行文件加载到该进程的上下文中。至此,Hello 从一个静态的程序(Program)蜕变为一个动态运行的进程(Process)。

020 (From Zero to Zero):在 Shell 为 Hello 分配进程后,系统为其创建了全新的独立虚拟地址空间,初始状态为零(Zero)。随着程序的运行,CPU 为其分配时间片,执行底层机器指令,内存管理单元(MMU)通过缺页异常将实际的物理内存映射到虚拟内存中,执行诸如遍历打印、休眠 0 秒以及等待键盘输入等操作。当程序执行完毕(触发 return 0 或收到终止信号)后,父进程(Shell)负责对其进行回收,操作系统内核随之释放该进程占用的所有物理内存和数据结构。Hello 的一切运行痕迹从计算机中彻底抹除,重归于零(Zero)。

1.2 环境与工具

  1. 硬件环境:
  • 宿主机:13th Gen Intel(R) Core(TM) i7-13650HX,基准频率 2.60 GHz;约 24GB 物理内存。
  • 虚拟机:分配 4 核 CPU;8GB RAM;128GB 虚拟硬盘。
  1. 软件环境:Windows 11 64位(宿主机);VMware Workstation(虚拟机软件);Ubuntu 20.04 LTS 64位(客户机操作系统)。
  2. 开发与调试工具:GCC,GDB,edb-debugger,readelf,objdump,HexEdit 等。

1.3 中间结果

在将 hello.c 编译为可执行文件的过程中,生成了以下中间结果文件:

  1. hello.i:预处理后的 C 程序文本文件,用于展开宏定义、插入头文件内容并剔除注释。
  2. hello.s:编译后生成的汇编语言文本文件,将高级语言逻辑转化为特定指令集(x86-64)的汇编指令。
  3. hello.o:汇编后生成的可重定位目标文件,由二进制机器指令组成,包含待重定位的符号表。
  4. hello:链接后生成的最终可执行目标文件,合并了系统启动代码和 C 标准库,可被系统直接加载运行。

1.4 本章小结

本章简要梳理了 Hello 程序的生命周期,阐述了其从代码(Program)到进程(Process)的 P2P 过程,以及从内存分配到最终消亡回收的 020 过程。同时,列出了本次大作业实验所依赖的软硬件环境、调试工具及编译流程中产生的关键中间结果,为后续的各阶段深入分析奠定了基础。

(第1章0.5分)


第2章 预处理

2.1 预处理的概念与作用

预处理是 C 语言编译过程的第一阶段,由预处理器(cpp)负责执行。预处理器会根据源文件中以 # 开头的预处理指令,在不改变代码核心逻辑的前提下,直接对文本进行修改与替换。 预处理的主要作用包括:

  1. 文件包含:处理 #include 指令,将被包含的头文件(如本程序中的 stdio.h、unistd.h 和 stdlib.h)的内容直接复制插入到程序文本中。
  2. 宏展开:处理 #define 指令,将程序中使用的宏替换为对应的实际代码或数值。
  3. 条件编译:处理 #if、#ifdef、#ifndef 等指令,根据条件决定是否保留某段代码。
  4. 剔除注释:删除源代码中所有的块注释和行注释,减少后续编译器的处理负担。

2.2在Ubuntu下预处理的命令

图 1 预处理指令

图 2 预处理输出文件

3 hello.i内容

2.3 Hello的预处理结果解析

通过文本编辑器打开预处理后生成的 hello.i 文件(如图 3),可以发现其代码行数从原本的 20 余行剧增至数千行。原本代码顶部的注释说明被完全剔除。 这种显著的膨胀主要归因于 #include 指令的作用。预处理器在系统头文件目录中找到了 stdio.h、unistd.h 和 stdlib.h,并将这些库文件中大量的类型定义(typedef)、结构体声明以及外部函数声明(例如程序中调用的 printf、sleep、atoi、exit 等)完整地展开并插入到了 hello.i 中。拖动到文件末尾,我们可以看到原本编写的 main 函数依然保留了 C 语言的文本形态,且内部逻辑没有发生任何改变,等待进入下一步的编译阶段。

2.4 本章小结

本章详细探讨了 GCC 编译流程中的预处理阶段。通过实际使用 gcc -E 命令对 hello.c 进行预处理,我们直观地观察到了预处理器如何通过展开头文件和宏定义、清除注释等操作,为编译器准备了一份庞大但内容详尽、纯粹的 C 语言文本文件 hello.i。

(第2章0.5分)


第3章 编译

3.1 编译的概念与作用

编译的概念:在此阶段,编译程序(cc1)将预处理后生成的 C 语言文本文件 hello.i 翻译成汇编语言文本文件 hello.s。

编译的作用:编译器主要通过词法分析、语法分析、语义分析以及代码优化等步骤,将高级语言(C 语言)的抽象控制流、数据类型和函数调用,精确地映射为特定底层机器架构(如 x86-64 架构)所能识别的汇编语言指令。汇编代码提供了一种相对通用的底层语言,为下一步汇编器生成机器指令打下了直接基础。

3.2 在Ubuntu下编译的命令

图 4 编译命令

图 5 编译输出文件

.file "hello.c"

.text

.section .rodata

.align 8

.LC0:

.string "\347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \346\211\213\346\234\272\345\217\267 \347\247\222\346\225\260\357\274\201"

.LC1:

.string "Hello %s %s %s\n"

.text

.globl main

.type main, @function

main:

.LFB6:

.cfi_startproc

endbr64

pushq %rbp

.cfi_def_cfa_offset 16

.cfi_offset 6, -16

movq %rsp, %rbp

.cfi_def_cfa_register 6

subq $32, %rsp

movl %edi, -20(%rbp)

movq %rsi, -32(%rbp)

cmpl $5, -20(%rbp)

je .L2

leaq .LC0(%rip), %rdi

call puts@PLT

movl $1, %edi

call exit@PLT

.L2:

movl $0, -4(%rbp)

jmp .L3

.L4:

movq -32(%rbp), %rax

addq $24, %rax

movq (%rax), %rcx

movq -32(%rbp), %rax

addq $16, %rax

movq (%rax), %rdx

movq -32(%rbp), %rax

addq $8, %rax

movq (%rax), %rax

movq %rax, %rsi

leaq .LC1(%rip), %rdi

movl $0, %eax

call printf@PLT

movq -32(%rbp), %rax

addq $32, %rax

movq (%rax), %rax

movq %rax, %rdi

call atoi@PLT

movl %eax, %edi

call sleep@PLT

addl $1, -4(%rbp)

.L3:

cmpl $9, -4(%rbp)

jle .L4

call getchar@PLT

movl $0, %eax

leave

.cfi_def_cfa 7, 8

ret

.cfi_endproc

.LFE6:

.size main, .-main

.ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0"

.section .note.GNU-stack,"",@progbits

.section .note.gnu.property,"a"

.align 8

.long  1f - 0f

.long  4f - 1f

.long  5

0:

.string  "GNU"

1:

.align 8

.long  0xc0000002

.long  3f - 2f

2:

.long  0x3

3:

.align 8

4:

表 1 编译文件内容

3.3 Hello的编译结果解析

通过分析生成的 hello.s 汇编代码,可以清晰地观察到 C 语言的各种高级抽象被编译器精确地转化为 x86-64 架构下的机器级操作。以下结合汇编源码逐一进行分类解析:

  1. 数据类型

常量字符串:存放于 .section .rodata 只读数据段中。.LC0 存放了错误提示信息的中文字符串,.LC1 存放了用于循环打印的 "Hello %s %s %s\n"。

局部变量与参数:编译器在 main 函数开头通过 subq $32, %rsp 分配了栈帧空间,并将局部变量与参数保存在栈中。其中第一个参数 argc(原在 %edi 中)被转存至 -20(%rbp),第二个参数 argv 指针数组首地址(原在 %rsi 中)被转存至 -32(%rbp),循环变量 i 被分配在 -4(%rbp)。

  1. 赋值操作

变量初始化:对应源码中的 i=0,汇编代码使用数据传送指令 movl $0, -4(%rbp),将立即数 0 写入栈中局部变量 i 所在的位置。

  1. 算术操作

自增操作:对应源码 for 循环中的 i++,汇编代码使用 addl $1, -4(%rbp) 指令,将内存中存储的 i 的值加 1。

指针地址计算:在访问 argv 数组的不同元素时,由于 64 位系统中指针占用 8 个字节,汇编代码通过基址加上 8 的倍数进行偏移计算。例如,通过 addq $8, %rax 获取 argv[1],addq $16, %rax 获取 argv[2],以此类推计算出所有字符串参数的地址。

  1. 关系操作

等值比较:对应 if(argc!=5),汇编代码使用 cmpl $5, -20(%rbp) 指令,将栈中的 argc 值与立即数 5 进行比较,并设置处理器的条件码。

大小比较:对应 i<10,编译器进行了等价优化,将汇编代码转换为与 9 进行比较:cmpl $9, -4(%rbp)。

  1. 控制转移操作

条件分支:紧跟在 argc 与 5 比较之后的是条件跳转指令 je .L2。如果两者相等(ZF 标志位为 1),则跨过错误处理逻辑跳转至 .L2;若不相等,则顺序向下执行打印和退出程序的指令。

循环控制:汇编采用了“跳转到中间(Jump-to-middle)”策略来翻译 for 循环。循环初始化后,先使用 jmp .L3 无条件跳转至循环条件判断处。在 .L3 进行比较后,若条件满足则通过 jle .L4(小于等于跳转)指令跳转至 .L4 标签执行循环体内的函数调用。

  1. 函数调用操作

根据 x86-64 的 ABI 调用约定,函数参数依次通过 %rdi、%rsi、%rdx、%rcx 等寄存器传递:

参数错误提示(编译器优化):对于 printf("用法: Hello 学号 姓名 手机号 秒数!\n");,编译器识别出该字符串不含格式化参数且以换行符结尾,将其智能优化为调用 puts,即 call puts@PLT。

程序退出:对于 exit(1),通过 movl $1, %edi 将参数 1 放入 %edi,随后执行 call exit@PLT。

循环内打印:对于 printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);,代码分别将 .LC1 标签地址和三个字符串指针送入 %rdi、%rsi、%rdx、%rcx,并通过 movl $0, %eax 清零 %eax(因为 printf 属于可变参数函数),最后执行 call printf@PLT。

类型转换与休眠:对应 sleep(atoi(argv[4]));,首先将 argv[4] 的内存值放入 %rdi 并执行 call atoi@PLT,由于返回值自动保存在 %eax 中,紧接着执行 movl %eax, %edi 将其转为 sleep 的参数,最终调用 call sleep@PLT。

输入等待:主函数末尾直接执行 call getchar@PLT 等待终端输入。

3.4 本章小结

本章对 Hello 程序的编译阶段进行了深入分析。通过生成 hello.s 汇编文件,我们详尽地观察了 C 语言中的高级抽象是如何被一层层剥开的。无论是基本数据类型、字符串常量、算术运算,还是复杂的控制转移结构和严格遵循 ABI 规范的函数调用过程,编译器都精确地将其转化为 x86-64 架构下对应的底层机器操作指令。这不仅展示了编译器强大的代码转化与调度能力,也为理解程序在硬件上的实际运行轨迹提供了极佳的观测窗口。

(第3章2分)


第4章 汇编

4.1 汇编的概念与作用

汇编的概念:汇编是编译过程的第三步。在此阶段,汇编器(as)将编译器生成的汇编语言文本文件(hello.s)翻译成机器语言指令,并将这些指令打包成可重定位目标文件(hello.o)。

汇编的作用:汇编器将易于人类阅读的助记符指令转化为计算机硬件能够直接解析和执行的二进制机器码。生成的 .o 文件是一种 ELF 格式的二进制文件,它包含了程序的代码、数据以及用于指导下一步链接工作的重定位信息,但此时程序中各段的绝对内存地址尚未确定。

4.2 在Ubuntu下汇编的命令

图 6 汇编指令

图 7 汇编输出文件

4.3 可重定位目标elf格式

图 8 用readelf等列出其各节的基本信息

通过 readelf 工具分析 hello.o 的结构,可以得出以下关键的 ELF 格式信息:

  1. ELF头(ELF Header): 

通过 readelf -h hello.o 可以看到,文件的 Magic Number 为 7f 45 4c 46 02 01 01 00...,标识这是一个 64 位(ELF64)、小端序(little endian)的 ELF 文件。其类型(Type)为 REL (Relocatable file),说明这是一个可重定位目标文件。目标架构(Machine)为 Advanced Micro Devices X86-64。由于尚未链接,入口点地址(Entry point address)为 0x0。

  1. 节头表(Section Headers):

通过 readelf -S hello.o 可以看到文件包含了 14 个节。主要的节包括:

  1. .text(代码段):类型为 PROGBITS,大小为 0x9d 字节,拥有分配(A)和执行(X)权限,存放了编译后的机器级代码。
  2. .rodata(只读数据段):大小为 0x40 字节,存放了程序中的格式化输出中文字符串和 "Hello %s %s %s\n" 字符串常量。
  3. .data 和 .bss 节目前大小为 0,因为程序中没有已初始化和未初始化的全局/静态变量。
  4. .rela.text(重定位节):记录了代码段中需要重定位的位置信息。
  1. 重定位条目(Relocation Entries):

通过 readelf -r hello.o 查看到 .rela.text 节中包含 8 个重定位条目。由于汇编器在处理时不知道 .rodata 中字符串常量的最终地址,也不知道 puts、exit、printf、atoi、sleep、getchar 这些位于 C 标准库中的函数地址,因此为它们生成了重定位记录。其类型主要为 R_X86_64_PC32(用于相对数据的引用,如 .rodata)和 R_X86_64_PLT32(用于相对外部函数的过程链接表引用)。

4.4 Hello.o的结果解析

0000000000000000 <main>:

   0:   f3 0f 1e fa             endbr64

   4:   55                      push   %rbp

   5:   48 89 e5                mov    %rsp,%rbp

   8:   48 83 ec 20             sub    $0x20,%rsp

   c:   89 7d ec                mov    %edi,-0x14(%rbp)

   f:   48 89 75 e0             mov    %rsi,-0x20(%rbp)

  13:   83 7d ec 05             cmpl   $0x5,-0x14(%rbp)

  17:   74 16                   je     2f <main+0x2f>

  19:   48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi        # 20 <main+0x20>

                        1c: R_X86_64_PC32       .rodata-0x4

  20:   e8 00 00 00 00          callq  25 <main+0x25>

                        21: R_X86_64_PLT32      puts-0x4

  25:   bf 01 00 00 00          mov    $0x1,%edi

  2a:   e8 00 00 00 00          callq  2f <main+0x2f>

                        2b: R_X86_64_PLT32      exit-0x4

  2f:   c7 45 fc 00 00 00 00    movl   $0x0,-0x4(%rbp)

  36:   eb 53                   jmp    8b <main+0x8b>

  38:   48 8b 45 e0             mov    -0x20(%rbp),%rax

  3c:   48 83 c0 18             add    $0x18,%rax

  40:   48 8b 08                mov    (%rax),%rcx

  43:   48 8b 45 e0             mov    -0x20(%rbp),%rax

  47:   48 83 c0 10             add    $0x10,%rax

  4b:   48 8b 10                mov    (%rax),%rdx

  4e:   48 8b 45 e0             mov    -0x20(%rbp),%rax

  52:   48 83 c0 08             add    $0x8,%rax

  56:   48 8b 00                mov    (%rax),%rax

  59:   48 89 c6                mov    %rax,%rsi

  5c:   48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi        # 63 <main+0x63>

                        5f: R_X86_64_PC32       .rodata+0x2c

  63:   b8 00 00 00 00          mov    $0x0,%eax

  68:   e8 00 00 00 00          callq  6d <main+0x6d>

                        69: R_X86_64_PLT32      printf-0x4

  6d:   48 8b 45 e0             mov    -0x20(%rbp),%rax

  71:   48 83 c0 20             add    $0x20,%rax

  75:   48 8b 00                mov    (%rax),%rax

  78:   48 89 c7                mov    %rax,%rdi

  7b:   e8 00 00 00 00          callq  80 <main+0x80>

                        7c: R_X86_64_PLT32      atoi-0x4

  80:   89 c7                   mov    %eax,%edi

  82:   e8 00 00 00 00          callq  87 <main+0x87>

                        83: R_X86_64_PLT32      sleep-0x4

  87:   83 45 fc 01             addl   $0x1,-0x4(%rbp)

  8b:   83 7d fc 09             cmpl   $0x9,-0x4(%rbp)

  8f:   7e a7                   jle    38 <main+0x38>

  91:   e8 00 00 00 00          callq  96 <main+0x96>

                        92: R_X86_64_PLT32      getchar-0x4

  96:   b8 00 00 00 00          mov    $0x0,%eax

  9b:   c9                      leaveq

  9c:   c3                      retq   

表 2 objdump反汇编结果

使用 objdump -d -r hello.o 得到反汇编代码,将其与第 3 章的 hello.s 进行对照,可以发现汇编指令的逻辑未变,但形式和操作数已经发生了底层的映射转变:

  1. 机器语言的构成:

在反汇编代码中,左侧是十六进制的相对偏移地址,中间是变长的十六进制机器码,右侧是对应的汇编助记符。例如,push %rbp 被映射为单字节机器码 55;sub $0x20,%rsp 被映射为四字节机器码 48 83 ec 20。机器语言完全由这些二进制位(展示为十六进制)构成,直接受硬件解码执行。

  1. 分支转移的映射差异:

在 hello.s 中,跳转指令使用文本标签(如 je .L2,jmp .L3)。在机器指令 hello.o 中,标签被消除,取而代之的是PC相对寻址的偏移量。 例如,反汇编中的 17: 74 16 je 2f <main+0x2f>。机器码为 74 16,其中 74 是 je 的操作码,16 是操作数(偏移量)。当 CPU 读完这条 2 字节指令后,PC(程序计数器)的值为 0x19。计算 0x19 + 0x16 = 0x2f,这正是下一条目标指令(即原 .L2 标签处)的精确地址。

  1. 函数调用的映射差异:

在 hello.s 中,直接写有 call printf@PLT。而在 hello.o 的机器码中,例如 20: e8 00 00 00 00 callq 25 <main+0x25>,调用指令 call 的操作码 e8 后面跟着的 4 个字节全为 00 00 00 00。 这是因为汇编阶段尚未进行符号解析与链接,此时不知道 puts 或 printf 等函数的实际内存地址,于是暂时使用全 0 占位。紧跟其后的正是重定位提示:21: R_X86_64_PLT32 puts-0x4,它明确告诉后续的链接器:请在相对偏移 0x21 的位置填入 puts 函数的确切相对地址。

4.5 本章小结

本章详细解析了汇编阶段的工作原理。通过将 hello.s 汇编生成 hello.o,我们借由 readelf 工具剖析了 ELF 文件的底层结构,特别是节头表与重定位表的内容。结合 objdump 的反汇编输出,我们深入对比分析了从文本汇编到二进制机器码的精确映射,尤其是分支跳转的相对寻址计算以及外部函数调用的“占位与重定位”机制。这清晰地展示了可重定位目标文件是如何为最终的链接过程做好准备的。

(第4章1分)


5链接

5.1 链接的概念与作用

链接的概念:链接(Linking)是将各种代码和数据片段收集并组合成一个单一文件的过程,该文件可被系统加载(复制)到内存并执行。在这个阶段,链接器(ld)将汇编器生成的重定位目标文件(hello.o)与系统的各类启动目标文件以及 C 标准库结合,生成最终的可执行目标文件 hello。 链接的作用:链接器的核心作用包含两个方面。一是符号解析(Symbol Resolution),将目标文件中的每个全局符号引用(如 main)与唯一的符号定义绑定;二是重定位(Relocation),将多个合并后的代码节和数据节分配到具体的运行时虚拟地址,并修改重定位节中所有对符号的引用,使其指向正确的绝对地址或相对偏移地址,从而使得程序能够正确地调用系统函数(如 printf、sleep、exit 等)。 

5.2 在Ubuntu下链接的命令

图 9 链接过程

链接过程分析:通过 -v 参数可以观察到,真正的链接工作是由 collect2(GCC 包装的 ld 链接器)完成的。链接器不仅仅处理了我们的 hello.o,还按照特定的顺序链接了操作系统的启动文件和库文件: 首先链接了 crt1.o、crti.o 和 crtbegin.o,它们包含了程序的入口函数 _start 和初始化代码;然后链接了我们的 hello.o;接着链接了 C 标准库 -lc(提供 printf 等函数的实现);最后链接了 crtend.o 和 crtn.o 以提供程序退出和清理的代码。同时,指定了动态链接器 /lib64/ld-linux-x86-64.so.2。

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

图 10 readelf 工具分析链接后的可执行文件 hello

通过 readelf 工具分析链接后的可执行文件 hello,其 ELF 格式发生了显著变化:

  1. ELF头(ELF Header):

文件类型(Type)从 .o 文件的 REL (Relocatable file) 变成了 EXEC (Executable file)。

程序的入口地址(Entry point address)不再是 0x0,而是变为了具体的虚拟地址 0x4010f0(即 _start 函数的地址)。

  1. 节头表(Section Headers):

文件中的节数量大幅增加(31个节)。由于采用了 -no-pie 参数,各个节都被分配了明确的虚拟地址(Addr):

.interp(动态链接器路径):起始地址 0x400318。

.plt(过程链接表):起始地址 0x401020。

.text(代码段):起始地址 0x4010f0。

.rodata(只读数据段):起始地址 0x402000。

.got.plt(全局偏移表):起始地址 0x404000。

.data(已初始化数据)和 .bss(未初始化数据):分别位于 0x404048 和 0x404058。

5.4 hello的虚拟地址空间

图 11 info proc mappings结果

通过 info proc mappings 指令,可见 hello 进程的虚拟地址空间分布:

代码段:0x400000 到 0x401000(以及后续段)映射了可执行文件,具有 r-x 权限,存放 .text 代码段。

库映射区:0x7ffff7dbd000 到 0x7ffff7fa9000 映射了 libc-2.31.so,这是程序执行 printf 等系统函数所需的共享库。

动态链接器:0x7ffff7fcf000 到 0x7ffff7ffb000 映射了 ld-2.31.so,负责运行时符号解析。

栈区:0x7ffffffdd000 到 0x7ffffffff000,用于函数调用和局部变量存储。

5.5 链接的重定位过程分析

图 12 反汇编部分关键内容

通过对比 hello.o 和 hello 的反汇编代码,可以清晰地观察到链接器是如何将分散的目标文件转化为可执行文件的。

  1. 从偏移到绝对虚拟地址的映射:

在 hello.o 中,所有代码引用均基于相对于节起始位置的偏移(例如 call 0 形式)。而在链接后的 hello 中,链接器根据 ELF 节头表中的地址分配,将 main 函数重定位到了虚拟地址 0x401169,将 puts@plt 重定位到了 0x401060,将 printf@plt 重定位到了 0x401070。此时,所有的指令跳转和函数调用目标都已经指向了确定的内存区域。

  1. 函数调用的相对寻址重定位(以 puts@plt 为例):
  1. 在 hello.o 中:汇编器无法预知 puts 函数在标准库中的地址,因此 callq 指令的机器码被置为 e8 00 00 00 00,并在 .rela.text 中留下了重定位条目。
  2. 在 hello 中:该指令变为了 1183: e8 d8 fe ff ff callq 1060 <puts@plt>。
  3. 重定位原理验证:

当前 call 指令所在的地址是 0x1183,指令长度为 5 字节,下一条指令(即 PC 值)为 0x1188。

机器码 e8 d8 fe ff ff 中的偏移量为补码 0xfffffed8(即 -296 的十进制表示)。

跳转目标地址计算:0x1188 + (-296) = 0x1188 - 0x128 = 0x1060。

计算结果精确指向了 0x401060 处的 puts@plt。这证明了链接器通过填充偏移量,成功将各模块间的调用关系绑定到了一起。

  1. 链接的本质:

链接过程实际上是符号表合并与地址修正的过程。链接器通过全局扫描,将 hello.o 中对 puts、printf、atoi、sleep 等符号的“空引用”,根据动态库提供的真实地址进行了填补。通过计算指令间相对距离(PC 相对寻址),链接器消除了目标文件间的隔阂,使得最终生成的可执行文件能够通过相对跳转指令在进程空间中无缝地定位到库函数入口。

5.6 hello的执行流程

图 13 hello的执行流程(摘选部分)

  1. 执行流程分析

Hello 程序的执行是从操作系统内核将控制权移交给用户态程序的入口点开始的。通过 GDB 调试,我们追踪到了如下的关键控制流转移:

  1. 程序入口点 (_start):

程序执行的起点是 _start(地址:0x4010f0)。从汇编代码中可以看出,该段代码负责标记最外层函数栈帧以支持调试(xor %ebp, %ebp 清零基址寄存器)和堆栈对齐(and $0xfffffffffffffff0, %rsp)。最核心的跳转发生在 callq *0x2ed2(%rip)(地址 0x401118),此指令通过 GOT 表项间接调用了库函数 __libc_start_main,并将 main 函数的地址(0x4011d6)作为参数 rdi 传入,从而正式启动程序运行环境。

  1. 运行时环境初始化 (__libc_start_main):

控制权跳转至 __libc_start_main (0x7ffff7de0fc0) 后,程序进入 C 运行时库(glibc)。该函数完成了极其复杂的初始化工作,包括设置进程的用户态执行环境、动态链接的地址解析、注册 atexit 终止处理函数等。在完成了这一系列准备工作后,由该函数正式发起对用户 main 函数的调用。

  1. 用户主函数执行 (main):

控制权最终到达用户编写的 main 函数(地址:0x4011d6)。此时通过 push %rbp 和 mov %rsp, %rbp 建立了新的栈帧,并执行了源代码中定义的逻辑:

通过 callq 1060 <puts@plt> 处理参数数量不匹配的错误分支;

通过 callq 1070 <printf@plt> 循环打印 Hello 字符串;

调用 atoi@plt 和 sleep@plt 处理秒数控制;

最后通过 callq 10b0 <getchar@plt> 阻塞等待输入。

  1. 程序终止流程:

在 main 执行完毕后,通过 leaveq 和 retq 返回至 __libc_start_main。程序调用 __GI_exit 进行退出清理(如关闭打开的文件流),最终由内核执行 syscall 系统调用(0x7ffff7de1114),彻底释放内存资源并结束进程。

  1. 调用跳转序列汇总表

子程序/标签名

虚拟地址 (基于你发来的数据)

作用描述

_start

0x4010f0

程序加载后的初始执行起点

__libc_start_main

0x7ffff7de0fc0

环境初始化,负责调用 main

main

0x4011d6

用户主函数入口

puts@plt

0x401060

参数不符合要求时的错误信息打印

exit@plt

0x4010d0

错误情况下的进程终止

printf@plt

0x401070

循环体内的核心输出

atoi@plt

0x4010c0

解析 argv[4] 参数字符串

sleep@plt

0x4010e0

执行休眠逻辑

getchar@plt

0x4010b0

阻塞程序,等待键盘输入

5.7 Hello的动态链接分析

图 14 动态链接前后 printf@got.plt 地址的变化对比。初始化阶段的占位符地址和调用后已绑定的 libc 绝对地址。

  1. 实验现象

通过 GDB 调试,我们跟踪了 printf 函数在运行时的地址解析过程:

初次观察(延迟绑定前):程序运行至 main 函数入口时,查看 .got.plt 中 printf 对应的条目(地址 0x404020),其存储值为 0x401040。该地址指向 PLT 中的一段公共跳转代码,表明此时 printf 的真实物理地址尚未解析,程序处于延迟绑定的初始状态。

二次观察(绑定完成后):在 main 函数执行完循环并调用 printf 后,再次查看同一地址 0x404020,发现其值已更新为 0x00007ffff7e1ecc0。这是一个位于 libc.so.6 库空间内的真实内存地址。

  1. 结论分析

实验结果清晰地展示了动态链接器的核心机制:

延迟绑定机制:系统在程序启动时并不直接定位库函数的真实地址,而是通过 GOT 表的初始占位符引导至动态链接器。

地址覆写(Overwrite):当程序第一次执行 printf 调用时,动态链接器介入,通过符号查找获取 libc 中 printf 的绝对物理地址,并将其回填至 .got.plt 表对应的内存单元。

优化性能:后续的循环调用直接通过 .got.plt 访问已绑定的物理地址,避免了重复解析,极大提升了程序执行效率。

5.8 本章小结

本章深入分析了 hello 程序从源代码到可执行文件的构建过程,并利用 GDB 调试器对其链接及运行时行为进行了深度剖析。总结如下:

  1. 编译与链接流程:通过对比 .o 可重定位目标文件与 hello 可执行文件的反汇编代码,验证了链接器(ld)如何通过扫描符号表、处理 .rela.text 重定位条目,完成了跨模块的符号地址填充。通过对 main 函数中 callq 跳转指令的计算,验证了 PC 相对寻址在链接过程中的确定性作用。
  2. 虚拟内存布局:通过 info proc mappings 指令,我们观测到了程序各段(代码段、数据段、共享库及栈)在虚拟内存中的精确分布。实验证明,操作系统通过段权限管理(如代码段 r-x,数据段 rw-)有效保障了程序的运行安全,同时也清晰展示了动态链接库 (libc.so) 的运行时加载机制。
  3. 动态链接机制:本章核心实验验证了 printf 等函数的延迟绑定(Lazy Binding)策略。调试结果显示,.got.plt 表中的占位符在程序执行初期指向 PLT 入口,而在函数被首次调用后,动态链接器成功将其覆写为库函数的绝对物理地址。这一机制在兼顾启动速度与内存效率的同时,实现了标准库函数的无缝对接。
  4. 程序执行生命周期:通过汇编级单步跟踪,理清了程序从内核态 execve 到用户态 _start,再进入 __libc_start_main 完成初始化,最后抵达 main 函数的完整控制流转移路径。这一过程完整还原了现代操作系统运行程序的真实底层逻辑。

综上所述,本章不仅验证了链接技术的理论基础,更通过详尽的调试实验,揭示了链接器、动态链接器与操作系统内核在程序运行中的紧密配合。这为后续章节对进程生命周期管理及存储管理的深入探讨奠定了坚实的技术基础。

(第5章1分)


6hello进程管理

6.1 进程的概念与作用

进程(Process)是计算机科学中最深刻、最成功的概念之一,它的经典定义是:一个执行中程序的实例。 在系统中,进程为应用程序提供了两个极为关键的抽象:

  1. 独立的逻辑控制流:它提供给程序一种假象,仿佛该程序在独占地使用处理器。每个进程都在系统内核的时间片轮转调度下并发执行。
  2. 私有的地址空间:它提供给程序一种假象,仿佛该程序独占地使用系统的内存系统。通过虚拟内存机制,每个进程都拥有一套独立的虚拟地址集合,不同进程间的内存互不干扰。

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

Shell(如 Linux 中的 bash)是一个交互型的应用级程序,它代表用户运行其他程序,是用户与操作系统内核交互的界面(外壳)。 Bash 的处理流程如下:

  1. 读取输入:从标准输入(键盘)读取用户键入的命令行。
  2. 解析命令:将命令行字符串分割成参数数组(如分解出可执行文件路径和各个参数)。
  3. 判断命令类型:检查该命令是否为内置命令(built-in command,如 cd, pwd, jobs 等)。如果是,则直接在 Shell 进程内部执行。
  4. 创建与执行(若是外部命令):如果是外部可执行文件(如 ./hello),Shell 会调用 fork() 创建一个子进程,随后在子进程中调用 execve() 加载并运行该程序。
  5. 前后台管理:如果命令非后台运行(没有 & 结尾),Shell 会调用 waitpid() 阻塞自身,等待子进程(前台作业)执行完毕并回收它;如果后台运行,Shell 则直接打印该进程的 PID,并等待接收下一条命令[1]。

6.3 Hello的fork进程创建过程

当在 Bash 中输入 ./hello 2024111842 黄邱与 13036578390 0 并回车后,Bash 发现这是一个外部程序,于是调用 fork() 函数为 Hello 程序创建一个全新的子进程。

状态克隆:新创建的子进程几乎但不完全等同于父进程(Bash)。它获得了父进程用户级虚拟地址空间的一个副本(包括代码和数据段、堆、共享库以及用户栈),并获得了父进程任何打开文件描述符相同的副本(所以 Hello 进程能和 bash 一样在终端输出信息)。

独立并发:子进程拥有自己独立的 PID。fork 调用会返回两次:在父进程(Bash)中返回子进程的 PID,在子进程中返回 0。从此,Hello 的子进程与 Bash 进程开始并发执行。

6.4 Hello的execve过程

子进程被 fork 创建出来后,虽然有了独立的资源,但此时它运行的仍然是 Bash 的代码。为了运行 Hello 程序,子进程会调用 execve 函数。

覆盖当前进程:execve 函数在当前子进程的上下文中加载并运行 Hello 程序。它会擦除子进程现有的用户区域(除了 PID 和文件描述符等保留资源)。

加载 ELF 文件:操作系统的加载器解析 hello 这个 ELF 格式的可执行文件,根据其程序头表(Program Header Table),将代码段、数据段映射到子进程的虚拟内存空间中。

初始化与跳转:加载器设置好用户栈(将命令行参数 argv 和环境变量 envp 压入栈中),最后将 PC(程序计数器)跳转到 ELF 规定的入口点 _start,至此,Hello 程序真正开始在自己的进程上下文中执行。

6.5 Hello的进程执行

在 Hello 程序的运行过程中,CPU 会在多个进程之间交替执行,这就涉及到上下文切换(Context Switch)和用户态/核心态转换。

  1. 时间片轮转:Hello 进程并不是一直占用 CPU。内核为每个进程分配时间片,当 Hello 的时间片耗尽,或者执行了 sleep 函数主动让出 CPU 时,内核会触发定时器中断或系统调用。
  2. 模式切换(用户态到核心态):当发生系统调用(如 printf 底层调用 write,或者调用 sleep)、中断或异常时,CPU 会将控制权转移给内核,状态寄存器中的模式位从用户态(User Mode)切换为核心态(Kernel Mode)。内核此时拥有访问系统所有资源的最高权限。
  3. 上下文切换:在核心态下,内核决定挂起 Hello 进程,它会将 Hello 当前的上下文(包括通用寄存器、程序计数器、状态寄存器、用户栈指针等)保存到内存中的进程控制块(PCB)里。随后,内核恢复另一个进程的上下文,并将控制权交给那个进程。当再次调度到 Hello 进程时,内核恢复其上下文,从上次保存的位置继续执行。

6.6 hello的异常与信号处理

  1. 异常的分类与处理

在 Hello 程序的整个生命周期中,主要会产生以下几类异常(Exceptional Control Flow):

  1. 中断(Interrupt):属于异步异常。来自处理器外部的 I/O 设备。例如在程序运行时,定时器芯片发出的时钟中断(用于进程调度和 sleep 唤醒),以及键盘输入导致的外设中断。处理完后会返回当前指令的下一条指令。
  2. 陷阱(Trap)/ 系统调用:属于同步异常。Hello 执行 printf(底层调用 write)、sleep、exit 时,会通过 syscall 指令主动触发陷阱,陷入内核态执行操作系统提供的服务。
  3. 故障(Fault):属于同步异常。例如在程序加载和执行初期,由于虚拟内存尚未分配物理页,会触发缺页故障(Page Fault)。内核的缺页处理程序为其分配内存后,会返回重新执行触发故障的指令。
  1. 终端交互操作与信号处理机制验证

根据实验要求,我们在终端中对正在运行的 Hello 进程进行了各种键盘与命令交互(如图 15,按照ppt要求0秒过短,来不及按键,故此节采用2秒),具体分析如下:

图 15 各命令与运行结果

  1. 不停乱按键盘与回车:

现象:程序打印时在键盘输入 sadfa 等字符并按下回车,字符被回显在屏幕上,但 Hello 程序依然按照原有的节奏打印。

处理机制:按下键盘触发了硬件中断,操作系统的键盘中断处理程序拦截了该信号,将按键存入键盘缓冲区,并回显到终端屏幕。因为 Hello 此时的主循环中没有请求从标准输入读取数据(直到最后的 getchar),所以这些输入处于被缓存状态,没有影响进程的当前控制流。

  1. 按下 Ctrl-Z 挂起进程与进程状态查看:

现象:按下 Ctrl-Z 后,终端显示 Stopped。

处理机制:终端驱动程序捕捉到该组合键,向系统前台进程组发送了一个 SIGTSTP (Signal 20) 信号。Hello 进程收到该信号后被挂起(停止调度),控制权交还给 Bash Shell。

命令验证:

jobs:Shell 打印出后台作业列表,显示有两个 Hello 进程处于 Stopped 状态。

ps:读取内核的进程表,清晰地显示了 PID 为 9270 和 9405 的 Hello 进程仍驻留在系统中,并没有被销毁。

pstree | grep hello:显示出 bash 进程下挂载了 hello 的子进程树,证明了它们之间的父子进程关系。

  1. 使用 fg 调回前台与 Ctrl-C 终止进程:

现象:执行 fg %1 后,程序从挂起处继续打印。按下 Ctrl-C 后,程序立即退出,返回 Shell 提示符。

处理机制:fg 命令导致 Bash 向该进程组发送 SIGCONT (Signal 18) 信号。内核重新将该进程加入就绪队列,Hello 进程得以继续执行。随后按下的 Ctrl-C 让终端驱动发出 SIGINT (Signal 2) 信号,Hello 进程捕获该信号后执行其默认行为——强制终止进程,随后由 Bash 进程回收其资源。

  1. 使用 kill 命令处理进程:

现象与分析:在终端输入 kill -9 %1 试图杀死作业 1 时,Shell 提示 no such job。这是因为作业 1 在上一步中已经被 Ctrl-C(SIGINT 信号)终止并回收。

处理机制:随后再次运行并使用 Ctrl-Z 挂起时,作业号变为了 3。若对存在的进程执行 kill -9 %PID,则相当于向目标发送 SIGKILL (Signal 9) 信号。该信号无法被程序捕获或忽略,内核会无条件地强制剥夺其所有资源并彻底终止进程。

6.7本章小结

本章以进程为主线,详细梳理了操作系统管理程序执行的底层机制。 首先,阐述了进程提供的“独立逻辑控制流”与“私有地址空间”两大核心抽象。其次,通过追踪 Bash 外壳程序的处理流程,我们明确了 fork 函数创建子进程(克隆状态与虚拟内存)以及 execve 函数加载覆盖可执行文件(建立代码与数据映射)的具体机制。在进程执行层面,探讨了在内核调度的指挥下,程序如何在用户态与核心态之间完成模式切换与上下文切换。 最后,通过大量的终端实操验证,我们观察了中断、陷阱及故障等异常的产生。结合 Ctrl-Z、Ctrl-C、jobs、fg 等命令的组合使用,深入剖析了 SIGINT、SIGTSTP、SIGCONT 和 SIGKILL 等关键信号的生成、传递与默认处理行为。这一章清晰地展现了计算机系统利用“异常控制流”来保障多任务并发安全与软硬件协同交互的智慧。

(第6章2分)


7hello的存储管理

7.1 hello的存储器地址空间

在 hello 程序的运行过程中,系统内存管理涉及多种地址概念,它们在不同的抽象层次上发挥作用:

逻辑地址(Logical Address):逻辑地址是机器语言指令中用来指定操作数或指令位置的地址。在 Intel 架构中,逻辑地址由“段选择符(Segment Selector)”和“段内偏移量(Offset)”组成。在 hello 的反汇编代码中,我们看到的如 0x4011d6 这样的地址,实际上是段内偏移量。

线性地址(Linear Address):逻辑地址经过段式内存管理单元(Segmentation Unit)转换后得到的地址。线性地址空间是一个连续的、非分段的地址空间。

虚拟地址(Virtual Address,VA):在现代启用了分页机制的操作系统(如运行 hello 的 Linux 系统)中,线性地址实际上就是虚拟地址。虚拟地址是 hello 进程所“看到”的地址空间。每个进程都有独立的虚拟地址空间(例如 64 位 Linux 中的 256 TB 空间)。

物理地址(Physical Address,PA):物理地址是实际访问主存(RAM)硬件时使用的地址。CPU 内存管理单元(MMU)负责将 hello 的虚拟地址(或线性地址)转换成内存条上真实的物理地址。

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

在 Intel x86 架构中,段式管理是逻辑地址转换为线性地址的第一步机制。

  1. 分段机制:逻辑地址由 16 位的段选择符(存放在段寄存器如 CS, DS, SS 中)和段内偏移量(即程序中实际使用的地址值)组成。
  2. 描述符表:系统维护着全局描述符表(GDT)或局部描述符表(LDT)。段选择符中包含了索引,用于在 GDT/LDT 中查找对应的段描述符(Segment Descriptor)。
  3. 地址计算:段描述符中存储了该段的基地址(Base Address)、段界限(Limit)和访问权限。MMU 硬件通过将段描述符中的“基地址”与逻辑地址中的“偏移量”相加,计算出线性地址。

在 hello 程序中的实际应用: 现代 64 位 Linux 系统(x86-64)采用了平坦内存模型(Flat Memory Model)。在这种模型下,所有段的基地址都被统一设置为 0。因此,逻辑地址中的偏移量直接等于计算出的线性地址(虚拟地址),段式管理的地址转换在实际效果上被“旁路”了。

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

在平坦模型下,线性地址即等于虚拟地址。hello 程序通过页式管理(Paging)将虚拟地址变换为物理地址。

  1. 分页概念:系统将虚拟内存和物理内存分割为固定大小的块,称为虚拟页(Virtual Page, VP)和物理页(Physical Page, PP),通常大小为 4KB。
  2. 地址拆分:MMU 将 hello 的虚拟地址拆分为两部分:虚拟页号(VPN)和虚拟页偏移量(VPO)。物理地址也同样被划分为物理页号(PPN)和物理页偏移量(PPO)。
  3. 页表映射:操作系统在主存中维护着页表(Page Table),页表本质上是一个存放页表条目(PTE)的数组。MMU 利用 VPN 作为索引查找对应的 PTE。
  4. 地址合成:
  1. 由于页大小相同,页内偏移保持不变,即 VPO = PPO。
  2. 如果 PTE 的有效位(Valid Bit)为 1,MMU 从 PTE 中提取出物理页号 PPN。
  3. MMU 将 PPN 与 VPO 拼接,生成最终的物理地址 PA。

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

现代 Core i7 处理器支持 64 位架构,使用 48 位虚拟地址和 52 位物理地址,并采用了 TLB(翻译后备缓冲器) 和 四级页表 机制来加速和管理地址转换。

当 hello 进程尝试访问一个虚拟地址 VA 时,地址转换的完整过程如下:

  1. TLB 缓存查找:

虚拟地址 VA 的 VPN 被进一步拆分为 TLB 标记(TLBT)和 TLB 索引(TLBI)。MMU 首先在 TLB 中查找。如果命中(TLB Hit),直接获取 PPN,将其与 VPO 拼接得到 PA,转换完成。

  1. 四级页表漫游(TLB Miss):

如果 TLB 未命中,硬件页表漫游器(Page Walker)需要访问内存中的四级页表。48 位 VA 被分为:四个 9 位的虚拟页号(VPN1 到 VPN4)和一个 12 位的页偏移量(VPO)。

第一级(PGD):控制寄存器 CR3 包含第一级全局页目录的物理基址。MMU 使用 VPN1 作为索引查找 PGD 条目,获得第二级页表的基址。

第二级(PUD):使用 VPN2 作为索引查找,获得第三级页表的基址。

第三级(PMD):使用 VPN3 作为索引查找,获得第四级页表的基址。

第四级(PT):使用 VPN4 作为索引,在最终的页表中查找到对应的 PTE,获取实际的物理页号 PPN。

  1. 合成地址并更新 TLB:

获取 PPN 后,MMU 将其与 VPO 组合成物理地址 PA。同时,硬件将这个新的映射关系更新到 TLB 中,以便下次快速访问。

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

获得了物理地址 PA 之后,CPU 需要从内存层次结构中提取数据,这依赖于 L1、L2、L3 三级高速缓存(Cache)。

  1. 物理地址拆分:物理地址 PA 被拆分为三个字段:缓存块偏移(CO)、缓存组索引(CI)和缓存标记(CT)。
  2. L1 Cache 访问:

组选择:根据 CI 字段,定位到 L1 Cache(分为数据 Cache 和指令 Cache)中对应的高速缓存组。

行匹配:在该组内的所有缓存行中,对比有效位(Valid Bit)和缓存标记。如果有效位为 1 且标记与 CT 匹配,则发生缓存命中(Cache Hit)。

字抽取:命中后,根据偏移量 CO,从缓存行的数据块中提取所需的字节或字长数据,返回给 CPU。

  1. 多级回退:

如果 L1 Cache 未命中(Cache Miss),硬件将向 L2 Cache 发起相同的查找过程;如果 L2 依然未命中,则查找 L3 Cache;若 L3 依然未命中,则最终通过内存总线向主存(DRAM)发起读取请求。主存返回数据后,会将其沿着 L3、L2、L1 的路径依次缓存,以备后用

7.6 hello进程fork时的内存映射

当在 Shell 中输入执行命令时,父进程(Shell)会调用 fork 为 hello 创建子进程。fork 调用触发的内存映射过程如下:

  1. 数据结构复制:内核为新进程分配独有的 PID,并创建其特有的进程控制块(PCB)、mm_struct(内存描述符)、区域结构(vm_area_struct)和页表的精确副本。
  2. 写时复制(Copy-on-Write, COW):

为了提高效率,内核在此阶段并不会直接复制整个物理内存页。

它将父进程和子进程(hello 的外壳)的所有虚拟页面标记为只读(Read-Only)。

将两个进程的区域结构(VMA)都标记为私有的、写时复制。

  1. 独立执行:此时 hello 的子进程与 Shell 共享相同的物理内存。只有当其中任何一个进程试图执行写操作(如修改变量)时,才会触发保护异常。此时内核才会为尝试写操作的进程在物理内存中创建该页面的新副本,并将相应的页表条目恢复为可写。

7.7 hello进程execve时的内存映射

fork 之后,子进程调用 execve 函数来加载和执行 hello 可执行文件。execve 会利用虚拟内存机制完成当前进程空间的重写:

  1. 删除已存在的用户区域:删除当前进程虚拟地址空间中已存在的所有用户级区域结构(即清理从 Shell 继承来的旧映射)。
  2. 映射私有区域:为 hello 的代码、数据、bss 节和用户栈创建新的区域结构。

.text 节和 .data 节被映射为私有的、写时复制的,对应于磁盘上 hello 文件的相应部分。

.bss 节被映射为匿名文件,初始全为零。

栈和堆区域同样被映射为匿名文件,初始长度为空。

  1. 映射共享区域:如果 hello 用到了动态链接库(如 libc.so),系统会将这些共享对象动态链接并映射到虚拟地址空间的共享区域。
  2. 设置 PC:最后,execve 将程序计数器(PC)指向 hello 代码区域的入口点(_start)。至此,通过按需页面调度(Demand Paging),物理内存将在程序实际访问时才被逐步加载。

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

在 hello 程序的执行过程中,如果 CPU 访问的虚拟页没有缓存在物理内存中(即 PTE 中的有效位为 0),就会触发缺页故障(Page Fault),产生异常转移到内核的缺页中断处理程序。

处理流程如下:

  1. 合法性检查(段错误判断):缺页处理程序首先检查发生故障的虚拟地址是否在 hello 进程合法的虚拟内存区域(VMA)内。如果地址越界,内核触发“段错误(Segmentation Fault)”并终止进程。
  2. 权限检查(保护异常):检查进程是否有权限执行该操作(例如,是否试图写一个只读页面,如 .text 代码段)。如果不具备权限,内核触发保护异常并终止进程。
  3. 选择牺牲页:如果访问合法,说明是正常的缺页。内核必须选择一个物理页框作为牺牲页(Victim Page)。如果该页面被修改过(Dirty Bit为1),则先将其写回磁盘(Swap 空间)。
  4. 页面调入与更新:内核从磁盘(如可执行文件 hello 或交换区)读取缺失的页面,加载到刚腾出的物理页框中,随后更新 hello 进程相应的 PTE,将其有效位设为 1,并填入新的物理页号。
  5. 恢复执行:缺页中断处理程序返回,重新执行引发缺页故障的那条指令。此时,由于页已驻留主存,MMU 将顺利完成 VA 到 PA 的转换。

7.9动态存储分配管理

hello 程序在调用 printf 等标准库函数时,底层往往会隐式调用 malloc 等动态内存分配器在堆(Heap)中分配缓冲区。动态内存分配器维护着一个进程的虚拟内存区域——堆,它紧接在未初始化数据区域后向上生长。

  1. 动态内存管理的基本方法:

分配器将堆视作一组大小不同的块(Block)的集合来维护,每个块或者是已分配的,或者是空闲的。典型的块结构包含块头部(记录块大小和分配状态位)、有效载荷(Payload)以及为了对齐的填充。

  1. 空闲链表组织与管理策略:
  1. 隐式空闲链表:所有的块(包括空闲和已分配)通过头部中的块大小连成一个单向序列。分配时需线性遍历以寻找足够大的空闲块。
  2. 显式空闲链表:仅在空闲块的有效载荷区存放前驱(Pred)和后继(Succ)指针,形成双向链表,大幅提升了查找和释放效率。
  3. 分离适配(Segregated Fits):维护多个空闲链表,每个链表中的空闲块大小在一个特定的范围内(如 2 的幂次类),这是目前 glibc 的 malloc 采用的主流高效策略。
  1. 空闲块的分配与适配策略:

当程序请求分配内存时,分配器根据以下策略搜索空闲块:

  1. 首次适配(First Fit):从头开始查找,选择第一个足够大的空闲块。速度较快,但易产生前端碎片。
  2. 下一次适配(Next Fit):从上一次查找结束的地方开始搜索。
  3. 最佳适配(Best Fit):查找整个链表,选择足够大且最接近请求大小的空闲块。能最大程度减少内存碎片。
  1. 合并与释放策略:

当通过 free 释放内存时,分配器会检查相邻块是否也是空闲的。为了解决假碎片问题,分配器采用立即合并或推迟合并策略,利用空闲块的脚部(Footer)实现快速的边界标记合并[1]。

7.10本章小结

本章以 hello 程序的运行为载体,详细探讨了现代计算机系统中的存储管理机制。从底层的地址转换入手,理清了逻辑地址、线性地址、虚拟地址到物理地址的映射链路;深入剖析了在 TLB 与多级页表硬件支持下,操作系统如何高效实现 VA 到 PA 的转换,并借助 L1-L3 Cache 消除 CPU 与主存间的速度鸿沟。此外,结合进程的生命周期,探讨了 fork 和 execve 阶段虚拟内存区域的创建与重写机制,以及缺页故障的底层处理流程。最后,对运行时动态存储分配的基本原理和链表管理策略进行了总结。存储管理通过各种复杂的硬件机制与内核数据结构,不仅保护了 hello 进程的安全与独立性,也极大提升了系统的内存利用效率。

(第7章 2分)


8hello的IO管理

8.1 Linux的IO设备管理方法

在 Linux 操作系统中,所有的 I/O 设备(如网络、磁盘、终端显示器、键盘等)都被模型化为文件。这种将设备优雅地抽象为文件的方式,使得系统中的所有输入和输出都可以当做对相应文件的读和写来执行。

  1. 设备的模型化:文件

Linux 的“万物皆文件”理念意味着,一个 Linux 文件就是一个 m 个字节的序列。所有的 I/O 设备都被映射为文件系统中的节点。例如,hello 程序的终端标准输入被映射为 /dev/stdin,标准输出被映射为 /dev/stdout。

  1. 设备管理:Unix I/O 接口

既然设备被抽象为了文件,Linux 内核便提供了一个简单、低级的应用接口,称为 Unix I/O。这套接口提供了一组统一的系统调用,使得应用程序在管理设备和进行数据传输时,无需了解底层硬件的物理结构和通信细节,只需统一使用由 Unix I/O 定义的读写 API 即可完成所有的 I/O 操作。

8.2 简述Unix IO接口及其函数

Unix I/O 接口提供了一组基础且统一的系统级函数,供应用程序执行设备的打开、读写、定位和关闭操作。其核心接口函数主要包括:

  1. 打开和创建文件:open()

应用程序通过调用 open 函数来要求内核打开或创建指定的文件。内核返回一个非负整数,称为文件描述符(File Descriptor, fd)。这个描述符在后续的所有操作中用于标识该文件。Linux Shell 默认会为每个进程打开三个文件描述符:0(标准输入)、1(标准输出)和 2(标准错误)。

  1. 改变当前文件位置:lseek()

对于每个打开的文件,内核会维护一个文件位置 k(初始为 0)。应用程序可以通过 lseek 函数显式地设置文件的当前位置,以便在文件的特定位置进行读写。

  1. 读写文件:read() 和 write()
  1. read(fd, buf, n):从文件描述符 fd 的当前文件位置复制最多 n 个字节到内存缓冲区 buf 中,并将文件位置向后移动实际读取的字节数。
  2. write(fd, buf, n):从内存缓冲区 buf 复制最多 n 个字节到文件描述符 fd 当前的文件位置,并相应更新文件位置。
  1. 关闭文件:close()

当应用程序完成 I/O 操作后,调用 close(fd) 通知内核关闭指定文件。内核随后释放该文件打开时占用的数据结构,并将该文件描述符恢复到可用的描述符池中。

8.3 printf的实现分析

hello 程序中的 printf 并不是直接与显示器硬件对话的,它的执行经历了一个从用户层格式化到系统调用,再到硬件驱动渲染的复杂链路:

  1. 格式化与缓冲区生成 (vsprintf):

printf 接收到格式化字符串和变长参数后,内部会调用 vsprintf 函数。vsprintf 的作用是解析格式化占位符(如 %s, %d),将各种类型的数据转换为 ASCII 字符序列,并将其写入到一个预分配的字符数组缓冲区(buf)中,最终返回需要打印的字符串长度。

  1. 触发系统调用 (write):

构造好缓冲区后,printf 内部会调用封装了系统调用的 write(1, buf, len) 函数,尝试将生成的字符串写入到文件描述符 1(标准输出)。在 x86-64 架构下,这个封装函数会将系统调用号(对于 write 是 1)放入 %rax 寄存器,将参数放入 %rdi, %rsi, %rdx 中,然后执行 syscall 指令(在早期的 32 位系统中是 int 0x80)。这会触发同步异常(陷阱),使 CPU 状态切换到核心态,并将控制权交给内核的 sys_write 处理程序。

  1. 字符显示驱动处理:

内核接收到写入标准输出的请求后,会将其传递给终端设备驱动程序。设备驱动将 ASCII 字符串转换成可以在屏幕上显示的像素图案。它通过查阅系统内置的字模库(Font Library),将每一个 ASCII 字符映射为对应的点阵或矢量字模。

  1. 写入 VRAM 与硬件渲染:

驱动程序根据字模信息,将构成字符的每一个像素点的 RGB 颜色信息写入到显卡的显存(VRAM)中的对应地址。最后,显示芯片(GPU 或显示控制器)按照固定的硬件刷新频率,逐行、不间断地读取 VRAM 中的 RGB 颜色数据,通过信号线传输给液晶显示器面板,最终点亮对应的物理像素[2],hello 的字符串便呈现在了屏幕上。

8.4 getchar的实现分析

hello 程序在末尾调用了 getchar(),这一过程揭示了计算机处理异步外部输入的全貌:

  1. 硬件层面的异步异常(键盘中断):

当用户在键盘上按下一个键时,键盘控制器硬件会捕捉到这个物理动作,并向 CPU 的中断引脚发送一个电信号,引发一个硬件中断(异步异常)。CPU 在完成当前指令后,暂停正在执行的进程,利用中断向量表跳转到内核中相应的键盘中断处理子程序。

  1. 键盘缓冲区填充:

键盘中断处理程序从键盘控制器的一个特定端口读取该按键的物理“扫描码(Scan Code)”。接着,驱动程序将该扫描码映射成对应的 ASCII 码字符。如果是一个普通字符,该 ASCII 码会被保存到系统内核维护的键盘输入缓冲区(Keyboard Buffer)中。处理完毕后,中断返回。

  1. 用户态的系统调用(read):

回到 hello 程序的上下文,getchar() 函数本质上是调用了系统函数 read(0, buf, 1),请求从文件描述符 0(标准输入)读取一个字节。由于终端内核驱动默认工作在规范模式(Canonical Mode,表现为行级缓冲),read 调用会被内核阻塞(进程进入睡眠状态),直到内核判断用户在键盘上按下了“回车键”(即生成了换行符 \n)。

  1. 数据返回:

当接收到回车键中断后,内核将缓冲区中的 ASCII 字符复制到 getchar 提供的用户空间缓冲区中。此时 read 系统调用完成,进程被唤醒,getchar 提取出第一个字符并返回给 hello 程序。

8.5本章小结

本章系统地剖析了 hello 程序中 I/O 操作的底层实现机制。

Linux 通过“万物皆文件”的抽象模型,将极其复杂的底层物理设备统一简化为文件,并提供了一套精简的 Unix I/O 接口(open, read, write, close, lseek),使得应用程序能够在不关心底层硬件通信协议的前提下进行数据传输。

通过深度跟踪 hello 源码中的两个核心 I/O 调用,我们清晰地看到了一次普通的输入输出背后的系统全景: printf 展示了数据是如何从用户空间的格式化字符串(vsprintf),通过系统调用陷阱(syscall)进入内核,再被设备驱动翻译为字模像素,最终写入显存由显示器渲染出来的自上而下的流动;而 getchar 则完美演示了外部电信号是如何触发硬件异步中断,进而转化为系统缓冲区内的 ASCII 码,并最终通过 read 调用被阻塞等待的用户态进程接收的自下而上的交互。 存储与 I/O 共同构成了计算机软硬件接口的基石,至此,hello 程序在计算机系统中的完整漫游之旅画上了句号。

(第8章 选做 0分)

结论

  1. 用计算机系统的语言,逐条总结 hello 所经历的过程

Hello 程序看似简单的“打印与退出”,实际上在底层经历了一场极其复杂的“计算机系统漫游”。它的一生可以高度浓缩为以下十个关键阶段:

  1. 编写与预处理(Preprocessing):在高级语言层面编写 hello.c 源码,预处理器(cpp)解析 #include 等伪指令,将外部头文件插入,生成经过宏展开的文本文件 hello.i。
  2. 编译(Compilation):编译器(cc1)进行词法、语法和语义分析,将 C 语言代码翻译为特定指令集架构(ISA)的汇编语言程序 hello.s。
  3. 汇编(Assembly):汇编器(as)将汇编指令翻译为机器可识别的二进制机器语言,生成可重定位目标文件 hello.o。
  4. 链接(Linking):链接器(ld)进行符号解析和重定位,将 hello.o 与系统库(如 libc)的代码及数据合并,计算出准确的相对或绝对地址,最终生成可执行目标文件 hello。
  5. 进程创建(Process Creation):在终端输入执行指令后,Bash Shell 作为父进程调用 fork 函数,为 Hello 映射出一套拥有独立私有地址空间和逻辑控制流的子进程。
  6. 程序加载(Loading):子进程调用 execve 函数,擦除原有的用户区域,重新分配代码段、数据段及堆栈区域的虚拟内存映射,并将 PC 指针指向 ELF 文件的入口点 _start。
  7. 动态链接(Dynamic Linking):程序启动及执行时,动态链接器通过 GOT 和 PLT 表的协同机制,采用延迟绑定(Lazy Binding)策略,在运行时解析并回填 printf 等共享库函数的绝对物理地址。
  8. 内存访问与地址翻译(Memory Translation):CPU 执行访存指令时,发出虚拟地址(VA)。MMU 结合控制寄存器中的页表基址,在 TLB 和多级页表中进行查找。若发生缺页故障(Page Fault),则触发陷阱调入物理页;若翻译成功,则生成物理地址(PA)去多级 Cache 和主存中提取数据。
  9. I/O 操作与异常控制流(I/O & ECF):printf 通过 syscall 触发陷阱陷入内核态,设备驱动将 ASCII 码渲染为显存中的字模像素;而 getchar 则导致进程阻塞,直到键盘硬件中断引发系统调用返回,实现了异步信号与软硬件的交互。
  10. 终止与回收(Termination & Reaping):main 函数返回后,调用 exit 终止进程。内核释放其占用的绝大部分数据结构和物理内存。最终,作为僵死(Zombie)进程的 Hello 被父进程 Bash 通过 waitpid 彻底回收,消失在系统的尘埃中。
  1. 对计算机系统设计与实现的深切感悟

纵观 Hello 的一生,我对计算机系统的设计哲学产生了极大的震撼,最深切的感悟可以概括为两个词:抽象(Abstraction)与缓存(Caching)。

  1. 抽象的艺术:指令集架构是对 CPU 硬件的抽象,虚拟内存是对主存和磁盘的抽象,进程是对处理器、主存和 I/O 设备的综合抽象,而文件则是对所有外设的终极抽象。这种层层剥离硬件复杂性的设计,让上层软件开发者得以在一个“理想化的完美机器”上挥洒创意,而不用在代码里计算磁道和物理电压。
  2. 缓存的智慧:从寄存器到 L1/L2/L3 Cache,从 TLB 到虚拟内存分页,再到文件系统的页缓存,计算机系统无处不在践行着“局部性原理”。系统的性能优化史,本质上就是一部如何更巧妙地设计和利用缓存的历史。
  1. 我的创新理念与新的设计实现方法

在深入学习了现有计算机系统的设计后,我也产生了一些关于未来系统架构的创新设想:

  1. 基于 AI 的动态系统调度与预测(AI-driven OS): 传统的操作系统(如 Linux 的 CFS 调度器或 LRU 页面置换算法)多基于固定的启发式规则。未来可以在 OS 内核的底层引入轻量级的机器学习模型,根据进程的历史行为,动态预测其后续的缺页故障(提前 Prefetching)、预测分支跳转、甚至预测 TLB 的使用规律。通过 AI 替代僵硬的规则,实现“千程千面”的自适应资源分配。
  2. 细粒度的硬件级内存安全设计(Hardware-level Capability Security): 在 hello 的调试中我发现,传统的页式管理对内存的安全控制(rwx)粒度过大(通常为 4KB),极易引发缓冲区溢出等安全漏洞。我设想引入一种基于“能力标识符(Capability Pointers)”的硬件架构(类似于 CHERI 架构的概念)。在这种设计下,MMU 不仅检查虚拟页的访问权限,还在硬件层面上对每一次指针访问进行严格的边界和生命周期校验,从根本上在硬件指令级消灭 C/C++ 语言的内存越界问题,而不再仅仅依赖软件层面的栈保护(Stack Protector)。
  3. 计算与存储的深度融合(Processing-in-Memory, PIM): 在分析 CPU 访问内存的开销时,冯·诺依曼架构的“内存墙”问题非常显著。未来可以设计一种新的 I/O 与存储架构,将部分简单的算术逻辑单元(ALU)直接集成到 DRAM 芯片内部。像 hello 这种程序中涉及到大量大块内存初始化(如 BSS 段清零)或简单字符搜索的操作,可以直接发送高级指令让内存芯片自己完成,彻底省去数据在系统总线上的来回搬运,极大提高系统的吞吐率。

(结论0分,缺失-1分)


附件

文件名

文件类型

作用与说明

hello.c

源程序文件

初始的 C 语言纯文本源代码,是整个程序生命周期的起点。

hello.i

预处理文件

由预处理器(cpp)处理 hello.c 后生成的文本文件。其作用是展开了所有的宏定义(如 #define)、处理了条件编译指令,并将引用的头文件(如 #include <stdio.h>)内容直接插入到代码中。

hello.s

汇编语言文件

由编译器(cc1)对 hello.i 进行词法、语法和语义分析后生成的文本文件。其作用是将高级语言翻译成了对应硬件架构(如 x86-64)的底层汇编指令。

hello.o

可重定位目标文件

由汇编器(as)对 hello.s 翻译后生成的二进制机器语言文件。包含了机器指令、数据、重定位表和符号表,但其内存地址尚未最终解析,用于提供给链接器进行模块合并。

hello

可执行目标文件

由链接器(ld)将 hello.o 与系统库(如 C 标准库)合并后生成的最终二进制可执行文件。其所有的符号引用已被解析为确定的虚拟内存地址,可由操作系统加载并执行。

hello_asm.txt

反汇编代码文件

实验过程中通过 objdump 工具导出的文本记录文件。其作用是方便在本次大作业的报告中直观地查看、搜索和分析 hello.o 或 hello 程序的底层机器级指令和执行流。

(附件0分,缺失 -1分)


参考文献

为完成本次大作业你翻阅的书籍与网站等

  1. Randal E. Bryant, David R. O'Hallaron. 深入理解计算机系统(原书第3版)[M]. 龚建荣, 贺莲 译. 北京: 机械工业出版社, 2016.
  2. Pianist. printf 函数实现的深入剖析 [EB/OL]. 博客园, [转]printf 函数实现的深入剖析 - Pianistx - 博客园.
  3. Linux man pages. Linux Programmer's Manual (如 fork, execve, mmap, waitpid 等系统调用参考) [EB/OL]. Linux man pages online.
  4. Free Software Foundation. Debugging with GDB: The GNU Source-Level Debugger [EB/OL]. https://sourceware.org/gdb/current/onlinedocs/gdb/.
  5. Free Software Foundation. GCC online documentation [EB/OL]. https://gcc.gnu.org/onlinedocs/.
  6. Tool Interface Standard (TIS). Executable and Linkable Format (ELF) Specification Version 1.2 [S]. 1995.

(参考文献0分,缺失 -1分)

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐