计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能
学 号 2022112612
班 级 2203602
学 生 陈思华
指 导 教 师 吴锐
计算机科学与技术学院
2024年5月
本文从c程序hello.c着手,详细介绍了hello程序在Linux系统下从源代码到执行的完整生命周期,包括预处理、编译、汇编、链接、进程管理、存储管理、I/O管理等各个阶段。文章首先概述了hello程序的基本概念、环境与工具以及中间结果;紧接着详细阐述了各个阶段的概念、作用以及在Ubuntu下的具体操作命令。其中对于链接阶段,文章深入分析了链接的概念与作用、链接命令、可执行目标文件格式以及虚拟地址空间;此外,文章还探讨了hello进程管理、存储管理和I/O管理等内容,包括进程创建、内存映射、IO设备管理方法等;同时在文章末尾对本文的主要内容进行了总结。
关键词:Linux;hello程序;计算机系统;P2P;020;汇编;链接;进程管理;存储管理;IO管理
目 录
第1章 概述
1.1 Hello简介
从源代码到可执行的目标文件的转化通常会经历几个关键的步骤,包括预处理、编译、汇编和链接等。以下是一个详细的流程介绍:
- 预处理(Preprocessing):
概念: 预处理是编译过程的第一个阶段,它发生在真正的编译开始之前。在这个阶段,预处理器接收源代码,并对其进行文本替换、条件编译等操作;
主要操作:
宏定义替换:例如,如果源代码中有一个宏#define PI 3.14159,那么在预处理阶段,所有的PI都会被替换为3.14159;
条件编译:根据预处理器指令(例如#ifdef、#ifndef等),某些代码块可能会被包含或排除在编译过程中;
文件包含:使用#include指令将其他文件的内容包含到当前源文件中;
- 编译(Compilation):
概念: 编译是将源代码转化为汇编代码的过程。这个阶段会检查代码的语法和语义,确保其正确性,并生成汇编语言代码;
主要操作:
词法分析:将源代码分解为一系列的词法单元或标记;
语法分析:将这些词法单元组合成语法结构(如表达式、语句等);
语义分析:检查这些语法结构是否有意义,例如类型检查;
代码生成:生成对应的汇编代码;
- 汇编(Assembly):
概念: 汇编是将汇编语言代码转化为机器语言代码的过程。每个汇编指令对应于一个机器语言指令;
主要操作:
符号解析:处理在源代码中定义的符号(如变量名);
代码优化:对汇编代码进行一些优化;
指令选择:从机器语言中选择与汇编指令对应的指令;
地址生成:为指令中的标签生成实际地址;
- 链接(Linking):
概念: 链接是将所有的目标文件和所需的库文件合并为一个可执行文件的过程。这个过程解决了符号引用的问题,例如一个函数或变量在其他文件中定义,但在当前文件中被引用;
主要操作:
重定位:解决在编译过程中产生的符号引用问题;
合并目标文件:将多个目标文件的内容合并到一个单一的可执行文件中;
加载动态链接库(如果需要的话);
- 生成目标文件与可执行文件:
经过上述所有步骤后,最终会生成一个或多个目标文件(通常是.o或.obj文件),以及一个可执行文件(通常是.exe文件对于Windows系统或没有扩展名的文件对于UNIX-like系统)。这些文件可以被复制到其他计算机上并在没有编译器和链接器的环境中执行,因为它们已经是机器语言代码;
1.2 环境与工具
X64 CPU;2GHz;2G RAM;256GHD Disk 以上;
Windows10 64位;VMware Workstation Pro15.5.1;Ubuntu 20.04.4;
gdb;edb;readelf;objdump;Code::Blocks20.03
1.3 中间结果
文件名称 | 文件含义 |
hello.c | 源文件 |
hello.i | 预处理文件 |
hello.s | 汇编文件 |
hello.o | 可重定位目标文件 |
hello | 目标文件 |
hello.elf | 可重定位目标文件的elf文件 |
hello2.elf | 目标文件的elf文件 |
asm1 | 可重定位目标文件的反汇编文件 |
asm2 | 目标文件的反汇编文件 |
1.4 本章小结
本章介绍了hello的P2P 020的过程,概括了实验的硬件环境和软件环境,以及提供了实验过程用到的中间文件;
第2章 预处理
2.1 预处理的概念与作用
预处理是程序开发中的一个重要阶段,主要在程序编译之前进行。它涉及到一系列的操作,如宏替换、特殊符号处理、条件编译等,旨在为编译器提供一个清晰、准确的代码版本,以便于编译器进行后续的编译过程;
预处理的作用主要包括:
- 处理宏定义:对于#define指令,进行宏替换。这可以帮助减少代码的重复,使代码更加简洁;
- 处理特殊符号:预编译程序可以识别一些特殊符号,并在后续中进行替换。这有助于实现一些特定的编程需求,如条件编译等;
- 处理条件编译:如#if等。预处理可以根据不同的条件选择性地编译代码,这样可以更加灵活地控制程序的编译结果;
- 处理头文件:预处理可以处理头文件,包括对头文件的包含、条件编译等。这有助于确保程序在编译时能够正确地链接到所需的库和资源;
2.2在Ubuntu下预处理的命令
cpp hello.c > hello.i
2.3 Hello的预处理结果解析
hello.i从3048行开始,后面才是hello主程序的代码。前面3047行均为预处理插入的宏定义等。Cpp执行预处理指令时,从系统中寻找头文件stdio.h,将hello.c中的预处理语句替换为stdio.h中的语句。对于define预处理,则检查程序中每一次出现的位置,做宏定义替换;
2.4 本章小结
本章简要介绍预处理基本概念和实际流程,以hello.c的预处理和hello.i的内容展示为例进行阐述。
第3章 编译
3.1 编译的概念与作用
编译是将源代码转化为目标代码的过程,通常用于将高级语言编写的程序转换为机器语言,以便计算机能够执行。编译的作用包括:
- 代码转换:编译的主要作用是将源代码(用高级语言编写的程序)转换成目标代码(机器语言或低级语言),从而使计算机能够理解和执行;
- 错误检查:编译过程会检查源代码中的语法错误,包括拼写错误、语法结构错误等,并在编译时指出这些错误,以便开发者进行修正;
- 优化:编译器可以对源代码进行优化,以提高生成的目标代码的执行效率。优化可能包括减少不必要的计算、消除冗余代码、重新组织指令顺序等;
- 生成可执行文件:编译成功后,会生成一个可执行文件,该文件包含了计算机可以执行的指令。用户可以直接运行这个可执行文件,而不需要每次执行时都重新编译源代码;
- 提高安全性:编译过程可以帮助提高软件的安全性。由于源代码是经过编译转化为目标代码的,因此可以防止未经授权的用户直接查看或修改源代码,从而在一定程度上保护了软件的保密性和完整性;
3.2 在Ubuntu下编译的命令
gcc -m64 -no-pie -fno-PIC -S hello.c -o hello.s
3.3 Hello的编译结果解析
文件结构分为若干个部分;
部分 | 含义 |
.file | 文件内容 |
.text | 代码段 |
.global | 全局变量 |
.section .rodata | 只读变量 |
.type | 数据类型 |
.string | String类型 |
- 数据
字符串和数字常量这样的常量数据,以立即数或只读数据的形式存在在汇编代码中;
hello程序的局部变量i、argc、输入的参数等保存在栈中或寄存器中;
- 赋值
使用mov指令进行赋值,例如本程序把1赋值给%edi寄存器;
- 算术操作
加法操作,通过每次对(%rbp-4)进行加一操作,判断与7的大小关系,作为for循环的计数器;
- 关系操作
接上,将(%rbp-4)与立即数7作比较操作;
- 数组/指针/结构操作
在栈上获得两个连续位置的数据进行处理,其实就是获得数组argv的成员;
- 控制转移
判断是否相等,得到跳转条件。通过jump可以实现分支语句和循环语句;
- 函数操作
先设置参数,如%rdi、%rsi、%rdx等,然后调用函数;
3.4 本章小结
本章简要介绍“编译”的概念,以hello.c的汇编文件hello.s为例,分析各种数据结构和各类操作在汇编代码中如何实现;
第4章 汇编
4.1 汇编的概念与作用
概念:汇编大多是指汇编语言,汇编程序。把汇编语言翻译成机器语言的过程称为汇编;
作用:将汇编程序翻译成机器语言指令,将这些指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中,.o文件是一个二进制文件。它包含程序的指令编码;
4.2 在Ubuntu下汇编的命令
gcc -m64 -no-pie -fno-PIC -c hello.c -o hello.o
4.3 可重定位目标elf格式
readelf hello.o -a > hello.elf
- ELF header
- 开头16个字节
开头四个字节是elf magic魔数,分别对应ascii码中的del控制符、字符E、字符L、字符F。魔数用来确认文件类型,操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确拒绝加载。
第五个字节表示elf文件类型,0x1表示32位,0x2表示64位。
第六个字节表示字节序,0x1表示小端法,0x2表示大端法
第七个字节表示版本号,通常是1。
剩下的没有定义,用0填充;
- Type: 可重定位文件(还有可执行文件、共享文件)
- Start of section headers: section header在elf文件中的起始地址是1184
- Size of this header: elf header大小:64字节(section在elf文件中的起始位置是0x40)
- Size of section headers: 每个表项大小是64个字节
- Number of section heasers:一共包含14个表项
- Section header
offset表示每个section的起始位置,size表示section的大小
- Sections
- .text section 存放已经编译好的机器代码
- .data section存放已初始化的全局变量和静态变量的值
- .bss section(better save space)存放未初始化的全局变量和静态变量(被初始化为0的全局变量和静态变量也存放在bss中
- bss section并不占据实际的空间,仅仅是一个占位符,区分已初始化和未初始化的变量是为了节省空间。当程序运行时,会在内存中分配这些变量,并把初始值设为0
- .rodata section(read only)存放只读数据,例如printf语句中的字符串和switch语句中的跳转表
- symbol table
- Num:序号
- Value:函数相对于.text section起始位置的偏移量(16进制)
- Size:所占字节数
- Type:数据类型(object是数据对象,例如变量和数组在符号表的类型都为object;func函数)
- Bind:global全局符号,local局部符号
- Vis:在c语言中并未使用,可以忽略
- ndx(index):索引值(索引值可以通过查看header来获得;printf虽然也是函数,但只是被引用,ndx是undifine类型;common和bss的区别——common仅用来存放未初始化的全局变量,.bss用来存放未初始化的静态变量以及初始化为0的全局或者静态变量;)
- Name:名称
4.4 Hello.o的结果解析
- 跳转指令:在反汇编文件中,跳转目标使用的是PC相对的地址,即目标指令地址与当前指令下一条指令的地址之差的补码表示。而在.s文件中,通常使用段名称进行跳转;
- 函数调用:在反汇编中,call的目标地址是当前下一条指令的地址。而在.s文件中,函数调用之后直接跟着函数名称;
- 数制表示:反汇编中通常使用十六进制数,而.s文件中使用的是十进制数;
4.5 本章小结
本章介绍汇编基本概念,通过分析hello.o文件和elf文件,研究了elf文件的结构。在比较反汇编文件和汇编文件的细节中,发现二者的一些区别;
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存执行。链接可以执行于编译时、加载时或运行时。链接的作用主要包括:
- 组织程序:通过将各种代码和数据片段链接在一起,形成一个单一的文件,可以方便地组织和管理程序;
- 提高效率:链接过程可以消除重复的代码和数据,减少内存占用,提高程序的执行效率;
- 实现模块化开发:通过链接,可以将程序分解为多个模块,每个模块可以独立开发、编译和测试,从而实现模块化开发,提高开发效率;
- 处理依赖关系:链接过程可以处理不同模块之间的依赖关系,确保程序在编译和运行时能够正确地链接到所需的模块和库;
- 实现动态加载:通过动态链接,可以在程序运行时加载不同的模块或库,从而实现动态加载和卸载功能;
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
命令:readelf hello -a > hello2.elf
- ELF header
- 开头16个字节
开头四个字节是elf magic魔数,分别对应ascii码中的del控制符、字符E、字符L、字符F。魔数用来确认文件类型,操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确拒绝加载。
第五个字节表示elf文件类型,0x1表示32位,0x2表示64位。
第六个字节表示字节序,0x1表示小端法,0x2表示大端法
第七个字节表示版本号,通常是1。
剩下的没有定义,用0填充。
- Type: 可重定位文件(还有可执行文件、共享文件)
- Start of section headers: section header在elf文件中的起始地址是1184
- Size of this header: elf header大小:64字节(section在elf文件中的起始位置是0x40)
- Size of section headers: 每个表项大小是64个字节
- Number of section heasers:一共包含14个表项
- Section header
offset表示每个section的起始位置,size表示section的大小
- Sections
.text section 存放已经编译好的机器代码
.data section存放已初始化的全局变量和静态变量的值
.bss section(better save space)存放未初始化的全局变量和静态变量(被初始化为0的全局变量和静态变量也存放在bss中
bss section并不占据实际的空间,仅仅是一个占位符,区分已初始化和未初始化的变量是为了节省空间。当程序运行时,会在内存中分配这些变量,并把初始值设为0
.rodata section(read only)存放只读数据,例如printf语句中的字符串和switch语句中的跳转表
- symbol table
- Num:序号
- Value:函数相对于.text section起始位置的偏移量(16进制)
- Size:所占字节数
- Type:数据类型(object是数据对象,例如变量和数组在符号表的类型都为object;func函数)
- Bind:global全局符号,local局部符号
- Vis:在c语言中并未使用,可以忽略
- ndx(index):索引值(索引值可以通过查看header来获得;printf虽然也是函数,但只是被引用,ndx是undifine类型;common和bss的区别——common仅用来存放未初始化的全局变量,.bss用来存放未初始化的静态变量以及初始化为0的全局或者静态变量;)
- Name:名称(dynamic section动态链接表;program headers)
5.4 hello的虚拟地址空间
程序加载到地址0x400000-0x401000处;
5.5 链接的重定位过程分析
在hello.o的反汇编程序中,函数中的语句前面的地址都是从函数开始从依次递增的,而不是虚拟地址;而经过链接后,在右侧的返回变种每一条指令都被分配了虚拟地址;
- 函数调用
如图所示,在左侧helllo.o的反汇编程序中,图中选中的函数调用指令由于还没有分配虚拟地址,所以只能用偏移量跳转,而右侧链接后已经分配好了虚拟地址,可以直接用call虚拟地址进行跳转;
- 跳转指令
同理函数调用,链接后可以采用虚拟地址跳转;
5.6 hello的执行流程
<_init>:401000
<.plt>:401020
<puts@plt> :401090
<printf@plt>:4010a0
<getchar@plt>:4010b0
<atoi@plt>:4010c0
<exit@plt>:4010d0
<sleep@plt>:4010e0
<_start>:4010f0
<_dl_relocate_static_pie>:401120
<main> :401125
<_libc_scu_init>:4011c0
<_libc_csu_fini>:401230
<_fini>: 401238
5.7 Hello的动态链接分析
动态链接的过程可以分为以下步骤:
- 动态链接器自举:动态链接器首先进行自举,获取动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口;
- 合并符号表:将可执行文件和链接器本身的符号表合并到一个全局符号表中;
- 寻找共享对象:链接器在“.dynamic”段中找到可执行文件依赖的共享对象,将这些共享对象的名称加入到一个装载集合中;
- 重定位:对于可执行文件中的相对地址,进行重定位,以使它们指向正确的位置;
- 填充全局偏移表:对于模块外部的函数和数据的引用,使用全局偏移表(GOT)进行间接引用。动态链接器在装载模块的时候会查找每个函数所在地址,并填充GOT中的各个表项,以保证每个指针均指向正确的地址;
5.8 本章小结
本章介绍了链接的基本概念,通过对目标文件、可重定位目标文件、反汇编文件的内容对比,发现其异同,并推断出链接过程的具体实现;
第6章 hello进程管理
6.1 进程的概念与作用
进程是操作系统进行资源分配的基本单位,它是程序运行时的实例,是程序在计算机上运行的动态过程。进程具有以下特征:
- 动态性:进程是程序在内存中加载和运行的实例,它随着程序的执行而动态地创建,随着程序的结束而销毁;
- 独立性:进程是一个独立的实体,拥有独立的地址空间和系统资源,通过系统调用与内核进行交互;
- 制约性:由于进程间的相互制约,进程的执行需要遵循同步的原则,以确保资源竞争和数据一致性的问题得到解决;
- 并发性:进程在操作系统中是并发执行的,通过进程间的通信和同步机制,可以实现高效的资源利用和任务管理;
- 进程的作用主要体现在多任务处理和资源管理上:通过创建多个进程,操作系统可以同时运行多个程序,实现多任务处理。每个进程都有独立的内存空间和系统资源,互不干扰,使得多个任务可以同时进行。此外,进程的出现使得操作系统的资源管理更加合理和高效,通过进程调度、内存管理等机制,操作系统可以合理地分配和利用系统资源,提高系统的性能和稳定性;
6.2 简述壳Shell-bash的作用与处理流程
壳Shell-bash是一个C语言程序,其主要作用如下:
- 代表用户执行进程;
- 交互性地解释和执行用户输入的命令;
- 调用系统级的函数或功能来执行程序、建立文件、进行并行操作等;
- 协调程序间的运行冲突,保证程序能够以并行形式高效执行;
Shell-bash的处理流程如下:
- 终端进程读取用户由键盘输入的命令行;
- 分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量;
- 检查第一个命令行参数是否是一个内置的shell命令。如果是,直接执行;否则,进行下一步;
- 调用fork()创建新进程/子进程;
- 在子进程中,用步骤2获取的参数,调用execve()执行指定程序;
6.3 Hello的fork进程创建过程
在Unix和类Unix系统中,fork()系统调用用于创建一个新的进程,这个新进程是当前进程的副本。新进程(子进程)会继承父进程的所有资源,包括代码、数据、打开的文件描述符、环境变量等;
6.4 Hello的execve过程
execve()系统调用用于在当前进程中执行一个新的程序。它实际上是替换了当前进程的映像,而不是像fork()那样创建一个新的进程;
6.5 Hello的进程执行
在操作系统中,进程的执行是一个复杂的过程,涉及到多个系统调用和内核级别的操作;
- 编译:然后,程序被编译成机器代码,这是计算机可以直接执行的指令;
- 加载到内存:接下来,操作系统使用execve()或fork()系统调用将编译后的程序加载到内存中。如果使用fork(),会创建一个新的进程;如果使用execve(),则会替换当前进程的映像;
- 进程调度:操作系统根据进程的状态(如运行中、等待I/O操作、休眠等)和调度策略,决定哪个进程应该获得CPU时间片;
- 执行:获得CPU时间片的进程开始执行。这通常意味着从程序的main函数开始执行;
- 输出:程序执行到输出语句(如printf()),操作系统将字符发送到相应的设备(如终端或显示器);
- 退出:程序执行完毕后,操作系统会回收进程使用的资源(如内存、文件描述符等),并将控制权返回给操作系统;
6.6 hello的异常与信号处理
在hello程序的执行过程中,可能会遇到各种类型的异常和信号。下面将详细讨论这些异常和信号;
- 异常类型
运行时异常:如除以零、空指针引用等,这类异常会导致程序崩溃。
资源异常:如文件未找到、内存不足等,这类异常通常需要程序进行适当的错误处理。
输入异常:用户输入了不符合程序要求的数据。
- 产生的信号
SIGINT:当用户按下Ctrl+C时发送,通常用于中断程序;
SIGTSTP:当用户按下Ctrl+Z时发送,用于暂停程序;
SIGTERM:请求程序终止的正常信号;
- 信号处理与命令
不断乱按
Ctrl-c
Ctrl-z
Jobs
Ps
Pstree
Fg
- 异常与信号的处理示例
当用户不停乱按键盘,包括回车、Ctrl-Z、Ctrl-C等时,Hello程序可能会接收到上述信号。例如,按下Ctrl-Z会使程序暂停并发送到后台,此时可以使用ps查看进程状态,使用fg恢复程序到前台,或使用kill终止程序;
总之,了解和处理异常与信号是编写健壮和可靠程序的关键部分。通过合理的代码设计和使用操作系统提供的工具,可以有效地管理程序的执行和应对各种异常情况;
6.7本章小结
简要介绍进程的基本概念,介绍fork函数和execve函数。介绍信号处理和异常处理的基本知识,在程序上对多种键盘输入进行测试。
第7章 hello的存储管理
7.1 hello的存储器地址空间
- 逻辑地址:这是程序代码经过编译后出现在汇编程序中的地址。逻辑地址是用来指定一个操作数或者是一条指令的地址,通常是指相对于某个基准点的偏移量。在有地址变换功能的计算机中,访问指令给出的地址(操作数)就是逻辑地址,也称为相对地址。逻辑地址需要经过寻址方式的计算或变换才能得到内存储器中的物理地址;
- 线性地址:也称为虚拟地址,它是一个无符号整数,通常用来表示高达4GB的地址空间,也就是高达4294967296个内存单元。线性地址通常用十六进制数字表示,值域从0x00000000到0xfffffff。线性地址经过段机制的转换后成为物理地址;
- 物理地址:这是CPU地址总线传来的地址,由硬件电路控制。物理地址用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相应;
- 在这个存储器地址空间中,逻辑地址和线性地址都只是中间层的抽象,而物理地址是最终被硬件所识别的地址。这些地址空间的概念是操作系统进行内存管理和进程调度的基础;
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel的逻辑地址到线性地址的变换,也称为段式管理,是一种内存管理技术。在这种技术中,程序被划分为若干个逻辑段(segments),每个段都是一个逻辑实体。程序员需要知道并使用这些段;
逻辑地址到线性地址的变换过程如下:
- 给定一个完整的逻辑地址[段选择符:段内偏移地址],首先看段选择描述符中的T1字段是0还是1,以确定当前要转换的是GDT(全局描述符表)中的段,还是LDT(局部描述符表)中的段。根据指定的相应的寄存器,可以得到其地址和大小,从而得到一个数组;
- 拿出段选择符中的前13位,可以在这个数组中查找到对应的段描述符,这样就有了Base,即基地址;
- 把基地址Base+Offset(偏移量)结合起来,就得到了下一个阶段的地址;
这个过程使得操作系统能够更灵活地管理内存,提供了一种隔离和保护机制,每个段都有自己的访问权限和属性。同时,也使得程序可以访问高达4GB的地址空间,提高了内存的使用效率;
7.3 Hello的线性地址到物理地址的变换-页式管理
Hello程序的线性地址到物理地址的变换是通过页式管理来实现的。页式管理是一种内存管理技术,它将物理内存划分为固定大小的页框(page frame),并将逻辑内存也划分为相同大小的页面(page)。每个页面可以映射到任何一个空闲的页框中;
线性地址到物理地址的变换过程如下:
- CR3寄存器:首先,处理器从CR3控制寄存器中获取页目录的基地址。CR3寄存器中存储的是页目录的起始物理地址;
- 页目录:线性地址的高10位被用作索引来访问页目录。页目录项中存储了对应页表的基地址;
- 页表:接下来,处理器使用线性地址的中间10位作为索引来访问页表。页表项中存储了目标页面的物理地址或者页面属性等信息;
- 物理地址:最后,处理器将线性地址的低12位作为页内偏移,与页表项中的物理地址相加,得到最终的物理地址;
通过页式管理,操作系统可以更加灵活地进行内存分配和管理。程序使用的逻辑内存可以被映射到不连续的物理内存上,提高了内存的利用率。同时,页式管理还提供了内存保护机制,每个页面都有自己的访问权限和属性,可以防止程序越界访问或者非法修改其他程序的内存空间;
7.4 TLB与四级页表支持下的VA到PA的变换
TLB(Translation Lookaside Buffer)是用于缓存最近访问的页表条目的硬件组件,以减少访问页表的时间开销。在四级页表支持下,从虚拟地址(VA)到物理地址(PA)的变换涉及到多个步骤,其中TLB扮演着重要的角色;
以下是VA到PA的变换过程:
- 页全局目录:首先,处理器检查页全局目录(PGD)中的条目。如果所需的虚拟地址在TLB中不存在,处理器将查询PGD以找到相应的页目录(PD)的基地址;
- 页上级目录:处理器使用虚拟地址的高级别位索引到页上级目录(PUD)。如果所需的虚拟地址在TLB中不存在,处理器将查询PUD以找到相应的页中间目录(PMD)的基地址;
- 页中间目录:处理器使用虚拟地址的中级别位索引到页中间目录(PMD)。如果所需的虚拟地址在TLB中不存在,处理器将查询PMD以找到相应的页表(PT)的基地址;
- 页表:处理器使用虚拟地址的低级别位索引到页表(PT),找到相应的页框(page frame)的物理地址;
- TLB检查:在每个步骤中,处理器都会检查TLB以查看所需的虚拟地址是否已经在TLB中缓存。如果存在,则直接使用TLB中的物理地址进行访问;
- 物理地址:经过上述步骤后,处理器获得了目标页框的物理地址,与虚拟地址的低级别位相加,得到最终的物理地址(PA);
通过四级页表和TLB的支持,操作系统可以更加精细地控制和管理内存访问权限和属性,从而提高内存管理的灵活性和效率。同时,TLB的存在也减少了访问页表的时间开销,提高了系统的性能。
7.6 hello进程fork时的内存映射
当一个进程使用fork()系统调用创建一个新的进程时,新进程(子进程)会继承父进程的内存映射。这意味着子进程将获得父进程的代码段、数据段、堆和栈等内存区域的副本;
以下是fork()系统调用后父子进程的内存映射情况:
- 代码段:子进程复制父进程的代码段,这是必须的,因为子进程需要执行相同的程序代码;
- 数据段:子进程复制父进程的数据段,包括全局变量和静态变量等;
- 堆:子进程通常会从堆的起始位置开始复制父进程的堆区域。但要注意,子进程可能不会复制整个堆,而是使用适当的内存管理机制来分配和释放内存;
- 栈:子进程也会复制父进程的栈区域。这是为了保存函数调用的返回地址和局部变量等;
需要注意的是,虽然子进程继承了父进程的内存映射,但它们是独立的进程,有自己的虚拟地址空间。对子进程的内存进行修改不会影响父进程的内存,反之亦然。此外,父子进程可以使用exec()系列函数来替换各自的内存映像,执行不同的程序。
总之,fork()系统调用创建了一个与父进程几乎完全相同的子进程,包括内存映射。这使得操作系统能够快速地创建新进程,并为其提供必要的资源来执行任务;
7.7 hello进程execve时的内存映射
当一个进程使用execve()系统调用时,它会替换当前进程的内存映像为一个新的程序。execve()系统调用将加载新的程序到进程的地址空间,并释放旧程序的内存映射。
以下是execve()系统调用后进程的内存映射情况:
- 代码段:被替换为新程序的代码段。这是新程序的执行代码。
- 数据段:被替换为新程序的数据段。这包括全局变量和静态变量等。
- 堆:堆区域也会被替换为新程序所需的堆区域。新程序可以使用malloc()、free()等函数来分配和释放内存。
- 栈:栈区域也会被替换为新程序所需的栈区域。这包括保存函数调用的返回地址和局部变量等。
需要注意的是,execve()系统调用后,当前进程的内存映像被完全替换为新程序的内存映像。这意味着原程序的代码和数据不再存在于进程的地址空间中,无法再访问或修改。
总之,execve()系统调用用于在当前进程中加载并执行一个新的程序,它会替换进程的内存映像,并释放旧程序的内存映射。这使得进程能够执行不同的程序,并为其提供独立的内存空间。
7.8 缺页故障与缺页中断处理
缺页故障是指当一个进程在运行过程中需要访问某个页面,但在内存中找不到该页面,导致访问失败的现象。缺页故障是虚拟内存管理中常见的问题之一,它会导致进程运行异常或崩溃。
缺页中断是指当发生缺页故障时,处理器会触发一个中断,通知操作系统进行处理。操作系统会根据缺页中断的类型和上下文信息,进行相应的处理。
缺页中断处理通常包括以下几个步骤:
- 判断是否允许中断:在发生缺页中断时,首先需要判断是否允许中断。如果允许中断,则进入下一步处理;否则,将该缺页中断推迟处理。
- 保护处理器上下文:为了确保中断处理过程中的数据完整性,需要保护当前的处理器上下文,包括程序计数器、通用寄存器、状态寄存器等信息。
- 查找缺页的中断源:操作系统需要查找缺页中断的具体原因,确定是哪个进程、哪个页面发生了缺页故障。这可以通过检查中断向量表、进程控制块等信息来实现。
- 进行缺页故障处理:根据不同的缺页情况,操作系统可以选择不同的处理方式。常见的处理方式包括将所需页面从磁盘加载到内存中、将不常用的页面替换出去等。
- 恢复处理器上下文:完成缺页故障处理后,需要恢复处理器的上下文信息,使进程能够继续执行。
- 结束中断处理:最后,操作系统会结束缺页中断的处理,并返回到被中断的进程继续执行。
通过缺页中断处理机制,操作系统能够有效地管理虚拟内存,确保进程的正常运行。
7.9动态存储分配管理
动态内存管理是在程序运行时分配和释放内存的过程。在C语言中,malloc、calloc、realloc和free等函数用于实现动态内存管理。以下是一些基本的方法和策略:
- malloc()、calloc()和realloc()函数:
malloc(size):此函数从堆中分配指定字节数的未初始化的内存。如果分配成功,它返回一个指向被分配内存的指针;否则,返回NULL。
calloc(n, size):此函数分配指定数量的对象,每个对象具有指定的字节大小。分配的内存被初始化为零。如果成功,它返回一个指向被分配内存的指针;否则,返回NULL。
realloc(ptr, size):此函数重新分配之前通过malloc或calloc分配的内存块的大小。它可以增大或减小内存块的大小。如果新的大小大于旧的大小,新内存块可能包含旧的内存块的数据以及额外的未初始化的内存。如果新的大小小于旧的大小,那么旧内存块的一部分将被丢弃。
- free()函数:此函数释放之前通过malloc、calloc或realloc分配的内存。这是非常重要的,因为未释放的内存可能会导致内存泄漏,这是一种常见的编程错误。
- 内存碎片:频繁的内存分配和释放可能导致大量的内存碎片。为了避免这种情况,可以使用free函数一次性释放所有相关联的内存块,或者使用brk和sbrk函数来移动堆的顶部。
- 垃圾回收:这是一种自动检测并释放未使用的内存的技术。然而,C语言没有内置的垃圾回收机制,所以需要手动管理内存。
- 静态和动态内存管理:静态内存管理主要涉及在编译时确定的数据的大小和数量,而动态内存管理则允许在运行时更改这些值。
- 内存对齐:为了优化性能,数据通常在特定的地址处对齐。这可以通过使用特定的编译器指令或结构布局规定来实现。
- 预分配缓冲区:对于需要大量动态内存的应用程序,可以使用预分配缓冲区来减少分配和释放操作的开销。当应用程序接近其预分配的缓冲区上限时,可以请求更大的缓冲区,而不是每次需要更多空间时都分配新的缓冲区。
动态内存管理需要谨慎处理,以避免常见的错误,如双重释放、使用已释放的内存或内存泄漏等。理解并正确使用这些工具和方法是写出健壮、高效的C程序的关键。
7.10本章小结
本章简要介绍存储相关的知识。介绍不同地址概念以及他们之间的转换,讲述fork和execve函数的存储映射,最后介绍缺页处理和动态内存分配。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
Linux的IO设备管理方法主要包括以下几种:
- 设备驱动程序开发:设备驱动程序是直接与硬件交互的软件,它必须符合特定的接口和规范,以便操作系统可以与之通信并正确地控制设备。
- 使用设备文件:Linux将所有设备视为文件,通过在/dev目录下创建的设备文件来访问设备。这些设备文件允许应用程序像访问普通文件一样访问设备。
- 使用udev管理设备:udev是Linux内核的一部分,用于管理设备节点。它可以根据设备的动态变化重新配置设备文件,并提供了用于查询和管理设备的工具。
- 使用系统管理命令和工具:Linux提供了一系列的命令和工具,用于查看设备信息、监视设备状态、管理设备驱动程序等。例如,lsusb可以列出USB设备,lspci可以列出PCI设备。
- 使用面向对象的设备管理框架:Linux还提供了一些面向对象的设备管理框架,如DeviceKit和Polkit。这些框架提供了一种更高级的方式来管理设备,包括自动发现设备、提供用户界面等。
- 使用高级I/O工具:Linux还提供了一些高级的I/O工具,如ioengine、libaio等。这些工具提供了更高级的I/O操作接口,可以更好地控制和管理I/O操作。
8.2 简述Unix IO接口及其函数
Unix的IO接口提供了对输入/输出设备的统一访问方式,使得应用程序可以以一致的方式与设备进行交互。以下是Unix IO接口的主要函数:
- open():打开或创建一个文件或设备,并返回一个文件描述符。
- read():从已打开的文件或设备中读取数据。
- write():将数据写入已打开的文件或设备。
- close():关闭已打开的文件或设备,并释放与之相关的资源。
- lseek():更改文件位置指针,以便可以从特定位置读取或写入数据。
- fcntl():对已打开的文件或设备执行各种操作,如获取/设置文件状态标志、获取/设置文件锁定等。
- ioctl():对已打开的文件或设备执行特定于设备的操作。
这些函数是Unix IO接口的核心,它们提供了一种标准化的方式来访问文件和设备,使得应用程序可以轻松地与各种不同的设备进行交互。
8.3 printf的实现分析
从计算机系统的视角,printf函数的实现涉及到多个层次和组件,包括操作系统、硬件、编译器等。以下是一个简化版的分析:
- 输入参数解析:
printf首先解析格式字符串,确定要输出的数据类型和格式。例如,如果格式字符串是"%d %s",函数就知道需要输出一个整数和一个字符串。
- 参数转换:
对于每种数据类型,printf会根据格式字符串将其转换为可打印的格式。例如,整数会被转换为对应的文本表示,字符串直接被复制到输出缓冲区。
- 缓冲区管理:
为了提高性能,printf通常使用缓冲区来存储要输出的数据。这样,数据可以分批输出到终端或文件,而不是逐个字符地输出。
- 系统调用:
当缓冲区满时或显式调用时(如使用fflush),printf会进行系统调用,将缓冲区的内容输出到相应的设备(如终端或文件)。这通常涉及到低级的系统调用,如write()。
- 格式化输出:
对于不同的数据类型,printf需要使用不同的格式化规则。例如,对于整数,可能需要使用十进制、十六进制或八进制表示。对于浮点数,可能需要使用小数点表示或科学记数法。这些都需要通过格式化规则来处理。
- 内存分配与释放:
为了存储转换后的字符串和可能的格式化数据,printf可能需要动态分配内存。当输出完成时,这些内存需要被释放。
- 格式化说明符:
printf支持各种格式化说明符,如%d表示整数、%s表示字符串等。这些说明符告诉函数如何处理和转换输入参数。
- 字符编码与转换:
printf可能还需要处理字符编码的转换,特别是当输出到非本地环境的终端或文件时。这可能涉及到使用特定的字符集或编码转换库。
- 错误处理与异常:
如果在转换或输出过程中出现错误(如内存不足、无效的格式说明符等),printf需要能够正确地处理这些异常情况。这可能涉及到返回错误代码、设置特定的错误标志等。
- 平台与编译器依赖性:
printf的实现可能因编译器和操作系统的不同而有所差异。这意味着,为了在不同平台上正确工作,可能需要编写特定于平台的代码或使用特定的库函数。
- 优化与性能考虑:
为了提高性能,printf的实现可能会使用各种优化技术,如缓存常见的格式化结果、减少系统调用的次数等。这些优化可以帮助提高程序的执行速度并减少不必要的系统开销。
- 标准与合规性:
printf需要符合C语言的标准,确保在不同的系统和编译器上都能提供一致的行为。这可能涉及到遵循特定的规范和标准要求。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
从计算机系统的角度,getchar的实现涉及到多个层次和组件,包括操作系统、硬件、编译器等:
- 硬件层:
当用户在键盘上输入一个字符时,键盘的输入线会将这个字符发送到计算机的输入缓冲区。
计算机的CPU通过中断机制感知到这个输入,并通知操作系统有新的输入数据。
- 操作系统层:
操作系统负责管理输入缓冲区,并在适当的时候将数据传递给相应的进程。
当getchar被调用时,操作系统查找输入缓冲区,找到下一个字符,并将其传递给进程。
- 库函数层:
getchar函数是C标准库中的一个函数,它封装了与操作系统的交互。当getchar被调用时,它实际上是在等待操作系统提供下一个字符。
- 调用栈与上下文切换:
当用户在键盘上输入字符时,CPU可能会进行上下文切换,从执行其他任务切换回执行getchar函数。这涉及到保存和恢复CPU的状态,确保getchar可以正确地获取到输入的字符。
- 缓冲与非阻塞IO:
通常,getchar不会立即返回输入的字符,因为输入可能会先被缓冲起来。如果使用的是非阻塞IO,getchar会立即返回,而不会等待输入。这需要操作系统的支持,并涉及到更复杂的系统调用和配置。
- 错误处理和异常:
如果输入流因为某种原因被关闭或出现错误,getchar需要能够正确地处理这些情况。这可能涉及到检查文件描述符的状态或使用异常处理机制。
- 多线程与并发:
在多线程环境中,多个线程可能同时调用getchar。这需要使用同步机制来确保每个线程都能正确地获取输入字符,而不会发生竞态条件。
- 系统调用与API:
getchar最终需要调用操作系统的API来从输入流中读取数据。这可能涉及到低级的系统调用,如read()。
- 优化与性能考虑:
为了提高性能,可能会使用各种优化技术,如直接从输入设备读取数据、减少上下文切换的次数等。
- 移植性与平台依赖性:
getchar的实现可能因操作系统和硬件平台的不同而有所差异。因此,实现可能需要考虑平台的特定细节和API。
- 标准与合规性:
getchar需要符合C语言的标准,确保在不同的系统和编译器上都能提供一致的行为。
- 安全考虑:
在处理用户输入时,需要考虑安全性问题,如防止缓冲区溢出、确保输入的有效性等。这可能涉及到使用安全的API和数据验证机制。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了系统级io,包括io设备的关理、unix接口,以及printf、和getchar的实现分析;
结论
通过本次学习,我认识到计算机系统是一个复杂的交互式系统,由硬件和软件两个部分组成。硬件是物理组件的总称,包括中央处理器、存储器、输入输出设备等。而软件则是运行在计算机上的程序和数据的集合。通过硬件和软件的协同工作,计算机才能实现各种复杂的功能;
在学习过程中,我了解到计算机的基本工作原理。当用户在键盘上输入一个字符时,键盘接口将这个字符发送到中央处理器。中央处理器根据存储器中的程序指令对这个字符进行处理,并将结果存储回存储器或输出到显示器。这个过程涉及到数据在不同部件之间的传输和转换,涉及到数据总线、地址总线等概念;
此外,我也对操作系统有了更深入的了解。操作系统是计算机系统的核心软件,负责管理计算机的硬件和软件资源,提供用户界面和应用程序接口。通过学习操作系统的基本原理,我明白了进程管理、内存管理、文件系统和设备驱动程序等重要概念;
附件
文件名称 | 文件含义 |
hello.c | 源文件 |
hello.i | 预处理文件 |
hello.s | 汇编文件 |
hello.o | 可重定位目标文件 |
hello | 目标文件 |
hello.elf | 可重定位目标文件的elf文件 |
hello2.elf | 目标文件的elf文件 |
asm1 | 可重定位目标文件的反汇编文件 |
asm2 | 目标文件的反汇编文件 |
参考文献
[1] Computer Systems: A Programmer's Perspective (3rd Edition)深入理解计算机系统(CSAPP)(第三版)