本论文将CSAPP课程所学内容通过hello小程序的一生,对我们所学进行全面的梳理与回顾。主要在Ubuntu下进行相关操作,对hello程序从.c文件到可执行文件再到被系统回收的过程进行了全面的梳理与剖析。使用了许多Ubuntu下的操作工具,进行细致的历程分析,目的是加深对计算机系统的了解,同时进一步对所学知识进行复习。
关键词:hello;程序调用;计算机系统;Ubuntu ;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
- Program: 在 editor 中键入代码得到 hello.c 程序
- Process: hello.c(在 Linux 中),经过过 cpp 的预处理、 ccl 的编译、 as 的汇编、 ld 的链接最终成为可执目标程序 hello。 在 shell 中键入启动命令后, shell 为其 fork,产生子进程。
020:
- shell 为 hello 进程 execve,映射虚拟内存,进入程序入口后程序开始载入物理内存。
- 进入 main 函数执行目标代码, CPU 为运行的 hello 分配时间片执行逻辑控制流。
- 当程序运行结束后, shell 父进程负责回收 hello 进程,内核删除相关数据结构。
1.2 环境与工具
硬件环境:处理器:AMD® Ryzen 7 6800H with radeon graphics × 2
RAM:16.00GB 系统类型:64位操作系统,基于x64的处理器
软件环境:Windows11 64位;Ubuntu 22.04.4 LTS
开发与调试工具:gcc,as,ld,vim,edb,gdb,readelf,VScode
1.3 中间结果
文件名 |
|
预处理后的文件 |
hello.i |
编译之后的汇编文件 |
hello.s |
汇编之后的可重定位目标文件 |
hello.o |
链接之后的可执行目标文件 |
Hello |
Hello.o 的 ELF 格式 |
Elf.txt |
Hello.o 的反汇编代码 |
Disas_hello.s |
hello的ELF 格式 |
hello1.elf |
hello 的反汇编代码 |
hello1_objdump.s |
1.4 本章小结
本章对hello进行了一个总体的概括,首先介绍了P2P、020的意义和过程,介绍了作业中的硬件环境、软件环境和开发工具,最后简述了从.c文件到可执行文件中间经历的过程。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。
预处理的作用:
- 将源文件中用#include 形式声明的文件复制到新的程序中。比如 hello.c 第 7-9 行中的#include<stdio.h> 等命令告诉预处理器读取系统头文件 <stdio.h> <unistd.h> <stdlib.h> 的内容,并把它直接插入到程序文本中。
- 宏定义与替换。预处理器允许定义带参数的宏,在编译前会用实际值替换用#define 定义的字符串。
- 特殊符号,预编译程序可以识别一些特殊的符号,预编译程序对于在源程序中出现的这些串将用合适的值进行替换。
- 注释去除。在预处理阶段,所有注释(// 和 /* ... */)都会被移除,确保它们不会干扰后续的编译过程。
2.2在Ubuntu下预处理的命令
指令:cpp hello.c > hello.i
图1 ubuntu中的预处理过程
2.3 Hello的预处理结果解析
查看编译结果hello.i显示,文本内容已经达到3092行;原来hello.c的主函数被放到了文末(3078~3092行)。
图2 hello.i末尾的hello.c主函数
在这之前出现的是头文件<stdio.h> <unistd.h> <stdlib.h> 的内容依次展开,如果头文件中仍然有以字符“#”开头的内容,则预处理器继续对其进行处理,最终的hello.i文件中没有宏定义、文件包含及条件解析等内容。以stdio.h的展开为例:
图3 hello.i中的库文件展开
2.4 本章小结
本章主要介绍了预处理(包括头文件的展开、宏替换、去掉注释、条件编译)的概念和作用,以及Ubuntu下预处理的cpp指令,同时针对hello.c文件的预处理结果hello.i进行了文本文件解析,详细了解了预处理的内涵。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译的概念:
编译:1、利用编译程序从源语言编写的源程序产生目标程序的过程。 2、用编译程序产生目标程序的动作。编译就是把高级语言变成计算机可以识别的二进制语言,计算机只认识1和0,编译程序把人们熟悉的语言换成二进制的。 编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
编译的作用:
1.编译器首先会检查源代码的语法是否正确。如果发现语法错误,编译器会生成错误信息并终止编译过程。
2.在语法检查之后,编译器会进行语义分析,确保代码的逻辑和意义是正确的。例如,检查变量是否在使用前已声明,函数调用时参数的类型和数量是否匹配等。
3. 编译器会尝试优化代码,以提高生成的目标代码的性能。例如,移除不必要的计算、简化表达式、在适当的时候使用寄存器而不是内存等。
4. 编译器会将经过优化的中间表示生成目标代码。目标代码可以是机器码,或者是某种中间代码(如字节码)。
5. 链接器将多个目标文件和库文件链接在一起,生成最终的可执行文件。链接阶段可以解决符号引用问题,即将函数调用和变量引用解析到实际的内存地址。
6. 在编译过程中,编译器不仅会进行语法和语义检查,还会检测其他类型的错误,如类型不匹配、未定义的变量或函数等。
(注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序)
3.2 在Ubuntu下编译的命令
指令:gcc -S hello.i -o hello.s
图5 编译指令
3.3 Hello的编译结果解析
3.3.1.1常量
在if语句
if(argc!=5)
中,常量5的值保存在.text中,作为指令的一部分:
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
cmpl $5, -20(%rbp)
je .L2
同理,语句
for(i=0;i<10;i++){
printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);
sleep(atoi(argv[4]));
}
中的常量0,10,1,2,3,4也被保存在.text节中。
在
printf(" Hello 2022113291 毛瑞鑫 18691707113 3!\n");
中出现的常量则被保存在.rodata中作为只读常量。
.LC0:
.string " Hello 2022113291 \346\257\233\347\221\236\351\221\253 18691707113 \347\247\222\346\225\260\357\274\201"
3.3.1.2 变量
全局变量:
初始化的全局变量储存在.data节,它的初始化不需要汇编语句,而是直接完成的。
局部变量:
局部变量存储在寄存器或栈中。程序中的局部变量i定义句:
int i;
在汇编代码中
.L2:
movl $0, -4(%rbp)
jmp .L3
此处是循环前i=0的操作,i被保存在栈当中、%rsp-4的位置上。
3.3.2 算术操作
在for循环语句中,使用了自增操作符(++):
for(i=0;i<10;i++)
在每次循环的结束时执行,对i进行一次增加,栈上储存i的值加1。
call sleep@PLT
addl $1, -4(%rbp)
3.3.3 控制转移
程序在第14行使用if语句来判断传入参数是否等于5:
if(argc!=5)
{
printf(" Hello 2022113291 毛瑞鑫 18691707113 3 \n");
exit(1);
}
实现该if语句的汇编代码为
cmpl $5, -20(%rbp)
je .L2
je用于判断cmpl产生的条件码,若两个操作数的值不相等则跳转到指定地址。
程序在第18行使用了for循环语句:
for(i=0;i<10;i++)
{
printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);
sleep(atoi(argv[4]));
}
实现for循环的汇编代码为:
L3:
cmpl $9, -4(%rbp)
jle .L4
jle用于判断cmpl产生的条件码,若后一个操作数的值小于等于前一个操作数的值(9)时则跳转到指定地址。
3.3.4 函数操作
main ( ):
参数传递:传入参数argc和argv[],分别用寄存器%rdi和%rsi存储。
函数调用:被系统启动函数startproc调用。
函数返回:将%eax的值置为0
main:
.LFB6:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $32, %rsp
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
cmpl $5, -20(%rbp)
je .L2
leaq .LC0(%rip), %rax
movq %rax, %rdi
call puts@PLT
movl $1, %edi
call exit@PLT
printf ( ):
参数传递:call puts时只传入了字符串参数首地址;for循环中call printf时传入了 argv[1]和argc[2]的地址。
函数调用:if判断满足条件后调用,for循环中被调用。
源代码1:
printf(" Hello 2022113291 毛瑞鑫 18691707113 3 \n");
汇编代码1:
.string " Hello 2022113291 \346\257\233\347\221\236\351\221\253 18691707113 \347\247\222\346\225\260\357\274\201"
.LFB6:
cmpl $5, -20(%rbp)
je .L2
leaq .LC0(%rip), %rax
call puts@PLT
源代码2:
printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);
汇编代码2:
.L4:
movq -32(%rbp), %rax
addq $24, %rax
movq (%rax), %rcx
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq %rax, %rsi
leaq .LC1(%rip), %rax
movq %rax, %rdi
movl $0, %eax
call printf@PLT
exit ( ):
参数传递:传入的参数为1,再执行退出命令
函数调用:if判断条件满足后被调用.
源代码:
exit(1);
汇编代码:
movl $1, %edi
call exit@PLT
sleep():
参数传递:传入参数atoi(argv[4]),
函数调用:for循环下被调用,call sleep
源代码:
sleep(atoi(argv[4]));
汇编代码:
movq %rax, %rdi
call atoi@PLT
movl %eax, %edi
call sleep@PLT
addl $1, -4(%rbp)
getchar ( ) :
函数调用:在main中被调用,call getchar
源代码:
getchar();
汇编代码:
.L3:
call getchar@PLT
movl $0, %eax
Leave
3.3.5 数组/指针/结构操作
main函数所传递的参数中含有指针数组char *argv[]
int main(int argc,char *argv[])
在argv数组中,argv[0]指向输入程序的路径和名称,argv[1],argv[2]和argv[3]分别表示3个字符串。
因为char* 数据类型占8个字节,由
LFB6:
subq $32, %rsp
movl %edi, -20(%rbp)//argc存储于%edi
movq %rsi, -32(%rbp)//argv存储于%rsi
.L4:
leaq .LC1(%rip), %rax
movq %rax, %rdi
movl $0, %eax
call printf@PLT
.LC1:
.string "Hello %s %s %s\n"
.text
.globl main
.type main, @function
对比原函数可知通过%rax-8,%rax-16,%rax-24,分别得到argv[1],argv[2]和argv[3]三个字符串。
3.4 本章小结
本章主要介绍了编译的概念以及过程。同时通过分析代码表现了c语言如何转换成为汇编代码。介绍了汇编代码如何实现常量、变量、传递参数以及分支和循环。编译器所做的工作,就是通过语法检查和代码优化,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或使用汇编代码表示。通过分析汇编代码,可以了解程序的运行过程与参数的传递转换,进而从计算机的角度理解代码的运行过程。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:
驱动程序运行汇编器,将汇编语言翻译成可执行的机器语言的过程称为汇编,同时这个机器语言文件也是可重定位目标文件。
汇编的作用:
汇编就是将高级语言转化为机器可直接识别执行的代码文件的过程,汇编器将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o 目标文件中。.o 文件是一个二进制文件,它包含程序的指令编码。
(注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。)
4.2 在Ubuntu下汇编的命令
指令:as hello.s -o hello.o
图6 汇编指令
4.3 可重定位目标elf格式
指令:readelf -a hello.o > ./elf.txt
图7 生成可重定位目标elf格式文件
分析:
1.ELF 头:
包含了系统架构,编码方式,ELF头大小,节的大小和数量等一系列信息。Elf头内容如下:
图8 ELF头
2.节头:
描述了.o文件中出现的各个节的类型、位置、所占空间大小等信息。
图9 节头
表述了各个段引用的外部符号等,在链接时,需要通过重定位节对这些位置的地址进行修改。链接器会通过重定位条目的类型判断该使用什么养的方法计算正确的地址值,通过偏移量等信息计算出正确的地址。
本程序需要重定位的信息有:.rodata中的模式串,puts,exit,printf,atoi,sleep,getchar这些符号。
图10 重定位节
4.符号表:
.symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
图11 符号表
4.4 Hello.o的结果解析
指令:objdump -d -r hello.o > disas_hello.s
图12 反汇编指令
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
图13 反汇编文件(部分)
分析hello.o的反汇编,并与第3章的 hello.s进行对照分析:
- 数的表示:hello.s中的操作数为十进制,hello.o反汇编代码中的操作数是十六进制。
- 分支转移:跳转语句之后,hello.s中是.L2和.LC1等段名称,而反汇编代码中跳转指令之后是相对偏移的地址,也即间接地址。
- 函数调用:hello.s中,call指令使用的是函数名称,而反汇编代码中call指令使用的是main函数的相对偏移地址。因为函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
4.5 本章小结
本章对汇编结果进行了详细的介绍。经过汇编器的操作,汇编语言转化为机器语言,hello.o可重定位目标文件的生成为后面的链接做了准备。通过对可重定位目标elf格式进行了详细的分析,和对比hello.s和hello.o反汇编代码的区别,能够更深刻地理解汇编语言到机器语言实现的转变。同时对hello.o文件进行反汇编,将disas_hello.s与之前生成的hello.s文件进行了对比。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接的概念:
链接是一个程序,将一个或多个由编译器或汇编器生成的代码和数据部分外加库收集(符号解析和重定位)起来并组合成一个可执行文件的过程。
链接的作用:
令源程序节省空间而未编入的常用函数文件(如printf.o)进行合并,生成可以正常工作的可执行文件。这令分离编译成为可能,节省了大量的工作空间。
5.2 在Ubuntu下链接的命令
指令:ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
图14 链接指令
5.3 可执行目标文件hello的格式
指令:readelf -a hello > hello1.elf
1.ELF 头:
图15 ELF头
2.节头:
展示了各个节的大小、类型、地址、偏移量和其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
图16 节头
程序头表(Program Header)显示在运行时使用的段(Segments),而节头表(Section Header)则列出了二进制文件的所有节(Sections)的集合。程序头表主要用于运行时加载和链接。
图17 程序头
5.4 hello的虚拟地址空间
使用edb加载hello,数据转储窗口可以查看加载到虚拟地址中的 hello 程序。查看 ELF 格式文件中的程序头部分,它告诉链接器运行时加载的内容,并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的各方面的信息。在下面可以看出,程序包含PHDR,INTERP,LOAD ,DYNAMIC,NOTE ,GNU_STACK,GNU_RELRO几个部分,如下图所示。
图18 edb加载后的数据转储部分
其中PHDR 保存程序头表。INTERP 指定在程序已经从可执行文件映射到内存之后,必须调用的解释器。LOAD 表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据、程序的目标代码等。DYNAMIC 保存了由动态链接器使用的信息。NOTE 保存辅助信息。GNU_STACK是权限标志,用于标志栈是否是可执行。GNU_RELRO为指定在重定位结束之后哪些内存区域是需要设置只读。
5.5 链接的重定位过程分析
指令:objdump -d -r hello > hello_objdump.s
图19 反汇编指令
5.5.1分析hello与hello.o的不同,说明链接的过程。
1.链接增加新的函数:在hello中链接加入了在hello.c中用到的库函数,如exit、printf、sleep、getchar等函数。
2.增加的节:hello中增加了.init和.plt节,和一些节中定义的函数。
图20新增.init和.plt节
3.函数调用:hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
4.地址访问:hello反汇编的代码有明确的虚拟地址,完成了重定位。
5.5.2结合hello.o的重定位项目,分析hello中对其怎么重定位的。
重定位的过程分为两大步:
1.重定位节和符号定义:在这一步中,连接器将所有相同类型的节合并成为同一类型的新的聚合节。例如,来自所有输入模块的.data节全部被合并成一个节,这个节成为输出的可执行目标文件的.data节。
2.重定位节中的符号引用:在这一步中,连接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时地址。要执行这一步,连接器依赖于可重定位条目,及5.3节中分析的那些数据。
5.6 hello的执行流程
使用edb执行hello的结果:
图21使用edb执行hello的结果
结合hello的反汇编结果,可以得到hello的执行流程:(地址仅列出后6位)
401000 <_init>
401020 <.plt>
401090 <puts@plt>
4010a0 <printf@plt>
4010b0 <getchar@plt>
4010c0 <atoi@plt>
4010d0 <exit@plt>
4010e0 <sleep@plt>
4010f0 <_start>
401120 <_dl_relocate_static_pie>
401125 <main>
402008 <_IO_stdin_used+0x8>
40203a <_IO_stdin_used+0x3a>
4011c8 <_fini>
5.7 Hello的动态链接分析
在elf中查看与动态链接相关的段:
图22 elf中与动态链接相关的段
.got:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
通过调试可以看出共享链接库代码是动态的目标模块,对动态链接的重定位过程就是在程序开始运行或者调用程序加载时,自动加载该代码到任意的一个内存地址,并和一个在目标模块内存中的应用程序链接起来。在plt和got中分别存放着链接器的目标变量和函数的运行时地址。一个动态的链接器通过静态的过程偏移链接表plt+got链接器实现函数的动态过程链接,这样它就包含了正确的绝对运行时地址。
5.8 本章小结
本章主要了解了在hello程序在linux中链接的过程。通过查看hello的虚拟地址空间,并且对比hello与hello.o的反汇编代码,得到了hello的执行流程,更好地掌握了链接与之中重定位的过程。同时知道了hello会在它运行时要求动态链接器加载和链接某个共享库,而无需在编译时将那些库链接到应用中。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
狭义上的进程指的就是一个执行中程序的实例。广义上的进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
进程的作用:
进程提供给应用程序的关键抽象:一个独立的逻辑控制流,如同程序独占处理器;一个私有的地址空间,如同程序独占内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:Shell 是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。Shell 有自己的编程语言用于对命令的编辑,它允许用户编写由 shell 命令组成的程序。同时Shell还负责连接用户和操作系统以及内核。
bash命令的执行分为四大步骤:输入、解析、扩展和执行。
1.输入:在交互模式下,输入来自终端。bash使用GNU Readline库处理用户命令输入。
2.解析:解析阶段的主要工作为词法分析和语法解析。词法分析指分析器从Readline或其他输入获取字符行,根据元字符将它们分割成word,并根据上下文环境标记这些word(确定单词的类型)。语法解析指解析器和分析器合作,根据各个单词的类型以及它们的位置,判断命令是否合法以及确定命令类型。
3.扩展:扩展阶段对应于单词的各种变换,最终得到可用于执行的命令。
4.执行。
6.3 Hello的fork进程创建过程
根据shell的处理流程,可以推断,输入命令执行hello后,父进程如果判断不是内部指令,即会通过fork函数创建子进程。子进程与父进程近似,并得到一份与父进程用户级虚拟空间相同且独立的副本——包括数据段、代码、共享库、堆和用户栈。父进程打开的文件,子进程也可读写。二者之间最大的不同或许在于PID的不同。Fork函数只会被调用一次,但会返回两次,在父进程中,fork返回子进程的PID,在子进程中,fork返回0。
6.4 Hello的execve过程
execve函数用于装载一个可执行文件以进程为单位加载到内存中。只有当出现错误时,例如找不到Hello时,execve才会返回到调用程序,这里与一次调用两次返回的fork不同。
在execve加载了Hello之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下的原型:
int main(intargc , char **argv , char *envp);
execve系统调用的执行过程:
- 删除已存在的用户区域(自父进程独立)。
- 映射私有区:为Hello的代码、数据、.bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的。
- 映射共享区:比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。
- 设置PC:exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
6.5 Hello的进程执行
上下文信息:操作系统使用一种称为上下文切换的较高层次的异常控制流来实现多任务。由于每个CPU只能同时处理一个进程,而很多时候系统中有很多进程都要去运行,因此处理器只能一段时间就要切换新的进程去运行,这时就需要先行存储目前进程的状态,再将欲执行的进程之状态读回CPU中。而实现不同进程中指令交替执行的机制称为进程的上下文切换。
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片,它是分时操作系统分配给每个正在运行的进程微观上的一段CPU时间。
示例:printf进程的调度过程
图23 进程的调度过程
初始时,控制流在hello内,处于用户模式
调用系统函数printf后,进入内核态,此时间片停止。
2s后,发送中断信号,转回用户模式,继续执行指令。
调度的过程:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。
以执行printf函数为例,printf函数请求调用打印进程,printf将内核抢占,进入倒计时,当倒计时结束后,hello程序重新抢占内核,继续执行。
用户态与核心态转换:
从用户态到内核态切换可以通过三种方式:
系统调用:用户态进程主动切换到内核态的方式.用户态进程通过系统调用申请使用操作系统的提供的程序完成操作.系统调用本身就是中断.
异常:当CPU在执行运行在用户态下的程序时.发生了某些事先不可知的异常.这时会触发由当前运行进程切换到处理此异常的内核相关程序中.也就转到了内核态.比如缺页异常.
外设中断:当外设完成用户的请求时.会向CPU发送中断信号.
6.6 hello的异常与信号处理
正常运行:
图24 正常运行
异常类型:
类别 |
原因 |
异步/同步 |
返回行为 |
中断 |
来自I/O设备的信号 |
异步 |
总是返回到下一条指令 |
陷阱 |
有意的异常 |
同步 |
总是返回到下一条指令 |
故障 |
潜在可恢复的错误 |
同步 |
可能返回到当前指令 |
终止 |
不可恢复的错误 |
同步 |
不会返回 |
处理方式:
图 25 中断处理方式
图 26 陷阱处理方式
图 27 故障处理方式
图 28 终止处理方式
按下CTRL+Z:进程收到 SIGSTP 信号, hello 进程挂起。
用ps查看其进程PID,可以发现hello的PID是13146;再用jobs查看此时hello的后台 job号是1,调用 fg 1将其调回前台。
图29 按下CTRL+Z
按下CTRL+C:进程收到 SIGINT 信号,结束 hello。在ps中查询不到其PID,在job中也没有显示,可以看出hello已经被彻底结束。
图30 按下CTRL+C
按下回车:程序换行,运行情况不变。
图31 按下回车
乱按:只是将屏幕的输入缓存到缓冲区。乱码被认为是命令。
图32 乱按
Kill命令:挂起的进程被终止,在ps中无法查到到其PID。显示程序被强制中止,
图33 挂起状态下输入kill命令
6.7本章小结
本章了解了hello进程的执行过程。在hello运行过程中,内核有选择对其进行管理,决定何时进行上下文切换。何时进行内核态与用户态的切换。在hello的运行过程中,当接受到不同的异常信号时,异常处理程序将对异常信号做出回应,执行相应的代码,每种信号都有不同的处理机制,对不同的异常信号,hello也有不同的处理结果。
第7章 hello的存储管理
7.1 hello的存储器地址空间
- 逻辑地址:程序经过编译后产生的与段相关的偏移地址部分(hello.o)。
- 线性地址:逻辑地址向物理地址转化过程中的一步,逻辑地址经过段机制后转化为线性地址。程序hello的代码会产生逻辑地址,或者说是(即hello程序)段中的偏移地址,它加上相应段的基地址就生成了一个线性地址。
- 虚拟地址:有时我们也把逻辑地址称为虚拟地址。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的,是hello中的虚拟地址。
- 物理地址:CPU通过地址总线的寻址,对应地址物理内存的地址信号,是地址变换的最终结果地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式存储管理中以段为单位分配内存,每段分配一个连续的内存区,但各段之间不要求连续,内存的分配和回收类似于动态分区分配,由于段式存储管理系统中作业的地址空间是二维的,因此地址结构包括两个部分:段号和段内位移。
段式管理地址变换过程:在段式管理地址变换过程中,和页式变换基本相同,先要为运行的进程建立一个段表。段表包括:段号、段长、存储权限、状态、起始地址、修改位、增补位。在段式系统中,分段的共享是通过两个作业的段表中相应表目都指向被共享部分的同一个物理副本来实现的,因为段是一个完整的逻辑信息,所以可以共享,但是页不完整,难以实现共享。 不能修改的过程称为纯过程或可重入过程。这样的过程和不能修改的数据是可以共享的,而可修改的程序和数据则不能共享。
图34 段式管理
段式管理的特点:
优点: 1、提供了内外存统一管理的虚拟实现方案
2、段式虚存每次交换的是一个程序段或数据段
3、在段式管理中,段长可以根据需要动态扩充
不足: 1、要求更多的硬件支持,提高了机器的成本
2、每段的长度受内存可用空闲区大小的限制
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
图35 页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
MMU把虚拟地址(VA)转化成物理地址(PA)是通过查询页表(PTE)实现的,PTE中存储了虚拟页到物理页的映射关系,PTE是常驻内存的一个表,如果CPU每次查询都去访问内存访问PTE的话速度太慢,所以引入了TLB(与Cache类似),TLB是MMU中一个小的具有较高相联度的缓存,其运行机理类似于Cache,只不过存储的只是PTE而已,通过这样的缓存极大的提高了PTE的访问效率,但对于地址空间位64位的系统来讲,PTE占用了非常多的内存,于是引入了多级页表,第一级页表常驻内存,而其它的级数只在用到的时候创建放入内存中,这样极大的减少了内存的需要。
在访问时,MMU通过把根据虚拟地址查表一级PTE,PTE根据虚拟地址指向下一级页表,下一级页表又根据虚拟地址指向下下级页表,到第四级页表时查询得到具体的物理页号(PPN),根据PPN和VPO(虚拟页面偏移量与物理页面偏移量PPO相同),就可以访问到具体的物理内存了。
图36 使用k级页表进行翻译
7.5 三级Cache支持下的物理内存访问
CPU发送一条虚拟地址,随后MMU按照上述操作获得了物理地址PA。根据cache大小组数的要求,将PA分为CT(标记位)CS(组号),CO(偏移量)。根据CS寻找到正确的组,比较每一个cacheline是否标记位有效以及CT是否相等。如果命中就直接返回想要的数据,如果不命中,就依次去L2,L3,主存判断是否命中,当命中时,将数据传给CPU同时更新各级cache的cacheline(如果cache已满则要采用换入换出策略)。
图37 3级Cache
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,同时为这个新进程创建虚拟内存。
它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。
加载并运行 hello 需要以下几个步骤:
删除当前进程虚拟地址中已存在的用户区域
映射私有区域,为新程序的代码、数据、bss和栈创建新的区域结构,所有这些新的区域都是私有的、写时复制的
映射共享区域,将hello与libc.so动态链接,然后再映射到虚拟地址空间中的共享区域
设置当前进程上下文程序计数器(PC),使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
如果程序执行过程中发生了缺页故障,则内核调用缺页处理程序。处理程序执行如下步骤:.
1.检查虚拟地址是否合法,如果不合法则触发一个段错误,程序终止
2.检查进程是否有读、写或执行该区域页面的权限,如果不具有则触发保护异常,程序终止
3.两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表
4.将控制转移给hello进程,再次执行触发缺页故障的指令。
7.9动态存储分配管理
动态储存分配管理使用动态内存分配器来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
动态内存分配主要有两种基本方法与策略:隐式空闲链表和显式空闲链表。
(1)隐式空闲链表的空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。我们需要某种特殊标记的结束块,即一个设置了已分配位而大小为零的终止头部。
用隐式空闲链表来组织堆。阴影部分是已分配块,没有引用的部分是空闲块。头部标记为(大小(字节)/已分配位)
(2)显式空闲链表是将空闲块组织为某种形式的显式数据结构。堆被组织为一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继的指针。使用双向链表而不是隐式空闲链表使得首次适配的时间从块数的线性时间减小到空闲块数的线性时间。
7.10本章小结
本章主要介绍了 hello 的存储器地址空间、 intel 的段式管理、 hello 的页式管理,在指定环境下介绍了 VA 到 PA 的变换、物理内存访问,还介绍 hello 进程 fork 时的内存映射、 execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件(Linux 把这些设备当作一种特殊文件整合到文件系统中,一般通常位于 /dev 目录下。可以使用与普通文件相同的方式来对待这些特殊文件。)
设备管理:unix io接口。
这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。
我们可以对文件的操作有:打开关闭操作open和close;读写操作read和write;改变当前文件位置lseek等。
8.2 简述Unix IO接口及其函数
Unix IO接口:
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
Linux Shell创建的每个进程都有三个打开的文件:标准输入、标准输出、标准错误。
改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置 k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行 seek,显式地将改变当前文件位置 k。
读写文件。一个读操作就是从文件复制 n > 0 个字节到内存,从当前文件位置 k 开始,然后将 k 增加到 k + n。给定一个大小为 m 字节的文件,当 k >= m 时,执行读操作会触发 EOF,应用程序能检测到它。类似地,写操作就是从内存中复制 n > 0 个字节到一个文件,从当前文件位置 k 开始,然后更新 k。
关闭文件。内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
Unix IO函数:
1.open:一个应用程序通过此方法来要求内核打开相应的文件,内核返回一个非负整数,叫做文件描述符,后续所有的操作都基于这个文件描述符。内核记录了对应文件描述符的所有信息,应用程序只需要记住这个描述符。
2.close:用于关闭一个被打开的的文件,描述符为fd,0成功,-1出错.
3.read 读取文件会从当前文件位置复制字节到内存,然后更新文件位置(前提是文件支持seeking),返回从文件fd读取到buf的字节数。
4.write 写入文件会将字节从内存复制到当前文件位置,然后更新当前文件位置(前提是文件支持seeking)
5.seek:对于每个打开的文件,内核保存着一个文件位置k,表示从文件开头起始字节的偏移量,默认为0.应用程序可以通过seek显示的设置k的值。
8.3 printf的实现分析
printf函数的函数体为:
图38 printf函数的函数体
使用的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的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。之后调用write函数将buf的前i个字符输出到终端,调用了unix I/O。之后系统调用sys_call函数:syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息并将点阵信息存储到vram中。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),于是我们的打印字符串就显示在了屏幕上。
8.4 getchar的实现分析
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有一个int型的返回值。当程序调用getchar时,程序就等着用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车为止(回车字符也放在缓冲区中)。
当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回EOF(end of file,即-1),且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了 Linux 的 I/O 设备的基本概念和管理方法,以及Unix I/O 接口及其函数。最后分析了printf 函数和 getchar 函数的工作过程。
(第8章1分)
结论
hello在程序员通过键盘输入保存在磁盘上以.c文件存储,之后经过预处理,拓展得到hello.i文本文件;经过编译得到汇编代码hello.s汇编文件;通过汇编又得到二进制可重定位目标文件hello.o;再经过链接得到了hello可执行文件。通过这一系列过程,它从人能看懂的文本文件变成了机器能够看懂的二进制文件。
之后,在shell输入./hello,shell根据输入判断,不是内置指令,于是先执行fork,创建了子进程,此时复制了一份虚拟内存并且都映射到物理内存中相同的地址空间中,并把他们标记成为写复制,之后在子进程中调用execve加载运行当前进程的上下文中加载并运行新程序hello的程序,至此hello成为了独立的进程。hello加载进入内存之后,首先会进行动态链接,动态链接器会根据hello的需要构建一个查表函数,而hello通过这个查表函数来进行对共享库函数的调用。hello再运行时会调用一些函数,比如printf函数,这些函数与linux I/O的设备模拟化密切相关。
在hello执行完毕之后调用exit退出,在退出后会给shell父进程发送一个SIGCHLD信号,shell父进程收到这个信息后会释放之前用于存储hello信息的一些内存空间,这就是hello从Zero到Zero的一生。
通过大作业,我发现一个小小的Hello进程实现起来需要考虑的方面是如此之多,调用的函数和系统操作涉及如此之广,令我不禁感叹于计算机系统的神妙。从一个hello开始对它的方方面面进行梳理,可以让我系统性地认识整个计算机软硬件系统结构。我们基于实践在学习计算机,然而却也要基于理论;我们不应该只盯着顶层的实现,而忽视底层的构造。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件的作用 |
文件名 |
预处理后的文件 |
hello.i |
编译之后的汇编文件 |
hello.s |
汇编之后的可重定位目标文件 |
hello.o |
链接之后的可执行目标文件 |
Hello |
Hello.o 的 ELF 格式 |
Elf.txt |
Hello.o 的反汇编代码 |
disas_hello.s |
hello的ELF 格式 |
hello1.elf |
hello 的反汇编代码 |
hello1_objdump.s |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 《深入理解计算机系统》 Randal E.Bryant David R.O’Hallaron 机械工业出版社.
[2] C/C++预处理过程详细梳理(预处理步骤+宏定义#define/#include+inline函数+宏展开顺序+条件预处理+其它预处理定义)https://blog.csdn.net/luolaihua2018/article/details/124067982
[3] 你真的懂Hello World!吗?(编译与链接,静态链接与动态链接)你真的懂Hello World!吗?(编译与链接,静态链接与动态链接)-CSDN博客
[4] Executable and Linkable Format(ELF)https://blog.csdn.net/Kongxiangyunltj/article/details/136391027
[5] Linux下shell脚本:bash的介绍和使用(详细)https://blog.csdn.net/weixin_42432281/article/details/88392219
[6] Linux内核分析(七)系统调用execve处理过程https://blog.csdn.net/yubo112002/article/details/82527157
[7] UNIX IO 简介https://blog.csdn.net/weixin_42695485/article/details/110248969
[8] 博客园 [转]printf 函数实现的深入剖析 https://www.cnblogs.com/pianist/p/3315801.html
(参考文献0分,缺失 -1分)