程序人生-Hello’s P2P

添加图片注释,不超过 140 字(可选)

计算机系统原理

大作业

题 目 程序人生-Hello's P2P

专 业 AI+先进技术领军班

学   号 2024111517

班   级 2024Q0302

学 生 蒋欣成

指 导 教 师 史先俊

计算学部

2025年9月

摘 要

本文以经典的"Hello World"程序(hello.c)为案例,系统性地分析了从源代码到可执行文件的完整生命周期,涵盖预处理、编译、汇编、链接、进程管理、存储管理、I/O管理等关键阶段。全文以八个章节为结构,遵循从理论到实践的路径:首先阐述各环节的基本概念与作用,再结合具体命令操作、中间文件解析及截图示例,逐步揭示程序在Linux环境下的编译执行流程与操作系统底层机制。

通过剖析hello.c的预处理展开、汇编指令生成、符号解析与重定位、进程的创建与调度、虚拟内存映射以及I/O接口的实现,本文直观展示了计算机系统各层次(硬件、操作系统、运行时库、应用程序)之间的协同工作原理,深化了对计算机体系结构和程序运行机制的理解。

关键词:计算机系统;程序生命周期;体系结构;

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

自媒体发表截图


目 录

第1章 概述- 5 -

1.1 Hello简介- 5 -

1.2 环境与工具- 5 -

1.3 中间结果- 5 -

1.4 本章小结- 5 -

第2章 预处理- 6 -

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

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

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

2.4 本章小结- 6 -

第3章 编译- 7 -

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

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

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

3.4 本章小结- 7 -

第4章 汇编- 8 -

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

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

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

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

4.5 本章小结- 8 -

第5章 链接- 9 -

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

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

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

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

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

5.6 hello的执行流程- 9 -

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

5.8 本章小结- 10 -

第6章 hello进程管理- 11 -

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

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

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

6.4 Hello的execve过程- 11 -

6.5 Hello的进程执行- 11 -

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

6.7本章小结- 11 -

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

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

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

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

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

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

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

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

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

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

7.10本章小结- 13 -

第8章 hello的IO管理- 14 -

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

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

8.3 printf的实现分析- 14 -

8.4 getchar的实现分析- 14 -

8.5本章小结- 14 -

结论- 15 -

附件- 16 -

参考文献- 17 -


第1章 概述

1.1 Hello简介

根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。

P2P指的是你从一个存储在磁盘上的静态程序,被加载到内存中成为一个动态进程的完整过程。

预处理:

预处理器会对 hello.c源文件进行文本层面的处理。这包括将 #include指令所指向的头文件内容直接插入到源代码中,也会展开所有宏定义,并处理条件编译指令。最终生成一个纯C代码的 hello.i文件。

编译:

编译器对 hello.i文件进行复杂的词法分析、语法分析、语义分析和优化,将其翻译成与特定处理器架构(如x86-64)相关的汇编代码,生成 hello.s文件。

汇编:

汇编器将人类可读的 hello.s汇编代码几乎逐句翻译成机器可以直接理解的机器指令,并打包成一个可重定位目标文件hello.o。这个文件已经是二进制格式,但像 printf这样的外部函数地址尚未确定。

链接:

链接器扮演"最终装配师"的角色。它将 hello.o和所需的标准库(如C运行库)合并在一起,解析所有未确定的符号地址,进行地址空间分配和重定位,最终生成一个完整的、可以被加载执行的可执行目标文件hello。

加载与执行:

当你在Shell中输入 ./hello并回车后,Shell会调用 fork()系统调用创建一个新的子进程。随后,在这个子进程中调用 execve()函数,该函数会用 hello可执行文件的内容覆盖当前进程的内存空间,并设置好运行所需的上下文(如栈、堆等)。

O2O 则生动描绘了一个进程从创建到消亡的完整生命周期,犹如一场从虚无中来、回虚无中去的旅程。

Zero:登场之前

在程序被执行之前,Hello程序只是磁盘上的一系列二进制数据,物理内存中并没有它的活动痕迹,此为初始的"零"状态。

Process:生命的华彩

通过 fork()和 execve(),Hello进程被加载到内存中,操作系统为其分配一个唯一的进程ID和独立的虚拟地址空间。CPU通过时间片轮转机制让它与其他进程交替运行。当它要访问数据或指令时,内存管理单元通过页表等机制将虚拟地址转换为物理地址。如果要打印信息,I/O管理系统会处理这一切。

归零:完美的谢幕

当 main函数返回或进程被信号终止后,Hello进程的生命走向终点。操作系统内核会负责回收为其分配的所有资源:内存空间、打开的文件描述符、进程描述符等。它在物理内存中存在的所有痕迹被清理干净,重归于"零"。

1.2 环境与工具

列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。

Ubuntu Linux

Codeblocks

1.3 中间结果

列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。

Hello.c 源程序

Hello 用于调试程序

Hello.i​展开头文件、宏替换、处理条件编译

Hello.o 将汇编指令翻译成可直接被CPU识别的机器指令

Hello.s 将C代码翻译成机器指令的文本描述

Hello1 采用链接生成的调试程序

Hello.asm 用反汇编查看hello.o的内容

Hello1.asm 用于查看hello1的内容

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

Hello1.elf 用readelf读取hello1得到的ELF格式信息

1.4 本章小结

本章系统介绍了Hello程序的P2P(From Program to Process)与O2O(From Zero to Zero)完整生命周期。P2P过程详细阐述了从源代码hello.c经过预处理、编译、汇编、链接最终生成可执行文件的完整编译流程;O2O过程则描述了程序从磁盘静态存储到内存中动态执行,最终资源回收的进程生命周期。同时,本章明确了实验所需的硬件环境、软件平台和开发调试工具链,并列出了在整个过程中生成的关键中间文件及其作用,为后续章节的深入分析奠定了坚实基础。

(第1章0.5分)


第2章 预处理

2.1 预处理的概念与作用

预处理是 C/C++ 等编程语言编译过程中的一个重要阶段,它像一个"编辑助手",在源代码被正式编译之前,先对其进行一系列文本层面的处理和转换。

预处理的核心价值主要体现在以下几个方面:

文件包含

这是通过 #include指令实现的。预处理器会找到指定文件,并将其全部内容原封不动地复制并插入到 #include指令所在的位置 。这极大地促进了代码的复用和模块化开发。包含文件有两种形式:使用尖括号(如 #include <stdio.h>)通常用于包含系统标准头文件,编译器会到系统路径下查找;使用双引号(如 #include "myheader.h")则优先在当前目录查找,常用于包含用户自定义的文件 。

宏定义

宏定义通过 #define指令实现,它为一个字符串(代码片段或常量)起一个名字(宏名)。预处理器在编译前会将代码中所有出现宏名的地方替换为它所定义的字符串 。

无参宏:常用于定义常量,例如 #define PI 3.14159。这提高了代码的可读性和可维护性 。

带参宏:类似于函数,可以进行参数替换,例如 #define MAX(a, b) ((a) > (b) ? (a) : (b))。需要注意的是,宏是简单的文本替换,不涉及类型检查,使用时需谨慎以避免因运算符优先级或表达式多次求值导致意外结果 。

条件编译

条件编译指令(如 #if, #ifdef, #ifndef, #elif, #else, #endif)允许你根据特定条件,决定哪些代码块参与编译,哪些被忽略 。这在以下场景非常有用:

调试与发布:可以包含调试专用的代码(如打印日志),在发布版本中将其排除 。

跨平台移植:针对不同操作系统或硬件平台编写不同的代码,通过条件判断来编译对应的部分 。

防止头文件重复包含:通过 #ifndef和 #define配合(即"头文件保护宏"),可以确保一个头文件的内容只被引入一次,避免重复定义错误 。

2.2在Ubuntu下预处理的命令

添加图片注释,不超过 140 字(可选)

gcc -E hello.c -o hello.i

-E:指示GCC只进行预处理,完成后停止。

-o hello.i:指定输出的预处理文件名为 hello.i。如果省略此选项,结果会直接打印在终端上。

执行此命令后,hello.i文件会包含展开所有头文件和宏之后的代码。

2.3 Hello的预处理结果解析

Hello.i的内容通过分析 hello.i这个预处理后的文件,我们可以清晰地看到预处理器是如何一步步"打扫"和"准备"我们的源代码的。它主要完成了以下几项关键工作。

头文件包含的展开

这是预处理结果中最显著的变化。预处理器会处理所有的 #include指令。

操作方式:当预处理器遇到 #include <stdio.h>这样的指令时,它会直接找到这个头文件(通常在系统路径如 /usr/include/下),并将该文件的全部内容逐字插入到 #include指令所在的位置。

结果表现:这导致一个简单的 #include行被替换成数百甚至上千行的代码。你会发现,原始的 #include指令消失了,取而代之的是来自 /usr/include/stdio.h、/usr/include/unistd.h等头文件的大量内容。这些内容本身可能又包含了其他头文件,因此最终生成的 hello.i文件会非常庞大。

宏定义的展开

预处理器会处理所有以 #define进行的宏定义。

操作方式:它会扫描源代码,将所有出现的宏名替换为它所定义的值或代码片段(即"宏体")。如果宏是带参数的,参数也会被实际调用时传入的值所替换。

结果表现:在 hello.i文件中,你再也看不到原始的 #define指令和宏名。它们都已经被替换成了具体的值。例如,如果你的代码中有 #define PI 3.14,那么在 hello.i中所有使用 PI的地方都会变成 3.14。

条件编译的处理

预处理器会根据 #if, #ifdef, #ifndef等条件编译指令来决定哪些代码块保留,哪些被删除。

操作方式:预处理器会判断这些指令后的条件表达式是否为真。如果为真,则保留对应的代码块;如果为假,则完全移除该代码块。

结果表现:在 hello.i文件中,所有的条件编译指令(如 #if, #else, #endif)本身都会消失,只留下满足条件、需要被编译的代码。

注释的删除

预处理器会移除所有形式的注释。

操作方式:无论是单行注释 //还是多行注释 /* ... */,都会被预处理器识别并删除。

结果表现:在 hello.i文件中,你找不到任何你在源文件中写的注释。它们被简单地替换为空格,以确保不会影响代码的原有结构。

行号标记与文件名信息的添加

为了便于编译器在后续阶段进行调试和错误定位,预处理器会插入特殊的行标记(Linemarkers)。

格式:这些行标记的格式通常类似于 # linenum "filename" flags。例如,# 1 "hello.c"表示接下来的代码源自 hello.c文件的第1行。

作用:当编译器在编译 hello.i文件报告错误时,可以根据这些行标记精确地指出错误在原始 hello.c文件中的位置,而不是在庞大的 hello.i文件中的位置。

空白字符的处理

预处理器会对源代码中的空白字符(空格、制表符、换行符)进行一定处理,使输出更规整。

操作方式:标记之间的空白字符可能会被"折叠"成单个空格。同时,为了保持代码的视觉对齐,预处理器可能会在非指令行的第一个标记前添加适当的空格。

2.4 本章小结

预处理是程序编译前由预处理器对源代码进行文本处理的阶段,它处理所有以#开头的指令,主要包括文件包含(通过#include将头文件内容插入源文件)、宏替换(通过#define定义常量或宏函数进行文本替换)和条件编译(使用#if、#ifdef等指令根据条件控制代码块是否参与编译)三大功能,此外还会删除注释和处理一些特殊指令。在Ubuntu下,可以使用gcc -E hello.c -o hello.i命令生成预处理后的.i文件,该文件会展开所有头文件和宏定义,并添加行号标记以便调试。对hello.i文件的解析显示了预处理如何将简洁的源文件转化为庞大的中间代码,而为后续编译阶段生成的hello.s汇编文件则进一步揭示了预处理结果如何被转换为低级机器指令的文本描述,体现了预处理在连接高级语言与底层硬件执行之间的桥梁作用。

(第2章0.5分)


第3章 编译

3.1 编译的概念与作用

概念:

编译是将人类可读的高级编程语言翻译成计算机能直接理解和执行的低级语言的过程。

作用:

实现高级语言到机器语言的翻译:这是编译最根本的作用。它架起了人类可读的高级语言与计算机只能执行的二进制机器指令之间的桥梁。

代码优化以提高效率:编译器在翻译过程中会进行大量优化,从而生成运行更快、占用内存更少的目标代码。

检查代码错误:在编译的各个阶段,编译器能及时发现源代码中的词法错误、语法错误和基本的语义错误,并给出错误报告,极大方便了调试。

实现跨平台能力:对于一些生成中间代码(如Java字节码)的语言,编译使其可以"一次编写,到处运行"。另一种方式是为不同平台提供不同的编译器,将同一份源码编译成不同平台的可执行文件。

管理复杂性与模块化开发:通过分别编译多个源文件再链接的方式,便于大型软件项目的分工合作、模块化管理和增量编译。

3.2 在Ubuntu下编译的命令

添加图片注释,不超过 140 字(可选)

gcc -S hello.i -o hello.s

-S:指示GCC进行预处理和编译,将C代码转换为汇编代码,完成后停止。

3.3 Hello的编译结果解析

代码对应的C程序逻辑大致是:检查命令行参数数量(预期为5个,包括程序名),若不匹配则报错退出;否则循环10次,每次打印参数并睡眠指定秒数,最后等待输入。

3.3.1 汇编代码整体结构与节(Sections)

汇编代码由多个节(section)组成,每个节存储不同类型的数据或指令:

.text节:存放程序指令(如main函数代码)。编译器将C代码翻译为机器指令后集中于此。

.rodata节:存放只读数据(如字符串常量)。本例中的.LC0(错误信息)和.LC1(格式字符串)在此节,使用.align 8确保8字节对齐以提高访问效率。

.note.gnu.property节:存储元数据(如栈保护信息),供链接器或加载器使用。

标识符说明:

.LFB6、.LC0等局部标签由编译器生成,用于管理控制流和数据。

.cfi_*指令(如.cfi_startproc)用于栈展开调试。

3.3.2 数据类型处理

编译器根据变量的作用域和类型分配存储位置:

常量:

字符串常量:如.LC0(八进制转义序列表示的中文文本)和.LC1("Hello %s %s %s\n")被存入.rodata节。它们的地址通过lea .LC0(%rip), %rdi加载到寄存器供函数使用。

整型常量:直接嵌入指令,如cmpl 5,−20(5。

变量:

局部变量:

循环变量i(int型)存储在栈帧偏移-4(%rbp)处,通过movl $0, -4(%rbp)初始化。

参数argc(int型)存于-20(%rbp),argv(char**型)存于-32(%rbp)。函数入口通过movl %edi, -20(%rbp)和movq %rsi, -32(%rbp)保存寄存器参数到栈中。

全局/静态变量:本例未出现,但通常全局变量会存入.data(已初始化)或.bss(未初始化)节,静态变量类似但作用域受限。

类型处理:

指针类型:argv作为char*[]类型,其元素通过基址偏移访问(如addq $8, %rax获取argv[1])。

隐式类型转换:atoi(argv[4])将char*转换为int,编译器通过call atoi实现,结果存于%eax。

3.3.3 运算与赋值操作

赋值操作:

直接赋值:movl $0, -4(%rbp)对应i = 0。

复合赋值:addl $1, -4(%rbp)对应i++,编译器优化为增量指令而非先加后存。

算术运算:

加法:addq 24,1, -4(%rbp)实现i++。

取负/正:未显式出现,但编译器通常通过neg或直接传输指令处理。

位与逻辑操作:

本例未出现位操作,但逻辑判断如cmpl $5, -20(%rbp)隐含比较结果,通过je/jle触发跳转。

3.3.4 数组、指针与结构操作

数组操作:

argv作为指针数组,元素通过基址偏移访问:

argv[1]:movq -32(%rbp), %rax; addq $8, %rax(偏移8字节,因指针大小为8字节)。

argv[2]:addq 16,24, %rax。

指针操作:

取地址:lea .LC1(%rip), %rdi获取字符串地址。

解引用:movq (%rax), %rcx加载argv元素指向的字符串。

结构操作:本例未出现结构体,但通常通过偏移量访问成员(如s.id对应固定偏移)。

3.3.5 控制转移实现

条件分支(if):

cmpl $5, -20(%rbp)比较argc与5,je .L2实现if (argc == 5)的跳转;否则顺序执行puts和exit。

循环(for):

初始化:movl $0, -4(%rbp)设置i=0。

条件检查:.L3中cmpl $9, -4(%rbp)比较i与9,jle .L4实现i <= 9时跳转至循环体。

迭代:.L4结尾的addl $1, -4(%rbp)递增i,jmp .L3返回检查。

其他控制流:未使用switch/do-while,但编译器通常通过跳转表或条件跳转实现。

3.3.6 函数操作处理

参数传递:

整数和指针通过寄存器传递:main的参数argc用%edi,argv用%rsi。

printf参数:格式字符串地址存%rdi,后续参数按顺序存%rsi(argv[1])、%rdx(argv[2])、%rcx(argv[3]),movl $0, %eax表示无浮点参数。

函数调用与返回:

调用:call printf@PLT使用PLT(过程链接表)实现动态链接。

返回:main结尾的movl $0, %eax设置返回值0,leave和ret恢复栈帧并返回。

栈帧管理:

入口:pushq %rbp保存基指针,movq %rsp, %rbp设置新栈帧,subq $32, %rsp为局部变量分配空间(16字节对齐)。

出口:leave等价于movq %rbp, %rsp; popq %rbp,恢复栈指针。

3.4 本章小结

编译的本质是将高级语言程序转换为计算机能直接执行的机器语言程序。这个过程如同一位技艺高超的翻译官,不仅进行语言转换,还会检查错误、优化性能,并管理程序的复杂结构。其主要作用包括:

搭建沟通桥梁:实现人类可读的高级语言到机器指令的转换。

提升执行效率:通过代码优化生成更高效的目标代码。

保障代码质量:在编译过程中进行词法、语法和基础语义错误检查。

支持跨平台与模块化开发:例如通过中间代码或为不同平台编译,以及分别编译再链接的方式管理大型项目。

在Ubuntu下使用GCC(GNU Compiler Collection)编译一个C程序,通常经历预处理、编译、汇编、链接四个关键阶段。例如,命令 gcc -S hello.i -o hello.s即完成了从预处理后的文件(hello.i)到汇编代码(hello.s)的编译阶段。

从Hello.s看编译器的具体实现

对hello.s汇编代码的分析,让我们能直观看到编译器如何将C语言的各种元素映射到机器指令和内存布局上。

数据存储与类型处理:编译器会根据数据类型和作用域差异化管理数据。例如,字符串常量存储在.rodata节;局部变量(如循环变量i、参数argc和argv)则在栈上分配空间。对于指针类型(如char** argv),通过计算基地址偏移来访问元素。

控制流实现:C语言中的控制结构被翻译成汇编中的条件跳转和循环逻辑。例如,if (argc == 5)被转换为 cmpl $5, -20(%rbp)和 je .L2;for循环则被分解为初始化、条件检查、循环体执行和迭代递增几个明确的标签和跳转块。

函数调用机制:函数调用遵循特定的调用约定。例如,前几个整数或指针参数通过%rdi, %rsi, %rdx等寄存器传递,返回值通过%rax寄存器带回。同时,编译器会自动插入代码来管理栈帧(如pushq %rbp, subq $32, %rsp),确保函数调用的隔离与正确返回。

编译器的设计处处体现着对效率和控制力的追求:

直接操作硬件资源:汇编代码中大量使用寄存器和精确的内存地址计算,旨在减少内存访问次数,提升执行速度。

精细的指令选择:即便是简单的i++,编译器也可能根据上下文优化为更高效的incl指令,而非简单的加载、加1、存储三步操作。

结构化可读性与底层控制的结合:虽然我们编写的是结构化的高级语言,但编译器最终生成的是顺序执行的机器指令。通过分析汇编代码,开发者可以理解程序底层的真实行为,这对于性能调优、漏洞分析等领域至关重要。从最简单的Hello World到处理命令行参数、循环、函数调用,编译器默默地将高级逻辑转化为处理器能够一步步执行的精确指令序列。

(第3章2分)


第4章 汇编

4.1 汇编的概念与作用

概念:特指由汇编器将汇编语言源代码转换为机器代码目标程序的翻译过程本身

实现底层硬件精确控制

汇编语言最不可替代的作用在于它能直接、高效地控制计算机硬件。由于它和机器指令基本是一一对应的,程序员可以使用它直接操作CPU寄存器、内存地址和I/O端口。这种特性使得它在开发一些底层系统核心组件时至关重要,例如:

操作系统内核:操作系统的启动引导、任务调度等最基础的部分往往需要汇编代码来初始化硬件。

设备驱动程序:驱动需要直接与显卡、硬盘控制器等硬件设备交互,汇编语言能提供精确的时序控制。

嵌入式系统与实时系统:在资源受限的微控制器和对响应时间有严格要求的实时系统中,汇编语言能确保程序小巧且运行可预测。

优化程序性能与效率

在对执行速度、内存占用有极致要求的场景下,汇编语言是进行性能优化的终极手段。

提升速度:由汇编语言编写的程序经过汇编器翻译后,生成的目标代码非常精简,执行效率高,占用内存少。因此,在早期计算机资源紧张的时代,以及现在一些大型软件的核心算法(如视频编解码、图形渲染引擎)中,仍会使用汇编来"抠"出每一分性能。

空间优化:在存储空间极其有限的设备(如某些嵌入式芯片)中,汇编语言能产生体积最小的机器码,充分利用每一字节的存储。

辅助软件调试与分析

由于汇编语言反映了代码在CPU上执行的真正过程,它在软件调试和逆向工程中扮演着关键角色。

理解程序本质:当高级语言程序出现难以理解的底层错误时,开发者可以通过查看编译器生成的汇编代码,来定位问题的根本原因。

分析软件机制:在信息安全领域,分析病毒、进行软件破解或逆向工程时,分析师必须阅读汇编代码,因为通常无法获得软件的源代码。

深化计算机系统理解

对于计算机科学领域的学习者和研究者而言,学习汇编语言具有重要的教育意义。

理解计算机工作原理:通过学习汇编语言,可以深入理解CPU如何执行指令、内存如何管理、数据如何流动等计算机核心工作原理。这正是《计算机组成原理》、《操作系统》等课程的重要实践基础。

奠定高级语言基础:了解底层机制有助于程序员更好地理解高级语言中的各种概念(如指针、函数调用栈)是如何在机器层面实现的,从而写出更高效、更稳健的代码。

4.2 在Ubuntu下汇编的命令

添加图片注释,不超过 140 字(可选)

gcc -c hello.s -o hello.o

-c:指示GCC进行预处理、编译和汇编,但不进行链接,生成目标文件。

4.3 可重定位目标elf格式

添加图片注释,不超过 140 字(可选)

elf文件头定义了文件的整体属性信息,比较重要的几个属性是:魔术字,入口地址,程序头位置、长度和数量,文件头大小(52字节),段表位置、长度和个数。

添加图片注释,不超过 140 字(可选)

节表头:记录ELF文件中各节位置,大小,偏移等信息。

添加图片注释,不超过 140 字(可选)

重定位是连接符号引用与符号定义的过程。例如,程序调用函数时,关联的调用指令必须在执行时将控制权转移到正确的目标地址。可重定位文件必须包含说明如何修改其节内容的信息。通过此信息,可执行文件和共享目标文件可包含进程的程序映像的正确信息。

添加图片注释,不超过 140 字(可选)

符号表是编译器(或解释器)在翻译源代码过程中使用的一种核心数据结构,其主要作用是为源代码中的各种标识符(如变量名、函数名、常量名等)建立并维护一个信息注册与查询中心。

添加图片注释,不超过 140 字(可选)

4.4 Hello.o的结果解析

添加图片注释,不超过 140 字(可选)

hello.o: 文件格式 elf64-x86-64

Disassembly of section .text:

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_PLT32puts-0x4

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

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

2b: R_X86_64_PLT32exit-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_PLT32printf-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_PLT32atoi-0x4

80:89 c7 mov %eax,%edi

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

83: R_X86_64_PLT32sleep-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_PLT32getchar-0x4

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

9b:c9 leaveq

9c:c3 retq

特性对比 hello.s(汇编源代码) hello.o(反汇编机器码) 关键差异说明
指令表示 助记符(如 movl, call) 机器码(如 c7 45 fc) 汇编器将助记符翻译为二进制操作码。
函数/标号 直接使用符号名(如 .L2, puts) 使用相对偏移或0占位符(如 1c: R_X86_64_PC32 .rodata-0x4) 汇编时外部符号地址未知,需链接器重定位。
操作数格式 十进制(如 $0) 十六进制(如 $0x0) 仅为数值表示形式的差异,本质相同。
分支/跳转目标 标签(如 .L4:) 绝对地址偏移(如 jle 38 <main+0x38>) 汇编器已将标签计算为相对于节开始的偏移地址。
全局数据访问 符号化地址(如 .LC0(%rip)) 地址为0并带重定位条目(如 5f: R_X86_64_PC32 .rodata+0x2c) 运行时地址需链接器填入,访问方式为PC相对寻址。

机器语言的构成与映射关系

机器语言是CPU能够直接解码和执行的低级指令集,由操作码​ 和操作数​ 组成。

它指定了CPU要执行的具体操作,例如加法、减法、数据移动或跳转。在您提供的反汇编代码中,每一行开头的十六进制数字(如 f3 0f 1e fa, 55)就是操作码。例如,55对应 push %rbp指令的操作码。

操作数指明了指令操作的对象。机器指令中的操作数可以是立即数、寄存器编号或内存地址。

映射关系:汇编器负责将汇编指令中的操作数(如 0x5, -0x14(%rbp))编码成机器指令中对应的二进制形式。例如,cmpl 0x5, -0x14(%rbp)这条指令,汇编器会生成操作码 83 7d ec来表示"比较立即数"和操作的内存位置,而立即数 5则被直接编码为 05。

关键差异深度解析

  1. 函数调用 在 hello.s中,函数调用直接使用符号名,例如 call puts。 在 hello.o的反汇编中,调用指令的操作数被置为0,并生成重定位条目(如 21: R_X86_64_PLT32 puts-0x4)。这是因为在汇编阶段,puts等外部函数的实际地址尚未确定。这个重定位条目告诉链接器:"在最终链接时,请将 call指令后面的4个字节(32位)替换为 puts函数相对于过程链接表(PLT)的正确偏移地址"。这是两者最显著的不一致之一。

  2. 分支转移在 hello.s中,使用标签(如 .L2, .L4)来标记跳转目标。 在 hello.o的反汇编中,标签被替换为具体的偏移地址(如 je 2f <main+0x2f>,意为"跳转到位于 main函数起始偏移 0x2f字节处的指令")。这个偏移地址是汇编器在将汇编代码翻译成机器代码时,根据代码布局计算出来的相对地址。与函数调用不同,由于跳转目标在同一文件同一节内,其地址在汇编阶段即可确定,因此无需链接器重定位。

  3. 全局数据访问在 hello.s中,访问字符串常量通常使用类似于 lea .LC0(%rip), %rdi的指令,其中 .LC0是字符串所在的地址标签。

在 hello.o的反汇编中,这条指令变为 lea 0x0(%rip), %rdi,并伴随重定位条目 1c: R_X86_64_PC32 .rodata-0x4。这同样是地址未决的表现。链接器之后会根据此条目,计算出字符串常量真正的运行时地址与下一条指令地址之间的差值,并回填到操作数中,形成有效的PC相对寻址。

4.5 本章小结

汇编过程是将人类可读的汇编指令精准翻译成机器指令的关键步骤,并生成包含代码、数据和重定位信息的可重定位目标文件。hello.o的ELF格式及其反汇编代码表明,它本身并非完整可执行程序,而是一个半成品,其价值在于为链接器的最终"组装"提供了标准化、模块化的组件。深入理解这一过程及文件格式,对于掌握程序构建的底层机制、进行高性能优化和深度调试具有不可替代的意义。

(第4章1分)


第5章 链接

5.1 链接的概念与作用

概念:将分散的代码与数据片段汇集并融合成一个完整的文件的过程。这一文件能够被载入内存并启动执行。

作用:链接是指链接器将一个或多个由编译器或汇编器生成的目标文件以及所需的库文件合并,生成一个可执行文件(如 hello)的过程。其核心作用在于符号解析(解决目标文件之间的函数、变量引用问题)和地址重定位(为所有代码和数据分配最终的运行时内存地址)。通过这种方式,链接将分散编译的模块"拼接"成一个完整的、可直接被操作系统加载运行的程序。

5.2 在Ubuntu下链接的命令

添加图片注释,不超过 140 字(可选)

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

分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。

添加图片注释,不超过 140 字(可选)

通过对比分析发现,hello1.elf与 hello.elf的ELF文件头所包含的信息类别基本一致。它们的起始部分都是一个16字节的魔数(Magic),这个序列标识了文件为ELF格式,并定义了生成该文件的系统的字长(32位/64位)和字节序(大端序/小端序)。文件头中魔数之后的部分,包含了帮助链接器解析和处理目标文件的各种信息。

具体到两个文件的差异:与 hello.elf相比,hello1.elf的一些基础属性,如魔数序列和文件类别(Class),保持不变。然而,其文件类型(例如是可重定位文件、可执行文件还是共享库文件)发生了变化。同时,hello1.elf的程序头表(Program Header Table)的大小以及节头表(Section Header Table)中条目的数量有所增加。此外,hello1.elf还获得了一个明确的程序入口点地址 0x4010f0,这通常意味着它可能从一个需要进一步链接的目标文件(.o)变成了一个可以直接加载执行的可执行文件。

添加图片注释,不超过 140 字(可选)

节头表是ELF文件中用于链接视图的核心数据结构,它通过一个结构体数组详细描述了文件中所有节的属性信息,为链接器在合并节、解析符号和进行重定位时提供关键依据。

添加图片注释,不超过 140 字(可选)

程序头是ELF文件中用于执行视图的核心数据结构,它通过一个结构数组详细描述了系统加载程序时所需的各种段的布局和属性,为操作系统的加载器提供了将文件映射到内存并启动执行的关键信息。

添加图片注释,不超过 140 字(可选)

动态链接

添加图片注释,不超过 140 字(可选)

重定位节是ELF文件中用于地址修正的关键数据结构。它包含了一系列重定位条目,指导链接器或动态链接器在构建或加载程序时,修改代码和数据段中对地址的引用,使其指向正确的内存位置。

添加图片注释,不超过 140 字(可选)

符号表中包含了程序中各种符号(函数、变量和标签等),以便链接器和调试器等工具能够定位和处理这些符号。

添加图片注释,不超过 140 字(可选)

5.4 hello的虚拟地址空间

使用gdb/edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。

添加图片注释,不超过 140 字(可选)

5.5 链接的重定位过程分析

objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。

添加图片注释,不超过 140 字(可选)

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

添加图片注释,不超过 140 字(可选)

结合hello.o的重定位项目,分析hello中对其怎么重定位的。

在动态链接过程中,链接器需要处理目标文件中的重定位条目。它会分析这些条目,计算出目标函数在过程链接表(PLT)中的条目与当前指令下一条指令之间的相对距离。然后,链接器会根据这个计算出的相对偏移量,修改目标指令操作码后的相应字节,将这些字节的内容重写为正确的相对地址。这样,当程序执行时,call指令就能正确地跳转到 PLT 中的目标函数入口。通过完成对所有此类重定位条目的处理,链接器最终生成了地址引用完整的、可供反汇编器解析的代码

添加图片注释,不超过 140 字(可选)

5.6 hello的执行流程

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

添加图片注释,不超过 140 字(可选)

添加图片注释,不超过 140 字(可选)

  1. 开始执行:_start、_libe_start_main

(2)执行main:_main、printf、_exit、_sleep、getchar

(3)退出:exit

5.7 Hello的动态链接分析

分析hello程序的动态链接项目,通过edb/gdb调试,分析在动态链接前后,这些项目的内容变化。要截图标识说明。

当程序调用一个由共享库定义的函数时,由于编译器无法预测这时候函数的地址是什么,编译系统提供了延迟绑定的方法,将过程地址的绑定推迟到第一次调用该过程时。延迟绑定主要通过GOT与PLT的写作来确定函数的地址。

这里查看pritnf的延迟绑定结果。

添加图片注释,不超过 140 字(可选)

添加图片注释,不超过 140 字(可选)

添加图片注释,不超过 140 字(可选)

动态链接前后

之前的状态:0x404020→ 0x0000000000401040(指向PLT内部的桩代码,用于触发动态链接器)

现在的状态:0x404020→ 0x00007ffff7e20cc0(指向libc.so.6中printf的真实实现)

5.8 本章小结

本章深入探讨了程序构建过程中的关键环节------链接。首先明确了链接是将分散编译的目标模块和库文件整合成单一可执行文件的过程,其核心作用是符号解析和地址重定位。通过手动链接命令,揭示了链接器在幕后需要处理的复杂依赖关系。

通过对可执行文件 hello的ELF格式分析,详细阐述了其核心结构:ELF头作为文件蓝图,定义了整体属性;节头表描述了链接视图下的各种节(如代码节 .text、数据节 .data);程序头表则提供了执行视图,指导操作系统如何将程序映射到虚拟内存空间。使用调试器对虚拟地址空间的探查,直观地验证了链接阶段分配的内存地址在运行时的实际映射。

通过对比 hello.o与 hello的反汇编代码,清晰地展示了重定位的实际过程:链接器通过计算和修正地址引用,将零散的目标文件"缝合"成统一的整体。最后,跟踪了 hello从 _start到 main再到终止的执行流程,并分析了动态链接中延迟绑定通过GOT和PLT实现的巧妙机制。

链接不仅是程序构建的最后一步,更是连接编译时与运行时的重要桥梁。理解链接原理,对于深入掌握程序如何被加载、如何在内存中布局、如何与系统交互至关重要,也是进行大型项目构建、库文件管理和程序调试优化的基础。

(第5章1分)


第6章 hello进程管理

6.1 进程的概念与作用

进程是计算机中正在运行的程序的实例,是操作系统进行资源分配和调度的基本单位。简单来说,进程就是程序在内存中的一次执行活动,它包含了程序代码、相关数据以及运行时的状态信息。

1.资源分配的基本单位

进程是操作系统进行资源分配的最小单位。当进程被创建时,操作系统会为其分配独立的内存空间、CPU时间片、文件句柄、网络连接等系统资源。每个进程都拥有独立的虚拟地址空间,这保证了进程间的内存隔离,一个进程的崩溃不会影响其他进程。

  1. 实现多任务并发执行

通过进程调度机制,操作系统可以在单核CPU上实现多个进程的"同时"运行。即使只有一个CPU核心,通过快速切换时间片,也能让用户感觉多个程序在同时运行,这大大提高了系统的利用率和用户体验。

  1. 提供安全隔离机制

每个进程都有独立的地址空间,进程之间不能直接访问彼此的内存数据。这种隔离机制保证了系统的安全性,即使某个进程出现错误或恶意行为,也不会直接破坏其他进程或系统内核。

  1. 支持程序动态执行

进程具有动态性,它从创建到消亡经历了完整的生命周期。进程可以根据需要创建子进程,也可以与其他进程进行通信和协作,这为构建复杂的应用程序提供了基础。

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

Shell(特别是Bash)是Linux/Unix系统的命令行解释器,它作为用户与系统内核之间的桥梁,负责接收用户输入的命令,进行解释、执行,并将结果返回给用户。它不仅是交互式操作的界面,也是一种脚本编程语言,能够将多个命令组织成脚本文件,实现复杂或重复性的系统管理任务自动化。

Shell的基本功能包括命令历史记录(方便用户查看和重复执行过往命令)、命令别名设置(为复杂命令创建简短的替代名称)以及命令和文件路径的自动补全(通过Tab键提高输入效率)。它还通过输入输出重定向(如 >、>>、<)改变标准输入/输出的方向,以及使用管道符(|)将一个命令的输出作为另一个命令的输入,从而实现强大的命令组合能力。

Shell脚本的执行主要有几种方式:一是为脚本文件添加可执行权限后直接运行(如 ./script.sh);二是通过指定的解释器来执行(如 bash script.sh);三是使用 source命令或点号(.)在当前Shell环境中执行脚本。通过灵活运用这些特性和执行方式,Shell极大地提升了系统管理和开发的效率。

6.3 Hello的fork进程创建过程

在终端中输入 ./hello 命令后,shell 会接收并解析该命令。当确认这是一个可执行程序时,shell 会通过调用 fork函数创建一个子进程。子进程会完全复制父进程的代码段、数据段、堆、共享库以及栈段等内存结构,但两者拥有不同的进程 ID(PID)。接着,父进程会将该子进程纳入到一个新的或已有的进程组中。

6.4 Hello的execve过程

子进程创建完成后,会调用 execve函数加载指定的可执行文件(此处为 hello)到当前进程空间中。该函数会先清空原进程的用户地址空间,再根据可执行文件的结构,建立新的代码段、数据段等内存映射,并将程序所需的共享库载入内存。最终,进程从 hello的入口点开始执行。

6.5 Hello的进程执行

结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。

在终端中输入 ./hello命令后,操作系统首先对其进行解析,定位到对应的可执行文件,接着通过 fork系统调用为该命令创建子进程。由于 fork涉及内核操作,处理器会立即从用户态切换到核心态,执行流程进入操作系统内核。

内核会暂存当前进程的上下文,随后由进程调度程序选择下一个待运行的进程,并恢复其之前保存的上下文。完成切换后,控制权交还给该进程,处理器重新回到用户态继续执行。在此过程中,操作系统为每个进程分配时间片,决定其执行时长。若时间片用完或发生 I/O 事件,将触发中断,再次切换到核心态,由内核处理中断并可能重新调度进程。

最后,当进程运行结束时,操作系统会回收其占用的资源,并向父进程发送相应的通知信号。

6.6 hello的异常与信号处理

hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。

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

添加图片注释,不超过 140 字(可选)

中断异常

中断通常由外部硬件设备异步触发,例如键盘输入、磁盘I/O完成或定时器信号。它的核心特点是与当前正在执行的指令无关。

处理方式:处理中断异常时,CPU会暂停当前指令流,硬件会自动保存当前程序的计数器(PC)和程序状态字(PSW)等关键上下文。随后,CPU根据中断向量表找到并执行对应的中断服务程序,以响应外部设备的需求。中断处理完成后,CPU会恢复之前保存的上下文,并返回到被中断的程序,从下一条指令继续执行,整个过程对原程序是透明的。

陷阱异常

陷阱是程序有意主动触发的同步事件,最典型的例子是系统调用。当程序执行如 ecall这样的特殊指令时,会主动陷入内核,请求操作系统提供服务。

处理方式:处理陷阱异常时,其硬件保存上下文的过程与中断类似。随后,CPU会跳转到预设的陷阱处理程序入口,这通常是操作系统内核的代码。操作系统会分析陷阱产生的原因(例如,通过寄存器判断是哪个系统调用),然后执行相应的服务例程(如打开文件、创建进程等)。服务完成后,操作系统会安排CPU从陷阱返回,并继续执行发出陷阱指令的下一条指令。

故障异常

故障是由程序执行时出现的可修复错误引起的同步异常,例如访问无效内存地址(段错误)或遇到缺页异常。

处理方式:处理故障异常的目标是尝试修复错误,使程序能够继续执行。当故障发生时,硬件会保存上下文,然后将控制权交给操作系统的故障处理程序。处理程序会诊断故障原因,如果可能,会尝试修复问题(例如,缺页处理程序会将所需的页面从磁盘调入内存)。修复成功后,硬件会重新执行那条引发故障的指令。如果错误无法修复(例如,访问了无权访问的内存),则故障可能会升级为终止异常,导致进程被结束。

终止异常

终止是由不可恢复的严重硬件错误导致的异常,例如内存奇偶校验错误或严重的硬件故障。

处理方式:这是最严重的情况,处理目标不再是恢复程序运行,而是最大限度地保护系统整体稳定性,防止错误扩散。当终止发生时,操作系统通常无法进行有效的修复。它会立即终止正在运行的进程或整个系统,并可能输出错误日志用于后续诊断。在某些极端情况下(如双重故障),系统可能直接崩溃或重启。

希望以上分点说明能帮助你清晰地理解不同异常的处理逻辑。

不停乱按

添加图片注释,不超过 140 字(可选)

回车

添加图片注释,不超过 140 字(可选)

Ctrlz

添加图片注释,不超过 140 字(可选)

添加图片注释,不超过 140 字(可选)

Ctrlc

添加图片注释,不超过 140 字(可选)

6.7本章小结

本章系统地阐述了进程的核心概念及其在操作系统中的核心作用,详细解析了Shell作为命令行解释器的功能与工作机制。通过以hello可执行文件为例,具体分析了进程的创建(如fork机制)、程序加载(如exec系列函数)以及进程终止与资源回收的完整生命周期。此外,本章还模拟了程序执行过程中的典型异常场景,并对相应的信号处理和进程状态转换进行了深入分析。

(第6章2分)


第7章 hello的存储管理

7.1 hello的存储器地址空间

7.1.1 逻辑地址

逻辑地址是程序代码在分段内存管理模型中生成的地址,通常表示为段选择符(段基址)与段内偏移量的组合。在支持地址转换的CPU保护模式下,逻辑地址并不能直接用于访问物理内存,而需通过分段机制转换为线性地址。在hello程序的反汇编代码中,指令所引用的地址即为逻辑地址,必须结合对应的段基址进行转换,才能获得其实际对应的内存地址。

7.1.2 线性地址

线性地址是逻辑地址转换至物理地址过程中的中间层。当CPU的分段管理单元将一个逻辑地址(由段基址和偏移量组成)中的这两部分相加后,便得到了一个连续的、32位的线性地址。如果系统未启用分页机制,此线性地址即直接对应物理地址。在分析hello的反汇编代码时,代码中显示的偏移地址(逻辑地址)与段基址相加后,即得到对应的线性地址。

7.1.3 虚拟地址

虚拟地址是程序在虚拟内存空间中使用的地址。它提供了一个与物理内存大小无关的、统一的地址空间抽象。在Linux系统的具体实现中,为了简化内存管理,通常将段基址设置为0,这使得逻辑地址的偏移量部分直接等于线性地址。因此,在Linux环境下,虚拟地址在数值上等同于线性地址。查看hello可执行文件的ELF格式,程序头中的VirtAddr字段所指示的就是各节的虚拟地址。基于Linux的这一特性,hello反汇编代码中的地址加上对应段基址(通常为0)得到的线性地址,也就是该指令或数据所在的虚拟地址。

7.1.4 物理地址

物理地址是计算机主存(RAM)每个字节单元的唯一标识,是数据在硬件层面上的实际存放位置。当CPU需要访问内存时,最终提交到地址总线上的就是物理地址。在hello程序的执行过程中,其代码和数据所使用的虚拟地址,会通过操作系统和内存管理单元(MMU)的协作,经过复杂的地址翻译过程,最终被映射到具体的物理地址上。CPU正是通过这个物理地址来读写真正的内存数据。结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。

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

在x86架构中,逻辑地址由两个部分构成:段选择符和段内偏移量。段选择符是一个16位的字段,。其中,高13位为索引号,用于定位段描述符在描述符表中的位置;最低2位为请求特权级,用于指定当前访问的特权级;第2位为表指示符,用于选择全局描述符表(GDT,TI=0)或局部描述符表(LDT,TI=1)

添加图片注释,不超过 140 字(可选)

在地址转换过程中,首先根据TI位判断应访问GDT还是LDT,并获取对应描述符表的基地址与界限。随后,利用索引号在描述符表中定位相应的段描述符,从中读取段的基地址。最终,将段基地址与段内偏移量相加,即得到线性地址。

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

在现代操作系统中,线性地址(也称为虚拟地址)通常通过页式管理机制转换为物理地址。线性地址被划分为虚拟页号​ 和虚拟页偏移量​ 两部分。系统为每个进程维护一个页表,该表是一个由页表项​ 组成的数组,用于记录虚拟页到物理页框的映射关系。

地址转换过程如下:首先,从进程控制块中获取页目录的基地址。利用线性地址中作为页目录索引的高位部分,在页目录中找到对应的页目录项,该项保存了下一级页表的物理基地址。接着,通过线性地址中作为页表索引的中间部分,在上述页表中定位到具体的页表项,从中获得目标物理页框的起始地址。最后,将该起始地址与线性地址中作为页内偏移的低位部分相加,即得到最终的物理地址。

为提升转换效率,CPU中通常设有转译后备缓冲器,用于缓存最近使用的页表项。当进行地址转换时,MMU首先查找TLB。若TLB命中,则可直接获得物理页框号。若未命中,则需按上述步骤访问内存中的页表,并将取得的页表项缓存至TLB中。若页表项显示目标页面不在物理内存中(即触发缺页异常),则操作系统负责从磁盘调入相应页面,并更新页表后重新执行地址转换。

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

TLB,常称为快表,是现代 CPU 中用于加速虚拟地址到物理地址转换的专用硬件缓存。其核心作用是缓存最近使用的页表项,避免每次地址转换都需访问速度较慢的主存,从而显著提升系统性能

TLB可视为页表的高速缓存,其查询基于虚拟地址中的虚拟页号。TLB通常采用高相联度(如全相联或组相联)的组织方式。若TLB有 T=2t个组,则TLB索引(TLBI)由VPN的最低 t位构成,TLB标记(TLBT)则由VPN的剩余高位组成,以此进行组选择和行匹配。

其工作流程核心在于命中与未命中两种情形:

TLB命中:当CPU产生虚拟地址后,内存管理单元(MMU)可直接从TLB中获取对应的PTE,迅速完成地址翻译并将物理地址发送至缓存/主存,最终将数据返回CPU。这避免了访问多级页表的开销。

TLB未命中:若TLB中无对应PTE,则需进行页表遍历。以四级页表为例,CR3寄存器指向第一级页表的基地址。MMU依次用虚拟地址中的四个VPN片段作为偏移量,逐级查找下一级页表的基地址。该过程需经历四次内存访问,最终从第四级页表的PTE中获取物理页框号(PPN),与页内偏移(VPO)组合成物理地址(PA)。此后,该PTE通常会被载入TLB,以备后续访问。

添加图片注释,不超过 140 字(可选)

采用多级页表(层次结构如图所示)的主要优势在于节省存储空间。若某级页表中的PTE为空,则其指向的下一级页表就无需存在。这种结构允许操作系统仅为进程实际使用的虚拟地址区域分配页表空间,避免了单级页表中对庞大而稀疏的地址空间进行全映射的浪费。尽管在TLB未命中时,多级页表需要更多次数的内存访问,但TLB的高命中率有效缓解了这一开销。

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

在获得物理地址(PA)后,MMU 会将其发送至 L1 高速缓存以查询数据。缓存子系统首先从物理地址中解析出三个关键字段:缓存偏移(CO,用于定位缓存行内的具体数据)、缓存组索引(CI,用于确定目标缓存组)以及缓存标记(CT,用于标识缓存行对应的物理地址高位部分)。

随后,缓存控制器使用 CI 定位到对应的缓存组。在常见的组相联映射结构中(例如每组 8 路),控制器会并行地将 CT 与该组内所有缓存行的标记进行比较 。如果某一路的标记与 CT 匹配,并且该缓存行的有效位为 1,则表明发生了一次缓存命中。此时,系统再结合 CO 从命中的缓存行中提取出目标数据,经由 MMU 返回给 CPU 。

若未找到匹配的标记(即缓存未命中),查询请求会依序向更低层级的存储结构传递,遵循 L1 → L2 → L3 → 主存的访问顺序 。一旦在某一级(如主存)找到所需数据块,该块会被载入到最初发起查询的 L1 缓存中。放置时,若目标组内存在空闲块,则直接存入;若组内所有块均已被占用,则发生冲突,此时通常采用 LRU(最近最少使用)等替换算法决定淘汰哪个旧块以腾出空间 。

7.6 hello进程fork时的内存映射

当父进程调用 fork()函数时,内核会启动新进程的创建流程。首先,内核为子进程分配独立的内核数据结构,包括进程控制块(task_struct),并为其分配一个唯一的进程标识符(PID),以区别于父进程。

为构建子进程的虚拟内存空间,fork()会复制父进程的虚拟内存结构,包括 mm_struct、内存区域描述符(VMA)以及页表。此时,父子进程的页表项被设置为只读,且相关内存区域被标记为私有写时复制,从而在共享物理页面的同时为后续修改做好准备。

当 fork()调用返回时,子进程已具备与父进程在调用 fork()时完全一致的虚拟内存映像。此时,两者共享相同的物理内存内容。若任一进程尝试对共享页面进行写入操作,会触发写时复制机制:内核将为该进程分配新的物理页面,复制原页面内容,并更新其页表以映射到新页面。这样,修改仅在当前进程的私有空间内生效,从而确保进程间内存空间的隔离性与独立性。

7.7 hello进程execve时的内存映射

当调用 execve函数在当前进程中加载并运行可执行目标文件 hello时,该函数会使用 hello程序完全替换当前进程映像。此过程包含以下几个关键步骤:

1.清除现有用户空间结构​

首先,execve会删除当前进程虚拟地址空间用户部分中所有已存在的区域结构,释放原有程序占用的资源。

2.建立私有内存映射区域​

随后,execve为 hello程序的代码段(.text)、数据段(.data)、BSS 段(.bss)、栈和堆创建新的内存映射区域。这些区域均设置为私有且写时复制属性:

代码段与数据段:直接映射到 hello文件中对应的 .text和 .data节区。

BSS 段:映射到匿名文件(初始内容为二进制零),其大小由 hello文件定义但无实际存储内容。

栈与堆:初始长度为二进制零,并在进程运行时动态扩展。

3.映射共享库区域​

如果 hello程序链接了共享库(如 glibc),execve会将这些共享对象映射到用户虚拟地址空间的共享区域中,以便在运行时进行动态链接。

4.设置程序执行入口点​

最后,execve将当前进程上下文中的程序计数器设置为代码区域的入口地址(即 hello的 e_entry值)。当该进程再次被调度执行时,指令指针将指向此入口,从而启动 hello程序的执行。

在整个过程中,Linux 内核会根据需要动态地换入(page in)hello的代码和数据页面,确保程序在物理内存中有足够的支撑。

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

在虚拟内存系统中,当 CPU 试图访问一个尚未调入物理内存的虚拟页面时,会触发 缺页(Page Fault)。具体来说,若地址翻译硬件检查页表项(PTE)发现其有效位为 0,即表示该虚拟页未缓存在 DRAM 中,便会引发缺页异常,从而调用内核中相应的异常处理程序 。

处理程序首先选择一个物理页作为"牺牲页",图中存放 VP4 的 PP3。若该牺牲页的内容已被修改(脏页),则需将其写回磁盘;否则,直接覆盖即可 。随后,内核将所需虚拟页 VP3 从磁盘载入至空闲出的物理页框 PP3,并更新 PTE3 中的物理页号和有效位,建立映射关系 。

添加图片注释,不超过 140 字(可选)

完成页面置换与页表更新后,异常处理程序返回,系统重新执行原先引发缺页的指令。此时 VP3 已缓存在物理内存中,地址翻译可正常完成,CPU 能够顺利访问目标数据 。

添加图片注释,不超过 140 字(可选)

7.9动态存储分配管理

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

7.9.1 动态内存管理的基本方法

动态内存分配器负责管理进程虚拟地址空间中的堆区域。堆通常起始于未初始化数据段之后,并向高地址方向扩展。内核通过变量 brk记录堆的当前顶端。分配器将堆视为一系列内存块的集合,每个块要么处于已分配状态(供应用程序使用),要么处于空闲状态(可供分配)。

分配器主要分为两种类型:

显式分配器:要求程序主动释放不再需要的内存块。例如,C语言中的 malloc()用于分配内存,free()用于释放内存。

隐式分配器(垃圾收集器):由分配器自动检测并回收程序不再使用的内存块,无需程序员显式释放。

在许多实际场景中,例如 printf函数内部可能会调用 malloc来动态分配缓冲区。malloc函数返回一个指针,指向至少满足指定大小的内存块。当程序不再需要该内存时,必须调用 free函数进行释放,以避免内存泄漏。

7.9.2 动态内存管理的策略

程序使用动态内存分配的主要原因是只有在运行时才能确定数据结构的大小。例如,若需读取一个长度未知的整数序列到数组中,使用静态数组会限制最大容量,而动态分配则能根据实际需求灵活调整,其上限仅受虚拟内存大小限制。对于已编译的库函数(如 printf),修改其内部缓冲区大小的唯一方法就是通过动态内存管理。

为高效管理堆内存,分配器采用多种策略:

空闲块组织方式:包括隐式空闲链表、显式空闲链表或分离空闲链表等结构,以提升搜索效率。

放置策略:当分配请求到来时,分配器需在空闲链表中寻找合适的块。常见策略有:

首次适配:从头开始搜索,选择第一个足够大的空闲块。

最佳适配:搜索整个链表,选择满足需求的最小空闲块。

下一次适配:从上一次搜索结束的位置开始继续查找。

分割与合并:当找到的空闲块大于请求大小时,通常将其分割,剩余部分作为新的空闲块。释放内存时,合并相邻的空闲块可以形成更大的块,减少内存碎片。合并操作常利用边界标记来快速访问相邻块的状态信息。

7.10本章小结

本章深入剖析了hello程序的存储管理机制。从逻辑地址、线性地址、虚拟地址到物理地址的转换过程,揭示了MMU在硬件层面的地址翻译作用。通过分析段式管理、页式管理、TLB、多级Cache以及fork和execve过程中的内存映射策略,阐明了操作系统如何通过虚拟内存机制为进程提供独立的地址空间抽象,并高效地利用物理资源。缺页中断处理体现了按需调页的精髓,而动态内存分配则展示了程序运行时如何灵活管理堆空间。这些机制共同构成了现代计算机系统复杂而精巧的存储管理体系。

(第7章 2分)


第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

在 Linux 系统中,所有输入/输出设备(如磁盘、键盘、显示器和网络接口)均被统一模型化为文件,并通常组织在 /dev目录下。这种"一切皆文件"的设计理念,使得应用程序可以像操作普通文件一样,通过相同的系统调用接口与各类硬件设备交互。

内核通过 Unix I/O 接口(也称为"系统级 I/O")为这种统一访问提供支持。该接口定义了一组简洁、低级的原子操作,主要包括:

open():打开设备文件,返回文件描述符。

read():从设备读取数据。

write():向设备写入数据。

lseek():调整读写位置(如适用)。

close():关闭设备文件,释放资源。

设备文件通过主设备号和次设备号进行标识。主设备号对应特定的设备驱动程序,次设备号则用于区分由同一驱动程序管理的不同设备实例。在 Linux 中,设备通常被划分为字符设备(以字节流方式访问,如终端)和块设备(以数据块为单位访问,如硬盘)。

这种高度抽象的 I/O 管理方法,核心优势在于其统一性。开发者无需为每种设备编写特定的交互逻辑,只需掌握一套基于文件描述符的操作方式,即可处理绝大多数 I/O 场景。这种设计不仅降低了开发与学习的复杂度,也增强了程序在不同设备间的可移植性。所有的 I/O 操作最终由内核中的设备驱动层和虚拟文件系统(VFS)层具体实现,从而对用户态程序屏蔽了底层硬件的差异。

8.2 简述Unix IO接口及其函数

在 Linux 系统中,所有输入/输出设备(如磁盘、键盘和显示器等)均被统一模型化为文件进行处理。这种"一切皆文件"的设计理念使系统能够通过一套一致的接口------Unix I/O------来管理所有I/O操作。该接口由内核提供,具有简洁、低级的特性,为应用程序提供了访问硬件设备的统一方式。

8.2.1 Unix I/O 接口的核心概念

Unix I/O 的核心机制围绕文件描述符展开,其工作流程包含以下关键环节:

打开文件

应用程序通过 open函数请求内核打开某个文件(或设备),内核会创建一个内部数据结构记录该文件的访问信息,并返回一个非负整数作为文件描述符。该描述符在后续所有操作中唯一标识已打开的文件。

预定义的标准流

每个进程启动时都会自动打开三个标准文件流,它们对应固定的描述符:

标准输入(STDIN_FILENO):描述符为 0

标准输出(STDOUT_FILENO):描述符为 1

标准错误(STDERR_FILENO):描述符为 2

这些常量定义在 <unistd.h>头文件中,建议使用常量名而非直接使用数字。

文件位置与定位操作

内核为每个打开的文件维护一个当前文件位置(初始值为 0,即文件起始处),该位置表示下一次读写操作的字节偏移。应用程序可通过 lseek函数显式调整此位置。

读写操作

读操作:从文件的当前位置开始,将指定数量的字节读入内存缓冲区,并相应增加文件位置。若当前位置已超过文件末尾,则读操作返回 0,表示遇到文件结束条件(EOF,文件尾并无特殊终止符)。

写操作:将内存缓冲区中的数据写入文件,从当前位置开始,并更新文件位置。

关闭文件

当文件不再需要访问时,应用程序应调用 close函数关闭文件描述符。内核会释放相关的内部数据结构,并将该描述符回收至可用描述符池。即使进程未显式关闭文件,在其终止时内核也会自动关闭所有已打开的文件。

8.2.2 Unix I/O 接口函数详解

函数 原型 功能描述 返回值与关键参数
打开文件​ int open(const char *filename, int flags, mode_t mode); 打开或创建文件,返回文件描述符。flags指定访问模式,mode设置新文件的权限。 返回值:成功返回最小未用描述符,出错返回 -1。关键 flags:O_RDONLY(只读)、O_WRONLY(只写)、O_RDWR(读写)、O_CREAT(不存在则创建)、O_TRUNC(存在则截断)、O_APPEND(追加模式)。
关闭文件​ int close(int fd); 关闭指定描述符 fd对应的文件,释放内核资源。 返回值:成功返回 0,出错返回 -1。
读文件 ssize_t read(int fd, void *buf, size_t n); 从描述符 fd的当前文件位置读取最多 n字节到缓冲区 buf。 返回值:成功返回读取的字节数;0表示 EOF;出错返回 -1。
写文件 ​ssize_t write(int fd, const void *buf, size_t n); 将缓冲区 buf中的 n字节写入描述符 fd的当前文件位置。 返回值:成功返回写入的字节数;出错返回 -1。

8.3 printf的实现分析

https://www.cnblogs.com/pianist/p/3315801.html

从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.

字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

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

以下是printf函数的函数体。

int printf(const char *fmt, ...) {

int i;

char buf[256];

va_list arg = (va_list)((char*)(&fmt) + 4);

i = vsprintf(buf, fmt, arg);

write(buf, i);

return i; }

printf 函数的参数列表包含一个固定参数和一个可变参数部分:其第一个参数是 const char*类型的格式字符串 fmt,用于指定输出格式;后续参数以省略号(...)表示,允许传入数量不定的实际参数。由于函数在编译时无法预知调用时传入的可变参数数量,因此必须在运行时依据格式字符串中的格式说明符(如 %d、%s 等)来动态解析并确定参数的个数与类型。

这种解析机制依赖于标准库中的 va_list系列宏(如 va_start, va_arg, va_end)来实现对可变参数的访问。printf 函数会依次读取格式字符串中的每个字符,当遇到格式占位符时,便按对应的类型从可变参数列表中提取参数值。这也意味着,格式字符串必须与后续可变参数的类型和数量严格匹配,否则会导致未定义行为。

va_list arg = (va_list)((char*)(&fmt) + 4);这行代码的作用是手动构造一个 va_list类型的指针 arg,使其指向函数可变参数列表(即 ...部分)中的第一个参数。

其原理基于C语言函数调用时的一个关键约定:参数是从右向左依次压入栈空间的。当调用类似 printf(const char* format, ...)的函数时,最右边的参数最先入栈,最左边的参数(在这里是固定参数 fmt)最后入栈。在常见的x86系统架构下,栈内存的生长方向是从高地址向低地址。因此,最后入栈的固定参数 fmt在栈中处于相对较高的地址。通过取得 fmt的地址 &fmt,然后将其转换为 char*类型(便于进行字节级别的地址计算),再加上一个偏移量(例如代码中的 4,这个值通常与系统字长和编译器的调用约定相关,目的是跳过固定参数 fmt在栈中所占的空间),最终得到的地址就指向了第一个可变参数在栈中的位置。

接下来的 i = vsprintf(buf, fmt, arg);是调用 vsprintf函数的核心步骤。这个函数的作用是,根据格式字符串 fmt所指定的规则,以及从 arg所指向的地址开始解析的可变参数列表,将格式化后的结果字符串写入到缓冲区 buf中。函数会返回写入缓冲区的字符数量,这个值通常被赋给变量 i以供后续使用。vsprintf是实现 printf系列函数功能的关键底层例程

int vsprintf(char *buf, const char *fmt, va_list args) {

char* p;

char tmp[256];

va_list p_next_arg = args;

for (p=buf;*fmt;fmt++)

{

if (*fmt != '%')

{

*p++ = *fmt;

continue;

}

fmt++;

switch (*fmt)

{

case 'x':

itoa(tmp, *((int*)p_next_arg));

strcpy(p, tmp);

p_next_arg += 4;

p += strlen(tmp);

break;

case 's':

break;

default:

break;

}

} return (p - buf); }

vsprintf返回的是要打印的字符串的长度。

Write这句是写操作,就是传入buf与参数数量i,将buf中的i个元素写到终端。

write:

mov eax, _NR_write

mov ebx, [esp + 4]

mov ecx, [esp + 8]

int INT_VECTOR_SYS_CALL

传参,然后int INT_VECTOR_SYS_CALL结束。

INT_VECTOR_SYS_CALL的实现:

init_idt_desc(INT_VECTOR_SYS_CALL,DA_386IGate, sys_call, PRIVILEGE_USER);

sys_call的实现:

sys_call:

call save

push dword [p_proc_ready]

sti

push ecx

push ebx

call [sys_call_table + eax * 4]

add esp, 4 * 3

mov [esi + EAXREG - P_STACKBASE], eax

cli

ret

在这里,sys_call实现了显示格式化了的字符串,也就是ASCII到字模库到显示vram的信息。

8.4 getchar的实现分析

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

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

在计算机系统中,键盘输入是一个典型的异步异常处理过程,涉及从硬件中断到应用层函数调用的完整链路。以下将系统梳理键盘中断的处理机制,并阐明其与 getchar等标准I/O函数的关系。

1.键盘中断的底层处理流程

当用户按下或释放键盘上的某个键时,会触发一个键盘中断(通常为 IRQ1)。该中断的处理流程如下:

扫描码生成与传递:键盘控制器将按键动作转换为扫描码并存入其输出缓冲区,随后向CPU的中断控制器发出中断请求。

中断服务程序(ISR):CPU响应中断后,会执行预设的键盘中断服务程序。该程序的核心任务是从指定端口(如 0x60)读取扫描码。

扫描码到ASCII码的转换:中断服务程序通常通过查表(如 key_map)等方式,将扫描码转换为对应的ASCII码(或系统使用的其他字符编码)。

存入系统键盘缓冲区:转换得到的字符码会被存入系统级的键盘缓冲区中。这个缓冲区由操作系统内核维护,用于临时存储尚未被应用程序读取的输入字符。

2.getchar() 的工作原理

getchar()是C语言标准库提供的函数,用于从标准输入(stdin)读取一个字符。其行为与上述键盘中断处理流程紧密衔接,具体实现如下:

宏定义与缓冲机制:getchar()通常通过宏定义为 getc(stdin)。这意味着它依赖于标准I/O库管理的输入缓冲区。

等待与读取:当程序调用 getchar()时,并非直接与硬件交互。如果标准I/O缓冲区为空,则程序会等待。一旦用户按下回车键,此前输入的一行字符(包括回车符本身)会被送入缓冲区,getchar()则从缓冲区中读取第一个字符并返回其ASCII码[intellectual property 1]。

缓冲区内字符的消耗:如果用户在按回车前输入了多个字符,后续的 getchar()调用会依次从缓冲区中读取剩余的字符,而不会再次等待用户按键,直到缓冲区为空。

3.连接底层中断与上层应用

getchar()等标准库函数通过系统调用接口(例如 read)与操作系统内核交互。当这些函数需要数据时,内核会从系统键盘缓冲区中取出字符,再传递给用户空间的应用程序。因此,可以将 getchar()的工作流程视为对底层键盘中断所产生结果的消费过程。

8.5本章小结

本章系统地阐述了 Linux 的 I/O 系统。其核心在于 "一切皆文件"​ 的抽象,通过 VFS​ 和 Unix I/O​ 接口为应用程序提供了统一、简洁的设备访问方式。通过分析 printf和 getchar的实现,我们深入理解了从用户态库函数到系统调用,再到内核中断处理和设备驱动的完整I/O路径。整个过程涉及虚拟地址与物理地址的转换、系统调用陷入内核、硬件中断处理、多级缓冲技术(如标准I/O缓冲区、内核缓冲区)等底层机制,充分体现了操作系统管理硬件资源、为应用程序提供服务的核心价值。

(第8章 1分)

结论

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

你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。

本报告以简单的 hello.c程序为线索,系统地追溯和剖析了一个程序在计算机系统中从静态代码到动态进程,直至最终消亡的完整生命周期。通过这个过程,我们得以用计算机系统的语言,清晰地总结 hello所经历的关键阶段:

1.从源码到可执行文件(P2P: Program to Process):

预处理:cpp预处理器根据 #指令对 hello.c进行宏替换、头文件包含和条件编译,生成纯 C 代码 hello.i。

编译:ccl编译器对 hello.i进行词法、语法分析、优化,生成针对特定架构(x86-64)的汇编代码 hello.s。

汇编:as汇编器将 hello.s中的助记符翻译成机器指令,生成可重定位目标文件 hello.o。此时,外部函数(如 printf)的地址尚未解析。

链接:ld链接器将 hello.o与所需的标准库(如 libc.so)合并,进行符号解析和地址重定位,生成最终的可执行目标文件 hello。此过程解决了外部符号的地址问题,并安排了程序在虚拟内存空间中的布局。

从进程创建到执行(O2O: Operation to Operation):

进程创建:在 Shell 中输入 ./hello后,Shell 调用 fork()系统调用,创建了一个与自身几乎完全相

添加图片注释,不超过 140 字(可选)

计算机系统原理

大作业

题 目 程序人生-Hello's P2P

专 业 AI+先进技术领军班

学   号 2024111517

班   级 2024Q0302

学 生 蒋欣成

指 导 教 师 史先俊

计算学部

2025年9月

摘 要

本文以经典的"Hello World"程序(hello.c)为案例,系统性地分析了从源代码到可执行文件的完整生命周期,涵盖预处理、编译、汇编、链接、进程管理、存储管理、I/O管理等关键阶段。全文以八个章节为结构,遵循从理论到实践的路径:首先阐述各环节的基本概念与作用,再结合具体命令操作、中间文件解析及截图示例,逐步揭示程序在Linux环境下的编译执行流程与操作系统底层机制。

通过剖析hello.c的预处理展开、汇编指令生成、符号解析与重定位、进程的创建与调度、虚拟内存映射以及I/O接口的实现,本文直观展示了计算机系统各层次(硬件、操作系统、运行时库、应用程序)之间的协同工作原理,深化了对计算机体系结构和程序运行机制的理解。

关键词:计算机系统;程序生命周期;体系结构;

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

自媒体发表截图

添加图片注释,不超过 140 字(可选)

图1自媒体发表截图


目 录

第1章 概述- 5 -

1.1 Hello简介- 5 -

1.2 环境与工具- 6 -

1.3 中间结果- 6 -

1.4 本章小结- 7 -

第2章 预处理- 8 -

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

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

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

2.4 本章小结- 10 -

第3章 编译- 12 -

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

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

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

3.4 本章小结- 15 -

第4章 汇编- 17 -

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

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

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

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

4.5 本章小结- 24 -

第5章 链接- 26 -

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

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

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

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

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

5.6 hello的执行流程- 33 -

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

5.8 本章小结- 36 -

第6章 hello进程管理- 38 -

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

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

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

6.4 Hello的execve过程- 39 -

6.5 Hello的进程执行- 39 -

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

6.7本章小结- 43 -

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

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

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

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

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

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

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

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

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

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

7.10本章小结- 51 -

第8章 hello的IO管理- 52 -

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

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

8.3 printf的实现分析- 54 -

8.4 getchar的实现分析- 56 -

8.5本章小结- 58 -

结论- 58 -

附件- 60 -

参考文献- 61 -


第1章 概述

1.1 Hello简介

根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。

P2P指的是你从一个存储在磁盘上的静态程序,被加载到内存中成为一个动态进程的完整过程。

预处理:

预处理器会对 hello.c源文件进行文本层面的处理。这包括将 #include指令所指向的头文件内容直接插入到源代码中,也会展开所有宏定义,并处理条件编译指令。最终生成一个纯C代码的 hello.i文件。

编译:

编译器对 hello.i文件进行复杂的词法分析、语法分析、语义分析和优化,将其翻译成与特定处理器架构(如x86-64)相关的汇编代码,生成 hello.s文件。

汇编:

汇编器将人类可读的 hello.s汇编代码几乎逐句翻译成机器可以直接理解的机器指令,并打包成一个可重定位目标文件hello.o。这个文件已经是二进制格式,但像 printf这样的外部函数地址尚未确定。

链接:

链接器扮演"最终装配师"的角色。它将 hello.o和所需的标准库(如C运行库)合并在一起,解析所有未确定的符号地址,进行地址空间分配和重定位,最终生成一个完整的、可以被加载执行的可执行目标文件hello。

加载与执行:

当你在Shell中输入 ./hello并回车后,Shell会调用 fork()系统调用创建一个新的子进程。随后,在这个子进程中调用 execve()函数,该函数会用 hello可执行文件的内容覆盖当前进程的内存空间,并设置好运行所需的上下文(如栈、堆等)。

O2O 则生动描绘了一个进程从创建到消亡的完整生命周期,犹如一场从虚无中来、回虚无中去的旅程。

Zero:登场之前

在程序被执行之前,Hello程序只是磁盘上的一系列二进制数据,物理内存中并没有它的活动痕迹,此为初始的"零"状态。

Process:生命的华彩

通过 fork()和 execve(),Hello进程被加载到内存中,操作系统为其分配一个唯一的进程ID和独立的虚拟地址空间。CPU通过时间片轮转机制让它与其他进程交替运行。当它要访问数据或指令时,内存管理单元通过页表等机制将虚拟地址转换为物理地址。如果要打印信息,I/O管理系统会处理这一切。

归零:完美的谢幕

当 main函数返回或进程被信号终止后,Hello进程的生命走向终点。操作系统内核会负责回收为其分配的所有资源:内存空间、打开的文件描述符、进程描述符等。它在物理内存中存在的所有痕迹被清理干净,重归于"零"。

1.2 环境与工具

列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。

Ubuntu Linux

Codeblocks

1.3 中间结果

列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。

Hello.c 源程序

Hello 用于调试程序

Hello.i​展开头文件、宏替换、处理条件编译

Hello.o 将汇编指令翻译成可直接被CPU识别的机器指令

Hello.s 将C代码翻译成机器指令的文本描述

Hello1 采用链接生成的调试程序

Hello.asm 用反汇编查看hello.o的内容

Hello1.asm 用于查看hello1的内容

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

Hello1.elf 用readelf读取hello1得到的ELF格式信息

1.4 本章小结

本章系统介绍了Hello程序的P2P(From Program to Process)与O2O(From Zero to Zero)完整生命周期。P2P过程详细阐述了从源代码hello.c经过预处理、编译、汇编、链接最终生成可执行文件的完整编译流程;O2O过程则描述了程序从磁盘静态存储到内存中动态执行,最终资源回收的进程生命周期。同时,本章明确了实验所需的硬件环境、软件平台和开发调试工具链,并列出了在整个过程中生成的关键中间文件及其作用,为后续章节的深入分析奠定了坚实基础。

(第1章0.5分)


第2章 预处理

2.1 预处理的概念与作用

预处理是 C/C++ 等编程语言编译过程中的一个重要阶段,它像一个"编辑助手",在源代码被正式编译之前,先对其进行一系列文本层面的处理和转换。

预处理的核心价值主要体现在以下几个方面:

文件包含

这是通过 #include指令实现的。预处理器会找到指定文件,并将其全部内容原封不动地复制并插入到 #include指令所在的位置 。这极大地促进了代码的复用和模块化开发。包含文件有两种形式:使用尖括号(如 #include <stdio.h>)通常用于包含系统标准头文件,编译器会到系统路径下查找;使用双引号(如 #include "myheader.h")则优先在当前目录查找,常用于包含用户自定义的文件 。

宏定义

宏定义通过 #define指令实现,它为一个字符串(代码片段或常量)起一个名字(宏名)。预处理器在编译前会将代码中所有出现宏名的地方替换为它所定义的字符串 。

无参宏:常用于定义常量,例如 #define PI 3.14159。这提高了代码的可读性和可维护性 。

带参宏:类似于函数,可以进行参数替换,例如 #define MAX(a, b) ((a) > (b) ? (a) : (b))。需要注意的是,宏是简单的文本替换,不涉及类型检查,使用时需谨慎以避免因运算符优先级或表达式多次求值导致意外结果 。

条件编译

条件编译指令(如 #if, #ifdef, #ifndef, #elif, #else, #endif)允许你根据特定条件,决定哪些代码块参与编译,哪些被忽略 。这在以下场景非常有用:

调试与发布:可以包含调试专用的代码(如打印日志),在发布版本中将其排除 。

跨平台移植:针对不同操作系统或硬件平台编写不同的代码,通过条件判断来编译对应的部分 。

防止头文件重复包含:通过 #ifndef和 #define配合(即"头文件保护宏"),可以确保一个头文件的内容只被引入一次,避免重复定义错误 。

2.2在Ubuntu下预处理的命令

添加图片注释,不超过 140 字(可选)

图2

gcc -E hello.c -o hello.i

-E:指示GCC只进行预处理,完成后停止。

-o hello.i:指定输出的预处理文件名为 hello.i。如果省略此选项,结果会直接打印在终端上。

执行此命令后,hello.i文件会包含展开所有头文件和宏之后的代码。

2.3 Hello的预处理结果解析

Hello.i的内容通过分析 hello.i这个预处理后的文件,我们可以清晰地看到预处理器是如何一步步"打扫"和"准备"我们的源代码的。它主要完成了以下几项关键工作。

头文件包含的展开

这是预处理结果中最显著的变化。预处理器会处理所有的 #include指令。

操作方式:当预处理器遇到 #include <stdio.h>这样的指令时,它会直接找到这个头文件(通常在系统路径如 /usr/include/下),并将该文件的全部内容逐字插入到 #include指令所在的位置。

结果表现:这导致一个简单的 #include行被替换成数百甚至上千行的代码。你会发现,原始的 #include指令消失了,取而代之的是来自 /usr/include/stdio.h、/usr/include/unistd.h等头文件的大量内容。这些内容本身可能又包含了其他头文件,因此最终生成的 hello.i文件会非常庞大。

宏定义的展开

预处理器会处理所有以 #define进行的宏定义。

操作方式:它会扫描源代码,将所有出现的宏名替换为它所定义的值或代码片段(即"宏体")。如果宏是带参数的,参数也会被实际调用时传入的值所替换。

结果表现:在 hello.i文件中,你再也看不到原始的 #define指令和宏名。它们都已经被替换成了具体的值。例如,如果你的代码中有 #define PI 3.14,那么在 hello.i中所有使用 PI的地方都会变成 3.14。

条件编译的处理

预处理器会根据 #if, #ifdef, #ifndef等条件编译指令来决定哪些代码块保留,哪些被删除。

操作方式:预处理器会判断这些指令后的条件表达式是否为真。如果为真,则保留对应的代码块;如果为假,则完全移除该代码块。

结果表现:在 hello.i文件中,所有的条件编译指令(如 #if, #else, #endif)本身都会消失,只留下满足条件、需要被编译的代码。

注释的删除

预处理器会移除所有形式的注释。

操作方式:无论是单行注释 //还是多行注释 /* ... */,都会被预处理器识别并删除。

结果表现:在 hello.i文件中,你找不到任何你在源文件中写的注释。它们被简单地替换为空格,以确保不会影响代码的原有结构。

行号标记与文件名信息的添加

为了便于编译器在后续阶段进行调试和错误定位,预处理器会插入特殊的行标记(Linemarkers)。

格式:这些行标记的格式通常类似于 # linenum "filename" flags。例如,# 1 "hello.c"表示接下来的代码源自 hello.c文件的第1行。

作用:当编译器在编译 hello.i文件报告错误时,可以根据这些行标记精确地指出错误在原始 hello.c文件中的位置,而不是在庞大的 hello.i文件中的位置。

空白字符的处理

预处理器会对源代码中的空白字符(空格、制表符、换行符)进行一定处理,使输出更规整。

操作方式:标记之间的空白字符可能会被"折叠"成单个空格。同时,为了保持代码的视觉对齐,预处理器可能会在非指令行的第一个标记前添加适当的空格。

2.4 本章小结

预处理是程序编译前由预处理器对源代码进行文本处理的阶段,它处理所有以#开头的指令,主要包括文件包含(通过#include将头文件内容插入源文件)、宏替换(通过#define定义常量或宏函数进行文本替换)和条件编译(使用#if、#ifdef等指令根据条件控制代码块是否参与编译)三大功能,此外还会删除注释和处理一些特殊指令。在Ubuntu下,可以使用gcc -E hello.c -o hello.i命令生成预处理后的.i文件,该文件会展开所有头文件和宏定义,并添加行号标记以便调试。对hello.i文件的解析显示了预处理如何将简洁的源文件转化为庞大的中间代码,而为后续编译阶段生成的hello.s汇编文件则进一步揭示了预处理结果如何被转换为低级机器指令的文本描述,体现了预处理在连接高级语言与底层硬件执行之间的桥梁作用。

(第2章0.5分)


第3章 编译

3.1 编译的概念与作用

概念:

编译是将人类可读的高级编程语言翻译成计算机能直接理解和执行的低级语言的过程。

作用:

实现高级语言到机器语言的翻译:这是编译最根本的作用。它架起了人类可读的高级语言与计算机只能执行的二进制机器指令之间的桥梁。

代码优化以提高效率:编译器在翻译过程中会进行大量优化,从而生成运行更快、占用内存更少的目标代码。

检查代码错误:在编译的各个阶段,编译器能及时发现源代码中的词法错误、语法错误和基本的语义错误,并给出错误报告,极大方便了调试。

实现跨平台能力:对于一些生成中间代码(如Java字节码)的语言,编译使其可以"一次编写,到处运行"。另一种方式是为不同平台提供不同的编译器,将同一份源码编译成不同平台的可执行文件。

管理复杂性与模块化开发:通过分别编译多个源文件再链接的方式,便于大型软件项目的分工合作、模块化管理和增量编译。

3.2 在Ubuntu下编译的命令

添加图片注释,不超过 140 字(可选)

图3.2Ubuntu下编译

gcc -S hello.i -o hello.s

-S:指示GCC进行预处理和编译,将C代码转换为汇编代码,完成后停止。

3.3 Hello的编译结果解析

代码对应的C程序逻辑大致是:检查命令行参数数量(预期为5个,包括程序名),若不匹配则报错退出;否则循环10次,每次打印参数并睡眠指定秒数,最后等待输入。

3.3.1 汇编代码整体结构与节(Sections)

汇编代码由多个节(section)组成,每个节存储不同类型的数据或指令:

.text节:存放程序指令(如main函数代码)。编译器将C代码翻译为机器指令后集中于此。

.rodata节:存放只读数据(如字符串常量)。本例中的.LC0(错误信息)和.LC1(格式字符串)在此节,使用.align 8确保8字节对齐以提高访问效率。

.note.gnu.property节:存储元数据(如栈保护信息),供链接器或加载器使用。

标识符说明:

.LFB6、.LC0等局部标签由编译器生成,用于管理控制流和数据。

.cfi_*指令(如.cfi_startproc)用于栈展开调试。

3.3.2 数据类型处理

编译器根据变量的作用域和类型分配存储位置:

常量:

字符串常量:如.LC0(八进制转义序列表示的中文文本)和.LC1("Hello %s %s %s\n")被存入.rodata节。它们的地址通过lea .LC0(%rip), %rdi加载到寄存器供函数使用。

整型常量:直接嵌入指令,如cmpl 5,−20(5。

变量:

局部变量:

循环变量i(int型)存储在栈帧偏移-4(%rbp)处,通过movl $0, -4(%rbp)初始化。

参数argc(int型)存于-20(%rbp),argv(char**型)存于-32(%rbp)。函数入口通过movl %edi, -20(%rbp)和movq %rsi, -32(%rbp)保存寄存器参数到栈中。

全局/静态变量:本例未出现,但通常全局变量会存入.data(已初始化)或.bss(未初始化)节,静态变量类似但作用域受限。

类型处理:

指针类型:argv作为char*[]类型,其元素通过基址偏移访问(如addq $8, %rax获取argv[1])。

隐式类型转换:atoi(argv[4])将char*转换为int,编译器通过call atoi实现,结果存于%eax。

3.3.3 运算与赋值操作

赋值操作:

直接赋值:movl $0, -4(%rbp)对应i = 0。

复合赋值:addl $1, -4(%rbp)对应i++,编译器优化为增量指令而非先加后存。

算术运算:

加法:addq 24,1, -4(%rbp)实现i++。

取负/正:未显式出现,但编译器通常通过neg或直接传输指令处理。

位与逻辑操作:

本例未出现位操作,但逻辑判断如cmpl $5, -20(%rbp)隐含比较结果,通过je/jle触发跳转。

3.3.4 数组、指针与结构操作

数组操作:

argv作为指针数组,元素通过基址偏移访问:

argv[1]:movq -32(%rbp), %rax; addq $8, %rax(偏移8字节,因指针大小为8字节)。

argv[2]:addq 16,24, %rax。

指针操作:

取地址:lea .LC1(%rip), %rdi获取字符串地址。

解引用:movq (%rax), %rcx加载argv元素指向的字符串。

结构操作:本例未出现结构体,但通常通过偏移量访问成员(如s.id对应固定偏移)。

3.3.5 控制转移实现

条件分支(if):

cmpl $5, -20(%rbp)比较argc与5,je .L2实现if (argc == 5)的跳转;否则顺序执行puts和exit。

循环(for):

初始化:movl $0, -4(%rbp)设置i=0。

条件检查:.L3中cmpl $9, -4(%rbp)比较i与9,jle .L4实现i <= 9时跳转至循环体。

迭代:.L4结尾的addl $1, -4(%rbp)递增i,jmp .L3返回检查。

其他控制流:未使用switch/do-while,但编译器通常通过跳转表或条件跳转实现。

3.3.6 函数操作处理

参数传递:

整数和指针通过寄存器传递:main的参数argc用%edi,argv用%rsi。

printf参数:格式字符串地址存%rdi,后续参数按顺序存%rsi(argv[1])、%rdx(argv[2])、%rcx(argv[3]),movl $0, %eax表示无浮点参数。

函数调用与返回:

调用:call printf@PLT使用PLT(过程链接表)实现动态链接。

返回:main结尾的movl $0, %eax设置返回值0,leave和ret恢复栈帧并返回。

栈帧管理:

入口:pushq %rbp保存基指针,movq %rsp, %rbp设置新栈帧,subq $32, %rsp为局部变量分配空间(16字节对齐)。

出口:leave等价于movq %rbp, %rsp; popq %rbp,恢复栈指针。

3.4 本章小结

编译的本质是将高级语言程序转换为计算机能直接执行的机器语言程序。这个过程如同一位技艺高超的翻译官,不仅进行语言转换,还会检查错误、优化性能,并管理程序的复杂结构。其主要作用包括:

搭建沟通桥梁:实现人类可读的高级语言到机器指令的转换。

提升执行效率:通过代码优化生成更高效的目标代码。

保障代码质量:在编译过程中进行词法、语法和基础语义错误检查。

支持跨平台与模块化开发:例如通过中间代码或为不同平台编译,以及分别编译再链接的方式管理大型项目。

在Ubuntu下使用GCC(GNU Compiler Collection)编译一个C程序,通常经历预处理、编译、汇编、链接四个关键阶段。例如,命令 gcc -S hello.i -o hello.s即完成了从预处理后的文件(hello.i)到汇编代码(hello.s)的编译阶段。

从Hello.s看编译器的具体实现

对hello.s汇编代码的分析,让我们能直观看到编译器如何将C语言的各种元素映射到机器指令和内存布局上。

数据存储与类型处理:编译器会根据数据类型和作用域差异化管理数据。例如,字符串常量存储在.rodata节;局部变量(如循环变量i、参数argc和argv)则在栈上分配空间。对于指针类型(如char** argv),通过计算基地址偏移来访问元素。

控制流实现:C语言中的控制结构被翻译成汇编中的条件跳转和循环逻辑。例如,if (argc == 5)被转换为 cmpl $5, -20(%rbp)和 je .L2;for循环则被分解为初始化、条件检查、循环体执行和迭代递增几个明确的标签和跳转块。

函数调用机制:函数调用遵循特定的调用约定。例如,前几个整数或指针参数通过%rdi, %rsi, %rdx等寄存器传递,返回值通过%rax寄存器带回。同时,编译器会自动插入代码来管理栈帧(如pushq %rbp, subq $32, %rsp),确保函数调用的隔离与正确返回。

编译器的设计处处体现着对效率和控制力的追求:

直接操作硬件资源:汇编代码中大量使用寄存器和精确的内存地址计算,旨在减少内存访问次数,提升执行速度。

精细的指令选择:即便是简单的i++,编译器也可能根据上下文优化为更高效的incl指令,而非简单的加载、加1、存储三步操作。

结构化可读性与底层控制的结合:虽然我们编写的是结构化的高级语言,但编译器最终生成的是顺序执行的机器指令。通过分析汇编代码,开发者可以理解程序底层的真实行为,这对于性能调优、漏洞分析等领域至关重要。从最简单的Hello World到处理命令行参数、循环、函数调用,编译器默默地将高级逻辑转化为处理器能够一步步执行的精确指令序列。

(第3章2分)


第4章 汇编

4.1 汇编的概念与作用

概念:特指由汇编器将汇编语言源代码转换为机器代码目标程序的翻译过程本身

实现底层硬件精确控制

汇编语言最不可替代的作用在于它能直接、高效地控制计算机硬件。由于它和机器指令基本是一一对应的,程序员可以使用它直接操作CPU寄存器、内存地址和I/O端口。这种特性使得它在开发一些底层系统核心组件时至关重要,例如:

操作系统内核:操作系统的启动引导、任务调度等最基础的部分往往需要汇编代码来初始化硬件。

设备驱动程序:驱动需要直接与显卡、硬盘控制器等硬件设备交互,汇编语言能提供精确的时序控制。

嵌入式系统与实时系统:在资源受限的微控制器和对响应时间有严格要求的实时系统中,汇编语言能确保程序小巧且运行可预测。

优化程序性能与效率

在对执行速度、内存占用有极致要求的场景下,汇编语言是进行性能优化的终极手段。

提升速度:由汇编语言编写的程序经过汇编器翻译后,生成的目标代码非常精简,执行效率高,占用内存少。因此,在早期计算机资源紧张的时代,以及现在一些大型软件的核心算法(如视频编解码、图形渲染引擎)中,仍会使用汇编来"抠"出每一分性能。

空间优化:在存储空间极其有限的设备(如某些嵌入式芯片)中,汇编语言能产生体积最小的机器码,充分利用每一字节的存储。

辅助软件调试与分析

由于汇编语言反映了代码在CPU上执行的真正过程,它在软件调试和逆向工程中扮演着关键角色。

理解程序本质:当高级语言程序出现难以理解的底层错误时,开发者可以通过查看编译器生成的汇编代码,来定位问题的根本原因。

分析软件机制:在信息安全领域,分析病毒、进行软件破解或逆向工程时,分析师必须阅读汇编代码,因为通常无法获得软件的源代码。

深化计算机系统理解

对于计算机科学领域的学习者和研究者而言,学习汇编语言具有重要的教育意义。

理解计算机工作原理:通过学习汇编语言,可以深入理解CPU如何执行指令、内存如何管理、数据如何流动等计算机核心工作原理。这正是《计算机组成原理》、《操作系统》等课程的重要实践基础。

奠定高级语言基础:了解底层机制有助于程序员更好地理解高级语言中的各种概念(如指针、函数调用栈)是如何在机器层面实现的,从而写出更高效、更稳健的代码。

4.2 在Ubuntu下汇编的命令

添加图片注释,不超过 140 字(可选)

图4.2Ubuntu下汇编

gcc -c hello.s -o hello.o

-c:指示GCC进行预处理、编译和汇编,但不进行链接,生成目标文件。

4.3 可重定位目标elf格式

添加图片注释,不超过 140 字(可选)

图4.3.1可重定位目标elf格式

elf文件头定义了文件的整体属性信息,比较重要的几个属性是:魔术字,入口地址,程序头位置、长度和数量,文件头大小(52字节),段表位置、长度和个数。

添加图片注释,不超过 140 字(可选)

图4.3.2elf头

节头表:记录ELF文件中各节位置,大小,偏移等信息。

添加图片注释,不超过 140 字(可选)

图4.3.3节头表

重定位是连接符号引用与符号定义的过程。例如,程序调用函数时,关联的调用指令必须在执行时将控制权转移到正确的目标地址。可重定位文件必须包含说明如何修改其节内容的信息。通过此信息,可执行文件和共享目标文件可包含进程的程序映像的正确信息。

添加图片注释,不超过 140 字(可选)

图4.3.4重定位节

符号表是编译器(或解释器)在翻译源代码过程中使用的一种核心数据结构,其主要作用是为源代码中的各种标识符(如变量名、函数名、常量名等)建立并维护一个信息注册与查询中心。

添加图片注释,不超过 140 字(可选)

图4.3.5符号表

4.4 Hello.o的结果解析

添加图片注释,不超过 140 字(可选)

图4.4反汇编

hello.o: 文件格式 elf64-x86-64

Disassembly of section .text:

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_PLT32puts-0x4

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

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

2b: R_X86_64_PLT32exit-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_PLT32printf-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_PLT32atoi-0x4

80:89 c7 mov %eax,%edi

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

83: R_X86_64_PLT32sleep-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_PLT32getchar-0x4

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

9b:c9 leaveq

9c:c3 retq

特性对比 hello.s(汇编源代码) hello.o(反汇编机器码) 关键差异说明
指令表示 助记符(如 movl, call) 机器码(如 c7 45 fc) 汇编器将助记符翻译为二进制操作码。
函数/标号 直接使用符号名(如 .L2, puts) 使用相对偏移或0占位符(如 1c: R_X86_64_PC32 .rodata-0x4) 汇编时外部符号地址未知,需链接器重定位。
操作数格式 十进制(如 $0) 十六进制(如 $0x0) 仅为数值表示形式的差异,本质相同。
分支/跳转目标 标签(如 .L4:) 绝对地址偏移(如 jle 38 <main+0x38>) 汇编器已将标签计算为相对于节开始的偏移地址。
全局数据访问 符号化地址(如 .LC0(%rip)) 地址为0并带重定位条目(如 5f: R_X86_64_PC32 .rodata+0x2c) 运行时地址需链接器填入,访问方式为PC相对寻址。

机器语言的构成与映射关系

机器语言是CPU能够直接解码和执行的低级指令集,由操作码​ 和操作数​ 组成。

它指定了CPU要执行的具体操作,例如加法、减法、数据移动或跳转。在您提供的反汇编代码中,每一行开头的十六进制数字(如 f3 0f 1e fa, 55)就是操作码。例如,55对应 push %rbp指令的操作码。

操作数指明了指令操作的对象。机器指令中的操作数可以是立即数、寄存器编号或内存地址。

映射关系:汇编器负责将汇编指令中的操作数(如 0x5, -0x14(%rbp))编码成机器指令中对应的二进制形式。例如,cmpl 0x5, -0x14(%rbp)这条指令,汇编器会生成操作码 83 7d ec来表示"比较立即数"和操作的内存位置,而立即数 5则被直接编码为 05。

关键差异深度解析

  1. 函数调用 在 hello.s中,函数调用直接使用符号名,例如 call puts。 在 hello.o的反汇编中,调用指令的操作数被置为0,并生成重定位条目(如 21: R_X86_64_PLT32 puts-0x4)。这是因为在汇编阶段,puts等外部函数的实际地址尚未确定。这个重定位条目告诉链接器:"在最终链接时,请将 call指令后面的4个字节(32位)替换为 puts函数相对于过程链接表(PLT)的正确偏移地址"。这是两者最显著的不一致之一。

  2. 分支转移在 hello.s中,使用标签(如 .L2, .L4)来标记跳转目标。 在 hello.o的反汇编中,标签被替换为具体的偏移地址(如 je 2f <main+0x2f>,意为"跳转到位于 main函数起始偏移 0x2f字节处的指令")。这个偏移地址是汇编器在将汇编代码翻译成机器代码时,根据代码布局计算出来的相对地址。与函数调用不同,由于跳转目标在同一文件同一节内,其地址在汇编阶段即可确定,因此无需链接器重定位。

  3. 全局数据访问在 hello.s中,访问字符串常量通常使用类似于 lea .LC0(%rip), %rdi的指令,其中 .LC0是字符串所在的地址标签。

在 hello.o的反汇编中,这条指令变为 lea 0x0(%rip), %rdi,并伴随重定位条目 1c: R_X86_64_PC32 .rodata-0x4。这同样是地址未决的表现。链接器之后会根据此条目,计算出字符串常量真正的运行时地址与下一条指令地址之间的差值,并回填到操作数中,形成有效的PC相对寻址。

4.5 本章小结

汇编过程是将人类可读的汇编指令精准翻译成机器指令的关键步骤,并生成包含代码、数据和重定位信息的可重定位目标文件。hello.o的ELF格式及其反汇编代码表明,它本身并非完整可执行程序,而是一个半成品,其价值在于为链接器的最终"组装"提供了标准化、模块化的组件。深入理解这一过程及文件格式,对于掌握程序构建的底层机制、进行高性能优化和深度调试具有不可替代的意义。

(第4章1分)


第5章 链接

5.1 链接的概念与作用

概念:将分散的代码与数据片段汇集并融合成一个完整的文件的过程。这一文件能够被载入内存并启动执行。

作用:链接是指链接器将一个或多个由编译器或汇编器生成的目标文件以及所需的库文件合并,生成一个可执行文件(如 hello)的过程。其核心作用在于符号解析(解决目标文件之间的函数、变量引用问题)和地址重定位(为所有代码和数据分配最终的运行时内存地址)。通过这种方式,链接将分散编译的模块"拼接"成一个完整的、可直接被操作系统加载运行的程序。

5.2 在Ubuntu下链接的命令

添加图片注释,不超过 140 字(可选)

图5.2Ubuntu下链接

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

分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。

添加图片注释,不超过 140 字(可选)

图5.3.1readelf

通过对比分析发现,hello1.elf与 hello.elf的ELF文件头所包含的信息类别基本一致。它们的起始部分都是一个16字节的魔数(Magic),这个序列标识了文件为ELF格式,并定义了生成该文件的系统的字长(32位/64位)和字节序(大端序/小端序)。文件头中魔数之后的部分,包含了帮助链接器解析和处理目标文件的各种信息。

具体到两个文件的差异:与 hello.elf相比,hello1.elf的一些基础属性,如魔数序列和文件类别(Class),保持不变。然而,其文件类型(例如是可重定位文件、可执行文件还是共享库文件)发生了变化。同时,hello1.elf的程序头表(Program Header Table)的大小以及节头表(Section Header Table)中条目的数量有所增加。此外,hello1.elf还获得了一个明确的程序入口点地址 0x4010f0,这通常意味着它可能从一个需要进一步链接的目标文件(.o)变成了一个可以直接加载执行的可执行文件。

添加图片注释,不超过 140 字(可选)

图5.3.2elf头

节头表是ELF文件中用于链接视图的核心数据结构,它通过一个结构体数组详细描述了文件中所有节的属性信息,为链接器在合并节、解析符号和进行重定位时提供关键依据。

添加图片注释,不超过 140 字(可选)

图5.3.3节头表

程序头是ELF文件中用于执行视图的核心数据结构,它通过一个结构数组详细描述了系统加载程序时所需的各种段的布局和属性,为操作系统的加载器提供了将文件映射到内存并启动执行的关键信息。

添加图片注释,不超过 140 字(可选)

图5.3.4程序头

动态链接

添加图片注释,不超过 140 字(可选)

图5.3.5动态链接

重定位节是ELF文件中用于地址修正的关键数据结构。它包含了一系列重定位条目,指导链接器或动态链接器在构建或加载程序时,修改代码和数据段中对地址的引用,使其指向正确的内存位置。

添加图片注释,不超过 140 字(可选)

图5.3.6重定位节

符号表中包含了程序中各种符号(函数、变量和标签等),以便链接器和调试器等工具能够定位和处理这些符号。

添加图片注释,不超过 140 字(可选)

图5.3.6符号表

5.4 hello的虚拟地址空间

使用gdb/edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。

添加图片注释,不超过 140 字(可选)

图5.4.1虚拟地址

5.5 链接的重定位过程分析

objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。

添加图片注释,不超过 140 字(可选)

图5.5.1objdump

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

添加图片注释,不超过 140 字(可选)

图5.5.2plt

结合hello.o的重定位项目,分析hello中对其怎么重定位的。

在动态链接过程中,链接器需要处理目标文件中的重定位条目。它会分析这些条目,计算出目标函数在过程链接表(PLT)中的条目与当前指令下一条指令之间的相对距离。然后,链接器会根据这个计算出的相对偏移量,修改目标指令操作码后的相应字节,将这些字节的内容重写为正确的相对地址。这样,当程序执行时,call指令就能正确地跳转到 PLT 中的目标函数入口。通过完成对所有此类重定位条目的处理,链接器最终生成了地址引用完整的、可供反汇编器解析的代码

添加图片注释,不超过 140 字(可选)

图5.5.3main

5.6 hello的执行流程

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

程序开始有_start调用__libc_start_main

添加图片注释,不超过 140 字(可选)

图5.6.1_start和__libc_start_main

添加图片注释,不超过 140 字(可选)

图5.6.2main

  1. 开始执行:_start、_libe_start_main

(2)执行main:_main、printf、_exit、_sleep、getchar

(3)退出:exit

5.7 Hello的动态链接分析

分析hello程序的动态链接项目,通过edb/gdb调试,分析在动态链接前后,这些项目的内容变化。要截图标识说明。

当程序调用一个由共享库定义的函数时,由于编译器无法预测这时候函数的地址是什么,编译系统提供了延迟绑定的方法,将过程地址的绑定推迟到第一次调用该过程时。延迟绑定主要通过GOT与PLT的写作来确定函数的地址。

这里查看pritnf的延迟绑定结果。

添加图片注释,不超过 140 字(可选)

图5.7.1main

添加图片注释,不超过 140 字(可选)

图5.7.2动态链接前

添加图片注释,不超过 140 字(可选)

图5.7.3动态链接后

动态链接前后

之前的状态:0x404020→ 0x0000000000401040(指向PLT内部的桩代码,用于触发动态链接器)

现在的状态:0x404020→ 0x00007ffff7e20cc0(指向libc.so.6中printf的真实实现)

5.8 本章小结

本章深入探讨了程序构建过程中的关键环节------链接。首先明确了链接是将分散编译的目标模块和库文件整合成单一可执行文件的过程,其核心作用是符号解析和地址重定位。通过手动链接命令,揭示了链接器在幕后需要处理的复杂依赖关系。

通过对可执行文件 hello的ELF格式分析,详细阐述了其核心结构:ELF头作为文件蓝图,定义了整体属性;节头表描述了链接视图下的各种节(如代码节 .text、数据节 .data);程序头表则提供了执行视图,指导操作系统如何将程序映射到虚拟内存空间。使用调试器对虚拟地址空间的探查,直观地验证了链接阶段分配的内存地址在运行时的实际映射。

通过对比 hello.o与 hello的反汇编代码,清晰地展示了重定位的实际过程:链接器通过计算和修正地址引用,将零散的目标文件"缝合"成统一的整体。最后,跟踪了 hello从 _start到 main再到终止的执行流程,并分析了动态链接中延迟绑定通过GOT和PLT实现的巧妙机制。

链接不仅是程序构建的最后一步,更是连接编译时与运行时的重要桥梁。理解链接原理,对于深入掌握程序如何被加载、如何在内存中布局、如何与系统交互至关重要,也是进行大型项目构建、库文件管理和程序调试优化的基础。

(第5章1分)


第6章 hello进程管理

6.1 进程的概念与作用

进程是计算机中正在运行的程序的实例,是操作系统进行资源分配和调度的基本单位。简单来说,进程就是程序在内存中的一次执行活动,它包含了程序代码、相关数据以及运行时的状态信息。

1.资源分配的基本单位

进程是操作系统进行资源分配的最小单位。当进程被创建时,操作系统会为其分配独立的内存空间、CPU时间片、文件句柄、网络连接等系统资源。每个进程都拥有独立的虚拟地址空间,这保证了进程间的内存隔离,一个进程的崩溃不会影响其他进程。

  1. 实现多任务并发执行

通过进程调度机制,操作系统可以在单核CPU上实现多个进程的"同时"运行。即使只有一个CPU核心,通过快速切换时间片,也能让用户感觉多个程序在同时运行,这大大提高了系统的利用率和用户体验。

  1. 提供安全隔离机制

每个进程都有独立的地址空间,进程之间不能直接访问彼此的内存数据。这种隔离机制保证了系统的安全性,即使某个进程出现错误或恶意行为,也不会直接破坏其他进程或系统内核。

  1. 支持程序动态执行

进程具有动态性,它从创建到消亡经历了完整的生命周期。进程可以根据需要创建子进程,也可以与其他进程进行通信和协作,这为构建复杂的应用程序提供了基础。

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

Shell(特别是Bash)是Linux/Unix系统的命令行解释器,它作为用户与系统内核之间的桥梁,负责接收用户输入的命令,进行解释、执行,并将结果返回给用户。它不仅是交互式操作的界面,也是一种脚本编程语言,能够将多个命令组织成脚本文件,实现复杂或重复性的系统管理任务自动化。

Shell的基本功能包括命令历史记录(方便用户查看和重复执行过往命令)、命令别名设置(为复杂命令创建简短的替代名称)以及命令和文件路径的自动补全(通过Tab键提高输入效率)。它还通过输入输出重定向(如 >、>>、<)改变标准输入/输出的方向,以及使用管道符(|)将一个命令的输出作为另一个命令的输入,从而实现强大的命令组合能力。

Shell脚本的执行主要有几种方式:一是为脚本文件添加可执行权限后直接运行(如 ./script.sh);二是通过指定的解释器来执行(如 bash script.sh);三是使用 source命令或点号(.)在当前Shell环境中执行脚本。通过灵活运用这些特性和执行方式,Shell极大地提升了系统管理和开发的效率。

6.3 Hello的fork进程创建过程

在终端中输入 ./hello 命令后,shell 会接收并解析该命令。当确认这是一个可执行程序时,shell 会通过调用 fork函数创建一个子进程。子进程会完全复制父进程的代码段、数据段、堆、共享库以及栈段等内存结构,但两者拥有不同的进程 ID(PID)。接着,父进程会将该子进程纳入到一个新的或已有的进程组中。

6.4 Hello的execve过程

子进程创建完成后,会调用 execve函数加载指定的可执行文件(此处为 hello)到当前进程空间中。该函数会先清空原进程的用户地址空间,再根据可执行文件的结构,建立新的代码段、数据段等内存映射,并将程序所需的共享库载入内存。最终,进程从 hello的入口点开始执行。

6.5 Hello的进程执行

结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。

在终端中输入 ./hello命令后,操作系统首先对其进行解析,定位到对应的可执行文件,接着通过 fork系统调用为该命令创建子进程。由于 fork涉及内核操作,处理器会立即从用户态切换到核心态,执行流程进入操作系统内核。

内核会暂存当前进程的上下文,随后由进程调度程序选择下一个待运行的进程,并恢复其之前保存的上下文。完成切换后,控制权交还给该进程,处理器重新回到用户态继续执行。在此过程中,操作系统为每个进程分配时间片,决定其执行时长。若时间片用完或发生 I/O 事件,将触发中断,再次切换到核心态,由内核处理中断并可能重新调度进程。

最后,当进程运行结束时,操作系统会回收其占用的资源,并向父进程发送相应的通知信号。

6.6 hello的异常与信号处理

hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。

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

中断异常

中断通常由外部硬件设备异步触发,例如键盘输入、磁盘I/O完成或定时器信号。它的核心特点是与当前正在执行的指令无关。

处理方式:处理中断异常时,CPU会暂停当前指令流,硬件会自动保存当前程序的计数器(PC)和程序状态字(PSW)等关键上下文。随后,CPU根据中断向量表找到并执行对应的中断服务程序,以响应外部设备的需求。中断处理完成后,CPU会恢复之前保存的上下文,并返回到被中断的程序,从下一条指令继续执行,整个过程对原程序是透明的。

添加图片注释,不超过 140 字(可选)

图6.6.1中断异常

陷阱异常

陷阱是程序有意主动触发的同步事件,最典型的例子是系统调用。当程序执行如 ecall这样的特殊指令时,会主动陷入内核,请求操作系统提供服务。

处理方式:处理陷阱异常时,其硬件保存上下文的过程与中断类似。随后,CPU会跳转到预设的陷阱处理程序入口,这通常是操作系统内核的代码。操作系统会分析陷阱产生的原因(例如,通过寄存器判断是哪个系统调用),然后执行相应的服务例程(如打开文件、创建进程等)。服务完成后,操作系统会安排CPU从陷阱返回,并继续执行发出陷阱指令的下一条指令。

添加图片注释,不超过 140 字(可选)

图6.6.2陷阱异常

故障异常

故障是由程序执行时出现的可修复错误引起的同步异常,例如访问无效内存地址(段错误)或遇到缺页异常。

处理方式:处理故障异常的目标是尝试修复错误,使程序能够继续执行。当故障发生时,硬件会保存上下文,然后将控制权交给操作系统的故障处理程序。处理程序会诊断故障原因,如果可能,会尝试修复问题(例如,缺页处理程序会将所需的页面从磁盘调入内存)。修复成功后,硬件会重新执行那条引发故障的指令。如果错误无法修复(例如,访问了无权访问的内存),则故障可能会升级为终止异常,导致进程被结束。

添加图片注释,不超过 140 字(可选)

图6.6.3故障异常

终止异常

终止是由不可恢复的严重硬件错误导致的异常,例如内存奇偶校验错误或严重的硬件故障。

处理方式:这是最严重的情况,处理目标不再是恢复程序运行,而是最大限度地保护系统整体稳定性,防止错误扩散。当终止发生时,操作系统通常无法进行有效的修复。它会立即终止正在运行的进程或整个系统,并可能输出错误日志用于后续诊断。在某些极端情况下(如双重故障),系统可能直接崩溃或重启。

添加图片注释,不超过 140 字(可选)

图6.6.4

不停乱按

添加图片注释,不超过 140 字(可选)

图6.6.5不停乱按

回车

添加图片注释,不超过 140 字(可选)

图6.6.6回车

Ctrlz以及一系列操作

添加图片注释,不超过 140 字(可选)

图6.6.7.1ctrlz

添加图片注释,不超过 140 字(可选)

图6.6.7.2ctrlz

Ctrlc

添加图片注释,不超过 140 字(可选)

图6.6.8ctrlc

6.7本章小结

本章系统地阐述了进程的核心概念及其在操作系统中的核心作用,详细解析了Shell作为命令行解释器的功能与工作机制。通过以hello可执行文件为例,具体分析了进程的创建(如fork机制)、程序加载(如exec系列函数)以及进程终止与资源回收的完整生命周期。此外,本章还模拟了程序执行过程中的典型异常场景,并对相应的信号处理和进程状态转换进行了深入分析。

(第6章2分)


第7章 hello的存储管理

7.1 hello的存储器地址空间

7.1.1 逻辑地址

逻辑地址是程序代码在分段内存管理模型中生成的地址,通常表示为段选择符(段基址)与段内偏移量的组合。在支持地址转换的CPU保护模式下,逻辑地址并不能直接用于访问物理内存,而需通过分段机制转换为线性地址。在hello程序的反汇编代码中,指令所引用的地址即为逻辑地址,必须结合对应的段基址进行转换,才能获得其实际对应的内存地址。

7.1.2 线性地址

线性地址是逻辑地址转换至物理地址过程中的中间层。当CPU的分段管理单元将一个逻辑地址(由段基址和偏移量组成)中的这两部分相加后,便得到了一个连续的、32位的线性地址。如果系统未启用分页机制,此线性地址即直接对应物理地址。在分析hello的反汇编代码时,代码中显示的偏移地址(逻辑地址)与段基址相加后,即得到对应的线性地址。

7.1.3 虚拟地址

虚拟地址是程序在虚拟内存空间中使用的地址。它提供了一个与物理内存大小无关的、统一的地址空间抽象。在Linux系统的具体实现中,为了简化内存管理,通常将段基址设置为0,这使得逻辑地址的偏移量部分直接等于线性地址。因此,在Linux环境下,虚拟地址在数值上等同于线性地址。查看hello可执行文件的ELF格式,程序头中的VirtAddr字段所指示的就是各节的虚拟地址。基于Linux的这一特性,hello反汇编代码中的地址加上对应段基址(通常为0)得到的线性地址,也就是该指令或数据所在的虚拟地址。

7.1.4 物理地址

物理地址是计算机主存(RAM)每个字节单元的唯一标识,是数据在硬件层面上的实际存放位置。当CPU需要访问内存时,最终提交到地址总线上的就是物理地址。在hello程序的执行过程中,其代码和数据所使用的虚拟地址,会通过操作系统和内存管理单元(MMU)的协作,经过复杂的地址翻译过程,最终被映射到具体的物理地址上。CPU正是通过这个物理地址来读写真正的内存数据。结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。

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

在x86架构中,逻辑地址由两个部分构成:段选择符和段内偏移量。段选择符是一个16位的字段,。其中,高13位为索引号,用于定位段描述符在描述符表中的位置;最低2位为请求特权级,用于指定当前访问的特权级;第2位为表指示符,用于选择全局描述符表(GDT,TI=0)或局部描述符表(LDT,TI=1)

添加图片注释,不超过 140 字(可选)

图7.2逻辑地址

在地址转换过程中,首先根据TI位判断应访问GDT还是LDT,并获取对应描述符表的基地址与界限。随后,利用索引号在描述符表中定位相应的段描述符,从中读取段的基地址。最终,将段基地址与段内偏移量相加,即得到线性地址。

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

在现代操作系统中,线性地址(也称为虚拟地址)通常通过页式管理机制转换为物理地址。线性地址被划分为虚拟页号​ 和虚拟页偏移量​ 两部分。系统为每个进程维护一个页表,该表是一个由页表项​ 组成的数组,用于记录虚拟页到物理页框的映射关系。

地址转换过程如下:首先,从进程控制块中获取页目录的基地址。利用线性地址中作为页目录索引的高位部分,在页目录中找到对应的页目录项,该项保存了下一级页表的物理基地址。接着,通过线性地址中作为页表索引的中间部分,在上述页表中定位到具体的页表项,从中获得目标物理页框的起始地址。最后,将该起始地址与线性地址中作为页内偏移的低位部分相加,即得到最终的物理地址。

为提升转换效率,CPU中通常设有转译后备缓冲器,用于缓存最近使用的页表项。当进行地址转换时,MMU首先查找TLB。若TLB命中,则可直接获得物理页框号。若未命中,则需按上述步骤访问内存中的页表,并将取得的页表项缓存至TLB中。若页表项显示目标页面不在物理内存中(即触发缺页异常),则操作系统负责从磁盘调入相应页面,并更新页表后重新执行地址转换。

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

TLB,常称为快表,是现代 CPU 中用于加速虚拟地址到物理地址转换的专用硬件缓存。其核心作用是缓存最近使用的页表项,避免每次地址转换都需访问速度较慢的主存,从而显著提升系统性能

TLB可视为页表的高速缓存,其查询基于虚拟地址中的虚拟页号。TLB通常采用高相联度(如全相联或组相联)的组织方式。若TLB有 T=2t个组,则TLB索引(TLBI)由VPN的最低 t位构成,TLB标记(TLBT)则由VPN的剩余高位组成,以此进行组选择和行匹配。

其工作流程核心在于命中与未命中两种情形:

TLB命中:当CPU产生虚拟地址后,内存管理单元(MMU)可直接从TLB中获取对应的PTE,迅速完成地址翻译并将物理地址发送至缓存/主存,最终将数据返回CPU。这避免了访问多级页表的开销。

TLB未命中:若TLB中无对应PTE,则需进行页表遍历。以四级页表为例,CR3寄存器指向第一级页表的基地址。MMU依次用虚拟地址中的四个VPN片段作为偏移量,逐级查找下一级页表的基地址。该过程需经历四次内存访问,最终从第四级页表的PTE中获取物理页框号(PPN),与页内偏移(VPO)组合成物理地址(PA)。此后,该PTE通常会被载入TLB,以备后续访问。

添加图片注释,不超过 140 字(可选)

图7.4工作流程

采用多级页表(层次结构如图所示)的主要优势在于节省存储空间。若某级页表中的PTE为空,则其指向的下一级页表就无需存在。这种结构允许操作系统仅为进程实际使用的虚拟地址区域分配页表空间,避免了单级页表中对庞大而稀疏的地址空间进行全映射的浪费。尽管在TLB未命中时,多级页表需要更多次数的内存访问,但TLB的高命中率有效缓解了这一开销。

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

在获得物理地址(PA)后,MMU 会将其发送至 L1 高速缓存以查询数据。缓存子系统首先从物理地址中解析出三个关键字段:缓存偏移(CO,用于定位缓存行内的具体数据)、缓存组索引(CI,用于确定目标缓存组)以及缓存标记(CT,用于标识缓存行对应的物理地址高位部分)。

随后,缓存控制器使用 CI 定位到对应的缓存组。在常见的组相联映射结构中(例如每组 8 路),控制器会并行地将 CT 与该组内所有缓存行的标记进行比较 。如果某一路的标记与 CT 匹配,并且该缓存行的有效位为 1,则表明发生了一次缓存命中。此时,系统再结合 CO 从命中的缓存行中提取出目标数据,经由 MMU 返回给 CPU 。

若未找到匹配的标记(即缓存未命中),查询请求会依序向更低层级的存储结构传递,遵循 L1 → L2 → L3 → 主存的访问顺序 。一旦在某一级(如主存)找到所需数据块,该块会被载入到最初发起查询的 L1 缓存中。放置时,若目标组内存在空闲块,则直接存入;若组内所有块均已被占用,则发生冲突,此时通常采用 LRU(最近最少使用)等替换算法决定淘汰哪个旧块以腾出空间 。

7.6 hello进程fork时的内存映射

当父进程调用 fork()函数时,内核会启动新进程的创建流程。首先,内核为子进程分配独立的内核数据结构,包括进程控制块(task_struct),并为其分配一个唯一的进程标识符(PID),以区别于父进程。

为构建子进程的虚拟内存空间,fork()会复制父进程的虚拟内存结构,包括 mm_struct、内存区域描述符(VMA)以及页表。此时,父子进程的页表项被设置为只读,且相关内存区域被标记为私有写时复制,从而在共享物理页面的同时为后续修改做好准备。

当 fork()调用返回时,子进程已具备与父进程在调用 fork()时完全一致的虚拟内存映像。此时,两者共享相同的物理内存内容。若任一进程尝试对共享页面进行写入操作,会触发写时复制机制:内核将为该进程分配新的物理页面,复制原页面内容,并更新其页表以映射到新页面。这样,修改仅在当前进程的私有空间内生效,从而确保进程间内存空间的隔离性与独立性。

7.7 hello进程execve时的内存映射

当调用 execve函数在当前进程中加载并运行可执行目标文件 hello时,该函数会使用 hello程序完全替换当前进程映像。此过程包含以下几个关键步骤:

1.清除现有用户空间结构​

首先,execve会删除当前进程虚拟地址空间用户部分中所有已存在的区域结构,释放原有程序占用的资源。

2.建立私有内存映射区域​

随后,execve为 hello程序的代码段(.text)、数据段(.data)、BSS 段(.bss)、栈和堆创建新的内存映射区域。这些区域均设置为私有且写时复制属性:

代码段与数据段:直接映射到 hello文件中对应的 .text和 .data节区。

BSS 段:映射到匿名文件(初始内容为二进制零),其大小由 hello文件定义但无实际存储内容。

栈与堆:初始长度为二进制零,并在进程运行时动态扩展。

3.映射共享库区域​

如果 hello程序链接了共享库(如 glibc),execve会将这些共享对象映射到用户虚拟地址空间的共享区域中,以便在运行时进行动态链接。

4.设置程序执行入口点​

最后,execve将当前进程上下文中的程序计数器设置为代码区域的入口地址(即 hello的 e_entry值)。当该进程再次被调度执行时,指令指针将指向此入口,从而启动 hello程序的执行。

在整个过程中,Linux 内核会根据需要动态地换入(page in)hello的代码和数据页面,确保程序在物理内存中有足够的支撑。

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

在虚拟内存系统中,当 CPU 试图访问一个尚未调入物理内存的虚拟页面时,会触发 缺页(Page Fault)。具体来说,若地址翻译硬件检查页表项(PTE)发现其有效位为 0,即表示该虚拟页未缓存在 DRAM 中,便会引发缺页异常,从而调用内核中相应的异常处理程序 。

处理程序首先选择一个物理页作为"牺牲页",图中存放 VP4 的 PP3。若该牺牲页的内容已被修改(脏页),则需将其写回磁盘;否则,直接覆盖即可 。随后,内核将所需虚拟页 VP3 从磁盘载入至空闲出的物理页框 PP3,并更新 PTE3 中的物理页号和有效位,建立映射关系 。

添加图片注释,不超过 140 字(可选)

图7.8.1更新前

完成页面置换与页表更新后,异常处理程序返回,系统重新执行原先引发缺页的指令。此时 VP3 已缓存在物理内存中,地址翻译可正常完成,CPU 能够顺利访问目标数据 。

添加图片注释,不超过 140 字(可选)

图7.8.2更新后

7.9动态存储分配管理

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

7.9.1 动态内存管理的基本方法

动态内存分配器负责管理进程虚拟地址空间中的堆区域。堆通常起始于未初始化数据段之后,并向高地址方向扩展。内核通过变量 brk记录堆的当前顶端。分配器将堆视为一系列内存块的集合,每个块要么处于已分配状态(供应用程序使用),要么处于空闲状态(可供分配)。

分配器主要分为两种类型:

显式分配器:要求程序主动释放不再需要的内存块。例如,C语言中的 malloc()用于分配内存,free()用于释放内存。

隐式分配器(垃圾收集器):由分配器自动检测并回收程序不再使用的内存块,无需程序员显式释放。

在许多实际场景中,例如 printf函数内部可能会调用 malloc来动态分配缓冲区。malloc函数返回一个指针,指向至少满足指定大小的内存块。当程序不再需要该内存时,必须调用 free函数进行释放,以避免内存泄漏。

7.9.2 动态内存管理的策略

程序使用动态内存分配的主要原因是只有在运行时才能确定数据结构的大小。例如,若需读取一个长度未知的整数序列到数组中,使用静态数组会限制最大容量,而动态分配则能根据实际需求灵活调整,其上限仅受虚拟内存大小限制。对于已编译的库函数(如 printf),修改其内部缓冲区大小的唯一方法就是通过动态内存管理。

为高效管理堆内存,分配器采用多种策略:

空闲块组织方式:包括隐式空闲链表、显式空闲链表或分离空闲链表等结构,以提升搜索效率。

放置策略:当分配请求到来时,分配器需在空闲链表中寻找合适的块。常见策略有:

首次适配:从头开始搜索,选择第一个足够大的空闲块。

最佳适配:搜索整个链表,选择满足需求的最小空闲块。

下一次适配:从上一次搜索结束的位置开始继续查找。

分割与合并:当找到的空闲块大于请求大小时,通常将其分割,剩余部分作为新的空闲块。释放内存时,合并相邻的空闲块可以形成更大的块,减少内存碎片。合并操作常利用边界标记来快速访问相邻块的状态信息。

7.10本章小结

本章深入剖析了hello程序的存储管理机制。从逻辑地址、线性地址、虚拟地址到物理地址的转换过程,揭示了MMU在硬件层面的地址翻译作用。通过分析段式管理、页式管理、TLB、多级Cache以及fork和execve过程中的内存映射策略,阐明了操作系统如何通过虚拟内存机制为进程提供独立的地址空间抽象,并高效地利用物理资源。缺页中断处理体现了按需调页的精髓,而动态内存分配则展示了程序运行时如何灵活管理堆空间。这些机制共同构成了现代计算机系统复杂而精巧的存储管理体系。

(第7章 2分)


第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

在 Linux 系统中,所有输入/输出设备(如磁盘、键盘、显示器和网络接口)均被统一模型化为文件,并通常组织在 /dev目录下。这种"一切皆文件"的设计理念,使得应用程序可以像操作普通文件一样,通过相同的系统调用接口与各类硬件设备交互。

内核通过 Unix I/O 接口(也称为"系统级 I/O")为这种统一访问提供支持。该接口定义了一组简洁、低级的原子操作,主要包括:

open():打开设备文件,返回文件描述符。

read():从设备读取数据。

write():向设备写入数据。

lseek():调整读写位置(如适用)。

close():关闭设备文件,释放资源。

设备文件通过主设备号和次设备号进行标识。主设备号对应特定的设备驱动程序,次设备号则用于区分由同一驱动程序管理的不同设备实例。在 Linux 中,设备通常被划分为字符设备(以字节流方式访问,如终端)和块设备(以数据块为单位访问,如硬盘)。

这种高度抽象的 I/O 管理方法,核心优势在于其统一性。开发者无需为每种设备编写特定的交互逻辑,只需掌握一套基于文件描述符的操作方式,即可处理绝大多数 I/O 场景。这种设计不仅降低了开发与学习的复杂度,也增强了程序在不同设备间的可移植性。所有的 I/O 操作最终由内核中的设备驱动层和虚拟文件系统(VFS)层具体实现,从而对用户态程序屏蔽了底层硬件的差异。

8.2 简述Unix IO接口及其函数

在 Linux 系统中,所有输入/输出设备(如磁盘、键盘和显示器等)均被统一模型化为文件进行处理。这种"一切皆文件"的设计理念使系统能够通过一套一致的接口------Unix I/O------来管理所有I/O操作。该接口由内核提供,具有简洁、低级的特性,为应用程序提供了访问硬件设备的统一方式。

8.2.1 Unix I/O 接口的核心概念

Unix I/O 的核心机制围绕文件描述符展开,其工作流程包含以下关键环节:

打开文件

应用程序通过 open函数请求内核打开某个文件(或设备),内核会创建一个内部数据结构记录该文件的访问信息,并返回一个非负整数作为文件描述符。该描述符在后续所有操作中唯一标识已打开的文件。

预定义的标准流

每个进程启动时都会自动打开三个标准文件流,它们对应固定的描述符:

标准输入(STDIN_FILENO):描述符为 0

标准输出(STDOUT_FILENO):描述符为 1

标准错误(STDERR_FILENO):描述符为 2

这些常量定义在 <unistd.h>头文件中,建议使用常量名而非直接使用数字。

文件位置与定位操作

内核为每个打开的文件维护一个当前文件位置(初始值为 0,即文件起始处),该位置表示下一次读写操作的字节偏移。应用程序可通过 lseek函数显式调整此位置。

读写操作

读操作:从文件的当前位置开始,将指定数量的字节读入内存缓冲区,并相应增加文件位置。若当前位置已超过文件末尾,则读操作返回 0,表示遇到文件结束条件(EOF,文件尾并无特殊终止符)。

写操作:将内存缓冲区中的数据写入文件,从当前位置开始,并更新文件位置。

关闭文件

当文件不再需要访问时,应用程序应调用 close函数关闭文件描述符。内核会释放相关的内部数据结构,并将该描述符回收至可用描述符池。即使进程未显式关闭文件,在其终止时内核也会自动关闭所有已打开的文件。

8.2.2 Unix I/O 接口函数详解

函数 原型 功能描述 返回值与关键参数
打开文件​ int open(const char *filename, int flags, mode_t mode); 打开或创建文件,返回文件描述符。flags指定访问模式,mode设置新文件的权限。 返回值:成功返回最小未用描述符,出错返回 -1。关键 flags:O_RDONLY(只读)、O_WRONLY(只写)、O_RDWR(读写)、O_CREAT(不存在则创建)、O_TRUNC(存在则截断)、O_APPEND(追加模式)。
关闭文件​ int close(int fd); 关闭指定描述符 fd对应的文件,释放内核资源。 返回值:成功返回 0,出错返回 -1。
读文件 ssize_t read(int fd, void *buf, size_t n); 从描述符 fd的当前文件位置读取最多 n字节到缓冲区 buf。 返回值:成功返回读取的字节数;0表示 EOF;出错返回 -1。
写文件 ​ssize_t write(int fd, const void *buf, size_t n); 将缓冲区 buf中的 n字节写入描述符 fd的当前文件位置。 返回值:成功返回写入的字节数;出错返回 -1。

8.3 printf的实现分析

https://www.cnblogs.com/pianist/p/3315801.html

从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.

字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

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

以下是printf函数的函数体。

int printf(const char *fmt, ...) {

int i;

char buf[256];

va_list arg = (va_list)((char*)(&fmt) + 4);

i = vsprintf(buf, fmt, arg);

write(buf, i);

return i; }

printf 函数的参数列表包含一个固定参数和一个可变参数部分:其第一个参数是 const char*类型的格式字符串 fmt,用于指定输出格式;后续参数以省略号(...)表示,允许传入数量不定的实际参数。由于函数在编译时无法预知调用时传入的可变参数数量,因此必须在运行时依据格式字符串中的格式说明符(如 %d、%s 等)来动态解析并确定参数的个数与类型。

这种解析机制依赖于标准库中的 va_list系列宏(如 va_start, va_arg, va_end)来实现对可变参数的访问。printf 函数会依次读取格式字符串中的每个字符,当遇到格式占位符时,便按对应的类型从可变参数列表中提取参数值。这也意味着,格式字符串必须与后续可变参数的类型和数量严格匹配,否则会导致未定义行为。

va_list arg = (va_list)((char*)(&fmt) + 4);这行代码的作用是手动构造一个 va_list类型的指针 arg,使其指向函数可变参数列表(即 ...部分)中的第一个参数。

其原理基于C语言函数调用时的一个关键约定:参数是从右向左依次压入栈空间的。当调用类似 printf(const char* format, ...)的函数时,最右边的参数最先入栈,最左边的参数(在这里是固定参数 fmt)最后入栈。在常见的x86系统架构下,栈内存的生长方向是从高地址向低地址。因此,最后入栈的固定参数 fmt在栈中处于相对较高的地址。通过取得 fmt的地址 &fmt,然后将其转换为 char*类型(便于进行字节级别的地址计算),再加上一个偏移量(例如代码中的 4,这个值通常与系统字长和编译器的调用约定相关,目的是跳过固定参数 fmt在栈中所占的空间),最终得到的地址就指向了第一个可变参数在栈中的位置。

接下来的 i = vsprintf(buf, fmt, arg);是调用 vsprintf函数的核心步骤。这个函数的作用是,根据格式字符串 fmt所指定的规则,以及从 arg所指向的地址开始解析的可变参数列表,将格式化后的结果字符串写入到缓冲区 buf中。函数会返回写入缓冲区的字符数量,这个值通常被赋给变量 i以供后续使用。vsprintf是实现 printf系列函数功能的关键底层例程

int vsprintf(char *buf, const char *fmt, va_list args) {

char* p;

char tmp[256];

va_list p_next_arg = args;

for (p=buf;*fmt;fmt++)

{

if (*fmt != '%')

{

*p++ = *fmt;

continue;

}

fmt++;

switch (*fmt)

{

case 'x':

itoa(tmp, *((int*)p_next_arg));

strcpy(p, tmp);

p_next_arg += 4;

p += strlen(tmp);

break;

case 's':

break;

default:

break;

}

} return (p - buf); }

vsprintf返回的是要打印的字符串的长度。

Write这句是写操作,就是传入buf与参数数量i,将buf中的i个元素写到终端。

write:

mov eax, _NR_write

mov ebx, [esp + 4]

mov ecx, [esp + 8]

int INT_VECTOR_SYS_CALL

传参,然后int INT_VECTOR_SYS_CALL结束。

INT_VECTOR_SYS_CALL的实现:

init_idt_desc(INT_VECTOR_SYS_CALL,DA_386IGate, sys_call, PRIVILEGE_USER);

sys_call的实现:

sys_call:

call save

push dword [p_proc_ready]

sti

push ecx

push ebx

call [sys_call_table + eax * 4]

add esp, 4 * 3

mov [esi + EAXREG - P_STACKBASE], eax

cli

ret

在这里,sys_call实现了显示格式化了的字符串,也就是ASCII到字模库到显示vram的信息。

8.4 getchar的实现分析

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

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

在计算机系统中,键盘输入是一个典型的异步异常处理过程,涉及从硬件中断到应用层函数调用的完整链路。以下将系统梳理键盘中断的处理机制,并阐明其与 getchar等标准I/O函数的关系。

1.键盘中断的底层处理流程

当用户按下或释放键盘上的某个键时,会触发一个键盘中断(通常为 IRQ1)。该中断的处理流程如下:

扫描码生成与传递:键盘控制器将按键动作转换为扫描码并存入其输出缓冲区,随后向CPU的中断控制器发出中断请求。

中断服务程序(ISR):CPU响应中断后,会执行预设的键盘中断服务程序。该程序的核心任务是从指定端口(如 0x60)读取扫描码。

扫描码到ASCII码的转换:中断服务程序通常通过查表(如 key_map)等方式,将扫描码转换为对应的ASCII码(或系统使用的其他字符编码)。

存入系统键盘缓冲区:转换得到的字符码会被存入系统级的键盘缓冲区中。这个缓冲区由操作系统内核维护,用于临时存储尚未被应用程序读取的输入字符。

2.getchar() 的工作原理

getchar()是C语言标准库提供的函数,用于从标准输入(stdin)读取一个字符。其行为与上述键盘中断处理流程紧密衔接,具体实现如下:

宏定义与缓冲机制:getchar()通常通过宏定义为 getc(stdin)。这意味着它依赖于标准I/O库管理的输入缓冲区。

等待与读取:当程序调用 getchar()时,并非直接与硬件交互。如果标准I/O缓冲区为空,则程序会等待。一旦用户按下回车键,此前输入的一行字符(包括回车符本身)会被送入缓冲区,getchar()则从缓冲区中读取第一个字符并返回其ASCII码[intellectual property 1]。

缓冲区内字符的消耗:如果用户在按回车前输入了多个字符,后续的 getchar()调用会依次从缓冲区中读取剩余的字符,而不会再次等待用户按键,直到缓冲区为空。

3.连接底层中断与上层应用

getchar()等标准库函数通过系统调用接口(例如 read)与操作系统内核交互。当这些函数需要数据时,内核会从系统键盘缓冲区中取出字符,再传递给用户空间的应用程序。因此,可以将 getchar()的工作流程视为对底层键盘中断所产生结果的消费过程。

8.5本章小结

本章系统地阐述了 Linux 的 I/O 系统。其核心在于 "一切皆文件"​ 的抽象,通过 VFS​ 和 Unix I/O​ 接口为应用程序提供了统一、简洁的设备访问方式。通过分析 printf和 getchar的实现,我们深入理解了从用户态库函数到系统调用,再到内核中断处理和设备驱动的完整I/O路径。整个过程涉及虚拟地址与物理地址的转换、系统调用陷入内核、硬件中断处理、多级缓冲技术(如标准I/O缓冲区、内核缓冲区)等底层机制,充分体现了操作系统管理硬件资源、为应用程序提供服务的核心价值。

(第8章 1分)

结论

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

你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。

本报告以简单的 hello.c程序为线索,系统地追溯和剖析了一个程序在计算机系统中从静态代码到动态进程,直至最终消亡的完整生命周期。通过这个过程,我们得以用计算机系统的语言,清晰地总结 hello所经历的关键阶段:

1.从源码到可执行文件(P2P: Program to Process):

预处理:cpp预处理器根据 #指令对 hello.c进行宏替换、头文件包含和条件编译,生成纯 C 代码 hello.i。

编译:ccl编译器对 hello.i进行词法、语法分析、优化,生成针对特定架构(x86-64)的汇编代码 hello.s。

汇编:as汇编器将 hello.s中的助记符翻译成机器指令,生成可重定位目标文件 hello.o。此时,外部函数(如 printf)的地址尚未解析。

链接:ld链接器将 hello.o与所需的标准库(如 libc.so)合并,进行符号解析和地址重定位,生成最终的可执行目标文件 hello。此过程解决了外部符号的地址问题,并安排了程序在虚拟内存空间中的布局。

从进程创建到执行(O2O: Operation to Operation):

进程创建:在 Shell 中输入 ./hello后,Shell 调用 fork()系统调用,创建了一个与自身几乎完全相同的子进程。该过程运用了写时复制技术以提升效率。

程序加载与执行:子进程通过 execve()系统调用加载 hello程序。execve()清除原进程映像,根据 hello的 ELF 格式信息,为其设置新的代码段、数据段、堆、栈以及共享库映射,并跳转到程序入口点 _start,最终调用 main函数。

内存管理与指令执行:CPU 从 main函数开始取指、译码、执行。在访问指令和数据时,内存管理单元通过页式管理机制,将虚拟地址转换为物理地址。转换过程中,TLB 充当页表的高速缓存以加速地址翻译。若所需页面不在物理内存中,则触发缺页异常,由操作系统负责将页面从磁盘调入。

I/O 操作:当执行到 printf时,该函数内部调用 write系统调用,陷入内核态,由内核的驱动程序最终将字符信息写入控制台或终端模拟器。当执行到 getchar时,则通过 read系统调用,等待并获取来自键盘中断服务程序存入缓冲区的输入字符。

进程终止与资源回收:

当 main函数返回或进程被信号终止后,进程生命结束。操作系统内核会回收其占用的所有资源,包括内存空间、打开的文件描述符、进程描述符等。同时,内核会向其父进程发送 SIGCHLD信号。

2.深切感悟与创新理念

通过将 hello程序"庖丁解牛"般的分析,我对计算机系统的设计与实现有了更深刻的感悟:

精妙的层次化抽象:计算机系统最伟大的力量在于其层层叠叠的抽象。从高级语言到汇编指令,从虚拟地址空间到物理内存,从文件抽象到硬件设备,每一层都隐藏了下层的复杂性,并为上层提供了简洁、统一的接口。这种设计使得开发者可以在不同层次上高效工作,而无需关心底层所有细节。hello的执行过程完美诠释了这一点:程序员只需关心 printf的逻辑,而无需了解其背后复杂的系统调用、地址转换和设备驱动。

软硬件协同的深度整合:系统的效率与功能并非仅由软件或硬件单独决定,而是二者紧密协作的结果。例如,地址翻译由 MMU 硬件加速,但其依赖的页表由操作系统软件维护;异常和中断处理由硬件触发,但处理程序由操作系统提供。这种协同设计是计算机系统高性能的基石。

资源管理的权衡艺术:系统设计处处体现着权衡。写时复制在 fork()中牺牲了少量初始性能换取了巨大的内存节省;多级页表用多次访存的潜在开销换取了稀疏地址空间下的内存效率;TLB 和 Cache 用硬件成本换取了对速度和效率的极致追求。理解这些权衡是理解系统设计的关键。

3.创新理念:在深入理解上述机制的基础上,一个可能的创新方向是设计更智能、可预测的混合内存管理策略。例如,对于类似 hello这样的短生命周期程序,是否可以由链接器或加载器进行静态分析,向内核提供"提示",标明其可能的内存访问模式?内核可据此进行更积极的预取或更合适的页面放置策略,甚至为特定类型的进程预留专用的内存池或缓存策略,从而减少缺页中断,进一步提升系统整体响应速度。这需要编译器、链接器、加载器和操作系统内核更紧密的协作,体现了对系统资源进行全栈式协同优化的思想。

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


附件

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

Hello.c 源程序

Hello 用于调试程序

Hello.i​展开头文件、宏替换、处理条件编译

Hello.o 将汇编指令翻译成可直接被CPU识别的机器指令

Hello.s 将C代码翻译成机器指令的文本描述

Hello1 采用链接生成的调试程序

Hello.asm 用反汇编查看hello.o的内容

Hello1.asm 用于查看hello1的内容

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

Hello1.elf 用readelf读取hello1得到的ELF格式信息

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


参考文献

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

1\] [(50 封私信 / 80 条消息) 玩转操作系统---------中断和异常处理 - 知乎](https://zhuanlan.zhihu.com/p/712669716 "(50 封私信 / 80 条消息) 玩转操作系统———中断和异常处理 - 知乎") \[2\] [中断处理过程示意图_聊聊什么是中断机制?-CSDN博客](https://blog.csdn.net/weixin_35792271/article/details/112228863 "中断处理过程示意图_聊聊什么是中断机制?-CSDN博客") \[3\] [\[转\]printf 函数实现的深入剖析 - Pianistx - 博客园](https://www.cnblogs.com/pianist/p/3315801.html "[转]printf 函数实现的深入剖析 - Pianistx - 博客园") \[4\] [1、反汇编入门指南-CSDN博客](https://blog.csdn.net/d3e4f/article/details/151271351 "1、反汇编入门指南-CSDN博客") \[5\] [(50 封私信 / 80 条消息) GDB调试C/C++程序(快速入门,新手必看) - 知乎](https://zhuanlan.zhihu.com/p/1926246054858330693 "(50 封私信 / 80 条消息) GDB调试C/C++程序(快速入门,新手必看) - 知乎") \[6\] [进程(Process)详解-CSDN博客](https://blog.csdn.net/m0_53415522/article/details/147465806 "进程(Process)详解-CSDN博客") \[7\] [(50 封私信 / 80 条消息) ELF文件装载和符号表解析 - 知乎](https://zhuanlan.zhihu.com/p/2666034609 "(50 封私信 / 80 条消息) ELF文件装载和符号表解析 - 知乎") (参考文献0分,缺失 -1分)同的子进程。该过程运用了写时复制技术以提升效率。 程序加载与执行:子进程通过 execve()系统调用加载 hello程序。execve()清除原进程映像,根据 hello的 ELF 格式信息,为其设置新的代码段、数据段、堆、栈以及共享库映射,并跳转到程序入口点 _start,最终调用 main函数。 内存管理与指令执行:CPU 从 main函数开始取指、译码、执行。在访问指令和数据时,内存管理单元通过页式管理机制,将虚拟地址转换为物理地址。转换过程中,TLB 充当页表的高速缓存以加速地址翻译。若所需页面不在物理内存中,则触发缺页异常,由操作系统负责将页面从磁盘调入。 I/O 操作:当执行到 printf时,该函数内部调用 write系统调用,陷入内核态,由内核的驱动程序最终将字符信息写入控制台或终端模拟器。当执行到 getchar时,则通过 read系统调用,等待并获取来自键盘中断服务程序存入缓冲区的输入字符。 进程终止与资源回收: 当 main函数返回或进程被信号终止后,进程生命结束。操作系统内核会回收其占用的所有资源,包括内存空间、打开的文件描述符、进程描述符等。同时,内核会向其父进程发送 SIGCHLD信号。 2.深切感悟与创新理念 通过将 hello程序"庖丁解牛"般的分析,我对计算机系统的设计与实现有了更深刻的感悟: 精妙的层次化抽象:计算机系统最伟大的力量在于其层层叠叠的抽象。从高级语言到汇编指令,从虚拟地址空间到物理内存,从文件抽象到硬件设备,每一层都隐藏了下层的复杂性,并为上层提供了简洁、统一的接口。这种设计使得开发者可以在不同层次上高效工作,而无需关心底层所有细节。hello的执行过程完美诠释了这一点:程序员只需关心 printf的逻辑,而无需了解其背后复杂的系统调用、地址转换和设备驱动。 软硬件协同的深度整合:系统的效率与功能并非仅由软件或硬件单独决定,而是二者紧密协作的结果。例如,地址翻译由 MMU 硬件加速,但其依赖的页表由操作系统软件维护;异常和中断处理由硬件触发,但处理程序由操作系统提供。这种协同设计是计算机系统高性能的基石。 资源管理的权衡艺术:系统设计处处体现着权衡。写时复制在 fork()中牺牲了少量初始性能换取了巨大的内存节省;多级页表用多次访存的潜在开销换取了稀疏地址空间下的内存效率;TLB 和 Cache 用硬件成本换取了对速度和效率的极致追求。理解这些权衡是理解系统设计的关键。 3.创新理念:在深入理解上述机制的基础上,一个可能的创新方向是设计更智能、可预测的混合内存管理策略。例如,对于类似 hello这样的短生命周期程序,是否可以由链接器或加载器进行静态分析,向内核提供"提示",标明其可能的内存访问模式?内核可据此进行更积极的预取或更合适的页面放置策略,甚至为特定类型的进程预留专用的内存池或缓存策略,从而减少缺页中断,进一步提升系统整体响应速度。这需要编译器、链接器、加载器和操作系统内核更紧密的协作,体现了对系统资源进行全栈式协同优化的思想。 (结论0分,缺失-1分) *** ** * ** *** 附件 列出所有的中间产物的文件名,并予以说明起作用。 Hello.c 源程序 Hello 用于调试程序 Hello.i​展开头文件、宏替换、处理条件编译 Hello.o 将汇编指令翻译成可直接被CPU识别的机器指令 Hello.s 将C代码翻译成机器指令的文本描述 Hello1 采用链接生成的调试程序 Hello.asm 用反汇编查看hello.o的内容 Hello1.asm 用于查看hello1的内容 Hello.elf 用readelf读取hello.o得到的ELF格式信息 Hello1.elf 用readelf读取hello1得到的ELF格式信息 (附件0分,缺失 -1分) *** ** * ** *** 参考文献 为完成本次大作业你翻阅的书籍与网站等 \[1\] [(50 封私信 / 80 条消息) 玩转操作系统---------中断和异常处理 - 知乎](https://zhuanlan.zhihu.com/p/712669716 "(50 封私信 / 80 条消息) 玩转操作系统———中断和异常处理 - 知乎") \[2\] [中断处理过程示意图_聊聊什么是中断机制?-CSDN博客](https://blog.csdn.net/weixin_35792271/article/details/112228863 "中断处理过程示意图_聊聊什么是中断机制?-CSDN博客") \[3\] [\[转\]printf 函数实现的深入剖析 - Pianistx - 博客园](https://www.cnblogs.com/pianist/p/3315801.html "[转]printf 函数实现的深入剖析 - Pianistx - 博客园") \[4\] [1、反汇编入门指南-CSDN博客](https://blog.csdn.net/d3e4f/article/details/151271351 "1、反汇编入门指南-CSDN博客") \[5\] [(50 封私信 / 80 条消息) GDB调试C/C++程序(快速入门,新手必看) - 知乎](https://zhuanlan.zhihu.com/p/1926246054858330693 "(50 封私信 / 80 条消息) GDB调试C/C++程序(快速入门,新手必看) - 知乎") \[6\] [进程(Process)详解-CSDN博客](https://blog.csdn.net/m0_53415522/article/details/147465806 "进程(Process)详解-CSDN博客") \[7\] [(50 封私信 / 80 条消息) ELF文件装载和符号表解析 - 知乎](https://zhuanlan.zhihu.com/p/2666034609 "(50 封私信 / 80 条消息) ELF文件装载和符号表解析 - 知乎") (参考文献0分,缺失 -1分)

相关推荐
初中就开始混世的大魔王1 小时前
2 Fast DDS Library概述
c++·中间件·信息与通信
娇娇yyyyyy2 小时前
C++基础(6):extern解决重定义问题
c++
Neteen3 小时前
【数据结构-思维导图】第二章:线性表
数据结构·c++·算法
灰色小旋风3 小时前
力扣——第7题(C++)
c++·算法·leetcode
Ralph_Y4 小时前
C++网络:一
开发语言·网络·c++
程序猿编码4 小时前
探秘 SSL/TLS 服务密码套件检测:原理、实现与核心设计(C/C++代码实现)
c语言·网络·c++·ssl·密码套件
故事和你914 小时前
sdut-程序设计基础Ⅰ-实验二选择结构(1-8)
大数据·开发语言·数据结构·c++·算法·优化·编译原理
像素猎人5 小时前
数据结构之顺序表的插入+删除+查找+修改操作【主函数一步一输出,代码更加清晰直观】
数据结构·c++·算法
蜡笔小马5 小时前
32.Boost.Geometry 空间索引:R-Tree 接口详解
c++·boost·r-tree