哈尔滨工业大学计算机系统ICS大作业

计算机系统

大作业

计算机科学与技术学院

2025年5月

摘  要

本文旨在阐述C语言程序从源代码转化为可执行文件的完整过程。以示例程序 hello.c 为出发点,系统地解析了计算机在生成 hello 可执行文件过程中所经历的预处理、编译、汇编、链接以及进程管理等各个阶段。文章不仅从理论层面介绍了相关工具的原理与机制,还通过实际操作展示了各阶段的执行过程及其结果,全面揭示了计算机系统的运行机制与体系结构,帮助读者更深入地理解并掌握C语言程序的编译与执行流程。

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

目  录

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

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

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

1.3 中间结果............................................................................................................ - 4 -

1.4 本章小结............................................................................................................ - 4 -

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

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

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

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

2.4 本章小结............................................................................................................ - 5 -

第3章 编译................................................................................................................ - 6 -

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

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

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

3.4 本章小结............................................................................................................ - 6 -

第4章 汇编................................................................................................................ - 7 -

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

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

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

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

4.5 本章小结............................................................................................................ - 7 -

第5章 链接................................................................................................................ - 8 -

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

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

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

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

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

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

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

5.8 本章小结............................................................................................................ - 9 -

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

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

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

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

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

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

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

6.7本章小结.......................................................................................................... - 10 -

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

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

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

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

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

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

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

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

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

7.9动态存储分配管理........................................................................................... - 11 -

7.10本章小结........................................................................................................ - 12 -

第8章 hello的IO管理.................................................................................... - 13 -

8.1 Linux的IO设备管理方法............................................................................. - 13 -

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

8.3 printf的实现分析........................................................................................... - 13 -

8.4 getchar的实现分析....................................................................................... - 13 -

8.5本章小结.......................................................................................................... - 13 -

结论............................................................................................................................ - 14 -

附件............................................................................................................................ - 15 -

参考文献.................................................................................................................... - 16 -

第1章 概述

1.1 Hello简介

P2P(Program to Process):指的是从源代码 hello.c(Program)转变为运行时进程(Process)的全过程。要使 hello.c 这个 C 语言程序得以运行,首先需要将其转换为可执行文件。这个过程包括四个主要阶段:预处理、编译、汇编和链接。完成这些步骤后,生成可执行文件 hello,此时便可在 shell 中执行。shell 调用该程序时,会为其分配独立的进程空间,从而使其作为进程运行。

020(Zero to Zero):意指程序运行前后,系统内存中都不再保留 hello 程序的内容。最初,内存中并不存在与 hello 相关的数据;当 shell 通过 execve 系统调用启动 hello 程序时,内核将虚拟内存映射到物理内存,从程序的入口点开始加载并执行,最终进入 main 函数执行目标代码。程序运行结束后,shell 作为父进程会回收子进程资源,内核也会清除与 hello 程序相关的数据结构,实现从“零”开始到“零”结束的生命周期闭环。

1.2 环境与工具

硬件环境

处理器  13th Gen Intel(R) Core(TM) i7-13700H   2.40 GHz

机带 RAM  16.0 GB (15.8 GB 可用)

系统类型     64 位操作系统, 基于 x64 的处理器

软件环境

Linux DELL 6.8.0-54-generic #56-Ubuntu SMP PREEMPT_DYNAMIC Sat Feb  8 00:37:57 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux;windows11 64位

开发与调试工具Visual Studio 2022 64 objump edb gcc readelf

1.3 中间结果

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

1.4 本章小结

本章首先介绍了hello的P2P,020流程,包括流程的设计思路和实现方法;然后,详细说明了本实验所需的硬件配置、软件平台、开发工具以及本实验生成的各个中间结果文件的名称和功能。

第2章 预处理

2.1 预处理的概念与作用

预处理是指在程序编译之前,由预处理器对源代码文件进行初步加工的过程。其核心任务是对源代码中的文本进行替换操作,主要处理所有以 # 开头的预处理指令(如 #include、#define 等)。此外,预处理器还会删除代码中的注释和多余的空白字符,从而为后续的编译阶段做好准备。可以将预处理指令简单理解为编译器在翻译代码前必须处理的命令,它们通常会被替换为实际的代码内容或相关定义。

2.2在Ubuntu下预处理的命令

预处理的命令:gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i

2.3 Hello的预处理结果解析

      打开hello.i文件,发现其被扩展为几千行,但是源程序的其他部分都保持不变,表明.c文件被修改过。

在对 hello.c 程序进行预处理后,生成了中间文件 hello.i,该文件记录了预处理阶段的全部处理结果,是编译器进入语法分析前的直接输入。预处理的主要任务包括头文件展开、宏替换、注释删除以及行号信息的插入。通过对 hello.i 文件的分析,可以清晰地理解预处理器在源代码编译流程中的具体作用。

首先,源程序中通过 #include 引入的标准库头文件(如 <stdio.h>、<unistd.h> 和 <stdlib.h>)被预处理器完整地展开为系统头文件中的具体内容。例如,#include <stdio.h> 被替换为 /usr/include/stdio.h 文件中的所有声明与定义,该文件又可能间接包含多个其他头文件,如 <stddef.h>、<features.h> 等。这种递归展开极大地扩展了程序的文本量,使得 hello.i 文件在内容上远远超过原始源代码。

其次,预处理器对所有宏定义进行了替换,如将 NULL 替换为 ((void*)0),将常量 EOF 替换为具体的整型数值等。此外,所有注释和多余的空白字符被清除,保留了仅供编译使用的纯净代码文本。为保持调试信息的准确性,预处理器还插入了大量行号控制指令,如 # 1 "/usr/include/stdio.h",用于标识当前代码所对应的源文件及其行号,便于后续调试与错误定位。

在 hello.i 文件的末尾,可以找到程序的主体代码,即用户原始编写的 main 函数。这部分代码在预处理过程中被保留,用于后续的语法与语义分析阶段。

2.4 本章小结

本章介绍了在 Linux 环境下,如何通过命令行对 C 语言程序进行预处理,并解释了预处理的基本概念及其作用。我们以一个简单的 hello 程序为例,演示了从源文件 hello.c 转换为预处理文件 hello.i 的完整过程,并结合具体代码,对预处理结果进行了详细分析。

通过对 hello.i 文件的观察可以看出,预处理阶段不仅将标准输入输出库 <stdio.h> 的内容引入其中,还包含了大量宏定义、常量定义、行号信息以及条件编译指令。这些内容为后续的编译过程打下了基础,有助于程序的模块化、可读性与可移植性。

第3章 编译

3.1 编译的概念与作用

     编译的概念:计算机程序编译的概念是指将用高级程序设计语言书写的源程序,翻译成等价的汇编语言格式程序的翻译过程。

编译的作用:计算机程序的编译作用在于将高级语言编写的源代码转换为汇编语言或其他中间表示形式,从而实现程序的可执行性,同时提升编程效率与跨平台的可移植性。编译过程通常包括若干关键阶段:词法分析、语法分析、语义分析、中间代码生成、代码优化,以及最终的目标代码生成。这些阶段相互协作,共同完成从源程序到目标程序的转换过程。

3.2 在Ubuntu下编译的命令

gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s

3.3 Hello的编译结果解析

3.3.1汇编初始部分

.file               声明出源文件

.text               表示代码节

.section   .rodata    表示只读数据段

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

.string              声明一个字符串

.globl              声明全局变量

.type               声明一个符号的类型

3.3.2 数据部分

(1)字符串常量:

前者内容是 "用法: Hello 学号 姓名 手机号 秒数!

这些常量存储在.rodata (read-only data) 段中。在代码中通过           加载它们的地址。

(2)整型常量:

5: 用于 argc 检查

 

1: 用于 exit(1) 的参数

9: 用于循环条件判断

(3)局部变量

argc (整型,主函数参数): 存储在栈上 -20(%rbp) 的位置。

argv (指针,主函数参数): 存储在栈上 -32(%rbp) 的位置。

3.3.3赋值

: 将 main 函数的第一个参数(argc,在 %edi 中传递)赋值给栈上的局部变量。

: 将 main 函数的第二个参数(argv,在 %rsi 中传递)赋值给栈上的局部变量。

3.3.4全局函数

hello.c中只声明了一个全局函数

3.3.5算术操作

+ (加法):

addq $8, %rax, addq $16, %rax, addq $24, %rax, addq $32, %rax: 这些指令用于指针运算,访问 argv 数组的不同元素。argv 是 char** 类型,在64位系统中 sizeof(char*) 是8字节。所以 argv[1] 的地址是 argv 基地址加8,argv[2] 是基地址加16,依此类推。

addl $1, -4(%rbp): 循环计数器自增 (i++)。

- (减法):

subq $32, %rsp: 在栈上为局部变量分配空间。

 * (乘法), / (除法), % (取模): 在此汇编代码片段中未出现。

 ++ (自增), -- (自减):

addl $1, -4(%rbp) 实现了 i++ 。

取正/负 (+/- 单目运算符): 未明确出现。

复合赋值 (如 +=): addl $1, -4(%rbp) 实际上等同于C代码中的 i += 1。

3.3.6关系操作

== (等于):

比较 argc (在 -20(%rbp)) 是否等于5 。

je .L2 如果相等则跳转到 .L2 标签处。

!= (不等于):

如果

后 je .L2 条件不满足(即不跳转),则意味着 argc != 5。

 > (大于), < (小于), >= (大于等于), <= (小于等于):

比较循环计数器 i (在 -4(%rbp)) 是否小于或等于9 。

jle .L4 如果小于或等于则跳转到 .L4 标签处。

3.3.7控制转移

if/else:

je .L2 (如果 argc == 5,跳转到 .L2,即 if 的主体部分)

(隐式的 else 分支,如果 argc != 5):

代码段

for / while (循环): 代码中有一个 for 循环结构,可以理解为 for (i=0; i<=9; i++):

初始化:  (对应 i=0)。

首次跳转到条件判断: jmp .L3。

循环体标签: .L4:。

...(循环体代码,包括 printf, atoi, sleep 调用)...

增量:

条件判断标签: .L3:。

 (比较 i 和 9)。

jle .L4 (如果 i <= 9,则跳转回 .L4 继续循环)。

do/while: 当前结构更像 for 或 while。

continue / break: 在这个特定的循环结构中,没有明显的 continue 或 break 逻辑;循环固定执行10次。

无条件跳转: jmp .L3 。

3.3.8函数操作

(1)main函数

(2) 函数序言

这是标准的函数入口代码,用于建立栈帧。

(3) 函数尾声

这是标准的函数入口代码,用于建立栈帧。

(4) 函数调用:

call puts: 调用 puts 函数。参数 (字符串 .LC0 的地址) 在 %edi 中传递。

call exit: 调用 exit 函数。参数 (1) 在 %edi 中传递。

call printf: 调用 printf 函数。

格式字符串 .LC1 的地址在 %edi 中。

第一个可变参数 argv[1] (char*) 在 %rsi 中。

第二个可变参数 argv[2] (char*) 在 %rdx 中。

第三个可变参数 argv[3] (char*) 在 %rcx 中。

movl $0, %eax:对于可变参数函数如 printf,%eax 需要设置为用于传递浮点参数的向量寄存器的数量,这里是0。

call atoi: 调用 atoi 函数。参数 (argv[4] 的内容,即一个 char*) 在 %rdi 中传递。

加载 argv[4] 的过程:

代码段

call sleep: 调用 sleep 函数。参数 (来自 atoi 的返回值,在 %eax 中,然后移到 %edi) 在 %edi 中传递。

call getchar: 调用 getchar 函数。不需要寄存器参数。返回值在 %eax 中。

3.4 本章小结

本章讲解了C编译器将 hello.i 文件转换为 hello.s 汇编文件的过程,简要阐述了编译的概念与作用,展示了编译阶段所用的指令。通过对生成的 hello.s 文件中汇编代码的分析,进一步探讨了数据处理、函数调用、赋值、算术与关系运算、控制跳转以及类型转换等内容,并对比了这些操作在源代码与汇编代码中的具体实现方式。

第4章 汇编

4.1 汇编的概念与作用

汇编的概念:汇编是指由汇编器(as)将包含汇编语言的 .s 文件翻译成机器语言指令的过程。这些指令随后被打包成一种可重定位的目标文件格式,生成的就是 .o 目标文件。.o 文件是一个二进制文件,其中包含了 main 函数及其他代码片段的指令编码。

汇编的作用:汇编是将高级语言间接转换为机器能够直接识别和执行的机器码的过程。在这一过程中,汇编器会把 .s 汇编代码翻译成对应的机器语言指令,并将这些指令按照可重定位目标文件的格式打包生成 .o 文件。.o 文件是一种二进制格式,包含了程序中各部分(如函数)对应的指令编码。

4.2 在Ubuntu下汇编的命令

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

4.3 可重定位目标elf格式

输入readelf -a hello.o > hello.elf 指令获得 hello.o 文件的 ELF 格式:

(1)ELF头

ELF 文件头 (ELF Header) 提供了文件的元数据,是解析 ELF 文件的起点。根据 hello.o 的文件头信息,该文件被识别为一个64位 (ELF64)、采用小端字节序 (little endian) 的二进制补码表示。其类型为可重定位文件 (REL),表明这是一个编译后但尚未链接的目标文件,其内部地址引用可能需要在链接阶段进行调整。文件头同时指明了其目标操作系统ABI为 UNIX System V,目标机器架构为 Advanced Micro Devices X86-64。值得注意的是,作为可重定位文件,其程序入口点地址 (Entry point address) 设定为0x0,程序头表 (Program headers) 的数量和大小也均为0,因为这些结构主要用于描述可执行文件加载到内存的方式。文件头明确了节头表 (Section headers) 的位置 (偏移1080字节处)、大小 (每个64字节) 及数量 (14个),并指定了节头字符串表 (.shstrtab) 的索引 (13),这些信息是理解文件内容布局的基础。

(2)节头表(section header)

节头表 (Section Headers) 描述了构成 ELF 文件的各个节区 (Sections) 的属性与内容。在 hello.o 中,主要的节区包括:代码节区 .text (偏移0x40,大小0x99字节),其标志为 AX (Allocatable, Executable),包含了程序编译后的机器指令,如 main 函数的实现;只读数据节区 .rodata (偏移0xe0,大小0x40字节),标志为 A (Allocatable),用于存储常量字符串如程序中使用的提示信息和格式化字符串;数据节区 .data 和 .bss 在此文件中大小均为0,分别用于存储已初始化和未初始化的全局/静态变量。此外,还包括注释节区 .comment (存储编译器版本等信息)、用于栈可执行性标记的 .note.GNU-stack、GNU特定属性的 .note.gnu.property、异常处理相关的 .eh_frame,以及存储符号信息的 .symtab、字符串表 .strtab (用于符号名) 和节名字符串表 .shstrtab。

(3)重定位节

(4)符号表

符号表 (.symtab 节区,偏移0x1a8,包含11个条目) 是ELF文件中另一个核心组成部分,它记录了文件中定义和引用的所有符号信息。每个符号条目包含了符号名 (存储在 .strtab 中)、符号值 (通常是其在对应节内的偏移)、大小、类型 (如 FUNC 代表函数,FILE 代表源文件名,SECTION 代表节)、绑定属性 (如 GLOBAL 或 LOCAL) 以及符号所在的节索引 (Ndx)。在 hello.o 的符号表中,可以观察到本地符号如源文件名 "hello.c" 和节名如 .text、.rodata。全局定义的符号如 main 函数,其类型为 FUNC,绑定为 GLOBAL,定义在 .text 节 (Ndx 1),大小为153字节。同时,符号表也列出了多个未定义 (UND) 的全局符号,如 puts、exit、printf 等,这些符号代表了 hello.o 对外部库函数的依赖,其真实地址将在链接过程中由链接器解析并填充。这些未定义符号的存在是可重定位文件区别于可执行文件的关键特征之一。

4.4 Hello.o的结果解析

4.4.1指令

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

4.4.2与hel1o.s的对照分析

(1) 函数序言

反汇编代码:

hello.s见上一章。

endbr64 (机器码 f3 0f 1e fa) 是 Intel 控制流强制技术的一部分。

push %rbp (机器码 55) 将基址指针 %rbp 压栈。

mov %rsp,%rbp (机器码 48 89 e5) 设置新的栈帧基址。

sub $0x20,%rsp (机器码 48 83 ec 20) 为局部变量分配32字节 (0x20) 的栈空间。

这部分机器码与汇编指令一一对应,操作数也直接明了。

(2) 参数传递与局部变量赋值

反汇编代码:

hello.s代码:

-0x14(%rbp) 对应 hello.s 中的 -20(%rbp) (十进制),-0x20(%rbp) 对应 -32(%rbp)。

mov %edi,-0x14(%rbp) (机器码 89 7d ec):ec 是 -0x14 的补码表示(单字节)。

cmpl $0x5,-0x14(%rbp) (机器码 83 7d ec 05):05 是立即数5。

movl $0x0,-0x4(%rbp) (机器码 c7 45 fc 00 00 00 00):fc 是 -0x4 的补码表示,00 00 00 00 是要存入的立即数0。

(3) 操作数差异:分支转移与函数调用

这是机器语言操作数与汇编语言操作数差异最明显的领域。汇编语言使用符号标签,而机器语言使用相对偏移量或需要重定位的地址。

条件分支 (je):

  • hello.s: je .L2 (跳转到符号标签 .L2)
  • hello.o 反汇编: 17: 74 14 je 2d <main+0x2d>
  • 机器码: 74 14
    • 74 是 je 指令(短跳转)的操作码。
    • 14 (十六进制) 是一个相对偏移量。跳转的目标地址计算方式为:当前指令的下一条指令地址 + 偏移量。
      • je 指令本身在地址 0x17,长度为2字节。下一条指令地址是 0x17 + 0x2 = 0x19。
      • 目标地址 = 0x19 + 0x14 = 0x2d。这正是反汇编显示的 <main+0x2d>。
    • 汇编器在处理 je .L2 时,会计算出 .L2 相对于当前指令的偏移量,并将其编码到机器指令中。

  无条件跳转 (jmp):

  • hello.s: jmp .L3
  • hello.o 反汇编: 34: eb 51 jmp 87 <main+0x87>
  • 机器码: eb 51
    • eb 是 jmp 指令(短跳转)的操作码。
    • 51 (十六进制) 是相对偏移量。
      • jmp 指令在地址 0x34,长度2字节。下一条指令地址 0x34 + 0x2 = 0x36。
      • 目标地址 = 0x36 + 0x51 = 0x87。

  函数调用 (call) 与重定位:

  • hello.s: call puts
  • hello.o 反汇编:
  •   1e: e8 00 00 00 00       call   23 <main+0x23>
  •                        1f: R_X86_64_PLT32  puts-0x4
  • 机器码: e8 00 00 00 00
    • e8 是 call 指令(使用32位相对偏移)的操作码。
    • 00 00 00 00 是一个占位符。因为 puts 是一个外部函数,其地址在编译 hello.c 成 hello.o 时是未知的。
    • 反汇编旁边的 1f: R_X86_64_PLT32 puts-0x4 是一个重定位条目的注解。它告诉我们,链接器 (linker) 在链接阶段会修改这四个字节 00 00 00 00,将其替换为到 puts 函数(通常是通过过程链接表 PLT 的一个桩代码)的正确相对偏移量。
    • 在链接之前,objdump 将 00 00 00 00 这个偏移解释为跳转到 0x1e (call指令地址) + 0x5 (call指令长度) + 0x0 (偏移) = 0x23,即 <main+0x23>,这只是对当前未解析状态的字面解释。
  • 类似地,对 exit, printf, atoi, sleep, getchar 的调用也使用了 e8 00 00 00 00 这样的机器码,并伴随着相应的 R_X86_64_PLT32 重定位条目。

4.5 本章小结

    本章介绍了汇编的基本概念与主要功能,并以 Ubuntu 系统下的 hello.s 文件为例,说明了如何将其汇编生成 .o 目标文件,以及如何进一步构建出 ELF 格式的可执行文件 hello.elf。通过将可重定位的目标文件转化为 ELF 格式并观察其结构,对文件中的各个节段进行了简要解析。随后,结合 hello.o 的反汇编代码(保存在 hello.asm 中)与原始 hello.s 汇编代码的对比分析,帮助读者更清晰地理解了汇编语言到机器语言的转换过程,以及在链接前机器所需完成的准备工作。

5章 链接

5.1 链接的概念与作用

链接的概念:链接(linking)是将多个代码和数据片段收集、整合为一个单一可执行文件的过程,该文件可以被加载到内存中并由处理器执行。链接过程可以发生在不同的阶段:一是在编译时(compile time),即源代码被翻译为机器代码的过程中完成;二是在加载时(load time),即程序被加载器载入内存准备运行时进行;甚至还可以在运行时(run time)动态执行链接,以满足某些运行期依赖的需求。

链接的作用:链接通常由称为链接器(linker)的程序自动完成,它使得分离编译成为可能。借助链接器,我们无需将整个大型应用程序组织为一个庞大的源文件,而是可以将其划分为若干更小、结构清晰、易于管理的模块。这些模块可以独立修改和编译,从而提高开发效率。当某个模块发生更改时,只需重新编译该模块,并重新进行链接即可,无需重新编译整个程序。

5.2 在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 /usr/lib/x86_64-linux-gnu/crtn.o hello.o -lc

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

    (1)ELF头(ELF Header)

       hello1.elf 中的 ELF 头部与 hello.elf 的 ELF 头部在信息种类上基本一致,均以一个 16 字节的 Magic 序列开头,该序列描述了生成文件所使用系统的字长和字节序(大小端)。ELF 头的其余部分则包含了供链接器使用的信息,用于语法分析和解释目标文件的结构。

与 hello.elf 相比,hello1.elf 中的一些关键信息发生了变化:尽管如 Magic 字节序、文件类别等基本属性保持不变,但文件类型发生了改变,程序头大小和节头数量有所增加,同时文件获得了明确的入口地址。这些变化反映了从中间目标文件到最终可执行文件转变过程中结构上的演化。

2)节头

ELF 文件中的节表描述了各个节(section)的大小、在文件中的偏移量以及其他属性,如类型、权限等。在链接过程中,链接器会将来自不同目标文件的相同类型的节(如 .text.data.rodata 等)合并为一个更大的统一节段。随后,链接器会根据合并后节段的实际大小和偏移量,重新计算并调整所有符号的地址,确保程序在运行时能够正确访问代码和数据。这个过程是链接器完成地址重定位和符号解析的关键步骤。l

3)程序头

程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。

4Dynamic section

5Symbol table

       符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。

5.4 hello的虚拟地址空间

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

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

看各段信息。

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

5.5 链接的重定位过程分析

5.5.1分析hello与hello.o区别

objdump -d -r hell0 > hello.asm生成反汇编文件hello.asm

与第四章生成文件对比不同之处如下:

(1)新增的节和启动代码

hello.o:

主要包含编译用户代码后生成的节,如 .text (用户代码).data (如果存在已初始化数据).bss (如果存在未初始化数据).rodata (只读数据)

hello:

.init (地址 0x401000): 包含由链接器添加的初始化代码,这部分代码在 main 函数执行之前运行,用于设置程序运行环境,例如调用全局构造函数或初始化某些库。反汇编显示它可能调用 __gmon_start__ (用于性能剖析)

.plt (Procedure Linkage Table, 地址 0x401020) .plt.sec (地址 0x401090): 这些是动态链接的核心。当程序调用共享库中的函数时,会通过PLT中的条目进行间接跳转。PLT允许延迟绑定,即函数地址在第一次被调用时才解析。

.plt 的第一项 (0x401020) 通常用于解析动态链接器自身。后续条目 (0x401030 开始) 是每个外部函数的桩代码 (stub) 的一部分,它们通过 push 一个索引并跳转回 .plt 的第一项来请求地址解析。

.plt.sec 包含每个外部函数的具体跳转桩,如 puts@plt (0x401090)printf@plt (0x4010a0)等。这些桩代码通过 jmp *<GOT表项地址> 跳转到已解析的函数实际地址。

(2)外部函数调用的地址解析

hello.o:

对外部函数(如 puts, printf, exit 等)的调用指令 call 后面跟着的是 00 00 00 00 这样的占位符,表示地址未解析。

反汇编器会在这些 call 指令旁边显示重定位条目信息,如 R_X86_64_PLT32 puts-0x4

hello:

call 指令的目标地址现在是PLT中对应函数的桩代码地址。

例如,在 main 函数 (0x401125) 中:

调用 put:

e8 48 ff ff ff 中的 48 ff ff ff 是一个相对偏移量,计算结果为 0x401143 + 5 + 0xffffff48 = 0x401090,即 puts@plt 的地址。

调用 printf:

其他如 exit@plt, atoi@plt, sleep@plt, getchar@plt 的调用也是类似的。

(3)数据访问的地址解析

hello.o:

访问存储在 .rodata 节中的字符串时,mov 指令加载的地址也是 00 00 00 00 这样的占位符,并伴有重定位注解。

hello (可执行文件):

mov 指令现在加载的是字符串在内存中的确切虚拟地址。

例如,在 main 函数中:

加载用法消息字符串:  (0x402008 是该字符串在 .rodata 节加载后的虚拟地址)

加载 "Hello %s %s %s\n" 格式字符串:  (0x402038 是该字符串的虚拟地址)

5.5.2重定位过程

重定位过程主要包括以下步骤:

地址分配(Address Assignment:链接器将所有输入的目标文件和库合并,并为它们的各个节(如 .text, .data, .rodata)分配最终的虚拟内存地址。

符号解析(Symbol Resolution:链接器解析所有符号引用。对于在其他目标文件中定义的符号,它会找到其定义;对于在共享库中定义的符号,它会记录下如何通过动态链接机制(如GOT、PLT)找到它们。

地址修正(Address Patching:链接器遍历每个目标文件中的重定位条目。每个条目会告诉链接器:

哪个位置需要修改:通常是代码节或数据节中的某个偏移。

需要哪个符号的地址:即这个位置原本打算引用哪个符号。

如何修改:重定位类型指示了如何根据符号的最终地址来计算并写入新的值(例如,是写入绝对地址,还是相对于程序计数器PC的偏移量,或者是GOT条目的偏移等)。

链接器会根据符号解析得到的最终地址和重定位类型,计算出正确的值,并更新(“修补”)目标文件中的占位符。

例如,在之前的 hello.o 反汇编中,像 call puts 这样的指令,其机器码中对 puts 地址的引用部分最初是占位符 00 00 00 00,并伴有一个 R_X86_64_PLT32 类型的重定位条目。在链接生成可执行文件 hello 时,链接器会:

1.确定 puts 函数将通过PLT(过程链接表)来调用。

2.计算出 hello 文件中 puts@plt 的地址。

3.根据重定位条目,将 main 函数中调用 puts 的 call 指令的占位符修正为指向 puts@plt 的正确相对偏移量。

5.6 hello的执行流程

5.6.1过程

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

(I)开始执行:_start、_libe_start_main

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

(3)退出:exit

5.6.2子程序名或地址

程序名               程序地址

_start                0x4010f0

_libc_start_main       0x2f12271d

main                0x401125

_printf               0x4010a0

_sleep               0x4010e0

_getchar             0x4010b0

_exit                0x4010d0

5.7 Hello的动态链接分析

动态链接的基本思想是将程序拆分为多个相对独立的模块,只有在程序运行时,才将这些模块链接成一个完整的可执行程序。这样可以实现代码共享、节省内存,并便于模块的独立更新。在使用共享库函数时,编译器无法确定这些函数的运行时地址,因为它们所在的共享库可能在运行时被加载到任意内存位置。

为解决这一问题,编译器通常会为这些函数调用生成一条重定位记录,由动态链接器在程序加载阶段对其进行解析和修正。

为了进一步提高效率,现代系统采用了**延迟绑定(lazy binding)**机制,这一机制主要通过 GOT(全局偏移表)PLT(过程链接表) 实现。在程序第一次调用共享库中的函数时,PLT 会引导程序跳转至链接器,完成地址解析并更新 GOT;此后对该函数的调用将直接跳转至已解析的地址,从而避免重复解析。

根据 hello.elf 文件的信息,GOT 的起始地址位于 0x404000,这是动态链接器用于存储函数实际地址的跳转表,在程序运行期间会被逐步填充。

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

调用了dl_init之后字节改变:

对于变量而言,利用代码段和数据段的相对位置不变的原则去计算正确地址。

对于库函数而言,需要pltgot合作。plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。接下来执行程序的过程中,就可以使用过程链接表plt和全局偏移量表got进行动态链接。

5.8 本章小结

    本章首先阐述了链接的基本概念及其在程序构建中的作用,接着通过实际命令演示了如何将目标文件链接生成可执行文件 hello,并对该文件的 ELF 格式结构进行了分析。随后,利用调试工具 edb 观察了 hello 程序在内存中的虚拟地址空间分布情况。最后,以 hello 程序为例,详细解析了其重定位过程、执行过程以及动态链接机制的实现,帮助读者全面理解从编译到运行的关键环节。

6章 hello进程管理

6.1 进程的概念与作用

进程的经典定义是:一个执行中程序的实例。它表示程序在某个数据集合上的一次动态执行过程,是操作系统进行资源分配与调度的基本单位,也是操作系统结构的核心组成部分。在传统操作系统中,进程不仅是资源分配的基本单位(例如内存、文件句柄、CPU 时间片等),也是程序执行的基本单位,负责承载程序的执行逻辑与运行状态。

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

6.2.1 Shell-bash的作用

进程为程序提供了一种抽象的运行环境,使程序看起来像是在独占使用处理器和内存。从程序的角度看,处理器仿佛在连续不断地、一条接一条地执行它的指令,内存空间也是完全属于它自己的。实际上,这是操作系统通过进程隔离、上下文切换和虚拟内存机制实现的“假象”。作为执行中程序的实例,进程为程序提供了完整的运行上下文,包括代码、数据、堆栈、打开的文件、寄存器状态等。系统中的每个程序都在某个特定进程的上下文中运行,操作系统通过管理这些进程,实现多任务并发与资源共享。

6.2.2 Shell-bash的处理流程

Shell 是一种交互式的应用级程序,也被称为命令解析器。它为用户提供了一个操作界面,用于接收和解析用户输入的命令,并根据命令内容调用和调度相应的应用程序执行。Shell 作为用户与操作系统内核之间的桥梁,不仅支持基本命令的执行,还支持变量、控制结构、脚本编写等功能,是类 Unix 系统中重要的用户交互工具。

6.3 Hello的fork进程创建过程

Shell 的执行流程通常如下:

首先,它从终端读取用户输入的命令,并对该命令进行解析。如果识别到的是一个内置命令(如 cd、exit 等),Shell 会直接在当前进程中执行该命令。而如果是一个外部程序,Shell 则通过 fork 创建一个新的子进程,在该子进程的上下文中加载并执行指定的程序(使用 exec 族函数)。

接下来,Shell 会判断该命令是运行在前台还是后台。

如果是前台程序,Shell 会主动等待该子进程运行结束,然后继续接受新命令;

如果是后台程序(通常命令后加 &),Shell 则不会等待其完成,而是将其放入后台运行,并立即返回提示符供用户继续输入其他命令。

在整个过程中,Shell 还可以监听来自终端的信号输入(如 Ctrl+C 发送的 SIGINT),并根据设定对这些信号进行适当的处理,如中断当前进程、忽略某些信号或进行清理操作等。这些机制确保了 Shell 的交互性与稳定性。

6.4 Hello的execve过程

execve 函数用于在当前进程的上下文中加载并执行一个新的程序,其函数声明如下:

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

参数说明:

filename:要执行的可执行文件的路径;

argv[]:传递给程序的参数列表,第一个参数通常是程序名本身;

envp[]:指向环境变量字符串数组的指针列表。

调用 execve 后,当前进程的用户空间将被新程序的内容所完全替代:代码段、数据段、堆和栈都被新的程序加载内容所覆盖,原有的执行状态(除了 PID)全部被替换。这一过程不会创建新进程,而是让当前进程“变身”为另一个程序。

重要特性包括:

如果 execve 调用成功,它不会返回;从这一点看,它与 fork 的“一次调用两次返回”相对;

只有在出现错误(例如文件不存在或无执行权限)时,execve 才会返回,通常返回 -1 并设置 errno 以指示错误原因。

当新程序开始运行时,其 main 函数会被调用,参数由 argv 和 envp 初始化,且栈中大致结构如下(自高地址向低地址排列):

┌────────────────────────────┐

│      环境变量 envp[]       │

├────────────────────────────┤

│      参数列表 argv[]        │

├────────────────────────────┤

│        参数个数 argc        │

├────────────────────────────┤

│         返回地址 (空)        │

└────────────────────────────┘

这个用户栈布局由内核在加载新程序时自动构建,确保程序能从 main(int argc, char *argv[], char *envp[]) 正常开始执行。

6.5 Hello的进程执行

在 hello 程序运行的过程中,进程为应用程序提供了两个核心抽象:

独立的逻辑控制流:即程序的指令似乎是一个接一个连续执行的,仿佛该进程独占了处理器资源。这使得开发者可以用顺序的思维来编写程序。

私有的地址空间:每个进程拥有一个独立的虚拟地址空间,看起来它独享整个内存,无法直接访问其他进程的数据,这为程序运行提供了内存安全与隔离。

而这些抽象是由操作系统通过以下机制实现的:

1. 逻辑控制流

逻辑控制流指的是程序执行时形成的指令序列。通过调试器我们可以看到指令逐条执行,其地址由程序计数器(PC)决定。多个逻辑控制流可以并发执行(如多进程或多线程),即在时间上交替运行。

2. 上下文切换(Context Switch)

操作系统通过上下文切换来支持多任务并发执行。当 CPU 从一个进程切换到另一个进程时,会保存当前进程的状态(即上下文),然后加载另一个进程的上下文,这个切换由内核完成。上下文包括:

通用寄存器

浮点寄存器

程序计数器

状态寄存器

用户栈和内核栈

内核中记录进程状态的各种数据结构

3. 时间片(Time Slice)

进程在 CPU 上运行的时间是被切分成小段的,每段称为时间片。操作系统为每个进程分配一个时间片,时间片用完后就会触发上下文切换,切换到另一个进程。这种方式实现了“伪并行”的多任务执行,称为时间分片调度。

4. 用户模式与内核模式

处理器有两种运行权限模式:

用户模式:程序只能执行普通指令,无法访问硬件资源或内核内存。

内核模式:允许执行特权指令(如 I/O 操作、访问内存管理单元等)。

模式的切换通常发生在系统调用、异常处理或中断时。操作系统通过一个控制寄存器中的模式位进行权限控制。

5. hello 程序的执行过程

当用户在 Shell 中输入 ./hello 2023113004 邵博儒 后,Shell 调用 fork 创建子进程,再由子进程调用 execve 加载 hello 程序。

execve 会清除旧程序的虚拟内存,并为 hello 分配新的虚拟地址空间,加载代码和数据段,程序开始执行,此时运行在用户模式。

执行到 printf 时,程序通过系统调用进入内核模式,执行底层输出操作,再返回用户模式。

接着,执行 sleep 函数,程序会暂时挂起并进入内核模式,等待唤醒。

在此期间,CPU 会执行上下文切换,将运行权交给其他进程,实现进程间的轮换与调度。

如果有信号(如时钟中断或外部输入)到来,内核会调度信号处理程序,执行后再返回用户程序。

这种基于抽象与调度机制的设计,使得多个程序能够安全、并发、高效地共享计算机资源,同时让开发者可以用简洁的逻辑控制程序行为。

6.6 hello的异常与信号处理

6.6.1异常

6.6.2异常的处理方式

1. 中断 (Interrupt)

处理方法:

CPU 检测:CPU 检测到中断信号。

暂停当前进程:CPU 暂停 "hello" 程序的执行。

保存状态:CPU 将 "hello" 的当前状态(如程序计数器、寄存器等)保存到内核堆栈中。

识别中断源:CPU 通过中断向量表(或中断描述符表 IDT)找到对应的中断处理程序的入口地址。

跳转至内核处理程序:CPU 跳转到操作系统内核中预定义的中断服务例程 (ISR)。

内核服务中断:

操作系统内核首先屏蔽当前中断或更低优先级的中断,以避免嵌套干扰。

内核识别具体的中断源,并执行相应的处理(例如,读取I/O数据、更新系统时钟、通知相关驱动程序)。

处理完毕后,内核可能会清除中断标志。

恢复或调度:

如果中断处理不需要立即调度其他进程,内核会恢复 "hello" 程序被中断时的状态。

如果发生了需要更高优先级进程运行的情况(例如,一个重要的I/O完成了,等待该I/O的进程现在可以运行了),或者 "hello" 的时间片已用完,内核可能会进行一次上下文切换,调度另一个进程运行。

返回用户态:如果 "hello" 被恢复,它会从被中断的那一点继续执行,通常对中断的发生是无感的(除了执行时间上的延迟)。

对 "hello" 的影响:"hello" 的执行被暂时挂起,当中断处理完成后,它会从断点处继续。通常情况下,中断对 "hello" 程序的逻辑是透明的,但会影响其实时性。

2. 陷阱 (Trap)

定义与原因:陷阱是同步发生的事件,由正在执行的指令有意触发。最常见的陷阱是系统调用(System Call),例如当 "hello" 程序执行 printf 函数时,底层会通过 syscall 指令请求操作系统内核的服务(如向屏幕输出字符)。调试器设置的断点(如 int3 指令)也是一种陷阱。

处理方法:

CPU 执行陷阱指令:当 "hello" 执行到如 syscall 这样的指令时,CPU 识别出这是一个陷阱。

模式切换与状态保存:CPU 从用户态切换到内核态,并保存 "hello" 的当前状态(程序计数器、用户栈指针等)。

跳转至内核处理程序:CPU 根据陷阱指令的类型或参数(如系统调用号)跳转到内核中相应的陷阱处理例程。

内核执行服务:

对于系统调用,内核会验证参数,然后执行请求的服务(如文件读写、进程创建、网络通信等)。

对于断点陷阱,内核可能会通知相关的调试器进程,并将控制权交给调试器。

返回结果与恢复:服务完成后,内核会将结果(如果有)返回给 "hello" 程序(通常通过寄存器),恢复 "hello" 的状态,并从用户态切换回用户态,使其从陷阱指令的下一条指令继续执行。

对 "hello" 的影响:"hello" 的执行会主动进入内核以获得特定服务。这是一种正常的、预期的控制转移。例如,printf 的输出就是通过系统调用陷阱完成的。

3. 故障 (Fault)

定义与原因:故障是同步发生的事件,由当前指令执行时检测到的潜在可恢复错误引起。如果能够被修复,程序可以从导致故障的同一条指令重新执行。常见的故障包括:

缺页故障 (Page Fault):当 "hello" 程序试图访问一个在虚拟地址空间中有效但当前不在物理内存中的页面时发生(例如,页面被交换到了磁盘上,或者是写时复制的页面首次写入)。

段错误 (Segmentation Fault) / 保护故障 (Protection Fault):当 "hello" 程序试图访问一个它没有权限访问的内存地址(如写入只读区域、访问内核空间地址),或进行非法操作(如除以零、执行特权指令)。

处理方法:

CPU 检测故障:CPU 在执行某条指令时检测到错误条件。

状态保存:CPU 保存当前状态,特别是导致故障的指令的地址。

跳转至内核处理程序:CPU 跳转到内核中相应的故障处理例程。

内核分析与处理:

对于缺页故障:

内核检查访问的虚拟地址是否有效,以及访问权限是否合法。

如果地址有效且权限合法,但页面不在物理内存中(例如已被换出到磁盘),内核会从磁盘调入该页面到物理内存,更新页表。然后,内核会使 "hello" 程序重新执行导致故障的那条指令。这个过程对 "hello" 程序通常是透明的(除了性能延迟)。

如果地址无效或权限非法(例如,"hello" 试图访问一个根本未映射的地址),则这是一个致命的缺页故障。内核会将其视为一个错误,并通常向 "hello" 进程发送一个信号(如 SIGSEGV - 段违例)。

对于其他故障(如段错误、非法指令、除零错误等):

这些通常是不可恢复的程序错误。内核会向 "hello" 进程发送一个相应的信号(如 SIGSEGV、SIGILL - 非法指令、SIGFPE - 浮点异常/算术异常)。

信号处理或终止:

如果 "hello" 程序为收到的信号注册了处理函数,则该处理函数会被执行。

如果 "hello" 没有注册处理函数,或者处理函数执行后返回,则操作系统会执行该信号的默认动作。对于 SIGSEGV、SIGILL、SIGFPE 等严重错误信号,默认动作通常是终止 "hello" 进程。

对 "hello" 的影响:如果是可修复的故障(如合法的缺页),"hello" 可以继续执行。如果是不可修复的故障,"hello" 通常会收到一个致命信号并被终止,除非它有特定的信号处理机制来尝试清理或记录错误。

4. 终止 (Abort)

定义与原因:终止是同步发生的事件,代表了严重的、不可恢复的硬件错误或系统状态不一致。这类错误通常不是由 "hello" 程序的逻辑直接引起的,但可能在其执行期间被检测到。例如,硬件检测到内存校验错误,或者CPU执行了自身无法处理的严重错误。

处理方法:

CPU/硬件检测:检测到严重错误。

系统尝试处理:CPU 会跳转到内核中一个非常底层的错误处理例程。

内核响应:

由于错误的严重性,操作系统通常无法恢复当前进程 ("hello") 的执行,甚至可能无法保证整个系统的稳定。

操作系统会尽力记录错误信息(例如在系统日志中)。

操作系统通常会强制终止引发或受影响的进程 ("hello"),可能会发送 SIGABRT 或类似的信号,或者直接清理进程资源。

在某些极端情况下,如果错误影响到整个系统的稳定性,操作系统可能会选择宕机 (panic) 或重启,以防止进一步的数据损坏。

对 "hello" 的影响:"hello" 程序会被强制终止,几乎没有机会进行自定义的清理或恢复操作。这是最严重的一种异常类型。

6.6.3运行结果及相关命令

1)正常运行状态

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

2)运行时按下Ctrl + C

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

3)运行时按下Ctrl + Z

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

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

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

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

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

8)不停乱按

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

6.7本章小结

    本章主要围绕计算机系统中的进程与 Shell 这两个核心概念展开探讨。首先通过一个简单的 hello 程序引入,简要介绍了进程的定义、作用以及 Shell 的功能和基本处理流程。随后,深入分析了 hello 程序从进程创建(fork)、程序加载(execve) 到运行与调度的全过程,具体说明了在用户输入命令后 Shell 如何解析、派生子进程并执行目标程序。此外,本章还对程序运行过程中可能遇到的异常情况(如执行失败、信号中断)进行了说明,并对程序运行结果中出现的各种输入和输出现象进行了合理的解释,帮助读者更全面地理解进程生命周期以及 Shell 在系统中的调度和控制作用。

7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址: 在具有地址变换功能的计算机系统中,指令中给出的地址称为逻辑地址,也叫相对地址,它由段标识符和段内偏移量组成,必须经过地址转换才能得到实际访问的物理地址。在程序 hello 中,生成的地址就是与段相关的偏移地址部分,这些逻辑地址在运行时由操作系统和硬件共同转换为物理地址,从而实现对内存的正确访问。

线性地址: 线性地址是逻辑地址到物理地址变换之间的一步,程序hello的代码会产生逻辑地址,在分段部件中逻辑地址是段中的偏移地址,加上基地址就是线性地址。

虚拟地址: 程序访问存储器时使用的地址称为虚拟地址,它是由操作系统为每个进程提供的独立地址空间,与实际物理内存的容量无关。虚拟地址需要通过地址转换机制映射为物理地址,才能真正访问内存。在 hello 程序中,所用的正是这种虚拟地址,操作系统通过页表等结构将其转换为实际的物理地址,从而实现进程的隔离与内存管理。

物理地址: 在存储器里以字节为单位存储信息,每一个字节单元给一个唯一的存储器地址,这个地址称为物理地址,是hello的实际地址或绝对地址。

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

段式管理是一种将程序划分为若干逻辑单元(段)进行存储和管理的内存管理方式。每个段代表程序中的一个逻辑实体,如代码段、数据段或堆栈段。系统通过段表来维护每个段的信息,包括段号、段起点地址、装入状态以及段长度等。

在段式管理中,一个逻辑地址由两部分组成:段选择符(段标识符)和段内偏移量。段选择符是一个16位的字段,其中前13位作为索引用于在段描述符表中查找对应的段描述符,后3位为访问权限等硬件控制位。段描述符包含该段的基地址、段限长以及访问权限等信息。

系统中维护两个主要的描述符表:

全局描述符表(GDT):整个系统唯一,包含操作系统使用的段描述符和每个任务对应的LDT段描述符;

局部描述符表(LDT):每个任务或进程独有,记录该进程私有的代码段、数据段、堆栈段及各种门描述符(如任务门、调用门)。

通过这种机制,段式管理不仅支持程序逻辑结构的划分,还为多任务操作提供了良好的内存保护和隔离能力

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

虚拟内存被组织为一个由磁盘上的连续字节单元组成的线性地址空间,系统将其划分为固定大小的虚拟页,而物理内存则被划分为对应大小的物理页。操作系统通过页表来管理虚拟页与物理页之间的映射关系。

页表是一个包含多个**页表条目(PTE)**的数组,每个PTE包括:

一个有效位:表示该虚拟页当前是否已被加载到物理内存(DRAM)中;

一个物理页地址字段:若有效位为1,该字段指向对应的物理页起始地址。

当程序访问某个虚拟地址时,内存管理单元(MMU)会查阅页表,将虚拟地址转换为物理地址。如果有效位未设置,即发生缺页中断,操作系统会将对应的页从磁盘调入内存,更新页表后重新执行指令。

这种机制支持了进程的隔离、按需加载和更高效的内存利用,是现代操作系统中虚拟内存管理的核心。

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

在Core i7处理器中,采用四级页表结构来进行虚拟地址到物理地址的映射。其工作流程如下:

虚拟地址VA生成:CPU执行程序时生成虚拟地址(VA)。

交给MMU处理:该地址被送入内存管理单元(MMU),MMU使用虚拟地址的高位部分作为虚拟页号(VPN)。

TLB查询:

MMU首先在**快表(TLB)**中查找是否有该VPN对应的映射。

若TLB命中,则立即返回对应的物理页号(PPN),与虚拟地址的页内偏移(VPO)拼接生成物理地址(PA)。

若TLB未命中,则进入页表查找阶段。

页表查询过程:

MMU使用寄存器 CR3 获取**第一级页表(PML4)**的物理地址。

虚拟地址中的 VPN 分成四部分(VPN1、VPN2、VPN3、VPN4),依次作为偏移,逐级在 PML4 → PDPT → PDT → PT 中索引,找到最终的页表条目(PTE)。

PTE 中包含目标页的物理页号(PPN)。

物理地址生成:将最终查找到的 PPN 与原虚拟地址中的页内偏移 VPO 拼接,形成完整的物理地址 PA。

更新TLB:将这条页表映射缓存到 TLB 中,便于下次快速访问。

这种四级页表结构使得x86-64架构能支持高达 48 位甚至更高的虚拟地址空间,同时配合TLB提高了地址翻译的效率。

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

高速缓存(Cache)结构中,系统将 m 位地址划分为三部分:

t 位标记(Tag):用于验证缓存行是否匹配;

s 位组索引(Set Index):用于定位缓存中的特定组;

b 位块偏移(Block Offset):用于定位块内的具体字节。

当处理器访问某个地址时:

使用组索引位定位到缓存中的一个组。

在该组内查找有效位为1且标记位与地址中的标记匹配的缓存行。

若找到了,称为缓存命中,数据可以直接从缓存中读取。

若没有匹配项,则为缓存未命中:

需要从主存中将目标数据块加载到缓存中。

替换策略决定新块存放在组内的哪一行,常见的策略有:

LRU(最近最少使用):替换最近最久未被访问的缓存行;

FIFO:替换最早进入该组的行;

Random:随机选择一行替换。

这样的结构和策略设计可以在不显著增加复杂性的前提下,提高数据访问速度,减小主存访问延迟。

7.6 hello进程fork时的内存映射

当当前进程调用 fork 函数时,操作系统内核会为新进程创建所需的数据结构,并分配一个唯一的进程标识符(PID)。为了构建新进程的虚拟内存空间,内核会复制当前进程的内存描述符(mm_struct)、各个内存区域的结构信息以及页表,从而创建一个虚拟地址空间的副本。此时,新进程的内存布局与原进程完全一致。由于采用了**写时复制(Copy-on-Write, COW)**机制,两个进程在读取共享内存页面时使用同一份物理页,而当任一进程尝试写入某个页面时,内核才会为该进程分配新的物理页面,并完成内容复制。这种机制不仅节省内存资源,还保持了进程间虚拟地址空间相互独立的抽象。

7.7 hello进程execve时的内存映射

execve 函数在当前进程中加载并运行指定的可执行文件(如 hello 程序),实现了对当前进程映像的彻底替换,整个加载过程主要包括以下几个关键步骤:

清除原有用户空间:操作系统首先移除当前进程虚拟地址空间中已有的用户区域结构(包括代码段、数据段、堆栈等),为加载新程序腾出空间。

映射私有区域:为新程序分配虚拟地址空间中的私有区域,包括 .text(代码段)、.data(已初始化数据段)、.bss(未初始化数据段)、堆和用户栈。这些区域通常被标记为私有和写时复制(Copy-on-Write)。其中:

text 和 .data 段从 hello 文件中直接映射;

.bss 段是零初始化的匿名映射;

堆和栈区域初始为空,按需增长。

映射共享区域:将 hello 程序所依赖的共享库(如 libc.so)映射到虚拟地址空间的共享区域,通常这些共享库已被系统加载过,加载过程可通过内存映射(mmap)实现,共享段是只读共享的。

设置程序计数器(PC):最后一步是将当前进程的程序计数器设置为 hello 程序的入口地址,使 CPU 从该位置开始执行,从而完成对新程序的启动。

这个过程中,execve 不会返回(除非出错),因此它本质上用新的程序映像“覆盖”了当前进程,是进程执行替换的重要机制。

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

当程序执行过程中访问一个尚未映射到物理内存的虚拟地址时,会触发缺页故障(page fault),此时操作系统内核会调用缺页处理程序来解决该异常。处理流程如下:

合法性检查:
内核首先检查发生缺页的虚拟地址是否属于当前进程合法的地址空间(即是否存在于进程的虚拟内存映射区域,如 .text、.data、堆或栈等)。

如果地址非法(例如指向未映射区域或越界栈空间),则触发段错误(segmentation fault),操作系统终止该进程。

访问权限检查:
若地址合法,内核继续检查当前访问类型(读、写或执行)是否被允许。

如果访问违反了该页的权限(如试图写入只读页面),则触发保护异常(protection fault),进程被终止。

页面调入与替换:
如果前两步都通过,说明该页确实应该存在,只是尚未加载到内存。

此时,内核可能需要选择一个已有的物理页面作为“牺牲页面”来腾出空间(根据替换算法,如 LRU)。

如果该牺牲页面被修改过(即“脏页”),则必须先写回磁盘(swap out)。

然后,从磁盘将所需页面读入内存(swap in),更新页表项以映射新页面,并将其标记为有效。

恢复执行:页面装载完成后,内核将控制权交还给原进程,重新执行触发缺页故障的那条指令。由于该页面现在已映射,指令可以成功执行,进程继续运行。

整个过程对用户程序来说是透明的,也体现了虚拟内存的一个重要特性:按需分页 和 延迟加载。

7.9动态存储分配管理

以下格式自行编排,编辑时删除

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

7.10本章小结

  本章主要介绍了 hello 程序的存储器地址空间结构,深入探讨了 Intel 架构下的段式管理机制和页式管理机制。通过以 Intel Core i7 处理器为例,详细说明了从虚拟地址(VA)到物理地址(PA)的转换过程及物理内存的访问方式。随后分析了 hello 进程在执行 fork 系统调用时的虚拟内存映射情况,以及在执行 execve 系统调用时内存空间的重建过程。最后,还探讨了程序运行中发生缺页故障时,操作系统如何通过缺页中断处理机制完成页面调入、页表更新以及异常处理等步骤,保障进程的正常执行。

结论

hello所经历的过程

  1. 代码编写 (Code Authoring): 程序员使用文本编辑器在用户空间创建 hello.c 源代码文件。此文件以字符序列的形式存储在磁盘上,是程序逻辑的人类可读描述。
  2. 预处理 (Preprocessing): hello.c 被C预处理器(如cpp)处理。此阶段展开宏定义,处理 #include 指令(如将 <stdio.h> 的内容文本插入),并移除注释,生成中间文件 hello.i。
  3. 编译 (Compilation): 编译器(如gcc的前端cc1)对 hello.i进行词法分析、语法分析、语义分析、优化,并最终生成特定目标体系结构(如x86-64或ARM)的汇编代码文件 hello.s。
  4. 汇编 (Assembly): 汇编器(如as)将 hello.s中的汇编指令逐条翻译成机器语言指令,并打包成可重定位目标文件 hello.o。此文件采用ELF(Executable and Linkable Format)格式,包含了代码段(.text)、数据段(.rodata等)、符号表、以及供链接器使用的重定位信息。
  5. 链接 (Linking): 链接器(如ld)将 hello.o 与C运行时库(crt1.o, crti.o等)及标准库函数(如printf的实现,可能来自libc.so或libc.a)合并。此过程包括:
    • 符号解析:确保每个符号引用都有唯一的定义。
    • 地址重定位:修改代码和数据节中的地址引用,使其指向最终加载到内存中的正确位置。
    • 生成最终的可执行文件 hello(也是ELF格式,但类型为EXEC)。此文件包含了所有执行所需的代码和数据,以及用于加载到内存的程序头表。对于动态链接,还会包含 .interp 段指定动态链接器路径,以及 .plt 和 .got 等辅助动态链接的节。
  6. 加载 (Loading): 当用户在shell中执行 ./hello 时:
    • Shell进程通过 fork() 系统调用创建一个子进程。
    • 子进程通过 execve() 系统调用,陷入内核。
    • 内核的加载器读取 hello 文件的ELF头和程序头表。根据程序头表的指示,将可执行文件的代码段和数据段(Segments)从磁盘复制或映射到子进程的虚拟地址空间中(如使用 mmap)。
    • 初始化 .bss 段(清零)。
    • 如果程序是动态链接的,内核加载并启动在 .interp 段中指定的动态链接器(如 /lib64/ld-linux-x86-64.so.2)。
    • 动态链接器接着加载 hello 所依赖的共享库(如 libc.so.6),并对这些库中的符号引用进行运行时重定位。
    • 最终,控制权转移到 hello 程序的入口点(通常是 _start)。
  7. 执行 (Execution):
    • _start 代码(由C运行时提供)初始化执行环境(如设置堆栈指针、传递 argc/argv),然后调用 hello.c 中定义的 main 函数。
    • CPU按照程序计数器 (PC) 的指示,从内存中(通过多级缓存系统)逐条取出 main 函数及其调用的函数的机器指令,进行译码和执行。
    • 数据访问与地址转换:当指令访问内存时,CPU产生的虚拟地址 (VA) 由内存管理单元 (MMU) 借助页表和TLB (Translation Lookaside Buffer) 转换为物理地址 (PA)。
    • 系统调用 (陷阱):当 hello 程序执行如 printf 这样的库函数需要操作系统服务时(例如进行I/O操作),会通过 syscall 指令发起系统调用,CPU从用户态切换到内核态,执行内核中相应的服务例程。完成后,返回用户态继续执行。
    • 中断处理:在 hello 执行期间,外部硬件(如定时器、I/O设备)可能发出中断信号。CPU会暂停 hello 的当前指令,保存上下文,跳转到内核的中断服务例程。处理完毕后,内核恢复 hello 的上下文并继续其执行(或调度其他进程)。
    • 故障处理:若 hello 执行了非法操作(如除以零、访问无效内存地址导致缺页故障或段错误),CPU会检测到并产生一个故障。
      • 缺页故障:如果访问的页面有效但在磁盘上,内核会将其调入物理内存,更新页表,然后重新执行导致故障的指令。
      • 段错误等严重故障:内核通常会向 hello 进程发送一个信号(如 SIGSEGV),若无特定处理,默认行为是终止进程。
    • 终止请求:若 hello 试图执行非法操作,或收到不可恢复的错误信号,或硬件发生严重故障,可能导致进程被操作系统终止 (Abort)。
  8. 终止 (Termination):
    • main 函数执行完毕并返回一个退出码,或者程序中调用 exit() 函数。
    • exit() 函数会执行一些清理操作(如调用 atexit 注册的函数,刷新标准I/O缓冲区),然后通过系统调用 (exit_group) 通知内核进程即将终止。
    • 内核回收 hello 进程所占用的所有资源,包括虚拟内存空间、页表、文件描述符等。
    • 内核将 hello 的退出状态通知给其父进程(通常是shell)。

至此,hello 程序完成了它在计算机系统中的完整生命周期,从一行行源代码,经历编译、链接、加载,到在CPU上执行,最终被系统回收。

感悟

通过本次实验,我深刻体会到计算机系统内部运作的精密与强大。一个看似简单的任务,背后却依赖于处理器、内存、操作系统等多个系统组件的紧密配合与高效运作,展现出严谨的逻辑设计与现代技术的高度融合。这不仅加深了我对计算机系统工作原理的理解,也让我更加敬畏和欣赏计算机工程的精巧与智慧。

附件

文件名

功能

hello.c

源程序

hello.i

预处理后得到的文本文件

hello.s

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

hello.o

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

hello.elf

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

hello_o_asm.txt

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

hello.asm

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

hello

可执行文件

hello1.elf

readelf读取hello得到的ELF格式

参考文献

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

[2]   Silberschatz, A., Galvin, P. B., & Gagne, G. (2018). Operating System Concepts (10th ed.). John Wiley & Sons, Inc.

[3]   Patterson, D. A., & Hennessy, J. L. (2017). Computer Organization and Design ARM Edition: The Hardware Software Interface. Morgan Kaufmann Publishers.

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

「LLM那些事」系列第 4 篇《上下文窗口的边界》,文章连接:https://blog.csdn.net/houwenjin/article/details/163999753。 演示什么:在「预测」Sheet 的黄色格子里输入一句话(默认「来泡一杯」),四个「模型」——分别只统计最后 1 / 2 / 3 / 4 个字的 n-gram 查表——同时预测下一个字。同一个输入,看的上下文越长,候选越少、预测越确定: ┌────────────────┬──────────┬───────────────┬──────┐ │ 只看最后几个字 │ 用的前缀 │ 候选下一字数 │ 预测 │ ├────────────────┼──────────┼───────────────┼──────┤ │ 1 个 │ 杯 │ 3(茶/子/水) │ 模糊 │ ├────────────────┼──────────┼───────────────┼──────┤ │ 2 个 │ 一杯 │ 2(茶/水) │ 收窄 │ ├────────────────┼──────────┼───────────────┼──────┤ │ 3 个 │ 泡一杯 │ 1(茶) │ 确定 │ ├────────────────┼──────────┼───────────────┼──────┤ │ 4 个 │ 来泡一杯 │ 1(茶) │ 确定 │ └────────────────┴──────────┴───────────────┴──────┘
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值