2. 数据的内部表示法
2.1 比特、字节和字
从本质上讲,计算机中所有的数值都是按照被称为比特的基本单元的形式存储的。
1个比特(bit)精确记录一个数值,它可能取两种状态中的一个。
如果把机器中的电路当成一个小的开关,那么可以把这两种状态标识为断开和闭合。
单词bit实际上是binary digit的缩写,因此把这两种状态用0和1表示就很顺理成章。
0和1这两个数字是计算机运算所基于的二进制系统所用的两个数字。
因为1个比特包含的信息量极为有限,所以比特本身并没有为存储数据提供一个很方便的机制。
为了方便地存储整型和字符型等常见类型的信息,将若干个独立的比特组成一个较大的单元,作为完整的存储单元。
这种最小的组合单位被称为字节(byte),它足以容纳一个需要8个比特的字符。
在大多数计算机中,字节也可以组成更大一些的称为字(word) 的结构,一个字的大小通常要求能够容纳一个整型数据。
有些计算机用两个字节组成字,有些计算机用四个字节组成一个字,也有很少的计算机的规定十分特殊。
2.2 内存地址
在存储系统中,每个字节都会有一个数字的地址(address) 。
一般来说,第一个字节在计算机中的地址为0,第二个字节的地址为1,依此类推,直到机器中所含有的字节数为止。
例如,可以用下图表示64KB的计算机的内存:
![](https://i-blog.csdnimg.cn/blog_migrate/b5cd42173063ef69c0a770a55565bc8d.png)
内存中的每一个字节可以容纳一个字符的信息。
例如,如果声明一个字符变量ch
,编译器会在当前的函数帧中为这个变量保留一个字节的空间。
假设这个字节的地址为1000。如果程序执行语句:
ch = 'A';
字符 ‘A’ 的内部表示就会被存储在地址为1000的单元中。由于 ‘A’ 的ASCII码是65,因此内存的分配结果如下图a所示:
![](https://i-blog.csdnimg.cn/blog_migrate/fae9880d5c8ae34a11bec5956ea97465.png)
在大多数程序设计的应用中,当程序调用函数时,函数中的变量被分配到内存中的某个位置,但是不能提前知道这些变量的地址。
比一个字符大的数值放在内存中连续的字节单元中。
例如,如果在一台计算机中一个整型变量占两个字节,那么这个整型变量就需要两个字节的连续的内存单元,因此它存放在下图b所示的阴影部分中:
![](https://i-blog.csdnimg.cn/blog_migrate/3d6c7e14882d49d6f24ac05c2ae47add.png)
需要多个字节的数值用第一个字节的地址标识,所以图b阴影部分所存放的整型数值是存储在地址为1000的字。
2.3 运算符sizeof
除了字符型数据在所有的计算机中被定义成一个字节的长度之外,其余类型的数据在计算机中存储所需要的字节数,对于不同的计算机是不同的。
一般来说,C语言编译器的设计者会选择对计算机最有效的数据存储长度。
在编写C语言程序时,可以使用运算符sizeof
了解一个变量占用了多少内存空间。
sizeof
只有一个操作数,这个操作数必须是一个放在圆括号内的类型名或是一个表达式。
如果操作数是一个类型,那么sizeof
运算符将会返回存储这个类型的数据所需要的内存字节数;
如果操作数是一个表达式,那么sizeof
运算符将会返回存储这个表达式的值所需要的内存字节数。
例如,表达式
sizeof(int)
将返回存储一个整型数据所需要的字节数。
表达式
sizeof(x)
将返回存储变量x所需要的字节数。
2.4 变量的内存分配
当在程序中声明一个变量时,编译器会给变量预留内存空间。这种预留内存空间的过程称作分配(allocation)。
全局变量在程序开始执行时分配,直到程序执行完之后才会释放内存空间;
局部变量只有当函数调用时才会分配内存空间。
变量本身在分配给这个函数的存储空间中分配,函数的存储空间称作函数的帧(frame)。
只要函数在运行,局部变量在帧中的地址始终是不变的。当函数返回时,帧以及它的所有变量都被丢弃,以便让别的函数使用。
如果声明了一个简单变量,编译器会为这个变量预留其需要的内存空间,即sizeof
的返回值。
如果声明了一个数组变量,编译器会为数组元素预留一段连续的内存空间保存组成数组的所有元素的值。
根据元素的类型,数组的每个元素可以需要1个或几个字节的内存空间。
例如,变量声明:
char charArray[20];
将会预留20个字节的内存空间,因为每一个字符仅占一个字节。
假设NJudges
设为5,数组中每个元素都是double
型,需要八个字节的内存空间。用下列语句声明的数组scores
:
double scores[NJudges];
需要40字节。
如果帧在内存中的开始地址是1000,数组元素的存放情况如下图所示:
![](https://i-blog.csdnimg.cn/blog_migrate/980a969748d276b01d581a5f6b0642f9.png)
0号元素将存放在1000~1007,1号元素将存放在1008~1015,依此类推。
当C语言编译器遇到一个选择表达式:
scores[i]
它通过把下标值和每个元素的大小相乘再加上数组的首地址,来计算出数组元素的地址。
在上图中,元素的大小是8,数组的首地址是1000。如果i
的大小为2,那么scores[i]
地址就为
2×8+1000
1016。
在这种情况下,数组元素的首地址(就是1000)也叫做数组的基地址(base address),而找到正确元素所需的调整量(就是2×8,即16)叫做偏移量(offset)。
2.5 引用超出数组范围的元素
上图中,数组scores
有五个合法的下标值:0、1、2、3和4。
如果选择一个下标值超出这个范围的元素,如i的值为5。在这种情况下,C语言会如何处理呢?
在很多情况下,程序会照常运行,没有错误的反馈信息。
在大多数系统中,程序通过把偏移量加上数组的基地址计算出选择表达式的值。而偏移量本身又是通过将元素的序号乘以元素的大小而得出的。
因此,在这个例子中,C编译器将会选择存储在地址 5×8+1000=1040 的数值。然而,地址1040的内容都与数组scores
无关。
当程序有一些无法说明的错误行为时,应该怀疑是否数组选择超出了范围。
一个调试程序的技巧就是在引用数组元素前,先输出元素的下标值。当发现并解决了问题之后,就可以把这一行输出语句去掉。
然而,在某些情况下,最安全的方法是加上一些条件测试语句,以检测数组元素的下标是否超出范围。
在计算scores[i]
之前, 可以加上如下语句:
if (i<0 || i >= NJudges) {
fprintf(stderr, "Index x (value %d) is out of bounds.\n", i);
另一方面,为下标操作加上上述的条件测试语句,会增加程序的长度和复杂性。
一般来说,当认为数组元素的下标值很有可能超出范围时,才应该加上一些测试语句。
例如,检测下面的for循环语句中的i
值是不必要的:
for (i = 0; i < NJudges; i++)
for循环语句本身就限制数值i
不会超出范围。
然而,如果使用一个作为参数传递的下标值,就要特别小心。在这种情况下,不太容易控制此下标值的大小,因为它来自程序的另外的部分。
参考
《C语言的科学和艺术》 —— 第11章 数组