c/docs/notes/01_c-basic/05_xdx/index.md
2024-10-22 11:09:19 +08:00

21 KiB
Raw Permalink Blame History

第一章:前言

1.1 概述

  • 计算机的底层只有二进制,即计算机中运算存储所有数据都需要转换为二进制,包括:数字、字符、图片、视频等。

1.2 冯·诺依曼体系结构

  • 之前,我们也提到现代的计算机(量子计算机除外)几乎都遵循冯·诺依曼体系结构,其理论要点如下:
    • 存储程序程序指令数据都存储在计算机的内存中,这使得程序可以在运行时修改。
    • 二进制逻辑:所有数据和指令都以二进制形式表示。
    • 顺序执行:指令按照它们在内存中的顺序执行,但可以有条件地改变执行顺序。
    • 五大部件:计算机由运算器控制器存储器输入设备输出设备组成。
    • 指令结构:指令由操作码和地址码组成,操作码指示要执行的操作,地址码指示操作数的位置。
    • 中心化控制计算机的控制单元CPU负责解释和执行指令控制数据流。
  • 所以,再次论证了为什么计算机只能识别二进制。

第二章:进制

2.1 常见的进制

  • 在生活中,我们最为常用的进制就是十进制,其规则是满 10 进 1 ,即:

  • 在计算机中,常见的进制有二进制八进制十六进制,即:
    • 二进制:只能 0 和 1 ,满 2 进 1 。
    • 八进制0 ~ 7 ,满 8 进 1 。
    • 十六进制0 ~ 9 以及 A ~ F ,满 16 进 1 。

Note

在十六进制中,除了 09 这十个数字之外,还引入了字母,以便表示超过 9 的值。其中,字母 A 对应十进制的 10 ,字母 B 对应十进制的 11 ,字母 C 对应十进制的 12,字母 D 对应十进制的 13,字母 E 对应十进制的 14,字母 F 对应十进制的 15

  • 进制的换算举例,如下所示:
十进制 二进制 八进制 十六进制
0 0 0 0
1 1 1 1
2 10 2 2
3 11 3 3
4 100 4 4
5 101 5 5
6 110 6 6
7 111 7 7
8 1000 10 8
9 1001 11 9
10 1010 12 a 或 A
11 1011 13 b 或 B
12 1100 14 c 或 C
13 1101 15 d 或 D
14 1110 16 e 或 E
15 1111 17 f 或 F
16 10000 20 10
... ... ... ...
  • 二进制和十六进制的关系:十六进制是以 16 为基数的进制系统16 在二进制中表示为 ( 2^4 ),即:一个十六进制可以表示 4 位二进制。

Note

十六进制的范围是0 ~ F 0 ~ 15对应的二进制数的范围是0000 ~ 1111 0 ~ 15

  • 每个十六进制数都可以映射到一个唯一的 4 位二进制数,即:
十六进制 二进制
0 0000
1 0001
2 0010
3 0011
4 0100
5 0101
6 0110
7 0111
8 1000
9 1001
A 1010
B 1011
C 1100
D 1101
E 1110
F 1111

Note

由此可见,每个十六进制数字确实由 4 位二进制数表示。

  • 二进制和八进制的关系:八进制是以 8 为基数的进制系统8 在二进制中表示为 ( 2^3 );即:一个八进制位可以表示 3 个二进制位。

Note

八进制的范围是0 ~ 7 对应的二进制数的范围是000 ~ 111。

  • 每个八进制数位都可以映射到一个唯一的 3 位二进制数,即:
八进制 二进制
0 000
1 001
2 010
3 011
4 100
5 101
6 110
7 111

Note

由此可见,每个八进制数字确实由 3 位二进制数表示。

2.2 C 语言中如何表示不同进制的整数?

  • 规则如下:

    • 在 C 语言中,如果是二进制(字面常量),则需要在二进制整数前加上 0b0B
    • 在 C 语言中,如果是八进制(字面常量),则需要在八进制整数前加上 0
    • 在 C 语言中,如果是十进制(字面常量),正常数字表示即可。
    • 在 C 语言中,如果是十六进制(字面常量),则需要在十六进制整数前加上 0x0X
  • 示例:

#include <stdio.h>

int main() {

    int num1 = 0b10100110; // 二进制
    int num2 = 0717563; // 八进制
    int num3 = 1000; // 十进制
    int num4 = 0xaf72; // 十六进制

    printf("num1 = %d\n", num1); // num1 = 166
    printf("num2 = %d\n", num2); // num2 = 237427
    printf("num3 = %d\n", num3); // num3 = 1000
    printf("num4 = %d\n", num4); // num4 = 44914

    return 0;
}

2.3 输出格式

  • 在 C 语言中,可以使用不同的格式占位符输出不同进制的整数,如下所示:
    • %d:十进制整数。
    • %o :八进制整数。
    • %x:十六进制整数。
    • %#o :显示前缀 0 的八进制整数。
    • %#x :显示前缀 0x 的十六进制整数。
    • %#X :显示前缀 0X 的十六进制整数。

Caution

C 语言中没有输出二进制数的格式占位符!!!

  • 示例:
#include <stdio.h>

int main() {

    int num = 100;

    printf("%d 的十进制整数: %d\n", num, num); // 100 的十进制整数: 100
    printf("%d 的八进制整数: %o\n", num, num); // 100 的八进制整数: 144
    printf("%d 的十六进制整数: %x\n", num, num); // 100 的十六进制整数: 64
    printf("%d 的八进制(前缀)整数: %#o\n", num, num); // 100 的八进制(前缀)整数: 0144
    printf("%d 的十六进制(前缀)整数: %#x\n", num, num); // 100 的十六进制(前缀)整数: 0x64
    printf("%d 的十六进制(前缀)整数: %#X\n", num, num); // 100 的十六进制(前缀)整数: 0X64

    return 0;
}

第三章: 进制的运算规则

3.1 概述

  • 十进制的运算规则,如下所示:
    • (针对加法而言)。
    • (针对减法而言)。
  • 二进制的运算规则,如下所示:
    • (针对加法而言)。
    • (针对减法而言)。
  • 八进制的运算规则,如下所示:
    • (针对加法而言)。
    • (针对减法而言)。
  • 十六进制的运算规则,如下所示:
    • 十六(针对加法而言)。
    • 十六(针对减法而言)。

3.2 二进制的运算

  • 二进制的加法:1 + 0 = 11 + 1 = 1011 + 10 = 101111 + 111 = 1110

  • 二进制的减法:1 - 0 = 110 - 1 = 1101 - 11 = 101100 - 111 = 101

3.3 八进制的运算

  • 八进制的加法:3 + 4 = 75 + 6 = 1375 + 42 = 1372427 + 567 = 3216

  • 八进制的减法:6 - 4 = 252 - 27 = 33307 - 141 = 1467430 - 1451 = 5757

3.4 十六进制的运算

  • 十六进制的加法:6 + 7 = D18 + BA = D2595 + 792 = D272F87 + F8A = 3F11

  • 十六进制的减法:D - 3 = A52 - 2F = 23E07 - 141 = CC67CA0 - 1CB1 = 5FEF

第四章:进制的转换

4.1 概述

  • 不同进制的转换,如下所示:

  • 在计算机中,数据是从右往左的方式排列的;其中,最右边的是低位,最左边的是高位,即:

4.2 二进制和十进制的转换

4.2.1 二进制转换为十进制

  • 规则:从最低位开始,将每个位上的数提取出来,乘以 2 的 (位数 - 1 )次方,然后求和。

Note

  • ① 在学术界,将这种计算规则,称为位权相加法
  • 八进制转换为十进制十六进制转换为十进制二进制转换为十进制的算法相同!!!
  • 示例:十进制转十进制

  • 示例:二进制转十进制

4.2.2 十进制转换二进制

  • 规则:将该数不断除以 2 ,直到商为 0 为止,然后将每步得到的余数倒过来,就是对应的二进制。

Note

  • ① 在学术界,将这种计算规则,称为短除法连续除2取余法
  • ② 很好理解,只有不断地除以 2 ,就能保证最大的数字不超过 2 ,这不就是二进制(只能有 0 或 1
  • 八进制转换为二进制十六进制转换为二进制十进制转换为二进制的算法相同!!!
  • 示例:十进制转十进制

  • 示例:十进制转二进制

4.2.3 二进制转八进制

  • 规则:从右向左,每 3 位二进制就是一个八进制,不足补 0分组转换法

  • 示例011 101 001 -> 351

4.2.4 二进制转十六进制

  • 规则:从右向左,每 4 位二进制就是一个十六进制,不足补 0分组转换法

  • 示例1110 1001 -> 0xE9

第五章:原码、反码和补码

5.1 概述

  • 机器数:一个数在计算机的存储形式是二进制,我们称这些二进制数为机器数。机器数可以是有符号的,用机器数的最高位来存放符号位,0 表示正数,1 表示负数。

Important

  • ① 这里讨论的适用于有符号位的整数int 等。
  • ② 这里讨论的不适用于无符号位的整数unsinged int 等。

  • 真值(数据位):因为机器数带有符号位,所以机器数的形式值不等于其真实表示的值(真值),以机器数 1000 0001 为例,其真正表示的值(首位是符号位)为 -1而形式值却是 129 ,因此将带有符号位的机器数的真正表示的值称为机器数的真值。

Important

  • ① 这里讨论的适用于有符号位的整数int 等。
  • ② 这里讨论的不适用于无符号位的整数unsinged int 等。

5.2 原码

  • 原码的表示与机器数真值表示的一样,即用第一位表示符号,其余位表示数值。
  • 规则:
    • 正数的原码是它本身对应的二进制数,符号位是 0 。
    • 负数的原码是它本身绝对值对应的二进制数,但是符号位是 1 。
  • +1 的原码,使用 16 位二进数来表示,就是:
十进制数 原码16位二进制数
+1 0000 0000 0000 0001
  • -1 的原码,使用 16 位二进数来表示,就是:
十进制数 原码16位二进制数
-1 1000 0000 0000 0001

Important

  • ① 按照原码的规则,会出现 +0-0 的情况,即:0000 0000 0000 0001+01000 0000 0000 0001-0显然不符合实际情况。
  • ② 所以,计算机底层虽然存储和计算的都是二进数,但显然不是原码。

5.3 反码

  • 规则:

    • 正数的反码和它的原码相同。
    • 负数的反码是在其原码的基础上,符号位不变,其余各位取反。
  • +1 的反码,使用 16 位二进数来表示,就是:

十进制数 原码16位二进制数 反码16位二进制数
+1 0000 0000 0000 0001 0000 0000 0000 0001
  • -1 的反码,使用 16 位二进数来表示,就是:
十进制数 原码16位二进制数 反码16位二进制数
-1 1000 0000 0000 0001 1111 1111 1111 1110

Important

  • ① 按照反码的规则,如果是 +0,对应的原码是 0000 0000 0000 0000那么其反码还是 0000 0000 0000 0000如果是 -0,对应的原码是 1000 0000 0000 0000其反码是 1111 1111 1111 1111显然不符合实际情况。
  • ② 所以,计算机底层虽然存储和计算的都是二进数,但显然不是反码。

5.4 补码

  • 规则:

    • 正数的补码和它的原码相同。
    • 负数的补码是在其反码的基础上 + 1 。
  • +1 的补码,使用 16 位二进数来表示,就是:

十进制数 原码16位二进制数 反码16位二进制数 补码16位二进制数
+1 0000 0000 0000 0001 0000 0000 0000 0001 0000 0000 0000 0001
  • -1 的补码,使用 16 位二进数来表示,就是:
十进制数 原码16位二进制数 反码16位二进制数 补码16位二进制数
-1 1000 0000 0000 0001 1111 1111 1111 1110 1111 1111 1111 1111
  • 如果 0 ,按照 +0 的情况进行处理,如下所示:

  • 如果 0 ,按照 -0 的情况进行处理,如下所示:

  • +1-1原码补码的转换过程,如下所示:

Important

  • ① 补码表示法解决了原码反码存在的两种零(+0-0)的问题,即:在补码表示法中,只有一个零,即 0000 0000
  • ②补码使得加法运算减法运算可以统一处理,通过将减法运算转换为加法运算,可以简化硬件设计,提高了运算效率。
  • ③ 计算机底层存储计算的都是二进数的补码。换言之,当读取整数的时候,需要采用逆向的转换,即:将补码转换为原码。正数的原码、反码、补码都是一样的,三码合一。负数的补码转换为原码的方法就是先减去 1 ,得到反码,再按位取反,得到原码(符号位是不能借位的)。

5.5 总结

  • ① 计算机底层存储计算的都是二进数的补码。换言之,当读取整数的时候,需要采用逆向的转换,即:将补码转换为原码。
  • ② 正数的原码、反码和补码都是一样的,三码合一。
  • ③ 负数的反码是在其原码的基础上按位取反0 变 1 1 变 0 ),符号位不变;负数的补码是其反码 + 1 。
  • ④ 0 的补码是 0 。
  • ⑤ 负数的补码转换为原码的方法就是先减去 1 ,得到反码,再按位取反,得到原码(符号位是不能借位的)。

第六章:计算机底层为什么使用补码?

6.1 概述

  • 加法减法是计算机中最基本的运算,计算机时时刻刻都离不开它们,所以它们由硬件直接支持。为了提高加法和减法的运行效率,硬件电路必须设计得尽量简单。

  • 对于有符号位的数字来说,内存需要区分符号位和数值位:对于人类来说,很容易识别(最高位是 0 还是 1但是对于计算机来说需要设计专门的电路这无疑增加了硬件的复杂性增加了计算时间。如果能将符号位和数值位等同起来让它们一起参与运算不再加以区分这样硬件电路就可以变得非常简单。

  • 此外,加法和减法也可以合并为一种运算,即:加法运算。换言之,减去一个数就相当于加上这个数的相反数,如:5 - 3 相当于 5 +-310 --9相当于 10 + 9

  • 如果能够实现上述的两个目标,那么只需要设计一种简单的、不用区分符号位和数值位的加法电路,就能同时实现加法运算和减法运算,而且非常高效。其实,这两个目标已经实现了,真正的计算机的硬件电路就是这样设计的。

  • 但是,简化硬件电路是有代价的,这个代价就是有符号数在存储和读取的时候都要继续转换。这也是对于有符号数的运算来说,计算机底层为什么使用补码的原因所在。

6.2 补码到底是如何简化硬件电路的?

  • 假设 6 和 18 都是 short 类型,现在我们要计算 6 - 18 的结果,根据运算规则,它等价于 6 +-18。如果按照采用原码来计算,那么运算过程是这样的,如下所示:

Note

直接使用原码表示整数,让符号位也参与运算,那么对于减法来说,结果显然是不正确的。

  • 于是,人们开始继续探索,不断试错,终于设计出了反码,如下所示:

Note

直接使用反码表示整数,让符号位也参与运算,对于 6 +-18来说结果貌似正确。

  • 如果我们将被减数减数对调一下,即:计算 18 - 6 的结果,也就是 18 +-6的结果,继续采用反码来进行运算,如下所示:

Note

  • ① 6 - 186+-18如果采用反码计算结果是正确的但是18 - 618 +-6如果采用反码计算,结果相差 1 。
  • ② 可以推断:如果按照反码来计算,小数 - 大数,结果正确;而大数 - 小数,结果相差 1 。

  • 对于这个相差的 1 必须进行纠正,但是又不能影响小数-大数的结果。于是,人们又绞尽脑汁设计出了补码,给反码打了一个“补丁”,终于把相差的 1 给纠正过来了。那么,6 - 18 按照补码的运算过程,如下所示:

  • 那么,18 - 6 按照补码的运算过程,如下所示:

Important

总结:采用补码的形式正好将相差的 1纠正过来,也没有影响到小数减大数,这个“补丁”非常巧妙。

  • ① 小数减去大数,结果为负,之前(负数从反码转换为补码需要 +1加上的 1 ,后来(负数从补码转换为反码要 -1还需要减去正好抵消掉所以不会受到影响。
  • ② 大数减去小数,结果为正,之前(负数从反码转换为补码需要 +1加上的 1 ,后来(正数的补码和反码相同,从补码转换为反码不用 -1就没有再减去不能抵消掉这就相当于给计算结果多加了一个 1。

补码这种天才般的设计,一举达成了之前加法运算和减法运算提到的两个目标,简化了硬件电路。

6.3 问题抛出

  • 在 C 语言中,对于有符号位的整数,是使用 0 作为正数,1 作为负数,来表示符号位,并使用数据位来表示的是数据的真值,如下所示:
int a = 10;
int b = -10;

  • 但是,对于无符号位的整数而言,是没有符号位和数据位,即:没有原码、反码、补码的概念。无符号位的整数的数值都是直接使用二进制来表示的(也可以理解为,对于无符号位的整数,计算机底层存储的就是其原码),如下所示:
unsigned int a = 10;
unsigned int b = -10;

  • 这就是导致了一个结果就是:如果我定义一个有符号的负数,却让其输出无符号,必然造成结果不对,如下所示:
#include <stdio.h>

char *getBinary(int num) {
    static char binaryString[33];
    int         i, j;

    for (i = sizeof(num) * 8 - 1, j = 0; i >= 0; i--, j++) {
        const int bit   = (num >> i) & 1;
        binaryString[j] = bit + '0';
    }

    binaryString[j] = '\0';
    return binaryString;
}

int main() {

    // 禁用 stdout 缓冲区
    setbuf(stdout, NULL);

    int num = -10;
    printf("b=%s\n", getBinary(num)); // b=11111111111111111111111111110110
    printf("b=%d\n", num);            // b=-10
    printf("b=%u\n", num);            // b=4294967286

    return 0;
}
  • 其实C 语言的底层逻辑很简单C 语言压根不关心你定义的是有符号数还是无符号数,它只关心内存(如果定义的是有符号数,那就按照有符号数的规则来存储;如果定义的是无符号数,那就按照无符号数的规则来存储)。换言之,有符号数可以按照无符号数的规则来输出,无符号数也可以按照有符号数的规则来输出,至于输出结果对不对,那是程序员的事情,和 C 语言没有任何关系。

Important

  • ① 实际开发中,printf 函数中的常量、变量或表达式,需要和格式占位符一一对应;否则,将会出现数据错误的现象。
  • ② 正因为上述的原因很多现代化的编程语言Java 等,直接取消了无符号的概念。但是,很多数据库是使用 C 语言开发的MySQL 等,就提供了创建数据表的字段为无符号类型的功能,即:UNSIGNED(正整数) ,不要感觉困惑!!!
  • ③ 对于 1000 0000 …… 0000 0000 这个特殊的补码,无法按照上述的方法转换为原码,所以计算机直接规定这个补码对应的值就是 -2³¹,至于为什么,下节我们会详细分析。