hello的一生hit_csapp1180300108xxg

计算机系统

大作业

题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术学院
学   号 1180300108
班   级 1803001
学 生 关天昊    
指 导 教 师 郑贵滨

计算机科学与技术学院
2019年12月
摘 要
摘要是论文内容的高度概括,应具有独立性和自含性,即不阅读论文的全文,就能获得必要的信息。摘要应包括本论文的目的、主要内容、方法、成果及其理论与实际意义。摘要中不宜使用公式、结构式、图表和非公知公用的符号与术语,不标注引用文献编号,同时避免将摘要写成目录式的内容介绍。
本论文主要讲述了hello.c源程序从预处理到编译到汇编到链接等一系列操作完成从源程序到可执行程序的转化,同时也是计算机如何完成hello.c从program到process的转化,同时又介绍了Linux系统下的shell bash技术对可执行文件的处理,从创建子进程,到执行程序,到上下文切换,到内存管理,到异常信号处理,到父进程对子进程的回收等等,系统又详细的说明了Linux系统的各方面协调工作的能力,从多方面阐述了Linux系统如何为可执行文件提供了一个如此优秀全面的运行平台
关键词:编译,运行,进程,信号处理,

目 录

第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.c。
在主人有意地将我从普通的.txt改成.c的一刹那,我觉察到我的人生可能因此而完全走上了另一条道路。
在linux终端下,我看到了一条预处理的指令,然后我觉察到什么东西在看着我一样,我虽然只能隐隐约约看到cpp的字样,但是我却能清醒的觉察到我的身体仿佛被看穿了一样。那一刻,我仿佛像一个不经世事的孩童一般,在cpp面前毫无保留,接着我看到了我自己!我很惊奇,我发现我的扩展名变了,我变成了hello.i,索性我的大部分还在,只是比之前的我“壮”了一些。就这样我变成了hello.i,窥探过自身我才觉察到我的升华,我感到了自己从十几行代码到上千行代码的前所未有的充实感,但是这还远远不够…
我仿佛像一个冒险家一般,对于外来事物来者不拒,我想要成长,我急切的渴望着力量,于是我走访了ccl,之后我被编译成为hello.s,再然后我找上了as,之后我被汇编成为hello.o,但是,我只觉察到了自己自身的变化,随着变化的越来越多,我的眼光也越来越大。
我诞生到这个世界中肯定有我自身的道理的,我不怕被改变,我只怕自己还没来得及知道自己的使命就悄无声息的离开这个世界,那将是最悲哀的,因而,怀揣着对力量和存在意义的渴望,我义无反顾的冲向了ld,在那里,我见到了好多好多和我一样带着同等标签(.o)的一群家伙,我知道,来到这里是对的,一定是上天的安排让我们相遇。就这样,在ld的帮助下,我们结合(链接)了。
我变成了hello,是的你没有看错,到那时我才真正体会到可执行程序的强大。我真正的接触了高层,步入了上流社会,从此可能走向人生巅峰!
shell中一行启动命令,我觉察到我开始执行我的使命了,shell为我fork,一刹那,我觉察到了子进程的诞生,仿佛是血脉之间的牵挂。(此间便是从program到process的全过程)
再然后,我觉察到shell为我的小子进程execve,mmap,分我时间片,不知为何我清晰地认识到,他就是我,我们是一样的,无论OS与MMU怎么折腾他,各种辅助(TLB、4级页表、3级Cache,Pagefile)怎么为他加速,IO管理和信号处理如何运作,他都没有抱怨过一句,只是咬紧牙关坚持了下来,那一刻我感觉到仿佛我的内心就是他的内心,我能清晰的读懂他狰狞坚毅面庞下的心情,这是我的演出,不管别人怎么看我,不管我的演技有多么拙劣,也不管我是否只有这一次出场的机会,但是现在这个时间,这里,是我的主场!
运行的时间转瞬即逝,我收到了命令,我要回收这个子程序,这也是命中注定,我看不清他的情绪,但是我心里是欣慰的,他应该也是这样吧,我自顾自的这么认为着,然后,在看到下一个始终信号之前我也陷入了深度睡眠中。
我不知道在我睡着之后外面发生了什么,我也不知道在我再一次清醒过来的时候我会在哪里,还会不会有这段记忆,但是在我睡着的前一秒,我的内心是欣慰的,至少那个时候还有我自己,知道我真的来过。(从zero到zero)
1.2 环境与工具
1.2.1 硬件环境
Intel Core i7-7700HQ 2.81GHz,8GB RAM,128GB SSD
1.2.2 软件环境
Windows10 64位; Vmware 11;Ubuntu 16.04 LTS 64位
1.2.3 开发工具
Visual Studio 2010 64位;CodeBlocks 64位;vi/vim/gedit+gcc;readelf;edb、gdb;
1.3 中间结果
hello.c原始c程序(源程序)
hello.i预处理操作后生成的文本文件
hello.s编译之后生成的汇编语言文件
hello.o汇编之后生成的可重定位文件
hello链接之后生成的可执行程序
hello.txt可执行文件hello的反汇编语言代码
helloo.txt可重定位文件hello.o的反汇编语言代码
hello.elf可执行文件hello的ELF文件格式
1.4 本章小结
通过hello的简介叙述hello的一生,简述本篇论文主要讲述的各种操作过程以及为了研究这些操作所用到的工具和环境,描述出一个本篇论文的大体结构。

第2章 预处理
2.1 预处理的概念与作用

预处理的概念:在编译之前进行的处理。预编译器(cpp)根据以字符#开头的命令,将头文件stdio.h的内容直接插入到Hello.c文件中,最终的得到一个以i为扩展名的C文件—Hello.i文件。
这个文件的含义同没有经过预处理的源文件是相同的,但内容有所不同。
C语言的预处理主要有三个方面的内容: 1.宏定义; 2.文件包含; 3.条件编译。预处理命令由#(hash字符)开头, 它独占一行, #之前只能是空白符. 以#开头的语句就是预处理命令, 不以#开头的语句为C中的代码行

e.g:
(1)【非含参数宏定义】#define PI 3.1415926 把程序中出现的PI全部换成3.1415926
(2)【含参数宏定义】#define S(a,b) ab 把程序中的S(a,b)参数替换成ab
(3)【文件包含】#include <stdio.h> 把stdio.h头文件添加到当前源文件中,变成源文件的一部分
(4)【条件编译】
#ifdef
程序段1
#endif
当标识符已经定义时,程序段1才参加编译。
预处理的作用:预编译器根据程序中以“#”字符号开头的一些命令来处理源程序,
比如将一些头文件的处理,“#”开头的命令告诉预处理器读取头文件的内容并将其插入到源程序文本中,从而得到一个.i为扩展名的新的c程序。
【宏定义】:使用宏可提高程序的通用性和易读性,减少不一致性,减少输入错误和便于修改;
【文件包含】:已编写好的头文件可以极大程度上帮助程序员缩短代码行数,程序编写过程中必要的方法在已有的头文件中声明,大大缩短了代码的行数,提高代码的整洁性,可读性,有助于软件开发(源程序编写)。
【条件编译】:该预处理使得问题或算法的解决方案增多,便于我们选择合适的解决方案。
2.2在Ubuntu下预处理的命令
通常:gcc -E hello.c > hello.

2.3 Hello的预处理结果解析
预处理之后的程序从十几行变到了几千行,且在源程序中编写的代码在程序最下方。
对比于删除所有“#”C语言指令生成的.i程序(仍只有几十行),可以确定该阶段预编译器是处理了源程序中的#include把相应头文件加入到源程序中。
预处理时cpp在给源程序加入头文件的过程中对头文件中的某些需要预处理的语句同样需要进行处理,一层层递归,直到完全处理所有“#”才算完成预处理过程处理结果分析可见,其中只有常量,如数字、字符串、变量等的定义,以及C语言的关键字,如main,if,else,for,while,{,}, +,-,*,\,等等

2.4 本章小结
预处理可以方便程序员的代码编写,使程序的简洁性大大提高,且预先编辑好的方法函数在头文件中包含,大大降低了代码的编写难度,减少了代码行数,提高可读性,有助于软件开发(源程序编写)效率的提高。
第3章 编译
3.1 编译的概念与作用

编译的概念:编译器(ccl)将.i文件(处理过得源程序)编译成.s文件。由于计算机并不能直接接受和执行用高级语言编写的源程序(此处指.c,.i文件),因而利用编译器,能将高级语言编写的程序全盘扫描,翻译成用机器语言表示的与之等价的目标程序(此处指.s,即汇编语言程序),该目标程序能被计算机接受和执行,以便后续翻译等操作进行。
编译的作用:编译过程中,编译器会对文件内部的语法和语义做处理,(至少扫描源文件一遍),保证无误才能生成目标程序。由此可见,编译操作实际上是对源程序进行整体全面的检查,确保无误才能进一步执行后续操作进而生成可执行文件,所以编译出错的程序注定无法解释和运行。
同时,编译操作实际上将计算机无法理解接受和执行的文件,转变成了低级的但是计算机可以接受和执行的机器语言所写的目标程序,该目标程序是应用汇编语言编写的,它为不同高级语言的不同编译器提供了通用的输出语言,因而编译操作也是从高级语言到机器语言的过渡操作,有不可或缺的作用。
3.2 在Ubuntu下编译的命令

通常:gcc -S hello.i > hello.s

3.3 Hello的编译结果解析
3.3.1全局变量:
首先是全局变量部分:

这里可见,将全局变量存放在.rodata中。注意这里因为是int类型数据,而初始化时赋给的值是2.5,是float类型数据,所以将2.5强制转化为2,所以sleepsecs的值仍为2。
3.3.2 字符串数据:
之后是存储的字符串内的数据:

将可能需要打印的东西存放在函数.LC0和.LC1中,以便后面的主函数进行调用。
3.3.3 主函数:
之后是主函数,代码如下图。

main函数:

传递控制,main函数因为被调用call才能执行(被系统启动函数__libc_start_main调用),call指令将下一条指令的地址dest压栈,然后跳转到main函数。
传递数据,外部调用过程向main函数传递参数argc和argv,分别使用%rdi和%rsi存储,函数正常出口为return 0,将%eax设置0返回。
分配和释放内存,使用%rbp记录栈帧的底,函数分配栈帧空间在%rbp之上,程序结束时,调用leave指令,leave相当于mov %rbp,%rsp,pop %rbp,恢复栈空间为调用之前的状态,然后ret返回,ret相当pop IP,将下一条要执行指令的地址设置为dest。

3.3.4 其它标注与汇编知识:
变量分为全局变量和静态变量。
全局变量又分为已初始化变量和未初始化变量。已初始化变量存放在.data字节,未初始化存放在.bss字节。
局部变量在程序运行时,堆栈为其分配空间,程序运行结束,其值也随之被舍弃。
长度类型:
char b(1字节)
short w(2字节)
int l(4字节)
float l(4字节)
long q(8字节)
double q(8字节)
汇编代码的一些运算指令:
add 加法 sub 减法 imul 乘法 div 除法
xor 亦或 or 或 and 与
sal 算数左移 shl逻辑左移
sar算术右移 shr 逻辑右移
3.3.5 控制转移
程序中涉及的控制转移

if (argv!=3):当argv不等于3的时候执行程序段中的代码。如图,对于if判断,编译器使用跳转指令实现,首先cmpl比较argv和3,设置条件码,使用je判断ZF标志位,如果为0,说明argv-3=0 argv==3,则不执行if中的代码直接跳转到.L2,否则顺序执行下一条语句,即执行if中的代码。for(i=0;i<10;i++):使用计数变量i循环10次。编译器的编译逻辑是,首先无条件跳转到位于循环体.L4之后的比较代码,使用cmpl进行比较,如果i<=9,则跳入.L4 for循环体执行,否则说明循环结束,顺序执行for之后的逻辑。

3.4 本章小结
通过解读汇编代码,详细了解了hello程序在运行时的汇编机制和汇编流程。应注意的是,当数据类型和初始化值不一致时,初始化值会被强制转化为可适应数据类型的值,导致其值发生变化。这里显然是一个故意设置的问题,但是在我们进行编程时应该注意防范这种类型的错误。

第4章 汇编
4.1 汇编的概念与作用
汇编的概念:汇编器(as)将hello.s翻译成机器语言指令,并把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保留在目标文件hello.o中。
目标文件中所存放的也就是与源程序等效的目标的机器语言代码。
目标文件由段组成。通常一个目标文件中至少有两个段:
代码段:该段中所包含的主要是程序的指令。该段一般是可读和可执行的,但一般却不可写。
数据段:主要存放程序中要用到的各种全局变量或静态的数据。一般数据段都是可读,可写,可执行的。
汇编的作用:汇编操作将汇编语言程序转变为一个可重定位文件,该文件不是最终的可执行文件,但是该文件可以和一些静态连接库或者动态连接库链接共同加入到可执行文件中去。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o

4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。

命令:readelf -a hello.o >helloelf.o.txt
ELF头:

节头:

flag值:

重定位信息:

符表信息:

4.4 Hello.o的结果解析

对照分析:上述编译产生的汇编语言程序不存在重定位节之类的信息,同时也没有该汇编操作之后生成的机器语言代码。比较之后可以得出当前的可重定位文件反汇编得到的汇编语言是已经到达机器级别的可以被计算机识别层次上的汇编语言代码(由于机器语言指令),较之编译生成的.s文件,该汇编语言程序由于存在重定位节,因而多采用pc相对或者绝对寻址的方法进行系统函数的调用等,但得到的这两种汇编语言大体并没有区别。

机器语言与汇编语言:机器语言是由二进制字节码构成的(图中十六进制表示),程序中每一句汇编语言都对应着一句机器语言指令,换句话说,每一句汇编语言指令都与特定的机器语言指令一一映射,所以本质上看汇编语言程序可以把每一条指令看成一条机器语言指令,(因为汇编语言只需要一次编译就到机器语言了)。
该反汇编生成程序中,有些操作数和汇编语言不匹配,同时我们也能看到一诸多汇编语言代码中包含着一个个重定位节(例如.rotate),相应的函数调用诸如R_x86_64_PC32等重定位节采用pc相对寻址得到想要函数的有效地址,而诸如R_x86_64_32等重定位节采用绝对寻址得到想要函数的有效地址,由于需要重定位,所以有些操作数在对比机器语言和汇编语言的时候会有不一致,但想要实现的操作无差别。

第5章 链接
5.1 链接的概念与作用

链接的概念:链接是处理可重定位文件,由链接器负责将所有程序的目标文件与所需的素有附加的目标文件连接起来并最终生成可执行文件。附加的目标文件可以是静态连接库(通常以.a结尾),也可以是动态链接库(通常以.so结尾)。链接的最终产物是可执行文件
链接的作用:链接完成了从重定位文件到可执行文件的转化。由于汇编程序通过汇编生成的目标文件不能被立即执行,其中可能存在着很多没有解决的问题,例如系统函数的调用,所以链接操作将所有程序中可能用到的文件彼此连接成一个可执行文件,这样的可执行文件才能被系统正确的执行。
5.2 在Ubuntu下链接的命令
指令:ld -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 /usr/lib/gcc/x86_64-linux-gnu/7/crtbegin.o -L/usr/lib/gcc/x86_64-linux-gnu/7 -L/usr/lib/x86_64-linux-gnu -L/usr/lib -L/lib/x86_64-linux-gnu -L/lib/…/lib hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/7/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。

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

节头:

flag值:

程序头:

节到段的映射:

偏移0xe50的位置包含21个条目:

重定位信息:

其它信息:

在节头中包含各个节的大小和在程序中的偏移量。利用节头提供的信息我们就可以定位每一节的位置。
5.4 hello的虚拟地址空间

使用edb调试,可见hello的可执行文件通过头部表把连续的文件节映射到分散的运行时内存段,通过地址和偏移量,我们得到程序的起始位置为0x400000,数据的起始位置为0x600000。
5.5 链接的重定位过程分析
Hello中地址为虚拟内存地址,hello.o中则是相对偏移地址。对跳转函数和调用的函数亦是如此。
首先,hello中没有.rela.text节、.bss节、.note.GNU-stack节、.rela.eh_frame节。相对地,增加了以下这些节:
.interp:保存ld.so的路径
.note.ABI-tag:Linux下特有的节
.hash:符号的哈希表
.gnu.hash:GNU拓展的符号的哈希表
.dynsym:运行时/动态符号表
.dynstr:存放.dynsym节中的符号名称
.gnu.version:符号版本
.gnu.version_r:符号引用版本
.rela.dyn:运行时/动态重定位表
.rela.plt:.plt节的重定位条目
.init:程序初始化需要执行的代码
.plt:动态链接-过程链接表
.fini:当程序正常终止时需要执行的代码
.dynamic:存放被ld.so使用的动态链接信息
.got:动态链接-全局偏移量表-存放变量
.got.plt:动态链接-全局偏移量表-存放函数
5.6 hello的执行流程
(以下格式自行编排,编辑时删除)
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
ld-2.23.so _dl_start ld-2.23.so _dl_init LinkAddress _start libc-2.23.so _libc_start_main libc-2.23.so _cxa_atexit LinkAddress _libc_csu.init libc-2.23.so _setjmp
5.7 Hello的动态链接分析
链接器为该引用生成一条重定位记录,然后动态链接器在程序加载时再解析。GNU系统使用延迟绑定,将过程地址绑定推迟到第一次调用该过程时。
5.8 本章小结
通过链接的学习,对学生对于链接的过程和具体发生了什么进行了更深入的了解。
(第5章1分)

第6章 hello进程管理
6.1 进程的概念与作用
进程的经典定义是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
进程提供给应用程序两个关键抽象:
1.一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
2.一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
shell是一个交互型的应用级程序,它代表用户运行其他程序。
shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。
求值步骤:
1.调用parseline函数解析以空格分隔的命令行参数,构造argv向量传递给execve;
2.调用builtin_command函数,检查第一个命令行参数是否是一个内置的shell命令,如果是,它就立即解释这个命令,并返回值1;
3.如果builtin_command函数返回0,那么shell创建一个子进程,并在子进程中执行所请求的程序。如果用户要求在后台运行该程序,那么shell返回到循环的顶部,等待下一个命令行。否则,shell使用waitpid函数等待作业终止。当作业终止时,shell就开始下一轮迭代。
6.3 Hello的fork进程创建过程
在执行hello程序的过程中,需要在shell命令行中键入 ./hello 1170301027 fengshuai, 运行的终端程序会对输入的命令进行解析,由于./hello不是内置命令(quit,fg,bg,stop等等),所以shell会调用fork函数为程序创建一个新的运行的子进程,这个子进程几乎但不完全与父进程相同,但是不可否认的子进程得到哦与父进程用户级虚拟地址空间(但是独立)的一个副本,包括代码和数据段,堆、共享库以及用户栈。子进程还同时获得了与父进程任何打开文件描述符相同的副本,这就意味着,当父进程调用fork时,子进程可以读写父进程中打开的各种文件。子进程和其父进程之间最大的区别就是他们有不同的PID。
6.4 Hello的execve过程
在创建了子进程之后,通过判断PID == 0来判断是否是子进程。然后在子进程中调用execve函数在当前上下文中加载并运行Hello程序。该函数通过调用启动加载器来执行程序,然后会有新的代码段数据段被初始化为Hello准备,然后设置PC指向_start函数,然后设置堆栈,最后将控制传递给main函数正式开始运行程序。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
概念定义:
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
上下文切换:由内核中的调度器完成的,当内核调度新的进程运行后,它就会抢占当前进程,(1)保存当前进程的上下文(2)恢复某个先前被抢占的进程被保存的上下文(3)将控制传递给这个新恢复的进程
逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
Hello进程执行分析:
在正确输入命令行执行./hello程序之后程序会循环十次的显示用户的学号和姓名信息,而此时循环语句中的sleep函数的调用就满足上下文切换条件。Sleep函数属于系统调用,它显式的请求让调用进程休眠,一般而言,计时系统调用没有阻塞,内核也可以决定执行上下文切换。 在调用sleep之前,如果hello程序不被抢占资源,那么将会顺序执行当前进程,(看似当前进程独立的占用所有资源),一旦发生抢占现象,就会进入上下文切换。
Hello初始运行在用户模式下,在调用sleep函数之后,陷入内核模式,此时其他的进程会来抢占,完成一次上下文切换;当sleep函数调用完成等待sleepsecs秒或者因为某个中断信号(例如pause)而过早的返回之后,内核处理该信号并主动释放当前正在运行的进程,切换回hello进程继续执行,完成又一次的上下文切换。这在用户的角度来看不过是程序休眠了2秒后又继续执行那么简单。进程在调用getchar函数的时候,实际上意味着调度stdin标准输入流中的read系统调用,同样会执行上下文切换。
Hello进程在用户模式下执行到getchar函数的时候,它通过执行系统调用read而陷入到内核模式,其他进程抢占。内核中的陷阱处理程序请求来自磁盘控制器中的DMA传输,并且安排在磁盘控制器完成从磁盘大内存的数据传输。此间进程在完成从用户输入数据之后,直到磁盘发出一个中断信号,表示数据已经从磁盘传入到内存中,内核判定其他进程已经运行了足够的时间,就执行又一个上下文切换,从其他进程切换回hello进程继续运行。
6.6 hello的异常与信号处理
异常有中断和终止两种。
中断异常:在程序进行途中输入ctrl+z,程序被中断,再输入job命令可见该程序依然存在。

终止异常:在程序运行时输入ctrl+c,程序被终止,输入jobs可见,程序已经终止,并没有在后台等待运行。输入ps,显示只有bash和ps,并没有hello运行过的痕迹,可见hello已经被彻底“杀死”。输入kill也无进程可kill。在运行过程中乱按,被按下的字母会被打印在终端上,但并不影响程序运行。

hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.7本章小结
hello的实际执行过程中,shell为其fork产生子进程,为其exceve执行程序,为其分配时间片,为其处理异常。在shell的支持下,hello可以有条不紊地执行一定的操作。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1 hello的存储器地址空间
逻辑地址(LogicalAddress):逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址,其实是指由程序产生的与段相关的偏移地址部分(段内偏移量)。映射到hello.o里面的相对偏移地址。
线性地址(address space):线性地址是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。此间映射到hello里面的虚拟内存地址。
虚拟地址(Virtual Address, VA) :CPU 通过生成一个虚拟地址。映射到hello里面的虚拟内存地址。
物理地址(Physical Address,PA):物理地址用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。计算机系统的主存被组织成一个由M 个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。映射到hello在运行时虚拟内存地址对应的物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理: 逻辑地址->线性地址==虚拟地址
1、逻辑地址=段选择符+偏移量
2、每个段选择符大小为16位,段描述符为8字节(注意单位)。
3、GDT为全局描述符表,LDT为局部描述符表。
4、段描述符存放在描述符表中,也就是GDT或LDT中。
5、段首地址存放在段描述符中。
每个段的首地址都存放在自己的段描述符中,而所有的段描述符都存放在一个描述符表中(描述符表分为全局描述符表GDT和局部描述符表LDT)。而要想找到某个段的描述符必须通过段选择符才能找到。段选择符由三部分组成,从左到右依次是index【索引】,TI,RPL。
Index处,我们可以将描述符表看成是一个数组,每个元素都存放一个段描述符,那么index就表示数组下标,亦即某个段描述符在数组中的索引。再者,当TI为0时,表示段描述符在GDT中,当TI为1的时候,表示段描述符在LDT中。
RPL代表请求特权级,RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级。
现在假设我们有一个段的段选择符,他的TI是0,Index是8,那么我们可以知道这个段的段描述符实在GDT数组中索引为8的位置。从而由我们知道的GDT的起始地址,每个段描述符的大小,就可以精确地找到我们想要的段描述符,从而获取某个段的首地址,然后再将从段描述符中获取到的段首地址与逻辑地址的偏移量相加就得到了线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理: 虚拟地址->物理地址
分页机制是对虚拟地址内存空间进行分页。
Linux系统有自己的虚拟内存系统,如图7.4,Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct的链表,一个链表条目对应一个段,所以链表相连指出了hello进程虚拟内存中的所有段。概念上而言,虚拟内存被组织为一个由存放在磁盘上N个连续的字节大小的单元组成的数组,每子节都有唯一一个虚拟地址作为到数组的索引。磁盘上数组的内容被缓存在主存中。和存储器结构中其他的缓存一样,磁盘(较低层)的数据被分割成块,此间(VM)虚拟内存系统将虚拟内存分割成拟页VP(Virtual Page)大小固定的块来处理这个问题,linux下通常每个虚拟页的大小为4KB,与之相类似,物理内存也被分割成物理页PP(Physical Page),大小和虚拟页大小一致。此间虚拟内存系统中的MMU内存管理单元对地址的翻译,就形象为物理内存中叫做页表的数据结构从虚拟页映射到物理页的过程。图7.5详细的展示了地址翻译的全过程。页表基址寄存器指向当前页表,n位的虚拟地址包含虚拟页号和虚拟页偏移量两部分,同样物理地址也由物理页号和物理页偏移量组成。MMU通过VPN来选择适当的PTE,由此,将索引到的页表条目中的PPN和VPO串联起来就是虚拟地址
步骤如下:

  1. 处理器生成一个虚拟地址,并将其传送给MMU
    2-3) MMU 使用内存中的页表生成PTE地址
  2. MMU 将物理地址传送给高速缓存/主存
  3. 高速缓存/主存返回所请求的数据字给处理器
    7.4 TLB与四级页表支持下的VA到PA的变换
    TLB:翻译后备缓冲器,TLB是MMU中一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块,通常有高度的相连度,TLB实现饿了虚拟页码向物理页码的映射,对于页码数很少的页表可以完全包含在TLB中。用于族选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来。
    关键点在于,所有的地址翻译的步骤都是在MMU中执行的,因此非常快
    翻译步骤:
  4. 处理器CPU生成一个虚拟地址,并将其传送给MMU
    2-3) MMU 从TLB中取出相应的PTE
  5. MMU 将这个虚拟地址翻译成物理地址传送给高速缓存/主存
  6. 高速缓存/主存返回所请求的数据字给处理器CPU
    多级页表:以二级页表为例
    一级页表: 每个 PTE 指向一个页表 (常驻内存)
    二级页表: 每个 PTE 指向一页(paged in and out like any other data页面可以调入或调出页表)二级页表中的每个PTE都负责一个4KB的虚拟内存页面,就像我们产看的只有一级的页表一样,注意,使用四字节的PTE,每一个一级和二级页表都是4KB的字节,这刚好和一个页面的大小是一样的。
    这种方法从两个方面减少了内存要求。第一,如果一个一级页表是空的,那么其对应的二级页表将不存在。这代表着一种巨大的潜在节约,移位对于一个典型的程序,四GB的虚拟地址空间的大部分都将会是未分配的。第二,只有一级页表才需要总是在主存里,虚拟系统可以再需要时创建,页面调入或者调出二级页表这就减少了主存的压力,只有经常使用的二级页表才会在主存里。
    Core i7MMU如何使用四级页表来讲虚拟地址翻译成物理地址的全过程。36位VPN被划分成了四个9位的片,每个片被用作到一个也表的偏移量。CR3寄存器包含L1也表的物理地址。VPN1提供一个到L1PTE(页表条目)的偏移量,这个PTE包含L2也表的基地址。VPn2提供一个到L2PTE的偏移量,以此类推…。
    7.5 三级Cache支持下的物理内存访问

三级cache运行的Linux的 Core i7内存系统中对于处理器的封装很有讲究(此间不做过多介绍),我们可知的是,Linux使用的是4KB的页,并且通过TLB虚拟寻址能得到我们想要的物理地址,然而有了这个物理地址之后,处理器可以通过对物理地址的处理而得到相应的在依赖物理地址寻址的L1L2L3三级高速缓存快速判断命中与否。
对L1高速缓存(注意不是页表!!)来说,在通过页表操作获取到从虚拟地址(VA)转化来的物理地址(PA)之后,使用CI(64组6位组索引位)进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则造成一次L1缓存命中(hit),然后根据数据偏移量CO(后六位)取出数据返回。如果当前没有匹配成功但是此时valid标志位已经被设置为1(块不匹配但是已分配),那么此时造成一次L1缓存不命中(miss),则此时需要向下一级cache中查询数据(优先级依次为L2->L3->主存)。直到查询到数据后,判断当前组内是否有空闲块,如若有则直接写入;否则L1cache将会采用最近最少使用策略对组中的某个确定块进行驱逐(eviction)然后再写入。事实上实际系统在运行的时候当在需要翻译虚拟地址的时,CPU就已经将VPN发送到了高速缓存中。也就是说,理解翻译过程之后我们知道由于物理地址的PPO就是就是虚拟地址的VPO,所以,在MMU忙着向TLB请求一个PTE页表条目的时候,L1高速缓存实际上已经开始在分离组索引并查找相应的组了。这极大地情况上加快了翻译效率。
7.6 hello进程fork时的内存映射
Linux shell下fork函数如何为每个新进程提供私有的虚拟地址空间.
为新进程创建虚拟内存
创建当前进程的的mm_struct, vm_area_struct和页表的原样副本.
两个进程中的每个页面都标记为只读
两个进程中的每个区域结构(vm_area_struct)都标记为私有的写时复制(COW)在新进程中返回时,新进程拥有与调用fork进程相同的虚拟内存随后的写操作通过写时复制机制创建新页面
7.7 hello进程execve时的内存映射
exceve执行步骤:
(1)删除已存在的用户区域
(2)创建新的区域结构(私有的、写时复制)
–代码和初始化数据映射到.text和.data区(目标文件提供)
–.bss和栈堆映射到匿名文件 ,栈堆的初始长度0
(3)共享对象由动态链接映射到本进程共享区域
(4)设置PC,指向代码区域的入口点。Linux根据需要换入代码和数据页面
7.8 缺页故障与缺页中断处理
缺页故障是一种常见的故障,当指令引用一个虚拟地址,在MMU中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出的时候就会发生故障。缺页异常调用内核中的缺页异常处理程序。,该程序会选择一个牺牲页,如果该被牺牲页已经被修改过则会被内核直接复制回磁盘。总之内核会总是修改该牺牲页的页表条目(PTE),反应出该牺牲页已经不再缓存在主存中的事实。
随后,当缺页异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重新发送到地址翻译硬件进行地址翻译,此时将不再发生异常。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(图7.12),堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后面开始,并向上生长(更高的地址)。对于每一个进程,内核维护着一个变量brk,用它来指向堆的顶部(堆最小的地址处)。

分配器将堆视为一组不同大小的 块(blocks)的集合来维护,每个块要么是已分配的,要么是空闲的。
分配器的类型:
显式分配器: 要求应用显式地释放任何已分配的快
例如,C语言中的 malloc 和 free隐式分配器: 应用检测到已分配块不再被程序所使用,就释放这个块
比如Java,ML和Lisp等高级语言中的垃圾收集 (garbage collection)
程序使用动态内存分配的最主要原因是经常直到程序运行时,才知道某些数据结构的大小。
在本小节中,介绍两种动态存储分配管理方法:1)隐式空闲链表法 2)显示空闲链表法;
隐式空闲链表(边界标记):通过头部中的大小字段隐含的连接空闲块
1.堆及堆中内存块的组织结构:
2.适配方法:
首次适配 (First fit):
从头开始搜索空闲链表,选择第一个 合适的空闲块:
可以取总块数 ( 包括已分配和空闲块 ) 的线性时间
在靠近链表起始处留下小空闲块的 “碎片”;
下一次适配 (Next fit):
和首次适配相似,只是从链表中上一次查询结束的地方开始
比首次适应更快: 避免重复扫描那些无用块
一些研究表明,下一次适配的内存利用率要比首次适配低得多;
最佳适配 (Best fit):
查询链表,选择一个 最好的 空闲块: 适配,剩余最少空闲空间
保证碎片最小——提高内存利用率
通常运行速度会慢于首次适配;
3.分割空闲块:
在分配块小于空闲块的时候我们可以把空闲块分割成两部分;
释放已分配块:
在程序中不没有用的块或者已经用完了的块需要释放回收;
合并相邻的空闲块:
立即合并 (Immediate coalescing): 每次释放都合并
延迟合并 (Deferred coalescing): 尝试通过延迟合并,即直到需要才合并来提高释放的性能.例如:为 malloc扫描空闲链表时可以合并;外部碎片达到阈值时可以合并
显示空闲链表:在空闲块中用指针连接空闲块
将空闲块组织成链表形式的数据结构。堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。
操作大致上与隐式空闲链表所要完成的操作相同,但要点是,我们只保留空闲块链表,而不是所有块;再者由于无法确定下一个块的位置以及大小(可以在任何地方),故而我们需要存储空闲块前后指针,而不仅仅是大小;同时需要合并边界标记;
插入原则:针对已释放的块,我们有
LIFO(last-in-first-out)policy,后进先出法:
将新释放的块放置在链表开始处;
地址顺序法:
按照地址顺序维护链表:
Addr(祖先)<Addr(当前回收快)<Addr(后继)
7.10本章小结
本章前半章主要讲述了linux下的存储管理,虚拟地址到物理地址的映射过程,翻译过程,以及系统是通过什么怎么样辅助地址翻译的过程的(TLB,四级页表,三级cache),后半章主要讲述了缺页故障的处理过程以及动态内存分配器工作原理原因及必要性等等,理解这些对于我们对系统存储方面的理解有很大的帮助。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:一个linux的文件就是一个m字节的序列:B0,B1,…,B【m-1】所有的I/O设备都被模型化为文件,甚至于内核也被映射为文件。
设备管理:所有的输入和输出都被当做对文件的读和写来执行。将设备优雅的映射为文件的方式,允许Linux的内核引出一个简单的低级的应用接口,成为Unix I/O接口,这使得所有的输入和输出都能够以一种统一并且一直的方式来执行。
8.2 简述Unix IO接口及其函数
Unix IO接口基本操作:

1.打开和关闭文件
open()and close()
2.读写文件
read() and write()
3.改变当前的文件位置 (seek)
指示文件要读写位置的偏移量
lseek()
函数的具体声明:
1.int open(char* filename,int flags,mode_t mode) ,进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,否则返回值为-1表示一个错误。flags参数指明了进程访问这个文件的形式。mode参数指定了新文件的访问权限位。
2.int close(fd),fd是需要关闭的文件的描述符,close返回操作结果(成功为0出错为-1)。关闭一个已关闭的描述符会出错!
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的当前文件位置。返回值-1表示一个错误,否则返回值表示实际传送的字节数量表示成功。
5.lseek函数,应用程序调用该函数能够显示地修改当前文件的位置。
8.3 printf的实现分析
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
C语言标准输入输出库中对于printf函数的定义是这样的:
int printf(const char fmt, …)
{
int i;
char buf[256];
va_list arg = (va_list)((char
)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
从上述代码中我们可以看到 它声明了一个缓冲区变量,大小是256,又声明了一个类型为va_list(定义为指针型变量)的变量,其中((char
)(&fmt) + 4)这部分代表printf参数中“…”的第一个参数,而参数中的fmt正好指向第一个参数。
好了了解了这些之后,我们再看看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++; //判定是“%”,第一个%后面接的内容很重要,与参数有关例如:%d,那第一个参数就应该是整型变量
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,此时i已经被设置为需要打印字符串的长度,接下来就是write的实现了,不用说也知道这句话无非是想告诉OS,我需要打印出在缓冲区中的i个字符,下面追踪到系统write函数的反汇编语言实现;
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]int INT_VECTOR_SYS_CALL
其实简单来看,不过是放到六十四位系统,通过寄存器传了两个参数然后调用了一下系统函数就结束了,此间不做深究。
最后,纵观全局,printf函数其实并不能确定其参数在什么地方结束,也不知道参数的个数,它只会根据format中打印格式的数目依次打印堆栈中参数format后面地址的内容直到结束,这一点其实在我们高级语言设计C语言代码实现过程中已经有所体会。
8.4 getchar的实现分析
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序(发生上下文切换),键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。
getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回
8.5本章小结
本章中主要认识了linux的IO设备管理办法,知道Unix IO接口以及函数,具体分析了printf函数和getchar函数的实现,属于更深层次的代码层面的探究。
结论
1.预处理,这个步骤是将预处理命令进行“翻译”,包括条件编译语句的处理将头文件的内容写在程序里,以及宏定义的替换。
2.编译,将.i文件编译成汇编语言文件。对于不同的高级语言源程序,使用同样的汇编语言,从而保证下一步汇编的顺利进行。
3.汇编,生成可重定位目标文件。计算机只能识别二进制数据。为了节省内存空间,我们把几部分代码分别放在不同的文件里,并在运行时进行动态链接。
4.链接,将可重定位目标文件中各个段的地址修改为运行时的实际虚拟地址,与标准库进行链接,使程序可以被加载到内存中运行。
5.运行,shell创建子进程,程序加载到内存,CPU通过MMU翻译虚拟地址,读写数据,期间涉及到高速缓存的加载和替换。标准输入输出函数通过中断和信号处理,显示字符串,读入用户输入的字符。运行过程中,还可以从键盘发送信号,结果可以是进程的挂起或终止。
感触最深就是高级语言编写的代码不过十几行,但是实际实现它的过程中并不容易,可能我们只不过是几个按键的操作在计算机内部却发生了极其复杂的变化
附件
hello.c 原始c程序(源程序)
hello.i 预处理操作后生成的文本文件
hello.s 编译之后生成的汇编语言文件
hello.o 汇编之后生成的可重定位文件
Hello 链接之后生成的可执行程序
hello.txt 可执行文件helllo的反汇编语言代码
helloo.txt 可重定位文件hello.o的反汇编语言代码
hello.elf 可执行文件hello的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.
(参考文献0分,缺失 -1分)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值