数字逻辑与 C 语言回顾
不管你平时写 Java 还是 C,这台计算机底下其实没有任何"对象",只有一堆在 0 和 1 之间反复横跳的晶体管。CS61C 要做的,就是把"软件"一路拽回"硬件"的起点。这一篇干两件事:先用数字逻辑把"软件"接回"硬件"(门电路、加法器、补码),再系统回顾 C 语言里最容易踩的坑——指针、手动内存、位运算。
1. 什么是"位":一切从开关开始
计算机最底层没有整数、没有字符串,只有一个个能表示两种状态的元件——通电 / 断电,记为 1 / 0,这就是一个 bit(位)。把若干 bit 并排,就能表示更大的数:8 个 bit 叫 1 字节(byte),32 个 bit 是常见 int 的宽度。
单个 bit 太弱,于是我们用"门电路(gate)"把 bit 组合运算。门是接受若干 0/1 输入、输出一个 0/1 的小电路,由晶体管搭成。最基础的几种:
本质一句话:门电路就是"对 0/1 做布尔运算的微型黑盒",再复杂的 CPU 也只是几十亿个这样的门叠出来的。
2. 把 1 位加法做成电路:全加器
有了 AND/OR/XOR,就能拼出"计算 1 比特加法"的电路——全加器(full adder)。它吃三个输入:两位加数 a、b,以及来自低位的进位 cin;吐两个输出:本位和 sum、向高位的进位 cout。
sum = a ^ b ^ cin(三个里恰有奇数个 1 时本位为 1)cout = (a & b) | (cin & (a ^ b))(任意两个及以上为 1 就向前进位)
把 32 个这样的全加器按 cout→cin 串起来,就得到一个能算 32 位整数加法的电路。英特尔那颗芯片里,数以亿计的门干的就是这种事——只是更快、更密。
3. 为什么是二进制:补码(two's complement)
能表示 0/1 之后,怎么表示负数?最自然也最巧妙的方案是 补码(two's complement):最高位(最左)当符号位,且负数 = 对应正数按位取反再加 1。
它的妙处:加法器不需要特判正负,同一套电路既算 3 + 5 也算 3 + (-5);而且 -1 的 32 位补码就是全 1(0xFFFFFFFF)。看一段 C 代码验证:
1 |
|
1 | int -1 的位模式(按 unsigned 看): 4294967295 (0xFFFFFFFF) |
坑:补码有"不对称范围"。32 位 int 能表示 [-2147483648, 2147483647]——负数比正数多一个(因为没有 "+0 的负数")。所以 2147483647 + 1 不会得到 2147483648,而是溢出回 -2147483648。这种"有符号溢出"是未定义行为(UB),编译器可以不保证任何结果,千万别写依赖它的代码。
4. C 与 Java 的几个关键差异
同样是写程序,Java 和 C 在"内存归谁管"这件事上走向两个极端:
| 维度 | Java | C |
|---|---|---|
| 内存管理 | 垃圾回收自动释放 | 手动 malloc / free |
| "取地址" | 没有指针概念 | &x 取地址,*p 解引用 |
| 指针算术 | 引用不能 +/- |
p+1 合法,移动一个元素 |
| 数组越界 | 抛 ArrayIndexOutOfBoundsException |
静默读/写非法内存,后果难料 |
| 字符串 | 真·对象 String |
char 数组 + 末尾 \0 |
本质一句话:Java 把内存的脏活挡在运行时后面,C 则把钥匙直接交到你手上——灵活十倍,也要求你对每一块自己申请的内存负责到底。
5. 指针是什么:地址就是个整数
C 里指针就是"内存地址",而地址本质上就是一个编号整数。变量 a 住在某个地址,&a 拿到它的门牌号,int *p = &a 让 p 记下这个门牌,*p 是按门牌去敲门取人。
1 |
|
1 | a = 42 |
(具体地址每次运行不同,但 &a 与 p 一定相等,这就是"指针存着地址"的铁证。)
坑:p 未初始化就 *p 是未定义行为;p 指向的变量已经 free/出了作用域,得到的是悬空指针(dangling);NULL 指针解引用直接崩溃。Java 的引用永远不会"悬空",因为 GC 保证有用对象不被回收——C 里这条安全网没了。
6. 数组即指针算术:arr[i] 的真身
C 里 数组名 在大多数语境下会"退化"成指向首元素的指针,于是 arr[i] 在编译器眼里就是 *(arr + i)——从首地址往后挪 i 个元素宽度的距离。
1 |
|
1 | arr[2] = 30 |
arr + 2 与 &arr[2] 完全相等,因为 int 占 4 字节,+2 实际跳了 8 字节——指针算术按元素类型宽度走,不是按字节。这正是 C 比"裸地址 + 手动偏移"安全一点的地方。
7. 进程内存长什么样:栈与堆
写 C 必须脑子里有张"内存地图"。一个进程的典型虚拟地址布局(地址自下而上递增:底部低地址放代码,顶部高地址是栈):
- 栈(stack):函数局部变量、参数的家。函数调用结束自动回收(所以返回"指向局部变量的指针"必是悬空)。
- 堆(heap):
malloc来的、活得比函数调用久的内存,必须自己free,忘了就内存泄漏,重复free就崩溃。
1 |
|
1 | buf[3] = 30 |
8. 位运算实战:直接拨动开关
既然底层全是 bit,C 提供了一组直接操作位的运算符,比"乘除以 2"更贴近硬件、也更高效:
| 运算 | 符号 | 典型用途 |
|---|---|---|
| 与 | & |
清位 / 取某几位 |
| 或 | | |
置位 |
| 异或 | ^ |
翻转位 / 无临时变量交换 |
| 取反 | ~ |
全部位反转 |
| 左移 | << |
乘 2 的幂 |
| 右移 | >> |
除 2 的幂(有符号数看实现) |
1 |
|
1 | 交换前: x=5 y=9 |
坑:移位位数不能 ≥ 类型宽度(UB);对有符号负数右移是"算术移位"还是"逻辑移位"由编译器定,写跨平台代码时位运算尽量用 unsigned。
🐾 小结
- 计算机最底是门电路对 0/1 做布尔运算,全加器串起来就是整数加法器。
- 补码让正负加减共用一套电路,但范围不对称、
INT_MAX + 1会溢出回负数(UB)。 - C 把内存的管理权交到你手里:靠指针与手动
malloc/free掌控堆;&取地址、*解引用;代价是放弃了 Java 的 GC 与越界保护,越界与悬空都得自己负责。 arr[i]≡*(arr + i):指针算术按元素宽度走。- 进程内存里栈管局部变量、堆管长寿内存,二者从两端生长。
- 位运算(
& | ^ ~ << >>)直接拨动 bit,置位/清位/交换都能优雅表达,但注意无符号与移位边界。

