bigywater.tech
Go · 45

Go 二进制为什么这么大:符号表、反射元数据与 -ldflags -s -w

Go 的二进制大,主要因为它自带运行时、垃圾回收、类型信息与完整的函数表。这篇用真实构建量出:不同依赖带来多少、-s -w 去掉了什么、pclntab 是什么、体积都花在哪些包上,以及反射用法怎样让链接器没法丢掉不用的方法。

~0.9 MiB
只要用了 reflect.Value.MethodByName,二进制就多这么多(再加每个方法约 470 字节)
33%–35%
-ldflags="-s -w" 对各种程序的体积缩减
pclntab
与 __text 一样大(650154 与 632260 字节);panic 回溯靠它,剥离符号后函数名仍在

依赖决定体积

实测 · 本机执行输出
  println 版(不导入 fmt):默认 1831778 字节(1.75 MiB);-ldflags="-s -w" 1195250 字节(1.14 MiB),小 35%
  fmt.Println 版:默认 2429714 字节(2.32 MiB);-ldflags="-s -w" 1587714 字节(1.51 MiB),小 35%
  加 encoding/json:默认 3766466 字节(3.59 MiB);-ldflags="-s -w" 2477826 字节(2.36 MiB),小 34%
  加 net/http 客户端:默认 8316338 字节(7.93 MiB);-ldflags="-s -w" 5612050 字节(5.35 MiB),小 33%
  加 net/http 服务端:默认 8217154 字节(7.84 MiB);-ldflags="-s -w" 5544514 字节(5.29 MiB),小 33%

最小的 hello world 约 1.75 MiB(只用内建 println,不导入 fmt),fmt 带来 0.57 MiB,encoding/json 再加 1.27 MiB,net/http 把它推到 7.8–7.9 MiB——因为它拖进 TLS、x509、HTTP/2、net 等一大串包。体积主要是“导入了什么”,而不是你自己写了多少代码。

-s 与 -w

实测 · 本机执行输出
  默认:2429714 字节(2.32 MiB)
  -ldflags=-s(去掉符号表):1587714 字节(1.51 MiB)
  -ldflags=-w(去掉 DWARF 调试信息):1686674 字节(1.61 MiB)
  -ldflags="-s -w":1587714 字节(1.51 MiB)
  -trimpath:2396642 字节(2.29 MiB)
  -trimpath 加 -s -w:1571202 字节(1.50 MiB)
  size -m(默认构建)里的段:
    Segment __PAGEZERO: 4294967296 (zero fill)
    Segment __TEXT: 1343488
    Section __text: 632260
    Section __symbol_stub1: 696
    Section __rodata: 40578
    Section __gopclntab: 650154
    Segment __DATA_CONST: 144880
    Section __rodata: 7296
    Section __go_type: 136160
    Section __go_func: 960
    Section __got: 464
    Segment __DATA: 213424
    Section __go_buildinfo: 224
    Section __go_fipsinfo: 120
    Section __go_module: 568
    Section __noptrdata: 19266

-s 去掉符号表,-w 去掉 DWARF 调试信息;实测只写 -s 就和 -s -w 一样大(DWARF 随符号表一起没了),只写 -w 单独能省约 0.71 MiB(2429714 → 1686674 字节)。-trimpath 只是把路径换成相对形式,体积只小一点(省约 33 KB)。代价是 go tool nm 与调试器能看到的东西变少,不影响运行,也不影响 panic 的栈回溯。

体积用在哪

实测 · 本机执行输出
  nm 里占文件空间的符号(类型 T/R/D 等,不含 BSS)合计 5483407 字节(文件 8217154 字节);BSS 符号合计 33779728 字节,最大的是 crypto/internal/fips140/drbg.memory(33554432 字节)——BSS 不占文件空间,但运行时要映射内存
    runtime                            2319311 字节  42.3%
    _type:*                             535536 字节   9.8%
    go:*(编译器生成的数据)                      483400 字节   8.8%
    vendor/golang                       224122 字节   4.1%
    crypto/tls                          222035 字节   4.0%
    crypto/internal/fips140/nistec      137648 字节   2.5%
    net                                 136336 字节   2.5%
    net/http/internal/http2             123867 字节   2.3%
    net/http                            107073 字节   2.0%
    crypto/x509                          92394 字节   1.7%
  最大的 5 个符号:
     1681680  runtime.pclntab
      535536  _type:*
      365592  go:func.*
      116128  runtime.rodata
      116128  go:string.*

按符号统计,运行时(含它的类型与函数表)占了 4 成以上,最大的单个符号是 runtime.pclntab(程序计数器到函数、文件、行号的映射表,栈回溯、垃圾回收的栈扫描都要用它);_type:* 是类型描述符(反射与接口用)。BSS 里有一个 32 MiB 的 crypto/internal/fips140/drbg.memory:不占文件空间,但会占运行时的内存映射(我没有量它的实际驻留内存)。

剥离后还有函数名

实测 · 本机执行输出
  默认:1831906 字节;go tool nm 可用,符号 1826 个;panic 回溯里的函数:main.boom(...) main.main()
  -ldflags="-s -w":1195362 字节;go tool nm 可用,符号 42 个;panic 回溯里的函数:main.boom(...) main.main()

剥离符号表后 go tool nm 从 1826 个符号变成 42 个,但 panic 的回溯照样显示 main.boom,因为回溯读的是 pclntab。所以生产构建加 -s -w 通常是安全的;想保留符号以便事后分析就别加。

反射与死代码消除

实测 · 本机执行输出
  不用反射,只直接调 M1:默认 2429698 字节、-s -w 1587714 字节;符号表里 main.T.M<数字> 的函数有 0 个
  reflect.TypeOf(...).Name()(用了反射但不调方法):默认 2446258 字节、-s -w 1604258 字节;符号表里 main.T.M<数字> 的函数有 0 个
  reflect.Value.MethodByName(动态名字):默认 3444562 字节、-s -w 2276322 字节;符号表里 main.T.M<数字> 的函数有 200 个
  同一个类型的方法数 N 变化时(都用 MethodByName,默认构建):
    N =   0:3349922 字节,比 N = 0 多 0 字节
    N =  20:3367602 字节,比 N = 0 多 17680 字节(每个方法约 884 字节)
    N = 100:3405378 字节,比 N = 0 多 55456 字节(每个方法约 555 字节)
    N = 200:3444562 字节,比 N = 0 多 94640 字节(每个方法约 473 字节)

链接器会丢掉没被引用的函数,但如果程序里出现 MethodByName 这样“按名字找方法”的反射调用,编译器无法知道会调哪个,所以把能被反射到的导出方法全部保留(实验里 200 个方法全留着,符号表里 200 个)。仅仅用 reflect.TypeOf 不会触发。这一点只看到现象,我没有读链接器里决定保留的那段代码。

不同平台

实测 · 本机执行输出
  darwin/arm64:默认 2429714 字节(2.32 MiB);-s -w 1587714 字节(1.51 MiB)
  linux/amd64:默认 2346705 字节(2.24 MiB);-s -w 1515680 字节(1.45 MiB)
  linux/arm64:默认 2342341 字节(2.23 MiB);-s -w 1573024 字节(1.50 MiB)
  windows/amd64:默认 2496512 字节(2.38 MiB);-s -w 1660416 字节(1.58 MiB)

交叉编译出的 hello world 大小相近,windows 稍大。数字都是 CGO_ENABLED=0、go build 默认选项的结果,没有试 -gcflags、UPX 等其它减小体积的手段。

分步演示

二进制体积
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。所有大小来自真实构建(go build 默认选项,CGO_ENABLED=0);段大小用 macOS 的 size -m;符号大小用 go tool nm -size(BSS 符号单独统计);panic 回溯来自真实运行
    • 没有验证:UPX 压缩、-gcflags 与 -ldflags 的其它体积选项、链接器死代码消除的具体算法、BSS 的 32 MiB 在运行时的实际驻留内存、Linux 上 ELF 各段的占用(只在 macOS 上看了段)、go build -buildmode=pie 等其它构建模式
    京ICP备2026067502号-1京公网安备11010502063487号