【程序人生-Hello‘s P2P】

摘  要

本篇文章聚焦于阐述 C 语言程序从源代码到可执行文件的转变过程。以 hello.c 程序为案例,深入剖析了计算机生成 hello 可执行文件所经历的预处理、编译、汇编、链接以及进程管理等全流程。论文不仅从理论层面深入探究了相关工具的原理与方法,还通过实际操作演示了工具的运用及相应结果,以此剖析计算机系统的工作原理与体系架构,致力于助力读者更为深入且透彻地理解与掌握 C 语言程序的编译和执行机制。

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

目  录

 

第1章 概述................................................................................... - 4 -

1.1 Hello简介............................................................................ - 4 -

1.2 环境与工具........................................................................... - 5 -

1.3 中间结果............................................................................... - 5 -

1.4 本章小结............................................................................... - 5 -

第2章 预处理............................................................................... - 7 -

2.1 预处理的概念与作用........................................................... - 7 -

2.2在Ubuntu下预处理的命令................................................ - 7 -

2.3 Hello的预处理结果解析.................................................... - 8 -

2.4 本章小结............................................................................... - 8 -

第3章 编译................................................................................... - 9 -

3.1 编译的概念与作用............................................................... - 9 -

3.2 在Ubuntu下编译的命令.................................................... - 9 -

3.3 Hello的编译结果解析........................................................ - 9 -

3.4 本章小结............................................................................. - 14 -

第4章 汇编................................................................................. - 16 -

4.1 汇编的概念与作用............................................................. - 16 -

4.2 在Ubuntu下汇编的命令.................................................. - 16 -

4.3 可重定位目标elf格式...................................................... - 17 -

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

4.5 本章小结............................................................................. - 24 -

第5章 链接................................................................................. - 25 -

5.1 链接的概念与作用............................................................. - 25 -

5.2 在Ubuntu下链接的命令.................................................. - 25 -

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

5.4 hello的虚拟地址空间....................................................... - 32 -

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

5.6 hello的执行流程............................................................... - 38 -

5.7 Hello的动态链接分析...................................................... - 39 -

5.8 本章小结............................................................................. - 40 -

第6章 hello进程管理.......................................................... - 41 -

6.1 进程的概念与作用............................................................. - 41 -

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

6.3 Hello的fork进程创建过程............................................ - 42 -

6.4 Hello的execve过程........................................................ - 42 -

6.5 Hello的进程执行.............................................................. - 43 -

6.6 hello的异常与信号处理................................................... - 44 -

6.7本章小结.............................................................................. - 50 -

第7章 hello的存储管理...................................................... - 51 -

7.1 hello的存储器地址空间................................................... - 51 -

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

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

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

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

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

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

7.8 缺页故障与缺页中断处理................................................. - 58 -

7.9本章小结.............................................................................. - 59 -

结论............................................................................................... - 60 -

附件............................................................................................... - 62 -

参考文献....................................................................................... - 63 -

第1章 概述

1.1 Hello简介

1.1.1 P2P

P2PProgram to Process强调程序从静态源代码到动态运行进程的转化过程,其中包含以下中间步骤与文件形态:

Program(程序源代码)

.c文件:源代码文件,如hello.c,此时程序仅是文本形式。

预处理阶段(Preprocessing

.i 文件:使用预处理器(如gcc -E)对hello.c进行宏展开、头文件展开后生成的文件,如hello.i。

编译阶段(Compilation

.s 文件:编译器(如gcc -S)将.i文件转换成汇编语言形式的文件,如hello.s。

汇编阶段(Assembly

.o 文件:汇编器(如gcc -c或as)将汇编语言代码转换成机器语言(二进制指令)的目标文件,如hello.o。

链接阶段(Linking

可执行文件(.out或可直接执行的二进制文件,如hello):链接器(如ld或gcc hello.o -o hello)将多个目标文件与系统库链接后生成最终的可执行文件(如a.out或hello)。

Process(进程)

当用户运行程序(如执行./hello或./a.out)后,操作系统将可执行文件加载到内存中,创建一个动态运行的实例(进程),使得静态的可执行文件变为动态运行的进程。

P2P完整阶段的具体文件序列为:

hello.c → hello.i → hello.s → hello.o → hello/a.out(可执行文件)→ 运行中的进程

1.1.2 020

“020”强调程序生命周期内从资源空闲(零状态)到占用资源(非零状态),再回到资源释放(零状态)的完整过程,结合文件状态说明如下:

第一个“0”(零状态)

    初始时程序(源代码)处于静态磁盘存储状态,没有实际资源被占用,仅仅是文本文件(.c)。

中间的“2”(从零到非零,资源分配)

程序经历以下中间文件的生成:

.i文件(预处理后的中间文件,占用磁盘)

.s文件(编译后汇编文件,占用磁盘)

.o文件(汇编后目标文件,占用磁盘)

.out或可执行文件(占用磁盘,准备运行)

    运行程序时,操作系统通过系统调用加载可执行文件到内存中创建进 程,分配内存、CPU、I/O资源,正式进入非零(占用资源)状态。

最后一个“0”(从非零回到零,资源回收)

当程序运行结束后,进程终止,操作系统释放所有被占用的资源(内存、文件资源、CPU资源等),磁盘中中间文件(如.i、.s、.o、.out)也可由用户手动删除,系统重新回到初始的零状态。

020完整阶段序列:

初始状态(0资源) → hello.c→ hello.i (预处理)→ hello.s (编译)→ hello.o (汇编)→ hello/a.out (链接)→ 创建进程(分配内存和CPU资源,非0状态运行)→ 进程终止,资源释放(回到0状态)

1.2 环境与工具

PU i7-12700H,16G RAM,1T SSD;Win11,VMware,Ubuntu20.04

1.3 中间结果

表格 1 hello的中间结果

hello.i

预处理后得到的文本文件

hello.s

编译后得到的汇编语言文件

hello.o

汇编后得到的可重定位目标文件

hello.asm

反汇编hello.o得到的反汇编文件

hello1.asm

反汇编hello可执行文件得到的反汇编文件

hello.elf

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

1.4 本章小结

本章主要介绍了程序从源代码到运行过程中的各个中间阶段及其对应的文件形态,并结合资源生命周期阐明了程序在磁盘与内存中从“零”到“非零”再回到“零”的完整过程。此外,还说明了实验所使用的软硬件环境及各阶段的具体中间产物。

图 1编译系统

第2章 预处理

2.1 预处理的概念与作用

2.1.1 预处理的概念

预处理是C语言程序编译过程中的第一阶段,由预处理器(CPP)完成。预处理主要执行文本替换任务,包括:宏定义的展开、头文件的包含(如stdio.h、stdlib.h)、条件编译以及注释的删除。预处理不会进行语法检查,其输出结果依旧是可读的C语言代码。

2.1.2 预处理的作用

预处理的作用在于提高程序的模块化和可读性,简化代码管理,提高程序移植性。经过预处理后的文件扩展名通常为“.i”,该文件将成为后续编译阶段的输入。

2.2在Ubuntu下预处理的命令

预处理的命令:gcc -E hello.c -o hello.i

图 2在Ubuntu下预处理的命令

  • gcc:调用GNU C编译器。
  • -E:指定只进行预处理,不进行编译、汇编和链接。
  • hello.c:源代码文件名。
  • -o hello.i:指定输出的预处理文件名为hello.i。

图 3 gcc -E hello.c -o hello.i的结果

2.3 Hello的预处理结果解析

图 4  hello.i文件内容展示

对生成的hello.i文件进行查看,我们可以发现以下内容变化:

1、原hello.c程序中的宏定义被完全展开。

2、所有的注释(如// 大作业的 hello.c 程序)被移除。

3、所有包含的头文件(如stdio.h、unistd.h、stdlib.h)被插入到源文件内,从而大幅增加了代码长度。例如,源程序中包含的#include <stdio.h>预处理后会展开成stdio.h头文件的完整内容,其中包括各种函数声明、类型定义及宏定义。

2.4 本章小结

本章主要阐述了C语言程序预处理的基本概念和作用,以及在Ubuntu系统中如何执行预处理命令。通过对hello.c的预处理实例,我们清晰地看到了宏定义展开、头文件插入和注释删除等预处理操作的具体效果,理解了预处理在整个编译流程中的重要地位,为后续的编译阶段提供了清晰的C语言源代码基础。

第3章 编译

3.1 编译的概念与作用

3.1.1 编译的概念

编译是将高级语言源代码(此处为预处理后的文件 .i)转换为汇编语言程序(扩展名 .s)的过程。这一阶段由编译器负责实现,它对代码进行词法分析、语法分析、语义检查、以及一定程度的优化,最终输出汇编语言形式的中间代码。

3.1.2 编译的作用

编译的作用主要是使高级语言程序能够被计算机进一步处理,并优化代码结构,提高程序的运行效率

3.2 在Ubuntu下编译的命令

编译的命令:gcc -S hello.i -o hello.s

图 5在Ubuntu下编译的命令

  • gcc:调用GNU C编译器。
  • -S:指定只编译到汇编代码阶段。
  • hello.i:预处理后的文件。
  • -o hello.s:指定输出汇编语言文件名。

图 6 gcc -S hello.i -o hello.s的结果

3.3 Hello的编译结果解析

3.3.1 汇编初始部分

在main函数前有一部分字段展示了节名称:

图 7 hello.s汇编初始部分

以下是汇编初始部分的节名称及其含义:

表格 2汇编初始部分的节名称及其含义

.file

声明出源文件

.text

表示代码节

.section和.rodata

表示只读数据段

.align

声明对指令或者数据的存放地址进行对齐的方式

.string

声明一个字符串

.globl

声明全局变量

.type

声明一个符号的类型

3.3.2 数据部分

(1)字符串程序有两个字符串存放在只读数据段中,如图:

图 8两个字符串存放在只读数据段

hello.c 中,唯一的字符串数组是在 main函数中传入的第二个参数(即 char **argv)。每个数组元素是一个指向字符串的指针。由于数组的起始地址存放在栈中,并且该地址被两次传递作为参数给 printf,可以看到如下代码将 %rax 寄存器设置为这两个字符串的起始地址:

图 9 图 10两个字符串的起始地址

(2)参数argc

参数argcmain 函数的第一个参数,它被存储在 %edi 寄存器中。

图 11寄存器压入栈

从第22行,可以看出寄存器%edi地址被压入栈中。

从第24行,可以看出该地址上的数值与立即数5进行比较,从而得知argc的值是否大于或者小于5;如果小于5,则被存放在寄存器并被压入栈中。

(3)局部变量的存储

程序中的局部变量只有一个,通过第32行语句(下图)可以看到局部变量 i 存储在栈的 -4(%rbp) 地址位置。

图 12局部变量的存储

3.3.3 全局函数

hello.c 中只声明了一个全局函数 int main(int argc, char* argv[]),我们通过汇编代码可以看到它是如何被定义和实现的。

在汇编代码中,相关的声明如下:

图 13全局函数

这两行代码表明 main 是一个全局函数。.global 指令声明 main 为全局符号,意味着它可以被其他模块或文件所引用。.type 指令则指定 main 的类型为函数类型,即表明它是一个函数。

在汇编中,main 函数被定义在 .text 段中,标识着程序的执行代码部分。通过这段汇编代码,我们可以看到 main 函数的入口和相关实现细节。

3.3.4 赋值操作

1、赋值给栈中的位置:

图 14赋值给栈中的位置

在该程序中,使用了 movl movq 指令进行赋值操作。特别注意,movl 用于将 32 位数据移动到栈中,而 movq 用于将 64 位数据移动到栈中。栈中数据的位置通常由基指针 %rbp 指定。

2、寄存器之间的赋值:

程序中也有将一个寄存器的值赋给另一个寄存器的操作。例如:

图 15寄存器之间的赋值

这条指令将栈指针 %rsp 的值赋给基指针 %rbp,为接下来的栈操作提供基准

3、常数赋值给栈上的位置:

    程序中使用 movl 将常数值赋给栈上的特定位置。例如:

图 16常数赋值给栈上的位置

这条指令将常数 0 赋值给栈上 -4(%rbp) 的位置。

4、调用函数时传递参数:

通过将值赋给特定寄存器,程序将参数传递给外部函数。在这段代码中,传递给函数的参数值通过以下几种赋值操作来设置:

    

图 17图 18调用函数时传递参数

这里 movq -32(%rbp), %rax 将栈上数据赋给 %rax 寄存器,接着使用 leaq 将字符串地址赋给 %rdi 寄存器,这些寄存器的值随后会作为参数传递给函数(如 printf 和 puts)。

3.3.5 算术操作

1、减法操作(subq):

这条指令将常数 32 从栈指针 %rsp 中减去,意味着栈指针向下移动了 32 字节,通常是为局部变量分配栈空间。

图 19减法操作

2、加法操作(addq):

这条指令将常数 24 加到 %rax 寄存器的值上,更新 %rax 寄存器的内容。

图 20加法操作

3.3.6 关系操作

1、比较操作(cmp):

图 21比较操作

这条指令将栈上 -20(%rbp) 地址处的数据与常数 5 进行比较。cmp 指令会根据两个操作数的差值设置标志寄存器,但不会保存结果。它只是用于为条件跳转指令提供信息。

2、条件跳转(je):

图 22条件跳转

这条指令会在比较结果为“相等”时跳转到标签 .L2。也就是说,如果 cmpq 操作中的两个数相等,程序就会跳转到 .L2 处执行。

3.3.7 控制转移指令

利用je、jmp、jle进行跳转,控制转移指令

图 23 je控制转移指令

这个指令是 "Jump if Equal"(若相等则跳转)。它会根据上一条比较指令(cmpl)的结果来决定是否跳转到标签 .L2

图 24 jmp控制转移指令

这个指令是无条件跳转。它会跳转到标签 .L3。

图 25 jle控制转移指令

这个指令是 "Jump if Less or Equal"(若小于或等于则跳转)。它会根据上一条比较指令(cmpl)的结果来决定是否跳转到标签 .L4。

3.3.8 函数操作

1、main 函数

(1)参数传递:

main 函数的参数为 int argc, char* argv[],其中 argc 表示命令行参数的数量,argv 是一个指针数组,每个元素指向一个参数的字符串。参数 argv[0] 是程序名称,argv[1] 是第一个命令行参数,依此类推。

这些参数的地址和值都在函数调用前通过栈进行传递。在汇编代码中,%rdi、%rsi、%rdx 等寄存器用于传递参数。

(2)函数调用:

在 main 函数中,使用了 call 指令调用其他函数。主要的调用有:

  • printf用于打印输出,输出格式是 "Hello %s %s %s\n",对应的参数是 argv[1]、argv[2] 等。
  • exit程序退出,传递了一个退出状态(通常是1)。
  • sleep让程序暂停指定的秒数,秒数由 atoi 函数转换的结果提供。

(3)局部变量:

main 函数使用了局部变量 i,用于循环迭代等操作。在汇编代码中,局部变量通过栈分配,通常在栈中有一个偏移量。

2、printf 函数

(1)参数传递:

printf 函数在汇编中调用时,参数通过寄存器传递。具体来说,格式字符串(例如 "Hello %s %s %s\n")的地址传递给 %rdi,argv[1] 和 argv[2] 等通过 %rsi 和 %rdx 等寄存器传递。

第一次调用时,格式字符串的地址传递给 %rdi,第二次传递 argv[1] 和 argv[2]。

(2)函数调用:

在汇编中,通过 call printf@PLT 调用 printf 函数。这种调用会跳转到 printf 的地址,执行其功能。

图 26调用printf函数

3、exit 函数

参数传递与函数调用:

exit 函数接收一个退出状态作为参数,通常将退出状态设置为1或0。汇编代码中,将退出状态(1)传递给 %rdi 寄存器,然后通过 call exit@PLT 调用 exit 函数,退出程序。

图 27调用exit函数

4、atoi 和 sleep 函数

(1)atoi 函数:

atoi 函数将字符串参数转换为整数。在汇编中,将 argv[3] 的地址传递给 %rdi,然后调用 atoi 函数来进行转换。

atoi 函数执行后,返回的整数存储在 %eax 寄存器中。

图 28调用atoi函数

(2)sleep 函数:

sleep 函数接收一个整数(秒数)作为参数。在汇编中,将 atoi 函数返回的值传递给 %edi,然后调用 sleep 函数。程序会暂停指定的秒数。

图 29调用sleep函数

5、getchar 函数

无参数传递:

getchar 函数从标准输入读取一个字符。它没有参数,因此在汇编中直接使用 call getchar@PLT 来调用该函数。

图 30调用getchar函数

总结来说,代码中的每个函数通过寄存器进行参数传递,并使用 call 指令进行调用。具体的参数通过栈和寄存器传递,汇编代码中详细描述了这些操作。

3.3.9 类型转换

1atoi 函数调用(字符串转整数)

atoi(ASCII to Integer)函数将字符串表示的数字转换为整数。在代码中,我们看到 atoi 的调用位于 第 51 行:

call atoi@PLT

该函数的输入是 argv[3] 的地址,表示一个字符串(数字的字符表示)。在汇编中,argv[3] 的值通过寄存器 %rdi 传递给 atoi。atoi 函数会将该字符串解析为一个整数,并将结果存储在 %eax 寄存器中。其汇编语言如下:

movq argv[3], %rdi   ; 将 argv[3](字符串)加载到 %rdi

call atoi@PLT        ; 调用 atoi 将字符串转换为整数

这种类型转换是从字符串(字符数组)到整数类型(int)。

2、将 atoi 的返回值传递给 sleep(整数转秒数)

sleep 函数用于使程序暂停一段时间,参数为秒数。在汇编中,atoi 返回的整数存储在 %eax 寄存器中。这个值通过以下步骤传递给 sleep:

movl %eax, %edi     ; 将 %eax 中的值(整数秒数)传递给 %edi

call sleep@PLT       ; 调用 sleep 函数,暂停指定的秒数

这里,atoi 的返回值(整数)通过寄存器 %eax 传递到 %edi,然后传递给 sleep 函数。sleep 函数会根据整数值来控制暂停的时间。

3、其它隐性类型转换

代码中的类型转换主要集中在 atoi 和 sleep 函数调用中:

从字符串(char*)到整数(int)的转换是通过 atoi 完成的。将整数(int)传递给 sleep 函数作为秒数。

总结来说,在这个汇编代码中,类型转换 主要发生在:(1)字符串到整数(通过 atoi)。(2)整数作为秒数传递(通过 sleep)。

这些转换确保了输入数据(字符串)能够正确地被解析并用于需要整数的操作。

3.4 本章小结

  在本章中,我们详细探讨了编译的概念、作用以及在Ubuntu下的编译命令。通过对hello.c程序进行编译操作,我们深入理解了从高级语言源代码到汇编语言的转换过程。编译阶段对程序进行词法分析、语法分析和优化,最终输出汇编代码作为中间文件(.s)。此外,本章还详细介绍了编译过程中涉及的各个步骤和常用命令,并结合实际案例解析了编译结果。

在Ubuntu下使用gcc -S命令对hello.i进行编译时,程序的汇编输出通过不同的汇编指令来展示程序的结构和各部分功能。通过对汇编初始部分和数据部分的分析,进一步加深了对编译后代码结构和寄存器使用的理解。汇编语言不仅仅是高级语言的转化结果,还涉及到了硬件资源的管理和指令的优化。

本章的内容为后续的汇编、链接等阶段打下了坚实的基础,通过对编译结果的逐步分析和理解,我们获得了更深刻的计算机系统内部工作机制的认知。

第4章 汇编

4.1 汇编的概念与作用

4.1.1 汇编的概念

1、汇编语言:汇编语言是一种低级语言,设计用于与特定计算机硬件(尤其是CPU架构)直接交互。它通过一组助记符表示机器指令,例如 MOV, ADD,SUB 等。

2、目标文件(.o文件):目标文件是由汇编器(Assembler)将汇编语言源文件(.s 文件)转换而来的中间文件。目标文件包含机器语言的二进制代码,但它不能直接执行。它通常包含机器指令、符号表、重定位信息等。

4.1.2 汇编的作用

1、机器指令的生成

汇编就是将高级语言转化为机器可直接识别执行的代码文件的过程,汇编器将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式。 .o 文件是一个二进制文件,它包含程序的指令编码。

2、优化性能

汇编语言允许程序员在硬件层面进行精细的控制,因此它能够生成比高级语言更高效的代码。在对性能要求极高的场合,程序员会使用汇编语言进行关键部分的优化,确保程序以最低的资源消耗高效运行。

3、硬件控制

汇编语言与计算机硬件指令集紧密相关,因此它能够直接控制CPU寄存器、内存等硬件资源。这对于开发操作系统、嵌入式系统或设备驱动等需要与硬件紧密交互的程序非常重要。

4.2 在Ubuntu下汇编的命令

在Ubuntu系统下,对hello.s进行汇编的命令为:

gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o

运行截图如下:

图 31在Ubuntu下汇编的命令

gcc:GNU 编译器集合(GNU Compiler Collection)的命令,用于编译 C 语言、C++、汇编语言等程序。

-m64:指定生成 64 位代码。它告诉编译器使用 64 位架构的指令集(如 x86-64)。该选项影响编译过程中寄存器的大小和数据模型(指针大小为 64 位)。

-no-pie:禁用位置独立执行(Position Independent Executable, PIE)。PIE 是一种可执行文件的特性,可以在内存的任意位置加载。这个选项表示生成的目标文件不支持这一特性,通常是为了生成更加传统的可执行文件。

-fno-pic:禁用位置独立代码(Position Independent Code, PIC)。在生成共享库或需要在任意内存地址执行的代码时,编译器会生成位置独立代码,允许程序在内存的任何位置加载。这个选项指示编译器不生成位置独立代码。

-c:只进行编译,不进行链接。该选项意味着 gcc 会将汇编文件 hello.s 编译成目标文件 hello.o,但不会生成最终的可执行文件(即不链接)。

-o hello.o:指定输出文件的名称。此选项表示将编译后的目标文件命名为 hello.o。如果没有这个选项,默认的输出文件名为 a.out。

hello.s:这是输入的汇编源文件,包含用汇编语言编写的程序代码。该文件的扩展名 .s 表示这是汇编语言源代码。

以下是gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o的结果:

图 32 gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o的结果

4.3 可重定位目标elf格式

4.3.1 生成ELF格式的可重定位目标文件

在shell中输入readelf -a hello.o > hello.elf 指令获得 hello.o 文件的 ELF 格式,运行截图如下:

图 33readelf -a hello.o > hello.elf后的结果

readelf 是一个用于查看 ELF 文件内容的工具,能够显示 ELF 文件的各类信息。它可以展示如 ELF 头、节头、段头、符号表等各种细节内容。

-a是 readelf 命令的一个选项,表示输出 ELF 文件的所有信息

hello.o是一个目标文件(Object File)。这个文件通常是编译阶段的产物,包含机器语言指令和其他信息,但尚未经过链接。

>是 Linux/Unix 命令中的输出重定向符号。它将命令的标准输出(stdout)保存到指定的文件中,而不是在终端显示。

4.3.2 查看ELF格式文件的内容

ELF(Executable and Linkable Format)是一种广泛使用的文件格式,用于存储可执行文件、目标文件、共享库和核心转储等。ELF格式定义了文件的结构,包括文件头、节头、段头等。

1、ELF

ELF头部存储了ELF文件的基本信息,例如文件类型、系统架构、入口点、节头的位置等。

(1)Magic:7f 45 4c 46 是ELF文件的标识符。它用来确保文件是ELF格式。接下来的部分表示该文件的类型信息,如02 01 01表示该文件为64位的ELF文件,采用小端序(little-endian)。

(2)类别:ELF64,表示该文件是64位格式。

(3)数据:2 补码,小端序,表明文件使用小端字节序,符合x86-64架构的常规设置。

(4)版本:版本1表示这是当前ELF格式的标准版本。

(5)操作系统/ABI:UNIX - System V表示该文件符合UNIX系统V的ABI(应用程序二进制接口)标准。

(6)类型:REL表示这是一个可重定位文件(Relocatable),一般用于编译阶段生成目标文件。

(7)系统架构:Advanced Micro Devices X86-64,表明该文件针对x86-64架构编译。

(8)入口点地址:0x0,说明该ELF文件没有设置有效的入口点(通常用于共享库或中间文件)。程序头起点:0,没有程序头,因此该文件没有程序段(只包含节)。

(9)节头起点:1088 (bytes into file),从文件的1088字节处开始存放节头信息。

(10)节头表的数量和大小:文件中有14个节头,每个节头大小为64字节。

(11)节头字符串表索引:节头的名称表索引为13,这个表包含了文件节的名称信息。

图 34 ELF头的信息

2、节头

节头表存储了ELF文件各个节的信息。每个节通常包含代码、数据、符号等信息。

[1] .text

类型:PROGBITS,表示包含程序代码。

权限:AX,表示此节具有执行(X)和分配(A)权限,通常存放程序的指令。

地址和偏移:文件偏移为0x40,长度为163字节。

[2] .rela.text

类型:RELA,这是一个重定位节,记录了需要在链接时修改的地址。

偏移:0x2f0,大小为192字节,包含了符号重定位信息。

[3] .data

类型:PROGBITS,存放程序的数据段。

权限:WA,表示该节可写(W)且分配(A)。

[4] .bss

类型:NOBITS,未初始化的全局变量和静态变量。

权限:WA,该节可写,且没有分配初始值。

[5] .rodata

类型:PROGBITS,存放只读数据。

权限:A,表示只读(A)且已分配。

[6] .comment

类型:PROGBITS,存储编译器注释信息。

权限:MS,表示可以合并(M)并且包含字符串(S)。

[7] .note.GNU-stack

存放栈信息,通常由GNU工具链生成,用来指示是否使用栈保护。

[8] .note.gnu.pr[...]

存储有关GNU工具的注释。

[9] .eh_frame

存放异常处理信息,通常用于C++程序中的异常机制。

[10] .rela.eh_frame

重定位信息,特别是与异常处理相关的部分。

[11] .symtab

符号表,包含程序中的所有符号信息。符号表中包含了函数、变量、段等符号。

[12] .strtab

字符串表,存储符号名称。

[13] .shstrtab

节头名称字符串表,存储所有节头的名称。

图 35节头表储存的信息

3、重定位节

重定位节存储了需要在链接时调整的符号和地址信息。.rela.text这个节包含了多个重定位条目,指示在链接过程中哪些地址需要被修正。例如:R_X86_64_PC32:这是一种重定位类型,表示需要修正一个32位的地址值,通常是相对地址。R_X86_64_PLT32:这表示需要修正一个指向PLT(过程链接表)中函数的指针。

图 36重定位节的信息

4、符号表

符号表(.symtab)存储了程序中所有符号(如函数、变量等)的信息。每个符号条目包含符号的值、类型、大小、绑定方式(是否为全局符号)等信息。例如:

(1)main:这是程序的入口点(main函数),类型是FUNC,并且是全局符号。

(2)puts、exit、printf、atoi、sleep、getchar:这些是外部函数,标记为UND(未定义),表示它们是从其他文件(如标准库)中引入的。

图 37符号表的信息

4.4 Hello.o的结果解析

4.4.1 命令

在shell中输入 objdump -d -r hello.o > hello.asm 指令输出hello.o的反汇编文件,运行截图如下:

图 38 objdump -d -r hello.o > hello.asm的结果

objdump 是一个常用的工具,用于显示目标文件(如 .o 文件)的内容,它支持反汇编、符号表查看、节头信息查看等功能。objdump 是 GNU 二进制工具集的一部分。

-d 选项表示反汇编操作,具体来说,它会将目标文件中的机器代码转换为人类可读的汇编代码。这个选项用于查看目标文件中的代码段(例如 .text 段)如何映射为汇编指令。这意味着,objdump -d 会将文件中的二进制机器指令(如 CPU 执行的指令)反汇编为相应的汇编指令(如 mov, add, jmp 等),便于理解和分析。

-r 选项表示显示重定位信息。重定位是链接过程中调整程序地址的过程,目标文件通常包含重定位信息,这些信息帮助链接器在程序的最终地址空间中修正指令和数据的地址。这个选项会输出重定位节(如 .rela.text 等)的内容,显示与文件中每个地址相关的符号和地址修正。

hello.o 是一个目标文件,通常是在编译阶段生成的二进制文件,包含了汇编语言代码转换后的机器代码。目标文件还可能包含其他节,如 .text(代码段)、.data(数据段)等。

> 是 Linux/Unix 中的输出重定向符号。它会将命令的输出结果写入指定的文件,而不是直接在终端显示。

4.4.2 与hello.s的对照分析

1、增加机器语言

在hello.asm中,每一条指令增加了一个十六进制的表示,即该指令的机器语言。例如:在hello.s中,我们第21行语句如下:

图 39 hello.s中第21行语句

而在hello.asm中,增加了一个十六进制的表示,表示如下:

图 40hello.asm中第11行语句

2、操作数的进制

在1、增加机器语言的例子中,操作数也进行了改变。原在hello.s中的操作数$32,在hello.asm中,操作数变成了十六进制,变成了$0x20。可见只是进制表示改变,数值未发生改变。

3、分支转移

反汇编的跳转指令中,所有跳转的位置被表示为主函数+段内偏移量这样确定的地址,见下图,而不再是hello.s中的段名称。

图 41 hello.asm中主函数+段内偏移量表示转移地址

而在hello.s中,地址用.L2、.L3、.L4这样的段地址进行跳转,见下图:

图 42 hello.s中段地址表示转移地址

4、函数调用

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

图 43 hello.s中调用函数表示

在汇编语言中,函数调用则是通过类似 call puts@PLT 的汇编指令表示。这会将函数地址加载到寄存器中并跳转执行。例如,call puts@PLT 会调用 puts 函数,call exit@PLT 会调用 exit 函数,call printf@PLT 调用 printf,call atoi@PLT 调用 atoi,等等。

而在hello.asm中,函数调用在反汇编中显示为机器指令。e8 00 00 00 00 是 call 指令(调用函数),并且后面跟随的是函数地址或相对地址。

图 44 hello.asm中调用函数表示

5、符号表与重定位

反汇编的 hello.asm 直接显示了重定位条目和符号名。例如:.rela.text 中的重定位条目(如 R_X86_64_PLT32 puts)表示需要将某些符号地址(如 puts)修正为正确的地址。

图 45 hello.asm的地址修正

hello.s在汇编源代码中,符号的名称直接作为汇编指令的操作数。例如:movq %rdi, -32(%rbp) 和 call puts@PLT 都是直接使用了符号(puts)和寄存器。

4.5 本章小结

本章主要介绍了汇编语言的基本概念和作用,详细阐述了汇编阶段在程序从源代码到目标文件转化过程中的关键作用。首先,本文探讨了汇编语言与机器指令的关系,强调了汇编语言如何通过助记符指令直接与计算机硬件交互,从而生成可执行的机器代码。接着,本文分析了在Ubuntu系统下进行汇编操作的过程,并详细解释了可重定位目标文件(.o 文件)的结构。

在汇编过程中,目标文件采用ELF格式进行存储,通过使用readelf工具查看了该格式的详细信息,包括ELF头、节头及符号表等。此外,我们还利用objdump命令对目标文件进行了反汇编分析,进一步理解了目标文件与汇编源文件之间的差异,尤其是在机器语言表示和符号表重定位方面。

第5章 链接

5.1 链接的概念与作用

5.1.1 链接的概念

链接是编译过程的最后一步,在这个阶段,编译器生成的中间代码文件(目标文件)被组合成一个完整的可执行文件或共享库。具体来说,链接过程会做以下几件事:

(1)符号解析:链接器会根据目标文件中的符号表,解析程序中的所有函数和变量的引用,确保所有的外部函数调用、变量引用都能指向正确的内存地址。

(2)地址分配:链接器根据程序的虚拟内存布局为不同的代码和数据段分配地址,确保程序的每一部分(如代码段、数据段)都能正确地加载到内存中。

(3)重定位:如果目标文件中的代码和数据没有直接指向最终地址(因为它们是相对地址),链接器会在链接过程中修改这些地址,使其指向正确的地址。

5.1.2 链接的作用

合并多个目标文件:编译器通常将源代码文件分成多个目标文件(.o 文件),每个目标文件包含源代码的一部分。在链接过程中,链接器将这些目标文件合并,生成一个单一的可执行文件或库。

符号解析与重定位:程序中的某些函数和变量可能在其他文件中定义,链接器会将这些符号解析并重新定位,确保程序中每个符号都指向正确的位置。比如,函数调用或全局变量的引用会在链接阶段被替换为实际的内存地址。

库的链接——静态链接:在静态链接中,所有需要的库函数和对象文件在编译时就已经集成到目标文件中,生成最终的可执行文件。这个文件不依赖于外部库,因而可以独立运行。

库的链接——动态链接:在动态链接中,库文件(例如 .so 文件)不会在编译时被完全链接到程序中,而是等到程序运行时动态加载。这样可以节省内存,因为多个程序可以共享同一个库文件。

优化与内存管理:通过链接,程序可以实现更好的内存布局,减少内存占用。通过优化链接过程,链接器能够去除无用的代码或函数(例如,未被调用的函数),从而减小生成文件的大小。

5.2 在Ubuntu下链接的命令

在Ubuntu系统下,链接的命令为: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

运行截图如下:

 

 

图 46链接后的结果展示

ld 是 Linux 下的链接器,用于将目标文件(.o 文件)和库文件链接成一个可执行文件或共享库。它是 GCC 编译工具链的一部分。

-o hello选项指定了输出的文件名为 hello,即链接后的可执行文件将被命名为 hello。

-dynamic-linker /lib64/ld-linux-x86-64.so.2选项指定了动态链接器(dynamic linker)的路径,动态链接器是用来加载和链接动态库(共享库)的工具。在这个例子中,/lib64/ld-linux-x86-64.so.2 是 64 位 Linux 系统上的动态链接器路径。这个动态链接器将在运行时负责加载程序依赖的共享库。

/usr/lib/x86_64-linux-gnu/crt1.o是 C 运行时(CRT,C Runtime)的启动文件,包含了程序的入口点初始化代码。它主要负责设置程序运行的环境,执行一些初始化任务(比如设置堆栈、调用程序的 main 函数)。

/usr/lib/x86_64-linux-gnu/crti.o 是 C 运行时初始化文件,它包含了程序启动之前的一些基本设置,用于保证程序在启动时能够正确运行。在这个文件中,包含了一些初始化程序所必需的内容,如对 C 运行时的初始化。

hello.o是由编译器生成的目标文件,包含了 hello.c(源代码)编译后生成的机器代码。这个文件包含了程序的主要逻辑。

/usr/lib/x86_64-linux-gnu/libc.so是 C 标准库(libc)的共享库文件。在大多数程序中,都会使用 C 标准库提供的函数(如 printf、malloc、free 等)。通过指定这个文件,程序可以在运行时动态链接到 C 标准库中的函数。

/usr/lib/x86_64-linux-gnu/crtn.o是 C 运行时的结束文件,它负责在程序运行结束时清理资源。程序在退出时会调用这个文件中的代码,来处理清理和资源释放的工作。

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

使用readelf -a hello命令,解析hello的ELF格式,得到hello的节信息和段信息:

5.3.1 ELF头

在这张 readelf -a hello 的输出中,ELF 头同样以 “7f 45 4c 46” 的 Magic 字节开头,表明这是一个符合 ELF 规范的文件;接着标示为 ELF64 格式,采用小端(little endian)二补码存储,版本号为 1,遵循 UNIX – System V 的 ABI(ABI 版本 0),机器架构为 AMD x86-64。与可重定位目标文件不同,这里 类型 为 EXEC(可执行文件),因此它具有入口点 0x4010f0,并且在文件第 64 字节处开始包含 程序头表(每项 56 字节,共 12 项);而节头表位于偏移 13560 字节处,包含 27 个节头(每个 64 字节),节头名称字符串表索引为 26。除了 Type、入口地址、程序头表及节头数量等与目标文件差异明显外,Magic、Class、Data、Version、OS/ABI、Machine 等基础信息与之前的 ELF 文件保持一致。

图 47 ELF头

5.3.2 节头

以下展示了可执行文件的节头表(Section Headers),每一行都列出了节的序号、名称(如 .interp、.text、.data 等)、类型(PROGBITS、NOTE、RELA、SYMTAB 等)、在内存中的虚拟地址、在文件中的偏移量、节的大小、标志位(可写 W、可分配 A、可执行 X、合并 M、字符串 S、调试信息 I 等)、以及该节所关联的符号表索引(Link)和附加信息(Info)、以及对齐要求(Align)。例如,.text 段被标记为 PROGBITS、可分配且可执行(AX),它从文件偏移 0x101f0 处开始,占 0xd8 字节,加载到虚拟地址 0x4010f0,按 16 字节对齐;.data 段存放可写数据(WA),偏移 0x3048,大小 0x4,地址 0x404048,对齐 8;.rodata 段存放只读常量(A),偏移 0x2048,大小 0xa0,地址 0x402048。像 .rela.plt 和 .rela.dyn 这样的 RELA 段则包含重定位条目,供链接器在装载时修正符号引用;.symtab 是符号表,.strtab 和 .shstrtab 分别存放符号名称和节名称字符串。链接器在生成最终可执行文件时,会根据这些节头信息将各目标文件的同名节合并、重新分配内存地址和文件偏移,并利用重定位和符号表来完成符号解析和地址修正,从而得到布局合理、能够正确运行的程序映像。

 

图 48节头

5.3.3 程序头

以下是可执行文件的程序头表(Program Headers),它决定了运行时内核和加载器如何把文件映像装入内存。第一行 PHDR 段描述了程序头表自身在文件中(Offset 0x40)和内存中(VirtAddr 0x400040)的存放位置与大小,用来让动态链接器定位后续各段。接下来 INTERP 段(Offset 0x2e0, VirtAddr 0x4002e0)指定了运行时要调用的动态链接器 /lib64/ld-linux-x86-64.so.2。随后是一系列 LOAD 段:第一个 LOAD(Offset 0x0, VirtAddr 0x400000)映射了只读执行的代码段(R),第二个 LOAD(Offset 0x5f0, VirtAddr 0x4005f0)继续代码区并带可执行权限(R E),第三个 LOAD(Offset 0x1000, VirtAddr 0x401000)映射了.hint、.plt 等只读可执行内容,第四个 LOAD(Offset 0x2e50, VirtAddr 0x403e50)则映射了可读写的数据段(RW),所有这些 LOAD 段都有 0x1000 或 0x8 的对齐。DYNAMIC 段紧接数据段,包含动态链接所需的符号表、重定位表等信息;NOTE 和 GNU_PROPERTY 段则存放 ELF 注释与属性;GNU_STACK 段用来标记栈空间的读写权限;最后的 GNU_RELRO 段将某些已重定位的数据设为只读,防止运行时被意外篡改。整个程序头表通过这些条目告诉内核如何按权限和对齐要求把文件各部分正确映射到进程的虚拟内存中。

 

图 49程序头

5.3.4 Dynamic section

在偏移 0x2e50 处的动态节包含 21 条记录,它首先通过 (NEEDED) libc.so.6 指明运行时所依赖的共享库,然后 (INIT)=0x401000 与 (FINI)=0x4011c8 分别给出程序初始化和清理例程的入口地址。紧接着,(HASH)=0x400350 与 (GNU_HASH)=0x400388 指向两种哈希表以加速符号查找,(STRTAB)=0x400480、(SYMTAB)=0x4003a8、(STRSZ)=103 及 (SYMENT)=24 则定位并描述了符号名称字符串表和符号表条目的大小。(PLTGOT)=0x404000、(PLTRELSZ)=144、(PLTREL)=RELA 与 (JMPREL)=0x400560 定义了针对过程链接表(PLT)的重定位记录,而 (RELA)=0x400530、(RELASZ)=48、(RELAENT)=24 又给出了全局数据和其他符号的重定位表信息。最后,(VERNEED)=0x400500、(VERNEEDNUM)=1 与 (VERSYM)=0x4004e8 提供了符号版本需求和版本索引表,整个动态表以 (NULL)=0x0 结尾,这些条目共同指导动态链接器如何查找库、解析符号、应用重定位并绑定正确版本,从而完成程序的动态加载。

图 50 Dynamic section

5.3.5 Symbol table

在这张符号表输出中,首先看到的是 动态符号表 (.dynsym),它包含 9 条全局或弱符号,仅用于运行时动态链接。第 0 项保留为空;第 1 至 4 项都是外部函数(puts@GLIBC_2.2.5、atoi@GLIBC_2.2.5、exit@GLIBC_2.2.5、sleep@GLIBC_2.2.5,以及一个版本为 GLIBC_2.34 的函数)标记为 UND,表示它们的定义来自 libc.so.6;第 5 项 __gmon_start__ 为弱符号,用于性能检测;剩下几项也是各类外部函数调用。动态链接器正是通过这些 .dynsym 条目来解析并绑定库函数。紧接其后的 完整符号表 (.symtab) 共 26 条条目,既包含本地符号也包含全局符号。前几项是编译生成时的文件级符号(如 crt1.o、hello.c、.dynamic、GLOBAL_OFFSET_TABLE_ 等);中间部分列出了运行时用到的各种地址标记(_edata、.init、.fini、__data_start、__bss_start、_IO_stdin_used、_end);还包括启动例程 _start(值 0x4010f0)、运行时重定位函数 _dl_relocate_static_pie(隐藏符号)、以及应用程序的 main 函数(值 0x401125);最后几项则是与运行时库函数相连的未定义符号(puts@GLIBC_2.2.5、getchar@GLIBC_2.2.5、printf@GLIBC_2.2.5、atoi@GLIBC_2.2.5、exit@GLIBC_2.2.5、sleep@GLIBC_2.2.5),供链接阶段或运行时解析。通过 .symtab,链接器能够完成更细粒度的符号解析、重定位和调试信息查询,而 .dynsym 则专注于运行时库函数的动态绑定。

图 51 Symbol table

5.4 hello的虚拟地址空间

观察程序头的LOAD可加载的程序段的地址为0x400000。如图:

图 52程序头

使用edb打开hello从Data Dump窗口观察hello加载到虚拟地址的情况,查

看各段信息。如图:

图 53从Data Dump窗口观察hello加载到虚拟地址的情况

程序从地址0x400000开始到0x401000被载入,虚拟地址从0x400000到0x400f0结束,根据5.3中的节头部表,可以通过edb找到各段的信息。

如.interp节,在hello.elf文件中能看到开始的虚拟地址:

图 54 hello.elf中.interp节的虚拟地址

在edb中找到对应的信息:

图 55 edb找到对应的.interp节的虚拟地址

如.dynstr节,在hello.elf文件中能看到开始的虚拟地址:

图 56 hello.elf中.dynstr节的虚拟地址

在edb中找到对应的信息:

图 57 edb找到对应的.dynstr节的虚拟地址

 

5.5 链接的重定位过程分析

5.5.1 ELF分析hello与hello.o的区别

在Shell中使用命令objdump -d -r hello > hello1.asm生成反汇编文件hello1.asm

图 58 objdump -d -r hello > hello1.asm后的结果

第四章中生成的hello.asm文件和hello1.asm文件,这两份汇编其实反映了程序在「未链接」和「已链接」两种不同阶段的样子,以下我们来分析不同之处:

1)链接后函数数量增加

在 hello.o 中,反汇编只展示了一个符号化的 main 函数(及其局部符号 .L2/.L3 等),所有代码都紧凑地位于 .text 节中。

图 59所有代码都紧凑位于.text节中

链接生成的可执行文件里,除了 main 以外,还多出了 _start、__libc_start_main@GLIBC_2.34、_dl_relocate_static_pie、.init 中的钩子、.fini 中的清理例程,和整块用来跳转到共享库函数的 PLT 代码(puts@plt、printf@plt、getchar@plt、atoi@plt、exit@plt、sleep@plt)——函数总数大幅增加,且分布在多个段里。

图 60除了.text节还有很多其他段

(2)调用指令(call)的参数

在目标文件反汇编里,call 指令通常写作 call 32 <main+0x32> 或者带着重定位标记的 callq *rel 0x32,它们只是相对于节内偏移的占位,会在链接时由链接器修正。

图 61标记一个相对的地址

在可执行文件中,所有调用都被修正为针对 PLT 或者绝对地址的形式:例如 call *0x2edb(%rip) 跳到对应的 PLT 項,或直接 mov rdi,0x401125; call *0x2edb(%rip) 以正确的虚拟地址调用 __libc_start_main,call 0x401090 <puts@plt> 等等。

图 62所有调用都被修正为针对 PLT 或者绝对地址的形式

(3)跳转指令(jmp/je)的参数

链接前跳转使用诸如 je 32 <main+0x32>、jmp 91 <main+0x91> 这种基于“节内偏移”的相对地址。

图 63跳转基于“节内偏移”的相对地址

链接后则变成了 je 0x401157 <main+0x32>、jmp 0x4011b6 <main+0x91>,即所有分支都被映射到最终的虚拟地址空间。

图 64均被映射到最终的虚拟地址空间

(4)重定位信息的消失

.o 文件中每一处对外部符号或数据的引用都保留了 R_X86_64_PC32、R_X86_64_PLT32 等重定位表条目。

图 65 hello.asm中保留了重定位表条目

可执行文件中,这些重定位都已应用完毕,反汇编不再显示 reloc 标记,而是直接给出运行时真正要跳转或调用的地址。

图 66直接给出跳转地址或者调用的地址

5.5.2 重定位过程

重定位的过程主要涉及将各个模块的代码和数据在内存中进行合理布局,并调整其中的符号引用地址,使其能够正确运行。

  1. 重定位节和符号定义

节的合并:链接器会将所有相同类型的节合并为同一类型的聚合节。例如,把各个模块中的代码节(如 .text节)合并成一个大的代码节,将数据节(如 .data节)合并成一个大的数据节等。这样做的目的是为了形成一个完整的、连续的程序内存布局,使得程序在运行时能够更高效地访问代码和数据。

地址分配:链接器会为新的聚合节分配运行时的内存地址。同时,也会为输入模块定义的每个节以及输入模块定义的每个符号分配对应的地址。通过这一步操作,程序中的每条指令和全局变量都拥有了唯一的运行内存地址,从而确定了它们在程序运行时的具体位置。

  1. 重定位节中的符号引用

 符号引用的修改:在这一阶段,链接器会根据上一步确定的符号地址,修改代码节和数据节中对每个符号的引用。因为程序中的指令和数据可能会引用其他模块中的符号,而这些符号在重定位之前的地址可能只是相对地址或者临时地址,所以需要链接器将其转换为正确的运行时地址。

 依赖重定位条目:重定位条目是一种数据结构,它包含了符号引用的位置以及需要进行重定位的类型等信息。链接器会根据这些重定位条目来找到程序中需要修改的符号引用位置,并按照相应的规则将它们指向正确的运行时地址。

  1. 重定位过程的地址计算方法

图 67重定位过程的地址计算方法

5.6 hello的执行流程

5.6.1 过程

通过edb的调试,一步一步地记录下call命令进入的函数。

图 68记录call命令

(1)开始执行:_start、_libe_start_main

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

(3)退出:exit

5.6.2 子程序名或地址

表格 3子程序名对应的程序地址

程序名

程序地址

start

0x4010f0

libc_start_main

0x2f12271d

main

0x401125

printf

0x4010a0

sleep

0x4010e0

getchar

0x4010b0

exit

0x4010d0

5.7 Hello的动态链接分析

动态链接的核心在于将可执行程序本身与共享库分离,程序在编译时并不知道共享库函数的最终加载地址,而是在程序运行时通过 GOT(全局偏移量表)与 PLT(过程链接表)协同实现“延迟绑定”——首次调用时才解析符号地址,后续调用直接跳转到解析好的地址。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。延迟绑定是通过GOT和PLT实现的,根据hello.elf文件可知,GOT起始表位置为:0x404000:

图 69 .got.plt的位置

GOT表位置在调用dl_init之前0x404008后的16个字节均为0:

图 70 0x404008后的16个字节均为0

调用了dl_init之后字节改变了:

图 71调用dl_init之后字节改变

对于全局变量,链接器直接利用代码段与数据段在内存中的固定相对位置,依据这一偏移关系计算出它们的真实运行地址。至于对共享库函数的调用,则依赖于过程链接表(PLT)和全局偏移量表(GOT)共同完成:(1)初始化阶段:PLT 中的跳转桩(stub)会先跳转到 GOT中对应条目所存的地址,而此时GOT 条目里存放的正是该 PLT stub 的第二条指令地址--也就是跳回解析入口。(2)首次调用:跳转回解析入口后,动态链接器负责查找目标函数在共享库中的实际地址,并将此地址写回 GOT 相应条目。
(3)后续调用:GOT条目已经被更新为函数的真实地址,此时 PLT stub 再次执行时,会直接跳转到该地址,无需再次经过解析。
通过这种延迟绑定机制,程序在运行过程中就能高效地借助 PLT 和 GOT 动态链接到正确的库函数。

5.8 本章小结

本章围绕程序的链接(Linking)阶段进行了系统而深入的分析和实践。首先阐述了链接的基本概念与作用,说明了链接器如何将多个可重定位目标文件合并、解析符号、分配地址并完成重定位,为生成最终可执行文件打下基础;并给出了在 Ubuntu 下调用 `ld`(或通过 `gcc` 间接)进行静态与动态链接的典型命令格式与参数含义。 

随后,借助 `readelf`、`objdump` 等 ELF 工具,对 `hello` 可执行文件的 ELF 头、节头表、程序头、动态节和符号表进行了详尽剖析,明确各节在文件与内存中的偏移、权限和对齐要求,揭示了加载器如何根据程序头表将不同段映射到进程虚拟地址空间。紧接着,通过 EDB 调试环境中的 Data Dump 窗口,直观展示了 `hello` 程序在 0x400000 开始的虚拟内存布局,并关联节头信息定位各段加载位置。 

本章着重分析了链接器在“节合并与地址分配”以及“重定位条目应用”两大阶段的地址计算方法,结合目标文件与可执行文件反汇编对比,揭示了链接前后 `call`/`jmp` 指令如何从“节内偏移”被修正为运行时虚拟地址或 PLT 跳转。最后,以 `printf` 为例,详细讲解了 PLT + GOT 延迟绑定机制,从初始化阶段的 stub 跳转、首次调用时的解析写回,到后续调用的零开销跳转整个过程,加深了对动态链接原理的理解。 

第6章 hello进程管理

6.1 进程的概念与作用

6.1.1 进程的概念

进程的经典定义就是一个执行中程序的实例。进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。

其中,进程具有三个特性:

(1)动态性:进程会随着执行不断改变自身状态(就绪、运行、阻塞、终止等)。

(2)并发性:在多道程序环境或多核处理器上,多个进程可以“同时”执行(通过时间片轮转或多核并行)。

(3)独立性:每个进程拥有独立的地址空间和系统资源(文件描述符、打开的设备、CPU 寄存器上下文等)。

6.1.2 进程的作用

1、资源分配与保护单元

操作系统以“进程”为单位分配 CPU、内存、I/O 设备等资源;通过独立的地址空间和权限机制,保证各进程间互不干扰、数据隔离。

2、调度与并发执行

调度器根据进程的优先级、时间片、I/O 等待状况,决定哪个进程获得 CPU 使用权,从而实现多任务并发。时间片轮转:让每个进程轮流运行一个小时间段,提高系统响应性。多级反馈队列、实时调度策略等算法,满足不同类型进程的性能需求。

3、程序结构与模块化

将大型应用程序划分为若干进程(或进程+线程),便于模块化设计、故障隔离和权限控制。Web 服务器常用“主进程+工作进程”架构。数据库、容器化平台也依赖进程隔离来提供安全、稳定的运行环境。

4、执行上下文管理

进程封装了程序执行的全部上下文,包括代码、数据和运行状态。切换进程时,内核保存/恢复 PCB 中的寄存器和内存映射,保证断点重启的连续性。

5、进程间通信(IPC)

虽然进程相互独立,但许多场景需要它们协同工作。操作系统提供多种 IPC 机制:管道(Pipe)、消息队列、信号量;共享内存、套接字(Socket);信号(Signal)、文件映射(Memory-mapped file)

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

6.2.1 壳Shell-bash的作用

Bash(Bourne-Again SHell)既是一个交互式命令解释器,也是一个功能完备的脚本语言环境。它承担以下主要职能:

1、命令解析与执行

接受用户在终端或脚本中输入的命令,进行词法和语法分析,并调用内置命令或启动外部程序完成相应操作。

2、环境管理

维护环境变量、当前工作目录、Shell 选项以及别名、函数等,提供灵活的会话配置和继承机制。

3、脚本编程能力

支持变量、数组、流程控制(if、for、while 等)、函数和算术/字符串运算,使得用户能够编写自动化脚本和小型应用。

4、重定向与管道

允许将标准输入、输出、错误重定向到文件或其他进程,实现数据流式处理与组合。

5、作业控制与信号处理

提供前台/后台作业管理(&、jobs、fg、bg)和信号捕获/处理(trap),以便用户控制并发任务及响应中断。

6.2.2 Shell-bash 的处理流程

先在终端接收输入的指令,再对这些指令进行解析。一旦判断该指令是内置指令,就马上执行它。如果不是内置指令,就利用 fork 来创建一个新子进程,然后在该子进程里运行指定程序。同时要确定这个程序是前台程序还是后台程序,若是前台程序,就等待它执行完毕;要是后台程序,就将其置于后台并返回。在这一系列过程中,shell 能够接收来自键盘的信号,并对其进行相应的处理。

6.3 Hello的fork进程创建过程

当用户在 Shell 提示符下输入:./hello 2023112897 蒋友鑫 18382075363 3

首先,Shell 会检测到这不是一个内建命令,于是由当前(父)进程调用 fork()。fork() 会在内核中为子进程分配一个新的进程表项,并复制父进程的用户态虚拟地址空间——包括可执行代码、数据段、堆、已加载的共享库以及用户栈。除了进程 ID(PID)不同之外,子进程与父进程在初始状态下拥有完全相同的内存映像。调用 fork() 之后,父进程会接收到子进程的 PID 作为返回值,而子进程中则会得到 0。通过这个返回值,程序能够判断自己是运行在父进程中还是在子进程中。

6.4 Hello的execve过程

当调用int execve(const char *filename, const char *argv[],const char *envp[]);

时,内核会在当前进程中将可执行文件 filename 的所有代码和数据载入到进程的地址空间,并使用 argv 中的参数和 envp 指定的环境变量来启动它。除非发生错误(例如找不到指定的文件),execve 不会返回;一旦成功执行,新程序就取代了原进程映像。也就是说,与 fork() 不同——fork() 在父子进程中各返回一次——execve() 调用后,若正常,则不会再次返回到调用它的那段代码。程序启动时,操作系统会在用户栈上按照一定布局摆放 argc、argv、envp 等信息,如示意图所示:

图 72 用户栈的结构示意图

6.5 Hello的进程执行

hello 程序从启动到退出,其执行过程可以分为以下几个关键阶段,对应操作系统为进程和 CPU 调度所提供的抽象:

6.5.1 逻辑控制流与私有地址空间

当通过 execve("./hello", argv, envp) 启动 hello 程序时,操作系统会替换当前进程的映像,并为其建立一块全新的虚拟地址空间。这片空间包含了可执行文件的代码段、数据段、未初始化数据段(BSS)、堆区以及用户栈,同时映射所需的共享库。程序计数器(PC)从 main 函数的入口位置开始,依次取出指令并执行,这条指令序列便构成了进程的“逻辑控制流”。在这个隔离的地址空间中,hello 程序仿佛独占了整个内存,而实际上内核通过页表和虚拟内存机制在物理内存中为它分配了相应的资源。

6.5.2 用户态到内核态的切换

在 hello 程序执行 printf("Hello 2023112897 蒋友鑫 18382075363\n") 时,库函数最终会调用底层的写系统调用(如 write)。此时,处理器从用户模式自动切换到内核模式,跳转到内核中对应的 write 实现,完成字符缓冲区的拷贝和设备驱动层面的输出操作。系统调用完成后,内核再将处理器状态恢复到用户模式,并返回到执行 printf 之后的下一条指令。

6.5.3 时间切片与上下文切换

为了在多进程环境中公平地分配 CPU,内核采用时间片轮转调度策略。每个进程在运行时都会被赋予一个固定长度的时间片,当时间片耗尽或出现更高优先级的就绪进程时,调度器便触发一次上下文切换。上下文切换过程中,内核会将当前进程的通用寄存器、程序计数器、栈指针等寄存器状态保存到其进程控制块(PCB)中,然后加载下一个进程的状态,恢复其寄存器并从上次中断的位置继续执行。这样,多个进程的逻辑流便可交替在同一个或多核 CPU 上并发运行。

6.5.4 休眠与信号处理

当 hello 程序调用 sleep(n) 时,它会进入可中断的睡眠状态,内核在定时器中登记一个唤醒事件,并将该进程挂起,让出 CPU 给其他就绪进程。睡眠期间,如果有信号到达(例如用户按下中断键或定时器信号),内核会提前唤醒 hello,先执行相应的信号处理程序,再根据处理结果决定是否继续睡眠或返回到用户态的后续指令。到了预定睡眠时长,内核将 hello 重新加入就绪队列,等待下一次调度。

6.5.5 进程退出

当 hello 程序执行到 return 0; 或调用 exit() 时,会再次触发一次内核切换,将退出码记录到父进程可查看的位置,并开始回收资源:撤销其虚拟内存映射、关闭打开的文件描述符、释放内核数据结构。进程控制块被标记为“已终止”,并在适当时机从就绪队列中移除。至此,hello 的整个执行生命周期才算圆满结束。

6.6 hello的异常与信号处理

6.6.1 异常的分类

图 73异常的分类

6.6.2 异常的处理方式

图 74中断处理,中断处理程序将控制返回给应用程序控制流中的下一条指令

图 75陷阱处理,陷阱处理程序将控制返回给应用程序控制流中的下一条指令

图 76故障处理,根据故障是否能够被修复,故障处理程序要么重新执行引起故障的指令,要么终止

图 77终止处理,终止处理程序将控制传递给一个内核abort例程,该例程会终止这个应用程序

6.6.3 运行结果及相关命令

(1)正常运行状态

在程序正常运行时,打印10次提示信息,以输入回车为标志结束程序,并回收进程。

 

图 78正常运行状态

(2)运行时按下Ctrl+Z

按下Ctrl + Z,Shell进程收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程。

图 79运行时按下Ctrl+Z

(3)运行时按下Ctrl+C

按下Ctrl + C,Shell进程收到SIGINT信号,Shell结束并回收hello进程。

图表 80运行时按下Ctrl+C

(4)对hello进程的挂起可由ps和jobs命令查看,可以发现hello进程确实被挂起而非被回收,且其job代号为1。

图 81 ps和jobs的结果

 

 

 

 

 

(5)在Shell中输入pstree命令,可以将所有进程以树状图显示:

图 82所有进程以树状图

(6)输入kill命令,则可以杀死指定(进程组的)进程:

图 83 kill指令杀死指定(进程组的)进程

(7) 输入fg 1则命令将hello进程再次调到前台执行,可以发现Shell首先打印hello的命令行命令,hello再从挂起处继续运行,打印剩下的语句。程序仍然可以正常结束,并完成进程回收。

图 84 fg 1重启进程

(8)不停乱按

在程序执行过程中乱按所造成的输入均缓存到stdin,当getchar的时候读出一个’\n’结尾的字串(作为一次输入),hello结束后,stdin中的其他字串会当做Shell的命令行输入。

图 85不停乱按

 

 

6.7本章小结

第6章围绕 hello 程序的进程管理展开,从进程的基本概念、Shell bash 的启动流程,到 hello 进程的创建、替换、执行与终结,系统地揭示了操作系统如何借助多种抽象来支持应用程序运行。首先,我们介绍了进程作为资源分配与调度的基本单位,具有独立的地址空间和控制流;随后阐述了 Bash 解析命令、通过 fork() 派生子进程并在其中执行外部程序的全过程;在 execve() 调用中,原进程映像被新程序替换,用户栈上按约定布置 argc/argv/envp;接着剖析了 hello 从启动、执行 printf 和 sleep 等系统调用中的用户态/内核态切换,到内核以时间片和上下文切换实现多进程并发;还说明了信号到达时的中断唤醒与处理机制;最后,当 hello 调用 exit() 退出时,内核回收其虚拟空间和内核数据结构,完整地关闭了这一进程生命周期。

第7章 hello的存储管理

7.1 hello的存储器地址空间

为了让 hello 程序能在具有虚拟内存和分段机制的现代操作系统中运行,CPU 与内存之间要经过多级地址变换。下面结合 hello 程序执行过程中产生的各类地址,逐层加以说明。

7.1.1 逻辑地址

“逻辑地址”也称“程序地址”或“相对地址”,是 CPU 在执行指令时产生的地址。对于带有分段(segment)机制的系统,它由段选择子(segment selector)和段内偏移(offset)两部分组成。在 hello 程序中,所有对指令和数据的引用(如读取 argv 中的参数、访问全局变量或调用函数)最初都形成逻辑地址——段选择子指向对应的代码段或数据段,偏移量则由编译器和汇编器在生成 hello 可执行文件时确定。

7.1.2 线性地址

逻辑地址要映射到物理内存之前,首先进入分段单元(segmentation unit)处理:操作系统在为 hello 建立进程时,会为其各段加载相应的基址(base)和界限(limit)到段寄存器中。CPU 将逻辑地址中的偏移加上段基址,就得到了“线性地址”(linear address 或者段后地址)。例如,hello 的代码段起始基址加上指令流偏移,就生成了代码执行的线性地址;同理,数据段基址加上数据偏移,形成访问全局变量的线性地址。

7.1.3 虚拟地址

在启用了分页(paging)机制的操作系统中,线性地址还需经过分页单元(page unit)进一步转换。“虚拟地址”常被等同于线性地址——它是操作系统为 hello 分配的与物理内存无关的地址空间视图。页目录和页表将每个虚拟页映射到实际的物理帧上。hello 在运行时,无论其占用的物理内存如何分散,都只感知一整块连续的虚拟地址空间,从 0 开始直到用户空间上限。

7.1.4 物理地址

最终,通过页表查找,虚拟地址被转换成“物理地址”(physical address),这是对内存芯片上实际字节单元的索引。物理地址以字节为单位,每个地址对应内存中的一个存储单元。在 hello 程序生命周期内,内核会根据内存分配与换入换出策略,将 hello 的各页加载到物理内存中的不同位置;但程序本身只通过虚拟/线性地址访问数据,无需关心它们在物理内存中的真实位置。

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

在 Intel 架构中,每当程序访问内存时产生的“逻辑地址”(也称段内地址)由两部分构成:16 位的段选择子(Segment Selector)和 32 位的段内偏移(Offset)。段选择子本身也是一个复合字段,其中高 13 位是索引(Index),指向段描述符表中的一项;第 2 位(TI 位)用来区分是全局描述符表(GDT)还是当前任务的局部描述符表(LDT);最低 2 位则用作请求特权级(RPL),与访问权限检查相关。

当 CPU 取到一条含有段选择子和偏移的逻辑地址时,首先根据 TI 位和索引,到 GDT 或 LDT 中定位到对应的段描述符(Segment Descriptor)。这个描述符记录了该段的线性基址(Base),段界限(Limit),以及访问权限、存在位、DPL(描述符特权级)等属性。CPU 会先检查偏移是否在段界限之内,并确保当前 CPL(当前特权级)与段描述符的 DPL 及 RPL 之间满足访问规则。

    通过安全性和界限检查后,CPU 将段描述符中的基址与逻辑地址中的偏移相加,计算出一个“线性地址”(Linear Address)。此时,分段管理的工作就完成了——逻辑上、程序看起来是在自己的私有地址空间中连续访问;实际上,不同段可以映射到内存中的任意位置。

    具体来说,对于 hello 程序中的指令取址或数据访问,CPU 会使用其 CS、DS、SS 等段寄存器中缓存的段选择子,迅速索引到内核已加载到 GDT/LDT 的段描述符,取得该段的基址。然后把偏移加上这个基址,就得到线性地址。后续若启用了分页机制,线性地址还将进一步经由页表映射到真正的物理地址,但那是分页单元的职责,与这里的段式管理阶段相互独立。

通过这种分段机制,操作系统既能为每个任务/进程维护独立的代码段、数据段和栈段,又能依照需要在全局或局部描述符表中共享或复用某些段(例如共享库或内核段),从而在提高安全性和灵活性的同时,仍然为上层程序提供了对“连续地址空间”的直观抽象。

    段式管理图示如下:

图 86段式管理图

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

在 Intel 架构的分页机制下,线性地址被划分为“页号+页内偏移”两部分,用于索引页表并最终定位到物理内存中的具体字节。假设使用4KB 的分页粒度,线性地址的低 12 位便是页内偏移(offset),而高位构成页号(page number)。当CPU 要访问某个线性地址时,MMU(内存管理单元)会首先将该页号送去查询当前进程所使用的页表。
页表本身是系统为每个进程维护的一张映射表--页表条目(PTE)的数组。每个 PTE 包含一个“有效位”(Present bit)和一个物理页框号(Frame Number)字段。当 PTE 的有效位被置为 1 时,物理页框号就指明了该虚拟页当前映射到 DRAM 中哪一个 4 KB 的起始地址:如果有效位为 0,则表明该虚拟页尚未加载到内存,此时会触发缺页异常,操作系统再从磁盘(通常是交换区或页面文件)将对应页调入内存,并更新该 PTE 使其有效。
一旦 MMU 在页表中找到一个有效的 PTE,就将该物理页框号左移12 位,与线性地址的低 12 位偏移合并,生成最终的物理地址,用来访问内存芯片上的实际字节单元。整个过程对上层程序透明:它仿佛拿到了一片连续的线性空间,而底层则通过页表管理将这段连续空间映射到可能零散的物理内存上,同时配合缺页异常处理和置换算法,实现了大于物理内存容量的虚拟内存支持。

下面为页式管理的图示:

图 87页式管理图

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

在 x86-64 架构中,为了加速虚拟地址(VA)到物理地址(PA)的转换,处理器引入了两套机制:一是流水线中高速缓存形式的快表——转换后备缓冲区(TLB, Translation Lookaside Buffer),二是内存中分层组织的四级页表。每当 CPU 发起一次内存访问,首先会在 TLB 中查找对应页号的映射项;若命中,就直接将该项中存储的物理页框号(PFN, Page Frame Number)与 VA 的页内偏移合并,迅速形成 PA,并继续执行;若未命中(TLB miss),则需要依次访问四级页表来完成一次完整的页表遍历。

这一遍历过程如下。处理器从控制寄存器 CR3 中读取当前进程的页表基址,CR3 实际上指向 PML4(顶级页表)的物理地址。CPU 将 VA 的高位拆分为四段索引:首先用最高的 9 位作为对 PML4 表的索引,定位到一个 PML4E(PML4 Entry);该项中存放着下一级页目录指针表(PDPT)的物理基址。接着,用接下来的 9 位索引 PDPT,找到 PDE(PDPT Entry),获取二级页目录(PD)的地址;紧接着,再用下一组 9 位到 PD 中查找 PDE(Page Directory Entry),获得第三级页表(PT)的地址;最后,用倒数第二组 9 位在 PT 中定位 PTE(Page Table Entry),此项记录了最终物理页框号以及访问权限、存在位等标志。整个遍历完成后,页表硬件会将该 PTE 中的物理页框号与 VA 的最低 12 位偏移拼接成完整的物理地址。工作原理如下:

图 88四级页表工作原理

完成一次页表行走并得到结果后,处理器会将该 VA–PA 对应关系连同访问权限等元信息一并缓存到 TLB 中,以便后续对同一页的访问能够在一次查表动作内完成,大幅减少访问延迟。若后续对同一虚拟页的访问再次发生,TLB 命中就能立即返回物理地址,无需再次遍历那四层页表;但当上下文切换、页表项更新或 TLB 容量受限需要置换时,旧的条目会被驱逐,触发新的页表行走或借助软件中断(如 page fault)来重新建立映射。

多级页表的工作原理展示如下:

图 89多级页表工作原理

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

当 CPU 核心需要读取某个物理地址(PA)上的数据时,MMU 首先将虚拟地址(VA)转换为相应的 PA;随后,这个 PA 会依次在各级缓存中进行查找:

1、L1 Cache(一级缓存)

    这是距离执行单元最近的缓存,通常分为 L1d(数据)和 L1i(指令),每个核心各自拥有,容量一般为 32–64 KB,访问延迟为 1–4 个时钟周期。CPU 将 PA 的高位取出一部分作为索引,在 L1 的组-相联结构中定位到可能的缓存行,并比较标签(Tag)。如果标签匹配且有效位(Valid bit)为 1,便构成一次 L1 命中,直接从 L1 读取数据,完成访问。

2、L2 Cache(二级缓存)

    若 L1 未命中,就会发起对私有 L2 Cache 的访问,容量通常为 256 KB–1 MB,访问延迟约 10–15 个时钟周期。L2 同样采用组-相联结构,通过 PA 的索引与标签查找缓存行。L2 命中时,令 L1 回填(fill)该缓存行,然后再将数据返回给 CPU;若 L2 也未命中,则继续向下一级请求。

3、L3 Cache(三级缓存)

    多核处理器往往共享一个更大容量的 L3 Cache(一般为 4–32 MB),访问延迟在 30–50 个时钟周期左右。L3 通常采用更高的相联度或全相联结构,以提高缓存命中率。CPU 在此层再次利用 PA 的索引与标签进行查找;一旦 L3 命中,数据回填到 L2 和 L1,然后交付给执行单元。

4、物理内存(DRAM

    只有在所有三级缓存都未命中的情况下,才会真正发起对主存的访问。此时,内存控制器(Memory Controller)会根据 PA 的行地址(一般 64 B 对齐)在内存通道和 Rank 中定位对应的 DRAM 行(Row)与列(Column),并启动行激活(ACTIVATE)、读(READ)或写(WRITE)操作。主存访问的往返时间通常在 100–200 纳秒之间,远高于 L1/L2/L3 缓存的访问延迟。取回的缓存行首先被装载到 L3(或直接装载到 L2/L1,依据具体实现),再逐级上返,满足 CPU 的读写请求。

图 90使用页表的地址翻译

7.6 hello进程fork时的内存映射

当 hello 进程调用 fork() 时,内核首先为子进程分配一个新的进程描述符(task_struct),并复制父进程的内存管理信息——包括 mm_struct、所有的虚拟内存区域(VMA,vm_area_struct 链表)以及页表结构的镜像。但在真正的物理页面层面,父子进程此时仍然指向同一组页面:内核会把所有这些页面的引用计数加一,并将它们在两个页表中都标记为只读,从而启用“写时复制”(Copy-On-Write)。

此后,父进程或子进程一旦对某个页面发起写操作,CPU 触发页错误,内核检测到这是一个 COW 异常,于是为该进程单独分配一页新的物理内存,将原页面内容拷贝过来,并将该进程的页表条目更新为指向新页、且恢复为可写;而另一方仍继续引用旧的物理页、保持只读。通过这种机制,父子进程在逻辑上各自拥有独立的地址空间映像,但真正耗费物理内存的写入页面只有在需要时才会被复制,极大地提升了创建子进程的效率。

图 91进程2写了私有区域中的一个页之后

7.7 hello进程execve时的内存映射

在 hello 进程中调用 execve("./hello", argv, envp) 时,内核会在原有进程上下文中彻底重建它的用户态内存映射,让新的 hello 可执行文件取代之前的映像。其核心过程大致如下。

    首先,内核会清除掉当前进程所有旧的用户空间区域(VMA,vm_area_struct),包括原来的代码段、数据段、堆、栈,以及所有映射的共享库或匿名内存。这样做相当于把进程的用户态地址空间恢复到“空白状态”,只保留它的内核态数据结构(如 task_struct、mm_struct 框架和页表根指针 CR3)。

    接着,内核为即将加载的 hello 程序创建一组新的私有映射区域。它会按照 ELF 文件头的各段描述,将 .text 段以只读、可执行的方式映射到用户空间;将 .data 段映射为可读写的文件映射;为 .bss 段分配一块匿名、零初始化的区域;并在高地址处创建一个空栈区域,同样以匿名零页开始,设置合理的初始大小和访问权限。堆空间则在程序首次调用 brk() 或 mmap() 时动态扩展,也被标记为私有、写时复制。

    然后,内核会把程序依赖的共享库(如动态链接器 ld-linux.so、以及 libc.so)映射进同一个地址空间。每个库文件都作为只读、可执行和可写(仅针对内部数据)三种权限的组合映射到指定位置,并且各自的全局偏移表(GOT)和重定位表也会被解析,以便动态链接器在运行时把符号表绑定到实际地址。

完成上述映射以后,内核会用新的入口点(ELF 头中指定的 e_entry)更新进程的程序计数器,并在寄存器中按照 ABI 约定填入 argc、argv、envp 的值——其中参数指针数组本身和环境变量字符串都被压入用户栈顶。

最后,内核执行一次全局的 TLB 刷新,使所有旧的页表项失效;至此,控制权跳转到 hello 的入口,进程在用户模式下开始执行新程序的第一条指令。

图 92加载器是如何映射用户地址空间的区域的

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

当处理器在访问某个虚拟地址时发现对应的页表条目标记为“未在内存中”(Present = 0),就会触发一次缺页异常(Page Fault)。此时,CPU 会自动产生一个特权级切换,从用户态进入内核态,并在中断向量表中跳转到缺页中断处理例程。处理例程会首先从硬件提供的 CR2 寄存器中取出导致故障的虚拟地址。

内核的缺页处理程序会按以下流程恢复执行。首先,它会验证该地址是否落在进程的任意合法虚拟内存区域(VMA)之内;若超出所有已分配区域,则说明程序访问了不该拥有的地址,于是产生段错误(Segmentation Fault),并将该进程终止。接着,它检查访问权限——如果这是一次写操作却对只读段进行写,或执行权限不足,就触发保护异常(Protection Fault),同样终止进程。通过这两步检查后,说明该页合法且有权限访问,内核便要将所需数据调入内存:它会挑选一个可牺牲的物理页(即某个已映射页)作为置换对象,如果该页自上次加载后曾被修改(Dirty = 1),则先将其内容写回到交换设备;然后从磁盘或交换区读取目标页,将其放入这一物理帧,并更新当前进程页表中的条目——包括新的物理页框号、置位 Present = 1 以及清除 Dirty 和 Accessed 标志。最后,内核会刷新相关的 TLB 条目,恢复进程上下文,并重新执行导致缺页的那条指令,使得程序像从未中断一样继续运行。

图 93缺页处理

7.9本章小结

本章围绕 hello 程序在现代操作系统中的存储管理展开,从进程视角引入逻辑地址、线性地址、虚拟地址与物理地址的多级映射,揭示了分段与分页机制如何协同构建连续的虚拟空间,并借助 TLB 与四级页表优化地址转换性能。随后,我们考察了三级缓存层次如何缓存物理访问以掩盖 DRAM 延迟,并通过写时复制机制描述了 fork 时父子进程共享页面到私有页面的演变过程;在 execve 调用中,又演示了如何拆除旧映像、重建新的私有与共享映射;缺页中断处理部分深入阐释了地址合法性与权限校验、置换与换入、页表与 TLB 更新的完整流程。最后,在动态存储分配管理环节,本章简要说明了在程序运行时如何使用堆与 brk/mmap 接口按需申请与释放内存,以及操作系统如何维护空闲块、分割与合并以减少碎片。通过以上内容,读者应当理解从 CPU 发出每一次内存请求到最终获得所需数据——无论是代码、全局数据、堆栈还是堆上对象——操作系统与硬件如何层层协作,实现高效、安全、可扩展的存储管理。

结论

hello所经历的过程:

1、源代码阶段

在源代码阶段,用户通过文本编辑器撰写 hello.c 文件,其中使用了 #include <stdio.h>、int main() 等 C 语言提供的高层语义。此时的源码文件仅承载抽象语义,不包含任何具体目标平台的机器指令或内存布局信息,它是整个程序生命周期的起始点。

2、预处理阶段

预处理阶段主要处理代码中的预处理指令。所有 #define 宏被展开,#include 指令引入头文件内容,从而生成完整的、可编译的 C 代码流,通常会生成一个 .i 文件。同时,预处理器会根据 #if/#ifdef等条件编译指令判断并剔除不需编译的代码分支,使后续编译过程更加高效。

3、编译阶段

编译阶段是一个复杂的过程。首先进行词法与语法分析,将预处理后的代码转换为抽象语法树(AST),并执行类型检查与语义分析,确保代码的正确性。接着生成中间表示(IR),如 GCC 的 GIMPLE 或 LLVM IR,使编译器能够对代码进行优化。优化过程包括局部优化(如常量折叠)和全局优化(如死代码消除)等。最后,编译器将优化后的 IR 翻译成特定 CPU 架构(如 x86_64)的汇编代码,输出为 .s 文件。

4、汇编阶段

在汇编阶段,汇编器将 .s文件中的汇编代码转换为机器指令的二进制形式,生成目标文件 .o。这个过程涉及将汇编助记符转换为对应的二进制指令。此外,.o文件中会包含未定地址的符号引用(如函数、全局变量)及其重定位表,这些信息将用于后续的链接步骤。

5、链接阶段

链接阶段主要负责符号解析和段合并。链接器会合并多个目标文件 .o,为每个符号分配最终的内存地址。对于动态链接,链接器会保留对外部库符号的延迟绑定信息,如 PLT(Procedure Linkage Table)和 GOT(Global Offset Table)。同时,链接器会将各输入段(如 .text、.data、.bss 等)按约定的内存模型拼接,并生成程序头表与节头表,最终形成 ELF 格式的可执行文件 hello.elf。

6、加载阶段

加载阶段由操作系统内核完成。内核加载器读取 ELF 文件的头部与程序头表,将程序的各个段(如代码段、数据段等)按需映射到进程的虚拟地址空间中,通常使用 mmap 系统调用来实现。加载器还会设置各段的属性,如可执行性、可写性、私有或共享等。如果是动态可执行文件,内核会启动动态链接器 ld-linux.so,解析 .dynamic段,加载所需的共享库,并完成 GOT 重定位,以支持延迟绑定。

7、初始化与运行阶段

在初始化与运行阶段,程序开始执行前会先运行 C 运行时初始化代码(CRT)。这些初始化操作包括构造全局对象、初始化堆栈保护等。完成初始化后,控制权移交给用户定义的 main函数。程序通过系统调用(如 write、read、exit 等)与操作系统内核交互,实现程序的实际功能,最终完成程序的运行。

8、进程终止与清理阶段

当程序执行完毕,通过 return语句或调用exit函数触发exit系统调用。此时,操作系统内核负责回收进程的虚拟内存空间、关闭打开的文件描述符,并将进程的退出状态传递给父进程。此外,操作系统还会进行垃圾回收,释放进程的所有资源,包括页表、句柄、信号等,确保进程完全终止并清理干净,释放系统资源。

感悟:

1、分层与模块化:理性分割,简化复杂性

计算机系统之所以能够扩展并易于维护,关键在于明确的层次结构——从高级语言抽象到硬件微架构,每一层只关注自身职责并暴露清晰接口。这种“黑盒+契约”思想,不仅有助于团队协作,也为后续优化与替换提供了可能。

2、管道式工具链:数据流驱动的高效协同

经典的编译工具链以流水线方式将源代码转化为可执行文件,每阶段产生中间表示。未来可尝试将各阶段更紧密地耦合,例如基于增量编译与即时编译(JIT)混合模式,实现“编译即运行”——即在开发时动态执行热点代码,实时反馈性能瓶颈。

3、可重定位与安全:类型感知的链接机制

传统的链接器只按符号名与地址解决重定位,缺乏类型检查。创新设想是在符号表中保留更丰富的类型元数据,链接时进行“契约校验”,防止由于错误重定位或恶意注入导致的类型混淆攻击,并提升跨模块调用的安全性。 

4、动态链接与微服务:类操作系统的应用容器化

PLT/GOT 机制实现了运行时的延迟绑定,但仍受限于单一进程。借鉴微服务架构,可将动态链接库视作“轻量级服务”,用更细粒度的沙箱进程隔离和 gRPC 栈替代 PLT 调用,实现进程间 ABI 封装与零停机热更新。

5、内存与能效:软硬协同的优化路径

现代处理器对内存访问延迟十分敏感,编译器应与操作系统协同,结合硬件性能计数器数据,自动调整代码布局与页表属性,减少 TLB 未命中与缓存冲突。此外,可在链接或加载阶段内置能耗模型,引导代码生成器优先选取更节能的指令序列。

6、形式化验证与可追溯性:提升系统可靠性

将形式化方法引入 ELF 加载器与链接器,实现关键算法(如地址分配、重定位)在数学模型下验证正确性,避免安全漏洞;并通过可追溯的“编译流水线日志”让每次构建都能精确复现与审计。

附件

文件名

生成方式/命令

作用说明

hello.c

 

源程序

hello.i

gcc -E hello.c -o hello.i

预处理输出的纯 C 源码,完成宏展开、头文件包含与条件编译,供编译器进行词法和语法分析。

hello.s

gcc -S hello.i -o hello.s

编译阶段生成的汇编语言文件,包含经过优化的机器指令助记符,面向目标架构(如 x86_64)。

hello.o

gcc -c hello.s -o hello.o

汇编器输出的可重定位目标文件,包含机器码二进制、符号表和重定位信息,供链接器合并。

hello.elf

readelf -a hello.o > hello.elf

使用 readelf 导出的 ELF 格式信息文件,展示 ELF 头部、节头表、符号表、重定位条目等结构细节。

hello.asm

objdump -d -r hello.o > hello.asm

对目标文件 .o 进行反汇编,查看机器码对应的汇编指令及重定位条目,便于代码级别分析。

hello1.asm

objdump -d -r hello > hello1.asm

对最终可执行文件进行反汇编,展示链接后及加载前的完整汇编指令布局和 PLT/GOT 跳转细节。

hello

 

最终可执行文件

列出所有的中间产物的文件名,并予以说明起作用。

参考文献

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

[1] Bryant, R. E., & O'Hallaron, D. R. (2016). Computer Systems: A Programmer's Perspective (3rd ed.). Pearson.

[2] Kerrisk, M. (2010). The Linux Programming Interface. No Starch Press.

[3] GCC: The GNU Compiler Collection. (n.d.). GCC Official Documentation. Retrieved from https://gcc.gnu.org/onlinedocs/

[4] Intel Corporation. (2023). *Intel® 64 and IA-32 Architectures Software Developer Manuals*. Retrieved from https://software.intel.com/en-us/articles/intel-sdm

[5] Tanenbaum, A. S., & Bos, H. (2015). Modern Operating Systems (4th ed.). Pearson.

[6] Levine, J. R. (2000). Linkers and Loaders. Morgan Kaufmann.

[7] Love, R. (2013). Linux System Programming (2nd ed.). O'Reilly Media.

[8] The Linux Kernel Documentation. (n.d.). Process Address Space. Retrieved from https://www.kernel.org/doc/html/latest/

[9] Drepper, U. (2006). How To Write Shared Libraries. Retrieved from https://www.akkadia.org/drepper/dsohowto.pdf

[10] Patterson, D. A., & Hennessy, J. L. (2017). Computer Organization and Design: The Hardware/Software Interface (6th ed.). Morgan Kaufma

 

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值