计算机系统大作业 Hello的一生

题     目  程序人生-Hello’s P2P

专       业      计算学部                 

学     号      1190200704                 

班     级      1903008                 

学       生      高展鹏               

指 导 教 师      吴锐                

计算机科学与技术学院

2021年5月

摘  要

本文基于IntelCorei5处理器和Linux系统,利用gcc、objdump等工具,简述了Hello.c源程序的预处理、编译、汇编、链接、运行的主要过程,以及hello程序的进程管理、存储管理与I/O管理,通过hello.c这一程序的程序周期的描述,对程序的编译、加载、运行有个初步的了解。

    本文结合课本和课外资料,将计算机系统的各方面的知识与程序生成、运行的各阶段系统的结合起来,将课本知识落实、融会贯通,成为一个整体,构建完整的程序运行过程,更好的阐明了程序的整个生命周期。

关键词:深入理解计算机系统;预处理;编译;汇编;链接;加载;进程 ;I/O管理                           

目  录

第1章 概述... - 4 -

1.1 Hello简介... - 4 -

1.2 环境与工具... - 4 -

1.3 中间结果... - 5 -

1.4 本章小结... - 5 -

第2章 预处理... - 6 -

2.1 预处理的概念与作用... - 6 -

2.2在Ubuntu下预处理的命令... - 6 -

2.3 Hello的预处理结果解析... - 6 -

2.4 本章小结... - 8 -

第3章 编译... - 9 -

3.1 编译的概念与作用... - 9 -

3.2 在Ubuntu下编译的命令... - 9 -

3.3 Hello的编译结果解析... - 9 -

3.4 本章小结... - 14 -

第4章 汇编... - 15 -

4.1 汇编的概念与作用... - 15 -

4.2 在Ubuntu下汇编的命令... - 15 -

4.3 可重定位目标elf格式... - 15 -

4.4 Hello.o的结果解析... - 19 -

4.5 本章小结... - 21 -

第5章 链接... - 22 -

5.1 链接的概念与作用... - 22 -

5.2 在Ubuntu下链接的命令... - 22 -

5.3 可执行目标文件hello的格式... - 22 -

5.4 hello的虚拟地址空间... - 30 -

5.5 链接的重定位过程分析... - 32 -

5.6 hello的执行流程... - 34 -

5.7 Hello的动态链接分析... - 34 -

5.8 本章小结... - 35 -

第6章 hello进程管理... - 37 -

6.1 进程的概念与作用... - 37 -

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

6.3 Hello的fork进程创建过程... - 37 -

6.4 Hello的execve过程... - 38 -

6.5 Hello的进程执行... - 40 -

6.6 hello的异常与信号处理... - 41 -

6.7本章小结... - 42 -

第7章 hello的存储管理... - 43 -

7.1 hello的存储器地址空间... - 43 -

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

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

7.4 TLB与四级页表支持下的VA到PA的变换... - 45 -

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

7.6 hello进程fork时的内存映射... - 47 -

7.7 hello进程execve时的内存映射... - 49 -

7.8 缺页故障与缺页中断处理... - 50 -

7.9动态存储分配管理... - 52 -

7.10本章小结... - 54 -

第8章 hello的IO管理... - 55 -

8.1 Linux的IO设备管理方法... - 55 -

8.2 简述Unix IO接口及其函数... - 55 -

8.3 printf的实现分析... - 56 -

8.4 getchar的实现分析... - 57 -

8.5本章小结... - 57 -

结论... - 58 -

附件... - 59 -

参考文献... - 60 -

第1章 概述

1.1 Hello简介

使用I/O设备编写高级语言代码hello.c,存储在磁盘中。hello.c经过预处理,删除了注释,插入了库函数,成为了hello.i。

经过了编译器后,它被编译为为hello.s,在这个过程中,他承受了编译器对原代码进行的词法、语法、语义分析,被编写成对应的代码并且被注入伪指令添加各种注释,以此有利于后续的工作。

hello.s经过汇编阶段的翻译成可重定位目标程序hello.o,在这个阶段汇编器生成ELF格式的文件,包含各种重定位条目、符号表、数据段、代码段等区段,他的这些属性十分重要。

hello.o解除了很多动态库和静态库,在链接阶段,通过链接器,与其他必要的可重定位目标文件、共享库链妾,并对符号进行重定位,最后生成可执行目标文件hello,至此,hello终于可以实现其功能。

Linux的Shell 通过fork为hello创建新进程,他调用execve在相应进程上运行hello程序。然后Shell调用mmap函数创建新的虚拟内空间,并构建内存映射。之后计算机通过内核和各种硬件如寄存器、CPU、MMU、TLB、三级Cache、四级页表等来进行内存的翻译、访问和加速, hello的虚拟空间也用了许多他要的数据。

最后,在hello程序结束后,Shell 调用waitpid函数回收hello进程,内核释放内存,删除为hello创建的所有数据结构,hello失去生命,完成了作为一个程序的使命。

1.2 环境与工具

硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上

软件环境:Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位;

开发与调试工具GCCEDBHexeditObjdumpreadelf vscode

1.3 中间结果

hello.c:源程序

hello.i:预处理后的文本文件

hello.s:编译后汇编程序文本文件

hello.o:汇编后的可重定位目标程序(二进制文件)

hello_o.txt:hello.o的反汇编文件

hello.txt:hello的反汇编文件

hello_o_elf.txt:ELF格式下的hello.o

hello_elf.txt:ELF格式下的hello

hello:链接后的可执行目标文件

1.4 本章小结

本章简述了hello.c源程序的程序周期:P2P和O2O的过程。并且介绍实验的硬软件环境、开发和调试工具,以及中间所生成的相关文件。

第2章 预处理

2.1 预处理的概念与作用

预处理的概念:在编译之前进行的处理。

C语言的预处理有三方面的内容: 1.宏定义; 2.文件包含; 3.条件编译。预处理器 cpp 根据以字符#开头的命令(宏定义、条件编译),修改原始的 C 程序,将引用的所有库展开合并成为一个完整的文本文件。

预处理作用:

处理源文件中以“#”开头的预编译指令,包括:

删除“#define”并展开所定义的宏

处理所有条件预编译指令,如“#if”,“#ifdef”, “#endif”等

插入头文件到“#include”处,可以递归方式进行处理

删除所有的注释“//”和“/* */”

添加行号和文件名标识,以便编译时编译器产生调试用的行号信息

保留所有#pragma编译指令(编译器需要用)

2.2在Ubuntu下预处理的命令

生成文件hello.i

2.3 Hello的预处理结果解析

hello.i的内容

hello.c几十行被拓展到了3060行,而int main被推到了最后。stdio.h unistd.h stdlib.h头文件被依次插入到相应的位置,对应的/usr/include/stdio.h 其中依然使用了#define 语句,但cpp 删除“#define”并展开所定义的宏,最终hello.i中是没有的。

进行搜索#define不存在,这是由于.h中其中使用了大量的#ifdef #ifndef 的语句,cpp 对条件值进行判断来决定执不执行其中的语句。经过预编译处理后,得到的是预处理文件(hello.i) ,它被拓展了很多内容,还是一个可读的文本文件,但不包含任何宏定义。

教材上对于预处理的解释

2.4 本章小结

本章介绍了预处理阶段的相关概念、定义、应用以及方法,通过具体的hello实例说明预处理过程中对头文件stdio.h的解析、对头文件stdlib.h的解析、对头文件unistd.h的解析。浴池里过层中进行了头文件引用,define宏替换,删除注释。

第3章 编译

3.1 编译的概念与作用

编译的概念:

  编译器将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。编译的过程实质上是把预处理文件进行词法分析、语法分析、语义分析、优化,从C语言等高级语言转换为成机器更好理解的汇编语言程序,转换后的文件仍为ASCII文本文件。

编译的作用:

  编译后生成的.s汇编语言程序文本文件比预处理文件更容易让机器理解、比.o可重定位目标文件更容易让程序员理解,是对于程序像机器指令的一步关键过程。

3.2 在Ubuntu下编译的命令

生成文件hello.s

3.3 Hello的编译结果解析

hello.s的内容

3.3.1常量解析

对于代码开始解析

   

.file 源文件是 本处是hello.c

.text 下面是代码段

.section .rodata 下面是rodata 节

.align 声明对指令或者数据的存放地址进行对齐的方式 本处为8

.LC0

.LC1

字符串分别为如下

.globl 声明全局变量 main

.type 用来指定是函数类型或是对象类型 本处main @functio

3.3.2程序中的数据类型

hello.s 用到的 C 数据类型:整数、字符串、数组。

1.整数

程序中的整数有如下这些

1)int argc:作为main函数第一个参数传入,这个argc值是后面参数argv指针数组的数组元素个数

2)int i:编译器将局部变量i存储在了栈空间中,本处编译器将 i 存储在栈上空间-4(%rbp)中。

3)立即数:直接在指令中出现。

2.字符串

程序中的字符串

1)第一个printf函数:输出格式化参数,在hello.s中声明如图,字符串被编码成UTF-8格式,一个汉字在utf-8编码用三个字节来表示,用\区分每个字节。

2)第二个printf函数:传入输出格式化参数"Hello %s %s\n",并且其是只读的。

后两个字符串都声明在了.rodata 只读数据节。

3.数组

程序中的数组

char *argv[] 数组的具体内容是main,函数执行时输入的命令行,argv 作为存放 char 指针的数组同时是第二个参数传入。argv 指针指向已经分配好的、一片存放着字符指针的连续空间。

3.3.3程序中的赋值

我们为变量i赋值为0

3.3.4程序中的类型转换

这个程序里使用了atoi,其作用是把字符串转换成整型数。

这个操作的目的是将我们在命令行上键入的argv[3]给转换成int类型值传递给sleep函数,也就是把秒数传递给sleep函数,放便于后续的操作。

3.3.5程序中的算术操作

对i进行了i++的运算操作,使用了addl

3.3.6程序中的关系操作

1)

第一个if(argc!=4):判断 argc 不等于 4。hello.s 中使用 cmpl $4,-20(%rbp),计算 argc-4,设置条件码,为下一步 je跳转作准备。

2)

第二个i<8:判断 i 小于8。hello.s 中使用 cmpl $7,-4(%rbp),计算 i-7 然后设置条件码,为下一步 jle跳转做准备。

3.3.7程序中的控制转移

1)if判断

不难发现这是一个if判断:当 argv 不等于 4 的时候执行程序段中的代码。对于 if 判断,编译器使用跳转指令实现,首先 cmpl 比较 argv 和 4,对于比较操作,是用减法实现的,并设置条件码,使用 je 判断 ZF 标志位,如果为 0,说明 argv-4=0也就说明了 argv==4,那么就不执行if 中的代码直接跳转到.L2顺序执行下一条语句。

2)for循环

不难发现这是一个for循环:for(i=0;i<8;i++):使用计数变量 i 和跳转语句循环 8 次。首先无条件跳转到位于循环体.L4 之后的比较代码,使用 cmpl 进行比较,如果 i<=7,则跳入.L4 for 循环体执行,否则说明循环结束。

3.3.8程序中的函数

1)main 函数:

传递控制:main 函数被调用 call 执行,call 指令将下一条指令的地址压栈,然后跳转到 main 函数。

传递数据:外部调用过程向 main 函数传递参数 argc 和 argv,分别使用%rdi 和%rsi 存储,将%eax 设置 0返回,也就是return 0 。

最后的leave 指令相当于mov %rbp,%rsp,pop %rbp,恢复栈空间为调用之前的状态,然后 ret返回,ret 相当 pop IP,将下一条要执行指令的地址设置返回地址。

2)printf 函数:

传递数据:第一次 printf 将%rdi 设置为("用法: Hello 学号 姓名 秒数!\n")字符串的首地址。第二次 printf 设置%rdi 为“Hello %s %s\n”的首地址,设置%rsi 为 argv[1],%rdx 为 argv[2]。

控制传递:第一次 printf 只有一个字符串参数,call puts;第二次 printf 使用 call 的是printf

3)atoi函数:

传递参数:

把argv[3]的数据传给%rdi,作为atoi的参数,其中使用了%rax寄存器

控制转移:call atoi

4)sleep 函数:

传递数据:将%edi 设置为atoi(argv[3])。

控制传递:call sleep

5)getchar 函数:

控制传递:call gethcar

6)exit 函数:

传递数据:将%edi 设置为 1。

控制传递:call exit

3.4 本章小结

本章中解析了汇编代码中的C语言程序结构,可以看到汇编语言是更接近机器的一种语言。我们通过对于hello.i进行编译,获得hello.s文件,结合编译的概念及作用分析了编译对文本.i文件的相应处理,详细地阐述了数据、赋值、类型转换、算术操作、逻辑/位操作、关系操作、数组/指针/结构操作、控制转移、函数操作的过程,并对结果进行了相应的解析。完成此阶段后,hello.s就可以进行下一阶段的汇编处理。

第4章 汇编

4.1 汇编的概念与作用

概念:

  汇编器将hello.s文件翻译成二进制机器语言指令,把这些指令打包成可重定位目标程序格式,并将结果保存到目标文件hello.o中。hello.o是一个二进制文件,包含着程序的指令编码,如果用文本编辑器打开,将看到一堆乱码。

作用:

  汇编过程将汇编代码转换为计算机能够理解并执行的二进制机器代码,这个二进制机器代码是程序在本机器上的机器语言的表示。

4.2 在Ubuntu下汇编的命令

生成的文件hello.o

4.3 可重定位目标elf格式

生成hello.o的elf文件

4.3.1elf

elf头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。elf头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括elf头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。

hello的相关信息如下:

hello.o的elf以一个16进制序列:7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00作为elf头的开头。这个序列描述了生成该文件的系统的字的大小为8字节和字节顺序为小端序。

从上面的信息中我们可以得知elf头的大小为64字节,目标文件的类型为可重定位文件、机器类型为Advanced Micro Devices X86-64即AMD X86-64、节头部表的文件偏移为0,以及节头部表中条目的大小,其数量为13。

4.3.2节头部表

由教材我们可以知道节头部表是描述目标文件的节,里面有如下的信息:

.text

已编译程序的机器代码

.rodata

只读数据

.data

已初始化的全局和静态C变量

.bss未初始化的全局和静态C遍历

.symtab

存放程序中定义和引用的函数和全局变量信息

.rel.text

一个.text节中位置的列表,链接时修改

.rel.data

被模块引用或定义的所有全局变量的重定位信息

.debug

条目是局部变量、类型定义、全局变量及C源文件

.line

C源程序中行号和.text节机器指令的映射

.strtab

.symtab和.debug中符号表及节头部中节的名字

hello.o的节头部表如下所示:

4.3.3重定位节

重定位节.rela.text ,一个.text 节中位置的列表,包含.text 节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。

我们可以计算重定位的地址:以.rodata的重定位为例,它的重定位地址为refptr.则应先计算引用的运行时地址refaddr=ADDR(s)+ r.offset, .rodata的offset为0x16。然后,更新引用,refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr), addend查表可知为0,refaddr已经算出来了,所以,.rodata的重定位地址我们就可以算出来了。

4.3.4符号表

符号表,它存放在程序中定义和引用的函数和全局变量的信息。符号表有汇编器构造,使用编译器输出到汇编语言.s文件中的符号。每个符号表是一个条目的数组。hello.o的符号表如下所示:

其中的符号对应关系如下:

Value符号对应字符串在strtab节中的偏移量

Size符号对应目标字节数

Type符号类型:数据、函数、源文件、节、未知

Bind绑定属性:全局符号、局部符号、弱符号

Ndx符号对应目标所在的节,或其他情况,比如 UND节

Name名字

4.4 Hello.o的结果解析

生成hello.o的反汇编文件

hello.o的反汇编内容如下:

对比hello.o和hello.s,可以发现,对于具体的语句类型,汇编与反汇编几乎一致,不同点具体如下:

1)hello.s前没有一串二进制数,这是程序相应的机器码;

反汇编代码前面有与之对应的机器码。

2)立即数在hello.s这一汇编语言文本文件中为十进制;

而在反汇编代码中为十六进制。

3)在汇编代码中,代码直接声明具体的段存储位置,通过存储在.rodata段中的符号如.L2,jmp直接跳转至相应符号声明位置;

而反汇编代码是依据地址跳转的。

4)汇编代码仍然采用直接声明的方式,即通过助记符,如call getchar

而反汇编代码采用重定向的方式进行跳转,机器代码在此处留下一些地址以供链接时重定向,链接时根据标识、重定位条目自动填充地址。

4.5 本章小结

在汇编阶段,汇编程序成为了.o 可重定位文件。本章中将反汇编文件与.s汇编程序进行比较,描述了它们的区别与转化并且。接下来可进入下一步工作。

5章 链接

5.1 链接的概念与作用

概念:

  链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接执行符号解析、重定位过程。

作用:

  把可重定位目标文件和命令行参数作为输入,产生一个完全链接的,可以加载运行的可执行目标文件。使得分离编译成为可能。

5.2 在Ubuntu下链接的命令

生成的文件hello

5.3 可执行目标文件hello的格式

生成的文件hello_elf.txt

5.3.1elf

对于elf头进行分析可以得到如下信息:

1)Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00

确定文件的类型或格式,与之前是一样的

2)类别: ELF64

ELF64格式

3)数据: 2 补码, 小端序little endian

二进制补码类型,以及得知机器是little endian 小端

4)Version: 1 (current)

版本信息Version

5)OS/ABI: UNIX - System V

操作系统UNIX - System V

6)ABI Version: 0

ABI版本

7)类型: EXEC (可执行文件)

目标文件格式 可执行文件EXEC (Executable file)

8)系统架构: Advanced Micro Devices X86-64

Advanced Micro Devices X86-64的机器

9)版本: 0x1

版本

10)入口点地址: 0x401090

程序执行的入口地址

11)程序头起点: 64 (bytes into file)

段头部表的开始

12)Start of section headers: 14208 (bytes into file)

节头部表的开始

13)标志: 0x0

标志

14)Size of this header: 64 (bytes)

头大小

15)Size of program headers: 56(bytes)

段头部表大小

16)Number of program headers: 12

几个段头部表

17)Size of section headers: 64 (bytes)

节头部表大小

18)Number of section headers: 27

节头部表数量

19)Section header string table index: 26

字符串表在节头部表中的索引

5.3.2节头部表

与hello.o相比,hello的节头部表多出来了如下的部分:
1)interp段:

动态链接器由ELF文件中的 .interp段指定。该段里保存的是一个字符串,这个字符串就是可执行文件所需要的动态链接器的位置,常位于/lib/ld-linux.so.2。

2)dynamic段:

该段中保存了动态链接器所需要的基本信息,是一个结构数组,可以看做动态链接下 ELF文件的“文件头”。存储了动态链接会用到的各个表的位置等信息。

3)dynsym段:

该段与 “.symtab”段类似,但只保存了与动态链接相关的符号,很多时候,ELF文件同时拥有 .symtab 与 .synsym段,其中 .symtab 将包含 .synsym 中的符号。该符号表中记录了动态链接符号在动态符号字符串表中的偏移,与.symtab中记录对应。

4)dynstr段:

该段是 .dynsym 段的辅助段,.dynstr 与 .dynsym 的关系,类比与 .symtab 与 .strtab的关系 hash段:在动态链接下,需要在程序运行时查找符号,为了加快符号查找过程,增加了辅助的符号哈希表,功能与 .dynstr 类似

5)rel.dyn段:

对数据引用的修正,其所修正的位置位于 “.got”以及数据段(类似重定位段 “rel.data”)

6)rel.plt段:

对函数引用的修正,其所修正的位置位于 “.got.plt”。

5.3.3符号表

符号表用来存放程序中定义和引用的函数和全局变量的信息。

符号表符号对应关系如下:

Value符号对应字符串在strtab节中的偏移量

Size符号对应目标字节数

Type符号类型:数据、函数、源文件、节、未知

Bind绑定属性:全局符号、局部符号、弱符号

Ndx符号对应目标所在的节,或其他情况,比如 UND节

Name名字

       5.3.4程序头表

       相比起hello.o没有差别

5.3.5 Section to Segment mapping

 

查阅资料以后依然不是十分理解

5.3.6 重定位节

不难发现hello与hello.o再重定位节上的差别:

在可重定位目标文件中hello.o,每个可装入节的起始地址总是0;

在可执行目标文件中hello,转入节地址确定,被填充进了正确的地址

5.3.7版本信息

5.4 hello的虚拟地址空间

      

通过分析我们可以知道,在 0x400000~0x401000 段中,hello程序被载入,从虚拟地址 0x400000 开始,到0x400fff 结束,排列如上图所示

查看 ELF 格式文件中的程序头表,它告诉链接器运行时加载的内容并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的大小、位置、标志、访问权限和对齐方面的信息。在上图可以看出,程序包含如下的段:

PHDR:

保存程序头表。

INTERP:

指定在程序已经从可执行文件映射到内存之后必须调用的解释器。

GNU_STACK:

权限标志,标志栈是否是可执行的。

GNU_RELRO:

指定在重定位结束之后需要设置把哪些内存区域设置为只读的。

LOAD:

表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据、程序的目标代码等。

DYNAMIC:

保存由动态链接器使用的信息。

NOTE:

保存辅助信息。

5.5 链接的重定位过程分析

生成的文件hello.txt

hello.o与hello的反汇编代码分析区别如下:

1)hello.o反汇编代码虚拟地址从0开始;

而hello反汇编代码从0x401000开始。

2)hello.o反汇编代码就直接是.text段,然后为main函数;

hello反汇编的结果中,由于链接过程中重定位而加入进来各种函数、数据。如开始的函数和调用的函数填充在main函数之前。所以main函数的位置发生了巨大的改变。

3)call函数引用全局变量以及跳转模块值时地址也有所变化。

可执行文件跳转和应用就是虚拟内存地址;

hello.o反汇编的跳转的就是只要hello数据时对应的位置。

    根据资料我们可知重定位条目有两种类型:R_X86_64_PC32和R_X86_64_32:

refaddr = ADDR(s) + r.offset

hello.o反汇编代码的rodata:

重定位符号是 rodata的用的是绝对引用。

hello.o反汇编代码的exit

hello.o反汇编代码的 printf

hello.o反汇编代码的 sleep

hello.o反汇编代码的 getchar 这几个都是相对引用

在符号表中,这几个符号条目的地址都是0,因为它们是动态链接的,与当前模块无关,不在符号表中,这些函数的引用都在PLT中 ,故我们要通过PLT算出这些符号的地址。

5.6 hello的执行流程

依次是:

5.7 Hello的动态链接分析

通过查阅资料我们知道,对于动态共享链接库中 PIC 函数,编译器没有办法预测函数的 运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表 PLT+全局偏移量表 GOT 实现函数的动态链接,GOT 中存放函数目标地址,PLT 使用 GOT 中地址跳转到目标函数。

关于PIC代码,一共有四种引用情况

(1) 模块内的过程调用、跳转,采用PC相对偏移寻址

(2) 模块内数据访问,如模块内的全局变量和静态变量

(3) 模块外的过程调用、跳转

(4) 模块外的数据访问,如外部变量的访问

用 readelf工具,在 hello的节头表中可以找到GOT表

GOT表的位置如上图所示位于0x404000处,内容如下:

其指向的动态连接器,

查询资料我们得知,模块间调用、跳转用“延迟绑定”技术减少指令条数:不在加载时重定位,而延迟到第一次函数调用时,需要用GOT和PLT

函数调用时,首先跳转到 PLT 执行.plt 中逻辑,第一次访问跳转时GOT 地址为下一条指令,将函数序号压栈,然后跳转到 PLT[0],在 PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写 GOT,再将控制传递给目标函数。之后如果对同样函数调用,第一次访问跳转直接跳转到目标函数。

5.8 本章小结

本章通过对于hello.o进行动态编译,获得hello可执行文件,结合链接的概念及作用分析了链接对文本.o文件的相应处理,查看 hello 的 elf 格式并分析,详细地分析ELF头,重定位信息和段头部表的各部分详细含义,并对 objdump 得到的反汇编代码与 hello.s、hello.objdump 进行比较,了解链接时重定位等操作对于相关信息的,一些转换,并对结果进行了相应的解析。

6章 hello进程管理

6.1 进程的概念与作用

概念:

进程的经典定义是一个执行中程序的实例,系统的每个程序都运行在某个进程的上下文。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存里的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。

作用:

通过进程,我们会得到一种假象,好像我们的程序是当前唯一运行的程序,我们的程序独占处理器和内存,我们程序的代码和数据好像是系统内存中唯一的对象。

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

Shell 的作用:

Shell 是一个用 C 语言编写的程序,他是用户使用 Linux 的桥梁。Shell 是指一种应用程序,Shell 应用程序提供了一个界面,用户通过这个界面访问操作系统内核的服务。

处理流程:

1)从终端读入输入的命令。

2)将输入字符串切分获得所有的参数

3)如果是内置命令则立即执行

4)否则调用相应的程序为其分配子进程并运行

5)shell 应该接受键盘输入信号,并对这些信号进行相应处理

6.3 Hello的fork进程创建过程

我们在shell上输入./hello,这个不是一个内置的shell命令,所以shell会认为hello是一个可执行目标文件,通过调用某个驻留在存储器中被称为加载器的操作系统代码来运行它。

在终端输入./hello 1190200704 高展鹏 5,当shell运行一个程序时,父进程通过fork函数生成这个程序的进程。新创建的子进程几乎但不完全与父进程相同,包括代码、数据段、堆、共享库以及用户栈。父进程和新创建的子进程之间最大的区别在于他们有不同的PID。

在子进程执行期间,父进程默认选项是显示等待子进程的完成。新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的( 但是 独立 的)一份副本,这就意味着,当父进程调用 fork 时,子进程可以读写父进程中打开的任何文件。

6.4 Hello的execve过程

execve函数在当前进程的上下文中加载并运行一个新程序

execve函数原型:

int execve(const char* filename,const char* argv[],const char* envp[]);

execve 函数在当前进程的上下文中加载并运行可执行目标文件filename=hello,且带参数列表argv和环境变量列表envp,只有当出现错误时,例如找不到hello,execve 才会返回到调用程序,所以,与fork一次调用返回两次不同,execve 调用一次并从不返回。

argv变量指向一个以NULL结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例argv[0]是可执行目标文件的名字。环境变量列表也是由一个类似的数据结构表示的,envp变量指向一个以NULL结尾的指针数组,其中每个指针指向一个环境变量字符串,每个字符串都是形如”name=value”的“名字-值”对。

6.5 Hello的进程执行

逻辑控制流:一系列程序计数器 PC 的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。

时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。

用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。简单看 hello sleep 进程调度的过程:当调用 sleep 之前,如果 hello 程序不被抢占则顺序执行,假如发生被抢占的情况,则进行上下文切换,上下文切换是由内核中调度器完成的,当内核调度新的进程运行后,它就会抢占当前进程,并进行 (1)保存以前进程的上下文

(2)恢复新恢复进程被保存的上下文,

(3)将控制传递给这个新恢复的进程 ,来完成上下文切换。

hello 初始运行在用户模式,在 hello 进程调用 sleep 之后陷入内核模式,内核处理休眠请求主动释放当前进程,并将 hello 进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时发送一个中断信号,此时进入内核状态执行中断处理,将 hello 进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。

6.6 hello的异常与信号处理

6.6.1不停乱按,包括回车

如果乱按过程中没有回车,这个时候只是把输入屏幕的字符串缓存起来,如果输入最后是回车,getchar读回车,并把回车前的字符串当作shell输入的命令

6.6.2 Ctrl-Z

输入Ctrl+Z会发送一个SIGTSTP信号给前台进程组的每个进程,结果是停止hello程序

6.6.3 Ctrl-C

在程序运行过程中输入Ctrl+C,会让内核发送一个SIGINT信号给到前台进程组中的每个进程,结果是终止前台进程,通过ps命令发现这时hello进程已经被回收

6.6.4 Ctrl-Z后运行ps

6.6.5 Ctrl-Z后运行jobs

6.6.6 Ctrl-Z后运行fg

6.7本章小结

本章节主要关注Shell如何运行 hello程序,以及对 hello进程执行过程的讨论。本章介绍了进程的概念与作用、 Shell及其工作流程,展示了fork函数如何创建新进程。之后展示了 execve加载运hello程序的过程,以及 hello的进程执行过程,介绍进程时间片的概念、进程调度的过程、用户态与核心态的转换等。最后介绍了hello的异常与信号处理。

7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:

在有地址变换功能的计算机中,访问指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。

物理地址:

在存储器里以字节为单位存储信息,为正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址,称为物理地址(Physical Address),又叫实际地址或绝对地址。

虚拟地址:

CPU启动保护模式后,程序运行在虚拟地址空间中。注意,并不是所有的“程序”都是运行在虚拟地址中。CPU在启动的时候是运行在实模式的,Bootloader以及内核在初始化页表之前并不使用虚拟地址,而是直接使用物理地址的。

线性地址:

线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。

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

段式管理就是把虚拟地址空间中的虚拟内存组织成一些长度可变的称为段的内存单元。段是虚拟地址到线性地址转化的基础。每个段有三个参数定义:

段基地址:指定段在线性地址空间中的开始地址。基地址是线性地址对应于段中偏移0处。

段限长:是虚拟地址空间中段内最大可用偏移地址。定义了段的长度。

段属性:指定段的特性。如该段是否可读、可写或可作为一个程序执行,段的特权级等。

这三个参数存储在一个称为段描述符的结构项中。在逻辑地址到线性地址的转换映射过程中会使用这个段描述符。段描述符保存在内存中的段描述符表中。

处理器把逻辑地址转化成一个线性地址的过程:

(1)首先确定要访问的段,然后决定使用的段寄存器。

(2)使用段选择符中的索引值在GDT或LDT中定位相应的段描述符,他们的首地址则通过GDTR寄存器和LDTR寄存器来获得

(3)将段选择符的索引值*8,然后加上GDT或LDT的首地址,就能得到当前 段描述符的地址。

(4)利用段描述符校验段的访问权限和范围,以确保该段是可以访问的并且偏移量位于段界限内。

(5)利用段描述符中取得的段基地址加上偏移量,形成一个线性地址。

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

计算机利用页表,通过MMU来完成从虚拟地址到物理地址的转换。

线性地址即虚拟地址,用VA来表示。由图7.1所示,VA被分为虚拟页号(VPN)与虚拟页偏移量(VPO),CPU取出虚拟页号,通过页表基址寄存器(PTBR)来定位页表条目,在有效位为1时,从页表条目中取出信息物理页号(PPN),通过将物理页号与虚拟页偏移量(VPO)结合,得到由物理地址(PPN)和物理页偏移量(PPO)组合的物理地址。

7.4 TLB与四级页表支持下的VA到PA的变换

每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降1或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。

TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单一PTE组成的块。TLB通常有高的相联度,从虚拟地址中的页号提取出组选择和行匹配的索引和标记字段。

因为所有的地址翻译都是在芯片上的MMU中进行的,因此非常快。

多级页表:将虚拟地址的VPN划分为相等大小的不同的部分,每个部分用于寻找由上一级确定的页表基址对应的页表条目。如下图,VPN被分为k个部分,第一级VPN结合基址寄存器得到一个页表条目,其中存放下一级页表的基址,再结合VPN2,得到第三级页表基址,继续寻找,以此类推,直到最后确定对应的物理页号,与VPO结合,由图7.3,得到由PPN与PPO结合成的物理地址,用于物理地址寻址。

如果是二级页表,第一级页表的每个PTE负责一个4MB的块,每个块由1024个连续的页面组成。二级页表每一个PTE负责一个4KB的虚拟地址页面。这样的好处在于,如果一级页表中有一个PTE是空,那么二级页表就不会存在,这样会有巨大的潜在节约,因为4GB的地址空间大部分都是未分配的。

现在的64位计算机采用4级页表,36位的VPN被封为4个9位的片,每个片被用作一个页面的偏移,CR3寄存器包含L1页表的物理地址。VPN1提供到一个L1PET的偏移量,这个PTE包含L2页表的基地址,VPN2提供一个到L2PTE的偏移量,以此类推。

48位的虚拟地址的前36位被分为四级VPN区。结合存放在CR3的基址寄存器,由前面多级页表的知识,可以确定最终的PPN,与VPO结合得到物理地址。

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

三级Cache支持下的物理内存访问就是已经通过虚拟地址得到了物理地址,物理地址有52位,0~5位是CO(Cache偏移量),6~11位是CI(Cache索引),12~51位是CT(Cache标记),我们先利用CI找到对应的Cache组,如CI=0,就找编号为0的Cache组,每一个Cache组中有8个Cache行,我们找到标记为等于CT的Cache行,如果这个行存在且有效位为1,则缓存命中,取出偏移量为CO的字节,并传递给CPU。如果缓存未命中,则继续到L2中寻找,L2未命中到L3中,L3未命中到主存中寻找。

7.6 hello进程fork时的内存映射

运行hello程序时,shell会调用fork为hello创建一个新进程。首先,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程(shell进程)的mm struct、区域结构和页表的原样副本。它将两个进程(新进程和shell进程)中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

具体流程可以按照上图理解

7.7 hello进程execve时的内存映射

execve函数加载并运行hello程序需要以下几个步骤:

(1)删除已存在的用户区域

删除当前进程虚拟地址的用户部分中已存在的区域结构。(即清除这个进程曾经运行过的程序遗留下来的痕迹,为新程序初始化区域)

(2)映射私有区域为新程序的代码、数据、bss和栈区创建新的区域结构。所有的这些区域都是私有的,写时复制的。代码和数据区被映射为hello文件中的.text和.data区。bss区是请求二进制零的,映射到匿名文件,其大小包含在hello中。

(3)映射共享区域如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

(4)设置程序计数器(PC)execve做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

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

使用页表的地址翻译:

地址翻译页面命中:

(1)处理器生成了一个虚拟地址,并把它传送给MMU

(2)MMU生成PTE地址,并从高速缓存器/主存请求得到它

(3)高速缓存/主存向MMU返回PTE

(4)MMU构造物理地址,并把它传送给高速缓存/主存

(5)高速缓存/主存返回所请求的数据字给处理器

地址翻译缺页:

(1)处理器生成了一个虚拟地址,并把它传送给MMU

(2)MMU生成PTE地址,并从高速缓存器/主存请求得到它

(3)高速缓存/主存向MMU返回PTE

(4)PTE有效位为零,因此MMU触发缺页异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序

(5)缺页处理程序确定物理内存中牺牲页(若页面被修改,则换出到磁盘)

(6)缺页处理程序调入新的页面,并更新内存中的PTE

(7)缺页处理程序返回到原来进程,再次执行缺页的指令

7.9动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk(读做"break"),它指向堆的顶部。

分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块 保持空闲,直到它显式地被应用所分配。一个已分配 的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。

(1)显式分配器(explicitallocator):

要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做ma耳oc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。

(2)隐式分配器(implicitallocator):

另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的巳分配的块的过程叫做垃圾收集。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。

动态内存分配主要有两种基本方法与策略:

1.带边界标签的隐式空闲链表分配器管理:

带边界标记的隐式空闲链表的每个块是由一个字的头部、有效载荷、可能的额外填充以及一个字的尾部组成的。在隐式空闲链表中,因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。其中,一个设置了已分配的位而大小为零的终止头部将作为特殊标记的结束块。

当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配合最佳适配。分配完后可以分割空闲块减少内部碎片。同时分配器在面对释放一个已分配块时,可以合并空闲块,其中便利用隐式空闲链表的边界标记来进行合并。

2.显式空间链表管理:

显式空闲链表是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。如,堆可以组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。

7.10本章小结

本章主要介绍了 hello 的存储器地址空间、intel 的段式管理、hello 的页式管理,介绍了 VA 到 PA 的变换、物理内存访问,还介绍了 hello进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。

8章 hello的IO管理

8.1 Linux的IO设备管理方法

所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。

设备的模型化:文件

设备管理:unix io接口

8.2 简述Unix IO接口及其函数

Unix I/O接口统一操作:

设备可以通过Unix I/O接口被映射为文件,这使得所有的输入和输出都能以一种统一且一致的方式来执行:

打开文件:

一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。

Linux shell创建的每个进程开始时都有三个打开的文件:

标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可以用来代替显式的描述符值。

改变当前的文件位置

对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。

读写文件:

一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的而文件,当k>=m时执行读操作会触发一个成为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。

关闭文件:

当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

Unix I/O接口函数:

(1) int open(char* filename,int flags,mode_t mode) ,进程通过调用 open 函数来打开一个存在的文件或是创建一个新文件的。open 函数将 filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。

(2) int close(fd),fd 是需要关闭的文件的描述符,close 返回操作结果。

(3) ssize_t read(int fd,void *buf,size_t n),read 函数从描述符为 fd 的当前文件位置赋值最多 n 个字节到内存位置 buf。返回值-1 表示一个错误,0表示 EOF,否则返回值表示的是实际传送的字节数量。

(4)ssize_t wirte(int fd,const void *buf,size_t n),write 函数从内存位置 buf复制至多 n 个字节到描述符为 fd 的当前文件位置。

8.3 printf的实现分析

(char*)(&fmt) + 4) 表示的是…可变参数中的第一个参数的地址。

printf需要做的事情是:接受一个fmt的格式,然后将匹配到的参数按照fmt格式输出。在这个过程中用了两个外部函数,一个是vsprintf,还有一个是write。

vsprintf函数作用是接受确定输出格式的格式字符串fmt(输入)。用格式字符串对个数变化的参数进行格式化,产生格式化输出。

write函数将buf中的i个元素写到终端。

从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.

字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

getchar函数使用一个int型的返回值。当程序调用getchar时,程序就等着用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车为止(回车字符也放在缓冲区中)。

当用户键入回车之后,getchar开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ASCII码并且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。

异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

本章介绍了Unix是如何将I/O设备模型化文件并统一处理的,同时详细介绍了Unix I/O函数,并对标准I/O函数printf和getchar的实现进行了分析:标准I/O函数的实现都调用了系统的I/O函数,通过中断指令将程序控制权交给系统内核,进行相应的中断处理,然后对硬件进行相应操作。

结论

hello所经历的过程:

使用I/O设备编写高级语言代码hello.c,存储在磁盘中。hello.c经过预处理,删除了注释,插入了库函数,成为了hello.i。

经过了编译器后,它被编译为为hello.s,在这个过程中,他承受了编译器对原代码进行的词法、语法、语义分析,被编写成对应的代码并且被注入伪指令添加各种注释,以此有利于后续的工作。

hello.s经过汇编阶段的翻译成可重定位目标程序hello.o,在这个阶段汇编器生成ELF格式的文件,包含各种重定位条目、符号表、数据段、代码段等区段,他的这些属性十分重要。

hello.o解除了很多动态库和静态库,在链接阶段,通过链接器,与其他必要的可重定位目标文件、共享库链妾,并对符号进行重定位,最后生成可执行目标文件hello,至此,hello终于可以实现其功能。

Linux的Shell 通过fork为hello创建新进程,他调用execve在相应进程上运行hello程序。然后Shell调用mmap函数创建新的虚拟内空间,并构建内存映射。之后计算机通过内核和各种硬件如寄存器、CPU、MMU、TLB、三级Cache、四级页表等来进行内存的翻译、访问和加速, hello的虚拟空间也用了许多他要的数据。

最后,在hello程序结束后,Shell 调用waitpid函数回收hello进程,内核释放内存,删除为hello创建的所有数据结构,hello失去生命,完成了作为一个程序的使命。

附件

hello.c:源程序

hello.i:预处理后的文本文件

hello.s:编译后汇编程序文本文件

hello.o:汇编后的可重定位目标程序(二进制文件)

hello_o.txt:hello.o的反汇编文件

hello.txt:hello的反汇编文件

hello_o_elf.txt:ELF格式下的hello.o

hello_elf.txt:ELF格式下的hello

hello:链接后的可执行目标文件

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值