ICS大作业论文

本文通过对Hello程序的全面分析,系统阐述了从程序源代码到执行过程的完整计算机系统运行机制。研究采用GCC编译工具链和调试工具,追踪分析了预处理、编译、汇编和链接四个阶段的中间产物及其特性。文中详细解析了逻辑地址到物理地址的多级转换过程、TLB与缓存加速机制、页式内存管理以及动态链接的运行时解析过程。同时探讨了进程创建与内存映射机制,包括fork的写时复制技术和execve的地址空间重构。研究结果揭示了现代计算机系统各抽象层次间的协作关系,从硬件的存储层次结构到操作系统的内存管理和进程调度,再到应用程序的执行流程,展现了计算机系统设计的复杂性和精巧性。

关键词:程序编译;动态链接;内存管理;地址转换;进程调度;缓存机制。

(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)

目 录

第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简介

根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。

From Program to Process,就是从编写代码,到编译、运行的过程。

编写代码->编译->汇编->链接;运行时,进程管理分配地址和时间片;结束时,释放内存、删除进程。

1.2 环境与工具

列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。

Visual Studio Code;

WSL2;

Ubuntu 22.04;

Gdb;

MinGw;

1.3 中间结果

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

Hello.c

Hello.i

Hello.s

Hello.o

Hello.so

Hello.elf Hello.exe

1.4 本章小结

整个实验过程通过对Hello程序的全面分析,系统阐述了从程序源代码到执行过程的完整计算机系统运行机制。研究采用GCC编译工具链和调试工具,追踪分析了预处理、编译、汇编和链接四个阶段的中间产物及其特性。文中详细解析了逻辑地址到物理地址的多级转换过程、TLB与缓存加速机制、页式内存管理以及动态链接的运行时解析过程。同时探讨了进程创建与内存映射机制,包括fork的写时复制技术和execve的地址空间重构。研究结果揭示了现代计算机系统各抽象层次间的协作关系,从硬件的存储层次结构到操作系统的内存管理和进程调度,再到应用程序的执行流程,展现了计算机系统设计的复杂性和精巧性。

(第1章0.5分)

第2章 预处理

2.1 预处理的概念与作用

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

概念:在编译之前,通过预处理器,改写或增强代码。

作用:

  1. 包含头文件
  2. 处理宏定义
  3. 处理条件编译
  4. 移除注释
  5. 最后生成 .i 文件。

2.2在Ubuntu下预处理的命令

gcc -E hello.c -o hello.i

2.3 Hello的预处理结果解析

1.预处理后的 .i 文件可以正常记事本打开

2.代码达到了惊人的3000行

3.多出的函数有:extern; typedef; enum

4.程序主体在文件的最后部分

2.4 本章小结

实验了hello的预处理阶段。

(第2章0.5分)

第3章 编译

3.1 编译的概念与作用

概念:

编译是将高级语言如C, 转换为汇编语言的过程,对C语言来说,是从 .i ->.s的过程。

作用:

  1. 将高级语言转换为更底层的汇编代码,让计算机离理解程序更进一步。
  2. 提高运行速度和减少资源占用。

3.2 在Ubuntu下编译的命令

gcc -S Hello.i -o Hello.s

3.3 Hello的编译结果解析

3.3.1 整型数据和变量的处理

汇编代码中对整型变量的处理主要体现在:

subq $32, %rsp # 分配32字节栈空间
movl %edi, -20(%rbp) # 保存参数argc到栈上(-20(%rbp))
movl $0, -4(%rbp) # 将局部变量i初始化为0
addl $1, -4(%rbp) # i++操作
cmpl $9, -4(%rbp) # 比较i与9
movl $0, %eax # 设置返回值为0

  1. 局部变量存储
    • 变量i存储在栈帧上的-4(%rbp)位置
    • 32位整数使用movl指令操作
    • 变量赋值直接通过内存写入实现
  2. 函数参数
    • argc从%edi寄存器(第一个参数)存入栈上的-20(%rbp)
  3. 返回值
    • 通过设置%eax寄存器为0实现return 0

3.3.2 指针和数组操作

movq %rsi, -32(%rbp) # 保存argv指针
movq -32(%rbp), %rax # 将argv加载到rax
addq $8, %rax # rax += 8 (指向argv[1])
movq (%rax), %rax # rax = *rax (取出argv[1]指向的字符串)

  1. 指针存储
    • argv作为指针数组保存在-32(%rbp)
    • 64位地址通过movq指令处理
  2. 指针运算
    • 通过addq $8, %rax实现指针加法,每次加8字节(一个指针大小)
    • argv[1]: addq $8, %rax
    • argv[2]: addq $16, %rax
    • argv[3]: addq $24, %rax
    • argv[4]: addq $32, %rax
  3. 内存访问
    • 通过movq (%rax), %rax解引用指针,获取指针指向的值

3.3.3 字符串常量处理

.section .rodata
.align 8
.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"
.LC1:
.string "Hello %s %s %s\n"

  1. 字符串存储
    • 字符串常量存储在.rodata(只读数据)段
    • 中文字符以UTF-8十六进制表示
    • 通过标签(.LC0和.LC1)引用
  2. 字符串访问
    • 使用leaq .LC0(%rip), %rax加载字符串地址
    • 使用基于RIP的相对寻址,确保位置无关代码(PIC)

3.3.4 条件分支实现

cmpl $5, -20(%rbp) # 比较argc与5
je .L2 # 如果相等,跳转到L2
leaq .LC0(%rip), %rax # 否则加载错误提示
movq %rax, %rdi # 设置printf参数
call puts@PLT # 调用puts
movl $1, %edi # 设置exit参数
call exit@PLT # 调用exit
.L2: # 正常流程继续

  1. 比较操作
    • 通过cmpl指令比较整数值
    • je(相等时跳转)实现条件分支
  2. 短路行为
    • 使用条件跳转指令(je)实现if-else逻辑
    • 执行错误分支后使用exit避免执行正常代码路径

3.3.5 循环控制结构

movl $0, -4(%rbp) # i = 0
jmp .L3 # 跳转到条件检查
.L4: # 循环体开始
# 循环体代码
addl $1, -4(%rbp) # i++
.L3: # 条件检查
cmpl $9, -4(%rbp) # 比较i与9
jle .L4 # 如果i<=9,继续循环

  1. 循环初始化
    • movl $0, -4(%rbp)设置计数器初值
  2. 循环条件检查
    • 在.L3标签处进行条件检查
    • 使用cmpl和jle(小于等于时跳转)实现i<10条件
  3. 循环增量
    • addl $1, -4(%rbp)实现i++
    • 循环体完成后更新计数器,然后进行条件检查
  4. 循环跳转
    • 首次进入循环时直接跳到条件检查
    • 条件满足时跳回循环体

3.3.6 函数调用机制

leaq .LC1(%rip), %rax # 加载格式字符串地址
movq %rax, %rdi # 设置第一个参数(格式字符串)
movl $0, %eax # 设置浮点参数数量为0
call printf@PLT # 调用printf

  1. 参数传递
    • 前6个参数通过寄存器传递:%rdi, %rsi, %rdx, %rcx, %r8, %r9
    • printf的格式字符串通过%rdi传递
    • 参数顺序设置为:%rdi(格式串), %rsi(argv[1]), %rdx(argv[2]), %rcx(argv[3])
  2. 函数调用
    • 通过call指令调用函数
    • 使用PLT(过程链接表)处理外部库函数:call 函数名@PLT
  3. 返回值获取
    • 函数返回值保存在%eax/%rax寄存器中
    • atoi的返回值直接用作sleep的参数:从%eax传到%edi

3.3.7 栈帧管理

.cfi_startproc
endbr64
pushq %rbp # 保存调用者的基址指针
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp # 设置新的基址指针
.cfi_def_cfa_register 6
subq $32, %rsp # 分配32字节栈空间

  1. 函数序言
    • pushq %rbp:保存前一个函数的栈基址
    • movq %rsp, %rbp:设置新的栈基址
    • subq $32, %rsp:为局部变量分配栈空间
  2. 函数尾声
    • leave:恢复栈(相当于movq %rbp, %rsp和popq %rbp)
    • ret:返回调用者
  3. 调试信息
    • .cfi_开头的指令是调试信息,用于异常处理和栈回溯

3.3.8 系统API调用

call printf@PLT
call sleep@PLT
call exit@PLT
call atoi@PLT
call getchar@PLT

  1. 库函数调用
    • 所有标准库函数通过PLT间接调用
    • PLT允许动态链接,程序加载时解析实际地址
  2. 系统调用处理
    • 高级API(如sleep、printf)由C库包装底层系统调用
    • 调用约定遵循x86-64 System V ABI

3.3.9 返回值处理

call getchar@PLT # 调用getchar()
movl $0, %eax # 设置返回值为0
leave
.cfi_def_cfa 7, 8
ret

  1. 返回值设置
    • 通过movl $0, %eax设置返回值为0
    • 整型返回值存放在%eax寄存器
  2. 函数退出
    • leave清理栈帧
    • ret返回调用者,隐含弹出返回地址

通过以上分析,可以看出编译器如何将C语言的高级结构转换为汇编指令序列,实现相同的功能但直接操作CPU寄存器和内存。

3.4 本章小结

实验了C语言程序编译而成的汇编代码。

(第3章2分)

第4章 汇编

4.1 汇编的概念与作用

汇编是将汇编代码(.s 文件)转换为目标文件(.o 文件)的过程,这一过程由汇编器(Assembler)完成,最终生成机器语言的二进制指令。

作用:

将汇编代码转换为机器码,使 CPU 能够理解并执行指令。

优化指令,根据 CPU 架构调整代码,提高执行效率。

生成独立的目标文件,用于后续链接。

汇编器会解析 .s 文件中的指令,并转换为相应的机器代码,写入 .o 文件。

4.2 在Ubuntu下汇编的命令

gcc -c Hello.s -o Hello.o

4.3 可重定位目标elf格式

4.4 Hello.o的结果解析

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

objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。

通过比较 disass_hello.s(反汇编代码)和 Hello.s(GCC编译的汇编代码),可以发现两者描述的是相同程序,但表示方式和包含的信息有明显区别。

主要区别

1. 格式和表示方式

  1. 反汇编代码
    1. 包含指令的机器码和内存地址偏移量
    2. 使用绝对地址表示跳转目标
    3. 包含重定位信息(如R_X86_64_PLT32等)
  2. GCC汇编代码
    1. 使用符号标签(如.L2, .L3)表示跳转目标
    2. 包含汇编器伪指令和节定义(.text, .rodata等)
    3. 包含调试信息(.cfi_开头的指令)

关键功能对比

功能

反汇编代码

GCC汇编代码

栈帧设置

push %rbp; mov %rsp,%rbp; sub $0x20,%rsp

pushq %rbp; movq %rsp, %rbp; subq $32, %rsp

参数检查

cmpl $0x5,-0x14(%rbp); je 32 <main+0x32>

cmpl $5, -20(%rbp); je .L2

错误处理

使用重定位引用库函数:call puts和call exit

使用PLT引用:call puts@PLT和call exit@PLT

循环实现

使用硬编码偏移地址

使用符号标签(.L3, .L4)

代码功能分析

两段代码都实现了相同的C程序功能: 1. 检查命令行参数数量(必须是5个) 2. 如果参数不够,打印错误信息并退出 3. 初始化循环计数器i=0 4. 循环10次,每次: - 打印“Hello 学号 姓名 手机号” - 调用sleep休眠指定秒数 5. 调用getchar等待用户输入 6. 返回0

特别说明

  • 反汇编代码中的0:, 4:, 8:等是指令在内存中的偏移地址
  • 反汇编代码中的R_X86_64_PC32、R_X86_64_PLT32是重定位信息,用于链接器处理外部符号引用
  • GCC汇编代码中的.cfi_指令是调用帧信息,用于异常处理和调试
  • 字符串常量在GCC汇编中直接定义(如.LC0、.LC1),而在反汇编代码中只有引用

总的来说,两个文件表示的是同一个程序,只是一个是从已编译的二进制文件反向生成的,另一个是编译器直接生成的汇编代码,因此在表示方式和包含的元信息上有所不同。

说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。

机器语言的基本构成

机器语言是计算机能直接识别和执行的二进制代码,在x86-64架构下由以下部分组成:

  1. 前缀(Prefix) - 可选,修改指令行为(如REX前缀48表示64位操作)
  2. 操作码(Opcode) - 指定要执行的操作
  3. ModR/M字节 - 指定操作数的寻址模式和寄存器
  4. SIB字节 - 针对复杂内存寻址的缩放-索引-基址
  5. 位移量(Displacement) - 内存引用的偏移量
  6. 立即数(Immediate) - 常量值

汇编语言与机器语言映射差异

1. 操作数表示差异

汇编语言

机器语言

示例

寄存器名称(%rax)

编码在ModR/M字节中

48 89 c7 → mov %rax,%rdi

立即数($0x1)

直接以字节序列存储

bf 01 00 00 00 → mov $0x1,%edi

内存寻址(-0x14(%rbp))

ModR/M字节+位移量

89 7d ec → mov %edi,-0x14(%rbp)

2. 分支转移(跳转)指令差异

在disass_hello.s中,跳转使用相对偏移表示:

17: 74 19 je 32 <main+0x32>

其中: - 74 是je操作码 - 19 是相对偏移量(如果相等则跳转到当前位置+0x19字节)

而在Hello.s中,使用标签表示:

cmpl $5, -20(%rbp)
je .L2

3. 函数调用差异

在反汇编代码中,函数调用包含重定位信息:

23: e8 00 00 00 00 call 28 <main+0x28>
24: R_X86_64_PLT32 puts-0x4

其中: - e8 是call指令操作码 - 00 00 00 00 是占位符,链接时会替换为实际偏移量 - R_X86_64_PLT32 puts-0x4 是重定位记录,指示链接器填充正确的地址

而在GCC生成的汇编中,直接使用符号引用:

call puts@PLT

重定位与链接

在机器码中,以下结构处理外部符号引用:

19: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax
1c: R_X86_64_PC32 .rodata-0x4

这表示在指令偏移量0x1c处有一个重定位条目,类型为R_X86_64_PC32,链接时将计算.rodata节的相对地址减4并填充到这个位置。

指令编码示例分析

  1. 栈帧设置:
  • 4: 55 push %rbp
    5: 48 89 e5 mov %rsp,%rbp
    8: 48 83 ec 20 sub $0x20,%rsp
    • 55: 单字节操作码,表示push rbp
    • 48 89 e5: 64位mov指令,rsp→rbp
    • 48 83 ec 20: 64位sub指令,从rsp减去0x20(32)
  1. 分支判断: 13: 83 7d ec 05 cmpl $0x5,-0x14(%rbp) 17: 74 19 je 32 <main+0x32>tt
    • 83 7d ec 05: 比较内存与立即数
    • 74 19: 如果相等,跳转偏移0x19字节
  2. 循环控制:
  • 91: 83 7d fc 09 cmpl $0x9,-0x4(%rbp)
    95: 7e a4 jle 3b <main+0x3b>
    • 检查计数器是否<=9
    • 7e a4: 如果小于等于,向后跳转0xa4字节(负偏移,表示循环回到前面的代码)

总结

机器语言与汇编语言之间的映射不完全是一对一的关系:

- 机器语言使用数值偏移量处理控制流

- 汇编语言使用符号和标签提供更好的可读性

- 重定位信息使机器码能在链接时正确处理外部引用

- 相同功能的代码在不同表示中可能有不同的指令序列和寻址方式

这些差异正是编译、汇编和链接过程需要解决的问题,最终将高级语言转换为计算机可执行的机器码。

4.5 本章小结

实验了汇编过程。

(第4章1分)

第5章 链接

5.1 链接的概念与作用

是编译过程的最后一个步骤,它负责将编译后的目标文件(.o)与库文件(如 libc.so.6 或 libm.so)结合在一起,最终生成可执行文件。

作用:

将多个 .o 文件合并,使不同编译单元组成完整的程序。

解析符号表,找到外部函数(如 printf)的地址,并正确关联。

如果使用 静态库(.a),则代码在编译时会被嵌入到可执行文件中。

如果使用 动态库(.so),则程序运行时才会加载库文件,提高灵活性。

5.2 在Ubuntu下链接的命令

gcc -c hello.c backdoor.c

ld -o hello hello.o backdoor.o -lc

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

分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。

5.4 hello的虚拟地址空间

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

5.5 链接的重定位过程分析

objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。

结合hello.o的重定位项目,分析hello中对其怎么重定位的。

通过比较Hello.s(GCC直接生成的汇编代码)和disass_hello.s(反汇编得到的汇编代码),可以详细了解链接过程中发生的变化。

关键差异分析

1. 符号解析与地址分配

Hello.s:

je .L2
jmp .L3

disass_hello.s:

1200: 74 19 je 121b <main+0x32>
1222: eb 56 jmp 127a <main+0x91>

链接过程: 链接器将符号标签(.L2, .L3)转换为确切的内存地址(0x121b, 0x127a),所有跳转指令都被更新为指向这些实际地址。

2. 外部函数调用处理

Hello.s:

call puts@PLT
call exit@PLT

disass_hello.s:

120c: e8 8f fe ff ff call 10a0 <puts@plt>
1216: e8 c5 fe ff ff call 10e0 <exit@plt>

链接过程: 链接器将@PLT符号引用转换为实际的偏移地址。注意e8 8f fe ff ff是一个相对调用指令,其中8f fe ff ff是相对当前指令指针的偏移量(负值,向后跳)。

3. 数据段引用解析

Hello.s:

leaq .LC0(%rip), %rax

disass_hello.s:

1202: 48 8d 05 ff 0d 00 00 lea 0xdff(%rip),%rax

链接过程: 链接器计算出字符串常量.LC0相对于指令指针的确切偏移量(0xdff),取代了符号引用。

4. 目标文件合并

Hello.s: 不包含backdoor函数

disass_hello.s: 包含backdoor函数实现:

000000000000128c <backdoor>:
128c: f3 0f 1e fa endbr64
...
129a: c3 ret

链接过程: 链接器将多个目标文件(来自hello.c和backdoor.c)合并成一个可执行文件,按顺序安排各函数的地址空间。

链接过程详解

链接是将编译后生成的多个目标文件转换为一个可执行文件的过程,主要包括以下步骤:

1. 空间分配

链接器首先确定每个输入目标文件中各段(代码段、数据段等)的大小,然后为合并后的段分配地址空间。

  1. 示例: disass_hello.s中<main>位于地址0x11e9,而<backdoor>位于0x128c,表明链接器已为每个函数分配了连续的地址空间。

2. 符号解析

链接器解析每个目标文件中的符号引用,查找其定义并建立符号表。

  1. Hello.s中: 使用符号名(如.L2、puts@PLT)
  2. 链接后: 这些符号被解析为实际内存地址(如0x121b、0x10a0)

3. 重定位

链接器修改代码和数据中的地址引用,使其指向符号的最终内存位置。

  1. 局部重定位示例:
  • Hello.s: jle .L4
    disass_hello.s: 127e: 7e a4 jle 1224 <main+0x3b>
  1. 外部符号重定位示例:
  • Hello.s: call printf@PLT
    disass_hello.s: 1257: e8 54 fe ff ff call 10b0 <printf@plt>

4. PLT和GOT构建

对于动态链接的程序,链接器创建过程链接表(PLT)和全局偏移表(GOT):

  1. PLT: 包含跳转到实际库函数的代码桩
  2. GOT: 存储动态链接库函数的实际地址
  3. 示例: disass_hello.s中的外部函数调用(如puts、printf)都通过PLT间接调用

5. 可执行文件生成

最后,链接器生成包含所有段、符号表、重定位信息和动态链接信息的可执行文件。

总结

通过比较Hello.s和disass_hello.s,我们可以清楚地看到链接过程的效果:

  • 符号名被转换为实际内存地址
  • 外部引用通过PLT/GOT机制解析
  • 相对地址引用被计算为确切的偏移量
  • 多个目标文件被合并到一起(如添加了backdoor函数)
  • 伪指令和调试信息(如.cfi_指令)被处理或移除

5.6 hello的执行流程

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

程序从加载到终止,经历了以下核心阶段和函数调用:

1. 加载阶段

  1. 操作系统将可执行文件加载到内存
  2. 动态链接器(ld.so)解析程序依赖的共享库
  3. 建立GOT(Global Offset Table)PLT(Procedure Linkage Table)

2. 初始化阶段

  1. 程序入口点:_start(由链接器设置)
  2. _start → 调用 __libc_start_main
  3. __libc_start_main 设置运行环境(初始化libc,构建环境变量,准备参数等)

3. main函数执行

main开始按顺序调用:

main: # 地址:0x00005555555551e9
├── 参数检查判断
│ ├── puts@PLT # 地址:0x5555555550a0
│ │ └── __GI__IO_puts
│ └── exit@PLT # 地址:0x5555555550e0
├── 循环10次
│ ├── printf@PLT # 地址:0x5555555550b0
│ ├── atoi@PLT # 地址:0x5555555550d0
│ └── sleep@PLT # 地址:0x5555555550f0
└── getchar@PLT # 地址:0x5555555550c0

4. 终止阶段

  1. main 返回值传递给 __libc_start_main
  2. __libc_start_main 调用 exit 处理终止
  3. exit 执行清理操作:
    1. 调用通过atexit注册的函数
    2. 刷新并关闭所有打开的流
    3. 移除临时文件
  4. 最终调用 _exit 系统调用终止进程

关键PLT/GOT解析过程

5.7 Hello的动态链接分析

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

运行前:

运行后:



call put后:

动态链接是现代操作系统中程序加载和执行的重要机制,本文将分析Hello程序中的动态链接过程以及链接前后的变化。

动态链接基础结构

Hello程序中涉及以下主要的动态链接结构:

  1. PLT (Procedure Linkage Table):用于跳转到实际的库函数
  2. GOT (Global Offset Table):存储外部函数的实际地址

动态链接项目及其变化

1. puts@PLT (0x5555555550a0)

链接前状态: - GOT表项包含PLT中跳回指令的地址 - PLT代码将控制权交给动态链接器

# PLT 入口 (链接前)
puts@plt:
0x5555555550a0: jmp *0x2f4a(%rip) # 指向GOT表项
0x5555555550a6: push $0x0 # 重定位索引
0x5555555550ab: jmp 0x555555555080 # 跳转到PLT0(动态链接器入口)

链接后状态: - GOT表项更新为指向__GI__IO_puts实际地址(0x7ffff7e0be50) - 后续调用直接跳转到该地址

当main+35处调用puts@PLT时,第一次会触发完整链接过程:

main+35 -> puts@PLT -> 动态链接器 -> __GI__IO_puts(0x7ffff7e0be50)

2. printf@PLT (0x5555555550b0)

从汇编代码可见,程序在循环中调用printf:

0x0000555555555257 <+110>: call 0x5555555550b0 <printf@PLT>

链接过程变化: - 首次调用:PLT -> 动态链接器 -> 真实printf函数 - 后续调用:PLT -> 直接通过GOT跳转到真实函数

3. 其它动态链接函数

同样的链接过程也适用于: - atoi@PLT (0x5555555550d0) - sleep@PLT (0x5555555550f0) - getchar@PLT (0x5555555550c0) - exit@PLT (0x5555555550e0)

验证动态链接变化的方法

使用GDB可以观察动态链接前后GOT表项的变化:

# 查看GOT表项(链接前)
(gdb) x/gx 0x555555557ff0 # puts的GOT表项
0x555555557ff0: 0x00005555555550a6 # 指向PLT中的下一条指令

# 执行puts后再查看GOT表项(链接后)
(gdb) x/gx 0x555555557ff0
0x555555557ff0: 0x00007ffff7e0be50 # 已更新为__GI__IO_puts的实际地址

动态链接过程详解

  1. 第一次调用
    • 控制权从main转到puts@PLT
    • PLT代码检查GOT表项,发现未解析
    • 将重定位索引推入栈中
    • 跳转到动态链接器入口
    • 动态链接器解析函数符号
    • 更新GOT表项为实际函数地址
    • 跳转到实际函数执行
  2. 后续调用
    • 控制权从main转到puts@PLT
    • PLT代码检查GOT表项,发现已解析
    • 直接跳转到GOT表项指向的实际函数地址

这种延迟绑定机制避免了程序启动时解析所有符号的开销,提高了程序的加载速度。只有当函数第一次被调用时,才会进行实际的符号解析和地址绑定。

 

5.8 本章小结

实验了链接的过程。

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

(第5章1分)

第6章 hello进程管理

6.1 进程的概念与作用

进程(Process)是程序在计算机中的一次执行活动,是操作系统分配资源的基本单位。它不仅包含程序的代码(指令集合),还包括当前活动所需的资源集合,如打开的文件、挂起的信号、内核数据结构、处理器状态、内存地址空间及一个或多个执行线程等。

从技术角度看,进程是由PCB(Process Control Block,进程控制块)、程序段和数据段三部分组成的独立运行的活动实体。

进程的作用

资源隔离:实现不同应用程序间的资源隔离,提高系统安全性和稳定性

并发执行:实现多任务并发执行,充分利用处理器资源

内存保护:为每个进程提供独立的地址空间,防止相互干扰

交互能力:通过进程间通信机制实现数据交换和协作

提高系统吞吐量:合理调度进程,最大化系统资源利用率

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

Shell是一个命令行解释器,作为用户与操作系统内核之间的接口层。Bash(Bourne Again SHell)是Linux系统中最常用的Shell实现。

Shell的主要作用: - 命令解释:接收用户输入的命令并解释执行 - 程序执行:启动其他程序(如hello) - 环境管理:维护环境变量 - 管道与重定向:实现命令间的数据流转 - 脚本执行:作为脚本语言解释器

Shell处理命令的流程

以执行./hello 学号 姓名 12345678901 1为例:

  1. 提示符显示:显示命令提示符(如user@host:~$)
  2. 命令读取:读取用户输入的命令行
  3. 命令解析
    • 分词处理:将命令拆分为程序名和参数
    • 引号处理:处理引号内的空格和特殊字符
    • 变量替换:替换命令中的环境变量(如$HOME)
    • 通配符展开:展开*、?等通配符
    • 别名替换:替换已定义的命令别名
  4. 路径查找
    • 检查hello是否为绝对或相对路径
    • 如为相对路径,在当前目录查找可执行文件
  5. 权限检查
    • 验证文件是否有执行权限
    • 检查文件系统访问权限
  6. 创建进程
    • 调用fork()系统调用创建子进程
    • 子进程是父进程(Shell)的副本
  7. 程序加载
    • 子进程调用execve()加载hello程序
    • 传递命令行参数(学号, 姓名, 12345678901, 1)
    • 设置环境变量
  8. 等待完成
    • Shell进程调用wait()等待子进程结束
    • 对于前台进程,Shell暂时不显示提示符
  9. 信号处理
    • 处理Ctrl+C(SIGINT)等信号
    • 将信号转发给前台进程组
  10. 状态处理
    • 获取子进程退出状态
    • 更新Shell的$?变量
    • 显示新的命令提示符

作业控制

Shell提供作业控制机制: - &将程序放入后台执行(./hello 学号 姓名 12345678901 1 &) - Ctrl+Z挂起当前前台进程 - jobs命令查看后台作业 - fg将后台作业调至前台 - bg在后台继续执行挂起的作业

Bash作为命令解释器是用户与操作系统交互的主要方式,它不仅执行命令,还提供了变量、控制结构和函数等编程功能,使用户能够通过脚本自动化执行复杂任务。

6.3 Hello的fork进程创建过程

1. 命令解析

  1. Shell解析命令行./hello 学号 姓名 手机号 秒数
  2. 将其分解为程序路径和参数数组

2. Fork系统调用

  1. Shell调用fork()系统调用创建子进程
  2. 内核将进程表中创建新条目,分配唯一PID
  3. 复制父进程(Shell)的PCB(进程控制块)信息

3. 地址空间复制

  1. 使用写时复制(Copy-on-Write)机制复制地址空间
  2. 子进程获得与父进程相同的虚拟内存布局
  3. 内核创建新的页表,但页表项指向相同的物理页面
  4. 所有共享页面标记为只读

4. 资源继承

子进程从Shell继承: - 打开文件描述符表(包括标准输入/输出/错误) - 当前工作目录 - 环境变量 - 用户/组ID - 信号处理设置 - 资源限制

5. 执行状态

  1. fork()在父进程中返回子进程PID
  2. 在子进程中返回0
  3. 子进程与父进程同时运行,执行相同的代码

6. 进程关系

  1. 子进程的父进程ID(PPID)设置为Shell的PID
  2. 加入Shell的进程组
  3. 默认情况下,与Shell在同一会话中

7. 内核实现

  1. 内核创建新任务结构(task_struct)
  2. 复制虚拟内存管理结构(mm_struct)
  3. 分配新的内核栈
  4. 设置子进程的CPU寄存器状态与父进程相同

fork完成后,子进程继续执行,通常会调用execve()加载Hello程序,替换地址空间内容,开始执行新程序代码。而父进程(Shell)则通常调用wait()或waitpid()等待子进程结束。

这种设计让Shell能够方便地创建新进程,同时允许子进程完全继承父进程的执行环境,为命令行程序的执行提供了高效的机制。

6.4 Hello的execve过程

在fork创建子进程后,Shell子进程通过execve系统调用加载并执行Hello程序的过程如下:

1. execve系统调用

  1. 子进程调用execve("./hello", argv, envp)
  2. 传入程序路径、参数数组和环境变量
  3. 系统调用进入内核态

2. 程序验证

  1. 内核检查hello文件的存在性和访问权限
  2. 验证文件格式(ELF格式)
  3. 检查执行权限

3. 旧进程资源清理

  1. 释放当前进程的用户空间内存
  2. 保留进程ID、文件描述符表、信号处理等资源
  3. 取消现有的内存映射

4. ELF文件解析

  1. 读取ELF头部(ELF Header)
  2. 加载程序头表(Program Header Table)
  3. 确定内存布局和程序入口点

5. 地址空间重构

  1. 创建全新的虚拟地址空间
  2. 按照ELF文件中的段(Segments)信息构建内存映射
  3. 设置代码段(只读)、数据段(可读写)、BSS段(零初始化)
  4. 创建新的堆栈空间

6. 内存映射

  1. 将程序文件映射到内存(代码段、只读数据段)
  2. 为可写数据段分配内存并初始化
  3. 设置程序堆、栈区域
  4. 使用demand paging机制,页面实际访问时才加载

7. 动态链接

  1. 加载动态链接器(ld.so)
  2. 解析hello程序依赖的共享库(libc.so等)
  3. 加载所需库到内存
  4. 建立PLT(Procedure Linkage Table)和GOT(Global Offset Table)
  5. 解析外部符号(printf、sleep等)

8. 参数与环境准备

  1. 在用户栈上构建参数和环境变量
  2. 根据hello.c中的声明设置argc和argv
  3. 参数包括:“./hello”, “学号”, “姓名”, “手机号”, “秒数”

9. 寄存器初始化

  1. 设置程序计数器(PC)指向程序入口点(_start)
  2. 初始化栈指针(SP)
  3. 设置其他必要寄存器

10. 控制权转移

  1. 从内核态返回用户态
  2. 控制权传递给新程序的入口点
  3. 开始执行hello程序的代码

11. 程序启动

  1. 程序入口点(_start)调用C运行库初始化
  2. 初始化运行环境(__libc_start_main)
  3. 最终调用hello的main函数开始执行用户代码

这一过程将Shell子进程完全变换为执行hello程序的进程,旧程序的代码和数据被新程序替换,但进程ID、文件描述符等系统资源得以保留,实现了在相同进程上下文中执行不同程序的机制。

6.5 Hello的进程执行

当Hello程序通过execve被加载到内存并开始执行后,整个进程执行过程如下:

1. 程序初始化

    1. 控制权从操作系统转移到程序入口点_start
    2. _start调用__libc_start_main设置运行环境
    3. 初始化libc库,设置堆管理、I/O缓冲区等
    4. 注册atexit处理函数
    5. 最终调用Hello程序的main函数

2. 命令行参数解析

    1. main(argc, argv)接收参数数量和参数数组
    2. 程序检查argc值是否为5
    3. 如果参数不正确,输出使用说明并退出:
    • if(argc!=5){
      printf("Hello learnNumber name 12312341234 4!\n");
      exit(1);
      }

3. 主循环执行

    1. 程序进入for循环,重复执行10次:
    • for(i=0;i<10;i++){
      printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);
      sleep(atoi(argv[4]));
      }
    1. 每次循环执行以下操作:
      • 调用printf输出问候消息,包含三个命令行参数
      • 调用atoi将第四个参数(秒数)转换为整数
      • 调用sleep系统调用让进程休眠指定秒数

4. 进程状态转换

  1. 运行态(Running): 当CPU执行Hello进程代码时
  2. 就绪态(Ready): 当时间片用完,等待下次调度
  3. 阻塞态(Blocked/Waiting): 当执行sleep系统调用时
  4. 挂起(Suspended): 当用户按下Ctrl-Z时

5. 系统调用交互

  1. printf: 触发write系统调用,与内核交互写入标准输出
  2. sleep: 进程调用sys_nanosleep,请求内核在指定时间后唤醒
  3. getchar: 触发read系统调用,从标准输入读取一个字符
  4. 每次系统调用都涉及用户态/内核态切换

6. 信号处理

  1. 进程能接收多种信号:
    1. SIGINT (Ctrl+C): 默认终止进程
    2. SIGTSTP (Ctrl+Z): 默认挂起进程
    3. SIGCONT: 恢复被挂起的进程
  2. 进程的前台/后台状态影响信号处理

7. 等待用户输入

  1. 执行getchar()函数,进程进入阻塞状态
  2. 等待用户在终端输入字符
  3. 此阶段进程处于睡眠状态,释放CPU资源

8. 程序终止

  1. 用户输入后,main函数返回0
  2. 控制权回到C运行库
  3. 执行atexit注册的终止处理函数
  4. 关闭打开的文件描述符
  5. 通过exit系统调用终止进程
  6. 内核回收进程资源,并向父进程(Shell)发送SIGCHLD信号

整个过程展示了Hello程序作为一个进程的完整生命周期,从初始化到终止,以及与操作系统的各种交互。

6.6 hello的异常与信号处理

当Hello程序接收到信号时:

  • 信号产生
    1. 用户在终端按下Ctrl+C (SIGINT)
    2. 内核生成相应信号
  • 信号传递
    1. 内核在进程的PCB中设置信号待处理标志
    2. 进程从用户态切换到内核态时检查待处理信号
  • 信号处理
    1. SIGINT:默认终止进程
    2. SIGTSTP:默认挂起进程
    3. SIGCONT:恢复被挂起的进程

4. 程序异常处理

Hello程序可能遇到的异常情况:

  • 段错误(SIGSEGV)
    1. 如果Hello尝试访问非法内存地址
    2. 内核发送SIGSEGV信号给进程
    3. 默认行为是终止进程并生成core dump
  • 浮点异常(SIGFPE)
    1. 数学计算错误(虽然Hello不涉及)
    2. 默认也是终止进程
  • 总线错误(SIGBUS)
    1. 内存访问对齐错误
    2. 默认终止进程

5. Hello中的阻塞操作与中断

Hello程序中有两个可被中断的阻塞操作:

sleep(atoi(argv[4])); // 可被信号中断
getchar(); // 等待输入,可被信号中断

当这些函数被SIGINT或SIGTSTP中断时: - sleep()会提前返回,返回剩余睡眠时间 - getchar()会返回EOF或错误

6. 特殊情况处理

  • Ctrl+C处理
    1. 按下Ctrl+C时,终端驱动发送SIGINT
    2. Hello进程接收SIGINT后终止
    3. Shell检测到子进程终止,显示新提示符
  • Ctrl+Z处理
    1. 按下Ctrl+Z时,Hello进程被挂起
    2. 进程状态从Running变为Stopped
    3. Shell通知用户进程已暂停,显示作业号
    4. 可通过fg命令恢复进程
  • 僵尸进程处理
    1. 如果Shell未等待Hello进程退出状态
    2. Hello变成僵尸进程直到Shell读取退出状态

操作系统提供的默认机制确保了程序能够正确响应各种中断和异常情况,维持系统的稳定性和用户交互的灵活性。

6.7本章小结

实验了信号部分。

(第6章2分)

第7章 hello的存储管理

7.1 hello的存储器地址空间

通过分析Hello程序的执行过程,可以清晰地解释四种不同的地址概念以及它们之间的转换关系。

1. 逻辑地址

逻辑地址是程序员编写程序时使用的地址,通常以段选择符和偏移量表示。

在Hello程序中的体现: - 源代码中的变量引用(如argv[1]) - 函数调用(如puts、printf) - 代码中的标签和跳转(如汇编中的.L2、.L3、.L4)

// hello.c中的逻辑地址引用
printf("Hello %s %s %s\n", argv[1], argv[2], argv[3]);

2. 线性地址

线性地址是经过段转换后的地址,是分段机制的结果。

在hello.s中体现为:

// 段选择符+偏移量转换为线性地址
leaq .LC0(%rip), %rax // 相对寻址形式
movq %rax, %rdi // 传递给函数的参数

这里的.LC0(%rip)是一个相对寻址形式,用于访问常量字符串,RIP是指令指针寄存器。

3. 虚拟地址

虚拟地址是进程的地址空间视图,在现代64位系统中通常与线性地址等同。

在GDB调试信息中体现:

main:
0x00005555555551e9 <+0>: endbr64
...
call 0x5555555550a0 <puts@plt>

这个0x00005555555551e9就是main函数的虚拟地址,而0x5555555550a0是puts@plt的虚拟地址。

4. 物理地址

物理地址是内存条上实际的地址位置,由MMU通过页表转换而来。

虽然我们在用户程序中看不到物理地址,但在动态链接过程中可以观察到地址转换:

  1. 程序调用puts@plt (0x5555555550a0)
  2. 初始时GOT表项指向PLT下一条指令
  3. 动态链接器解析后,GOT表项更新为__GI__IO_puts (0x7ffff7e0be50)
  4. 当CPU访问这个地址时,MMU通过页表将其转换为物理地址

地址转换过程示例

以调用puts函数为例,hello程序中的完整地址转换过程:

逻辑地址: puts(字符串参数)
↓ [编译时解析]
线性地址: call puts@PLT (call 0x5555555550a0)
↓ [运行时解析]
虚拟地址: 0x7ffff7e0be50 (__GI__IO_puts的入口点)
↓ [MMU页表转换]
物理地址: 实际的RAM地址(对用户不可见)

通过PLT/GOT机制,程序在运行时动态解析函数地址,并将虚拟地址转换为物理地址,从而实现了程序的执行。这种多级地址转换机制是现代操作系统内存管理的核心,使得程序可以在不知道实际物理内存位置的情况下正确执行。

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

在Intel处理器架构中,逻辑地址到线性地址的转换是通过段式管理机制完成的,其过程如下:

逻辑地址的组成

  1. 段选择符(Selector): 存储在段寄存器中(CS, DS, SS, ES, FS, GS)
  2. 偏移量(Offset): 表示在段内的相对位置

段选择符的结构(16位)

  1. 索引位(位3-15): 用于在描述符表中选择对应的段描述符
  2. 表指示位(TI,位2): 0表示GDT,1表示LDT
  3. 请求特权级(RPL,位0-1): 指定访问所需的特权级别

地址转换过程

  • 处理器根据段选择符查找对应的段描述符:
    1. 从GDTR/LDTR寄存器获取描述符表基址
    2. 计算: 描述符表基址 + (索引位 × 8)
  • 从段描述符中提取关键信息:
    1. 段基址(Base Address): 32位基地址
    2. 段界限(Limit): 段的大小限制
    3. 属性(Attributes): 访问权限、类型等
  • 计算线性地址:
  • 线性地址 = 段基址 + 偏移量
  • 保护检查:
    1. 验证偏移量是否在段界限内
    2. 检查访问权限是否符合要求
    3. 比较CPL、RPL与段DPL的特权级关系

现代系统中的应用

在现代64位系统中,通常采用平坦内存模型,所有段的基址都设为0,而段限长设为最大值(0xFFFFFFFF),使得逻辑地址与线性地址在数值上相等,但段机制仍然用于访问控制和保护。

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

在现代操作系统中,线性地址到物理地址的转换是通过页式内存管理机制实现的。以Hello程序为例,我们可以详细分析这一过程。

页式管理基本概念

页式内存管理将物理内存和虚拟内存空间划分为大小相等的页(Page),在x86-64架构中通常为4KB。Hello程序在执行时:

  1. 程序的代码、数据被加载到不连续的物理内存页中
  2. 操作系统维护页表(Page Table)记录映射关系
  3. CPU的内存管理单元(MMU)负责地址转换

x86-64架构中的地址转换过程

Hello程序在x86-64系统中使用4级页表结构进行地址转换。

转换步骤

当Hello程序访问内存(例如获取指令或操作数据)时:

  1. CPU从CR3寄存器获取PGD(Page Global Directory)基址
  2. 使用线性地址的PGD索引找到PUD(Page Upper Directory)表项
  3. 使用PUD索引找到PMD(Page Middle Directory)表项
  4. 使用PMD索引找到PTE(Page Table Entry)表项
  5. PTE包含物理页框号(Physical Page Frame Number)
  6. 物理地址 = 物理页框号 + 页内偏移

实例分析

以Hello.s中的指令为例:

leaq .LC0(%rip), %rax # 加载字符串地址

在disass_hello.s中对应:

1202: 48 8d 05 ff 0d 00 00 lea 0xdff(%rip),%rax # 2008

这条指令访问的线性地址(0x2008)转换过程: 1. MMU提取地址各部分(PGD索引、PUD索引、PMD索引、PTE索引、偏移) 2. 通过CR3寄存器找到页表 3. 四级查表得到物理页框号 4. 最终物理地址 = 物理页框号 + 0x008(页内偏移)

优化与保护机制

  1. TLB加速: 转换后的地址被缓存在TLB(Translation Lookaside Buffer)中加速后续访问
  2. 按需分页: Hello程序初始只有部分被加载到物理内存,其余部分在需要时通过缺页异常加载
  3. 页保护: 每个页表项包含权限位(R/W/X),在Hello运行时防止非法访问
  • 例如: 代码段(.text)设置为只读可执行
    数据段(.data)设置为可读写不可执行
  1. 共享页: 多个Hello实例可共享只读页(如代码段)以节省物理内存

结论

线性地址到物理地址的转换是操作系统内存管理的核心机制,通过多级页表结构实现了高效的地址空间隔离和保护。Hello程序在执行过程中,每次内存访问都会经过这一转换过程,但由于TLB的存在,大部分转换操作被缓存,使得程序能够高效运行。

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

TLB基本概念

TLB(Translation Lookaside Buffer) 是一种专用的高速缓存,用于存储最近使用的虚拟地址到物理地址的映射关系。它的主要目的是加速地址转换过程,避免每次访问内存都需要遍历多级页表。

地址转换流程

1. TLB查询阶段

当CPU生成虚拟地址时,MMU首先查询TLB:

CPU → 虚拟地址 → TLB查询
↙ ↘
TLB命中 TLB未命中
↓ ↓
直接获得物理地址 进入页表遍历

2. 四级页表遍历(TLB未命中时)

在x86-64架构中,地址转换需要遍历四级页表:

  1. 从CR3寄存器获取PGD基址
  2. PGD查询:使用VA[47:39]作为索引,找到PUD表基址
  3. PUD查询:使用VA[38:30]作为索引,找到PMD表基址
  4. PMD查询:使用VA[29:21]作为索引,找到PTE表基址
  5. PTE查询:使用VA[20:12]作为索引,找到页框号(PFN)
  6. 物理地址计算:PA = PFN + VA[11:0](页内偏移)

3. TLB更新

完成页表遍历后,系统将新的映射关系添加到TLB: - 存储<虚拟页号, 物理页框号>对 - 同时记录访问权限和其他属性位

优化机制

  1. 多级TLB:现代处理器通常有L1 TLB和L2 TLB,分别缓存指令和数据的映射
  2. 大页支持:可使用2MB或1GB的大页,减少TLB条目数量,提高命中率
  3. ASID/PCID:进程上下文标识符,允许TLB在进程切换时保留部分映射

示例:Hello程序中的地址转换

当Hello程序执行leaq .LC0(%rip), %rax指令时:

  1. 首先检查TLB是否有0x2008的映射
  2. 如果TLB未命中,通过四级页表找到物理地址
  3. 将此映射添加到TLB
  4. 后续再访问相同或邻近地址时,可直接从TLB获取物理地址

TLB的使用大幅减少了内存访问次数,从页表遍历所需的4次内存访问减少到1次,显著提高了系统性能。

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

缓存层次结构

现代处理器中通常采用三级Cache层次结构来弥补CPU与主存之间的速度差距:

  1. L1 Cache
    • 最接近CPU核心,速度最快(通常1-2个时钟周期)
    • 容量小(32-64KB)
    • 通常分为指令缓存(L1i)和数据缓存(L1d)
    • 每个CPU核心私有
  2. L2 Cache
    • 中等速度(10-20个时钟周期)
    • 中等容量(256KB-1MB)
    • 通常每个CPU核心私有,少数架构中共享
  3. L3 Cache
    • 速度较慢(40-60个时钟周期)
    • 容量大(4MB-50MB)
    • 通常所有CPU核心共享

物理内存访问流程

当处理器需要访问物理内存时:

CPU → L1 → L2 → L3 → 物理内存
↑ ↑ ↑
| | |
命中 命中 命中

  1. CPU首先检查L1 Cache
  2. 如果L1未命中,检查L2 Cache
  3. 如果L2未命中,检查L3 Cache
  4. 如果L3未命中,访问物理内存(100-300个时钟周期)
  5. 数据从内存加载后,会填充各级缓存

关键技术

1. 缓存组织方式

  • 直接映射:每个内存地址只能映射到缓存中的一个位置
  • 全相联:内存地址可映射到缓存中的任意位置
  • 组相联:综合以上两种方法的折中方案(最常用)

2. 写入策略

  • 写直达(Write-through):数据同时写入缓存和内存
  • 写回(Write-back):数据仅写入缓存,被替换时才写回内存

3. 缓存一致性

  • 通过MESI协议等机制确保多核处理器间的缓存一致性
  • 监听总线上其他核心的内存操作

4. 缓存预取

  • 硬件预取:硬件自动预测并加载可能用到的数据
  • 软件预取:通过指令显式请求预取数据

性能影响

三级缓存显著提高了系统性能: - 减少平均内存访问延迟 - 缓解内存带宽瓶颈 - 降低功耗

以Hello程序为例,当执行printf函数时: 1. 指令首先从L1i缓存获取 2. 函数参数和数据从L1d缓存获取 3. 如果发生缓存未命中,才会逐级向下查找 4. 访问字符串数据时,空间局部性使相邻数据也被缓存,加速后续访问

缓存的高效使用是现代处理器性能的关键因素,通过利用程序的时间局部性和空间局部性原理,大幅提升了物理内存访问速度。

7.6 hello进程fork时的内存映射

在Linux系统中,当hello进程执行fork()系统调用创建子进程时,内存映射会经历以下特殊处理:

写时复制(Copy-on-Write)机制

fork操作采用写时复制(COW)机制来提高效率:

  1. 虚拟地址空间复制
    • 子进程获得与父进程相同的虚拟地址空间布局
    • 所有的段(代码段、数据段、堆、栈等)在虚拟地址空间中位置相同
  2. 页表处理
    • 子进程创建新的页表结构
    • 页表项指向与父进程相同的物理页面
    • 所有共享的页面标记为只读
  3. 物理内存共享
    • 初始时,子进程与父进程共享相同的物理内存页
    • 实际上只复制页表结构,而非物理内存内容

写操作触发内存复制

当hello程序的父子进程任一方尝试修改共享内存时:

  1. 触发页面错误
    • CPU检测到对只读页面的写入尝试
    • 生成页错误异常
  2. 操作系统处理
    • 内核为写入进程分配新的物理页面
    • 复制原始页面内容到新页面
    • 更新进程页表,指向新的物理页面
    • 将新页面标记为可写
  3. 继续执行
    • 重新执行触发错误的指令
    • 现在写操作可以成功完成

特殊内存区域处理

  1. 代码段:通常保持共享,因为是只读的
  2. 共享库:如hello调用的libc,在fork后继续共享物理页面
  3. 私有映射:如栈和堆,遵循COW机制
  4. 共享映射:如共享内存段,fork后保持共享状态

这种写时复制的内存映射机制大大提高了fork的效率,特别是对于像hello这样的程序,如果子进程立即执行exec加载新程序,则大部分内存页面可能根本不需要复制。

7.7 hello进程execve时的内存映射

当hello进程执行execve()系统调用加载新程序时,内存映射会发生彻底重构:

原有内存空间的处理

与fork不同,execve会完全替换进程的内存空间:

  1. 清空原有内存映射
    • 释放当前进程的所有私有内存映射
    • 解除所有共享内存段的映射
    • 销毁原有的页表结构
  2. 保留的系统资源
    • 进程ID和进程关系保持不变
    • 打开的文件描述符(除非设置了FD_CLOEXEC)
    • 信号处理配置(除非执行了特定重置)

新程序的内存映射构建

execve会为新程序创建全新的地址空间。

加载过程详解

  1. 解析ELF头
    • 读取新程序的ELF头部信息
    • 确定程序类型、入口点和内存布局
  2. 段加载
    • 按照程序头表(Program Header Table)创建内存映射
    • 将可执行文件中的代码段(TEXT)、数据段(DATA)加载到内存
    • 为BSS段分配零填充内存
  3. 动态链接
    • 加载程序依赖的共享库
    • 构建GOT和PLT表
    • 解析全局符号引用
  4. 堆栈初始化
    • 创建新的用户栈空间
    • 设置环境变量和命令行参数
    • 初始化TLS(线程本地存储)

内存映射特点

  1. 干净的地址空间
    • 新程序获得全新的虚拟地址空间
    • 不存在COW页面
  2. 延迟加载
    • 程序段通常使用demand paging(按需分页)技术
    • 初始只映射页表,实际物理页面在首次访问时才分配
  3. 共享库优化
    • 多个进程执行相同库时,库代码段在物理内存中只存一份
    • 每个进程拥有私有的库数据段拷贝

execve操作是一种完全的进程变身机制,保留了进程的外壳(PID等系统资源),但替换了内部的全部内容(代码、数据、堆栈等),使得一个进程可以完全转变为另一个程序。

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

缺页故障概述

缺页故障(Page Fault) 是当进程访问的内存页不在物理内存中时触发的异常。在虚拟内存系统中,这是一种常见且必要的机制,它使操作系统能够实现按需分页(Demand Paging)。

缺页故障的类型

  1. 主要缺页故障:请求的页面在磁盘上但不在物理内存中
  2. 次要缺页故障:页面在物理内存中但未映射到进程的页表
  3. 保护性缺页故障:页面存在但访问权限不足
  4. 无效缺页故障:访问了不合法的内存地址

缺页中断处理流程

当hello程序访问尚未加载到内存的页面时,处理过程如下:

  1. 异常触发
    • MMU检测到虚拟地址无法转换为物理地址
    • 处理器生成页错误异常,将控制权转交给内核
  2. 中断处理程序
    • 保存当前执行上下文
    • 确定缺页的虚拟地址(通过CR2寄存器)
    • 检查故障原因
  3. 页面加载
    • 为缺页分配物理页框
    • 从磁盘加载请求的页面内容
    • 更新页表,建立虚拟地址到物理地址的映射
    • 设置页面的访问权限
  4. 恢复执行
    • 恢复进程上下文
    • 重新执行触发异常的指令

缺页中断优化策略

  1. 预取技术
    • 系统预测并提前加载可能需要的页面
    • 减少缺页故障发生率
  2. 页面置换算法
    • LRU、Clock、FIFO等算法决定哪些页面被替换
    • 影响缺页处理的效率和应用性能
  3. 工作集模型
    • 维护进程活跃使用的页面集合
    • 避免频繁的页面调入调出(抖动)

在hello程序中的应用

在hello程序执行中,缺页处理扮演着重要角色:

  • 初始时程序代码和数据并非全部加载,而是按需加载
  • 当访问字符串常量(“.LC0”、“.LC1”)时可能触发缺页
  • 动态链接的库函数(如printf、sleep)首次调用时也可能触发缺页
  • 堆栈增长超过预分配范围时会触发缺页扩展

缺页中断处理机制使系统能够高效管理物理内存,在hello这样的程序和复杂应用之间合理分配资源,实现虚拟内存的透明访问。

7.9动态存储分配管理

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

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

7.10本章小结

学习了内存分配。

(第7章 2分)

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

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

设备的模型化:文件

设备管理:unix io接口

8.2 简述Unix IO接口及其函数

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

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本章小结

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

(第8章 选做 0分)

结论

用计算机系统的语言,逐条总结hello所经历的过程。

你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。

总结:

1. 程序加载与初始化

当执行./hello 学号 姓名 手机号 秒数命令时:

  1. shell进程使用fork()创建子进程,采用写时复制(COW)机制共享内存页
  2. 子进程通过execve()系统调用加载hello程序
  3. 动态链接器(ld.so)启动,解析ELF头部,加载程序段到虚拟地址空间
  4. 创建GOT/PLT表为动态链接函数准备间接跳转机制
  5. 为数据段(.data、.bss)分配内存,建立栈空间

2. 地址空间建立与转换

  1. 进程获得完整的虚拟地址空间(0x0000000000000000-0x00007FFFFFFFFFFF)
  2. 程序段被映射到特定区域:
    1. 代码段(.text):0x555555554000起始
    2. 只读数据(.rodata):包含字符串常量“.LC0”、“.LC1”
    3. 共享库:libc等在0x7FFFF7开头的地址空间
  3. 虚拟地址转物理地址经过多级翻译:
    1. 逻辑地址 → 线性地址(段转换,现代系统基本为平坦模型)
    2. 线性地址 → 物理地址(页表转换,通过CR3寄存器和四级页表)
    3. TLB缓存转换结果加速访问

3. 执行流与动态链接

  1. 控制权转交给入口点_start,初始化运行环境
  2. _start调用__libc_start_main,最终调用hello的main函数
  3. 指令执行过程中,第一次调用库函数(如puts@plt)触发动态链接:
    1. PLT代码跳转到GOT表查询函数地址
    2. 若未解析,跳转到动态链接器
    3. 链接器解析符号,更新GOT表项为实际函数地址
    4. 后续调用直接通过GOT跳转到目标函数

4. 内存访问与缓存层次

  1. 指令和数据先在L1缓存查找(~1-2周期)
  2. 未命中依次查询L2缓存(10-20周期)、L3缓存(40-60周期)
  3. 最终访问物理内存(~100-300周期)
  4. 缺页异常情况下,触发内核中断处理:
    1. MMU检测到页不存在,触发#PF异常
    2. 内核通过CR2寄存器获取故障地址
    3. 分配物理页框,从磁盘加载数据
    4. 更新页表项,重新执行失败指令

5. 程序执行主循环

  1. main函数检查参数数量(argc)是否为5
  2. 执行10次循环,每次:
    1. 调用printf输出问候信息
    2. atoi转换秒数参数
    3. 调用sleep系统调用,触发进程状态转换:Running→Sleeping
    4. 定时器到期后,进程被唤醒:Sleeping→Ready→Running
  3. 最后调用getchar等待用户输入,进入阻塞状态

6. 程序终止与资源回收

  1. main函数返回时,控制权回到__libc_start_main
  2. 执行终止处理,调用exit
  3. 关闭文件描述符,执行atexit注册的函数
  4. 通过_exit系统调用终止进程
  5. 内核回收进程资源:释放页表、物理内存和其他系统资源
  6. shell进程接收到子进程终止信号,继续执行

整个过程展示了现代计算机系统多层次的抽象和复杂的协作机制,从硬件的地址转换和缓存,到操作系统的内存管理和进程调度,再到应用程序的动态链接和执行,共同实现了hello程序的功能。

感悟:

  1. 实验时,使用了WSL和VScode,方便地进入了ubuntu环境和运行ubuntu环境下软件、指令,体验和效率远比虚拟机要高。
  2. 善用VScode支持的copilot进行带附件的询问。

(结论0分,缺失-1分)

附件

1. hello.i

预处理后的文件 - 由预处理器生成,包含了所有展开的宏和包含的头文件 - 移除了所有注释 - 包含了完整的#include文件内容(如stdio.h、unistd.h和stdlib.h) - 可以看到大量的类型定义和函数声明 - 用于检查预处理阶段的结果

2. Hello.s

汇编代码文件 - 由编译器前端生成的汇编语言代码 - 包含了函数、变量的汇编表示 - 定义了数据段(.section .rodata)和代码段(.text) - 包含了main函数及其所有指令的汇编表示 - 用于理解编译器如何将C代码转换为底层指令

3. hello.o

目标文件 - 由汇编器生成的二进制目标文件 - 包含了机器码,但尚未链接 - 包含符号表和重定位信息 - 是生成最终可执行文件前的最后一个中间产物

4. hello.elf

可执行链接格式文件 - 由链接器生成的最终可执行文件 - 包含完整的程序代码和数据段 - 所有符号引用已解析,外部函数调用已链接到对应库 - 具有明确的程序入口点(_start) - 包含程序头表(Program Header Table),描述内存布局 - 可直接被操作系统加载和执行 - 通常省略“.elf”后缀,直接命名为“hello”

5. gdb_hello: 调试信息文件,包含了程序执行时的内存地址和函数调用信息

6. disass_hello.s: 反汇编文件,通过对可执行文件进行反汇编得到,用于分析程序的机器码

(附件0分,缺失 -1分)

参考文献

[1]www.github.com

[2]www.cnblogs.com

[3]ctf-wiki.org

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

[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.

[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.

[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).

[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.

[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.

[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.

(参考文献0分,缺失 -1分)

posted @ 2025-05-25 14:21  minatomorin  阅读(27)  评论(0)    收藏  举报