HIT CSAPP 程序人生

计算机系统

大作业

题     目  程序人生-Hellos P2P  

专       业             医学类1    

学     号           2022110242     

班   级         2252002        

学       生             谢全荣       

指 导 教 师            史先俊   

计算机科学与技术学院

20245

摘  要

本文通过追踪一个简单的"hello"程序的传奇一生,深入探讨了它在现代计算机系统中的完整生命周期。我们从源代码编写开始,详细解析了hello程序如何通过编译、链接等步骤转化为可执行文件,并进一步探讨了它在操作系统中的加载、执行以及最终的终止过程。

首先,我们介绍了hello程序的源代码编写,它通常包含一个简单的输出语句。随后,我们详细描述了hello.c程序如何通过编译器(如GCC)转化为目标文件,并进一步链接成可执行文件。在这一阶段,我们强调了编译器在词法分析、语法分析、语义分析、优化以及代码生成等方面的关键作用。

接下来,我们探讨了hello程序在操作系统中的加载和执行过程。我们分析了Shell如何创建子进程,并通过execve系统调用加载并运行hello程序。同时,我们深入探讨了虚拟内存管理、物理内存加载以及CPU如何执行hello程序的指令。

最后,我们讨论了hello程序的终止过程,包括Shell如何回收子进程,以及操作系统如何删除相关数据结构并释放资源。

通过对hello程序的传奇一生的分析,我们揭示了现代计算机系统的复杂性和精妙之处。hello程序虽然简单,但它却充分体现了计算机系统的基本组成和工作原理,为深入理解计算机系统提供了重要的窗口。

关键词:链接;可执行文件;函数调用;虚拟内存;进程管理 ;IO管理                          

目  录

第1章 概述

1.1 Hello简介

1.2 环境与工具

1.3 中间结果

文件

作用

hello.c

源文件

hello.i

预处理后生成的文件

hello.s

编译后生成的文件

hello.o

汇编后生成的可重定位目标程序

hello

最终生成的可执行目标文件

asm.txt

hello.o反汇编生成的文件

elf.txt

hello.o的elf格式文件

asm1.txt

hello反汇编生成的文件

elf1.txt

hello的elf格式文件

表1-1

1.4 本章小结

第2章 预处理

2.1 预处理的概念与作用

2.2在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

2.4 本章小结

第3章 编译

3.1 编译的概念与作用

3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.4 本章小结

第4章 汇编

4.1 汇编的概念与作用

4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

4.4 Hello.o的结果解析

4.5 本章小结

5链接

5.1 链接的概念与作用

5.2 在Ubuntu下链接的命令

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

5.4 hello的虚拟地址空间

5.5 链接的重定位过程分析

5.6 hello的执行流程

5.7 Hello的动态链接分析

5.8 本章小结

6hello进程管理

6.1 进程的概念与作用

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

6.3 Hello的fork进程创建过程

6.4 Hello的execve过程

6.5 Hello的进程执行

6.6 hello的异常与信号处理

6.7本章小结

7hello的存储管理

7.1 hello的存储器地址空间

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

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

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

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

7.6 hello进程fork时的内存映射

7.7 hello进程execve时的内存映射

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

7.9动态存储分配管理

7.10本章小结

8hello的IO管理

8.1 Linux的IO设备管理方法

8.2 简述Unix IO接口及其函数

8.3 printf的实现分析

8.4 getchar的实现分析

8.5本章小结

结论

附件

参考文献


第1章 概述

1.1 Hello简介

根据Hello的自白,利用计算机系统的术语来简述Hello的P2P(From Program to Process)和020(From Zero to Zero)的整个过程时,可以将其分解为以下步骤:

一、Hello的P2P(From Program to Process)过程:

即为源文件到可执行程序文件的转化。在这里gcc编译驱动程序读取源文件hello.c,并将其翻译成一个可执行目标文件hello。编译过程包括四个阶段(如图1所示:

①预    图1

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

②编译阶段。编译器(ccl)将文本文件 hello.i 翻译成文本文件 hello.s,它包含一个汇编语言程序。该程序包含函数 main 的定义,汇编语言是非常有用的,因为它为不同高级语言的不同编译器提供了通用的输出语言。

③汇编阶段。接下来,汇编器(as)将 hel1o.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件hello.o中。

④链接阶段。请注意,hel1o程序调用了 printf函数,它是每个C编译器都提供的标准C库中的一个函数。printf函数存在于一个名为 printf.o的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的 hello.o程序中。链接器(Id)就负责处理这种合并。结果就得到 hello文件,它是一个可执行目标文件。

当我们运行hello时,在shell中利用fork()函数创建子进程,再用execve加载hello程序,这时,hello就由程序(program)变成了一个进程(process),完成了hello的P2P的过程。

二、Hello的020(From Zero to Zero)过程

即为进程从无到有产生,操作系统加载执行并最终回收资源的过程。

From Zero:初始状态。在程序开始执行之前,它在系统中是不存在的,即处于“零”状态。没有为其分配任何资源(如内存、CPU时间等)。

执行过程:

资源分配:当程序开始执行时,操作系统会为其分配必要的资源。这包括在内存中为其分配空间以存储程序和数据,以及为其分配CPU时间片以执行程序中的指令。

执行指令:程序开始执行其指令,完成预定的功能。在这个过程中,程序会不断地与操作系统和其他硬件设备进行交互,以获取必要的资源和服务。

To Zero:

资源回收:当程序执行完毕后,操作系统会回收为其分配的资源。这包括释放程序在内存中占用的空间,以及回收其使用的CPU时间片等。通过这种方式,系统可以确保资源的有效利用和管理的公平性。

结束状态:在资源回收完成后,程序在系统中不再存在,即回到了“零”状态。此时,程序已经完成了其预定的功能并退出了执行。

综上所述,Hello的P2P和020过程涉及到了从编写源代码到编译成目标文件,再到操作系统加载执行并最终回收资源的整个过程。这个过程展示了计算机系统中程序执行的基本流程和资源管理的重要性。

1.2 环境与工具

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

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

开发工具:CodeBlocks 64位;Visual Studio 2022 64位; vi/vim/gedit+gcc。

1.3 中间结果

文件

作用

hello.c

源文件

hello.i

预处理后生成的文件

hello.s

编译后生成的文件

hello.o

汇编后生成的可重定位目标程序

hello

最终生成的可执行目标文件

asm.txt

hello.o反汇编生成的文件

elf.txt

hello.o的elf格式文件

asm1.txt

hello反汇编生成的文件

elf1.txt

hello的elf格式文件

表1-1

1.4 本章小结

本章主要介绍了hello的P2P和020的过程,接着介绍了完成本次大作业所需的的软硬件环境及开发工具。同时还列出了完成大作业过程中产生的结果文件。


第2章 预处理

2.1 预处理的概念与作用

预处理的概念:预处理是指预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,获得一个后缀为.i的文件,在本例中hello.c与处理后生成hello.i,预处理后的文件仍为文本文件。

预处理的作用:

(1)能够完成头文件的包含,将包含的文件插入到程序文本中(#include);

(2)可以进行宏替换,用实际的常量替换它的符号;

(3)删除源程序中所有的注释部分;

(4)实现特殊控制指令(如#error)。

2.2在Ubuntu下预处理的命令

在ubuntu下预处理指令为 gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i,该命令表示hello.c文件经预处理生成hello.i文件

图2-1

2.3 Hello的预处理结果解析

在进行预处理操作之后,打开hello.i文件后发现,相较于hello.c文件内容明显增加,由初始几行增加到3061行,并且由最后几行可找到初始hello程序,与之前hello.c中的源程序存在差别,主要包括以下几个方面:

(1)hello.c中的注释和多余空白均被删除;

(2)替换宏定义,cpp识别到#include这种指令就会在环境中搜寻该头文件并将其递归展开;

(3)#include中包含的几个文件插入到hello.i中

图2-2

图2-3

2.4 本章小结

本章主要先引入预处理的概念及作用,包括宏定义、注释、包含等方面,再实现在Ubuntu上的预处理指令,将hello.c文件转化生成hello.i文件,并对其结果解析比对。


第3章 编译

3.1 编译的概念与作用

  汇编的概念:编译器(ccl)将预处理生成的文件hello.i翻译成汇编文件文件hello.s。 它包含一个汇编语言程序。

汇编的作用:将高级语言编译为相应的机器语言,这里将C语言转化为intel x86汇编指令。

3.2 在Ubuntu下编译的命令

在ubuntu下编译的命令为gcc -S hello.i -o hello.s,该命令表示hello.i文件通过编译器翻译生成hello.s汇编文件

图3-1

3.3 Hello的编译结果解析

图3-2

3.3.1数据

1.常量

1).字符串

图3-3

划线为字符串常量,其中英文与数字可直接显示,汉字则被编码为UTF-8 格式,一个汉字占3个字节。

分别对应.c文件中的以下两部分

图3-4

2)立即数 在汇编语言中,以$n形式给出,如下为:$5,$1

图3-5

  1. 局部变量

通常保存在寄存器或堆栈中,本函数中包括以下三种:i、argc以及argv。

1)int i局部变量

图3-6

2)int argc 作为main函数的第一个参数,保留在堆栈里

3)char*agrv[ ]为一个指针数组,作为main函数的第二个参数,此处采用偏移量寻址,在访存时,使用%rax寄存器间接访存。

图3-7

3.3.2赋值操作

程序中绝大部分由mov指令完成,例如下图

                          图3-8

3.3.3算术操作

划线部分表示,%rax的值+24

图3-9

划线部分为变量i的自增操作

                          图3-10

3.3.4关系判断和控制转移

  1. 关系判断

1)argc!=5,将argc的值存在-20(%rbp) 的位置后与5比较大小,再判断是否跳转到L2

图3-11

2)i<10,比较9与-4(%rbp)的值,若相等则跳转到L4执行功能否则跳出循环

图3-12

2.控制转移

1)if(argc!=5),将5与-20(%rbp)的值即argc比较,若相等则跳转到L2

图3-11

2).for循环语句,for(i=0,i<10,i++),比较9与-4(%rbp)的值,若相等则跳转到L4执行功能

图3-12

3.3.5数组/指针/结构操作

由前面可知char*argv作为一个指针数组,其首地址首先传入%rax,之后偏移量寻址方式,被存储到即-32(%rbp),完成后%rax对应argv[1],同理,以下指令完成后,argv[2]也被存储到%rax中

图3-13

3.3.6函数操作

汇编语言中,使用call指令进行函数调用,包括puts,exit,printf,atoi,sleep,getchar等函数的调用,ret指令表示函数返回

例如对printf函数调用,编译器调用puts函数优化,把数据放在%rdi寄存器中作为函数参数,call指令调用函数,其他函数同理。

图3-14

3.3.7类型转换

比如在此汇编文件中,sleep函数的参数为int型,但argv为字符串数组,在汇编时用atoi将字符串转化成int型,atoi函数表示强制处理该类型转换。

图3-15

3.4 本章小结

本章主要讲编译,先引入其概念与作用,再实现Ubuntu下由hello.i到hello.s的编译实现,之后对编译文件和源程序文件进行对比分析,主要包括数据类型、操作类型、函数调用与类型转换方面,其中数据类型包括常量和局部变量,操作包括赋值、算术、关系、控制等。

第4章 汇编

4.1 汇编的概念与作用

汇编的概念:汇编器将汇编语言翻译为机器语言指令,例如汇编器as将hello.s文件翻译为hello.o文件的过程,其中hello.o文件为一个可重定位目标程序,包含程序的指令编码。

      汇编的作用:将高级语言转化为机器能直接识别并执行的机器语言二进制代码,这个二进制机器代码是程序在本机器上的机器语言的表示。

4.2 在Ubuntu下汇编的命令

命令为:gcc hello.s -c -o hello.o 

图4-1

4.3 可重定位目标elf格式

执行命令readelf -a hello.o >elf.txt

图4-2

ELF头:以一段16字节的序列开始,描述了构成该序列系统的字的大小和字节顺序,包含了目标文件的类型、系统架构信息、节头大小和数量等信息。具体见下图:

图4-3

节头部表:包含了文件中出现的各个节的语义、类型、位置和大小等信息,例如.text、.data、.bss、rodata等的名称、类型、地址、偏移量等都存储在节头部表中。其中代码可执行但不可写,数据段与只读数据段均不可执行,且只读数据段不可写。由图可知,hello程序中有14个节。下面介绍其中的节:

图4-4

.text

已编译程序的机器代码

.data

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

.bss

未初始化或所有被初始化为0的全局变量和静态变量,不占据实际空间,仅为占位符

.rodata

记录只读数据,如开关语句中的跳转表

.comment

显示版本控制信息

.note

注释节详细描述

.eh_frame

处理异常

.symtab

存放符号表

.strtab

字符串表

.shstrtab

包含节区名称

图4-5

重定位节:是可重定位目标文件的重要组成部分,显示各段引用的外部符号、外部函数等,重定位节可以在链接时对其进行重定位操作,修改其地址,由下图可知elf文件中进行重定位的信息有.rodata的模式串,puts,exit,printf,atoi,sleep,getchar等符号。

图4-6

符号表:.symtab节,由汇编器生成,主要存放了一些程序中的定义并引用的全局变量和程序中定义的局部变量的类型和需要重定位函数等信息,由下图可知,存储了value,size type等信息。

图4-7

4.4 Hello.o的结果解析

执行命令objdump -d -r hello.o >asm.txt  将hello.o的反汇编文件存储在asm.txt文件中。

hello.s                                    asm.txt

图4-8

由上图对比hello.s和asm.txt可知

1)数字使用进制不同,hello.s使用十进制,而反汇编文件asm.txt使用十六进制

2)分支转移不同,hello.s使用段名.L,而反汇编文件asm.txt直接使用跳转地址

3)函数调用不同,hello.s在call指令后直接引用函数名,而反汇编文件asm.txt在call指令后使用引用函数的首地址

objdump -d -r hello.o  分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。

4.5 本章小结

本章主要讲汇编,首先引入汇编的概念和作用,接着实现linux系统下的汇编操作,将hello.s转化为hello.o文件,并使用elf命令生成hello.o的可重定位目标文件,介绍其包含的信息与功能,如ELF头、节头部表、重定位节和符号表,最后解析hello.o文件的反汇编文件asm.txt,同时与hello.s文件对比分析。


5链接

5.1 链接的概念与作用

接的概念:链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。

链接的作用:链接器在软件开发中扮演着一个关键的角色,使得分离编译成为可能,不用再将一个大型的应用程序组织为一个巨大的源文件,而是把它分解为更小、更好管理的模块,可以独立的修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。

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-1

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

   使用命令:readelf -a hello >elf1.txt

较elf.txt,在保持原有elf头、节头、符号表等的基础上,新增加了程序头、dynamic section,并新增共享库等信息。

elf头:与elf.txt相比,由REL(可重定向目标文件)变为EXEC(可执行文件),并且程序头入口点地址变为0x4010f0,另外程序和节的起始位置和大小改变,节数变为了27个。

图5-2

节头部表(较长,截取部分):内容较前面elf.txt基本不变,但变为27节。

图5-3

符号表(较长,截取部分):存储信息基本不变,但内容较前面elf.txt增多,增加了一些外部函数和变量。

图5-4

程序头:主要在程序执行时使用,作用如下:

1)定义了文件在内存中的布局。它告诉操作系统哪些部分应该被加载到进程的地址空间中,以及这些部分在内存中的位置。

2)描述了文件的各个段,如代码段、数据段、动态链接信息等。

3)程序加载时,操作系统会读取程序头来确定哪些段需要被加载到内存中。程序头还包含了动态链接器所需的信息,以便在运行时解析符号和重定位地址。

图5-5

Dynamic section:与动态链接直接相关,提供必要链接信息,包括共享库、动态链接符号表的位置等。同时,它可能包含指示动态链接器进行立即重定位的标志等。

图5-6

5.4 hello的虚拟地址空间

  这里我们在Ubuntu中使用edb加载hello,能够查看本进程的虚拟地址空间各段信息。在edb的data dump中可以看到关于elf文件的二进制信息,其中非常明显的便是ELF头在0x400000处的16字开始序列。而在5.3中程序头中可以看到INTERP标志性的/lib64/ld-linux-x86-64.so.2,同时注意到其VirtAddr为0x400200,因此data dump中找到00400200,它对应着INTERP的虚拟地址。同理,关于程序头中标出的各个段的虚拟地址在datadump中都有对应。

由下图hello文件的虚拟地址空间是从0x0000558c929b6000-0x0000558c929b7000,

,二者相互对应

图5-7

   

5.5 链接的重定位过程分析

执行命令:objdump -d -r hello >asm1.txt

图5-8

查看hello文件的反汇编文件asm1.txt,发现较asm.txt而言,

  1. 内容增多,
  2. 同时在main函数之前增加了.init,.plt,_start的反汇编内容和节中相关函数如puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等,
  3. 另外由于hello无需重定位,其反汇编代码中地址为虚拟地址,
  4. 且无需重定位条目,因为在链接过程中已完成重定位。

图5-9

图5-10

链接过程:

1)合并相同节:合并相同类型的节成为一个新节,例如合并所有文件的.plt成为一个新的.plt节,即为hello文件的新的.plt节

2)确定地址:链接器ld分配内存地址给新节以及符号。确定地址后全局变量,指令等具有唯一的运行时地址

asm.txt:

图5-11

asm1.txt:

图5-12

圈出部分表明hello中对其重定位方式分别为立即数、控制转移、函数调用进行重定位

objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。

结合hello.o的重定位项目,分析hello中对其怎么重定位的。

5.6 hello的执行流程

从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)调用与跳转的各个子程序名或程序地址如下:

图5-13

5.7 Hello的动态链接分析

got中存放函数目标地址,plt使用got中地址跳转到目标函数。二者信息如下图 ,由图可知got其地址在0x403ff0  

图5-14

图5-15

在gdb中定位0x403ff0地址,并在_start前后设置断点,结果如图5-15所示。由结果可知在dl_start前后,0x4010f0处8bit数据分别由00007ffff7fd0100和00007ffff7fde1630变为了00007ffff7ff2684和00007ffff7f564bd,说明动态链接项目都发生了变化。

5.8 本章小结

本章主要讲链接,首先引入链接的概念和作用,再实现Ubuntu下的链接命令,同时分析了hello对应elf1.txt,以及与elf.txt的异同,之后介绍并用edb查看了hello的虚拟地址空间,通过对比asm.txt与asm1.txt更加深入解析了重定位过程和链接过程,同时使用edb对动态链接进行进一步分析概述,至此链接过程结束,之后hello将作为一个进程。


6hello进程管理

6.1 进程的概念与作用

进程的概念:

进程由程序、数据和进程控制块三部分组成。是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配的基本单位,也是操作系统结构的基础。它是动态产生,动态消亡的。任何进程都可以同其他进程一起并发执行。

另外进程是一个能独立运行的基本单位,同时也是系统分配资源和调度的独立单位。但由于进程间的相互制约,使进程具有执行的间断性。

进程的作用:进程提供给应用程序一个独立的逻辑控制流;一个私有的地址空间。在Shell中运行hello程序时,Shell会调用fork函数创建一个子进程。应用程序也可创建新进程;操作系统为hello进程分配必要的系统资源,如CPU时间、内存空间等。调度器负责将CPU时间分配给不同的进程,确保hello进程能够得到执行的机会。

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

Shell-bash的作用:

Shell是一个与操作系统交互的命令行解释器,用户可以将一系列命令编写成脚本文件,通过bash解释执行,bash将用户的指令翻译给OS内核,让内核来进行处理,并把处理的结果反馈给用户。

Shell-bash的处理流程:

1)从终端、文件或管道中读取用户输入的命令。

2)对读取到的命令进行解析,识别出命令名、参数、选项等。

3)查找命令:根据命令名在系统的PATH环境变量中查找对应的可执行文件或脚本文件。

4)分析命令:

①输入为内置命令,直接解释执行

②输入为非内置命令,则调用相应函数分配子进程执行

③如果找不到命令,则返回错误信息。

等待命令执行完毕:bash会等待命令执行完毕并收集命令的返回值。如果命令执行成功,返回值一般为0;如果失败,则返回非零值。

5)输出当前参数后继续进行处理下一参数,直到全部处理完为止

6.3 Hello的fork进程创建过程

当用户在Shell中输入并执行hello命令时,bash解析此命令是否为内置命令,在当前状态查找并执行hello,此时bash调用fork函数来创建一个新的子进程。子进程在创建时,会复制父进程的PCB(进程控制块)结构体的部分内容,包括父进程的代码段、数据段、堆和栈等,fork函数返回两次,但在父进程中返回新创建的子进程的PID,而在子进程中返回0。

图6-1

跟踪hello进程

图6-2

6.4 Hello的execve过程

hello程序调用函数fork创建新的子进程之后,子进程会调用execve函数,execve函数能够在当前进程的上下文中加载并运行一个新程序,同时携带传递准备好的文件路径名、参数列表和环境变量给内核。在execve函数加载了hello之后,它调用加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段(初始化为零),但是,进程ID(PID)和一些其他属性(如父进程ID、会话ID等)会保持不变。通过映射关系,将hello中的代码和数据初始化在新的代码和数据,然后跳转到_start函数的地址,调用main函数。

因此,execve没有返回值。但当新程序执行完毕后,它会返回一个退出状态给其父进程(通过wait或waitpid等系统调用获取)。

6.5 Hello的进程执行

1)上下文信息

进程上下文信息包括了进程执行时所需的所有状态信息,如CPU寄存器的值、程序计数器、栈信息、内存管理信息、打开文件描述符等。这些信息在进程切换时会被保存和恢复,确保进程在被调度回来时能够继续执行。

图6-3

2)时间片

进程时间片是操作系统分配给每个进程执行的时间片段。当进程的时间片用完时,操作系统会暂停该进程的执行,并将其状态信息保存到进程上下文中,然后调度另一个进程执行。通过这种方式,操作系统可以实现多个进程的并发执行。

3)进程调度

进程调度是操作系统决定哪个进程在何时获得CPU资源的过程。调度算法会考虑多个因素,如进程的优先级、时间片的使用情况、I/O操作等。在hello进程执行时,操作系统会根据调度算法来决定何时将其放入就绪队列,并在适当的时机将其调度到CPU上执行。抢占进程时包括保存前进程的上下文;恢复执行新进程的上下文;把控制给新恢复进程实现上下文切换。

4)用户态和核心态

表示CPU的两种执行状态。用户模式下,进程只能执行用户空间的代码,不能直接访问系统资源或执行特权指令。大部分的应用程序代码都在用户模式下执行。内核模式下,进程可以执行任何指令,访问任何内存地址,控制所有系统资源。操作系统的内核代码在内核模式下执行。

当hello进程需要执行系统调用(如execve来加载并执行程序)时,会触发从用户态到核心态的转换。这个过程通常通过中断或异常来实现。一旦进入核心态,操作系统会执行相应的内核代码来处理系统调用,如加载hello程序到内存中并执行。当系统调用完成后,操作系统会将控制权返回给hello进程,并触发从核心态到用户态的转换,使hello进程继续在用户态下执行。

图6-4

6.6 hello的异常与信号处理

Hello的几种异常:中断、陷阱、故障和终止

1)中断:异步的,由处理器外部的I/O设备产生的。敲键盘后,键盘控制器向处理器的中断引脚发送信号来触发中断,同时将异常号放到系统总线上,cpu调用相应程序来处理中断,完毕后返回继续执行下一条指令。

2)陷阱:同步的,是CPU执行当前指令的结果。是一种故意触发异常,主要为用户程序和操作系统内核之间提供类似函数的接口,当要读取文件或创建新进程时,向内核提供服务,处理器提供syscall指令,内核调用陷阱处理程序来执行系统调用。

3)故障:同步的,是CPU执行当前指令的结果。由错误情况引起,有可能被故障处理程序修复,发生故障时,处理器将控制转移给故障处理程序,若能修正,则返回故障指令并重新执行,若无法修正,则终止该程序。例如缺页异常,

假设当前指令引用了一个虚拟地址,但与其对应的物理页面并不在内存中,此事须从磁盘中读取数据到内存,此时当前指令引发故障,之后程序从磁盘加载对应页面到内存,将控制返回给引起故障的指令,之后指令可以继续执行而不引发故障。

  1. 终止:同步的,是CPU执行当前指令的结果。是由不可恢复的致命错误导致的,通常为硬件错误,例如DRAM或SRAM的存储位被损坏时,会导致奇偶校验错误,终止处理程序从不将控制返回给应用程序,而是直接终止程序。

产生的信号类型:SIGINT、SIGKILL、SIGSEGV、SIALARM、SIGCHLD等

程序的运行

1)正常运行

图6-5

2)运行过程中不停乱按

图6-6

3)运行过程中回车

    

图6-7

4)运行过程中键入Ctrl-Z

    

图6-8

5)运行过程中键入Ctrl-C

    

图6-9

  1. Ctrl-z后可以运行ps  jobs  pstree  fg  kill 等命令

ps打印各进程PID

图6-10

jobs打印被挂起的hello的信息

图6-11

pstree打印进程树

图6-12

fg继续进行

图6-13

kill杀死进程

图6-14

6.7本章小结

本章主要讲hello进程,首先引入其概念和作用,接着介绍shell-bash的作用和处理流程,之后介绍如何使用fork函数创建新进程,以及hello的exceve过程,然后介绍hello的进程执行,包括上下文,时间片,进程调度,用户态和核心态等信息,最后针对异常和信号处理,解释了异常的几种类型以及信号处理流程。


7hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:程序在编写时所使用的地址,也称为虚拟地址或相对地址。通常与程序的源代码或中间代码(如汇编代码)中的内存引用相对应。在多用户或多任务操作系统中,逻辑地址空间是私有的,每个进程或任务都有自己的逻辑地址空间。在程序执行前需要转换为物理地址。

线性地址:是逻辑地址到物理地址转换过程中的一个中间步骤。在某些架构中(如x86的保护模式),逻辑地址首先被转换为线性地址,然后再转换为物理地址。线性地址空间是全局的,所有进程共享同一个线性地址空间。线性地址通常与物理地址的大小相同(例如,在32位系统中为4GB)。

虚拟地址:它和逻辑地址在许多上下文中是相似的,并且经常可以互换使用。

它是私有的,并且每个进程都有自己的虚拟地址空间。在现代操作系统中,内存管理单元(MMU)负责将虚拟地址转换为物理地址。通过使用虚拟地址,操作系统可以实现内存保护、地址空间隔离和虚拟内存等功能。

物理地址:物理地址是内存中的实际硬件地址,也称为机器地址或实地址。

处理器使用物理地址来访问内存单元。在没有内存管理硬件(如MMU)的系统中,程序直接使用物理地址。在具有内存管理的系统中,虚拟地址(或逻辑地址)通过地址转换机制(如分页或分段)转换为物理地址。

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

概念:为了让程序在内存中能自由浮动而又不影响它的正常执行,CPU将内存划分成逻辑上的段来给程序使用。与段有关的信息需要8个字节来描述,为了存放这些段描述符,就在内存中开辟了一段空间,所有的段描述符以数组的形式存放在一起,这就构成了一个段描述表(Descriptor Table)。全局描述符表(GDT)是最主要的描述符表,它在任何时刻都可以使用。在进入保护模式前,必须要先定义全局描述表的内容。

变换过程:

逻辑地址由两个16位的地址分量构成,一个为段选择符(Segment Selector),另一个为偏移量。段选择符是为了找到段描述符。首先检查段选择符的TI字段,决定段描述符保存在哪一个描述表中(GDT或LDT),其中TI=0表示使用GDT,TI=1表示使用LDT。从段选择符的index字段计算段描述符的地址,index字段的值乘以8(描述符大小),这个结果与gdtr或ldtr寄存器的内容相加。把逻辑地址的偏移量与段描述符base字段的值相加,就得到了线性地址。

例如假设GDT在内存地址0x00020000,且由段选择符所指定的索引号为2。相应的段描述符地址是:0x00020000 + (2 * 8) = 0x00020010。如果逻辑地址的偏移量是0x1234,那么线性地址就是段描述符的base字段值(从0x00020010处读取)加上0x1234。

图7-1

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

概念:页式管理是一种内存空间存储管理的技术,它将各进程的虚拟空间划分成若干个长度相等的页(page),同时把内存空间也按页的大小划分成片或页面(page frame)。然后,页式管理通过建立虚拟地址与物理地址之间的一一对应页表,并使用相应的硬件地址变换机构,来解决离散地址变换问题。

变换过程:

1)首先将其与CPU内部的TLB(Translation Lookaside Buffer)中的条目进行比较。若TLB中存在匹配的条目(即线性地址的高位与TLB中某个条目的线性页面值相等),则可以直接从TLB中获取对应的物理页面值,并与线性地址的页内偏移量组合,形成物理地址。

2)若TLB中没有匹配的条目,系统会使用常驻于存储器中的页目录表和页表来进行转换。

首先,线性地址的高10位(页目录索引)会被用来在页目录表中查找对应的页目录项。通常将页目录索引乘以4(因为每个页目录项通常占用4个字节)并与页目录表基址相加,从找到的页目录项中,系统会读取页表地址指针(高20位)和页目录项的属性(低12位)。接着,线性地址的中间10位(页表索引)会被用来在页表中查找对应的页表项。同样将页表索引乘以4并与页表地址指针相加。

从找到的页表项中,系统会读取物理页地址指针(高20位)和页表项的属性(低12位)。最后,将物理页地址指针与线性地址的低12位(页内偏移量)相加,就得到了对应的物理地址。

3)当通过页目录表和页表进行转换得到物理地址后,系统会把这次转换的信息(线性页面值及对应的物理页面值)用来更新TLB,通常是替换掉TLB中最近较少使用的条目。这样可以加速未来的地址转换过程,因为TLB的访问速度通常远快于内存中的页目录表和页表。

图7-2

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

TLB:全称Translation Lookaside Buffer,即旁路转换缓冲或地址转换后备缓冲,是一种特殊的高速缓存,用于加速虚拟地址到物理地址的转换过程。它存储了最近使用的页表项,当CPU需要访问某个内存地址时,首先会在TLB中查找该地址的映射关系。

四级页表:当今操作系统普遍采用64位架构,CPU最大寻址能力虽然达到了64位,但实际上只使用48位进行寻址。其内存管理采用了9-9-9-9-12的分页模式,表示物理地址拥有四级页表,依次为PXE(Page Directory Pointer Table Entry)、PPE(Page Directory Entry)、PDE(Page Table Entry)和PTE(Page Table Entry)。

TLB与四级页表共同支持了从虚拟地址到物理地址的高效变换过程。TLB通过缓存最近使用的页表项来加速地址转换,而四级页表则提供了从虚拟地址到物理地址的精确映射。

VA到PA的变换过程

1)TLB查找:当CPU需要访问某个虚拟地址(VA)时,首先会在TLB中查找该地址的映射关系。如果TLB命中(TLB hit),即TLB中存在该VA对应的VA-PA映射关系,则可以直接从TLB中获取物理地址(PA),从而加快内存访问的速度。

2)页表查找(如果TLB未命中):如果TLB未命中(TLB miss),即TLB中没有该VA对应的VA-PA映射关系,CPU需要到页表中查找。根据四级页表的结构,CPU会依次查找PXE、PPE、PDE和PTE,直到找到对应的PTE项。PTE项中存储了VA到PA的映射关系,包括物理页地址指针(高20位)和页表项的属性(低12位)。

3)计算物理地址:

将PTE项中的物理页地址指针与VA的页内偏移量(低12位)相加,得到完整的物理地址(PA)。

图7-3

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

运作原理是使用较快速的储存装置保留一份从慢速储存装置(如物理内存)中所读取的数据并进行拷贝。当CPU需要再次访问这些数据时,它可以先从三级缓存中读取,从而避免了直接从物理内存中读取数据所带来的延迟。

三级缓存的过程:

当CPU需要读取数据时,它会首先检查一级缓存(L1 Cache),如果数据在一级缓存中命中,则直接读取。

如果一级缓存未命中,CPU会检查二级缓存(L2 Cache)。如果数据在二级缓存中命中,则读取二级缓存中的数据。

如果二级缓存也未命中,CPU会检查三级缓存(L3 Cache)。如果数据在三级缓存中命中,则读取三级缓存中的数据。

如果三级缓存也未命中,CPU则必须从物理内存中读取数据。

通过增加缓存的层级,尤其是三级缓存,CPU可以更有效地从缓存中读取数据,从而减少对物理内存的访问次数。这不仅降低了内存延迟,还提高了大数据量计算时处理器的性能。

图7-4

7.6 hello进程fork时的内存映射

基本概念:fork是Unix和类Unix操作系统中创建新进程的主要方法。当一个进程调用fork时,它会创建一个与当前进程几乎完全相同的副本,包括父进程的内存布局、环境变量、打开的文件描述符等。

内存映射:在fork时,子进程会继承父进程的内存映射。这意味着父进程的代码段、数据段、堆和栈都会被复制到子进程中。然而,这种复制是“写时复制”(Copy-On-Write, COW)的,即只有当子进程或父进程尝试修改内存中的某个页面时,该页面才会被实际复制。这种机制有效地减少了不必要的内存拷贝,提高了性能。父进程和子进程在fork后拥有独立的虚拟地址空间,但它们映射到相同的物理内存页面(在写时复制之前)。这允许它们各自独立地访问和修改自己的内存区域,而不会互相干扰。

7.7 hello进程execve时的内存映射

基本概念:execve是一个系统调用,用于执行一个新的程序。当一个进程调用execve时,它会用新的程序完全替换自己的内存映像,包括代码、数据、堆和栈。这意味着原有的内存映射会被丢弃,而新的程序文件(通常是可执行文件)会被加载到内存中。

内存映射:在execve时,操作系统会丢弃当前进程的内存映射,并根据新的程序文件创建新的内存映射。这通常包括将程序的代码段映射到虚拟地址空间的某个区域,并为其分配堆和栈空间。此外,任何必要的文件或库也会被映射到虚拟地址空间中。在execve后,进程的虚拟地址空间会被完全重新组织,以反映新的程序结构。原有的内存映射会被丢弃,而新的内存映射会根据新的程序文件来创建。

图7-4

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

缺页故障(Page Fault,又名硬错误、硬中断、等):当软件试图访问已映射在虚拟地址空间中,但是并未被加载在物理内存中的一个分页时,由中央处理器的内存管理单元(MMU)所发出的中断。是操作系统(如Microsoft Windows和各种类Unix系统)中常见且有必要的机制,用于利用虚拟内存来增加程序可用内存空间。

处理流程如下:

①处理器将虚拟地址发送给MMU;

②MMU生成PTE地址(PTEA),并从高速缓存/主存请求得到;

③高速缓存/主存向MMU返回PTE;

④PTE有效位为0, MMU触发缺页异常,传递CPU中的控制到操作系统内核的缺页异常处理程序;

⑤缺页处理程序确定出物理内存中的牺牲页 ,若页面被修改,则把它写回到磁盘中。

⑥缺页处理程序调入新的页面,并更新内存中的PTE;

⑦缺页处理程序返回到原进程,再次执行导致缺页的指令,CPU将VA重新送给MMU并执行相应访问操作,之后将不会再出现缺页情况。

图7-5

7.9动态存储分配管理

基本方法:

程序使用malloc(或calloc/realloc)这些函数来请求分配指定大小的内存块。这些函数返回指向分配的内存的指针,或者在内存不足时返回NULL。分配内存后,程序可以通过返回的指针来访问和修改这块内存。当程序不再需要某块内存时,它应该使用free函数来释放这块内存。这样可以避免内存泄漏。

基本策略:

1) 内存池

为了减少内存分配和释放的开销,一些程序会使用内存池来预先分配一大块内存,并在需要时从这块内存中分配和释放小块内存。这可以减少与操作系统的交互次数,提高性能。

2)引用计数

用于跟踪内存块被引用的次数。每当内存块被引用时,计数加1;每当引用被释放时,计数减1。当计数为0时,内存块可以被安全地释放。这种方法可以自动处理内存泄漏,但不适用于存在循环引用的情况。

3)垃圾回收

自动管理内存,它可以识别并释放不再被程序使用的内存块。垃圾回收器会定期扫描内存,找出所有不再被引用的对象,并将它们占用的内存释放。这种方法可以大大简化内存管理,但可能会增加程序的运行时间。

4)分区

将内存划分为多个固定大小的分区,每个分区都可以存储一个对象。当需要分配内存时,程序会选择一个足够大的分区来存储对象。这种方法可以简化内存管理,但可能导致内存利用率降低。

5)伙伴系统

用于动态内存分配,它将内存块组织成一系列不同大小的“伙伴对”。当一个内存块被释放时,它会被与其大小的伙伴合并成一个更大的内存块。当需要分配内存时,程序会找到最接近请求大小的伙伴对,并从中拆分出所需大小的内存块。这种方法可以提高内存利用率,但可能导致内存碎片。

Printf会调用malloc,请简述动态内存管理的基本方法与策略。

7.10本章小结

本章主要讲hello程序存储管理的相关内容。首先介绍了逻辑地址、线性地址、虚拟地址以及物理地址的概念,再通过段式管理和页式管理讲述了从逻辑地址到线性地址,再到物理地址的变换,和在TLB与四级页表支持下的虚拟地址到物理地址的变换,三级cache支持下的物理内存访问操作。最后介绍了hello进程的调用fork和execve时的内存映射以及缺页故障和缺页中断处理的方式及动态分配管理的基本方法与策略。


8hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

一个linux文件就是一个m字节的序列,有普通文件如文本文件和二进制文件,包含链接的目录文件,用来与另外一个进程跨网络通信的套接字文件以及其他文件如命名通道、符号链接、字符和块设备

设备管理:unix io接口,使得输入输出统一一致

Linux 的 I/O 设备管理方法将每个设备都被视为一个特殊的文件,通过文件描述符进行访问和控制。可以使用标准的文件 I/O 操作(如 read、write、open)来进行输入输出,Linux 通过文件系统的统一性,将设备的访问、控制和管理与普通文件一致化。此外,采用设备驱动模型,通过设备驱动程序管理各类硬件,将硬件底层细节与上层应用程序隔离,使得应用程序更专注于高层

设备的模型化:文件

设备管理:unix io接口

8.2 简述Unix IO接口及其函数

unix io接口:在计算机系统中用于连接和交换信息的通道,主要用于数据传输和控制信号传递,是连接CPU与外设之间的部件,它完成CPU与外界的信息传送。还包括辅助CPU工作的外围电路,如中断控制器、DMA控制器、定时器、高速cache。

1)打开文件:通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。

2)改变当前文件的位置:对于每个文件,内核保持着文件位置k、初始为0。该文件位置即为从文件开头起始的字节偏移量。可通过执行seek操作,显示地设置文件的当前位置为k。

3)读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。

4)关闭文件:完成文件访问,通知内核关闭该文件。

I/O接口函数:

1)打开文件:open 函数,打开一个文件,并返回一个文件描述符(file descriptor)。int open(const char *pathname, int flags, ...);其中pathname:要打开的文件的路径名。flags:用于指定打开文件的模式(如只读、只写、读写等)以及其他选项(如创建文件、追加写等)。...:如果flags中包含了O_CREAT,则还需要一个额外的参数mode来指定新文件的权限。

2)关闭文件:close 函数,关闭一个已打开的文件描述符。int close(int fd);参数:fd 是要关闭的文件描述符。

3)读取文件:read 函数,从文件中读取数据到缓冲区。ssize_t read(int fd, void *buf, size_t count);fd:要读取的文件描述符。buf:指向用于存储读取数据的缓冲区的指针。count:要读取的字节数。

4)写入文件:write 函数,将数据从缓冲区写入文件。ssize_t write(int fd, const void *buf, size_t count);fd:要写入的文件描述符。buf:指向包含要写入数据的缓冲区的指针。count:要写入的字节数。

5)文件定位:lseek 函数,移动文件的读写指针到指定位置。off_t lseek(int fd, off_t offset, int whence);fd:要移动读写指针的文件描述符。offset:相对于whence的偏移量。whence:指定偏移量的起始位置(如文件开头、当前位置、文件结尾等)。

8.3 printf的实现分析

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

之后printf使用了可变参数的模式,在函数体内它调用了vsprintf函数,vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。

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

   

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

   }

接着调用write底层系统函数,将数据从缓冲区写入标准输出。此时,在 Linux 中,用户空间程序通过软中断或系统调用(int 0x80 或 syscall 指令)切换到内核空间执行系统调用。

字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

write:
     mov eax, _NR_write
     mov ebx, [esp + 4]
     mov ecx, [esp + 8]
     int INT_VECTOR_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

8.4 getchar的实现分析

getchar函数体如下:

int getchar(void){    

    static char buf[BUFSIZ];    

    static char *bb = buf;    

    static int n = 0;    

    if(n == 0)    

    {    

        n = read(0, buf, BUFSIZ);    

        bb = buf;    

    }    

   return(--n >= 0)?(unsigned char) *bb++ : EOF;    

}

主要调用read函数,将缓冲区都读到buf里面,n将缓冲区的长度赋值给n,如果n>0,那么返回buf的第一个元素,

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

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

8.5本章小结

本章主要讲hello的IO管理,首先引入linux下的IO设备管理方法,接着讲述了unix IO接口及相关函数,解释其流程与几种常见的函数如open,close,read,lseek等,并结合以上函数分析了printf和getchar的实现方式

结论

首先由hello的P2P、020的引入,对hellode整个生命周期建立整体框架,hello总结如下:

1、编写:使用C语言编写源程序hello.c,hello从此诞生。

2、预处理:预处理器(cpp)对源程序hello.c文件进行处理,生成hello.i文件。

3、编译:编译器(cc1)将hello.i文件进行编译,得到汇编文件hello.s。将源代码转换成机器码。

4、汇编:汇编器(as)将汇编文件hello.s转化为可重定位目标文件hello.o。

5、链接:链接器(ld)将可重定位目标文件hello.o和其他目标文件链接生成可执行目标文件hello。

6、在shell输入./hello 2022110242 谢全荣 17793884284 4运行hello程序。

7、创建进程:shell调用fork函数为hello创建子进程。

8、运行进程:调用execve函数运行hello,加载映射虚拟内存,在当前进程的上下文中加载运行hello。

9、访问内存:在运行hello时会涉及访存操作,这就需要通过MMU将需要访问的虚拟地址转化为物理地址即进行段式及页式管理并访问。

10、信号与异常:hello运行过程中可能会产生各种异常与信号,系统对其异常处理和信号处理等一系列操作

13、hello运行结束,最终被父进程或init进程回收所占资源

感悟:

在完成本次大作业的工程中,我不仅温习到了书本中所讲到的知识,同时将理论用于实践,对所学有了更深入的理解,另外,本次大作业着眼于hello程序的一系列操作,将每章的琐碎的知识点串联在一起,使得我对整本书有了一个更加清晰的架构,同时他也让我从一个程序员的角度了解了各函数各流程的使用,让我有了更深入的思考。


附件

hello.c: hello的C语言源程序

hello.i: ASCII码的中间文件(预处理器产生),用于分析预处理过程。

hello.s: ASCII汇编语言文件,用于分析编译的过程。

hello.o:可重定位目标程序(汇编器产生),用于分析汇编的过程。

hello:可执行目标文件(链接器产生),用于分析链接的过程.

elf.txt:hello.o的通用的可执行、可链接和可共享的文件格式

elf1.txt:hello的通用的可执行、可链接和可共享的文件格式

asm.txt:hello.o的objdump反汇编文件,用于分析可重定位目标文件hello.o。

asm1.txt:hello的objdump反汇编文件,用于分析可执行目标文件hello。

参考文献

[1] Gopher大威,通过hello程序的生命周期理解计算机系统,csdn计算机系统专栏,2020.5.9

[2]  kkbca,Linux------进程的fork()详解,csdn,2024.1.16

[3]  zmrlinux ,动态链接详解,csdnc/c++专栏,2015.8.21

[4]  南方铁匠,操作系统内存管理——分区、页式、段式管理、段页式 csdn操作系统栏目,2017.11.13

[5]  ZY-JIMMY,Linux进程管理 | 替换进程映像exec系列函数,csdnLinux基础、网络与内核专栏,2019.2.23

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值