HIT 深入理解计算机系统CSAPP 大作业 --程序人生 Hello's P2P

**

HIT 深入理解计算机系统CSAPP 大作业 --程序人生 Hello’s P2P

**

计算机系统

大作业

题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术专业
学   号 1180300427
班   级 1803004
学 生 薛俊琦   
指 导 教 师 史先俊

计算机科学与技术学院
2019年12月
**

摘 要

hello是我们每个程序员都接触过的文件。也是很容易被忽视的文件。而该实验讲述hello.c经过预处理,编译,汇编,链接后生成可执行文件的过程。生成的可执行文件在运行过程中的具体操作:shell读取命令,fork创建子进程,execve调用hello。在运行hello过程中,异常信号的产生和处理等。hello在内存中的存储。再到hello结束,父进程对其回收操作。这一过程恰好体现了计算机系统的理念。
关键词: 编译 链接 异常信号 存储
**

**目 录

第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简介

P2P(From program to process),020(From Zero-0 to Zero-0)通过该两个过程简述hello:
hello.c是由用户输入的C程序代码hello.c经过预处理生成hello.i,hello.i经过编译生成hello.s,hello.s经过汇编生成可重定位目标文件hello.o,hello.o经过和多个可重定位目标文件链接生成可执行文件hello。
然后运行hello,用户输入命令,shell根据命令fork子程序,子程序中execve函数运行hello。这就要为hello各段创建新的空间。由于首次加载,会产生缺页,调用缺页中断处理子程序,多次调用后,hello就加载到内存中。由于运行过程中会收到外部的信号,例如用户输入,所有还要相对的信号处理程序。当hello执行完毕,hello就结束,等待的就是被父进程回收。

1.2 环境与工具

软件环境:Ubuntu-18.04.3 64位 VMware
硬件环境:Intel Core i5 8GBRAM x64
开发与调试工具:gcc readelf edb objdump

1.3 中间结果

hello.i --预处理生成的文本文件hello.i
hello.s --编译产生的文本文件hello.s
hello.o --汇编后生成的可重定位目标文件hello.o
hello --链接生成的可执行文件hello
hello2.s – objdump hello生成的hello的反汇编代码文件
helloo.s --objdump hello.o生成的hello.o的反汇编代码文件

1.4 本章小结

介绍了hello从开始到结束的过程以及生成的一些文件:hello.c经过预处理生成hello.i,再经过编译生成hello.s 汇编生成hello.o 链接生成hello可执行文件。接着就是hello的运行020的过程,最后就是被父进程回收,结束hello。

第2章 预处理

2.1 预处理的概念与作用

预处理是在将程序文件hello.c翻译成可执行文件hello阶段中的第一个阶段。

预处理阶段:预处理器根据以字符#开头的命令,修改原始的C程序。比如hello.c中第一行的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名。

2.2在Ubuntu下预处理的命令

预处理指令:gcc -E hello.c -o hello.i
在这里插入图片描述
图 1

该命令得到了以.i作为文件扩展名的hello.i文件:
在这里插入图片描述
图 2

2.3 Hello的预处理结果解析

预处理是根据#开头的命令,修改程序。如:hello.c程序中的stdio.h,unistd.h,stdlib.h已经被插入到了程序文本中。
得到的hello.i程序相比于hello.c程序字节增加,从hello.i文件中可以看出,预处理器已经将头文件包含的函数读取到了程序中进行展开,hello.i中不再有#定义。
在这里插入图片描述
图 3

2.4 本章小结

第二章是程序从.c程序翻译到可执行文件的第一步–预处理,该过程是在预处理器上完成的。在linux下,通过gcc指令进行预处理 gcc -E hello.c -o hello.i
在预处理过程中,预处理器将.c程序中的#定义展开,引入。生成一个新的.i为文件扩展名的文件,进而进行下一过程–编译。

第3章 编译

3.1 编译的概念与作用

编译阶段:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。该程序包含函数main的定义。
编译的作用:将高级语言翻译成计算机语言,高级语言计算机是无法直接识别的,翻译为汇编语言后,计算机可以识别。同时可以检查出程序的正确性,只有程序无误后,才会被编译。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序

3.2 在Ubuntu下编译的命令

编译指令:gcc -S hello.i -o hello.s
在这里插入图片描述
图 4
生成的hello.s文件:
在这里插入图片描述
图 5

3.3 Hello的编译结果解析

3.3.1 编译器处理C数据类型
一.常量类型:
该程序涉及的常量只有字符串常量
在hello.c程序中字符常量分别是"用法: Hello 学号 姓名 秒数!\n" 和"Hello %s %s\n"。在编译过程中,字符串常量保存在数据段。在hello.s文件中,可以找到他们的位置:
在这里插入图片描述
图 6

二.变量类型:
1.局部变量
首先是在程序中出现的整形局部变量i,在编译阶段中,局部变量参数向寄存器传参,所以局部变量i通常保存在寄存器或栈中。而在该C程序中,i的作用是作为for循坏循坏标志,最初被赋给了0 。所以只要在汇编语言中找到有赋值0操作对应的寄存器或者栈就是i。从下图可以看到变量i保存在了-4(%rbp)这个位置的栈中,也就是说该hello.c程序局部变量i并没有存在寄存器中,而是保存在了栈。
在这里插入图片描述
图 7

2.函数参数
int argc是该函数的整型参数,函数参数被保存在寄存器中。它是函数第一个参数,被保存在%edi寄存器中,如下图:

在这里插入图片描述
图 8
可以看到在函数中将%edi寄存器内容(argc)复制到了-20(%rbp)栈中。
char *argv[]是函数的第二个参数,它被保存在寄存器%esi中。
在这里插入图片描述
图 9
在函数中将%rsi(*argc[])复制到了-32(%rbp)中。
该程序中还包括一个函数sleep函数,该函数中参数是一个由字符串数组转化成的整数参数,在下图程序中调用atoi函数,返回值送给了%edi即函数参数。
在这里插入图片描述
图 10
3.字符串数组
在程序中涉及到了argv[]数组中的三个元素,argv[1],argv[2],argv[3]。数组通常是被保存在一个连续的地址中,数组的第0号元素就是数组的起始地址,然后采用寻址方式访问数组元素。
在这里插入图片描述
图 11
该过程表示两个字符串数组
3.3.2赋值操作
该程序中赋值操作只有一处,就是在进行for循坏过程中,对局部变量i进行赋值操作。
在这里插入图片描述
图 12
采用movl操作,将立即数0赋值给了i,然后i开始循环操作。赋值操作中,第一个数是源操作数,第二个数是目的操作数,赋值是将源操作数复制给目的操作数的过程。
3.3.3类型转换
在程序sleep函数中,函数的参数是一个整数,而该整数恰好是通过类型转换获得。
调用atoi函数,将字符串转换为整数,如下图,最后整数保存在了%edi中。
在这里插入图片描述
图 13

3.3.4算术操作
该程序中涉及的算术操作只有i++,这一操作在循坏体中,控制循环条件判断及循坏结束。

在这里插入图片描述
图 14
可以看到L3判断循坏条件,当满足条件是进入循坏体L4,在调用完sleep函数后,结束一次循环,然后执行i++操作,该操作是将-4(%rbp)栈中的内容i直接+1操作,实现i++
3.3.5关系操作
1.在程序中if条件判断中,判断argc是否等于4
在汇编程序中,该操作实现如下:
在这里插入图片描述
图 15
由于前面将%edi复制到了-20(%rbp)中,所以-20(%rbp)保存的是argc参数,在第三行中将argc与立即数4进行了比较,如果相等,跳转到L2,如果不相等继续执行,实现了!=操作。
2.在for循环体中,将i与8比较,如果i<8继续执行循环结构
在汇编程序中,实现方法如下:
在这里插入图片描述
图 16
在L3中,-4(%rbp)栈中的值i与立即数7进行比较,如果小于等于,则跳转到L4循环体执行循环结构,否则如果i大于7,即i=8跳出循环,与for循环对应一致。
可以看出,汇编语言判断两个数的关系,是采用cmp指令操作完成的。
3.3.6数组/指针操作
数组元素有字符串数组argv[],指针有指向数组argv的指针。
对于数组元素的操作,通常是将数组首个元素的地址放入栈中,而在该程序中也是如此,将数组argv首元素,也是指向argv的指针的地址放入了-32(%rbp)中(由于这里将argv首地址当作函数第二个参数传入,所以%rsi寄存器中存放的就是该地址)
在这里插入图片描述
图 17
前面已经说过,如果想访问数组元素,那么只需要用起始地址加上偏移量字节大小即可访问,采用这种寻址方式,就能访问到数组中的每个元素。在for循环体中,数组元素作为函数参数传递,所以只要在L4中(for循环体)就可以找到数组元素所在的位置及表达形式:
在这里插入图片描述
图 18
采用将地址加上立即数的方式进行访问数组,该立即数就是偏移量
字节大小。16=2*8,可知第二行是访问的argv[2]元素,同理第五行访问argv[1]。
3.3.7控制转移
1.if的控制转移
在程序中,判断argc与4是否相等,相等则跳转,不相等执行条件体。由于这里有跳转,所以就涉及到了控制转移。在汇编程序中实现如下:
在这里插入图片描述
图 19
将argc与4进行比较,如果相等跳转到L2,即将控制转移给L2程序,不相等继续执行。L2所代表的是什么程序呢?
在这里插入图片描述
图 20
可以看到,L2是一个赋值,然后跳转程序,跳转到L3,就是执行下面要讲的for循环。(将控制转移给L3,执行for循环)
2.for循环的控制转移
前面已经讲过,如果argc和4相等,控制转移给L2,然后再转移给L3,进入for循环,为什么说L3是for循环?如下图,先将i与7比较,如果i小于等于7,跳转到L4(这里又涉及控制转移),如果大于7,就接着执行下面程序,可见与for循环吻合。所以这里面涉及了将L3的控制转移到L4程序中。
在这里插入图片描述
图 21
3.3.8函数操作
1.参数传递
前面已经举例过参数传递,函数的参数通常是存在寄存器中(例如%edi,%esi等),main函数有两个参数,就分别存在%edi和%esi中,如下图:
在这里插入图片描述
图 22
Sleep函数也是如此。
2.函数调用
函数调用通常是采用call指令,后面跟着调用函数的地址。
在这里插入图片描述
图 23
如main函数中,调用printf函数,由于这里printf打印的是字符串,所以调用puts完成。
在这里插入图片描述
图 24
又如这里,都用了call指令调用函数。调用完函数是应该返回到主程序的,那么如何返回?下面将会讲到。
3.函数返回
关于函数返回,这里就要关注call的下一条指令的地址(在主程序中调用函数之后的指令),在调用时,会将该地址进行压栈,然后将该地址所占有的部分设为被调用者的部分,然后当函数执行完毕后,调用ret,将该地址弹出,然后从该地址开始执行程序。。

c程序中是用return返回函数,而在汇编语言中,采用的是ret指令,将返回地址弹出,从该地址开始执行。
在这里插入图片描述
图 25
如L3 ret指令,实现了函数返回功能。

3.4 本章小结

编译是从.c程序转化为可执行文件的第二步,该过程主要是将机器不能理解的高级语言转化为机器可以识别的汇编语言,这就要求程序员对于汇编语言也有一定得了解和认识。同时我们还了解了编译器和它的优化能力。
在汇编程序中,高级语言复杂的指令转化成了简单的机器指令,同时多种多样的数据也变得统一,没有类型的区分,这就要根据机器指令来判断数据类型。

第4章 汇编

4.1 汇编的概念与作用

汇编阶段:接下来,汇编器将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。Hello.o文件是一个二进制文件,它包含的17个字节是函数main的指令编码,如果我们在文本编译器中打开hello.o文件,将看到一堆乱码。

作用:生成.o二进制文件,便于机器理解代码。

4.2 在Ubuntu下汇编的命令

linux下汇编指令:gcc -c hello.s -o hello.o
在这里插入图片描述
图 26
生成.o文件:
在这里插入图片描述
图 27

4.3 可重定位目标elf格式

指令:readelf -a hello.o
1.查看ELF格式各个头信息,首先是ELF头信息,ELF头信息包含了生成该文件的系统的字的大小和字节顺序。从下图Magic可以看出,ELF头是以一个16字节序列开始的:
在这里插入图片描述
图 28
从图中还可以看出很多信息,例如Data显示:是采用两位补码,小端存储的方式。
2.接下来就是节头部表:
在这里插入图片描述
图 29
从上图可以看到.text节,.rela.text节,.data节等等,对应的是节的名称,还有节的类型,地址等等信息。
在书中,详情的介绍了部分节的信息:
.text:已编译程序的机器代码。
.rodata:只读数据。
.data:已初始化的全局和静态C变量。局部变量被保存在栈中。
.bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。
.symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
.rel.text:一个.text节中位置的列表。
.rel.data:被模块引用或定义的所有全局变量的重定位信息。
.debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义。
.line:原始C源程序中的行号和.text节中机器指令之间的映射。
.strtab:一个字符串表。
3.重定位节和符号定义
如下是重定位节:
在这里插入图片描述
图 30
在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。然后链接器将运行时内存地址赋给新的聚合节。链接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时地址。
当汇编器生成一个目标模块时,他并不知道数据和代码最终被放在内存中什么地方。他就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。重定位条目如下图所示:
在这里插入图片描述
图 31
从重定位条目中的信息,可以计算出重定位后的地址。
4.符号和符号表
每个可重定位目标模块m都有一个符号表,它包含m定义和引用的符号的信息,在链接器的上下文中,有三种不同的符号:
(1)由模块m定义并能被其他模块引用的全局符号
(2)由其他模块定义并被m模块引用的全局符号
(3)只被模块m定义和引用的局部符号。
符号表是由汇编器构造的,使用编译器输出到汇编器语言.s文件中的符号。符号表如下图:
在这里插入图片描述
图 32

4.4 Hello.o的结果解析

采用objdump指令进行反汇编得到程序如下:
在这里插入图片描述
图 33
第三章hello.s程序如下:
在这里插入图片描述
图 34
对比分析,hello.o是一个二进制文本文件,其中包含的就是如objdump反汇编生成的代码中左侧的数字,objdump将其翻译成汇编代码。可见机器语言就是数字序列。而汇编语言是通过指令完成。机器语言中的数字通过翻译会生成汇编语言,汇编语言也可以通过汇编生成机器语言。
对比发现大部分操作指令相似,但也有一些不同,相似结果不再讨论,主要说一下不同之处:
1.机器语言中的操作数与汇编语言不一致:这里主要体现在hello.s中的操作数是我们常用的的十进制,而hello.o中的操作数是机器中常用的16进制。
2.分支跳转:可以看到在hello.s中,不同的跳转被分成了不同的段,L2,L3,L4,main等。而在hello.o中全都是在主函数中,这就很像我们的C程序。hello.s跳转到不同的段,而hello.o中则是跳转到主函数中的不同地址处去执行条件结构。
3.函数调用:hello.s中call指令后面直接调用函数名,例如puts函数等。而在hello.o文件中,call并没有调用函数名,而是一段相对偏移地址,进而调用某函数。

4.5 本章小结

汇编阶段是.c程序转化为可执行程序的第三阶段,该阶段将.s程序转化成了可重定位目标文件.o文件。将汇编语言转化为二进制机器语言。同时还介绍了ELF头文件各个部分的信息,以及重定位相关概念。对比了hello.s和hello.o文件二者的差别,有助于理解汇编。

第5章 链接

5.1 链接的概念与作用

链接阶段:请注意,hello程序调用了printf函数,它是每个C编译器都提供的标准C库中的一个函数,printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的hello.o程序中。链接器就负责处理这种合并,结果就得到hello文件,它是一个可执行目标文件,可以被加载到内存中,由系统执行。
作用:将多个.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
在这里插入图片描述
图 35
生成可执行目标文件hello:
在这里插入图片描述
图 36

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

节头部表如下图,从节头部表可以看出,总共有24个节,从.interp节开始,到.shstrtab节结束,前面各个节的功能已经描述过。还可以看出每个节的大小,类型,地址等。

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

5.4 hello的虚拟地址空间

通过5.3中上图可以发现,不同段对应不同地址,.下面做了个表格,方便对照各段信息
.interp 0x400200
.note.ABI-tag 0x40021c
.hash 0x400240
.gnu.hash 0x400278
.dynsym 0x400298
.dynstr 0x400370
.gnu.version 0x4003cc
.gnu.version_r 0x4003e0
.rela.dyn 0x400400
.rela.plt 0x400430
.init 0x4004c0
.plt 0x4004e0
.text 0x400550
.fini 0x400684
.rodata 0x400690
.eh_frame 0x4006d0
.dynamic 0x600e50
.got 0x600ff0
.gotplt 0x601000
.data 0x601048
.comment 0x60104c
.symtab 0x601078
.strtab 0x601510
.shstrtab 0x601668
通过edb加载hello,查看虚拟地址空间各段信息与上表对照,发现二者一样:
在这里插入图片描述
在这里插入图片描述
图 38
可见二者各段的起始位置都是一样的,同时edb查看的还包括了各段的具体信息。而readelf只是各段的大概信息。

5.5 链接的重定位过程分析

在这里插入图片描述
图 39
输入如上图指令,反汇编查看hello,同时与hello.o对比。
这里主要对比hello的main函数过程
hello如下:
在这里插入图片描述
图 40
hello.o如下:
在这里插入图片描述
图 41
对比观察:通过对比观察发现,基本操作没什么变化,操作指令依旧是类似的。这和前面的对比很相像。但是也有一些不同:
1.函数地址发生变化,这主要从main开始hello程序中main起始地址为400582,而hello.o中main起始地址是从0开始,这也就导致了其他函数变量等地址的不同。
2.call指令发生了变化。前面说过,hello.o中call后接的是相对偏移地址,因为在hello.o中,call指令还没有确定函数的地址,只是知道相对偏移地址,并不知道是哪个函数。而在hello中,链接过程将函数确定了下来,call指令后接的是函数名。
3.hello增加了节,这些节在hello.o中是不存在的,例如.interp等
之所以造成这些不同,是因为链接过程,那么链接器是如何链接的。
为了构造可执行文件hello,链接器必须完成两个主要任务:
1.符号解析:目标文件定义和引用符号,每个符号对应于一个函数,一个全局变量或者一个静态变量。符号解析的目的就是将每个符号引用正好和一个符号定义关联起来。
2.重定位:编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执行这样的重定位。
结合hello.o的重定位项目,分析hello重定位方法:
链接器完成符号解析后,把代码中的每个符号引用和正好一个符号定义关联起来,此时链接器就知道他的输入目标模块中的代码节和数据节的确切大小,然后开始重定位,将合并输入模块,并为每个符号分配运行时地址,分为两步:
1.重定位和符号定义。在这一步中,链接器将hello.o所有类型相同的节合并成同一类型的新的聚合节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,hello中的每条指令和全局变量都有唯一的运行时内存地址了。
2.重定位节中的符号引用。链接器依赖hello.o中的可重定位条目,修改代码节和数据节中每个符号的引用,使得它们指向正确的运行时地址。

5.6 hello的执行流程

通过edb查看hello调用子程序如下:
ld-2.27.so!_dl_start
ld-2.27.so!_dl_init
hello!_start
libc-2.27.so!__libc_start_main
-libc-2.27.so!__cxa_atexit
-libc-2.27.so!__libc_csu_init
hello!_init
libc-2.27.so!_setjmp
-libc-2.27.so!_sigsetjmp
–libc-2.27.so!__sigjmp_save
hello!main
hello!puts@plt
hello!exit@plt
–ld-2.27.so!_dl_lookup_symbol_x
libc-2.27.so!exit

5.7 Hello的动态链接分析

共享库是致力于解决静态库缺陷的创新。共享库作为一个目标模块,再运行或加载时,可以加载到任意的地址,并和一个在内存中的程序链接起来,成为动态链接。但是程序在调用共享库的函数的时候,是不知道这个函数运行时的地址的,GNU恰好使用了延迟绑定来解决这个问题。在加载时,动态链接器会重定位GOT中的条目,来获得正确的绝对地址。
我们通过edb调试来观察动态链接。
首先,根据前面的查看可知.got.plt节在地址0x601000处,这也就是GOT的起始位置,知道这个对我们有很大帮助。
接下来单步调试,分别查看在do_init前和后的变化,如下图为调试在do_init前:可以发现在0X601000处大部分为0。
在这里插入图片描述

图 42
当调试到do_init时,发生了变化,在0x601000后面可以看到地址0x7f4651e1c170。在0x601010前可以看到地址0x7f4651c0a680。这两个地址就是我们分别要找的GOT[1]和GOT[2]地址。
在这里插入图片描述
图 43
GOT[2]时动态链接在ld-linux.so中的入口点,我们用edb找到动态链接函数。即如下图所示
在这里插入图片描述
图 44

5.8 本章小结

第五章是生成可执行文件的最后一步-链接,将多个可重定位文件链接生成一个可执行文件。涉及了重定位和符号解析,对重定位有了更深刻的理解。以及静态链接动态链接过程,深化了动态链接和共享库的应用方式。

第6章 hello进程管理

6.1 进程的概念与作用

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

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

shell是一个交互型应用程序,代表用户运行其他程序(是命令行解释器,以用户态方式运行的终端进程)
其基本功能是解释并运行用户的指令,重复如下处理过程:
1.终端进程读取用户由键盘输入的命令行。
2.分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量。
3.检查第一个(首个,第0个)命令行参数是否是一个内置的shell命令。
4.如果不是内部命令,调用fork()创建新进程/子进程。
5.在子进程中,用步骤2获取的参数,调用execve()执行指定程序。
6.如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait…)等待作业终止后返回
7.如果用户要求后台运行(命令末尾有&号),则shell返回。

6.3 Hello的fork进程创建过程

在shell原理中已经说过,先判断终端输入的是不是内部命令。终端输入./ hello 学号 姓名 秒数 由于这不是内置命令,调用fork()创建子进程。子进程创建后与父进程具有相同的副本,相同且独立的虚拟地址空间等。当父进程调用fork时,子进程读写父进程任意的文件。子进程与父进程最大的区别在于PID不同。同时fork会返回两个返回值,根据返回值可以区分父进程和子进程。

6.4 Hello的execve过程

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

6.5 Hello的进程执行

1.上下文信息:操作系统内核使用一种称为上下文切换的较高层形式的一场控制流来实现多任务。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器,浮点寄存器,程序计数器,用户栈,状态寄存器,内核栈和各种内核数据结构。
2.进程时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
3.用户态与内核态转换:为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。处理器通常是用某个控制寄存器的一个模式位来提供这种功能的,该寄存器描述了进程当前享有的特权,当设置里模式位时,进程就运行在内核模式,一个运行在内核模式中的进程可以执行指令集的任何指令,并且可以访问系统中的任何内存位置。进程从用户模式变为内核模式唯一方法是通过诸如中断,故障或者陷入系统调用这样的异常。当处理程序返回到应用程序代码时,处理器就把模式从内核改到用户模式。
4.调度:在进程执行的某些时刻,内核可以决定当前进程,并重新开始一个先前被抢占了的进程,这种决策叫做调度。

在这里插入图片描述
图 46
了解完了基本概念,我们来看hello的执行过程
hello程序运行后,会执行某些函数,然后内核决定当前抢占进程,重新开始一个先前被抢占了的进程,就是上面所说的调度。内核调度新进程后,hello被抢占,此时上下文切换,控制转移到新的进程。就如上图,从hello切换到新进程的这一过程中,是在内核态进行的。切换后,切换到用户态。当内核判断新进程进行足够长的时间后,再切换回进程hello,切换的过程由内核态切换到用户态。

6.6 hello的异常与信号处理

hello的异常可以分为四类:中断,陷阱,故障和终止,下表概括了异常类型和原因等。
类别 原因 异步/同步 返回行为
中断 来自I/O设备的信号 异步 总是返回到下一条指令
陷阱 有意的异常 同步 总是返回到下一条指令
故障 潜在可恢复的错误 同步 可能返回到当前指令
终止 不可恢复的错误 同步 不会返回
信号处理:
1.乱按键盘时,发现进程并没有接收到信号,不会影响程序,程序继续进行:
在这里插入图片描述
图 47
2.按回车,进程继续进行,但是回车会当作进程结束后的命令行,由终端读入:
在这里插入图片描述
图 48
3.按Ctrl-Z,进程收到停止信号,进程停止
在这里插入图片描述
图 49
4.按Ctrl-C,进程收到终止信号,进程终止。
在这里插入图片描述
图 50
5.Ctrl-Z后按ps,进程被挂起,并列出进程
在这里插入图片描述
图 51
6.Ctrl-Z后按jobs,列出已启动的任务状态
在这里插入图片描述
图 52
7.Ctrl-Z后按pstree,列出进程间的联系
在这里插入图片描述
在这里插入图片描述
图 53
8.Ctrl-Z后按fg,调进程到前台
在这里插入图片描述
图 54
9.Ctrl-Z后kill,kill发送信号,杀死进程。
在这里插入图片描述
图 55

6.7本章小结

首先对进程有了概念–一种符合我们认知的假象。然后了解了进程的建立,运行,终止。进程在执行中的细节,例如fork创建子进程等。通过hello进程的演示,有了更深的理解。了解了异常的类型和产生的原因,通过对异常了解,进程有了更深刻的概念。

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:即段地址:偏移地址。实模式下: 逻辑地址CS:EA =物理地址CS*16+EA;保护模式下:以段描述符作为下标,到GDT/LDT表查表获得段地址, 段地址+偏移地址=线性地址。
线性地址:非负整数地址的有序集合
虚拟地址: N = 2n 个虚拟地址的集合 ===线性地址空间
物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每个字节都有一个唯一的物理地址。

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

前面已经说到,逻辑地址是由段地址和偏移地址两部分
段标识符是16位长,也叫段选择符,如下图:
在这里插入图片描述
图 56
1.TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT)
2. RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位
于最低级的用户态,第0级高于第3级。
3.高13位-8K个索引用来确定当前使用的段描述符在描述符表中的位置。
接下来是段描述符:段描述符是一种数据结构,实际上就是段表项,分两类:
1.用户的代码段和数据段描述符
2.系统控制段描述符,又分两种:
(1)特殊系统控制段描述符,包括:局部描述符表(LDT)描述符和任务状态段(TSS)描述符
(2)控制转移类描述符,包括:调用门描述符、任务门描述符、中断门描述符和陷阱门描述符
描述符表实际上就是段表,由段描述符(段表项)组成。有三种类型
1.全局描述符表GDT:只有一个,用来存放系统内每个任务都可能访问的描述符,例如,内核代码段、内核数据段、用户代码段、用户数据段以及TSS(任务状态段)等都属于GDT中描述的段
2.局部描述符表LDT:存放某任务(即用户进程)专用的描述符
3.中断描述符表IDT:包含256个中断门、陷阱门和任务门描述符
在这里插入图片描述

图 57
一个完整的逻辑地址是段选择符:偏移地址,先了解当前转换时GDT的段还是LDT的段,根据寄存器得到地址和大小,然后得到一个组。再拿出段选择符前13位,找到对应段描述符,即可知道基址地址。队友把基址地址和得到数组结合,就得到要转换的线性地址。

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

形式上来说,地址翻译是一个N元素的虚拟地址空间中的元素和一个M元素的物理地址空间中元素之间的映射。MMU实现了这种线性地址到物理地址的变换,它是利用页表来实现的。CPU中的一个控制寄存器,页表基址寄存器指向当前页表。n位的虚拟地址包含两个部分:一个P位的虚拟页面偏移,和一个n-p位的虚拟页号。MMU利用VPN来选择适当的PTE。例如VPN0选择PTE0,VPN1选择PTE1,以此类推。将页表条目中物理页号和虚拟地址中的VPO串联起来,就得到相应的物理地址。注意,因为物理和虚拟页面都是P字节的,所以物理页面偏移和VPO是相同的。如下图所示:
在这里插入图片描述
图 58

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

下图给出了Core i7 MMU如何使用四级的页表来将虚拟地址翻译成物理地址。36 位VPN被划分成四个9位的片,每个篇被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN1提供一个到L1 PTE的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2 PTE的偏移量,以此类推。
在这里插入图片描述
图 59

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

对于高速缓存(cache),假定我们知道,高速缓存的组数S,每组的行数E,以及每块的 大小B。那么我们就可以计算出S=2^s, B=2^b,由此计算出s和b。高速缓存结构如下图所示。那么我们就可以把物理地址划分为三部分,后b位为块内偏移地址,紧接着s位为组索引位,剩下的就是t标记位。
t位标记位 s位组索引位 b位块内偏移
判断是否命中,先根据s组索引位找到cache相应的组,再根据标记位找到某一行,看该行的有效位v是否为1,为1则命中,不为1则未命中。1级cache未命中则在2级cache重复如上操作,2级未命中再在3级cache如上。如果命中。就要采用适当的替换策略来进行替换。
在这里插入图片描述
图 60

7.6 hello进程fork时的内存映射

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

7.7 hello进程execve时的内存映射

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

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

1.缺页:在虚拟内存中的习惯说法中,DRAM缓存不命中称为缺页。下图展示了缺页之前我们的示例页表的状态。CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4。如果VP4已经被修改了,那么内核就会内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。
在这里插入图片描述
图 62
3.缺页中断处理:接下来,内核从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。下图展示了在缺页之后我们的示例页表的状态。
4.
在这里插入图片描述
图 63

7.9动态存储分配管理

一.动态内存分配器维护着一个进程的虚拟内存区域,称为堆。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应程序使用。空闲块可用来分配。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
1.显式分配器,要求应用显式地释放任何已分配的块。
2.隐式分配器,另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。
二.malloc函数
malloc函数返回一个指针,指向大小至少为size字节的内存块,这个块可能包含在这个块内的任何数据对象类型做对齐。
1.隐式空闲链表
任何实际的分配器都需要一些数据结构,允许它来区别块边界。大多数分配器将这些信息嵌入块本身,如下图:
在这里插入图片描述
图 64
假设块的格式如上图所示,我们可以将堆组织为一个连续的已分配块和空闲块的序列,如下图:
在这里插入图片描述
图 65
称这种结构为隐式空闲链表。
2.放置已分配的块
分配器执行这种搜索的方式是由放置策略确定的。一些常见的策略是首次适配,下一次适配和最佳适配。
(1)首次适配从头开始搜索空闲链表,选择第一个合适的空闲块
(2)下一次适配和首次适配很相似,只不过不是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。
(3)最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。
3.分割空闲块
一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,那就是分配这个空闲块中多少空间。一个选择是用整个空闲块。虽然这种方式简单而快捷,但是主要的缺点就是会产生内部碎片。
4.获取额外的堆内存
如果分配器不能为请求块找到合适的空闲块,一个选择是合并。另一个选择就是调用sbrk函数,向内核请求额外的堆内存。
5.合并空闲块
当分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲块相邻。这些邻接的空闲块可能引起一种现象,叫做假碎片。为了解决这个问题,任何实际的分配器都必须合并空闲块这个过程称为合并。
下面介绍一下带边界标记的合并:
在这里插入图片描述
图 66
如上图,在每个块的结尾处添加一个脚部, 其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断当前一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。
6.显示空闲链表
一种更好的方式是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred和succ指针,如下图所示:
在这里插入图片描述
图 67
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于我们所选择的空闲链表中块的排序策略。
一种方法是后进先出的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于按照地址排序二点首次适配比LIFO排序的首次适有更高的内存利用率,接近最佳适配的利用率。

7.10本章小结

第七章引入了物理地址和虚拟地址的概念,以及相互转换的过程–段式空间和页面管理。同时讨论了页命中和页不命中的操作。对fork和execve有了新的认识。了解了动态内存分配的方式和放置策略。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

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

8.2 简述Unix IO接口及其函数

1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需要记住这个描述符。
2.Linux shell创建的每个进程开始时都有三个打开的文件:标准输入,标准输出和标准错误。
3.改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显示地设置文件的当前位置为k。
4.读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。
5.关闭文件。当应用完成了对文件地访问后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
函数:
1.open函数
将filename转换为文件描述符,并且返回描述符数字。返回地描述符总是在进程中当前没有打开地最小描述符。flag参数指明了进程打算如何访问这个文件。
2.close函数
调用该函数关闭一个打开的文件。
3.read函数
从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
4.write函数
从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
5.lseek函数
调用该函数,应用程序能够显示地修改当前文件的位置。

8.3 printf的实现分析

如下时printf代码:
在这里插入图片描述
图 68
在printf中,开始定义了一个整型变量和字符型数组。
看va_list arg = (va_list)((char*)(&fmt) + 4)这句。
对于 va_list的定义如下:
typedef char va_list
这说明它是一个字符指针。
其中的: (char
)(&fmt) + 4) 表示的是…中的第一个参数。
还可以看出,该函数主要调用了vsprintf函数,来看下vsprintf的内容:
在这里插入图片描述
图 69
vprintf返回的是一个长度,该长度就是要打印的字符串的长度。
所以说:vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
接下来让我们追踪一下write函数:
在这里插入图片描述
图 70
然后再看一下sys_call的实现方式:
在这里插入图片描述
图 71
发现涉及到的东西太多。我们可以把sys_call换一种形式:
在这里插入图片描述
图 72
这样就大致实现了printf的功能。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

来看一下getchar函数的代码
在这里插入图片描述
图 73
程序调用getchar,然后等待用户输入,用户输入的字符会被存放在缓冲区中。当用户输入回车后,getchar开始读字符。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

第八章主要讲述hello的I/O管理。先分析了Linux的I/O设备管理方法。然后简述Unix IO接口及其函数。最后就是对printf和getchar两个我们常用的函数进行实现方法和分析过程。

结论

hello是一个可执行文件,它是由hello.c文件生成。而hello.c是由程序员编写的C程序文件。在hello.c生成可执行文件hello过程中主要涉及如下几个阶段:
1.预处理阶段,预处理器将hello.c预处理,生成文本文件hello.i。
2.编译阶段,由编译器完成,编译器将hello.i生成汇编程序,生成了一个文本文件hello.s
3.汇编阶段,汇编器完成,汇编器将汇编语言生成机器语言,生成可重定位目标文件hello.o。
4.链接阶段,生成可执行文件的最后一个阶段,由链接器实现,将多个可重定位目标文件链接生成可执行文件hello。
hello的生成只是一个开始,接下来就是运行hello,这里需要用户输入命令,然后shell根据命令是否是内部,fork子程序。在该新创建的子程序中,会调用execve函数加载hello,将hello复制到内存中,hello也就成为了进程,获得了相应的PID,时间片,虚拟地址空间等等。
在hello运行过程中,用户可以输入命令信号如Ctrl-Z等对进程发送信号。hello通过抽象为文件的I/O设备输出字符串。
当进程结束后,hello也就结束了他的任务,等待他的就是被父进程回收,看似简单的一个程序,实际上蕴含着多种复杂的操作,这就是计算机系统的魅力。

附件

hello.c --hello源程序
hello.i --预处理生成的文本文件hello.i
hello.s --编译产生的文本文件hello.s
hello.o --汇编后生成的可重定位目标文件hello.o
hello --链接生成的可执行文件hello
hello2.s – objdump hello生成的hello的反汇编代码文件
helloo.s --objdump hello.o生成的hello.o的反汇编代码文件

参考文献

[1] 兰德尔 E.布莱恩特,大卫 R.奥哈拉伦. 《深入理解计算机系统(第三版)》. 机械工业出版社《深入理解计算机系统(第三版)》
[2]https://www.cnblogs.com/pianist/p/3315801.html printf函数实现的分析
[3]https://www.runoob.com/cprogramming/c-function-getchar.html getchar函数的分析

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值