zhengrenzhe's blog   About

C 语言中数字的存储方式

C 语言作为一种历史悠久的系统级编程语言,理解其底层数据如何存储是很有必要的。在它的基本数据类型中,有三类最为重要:「有符号数」,「无符号数」,「浮点数」,本文将分析这三种类型的数据在计算机内如何存储、表示、计算。

位、字节

不论是有符号数、无符号数还是浮点数,在机器内部都是以二进制来表示。在计算机内部,最小的数据单位是「位」,即一个0或1,目前通常由8位组成一个「字节」。这三种数据类型均由若干字节表示,也就是用「有限」数量的位来对数字进行编码,不同长度的位所能表示的大小也不同。

例如:short int 在64位机器上占2字节,当它存储有符号数2时,在小端法机器上其位级表示是:0000 0010 0000 0000

大小端

在上面的 short int 例子中,我们提到了小端法,它与大端法是一对,用来描述数据的有效字节先后顺序 —— 最低有效字节在前的称为小端法,最高有效字节在前的称为大端法。仍以上面的 short int 为例,十进制 2 的二进制是 10,所以 2 在机器内的表示应该是 0000 0000 0000 0010,在小端法机器中,最低有效字节在前,所以应该是0000 0010在前,这就有了前面的结果。

实际上使用哪种字节顺序是任意的,目前主流的三大操作系统:Windows,macOS,Linux 都是小端法,在写程序时一般也无需关心机器到底是大端还是小端。

无符号数

无符号数指的是大于或等于0的整数,在 C 语言中定义无符号数的方法就是在 int,long,short,char 的前面加上 unsigned,随着数据类型的不同,其对应的无符号数所能表示的范围也不同。在 64 位机器上,典型的无符号数取值范围是:

数据类型 最小值 最大值
unsigned char 0 255
unsigned short 0 65535
unsigned int 0 4294967295

这个范围是如何得出的呢?这与无符号数的编码方式有关。因为它总是大于或等于0,所以不需要符号位,也就可以把它所占的字节都用来表示数据,就像单纯的使用二进制一样。如果以函数 B2U 来表示 w 位无符号数的位向量 $$\vec{x}\ \ \dot=\ \ [x_{w-1},\ x_{w-2},\ x_{w-3},\ \ldots,\ x_0]$$ 就是: $$B2U_w(\vec{x})\ \ \dot=\ \ \sum_{i=0}^{w-1} x_i2^i$$ 例如:对于unsigned char类型来说,当位全为1时处于最大值,即:$$[1, 1, 1,1,1,1,1,1]$$ 使用 B2U 来将其转换为无符号数就是:$$1 \times 2^7+1 \times2^6+1 \times 2^5+ \ldots +1 \times 2^1+1 \times 2^0 = 255$$

通过公式 B2U 可以看出,每一个位向量即01序列都唯一对应一个无符号数,反之亦然,所以无符号数可在取值范围内被精确表示。

有符号数

有符号数指的是值可能为负的数,在 C 语言中默认使用有符号数,不同于无符号数,有符号数不需要显式声明signed。与无符号数相同的是,不同的有符号数类型所能表示的范围也不同。在64位机器上,典型的有符号数取值范围是:

数据类型 最小值 最大值
char -128 127
short -32768 32767
int -2147483648 2147483647

可以看到原先无符号数的表示范围被一分为二,这是因为有符号数的最高位变成了符号位,该位表示值的正负情况,0表示正,1表示负,其余各位与无符号数的规则类似。如果仍以上面的位向量 $\vec{x}$ 为例,以函数 B2T 来表示该位向量就是:$$B2T_w(\vec{x})\ \ \dot=\ \ -x_{w-1}2^{w-1} + \sum_{i=0}^{w-2}x_i2^i$$其中,$-x_{w-1}2^{w-1}$就是最高位的符号位。

char 类型的位向量 $[1,1,1,1,1,1,1,1]$ 为例,使用 B2T 将其转换为有符号数就是:$$-1 \times 2^7 +1 \times 2^6 + 1 \times 2^5 + \ldots + 1 \times 2^0 = -1$$

在有符号数中,符号位的权重是 $-2^{w-1}$,在 B2T 的计算过程中,只要符号位为1,则负权值为计算过程中唯一的负值,第 $w-2$ 至 $0$ 位中只要有某一位的值是1,都会使总体结果不断的趋向 $-1$,如果全为1,经过计算则得到了最大的负数 $-1$,要想得到最小的负数,只要只保持符号位为1,其他位不出现正数即可,即符号位为1,其他位全0:$[1, 0, 0, 0, 0, 0, 0, 0]$,这就得到了最小负数-128。

由此可以看出,有符号数的范围是不对称的,例如 char 的最小值 -128 并没有对应的最大值 128,其最大值是127,位级表示是$[0, 1, 1, 1, 1, 1, 1, 1]$,如果对其 +1,位级表示变成了 $[1, 0, 0, 0, 0, 0, 0, 0]$。注意,这在位级表示上是正确的,但以有符号数的规则进行解释时,该值就变成了 -128,使用 C 语言验证确实如此。

char a = 127;
a+=1;
printf("%d", a); // -128

有符号数与无符号数之间的转换

C 语言允许不同的数字数据类型之间做「强制类型转换」,不同类型的数据在运算中也会发生「隐式类型转换」,而这些类型转换往往是导致错误的原因,所以我们要了解类型转换的规则。数据类型转换的情况是多样的,例如 short 转换为 int,unsigned int 转换为 int,在本节中只讨论有符号数与无符号数的转换,也就是同样字长的 unsigned 与 signed 的相互转换。

从前文可以看到,有符号数与无符号数的在位级上的差别就是最高位是否为符号位,所以核心的一点就是有符号数与无符号数之间不论怎么转换,其「位模式」是不变的,也就是说该值的01序列不变变的只是解释该序列的方法

我们写一个简单的 C 程序来验证这一点:

int main() {
    signed char a = -12;
    unsigned char b = (unsigned char) a;

    printf("%d, %d", a, b); // -12, 244

    return 0;
}

在本例中 a 的位模式是 0xf4,当强制类型转换为 b 后,b 的位模式仍是 0xf4,这就说明了强制类型转换不改变位模式,只改变了解释位的方式。如果要手动计算 b 的结果,我们可以通过 B2T 反推出 a 的位模式,再通过 B2U 计算出该位模式对应的无符号数,而更方便的方法是通过这两个同时来推出一个新公式T2U。

如果我们计算 B2U 与 B2T 之差可以发现,从 0 到 w-2 位将相互抵消,只剩下一个$x_{w-1}2^w$,即

$$B2U_w(\vec{x}) - B2T_w(\vec{x}) = x_{w-1}2^{w-1} - -x_{w-1}2^{w-1} = x_{w-1}2^w$$也就是说$$B2U_w(\vec{x}) = B2T_w(\vec{x}) + x_{w-1}2^w$$因为有无符号数的位模式不变,所以可以说二进制到无符号数等于二进制到有符号数加$x_{w-1}2^w$,而有符号数的位模式中$w-1$位决定了该数的正负,所以公式 T2U 可以归纳为:

$$T2U_w(x) = \begin{cases}x+2^w,\ \ x<0 \\x,\ \ \ \ \ \ \ \ \ \ \ x \geqslant 0\end{cases}$$

例如上面的 C 程序中,a 的值为 -12,小于0,则其对应的无符号数为 $-12 + 2^8 = 244$。

根据上面 $B2U_w(\vec{x})$ 与 $B2T_w(\vec{x})$ 的关系,也可以说二进制到有符号数等于二进制到无符号数减$x_{w-1}2^w$,而无符号数的位模式中$w-1$位决定该数是否大于或等于$2^{w-1}$,所以公式 U2T 可以归纳为:

$$U2T_w(x) = \begin{cases} x\ \ \ \ \ \ \ \ \ ,\ \ x < 2^{w-1} \\ x - 2^w,\ \ x \geqslant 2^{w-1} \end{cases}$$

下面写一个简单的 C 语言程序来验证:

int main() {
    unsigned char a = 229;
    signed char b = (signed char) a;

    printf("%d, %d", a, b); // 229, -27

    return 0;
}

此时变量 a 的值为 $229\ >\ 2^7$,所以其对应的有符号数为$229 - 2^8 = -27$。

有符号数与无符号数的扩展与截断

上节说到同一字长有符号数与无符号数之间的转换,这节来讨论同一符号情况下数据的扩展与截断,简单来说就是 short 转换为 int,或 unsigned int 转换为 unsigned short。

将一个无符号数转换为一个更大的数据类型,只要在开头添加0,这称之为「零扩展」。将一个有符号数转换为一个更大的数据类型,因为最高位是符号位的原因,所以要在开头添加最高有效位的值,这称之为「符号扩展」

例如

$$\vec{x} = [x_{w-1}, x_{w-2}, x_{w-3}, \ldots , x_0]$$

,如果 $\vec{x}$ 为 unsigned char,则扩展至 unsigned short 后位模式为

$$[0, 0, 0, 0, 0, 0, 0, 0, x_{w-1}, x_{w-2}, x_{w-3}, \ldots , x_0]$$;

如果 $\vec{x}$ 为 signed char,则扩展至 signed short 后位模式为

$$[x_{w-1}, x_{w-1}, x_{w-1}, x_{w-1}, x_{w-1}, x_{w-1}, x_{w-1}, x_{w-1}, x_{w-1}, x_{w-2}, x_{w-3}, \ldots , x_0]$$

下面写一个简单的 C 语言程序来验证:

typedef unsigned char * byte_pointer;

void show_bytes(byte_pointer start, int len){
    for (int i = 0; i < len; ++i) {
        printf("%.2x ", start[i]);
    }
    printf("\n");
}

int main() {

    unsigned char a = 123;
    unsigned short b = a;

    signed char c = -123;
    signed short d = c;

    show_bytes((byte_pointer) &a, sizeof(a)); // 7b
    show_bytes((byte_pointer) &b, sizeof(b)); // 7b 00
    show_bytes((byte_pointer) &c, sizeof(c)); // 85
    show_bytes((byte_pointer) &d, sizeof(d)); // 85 ff

    return 0;
}

说完了数据的扩展,下面来看看数据的截断。数据的截断就没有扩展那么麻烦了,假设截断前的数据类型长为 w 位,截断至一个新的数据类型长度为 k 位,则只需丢掉原始数据类型的高 w-k 位即可,并且不论有无符号,都使用该规则。

下面写一个简单的 C 语言程序来验证:

int main() {

    unsigned short a = 12345;
    unsigned char b = (unsigned char) a;

    signed short c = -12345;
    signed char d = (signed char) c;

    show_bytes((byte_pointer) &a, sizeof(a)); // 39 30
    show_bytes((byte_pointer) &b, sizeof(b)); // 39
    show_bytes((byte_pointer) &c, sizeof(c)); // c7 cf
    show_bytes((byte_pointer) &d, sizeof(d)); // c7

    printf("%d\n%d", b, d); // 57 -57

    return 0;
}

从例子中可以看到,当无符号的 short 转换为 char 时,丢掉高字节(测试机器为小端法)30,低字节值为 0x39,即 57;当有符号 short 转换为 char 时,丢掉高字节 cf,低字节值为 0xc7,即 -57。

浮点数

前面的有符号数无符号数均只能表示整数,而浮点数可以用来表示小数,目前绝大多数编程语言都使用 IEEE-754 浮点数标准,通过理解该标准如何表示小数,我们可以解释以前在开发中的一些奇怪现象。

常见的浮点数格式有 32位的单精度 与 64位的双精度,IEEE浮点标准用 $V = (-1)^s \times M \times 2^E $ 来表示一个数,其类似于科学计数法 $EXP = a \times 10^n$。

在公式中各个部分的意义如下:

对应于公式中的三部分,浮点数对位进行划分,来分别对这些值进行编码:

给定了位模式,根据 exp 的不同,被编码值可分为三种情况,下图以单精度为例:

1. 规格化的值

在最普遍的情况下,exp 的位模式不全为0,也不全为1(十进制的255),frac 任意,此时说该浮点数是一个「规格化的值」,其中阶码字段被解释为以偏置形式表示的有符号整数,即 $E = e - Bias$,其中 e 即为 exp,$Bias = 2^{k-1} -1$。

小数字段 frac 解释为描述小数值 f($0 \leqslant f < 1$),其二进制表示为$0.f_{n-1} \cdots f_1 f_0$,也就是说二进制小数点在最高位的右边。尾数定义为 $M = 1 + f$,这称为「隐含的以1开头的表示」,这么做的原因是可以获得一个额外的精度位,即 $M = 1.f_{n-1} \cdots f_1 f_0$

2. 非规格化的值

当阶码全为 0 时,该浮点数是一个「非规格化的值」,此时阶码为 $E = 1 - Bias$,尾数值为 $M = f$,不包含开头隐含的 1。

非规格化有两个作用:首先,它可以来表示0。因为规格化数的尾数是隐含的以1开头,所以$M \geqslant 1$。而非规格化值的尾数不包含隐含的1,所以它可以用来表示0。它的另一个用途是表示那些非常接近于0.0的数。

3. 特殊值

特殊值有两种:无穷大与 NaN。

当阶码全1,小数字段全0时表示无穷大,这出现在两个非常大的数相乘或者除以零时,用无穷表示该结果,此时 s 为 1 时表示 $-\infty$,s 为 0 时表示 $+\infty$。

当阶码全1,小数字段非0时表示 NaN,即 Not a Number,当一些计算结果不是实数或无穷时,以 NaN 表示。


讲了一大堆理论,现在来实际计算一下。简单起见,假设有基于 IEEE 浮点格式的 5 位浮点表示,有 1 个符号位,2 个阶码位,2 个小数位:

$[0\ 00\ 10]$

阶码为全0,这是一个非规格化数,所以

$[1\ 10\ 10]$

阶码非全0非全1,这是一个规格化数,所以

如果看懂了上面两个例子,那么 IEEE 浮点数的编码方式可以说基本掌握了,下面来探讨一些细节问题。


刚开始看你可能会对 M 的计算过程感到疑惑: $M = 1.f_{n-1} \cdots f_1 f_0$或$0.f_{n-1} \cdots f_1 f_0$,这个是如何计算成小数的呢?

以 $[1\ 10\ 10]$ 为例,$M = 1.10$,这里的 M 是二进制小数。十进制小数的表示形式为:

$$d_md_{m-1} \cdots d_1d_0\ .\ d_{-1}d_{-2} \cdots d_{-n}$$

其中 $d_i$ 的范围是0-9,即

$$d = \sum_{i=-n}^m 10^i \times d_i$$

类似的,对于二进制小数

$$b = \sum_{i=-n}^m 2^i \times b_i$$

所以

$$M = 1.10 = 1 \times 2^0 + 1 \times 2^{-1} + 0 \times 2^{-2} = \frac{3}{2} = 1.5$$

又以 $[1\ 10\ 11]$ 为例,

$$M = 1.11 = 1 \times 2^0 + 1 \times 2^{-1} + 1 \times 2^{-2} = 1 + \frac{1}{2} + \frac{1}{4} = 1.75$$

在这里尾数仅加了1,所能表示的值就从1.5到了1.75,也就意味着1.5-1.75之间的值无法被精确表示,当然现在尾数仅2位,精确表示的能力比较弱,如果是32位浮点数或64位浮点数,尾数有23位或52位,那能精确表示的数就多了,尾数的位数越高,所能表示的值越多,所能表示的值的间距越小。但是即使是它们也无法精确表示每一个数,对应无法精确表示的值只能进行舍入

下面写一个简单的 C 语言程序来看下 float 类型的数据是如何存储与表示的

int main() {
    float a = 1.25;
    show_bytes((byte_pointer) &a, sizeof(a)); // 00 00 a0 3f
    return 0;
}

因为是小端法机器,所以真实的位级表示是

$$[0\ 01111111\ 01000000000000000000000]$$

这显然是一个规格化数,所以:

计算结果1.25就是变量 a 的值。

总结

本文简要的概括了有符号数、无符号数、浮点数在机器内的表示与转换,并举了些简单的例子,以求能对这几种数据类型的工作方式有一个大概的了解。

← WebAssembly初探  回溯法 →