hello

计算机系统

大作业

题 目 程序人生-Hello’s P2P
专 业
学   号
班   级
学 生    
指 导 教 师

计算机科学与技术学院
2018年12月
摘 要
本文从hello.c开始以hello程序为视角讲述了计算机是如何处理一份C语言代码、程序如何在操作系统上运行以及程序对储存资源的使用。涉及到编译、汇编、链接、进程管理、储存管理和I/O管理。本文所述都是基于Linux平台,通过gcc、objdump、gdb、edb等工具对一段程序代码预处理、编译、汇编、链接与反汇编的过程进行分析与比较,并且通过shell及其他Linux内置程序对进程运行过程进行了分析。
关键词:操作系统;计算机组成原理;汇编;内存;进程管理;I/O管理

(摘要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.c诞生于某型文本编辑器,是一个单纯的文本文件。通过预处理,变成.i文件,依然是一个文本文件,是将hello.c的内容进一步展开(替换宏定义,明确include的复制位点)。随后hello.i将被进一步编译成.s文件。直到此时hello.s还是文本文件,但已经被翻译成了汇编代码,并且有着一系列用于修饰的头部说明,声明了各个变量函数的存在方式与存在位置。接着,hello被汇编为.o文件,此时hello.o已经是一个二进制文件了,我们通过反汇编和readelf读取其信息,发现其已经被翻译成01序列的机器语言。最后连接器将hello.o和它依赖的库链接起来,形成可执行文件,反汇编这个文件,将会发现hello的所有函数也出现在了里面同时还有系统相关的启动函数。到这里hello已经变成了一个完整的函数。
接着hello通过shell被执行,由内核为其分配内存与计算资源。这时hello与其它的进程同时存在于操作系统上。当hello因为外界的信号或自己的结束时,内核将删除其内存,不再为其分配时间片。hello就此执行完毕。
1.2 环境与工具
1.硬件环境
Intel Core i5 X64 CPU;2.5GHz;8G RAM;128G SSD + 1T HDD
2.软件环境
Windows 10 64位; VMware 14;Ubuntu 16.04 LTS 64位
3.开发与调试工具
GDB;EDB;OBJDUMP;READELF;CodeBlocks 64位;vim/gedit+gcc
1.3 中间结果
文件名 描述
hello.i 对hello.c预处理结果
hello2.i 对hello.i预处理结果
hello.s 对hello.i编译结果
hello.elf hello.o的elf文件
hello.o 对hello.s的汇编结果
hello2.elf hello.o的elf文件
hello.objdump hello.o的反汇编文件
hello2.objdump hello的反汇编文件
hello 可执行文件
test.c 测试代码(测试信号和状态时所用)
a.out 测试代码的可执行文件

1.4 本章小结
潜龙勿用,阳在下也
此时的hello尚且躺在文本编辑器里。本章只是讲述了后面几章hello将走的路,没有展开。通过这一章可以大致了解hello一生将经历的事情。

(第1章0.5分)

第2章 预处理
2.1 预处理的概念与作用
概念:预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号用来支持语言特性(如C/C++的宏调用)。
作用:

  1. 将源文件中用#include形式声明的文件复制到新的程序中。比如hello.c第6-8行中的#include<stdio.h> 等命令告诉预处理器读取系统头文件stdio.h unistd.h stdlib.h 的内容,并把它直接插入到程序文本中。
  2. 用实际值替换用#define定义的字符串
  3. 根据#if后面的条件决定需要编译的代码
    PS:简单地讲,这一步相当于在不知道代码运行方式的情况下,把代码翻译得更加直白。把该替换的替换,该插入的插入,不对代码内容进行解析。

2.2在Ubuntu下预处理的命令
cpp hello.c > hello.

2.3 Hello的预处理结果解析
事实上,hello.c预处理之后依然是一份C语言文件
2.3.1分析:
我们这样来处理一下:

我们打开两份.i文件比较,发现其核心内容基本一致(但不一样)

.i文件中分为几部分,分别标注从哪个文件的多少行开始复制代码插入,对宏定义的替换,以及原代码。
对比两份代码:
1.两份代码除了头部定义有所不一样以外,在后面复制的情况基本一致,这表明.i文件在这一部分开始复制引用的代码
2.在后面原代码部分,我们可以看到两者是一模一样的,也就是说,预处理的时候不会简单地将原代码放在后面,而是分出预处理的部分和“.c上的C语言”部分
结论:我们通过分析得知,.i文件依然是一份C语言代码,它没有任何宏定义与#include语句,且有大量的对数据类型的解释。最后会带上一份“我们写的代码”部分,来组织这一系列解释。
2.4 本章小结
见龙在田,德施普也。
hello这时已经做好了最开始的准备,见龙在田,时舍也,为后一步做好万全的准备是这一章的任务。预处理是将代码完全展开的一步操作,会生成一个.i文件来带着hello走向后面的生命历程。其作用是为后面的编译进行服务。
(第2章0.5分)

第3章 编译
3.1 编译的概念与作用
概念:
1.编译是一种计算机行为,它会将用某种编程语言写成的源代码(原始语言),转换成另一种编程语言(目标语言)
2.具体到本例,就是讲.i文件转化为.s文件,把C语言转化为汇编语言
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i > hello.s

3.3 Hello的编译结果解析
3.3.0汇编指令:

3.3.1:数据:
a)源代码分析:
源代码中有以下
描述 定义 类型
休眠时间 int sleepsecs=2.5; 全局int
循环标志变量 int i; main()局部变量
printf()的输出 “Usage: Hello 学号 姓名!\n” 字符串
printf()输出 “Hello %s %s\n” 字符串
main()的参数 int argc 局部变量
main()的参数数组 char *argv[] 局部变量数组
b)通过.s分析
 sleepsecs:
在.s中定义了其全局变量的地位
更多的:

可知:编译器处理sleepsecs时在.data节声明该变量(.data节存放已经初始化的全局和静态C变量)。在第4行中,可以看到,编译器首先将sleepsecs在.text代码段中声明为全局变量,其次在.data段中,设置对齐方式为4、设置类型为对象、设置大小为4字节、设置为long类型其值为2(这里将2.5强转为了2, long与int效果相同故编译器通用了)。

 argc和i:
跟着汇编寻找i:

可知:i在36行时被赋值为0(从代码中赋值),存在栈里-4(%rbp)(印证了其是局部变量),同时我们也可以找到i在54行和52行分别有比较与自增的行为。
同样的方法,我们可以找到argc是在27行通过寄存器放入栈中-20(%rbp),故而argc也是一个局部变量。

 argv[]:
这是一个数组,但传入参数时只传入其地址28行,而其内容将保存在栈中,这一点在44,45取值时有所体现。

 字符串:

可以看到字符串定义.rodata只读数据段

3.3.2赋值
程序中涉及的赋值操作有:

a)int sleepsecs=2.5 :因为sleepsecs是全局变量,所以直接在.data节中将sleepsecs声明为值2的long类型数据。(已在3.3.1中详述)

b)i=0:整型数据的赋值使用mov指令完成,根据数据的大小不同使用不同后缀。
分别在36,52,54行有所体现
3.3.3 类型转换
程序中涉及类型转换的只有:
int sleepsecs=2.5
浮点数强转为整型将会舍去小数点后的部分。
由于浮点数默认为double故而能够精确转化(即便是float在这个数据大小情况下也可以精确转化)
3.3.4 算数操作
进行数据算数操作的汇编指令有:

本程序中涉及的算数操作有:

a)i++,对计数器i自增,使用的是addl指令,就可以完成这一任务
b)leaq .LC1(%rip),%rdi,使用了取地址指令leaq计算LC1的段地址%rip+.LC1并传递给%rdi。
3.3.5位操作:本程序中暂未涉及到未操作
3.3.5关系操作:
进行关系操作的汇编指令有

程序中涉及的关系运算为:

  1. argc!=3:
    判断argc不等于3。hello.s中使用cmpl $3,-20(%rbp),计算argc-3然后设置条件码,为下一步je利用条件码进行跳转作准备。
  2. i<10:
    判断i小于10。hello.s中使用cmpl $9,-4(%rbp),计算i-9然后设置条件码,为下一步jle利用条件码进行跳转做准备。
    3.3.7控制转移:
    程序中涉及的控制转移有:
  3. if (argv!=3):
    C语言常用的选择分支,这里是判定当argc不等于3时则执行后面的代码。
    如下指令

程序将跳转至L2继续执行。这里的cmpl作用只是比较两个数,并改变标志位ZF,实际上控制跳转的是je.即是当argv==3,时则执行下一条语句,如果argv!=3则跳转至L2.这样便完成了if语句的功能

  1. for(i=0;i<10;i++):
    C语言中常用的循环结构,设标志变量i,从0自增至9,每次循环+1,执行固定次数的循环体。
    如下指令

这段代码可以从52行开始读
首先无条件跳到L4然后就会有比较判断,接下来根据上一小条所讲,前十次会回到L3执行循环体,到i==10时54行的比较不会触发55行的跳转,于是执行56行循环结束,由此来实现for循环的功能。
3.3.8函数控制
程序中涉及的函数有

  1. main函数:
  1. 传递控制:main函数的调用是被被系统启动函数__libc_start_main使用call指令才开始运行的。这一点在C语言代码里没有体现,可以在反编译时发现。
  2. 传递数据:main函数有两个参数argc和argv[] 在汇编代码中容易见到,它们使用%rdi和%rsi传参(实时上,绝大多数情况,函数的第一个第二个参数都是通过这种方式传参的。)最后main函数的返回值固定为0,是将%eax赋值为0,然后结束达到传出返回值的结果。
  3. 分配和释放内存:像其它函数一样,main使用%rbp记录栈帧,函数的栈空间依此向下生长,程序结束时,调用leave指令,leave相当于这样的操作:mov %rbp,%rsp,pop %rbp,作用是恢复栈空间到调用之前的状态,然后ret返回,ret相当pop IP,将下一条要执行指令的地址设置为dest。
  1. printf函数:
  1. 传递数据:
    第一次printf出现在

这里由于只是输出一个单纯的字符串,所以将printf等价为了puts,并且只有一个参数,我们记在了.rodata段里,取出来交给%rd完成数据传递i
第二次printf出现在

39~45行都在取argv[1]和argv[2],通过寻到argv[0]然后按字节加地址来获取argv[1]和argv[2],而格式字符串则因为已经储存在.rodata段里面了,所以直接从里取出交给%rdi。最后传参完毕,调用printf
2) 控制传递:
第一次和第二次略有不同,第一次因为只是输出一个单纯的字符串,所以将printf等价为了puts。第二次则是调用printf。
2. exit函数:

  1. 传递数据:程序中只有一句
    movl $1, %edi
    即将%edi设置为1。
  2. 控制传递:程序中同样只有一句
    call exit@PLT。
  1. sleep函数:
  1. 传递数据:

将%edi设置为sleepsecs。
2) 控制传递:call sleep@PLT。
4. getchar函数:

  1. 控制传递:call gethcar@PLT
    3.4 本章小结
    终日乾乾,反复道也
    hello到这里已经正式地走上了它的生命历程,九三,上不在天,下不在田,故乾乾。这时一个中间的过程,但支撑着后面的九五在天的hello的根基。
    .i文件将程序掰开了放在编译器面前,编译器根据.i文件的内容解析整个程序,将其代码部分翻译成汇编语言,并将数据部分或嵌入汇编语言或独立声明至数据段,此时hello想干什么已经被解析知晓了。总得来说编译时为后续的汇编,将程序的数据与逻辑分类,并转化为更直观的方式呈递到汇编操作前。接着hello的生命将由.s文件来传递。
    (第3章2分)

第4章 汇编
4.1 汇编的概念与作用
概念:使用汇编语言编写的源代码,然后通过相应的汇编程序将它们转换成可执行的机器代码。这一过程被称为汇编过程。
作用:尽管汇编语言已经很接近机器的运作了,但是对于只会读取0/1的计算机来说还是太过于抽象,汇编的作用就是真正地让hello走进计算机,被完全读懂。
4.2 在Ubuntu下汇编的命令

上图操作包括了本章后续部分的指令
4.3 可重定位目标elf格式
我们可以使用readelf -a hello.o > helloo.elf 指令获得hello.o文件的ELF格式
ELF 头:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
类别: ELF64
数据: 2 补码,小端序 (little endian)
版本: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
类型: REL (可重定位文件)
系统架构: Advanced Micro Devices X86-64
版本: 0x1
入口点地址: 0x0
程序头起点: 0 (bytes into file)
Start of section headers: 1152 (bytes into file)
标志: 0x0
本头的大小: 64 (字节)
程序头大小: 0 (字节)
Number of program headers: 0
节头大小: 64 (字节)
节头数量: 13
字符串表索引节头: 12

节头:
[号] 名称 类型 地址 偏移量
大小 全体大小 旗标 链接 信息 对齐
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000081 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000340
00000000000000c0 0000000000000018 I 10 1 8
[ 3] .data PROGBITS 0000000000000000 000000c4
0000000000000004 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 000000c8
0000000000000000 0000000000000000 WA 0 0 1
[ 5] .rodata PROGBITS 0000000000000000 000000c8
000000000000002b 0000000000000000 A 0 0 1
[ 6] .comment PROGBITS 0000000000000000 000000f3
000000000000002b 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 0000011e
0000000000000000 0000000000000000 0 0 1
[ 8] .eh_frame PROGBITS 0000000000000000 00000120
0000000000000038 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 00000400
0000000000000018 0000000000000018 I 10 8 8
[10] .symtab SYMTAB 0000000000000000 00000158
0000000000000198 0000000000000018 11 9 8
[11] .strtab STRTAB 0000000000000000 000002f0
000000000000004d 0000000000000000 0 0 1
[12] .shstrtab STRTAB 0000000000000000 00000418
0000000000000061 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)

There are no section groups in this file.

本文件中没有程序头。

There is no dynamic section in this file.

重定位节 ‘.rela.text’ at offset 0x340 contains 8 entries:
偏移量 信息 类型 符号值 符号名称 + 加数
000000000018 000500000002 R_X86_64_PC32 0000000000000000 .rodata - 4
00000000001d 000c00000004 R_X86_64_PLT32 0000000000000000 puts - 4
000000000027 000d00000004 R_X86_64_PLT32 0000000000000000 exit - 4
000000000050 000500000002 R_X86_64_PC32 0000000000000000 .rodata + 1a
00000000005a 000e00000004 R_X86_64_PLT32 0000000000000000 printf - 4
000000000060 000900000002 R_X86_64_PC32 0000000000000000 sleepsecs - 4
000000000067 000f00000004 R_X86_64_PLT32 0000000000000000 sleep - 4
000000000076 001000000004 R_X86_64_PLT32 0000000000000000 getchar - 4

重定位节 ‘.rela.eh_frame’ at offset 0x400 contains 1 entry:
偏移量 信息 类型 符号值 符号名称 + 加数
000000000020 000200000002 R_X86_64_PC32 0000000000000000 .text + 0

The decoding of unwind sections for machine type Advanced Micro Devices X86-64 is not currently supported.

Symbol table ‘.symtab’ contains 17 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS hello.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5
6: 0000000000000000 0 SECTION LOCAL DEFAULT 7
7: 0000000000000000 0 SECTION LOCAL DEFAULT 8
8: 0000000000000000 0 SECTION LOCAL DEFAULT 6
9: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 sleepsecs
10: 0000000000000000 129 FUNC GLOBAL DEFAULT 1 main
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND GLOBAL_OFFSET_TABLE
12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts
13: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND exit
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
15: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sleep
16: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND getchar

No version information found in this file.

  1. 表头

这个版本非常贴心地给出了中文翻译里面记录的是文件的基本属性,为后面的内容进行一个阅读引导
2. 节头

节头相当于一个目录,告诉你各个节在哪里、大小、偏移量等等。
3. 重定位节

这是一个相当重要的部分,由于在文件在没有连接时,不能确定各个数据在哪个位置,只能通过重定位条目来相对寻址,上一节的汇编代码与最终可执行文件翻译出来的汇编代码是有差别的,最主要的就在于获取全局数据的方式,在本章中的重定位条目就是为了转呈两者之间的关系,使得程序获得可连接的属性,以方便分离开发。

我们知道重定位条目有如下属性(我们以65行为例):
r.offset = 0x18
r.symbol = .rodata
r.type = R_X86_64_PC32
r.addend = -4

这些东西的意思是我将会在0x18行进行一个32位的PC相对引用
具体引用方式如下:我们通过连接器获得.rodata的地址,在加上偏移量即
refaddr=ADDR(.text)+r.offset
这一个地址即是所引用的运行时地址。最后再更新引用,使得它运行时指向目标位置
*refptr=ADDR(.rodata)-4-refaddr
最后再执行这条指令时将PC入栈,然后PC = PC+*refptr,获得该条目的内容。

  1. 符号表

符号表算是一个宏定义,它存放着程序中定义和引用的函数和全局变量的信息,并且重定位条目也需要里面的声明。
4.4 Hello.o的结果解析
使用命令objdump -d -r hello.o 分析hello.o的反汇编:

汇编部分与上一对比分析:
a) 把各个小节合并了,取而代之的是确定的地址
b) 函数调用方式改变了,之前直接call函数名,现在有了确切的地址(虽然还是需要重定位条目辅助)
c) 全局变量调用有所不同,全局变量与函数一致,都是通过重定位条目重新确定位置,目前尚以0来代替,但也算是有一个确切地址,而非用名字来代替。
4.5 本章小结
或跃在渊,进无咎也
本章的处理已经让hello的编译生命进行到很靠后的位置了,即将进行链接,完成最后一步,进无咎也。
本章的汇编,实则是将hello从“人类的语言”翻译到机器语言,我们虽然还可以通过反汇编来查看分析它,但实际上hello现在已经由一串0/1承载了。这个时候hello的编码已经直接与机器级指令接轨了,相对于C语言的时候已经不那么抽象了。现在它已经完成了它自己的一切准备活动,与其他文件链接后即可生成最终的可执行文件。
(第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.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的格式
使用readelf -a hello > hello2.elf 命令生成hello程序的ELF格式文件(为了方便与之前对比,这里生成的elf与之前命名略有不同)
我们发现新的.elf比之前多了许多符号表和版本信息和重定位信息,同时表头那里也记录着一个是(可重定位文件)一个是(可执行文件),但主体结构基本一致。

5.4 hello的虚拟地址空间
使用edb打开hello,查看hello的data dump的内存

我们注意到在.elf中:

这说明在0x400000~0x401000段中,程序被载入,且虚拟地址从0x400000开始,0x400fff结束,这之间每个节(开始 ~ .eh_frame节)的排列即开始结束
由于篇幅原因就不在一一验证,下面再验证第二个:

对应着:

5.5 链接的重定位过程分析
使用objdump -d -r hello > hello.objdump 获得hello的反汇编代码。
这一次获得的代码明显比一开始多了许多,我们整理了一下有:

可以看到,多出来的部分都是为了主函数服务的,并且是在链接之后,由重定位条目从其他地方加入进来的。具体重定位条目重定位方法已在第四章中详解。
5.6 hello的执行流程
跟踪edb发现hello函数的执行顺序如下
-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
*hello!printf@plt
*hello!sleep@plt
*hello!getchar@plt
ld-2.27.so!_dl_runtime_resolve_xsave
-ld-2.27.so!_dl_fixup
ld-2.27.so!_dl_lookup_symbol_x
libc-2.27.so!exit

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

分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
5.8 本章小结
飞龙在天,大人造也
到这里hello真正地到达了最后形态,实为大人造也。
链接是生成可执行文件的最后一步,可以说走到这一步hello才真正的诞生,但也可以说这标志着hello孕育的结束。无论如何,hello已经被生成了,在这一章中hello本身并没有太大的改变,而是巧妙地糅合了其它的代码块进来,把它们结合成一个整体。
(第5章1分)

第6章 hello进程管理
6.1 进程的概念与作用
概念:进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
作用:进程为用户提供了以下假象:我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。这样,我们就有可能让计算机同时执行多个活动。
6.2 简述壳Shell-bash的作用与处理流程
概念:shell指“为用户提供用户界面”的软件,通常指的是命令行界面的解析器。一般来说,这个词是指操作系统中提供访问内核所提供之服务的程序。Shell也用于泛指所有为用户提供操作界面的程序,也就是程序和用户交互的接口。因此与之相对的是内核(英语:Kernel),内核不提供和用户的交互功能。
作用:提供了一个界面,用户通过这个界面访问操作系统内核的服务。
处理流程:
1)从终端读入输入的命令。
2)将输入字符串切分获得所有的参数
3)如果是内置命令则立即执行
4)否则调用相应的程序为其分配子进程并运行
##shell应该能时刻接受到键盘信号,并向进程发出信号

6.3 Hello的fork进程创建过程

  1. 首先shell-bash接收到“./hello 周玉 00000001”的输入
  2. bash通过比对,知道了“./hello 周玉 00000001”不是内置命令
  3. 于是就根据地址判断比对到hello,并且直到该命令需要执行该可执行文件
  4. 于是bash为其创建了一个仅与自己PID不同的进程,新的进程将执行“./hello 周玉 00000001”,而bash依然担负交互的任务,并且bash是“./hello 周玉 00000001”的父进程。

6.4 Hello的execve过程
execve函数在当前进程的上下问中加载并允许一个新程序——即hello
execve的函数原型如下
int execve(const char * filename,char * const argv[ ],char * const envp[ ]);
execve函数加载并运行可执行文件filename,且带参数列表argv和环境变量列表envp。只有在程序运行失败时,execve会有返回,否则其不会返回。
其参数组织结构如下

execve调用加载器,创建类似下面的内存映像

然后将环境与参数与之对应复制到内存里,如此便加载并运行了hello
6.5 Hello的进程执行
由于计算机不止需要处理hello,但又只有有限的CPU,而且不同进程间还会同时使用内存资源。故而hello并不是独享计算资源。而是通过不断保存读取上下文信息,在自己的时间片内处理自己的计算。
其具体过程如下:

  1. 此时hello正在正常执行
  2. 这时hello发生休眠或者时间片用完,这时内核将会决定把计算资源交给另外一个进程——进程B
  3. 此时磁盘开始为B复制信息至内存,而操作系统进入内核模式,交接hello和B的上下文。
  4. 当磁盘工作完成时,将会发送信号至内核,内核此时恢复了B上一次执行的状态,在B自己看起来就像自己连续执行一样。
  5. 随后B将会进行与hello相同的操作

对于内存资源,其操作方式与6.4节类似。
6.6 hello的异常与信号处理
我们对hello作如下实验:
a) 不进行任何操作,简单地运行完毕

我们发现程序并不能执行完,而是挂起等待键盘输入,此时程序将会停止。我们做如下实验:将hello.c改为:

这样会方便我们验证其是否被挂起,我们运行该程序:

当程序不活动时查看jobs获得其已经停止的状态,而后回到前台,输入,发现程序又被唤醒

最后,我们发现程序执行完毕,正常终止。
b) 在程序运行时输入任意字母:

我们发现程序正常执行完毕,并且中途输入的字母会保留在输入缓冲区里,等到getchar()时读走一位,剩余部分会在bash里面体现出来。
c) 在运行时输入Ctrl C:

由于程序本身会输出不少东西,这里看不太清,我梳理一下我的输入
./Hello 1170300919 wyh &
jobs
fg %5
^C
jobs
我们可以看出,在输入jobs后进程被终止并被内核回收了
这是因为其收到了来自bash的SIGINT信号。
d) 在运行时输入Ctrl Z

同样先梳理我的输入:
./Hello 1170300919 wyh &
jobs
fg %5
^Z
我们容易看出来,这一次进程收到了来自bash的SIGTSTP信号。故而停止了,我们可以通过向其发送SIGCONT使其恢复运行。
6.7本章小结
亢龙有悔,盈不可久
虽说hello成功地运行起来了,但在运行完之后将会被内核回收,从没有到没有,盈不可久
本章中Hello是真正地活动在了计算机上。它由bash向内核请求,由内核为其分配PID与计算资源,与此同时还有许多其他进程一同共享计算机,内核会协调它们的工作时间,为每个进程分配时间片。对于bash来说他还需要接受外界输入,通过异常处理并向其子进程们发送相应的信号。
(第6章1分)

第7章 hello的存储管理
7.1 hello的存储器地址空间
物理地址:物理地址(英语:physical address),也叫实地址(real address)、二进制地址(binary address),它是在地址总线上,以电子形式存在的,使得数据总线可以访问主存的某个特定存储单元的内存地址。
逻辑地址:在计算机体系结构中是指应用程序角度看到的内存单元(memory cell)、存储单元(storage element)、网络主机(network host)的地址。 逻辑地址往往不同于物理地址(physical address),通过地址翻译器(address translator)或映射函数可以把逻辑地址转化为物理地址。
线性地址:逻辑地址在分段机制之后被转换为线性地址,并且在描述符的组合分页机制中用作线性地址:offset。
7.2 Intel逻辑地址到线性地址的变换-段式管理
原始8086处理器的寄存器是16位。为了在不改变寄存器和指令的位宽的情况下访问更多的地址空间,引入了段寄存器。 8086设计了一个20位宽的地址总线,通过设置段移位4位加偏移地址来获得一个20位地址,即逻辑地址。存储器分为不同的段,段具有段寄存器,段寄存器具有堆栈,代码和两个数据寄存器。
分段功能分为保护模式和实模式。

实模式:8086 CPU有20根地址线,可直接寻址的物理地址空间为1M。尽管8086/8088内部的ALU每次最多进行16位运算,但存放存储单元地址偏移的指针寄存器都是16位的,所以8080/8086通过内存分段和使用段寄存器的方法来有效地实现寻址1M的空间。
存储单元的逻辑地址由段值和偏移两部分组成,用如下形式表示:
段值:偏移
所以根据逻辑地址可以方便地得到存储单元的物理地址,计算公式如下:
物理地址(20位) = 段值*16+偏移
段值通过段寄存器的值来取得,偏移可由指令指针的IP或其他可作为内存指针使用的寄存器给出。偏移还可以直接用16位数给出。
指令中不使用物理地址,而使用逻辑地址,由总线接口单元BIU按需要根据段值和偏移自动形成20位物理地址。

保护模式:最开始的程序寻址是直接的“段:偏移”模式,这样的好处是所见即所得,程序员指定的地址就是物理地址,物理地址对程序员是可见的。但是,由此也带来两个问题:1)无法支持多任务2)程序的安全性无法得到保证(用户程序可以改写系统空间或者其他用户的程序内容)。

实模式将整个物理内存看成分段的区域,程序代码和数据位于不同区域,系统程序和用户程序没有区别对待,而且每一个指针都是指向"实在"的物理地址。这样一来,用户程序的一个指针如果指向了系统程序区域或其他用户程序区域,并改变了值,那么对于这个被修改的系统程序或用户程序,其后果就很可能是灾难性的。为了克服这种低劣的内存管理方式,处理器厂商开发出保护模式。这样,物理内存地址不能直接被程序访问,程序内部的地址(虚拟地址)要由操作系统转化为物理地址去访问,程序对此一无所知。

在保护模式下,全部32条地址线有效,可寻址高达4G字节的物理地址空间;扩充的存储器分段管理机制和可选的存储器分页管理机制,不仅为存储器共享和保护提供了硬件支持,而且为实现虚拟存储器提供了硬件支持;支持多任务,能够快速地进行任务切换和保护任务环境;4个特权级和完善的特权检查机制,既能实现资源共享又能保证代码和数据的安全和保密及任务的隔离;支持虚拟8086方式,便于执行8086程序。

简单来说,实模式可以直接访问物理内存而保护模式需要通过虚拟地址间接访问。
7.3 Hello的线性地址到物理地址的变换-页式管理
为了让每个进程都享受到看似独立的空间,我们引入了页式管理:
其基本思想是:用户程序的地址空间被划分成若干固定大小的区域,称为“页”,相应地,内存空间分成若干个物理块,页和块的大小相等。可将用户程序的任一页放在内存的任一块中,实现了离散分配。
而每一块物理块将会被页表上的某一项对应:

其地址变换操作如下:

a) 处理器 生成一个虚拟地址,并把它传送给 MMU。
b) MMU 生成 PTE 地址,并从高速缓存/主存中请求这个 PTE 。
c) 高速缓存/主存向 MMU 返回 PTE。
d) MMU 构造物理地址,并把它传送给高速缓存/主存。
e) 高速缓存/主存返回所请求的数据字给处理器
以上是页命中的理想情况,如果页不命中:
a) 处理器 生成一个虚拟地址,并把它传送给 MMU。
b) MMU 生成 PTE 地址,并从高速缓存/主存中请求这个 PTE 。
c) 高速缓存/主存向 MMU 返回 PTE。
d) PTE 中的有效控制位为 0 ,所以 MMU 触发了一次异常,传递 CPU 中的控制到操作系统内核中的缺页异常处理程序。
e) 缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。
f) 缺页处理程序调入新的页面,并更新内存中的 PTE。缺页处理程序返回原来的进程,再次执行导致缺页的指令, CPU 将引起缺页的虚拟地址重新发送给 MMU ,因为虚拟页面现在存在主存中,所以会命中,主存将请求字返回给处理器。

7.4 TLB与四级页表支持下的VA到PA的变换
对于上面的寻址方式进行分析,我们发现页不命中的代价太高了,于是我们通过添加中间缓存的办法来缓解这一缺陷。
第 1 步 CPU 产生一个虚拟地址
第 2 和 3 步 MMU 从 TLB 中取出对应的 PTE 。
第 4 步 MMU 将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
第 5 步 高速缓存/主存将所请求的数据字返回 CPU。
如下图所示,当 TLB 不命中的时候, 多了步骤 3 和 4 ,MMU 必须从 L1 缓存中取出对应的 PTE , 新取出的 PTE 存放在 TLB 中,可能会覆盖一个已经存在的 PTE 。

7.5 三级Cache支持下的物理内存访问
Cache的寻址方式与也是一个缓存思想。
我们在上一步已经获得了物理地址,cache就是利用这个地址快速找到该地址存储的内容。
根据物理地址,匹配L1cache的组,然后寻找组里是否有对应的标志,如果有则命中,取出内容,如果没有对应的标志,则不命中,使用替换策略LRU LFU等来处理。
7.6 hello进程fork时的内存映射
shell通过调用函数fork()创建子进程,并且内核为新进程打开各种数据结构。并为其分配唯一的PID,新创建的子进程获得与父进程相同的虚拟存储空间中间有一个备份,包括只读段,可读写数据段,堆栈,用户堆栈等
其具体执行情况详见6.3

7.7 hello进程execve时的内存映射
由于计算机不止需要处理hello,但又只有有限的CPU,而且不同进程间还会同时使用内存资源。故而hello并不是独享计算资源。而是通过不断保存读取上下文信息,在自己的时间片内处理自己的计算。
其具体过程如下:
6. 此时hello正在正常执行
7. 这时hello发生休眠或者时间片用完,这时内核将会决定把计算资源交给另外一个进程——进程B
8. 此时磁盘开始为B复制信息至内存,而操作系统进入内核模式,交接hello和B的上下文。
9. 当磁盘工作完成时,将会发送信号至内核,内核此时恢复了B上一次执行的状态,在B自己看起来就像自己连续执行一样。
10. 随后B将会进行与hello相同的操作

对于内存资源,其操作方式与6.4节类似。

7.8 缺页故障与缺页中断处理
缺页故障是常见错误。当指令引用虚拟地址时,MMU查找页表并发现物理地址对应于该地址不在内存中,因此从磁盘中删除时必须发生故障处理流程。
缺页中断处理:缺页中断处理程序是系统内核中的代码。选择牺牲页面。如果已修改牺牲页面,则将其换出,交换新页面并更新页面表。返回时,CPU重新启动导致缺页中断的指令。此命令再次将VA发送到MMU,这次MMU可以正常翻译VA。
7.9动态存储分配管理
(以下格式自行编排,编辑时删除)
动态内存分配器管理着堆区。常见的操作系统的管理方式是通过显示红黑树来管理分配堆区内存。
显式与隐式:
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。

块的组织:
已分配的内存块:
体积 001
内容
体积 001
未分配的内存块:
体积 000
左孩子空闲快
右孩子空闲块
红或黑
内容
体积 000

分配方法:分配器将堆视为一组不同大小的块的集合来维护,每个块就是一个连续的虚拟内存片,要么是已分配的要么是空闲的。已分配的块显式地保留为供应用程序使用,空闲块可以用来分配。空闲块保持空闲直到它显式地被应用分配。一个已分配的块保持已分配状态,直到它被释放。
释放方法:修改空闲块的有效位,将其添加至红黑树中,然后平衡红黑树。
7.10本章小结
本章讲述了Hello调用内存资源的方式,并顺带讲了程序与内存交互的机制。同时以intel Core7在指定环境下介绍了VA到PA的变换、物理内存访问,还介绍了hello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)

第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
UNIX系统将所有的外部设备都看作一个文件来看待,所有打开的文件都通过文件描述符来引用。文件描述符是一个非负整数,它指向内核中的一个结构体。当打开一个现有文件或创建一个新文件时,内核向进程返回一个文件描述符。而对于一个socket的读写也会有相应的文件描述符,称为socketfd(socket描述符)。
8.2 简述Unix IO接口及其函数
Unix I/O接口统一操作:
a) 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
b) Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
c) 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
d) 读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
e) 关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去
Unix I/O函数:

a) int open(char* filename,int flags,mode_t mode) ,进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
b) int close(fd),fd是需要关闭的文件的描述符,close返回操作结果。
c) ssize_t read(int fd,void *buf,size_t n),read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
d) ssize_t wirte(int fd,const void *buf,size_t n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
(以下格式自行编排,编辑时删除)
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

我们首先查看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; 

}
易知:arg获得第二个不定长参数,即输出的时候格式化串对应的值。
我们接着查看vsprintf:
int vsprintf(charbuf,constcharfmt,va_listargs)
{
char*p;
chartmp[256];
va_listp_next_arg=args;

for(p=buf; *fmt; fmt++)

{
    if(*fmt!='%')//忽略无关字符
    {
        *p++=*fmt;
        continue;

    }

    fmt++;

    switch(*fmt)
    {
    case'x':

//只处理%x一种情况
itoa(tmp,((int)p_next_arg));//将输入参数值转化为字符串保存在tmp
strcpy(p,tmp);
//将tmp字符串复制到p处
p_next_arg+=4;//下一个参数值地址
p+=strlen(tmp);//放下一个参数值的地址
break;
case’s’:
break;
default:
break;

    }

}
return(p-buf);

//返回最后生成的字符串的长度
}
可以看出,vs返回的是最后需要输出字符串的长度并把字符串中该修改替换的地方改完,将其保存在buf里面。
接下来,printf将通过write来输出
write:

mov eax, _NR_write

mov ebx, [esp + 4]

mov ecx, [esp + 8]

int INT_VECTOR_SYS_CALL
可以看出,到这里,函数就已经长得很不C语言了,这里用汇编将对应的字符串的信息放入寄存器里
int INT_VECTOR_SYS_CALLA代表通过系统调用syscall
我们来看sys_call
sys_call:
call save

 push dword [p_proc_ready]

 sti

 push ecx
 push ebx
 call [sys_call_table + eax * 4]
 add esp, 4 * 3

 mov [esi + EAXREG - P_STACKBASE], eax

 cli

 ret

该函数将字符串中的字节复制到显存,然后字符显示驱动把ASCII码子字库里的点阵信息取出储存到vram里。然后显示芯片会读取vram,并通过信号总线向显示器传输每个像素点的RGB
最后Hello程序的输出就显示出来了
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
到这里对Hello的讲述基本就基本完成了。本章讲述了hello是如何把信息显示在人类面前的。也通过这个讲述了IO设备管理方法、Unix IO接口及其函数。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
hello的一生总结
生成:预处理、编译、汇编、链接
加载:fork、execve
执行:磁盘读取、虚拟内存映射、CPU执行指令、内核调度、缓存加载数据、信号处理、Unix I/O输入与输出
回收:内核处理
hello.c一开始是一个单纯的文本文件。通过预处理,变成.i文件,依然是一个文本文件,是将hello.c的内容进一步展开(替换宏定义,明确include的复制位点)。随后hello.i将被进一步编译成.s文件。直到此时hello.s还是文本文件,但已经被翻译成了汇编代码,并且有着一系列用于修饰的头部说明,声明了各个变量函数的存在方式与存在位置。接着,hello被汇编为.o文件,此时hello.o已经是一个二进制文件了,我们通过反汇编和readelf读取其信息,发现其已经被翻译成01序列的机器语言。最后连接器将hello.o和它依赖的库链接起来,形成可执行文件,反汇编这个文件,将会发现hello的所有函数也出现在了里面同时还有系统相关的启动函数。到这里hello已经变成了一个完整的函数。
接着hello通过shell被执行,由内核为其分配内存与计算资源。这时hello与其它的进程同时存在于操作系统上。当hello因为外界的信号或自己的结束时,内核将删除其内存,不再为其分配时间片。hello就此执行完毕。

不仅仅是通过本次大作业,在之前的各个实验中,我可以感受到各部分实现困难又精巧。每个部分都不断地贴近硬件,为其优化。同时在抽象方面为用户通过高效方便的接口。不得不为之感佩。
(结论0分,缺失 -1分,根据内容酌情加分)

附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名 描述
hello.i 对hello.c预处理结果
hello2.i 对hello.i预处理结果
hello.s 对hello.i编译结果
hello.elf hello.o的elf文件
hello.o 对hello.s的汇编结果
hello2.elf hello.o的elf文件
hello.objdump hello.o的反汇编文件
hello2.objdump hello的反汇编文件
hello 可执行文件
test.c 测试代码(测试信号和状态时所用)
a.out 测试代码的可执行文件

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

参考文献
为完成本次大作业你翻阅的书籍与网站等
[1]兰德尔 E. 布莱恩特. 深入理解计算机系统[M].龚奕利,译.北京: 机械工业出
版社, 2016: 22-88
[2] 维基百科https://zh.wikipedia.org/wiki/Wikipedia:%E9%A6%96%E9%A1%B5
(参考文献0分,缺失 -1分)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值