一、先搞清楚一件事:CPU 只认数字
你写的代码,不管是 C 还是 C#,CPU 都看不懂。
CPU 能读的只有内存里的一串数字。所以编译器要做的事,就是把你的代码翻译成一串数字。
你写的: a = b + c 变成数字: 3, 1, 2, 5问题来了:CPU 看到3, 1, 2, 5这几个数,怎么知道这是"做加法"而不是"读文件"?
答案是事先约好。
有一张表,规定好:
1 号 = 做加法 2 号 = 做减法 3 号 = 从内存读一个数 4 号 = 往内存写一个数 ...CPU 看到第一个数字是3,查表知道"要读内存",于是就去读内存。
这个"1 号"“2 号”"3 号"的编号,就叫操作码(opcode)。
就这么简单。它是一个编号,一个 enum 值。
二、一条指令的两半
光有编号不够。你说"做加法",加谁和谁?
所以一条完整的指令要有两部分:
加法 rax rbx ↑ └───┬───┘ 操作码 操作数 "做什么" "对谁做"操作码说要干什么。操作数说对谁干。
写成字节的样子(数字是我瞎编的,意思对就行):
内存里: [ 01 ] [ 03 ] [ 04 ] ↑ ↑ ↑ 操作码 操作数1 操作数2 "加法" "3号" "4号" 寄存器 寄存器 含义: 把 3 号寄存器和 4 号寄存器加起来CPU 的工作循环就是:
- 从内存读一个字节
- 查表,看是几号操作
- 再读几个字节,知道对谁操作
- 执行
- 回到第 1 步,读下一条
一直这么转,转到程序结束。
三、动手造一台小机器
光说还是抽象。咱们造一台最简单的虚拟机,一共四条指令。
先定操作码表:
0 = HALT 停机 1 = PUSH 把一个数放到栈上 2 = ADD 把栈顶两个数加起来 3 = PRINT 打印栈顶的数现在用它算 2 + 3:
程序 = [ 1, 2, 1, 3, 2, 3, 0 ] ↑ ↑ ↑ ↑ ↑ ↑ ↑ PUSH 2 PUSH 3 ADD PRINT HALT一步步跑:
读到 1 (PUSH),再读到 2 → 栈: [2] 读到 1 (PUSH),再读到 3 → 栈: [2, 3] 读到 2 (ADD) → 栈: [5] 读到 3 (PRINT) → 屏幕打印 5 读到 0 (HALT) → 结束代码写出来大概三十行:
intprogram[]={1,2,1,3,2,3,0};intstack[64];intsp=0;// 栈指针intpc=0;// 当前读到程序的哪个位置for(;;){intop=program[pc++];// 读一个操作码switch(op){case1:// PUSHstack[sp++]=program[pc++];// 再读一个数,压栈break;case2:// ADDsp--;stack[sp-1]=stack[sp-1]+stack[sp];break;case3:// PRINTprintf("%d\n",stack[sp-1]);break;case0:// HALTreturn0;}}这就是一台完整的虚拟机。Lua、Python、Java 的核心跑的就是这个循环,只是指令多几百条,细节多很多。
注意那个switch——操作码的用途就是用来 switch 的。它存在的全部意义就是让程序知道该跳到哪个分支。
四、真实的 CPU 也是这样
把上面那台小机器换成真 CPU,原理一模一样,只是编号是 Intel 和 ARM 定的,不是你定的。
举个最简单的例子。x86 里:
0x90 = 什么都不做(NOP)就一个字节,0x90,CPU 看到它就空转一拍。
再看一条常用的:
0x55 = 把 rbp 寄存器的值压栈也是一个字节。为什么这么短?因为**"压栈"和"rbp"被合在一个编号里了**——Intel 给八个寄存器各分了一个编号,0x50到0x57,分别对应 push 八个不同的寄存器。
常用操作给短编号,这是为了省内存。
五、唯一需要知道的"复杂"之处
这里只有一件事值得讲,就是指令有多长。
x86:每条指令长度不一样
0x90 1 个字节 0x55 1 个字节 0x48 0x89 0xe5 3 个字节 ... 最长能到 15 个字节好处是省空间,常用指令只占一个字节。
坏处是:你不能从中间开始读。
假设内存里是这样:
地址: 100 101 102 103 104 内容: 48 89 e5 90 55从 100 开始读,CPU 知道48 89 e5是一条(3 字节),然后90是一条,55是一条。读对了。
但如果你从 101 开始读,89 e5 90会被当成另一条完全不同的指令。整个后面全错。
从 100 开始: [48 89 e5] [90] [55] ← 对 从 101 开始: [89 e5 90] [55] ... ← 全错所以 x86 的字节流必须从正确的起点开始解析。这就像一句没有空格的英文:
NOWHERE从头读是 “nowhere”,从第三个字母读是 “here”。字节流也一样,起点错了,意思全变。
ARM:每条指令都是 4 字节
手机 CPU(ARM)和 RISC-V 换了个思路:所有指令固定 4 字节。
地址: 100-103 104-107 108-111 内容: 一条 一条 一条好处是永远不会读错位置,随便从哪个 4 字节边界开始都对。而且 CPU 可以一次并行解码八条,因为它提前就知道每条在哪。
坏处是浪费空间。哪怕"什么都不做"这种最简单的操作,也得占满 4 个字节。
两边各有道理
| x86 | ARM | |
|---|---|---|
| 指令长度 | 1~15 字节 | 固定 4 字节 |
| 省空间 | 省 | 不省 |
| 解码 | 麻烦,必须顺着读 | 简单,随便从哪读 |
电脑 CPU 选了省空间,手机 CPU 选了好解码。没有对错。
六、这知识什么时候用得上
你平时不会手写操作码。但有几个场景会撞上它。
调试器下断点,是在偷偷改你的代码
x86 有个一字节指令:
0xCC = 暂停,交给调试器你在 IDE 里点一下行号设断点,调试器实际做的事是:
原来: 48 89 e5 改成: CC 89 e5 ← 把第一个字节换成 CC,原来的 48 存起来程序跑到这里就停住,调试器接管。你按继续的时候,它把48填回去,再让程序接着跑。
为什么选一字节的指令?因为不管原来那条指令多长,覆盖一个字节总是安全的。
这也解释了一个现象:断点如果下错位置(落在指令中间),反汇编窗口会显示一堆乱码。原因就是上面说的——起点错了,后面全错。
崩溃日志里的 “illegal instruction”
看到SIGILL或者EXCEPTION_ILLEGAL_INSTRUCTION,意思是:
CPU 读到了一个编号,查表发现没这个号。
什么情况下会这样?基本都是程序跑到了不该去的地方:
- 函数指针被改坏了
- 虚表被覆盖了
- 栈溢出改掉了返回地址
程序跳到了一片不是代码的内存上,把数据当指令读,自然查不到对应的编号。
所以看到这个错误,先怀疑有东西把内存写坏了,不是某行逻辑算错了。
游戏里给引擎函数打补丁
想在某个引擎函数前后插点自己的代码(埋点、改行为、热更新),常见做法是改掉函数开头:
原来: 55 48 89 e5 ... 函数正常开头 改成: E9 xx xx xx xx 跳到我的代码去0xE9是"跳转",加 4 个字节的地址,一共 5 字节。
麻烦在哪?5 个字节可能不是整数条指令。
要覆盖 5 字节: 55 | 48 89 e5 | 8b 45 ... ↑ ↑ ↑ 1 3 第 5 个字节落在这条指令中间你把它切一半,剩下那半截就是垃圾。所以得先算清楚指令边界,把被切到的整条指令搬到别处去。
MinHook、Detours 这些库内部都带一个小型的"指令长度计算器",就是为了干这件事。
ARM 上这个问题不存在——4 字节一条,边界天然对齐。
七、如果你要自己写一个
游戏里经常需要跑点脚本:技能配置、对话逻辑、行为树。这些底下都是一套自定义操作码。
几条建议:
定长指令。比如统一 32 位一条,操作码放在固定的几位上。解码就是位移加掩码,几乎不花时间,而且工具好写。
Lua 的做法可以直接抄:
┌────────┬────────┬────────┬────────┐ │ B │ C │ A │ 操作码 │ │ 9 位 │ 9 位 │ 8 位 │ 6 位 │ └────────┴────────┴────────┴────────┘ 32 位,刚好一次读取操作码 6 位,能放 64 条指令,对大多数脚本语言够用。
先把 switch 写通,别一开始就优化。上面那三十行的switch版本足够跑起来。等 profiler 告诉你分派是瓶颈了,再去换成跳转表(computed goto)那套。
一定要同时写个反汇编器。就是把字节码打印成人能读的文本:
PUSH 2 PUSH 3 ADD PRINT HALT几十行代码的事,但字节码出 bug 的时候,没有它你会完全瞎掉。这是投入产出比最高的一个工具。
总结
操作码就是一个编号,告诉机器该执行哪个操作。
一条指令 = 操作码(做什么)+ 操作数(对谁做)。
机器的工作就是个循环:读一个编号 → 查表 → 执行 → 读下一个。你那三十行switch就是这个循环。
真实 CPU 唯一多出来的复杂度是指令长度:x86 变长,省空间但必须顺着读;ARM 定长,费空间但随便从哪读都对。
这个区别会一路影响到调试器怎么下断点、hook 库怎么改函数、崩溃日志为什么显示乱码。
说白了就是:操作码是 enum,解释器是 switch。剩下的都是细节。