ARTICLE DETAIL

建站实战干货

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

从 Go 程序到 CPU 执行——程序是如何跑起来的

2026/8/3 11:20:48 拓冰建站 浏览量
从 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 / 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 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 一个字节一个字节拿,解析成"干啥",然后执行。没注释,没函数名,没类型信息——你写的addmain这些名字编译完一个都不剩。

这就是"编译进二进制"的真正含义——你写的 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 CodeMachine Fingerprint / Hardware ID
是什么二进制指令(55 48 89 e5...硬件序列号拼的 hash
谁生成的编译器反作弊系统采集硬件
用途CPU 执行程序识别"这台电脑"
能改吗改源码重新编译换硬件或装虚拟硬件驱动

九、总结

用那个简单程序串一遍完整流程:

funcadd(a,bint)int{returna+b}funcmain(){result:=add(3,5)fmt.Println(result)}
  1. 编译go build把 Go 代码翻译成机器码,addmain都变成55 48 89 e5...这种十六进制指令,打包进hello.exe
  2. 运行— 双击 exe,操作系统创建进程,把 exe 的.text.data等段加载进内存
  3. 执行 main— CPU 从 main 的入口地址开始,一条一条拿指令执行
  4. 调 add— 遇到call,把返回地址 push 到栈上,跳进 add 的机器码
  5. 返回— add 算完a+b,执行ret,从栈上弹出返回地址跳回 main
  6. 全程— 同一个进程、同一个线程、同一个栈,就靠 CPU 在.text段里反复横跳

没有魔法,没有黑科技。就是一个.exe文件加载到内存,CPU 一个地址接一个地址地跑,函数调用就是call跳进去ret跳回来——从头到尾。