你敲下 print("Hello"),或者 fmt.Println("Hello"),回车,屏幕上多出一行字。这一秒里,这句代码先被切成一个个词,又被拼成一棵树,再编译成字节码或汇编,接着和别的碎片缝成可执行文件,最后在 CPU 里变成时钟节拍下流动的高低电平。下面就把这条链路从字符一路走到电信号的过程拆开看。
CPU 的世界里只有开和关
先把终点说清楚:CPU 不认识你写的任何语言。它内部只有一种东西在工作,就是晶体管。
晶体管可以当成一个电压控制的开关。以最常见的 MOS 管为例,它有三个极:源极、漏极、栅极。栅极不加电压,源漏之间断开;加了足够电压,中间形成导电沟道,电流通过。于是开关只有通和断两种状态,对应数字电路里的 1 和 0。
单个开关做不了什么,把若干个按特定方式连起来,就得到逻辑门。与门要两个输入都是 1 才输出 1,或门只要有一个 1 就输出 1,非门把输入反过来。用这些门再往上搭,加法器、多路选择器、触发器就出来了。触发器能锁住一位数据,直到被新的信号改写,寄存器和缓存都靠它。
把这些零件大规模集成,就是算术逻辑单元、寄存器堆和控制单元。今天一颗旗舰图形芯片(比如 RTX 5090 用的 GB202)里集成了大约 922 亿个晶体管,用的是台积电 4NP 工艺;苹果 2026 年 8 月发布的 M6 是它第一颗 2 纳米芯片。数量听着吓人,但每一个都只是开关。
所以 “Hello” 想出现在屏幕上,最终一定得落到某条电路上的一串高低电平。剩下的问题就是:怎么变过去。
起点:一行代码只是一串字节
你在编辑器里看到的 print("Hello"),存到硬盘上就是 14 个字节。p、r、i、n、t 各占一个,左括号一个,双引号一个,H、e、l、l、o 各一个,再加右引号和右括号。没有 “代码” 这种东西,只有字节。
编码这一步也值得留意。Python 3.15 起,源码默认按 UTF-8 读取(PEP 686);Go 同样规定源文件必须是 UTF-8。所以编译器拿到的是确定的一串字节,接下来要做的事,是把它重新切成人能理解、程序也好处理的单位。
词法分析:把字符流切成 token
编译的第一个阶段叫词法分析,也叫扫描。它从左到右读这些字节,按事先定好的规则认出一个个 “词”,每个词记成一个 token,通常带两部分信息:它属于什么类别,以及它的字面值。
用 CPython 自带的工具就能看到结果。假设文件里只有一行:
print("Hello")运行 python -m tokenize,输出是:
0,0-0,0: ENCODING 'utf-8'
1,0-1,5: NAME 'print'
1,5-1,6: OP '('
1,6-1,13: STRING '"Hello"'
1,13-1,14: OP ')'
1,14-1,15: NEWLINE '\n'
2,0-2,0: ENDMARKER ''print 被识别成 NAME,括号是 OP,带引号的 Hello 是 STRING。最前面那列数字是它在源码里的位置,方便后面报错时指出第几行、第几列。
常见的 token 类别大致是这几类:
| 类别 | 例子 |
|---|---|
| 关键字 | if、def、import |
| 标识符 | print、main |
| 字面量 | 字符串 “Hello”、整数 42 |
| 运算符与分隔符 | 加号、等号、括号、分号 |
词法规则本身不复杂,用正则表达式或有限自动机就能描述:读到一个字母,就往后一直读到非字母数字下划线为止;读到引号,就一路找到配对的引号。规则简单,但把它和后面的语法分析分开,能让整个流程更快,也更好维护。括号或引号没配对这类错误,通常在这一层就报出来了。
语法分析:把 token 拼成抽象语法树
token 流还是一条线,程序的结构却是立体的。语法分析就是按语言的文法,把这条线组合成一棵树,这棵树叫抽象语法树(AST)。
文法用 BNF 一类的记法描述,比如 “语句可以是一条表达式” 或者 “表达式可以是名字后面跟参数列表”。有了文法,解析器就能判断 token 的顺序合不合法,并把合法的序列整理出层次。
还是那行 print("Hello"),CPython 给出的 AST 是这样:
Module(
body=[
Expr(
value=Call(
func=Name(id='print', ctx=Load()),
args=[
Constant(value='Hello')]))])读法很直白:一个模块,里面有一条表达式语句,语句的值是一次调用,被调用的名字是 print,参数是一个常量 “Hello”。分号、括号、换行这些表面符号在这棵树里都不见了,留下的是纯粹的调用关系。
CPython 从 3.9 开始换成了 PEG 解析器(PEP 617),它的输入是 token 流而不是字符流,这一点和多数 PEG 解析器的习惯不太一样。Python 的文法写在 Grammar 目录下的 python.gram 里,AST 节点的规格用 ASDL 写在 Parser/Python.asdl 里,解析器的 C 代码由 Tools 下的生成器自动产出。Go 则在标准库里提供了 go/parser 和 go/ast 两个包,想自己写工具分析 Go 代码,从这两个包入手最省事。
少写一个右括号、缩进对不齐,报错就发生在这一层。
语义分析与中间表示:让机器读懂你的意图
树搭好了,编译器还不能直接生成代码。它得先搞清楚每个名字指的是谁,类型对不对。这一步通常叫语义分析,做的是引用消解、类型检查、作用域管理这些事,顺手把变量和函数的类型、位置记进符号表,供后面查用。
之后是生成中间表示,也就是 IR。为什么要多这一层?因为源语言和目标机器差别太大,直接在汇编上做优化,换个 CPU 架构就得重写一遍。把优化放在与硬件无关的 IR 上做,前端共用一套,后端各管各的。主流的 IR 有 LLVM IR、GCC 用的 GIMPLE、Go 的 SSA 形式,CPython 用的则是它自己的字节码。
优化就在这一层展开:常量折叠把 1 加 2 直接算成 3,死代码消除扔掉永远走不到的分支,循环优化把重复计算提到循环外,寄存器分配决定哪些值留在最快的地方。你写的源码和机器真正执行的指令之间,差距主要来自这里。
分岔路口:字节码路线和机器码路线
走到这里,路分成两条。
路线一:Python 的字节码
CPython 把 AST 编译成字节码,一种精简的、与具体 CPU 无关的指令序列,缓存进 pycache 目录,文件名类似 hello.cpython-313.pyc。用标准库 dis 模块可以反汇编看一眼:
0 RESUME 0
1 LOAD_NAME 0 (print)
PUSH_NULL
LOAD_CONST 0 ('Hello')
CALL 1
POP_TOP
RETURN_CONST 1 (None)这几条指令的意思依次是:把名字 print 取到栈上,压入一个空占位,把常量 “Hello” 取到栈上,调用一个参数的函数,丢掉返回值,最后返回 None。字节码不直接对应机器码,而是由 Python 虚拟机一条条读出来执行。
Python 也在往编译方向走。3.15 的实验性 JIT 在 x86-64 Linux 上比标准解释器快约 7% 到 8%,在 AArch64 macOS 上比尾调用解释器快约 11% 到 12%。3.14 已经让自由线程构建成为官方支持选项,多线程不再被一把全局锁卡住。
路线二:C 和 Go 的机器码
编译型语言走到这里,产出的是真正的机器指令。拿一段最小的 C 代码举例,它只做一件事,输出一行字:
int puts(const char *s);
int main(void) {
puts("Hello, world!");
return 0;
}用 gcc -O2 编译成汇编,核心部分是这样(这里改用 Intel 语法书写,读起来顺一点):
main:
endbr64
sub rsp, 8
lea rdi, .LC0[rip]
call puts@PLT
xor eax, eax
add rsp, 8
ret一条条看:endbr64 是留给处理器的安全标记,sub rsp, 8 腾出 8 字节栈空间,lea 把字符串地址送进 rdi 这个参数寄存器,call 调用 puts,xor 把返回值置零,add 恢复栈指针,ret 返回。所谓的 “编译”,到这里算是把意思翻译完了。
两条路线差在哪,列个表更清楚:
| 对比项 | C / Go | Python |
|---|---|---|
| 产物 | 原生机器码 | 字节码文件 |
| 执行者 | CPU | Python 虚拟机 |
| 是否绑定架构 | 绑定,换架构要重编 | 不绑定,字节码跨平台 |
| 首次运行开销 | 编译时付出 | 边解释边执行,JIT 另有预热 |
| 典型文件 | ELF 可执行文件 | cpython-313.pyc |
汇编与链接:把碎片拼成可执行文件
上面那段汇编还不是最终形态。汇编器把它翻译成机器码,装进目标文件,后缀 .o。用 objdump 看一眼,每条汇编都对应一串十六进制字节:
| 偏移 | 机器码字节 | 说明 |
|---|---|---|
| 0 | f3 0f 1e fa | endbr64,间接跳转的安全标记 |
| 4 | 48 83 ec 08 | 为栈指针保留 8 字节 |
| 8 | 48 8d 3d 00 00 00 00 | 取字符串地址,具体数值待定 |
| f | e8 00 00 00 00 | 调用 puts,目标地址待填 |
| 14 | 31 c0 | 返回值清零 |
| 16 | 48 83 c4 08 | 恢复栈指针 |
| 1a | c3 | 返回 |
注意两处全零的 00 00 00 00。字符串在哪、puts 在哪,此刻都还不知道,汇编器先填零占位。谁来补?链接器。用 readelf 看目标文件的重定位表,能看到两条待填记录,一条指向字符串常量,一条指向 puts:
Relocation section '.rela.text.startup' at offset 0x1a0 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
00000000000b 000300000002 R_X86_64_PC32 0000000000000000 .LC0 - 4
00000000000010 000500000004 R_X86_64_PLT32 0000000000000000 puts - 4链接器把若干目标文件合并成一个,给每个符号定下最终地址,再回填这些占位。puts 本身不在你的代码里,它在 libc 里,所以最终产物要声明依赖 libc.so.6,运行时由动态加载器去解析。汇编里写成 puts@PLT,也是这个原因。
链接完成后就是可执行文件。用 readelf 看它的头部,会看到类型是 DYN(位置无关可执行文件),入口地址 0x1080。这个文件不再是一堆字节,而是一张交给操作系统的说明书:代码放哪、数据放哪,从哪开始执行。
Go 有点不同,它自带汇编器和链接器,go build 一条命令走完编译、汇编、链接,默认还把运行时静态链接进去,所以一个 Go 程序直接拷到别的机器上往往就能跑。
终点站:在 CPU 里,指令变成电信号
程序能跑,靠的是操作系统。你在终端敲下可执行文件的名字,shell 调 execve,内核把文件映射进地址空间,把指令指针设到入口地址,然后 CPU 开始一条条取指令。
经典的执行流程分五步:取指、译码、执行、访存、写回。取指从内存取来指令字节;译码器把这些字节拆成操作和控制信号;执行单元按信号动起来;需要的话读写内存;最后把结果写回寄存器。现代处理器把这五步排成流水线,让不同阶段的指令同时推进,再加上乱序执行、分支预测和多发射,一秒钟能推进几十亿条指令。
关键在译码那一步。前面那张表里的 f3 0f 1e fa,译码器认出来是 endbr64;48 8d 3d 后面跟着四个字节,它知道这是一条取地址的指令。这些判断完全由门电路实现:某个比特是 1,对应的线路上就有高电平,与门、或门据此打开或关闭,控制信号就出来了。指令是二进制的,二进制的每一位落到物理上,就是一条线的高低电平。
以 3GHz 的处理器来算,一秒里有 30 亿个时钟周期。每一个跳变,都有几百万到上亿个晶体管在同时开关。
最典型的一幕在最后。puts 把 “Hello, world!” 的字节准备好,发起一次写操作,这些字节从内存经过寄存器交给内核,内核再交给终端,屏幕上于是多出一行字。你看到的是文字,底下走的全是电平。
顺手记几个数字
写这类文章最怕版本过时,所以把截至 2026 年 10 月的几个事实放在这里。
| 项目 | 状态 |
|---|---|
| CPython | 3.15.0 于 2026 年 10 月 9 日发布,默认使用 UTF-8 |
| Python 3.14 | 2025 年 10 月 7 日首发,此后持续发布补丁版本 |
| Go | 1.27.0 于 2026 年 8 月 19 日发布,约半年一版 |
| GCC | 16.1 于 2026 年 4 月 30 日发布 |
| LLVM | 21.1 于 2025 年 8 月 26 日发布 |
| 制程 | 台积电 2 纳米已于 2025 年第四季度量产 |
| 芯片规模 | 旗舰图形芯片 GB202 集成约 922 亿个晶体管 |
这些数字都能在官方站点查到。Python 侧有 3.15 新特性说明 和 下载页;Go 侧有 下载页 与 版本历史;编译器和硬件侧可以看 GCC 官网 与 LLVM 官网。
搞懂这条链路有什么用
最直接的好处是定位问题。SyntaxError 出在词法和语法层,多半是标点写错了;TypeError、NameError 出在语义层,说明名字或类型没对上;程序能跑但慢,那多半要去 IR 优化和汇编这一层找答案。
再往深一点,知道哪种写法会多出一条指令,知道哪些数据能进缓存,写出来的代码自然更对硬件友好。换语言时也用得上,Go 的逃逸分析决定变量分在栈上还是堆上,Python 的自由线程构建改变了并发代码的代价模型,这些其实是同一套底层逻辑的不同表现。
说白了,抽象是工具,不是遮羞布。知道它底下压着什么,出问题时才有方向。
写在最后
回到最开始那行 print("Hello")。它经过了切词、拼树、检查、翻译、优化、汇编、链接、装载八个环节,才变成 CPU 里一串流动的电平。这一整套不是谁一夜想出来的,是几十年里一层层叠上去的。
下次敲下回车,屏幕上跳出那行字的时候,不妨想一想这一秒里都发生了什么。