计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 未来技术学院人工智能领域
学 号
班 级
学 生 粉红色的章鱼哥
指 导 教 师 刘宏伟
计算机科学与技术学院
2024年6月
本文详细介绍了从编写一个简单的"Hello"程序到其在计算机系统中的完整生命周期,强调了每个阶段的重要性和相互关联性。全文共分为七个章节,第1章提供了对"Hello"程序的初步介绍,第2章分析了hello.c的预处理,第3章探讨了编译过程,第4章聚焦于汇编过程,第5章讨论了链接的机制,并着重分析了可执行目标文件"hello"的格式和链接过程中的重定位机制,第6章展示了hello的进程管理,第7章最后一章深入于hello的存储管理。全文系统地阐述了hello的一生。
本文旨在为读者提供了"Hello"程序开发的全面视角,同时展示了计算机系统中程序运行和管理的全过程。帮助读者加深对程序全生命周期的理解。
关键词: 预处理、编译、汇编、链接、进程管理、存储管理
目 录
第1章 概述
1.1 Hello简介
Hello的P2P:"From Program to Process" 是描述hello从源代码状态转变到运行中的进程状态的过程。程序从编写开始,历经预处理,编译,汇编,链接然后生成可执行文件,接下来加载器加载到内存中,操作系统为程序创建一个新的进程并分配必要的内存空间。在执行过程中还会与操作系统进行信号的交互。最终程序返回退出状态码给操作系统。这个过程涵盖了程序从源代码到运行中的进程的完整生命周期。作为每个程序员入门的程序,hello实际上体现了计算机系统中程序从一个构思,到最终完成执行回归于无的过程。
1.2.1 硬件环境
AMD Ryzen 7 6800H with Radeon Graphics;3.20 GHz;16.0 GB RAM;512GHD Disk
1.2.2 软件环境
Windows11 64位;VMware® Workstation 17 Pro;Ubuntu 20.04 LTS 64位;
1.2.3 开发工具
Visual Studio 2022 64位;CodeBlocks 64位;vi/vim/gedit+gcc
1.3 中间结果
hello.c | 源文件 |
hello.i | 预处理文件 |
hello.s | 汇编语言 |
hello.o | 机器代码(未链接) |
hello | 可执行文件 |
hello.asm | hello的反汇编文件 |
1.4 本章小结
在本章中,我们介绍了hello的P2P,020的过程。列出了环境和工具,说明过程中所生成的中间结果。
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
预处理是指在编译源代码之前对源代码进行处理的过程,是C编译过程的第一步,源文件(.c)经过预处理后得到修改了的源程序(.i)。主要包括头文件包含、注释删除、宏替换、条件编译等。
预处理器是负责进行预处理的工具或软件,它根据预处理指令对C源代码文件进行处理,并生成一个经过处理后的源代码文件,GCC编译器中使用独立的预处理器cpp。
2.1.2 预处理的作用
预处理是在源代码编译之前对源代码进行处理的过程,源文件经过预处理后会生成一个新的源代码文件(.i),然后才进行编译、链接和执行等后续的步骤。其作用主要有以下几个方面:
- 文件包含:通过#include指令,将其他源代码文件的内容包含到当前的源文件中,避免代码重复编写,提高代码的模块化和可维护性。
- 注释删除:删除源代码中的注释,减小源代码的大小和复杂性。
- 宏替换:将宏调用替换为宏定义中的具体代码片段。宏定义使用#define指令,在编写代码时可以使用宏调用,简化和复用代码。
- 条件编译:通过条件编译指令(如#if、#ifdef、#ifndef、#elif、#else和#endif),根据不同的条件选择性地编译代码片段,实现对不同平台、不同配置的代码编译和执行。
2.2在Ubuntu下预处理的命令
使用命令gcc -E -o hello.i hello.c对hello.c进行预处理,-E选项命令编译器只执行预处理阶段,-o选项指定输出文件的名称,-o hello.i指定了输出文件为hello.i。
2.3 Hello的预处理结果解析
打开预处理生成的hello.i文件,查看文件内容如下:
预处理器输出的第一部分的每行都以 # 开头,指示一种预处理指令或注释。下面是对这些特定行的解释:
# 1 "hello.c" | 表示预处理器正在处理名为 hello.c 的源文件,# 1 表示这是该文件的第一条指令。 |
# 1 "<built-in>" | 这表示预处理器正在处理编译器内置的宏定义或指令,<built-in> 通常不是实际的文件名,而是表示编译器的内置行为。 |
# 1 "<command-line>" | 预处理器正在处理来自命令行的指令,这些指令是由用户在编译时提供的。 |
# 31 "<command-line>" | 预处理器在处理命令行指令的过程中跳到了第 31 条指令。这通常发生在处理完一个文件后,预处理器继续处理其他来自命令行的指令。 |
# 1 "/usr/include/stdc-predef.h" 1 3 4 | 预处理器开始包含位于 /usr/include/ 目录下的 stdc-predef.h 头文件。这个文件通常包含了一些标准C库的预定义宏。后面的 1 3 4 帮助 GCC 编译器用来控制文件包含的逻辑。 |
# 32 "<command-line>" 2 | 预处理器在处理命令行指令时返回到了第 32 条指令的第 2 部分。这通常意味着预处理器在处理完一个包含文件后,返回到命令行指令的下一个部分。 |
…… | (后续含义同理) |
typedef 关键字用于定义新的类型名称,这些新类型通常是对现有类型的别名或对复杂类型的简化表示。
extern用于声明一个变量或函数是在另一个文件或编译单元中定义的。可以告诉编译器在当前文件中不需要寻找其定义,而是在链接时从其他文件中寻找。
最后是原hello.c的主体部分的内容,去除了所有注释和头文件包含语句。
2.4 本章小结
在本章中,我们首先了解了编译预处理阶段的概念和作用。通过实验,使用gcc中的cpp预处理器将hello.c进行预处理得到hello.i,并进行分析,加深了对预处理过程的理解。
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
编译器将经过预处理后的.i文件编译成汇编语言代码,即生成.s文件。在这个阶段,编译器会将高级语言代码翻译成相应的汇编语言代码。
3.1.2 编译的作用
- 语言转换:将人类可读的高级语言代码转换成机器或汇编语言。
- 优化:在转换过程中,编译器会对代码进行优化,以提高执行效率。
- 错误检测:编译器在编译过程中会检测语法错误和潜在的逻辑问题。
- 代码验证:确保代码符合语言规范,并且可以安全执行。
3.2 在Ubuntu下编译的命令
使用命令gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC -S -o hello.s hello.i对hello.i进行编译。-m64:指定生成 64 位目标代码。-Og:进行开发时优化,即进行一些基本优化以提高代码执行效率。-no-pie:禁止生成位置无关可执行文件。-fno-stack-protector:禁用堆栈保护。-fno-PIC:禁用位置无关代码。-S选项命令编译器只执行编译阶段。
3.3 Hello的编译结果解析
3.3.1 数据
1.常量
(1)立即数常量
如图,以红色框选部分展示了部分包含立即数的代码,汇编代码中通过’$’符号提示的阿拉伯数字为立即数,通常用于比较操作或作为参数传递给函数。第23行中,将栈指针所指减去8个字节,为局部变量分配空间。第25行中,立即数5用于与%edi寄存器进行比较,判断命令行参数的数量是否为5。第28行中,立即数0用来初始化计数器 %ebp。其余立即数同理。
(2)字符串常量
如图,第五行到第九行的红色框选区域为字符串常量,其中LC0是包含程序用法信息的字符串,对应
,LC1是printf()函数调用的格式化字符串。
2.变量
(1)寄存器变量
如图是代码中出现的部分CPU寄存器,以’%’开始,它们在汇编代码中用作快速访问的存储位置。
%rbp是帧指针,用于维护栈帧的底部。
%rsp是栈指针,指向栈的顶部。
%rbx:在函数开始时保存,用于存储参数和循环计数器。
%rdi, %rsi, %rdx, %rcx:通常用于函数调用时传递参数。
%eax:用于存储函数的返回值和某些函数调用的结果。
%ebp:在本程序的循环中用作计数器。
(2)局部变量
如图所示,第23行通过将栈指针%rsp减去8个字节的地址,为局部变量分配了八个字节的栈空间。
3.3.2 赋值
1.寄存器赋值到寄存器
如图,movl指令的源和目的都是寄存器变量,将%rsi寄存器的值赋值给%rbx寄存器,目的是保存argv指针。
2.立即数赋值到寄存器
如图,此处movl指令的源是立即数,而目的是寄存器。具体功能而言,在第35行中,将LC1字符串的首地址赋值给%edi寄存器(隐含清空%rdi的高32位),然后调用printf输出字符串。第36行中,将立即数0赋值给%eax寄存器。
3.内存数据复制到寄存器
如图,前三行中,以%rbx寄存器指向的内存地址作为基地址,然后从加上24、16、8字节的位置分别加载数据到%rcx、%rdx、%rsi寄存器。第38行中,从%rbx寄存器指向的内存地址加上32字节的位置加载数据到%rdi寄存器,准备作为参数传递给atoi函数。
3.3.3 算术操作
1.加法
如图,第42行,将%ebp加1对应于
循环体中的循环计数器i每次进行加一。第52行中,将%rsp加回8,释放之前为局部变量分配的栈空间。
2.减法
如图,第23行中,通过将栈指针%rsp减去8个字节的地址,为局部变量分配了八个字节的栈空间。
3.3.4 关系操作
如图,第25行中的cmpl指令将立即数5和%edi的值进行比较,用于检查命令行参数的数量是否为5。第30行中的cmpl指令是将立即数9和%ebp的值进行比较,检查循环计数器 %ebp 是否大于9,如果是则跳转到 .L7 结束循环。
3.3.5 数组/指针/结构操作
1.数组操作
如图,movq %rsi, %rbx将命令行参数的指针%rsi(指向 argv,从
可知,argv参数是字符串指针数组)移动到 %rbx 寄存器作为之后加载数据的基址保存寄存器。
然后将%rbx内的地址当做基址,加载数组内的其它指针到相应寄存器。
2.指针操作
如图,第17行,将当前的帧指针%rbp压入栈中,以保存函数调用前的帧指针值。第54行和第56行,将栈顶元素弹出并保存到相应寄存器,这个过程隐含了将%rsp指针向栈底移动字节数。
3.3.6 控制转移
1.条件跳转
如图,第26行中,当上述 cmpl $5, %edi 操作的结果不相等(即命令行参数个数不等于5),则跳转到标签 .L6。第31行中,当上述 cmpl $9, %ebp 操作的结果是 %ebp 大于9(循环达到终止条件),则跳转到标签 .L7。
2.无条件跳转
如图,第43行中,程序无条件跳转到标签.L2。
3.3.7 函数操作
1.调用 printf 函数:
第35行movl $.LC1, %edi: 加载 printf 格式化字符串的地址到 %edi 寄存器。这是 printf 函数的第一个参数。movq 24(%rbx), %rcx: 加载第一个用户定义的参数(学号)的地址到 %rcx。movq 16(%rbx), %rdx: 加载第二个用户定义的参数(姓名)的地址到 %rdx。movq 8(%rbx), %rsi: 加载第三个用户定义的参数(手机号)的地址到 %rsi。call printf: 调用 printf 函数进行格式化输出。
2.调用 atoi 函数:
movq 32(%rbx), %rdi: 加载第四个用户定义的参数(秒数字符串)的地址到 %rdi。call atoi: 调用 atoi 函数将字符串转换为整数。atoi 函数返回的整数值通过 %eax 寄存器传递。
3.调用 sleep 函数:
movl %eax, %edi: 将 atoi 函数返回的整数值移动到 %edi 寄存器。sleep 函数需要秒数作为参数。call sleep: 调用 sleep 函数暂停程序执行指定秒数。sleep函数用于暂停执行,没有返回值。
4.调用 getchar 函数:
call getchar: 调用 getchar 函数从标准输入读取一个字符,不需要参数。getchar 函数用于读取单个字符,程序没有对返回值的需求,所以不用寄存器返回值。
5.调用 puts 函数:
movl $.LC0, %edi: 加载错误信息字符串的地址到 %edi 寄存器。这是 puts 函数的参数。call puts: 调用 puts 函数打印字符串到标准输出。
6.调用 exit 函数:
movl $1, %edi: 设置退出状态码为1,这是 exit 函数的参数。call exit: 调用 exit 函数退出程序。
3.3.8 特殊指令和伪操作
.file "hello.c"指明汇编代码对应的源文件名称。.text指示汇编器接下来的部分是代码段(.text section)。.section .rodata.str1.8,"aMS",@progbits,1和.align 8定义一个只读数据段,用于存储字符串常量.LC0,并且按照8字节对齐。.section .rodata.str1.1,"aMS",@progbits,1定义另一个只读数据段,用于存储以下字符串.LC1。
.globl为全局符号声明,.globl main声明 main 函数为全局符号,使其可以被其他代码引用。.type为函数类型声明.type main, @function: 指定 main 的类型为函数。
.size main, .-main: 指定了 main 函数的大小,说明 main 函数的大小。.ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0"指明编译器的版本信息。.section .note.GNU-stack,"",@progbits这一部分定义了 GNU 栈属性的节。.section .note.gnu.property,"a"部分定义 GNU 属性节,包含属性信息,如栈保护等。
3.4 本章小结
通过编译将hello.i编译为hello.s,并对汇编代码进行分析,说明了编译器是怎么处理C语言的各个数据类型以及各类操作的。
第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编是将汇编源文件(.s文件)转化为目标文件(.o文件)的过程。汇编代码由汇编指令组成,每个汇编指令都有着相对应的机器指令,通过汇编器将汇编指令转化为机器指令,从而能在计算机上执行。
4.1.2 汇编的作用
汇编主要有以下作用:
1.转换为机器代码: 将人类可读的汇编语言转换成CPU可以执行的机器代码。
2.代码优化: 汇编器在转换过程中可能进行一些优化,比如指令选择和寄存器分配。
3.符号解析: 汇编器解析代码中的符号(如标签和常量),确保它们在生成的机器代码中正确引用。
4.内存地址分配: 在汇编过程中,确定代码和数据在内存中的地址。
4.2 在Ubuntu下汇编的命令
使用命令gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC -c -o hello.o hello.s对hello.s进行汇编。
4.3 可重定位目标elf格式
ELF 头部信息:
Magic: 文件开头的魔数 7f 45 4c 46 表明它是一个 ELF 文件,随后的 02 01 01 00 表示这是 64 位的 ELF 格式。
类别: ELF64 表示这是一个 64 位的 ELF 文件。
数据: 2 补码,小端序 (little endian) 表示数据使用补码表示,并且是小端序格式。
Version: 1 (current) 表示使用的是当前版本的 ELF 标准。
OS/ABI: UNIX - System V 表示这个文件是为 UNIX System V ABI 系统创建的。
ABI 版本: 0 表示 ABI 的版本号。
类型: REL (可重定位文件) 表示这是一个可重定位的对象文件,它需要被链接器进一步处理。
系统架构: Advanced Micro Devices X86-64 表示这个文件是为 AMD x86-64 架构编译的。
版本: 0x1 表示对象文件的版本。
入口点地址: 0x0 表示没有设置入口点,这是对象文件的典型特征,因为入口点通常在最终的可执行文件中设置。
程序头起点: 0 表示没有程序头,这是对象文件的标准特征。
节头起点: 1240 (bytes into file) 表示节头的开始位置。
标志: 0x0 表示没有设置任何特殊标志。
Size of this header: 64 (bytes) 表示 ELF 头部的大小。
Size of program headers: 0 (bytes) 表示没有程序头。
Number of program headers: 0 表示没有程序头。
Size of section headers: 64 (bytes) 表示每个节头部的大小。
Number of section headers: 15 表示有 15 个节头。
Section header string table index: 14 表示节头字符串表的索引。
节头信息:
节头提供了文件中各个节的详细信息,包括它们的名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息和对齐等。
.text 节包含可执行代码。
.data 节包含已初始化的全局和静态C变量。
.bss 节包含未初始化的静态变量,以及初始化为0的全局或静态变量。
.rodata.str1.8 和 .rodata.str1.1 节包含只读数据。
.comment 和 .note.GNU-stack 节包含文件的注释和栈信息。
.note.gnu.property 节包含 GNU 属性,这些属性提供有关文件的特定于处理器的特性信息。
.eh_frame 节包含异常处理信息。
.rela.text 和 .rela.eh_frame 节包含重定位信息,这对于链接器解析符号引用是必要的。
.symtab(符号表)和 .strtab(字符串表)节包含程序的符号信息和字符串信息。
重定位信息:
重定位节包含必要的信息,用于在链接时解析符号引用。偏移量: 指定了需要重定位的机器代码在 .text 节中的偏移量。
信息: 包含了重定位类型和符号索引的信息。
类型: 指定了重定位的类型,R_X86_64_32是一种 32 位的直接重定位类型,不涉及间接跳转或调用,而是直接将地址放入代码或数据中。 R_X86_64_PLT32是一种用于过程链接表PLT的 32 位重定位类型。
符号值: 指定了被引用的符号的值,这可以是一个常量或者一个符号名称。
加数: 是一个加数(通常是地址占位的字节数目),它将被加到重定位后的地址上,以提供最终的引用地址。
重定位节 '.rela.text':
.rela.text 节中的条目指示链接器如何调整代码中的符号引用,以便它们指向正确的地址。
以第一条为例,条目表示在 .text 节的偏移 0x29 处,有一个 R_X86_64_32 类型的重定位,它引用了 .rodata.str1.1 节,并且没有加数。
重定位节 '.rela.eh_frame':
这个节包含了针对 .eh_frame 节的重定位条目。.eh_frame 节包含异常处理(也称为栈展开)信息。
如图所示,这一条目表示在 .eh_frame 节的偏移 0x20 处,有一个 R_X86_64_PC32 类型的重定位,它引用了 .text 节的开始位置,并且没有加数。
符号表信息:
包含了18个条目,每个条目提供了关于程序中定义或引用的符号的信息。每个条目包含以下字段:Num: 符号表项的索引,用于唯一标识一个符号。Value: 符号的内存地址或值。Size: 符号的大小,对于函数来说,这通常是函数的代码长度;对于变量来说,这是变量所占的字节数。Type: 符号的类型,例如 NOTYPE 表示没有特定类型,FUNC 表示函数。Bind: 符号的绑定属性,GLOBAL 表示全局符号,LOCAL 表示局部符号。Vis: 符号的可见性,DEFAULT 表示默认可见性,UND 表示未定义(在当前文件中没有定义)。Ndx: 符号所在的节的索引。Name: 符号的名称。
说明:
本文件中没有节组。
本文件没有程序头(程序头用于可执行文件和动态库,而不是可重定位对象文件)。
本文件没有动态节(动态节用于动态库和可执行文件)。
本文件没有版本信息。
4.4 Hello.o的结果解析
hello.o的反汇编如右图,与hello.s进行对照分析:
1. 汇编语言与机器代码的映射:
汇编语言中的指令和操作数被转换成机器代码中的操作码和操作数编码。汇编代码中的 .file、.section、.align 等伪操作在机器码中没有对应。
2.操作数映射
汇编中的立即数和地址常量在机器码中通常以十六进制直接编码的形式出现。寄存器类操作数(如寄存器名 %rbp、%rsp 等)在机器码中被替换为相应的寄存器编码。内存操作数在机器码中被转换为使用基址寄存器和偏移量的形式。
3. 分支和控制转移:
汇编中的条件跳转指令(如 jne .L6 使用标签跳转)在机器码中转换为条件跳转的指令码和跳转目标的偏移量。
4.函数调用:
反汇编中callq对应汇编中的call指令,使用被调用的函数的偏移量作为目标。
5. 重定位信息:
反汇编代码中的 R_X86_64_32 和 R_X86_64_PLT32 是重定位类型,它们指示链接器需要将这些地址转换为最终的运行时地址。
5. 汇编代码中的伪操作和指令:
7. 机器语言的构成:
机器语言由操作码(Opcode)、操作数(Operands)和可能的立即数(Immediates)组成。
操作码确定要执行的操作,操作数指定操作的对象,立即数则提供了操作所需的固定值。
8. 汇编语言与机器语言的映射关系:
汇编语言中的指令和操作数被转换成机器码中的操作码和操作数编码。
汇编语言中的标签(如 main、.L6)在机器码中被转换为地址偏移量。
9. 机器语言中的不一致性:
在某些情况下,汇编语言中的操作数和机器语言中的操作数可能看起来不一致。这可能是因为汇编器在生成机器码时进行了优化或简化。
分支转移和函数调用的机器码可能包含相对偏移量或间接地址,这与汇编语言中的直接标签引用不同。
4.5 本章小结
通过汇编将hello.s编译为hello.o,并对ELF格式进行分析,通过对照分析反汇编和汇编语言说明机器语言与汇编语言的映射关系。
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接是将编译器生成的目标文件(.o文件)合并成最终的可执行文件的过程。链接的目的是将独立的不可执行的目标文件按照一定的规则合并在一起,生成一个完整的可执行文件。
5.1.2 链接的作用
链接有众多作用,主要列举以下几点:
1.解决外部引用: 链接器解析程序中对外部符号的引用,并将它们链接到正确的定义。
2.内存地址分配: 为程序的代码和数据分配最终的内存地址。
3.支持模块化编程: 允许开发者将程序分解成模块,单独编译,然后链接在一起。
4.提供动态链接支持: 允许程序在运行时加载和使用共享库,实现内存和性能优化。
5.2 在Ubuntu下链接的命令
使用链接命令ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o链接得到hello。
5.3 可执行目标文件hello的格式
程序头(Program Headers):
PHDR:程序头表起始地址为0x0000000000400040,大小为0x00000000000002a0。
INTERP:程序解释器地址为0x00000000004002e0,大小为0x000000000000001c。解释器请求/lib64/ld-linux-x86-64.so.2。
有多个LOAD段,分别从虚拟地址0x0000000000400000开始,大小不一,具有不同的权限(如读R、读写RW等)。
节头:
.interp:起始地址0x00000000004002e0,大小0x000000000000001c。
.note.gnu.property:起始地址0x0000000000400300,大小0x0000000000000020。
.note.ABI-tag:起始地址0x0000000000400320,大小0x0000000000000020。
.hash:起始地址0x0000000000400340,大小0x0000000000000038。
.gnu.hash:起始地址0x0000000000400378,大小0x000000000000001c。
.dynsym:动态符号表,起始地址0x0000000000400398,大小0x00000000000000d8。
.dynstr:动态字符串表,起始地址0x0000000000400470,大小0x000000000000005c。
.gnu.version:版本符号表,起始地址0x00000000004004cc,大小0x0000000000000012。
.gnu.version_r:版本需求表,起始地址0x00000000004004e0,大小0x0000000000000020。
.rela.dyn:动态重定位表,起始地址0x0000000000400500,大小0x0000000000000030。
.rela.plt:过程链接表重定位,起始地址0x0000000000400530,大小0x0000000000000090。
.init:初始化代码,起始地址0x0000000000401000,大小0x000000000000001b。
.plt:过程链接表,起始地址0x0000000000401020,大小0x0000000000000070。
.plt.sec:安全过程链接表,起始地址0x0000000000401090,大小0x0000000000000060。
.text:文本(代码)段,起始地址0x00000000004010f0,大小0x0000000000000125。
.fini:终止代码,起始地址0x0000000000000401218,大小0x000000000000000d。
.rodata:只读数据段,起始地址0x0000000000402000,大小0x0000000000000048。
.eh_frame:异常处理帧,起始地址0x0000000000402048,大小0x0000000000000104。
.dynamic:动态链接信息,起始地址0x0000000000403e50,大小0x00000000000001a0。
.got:全局偏移表,起始地址0x0000000000403ff0,大小0x0000000000000010。
.got.plt:过程链接全局偏移表,起始地址0x0000000000404000,大小0x0000000000000048。
.data:已初始化数据段,起始地址0x0000000000404048,大小0x0000000000000004。
动态节(Dynamic Section):
包含动态链接的信息,位于偏移量0x2e50,包含21个条目,涉及共享库依赖(NEEDED)、初始化(INIT)、终止(FINI)、符号表(SYMTAB)、字符串表(STRTAB)等信息。
重定位节(Relocation Sections):
.rela.dyn:包含动态重定位条目,通常用于动态链接时对全局变量的地址进行调整。包含2个条目,例如R_X86_64_GLOB_DAT类型重定位。
.rela.plt:包含过程链接表(Procedure Linkage Table, PLT)的重定位条目,用于在程序执行时将对函数的调用转发到正确的地址。包含6个条目,例如R_X86_64_JUMP_SLO类型重定位。
符号表(Symbol Tables):
.dynsym:包含9个条目,列出了全局或局部符号,如puts、printf等。
.symtab:包含51个条目,包括函数、对象、文件等类型的符号。
版本符号表(.gnu.version)和版本需求表(.gnu.version_r)提供了与GLIBC版本相关的信息。.note.gnu.property和.note.ABI-tag,提供了关于文件属性和ABI版本的信息。
5.4 hello的虚拟地址空间
如图,观察可知.init虚拟地址为0x401000与5.3中一致。
如图,观察可知.plt虚拟地址为0x401020与5.3中一致。
如图,观察可知.plt.sec虚拟地址为0x401090与5.3中一致。
如图,观察可知.text虚拟地址为0x4010f0与5.3中一致。
如图,观察可知.fini虚拟地址为0x401218与5.3中一致。
5.5 链接的重定位过程分析
1.hello.o(目标文件)与hello(可执行文件)的区别:
Hello.o包含未解析的外部引用。包含重定位条目,指示链接器在链接时需要如何调整地址。不包含程序的完整执行环境,需要与其他 .o 文件和库一起被链接器处理。
hello的所有外部引用已被解析,即链接器已经将它们替换为实际的内存地址。不包含重定位条目,因为所有地址都已经被最终确定。包含程序头,指示操作系统如何加载程序到内存。
2.链接过程:
符号解析:链接器处理 .o 文件中的每个符号(变量和函数),并查找它们的定义。如果符号在其他 .o 文件或库中定义,链接器会记录这些信息。
地址分配:链接器为所有代码和数据分配内存地址,包括代码段(.text)、数据段(.data)、只读数据段(.rodata)等。
重定位:链接器使用 .o 文件中的重定位条目来调整代码和数据中的地址。例如,如果 hello.o 中有对 printf 的调用,链接器会将调用指令中的地址替换为 printf 在最终可执行文件中的地址。
合并节:链接器将所有 .o 文件中的相同类型的节合并。如所有的 .text 节会被合并成一个单一的代码段。
创建动态链接信息: 如果程序使用动态链接库的情况下,链接器会创建动态链接所需的信息。
生成可执行文件:链接器生成最终的可执行文件,包括程序头、符号表、重定位后的代码和数据等,等待被执行。
3.hello.o 中的重定位条目分析:
在 hello.o 的反汇编输出中,我们可以看到几种类型的重定位条目,例如:R_X86_64_32:32位的相对地址重定位,通常用于数据段中的地址引用。R_X86_64_PLT32:32位的进程链接表(PLT)重定位,用于代码段中的函数调用。这些重定位条目由编译器生成,需要在链接时被解析。
在hello的可执行文件中,链接器已经处理了所有的重定位条目。PLT条目在链接过程中被重定位,链接器将它们替换为指向实际函数的绝对地址。指令会直接调用到正确的地址,而不再包含重定位条目。
5.6 hello的执行流程
通过gdb执行hello,在_dl_start设置断点。
程序首先进入_dl_catch_exception@plt。
接下来进入_init。
然后执行_start。
然后进行_setjmp、__sigsetjmp。
之后开始__sigjmp_save。
然后进行__libc_start_main,执行main函数
最后执行__GI_exit。
5.7 Hello的动态链接分析
如图,在程序执行前查看plt,可以看到plt没有被初始化,存在大量的0x00,与hello.o内容一致。
如图,程序执行后,再次查看plt内容,发现动态链接后,plt内容被替换为了相应的地址。
5.8 本章小结
本章通过链接将hello.o链接得到可执行文件hello,对hello的ELF格式进行分析,查看其各段信息。通过对照分析hello与hello.o说明了链接的过程。接下来对重定位过程进行了分析。然后使用gdb执行hello,说明了从加载hello到_start,到call main,以及程序终止的所有过程。最后我们分析hello程序的动态链接项目,通过gdb调试,分析在动态链接前后这些项目的内容变化。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
进程是计算机操作系统中表示程序执行的一个实体,拥有独立的地址空间,包括程序代码、运行状态、数据和系统资源等。它是程序在执行时所占用的内存、数据等信息的集合。
6.1.2 进程的作用
进程的作用如下:
1.资源管理:进程提供了一种机制,使得操作系统可以有效地管理和分配系统资源,如 CPU 时间、内存、I/O 设备等。
2.并发执行:进程允许多个程序并发执行,提高了系统资源的利用率和系统的吞吐量。
3.数据保护:由于进程间的隔离性,一个进程不能直接访问另一个进程的内存空间,这保护了数据的安全性。
4.模块化设计:进程的独立性使得程序设计可以模块化,每个模块作为一个独立的进程运行,简化了程序设计和调试。
6.2 简述壳Shell-bash的作用与处理流程
Shell是计算机操作系统中的一种命令解释器,它允许用户通过键盘输入命令来与操作系统进行交互。Shell解释并执行用户输入的命令,并将命令的输出结果显示给用户。Bash是Unix/Linux系统上常用的Shell程序之一
Bash的主要作用如下:
1.命令行解释器:Bash解释和执行用户在命令行输入的命令。它能够调用操作系统提供的命令和工具,执行系统指令,管理文件和目录,进行进程管理,等等。
2.脚本编写和执行:Bash支持脚本编写,用户可以使用Bash编写Shell脚本,将一系列命令组合在一起实现特定的功能。脚本可以自动化执行任务,提高工作效率。
3.任务自动化与批处理:Bash脚本可以用于自动化执行一系列任务,减少人工干预,提高效率。用户可以编写脚本来执行备份、定期清理、定时任务等操作。
4.环境配置和自定义:Bash可以通过配置文件或环境变量进行自定义和配置。用户可以修改Bash的行为和功能。
处理流程如下:
1.用户在命令行输入命令。
2.Shell解析用户输入的命令,并根据命令类型、参数等信息确定如何执行该命令。
3.Shell调用相应的系统命令或执行Shell内置命令,并处理命令的输入、输出和错误。
4.执行结果将被显示给用户。
5.整个过程循环进行,用户可以不断输入命令,Shell会持续解析和执行这些命令。
6.3 Hello的fork进程创建过程
Shell执行hello的过程就是一个子进程创建的过程,hello可以看做是shell当前上下文中创建的一个子进程。当调用fork()系统调用时,操作系统会复制当前进程(称为父进程),并创建一个新的子进程。子进程将继承父进程的代码、数据和状态,包括打开的文件和其他资源。父进程和子进程具有相同的执行路径,但在接下来的执行中,它们将被操作系统独立管理。在父进程中,fork()返回子进程的进程ID,而在子进程中,fork()返回0。
6.4 Hello的execve过程
当调用execve()时,操作系统将读取指定的可执行文件,创建一个新的进程,并将其内存映像替换为新程序的代码和数据。然后,操作系统设置新进程的参数和环境变量,并开始执行新程序。在hello的执行中,Shell在fork产生的子进程中,调用 execve() 函数,将./hello程序加载到子进程的地址空间,并开始执行程序的代码。
6.5 Hello的进程执行
当执行一个名为hello的程序时,操作系统会经历一系列步骤来创建、管理和调度进程。以下是hello程序执行的详细过程,包括进程上下文、时间片、调度、以及用户态与核心态的转换:
1.进程创建
加载程序: 用户通过某种方式(例如在命令行输入./hello)请求执行hello程序。操作系统的加载器将程序的代码和初始数据加载到内存中。
创建进程: 操作系统为hello程序创建一个新的进程。这个进程包含了程序的代码、数据、堆栈、环境变量等。
设置栈: 操作系统为新进程设置栈,用于函数调用和局部变量存储。
2.进程上下文
进程上下文包括程序计数器(PC)、寄存器集合、内存管理信息、I/O状态、文件描述符等。
3.进程调度
调度选择: 操作系统的调度器根据某种策略(如轮转、优先级调度等)选择一个就绪状态的进程来运行。
时间片: 选中的进程获得CPU时间片,即一段CPU时间,进程可以在这段时间内执行指令。
执行: hello进程开始执行,使用其分配到的时间片。
4.用户态与核心态转换
用户态: 进程大多数时间在用户态执行,处理应用程序逻辑。
系统调用: 当hello程序需要进行输入/输出操作或请求操作系统服务时,会进行系统调用。这导致从用户态切换到核心态。
上下文切换: 操作系统保存当前进程的上下文,加载要执行的进程的上下文。
核心态: 在核心态,操作系统可以访问所有硬件资源和内存,执行系统调用请求的操作。
返回用户态: 系统调用完成后,控制权返回给用户态的进程,进程继续执行。
6.6 hello的异常与信号处理
1.异常的分类
异常可以被分类为几种类型,每种类型对应不同的系统响应和处理方式。主要有中断、陷阱和系统调用、故障、终止四种类型:
中断:由外部硬件设备或定时器产生的信号,用于中断当前程序的执行。通常不直接产生标准信号,但可以触发处理程序响应硬件事件。操作系统通常会暂停当前程序的执行,响应中断,然后恢复程序执行。
陷阱由程序中的特定指令触发,通常用于调试或异常情况下的同步检查点。可以通过信号处理函数捕获。系统调用是程序请求操作系统服务的一种方式,如文件操作、进程控制等。通常不直接产生信号,但系统调用失败可能设置错误码。系统调用通过返回值或错误码来处理,程序需要检查系统调用的返回状态并作出相应处理。
故障:当程序执行非法操作时触发,如访问无效内存、算术溢出等。产生信号主要有:SIGSEGV,段错误,如访问无效的内存地址。SIGFPE,浮点异常,如算术错误(除以零、溢出)。SIGILL,非法指令,如执行了无效的机器指令。可以通过信号处理函数捕获并尝试恢复,或默认终止程序。
终止:程序正常或异常终止。产生信号主要有:SIGINT,通常由用户中断(Ctrl-C)产生。SIGTERM,终止信号,可以由系统调用如 kill 产生。SIGQUIT,通常由用户退出(Ctrl-\)产生,可能产生核心转储。程序可以通过注册信号处理函数来处理终止信号,执行清理工作然后退出。
2.hello可能出现的异常及相应处理
SIGFPE (浮点异常):如果程序中存在错误的浮点运算,如除以零,将触发 SIGFPE。默认情况下,程序将终止。可以通过信号处理函数来捕获并处理此信号。
SIGSEGV (段错误):如果程序试图访问无效的内存地址,比如解引用一个空指针,将触发 SIGSEGV。默认情况下,程序将终止。可以通过信号处理来避免程序终止,并进行错误恢复。
SIGABRT (异常终止):如果程序调用 abort 函数,将触发 SIGABRT。默认情况下,程序将终止,并可能生成核心转储。可以通过信号处理来执行清理操作。
SIGINT (中断信号):当用户按下 Ctrl-C 时,将向前台进程发送 SIGINT。默认情况下,程序将终止。可以通过捕获 SIGINT 来中断程序执行。
SIGTERM (终止信号):可以由 kill 命令或其他系统调用触发。程序可以捕获 SIGTERM 来执行清理工作,然后退出。
SIGHUP (挂起信号):当用户关闭终端时,将发送 SIGHUP。程序可以选择忽略它或执行特定的清理操作。
SIGQUIT (退出信号):当用户按下 Ctrl-\ 时,将发送 SIGQUIT。默认情况下,程序将终止并生成核心转储。可以通过信号处理来改变这种行为。
SIGILL (非法指令):如果程序执行了非法的机器语言指令,将触发 SIGILL。默认情况下,程序将终止。这种信号通常难以通过程序逻辑来处理。
3.信号处理:
程序可以通过注册信号处理函数来捕获和处理信号。
如果程序没有注册信号处理函数,它会使用默认的信号处理行为,这通常会导致程序立即终止。
4.用户键盘操作、命令及运行结果:
如图,在命令行中输入./hello 参数 ,程序正常执行,程序在完成输出后不会停止。
如图,在执行过程中,随便进行按键(非指令)不会对现行程序hello造成影响,但是输入内容(带有回车)会保留在缓冲区,hello执行完后会被shell当做新的内容进行解析,由于是乱码,所以不能找到相应命令。
按下Ctrl-C:发送 SIGINT 信号给前台进程,程序终止。
按下Ctrl-Z:发送 SIGTSTOP 信号,将前台进程hello放到后台,并进入“停止”。在这种状态下,进程不会消耗 CPU 资源,但它的地址空间和内存仍然被操作系统维护。
ps:列出当前用户的所有进程,可以看到hello的PID为2089,TIME(占用cpu时间)为0,与hello被暂停转入后台相符合。
jobs:列出当前终端会话中的作业,包括后台作业。看到被暂停的hello。
pstree:以树状图的形式显示进程的层次结构。图中黄色框选区域是本终端中的两个进程,hello和pstree。
fg:将后台作业hello带到前台继续运行。
kill -9 PID:发送-9信号(SIGKILL)到指定PID的进程,用于终止该进程。这里发送到2089号进程,终止hello。
6.7本章小结
本章分析了hello的进程管理,由shell-bash出发,分析了hello的fork进程创建过程、execve过程、进程执行结果以及异常与信号处理过程。
第7章 hello的存储管理
7.1 hello的存储器地址空间
在计算机系统中,程序的执行涉及到不同类型的地址,这些地址帮助操作系统管理内存并确保程序的隔离性和安全性。以下结合hello程序来说明逻辑地址、线性地址、虚拟地址和物理地址的概念:
1.逻辑地址:
逻辑地址是在编译时生成的地址,它们是相对于程序的代码或数据段的起始地址的偏移量。编译器在编译程序时,会为程序中的变量、数组、字符串等数据生成逻辑地址。
2.线性地址:
线性地址是逻辑地址与偏移量的组合,是一个连续的地址区域,它可以是逻辑地址空间的一个子集或者与之一致。在操作系统的转换过程中,逻辑地址被转换为线性地址,以便进行内存管理。逻辑地址通常需要通过内存管理单元(MMU)转换为物理地址,而线性地址可以直接映射到物理地址(或通过段表转换)。
3.虚拟地址:
虚拟地址是程序在执行时实际使用的地址,它们构成了程序的虚拟地址空间。虚拟地址允许每个程序拥有自己的地址空间,而这个空间不需要直接映射到物理内存。在hello程序执行时,程序中的变量访问、函数调用等操作都是通过虚拟地址进行的。
4.物理地址:
物理地址是实际存储在物理内存(RAM)上的地址。物理地址是内存单元在硬件上的实际位置。在hello程序执行过程中,当程序访问变量或调用函数时,操作系统的内存管理单元(MMU)会将虚拟地址转换为物理地址, CPU 访问实际内存单元使用物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在Intel架构的段式内存管理允许程序通过段式保护机制安全地访问内存资源。逻辑地址转换为线性地址的过程涉及使用段寄存器中的段选择器来索引全局描述符表(GDT)或局部描述符表(LDT),获取相应的段描述符,然后将段描述符中的基地址与逻辑地址的段内偏移量相加,经过段界限检查,得到线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
hello执行时使用的是虚拟地址,程序本身不需关心物理地址。需要进行内存访问时首先进行页表查找,虚拟地址(线性地址)被内存管理单元(MMU)分解为页号和页内偏移量。接下来MMU 使用页号查找页表,页表包含了虚拟页到物理页的映射。然后在页表中找到对应的包含了虚拟页对应的物理页帧号的页表条目(PTE),如果页表条目指示的页不在物理内存中(缺页),操作系统将处理缺页异常,从磁盘加载所需的页到物理内存,更新页表条目,然后重新执行导致缺页的内存访问。之后将页表条目中的物理页帧号与页内偏移量结合,形成完整的物理地址。(物理页帧号确定了物理内存中的页位置,偏移量指定了页内的具体位置。)最后,CPU 使用这个物理地址来访问实际的内存单元,读取或写入数据。
7.4 TLB与四级页表支持下的VA到PA的变换
1.四级页表的查找:页表查找从根页表开始,使用虚拟页号的最高几位索引根页表,找到第一级页表项。这个页表项指向第二级页表的物理地址。然后,使用下一组虚拟页号的位来索引第二级页表,依此类推,直到找到最终的页表项。CPU 从最终页表项中提取物理页帧号(PFN)。使用提取出的物理页帧号和原始虚拟地址中的页内偏移量,CPU 合成完整的物理地址。物理页帧号确定了物理页在内存中的位置,而偏移量指定了该页内的目标字节位置。
2.缺页处理:如果在页表查找过程中发现所需的页不在物理内存中(即缺页),CPU 将触发缺页异常。操作系统将负责处理缺页异常,将缺失的页从次级存储(如硬盘)加载到物理内存中,并更新页表项以反映新的物理页帧号。
3.TLB 更新:一旦物理地址被确定,CPU 会更新 TLB,将新的虚拟地址到物理地址的映射添加到 TLB 中,以便未来对该地址的访问可以更快地被解析。
7.5 三级Cache支持下的物理内存访问
1.三级缓存(Cache):
L1 Cache是与CPU核心关联的小容量高速缓存,它存储了最常用的数据和指令。L2 Cache是较大容量的高速缓存,通常与多个CPU核心共享。L3 Cache是更大容量的高速缓存,通常作为多个CPU共享的最后级缓存。三级缓存的层次结构是依次递增的,容量也随之递增,但速度递减。因此,CPU在访问物理内存时,会优先访问cache。
2.访问过程:
当CPU需要读取或写入物理内存时,首先会在L1 Cache中查找对应的数据。如果数据在L1 Cache中未命中,CPU会尝试在L2 Cache中查找对应的数据。如果数据在L2 Cache中未命中,CPU会尝试在L3 Cache中查找对应的数据。如果数据在L3 Cache中未命中,则需要进一步访问物理内存。在此过程中,访问一旦命中,就停止向下进行更慢的访问。
3.缓存更新:
当更慢的存储层级发生命中时,会使用缓存的替换策略,将命中的数据页搬运到更快的层级中,以便将来的访问可以更快地进行。
7.6 hello进程fork时的内存映射
1.hello进程fork() 时,会通过复制父进程的地址空间来创建子进程。子进程的虚拟地址空间与父进程相同。创建后子进程将会获得了父进程内存页的一个副本,并继承父进程的内存映射,包括代码段、数据段、堆、栈和任何打开的文件描述符。
2.fork()使用写时复制技术来优化 fork() 的性能。在这种机制下,子进程最初只复制父进程的页表条目,而不是实际的内存页。只有当子进程尝试修改内存页时,才会真正复制数据,从而避免不必要的内存复制。
3.尽管子进程的内存映射与父进程相同,但它拥有自己的进程标识符(PID),并且是作为独立的进程在操作系统中运行。在子进程中,fork() 返回 0。而在父进程中,返回子进程的 PID。子进程可以使用这个返回值来识别自己,并执行与父进程不同的代码路径。
4.子进程继承父进程的环境变量,但任何对环境的更改只会影响当前进程及其子进程,不影响其他进程。
5.子进程和父进程可以继续独立地执行。它们可能根据需要修改自己的内存映射,例如通过加载新的库或分配额外的内存。
7.7 hello进程execve时的内存映射
hello进程execve时,内存映射将发生如下改变:
1.execve会删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2.为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。
3.代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
4.映射共享区域,如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
5.设置当前进程上下文中的程序计数器PC,使之指向代码区域的人口点。下一次调度这个进程时,它将从这个人口点开始执行。Linux将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
缺页故障(Page Fault)发生在程序试图访问的虚拟地址所对应的页不在物理内存中时。当这种情况发生时,内存管理单元(MMU)会触发一个硬件中断,将控制权转交给操作系统的缺页中断处理程序。
操作系统的缺页处理程序首先会分析缺页的原因,检查页表项来确认这是一个有效的缺页事件。如果物理内存已达到容量上限,操作系统需要执行页面置换策略,将不常用的页面从物理内存中移除,为新页腾出空间。随后,操作系统从次级存储中读取所需的页面到物理内存,并更新页表项,将新的物理页帧号与虚拟页关联起来。
在页面加载到物理内存并更新页表之后,操作系统会设置适当的访问权限,确保进程能够按照既定的权限访问内存。然后,操作系统会返回到发生缺页的指令处,重新执行该指令。由于更大的存储一般较慢,所以缺页故障可能导致程序执行出现显著延迟。
7.9动态存储分配管理
printf 函数本身通常不会调用 malloc。但是如果printf 使用了一些内部数据结构或缓冲区,这些数据结构或缓冲区可能是动态分配的,这可能会间接地导致动态内存分配。具体的内存管理主要有以下几方面:
1.动态内存分配:使用 malloc、calloc 或 realloc 等函数从堆(heap)中分配内存。这些函数返回一个指向新分配内存的指针。
2.内存释放:使用 free 函数将之前分配的内存返回给堆,以便可以被其他部分的程序再次使用。
3.内存分配策略:操作系统和运行时库通常有自己的内存分配策略,如首次适应(first-fit)、最佳适应(best-fit)或最差适应(worst-fit)算法。
7.10本章小结
在本章中,我们深入探讨了hello程序在存储管理方面的多个关键概念和机制,涵盖了段式管理和页式管理下物理地址的计算,从逻辑地址到物理地址的转换,,进程的内存映射和动态存储分配,TLB与四级页表支持下的VA到PA的变换,三级缓存支持下的物理内存访问,hello进程fork和execve时的内存映射,缺页故障与缺页中断处理,动态存储分配管理等方面。
结论
"hello" 程序的一生,从无到有,再到执行和结束,可以概括为以下几个阶段:编码阶段:使用高级编程语言(如C语言)编写源代码,定义程序的逻辑和功能。
1.编译阶段:源代码首先预处理,然后经过编译器处理,转换为汇编代码,然后再进一步汇编得到为机器码。
2.链接阶段:链接器将编译生成的目标文件与库文件链接,生成单一的可执行文件,解决外部引用。
3.加载阶段:操作系统的加载器将可执行文件加载到内存中,分配必要的资源,如代码段、数据段和堆栈。
4.执行阶段:CPU按照程序计数器的指示,逐条执行指令,从程序的入口点(main函数)开始。
5.I/O操作:程序通过系统调用执行输入输出操作,实现用户交互。
6.程序终止:程序执行完成后,通过返回语句或执行结束,操作系统接收程序的退出状态码。
7.资源回收:操作系统负责回收程序占用的资源,包括内存空间和打开的文件等。
8.异常处理:在整个生命周期中,程序可能遇到异常情况,需要通过异常处理机制来确保程序的健壮性。
这个过程涵盖了计算机系统中软件生命的全周期,从编码到回收,每个环节都至关重要,确保了程序的可执行性、稳定性和可用性。
在这个过程中,计算机系统的设计和实现需要考虑模块化、抽象、优化、安全性、可扩展性用户体验等多个方面。
在未来,我们可以通过融合创新理念,例如:智能化、去中心化、绿色计算、跨平台兼容性、隐私保护等不断推动计算机系统设计和实现的进步,创造出更加强大、智能和友好的系统。
附件
hello.c | 源文件 |
hello.i | 预处理文件 |
hello.s | 汇编语言 |
hello.o | 机器代码(未链接) |
hello | 可执行文件 |
hello.asm | hello的反汇编文件 |
参考文献
1. Randal E. Bryant 深入理解计算机系统 第三版 机械工业出版社 2017.4
2. 袁春风 计算机系统基础 机械工业出版社第二版 2019.12