程序人生-Hello‘s P2P——计算机系统大作业
计算机系统大作业
题 目 程序人生-Hello’s P2P
专 业 未来技术学院
学 号 2023113187
班 级 23WLR14
学 生 陆昱君
指 导 教 师 吴锐
计算机科学与技术学院
2025年5月
摘 要
本文以一个简单的Hello程序为研究对象,详细解析了其从源代码到最终执行的全过程。研究内容涵盖编译系统的预处理、编译、汇编和链接环节,以及操作系统层面的进程管理、存储管理和I/O管理。通过对Hello程序各个阶段产物的分析,深入剖析了计算机系统的层次结构和工作原理,展示了从高级语言到机器语言的转换过程,以及程序在计算机系统中的加载与执行机制。研究发现,即使是简单的Hello程序,也涉及计算机系统的多个方面,包括编译系统、操作系统和计算机体系结构等知识。本文的研究有助于加深对计算机系统整体工作流程的理解,对系统级程序设计和优化具有重要的理论和实践意义。
关键词:Hello程序;编译系统;进程管理;存储管理;I/O管理;Linux系统
目 录
第1章 概述
1.1 Hello简介
1.2 环境与工具
1.3 中间结果
1.4 本章小结
第2章 预处理
2.1 预处理的概念与作用
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
2.4 本章小结
第3章 编译
3.1 编译的概念与作用
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.4 本章小结
第4章 汇编
4.1 汇编的概念与作用
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
4.4 Hello.o的结果解析
4.5 本章小结
第5章 链接
5.1 链接的概念与作用
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
5.4 hello的虚拟地址空间
5.5 链接的重定位过程分析
5.6 hello的执行流程
5.7 Hello的动态链接分析
5.8 本章小结
第6章 hello进程管理
6.1 进程的概念与作用
6.2 简述壳Shell-bash的作用与处理流程
6.3 Hello的fork进程创建过程
6.4 Hello的execve过程
6.5 Hello的进程执行
6.6 hello的异常与信号处理
6.7本章小结
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.3 Hello的线性地址到物理地址的变换-页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
7.5 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
7.7 hello进程execve时的内存映射
7.8 缺页故障与缺页中断处理
7.9动态存储分配管理
7.10本章小结
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.2 简述Unix IO接口及其函数
8.3 printf的实现分析
8.4 getchar的实现分析
8.5本章小结
结论
附件
参考文献
第1章 概述
1.1 Hello简介
根据\"Hello的自白\",Hello是一个程序实体(Program),其生命历程体现了计算机系统的P2P(从Program到Process)和O2O(从Zero-0到Zero-0)过程。
从源程序(Program)开始,Hello经历了预处理、编译、汇编和链接四个阶段,最终形成可执行程序。在运行时,操作系统通过fork创建进程(Process),再通过execve加载Hello程序,使其成为一个活跃的进程。Hello程序在执行过程中,会受到操作系统的进程管理、存储管理和I/O管理的支持。
从零到零(O2O)的过程则体现在程序的整个生命周期:从源代码(零)出发,经过编译系统处理,再由操作系统管理,最终执行完毕回到零,完成整个生命周期。在这个过程中,涉及到CPU/RAM/IO等硬件上的运行,以及操作系统提供的存储管理、IO管理等支持。
1.2 环境与工具
为完成本论文的研究,使用了以下软硬件环境和工具:
- 硬件环境:Intel处理器,x86_64架构,具备多级Cache和TLB
- 操作系统:Ubuntu 20.04 桌面版 (64位)
- 编译工具:GCC 9.3.0 (gcc -m64 -no-pie -fno-PIC 编译选项)
- 调试工具:GDB 9.2,Edb-debugger
- 分析工具:objdump,readelf,nm,size,ldd
- 系统监控:ps,top,pstree,jobs,strace
- 编辑器:VSCode,VIM
1.3 中间结果
在分析Hello程序的过程中,生成了以下中间结果文件:
- hello.c:原始C语言源代码
- hello.i:预处理后的文件,包含展开的宏和头文件
- hello.s:编译后生成的汇编代码文件
- hello.o:汇编后生成的目标文件,包含机器码但尚未链接
- hello:链接后生成的可执行文件,可直接运行
1.4 本章小结
本章简要介绍了Hello程序的生命历程,涵盖了从源代码到可执行程序再到进程的全过程。这一过程体现了计算机系统的分层设计思想和各层次间的交互关系。接下来的章节将深入分析Hello程序在各个阶段的特点和变化,揭示计算机系统工作的内在机制。
第2章 预处理
2.1 预处理的概念与作用
预处理是编译过程的第一步,主要完成以下工作:
- 删除所有的注释
- 展开所有的宏定义
- 处理条件编译指令
- 插入被#include包含的文件
- 添加行号和文件名标识
- 保留所有的#pragma编译器指令
预处理的主要作用是将C源代码转换为只包含基本C语言元素的中间代码,使编译器能够专注于语法分析和代码生成。
2.2在Ubuntu下预处理的命令
在Ubuntu下,使用以下命令对hello.c进行预处理:
gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
预处理后的hello.i文件比原始的hello.c文件大得多,这是因为它包含了所有展开的头文件内容。具体分析如下:
- 头文件展开:#include 、#include 和#include 被展开成大量的声明和定义,包括标准I/O函数、系统调用接口和标准库函数。
- 宏定义处理:hello.c中虽然没有自定义宏,但是引入的头文件中包含了大量宏定义,如NULL、EOF等。
- 注释删除:hello.c中的所有注释都被删除。
- 行号信息:预处理器在文件中添加了# 行号 \"文件名\"形式的行号信息,用于编译错误定位。
2.4 本章小结
预处理阶段是编译系统的第一步,它将包含多种预处理指令的源程序转换为纯粹的C语言代码。通过分析hello.c的预处理过程,可以看出预处理器的主要工作是文本替换和文件包含,为后续的编译阶段做好准备。预处理不涉及任何语法检查或代码优化,仅对源代码进行文本级别的处理。
第3章 编译
3.1 编译的概念与作用
编译是指将预处理后的代码(.i文件)转换为汇编语言代码(.s文件)的过程。编译器在这一阶段完成以下工作:
- 词法分析:将源代码分解为词法单元(token)
- 语法分析:建立语法树
- 语义分析:类型检查和其他语义错误检查
- 中间代码生成:生成与机器无关的中间表示
- 代码优化:对中间代码进行优化
- 目标代码生成:生成特定目标机器的汇编代码
编译的主要作用是将高级语言转换为特定处理器架构的汇编语言,为后续的汇编过程做准备
3.2 在Ubuntu下编译的命令
使用以下命令将hello.i编译为hello.s:
gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.0 总体结构与约定
- 文件结构: 汇编文件以 .file 指令开始,指明原始C文件名。使用 .text 指令标识代码区的开始,.section .rodata 标识只读数据区的开始(用于存放字符串常量)。
- 函数定义: 使用 .globl main 将 main 函数声明为全局可见符号,.type main, @function 指明其类型为函数。函数体由标签 main: 开始,到 .cfi_endproc 结束。
- AT&T 语法: 操作数顺序为 源, 目的,寄存器名前有 %,立即数前有 $。内存寻址方式为 偏移量(基址寄存器, 变址寄存器, 伸缩因子)。
- x86-64 调用约定 (System V AMD64 ABI):
- 前六个整数/指针参数依次通过寄存器 %rdi, %rsi, %rdx, %rcx, %r8, %r9 传递。
- 浮点参数通过 %xmm0 - %xmm7 传递。
- 函数返回值(整数/指针)通常在 %rax 中。
- 栈是向低地址增长的。
- 调用者负责保存 %rax, %rcx, %rdx, %rsi, %rdi, %r8 - %r11 这些调用者保存寄存器。被调用者负责保存 %rbx, %rbp, %r12 - %r15 这些被调用者保存寄存器。
- 栈帧结构:
- 函数通常以 pushq %rbp 和 movq %rsp, %rbp 开始,建立新的栈帧基址 %rbp。
- 通过 subq $N, %rsp 为局部变量在栈上分配空间。
- 局部变量和保存的参数通过相对于 %rbp 的负偏移量访问。
- 函数返回前使用 leave (等价于 movq %rbp, %rsp; popq %rbp) 或相应的指令恢复调用者的栈帧,然后 ret 返回。
- CFI (Call Frame Information) 指令: 如 .cfi_startproc, .cfi_def_cfa_offset 等,这些指令不生成实际的机器码,而是给汇编器和链接器提供信息,用于生成调试信息和异常处理(栈回溯)。
3.3.1 数据类型处理
- int argc (整型参数):
- 在 main 函数入口,argc (第一个整型参数) 通过 %edi 寄存器传入 (第15行: movl %edi, -20(%rbp))。%edi 是 %rdi 的低32位。
- 它被存储在栈帧中 -20(%rbp) 的位置,占用4字节。
- char *argv[] (指针数组参数,即 char **argv):
- argv (第二个指针参数) 通过 %rsi 寄存器传入 (第16行: movq %rsi, -32(%rbp))。
- 它被存储在栈帧中 -32(%rbp) 的位置,是一个指向指针的指针,在64位系统下占用8字节。
- int i (整型局部变量):
- 在循环初始化时 (第36行: movl $0, -4(%rbp)),i 被分配在栈帧中 -4(%rbp) 的位置,占用4字节。
- 字符串常量 (String Literals):
- 如 \".LC0\", \".LC1\", \".LC2\", \".LC3\" (第5行等)。
- 这些字符串存储在 .rodata (只读数据)段中。
- 在代码中通过RIP相对寻址方式加载其地址,例如 leaq .LC0(%rip), %rdi (第19行),leaq (Load Effective Address) 计算 .LC0 的有效地址(相对于当前指令指针 %rip)并存入 %rdi。这使得代码可以是位置无关的。
- 字符串以空字符 \\0 结尾 (由 .string 伪指令自动添加)。
3.3.2 C语言操作的汇编实现
- 函数调用 (Function Calls):
- 参数传递:
- puts(char *s): 第一个参数 s (字符串地址) 通过 %rdi 传递。例如第19行 leaq .LC0(%rip), %rdi。
- exit(int status): 第一个参数 status 通过 %edi 传递。例如第21行 movl $1, %edi。
- atoi(const char *nptr): 第一个参数 nptr (字符串地址) 通过 %rdi 传递。例如第27行 movq %rax, %rdi (此时 %rax 中存的是 argv[4] 的地址)。
- printf(const char *format, ...):
- 第一个参数 format (格式字符串地址) 通过 %rdi 传递 (第52行: leaq .LC2(%rip), %rdi)。
- 后续参数 argv[1], argv[2], argv[3], i+1 按顺序通过 %rsi, %rdx, %rcx, %r8d 传递。
- argv[1] (字符串地址) -> %rsi (第51行)。
- argv[2] (字符串地址) -> %rdx (第46行)。
- argv[3] (字符串地址) -> %rcx (第43行)。
- i+1 (整型) -> %r8d (第50行,%esi 的内容先计算好 i+1,然后移入 %r8d)。
- 对于可变参数函数如 printf,如果未使用XMM寄存器传递浮点参数,ABI要求 %al (或 %eax) 设置为0 (第53行: movl $0, %eax)。
- sleep(unsigned int seconds): 第一个参数 seconds 通过 %edi 传递。例如第60行 movl %eax, %edi (此时 %eax 中存的是 atoi 的返回值)。
- getchar(): 无参数。
- 调用指令: 使用 call 函数名@PLT 指令。例如 call puts@PLT (第20行)。@PLT 后缀表示这是一个对动态链接库中函数的调用,将通过过程链接表 (PLT) 进行解析。
- 返回值: 整数或指针返回值通常在 %eax (32位) 或 %rax (64位) 寄存器中。
- atoi 的返回值在 %eax 中 (第28行调用后,第29行 testl %eax, %eax 使用)。
- getchar 的返回值在 %eax 中 (第68行调用后,第69行 movl $0, %eax 覆盖了它,因为返回值未使用)。
- main 函数的返回值 0 被放入 %eax (第69行)。
- 参数传递:
- 条件判断 (if语句):
- if(argc!=5):
- cmpl $5, -20(%rbp) (第17行): 比较存储 argc 的栈位置 -20(%rbp) 的内容和立即数 5。cmp 指令会设置条件码寄存器。
- je .L2 (第18行): 如果相等 (Zero Flag ZF=1),则跳转到 .L2 标签,跳过 if 块内的代码。否则顺序执行。
- if (atoi(argv[4]) <= 0):
- call atoi@PLT (第28行): 调用 atoi,结果在 %eax。
- testl %eax, %eax (第29行): 测试 %eax。如果 %eax 是0,ZF=1。如果 %eax 是负数,SF=1。如果 %eax 是正数,ZF=0, SF=0。
- jg .L3 (第30行): 如果结果大于0 (Jump if Greater, 即 ZF=0 且 SF=OF,或者对于 testl 后通常看符号位和零位),则跳转到 .L3。这里 jg 意味着如果 atoi 的结果是正数,则跳转。
- if(argc!=5):
- 循环 (for语句):
- for(i=0; i<10; i++)
- 初始化 i=0: movl $0, -4(%rbp) (第36行)。将局部变量 i (存储在栈上的 -4(%rbp)) 初始化为0。
- 无条件跳转到条件判断: jmp .L4 (第37行)。这是常见的编译器优化,将条件判断放在循环末尾,形成do-while类型的结构,循环体至少执行一次(如果条件一开始就满足的话)或者通过入口跳转控制。
- 循环体标签: .L5 (第38行)。
- 循环体内的操作: (第39行到第61行) 已在函数调用部分详细解析。
- 增量 i++: addl $1, -4(%rbp) (第62行)。将栈上 i 的值加1。
- 条件判断标签: .L4 (第63行)。
- 条件比较 i<10 (等价于 i<=9): cmpl $9, -4(%rbp) (第64行)。比较 i 和 9。
- 条件跳转: jle .L5 (第65行)。如果 i 小于或等于 9 (Jump if Less or Equal),则跳转回 .L5 继续执行循环体。否则,循环结束,顺序执行。
- 指针和数组访问 (argv[N]):
- argv 是一个 char ** 类型,存储在 -32(%rbp)。它指向一个 char * 数组,每个 char * 指向一个命令行参数字符串。
- 访问 argv[k] (其中 k 是索引,每个 char * 在64位系统占8字节):
- 加载 argv 的基地址到 %rax (例如第24行: movq -32(%rbp), %rax)。
- 计算 argv[k] 的地址:%rax + k * 8。例如,访问 argv[4] (秒数) 时 (第25行: addq $32, %rax,因为 4*8=32)。
- 解引用得到 argv[k] 的值 (即参数字符串的地址):movq (%rax), %rax (第26行)。
- 这个过程在多次访问 argv[1], argv[2], argv[3], argv[4] 时重复出现,用于获取相应的字符串地址作为函数参数。
- 局部变量的存储:
- int i: 存储在栈帧的 -4(%rbp)。
- argc 和 argv (函数参数) 也被复制到了栈帧的 -20(%rbp) 和 -32(%rbp)。这是一种常见的做法,即使参数是通过寄存器传递的,编译器也可能将它们保存到栈上,以便后续使用或调试。
- 函数返回 (return 0):
- movl $0, %eax (第69行): 将返回值 0 放入 %eax 寄存器。
- leave (第70行): 恢复调用者的栈帧,等价于 movq %rbp, %rsp (将栈顶指针恢复到当前帧的基址,即释放局部变量空间) 和 popq %rbp (从栈中恢复调用者的 %rbp 值)。
- ret (第71行): 从栈顶弹出返回地址,并跳转到该地址,将控制权交回给调用者。
3.3.3 其他汇编指令和伪指令
- .align 8: 确保接下来的数据或代码按8字节对齐。
- .size main, .-main: 定义 main 符号的大小,. 代表当前地址,所以 .-main 计算了从 main 标签开始到当前位置的字节数。
- .ident \"GCC: ...\": 包含编译器的标识信息。
- .section .note.GNU-stack,\"\",@progbits: 一个特殊的节,用于指示栈是否应该是可执行的。空字符串(第二个参数)通常表示栈不可执行,这是一个安全特性。
- .section .note.gnu.property,\"a\" 及其后续内容:用于描述目标文件的GNU特定属性,与程序逻辑本身关系不大,更多是ABI和二进制文件格式的元信息。
3.4 本章小结
在这一阶段,编译器 (GCC) 将经过预处理的C代码 (hello.i) 转换成了针对目标体系结构 (x86-64) 的汇编代码 (hello.s)。这个过程主要包括:
- 词法、语法、语义分析: 检查代码的正确性。
- 控制流转换: 将C语言的 if 语句、for 循环等结构转换为使用比较指令 (cmpl) 和条件/无条件跳转指令 (je, jg, jle, jmp) 的汇编逻辑。
- 数据表示与存储:
- 局部变量 (如 i) 和传入参数的副本 (如 argc, argv) 被分配在运行时栈的栈帧中,通过 %rbp 相对寻址访问。
- 字符串常量被放置在 .rodata 段中,通过RIP相对寻址加载。
- 函数调用机制:
- 严格遵循目标平台的ABI(System V AMD64 ABI)进行参数传递(通过寄存器 %rdi, %rsi, %rdx, %rcx, %r8, %r9 和栈)和返回值处理(通过 %rax)。
- 使用 call 指令调用函数,并通过 @PLT 后缀标记了对动态链接库函数的引用。
- 栈帧管理: 使用 %rbp 作为帧指针,通过 pushq %rbp, movq %rsp, %rbp, subq, leave, ret 等指令来创建、使用和销毁栈帧。
- 代码优化: 即使在 -Og (优化主要为调试) 级别下,编译器也可能进行一些简单的优化,例如将只输出字符串的 printf 优化为 puts。
通过分析 hello.s,我们可以清晰地看到C语言的高级构造是如何映射到底层汇编指令的,为后续理解机器码生成和链接过程打下了基础。
第4章 汇编
4.1 汇编的概念与作用
概念: 汇编是将汇编语言程序 (.s 文件) 翻译成机器语言指令的过程。汇编器 (assembler) 逐条读取汇编指令,并将其转换为等价的二进制机器码。
作用:
- 生成机器可以直接执行的指令。
- 将符号名(如标签、变量名,尽管在 .s 文件中变量名已不多见,主要是标签和外部符号)与内存地址(通常是相对地址或需要重定位的地址)关联起来。
- 将这些机器指令和相关数据打包成一种标准格式的可重定位目标文件(在Linux中通常是ELF格式),该文件包含了足够的信息供链接器后续处理。
- 这个阶段不解决外部符号引用(比如对 printf 等库函数的调用),这些将由链接器处理。
4.2 在Ubuntu下汇编的命令
使用以下命令对hello.s进行汇编
4.3 可重定位目标elf格式
使用readelf命令可以查看hello.o的ELF格式信息:
readelf -a hello.o
结果可知:
ELF文件由以下几个主要部分组成:
- ELF头(ELF Header):包含文件类型、机器类型、入口点等基本信息
- 程序头表(Program Header Table):用于可执行文件的加载
- 节区(Sections):包含代码、数据、符号表等实际内容
- 节区头表(Section Header Table):描述各节区的信息
以下是hello.o的主要节区:
- .text:包含程序的机器代码
- .data:包含已初始化的全局和静态变量
- .bss:包含未初始化的全局和静态变量
- .rodata:包含只读数据,如字符串常量
- .symtab:符号表,包含函数和全局变量的信息
- .rel.text:包含代码中需要重定位的地方
- .strtab:字符串表,保存所有符号的名称
重定位项目分析
使用readelf -r hello.o命令可以查看重定位项:
这些重定位项表示在链接阶段需要修正的地址引用,主要是对外部函数(printf、exit、sleep、getchar)的调用。
4.4 Hello.o的结果解析
使用objdump命令可以对hello.o进行反汇编分析:
objdump -d -r hello.o
反汇编结果展示了hello.o中的机器码和对应的汇编指令。与hello.s文件对比,可以发现以下关键差异和映射关系
机器码表示:每条汇编指令都被转换为对应的机器码(二进制指令的十六进制表示)。
例如:
地址表示:hello.s中使用的标签在hello.o中被转换为相对偏移量。例如,hello.s中的跳转指令:
函数调用:hello.s中的函数调用如call printf@PLT在hello.o中表示为:
这里的0表示目标地址暂时未知,需要在链接时填充。R_X86_64_PC32是重定位类型,表示这里需要填入printf函数相对于当前位置的32位偏移量。
操作数差异:
立即数:汇编中的立即数在机器码中直接编码
寄存器:寄存器在机器码中使用特定的编码表示
内存引用:如-4(%rbp)在机器码中被编码为基址寄存器加偏移量的形式
条件分支:汇编中的条件分支指令如je .L2在机器码中编码为:
特别需要注意的是,hello.o中的分支转移和函数调用指令的操作数与汇编代码不同:
-
- 汇编代码中使用标签或函数名作为跳转目标
- 机器码中使用相对偏移量(相对PC的偏移)
- 对于外部函数调用,机器码中先用0占位,并标记需要重定位
这种差异反映了汇编语言作为人类可读的中间表示与实际机器执行的二进制代码之间的映射关系。
4.5 本章小结
汇编阶段是将汇编语言代码转换为机器码的过程,生成可重定位目标文件。通过分析hello.o,我们了解了汇编器如何将汇编指令翻译为二进制机器指令,如何处理标签和跳转,以及如何生成重定位信息。汇编生成的目标文件保留了符号信息和重定位记录,这些信息将在链接阶段用于解析符号引用和地址重定位。ELF格式的目标文件组织了代码、数据和元数据,为后续的链接过程提供了必要的信息。汇编阶段体现了计算机系统中抽象层次转换的重要一环,将人类可读的汇编代码转换为CPU可执行的机器指令,同时保留了必要的信息以支持模块化程序的链接。
第5章 链接
5.1 链接的概念与作用
链接是将一个或多个目标文件(.o文件)组合成一个可执行文件或共享库的过程。链接器的主要工作包括:
- 符号解析:将每个符号引用(如函数调用、变量访问)与其定义关联起来,解决模块间的相互依赖关系
- 重定位:调整代码和数据的地址,使各个部分能够正确地组合在一起
- 合并段:将相同类型的段(如.text、.data)合并为一个连续的段
- 处理库文件:从静态库或共享库中提取所需的代码和数据
- 生成程序头:创建可执行文件的程序头表,指导操作系统如何加载程序
链接的主要作用是支持模块化程序设计,使程序员可以将大型程序分解为小的、可管理的模块,独立编译后再组合成完整的程序。链接过程将这些独立的部分组合成一个整体,解决它们之间的引用关系,并为程序的加载和执行做好准备。
5.2 在Ubuntu下链接的命令
使用ld命令进行链接需要指定多个文件,包括运行时启动代码、目标文件和库文件:
ld -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 \\
-lc \\
/usr/lib/x86_64-linux-gnu/crtn.o \\
-o hello
这个命令包含以下组件:
-
- 动态链接器:/lib64/ld-linux-x86-64.so.2是程序运行时的动态链接器
- 启动代码:crt1.o包含程序入口点_start,crti.o和crtn.o提供初始化和终结代码
- 用户代码:hello.o是我们的目标文件
- 标准库:-lc链接C标准库
5.输出文件:-o hello指定输出文件名为hello
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
可执行目标文件 hello 的格式分析
通过 readelf -a hello 命令的输出,我们可以清晰地洞察 hello 可执行文件的ELF结构。首先,ELF头部(ELF Header)指明了这是一个64位的Linux可执行程序(Class: ELF64, Type: EXEC),其目标架构为x86-64,程序执行的入口点虚拟地址为 0x4010f0。
文件内部由多个关键节区(Sections)组成:
.text (代码段):起始地址 0x4010f0,大小为405字节 (0x195),存放程序的主要机器指令,具有可执行权限。
.rodata (只读数据段):起始地址 0x402000,大小为143字节 (0x8f),用于存储常量字符串等只读数据。
.data (已初始化数据段):起始地址 0x404048,大小为4字节 (0x4),存放已初始化的全局变量和静态变量。
(.bss 段未在文件中占据实际空间,但符号表显示其在内存中从 0x40404c 开始,大小约4字节,用于存放未初始化的全局变量。)
· .interp:指定了程序解释器(动态链接器)为 /lib64/ld-linux-x86-64.so.2。
· .dynamic:记录了程序的动态链接信息,如依赖的共享库(例如 libc.so.6)。
· .got / .plt:全局偏移表和过程链接表,用于实现对共享库函数的调用。
· .rela.dyn / .rela.plt:包含重定位信息,指导动态链接器在加载时修正地址。
这些节区和头部信息共同定义了 hello 程序在磁盘上的存储方式以及加载到内存中执行时的映射关系和行为。
5.4 hello的虚拟地址空间
使用GDB加载hello程序可以查看其虚拟地址空间:
gdb hello
(gdb) info files
GDB输出显示hello被加载到虚拟内存中的情况:
通过GDB的 info files 命令,我们观察到 hello 程序的入口点为 0x4010f0,这与 readelf 从ELF头部读取到的信息完全一致。进一步分析,GDB显示的各主要节区的内存加载地址也与 readelf 中节区头部的记录相符:
- .interp 节区在GDB中加载于 0x4002e0 到 0x4002fc,其大小为28字节,与 readelf 报告的地址 0x4002e0 和大小 0x1c (28字节) 一致。
- 代码核心所在的 .text 节区,在 readelf 中记录的起始地址为 0x4010f0,大小为405字节。
- 只读数据段 .rodata,readelf 显示其地址为 0x402000,大小143字节。GDB中其加载范围为 [补充GDB中.rodata的范围,并比较]。
- 可读写数据段 .data,readelf 显示其地址为 0x404048,大小4字节。
- (如果GDB显示了 .bss)未初始化数据段 .bss 在GDB中位于 [补充GDB中.bss的范围],这些节区的内存布局符合 readelf 中程序头部表(Program Headers)里 LOAD 段的指示。例如,包含 .text 和 .rodata 的节区被加载到从虚拟地址开始的、具有可读和可执行权限的内存段中。而包含 .data 和 .bss 的节区则被加载到从虚拟地址开始的、具有可读写权限的内存段中。
这种对照清晰地展示了ELF文件结构如何指导操作系统将程序有效地映射到虚拟内存空间以供执行。”
5.5 链接的重定位过程分析
比较hello.o和hello的反汇编结果:
objdump -d -r hello.o
objdump -d hello
链接器的工作就是把一堆各自编译好的 .o 文件(它们内部的地址都是相对的,对外部的引用都是悬而未决的)和库文件“粘合”起来。
- 首先,它把所有 .o 文件中相同的节区(比如所有的 .text 合并成一个大的 .text,所有的 .data 合并成一个大的 .data)收集起来。
- 然后,它给这些合并后的节区分配最终的虚拟内存地址。
- 最关键的一步就是重定位:它检查每个 .o 文件中的重定位条目。这些条目就像是“待办事项列表”,告诉链接器:“嘿,我这里有个 call 指令,它想调用 printf,但我不知道 printf 在哪,你帮我把地址填对。”
- 对于程序内部的函数调用或变量访问,链接器可以直接计算出绝对地址或PC相对地址填进去。
- 对于外部共享库的函数调用(如 printf),链接器会生成PLT和GOT的结构。它把 call printf 改成 call printf@plt,然后在 .rela.plt 中留下一个“任务”给动态链接器,让动态链接器在程序运行时把 printf 的真正地址填到 .got.plt 的相应位置。这样,第一次调用 printf 时会触发动态链接器去查找并填充地址,以后再调用就直接跳转了。
- 对于外部共享库的全局变量,也是通过GOT来间接访问,其地址同样由动态链接器在运行时填充。
通过 objdump -d -r hello 的输出,我们看到的是链接器已经处理(或为动态链接器准备好处理)了这些重定位之后的结果:代码中的地址引用(如 callq *GOT地址 或 callq PLT地址)都指向了PLT或GOT中的条目,或者已经是程序内部的绝对/相对地址。而 hello.o 的反汇编(如果提供)则会更清晰地显示出那些在链接前“悬而未决”的地址和符号引用,以及它们对应的重定位类型。
5.6 hello的执行流程
使用GDB跟踪hello程序的执行流程:
gdb hello
set args 2023113187 陆昱君 13390859635 0
(gdb) break _start
(gdb) break main
(gdb) run
hello 程序的执行流程是:
- 启动: 操作系统加载 hello 后,从入口点 _start (0x1100) 开始执行。
- 初始化: _start 会调用C库的 __libc_start_main 函数,后者负责设置好C程序的运行环境,并调用 main 函数。
- 核心逻辑: main 函数 (0x401125) 执行我们编写的程序逻辑,期间可能会调用 printf、puts 等库函数(这些调用会通过PLT进行)。
- 终止: main 函数返回后,__libc_start_main 会调用 exit 函数,exit 函数完成清理工作并最终通过系统调用结束进程。
关键函数调用和跳转:
- _start → libc_start_main
- libc_start_main → libc_csu_init
- libc_start_main → main
- main → printf@plt → printf
- main → sleep@plt → sleep
- main → getchar@plt → getchar
- main返回 → exit
- exit → exit系统调用
5.7 Hello的动态链接分析
hello程序使用动态链接,其外部函数引用通过PLT(过程链接表)和GOT(全局偏移表)解析。使用ldd命令查看动态库依赖:
ldd hello
输出显示hello依赖以下动态库:
使用GDB观察动态链接过程:
gdb hello
(gdb) break main
(gdb) run 2023113187 陆昱君 13390859635 0
(gdb) x/3i 0x00005555555551e9 # printf@plt地址
动态链接的工作原理:
- 延迟绑定:
- 程序开始运行时,GOT中的地址指向PLT中的下一条指令
- 第一次调用函数时,跳转到PLT中的解析代码
- 首次调用过程:
- 程序调用printf@plt
- PLT代码跳转到GOT中的地址(初始指向PLT中的解析代码)
- 解析代码将函数索引压栈,跳转到动态链接器
- 动态链接器查找printf的实际地址
- 更新GOT表项指向printf的实际地址
- 跳转到printf执行
- 后续调用:
- 程序再次调用printf@plt
- PLT代码跳转到GOT中的地址(现在指向printf的实际地址)
- 直接执行printf,不再需要解析
5.8 本章小结
链接是将独立编译的目标文件组合成可执行程序的过程。通过分析hello程序的链接过程,我们了解了链接器如何解析符号引用,如何进行地址重定位,以及如何生成可执行文件。
静态链接将所有代码和数据合并到一个文件中,而动态链接则在运行时解析函数引用,提高了代码共享和内存利用效率。PLT和GOT机制支持了延迟绑定,减少了程序启动时间。
链接过程体现了计算机系统中模块化设计的重要性,使得程序可以分解为独立开发和编译的模块,再组合成完整的应用程序。链接是连接编译系统和运行时系统的桥梁,使得程序从静态文件转变为动态执行的进程
第6章 hello进程管理
6.1 进程的概念与作用
进程是操作系统分配资源的基本单位,是程序的一次执行实例。进程具有以下特征:
- 独立的地址空间:每个进程有自己的虚拟地址空间,与其他进程隔离
- 资源所有权:进程拥有各种系统资源,如内存、文件描述符、CPU时间等
- 执行状态:进程有运行、就绪、阻塞等不同状态
- 进程控制块(PCB):操作系统为每个进程维护一个PCB,包含进程状态、程序计数器、寄存器值等信息
进程的主要作用包括:
- 并发执行:允许多个程序同时运行,提高系统资源利用率
- 资源隔离:提供程序执行的独立环境,防止程序间相互干扰
- 资源共享:通过进程间通信机制实现协作
- 模块化:支持将大型系统分解为多个协作的进程
进程是操作系统实现多任务的基础,使得计算机能够同时运行多个应用程序,提高用户体验和系统效率。
6.2 简述壳Shell-bash的作用与处理流程
Shell(如bash)是用户与操作系统内核交互的接口,其主要作用包括:
- 命令解释:解析用户输入的命令,识别命令名和参数
- 程序执行:创建进程执行用户命令
- 环境管理:设置和维护环境变量
- 脚本执行:解释和执行shell脚本
- I/O重定向和管道:控制命令的输入输出流
- 作业控制:管理前台和后台作业
当用户在bash中输入./hello 20220517 zhangsan 17872918 1命令时,bash的处理流程如下:
- 命令解析:
- 读取用户输入的命令行
- 解析命令名(./hello)和参数(20220517 zhangsan 17872918 1)
- 检查命令是否为内建命令,若不是则准备执行外部程序
- 进程创建:
- 调用fork()系统调用创建子进程
- 子进程是父进程(bash)的副本
- 程序加载:
- 子进程调用execve()系统调用,加载hello程序
- 子进程的地址空间被hello程序替换
- 等待执行:
- 父进程(bash)调用wait()系统调用,等待子进程执行完毕
- 如果命令以&结尾,则bash不等待,继续接受新命令(后台执行)
- 终止处理:
- 子进程执行完毕后,向父进程发送SIGCHLD信号
- 父进程接收信号,获取子进程的退出状态
- bash显示新的命令提示符,等待下一个命令
bash的这种处理流程体现了Unix/Linux系统的进程管理机制,通过fork-exec模式实现了灵活的命令执行。
6.3 Hello的fork进程创建过程
编译执行代码
1.父进程先执行: 程序开始时,只有一个父进程。它首先执行了 printf(\"Hello from parent process\\n\");。
2. fork() 创建副本: 接着调用 fork(),操作系统会复制当前的父进程,创建一个几乎一模一样的子进程。
3. fork() 的不同返回值:
- 在父进程中,fork() 返回新创建的子进程的ID(一个正数)。因此,if (fork() == 0) 这个条件对父进程来说是假的。
- 在子进程中,fork() 返回 0。因此,if (fork() == 0) 这个条件对子进程来说是真的。
4. 子进程执行特定代码: 由于条件为真,子进程执行了 if 语句块里的 printf(\"Hello from child process\\n\");。
5. 并发执行: fork() 之后,父进程和子进程就同时(或在单核上快速轮流)存在并运行了。它们都继承了标准输出,所以打印信息会显示在同一个终端。
6. 各自退出:父进程跳过了 if 语句块,然后和子进程一样,最终都执行到 return 0; 结束。
6.4 Hello的execve过程
execve是用新程序替换当前进程的系统调用。在fork创建子进程后,子进程通过execve加载hello程序:
在Ubuntu上可以使用strace命令跟踪execve系统调用:
strace -f -e execve ./hello 2023113187 陆昱君 13390859635 0
· 命令行执行 ./hello 时,实际上shell(父进程)通过类似 fork + execve 的方式来启动它的。(虽然是直接用 strace 启动 hello,但 strace 内部为了执行 hello 也会发起 execve 系统调用)。
· execve 系统调用被成功执行,指定了要运行的程序是 ./hello。
· 命令行参数 \"./hello\", \"2023113187\", \"陆昱君\", \"13390859635\", \"0\" 被正确地传递给了新的 hello 程序。
· 环境变量也被传递给了新的 hello 程序。
· 新的 hello 程序随后开始执行,并产生了你看到的输出。
· 最后,hello 程序正常退出,返回状态码0。
6.5 Hello的进程执行
在Ubuntu上可以使用以下命令观察进程执行:
# 查看进程状态和CPU使用情况
ps -ef | grep hello
# 跟踪系统调用
strace -f ./hello 2023113187 陆昱君 13390859635 0
hello进程被创建后,由操作系统的进程调度器管理其执行。进程执行涉及以下方面:
进程上下文:
进程上下文包含CPU寄存器值、程序计数器、栈指针等
保存在进程控制块(PCB)中,Linux中对应task_struct结构
上下文切换时,操作系统保存当前进程上下文并恢复下一个进程上下文
进程调度:
Linux使用完全公平调度器(CFS)分配CPU时间
每个进程有一个权重,根据nice值和优先级确定
调度器选择虚拟运行时间最小的进程执行
hello进程在执行sleep()时会主动让出CPU
用户态与核心态转换:
用户态:权限受限,无法直接访问硬件和执行特权指令
核心态:完全访问权限,可执行所有指令
转换触发点:
系统调用:如printf调用write()
异常:如段错误
中断:如键盘输入
转换机制:
x86-64架构使用syscall指令进入核心态
系统调用号和参数通过寄存器传递
执行内核中的系统调用处理程序
使用sysret指令返回用户态
时间片轮转:
默认时间片通常为几毫秒到几十毫秒
时钟中断触发调度器重新评估进程优先级
hello程序在循环中多次调用sleep(),主动让出CPU时间
6.6 hello的异常与信号处理
hello执行过程中可能出现的异常类型:
- 同步异常:
- 除零错误:产生SIGFPE信号
- 段错误:访问无效内存,产生SIGSEGV信号
- 非法指令:产生SIGILL信号
- 异步异常(中断):
- 时钟中断:用于进程调度
- 键盘中断:用户输入时触发
- 软件异常(陷阱):
- 系统调用:如printf、sleep、getchar
用户可以通过键盘操作向hello程序发送信号:
- Ctrl+C:发送SIGINT信号,默认终止程序
- Ctrl+Z:发送SIGTSTP信号,默认暂停程序
- Ctrl+\\:发送SIGQUIT信号,终止程序并生成core文件
# 编译并运行hello程序
gcc -m64 -no-pie -fno-PIC hello.c -o hello
./hello 2023113187 陆昱君 13390859635 0
# 在另一个终端查看进程
ps -ef | grep hello
# 按Ctrl+Z暂停程序,然后执行以下命令
jobs # 显示后台作业
pstree -p # 显示进程树
fg # 恢复前台执行
bg # 在后台继续执行
kill -STOP $(pgrep hello) # 暂停进程
kill -CONT $(pgrep hello) # 继续执行进程
kill -TERM $(pgrep hello) # 终止进程
6.7本章小结
进程管理是操作系统的核心功能之一,它使得程序能够在计算机上执行。通过分析hello程序的进程创建、执行和终止过程,我们了解了操作系统如何管理进程,包括进程创建(fork)、程序加载(execve)、进程调度、用户态与内核态切换、进程间通信等机制。
hello程序的执行过程展示了Linux进程管理的全貌,从shell通过fork-exec模式创建进程,到进程调度执行,再到信号处理和终止,涵盖了进程生命周期的各个阶段。这些机制共同构成了操作系统的进程管理子系统,为应用程序提供执行环境,实现了资源的有效管理和分配。
第7章 hello的存储管理
7.1 hello的存储器地址空间
在hello程序中涉及到多种地址概念:
- 逻辑地址:程序生成的地址,由段选择符和段内偏移量组成。在hello程序中,如printf(\"Hello %s %s %s\\n\",argv[1],argv[2],argv[3])语句中对argv数组的引用就是逻辑地址。
- 线性地址:也称为虚拟地址,是逻辑地址经过段式管理转换后的地址。在x86-64架构下,段基址通常为0,因此逻辑地址的偏移量直接作为线性地址。
- 物理地址:实际的内存硬件地址,CPU最终用于访问内存。线性地址通过页式管理转换为物理地址。
hello程序的地址空间包括:
- 代码段:存放程序指令,只读
- 数据段:存放全局变量和静态变量
- 堆:动态内存分配区域
- 栈:存放局部变量和函数调用信息,如argv数组
7.2 Intel逻辑地址到线性地址的变换-段式管理
在x86-64架构下,虽然保留了段式管理机制,但主要以平坦内存模型工作:
- 段寄存器:CS(代码段)、DS(数据段)、SS(栈段)、ES/FS/GS(附加段)
- 段描述符:在GDT(全局描述符表)或LDT(局部描述符表)中定义段的基址、界限和属性
- 地址转换过程:
- 逻辑地址 = 段选择符:偏移量
- 段选择符用于在GDT/LDT中查找段描述符
- 线性地址 = 段基址 + 偏移量
在64位模式下,段基址通常设置为0,因此逻辑地址的偏移量直接作为线性地址。hello程序在64位模式下运行时,段式管理实际上是透明的,代码中使用的地址直接被视为线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理将线性地址空间和物理内存空间划分为固定大小的页(通常为4KB),通过页表建立线性页号到物理页帧号的映射:
- 地址划分:
- 线性地址被划分为页号和页内偏移
- 在x86-64架构下,典型的4KB页面,页内偏移为低12位
- 页表查询:
- 页号用于查询页表,获取物理页帧号
- 物理地址 = 物理页帧号 * 页大小 + 页内偏移
- hello程序的页表:
- 代码段、数据段、堆和栈分别映射到不同的物理页面
- 页表由操作系统维护,对程序透明
在Ubuntu上可以查看进程的内存映射:
cat /proc/2629/maps
7.4 TLB与四级页表支持下的VA到PA的变换
现代x86-64架构使用四级页表和TLB加速地址转换:
- 四级页表结构:
- PGD(页全局目录)
- PUD(页上层目录)
- PMD(页中间目录)
- PTE(页表项)
- 地址划分:
- 线性地址的高36位被划分为四个9位的索引,用于查询四级页表
- 低12位为页内偏移
- TLB(Translation Lookaside Buffer):
- 缓存最近使用的页表项
- 加速地址转换,避免多次内存访问
- 上下文切换时可能需要刷新TLB
- 地址转换过程:
- 首先检查TLB,如果命中则直接获得物理页帧号
- 否则,通过CR3寄存器找到PGD,然后逐级查找PUD、PMD和PTE
- 最终得到物理页帧号,与页内偏移组合形成物理地址
在Linux中,可以通过以下命令查看TLB和页表相关信息
perf stat -e dTLB-loads,dTLB-load-misses ./hello 2023113187 陆昱君 13390859635 0
7.5 三级Cache支持下的物理内存访问
获取物理地址后,CPU通过多级Cache访问内存:
- L1 Cache:
- 分为指令缓存(I-Cache)和数据缓存(D-Cache)
- 容量通常为32KB-64KB
- 访问延迟约3-4个时钟周期
- L2 Cache:
- 统一缓存,同时缓存指令和数据
- 容量通常为256KB-1MB
- 访问延迟约10-20个时钟周期
- L3 Cache:
- 多核共享的统一缓存
- 容量通常为数MB
- 访问延迟约40-60个时钟周期
- Cache工作原理:
- 局部性原理:时间局部性和空间局部性
- Cache行:通常为64字节
- 映射策略:直接映射、全相联和组相联
- 替换策略:LRU、FIFO等
hello程序执行时,指令和数据首先在L1 Cache中查找,未命中则依次查找L2、L3 Cache,最后访问主内存。
在Ubuntu上可以查看CPU Cache信息:
7.6 hello进程fork时的内存映射
fork创建子进程时,子进程获得父进程内存空间的副本,但采用写时复制(Copy-on-Write)机制优化:
- 写时复制机制:
- 子进程和父进程共享相同的物理页面
- 页表项标记为只读
- 当任一进程尝试写入共享页面时,触发缺页异常
- 内核为写入进程创建页面的副本
- fork的内存操作:
- 复制父进程的页表
- 增加共享页面的引用计数
- 设置页面为只读
可以通过以下程序观察fork的内存映射:
#include
#include
#include
int main() {
int pid = fork();
if (pid == 0) {
// 子进程
printf(\"子进程: %d\\n\", getpid());
system(\"cat /proc/self/maps | grep -v \'vdso\\\\|vsyscall\'\");
} else {
// 父进程
printf(\"父进程: %d\\n\", getpid());
system(\"cat /proc/self/maps | grep -v \'vdso\\\\|vsyscall\'\");
}
return 0;
}
查看输出
父进程和子进程的内存映射信息会分别打印出来。
7.7 hello进程execve时的内存映射
execve加载新程序时,会重新创建进程的地址空间:
- 内存释放:
- 释放当前进程的大部分地址空间(代码段、数据段、堆)
- 保留进程ID、打开的文件描述符等资源
- 新程序加载:
- 根据ELF文件的程序头表创建新的内存映射
- 将各段加载到相应的虚拟地址
- 设置新的栈和堆区域
- 动态链接库映射:
- 加载程序依赖的共享库
- 在内存中映射共享库的代码和数据
可以通过以下命令观察execve前后的内存映射变化:
输出的内存映射信息显示了 exec_test 程序在调用 execve 之前的内存布局。
这些信息包括代码段、数据段、堆、共享库等的内存映射
7.8 缺页故障与缺页中断处理
缺页故障是指程序访问的页面不在物理内存中的情况:
- 缺页异常触发:
- 程序访问的虚拟页面在页表中标记为不存在
- CPU生成缺页异常,进入内核态
- 内核处理过程:
- 检查虚拟地址是否合法
- 确定缺页类型:
- 首次访问(懒分配)
- 被换出到交换空间
- 写时复制
- 分配物理页面或从磁盘读取页面
- 更新页表项,建立映射
- 恢复执行:
- 重新执行触发缺页的指令
hello程序可能在以下情况触发缺页:
- 首次访问堆上分配的内存
- printf函数内部调用malloc分配缓冲区
- 写时复制时修改共享页面
在Ubuntu上可以观察缺页情况
# 查看进程的缺页统计
ps -o pid,min_flt,maj_flt,cmd $(pgrep hello)
- 这些进程的次级页错误数量较高,但主要页错误数量为 0,说明这些进程在运行过程中主要访问了已经加载到内存中的页面,没有触发磁盘 I/O 操作。
- 这些进程的命令行参数一致,表明它们执行了相同的任务,但可能是由不同的父进程启动
7.9动态存储分配管理
printf函数在内部实现中可能调用malloc进行动态内存分配。动态内存管理的基本方法与策略:
- 分配器类型:
- 显式分配器:如malloc/free,由程序员显式控制内存分配和释放
- 隐式分配器:如垃圾收集器,自动回收不再使用的内存
- 基本策略:
- 维护空闲块列表
- 响应分配请求时,选择合适的空闲块
- 处理碎片问题
- 常见分配算法:
- 最佳适配(Best Fit):选择最接近请求大小的空闲块
- 首次适配(First Fit):选择第一个足够大的空闲块
- 下次适配(Next Fit):从上次分配位置开始查找
- 伙伴系统(Buddy System):将内存分割为2的幂次大小的块
7.10本章小结
存储管理是计算机系统的核心部分,它为进程提供了内存资源和虚拟地址空间。通过分析hello程序的存储管理过程,我们了解了从逻辑地址到物理地址的转换机制,多级页表和TLB的作用,Cache的工作原理,以及fork、execve操作对内存映射的影响。
现代计算机系统采用复杂的内存层次结构,包括虚拟内存、物理内存和多级Cache,以平衡性能和容量需求。操作系统通过页式管理提供了内存保护、地址空间隔离和虚拟内存等重要功能,支持了进程的独立执行环境。
这些机制共同构成了现代计算机系统的存储管理体系,支持了虚拟内存、内存保护和动态内存分配等重要功能,为应用程序提供了高效、安全的内存访问环境
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
Linux采用\"一切皆文件\"的设计理念,将I/O设备抽象为文件,使用统一的文件操作接口访问设备:
- 设备的模型化:
- 字符设备:以字符为单位进行I/O操作,如键盘、终端
- 块设备:以块为单位进行I/O操作,如磁盘
- 网络设备:用于网络通信
- 特殊文件:如/dev/null, /dev/zero等
- 设备文件系统:
- /dev目录包含设备文件
- 主设备号和次设备号标识不同设备
- udev系统动态管理设备文件
- 设备驱动程序:
- 实现设备特定的操作
- 向上提供统一的接口
- 处理中断和异步事件
在Ubuntu上可以查看设备文件:
ls -l /dev/tty* # 终端设备
ls -l /dev/sd* # 磁盘设备
8.2 简述Unix IO接口及其函数
Unix/Linux提供了一组标准的I/O接口函数,主要包括:
- 基本文件操作:
- open():打开文件或设备
- close():关闭文件描述符
- read():从文件读取数据
- write():向文件写入数据
- lseek():移动文件指针
- 高级流操作:
- fopen():打开文件流
- fclose():关闭文件流
- fprintf():格式化输出到流
- fscanf():从流读取格式化输入
- fread()/fwrite():二进制读写
- 标准I/O:
- printf():格式化输出到标准输出
- scanf():从标准输入读取格式化数据
- getchar()/putchar():字符输入输出
hello程序使用printf()输出信息,使用getchar()等待用户输入。这些函数内部最终调用系统调用read()和write()与设备交互。在Ubuntu上可以跟踪hello程序的I/O系统调用:
strace -e trace=read,write ./hello ... 命令,我们追踪了 ./hello 程序的 read 和 write 系统调用。read 调用显示了程序启动时动态链接器加载共享库的过程。多次 write 调用则展示了程序按预期将包含参数(学号、姓名、手机号)的 \"Hello...\" 信息和提示语输出到标准输出。strace 将输出中的中文字符以八进制转义显示,这并非程序乱码,而是其标准表示。此追踪验证了程序的I/O行为与参数处理。
8.3 printf的实现分析
rintf是C标准库中的格式化输出函数,其实现涉及多个层次:
- 用户空间实现:
- printf()函数解析格式字符串,处理各种格式化指令
- 调用vfprintf()进行实际的格式化
- 格式化后的数据写入stdout缓冲区
- 当缓冲区满或遇到换行符时,调用write()系统调用
- 系统调用层:
- write()系统调用触发从用户态到内核态的转换
- 在x86-64架构下,通过syscall指令进入内核态
- 系统调用号和参数通过寄存器传递
- 内核实现:
- sys_write()函数处理写请求
- 通过文件描述符找到对应的文件对象
- 调用文件对象的write方法
- 设备驱动层:
- 终端驱动程序接收字符
- 处理特殊字符和转义序列
- 将字符发送到显示设备
- 硬件层:
- 字符被转换为字模(字体位图)
- 字模被写入显存(VRAM)
- 显示控制器读取VRAM,生成显示信号
- 显示器根据信号显示字符
在Ubuntu上可以使用以下命令查看printf的实现:
8.4 getchar的实现分析
getchar是C标准库中的字符输入函数,其实现涉及键盘中断处理和系统调用:
- 键盘中断处理:
- 用户按键产生硬件中断
- CPU跳转到中断处理程序
- 键盘驱动读取扫描码,转换为ASCII码
- 字符被放入键盘缓冲区
- 用户程序调用getchar:
- getchar()调用fgetc(stdin)
- 检查stdin的缓冲区
- 如果缓冲区为空,调用read()系统调用
- 系统调用和阻塞:
- read()系统调用触发从用户态到内核态的转换
- 内核检查键盘缓冲区
- 如果缓冲区为空,进程进入睡眠状态(阻塞)
- 当有键盘输入时,进程被唤醒,读取字符并返回
- 行缓冲模式:
- 终端通常处于行缓冲模式
- 字符被存储在行缓冲区中,直到遇到换行符
- 按下回车键后,整行字符被传递给读取进程
在Ubuntu上可以跟踪getchar的实现:
strace -e read ./hello 2023113187 陆昱君 13390859635 0
8.5本章小结
I/O管理是操作系统的重要组成部分,为应用程序提供了与外部设备交互的能力。通过分析hello程序的I/O操作,我们了解了Linux的I/O设备管理方法,Unix I/O接口及其函数,以及printf和getchar的实现机制。
Linux的\"一切皆文件\"理念提供了统一的I/O接口,简化了设备访问,提高了代码可移植性。标准I/O库在系统调用之上提供了缓冲和格式化功能,提高了I/O效率和易用性。
这些I/O机制使得程序能够与用户和外部环境进行交互,实现数据的输入和输出,体现了操作系统作为硬件抽象层的重要作用。
结论
通过对Hello程序的全面分析,我们完整地追踪了一个程序从源代码到执行的整个生命周期:
- 编译系统处理:Hello程序首先经过预处理(展开宏和包含文件),然后被编译器转换为汇编代码,接着由汇编器生成目标文件,最后通过链接器与其他目标文件和库文件结合成可执行文件。
- 操作系统加载与执行:当用户执行Hello程序时,Shell通过fork创建新进程,然后通过execve加载程序。操作系统为程序分配虚拟地址空间,设置代码段、数据段、堆和栈,并处理动态链接。
- 进程管理:Hello进程受到操作系统的调度管理,获得CPU时间片执行,在用户态和内核态之间切换,并响应用户的输入和信号。
- 存储管理:Hello程序的地址空间由多级页表和TLB管理,实现了虚拟地址到物理地址的转换。多级Cache层次结构加速了内存访问。
- I/O管理:Hello程序通过printf输出信息,通过getchar获取输入,这些操作通过系统调用与设备驱动程序交互,实现了与用户的交互。
总结
通过学习计算机系统,我深刻认识到计算机系统的分层设计思想、抽象机制和接口规范的重要性。这种设计使得复杂系统能够被分解为相对独立的模块,每个模块只需关注自己的职责,通过定义良好的接口与其他模块交互。这种思想不仅适用于计算机系统,也适用于各种复杂软件系统的设计。
附件
本次分析过程中生成的中间文件及其作用:
- hello.c:原始C语言源代码,包含程序的主函数(main)和所有功能实现代码
- hello.i:预处理后的C代码,已展开所有宏定义和包含的头文件内容,删除注释和预处理指令
- hello.s:汇编语言文件,包含x86架构的AT&T语法格式汇编指令,保留函数调用关系
- hello.o:可重定位目标文件(ELF格式),包含未链接的机器码和未解析的符号引用
- hello:最终可执行文件(ELF格式),包含程序入口点和完整的链接信息
实验程序扩展组文件说明:
- fork.c:进程创建实验的原始源代码
- fork:编译链接后的可执行程序
- exec_test.c:程序替换实验的原始源代码
- exec_test:编译链接后的可执行程序
参考文献
[1] Randal E. Bryant, David R. O\'Hallaron. 深入理解计算机系统[M]. 第3版. 北京: 机械工业出版社, 2016.
[2] W. Richard Stevens, Stephen A. Rago. UNIX环境高级编程[M]. 第3版. 北京: 人民邮电出版社, 2014.
[3] 陈莉君, 张琼声, 冯博琴. Linux操作系统原理与应用[M]. 北京: 清华大学出版社, 2012.
[4] Linux 内核源代码在线分析[EB/OL]. https://elixir.bootlin.com/linux/latest/source, 2023-10-25.
[5] GCC官方文档[EB/OL]. https://gcc.gnu.org/onlinedocs/, 2023-11-10.
[6] ELF Format[EB/OL]. http://www.skyfree.org/linux/references/ELF_Format.pdf, 2023-11-15.
[7] 汇编语言程序设计[M]. 北京: 清华大学出版社, 2015.
[8] Linux 系统编程手册[EB/OL]. http://man7.org/linux/man-pages/index.html, 2023-12-01.
[9] 动态链接和加载过程分析[J/OL]. 计算机科学与探索, 2022, 16(5): 991-1003.