从 Go 程序到 CPU 执行——程序是如何跑起来的以一个最简单的 Go 程序为例packagemainimportfmtfuncadd(a,bint)int{returnab}funcmain(){result:add(3,5)fmt.Println(result)}就这么几行代码最终是怎么变成 CPU 能跑的东西的一、源代码 → 可执行文件你写的.go文件是给人看的文本CPU 不认识。编译器把它翻译成 CPU 直接能执行的机器指令这个过程叫编译产物就是一个可执行文件——也就是二进制文件。main.go ──── go build ──→ hello.exe二进制文件 全是机器码编译型 vs 解释型Go / C / RustPython / JavaScript / PHP怎么跑先编译成二进制直接运行一行一行读源码边翻译边执行类比整本书翻译好再出版外国人当场听一句翻一句速度快已是机器码慢翻译要时间部署扔一个 exe 就行要装运行环境 复制所有源码二、编译出的程序长什么样一个编译好的程序以 Linux ELF 格式为例大致分为这几部分hello可执行文件 / 二进制 ┌────────────────────────────┐ │ ELF Header │ ← 我是可执行文件入口在这 ├────────────────────────────┤ │ .text │ ← 机器码你写的所有函数 │ add 的指令 │ │ main 的指令 │ │ fmt.Println 的指令 │ │ ... │ ├────────────────────────────┤ │ .rodata │ ← 只读数据常量字符串 │ Hello, World │ │ 格式化字符串 │ ├────────────────────────────┤ │ .data │ ← 已初始化的全局变量 │ 包级变量 │ ├────────────────────────────┤ │ .bss │ ← 未初始化的全局变量全是0 │ 不占文件空间运行时分配 │ ├────────────────────────────┤ │ Symbol Table符号表 │ ← 函数名、变量名的索引 │ main.add → 0x00401200 │ 调试用release 可去掉 │ main.main → 0x00401100│ ├────────────────────────────┤ │ Debug Info调试信息 │ ← 源码行号对应关系 │ 第7行 ← → 0x00401200 │ 调试用release 可去掉 └────────────────────────────┘运行时内存里多了什么┌────────────────────────────┐ │ 上面的所有段从文件加载 │ ├────────────────────────────┤ │ Stack栈 │ ← 函数调用局部变量 │ 主线程的栈 │ push/pop自动回收 │ int a3, b5, result│ ├────────────────────────────┤ │ Heap堆 │ ← make/newGC 管理 │ 动态分配的数据 │ └────────────────────────────┘一句话概括各段段存什么类比.text函数机器码菜谱的步骤.rodata常量字符串菜谱上印死的文字.data全局变量厨房墙上挂的常备工具.bss空的全局变量预留的空柜子Stack局部变量、函数调用链临时摆在台面上的碗碟Heap动态分配的内存仓库要多少拿多少三、.text 段就是一堆机器码.text 段 ┌──────────────────────────────────────┐ │ │ │ add 函数: │ │ 0x00401200: 55 push rbp │ │ 0x00401201: 48 89 e5 mov rbp, rsp │ 0x00401204: 48 89 7d f8 mov [rbp-8], rdi ; 存参数 a │ 0x00401208: 48 89 75 f0 mov [rbp-16], rsi ; 存参数 b │ 0x0040120c: 48 8b 45 f8 mov rax, [rbp-8] ; 取 a │ 0x00401210: 48 03 45 f0 add rax, [rbp-16] ; a b │ 0x00401214: c9 leave ; 恢复栈 │ 0x00401215: c3 ret ; 返回 │ │ │ main 函数: │ │ 0x00401100: 55 push rbp │ │ 0x00401101: 48 89 e5 mov rbp, rsp │ 0x00401104: bf 03 00 00 00 mov edi, 3 ; 参数 a 3 │ 0x00401109: be 05 00 00 00 mov esi, 5 ; 参数 b 5 │ 0x0040110e: e8 ed 00 00 00 call 0x00401200 ; 调 add │ 0x00401113: ... ; 拿返回值调 fmt.Println │ ... │ └──────────────────────────────────────┘就是一长串数字操作码 操作数CPU 一个字节一个字节拿解析成干啥然后执行。没注释没函数名没类型信息——你写的add、main这些名字编译完一个都不剩。这就是编译进二进制的真正含义——你写的 Go 代码最终就变成这么一堆十六进制数字CPU 不认识 Go只认识这玩意儿。四、CPU 如何执行跳来跳去函数调用就是跳地址CPU 靠一个叫IP 寄存器指令指针的寄存器走路IP 0x00401100 → CPU 去 0x00401100 拿指令执行 main 的第一行 IP 0x00401104 → mov edi, 3准备参数 a3 IP 0x00401109 → mov esi, 5准备参数 b5 IP 0x0040110e → call 0x00401200IP 改成 0x00401200跳进 add IP 0x00401200 → push rbp开始执行 add IP 0x00401210 → add rax, [rbp-16]执行 a b IP 0x00401215 → retIP 改回 0x00401113跳回 main IP 0x00401113 → 继续往下调 fmt.Println函数调用 改一下 IP 寄存器的值 在同一个线程里跳个地址。线程每秒做几亿次这种事if/else 是跳for 循环是跳函数调用是跳——一整条执行流就是由一个接一个的跳组成的。整个程序就是一个跳转链.text 段机器码 ┌──────────────────────────────────────────────────┐ │ │ │ main: │ │ ① 准备参数 a3, b5 │ │ ② call add ──────────────────────┐ │ │ ④ 拿返回值调 fmt.Println │ │ │ │ │ │ add: ↓ │ │ ③ push rbp → 取 a → 取 b → add │ │ │ → leave → ret ────────────────→ 回到 main │ │ │ └──────────────────────────────────────────────────┘ CPU 做的事拿一个地址 → 执行 → call 跳到另一个地址 → 执行 → ret 跳回来 → 继续...没有魔法就是一个地址接一个地址。五、线程上下文 vs 函数调用函数调用同线程内跳地址线程自己主动跳开销极小纳秒级① main 执行到 call 0x00401200 ├─ 把下一条指令地址 0x00401113 push 到栈上记住回家的路 └─ 跳到 add 的入口 0x00401200 ② 执行 add 函数体 ├─ 在栈上分配局部变量 ├─ 执行 a b └─ 结果存进 rax 寄存器 ③ add 执行到 ret └─ 从栈上弹出 0x00401113跳回去全程没换线程就是同一个线程在同一个栈上函数进来 push函数退出 pop。线程上下文切换操作系统强行换人线程自己无感知① 线程 A 正在执行 add 函数 ② 操作系统时间片到了A 你先停一下 ├─ 保存 A 的所有寄存器快照RIP、RSP、通用寄存器... └─ 恢复线程 B 之前保存的寄存器快照 ③ 线程 B 开始跑 ④ 又过了一个时间片 ⑤ 操作系统切回线程 A恢复寄存器快照 ⑥ 线程 A 从 add 里继续执行它完全不知道自己被暂停过关键区别函数调用跳地址线程上下文切换谁发起的代码自己call指令操作系统开销纳秒级改个 IP微秒级保存/恢复整套寄存器线程没换还是自己换了另一个线程上 CPU代码感知call/ret是显式写的完全无感知一个类比跳地址函数调用 你在看书翻到后面某一页又翻回来 全程是你自己在看 线程上下文切换 书看到一半有人把书合上放一边 过了 10 分钟又把书打开翻回同一页 塞回你手里接着看 你完全没参与这个过程六、线程上下文里有什么线程上下文不存函数地址表。它只记当前跑到哪了——就是 CPU 寄存器的快照线程上下文切换时保存/恢复的东西 ├── RIP / EIP ← 当前执行到哪条指令比如 add 函数里第 3 条 ├── RSP / ESP ← 栈顶在哪 ├── RBP / EBP ← 栈底在哪 ├── RAX, RBX, RCX... ← 通用寄存器比如正在算的 35 的中间值 ├── RFLAGS ← 状态标志 └── ...函数地址在哪在.text段代码段是静态的属于进程不属于线程进程 ├── .text 段 ← 所有函数的机器码在这所有线程共享 │ add: 0x00401200 │ main: 0x00401100 │ fmt.Println: 0x00402000 │ ... │ ├── 线程 A │ RIP: 0x0040120A ← 正在执行 add 的第 3 条指令 │ 栈: [返回地址 0x00401113, a3, b5] │ └── 线程 B RIP: 0x00402010 ← 在执行 fmt.Println 的某条指令 栈: [返回地址 ...]线程上下文只存一个指针RIP不存函数表。函数表是进程代码段里的所有线程共享。七、一个进程就是一个 exeexe 文件 ←→ 进程 一个 exe 启动 操作系统创建一个进程把 exe 加载进去 两个 exe 同时跑 两个独立的进程你双击hello.exe任务管理器里出现一个进程。再双击一次又出来一个进程。两个进程跑的是同一个 exe但内存独立、互不干扰。一个进程能有什么一个进程 ├── .text 段 ← 所有函数机器码所有线程共享 ├── .data 段 ← 全局变量所有线程共享 ├── 主线程 ← 有自己的栈 ├── 工作线程 1 ← 有自己的栈 ├── 工作线程 2 ← 有自己的栈 └── Heap堆 ← 所有线程共享线程之间共享.text、.data、堆。进程之间完全隔离互相不能访问对方内存。八、题外话游戏封号的机器码跟上面的完全不是一回事。上面聊的是machine code——CPU 执行的二进制指令存在.text段里。游戏封号说的机器码是machine fingerprint机器指纹跟你电脑的硬件绑定游戏启动 → 扫描硬件信息 → 拼出一个唯一 ID ┌──────────────────────────────────────────┐ │ 硬盘序列号 WD-ABC12345 │ │ 主板序列号 MB-XYZ789 │ │ MAC 地址 00:1A:2B:3C:4D:5E │ │ CPU 序列号 ... │ │ ↓ │ │ 拼一起 → 算个 hash → 机器码 │ │ → 8F3A2B1C... │ └──────────────────────────────────────────┘封号时把这个 hash 拉黑换账号、换 IP 都没用——除非换硬件。这就是为什么外挂教程常说过机器码——把游戏采集到的硬件序列号改掉或者绕过。两个机器码对比CPU 的机器码封号的机器码英文Machine CodeMachine Fingerprint / Hardware ID是什么二进制指令55 48 89 e5...硬件序列号拼的 hash谁生成的编译器反作弊系统采集硬件用途CPU 执行程序识别这台电脑能改吗改源码重新编译换硬件或装虚拟硬件驱动九、总结用那个简单程序串一遍完整流程funcadd(a,bint)int{returnab}funcmain(){result:add(3,5)fmt.Println(result)}编译—go build把 Go 代码翻译成机器码add和main都变成55 48 89 e5...这种十六进制指令打包进hello.exe运行— 双击 exe操作系统创建进程把 exe 的.text、.data等段加载进内存执行 main— CPU 从 main 的入口地址开始一条一条拿指令执行调 add— 遇到call把返回地址 push 到栈上跳进 add 的机器码返回— add 算完ab执行ret从栈上弹出返回地址跳回 main全程— 同一个进程、同一个线程、同一个栈就靠 CPU 在.text段里反复横跳没有魔法没有黑科技。就是一个.exe文件加载到内存CPU 一个地址接一个地址地跑函数调用就是call跳进去ret跳回来——从头到尾。