哈尔滨工业大学
计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 信息安全
学 号 2022113527
班 级 2203202
学 生 陈鹤冲
指 导 教 师 史先俊
计算机科学与技术学院
2024年5月
本文探讨了简单的C语言程序hello.c在Linux操作系统中的整个生命周期。研究从hello.c的原始代码开始,依次分析了编译、链接、加载、运行、终止和回收等各个阶段,以揭示该程序的"一生"。通过使用gcc编译器等工具,挖掘hello程序的生命周期,将课程知识内化并升华,从而更好地理解和掌握计算机系统的工作原理。本文的研究不仅有助于系统地掌握计算机系统的基本知识,而且通过实际案例的分析,加强了对课堂知识的理解和应用能力。
关键词:计算机系统; CSAPP;HIT;大作业;Hello 程序;生命周期;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
6.2 简述壳Shell-bash的作用与处理流程... - 44 -
6.3 Hello的fork进程创建过程... - 44 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 52 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 54 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 55 -
7.5 三级Cache支持下的物理内存访问... - 56 -
7.6 hello进程fork时的内存映射... - 57 -
7.7 hello进程execve时的内存映射... - 58 -
第1章 概述
1.1 Hello简介
P2P:
From Program to Process。指hello.c文件从程序(program)到进程(process)的过程:
预处理阶段:hello.c通过预处理器(cpp, C Pre-processor)得到另一个C程序hello.i;
编译阶段:hello.i通过编译器(ccl, C Compiler)翻译成文本文件hello.s,它包含一个汇编程序;
汇编阶段:hello.s经过汇编器(as, Assembler)翻译成机器语言指令,将其打包成可重定位目标程序(relocatable object program),将结果保存在二进制文件hello.o中;
链接阶段:hello.o经过链接器(ld, Linker)得到可执行文件hello;
文件的执行:在shell中输入 ./hello 后,shell通过fork产生子进程,于是hello从程序(program)成为进程(process);
020:
From Zero-0 to Zero-0。指hello在运行前与运行后对内存的占用都是零:
From 0:初始时内存中没有hello相关文件的内容;
运行:在shell下调用execve函数,系统将hello文件载入内存,执行相关代码;
To 0:程序运行结束后,hello进程被回收,并由内核删除对应数据;
1.2 环境与工具
硬件:AMD Ryzen 7 5800H with Radeon Graphics 3.20 GHz;
DDR4 16GBytes;
512GB SSD + 1TB SSD;
软件:Windows 11 家庭中文版 23H2;
Ubuntu 22.04.3 LTS;
调试工具:Visual Studio Community 2022 17.10.1;
gcc, vim, readelf, objdump, edb,gdb;
1.3 中间结果
文件名 | 作用 |
hello.c | C语言源文件 |
hello.i | 预处理后的文本文件 |
hello.s | 编译后的汇编文件 |
hello.o | 汇编后的可重定位目标执行文件 |
hello.elf | hello.o 得到的 ELF 文件 |
hello.asm | hello.o反汇编文件 |
hello | 链接后可执行文件 |
hello2.elf | hello 得到的 ELF 文件 |
hello2.asm | hello 反汇编文件 |
图表 1 中间结果文件
1.4 本章小结
本章简要介绍了 hello 的 P2P,020 的具体含义,列出了为编写本论文,采用的软硬件环境与生成的中间结果文件的名字,文件的作用等。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
C语言预处理是C语言编译过程的一个阶段,预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,包括宏替换、文件包含、条件编译等,最终生成经过预处理的代码,然后再进行编译。
预处理的作用:
包含头文件:#include指令用于包含标准库头文件或用户自定义的头文件。这允许程序员使用库中定义的函数、变量和类型。
定义宏:#define指令用于定义宏,宏可以是简单的替换,也可以是复杂的表达式或代码块。
条件编译:#ifdef, #ifndef, #if, #else, #elif, #endif等指令用于条件编译,允许根据预定义的宏来包含或排除代码段。
其他编译器指令:如#pragma,用于提供特定编译器的指令或信息。
文件包含保护:防止头文件被多次包含,这是一种常见的做法,以避免在编译时重复定义。
2.2在Ubuntu下预处理的命令
Ubuntu下预处理命令:
cpp hello.c > hello.i
图表 2 Ubuntu下预处理过程
2.3 Hello的预处理结果解析
打开hello.i文件,可以看到文件已被扩展至3091行,其中3079行至3092行对应原hello.c文件中的main函数部分:
图表 3 hello.c文件中包含main函数部分
上面则是去除注释后,对三个 #include 包含文件的展开,即 stdio.h、unistd.h与stdlib.h。展开的具体流程概述如下(以 stdio.h 为例):CPP 先删除指令 #include <stdio.h>,并到 Ubuntu 系统的默认的环境变量中寻找 stdio.h,最终打开路径/usr/include/stdio.h 下的 stdio.h 文件。若 stdio.h 文件中使用了 #define 语句,则按照上述流程继续递归地展开,直到所有 #define 语句都被解释替换掉为止。
2.4 本章小结
本章主要介绍了预处理的定义与作用、并结合预处理之后的文件对预处理结果进行了解析。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译的概念:
编译器(ccl)将文本文件hello.i翻译成包含一个汇编语言程序的文本文件hello.s 。在这个阶段,预处理过的代码被翻译成目标处理器架构特有的汇编指令。这些形成了一种中间的人类可读语言。
编译的作用:
代码转换:编译器将预处理后的C代码转换成汇编语言。汇编语言是一种低级语言,它与机器码非常接近,但仍然包含一些助记符,使得程序员更容易理解和编写。
优化:在编译阶段,编译器会对代码进行优化,以提高程序的执行效率和性能。这可能包括循环展开、常量折叠、死代码消除等。
错误检测:编译器在编译过程中会检查代码的语法和语义错误,并在发现错误时报告给用户。
代码生成:编译器生成汇编代码,这些代码是机器指令的文本表示形式。每个汇编指令通常对应一个机器指令。
准备汇编:生成的.s文件是为下一步的汇编过程准备的。在汇编过程中,汇编器将这些汇编指令转换成机器码。
可读性:尽管汇编语言对于最终用户来说不如高级语言直观,但它仍然比机器码更易于阅读和理解,这有助于调试和教育目的。
3.2 在Ubuntu下编译的命令
参考 PPT,在 Ubuntu 系统下,进行编译的命令为:
gcc -m64 -no-pie -fno-PIC -S -o hello.s hello.i
图表 4 Ubuntu下编译过程
3.3 Hello的编译结果解析
3.3.1 汇编指令
指令 | 含义 |
.file | 指定该文件的名称。 |
.text | 指定接下来的代码属于程序的文本(代码)段 |
.section | 用于指示汇编器将随后的代码或数据放入指定的程序内存段(section)中 |
.rodata | 指定一个只读数据段 |
.note.gnu.property | 存储 GNU 属性的段 |
.note.GNU-stack | 指示栈的保护需求的段 |
.align | 指定接下来的指令或数据应该按照指定的对齐边界进行对齐 |
.LC0 .LC1 .LFB6 .LFE6 .L2 .L4 .L3 | 标签(labels),它们通常用于标识常量或代码片段的开始 "Function Begin" 指示一个函数的开始。 "Function End" 指示一个函数的结束。 .L2 .L3 .L4通常用于跳转指令 |
.globl | 声明一个全局符号 |
.type | 声明一个符号的类型 |
.size | 指定一个符号(通常是函数或对象)的大小 |
.string | 定义一个字符串常量 |
.ident | 用于在可执行文件或对象文件中嵌入识别信息 |
.long | 声明一个长整型(long integer)常量。 |
.cfi_startproc .cfi_def_cfa_offset .cfi_offset .cfi_def_cfa_register .cfi_def_cfa .cfi_endproc | DWARF 调试信息 |
图表 5 hello.s中的主要汇编指令
3.3.2 数据
3.1.2.1 常量
字符串:
"用法: Hello 学号 姓名 手机号 秒数!\n"
"Hello %s %s %s\n"
图表 6 两字符串在hello.s文件中的定义
用.string申明并用 .LC0/.LC1标记
3.1.2.2 变量(全局/局部/静态)
整形:
i
argc
图表 7 整型i在hello.s中的表示
使用栈为局部变量分配栈空间,可以看到整形i、argc的大小为32位
指针:
argv
图表 8 argc与argv在hello.s文件中的表示
使用栈为局部变量分配栈空间,argv为指针64位
3.1.2.3 类型
函数:
main
图表 9 hello.s中申明main符号的类型为函数
申明main为全局符号,类型为函数
3.1.3 操作
3.1.3.1 赋值
赋0:
i=0
图表 10 hello.c中i赋值为0
图表 11 hello.s中i赋值为0
通过使用立即数赋值给寄存器实现
3.1.3.2 算数操作与数组/指针操作
创建变量:
i
图表 12 hello.s中创建变量i
通过更改寄存器的值设置变量长度
调用数组内容:
argv
图表 13 hello.s中数组的的调用
通过移动寄存器地址设置数组偏移量得到不同数组下标的值
++操作:
i++
图表 14 hello.s中i++与for循环的实现
给i的值每次加一循环
3.1.3.3 关系操作与控制转移
不等关系:
argc!=5
图表 15 hello.c中的if判断与分支语句
图表 16 hello.s中的if判断与分支语句
判断若argc==5则跳转L2,即不满足if的条件,往下执行;若argc!=5则执行if分支语句中的内容,即汇编语句继续往下一行执行
小于关系:
i<10
图表 17 hello.c中的for循环
图表 18 hello.s中的for循环
判断i是否小于9,若是,则跳转到L4,即继续循环;若不是,则往下执行,即跳出循环
3.1.3.4 函数操作
main:
图表 19 hello.c中的main函数
图表 20 hello.s中的main函数初始化
初始化main函数:将rbp寄存器压栈记录当前位置,利用栈接收edi寄存器与rsi寄存器中的参数,分别为int类型的argc与指向字符数组类型的指针argv
图表 21 hello.s中的main函数结束
函数的结束:ret
puts(printf):
图表 22 hello.c中的printf函数
图表 23 hello.s中第1个printf函数的实现
将.LC0的内容即
"用法: Hello 学号 姓名 手机号 秒数!\n"
作为参数调用puts函数
图表 24 hello.s中第2个printf函数的实现
将.LC1的内容即
"Hello %s %s %s\n"
作为参数调用printf函数
exit:
图表 25 hello.c中的exit函数
图表 26 hello.s中的exit函数
将立即数1作为参数调用exit函数
atoi:
图表 27 hello.c中的atoi函数
图表 28 hello.s中的atoi函数
将数组第四位元素作为参数调用给atoi函数
sleep:
图表 29 hello.c中的sleep函数
图表 30 hello.s中的sleep函数
将atoi函数的返回值作为参数调用sleep函数
getchar:
图表 31 hello.c中的getchar函数
图表 32 hello.s中的getchar函数
直接调用getchar函数
3.4 本章小结
本章介绍了编译的概念与作用,并对hello.s文件进行了解析。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编的概念
汇编是指汇编器(Assembler)将以 .s 结尾的汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在 .o 目标文件中的过程
汇编的作用
平台特定代码生成:将汇编代码转换成特定于目标平台的机器代码,确保程序能够在该平台上运行。
性能优化:汇编语言允许程序员精确控制硬件,这有助于编写高效的代码,尤其是在性能关键的应用中。
系统编程:在操作系统内核、驱动程序、嵌入式系统等低级编程领域,汇编语言提供了对硬件的直接控制。
硬件接口:汇编语言经常用于编写与硬件紧密交互的代码,如初始化代码、中断处理程序等。
教学和学习:汇编语言是理解计算机体系结构和低级编程概念的重要工具。
调试和分析:在调试过程中,汇编代码可以帮助开发者理解程序的低级行为,尤其是在解决性能问题或安全问题时。
兼容性和遗留系统:在需要与旧系统或遗留代码兼容时,汇编语言可以提供支持。
4.2 在Ubuntu下汇编的命令
在 Ubuntu 下汇编的命令为:
gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
图表 33 Ubuntu下汇编过程
4.3 可重定位目标elf格式
首先,在 shell 中输入
readelf -a hello.o > hello.elf
指令获得 hello.o 文件的ELF 格式:
图表 34 获得hello.o文件的elf格式
ELF头:
以 16 字节序列 Magic 开始,其描述了生成该文件的系统的字的大小和字节顺序,ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等相关信息。
Magic: 7f 45 4c 46 是ELF文件的标识符,表明这是一个ELF文件。
类别: ELF64 表示这是一个64位的文件。
数据: 2 补码,小端序 (little endian) 表示数据是以小端序格式存储的。
Version: 1 (current) 表示使用的是当前版本的ELF标准。
OS/ABI: UNIX - System V 表示这个文件是为UNIX System V ABI设计的。
类型: REL (可重定位文件) 表示这是一个可重定位的文件,它需要通过链接器链接后才能成为可执行文件。
系统架构: Advanced Micro Devices X86-64 表示这个文件是为AMD64架构设计的。
入口点地址: 0x0 表示没有指定入口点,这符合可重定位文件的特性。
程序头起点: 0 表示没有程序头。
节头起点: 1080 表示节头信息从文件的第1080个字节开始。
标志: 0x0 表示没有设置任何特殊标志。
Size of this header: 64 字节,这是ELF头部的标准大小。
图表 35 ELF头
节头:
节头包含了文件中的各个节(section)的信息,例如 .text、.data、.rodata 等,每个节都有其类型、大小、位置等属性。
图表 36 节头
重定位节.rela.text:
包含了链接器需要的信息来调整代码和数据的地址。
偏移量:这是重定位条目在 .text 节中的偏移量。
信息:包含了重定位类型和符号的索引。
类型:指定了重定位的类型。
符号值:符号在内存中的地址。
符号名称:符号的名称,通常是函数或变量。
加数:有时需要在符号值上加上一个常量来得到最终的地址。
图表 37 重定位节.rela.text
重定位节.rela.en_frame:
图表 38 重定位节.rela.en_frame
符号表:
包含了11个条目,每个条目都包含了符号的编号、值、大小、类型、绑定属性、可见性、节索引以及符号名称。
Num: 符号表条目的编号。
Value: 符号在内存中的地址。
Size: 符号的大小。
Type: 符号的类型,例如 NOTYPE、FUNC、FILE、SECTION。
Bind: 符号的绑定属性,例如 LOCAL(局部)或 GLOBAL(全局)。
Vis: 符号的可见性,DEFAULT 表示默认的可见性。
Ndx: 符号所在的节索引,UND 表示未定义,即在当前文件中没有定义。
Name: 符号的名称。
图表 39 符号表
.note.gnu.property 节
GNU特有的节,用于存储与文件相关的属性。这些属性通常用于提供额外的处理器特性信息,这些信息可以被链接器或加载器使用,以确保程序能够正确地运行在目标系统上。
所有者: GNU 表示这个属性节是由GNU工具链(如GCC编译器或GNU链接器)添加的。
Data size: 0x00000010 表示属性数据的大小为16字节(0x10是16的十六进制表示)。
Description: NT_GNU_PROPERTY_TYPE_0 是属性的描述符,它是一个枚举值,用于标识属性的类型。在这里,它表示属性类型为0,这是GNU属性的第一个版本。
图表 40 .note.gnu.proper节
4.4 Hello.o的结果解析
使用
objdump -d -r hello.o > hello.asm
分析 hello.o 的反汇编,并与第 3 章的hello.s 文件进行对照分析。
图表 41 生成hello.o的反汇编文件hello.asm
机器语言的构成与和汇编语言的映射关系:
起始指令:
endbr64 是一个终结者指令,用于阻止间接分支和调用的推测执行。
push %rbp 和 mov %rsp,%rbp 设置栈帧。
sub $0x20,%rsp 为局部变量分配栈空间。
参数保存:
mov %edi,-0x14(%rbp) 和 mov %rsi,-0x20(%rbp) 保存了函数的输入参数。
条件检查:
cmp $0x5,-0x14(%rbp) 检查第一个参数是否为5。
je 2d <main+0x2d> 如果是5,则跳转到代码的另一部分。
调用 puts 函数:
mov $0x0,%edi 设置 puts 的参数。
call 23 <main+0x23> 调用 puts 函数。
这里的重定位信息 R_X86_64_PLT32 puts-0x4 表示链接器需要解析 puts 的地址。
退出程序:
mov $0x1,%edi 设置 exit 的参数。
call 2d <main+0x2d> 调用 exit 函数。
这里的重定位信息 R_X86_64_PLT32 exit-0x4 表示链接器需要解析 exit 的地址。
循环开始:
jmp 87 <main+0x87> 跳转到循环的开始。
处理参数:
通过 mov 和 add 指令处理传入的参数,可能与数组或结构体有关。
调用 printf 函数:
类似于 puts,这里调用 printf 函数,并传递格式化的输出。
调用 atoi 函数:
atoi 被调用来将字符串转换为整数。
调用 sleep 函数:
sleep 使程序暂停执行指定的秒数。
循环计数:
addl $0x1,-0x4(%rbp) 和 cmp $0x9,-0x4(%rbp) 实现了一个循环计数器。
读取字符:
call 92 <main+0x92> 调用 getchar 函数。
退出循环:
jle 36 <main+0x36> 如果计数器小于或等于9,则跳转回循环开始。
函数返回:
leave 和 ret 指令清理栈帧并返回到调用者。
对比 hello.asm 与 hello.s,发现两者在如下地方存在差异:
分支转移:
在 hello.s 中,跳转指令的目标地址直接记为段名称,如.L2,.L3 等。而在反汇编得到的 hello.asm 中,跳转的目标为具体的地址。
图表 42 反汇编中的分支转移
函数调用:
在 hello.s 文件中,call 之后直接跟着函数名称,而在反汇编得到的 hello.asm中,call 的目标地址是当前指令的下一条指令。
图表 43 反汇编中的函数调用
4.5 本章小结
本章介绍了汇编的概念与作用,在 Ubuntu 下将 hello.s 文件翻译为 hello.o 文件,并生成 hello.o 的 ELF 格式文件 hello.elf,研究了 ELF 格式文件的具体结构,比较了 hello.o 的反汇编代码(保存在 hello.asm 中)与 hello.s 中代码的异同之处。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接的概念:
链接是将一个或多个编译后的目标文件与库文件(如标准库、数学库等)结合起来,生成一个单一的可执行文件的过程。链接器(如ld)负责这项工作。
链接的作用:
解决外部引用:在编译阶段,如果代码中使用了其他文件定义的变量或函数,编译器会生成外部引用。链接器负责找到这些外部定义,并将其与相应的定义链接起来。
代码重用:通过链接,开发者可以编写可重用的库,并在多个程序中使用它们,而无需重复编写相同的代码。
优化内存使用:链接器可以优化内存使用,例如,通过共享相同的库代码,减少可执行文件的大小。
符号解析:链接器将程序中的符号(变量名、函数名等)与它们在内存中的地址关联起来。
库函数调用:大多数编程语言都提供了标准库,这些库在编译时通常不会被包含在目标文件中。链接器会在链接阶段将这些库函数与程序链接起来。
错误检测:链接器还可以检测一些编译器无法检测的错误,如未定义的外部引用。
5.2 在Ubuntu下链接的命令
在 Ubuntu 下链接的命令如下:
ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
图表 44 Ubuntu链接过程
5.3 可执行目标文件hello的格式
在 Shell 中输入命令
readelf -a hello > hello2.elf
生成 hello 程序的 ELF 格式文件,保存为 hello2.elf(与第四章中的 elf 文件作区分):
图表 45 生成hello程序的反汇编文件
ELF 头信息解析:
Magic: 文件开头的“magic number”确认它是一个 ELF 文件。
类别: ELF64,表示这是一个 64 位的文件。
数据: 2 补码,小端序,表示数据字节序。
Version: 1,当前版本。
OS/ABI: UNIX - System V,操作系统/应用程序二进制接口。
系统架构: Advanced Micro Devices X86-64,处理器架构。
类型: EXEC (可执行文件),文件类型。
入口点地址: 0x4010f0,程序的入口点。
程序头起点: 64 字节进入文件。
节头起点: 13560 字节进入文件。
标志: 0x0,没有设置特殊标志。
Size of this header: 64 字节,ELF 头的大小。
Size of program headers: 56 字节,程序头的大小。
Number of program headers: 12,程序头的数量。
Size of section headers: 64 字节,节头的大小。
Number of section headers: 27,节头的数量。
Section header string table index: 26,节头字符串表索引。
图表 46 ELF头
节头:
节头列出了文件中的各个节(sections),包括它们的名称、类型、地址、大小等信息。例如:
.init: 初始化代码。
.plt: 过程链接表,用于动态链接。
.text: 可执行代码。
.rodata: 只读数据。
.dynamic: 动态链接信息。
.got 和 .got.plt: 全局偏移表。
.data: 初始化数据段。
.symtab 和 .strtab: 符号表和字符串表,用于调试。
图表 47 节头
程序头:
程序头(Program header)定义了程序的内存布局,包括程序的加载方式、内存地址、大小等。例如:
PHDR: 程序头表。
INTERP: 程序解释器,指向动态链接器。
LOAD: 加载段,定义了文件中的哪些部分需要被加载到内存中。
DYNAMIC: 动态链接信息。
NOTE: 包含额外的元数据。
图表 48 程序头
节(sections)到段(segments)的映射关系:
段节(Segment to Section mapping): 显示了哪些节被包含在每个段中。
段编号: 每个段都有一个编号,从 00 开始。
图表 49 节到段映射表
动态节:
动态节(Dynamic section)包含了程序在运行时需要的动态链接信息。例如:
NEEDED: 依赖的共享库,这里是 libc.so.6。
INIT 和 FINI: 初始化和终止函数的地址。
HASH, GNU_HASH: 符号哈希表。
STRTAB 和 SYMTAB: 字符串表和符号表。
PLTGOT, PLTRELSZ, PLTREL, JMPREL: 过程链接表相关的条目。
图表 50 动态信息解析
重定位节:
重定位节(Relocation section)包含了需要在程序加载时或运行时进行地址重定位的信息。例如.rela.dyn 和 .rela.plt包含动态重定位条目。
图表 51 重定位信息解析
符号表:
符号表(Symbol table)包含了程序中定义和引用的符号的信息。例如:
.dynsym: 动态符号表,包含了动态链接中使用的符号。
.symtab: 符号表,包含了程序中的所有符号。
图表 52 符号表
桶列表长度直方图(Histogram for bucket list length)
这个直方图显示了动态链接器用来加速符号查找的哈希桶列表的长度分布。桶列表是动态链接器用来快速定位动态符号表(.dynsym)中的符号的一种数据结构。
Length: 桶列表的长度。
Number: 该长度的桶列表的数量。
% of total: 该长度的桶列表占总桶列表的百分比。
Coverage: 这些桶列表覆盖的符号总数的百分比。
有两个长度为3的桶列表,占总数的66.7%,并且覆盖了100%的符号。
图表 53 桶列表长度直方图
版本符号节(.gnu.version)
这个节包含了程序需要的库的版本信息。每个条目都指明了符号对应的库版本。
地址 (Address): 节在文件中的地址。
Offset: 节相对于文件开始的偏移。
Link: 链接到动态符号表(.dynsym)的索引。
在这个例子中,有9个条目,每个条目都包含了不同版本的 GLIBC(GNU C Library):
0: 本地符号
1: GLIBC_2.34 版本
2 和 3: GLIBC_2.2.5 版本
图表 54 版本符号节
版本需求节(.gnu.version_r)
这个节包含了程序运行时需要的库的版本需求。
地址: 节在文件中的地址。
Offset: 节相对于文件开始的偏移。
Link: 链接到动态字符串表(.dynstr)的索引。
在这个例子中,有一个条目,表明程序需要以下库的特定版本:
版本: 1
文件: libc.so.6
计数: 2(表示有两个符号需要这个库的版本)
图表 55 版本需求节
GNU 属性注释(.note.gnu.property)
这个注释节包含了与文件相关的 GNU 属性信息。
所有者 (Owner): GNU
数据大小 (Data size): 0x00000020
描述 (Description): NT_GNU_PROPERTY_TYPE_0
属性包括:
x86 特性:IBT(Intel Burn Test),SHSTK(Stack Checking)
x86 ISA 需求:x86-64-baseline(x86-64 的基本指令集)
图表 56 GUN属性注释
ABI 标签注释(.note.ABI-tag)
这个注释节包含了应用程序二进制接口(ABI)的版本信息。
所有者: GNU
数据大小: 0x00000010
描述: NT_GNU_ABI_TAG
详细信息包括:
操作系统: Linux
ABI 版本: 3.2.0
图表 57 ABI标签注释
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
图表 58 edb的data dump
对照发现其内容与地址和asm文件中相同,程序被载入至地址0x400000 ~ 0x401000,在 0x400fff 之后存放的是.dynamic 到.shstrtab 节的内容
5.5 链接的重定位过程分析
在Shell中使用命令
objdump -d -r hello > hello2.asm
生成反汇编文件hello2.asm,
图表 59 生成hello的返汇编文件hello2.asm
与第四章中生成的 hello.asm 文件进行比较,其不同之处如下:
包含的函数增加:
图表 60 hello2.asm中的.plt
图表 61 hello2.asm中的.init
图表 62 hello2.asm中的puts、printf等函数
图表 63 hello2.asm中.text节新增的内容
图表 64 hello2.asm中的.fini
由于链接需要将main函数中用到的函数加入到可执行文件中,hello2.asm中的函数较hello.asm多了很多
call的地址不同
图表 65 hello2.asm中call的内容
图表 66 hello.asm中call的内容
hello.asm文件中call的是相对main函数的偏移量,而hello2.asm中call的是完整的地址
跳转地址不同
图表 67 hello2.asm中跳转地址
图表 68 hello.asm中跳转地址
同call地址的改变相同,hello.asm文件中跳转的是相对main函数的偏移量,而hello2.asm中跳转的是完整的地址
5.6 hello的执行流程
_init函数在程序开始执行前被调用,它可能用于初始化一些程序运行时需要的全局数据。_start函数是程序的入口点。它首先清除ebp寄存器,设置堆栈,然后调用__libc_start_main函数。负责初始化程序的运行环境,包括堆栈、全局变量等,并最终调用main函数。main函数是用户定义的程序入口点。在main函数中,程序执行具体的逻辑,比如打印信息、读取输入等。当main函数返回时,程序会执行一些清理工作,然后通过调用exit函数来终止程序。_fini函数在程序终止前被调用,用于执行一些必要的清理工作。
5.7 Hello的动态链接分析
动态链接器使用过程链接表 PLT+全局偏移量表 GOT 实现函数的动态链接,在 GOT 中存放函数目标地址,PLT 使用 GOT 中地址跳转到目标函数,在加载时,动态链接器会重定位 GOT 中的每个条目,使得它包含目标的正确的绝对地址。got 与.plt 节保存着全局偏移量表 GOT,其内容从地址 0x404000 开始。
图表 69 .plt中的地址
当程序运行并需要调用一个动态链接库中的函数时,控制流首先进入.plt中的相应“桩”。这个“桩”将跳转到动态链接器的解析函数,如果函数地址尚未解析,动态链接器将查找并解析函数的实际地址,然后跳转到该地址执行函数。一旦函数地址被解析,后续的调用将直接跳转到该地址,不再经过.plt“桩”。
在.plt中,每个函数的“桩”通过不同的参数(如$0x0, $0x1, $0x2等)来区分不同的函数调用。这些参数是传递给动态链接器的,用于标识需要解析的具体函数。
0x404008~0x404017 之间的内容,对应着全局偏移量表 GOT[1]和 GOT[2]的内容发生了变化。GOT[1]保存的是指向已经加载的共享库的链表地址。GOT[2]是动态链接器在 ld-linux.so 模块中的入口。这样,接下来执行程序的过程中,就可以使用过程链接表 PLT 和全局偏移量表 GOT 进行动态链接。
5.8 本章小结
本章中介绍了链接的概念与作用、得到链接后的 hello 可执行文件的 ELF格式文本 hello2.elf,据此分析了 hello2.elf 与 hello.elf 的异同;比较了反汇编文件 hello2.asm 与 hello.asm 的异同。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:
进程(Process)是一个正在运行的程序的实例,系统中的每一个程序都运行在某个进程的上下文中。每个进程都有其独立的内存空间,这意味着进程间的信息必须通过进程间通信(IPC)机制来交换。进程是操作系统资源分配和调度的一个单位。
进程的作用:
资源分配:操作系统通过进程来分配系统资源,如CPU时间、内存空间等。
隔离性:进程提供了隔离性,使得不同的应用程序之间互不干扰,一个进程的崩溃不会直接影响到其他进程。
并发执行:进程允许多个程序并发执行,提高了系统资源的利用率和系统的吞吐量。
模块化:进程的独立性使得操作系统的设计和实现更加模块化,简化了系统的设计。
安全性:进程的隔离性还提供了安全性,操作系统可以对进程进行权限控制,确保关键进程的安全。
进程间通信:进程间的通信机制使得不同的进程能够协作完成任务,如通过管道、消息队列、共享内存等。
简化编程模型:进程为程序员提供了一个相对简单的编程模型,程序员不需要直接管理硬件资源,而是通过进程来间接地使用系统资源。
支持多任务:进程是现代操作系统支持多任务处理的基础,用户可以同时运行多个应用程序。
6.2 简述壳Shell-bash的作用与处理流程
Bash-Shell 的作用:
命令解释器:Bash 读取用户输入的命令,并将其转换成操作系统能够理解的格式。
脚本编程:Bash 支持脚本编程,允许用户编写 Shell 脚本来自动化任务。
用户界面:Bash 提供了一个用户界面,允许用户与操作系统进行交互。
命令历史:Bash 保存用户的命令历史,用户可以使用历史命令来快速重复之前的操作。
命令别名:Bash 允许用户为长命令或常用命令创建别名,简化命令输入。
环境变量管理:Bash 管理环境变量,这些变量提供了有关系统行为和用户会话的信息。
管道和重定向:Bash 支持管道操作,允许用户将一个命令的输出作为另一个命令的输入。
进程控制:Bash 允许用户启动、停止、暂停和恢复进程。
文件操作:Bash 提供了丰富的命令来创建、复制、移动和删除文件。
网络通信:Bash 支持网络命令,允许用户进行远程登录和文件传输。
Bash Shell 的处理流程:
启动:当用户登录系统时,Bash Shell 被启动。
读取命令:Bash 等待用户输入命令。
命令解析:用户输入命令后,Bash 解析命令及其参数。
执行命令:如果命令是内置命令,Bash 直接执行。如果命令是外部程序,Bash 会在系统路径中搜索相应的可执行文件。
环境设置:在执行命令之前,Bash 设置环境变量,如 PATH、PWD 等。
命令执行:Bash 执行命令,并将输出显示给用户。
错误处理:如果命令执行失败,Bash 会显示错误信息。
等待用户输入:命令执行完成后,Bash 返回提示符,等待用户输入新的命令。
脚本执行:如果用户启动一个 Shell 脚本,Bash 会读取脚本文件并按顺序执行其中的命令。
退出:用户可以通过输入 exit 命令或关闭终端来退出 Bash Shell。
6.3 Hello的fork进程创建过程
打开 Shell,输入命令
./hello 2022113527 陈鹤冲 15223041007 1
带参数执行生成的可执行文件。
fork 进程的创建过程如下:首先,带参执行当前目录下的可执行文件 hello,父进程会通过 fork 函数创建一个新的运行的子进程 hello。子进程获取了与父进程的上下文,包括栈、通用寄存器、程序计数器,环境变量和打开的文件相同的一份副本。子进程与父进程的最大区别是有着跟父进程不一样的 PID,子进程可以读取父进程打开的任何文件。当子进程运行结束时,父进程如果仍然存在,则执行对子进程的回收,否则就由 init 进程回收子进程。
图表 70 执行程序
6.4 Hello的execve过程
父进程调用 fork() 创建子进程。子进程复制父进程的资源,包括内存空间和寄存器状态。子进程调用 execve() 来加载新程序 hello。execve() 函数接受 hello 程序的路径和命令行参数数组。execve() 调用驻留在内存中的启动加载器(通常是一个小型的加载器程序),这个加载器负责加载新程序。加载器删除子进程现有的虚拟内存段,包括代码段、数据段、堆和栈,为 hello 程序创建新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到 hello 可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。加载器设置程序计数器指向 hello 程序的 _start 地址,这是程序的入口点。_start 函数是程序的启动例程,它负责进行一些必要的初始化,如设置堆栈等。_start 函数最终调用 hello 程序中的 main 函数,这是用户定义的程序入口点。在加载过程中,并没有立即从磁盘复制所有数据到内存。操作系统使用按需分页机制,只有当 CPU 引用一个被映射的虚拟页时,页面才会从磁盘传送到内存。从 main 函数开始,hello 程序按照其逻辑控制流执行。
6.5 Hello的进程执行
当 Shell 启动 hello 程序时,操作系统为它创建一个新的进程。这个进程被分配一个独立的地址空间,并开始在用户模式下运行。hello 进程开始执行其逻辑控制流,这是一系列指令执行的顺序。在没有被抢占的情况下,hello 会顺序执行其代码。操作系统使用时间片轮转调度算法,这意味着 hello 进程会在分配给它的时间片内运行。时间片用完后,如果还有其它进程等待 CPU,操作系统可能会抢占 hello 进程。如果 hello 进程被抢占,操作系统会进行上下文切换。这包括保存 hello 进程的状态(如程序计数器、寄存器等)到其进程控制块中,并加载下一个要运行的进程的上下文。
在执行 sleep 函数之前,hello 进程主要在用户模式下运行。调用 sleep 后,hello 进程会请求进入睡眠状态,这需要切换到核心态以执行系统调用。sleep 函数是一个系统调用,它会导致从用户模式切换到核心态。操作系统内核处理这个请求,并将 hello 进程移动到等待队列。hello 进程在等待队列中等待指定的时间。在这个过程中,它不占用 CPU 资源,操作系统可以调度其他进程运行。操作系统中的定时器开始计时。当 hello 进程的睡眠时间到期时,定时器会触发一个中断。中断发生后,操作系统会处理这个中断,将 hello 进程从等待队列移除,并放回运行队列,使其成为就绪状态。操作系统的调度器选择 hello 进程再次运行。这涉及到再次进行上下文切换,保存当前进程的状态,并恢复 hello 进程的状态。sleep 函数完成后,hello 进程从核心态返回到用户模式,并从 sleep 函数调用后的指令继续执行。hello 进程完成其逻辑控制流后,会执行退出操作,这通常涉及到清理资源和通知操作系统其已经完成。
6.6 hello的异常与信号处理
正常运行:
在程序正常运行时,打印十次提示信息
图表 71 正常运行
执行过程中回车:
在程序运行时按回车,会多打印几处空行,程序可以正常结束。
图表 72 程序执行过程中按回车
程序执行过程中Ctrl-c:
按下 Ctrl + C,Shell 进程收到 SIGINT 信号,Shell 结束并回收 hello 进程。
图表 73 程序执行过程中按Ctrl-c
程序执行过程中Ctrl-z:
按下 Ctrl + Z,Shell 进程收到 SIGSTP 信号,Shell 显示屏幕提示信息并挂
起 hello 进程。
图表 74 程序执行过程中按Ctrl-z
Ctrl-z后ps与jobs:
对 hello 进程的挂起可由 ps 和 jobs 命令查看,可以发现 hello 进程确实被
挂起而非被回收,且其 job 代号为 1。
图表 75 Ctrl-z后按ps
图表 76 Ctrl-z后按jobs
Ctrl-z后ps与tree:
在 Shell 中输入 pstree 命令,可以将所有进程以树状图显示
图表 77 Ctrl-z后按pstree
Ctrl-z后kill:
输入 kill 命令,则可以杀死指定(进程组的)进程
图表 78 kill前
图表 79 kill后
Ctrl-z后fg:
输入 fg 1 则命令将 hello 进程再次调到前台执行,可以发现 Shell 首先打印hello 的命令行命令,hello 再从挂起处继续运行,打印剩下的语句。程序仍然可以正常结束,并完成进程回收。
图表 80 Ctrl-z后fg
乱按:
在程序执行过程中乱按所造成的输入均缓存到 stdin,当 getchar 的时候读出一个’\n’结尾的字串(作为一次输入),hello 结束后,stdin 中的其他字串会当做 Shell 的命令行输入。
图表 81 执行过程中乱按
6.7本章小结
本章介绍了进程的概念与作用,以及 Shell-bash 的基本概念。针对进程,根据 hello 可执行文件的具体示例研究了 fork, execve 函数的原理与执行过程,并给出了 hello 带参执行情况下各种异常与信号处理的结果。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址
逻辑地址是由程序产生的地址,它是相对于程序的起始地址的偏移量。在汇编语言中,程序员通常编写相对偏移地址,这些地址在编译和链接后转换成逻辑地址。在分段的内存模型中,逻辑地址由段选择符和偏移量组成。选择符用于选择适当的段描述符,该描述符包含了段的基地址和其他信息。例如,在 hello.asm 汇编源文件中,可能使用相对偏移来访问变量或指令。这些偏移在程序被编译和链接后,会与段的基地址组合形成完整的逻辑地址。
线性地址(虚拟地址)
线性地址是逻辑地址经过内存管理单元(MMU)的段机制转换后的地址。在分页机制中,线性地址是分页机制的输入。在保护模式下,段机制将逻辑地址转换为线性地址。这涉及到将选择符和偏移量组合成描述符:偏移量的格式。现代操作系统通常使用分页机制来管理内存。线性地址空间被划分为固定大小的页框,每个页框映射到物理内存中的一个帧。在 hello 程序中,当程序运行时,操作系统会为它创建一个虚拟地址空间。程序中的逻辑地址会被转换为线性地址,这些地址指向虚拟内存中的页。
物理地址
物理地址是CPU通过地址总线访问实际物理内存时使用的地址。MMU将线性地址转换为物理地址。CPU通过地址总线发送物理地址,以便北桥芯片或内存控制器可以访问正确的内存位置。CPU对内存的访问是通过前端总线(在现代架构中通常是高速串行链接)完成的。在前端总线上传输的地址都是物理内存地址。在 hello 程序的执行过程中,当程序引用一个线性地址时,MMU会查找页表,将线性地址转换为相应的物理地址,然后CPU通过地址总线访问这个物理地址对应的内存位置。
7.2 Intel逻辑地址到线性地址的变换-段式管理
实模式下的段式管理(8086处理器)
在实模式下,Intel 8086处理器使用段寄存器来扩展可寻址的内存空间。8086 CPU的寄存器是16位宽,因此直接只能寻址 2*16 + 2*16 (即64KB)的内存空间。为了能够访问更大的地址空间,引入了段寄存器(如CS、DS、ES等)。
段寄存器:存放段基地址,左移4位(即乘以16)后与偏移地址相加,得到20位的物理地址。
逻辑地址计算:逻辑地址 = 段寄存器值(左移4位)+ 偏移地址。这样,每个段可以访问64KB的内存,通过不同的段寄存器可以访问到1MB的内存空间。
保护模式下的段式管理
在保护模式下,段式管理更为复杂,引入了描述符和段描述符表(GDT和LDT)。
段寄存器:不再直接存放段基地址,而是存放段选择符。
段选择符:包含三部分:索引(Index)、TI(Table Indicator)、RPL(Requester Privilege Level)。
索引:确定段描述符在描述符表(GDT或LDT)中的位置。
TI:指示是使用全局描述符表(GDT,TI=0)还是局部描述符表(LDT,TI=1)。
RPL:确定请求的特权级别。
段描述符:在GDT或LDT中,每个段描述符包含以下信息:
Base:段的基地址。
Limit:段的界限或大小。
DPL:描述符的特权级。
线性地址计算:通过段选择符找到对应的段描述符,使用段描述符中的基地址加上偏移量,得到线性地址。
现代x86系统
在现代的x86系统中,通常使用扁平内存模型,逻辑地址直接映射到线性地址,分段功能被关闭或简化。
扁平模型:在这种模型下,逻辑地址与线性地址相同,不再需要通过段描述符进行转换。
分页机制:线性地址通过分页机制转换为物理地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存组织
段的集合:Linux将虚拟内存组织成一些段的集合。每个段对应一段连续的虚拟地址空间,段之外的虚拟内存是不存在的。
任务结构和内存描述
task_struct:内核为每个进程(如hello进程)维护一个任务结构task_struct,其中包含了该进程的内存管理信息。
mm_struct:mm_struct是描述虚拟内存状态的数据结构,包括页表基址(pgd)和内存映射(mmap)。
页表基址:pgd是指向第一级页表的基地址,它与进程相关联,用于页表的访问。
vm_area_struct 链表:mmap是vm_area_struct结构的链表,每个链表条目代表一个内存段。
虚拟页和物理页
虚拟页(VP):系统将每个段分割为固定大小的块,即虚拟页,Linux中每个虚拟页的大小为4KB。
物理页(PP/页帧):物理内存也被分割为与虚拟页大小相同的物理页。
地址翻译和页表
MMU:内存管理单元(MMU)负责地址翻译,它使用页表来将虚拟页映射到物理页。
页表条目(PTE):页表中的每个条目(PTE)包含有效位、权限信息和物理页号。
线性地址到物理地址的转换
页表基址寄存器(PTBR):通过PTBR加上虚拟地址中的虚拟页号(VPN)来访问页表,获取相应的PTE。
有效位检查: a. 无效:如果PTE的有效位为0,表示该虚拟页未被缓存到物理内存中,产生缺页异常。 b. 有效:如果有效位为1,表示该虚拟页已经缓存在物理内存中。
物理页号(PPN):从有效的PTE中获取物理页号(PPN)。
物理地址计算:物理地址由物理页号(PPN)加上虚拟页偏移量(VPO)计算得到。
缺页处理
缺页异常:当访问的虚拟页不在物理内存中时,操作系统会触发缺页异常处理程序。
页面置换:如果需要,操作系统会选择一个牺牲页,将其换出到磁盘,并将新的页面调入物理内存。
重新访问:缺页处理后,进程会重新执行导致缺页的指令,此时页表已更新,可以成功访问物理内存。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB(Translation Lookaside Buffer)
TLB是一种缓存,用于存储最近或频繁访问的地址转换条目,以减少对页表的查询次数,从而加快地址转换速度。
TLB结构:Intel Core i7 CPU的TLB是4路16组相联,意味着TLB被分为16组,每组有4个条目,每个条目可以存储一个地址转换条目。
TLB索引:虚拟地址的前32位(VPN)用于TLB索引(TLBI,后4位)和标记(TLBT,前28位)。
四级页表
当TLB未命中时,MMU(内存管理单元)将通过四级页表查询来获取物理地址。
页表大小:每个页表大小为4KB。
页表条目大小:每个页表条目(PTE)大小为8字节。
页表条目数量:每个页表有512个PTE条目(4KB / 8B = 512)。
VPN位数:虚拟地址的前36位用于VPN(36位),因为页表大小为4KB,所以需要9位来索引页表中的PTE,四级页表共需要36位来索引。
VA到PA的变换过程
地址产生:CPU执行指令,产生虚拟地址VA。
TLB查找:MMU使用VA的前36位VPN作为TLBT(前32位)+TLBI(后4位)在TLB中进行匹配。如果TLB命中,MMU从TLB条目中获取PPN,并与VA的后12位VPO组合成52位的物理地址PA。
页表查询:如果TLB未命中,MMU将通过四级页表进行查询。CR3寄存器指向第一级页表的起始位置,VPN的前9位(VPN1)用于在第一级页表中索引PTE。MMU查询第一级页表,获取第二级页表的物理地址,重复此过程直到第四级。在第四级页表中,MMU查询到PPN,并与VPO组合成PA。
TLB更新:一旦物理地址被确定,MMU将新的VPN和PPN条目添加到TLB中,以便将来的访问可以快速命中。
缺页和段错误
缺页故障:如果查询PTE时发现对应的页不在物理内存中,将引发缺页故障,操作系统将处理该故障,将缺失的页加载到物理内存中。
段错误:如果访问的PTE条目权限不够,将引发段错误,操作系统将处理该错误。
7.5 三级Cache支持下的物理内存访问
L1 Cache 访问
物理地址PA的获取:首先,通过前面的步骤,我们已经将虚拟地址VA转换为物理地址PA。
组索引CI:使用PA的最后6位作为组索引(CI),在L1 Cache中找到对应的组。
块偏移CO:PA的倒数第二位到倒数第七位(共6位)作为块偏移(CO),用于在缓存块内定位具体的数据。
标记CT:PA的剩余前40位(52-12=40,因为最后12位用于CI和CO)作为标记(CT),用于与缓存行中的标记进行比较。
缓存命中与不命中
匹配CT:在L1 Cache中,将CT与组内8个缓存行的标记进行比较。
有效位验证:如果找到匹配的CT并且缓存行的有效位(valid bit)为1,则缓存命中(hit)。
数据返回:根据CO从匹配的缓存行中取出数据并返回。
缓存不命中处理
L1 Cache不命中:如果L1 Cache中没有找到有效匹配,就会发生缓存不命中(miss)。
L2 Cache查询:接下来,处理器会查询L2 Cache,使用与L1相同的索引和标记比较方法。
L3 Cache查询:如果L2 Cache也未命中,处理器会继续查询L3 Cache。
数据加载与缓存替换
主存查询:如果所有级别的缓存都没有命中,处理器最终会从主存中加载数据。
数据加载:将数据从主存加载到缓存中,通常是加载整个缓存块大小的数据。
替换策略:如果目标组内没有空闲块,则需要根据替换策略(如最近最少使用LFU)选择一个缓存行进行替换。
更新缓存:将新加载的数据及其CT和有效位更新到选定的缓存行中。
7.6 hello进程fork时的内存映射
创建新进程:内核为新进程创建必需的数据结构,包括一个新的task_struct结构体,它包含了进程控制信息。
分配PID:内核为新创建的子进程分配一个唯一的进程标识符(PID)。
复制虚拟内存结构:内核创建当前hello进程的虚拟内存结构的副本,包括mm_struct(内存管理结构),区域结构(描述地址空间的各个区域),以及页表。
只读页面:在fork操作完成后,两个进程(父进程和子进程)的页面都被标记为只读。这是为了确保在写时复制(Copy-On-Write, COW)机制生效之前,父进程和子进程的内存内容不会被意外修改。
私有写时复制:每个区域结构都被标记为私有的写时复制。这意味着,当任一进程尝试写入该区域时,内核将创建该页面的一个新副本,确保父进程和子进程各自拥有独立的内存页面。
相同的虚拟内存:在fork返回后,子进程拥有与父进程调用fork时相同的虚拟内存镜像。这包括代码段、数据段、堆、栈等。
写操作导致复制:当父进程或子进程执行写操作时,写时复制机制会触发。如果操作的页面是两个进程共享的,内核将为执行写操作的进程创建该页面的一个新副本,从而确保每个进程都维护自己的私有地址空间。
独立性维护:通过写时复制,父进程和子进程各自独立地拥有自己的内存页面,即使它们起始时拥有相同的内存内容。
7.7 hello进程execve时的内存映射
删除已存在的用户区域:
内核删除当前进程虚拟地址空间用户部分的现有区域结构。
这包括代码、数据、堆、栈等所有用户空间的内存区域。
映射私有区域:
内核为新程序创建新的私有区域结构,这些区域在创建时都是私有的,并采用写时复制(Copy-On-Write, COW)策略。
代码区域 (.text):包含程序的机器代码,从hello文件中映射。
数据区域 (.data):包含程序的初始化全局变量和静态变量,同样从hello文件中映射。
BSS区域:包含未初始化的全局变量和静态变量,请求二进制零(Bring Your Own Zeroes, BOYZ),映射到匿名文件。
栈和堆区域:栈和堆通常在程序运行时动态分配,初始长度为零,随着程序运行需要时动态增长。
映射共享区域:
如果hello程序与共享对象或目标链接(例如,标准C库libc.so),这些共享对象将被动态链接到hello程序中。
共享对象被映射到用户虚拟地址空间的共享区域内,允许多个进程共享这些库代码和数据。
设置程序计数器(PC):
execve函数执行的最后步骤是更新当前进程的上下文,特别是设置程序计数器。
程序计数器被设置为指向新程序代码区域的入口点(通常是_start符号或main函数的起始地址)。
7.8 缺页故障与缺页中断处理
缺页故障发生
虚拟地址访问:程序执行时,CPU生成虚拟地址(VA),并发送给内存管理单元(MMU)进行地址翻译。
页表查找:MMU在页表中查找与虚拟地址对应的条目(PTE),试图将虚拟地址转换为物理地址(PA)。
缺页发现:如果页表中不存在对应的条目,或者虽然存在但页面当前不在物理内存中(例如,页面可能被交换出去了),MMU 无法完成地址翻译。
缺页异常:MMU 触发一个缺页异常,控制权转移到操作系统内核中的缺页处理程序。
缺页中断处理
合法性检查:缺页处理程序首先检查尝试访问的虚拟地址是否合法。如果地址非法,处理程序将终止进程并报告段错误(Segmentation Fault)。
选择牺牲页:如果地址合法,缺页处理程序将从物理内存中选择一个牺牲页(victim page)来替换。选择牺牲页的策略可能基于多种算法,如最近最少使用(LRU)。
页面置换:如果牺牲页已被修改(即脏页),它需要先被写回磁盘到相应的后备存储中,这个过程称为页面置换(Page Replacement)。
加载新页面:将新的页面从磁盘读取到物理内存中,更新页表以反映新的物理页面和虚拟页面之间的映射。
更新页表:在页表中创建或更新条目,将新的物理页面与虚拟地址关联起来,并设置适当的权限。
返回用户模式:缺页处理程序完成页面加载和页表更新后,返回控制权给用户空间的进程。
重新执行指令:CPU再次执行之前引起缺页的指令,这次虚拟地址已经有了有效的物理地址映射,MMU能够成功翻译地址,进程继续执行。
7.9动态存储分配管理
基本方法
内存分配器的角色:动态内存分配器负责维护进程的虚拟内存区域,即堆(Heap)。分配器将堆视为不同大小的内存块集合。
内存块的状态:内存块要么是已分配的(Allocated),被应用程序使用。要么是空闲的(Free),等待被分配。
内存块的生命周期:已分配的块将保持已分配状态,直到显式释放。空闲块保持空闲,直到被应用程序显式分配。
分配策略
显式分配器:要求应用程序显式地释放已分配的内存块。例如,C语言中的free()函数。
隐式分配器:自动检测内存块何时不再被使用,并释放它。通常与垃圾收集器(Garbage Collector)相关。
内存管理技术
隐式空闲链表:在内存块中增加头部(Header)和脚部(Footer)信息,用于维护空闲块。利用Header和Footer中的大小信息来寻找前后空闲块,实现空闲块的合并。
显式空间链表:空闲块通过双向链表显式链接。每个空闲块包含指向前驱(pred)和后继(succ)的指针。
空闲块合并:通过Header和Footer中的信息,可以快速合并相邻的空闲块。合并策略根据前后块的状态(空闲或已分配)来确定。
分离存储:维护多个空闲链表,每个链表包含大小相同的块。通过块大小的等价类进行分离存储,便于管理和分配。
Printf和Malloc
在C语言中,printf函数通常不会直接调用malloc。malloc是用于动态分配内存的函数,而printf是用于格式化输出的函数。然而,如果printf需要处理非常大的数据或者需要动态分配内存来存储格式化字符串,它可能会间接地使用malloc(例如,通过asprintf函数)。
7.10本章小结
本章主要介绍了 hello 的存储器地址空间、intel 的段式管理、hello 的页式管理, VA 到 PA 的变换、物理内存访问,hello 进程 fork、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
在Linux中,所有的I/O设备(如键盘、鼠标、打印机、磁盘驱动器等)都被模型化为一个特殊的文件。这些文件通常位于/dev目录下。每个设备文件代表一个物理设备或逻辑设备。例如,/dev/tty可以代表控制台终端,/dev/sda代表第一个SATA硬盘。
设备管理:Unix I/O 接口
打开文件:使用open系统调用来打开一个设备文件,获取文件描述符。
读写文件:使用read和write系统调用来执行输入和输出操作。对设备文件的读写操作对应于对设备的I/O操作。
改变文件位置:使用lseek系统调用来改变文件内当前位置的指针,这可以用于随机访问设备。
关闭文件:使用close系统调用来关闭设备文件,释放文件描述符。
设备驱动程序
Linux内核包含设备驱动程序,它们是操作系统和硬件设备之间的桥梁。驱动程序负责处理对设备文件的I/O请求。设备驱动程序提供了一个抽象层,隐藏了硬件的具体细节,使得应用程序能够通过标准的文件I/O操作来访问硬件设备。
访问权限
设备文件具有特定的访问权限,这决定了哪些用户或进程能够打开和操作设备。某些设备文件可能需要特殊的权限才能访问,例如,访问/dev/mem通常需要超级用户权限。
统一的I/O模型
通过将设备模型化为文件,Linux提供了一个统一的I/O接口,简化了设备的访问和管理。这种模型确保了对所有设备的I/O操作都遵循相同的模式,从而简化了应用程序的编写。
8.2 简述Unix IO接口及其函数
Unix I/O 接口统一操作
打开文件:应用程序通过调用内核来打开一个文件或I/O设备。内核返回一个文件描述符,这是一个小的非负整数,用于标识文件。内核记录有关打开文件的所有信息。
标准文件:每个由Shell创建的进程都有三个预打开的文件:标准输入(stdin)、标准输出(stdout)、标准错误(stderr)。
改变文件位置:内核为每个打开的文件维护一个文件位置指针(通常称为文件偏移量)。应用程序可以通过seek系统调用显式地改变文件位置。
读写文件:读操作从文件中复制字节到内存,从当前文件位置开始,并更新文件位置。写操作从内存复制字节到文件,同样从当前文件位置开始,并更新文件位置。读取到达文件末尾时触发EOF(文件结束标志)。
关闭文件:内核释放与文件打开相关的数据结构资源。文件描述符被回收,可供再次使用。
Unix I/O 函数
open:
函数原型:int open(char* filename, int flags, mode_t mode);
打开一个存在的文件或创建一个新文件,并返回文件描述符。filename是要打开或创建的文件名。flags指定文件访问模式(如只读、写入、追加等)。mode为新创建文件的访问权限。
close:
函数原型:int close(int fd);
fd是要关闭的文件的文件描述符。关闭文件,并释放相关资源。
read:
函数原型:ssize_t read(int fd, void *buf, size_t n);
从文件描述符fd指定的位置读取最多n个字节到缓冲区buf。返回值是实际读取的字节数,-1表示错误,0表示EOF。
write:
函数原型:ssize_t write(int fd, const void *buf, size_t n);
将最多n个字节的数据从缓冲区buf写入文件描述符fd指定的位置。返回值是成功写入的字节数,-1表示错误。
8.3 printf的实现分析
printf函数的实现
int printf(const char *fmt, ...)
{
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
printf使用C语言的可变参数机制(...),这允许函数接受不定数量的参数。va_list是一个字符指针,用于访问可变参数列表中的参数。通过((char*)(&fmt) + 4)计算得到第一个额外参数的地址,将其转换为va_list类型,这里fmt是printf的第一个参数(格式化字符串)。调用vsprintf函数用于生成格式化后的字符串。它接受缓冲区buf、格式化字符串fmt和参数列表arg,生成格式化字符串,并返回生成字符串的长度i。系统调用write函数将格式化后的字符串写入到标准输出(通常是屏幕)。在Windows下,write可能通过系统调用syscall或中断int 0x80来实现。
vsprintf函数的实现
int vsprintf(char *buf, const char *fmt, va_list args)
{
// ...格式化字符串处理逻辑...
}
vsprintf遍历格式化字符串fmt,解析每个指令(如%x、%s等),并从va_list中获取相应的参数。对于每个指令,vsprintf执行相应的格式化操作,将参数转换为字符串,并存储到缓冲区buf中。返回生成的格式化字符串的长度。
write系统调用
write:
mov eax, _NR_write
mov ebx, [esp + 4] // 文件描述符
mov ecx, [esp + 8] // 缓冲区指针
int INT_VECTOR_SYS_CALL // 在Linux下是int 0x80
write准备系统调用参数,将它们放入寄存器中。eax寄存器用于存储系统调用号_NR_write。ebx和ecx寄存器用于存储文件描述符和缓冲区指针。通过中断int INT_VECTOR_SYS_CALL(或syscall系统调用)触发系统调用,操作系统内核将处理这个请求。
字符显示驱动子程序
系统调用将字符串中的字符转换为ASCII码。字符显示驱动子程序使用字模库将ASCII码映射到点阵信息。点阵信息存储到显存(VRAM)中,显存存储每个点的RGB颜色信息。显存芯片按照刷新频率读取VRAM,并控制液晶显示器显示每个点的RGB分量。
8.4 getchar的实现分析
键盘中断处理
当用户按下键盘上的一个键时,键盘硬件生成一个扫描码,这个扫描码是按键的唯一标识。键盘硬件发出中断信号给CPU,请求处理键盘输入。这是一种异步事件,可以打断当前正在运行的进程。操作系统中的键盘中断处理程序被触发,它首先从键盘接口读取扫描码。键盘中断子程序将扫描码转换为对应的ASCII码(如果适用),这个转换可能依赖于键盘布局和当前的输入法状态。转换后的ASCII码字符被保存到系统的键盘缓冲区中,等待用户进程读取。
getchar函数实现
getchar通常通过系统调用read从标准输入读取数据。read函数可以从任何文件描述符读取数据,包括键盘(文件描述符0)。getchar使用read从键盘缓冲区中读取ASCII码字符。如果缓冲区中有多个字符,read可以一次性读取多个字符,直到遇到特定的分隔符,通常是换行符(\n)或回车符(\r)。getchar读取第一个字符并立即返回,而不是等待换行或回车。如果需要读取整行,可能需要循环调用getchar直到遇到换行或回车。如果键盘缓冲区为空(即用户尚未按下任何键),read调用可能阻塞,直到有输入可用。在某些系统中,可以通过终端设置来控制输入是立即传递(非缓冲)还是等待一定量的输入后再传递(缓冲)。
8.5本章小结
本章主要介绍了 linux 的 IO 设备管理方法和及其接口和函数,对 printf 函数和getchar 函数的底层实现有了基本了解。
(第8章1分)
结论
hello程序的一生是计算机系统中一个典型的程序从编写到执行再到终止的完整过程。
编写(Edit):使用文本编辑器创建源代码文件hello.c。
预处理(Preprocess):通过预处理器处理hello.c,展开所有#include指令,替换宏定义,生成hello.i。
编译(Compile):编译器将预处理后的hello.i文件转换成汇编语言文件hello.s。
汇编(Assemble)汇编器将hello.s汇编成机器语言,生成可重定位目标文件hello.o。
链接(Link):链接器将hello.o与其它必要的库和目标文件链接,生成可执行文件hello。
加载运行(Load & Run):在Shell中执行./hello命令,启动程序。
创建子进程(Fork):Shell调用fork系统调用创建hello程序的子进程。
运行程序(Exec): Shell执行execve调用,加载hello程序的代码和数据到子进程的虚拟内存空间。
执行指令(Execute Instructions):CPU开始执行程序的main函数,按照程序计数器的指示顺序执行指令。
访问内存(Memory Access):MMU将虚拟地址转换为物理地址,CPU通过高速缓存系统访问数据。
动态申请内存(Dynamic Memory Allocation):如果程序中调用了malloc或类似函数,动态内存分配器将从堆中分配内存。
信号处理(Signal Handling):如果用户发送了如Ctrl-C或Ctrl-Z等信号,操作系统将调用相应的信号处理函数。
结束(Exit):程序执行完毕,或接收到终止信号后,调用exit系统调用,内核删除该进程的数据结构。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件名 | 作用 |
hello.c | C语言源文件 |
hello.i | 预处理后的文本文件 |
hello.s | 编译后的汇编文件 |
hello.o | 汇编后的可重定位目标执行文件 |
hello.elf | hello.o 得到的 ELF 文件 |
hello.asm | hello.o反汇编文件 |
hello | 链接后可执行文件 |
hello2.elf | hello 得到的 ELF 文件 |
hello2.asm | hello 反汇编文件 |
表格 1 产生中间产物名称及作用
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] Randal E.Bryant, David O'Hallaron. 深入理解计算机系统[M]. 机械工业出版社.2018.4
[2] Pianistx.printf 函数实现的深入剖析[EB/OL].2013[2021-6-9].
https://www.cnblogs.com/pianist/p/3315801.html.
[3] 梦想之家 xiao_chen.ELF 文件头更详细结构[EB/OL].2017[2021-6-10].
https://blog.csdn.net/qq_32014215/article/details/76618649.
[4] Florian.printf 背后的故事[EB/OL].2014[2021-6-10].
https://www.cnblogs.com/fanzhidongyzby/p/3519838.html.
[5] CSDN 若干博客:
https://blog.csdn.net/wulex/article/details/78027957
https://blog.csdn.net/xuehuafeiwu123/article/details/72963229
https://blog.csdn.net/qq_32014215/article/details/76618649
https://blog.csdn.net/qq_32014215/article/details/76618649
https://www.cnblogs.com/fanzhidongyzby/p/3519838.html
https://www.cnblogs.com/fanzhidongyzby/p/3519838.html
[6] 博客园若干博客:
https://www.cnblogs.com/jiqing9006/p/8268233.html
https://www.cnblogs.com/xmphoenix/archive/2011/10/23/2221879.html
https://www.cnblogs.com/fanzhidongyzby/p/3519838.html
https://www.cnblogs.com/zengyiwen/p/5755186.html
[7] 百度百科、维基百科的若干词条
(参考文献0分,缺失 -1分)