从 Go 程序到 CPU 执行——程序是如何跑起来的
从 Go 程序到 CPU 执行——程序是如何跑起来的
以一个最简单的 Go 程序为例:
packagemainimport"fmt"funcadd(a,bint)int{returna+b}funcmain(){result:=add(3,5)fmt.Println(result)}就这么几行代码,最终是怎么变成 CPU 能跑的东西的?
一、源代码 → 可执行文件
你写的.go文件是给人看的文本,CPU 不认识。编译器把它翻译成 CPU 直接能执行的机器指令,这个过程叫编译,产物就是一个可执行文件——也就是二进制文件。
main.go ──── go build ──→ hello.exe(二进制文件) (全是机器码)编译型 vs 解释型
| Go / C / Rust | Python / 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 a=3, b=5, result)│ ├────────────────────────────┤ │ Heap(堆) │ ← make/new,GC 管理 │ 动态分配的数据 │ └────────────────────────────┘一句话概括各段
| 段 | 存什么 | 类比 |
|---|---|---|
.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(准备参数 a=3) IP = 0x00401109 → mov esi, 5(准备参数 b=5) IP = 0x0040110e → call 0x00401200(IP 改成 0x00401200,跳进 add) IP = 0x00401200 → push rbp(开始执行 add) IP = 0x00401210 → add rax, [rbp-16](执行 a + b) IP = 0x00401215 → ret(IP 改回 0x00401113,跳回 main) IP = 0x00401113 → 继续往下,调 fmt.Println函数调用 = 改一下 IP 寄存器的值 = 在同一个线程里跳个地址。线程每秒做几亿次这种事,if/else 是跳,for 循环是跳,函数调用是跳——一整条执行流就是由一个接一个的跳组成的。
整个程序就是一个跳转链
.text 段(机器码) ┌──────────────────────────────────────────────────┐ │ │ │ main: │ │ ① 准备参数 a=3, b=5 │ │ ② 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... ← 通用寄存器(比如正在算的 3+5 的中间值) ├── RFLAGS ← 状态标志 └── ...函数地址在哪?在.text段(代码段),是静态的,属于进程,不属于线程:
进程 ├── .text 段 ← 所有函数的机器码在这,所有线程共享 │ add: 0x00401200 │ main: 0x00401100 │ fmt.Println: 0x00402000 │ ... │ ├── 线程 A │ RIP: 0x0040120A ← 正在执行 add 的第 3 条指令 │ 栈: [返回地址 0x00401113, a=3, b=5] │ └── 线程 B RIP: 0x00402010 ← 在执行 fmt.Println 的某条指令 栈: [返回地址 ...]线程上下文只存一个指针(RIP),不存函数表。函数表是进程代码段里的,所有线程共享。
七、一个进程就是一个 exe
exe 文件 ←→ 进程 一个 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 Code | Machine Fingerprint / Hardware ID |
| 是什么 | 二进制指令(55 48 89 e5...) | 硬件序列号拼的 hash |
| 谁生成的 | 编译器 | 反作弊系统采集硬件 |
| 用途 | CPU 执行程序 | 识别"这台电脑" |
| 能改吗 | 改源码重新编译 | 换硬件或装虚拟硬件驱动 |
九、总结
用那个简单程序串一遍完整流程:
funcadd(a,bint)int{returna+b}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 算完
a+b,执行ret,从栈上弹出返回地址跳回 main - 全程— 同一个进程、同一个线程、同一个栈,就靠 CPU 在
.text段里反复横跳
没有魔法,没有黑科技。就是一个.exe文件加载到内存,CPU 一个地址接一个地址地跑,函数调用就是call跳进去ret跳回来——从头到尾。
