Golang 二进制文件的静态和动态链接

概述

Golang 最大的优势之一就是支持跨平台编译,真正的实现了Java的梦想:一次编译,到处运行。

不过,在某些特定情况下,也会存在一些特殊之处。

静态链接与动态链接

静态链接是是在编译时将程序与所依赖的所有库(通常是 .a.lib 文件)一起打包,生成一个独立的可执行文件。也就是说,所有需要的函数和库代码在编译过程中就已经嵌入到可执行文件中。

因为这样编译出来的二进制更便携,它不需要在其运行的主机系统上存在库。因此,你的二进制文件可以在任何系统上运行,无论哪个发行版/版本,并且不依赖于任何系统库。

动态链接是在程序运行时(即执行时)将程序与共享库(如 .dll.so.dylib 文件)链接。程序本身包含对这些库的引用,而不直接包含库的代码。程序启动时,操作系统会加载这些库文件并链接到程序。

这种方式的优点是比较灵活,如果依赖的库发生了更新,只要接口没变,程序不需要编译就可以使用最新版本的库,而且因为二进制包含对库的引用,不包含库代码,因此文件较小。

静态链接程序

我们先来编写一个简单的 “Hello World” 程序,,经过打包后,它将成为一个静态链接的二进制文件。

package main  
  
func main() {  
    println("hello world!")  
}

接着编译这个 Go 文件:

go build -o main1 main.go

然后使用 file 命令来检查文件类型:

$ file main1 | tr , '\n' 
hello_linux: ELF 64-bit LSB executable
 x86-64
 version 1 (SYSV)
 statically linked
 Go BuildID=...
 with debug_info
 not stripped

从上述命令的输出结果可知,main1 文件属于 64 位静态链接的 ELF 格式 可执行文件,适用于 x86-64 架构,包含了 调试信息,并且没有进行 剥离(即保留了调试符号)。

另外,还有 Linux 程序 ldd 能够帮助我们判断二进制文件是静态链接还是动态链接,执行以下命令:

$ ldd main1
not a dynamic executable

动态链接程序

在 Golang 中,有一种名为 CGO 的机制,它可以用来调用 C 代码,就连 Go 的标准库也在使用这一机制,例如 net 包(https://pkg.go.dev/net),其就借助了 C 库来处理 DNS。以下是相关示例代码:

package main  
  
import (  
    "fmt"  
    "log"    
    "net"
)  
  
func main() {  
    ns, err := net.LookupNS("typesafe.cn")  
    if err != nil {  
       log.Fatal(err)  
    }  
    for _, n := range ns {  
       fmt.Printf("%+v\n", n)  
    }  
}

同样需要编译这个 Go 文件:

go build -o main2 main2.go

之后,我们再次利用 file 和 ldd 程序来检查第二个二进制文件,操作及结果如下:

$ file main2 | tr , '\n'
main2: ELF 64-bit LSB executable
 x86-64
 version 1 (SYSV)
 dynamically linked
 interpreter /lib64/ld-linux-x86-64.so.2
 Go BuildID=...
 with debug_info
 not stripped
$ ldd main2
        linux-vdso.so.1 (0x00007ffc09ec8000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f3651936000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f3651b21000)

由此可以看出,main2 是一个 64 位动态链接的 ELF 可执行文件,其运行依赖于 glibclibc.so.6)以及 动态链接器ld-linux-x86-64.so.2),并且包含 调试信息,未进行剥离操作。在运行时,会通过 动态链接器 来加载这些共享库。

将动态链接转为静态链接

有意思的是,Go 的 net 包提供了纯 Go 实现,这使得我们在编译时可以禁用 cgo。具体而言,可以通过指定构建标签或者设置 CGO_ENABLED=0 来彻底禁用 cgo。示例如下:

$ CGO_ENABLED=0 go build -o main2 main2.go
$ ldd main2
not a dynamic executable

从上述输出结果能够证实,通过这两种方式操作后,最终都能得到一个静态二进制文件。

交叉编译

如文章开头所提到的,交叉编译是 Golang 的一大显著优势,它能够让代码在各种不同的平台与架构上运行。以下是一些常见的交叉编译命令示例:

# 编译为 macOS 上的 amd64 架构
GOOS=darwin GOARCH=amd64 go build -o main2_darwin_amd64 main2.go

# 编译为 macOS 上的 arm64 架构
GOOS=darwin GOARCH=arm64 go build -o main2_darwin_arm64 main2.go

# 编译为 Linux 上的 amd64 架构
GOOS=linux GOARCH=amd64 go build -o main2_linux_amd64 main2.go

# 编译为 Linux 上的 arm64 架构
GOOS=linux GOARCH=arm64 go build -o main2_linux_arm64 main2.go

# 编译为 Linux 上的 386 架构 (32-bit)
GOOS=linux GOARCH=386 go build -o main2_linux_386 main2.g
...

需要注意的是,前提是没有使用 CGO,一旦使用了 CGO,那么运行平台基本就会和开发平台绑定起来了。

减少二进制大小

或许你已经留意到,在上述 file 命令输出结果中出现了 “with debug_info not stripped” 这样的提示,这意味着二进制文件包含了调试信息。通常情况下,我们并不需要这些调试信息,将其删除能够有效减小二进制文件的大小,示例操作及结果如下:

$ CGO_ENABLED=0 go build -o main2 main.go
$ ll -h main2 
-rwxr-xr-x 1 root root 2.9M  1月 5日 15:32 main2
$ file main2 | tr , '\n'
main2: ELF 64-bit LSB executable
 x86-64
 version 1 (SYSV)
 statically linked
 Go BuildID=...
 with debug_info
 not stripped
$ CGO_ENABLED=0 go build -ldflags "-s -w" -o main2 main.go
$ ll -h main2 
-rwxr-xr-x 1 root root 2.0M  1月 5日 15:33 main2
$ file main2 | tr , '\n'
main2: ELF 64-bit LSB executable
 x86-64
 version 1 (SYSV)
 statically linked
 Go BuildID=...
 stripped

结语

静态链接和动态链接各有千秋,根据自身的业务选择合适的方案即可。