C语言:内存地址对齐、大小端详解

阅读(一)

我们常常看到“alignment", "endian"之类的字眼, 但很少有C语言教材提到这些概念. 实际上它们是与处理器与内存接口, 编译器类型密切相关的.

考虑这样一个例子: 两个异构的CPU进行通信, 定义了这样一个结果来传递消息:

struct Message {
short opcode;
char subfield;
long message_length;
char version;
short destination_processor;
}message; 用这样一个结构来传递消息貌似非常方便,但也引发了这样一个问题: 若这两种不同的CPU对该结构的定义不一样,两者就会对消息有不同的理解.有可能导致二义性.会引发二义性的有这两个方面:
  1. 内存地址对齐
  2. 大小端定义

本文先介绍内存地址对齐和大小端的概念, 再回头来看这个例子就豁然开朗了.

内存地址对齐,洋名叫做" Byte Alignment".

大部分16位和32位的CPU不允许将字或者长字存储到内存中的任意地址. 比如Motorola 68000不允许将16位的字存储到奇数地址中, 将一个16位的字写到奇数地址将引发异常.

实际上, 对于c中的字节组织, 有这样的对齐规则:

  • 单个字节(char)能对齐到任意地址
  • 2字节(short)以2字节边界对齐
  • 4字节(int, long)以4字节边界对齐
不同CPU的对其规则可能不同, 请参考手册.


为什么会有上述的限制呢? 理解了内存组织, 就会清楚了
CPU通过地址总线来存取内存中的数据, 32位的CPU的地址总线宽度即为32位, 标记为A[0:31]. 在一个总线周期内, CPU从内存读/写32位. 但是CPU只能在能够被4整除的地址进行内存访问, 这是因为: 32位CPU不使用地址总线的A1和A0,比如ARM, 它的A[0:1]用于字节选择(32位总线,一次传输一共可以传输4byte数据,A[0:1]可以控制4byte数据中第几byte传输或者全部传输), 用于逻辑控制, 而不和存储器相连, 存储器连接到A[2:31].

访问内存的最小单位是字节(byte), A0和A1不使用, 那么对于地址来说, 最低两位是无效的, 所以它只能识别能被4整除的地址了. 在4字节中, 通过A0和A1确定某一个字节.

再看看刚才的message结构, 你想想它占了多少字节? 别想当然的以为是10个字节. 实际上它占了12个字节. 不信? 用sizeof(message)看吧. 对于结构体, 编译器会针对起中的元素添加"pad"以满足字节对齐规则. message会被编译器改为下面的形式:

struct Message

{
short opcode;
char subfield;
char pad1; // Pad to start the long word at a 4 byte boundary
long message_length;
char version;
char pad2; // Pad to start a short at a 2 byte boundary
short destination_processor;
char pad3[4]; // Pad to align the complete structure to a 16 byte boundary
};

如果不同的编译器采用不同的对齐规则, 对传递message可就麻烦了.
 

Byte Endian是指字节在内存中的组织,所以也称它为Byte Ordering.   

        对于数据中跨越多个字节的对象, 我们必须为它建立这样的约定:

(1) 它的地址是多少?

(2) 它的字节在内存中是如何组织的?

        针对第一个问题,有这样的解释:

        对于跨越多个字节的对象,一般它所占的字节都是连续的, 它的地址等于它所占字节最低地址.(链表可能是个例外, 但链表的地址可看作链表头的地址).

比如: int x, 它的地址为0x100. 那么它占据了内存中的Ox100, 0x101, 0x102, 0x103这四个字节.

        上面只是内存字节组织的一种情况: 多字节对象在内存中的组织有一般有两种约定. 考虑一个W位的整数. 它的各位表达如下:

[Xw-1, Xw-2, ... , X1, X0]

        它的MSB (Most Significant Byte, 最高有效字节)为[Xw-1, Xw-2, ... Xw-8]; LSB (Least Significant Byte, 最低有效字节)为 [X7, X6, ..., X0]. 其余的字节位于MSB, LSB之间. 

        LSB和MSB谁位于内存的最低地址, 即谁代表该对象的地址? 这就引出了大端(Big Endian)与小端(Little Endian)的问题。

        如果LSB在MSB前面, 既LSB是低地址, 则该机器是小端; 反之则是大端. DEC (Digital Equipment Corporation, 现在是Compaq公司的一部分)和Intel的机器一般采用小端. IBM, Motorola, Sun的机器一般采用大端. 当然, 这不代表所有情况. 有的CPU即能工作于小端, 又能工作于大端, 比如ARM, PowerPC, Alpha. 具体情形参考处理器手册.

        举个例子来说名大小端:  比如一个int x, 地址为0x100, 它的值为0x1234567. 则它所占据的0x100, 0x101, 0x102, 0x103地址组织如下图:

 

        0x01234567的MSB为0x01, LSB为0x67. 0x01在低地址(或理解为"MSB出现在LSB前面,因为这里讨论的地址都是递增的), 则为大端; 0x67在低地址则为小端.

认清这样一个事实: C中的数据类型都是从内存的低地址向高地址扩展,取址运算"&"都是取低地址.

两个测试Bit Endian的小程序


method_1

#include <stdio.h>

int main(int argc, char *argv[])
{

  int c = 1;
  if ((*(char *)&c) == 1) {
    printf("little endian\n");
  }
  else
    printf("big endian");

  return 0;
}

        int c 在内存中的表达为: 0x00000001. (这里假设int为4字节). 用char可以截取一个字节. LSB为0x01, 若它出现在c的低地址, 则为小端.

method_2

#include <stdio.h>

int main(void)
{
/* Each component to a union type is allocated storage at the beginning of the union */
         
  union {
    short n;
    char c[sizeof(short)];
  }un;
 
  un.n = 0x0102;
 
  if ((un.c[0] == 1 && un.c[1] == 2))
    printf("big endian\n");
  else if ((un.c[0] == 2 && un.c[1] == 1))
    printf("little endian\n");
  else
    printf("error!\n");
  return 0;
}

 union中元素的起始地址都是相同的——位于联合的开始. 用char来截取感兴趣的字节.
     

区分大端与小端有什么用呢? 如果两个不同Endian的机器进行通信时, 就有必要区分了内存地址对齐及大小端

阅读(二)

一、什么是对齐,以及为什么要对齐:

1. 现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定变量的时候经常在特定的内存地址访问,这就需要各类型数据按照一定的规则在空间上排列,而不是按顺序的一个接一个的排放,这就是对齐。

2. 对齐的作用和原因:各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。其他平台可能没有这种情况, 但是最常见的是如果不按照适合其平台的要求对数据存放进行对齐,会在存取效率上带来损失。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为 32位)如果存放在偶地址开始的地方,那么一个读周期就可以读出,而如果存放在奇地址开始的地方,就可能会需要2个读周期,并对两次读出的结果的高低 字节进行拼凑才能得到该int数据。显然在读取效率上下降很多。这也是空间和时间的博弈。

二、对齐的实现

通常,我们写程序的时候,不需要考虑对齐问题。编译器会替我们选择适合目标平台的对齐策略。当然,我们也可以通知给编译器传递预编译指令而改变对指定数据的对齐方法。
但是,正因为我们一般不需要关心这个问题,所以因为编辑器对数据存放做了对齐,而我们不了解的话,常常会对一些问题感到迷惑。最常见的就是struct数据结构的sizeof结果,出乎意料。为此,我们需要对对齐算法所了解。
对齐的算法:
由于各个平台和编译器的不同,现以本人使用的gcc version 3.2.2编译器(32位x86平台)为例子,来讨论编译器对struct数据结构中的各成员如何进行对齐的。
设结构体如下定义:
struct A {
    int a;
    char b;
    short c;
};
结构体A中包含了4字节长度的int一个,1字节长度的char一个和2字节长度的short型数据一个。所以A用到的空间应该是7字节。但是因为编译器要对数据成员在空间上进行对齐。
所以使用sizeof(strcut A)值为8。
现在把该结构体调整成员变量的顺序。
struct B {
    char b;
    int a;
    short c;
};
这时候同样是总共7个字节的变量,但是sizeof(struct B)的值却是12。
下面我们使用预编译指令#pragma pack (value)来告诉编译器,使用我们指定的对齐值来取代缺省的。
#pragma pack (2) /*指定按2字节对齐*/
struct C {
    char b;
    int a;
    short c;
};
#pragma pack () /*取消指定对齐,恢复缺省对齐*/
sizeof(struct C)值是8。

修改对齐值为1:
#pragma pack (1) /*指定按1字节对齐*/
struct D {
    char b;
    int a;
    short c;
};
#pragma pack () /*取消指定对齐,恢复缺省对齐*/
sizeof(struct D)值为7。

对于char型数据,其自身对齐值为1,对于short型为2,对于int,float,double类型,其自身对齐值为4,单位字节。
这里面有四个概念值:
1)数据类型自身的对齐值:就是上面交代的基本数据类型的自身对齐值。

2)指定对齐值:#pragma pack (value)时的指定对齐值value。

3)结构体或者类的自身对齐值:其成员中自身对齐值最大的那个值。

4)数据成员、结构体和类的有效对齐值:自身对齐值和指定对齐值中较小的那个值。


       有了这些值,我们就可以很方便的来讨论具体数据结构的成员和其自身的对齐方式。有效对齐值N是最终用来决定数据存放地址方式的值,最重要。有效对齐N,就是表示“对齐在N上”,也就是说该数据的"存放起始地址 % N = 0"(取余运算).而数据结构中的数据变量都是按定义的先后顺序来排放的。第一个数据变量的起始地址就是数据结构的起始地址。结构体的成员变量要对齐排放,结构体本身也要根据自身的有效对齐值圆整(就是结构体成员变量占用总长度需要是对结构体有效对齐值的整数倍,结合下面例子理解)。这样就不难理解上面的几个例子的值了。
例子分析:
分析例子B;
struct B {
    char b;
    int a;
    short c;
};
假设B从地址空间0x0000开始排放。该例子中没有定义指定对齐值,在笔者环境下,该值默认为4。第一个成员变量b的自身对齐值是1,比指定或者默认指定对齐值4小,所以其有效对齐值为1,所以其存放地址0x0000符合0x0000 % 1 = 0.第二个成员变量a,其自身对齐值为4,所以有效对齐值也为 4,所以只能存放在起始地址为0x0004到0x0007这四个连续的字节空间中,符合0x0004 % 4 = 0,且紧靠第一个变量。第三个变量c,自身对齐值为2,所以有效对齐值也是2,可以存放在0x0008到0x0009这两个字节空间中,符合0x0008 % 2 = 0。所以从0x0000到0x0009存放的都是B内容。再看数据结构B的自身对齐值为其变量中最大对齐值(这里是b)所以就是4,所以结构体的有效对齐值也是4。根据结构体圆整的要求, 0x0009到0x0000 = 10字节,(10+2)% 4 = 0。所以0x0000A到0x000B也为结构体B所占用。故B从0x0000到0x000B 共有12个字节,sizeof(struct B) = 12;

同理,分析上面例子C:
#pragma pack (2) /*指定按2字节对齐*/
struct C {
    char b;
    int a;
    short c;
};
#pragma pack () /*取消指定对齐,恢复缺省对齐*/
第一个变量b的自身对齐值为1,指定对齐值为2,所以,其有效对齐值为1,假设C从0x0000开始,那么b存放在0x0000,符合0x0000 % 1 = 0;第二个变量,自身对齐值为4,指定对齐值为2,所以有效对齐值为2,所以顺序存放在0x0002、0x0003、0x0004、0x0005四个连续字节中,符合0x0002 % 2 = 0。第三个变量c的自身对齐值为2,所以有效对齐值为2,顺序存放
在0x0006、0x0007中,符合0x0006 % 2 = 0。所以从0x0000到0x00007共八字节存放的是C的变量。又C的自身对齐值为4,所以C的有效对齐值为2。又8 % 2 = 0,C只占用0x0000到0x0007的八个字节。所以sizeof(struct C) = 8.

有 了以上的解释,相信你对C语言的字节对齐概念应该有了清楚的认识了吧。在网络程序中,掌握这个概念可是很重要的喔,在不同平台之间(比如在Windows 和Linux之间)传递2进制流(比如结构体),那么在这两个平台间必须要定义相同的对齐方式,不然莫名其妙的出了一些错,可是很难排查的哦^_^。            

阅读(三)

内存地址对齐,是一种在计算机内存中排列数据、访问数据的一种方式,包含了两种相互独立又相互关联的部分:基本数据对齐和结构体数据对齐。当今的计算机在计算机内存中读写数据时都是按字(word)大小块来进行操作的(在32位系统中,数据总线宽度为32,每次能读取4字节,地址总线宽度为32,因此最大的寻址空间为2^32=4GB,但是最低2位A[0],A[1]是不用于寻址,A[2-31]才能存储器相连,因此只能访问4的倍数地址空间,但是总的寻址空间还是2^30 * 字长 = 4GB,因此在内存中所有存放的基本类型数据的首地址的最低两位都是0,除结构体中的成员变量)。基本类型数据对齐就是数据在内存中的偏移地址必须等于一个字的倍数,按这种存储数据的方式,可以提升系统在读取数据时的性能。为了对齐数据,可能必须在上一个数据结束和下一个数据开始的地方插入一些没有用处字节,这就是结构体数据对齐。

举个例子,假设计算机的字大小为4个字节,因此变量在内存中的首地址都是满足4地址对齐,CPU只能对4的倍数的地址进行读取,而每次能读取4个字节大小的数据。假设有一个整型的数据a的首地址不是4的倍数(如下图所示),不妨设为0X00FFFFF3,则该整型数据存储在地址范围为0X00FFFFF3~0X00FFFFF6的存储空间中,而CPU每次只能对4的倍数内存地址进行读取,因此想读取a的数据,CPU要分别在0X00FFFFF0和0X00FFFFF4进行两次内存读取,而且还要对两次读取的数据进行处理才能得到a的数据,而一个程序的瓶颈往往不是CPU的速度,而是取决于内存的带宽,因为CPU得处理速度要远大于从内存中读取数据的速度,因此减少对内存空间的访问是提高程序性能的关键。从上例可以看出,采取内存地址对齐策略是提高程序性能的关键。

 

内存地址对齐 - Stupid Bastard - 学习者的博客

 

结构体(struct)是C语言中非常有用的用户自定义数据类型,而结构体类型的变量以及其各成员在内存中的又是怎样布局的呢?怎样对齐的呢?很显然结构体变量首地址必须是4字节对齐的,但是结构体的每个成员有各自默认的对齐方式,结构体中各成员在内存中出现的位置是随它们的声明顺序依次递增的,并且第一个成员的首地址等于整个结构体变量的首地址。下面列出了在Microsoft,Borland,GNU上对于X86架构32位系统的结构体成员各种类型的默认对齐方式。

char(1字节),1字节对齐

short(2字节),2字节对齐

int(4字节),4字节对齐

float(4字节),4字节对齐

double(8字节),Windows系统中8字节对齐,Linux系统中4字节对齐。

当结构体某一成员后面紧跟一个要求比较大的地址对齐成员时(例如char成员变量后面跟一个double成员变量),这时要插入一些没有实际意义的填充(Padding)。而且总的结构体大小必须为最大对齐的倍数。

下面是一个有char,int,short三种类型,4个成员组成的结构体,该结构体在还未编译之前是大小占8个字节。

struct AlignData{

char a;

short b;

int c;

char d;

};

编译之后,为了保持结构体中的每个成员都是按照各自的对齐,编译器会在一些成员之间插入一些padding,因此编译后得到如下的结构体:

struct AlignData {

char a;

char Padding0[1];

short b;

int c;

char d;

char Padding1[3];

};

编译后该结构体的大小为12个字节,最后一个成员d后面填充的字节数要使该结构体的总大小是其成员类型中拥有最大字节数的倍数(int拥有最大字节数),因此d后面要填充3个字节。下面举一些结构体例子来说明结构体的填充方式:

例子1:

struct struct1{

char a1;

char b1;

};

结构体struct1的大小为2字节,因为char在结构体中的默认对齐是1,因此在a1和b1之间没有数据填充,而且其成员中占用字节最大的类型为char,因此结构体结束处和b1之间也没有数据填充。

例子2:

struct struct2{

char a2;

short b2;

};

结构体struct2的大小为4字节,b2的是按2字节对齐,因此在b2于a2之间填充一个字节,而其成员中占用字节最大的类型为short,因此该结构体结束处和b2之间没有任何数据填充。

例子3:

Struct struct3{

double a3;

char b3;

};

结构体struct3的大小为16字节,因为b3是按1字节对齐,所以b3与a3之间没有数据填充,而其成员中占用字节最大的类型为double,在Windows平台下是8字节对齐,因此该结构体结束处和b3之间有7个字节的数据填充。

填充字节的大小和新的偏移地址有如下计算公式:

padding = (align - (offset mod align)) mod align

new offset = offset + ((align - (offset mod align)) mod align)

例如求成员a,b之间的填充字节,b的默认对齐为align=2个字节,b的未填充之前的偏移量offset=1,因此填充字节数padding=(2-(1 mod 2)) mod 2 = 1字节。如果要算接下来的成员之间的填充数,已经填充的字节也要算上,不然在算偏移量的时候会出错编译后的结构体比未编译之前多出了3个字节,有没有什么办法可以在保持各成员地址对齐的前提下,又能减少结构体的大小?必须有的!

如果把struct AlignData的成员顺序调整成如下形式:

struct AlignData{

char a;

char d;

short b;

int c;

};

那么编译后不用填充字节就能保持所有的成员都按各自默认的地址对齐。这样可以节约不少内存!一般的结构体成员按照默认对齐字节数递增或是递减的顺序排放,会使总的填充字节数最少。基本数据类型数组在内存中的布局并不是每个数组的元素都是按照4字节对齐的,但是数组的首地址必须是按照4字节对齐,而且每个元素之间没有填充,为什么没有填充呢?地址对齐和填充的目的是减少内存读取的次数,但现在只要数组的首地址按4字节对齐,任何小于等于4字节的类型数组(char, short, int)中的任意数组元素都能通过一次内存读取来获得(假设该数据没有加载到高速缓存),任何大于4字节类型数组(double)中的任意数组元素都能通过两次内存读取来获得任何大于4字节类型数组(double)中的任意数组元素都能通过两次内存读取来获得。因此要求每个数组元素都是按照4字节对齐是没有必要,浪费空间的。

结构体数组在内存中的布局,只要保持结构体数组的首地址是按照4字节对齐,而且每个数组元素同样也不必按照4字节地址对齐,就能尽量使内存的读取次数降到最低,因为只要每个结构体元素自己内部的填充和对齐都是上述的方式,那么同样也能达到既能减少内存访问的次数,又能节约不必要的内存浪费。但是有人会有这样的疑问,既然每个结构体首地址按照4字节对齐,为什么结构体内部每种数据类型还要各自默认的对齐大小进行对齐?其实其目的同样也是减少内存访问的次数,因为结构体是用户自定义的类型,内部还是由一些基本数据类型组成的!以上的对齐方式都是Windows默认的对齐方式,用户可以根据需求来设置自己的对齐方式,特别是在一些内存受限的系统中,内存比速度更重要!但是建议用户还是不要轻易来设置自己的对齐方式,如果用得不恰当的话,可能会造成大量冗余的内存读取,而且可能会出现不兼容的问题。可以用#pragma pack指令来对其进行设置。由内存地址对齐而引发的对减少内存访问次数的思考当今的CPU的处理速度远比内存访问的速度快,程序的执行速度的瓶颈往往不是CPU的处理速度不够,而是内存访问的延迟,虽然当今CPU中加入了高速缓存用来掩盖内存访问的延迟,但是如果高密集的内存访问,一种延迟是无可避免的。内存地址对齐给程序带来了很大的性能提升,在windows等系统了,编译器都提供了自动地址对齐,给程序员带来了很大的方便。但是减少对内存访问还是值得探讨的问题。

调整结构体成员变量的布局是减少内存访问次数的途径之一。下面分别介绍两种不同的结构体数据成员调整方案,都能得到很好的性能提升。

1.       按成员内存对齐大小按升序或是降序排序,减少结构体的大小。看如下两个结构体:

struct BeforeAdjust{

char a;

short b;

int c;

char d;

};

 

struct AfterAdjust{

char a;

char d;

short b;

int c;

};

从表面上看结构体BeforeAdjust和AfterAdjust成员都一样,就是成员布置的顺序有差异,因此造成了这两种类型数据占据空间大小有所不同,BeforeAdjust大小占12个字节,AfterAdjust大小占8个字节,因此从读取一个BeforeAdjust类型的数据要进行3次内存读取操作,而AfterAdjust类型的数据要进行2次内存读取操作。下面我分别对大小为1000万的这两种结构体的动态数组进行初始化,然后依次读取数组数据对每个数据成员做求和操作,得到的测试时间如下表。

内存地址对齐 - Stupid Bastard - 学习者的博客

 

从上面的测试数据可以看出,同样的数据成员,就是因为摆放的顺序不同而造成性能有28.127%的差异。因此调整好结构体内的数据成员的摆放顺序既可以减少内存的使用,又可以提高程序的性能。

2. 把一些字节数占用比较少的成员合并到字节数占用大的成员。首先看如下两个结构体:

struct UnMergeMember{

int a;

int b;

char c;

};

 

struct MergeMember{

int a;

union{

int b;

char c;};

};

UnMergeMember结构体由三个成员变量a,b,c,分别是int,int,char类型,按照地址对齐的规则,该结构体占用12个字节。因此初始化UnMergeMember类型变量涉及到3次内存读操作,3次赋值操作,3次内存写操作。MergeMember结构体由一个int类型的成员和一个联合体变量组成,按照地址对齐规则,该结构体占用8个字节。

联合体union{int b; char c;}占用4个字节,高位3个字节保存变量b(前提是用3字节能足够表示b的数据范围),最低位1个字节保存变量c。假设定义一个MergeMember类型的变量为merge,初始化每个成员变量如下:

merge.a = some integer;

merge.b = some integer;

merge.b <<= 8;

merge.c = some char;

初始化一个MergeMember类型的数据只涉及到2次的内存读操作、3次赋值操作、1次位移操作,2次内存写操作。从上述可以看出初始化一个UnMergeMember类型的变量比MergeMember类型变量多了1次读操作和写操作,少了1次位移操作。下面我分别对大小为1000万的这两种结构体的动态数组进行初始化,然后依次读取数组数据对每个数据成员做求和操作,得到的测试时间如下表。

内存地址对齐 - Stupid Bastard - 学习者的博客

 

从上面的测试数据可以看出,在结构体中把小数据归并到大数据可以减少内存读取的次数,虽然多了一些CPU的操作,但是用CPU的操作换取内存数据读取次数,程序性能肯定能得到提高。上面的测试程序可以得到43.5%的性能提高(基本等于内存读取次数减少比(6-4)/4=50%),在对性能要求特别高的系统中,这么大幅度的性能是相当可观。上述的例子也可以通过位段实现(Bit-fields),但是位段只能对整数进行操作,如果把浮点数于和int类型的数据放在一起用位段实现显然不行,但是通过位移的方法也可以把char类型的数据并入浮点数float或是double中。

3. 通过位段(Bit-fields)的方式把一些整形数据按照各自需求的字段数来分配。这种方式可以大大节省空间,TCP协议的首部的定义就是采用位段的方式来定义的。位段的使用比较简单,这里我就不赘述了,可以参考相关的资料。但是值得注意的是,使用位段的方式的对齐方式也要遵守上述结构体对齐的方式,看下面一些结构体以及相应的大小:

struct BitField1{

char a:1;

char b:2;

char c:3;

char d:2;

};

 

struct BitField2{

char a:1;

char b:2;

char c:3;

char d:2;

int e:4;

};

Sizeof(BitField1)等于1(char大小的倍数),sizeof(BitField2)等于8(int大小的倍数)。

                    

转载于:https://my.oschina.net/chaenomeles/blog/673091

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值