2021春哈工大计算机系统大作业

摘 要
本文主要阐述hello程序在Linux系统的生命周期,借助edb、gcc等工具探讨hello程序从hello.c经过预处理、编译、汇编、链接生成可执行文件的全过程。同时比较全方面的涉及了Hello程序在其生命周期中可能出现的特殊情况以及处理方法等。

关键词:程序;进程;计算机系统;shell;预处理;编译;汇编;

(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)

目 录

第1章 概述 - 4 -
1.1 HELLO简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 4 -
1.4 本章小结 - 4 -
第2章 预处理 - 5 -
2.1 预处理的概念与作用 - 5 -
2.2在UBUNTU下预处理的命令 - 5 -
2.3 HELLO的预处理结果解析 - 5 -
2.4 本章小结 - 5 -
第3章 编译 - 6 -
3.1 编译的概念与作用 - 6 -
3.2 在UBUNTU下编译的命令 - 6 -
3.3 HELLO的编译结果解析 - 6 -
3.4 本章小结 - 6 -
第4章 汇编 - 7 -
4.1 汇编的概念与作用 - 7 -
4.2 在UBUNTU下汇编的命令 - 7 -
4.3 可重定位目标ELF格式 - 7 -
4.4 HELLO.O的结果解析 - 7 -
4.5 本章小结 - 7 -
第5章 链接 - 8 -
5.1 链接的概念与作用 - 8 -
5.2 在UBUNTU下链接的命令 - 8 -
5.3 可执行目标文件HELLO的格式 - 8 -
5.4 HELLO的虚拟地址空间 - 8 -
5.5 链接的重定位过程分析 - 8 -
5.6 HELLO的执行流程 - 8 -
5.7 HELLO的动态链接分析 - 8 -
5.8 本章小结 - 9 -
第6章 HELLO进程管理 - 10 -
6.1 进程的概念与作用 - 10 -
6.2 简述壳SHELL-BASH的作用与处理流程 - 10 -
6.3 HELLO的FORK进程创建过程 - 10 -
6.4 HELLO的EXECVE过程 - 10 -
6.5 HELLO的进程执行 - 10 -
6.6 HELLO的异常与信号处理 - 10 -
6.7本章小结 - 10 -
第7章 HELLO的存储管理 - 11 -
7.1 HELLO的存储器地址空间 - 11 -
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 -
7.9动态存储分配管理 - 11 -
7.10本章小结 - 12 -
第8章 HELLO的IO管理 - 13 -
8.1 LINUX的IO设备管理方法 - 13 -
8.2 简述UNIX IO接口及其函数 - 13 -
8.3 PRINTF的实现分析 - 13 -
8.4 GETCHAR的实现分析 - 13 -
8.5本章小结 - 13 -
结论 - 14 -
附件 - 15 -
参考文献 - 16 -

第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P(From Program to Process):程序(program)是由二进制码组成的文件,其中包含了程序的指令和数据等信息。程序运行时,操作系统将该文件加载到内存中,并且将运行中的程序实例抽象为一个进程(process),这个过程称为P2P。详细过程为:程序员用高级语言编写得到.c文件,再经过编译器进行预处理得到.i文件,然后对其编译得到.s汇编语言文件,最后通过汇编器将.s文件翻译成机器语言,将指令打包成为可重定位的.o目标文件,再通过链接器与库函数链接得到可执行文件hello,执行此文件hello,操作系统会为其fork产生子进程,再调用execve函数加载进程。
020(From 0 to 0):即程序从无到有最终又被清除至无的整个过程。程序员从“0”开始创建、编辑程序的源代码,再经过编译、链接等形成一个二进制文件(程序),这个二进制文件(程序)由操作系统加载运行。操作系统运行结束后清理掉程序运行时所占用的内存,关闭程序打开的文件描述符,释放程序运行占用的物理资源,清理程序在内核中的数据结构等,至此。程序运行的痕迹被操作系统完全擦除,回归至“0”。
Hello:Hello简单来说是一个程序,它从一个由程序员通过编译器创建编写的一个源程序(源文件)hello.c开始,经过预处理器(cpp)的预处理生成hello.i文件,再经过编译器(ccl)的编译生成hello.s文件,然后编译器(as)的汇编生成hello.o文件,最后通过链接器(ld)进行链接成为可执行目标程序hello。此时,通过在shell中输入./hello将hello目标文件中的代码和数据从磁盘复制到主存,shell会调用fork函数创建一个子进程,并且通过加载保存上下文将控制权交给子进程。Hello加载完成后,处理器开始执行此程序,处理器将此程序先翻译成机器语言后翻译成指令编码,最后把程序的调用进行链接,将新的代码段和数据段初始化至hello目标文件。此时,加载器会从开始地址开始,进入主函数执行目标代码,而中央处理器(CPU)则会为hello分配时间片执行逻辑控制流。运行结束后输出的内容会从主存复制到寄存器文件中,再从寄存器复制到显示设备上,最终会在屏幕上呈现。至此进程终止,shell父进程回收子进程,操作系统恢复shell的上下文,将控制权重新还给shell。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:Intel Core i7 x64CPU 16G RAM 512G SSD
软件环境:Ubuntu18.04.1 LTS
开发与调试工具:visual studio 、edb、gcc、gdb、readelf、HexEdit、ld
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 作用
Hello.i 预处理器修改后的源程序,用来分析预处理器的行为
Hello.o 可重定位目标程序,用来分析汇编器的行为
Hello.out Hello反汇编之后的可重定位文件,用来分析重定位过程
Hello.s 编译器生成的编译程序,用来分析编译器行为
Hello.elf Hello.o的elf格式
Hello1.elf Hello的elf格式
Helloo.txt 可重定位目标文件hello.o使用objdump工具产生的反汇编代码
Hello 可执行目标程序,用来分析链接器行为

1.4 本章小结
本章主要介绍了hello的P2P和020的概念、过程等,并列出了本次实验所需环境以及中间结果介绍等。
(第1章0.5分)

第2章 预处理
2.1 预处理的概念与作用
预处理的概念:预处理是C语言的一个重要功能,它由预处理程序负责完成。当对一个源文件进行编译时,系统将自动引用预处理程序对源程序中的预处理部分作处理,处理完毕自动进入对源程序的编译。C语言提供多种预处理功能,主要处理以#开始的预编译指令,如宏定义(#define)、文件包含(#include)、条件编译(#ifdef)等。
预处理的作用:合理使用预处理功能编写的程序便于阅读、修改、移植和调试,也有利于模块化程序设计。以#includ为例,预处理器(cpp)把程序中声明的文件复制到这个程序中,具体到hello.c的就是#include <unistd.h> 、#include <stdlib.h>、#include<stdio.h>。cpp把这些头文件的内容插入到程序文本中,方便编译器进行下一步的编译。结果就是得到了另一个c程序,通常得到的程序以.i作为文件扩展名。
2.2在Ubuntu下预处理的命令
命令:gcc hello.c -m64 -no-pie -fno-PIC -E -o hello.i
截图,展示预处理过程!

图表 1预处理命令
2.3 Hello的预处理结果解析
结果:在目录下获得预处理文件hello.i

图表 2hello.i
解析:
Hello.i文件主要内容有处理过后的c语言代码等重要内容,打开hello.c文件如下图(由于Hello.c文件内容过多,只截取了一部分):

图表 3hello.c(1)

图表 4hello.c(2)
2.4 本章小结
本章内容主要包含预处理机制的概念和内容等,理解和分析预处理文件是hello生命周期的第一步,非常重要。
(第2章0.5分)

第3章 编译
3.1 编译的概念与作用
编译的概念:编译(compilation , compile)是指利用编译程序从预处理文本文件(.i)产生汇编程序(.s)的过程。这个过程就是把完成预处理后的文件进行一系列词法分析、语法分析、语义分析以及优化后生成相应的汇编代码文件。
编译的作用:经过编译,可以将高级编程语言转换成与机器指令转换相对简单的汇编语言,从而将人从繁杂的机器指令中解放出来,专注于高级的逻辑,提高了效率。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序。
3.2 在Ubuntu下编译的命令
命令:gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s

图表 5编译的命令
应截图,展示编译过程!
3.3 Hello的编译结果解析
结果:
图表 6编译的结果
3.3.0汇编指令:
.file:声明源文件
.text:代码节
.sectio:把代码划分成若干个段(Section)
.rodata:只读代码段
.align:数据或者指令的地址对齐方式
.string:声明一个字符串
.global:声明全局变量
.type:声明一个符号是数据类型还是函数类型

图表 7汇编指令
3.3.1局部变量(i):
如下图所示,hello.c文件中i为main函数中的一个局部变量,一般情况下c语言中的普通的局部变量应存放在栈帧中,但此时编译器将代码优化为将i存放在栈中。

图表 8局部变量i

3.3.2参数的传递:
	C语言中参数可以通过寄存器和栈帧传递,在hello程序中,main、exit、sleep、atoi、puts、printf函数的参数都以寄存器传递。

图表 9参数的传递

图表 10参数的传递

图表 11参数的传递
3.3.3字符串常量:
在hello.s中,字符串常量存放在.rodata段(只读数据段)中,如下图所示。

图表 12字符串常量
3.3.4数组(char *argv[]):
如下图所示,main函数的argv参数首元素的指针被存放在栈帧中,即数组的首地址被存放在栈中。

图表 13数组
3.3.5全局函数(main):
hello.c中声明了一个全局函数int main(int argc,char *argv[]),下图中汇编代码说明main函数是一个全局函数。

图表 14main函数
3.3.6赋值操作:
Hello中的赋值操作主要有:i=0,而这个操作在.s文件中汇编代码主要使用mov指令来实现。(mov指令:movb:一个字节赋值,movw:两个字节赋值,movl:四个字节赋值,movq:八个字节赋值)

图表 15赋值i=0
3.3.7算数操作:
Hello中的算数操作主要有i++,i是Int类型,因此在汇编代码中用addl实现此操作。

图表 16i=i+1
3.3.8关系操作:
Hello中存在条件语句中的条件判断操作:argc!=3,在汇编代码中被编译为cmpl $3,-20(%rbp),cmpl指令还具备设置条件码的作用,根据条件码来判断是否需要跳转到某个分支。

图表 17跳转
Hello中存在循环判断条件i<10,在汇编代码中被翻译为cmpl $9,-4(%rbp),cmpl设置条件码为下一步jle指令跳转做好准备。

图表 18判断
3.3.9函数操作:
Hello中设计的函数操作主要有:main,printf,exit,sleep,getchar函数。其中main函数的参数是argv和argc,printf函数的参数分别是字符串,exit函数的参数是1,sleep函数的参数是sleepsecs,所有函数的返回值都存储在%eax寄存器中。
函数的调用与传参:给函数传参需要先设定寄存器,将参数传给所设的寄存器,再通过call来跳转到调用的函数开头的地址。

图表 19调用函数
3.4 本章小结
本章概括了编译的概念和作用,重点分析了c程序的数据与操作翻译成汇编语言时的表示和处理方法, 编译器将.i 文件编译为.s 的汇编代码文件,hello 程序便从 高级语言(C语言)被解读成为为更加低级的汇编语言。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程,汇编器(as)将.s汇编程序翻译成机器语言并将这些指令打包成可重定目标程序的格式存放在.o目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。
汇编的作用:将汇编语言翻译成机器语言,使其在链接后能够被机器识别并执行。

4.2 在Ubuntu下汇编的命令
命令:gcc hello.s -c -o hello.o

图表 20汇编的命令
4.3 可重定位目标elf格式
在linux系统下生成Hello.o文件的elf格式的命令:readelf -a hello.o > hello.elf

图表 21ELF文件
分析hello.elf文件中的内容:

  1. elf头:ELF头(ELF header)是以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头其余部分包含了帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小、目标文件的类型(如可重定位、可执行、共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。

图表 22ELF头
2. 节头:负责记录各节的名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐等。

图表 23节头
3. 重定位节(.rela.text):重定位节保存的是.text节中需要被修正的信息(任何调用外部函数或者引用全局变量的指令都需要被修正),调用外部函数的指令和引用全局变量的指令需要重定位,调用局部函数的指令不需要重定位。Hello程序中需要被重定位的有printf、puts、exit、sleep、sleepseces、getchar和.rodata中的.L0和.L1。

图表 24重定位头
4. 符号表(.symtab):符号表存放在程序中定义和引用的函数和全局变量的信息。.symtab符号表不包含局部变量的条目。

图表 25符号表
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o > helloo.txt

图表 26
反汇编:

图表 27

图表 28
与hello.s进行对比发现差别有:
(1) 在汇编代码中使用.LC0(%rip)访问全局变量,反汇编代码中为0x0 (%rip),由于访问全局变量时需要重定位,所以反汇编中需要初始化为0并且添加重定位条目。

(2) 在汇编代码中调用函数时直接调用这个函的名称,而在反汇编文件中call之后是main加偏移量。

(3) 在汇编代码中分支跳转直接以.L0等助记符表示,但在反汇编代码中分支转移的表示为主函数+段内偏移量。

4.5 本章小结
本章对应的主要是hello.s汇编到hello.o的过程。在本章中,我们查看了hello.o的可重定位目标文件的格式,使用反汇编查看hello.o经过反汇编过程生成的代码并且把它与hello.s进行比较,分析和阐述了从汇编语言进一步翻译成为机器语言的汇编过程。
(第4章1分)

第5章 链接
5.1 链接的概念与作用
链接的概念:链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载(或复制)到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序执行的。
连接的作用:链接器在软件开发过程中扮演着一个关键的角色,链接重要性体现在它使得应用程序分离的编译过程成为了可能,使我们可以把一个大的应用程序和连接器分开进行编译,独立进行管理和单独进行修改,不必重新编译其他文件。极大的提升了进行大型文件编写的效率。
5.2 在Ubuntu下链接的命令
命令:ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.0/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

图表 29

图表 30
5.3 可执行目标文件hello的格式
生成Elf文件:

图表 31

(1) elf头:elf头的个数由13个增加为25个。Type类型为EXEC说明hello是一个可执行目标文件。

图表 32ELF头
(2) 节头(Section Headers):节头对Hello中所有的节信息进行了声明,包括size以及偏移量offset,根据节头中的信息可以通过hexedit定位各个节所占的空间的起始位置、大小等。
Address:程序被载入到虚拟地址的起始地址。

图表 33节头(1)

图表 34节头(2)
(3) 重定位节(.rela.text):

图表 35重定位节
(4) 符号表(.symtab):

图表 36重定位节(2)

5.4 hello的虚拟地址空间
通过edb加载hello程序。

图表 37
可以看出hello的虚拟地址空间开始于0x400000,结束于0x400ff0。

图表 38
根据hello.elf中的节头部表,可通过edb寻找各个节的信息。
如下图.txt节的虚拟地址开始于0x400500,大小为0x122。

图表 39
.interp的虚拟地址开始于0x400200:

图表 40

5.5 链接的重定位过程分析
命令:objdump -d -r hello > hello.out

图表 41
获得hello的反汇编代码如下图:

图表 42
分析Hello的反汇编与hello.o的反汇编的不同:

  1. hello的反汇编已经完成了重定位,有确定的虚拟地址,而hello.o的反汇编中代码的虚拟地址均为0,说明没有完成可重定位。

图表 43

图表 44
2. hello的反汇编代码中比hello.o反汇编代码e多了许多节以及函数的汇编代码(增加了.init和.plt节),这些节都具有一定的功能和含义,如下图。

图表 45hello.out中增多的节和代码
Hello的重定位过程:

  1. 重定位节和符号定义链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
  2. 重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。
  3. 重定位条目当编译器遇到对最终位置未知的目标引用时,它会生成一个重定位条目。代码的重定位条目放在.rel.txt中。
    5.6 hello的执行流程
    使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
    子程序名称 程序的地址
    <ld-2.27.so!_dl_start> 0x7f4939433ea0
    <ld-2.27.so!_dl_init> 0x7f25b2da77d0
    <hello!_start> 0x400500
    <libc-2.27.so!_lib_start_main> 0x7f600b73fb10
    <hello!puts@plt+0> 0x4004b0
    <hello!exit@plt+0> 0x4004e0
    <ld-2.27.so!_dl_runtime_resolv_xsavec> 0x601010
    <ld-2.27.so!_dl_fixup> 0x7f600bblef80
    <libc-2.27.so!exit> 0x7f600b761240
    <libc-2.27.so!_run_exit_handlers> 0x7f600b760ff0

5.7 Hello的动态链接分析
共享库:共享库是一个目标模块,在加载或运行时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程被称为动态链接,是由一个叫做动态链接器的程序来执行的。共享库也称为共享目标。
动态链接的基本思想:先把程序按照模块拆分成各个相对独立的部分,在程序运行时将这些相对独立的部分链接在一起形成一个完整的程序。
动态延迟绑定:动态的链接器在正常工作的时候链接器采取了延迟绑定的连接器策略,由于静态的编译器本身无法准确的预测变量和函数的绝对运行时的地址,动态的连接器需要等待编译器在程序开始加载时在对编译器进行延迟解析,这样的延迟绑定策略叫做动态延迟绑定。
延迟绑定是通过got和plt实现的。Got是数据段的一部分,而plt是代码段的一部分。

图表 46got起始表的位置为0x601000

5.8 本章小结
本章主要介绍了链接的概念和作用,通过对Hello可执行程序的分析,回顾了链接的基本概念、文件的重定位过程、动态链接过程、虚拟地址空间、可重定位目标文件elf格式的各个节等与链接相关的内容。
(以下格式自行编排,编辑时删除)
(第5章1分)

第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:进程的经典定义就是一个执行中程序的实例,系统中的每个程序都运行在某个进程的上下文中。
进程的作用:每次用户通过向shell应用程序输入一个可执行程序的名字,运行这个可执行程序时,shell就会自动创建一个新的应用进程,并在这个新的应用进程的可执行上下文中自动运行这个可执行文件,应用程序也同样自动创建新的可执行进程,并在这个新进程的可执行上下文中用户可以运行自己的可执行代码或者其他应用程序。进程为用户提供了两个假象:一是程序好像是当前系统中运行的唯一的程序,好像独占的使用处理器和内存。二是处理器好像是无间断的执行着程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Shell的作用:shell(壳)是一种交互型的应用级程序,是Linux的外壳,提供了一个界面,用户可以通过这个界面访问操作系统内核。
Bash:是一个为GNU项目编写的UNIX shell,也就是LINUX系统下的shell。
Shell-bash的作用就是linux系统的一个命令解释器。

图表 47shell
处理流程:

  1. 从终端读入输入的命令。
  2. 将输入字符串切分获得所有的参数。
  3. 如果是内置命令则立即执行。
  4. 否则调用相应的程序为其分配子进程并运行。
  5. shell应该接受键盘输入信号,并对这些信号进行相应处理。

图表 48shell处理流程
6.3 Hello的fork进程创建过程
在终端中输入命令后,shell会处理该命令,判断出不是内置命令,则会调用fork函数创建一个新的子进程,子进程几乎但不完全与父进程相同。通过fork函数,子进程得到与父进程用户级虚拟地址空间相同的但是虚拟地址独立、PID也不相同的一份副本。
例如:在shell上输入./hello,shell首先对输入的命令进行解析,由于这个命令不是一个内置的shell命令所以shell会认为hello是一个可执行目标文件,通过调用fork()创建一个子进程。
6.4 Hello的execve过程
当创建了一个子进程之后,子进程调用execve函数在当前子进程的上下文中加载并运行一个新的程序——hello程序。
Execve:execve调用驻留在内存中被称为启动加载器的操作系统代码来执行Hello程序,加载器删除子进程现有的用户虚拟内存段,并创建一组新的代码、数据、堆和栈,并将新的栈和堆初始化为0。然后通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。最后加载器设置PC 指向_start 地址,_start 最终调用hello中的main 函数。然后加载器将PC指向hello程序的起始位置(_start),即从下条指令开始执行hello程序。
6.5 Hello的进程执行
逻辑控制流:即使在系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果想用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一的对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC的值得序列叫做逻辑控制流,或者简称逻辑流。
时间片:一个进程执行它的控制流的一部分的每一个时间段叫做时间片。
并发流:一个逻辑流的执行在时间上与另一个流重叠。
私有地址空间:进程为每个流都提供一种假象,好像它是独占的使用系统地址空间。进程为每个程序提供它自己的私有空间地址,一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,从这个意义上说,这个地址空间是私有的。
用户模式和内核模式:处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围,从而使操作系统内核提供一个无懈可击的进程抽象。处理器通常是用某个控制寄存器的一个模式位来提供能这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据。
上下文:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构。
上下文切换:当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程:(1)保存之前进程的上下文(2)恢复某个先前被抢先的进程被保存的上下文(3)将控制传递给这个新恢复的进程。

图表 49进程的上下文切换
调度:在hello程序执行的某些时刻,内核可以决定抢占当前进程并重新开始一个先前被抢占了的进程,这种决策就叫做调度。
Hello的进程执行:在进程调用execve函数之后,进程已经为Hello程序分配好了新的虚拟地址空间,并且已经将Hello的.txt和.data节分配虚拟地址空间的代码区和数据区。在调用进程发送sleep之前,hello在当前的用户内核模式下进程继续运行,在内核中进程再次调用当前的sleep,之后进程进程转入用户内核等待休眠模式,内核中所有正在处理等待休眠请求的应用程序主动请求释放当前正在发送处理sleep休眠请求的进程,将当前调用hello的进程自动加入正在执行等待的队列,移除或退出正在内核中执行的进程等待队列。然后设置定时器,休眠时间等于设置的时间,当定时器时间到的时候发送一个中断信号。内核收到中断信号进行中断处理,hello被重新加入运行队列等待执行,这时Hello就可以运行在自己的逻辑控制流中。
6.6 hello的异常与信号处理

hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
Hello程序可能会出现的异常有:
(1) 中断:在程序执行过程中可能出现外部I/O设备引起的异常,或时钟中断等。
(2) 陷阱:陷阱是执行一条指令的结果,hello执行sleep函数时会出现这个异常。
(3) 故障:可能会发生缺页故障等。
(4) 终止:终止是不可恢复的错误,在hello执行过程可能会出现DRAM或SRAM位损坏的奇偶错误等。
会产生哪些信号:

图表 50信号
异常与信号的处理:

  1. 正常运行Hello程序,结果如下图,在程序执行结束后,进程被正常回收。

图表 51程序正常运行
2. 运行时在键盘上任意乱按,如下图,输入的内容会被保存在缓冲区中,等进程结束后作为命令行输入程序。

图表 52乱输入情况
3. 运行时按下ctrl+c,结果如下图,操作系统向进程发送SIGINT信号,信号处理程序终止并回收进程。

图表 53Ctrl+c
4. 运行时按下ctrl+z,结果如下图所示,shell进程收到操作系统发出的SIGSTP信号,挂起前台的作业,此时hello进程并没有回收,而是运行在后台下。通过ps命令我们可以看到Hello进程没有被回收,其进程号为4139,job ID为1,状态是Stopped,使用fg 1命令将其调到前台后,可以看到程序继续运行打印剩下的信息。

图表 54ps

图表 55jobs 和 fg 1
在程序进行时再次按下ctrl+z,此时用pstree查看进程,发现Hello进程在如图所示的位置。

图表 56pstree查看进程
5.输入kill -9 4190终止hello进程,再用jobs命令查看发现hello进程已被终止,在ps命令下查看已经没有Hello进程,表明进程已经被终止、回收。

图表 57杀死进程

6.7本章小结
本章主要阐释了进程的定义与作用,介绍了shell-bash的处理流程和作用,重点分析了调用fork()创建新的进程,调用execve函数执行hello程序,以及Hello的进程执行等,最后着重分析了hello进程的异常与信号处理。
(第6章1分)

第7章 hello的存储管理
7.1 hello的存储器地址空间
1.逻辑地址:逻辑地址是程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或一条指令的地址。它由选择符和偏移量组成。
2.线性地址(虚拟地址):一个逻辑地址在经过段地址机制的转化后变成一个线性分页地址,它与逻辑地址类似也是一个不真实的地址。其格式可以表示为虚拟地址描述符:偏移量。
3.物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。物理地址用于内存芯片级的单元寻址,与处理器和CPU链接的地址总线相对应。最后两位涉及权限检查。
7.2 Intel逻辑地址到线性地址的变换-段式管理
机器语言指令中出现的内存地址,都是逻辑地址,需要转换成线性地址,再经过MMU(CPU中的内存管理单元)转换成物理地址才能够被访问到。
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,表示具体的是代码段寄存器还是栈段寄存器抑或是数据段寄存器。如图所示。

图表 58段选择符
每个段的首地址会被存储在各自的段描述符里,而所有的段描述符都会位于段全局描述符表中。通过段选择符可以快速寻找到某个段的段全局描述符。段地址的偏移量结构为段选择符+偏移量。
索引号就是段描述符的索引,段描述符具体地址描述了一个段。很多个段描述符,就组了一个数组,叫“段描述符表”,这样,我们就可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符。
逻辑地址转换为线性地址:

  1. 首先对于一个给定的逻辑地址(段选择符:段内偏移地址),看其段选择符的T1=0还是=1,判断需要转换的是GDT中的段还是LDT中的段,再根据相应的寄存器得到地址和大小,获得一个数组(段描述符表)。
  2. 取段选择符中的前13位,在数组中查找到对应的段描述符,获得BASE。
  3. 将base+offset获得转换后的线性地址。

图表 59逻辑地址转换为线性地址
7.3 Hello的线性地址到物理地址的变换-页式管理
从管理和效率的角度出发,线性地址被分为以固定长度为单位的组,称为页 (page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page [2^20]的大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址——对应的页的地址。另一类“页”,我们称之为物理页,或者是页框、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。
线性地址到物理地址之间的转换通过分页机制完成,分页机制是对虚拟地址内存空间进行分页。
地址翻译:把一个n元素的虚拟地址空间(vas)中的虚拟元素与一个m元素的另一个物理虚拟地址空间进行相互映射。具体示意如下:

图表 60地址翻译
同任何缓存一样,虚拟内存系统必须有某种方法来判定一个虚拟页是否缓存在DRAM中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择牺牲一个页,并将虚拟页从磁盘中复制到DRAM中,替换这个牺牲页。
这些功能是由软硬件联合提供的,包括操作系统软件、MMU(内存管理单元)中的地址翻译硬件和一个存放在物理内存中叫做页表的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表,操作系统负责维护页表的内容,以及在磁盘和DRAM之间来回传送页。
7.4 TLB与四级页表支持下的VA到PA的变换
按照上述的变换模式,每次CPU产生一个虚拟地址并发送给地址管理单元时,MMU就必须查找到一个PTE行用来将虚拟地址翻译成物理地址,但如果情况不好的话,会从内存多去一次数据,代价非常大,为了消除这种操作带来的大量时间开销,许多系统在MMU中设计了一个关于PTE的小的、虚拟寻址的缓存,成为翻译后备缓冲器(TLB),也被称为快表。
在MMU中,TLB李宇VPN进行寻址,VPN分为组索引(TLBI)和标记(TLBT),用来区别可能映射到同一个TLB组的不同的VPN。如下图所示:

图表 61虚拟地址组成
这样,MMU在读取PTE时会直接通过TLB,若不明智则从内存中将PTE复制给TLB。
为了减少页表太大而造成的空间的损失,使用层次结构的页表来压缩页表。
以Corei7为例,corei7采用的是四级页表层次结构,每个四级页表进程都有自己的私有的页表层次结构,这种设计方法从两个方面减少了对内存的需求:如果以及页表的PTE全部为空,那么二级页表就不会继续存在,从而为进程节省了大量的内存,并且只有一级页表才会有需求总是在一个内存中。
四级页表的层次结构操作流程如下:

图表 62四级页表
36位的VPN被划分为四个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器存储了L1页表的物理地址,指向第一级页表的一个起始和最终位置。VPN1提供一个到L1 PTE的偏移量,这个PTE包含L2页表的基地址。VPN2提供一个到L2 PTE的偏移量,四级以此类推。
7.5 三级Cache支持下的物理内存访问
Cache(高速缓存器):解决处理器与慢速DRAM(内存)设备之间巨大的速度差异而设计的。Cache属于硬件系统,linux不会管理cache但会提供flush整个cache的接口。Cache分为一级cache、二级cache、三级cache等。
三级cache的缓存层次结构:

图表 63三级cache
三级Cache支持下的物理内存访问:
物理地址的结构包括组索引位(CI),标记位(CT),偏移量(CO)。如下图所示。

图表 64
得到一个物理地址后,可以通过组索引确定在cache里的哪一组,通过标记位确定是否与组里某一行的标记相同,如果有相同行,通过块偏移位确定具体是哪个数据块,从而获得我们所需要的数据。如果没有在cache中找到,则需要从内存中取数据,并且找到cache里最久没有被用到的一行进行替换。
7.6 hello进程fork时的内存映射
内存映射:Linux通过将一个虚拟内存区域与一个磁盘上的对象关联起来以初始化这个内存区域,这个过程成为内存映射。
当fork函数被当前进程调用(创建Hello进程)时,内核为hello进程创建各种数据结构,并分配给他一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样的副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有写时复制。
当fork在新进程返回时,新进程现在的虚拟内存恰好与调用fork之前的虚拟内存相同,此时新进程并不包括Hello程序相关内容,只有在新进程调用execve函数加载Hello程序,当要对虚拟内存进行写操作时,写时复制机制就会创建一个新的页面将hello的各个段映射到相应区域。

图表 65虚拟内存结构

7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。 它可能会自动覆盖当前进程中的所有虚拟地址和空间,删除当前进程虚拟地址的所有用户虚拟和部分空间中的已存在的代码共享区域和结构,但它不会自动创建一个新的代码共享进程。
加载并运行 hello 需要以下几个步骤:

  1. 自动覆盖当前进程中的所有虚拟地址和空间,删除当前进程虚拟地址的所有用户虚拟和部分空间中的已存在的代码共享区域和结构。
  2. 通过链接映射到新的私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,这些新的区域都是私有的、写时复制的。
  3. 通过链接映射共享区域,hello程序与共享对象libc.so进行链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间的共享区域中。
  4. 设置程序计数器(PC),execve函数做的最后一步就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点(_start函数)。

图表 66加载器映射用户地址空间
7.8 缺页故障与缺页中断处理
缺页故障:在指令请求一个虚拟地址时,MMU中查找页表,如果这时对应得物理地址没有存在主存的内部,我们必须要从磁盘中读出数据。在虚拟内存的习惯说法中,DRAM缓存不命中成为缺页。
处理:在发生缺页后系统会调用内核中的一个缺页处理程序,选择一个页面作为牺牲页面。具体流程如下:

  1. CPU生成一个虚拟地址,并将这个虚拟地址传递给MMU
  2. 地址管理单元生产PET地址,向高速缓存或者主存请求获取
  3. 高速缓存或主存向MMU返回PET
  4. PET中有效位为0,MMU出发一个异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
  5. 缺页处理程序确定物理内存中的牺牲页,如果这个页面已经被修改则将它换出至磁盘
  6. 缺页处理程序调用新的页面并更新内存中的PTE
  7. 缺页处理程序返回地址到原来的进程,重新启动导致缺页的指令,该指令将从内存中正常的读取。

图表 67缺页处理
7.9动态存储分配管理
动态分配器维护着一个进程的虚拟内存区域。成为堆(heap)。分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟虚拟内存片(chunk),要么是已分配的,要么是空闲的。空闲块可用来分配。空闲块保持空间,直到它显式的被应用所分配。一个已分配的块保持已分配状态,直到他被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。分配器有两种基本风格:显式分配器和隐式分配器。
对于hello程序,使用的是显式分配器。C标准库通过malloc程序包的显式分配器。如下图。

图表 68malloc
Malloc函数返回一个至少包含size大小的内存块的指针,这里的size需要数据对齐的块大小,64位中返回的地址总位是16的倍数,32位中是8的倍数。
Free函数吧当前地址对应的块释放成空闲块。

图表 69free
任何实际的分配器都需要一些数据结构允许他来区别块边界以及区别已分配块和空闲块,大多数分配器将这些信息嵌入块本身,主要有三种方法构造分配器:

  1. 隐式空闲链表:一个块是由一个字的头部、有效载荷以及可能的一些额外的填充组成的。头部编码了这个块的大小,以及这个快是已分配还是空闲。其数据结构如下图所示。这种结构下块与块之间是通过头部的块大小隐式连接在一起,意思是我们要找到下一个块,只需要用当前块地址加上块大小即可。

图表 70隐式空闲链表
2. 显式空闲链表:因为块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不适合的。显式空间链表是将空闲块组织为某种形式的显示数据结构,这种结构比隐式多了两个指针,一个指针指向前一个空闲块,一个指针指向后一个空闲块,这样做的好处是在查找空闲块时,不需要进行很多无用的查找操作(隐式查找时需要看每一个块,这里只需要看空闲块)。

图表 71隐式空间链表
3. 分离的空闲链表:这种方法是在显式空闲链表的基础上进行的优化,把所有的块大小分为一些等价类,每个等价类里都有一个链表,我们寻找空闲块时就只需要在对应大小的链表里寻找即可。
7.10本章小结
本章主要介绍了程序的存储结构,通过段式管理从逻辑地址变换为虚拟地址,通过页式管理从虚拟地址变换到物理地址。分析了程序访问过程中高速缓存器cache的结构和多级页表结构等,以及进程是如何加载虚拟内存空间,内存映射和动态内存分配问题。这些知识有助于我们编写代码时更好的利用高速缓存器,加速程序的运行。
(第7章 2分)

第8章 hello的IO管理
8.1 Linux的IO设备管理方法
输入输出(I/O)是在主存和外部设备之间复制数据的过程。输入操作是从I/O设备复制数据到主存,输出操作是从主存复制数据到I/O设备。
一个Linux文件就是一个m字节的序列:B0,B1,B2……Bm
所有的 IO 设备(如网路、磁盘、终端)都被模型化为文件,而所有的输入和输出都被 当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许linux内核引出一个简单、低级的应用接口,称为Unix I/O,使得所有的输入和输出都能以一种统一且一致的方式来执行。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
Unix IO接口:

  1. 打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。而应用程序只需要记住这个描述符。
  2. Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO、STOOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
  3. 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为K 。
  4. 读写文件:一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。
  5. 关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
    Unix I/O函数:
  6. 进程是通过调用open 函数来打开一个已存在的文件或者创建一个新文件的(int open(char* filename,int flags,mode_t mode)):open函数将filename(文件名) 转换为一个文件描述符(C语言中形式表现为指针),并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。
  7. 进程通过调用close 函数关闭一个已打开的文件。intclose(int fd),fd是需要关闭的文件的描述符,close返回操作结果:若关闭成功则为0, 若出错则为-1。
  8. 应用程序是分别调用read 和write 函数来执行输入和输出。
    ssize_tread(int fd, void *buf, size_t n);read函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示发生一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。
    ssize_twrite(int fd, const void *buf, size_t n);write函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。
    8.3 printf的实现分析
    Printf的函数体:

图表 72printf函数体
Printf函数的内容:
1.由下图va_list 的定义可知,va_list是一个字符指针。其中的((char*)(&fmt)+4)表示的是…中的第一个参数。

图表 73va_list
2.由下图可以看出,vsprintf函数返回的的是一个长度(要打印出来的字符串的长度)。Write是写操作,把buf中的i个元素的值写到终端。因此,vsprintf的作用就是格式化,它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出

图表 74vsprintf

图表 75vsprintf
4. 下一步,调用write,在printf中调用系统函数write(buf,i)将长度为i的buf输出,在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址。

图表 76write
可以找到INT_VECTOR_SYS_CALL的实现:

Int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数,sys_call函数如下:

图表 77sys_call
可以看出,sys_call将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCLL码值。
5. 最后通过字符显示驱动子程序从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息),显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),将打印字符串显示在屏幕上。

8.4 getchar的实现分析
Getchar的源代码为:

图表 78getchar源代码

异步异常-键盘中断的处理:当用户在敲击键盘上的按钮时,键盘接口获得一个代表该按键的键盘扫描码,同时产生一个中断请求,这时键盘中断处理子程序。接受按键扫描码转成ASCLL码并保存到系统的键盘缓冲区内部。
Getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区的ASCLL码知道读到回车键才返回这个字符串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后进行返回操作。

8.5本章小结
本章主要介绍了 Linux 的 IO 设备管理方法——将所有设备映射为文件,允许linux内核引出一个简单、低级的应用接口——Unix I/O、Unix IO 接口及其函数,主要对 printf 函数和 getchar 函数的实现进行了分析。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
Hello经历的过程:

  1. hello出生于文本编译器,是由程序员用高级语言亲手写出的一个个字符,形成hello.c文件。
  2. 之后Hello要经过预处理操作,在这一步我们会对hello中带#的指令进行解析,生成hello.i文件。
  3. 经过预处理后的hello仍然无法被计算机识别,因此要进行编译操作,这一步将c语言程序编译成汇编语言程序,生成hello.s文件。
  4. 之后,hello.s还需要经过汇编阶段变成可重定位目标文件hello.o,成为机器可以读懂的东西。
  5. 链接:Hello.o文件和动态链接库共同链接成可执行目标程序hello。至此P2P过程结束。
  6. 接下来要对程序进行运行,用户在shell中输入./hello 1190200807 张雯琦后按下回车键运行Hello程序。
  7. Shell调用fork和execve函数加载映射虚拟内存,为hello程序创建新的代码、数据、堆和栈段等,删除原来的进程内容,加载我们现在进程的代码,数据等到进程自己的虚拟内存空间。
  8. CPU取指令并为进程分配时间片,加载器将计数器预置在程序入口点,hello顺序的执行进程的逻辑控制流。此时CPU会给出一个虚拟地址,通过MMU从页表里得到物理地址, 在通过这个物理地址去cache或者内存里可以获得需要的数据。
  9. 在程序执行过程中,如果从键盘输入Ctrl-C等命令,会给进程发送一个信号,然后通过信号处理函数对信号进行处理。
  10. 执行完成后父进程回收子进程,内核删除为该进程创建的数据结构,到此为止,hello运行结束。

你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
通过详细全面的追踪Hello的一生,我深刻感受到计算机内部如此精妙,完美和谐的设计机制,这让我认识到每一个小小的程序在计算机中都有自己的完整的一生,要经历很多事情,也提醒我在设计一个程序时需要多方位取考虑,也需要多环节去考虑,想办法让让小小的机器和小小的代码发挥更加强大的力量。
(结论0分,缺失 -1分,根据内容酌情加分)

附件

列出所有的中间产物的文件名,并予以说明起作用。
文件名 作用
Hello.i 预处理器修改后的源程序,用来分析预处理器的行为
Hello.o 可重定位目标程序,用来分析汇编器的行为
Hello.out Hello反汇编之后的可重定位文件,用来分析重定位过程
Hello.s 编译器生成的编译程序,用来分析编译器行为
Hello.elf Hello.o的elf格式
Hello1.elf Hello的elf格式
Helloo.txt 可重定位目标文件hello.o使用objdump工具产生的反汇编代码
Hello 可执行目标程序,用来分析链接器行为

(附件0分,缺失 -1分)

参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 深入理解计算机系统 大卫R.奥哈拉伦 兰德尔E.布莱恩特
[2] printf函数实现的深入剖析 https://www.cnblogs.com/pianist/p/3315801.html
[3] ELF格式分析:https://blog.csdn.net/edonlii/article/details/8779075
[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.
(参考文献0分,缺失 -1分)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值