图片上传一直不成功,以后有机会我再上传吧
计算机系统大作业
题 目 程序人生-Hello’s P2P
专 业 数据科学与大数据技术
学 号 2021110774
班 级 2103501
学 生
指 导 教 师 史先俊
计算机科学与技术学院
2023年4月
本文详细介绍了hello.c程序的一生,从源代码到经过预处理、编译、汇编、链接最终生成可执行目标文件hello。在Linux系统下,hello文件依次经过cpp预处理、ccl编译、as汇编、ld链接最终成为可执行目标程序hello。通过在shell中键入启动命令后,shell为其fork,产生子进程,内核为新进程创建数据结构,hello便从可执行程序(Program)变成为进程(Process)。同时,Hello的P2P和020描述了hello.c文件从可执行程序变为运行时进程的过程,以及内存中没有相关内容到程序运行结束后回收和删除hello相关数据的过程。
关键词:计算机系统,预处理,编译,汇编,链接,异常,进程,存储,I/O,程序,P2P
目 录
2.2在Ubuntu下预处理的命令............................................................................. - 6 -
5.3 可执行目标文件hello的格式........................................................................ - 8 -
6.2 简述壳Shell-bash的作用与处理流程........................................................ - 10 -
6.3 Hello的fork进程创建过程......................................................................... - 10 -
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 -
8.2 简述Unix IO接口及其函数.......................................................................... - 13 -
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:CPU: AMD Ryzen 7 5800H with Radeon Graphics 3.20 GHz
RAM: 16GB
软件环境:Windows10 64位
VMware11
Ubuntu 64位
开发与调试工具:Visual Studio 2022;
gedit,gcc,notepad++,readelf, objdump, hexedit, edb
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.c | hello源代码 |
hello.i | 预处理之后的文本文件 |
hello.s | hello的汇编代码 |
hellooalt.s | hello.o的反汇编代码 |
helloalt.s | hello的反汇编代码 |
hello.o | hello的可重定位文件 |
hello | hello的可执行文件 |
hello.elf | hello的elf文件 |
helloo.elf | hello.o的elf文件 |
1.4 本章小结
本章进行了hello的简介,简要介绍了P2P与020,同时列出了完成本次论文所需要的软件、硬件环境以及开发调试工具,也列举了hello.c生成的中间结果的名字与功能。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
在预编译的过程中,主要处理源代码中的预处理指令,引入头文件,去除注释,处理所有的条件编译指令(#ifdef,#ifndef,#else,#elif,#endif),宏的替换,添加行号,保留所有的编译器指令。例如#include 命令使预处理器读取stdio.h,unistd.h,stdlib.h 等系统头文件的内容,并把这些内容直接插入到程序文本中以及用实际值替换用#define定义的字符串。
2.1.2预处理的作用
1.预处理会将#include后的头文件内容直接插入程序文本,以及用实际值替换#define后面的宏定义,方便程序后续处理。
2.预处理允许通过文件包含命令将另一个源文件的内容全部包含在此文件中,将这些内容一同编译,生产目标文件。
对程序进行预处理后,编译器在对程序进行编译的时候更加方便。
2.2在Ubuntu下预处理的命令
Ubuntu下通过gcc命令对hello.c文件进行预处理,生成hello.i文件
2.3 Hello的预处理结果解析
Linux下打开hello.i文件,发现hello.i已经扩展到3060行,hello.c中的main代码从3047行到3060行,前面3000+行为预处理的结果,是头文件stdio.h unistd.h stdlib.h 的依次展开,预处理会在系统中找到#include后的文件,在程序文本中展开,替换掉原来的#include<>,同时也会删除程序中的多余空白符与注释,方便进一步处理
2.4 本章小结
本章包含了预处理的概念和作用,并以Ubuntu系统下hello.c文件的预处理得到的hello.i举例,分析预处理后hello.i程序文本的变化。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
编译器通过词法与语法分析,将指令翻译成汇编代码,将hello.i文件编译成汇编语言文件hello.s,hello.s中为机器语言的指令
3.1.2编译的作用
将文本文件翻译成汇编语言文件,为后续处理将其转化为二进制文件做准备
3.2 在Ubuntu下编译的命令
gcc -m64 -Og -S -no-pie -fno-PIC hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1 开头伪指令分析
.file "hello.c" (源文件)
.text (代码段)
.section .rodata.str1.8,"aMS",@progbits,1 (.rodata节)
.align 8 (对齐方式)
.LC0:
.string "\347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \347\247\222\346\225\260\357\274\201" (字符串)
.section .rodata.str1.1,"aMS",@progbits,1 (.rodata节)
.LC1:
.string "Hello %s %s\n" (字符串)
.text (代码段)
.globl main (全局变量名)
.type main, @function (指定对象类型或函数)
3.3.2 数据
C语言的数据有:变量、常量、表达式、类型、宏
(1)常量 大多数以立即数的形式出现在汇编代码中
例1:中的“1”,在hello.s中形式为
例2:有些情况下编译器不会使用hello.i原来的常量,如循环语句 i小于5的比较在hello.s中形式为i与4比较大于则跳出循环(即i<=4)
(2)变量 类型有全局变量、局部变量、静态变量
已初始化的全局变量和静态变量存放在.data节,而未初始化的全局变量存放于.bss节,局部变量则在栈中。
在hello.c文件中并无全局变量和静态变量,所以在我们hello.s中开头的伪指令里并没有.data和.bss,而是有只读数据节(.rodata),里面会存放输出的格式串
例1:在hello.s中的代码为而.LC0中为
即我们要输出的字符串的编码形式。
例2:在hello.s中代码为而.LC1中为
局部变量:main函数内定义了局部变量i,存入寄存器%ebp中,在for循环内,我们可以在hello.s中找到与4大小比较与加1操作和。
(3) 表达式
hello.c中一共有3个表达式
例1:表达式在hello.s中为
例2:表达式在hello.s中为
例3:表达式在hello.s中为
3.3.3 赋值
汇编语言中的赋值操作通过mov指令实现,mov指令有多个形式movb、movw、movl、movq以及movabsq,传送的是不同大小的数据
例1:int类型赋值操作在hello.s中为
3.3.4 算术操作
算术操作有+ - * / % ++ -- 取正/负+- 复合“+=”等,在汇编语言指令中为在hello.s中有使用
例1: i加1操作为
3.3.5 类型转换
类型转换分为显示与隐式类型转换
例1:代码int i = 1.1,1.1赋给整型i,小数部分会向0舍入,即i的值为1,这是一个隐式类型转换。
例2:代码 float i = (int)2.5*2.5,结果为5,因为(int)2.5会将2.5转换成int类型的数字,即为2,这是一个显式类型转换。
3.3.6 关系操作
关系操作指令表为
其中关系操作指令包含CMP和TEST。它们只设置条件码,并不改变任何其他寄存器。cmp指令根据两个操作数之差来设置条件码。TEST指令的行为与AND指令一样,区别在于TEST只设置条件码而不改变目的寄存器的值。在hello.s中
在hello.s中使用了cmp指令来判断argc!=4和i<8,分别为和
3.3.7 数组操作
C语言中的数组是一种将标量数据聚集成更大数据类型的方式。对于数据类型T和整型常数n,声明数组a[n],起始位置表示为X,表明它在内存中分配一个K·n字节的连续区域(K为数据类型T的大小)同时,它引入了标识符A,可以用A来作为指向数组开头的指针,这个指针的值就是X,可以用0~N-1的整数索引来访间该数组元素。数组元素会被存放在地址为X+L·i上的地方。
在hello.c中的main函数有个参数为char *argv[],这是一个指针数组,每个数组元素是一个指向参数字符串的指针。在hello.c中,有对数组元素的使用:
其汇编代码为:
其中%rsi和%rdx分别向printf传递参数argv[1]和argv[2],%rdi则向sleep传送参数argv[3]。以下是argv函数的存放方式举例。
3.3.8 控制转移
控制转移常用jump指令,jump指令根据条件不同也有不同的跳转方式,以下是跳转规则:
在hello.c中,有if语句的条件控制在汇编语言中如下
argc 是 main 函数的第一个参数,因此存储在 %edi 中。程序会使用 cmpl 指令来比较 argc 和数字 4 的大小,并设置条件码。接下来,程序会使用 jne 指令根据条件码来比较 argc 和 4 是否相等,并根据比较结果来选择是否跳转到 .L6 标签处执行一段特定的代码。如果 argc 不等于 4,程序将跳转到 .L6 标签处执行特定的代码。如果 argc 等于 4,则程序将继续执行接下来的指令。
在hello.c中,同样for语句的循环控制
在汇编语言中为
首先,程序会将变量 i 初始化为 0。然后,程序将跳转到循环判断表达式处,检测是否满足循环条件。如果满足循环条件,则程序将跳转到循环体中执行代码,然后执行循环表达式,更新变量 i 的值。程序将重复执行这个过程,直到循环条件不满足为止。
3.3.9 函数操作
两个函数A、B,A调用B,函数调用包括以下机制:
传递控制:在进入函数B时,程序计数器必须被设置为B的代码的起始地址,然后在返回时,要把程序计数器设置为调用B函数后面那条指令的地址。这个过程使用call和ret指令实现。
传递数据:函数A必须能够向B提供一个或多个参数, B必须能够向A返回一个值。函数A可以通过寄存器向B传递6个参数,分别存放在%rdi、%rsi、%rdx、%rcx、%r8、%r9中,超过6个的部分就要通过栈来传递。
分配和释放内存:在开始时, B可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
在hello.s中的函数调用有如下示例(图片为汇编语言):
1.printf("用法: Hello 学号 姓名 秒数!\n"):将唯一的参数.LC0传递到%edi中,然后调用puts函数。
2.exit(1):将唯一的参数1传递到%edi中,然后调用exit函数。
3.printf("Hello %s %s\n",argv[1],argv[2]):传递了4个参数,第一个是%edi中的.LC1,也就是在.rodata中的格式串,第2个时%eax中的0,第3个是argv[1],通过8(%rbp)计算得到,第4个是argv[2],通过16(%rbp)计算得到。
4.atoi(argv[3]):将唯一参数24(%rbx),即argv[3],传入%rdi中,然后调用atoi函数。
在函数调用时,call指令将返回地址压入栈中,并将PC设置为调用函数的起始地址;函数结束时,使用ret指令从栈中弹出返回地址,并将PC设为返回地址。如果需要返回值,则可以将其存储在%rax寄存器中。
3.4 本章小结
本章介绍了编译及其相关操作。编译的目标是将高级语言源代码翻译成机器语言,而将hello.i编译成汇编文本文件hello.s是编译的一个步骤。汇编代码是机器代码的文本表示,给出程序中的每一条指令。阅读和理解汇编代码是一项非常重要的技能,通过理解这些汇编代码,我们能够更好地理解编译器的优化能力,并分析代码中隐含的低效率。
第4章 汇编
4.1 汇编的概念与作用
汇编语言是一种低级别的编程语言,它用于编写计算机程序。在汇编语言中,程序员使用特殊的文本标记(称为指令)来描述计算机中的基本操作。这些指令通常涉及到对内存、寄存器和其他计算机硬件的操作。
汇编语言被广泛用于系统编程和底层硬件驱动程序开发,因为它可以提供对计算机硬件的精细控制。汇编语言还可以用于优化代码,从而提高程序的执行效率。
汇编语言相对于高级编程语言具有以下优点,如可以直接操作硬件,实现对底层硬件的精细控制,可以实现高效的代码,从而提高程序的执行速度和效率,和可以更好地理解和调试代码。
汇编语言虽然具有上述优点,但也存在一些缺点,如编写汇编代码需要对底层硬件有深入的理解和掌握,汇编代码的可读性较低,难以理解和维护,以及编写汇编代码的工作量大,开发效率低。
因此,在开发大型软件项目时,通常使用高级编程语言来提高开发效率和可维护性。但在某些特殊场合,如编写操作系统、编写嵌入式系统等,汇编语言仍然是必不可少的编程语言。
4.2 在Ubuntu下汇编的命令
Linux 下汇编命令gcc -m64 -Og -c -no-pie -fno-PIC hello.c -o hello.o
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
可重定位目标文件的ELF格式如下:
通过readelf工具,具体分析可重定位目标文件hello.o,分析这些节都做些什么。
首先是ELF头,如下图:
一个ELF文件的开头是一个16字节的序列,它描述了生成该文件的系统的字节顺序和字大小。ELF头的剩余部分包含链接器所需的语法解析和目标文件解释所需的信息。这些信息包括ELF头的大小、目标文件的类型(如可重定位、可执行或共享对象)、机器类型(如x86-64)、节头表在文件中的偏移量、以及节头表中每个条目的大小和数量。
重定位节.rela.text:存放着代码的重定位条目。当链接器把这个目标文件和其他文件组合时,需要修改这些位置
每一个重定位条目的数据结构如下:
它包括offset需要被修改的引用的节偏移、symbol被修改引用应该指向的符号、type告知链接器如何修改新的引用、以及偏移调整addend
CSAPP中给出了重定位的计算方法:
重定位节.rela.eh_frame:eh_frame 节的重定位信息
符号表:用来存放程序中定义和引用的函数和全局变量的信息。注意,符号表不包含局部变量的条目。
4.4 Hello.o的结果解析
(以下格式自行编排,编辑时删除)
objdump -d -r hello.o
4.4.1 机器语言的构成
x86-64指令的长度可以从1到15个字节不等,常用指令和少数操作数的指令所需字节数较少,例如pop %rbx只需一个字节5b,而那些不太常用或操作数较多的指令则需要更多字节数。
指令格式的设计方式是,从某个给定位置开始,可以将字节唯一地解码成机器指令。例如,只有指令pushq %rbx是以字节值53开头的。如果指令中有地址或常数,则需要按小端存储顺序依次存放。
4.4.2 与第三章hello.s对照分析
反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码;反汇编器使用的指令命名规则与GCC生成的汇编代码使用的有些细微的差别。例如:省略了很多指令结尾的‘q’.
在函数调用和分支跳转时,二者也是有差别的。
分支跳转时,如判断argc!=4,然后跳转
hello.s中是jump到.L6,用一个标记指示;
hello.o的反汇编是跳转到地址15
函数调用时,如printf("Hello %s %s\n",argv[1],argv[2])
hello.s中是将参数传递到寄存器之后,call后紧跟的是调用函数明
而hello.o的反汇编中call后紧跟的是45 <main+0x45>,并且还有一个重定位条目,用于链接时重定位。
需要注意的是,printf函数在只读数据段(.rodata)中的格式化字符串不再使用类似.LC1这样的符号来表示,而是使用指令mov $0x0, %esi来表示,并且后面跟随着一个重定位条目,用于在链接时进行重定位。
4.5 本章小结
本章介绍了汇编语言及其处理过程。汇编器可以将汇编代码翻译成机器指令,将这些指令打包成一个可重定位目标文件,然后将结果保存在hello.o中。hello.o是一个二进制文件,包含函数main的指令编码。多个可重定位目标文件可以在链接时合并为一个可执行目标文件。
第5章 链接
5.1 链接的概念与作用
本章介绍了链接及其过程。链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载(复制)到内存并执行。链接可以执行于编译时、加载时、运行时。例如,当hello程序调用printf函数时,它是每个C编译器都提供的标准C库中的一个函数。printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,这个文件必须以某种方式合并到我们的hello.o程序中。链接器(ld)就负责处理这种合并,结果得到可执行目标文件hello。
链接使得分离编译成为可能。我们不必将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
ld链接命令 ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
使用readelf -a hello > hello.elf 命令生成hello 程序的ELF 格式文件。
首先是ELF头,描述文件的总体格式,还包括程序的入口点。
.text、.rodata 和 .data 节与可重定位目标文件中的节非常相似,除了这些节已经被重定位到它们最终的运行时内存地址。.init 节定义了一个小函数 _init,用于程序初始化代码会调用它。因为可执行文件是完全连接的,所以没有 .rel 节。
节头记录每个节的名称、偏移量、大小、位置等信息
程序头部表描述了可执行文件的连续片段如何映射到连续内存段中。每个表项提供了映射到虚拟地址空间的大小、物理地址、标志、访问权限和对齐方式。通过读取这些信息,我们可以确定各个段的起始地址。
和可重定位目标文件相比,链接过程中链接了许多其他文件,如库函数等。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息。
首先从0x400000开始查看,我们发现第一行竟然和ELF头中的Magic是相同的,这绝非偶然,这说明程序是从0x400000处开始加载的
再看数据段,它是从0x600e10的。
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
使用objdump -d -r hello命令反汇编hello如下
在可执行文件中,程序头部表描述了将文件中的连续片段映射到连续内存段的映射关系。每个表项提供了段在虚拟地址空间中的大小、物理地址、标志、访问权限和对齐方式。通过读取程序头部表,我们可以获取每个段的起始地址。
在程序的链接过程中,许多函数被链接到可执行文件中,如__init、puts@plt、__printf_chk@plt等。然而,在分析main函数时,我们只需要关注它的调用方式,因为它能够揭示可重定位hello.o与可执行文件hello之间的差异。
我们可以发现,在调用函数时,call语句中包含函数地址,这些地址都是PC相对引用的。例如,调用strtol函数的地址为0x401040,而紧随其后的下一条指令地址为0x4011ca,两者之间的距离是0xfffffe76。根据小端法,这个距离的机器指令表示为76 fe ff ff。
对数据的重定位。printf语句中的格式串也用其地址做为参数传递,
而在hello.o中用0占位和一个重定位条目进行表示
5.6 hello的执行流程
使用edb进行单步调试,观察程序调用的函数。在调用main之前,程序会进行初始化工作,并调用_init函数。在_init函数之后,动态链接的重定位工作已经完成。我们可以看到一系列在程序中所用到的库函数,比如printf、exit和atoi等。实际上,这些函数在代码段中并不占用实际的空间,它们只是一个占位的符号。这些函数的内容实际上在共享区(高地址)中。之后程序调用_start函数,准备开始执行main函数的内容。main函数内部所调用的函数在第三章已经进行了充分的分析,这里略过main内部的函数。在执行完main函数之后,程序会执行__libc_csu_init、__libc_csu_fini和_fini函数,最终这个程序才结束。下面列出了各个函数的名称和地址,包括_init、puts@plt、printf@plt、getchar@plt、atoi@plt、exit@plt、sleep@plt、_start、_dl_relocate_static_pie、deregister_tm_clones、register_tm_clones、__do_global_dtors_aux、frame_dummy、main、__libc_csu_init、__libc_csu_fini和_fini函数。
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
对于动态共享链接库中的 PIC 函数,编译器无法预测函数的运行时地址,因此需要添加重定位记录,等待动态链接器处理。GNU 编译系统使用延迟绑定技术,将过程地址的绑定推迟到第一次调用该过程时。使用延迟绑定的动机是,对于像 libc.so 这样的共享库输出的成百上千个函数,一个典型的应用程序只会使用其中很少的一部分。将函数地址的解析推迟到实际调用的地方,可以避免动态链接器在加载时进行成百上千个其实并不需要的重定位。第一次调用过程的运行时开销很大,但是其后的每次调用都只会花费一条指令和一个间接的内存引用。
延迟绑定是通过两个数据结构的交互来实现的,这两个数据结构是 GOT(全局偏移量表)和 PLT(过程链接表)。如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的 GOT 和 PLT。GOT 是数据段的一部分,PLT 是代码段的一部分。下图介绍了 GOT 和 PLT 交互的一个例子。需要注意的是,当 GOT 和 PLT 联合使用时,GOT[0]和GOT[1]包含动态连接器在解析函数地址时会使用的信息。GOT[2]是动态连接器在 ld-linux.so 模块中的入口点。
5.8 本章小结
本章介绍了链接及其过程。链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以由静态编译器在编译时完成,也可以在加载时和运行时由动态链接器来完成。链接器处理三种形式的目标文件,包括可重定位的、可执行的和共享的。
链接器的两个主要任务是符号解析和重定位。符号解析指的是将每个符号名与其在代码或数据片段中的地址关联起来的过程。在可重定位的和共享的目标文件中,链接器需要将所有引用符号的地方与定义符号的地方建立联系,以便能够在后续的重定位过程中正确地调整这些引用符号的地址。重定位是指将一个目标文件中的地址映射到实际的内存地址的过程,同时还需要解决引用不同共享库中同名符号的问题。
链接过程的实现通常涉及到几个数据结构,如符号表、重定位表和全局偏移量表(GOT)等。符号表记录了每个符号名及其对应的地址信息,重定位表记录了需要被调整的地址和调整后的地址,GOT则用于在共享库和主程序之间建立引用符号的地址映射关系。这些数据结构的实现可以采用不同的算法和数据结构,以提高链接器的性能和效率。
链接是编译过程中重要的一环,它可以将多个模块组合成为一个可执行的程序或共享库。了解链接的过程和实现原理对于开发人员来说非常重要,可以帮助他们更好地理解编译和运行程序的过程,同时也能够帮助他们更好地优化代码和解决一些常见的链接问题。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
一个运行中的程序实例是系统进行资源分配和调度的基本单位,通常由文本区域、数据区域和堆栈组成。文本区域存储处理器执行的代码,数据区域存储变量和进程执行期间使用的动态分配内存,而堆栈区域存储活动过程调用的指令和本地变量。
6.1.2进程的作用
操作系统为应用程序提供两个关键的抽象:进程和虚拟内存。进程提供了一个独立的逻辑流,让应用程序看起来好像是独占地使用处理器。而虚拟内存提供了一个私有的地址空间,让应用程序看起来好像是独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
壳(Shell)是计算机操作系统提供的一个用户界面,用于与操作系统内核进行交互。Bash(Bourne-Again SHell)是一种常见的壳程序,常用于Linux和其他类Unix系统。
Bash的作用是接收用户在命令行界面输入的命令,将其解释成操作系统内核可以理解的格式,并交由内核执行。Bash还提供了一些内置命令和环境变量,使得用户可以方便地执行各种系统操作和管理任务。
Bash的处理流程如下:
1.用户在命令行输入命令。
2.Bash解析用户输入的命令,并根据空格将命令和参数分离。
3.Bash查找可执行文件或内置命令,并将命令和参数传递给该程序或命令。
4.如果命令需要读取输入或输出结果,Bash将建立相关的管道或重定向,以便将输入或输出结果传递给该命令。
该命令执行完毕后,Bash会将执行结果返回给用户,或者将结果输出到屏幕上。
此外,Bash还支持Shell脚本,这是一种将多个命令按顺序组合起来的方式,以便一次性执行多个操作。Shell脚本可以自动化完成各种重复的系统操作和管理任务,从而提高了效率和减少了错误。
6.3 Hello的fork进程创建过程
当程序运行到 main() 函数中的 fork() 调用时,将会创建一个新的进程。具体的过程如下:
- 当执行到 fork() 函数时,系统会复制当前进程的所有数据,包括代码段、全局变量、堆栈、寄存器等,生成一个新的进程,称为子进程。
- 子进程会从 fork() 调用的下一条语句开始执行,也就是说,子进程会复制当前进程的程序计数器,并将其指向下一条语句。
- fork() 函数的返回值对于父进程和子进程是不同的。对于父进程,返回值是子进程的进程ID,对于子进程,返回值是0。
- 在父进程中,fork() 函数返回子进程的进程ID。可以使用这个进程ID来识别和控制子进程。如果返回值是负数,则表示出现了错误。
- 在子进程中,fork() 函数返回0。可以使用这个返回值来区分父进程和子进程的执行路径。
- 子进程的代码段、数据段和堆栈与父进程完全独立,子进程不会影响父进程的运行状态,也不会受到父进程的影响。因此,父进程和子进程可以并行执行不同的任务。
- 在 hello 程序中,fork() 函数的调用是在主函数的开始处。因此,当程序运行时,将会创建一个新的子进程,并在父进程和子进程中分别执行相同的代码。但由于子进程的代码段和数据段是独立的,所以子进程会从 fork() 函数下一条语句开始执行,即从循环语句开始。
6.4 Hello的execve过程
当 hello 程序运行时,当满足一定条件时(例如,命令行参数不正确时),程序将调用 exit() 函数来退出。如果命令行参数正确,则程序将继续执行,并调用 execve() 函数来执行另一个程序。
execve() 函数接受3个参数:要执行的程序的路径、命令行参数和环境变量。当调用 execve() 函数时,当前进程的代码段和数据段将会被替换为新程序的代码段和数据段。也就是说,当前进程将变成要执行的程序,并从新程序的第一条语句开始执行。
如果 execve() 函数执行成功,则不会返回,程序将继续执行新程序的代码。如果 execve() 函数执行失败,则会返回一个负数值,并显示错误消息。在这种情况下,当前进程将继续执行原来的程序。在 hello 程序中,当命令行参数正确时,程序将调用 execve() 函数来执行 ps 命令。具体地,程序将调用 system() 函数,该函数将会调用 execve() 函数来执行 ps 命令。在这个过程中,当前进程将会被替换为 ps 命令,从而在终端上输出系统进程信息。
6.5 Hello的进程执行
在操作系统中,每时每刻都有许多程序在运行,但我们通常将每个进程视为独立占用CPU、内存和其他资源。如果我们对程序进行单步调试,我们可以观察到一系列程序计数器(PC)的值,这些值构成了程序的逻辑控制流程。实际上,多个程序在计算机内部并行执行,它们的执行是交错进行的,就像下图所示,每个程序都会交替地运行一小段时间。在同一个处理器核心中,每个进程执行它的流程的一部分后,就会被抢占(暂时挂起),然后轮到其他进程运行。
操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。内核为每个进程维护一个上下文,上下文包含内核重新启动被抢占进程所需的状态。它由多个对象的值组成,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈以及多种内核数据结构,如描述地址空间的页表、进程表等。上下文切换的流程是:1.保存当前进程的上下文;2.恢复某个先前被抢占的进程的保存上下文;3.将控制传递给这个新恢复的进程。
为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制来限制一个应用可以执行的指令以及它可以访问的地址空间范围。处理器通常使用某个控制寄存器的一个模式位提供两种模式的区分,该寄存器描述了进程当前享有的特权。当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据。设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
接下来分析 "hello" 进程的进程调度。当 "hello" 显式地请求休眠并调用 "sleep" 函数时,控制权转移到另一个进程。此时计时器开始计时,当计时器到达 "argv[3]",即 1 秒时,它会产生一个中断信号来中断当前正在运行的进程,并进行上下文切换,恢复 "hello" 在休眠前的上下文信息,控制权回到 "hello" 进程,继续执行。当循环结束后,"hello" 调用 "getchar" 函数,此时进程由用户模式切换到内核模式。在内核模式下,陷阱处理程序请求来自键盘缓冲区的 DMA 传输,并执行上下文切换,将控制权传递给其他进程。当完成键盘缓冲区到内存的数据传输后,引发一个中断信号,此时内核从其他进程切换回 "hello" 进程,并执行 "return",进程终止。
6.6 hello的异常与信号处理
hello 执行过程中可能出现四类异常:中断、陷阱、故障和终止。
1. 中断是来自 I/O 设备的信号,异步发生,中断处理程序对其进行处理,返 回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
2. 陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指 令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
3. 故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成 功,则将控制返回到引起故障的指令,否则将终止程序。
4. 终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程 序会将控制返回给一个 abort 例程,该例程会终止这个应用程序。
以下是hello中对于各个异常和信号的处理
1.正常运行
程序正常执行,总共循环5次每次输出提示信息之后等待我们从命令行输入的秒数,最后需要输入一个字符回车结束程序。
2.中途按下ctrl-Z
内核向前台进程发送一个SIGSTP信号,前台进程被挂起,直到通知它继续的信号到来,继续执行。当按下fg 1 后,输出命令行后,被挂起的进程从暂停处,继续执行。
3.中途按下ctrl-C
内核向前台进程发送一个SIGINT信号,前台进程终止,内核再向父进程发送一个SIGCHLD信号,通知父进程回收子进程,此时子进程不再存在
4.运行途中乱按
运行途中乱按后,只是将乱按的内容输出,程序继续执行,但是我们所输入的内容到第一个回车之前会当做getchar缓冲掉,后面的输入会简单的当做我们即将要执行的命令出现在shell的命令行处。
5.输入ps打印前台进程组
ps打印当前进程的状态
6.pstree打印进程树
7.列出jobs当前的任务
jobs,打印进程状态信息
8.输入fg 1,继续执行前台进程1
9.输入kill
kill之后会根据不同的发送信号的值,以及要发送的进程的pid发送相应的信号,这里我们将hello杀死。
6.7本章小结
本章介绍了进程管理,其中进程是一个正在运行的程序的实例,它提供了两个关键的抽象概念:好像程序在独占处理器,好像程序在独占内存系统。每个进程都处于某个进程上下文中,并且每个进程都有自己的上下文,用于操作系统通过上下文切换进行进程调度。用户可以通过shell和操作系统交互,向内核提出请求,shell使用fork函数和execve函数来运行可执行文件。
第7章 hello的存储管理
7.1 hello的存储器地址空间
本章介绍了链接及其过程。链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以由静态编译器在自编译时完成,也可以由动态链接器在加载时和运行时完成。链接器处理三种形式的目标文件,包括可重定位的、可执行的和共享的。链接器的两个主要任务是符号解析和重定位。
在计算机系统中,物理地址(physical address)用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。它表示当前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
逻辑地址(Logical Address)是指由程序产生的和段相关的偏移地址部分,表示为[段标识符:段内偏移量]。
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址。如果没有启用分页机制,那么线性地址直接就是物理地址。
虚拟地址(Virtual Address)是指虚拟内存为每个程序提供的一个大的、一致的和私有的地址空间。它是由程序生成的,每个字节对应的地址称为虚拟地址。虚拟地址包括VPO(虚拟页面偏移量)、VPN(虚拟页号)、TLBI(TLB 索引)和TLBT(TLB 标记)。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址空间是由段地址和偏移地址组成的。通过将段地址和偏移地址相加,可以得到线性地址。
在实模式下,逻辑地址被解释为 CS:EA=CS*16+EA,转换成物理地址。而在保护模式下,逻辑地址要先用段描述符在 GDT/LDT 表中查找相应的段地址,再加上偏移地址得到线性地址。段内偏移量已经在链接后确定为 32 位地址,因此需要使用逻辑地址前 16 位来获得段地址,这 16 位存放在段寄存器中。
段寄存器是 16 位的,用于存储段选择符。其中,CS(代码段)存储程序代码所在的段,SS(栈段)存储栈区所在的段,DS(数据段)存储全局静态数据区所在的段,ES、GS 和 FS 这三个段寄存器可以指向任意数据段。
段选择符中的字段含义如下:
TI=0 表示选择全局描述符表(GDT),TI=1 表示选择局部描述符表(LDT)。
RPL 字段表示 CPU 的当前特权级。RPL=00 为最高级的内核态,RPL=11 为最低级的用户态。
高 13 位-8K 个索引用来确定当前使用的段描述符在描述符表中的位置。
段描述符是一种数据结构,用于描述一个段的特性,包括段的起始地址、长度、访问权限、特权级等信息。段描述符分为两类:用户的代码段和数据段描述符,以及系统控制段描述符。
描述符表实际上是段表,分为全局描述符表(GDT)、局部描述符表(LDT)和中断描述符表(IDT)。GDT只有一个,用于存放系统内每个任务都可能访问的描述符,例如,内核代码段、内核数据段、用户代码段、用户数据段以及TSS(任务状态段)等都属于GDT中描述的段。LDT用于存放某个任务(即用户进程)专用的描述符。IDT包含256个中断门、陷阱门和任务门描述符,用于处理中断、异常和系统调用等事件。
逻辑地址空间表示为:段地址:偏移地址,其中段地址存储在段寄存器中。在实模式下,逻辑地址可以通过 CS:EA = CS * 16 + EA 计算出物理地址。在保护模式下,逻辑地址需要通过段描述符获得段地址,然后再加上偏移地址才能得到线性地址。段描述符中包含特权级、段限制、段基地址等信息,通过段选择符(存储在段寄存器中)和TI、RPL等字段可以选择相应的描述符。
下图展示了逻辑地址到线性地址的转化过程:
逻辑地址由段选择符和偏移地址组成,根据段选择符选择相应的段描述符,获得段地址和段限制,将段地址和偏移地址合成线性地址,再经过页表变换得到最终的物理地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
在Linux下,虚拟地址到物理地址的转化与翻译依赖于页式管理机制,其中虚拟内存作为内存管理的关键。虚拟内存可以被看作一个由N个连续的字节大小的单元组成的数组,存放在磁盘上。这些虚拟内存块被缓存到物理内存中(DRAM cache),每个块被称为一个页,其大小为P = 2p字节。分页机制通过将虚拟内存和物理内存分为多个页,建立映射关系,从而可以高效利用内存资源并方便地进行管理。一般情况下,一个页面的标准大小是4KB,但有时也可以达到4MB。虚拟页面作为磁盘内容的缓存,具有全相联的DRAM缓存、大的映射函数等特点,不同于硬件对SRAM缓存更复杂精密的替换算法。虚拟页面地集合被分为三个不相交的子集:已缓存、未缓存和未分配。
页表实现从虚拟页到物理页的映射,依靠的是页表,页表就是是一个页表条目 (Page Table Entry, PTE)的数组,将虚拟页地址映射到物理页地址。这个页表是常驻与主存中的。
下图展示了页式管理中虚拟地址到物理地址的转换:
下图a展示了当页面命中时,CPU硬件执行的步骤:
第1步:处理器生成一个虚拟地址,并把它传送给MMU;
第2步:MMU生成PTE地址,并从高速缓存/主存请求得到它;
第3步:高速缓存/主存向MMU返回PTE;
第4步:MMU构造物理地址,并把它传送给高速缓存/主存;
第5步:高速缓存/主存返回所请求的数据字给处理器
处理缺页如图b所示:
第1~3步:和图a中的第1步到第3步相同;
第4步:如果PTE中的有效位为零,MMU将触发一次异常,并将其传递给CPU中的控制单元,控制单元将操作系统内核的缺页异常处理程序作为处理程序;
第5步:缺页处理程序将确定要换出的物理内存页,如果该页面已经被修改,则将其交换到磁盘中;
第6步:缺页处理程序将调入新的页面,并更新内存中的PTE;
第7步:缺页处理程序返回到原始进程,重新执行导致缺页的指令。CPU会将引起缺页的虚拟地址重新发送给MMU。由于虚拟页面现在已经缓存在物理内存中,因此会命中。在MMU执行图b中的步骤之后,主存将返回所请求的数据字给处理器。
7.4 TLB与四级页表支持下的VA到PA的变换
为了消除每次 CPU 产生一个虚拟地址后,MMU 需要查阅 PTE 带来的时间开销,许多系统都在 MMU 中包括了一个关于 PTE 的小的缓存,称为翻译后被缓冲器(TLB),TLB 的速度快于 L1 cache。
TLB 通过虚拟地址的 VPN 部分进行索引,分为索引(TLBI)与标记(TLBT)两个部分。这样,MMU 在读取 PTE 时会直接通过 TLB 进行查找,如果查找成功则快速访问下一级页表或者得到第四级页表中的物理页表,否则再从内存中将 PTE 复制到 TLB。同时,为了减少页表太大而造成的空间损失,可以使用层次结构的页表以压缩页表大小。例如,core i7 使用的是四级页表。
在四级页表层次结构的地址翻译中,虚拟地址被划分为 4 个 VPN 和 1 个 VPO。每个 VPNi 都是一个到第 i 级页表的索引,第 j 级页表中的每个 PTE 都指向第 j+1 级某个页表的基址,第四级页表中的每个 PTE 包含某个物理页面的 PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定 PPN 之前,MMU 必须访问四个 PTE。
综上,在四级页表下,MMU 根据虚拟地址不同段的数字通过 TLB 快速访问得到下一级页表的索引或者得到第四级页表中的物理页表,然后与 VPO 组合,得到物理地址(PA)。
7.5 三级Cache支持下的物理内存访问
首先,对于给定的物理地址,CPU 会根据其 s 位组索引索引到 L1 cache 的某个组,然后在该组中查找是否有某一行的标记等于物理地址的标记并且该行的有效位为 1。如果有,则说明命中,从该行对应物理地址的 b 位块偏移的位置取出一个字节并提供给 CPU 使用。如果不满足上述条件,则说明不命中,需要继续访问下一级 cache,访问原理与 L1 相同。如果三级 cache 都没有要访问的数据,则需要访问内存,从中取出数据并放入 cache。
7.6 hello进程fork时的内存映射
当 fork 函数被当前进程调用时,内核会为新进程创建各种数据结构,并分配给它一个唯一的 PID。为了创建新进程的虚拟内存,它会复制当前进程的 mm_struct、区域结构和页表,生成一个原样副本。此外,它会将两个进程中的页面标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当 fork 在新进程中返回时,新进程的虚拟内存和调用 fork 时存在的虚拟内存完全相同。当这两个进程中的任意一个进程进行写操作时,写时复制机制就会创建新的页面,从而为每个进程维护私有地址空间的概念。
7.7 hello进程execve时的内存映射
execve 函数调用会驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件 hello 中的程序,以有效地替代当前程序。加载并运行 hello 需要以下几个步骤:
1.删除已存在的用户区域。即删除当前进程虚拟地址的用户部分中的已存在区域结构。
2.映射私有区域。为新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区。bss 区域是请求二进制零的,映射到匿名文件中,其大小包含在 hello 中。栈和堆地址也是请求二进制零的,初始长度为零。
3.映射共享区域。hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。execve 做的最后一件事就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
DRAM缓存未命中,即缺页,指的是虚拟内存中的某个字不在物理内存中。当CPU访问虚拟页的某个字时,地址翻译硬件将读取对应虚拟页的页表条目,如果有效位推断出该页未被缓存,就会触发一个缺页异常。缺页异常会调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,把要缓存的页缓存到该牺牲页的位置。如果这个牺牲页被修改过,就将其交换出去。缺页处理程序返回后,CPU重新执行引起缺页的指令,该指令再次发送虚拟地址到MMU,这次MMU将可以正常地将虚拟地址翻译为物理地址。
在上图中,当VP3被引用时,由于它未被缓存,触发了一个缺页异常。缺页处理程序选择了VP4作为牺牲页,并从磁盘上读取了VP3的副本。在缺页处理程序返回后,CPU重新执行引起缺页的指令,该指令再次发送虚拟地址到MMU,这次MMU将可以将虚拟地址翻译为物理地址,而不再产生异常。
7.9动态存储分配管理
动态内存分配器维护一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块是一个连续的虚拟内存片,可以被分配给应用程序使用。已分配的块显式地保留给应用程序使用,而空闲块则用于分配。一个空闲块会一直保持空闲状态,直到被应用程序显式地分配。一个已分配的块会一直保持已分配状态,直到被释放,这个释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
内存分配器可以分为两种基本风格:显式分配器和隐式分配器。显式分配器要求应用程序显式地释放已经分配的块,而隐式分配器则会检测一个已分配块是否被应用程序使用,如果没有,就会自动释放该块,这个过程称为垃圾收集。
7.10本章小结
本章介绍了hello操作系统中的存储管理机制。我们讨论了虚拟地址、线性地址和物理地址之间的关系,介绍了段式管理和页式管理,以及虚拟地址到物理地址的转换,还涉及到了物理内存的访问。我们还讨论了在hello进程中进行fork和execve时的内存映射过程,以及处理缺页故障和缺页中断的过程。最后,我们还介绍了动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件是一个包含m个字节的序列:B0,B1,…,Bm-1。所有的I/O设备都被视为文件。文件的类型有以下几种:
1.普通文件:包含任何数据,分为两类:
1) 文本文件:只含有ASCII码或Unicode字符的文件
2)二进制文件:所有其他文件
2.目录:包含一组链接的文件,每个链接都将一个文件名映射到一个文件。
3.套接字:用于与另一个进程进行跨网络通信的文件。
所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
Unix I/O接口提供了以下几种操作:
1.打开文件:程序可以请求内核打开文件,并获得一个小的非负整数(即描述符),用于标识该文件。通过记录描述符,程序就能记录打开文件的所有信息。
2.为进程打开三个文件:在进程启动时,shell会为其打开三个文件:标准输入、标准输出和标准错误输出。
3.改变当前文件位置:对于每个打开的文件,内核都会保存一个文件位置k,初始值为0,表示从文件开头开始的字节偏移量。应用程序可以使用seek操作显式地设置文件的当前位置为k。
4.读写文件:读操作从文件中复制n个字节到内存,从当前文件位置k开始,并将k增加到k+n。对于一个大小为m字节的文件,当k>=m时,执行读操作将触发一个称为EOF的条件。应用程序可以检测到该条件,但在文件结尾处并没有明确的EOF符号。
5.关闭文件:内核会释放打开文件时创建的数据结构和占用的内存资源,并将描述符恢复到可用的描述符池中。无论进程因何种原因终止,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O函数提供了以下几种操作:
1.int open(char *filename, int flags, mode_t mode);
2.open函数将文件名filename转换为文件描述符,并返回描述符数字。返回的描述符总是当前进程中未打开的最小描述符。flags参数指定进程打算如何访问该文件,mode参数指定新文件的访问权限位。
3.int close(int fd);
4.close函数关闭一个打开的文件。
5.ssize_t read(int fd, void *buf, size_t n);
6.read函数从描述符为fd的当前文件位置将最多n个字节复制到内存位置buf中。返回值-1表示错误,0表示EOF,否则返回的值表示实际传输的字节数量。
7.ssize_t write(int fd, const void *buf, size_t n);
8.write函数将内存位置buf中最多n个字节复制到描述符fd的当前文件位置。
8.3 printf的实现分析
函数printf接受一个格式化字符串fmt以及可变数量的参数,使用vsprintf函数将它们格式化成一个字符串,并将结果写入到一个缓冲区中。然后使用系统调用write将该字符串输出到屏幕上,并返回该字符串的长度。
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;
}
函数vsprintf的作用是将格式化字符串fmt和对应的可变参数转换成一个字符串,并返回该字符串的长度。转换的过程中需要将参数按照格式化字符串的格式进行解析和处理。
将输出字符串显示到屏幕上需要经过以下步骤:字符显示驱动子程序将ASCII码转换成字模库中对应的字模,然后将字模的数据存储到显示缓存区中。显示芯片按照一定的刷新频率逐行读取显示缓存区中的数据,并通过信号线向液晶显示器传输每一个像素的RGB颜色信息,从而实现字符在屏幕上的显示。这个过程中可能需要使用系统调用int 0x80或syscall等来实现对底层硬件的访问。
8.4 getchar的实现分析
当用户在键盘上按下某个键时,键盘接口将生成一个代表该键的键盘扫描码,并产生一个中断请求。中断请求会中断当前进程的执行,并运行键盘中断子程序。键盘中断子程序首先从键盘接口获取该键的扫描码,然后将其转换为相应的 ASCII 码,并将其存储到系统的键盘缓冲区中。
现在来看函数 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;
}
这段代码实现了从标准输入读取一个字符的功能。在执行过程中,getchar 调用了 read 函数,read 函数通过系统调用将键盘缓冲区中的数据读取出来,直到读到回车符为止,然后返回读取到的整个字符串。而在 getchar 函数中,只有第一个字符被返回,其余的字符被存储在输入缓冲区中,等待下一次读取。在这个过程中,如果缓冲区中没有数据,则会调用 read 函数从标准输入中读取数据,并将其存储到缓冲区中。
8.5本章小结
本章主要介绍了Linux操作系统中I/O设备的管理方法,包括Unix I/O接口和相关函数的使用。其中,详细讲解了printf函数的实现原理以及getchar函数的具体实现方式。通过本章的学习,读者可以更深入地了解Linux系统的输入输出机制,以及在编写C语言程序时如何利用I/O函数进行数据的读写和输出。
结论
从一个简单的hello.c程序,竟然能够产生这么多复杂的过程,这说明任何程序都不是简单的。这些程序需要经过多重考验,从源代码变成可执行目标文件并不是通过简单地按下运行按钮就能完成的。只有学完ICS这门神课才能真正理解其中的艰辛。
让我们简单回顾一下hello.c程序的生命周期。首先,它会被C预处理器(cpp)处理,拓展带#的内容,变成hello.i文本文件;接着被编译器(ccl)转化成汇编文本。但是,汇编文本还无法被机器直接处理,因为它只能识别01序列。因此,需要由汇编器将hello.s生成可重定位目标文件hello.o。但是问题又来了,hello程序调用的printf等库函数并不在hello.o文件中,所以无法运行。因此,链接器通过静态或动态链接最终生成了可执行目标文件hello。你可能以为hello的任务完成了,但它只是在磁盘上静静地待着,还需要很多"神仙"的帮助才能运行,如fork创建新进程,execve加载映射等等。在运行过程中还可能会遇到异常情况,需要异常处理程序才能解决。hello的一生可谓惊险而刺激!
计算机系统是一个非常精细和巧妙的系统,比如流水线处理器精确到每个几微秒周期;它还引入了缓存的概念。我不由得赞叹计算机系统设计者的高超智慧。在这门课中我学到了很多东西,感谢老师一学期以来的辛勤付出和耐心解答。虽然像hello一样,我只是经历了预处理的一小步,但我相信在未来的路上,还有很长的路要走。
附件
hello.c | hello源代码 |
hello.i | 预处理之后的文本文件 |
hello.s | hello的汇编代码 |
hellooalt.s | hello.o的反汇编代码 |
helloalt.s | hello的反汇编代码 |
hello.o | hello的可重定位文件 |
hello | hello的可执行文件 |
hello.elf | hello的elf文件 |
helloo.elf | hello.o的elf文件 |
参考文献
- 大卫R.奥哈拉伦,兰德尔E.布莱恩特. 深入理解计算机系统[M]. 机械工业出版社,2016.
- CSDN(www.csdn.net)