HIT计算机系统大作业

HIT 计算机系统大作业

题 目 程序人生-Hello’s P2P
专 业 软件工程
学   号 1183710105
班   级 1837101
学 生 陈文韬    
指 导 教 师 史先俊

计算机科学与技术学院
2019年12月
摘 要
本文以最简单的hello程序作为例子,讲解了从hello.c到最后hello程序运行结束的过程:由预处理到编译;由汇编到链接;由进程到存储再到I/O。作者在一学期计算机系统的学习基础上,根据自己的理解和网上查阅的资料,对helllo看似简单实则复制而伟大的一生进行了整理,并以此文的形式展现在了大家的面前。

关键词:hello程序;深入理解计算机系统;预处理;编译;汇编;链接;进程;存储;I/O

目 录

第1章 概述 - 4 -
1.1 HELLO简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 4 -
1.4 本章小结 - 4 -
第2章 预处理 - 6 -
2.1 预处理的概念与作用 - 6 -
2.2在UBUNTU下预处理的命令 - 6 -
2.3 HELLO的预处理结果解析 - 6 -
2.4 本章小结 - 7 -
第3章 编译 - 9 -
3.1 编译的概念与作用 - 9 -
3.2 在UBUNTU下编译的命令 - 9 -
3.3 HELLO的编译结果解析 - 9 -
3.3.1整形局部变量 - 10 -
3.3.2字符串 - 10 -
3.3.3赋值操作 - 11 -
3.3.4算数操作 - 11 -
3.3.5关系操作 - 11 -
3.3.6数组操作 - 12 -
3.3.7控制转移 - 12 -
3.3.8函数操作 - 13 -
3.4 本章小结 - 16 -
第4章 汇编 - 17 -
4.1 汇编的概念与作用 - 17 -
4.2 在UBUNTU下汇编的命令 - 17 -
4.3 可重定位目标ELF格式 - 17 -
4.4 HELLO.O的结果解析 - 20 -
4.5 本章小结 - 22 -
第5章 链接 - 23 -
5.1 链接的概念与作用 - 23 -
5.2 在UBUNTU下链接的命令 - 23 -
5.3 可执行目标文件HELLO的格式 - 24 -
5.4 HELLO的虚拟地址空间 - 29 -
5.5 链接的重定位过程分析 - 30 -
5.6 HELLO的执行流程 - 34 -
5.7 HELLO的动态链接分析 - 35 -
5.8 本章小结 - 36 -
第6章 HELLO进程管理 - 37 -
6.1 进程的概念与作用 - 37 -
6.2 简述壳SHELL-BASH的作用与处理流程 - 37 -
6.3 HELLO的FORK进程创建过程 - 37 -
6.4 HELLO的EXECVE过程 - 38 -
6.5 HELLO的进程执行 - 39 -
6.6 HELLO的异常与信号处理 - 40 -
6.6.1正常执行 - 41 -
6.6.2乱序输入 - 42 -
6.6.3输入^Z - 43 -
6.6.4输入^C - 45 -
6.7本章小结 - 45 -
第7章 HELLO的存储管理 - 46 -
7.1 HELLO的存储器地址空间 - 46 -
7.2 INTEL逻辑地址到线性地址的变换-段式管理 - 47 -
7.3 HELLO的线性地址到物理地址的变换-页式管理 - 49 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 51 -
7.5 三级CACHE支持下的物理内存访问 - 52 -
7.6 HELLO进程FORK时的内存映射 - 53 -
7.7 HELLO进程EXECVE时的内存映射 - 53 -
7.8 缺页故障与缺页中断处理 - 54 -
7.9动态存储分配管理 - 55 -
7.10本章小结 - 56 -
第8章 HELLO的IO管理 - 57 -
8.1 LINUX的IO设备管理方法 - 57 -
8.2 简述UNIX IO接口及其函数 - 57 -
8.3 PRINTF的实现分析 - 57 -
8.4 GETCHAR的实现分析 - 58 -
8.5本章小结 - 59 -
结论 - 60 -
附件 - 61 -
参考文献 - 62 -

第1章 概述

1.1 Hello简介

hello,是一个程序,最早通过编译器,建立.c文件,并输入一段代码,以hello.c的形式存在,也叫源程序。接下来,通过预处理,编译,汇编,链接形成可执行文件。
在这里插入图片描述
之后,用户通过在linux的shell上输入”./hello 1183710105 陈文韬 1”运行:用fork创建子进程,用execve加载,用mmap划出空间;处理器从.text节不断取出代码,.从.data节不断取出数据;调度器为hello规划进程执行的时间片。
如果过程中有信号,程序会进行响应。
当程序结束后,父进程回收hello,内核删除hello的所有数据结构。

1.2 环境与工具

硬件环境:X64 CPU(Core i7-8750H 2.2GHz),8GB RAM,512GB SSD
软件环境:Windows 10,Vmware Workstation 19 Pro,Ubuntu 64
开发与调试工具: gcc,as,ld,edb,readelf,codeblocks

1.3 中间结果

hello: 链接之后的可执行目标文件
hello.c: 源文件
hello.elf: hello的ELF格式
hello.i: 预处理产生的文本文件
hello.o: 汇编产生的可重定位目标文件
hello.objdmp: hello的反汇编代码
hello.s: 编译产生的汇编文件
hello.txt: hello.o的ELF格式

1.4 本章小结

本章是对hello的高度概括,下面让我们认认真真地一起走完hello的一生!

第2章 预处理

2.1 预处理的概念与作用

概念:预处理是在编译之前,预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中第6行的#include <stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名。
作用:将源文件中用#include形式声明的文件复制到新的程序中。
删除所有注释。比如:由“//”后的内容或“/”与“/”之间的内容。
删除“#define”,用实际值替换#define定义的字符串。

2.2在Ubuntu下预处理的命令

预处理命令:gcc -E hello.c -o hello.i
截图示例:
在这里插入图片描述

2.3 Hello的预处理结果解析

在这里插入图片描述在这里插入图片描述
通过打开.i文件,我们可以看到:文件变得很大,约3000多行,而我们的代码在最后出现。这是由于:之前的stdio.h,unistd.h,stdlib.h都进行了展开,头文件中的内容包含进了.i文件中(经过递归替换逐步包含)。
所以,经过预处理之后,hello.c文件转化生成一个只有常量如数字、字符或变量的定义的输出文件.i(此时的.i文件中没有宏定义、条件编译指令、特殊符号等)。

2.4 本章小结

预处理是将源程序(文本)经过预处理器转换成修改了的源程序(文本)的过程。它是编译之前的操作,根据以字符#开头的命令,修改原始的C程序,由hello.c生成hello.i(还是可读的C语言风格的文本文件)。

第3章 编译

3.1 编译的概念与作用

概念:编译是编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。
作用:通过词法分析,语法分析,目标代码的生成进行词法分析和语法分析,分析过程中发现有语法错误,给出提示信息。

3.2 在Ubuntu下编译的命令

编译命令:gcc -S hello.i -o hello.s
截图示例:
在这里插入图片描述

3.3 Hello的编译结果解析

打开.s文件后,我们看到了如下的示例:
在这里插入图片描述
其中包含了我们的汇编语言程序指令:
在这里插入图片描述
以下是对编译器是怎么处理C语言的各个数据类型以及各类操作的说明:

3.3.1整形局部变量

我们在main中定义了一个int型的数—i: 。并在for循环的时候shiyong了它: 。
对于局部变量,编译器会将它放在寄存器或栈中,为了确定i的位置,我们在.s文件中找到它的汇编代码:
在函数当中,我们可以读到: (这是循环时,每循环一次,i加1),也就是说,i应该是在-4(%rbp)的位置,根据这一条线索,我们可以找到如下的汇编代码:在这里插入图片描述
,可知,i是在栈里的。

3.3.2字符串

hello.c中,有两个地方涉及到了字符串的输出:
在这里插入图片描述
如果我们在./hello 后没有输入两个字符串,那程序就会打印 来提示输入格式:
在这里插入图片描述
如果我们在./hello 后再输入两个字符串(形如“./hello xx xx”)就会形成三个参数,就会正常输出我们输入的字符串:
在这里插入图片描述这两个字符串,在编译的时候,编译器把它们放在了.rodata节中,如图所示:
在这里插入图片描述

3.3.3赋值操作

在循环开始的时候,我们将i赋值为0: ,这是通过movl指令完成的: 在这里插入图片描述

3.3.4算数操作

hello.c中的算术操作存在于for循环中: ,每循环一次都要对i进行加1操作:在这里插入图片描述

3.3.5关系操作

我们在hello.c中有两个地方用到了关系操作:
1. :在这里插入图片描述
在最开始的时候,判断argc的大小,因为argc是main函数的第一个参数,所以,他应该存放在%edi中。在.s文件中我们可以找到相对应的汇编语言代码: 在这里插入图片描述:在这里,它判断argc是否为4,从而来决定程序接下来应该如何执行(其中4是立即数,-20(%rbp)是argc)——若相等,就跳转到.L2。
2. 在这里插入图片描述
在for循环中,每次都会比较i的大小来判断循环是否结束,在.s文件中可以找到相对应的汇编代码: 在这里插入图片描述,这里是比较i和7的大小,如果小于等于就跳转,所以循环的条件是:i<=7(与i<8的效果是等价的)。

3.3.6数组操作

我们向main函数传入了argv[]作为第二个参数,argv[]是一个指针数组,在后面打印和休眠的时候,我们会用到它:
在这里插入图片描述在汇编代码.s的文件中,我们可以找到相关的汇编代码:
在这里插入图片描述
(黄色标注的位置)
因为argv[]是指针类型,所以它的大小应该是8字节的所以用%rsi传给了%rbp,其中argv[1]在8(%rbp)的位置,argv[2]在16(%rbp)的位置,argv[3]在24(%rbp)的位置。

3.3.7控制转移
  1. 在这里插入图片描述
    根据参数argc的值决定函数接下yi应该怎样进行:如果argc等于4,那么我们的程序就往下顺序执行;如果argc不等于4,那么我们的程序就会跳转到.L2:在这里插入图片描述

  2. 在这里插入图片描述
    在汇编代码中,我们先无条件跳转到位于循环体.L4之后的比较部位,比较i与7的大小,如果i小于等于7,则跳转到.L4进行循环,否则的话顺序往下进行其他操作:在这里插入图片描述

3.3.8函数操作

我们会从以下几个方面分析分析函数的调用
1.参数传递:
当调用一个过程时,除了要把控制传递给它并在过程返回时再传递回来之外,过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括返回一个值。x86-64中, 大部分过程间的数据传送是通过寄存器实现的。例如,我们已经看到无数的函数示例,参数在寄存器%rdi、%rsi和其他寄存器中传递。当过程P调用过程Q时,P的代码必须首先把参数复制到适当的寄存器中。类似地,当Q返回到P时, P的代码可以访问寄存器%rax 中的返回值。
2.函数调用:
调用函数时,会将返回地址压入栈中,并且将rip的值指向所调用函数的地址,函数运行时,将进行某些寄存器的原始值保存,利用传递进来的参数进行操作,并且将返回结果放在rax寄存器中,等函数执行完之后调用ret弹出原来的rip并且将栈帧结构恢复。
3.函数返回:
函数返回时,如果有返回值,则先将返回值存在%rax中,再用ret等操作返回。
我们将对main函数,exit函数,puts函数,printf函数,atoi函数,sleep函数,getchar函数从以上这几点进行分析:
1.main函数:
在这里插入图片描述
main函数是在主程序里面定义的一个全局符号,它被系统函数所调用,编译器将main函数在.text节中声明为全局的函数。
在这里插入图片描述
参数传递:由于main是系统调用的,所以它的参数之前就在%rsi和%rdi中了,而当在shell中执行可执行文件hello时,execve函数将argc和argv[]作为参数调用加载器传递给main函数。
函数调用:main是系统调用的。
函数返回:通过 ,将0压入到eax中,然后调用了leav平衡栈帧,返回退出。
在这里插入图片描述
2.exit函数
在这里插入图片描述
参数传递:将1传给了%edi,完成参数传递。
在这里插入图片描述
函数调用:通过call exit@PLT函数,进行函数调用。
在这里插入图片描述
函数返回:从exit返回。
3.puts函数
在这里插入图片描述
参数传递:puts函数只需要输出一个字符串常量,也就是一个数组,它将.LC0位置的字符串首地址复制过来赋值给%rdi作为参数。
在这里插入图片描述
函数调用:通过call puts@PLT函数,进行函数调用。
在这里插入图片描述
函数返回:从puts中返回。
4. printf函数
在这里插入图片描述
参数传递:printf函数是格式化输出,且具有多个参数传递,当参数在6个以内时,使用寄存器传参,超过7个则用栈来传递。在本次的函数中,只有3个参数,所以用%rdi,%rsi,%rdx进行传递。参数为字符串首地址,但后两个存在字符数组里面,所以在寻址时,通过内存寻址的方法:第一步取出刚刚在-32(%rbp)存放的argv首地址,然后将argv[1],argv[2]的地址搞出来,再通过间接寻址将其中存放的内容,即字符串的地址取出。
在这里插入图片描述
函数调用:通过call puts@PLT函数,进行函数调用。
在这里插入图片描述
函数返回:从printf中返回。
5. atoi函数
在这里插入图片描述
参数传递:将argv[3]通过%rdi传递给atoi函数。
在这里插入图片描述
函数调用:通过call atoi@PLT函数,进行函数调用。
在这里插入图片描述
函数返回:从atoi中返回。
6.sleep函数
在这里插入图片描述
参数传递:将atoi的返回值%eax通过%rdi传递给sleep函数。
在这里插入图片描述
函数调用:通过call sleep@PLT函数,进行函数调用。
在这里插入图片描述
7.getchar函数
在这里插入图片描述
参数传递:无参数传递。
函数调用:通过callgetchar@PLT函数,进行函数调用。
在这里插入图片描述
函数返回:从getchar中返回。

3.4 本章小结

本章主要阐述了通过编译,从.i文件到.s文件,也就是函数的c代码变为了汇编代码,编译器分别从c语言的数据,赋值语句,类型转换,算术操作,逻辑/位操作,关系操作,指针操作,控制转移与函数操作这几个关键点布局:首先是对各个全局变量进行的声明,包括main函数。对于hello.c中定义的函数,则在声明之后跟上它内部相应的代码。编译器此处做的各种工作一个是为了接下来汇编器将.s文件中的汇编语言转化为机器指令。 

第4章 汇编

4.1 汇编的概念与作用

概念:汇编是汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,它包含的17个字节是函数main 的指令编码。如果我们在文本编辑器中打开hello.o文件,将看到一堆乱码。
作用:将编译过的.s文件中的汇编语言指令转化成机器语言指令并且装入到.o可重定向文件。生成每一个汇编语句几乎都对应一条机器指令。汇编相对于编译过程比较简单,根据汇编指令和机器指令的对照表一一翻译即可。
4.2 在Ubuntu下汇编的命令
截图示例:
在这里插入图片描述
4.3 可重定位目标elf格式
在这里插入图片描述
readelf -a hello.o
ELF头:
ELF头以一个16进制字节的序列开始,这个序列描述了生成该文件的系统字的大小和字节顺序。
ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(可重定位、可执行或者可共享的)、机器类型(如x86-64)、节头部表的文件便宜,以及节头部表中条目的大小和数量。
不同节的位置和大小是由节头部表描述的,其中目标文件中的每个节都有一个固定大小的条目。
在这里插入图片描述
节头部表:
包含了文件中出现的各个节的语义,包括节 的类型、位置和大小等信息。
在这里插入图片描述
重定位项目:
当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
在这里插入图片描述
符号表:
是一种用于语言翻译器(例如编译器和解释器)中的数据结构。在符号表中,程序源代码中的每个标识符都和它的声明或使用信息绑定在一起,比如其数据类型、作用域以及内存地址。
在这里插入图片描述

4.4 Hello.o的结果解析

在这里插入图片描述
指令:objdump -d -r hello.o
我们与.s文件进行对比:
在这里插入图片描述
可以发现,二者大体上区别不大,zhuyao区别有以下几点:
1.立即数已经变成了16进制的数
在.s文件中的 在这里插入图片描述,在.o文件中已经变成了在这里插入图片描述 ,我们可以看出:10进制数32已经转换为16进制的0x20。
2. .o文件中所有的跳转都是采用的相对寻址
举个例子,.s文件中的 在这里插入图片描述,在.o文件中已经变成了在这里插入图片描述 ,.L3已经由地址<main+0x7c>来表示了。
3.调用函数时,调用方式有所不同
在调用函数时,从直接call“函数名“,变成了,计算出函数与下一条指令的相对位置,通过相对寻址,然后再后边添加重定位条目进行调用。比如:在这里插入图片描述 再.o文件中变成了在这里插入图片描述
4.指令有所不同
比如:有的指令在.o文件中少了q:.s文件中的movq( 在这里插入图片描述)在.o文件中变成了mov( 在这里插入图片描述);
5…o文件每一条汇编语言前douyou对应的机器码。
比如: 在这里插入图片描述

4.5 本章小结

本章,我们生成了.o文件,并通过readelf来读取ELF可重定位目标文件文件的内容。其中,包括了:ELF头,节头部表,重定位项目,符号表。之后,用objdump来读取了.o文件中的内容,在与.s比较分析后,得到了5点的不同。经过编译后,编译过的.s文件中的汇编语言指令转化成机器语言指令并且装入到.o可重定向文件,生成每一个汇编语句几乎都对应一条机器指令,这样,我们的程序为接下来的连接做好了充足的准备。

第5章 链接

5.1 链接的概念与作用

概念:链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可执行于编译(compile time)时,也就是在源代码被翻译成机器代码时;也可以执行于加载时(load time),也就是在源代码被加载器(loader)加载到内存并执行时;甚至执行于运行时(run time),也就是由应用程序来执行。在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。
作用:链接使得分离编译成为可能,即可以将一个大项目分解为较小的、更好管理的模块,可以单独对其进行修改和变异,最后再将其链接到一起。

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.3 可执行目标文件hello的格式
在这里插入图片描述在这里插入图片描述

elf头:
在这里插入图片描述
节头:
在这里插入图片描述
程序头:
在这里插入图片描述
段节:
在这里插入图片描述
动态偏移表:
在这里插入图片描述
重定位节:
在这里插入图片描述
符号表:
在这里插入图片描述
版本符号节:
在这里插入图片描述
版本需求节:
在这里插入图片描述

5.4 hello的虚拟地址空间

用edb打开hello程序后,我们可以在Data Dump里看到hello的虚拟地址空间,截屏如下:
在这里插入图片描述.txt始于0x400550处
之后,我们再去查看一下程序头:
在这里插入图片描述
再与5.3进行对比,我们可以看出,程序有7个段:
PHDR包含程序头表本身
INTERP:只包含了一个section,在这个节中,包含了动态链接过程中所使用的解释器路径和名称。
LOAD两个:第一个是代码段,第二个是数据段。在程序运行时需要映射到虚拟空间地址。
DYNAMIC:保存了由动态链接器使用的信息。
NOTE: 保存了辅助信息。
GNU_STACK:堆栈段。
GNU_RELRO:在重定位之后哪些内存区域需要设置只读。

5.5 链接的重定位过程分析

执行objdump -d -r hello之后,我们得到如下结果:

在这里插入图片描述在这里插入图片描述
在这里插入图片描述
此时,与.o文件进行对比,我们发现hello与.o文件不同的是:
1.,hello中已不只有main函数的部分,在函数中调用的函数的汇编代码也在里面。
2.hello中增加了新的的节:
在这里插入图片描述
3.经过重定位之后,所有的函数都有了确定的运行时地址,因此跳转的时候,直接跳转到相应地址即可:例如:在这里插入图片描述 ,已经变为 在这里插入图片描述
4.call和lea后面的重定位的信息由
在这里插入图片描述
变为了在这里插入图片描述

5.6 hello的执行流程

在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述

5.7 Hello的动态链接分析

先在elf中找到GOT表的地址:
在这里插入图片描述
在do_init之后:
在这里插入图片描述在do_init之后:
在这里插入图片描述
看之前找到的地址,已经由都是0变为了相应的偏移量。

5.8 本章小结

本章我们介绍了连接的作用和概念,在虚拟机里将hello.o与其他文件链接生成了hello的可执行程序并查看了elf文件。之后,应用工具edb对其虚拟地址进行了跟踪。下面,我们将对hello的进程管理进行下一步探索。

第6章 hello进程管理

6.1 进程的概念与作用

概念:进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
作用:进程提供给应用程序的关键抽象如下:
一个独立的逻辑控制流。它提供一个假象,好像我们的程序独占地使用处理器。
一个私有的地址空间。它提供一个假象,好像我们的程序独占地使用内存系统。

6.2 简述壳Shell-bash的作用与处理流程

作用:shell是一个应用程序,他在操作系统中提供了一个用户与系统内核进行交互的界面。在计算机科学中,Shell俗称壳(用来区别于核),是指“为使用者提供操作界面”的软件(命令解析器)。它类似于DOS下的command.com和后来的cmd.exe。它接收用户命令,然后调用相应的应用程序。同时它又是一种程序设计语言。作为命令语言,它交互式解释和执行用户输入的命令或者自动地解释和执行预先设定好的一连串的命令;作为程序设计语言,它定义了各种变量和参数,并提供了许多在高级语言中才具有的控制结构,包括循环和分支。
处理流程:
1.在虚拟的shell界面上出现命令提示符($或#) ;
2.获取用户指令:获取用户在命令提示符后面输入的命令及其参数,并注意命
令输入的最大长度;
3.解析指令:对用户输入的命令进行解析,解析出命令名和参数;如果是内置命令则立即执行,否则调用相应的程序为其分配子程序并进行
4.寻找命令文件:每个命令的执行都必须依靠对应的可执行文件,这些文件的存放路径存放在用户的PATH环境变量里;
5.执行命令:可通过fork系统调用创建一 个进程来完成执行命令的任务,具体的命令执行用execv函数。
6.等待并回收子程序。

6.3 Hello的fork进程创建过程

1.linux系统下的终端中始终运行着一个Shell来执行用户输入的操作,作为用户与系统之间的媒介。在终端中输入./hello 1183710105 陈文韬 1。
2.shell分析输入的命令,发现并不是一个内置命令,所以调用相应的程序解释执行hello程序,
3. 使用fork创建子进程,新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。父进程和子进程是并发运行的独立进程,内核能够以任何方式交替执行它们的逻辑控制流中的指令。
在这里插入图片描述

6.4 Hello的execve过程

execve函数在当前进程的上下文中加载并运行一个新程序
在这里插入图片描述
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve 调用一次并从不返回。
参数列表是用数据结构表示的。argv 变量指向一个以null 结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例,argv[0] 是可执行目标文件的名字。环境变量的列表是由一个类似的数据结构表示的。envp变量指向一个以null结尾的指针数组,其中每个指针指向-一个环境变量字符串,每个串都是形如’name=value” 的名字-值对。
在这里插入图片描述在这里插入图片描述
在execve加载了filename之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下形式的原型:在这里插入图片描述 ,或者等价的:
在这里插入图片描述
总结起来就是:先删除已存在的用户区域,再映射私有区域和共享区域,最后设置程序计数器(PC)
在这里插入图片描述

6.5 Hello的进程执行

在这里插入图片描述

操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。上下文切换机制建立在较低层异常机制之上的。
内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
hello一开始运行在用户模式,当调用sleep时进入内核模式,内核处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时时发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
当hello调用getchar的时候,实际落脚到执行输入流是stdin的系统调用read,hello之前运行在用户模式,在进行read调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回hello进程。

6.6 hello的异常与信号处理

异常可以分为以下几类:
在这里插入图片描述
他们的处理方式如下:
中断: 在这里插入图片描述 陷阱: 在这里插入图片描述 故障: 在这里插入图片描述 终止: 在这里插入图片描述

6.6.1正常执行

当不输入如何参数的时候
在这里插入图片描述
输出之前设定好的输入格式提示;
当符合输入标准时,每隔相应输入的秒数(我输入的是1秒),就会打印输入的学号和字符串(例如:1183710105 陈文韬),最后用户输入一个字符串,之后程序结束。
在这里插入图片描述

6.6.2乱序输入

最开始,按规定的格式输入./hello 1183710105 陈文韬 1,在hello执行的时候,在键盘上随意输入,输入的字符串会打印出来,但hello程序还是正常进行,输出Hello 1183710105 陈文韬。当运行结束后,发现之前回车间隔之间的输入都会作为shell的命令读入,应该是被放到了缓冲区中。

6.6.3输入^Z

使用ctrl+z之后,将会发送一个SIGTSTP信号给shell,然后shell将转发给当前执行的前台进程组,使hello进程挂起。
在这里插入图片描述
1.输入ps
在这里插入图片描述
查看当前的进程,发现hello还在当前进程中。
2.输入jobs
在这里插入图片描述
3.输入pstree
在这里插入图片描述
用pstree查看运行树
4.输入fg
在这里插入图片描述
输入ctrl-z后,hello的进程停止,但是没有消失,这时如果输入fg指令,hello会接着上次停止的地方继续进行。
5.执行kill命令
在这里插入图片描述
kill命令将hello的程序终止。

6.6.4输入^C

使用^C的命令,将会发送一个SIGINT信号给shell,然后shell将转发给当前执行的前台进程组,使hello进程被终止,直接退出。
在这里插入图片描述

6.7本章小结

本章从进程的角度,对hello进行了分析,从shell调用fork函数生成子程序,再用execve进行加载,最后在hello程序执行过程中,对回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps,jobs,pstree,fg,kill 等命令的处理,让我们对进程有了更好的理解。

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址(Logical Address):是指由程式产生的和段相关的偏移地址部分。例如,你在进行C语言指针编程中,能读取指针变量本身值(&操作),实际上这个值就是逻辑地址,他是相对于你当前进程数据段的地址,不和绝对物理地址相干。只有在Intel实模式下,逻辑地址才和物理地址相等(因为实模式没有分段或分页机制,Cpu不进行自动地址转换);逻辑也就是在Intel保护模式下程式执行代码段限长内的偏移地址(假定代码段、数据段如果完全相同)。应用程式员仅需和逻辑地址打交道,而分段和分页机制对你来说是完全透明的,仅由系统编程人员涉及。应用程式员虽然自己能直接操作内存,那也只能在操作系统给你分配的内存段操作。
线性地址(Linear Address):是逻辑地址到物理地址变换之间的中间层。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址能再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。Intel 80386的线性地址空间容量为4G(2的32次方即32根地址总线寻址)。
虚拟地址(vitual address):实际上就是这里的线性地址。
在这里插入图片描述
物理地址(Physical Address):是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
在这里插入图片描述

7.2 Intel逻辑地址到线性地址的变换-段式管理

段式管理: 逻辑地址->线性地址==虚拟地址
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图:
在这里插入图片描述
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图:
在这里插入图片描述
最后两位涉及权限检查,这里不予以论述。 索引号,是“段描述符(segment descriptor)”,具体地址描述了一个段。这样,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,由8个字节组成,如下图:
在这里插入图片描述
图示比较复杂,可以利用一个数据结构来定义它,不过,在此只关心一样,就是Base字段,它描述了一个段的开始位置的线性地址。Intel设计的本意是,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。什么时候该用GDT,什么时候该用LDT是由段选择符中的T1字段表示的,=0,表示用GDT,=1表示用LDT。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。再看这张图比起来要直观些:
在这里插入图片描述
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
1、看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
2、拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
3、把Base + offset,就是要转换的线性地址了。 还是挺简单的,对于软件来讲,原则上就需要把硬件转换所需的信息准备好,就可以让硬件来完成这个转换了。

7.3 Hello的线性地址到物理地址的变换-页式管理

页式管理: 虚拟地址->物理地址
Linux将虚拟内存组织成一些区域(也叫做段)的集合。一个区域(area)就是已经存在着的(已分配的)虛拟内存的连续片(chunk),这些页是以某种方式相关联的。例如,代码段、数据段、堆、共享库段,以及用户栈都是不同的区域。每个存在的虚拟页面都保存在某个区域中,而不属于某个区域的虚拟页是不存在的,并且不能被进程引用。
区域的概念很重要,因为它允许虛拟地址空间有间隙。内核不用记录那些不存在的虚拟页,而这样的页也不占用内存、磁盘或者内核本身中的任何额外资源。
在这里插入图片描述
而物理内存被划分为一小块一小块,每块被称为帧(Frame)。分配内存时,帧是分配时的最小单位,最少也要给一帧。在虚拟内存中,与帧对应的概念就是页(Page)。
线性地址的表示方式是:前部分是虚拟页号后部分是虚拟页偏移。
在这里插入图片描述
CPU通过将逻辑地址转换为虚拟地址来访问主存,这个虚拟地址在访问主存前必须先转换成适当的物理地址。CPU芯片上叫做内存管理单元(MMU)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址。然后CPU会通过这个物理地址来访问物理内存。
页表就是一个页表条目(PTE)数组,虚拟地址空间中的每个页在页表中的一个固定偏移量处都有一个PTE。PTE是由一个有效位和一个n个字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置。
MMU利用虚拟页号(VPN)来在虚拟页表中选择合适的PTE,当找到合适的PTE之后,PTE中的物理页号(PPN)和虚拟页偏移量(VPO)就会组合形成物理地址。其中VPO与PPO相同,因为虚拟页大小和物理页大小相同,所需要的偏移量位数也就相同。此时,物理地址就通过物理页号先找到对应的物理页,然后再根据物理页偏移找到具体的字节:
1.如果有效位是 0+NULL 则代表没有在虚拟内存空间中分配该内存;
2.如果是有效位 0+非 NULL,则代表在虚拟内存空间中分配了但是没 有被缓存到物理内存中;
3.如果有效位是 1 则代表该内存已经缓存在了物理内存中,可以得到其 物理页号 PPN,与虚拟页偏移量共同构成物理地址 PA。
在这里插入图片描述
7.4 TLB与四级页表支持下的VA到PA的变换
在这里插入图片描述
整个过程从CPU出发。CPU内部将逻辑地址转化为虚拟地址以后,传送给MMU进行地址翻译,MMU首先将VPN取出,构建TLB的标记位和组索引,然后在翻译后备缓冲器TLB中寻找对应的PTE条目。
这里的TLB有16个组,每组4个条目(四路相联),所以构建时将VPN的低4位取出作为组索引,剩余位作为标记位。通过组索引和行匹配寻找对应的PPN。如果命中,就将其取出,构建下一步使用的PPN。
如果TLB不命中,MMU就通过4级页表从高速缓存中取出相应的PTE,作为PPN构建物理地址,同时在TLB中更新。
多级页表的虚拟地址翻译与单级页表的主要区别在于页表的保存形式。
多级页表从两个角度减少了内存要求。第一,如果一级页表中的一个PTE是空的,那么相应的所有低级页表都不存在,这意味着巨大的潜在节约。第二,只有一级页表才需要总是缓存在主存中,虚拟内存系统可以在需要时创建、页面调入或调出二级页表,这就减少了主存的压力;只有最经常使用的二级页表才需要缓存在主存中。
在这里插入图片描述

7.5 三级Cache支持下的物理内存访问

在这里插入图片描述MMU发送物理地址PA给L1缓存,L1缓存从物理地址中抽取出缓存偏移CO、缓存组索引CI以及缓存标记CT。高速缓存根据CI找到缓存中的一组,并通过CT判断是否已经缓存地址对应的数据,若缓存命中,则根据偏移量直接从缓存中读取数据并返回;若缓存不命中,则继续从L2、L3缓存中查询,若仍未命中,则从主存中读取数据。

7.6 hello进程fork时的内存映射

当fork函数被当前进程调用的时候,内核会为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进场创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本,它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记位私有的写时复制。
当fork函数在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的概念。

7.7 hello进程execve时的内存映射

虚拟内存和内存映射在将程序加载到内存的过程中也扮演着关键的角色。既然已经理解了这些概念,我们就能理解execve函数实际上是如何加载和执行程序的。假设运行在当前进程中的程序执行了如下的execve调用:
在这里插入图片描述execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的. text和.data区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。图7.7-1概括了私有区域的不同映射。
映射共享区域。hello程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虛拟地址空间中的共享区域内。
设置程序计数器(PC)。execve做的最后- -件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个人口点开始执行。Linux将根据需要换人代码和数据页面。
在这里插入图片描述

7.8 缺页故障与缺页中断处理

缺页故障是一种常见的故障,当指令引用一个虚拟地址,在MMU中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出的时候就会发生故障。其处理流程遵循图 所示的故障处理流程:
在这里插入图片描述
缺页中断处理:缺页处理程序是系统内核中的代码,选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,这次MMU就能正常翻译VA了。

7.9动态存储分配管理

虽然可以使用低级的mmp和munmap函数来创建和删除虚拟内存的区域,但是C程序员还是会觉得当运行时需要额外虚拟内存时,用动态内存分配器(dynamic memory allocator)更方便,也有更好的可移植性。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个
变量brk(读做“break"),它指向堆的顶部。
分配器将堆视为一-组不同大小的块(block)的集合来维护。每个块就是一一个连续的虛拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
显式分配器(explicit allocator), 要求应用显式地释放任何已分配的块。
隐式分配器(implicit allocator), 另一方面,要求分配器检测-一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(garbagecollec-tor),而自动释放未使用的已分配的块的过程叫做垃圾收集(garbagecollection)。
在程序运行时程序员使用动态内存分配器(如malloc)获得虚拟内存。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护,每个块要么是已分配的,要么是空闲的。
分配器的类型包括显式分配器和隐式分配器。前者要求应用显式地释放任何已分配的块,后者在检测到已分配块不再被程序所使用时,就释放这个块。
动态内存管理的策略包括:首次适配、下一次适配和最佳适配。
首次适配会从头开始搜索空闲链表,选择第一个合适的空闲块。搜索时间与总块数(包括已分配和空闲块)成线性关系。会在靠近链表起始处留下小空闲块的“碎片”。
下一次适配和首次适配相似,只是从链表中上一次查询结束的地方开始。比首次适应更快,避免重复扫描那些无用块。
最佳适配会查询链表,选择一个最好的空闲块,满足适配,且剩余最少空闲空间。它可以保证碎片最小,提高内存利用率。
分割空闲块:适配到合适的空闲块,分配器将空闲块分割成两个部分是分配块,一个是新的空闲块。
增加堆的空间:通过调用sbrk函数,申请额外的存储器空间,插入到空链表中。
合并空闲块:合并的策略包括立即合并和推迟合并——即合并即释放完就合并,但这样可能导致没必要的分割重复;推迟合并即需要的时候再合并,这样可以避免抖动的产生。

7.10本章小结

本章我们对hello的存储器地址空间,逻辑地址到线性地址再到物理地址以及多级页表和多级缓存有了进一步的了解,知道了hello是如何进行工作的。此外,我们温故而知新,回头重温了一下fork函数和exevve函数,发现了二者更多的奥秘。最后对计算机的动态存储分配管理进行了探讨。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:所有的IO设备都被模型化为文件,而所有的输入输出都被当作对相应文件的读和写来执行。
设备管理:Linux内核的简单的低级接口–unix io接口
文件就是一个字节序列,所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。

8.2 简述Unix IO接口及其函数

打开文件:int open(char *filename, int flags, mode_t mode);
关闭文件:int close(int fd);
读文件:ssize_t read(int fd, void *buf, size_t n);
写文件:ssize_t write(int fd, const void *buf, size_t n);

8.3 printf的实现分析

在这里插入图片描述
printf接受一个fmt的格式,然后将匹配到的参数按照fmt格式输出,fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。fmt也是个变量,它的位置,是在栈上分配的,它也有地址。
之后printf调用了外部函数vsprintf,
在这里插入图片描述
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
之后vsprintf的输出到write系统函数中。在Linux下,write通过执行syscall指令实现了对系统服务的调用,从而使内核执行打印操作。内核会通过字符显示子程序,根据传入的ASCII码到字模库读取字符对应的点阵,然后通过vram(显存)对字符串进行输出。显示芯片将按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),最终实现printf中字符串在屏幕上的输出。

8.4 getchar的实现分析

异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。

8.5本章小结

    本章主要介绍了Linux的IO设备管理方法,Unix IO接口以及其函数,分析了printf函数和getchar函数。就此,hello彻底完整了,我们也讲完了它平凡而又伟大的一生。

结论

hello,诞生于程序员向.c文件中敲入代码的那一刻,随着代码的逐渐完善,他像所有的新生儿一样,来到了这个世界,以hello.c的身份正式开启了属于他的传奇:
最开始,hello要经过一系列的编译:首先,在预处理器中,hello.c经过预处理,与所有的外部库合体成为了hello.i;再在编译器中经过编译,成为了hello.s;之后,汇编器又将hello.s转换为可重定位的目标文件hello.o;最后,连接器会把hello.o进行链接,于是,可执行的目标程序hello就新鲜出炉了!
在运行程序时,我们在shell中输入./hello 1183710105 陈文韬 1:shell调用fork函数创建了子进程、调用execve函数进行加载,并最终进入main函数;函数会根据虚拟内存去寻找物理地址,调用相关的函数,并根据需要动态调整堆和栈。
如果,我们在程序执行的时候,向其发送信号,他也会进行相关的操作(诸如:挂起、停止等)。
最后,他在结束后被父进程回收,内核删除他所有的数据,从此,hello的一生也落下了帷幕。

附件

在这里插入图片描述
hello: 链接之后的可执行目标文件
hello.c: 源文件
hello.elf: hello的ELF格式
hello.i: 预处理产生的文本文件
hello.o: 汇编产生的可重定位目标文件
hello.objdmp: hello的反汇编代码
hello.s: 编译产生的汇编文件
hello.txt: hello.o的ELF格式

参考文献

[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
[7] printf函数实现的深入剖析:https://www.cnblogs.com/pianist/p/3315801.html
[8] Randal E. Bryant ,David R. O’Hallaron: Computer Systems

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值