对话:运行时 - CPU如何执行你的程序


前置知识: [03-loading.md] 难度: ⭐⭐⭐ 预计时间: 50分钟 关键词: 指令周期, 程序计数器, 调用栈, 栈帧, 函数调用, 返回地址

引入场景

程序已经被装载到内存,CPU的程序计数器指向了入口点。从这一刻起,CPU开始逐条取指令、执行指令。你写的每一个if、for、函数调用,最终都变成了CPU的一系列简单操作。

对话探索

问题1:CPU执行程序的最基本步骤是什么?它是怎么知道”下一条该做什么”的?

💭 思考方向:

  • CPU内部有什么寄存器来跟踪执行位置?
  • 一条指令完成后,如何找到下一条?
  • 遇到 if 分支时呢?

📖 参考答案: CPU的基本工作循环是取指-译码-执行(Fetch-Decode-Execute):

       ┌──────────────────────┐
       ▼                      │
  ┌─────────┐  ┌─────────┐  ┌─────────┐
  │  取指    │→│  译码    │→│  执行    │
  │ Fetch   │  │ Decode  │  │ Execute │
  └─────────┘  └─────────┘  └─────────┘
  从PC指向的    解析指令含义    执行运算
  内存取指令    确定操作数     写回结果
                             更新PC

程序计数器(PC / IP):

  • 存储”下一条要执行的指令的地址”
  • 顺序执行时:PC = PC + 指令长度
  • 分支/跳转时:PC = 目标地址
地址     指令                    PC变化
0x1000   MOV RAX, 5             PC → 0x1003(下一条)
0x1003   ADD RAX, 3             PC → 0x1006
0x1006   CMP RAX, 10            PC → 0x1009
0x1009   JNE 0x1020             PC → 0x1020(条件跳转!)
...
0x1020   MOV RBX, RAX           PC → 0x1023

所有的 if、for、switch 最终都变成了这种地址跳转。


问题2:函数调用时发生了什么?CPU怎么知道函数执行完后要回到哪里?

💭 思考方向:

  • main() 调用 add(3, 5),add执行完后怎么回到main?
  • 返回地址存在哪里?
  • 如果函数调用了十层嵌套呢?

📖 参考答案: 函数调用使用**栈(Stack)**来管理返回地址和局部变量:

main() 调用 add(3, 5) 时:

CALL add    →   1. 把返回地址(CALL下一条指令地址)压入栈
                2. 跳转到add的地址(修改PC)

add函数执行...

RET         →   1. 从栈顶弹出返回地址
                2. 跳转到该地址(修改PC),回到main继续执行

每次函数调用都在栈上创建一个栈帧(Stack Frame):

高地址
┌──────────────────┐
│ main的栈帧        │
│   局部变量 result  │
│   ...             │
├──────────────────┤ ← main调用add时的栈指针
│ 返回地址           │  (回到main的哪条指令)
│ 旧的栈基址         │  (main栈帧的底部)
├──────────────────┤ ← add栈帧开始
│ add的栈帧          │
│   参数 a = 3      │
│   参数 b = 5      │
│   局部变量         │
└──────────────────┘ ← 栈顶(SP)
低地址

这就是”调用栈”——你在panic或debugger中看到的堆栈追踪(stack trace),就是在遍历这些栈帧的返回地址。


问题3:Go的函数调用和C有什么不同?

💭 思考方向:

  • Go支持多返回值,C通常只有一个
  • Go的goroutine有自己的栈
  • Go的栈可以增长

📖 参考答案:

特性CGo
参数传递寄存器 + 栈早期纯栈传递,Go 1.17+寄存器传递
返回值通常1个(通过寄存器)多个(通过栈或寄存器)
栈大小固定(通常8MB)初始小(几KB),可动态增长
栈增长不能,溢出即崩溃检测到不够时分配更大的栈并复制

Go的连续栈(Contiguous Stack):

初始状态:                    栈不够用时:
┌──────┐                    ┌──────────────┐
│ 2KB栈 │                   │ 4KB新栈       │
│ [数据]│   → 检测到栈即将   │ [复制旧数据]   │
│      │     溢出           │ [新的空间]     │
└──────┘                    └──────────────┘

Go编译器在每个函数入口插入栈增长检查代码。如果当前栈空间不足,就分配一个更大的栈,把旧数据复制过去,然后更新所有指向旧栈的指针。


问题4:程序崩溃时的错误信息(stack trace)是怎么产生的?

💭 思考方向:

  • panic时Go打印的调用栈信息
  • 每个栈帧包含返回地址
  • 如何从返回地址推断出函数名和行号?

📖 参考答案:

// 当panic发生时
goroutine 1 [running]:
main.divide(0x0)
        /home/user/main.go:10 +0x28
main.calculate()
        /home/user/main.go:15 +0x1f
main.main()
        /home/user/main.go:20 +0x17

产生过程:

  1. 遍历栈帧:从当前SP开始,沿着保存的栈基址链逐帧回溯
  2. 提取返回地址:每个栈帧中都有返回地址
  3. 地址→函数名:查可执行文件中的符号表和调试信息(DWARF)
  4. 地址→行号:查调试信息中的行号表
栈回溯过程:
                                     符号表/DWARF
SP → divide栈帧 → 返回地址0x4a1f ────→ main.calculate:15
     ↓ 栈基址链
     calculate栈帧 → 返回地址0x4a37 ──→ main.main:20
     ↓
     main栈帧 → 返回地址0x42e0 ──────→ runtime.main

这也解释了为什么 go build -ldflags="-s -w" 去掉调试信息后,panic输出的堆栈信息会少很多。


问题5:CPU只是在重复”取指-执行”,是什么让它能做那么多复杂的事?

💭 思考方向:

  • 最简单的操作:加减、比较、跳转、读写内存
  • 一切复杂行为都由简单操作组合而来
  • 这和”抽象层次”有什么关系?

📖 参考答案: CPU的指令集其实很简单,通常就这几类:

类别指令示例做什么
算术ADD, SUB, MUL数学运算
逻辑AND, OR, XOR位运算
数据移动MOV, LOAD, STORE寄存器↔内存
比较CMP, TEST设置标志位
跳转JMP, JNE, CALL, RET改变PC

但通过组合和抽象,这些简单操作构建了一切:

简单指令
  → 函数(封装一组指令)
    → 模块(封装一组函数)
      → 程序(封装多个模块)
        → 操作系统(管理多个程序)
          → 分布式系统(协调多个操作系统)

这就是计算机科学最深刻的原理之一:通过分层抽象,从简单构建复杂。

Go语言实践

package main
 
import (
    "fmt"
    "runtime"
)
 
func innerFunc() {
    // 获取调用栈信息
    buf := make([]byte, 4096)
    n := runtime.Stack(buf, false)
    fmt.Printf("=== 调用栈 ===\n%s\n", buf[:n])
}
 
func middleFunc() {
    innerFunc()
}
 
func main() {
    middleFunc()
}
# 运行查看调用栈
$ go run main.go
# 你会看到 main.main → main.middleFunc → main.innerFunc 的调用链
 
# 查看汇编中的函数调用
$ go tool compile -S main.go 2>&1 | grep -A5 "CALL"
# 可以看到 CALL 指令和返回地址
 
# 查看编译后的函数序言(栈增长检查)
$ go tool compile -S main.go 2>&1 | grep -A3 "main.main"
# 开头会有 CMPQ SP, stackguard 之类的栈检查代码
// 演示栈溢出(Go会自动增长栈,不会像C那样直接崩溃)
package main
 
import "fmt"
 
func recursive(n int) int {
    if n <= 0 {
        return 0
    }
    // Go会在需要时自动扩展goroutine的栈
    return n + recursive(n-1)
}
 
func main() {
    // 即使递归很深,Go也能处理(但有goroutine栈大小上限,默认1GB)
    result := recursive(100000)
    fmt.Println("Result:", result)
}

小结

⭐ 核心要点:

  1. 取指-译码-执行:CPU的基本循环,程序计数器(PC)控制执行流
  2. 调用栈管理函数调用:栈帧保存返回地址和局部变量,panic的堆栈追踪就是在遍历栈帧
  3. Go的栈与众不同:初始很小、可以增长、每个goroutine一个独立栈

关联阅读