不管你平时写 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 的小电路,由晶体管搭成。最基础的几种:

AND & 1&1=1, 其余=0 OR | 0|0=0, 其余=1 NOT ! !1=0, !0=1 XOR ^ 相异为1, 相同为0

本质一句话:门电路就是"对 0/1 做布尔运算的微型黑盒",再复杂的 CPU 也只是几十亿个这样的门叠出来的。

2. 把 1 位加法做成电路:全加器

有了 AND/OR/XOR,就能拼出"计算 1 比特加法"的电路——全加器(full adder)。它吃三个输入:两位加数 ab,以及来自低位的进位 cin;吐两个输出:本位和 sum、向高位的进位 cout

  • sum = a ^ b ^ cin(三个里恰有奇数个 1 时本位为 1)
  • cout = (a & b) | (cin & (a ^ b))(任意两个及以上为 1 就向前进位)
全加器 Full Adder a b cin FA逻辑 sum cout 把 32 个全加器串成一列 → 就是 32 位整数加法器

把 32 个这样的全加器按 cout→cin 串起来,就得到一个能算 32 位整数加法的电路。英特尔那颗芯片里,数以亿计的门干的就是这种事——只是更快、更密。

3. 为什么是二进制:补码(two's complement)

能表示 0/1 之后,怎么表示负数?最自然也最巧妙的方案是 补码(two's complement):最高位(最左)当符号位,且负数 = 对应正数按位取反再加 1。

它的妙处:加法器不需要特判正负,同一套电路既算 3 + 5 也算 3 + (-5);而且 -1 的 32 位补码就是全 1(0xFFFFFFFF)。看一段 C 代码验证:

1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main(void) {
int x = -1;
unsigned int u = (unsigned int)x; // 把同样的位模式当无符号看
printf("int -1 的位模式(按 unsigned 看): %u (0x%08X)\n", u, u);
printf("sizeof(int) = %zu 字节 = %d 位\n", sizeof(int), (int)(sizeof(int) * 8));
return 0;
}
1
2
int  -1 的位模式(按 unsigned 看): 4294967295 (0xFFFFFFFF)
sizeof(int) = 4 字节 = 32 位

:补码有"不对称范围"。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 = &ap 记下这个门牌,*p 是按门牌去敲门取人。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main(void) {
int a = 42;
int *p = &a;
printf("a = %d\n", a);
printf("&a = %p\n", (void*)&a);
printf("p (存的是&a) = %p\n", (void*)p);
printf("*p (a的值) = %d\n", *p);
*p = 100; // 通过指针改了 a 家里的值
printf("改 *p 后 a = %d\n", a);
return 0;
}
1
2
3
4
5
a        = 42
&a = 0x7ffd4a2b3c5c
p (存的是&a) = 0x7ffd4a2b3c5c
*p (a的值) = 42
改 *p 后 a = 100

(具体地址每次运行不同,但 &ap 一定相等,这就是"指针存着地址"的铁证。)

p 未初始化就 *p未定义行为p 指向的变量已经 free/出了作用域,得到的是悬空指针(dangling)NULL 指针解引用直接崩溃。Java 的引用永远不会"悬空",因为 GC 保证有用对象不被回收——C 里这条安全网没了。

6. 数组即指针算术:arr[i] 的真身

C 里 数组名 在大多数语境下会"退化"成指向首元素的指针,于是 arr[i] 在编译器眼里就是 *(arr + i)——从首地址往后挪 i 个元素宽度的距离。

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main(void) {
int arr[4] = {10, 20, 30, 40};
printf("arr[2] = %d\n", arr[2]);
printf("*(arr + 2) = %d\n", *(arr + 2));
printf("&arr[2] = %p\n", (void*)&arr[2]);
printf("arr + 2 = %p\n", (void*)(arr + 2));
return 0;
}
1
2
3
4
arr[2]      = 30
*(arr + 2) = 30
&arr[2] = 0x7ffd4a2b3c64
arr + 2 = 0x7ffd4a2b3c64

arr + 2&arr[2] 完全相等,因为 int 占 4 字节,+2 实际跳了 8 字节——指针算术按元素类型宽度走,不是按字节。这正是 C 比"裸地址 + 手动偏移"安全一点的地方。

7. 进程内存长什么样:栈与堆

写 C 必须脑子里有张"内存地图"。一个进程的典型虚拟地址布局(地址自下而上递增:底部低地址放代码,顶部高地址是栈):

高地址 ↑ 栈 stack ↓ 生长 (函数局部变量, 向低地址) 堆 heap ↑ 生长 (malloc 向高地址) 全局/静态区 代码段 text ↓ 低地址 栈和堆从两端往中间长,相遇即"内存耗尽"
  • 栈(stack):函数局部变量、参数的家。函数调用结束自动回收(所以返回"指向局部变量的指针"必是悬空)。
  • 堆(heap)malloc 来的、活得比函数调用久的内存,必须自己 free,忘了就内存泄漏,重复 free 就崩溃。
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#include <stdlib.h>

int main(void) {
int *buf = (int*)malloc(4 * sizeof(int)); // 在堆上要 4 个 int
if (buf == NULL) return 1; // 永远检查 malloc 是否成功
for (int i = 0; i < 4; i++) buf[i] = i * 10;
printf("buf[3] = %d\n", buf[3]);
free(buf); // 用完归还,否则泄漏
return 0;
}
1
buf[3] = 30

8. 位运算实战:直接拨动开关

既然底层全是 bit,C 提供了一组直接操作位的运算符,比"乘除以 2"更贴近硬件、也更高效:

运算 符号 典型用途
& 清位 / 取某几位
| 置位
异或 ^ 翻转位 / 无临时变量交换
取反 ~ 全部位反转
左移 << 乘 2 的幂
右移 >> 除 2 的幂(有符号数看实现)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

int main(void) {
int x = 5, y = 9;
printf("交换前: x=%d y=%d\n", x, y);
x = x ^ y; // 三步 XOR 交换,不借第三个变量
y = x ^ y;
x = x ^ y;
printf("交换后: x=%d y=%d\n", x, y);

unsigned int flags = 0;
flags |= (1u << 3); // 置第 3 位
printf("设第3位: 0x%X, 第3位=%d\n", flags, (flags >> 3) & 1);
flags &= ~(1u << 3); // 清第 3 位
printf("清第3位: 0x%X\n", flags);
return 0;
}
1
2
3
4
交换前: x=5 y=9
交换后: x=9 y=5
设第3位: 0x8, 第3位=1
清第3位: 0x0

:移位位数不能 ≥ 类型宽度(UB);对有符号负数右移是"算术移位"还是"逻辑移位"由编译器定,写跨平台代码时位运算尽量用 unsigned

🐾 小结

  • 计算机最底是门电路对 0/1 做布尔运算,全加器串起来就是整数加法器。
  • 补码让正负加减共用一套电路,但范围不对称、INT_MAX + 1 会溢出回负数(UB)。
  • C 把内存的管理权交到你手里:靠指针与手动 malloc/free 掌控堆;& 取地址、* 解引用;代价是放弃了 Java 的 GC 与越界保护,越界与悬空都得自己负责。
  • arr[i]*(arr + i):指针算术按元素宽度走。
  • 进程内存里管局部变量、管长寿内存,二者从两端生长。
  • 位运算(& | ^ ~ << >>)直接拨动 bit,置位/清位/交换都能优雅表达,但注意无符号与移位边界。