计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 网络空间安全
学 号 2022111067
班 级 22L03901
学 生 张荣燚
指 导 教 师 史先俊
计算机科学与技术学院
2024年5月
本文主要通过分析 hello.c 这个程序在linux系统x86-64环境下从编写到运行终止的一生(预处理、编译、汇编、链接、进程管理、存储管理、IO管理),回顾了这学期计算机系统这门课的几乎所有知识,包括计算机的信息表示及处理、程序的机器级表示、处理器体系结构、存储器层次结构、链接过程、异常控制流、虚拟内存等。在分析过程中主要使用 ubuntu 作为操作系统,并使用一些工具来辅助完成,为了对于计算机系统的工作与原理有更深的了解。
关键词:计算机系统;hello;预处理;编译;汇编;链接;进程管理 ;存储管理;IO管理
目 录
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:From Program to Process(大致分为四个阶段)
(1)预处理:首先,Hello 的开始是一段储存在磁盘上的程序文本(Program),在需要使用这一个代码文件的时候,用预处理器处理 hello.c 文件,生成一个 hello.i 文件也就是修改了的源程序
(2)编译:编译器将hello.i翻译程hello.s,编译这个阶段包括语义分析、语法分析、词法分析
(3)汇编:汇编器根据指令集将hello.s翻译成机器指令,并且把这一系列指令按照固定的规则进行打包,得到可重定位目标文件(在第七章链接中涉及)hello.o
(4)链接:在hello程序中调用了printf这个函数,链接器负责把printf.o和hello.o进行合并,最终得到可执行目标文件hello
最后在 shell 中调用相关命令将为其创建进程(Process),执行程序。
020:From Zero-0 to Zero-0
1.2 环境与工具
硬件环境:
设备名称 LAPTOP-DMTCJVFB
处理器 13th Gen Intel(R) Core(TM) i5-13500H 2.60 GHz
机带 RAM 16.0 GB (15.7 GB 可用)
设备 ID 773F669F-47BA-440D-A3CC-083CDCEB6211
产品 ID 00342-30876-43689-AAOEM
系统类型 64 位操作系统, 基于 x64 的处理器
笔和触控 为 10 触摸点提供触控支持
软件环境:Windows11 64位; Vmware 17;Ubuntu 20.04 LTS 64位;
开发与调试工具:gcc,as,ld,vim,edb,readelf,Visual Studio 2022 64位,CodeBlocks 64位,gedit,gdb
1.3 中间结果
文件名 | 文件的作用 |
hello.i | 预处理后的文件 |
hello.s | 汇编文件 |
hello.o | 可重定位目标文件 |
hello | 可执行目标文件 |
elf.txt | Hello.o 的 ELF 格式 |
hello_disass.s | Hello.o 的反汇编代码 |
hello.elf | Hello的elf格式文件 |
hello_objdump.s | Hello的反汇编代码 |
hello.c | C文件 初始文件 |
1.4 本章小结
本章主要对hello程序中P2P、020的概念进行阐述,并说明hello程序运行的硬件环境、软件环境,最后对论文中间产生的文件进行描述。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理(cpp)指的是程序在编译之前进行的处理,是计算机在处理一个程序时所进行的第一步处理,可以进行代码文本的替换工作,能够对源程序. c文件中出现的以字符“#”开头的命令进行处理。
作用:
(1)宏替换:预处理器会查找宏定义(由#define指令定义)并将其替换为相应的值或代码片段。
(2)文件包含:处理#include指令,将指定的头文件内容包含到源文件中。这可以是标准库头文件或用户自定义的头文件。比如hello.c的第一行的#include<stdio.h>命令告诉预处理器读取系统有文件stdio.h的内容,并把它插入到程序文本中。
(3)条件编译:根据#ifdef、#ifndef、#if、#else、#elif和#endif指令,预处理器可以决定是否包含特定的代码段。这常用于根据不同的编译条件编译不同的代码路径。
(4)错误检查:预处理器还会检查宏的使用是否正确,比如未定义的宏的使用等。
(5)行控制:处理#line指令,可以用来改变编译器报告错误消息时显示的文件名和行号。
(6)删除注释:.删除c语言源程序中的注释部分。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
如下图所示,经过预编译过程后,文件从24行拓展至3061行。在其中,文件中所有的注释已经消失。完成了对头文件的展开,对宏定义的替换等内容。
下图可以看出,在程序的前方为hello引用的所有的头文件stdio.h,unistd.h,
stdlib.h内容的展开,其中包含了其他头文件的展开(三个头文件引用的其他头文件)以及extern引用外部符号的部分,以及利用typedef来定义变量类型别名。
观察如下两张图,发现在源代码头部出现的注释在预处理之后的源
代码部分已经不可见,因此这就印证了上面说的在预处理过程中预处理
器将删除源代码中的注释部分。
2.4 本章小结
这一章主要介绍了在预处理过程中预处理器的工作(头文件展开,宏替换,删除注释,条件替换等),同时使用ubuntu展示了对hello.c文件的预处理过程和结果。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:编译是把通常为高级语言的源代码(这里指经过预处理而生成的 hello.i)到能直接被计算机或虚拟机执行的目标代码(这里指汇编文件 hello.s)的翻译过程。
作用:
- 词义分析(扫描):将源代码程序输入扫描器,将源代码中的字符序列分割为一 系列 c 语言中的符合语法要求的字符单元。
- 语法分析:语法分析器根据语言的语法规则,将词素序列组织成一棵语法分析树,这棵树表示了源代码的语法结构。
- 语义分析:语义分析器使用语法树和符号表中的信息来检查源程序是否和语言定义的语义一致,它同时收集类型信息,并存放在语法树或符号表中,为代码生成阶段做准备,这个阶段不涉及程序执行时的错误,如除以零(1/0)这类运行时错误。
- 中间代码:中间代码的作用是可使使得编译程序的逻辑更加明确,主要是为了下一步代码优化的时候优化的效果更好。
- 代码优化:根据用户指定的不同优化等级对代码进行安全的、等价的优化,这 一行为的目的主要是为了提升代码在执行时的性能。
- 生成代码:最后将生成一个汇编语言代码文件,也就是我们最后得到的 hello.s 文件,这一文件中的源代码将以汇编语言的格式呈现。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
3.3.1 数据
(1)常量
数字常量:通过观察发现在源代码中使用的数字常量都是储存在.text 段的(立即数直接在指令中出现,避免了额外的数据段管理,简化了编译和链接过程),包括在比较的时候使用的数字变量 5,在循环的时候使用的循环比较变量等数字常量都是储存在.text 节的,具体情况见如下图:
字符串常量:可以发现在 printf 等函数中使用的字符串常量是储存在.rotate 段的,具体储存情况见如下图:
- 局部变量
程序中的局部变量储存在栈中的某一个位置或是直接储存在寄存器中。例如下图中的int i,它储存在栈中起始地址为%rbp-4 的位置
3.3.2 赋值
编译器使用mov指令给寄存器或栈的某个地址进行赋值,如下图就是将0赋值给起始地址为%rbp-4的一处空间。通过mov指令的后缀l得知这是4字节的传送,对应于hello.c中的int i。
3.3.3 类型转换
在hello.s中主要存在隐式的数据类型转换。这些转换在指针和整数之间进行,通过加载、加法和解引用等操作实现。
例如下图有几处从整数到指针的隐式转换,这些转换是通过地址计算和寄存器操作完成的:“-32(%rbp) ”是一个指针(地址),通过对其进行加法操作“addq $24, %rax,%rax ”变成了新的指针,指向特定的内存位置。接下来使用 “movq (%rax), %rcx”,将该内存位置的值加载到“ %rcx ”寄存器中。这里的 addq 操作实际上是将一个整数值(24)加到一个指针上,这就是一种隐式的类型转换。
类似地,从指针到整数的转换也是隐式的,通过加载地址指向的内容到整数寄存器中实现。在下图中,“movq -32(%rbp), %rax ”和“ addq $32, %rax” 将指针加载到 %rax 并进行了偏移。“movq (%rax), %rax ”将指针解引用,结果存储在 %rax 中,随后传递给 atoi 函数。这个过程将一个指针值转换为一个整数值。
3.3.4 算术操作
汇编程序中有一系列的算数操作,例如加法有add指令、减法sub指令等等。这样的指令和他们的操作数搭配在一起就实现了源程序中的算数操作。对于局部变量 i,由于其是循环变量,因此在每一轮的循环中都要修改这个值,对于这个局部变量的算术操作(i++)的汇编代码如下:
3.3.5 关系操作
关系操作通常与条件跳转语句搭配在一起使用,汇编程序通过一系列跳转指令实现条件判断和跳转,例如jmp、jne、jl等。hello.s一共出现了两处关系操作,具体情况可以分别分析。第一处是对于 argc 的判断,当等于 5 的时候将进行条件跳转,如下图:
第二处是在 for 循环中对于循环变量 i的判断,这一段的汇编代码如下图所示,当循环变量 i 大于等于 9的时候将进行条件跳转。
3.3.6 数组/指针/结构操作
数组操作在汇编中通常通过指针和偏移量来实现。虽然代码中没有显式的数组声明,但通过对指针的偏移操作,可以推断出数组的使用。例如在hello.s中出现的数组操作就是对于 argv 数组的操作,argv[0]存放在-32(%rbp),那么argv[2]就在。-16(%rbp),对于数组操作的汇编代码如下截图:
3.3.7函数操作
函数是一种过程,提供了一种封装代码的方式,用一组指定的参数和可选的返回值来实现某种功能。对函数的调用包括控制转移、传递数据、分配和释放内存几个部分。
函数操作的大体流程:函数调用使用call操作,提前把需要的参数存放在寄存器%rdi,%rsi,%rdx,%rcx,%r8,%r9(超过六个参数时,通过栈传递)。函数调用通过ret返回,若有返回值存放在%rax。
控制转移:使用call进行过程调用时需要把下一条指令地址存放在栈中以便返回时能够回到这个位置,并且要把PC的值更新为call后面的符号表示的地址,然后跳转到被调用函数的地址。在被调用函数结束时通过ret返回,把PC设置为栈中之前存放的指令地址值,从栈中弹出返回地址,然后跳转到返回地址执行。
传递数据:在 x86-64 体系结构中,前六个整数或指针参数先后通过以下寄存器传递:%rdi,%rsi,%rdx,%rcx,%r8,%r9,超过六个参数时,通过栈传递。
分配和释放内存:函数调用后通常需要考虑保存寄存器和局部变量,这时需要开辟一块栈空间,返回之前再进行释放。
hello.c程序中涉及的函数有main、printf、sleep、atoi、exit和getchar。这里以main函数为例:
main函数传入参数 argc 和 argv,其中 argv 储存在栈中,argc 储存在%rdi 中。在源代码中最后的返回语句是 return 0,因此在汇编代码中最后将%eax 设置为 0并返回这一寄存器。汇编代码如下图所示:
3.4 本章小结
本章主要介绍了在将修改了的源程序文件转换为汇编程序的时候主要发生的 变化以及汇编代码文件中主要存在的部分以及源代码中的一些主要的操作对应的 汇编代码中的汇编代码的展现形式,并在x86-64指令下以hello.s为例,分析了汇编程序是如何实现源程序中的一些表示和操作(数据表示,算术操作,控制转移,函数操作等等)。
总的来说,编译器做的就是在进行词义分析和语义分析之后判断源代码符合语法要求之后将高级的程序语言向较低级的汇编语言转换。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器 as 将 hello.s 翻译成机器语言指令,把这些指令打包成一种叫可重定 位目标程序的格式,并将结果保存在目标文件 hello.o 中。(hello.o 是一个二进制文 件)。
作用:将汇编代码根据特定的转换规则转换为二进制代码,也就是机器代码,机器代码也是计算机真正能够理解的代码格式。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
as hello.s -o hello.o
或者gcc -c hello.s -o hello.o
gcc -Og -c hello.c
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
4.3.1 可重定位目标文件ELF格式
典型的可重定位目标文件ELF格式如下图
4.3.2 命令
readelf -a hello.o > ./elf.txt(使用这一命令导出我们需要的 elf 的文件)
4.3.3 ELF头
ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。具体代码如下:
4.3.4 节头部表
不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。如下图所示
4.3.5 重定位节
当汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data。
重定位条目包含以下几个部分:需要被修改的引用的节偏移offset,被修改引用应该指向的符号symbol,类型type表明如何修改新的引用,还可能有偏移调整量addend。
本程序需要重定位的信息有:.rodata 中的模式串,puts,exit,printf,atoi, sleep ,getchar 。具体重定位节的信息如下图所示:
4.3.6 符号表
符号表存放在程序中定义和引用的函数和全局变量的信息。例如本程序中的 getchar、puts 、exit 等函数名都需要在这一部分体现,如下图所示:
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
通过objdump -d -r hello.o > hello_disass.s命令得到hello.o的反汇编代码
反汇编代码如下:
与第 3章的 hello.s 进行对照分析。 可以发现有如下几点不同:
(1)进制不同:hello.s 中对于数字的表示是十进制的,而 hello.o 反汇编之后数字的表示是十六进制的。
(2)分支转移:对于条件跳转,hello.s中给出的是段的名字,例如.L2 等来表示跳转的地址,而 hello.o反汇编文件的跳转命令后跟着的是需要跳转部分的偏移地址。
(3)函数调用:hello.s 中,call 指令后跟的是需要调用的函数的名称,而 hello.o 的反汇编代码中 call 指令后跟的是下一条指令的地址。
4.5 本章小结
本章现时介绍了汇编的概念和作用,然后将hello.s汇编为hello.o,并以hello.o为例介绍了ELF可重定位目标文件的格式,包括ELF头、节头部表、重定位节、符号表,尤其是对重定位条目进行了分析。最后通过对hello.o的反汇编,把反汇编代码和hello.s进行对照,从另一个角度介绍了汇编和重定位这个过程。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
注意:这儿的链接是指从 hello.o 到hello生成过程。
概念:链接是将各种不同文件的代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
作用:链接可以执行于编译时,也就是源代码被翻译成机器代码时;也可以执行于加载时,即程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。链接把预编译好了的若干目标文件合并成为一个可执行目标文件,使得分离编译成为可能,不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为可独立修改和编译的模块。当改变这些模块中的一个时,只需简单重新编译它并重新链接即可,不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件。
Linux下链接命令:
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等列出其各段的基本信息,包括各段的起始地址,大小等信息。
5.3.1命令
readelf -a hello > hello.elf
5.3.2 ELF头
一个16字节的序列开始,这个序列描述了生成该文件的系统字的大小和字节顺序。剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如课冲定位、可执行或者共享)、机器类型(如x86_64)、节头部表的文件偏移,以及节头部表中的条目的大小和数量。
5.3.3 节头表:
详细标识了每个节的名称、类型、地址、偏移量、大小、读取权限、对齐方式等,如.interp节,类型为PROGBITS,起始地址为0x4002e0,偏移量0x2e0,大小为0x1c,属性为A,即可执行,对齐方式为1字节。
5.3.4 .rel重定位节
在ELF表中有两个.rel节,分别是.rela.dyn和.rela.plt。内容有偏移量、信息、类型、符号值、符号名称等。在重定位节中可以看到符号名称有 puts, exit, printf, atoi, sleep, getchar等
5.3.5 .symtab节
存放在程序中定义和引用的函数和全局变量的信息,具体数据如下,其中存放了 main , puts , exit , printf , atoi , sleep , getchar等函数的信息。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
Data Dump中可看到加载到虚拟地址空间的hello程序。ELF文件中的Header告诉链接器运行时加载的内容,并提供动态链接的信息。提供了被载入后的虚拟地址空间与物理地址空间。
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照发现edb显示的各段虚拟地址空间与5.3中的一致。以.init为例,在5.3中可以看到它的起始地址是0x401000,大小为0x1b字节,考虑对齐后实际要占0x20字节,这可以在edb的data dump中找到。
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
5.5.1命令
objdump -d -r hello > hello_objdump.s
5.5.2 hello与hello.o的不同
(1)hello的反汇编文件比hello.o的反汇编文件多了很多新的函数,这些函数是链接过程中链接器从共享库中提取出来的,如getchar,puts,printf等;将其加入可执行目标文件中使其更加完整。
(2)在函数调用上,hello中的call后面跟着的是所调用函数的虚拟内存地址,不同于hello.o中的相对偏移地址,链接器为我们算好了跳转的位置。例如puts函数的虚拟地址如下图:
(3)在hello增加了.init节和.plt 节,和一些节中定义的函数。
(4) hello中无 hello.o 中的重定位条目,并且跳转和函数调用的地址在 hello 中都变成了虚拟内存地址。这是由于 hello.o 中对于函数还未进行定位,只是在.rel.text中添加了重定位条目,而 hello 进行定位之后自然不需要重定位条目。
5.5.3 链接的过程:
链接主要分为两个过程:符号解析和重定位。
(1)符号解析:目标文件定义和引用符号,符号解析将每个符号引用和一个符号定义关 联起来。
(2)重定位:编译器和汇编器生成从 0 开始的代码和数据节。链接器通过把每个符号 定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引 用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令, 不加甄别地执行这样的重定位。
5.6 hello的执行流程
使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址。
调用和跳转的各子程序与地址:
401000 <init>
401020 <.plt>
401090 <puts@plt>
4010a0 <printf@plt>
4010b0 <getchar@plt>
4010c0 <atoi@plt>
4010d0 <exit@plt>
4010e0<sleep@plt>
4010f0 <start>
401125 <main>
4011d0 < libc csu init>
401240 < libc csu fini>
401248 <_fini>
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb/gdb调试,分析在动态链接前后,这些项目的内容变化。要截图标识说明。
当程序调用一个由共享库定义的函数时,由于编译器无法预测这时候函数的地址是什么,因此这时,编译系统提供了延迟绑定的方法,将过程地址的绑定推迟到第一次调用该过程时。通过 GOT 和过程链接表 PLT的协作来解析函数的地址。在加载时,动态链接器会重定位 GOT中的每个条目,使它包含正确的绝对地址,而 PLT 中的每个函数负责调用不同函数。那么,通过观察 edb,便可发现 dl_init 后.got.plt 节发生的变化。
Hello.elf中的.got如下(可知GOT起始表位置为0x403ff0):
调用前:.got表位置在调用dl_init之前0x403ff0后的16个字节均为0
调用后:.got发生了变化,存入了地址。其中GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息,GOT[2]是动态链接器在1d-linux.so模块中的入口点,其余的每个条目对应于一个被调用的函数。
5.8 本章小结
在链接过程中,各种代码和数据片段收集并组合为一个单一文件。利用链接器, 分离编译成为可能,我们不用将应用程序组织为巨大的源文件,只是把它们分解为 更小的管理模块,并在应用时将它们链接就可以完成一个完整的任务。
通过对比hello与hello.o ,更好地理解了链接与重定位的相关过程、ELF文件各部分的含义、hello的执行过程、动态链接过程等。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
作用:
在现代系统上运行一个程序时,我们会得到一个假象,好像我们的程序是系统中唯一运行的程序一样。我们的程序好像独占处理器和内存。处理器好像无间断 地一条接一条执行我们程序中的指令,我们程序中的代码和数据好像是系统内存 中唯一的对象。这些假象是通过进程的概念提供的。进程提供给应用程序关键的抽象:
(1)一个独立的逻辑控制流,提供一个程序独占处理器的假象
(2)一个私有的 地址空间,提供一个程序独占地使用内存系统的假象。
6.2 简述壳Shell-bash的作用与处理流程
Shell 是一个交互式的应用程序,用来代表用户运行其他程序。Shell 通过执行一系列读取和求值步骤来工作,之后终止。读取步骤从用户处获取一个命令行输入。求值步骤解析这个命令行,并代表用户运行相应的程序。
- 在求值过程中,Shell 首先调用 parseline 函数,构造一个将会传递给 execve 的 argv 向量。
- 命令行解析完毕后,eval 函数会调用 builtin_command 函数来检查该命令是否为内置命令。如果命令行的第一个参数是 Shell 内置命令名,Shell 会立即解释并执行该命令;否则,Shell 会认为该参数是一个可执行目标文件的名字,并在新的子进程上下文中加载和运行该文件。
- 如果命令行的最后一个参数是 “&”,Shell 将不会等待该命令完成,而是在后台执行;否则表示该命令在前台执行,Shell 将等待它完成。
- 接下来,Shell 使用 waitpid 函数等待作业终止,并开始新一轮的读取和求值步骤。
6.3 Hello的fork进程创建过程
父进程通过调用 fork 函数创建一个新的运行的子进程。调用 fork函数后,新 创建的子进程几乎但不完全与父进程相同:子进程得到与父进程虚拟地址空间相同的但是独立的一份副本,包括代码、数据段、堆、共享库以及用户栈,子进程获 得与父进程任何打开文件描述符相同的副本,这意味着当父进程调用 fork时,子进程可以读写父进程中打开的任何文件。fork 被调用一次,却返回两次,子进程返回 0,父进程返回子进程的 PID(父进程和新创建的子进程之间最大的区别在于它们有不同的 PID),父进程和创建的子进程最大的区别在于PID不同。
执行hello时,fork后的进程在前台执行,因此创建它的父进程shell暂时挂起等待hello进程执行完毕。
6.4 Hello的execve过程
shell fork一个子进程后,exceve函数在子进程的上下文中加载并运行一个新程序hello。exceve 函数加载并运行可执行目标文件Hello,并带参数列表argv和环境变量列表envp。只有当出现错误时,exceve 才会返回到调用程序。所以,与 fork 一次调用返回两次不同,在 exceve 调用一次并从不返回。当加载可执行目标文件后, exceve 会将这个进程执行的原本的程序完全替换,它会删除已存在的用户区域,包括数据和代码;然后,映射私有区:为Hello的代码、数据、.bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的;之后映射共享区;最后把控制传递给当前的进程的程序入口。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
在计算机中,宏观上进程是并发执行的,这涉及一套复杂完备的机制。
在系统中,指令总是一条一条执行着的,这些指令可能并不来自同一个进程的程序,但是这些指令的地址会形成一个执行的序列,这个PC值的序列称为逻辑控制流。处理器的一个物理控制系流会被分成多个逻辑控制流,每个进程一个。这些逻辑流的执行是交错的,也就是每个进程轮流使用处理器,一个进程执行它的控制流的一部分的每一时间段叫做时间片。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策叫做调度,是由内核中称为调度器的代码处理的。内核调度了一个新的进程运行后,它就抢占当前进程,并使用上下文(内核为每个进程维持一个上下文,它是内核重启被抢占的进程所需的状态,包括通用目的寄 存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结 构的值。)切换机制将控制转移到新的进程:
- 保存当前进程的上下文
- 恢复某个先前被抢占的进程被保存的上下文
- 将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用时,可能会发生上下文切换,这时就存在着用户态与核心态的转换,如下图所示:
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.6.1异常类型与处理方式
异常类型:
处理方式:
(1)中断处理方式
(2)陷阱处理方式
(3)故障处理方式
(4)终止处理方式
中断异常:详见6.6.3
陷阱:hello程序中sleep函数进行了系统函数调用,会触发异常处理机制,进入内核模式进行处理。除了中断和陷阱两种异常,还有故障和终止异常,故障通常可以被恢复,例如缺页异常,终止异常会使程序直接终止。
6.6.2正常执行状态
6.6.3中断异常
来自I/O设备的信号。按下ctrl-c时内核向shell发送SIGINT信号,按下ctrl-z时内核向shell发送SIGTSTP信号,两种情况下控制都会转移到对应的信号处理程序。在SIGINT信号处理程序中,shell向前台进程组发送信号,使前台进程组的所有成员终止;在SIGTSTP信号处理程序中,使前台暂时挂起。
- 运行hello ctrl-c测试
如下图可以看到在按下ctrl-c后进程终止,这时使用ps命令就看不到hello进程了。
- 运行hello ctrl-z测试
按下ctrl-z后进程是被暂时挂起,并没有终止,因此使用ps、jobs等还可以看到它。
Ctrl-Z 后运行 pstree,可看到它打印出的信息:
Ctrl-Z 后运行 fg:因为之前运行jobs 是得知 hello 的jid 为 1,那么运行 fg 1 可 以把之前挂起在后台的hello 重新调到前台来执行,打印出剩余部分,然后输入hello 回车,程序运行结束,进程被回收。
6.6.4不停乱按
将屏幕的输入缓存到缓冲区,乱码被认为是命令, 不影响当前进程的执行。
6.6.5 Kill
ctrl-z后运行 Kill:重新执行进程,可以发现 hello的进程号为 3493,那么便 可通过 kill -9 3493 发送信号 SIGKILL 给进程 3493,它会导致该进程被杀死。然后再运行 ps,可发现已被杀死的进程 hello。
6.7本章小结
本章以进程为出发点,主要介绍了进程的概念和作用以及shell的作用流程,并且从进程的视角分析了hello执行的全过程,概括了现代计算机系统实现进程处理的机制,并以hello为例介绍了一部分异常和信号的相关内容。从创建进程到进程并回收进程,这一整个过程中需要各种各样的异常和中断等信息。程序的高效运行离不开异常、信号、进程等概念,正是这些机制支持 hello能够顺利地在计算机上运行。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
- 逻辑地址
逻辑地址指由程序产生的与段相关的偏移地址部分,也叫相对地址。要 经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。hello.c经过汇编生成的偏移地址为逻辑地址,这些地址需要通过计算,通过加上对应段的基地址才能得到真正的地址。C语言中,读取指针变量本身值(&操作),实际上这个值就是逻辑地址。
- 线性地址
线性地址是计算机系统中用于访问内存的一个概念。它是在使用虚拟内存的计算机架构中介于逻辑地址和物理地址之间的中间层地址。程序hello的代码会产生段中的偏移地址,加上相应段的基地址就生成了一个线性地址。
- 虚拟地址
CPU通过生成虚拟地址访问主存。有时我们也把逻辑地址称为虚拟地址。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的,是hello中的虚拟地址。
- 物理地址
物理地址是指计算机系统中实际访问内存硬件的地址。它用于直接定位计算机内存中的数据位置。物理地址在物理内存空间中是唯一的,不同于虚拟地址和线性地址,它们可以通过内存管理单元(MMU)转换为物理地址。物理地址是出现在 CPU 外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。在 hello的运行中,在访问内存时需要通过 CPU 产生虚拟地址,然后通过地址翻译得到一个物理地址,并通过物理地址访问内存中的位置。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由段选择符和段内偏移(段内偏移是逻辑地址中段选择器后的部分,表示相对于段基地址的偏移量)组成。段选择器指向段描述符表中的一个条目,该条目包含段的基地址和大小等信息。
段选择符是一个16位长的字段。可以通过段选择符的前13位,直接在段描述符表中找到一个具体的段描述符。
段描述符是存储在GDT或LDT中的一个数据结构,它包含段的基址、限制和访问权限等信息。全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。
给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据段选择器的Index,从GDT或LDT中找到对应的段描述符,从段描述符中提取段的基地址。线性地址通过将段基址与逻辑地址中的段内偏移相加得到: 线性地址=段基址+段内偏移。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
这些功能由软硬件联合提供,包括操作系统软件、MMU(内存管理单元)中的地址翻译硬件和一个存放在物理内存中叫做页表的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理页时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘和DRAM之间传送页。
7.4 TLB与四级页表支持下的VA到PA的变换
线性地址(虚拟地址)由虚拟页号 VPN 和虚拟页偏移 VPO 组成。首先,MMU 从线性地址中抽取出 VPN,并且检查 TLB,看他是否因为前面某个内存引用缓存 了 PTE 的一个副本。TLB 从 VPN 中抽取出 TLB 索引和 TLB 标记,查找对应组中 是否有匹配的条目。若命中, 将缓存的 PPN 返回给 MMU;若 TLB不命中时,VPN 被划分为四个片,每个片被用作到一 个页表的偏移量,CR3 寄存器包含 L1 页表的物理地址。VPN1 提供到一个 L1 PTE 的偏移量,这个 PTE 包含 L2 页表的基地址。VPN2 提供到一个 L2 PTE 的偏移量,依次类推。最后在 L4 页表中对应的 PTE中取出 PPN,若得到的 PTE 无效或标记不匹配,就产生缺页,内核需调入所需页面,重新运行加载指令,若有效,则取出 PPN。最后将线性地址中的 VPO 与PPN 连接起来就得到了对应的物理地址。
四级页表以Core i7为例,如下图:
7.5 三级Cache支持下的物理内存访问
当CPU发出一个内存请求时,MMU将虚拟地址转换为物理地址,并将该物理地址发送到L1缓存进行查找。物理地址被解析成三个部分:缓存标记(Cache Tag, CT)、缓存组索引(Cache Index, CI)和缓存偏移(Cache Offset, CO)。首先,使用CI在L1缓存中选择一个特定的缓存组,然后检查该组中的每个缓存条目。如果某个条目的标记与CT匹配且有效位为1,则检测到缓存命中,从缓存行的偏移量CO处读取数据,并将其返回给MMU,再由MMU传递给CPU。如果L1缓存未命中,则继续在L2缓存中执行相同的查找过程,如果L2缓存也未命中,再查找L3缓存。如果所有缓存都未命中,则从主存中获取所需数据,将其存入各级缓存中以备后用,最后再次请求从缓存中读取数据并返回给CPU。
7.6 hello进程fork时的内存映射
当fork 函数被 shell 进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的 PID,为了给这个新进程创建虚拟内存,它创建了当前进程mm_struct、区域结构和页表的原样副本。
它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork 在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve 函数在当前进程中加载并运行包含在可执行目标文件 hello 中的程序,从而有效地替代了当前程序。加载并运行 hello 需要以下步骤:
(1)删除已存在的用户区域:首先,execve 会清除当前进程的用户地址空间,包括所有与当前进程关联的内存区域。这些区域包括代码段、数据段、堆、栈以及其他映射的内存区域。
(2)映射私有区域:接下来,为新程序 hello 创建新的内存区域结构。这些区域包括代码段、数据段、bss段(未初始化数据段)和栈段。所有这些新的区域都是私有的,并且采用写时复制策略。这意味着在实际写入之前,这些区域共享同一个物理页面,但在写入时会创建一个新的副本。
(3)映射共享区域:如果 hello 程序依赖于共享对象(如动态链接库),这些对象会被动态链接到程序中。系统将这些共享对象映射到用户虚拟地址空间中的共享区域。共享对象的映射允许多个进程共享相同的库代码,从而节省内存。
(4)设置程序计数器(PC):最后,系统将程序计数器设置为 hello 程序的入口点。程序计数器指向的是新程序的起始指令地址,即 _start 函数所在的位置,这通常由链接器在生成可执行文件时确定。
7.8 缺页故障与缺页中断处理
缺页故障是一种可恢复的异常,当指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中时,就会发生缺页故障。这种情况需要将相应的页面从磁盘加载到内存中才能继续执行。以下是缺页故障的处理过程:
缺页中断处理过程:当指令引用一个虚拟地址时,如果对应的物理页面不在内存中,就会触发缺页故障。此时,处理器会产生一个缺页中断,调用内核中的缺页异常处理程序。缺页异常处理程序首先会确定缺页的原因和需要加载的页面。在确定了需要的页面后,调入新的页面。内存中的页表会被更新,以反映新加载的页面与相应虚拟地址之间的映射关系。完成页面加载后,处理程序会将控制权返回给引起缺页故障的指令。
当指令再次执行时,由于相应的物理页面已经被加载到内存中,虚拟地址到物理地址的映射存在于页表中,指令可以无故障地执行并访问需要的数据。这样,通过缺页异常处理程序,系统能够从磁盘中动态加载所需的页面,从而有效地支持大规模的虚拟内存。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
7.9.1定义
动态内存分配器在维护进程的虚拟内存区域(称为堆)时,将堆视为由不同大小的块组成的集合。每个块是一个连续的虚拟内存片段,可以是已分配的(用于存储数据)或空闲的(可供分配)。分配器有两种基本风格:显式分配器和隐式分配器。
7.9.2分配器的基本风格
显式分配器要求应用程序显式地释放任何已分配的块。当程序使用完某块内存后,必须明确调用释放函数将该块返回给内存分配器,以便重新分配。一个典型的例子是C标准库提供的malloc程序包。使用malloc分配内存后,程序员必须使用free函数来释放内存。
隐式分配器,也称为垃圾收集器,不要求程序员显式释放内存。相反,分配器自动检测哪些已分配的块不再被程序使用,然后释放这些块。垃圾收集器通过追踪程序中所有活动的指针,并自动回收不再使用的内存块,减少了内存泄漏和悬挂指针的问题。
7.9.3基本方法与策略:
动态内存管理主要有两种基本方法与策略,即带边界标签的隐式空闲链表分配器管理和显式空闲链表管理。
在带边界标签的隐式空闲链表分配器中,每个内存块由头部、有效载荷、可能的额外填充和尾部组成。头部和尾部分别包含块的大小和分配状态(已分配或空闲)。当应用程序请求一个大小为k字节的块时,分配器会在空闲链表中搜索符合要求的空闲块,并采用三种常见的放置策略之一:首次适配、下一次适配和最佳适配。首次适配策略从头开始搜索,找到第一个满足要求的空闲块;下一次适配策略从上次搜索结束的位置开始搜索;最佳适配策略则搜索整个链表,找到最小的、足以满足要求的空闲块。在释放已分配的块时,分配器会检查前后相邻的块是否空闲,如果是,则合并这些块以减少碎片。
显式空闲链表管理将空闲块组织为某种形式的显式数据结构,通常是链表。由于程序不需要空闲块的主体部分,因此可以在空闲块中存储指向其他空闲块的指针。例如,堆可以组织成一个双向链表,每个空闲块包含前驱指针和后继指针,分别指向前一个和后一个空闲块。每个块还包含头部和尾部,用于记录块的大小和分配状态。显式空闲链表管理的放置策略与隐式空闲链表分配器管理的放置策略一致,都是首次适配、下一次适配和最佳适配。通过这两种方法和策略,动态内存分配器能够有效管理堆内存,满足不同大小的内存分配请求,同时尽量减少内存碎片,提高内存利用率。
7.10本章小结
本章主要以hello进程为例介绍了程序的存储管理。分析了逻辑地址、线性地址、虚拟地址、物理地址的关系区别以及它们之间的转换,也分析了实现虚拟地址向物理地址映射的机制和hello进程fork、execve时的内存映射。同时介绍了在发生缺页异常的时候系统将会如何处理这一异常。最后介绍了动态内存分配的定义、分配器的基本风格以及基本方法与策略。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
所有的 I/O 设备(例如网络、磁盘和终端) 都被模型化为文件,而所有的输入输出都被当做对相应文件的读和写来执行。
设备管理:unix io接口
这种将设备优雅地映射为文件的方式,允许 Linux内核引出一个简单、低级的应用接口,称为 Unix I/O。
主要的Unix I/O系统调用包括:
open:打开文件或设备,返回一个文件描述符。
read:从文件或设备读取数据。
write:向文件或设备写入数据。
close:关闭文件或设备。
lseek:改变当前文件位置。
8.2 简述Unix IO接口及其函数
8.2.1 Unix IO接口操作:
(1)打开文件:内核返回一个非负整数的文件描述符,通过对此文件描述符对文件进行所有操作。Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。
(2)改变当前的文件位置:文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。
(3)读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k。
(4)关闭文件:当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中。
8.2.3 Unix IO 函数:
(1)打开文件:int open(char *filename, int flags, mode_t mode)
open 函数将 filename 转换为一个文件描述符,并且返回描述符数字,返回的 描述符总是在进程当中没有打开的最小描述符。flags 参数指明了进程打算如何访 问这个文件,同时也可以是一个或者更多位掩码的或,为写提供给一些额外的指示。 mode 参数指定了新文件的访问权限位(仅在创建新文件时有效)。成功打开文件时,open函数返回一个文件描述符,用于标识打开的文件;否则返回-1,并设置全局变量errno来指示出错的原因。
(2)关闭文件:int close(int fd)
close函数关闭打开的一个文件,fd 是需要关闭的文件的描述符。成功返回 0,出错返回-1。
(3)读文件:ssize_tread(intfd, void *buf, size_tn)
read 函数从描述符为 fd 的当前文件位置复制最多 n个字节到内存位置buf。返回值-1表示错误,返回值 0 表示 EOF,否则返回值表示的是实际传送的字节数量。
(4)写文件:ssize_t write(int fd, const void *buf,size_tn)
Write函数从内存位置 buf 复制至多 n 个字节到描述符 fd 的当前文件位置。返回值-1表示出错,否则返回值表示内存向文件 fd 输出的字节的数量。
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
printf函数:printf 的输入参数是 fmt,但是后面是不定长的参数,同时在 printf 内存调用了两个函数,一个是 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函数:
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函数执行流程:
vsprintf 函数将所有的参数内容格式化之后存入 buf,然后返回格式化数组的 长度。write 函数将 buf 中的 i 个元素写到终端。从 vsprintf 生成显示信息,到
write 系统函数,到陷阱-系统调用 int 0x80 或 syscall。字符显示驱动子程序:从
ASCII 到字模库到显示vram(存储每一个点的 RGB 颜色信息)。显示芯片按照刷 新频率逐行读取 vram,并通过信号线向液晶显示器传输每一个点(RGB 分量)。
8.4 getchar的实现分析
getchar函数利用系统调用read来获取用户按键的ASCII码,直到检测到回车键才结束。调用getchar时,程序会暂停等待用户的按键输入,并将输入的字符存储在键盘缓冲区中,直到用户按下回车键。一旦用户按下回车键,getchar函数开始从标准输入流中读取一个字符,并返回该字符的ASCII码值。如果出现错误,函数返回-1,并将用户输入的字符回显到屏幕上。如果用户在按下回车键之前输入了多个字符,这些字符会保留在键盘缓冲区中,等待后续的getchar调用读取。后续的getchar调用会直接从缓冲区中读取字符,直到缓冲区中的字符被完全读取,然后再次等待用户的按键输入。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了 linux系统中的 I/O 设备基本概念和管理方法,同时简单分析了printf 和 getchar 函数的实现。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
Hello 的一生是简单的但是又蕴含着每一个 c 语言程序执行前的必经之路:
- 预处理,hello.c 文件通过 cpp 的预处理,得到了扩展后的源程序文件 hello.i
2. 编译,hello.i 通过编译器的处理,被翻译成了汇编语言程序hello.s
3. 汇编,在汇编器 as 的处理下,hello.s 生成了可重定位文件hello.o
4. 链接,链接器将重定位目标文件链接为可执行目标文件hello
5. 生成子进程,在 shell 中输入指定命令 ./hello 2022111067 zry 1
调用 fork 函数为 hello 生成字进程。
6. Execve 加载并运行 hello 程序,将它映射到对应虚拟内存区域,并依需求 载入物理内存。
7. I/O 设备:在 hello 程序中存在输入与输出,这些部分与 printf,getchar 函 数有关,这些函数与 linux 系统的I/O 设备密切相关。
8. Hello 将在 cpu 流水线中执行每一条指令。
9.异常处理:若有Ctrl+Z或Ctrl+C等信号,hello进程捕获这些信号,并执行相应的异常处理。
10. 程序运行结束后,父进程会对其进行回收,内核把它从系统中清除。 这样,hello 就结束了它的一生。
操作系统是计算机中很重要的一部分,通过了这次大作业回顾了csapp的主要章节,包括汇编指令的含义,CPU的运行逻辑,程序的优化方法,存储器的层次结构,异常处理,进程以及系统级IO等,通过对hello一生的分析对预处理,编译,汇编,链接都有了更加深入的了解。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件名 | 文件的作用 |
hello.i | 预处理后的文件 |
hello.s | 汇编文件 |
hello.o | 可重定位目标文件 |
hello | 可执行目标文件 |
elf.txt | Hello.o 的 ELF 格式 |
hello_disass.s | Hello.o 的反汇编代码 |
hello.elf | Hello的elf格式文件 |
hello_objdump.s | Hello的反汇编代码 |
hello.c | C文件 初始文件 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[2]《深入理解计算机系统》 RandalE.Bryant David R.O ’Hallaron 机械工业出版 社
[3] https://www.cnblogs.com/pianist/p/3315801.html
[4]https://docs.microsoft.com/zh-cn/cpp/build/creating-precompiled-header- files?view=msvc-160
[5] CSDN 博客 ELF 可重定位目标文件格式
[6]https://zhuanlan.zhihu.com/p/455061631
(参考文献0分,缺失 -1分)