HIT2022春计算机系统大作业

计算机系统

大作业

题     目  程序人生-Hello’s P2P 

专       业          人工智能      

学     号        7203610116      

班     级          2036015       

学       生         陈九成          

指 导 教 师           史先俊         

计算机科学与技术学院

2022年5月

摘  要

本论文将根据计算机系统课程所学内容及计算机系统实验课程的实际操作,解析hello小程序的一生,对我们所学进行全面的梳理与回顾。我们主要在Ubuntu下进行相关操作,加深对计算机系统的了解。

关键词:hello;计算机系统;Linux;

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

目 录... - 2 -

第1章 概述... - 5 -

1.1.      Hello的简介... - 5 -

P2P.. - 5 -

020. - 5 -

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

1.3.      中间结果... - 5 -

1.4.      本章小结... - 6 -

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

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

预处理的概念... - 7 -

预处理的作用... - 7 -

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

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

2.4       本章小结... - 11 -

第3章 编译... - 12 -

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

编译的概念... - 12 -

编译的作用... - 12 -

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

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

3.3.1.数据... - 14 -

3.3.2.       算术运算... - 17 -

3.3.3关系运算与控制转移... - 19 -

3.3.4数组/指针/结构操作... - 19 -

3.3.5函数操作... - 20 -

3.4       本章小结... - 23 -

第4章 汇编... - 24 -

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

汇编的概念... - 24 -

汇编的作用... - 24 -

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

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

4.3.1生成elf文件命令... - 24 -

4.3.2 ELF头... - 25 -

4.3.3节头... - 26 -

4.3.4 重定位条目... - 26 -

4.3.5符号表... - 27 -

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

4.5 本章小结... - 30 -

第5章 链接... - 31 -

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

链接的概念... - 31 -

链接的作用... - 31 -

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

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

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

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

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

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

5.8 本章小结... - 38 -

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

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

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

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

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

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

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

6.7本章小结... - 43 -

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

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

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

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

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

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

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

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

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

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

7.10本章小结... - 47 -

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

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

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

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

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

8.5本章小结... - 50 -

结论... - 50 -

附件... - 52 -

参考文献... - 53 -

 1章 概述

    1. Hello的简介

根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。

P2P

P2P就是 From Program to Process,在Linux中,我们编写的代码程序,就先记为hello.c叭,就是P2P中的Program,而Process就是将源程序hello.c经过cpp的预处理、ccl的编译、as的汇编、ld的链接最终成为可执行目标程序hello,这一整套工序,统称为From Program to  Process,即P2P。最终在shell中启动程序,然后fork生成子进程开始运行。

020

再经过P2P过程之后,shell为我们编写生成的hello可执行程序进行系统调用,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。

    1. 环境与工具

列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。

  1. 硬件环境:

CPU: AMD Ryzen7 4800HS With Radeon Graphic

16GB RAM 512GB SSD 64位操作系统

  1. 软件环境:Window10、VMware、Ubuntu18.04
  2. 开发与调试环境:

Window下的VScode、VS2019

Linux下的gdb、CodeBlocks、gcc、edb、vim、as、ld

    1. 中间结果

列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。

  1. hello:经过系统编译、汇编、链接生成的可执行文件
  2. hello.c:我们(用户)编写的源程序
  3. hello.i:将源程序进行预处理后生产的文本文件
  4. hello.s: 将预处理后文件编译生成的汇编文件
  5. hello.o: 汇编之后可重定向的目标文件
  6. hello.out: 跟hello一样都是hello.c的可执行程序
  7. elf.txt: hello.o的elf格式文件
  8. dis_hello.s: hello.o的反汇编代码内容
  9. hello.elf: hello的elf格式内容
  10. hello_objdump.s: hello的反汇编代码

    1. 本章小结

本章节对hell.c文件进行了一个总体的概括,这其实也适用于所有C语言程序乃至大部分高级语言程序。介绍了P2P、020过程及作用,说明了此报告在撰写时使用的硬件环境、软件环境与开发和调试工具,最后列举了从编写的.c文件到可执行文件过程中生成的中间文件。

(第1章0.5分)

第2章 预处理

    1. 预处理的概念与作用

预处理的概念,这就需要求助无所不知的百度 Google Bing了

预处理的概念

预处理为编译器做的预备工作阶段,在编译器处理程序之前预扫描源代码,完成头文件的包含,扩展,条件编译, 行控制(line control)等操作。预处理是文件在编译过程中一个单独的步骤。简言之,预处理进行的是一个文本替换操作,它们会指示编译器在实际编译之前完成所需的预处理。同时我们把 C 预处理器(C Preprocessor)简写为 CPP

预处理的作用

  1. 将源程序文件中的宏定义部分,比如define进行删除并用实际值进行替换
  2. 处理源程序中的库文件引用,当源程序带有#include时,预处理时会把包含的库文件插入到程序文件中
  3. 删除所有的注释,如:用//及/*包括的语句
  4. 添加行号和文件名标识,以便编译时编译器产生调试用的行号信息,保留所有#pragma编译指令(编译器需要用)
  5. 处理所有条件编译,比如ifdef和endif等
    1. 在Ubuntu下预处理的命令

根据第一章所学内容,预处理命令为:

cpp hello.c > hello.i

图 1 预处理命令及结果展示

    1. Hello的预处理结果解析

图2 hello.i文件部分代码

可以看出,经过预处理hello.c文件已经变为3105行内容,而其中绝大部分内容为插入的本地库函数和库文件, hello.c文件的内容从预处理文件的3092行开始一直到最后.由于hello.c文件没有define ifdef等指令,所以前面的内容是对于#include引用的库文件进行插入,以hello.c文件为例:

图 3 hello.i部分代码

hello.c 文件开头引用的库文件包括<stdio.h>  <unistd.h>  <stdlib.h>,首先Linux对hello.c 引用的库文件stdio.h进行寻找,找到如下路径

/usr/include/stdio.h

进入到这个路径内找到源文件后打开,stdio.h文件内容如下图所示

图4 stdio.h部分代码

可以发现,stdio.h文件中含有很多#define #ifdef #endif等定义,此时预处理也会对stdio.h文件中的宏定义进行处理,这也是为什么最终的hello.i文件是没有#denfine等内容.同时cpp会对stdio中的ifdef等进行判断是否执行,将执行部分保留,不执行部分直接删除,剩下的部分再与经过同样处理后的unistd.h  stdlib.h文件部分进行连接排序最后就得到了我们预处理得到的 hello.i文件.

图5 hello.i中的声明函数部分

    1. 本章小结

本章节主要介绍了预处理的概念及功能,包括头文件的展开、宏替换、去掉注释、条件编译等,以及Linxu下预处理的指令,同时以hello.c文件为例,查看预处理结果hello.i文本文件,并对其进行解析,详细了解了预处理的过程与功能。

(第2章0.5分)

第3章 编译

    1. 编译的概念与作用

编译的概念

一般说的编译是说将某种编程语言写成的源代码(原始语言)转换成另一种编程语言(目标语言)。

在这里指的是把高级语言文本程序翻译成等价的汇编语言文本程序:编译器ccl把预处理后的文本文件.i文件进行一系列语法分析及优化后生成相应的汇编语言文件.s文件的过程。其中.s文件中包含一个汇编语言程序。

编译的作用

  1. 语法分析:编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位
  2. 词法分析:词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序。执行词法分析的程序称为词法分析程序或扫描器。
  3. 语义检查和中间代码生成:源程序的一种内部表示,或称中间语言。中间代码的作用是可使编译程序的结构在逻辑上更为简单明确,特别是可使目标代码的优化比较容易实现中间代码。
  4. 源代码优化:指对程序进行多种等价变换,使得从变换后的程序出发,能生成更有效的目标代码。
  5. 目标代码生成:生成是编译的最后一个阶段。目标代码生成器把语法分析后或优化后的中间代码变换成目标代码。此处指汇编语言代码,须经过汇编程序汇编后,成为可执行的机器语言代码
    1. 在Ubuntu下编译的命令

编译的命令为,如下图所示

gcc -S hello.i -o hello.s

图6 编译命令及结果展示

    1. Hello的编译结果解析

首先看hello.s中的内容都是什么,如下图所示

图7 hello.s部分代码

首先解析每行命令开头,主要分为一下几部分

  1. .file: 源文件名
  2. .text: 代码部分
  3. .data: 数据部分
  4. .global: 全局变量
  5. .align: 对齐方式
  6. .type 指定是对象类型或是函数类型
  7. .size 大小
  8. .long 长整型
  9. .section  .rodata下面是.rodata

3.3.1.数据

  1. 常量:

常量就是代码中的确定数值,比如hello.c文件里的4、8,这些都是人为给定的数值

图8 hello.c中的常量

而在hello.s中,这部分数据会放置在代码段,跟指令结合在一起

图9 hello.s中的常量

可以看出.c文件中的4就存储在代码段中,跟条件判断语句结合在一起。

而对于printf函数中的常量,即

图10  hello.c 中 printf scanf函数中的字符串常量

函数当中的字符串常量将存储在LC0和LC1中

.

图11 hello.s中的字符串常量所在位置

  1. 变量
  1. 全局变量global

初始化的全局变量储存在.data节,它的初始化不需要汇编语句,而是直接完成的。但是我们的hello.c没有全局变量,所以hello.s文件也就没有相应区域

  1. 局部变量

局部变量通常会被分配到栈中(隶属于各自的进程,生命周期结束后会在栈上被释放),它没有标识符,也不需要被声明,而是直接使用。。比如hello.c中的int i;就是一个局部变量的定义,他在.s文件中的相应位置如下

图12 hello.s中局部变量int i的位置

此外,hello.c 还有局部变量int argc[],这是hello.c运行时传入的第一个参数,他会存储在寄存器edi中,表述输入的参数个数,而后利用mov语句赋值到-20(%rbp)中

图13 hello.s中int argc[]的部分

  1. 指针数组变量

在hello.c中的 char *argv[],根据数组在汇编中的位置,我们知道数组的首地址会存储在寄存器当中,根据数组下标来索引所需数组的位置。由下图可知,argv数组首地址存放在寄存器(%rbp-32)中,通过改变偏移量大小来获取argv的各个数组值,比如首地址+8就得到argv[1]的地址。同理,+16得到了argv[2]的地址。

图14 hello.s中指针型数组位置

      1. 算术运算
    1. 赋值

比如在hello.c中对i = 0的赋值

图15 hello.s中赋值操作

可以发现,对于局部变量的赋值,是通过mov语句实现的

  1. 算术运算

在hello.c中的循环操作中,有i++的操作,这在hello.s文件中是通过add语句实现的

图16 hello.s中的i++操作汇编实现

除此之外,汇编中还有其他的算术操作,比如在进行数组的索引时,也会进行算术运算,如3.3.1所言,数组是通过改变偏移量来寻址的,即通过argv[i] = argv[0]+8*i来得到argv[i]的地址。

图17 hello.s中数组寻址的算术操作

3.3.3关系运算与控制转移

图18 hello.c中的关系运算

由上图可知,hello.c中的关系运算 “!=”,在hello.s中的汇编语言中,会以cmpl等形式出现

图19 hello.s中的关系运算

此外,hello.c中在循环运算中也有关系判断,如下图所示

图20 hello.c中循环部分的关系运算

而在汇编语言中,也会以cmpl的形式出现,如下图所示

图21 hello.s中循环部分的关系运算

其中cmpl判断后面的jxx语句用来根据判断结果来决定跳转的位置,也就是控制转移。语句jxx中的xx就是判断条件,根据上条指令的结果和标志运算符的结果来决定是否跳转

3.3.4数组/指针/结构操作

hello.c中有个指针数组char *argv[],作为main函数的一个输入参数, argv[0]表示输入程序的路径和名称,argv[1]和argv[2]分别表示两个字符串。

图22 hello.c中的指针数组

char *的数据类型占用8个字节,所以在进行数组操作时,每个数组地址偏移量差8

图23 hello.s中的指针数组argv[]

可以看出argv的首地址,即argv[0]先是存储在%rsi中,再通过偏移量+8  +16来获得argv[1]和argv[2]

图24 hello.s中argv[1]和argv[2]

3.3.5函数操作

经过本学期的学习,我们知道程序中的函数调用一般通过跳转到特定代码执行待定函数之后再返回来实现的,在计算机系统当中函数主要是在堆栈中实现的。函数操作主要分为以下几部分:

  1. 传递参数给被调用者

在x86 64位的操作系统下,由于寄存器比较充足,因此函数传递参数通常使用寄存器来完成该功能的。而当寄存器不足的时候(或者参数太多),系统可以像32位下一样借用堆栈来传递参数。先按照:%rdi,%rsi,%rdx,%rcx,%r8,%r9的顺序传递参数,从第七个参数开始放在调用者栈结构中。

  1. call指令调用函数

call指令会将返回地址压入栈中,并且将rip的值指向所调用函数的地址,等函数执行完之后调用ret弹出原来的rip并且将栈帧结构恢复

  1. 函数进行操作

函数在自己栈帧内进行操作,返回值存入RAX寄存器。

  1. 函数返回

函数返回时,如果有返回值,一般是返回寄存器%rax的值,即将返回值存在%rax中,再用leave和ret等操作返回,控制权还给调用函数

下面我们深入到hello.c文件中,看一看hello.c文件的函数调用,根据hello.c的文件内容,可以发现,hello.c文件调用的函数有

main() printf() exit() sleep() atoi() getchar() 这六个,下面我们逐个分析这些函数

  1. main()函数

参数传递:传入参数为int argc[]和char *argv[],分别用寄存器%rdi和%rsi存储

函数调用:作为程序的主函数,在系统启动时就会被调用

函数操作:作为主函数, main函数使用栈指针,同时使用栈帧%rbp来记录使用情况。

函数返回:根据源程序的return 0相对应的汇编为设置%eax为0并且返回,对应return 0 

图25 main函数操作的汇编

由图可知,函数传递的参数argc和argv分别存储在%edi和%rsi中,最终%eax赋值为0函数退出

  图26 main函数的函数返回

  1. printf()函数

hello.c中引用了两个printf,在如下图位置:

图27 hello.c中的printf

参数传递:call puts时只传入了字符串参数首地址;for循环中call printf时传入了argv[1]和argc[2]的地址。

函数调用:if判断满足条件后调用,与for循环中被调用。

图28 hello.s中的printf调用

函数返回:由上图可以看出,printf的函数返回应该是在printf函数中完成的。

  1. exit()函数

参数传递:由下图及源函数,exit的函数传递参数为1,汇编中将%edi寄存器赋值为1,进行传递

函数调用:由if条件判断后,如果不跳转,则由call进行调用exit函数

函数返回:同样在exit函数中返回

图29 hello.s中exit函数调用

  1. sleep()函数

参数传递:源程序中,sleep函数传递的参数为atoi函数的返回值,即atoi(argv[3]),在汇编之中,atoi的返回值存储在%eax寄存器当中,再通过mov指令赋值到edi寄存器当中实现sleep函数参数的传递

函数调用: 通过call exit@PLT函数,进行函数调用

函数返回:在sleep函数中返回

图30 sleep的函数调用

  1. atoi()函数

参数传递:汇编中,将argv[3]的值通过寄存器%rdi来传递给atoi函数

函数调用: 通过call atoi@PLT函数,进行函数调用。

函数返回:在atoi函数中返回

图31 atoi的函数调用

  1. getchar()函数

参数传递:getchar()是个无参数传递的函数

函数调用: 通过call getchar@PLT调用getchar;

函数返回:在getchar中返回

图32 getchar函数的调用

    1. 本章小结

本章主要介绍了编译的概念以及过程。同时通过示例程序hello.c以及hello.s表现了c语言转换成为汇编的形式。介绍了汇编代码如何实现各种类型,包括变量、常量、函数调用、分支和循环控制、算术运算与关系判断。编译所做的工作,就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码表示。包括之前对编译的结果进行解析,都令我更深刻地理解了C语言的数据与操作,对C语言翻译成汇编语言有了更好的掌握,也相当于掌握了一定的汇编语言。

(第32分)

第4章 汇编

4.1 汇编的概念与作用

汇编的概念

汇编器(as)将.s汇编程序翻译成机器语言,把这些机器语言指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中,这个过程就叫做汇编.

汇编的作用

将编译之后的汇编语言汇编为机器语言,机器语言才是机器真正能直接识别并执行程序的语言,所以生成的.o文件是二进制文件,包含着程序包含的指令代码

4.2 在Ubuntu下汇编的命令

在Linux下的汇编指令为

as hello.s -o hello.o

图33 汇编指令及结果如图

4.3 可重定位目标elf格式

4.3.1生成elf文件命令

命令为:

readelf -a hello.o > ./elf.txt

图34 elf文件生成命令

4.3.2 ELF

ELF头将提供Magic,类别,数据,版本,OS/ABI,ABI,ELF头的大小、目标文件的类型、机器类型、字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息.ELF头的具体内容如下图所示:

图35 elf头

4.3.3节头

节头部表描述了在hello.o文件中不同节的位置和大小,其中目标文件中每个节都有一个固定大小的条目。具体的描述包括节的名称、类型、地址和偏移量等,如下图所示:

图 36 elf节头内容

4.3.4 重定位条目

当汇编器生成一个目标模块时,程序并不知道数据和代码最终将放在内存中的什么位置,它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。于是它就会生成一个重定位条目,用于告诉链接器在将目标文件合并成可执行目标文件时如何修改这个引用。在链接时,重定位节也会对这些位置的地址进行修改。链接器会通过重定位条目的类型判断该使用什么养的方法计算正确的地址值,通过偏移量等信息计算出正确的地址。其中代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。重定位部分具体代码如下图所示:

图37 elf的重定位部分

由上图可以看出,给的例程hello.c中需要重定位的信息有:.rodata中的模式串,puts,exit,printf,slepsecs,sleep,getchar。

4.3.5符号表

符号表,用于存放在程序中定义和引用的函数和全局变量的信息。具体内容如下图所示:

图37 elf符号表内容

4.4 Hello.o的结果解析

要想知道hello.o的结果,首先要进行反汇编查看,命令为:

objdump -d -r hello.o > dis_hello.s

这样就可以查看生成的dis_hello.s反汇编文件来进行解析,dis_hello.s的具体内容如下:

图38 dis_hello.s代码

下面将hello.o反汇编生成的dis_hello.s与第三章编译之后生成的hello.s文件进行对比,有如下不同:

  1. 操作数不同

在hello.s中数进制都是10进制而反汇编生成的dis_hello.s都是16进制,数值均是0x开头

图39 反汇编的操作数

  1. 分支跳转控制不同

在hello.s中,跳转为L2 L4 区段,包括整个hello.s文件都分为了LC0 LC1等部分

而在反汇编生成的dis_hello.s中,则用相应的地址代替

图40 dis_hello.s中的分支跳转控制

  1. 函数调用不同:

hello.s中,call指令使用的是函数名称,而反汇编代码中call指令使用的是main函数的相对偏移地址。因为函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。

图41 dis_hello.s中的函数调用

通过比较可以发现,反汇编生成的汇编语言更贴合Linux的机器语言

4.5 本章小结

本章对汇编结果进行了详尽的介绍。经过汇编器的操作,汇编语言转化为机器语言,hello.o可重定位目标文件的生成为后面的链接做了准备。通过对比hello.s和hello.o反汇编代码生成的文件dis_hello.s的区别,使得我们对该内容有了更加深入地理解,更深刻地理解了汇编语言到机器语言实现地转变,和这过程中为链接做出的准备,对可重定位目标elf格式进行了详细的分析,侧重点在重定位项目上

(第41分)

5章 链接

5.1 链接的概念与作用

链接的概念

链接是将各种不同文件的代码和数据部分收集(符号解析和重定位)起来并组合成一个单一文件的过程。

链接的作用

当程序调用函数库(如标准C库)中的一个函数,这个函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个函数必须通过链接器(ld)将这个文件合并到hello.o程序中,结果得到hello文件,它是一个可执行目标文件,可以被加载到内存中,由系统执行。链接器的存在让分离编译成为可能,节省了大量工作空间

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

图42 链接的命令

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

命令:readelf -a hello > hello.elf,此命令输入后可得到hello.elf文件

根据hello.elf的内容,可以看出

ELF头为:

图43 hello.elf的ELF头内容

节头:

图44 hello.elf中的节头内容

符号表

图45 符号表的内容

5.4 hello的虚拟地址空间

使用edb加载hello,查看Data Dump窗口可以看到hello程序的虚拟地址空间各段信息。  

图46 用edb加载hello查看虚拟内存

可以看出程序的虚拟内存地址从0x400000 – 0x401000

5.5 链接的重定位过程分析

命令: objdump -d -r hello > hello_objdump.s

图47 objdump内容

分析与hello.o的不同:

1.链接增加了新函数:

在hello中链接加入了在hello.c中用到的库函数,如exit、printf、sleep、getchar等函数。

2.增加了节:

hello中增加了.init和.plt节,和一些节中定义的函数。

3.函数调用:

hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。

4.地址访问:

hello.o中的相对偏移地址变成了hello中的虚拟内存地址。而hello.o文件中对于某些地址的定位是不明确的,其地址也是在运行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全部置为0,并且添加重定位条目。

链接的过程:

链接就是链接器(ld)将各个目标文件(各种.o文件)组装在一起,文件中的各个函数段按照一定规则累积在一起。

5.6 hello的执行流程

使用edb执行hello,从加载hello到_start,到call main,以及程序终止的主要过程如下:

  1. ld-2.27.so!_dl_start   7efb ff4d8ea0
  2. ld-27.so!_dl_init    7efb ff4e7630
  3. hello!_start  400500
  4. libc-2.27.so!__libc_start_main      7efb ff100ab0
  5. hello!puts@plt   4004b0
  6. hello!exit@plt    4004e0

5.7 Hello的动态链接分析

分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的 内容变化。要截图标识说明。

在elf文件中可以找到:

进入edb查看地址,可以发现

对于变量而言,我们利用代码段和数据段的相对位置不变的原则计算正确地址。对于库函数而言,需要plt、got合作,plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。plt就能跳转到正确的区域。

5.8 本章小结

本章结合实验中的hello可执行程序依此介绍了链接的概念及作用,在Ubuntu下链接的命令行;并对hello的elf格式进行了详细的分析对比,同时注意到了hello的虚拟地址空间知识;并通过反汇编hello文件,将其与hello.o反汇编文件对比,详细了解了重定位过程;遍历了整个hello的执行过程,在最后对hello进行了动态链接分析。

(第51分)

6章 hello进程管理

6.1 进程的概念与作用

概念

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

作用

进程提供给应用程序两个关键抽象:

1、逻辑控制流

每个程序似乎独占地使用CPU,通过OS内核的上下文切换机制提供

2、私有地址空间

每个程序似乎独占地使用内存系统,OS内核的虚拟内存机制提供

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

Shell是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。不仅如此,Shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。Shell编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等。

Shell处理流程:

1.打印提示信息

2.等待用户输入

3.接受命令

4.解释命令

5.找到该命令,执行命令,如果命令含有参数,输入的命令解释它

6.执行完成,返回第一步

6.3 Hello的fork进程创建过程

shell先判断出不是内置命令,于是加载可执行文件hello,通过fork创建一个子进程,子进程得到与父进程用户级虚拟地址空间相同的一份副本。子进程还获得与父进程任何打开文件描述符相同的副本。子进程与父进程有不同的pid。fork被调用一次,返回两次。在父进程中fork返回子进程的pid,在子进程中fork返回0.父进程与子进程是并发运行的独立进程。

6.4 Hello的execve过程

在shell创建的子进程中将会调用execve函数,来调用加载器,加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加载。每个程序都有一个运行时内存映像,如图6.2所示。当加载器运行时,它创建类似图6.2所示的内存映像。在程序头部表的引导下,加载器将可执行文件的片(chunk)复制到代码段和数据段。接下来,加载器跳转到程序的入口点,也就是_start函数的地址。这个函数是在系统目标文件ctrl.o中定义的,对所哟额C程序都是一样的。_start函数调用系统启动函数_ _libc_start_main,该函数定义在libc.so中,它初始化执行环境,调用用户层的main函数,处理main函数的返回值,并且在需要的时候把控制返回给内核。

结合虚拟内存和内存映射过程,可以更详细地说明exceve函数实际上是如何加载和执行程序Hello:

  1. 删除已存在的用户区域(自父进程独立)。
  2. 映射私有区:为Hello的代码、数据、.bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的。
  3. 映射共享区:比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。
  4. 设置PC:exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。

6.5 Hello的进程执行

结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。

逻辑控制流:

一系列程序计数器 PC 的值的序列叫做逻辑控制流。由于进程是轮流使用处理器的,同一个处理器每个进程执行它的流的一部分后被抢占,然后轮到其他进程。

用户模式和内核模式:

处理器使用一个寄存器提供两种模式的区分。用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据;内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。

上下文:

上下文就是内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。

示例:sleep进程的调度过程

图 14 进程上下文切换

初始时,控制流再hello内,处于用户模式

调用系统函数sleep后,进入内核态,此时间片停止。

2s后,发送中断信号,转回用户模式,继续执行指令。

调度的过程:

在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。

以执行sleep函数为例,sleep函数请求调用休眠进程,sleep将内核抢占,进入倒计时,当倒计时结束后,hello程序重新抢占内核,继续执行。

用户态与核心态转换:

为了能让处理器安全运行,不至于损坏操作系统,必然需要先知应用程序可执行指令所能访问的地址空间范围。因此,就存在了用户态与核心态的划分,核心态可以说是“创世模式”,拥有最高的访问权限,处理器以一个寄存器当做模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,保证了系统的安全性。

6.6 hello的异常与信号处理

程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps  jobs  pstree  fg  kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。

正常运行状态:

回车:可以发现,进程被回收了

Ctrl+Z停止前台作业

Ctrl+C:程序终止

乱敲键盘:乱按的内容保存在缓冲区,按下回车会在程序停止后执行这些内容,不按回车则不执行

总结起来,异常情况大致分为以下几种:

类别

原因

异步/同步

返回行为

中断

来自I/O设备的信号

异步

总是返回到下一条指令

陷阱

有意的异常

同步

总是返回到下一条指令

故障

潜在可恢复的错误

同步

可能返回到当前指令

终止

不可恢复的错误

同步

不会返回

6.7本章小结

异常控制流发生在计算机系统的各个层次,是计算机系统中提供并发的基本机制。

1)在硬件层,异常是由处理器中的事件触发的控制流中的突变

2)在操作系统层,内核用ECF提供进程的基本概念。

3)在操作系统和应用程序之间的接口处,应用程序可以创建子进,等待他们的子进程停止或者终止,运行新的程序,以及捕获来自其他进程的信号。

4)最后在应用层,C程序可以使用非本地跳转来规避正常的调用/返回栈规则,并且直接从一个函数分支到另一个函数。

同时还有四种不同类型的异常:中断,故障,终止和陷阱。

(第61分)

7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址

逻辑地址(Logical Address)是指由程序hello产生的与段相关的偏移地址部分(hello.o)。

线性地址

线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。程序hello的代码会产生逻辑地址,或者说是(即hello程序)段中的偏移地址,它加上相应段的基地址就生成了一个线性地址。

虚拟地址

有时我们也把逻辑地址称为虚拟地址。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的,是hello中的虚拟地址。

物理地址

物理地址(Physical Address)是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么hello的线性地址会使用页目录和页表中的项变换成hello的物理地址;如果没有启用分页机制,那么hello的线性地址就直接成为物理地址了。

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

一个逻辑地址由两部分组成,段标识符,段内偏移量。段标识符是一个16位长的字段组成,称为段选择符,其中前13位是一个索引号。后面三位包含一些硬件细节。
    索引号,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
    这里面,我们只用关心Base字段,它描述了一个段的开始位置的线性地址。
    全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。
    GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。
   给定一个完整的逻辑地址段选择符+段内偏移地址,
   看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
   拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。

把Base + offset,就是要转换的线性地址了

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

页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。

页表结构:在物理内存中存放着一个叫做页表的数据结构,页表将虚拟页映射到物理页,每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。

页表就是一个页表条目(PTE)数组,虚拟地址空间中的每个页在页表中的一个固定偏移量处都有一个PTE。PTE是由一个有效位和一个n个字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置。

MMU利用虚拟页号(VPN)来在虚拟页表中选择合适的PTE,当找到合适的PTE之后,PTE中的物理页号(PPN)和虚拟页偏移量(VPO)就会组合形成物理地址。其中VPO与PPO相同,因为虚拟页大小和物理页大小相同,所需要的偏移量位数也就相同。此时,物理地址就通过物理页号先找到对应的物理页,然后再根据物理页偏移找到具体的字节。

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

在酷睿i7中,36位的虚拟地址被分割成4个9位的片。CR3寄存器包含L1页表的物理地址。VPN1有一个到L1 PTE的偏移量,找到这个PTE以后又会包含到L2页表的基础地址;VPN2包含一个到L2PTE的偏移量,找到这个PTE以后又会包含到L3页表的基础地址;VPN3包含一个到L3PTE的偏移量,找到这个PTE以后又会包含到L4页表的基础地址;VPN4包含一个到L4PTE的偏移量,找到这个PTE以后就是相应的PPN。

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

CPU发送一条虚拟地址,随后MMU按照上述操作获得了物理地址PA。根据cache大小组数的要求,将PA分为CT(标记位)CS(组号),CO(偏移量)。根据CS寻找到正确的组,比较每一个cacheline是否标记位有效以及CT是否相等。如果命中就直接返回想要的数据,如果不命中,就依次去L2,L3,主存判断是否命中,当命中时,将数据传给CPU同时更新各级cache的cacheline(如果cache已满则要采用换入换出策略)。

7.6 hello进程fork时的内存映射

当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,同时为这个新进程创建虚拟内存。

它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。

7.7 hello进程execve时的内存映射

exceve函数加载和执行程序Hello,需要以下几个步骤:

1.删除已存在的用户区域。

2.映射私有区域。为Hello的代码、数据、bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时复制的。

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

4.设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中。

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

在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页(page fault) 。CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4。如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。

缺页处理程序从磁盘上用VP3的副本取代VP4,在缺页处理程序重新启动导致缺页的指令之后,该指令将从内存中正常地读取字,而不会再产生异常。

7.9动态存储分配管理

动态储存分配管理使用动态内存分配器来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。动态内存分配主要有两种基本方法与策略:

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

带边界标记的隐式空闲链表的每个块是由一个字的头部、有效载荷、可能的额外填充以及一个字的尾部组成的。

隐式空闲链表:在隐式空闲链表中,因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。其中,一个设置了已分配的位而大小为零的终止头部将作为特殊标记的结束块。

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

显示空间链表管理

显式空闲链表是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。如,堆可以组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。

显式空闲链表:在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。

7.10本章小结

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

(第7 2分)

8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化

文件(所有的I/O设备都被模型化为文件,甚至内核也被映射为文件)

设备管理

unix io接口

这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。

我们可以对文件的操作有:打开关闭操作open和close;读写操作read和write;改变当前文件位置lseek等

8.2 简述Unix IO接口及其函数

Unix IO接口:

打开文件:内核返回一个非负整数的文件描述符,通过对此文件描述符对文件进行所有操作。

Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。

改变当前的文件位置,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。

读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k

关闭文件:当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中

Unix IO函数:

1. open()函数

功能描述:用于打开或创建文件,在打开或创建文件时可以指定文件的属性及用户的权限等各种参数。

函数原型:int open(const char *pathname,int flags,int perms)

参数:pathname:被打开的文件名(可包括路径名如"dev/ttyS0")flags:文件打开方式,

返回值:成功:返回文件描述符;失败:返回-1

2. close()函数

功能描述:用于关闭一个被打开的的文件

所需头文件: #include <unistd.h>

函数原型:int close(int fd)

参数:fd文件描述符

函数返回值:0成功,-1出错

3. read()函数

功能描述: 从文件读取数据。

所需头文件: #include <unistd.h>

函数原型:ssize_t read(int fd, void *buf, size_t count);

参数:fd:将要读取数据的文件描述词。buf:指缓冲区,即读取的数据会被放到这个缓冲区中去。count: 表示调用一次read操作,应该读多少数量的字符。

返回值:返回所读取的字节数;0(读到EOF);-1(出错)。

4. write()函数

功能描述: 向文件写入数据。

所需头文件: #include <unistd.h>

函数原型:ssize_t write(int fd, void *buf, size_t count);

返回值:写入文件的字节数(成功);-1(出错)

5. lseek()函数

功能描述: 用于在指定的文件描述符中将将文件指针定位到相应位置。

所需头文件:#include <unistd.h>,#include <sys/types.h>

函数原型:off_t lseek(int fd, off_t offset,int whence);

参数:fd;文件描述符。offset:偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负(向前移,向后移)

返回值:成功:返回当前位移;失败:返回-1

8.3 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;

}

调用了两个函数vsprintf,write。

vsprintf函数的作用是将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。

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

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

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

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

8.4 getchar的实现分析

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

当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。

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

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

8.5本章小结

本章介绍了 Linux 的 I/O 设备的基本概念和管理方法,以及Unix I/O 接口及其函数。最后分析了printf 函数和 getchar 函数的工作过程。

(第81分)

结论

Hello的一生大致分为以下几步:

    1. hello.c经过预编译,拓展得到hello.i文本文件
    2. hello.i经过编译,得到汇编代码hello.s汇编文件
    3. hello.s经过汇编,得到二进制可重定位目标文件hello.o
    4. hello.o经过链接,生成了可执行文件hello
    5. bash进程调用fork函数,生成子进程;并由execve函数加载运行当前进程的上下文中加载并运行新程序hello
    6. hello的变化过程中,会有各种地址,但最终我们真正期待的是PA物理地址。
    7. hello再运行时会调用一些函数,比如printf函数,这些函数与linux I/O的设备模拟化密切相关
    8. hello最终被shell父进程回收,内核会收回为其创建的所有信息

计算机系统这门课程介绍了计算机系统的基本概念,包括最底层的内存中的数据表示、流水线指令的构成、虚拟存储器、编译系统、动态加载库,以及用户应用等。课程实验内容中包括了大量实际操作,可以帮助学生更好地理解程序执行的方式,改进程序的执行效率。让我们对硬件底层有了更深的了解,为以后对Linux系统、嵌入式开发、及软件开发上有不小的帮助。

(结论0分,缺失 -1分,根据内容酌情加分)

附件

文件的作用

文件名

预处理后的文件

hello.i

编译之后的汇编文件

hello.s

汇编之后的可重定位目标文件

hello.o

链接之后的可执行目标文件

Hello

Hello.o 的 ELF 格式

elf.txt

Hello.o 的反汇编代码

dis_hello.s

hello的ELF 格式

hello.elf

hello 的反汇编代码

hello_objdump.s

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

参考文献

为完成本次大作业你翻阅的书籍与网站等

[1]  博客园 printf函数实现的深入剖析

[2]  CSDN博客 Ubuntu系统预处理、编译、汇编、链接指令

[3]  博客园 从汇编层面看函数调用的实现原理

[4]  博客园 shell命令执行过程

[6]  博客园 从汇编层面看函数调用的实现原理(参考文献0分,缺失 -1分)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值