本文详细记录了名为“Hello”的C语言程序从源代码到可执行文件的全生命周期过程,并深入分析了其在Linux操作系统下的执行机制、进程管理、存储管理及I/O管理。研究首先通过预处理、编译、汇编和链接四个阶段,将Hello.c源代码转换为可执行文件,展示了编译器和链接器如何将高级语言代码转换为机器指令,并解析符号引用。随后,探讨了Hello程序在Linux下的执行流程,包括进程的创建(fork)、执行(execve)及异常处理机制,揭示了操作系统如何管理进程的生命周期。在存储管理方面,分析了虚拟地址到物理地址的转换过程,包括段式管理、页式管理、TLB缓存及多级Cache的作用,以及fork和execve系统调用对内存映射的影响。此外,还深入研究了Linux I/O设备管理方法,特别是“一切皆文件”的抽象理念,以及Unix I/O接口及其函数在printf和getchar实现中的应用。通过这一系列分析,本文不仅加深了对计算机系统底层工作原理的理解,还展示了理论与实践相结合的重要性,为后续深入学习操作系统、编译器设计等领域奠定了坚实基础。
关键词:计算机系统; 程序生命周期;编译过程;进程管理;存储管理;I/O管理;Linux操作系统;
目 录
6.2 简述壳Shell-bash的作用与处理流程... - 36 -
6.2.2 shell-bash的处理流程... - 37 -
6.3 Hello的fork进程创建过程... - 39 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 45 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 45 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 46 -
7.5 三级Cache支持下的物理内存访问... - 47 -
7.6 hello进程fork时的内存映射... - 48 -
7.7 hello进程execve时的内存映射... - 49 -
8.1.2 设备管理:unix io接口... - 55 -
第1章 概述
1.1 Hello简介
程序启动前,未占用任何系统资源(物理内存、CPU时间片等)。此时hello仅作为静态的ELF可执行文件存在于磁盘中。当用户在 shell 中运行 hello 程序时,shell 首先调用 fork() 创建一个子进程,然后在子进程中通过 execve() 系统调用加载并执行 hello 程序的机器码,此过程实现了从 shell到 hello 程序的 进程控制权传递,即一次 P2P 的“执行上下文切换”。程序运行后,CPU 从原来运行 shell 的上下文,切换到运行 hello 的进程上下文中,开始执行用户态代码。期间每次调用 printf(),都会通过 write 系统调用从用户态切换到内核态,将数据写入标准输出(终端设备),体现为 用户态-内核态切换中的 P2P 协作。而 sleep() 使进程暂时让出 CPU,进入睡眠状态,由调度器管理 CPU 分配。运行结束后,程序退出并通过 exit() 通知内核,shell 检测到子进程结束后恢复控制权,完成一个从 shell → 程序 → shell 的 P2P 生命周期闭环,展现了操作系统中处理器资源在多个进程之间切换与调度的全过程。整个过程体现了操作系统的资源动态分配与回收机制,从进程创建时的资源零占用,到执行期的资源动态分配,最终回归零占用状态,形成完整的生命周期闭环
1.2 环境与工具
1.2.1硬件环境
处理器:13th Gen Intel(R) Core(TM) i7-13650HX 2.60 GHz
机带RAM:16.0 GB
1.2.2软件环境
Windows: Windows 11 64位
Linux: 22.04.1-Ubuntu
Cache:
L1d: 96 KiB (2 instances)
L1i: 64 KiB (2 instances)
L2: 2.5 MiB (2 instances)
L3: 48 MiB (2 instances)
1.2.3调试工具
Gdb、Gcc、edb
1.3 中间结果
├──hello //代码文件夹
│ ├── hello // 可执行文件
│ ├── hello.c // c代码文件
│ ├── hello.elf //hello.o的elf信息
│ ├── hello.i //预处理文件
│ ├── hello.o //可重定位文件
│ ├── hello.s //汇编文件
│ ├── hello_dis.s //hello.o反汇编文件
│ ├── hello_asm.txt //hello的反汇编文件
图 1 中间文件
1.4 本章小结
本章简要概括了hello程序的p2p与o2o总流程,覆盖编译系统、进程管理、存储管理及信号处理等计算机系统核心机制。并列举了实验过程中的硬件软件环境与中间文件。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
预处理是源程序被编译之前的文本处理阶段,核心功能是通过处理以#开头的指令对代码进行转换和优化。主要完成宏定义、条件编译和文件包含等内容。
2.1.2预处理的作用
预处理器(cpp)的作用有将所有的#define删除,并且展开所有的宏定义;处理所有的条件预编译指令;处理#include预编译指令,将被包含的文件直接插入到预编译指令的位置;删除所有的注释。具体的的操作如下:
表 1 预处理操作
指令 | 描述 |
# define | 替换预处理宏 |
#include | 插入头文件 |
#undef | 取消预处理宏 |
#ifdef | 如果此宏已定义则返回true |
#ifndef | 如果此宏未定义则返回true |
#if | 测试编译时条件是否为真 |
#else | #if的替代方案 |
#elif | #else 和 #if 在同一语句中 |
#endif | 结束条件预处理 |
#error | 在 stderr 上打印错误消息 |
#pragma | 使用标准化方法向编译器发出特殊命令。 |
2.2在Ubuntu下预处理的命令
图 2 预处理指令
图 3 生成预处理文件
2.3 Hello的预处理结果解析
1. 删除全部的//单行注释,直接从头文件开始转换。
2. 展开stdio.h、unistd.h、stdlib.h等头文件
其中行标记格式为#<line> “<file>” [flags],表示来自文件的某一行,flag 3 表示文件是系统头文件,flag 4 表示遵循c99标准。
(1)stdio.h:展开近900行标准I/O相关声明,展开的过程中,同时递归展开头文件有关的文件依赖,比如
libc-header-start.h → features.h → wordsize.h
图 4 stdio.h的头文件展开
(2) unistd.h:提供sleep()等POSIX API
通过检查wordsize决定时间相关结构体使用32位还是64位,保证了平台的可移植性。
图 5 unist.h的头文件展开
(3)stdlib.h:包含atoi()、exit()等函数声明
图 6 stdlib.h的头文件展开
3. 原代码段在预处理文件的末尾部分
2.4 本章小结
在进行编译预处理时,对于原始的hello.c代码执行了注释移除、宏定义展开、条件编译与头文件递归展开等操作,将原始的24行代码扩展到了3092行的可供编译器直接处理的纯净C代码。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
编译阶段是指将预处理后的文件(.i文件)转换为汇编语言程序(.s文件)的过程,其核心作用是通过词法分析、语法分析、语义分析及优化等操作,将高级语言代码翻译为与目标机器架构相关的低级汇编指令,为后续生成可执行文件奠定基础。
3.1.2 编译的作用
1. 性能优化与硬件控制
汇编代码直接映射 CPU 指令,程序员可通过手动编写或修改 .s 文件实现精细化的性能优化
2. 程序中间表示的生成
.s 文件是编译器生成的中间产物,便于后续汇编器(如 as)将其转换为机器码。此阶段会完成符号汇总(如全局变量和函数名),为链接阶段解决跨模块引用提供基础。
3. 跨语言交互支
在混合编程场景中(如 C 与汇编混合编程),.s 文件允许直接嵌入汇编代码,实现与高级语言的数据交互和函数调用。
3.2 在Ubuntu下编译的命令
图 7 编译指令
图 8 生成的汇编语言文件
3.3 Hello的编译结果解析
3.3.1 数据处理
- 常量
通过.section .rodata声明只读数据段存储字符串常量,并通过.align 8确保8字节对齐优化。.LC0存储UTF-8编码的中文字符串输入指令
用法: Hello 学号 姓名 手机号 秒数!
.LC1存储ASCⅡ格式字符串,确定输入格式为
Hello %s %s %s\n
图 9 只读数据段常量
- 局部变量
程序中通过键盘输入的变量均为局部变量,在main函数中通过开辟一个32字节的栈空间来保存。保存argc到栈偏移-20字节处,保存argv到栈偏移-32字节处。
整体的栈结构为:
图 10 局部变量
- 全局变量/静态变量
汇编代码中无.data段与.bss段,c代码中未定义全局变量和静态变量。
3.3.2 赋值操作
- 初始化赋值
将立即数0直接写入栈偏移-4字节处,实现对于局部变量i的初始化。
图 11 局部变量i的赋值
图 12 局部变量i的定义
- 数组解引用赋值
在栈帧偏移-32字节的位置加载argv数组首地址,依次向下偏移获取argv中元素地址,并从中进行(rax)解引用加载arg[1],arg[2],arg[3]对应的值到rsi,rdx和rcx寄存器。
图 13 栈帧参数传递
3.3.3 类型转换
- 隐式类型转换
在存储32位的argc时自动进行位扩展,将高32位自动清零。
图 14 隐式位扩展
- 显式类型转换
在调用atoi的时候,将arg[4]的字符串地址传递给rdi寄存器,通过atoi函数将字符串转换成int形式。
图 15 atoi类型转换
3.3.4 算术操作
- 指针算术运算
通过指向地址的加减获取数组元素地址
图 16 地址加减运算
- 变量算术运算
通过32位加法运算addl实现循环变量i的自增,实现循环次数的控制。
图 17 局部变量运算
3.3.5 控制转移
- 判断传参个数
if(argc!=5)被编译为cmpl函数,通过条件码进行跳转判断,如果比较数与当前数相等,则跳转到.L2。
图 18 判断传参个数
- 判断循环终止条件
循环终止条件的控制变量i与9进行比较,控制进行十次循环,如果i小于等于9时继续进行循环,跳转到对应的.L4执行循环命令。
图 19 判断循环次数
3.3.6 函数操作
- 调用puts与exit函数以输出参数错误提示与异常终止程序。
图 20 调用puts与exit
- 调用printf函数以格式化输出三参数。
图 21 调用printf
- 调用atoi与sleep函数
将main函数中rdi保存的arg[4]字符串指针传递给atoi函数,并接收返回的暂停秒数保存在edi中。接下来再将经过隐式位扩展的edi参数传递给sleep函数,执行暂停程序。
图 22 调用atoi与sleep
- 调用getchar函数
循环结束后调用getchar()函数,等待用户输入一个字符后退出。
图 23 调用getchar
3.4 本章小结
本章分析出变量存储、条件控制与函数调用操作。程序将常量放入.rodata节,初始化全局变量放入.data节,通过标签定义和跳转等方式定义许多操作,为后序的汇编和链接生成可执行文件准备。
第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编过程是汇编器(as)将汇编语言转换成机器指令并生成可重定位目标文件的过程。其核心作用是通过汇编器将人类可读的汇编指令逐条翻译为计算机可直接执行的机器码,并生成与目标硬件平台兼容的二进制程序。
4.1.2 汇编的作用
1. 将汇编代码转换为机器指令
汇编器(如as)将每条汇编指令逐行转换为对应的二进制机器代码,生成计算机可直接识别的操作码(Opcode)。生成的.o文件是ELF格式的二进制文件,包含.text节、.data节等结构化数据。
2. 符号解析与重定位信息生成
汇编器会创建符号表(.symtab),记录程序中定义的函数和全局变量符号,以及引用的外部符号。汇编器分析代码中的外部引用,生成重定位条目。
生成可重定位目标文件
.o文件的ELF头包含平台信息、文件类型和节头数量。节头表(Section Header Table)描述各节的类型、偏移量及大小,如.rodata存储只读字符串,.bss预留未初始化变量的空间。
为链接阶段提供基础
每个.s文件经汇编后生成独立的.o文件,包含该模块的机器码和符号信息。链接器通过合并多个.o文件解决跨模块的符号引用。汇编阶段生成的地址是相对偏移量。
4.2 在Ubuntu下汇编的命令
图 24 汇编命令
图 25 生成的汇编文件
4.3 可重定位目标elf格式
4.3.1 生成elf格式文件
输入命令readelf -a hello.o分析elf文件格式:
图 26 readelf指令
4.3.2 elf头解析
图 27 elf头解析
Magic值是7f 45 4c 46确认是合法的ELF文件。类别是ELF64(64位文件格式),适用于x86-64架构。类型:REL(可重定位文件),说明这是编译器生成的中间目标文件(.o),尚未链接。入口点为0x0,符合可重定位文件特性,链接后才会分配实际入口地址。
程序头数量为0,这是因为可重定位文件无需程序头表。节头表位置起始于文件偏移1088字节,包含14个节头项,描述各节的属性。
4.3.3关键节解析
典型的ELF格式可重定位目标文件包含以下几个节:
表 2 ELF节解析
描述 | 作用 | 标志 | |
.text | 代码段 | 程序代码段 | 可分配、可执行 |
.rela.text | 重定位段 | 用于后续的重定位 | —— |
.data | 数据段 | 包含已初始化的全局变量和静态变量 | 可写、可分配 |
.bss | 包含未初始化数据 | 只读 | |
.rodata | 只读数据段 | 存储字符串常量 | 只读 |
.comment | 编译器版本信息 | —— | —— |
.note* | 包含安全属性 | —— | —— |
.*frame | 异常处理段 | 用于栈回溯 | —— |
.symtab | 符号节 | 包含全局符号和外部符号条目 | —— |
.*str* | 字符串表 | 存储符号名称与节名称字符串 | —— |
.rela.text节包含了八个重定位条目,涉及外部函数调用和只读数据引用,.rela.eh_frame节包含1个条目:用于异常处理帧的地址修正,关联.text节的起始位置。
图 28 重定位节
4.3.4符号表解析
第0条为起始占位符,无实际意义,类型为notype;
第1条标记源文件名为hello.c,是elf文件的源信息,类型为file;
第2、3条为标识.text节和.rodata节的节区符号;
第4条定义全局函数main,位于.text节,类型为函数;
第5-10条是未定义的外部符号,类型未明确指定。
图 29 符号表解析
4.4 Hello.o的结果解析
4.4.1反汇编命令
图 30 反汇编命令
图 31 生成的反汇编文件
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特是分支转移函数调用等。
4.4.2 反汇编解析
1. 文件基础信息
图 32 反汇编文件头
文件类型未elf64-x86-64,属于可重定位目标文件,由于尚未进入链接阶段,地址以相对地址的形式表示,main函数的地址为0x00000000000。
2. 初始化栈帧并进行传参
初始化栈帧,分配局部变量空间,符合标准函数调用约定。检查命令行参数数量(argc),若不足则进入错误处理流程.
图 33 反汇编函数入口
- 循环结构
通过两个跳转,进行循环条件判断与函数调用,使得延时打印操作成立。
图 34 反汇编循环
4.4.3 与编译产生的汇编语言比较
1. 符号表示差异
编译文件(.s)使用人类可读的汇编助记符,如 call puts@PLT,包含符号名称和标签。
反汇编文件每条指令前附加对应的机器码,如 e8 00 00 00 00,调用函数地址以虚拟地址形式表示,如 call 28 <main+0x28>。
2. 立即数表示
编译生成的文件中立即数用十进制表示,如 addq $24, %rax;而在反汇编文件中,立即数转换为16进制,如add $0x18,%rax,这符合机器码的二进制编码习惯。
3. 调试信息与元数据
编译文件包含 .cfi_* 指令,如 .cfi_def_cfa_offset 16,用于栈帧调试。反汇编文件调试信息被剥离,仅保留实际执行的指令。
4. 外部符号处理
字符串常量在编译文件中 .LC0 标签在反汇编中变为 lea 0x0(%rip),%rax,实际地址由 .rodata 节的重定位条目确定。
表 3 与第三章对比
特性 | 编译文件(.s) | 反汇编文件(.s) |
重定位信息 | 显式标注@PLT和.rela节 | 操作数全0, 依赖.rela.text重定位条目 |
跳转地址 | 使用标签和符号名 | 使用相对地址表示 |
调试元数据 | 包含.cfi_*指令和节声明 | 仅保留实际指令 无节声明 |
操作数格式 | 十进制立即数 | 十六进制立即数 |
4.5 本章小结
本章讨论了经过汇编之后生成的elf文件格式与反汇编生成的.s文件内容,并与hello.s进行了对比,了解汇编语言到机器语言之间的转换过程与差异分析。
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接是指将多个目标文件和库文件整合为一个可执行文件的过程,其核心作用是通过符号解析、地址分配与重定位,解决代码模块间的引用关系,生成可在计算机上直接运行的程序。链接过程是程序构建的关键步骤,它通过解决模块间的依赖关系,生成结构完整、逻辑正确的可执行文件,直接影响程序的运行效率和资源占用。
链接分为静态链接和动态链接两种类型:静态链接在编译时将库代码直接嵌入可执行文件,生成的文件独立但体积较大;动态链接在运行时加载共享库,可执行文件仅记录依赖关系,节省内存且便于更新。
5.1.2 链接的作用
- 符号解析
目标文件中可能存在未定义的符号。链接器在库文件或其他目标文件中查找符号定义,若未找到则报错。
- 地址重定位
目标文件中的代码和数据地址是局部偏移量,无法直接运行。链接器将所有模块的代码段(.text)、数据段(.data、.bss)合并到全局地址空间,并修正跳转指令和变量引用的绝对地址。函数调用指令call 0x0会被替换为实际内存地址。
- 库文件的处理
静态库直接嵌入代码,生成独立但冗余的可执行文件。动态库记录依赖路径,运行时由动态链接器加载共享库。
- 合并代码与数据段
链接器将多个目标文件的代码段合并为单一.text段,数据段合并为.data或.bss段,形成统一的内存布局。
- 生成可执行文件
最终文件包含操作系统所需的元信息(如ELF头、程序入口点_start),并支持调试符号(如-g选项)。
5.2 在Ubuntu下链接的命令
图 35 ubuntu链接命令
-dynamic-linker指定动态链接器路径,用于加载动态库;
crt1.o包含程序入口 _start,负责初始化并调用 main 函数;
crti.o 和 crtn.o处理全局构造;
链接标准 C 库(libc.so),提供 printf、exit 等函数。
5.3 可执行目标文件hello的格式
5.3.1 基本信息
图 36 hello格式分析
Magic:7f 45 4c 46 表示ELF格式标识;
Class:ELF64 表示64位文件;
Type:EXEC (Executable file) 表示可执行文件;
Machine:Advanced Micro Devices X86-64 表示x86-64架构;
Entry point:程序入口地址,此时不再是0;
Start of section headers:段表的起始偏移量;
段表起点:偏移量 0x34f8,共 27个段头,每个段头大小 64 字节。
5.3.2 段表信息
1. 代码与初始化段
表 4 代码段与初始化段信息
段名 | 类型 | 虚拟地址 | 文件偏移 | 大小 | 标志 | 作用 |
.text | PROGBITS | 0x4010f0 | 0x10f0 | 0xd8 (216) | AX | 主程序代码 |
.init | PROGBITS | 0x401000 | 0x1000 | 0x1b (27) | AX | 程序初始化代码 |
.fini | PROGBITS | 0x4011c8 | 0x11c8 | 0xd (13) | AX | 程序终止代码 |
2. 动态链接相关段
表 5 动态链接相关段信息
段名 | 类型 | 虚拟地址 | 文件偏移 | 大小 | 标志 | 作用 |
.plt | PROGBITS | 0x401020 | 0x1020 | 0x70 (112) | AX | 过程链接表 |
.plt.sec | PROGBITS | 0x401090 | 0x1090 | 0x60 (96) | AX | 安全增强的PLT段 |
.got.plt | PROGBITS | 0x404000 | 0x3000 | 0x48 (72) | WA | 全局偏移 |
.dynamic | DYNAMIC | 0x403e50 | 0x2e50 | 0x1a0 (416) | WA | 动态链接信息 |
3. 数据段
表 6 数据段信息
段名 | 类型 | 虚拟地址 | 文件偏移 | 大小 | 标志 | 作用 |
.data | PROGBITS | 0x404048 | 0x3048 | 0x4 (4) | WA | 已初始化的全局变量 |
.rodata | PROGBITS | 0x402000 | 0x2000 | 0x48 (72) | A | 只读数据 |
4. 调试与元信息段
表 7 调试段信息
段名 | 类型 | 虚拟地址 | 文件偏移 | 大小 | 标志 | 作用 |
.eh_frame | PROGBITS | 0x402048 | 0x2048 | 0xa0 (160) | A | 异常处理帧信息 |
.comment | PROGBITS | 0x0 | 0x304c | 0x2b (43) | MS | 编译器版本注释 |
.symtab | SYMTAB | 0x0 | 0x3078 | 0x270 (624) | 符号表 |
图 37 readelf查看段信息
5.3.3符号表信息
ELF文件包含两种符号表:.dynsym(动态符号表)记录动态链接所需的符号(如外部库函数),运行时由动态链接器解析。.symtab(静态符号表)包含所有静态符号信息(如本地函数、全局变量),通常用于调试和静态链接。
图 38 符号表信息
5.4 hello的虚拟地址空间
使用gdb/edb加载hello,查看本进程的虚拟地址空间各段信息:
通过与5.3对比,可以发现ELF文件中的节(Section)在进程虚拟地址空间中被映射为不同的段(Segment),具体对应关系如下。
5.4.1地址范围对齐
ELF节的地址,如 .text 的 0x4010f0,是链接时确定的虚拟地址,实际运行时若未启用PIE(位置无关可执行文件),地址保持一致。
内存段按页(4KB)对齐,例如 .text 段起始 0x401000,而ELF节 .text 的起始地址 0x4010f0 位于该段内。
5.4.2权限与标志的对应
.text 的 AX 标志对应内存段的 r-xp,但ELF未显式标记“可读”,因代码段默认可读。
.data 的 WA 标志对应内存段的 rw-p,但 .got 和 .got.plt 在ELF中标记为 WA,运行时需动态写入,因此权限一致。
5.4.3未映射的ELF节
.comment、.symtab 等节未映射到内存(地址为 0x0),因调试信息在运行时不需要加载,仅用于静态分析。
.bss 段未在 readelf -S 中显式列出,但内存中会分配未初始化数据空间。
5.5 链接的重定位过程分析
5.5.1符号解析
链接器遍历所有输入目标文件的符号表,匹配未解析符号。若符号未定义,链接器报错;若多定义,根据规则选择,将每个符号引用绑定到唯一的符号定义。
图 39 hello符号表信息
图 40 hello符号表解析
5.5.2重定位
相同类型的节合并为连续内存段,确定各符号的虚拟地址。
地址修正分为绝对地址修正与相对地址修正。绝对地址修正,如全局变量地址,直接替换为虚拟地址。相对地址修正,如 call 指令的操作数,根据目标地址与下条指令的偏移量计算。
链接器根据重定位类型(如 R_X86_64_PC32)计算修正值,写入目标文件的对应位置。
5.5.3hello与hello.o对比
特性 | hello.o(目标文件) | hello(可执行文件) |
外部符号引用 | 未解析的符号通过重定位条目标记 | 符号解析完成,通过PLT/GOT动态链接 |
入口点 | 无_start,仅包含用户代码 | 包含_start(程序入口)和初始化代码 |
重定位信息 | 存在.rela.text等重定位节 | 重定位已处理,仅保留动态链接相关的重定位 |
段合并 | 代码段独立 | 合并所有目标文件的段,并添加运行时库的 |
PLT/GOT | 无PLT/GOT | 生成PLT和GOT |
图 41 hello与hello.o对比
1. 文件类型与结构差异
hello.o 是可重定位目标文件,代码和数据的地址未最终确定,需要通过链接器处理。hello.o 仅包含基础节,未包含程序头表(Program Header Table),无法直接映射到内存。
hello 是可执行文件,已通过链接器完成符号解析和地址分配,可直接加载到内存执行。hello 新增程序头表,将节合并为内存段,并添加动态链接相关的节(如 .plt、.got、.dynamic)。
2. 符号解析与重定位
hello.o 的反汇编中,函数调用和全局变量引用的地址均为占位符 0x00,需要依赖重定位条目在链接时修正。
图 42 hello的符号解析
图 43 重定位节
hello 中的重定位完成,函数调用解析指向 PLT 条目,PLT 通过 GOT 跳转到动态库的实际地址。
图 44 hello中的函数跳转
数据引用修正:如 lea 0xec3(%rip),%rax 中的 0xec3 是 .rodata 段中字符串的实际偏移(0x402008)。
图 45 hello的数据引用
5.6 hello的执行流程
5.6.1断点信息
图 46 gdb断点信息
5.6.2调试信息
表 8 调用链与地址表
阶段 | 函数/子程序名 | 地址 |
动态链接初始化 | ld-linux-x86-64.so!_start | 0x00007ffff7fe3290 |
用户程序入口 | _start | 0x0000555555555100 |
程序初始化 | _init | 0x0000555555555000 |
用户主函数 | main | 0x0000555555555160 |
格式转换函数 | atoi@plt | 0x00005555555550d0 |
睡眠函数 | sleep@plt | 0x00005555555550f0 |
动态库调用 | printf@plt | 0x00005555555550b0 |
字符获取函数 | getchar@plt | 0x00005555555550c0 |
终止流程 | __GI_exit | 0x00007ffff7e0d0d0 |
当执行 ./hello 时,操作系统加载器负责将程序载入内存,并跳转到入口点 _start。该过程首先加载动态链接库,解析依赖项。然后初始化程序环境。
main 函数的调用链如下:检查 argc != 5 时,调用 printf 输出用法提示,随后调用 exit(1) 终止;调用 printf 打印格式化字符串(参数为 argv[1]、argv[2]、argv[3]);调用 atoi 将 argv[4] 转换为整数,传递给 sleep 函数;调用 getchar 等待用户输入。
return 0 触发 main 的退出逻辑,程序终止。main 返回后,控制权交还给 __libc_start_main,依次执行调用全局析构函数(如 .fini 段)和调用 _exit 系统调用(或 exit_group),释放资源并终止进程。
图 47 调用信息
5.7 Hello的动态链接分析
当程序调用一个由共享库图 48定义的函数时,由于编译器无法预测这时候函数的地 址是什么,因此这时,编译系统提供了延迟绑定的方法,将过程地址的绑定推迟到 第一次调用该过程时。通过 GOT 和过程链接表 PLT 的协作来解析函数的地址。在 加载时,动态链接器会重定位 GOT 中的每个条目,使它包含正确的绝对地址,而 PLT 中的每个函数负责调用不同函数。
程序加载时,所有动态函数的地址均指向对应的 PLT 条目:
printf@plt | 0x10b0 |
exit@plt | 0x10e0 |
sleep@plt | 0x10f0 |
getchar@plt | 0x10c0 |
图 49 动态链接之前
首次调用后,动态链接器解析函数地址后,GOT 条目被替换为实际函数地址:
printf@plt | 0x7ffff7c606f0 |
exit@plt | 0x7ffff7c455f0 |
sleep@plt | 0x7ffff7cea570 |
getchar@plt | 0x7ffff7c87ae0 |
图 50 动态链接之后
5.8 本章小结
本章通过分析可执行文件格式、链接后的虚拟地址空间,对于链接过程与执行流程有了更深入的了解。同时,对于edb与gdb的使用也更为了解。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
进程的就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。进程提供一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。进程还提供一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.1.12 进程的作用
进程是操作系统分配资源的载体。每个进程独占虚拟地址空间,防止其他进程非法访问其资源。通过多进程并发执行,操作系统可高效处理多任务。 进程将静态程序转化为动态执行实体,使操作系统统一管理不同任务的运行状态。
进程的独立性确保单个进程崩溃不会影响其他进程或系统稳定性。在当代操作系统中,进程作为线程的容器,允许多线程共享进程资源,同时实现更细粒度的并发控制。
6.2 简述壳Shell-bash的作用与处理流程
Shell 是一种命令行解释器, 其读取用户输入的字符串命令, 解释并且执行命令. 它是一种特殊的应用程序, 介于系统调用/库与应用程序之间, 其提供了运行其他程序的的接口.它可以是交互式的, 即读取用户输入的字符串;也可以是非交互式的, 即读取脚本文件并解释执行, 直至文件结束. 无论是在类 UNIX, Linux 系统, 还是 Windows, 有很多不同种类的 Shell: 如类 UNIX, Linux 系统上的 Bash, Zsh 等; Windows 系统上的 cmd, PowerShell 等.
图 51 UNIX中Shell所在的层次
bash的全名是Bourne Again Shell。最开始在Unix系统中流行的是sh,而bash作为sh的改进版本,提供了更加丰富的功能。一般来说,都推荐使用bash作为默认的Shell。
用户可以与 Shell 进行交互, 系统可以以多种方式启动运行 Shell. 根据用户输入与 Shell 的交互方式, Shell 可以分为交互式 Shell (interactive Shell)与非交互式 Shell:
交互式 Shell, 处理用户键入的命令, 并得到命令输出. 一般情况下, 通过与用户进行 IO 交互的 Shell, 都是交互式 Shell.
非交互式 Shell, 执行脚本文件命令, 直至文件结束时, 它也便退出了. 当运行 Shell 脚本时, 就启动了非交互式 Shell.
shell命令可以分为以下三类:
- 内建函数
shell的内建函数是自带的预先写好的,实现一定功能的程序。
- 可执行文件
可执行文件是shell之外的脚本,提供了使用者自定义的功能。Shell必须在系统中找到对应命令名的可执行文件,才能正确执行。我们可以用绝对路径来告诉Shell可执行文件所在的位置。如果用户只是给出了命令名,而没有给出准确的位置,那么Shell必须自行搜索一些特殊的位置,也就是所谓的默认路径。Shell会执行第一个名字和命令名相同的可执行文件。
- 别名
别名是给某个命令一个简称,以后在Shell中就可以通过这个简称来调用对应的命令。在Shell中,我们可以用alias来定义别名。
6.2.2 shell-bash的处理流程
Shell-Bash 的处理流程可以理解为一条命令从用户输入到最终执行的动态转化过程,其核心在于将人类可读的指令逐层拆解、扩展并转化为系统能执行的操作。当用户在终端输入一行命令后,Bash 会像精密的生产线一样依次完成多个阶段的处理。
首先,Bash 会读取输入内容,这取决于当前是交互模式还是脚本执行。交互模式下,用户敲击的字符会通过 Readline 库进入编辑缓冲区,支持行内编辑和历史命令调取;而非交互模式则直接逐行读取文件内容。随后进入解析阶段,Bash 根据空格、分号、管道符等元字符将命令拆解成单词(token),并识别命令类型。
接下来是扩展阶段,这是 Bash 最复杂的处理环节。首先进行大括号扩展,例如 {a,b}.txt 会展开为 a.txt b.txt,生成多个可能的路径组合。随后处理波浪号 ~,将其替换为用户的主目录路径。接着进行变量替换、命令替换(和数学运算,这些扩展可能嵌套执行,Bash 会按由内到外的顺序逐层解析。若命令中存在引号,单引号内的内容会跳过扩展,而双引号则允许变量和命令替换但保留其他字符的字面意义。
完成扩展后,Bash 根据环境变量 IFS对结果进行单词分割,将长字符串拆分为独立的参数。例如,变量扩展后的 "file name" 若未被引号包裹,可能被分割为 file 和 name 两个参数。最后进行路径扩展,扫描命令中的 *、? 等通配符,匹配实际存在的文件名。
进入执行阶段后,Bash 会判断命令类型:内置命令直接执行;外部程序则通过 fork() 创建子进程,并用 execve() 加载目标文件。对于管道命令(如 ls | grep test),左右两侧的命令会在不同子进程中并行执行,通过管道传递数据流。若脚本需要修改当前 Shell 环境,则需通过 source 命令或 . 符号执行,否则默认在子进程中运行以避免污染父环境。
整个过程还伴随错误检测,例如语法错误会直接中断执行,而命令未找到则会返回状态码。Bash 通过这种分层处理机制,既支持复杂的脚本逻辑,又能高效响应用户的实时指令,成为连接用户与操作系统的核心纽带。
图 52 shell处理流程
6.3 Hello的fork进程创建过程
当用户在终端输入 ./hello 学号 姓名 手机号 秒数 时,Shell首先解析命令参数,验证参数数量是否符合要求。若参数不足,程序会立即退出并提示用法错误。
Shell 调用 fork() 系统调用创建子进程。父进程(Shell)获得子进程 PID,继续监听用户输入,子进程 复制父进程的地址空间、文件描述符表等资源,但拥有独立的进程 ID(PID)和父进程 ID。写时复制(COW) 机制在此阶段生效,父子进程共享物理内存页,直到任一进程尝试修改数据时才会触发独立拷贝。
图 53 hello的fork进程创建过程
运行hello之后,父进程7306 fork了一个7335子进程。
6.4 Hello的execve过程
execve 过程将 hello 程序加载到进程中:内核首先验证可执行文件权限和格式,释放原进程用户态内存,根据 ELF 头将代码段、数据段映射到新虚拟地址空间,初始化堆栈并压入命令行参数和环境变量,设置程序计数器指向入口地址 _start,最后通过上下文切换跳转到 hello 的代码执行,完全替换原进程逻辑。
6.5 Hello的进程执行
图 54 hello进程执行流程
当用户发起程序运行指令./hello ……, shell 创建该进程,然后调用 execve() 执行程序。内核加载可执行文件建立PCB(进程控制块),分配虚拟地址空间,加载代码段、数据段、堆栈等。程序进入 main(),并在用户态运行。
在 Linux 中,每个进程由调度器管理。每个进程按优先级获得时间片,如果时间片用完且进程还未阻塞或退出,调度器会将其挂起,切换到其他进程。hello程序在执行 printf() 时一般不会耗尽时间片,因为 I/O 操作可能使其进入 阻塞态。每次 sleep() 会通过系统调用 nanosleep() 进入核心态,设置定时器并主动放弃 CPU;进程此时状态为 S(sleeping),直到定时器中断触发唤醒,再回到就绪态;再次被调度后进入运行态继续下一次循环。整个程序运行过程中会多次在用户态与核心态之间切换,关键过程如下:
表 9 用户态与核心态转换过程
操作 | 状态转换 | 说明 |
sleep() | 用户态 → 核心态 | 系统调用 nanosleep,设置定时器,进程阻塞 |
定时器中断 | 核心态 → 就绪态 | 定时到,内核唤醒进程,等待调度 |
printf() | 用户态 → 核心态 | 写标准输出,本质是 write 系统调用 |
按 Ctrl-C / Ctrl-Z | 核心态 | 捕获信号 SIGINT/SIGTSTP,内核处理信号 |
getchar() | 用户态 → 核心态 | 调用 read() 等待键盘输入,进程阻塞 |
用户按键 | 中断 → 核心态 | 键盘中断触发,数据传入进程继续执行 |
6.6 hello的异常与信号处理
6.6.1异常与信号
表 10 异常的类别
异常类别 | 触发原因 | 示例场景 | 处理机制 |
中断 | 异步外部事件 | 用户按下 Ctrl-C | 当前指令完成后,内核读取异常号并调用中断处理程序,结束后继续执行下一条指令 |
陷阱 | 有意的系统调用 | sleep(atoi(argv[4])) | 陷阱处理程序执行系统调用逻辑,控制返回到 sleep 后的指令 |
故障 | 可恢复错误 | 访问未映射的虚拟内存 | 若处理程序能修复,则重新执行故障指令;否则终止进程 |
终止 | 不可恢复错误 | 内存奇偶校验错误 | 直接终止进程,生成核心转储文件(core dump) |
表 11 可能的信号
异常情况 | 信号 | 信号编号 | 默认行为 | 说明 |
Ctrl-C | SIGINT | 2 | 终止进程 | 中断信号 用户请求终止程序 |
Ctrl-Z | SIGTSTP | 20 | 暂停进程 | 暂停程序运行 进程进入后台 |
回车 | 无信号 | - | - | 程序中的 getchar() 会读取它 |
kill PID | SIGTERM | 15 | 终止进程 | 手动终止某进程 |
kill -9 PID | SIGKILL | 9 | 强制终止进程 | 无法捕捉、阻止或处理 |
程序段错误 | SIGSEGV | 11 | 终止进程 | 内存访问越界等错误 |
6.6.2 hello的异常信号处理
1. ps 查看当前运行的进程,hello在后台运行时,ps查看对应的pid为8194.
图 55 ps命令
2. 按下ctrl+z后,输入jobs显示后台作业,发现hello已停止。
图 56 jobs命令
3. pstree查看进程树关系,可以看到hello和pstree都是bash的子进程。
图 57 pstree命令
- Fg将后台挂起的进程恢复至前台继续执行。
图 58 fg命令
- 输入ctrl_c直接杀死程序,ps查看无hello进程
图 59 ctrl c命令
- Kill 8180发送SIGTERM信号将hello挂起,ps查看发现进程仍存在;kill -9 8180发送SIGKILL,强制终止程序,进程被杀死。
图 60 kill命令
- 乱按并不影响程序执行。
图 61 乱按
6.7本章小结
本阐述了进程的核心概念及其在计算机系统中的重要作用。通过分析shell环境的工作原理和处理流程,揭示了命令行程序的执行机制。同时,本章还全面分析了程序执行期间可能触发的各类异常情况,包括中断、陷阱和故障等,并对相应的信号处理机制进行了详细说明,完整呈现了从进程创建、执行到异常处理的完整生命周期管理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1 四种地址的基本定义
逻辑地址是程序源代码中出现的地址,是 CPU 在执行指令时产生的地址。对用户程序来说,它就是“内存地址”。
线性地址是逻辑地址经过段机制(segmentation)转换而来,是一个统一的、连续的地址空间。现代 64 位系统基本将逻辑地址=线性地址。
虚拟地址是线性地址经过页表映射,转换成虚拟地址。实际上,现代操作系统直接将线性地址视为虚拟地址。
物理地址真正用于访问主存(RAM)的地址,是地址转换的最终结果,用户程序无法直接看到。
地址类型 | 定义 | 与 hello 的关系 |
逻辑地址 (Logical Address) | 程序代码中使用的地址,由 CPU 在执行时生成 | 程序中的变量、函数地址等,如 argv[i], i 等引用的地址 |
线性地址 (Linear Address) | 逻辑地址经过段机制转换得到的地址 | 对于 hello 编译时加了 -no-pie,地址固定,逻辑地址约等于线性地址 |
虚拟地址 (Virtual Address) | 线性地址经过页表映射,CPU 实际看到的地址 | 程序运行时由 MMU将其映射到物理地址 |
物理地址 (Physical Address) | 真实的内存硬件地址 | 操作系统控制,用户程序无法直接访问 |
图 62 四种地址
7.1.2 hello中的地址
图 63 hello中的地址
在这段代码中i 是栈上的局部变量,它的地址是逻辑地址。argv[1] 是从命令行传入的字符串地址,位于程序的堆栈段或 .data 段中。printf 是一个函数调用,其地址在代码段(.text)中。运行时这些地址都会转换为 虚拟地址,并通过页表机制转换为 物理地址。这些转换对程序员是不可见的,由操作系统和硬件自动完成。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在Intel的段式管理机制中,逻辑地址到线性地址的转换是通过段选择子+段内偏移的方式来实现的。逻辑地址并不是一个完整的内存地址,而是由段寄存器中的段选择子和指令中提供的偏移量共同组成。段选择子本质上是一个索引,指向一个段描述符,该描述符中记录了该段在内存中的基址、段界限以及一些访问权。当CPU访问一个逻辑地址时,会先读取段寄存器中保存的段选择子,并根据该选择子在GDT(全局描述符表)或LDT(局部描述符表)中找到对应的段描述符。随后,CPU将从段描述符中提取段基址,并将其与逻辑地址中的偏移相加,得到一个线性地址,即 LinearAddress = SegmentBase + Offset。这个线性地址是CPU后续进行分页转换或直接作为物理地址使用的中间地址。
图 64 段式管理
从逻辑地址到线性地址的转换体现了Intel架构对内存的层次化管理设计,段式机制不仅提供了地址隔离的功能,还支持多任务操作系统中不同任务或模块间的访问控制。虽然在现代64位系统中段式管理的角色被弱化,但了解这一过程对于理解x86架构的内存管理基础仍然至关重要。
7.3 Hello的线性地址到物理地址的变换-页式管理
在页式管理的内存管理机制下,程序中的线性地址到物理地址的变换过程可以分为三个主要步骤,尤其是在 x86-64 位架构下,它依赖于多级页表机制。
在基于页式管理的操作系统中,hello.c 程序中的线性地址需通过多级页表转换为物理地址,才能访问真实内存。以 x86-64 架构为例,CPU 使用48位的线性地址,通过四级页表逐级映射来完成转换。程序运行时,如执行 printf("Hello %s %s %s\n", argv[1], argv[2], argv[3]);,其中 argv[1] 等参数的地址存储于栈区,指向的是堆或栈中的字符串。当 CPU 访问 argv[1] 指向的地址时,会将该线性地址划分为:9位 PML4 索引、9位 PDPT 索引、9位 PD 索引、9位 PT 索引,以及12位页内偏移。通过查找页表,每一级页表项存储下一层页表的物理地址,最终在页表(PT)中找到线性页所属的物理页框。这个物理地址最终被送到内存控制器读取数据。
图 65 页式管理
通过页式管理,每个进程拥有独立的虚拟地址空间,有助于内存保护与隔离,同时通过页框映射支持虚拟内存机制,提升系统稳定性与内存利用率。
7.4 TLB与四级页表支持下的VA到PA的变换
在基于页式管理的现代操作系统中,虚拟地到物理地的转换不仅依赖多级页表,还需要借助 TLB来加速。64 位架构中,虚拟地址通常是 48 位,通过四级页映射到物理页框。具体流程是:CPU 访问一条指令或数据时,首先发出虚拟地址。CPU 的 MMU(会检查 TLB,看是否已缓存该虚拟页对应的物理页。如果 TLB 命中,则直接组合物理页框基址和页内偏移得到物理地址,无需查页表,大大加快访问速度;如果 TLB 未命中,CPU 则通过四级页表查找。线性地址的 48 位被拆成 4 级索引:PML4、PDPT、PD、PT,每级 9 位,最后的 12 位为页内偏移。系统首先用 CR3 寄存器中的页表基址访问 PML4,再逐级读取下一层页表的物理地址,直至找到最终页表中记录的物理页框基址。找到物理页框后,加上页内偏移得到最终物理地址,CPU 将此转换结果写入 TLB 以备下次快速访问。
通过 TLB 缓存和多级页表,系统在保证地址空间隔离、支持虚拟内存的同时,显著提高了地址转换效率,减少了访问延迟。整体上,TLB 是页式管理中不可或缺的重要组件,与四级页表共同构建高效、安全的虚拟内存系统。
图 66 TLB访问流程
7.5 三级Cache支持下的物理内存访问
在现代处理器中,为了弥补 CPU 与主存之间速度差距,通常使用多级高速缓存(Cache)机制来加速物理内存访问。在 x86-64 架构中,一般存在三级 Cache:L1、L2 和 L3。它们按速度从快到慢、容量从小到大依次排列,并与主存共同构成层次化存储体系。当 CPU 获取一个物理地址后,将按照 Cache 层级查找数据。
首先访问 L1 Cache,若命中则直接返回数据;若未命中则访问 L2,依此类推,直到 L3。如果三级 Cache 都未命中,才访问主存。L1 Cache 通常分为指令缓存和数据缓存,每个核心私有;L2 通常也是每个核心私有;L3 则是多个核心共享。
图 67 cache访问
以 hello.c 程序中 printf("Hello %s %s %s", ...) 的执行为例,当 argv[1] 的物理地址通过 TLB 和页表获得后,CPU 根据该地址首先访问 L1 Cache,若数据未命中,则转向 L2,再到 L3,最终可能访问主存 RAM。为了保持一致性,Cache 系统采用 MESI 等一致性协议。在数据写入时,还可能采用写直达(write-through)或写回策略。通过三级 Cache 的层级存储结构,大大减少了对主存的访问频率,降低了内存访问延迟,提高了程序执行效率,是现代高性能处理器架构不可或缺的组成部分。
7.6 hello进程fork时的内存映射
在 Linux 等现代操作系统中,hello 程序运行时会被加载为一个用户进程,其地址空间包括代码段、数据段、堆、栈和内存映射区域。当该进程调用 fork() 系统调用创建子进程时,内核并不会立即复制整个父进程的内存空间,而是采用写时复制技术实现高效内存共享。
具体来说,在 fork() 发生时,子进程获得一个与父进程几乎相同的虚拟地址空间映射结构,包括相同的页表和相同的虚拟地址,但这些页都被标记为“只读共享”,并由内核设置相关的 COW 标志位。这样,父子进程在 fork() 后实际上是共享相同的物理页框,避免了昂贵的大规模数据复制。
在 hello.c 中,父进程的代码段 .text、只读数据段,以及只读的共享库等在子进程中依旧共享,无需复制。只有当父进程或子进程试图写入某个页面时,CPU 在执行该写操作会触发页错误,内核捕获该异常后,会为触发写操作的进程分配一个新的物理页框,并将原始页面内容复制过去,从而完成“按需复制”。此后,修改只对当前进程可见,父子进程就此在该页上分离。这个机制兼顾了效率与独立性,是 Unix 进程模型中的一大亮点。
图 68 写时复制机制
此外,fork() 过程中,内核还会复制进程的内核态资源,但不会复制内存中的实际数据。进程映射关系可以通过 /proc/<pid>/maps 查看,从中可发现父子进程在刚 fork 后虚拟地址空间极其相似。总之,hello 程序执行 fork() 后,子进程获得与父进程一致的虚拟内存布局,借助 COW 实现高效的物理页共享,直到某一方尝试写操作,才触发实际的内存复制,确保地址空间的隔离性和系统的性能。
7.7 hello进程execve时的内存映射
在 Linux 操作系统中,hello.c 编译后的程序在通过 execve 系统调用被加载运行时,会经历一系列内存映射的初始化过程,构建出一个完整的用户空间地址布局。
execve 是加载可执行文件的关键系统调用,当用户输入命令运行 hello 程序时,shell 实际调用 execve 将原有进程空间清空,加载 hello 可执行文件的新程序映像。首先,内核会根据 ELF 格式解析程序头部信息,按需将各段映射到相应的虚拟地址空间中。例如,.text 段被映射为只读+可执行的内存区域,.data 段和 .bss 段被映射为可读写区域;同时,还会为栈空间分配一个固定范围的地址,并初始化程序参数 argc/argv/envp 等数据。堆区则从低地址部分通过 brk() 系统调用或后续 malloc 请求动态增长。
图 69 内存映射关系
此外,运行时所依赖的共享库也会被内核通过 mmap 映射进进程空间,形成多个动态链接库段。整个映射过程建立在页式管理机制之上,每个被映射的段实际都以页为单位,在页表中记录其虚拟地址到物理地址的映射关系。这个地址转换关系对于程序来说是透明的——hello 进程在访问如 printf、argv[1] 等内容时,仅使用虚拟地址,而实际的数据可能存在物理内存的任意位置。通过这种按需映射和懒加载机制,内核能够提高内存使用效率,同时为每个进程提供独立、安全的虚拟地址空间,避免进程间干扰。此机制也支持 Copy-on-Write 技术,在 fork 等系统调用中提高性能。
7.8 缺页故障与缺页中断处理
缺页故障(page fault)是虚拟存储系统中常见的一种异常事件,指的是程序在访问某个虚拟地址时,发现该地址对应的页并不在物理内存中,因而无法完成访问请求。为了继续执行程序,操作系统必须介入处理这个缺页事件。这个处理过程被称为缺页中断处理,是由硬件产生的中断请求引发的操作系统行为,涉及内核与内存管理系统的协同工作。
在现代操作系统中,进程所能访问的地址空间是虚拟的,操作系统通过页表将虚拟地址映射到物理地址。当程序访问一个虚拟地址时,硬件会通过页表查找其是否存在有效的物理页映射。如果发现该页不存在于当前内存中,硬件会触发一次缺页中断,将控制权交还给操作系统内核。
内核接收到缺页中断后,会首先判断此次缺页是否属于合法访问。操作系统需要查阅进程的虚拟内存描述信息,以确认该虚拟地址是否处于进程拥有的合法地址空间范围内。如果是非法访问,例如访问未被映射的空洞地址或违反权限限制,操作系统将向进程发送一个段错误信号,终止该进程或由用户自定义信号处理函数处理。
图 70 linux缺页处理
若地址合法,则操作系统开始查找该页应对应的内容来源。常见的情况包括该页尚未被分配物理页帧,或该页原本存在但被换出到磁盘。操作系统接下来将进行一系列内存管理工作:
图 71 缺页处理程序
整个过程通常在内核态完成,用户态程序对此几乎无感知,只是出现了一个略微延迟的内存访问。缺页处理是虚拟内存系统实现内存抽象、延迟分配、按需加载和程序隔离的核心机制之一,也是提高内存使用效率的重要手段。
7.9动态存储分配管理
动态存储分配管理是操作系统中一项核心的内存管理技术,主要用于在程序运行过程中,根据其不断变化的内存需求,在堆区或其他动态区域为其分配和回收内存。与静态分配和栈式内存分配不同,动态存储分配允许程序在运行时以任意大小请求内存,并在不再需要时将其释放。这种机制为程序设计提供了极大的灵活性,但也带来了碎片化、分配效率和安全性等方面的挑战。
在用户程序请求动态内存时,这一请求会被传递到运行时库,再由其向操作系统申请合适大小的内存块。在较小的请求下,运行时库往往会从已经管理的一块较大的内存区域中划分出一部分,而不会每次都直接与操作系统交互,从而减少系统调用的开销。只有在本地内存池不足时,运行时库才会通过系统调用向内核请求更多的内存。
操作系统或运行时库通常使用一种或多种内存分配算法来管理动态存储区,这些算法的目标是高效利用内存、减少碎片并提高分配速度。常见的分配策略包括首次适配(First Fit)、最佳适配(Best Fit)、最差适配(Worst Fit)等。在首次适配策略中,系统从低地址开始查找第一个足够大的空闲块,而最佳适配策略则遍历所有空闲块以寻找最接近请求大小的块,以试图最小化剩余空间。在实际应用中,由于最佳适配开销较大,操作系统或运行时库通常会使用某种近似方法或结合多个策略以平衡效率与碎片控制。
分配内存后,系统必须能够跟踪和管理这些内存块。这通常依赖于在每个块中维护一些元数据,例如块的大小、状态(已分配或空闲)以及相邻块的位置指针等。在释放内存时,系统会检查被释放的内存块,并尝试与其前后相邻的空闲块合并,以减少外部碎片的产生。此外,为了加快内存回收和再次分配的速度,系统还可能使用空闲链表、空闲树、分级管理等技术进行优化。
动态内存管理不可避免地引入了安全与正确性问题,例如内存泄漏、悬空指针、越界访问或双重释放,这些错误通常隐蔽且难以调试。因此,在某些语言或框架中,引入了自动垃圾回收机制或引用计数技术,以减轻程序员的负担并提高系统稳定性。此外,内核和某些运行时还会使用内存池、页保护或地址随机化技术增强安全性,防止非法访问或利用。
动态存储分配不仅在应用层广泛使用,在操作系统内核中也同样重要。内核模块、驱动程序、文件缓存、进程控制块等都依赖于高效且稳定的内存分配机制。为满足内核的实时性和空间效率要求,内核通常使用更精细和高度优化的分配器,如 slab 分配器、buddy 分配系统、SLUB、SLOB 等不同策略,以适应不同规模和频率的分配请求。
7.10本章小结
本章主要介绍了hello的存储管理。包括从虚拟地址到物理地址的变换过程,TLB与四级页表支持下的VA到PA的变换,运行hello进程fork和execve时的内存映射,缺页故障与缺页中断处理,最后介绍了动态存储的分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.1.1 设备模型化:文件抽象
在 Linux 操作系统中,I/O 设备的管理体现出高度的抽象化与统一性,最核心的理念是“一切皆文件”。这一模型化方式将各种硬件设备都抽象为文件系统中的一种特殊文件,称为设备文件,通常位于 /dev 目录下。每个设备文件由主设备和次设备号唯一标识,其中主设备号表示设备驱动程序的编号,而次设备号用于区分同一类驱动程序管理的不同设备。用户或应用程序通过标准文件操作接口来访问设备,而不需要直接操作硬件,这种抽象极大地简化了设备的使用与管理。
图 72 linux中的/dev目录
设备文件一般分为两种:块特殊文件是一个能存储固定大小块信息的设备,它支持以固定大小的块,扇区或群集读取和写入数据。每个块都有自己的物理地址。通常块的大小在 512 - 65536 之间。所有传输的信息都会以连续的块为单位。块设备的基本特征是每个块都较为对立,能够独立的进行读写。常见的块设备有 硬盘、蓝光光盘、USB 盘与字符设备相比,块设备通常需要较少的引脚。
图 73 不同种类IO
块特殊文件的缺点基于给定固态存储器的块设备比基于相同类型的存储器的字节寻址要慢一些,因为必须在块的开头开始读取或写入。所以,要读取该块的任何部分,必须寻找到该块的开始,读取整个块,如果不使用该块,则将其丢弃。要写入块的一部分,必须寻找到块的开始,将整个块读入内存,修改数据,再次寻找到块的开头处,然后将整个块写回设备。
另一类 I/O 设备是字符特殊文件。字符设备以字符为单位发送或接收一个字符流,而不考虑任何块结构。字符设备是不可寻址的,也没有任何寻道操作。常见的字符设备有打印机、网络设备、鼠标、以及大多数与磁盘不同的设备。
每个设备特殊文件都会和设备驱动相关联。每个驱动程序都通过一个主设备号来标识。如果一个驱动支持多个设备的话,此时会在主设备的后面新加一个次设备号来标识。主设备号和次设备号共同确定了唯一的驱动设备。
8.1.2 设备管理:unix io接口
在具体的 I/O 操作机制方面,Linux 通过 Unix 风格的 I/O 接口实现对设备的管理,支持阻塞与非阻塞模式、同步与异步操作、字符设备与块设备的差异化处理,以及多种 I/O 复用机制。例如,当一个进程使用 read 读取终端或串口设备时,若数据未准备好,系统会根据是否设置了阻塞标志来决定是让进程休眠等待数据,还是立即返回错误码。而使用 select、poll、epoll 等接口,进程可以同时监听多个设备文件的可读写状态,从而实现高并发的 I/O 处理。
Linux 还支持内存映射 I/O(mmap)、直接 I/O(Direct I/O)和异步 I/O(AIO)等高级功能,以适应不同性能场景的需求。内存映射 I/O 允许将设备缓冲区映射到用户空间,提高访问效率;而 AIO 允许提交读写请求后立即返回,由内核在后台完成操作,并通过回调或信号通知进程处理结果,这对于高性能服务器程序尤其重要。此外,Linux 提供了统一的 ioctl 接口用于设备的控制操作,它通过一个命令码和参数,允许用户空间对设备进行定制化的控制,如改变串口波特率、设置磁盘缓存策略等。
8.2 简述Unix IO接口及其函数
Unix I/O使得所有的输入和输出都能以一种统一且一致的方式来执行:
- 打开文件。应用程序会通过要求内核打开对应的文件,来宣告它想要访问一个I/O设备,而内核会返回一个描述符,用来在后续操作中标识这个文件。
- 改变当前的文件位置。对于每个打开的文件,内核保持一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
- 读写文件。一个读操作就是从文件文件复制n > 0个字节到内存,从当前文件位置k开始,然后将k增加到k + n。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
- open函数:int open(char *filename,int flags,mode_t node);
将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件,此外,flags参数也可以是一个或多更多位掩码的或,为写提供一些额外的指示。mode参数指定了新文件的访问权限位。
- close函数:int close(int fd);
关闭一个打开的文件,当关闭已关闭的描述符会出错。
read函数:ssize_t read(int fd,void *buf,size_t n);
从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
- write函数:ssize_t write(int fd,const void *buf,size_t n);
从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
- lseek函数:off_t lseek(int fd, off_t offset, int whence);
应用程序显示地修改当前文件的位置。
8.3 printf的实现分析
printf 是格式化输出函数,它的核心功能是将数据格式化为字符串并输出到标准输出。printf 是一个变参函数,所以它首先会解析格式化字符串。解析过程中会识别出格式说明符及其修饰。根据格式说明符,从参数列表中提取相应类型的参数。调用对应的格式化函数将每个参数转换成字符串。将格式化后的字符串写入一个缓冲区。缓冲区可能有行缓、全缓冲或无缓冲等策略。最终调用系统调用(如 write 或 fwrite)将缓冲区内容写入标准输出设备。
8.4 getchar的实现分析
getchar 是一个简单的字符输入函数,核心功能是从标准输入(通常是键盘)读取一个字符。getchar() 通常是宏或函数封装,实质等价于 getc(stdin),从标准输入流 stdin 中读取一个字符。stdin 通常是行缓冲:用户输入内容后按回车,整行才进入缓冲区。缓冲区由运行时库管理。实际从输入设备读取内容时,会通过系统调用(如 read)向内核请求输入数据。如果缓冲区还有数据,则直接返回字符;如果没有数据,就等待用户输入。
8.5本章小结
本章探讨了linux系统中IO设备的实现方式与管理方式,并深入了解了linux中的unixio接口及其对应函数作用及实现方法。在此基础上,对于printf与getchar的实现方式与底层调用方式做出了分析。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
- Hello生命周期
- 程序生成阶段
程序生成阶段主要经历了预处理-编译-汇编-链接的流程。预处理通过处理宏定义、条件编译、头文件展开,生成纯净的C代码。编译将预处理后的代码转换为汇编语言,完成语法分析、语义优化和中间代码生成。 汇编将汇编代码转换为机器指令,生成可重定位目标文件,包含符号表和重定位信息。 链接合并多个目标文件与库,解析符号引用,生成可执行文件。
- 程序执行流程
用户输入./hello后,Shell通过fork()创建子进程,复制父进程上下文,启用写时复制(COW)优化内存使用。子进程获得独立PID,父进程通过wait()回收资源。execve()清空原进程空间,按ELF头映射.text、.data、.bss到虚拟地址空间,初始化栈和堆。动态链接库通过ld-linux.so加载到共享内存区域。printf触发write系统调用,sleep通过nanosleep进入阻塞态,定时器中断唤醒后重回就绪队列。 Ctrl-C发送SIGINT终止进程,Ctrl-Z发送SIGTSTP挂起进程,可通过fg恢复或kill终止。
- 资源管理与异常处理
地址发生逻辑地址→线性地址(段式管理)→虚拟地址→物理地址(四级页表+TLB缓存)的转变。访问未加载页时触发缺页中断,内核分配物理页框,换入数据并更新页表。malloc/free通过动态内存分配器管理堆空间,使用首次适配或伙伴系统减少碎片。 mmap将文件或匿名内存映射到进程地址空间,支持高效I/O操作。
- 实验感悟
计算机系统是一个高度复杂且精妙的生态系统,其各个层次紧密协作,从硬件底层的电路设计、指令集架构,到操作系统对资源的管理调度,再到上层应用程序的功能实现,每一个环节都不可或缺。硬件与软件的协同发展推动着整个计算机系统不断演进,而其中的设计理念充满了对性能、可靠性、兼容性的权衡与取舍。例如,内存管理中页式管理和段式管理的结合,既满足了程序对连续地址空间的需求,又实现了高效的内存分配与保护;进程调度算法的设计需要在公平性和效率之间寻求平衡,以确保系统整体的稳定运行。这种层层递进、相互关联的架构设计,让我深刻体会到计算机系统的复杂性和精妙之处。
附件
├──hello //代码文件夹
│ ├── hello // 可执行文件
│ ├── hello.c // c代码文件
│ ├── hello.elf //hello.o的elf信息
│ ├── hello.i //预处理文件
│ ├── hello.o //可重定位文件
│ ├── hello.s //汇编文件
│ ├── hello_dis.s //hello.o反汇编文件
│ ├── hello_asm.txt //hello的反汇编文件
参考文献
[1] Randal E.Bryant David R.O'Hallaron.深入理解计算机系统(第三版).机械工业出版社,2016.
[2] 程序详细编译过程(预处理、编译、汇编、链接) - 知乎
[3] Linux下 可视化 反汇编工具 EDB 基本操作知识_linuxedb教程-CSDN博客
[4] Bash编程入门-1:Shell与Bash - 知乎
[5] Shell简介:Bash的功能与解释过程(一) Shell简介 - 知乎
[7] 段页式访存——逻辑地址到线性地址的转换_movl 8(%ebp), %eax-CSDN博客
[8] 段页式访存——线性地址到物理地址的转换_线性地址转为物理地址例题-CSDN博客
[9] Linux mem 1.1 用户态进程空间的创建 --- execve() 详解 - pwl999 - 博客园