计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
计算机科学与技术学院
2023年4月
本文深入探讨了HelloWorld程序在计算机系统中的重要性以及它在编程学习过程中的意义。HelloWorld,作为几乎每位程序员的入门之作,不仅是编程世界的门户,更是理解计算机系统复杂机制的起点。随着程序员能力的增长,他们逐步认识到HelloWorld背后隐含的计算机系统设计者的深刻思想和系统的复杂性。
在编译过程中,源代码通过gcc编译器的多个阶段转换成可执行文件。这包括:
1. 预处理(cpp):处理源代码中的宏定义和预处理指令。
2. 编译(cc1):将预处理后的代码转换成汇编语言。
3. 汇编(as):将汇编代码转换为机器语言。
4. 链接(ld):合并不同的代码和库文件,生成最终的可执行文件。
HelloWorld程序被保存在磁盘上,等待操作系统的调度。当程序运行时,操作系统通过分配虚拟地址空间、提供异常控制流等机制,支持其执行。此外,Unix I/O系统为程序与用户和系统文件的交互提供了必要的接口。
本文不仅揭示了计算机系统在编译源文件、运行进程方面的底层实现,还突出了操作系统、进程加载、C语言底层实现等关键组成部分在整个过程中的作用。通过这种方式,文章连接了计算机系统的核心概念(CSAPP)与程序员的实际编程经验,展现了从编写简单的HelloWorld程序到理解底层复杂系统的知识旅程。
关键词:计算机系统;操作系统;GCC编译器;C语言底层实现;Unix I/O系统
目 录
2.2在Ubuntu下预处理的命令............................................................................. - 8 -
5.3 可执行目标文件hello的格式...................................................................... - 30 -
6.2 简述壳Shell-bash的作用与处理流程........................................................ - 45 -
6.3 Hello的fork进程创建过程......................................................................... - 46 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................... - 55 -
7.3 Hello的线性地址到物理地址的变换-页式管理.......................................... - 57 -
7.4 TLB与四级页表支持下的VA到PA的变换................................................ - 58 -
7.5 三级Cache支持下的物理内存访问............................................................. - 60 -
7.6 hello进程fork时的内存映射..................................................................... - 61 -
7.7 hello进程execve时的内存映射................................................................. - 62 -
7.8 缺页故障与缺页中断处理.............................................................................. - 63 -
8.2 简述Unix IO接口及其函数.......................................................................... - 69 -
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
① P2P (From Program to Process): 这里的P2P并非指“Peer-to-Peer”,而是指“从程序到进程”的转变。程序(Program)是存储在硬盘上的代码,而当程序被运行时,它变成了一个或多个进程(Process),在操作系统的管理下执行。
② 程序的生命周期:
编写代码: 开发者将Hello程序编写为hello.c文件。
预处理、编译、汇编、链接: 这些是将源代码转换成可执行文件的步骤。预处理处理诸如宏定义等,编译将源代码转换为汇编语言,汇编器将汇编代码转换为机器代码,链接器则将多个对象文件链接成一个执行文件。
③ 操作系统的角色:
fork、execve、mmap: 这些是操作系统用来创建和管理进程的系统调用。fork 创建新的进程,execve 载入新的程序到进程,mmap 用于内存映射。
时间片: 操作系统分配给每个进程一定的执行时间。
④ 存储管理:
虚拟地址(VA)到物理地址(PA): 操作系统和内存管理单元(MMU)负责这一转换,确保有效和安全的内存访问。
TLB、页表、缓存: 用于加速内存访问和管理。
⑤ I/O管理: 操作系统管理输入输出设备,确保程序可以和硬件如键盘、显卡等交互。
⑥ 程序的结束: 程序执行完毕后,操作系统负责“收尸”,即清理占用的资源。
⑦ 020 (From Zero-0 to Zero-0): 这个表达可能是在强调程序的生命周期从无到有再到无的过程。
1.2 环境与工具
1.2.1 硬件环境
Processor Intel(R) Core(TM) 2.20 GHz
Installed RAM 16 GB
1.2.2 软件环境
OS Edition Windows 11 & Linux Ubuntu 22.04.3 LTS
System type 64-bit operating system, x64-based processor
Virtual Machine VMware Workstation 17 Player
1.2.3 开发工具
IDE Visual Studio Community 2022 & Code::Blocks 20.03
Compiler GNU GCC -m32 & GNU GCC -m64
1.3 中间结果
图1.3 中间结果文件图[1]
1.4 本章小结
本章讨论了程序编译、系统调用、内存管理和软硬件环境等概念。图示展示了从源代码到可执行文件的不同阶段,包括预处理文件(.i)、汇编语言文件(.s)、可重定位目标文件(.o)和可执行文件。本章着重从框架角度描述了程序到进程的转变,操作系统的角色,存储和I/O管理,程序生命周期的开始和结束,以及使用的开发工具和环境,下面我们将讨论每部分的实现细节。
图1.4 程序实现流程(艺术图)[2]
第2章 预处理
在那个静谧的刹那,程序员的指尖停歇,一次轻盈的点击唤醒了代码世界。hello.c文件,一段简洁的文字,寄托了创造者的智慧,如同灵魂的低语。而现在,随着运行键上小三角的闪烁,这灵魂即将腾飞,穿过预处理的大门,赋予程序以形态,开始它转瞬即逝的生命旅程。
2.1 预处理的概念与作用
预处理是编译过程中的初步阶段,其主要职责是文本替换和文件准备。在这个阶段,预处理器读取源代码文件,执行以井号(#)开头的预处理指令。这些指令不会被编译器直接编译,而是指导预处理器如何准备源代码。常见的预处理指令包括宏定义(#define)用于创建符号常量和宏,文件包含(#include)用于插入头文件的内容,以及条件编译指令(#if, #ifdef, #ifndef, #else, #elif, #endif)用于根据不同的编译条件选择性地编译代码部分。
预处理的作用不仅限于指令执行,它还负责删除所有的注释,并将所有的宏展开成为相应的值或代码片段。此外,预处理器还会处理一些特殊的操作符,如文件和行操作符(#和##),它们用于诊断信息中的错误定位。完成这些操作后,预处理器会生成一个中间文件,通常是扩展名为.i的文件,这个文件包含了所有必要的代码和数据,已经准备好交由编译器进行下一步的编译工作。预处理阶段不涉及语法和语义检查,它只是按照预处理指令执行文本替换和代码整理工作。
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
图2.3.1 代码源文件hello.c[3]
可以看到文件增大,并且在预处理文件的最后包含hello.c源代码。
下面结合截图来具体分析预处理的过程:
图2.3.2 预处理文件片段[4]
① 文件位置标记:# 数字 "文件名"格式的行是预处理器插入的位置标记。这些行告诉编译器,接下来的代码是从哪个文件哪一行开始的。例如,# 1 "hello.c"告诉编译器下一行代码来自hello.c文件的第一行。
② 包含文件展开:当预处理器遇到#include指令时,它会找到指定的文件并将其内容插入到当前文件中。例如,#include <stdio.h>会被stdio.h头文件的实际内容替换。# 1 "/usr/include/stdio.h" 1 3 4这样的行表示stdio.h头文件的内容开始的地方。
③ 宏展开:预处理结果片段中没有直接显示宏展开的例子,但如果有宏定义,预处理器将会把所有的宏名称替换为其定义的内容。
④ 条件编译:预处理器还处理条件编译指令。如果存在像#ifdef这样的条件编译指令,预处理器将根据条件是否满足来决定是否包含相关代码。
⑤ 系统特定的头文件:文件中提到的如/usr/include/x86_64-linux-gnu/bits/types.h这样的路径,表明预处理器根据系统的架构和配置选择了特定的头文件。这些通常包含平台和编译器特定的类型定义和宏。
在预处理结果中,我们可以看到一个具体的例子,typedef long unsigned int size_t;这行代码。这是从stddef.h头文件中提取的,并且在预处理阶段就已经包含到了hello.c文件的预处理输出中。这意味着在源代码中,所有后续引用size_t的地方都将使用long unsigned int作为其类型。这就是预处理器如何在编译之前准备代码的一个实例。
2.4 本章小结
本章深入探讨了预处理在C语言编译过程中的重要性和功能。预处理作为编译的首个阶段,承担着准备源代码以供后续编译的任务。这一阶段的核心工作包括宏的展开、处理头文件的包含、执行条件编译指令等,旨在生成一个没有预处理指令、只含有C语言结构的中间文件。
通过在Ubuntu环境下执行预处理命令的实例,本章详细说明了预处理器如何处理源代码。我们了解到预处理器如何插入文件位置标记、展开包含的头文件、处理宏定义,以及如何基于特定的编译条件选择性地编译代码段。此外,通过分析预处理结果,本章还展示了预处理器如何选择适合特定系统架构和配置的头文件,以及如何从这些文件中提取关键的类型定义,如size_t。
总之,预处理阶段对于整个编译过程至关重要,它不仅确保了源代码的正确转换,还为编译器生成高效的目标代码做好了准备。对预处理有深入理解的程序员能更有效地管理和优化他们的代码,更好地处理编译中的各种问题。
图2.4 预处理流程[5]
第3章 编译
如同一位艺术家将概念草图转化为具体作品,我们的hello.c文件,经过代码的精心拼接,已经获得了完整的结构与灵魂。然而,在程序的二进制世界里,这些用自然语言编织的代码不过是一张初步的蓝图。在这个蓝图能够在计算机的世界中呼吸生命之前,我们需要将其转化为实际的二进制代码结构——一个可执行的目标文件。这一转化的首要步骤,便是将这张人类可理解的蓝图,通过编译过程,转换为计算机可解读的形式——将高级语言编织的梦想,转化为汇编语言的现实。
3.1 编译的概念与作用
编译的概念与作用主要体现在将预处理后的代码(.i 文件)转换为汇编语言程序(.s 文件)。这一过程是程序开发中的关键步骤,它涉及以下几个方面:
① 代码转换:编译器将预处理后的高级语言代码转换为汇编语言。这种转换涉及语法和语义分析,确保代码符合语言规范并且逻辑上正确。
② 编译器优化:在编译过程中,编译器会进行代码优化,提高程序的运行效率。这包括删除冗余代码、优化循环结构、改善变量存储等。
③ 错误检测:编译器在转换代码的同时,也会检查代码中的错误,如语法错误、类型不匹配等,并给出相应的错误信息。
④ 平台适配:编译过程还包括根据目标平台的特定指令集和硬件特性,将代码转换为相应平台的汇编语言。
⑤ 中间表示生成:编译器通常会生成一种中间表示(Intermediate Representation, IR)的代码,这种代码是介于高级语言和机器语言之间的一种形式,有助于进一步的优化和转换。
总的来说,编译过程是将人类可读的高级语言代码转换为计算机可执行的低级语言代码的过程,它不仅涉及代码的直接转换,还包括优化、错误检测和平台适配等多个方面,是软件开发中不可或缺的一环。
3.2 在Ubuntu下编译的命令
编译命令: gcc -S hello.i -o hello.s
目标:将预处理后的代码(.i 文件)转换为汇编语言程序(.s 文件)
3.3 Hello的编译结果解析
本节描述编译器对不同类型数据和操作的具体处理过程:
图3.3 汇编代码截图[6]
下面我们将结合 图2-1(源代码)和 图3-1(汇编代码)进行分析:
3.3.1 数据的处理
① 常量:
在C代码中,字符串"用法: Hello 学号 姓名 秒数!\n"是一个常量。
在汇编代码中,这个字符串常量被存储在.rodata(只读数据)段中,对应的是.LC0标签下的.string指令。
② 变量:
局部变量:在C代码中,int i是一个局部变量。
在汇编代码中,这个局部变量i被存储在栈上,如-4(%rbp)。这是通过指令subq $32, %rsp为局部变量分配空间,然后通过类似movl $0, -4(%rbp)的指令进行操作。
③ 表达式:
在C代码中,argc != 4是一个表达式。
在汇编代码中,这个表达式被转换为一系列指令,首先是cmpl $4, -20(%rbp),这是比较操作,然后是基于这个比较结果的跳转指令je .L2。
④ 类型:
在C代码中,我们看到int类型用于变量i和函数main的返回类型。
在汇编代码中,类型信息不是直接可见的,但是通过使用的寄存器和操作的位宽(如32位操作用movl)可以间接推断出来。
3.3.2 赋值处理
① 赋值操作:
在C代码中,赋值操作主要出现在变量初始化和循环控制中,例如int i的初始化i = 0和循环中的i++。
在汇编代码中,这些赋值操作表现为对内存地址或寄存器的直接操作。例如,i = 0在汇编中对应movl $0, -4(%rbp),这是将0赋值给存储在栈上的变量i。循环中的i++对应addl $1, -4(%rbp),即在原有值的基础上增加1。
② 逗号操作符:
在C代码中,逗号操作符用于分隔表达式;在汇编代码中,逗号操作符不直接体现。汇编语言通常每条指令执行一个操作,因此不需要逗号来分隔表达式。
③ 变量初始化(是否初始化):
在C代码中,局部变量i在声明时就被初始化为0(int i = 0;)。这是一个明确的初始化操作。
在汇编代码中,变量的初始化体现为在变量被首次使用前给其分配一个初始值。例如,i的初始化在汇编中对应movl $0, -4(%rbp),这是在栈上为i分配初始值0。
3.3.3 类型转换
① 类型转换:
在C代码中,最明显的类型转换出现在sleep(atoi(argv[3]));这一行。这里,atoi函数将字符串转换为整数(int)类型。这是一个显式的类型转换。
在汇编代码中,类型转换不是直接可见的,但可以通过分析相关指令推断出来。例如,call atoi@PLT调用atoi函数进行字符串到整数的转换,然后这个整数值被用作sleep函数的参数。
② 隐式类型转换:
在C代码中,如果存在隐式类型转换,它通常发生在赋值或函数参数传递时。
在汇编代码中,隐式类型转换可能体现在操作数的大小和使用的寄存器上。例如,32位整数和64位整数可能会使用不同大小的寄存器。
③ 显式类型转换:
如前所述,atoi(argv[3])是一个显式的类型转换示例。
在汇编代码中,这种转换体现为对应函数(如atoi)的调用。
④ 不同数据类型的处理:
在C代码中,虽然只显式使用了int类型,但其他类型(如unsigned、char、long、float、double)的处理也遵循类似的规则:类型决定了变量在内存中的存储大小和操作指令的选择。
在汇编代码中,不同数据类型的处理主要体现在操作数的大小和使用的寄存器上。例如,对于float和double类型,可能会使用专门的浮点寄存器和指令。
⑤ sizeof操作:
在C代码中,sizeof操作用于确定数据类型或变量在内存中的大小。但在您提供的代码中没有使用sizeof。
在汇编代码中,sizeof操作通常在编译时就已经被解析,因此不会直接体现为特定的汇编指令。
3.3.4 算术操作
① 加法(+)和减法(-):
在C代码中,虽然没有直接的加法或减法操作,但循环中的i++实际上是i = i + 1的简写形式。
在汇编代码中,i++对应于addl $1, -4(%rbp),这是将存储在栈上的变量i的值增加1。
② 乘法(*)和除法(/):
该C代码中没有显式的乘法或除法操作。
如果存在,这些操作通常会转换为汇编中的mul(乘法)或div(除法)指令。
③ 取模(%):
源代码中没有使用取模操作。
在汇编中,取模操作通常与除法操作一起实现,结果存储在特定的寄存器中。
④ 自增(++)和自减(--):
如前所述,i++在汇编中对应于addl $1, -4(%rbp)。
自减操作(如果存在)会类似地转换为减法指令。
⑤ 取正/负(+/-):
源代码中没有显式的取正或取负操作。
在汇编中,取负操作通常通过neg指令实现,取正操作一般不需要特殊指令。
⑥ 复合赋值操作(如+=):
源代码中没有复合赋值操作。
如果存在,这些操作会被转换为相应的算术指令和赋值指令的组合。例如,a += b会转换为先进行加法操作,然后将结果存储回变量a。
3.3.5 逻辑/位操作
① 逻辑操作:
源代码中没有直接使用逻辑与(&&)、逻辑或(||)或逻辑非(!)操作。
如果存在,这些操作通常会转换为汇编中的条件跳转指令。例如,逻辑与可能会转换为一系列的比较和跳转指令,以确保所有条件都满足。
② 位操作:
源代码中没有使用位与(&)、位或(|)、位非(~)或位异或(^)操作。
在汇编中,这些操作会直接转换为相应的位操作指令,如and、or、xor等。
③ 移位操作:
源代码中没有使用位移操作(>>、<<)。
如果存在,这些操作在汇编中通常会转换为shr(逻辑右移)、shl(逻辑左移)、sar(算术右移)或sal(算术左移)指令。
④ 复合位操作:
源代码中没有使用复合位操作,如|=或<<=。
如果存在,这些操作在汇编中会被转换为相应的位操作指令和赋值指令的组合。
3.3.6 关系操作
① 等于(==)和不等于(!=):
在C代码中,if(argc != 4)使用了不等于(!=)操作来比较argc和数字4。
在汇编代码中,这个比较转换为cmpl $4, -20(%rbp),即将argc与4进行比较。接着,je .L2指令用于跳转,如果比较结果为“等于”,则跳转到.L2标签。这里的“跳转如果相等(je)”实际上是实现“不等于”逻辑的一部分。
② 大于(>)、小于(<):
源代码中没有直接使用大于或小于操作。
如果存在,这些操作在汇编中通常会转换为比较指令(cmp)后跟相应的条件跳转指令,如jg(跳转如果大于)或jl(跳转如果小于)。
③ 大于等于(>=)和小于等于(<=):
源代码中没有使用大于等于或小于等于操作。
在汇编中,这些操作也会通过比较指令和条件跳转指令实现,例如jge(跳转如果大于等于)或jle(跳转如果小于等于)。
3.3.7 数组/指针/结构操作
① 数组访问(A[i]):
在C代码中,通过argv[1]和argv[2]访问数组元素。这里argv是一个指向字符串的指针数组。
在汇编代码中,这些访问转换为对指针的偏移和间接引用。例如,movq -32(%rbp), %rax 加载argv的地址,然后通过addq $16, %rax 和 addq $8, %rax 来获取argv[1]和argv[2]的地址。
② 取地址(&v):
源代码中没有直接使用取地址操作。
在汇编中,取地址操作通常涉及获取变量的内存地址,但这通常是隐式进行的。
③ 解引用(*p):
源代码中通过argv[1]和argv[2]间接访问字符串,这可以看作是对指针的解引用。
在汇编代码中,解引用通过间接寻址模式实现,例如movq (%rax), %rdx,这里(%rax)表示rax寄存器中地址所指向的值。
④ 结构体成员访问(s.id):
源代码中没有使用结构体和其成员访问。
如果存在,结构体成员访问在汇编中通常通过计算偏移量来实现。
⑤ 通过指针访问结构体成员(p->id):
源代码中没有使用通过指针访问结构体成员的操作。
在汇编中,这类操作会转换为先解引用指针,然后访问特定的偏移量。
3.3.8 控制转移
① if/else:
在C代码中,if(argc != 4)是一个if语句的例子。如果条件不满足(即argc不等于4),程序打印一条消息并退出。
在汇编代码中,这个if语句转换为cmpl $4, -20(%rbp)(比较argc和4),接着是基于这个比较结果的跳转指令je .L2。如果条件为真(即argc等于4),执行跳转到.L2。
② switch:
源代码中没有使用switch语句。
在汇编中,switch通常通过一系列的比较和跳转指令实现,有时候会使用跳转表来优化。
③ for、while、do/while:
源代码中的for(i = 0; i < 8; i++)是一个for循环的例子。它控制打印和休眠操作的次数。
在汇编代码中,这个循环转换为设置循环初始条件(movl $0, -4(%rbp)),循环条件的检查(cmpl $7, -4(%rbp)),以及基于条件的跳转指令(jle .L4)。
④ 条件运算符(?:):
源代码中没有使用条件运算符。
在汇编中,条件运算符通常通过条件跳转指令实现。
⑤ continue和break:
源代码中没有直接使用continue或break。
在汇编中,continue通常通过跳转到循环的开始部分实现,而break通过跳转到循环结束部分实现。
3.3.9 函数操作
① 参数传递(地址/值):
在C代码中,main函数的定义为int main(int argc, char *argv[])。这里,argc是一个整数,按值传递,而argv是一个指向字符数组的指针,按地址传递。
在汇编代码中,argc的值通过寄存器%edi传递,这可以看到在main:标签下的movl %edi, -20(%rbp),这是将argc的值存储到栈上。同样,argv的地址通过寄存器%rsi传递,然后存储到栈上,如movq %rsi, -32(%rbp)。
② 函数调用:
在C代码中,例如printf("Hello %s %s\n", argv[1], argv[2]);是一个函数调用的例子。
在汇编代码中,这对应于call printf@PLT。在这个调用之前,参数被设置好并放在适当的寄存器中。例如,argv[1]和argv[2]的值被加载到寄存器%rsi和%rdx中,然后用于printf调用。
③ 局部变量:
在C代码中,int i;声明了一个局部变量i。
在汇编代码中,i被分配在栈上,如-4(%rbp)的位置。例如,movl $0, -4(%rbp)是初始化i为0的操作。
④ 函数返回:
在C代码中,return 0;表示main函数返回0。
在汇编代码中,这通过将0放入%eax寄存器来实现,对应于movl $0, %eax。ret指令用于从函数返回,它会跳转回函数被调用的地方。
3.4 本章小结
本章详细探讨了编译过程,即将高级语言编写的源代码转换为计算机可执行的汇编语言的关键步骤。通过对hello.c程序的编译实例分析,本章展示了编译器如何处理不同类型的数据和操作,以及如何进行代码优化和错误检测,确保代码适应特定的硬件平台。
关键内容包括:
① 编译的角色:编译不仅是代码转换的过程,还包括优化和适配,以提高程序的运行效率和跨平台兼容性。
② 编译过程:涵盖了从预处理后的代码转换到汇编语言的详细步骤,包括语法和语义分析、编译器优化、错误检测和平台适配。
③ 汇编代码分析:通过具体的汇编代码示例,解释了编译器如何处理变量、表达式、类型转换、算术和逻辑操作等,以及如何将这些高级语言结构转换为底层指令。
总结来说,本章通过深入分析编译过程,提供了对程序从源代码到可执行文件转换过程的全面理解,突出了编译器在程序开发中的核心作用,特别是在代码优化和平台适配方面的重要性。
图3.4 编译结构图[7]
第4章 汇编
hello.c程序,最初只是程序员心中的灵感,经过预处理和编译,这份灵感逐渐被赋予了计算机可解读的形态。然而,对于只懂得二进制语言的CPU来说,汇编语言仍如天书。因此,我们必须将hello.s文件转化为hello.o文件,使之成为CPU能够直接识别和执行的二进制旋律。这一过程,就像是将诗意的构思翻译成机器的严谨语言。
4.1 汇编的概念与作用
汇编的过程是将编译器生成的汇编语言文件(通常以.s为扩展名)转换为机器语言的二进制对象文件(.o文件)。这一过程由汇编器(Assembler)执行,它是编译过程中的一个关键步骤。汇编器的主要任务是解析和转换汇编指令,将其从人类可读的符号代码转化为机器可执行的指令集。
在这个过程中,每条汇编指令通常对应一条或多条机器指令。汇编器还处理诸如符号解析、地址和存储分配等任务。符号解析涉及将变量和函数名映射到其地址。此外,汇编过程还可能包括宏处理和文件链接准备等操作。最终生成的.o文件包含了程序执行所需的所有机器代码和数据,为后续的链接过程做好准备,以生成最终的可执行文件。
4.2 在Ubuntu下汇编的命令
在命令行中输入:as hello.s -o hello.o 完成汇编过程。
4.3 可重定位目标elf格式
4.3.1 cmd命令
在命令行中输入:readelf -a hello.o > ./elf_hello_o.txt
4.3.2 ELF文件分析:
图4.3.2 ELF文件[8]
① ELF头部(ELF Header):
Magic Number: 7f 45 4c 46,标识这是一个ELF文件。
Class: ELF64,表示这是一个64位的ELF文件。
Data: 2's complement, little endian,指定了数据的编码方式。
Type: REL (Relocatable file),表示这是一个可重定位文件,通常是编译但未链接的代码。
Section Headers: 描述了文件的不同部分,如代码段、数据段等。
② 节头部(Section Headers):
描述了文件中各个节(section)的属性和位置。
.text: 包含程序的执行代码。
.data: 包含已初始化的全局和静态变量。
.bss: 用于未初始化的全局和静态变量。
.rodata: 包含只读数据,如字符串常量。
.symtab: 符号表,包含程序中函数和变量的信息。
.strtab 和 .shstrtab: 字符串表,用于存储符号和节头部的名称。
.rela.text 和 .rela.eh_frame: 包含重定位信息。
③ 重定位部分(Relocation Sections):
.rela.text 和 .rela.eh_frame 是两个重要的重定位部分。这些部分包含了重定位条目,用于在链接过程中调整代码和数据引用。
每个重定位条目包含如下信息:
Offset:需要修改的代码或数据在节中的偏移量。
Info:提供关于符号和重定位类型的信息。
Type:重定位的类型,如R_X86_64_PC32,指定了重定位操作的具体方式。
Sym. Value 和 Sym. Name:引用的符号的值和名称。
④ 符号表(Symbol Table):
.symtab 节包含了程序中定义和引用的所有符号的列表,这些符号包括函数、变量等。
每个符号条目包含如下信息:
Value:符号的地址或偏移量。Size:符号数据的大小。
Type:符号的类型,如FUNC(函数)或NOTYPE(未指定类型)。
Bind:符号的绑定属性,如LOCAL(局部)或GLOBAL(全局)。
Ndx:符号所在的节索引。Name:符号的名称。
例如,条目4: 0000000000000000 152 FUNC GLOBAL DEFAULT 1 main表示main函数是一个全局符号,位于.text节,大小为152字节。
通过这些信息,我们可以了解ELF文件的结构和内容,以及它如何在系统中被加载和执行。这个特定的ELF文件是一个可重定位文件,包含了未链接的代码和数据,适用于64位的AMD X86-64架构。
4.4 Hello.o的结果解析
4.4.1 cmd命令
在命令行中输入:objdump -d -r hello.o > dump_hello_o.txt
4.4.2 文件结果分析
图4.4.2 hello.o经反汇编后的文件[9]
① 机器语言与汇编语言的映射:
汇编语言中的指令和标签(如.text、.LC0、main等)在机器语言中被转换为具体的地址和操作码。
(i)函数入口:
在hello.s中,函数main的开始标记为main:
在hello.o的反汇编中,这对应于地址0000000000000000 <main>:。汇编指令endbr64、push %rbp、mov %rsp,%rbp等在机器语言中分别对应于f3 0f 1e fa、55、48 89 e5。
(ii)局部变量操作:
在hello.s中,有指令subq $32, %rsp为局部变量分配空间,movl %edi, -20(%rbp)和movq %rsi, -32(%rbp)用于初始化局部变量。
在hello.o中,这些指令分别对应于48 83 ec 20(分配空间)、89 7d ec(移动%edi)、48 89 75 e0(移动%rsi)。
(iii)条件判断和跳转:
hello.s中的cmpl $4, -20(%rbp)和je .L2用于条件判断和跳转。
在hello.o中,这些指令转换为83 7d ec 04(比较操作)和74 19(条件跳转)。跳转指令74 19表示如果条件满足,则跳转到指定的偏移地址。
(iv)字符串和函数调用:
hello.s中的.LC0和.LC1标签指向字符串常量,call puts@PLT和call printf@PLT是函数调用。
在hello.o中,字符串通过偏移地址引用,如48 8d 05 00 00 00 00(引用.LC0)。函数调用转换为类似e8 00 00 00 00的形式,其中包含了函数的相对地址偏移。
(v)循环结构:
hello.s中的循环结构通过jmp .L3和cmpl $7, -4(%rbp)等指令实现。
在hello.o中,这些指令转换为eb 4b(无条件跳转)和83 7d fc 07(比较操作)。这些机器指令直接控制程序的执行流程。
② 操作数的处理:
在汇编语言中,操作数通常以符号形式出现,如寄存器名(%rbp、%rax)和变量名。
在机器语言中,这些符号被转换为特定的寄存器代码和内存地址。例如,%rbp和%rax对应于特定的寄存器代码。
③ 分支转移和函数调用:
汇编语言中的跳转和调用指令(如je .L2、call printf@PLT)在机器语言中转换为相应的操作码和地址偏移。
例如,call指令在机器语言中可能表示为一系列字节,其中包含调用函数的地址或偏移量。
分支转移(如je、jmp)在机器语言中通过地址偏移实现,这些偏移指示了跳转的目标地址。
④ 重定位条目的处理:
在反汇编中,可以看到重定位条目的注释,如R_X86_64_PC32 .rodata - 4。这些条目在链接时被解析,以确定正确的地址和偏移。
4.5 本章小结
本章深入探讨了从高级语言编写的源代码到生成机器可执行的二进制代码的整个过程,特别是汇编过程的概念与作用。我们首先了解了汇编过程,即将编译器生成的汇编语言文件(.s文件)转换为机器语言的二进制对象文件(.o文件)。这一过程涉及解析汇编指令、处理符号和地址分配,以及准备文件链接等任务,最终生成包含所有必要机器代码和数据的.o文件。
接着,我们探讨了在Ubuntu环境下执行汇编的具体命令,以及如何使用readelf和objdump工具来分析生成的ELF格式的可重定位目标文件。通过这些工具,我们能够详细了解ELF文件的结构,包括ELF头部、节头部、重定位部分和符号表等关键信息。
最后,本章通过反汇编hello.o文件,深入分析了机器语言与汇编语言之间的映射关系。这包括了函数入口、局部变量操作、条件判断、跳转、字符串和函数调用的处理,以及重定位条目的解析。这些分析帮助我们理解了机器语言的构成,以及它是如何从汇编语言中转换而来的,尤其是在操作数处理、分支转移和函数调用等方面的细节。
总的来说,本章不仅提供了对汇编过程的全面理解,还展示了如何使用工具来分析和理解生成的机器代码,这对于深入理解程序的编译和执行过程至关重要。
图4.5 汇编结构图[10]
第5章 链接
5.1 链接的概念与作用
链接是将多个对象文件(.o文件)合并成一个单一的可执行文件的过程。它解析和合并各个对象文件中的符号引用,处理重定位,确保程序中的各部分正确地相互引用。链接既可以是静态的,将所有必要的库和模块编译进最终的可执行文件,也可以是动态的,链接时仅包含对共享库的引用。这一过程对于构建最终可运行的程序至关重要。
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
5.3 可执行目标文件hello的格式
图5.3.1 ELF文件截图[11]
图5.3.2 ELF文件截图[12]
图5.3.3 ELF文件截图[13]
图5.3.4 ELF文件截图[14]
图5.3.5 ELF文件截图[15]
hello的ELF格式文件分析:
① ELF头部(ELF Header):
类型:EXEC(可执行文件)。
机器:Advanced Micro Devices X86-64,适用于64位AMD X86架构。
入口点地址:0x4010f0,程序执行的起始地址。
节头部起始地址:13560字节处。
节头部大小:64字节,共有27个节头部。
② 节头部(Section Headers):
.dynsym:动态链接符号表,起始地址0x4003a8,大小216字节。
.dynstr:动态链接字符串表,存储符号名。
.rela.dyn 和 .rela.plt:重定位条目,用于动态链接。
.init 和 .fini:程序初始化和终止时执行的代码。
.plt 和 .plt.sec:过程链接表,用于动态链接。
.text:程序的主要执行代码,起始地址0x4010f0,大小205字节。
.rodata:只读数据段,如字符串常量。
.eh_frame:异常处理框架。
.dynamic:动态链接信息。
.data:已初始化的全局和静态变量。
.comment:编译器注释。
.symtab 和 .strtab:符号表和字符串表,用于调试和静态链接。
.shstrtab:节头部字符串表,存储节名。
③ 程序头部(Program Headers):
描述了程序的内存布局和如何将节映射到内存中。
包括不同类型的段,如LOAD(加载到内存的段)、DYNAMIC(动态链接信息)等。
LOAD:定义了程序加载到内存时的几个段,包括它们的虚拟地址、物理地址、文件大小和内存大小。
DYNAMIC:包含了动态链接所需的信息,如所需共享库和重定位表。
④ 动态链接信息(Dynamic Section):
包含了动态链接器处理所需的各种标签和值,如所需的共享库(NEEDED)、初始化和终止函数的地址(INIT、FINI)等。
HASH 和 GNU_HASH:提供了动态链接时用于快速符号查找的哈希表。
STRTAB 和 SYMTAB:分别是动态链接时使用的字符串表和符号表。
RELA:包含了重定位条目,用于调整程序中的符号引用。
⑤ 重定位部分(Relocation Sections):
.rela.dyn 和 .rela.plt:包含了重定位条目,这些条目在程序加载到内存时由动态链接器处理,以确保符号引用指向正确的地址。这些条目包括符号的偏移量、类型和相关的符号信息。
⑥ 符号表(Symbol Tables):
.dynsym:动态链接时使用的符号表,包含了程序中引用的所有动态符号。
.symtab:包含了程序中所有符号的详细信息,通常用于调试。
5.4 hello的虚拟地址空间
图5.4 可执行文件信息图[16]
使用edb加载hello, data dump窗口可以查看加载到虚拟地址中的hello程序
下面是其与ELF文件的对比分析:
① 魔数和文件类别:
图片顶部的7f 45 4c 46是ELF文件的魔数,它标志着文件的开始,并且表示这是一个ELF格式的文件。接下来的02表示这是一个64位的ELF文件,01表示它是小端序。
② 程序头(PHDR):
在ELF文件中,程序头紧随文件头之后。它通常开始于文件的第64个字节,如您之前描述的PHDR部分。这些程序头指导操作系统如何将文件内容加载到内存中。
③ 解释器路径(INTERP):
虽然在图片中不直接显示,但是如果文件包含一个解释器路径段,那么它会告诉操作系统在启动程序之前加载哪个动态链接器。这是动态链接ELF文件的必要部分。
④ LOAD段:
这是文件最重要的部分之一,描述了哪些内存段需要从文件映射到进程的虚拟地址空间。例如,一个LOAD段可能指明文件中的代码段(.text)应该被加载到内存的某个位置,设置为可读和可执行。
⑤ 动态链接信息(DYNAMIC):
如果这个ELF文件是动态链接的,则DYNAMIC段包含了动态链接器运行时所需的信息,如所需共享库的名称和查找符号的位置。
⑥ 辅助信息(NOTE):
NOTE段通常包含了一些辅助信息,例如,在Linux系统上,它可能包含了关于生成文件的操作系统版本的信息。
⑦ 栈权限(GNU_STACK):
ELF文件还可以指定栈的权限,例如是否允许执行栈上的代码。这通常是通过一种特殊的程序头来实现的,它没有直接的内存映射,但会影响操作系统如何处理栈。
⑧ 只读重定位(GNU_RELRO):
为了安全,某些内存区域在程序启动后就不需要修改了。GNU_RELRO段描述了哪些区域应该在程序启动后设置为只读,以防止恶意代码修改它们。
5.5 链接的重定位过程分析
在命令行中输入:objdump -d -r hello > dump_hello.txt
图5.5 hello经反汇编后的文件[17]
下面是结合hello和hello.o反汇编得到的文件,对链接过程进行的分析:
在链接过程中,链接器(如ld)将一个或多个目标文件(.o文件)以及所需的库转换成一个可执行文件。在这个过程中,链接器执行了一系列的复杂步骤。
① 符号解析过程:
目标文件hello.o: 包含符号引用,如函数调用printf,但不包含这些函数的实际代码。例如,call printf后面跟着的是一个相对地址或者一个占位符。
可执行文件hello: 所有外部符号引用都已解析到正确的地址。例如,call printf将被转换为对应的内存地址或者指向全局偏移表(GOT)的指针。
② 地址空间分配:
目标文件hello.o: 段(如.text, .data)有自己的相对偏移,但没有最终的内存地址。
可执行文件hello: 链接器为每个段分配了最终的内存地址,并更新了段内的指令和数据引用以反映这些地址。
③ 段合并:
目标文件hello.o: 各个段是独立的,例如.text和.data。
可执行文件hello: 链接器将所有.text段合并成一个单独的.text段,将所有.data段合并成一个单独的.data段,等等。
④ 动态链接:
目标文件hello.o: 包含对动态链接库的未解析引用,如printf。
可执行文件hello: 包含了动态链接库(如libc.so.6)中符号的实际引用,这些引用通过GOT和PLT进行管理。
⑤ 重定位条目的应用:
目标文件hello.o: 包含重定位条目,这些条目描述了链接器需要如何修改代码和数据中的地址引用。
可执行文件hello: 链接器已经处理了所有的重定位条目,所有符号引用都指向了正确的地址。
⑥ 处理入口点:
目标文件hello.o: 不指定程序的入口点。
可执行文件hello: 链接器设置了程序的入口点(通常是_start),这是程序开始执行的地方。
例如,考虑以下反汇编代码片段:
在hello.o中:
23: e8 00 00 00 00 call 28 <main+0x28>
24: R_X86_64_PLT32 puts-0x4
这表示puts函数的调用是通过PLT进行的,R_X86_64_PLT32是一个重定位条目,告诉链接器需要在地址24加上puts函数的正确地址。
在hello中:
401148: e8 43 ff ff ff call 401090 <puts@plt>
在可执行文件中,call指令现在包含了实际的偏移,指向了PLT中puts函数的条目地址。
⑦ 对重定位的深入讨论:
在目标文件(如hello.o)中,重定位条目(Relocation entries)是编译器生成的,用于告诉链接器一旦确定了每个符号的最终地址,如何修改这些符号在代码和数据中的引用。
首先,让我们看一个具体的重定位条目示例:
在hello.o中,可能会有类似这样的代码:
0000000000000000 <main>:
...
23: e8 00 00 00 00 callq 28 <main+0x28>
24: R_X86_64_PLT32 puts-0x4
...
这里的callq是对puts函数的调用,e8 00 00 00 00是调用指令,后面跟着的地址是一个占位符。R_X86_64_PLT32 puts-0x4是一个重定位条目,告诉链接器这个调用需要链接到puts函数。
链接器将根据以下步骤进行重定位:
解析符号地址:
链接器首先要解析puts的地址。如果puts是一个库函数,链接器会在动态链接库(比如libc.so.6)中找到它的地址。
计算重定位偏移:
对于PLT32重定位条目,链接器会计算从call指令到puts函数的偏移量。在64位系统上,这通常是一个32位的相对地址。
应用重定位:
链接器将计算出的偏移量写入到call指令的占位符位置,替换00 00 00 00。
在最终的可执行文件hello中,重定位后的代码可能看起来像这样:
0000000000401030 <main>:
...
401048: e8 c3 05 00 00 callq 405c10 <puts@plt>
...
在这个例子中,e8 c3 05 00 00中的c3 05 00 00是链接器计算出的偏移量,它表示从当前指令到puts函数在程序链接表(PLT)中条目的偏移。这样,当main函数执行到这条call指令时,处理器会跳转到正确的地址执行puts函数。
重定位过程中的其他考虑事项包括:
如果是静态链接,链接器还需要处理所有其他的.o文件和静态库中的符号。
如果有全局变量或静态变量的地址需要确定,链接器也会更新这些引用。
对于全局偏移表(GOT)中的条目,链接器会更新GOT中的地址,使得在运行时动态链接器可以填入最终解析的地址。
重定位是链接过程中解决符号引用的关键步骤,确保了代码在运行时能够访问正确的函数和变量。
5.6 hello的执行流程
图5.6.1 跳转的函数地址[18]
图5.6.2 edb调试过程的部分截图[19]
5.7 Hello的动态链接分析
图5.7.1 动态链接符号列表[20]
图5.7.2 ELF文件中涉及动态链接的部分[21]
图5.7.3 edb调试过程截图[22]
使用edb调试,截取两次进入到main函数的部分,可以发现程序利用代码段和数据段的相对位置不变的原则计算变量的正确地址。
对于库函数的链接,需要.plt和.got,此处使用gdb调试:
图5.7.4 .plt和.got信息图[23]
.plt节:
.plt(程序链接表)节用于动态链接,每个条目通常包含一个跳转指令,它跳转到.got节中相应的地址。.plt节的前几条指令通常是与动态链接器协作的引导代码。
0x401020: 这是PLT的第一条指令,它通过间接跳转(通过%rip相对寻址)使用.got节的地址。
0x401026: 通常是PLT的第二条指令,它也是一个间接跳转,但是具体的目标地址是在.got节中。
接下来的指令0x401030、0x401040等,都是PLT的一部分,它们可能是特定函数的PLT条目,用于在第一次调用时触发动态解析。
.got节:
.got(全局偏移量表)节包含了程序中使用的全局数据的地址。对于动态链接的函数,.got中会有指向相应PLT条目的指针,这些指针在程序执行时会被动态链接器更新。
0x404000: 显示了.got节的前几个条目。第一个条目是一个函数地址,它指向了.dynamic节,可能是用于动态链接器的某种引导函数。
0x404010 和之后的地址是.got.plt节的一部分,每个条目对应一个PLT条目。初始时,这些条目指向PLT中的跳转指令,这些跳转指令会触发动态解析。
5.8 本章小结
本章节主要介绍了链接的基本概念、作用以及在Ubuntu下的链接命令。链接是编译过程中的一个重要环节,它将多个对象文件合并成一个完整的可执行文件,并处理符号引用和重定位。本章还解释如何通过objdump和edb等工具来分析hello程序的ELF格式文件和动态链接过程。通过这些分析,我们能够更好地理解程序的内存布局以及动态链接器是如何在程序运行时解析和更新符号地址的。最后,本章通过一系列的示例和工具使用说明,提供了对程序从链接到运行全过程的深入了解。
图5-8 程序链接流程图[24]
第6章 hello进程管理
6.1 进程的概念与作用
进程指的是在系统中正在运行的一个程序的实例。它不仅包括程序代码本身,还包括程序的当前活动,如程序计数器的状态、寄存器和变量的当前值等。进程是操作系统进行资源分配和调度的基本单位,具有以下几个关键作用:
① 资源分配:操作系统通过进程来分配和管理资源,如内存、处理器时间、I/O设备等。
② 隔离和保护:每个进程在自己的虚拟地址空间中运行,保证了不同进程之间的独立性和安全性。
③ 并发执行:多个进程可以并发执行,提高了计算机系统的效率和吞吐量。这是通过时间分片或多核处理器实现的。
④ 通信和协作:进程之间可以通过各种进程间通信机制(如管道、消息队列、共享内存等)进行数据交换和协作。
⑤ 状态管理:进程具有多种状态(如就绪、运行、等待、终止等),操作系统通过状态管理来优化进程的执行和资源利用。
总之,进程是操作系统结构的核心,使得计算机能够有效地运行多个任务,同时保证系统资源的合理分配和使用。
6.2 简述壳Shell-bash的作用与处理流程
Shell(特别是Bash)是一个命令行界面,用于与操作系统交互。它的主要作用是解释用户输入的命令,并将其转换为操作系统能理解和执行的指令。
处理流程大致如下:
命令输入:用户在命令行界面输入命令。
命令解析:Shell解析输入的命令,包括分离命令和参数,处理特殊字符(如引号、管道符)。
命令执行:Shell根据解析结果执行相应的程序或内置命令。
环境变量和配置文件:在启动时,Shell会加载环境变量和配置文件,如.bashrc,来定制环境和行为。
脚本执行:除了交互式命令,Shell还可以执行写有一系列命令的脚本文件。
Bash作为一种广泛使用的Shell,提供了高级特性,如命令历史、自动补全、文件通配符、管道和重定向等,使得用户与系统的交互更加高效和灵活。
6.3 Hello的fork进程创建过程
当一个程序(例如一个名为 "Hello" 的程序)在类Unix系统中执行 fork() 系统调用以创建进程时,其过程大致如下图:
图6.3 fork进程创建流程图[25]
6.4 Hello的execve过程
当一个名为 "Hello" 的程序在类Unix系统中执行 execve() 系统调用时,其过程如下:
① 调用 execve():程序中的执行流到达 execve() 系统调用。这通常是在程序的源代码中明确编写的,用于加载并执行一个新的程序。
② 指定程序和参数:execve() 调用需要指定要执行的新程序的路径,以及传递给该程序的参数列表和环境变量。
③ 终止当前进程:操作系统开始执行 execve() 调用,首先终止当前运行的 "Hello" 程序的进程。
④ 加载新程序:操作系统加载指定的新程序到当前进程的内存空间中。这包括读取可执行文件,设置内存空间(代码段、数据段、堆栈等)。
⑤ 传递控制权:一旦新程序加载完毕,操作系统将控制权传递给新程序。此时,新程序开始执行其入口点(如 main 函数)。
⑥ 新程序执行:新程序现在运行在原始 "Hello" 程序的进程上下文中,但拥有全新的程序代码和数据。
⑦ 清理:操作系统清理与原始 "Hello" 程序相关的任何未使用资源。
execve() 是一个非常强大的系统调用,因为它允许一个进程完全替换其执行的代码和数据,从而运行一个全新的程序。这是Unix和类Unix系统中进程管理和程序执行的基础机制之一。
6.5 Hello的进程执行
"Hello" 程序的进程执行流程在一个类Unix系统中可以被详细分成以下过程:
① 程序启动:当用户执行 "Hello" 程序时,操作系统的shell或其他界面创建一个新进程。这通常通过 fork() 系统调用完成,随后使用 execve() 加载 "Hello" 程序的可执行文件。
② 进程上下文初始化:新进程被赋予一个唯一的进程标识符(PID),并初始化进程上下文,包括程序计数器、寄存器集、打开的文件描述符、环境变量、进程的当前工作目录等。
③ 用户态执行:进程开始在用户态执行 "Hello" 程序。用户态是指进程执行非特权指令且无法直接执行内核代码或操作硬件的模式。
④ 系统调用和核心态:如果 "Hello" 程序需要执行如文件读写、网络通信等操作,它会进行系统调用。这导致进程从用户态切换到核心态(也称为内核态),在这个状态下,进程可以执行操作系统代码并进行直接的硬件访问。
⑤ 进程调度:操作系统的调度器管理所有进程的执行。当 "Hello" 进程获得CPU时间片时,它开始执行。当时间片用完或进程因等待I/O操作而阻塞时,调度器会挂起该进程,并选择另一个进程执行。
⑥ 上下文切换:在进程切换时,操作系统保存当前进程的状态(进程上下文),并加载下一个被调度进程的上下文。这包括保存和恢复程序计数器、寄存器、内存状态等。
⑦ 阻塞和唤醒:如果 "Hello" 进程进行了阻塞的系统调用(如等待输入),它会被置于等待状态,直到事件完成(如数据到达)。完成后,进程被唤醒,重新进入就绪队列等待调度。
⑧ 进程结束:当 "Hello" 程序完成其执行或接收到终止信号时,它会进入终止状态。操作系统回收其使用的资源,如内存和文件描述符,并清理进程表中的条目。
在整个过程中,操作系统确保资源的有效分配,保护进程间的隔离,并通过调度算法(如轮转、优先级调度)确保CPU时间的公平和高效使用。同时,用户态和核心态之间的转换是操作系统设计的关键,以确保系统的稳定性和安全性。
6.6 hello的异常与信号处理
6.6.1 程序异常和信号总述
① 异常类型:
硬件异常:由CPU硬件检测到的错误条件,如除零错误、非法内存访问(段错误)等。这些异常通常由操作系统内核处理。
软件异常:由程序错误或非法操作引起的异常,如数组越界、无效的函数调用等。这些异常在高级语言中通常以错误或异常的形式处理。
② 产生的信号:
SIGSEGV(段错误信号):当程序尝试访问未分配给它的内存时触发。
SIGFPE(浮点异常信号):例如,当程序尝试除以零时触发。
SIGINT(中断信号):当用户按下中断键(如Ctrl+C)时发送给程序。
SIGTERM(终止信号):请求程序终止,通常用于关闭程序。
SIGKILL(杀死信号):强制终止程序,不能被捕获或忽略。
SIGABRT(异常终止信号):通常由程序自身发出,如调用 abort() 函数时。
③ 信号处理:
默认处理:大多数信号有默认的处理行为,如终止进程(SIGSEGV)、忽略信号(SIGCHLD)等。
自定义处理:程序可以使用 signal() 或 sigaction() 系统调用来自定义特定信号的处理方式。例如,可以编写一个函数来处理SIGINT信号,以优雅地关闭资源并正常退出程序。
忽略信号:程序也可以选择忽略某些信号,但有些信号(如SIGKILL、SIGSTOP)不能被忽略。
信号掩码和阻塞:程序可以使用信号掩码来阻塞特定信号的传递,这在处理并发操作时特别有用。
异步处理:信号是异步的,可以在程序的任何执行点被接收和处理。因此,信号处理函数应该尽量简单,避免执行复杂的操作,以减少对程序执行流的干扰。
6.6.2 程序正常运行状态
6.6.3 发送信号及处理
① 随机乱按(以字母或数字为例),此时并不影响程序运行。
② 按Ctrl+C.当在运行程序时按下Ctrl+C,操作系统会向该程序发送 SIGINT(中断信号)。这是一种用于中断程序执行的信号。在ps中查询不到其PID,在job中也没有显示,hello已经被彻底结束。
③ 按Ctrl+Z. 当在运行程序时按下Ctrl+Z,操作系统会向该程序发送 SIGTSTP(终端停止信号)。这是一种用于暂停(而非终止)程序执行的信号。用ps查看其进程PID,可以发现hello的PID是;再用jobs查看此时hello的后台 job号是1,调用 fg 1将其调回前台。
④ 按Ctrl+Z后运行jobs pstree kill,如下图:
按Ctrl+Z后运行jobs
按Ctrl+Z后运行pstree
按Ctrl+Z后运行pstree
按Ctrl+Z后运行kill,对比前后的ps发现进程从2个变成了1个:
6.7本章小结
本章节全面介绍了进程的基本概念、作用以及在操作系统中的管理和信号处理机制。主要内容包括:
① 进程的概念与作用:解释了进程是操作系统中的基本单位,负责资源分配、隔离保护、并发执行、进程间通信和状态管理。
② Shell-bash的作用与处理流程:讨论了Shell(特别是Bash)作为命令行界面的角色,包括命令的输入、解析、执行,以及环境配置。
③ 进程创建与执行过程:详细阐述了进程的创建(如通过 fork())、执行新程序(如通过 execve())的过程,以及进程在用户态和核心态之间的转换。
④ 异常与信号处理:讲述了进程可能遇到的异常类型、产生的信号以及如何处理这些信号,包括默认处理、自定义处理和信号的忽略。
⑤ 信号发送及处理实例:概述了在实际操作中如何通过特定按键(如Ctrl+C、Ctrl+Z)发送信号,并对进程产生影响。
本章内容为理解操作系统中进程的管理和调度提供了全面的视角,强调了进程在计算机系统中的核心作用。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1
在计算机系统中,一个程序(如 "Hello")的存储器地址空间通常包括以下几个部分,每个部分对应不同类型的地址:
代码段(Text Segment):存储程序的可执行代码。在 "Hello" 程序中,这包括编译后的机器指令。
数据段(Data Segment):存储程序中的全局变量和静态变量。它分为已初始化的数据段(存储明确赋值的全局变量和静态变量)和未初始化的数据段(BSS,存储未明确初始化的全局变量和静态变量)。
堆(Heap):动态分配的内存空间,通常用于存储程序运行时创建的对象和数据结构。
栈(Stack):存储局部变量、函数参数、返回地址等。它支持局部变量的创建和销毁,以及函数调用时的参数传递。
7.1.2 地址类型:
在这个上下文中,可以解释以下地址概念:
① 逻辑地址(Logical Address):
逻辑地址是程序代码中使用的地址。
它是由程序生成的,用于访问程序的虚拟地址空间。
例如,当 "Hello" 程序访问一个数组元素时,它使用的是数组的逻辑地址。
逻辑地址是相对地址,相对于程序的起始位置。
② 线性地址(Linear Address):
线性地址是在使用分段内存管理的系统中出现的概念。
在这些系统中,逻辑地址首先被转换为线性地址,然后再转换为物理地址。
线性地址提供了一个连续的内存地址空间,简化了内存管理。
线性地址是通过将逻辑地址和段基址相加得到的。
③ 虚拟地址(Virtual Address):
虚拟地址通常与逻辑地址相同,在不使用分段的系统中直接对应。
它是程序视角下的地址,由操作系统管理。
虚拟地址通过内存管理单元(MMU)映射到物理地址。
虚拟内存系统允许程序使用比实际物理内存更大的地址空间。
④ 物理地址(Physical Address):
物理地址是实际的内存硬件位置的地址。
虚拟地址通过页表和其他内存管理机制转换为物理地址。
物理地址是计算机内存条上的实际位置,由硬件和操作系统共同管理。
物理内存是由物理地址直接寻址的。
在 "Hello" 程序的执行过程中,这些不同类型的地址协同工作,确保程序能够有效地访问和管理内存。逻辑地址和虚拟地址为程序提供了一个简化和统一的视角来处理内存,而线性地址和物理地址则涉及到操作系统和硬件层面的内存管理。这种地址转换机制是现代计算机系统中虚拟内存、进程隔离和内存保护等功能的基础。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在Intel架构的计算机系统中,逻辑地址到线性地址的转换是通过段式内存管理来实现的。这一过程涉及到逻辑地址(由段选择子和偏移量组成)到线性地址的映射。以下是这个转换过程的详细说明:
① 逻辑地址:
逻辑地址在Intel架构中由两部分组成:
段选择子(Segment Selector):这是指向段描述符表中一个特定条目的指针,用于选择一个内存段。
偏移量(Offset):这是在选定的内存段内的一个相对地址,用于指定从段的基址开始的特定位置。
② 段描述符:
每个段选择子指向一个段描述符,该描述符包含以下关键信息:
基址(Base Address):段的起始物理地址。
段限长(Segment Limit):定义段的大小。
访问权限(Access Rights):定义段的类型和访问权限。
③ 逻辑地址到线性地址的转换:
逻辑地址转换为线性地址的过程如下:
段选择子定位段描述符:首先,段选择子用于在段描述符表(如全局描述符表GDT或局部描述符表LDT)中定位到一个特定的段描述符。
计算线性地址:然后,将段描述符中的基址与逻辑地址中的偏移量相加,得到线性地址。这个地址是一个32位或64位的地址,取决于操作系统的架构。
④ 保护模式:
在Intel的保护模式下,这种段式管理提供了几个关键的功能:
内存保护:通过段的访问权限,防止不同程序的内存空间相互干扰。
内存隔离:每个程序都在自己的内存段中运行,增强了安全性和稳定性。
下图描述了该变换管理的结构:
图7.2 逻辑地址变换结构图[26]
7.3 Hello的线性地址到物理地址的变换-页式管理
在计算机系统中,特别是使用页式内存管理的系统中,程序(如 "Hello")的线性地址到物理地址的转换是一个关键过程。这个过程涉及到将程序的线性地址(也就是虚拟地址)映射到物理内存中的实际地址。以下是这个转换过程的详细说明:
① 线性地址(虚拟地址)
线性地址是程序在执行时使用的地址,它是由操作系统提供的,为程序提供了一个连续的地址空间视图。
在页式管理中,这些地址并不直接对应于物理内存中的实际位置。
② 页表
页式内存管理使用页表来维护线性地址到物理地址的映射关系。
每个进程通常都有自己的页表,操作系统负责管理这些页表。
③ 地址转换
分页:操作系统将线性地址空间分割成固定大小的块,称为“页”(Page)。同样,物理内存也被分割成同样大小的“页帧”(Page Frame)。
页表查找:当程序访问一个线性地址时,操作系统会首先将这个地址分解为两部分:页号(Page Number)和页内偏移(Offset)。
页号用于在页表中查找对应的页帧。
页内偏移表示在页帧中的具体位置。
映射到物理地址:页表条目将页号映射到物理页帧号。将页帧号与页内偏移组合,形成完整的物理地址。
④ 页表管理
操作系统负责页表的创建、更新和维护。
当内存不足时,操作系统可能会使用虚拟内存技术,将一些页交换到磁盘上(换出),并在需要时再加载回内存(换入)。
⑤ TLB(Translation Lookaside Buffer)
为了加速地址转换过程,许多系统使用一种称为TLB的高速缓存,它存储了最近使用的页表条目,从而加快页表查找过程。
通过这种页式管理和地址转换机制,操作系统能够有效地管理大量的内存资源,同时为每个程序提供独立和保护的地址空间,确保了系统的稳定性和安全性。在这个过程中,"Hello" 程序的每次内存访问都涉及到从线性地址到物理地址的转换。
7.4 TLB与四级页表支持下的VA到PA的变换
这里参考教材上的图片:
图7.4 Core i7页表翻译过程[27]
在现代计算机系统中,特别是在支持四级页表和使用翻译后援缓冲器(Translation Lookaside Buffer, TLB)的系统中,虚拟地址(Virtual Address, VA)到物理地址(Physical Address, PA)的转换是一个复杂但高效的过程。以下是这个过程的详细说明:
① 四级页表
四级页表是一种分层的页表结构,通常用于64位操作系统,如x86-64架构。它包括以下四个级别:
PML4(Page Map Level 4)
PDPT(Page Directory Pointer Table)
PD(Page Directory)
PT(Page Table)
每个级别的页表都存储着指向下一级页表的指针,最后一级的页表(PT)则存储着映射到物理页帧的指针。
② TLB
TLB是一种专用的缓存,用于存储最近使用的页表条目。它可以快速地将虚拟地址映射到物理地址,从而减少访问完整页表所需的时间。
虚拟地址到物理地址的转换过程
虚拟地址分解:64位虚拟地址被分解为几个部分,每个部分对应页表的一个级别。例如,在x86-64架构中,虚拟地址被分解为PML4、PDPT、PD、PT和页内偏移。
TLB查找:首先在TLB中查找虚拟地址的映射。如果找到(TLB命中),则直接获取物理地址;如果未找到(TLB未命中),则进行下一步。
③ 页表遍历:
使用虚拟地址的PML4部分在PML4表中查找,找到PDPT的地址。
使用PDPT部分在PDPT表中查找,找到PD的地址。
使用PD部分在PD表中查找,找到PT的地址。
最后,使用PT部分在PT表中查找,得到物理页帧的地址。
计算物理地址:将找到的物理页帧地址与虚拟地址的页内偏移组合,形成完整的物理地址。
更新TLB:将新的虚拟地址到物理地址的映射添加到TLB中,以便下次快速访问。
这个过程虽然在第一次访问时可能相对较慢(由于需要遍历页表),但随着TLB的使用,常用的地址映射可以被快速访问,大大提高了内存访问的效率。四级页表结构使得操作系统能够支持极大的虚拟地址空间,同时TLB的使用优化了地址转换的性能。
7.5 三级Cache支持下的物理内存访问
在现代计算机系统中,具有三级缓存(Cache)支持的物理内存访问是一个高效的数据检索过程。这个过程旨在减少访问主内存(RAM)的次数,从而提高整体性能。以下是三级缓存支持下物理内存访问的基本流程:
① 缓存层级:
L1缓存(Level 1 Cache):
最快速但容量最小的缓存。
通常集成在处理器芯片上,为每个核心提供专用的L1缓存。
分为数据缓存(L1d)和指令缓存(L1i)。
L2缓存(Level 2 Cache):
速度比L1慢,但容量更大。
可以为每个核心专用,或在多个核心间共享。
存储更多的数据和指令。
L3缓存(Level 3 Cache):
通常是所有核心共享的最大容量缓存。
速度比L1和L2慢,但仍然比主内存快得多。
作为多个核心间共享数据的中心存储。
② 物理内存访问流程
缓存查找:
当处理器需要访问数据时,它首先在L1缓存中查找。
如果L1缓存未命中(数据不在L1缓存中),则继续在L2缓存中查找。
如果L2缓存也未命中,最后在L3缓存中查找。
访问主内存:
如果所有三级缓存都未命中,处理器将访问主内存。
从主内存检索的数据会被加载到L3缓存中,同时可能更新L2和L1缓存。
缓存更新:
为了保持缓存的有效性,当数据从主内存加载到缓存时,可能会使用替换策略(如最近最少使用LRU)来替换旧的缓存内容。
如果处理器修改了缓存中的数据,这些更改可能需要回写到主内存,以保持内存一致性。
③ 性能优化:
缓存的存在显著减少了对慢速主内存的访问需求,从而提高了数据访问速度。
通过精心设计的缓存架构和智能的数据预取策略,系统能够预测和优化数据访问模式,进一步提高性能。
总之,三级缓存在物理内存访问中起着至关重要的作用,它们通过存储最常访问的数据来减少对主内存的直接访问,从而显著提高了处理器的数据处理能力和整体系统性能。
7.6 hello进程fork时的内存映射
当一个进程(例如名为 "Hello" 的进程)在类Unix系统中执行 fork() 系统调用时,其内存映射的处理是一个关键的操作。fork() 创建一个与原始进程几乎完全相同的子进程。以下是 fork() 调用时内存映射的具体情况:
① 内存映射的复制
代码段(Text Segment):
代码段包含程序的可执行代码。
在 fork() 调用时,代码段通常是共享的,因为代码不会被进程修改。
数据段(Data Segment):
数据段包含全局变量和静态变量。
初始时,数据段可能被设置为共享,但通常会在写入时复制(写时复制,Copy-On-Write)。
堆(Heap):
堆用于动态内存分配。
堆同样初始时可能共享,但在子进程或父进程修改内容时会进行写时复制。
栈(Stack):
栈包含局部变量、函数参数等。
栈也是写时复制,因为局部变量通常是进程特有的。
② 写时复制(Copy-On-Write, COW)
写时复制是一种优化策略,用于减少不必要的数据复制。
当父进程或子进程尝试修改共享的内存页面时,操作系统会为修改的进程创建这个页面的私有副本。
这意味着只有在必要时才会复制内存页面,从而节省内存并提高效率。
③ 文件描述符
fork() 后,子进程会继承父进程的文件描述符。
这些文件描述符在父子进程间是共享的,并指向相同的文件位置。
④ 进程地址空间
虽然父进程和子进程共享相同的物理内存页面,但它们拥有独立的虚拟地址空间。
任何进程对内存的修改都不会影响另一个进程的地址空间。
通过这种方式,fork() 能够高效地创建一个新进程,而不必立即复制整个进程的内存空间。这种方法在多进程编程中非常有效,特别是在需要创建大量子进程的场景中。
7.7 hello进程execve时的内存映射
当一个进程(例如名为 "Hello" 的进程)在类Unix系统中执行 execve() 系统调用时,其内存映射会经历显著的变化。execve() 用于加载一个新的可执行程序到当前进程的地址空间,并开始执行这个新程序。以下是 execve() 调用时内存映射的具体情况:
① 内存映射的重置
代码段(Text Segment):
原进程的代码段被新程序的代码段替换。
新程序的可执行代码加载到代码段。
数据段(Data Segment):
原进程的数据段(包括全局变量和静态变量)被新程序的数据段替换。
新程序的初始化数据加载到数据段。
堆(Heap):
原进程的堆空间被释放。
新程序可能会根据需要分配自己的堆空间。
栈(Stack):
原进程的栈被新程序的栈替换。
新程序的栈初始化,包括参数和环境变量的传递。
② 文件描述符
execve() 默认情况下会关闭原进程打开的所有文件描述符。
但可以通过设置文件描述符的 FD_CLOEXEC 标志来使特定的文件描述符在 execve() 调用后保持打开。
③ 环境变量
新程序通常会接收一组新的环境变量,这些变量可以在 execve() 调用中指定。
④ 进程ID和其他属性
虽然内存映射和执行的程序发生了变化,但进程的ID(PID)和其他一些属性(如进程组ID、用户ID和组ID)保持不变。
⑤ 总结:
execve() 的执行导致了当前进程内存映射的彻底重置,以便加载和运行新的程序。这个过程涉及到替换代码段、数据段、堆和栈,同时也可能涉及到环境变量和文件描述符的变化。execve() 是Unix和类Unix系统中进程管理和程序执行的基础机制之一,允许进程替换其执行的代码和数据,从而运行一个全新的程序。
7.8 缺页故障与缺页中断处理
缺页故障(Page Fault)是在虚拟内存系统中常见的一种情况,当一个进程尝试访问其虚拟内存地址空间中当前未加载到物理内存的部分时就会发生。缺页中断(Page Fault Interrupt)是操作系统响应缺页故障的机制。以下是缺页故障和缺页中断处理的详细说明:
① 缺页故障
触发条件:
进程访问一个映射到虚拟地址空间但尚未加载到物理内存的页面时,会触发缺页故障。
这通常发生在页面被交换出物理内存(换出到磁盘)或者在访问延迟加载的内存页面时。
检测机制:
硬件的内存管理单元(MMU)负责检测访问的虚拟地址是否有效且已加载到物理内存。
如果MMU无法在当前的页表中找到有效的物理地址映射,它会触发一个缺页中断。
② 缺页中断处理
中断服务例程:
操作系统定义了一个中断服务例程(Interrupt Service Routine, ISR)来处理缺页中断。
当缺页中断发生时,处理器暂停当前进程的执行,并转而执行这个特定的中断服务例程。
确定缺失页面:
操作系统确定引起中断的虚拟地址,以及对应的缺失页面。
页面换入:
如果页面在磁盘上(如交换文件或程序文件),操作系统将其从磁盘读入物理内存。
如果是首次访问页面(如延迟分配的堆内存),操作系统会分配一个新的物理页面,并可能将其初始化为零。
更新页表:
操作系统更新页表,将新加载的物理页面与虚拟地址关联起来。
重新执行指令:
完成页面换入和页表更新后,操作系统重新执行导致缺页中断的指令。
这次,由于所需的页面已经在物理内存中,指令可以正常执行,进程继续运行。
③ 处理页面替换:
如果物理内存不足,操作系统可能需要选择一个页面进行换出(写入磁盘)以腾出空间。
这通常涉及到页面替换算法,如最近最少使用(LRU)算法。
总结:
缺页故障和缺页中断处理是虚拟内存系统的核心组成部分,允许操作系统有效地管理内存,同时为进程提供比物理内存更大的地址空间。这种机制通过延迟加载和按需分配资源,提高了内存的使用效率和系统的整体性能。
7.9动态存储分配管理
动态存储分配是程序运行时从堆(Heap)分配内存的过程。例如,当你在C语言中使用 printf 函数时,它可能会内部调用 malloc 来动态分配内存,用于格式化字符串或其他临时存储需求。动态内存管理主要涉及内存的分配、使用和释放。以下是动态内存管理的基本方法和策略:
① 动态内存管理的基本方法
分配(Allocation):
malloc、calloc、realloc 等函数用于在堆上分配内存。
malloc 分配指定大小的内存块。
calloc 分配指定数量的元素,每个元素指定大小,并将内存初始化为零。
realloc 改变已分配内存块的大小。
使用(Usage):
分配的内存可以用来存储数据,如数组、结构体等。
程序员负责管理分配的内存,包括正确地访问和修改内存中的数据。
释放(Deallocation):
使用 free 函数释放之前分配的内存。
释放内存后,该内存区域不应再被访问,否则会导致未定义行为(如悬挂指针)。
② 动态内存管理的策略
首次适应(First Fit):
遍历内存列表,找到第一个足够大的空闲块。
最佳适应(Best Fit):
遍历内存,找到能够容纳数据且最接近所需大小的空闲块。
最差适应(Worst Fit):
选择最大的空闲内存块进行分配。
分割与合并:
分割:如果一个大的内存块被分配一小部分,剩余的部分将被分割成新的空闲块。
合并:释放内存时,相邻的空闲块会被合并成一个更大的空闲块。
避免内存泄漏与碎片:
确保每次 malloc 调用都有相应的 free 调用。
使用智能指针(如C++中的)可以帮助自动管理内存。
内存对齐:
为了提高访问效率,动态分配的内存通常按照一定的边界对齐。
总结
动态存储分配是管理堆内存的关键机制,允许程序在运行时根据需要分配和释放内存。正确地使用动态内存管理函数是避免内存泄漏和内存碎片的关键。高效的内存管理策略可以显著提高程序的性能和可靠性。
7.10本章小结
本章全面探讨了程序内存管理的关键方面,重点关注了 "Hello" 程序在不同内存管理环境下的行为。
主要内容包括:
程序的存储器地址空间:介绍了代码段、数据段、堆和栈的概念及其在程序中的作用。
地址类型与转换:解释了逻辑地址、线性地址、虚拟地址和物理地址的区别及其在内存管理中的重要性。
内存管理机制:包括Intel架构下的段式管理、页式管理以及TLB和四级页表在地址转换中的应用。
缓存与物理内存访问:讨论了三级缓存(L1、L2、L3)在提高物理内存访问效率中的作用。
进程内存映射:探讨了 fork() 和 execve() 系统调用对进程内存映射的影响。
缺页故障处理:描述了缺页故障的原因和操作系统如何处理这些故障。
动态存储分配:介绍了动态内存分配的方法、策略和最佳实践。
本章内容强调了有效内存管理在现代计算机系统中的重要性,特别是对于程序性能和稳定性的影响。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
Linux操作系统的I/O设备管理是一个复杂且高效的过程,涉及到多个层次和组件。Linux通过一系列机制来管理和控制对硬件设备的访问。以下是Linux I/O设备管理的主要方法:
① 设备文件和设备驱动程序
设备文件:Linux使用特殊的文件类型来表示硬件设备,这些文件通常位于/dev目录下。设备文件分为字符设备和块设备。
设备驱动程序:每个硬件设备都有相应的驱动程序,它是内核的一部分,负责与硬件设备通信和控制。
② 统一的设备模型(Unified Device Model)
Linux内核提供了一个统一的设备模型,用于管理所有类型的设备和驱动程序。
这个模型提供了设备注册、配置、管理等功能,并允许内核组件和驱动程序之间的交互。
③ 模块化和动态加载
Linux支持驱动程序的模块化和动态加载。这意味着可以在系统运行时加载或卸载驱动程序,而无需重启系统。
使用insmod、rmmod和modprobe等命令可以动态管理模块。
④ 缓冲和缓存
Linux使用缓冲区和缓存来提高I/O操作的效率。
缓冲区用于块设备的I/O操作,而缓存则用于存储频繁访问的数据。
⑤ I/O调度器
Linux提供了多种I/O调度器(如CFQ、Deadline、NOOP等),用于优化磁盘I/O操作。
I/O调度器负责决定何时以及如何执行磁盘I/O请求,以提高性能和响应时间。
⑥ 用户空间和内核空间交互
Linux提供了系统调用、ioctl等机制,允许用户空间程序与内核空间的设备驱动程序交互。
这些机制使得用户程序能够请求内核执行特定的硬件操作。
总之,Linux的I/O设备管理方法涵盖了从低级硬件控制到高级文件系统管理的各个方面,提供了灵活、高效且可扩展的设备管理能力。
8.2 简述Unix IO接口及其函数
Unix操作系统提供了一套统一的I/O接口,这些接口以系统调用的形式存在,使得对各种I/O设备的操作具有一致性。以下是Unix I/O接口的简述及其主要函数:
① 基本I/O操作
打开文件:
open():打开或创建一个文件,返回一个文件描述符(file descriptor)。
读取文件:
read():从打开的文件中读取数据。
写入文件:
write():向打开的文件写入数据。
关闭文件:
close():关闭打开的文件,释放文件描述符。
② 高级I/O操作
文件定位:
lseek():移动文件的读/写位置。
非阻塞和异步I/O:
fcntl()、ioctl():改变已打开文件的属性,如设置非阻塞模式。
目录操作:
opendir()、readdir()、closedir():用于读取和操作目录。
错误处理:
perror()、strerror():用于错误报告。
③ 特殊I/O操作
管道创建:
pipe():创建一个管道,用于进程间通信。
信号驱动I/O:
signal()、sigaction():用于处理异步事件。
内存映射I/O:
mmap():将文件或设备映射到内存。
多路复用I/O:
select()、poll():允许程序同时监视多个文件描述符的I/O状态。
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;
}
int vsprintf(char *buf, const char *fmt, va_list args)
{
char* p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) {
if (*fmt != '%') {
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case 'x':
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
printf 函数是C语言标准库中用于格式化输出的函数。
① 可变参数列表:printf 使用 ... 来接受可变数量的参数。这使得函数能够接受不确定数量和类型的参数。
② 缓冲区:定义了一个字符数组 buf 作为缓冲区,用于存储格式化后的字符串。
初始化 va_list:使用 va_list 类型的 arg 来处理可变参数。va_list 是处理可变参数的标准宏。
③ 调用 vsprintf:vsprintf 是 printf 的变体,它接受一个 va_list 类型的参数。这里,它用于将格式化的输出写入 buf。
写入标准输出:使用 write 函数将缓冲区的内容写入标准输出(通常是屏幕)。
返回值:返回写入字符的数量。
④ 格式化字符串:遍历格式字符串 fmt,根据格式指定符(如 %x、%s 等)来格式化参数。
⑤ 处理不同类型的参数:根据格式指定符,从 va_list 中获取相应的参数,并将其转换为字符串形式。
⑥ 字符串拼接:将转换后的字符串拼接到 buf 中。
返回生成的字符串长度:返回 buf 中字符串的长度。
⑦ 注意事项:
这个实现是简化的,不包含所有的格式指定符,也没有错误处理。
va_list 的使用需要特别注意,因为错误的处理可能导致内存访问错误。
write 函数是底层的系统调用,直接将数据写入文件描述符,这里是标准输出。
在实际的C标准库中,printf 的实现会更复杂,包括对多种格式指定符的支持和更健壮的错误处理。这个简化的实现提供了一个很好的示例,展示了 printf 如何在底层处理格式化输出。
8.4 getchar的实现分析
int getchar(void)
{
static char buf[BUFSIZ];
static char* bb=buf;
static int n=0;
if(n==0)
{
n=read(0,buf,BUFSIZ);
bb=buf;
}
return(--n>=0)?(unsigned char)*bb++:EOF;
}
① 静态缓冲区:
buf:一个静态字符数组,用作缓冲区,存储从标准输入读取的数据。
BUFSIZ 通常是一个预定义的大小,足够存储多个字符。
② 缓冲区指针和计数器:
bb:指向当前缓冲区中要读取的下一个字符。
n:缓冲区中剩余的字符数。
③ 读取输入:
当 n 为 0 时,表示缓冲区为空,需要从标准输入读取更多数据。
read(0, buf, BUFSIZ) 调用从文件描述符 0(标准输入)读取数据到 buf。
n 被设置为读取的字符数,bb 重置为指向 buf 的开始。
④ 返回字符或 EOF:
函数返回缓冲区中的下一个字符,并递减 n。
如果 n 在递减后仍然大于等于 0,返回 *bb++(当前字符,并将 bb 指针前移)。
如果 n 小于 0,表示没有更多字符可读,返回 EOF(文件结束标志)。
⑤ 异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要涵盖了Linux I/O设备管理、Unix I/O接口,以及printf和getchar函数的实现。关键点包括:
Linux I/O设备管理:介绍了设备文件、设备驱动程序、统一设备模型、模块化和动态加载、虚拟文件系统、缓冲和缓存、I/O调度器,以及网络I/O的管理。
Unix I/O接口:讨论了Unix系统的标准I/O操作,包括文件操作、高级I/O功能和特殊I/O操作。
printf函数实现:简化版的printf实现,展示了格式化字符串的处理和输出。
getchar函数实现:简化版的getchar实现,展示了从标准输入读取字符的过程。
本章内容强调了Linux和Unix系统在I/O管理和标准I/O函数实现方面的复杂性和高效性。
结论
一个简单的 "hello.c" 程序的背后隐藏着复杂的计算机运行机制。作为程序员,我们首先使用文本编辑器来编写原始的C语言代码。接下来,这段代码经历了一系列转换和处理过程:
① 预处理阶段:hello.c 文件经过预处理器处理,生成了 hello.i 文件。
② 编译阶段:编译器将 hello.i 文件编译成汇编代码,得到 hello.s 文件。
③汇编阶段:hello.s 文件经过汇编器的处理,转换成可重定位的目标文件 hello.o。
④ 链接阶段:链接器将 hello.o 文件与其他必要的库文件链接在一起,生成最终的可执行文件。
当我们运行这个程序时,操作系统的shell通过 fork 创建一个新进程,并通过 execve 加载 hello 程序及其所需的动态链接库。程序在运行期间,将使用独立的虚拟地址空间,利用分段和分页机制来访问内存。程序的输入输出操作涉及到Linux的I/O设备管理,这是计算机如何与外界交互的关键部分。最终,当 hello 程序运行结束后,shell的父进程将回收已终止的进程资源。
这一系列过程不仅展示了软件和硬件之间的紧密联系,也彰显了计算机科学领域工作者的深厚研究。正是他们的不懈努力,使我们能够深入理解像 hello.c 这样简单程序背后的复杂机制。作为计算机领域的一员,我们应当向这些先驱学习,不断提升自己,成为合格的计算机专业人士。
我的创新理念和设计实现:
① 模块化和抽象:在设计计算机系统时,应强调模块化和抽象,以便于管理复杂性并提高系统的可维护性和可扩展性。
② 性能优化:持续关注性能优化,例如通过改进缓存机制、优化I/O调度策略,以提高系统效率。
③ 安全性和稳定性:在设计和实现过程中,应将安全性和稳定性作为核心考虑,特别是在进程管理和内存管理方面。
④ 用户友好性:提高系统的用户友好性,例如通过改进命令行界面和错误报告机制,使系统更易于使用和调试。
⑤ 跨平台兼容性:考虑跨平台兼容性,使系统能够在不同的硬件和操作系统上运行,增加其适用性。
通过这些方法和理念,可以设计和实现更加高效、可靠和用户友好的计算机系统。
结尾图:总结hello程序的一生[28]
附件
列出所有的中间产物的文件名,并予以说明其作用
参考文献
[1] 深入理解计算机系统 第三版 机械工业出版社
[2] CSDN - 专业开发者社区
[4] Microsoft Learn:培养开拓职业生涯新机遇的技能
[5] IEEE Xplore
[6] Learn and Teach Computer Science with JetBrains Academy
[1] 该图显示了整个过程生成的结果文件的类型和名称
[2] 该图为程序编写生命周期的视觉呈现,展示了一个程序员在完成代码编写后会发生的场景(艺术图)
[3] 该截图展示了程序员编写的,未经处理的hello.c源文件
[4] 该截图展示了hello.i文件的截取部分
[5] 这张图表展示了C语言编译过程中的预处理阶段,包括宏展开、处理头文件和条件编译等,最终生成用于编译的中间文件。
[6] 该图显示了经编译器处理后得到的汇编文件hello.s的内容
[7] 该图描述了源程序hello.c经过预处理和编译得到hello.s汇编文件的流程图
[8] 该图片是由汇编后的hello.o文件生成的可重定位的ELF格式文件的截图
[9] 该图片为hello.o文件经过反汇编工具objdump得到的文件截图
[10] 这张图展示了汇编器处理流程,从读取汇编语言文件(.s),解析指令,解析符号,分配地址和存储,转换为机器语言文件(.o),最后准备链接生成可执行文件的整个过程。
[11] 该截图显示了可执行文件hello的ELF文件中的ELF Header部分
[12] 该截图显示了可执行文件hello的ELF文件中的Section Headers部分
[13] 该截图显示了可执行文件hello的ELF文件中的Program Headers部分
[14] 该截图显示了可执行文件hello的ELF文件中的Relocation部分
[15] 该截图显示了可执行文件hello的ELF文件中的Symbol Table部分
[16] 该图显示了使用edb调试软件加载可执行文件hello后查看虚拟地址空间的结果
[17] 该图显示了为hello文件经过反汇编工具objdump得到的文件
[18] 该图片显示了程序运行期间调用和跳转的函数地址
[19] 该图片显示了使用edb调试时执行到main函数的命令
[20] 该图片显示了在命令行中使用nm工具来查看程序的动态链接符号表
[21] 该图片显示了可执行文件hello的ELF文件中动态链接的部分
[22] 该图片显示了程序初始化前后main函数对应的内存段
[23] 该图片显示了程序在动态链接过程中.plt节和.got节的协作流程
[24] 该图详细描述了程序编译后链接过程的各个步骤,包括开始链接、解析符号、重定位代码和数据、处理库文件以及最终生成可执行文件。
[25] 该图描述了类Unix系统中执行 fork() 系统调用以创建进程时的流程
[26] 该图描述了Intel逻辑地址到线性地址的变换管理结构
[27] 该图描述了Core i7页表图,包含PT,PTE,VPN等内容
[28] 该图形象地描述了hello的一生,对整个报告做了总结(注:部分文字因为设备所限不能完整展示)