
计算机系统原理
大作业
题 目 ++程序人生++ ++-Hello's P2P++
专 业 ++工科试验班(++ ++AI++ ++先进技术领军班)++
学 号 ++2024113212++
班 级 ++24Q0301++
学 生 ++张泽晰++
指 导 教 师 ++史先俊++
计算学部
2025 年9月
摘 要
本论文以"程序人生-Hello's P2P"为主题,通过一个简单的Hello程序实例,深入探讨计算机系统的工作原理,旨在揭示程序从源代码到进程执行的完整生命周期。论文主要内容包括Hello程序的预处理、编译、汇编、链接、进程管理、存储管理及IO管理等关键阶段,详细分析了每个阶段的概念、作用及在Ubuntu环境下的具体实现过程。研究方法结合理论分析与实践操作,利用GCC工具链、调试工具(如GDB)和系统命令,逐步解析程序转换和系统交互机制。成果方面,论文成功演示了Hello程序如何经历编译链接成为可执行文件,并通过操作系统进行进程创建、调度、内存映射和异常处理,体现了计算机系统各组件(如编译器、链接器、内核、CPU)的协同工作。理论意义在于深化对计算机系统底层机制(如虚拟内存、进程隔离、动态链接)的理解,实际意义则为学习操作系统和编译原理提供了实践框架,具有重要的教育价值。
关键词:计算机系统;程序生命周期;编译与链接;进程管理;内存映射;
(摘要0分,缺失-1分, 根据内容精彩称都酌情加分0-1分 )
目 录
++[第1章 概述. - 5 -](#第1章 概述. - 5 -)++
++[1.1 Hello简介. - 5 -](#1.1 Hello简介. - 5 -)++
++[1.2 环境与工具. - 6 -](#1.2 环境与工具. - 6 -)++
++[1.3 中间结果. - 7 -](#1.3 中间结果. - 7 -)++
++[1.4 本章小结. - 7 -](#1.4 本章小结. - 7 -)++
++[第2章 预处理. - 9 -](#第2章 预处理. - 9 -)++
++[2.1 预处理的概念与作用. - 9 -](#2.1 预处理的概念与作用. - 9 -)++
++[2.2在Ubuntu下预处理的命令. - 10 -](#2.2在Ubuntu下预处理的命令. - 10 -)++
++[2.3 Hello的预处理结果解析. - 11 -](#2.3 Hello的预处理结果解析. - 11 -)++
++[2.4 本章小结. - 13 -](#2.4 本章小结. - 13 -)++
++[第3章 编译. - 14 -](#第3章 编译. - 14 -)++
++[3.1 编译的概念与作用. - 14 -](#3.1 编译的概念与作用. - 14 -)++
++[3.2 在Ubuntu下编译的命令. - 15 -](#3.2 在Ubuntu下编译的命令. - 15 -)++
++[3.3 Hello的编译结果解析. - 15 -](#3.3 Hello的编译结果解析. - 15 -)++
++[3.4 本章小结. - 23 -](#3.4 本章小结. - 23 -)++
++[第4章 汇编. - 24 -](#第4章 汇编. - 24 -)++
++[4.1 汇编的概念与作用. - 24 -](#4.1 汇编的概念与作用. - 24 -)++
++[4.2 在Ubuntu下汇编的命令. - 25 -](#4.2 在Ubuntu下汇编的命令. - 25 -)++
++[4.3 可重定位目标elf格式. - 25 -](#4.3 可重定位目标elf格式. - 25 -)++
++[4.4 Hello.o的结果解析. - 29 -](#4.4 Hello.o的结果解析. - 29 -)++
++[4.5 本章小结. - 32 -](#4.5 本章小结. - 32 -)++
++[第5章 链接. - 34 -](#第5章 链接. - 34 -)++
++[5.1 链接的概念与作用. - 34 -](#5.1 链接的概念与作用. - 34 -)++
++[5.2 在Ubuntu下链接的命令. - 35 -](#5.2 在Ubuntu下链接的命令. - 35 -)++
++[5.3 可执行目标文件hello的格式. - 35 -](#5.3 可执行目标文件hello的格式. - 35 -)++
++[5.4 hello的虚拟地址空间. - 38 -](#5.4 hello的虚拟地址空间. - 38 -)++
++[5.5 链接的重定位过程分析. - 40 -](#5.5 链接的重定位过程分析. - 40 -)++
++[5.6 hello的执行流程. - 44 -](#5.6 hello的执行流程. - 44 -)++
++[5.7 Hello的动态链接分析. - 49 -](#5.7 Hello的动态链接分析. - 49 -)++
++[5.8 本章小结. - 49 -](#5.8 本章小结. - 49 -)++
++[第6章 hello进程管理. - 50 -](#第6章 hello进程管理. - 50 -)++
++[6.1 进程的概念与作用. - 50 -](#6.1 进程的概念与作用. - 50 -)++
++[6.2 简述壳Shell-bash的作用与处理流程. - 51 -](#6.2 简述壳Shell-bash的作用与处理流程. - 51 -)++
++[6.3 Hello的fork进程创建过程. - 53 -](#6.3 Hello的fork进程创建过程. - 53 -)++
++[6.4 Hello的execve过程. - 53 -](#6.4 Hello的execve过程. - 53 -)++
++[6.5 Hello的进程执行. - 53 -](#6.5 Hello的进程执行. - 53 -)++
++[6.6 hello的异常与信号处理. - 53 -](#6.6 hello的异常与信号处理. - 53 -)++
++[6.7本章小结. - 53 -](#6.7本章小结. - 53 -)++
++[第7章 hello的存储管理. - 55 -](#第7章 hello的存储管理. - 55 -)++
++[7.1 hello的存储器地址空间. - 55 -](#7.1 hello的存储器地址空间. - 55 -)++
++[7.2 Intel逻辑地址到线性地址的变换-段式管理. - 55 -](#7.2 Intel逻辑地址到线性地址的变换-段式管理. - 55 -)++
++[7.3 Hello的线性地址到物理地址的变换-页式管理. - 55 -](#7.3 Hello的线性地址到物理地址的变换-页式管理. - 55 -)++
++[7.4 TLB与四级页表支持下的VA到PA的变换. - 55 -](#7.4 TLB与四级页表支持下的VA到PA的变换. - 55 -)++
++[7.5 三级Cache支持下的物理内存访问. - 55 -](#7.5 三级Cache支持下的物理内存访问. - 55 -)++
++[7.6 hello进程fork时的内存映射. - 55 -](#7.6 hello进程fork时的内存映射. - 55 -)++
++[7.7 hello进程execve时的内存映射. - 55 -](#7.7 hello进程execve时的内存映射. - 55 -)++
++[7.8 缺页故障与缺页中断处理. - 55 -](#7.8 缺页故障与缺页中断处理. - 55 -)++
++[7.9动态存储分配管理. - 55 -](#7.9动态存储分配管理. - 55 -)++
++[7.10本章小结. - 56 -](#7.10本章小结. - 56 -)++
++[第8章 hello的IO管理. - 57 -](#第8章 hello的IO管理. - 57 -)++
++[8.1 Linux的IO设备管理方法. - 57 -](#8.1 Linux的IO设备管理方法. - 57 -)++
++[8.2 简述Unix IO接口及其函数. - 57 -](#8.2 简述Unix IO接口及其函数. - 57 -)++
++[8.3 printf的实现分析. - 58 -](#8.3 printf的实现分析. - 58 -)++
++[8.4 getchar的实现分析. - 58 -](#8.4 getchar的实现分析. - 58 -)++
++[8.5本章小结. - 59 -](#8.5本章小结. - 59 -)++
++[结论. - 59 -](#结论. - 59 -)++
++[附件. - 60 -](#附件. - 60 -)++
++[参考文献. - 61 -](#参考文献. - 61 -)++
第1章 概述
1.1 Hello简介
1.1.1 P2P ( From Program to Process ):从程序到进程
①程序编写与编译:
Hello最初作为源代码文件hello.c(Program)被程序员创建。随后,它经历编译过程:
预处理:处理宏定义、头文件包含等,生成预处理后的代码(hello.i)。
编译:将C代码编译成汇编代码(hello.s)。
汇编:将汇编代码转换成机器代码(目标文件hello.o)。
链接:将目标文件与库函数链接,生成可执行文件(hello.out)。
②进程创建与加载:
在Shell(如Bash)中,用户输入命令运行Hello程序。操作系统(OS)的进程管理模块介入:
fork():OS调用fork系统调用,创建一个新进程(子进程),复制父进程(Shell)的上下文。
execve():子进程调用execve系统调用,加载Hello可执行文件到内存,替换当前进程的地址空间。
mmap():OS可能使用mmap系统调用将可执行文件映射到进程的虚拟地址空间,建立内存映射。
③进程执行:
OS为Hello进程分配CPU时间片,调度它运行。CPU执行取指、译码、执行循环,可能利用流水线技术提高效率。
存储管理:OS与MMU(内存管理单元)协作加速地址转换和数据访问,输入VA(虚拟地址)后,根据VA中的VPN(虚拟页号)去TLB(快表)中查询PPN(物理页号)。
若命中则PPN与PPO(物理页偏移直接继承VA中的VPO(虚拟页偏移))结合形成PA(物理地址),CPU将PA发送给L1、L2、L3 Cache(缓存),若数据在高速缓存中(缓存命中),直接返回给CPU,速度极快;若数据不在缓存中(缓存未命中),必须从主内存中加载,这会慢得多。
若不命中则会根据VA中的VPN和PTBR(页表基址寄存器)中的PTB(页表基址)在页表中寻找,一共分为四级页表,若能命中则PPN和PPO结合形成PA,之后同上;若不能命中则触发缺页故障,更新页表后再重新执行本条指令。
I/O管理:OS处理Hello的输入输出操作(如键盘输入、屏幕显示),通过设备驱动和中断机制实现软硬结合。
1.1.2 020 ( From Zero-0 to Zero-0 ):从创建到销毁
①从零开始(进程创建):
Hello进程从"零"状态开始,通过fork和execve从无到有地被创建。进程控制块被初始化,分配资源如内存、文件描述符。
②生命周期:
Hello进程在硬件(CPU、RAM、I/O设备)上执行,享受OS提供的服务:时间片分配、信号处理(如处理Ctrl+C)、内存管理等。
整个过程体现了计算机系统的协同工作:编译器、链接器、OS内核、CPU架构等。
③到零结束(进程终止):
Hello程序执行完毕后,进程正常终止。OS负责"收尸":
回收所有分配的资源(如内存、打开的文件)。
清除进程表项,释放进程控制块。
进程状态彻底回归"零",不留任何痕迹。
1.2 环境与工具
硬件环境(虚拟机):
CPU(处理器):13th Gen Intel(R) Core(TM) i9-13900H 3.0GHz(4核)
RAM(内存):4GB
HDD(机械硬盘):20GB
软件环境:Vmware 17pro;Ubuntu 20.04 LTS 64位;CodeBlocks 64位;
开发与调试工具:cpp,cc1,as,ld,readelf,objump,edb,gcc等工具
1.3 中间结果
|---------------|---------------------------------------------------------------------------------------------------|
| 中间结果文件的名字 | 文件的作用 |
| hello.i | 预处理器生成的hello.i是一个'纯净'的C代码文件,不包含任何预处理指令,可以直接交给编译器进行语法分析和代码生成。 |
| hello.s | 编译器将预处理后的C代码(hello.i)翻译成的汇编语言文件hello.s,包含了与源代码等效的、面向特定CPU架构的低级指令,是连接高级语言与机器码的桥梁,可交由汇编器进一步处理 |
| hello.o | 汇编器对汇编代码文件(hello.s)进行汇编处理后生成的可重定位目标文件 hello.o,其中包含机器指令、数据以及符号表等信息,但尚未进行最终地址解析与库链接,是链接器生成可执行文件的基础。 |
| hello | 链接器将目标文件(hello.o)及所需的库文件进行链接、重定位后生成的最终可执行文件 hello.out,其中包含完整的机器代码与运行时信息,可直接在终端执行。 |
| hello.asm | objdump根据可执行文件(hello)生成的反汇编文件 |
1.4 本章小结
本章作为整个报告的开篇,对 Hello 程序的生命周期进行了宏观概述。首先介绍了程序的两个核心视角:P2P(From Program to Process)展示了 Hello 从源代码到可执行进程的转变过程,涵盖了编译、链接、加载和执行四个关键阶段;020(From Zero-0 to Zero-0)则描绘了进程从无到有再到无的完整生命周期,强调了操作系统在资源分配与回收中的重要作用。其次,详述了实验环境配置,包括基于 VMware 的 Ubuntu 20.04 虚拟机环境,以及 cpp、gcc、readelf、objdump 等关键开发与分析工具。最后,列举了程序构建过程中生成的中间文件(hello.i、hello.s、hello.o 等),为后续章节的深入分析奠定了基础。本章通过概览性描述,建立了对程序执行全过程的初步理解,为后续章节对各阶段的详细剖析提供了框架支撑,体现了计算机系统各组件协同工作的整体性。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
预处理由预处理器负责执行。在GCC编译器中,预处理器通常是cpp。预处理器的操作对象是原始的C语言源文件(如hello.c),处理结果是一个纯C语言文本文件(通常扩展名为.i),其中不再包含任何预处理指令。
预处理指令均以井号#开头,例如#include, #define, #ifdef等。预处理器会识别并执行这些指令,但它本身并不理解C语言的语法(如变量、函数声明),其工作本质是文本替换和文件合并。
2.1.2 预处理的作用
预处理的作用是丰富和净化源代码,为后续的编译阶段做好准备。其主要作用体现在以下几个方面:
①头文件包含
作用:这是预处理最核心的功能之一。#include指令会将指定头文件(如stdio.h)的全部内容原封不动地插入到该指令所在的位置。
目的:将函数声明、宏定义、类型定义等必要的信息引入到当前源文件中。例如,Hello程序中#include <stdio.h>使得printf函数的声明被包含进来,这样编译器在后续阶段才能知道printf的存在及其参数类型。
②宏展开
作用:预处理器会查找所有由#define定义的宏,并进行简单的文本替换。
目的:提高代码的可读性和可维护性,或实现一些简单的函数功能。
③条件编译
作用:根据预定义的条件,决定哪些代码块会被包含进最终的.i文件,哪些会被忽略。
目的:使得同一份源代码可以在不同的编译环境(如不同的操作系统、不同的调试模式)下生成不同的程序。
指令:#if, #ifdef, #ifndef, #elif, #else, #endif。
④注释删除
作用:预处理器会移除源代码中的所有注释(//和/* ... */)。
目的:净化代码,减少编译器的负担,因为编译器不需要再处理注释。
2.2在Ubuntu下预处理的命令

这是原来的C代码,仅仅25行。



在hello.C中只有#include指令,预处理器cpp会将指令所指定头文件(stdio.h,unistd.h,stdlib.h)的全部内容原封不动地插入到该指令所在的位置,这使得hello.i激增到3000多行。通过截图展示hello.i中printf,sleep,getchar函数的位置间接展示预处理过程。
2.3 Hello的预处理结果解析


hello.i共3000多行,在main函数代码出现之前的大段代码源自于的头文件<stdio.h> <unistd.h> <stdlib.h> 的依次展开。
预处理过程中,#include指令的作用是把指定的头文件的内容包含到源文件中,具体来说:当预处理器遇到#include时,它会在系统的头文件路径下查找对应的文件,一般在/usr/include目录下,然后把头文件中的内容复制到源文件中。头文件中可能还有其他的#include指令,这些头文件也会被递归地展开到源文件中。
预处理器不会对头文件中的内容做任何计算或转换,只是简单地复制和替换。
2.4 本章小结
在C语言编译过程中,预处理是第一个重要阶段,由预处理器(GCC中的cpp)负责执行。预处理器读取原始的C语言源文件(如hello.c),处理所有以#开头的预处理指令,包括#include、#define、#ifdef等,生成一个纯C语言的文本文件(通常扩展名为.i)。这个文件不再包含任何预处理指令,而是将指令转换为实际的C代码。预处理器的工作本质是文本替换和文件合并,它并不理解C语言的语法结构,仅执行简单的文本操作。
预处理的主要作用体现在四个方面:首先是头文件包含,#include指令会将指定头文件的全部内容插入到指令所在位置,引入函数声明、宏定义和类型定义等必要信息;其次是宏展开,预处理器查找所有#define定义的宏并进行文本替换;第三是条件编译,根据预定义的条件决定哪些代码块被包含进最终文件,使同一份源代码适应不同的编译环境;最后是注释删除。
在Ubuntu系统中,可使用命令"cpp hello.c > hello.i"或"gcc -E hello.c -o hello.i"进行预处理。以Hello程序为例,原始hello.c仅有25行,经过预处理后生成的hello.i激增至3000多行,这是因为#include指令将头文件stdio.h、unistd.h和stdlib.h的内容依次展开并插入到源文件中。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
3.1.1****编译的概念
从 .i 文件到 .s 文件的编译阶段,其核心概念是编译器将预处理后的、纯净的高级语言源代码(.i文件),经过严格的语法语义分析和多层次的优化,最终翻译成与特定目标处理器架构对应的、人类可读的汇编指令序列(.s文件)的过程,本质上是实现从与机器无关的高级逻辑到与机器相关的低级表示的关键翻译。
3.1.2 编译的作用
此阶段是编译器将高级语言逻辑"落地"到具体机器架构的桥梁,其主要作用包括:
①语法和语义分析
语法分析:检查 .i文件中的代码是否符合高级语言的语法规则(如括号匹配、语句结构)。
语义分析:进行更深入的逻辑检查,如类型匹配、函数调用参数检查、变量作用域验证等。如果代码有逻辑错误(如用整数指针指向字符串),大部分错误会在此阶段被捕获并报告。
②生成中间代码与优化
编译器内部通常会先将高级语言代码转换成一种与机器无关的中间表示。在此基础上,编译器会执行大量与机器无关的优化,例如:
常量传播:x = 3 * 4;-> x = 12;
死代码消除:删除永远不会执行到的代码。
公共子表达式:提取出公共表达式,减少计算次数
复杂指令简化:用>>代替/,用<<代替*等
代码移动:将内循环里不断计算但始终不变的值挪到循环外。
循环展开:每次内循环多进行几次计算。
函数内联:将小函数调用直接展开。
...
③代码生成与目标相关优化
这是本阶段的最终任务:将优化后的中间表示映射到目标CPU的指令集,生成汇编代码。
在此过程中,会进行与机器相关的优化,例如:
指令选择:选择最高效的机器指令序列来实现某个操作。
寄存器分配:决定在有限的CPU物理寄存器中,哪些变量应存放在寄存器中(最快),哪些需溢出到内存中。这是影响性能的关键步骤。
指令调度:重排指令顺序,以充分利用现代CPU的流水线,减少停顿。
3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

逐行解释编译如下:
|--------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------|-------------------------------------------------------|
| 汇编 ||| 伪代码 | 注释 |
| | .file | hello.i | 汇编器处理的文件名叫做hello.i ||
| | .text | | 声明后续的代码和数据应该放入.text节 ||
| | .section | .rodata | 只读数据节 ||
| | .align 8 | | 强制要求下一条指令或数据的起始内存地址按8字节对齐 ||
| .LC0: | | | 局部变量标签0 ||
| | .string | \347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \346\211\213\346\234\272\345\217\267 \347\247\222\346\225\260\357\274\201 | "用法: Hello 学号 姓名 手机号 秒数!\n" ||
| .LC1: | | | 局部变量标签1 ||
| | .string | Hello %s %s %s\n | "Hello %s %s %s\n" ||
| | .text | | 代码段 ||
| | .globl | main | main是一个全局符号 | 全局符号声明 |
| | .type | main, @function | main是一个函数 | 符号类型声明 |
| main: | | | 主函数 ||
| .LFB6: | | | 函数开始 ||
| | .cfi_startproc || 调用框架信息指令 ||
| | pushq | %rbp | rbp压栈 | 保存旧的基指针 |
| | .cfi_def_cfa_offset 16 || 定义规范帧地址的偏移量。 | 因为pushq %rbp将8字节压栈,加上调用时压入的8字节返回地址,当前栈顶距原始帧基址16字节。 |
| | .cfi_offset 6, -16 || 保存寄存器6(rbp)在调用前的值相对于CFA(调用前的栈顶地址)偏移-16处。 ||
| | movq | %rsp, %rbp | rbp=rsp | rsp始终指向栈顶,赋值以后rbp作为当前栈帧基址保持不变 |
| | .cfi_def_cfa_register 6 || 现在使用寄存器6(rbp)作为计算CFA的基址寄存器。 ||
| | subq | 32, %rsp | rsp-=32 | 在栈上分配32字节空间(栈向下增长,越向下地址越低) |
| | movl | %edi, -20(%rbp) | M\[rbp-20\]=edi | 将寄存器edi中的值(第一个参数,即argc)存储在栈上rbp-20的位置 |
| | movq | %rsi, -32(%rbp) | M\[rbp-32\]=rsi | 将寄存器rsi中的值(第二个参数,即argv)存储在栈上rbp-32的位置 |
| | cmpl | 5, -20(%rbp) | 比较M[rbp-20]与5 | 比较argc与5 |
| | je | .L2 | if(argc==5)跳转到L2 ||
| | leaq | .LC0(%rip), %rdi | rdi="用法: Hello 学号 姓名 手机号 秒数!\n" | 将LC0处储存的局部变量赋值给寄存器rdi(第一个参数) |
| | call | puts@PLT | 调用打印函数 | 输入参数为rdi="用法: Hello 学号 姓名 手机号 秒数!\n" |
| | movl | 1, %edi | edi=1 | |
| | call | exit@PLT | 调用exit函数 | 输入参数为edi=1 |
| .L2: | | | 局部跳转标签2 ||
| | movl | 0, -4(%rbp) | M[rbp-4]=0 | 栈上rbp-4的位置的值为0,,即M[rbp-4]是i |
| | jmp | .L3 | 跳转到L3 ||
| .L4: | | | 局部跳转标签4 ||
| | movq | -32(%rbp), %rax | rax=M[rbp-32] | 将栈上rbp-32的位置的值argv存储在寄存器rax中 |
| | addq | 24, %rax | rax+=24 | |
| | movq | (%rax), %rcx | rcx=M\[rax\] | 将栈上rax位置(寄存器rax中的值作为地址)的值(argv\[3\])存储在寄存器rcx中(第四个参数) |
| | movq | -32(%rbp), %rax | rax=M\[rbp-32\] | |
| | addq | 16, %rax | rax+=16 | |
| | movq | (%rax), %rdx | rdx=M[rax] | rdx=argv[2](第三个参数) |
| | movq | -32(%rbp), %rax | rax=M[rbp-32] | |
| | addq | 8, %rax | rax+=8 | |
| | movq | (%rax), %rax | rax=M\[rax\] | rax=argv\[1\] |
| | movq | %rax, %rsi | rsi=rax | rsi=argv\[1\](第二个参数) |
| | leaq | .LC1(%rip), %rdi | rdi="Hello %s %s %s\\n"(第一个参数) ||
| | movl | 0, %eax | eax=0 | |
| | call | printf@PLT | 调用打印函数 | 输入上述四个参数 |
| | movq | -32(%rbp), %rax | rax=M[rbp-32] | 将栈上rbp-32的位置的值argv存储在寄存器rax中 |
| | addq | 32, %rax | rax+=32 | |
| | movq | (%rax), %rax | rax=M\[rax\] | rax=argv\[4\] |
| | movq | %rax, %rdi | rdi=rax | rdi=argv\[4\](第一个参数) |
| | call | atoi@PLT | 调用字符转数字函数 | 输入一个参数 |
| | movl | %eax, %edi | edi=eax | 将函数返回结果(秒数)存储在edi(第一个参数)中 |
| | call | sleep@PLT | 调用sleep函数 | 输入一个参数 |
| | addl | 1, -4(%rbp) | M[rbp-4]+1 | i++ |
| .L3: | | | 局部跳转标签3 ||
| | cmpl | 9, -4(%rbp) | 比较M\[rbp-4\]与9 | 比较i与9 |
| | jle | .L4 | if(i\<=9)跳转到L4 ||
| | call | getchar@PLT | 调用getchar函数 | |
| | movl | 0, %eax | eax=0 | |
| | leave | | 函数结束 ||
| | .cfi_def_cfa 7, 8 || 函数结束 ||
| | ret | | 函数结束 ||
| | .cfi_endproc || 函数结束 ||
3.3.1 数据
3.3.1.1 常量
常量均存放在只读数据节(.rodata)里,在本程序中有:
①.LC0:.string \347\224\250\346\263\225: Hello \345\255\246\345\217\267\345\247 \223\345\220\215 \346\211\213\346\234\272\345\217\267\347\247\222\346\225\260\ 357\274\201,即"用法: Hello 学号 姓名 手机号 秒数!\n",后来放在rdi里,为调用打印函数做铺垫。
②.LC1: .string Hello %s %s %s\n,后来放在rdi里,为调用打印函数做铺垫
3.3.1.2 局部变量
非static的局部变量存放在栈里,在本程序中有:
①int argc:代表输入参数的个数,刚开始放在寄存器edi里,后来又放在栈里rbp-20的位置中,为后续与5做比较奠定基础。
②char *argv[]:输入的参数都存入这个数组,它的元素分别是(此处说得并不严谨,详解请看3.3.6):
argv[0]:程序名称
argv[1]:学号
argv[2]:姓名
argv[3]:手机号
argv[4]:秒数
argv的基址刚开始放在寄存器rsi里,后来又放在栈里rbp-32的位置中,再后来又放在寄存器rax中,通过rax依次+24 +16 +8访问argv[1],argv[2],argv[3],为调用打印函数做铺垫,通过rax+32访问argv[4],为调用sleep函数做铺垫。
③int i:循环变量,从汇编的上下文推断应该是存储在栈中rbp-4处。
3.3.2 赋值
本程序涉及到的赋值就是第一次循环时的i=0,对应于汇编中的movl $0, -4(%rbp),即给栈中rbp-4的位置赋值为0。
3.3.3 类型转换
本程序涉及到的类型转换只有隐式类型转换 ,发生在sleep(atoi(argv[4]));这一行,atoi函数的返回类型值是int,而sleep函数的输入类型应该是unsigned int,这行发生了int到unsigned int的隐式类型转换。
3.3.4 算数操作
本程序涉及到的算数操作只有每次循环中的i++,对应于汇编中的addl $1, -4(%rbp),即栈中rbp-4的位置的值+1。
3.3.5 关系操作
①if(argc!=5):比较argc与5,如果argc不等于5,就打印"用法: Hello 学号 姓名 手机号 秒数!\n"。对应于汇编中的cmpl $5, -20(%rbp)。
②i<10:比较i与10,如果i<=9循环继续,否则停止循环。对应于汇编中的cmpl $9, -4(%rbp)。
3.3.6 数组/指针/结构体操作
本程序涉及到的数组/指针只有char *argv[],这是一个字符指针的数组,输入的参数都存入这个数组,它的元素(每个元素都是一个字符指针,指向字符串的首地址)分别是:
argv[0]:指向"程序名称"的指针
argv[1]:指向"学号"的指针
argv[2]:指向"姓名"的指针
argv[3]:指向"手机号"的指针
argv[4]:指向"秒数"的指针
char *argv[]实际上可以视为一个"字符串"数组,毕竟字符指针可以被视为字符串,它的每个元素都是字符串。
argv的基址刚开始放在寄存器rsi里,后来又放在栈里rbp-32的位置中,再后来又放在寄存器rax中,通过rax依次+24 +16 +8访问argv[1],argv[2],argv[3],为调用打印函数做铺垫,通过rax+32访问argv[4],为调用sleep函数做铺垫。
3.3.7 控制转移
①if(argc!=5){
printf("用法: Hello 学号 姓名 手机号 秒数!\n ");
exit(1);
}
比较argc与5,如果argc不等于5,就打印"用法: Hello 学号 姓名 手机号 秒数!\n"并退出。对应于汇编中的:
|---|------|------------------|----------------------------------|---------------------------------------|
| 汇编 ||| 伪代码 | 注释 |
| | cmpl | 5, -20(%rbp) | 比较M\[rbp-20\]与5 | 比较argc与5 |
| | je | .L2 | if(argc==5)跳转到L2 ||
| | leaq | .LC0(%rip), %rdi | rdi="用法: Hello 学号 姓名 手机号 秒数!\\n" | 将LC0处储存的局部变量赋值给寄存器rdi(第一个参数) |
| | call | puts@PLT | 调用打印函数 | 输入参数为rdi="用法: Hello 学号 姓名 手机号 秒数!\\n" |
| | movl | 1, %edi | edi=1 | |
| | call | exit@PLT | 调用exit函数 | 输入参数为edi=1 |
②for(i=0;i<10;i++){
printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);
sleep(atoi(argv[4]));
}
循环打印十次"Hello 学号 姓名 手机号 \n",每次间隔"秒数"秒。对应于汇编中的:
|------|-------|------------------|-----------------|-------------------------------------------------------|
| 汇编 ||| 伪代码 | 注释 |
| .L2: | | | 局部跳转标签2 ||
| | movl | 0, -4(%rbp) | M\[rbp-4\]=0 | 栈上rbp-4的位置的值为0,,即M\[rbp-4\]是i |
| | jmp | .L3 | 跳转到L3 ||
| .L4: | | | 局部跳转标签4 ||
| | movq | -32(%rbp), %rax | rax=M\[rbp-32\] | 将栈上rbp-32的位置的值argv存储在寄存器rax中 |
| | addq | 24, %rax | rax+=24 | |
| | movq | (%rax), %rcx | rcx=M[rax] | 将栈上rax位置(寄存器rax中的值作为地址)的值(argv[3])存储在寄存器rcx中(第四个参数) |
| | movq | -32(%rbp), %rax | rax=M[rbp-32] | |
| | addq | 16, %rax | rax+=16 | |
| | movq | (%rax), %rdx | rdx=M\[rax\] | rdx=argv\[2\](第三个参数) |
| | movq | -32(%rbp), %rax | rax=M\[rbp-32\] | |
| | addq | 8, %rax | rax+=8 | |
| | movq | (%rax), %rax | rax=M[rax] | rax=argv[1] |
| | movq | %rax, %rsi | rsi=rax | rsi=argv[1](第二个参数) |
| | leaq | .LC1(%rip), %rdi | rdi="Hello %s %s %s\n"(第一个参数) ||
| | movl | 0, %eax | eax=0 | |
| | call | printf@PLT | 调用打印函数 | 输入上述四个参数 |
| | movq | -32(%rbp), %rax | rax=M\[rbp-32\] | 将栈上rbp-32的位置的值argv存储在寄存器rax中 |
| | addq | 32, %rax | rax+=32 | |
| | movq | (%rax), %rax | rax=M[rax] | rax=argv[4] |
| | movq | %rax, %rdi | rdi=rax | rdi=argv[4](第一个参数) |
| | call | atoi@PLT | 调用字符转数字函数 | 输入一个参数 |
| | movl | %eax, %edi | edi=eax | 将函数返回结果(秒数)存储在edi(第一个参数)中 |
| | call | sleep@PLT | 调用sleep函数 | 输入一个参数 |
| | addl | 1, -4(%rbp) | M\[rbp-4\]+1 | i++ |
| .L3: | | | 局部跳转标签3 ||
| | cmpl | 9, -4(%rbp) | 比较M[rbp-4]与9 | 比较i与9 |
| | jle | .L4 | if(i<=9)跳转到L4 ||
| | call | getchar@PLT | 调用getchar函数 | |
| | movl | $0, %eax | eax=0 | |
| | leave | | 函数结束 ||
| | .cfi_def_cfa 7, 8 || 函数结束 ||
| | ret | | 函数结束 ||
| | .cfi_endproc || 函数结束 ||
3.3.8 函数操作
①main函数
参数传递:该函数的参数为int argc,,char*argv[]。具体参数传递地址和值都在前面阐述过。
函数调用:从汇编中看,运行程序时直接调用main函数。
局部变量:使用了局部变量i用于for循环,使用了局部变量char*argv[]用于打印函数和sleep函数。具体局部变量的地址和值都在前面阐述过。
函数返回:对应于汇编中的
|---|-------|---|-----------------------------|
| 汇编 ||| 解释 |
| | leave | | 恢复栈帧 |
| | .cfi_def_cfa 7, 8 || 告诉调试器/异常处理器:CFA现在 = rsp + 8 |
| | ret | | 弹出返回地址 |
| | .cfi_endproc || 标记函数结束 |
② printf函数
参数传递:该函数调用了两次。第一次调用参数为:"用法: Hello 学号 姓名 手机号 秒数!\n"(初始时在.LC0,后来放进寄存器rdi中),第二次调用参数为"Hello %s %s %s\n" (初始时在.LC1,后来放进寄存器rdi中),argv[1],argv[2] ,argv[3] (初始时分别在栈中rbp+24, +16, +8处,后来分别放进寄存器rsi,rdx,rcx中)。
函数调用:该函数调用了两次,通过使用call内部指令调用语句进行函数调用,并且将要调用的函数地址数据写入栈中,然后自动跳转到这个调用函数内部。两次执行效果分别时是打印"用法: Hello 学号 姓名 手机号 秒数!\n"和打印"用法: Hello 学号 姓名 手机号 \n"。
局部变量:argv[1],argv[2] ,argv[3]分别对应于"学号""姓名""手机号"。
③ exit函数
参数传递:调用参数为:1(放进寄存器edi中)。
函数调用:通过使用call内部指令调用语句进行函数调用,然后自动跳转到这个调用函数内部。
④ atoi函数
参数传递:调用参数为:argv[4](初始时在栈中rbp+32处,后来放进寄存器rdi中),函数返回值存储在寄存器eax中。
函数调用:通过使用call内部指令调用语句进行函数调用,并且将要调用的函数地址数据写入栈中,然后自动跳转到这个调用函数内部。执行结果是将字符串类型的argv[4](秒数)转换为int类型的argv[4]。
局部变量:argv[4]对应于"秒数"。
⑤sleep函数
参数传递:调用参数已经被转为int类型的argv[4](初始时在寄存器eax中,后来放进寄存器edi中)。
函数调用:通过使用call内部指令调用语句进行函数调用,并且将要调用的函数地址数据写入栈中,然后自动跳转到这个调用函数内部。执行结果是睡眠argv[4](秒数)秒。
局部变量:argv[4]对应于"秒数"。
⑥getchar函数
函数调用:通过使用call内部指令调用语句进行函数调用,并且将要调用的函数地址数据写入栈中,然后自动跳转到这个调用函数内部。执行结果是从标准输入(通常是键盘)读取下一个可用的字符,但由于它的返回值并没有变量来存储,所以在终端上没什么可视的效果。
3.4 本章小结
本章详细探讨了编译阶段的概念、作用及具体实现过程。编译作为程序构建的关键环节,将预处理后的高级语言代码(.i文件)转换为面向特定硬件架构的汇编代码(.s文件)。其主要作用包括语法语义分析、中间代码优化及目标代码生成,确保程序逻辑正确性并提升执行效率。通过Ubuntu环境下的编译命令(如/usr/lib/ gcc/x86_64-linux-gnu/9/cc1 hello.i -o hello.s),我们生成了Hello程序的汇编代码,并逐行解析了其指令结构、数据存储、控制流及函数调用机制。这一过程揭示了高级语言到底层汇编的映射关系,凸显了编译器在代码优化和硬件适配中的核心作用,为后续汇编和链接阶段奠定了基础。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
4.1.3 汇编的概念
从 .s 文件到 .o 文件的汇编阶段,其核心概念是汇编器(Assembler)将编译器生成的、人类可读的汇编语言指令序列(.s文件),逐行、一对一地翻译成对应目标处理器能够直接识别和执行的机器语言指令(二进制编码),并生成包含代码、数据及链接元信息的可重定位目标文件(.o文件)的过程。这是将符号化的助记符指令彻底"固化"为底层硬件指令的关键步骤。
4.1.4 汇编的作用
此阶段是将汇编语言"落地"为机器码的最终翻译器,其主要作用包括:
① 指令翻译与编码
这是汇编器的核心任务。它读取每一条汇编指令,根据目标CPU的指令集架构(ISA)手册,将其翻译成对应的二进制机器码。
② 数据与地址的初步解析
数据段生成:将汇编代码中定义的数据(如字符串常量"用法: Hello 学号 姓名 手机号 秒数!\n ")分配到特定的节(如.rodata),并计算其初始大小与值。
符号标记:对代码中的标签(如函数名、跳转目标)进行记录,生成符号表。此时符号的地址通常是相对于本文件开头的偏移量或未定值,需要在链接阶段最终确定。
③ 生成可重定位目标文件hello.o
汇编器的输出不是一个完整的程序,而是一个可重定位目标文件(hello.o文件)。这个有固定的格式ELF,该文件大致分为三个部分:
ELF header:记录文件的各种信息,如section(节)的数量,每个section的大小等。
sections:各种不同种类的的节,比如:
机器指令节(.text):存放已翻译好的二进制代码。
数据节(.data, .rodata, .bss等):存放初始化及未初始化的数据。
符号表(.sumtab):记录本文件定义和引用的所有符号(如函数名、变量名)及其属性。
重定位表(.rela.text,.rala.data等):标记出所有在链接时需要被修正的地址(例如,调用外部函数printf的指令地址目前是0,链接时需要填入真实地址)。这为后续的链接阶段做好了准备。
section header table(节头表):一个列表,里面的每个条目都是一个section header(节头),除了序号为0的section header以外每个section header都对应一个section,section header中记录着section的各种信息,如名字,大小等。
4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式
4.3.1ELF header(ELF头)

|--------------------------------------------------|----------------------------------|-------------------------|---------------------|--------|----------------------------|
| Magic: | 7f 45 4c 46 | 02 | 01 | 01 | 00 00 00 00 00 00 00 00 00 |
| 魔数,用来确定文件类型。操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确就会拒绝加载 | 分别对应ASCII码中的DEL控制符、'E'、'L'、以及'F' | ELF文件类型 0x1 32位 0x2 64位 | 字节序 0x1 小端法 0x2 大端法 | ELF版本号 | 无意义,填充用 |
|-----------------------------------|-------------------------------|------------------------------|
| 字段名称 | 值 | 解释 |
| 类别 | ELF64 | 64位 |
| 数据 | 2 位码,小端序 (little endian) | 小端法 |
| Version | 1 (current) | |
| OS/ABI | UNIX - System V | |
| ABI 版本 | 0 | |
| 类型 | REL (可重定位文件) | 文件类型是.o(可重定位目标文件) |
| 系统架构 | Advanced Micro Devices X86-64 | |
| 版本 | 0x1 | |
| 入口点地址 | 0x0 | |
| 程序头起点 | 0 (bytes into file) | |
| Start of section headers | 1192 (bytes into file) | section header table的基址是1192 |
| 标志 | 0x0 | |
| Size of this header | 64 (bytes) | ELF header的大小是64 bytes |
| Size of program headers | 0 (bytes) | |
| Number of program header | 0 | |
| Size of section headers | 64 (bytes) | 每个section header的大小是64 bytes |
| Number of section headers | 13 | 有13个section header |
| Section header string table index | 12 | |
4.3.2 Sections header table**(** 节头表 )

因为在汇编时每个section的起始地址都是0,所以只能根据Sections header table中各个section的偏移量来确定他们之间的位置关系,如下表
|-------|-----------------|------------------------------|------------------------|
| 号 | 名称 | 偏移量 (16 进制 ) | 大小( 16 进制) |
| 1 | .text | 00000040 | 0000000000000099 |
| 3 | .data | 000000d9 | 0000000000000000 |
| 4 | .bss | 000000d9 | 0000000000000000 |
| 5 | .rodata | 000000e0 | 0000000000000040 |
| 6 | .comment | 00000120 | 000000000000002c |
| 7 | .note.GNU-stack | 0000014C | 0000000000000000 |
| 8 | .eh_frame | 00000150 | 0000000000000038 |
| 10 | .symtab | 00000188 | 0000000000000198 |
| 11 | .strtab | 00000320 | 0000000000000048 |
| 2 | .rela.text | 00000368 | 00000000000000c0 |
| 9 | .rela.eh_frame | 00000428 | 0000000000000018 |
| 12 | .shstrtab | 00000440 | 0000000000000061 |
4.3.3Sections( 节 )
①.text(代码节)
详细分析请见4.4
②.data(数据节)
.data里存储着已初始化的(非const)全局变量和已经初始化的static局部变量。因为在本程序中没有这两种变量,所以.data大小为0
③.bss
.bss里存储着未初始化的(非const)全局变量和已经初始化的static局部变量。因为在本程序中没有这两种变量,所以.bss大小为0
④.rodata(只读数据节)
.rodata里存储着常量。在本程序中常量有"用法: Hello 学号 姓名 手机号 秒数!\n"和"Hello %s %s %s\n",和用objdump查看的结果相同

⑤.symtab(符号表)
符号表以"符号"的形式记录了程序中全局变量和函数,分为全局符号(默认情况),外部符号(extern全局变量/函数),本地/局部符号(static全局变量/函数)。在本程序中没有全局变量,只有普通的函数,所以符号表中记录了全局符号:main,exit,printf,atoi,sleep,getchar。

⑥.rela.text(可重定位代码)
.rela.text节是一个.text节中需要重定位的符号的列表。重定位:顾名思义,是重新确定位置(地址)的意思。为什么某些元素需要重定位呢?因为编译器在编译单个源文件时,会生成该文件对应的目标文件。当代码中引用其他模块的函数(如 printf)或全局变量时,编译器无法知道 这些符号在最终进程内存空间中的绝对地址,甚至不知道其他模块在哪里。于是,编译器只能先用一个临时值(通常是0或一个相对偏移)占位,并生成一条重定位条目 记录下这个占位的位置和需要修正的符号(调用本地函数自然不需要这样),这些重定位条目就会形成重定位节。当链接器把这个目标文件和其他文件链接时,就根据重定位节进行重定位。如图:

以第一行可重定位条目为例,解释重定位的计算过程:
|--------------|--------------|------------------------|------------------|-------------|
| 偏移量 | 信息 | 类型 | 符号值 | 符号名称 + 加数 |
| 000000000018 | 000500000002 | R_X86_64_PC32 (PC相对地址) | 0000000000000000 | .rodata - 4 |
看它对应的hello.o中的反汇编:

机器语言中操作数是00 00 00 00,汇编语言中是0x0(rip),这明显不是操作数的最终地址,因为汇编器在汇编时不知道这个字符串在最终的可执行文件中的位置(链接时才知道)所以只能先用0填充,并用可重定位条目记录它的信息方便链接器进行重定位。
先计算 运行时虚拟地址(用到本符号的指令的虚拟地址)=main的地址+偏移量
通过readelf -s hello | grep -E 'main'查看可执行文件中main的地址,如下图:

则运行时虚拟地址(用到本符号的指令的虚拟地址)=main的地址+偏移量=0x401176+0x000018=0x40118e
再计算 相对地址(操作数)=符号在最终的可执行文件中的虚拟地址-运行时虚拟地址(用到本符号的指令的虚拟地址)+加数
通过objdump -d hello可以看到重定位后的可执行文件的反汇编中对应的此指令:

得到符号在最终的可执行文件中的虚拟地址=0x402008
则 相对地址(操作数)=0x402008-0x40118e+(-0x000004)=0x000e76,这正好对应反汇编中内存地址的偏移,因为采用小端法,在机器语言中就是76 0e 00 00.
4.4 Hello.o的结果解析


|----|----------------------|------------------------|---------------------------------------|
| | 机器语言 | 汇编语言 | 注释 |
| 00 | 55 | push %rbp | rbp压栈,保存旧栈帧 |
| 01 | 48 89 e5 | mov %rsp,%rbp | rbp=rsp;//rsp指向栈顶 |
| 04 | 48 83 ec 20 | sub 0x20,%rsp | rsp-=0x20;//开辟了32个字节的栈空间 |
| 08 | 89 7d ec | mov %edi,-0x14(%rbp) | M\[rbp-0x14\]=edi;//栈中rbp-20的地方存了argc |
| 0b | 48 89 75 e0 | mov %rsi,-0x20(%rbp) | M\[rbp-0x20\]=edi;//栈中rbp-32的地方存了argv |
| 0f | 83 7d ec 05 | cmpl 0x5,-0x14(%rbp) | 比较argc与5 |
| 13 | 74 16 | je 2b <main+0x2b> | if(argc==5)跳转到2b |
| 15 | 48 8d 3d 00 00 00 00 | lea 0x0(%rip),%rdi | rdi="用法: Hello 学号 姓名 手机号 秒数!\n" |
| 1c | e8 00 00 00 00 | callq 21 <main+0x21> | 调用打印函数 |
| 21 | bf 01 00 00 00 | mov 0x1,%edi | edi=1 |
| 26 | e8 00 00 00 00 | callq 2b \
| 32 | eb 53 | jmp 87 <main+0x87> | 跳转到87 |
| 34 | 48 8b 45 e0 | mov -0x20(%rbp),%rax | rcx=argv[3] |
| 38 | 48 83 c0 18 | add 0x18,%rax | rcx=argv\[3\] |
| 3c | 48 8b 08 | mov (%rax),%rcx | rcx=argv\[3\] |
| 3f | 48 8b 45 e0 | mov -0x20(%rbp),%rax | rdx=argv\[2\] |
| 43 | 48 83 c0 10 | add 0x10,%rax | rdx=argv[2] |
| 47 | 48 8b 10 | mov (%rax),%rdx | rdx=argv[2] |
| 4a | 48 8b 45 e0 | mov -0x20(%rbp),%rax | rsi=argv[3] |
| 4e | 48 83 c0 08 | add 0x8,%rax | rsi=argv\[3\] |
| 52 | 48 8b 00 | mov (%rax),%rax | rsi=argv\[3\] |
| 55 | 48 89 c6 | mov %rax,%rsi | rsi=argv\[3\] |
| 58 | 48 8d 3d 00 00 00 00 | lea 0x0(%rip),%rdi | eax="Hello %s %s %s\\n" |
| 5f | b8 00 00 00 00 | mov 0x0,%eax | eax="Hello %s %s %s\n" |
| 64 | e8 00 00 00 00 | callq 69 <main+0x69> | 调用打印函数,输入以上四个参数 |
| 69 | 48 8b 45 e0 | mov -0x20(%rbp),%rax | rdi=argv[4] |
| 6d | 48 83 c0 20 | add 0x20,%rax | rdi=argv\[4\] |
| 71 | 48 8b 00 | mov (%rax),%rax | rdi=argv\[4\] |
| 74 | 48 89 c7 | mov %rax,%rdi | rdi=argv\[4\] |
| 77 | e8 00 00 00 00 | callq 7c \
| 87 | 83 7d fc 09 | cmpl 0x9,-0x4(%rbp) | 比较i与9 |
| 8b | 7e a7 | jle 34 \
| 97 | c9 | leaveq | 结束 |
| 98 | c3 | retq | 结束 |
注:标红的字体是与第3章的 hello.s不同的地方,具体来说就是.s中的十进制数在反汇编中都转换成相等的十六进制数,分支转移和函数调用。
4.4.1 机器语言的构成及其与汇编语言的映射关系
机器语言的构成:由0和1组成的二进制序列,在反汇编中是用16进制表示的(每四个二进制数对应1个16进制数),典型的结构是:
|-----|-----|
| 操作码 | 操作数 |
不同的操作码对应着不同的操作,操作数对应着操作对象。其中,操作数又分为立即数,寄存器,内存地址(基址,变址,缩放因子,偏移)。
映射关系: 每一条汇编语言指令,通常都直接对应一条机器语言指令。这种映射关系是由CPU的指令集架构定义好的。汇编器将编译器生成的汇编语言根据指令集架构映射成机器语言,这样CPU才能执行这些指令。
4.4.2 分析反汇编与汇编的不同
不同之处主要有三点:.s中的十进制数在反汇编中都转换成相等的十六进制数,分支转移和函数调用。重点分析一下分支转移和函数调用。
①分支转移:反汇编的跳转指令中,所有跳转的地址被表示为主函数+段内偏移量,而不再是段名称(例如.L3)。例如下面的jmp指令,反汇编文件中为:

而在汇编中为:

②函数调用:反汇编文件中对函数的调用与重定位条目相对应。观察下面两个call指令调用函数,在反汇编中为:

而在汇编中为:

在反汇编中call后面不再是函数名称,而是一条重定位条目指引的信息。
4.5 本章小结
本章详细阐述了汇编阶段的工作原理及其在程序构建过程中的关键作用。汇编是将编译器生成的人类可读的汇编代码(.s文件)转换为机器可执行的二进制指令,并生成可重定位目标文件(.o文件)的关键过程。汇编器作为这一过程的执行者,主要完成三项核心任务:将汇编指令精确翻译为对应的二进制机器码;处理数据定义和符号标记,为数据分配存储空间并在符号表中记录标签位置;生成符合ELF格式规范的可重定位目标文件,为后续链接阶段做好准备。
通过对hello.o文件的ELF结构深入分析,我们了解到可重定位目标文件由ELF头、节头表和多个功能各异的节(section)组成。其中.text节存储程序的机器指令,.rodata节包含只读数据如字符串常量,.symtab节记录符号信息,而.rela.text等重定位节则标记了需要在链接时修正的地址引用。这些结构共同构成了链接器所需的全部信息。
在对比hello.s与hello.o的反汇编结果时,我们观察到三个主要差异:数值表示从十进制转为十六进制;分支转移指令的目标地址从符号标签变为偏移量形式;函数调用地址从函数名变为重定位条目指引的临时地址。这些差异直观地展示了汇编器如何将符号化的汇编代码转化为机器可处理的二进制形式,同时保留足够的信息供链接器完成最终的地址解析。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
从多个.o文件(以及可能的静态库.a或动态库)到最终可执行文件(或共享库)的链接阶段,其核心概念是链接器(Linker)将多个可重定位目标文件中分散的代码段、数据段进行合并,解析并修正所有跨文件的符号引用(符号解析),将此前未确定的地址占位符替换为真实运行时地址(重定位),最终生成一个地址空间完整、可被操作系统直接加载执行的程序映像的过程。这是将多个"半成品"目标模块集成为完整可运行程序的关键整合步骤。
5.1.2 链接的作用
此阶段是程序构建流程中实现模块化组合与地址最终确定的"集成器",其主要作用包括:
① 符号解析(Symbol Resolution)
链接器遍历所有输入目标文件的符号表,将每个符号引用(如对 printf、main 或全局变量的调用/访问)与一个且仅一个符号定义进行匹配。若存在未定义符号(如忘记链接 libc)或多重复定义,链接器将报错并中止。此过程确保程序中所有外部引用均有确定的实现来源。
② 地址分配与重定位(Address Assignment and Relocation)
链接器为合并后的各节(.text、.data、.rodata 等)分配最终的虚拟内存地址(基于链接脚本或默认布局),并利用各 .o 文件中的 重定位表(Relocation Table),将此前标记为"待修正"的地址引用(如函数调用、全局变量访问)替换为计算出的真实地址。
③ 段合并与程序映像构建(Section Merging and Program Image Construction)
链接器将所有输入 .o 文件中相同类型的节(如所有 .text 节)按顺序或策略(如 .init、.fini)合并为一个连续的节,并按内存访问属性(可执行、可读写等)组织成最终的程序段(Segments)。同时生成程序头表(Program Header Table),供操作系统加载器使用,以将程序正确映射到进程地址空间。
④ 生成可执行或可共享目标文件
链接器的输出是一个完整的、自包含的目标文件,其形式取决于链接选项:
可执行文件(如 hello.out):包含操作系统可直接加载运行的完整程序映像,入口点(如 _start)已确定。
共享库(.so):包含位置无关代码(PIC)和动态符号信息,可在运行时由动态链接器(如 ld-linux.so)加载并与主程序链接。
该输出文件不再包含未解析的符号引用(除显式导出给动态链接器的符号外),地址空间布局固定(对静态链接而言),可直接交付执行或部署。
5.2 在Ubuntu下链接的命令

链接命令详解:
|-----------------------------|-----------|-----------------------------------------------------------------|
| 文件 | 类型 | 作用说明 |
| /lib64/ld-linux-x86-64.so.2 | 动态链接器 | 通过 -dynamic-linker 写入ELF的 PT_INTERP 段,告知内核运行时用哪个加载器 |
| crt1.o | 启动目标文件 | 提供 _start 入口点,负责设置栈、调用 __libc_start_main |
| crti.o | 初始化前导文件 | 定义 .init 和 .fini 节的开头,用于构造函数/析构函数调用框架 |
| crtbegin.o | GCC 运行时入口 | GCC 特有,用于 C++ 全局构造函数(__do_global_ctors)和栈保护初始化 |
| hello.o | 用户代码 | 你编译出的主程序目标文件(含 main 函数) |
| -lc | C 标准库 | 链接 libc.so(动态)或 libc.a(静态),提供 printf、malloc 等标准函数 |
| crtend.o | GCC 运行时结尾 | GCC 特有,提供 .init_array / .fini_array 的结束标记,配合 crtbegin.o 管理构造/析构 |
| crtn.o | 初始化尾部文件 | 定义 .init 和 .fini 节的结尾,与 crti.o 配对 |
按照C源文件的要求,后续分析的hello还是都通过命令gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC hello.c -o hello生成:

5.3 可执行目标文件hello的格式
5.3.1ELF header(ELF 头)

|--------------------------------------------------|----------------------------------|-------------------------|---------------------|--------|----------------------------|
| Magic: | 7f 45 4c 46 | 02 | 01 | 01 | 00 00 00 00 00 00 00 00 00 |
| 魔数,用来确定文件类型。操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确就会拒绝加载 | 分别对应ASCII码中的DEL控制符、'E'、'L'、以及'F' | ELF文件类型 0x1 32位 0x2 64位 | 字节序 0x1 小端法 0x2 大端法 | ELF版本号 | 无意义,填充用 |
|-----------------------------------|-------------------------------|------------------------------------|
| 字段名称 | 值 | 解释 |
| 类别 | ELF64 | 64位 |
| 数据 | 2 位码,小端序 (little endian) | 小端法 |
| Version | 1 (current) | |
| OS/ABI | UNIX - System V | |
| ABI 版本 | 0 | |
| 类型 | EXEC(可执行文件) | 文件类型是可执行文 |
| 系统架构 | Advanced Micro Devices X86-64 | |
| 版本 | 0x1 | |
| 入口点地址 | 0x4010f0 | |
| 程序头起点 | 64 (bytes into file) | |
| Start of section headers | 14944 (bytes into file) | section header table的基址是1192 |
| 标志 | 0x0 | |
| Size of this header | 64 (bytes) | ELF header的大小是64 bytes |
| Size of program headers | 56 (bytes) | 每个progr am header s(段头)的大小是56bytes |
| Number of program header | 13 | 有13个program header |
| Size of section headers | 64 (bytes) | 每个section header的大小是64 bytes |
| Number of section headers | 31 | 有31个section header |
| Section header string table index | 30 | |
5.3.2Program header table( 段头表 )


根据Program header table中各个segement的VA可以确定他们之间的位置关系,如下表
|------------------|--------------------|---------------------------|--------------------|
| TYPE**(种类)** | VA (虚拟地址) | FileSiz( 大小 ) | Flags (权限) |
| GNU_STACK | 0x0000000000000000 | 0x0000000000000000 | RW |
| LOAD | 0x0000000000400000 | 0x0000000000000648 | R |
| PHDR | 0x0000000000400040 | 0x00000000000002d8 | R |
| INTERP | 0x0000000000400318 | 0x000000000000001c | R |
| NOTE | 0x0000000000400338 | 0x0000000000000020 | R |
| GNU_PROPERTY | 0x0000000000400338 | 0x0000000000000020 | R |
| NOTE | 0x0000000000400358 | 0x0000000000000044 | R |
| LOAD | 0x0000000000401000 | 0x00000000000002e5 | R E |
| LOAD | 0x0000000000402000 | 0x0000000000000198 | R |
| GNU_EH_FRAME | 0x0000000000402048 | 0x0000000000000044 | R |
| LOAD | 0x0000000000403e10 | 0x0000000000000258 | RW |
| GNU_RELRO | 0x0000000000403e10 | 0x00000000000001f0 | R |
| DYNAMIC | 0x0000000000403e20 | 0x00000000000001d0 | RW |
注:R,W,E分别代表可读,可写,可执行
通过权限,可推断虚拟地址为0x0000000000401000的LOAD为Code Segement(代码段),因为它的权限是R E(可读可执行);可推断虚拟地址为0x0000000000 403e10的LOAD为Data Segement(代码段),因为它的权限是RW(可读可写)
5.4 hello的虚拟地址空间
首先用gdb加载hello程序,再在main处设置断点,然后运行(因为只有运行后才能看虚拟地址空间内的各段信息),然后用info proc mappings命令查看虚拟地址空间布局。


再用info files命令将其映射回节(section):

根据.text节,.data节确定Code Segment和Data Segment。
因为
|---------------------------------------|----|-------|
| 0x00000000004010f0-0x00000000004012d5 | is | .text |
所以
|----------------|--------------|----------|------------|-----------------------------------------|
| Start Addr | End Addr | Size | Offset | objfile |
| 0x401000 | 0x402000 | 0x1000 | 0x1000 | /mnt/hgfs/vmsharefile/bighomework/hello |
是Code Segment。
因为
|---------------------------------------|----|-------|
| 0x0000000000404048-0x0000000000404058 | is | .data |
所以
|----------------|--------------|----------|------------|-----------------------------------------|
| Start Addr | End Addr | Size | Offset | objfile |
| 0x404000 | 0x405000 | 0x1000 | 0x3000 | /mnt/hgfs/vmsharefile/bighomework/hello |
是Data Segment。
与5.3对照分析说明,5.3的Code Segment和Data Segment是:
|-------------------|--------------------|---------------------------|--------------------|
| TYPE (种类) | VA (虚拟地址) | FileSiz( 大小 ) | Flags (权限) |
| LOAD | 0x0000000000401000 | 0x00000000000002e5 | R E(代码段) |
| LOAD | 0x0000000000403e10 | 0x0000000000000258 | RW (数据段) |
我猜测两者差异的原因应该是gdb查看的段已经经过页对齐了,gdb里的段大小都是0x1000B=16^3B=2^12B=4kB,正好是一页的大小。
5.5 链接的重定位过程分析
通过命令行objdump -d -r hello > hello.asm生成hello的反汇编并存储到hello.asm文件中方便查看。


5.5.1 分析 hello 与 hello.o (的反汇编)的不同
①可执行文件的反汇编中的函数数量更多
可执行文件的反汇编hello.asm中,多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt,atoi@plt等函数的反汇编。这是因为动态链接器将共享库中hello.c用到的函数加入可执行文件中。

②分支转移jxx和函数调用call的操作数发生变化**(重定位)**
以"用法: Hello 学号 姓名 手机号 秒数!\n"为例说明操作数发生变化的原因。
首先,看它对应的hello.s中的反汇编:

机器语言中操作数是00 00 00 00,汇编语言中是0x0(rip)。
再看它对应的hello.o中的反汇编:

机器语言中操作数是76 0e 00 00,汇编语言中是0xe76(rip)。
为什么会发生这种变化呢?这是因为链接器进行了重定位。为什么某些符号需要重定位呢?因为编译器在编译单个源文件时,会生成该文件对应的目标文件。当代码中引用其他模块的函数(如 printf,exit等)或全局变量时,编译器无法知道这些符号在最终进程内存空间中的绝对地址,甚至不知道其他模块在哪里。于是,编译器只能先用一个临时值(通常是0或一个相对偏移)占位,并生成一条重定位条目记录下这个占位的位置和需要修正的符号(调用本地函数自然不需要这样),这些重定位条目就会形成重定位节。当链接器把这个目标文件和其他文件链接时,就根据重定位节进行重定位。重定位节如图:

"用法: Hello 学号 姓名 手机号 秒数!\n"对应的就是第一行可重定位条目,以它为例解释重定位的计算过程:
|--------------|--------------|------------------------|------------------|-------------|
| 偏移量 | 信息 | 类型 | 符号值 | 符号名称 + 加数 |
| 000000000018 | 000500000002 | R_X86_64_PC32 (PC相对地址) | 0000000000000000 | .rodata - 4 |
先计算 运行时虚拟地址(用到本符号的指令的虚拟地址)=main的地址+偏移量
通过readelf -s hello | grep -E 'main'查看可执行文件中main的地址,如下图:

则运行时虚拟地址(用到本符号的指令的虚拟地址)=main的地址+偏移量=0x401176+0x000018=0x40118e
再计算 相对地址(操作数)=符号本身在最终的可执行文件中的虚拟地址-运行时虚拟地址(用到本符号的指令的虚拟地址)+加数
通过objdump -d hello可以看到重定位后的可执行文件的反汇编中对应的此指令:

得到符号在最终的可执行文件中的虚拟地址=0x402008
则 相对地址(操作数)=0x402008-0x40118e+(-0x000004)= 0x402008-0x401192= 0x000e76,这正好对应反汇编中内存地址的偏移,因为采用小端法,在机器语言中就是76 0e 00 00.
5.5.2 链接的过程
链接是将多个目标文件(.o文件)和库合并成一个可执行文件的关键步骤,节以本程序为例讲解一下静态链接的全过程。
- 链接前的准备 ------ 生成可重定位目标文件 (main.o)
这是链接器的"原材料"。每个 .c 文件被独立编译成 .o 文件,.o文件里有:
代码节 (.text): 存放编译后的机器指令。
数据节 (.data): 存放已初始化的全局/静态变量。
BSS节 (.bss): 存放未初始化或初始化为0的全局/静态变量。注意,这个节在文件里不占实际空间,只是一个占位符。
只读数据节 (.rodata): 存放只读数据,比如字符串常量、const 全局变量。
符号表 (.symtab):它记录了这个模块定义和引用的所有符号(只记录程序中的函数和全局变量),包括:
全局符号:由本模块定义且可以被本模块和其他模块引用的符号。
外部符号(extern):由本模块定义但是仅可以被其他模块引用的符号。
局部符号(static):由本模块定义且仅由本模块引用的符号。
重定位表 (.rel.text, .rel.data): 这是链接器的"待办事项清单"。它记录了在 .text 和 .data 节中,有哪些位置的地址需要在链接时被重定位。至于为什么要进行重定位以及重定位的过程在5.5.1中已经详细分析了。
- 链接器的核心工作之一 ------ 符号解析
链接器扫描所有输入文件(main.o,libc.a),在扫描的同时管理三个集合。E:.o文件集合;U:引用但尚未定义的符号集合;D:输入文件中已定义的符号集合。

初始时,E,U,D三者均初始化为空,之后:
链接器从左到右依次读取输入文件。对于任意输入文件,
如果它是.o文件,就让其进入E,并检查它的符号表,将未定义的符号(如printf,exit等)放入U中,已经定义的放入D中
如果它是.a文件(库文件,.o文件的集合),就尝试在它的符号表中寻找U中的元素,找到以后将元素(如printf)从U中删除,并将这个元素对应的.o文件(printf.o)放入E中。
重复上述过程,直到扫描完所有的输入文件。
如果最终U为空,就合并E(main.o,printf.o,exit.o等)生成可执行文件,否则报错。
这告诉我们:命令行中输入文件的顺序很重要!通常把库文件放在命令行的末尾。
符号解析规则
在解释符号解析规则之前,先引入强/弱符号的概念:
强符号:函数和化且不为0的全局变量
弱符号:未初始化或为0的全局变量
符号解析时遇到同名符号的处理规则:
多个强符号,就会报错。
一个强多个弱,用强的。
多个弱的,用弱的。
多个弱符号链接时链接器并不会报错,但实际运行程序时候会出现一些让人费解的错误,用gcc -fno-common避免这种情况(使用以后多个弱符号链接会报错)。
③链接器的核心工作之二 ------重定位
这是链接过程中最精巧的一步。现在所有符号的定义都找到了,但代码和数据还散落在各自以0为起点的地址空间里。链接器需要将它们"拼"到一个统一的进程虚拟地址空间中。
合并与分配地址
链接器将所有输入模块的相同类型的节合并起来。例如,将所有 .o 的 .text 节合并成最终可执行文件的一个大的 .text 节。.data 节、.bss 节也是如此。
然后,链接器为这个合并后的"聚合节"分配在运行时进程虚拟地址空间中的起始地址。
一旦确定了每个"聚合节"的起始地址,那么每个符号的运行时地址也就确定了。例如,printf就是 .text 节起始地址加上它在节内的偏移量。
重定位符号引用 ------ 修改指令和数据
链接器现在有了所有符号的运行时地址,并且手里拿着每个 .o 文件提供的"待办事项清单"(重定位表)。
链接器遍历重定位条目,对代码(.text)和数据(.data)进行实际修改,详细过程见5.5.1。
- 生成可执行目标文件
完成以上所有步骤后,链接器会输出一个完整的可执行目标文件(如hello)。这个文件和 .o 文件结构类似(都是ELF格式),但有决定性不同:
它包含所有需要载入内存的段的信息(Program header table程序头表/段头表),这些"段"由多个"节"组成,方便操作系统在加载时建立映射。例如:
Code Segment(代码段/文本段):包含 .text 和 .rodata,权限是只读、可执行。
Data Segment(数据段):包含 .data(已初始化数据)和 .bss(未初始化数据,运行时由OS置零),权限是可读、可写。
所有的代码节、数据节都已经被合并,且所有的符号引用都已被重定位。文件中使用的地址已经是虚拟内存地址。换句话说,.o 文件是"可重定位的",而可执行文件是"可执行的",它的代码已经准备好被加载到指定的虚拟地址运行。
5.6 hello的执行流程
hello的整个执行流程分为GDB加载可执行文件,跳转到程序入口_start,_start调用__libc_start_main进行初始化,__libc_start_main调用main函数正式运行,程序终止(资源清理+退出)五个核心阶段,以下详细梳理各阶段的函数调用、跳转关系及地址特征:
- GDB加载hello可执行文件(前置准备)
当在终端执行gdb ./hello时,GDB完成以下操作(未启动程序,仅加载元信息):
解析ELF64格式:读取hello的ELF头部,解析程序头表(PT_LOAD、PT_DYNAMIC等)、节头表(.text、.data、.bss、.init等),识别代码段、数据段的内存布局。
加载符号表:若编译时加了 -g 调试参数,会加载完整符号表(函数名、变量名、行号);若未加,仅保留地址信息(无符号名)。
关联动态链接器:识别hello依赖的动态库,关联动态链接器。
初始化调试环境:通过ptrace系统调用绑定调试接口,为后续断点、单步跟踪做准备。
- 跳转到程序入口_start
当在GDB中执行 run时,程序正式启动,GDB通过加载器加载文件,完成内存映射,然后跳转到_start(链接器ld设置的程序入口点,非用户编写函数)。
关键信息
- _start函数特性
定义:位于crt1.o(C运行时初始化文件)中,是链接器指定的入口。
地址特征:可通过GDB命令info functions _start查看:

查看_start地址为0x00000000004010f0

反汇编_start,查看指令
- _start调用__libc_start_main进行初始化
_start不直接调用main,而是先调用__libc_start_main进行初始化。x86_64下完整调用/跳转流程如下:
- _start 自身初始化
_start 先执行栈初始化、参数传递准备(argc/argv入栈)等基础操作(刚才已经通过disas _start反汇编看到push、mov等栈操作指令)。
- _start 调用 __libc_start_main
地址特征:可通过disas __libc_start_main查看,它的起始地址为0x00007f fff7de2f90。

函数作用:glibc的核心初始化函数,负责搭建C程序的运行环境。
关键初始化工作:
初始化进程环境变量(environ)、命令行参数(argc、argv);
调用C运行时初始化函数 __init(对应ELF的.init节,执行全局构造函数、静态变量初始化);
注册程序退出清理函数;
最终通过call指令跳转到main函数。
- __libc_start_main调用main函数正式运行
main 地址特征:用户代码地址,可通过info functions main查看,地址为0x00000000004011d6。


跳转触发:由__libc_start_main主动调用main,main的返回值(如return 0)会被__libc_start_main接收。
④main函数执行流程(用户代码+库函数调用)
main是用户编写的核心函数,执行过程中会调用多个glibc库函数,GDB中可通过step(s,步入)、next(n,步过)跟踪,具体流程如下:
main函数的核心执行步骤
- 参数检查:判断argc!=5,若不满足:
调用 printf,打印用法提示;
调用 exit(1),终止程序。
- 参数正确时执行循环:
循环体(i=0到9):
调用 printf,打印Hello 学号 姓名 手机号;
先调用 atoi(argv[4]),将秒数字符串转为整数;
再调用 sleep;
-
循环结束后:调用 getchar();
-
main返回:return 0 将退出码0返回给__libc_start_main。
以上这些函数都可以通过gdb指令查看地址。

printf地址为0x00000000004010b0

exit地址为0x00000000004010c0

atoi地址为0x00007ffff7e035b0

sleep地址为0x00000000004010d0

getchar地址为0x00007ffff7e4a560
⑤程序终止流程(main返回后,清理资源+退出)
main函数返回后,程序不会立即终止,而是回到__libc_start_main,执行后续清理和退出逻辑,完整流程如下:
-
接收main返回码:__libc_start_main 接收main的返回码(如0)。
-
执行C运行时清理:调用 _fini(对应ELF的.fini节,执行全局析构函数、静态变量清理)。
-
调用exit函数:__libc_start_main 调用exit(传入main返回码),exit完成两项核心工作:
调用atexit注册的所有自定义清理函数;
刷新所有标准I/O流缓冲区(如stdout未输出的内容)。
-
调用_exit函数:exit 最终调用 _exit(下划线开头,与exit区分,无缓冲区刷新和清理函数调用)。
-
触发内核系统调用:_exit 通过syscall指令触发内核的 exit_group 系统调用(x86_64系统调用号60),内核执行:
释放进程占用的所有资源(内存、文件描述符、信号量等);
终止进程,将退出码返回给父进程(父进程可通过waitpid获取)。
以上这些函数都可以通过gdb指令查看地址。

_fini的地址为0x00000000004012d8

_exit地址为0x00007ffff7ea2110
5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序加载时才将它们链接在一起形成一个完整的程序。对于程序中用到的共享库函数,编译器在编译时没有办法预测函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。
这就产生一个新问题:一个大型程序可能依赖很多共享库,调用成百上千个函数。如果程序一启动,动态链接器就需要把所有这成百上千个函数的地址都解析并填好,会导致程序启动非常缓慢。而实际上,很多函数可能在程序整个运行期间都不会被调用到(例如错误处理函数)。那为什么要在启动时花时间解析它们呢?于是提出了"等到函数第一次被实际调用时,才去解析它的真实地址"的优化方案,这就是延迟绑定。
延迟绑定是通过PLT(过程链接表)和GOT(全局偏移表)实现的,因此我们必须先介绍一下PLT和GOT。
PLT(过程链接表):PLT本身在代码段(对应plt和 .plt.sec这两个节),每个函数都有一个对应的PLT条目,是一小段固定的、短的"代理代码"
GOT(全局偏移表):GOT本身在数据段(对应.got和 .got.plt这两个节,但.got.plt才是函数专用的),每个需要延迟绑定的函数在数据段都有一个对应的"格子"。这个格子最初存放的是"如何找到这个函数地址"的指令地址,在函数被第一次调用后,会被替换成函数的真实地址。
具体而言,当程序首次调用某个共享库函数(如printf)时,编译器生成的代码并非直接调用该函数,而是跳转至对应的PLT条目。PLT条目作为一段简短的代码,会首先查询GOT中对应于该函数的条目。在首次调用时,GOT条目内存储的并非函数真实地址,而是指向动态链接器中解析例程的指令。此时,控制权将移交至动态链接器,由其负责根据函数名称搜索并定位该函数在内存中的实际入口地址,随后将此真实地址回填至GOT的对应条目中,并最终跳转执行目标函数。此后,任何针对该函数的再次调用,其PLT代码在查询GOT时将直接获得函数的真实地址,从而实现无需动态链接器干预的直接跳转。通过这种"首次解析、后续直连"的代理机制,程序在保持动态链接灵活性的同时,避免了不必要的启动开销,实现了性能与功能的平衡。
根据readelf -S hello可知,PLT起始地址为0x401020,GOT起始地址为:0x404000。

接下来用gdb来验证上述理论分析(以printf为例),首先在终端输入gdb ./hello,然后disas main查看printf(严谨地说是__ printf_chk@plt,@plt 就说明这是printf的代理)的地址。

之后对这个地址进行反汇编,能查看printf函数对应的PLT条目。

图中画红色的部分就是代理代码要跳转的目标,这实际上就是printf的GOT条目的地址,现在让我们来查看一下这个地址的内容(注意此时还没有运行)。

然后设置在printf处设置断点break *0x000000000040120d,再运行r 2024113212 张泽晰 13163646205 0,再n进行一次printf调用。

这时再查看一下GOT条目的内容

GOT确实发生了变化!
5.8 本章小结
本章系统探讨了程序构建过程中的关键阶段------链接。链接作为将多个可重定位目标文件(如.o文件)和库文件合并生成可执行文件的核心步骤,其作用主要体现在符号解析、节合并、重定位以及可执行文件生成等方面。通过分析hello程序的具体实例,我们深入理解了链接器如何解析跨文件的符号引用、合并相同类型的节(如.text和.data),并利用重定位表修正地址引用,最终形成地址空间完整的可执行目标文件。
具体而言,本章首先介绍了链接的概念与作用,强调了链接在实现模块化编程和资源整合中的重要性。随后,通过Ubuntu环境下的链接命令演示了实际操作过程,并分析了可执行文件hello的ELF格式结构,包括ELF头、程序头表等。通过gdb工具,我们探查了hello进程的虚拟地址空间布局,验证了代码段和数据段的内存映射关系。
在重定位过程分析中,本章对比了hello.o与hello的反汇编代码,揭示了链接器如何将临时占位地址替换为运行时虚拟地址,并通过具体计算示例(如字符串常量的重定位)说明了PC相对寻址机制。此外,hello的执行流程分析展示了从程序入口_start到main函数调用、库函数执行乃至进程终止的完整路径,体现了操作系统与运行时环境的协同工作。
最后,本章探讨了动态链接机制,重点分析了PLT(过程链接表)和GOT(全局偏移表)在延迟绑定中的作用。通过gdb调试,我们验证了printf等函数首次调用时通过PLT跳转至动态链接器解析地址、后续调用直接访问GOT中缓存地址的过程,凸显了动态链接在平衡启动开销和灵活性方面的优势。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
进程是程序的一次动态执行实例,是操作系统进行资源分配和调度的基本单位。其核心概念是:当一个存储在磁盘上的、静态的可执行程序文件(如hello.out)被加载到内存中,并由操作系统为其分配独立的地址空间、系统资源(如文件、I/O设备)并建立相应的管理数据结构后,所创建的一个"活"的、正在执行的实体。它不仅仅是指令的集合,更包含了程序运行时的所有动态状态。
6.1.2 进程的作用
进程是构建现代计算环境并发执行能力的基石,其主要作用包括:
① 实现程序的并发执行
这是引入进程概念最核心的作用。操作系统通过创建多个进程,并在单个或多个CPU核心上快速切换(分时),使得多个程序"看起来"在同时运行,极大地提高了系统整体的资源利用率和吞吐量。
② 资源分配与保护的单元
资源封装:操作系统以进程为单位,为其分配和管理独立的虚拟地址空间、CPU时间片、打开的文件描述符、网络连接等资源。
隔离与保护:每个进程拥有独立的地址空间,一个进程无法直接访问或破坏另一个进程的内存数据,这为系统安全性和稳定性提供了最基础的硬件和操作系统级保障。
③ 提供程序运行的完整上下文
一个进程包含了程序运行的所有状态信息,这些信息主要体现在其核心数据结构------进程控制块(PCB)中,PCB是进程存在的唯一标识,其中大致包含:
标识信息:唯一的进程ID(PID),父进程ID等。
状态与调度信息:进程当前状态(运行、就绪、阻塞等)、优先级、调度参数。
内存管理信息:指向页表、内存段描述符的指针,定义了进程的地址空间布局。
文件与资源信息:当前工作目录、打开的文件列表、已分配的设备等。
处理器上下文:当进程被切换时,其所有寄存器的当前值(如PC、SP、通用寄存器)被保存在这里,以便下次恢复执行。这为进程的挂起、恢复和切换提供了可能。
④ 为进程间通信与同步建立基础
进程作为独立的执行实体,是进程间通信(IPC)机制(如管道、消息队列、共享内存)的作用对象。操作系统提供的这些机制,使得相互隔离的进程能够安全、可控地协作,共同完成复杂的任务。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell-bash 的作用
Shell(壳)是操作系统为用户提供的命令行解释器与交互界面,是介于用户(或脚本)与操作系统内核(Kernel)之间的关键桥梁。其核心作用是解释并执行用户的命令,从而启动和管理进程、控制作业流程、提供编程环境。具体作用包括:
①命令解释与执行
这是Shell最根本的作用。它读取用户输入的命令行(如 ls -l),解析其中的命令名、参数、操作符(如 |, >, &),并启动对应的程序(如 /bin/ls)来执行。
②进程创建与管理
Shell是用户创建和管理进程的主要工具。每当执行一个外部命令(非Shell内建命令),Shell会通过fork()系统调用创建一个新的子进程,并在其中通过execve()加载并运行目标程序。此外,它还管理作业控制(如前台/后台运行&、挂起Ctrl+Z)、查看进程状态(如jobs, ps)。
③环境提供与维护
Shell为用户提供了一个可配置的运行环境:
环境变量:维护并传递一组全局变量(如PATH, HOME),为进程提供配置信息和运行上下文。
工作目录:管理当前工作目录(pwd),并支持切换(cd)。
输入/输出重定向:通过解析 >, <, >> 等符号,为用户灵活地控制进程的标准输入、输出和错误流提供了可能。
管道连接:通过解析 | 符号,将一个进程的标准输出自动连接为下一个进程的标准输入,实现简单的进程间通信和复杂的数据处理流水线。
④编程与脚本执行
Shell(特别是Bash)自身拥有一套编程语言特性(变量、条件判断、循环、函数)。这使得用户可以将一系列命令组织成脚本文件(.sh)来执行,实现任务自动化,是系统管理和 DevOps 中的重要工具。
6.2.2 Shell-Bash 的处理流程
当用户在提示符下输入一行命令并按下回车后,Bash通常按以下流程处理:
①读取(Read)
Bash从标准输入(通常是终端)读取一行命令,直到遇到换行符。对于脚本,则从脚本文件中读取下一行。
②解析(Parse)
这是一个复杂的分析阶段,包括:
词法分析:将命令行字符串拆分成一系列的"词元"(Tokens),如命令、选项、参数、操作符。
语法解析:根据Shell的语法规则,理解这些词元之间的关系。例如,识别出重定向符(>)、管道符(|)、命令分隔符(;)、后台运行符(&)及其左右的操作对象。
展开(Expansion):这是Bash等高级Shell的核心步骤,按顺序进行多种替换:
大括号展开:{a,b}.c -> a.c b.c
波浪号展开:~ -> /home/username
参数和变量展开:$HOME -> /home/username
命令替换:$(date) -> 替换为命令date的输出结果
算术展开:$((1+2)) -> 3
单词拆分:将展开后的结果按分隔符(空格)拆分成独立的字段。
文件名生成(通配符展开):*.txt -> 展开为当前目录下所有.txt文件列表。
③执行(Execute)
根据解析结果,执行最终的逻辑命令:
内建命令:如果命令是Shell自身实现的(如cd, echo, export),则直接在Shell进程内部执行,不创建新进程。
外部命令/程序:对于外部程序(如/bin/ls):
a. fork():Shell进程调用fork(),创建一个与自己几乎完全相同的子进程。
b. exec():在子进程中,调用exec()系列函数,将自身进程的映像替换为目标程序的映像(代码、数据等)。子进程"变身为"目标程序。
c. 等待与信号处理:如果命令是前台作业,Shell会调用wait()或类似函数,暂停自身(进入睡眠状态),等待子进程执行完毕。子进程结束后,Shell恢复运行,显示新的提示符。如果是后台作业(以&结尾),Shell则不会等待,直接显示提示符。
④报告结果与循环
获取子进程的退出状态码。
完成一次命令执行周期,重新回到步骤1,显示提示符,等待下一条命令。
6.3 Hello的fork进程创建过程
首先用户在shel1界面输入指令:./hel1o 2024113212 张泽晰 13163646205 0,按下回车,就会开始Hello的fork进程创建过程,全流程如下
①shell读取当前的命令行(fgets) "./hel1o 2024113212 张泽晰 1316364 6205 0"
②shell解析(eval)命令行,判断是否为内置命令(builtin_cmd),hello不是则执行步骤③
③shell 使用fork创建子进程hello,继承了父进程shell的页表等进程控制信息,但拥有完全独立的私有虚拟地址空间(),且进程pid不同.
- shell 创建新的作业job,并将子进程hello加入此作业,等待前台作业终止。
ps:复习一下fork:在父进程中,fork返回子进程的PID,而在子进程中fork返回0,返回值提供一个明确的方法来分辨程序是父进程还是在子进程中执行。
6.4 Hello的execve过程
首先复习一下execve函数的定义:int execve(char *filename,char *argv[],char *envp[]).
紧接6.3所说,由fork产生的shell子进程会调用execve函数:
execve("./hello", argv, envp);
这个函数产生的效果如下:
①删除子进程现有的虚拟内存段,即已存在的用户区域vm_area
②根据argv[0]执行程序创建一组新的段,即新的区域结构,标记为私有的、写时复制
代码和初始化数据映射mmap到.text和.data区(目标文件提供)。这样虚拟地址空间的页映射到可执行文件页大小的片chunk,新的代码与数据段被初始化为可执行文件的内容。
.bss和栈堆映射mmap到匿名文件,栈堆的初始长度0
③共享对象由动态链接映射到本进程的共享区域,标记为共享对象。
④动态链接模块根据共享对象地址,初始化程序中的可重定位代码或数据的地址。
⑤在栈中建立环境变量字符串和环境变量表;在栈中建立参数字符串和参数表;
⑥跳到程序入口_start,开始取指令、译码分析并执行指令。
其中②③⑤是通过execve函数中的loader模块(加载器)实现的
6.5 Hello的进程执行
6.4的execve仍属于系统调用(内核态),但以跳转到_start为标志,我们已经正式从内核态转为用户态了,Hello程序正式获得CPU的执行权。
_start会进行一些底层的初始化工作,然后调用__libc_start_main,后者进一步初始化C运行时的环境,包括初始化标准I/O流、设置线程本地存储等,最后__libc_start_main才调用我们编写的main函数。
进入main函数后,程序首先检查参数个数argc。if(argc!=5),它会 prinf("用法: Hello 学号 姓名 手机号 秒数!\n")并通过exit()系统调用结束自己。在本节中我们还是重点讲解输入正确的情况,即在shel1界面输入的指令是:./hel1o 2024113212 张泽晰 13163646205 0,argc正好是5个,进入for循环。
for循环的每次迭代都包含两个关键操作:printf和sleep。
当执行到printf时,程序实际上是在调用C库函数,而C库函数内部会发起write系统调用。这时,CPU从用户态切换到内核态 ,硬件会自动保存用户态的上下文 (如程序计数器PC、栈指针SP、状态寄存器等)到内核栈,并切换到内核栈和内核地址空间。内核接管控制权后,验证参数的有效性,然后将字符串写入标准输出文件描述符对应的设备------通常是终端。完成写入后,内核恢复Hello进程的上下文,切换回用户态,程序继续执行。
接下来是sleep(atoi(argv[4]))。由于我们传入的秒数是手机号13163646205除以5的余数0,所以实际上sleep(0)几乎不等待。但即便如此,这仍然是一个完整的系统调用过程:从用户态切换为内核态 ,内核将进程状态标记为可中断睡眠,但由于等待时间为0,内核立即将进程状态改回就绪态,然后返回用户态。如果秒数大于0,内核会设置一个定时器,将进程从运行队列移到等待队列,调度其他进程运行,直到定时器到期才将Hello进程重新放回运行队列。
在这个循环执行期间,操作系统的时间片 机制在幕后持续工作。现代操作系统采用抢占式调度,每个进程被分配一个时间片(通常是几毫秒到几十毫秒)。当Hello进程的时间片用完,或者它主动调用sleep这样的阻塞式系统调用时,CPU会调用调度器进行进程调度。
进程调度 的核心是上下文切换,上下文切换的全流程如下:
①保存上下文:内核将当前进程(Hello)的"上下文"保存到其进程控制块中。这包括CPU的所有关键状态:通用寄存器(rax, rbx等)、程序计数器(rip)、栈指针(rsp)、状态寄存器(EFlags)以及内存管理单元(MMU)相关的寄存器(如CR3页表基址寄存器,用于地址空间切换)。
②选择下一个进程:调度器根据特定的策略(如Linux的CFS完全公平调度器),从就绪队列中挑选一个最值得运行的进程。这涉及到计算进程的虚拟运行时间、优先级等。
③恢复上下文:内核将选中进程的上下文从其进程控制块中加载到CPU的各个寄存器,这包括恢复它的栈指针、程序计数器,以及至关重要的CR3寄存器(从而切换到该进程的地址空间)。
④切换执行流:当调度器恢复新进程的程序计数器(rip)后,CPU便开始执行新进程的代码,调度完成。
这种切换对用户程序是透明的,但正是这种快速的切换制造了多个程序"同时运行"的假象。在整个过程中,进程的状态(运行、就绪、阻塞)在其进程控制块中维护,而上下文信息则是进程状态在CPU上的"瞬时快照",是调度器进行"场景还原"的依据。
循环执行10次后,程序调用getchar()等待用户输入。这又是一个阻塞式系统调用,Hello进程从运行态转入阻塞态,等待键盘输入事件。此时CPU可以运行其他进程,提高系统利用率。当用户按下任意键,键盘控制器产生硬件中断,CPU暂停当前工作,执行中断处理程序。中断处理程序读取键盘扫描码,转换为字符,唤醒等待输入的Hello进程。Hello进程从阻塞态转为就绪态,等待调度器再次分配CPU时间片。
一旦Hello进程重新获得CPU,getchar()返回,main函数执行到return 0,这会导致程序调用exit()系统调用。用户态又切换为内核态,内核开始清理进程占用的资源:关闭所有打开的文件描述符,释放内存页,发送SIGCHLD信号给父进程(Shell)。但Hello进程并没有完全消失,它变成了一个"僵死进程"------保留了退出状态和资源使用统计,等待父进程查询。只有当Shell调用wait()或waitpid()系统调用获取了子进程的退出状态后,Hello进程的进程描述符才被彻底释放,它在系统中的所有痕迹都被抹去。
整个执行过程中,Hello进程在运行态、就绪态和阻塞态之间多次转换。运行态时它在CPU上执行指令;就绪态时它等待调度器分配CPU;阻塞态时它等待I/O事件。用户态和内核态的切换更是频繁发生,每次系统调用都伴随着这种特权级的切换。
6.6 hello的异常与信号处理
首先复习一下异常,异常共分为以下四类:
|------|---------------|-------------|-------------------|----------------|----------------|
| | 异常类别 | 特点 | 具体实例 | 产生的信号 | 默认处理 |
| 异步异常 | 中断(Interrupt) | 来自I/O设备(硬件) | 用户按Ctrl-C、Ctrl-Z | SIGINT、SIGTSTP | 终止/停止进程 |
| 同步异常 | 陷阱(Traps) | 有意执行某条指令的结果 | 系统调用(sleep,exit等) | 无直接信号 | 进入内核态执行 |
| 同步异常 | 故障(Faults) | 潜在可恢复的错误 | 缺页、无效内存访问 | SIGSEGV | 终止进程+core dump |
| 同步异常 | 终止(Aborts) | 不可恢复的严重错误 | 严重硬件错误 | SIGKILL等 | 立即终止进程 |
处理流程可以用以下四张图片概括,一目了然:




接下来结合具体情况来说明异常与信号的处理。
- 回车运行结果截屏:

按下回车键,明显属于中断(因为这是来自硬件的),但由于回车键只是字符'\n'(换行符),并不是控制字符组合,没有预定义的信号与之关联,并不会产生信号。全流程如下图:

- Ctrl-Z运行结果截图:

按下Ctrl-Z,属于中断(因为这是来自硬件的),Ctrl-Z的效果是stop前台进程组的所有进程,会产生信号SIGTSTP(信号编号20)。全流程如下图:

- Ctrl-C运行结果截图:

按下Ctrl-C,属于中断(因为这是来自硬件的),Ctrl-C的效果是终止前台进程组的所有进程,会产生信号SIGINT(信号编号2)。全流程如下图:

- Ctrl-z后运行其他命令
Ctrl-z后运行ps jobs pstree命令的运行结果截图:



Ctrl-z后运行fg命令的运行结果截图:

Ctrl-z后运行kill命令(发送信号)的运行结果截图:

因为以上命令都涉及键盘输入+回车,应该都涉及中断,但因为他们的执行过程中都会产生系统调用,所以又都涉及到Trap。
|--------------|--------------------------------------|------------------------|
| 命令 | 涉及到Trap的原因 | 是否产生信号 |
| ps | 产生系统调用(fork、execve、open、read、write等) | 不产生信号 |
| jobs | 产生系统调用(write等) | 不产生信号 |
| pstree | 产生系统调用(fork、execve、open、read等) | 不产生信号 |
| fg | 产生系统调用(通过kill给后台作业发送信号) | 产生信号SIGCONT(继续信号,编号18) |
| kill -9 2164 | 产生系统调用(fork、execve、kill等) | 产生信号SIGKILL(信号编号9) |
6.7本章小结
本章深入探讨了Hello程序从创建到终止的完整进程生命周期,揭示了操作系统进程管理机制的核心原理与实现细节。进程作为程序的动态执行实例,是操作系统进行资源分配和调度的基本单位,它不仅包含可执行代码,更封装了程序运行时所需的完整上下文和状态信息。
在Linux系统中,Shell(Bash)作为用户与内核间的桥梁,通过解析命令行、创建子进程、执行程序等步骤,实现了对进程的启动和管理。当用户输入./hello命令时,Shell首先通过fork()系统调用创建子进程,该子进程继承了父进程的地址空间但拥有独立的虚拟内存映像;随后子进程调用execve()加载Hello程序,替换了原有的内存映像,建立了新的代码段、数据段和堆栈空间。
Hello进程执行过程中,操作系统通过时间片轮转和抢占式调度实现了多任务并发。每次系统调用(如printf、sleep、getchar)都会触发用户态到内核态的切换,CPU保存当前上下文、执行内核代码、恢复上下文后返回用户态。进程在运行、就绪和阻塞三种状态间动态转换,而上下文切换机制则保证了多个进程"同时"执行的假象。
异常与信号处理是进程管理的关键环节。本章详细分析了四种异常类型(中断、陷阱、故障、终止)及其对应的信号处理流程。对于Hello程序,Ctrl-C产生SIGINT信号终止进程,Ctrl-Z产生SIGTSTP信号暂停进程常。这些机制不仅提供了用户交互能力,也为进程间通信和协作奠定了基础。
(第6章2分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
①逻辑地址
定义:这是在机器指令中直接编码的地址,也是最"原始"的地址形式。在具有分段架构的CPU(如x86)中,它表现为 "段选择符:段内偏移量" 的形式。
来源:由编译器在生成指令时决定。例如,hello(可执行文件)反汇编call 指令后的操作数,在机器语言里都是逻辑地址。
核心要点:它是CPU执行指令时首先看到的地址。在现代操作系统(如Linux)中,为了简化内存模型,通常会将所有段基址设置为0。这样一来,段内偏移量就几乎等同于后续的"线性地址"。在大多数编程和讨论中,我们可以粗略地认为逻辑地址就是程序直接使用的那个地址值。
②线性地址/虚拟地址
定义:逻辑地址经过CPU的段式管理得到的地址,就是线性地址。
关键关系:在 x86-64 架构且启用分页机制后,这个"线性地址"就是虚拟地址。 这两个术语在当代操作系统的上下文中基本可以互换。
来源:由CPU的MMU在分段转换阶段(如果启用)产生。由于Linux等现代系统使用"平坦内存模型",这个转换通常是平凡的,逻辑地址的偏移量直接成为了线性/虚拟地址。
作用:虚拟地址定义了每个进程独有的、从0开始到最大值的连续地址空间。它是操作系统为进程提供的核心抽象。不同进程可以有相同的虚拟地址(比如都在0x400000运行),但它们指向的是完全不同的物理内存内容。
③ 物理地址
定义:这是最终出现在CPU地址总线上,用于访问物理内存(DRAM芯片)的地址。每一个物理地址唯一对应一个物理内存单元(一个字节)。
来源:虚拟地址经过MMU的页式管理进行地址翻译产生。
核心要点:物理地址是系统全局唯一的。操作系统负责管理所有物理地址,并通过为每个进程维护独立的页表,实现将不同的虚拟地址映射到相同或不同的物理地址,从而达成进程隔离、内存保护和虚拟化。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由段选择符和偏移量组成,将段选择符放入对应的段寄存器中,CPU会从段寄存器中拿出段选择符,根据段选择符的TI位选择描述符表(TI=1为LDT(局部描述符表);TI=0为GDT(全局描述符表)),再根据段选择符的索引位选择描述符表中的段描述符。

段选择符中有存取权限,段限(段的大小),段基址...,我们需要的是段基址。有效地址(EA)=基址寄存器+变址寄存器*比例因子+偏移量。

段基址+有效地址(EA)=线性地址,这就得到了线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
(本小节先讨论简单的情况:没有TLB,只有一级页表下的页式管理,复杂情况会在7.4详细讨论)

7.2已经得到了线性地址(虚拟地址,VA)了,CPU将VA输入给MMU(内存管理单元),虚拟地址分为两部分:VPN(虚拟页号)和VPO(虚拟页偏移量),MMU根据VPN(相当于页表索引)和PTBR(页表基址寄存器)中PTB(页表基址)就可以得到PTEA(页表条目地址),之后将PTEA输入给内存,内存查询页表中与PTEA对应的PTE(页表条目),页表条目分为两个部分:有效位和PPN(物理页号)。

若有效位是1,那么就命中了,内存将PTE返回给MMU,MMU将PPN与PPO(物理页号,直接继承VPO)组成PA(物理地址)。

若有效位是0,则未命中,触发缺页故障,内核开始执行缺页中断处理程序,若页表已经满了,那么内存就不得不从页表中选择牺牲页,驱逐到磁盘中,然后再从磁盘中选择需要的页替换上来;否则可以直接将PPN写入页表的空白条目中。之后再重新执行触发缺页故障的指令,这回内存将成功获得PTE并将其返回给MMU,MMU将PPN与PPO(物理页号,直接继承VPO)组成PA(物理地址)。
7.4 TLB与四级页表支持下的VA到PA的变换
只用页表还是太慢了,为了解决这个问题,就设计了页表专用的Cache:TLB(快表),直接在CPU里,访问速度和寄存器一样快。
(本小节为了简便起见,认为只有一级TLB,实际上Intel Core i7 内存系统是有两级TLB的,L1 TLB甚至还分为L1数据TLB和L1指令TLB)

7.2已经得到了线性地址(虚拟地址,VA)了,CPU将VA输入给MMU(内存管理单元),虚拟地址分为两部分:VPN(虚拟页号,36位)和VPO(虚拟页偏移量,12位)。
当MMU根据VPN查询TLB时,会把VPN视为两部分:TLBT(TLB标记,32位)和TLBI(TLB索引,4位);TLB本身是一个高速缓存组织,每个块有三个部分:有效位v,标记位tag,PPN。根据TLBI,MMU知道访问TLB的哪一组(Set);根据TLBT知道访问TLB的哪一路。

若有效位是1,那么就命中了,TLB将PPN返回给MMU,MMU将PPN与PPO(物理页号,直接继承VPO)组成PA(物理地址)。

若有效位是0,则未命中,MMU就会去查询(内存中的)四级页表,这时VPN被视为四个部分:VPN1,VPN2,VPN3,VPN4,分别对应四级页表。CR3寄存器会给出一级页表的基址,加上VPN1就是一级PTE,里面放着二级页表的基址,二级页表的基址+VPN2=三级PTE,里面三级页表的基址...依次类推,直到拿到四级页表的基址,四级页表的基址+VPN4就是四级PTE,里面放着PPN。内存将四级PTE返回给MMU,MMU将PPN与PPO(物理页号,直接继承VPO)组成PA(物理地址)。
当然以上是非常理想的情况(四级页表都命中了),一般四级页表中都会触发缺页故障,这是内核就开始执行缺页中断处理程序。详细过程见7.8
7.5 三级Cache支持下的物理内存访问
(为方便讨论,认为只有一个L1 Cache,实际上Intel Core i7 内存系统是有两个L1 Cache的,分别是L1数据Cache和L1指令Cache)

7.4得到了PA,CPU根据PA就可以去访问物理内存了,物理内存分为L1 Cache,L2 Cache L3 Cache,下面以访问L1 Cache为例,讲解一下如何访问物理内存。
首先将PA分为三部分,标记(t位),组索引(s位),块偏移(b位)。通过组索引能确定指令(或数据)处于Cache的第几组,通过标记能确定处于Cache的第几路,之后就唯一确定了一个块,观察块的有效位v。

若有效位是1,那么就命中了,L1 Cache会从"块偏移"处开始读指令(或数据),最后将其返回给CPU。

若有效位是0,则未命中,L1 Cache会向L2 Cache申请相关内容,这会重复以上步骤,若L2命中,则写回L1,再返回给CPU,若L2不命中,则对L3重复上述步骤...依次类推,直到拿到相关内容返回给CPU。
7.6 hello进程fork时的内存映射
回顾6.3 Hello的fork进程创建过程,当用户在shel1界面输入指令:./hel1o 2024113212 张泽晰 13163646205 0,按下回车,父进程shell就会执行fork()创建与父进程几乎一模一样的子进程。
这里最常见的误解是:fork完整地复制了父进程的整个物理内存内容。如果真是如此,那会极大的浪费空间。虚拟内存的存在,让fork变得更加高效。它的核心思想是:复制虚拟内存结构,而非物理内存内容,并在必要时通过写时复制 和缺页处理 动态创建私有副本。下面让我们来分步拆解这个过程:
①当fork()系统调用被内核处理时,内核为新创建的子进程构建虚拟内存环境。这包括:
- 创建 mm_struct 副本:
结构体mm_struct 包括了pgd(一级页表的基址)和mmap(指向虚拟区间链表的头节点)等。
内核为子进程创建一个新的mm_struct,并将其内容初始化为与父进程完全相同。
- 创建 vm_area_struct 链表副本:
结构体vm_area_struct本身是一个链表节点,链表的每个节点都对应一个虚拟内存区域,比如代码段、数据段、堆、栈、共享库映射等。节点内记录了区域的起止地址(vm_end,vm_start)、读写执行权限(vm_prot)、是否共享(vm_flags)、以及指向下一个节点的指针(vm_next)等。
内核为子进程复制父进程的vm_area_struct链表。此时,子进程的每个vm_area_struct都被标记为 VM_PRIVATE(私有的)和与写时复制(COW)相关的标志。这意味着,从区域层面上,内核已经知道这些区域未来可能需要进行COW。
- 创建页表副本:
内核为子进程创建一个新页表,但页表项的内容最初复制自父进程。也就是说,父子进程的页表项指向的是同一组物理页。
到这一步,子进程已经拥有了和父进程完全相同的虚拟地址空间布局,并且与父进程共享所有的物理内存页。

②设置"写时复制"(COW)
如果就此结束,父子进程的物理内存完全共享,那么任何一方的写操作都会立即被另一方看到,这破坏了进程的"私有地址空间"原则。所以,内核需要进行一些设置:
- 将共享的物理页标记为"只读":
内核遍历所有可写的私有区域(如.data, .bss, 堆,栈),将其对应的页表项中的"写权限位"清除。这意味着,无论父进程还是子进程,此刻都无法写入这些页面。
注意:代码段(.text)本身在父进程就无写权限的,不用修改。
- 记录"写时复制":
仅仅标记为只读是不够的,因为访问只读页可能触发缺页并报错(如尝试写.text会直接报段错误)。内核需要在某个地方记录"这个页面触发缺页是因为它被设置为了写时复制,而不是非法访问"。
这个信息通常通过页表项中保留的软件可用位,或与vm_area_struct区域描述中的VM_PRIVATE标志结合来判断。
此刻的状态是:父子进程共享着所有物理内存,但都只有只读权限。任何一方试图写入内存,都会触发缺页。

- 触发与处理"缺页故障"
现在,假设子进程(或父进程)执行了一条指令,试图修改一个具有COW标记的页面。
-
触发缺页:CPU的MMU在翻译地址时,发现其页表项有效(页面存在),但没有写权限。于是触发一个缺页故障。
-
内核诊断:控制权转到内核的缺页处理程序。处理程序进行诊断:
检查虚拟地址是否在进程的某个vm_area_struct区域内。是。
检查访问类型(写)是否符合区域权限(区域本身是可写的)。是。
检查页表项,发现页面在物理内存中,但只读。结合区域是VM_PRIVATE,诊断得出结论:这是一个合法的COW缺页。
- 执行COW:
内核从物理内存中分配一个新的、空闲的物理页。
将旧的、被共享的物理页中的内容,完整复制到新的页中。
修改当前进程(假设是子进程)的页表项,使其指向这个新的物理页帧,并恢复其"写权限"。
父进程的页表项保持不变,仍然指向原来的旧物理页帧,并保持只读。

- 恢复执行:缺页处理程序返回,CPU重新执行那条指令。这次,MMU成功翻译,且具有写权限,指令正常执行。
至此,子进程获得了它自己修改后数据的私有副本。 父进程的对应页面保持不变。这个操作是"按需"、"惰性"发生的,只有真正写入的页面才会被复制,极大地节省了开销。
7.7 hello进程execve时的内存映射
回顾6.4 Hello的execve过程,由fork产生的shell子进程会调用execve函数。应该牢记:它不创建新进程!进程ID(PID)保持不变。它所做的,是将当前进程的虚拟内存空间(代码、数据、堆、栈等)完全替换为从指定文件加载的新程序的映像,之后跳转到程序入口_start。具体来说,它的全过程是:
- 删除旧的子进程的用户区域
内核遍历当前进程的 vm_area_struct 链表,删除、释放所有用户空间的虚拟内存区域及其物理页面。
- 映射私有区域(代码段与数据段)
创建代码段(包括但不限于.text节):内核根据可执行文件头(如ELF)中的程序头表,创建一个新的、私有的、只读+可执行的 vm_area_struct 区域,对应新程序的代码段,代码段中的.text节是可执行文件提供的。注意:此时只是"映射"了一个虚拟地址范围(如 0x400000 开始),并没有将代码从磁盘读入物理内存。这是按需分页的体现,代码页会在首次执行指令时,通过缺页中断加载。
创建数据段(包括但不限于.data节和.bss节):类似地,内核创建私有的、可读写的vm_area_struct区域,对应数据段。其中.data节是可执行文件提供的;.bss节是请求二进制零的且映射到匿名文件。
创建用户栈:内核在用户地址空间的顶部创建一个私有、可读写的区域。它被映射到一个匿名文件,初始长度通常很小(如8KB),并设置为向低地址增长。寄存器rsp始终栈顶,栈内会由内核压入命令行参数(argv)和环境变量(envp)数组。
创建运行时堆:内核在数据段之后创建一个私有、可读写的区域,用于运行时堆。它同样被映射到一个匿名文件,初始长度也为0,并设置为向高地址增长。程序后续通过 brk/sbrk或 malloc在此区域动态分配内存。
- 映射共享区域
加载共享库:共享库(如libc.so)的代码段和大部分数据段会被映射到进程地址空间的"共享库的内存映射区域"(这也就是说:"共享库的内存映射区域"也是由文件提供的)。这是通过动态链接器(ld-linux.so)完成的。

④设置程序计数器
设置PC:内核将当前进程的程序计数器设置为可执行文件入口点地址,通常是 _start 的地址(在C程序中,_start 会调用 __libc_start_main,最终调用 main 函数)。
7.8 缺页故障与缺页中断处理
7.8.1 缺页故障
当CPU执行一条指令,需要访问一个虚拟内存地址时,内存管理单元MMU会查找页表来翻译这个地址。如果发生以下情况,MMU会触发一个缺页故障:
- 页表项是null,即无效的,未分配的。
- 页面不在物理内存中:页表项是已分配但未缓存的,即仍然驻留在磁盘上。
③权限不足:例如,试图向一个只读的页面写入数据(如.text代码段),或用户态程序尝试访问内核态页面。
7.8.2****缺页中断处理
这是一个硬件与操作系统内核紧密协作的过程。当触发缺页故障后:
①硬件响应(由MMU和CPU完成)
-
识别异常:CPU检测到MMU触发的缺页故障,中断当前用户进程的执行。
-
保存上下文:将当前进程的上下文(寄存器状态、程序计数器PC等)保存到其内核栈中。注意:此时保存的PC就是那条引起故障的指令地址。
-
切换模式:从用户态切换到内核态。
-
跳转:CPU根据中断描述符表,将控制权转移到操作系统预先设定好的缺页中断处理程序(Page Fault Handler),这是一个内核函数。
②软件处理(由操作系统内核完成)
操作系统被调用后,开始执行以下逻辑:
- 诊断原因:内核检查发生故障的虚拟地址和错误类型(通过检查CPU的状态寄存器)。
如果地址非法(例如,超出进程地址空间范围,或违反权限),则转入错误处理(如向进程发送SIGSEGV信号,终止进程)。
否则,确认这是一个"合法"的缺页(页面在磁盘,或需要分配新页)。
-
分配物理页帧:内核找到一个空闲的物理页帧。如果物理内存已满,则必须运行页面替换算法,选择一个"牺牲页",将其写回磁盘(如果它是脏的),并腾出该页帧。
-
加载数据:
如果缺页是因为页面在磁盘(交换区或映射的文件),内核发起一个磁盘I/O请求,将所需的页面从磁盘读入刚刚分配的物理页帧。此间,当前进程会被阻塞,切换到其他进程运行。
如果缺页是因为首次访问(如.bss段或新分配的堆页),内核可能直接将这个新分配的物理页帧清零(出于安全考虑)。
-
更新页表:当I/O完成(或页面被清零)后,内核修改当前进程的页表,创建或更新对应的页表项,使其指向新的物理页帧,并设置存在位、权限位等。
-
恢复执行:缺页处理程序返回。它不返回到自己的调用点之后,而是返回到最初触发故障的用户进程指令。
③硬件重试
-
恢复上下文:CPU从内核栈恢复之前保存的用户进程上下文。
-
重新执行指令:因为PC被恢复到了触发故障的那条指令,CPU会重新执行它。
-
成功访问:这次执行时,MMU再次查找页表,此时页表项有效且存在,地址翻译成功,指令得以正常执行下去。
7.8.3 实际例子

如图,MMU根据虚拟地址想访问未缓存的页(在图中用灰色来表示),这时会触发缺页故障,中断当前用户进程的执行,然后将当前进程的上下文(寄存器状态、程序计数器PC等)保存到其内核栈中。用户态切换为内核态,内核开始执行缺页中断处理程序。
缺页中断处理程序发现物理内存已满,必须运行页面替换算法,选择一个"牺牲页"(在本例中是VP4),腾出一个空闲的物理页。

然后内核发起一个磁盘I/O请求,将所需的页面(VP3)从磁盘读入刚刚分配的物理页。此间,当前进程会被阻塞,切换到其他进程运行。当I/O完成后,内核修改当前进程的页表,更新对应的页表项,使其指向新的物理页,并设置存在位、权限位等。

最后缺页处理程序返回。它不返回到自己的调用点之后,而是返回到最初触发故障的用户进程指令(内核态切换为用户态),CPU再从内核栈恢复之前保存的用户进程上下文,重新执行触发故障的那条指令。
7.9动态存储分配管理
Printf 会调用 malloc ,请简述动态内存管理的基本方法与策略。(此节课堂没有讲授,选做,不算分)
7.10本章小结
本章系统剖析了Hello进程在其生命周期中所经历的完整存储管理流程,深刻揭示了现代操作系统如何通过多层抽象与硬件协同,为用户程序营造一个安全、连续且高效的虚拟内存环境。
首先,本章厘清了逻辑地址、线性地址(虚拟地址)与物理地址之间的转换关系。在Linux所采用的平坦内存模型下,编译器产生的逻辑地址几乎直接等同于虚拟地址,这一简化为后续的页式管理奠定了基础。核心的地址转换机制由页式管理实现,它将连续的虚拟地址空间映射到可能不连续的物理内存上,从而实现了内存保护、隔离与高效利用。
为了克服单纯依赖内存页表带来的性能瓶颈,现代处理器引入了TLB(快表) 作为页表的高速缓存。本章详细阐述了在x86-64架构下,结合四级页表与TLB的虚拟地址到物理地址(VA-to-PA)的转换流程,展示了硬件如何在速度与灵活性之间取得平衡。地址转换成功后,对物理内存的访问同样依赖于三级Cache(L1/L2/L3) 层次结构,这一设计极大地缓解了CPU与主存之间的速度鸿沟。
在进程层面,本章重点分析了两个关键系统调用对存储空间的塑造作用。fork 过程并非简单地复制物理内存,而是通过写时复制(Copy-on-Write, COW) 机制,仅复制虚拟内存描述结构(如mm_struct和vm_area_struct),并将共享页标记为只读。只有当父子进程之一真正尝试写入时,才会触发缺页中断并完成物理页的复制,这种惰性策略极大地优化了系统资源的使用效率。而 execve 则彻底重塑了进程的地址空间,它删除旧的用户区域,并根据可执行文件的ELF结构,通过内存映射(mmap) 机制建立新的私有区域(代码、数据、堆、栈)和共享区域(动态链接库),为新程序的执行铺平道路。
整个虚拟内存系统的健壮性由缺页故障(Page Fault) 机制保障。无论是首次访问未加载的代码页、访问未初始化的堆空间,还是触发COW写操作,都会引发缺页中断。操作系统内核的缺页处理程序会精准诊断原因,按需从磁盘加载数据或分配物理页帧,并更新页表,最终让引发故障的指令得以成功重试。这一机制是按需分页、内存映射和写时复制等高级特性得以实现的核心基础。
(第 7 章 2 分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O 接口
Unix I/O 是Unix/Linux系统中一套统一、简洁的输入输出模型,其核心哲学是"一切皆文件"。该模型将所有I/O设备(磁盘、终端、网络套接字、管道等)都抽象为文件,通过文件描述符(File Descriptor)进行统一访问,提供了统一的读写接口。
8.2.2 Unix I/O 函数
在介绍函数本体之前,先介绍一个重要的概念:文件描述符( File Descriptor, fd )。
文件描述符是一个非负整数,是进程内对已打开文件的引用标识,标准文件描述符有三个:
0: 标准输入(STDIN_FILENO)
1: 标准输出(STDOUT_FILENO)
2: 标准错误(STDERR_FILENO)
常见的函数有以下几种:
- 文件打开与关闭
|---------------------------------------------------------------------------------------------------------------------------------------------|
| #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> /* 打开文件 */ int open(const char *pathname, int flags, mode_t mode); |
| flags:指定打开方式 |
| O_RDONLY, O_WRONLY, O_RDWR:读写模式 O_CREAT:不存在则创建 O_TRUNC:存在则清空 O_APPEND:追加模式 O_NONBLOCK:非阻塞模式 O_SYNC:同步写入(数据落盘) |
| /* 关闭文件 */ int close(int fd); |
- 文件读写操作
|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
| #include <unistd.h> /* 从文件读取数据 */ ssize_t read(int fd, void *buf, size_t count); /* 向文件写入数据 */ ssize_t write(int fd, const void *buf, size_t count); |
Unix I/O 的特点
无缓冲I/O:系统调用直接读写内核缓冲区,不提供用户空间缓冲
阻塞/非阻塞I/O:默认阻塞,可设置为非阻塞
面向字节流:读写以字节为单位,不区分记录边界
统一接口:不同设备使用相同接口
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章分析了hello程序的I/O管理机制,揭示了操作系统如何通过统一的抽象模型实现多样化的设备交互。Linux采用"一切皆文件"的哲学,通过文件描述符这一简洁而强大的抽象,将键盘、显示器等不同特性的设备纳入统一的I/O框架。这种设计不仅简化了编程模型,还增强了系统的可扩展性和可维护性。
I/O操作的本质是跨越用户空间与内核空间的系统调用过程。以printf和getchar为例,它们分别展示了输出和输入操作的完整链路:从高级语言库函数,到系统调用接口,再到内核缓冲区管理,最终抵达设备驱动程序和物理硬件。这一过程中,系统巧妙地处理了缓冲、同步、中断等复杂问题,为应用程序提供了简洁透明的接口。
特别值得注意的是I/O操作的层次化设计:用户级I/O库函数(如printf)提供格式化和缓冲功能;系统调用层(如write/read)实现用户态到内核态的转换;内核I/O子系统管理缓冲区、调度请求;设备驱动层处理特定硬件细节;最终由硬件控制器完成物理信号的转换与传输。这种分层架构使系统各组件职责清晰,也便于针对不同设备特性进行优化。
在hello程序执行过程中,这种I/O机制支持了其与用户的交互:打印提示信息、等待输入、响应中断信号。这些看似简单的操作背后,是操作系统精心设计的设备抽象、缓冲策略、中断处理与同步机制的协同工作。I/O子系统作为计算机系统与外部世界的桥梁,其设计质量直接影响用户体验和系统性能,体现了计算机系统"简单接口,复杂实现"的设计哲学。
(第 8 章 1 分)
结论
从计算机系统的视角,逐条总结 hello 程序所经历的完整过程:
①编写与预处理 (P2P: From Program to .i)
过程:程序员使用文本编辑器创建高级语言源代码 hello.c(Program)。预处理器(cpp)执行以 # 开头的指令,例如将 #include 的头文件内容直接插入、展开 #define 的宏、删除注释等,生成一个纯净的、不包含预处理指令的 C 代码文件 hello.i。
系统角色:预处理器进行文本级别的替换与合并,为编译阶段做准备。
②编译 (From .i to .s)
过程:编译器(cc1)对 hello.i 进行词法分析、语法分析、语义分析和优化,将其翻译成与特定 CPU 架构(如 x86-64)对应的汇编语言文件 hello.s。
系统角色:编译器将高级语言逻辑转换为低级的、符号化的汇编指令,并进行机器无关优化。
③汇编 (From .s to .o)
过程:汇编器(as)将人类可读的汇编指令 hello.s 逐条翻译成机器指令(二进制编码),生成可重定位目标文件 hello.o。此文件包含代码(.text)、数据(.data, .rodata)、符号表(.symtab)和重定位表(.rela.text)等信息,但地址尚未确定。
系统角色:汇编器完成从符号化指令到机器码的转换,生成"可重定位"目标文件。
④链接 (From .o to Executable)
过程:链接器(ld)将多个目标文件(如 hello.o)和所需的库文件(如 libc.a 或 libc.so)进行整合。主要工作包括:
符号解析:将目标文件中的符号引用(如 printf)与定义进行匹配。
重定位:合并相同类型的节(如所有 .text 节),并为符号和节分配最终的运行时内存地址,修正代码中的地址引用。
系统角色:链接器解决模块间引用,生成一个统一的、地址空间完整的可执行目标文件 hello。
⑤加载与进程创建 (P2P: From Executable to Process)
过程:用户在 Shell 中输入 ./hello 并回车。Shell 通过 fork() 系统调用创建一个新的子进程。子进程通过 execve() 系统调用加载 hello 可执行文件,取代当前进程的地址空间,从而将磁盘上的程序(Program)转变为内存中一个正在执行的进程实例(Process)。
系统角色:操作系统(内核)的进程管理模块负责进程创建(fork)和程序加载(execve),为程序执行准备独立的虚拟地址空间和运行上下文。
⑥进程执行 (Process Execution)
过程:
CPU 调度:操作系统调度器为 hello 进程分配 CPU 时间片。CPU 从程序入口点(如 _start)开始取指、译码、执行。
内存访问:CPU 发出的虚拟地址(VA)通过 MMU 查询页表转换为物理地址(PA)。TLB 加速此过程。数据在 CPU 缓存和主存之间流动。
函数调用:执行 main 函数,调用 printf, sleep, getchar 等库函数。这些调用可能涉及用户态到内核态的切换(系统调用)。
I/O 操作:printf 通过 write 系统调用将数据写入标准输出(屏幕)。getchar 通过 read 系统调用从标准输入(键盘)读取数据。
系统角色:CPU 执行指令,OS 进行调度、内存管理、处理系统调用,硬件(MMU, TLB, Cache)协同保证高效执行。
⑦异常与信号处理 (Exception & Signal Handling)
过程:在进程执行期间,可能发生异常或接收信号。例如,按下 Ctrl-C(SIGINT)会导致进程终止,按下 Ctrl-Z(SIGTSTP)会挂起进程。缺页故障(Page Fault)会触发内核加载缺失的页面。
系统角色:操作系统内核作为异常和信号的处理程序,中断当前进程的执行,响应外部事件或内部错误,维护系统的稳定性和交互性。
⑧进程终止 (O2O: From Process to Zero)
过程:hello 程序的 main 函数返回,或调用 exit() 系统调用,或收到终止信号。操作系统终止该进程,回收其占用的所有资源(内存、文件描述符、PCB 等),进程状态彻底消失,回归"零"状态。
系统角色:操作系统内核负责进程资源的清理和回收,确保没有资源泄漏。
总结:hello 程序的一生经历了从源代码(文本)到可执行文件(二进制)的静态构建阶段(P2P1),以及在运行时从磁盘上的程序被加载为内存中活动的进程(P2P2),最终在 CPU 和操作系统的共同管理下执行,直至资源被回收、进程消亡(O2O)的完整生命周期。整个过程深刻体现了编译系统、操作系统、处理器和硬件之间的精密协同工作。
我对计算机系统的深切感悟:即使是最简单的hello程序从.C源文件到在屏幕上输出也会经历如此复杂的过程,佩服无数计算机前辈们的聪明才智。
(结论0分,缺失-1分)
附件
|---------------|---------------------------------------------------------------------------------------------------|
| 中间结果文件的名字 | 文件的作用 |
| hello.i | 预处理器生成的hello.i是一个'纯净'的C代码文件,不包含任何预处理指令,可以直接交给编译器进行语法分析和代码生成。 |
| hello.s | 编译器将预处理后的C代码(hello.i)翻译成的汇编语言文件hello.s,包含了与源代码等效的、面向特定CPU架构的低级指令,是连接高级语言与机器码的桥梁,可交由汇编器进一步处理 |
| hello.o | 汇编器对汇编代码文件(hello.s)进行汇编处理后生成的可重定位目标文件 hello.o,其中包含机器指令、数据以及符号表等信息,但尚未进行最终地址解析与库链接,是链接器生成可执行文件的基础。 |
| hello | 链接器将目标文件(hello.o)及所需的库文件进行链接、重定位后生成的最终可执行文件 hello.out,其中包含完整的机器代码与运行时信息,可直接在终端执行。 |
| hello.asm | objdump根据可执行文件(hello)生成的反汇编文件 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
1\] 兰德尔 E. 布莱恩特,大卫 R. 奥哈拉伦. 深入理解计算机系统(原书第3版)\[M\]. 龚奕利,贺莲译. 北京:机械工业出版社,2016. \[2\] Pianistx. \[转\]printf 函数实现的深入剖析\[EB/OL\]. 博客园, \[2013-09-11\] \[2026-01-02\] [\[转\]printf 函数实现的深入剖析 - Pianistx - 博客园](https://www.cnblogs.com/pianist/p/3315801.html "[转]printf 函数实现的深入剖析 - Pianistx - 博客园"). \[3\] 九曲阑干【CSAPP-深入理解计算机系统】7-2 .可重定位目标文件\[EB/OL\]. 哔哩哔哩, \[2021-11-06\] \[2026-01-02\] [【CSAPP-深入理解计算机系统】7-2. 可重定位目标文件_哔哩哔哩_bilibili](https://www.bilibili.com/video/BV13q4y137za?spm_id_from=333.788.videopod.sections&vd_source=68a6a79a6e0a15d440e4049a6ee44b55 "【CSAPP-深入理解计算机系统】7-2. 可重定位目标文件_哔哩哔哩_bilibili") **(参考文献0分,缺失 -1分)**