ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

操作码揭秘:CPU如何读懂你的代码

2026/10/7 12:53:03 拓冰建站 浏览量
操作码揭秘:CPU如何读懂你的代码 一、先搞清楚一件事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];intsp0;// 栈指针intpc0;// 当前读到程序的哪个位置for(;;){intopprogram[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就一个字节0x90CPU 看到它就空转一拍。再看一条常用的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 字节手机 CPUARM和 RISC-V 换了个思路所有指令固定 4 字节。地址: 100-103 104-107 108-111 内容: 一条 一条 一条好处是永远不会读错位置随便从哪个 4 字节边界开始都对。而且 CPU 可以一次并行解码八条因为它提前就知道每条在哪。坏处是浪费空间。哪怕什么都不做这种最简单的操作也得占满 4 个字节。两边各有道理x86ARM指令长度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。剩下的都是细节。