计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 数学与应用数学+计算机科学与技术
学 号 2023110151
班 级 23SXS11
学 生 孙灿阳
指 导 教 师 史先俊
计算机科学与技术学院
2024年5月
hello程序最早诞生在源代码中,在经历了一系列变化后,变成了一个可执行文件,然后在进程中被执行,当程序执行完毕后,进程退出,hello的生命彻底结束。本文通过对hello的一生的探索,详细介绍了预处理、编译、汇编、链接、进程管理、存储管理、IO管理等方面的内容。
关键词:编译;链接;进程;存储管理;IO
目 录
第1章 概述................................................................................... - 4 -
1.1 Hello简介............................................................................ - 4 -
1.2 环境与工具........................................................................... - 4 -
1.3 中间结果............................................................................... - 4 -
1.4 本章小结............................................................................... - 4 -
第2章 预处理............................................................................... - 5 -
2.1 预处理的概念与作用........................................................... - 5 -
2.2在Ubuntu下预处理的命令................................................ - 5 -
2.3 Hello的预处理结果解析.................................................... - 5 -
2.4 本章小结............................................................................... - 5 -
第3章 编译................................................................................... - 6 -
3.1 编译的概念与作用............................................................... - 6 -
3.2 在Ubuntu下编译的命令.................................................... - 6 -
3.3 Hello的编译结果解析........................................................ - 6 -
3.4 本章小结............................................................................... - 6 -
第4章 汇编................................................................................... - 7 -
4.1 汇编的概念与作用............................................................... - 7 -
4.2 在Ubuntu下汇编的命令.................................................... - 7 -
4.3 可重定位目标elf格式........................................................ - 7 -
4.4 Hello.o的结果解析............................................................. - 7 -
4.5 本章小结............................................................................... - 7 -
第5章 链接................................................................................... - 8 -
5.1 链接的概念与作用............................................................... - 8 -
5.2 在Ubuntu下链接的命令.................................................... - 8 -
5.3 可执行目标文件hello的格式........................................... - 8 -
5.4 hello的虚拟地址空间......................................................... - 8 -
5.5 链接的重定位过程分析....................................................... - 8 -
5.6 hello的执行流程................................................................. - 8 -
5.7 Hello的动态链接分析........................................................ - 8 -
5.8 本章小结............................................................................... - 9 -
第6章 hello进程管理.......................................................... - 10 -
6.1 进程的概念与作用............................................................. - 10 -
6.2 简述壳Shell-bash的作用与处理流程........................... - 10 -
6.3 Hello的fork进程创建过程............................................ - 10 -
6.4 Hello的execve过程........................................................ - 10 -
6.5 Hello的进程执行.............................................................. - 10 -
6.6 hello的异常与信号处理................................................... - 10 -
6.7本章小结.............................................................................. - 10 -
第7章 hello的存储管理...................................................... - 11 -
7.1 hello的存储器地址空间................................................... - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理................... - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理............. - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换.................... - 11 -
7.5 三级Cache支持下的物理内存访问................................ - 11 -
7.6 hello进程fork时的内存映射......................................... - 11 -
7.7 hello进程execve时的内存映射..................................... - 11 -
7.8 缺页故障与缺页中断处理................................................. - 11 -
7.9动态存储分配管理.............................................................. - 11 -
7.10本章小结............................................................................ - 12 -
第8章 hello的IO管理....................................................... - 13 -
8.1 Linux的IO设备管理方法................................................. - 13 -
8.2 简述Unix IO接口及其函数.............................................. - 13 -
8.3 printf的实现分析.............................................................. - 13 -
8.4 getchar的实现分析.......................................................... - 13 -
8.5本章小结.............................................................................. - 13 -
参考文献....................................................................................... - 16 -
第1章 概述
1.1 Hello简介
-
-
- P2P:从程序到进程
-
1.1.1.1 程序阶段(Program)
Hello程序最早诞生在源代码中。我们有一个hello.c文件,这个文件是用高级编程语言C语言编写的,并且在此时它只是一个静态的、不可执行的代码文本。可以把它看作是一个“静止的实体”,没有任何动态执行能力。
1.1.1.2 预处理、编译、汇编、链接
这个源代码在经历了预处理、编译、汇编和链接的步骤后,变成了一个可执行文件。在这些过程中,程序源代码被转换为机器语言,形成一个二进制文件,它包含了计算机能够理解的指令,但程序本身并没有真正运行。
1.1.1.3 进程阶段(Process)
当用户在操作系统中执行这个二进制可执行文件时,操作系统会利用fork()和execve()来创建一个新的进程。此时,程序从静态代码变成了一个进程。进程会获得系统资源(如内存、CPU时间片)并开始执行。
在这个过程中,操作系统的进程管理会分配时间片,调度该进程在CPU上执行,并确保它有足够的内存(通过虚拟内存管理)。同时,操作系统会通过内存管理单元(MMU)为进程分配虚拟地址(VA)并映射到物理内存地址(PA)。如果程序访问的数据不在缓存中,操作系统会通过页表和TLB等机制进行管理,确保高效的数据访问。
操作系统会通过各种I/O管理机制(如文件系统、硬件设备驱动程序等)来保证程序能够顺利访问硬盘、显示器、键盘等外设。
1.1.2 O2O:从Zero-0到Zero-0
Hello的O2O过程可以被看作是程序从无到有,再从有到无的一个完整生命周期。O2O即Zero-to-Zero,即从“零”开始到“零”结束。这个过程是简洁而深刻的,涉及计算机系统如何执行一个简单程序的整个生命周期。
1.1.2.1 Zero-0:从程序的源代码开始,这时,源代码只是静态存在的文件,它并没有任何运行能力。它相当于是一个零的状态,没有经过任何处理,不能直接被计算机执行。
1.1.2.2 准备执行
在写完源代码后,需要将其转换成计算机能够执行的机器代码。这个过程经过以下几个步骤:预处理——处理所有宏定义和包含文件等;编译——将源代码转换为中间代码;汇编——将汇编语言转换为机器语言;链接——将所有目标文件和库文件链接成一个完整的可执行文件。这些步骤把程序从原始的零转化成了一个可以执行的文件,但它依然还不是进程。
1.1.2.3 从0到1
当用户决定运行该程序时,操作系统会加载可执行文件,并将其变成一个进程。此时,程序经历了从静态的代码到动态的进程的转变。操作系统为这个进程分配CPU时间、内存空间和其他硬件资源。操作系统中的进程管理模块开始为该进程分配时间片并调度它执行。
1.1.2.4 回到0
当程序完成输出并结束时,操作系统会清理进程的资源,释放内存,关闭文件描述符等,进程会正常退出。此时,进程状态变为“终止”,系统将其从内存中移除。这时,Hello的“生命”彻底结束,重新回到0。
1.2 环境与工具
硬件环境:X64 CPU;2.20GHz;16.0GB RAM
软件环境:Windows11;VMware Workstation Pro 17;Ubuntu 20.04
开发与调试工具:gcc, edb, objdump, readelf
1.3 中间结果
名字 | 作用 |
hello.i | 对hello.c预处理后的文件 |
hello.s | 对hello.i编译后生成的文本文件 |
hello.o | 对hello.s汇编后生成的可重定位目标文件 |
hello | 链接后生成的可执行目标文件 |
1.elf | hello.o的ELF文件 |
2.asm | hello.o的反汇编文件 |
3.elf | hello的ELF文件 |
4.asm | hello的反汇编文件 |
1.4 本章小结
本章对hello、环境与工具、中间结果做了一个介绍,为后面任务的完成做准备。我对hello的一生有了一个清晰的认识,从hello.c开始,经过预处理、编译、汇编、链接生成可执行文件hello,然后在各方的帮助下开始在进程中被执行,真正获得了生命,执行完毕后,又恢复了最开始的“0”状态。可以说,hello既是from program to progress,又是from zero to zero。
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
预处理是源代码编译过程中第一个步骤,它发生在实际的编译之前。在C语言或C++等语言的编译过程中,预处理器(如gcc的cpp)会对源代码进行处理,生成最终的代码供编译器进一步编译。预处理器处理的是源代码中的一些指令,这些指令以#开头,因此被称为预处理指令。
2.1.2 预处理的作用
2.1.2.1 宏定义和替换
预处理器会处理所有以#define定义的宏。宏是可以在程序中多次使用的代码片段,预处理器会在编译之前将宏名称替换为宏定义的内容。
2.1.2.2 文件包含(#include)
#include指令用于将其他文件(通常是头文件)包含到当前源文件中。预处理器会将包含的文件的内容插入到指定位置。
2.1.2.3 条件编译(#ifdef, #ifndef, #else, #endif)
条件编译允许程序员根据不同的条件来编译不同的代码部分。它常用于跨平台开发和调试。
2.1.2.4 宏函数(#define 的扩展)
预处理器不仅可以定义常量,还可以定义带参数的宏,这些宏在使用时会展开成函数代码。
2.1.2.5 文件保护(#ifndef, #define, #endif)
预处理器可以防止头文件被重复包含。例如,头文件中的代码可能会在多个地方被#include,为了防止重复定义,常使用宏来控制文件的多重包含。
2.1.2.6 替换常量和代码片段
预处理器可以替换常量值、简化复杂的计算[3]。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
图2-1 预处理过程
2.3 Hello的预处理结果解析
图2-2 hello.i(部分)
得到了另一个C程序——hello.i,一共有3061行。这是因为预处理器读取了系统头文件的内容,并把它直接插入程序文本中,比如stdio.h[1]。最后的3047行到3061行为原始的C程序。
2.4 本章小结
预处理是源文件到目标文件的转化过程中非常重要的一步,它通过宏定义、条件编译、文件包含等机制,在编译前对原始的C程序进行修改,从而为后续的编译阶段提供了更合适的代码。通过查看hello.i,我对预处理有了更清晰的认识。
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
编译器(cc1)将预处理后的源代码hello.i转换为目标架构的汇编代码hello.s,它包含一个汇编语言程序。
3.1.2 编译的作用
3.1.2.1 语法解析与翻译
在此阶段,编译器会对hello.i(预处理后的C语言代码)进行语法分析,确保代码符合目标语言(C语言)的语法规范。编译器会将C语言代码转化为对应的汇编语言代码,即hello.s文件。
语法树构建:编译器会通过抽象语法树(AST)来表示源代码的结构,从而理解程序的逻辑结构。
指令生成:编译器根据目标架构的指令集(如x86、ARM等)生成汇编指令。每个C语言表达式(如加法、赋值、函数调用等)都会对应一条或多条汇编指令。
3.1.2.2 将高层语言转化为低层语言
编译的作用之一是将高级语言(如C)转化为计算机能够理解并执行的低级语言。虽然C语言语法接近人类的语言,但计算机只能执行机器语言或汇编语言。通过编译,从hello.i到hello.s的转换使得程序更接近硬件。
这个过程涉及对数据类型、表达式、控制结构(如循环、条件语句)等的低级表示。例如,C语言中的一个printf()函数调用,在汇编语言中可能会变成一条系统调用或库函数调用,并通过相应的指令在计算机上执行。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
图3-1 编译过程
3.3 Hello的编译结果解析
3.3.1 数据
3.3.1.1 常量
hello.c中的5和1在hello.s中被表示为立即数$5和$1。
图3-2
图3-3
printf中的字符串位于.rodata。
图3-4
3.3.1.2 变量
局部变量i位于栈中。
图3-5
3.3.2 赋值
hello.c中的i = 0在hello.s中用movl实现。
图3-6
3.3.3 类型转换
调用atoi函数对输入的秒数进行类型转换。
图3-7
3.3.4 算数操作
hello.c中的i++在hello.s中用addl实现。
图3-8
3.3.5:关系操作
hello.c中的argc!=5和i<9在hello.s中用cmpl来实现。
图3-9
图3-10
3.3.6 数组/指针/结构操作
argv的地址为-32(%rbp),两个相邻元素的地址相差8,因此对-32(%rbp)加24,加16,加8,加32来计算另外4个元素的地址,然后进行读取。
图3-11
3.3.7 控制转移
if(argc!=5):
在hello.s中,cmpl对argc和5进行比较并设置条件码,je根据条件码选择是否跳转。
图3-12
for(i=0;i<9;i++):
在hello.s中,cmpl对i和8进行比较并设置条件码,jle根据条件码选择是否跳转。
图3-13
3.3.8 函数操作
3.3.8.1 参数传递
如果参数小于等于6个,则全部用寄存器传递参数;如果参数大于6个,则6个参数用寄存器传递,剩下的参数则存储在栈中。
3.3.8.2 函数调用
printf(“用法:Hello 学号 姓名 手机号 秒数!\n”)
在hello.s中,通过调用puts@PLT来实现打印。
图3-14
exit(1)
在hello.s中,通过调用exit@PLT来实现。
图3-15
printf(“Hello %s %s %s \n”, argv[1], argv[2], argv[3])
argv的地址为-32(%rbp),两个相邻元素的地址相差8,因此对-32(%rbp)加24,加16,加8来计算3个参数的地址,然后进行读取。然后调用printf@PLT进行打印。
图3-16
sleep(atoi(argv[4]))
对-32(%rbp)加32来计算参数的地址,然后进行读取,再调用atoi@PLT进行转换,最后调用sleep@PLT进行休眠。
图3-17
getchar()
在hello.s中,通过调用getchar@PLT来读取字符。
图3-18
3.3.8.3 函数返回
在hello.s中,指令ret用来实现函数返回。
图3-19
3.4 本章小结
编译器(cc1)将预处理后的源代码hello.i转换为目标架构的汇编代码hello.s,通过解析hello的编译结果(数据、赋值、类型转换、算数操作、逻辑/位操作、关系操作、数组/指针/结构操作、函数操作),我阅读汇编代码的能力得到了很大的提升,对编译有了更深的理解。
第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编是将汇编语言代码转换为机器语言的过程,它是计算机程序编译过程中的一个重要步骤。汇编语言是一种低级语言,它直接对应于计算机硬件的指令集架构,比高级编程语言更接近硬件。汇编器将汇编语言源代码转换为机器代码,这使得计算机能够执行该程序。
4.1.2 汇编的作用
4.1.2.1 将汇编语言转换为机器语言
机器语言是计算机能够直接执行的唯一语言,通常由二进制指令构成。汇编语言虽然接近机器语言,但依然由符号组成,需要通过汇编器转换成机器语言。汇编语言中的每条指令通过汇编器转换为特定硬件架构的机器指令。机器指令是计算机CPU能够识别并执行的原始指令。
4.1.2.2 提供更细粒度的控制
汇编语言比高级语言更接近硬件,因此它允许程序员对硬件的控制更细致。程序员可以直接操作CPU的寄存器、内存地址,甚至控制特定硬件设备的行为。在性能要求极高的场景下(如操作系统内核、嵌入式系统、驱动程序等),汇编语言常用于优化程序性能,确保代码能以最精确、最高效的方式与硬件交互。
4.1.2.3 代码优化和性能提升
使用汇编语言可以进行低级别的优化,例如利用特定CPU指令集架构的优势,避免高级语言编译器可能产生的冗余代码。汇编代码能够精确控制每一个操作,避免一些高级语言编译器生成的额外开销。在极端的性能要求下,编写汇编代码可以显著提升程序执行效率,特别是在嵌入式系统和对实时性要求严格的应用场合。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
图4-1 汇编过程
4.3 可重定位目标elf格式
图4-2 典型的ELF可重定位目标文件
首先获得hello.o的ELF文件
图4-3
4.3.1 ELF头
图4-4 ELF头
从ELF头可以看到:数据为小端序,文件类型为可重定位文件,入口点地址为0,没有程序头,节头开始处为1264,ELF头的大小为64字节,程序头的大小为0,程序头的数量为0,节头大小为64比特,节头数量为14。
4.3.2 节头
图4-5 节头
节头包含不同节的名称、类型、地址、偏移量、大小等信息。
4.3.3 重定位节.rala.text和.rela.eh_frame
图4-6 .rela.text和.rela.eh_frame
.rel.text是一个重定位节,通常用于存储与.text节(存储代码)的重定位相关的数据。当目标文件与其他文件组合时,它告诉链接器需要如何调整.text节中某些位置的指令或数据,确保程序在运行时能够正确调用函数、访问变量等。
.rela.eh_frame与.rel.text类似,它与.eh_frame节中的异常处理框架数据相关,用于存储指向.eh_frame中数据的符号重定位信息。
4.3.4 .symtab
图4-7 .symtab
在ELF文件格式中,.symtab节是一个非常重要的部分,它存储了程序中的符号信息。符号通常代表程序中的变量、函数、对象或其他可以由链接器、调试器等工具识别的元素,因此,.symtab节对于链接和调试等过程至关重要。
4.4 Hello.o的结果解析
首先得到hello.o的反汇编文件。
图4-8 反汇编过程
图4-9 2.asm
4.4.1 机器语言的构成
机器语言是计算机能够直接理解和执行的指令集,它由一系列的二进制位组成。每个机器指令通常由多个字段组成,用于指定操作码(操作符)、操作数以及其他控制信息。
4.4.2 与汇编语言的映射关系
汇编语言与机器语言之间的映射关系可以通过汇编器来实现,汇编器将汇编语言转换为机器语言。每条汇编指令对应一条机器语言指令。
4.4.3 分支转移的不同
对于if(argc!=5),2.asm和hello.s在跳转时不太一样。
图4-10 2.asm
图4-11 hello.s
4.4.4 函数调用的不同
对于exit(1),2.asm和hello.s在处理时不太一样。
图4-12 2.asm
图4-13 hello.s
4.5 本章小结
汇编器将汇编语言源代码转换为机器代码,这使得计算机能够执行该程序。通过查看hello.o的ELF文件,我对汇编有了更深入的了解。通过比较2.asm和hello.s,我看到了机器语言与汇编语言的联系与区别。
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接是程序开发中的一个重要过程,它是将源代码编译后的对象文件(.o文件)和库文件(如静态库、动态库)组合成最终可执行文件或共享库的过程。链接的主要任务是处理代码和数据之间的引用关系,确保程序中各个模块之间的符号(如变量和函数)能够正确连接和解析。
5.1.2 链接的作用
链接的核心作用是将多个对象文件和库文件中的代码和数据正确地连接在一起,解决它们之间的引用问题,并将程序中的外部符号(例如函数和变量)绑定到正确的地址上。具体作用如下:
5.1.2.1 符号解析
符号解析是链接的一个重要任务。一个程序中可能包含多个源文件,这些源文件在编译时生成多个对象文件。在编译过程中,每个对象文件会生成一个符号表,记录其中的函数、变量等符号。链接的过程就是将这些符号连接起来,解决它们之间的引用。
5.1.2.2 地址分配
在链接过程中,链接器为每个符号(如函数、变量)分配一个虚拟地址。链接器需要根据程序的结构、符号的顺序等因素,确定每个符号的内存地址。这一过程称为地址分配。
5.1.2.3 重定位
重定位是链接器根据程序的符号表和重定位信息,对对象文件中的地址进行调整的过程。程序中的某些地址(如函数和变量的地址)在编译时并不确定,链接器会根据符号解析的结果对这些地址进行修正。
5.1.2.4 库的链接
链接器还需要处理程序与库之间的链接。程序中引用的外部函数和变量通常定义在库文件中,链接器需要将程序的引用与库中的定义进行绑定。
5.1.2.5 生成可执行文件
链接器最终会生成一个可执行文件,这个文件是程序的最终输出,可以直接由操作系统加载并运行。
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-1 链接过程
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
图5-2 典型的ELF可执行目标文件
首先获得hello的ELF文件。
图5-3
5.3.1 ELF头
图5-3 ELF头
从ELF头可以看到:数据为小端序,文件类型为可执行文件,入口点地址为0x4010f0,程序头起点为64,节头开始处为14208,ELF头的大小为64字节,程序头的大小为56字节,程序头的数量为12,节头大小为64比特,节头数量为27。
5.3.2 节头
图5-4 节头
节头包含不同节的名称、类型、地址、偏移量、大小等信息。由此可以得到各段的起始地址,大小等信息。
5.3.3 程序头
图5-5 程序头
程序头描述了可执行文件的连续的片到连续的内存段的映射[1]。
5.3.4 段节
图5-6 段节
5.3.5 .rela.dyn
图5-7 .rela.dyn
.rela.dyn是一个重定位节,通常用于存储与.dyn节的重定位相关的数据。当目标文件与其他文件组合时,它告诉链接器需要如何调整.dyn节中某些位置的指令或数据,确保程序在运行时能够正确调用函数、访问变量等。
5.3.6 .rela.plt
图5-8 .rela.plt
.rela.plt是一个重定位节,通常用于存储与.plt节的重定位相关的数据。
5.3.7 .dynsym
图5-9 .dynsym
5.3.8 .symtab
图5-10 .symtab
在ELF文件格式中,.symtab节是一个非常重要的部分,它存储了程序中的符号信息,符号通常代表程序中的变量、函数、对象等。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
图5-11 Date Dump
5.5 链接的重定位过程分析
首先获得hello的反汇编文件。
图5-12 hello的反汇编过程
5.5.1 hello与hello.o的不同
与2.asm相比,4.asm多了_init, .plt, puts@plt, printf@plt, getchar@plt等部分。因为程序中引用了这些外部函数,在链接的过程中,链接器需要将程序的引用与库中的定义进行绑定,从而生成可执行文件hello,因此在4.asm中能看到这些部分。
5.5.2 hello中对hello.o的重定位
5.5.2.1 符号解析与重定位
hello.o中调用了外部函数,如printf,编译器在生成hello.o文件时不知道printf函数的具体地址。因此,链接器将printf作为一个外部符号记录在符号表中,并在hello.o的重定位表中插入相关重定位信息。在重定位过程中,链接器会查找printf符号的定义地址,并将hello.o文件中的所有printf调用地址调整为实际的库函数地址。
5.5.2.2 符号地址的调整
对于hello.o中定义的符号(如局部变量或函数),链接器会根据符号表为它们分配内存地址。假设hello.o中有一个变量x,在编译时,链接器将x的地址设置为一个占位符。链接器会根据最终的地址分配结果调整该占位符,确保它指向正确的内存位置。
5.5.2.3 代码段和数据段的重定位
代码段(.text)和数据段(.data)中存储的是程序的指令和数据,但这些部分的地址在编译时是相对的或未知的。在链接时,链接器会根据程序最终的内存布局调整这些地址,确保程序能够正确访问它们。
5.5.2.4 符号表和重定位表的更新
符号表中的符号会根据链接器的地址分配进行更新。例如,函数main和printf的地址会在链接时被解析和修改。重定位表则会记录这些符号在地址调整后的偏移量。
5.6 hello的执行流程
_start
_init
main
printf@plt
exit@plt
printf@plt
atoi@plt
sleep@plt
getchar@plt
_fini
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb/gdb调试,分析在动态链接前后,这些项目的内容变化。要截图标识说明。
5.8 本章小结
链接是将源代码编译后的对象文件(.o文件)和库文件(如静态库、动态库)组合成最终可执行文件或共享库的过程。通过查看hello的ELF文件,分析链接的重定位过程和hello的执行流程,链接对我来说不再是那么神秘了。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
进程是指执行中的程序的实例。它是系统资源分配的基本单位,是操作系统调度的基本单位。进程不仅仅包括程序代码,还包括与程序执行相关的所有资源,如内存、文件、输入输出设备、寄存器等。
6.1.2 进程的作用
6.1.2.1 程序执行的载体
进程是程序的实际执行单元。一个程序在执行时,操作系统会为其创建一个进程,并为该进程分配系统资源(如内存、CPU时间、I/O设备等)。进程为程序的执行提供了必需的资源和环境,使得程序能够独立运行。
6.1.2.2 资源管理与隔离
操作系统通过进程实现对计算机资源的管理和隔离。每个进程拥有独立的地址空间,不同进程之间的内存和资源相互隔离,确保了进程之间不会直接相互干扰。操作系统通过进程控制来管理计算机的CPU、内存、I/O设备等资源,确保资源的合理分配与回收。
6.1.2.3 并发执行
通过进程,操作系统可以实现多个程序的并发执行。在多核处理器的环境下,操作系统可以通过多进程或多线程的方式同时运行多个进程,实现任务的并发处理。进程的并发执行使得计算机能够更高效地利用 CPU 资源,提升系统的整体性能。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 作用:
6.2.1.1 命令解释器
Shell作为命令解释器,负责解析用户输入的命令并将其传递给操作系统内核执行。它处理命令的语法、参数、选项等,确保正确执行。
6.2.1.2 命令行界面
Shell提供了一个文本界面,允许用户通过键盘输入命令并接收操作系统返回的输出。这个界面通常用于系统管理、程序开发、调试、自动化脚本等操作。
6.2.1.3 脚本执行器
Shell还可以用来执行脚本。用户可以编写Shell脚本,将一系列命令、控制结构(如循环、条件判断)和变量整合在一起,通过一个命令执行多项任务。
6.2.1.4 进程控制
Shell可以启动、管理和终止进程。例如,通过ps命令查看进程,通过kill命令终止进程。
6.2.1.5 管道与重定向
Shell提供了管道和重定向功能,允许用户将多个命令的输出和输入进行连接或重定向到文件。这使得Shell成为非常强大的工具,可以将多个小程序组合成一个复杂的命令序列。
6.2.2 处理流程:
6.2.2.1. Shell将用户输入的命令分割成命令名和参数。
6.2.2.2. Shell根据环境变量PATH查找命令的实际路径。
6.2.2.3. Shell处理命令中的通配符,环境变量替换,命令替换等。
6.2.2.4. Shell将解析后的命令交给操作系统的内核执行,操作系统会根据命令类型进行相应的操作。
6.2.2.5. 如果是内置命令,不需要创建新进程,Shell自身处理。
6.2.2.6. 如果是外部命令,Shell会创建一个新进程来执行该命令。
6.2.2.7. 命令执行完毕后,操作系统返回执行结果,Shell将其显示在屏幕上,供用户查看。
6.2.2.8. Shell会显示新的提示符,等待用户输入下一条命令。
6.3 Hello的fork进程创建过程
- 输入./hello 2023110151 孙灿阳 15856587696 1。
- 该命令为外部命令,Shell会创建一个新进程来执行该命令。
- 调用fork函数创建一个新的运行的子进程。
- 子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,子进程可以读写父进程中打开的任何文件[1]。
- 命令在子进程执行完毕后,操作系统返回执行结果,Shell将其显示在屏幕上。
6.4 Hello的execve过程
调用execve函数,操作系统首先根据filename指定的路径和名称找到对应的可执行文件。然后,操作系统创建一个新的进程,并将该可执行文件加载到新进程的内存空间中。接下来,操作系统将新进程的参数和环境变量设置为argv和envp指定的内容。最后,操作系统启动新进程的执行,开始执行hello[2]。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
6.5.1 上下文切换
上下文切换是操作系统中进程调度的核心部分,它涉及到保存当前进程的状态,并加载下一个进程。这通常发生在以下几种情况:
时间片用完:操作系统需要保存当前进程的上下文(寄存器、程序计数器等),并调度另一个进程。
进程阻塞:例如,进程调用sleep()进入阻塞状态,操作系统会保存当前进程的上下文,调度其他进程运行。
在本程序中,虽然每次sleep()后会有一个时间间隔,但实际上,程序只有在sleep()函数内部的时间等待时才会进行上下文切换。否则,每次输出后,进程的调度会依赖于操作系统的时间片机制。
6.5.2 进程时间片
在现代操作系统中,CPU时间被分配给进程是通过时间片来实现的。每个进程在获得CPU资源时,操作系统会给它一个固定的时间片。时间片过后,操作系统会进行上下文切换,将CPU资源分配给另一个进程。
在本程序中,时间片的概念可以在以下两种场景中体现:
正常的CPU调度:在每次printf()输出信息时,进程运行在用户态,直到它调用sleep()。此时,操作系统可能会切换到其他进程(如果有其他进程在等待CPU资源)。
睡眠期间的调度:当程序调用sleep()函数时,进程进入睡眠状态,不占用CPU资源,操作系统会调度其他就绪的进程执行。
6.5.3 用户态与核心态转换
操作系统将进程的执行分为两种状态:
用户态:程序在用户空间内运行,操作系统不干预进程的执行,进程只能访问用户空间的内存,执行普通的计算任务。
核心态:进程执行涉及操作系统核心的操作时,会进入核心态。例如,程序调用sleep()或其他系统调用时,操作系统会切换到核心态来处理该请求。
在hello的进程执行过程中,会进行用户态和核心态转换:
用户态到核心态的转换:当程序调用sleep()函数时,操作系统会把当前进程挂起,进入核心态去处理系统调用。具体来说,sleep()通过系统调用进入核心态,操作系统会把当前进程标记为“睡眠”状态,并在指定的时间过后重新调度该进程。
核心态到用户态的转换:当sleep()调用结束后,操作系统会把进程恢复到用户态,然后继续执行后续的程序(即printf)。
6.5.4 进程调度
hello的执行过程中,进程调度主要体现在以下几个方面:
进程在用户态执行程序(如printf()),然后通过核心态的系统调用(如sleep())将进程挂起。
操作系统通过时间片机制调度进程,保证CPU资源的合理分配。
系统调用(如sleep())触发用户态与核心态的转换,从而实现进程的挂起和恢复。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.6.1 hello执行过程中会出现的异常种类
中断、陷阱、故障和终止。
6.6.2处理方法
6.2.2.1中断的处理方法:
在当前指令完成执行之后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序。当处理程序返回时,它就将控制返回给下一条指令(也即如果没有发生中断,在控制流中会在当前指令之后的那条指令)。结果是程序继续执行,就好像没有发生过中断一样[1]。
6.2.2.2 陷阱的处理方法:
陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令[1]。
6.2.2.3 故障的处理方法:
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序[1]。
6.2.2.4 终止的处理方法:
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。处理程序将控制返回给一个abort例程,该例程会终止这个应用程序。
6.2.3 会产生信号的种类:
图6-1 Linux信号
6.2.4 处理方法:
signal函数可以通过下列三种方法之一来改变和信号signum相关联的行为:
如果handler是SIG_IGN,那么忽略类型为signum的信号。
如果handler是SIG_DFL,那么类型为signum的信号行为恢复为默认行为。
否则,handler就是用户定义的函数的地址,这个函数被称为信号处理程序,只要进程接收到一个类型为signum的信号,就会调用这个程序。通过把处理程序的地址传递到signal函数从而改变默认行为,这叫做设置信号处理程序。调用信号处理程序被称为捕获信号。执行信号处理程序被称为处理信号[1]。
6.2.5 不停乱按
图6-2 不停乱按
乱按的内容会被输出,程序继续执行。
6.2.6 回车
图6-3 回车
回车会出现空行,没有其他影响。
6.2.7 Ctrl-Z
图6-4 Ctrl-Z
进程收到信号,被挂起,并打印信息。
6.2.8 Ctrl-C
图6-5 Ctrl-C
进程收到信号,终止。
6.2.9 Ctrl-z后运行ps命令
图6-6 Ctrl-z后运行ps命令
会打印各进程的PID等信息。
6.2.10 Ctrl-z后运行jobs命令
图6-7 Ctrl-z后运行jobs命令
会打印进程的状态。
6.2.11 Ctrl-z后运行pstree命令
图6-8 Ctrl-z后运行pstree命令
会打印进程的树状图。
6.2.12 Ctrl-z后运行fg命令
图6-9 Ctrl-z后运行fg命令
会将hello重新调到前台执行,且总共的打印次数保持不变。
6.2.13 Ctrl-z后运行kill命令
图6-10 Ctrl-z后运行kill命令
进程收到信号,会被杀死。
6.7本章小结
本章围绕hello的进程管理展开。进程是指执行中的程序的实例,具有非常重要的作用。通过查阅相关的资料,我了解了壳Shell-bash的作用与处理流程、Hello的fork进程创建过程、Hello的execve过程以及Hello的进程执行。最后,通过具体实验,真真切切的看到了hello的异常与信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
- 逻辑地址
逻辑地址通常指的是由程序生成并使用的地址。程序中的变量、指针等都是通过逻辑地址来访问内存的。在程序执行过程中,CPU会生成逻辑地址,而这些地址实际上是通过操作系统映射到物理内存的。
在hello的执行过程中,变量如argc、argv[]和i等,都是程序内部使用的,当你使用argv[1]、argv[2]等访问命令行参数时,程序实际上是通过逻辑地址来访问参数的内存空间。
- 线性地址
线性地址是操作系统对虚拟地址空间的一种映射形式。在一些计算机体系结构中,从虚拟地址到物理地址可能要经过多个转换,其中线性地址是虚拟地址转换的一个中间结果。
在现代操作系统中,虚拟地址通过段页式管理转换为线性地址,之后再通过页表映射为物理地址。
- 虚拟地址
虚拟地址是操作系统为每个进程提供的一种抽象的内存空间。每个程序运行时,它会被分配一个虚拟地址空间,这个地址空间是由操作系统和硬件协作来管理的。虚拟地址空间对于每个进程来说都是独立的,程序无需关心物理内存的具体位置。
在hello的执行过程中,当CPU执行printf、sleep等函数时,它会访问虚拟地址。虚拟地址由操作系统通过地址转换机制映射到物理内存地址。
例如,程序在访问argv[1]时,实际上是访问某个虚拟地址,操作系统会将这个虚拟地址转换为对应的物理地址。
- 物理地址
物理地址是计算机内存中实际存在的位置,是计算机硬件(RAM)中内存单元的实际地址。程序运行时,虚拟地址最终会被操作系统转换为物理地址,指向物理内存中的某个具体位置。
在hello的执行过程中,虽然使用的是虚拟地址,但操作系统通过内存管理单元(MMU)将这些虚拟地址映射到物理内存上。例如,当程序访问argv[]数组时,操作系统通过映射将这些虚拟地址转换为物理地址,然后CPU才能从内存中读取数据。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在Intel架构的x86处理器中,逻辑地址到线性地址的变换是通过段式管理机制完成的。在段式管理下,处理器使用逻辑地址来访问内存,而逻辑地址通常包含段选择符和偏移量。这些部分需要通过段描述符表转换成一个实际的线性地址。线性地址是操作系统通过分页等技术进一步映射到物理内存的地址。
7.2.1 逻辑地址组成
在Intel的x86架构中,逻辑地址是由两部分组成的:
段选择符:16位的值,用于从全局描述符表或局部描述符表中查找段描述符。
偏移量:32位(或更长,取决于模式),指定在该段内的数据位置。
7.2.2 段选择符
段选择符实际上是一个指向段描述符的索引。段选择符的结构如下:
索引:13位,指向段描述符表中的条目。
TI(Table Indicator):1位,指定段描述符表的位置。如果TI=0,表示使用GDT;如果TI=1,表示使用LDT。
RPL(Request Privilege Level):2位,指定请求的特权级(0-3),通常与访问控制相关。
段选择符指向一个段描述符,段描述符是存储在GDT或LDT中的一个数据结构,包含了段的起始地址、段的大小、段的类型等信息。
7.2.3 段描述符
段描述符是一个8字节的数据结构,包含了有关段的详细信息。最重要的字段包括:
基地址:段的起始地址。
段界限:段的大小或最大偏移量。
段类型、权限:段的权限和特性(如可执行、读写等)。
段大小标志:区分是否为使用字节为单位或页为单位的地址。
7.2.4 从逻辑地址到线性地址的转换过程
当程序访问一个逻辑地址时,CPU会通过段选择符获取段描述符,然后将其与偏移量结合起来计算线性地址。这个转换过程可以分为以下几个步骤:
步骤1:查找段描述符
CPU使用段选择符中的索引字段在GDT或LDT中查找相应的段描述符。段描述符包含段的基地址和其他描述信息。如果段选择符的TI位为0,段描述符在GDT中查找。如果TI位为1,段描述符在LDT中查找。
步骤2:计算线性地址
一旦找到段描述符,CPU就可以从中提取出段的基地址。段描述符还包含段的界限,即该段的最大偏移量。线性地址的计算方法为:线性地址 = 段基地址 + 偏移量。段基地址是从段描述符中获得的基地址,偏移量是逻辑地址中的偏移量部分。
步骤3:访问线性地址
线性地址是经过段管理系统转换后的地址。这个地址会被操作系统进一步映射到物理内存地址(如果启用了分页机制的话),但在段式管理中,线性地址通常已经足够进行内存访问。
7.3 Hello的线性地址到物理地址的变换-页式管理
7.3.1 页式管理简介
页式管理是一种内存管理方案,将虚拟地址空间(包括线性地址)分成固定大小的块,称为页,以及对应的页框。操作系统通过页表来将虚拟地址映射到物理地址。
7.3.2 线性地址的结构
在分页管理中,线性地址通常由多个部分组成。例如,在32位x86架构中,线性地址通常是32位的,分为三个部分:
页目录索引:位于线性地址的高10位。
页表索引:位于中间的10位。
页内偏移:位于低12位。
7.3.3 线性地址到物理地址的转换过程
当程序执行时,CPU会生成一个线性地址,该地址会通过分页机制进一步转换为物理地址。这个转换过程通常包括以下步骤:
步骤1:获取线性地址
首先,程序中的地址从逻辑地址经过段管理转换为线性地址。程序完成了段管理的步骤后,我们就有了一个线性地址。例如,当你通过命令行传入参数时,argv[]存储在某个内存区域,你通过argv[1]、argv[2]、argv[3]等访问这些参数。
步骤2 查找页目录和页表
页目录:线性地址的高10位用于索引页目录。页目录表是一个包含多个页表地址的表,每个条目指向一个页表。
页表:页表将线性地址的中间10位用于索引该页的具体条目,每个页表条目指向一个实际的物理页框。
步骤3 获取物理地址
最终,页表条目会提供物理页框的基地址,结合页内偏移就可以计算出物理地址。物理地址的计算方法为:物理地址 = 物理页框基地址 + 页内偏移。
7.3.4 以hello为例
程序中的字符串argv[1]、argv[2]、argv[3]被存储在内存中的某个位置。操作系统通过段式管理将这些变量的地址映射为线性地址。
程序在循环中访问argv[1]、argv[2]、argv[3],这些操作会使用线性地址。例如,当访问argv[1]时,CPU使用该地址执行相应的内存访问操作。
操作系统的内存管理单元(MMU)将argv[1]对应的线性地址转换为物理地址。转换过程如下:
首先,线性地址会被分解为:页目录索引、页表索引和页内偏移。
MMU使用页目录索引查找页目录表,找到相应的页表地址。
然后,MMU使用页表索引查找具体的页框地址,找到物理内存中的一个页框。
最后,MMU将页框基地址和页内偏移结合,得到物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1 四级页表支持下的虚拟地址到物理地址的转换
在x86-64架构中,虚拟地址的大小通常是64位,但实际支持的虚拟地址空间较小(比如48位)。虚拟地址空间通过多级页表来进行管理,具体地,Intel的64位架构使用了四级页表来处理虚拟地址到物理地址的转换。
7.4.1.1 四级页表的结构
虚拟地址的结构为:
PML4(Page Map Level 4):高9位(第47位到第39位)用于索引到PML4表,即页目录指针表。
PD(Page Directory):紧接着的9位(第38位到第30位)用于索引到页目录。
PT(Page Table):接下来的9位(第29位到第21位)用于索引到页表。
Page Offset:低12位(第20位到第0位)表示页内偏移,指向物理页内的具体位置。
7.4.1.2 转换过程
PML4查找:首先使用虚拟地址的高9位在PML4表中查找对应的页目录指针表条目。如果找到该条目,该条目指向页目录的物理地址。
页目录查找:接下来,使用虚拟地址中的第38到第30位在页目录表中查找对应的页表条目。如果该条目有效,它指向页表的物理地址。
页表查找:然后,使用虚拟地址中的第29到第21位在页表中查找对应的页框条目。如果该条目有效,它指向物理内存中的一个页框。
页内偏移:最后,使用虚拟地址中的低12位作为页内偏移,来确定在该页框内的具体位置。
7.4.2 TLB
TLB(Translation Lookaside Buffer)是一个高速缓存,用于缓存最近的虚拟地址到物理地址的转换结果,目的是加速地址转换过程。由于虚拟地址到物理地址的转换可能涉及多次查找页表(尤其是在多级页表的体系结构下),每次查找都可能消耗较多的时间。因此,TLB缓存了之前已查找到的虚拟地址到物理地址的映射,减少了每次转换时的查找时间。
7.4.2.1 TLB的工作原理
当CPU需要访问某个虚拟地址时,它首先会检查TLB是否有该虚拟地址的映射。如果TLB中有,这时我们称为TLB命中,CPU直接从TLB中获得物理地址,访问内存。如果TLB中没有,这时我们称为TLB未命中。CPU需要进行页表查找,并将新的虚拟地址到物理地址的映射加载到TLB中,以便下次快速访问。
7.4.2.2 TLB的设计
大小:TLB通常比页表要小得多,但它是非常快速的。它可能只有几百个条目,且它通常采用全相联或组相联的设计方式。
替换策略:当TLB已满时,会使用某种替换策略(如LRU最少使用算法)来淘汰旧的条目,腾出空间缓存新的映射。
7.4.2.3 TLB中的条目
每个TLB条目通常包括:
虚拟页号(VPN):虚拟地址中的页目录、页表和页框的组合,表示虚拟页。
物理页号(PPN):映射到该虚拟页的物理页号。
控制信息:如有效位、权限位等。
7.4.3 总结
在现代x86-64架构下,虚拟地址到物理地址的转换通过四级页表和TLB缓存机制共同工作:
四级页表:虚拟地址通过四级页表(PML4 -> 页目录 -> 页表 -> 页内偏移)进行转换,最终得到物理地址。
TLB缓存:TLB缓存了常用的虚拟地址到物理地址的映射,从而避免了频繁的多级页表查找,提高了内存访问的效率。
这种多级页表加上TLB缓存的方式,使得现代计算机可以高效、灵活地管理大规模的虚拟内存,同时提供了更好的性能和内存保护。
7.5 三级Cache支持下的物理内存访问
7.5.1 三级Cache简介
在现代处理器中,通常会有三级缓存(L1、L2、L3),它们的主要作用是缓存访问频繁的数据,以减少访问主存的延迟。每个级别的缓存都有其特点:
L1 Cache:通常是最小且最快的缓存,通常分为数据缓存和指令缓存。L1 Cache一般位于处理器核心内部,访问延迟最小,容量较小(一般为32KB到128KB)。
L2 Cache:相较于L1,L2 Cache较大,访问速度较慢,但依然比物理内存快得多。L2 Cache通常与处理器核心相关联,每个核心有独立的L2 Cache,或多个核心共享。
L3 Cache:L3 Cache是级别最高的缓存,通常为所有处理器核心共享,容量更大(几MB到十几MB不等),但访问速度较慢。L3 Cache的主要作用是进一步减少访问主存的频率。
7.5.2 物理内存访问的过程
处理器执行指令时,涉及从不同层次的缓存或主存中读取数据。这个过程的目的是尽量减少访问物理内存的次数,而优先从高速缓存(L1、L2、L3)中获取数据。
物理内存访问过程包括以下几个步骤:
步骤1:查找L1缓存
当处理器需要读取数据时,首先检查L1数据缓存是否包含所需的数据。如果缓存命中(即L1 Cache中有该数据),处理器可以直接从L1 Cache中获取数据,访问延迟最低。
L1缓存命中,CPU直接从L1 Cache获取数据;L1 Cache未命中,处理器将请求L2 Cache。
步骤2:查找L2 Cache
如果 L1 Cache未命中,处理器会继续检查 L2 Cache。L2 Cache通常较大,存储的数据比 L1 Cache更多,通常会包括最近使用但不频繁访问的数据。
如果数据存在于 L2 Cache,处理器会将数据从 L2 Cache读取,然后更新 L1 Cache;如果数据在 L2 Cache中也没有,处理器将请求 L3 Cache(如果有)或者 主内存。
步骤3:查找 L3 Cache
L3 Cache较大,通常是多核处理器共享的缓存。在 L3 Cache中查找数据比从主内存读取要快得多。
如果数据存在于 L3 Cache,处理器将数据读取到 L2 Cache,并可能将其传递到 L1 Cache,以便更快的访问;如果数据在 L3 Cache中也没有,处理器将从主存中读取数据。
步骤4:访问主存
如果数据不在L1、L2或L3 Cache中,最后的步骤是从物理内存(通常是DRAM)中读取数据。由于访问物理内存的速度远远低于访问缓存,处理器的访问延迟会显著增加。
处理器通过内存控制器与主内存交互,读取数据后,通常会将数据加载到L3 Cache,L2 Cache,甚至L1 Cache中,以便后续的快速访问。
7.5.3 物理内存访问过程中的缓存策略
7.5.3.1 缓存替换策略:
当缓存已满时,需要决定将哪个数据移出缓存。常见的缓存替换策略包括:
LRU:将最长时间未被访问的数据替换出缓存。
FIFO:将最早进入缓存的数据替换出缓存。
随机替换:随机选择一个缓存项进行替换。
7.5.3.2 写策略
处理器写数据时,有两种常见的写策略:
写直达:数据同时写入缓存和主内存。这样可以确保缓存与内存一致,但写操作的延迟较高。
写回:数据先写入缓存,只有当缓存中的数据被替换时,才会将修改的内容写回主内存。写回策略可以减少内存写操作的次数,但会增加缓存一致性管理的复杂性。
7.5.3.3 缓存一致性
在多核处理器中,各个核心有可能有自己的本地缓存(L1和L2),但多个核心间共享L3 Cache。为了保持缓存中的数据一致性,通常会采用缓存一致性协议,如MESI协议,来确保各个缓存中的数据在读取和写入时的一致性。
7.6 hello进程fork时的内存映射
7.6.1 进程地址空间
每个进程都有自己的虚拟地址空间。虚拟地址空间大致可以分为以下几个部分:
代码段:存放程序的机器码(代码)。
数据段:存放已初始化的全局变量和静态变量。
BSS段:存放未初始化的全局变量和静态变量。
堆:存放动态分配的内存(例如通过malloc和free管理的内存)。
栈:存放函数调用时的局部变量和函数调用信息。
7.6.2 fork()的行为
当调用fork()时,操作系统会为子进程复制父进程的地址空间。
传统上,父进程和子进程在调用fork()后会有完全相同的内存映射,子进程将获得父进程内存的完整副本。但在现代操作系统中,通常会采用“写时复制”策略来优化fork()的性能。这意味着,直到父进程或子进程尝试修改某个内存页时,操作系统才会为该页创建一个副本。换句话说,父进程和子进程在fork()后共享相同的内存,只有在修改时才会各自独立。
7.6.3 内存映射的具体变化
在调用fork()后,父子进程的内存映射会发生以下几种变化:
代码段:由于代码段是只读的,因此父进程和子进程可以共享同一段内存,不需要复制。
数据段和BSS段:这些区域通常包含全局变量和静态变量,父进程和子进程会各自有一个副本。
堆:对于堆内存,父进程和子进程最初会共享相同的内存页,但它们的虚拟地址不同,修改堆中的数据时会触发COW行为,从而创建新的内存页。
栈:栈内存对于父进程和子进程是独立的,因为栈是与函数调用和局部变量相关的。
7.7 hello进程execve时的内存映射
7.7.1 execve()简介
execve()的作用是将当前进程的内存空间完全替换为新的程序(包括代码、数据、堆、栈等)。执行execve()后,当前进程的执行状态、内存和代码都会被新的程序所替代。execve()是一个非常强大的系统调用,通常用于启动新的程序。
int execve(const char *pathname, char *const argv[], char *const envp[]);
pathname:要执行的新程序的路径。argv:新的程序的命令行参数。envp:新的程序的环境变量。
7.7.2 execve()的内存映射变化
当调用execve()时,操作系统会将当前进程的地址空间完全清除,并将新的程序加载到进程的地址空间中。以下是内存映射的具体变化:
代码段:新的程序的代码段会被加载到进程的内存中,替换原先的代码段。当前进程的代码段被清除。
数据段和BSS段:新的程序的全局变量和静态变量会加载到数据段中,原进程中的这些变量被丢弃。BSS段会被初始化为零。
堆:新的程序的堆会被初始化,原先的堆内存会被释放,新的程序从其起始位置开始使用堆内存。
栈:新的程序会重新设置栈,栈的内容也会被重置。
7.8 缺页故障与缺页中断处理
7.8.1 缺页故障简介
缺页故障是指程序访问虚拟内存中的某个地址时,该地址所对应的页面不在物理内存中,导致一个异常事件。虚拟内存的关键机制就是程序可以访问一个非常大的虚拟地址空间,而不必将所有的内存都装载到物理内存中。当程序访问一个尚未加载到物理内存的虚拟页面时,就会触发缺页故障。
7.8.2 缺页故障的原因
缺页故障通常发生在以下几种情况下:
页面不在物理内存中:当程序访问的虚拟地址所在的页没有加载到物理内存时,会产生缺页故障。
懒惰加载:虚拟内存的页面可能在程序运行时才被加载,即只有在首次访问时,操作系统才将其加载到物理内存中。此时访问一个尚未加载的页面时会发生缺页故障。
页面被换出:操作系统使用页面置换算法(如LRU、FIFO等)将一些页面从物理内存中换出到磁盘。如果程序后续访问这些被换出的页面,缺页故障将再次发生。
7.8.3 缺页中断处理流程
当缺页故障发生时,操作系统需要通过一系列步骤来处理这一中断。以下是典型的缺页中断处理流程:
7.8.3.1 硬件触发缺页中断
当程序访问的虚拟页面不在物理内存中时,硬件会触发一个缺页中断。处理器会保存当前的程序状态,并将控制权交给操作系统的缺页中断处理程序。
7.8.3.2 检查缺页原因
操作系统的缺页中断处理程序会检查页面是否有效。如果页面不存在,操作系统需要从硬盘中读取该页面并将其加载到内存中。如果是内存保护错误(例如非法访问只读内存等),操作系统可能会终止进程。
7.8.3.3 查找空闲物理页面
操作系统需要在物理内存中找到一个空闲的页面来装载缺失的虚拟页面。如果物理内存中没有空闲的页面,操作系统需要进行页面置换(即选择一个已加载的页面,先将其写回磁盘,然后加载新的页面)。
7.8.3.4 页面置换(如果需要)
如果没有空闲的物理页面,操作系统使用页面置换算法(如LRU、FIFO、Clock等)选择一个页面将其换出。被换出的页面会被写回磁盘,以便腾出空间加载新的页面。
7.8.3.5 从磁盘加载页面
操作系统将所需的虚拟页面从磁盘中加载到物理内存中。读取操作通常会涉及磁盘I/O,这可能是一个较慢的过程。
7.8.3.6 更新页表
一旦页面被加载到内存,操作系统会更新页表,将对应虚拟页面的页表项标记为有效,并设置正确的物理地址。
7.8.3.7 恢复进程执行
操作系统更新完页表后,恢复进程的执行,程序可以继续执行原来的指令。此时,程序访问的虚拟地址已被映射到物理内存的实际页面,缺页故障处理完成。
7.8.4 缺页故障的性能影响
缺页故障的处理需要花费时间和系统资源,尤其是在缺页故障频繁发生的情况下,可能导致程序运行缓慢。缺页故障的处理过程通常会导致以下性能问题:
磁盘I/O延迟:从磁盘加载页面是一个相对较慢的操作,特别是与内存访问相比。频繁的磁盘I/O操作会显著影响程序的执行效率。
页面置换开销:如果物理内存有限,频繁的页面置换可能会增加处理开销,尤其是在使用复杂页面置换算法时。
"页面抖动":当系统频繁地将页面换入换出时,可能出现所谓的“页面抖动”现象,进程的执行会因频繁的内存访问和磁盘I/O而极度缓慢。
7.8.5 减轻缺页故障负面影响的策略
为了减少缺页故障的发生和其带来的性能开销,操作系统会采用一些优化策略:
增加物理内存:增加系统中的物理内存可以减小缺页故障的发生率。
优化页面置换算法:选择高效的页面置换算法(如LRU、Clock算法等),确保最不常用的页面被优先换出。
局部性原理:程序通常具有时间局部性和空间局部性,操作系统会利用这些特性预加载页面或使用高效的预取策略来减少缺页故障。
内存映射文件:通过内存映射文件,可以避免频繁的磁盘I/O操作,提升性能。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
7.9.1 内存分配
动态内存的分配通常是通过以下函数实现的:
malloc(size_t size):分配size字节的内存块。返回一个指向该内存块的指针。如果分配失败,返回 NULL。
calloc(size_t num, size_t size):分配一个包含num个元素,每个元素size字节的内存块,并初始化为零。返回一个指向该内存块的指针。如果分配失败,返回 NULL。
realloc(void *ptr, size_t size):重新调整已经分配内存的大小。ptr是先前通过malloc或calloc分配的内存块的指针,size是新的内存大小。如果分配成功,返回一个指向新内存的指针。如果分配失败,返回NULL,且原内存保持不变。
7.9.2 内存释放
动态内存的释放是通过free函数实现的:free(void *ptr):释放先前通过malloc、calloc或realloc分配的内存块。调用free后,指向该内存块的指针不再有效,应将其设为NULL以避免悬挂指针问题。
7.9.3 内存池
内存池是将多个相似大小的内存块集中在一起管理的一种技术。程序预先分配一块大的内存区域,然后通过自定义的分配器在这个内存池中分配和回收内存。
优点:减少频繁调用操作系统的内存分配和回收函数的开销。
应用场景:例如,游戏中的对象池,或者线程池中的工作线程。
7.9.4 内存分配策略
动态内存分配的策略和方法很多,主要目的是有效地管理内存并避免内存碎片。以下是几种常见的策略:
7.9.4.1 首次适应
在内存块列表中,从头开始查找第一个足够大的空闲块来分配内存。
优点:简单,执行速度较快。
缺点:可能会留下较小的碎片,导致内存碎片化。
7.9.4.2 最佳适应
查找所有空闲块中最适合请求大小的块,即最小的可以容纳请求的空闲块。
优点:减少内存碎片,尽可能使内存块更紧凑。
缺点:查找过程较慢,尤其是在大量小内存块的情况下,容易导致大量的小碎片。
7.9.4.3 最差适应
查找最大的空闲块来进行内存分配,目的是希望剩余的空闲块尽可能大,减少碎片化。
优点:避免将大块内存分配给小请求,减少碎片。
缺点:可能会浪费空间,且管理较为复杂。
7.9.4.4 延迟分配
只有在实际使用内存时才进行内存分配,而不是提前分配。这样可以减少内存使用,并避免不必要的内存分配。
7.9.4.5 紧凑化
在内存中定期整理碎片,通过移动内存块来消除碎片,使内存更加紧凑。
优点:避免了长期存在的小碎片。
缺点:需要较大的开销,且可能会影响性能。
7.9.5 内存碎片
内存碎片是动态内存分配中的一个问题,指的是由于频繁的内存分配和释放,导致内存空间被切割成许多小块,不能有效利用。碎片通常分为两种:
外部碎片:程序中的空闲内存区域被分散在整个内存中,导致即使总的空闲内存足够,但无法满足一个大的内存请求。
内部碎片:由于分配的内存块大于实际需要的内存量,导致剩余的内存不能被使用。
处理内存碎片的方法:
内存池:通过预分配大块内存并进行细粒度的分配,减少碎片的发生。
内存紧凑化:通过移动内存中的块,避免空洞的产生。
分区分配:将内存分为多个大小相同的区域,每个区域内管理一个内存块,这样可以减少碎片。
7.9.6 内存泄漏
内存泄漏发生在程序分配了动态内存之后,没有正确释放这部分内存,导致无法再访问这块内存。随着时间推移,内存泄漏会导致程序使用越来越多的内存,最终消耗完系统的内存。
处理内存泄露的方法:
手动管理内存:程序员需要确保每次分配内存后,都调用free来释放内存。
智能指针:在C++中,智能指针(如std::unique_ptr和std::shared_ptr)可以自动管理内存,减少内存泄漏的风险。
内存泄漏检测工具:例如Valgrind、ASAN等工具可以帮助检测和修复内存泄漏问题。
7.9.7 Printf与动态内存管理
在C语言中,printf函数可能会间接调用动态内存分配函数,尤其是在格式化字符串或输出缓冲区的管理上。例如,printf会根据格式化字符串的内容动态分配缓冲区以存储输出内容。
7.10本章小结
这一章围绕hello的存储管理展开。通过查阅资料,我对hello的存储器地址空间、段式管理、页式管理、TLB与四级页表支持下的VA到PA的变换、三级Cache支持下的物理内存访问、hello进程fork时的内存映射、hello进程execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理有了较为清晰的认识,学到了很多知识,也深深体会到了存储技术的妙处。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
在Linux操作系统中,IO设备管理方法主要通过设备的模型化和统一的接口来进行。这种设计模式使得各种设备(如磁盘、网络接口、字符设备等)可以通过相同的方式进行访问和管理,从而简化了开发和使用。
8.1.1 设备的模型化:文件
在Linux中,所有的设备都通过文件来进行表示。具体来说,设备被视为文件的一部分,而文件又是通过系统调用进行操作的。这样的设计模式有助于统一管理和访问不同的硬件设备,并为用户提供一致的接口。
设备文件:在Linux中,设备通过特殊文件(通常在/dev目录下)来表示,这些文件被称为设备文件。设备文件分为两大类:字符设备:与字符流的设备进行交互,例如键盘、串口设备、打印机等。字符设备通过一次读取或写入一个字符的方式与设备交互。块设备:与块存储设备进行交互,例如硬盘、SSD等。块设备将数据分为多个块,可以进行随机读取和写入操作。
文件抽象:设备文件在文件系统中表现为普通文件,用户或应用程序可以通过标准的文件操作接口(如open、read、write、ioctl等)来与设备交互。这种设计将硬件设备的操作与常规的文件操作结合起来,简化了程序的开发和硬件的管理。
8.1.2 设备管理:Unix IO接口
Unix及其衍生操作系统(如Linux)通过统一的IO接口来进行设备管理。IO接口使得用户可以通过文件操作来访问不同种类的设备,而无需关心底层硬件的实现细节。主要的Unix IO接口包括以下几个部分:
打开设备:通过open()系统调用来打开设备文件。设备文件路径通常位于/dev目录下,例如,磁盘设备可能是/dev/sda,字符设备可能是/dev/ttyS0。
读取和写入:设备文件一旦被打开,用户就可以使用read()和write()函数进行数据的传输。例如,向磁盘设备写入数据或从网络设备读取数据。
控制设备:一些设备需要特殊的控制命令,这些命令通常通过ioctl()系统调用来发送。例如,修改串口设置、获取磁盘状态等操作可以通过ioctl来完成。
关闭设备:完成设备的操作后,可以通过close()来关闭设备文件,释放资源。
8.1.3 设备驱动模型
Linux操作系统通过设备驱动程序来管理设备的实际操作。设备驱动程序通常提供底层的硬件抽象,并通过系统调用接口与内核和用户空间进行交互。设备驱动程序负责将操作系统的文件操作转化为硬件设备的实际命令。
字符设备驱动:例如,对于一个串口设备,字符设备驱动将用户空间的read()和write()请求转化为串口硬件的读写操作。
块设备驱动:例如,对于磁盘设备,块设备驱动负责将read()和write()请求映射到实际的磁盘块读取和写入操作。
网络设备驱动:对于网络设备,网络设备驱动会将应用程序的发送和接收请求转换为网络协议栈的操作,涉及数据包的构造和传输。
8.1.4 设备模型的统一性与扩展性
Linux设备管理模型的一个重要特点是它的统一性和扩展性。通过将所有设备都建模为文件,Linux系统可以使用同一套接口进行管理和操作,无论设备是硬盘、网络接口还是虚拟终端。此外,Linux系统的设备管理接口非常灵活,可以方便地添加新类型的设备支持。
设备类:Linux设备管理将设备按类型划分为多个类别,如字符设备、块设备、网络设备等。每个类别有不同的处理机制和接口。
设备文件系统(devfs):设备文件系统是Linux中的一个虚拟文件系统,用于管理设备文件的创建和删除。随着现代Linux系统的发展,devfs逐渐被udev(用户空间设备管理器)取代,udev通过动态创建设备节点的方式提高了设备管理的灵活性。
内核模块:许多设备驱动程序以内核模块的形式存在,这使得设备驱动可以在不重启系统的情况下加载和卸载。通过模块化的方式,Linux系统支持多种硬件设备和驱动的扩展。
8.2 简述Unix IO接口及其函数
Unix IO接口是操作系统与用户空间程序之间进行数据交换的关键机制。在Unix系统中,几乎所有的设备(包括文件、终端、网络设备等)都被视为文件,并通过标准的文件操作接口进行访问。这种统一的接口提供了高效、简洁和灵活的数据访问方式。
8.2.1 Unix IO接口简介
Unix IO接口提供了一套用于文件、设备和网络通信的标准化函数,使得应用程序可以以类似访问普通文件的方式与硬件设备进行交互。常见的IO操作包括打开、读取、写入、关闭文件等。
主要函数和系统调用:打开文件:open();读取文件:read();写入文件:write();关闭文件:close();文件状态控制:ioctl();文件定位:lseek();文件权限检查:access()。
8.2.2 open()
open()系统调用用于打开文件或设备,并返回一个文件描述符。文件描述符是一个整数,用来表示打开的文件或设备。通过这个文件描述符,应用程序可以进行后续的读写操作。
int open(const char *pathname, int flags, mode_t mode);
pathname: 要打开的文件或设备的路径。flags: 打开文件时的标志。mode: 文件创建时的权限模式(仅在文件不存在时需要指定)。
返回值:成功时返回文件描述符,失败时返回-1。
8.2.3 read()
read()系统调用用于从文件或设备中读取数据。
ssize_t read(int fd, void *buf, size_t count);
fd: 文件描述符。buf: 用于存储读取数据的缓冲区。count: 要读取的字节数。
返回值:实际读取的字节数,若到达文件末尾则返回0,失败时返回-1。
8.2.4 write()
write()系统调用用于向文件或设备写入数据。
ssize_t write(int fd, const void *buf, size_t count);
fd: 文件描述符。buf: 存储写入数据的缓冲区。count: 要写入的字节数。
返回值:成功写入的字节数,失败时返回-1。
8.2.5 close()
close()系统调用用于关闭文件描述符,释放相关资源。
int close(int fd);
fd: 要关闭的文件描述符。
返回值:成功时返回0,失败时返回-1。
8.2.6 ioctl()
ioctl()系统调用用于设备控制,允许应用程序向设备驱动程序发送特殊的控制命令。这些命令通常与设备的特殊功能相关,如获取设备状态、设置设备参数等。
int ioctl(int fd, unsigned long request, ...);
fd: 文件描述符。request: 请求的控制命令。后续的参数:命令所需的附加参数。
返回值:成功时返回0,失败时返回-1。
8.2.7 lseek()
lseek()系统调用用于定位文件指针。通过这个函数,程序可以在文件中随机访问指定的位置。
off_t lseek(int fd, off_t offset, int whence);
fd: 文件描述符。offset: 文件指针的偏移量。whence: 偏移量的起始位置。
返回值:成功时返回新的文件指针位置,失败时返回-1。
8.2.8 access()
access()系统调用用于检查文件或目录的权限,通常用于程序执行前检查文件是否可以访问。
int access(const char *pathname, int mode);
pathname: 要检查的文件路径。mode: 权限类型。
返回值:成功时返回0,失败时返回-1。
8.3 printf的实现分析
8.3.1 printf函数的实现
printf是C语言中的标准输出函数,它用于将格式化的文本输出到终端或显示设备。在这个实现中,printf函数通过调用vsprintf来生成格式化的输出,然后使用write系统调用将内容输出到显示器[4]。
图8-1 printf
va_list arg = (va_list)((char*)(&fmt) + 4):这行代码的目的是跳过格式字符串 fmt,获取后面可变参数的地址。因为在32位系统中,指针fmt占4个字节,所以通过+4跳过fmt,指向第一个可变参数。
vsprintf(buf, fmt, arg):这个函数负责将格式字符串和可变参数按照指定的格式生成最终的字符串,并将生成的字符串存储在buf中。
write(buf, i):使用write函数将格式化的字符串buf(长度为i)写入显示设备。write是通过系统调用来完成输出的,它将数据传输到屏幕或终端。
8.3.2 vsprintf函数的实现
vsprintf函数负责解析格式字符串fmt,根据格式说明符(如%x)将可变参数格式化并填充到缓冲区buf中。
图8-2 vsprintf
itoa(tmp, *((int*)p_next_arg)):将p_next_arg指向的整数转换为十六进制字符串并存储在tmp中。这里假设参数是int类型(占4字节)。
strcpy(p, tmp):将转换得到的字符串tmp复制到buf中。
p_next_arg += 4:每次处理一个整数参数后,p_next_arg会向后移动4字节,指向下一个参数。
(p - buf):返回写入buf的字符数。
8.3.3 write系统调用
write是一个系统调用,用于将数据输出到设备。这个实现中,write被用来将格式化后的字符串发送到显示设备(如显示器)。
图8-3 write
mov eax, _NR_write:将系统调用号(write的系统调用号)放入eax寄存器。
mov ebx, [esp + 4]:将文件描述符(通常是1,表示标准输出)放入ebx寄存器。
mov ecx, [esp + 8]:将缓冲区(要输出的字符串)的指针放入ecx寄存器。
int INT_VECTOR_SYS_CALL:使用int 0x80中断触发系统调用,将控制权转交给内核处理实际的I/O操作。
8.3.4 显示输出的过程
最终,printf输出的内容将通过write系统调用传递到显示设备。整个过程如下:
8.3.4.1 字符映射
每个字符都被转换为一个位图(即字符的像素表示)。这些字符的位图数据通常保存在一个字符集(或字体库)中。
8.3.4.2 从字符到像素的转换
当生成格式化的字符串后,每个字符会根据其在字体库中的定义,转换为对应的像素矩阵(比如8x8或16x16点阵)。
8.3.4.3 写入显存(VRAM)
显示器的显存(Video RAM)存储了屏幕上每个像素的信息。生成的字符位图会被写入显存中,按正确的顺序排列在屏幕上。
8.3.4.4 显示刷新
显示芯片(如LCD或CRT)会根据设定的刷新率(通常为60Hz)周期性地从显存中读取数据,并将其转换为电信号传输到显示屏,最终呈现给用户。
8.4 getchar的实现分析
8.4.1 getchar()的实现
getchar()的基本作用是等待用户输入一个字符,并返回该字符的ASCII值。具体过程是,程序通过调用标准输入流的底层I/O函数来获取用户输入,当用户输入一个字符后按下回车键,getchar()将会读取到该字符并返回。
getchar()的简要实现流程:
该函数首先调用系统底层的I/O函数来读取输入字符。
如果输入字符并不是回车键,它会将字符返回给程序。
如果按下回车键(通常是换行符 \n),则getchar()函数才会结束当前输入并返回。
8.4.2 异步异常-键盘中断的处理
8.4.2.1 键盘输入的工作原理
硬件中断:当用户按下键盘上的某个键时,键盘生成一个中断信号,通知CPU进行处理。这是一个异步事件,因为它是在程序的执行过程中独立触发的。
扫描码与ASCII码:每次按下一个键时,硬件首先生成一个扫描码,表示某个键的物理位置。操作系统的键盘驱动程序将扫描码转换为对应的字符的ASCII码,然后将其存储在内核的缓冲区中。
缓冲区与系统调用:当程序调用getchar()等函数时,操作系统通过其标准I/O库(如glibc)从键盘缓冲区读取数据。如果缓冲区中有数据,getchar()会立即返回;否则,它会等待用户输入直到按下回车键。
8.4.2.2 具体操作流程
键盘驱动程序:当键盘按键被按下时,键盘硬件会发出中断请求,操作系统的键盘驱动程序会响应此请求。驱动程序将键的扫描码转换为相应的ASCII字符,并将字符放入一个输入缓冲区。
缓冲区管理:操作系统会维护一个缓冲区(例如,终端或控制台的输入缓冲区),存储用户输入的字符。缓冲区通常采用FIFO(先进先出)策略,确保字符按输入顺序处理。
getchar()的工作:当程序调用getchar()时,库函数会检查缓冲区是否有待读取的字符。如果有,getchar()会返回一个字符并从缓冲区中删除它。如果缓冲区为空,程序将等待用户输入,直到用户按下回车键。
按下回车键:回车键(通常是换行符 \n)是一个特殊字符,表示输入的结束。当用户按下回车键时,操作系统将换行符也放入缓冲区,并且getchar()会读取并返回此字符。
程序控制:getchar()函数阻塞程序执行,直到读取到输入的字符。因此,程序会暂停执行,等待用户输入。对于异步输入,程序需要处理I/O的等待(即阻塞),或者通过非阻塞I/O或信号处理等方式来处理。
8.5本章小结
本章围绕hello的IO管理展开。首先是理论层面——Linux的IO设备管理方法和Unix IO接口及其函数,然后是两个具体的函数的实现分析——printf和getchar。在完成了这一章后,我对IO管理有了一个清晰的认识。
结论
- hello最早诞生在源代码hello.c中。
- 在预处理阶段,预处理器修改hello.c,得到hello.i。
- 在编译阶段,hello.i被翻译成文本文件hello.s。
- 在汇编阶段,汇编语言被转换为机器代码,生成可重定位目标文件hello.o。
- 在链接阶段,hello.o和库文件组合成可执行文件hello。
- 在操作系统中执行这个二进制可执行文件时,操作系统会利用fork()和execve()来创建一个新的进程。此时,程序从静态代码变成了一个进程。进程会获得系统资源并开始执行。
- 在这个过程中,操作系统的进程管理会分配时间片,调度该进程在CPU上执行,并确保它有足够的内存(通过虚拟内存管理)。同时,操作系统会通过内存管理单元(MMU)为进程分配虚拟地址(VA)并映射到物理内存地址(PA)。如果程序访问的数据不在缓存中,操作系统会通过页表和TLB等机制进行管理,确保高效的数据访问。
- 操作系统会通过各种I/O管理机制(如文件系统、硬件设备驱动程序等)来保证程序能够顺利访问硬盘、显示器、键盘等外设。
- 当程序完成输出并结束时,操作系统会清理进程的资源,释放内存,关闭文件描述符等,进程会正常退出。此时,进程状态变为“终止”,系统将其从内存中移除。这时,Hello的“生命”彻底结束。
大作业真的非常有意义,它帮助我对课上所学的知识进行了巩固,同时让我感受到了学习计算机的乐趣。虽然在完成大作业的过程中遇到了很多的困难,但我都想办法一一克服了,让我的能力得到了提升。在未来,我将利用好在计算机系统这门课程中学到的钻研精神,向着自己感兴趣的方向不断探索。
附件
名字 | 作用 |
hello.i | 对hello.c预处理后的文件 |
hello.s | 对hello.i编译后生成的文本文件 |
hello.o | 对hello.s汇编后生成的可重定位目标文件 |
hello | 链接后生成的可执行目标文件 |
1.elf | hello.o的ELF文件 |
2.asm | hello.o的反汇编文件 |
3.elf | hello的ELF文件 |
4.asm | hello的反汇编文件 |
参考文献
[1] Randal E.Bryant,David O'Hallaron. 深入理解计算机系统[M]. 机械工业出版社,2016.7.
[2] 深入解析:execve函数的工作原理与安全实践,-CSDN博客. 深入理解execve函数.
[3] C/C++预处理过程详细梳理(预处理步骤+宏定义#define/#include+inline函数+宏展开顺序+条件预处理+其它预处理定义)_include和宏先后-CSDN博客. C/C++预处理过程详细梳理(预处理步骤+宏定义#define/#include+inline函数+宏展开顺序+条件预处理+其它预处理定义)
[4] [转]printf 函数实现的深入剖析 - Pianistx - 博客园.
转载自CSDN-专业IT技术社区
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/2401_86220402/article/details/144857662