程序人生----HIT CSAPP大作业
第1章 概述
1.1 Hello简介
Hello程序是一个简单的命令行应用程序,它允许用户输入学号、姓名、手机号、等待时间。程序会进入一个循环,每次循环都会输出“Hello 学号 姓名 秒数”的消息,并在每次输出后暂停指定的等待时间。
P2P: 程序的生成过程是分阶段的,包括预处理、编译、汇编和链接。在预处理阶段,源代码被清理和准备,以便后续处理。编译阶段将源代码转换为汇编语言。然后,在汇编阶段,汇编语言被转换为机器可识别的指令。最后,在链接阶段,多个目标文件被连接成一个可执行文件,即hello程序。
O2O: 当程序运行时,用户通过Shell输入指令,例如输入./hello。Shell会创建一个新的进程来执行hello程序。操作系统通过fork系统调用创建一个子进程,然后通过execve系统调用装载程序。这个过程包括内存分配和访问等操作,以确保程序能够在系统中运行。程序执行完成后,控制权返回给父进程或祖先进程,这些进程负责回收子进程的资源,确保系统资源得到释放。这是操作系统管理进程生命周期的过程,它确保了系统的稳定性和资源的高效利用。
1.2 环境与工具
硬件环境:
- X86_64 CPU AMD Ryzen 7 5700X 8-Core Processor
- Address sizes: 48 bits physical, 48 bits virtual
- 字节序: Little Endian
- Cache:
- L1d: 256 KiB (8 instances)
- L1i: 256 KiB (8 instances)
- L2: 4 MiB (8 instances)
- L3: 32 MiB (1 instance)
- 32G RAM; 2T SSD
软件环境:
Ubuntu 22.04.4 LTS 64位
开发与调试工具:
vscode, gcc, gdb, objdump, readelf, edb
1.3 中间结果
文件名称 | 作用 |
---|---|
hello.c | 储存nello程序源代码 |
hello.i | 源代码经过预处理产生的文件 |
hello.s | 对应的汇编语言文件 |
hello.o | 可重定位目标文件 |
hello o.s | hello.o的反汇编语言文件 |
hello o.elf | hello.o的ELF文件格式 |
hello | 二进制可执行文件 |
hello.elf | 可执行文件的ELF文件格式 |
hello_t.s | 可执行文件的汇编语言文件 |
1.4 本章小结
本章简述了Hello程序的一生,概括了从P2P到020的整个过程,还介绍了相关软硬件环境和开发调试工具,最后列出了相关中间文件以供参考。
第2章 预处理
2.1 预处理的概念以及作用
在软件开发中,预处理是源代码编译之前的一个步骤,它由预处理器执行。预处理器是一个程序,它处理源代码文本,以准备将其交给编译器。预处理器的任务是对源代码进行一系列的文本替换和处理,这个过程不会生成机器代码,而是为编译器生成一个准备好的源代码版本。
预处理的主要作用包括以下几点:
- 宏定义处理:预处理器可以处理源代码中的宏定义。宏可以是简单的文本替换,也可以是带参数的宏,预处理器会根据宏定义展开源代码中的宏调用。
- 文件包含:使用#include指令,预处理器可以将其他源文件的内容包含到当前文件中。这允许开发者将常用的代码片段或库定义放在单独的文件中,并在需要的地方包含它们。
- 条件编译:预处理器可以根据条件编译指令(如#ifdef、#ifndef、#if、#else、#elif和#endif)有选择地编译代码的一部分。这允许开发者编写可移植的代码,可以在不同的系统或配置下编译不同的代码段。
- 警告和错误指令:预处理器可以生成编译器警告或错误,这对于检测和避免潜在的问题非常有用。
- 其他文本操作:预处理器还可以执行其他文本操作,如删除注释、行拼接、标记粘贴等。
预处理器的作用是让源代码更加灵活和可维护。它允许开发者编写更加强大和适应性强的代码,同时简化了代码的管理和编译过程。预处理器的处理结果是一个纯净的源代码版本,它将交给编译器进行语法分析、优化和代码生成。
2.2在Ubuntu下预处理的命令
在Ubuntu下,预处理C源文件的命令通常是使用gcc
编译器提供的-E
选项。这个选项告诉编译器只进行预处理步骤,而不进行编译、汇编和链接。
例如,对于文件名为hello.c
的C源文件,可以使用以下命令来预处理它:
gcc -E hello.c -o hello.i
这里,-E
选项表示只进行预处理,hello.c
是输入的源文件,-o hello.i
指定了预处理后的输出文件名。输出文件通常以.i
作为扩展名,表示它是一个预处理后的C源文件。
预处理后的文件hello.i
包含了所有宏的展开、头文件的包含内容以及其他预处理指令的处理结果,但不包含任何编译器特定的代码或优化。这个文件可以直接查看,以便理解预处理器的输出和宏的展开情况。
2.3 Hello的预处理结果解析
打开hello.i文件后,其内容从原来的20多行激增至3000多行。这种变化主要是由于编译前的预处理阶段导致的,它引入了诸多在源代码中不可见的元素,具体包括:
-
头文件内容的包含:所有通过#include指令引入的头文件,例如<stdio.h>, <unistd.h>, <stdlib.h>等,它们的内容会被完整地插入到预处理后的输出文件中。这意味着,原本简洁的源代码会因此扩展,包含这些头文件提供的函数声明和宏定义。
-
宏的展开:如果源代码中使用了宏定义,预处理器会扫描代码,并将所有的宏调用替换为对应的宏定义内容。这个过程称为宏展开,可以显著增加代码的行数,尽管在您的代码中并未涉及到宏的使用。
-
条件编译的处理:源代码中的条件编译指令,如#ifdef, #ifndef, #if, #else, #elif, #endif等,允许开发者根据不同的编译条件包含或排除代码段。预处理器会根据这些条件的真假,决定哪些代码段应该被包含在预处理后的输出中,哪些则被忽略。
-
注释的处理:预处理器在处理源代码时,会删除掉所有的注释。这是因为注释是为了提高代码可读性而存在的,对程序的执行没有影响。因此,预处理后的输出文件中将不包含任何注释。
这些预处理操作使得hello.i文件的大小大幅增加,但同时也为编译器的后续处理提供了完整的代码信息。
2.4 本章小结
本章介绍了C程序中的预处理阶段,强调了预处理的概念和其在编译运行过程中的作用。预处理作为编译的首要步骤,生成的.i文件展示了原始源文件中的代码,还包括了通过头文件包含等操作引入的额外内容。通过查看.i文件,读者能更直观地感受到预处理前后源文件的变化,理解了预处理器对代码的处理过程,为后续编译和运行阶段奠定了基础。
3 编译
3.1 编译的概念与作用
在编译过程中,编译器接手经过预处理的源代码,并对代码进行深入的分析和转换。这一阶段包括对代码的语法和语义进行严格的检查,确保代码遵循语言的规则,并且逻辑上是合理的。编译器将源代码翻译成一种中间表示形式,这种形式通常是平台无关的,它简化了后续的处理步骤。
编译阶段的主要目的是生成汇编语言文件,这些文件是机器代码的直接前身,它们为程序的最终构建阶段——汇编和链接——奠定了基础。汇编语言文件包含了与源代码等效的机器指令,但仍然保持了人类可读的形式。
编译阶段的重要性不仅体现在确保代码的正确性和可维护性上,而且还在于它可以通过各种优化技术来提升程序的执行效率。这些优化可能包括但不限于常数折叠、循环展开、公共子表达式消除等,它们旨在减少程序的大小和运行时间,同时保证程序的行为不变。
此外,编译阶段还负责管理程序的内存布局和访问控制,确保程序在运行时能够高效地使用系统资源。通过编译器的这些复杂转换,程序员用高级语言编写的代码最终可以被转换成计算机能够理解和执行的机器指令。
3.2 Ubuntu下编译的命令
在Ubuntu下,编译C源文件的命令通常是使用gcc
编译器提供的-S
选项。
对于预处理后的hello.i
的文件,可以使用以下命令来编译它:
gcc -S hello.i -o hello.s
这里,-S
选项表示进行编译,hello.i
是输入的源文件,-o hello.s
指定了编译后的输出文件名。输出文件通常以.s
作为扩展名,表示它是一个编译后的汇编文件。
3.3 Hello的编译结果解析
3.3.1 文件总览
.file "hello.c"
:这是源文件名,用于调试和错误报告。.text
:这是一个段定义,表示接下来的代码是程序的代码部分。.section .rodata
:这是另一个段定义,表示接下来的数据是只读数据(如字符串常量)。.align 8
:这是一个对齐指令,表示接下来的数据应该在8字节边界上开始。.LC0:
和.LC1:
:这是两个标签,用于在其他地方引用这里的数据。.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"
和.string "Hello %s %s %s\n"
:这是两个字符串常量,它们在程序中被打印出来。.text
:这是另一个段定义,表示接下来的代码是程序的代码部分。.globl main
:这是一个全局符号定义,表示main
是一个可以在其他文件中引用的符号。.type main, @function
:这是一个类型声明,表示main
是一个函数。
.LFE6:
:这是一个局部标签,通常用于内部使用,不会出现在最终的目标文件中。.size main, .-main
:这是一个指示符,表示main
函数的大小。这里的.-main
表示从当前位置到main
标签的距离,也就是main
函数的大小。.ident "GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0"
:这是一个标识符,表示这个文件是由哪个版本的GCC编译器在哪个平台上编译的。.section .note.GNU-stack,"",@progbits
:这是一个特殊的节,用于指示这个程序是否需要一个可执行的栈。如果这个节不存在,那么链接器会假设这个程序需要一个可执行的栈。.section .note.gnu.property,"a"
:这是另一个特殊的节,用于存储一些GNU特有的属性。.align 8
:这是一个对齐指令,表示接下来的数据应该在8字节边界上开始。.long 1f - 0f
,.long 4f - 1f
,.long 5
,0:
,.string "GNU"
,1:
,.align 8
,.long 0xc0000002
,.long 3f - 2f
,2:
,.long 0x3
,3:
,.align 8
,4:
:这些都是.note.gnu.property
节的内容,用于存储一些GNU特有的属性。
这个文件的内容主要是 hello.c
文件编译后的汇编代码,以及一些用于调试和错误报告的信息。
3.3.2 字符串
3.3.2.1 字符串常量
在hello.s文件中,字符串常量被存储在.rodata(只读数据)段,这是为了确保这些数据在程序执行过程中不会被修改。以.LC0为例,.LC0(%rip)是一个内存地址表达式,它指的是相对于当前指令地址(由指令指针寄存器%rip的值给出)的偏移量为.LC0的数据地址。在此上下文中,这条指令的作用是将位于.LC0的字符串的内存地址加载到%rdi寄存器中,为即将到来的函数调用准备好参数传递。
其中.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",对应.c文件中"用法: Hello 学号 姓名 手机号 秒数!\n",其中中文已被编码为UTF-8 格式 一个汉字占3个字节
3.3.2.2 字符串数组
char* argc[]这是一个指针数组,由addq $, %rax可以看出,一个内容占8个字节,说明linux中一个地址的大小是8个字节。
3.3.3 整数
3.3.3.1 int i (局部变量)
根据movl $0, -4(%rbp) 可以看出编译器将i存到了-4(%rbp) 中,且占4个字节。
3.3.3.2 int argc (函数参数)
作为第一个参数被压栈pushq %rbp,传入main函数。
3.3.3.3 立即数
程序中其他整型都是以立即数的形式出现,在汇编代码中通常以十六进制的方式显示。
3.3.4 赋值
赋值操作通过数据传送指令来进行,主要是MOV类指令,包括movb,movw,movl,movq 这里表示将立即数0x0给%eax
3.3.5 算数操作
算术操作主要涉及到寄存器之间的数据传递和基本的操作。
在汇编语言中,算术操作通常直接对应于处理器提供的指令集。常见的算术操作包括:
- 加法 (Addition): 将两个数值相加。在x86汇编中,使用
add
指令。add destination, source
- 减法 (Subtraction): 从一个数值中减去另一个数值。在x86汇编中,使用
sub
指令。sub destination, source
- 乘法 (Multiplication): 将两个数值相乘。在x86汇编中,使用
imul
指令。imul destination, source
- 除法 (Division): 将一个数值除以另一个数值。在x86汇编中,使用
idiv
指令。
注意:idiv source
idiv
指令将寄存器中的数值除以源操作数,并且要求被除数必须在累加器(AL/AX/EAX/RAX)中。 - 增量 (Increment): 将数值加1。在x86汇编中,使用
inc
指令。inc destination
- 减量 (Decrement): 将数值减1。在x86汇编中,使用
dec
指令。dec destination
- 逻辑左移 (Logical Shift Left): 将二进制数向左移动指定的位数,并在右侧补零。在x86汇编中,使用
shl
或sal
指令。shl destination, count
- 逻辑右移 (Logical Shift Right): 将二进制数向右移动指定的位数,并在左侧补零。在x86汇编中,使用
shr
指令。shr destination, count
- 算术右移 (Arithmetic Shift Right): 将二进制数向右移动指定的位数,并在左侧补充最高位(符号位)的值。在x86汇编中,使用
sar
指令。sar destination, count
- 循环移位 (Rotate): 将二进制数向左或向右循环移动指定的位数。在x86汇编中,使用
rol
,ror
,rcr
,rcl
等指令。rol destination, count ror destination, count
- 取反 (Negation): 对一个数值取反。在x86汇编中,使用
neg
指令。neg destination
- 按位与 (Bitwise AND): 对两个数值进行按位与操作。在x86汇编中,使用
and
指令。and destination, source
- 按位或 (Bitwise OR): 对两个数值进行按位或操作。在x86汇编中,使用
or
指令。or destination, source
- 按位异或 (Bitwise XOR): 对两个数值进行按位异或操作。在x86汇编中,使用
xor
指令。xor destination, source
- 乘加 (Multiply and Add): 将两个数值相乘,然后将结果加到另一个数值上。在x86汇编中,使用
mul
和add
指令组合。imul destination, source add destination, another_source
不同的处理器架构可能会有不同的指令集和操作。
3.3.6 关系操作
主要包含两种相关指令:
test
指令:
test
指令执行位逻辑与(AND)操作,但并不保留操作结果,只是更新处理器的标志寄存器。这个指令通常用于检查寄存器或内存中的位模式,不需要保留操作结果,只关注零标志(ZF)和符号标志(SF)的状态。
cmp
指令:
cmp
指令用于对两个操作数进行比较,但同样不保留比较结果,只是更新处理器的标志寄存器。这个指令通过执行减法操作来进行比较,但并不保留减法的结果,只更新标志寄存器。
在这段代码中,比较的是 %rbp-4
的地址和立即数7,如果满足 jle=1
的条件,则会发生跳转。
3.3.7 条件分支
实现条件分支的关键在于关系操作和跳转指令的配合使用。首先,通过关系操作对条件进行判断,并设置相应的标志位。然后,根据标志位的状态决定是否执行跳转,从而转到代码的另一部分。这里的操作等同于源代码中的 if(argc!=4)
语句,如果条件满足(即相等),则跳转到另一段代码继续执行。
3.3.8 循环
循环的实现也依赖于关系操作和跳转指令。如上图所示,在 L3
的前两行进行了一个条件判断,这对应于源代码中的 i<8
。如果满足条件,就会跳转到 L4
,这里的 L4
对应于循环体。当再次执行到 L3
时,会重新进行条件判断。这与 do-while
语句的区别在于,for
循环和 while
循环在进入循环体之前,需要先进行条件判断。
3.4 本章小结
在汇编语言的层面上,程序通过使用寄存器和栈来管理局部变量和处理函数调用的细节。寄存器用于快速存储和访问数据,而栈则提供了一个后进先出(LIFO)的数据结构,用于存储局部变量、函数参数和返回地址。汇编代码中的条件跳转指令,如je
(等于时跳转)和jle
(小于或等于时跳转),是实现循环和控制程序流程的关键。这些指令根据特定的条件来决定程序的控制流,从而实现循环结构。
函数调用是通过call
指令来实现的,该指令将下一条指令的地址压入栈中,然后跳转到指定的函数地址。这允许函数执行完成后能够返回到调用点。函数的返回则由ret
指令完成,它从栈中弹出返回地址,并将控制权转移回该地址。
汇编代码的这段示例不仅展示了如何使用分支判断、循环和字符串处理,而且还深入揭示了在底层如何实现这些高级编程结构。通过汇编语言,我们可以直接看到函数调用的细节,包括参数的传递、栈的帧结构变化,以及条件分支的执行过程。这种底层视角对于理解程序执行的原理、性能优化和调试是非常重要的。
4 汇编
4.1 汇编的概念与作用
汇编是一种将人类可读的汇编语言代码转换为机器语言代码的过程。在这里,我们特指的是从 .s
文件(包含汇编语言代码的文件)到 .o
文件(包含机器语言代码的目标文件)的转换过程。
汇编的主要作用是为了让程序员能够以更接近硬件的方式编写代码,同时还保持了一定的可读性。通过汇编,程序员可以直接操作处理器的寄存器,控制内存,甚至直接插入机器语言指令。这种能力使得汇编语言在性能优化、嵌入式系统编程、驱动开发等领域具有重要的应用。
同时,理解汇编语言也有助于更深入地理解计算机系统的工作原理,包括处理器架构、内存管理、函数调用约定等方面的知识。这对于深入理解高级语言的运行机制、进行底层优化、甚至进行逆向工程等工作都是非常有帮助的。
4.2 在Ubuntu下汇编的命令
在Ubuntu系统下,我们可以使用GNU汇编器(as)来进行汇编。以下是一个简单的汇编过程:
使用 as
命令或gcc
命令进行汇编:
as -o hello.o hello.s
这条命令会将 hello.s
文件汇编为 hello.o
目标文件。
4.3 可重定位目标elf格式
ELF(Executable and Linkable Format)是一种常见的二进制文件格式,用于存储程序或库。hello.o
就是一个ELF格式的可重定位目标文件。
我们可以使用 readelf
命令来查看ELF文件的详细信息。
4.3.1 ELF头
使用指令:readelf -h hello.o
查看ELF头
在这个ELF头信息中,有几个重要的信息值得我们注意:
-
类别(Class):这里的
ELF64
表示这是一个64位的ELF文件。这对于理解程序如何在内存中布局和执行是重要的。 -
数据(Data):这里的
2's complement, little endian
表示这个文件使用的是小端序的二进制补码格式。这对于理解如何解析文件中的二进制数据是重要的。 -
类型(Type):这里的
REL (Relocatable file)
表示这是一个可重定位的文件。这意味着这个文件包含的代码和数据可以在加载到内存时被放置到任何位置。 -
系统架构(Machine):这里的
Advanced Micro Devices X86-64
表示这个文件是为AMD的X86-64架构编译的。这对于理解程序如何在特定的硬件上执行是重要的。 -
入口点地址(Entry point address):这里的
0x0
表示程序的入口点(即程序开始执行的地方)在内存中的地址。对于可重定位的文件,这通常是0,因为实际的地址将在链接时确定。 -
节头起点(Start of section headers):这里的
1088 (bytes into file)
表示节头在文件中的位置。节头包含了文件中各个节的信息,如名称、大小、位置等。 -
节头数量(Number of section headers):这里的
14
表示文件中有14个节。这些节包含了程序的代码、数据、符号表等信息。 -
节头字符串表索引(Section header string table index):这里的
13
表示节头字符串表在节头列表中的索引。节头字符串表包含了节的名称。
4.3.2 节头
readelf -S hello.o
这将显示文件中的所有节,包括它们的名称、大小、地址等信息。
4.3.3 符号表
readelf -s hello.o
4.3.4 重定位段
对于重定位条目,我们可以使用以下命令查看:
readelf -r hello.o
这将列出所有的重定位条目,包括它们的偏移量、信息、类型和符号。
重定位是链接过程的一部分,用于更新符号引用的地址。每个重定位条目都对应一个需要更新的地址。
在这个例子中,我们有两个重定位节:.rela.text
和 .rela.eh_frame
。
.rela.text节
.rela.text
节包含8个条目,这些条目主要与程序的代码部分有关。每个条目都包含以下信息:
- 偏移量:这是需要重定位的地址在节中的偏移量。
- 信息:这包含了重定位的类型和相关的符号表索引。
- 类型:这是重定位的类型,例如
R_X86_64_PC32
和R_X86_64_PLT32
。这些类型描述了如何计算和应用重定位。 - 符号值:这是符号的当前值。对于未解析的符号,这通常为0。
- 符号名称 + 加数:这是符号的名称,以及一个可选的加数。加数会被添加到符号的值中。
例如,第一个条目表示在 .text
节的偏移量 0x1c
处有一个类型为 R_X86_64_PC32
的重定位,它引用了 .rodata
符号,并且加数为 -4
。
.rela.eh_frame节
.rela.eh_frame
节包含1个条目,这个条目与异常处理框架(eh_frame)有关。这个条目表示在 .eh_frame
节的偏移量 0x20
处有一个类型为 R_X86_64_PC32
的重定位,它引用了 .text
符号,并且加数为 0
。
这些重定位条目在链接过程中会被处理,以确保所有的符号引用都指向正确的地址。
通过分析这些信息,我们可以了解到更多关于程序结构和链接过程的细节。
4.4 Hello.o的结果解析
在分析 hello.o
的反汇编结果时,我们需要注意以下几点:
-
机器语言与汇编语言的映射关系:机器语言是计算机可以直接理解和执行的二进制代码,而汇编语言是一种更接近人类语言的低级编程语言。每条汇编指令都对应一条或多条机器语言指令。例如,汇编指令
mov eax, 1
可能对应机器语言指令B8 01 00 00 00
(在 x86 架构上)。 -
操作数的不一致:在汇编语言中,操作数通常是明确的值或者寄存器。然而,在机器语言中,操作数可能是相对于当前指令的偏移量,或者是一个内存地址。例如,汇编指令
jmp label
可能对应机器语言指令E9 XX XX XX XX
,其中XX XX XX XX
是从当前指令到label
的相对偏移量。 -
分支和函数调用:在汇编语言中,分支和函数调用通常使用标签来表示目标地址。然而,在机器语言中,这些目标地址通常被替换为相对于当前指令的偏移量(对于分支指令)或者绝对地址(对于函数调用指令)。例如,汇编指令
call func
可能对应机器语言指令E8 XX XX XX XX
,其中XX XX XX XX
是func
的绝对地址。
在分析 objdump -d -r hello.o
的输出时,可以看到这些映射关系。例如,类似 E8 XX XX XX XX
的机器语言指令,以及对应的汇编语言指令 call func
。同时,也可以看到重定位条目,这些条目表示链接器需要更新哪些地址。
通过对比 hello.o
的反汇编结果和 hello.s
,我们可以更深入地理解汇编语言和机器语言之间的关系,以及链接过程中发生的事情。
4.5 本章小结
在本章中,我们深入探讨了汇编语言的概念,以及它在文件转换过程中的作用。通过实践操作,我们完成了对hello.s文件的汇编过程,成功地生成了ELF(Executable and Linkable Format)格式的可重定位目标文件hello.o。为了进一步理解这个目标文件的结构和内容,我们使用了readelf工具来检视其ELF头部、节头部表、重定位条目以及符号表等关键组成部分。通过对这些信息的细致分析,读者不仅能够掌握可重定位目标文件的组织方式,还能够洞察到机器语言与汇编语言之间的直接映射关系。
最后,我们将生成的hello.o文件与初始的hello.s文件进行了对比分析。这种比较揭示了机器级指令与汇编指令之间的精确对应,从而加深了读者对汇编语言如何转化为机器语言的理解。通过这种实践学习,读者能够更加清晰地认识到汇编语言作为程序设计与机器语言之间的桥梁作用,为进一步的程序优化和性能分析奠定了坚实的基础。
5 链接
5.1 链接的概念与作用
链接是将一个或多个对象文件(如 hello.o
)和库文件组合成一个单一的可执行文件(如 hello
)的过程。链接器(linker)是执行这个过程的工具。
链接的主要作用包括:
-
符号解析:链接器将所有的符号引用与符号定义关联起来。例如,如果一个对象文件中的函数引用了另一个对象文件中定义的函数,链接器会更新这个引用,使其指向正确的地址。
-
地址分配:链接器为每个节(section)和符号分配运行时的内存地址。例如,链接器可能会将
.text
节放在内存的某个位置,然后将.data
节放在.text
节后面的位置。 -
重定位:链接器根据分配的地址更新所有的符号引用和重定位条目。例如,如果一个函数的地址被分配到了
0x1000
,那么链接器会将所有引用这个函数的指令更新为0x1000
。 -
合并节:链接器将同名的节合并到一起。例如,如果两个对象文件都有
.text
节,那么链接器会将这两个.text
节合并成一个。
通过链接过程,我们可以将多个对象文件和库文件组合成一个单一的可执行文件,这个文件可以被操作系统加载并执行。
5.2 在Ubuntu下链接的命令
在 Ubuntu 下,可以使用 ld 命令来链接对象文件:
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
这个命令将 hello.o 链接为一个名为 hello 的可执行文件。
ld:这是链接器的命令。
- -dynamic-linker /lib64/ld-linux-x86-64.so.2:这个选项指定了动态链接器的路径。动态链接器是在运行时加载和链接动态库的程序。
- -o hello:这个选项指定了输出文件的名称,也就是最终的可执行文件。
- /usr/lib/x86_64-linux-gnu/crt1.o、/usr/lib/x86_64-linux-gnu/crti.o、/usr/lib/gcc/x86_64-linux-gnu/9/crtbegin.o:这些是 C 运行时库的一部分,它们包含了程序启动和结束时需要执行的代码。
- hello.o:程序的对象文件。
- -lc:这个选项告诉链接器链接 C 标准库(libc)。
- /usr/lib/x86_64-linux-gnu/crtn.o:这是 C 运行时库的一部分,包含了程序结束时需要执行的代码。
5.3 可执行目标文件hello的格式
查看ELF头
查看节表头:
查看程序头表:
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息
5.5 链接的重定位过程分析
使用指令:objdump -d -r hello对hello进行反汇编
以main函数的第一个PC32寻址方式为例,根据节头表信息,查询到.rodata节地址为0x402000,计算0x402000 + 0x4 - 0x401141,获取偏移地址为0xec3。
5.6 hello的执行流程
名称 | 地址 |
---|---|
ld-2.27.so!dl start | 0x7ffff17d27a0 |
ld-2.27.so!dl init | 0x7f9e58629630 |
hello!_start | 0x400500 |
ld-2.27.so!_libc_start_main | 0x7f9f48249ab0 |
libc-2.27.so!cxa atexit | 0x7f4523fd3af7 |
hello!libc csu init | 0x7f87ffe13e26 |
hello main | 0x400530 |
hello!puts@plt | 0x400621 |
hello!exit@plt | 0x40062b |
hello!printf@plt | 0x400665 |
hello!sleep@plt | 0x400669 |
hello!getchar@plt | 0x400678 |
libc-2.27.so!exit | 0x7fce8c888134 |
5.7 Hello的动态链接分析
动态链接是Linux系统中一种将程序模块化并在运行时动态组合的机制。在这种机制下,程序被分割成多个模块,这些模块在程序运行时才会被链接起来,形成一个完整的程序实体。
在动态共享链接库中,位置无关代码(PIC)函数的地址在编译时是无法预知的。因此,编译器会为这些函数生成重定位记录,以便动态链接器在程序加载时能够正确地定位这些函数。为了不在程序运行时修改代码段,动态链接器采用了延迟绑定策略,通过过程链接表(PLT)和全局偏移量表(GOT)来实现。
在具体的实现中,GOT负责存储目标函数的地址,而PLT则通过GOT中的地址来实现对目标函数的跳转。通过分析hello程序的.got和.got.plt段,我们可以看到.got段的起始地址为0x403ff0,而.got.plt段的起始地址为0x404000。
在程序运行之前,.got段中的内容是预设的,其中包含了对PLT的引用。当程序运行并调用动态链接初始化函数dl_init后,.got段中的内容会发生变化,这表明动态链接过程已经完成,GOT中的地址已经被修正为正确的函数地址。
通过这种方式,程序可以在运行时动态地链接库函数,提高了程序的灵活性和模块化程度。同时,延迟绑定的策略也提高了程序的运行效率,因为在第一次调用函数时才会进行实际的地址绑定,减少了程序启动时的开销。
5.8 本章小结
本章系统地整理了程序链接和执行过程,详细分析了 “hello” 程序的 ELF 文件内容和其虚拟地址空间。我们通过符号解析和重定位,将多个可重定位的目标文件组合成一个可执行文件。在程序执行过程中,当调用共享库函数时,由于定义这些函数的共享模块可以在运行时加载到任意位置,因此我们需要进行动态链接。
第6章 hello进程管理
6.1 进程的概念与作用
进程是操作系统进行资源分配和调度的基本单位。它是一个具有一定独立功能的程序,关于某个数据集合的一次运行活动。每个进程都有自己的独立内存空间和系统资源。进程可以创建和终止,也可以被暂停和恢复。进程之间可以通过各种机制进行通信。
6.2 简述壳Shell-bash的作用与处理流程
Shell 是一个命令行解释器,它为用户提供了一个向操作系统发送请求以便运行程序的高级接口。bash 是 Bourne Again SHell 的缩写,是一个由 GNU 项目为 GNU 操作系统开发的一个 Shell 程序。bash 读取用户输入的命令,然后执行这些命令。
bash 的处理流程大致如下:
- 启动:当用户打开一个终端时,bash 会被启动。
- 读取输入:bash 会在命令提示符下等待用户输入命令。
- 解析命令:bash 会解析用户输入的命令,确定要执行的命令和参数。
- 执行命令:bash 会执行解析后的命令。如果命令是内建的,bash 会自己执行;如果命令是外部的,bash 会启动一个新的进程来执行。
- 输出结果:bash 会显示命令的执行结果。
- 回到第2步,等待下一个命令。
6.3 Hello的fork进程创建过程
当执行 hello
命令时,bash 首先解析这条命令。它发现 ./hello
不是 bash 的内置命令,于是在当前目录下寻找并尝试执行名为 hello
的文件。在这个过程中,bash 通过调用 fork
函数创建了一个子进程。
子进程是父进程的一个近似副本,它继承了父进程的许多属性,包括文件描述符、环境变量等。同时,子进程获得了一份与父进程用户级虚拟空间相同但是独立的副本,这包括数据段、代码段、共享库、堆和用户栈。尽管子进程的内容与父进程相似,但是它们是完全独立的,对一个进程的修改不会影响到另一个进程。
在创建子进程后,fork
函数会在父进程和子进程中各返回一次。在父进程中,fork
返回新创建的子进程的 PID;在子进程中,fork
返回 0。这样,父进程就可以通过返回的 PID 来控制和监视子进程,而子进程则可以通过返回值是否为 0 来判断自己是不是子进程。
6.4 Hello的execve过程
execve
是一个用于执行特定文件的系统调用。它的函数原型为:
int execve(const char *filename, const char *argv[], const char *envp[]);
execve
执行的文件路径由参数 filename
指定。第二个参数 argv
是一个指针数组,用于传递给执行文件的参数,需要以空指针 NULL
结束。最后一个参数 envp
是一个环境变量数组,其中每个元素都是一个形如 name=value
的环境变量字符串。
当 execve
加载了 filename
指定的文件后,它会调用启动代码,启动代码设置栈,然后将控制权传递给新程序的 main
函数。如果出现错误,例如找不到 filename
指定的文件,execve
会返回到调用程序。这与 fork
不同,fork
调用一次会返回两次,而 execve
调用一次并且不会返回。
execve
执行 hello
的过程可以分为以下四个步骤:
-
删除已存在的用户区域:
execve
会删除当前进程虚拟地址的用户部分中已存在的区域结构,即删除之前 shell 运行时已经存在的区域结构。 -
映射私有区域:
execve
为hello
的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello
文件中的.text
和.data
区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello
中。栈和堆区域也是请求二进制零的,初始长度为零。 -
映射共享区域:如果
hello
程序与共享对象(例如标准 C 库libc.so
)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。 -
设置程序计数器(PC):最后,
execve
设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
6.5 Hello的进程执行
当 execve
完成 hello
程序的加载和设置后,hello
进程就准备好被执行了。以下是 hello
进程执行的过程:
-
进程调度:操作系统的调度器负责决定哪个进程应该被执行。它会根据各种因素(如优先级、进程状态、CPU亲和性等)来选择一个进程。在这个例子中,假设
hello
进程被选中并获得了 CPU 的执行时间。 -
进程上下文切换:当
hello
进程被选中执行时,操作系统会进行进程上下文切换。这包括保存当前运行进程的状态(如寄存器值、程序计数器等),然后加载hello
进程的上下文(即它在上次运行时保存的状态)。 -
用户态与核心态转换:进程在执行过程中,可能会频繁地在用户态和核心态之间转换。用户态是进程执行用户级指令的模式,而核心态则允许进程执行内核级指令和访问受保护的内核资源。当
hello
进程需要进行如 I/O 操作、创建新进程等系统调用时,它需要切换到核心态。 -
执行:在加载了
hello
进程的上下文并切换到用户态后,hello
进程开始执行。它从程序计数器指向的位置开始执行指令。在hello
程序中,首先检查输入参数的数量,如果参数数量不为5,程序会打印错误信息并退出。如果参数数量正确,程序会进入一个循环,循环10次,每次打印一条包含输入参数的信息,并暂停一段由输入参数指定的时间。 -
时间片用尽或阻塞:
hello
进程会一直执行,直到它的时间片用尽或者它因等待 I/O 操作等原因而阻塞。在这两种情况下,操作系统会再次进行进程调度,选择另一个进程执行,并保存hello
进程的上下文以便之后恢复执行。
以上就是 hello
进程从被调度到执行的整个过程。
6.6 hello的异常与信号处理
hello程序执行过程中出现的异常可能有中断、陷阱、故障、终止等。
hello具体运行过程中,可能产生SIGINT、SIGKILL、SIGSEGV、SIALARM、SIGCHLD等信号,具体处理如下:
-
Ctrl+Z:进程收到SIGSTP信号,hello停止,此时进程并未回收,而是后台运行,通过ps指令可以对其进行查看,还可以通过fg指令将其调回前台。
-
Ctrl+C:进程收到SIGINT信号,hello终止。在ps中查询不到此进程及其PID,在jobs中也没有显示。
-
中途乱按:将屏幕的输入显示在输出上
-
kill命令:挂起的进程被终止,在ps中无法查到到其PID。
-
pstree命令:用树状图显示所有进程结构。
6.7 本章小结
本章主要介绍了进程和Bash-Shell的基础知识和功能。我们以hello程序为实例,详细探讨了其如何使用fork来创建子进程,以及如何利用execve来加载和执行用户程序。最后,我们结合实际操作,深入分析了hello程序对异常和信号处理的方式。
第7章 hello的存储管理
7.1 hello程序的存储器地址空间解析
在计算机科学中,存储器地址空间是指存储器中可以用来寻址的位置的集合。对于程序hello.c,其存储器地址空间可以从四个不同的角度来理解:逻辑地址、线性地址、虚拟地址和物理地址。
- 逻辑地址:这是由程序产生的与段相关的偏移地址部分。在汇编过程中,hello.c程序产生的偏移地址即为逻辑地址。例如,在C语言中,当我们获取一个指针变量的地址时(使用&操作符),实际上获取的就是这个变量的逻辑地址。
- 线性地址:线性地址是逻辑地址到物理地址转换过程中的中间层。在hello程序中,代码产生的段内偏移地址与相应段的基地址相加,就得到了一个线性地址。
- 虚拟地址:中央处理器(CPU)通过生成虚拟地址来访问主存储器。有时,逻辑地址也被称作虚拟地址,因为它们与虚拟内存空间的概念相似。逻辑地址与实际的物理内存容量无关,是hello程序中的虚拟地址。
- 物理地址:物理地址是指放置在寻址总线上的地址。当进行读取操作时,电路会根据这个地址的每一位值,从相应地址的物理内存中取出数据并放置到数据总线上进行传输。当进行写入操作时,电路会根据这个地址的每一位值,将数据总线上的内容写入到相应地址的物理内存中。物理内存是以字节(8位)为单位进行编址的。
7.2 Intel逻辑地址到线性地址的变换:段式管理
在Intel处理器的段式管理中,程序的存储空间被划分为多个逻辑段,例如代码段、数据段和堆栈段等。每个段都是一个独立的逻辑实体,它们通过段表进行管理,段表包含了诸如段号(或段名)、段起点、装入位和段长度等信息。
逻辑地址由两部分组成:段标识符和段内偏移量。段标识符,也称为段选择符,是一个16位的字段,其中前13位用于索引段描述符表中的段描述符,后3位包含了一些硬件相关的信息。段描述符是一个8字节长的数据结构,它详细描述了一个段的各种属性,包括段的基地址(Base)、限制(Limit)和其他的访问控制信息。
段描述符可以存在于全局描述符表(GDT)或局部描述符表(LDT)中。GDT是整个系统共享的,包含了操作系统使用的各种段的描述符以及各个任务或程序的LDT的描述符。每个任务或程序都有自己的LDT,其中包含了私有的代码段、数据段和堆栈段的描述符,以及一些用于任务切换和控制转移的门描述符。
逻辑地址到线性地址的转换过程如下:
- 首先,检查段选择符中的TI字段,以确定段描述符保存在GDT还是LDT中。
- 由于段描述符是8字节长的,因此在GDT或LDT中的相对地址可以通过段选择符的前13位乘以8来计算得到。
- 一旦找到了正确的段描述符,就可以得到段的基地址(Base)。
- 将基地址(Base)与段内偏移量相加,得到最终的线性地址。
段式管理的优点在于它简化了编译、管理和维护工作,允许程序分段并且每个段可以独立于其他段进行加载和操作。然而,这种管理方式可能会导致内存碎片和浪费,因为每个段都需要分配一个连续的内存区域,而这些区域之间可能存在未使用的空间。
上图展示了段式管理的概念和逻辑地址到线性地址的转换过程。通过图示可以更直观地理解段式存储管理的组织和地址转换的步骤。
7.3 Hello程序的线性地址到物理地址的变换:页式管理
在计算机操作系统中,页式管理是一种内存管理技术,它将虚拟内存和物理内存分割成固定大小的页。虚拟内存是由存放在磁盘上的N个连续的字节大小单元组成的数组,而物理内存也同样被分割成物理页。操作系统通过页表来管理这些虚拟页和物理页之间的映射关系。
每个页表条目(PTE)包含了有效位和地址字段。有效位表示对应的虚拟页是否被加载到物理内存(DRAM)中。如果有效位被设置,地址字段就指出了DRAM中相应物理页的起始位置。如果发生缺页,即需要的页不在物理内存中,操作系统会从磁盘加载该页到内存。
在页式管理中,地址结构分为两部分:虚拟页号(VPN)和虚拟页偏移量(VPO)。虚拟页号用于在页表中查找对应的页表项,而页表项包含了物理页号和访问权限等信息。通过将物理页号和页内偏移量组合,就可以得到最终的物理地址。
Intel处理器中,线性地址到物理地址的映射通常涉及多级页表。例如,使用10-10-12的页表结构,意味着线性地址被分为三个部分:前10位用于索引一级页表,接下来的10位用于索引二级页表,最后的12位作为页内偏移量。每个页表项通常是4字节(32位)大小,这样一级页表可以索引4KB(2^12)的地址空间,二级页表同理。
页式管理提供了高效的内存使用方式,通过请求调页或预调页技术,实现了虚拟内存和物理内存之间的统一管理。这种方式简化了内存分配和回收过程,减少了内存碎片,并且允许更灵活的内存分配策略。
页式管理通过将虚拟地址空间和物理内存划分为等长的页面,并使用页表来管理这些页之间的映射,从而实现了从线性地址到物理地址的高效转换。
7.4 TLB与四级页表支持下的VA到PA的变换
在计算机架构中,TLB(Translation Lookaside Buffer)是一个关键组件,它作为虚拟地址到物理地址映射的高速缓存,显著提高了地址转换的效率。当中央处理器(CPU)访问一个虚拟地址(VA)时,它首先会在TLB中查找,以确定是否存在对应的物理地址(PA)。如果查找命中(TLB hit),则可以直接获取物理地址,从而避免复杂的页表查询过程;如果未命中(TLB miss),则需要通过页表来进行地址转换。
在四级页表的支持下,虚拟地址被分为多个部分,包括页目录项索引、页表项索引和页内偏移量。这些部分用于在多级页表结构中进行遍历,以找到对应的物理地址。四级页表结构意味着虚拟地址转换过程涉及四个级别的页目录表和页表,每个级别都会将虚拟地址转换得更接近最终的物理地址。
如果TLB中未命中,系统将触发一次缺页异常(page fault),此时操作系统内核会介入,处理这一异常情况,并将缺失的页表项加载到TLB中,以便后续的访问能够更快地完成地址转换。
TLB和四级页表的结合使用,大大提高了地址翻译的效率,减少了内存访问的次数,从而提升了整体系统的性能。这种机制在处理大量内存地址转换时尤为重要,因为它减少了CPU在地址转换上的等待时间,使得CPU可以更高效地执行其他任务。
TLB和四级页表的机制,通过缓存和分层地址转换,优化了虚拟地址到物理地址的映射过程,对于现代操作系统的性能至关重要。
7.5 三级Cache支持下的物理内存访问
在现代计算机系统中,为了提高内存访问的速度和效率,采用了多级缓存体系,其中三级缓存(L1、L2和L3)是常见的架构。每一级缓存都具有不同的容量和访问速度,它们按照接近CPU的顺序排列,形成了存储层次结构。
当CPU需要访问内存数据时,首先在L1缓存中查找。得到物理地址PA之后,根据cache大小组数的要求,将PA拆分成CT(标记)、CI(索引)、CO(偏移量),用CI位进行索引,如果匹配成功且valid值为1,则为命中,根据偏移量在L1cache中取数。如果L1缓存中未命中,CPU将继续在下一级缓存(L2缓存)中进行查找。如果仍未命中,CPU会继续在L3缓存中查找。如果在L3缓存中也未命中,CPU最终将从主存中获取数据。
在三级缓存中找到所需数据的情况称为“缓存命中”,这时CPU可以直接从缓存中快速访问数据。如果数据不在缓存中,这种情况称为“缓存不命中”,此时CPU需要从主存中获取数据,并将数据存储到缓存中,以便后续的访问能够更快地进行。
缓存的作用是在快速但容量较小的存储(缓存)和较慢但容量较大的存储(主存)之间提供一个缓冲区,从而提高数据访问的效率。缓存命中可以显著加快程序的执行速度,而缓存不命中则会增加访问时间,影响系统性能。
在缓存中,数据的替换策略(如最近最少使用LRU)用于决定当缓存满了时,哪些数据应该被替换。这些策略旨在最大化缓存的利用率,并减少缓存不命中的次数。
三级缓存支持下的物理内存访问,通过在CPU和主存之间引入多级缓存,显著提高了数据访问的速度和效率,是现代计算机系统中提高性能的关键技术之一。
7.6 hello进程fork时的内存映射
在Unix和类Unix操作系统中,当进程调用fork系统调用创建一个新的子进程时,操作系统内核会为新进程分配虚拟内存。这个过程涉及到对父进程内存描述符(mm_struct)、虚拟内存区域链表(vm_area_struct链表)以及页表的一个完整副本的创建。
在fork操作中,内核会将父进程和子进程的每个内存页面都标记为只读,以确保它们在初始时刻共享这些页面。同时,每个虚拟内存区域结构(vm_area_struct)也会被标记为私有的写时复制(Copy-On-Write,COW)。这意味着,当任一进程尝试写入这些页面时,系统会为该进程创建一个该页面的副本,然后执行写操作。
当子进程从fork函数中返回时,它拥有与父进程完全相同的虚拟内存映射。然而,随后的写操作将触发写时复制机制,在这种机制下,内核会复制要修改的内存页面,从而实现父子进程在内存空间上的有效分离。这样,每个进程都能够拥有其私有的虚拟地址空间,独立进行操作,而不影响另一个进程的内存内容。
hello进程在调用fork时的内存映射,是通过创建父进程内存结构的副本,并使用写时复制技术,使得子进程能够高效地共享父进程的内存空间,同时在需要时保持内存空间的独立性。这种机制既提高了内存使用的效率,又保证了进程间的隔离性。
7.7 hello进程execve时的内存映射
当执行execve系统调用以启动新程序hello时,操作系统内核会执行一系列步骤来为新程序设置内存映射。这个过程涉及到删除旧程序的内存映射,并为新程序创建新的映射。
- 删除现有内存映射:execve会删除当前进程的用户部分中的所有已存在的虚拟内存区域结构,包括页表和vm_area_struct链表。这样做是为了确保新程序在一个干净的地址空间中运行,避免受到旧程序残留数据的影响。
- 创建新的私有区域:接下来,内核会为新程序的代码(.text)、数据(.data)、未初始化的数据(.bss)和栈区域创建新的虚拟内存区域结构。这些区域被标记为私有的写时复制(COW),并且代码和数据区域会映射到hello文件中的相应段。.bss和栈区域则是请求二进制零的,并映射到匿名文件。
- 创建新的共享区域:hello程序可能依赖于共享对象,如libc.so。这些共享库会被动态链接到程序中,并映射到用户虚拟地址空间的共享区域内。这样,多个进程可以共享这些库的同一份副本,减少内存的使用。
- 设置程序计数器:最后,execve会设置当前进程的上下文中的程序计数器(PC),使其指向新程序代码区域的入口点。这样新程序就可以开始执行了。
7.8 缺页故障与缺页中断处理
缺页故障是指当计算机系统试图访问一个虚拟地址上的数据时,发现该数据对应的物理地址并未加载到内存中,从而引发的一种异常情况。这种现象通常会导致系统执行中断服务程序以处理该异常。
在实际工作过程中,页面命中,即找到所需数据已经在内存中的过程,完全是由计算机硬件自动完成的。相反,处理缺页故障的任务则需要硬件和操作系统内核间的紧密协作。
当缺页故障发生时,处理器将通过页表查询发现所需页面不在内存中,进而触发缺页中断信号通知操作系统。操作系统内核接收到这个信号后,将启动相应的缺页处理程序(Page Fault Handler)。处理程序执行如下步骤:
-
检查虚拟地址是否合法:首先,缺页处理程序需要检查访问的虚拟地址是否合法。如果虚拟地址不合法,则触发一个段错误(Segmentation Fault),终止该进程。
-
检查访问权限:接下来,处理程序检查进程是否具有读、写或执行该区域页面的权限。如果进程不具备相应权限,则触发保护异常(Protection Fault),终止该进程。
-
选择牺牲页面:如果上述检查都通过,内核将选择一个牺牲页面(Victim Page),即需要腾出空间的页面。如果这个牺牲页面被修改过(即页面为“脏页”),系统需要将其内容写回到磁盘以防数据丢失。
-
加载新页面:接着,系统从磁盘读取所需的新页面并将其加载到内存中。
-
更新页表:在成功调入新页面后,缺页处理程序还需更新内存中的页表项(PTE),以使新页面的内存地址与虚拟地址正确映射。
-
恢复进程执行:完成上述所有步骤后,缺页处理程序将把控制权返回给最初发生缺页故障的进程。处理器会重新执行那条导致缺页故障的指令,从而完成本次内存访问操作。
通过上述步骤,系统能够顺利处理缺页故障,确保程序流畅执行。这样,硬件和操作系统内核的协作确保了虚拟内存系统的高效运行。
7.9动态存储分配管理
动态存储分配管理是指在程序运行期间,根据实际需求动态地分配和释放内存。动态内存管理在提高内存利用率和程序灵活性方面起着至关重要的作用。其基本方法与策略主要包括以下几个方面:
基本方法
动态内存管理通过维护一个虚拟内存区域“堆”来实现。分配器将堆视为一组不同大小的块的集合,每个块要么是已分配的,要么是空闲的。需要时,分配器选择一个合适的内存块进行分配。
内存分配与释放
-
内存分配:
- 内存分配通常通过调用诸如
malloc
(内存分配)、calloc
(清零内存并分配)和realloc
(重新分配)等库函数来实现。这些函数从堆空间中为进程分配所需大小的内存块,并返回该内存块的首地址。程序可以通过该地址来存取内存中的数据。 - 例如,
printf
函数在打印输出时,可能需要临时分配内存来格式化字符串,这就依赖于malloc
等动态内存管理函数来实现。
- 内存分配通常通过调用诸如
-
内存释放:
- 为了避免内存泄露,程序在不再需要某段动态分配的内存时,应该调用
free
函数来释放这段内存。释放内存后,使其可以被其他进程或进一步的内存分配请求重新使用。
- 为了避免内存泄露,程序在不再需要某段动态分配的内存时,应该调用
内存分配策略
内存分配器管理内存的策略通常包括以下几种:
-
首次适应(First Fit):
- 内存分配器从头开始遍历可用内存块链表,找到第一个满足要求的空闲内存块并进行分配。该方法简单、高效,但可能导致较多的小碎片。
-
下次适应(Next Fit):
- 类似于首次适应,不过每次从上一次分配结束的地方继续搜索。该方法有助于避免从头开始的搜索,可能提高效率。
-
最佳适应(Best Fit):
- 遍历所有空闲内存块,找到最小且满足需求的空闲块进行分配。该策略试图最小化剩余空闲空间,但可能导致较多的碎片问题。
-
最差适应(Worst Fit):
- 寻找最大的空闲块进行分配。该策略希望通过较少但较大的碎片来延长空闲内存块的使用时间。
内存管理策略
-
记录空闲块:
- 可以选择隐式空闲链表、显示空闲链表、分离的空闲链表和按块大小排序建立平衡树。
-
放置策略:
- 可以选择首次适配、下一次适配、最佳适配。
-
合并策略:
- 可以选择立即合并、延迟合并。
高级策略
-
伙伴系统(Buddy System):
- 通过将内存块划分为大小为2的幂次方的块,并在需要时合并或分割这些块来管理内存。
-
内存池(Memory Pool):
- 预先分配一大块内存,并在其中管理小块内存的分配和释放,以提高效率。
垃圾回收
现代操作系统还采用了垃圾回收(Garbage Collection)机制,它在后台自动检查和回收不再使用的内存,以避免内存泄露。垃圾回收在提高内存管理效率方面具有重要作用,但增加了系统开销,适用于某些特定的编程语言和环境。
通过上述方法和策略,动态存储分配管理能够有效地利用内存资源,避免内存泄漏和碎片化,提高程序的性能和可靠性。
7.10 本章小结
在本章中,我们深入探讨了存储器地址空间及其管理机制,这对于理解计算机系统的内存管理至关重要。我们详细介绍了不同类型的地址:逻辑地址、线性地址、虚拟地址和物理地址,以及它们在存储器中的作用和转换过程。
通过段式管理,我们学习了如何将逻辑地址转换为线性地址。这种转换过程涉及段选择符和段描述符,以及如何通过它们在段描述符表中找到段的基地址。此外,我们还了解了页式管理,它通过将逻辑地址转换为物理地址,实现了内存的高效利用和优化。
在本章中,我们还探讨了TLB(Translation Lookaside Buffer,翻译后备缓冲)和四级页表在地址转换中的作用,以及三级Cache在提高内存访问速度方面的贡献。这些机制共同工作,提高了系统的性能和效率。
我们还研究了进程在fork和execve系统调用时的内存映射机制,以及缺页故障的处理方法。这些内容帮助我们理解了操作系统如何有效地管理进程的内存资源,以及如何处理内存访问中的异常情况。
此外,我们讨论了动态存储分配管理,包括内存的申请、分配、使用和释放过程。我们了解了不同的内存分配策略和释放策略,以及它们如何影响内存的利用效率和程序的性能。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
在Linux系统中,所有的设备都被抽象为文件。这种设计理念使得设备的操作与普通文件的操作一致,简化了设备管理和编程接口。设备文件通常位于/dev
目录下,每个设备文件对应一个具体的硬件设备。设备文件分为两类:
- 字符设备:以字符为单位进行数据传输,例如键盘、串口等。
- 块设备:以块为单位进行数据传输,例如硬盘、光驱等。
通过将设备抽象为文件,Linux系统提供了一种统一的接口,使得对设备的操作可以通过标准的文件操作函数来完成。
设备管理:Unix IO接口
Linux系统通过Unix IO接口来管理设备。Unix IO接口提供了一组标准的系统调用,用于对文件和设备进行操作。这些系统调用包括打开、关闭、读写、控制等操作。通过这些接口,用户程序可以方便地与设备进行交互,而无需关心底层的硬件细节。
8.2 简述Unix IO接口及其函数
Unix IO接口提供了一组标准的系统调用,用于对文件和设备进行操作。以下是一些常用的Unix IO接口及其函数:
-
打开文件或设备:
int open(const char *pathname, int flags); int open(const char *pathname, int flags, mode_t mode);
pathname
:文件或设备的路径。flags
:打开文件的模式,如只读、只写、读写等。mode
:文件的权限(可选)。
-
关闭文件或设备:
int close(int fd);
fd
:文件描述符,表示要关闭的文件或设备。
-
读数据:
ssize_t read(int fd, void *buf, size_t count);
fd
:文件描述符。buf
:存储读取数据的缓冲区。count
:要读取的字节数。
-
写数据:
ssize_t write(int fd, const void *buf, size_t count);
fd
:文件描述符。buf
:包含要写入数据的缓冲区。count
:要写入的字节数。
-
文件定位:
off_t lseek(int fd, off_t offset, int whence);
fd
:文件描述符。offset
:相对于whence
的偏移量。whence
:定位的起始位置,可以是SEEK_SET
(文件开头)、SEEK_CUR
(当前位置)或SEEK_END
(文件末尾)。
-
控制设备:
int ioctl(int fd, unsigned long request, ...);
fd
:文件描述符。request
:控制命令。- 其他参数根据具体的控制命令而定。
通过这些Unix IO接口,用户程序可以方便地对文件和设备进行操作,实现数据的读写、设备的控制等功能。这些接口提供了统一的编程模型,使得对设备的操作与对文件的操作一致,简化了编程复杂度。
8.3 printf的实现分析
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等
printf
函数是C语言中用于格式化输出的标准库函数。其实现过程涉及多个步骤,从格式化字符串到最终输出到屏幕。
以下是printf
函数的实现分析:
-
vsprintf生成显示信息:
printf
函数首先调用vsprintf
函数,将格式化字符串和可变参数列表转换为一个完整的字符串。vsprintf
函数的原型如下:int vsprintf(char *buf, const char *fmt, va_list args);
vsprintf
函数遍历格式化字符串fmt
,根据格式说明符从args
中提取相应的参数,并将其转换为字符串形式存储在缓冲区buf
中。
-
调用write系统函数:
vsprintf
生成的字符串存储在缓冲区buf
中后,printf
函数调用write
系统函数将该字符串输出到标准输出(通常是终端)。write
系统函数的原型如下:ssize_t write(int fd, const void *buf, size_t count);
- 其中,
fd
是文件描述符,表示输出目标(标准输出的文件描述符通常为1),buf
是要输出的数据缓冲区,count
是要输出的字节数。
-
陷阱-系统调用:
write
系统函数最终通过系统调用将数据传递给操作系统内核。系统调用是用户态程序与内核态之间的接口。- 在x86架构上,系统调用通常通过
int 0x80
指令或syscall
指令来实现。例如:mov eax, 4 ; 系统调用号 (sys_write) mov ebx, 1 ; 文件描述符 (stdout) mov ecx, buf ; 缓冲区地址 mov edx, count ; 字节数 int 0x80 ; 触发系统调用
字符显示驱动子程序:从ASCII到字模库到显示VRAM
字符显示驱动子程序负责将字符转换为图形像素,并在屏幕上显示。其过程如下:
-
从ASCII到字模库:
- 字符显示驱动程序首先将ASCII码转换为字模(bitmap)。字模库存储了每个字符对应的像素点阵信息。
- 例如,字符’A’的ASCII码是65,驱动程序在字模库中查找对应的点阵数据。
-
将字模数据写入显示VRAM:
- 显示VRAM(Video RAM)是用于存储显示图像数据的内存区域。每个像素的颜色信息(通常是RGB分量)存储在VRAM中。
- 驱动程序将字模数据转换为像素数据,并写入显示VRAM的相应位置。
-
显示芯片读取VRAM并刷新屏幕:
- 显示芯片按照一定的刷新频率逐行读取VRAM中的数据,并通过信号线将每个像素的RGB分量传输到液晶显示器。
- 显示器接收到信号后,将像素数据转换为可见的图像显示在屏幕上。
通过上述步骤,printf
函数实现了从格式化字符串到最终在屏幕上显示字符的全过程。这个过程涉及字符串格式化、系统调用、字符显示驱动等多个环节,体现了操作系统和硬件的协同工作。
8.4 getchar的实现分析
异步异常-键盘中断的处理
键盘输入是通过异步中断机制来处理的。当用户按下键盘上的按键时,键盘控制器会生成一个中断信号,通知CPU进行处理。以下是键盘中断处理的详细过程:
-
键盘中断处理子程序:
- 当用户按下键盘上的按键时,键盘控制器会生成一个中断请求(IRQ1)。
- CPU响应中断请求,暂停当前执行的任务,转而执行键盘中断处理子程序。
-
接受按键扫描码并转换为ASCII码:
- 键盘中断处理子程序从键盘控制器读取按键的扫描码。
- 扫描码是键盘硬件生成的编码,需要转换为对应的ASCII码。这个转换过程通常通过查找扫描码到ASCII码的映射表来实现。
-
保存到系统的键盘缓冲区:
- 转换后的ASCII码被保存到系统的键盘缓冲区。键盘缓冲区是一个环形缓冲区,用于暂存用户输入的字符。
- 键盘缓冲区可以存储多个字符,直到用户程序读取这些字符。
getchar函数的实现
getchar
函数用于从标准输入读取一个字符。其实现过程涉及调用read
系统函数,通过系统调用读取键盘缓冲区中的字符。以下是getchar
函数的实现分析:
-
调用read系统函数:
getchar
函数调用read
系统函数,从标准输入(通常是文件描述符0)读取一个字符。read
系统函数的原型如下:ssize_t read(int fd, void *buf, size_t count);
- 其中,
fd
是文件描述符,表示输入源(标准输入的文件描述符通常为0),buf
是存储读取数据的缓冲区,count
是要读取的字节数。
-
通过系统调用读取按键ASCII码:
read
系统函数通过系统调用从键盘缓冲区读取字符。系统调用是用户态程序与内核态之间的接口。- 在x86架构上,系统调用通常通过
int 0x80
指令或syscall
指令来实现。例如:mov eax, 3 ; 系统调用号 (sys_read) mov ebx, 0 ; 文件描述符 (stdin) mov ecx, buf ; 缓冲区地址 mov edx, 1 ; 字节数 int 0x80 ; 触发系统调用
-
读取到回车键才返回:
getchar
函数会持续调用read
系统函数,读取键盘缓冲区中的字符,直到读取到回车键(ASCII码为13或’\n’)才返回。- 读取到的字符被存储在缓冲区中,并返回给调用者。
通过上述步骤,getchar
函数实现了从键盘输入到读取字符的全过程。这个过程涉及键盘中断处理、字符转换、系统调用等多个环节,确保用户输入能够被正确读取和处理。
8.5 本章小结
本章深入探讨了Linux系统中的IO管理方法,介绍了设备文件的模型化和Unix IO接口的使用。我们详细分析了printf
和getchar
函数的实现过程,涵盖了从格式化字符串生成、系统调用到字符显示驱动和键盘中断处理等多个环节。通过本章的学习,我们对Linux系统的IO操作有了更深入的理解,掌握了常用的Unix IO接口函数,并了解了关键函数的实现细节,为进一步应用Linux系统的IO操作打下了坚实基础。
结论
hello程序所经历的过程
-
预处理:
- 预处理器cpp对hello.c进行处理,将文件调用的所有外部库文件合并展开,生成一个经过修改的hello.i文件。
-
编译:
- 编译器ccl通过词法分析和语法分析,将hello.i翻译成具备在指令级别上控制硬件资源能力的汇编语言文件hello.s。
-
汇编:
- 汇编器as将汇编程序hello.s翻译成机器语言指令,生成可重定位目标程序hello.o。
-
链接:
- 链接器ld将hello.o与动态链接库链接整合为单一文件,生成完全链接的可执行目标文件hello。
-
进程载入:
- 通过Bash键入命令
./hello 2022111514 谭昊明 15714002238 1
,操作系统为程序fork新进程并通过execve加载代码和数据到为其提供的私有虚拟内存空间,程序开始执行。
- 通过Bash键入命令
-
进程控制:
- 由进程调度器对进程进行时间片调度,并通过上下文切换实现hello的执行,程序计数器(PC)更新,CPU按顺序取指,执行程序控制逻辑。
-
内存访问:
- 内存管理单元MMU将逻辑地址逐步转换成物理地址,通过三级Cache访问物理内存/磁盘中的数据。
-
信号处理:
- 进程接收信号,调用相应的信号处理函数对信号进行终止、停止、前/后台运行等处理。
-
进程回收:
- Shell等待并回收子进程,内核删除为进程创建的所有资源。
我的感想
我的感想
学习《深入理解计算机系统》(CSAPP)这门课程,让我对计算机系统的复杂性和精妙设计有了深刻的体会。一个简单的“hello”程序,从编写到执行,再到最终的结果展示,背后隐藏着无数的技术细节和系统机制。这段学习旅程不仅让我掌握了技术,更让我对计算机科学的美妙有了更深的感悟。
当我第一次编写“hello”程序时,我并未意识到它背后的复杂性。预处理、编译、汇编和链接,这些步骤将简单的源代码转化为可执行文件,每一步都充满了技术的智慧。预处理器将头文件展开,编译器将代码翻译成汇编语言,汇编器将汇编代码转化为机器指令,链接器将所有的目标文件和库文件整合为一个可执行文件。这一系列的转换过程,让我看到了计算机系统层次化设计的力量。
在操作系统的支持下,程序得以运行。进程的创建和管理、内存的分配和访问、信号的处理和响应,这些机制确保了程序的稳定运行。操作系统不仅是资源的管理者,更是程序执行的协调者。通过学习进程调度、内存管理和信号处理,我明白了操作系统如何在多任务环境下保持系统的平衡和高效。
内存管理单元(MMU)和缓存机制的学习,让我认识到硬件在系统性能中的关键作用。逻辑地址到物理地址的转换、缓存的层次结构,这些硬件细节直接影响到程序的执行效率。理解这些机制,让我学会了如何编写高效的代码,如何利用缓存友好的数据结构来提升程序性能。
信号处理和进程回收的机制,让我看到了系统设计的鲁棒性。无论是处理外部中断还是内部异常,系统都需要有一套完善的机制来应对各种突发情况。通过学习这些机制,我学会了如何编写健壮的程序,如何处理异常情况,确保程序在各种环境下都能稳定运行。
通过CSAPP这门课程,我不仅掌握了具体的技术,更培养了系统思维和工程素养。我深刻体会到,计算机系统的设计和实现是一门需要不断探索和创新的艺术。每一个细节都需要精心设计,每一个机制都需要反复推敲。正是这些细致入微的设计和实现,才使得计算机系统能够高效、稳定地运行。
“hello”程序的简单外表下,隐藏着计算机系统的复杂与精妙。它是每一个踏入编程世界的初学者首先接触到的程序,但它背后的实现过程却展示了计算机科学的深邃与伟大。通过这门课程,我不仅提升了编程技能,更加深了对计算机系统的理解和敬畏。这段学习旅程,让我对计算机科学充满了热爱和敬仰,也激励我在未来的学习和工作中不断探索和创新。
附件
文件名 | 文件的作用 |
---|---|
hello | 这是编译后的可执行文件 |
hello.c | 这是源代码文件 |
hello_d_r.s | 这是通过反汇编生成的源代码文件,包含了调试信息和重定位信息 |
hello_d_x.s | 这是通过反汇编生成的源代码文件,包含了调试信息和十六进制代码 |
hello.i | 这是预处理后的源代码文件 |
hello.o | 这是编译后的可重定位目标文件 |
hello_o_d_r.s | 这是目标文件的反汇编版本,包含了调试信息和重定位信息 |
hello.s | 这是编译器生成的汇编源代码文件 |
参考文献
[1] Randal E.Bryant David R.O’Hallaron.深入理解计算机系统(第三版).机械工业出版社,2016.
[2] https://www.cnblogs.com/buddy916/p/10291845.html
[3] https://www.cnblogs.com/pianist/p/3315801.html
[4] printf函数实现的深入剖析. https://www.cnblogs.com/pianist/p/3315801.html
[5] 一个简单程序从编译、链接、装载(执行)的过程 - 知乎 (zhihu.com)