C语言——数据在内存中的存储

本章我们将要介绍的是数据在C语言中是如何存储在内存中的,数据在内存中的存储是一个重点,它能更加地帮助我们从更深的层次去理解之前的很多知识点,让你明白这些知识点为什么是这样,而不是停留在因为别人告诉你是这样所以它就是这样的阶段。

下面就让我们开始本章的学习吧!

目录

2. 大小端字节序和字节序判断

2.0 引入

2.1 什么是大小端

2.2 为什么有大小端

2.3 练习 

2.3.1 练习1

思路:

源代码:

代码解析:

2.3.2 练习2

源代码:

解析:

知识点补充:

2.3.3 练习3

源代码:

​​​​​​​解析:

2.3.4 练习4

源代码:

解析: 

2.3.5 练习5

源代码:

解析: 

2.3.6 练习6

源代码:

解析:

2.4 小结

3. 浮点数在内存中的存储

3.1 引入

3.2 浮点数的存储

3.2.1 浮点数存的过程

3.2.2 浮点数取的过程 

3.3 题目解析

4.总结


1. 整数在内存中的存储

整数的2进制表示方法有三种,即原码、反码和补码 三种表示方法均有符号位和数值位两部分,符号位都是用0表示“正”,用1表示“负”,而数值位最高位的⼀位是被当做符号位剩余的都是数值位

正整数的原、反、补码都相同。

负整数的三种表示方法各不相同:

  • 原码:直接将数值按照正负数的形式翻译成⼆进制得到的就是原码。
  • 反码:将原码的符号位不变,其他位依次按位取反就可以得到反码。
  • 补码:反码+1就得到补码。 对于整形来说:数据存放内存中其实存放的是补码。 

对于整形来说:数据存放内存中其实存放的是补码。 

有没有同学好奇过,这是为什么呢?

这是因为啊,在计算机系统中,数值⼀律用补码来表示和存储。

原因在于,使用补码,可以将符号位和数值域统⼀处理; 同时,加法和减法也可以统⼀处理(CPU只有加法器)此外,补码与原码相互转换,其运算过程是相同的,不需要额外的硬件电路。

上面讲到的内容都是非常重要的,他将贯彻我们本章内容的学习,特别是最后这个——对于整形来说:数据存放内存中其实存放的是补码。

2. 大小端字节序和字节序判断

2.0 引入

可能大伙在看到标题这个大小端字节序会有点懵懵,这又是什么新的东西?别急,让我们给大家看个东西先

我们设置一个int类型(大小4个字节)的变量a,并将0x11223344赋值给a

int main()
{
	int a = 0x11223344;

	return 0;
}

调试的时候,我们可以看到在a中的 0x11223344 这个数字是按照字节为单位,倒着存储的。

4d22fb4449ee430bb400ac6da4df9657.png

(关于0x11223344的一些拓展解释)

ec963673cb1d4235a8c323395b861a72.png

总的来说就是:

  1. 整数在内存中储存的是二进制的补码
  2. 可能有同学会有疑问,难道a在内存中储存的就是11223344吗?在调试窗口中观察内存的时候,为了方便展示,显示的是16进制的值 。相当于我们投其所好写了一个16进制的数,方便在调试窗口观察,实际上在内存里还是由01组成的数字串。
  3. 储存的顺序是倒过来的

那为什么会是倒着存储的呢?为了解开这个疑问,接下来就请我们本part的主角登场了。

2.1 什么是大小端

在超过⼀个字节的数据在内存中存储的时候,就有存储顺序的问题,

按照不同的存储顺序,我们分为大端字节序存储和小端字节序存储,

下⾯是具体的概念:

  • 大端(存储)模式:是指数据的低位字节内容保存在内存的高地址处,而数据的高位字节内容,保存在内存的低地址处。
  • 小端(存储)模式:是指数据的低位字节内容保存在内存的低地址处,而数据的高位字节内容,保存在内存的高地址处。

上述概念需要记住,⽅便分辨大小端。 

(一些概念补充)

430a102092cb4a0da64e805a752a407c.png

55026b3784e44275b0fdb5200573d77b.png

2.2 为什么有大小端

为什么会有⼤⼩端模式之分呢?

这是因为在计算机系统中,我们是以字节为单位的,每个地址单元都对应着⼀个字节,⼀个字节为8 bit 位,但是在C语⾔中除了8 bit 的 char (大小为1个字节)之外,还有16 bit 的 short 型(大小为2个字节),32 bit 的 long 型(大小为4个字节)(要看具体的编译器),另外,对于位数⼤于8位的处理器,例如16位或者32位的处理器,由于寄存器宽度大于⼀个字节,那么必然存在着⼀个如何将多个字节安排的问题。因此就导致了⼤端存储模式和小端存储模式。

例如:⼀个 16bit 的 short 型 x ,在内存中的地址为 0x0010 , x 的值为 0x1122 ,那么 0x11 为⾼字节, 0x22 为低字节。对于⼤端模式,就将 0x11 放在低地址中,即 0x0010 中, 0x22 放在高地址中,即 0x0011中。小端模式,刚好相反。我们常⽤的 X86 结构是小端模式,⽽ KEIL C51 (单片机)则为大端模式。很多的ARM,DSP都为小端模式。有些ARM处理器还可以由硬件来选择是 ⼤端模式还是小端模式。

简单来讲就是:

  • 大小端字节序的存储模式都是合理的,它主要取决于各个开发者自己的喜好。
  • 存储的数据的顺序是客观存在的,在内存中它是以什么顺序存放也取决于开发者自己的喜好,只要最后能从内存中提取出正确顺序的数据即可。只是大小端的方式更加简单高效易理解,被广泛使用而已。
  • 由于大小端的存在,不同机器和软件使用的字节序存储方式不同,因此有时候我们在转移或使用数据时要注意下(大小端)字节序存储方式是否相同。因为即使是相同的数据,采用不同的字节序存储方式,最后读取出来的内容是不一样的。

2.3 练习 

让我们通过一些练习来加深本章讲解的知识点

2.3.1 练习1

设计⼀个小函数来判断当前机器的字节序。

思路:
  • 通过前面的大小端板块的学习,我们可以从数据的高低位保存的位置入手。,
  • 以1为例,在内存中的以 0x00 00 00 01的形式(这里为了方笔用16进制表示实际上在内存中以32位的形式)存储,
  • 我们可以通过 01 的所处的位置来判断是大端字节序还是小端字节序。( 01 存放在低地址处是小端;01 存放在高地址处是大端)

源代码:
#include <stdio.h>
int check_sys()
{
 int i = 1;
 return (*(char *)&i);
}
int main()
{
 int ret = check_sys();
 if(ret == 1)
 {
 printf("⼩端\n");
 }
 else
 {
 printf("⼤端\n");
 }
 return 0;
}

5e16e109b51c4895b1924540990bb473.png

代码解析:
  • 我们通过设置一个int 类型的变量 i ,并将1赋值给i
  •  如果是小端模式,则存储方式为0x01 00 00 00;                                                                     如果是大端模式,则存储方式为0x00 00 00 01,
  • 所以我们只要取出高位的第一个字节便可得知是大端还是小端,因此我们取出i的地址并强制类型转换成char*类型,然后再解引用(因为char*指针类型解引用一次只能访问一个字节,刚好满足我们的需求)
  • 最后将得到值返回给主函数,是1即是小端,是0就是大端。

在这里可能同学会有自己的想法,就是在强制类型转换的时候,我们是否可以先将 i 强制类型转换成char类型,然后将得到的值赋给一个char类型的变量 n (即 char n = (char)i  ),最后通过 n的值来判断大小端。

注意这是不可行的,这是因为不管大端还是小端,强制类型转换总是拿到我们要存储的数据的最后一个字节,也就是最低位的那个字节。所以无论大小端,n 存储的数据都是01这个字节,因此无法判断到底是小端还是大端。

2.3.2 练习2

源代码:
#include <stdio.h>
int main()
{
 char a= -1;
 signed char b=-1;
 unsigned char c=-1;
 printf("a=%d,b=%d,c=%d",a,b,c);
 return 0;
}

不知道大家看到这些代码,特别是一些基础比较好的比较机灵的同学,会不会比较疑惑,unsigned char 不是无符号类型吗?为什么可以存储一个负数 -1 呢?而且 printf 打印格式化用的还是整型打印 %d ,明明上面都是 char 类型啊。

解析:

接下里就让我来解惑:

  1. 首先我们明确一点 —— -1 是客观存在的数据,就像我们把一个正方形物体放进一个三角形容器里面,你不能说因为正方形物体与三角形容器不匹配,所以正方形物体就不存在,就是错误的吧?
  2. 那为什么我们说不能存储 -1 ,其实是因为 -1 存储进去后,你再解读出的值很可能就不是 -1 了,但 -1 仍然还是 -1 ,不变的。正方形物体可不可以放进三角形容具里?你如果一定要硬塞进去,或是还是可以的。但是此时容器里面的物体还会是正方体吗?应该不是了,但物体还是原来那个物体,只是形状不同罢了

好,在了解这些后,我们再回过头来看我们的题目

  1. 首先 -1 的二进制值要如何表示?是11111111111111111111111111111111对吧,一共32个比特位(整形数据的大小是4个字节),
  2. 然后我们将 -1 存进 a 中,因为 char 类型的大小只有一个字节,所以 a = 11111111,(8个1,一个字节八个比特位)
  3. a 、b 、c 均是如此,存放的时候可不会管那么多,全部都是 -1 ,至于对不同的类型来说这些 1 最后会被解读成什么,这可不该存储数据的管
  4. 来到打印环节,我们先从 b 入手,因为 b 是 signed char 类型,那么内存中的最高位就会被当作是符号位,可以存放负数,所以 b  =  -1;
  5. 重点讲解c:因为 c 是无符号变量,所以 c 没有符号位,全都是数值位,根据二进制计算最终得出 c = 255.
  6. 最后是 a ,这涉及到 char 到底是等同于 signed char 还是 unsigned char ,这取决于编译器。在vs2022中,char = signed char 。所以 a = -1。

让我们运行来验证一下:

fdf67e5fd40846f897f97cade9bc486a.png

完全符合。

知识点补充:

其实说到这里就在做一个补充,char 数据在内存中是怎么存储的

ad731499b6154bf3a9ec5bcc2119b416.png

*取反就是符号位不变,数值位按位取反

上图其实就解释了为什么 signed char 的取值范围是 -1 ~ 127 ,而我们也就可以以此类推出 unsigned char 的取值范围是  0 ~255 。当最高位的符号位也变成数值位,那么内存中的补码就是原码本身了,并不用取反+1了,这时候 1000 0000 根据二进制算法就是 2 ^ 7 = 128 了,接下去的数据也是如此。

2.3.3 练习3

请大家看看下面的 a 和 b 的值打印出来是多少 

源代码:
int main()
{
 char a = -128;
 printf("%u\n",a);

 char b = 128;
 printf("%u\n",b);

 return 0;
}

温馨提示一下,这里的 %u 是打印无符号,它会认为 a 中存放的是无符号数。

让我们先来揭开答案,然后再来分析:

b956605bfc5b4b1ba424dbb96396af47.png

结果是不是让大家有点大跌眼镜,不敢相信。为什么答案会是这样呢?

​​​​​​​​​​​​​​解析:
  1.  首先我们先写出 -128 的原码 —— 1000 0000 0000 0000 0000 0000 1000  0000
  2. 然后符号位不变 ,数值位取反 —— 1111 1111 1111 1111 1111 1111 0111 11111
  3. 接着再 + 1得到补码 —— 1111 1111 1111 1111 1111 1111 1000 0000
  4. 因为 a 是 char 类型,所以只取最后的八个比特位,即 a = 1000 0000
  5. 又因为 %u 的形式打印,把 a 当作无符号数 ,所以进行整形提升时用 0 补齐,得          a = 0000 0000 0000 0000 0000 0000 1000 0000
  6. 然后进行按位取反在 + 1的操作,得1111 1111 1111 1111 1111 1111 1000 0000
  7. 我们把得到的二进制数字串放到计算器中 求出它的十进制数9a10469155774da29573bee96feeb35f.png我们发现它跟我们我们编译器输出的结果如出一折

b也是同样的

  1. 我们先写出 128 的二进制数字串 —— 0000 0000 0000 0000 0000 0000 1000  0000     b 跟 a 的区别就是正数的补码跟原码是一样的,且他的字符位为0,
  2. 同样的将最后的八个比特位,即 1000  0000  放到 b 中,
  3. 同样的 %u 的形式打印,补码先整型提升,用 0 补齐得到——0000 0000 0000 0000 0000 0000 1000 0000
  4. 按位取反再 + 1,最后会发现依然是得到 ——​​​​​​                                111 1111 1111 1111 1111 1111 1000 0000
  5. 所以 a 和 b 的最后值一样。

 

2.3.4 练习4

源代码:
#include <stdio.h>
int main()
{
 char a[1000];
 int i;
 for(i=0; i<1000; i++)
 {
   a[i] = -1-i;
 }
 printf("%d",strlen(a));
 return 0;
}

解析: 

这道题创建了一个 char 类型的字符数组 a ,然后要我们求 a 的长度。

因为 strlen 函数遇到 “\0” 便停下来,而 "\0" 的ASCII码值是 0 ,所以这道题就变成求经过多少个 a 的第几个元素上是 0 。

细心的同学不知道有没有发现,在 for 循环里面,随着 i 的值的增大,a[ i ] 的值会超过 -128 。那 -128 之后又会是什么值呢? 是 -129 还是其他数值呢?我们接着讲。

我们假设这里有一个圆,圆上各点对应一个二进制数字串

77d3411c255649c1af60028844982c29.png

我们看出,当 -128 减到 -1 时,再减下去它会来到 0111 1111 ,也就是 127 ,如果真是这样的话,那么理论上 a 的长度就会是一整个圆的长度,也就是 128 + 127 共 255个数。

我们开始验证

a44750eff6db46bbb6d179ca896a2d17.png

结果正确,也就证明我们的假设与推断是正确的,for 循环内部的运行就是这样 ,进行着循环。

2.3.5 练习5

接下里请大家试着写出下列这组代码的输出结果:

源代码:
int main()
{
 unsigned char i = 0;
 for(i = 0;i<=255;i++)
 {
 printf("hello world\n");
 }
 return 0;
}

int main()
{
    unsigned char i = 0;
     for(i = 9;i>=0;i--)
     {
         printf("%u\n",i);
     }
     return 0;
}

解析: 

相信有了练习四的学习,这道题应该就很容易了,这两道题其实也是再次验证我们那个圆,就是char 类型数据的循环的正确性。

  • 先说第一个代码, i 是 unsigned char 无符号类型,其实跟 signed char 类型的循环是一样的,只不过后者的范围是 -128 ~ 127 ;而前者是 0 ~ 255,所以当 i++ 到 255 后,下一个数便是 0 ,开始新一轮循环。所以第一个代码的输出结果是死循环。
  • 第二个代码也同样,i 是 unsigned char 无符号类型,当 i-- 到 0 后,下一个数便是 255 ,待 255 自减到 0 后便开始新一轮循环。所以第二个代码同样是死循环。

2.3.6 练习6

来到我们最后一道题,依然还是写出输出结果。这道题稍稍有点难度

源代码:
#include <stdio.h>
//X86环境(必须是X86环境,X64会报错),小端字节序

int main()
{
 int a[4] = { 1, 2, 3, 4 };
 int *ptr1 = (int *)(&a + 1);
 int *ptr2 = (int *)((int)a + 1);
 printf("%x,%x", ptr1[-1], *ptr2);//%x是以16进制的形式打印的意思
 return 0;
}

让我们先揭晓答案,然后再来解析答案

896ecbe209674b3e8b612876ac70c3d7.png不知道屏幕前的你是否做对了?、

解析:
  • 首先这道题需要结合我们先前学习的指针的相关知识,我们先取数组 a 的地址再 +1,这是跳过整个数组的长度,也就是说指针 ptr1 指向的是数组a的元素4后面的地址。0fd47a2952a74bad8652a0b563396f9c.png(这里为了方便显示,我们采用16进制显示)                                                                       
  • 接着是 a ,我们经过调试发现:
  1.  ptr2 的地址比 a 多了一 ,而这个一正是我们 a + 1而来的。f83ba1b11e234f698312e7452ff6cfe0.png
  2. 必须要强调的是,也是必须要弄清楚的一点就是 a 前面的这个强制类型转换( int ),它相当于把 a 的地址 0x00ffb7c 强制转换成一个整形 ,即 0x00ffb7c 不再是地址了,而是一个十六进制的整形数据,跟 1 、2 、1000 这些整型数据是一样的,只是它是以十六进制显示的而已,所以这时候的 +1 就是单纯的 +1。
  3. 最后它又被强制类型转换成 int* ,又重新变回指针了,而我们 a 的地址即使数组的指针,也是首元素的地址,而加 1 后也就是指向了 a + 1后的地址。如图下图所示:129d0f5b319943c1ac1d9eccd37d7375.png此时 ptr2 指向的就是 01 后面的 00 ,以 00 为起点,重新填满一个新的数组,此时数组的元素就会发生改变。所以这便是我们要强调是小端字节序还是大端字节序的原因,不同的字节序带来的不同的排序会导致数组的元素不同。

2.4 小结

不i知道经过上面几道题的练习与讲解,大家对这些数据在内存存储是否有了些些醍醐灌顶的感觉。其实这些题目是为了出题而出题,主要目的是为了让大家更清晰地去了解其内部的运行与背后的逻辑,平时并不提倡大家这么写代码,该遵守的规范与语法我们还是得遵守的。

3. 浮点数在内存中的存储

常⻅的浮点数:3.14159、1E10等,浮点数家族包括: float、double、long double 类型。 浮点数表⽰的范围: float.h 中定义。整数表⽰的范围:limist.h 中定义。

3.1 引入

再开始介绍之前,让我们先来看有一道题目:

#include <stdio.h>
int main()
{
 int n = 9;
 float *pFloat = (float *)&n;
 printf("n的值为:%d\n",n);
 printf("*pFloat的值为:%f\n",*pFloat);
 *pFloat = 9.0;
 printf("num的值为:%d\n",n);
 printf("*pFloat的值为:%f\n",*pFloat);
 return 0;
}

问输出的是什么呢? 

老规矩现公布答案

c042b286e1434135ba027dde0e42f623.png

是不是很出乎意料?我们先不讲详细,这道题算是先埋下的一个伏笔。我们只需先知道一点即可,那就是

浮点数和整数在计算机中存储的方式不同

3.2 浮点数的存储

上⾯的代码中, num 和 *pFloat 在内存中明明是同⼀个数,为什么浮点数和整数的解读结果会差别 这么⼤?

要理解这个结果,⼀定要搞懂浮点数在计算机内部的表⽰⽅法。

根据国际标准IEEE(电气和电子工程协会) 754,任意⼀个⼆进制浮点数V可以表示成下⾯的形式:

V   =  (−1) ∗ S M ∗ 2E

  • (−1)S 表示符号位,当S=0,V为正数;当S=1,V为负数
  • M 表示有效数字,M是大于等于1,小于2的数。
  • eq?2%5E%7BE%7D指数位 

所以浮点数的存储其实存储的十S、M、E

举例来说: ⼗进制的5.0,写成⼆进制是 101.0 ,相当于 1.01×2^2 。(用 2 做底数是因为这是二进制数。如果此时是十进制的1234,则是 1.234 * 10 ^ 3 ,以十为底数)

那么,按照上⾯V的格式,可以得出S=0,M=1.01,E=2。

⼗进制的-5.0,写成⼆进制是 -101.0 ,相当于 -1.01×2^2 。那么,S=1,M=1.01,E=2。

IEEE 754规定: 对于32位的浮点数,最⾼的1位存储符号位S,接着的8位存储指数E,剩下的23位存储有效数字M ,

对于64位的浮点数,最⾼的1位存储符号位S,接着的11位存储指数E,剩下的52位存储有效数字M。

3338a027d2984847b2e33fcab183b13d.png

3.2.1 浮点数存的过程

IEEE 754 对有效数字M和指数E,还有⼀些特别规定。

前⾯说过, 1≤M<2 ,也就是说,M可以写成 1.xxxxxx 的形式,其中 xxxxxx 表⽰⼩数部分。 IEEE 754 规定,在计算机内部保存M时,默认这个数的第⼀位总是1,因此可以被舍去,只保存后⾯的 xxxxxx部分。⽐如保存1.01的时候,只保存01,等到读取的时候,再把第⼀位的1加上去。这样做的⽬ 的,是节省1位有效数字。以32位浮点数为例,留给M只有23位,将第⼀位的1舍去以后,等于可以保 存24位有效数字。

至于指数E,情况就⽐较复杂

首先,E为⼀个⽆符号整数(unsigned int)

这意味着,如果E为8位,它的取值范围为0~255;如果E为11位,它的取值范围为0~2047。但是,我 们知道,科学计数法中的E是可以出现负数的,所以IEEE 754规定,存⼊内存时E的真实值必须再加上 ⼀个中间数,对于8位的E,这个中间数是127;对于11位的E,这个中间数是1023。⽐如,2^10的E是 10,所以保存成32位浮点数时,必须保存成10+127=137,即10001001。

3.2.2 浮点数取的过程 

指数E从内存中取出还可以再分成三种情况:

E不全为0或不全为1

这时,浮点数就采⽤下⾯的规则表⽰,即指数E的计算值减去127(或1023),得到真实值,再将有效 数字M前加上第⼀位的1。 ⽐如:0.5 的⼆进制形式为0.1,由于规定正数部分必须为1,即将⼩数点右移1位,则为1.0*2^(-1),其 阶码为-1+127(中间值)=126,表⽰为01111110,⽽尾数1.0去掉整数部分为0,补⻬0到23位 00000000000000000000000,则其⼆进制表⽰形式为:

0 01111110 00000000000000000000000

用图例表示就是

89947ac2fa944f2ead3959217a9cecaa.png

E全为0 

这时,浮点数的指数E等于1-127(或者1-1023)即为真实值,有效数字M不再加上第⼀位的1,⽽是还 原为0.xxxxxx的小数。这样做是为了表示±0,以及接近于0的很小的数字。

0 00000000 00100000000000000000000

E全为1 

这时,如果有效数字M全为0,表⽰±⽆穷⼤(正负取决于符号位s);

0 11111111 00010000000000000000000

好了,关于浮点数的表⽰规则,就说到这⾥。 

3.3 题目解析

OK,讲了这么多,是时候让我们收回时间线了,让我们回到引入的题目,不过相信不少人现在心里也已经有自己的答案了。

先看第1环节,为什么 9 还原成浮点数,就成了 0.000000?

9以整型的形式存储在内存中,得到如下⼆进制序列:

0000 0000 0000 0000 0000 0000 0000 1001

⾸先,将 9 的⼆进制序列按照浮点数的形式拆分,得到第⼀位符号位s=0,后⾯8位的指数 E=00000000 ,

最后23位的有效数字M=000 0000 0000 0000 0000 1001。

由于指数E全为0,所以符合E为全0的情况。因此,浮点数V就写成:

V=(-1)^0 × 0.00000000000000000001001×2^(-126)=1.001×2^(-146) 

显然,V是⼀个很⼩的接近于0的正数,所以⽤⼗进制⼩数表⽰就是0.000000。

再看第2环节,浮点数9.0,为什么整数打印是 1091567616?

⾸先,浮点数9.0 等于⼆进制的1001.0,即换算成科学计数法是:1.001×2^3 

所以: 9.0  =  eq?-1%5E%7B0%7D *  (1.001)  ∗  eq?2%5E%7B3%7D , 

那么,第⼀位的符号位S=0,有效数字M等于001后⾯再加20个0,凑满23位,指数E等于3+127=130, 即10000010,

所以,写成⼆进制形式,应该是S+E+M,即

0 10000010 001 0000 0000 0000 0000 0000

 这个32位的⼆进制数,被当做整数来解析的时候,就是整数在内存中的补码,原码正是 1091567616/

4.总结

本章主要讲了各种数据在内存中是如何存储的,进一步加深我们对各种数据类型的理解。关于浮点数的一些知识点和规则我们记住怎么用就行,不必去深究太多为什么,规则是别人制定的,现阶段的我们学会去遵守就行了,假以时日我们自己足够强大成了权威,那时候我们才有底气与能力去制定规则。

最后感谢每一个看到这里的人。有任何不足请多多包涵并希望您能在评论区不吝提出。谢谢。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值