以前上计算机组成原理的时候,我一直觉得”取指、译码、执行”这些词离实际编程很远。
书上会写:
CPU 从 PC 指向的地址取出指令,经过译码后执行,再更新 PC。
看起来很简单。
直到我开始写 NEMU。
我第一次看到这样一段代码:
static void exec_once(Decode *s, vaddr_t pc) {
s->pc = pc;
s->snpc = pc;
isa_exec_once(s);
cpu.pc = s->dnpc;
}
刚开始看的时候,我其实完全不知道这几行代码为什么就能”运行一个程序”。
pc 是什么?
snpc 和 dnpc 又是什么?
isa_exec_once() 到底干了什么?
那条 addi a0, a0, 1 到底是在什么时候变成二进制的?
后来我才慢慢意识到:
我们平时写下的一行 C 代码,在机器眼里早就已经完全消失了。
比如:
a = b + 1;
编译器可能把它变成:
addi a0, a1, 1
汇编器再把这条指令编码成一个 32 位整数。
最终 CPU 看到的东西,可能只是:
00000000000101011000010100010011
NEMU 所做的事情,就是假装自己是一颗 CPU。
它从内存中把这 32 位取出来,然后根据 opcode、funct3、funct7 等字段判断:
“哦,这是一条 addi。”
然后找到源寄存器,提取立即数,做一次加法,再把结果写入目标寄存器。
最后更新 PC。
就这样,一条指令结束。
然后下一条。
下一条。
再下一条。
直到程序结束。
这件事情最让我震撼的地方其实不是 NEMU 本身。
而是我第一次发现:
计算机并没有我以前想象中那么神秘。
一个复杂的软件栈,如果不断往下剥:
Python
→ C/C++
→ 汇编
→ ISA
→ 寄存器和内存
→ 数字逻辑
最后依然是一些非常简单的规则。
复杂性只是被一层一层包装起来了。
以前学编程的时候,我经常会有一种感觉:
“这个函数为什么能这么调用?”
“操作系统为什么能启动程序?”
“调试器为什么能看到寄存器?”
“函数调用为什么会自动返回?”
很多时候我的答案都是:
因为系统就是这么设计的。
现在我越来越不喜欢这个答案。
比如函数为什么可以返回?
因为 jal 会保存返回地址。
返回地址通常保存在 ra。
函数结束时执行类似:
jalr x0, 0(ra)
PC 又回到了调用者。
所谓的:
return;
底下其实发生的是这些事情。
我现在越来越喜欢这种学习方式:
不是记住”它能工作”。
而是继续往下问:
为什么?
如果不知道,就再往下一层。
直到找到一个我能够完全理解的机制为止。
也许这也是我越来越喜欢操作系统、体系结构和 AI Infra 的原因。
这些方向都在研究同一个问题:
那些我们平时认为”理所当然”的能力,到底是怎么被构建出来的?
而我想做的事情,就是把这些黑盒,一个一个拆开。