【HITCSAPP 哈工大计算机系统期末大作业】 程序人生-Hello’s P2P

计算机系统

大作业

题     目  程序人生-Hello’s P2P 

专       业   计算机与电子通信类    

学     号        2023112915      

班     级        23L0505          

学       生        杨昕彦           

指 导 教 师         刘宏伟            

计算机科学与技术学院

2024年5月

摘  要

本文详细描述了“Hello”程序从源代码到可执行文件的全生命周期,涵盖了预处理、编译、汇编、链接、运行及回收的各个阶段。通过分析每个阶段的具体操作和工具,深入探讨了计算机系统的工作原理。文章首先介绍了预处理阶段如何展开宏和头文件,生成中间文件hello.i;接着阐述了编译阶段将hello.i转换为汇编代码hello.s的过程;随后讨论了汇编阶段将hello.s汇编成目标文件hello.o的步骤;最后讲解了链接阶段如何将hello.o与库文件合并生成可执行文件hello。在运行阶段,文章详细描述了Shell如何通过fork和execve创建并执行hello进程,并探讨了虚拟地址到物理地址的转换机制。此外,文章还分析了printf和getchar等函数的实现原理,揭示了Linux I/O设备管理的核心思想。通过这一系列步骤,文章展示了计算机系统从源代码到硬件执行的复杂流程,强调了理论与实践结合的重要性,并总结了学习计算机系统的深刻体会。

关键词:Hello;CSAPP;P2P;Linux; VM;I/O;Shell;Cache;Ubuntu;进程;

目  录

目录

第1章 概述

1.1 Hello简介

1.1.1 P2P

1.1.2 020

1.2 环境与工具

1.2.1 硬件环境

1.2.2 软件环境

1.2.3 开发工具

1.3 中间结果

1.4 本章小结

第2章 预处理

2.1 预处理的概念与作用

2.1.1 预处理的概念

2.1.2 预处理的作用

2.2在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

2.4 本章小结

第3章 编译

3.1 编译的概念与作用

3.1.1 编译的概念

3.1.2 编译的作用

3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.3.1 数据

3.3.1.1 常量

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

3.3.1.2 表达式

3.3.1.4 类型

3.3.1.5 宏

3.3.2 赋值

3.3.3 算术操作

3.3.4 关系操作

3.3.5 数组/指针/结构操作

3.3.6 控制转移

3.3.7函数操作

3.3.7.1参数传递(地址/值)

3.3.7.2函数调用

3.3.7.3函数返回

3.4 本章小结

第4章 汇编

4.1 汇编的概念与作用

4.1.1 汇编的概念

4.1.2 汇编的作用

4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

    4.3.1文件头

    4.3.2程序头表

    4.3.2节表

4.4 Hello.o的结果解析

4.4.1 hello.o反汇编与hello.s的比较

4.4.2 机器语言的构成

4.4.3 与汇编语言的映射关系

4.5 本章小结

第5章 链接

5.1 链接的概念与作用

5.1.1 链接的概念

5.1.2 链接的作用

5.2 在Ubuntu下链接的命令

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

    5.3.1 ELF头信息

    5.3.2节头

    5.3.3程序头

    5.3.4符号表

    5.3.5重定位节

5.4 hello的虚拟地址空间

5.5 链接的重定位过程分析

5.6 hello的执行流程

5.7 Hello的动态链接分析

5.8 本章小结

第6章 hello进程管理

6.1 进程的概念与作用

6.1.1 进程的概念

6.1.2 进程的作用

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

6.3 Hello的fork进程创建过程

6.4 Hello的execve过程

6.5 Hello的进程执行

6.6 hello的异常与信号处理

6.6.1 异常的类型

6.6.2 异常的处理方式

6.7本章小结

第7章 hello的存储管理

7.1 hello的存储器地址空间

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

    7.2.1段描述与段选择符

   7.2.2逻辑地址到线性地址的转换过程

7.2.3段寄存器与描述符缓存

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

7.3.1虚拟内存的组织结构

7.3.2页表与地址映射

7.3.2缺页异常处理

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

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

7.6 hello进程fork时的内存映射

7.6.1 fork()时的内存映射

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

7.7 hello进程execve时的内存映射

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

7.9动态存储分配管理

7.10本章小结

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

8.2 简述Unix IO接口及其函数

8.3 printf的实现分析

8.4 getchar的实现分析

8.5本章小结

结论

附件

参考文献


第1章 概述

1.1 Hello简介

1.1.1 P2P

P2P,即“从程序到进程”(From Program to Process),指的就是将人可读的源程序转变为操作系统可调度执行的进程的全过程。以hello为例,其生命周期始于一份用 C 语言编写、易于理解的源代码文件 hello.c,随后由 GCC 编译驱动程序接管,将其翻译成可执行文件 hello。整个翻译过程分为四个阶段:首先,预处理器 cpp 对 hello.c 进行预处理,生成含有展开宏和头文件内容的中间文件 hello.i;接着,编译器前端 cc1 将 hello.i 转换为汇编代码 hello.s;然后,汇编器 as 将 hello.s 汇编成目标文件 hello.o;最后,链接器 ld 将 hello.o 与所引用的标准库或其他库文件进行链接,输出最终的可执行文件 hello。生成可执行文件后,当在 Shell 中运行时,系统首先通过 fork() 创建一个子进程,再通过 execve() 将 hello 的代码和数据加载到该子进程的内存空间中,至此,一个新的进程便诞生了。

图1 编译系统

1.1.2 020

020,即From Zero-0 to Zero-0,描述了程序从加载到结束的完整生命周期。当用户在终端输入 ./hello 时,Shell 先调用 fork() 创建子进程,子进程再通过 execve() 将可执行文件映入进程虚拟地址空间,并借助 mmap 将必要的代码和数据从存储介质载入物理内存,随后交由 CPU 调度执行。CPU 为该进程分配时间片,依次进行取指、译码、执行等流水线操作;在此过程中,内存管理单元通过多级页表和 TLB 进行地址转换,并利用 L1、L2、L3 缓存加速数据访问;I/O 子系统则根据程序指令完成外部设备的读写输出。待程序执行结束后,子进程将进入僵尸状态,由父进程调用 wait() 或类似机制回收,内核随即释放其虚拟内存空间并清除相关进程表项,从而完成程序从zero到zero的过程。

1.2 环境与工具

1.2.1 硬件环境

X64 CPU;2GHz;2G RAM;256GHD Disk 以上

1.2.2 软件环境

Windows7/10 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位 以上

1.2.3 开发工具

Visual Studio 2010 64位以上;CodeBlocks 64位;vi/vim/gedit+gcc;edb

1.3 中间结果

文件的名字

文件的作用

hello.c

源程序文件

hello.i

hello.c通过预处理器cpp预处理后的文本文件

hello.s

hello.i通过编译器ccl编译后的汇编程序

hello.o

hello.s通过汇编器as汇编后的文件

hello

hello.o通过链接器ld链接后的可执行文件

1.4 本章小结

本章首先以 “Hello” 程序为例,阐述了从可读的 C 语言源代码到操作系统可调度进程的全流程:源文件通过 cpp、cc1、as 和 ld 四个阶段依次生成可执行文件,再由 Shell 调用 fork() 与 execve() 在内存中创建并运行进程;随后通过 mmap、多级页表、TLB 及 L1/L2/L3 缓存进行内存管理,并由 CPU 按时间片执行取指—译码—执行流水线,I/O 子系统负责外设读写;进程结束后由父进程 wait() 回收,内核释放资源,完成从“零”到“零”的闭环。此外,本章还介绍了硬件(X64、2 GHz、2 GB RAM、256 GB 硬盘及以上)、软件(Windows 7/10 64 位或 Ubuntu 16.04 LTS/优麒麟)、虚拟化(VirtualBox/VMware 11+)与开发工具(Visual Studio、Code::Blocks、GCC、edb 等)环境,并列出了 hello.c→hello.i→hello.s→hello.o→hello 及相关 ELF 和反汇编文件的中间产物及其作用。

第2章 预处理

2.1 预处理的概念与作用

2.1.1 预处理的概念

预处理是由预处理器(cpp)负责完成的源代码准备阶段,它会识别以 # 开头的指令并对 .c 文件进行改写与扩展。主要操作包括:

宏定义替换(define):将代码中出现的宏标识符替换为对应的文本或表达式;

文件包含(include):将被包含文件的内容嵌入到当前源文件中;

条件编译(ifdef、if 等):根据条件决定是否保留或丢弃特定代码段。

完成预处理后,生成的中间结果仍是一份合法的 C 语言源代码,通常以 .i 为扩展名,供后续编译阶段使用。

2.1.2 预处理的作用

预处理阶段通过解析以 # 开头的指令,对源代码进行初步转换和组织,为后续的编译、汇编和链接打下基础。其主要功能包括以下三方面:

  1. 宏定义替换
    允许程序员使用 define 定义符号常量或代码片断,在预处理时将这些宏标识符替换为对应的文本或表达式,从而减少重复代码、提升可维护性和可读性。
  2. 文件包含
    通过 include 指令将头文件或其他源文件的内容插入到当前文件中,这不仅能集中管理函数声明、数据结构和宏定义,还能实现模块化编程。
  3. 条件编译
    使用 if、ifdef、ifndef 等指令,根据编译环境或自定义宏的定义情况选择性地保留或剔除代码片段,以便同一份源代码在不同平台、不同配置下能够灵活编译。

总体而言,预处理使得程序在逻辑上更简洁、有条理,并增强了可移植性与调试效率,是现代 C/C++ 开发流程中不可或缺的第一步。

2.2在Ubuntu下预处理的命令

图2 cpp hello.c >hello.i

2.3 Hello的预处理结果解析

预处理阶段生成中间文件 hello.i,此时预处理器已根据源代码中 #include 指令在进入 main 函数之前,按顺序读取并展开了系统头文件 stdio.h、unistd.h 和 stdlib.h 的全部内容;若这些头文件内部仍包含以 # 开头的指令,预处理器同样会继续处理直至展开完毕。所有注释被剔除,宏定义在预处理完成后不再保留,最终得到一份标准且完整的 C 源代码,可直接供后续的编译阶段使用。

图3 预处理结果

2.4 本章小结

本节首先阐述了预处理在 C 语言编译流程中的概念与作用,然后在 Ubuntu 环境下使用gcc -E 命令对 hello.c 进行了预处理,生成了中间文件 hello.i。通过查看 hello.i,可以直观地看到:所有以 include 引入的系统头文件(如 stdio.h、unistd.h、stdlib.h)已按顺序展开插入;注释被清除;宏定义亦已展开且在结果文件中不再保留,从而形成一份可直接送入编译器的标准 C 源代码,有助于更深入地理解预处理阶段的核心功能与工作原理。

第3章 编译

3.1 编译的概念与作用

3.1.1 编译的概念

编译阶段由编译器(cc1)负责,将预处理后生成的 hello.i 文件作为输入,翻译成目标平台对应的汇编代码,并输出为 hello.s。该过程首先进行词法和语法分析,验证代码的正确性;接着在语义分析中检查类型与作用域;随后生成中间表示(IR),并对其进行必要的优化;最后将经过优化的 IR 转换为具体的汇编指令,形成包含 main 函数定义及所有函数实现的汇编程序。整个流程不仅完成了从高级语言到汇编语言的转换,还为后续的汇编和链接阶段提供了可读且高效的汇编源码。

3.1.2 编译的作用

编译阶段由编译器(cc1)接管,将预处理生成的 .i 文件翻译成目标平台对应的汇编代码(.s 文件),其主要功能包括以下几个方面:

  1. 语法与语义检查
    编译器首先对源代码进行词法分析和语法分析,确保程序符合语言规范;随后进行语义分析,检查类型一致性与作用域正确性,为生成正确的低级代码奠定基础。
  2. 中间表示与优化
    在分析通过后,代码会被转换为中间表示(IR),编译器可在此阶段对其进行多种优化,如常量折叠、循环展开等,以提升最终生成代码的执行效率。
  3. 汇编生成
    优化后的 IR 会被映射为具体的汇编指令,形成包含 main 函数定义及所有程序逻辑的汇编程序,便于后续的汇编和链接环节处理。

总体而言,编译不仅完成了将人类可读的高级语言转换为机器可执行的低级指令,还通过一系列检查和优化手段,保证了生成代码的正确性与高效性,同时为后续汇编和链接阶段提供了结构清晰、性能优良的汇编源文件。  

3.2 在Ubuntu下编译的命令

在Ubuntu中,hello.i文件进行编译的操作命令为:gcc -S hello.i -o hello.s

图4 编译命令

3.3 Hello的编译结果解析

3.3.1 数据

3.3.1.1 常量

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

图5 字符串常量

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

1)无全局与静态变量。

全局变量在汇编语言中通常存储在数据段(Data Segment)中。数据段是程序的一部分,用于存储全局变量和静态变量。全局变量在程序的整个生命周期内都存在,而不仅仅在特定的函数调用期间。需要注意的是,全局变量的修改和访问是在整个程序执行期间有效的,因此它可以被程序中的任何函数访问和修改。这使得全局变量在需要在不同部分之间共享数据时非常有用。由于hello.c中不含全局变量,这里就不详细描述了。

2)局部变量

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

-20(%rbp):该偏移量用于在栈帧中存放来自 main 函数的第一个参数 argc 的值,对应汇编指令 movl %edi, -20(%rbp)。

-32(%rbp):该偏移量用于在栈帧中存放来自 main 函数的第二个参数 argv 的地址,对应汇编指令 movq %rsi, -32(%rbp)。

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

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

图6 局部变量

函数调用结束后的清理: 在函数返回前,栈帧会被清理。这包括将栈指针恢复到原始的位置,以及弹出保存的帧指针值。这个过程确保了栈的一致性。

图7函数调用结束后的清理

3.3.1.2 表达式

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

图8 .c中的表达式argc!=5在.s文件

i<10表示为:

图9 .c中的表达式i<10在.s文件

3.3.1.4 类型

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

图10 不同的mov指令

3.3.1.5 宏

如果源代码中没有定义任何宏,那么在预处理阶段就不会进行宏替换操作。即使存在宏定义,所有的宏替换也都在预处理阶段完成,编译器不会再进行宏相关的处理。这意味着宏的作用仅限于预处理阶段,编译器处理的是已经展开了宏的代码。

3.3.2 赋值

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

图11 赋值不同的参数

赋值0给i

图12 赋值0给i

3.3.3 算术操作

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

图13 i++的完成方式

3.3.4 关系操作

编译器首先识别关系表达式中的关系运算符和操作数,并构建语法分析树或抽象语法树(AST)来表示表达式的结构。检查操作数的类型,确保它们与关系运算符兼容。根据C语言的运算符优先级和结合性规则,编译器确定关系表达式的求值顺序。

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

图14 i<10的完成方式

3.3.5 数组/指针/结构操作

        main接收的argv[]数组。

图14 main接收的argv[]数组

3.3.6 控制转移

编译器首先识别控制转移语句的语法结构,如if、else、switch、for、while等,检查控制转移语句的语义,确保条件表达式的类型是可比较的,并验证循环变量的初始化和更新表达式等。分析控制转移语句的控制流。

由于hello程序只涉及到了if和for,下面我们着重对这两个语法结构进行分析:

  1. if条件语句

if(argc!=4),被编译器转换为两行汇编语言,用指令cmpl和je来完成判定。

图15 if(argc!=4)

  1. for循环语句

for(i=0;i<8;i++),被编译器转换为两部分,分别是循环体L4和循环终止条件L3.

图16 for(i=0;i<8;i++)

3.3.7函数操作

3.3.7.1参数传递(地址/值)

在 C 语言中,函数参数的传递方式主要有两种:值传递和地址传递。在值传递中,实参的值被复制到形参中,函数内部对形参的修改不会影响实参。而在地址传递中,实参的地址被传递给形参,函数内部通过该地址可以直接修改实参的值。

在调用 main 函数时,操作系统会将命令行参数的个数(argc)和参数数组的地址(argv)传递给程序。在 x86-64 架构下,按照调用约定,前几个函数参数通过寄存器传递。具体来说,argc 会被传递到 %edi 寄存器,argv 的地址会被传递到 %rsi 寄存器。

在 main 函数的汇编代码中,可以看到以下指令:

图17 参数传递

这两条指令将寄存器中的值保存到栈帧中,以便在函数内部使用。其中,-20(%rbp) 用于存储 argc 的值,-32(%rbp) 用于存储 argv 的地址。

因此,参数的传递过程如下:操作系统将参数传递到指定的寄存器中,函数开始执行时将这些寄存器的值保存到栈帧中,供函数内部使用。这种机制确保了函数能够正确地接收到并处理传递给它的参数。

3.3.7.2函数调用

对于函数调用,编译器需要生成代码来传递参数、保存返回地址以及跳转到函数入口点。它还需要处理函数的返回,包括恢复调用点的上下文和获取返回值(如果有的话)。编译器将函数定义和调用转换为机器代码。通过call指令调用,如图,分别调用了头文件提供的printf, sleep, getchar函数。hello程序涉及到的函数包括main printf exit sleep getchar atoi等,其中的参数包含如下:

main函数包括argc和argv两个参数;

printf函数包括字符串参数"用法: Hello 学号 姓名 秒数!\n";

exit参数为0x1;

sleep参数为atoi(argv[3]);

atoi参数为argv[3];

getchar没有参数;

图18 函数调用

3.3.7.3函数返回

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

图19 函数返回

3.4 本章小结

本章深入探讨了 C 语言编译过程中的第二阶段——编译(Compilation),即将预处理后的 C 语言源文件(如 hello.i)转换为汇编语言代码(如 hello.s)的过程。通过在 Ubuntu 环境下使用 gcc -S hello.i -o hello.s 命令,生成了汇编文件 hello.s,并对其内容进行了详细分析。

在生成的汇编代码中,展示了变量的声明与初始化、表达式的计算、条件判断、函数调用以及循环控制等关键编译结果。例如,movl $0, -4(%rbp) 表示将整数 0 赋值给局部变量 i,cmpl $9, -4(%rbp) 用于比较变量 i 与常数 9 的大小,call printf@PLT 表示调用标准库函数 printf。这些汇编指令体现了高级语言结构在低级语言中的具体实现方式。

通过对 hello.s 文件的分析,进一步理解了编译器如何将高级语言的语法结构转换为汇编语言指令,为后续的汇编和链接阶段奠定了基础。这不仅加深了对编译过程的理解,也为学习程序的底层执行机制提供了实用的视角。

第4章 汇编

4.1 汇编的概念与作用

4.1.1 汇编的概念

汇编是将人可读的汇编语言文本(如 hello.s)交由汇编器(as)处理,翻译成对应的机器指令,并将生成的二进制代码按可重定位目标文件格式打包,最终输出一个不可直接打开的目标文件(如 hello.o)。这一过程完成了从汇编语言到机器语言的转换,使得上层编写的汇编程序能够被链接器进一步处理,最终组成可执行文件。

4.1.2 汇编的作用

  1. 机器指令翻译
    将上层编写的汇编代码转译成机器指令,使其在链接完成后生成的可执行文件能够被计算机硬件直接识别并运行。
  2. 底层控制
    汇编语言可直接操作计算机的核心硬件资源——包括 CPU 寄存器、内存地址、以及各类输入/输出设备等——因此极大地增强了对系统底层行为的掌控能力,适用于驱动程序、实时系统和嵌入式开发等场景。
  3. 性能优化
    由于无需经过高级语言的抽象层,汇编程序员能够精细地调度指令执行顺序、利用特定架构指令集、以及合理分配寄存器使用,从而最大限度地提升代码执行效率,实现对关键路径或性能瓶颈的深度优化。
  4. 最高效率的硬件执行
    机器语言是计算机能够直接执行的最低级语言,其指令无需解释或编译开销,完全由硬件电路驱动,因而在速度和资源利用率上都达到最优。

4.2 在Ubuntu下汇编的命令

在Ubuntu中,hello.s文件进行编译的操作命令为:gcc hello.s -c -o hello.o或者as -o hello.o hello.s

图20 gcc hello.s -c -o hello.o指令执行

4.3 可重定位目标elf格式

ELF 是 Unix 及类 Unix 系统中最常见的可执行文件与可重定位目标文件格式。一个标准的 ELF 文件由下图展示:

图21 可重定位目标文件格式

这种结构既支持最终生成可执行文件,也便于链接器将多个目标文件和库文件组合成完整程序。

    4.3.1文件头

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

命令:readelf -h hello.o

得到hello.o的ELF头

图22 hello.o的ELF头

    4.3.2程序头表

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

命令:readelf -l hello.o

图23 程序头表的查询

    4.3.2节表

节表记录了 ELF 文件中各个节(Section)的关键信息,包括节名称、节类型、在文件中的偏移地址、节大小以及其他属性等。ELF 文件中的代码和数据都被组织存放在不同的节中:例如,.text 节用于存放可执行代码,.data 节用于存放已初始化的全局和静态数据。通常,.text 与 .data 是在汇编源文件中显式声明的节,而诸如符号表节、字符串表节、重定位节等其他节,则由汇编器自动生成并插入到目标文件中,以便链接器和调试器后续使用。

命令:readelf -S hello.o

图24 readelf -S hello.o查询

之后用readelf -a hello.o探查 ELF 头部、程序头(若有)、节区表、重定位表、符号表、注释节等全部内容。以下为关键部分的摘录与解析:

图25 .rela.text的更详细信息

重定位节 .rela.eh_frame 记录了需要进行地址重定位的异常处理框架(exception handling frame)信息。其作用是调整异常处理框架(EH frame)中对代码地址的引用,保证运行时抛出或捕获异常时能正确定位。以下是查看 .rela.eh_frame 的输出:

图26 .rela.eh_frame

符号表 .symtab 是目标文件中非常重要的一部分,它列出目标文件中所有符号(函数名、全局变量、节引用等),对链接器分配最终地址及调试器符号解析至关重要。以下是符号表 .symtab 的具体内容和解释:

图27 .symtab的具体内容和解释

.note.gnu.property节包含平台或编译器特定属性注释,如 x86 架构的 IBT(Indirect Branch Tracking)与 SHSTK(Shadow Stack)支持标记,便于加载器或安全机制进行相应配置。

图28 .note.gnu.property的具体内容和解释

4.4 Hello.o的结果解析

4.4.1 hello.o反汇编与hello.s的比较

objdump -d -r hello.o

 

图29 hello.o的反汇编查看

对照分析:

在反汇编输出中,每行末尾的指令与原 hello.s 中的汇编代码保持一致,但在每条指令前,还会多出一串对应的十六进制机器码。与纯粹的汇编语言不同,汇编代码是一种面向人类、带有助记符的抽象表示;而从目标文件反汇编得到的内容,则完整地展示了机器语言——即计算机能够直接识别和执行的二进制编码。机器语言没有任何抽象层,完全由 0 和 1 组成,因而才能被处理器硬件所驱动。分支转移时,.s文件中会跳转到诸如.L3的代码段,像这样:

图30 hello.s的L3指令

而反汇编文件中,每条指令前的十六进制数表示该指令在地址空间(或文件)中的位置;而当指令本身是跳转(jmp、call 等)时,其操作数中包含的偏移量会被加到当前指令的地址上,计算出跳转目标的绝对地址,并在反汇编输出中一并显示:

图31 hello.o的反汇编代码

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

图32 汇编文件中的函数调用

在反汇编文件中,86: 表示受重定位条目影响的地址偏移量,是 call 指令中地址字段在指令流中的起始位置。
重定位表中的每个条目(Elf64_Rela 结构)记录了符号索引、类型和加数等信息,以便链接器根据这些数据修正目标文件中对应的地址域。
R_X86_64_PLT32 是重定位类型,表明对过程链接表(Procedure Linkage Table)的 32 位 PC 相对重定位,这种重定位用于支持运行时的延迟绑定调用。
在第一次调用时,PLT 条目会引导程序跳转到动态链接器,由其解析符号地址并写入全局偏移表(GOT),之后对同一函数的调用将直接透过 GOT 完成,从而加速后续调用性能。
在 readelf -r 或 objdump -r 的重定位输出中,可以看到这些条目详细列出偏移量(Offset)、Info 字段、重定位类型、符号名称和加数(Addend)等信息 Mindfruit
“atoi-0x4” 表示重定位目标符号为 atoi,加数为 -4,这正是因为 x86‑64 的 call 指令采用 PC 相对寻址方式,其位移是相对于下一条指令(P)的偏移数,因此需减去 4 字节来校正。
尽管名称为 R_X86_64_PLT32,链接器仍沿用 R_X86_64_PC32 的计算方法 S + A − P 来生成最终的重定位值,从而简化对 PC 相对分支的处理。
链接器在处理 R_X86_64_PLT32 重定位时,会根据公式 S + A − P (S 为符号值、A 为重定位加数、P 为调用指令下一地址)计算并填充至 call 指令的立即数域中,保证在运行时能跳转到正确的函数入口。
call 指令执行时,CPU 会将返回地址压入栈,并根据已填充的位移计算出目标地址后跳转,完成对目标函数(如 atoi)的调用。

图33 反汇编文件中的函数调用

4.4.2 机器语言的构成

x86‑64 指令采用可变长度编码,任何一条指令的长度介于 1 到 15 字节之间;例如操作数很少的 pop %rbx 仅需单字节操作码 0x5B,而更复杂或带更多操作数的指令则更长。指令格式由前缀(Legacy/REX)、操作码、ModR/M、SIB、可选位移和立即数等字段按固定顺序组成,CPU 从指令流起点逐字节地解析即可唯一识别出完整指令,无需回溯。若指令携带多字节常数或内存地址,这些字段必须按照小端序的方式依次编码,以保证加载时各宽度访问均能正确读取。

4.4.3 与汇编语言的映射关系

反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码;反汇编器使用的指令命名规则与GCC生成的汇编代码使用的有些细微的差别。

在函数调用和分支跳转时,二者也是有差别的。

1)函数调用:

函数调用在汇编语言中通常涉及到将参数压入栈中,然后跳转到函数入口点。函数返回时,通常会从栈中弹出返回值,并跳转到调用点之后的指令。

2)分支跳转:

在机器语言中,分支转移和函数调用通常涉及到特定的指令和操作数来表示跳转的目标地址。这些地址可能是绝对的,也可能是相对于当前指令或某个基准点的偏移量。

在汇编语言中,这些跳转目标通常使用标签来表示,如JMP label表示跳转到标签label处。在编译或汇编过程中,这些标签会被替换为实际的内存地址或偏移量。

4.5 本章小结

本章首先介绍了汇编阶段的基本流程:使用汇编器将汇编源文件(.s)转换为可重定位的目标文件(.o),这一过程仅完成从助记符到机器指令的翻译,而符号引用留待链接阶段处。
接着,通过 readelf -S hello.o 观察节区表,掌握了各节的名称、类型、在文件中的偏移与大小,以及标志属性等关键信息。
随后,使用 readelf -a hello.o 全面查看了 ELF 头部、重定位表(.rela.text、.rela.eh_frame)、符号表(.symtab)及注释节(.note.gnu.property)等,深入了解了链接器和动态加载所需的元数据。
在反汇编阶段,将目标文件中的二进制内容还原为汇编指令,并与原始汇编源对比,清晰地看到机器码(十六进制编码)是如何对应到汇编助记符的,进一步理解了指令执行逻辑与重定位机制。
通过本章学习,不仅掌握了从汇编程序到目标文件的完整流程,还能够借助 readelf 和反汇编工具分析二进制文件结构与机器代码执行逻辑,为后续的链接、调试和逆向工程奠定了坚实基础。

5章 链接

5.1 链接的概念与作用

5.1.1 链接的概念

链接是由链接器(ld)完成的一项关键步骤,其主要功能是将编译或汇编阶段产生的多个可重定位目标文件(.o)以及所需的库文件(静态库 .a 或动态库 .so)收集、符号解析并合并,生成可以被操作系统加载器装入内存的最终可执行文件或共享库。静态链接通常在编译时完成,将所有符号和代码打包进单一可执行文件;动态链接则可以在加载时或运行时延迟解析符号,常见于使用 dlopen/dlsym 的场景。
可通过链接实现将若干独立模块编译输出的.o文件合并为一个完整程序,也可将未定义符号留给运行时链接器再解析,因而链接机制既支持静态可执行,也支持共享库与动态加载。

5.1.2 链接的作用

链接将多个预编译好的目标文件整合为一个可执行文件,从而支持模块化的分离编译,使得大型应用不必写成单一庞大源程序,而是可以拆分为可独立修改、编译的功能模块。这样不仅大幅缩短了每次修改后重建的时间,提升了开发效率,还简化了代码组织与维护,增强了程序生成的灵活性和跨平台可移植性。链接器通过解析各目标文件间的符号引用,自动合并并解决模块间依赖,最终输出符合平台 ABI 规范的可执行映像)。

5.2 在Ubuntu下链接的命令

在Ubuntu中,hello.s文件进行编译的操作命令为:

ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o

图34 ld的操作过程

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

    5.3.1 ELF头信息

命令:readelf -h hello

得到hello的ELF头信息

图35 hello的ELF头信息

    5.3.2节头

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

命令:readelf -S hello

得到hello的节头部表

 

图36 hello的节头部表

    5.3.3程序头

命令:readelf -l hello

图37 hello的程序头表

    5.3.4符号表

命令:readelf -s hello

得到ELF符号表:

 

图38 hello的符号表

    5.3.5重定位节

命令:readelf -r hello

得到hello的重定位节

图39 hello的重定位节

5.4 hello的虚拟地址空间

命令:edb --run hello

使用edb加载hello

   图40 edb查看

可以看出,虚拟地址均从0x401000开始,从开始到结束这之间的每一个节对应5.3中的每一个节头表的声明。例如,在起始地址0x401000可与在ELF中看到对应:init。

  图41 hello的节头中的.init

  图42 edb查看

图43 0x401000

5.5 链接的重定位过程分析

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

命令:objdump -d -r hello

图44 得到hello的反汇编

hello:

图45 hello的反汇编

hello.o:

图46 hello.o的反汇编

hello:

图47 hello的反汇编

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

hello.o:

图48 hello.o的反汇编

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

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

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

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

图49 hello的反汇编

5.6 hello的执行流程

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

Edb查看如下:

图50 edb的symbols查看

5.7 Hello的动态链接分析

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

在程序调用由共享库定义的函数时,编译器无法在编译阶段确定该函数在内存中的准确地址。这是因为共享库在运行时可能被加载到内存的任意位置。为了解决这个问题,现代编译系统采用了一种称为延迟绑定的机制,将函数地址的解析推迟到程序第一次调用该函数的时刻进行。

延迟绑定的数据结构:PLT 与 GOT

延迟绑定依赖于两个关键的数据结构:

PLT
PLT 是一个跳转表,用于支持共享库函数的延迟调用。每个外部函数在 PLT 中都有一个对应的入口,该入口包含一段跳转指令。PLT[0] 是一个特殊条目,用于跳转至动态链接器。其余的 PLT 条目则负责各自对应的函数调用。

GOT
GOT 是一个存储函数实际地址的数组,每个条目占用 8 字节。GOT 与 PLT 协同工作。GOT[0] 和 GOT[1] 存储动态链接器在运行时使用的特定信息,GOT[2] 保存动态链接器的入口地址,其余条目用于存储各个外部函数的地址,并在第一次调用该函数时由链接器进行填充。

调用流程解析

当程序调用一个共享库函数时,整个延迟绑定过程如下:

  1. 第一次调用
    程序不会直接调用函数地址,而是跳转到该函数在 PLT 表中的条目。例如,调用 printf 实际上是跳转到 printf@PLT。该条目第一条指令通过 GOT 表进行间接跳转,此时 GOT 条目仍未填入真实地址,因此会跳转回该 PLT 条目的第二条指令。
  2. 跳转回自身并调用动态链接器
    PLT 的第二条指令会将函数标识符压入栈,然后跳转到 PLT[0]。PLT[0] 通过 GOT[1] 取出一个地址,压栈后,再通过 GOT[2] 跳转至动态链接器中的解析函数(位于 ld-linux.so 中)。
  3. 地址解析与跳转
    动态链接器根据压入栈的参数查找函数的真实地址,并将其写回对应的 GOT 条目。之后将控制权转交给真正的函数入口。
  4. 后续调用优化
    一旦 GOT 条目被填充,后续对该函数的调用将直接通过 GOT 中保存的真实地址跳转,无需再次进入动态链接器。这实现了函数调用的延迟绑定与高效性。

动态链接与重定位行为差异

程序启动初期,尚未加载动态链接器前,GOT 表中的各个函数地址尚未完成重定位,所有调用都需经过 PLT 路径进入链接器。只有在首次调用并完成重定位后,GOT 表才会更新为实际的函数地址,从而避免重复解析。这也体现了 hello 程序在动态链接器加载前后其地址解析行为的变化。

图51 edb

从地址 0x401020 开始,下面是各条目的结构特征与作用说明:

1. 0x401020 开始的指令块:PLT[0] 条目(通用入口)

401020: ff 35 e2 2f 00 00     jmp *0x2fe2(%rip)  ; 实际跳转到 GOT[2]

401026: 68 00 00 00 00        pushq $0x0         ; 推入函数ID(index)

40102b: e9 d1 ff ff ff        jmp 0x401001       ; 跳转回 .plt 起始

这是 .plt[0] 的内容,作用是跳转到动态链接器,完成函数的地址解析。这是第一次调用共享函数时执行的。

2. 后续条目(从 0x401030 开始,每 16 字节一组):

401030: ff 25 e2 2f 00 00     jmp *0x2fe2(%rip)   ; 间接跳转(通过 GOT)

401036: 68 00 00 00 00        push $0x0           ; 函数索引

40103b: e9 d1 ff ff ff        jmp 0x401010        ; 跳回 plt[0]

这类条目构成了具体函数的 PLT 调用入口,如:

printf@plt

getchar@plt

exit@plt

sleep@plt

这些跳转首先会通过 GOT 项获取函数地址,如果是首次调用会回到 .plt[0],进入链接器解析地址。

5.8 本章小结

本章通过实验中的 hello 可执行程序,系统性地介绍了程序链接的基本概念和主要作用。首先,通过分析 hello 程序的 ELF 文件格式,掌握了可执行文件中的各个段(如 .text、.data、.bss 等)及其布局特征,并对比了目标文件 hello.o 与最终可执行文件 hello 在结构上的差异,加深了对链接后程序组织方式的理解。

接着,探讨了 hello 程序的虚拟地址空间分布,明确了各段在内存中的映射情况,为理解程序加载执行提供了基础支撑。通过对 hello 和 hello.o 进行反汇编分析,详细比对了各符号的地址与调用过程,从而深入理解了链接器在重定位过程中的关键作用和实现机制。

此外,实验中还完整跟踪了 hello 程序的执行流程,整理并分析了执行过程中涉及的各个子函数调用及其关系,进一步加深了对程序执行逻辑的掌握。

最后,通过使用 gdb 和 edb 等调试工具,对 hello 程序的动态链接过程进行了实际分析,对比了程序在链接前后 .plt 和 .got 表的内容变化,全面认识了动态链接的工作原理及其对程序运行的影响。通过本章的学习与实验操作,对程序链接机制有了更深入和系统的理解。

6章 hello进程管理

6.1 进程的概念与作用

6.1.1 进程的概念

进程是计算机中已运行程序的一个动态实例,是系统进行资源分配和调度的基本单位,也是操作系统结构的核心组成部分。作为程序的基本执行实体,进程在当代以线程为中心的计算机体系结构中,扮演着线程运行环境的容器角色。简而言之,进程是程序实际运行时的基本载体和执行单位。

6.1.2 进程的作用

在现代计算机系统中,操作系统为每个进程分配一个唯一的进程标识符(PID),这使得程序员能够更高效地调度和管理正在运行的程序及其所占用的资源和数据。同时,每个程序独占一个进程,有助于实现资源隔离,从而增强对程序内部数据和状态的保护。

由于一个CPU核心在任意时刻只能执行一个进程的指令,这种执行机制促使硬件资源得以合理调度和充分利用。进程为程序提供了两个关键的抽象:逻辑控制流和私有地址空间。逻辑控制流使得每个进程看起来像是独占处理器在运行,而私有地址空间则确保每个进程拥有独立的内存区域,从而避免了不同程序之间的直接干扰

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

Shell是操作系统核心的交互接口,承担命令解释与系统控制的双重职能。其核心作用体现为:作为用户与内核的翻译层,将文本指令转化为系统调用;作为进程控制器,通过创建子进程执行外部程序;同时管理环境变量、实现脚本自动化。典型处理流程为:读取输入→解析命令(词法分析/语法扩展)→执行指令(内置命令直接响应,外部命令通过fork-exec机制启动子进程)→反馈结果并循环等待。这种机制既保证了系统操作的高效性,又通过进程隔离维护了系统稳定性,成为UNIX/Linux系统运维的核心工具。

6.3 Hello的fork进程创建过程

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

图52 运行结果

在UNIX/Linux进程复制机制中,fork()系统调用创建的子进程通过写时复制(Copy-on-Write)技术生成近乎完整的父进程副本。其核心特征表现为:

地址空间镜像:子进程继承父进程虚拟内存空间的精确拷贝(代码段/数据段/堆栈等),通过COW机制实现物理内存的高效复用

资源继承性:完整复制父进程的文件描述符表、信号处理程序及执行上下文环境

差异化标识:独立分配的进程标识符(PID)、父进程标识符(PPID)指向原进程、重置资源使用统计(CPU时间/文件锁等)、清除未决信号与定时器。

图53 内存空间示意图

6.4 Hello的execve过程

在fork创建子进程后,execve系统调用触发进程映像重构: 

1. 地址空间重置

   清空原进程虚拟内存空间 

   根据hello的ELF文件结构,重构代码段、数据段、堆栈及内存映射区域 

   保留原进程打开的文件描述符表 

2. 程序装载阶段*

   解析hello可执行文件的程序头部表 

   将.text(代码段)、.data(初始化数据段)载入内存 

   建立运行时堆(heap)和用户栈(stack)结构 

3. 执行控制转移 

   动态链接器(ld.so)完成共享库加载(若需) 

   重置寄存器状态,将程序计数器(EIP/RIP)指向入口点`_start` 

   通过`_start→__libc_start_main→main`调用链启动用户程序 

该过程通过内核态到用户态的切换,实现进程执行流的无缝转换。execve成功执行后,原进程上下文被完全替换,仅保留PID和文件资源,形成独立的新执行环境。整个加载过程通过内存映射技术实现物理内存的按需分配,保证执行效率。

6.5 Hello的进程执行

在程序执行期间,Shell为“hello”进程创建了一个子进程,该子进程具有独立的控制流。在“hello”进程的运行过程中,如果未受到外部抢占,则继续正常执行;若被抢占,则会进入内核模式,进行上下文切换,并返回用户模式,调度其他进程。当“hello”调用sleep系统调用时,为了最大化处理器资源的利用率,系统会将“hello”进程挂起,执行上下文切换进入内核模式,将其状态转入等待队列,同时启动定时器。当定时器到期后,sleep函数返回,触发相应中断,促使“hello”进程被重新调度,移出等待队列,切换回用户模式,从而继续执行其剩余的控制流。这一机制有效实现了进程的挂起与唤醒,确保了系统的调度效率与资源利用的优化

6.6 hello的异常与信号处理

6.6.1 异常的类型

类别

原因

异步/同步

返回行为

中断

来自I/O设备的信号

异步

总是返回到下一条指令

陷阱

有意的异常

同步

总是返回到下一条指令

故障

潜在可恢复的错误

同步

可能返回到当前指令

终止

不可恢复的错误

同步

不会返回

6.6.2 异常的处理方式

图54 中断处理方式

图55 陷阱处理方式

图56 故障处理方式

图57 终止处理方式

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

图58 Ctrl+Z

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

图59ps查看进程运行状态

运行jobs命令:

可以看到停止的作业:

图60 jobs看停止的作业

pstree 是一个 Linux/Unix 系统工具,用于以树状拓扑图直观展示系统中所有进程间的父子关系。其核心功能与用法如下:

进程可视化

将进程按层级关系显示为树形结构,根节点为系统初始进程(如 systemd 或 init)

分支节点表示派生关系,例如:sshd → bash → vim

关键信息标注

默认隐藏线程(可通过 -T 显示线程)

进程名旁标注进程号(PID,需 -p 参数)

高亮当前终端关联的进程树(-h)

图61 Pstree:显示所有运行中的进程的树状图

  1. Ctrl+C:进程收到 SIGINT 信号,结束 hello。在ps中查询不到其PID,在job中也没有显示,可以看出hello已经被彻底结束。

图62 ctrl+c

图63 ps与jobs的运行结果

  1. 中途乱按:只是将屏幕的输入缓存到缓冲区。乱码被认为是命令。

图64 中途乱按的结果

4)Kill命令:挂起的进程被终止,在ps中无法查到到其PID。

图65 kill指令的作用

6.7本章小结

本章系统阐述了操作系统进程的核心概念与运行机制,重点剖析了Shell作为用户-内核交互中介的关键作用。通过hello可执行程序实例,深度解析了进程创建双阶段模型:fork() 采用写时复制技术克隆父进程上下文,生成独立PID的子进程; execve()通过清空地址空间、加载ELF文件结构、重构代码/数据/堆栈段,实现进程映像的原子级替换。实验环节结合命令行参数传递场景,验证了信号处理机制对SIGINT(Ctrl+C)、SIGSEGV等异常事件的捕获与响应策略,揭示了进程执行流中断后的资源回收与状态回传原理。该研究完整呈现了从进程孵化、程序加载到执行监控的全链路技术细节,为理解UNIX系统级编程奠定了理论与实践基础。


第7章 hello的存储管理

7.1 hello的存储器地址空间

  1. 逻辑地址

定义:程序编译期生成的段内偏移量,在x86架构中表现为段选择符:偏移量`二元组(如 CS:EIP) 

实例特征:在`hello.asm`反汇编代码中体现为代码/数据的相对偏移(如 `0x4004e0`) 

  1. 线性地址

转换机制:逻辑地址经CPU段式管理单元(Segment Unit)转换后的连续地址空间 

分页预处理:作为分页机制的输入地址,描述程序视角的连续虚拟内存布局 

示例映射: hello程序的代码段/数据段在虚拟内存中的线性排布 

  1. 虚拟地址

体系结构特性:在x86保护模式下等同于线性地址(段式管理被扁平化) 

进程隔离性:每个进程独占独立的虚拟地址空间(如hello进程的0x400000起始代码区) 

  1. 物理地址

硬件寻址:经MMU单元通过页表(Page Table)+ TLB缓存转换后,输出至地址总线的电信号编码 

实际映射:对应DRAM芯片上的物理存储单元,如`hello`程序指令最终加载的内存条颗粒位置 

动态性:同一虚拟地址在不同时刻可能映射到不同物理地址(如进程上下文切换时) 

该多级映射体系通过段页式管理(Segmentation + Paging)实现了内存安全隔离与物理资源动态分配,保障了hello等进程的高效可靠运行。地址转换过程涉及CPU硬件(MMU)、操作系统(页表维护)及编译器(段描述符生成)的协同工作。

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

    7.2.1段描述与段选择符

在保护模式中,段的相关信息(如段基地址、段界限、访问权限等)被封装在段描述符中,每个段描述符占用 8 个字节。由于段寄存器仅有 16 位,无法直接存储完整的段描述符信息,因此 Intel 设计了全局描述符表(GDT)和局部描述符表(LDT)来集中存放段描述符。段寄存器中存储的是段选择符(Segment Selector),用于索引 GDT 或 LDT 中的段描述符。段选择符的结构如下:

图66 kill段选择符的结构

高 13 位:描述符索引(Index),指示段描述符在 GDT 或 LDT 中的位置。

第 2 位(TI):表指示符(Table Indicator),0 表示 GDT,1 表示 LDT。

低 2 位(RPL):请求特权级(Requested Privilege Level),用于权限检查。

当一个新的段选择符被加载到段寄存器中时,处理器会根据段选择符的索引和表指示符,从 GDT 或 LDT 中获取对应的段描述符,并将其加载到段寄存器的隐藏部分(描述符缓存)中。这样,在后续的内存访问中,处理器可以直接使用缓存的段描述符信息,无需再次访问内存中的描述符表,从而提高了访问效率。

   7.2.2逻辑地址到线性地址的转换过程

在保护模式下,逻辑地址由段选择符和段内偏移量组成。处理器将逻辑地址转换为线性地址的过程如下:

加载段描述符:当一个新的段选择符被加载到段寄存器中时,处理器根据段选择符中的索引和表指示符,从 GDT 或 LDT 中获取对应的段描述符,并将其加载到段寄存器的隐藏部分。

权限和有效性检查:处理器根据段描述符中的权限信息和当前的特权级,检查是否允许访问该段。

计算线性地址:处理器将段描述符中的段基地址与段内偏移量相加,得到线性地址。

这个过程确保了内存访问的安全性和灵活性。如果启用了分页机制,线性地址将进一步被转换为物理地址。

7.2.3段寄存器与描述符缓存

x86 架构提供了六个段寄存器:CS(代码段)、SS(堆栈段)、DS(数据段)、ES、FS 和 GS。每个段寄存器都有一个可见部分(存储段选择符)和一个不可见部分(描述符缓存)。当段选择符被加载到段寄存器中时,处理器会自动将对应的段描述符加载到描述符缓存中。在后续的内存访问中,处理器可以直接使用描述符缓存中的信息,无需再次访问内存中的描述符表,从而提高了访问效率。

在 x86 保护模式下,分段机制通过段选择符和段描述符的配合,实现了从逻辑地址到线性地址的转换。段选择符用于索引段描述符,段描述符提供段的基地址和访问权限等信息。处理器将段基地址与段内偏移量相加,得到线性地址。通过描述符缓存的机制,处理器可以高效地进行地址转换,确保了内存访问的安全性和性能。

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

在 Linux 系统中,虚拟地址到物理地址的转换主要依赖于分页机制(Paging),这是虚拟内存管理的核心组成部分。分页机制通过将虚拟内存和物理内存划分为固定大小的页(Page),并利用页表(Page Table)建立虚拟页与物理页之间的映射关系,从而实现高效的内存管理和访问控制。

7.3.1虚拟内存的组织结构

从概念上看,虚拟内存被组织为一个由 N 个连续字节大小的单元组成的数组,这些单元通常存储在磁盘上。为了提高访问速度,操作系统会将部分虚拟页缓存在物理内存(DRAM)中,这些缓存的内存块称为页(Page),每页的大小通常为 4KB,有时也可以是更大的 2MB 或 1GB。

虚拟页作为磁盘内容的缓存,具有以下特点:

全相联映射:任何虚拟页都可以映射到任何物理页框中,这需要一个更复杂的映射函数。

写回策略:DRAM 缓存通常采用写回(Write-Back)策略,而不是直写(Write-Through),以减少对内存的写操作次数。

替换算法:由于硬件实现复杂的替换算法存在限制,DRAM 缓存的替换策略通常由操作系统在软件层面管理。

虚拟页集合被划分为三个不相交的子集:

  1. 已缓存(Cached:当前驻留在物理内存中的页。
  2. 未缓存(Uncached:尚未加载到物理内存中的页。
  3. 未分配(Unallocated:尚未分配存储空间的页。

图67 内存映射关系

7.3.2页表与地址映射

页表是一个由页表条目(Page Table Entry, PTE)组成的数组,用于将虚拟页地址映射到物理页地址。每个进程都有自己的页表,页表常驻于主存中。在 x86 架构中,页表通常采用多级结构(如三级或四级页表),以减少内存占用并提高查找效率。

当处理器需要访问某个虚拟地址时,地址转换过程如下:

  1. 生成虚拟地址:CPU 生成一个虚拟地址,并将其发送给内存管理单元(MMU)。
  2. 查找页表:MMU 使用虚拟地址中的页目录索引和页表索引,逐级查找对应的页表条目(PTE)。
  3. 获取物理地址:如果 PTE 有效,MMU 提取其中的物理页框号,并与页内偏移量组合,形成完整的物理地址。
  4. 访问内存:CPU 使用物理地址访问主存,读取或写入数据。

为了加快地址转换速度,处理器通常配备了翻译后备缓冲区(Translation Lookaside Buffer, TLB),用于缓存最近使用的虚拟地址到物理地址的映射。如果 TLB 命中,MMU 可以直接获得物理地址,避免访问页表。

图68 页表的实现

下图展示了页式管理中虚拟地址到物理地址的转换:

图69 页式管理

7.3.2缺页异常处理

当访问的虚拟页不在物理内存中,即 PTE 的有效位为 0 时,会触发缺页异常(Page Fault)。操作系统的缺页异常处理程序会执行以下步骤:

  • 保存上下文:保存当前进程的状态,以便在处理完异常后恢复。
  • 查找页数据:确定所需页在磁盘上的位置。
  • 选择牺牲页:如果物理内存已满,选择一个页框进行替换(可能需要将其写回磁盘)。
  • 加载新页:将所需页从磁盘加载到物理内存中。
  • 更新页表:修改对应的 PTE,标记其为有效,并更新物理页框号。
  • 恢复执行:恢复进程状态,重新执行导致缺页的指令。

下面两张图展示了页面命中和缺页情况:

图70 页面命中

图71缺页

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

为了屏蔽每次 CPU 生成虚拟地址时对页表项(PTE)访问所引发的多级页表遍历延迟,现代处理器的内存管理单元(MMU)通常集成一个以内容可寻址存储器(CAM)实现的全关联小型缓存——翻译后备缓冲器(TLB),用于存储近期的虚拟页号(VPN)到物理页号(PPN)的映射,加速地址转换流程。
该 TLB 在命中时仅需数个时钟周期即可完成 VPN→PPN 的映射,其访问延迟常低于 L1 级数据缓存的访问延迟,从而显著提升内存访问的整体性能。

图72 虚拟地址中用以访问TLB的组成部分

TLB 将虚拟页号(VPN)的高位和低位分别划分为标记字段(TLBT)与索引字段(TLBI),并通过内容可寻址存储器(CAM)以全关联或组相联方式高速缓存页表项;MMU 在地址转换时首先根据 TLBI 定位候选集合,再通过 TLBT 完成标签匹配,若未命中则触发多级页表遍历,将对应的页表项(PTE)从内存加载并填充到 TLB 中。
    为避免单级页表尺寸过大导致的内存浪费,x86‑64(如 Intel Core i7)采用四级页表结构,依次由 PML4、PDPT、PD 及 PT 四级索引完成虚拟地址到物理页号(PPN)的映射。
    此外,TLB 的访问延迟仅需数个时钟周期,其速度通常优于 L1 数据缓存,这得益于 CAM 的并行标签比对机制及硬件预取优化,大幅提升了虚拟地址转换的整体性能

图73 二级页表

在四级页表结构下,虚拟地址被拆分为四段虚拟页号(VPN₄、VPN₃、VPN₂、VPN₁)和一段页内偏移(VPO)。其中,第 i 级页表的索引即对应 VPNᵢ,而第 j 级页表中的每个页表项(PTE)都存储着第 j+1 级页表的物理基址;在第 4 级页表中,PTE 则直接记录了目标物理页框号(PPN),或在需求下指向磁盘上的页交换区。当 MMU 执行地址转换时,若 TLB 未命中,便需依次读取这四级 PTE,直至获得最终的 PPN,随后将其与页内偏移组合,生成完整的物理地址。

图74 四级页表

综上所述,在四级页表体系下,MMU 会先利用虚拟地址中的四段 VPN 通过 TLB 进行快速查找——若命中则直接获得下一级页表的物理基地址或最终的物理页框号(PPN);若未命中,则依次访问 PML4、PDPT、PD 和 PT 四级页表项获取 PPN,最终将该 PPN 与页内偏移(VPO)拼接生成完整的物理地址(PA)。

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

Core i7的内存系统如图所示。

图75 内存系统

首先,根据物理地址的 s 位组索引索引到 L1 cache中的某个组,然后在该组中查找是否有某一行的标记等于物理地址的标记并且该行的有效位为 1,若有,则说明命中,从这一行对应物理地址 b 位块偏移的位置取出一个字节,若不满足上面的条件,则说明不命中,需要继续访问下一级 cache,访问的原理与 L1 相同,若是三级 cache 都没有要访问的数据,则需要访问内存,从内存中取出数据并放入cache。

图76 Cache访问

7.6 hello进程fork时的内存映射

当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的页面标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的概念。

7.6.1 fork()时的内存映射

(1)进程创建阶段

操作系统通过以下方式创建子进程:

新建独立的进程控制块(PCB),建立进程管理结构

继承父进程的关键上下文(包括打开的文件描述符、信号处理程序、内存映射表等)

(2)虚拟内存继承

子进程通过以下方式共享父进程内存资源:

获得与父进程完全一致的虚拟地址空间映射表

所有虚拟页表项初始设置为与父进程共享物理页帧(此时尚未进行物理内存复制)

(3)写时复制优化

内存资源的动态管理机制:

共享的物理内存页被标记为写保护状态

当任一进程(父/子)尝试执行写操作时触发页错误异常

内核拦截异常后执行物理页复制,为修改进程分配独立的新物理页

仅复制被修改的特定内存页(4KB粒度),保持未修改页的共享状态

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

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

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

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

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

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

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

图77 写时复制

7.7 hello进程execve时的内存映射

execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。 加载并运行 hello 需要以下几个步骤:

1.系统调用进入与权限检查

进程从用户态触发 sys_execve,切换到内核态,并检查调用者对目标文件的可执行权限。

同时核实当前进程是否有能力改变其映像(如是否被 ptrace 附加、是否为 set‑uid/set‑gid 等)。

2.清理旧的用户空间映射

内核调用 mm_release() 等函数,撤销当前进程所有进程空间(struct mm_struct)中的用户态内存映射(VMA),包括代码、数据、堆、栈及任何匿名或文件映射。

这意味着所有原来打开的匿名页和文件映射都被卸载,引用计数减少,必要时回收页面。

3.建立新程序的地址空间布局

解析 ELF 头与各段(segment)

内核读取 ELF 可执行文件头(ELF64_Ehdr),根据 program header 表(PHDR)里各 LOAD 段的偏移和大小,分别在进程的地址空间中创建对应的 VMA,并调用 do_mmap_pgoff() 将 .text、.data 区域以私有写时复制(private COW)的方式映射到文件内容。

.bss 和 堆/栈 区域

.bss 区段没有文件数据,只需要在虚拟区间内请求相应大小的匿名映射(全零页);

堆通常从符号 brk 开始,后续动态分配时按需扩展;

用户栈(ULOWER_STACK 附近)也是用匿名映射,但初始仅分配少量页面,后续通过缺页中断自动扩展。

4.加载动态链接器与共享库

如果这个 ELF 是动态可执行文件(ET_DYN + PT_INTERP),内核首先把动态链接器(如 /lib64/ld‑linux‑x86‑64.so.2)映射到地址空间,然后由链接器负责加载并重定位依赖的共享对象(如 libc.so.6)。

链接器在用户态(而非内核态)完成符号解析与重定位,设置各共享库的基址和 GOT/PLT 等。

5.构造栈帧:argv/envp/auxv

内核在用户栈顶压入 argc、argv 字符串指针数组、envp 数组,以及一组辅助向量(auxv),包含了诸如平台标识、页大小、程序入口点、动态链接器基址等信息,供动态链接器和程序启动时使用。

6.设置进程上下文并跳转到入口

更新 current->mm、current->active_mm 等指针;

重置信号处理、文件描述符的 close-on-exec 标志;

最后,将用户态寄存器(包括程序计数器 PC / RIP)设置为 ELF 头中指定的 e_entry,然后执行 return_to_user_mode(),开始执行 hello 的第一条指令。

图78 虚拟内存

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

缺页故障是操作系统管理虚拟内存时的一种核心机制,当程序试图访问尚未加载到物理内存的虚拟内存页面时便会触发。现代计算机通过虚拟内存技术为每个进程提供独立的虚拟地址空间,使得程序能够使用比实际物理内存更大的内存资源,但这些虚拟内存页面可能存储在物理内存或磁盘(如交换空间)中。当 CPU 访问某个虚拟地址时,内存管理单元(MMU)会查询页表,若发现对应的页表项标记为“无效”(即页面不在物理内存中),则会产生缺页故障。 

此时,操作系统会暂停当前进程,进入内核态处理该故障:首先检查访问的虚拟地址是否合法(例如是否存在越界或权限错误),若合法则从物理内存中分配一个空闲页框,若无空闲页框则通过页面置换算法(如 LRU、FIFO)将某个页面换出到磁盘,随后从磁盘(可能是交换文件或程序文件)中读取目标页面到物理内存,并更新页表以标记该页面为“有效”。最终,系统会重新执行触发缺页的指令,使程序继续运行。 

缺页故障分为三种类型:次要缺页(页面已在内存缓存中,仅需更新页表,无磁盘 I/O)、主要缺页(需从磁盘加载页面,耗时较长)和无效缺页(访问非法地址导致进程终止,如段错误)。其常见原因包括程序初次加载代码数据、内存访问的局部性变化(如遍历新数组)或物理内存不足导致的频繁页面置换(即“抖动”现象)。 

尽管缺页是内存扩展的必要机制,但频繁的主要缺页(尤其是涉及磁盘 I/O 时)会显著拖慢程序性能。例如,在首次访问动态分配的大数组元素时,若页面未预加载,每次访问都可能触发缺页。为优化性能,可通过预加载页面、调整页面大小、减少内存碎片或增加物理内存等手段降低缺页频率,从而提升系统效率。

下图对VP3的引用不命中,从而触发缺页。

图79 缺页

缺页之后,缺页处理程序选择VP4作为牺牲页,并从磁盘上用VP3的副本取代它。在缺页处理程序重新启动导致缺页的指令之后,该指令将从内存中正常地读取字,而不会再产生异常。、

缺页中断处理是操作系统在程序访问未加载到物理内存的虚拟页面时触发的核心机制。当CPU通过页表发现目标页面不在物理内存时,会暂停当前进程并交由操作系统处理:首先检查访问地址的合法性,若合法则分配物理页框,随后从磁盘加载所需页面到内存,更新页表并标记为“有效”,最后重新执行被中断的指令。此过程若涉及磁盘I/O会显著延迟程序运行,而页面已缓存时则无需磁盘操作,效率更高。该机制保障了虚拟内存的灵活性,但频繁缺页会严重拖累系统性能。

7.9动态存储分配管理

动态内存分配器作为内存管理的核心组件,主要负责管理进程虚拟内存中的堆区域。该机制将堆空间组织为多个离散的内存区块集合,每个区块表征一段连续的虚拟内存单元,其状态可分为已分配(allocated)或空闲(free)两种形态。

已分配区块由应用程序显式声明占用,这些内存单元被锁定为专用状态直至释放。与之对应的空闲区块则处于待分配状态,其内存资源可被动态调度以满足新的内存请求。特别需要指出的是,内存区块的状态转换遵循明确的规则:空闲区块仅能通过显式的分配请求转为占用状态,而已分配区块的释放既可通过程序员的显式操作完成,也可能由内存分配器通过垃圾回收等隐式机制自动实现。这种双模式释放机制有效平衡了开发灵活性与内存安全性,构成现代内存管理体系的重要特征。

内存管理中的分配器技术可分为两大范式:显式分配器与隐式分配器,二者在内存回收机制上存在本质差异。

1. 显式分配器:采用开发者主导的内存管理模式,要求程序员通过特定指令(如C语言的free())主动释放不再使用的内存块。这种机制将内存生命周期管理的责任赋予开发者,虽能实现精准控制,但也存在内存泄漏(Memory Leak)或重复释放(Double Free)等风险,典型代表包括C标准库的malloc/free接口。

2. 隐式分配器:通过自动垃圾回收(Garbage Collection, GC)机制实现内存自治,其核心在于动态追踪内存块的引用状态。当分配器检测到某内存块不再被任何指针引用时,自动将其标记为可回收资源并释放。该机制有效避免了人为管理失误,但需要运行时系统维护对象引用图谱(如Java虚拟机),且可能引入GC暂停等性能开销。

带边界标签的隐式空闲链表分配器原理:

图80 隐式空闲链表分配器

每个块可以增加四字节的头部和四字节的脚部保存块大小和是否分配信息,可以在 常数时间访问到每个块的下一个和前一个块,使空闲块的合并也变为常数时间,而且可以遍历整个链表。隐式空闲链表即为,利用边界标签区分已分配块和未分配块,根据不同的分配策略(首次适配、下一次适配、最佳适配),遍历整个链表,一旦找到符合要求的空闲块,就把它的已分配位设置为1,返回这个块的指针。隐式空闲链表并不是真正的链表,而是"隐式"地把空闲块连接了起来(中间夹杂着已分配块)。

显式空闲链表的基本原理:

图81 显式空闲链表分配器

使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线 性时间减少到了空闲块数量的线性时间。种方法使用后进先出的顺序维护链表,将新释放的块在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过 的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界 标记,那么合并也可以在常数时间内完成。按照地址顺序来维护链表,其中链 表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要 线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比 LIFO 排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。

动态内存管理通过两种基本方法实现:显式管理由开发者手动分配(如malloc)和释放(如free)内存,精准高效但易引发泄漏或错误;隐式管理则依赖垃圾回收器(GC)自动追踪无引用内存并释放,简化开发但可能引入性能波动。核心策略包括:采用首次适应、最佳适应等算法搜索空闲内存块;通过块合并或内存压缩减少碎片;利用内存池预分配资源提升效率,或通过分离空闲链表、伙伴系统分层优化分配。例如, printf可能在处理不定长输出时调用malloc动态申请缓冲区,此时内存池技术可减少频繁分配的开销。动态内存管理需权衡空间与时间效率,在控制碎片、降低延迟和保证灵活性之间寻求平衡,是系统性能优化的关键环节。

7.10本章小结

本章如同构建一套“城市级内存管理体系”: 

1. 虚拟地址空间是程序视角的“虚拟城市地图”,段式管理如同将城市划分为商业区、住宅区等专属分区(Intel分段机制),而页式管理则像将城市土地细化为标准尺寸的模块化拼图(内存分页),通过VA到PA转换实现“虚拟地图坐标”到“物理土地定位”的精准映射; 

2. 物理内存访问相当于在城市真实地块上建设施工,进程fork/execve时的内存映射如同为新建城区快速复制或重建规划蓝图; 

3. 缺页中断类似施工时发现某地块未开发,立即触发“物流补货系统”调拨资源(加载物理页),而动态内存分配则是城市中灵活调配临时仓库的物流调度中心(堆管理),协调内存资源的实时供需。 

这套体系从规划到执行,贯穿虚拟构想、物理实施与动态调度,构建了程序运行的“内存生态城市”。

8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

这种将设备映射为文件的设计,使内核能够提供一套简单而统一的应用层接口,也就是经典的 Unix I/O。无论是磁盘、终端还是网络设备,应用程序都可以通过以下四步以一致的方式与它们交互:

打开文件、定位文件指针、读写数据)、关闭文件

通过这种“万物皆文件”的理念,Linux 实现了设备访问的高度模块化和可扩展性,使得新设备的接入仅需遵循文件接口规范即可,无需为每种设备开发独立的调用接口。

8.2 简述Unix IO接口及其函数

Unix I/O 接口的几种操作:

1. 打开与创建:

open() / openat()打开一个已存在的文件或设备,并返回一个新的文件描述符;同时可指定标志如 O_RDONLY、O_CREAT、O_CLOEXEC 等。

creat()相当于 open(path, O_CREAT|O_WRONLY|O_TRUNC, mode) 的简化接口,用于创建并打开新文件。

2. 关闭

close()关闭指定的文件描述符,释放内核中对应的打开表项。

3. 数据传输

read()从文件描述符对应的文件或设备中读取最多 nbytes 字节到用户缓冲区,返回实际读取的字节数。

write()将用户缓冲区中的最多 nbytes 字节写入到对应的文件或设备,返回实际写入的字节数。

pread() / pwrite()与 read/write 类似,但在不改变文件偏移量的前提下指定读取/写入的文件偏移位置。

4. 文件偏移定位

lseek()调整文件描述符的当前偏移量,可用于随机访问或基于当前位置/文件开头/文件末尾的寻址。

5. 控制与属性查询

ioctl()向设备或文件描述符发送特殊控制指令,用于配置硬件或查询底层驱动状态。

fcntl()对文件描述符执行各种操作,如设置非阻塞(F_SETFL, O_NONBLOCK)、获取/设置 close-on-exec 标志、复制描述符等。

6. 描述符复制

dup() / dup2() / dup3()复制一个已有的文件描述符,生成新的描述符指向同一内核打开表项,可用于重定向标准输入/输出。

7. I/O 多路复用

select() / pselect()监视一组文件描述符的可读、可写或异常状态,支持超时等待。

poll()
类似 select,使用 pollfd 数组并支持更多事件类型,解决了文件描述符数量限制的问题。

epoll(Linux 专有:epoll_create1、epoll_ctl、epoll_wait)面向大规模并发场景设计的高效多路复用接口。

8. 内存映射

mmap()将文件或设备映射到进程虚拟内存空间,读写时直接在地址空间操作,可用于高性能 I/O 或与硬件共享内存。

munmap()取消先前的映射,释放对应的虚拟地址区间。

Unix I/O 函数:

1. int open(const char *pathname, int flags, mode_t mode)
open 函数用指定的 pathname 打开(或创建)一个文件,并返回对应的文件描述符。返回值为进程中尚未使用的最小整型描述符。flags 参数指定访问方式(如只读、只写、创建、截断、关闭执行时继承等),mode 参数则在创建新文件时定义文件的权限位(如所有者读写、组读、其他人读等)。

2. int close(int fd);
close 函数关闭参数 fd 指定的文件描述符,使其不再引用任何文件或设备,并将该描述符返还给系统以供后续 open 使用。若这是最后一个指向该文件的描述符,则会释放相关资源,例如移除任何记录锁,且若文件已被删除,则此时才真正回收磁盘空间。操作成功返回 0,失败返回 -1 并设置 errno。

3. ssize_t read(int fd, void *buf, size_t count);
read 函数尝试从描述符 fd 当前的文件偏移位置读取最多 count 字节的数据到用户提供的缓冲区 buf。返回值为实际读取的字节数;若返回 0 则表示已到达文件末尾 (EOF),返回 -1 则表示发生错误并设置 errno。在支持随机定位的文件上,每次读取后文件偏移量会自动向后移动相应的字节数。

4. ssize_t write(int fd, const void *buf, size_t count);
write 函数将用户缓冲区 buf 中最多 count 字节的数据写入到描述符 fd 对应的文件或设备。返回值为实际写入的字节数;若写入失败则返回 -1 并设置 errno。对于非阻塞文件描述符,写入量可能少于请求量,调用者需根据返回值决定后续操作。

8.3 printf的实现分析

显示信息先由 vsprintf 生成,再通过 write 系统调用输出,最后通过陷阱指(int 0x80 或 syscall)进入内核。

先找到 printf 的函数定义:

图82 printf的函数定义

其中,va_start() 与 va_end() 用于处理可变参数。在访问任何可变参数之前,必须先通过 va_start(argptr, fmt) 初始化参数指针 argptr;随后,每次调用 va_arg(argptr, type) 即可按指定类型依次取出下一个参数。参数取完后,函数返回之前务必调用 va_end(argptr),以确保堆栈状态得以正确恢复。之后,printf 会调用 Unix I/O 函数 write,将 printbuf 缓冲区中长度为 i 的字节输出到屏幕——这里 i 即由 vsprintf(printbuf, fmt, args) 返回的字符数,因此 vsprintf 的作用至关重要。

图83 vsprintf代码

vsprintf 的作用是根据 printf 的格式字符串和参数,将格式化后的结果写入缓冲区 buf 中,并返回生成字符串的长度。随后,write 系统调用负责输出:在 Linux 中,write(fd, buf, count) 的第一个参数 fd 为文件描述符,其中 1 表示标准输出。其汇编实现先将 fd、buf 和 count 分别加载到指定寄存器,然后执行中断指令(如 int 0x80 或 syscall),触发内核的系统调用入口;内核将缓冲区中的数据通过总线传递至显存(VRAM)。显示芯片依照刷新频率逐行读取 VRAM 中的 RGB 像素数据,并通过信号线将每个像素点传输至液晶显示器,最终将格式化后的字符串呈现于屏幕上。字符显示过程涉及:将 ASCII 码映射到字模库,再将字模点阵写入 VRAM,完成从字符到屏幕显示的整个流水线。

8.4 getchar的实现分析

当用户按下按键时,键盘控制器会生成对应的扫描码并触发一个中断请求,内核随即暂停当前进程,转而执行键盘中断处理程序。该程序首先从键盘控制器读取扫描码,将其转换为 ASCII 码,然后将得到的字符放入系统的键盘缓冲区中。

再来看 getchar 的实现:

int getchar(void) 
{ 
static char buf[BUFSIZ]; 
static char* bb=buf;
static int n=0; 
if(n==0)
{ 
n=read(0,buf,BUFSIZ);
bb=buf; 
} 
return(--n>=0)?(unsigned char)*bb++:EOF;
}

这里,getchar 调用了 read,而 read 又通过系统调用 (int 0x80 或 syscall) 将缓冲区中保存的 ASCII 字符读入到用户空间。read 会一直读取,直到碰到回车符(或达到缓冲区大小),然后返回实际读取的字节数。getchar 则每次只取出其中的第一个字符,其余字符仍留在输入缓冲区,供后续调用使用。

8.5本章小结

Linux I/O 设备管理方法
介绍了 Linux 如何将所有 I/O 设备统一抽象为文件(“万物皆文件”),并通过文件模型对设备进行管理,包括 block 设备、字符设备和网络设备的挂载与访问方式。

Unix I/O 接口及其系统调用
阐述了 Unix I/O 的四大基本流程:打开(open)、定位(lseek)、读写(read/write)和关闭(close),并补充了多路复用(select/poll/epoll)、控制操作(ioctl/fcntl)、内存映射(mmap)等扩展接口。

printf 函数实现分析
解析了 printf 内部如何借助 vsprintf 将格式化字符串与参数转换生成输出文本,随后调用 write 系统调用通过中断陷阱(int 0x80 或 syscall)将数据写入标准输出,并最终由显存和显示硬件完成屏幕显示。

getchar 函数实现原理
描述了键盘中断处理流程——扫描码读取、转换为 ASCII 并存入内核缓冲区;以及用户态 getchar 如何通过一次 read 系统调用批量读取缓冲区内容,再逐字符返回,剩余字符留在输入缓冲区中以供后续调用。

通过本章学习,全面掌握 Linux 内核对 I/O 设备的抽象与管理机制,Unix I/O 接口的使用范式,以及常用 C 标准库函数(printf、getchar)底层调用流程的实现细节。

结论

一.hello.c 的生命周期概览

1.预处理阶段

hello.c 经由预处理器(cpp)展开宏、处理 #include 和条件编译,生成纯文本的中间文件 hello.i。

2.编译阶段

编译器前端(如 gcc -S)将 hello.i 转换为汇编代码 hello.s,完成语法分析与中间代码生成。

3.汇编阶段

汇编器(如 as)将 hello.s 汇编成机器码,产出可重定位的目标文件 hello.o,并在符号表中记录外部引用。

4.链接阶段

链接器(ld)把 hello.o 与所依赖的库(静态或动态)合并,解决符号引用,生成可执行文件 hello。

5.运行阶段

Shell 进程调用 fork() 复制出子进程,再通过 execve("hello", …) 将新进程的地址空间替换为 hello 的映像。

运行中,虚拟地址被加载并重定位至物理内存(最终映射为物理地址 PA),程序调用诸如 printf、getchar 等函数,与 Linux 的 I/O 子系统交互。

6.退出与回收

程序结束后由父进程(Shell)通过 wait() 收集子进程的退出状态,内核回收其所有资源,包括页表、文件描述符和内存映射。

二.深度体验与收获

1.每一步都非“黑箱”

从简单的一行 printf("Hello\n"),其实背后要经历预处理、编译、汇编、链接、系统调用、内存管理等多道工序,方能展现在屏幕上。

只有亲自跟踪过各个中间文件与工具输出,才能真正理解编译链(toolchain)的严谨与魔力。

2.系统设计的精巧与严密

链接器的符号解析、动态链接的重定位机制、写时复制(COW)策略、段式地址空间管理、I/O 的“万物皆文件”抽象……每个细节都体现了系统设计者的智慧。

从逻辑到性能,内核与运行时库无缝协作,保障了程序的可移植性、可维护性与高效性。

3.理论与实践的结合

CSAPP(《深入理解计算机系统》)不仅停留在纸面概念,而是要求我们动手验证、在真实环境中排错,才能将抽象知识转化为直观感受。

例如,查看汇编输出发现调用约定、参数传递与栈帧布局的差异,加深了对 CPU 架构与 ABI 的理解。

4.学习路程的意义

从源代码到机器指令,再到硬件显示过程,每一步都凝聚了计算机系统的复杂性,也展现了其可控性与可预测性。正如流水线中每个微小周期都精确计算、缓存层级巧妙加速,操作系统与编译器同样为我们的程序护航。

三、致谢与展望

回顾这一学期的学习旅程,深感计算机系统的博大精深,也体会到不断钻研与动手实验的重要性。衷心感谢授课教师的耐心讲解与答疑,也感谢同学们的相互讨论。虽然我们刚刚踏上征途,但正是这些基础奠定了未来探索更深层次系统编程、操作系统原理与编译技术的信心与动力。

附件

文件名称

描述介绍

hello.c

源程序文件

hello.i

hello.c通过预处理器cpp预处理后的文本文件

hello.s

hello.i通过编译器ccl编译后的汇编程序

hello.o

hello.s通过汇编器as汇编后的文件

hello

hello.o通过链接器ld链接后的可执行文件

Objdump_hello.ohello.o

反汇编文件

Objdump_hello hello

反汇编文件

参考文献

[1] CSDN_专业开发者社区_已接入DeepSeekR1满血版

[2]DeepSeek | 深度求索

[3] https://github.com.

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值