数据的存储与表示 —— 所见非所得

C 语言温故而知新(一)

Posted by Shao Guoji on May 9, 2017

系列文章目录

数据的存储与表示 —— 所见非所得 - C 语言温故而知新(一)

用 “%d\n” 要多读一个数? —— scanf() 函数的那些坑 - C 语言温故而知新(二)



为什么要写这个系列?

很多人都觉得 C 语言好学,其实并不然。(你可以看看《C语言的迷题》)现在的这个社会更多地去关注那些时髦的技术,而忽略了这个流行了 40+ 年的 C 语言。一门技术如果能够流行 40 多年,这才是你需要去关注和学习的技术,而不是那些刚出来的技术(过度炒作的技术,Windows编程史)。这才是踏踏实实的精神。 —— 陈皓《如何学好C语言

C 语言是我美丽羞涩的梦

一直对 C 怀有敬畏之心,不管它到底是不是编程的基础、嵌入式的灵魂,C 语言都是一门值得让人细细品味的语言,这也是我众多「想做又没做」的事情之一。最近终于有机会重新学习一下 C,自然非常开心。虽然很多知识自认为懂了,但正所谓温故而知新,学习从来都不是「一次性动作」。事实上在回顾 C 语言时才发现自己的基础非常不扎实,有许多新的认识、感受与思考,觉得有必要稍作记录。

这个系列会记录一些自己觉得「毁三观」或者「重塑三观」的知识,不过即使这样,写出来的东西在一些大神看来依然十分粗浅,还请多多指教。



内容简介

在用 printf 进行命令行打印数据时,我们不时会发出「卧槽这是什么鬼……」的惊叹,在屏幕上看不到想要的结果。但其实数据无罪,计算机也没有错,只是我们不了解数据的来龙去脉,对打印结果产生了误会。更进一步讲,这是由于数据存储与表示的差异造成。



打印无符号数引发的思考

上次周工操着一口「失真英语」问了我一个关于 unsigned int 型变量的打印问题,大概意思是这样:

#include <stdio.h>

int main()
{
    unsigned int a;

    a = -1;
    printf("a = %d\n", a);

    return 0;
}

运行结果:

a = -1

他想不通为什么一个无符号数会打印出负号来。我乍一听,心中呵呵一笑,这不是在逗我么,无符号数怎么可能会有负号,开玩笑呢这……

你TM在逗我

但命令行上确实白底黑字打印着 a = -1 ,是的,我被这串字符狠狠地打脸了。不!科!学!啊!说好的无符号数呢?

我一直以为,无符号数是不会存储负号的,看来我错了。直到后来明白了数据的「存储」与「表示」不是一码事。



数据的存储与表示

数据的存储

「任何计算机问题都可以用分层思想解决」,赋值语句 a = -1; 和打印语句 printf("a = %d\n", a); 可以说是位于不同层次的操作 —— 赋值语句只负责把数据按一定的规则放入内存,不关心数据的含义,属于低级操作。而高级函数 printf 按照指定格式把数据输出。

也就是说赋值语句 a = -1;-1 作为一个整型常量,按照补码的方式存入了 a 对应的空间中。很显然,-1 在内存中存的就是 1111 1111 1111 1111 1111 1111 1111 1111

赋值语句才不管你这是正数还是负数、是奇数还是偶数、是质数还是合数……只要这是一个整型常量,赋值操作就存储其对应的补码形式,它就是这么天真、单纯。此时内存中这 32 条 1 根本就没有正负或类型之分,它就只是 「32 条 1」,仅此而已

数据的表示

如果机器(计算机)只是「自己玩自己的」,那就根本不会存在「数据的表示」这一说法,正是由于要进行人机交互,同样的 01 才会「变成」文字、图片、视频、声音、灯的亮灭、电平的变化等人类可感知到的信号。对于命令行打印而言,printf 语句就担当了「翻译官」重任。

当用 %d (十进制有符号整数)去打印一个数据,这时候就要关心数据的正负了,具体表现为程序将内存中最高位的二进制当做符号位,根据符号正负将二进制补码转换为十进制数再打印输出(正负数补码转换规则不同)。如果把 1111 1111 1111 1111 1111 1111 1111 1111 当做有符号数,就是 -1,如果当做无符号数(用 %u 打印),他就是 4294967295 (直接把「32 条 1」转换为十进制数)。

同样的数据,用不同的方式打印得到的结果不同,数据的存储是一回事,而数据的表示是另外一回事。打印结果从 -1 变成了 4294967295,并不代表数据发生了改变(事实上如果不用 = 去写这块空间的话,数据是不会变的),对数据的解析方式决定了数据的显示结果,眼见不一定为实

数据的存储与表示就像编码与解码,只有用同一种规则对数据进行读写、显示才能消除歧义,保证程序的正确性。



补充:数值打印 char —— 符号扩展和零扩展

认识到数据的存储与表示的区别后,一开始的问题其实已经有了答案:用无符号整型 unsigned int 去存一个负数并不会把其符号丢掉(前面讲了这个操作中甚至都没有「正负的概念」),而是先用补码的形式完整地保存数据,在打印时才对数据进行「软件层面」上的定义 —— 最高位用作符号位还是数值位,并进行转换、表示。

那再看一段代码:

#include <stdio.h>

int main()
{
    char a;
    
    a = -1;
    printf("a = %u", a);

    return 0;
}   

运行结果:

a = 4294967295

这个例子就更好的说明了这种「所见非所得」的关系,我们知道 char 类型是占一个字节大小的,8 位二进制最大也只能去到 255,所以这个 4294967295 绝壁不是变量 a 的真实值。而是把 a 按照无符号整型 %u 进行打印的显示结果,但 8 位的数据是如何变成 32 位的呢?这就涉及到「类型扩展」的问题。

思考一个问题,8 位数据放到 32 位空间中,高 24 位填充什么?很简单,不是 0 就是 1 嘛,于是就有了「符号扩展 (补 1)」和「零扩展(补 0) 」。

关于这两种扩展方式网上有各种解释说明,其实就一句话的事 —— 对于 unsigned 类型,扩展时填充 0 ,而 signed 类型要看符号位,符号位为 1,扩展时就填充 1,否则填充 0。

回到我们刚刚写的 char a = -1a 为有符号数,且最高位为 1(存储为 1111 1111 ),于是扩展成 32 位 int 型时就变成了 1111 1111 1111 1111 1111 1111 1111 1111(粗体为填充部分),这个值也是 int 型 -1 的存储内容(即用32 位来表示 -1 的补码)。不妨换一种思路理解,从「补数」的角度来看,这个值的无符号表示正是 32 位空间的「模」减去 1 的值(两个互补的数绝对值相加等于模) —— 2 ^ 32 - 1 = 4294967296 - 1 = 4294967295,正是我们打印的结果!

官方解释

关于 char 的类型转换问题,在 K&R 的《C程序设计语言》这部经典里有清晰的说明:

将字符类型转换为整型时,我们需要注意一点。C 语言没有指定 char 类型的变量是无符号变量(unsigned)还是带符号变量(signed)。当把一个 char 类型的值转换为 int 类型的值时,其结果有没有可能为负整数?对于不同的机器,其结果也不同,这反映了不同机器结构之间的区别。在某些机器中,如果 char 类型值的最左一位为 1,则转换为负整数(进行“符号扩展”)。而在另一些机器中,把 char 类型值转换为 int 类型时,在 char 类型值的左边添加 0,这样导致的转换结果值总是正值。

看来我用的电脑属于作者所说的「某些机器」呢,似乎两种机器的区别在于是把 char 当做 unsigned char 还是当做 signed char

在「用 char 类型存储非字符数据」(单片机中非常普遍)这个问题上,书中也做出了建议:

C 语言的定义保证了机器的标准打印字符集中的字符不会是负值,因此,在表达式中这些字符总是正值。但是,存储在字符变量中的位模式在某些机器中可能是负的,而在另一些机器上可能是正的。为了保证程序的可移植性,如果要在 char 类型的变量中存储非字符型数据,最好指定 signed 或 unsigned 限定符。

这就不难解释为什么单片机程序中都是清一色的 unsigned char 变量了。

类似的问题还有许多,比如「为什么按位取反 0 打印结果是 -1」,给人的感觉像是在表演「花式存 1 」。只要明白了内存中的二进制0、1以及命令行打印的字符是怎么来、到哪去的,那简直是想怎么玩就怎么玩~~~



变量类型的作用

从上面的实例可以知道无论是用 signed int 还是 unsigned int 来存储数据,在内存中的二进制数据都是一样的,那么变量类型就没用了么?并不是,通过上面的代码可以总结出变量类型的几点作用:

  1. 定义变量的存储空间大小(如 char 类型的 -1 是 8 个 1, 而 int 类型的 -1 有 32 个 1)
  2. 定义指针变量可访问范围的大小(如 int * 类型的变量能访问一块 4 个字节的空间)
  3. 定义变量的取值范围,便于编译器做数值溢出判断 —— 只要变量在取值范围内并且使用相同的类型进行存储与表示,程序就能正常工作(如 char 的范围是 -128 到 127, 用 unsigned int 定义就用 %u 打印,上面是故意制造错误来探究本质)
  4. 定义了变量转换为二进制(读写)的规则(如 int 型的 1float 型的 1.0 在内存中的二进制表示是不同的)
  5. 定义了变量所能执行的操作(如 % 运算只能用于整型数据)

废话了那么多其实只是想说明一件事:数据在内存中存着什么就是什么,用什么形式表示就看到什么,两者独立、互不干扰。




参考文章