Golang编译和链接

2,083 阅读9分钟

前言

我们知道编译 go 文件只需要执行 go build 命令,然后就可以生成 exe 文件。其实 go build 的过程大体上(注意并不是完全等价于)分为两步:compile + link。本篇文章尝试把 go build 流程梳理清楚。

好书引入

以下摘抄自《程序是怎么跑起来的》第 8 章:


能够把 C 语言等高级编程语言编写的源代码转换成本地代码(机器代码)的程序称为编译器。每个编写源代码的编程语言都需要其专用的编译器。将 C 语言编写的源代码转换成本地代码的编译器称为 C 编译器。

编译器首先读入代码的内容,然后再把源代码转换成本地代码(机器代码)。编译器中就好像有一个源代码同本地代码的对应表。但实际上,仅仅靠对应表是无法生成本地代码的。读入的源代码还要经过语法解析、句法解析、语义解析等,才能生成本地代码。

根据 CPU 类型的不同,本地代码的类型也不同。因而,编译器不仅和编程语言的种类有关,和 CPU 的类型也是相关的。例如,Pentium等 x86 系列 CPU 用的 C 编译器,同 PowerPC 这种 CPU 用的 C 编译器就不同。从另一个方面来看,这其实是非常方便的。因为这样一来,同样的源代码就可以翻译成适用于不同 CPU 的本地代码了:

因为编译器本身也是程序的一种,所以也需要运行环境。例如,有 Windows 用的 C 编译器、Linux 用的 C 编译器等。 此外, 还有一种交叉编译器,它生成的是和运行环境中的 CPU 不同的 CPU 所使用的本地代码。例如,在 Pentium 系列 CPU 的 Windows 这一运行环境下,也可以作成 SHA及 MIPS 等 CPU 用的 Windows CEB 程序,而这就是通过使用交叉编译器来实现的。


交叉编译在目标系统平台(开发出来的应用程序序所运行的平台)难以或不容易编译时非常有用。

go 语言是支持交叉编译的,可以在A平台编译出B或者C平台的可执行程序。

交叉编译常用的两个变量:

  • GOOS:目标操作系统
  • GOARCH:目标操作系统的架构
OSARCH系统版本
linux386/amd64/arm/arm64/>=Linux 2.6
windows386/amd64>=Windows 2000
darwin386/amd64OS X(Snow Leopard + Lion)

现在尝试在 windows 下编译 linux 平台的可执行文件:

默认编译

D:\workspace\mypro>go env
set GOARCH=amd64
//...
set GOOS=windows
//...

要编译 main.go 文件,运行 go build main.go,可以在当前目录下得到一个 main.exe 文件

image.png

交叉编译

D:\workspace\mypro>go env
set GOARCH=arm
//...
set GOOS=linux
//...

运行 go build main.go,发现在当前目录下得到一个名字为 main 且无后缀的 linux 可执行文件

image.png

查看对比一下两个可执行文件的格式:

D:\workspace\mypro\main>file main
main: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, Go BuildID=wnldC7cNGTIi0s34sMuo/eSTbp2sLmqH0E38oBstMpfh1UXZCfxHTt05H2vBp, not stripped

D:\workspace\mypro\main>file main.exe
main.exe: PE32+ executable (console) x86-64 (stripped to external PDB), for MS Windows

编译过程简介

注意这里说的编译指的是 compile 而不是 go build,go build 解释为构建更合适。

简单来说,每个模块的源代码文件(如.go)经过编译器编译成目标文件(Object.File,一般扩展名为.o或.obj)。

关于目标文件这个名称的由来,解释摘自《程序员的自我修养》:

我们认为对于Object文件没有一个很合适的中文名称,把它叫做中间目标文件比较合适,简称为目标文件。

细分整个编译过程的话,编译过程包括词法分析,语法分析,语义分析等等到机器代码生成: go-byq-3.png

尝试编译以下示例代码:

package hello

import "fmt"

func sayHello() string{
   res:="hello world!"
   return res
}

func main() {
   str:=sayHello()
   fmt.Println(str)
}

执行 go tool compile hello.go,可以看到在当前目录下生成了一个 hello.o 文件,也就是目标文件

链接过程简介

上面例子编译后生成的不是 exe 文件,而是扩展名为 “.o” 的目标文件。虽然目标文件的内容是机器代码,但却无法直接运行。那么这是为什么呢?原因就是当前程序还处于未完成状态。

我们再来看一遍上面的示例代码。sayHello() 函数和 main() 函数是我们自己写的,处理内容记录在源代码中。另外还有个 fmt.Println() 函数,用于打印传入的数据内容,但是我们这个 .go 文件源代码并没有记录 fmt.Println() 函数的代码逻辑。因此,这时就必须将存储着 fmt.Println() 函数的代码逻辑的目标文件同 hello.o 结合,否则处理就不完整,exe 文件也就无法完成。

把多个目标文件结合,生成1个 exe 文件的处理就是链接,运行连接的程序就称为链接器

以下摘自《程序员的自我修养》:

现代的编译和链接过程也并非想象中的那么复杂, 它还是一个比较容易理解的概念。比如我们在程序模块 main.c 中使用另外一个模块 func.c 中的函数 foo()。我们在 main.c 模块中每一处调用 foo 的时候都必须确切知道 foo 这个函数的地址,但是由于每个模块都是单独编译的,在编译器编译 main.c 的时候它并不知道 foo 函数的地址,所以它暂时把这些调用 foo 的指令的目标地址搁兽,等待最后链接的时候由链接器去将这些指令的目标地址修正。如果没有链接器,须要我们手工把每个调用 foo 的指令进行修正,则填入正确的 foo 函数地址。当 func.c 模块被重新编译,foo 函数的地址有可能改变时,那么我们在 main.c 中所有使用到foo 的地址的指令将要全部重新调整。这些繁琐的工作将成为程序员的址梦。使用链接器, 你可以直接引用其他模块的函数和全局变量而无须知道它们的地址, 因为链接器在链接的时候,会根据你所引用的符号 foo,白动去相应的 func.c 模块查找 foo 的地址,然后将 main.c模块中所有引用到 foo 的指令重新修正,让它们的目标地址为真止的 foo 函数的地址。这就是静态链接的最基本的过程和作用。

在链接过程中, 对其他定义在目标文件中的函数调用的指令须要被重新调整,对使用其他定义在其他目标文件的变量来说,也存在同样的问题。让我们结合具体的 CPU 指令来了解这个过程。假设我们有个全局变量叫 var,它在目标文件 A 里而。我们在目标文件 B 里面要访问这个全局变量, 比如我们在目标文件B里面有这么一条指令:movl SOx2a, var ,这条指令就是给这个 var 变量赋值 0x2a,相当于 C 语言里面的语句 var = 42。然后我们编译目标文件B,得到这条指令机器码,如下图所示。

图片.png

由于在编译目标文件B的时候,编译器并不知道变量 var 的目标地址.所以编译器在没法确定地址的情况下, 将这条 moy 指令的目标地址置为 0, 等待链接器在将目标文件 A 和 B 链接起来的时候再将其修正。 我们假设 A 和 B 链接后,变量 var 的地址确定下来为 0x1000,那么链接器将会把这个指令的目标地址部分修改成 Qx10000。这个地址修上的过程也被叫做重定位 (Relocation),每个要被修正的地方叫一个重定位入口(Relocation Entry)。重定位所做的就是给程序中每个这样的绝对地址引用的位置“打补丁”,使它们指向正确的地址。

.go 构建具体过程

简单示例程序:

package main

import "fmt"

func main() {
   fmt.Println("hello")
}

我们以示例程序为例,来说明 go 语言编译与链接的过程,我们可以使用go build命令,-x参数代表了打印执行的过程:

go build -x main.go

输出如下:

WORK=C:\Users\...\go-build2732852315
mkdir -p $WORK\b001\
cat >$WORK\b001\importcfg << 'EOF' # internal
# import config
packagefile fmt=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\fmt.a
packagefile runtime=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\runtime.a
EOF
cd D:\workspace\mypro
"D:\\go 1.16\\go1.16.windows-amd64\\go\\pkg\\tool\\windows_amd64\\compile.exe" -o "$WORK\\b001\\_pkg_.a" -trimpath "$WORK\\b001=>" -p main -complete -buildid lOPVa87WZrfyX4ZeZ1C-/lOPVa87WZ
rfyX4ZeZ1C- -goversion go1.16 -D _/D_/workspace/mypro -importcfg "$WORK\\b001\\importcfg" -pack -c=4 "D:\\workspace\\mypro\\main.go"
"D:\\go 1.16\\go1.16.windows-amd64\\go\\pkg\\tool\\windows_amd64\\buildid.exe" -w "$WORK\\b001\\_pkg_.a" # internal
cp "$WORK\\b001\\_pkg_.a" "C:\\Users\\...\\go-build\\76\\7693ee5428804757ec62d5cbb5a15cdedc847f54b770f13f343b9352b6cec441-d" # internal
cat >$WORK\b001\importcfg.link << 'EOF' # internal
packagefile command-line-arguments=$WORK\b001\_pkg_.a
packagefile fmt=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\fmt.a
packagefile runtime=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\runtime.a
packagefile errors=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\errors.a
packagefile internal/fmtsort=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\internal\fmtsort.a
packagefile io=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\io.a
packagefile math=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\math.a
packagefile os=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\os.a
packagefile reflect=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\reflect.a
packagefile strconv=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\strconv.a
packagefile sync=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\sync.a
packagefile unicode/utf8=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\unicode\utf8.a
packagefile internal/bytealg=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\internal\bytealg.a
packagefile internal/cpu=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\internal\cpu.a
//省略...
EOF
mkdir -p $WORK\b001\exe\
cd .
"D:\\go 1.16\\go1.16.windows-amd64\\go\\pkg\\tool\\windows_amd64\\link.exe" -o "$WORK\\b001\\exe\\a.out.exe" -importcfg "$WORK\\b001\\importcfg.link" -buildmode=pie -buildid=e-AHcHe-BzPb0s
ZCqmwN/lOPVa87WZrfyX4ZeZ1C-/kldYnFxwV0NcvzN0F8v_/e-AHcHe-BzPb0sZCqmwN -extld=gcc "$WORK\\b001\\_pkg_.a"
"D:\\go 1.16\\go1.16.windows-amd64\\go\\pkg\\tool\\windows_amd64\\buildid.exe" -w "$WORK\\b001\\exe\\a.out.exe" # internal
cp $WORK\b001\exe\a.out.exe main.exe
rm -r $WORK\b001\

下面我们对输出进行逐行分析

1.创建了一个临时目录,用于存放临时文件。默认情况下命令结束时自动删除此目录,如果需要保留添加work参数。

WORK=C:\Users\...\go-build2732852315
mkdir -p $WORK\b001\

2.生成编译配置文件 importcfg,配置着编译时需要依赖的包路径。

cat >$WORK\b001\importcfg << 'EOF' # internal
# import config
packagefile fmt=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\fmt.a
packagefile runtime=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\runtime.a
EOF

importcfg 文件配置了 fmt 包和 runtime 包的路径,其中 fmt 是 main.go 中引入的,而 runtime包是默认都会被引入。

3.执行 compile 命令,编译生成中间结果$WORK/b001/pkg.a

"D:\\go 1.16\\go1.16.windows-amd64\\go\\pkg\\tool\\windows_amd64\\compile.exe" -o "$WORK\\b001\\_pkg_.a" -trimpath "$WORK\\b001=>" -p main -complete -buildid lOPVa87WZrfyX4ZeZ1C-/lOPVa87WZ
rfyX4ZeZ1C- -goversion go1.16 -D _/D_/workspace/mypro -importcfg "$WORK\\b001\\importcfg" -pack -c=4 "D:\\workspace\\mypro\\main.go"

说明:

  • .a 文件由 compile 命令生成(通过 go tool compile 单独生成的是个 .o 文件,虽然文件名字不一样,但是内容是一样的,可理解为一个东西)。
  • .a 文件其实就是目标文件(object file),它是一个压缩包,内部包含了__.PKGDEF_go_.o  两个文件,分别为编译目标文件链接目标文件
  • .a 文件的格式和内容如下:
$ file _pkg_.a # 检查文件格式
_pkg_.a: current ar archive # 说明是ar格式的打包文件
$ ar x _pkg_.a #解包文件
$ ls
__.PKGDEF  _go_.o
  • ar 文件是一种非常简单的打包文件格式,广泛用于linux中静态链接库文件中,文件以字符串"!\n"开头。随后跟着60字节的文件头部(包含文件名、修改时间等信息),之后跟着文件内容。
  • 因为 ar 文件格式简单,Go编译器直接在函数中实现了ar打包过程。生成 ar 文件的代码如下:
func dumpobj1(outfile string, mode int) {
    bout, err := bio.Create(outfile)
    if err != nil {
        flusherrors()
        fmt.Printf("can't create %s: %v\n", outfile, err)
        errorexit()
    }
    defer bout.Close()
    bout.WriteString("!<arch>\n")

    if mode&modeCompilerObj != 0 {
        start := startArchiveEntry(bout)
        dumpCompilerObj(bout)
        finishArchiveEntry(bout, start, "__.PKGDEF")
    }
    if mode&modeLinkerObj != 0 {
        start := startArchiveEntry(bout)
        dumpLinkerObj(bout)
        finishArchiveEntry(bout, start, "_go_.o")
    }
}

startArchiveEntry用于预留ar文件头信息位置(60字节),finishArchiveEntry用于写入文件头信息,因为文件头信息中包含文件大小,在写入完成之前文件大小未知,所以分两步完成。

4.生成链接配置文件 importcfg.link,配置着链接时需要依赖的包路径。

cat >$WORK\b001\importcfg.link << 'EOF' # internal
packagefile command-line-arguments=$WORK\b001_pkg_.a
packagefile fmt=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\fmt.a
packagefile runtime=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\runtime.a
packagefile errors=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\errors.a
packagefile internal/fmtsort=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\internal\fmtsort.a
packagefile io=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\io.a
packagefile math=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\math.a
packagefile os=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\os.a
packagefile reflect=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\reflect.a
packagefile strconv=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\strconv.a
packagefile sync=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\sync.a
packagefile unicode/utf8=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\unicode\utf8.a
packagefile internal/bytealg=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\internal\bytealg.a
packagefile internal/cpu=D:\go 1.16\go1.16.windows-amd64\go\pkg\windows_amd64\internal\cpu.a
//省略...
EOF

这里再强调一下 importcfg 和 importcfg.link 的区别:

  • importcfg 配置了编译时(compile)需要依赖的包,编译时会使用里面的编译目标文件,类似于 c 的头文件,上面例子中,能看到只需要 fmt 和 runtime 两个包,这两个是直接用的。
  • importcfg.link 是链接时(link)需要依赖的包,链接器会解析包里面的链接目标文件,也就是真正的含有代码和数据的目标文件,是需要把所有涉及到的、用到的包全部引入,比如 fmt 包使用其他的包,其他包又使用到其他包。

5.执行链接器,生成最终可执行文件main.exe,同时可执行文件会拷贝到当前路径,最后删除临时文件。

"D:\\go 1.16\\go1.16.windows-amd64\\go\\pkg\\tool\\windows_amd64\\link.exe" -o "$WORK\\b001\\exe\\a.out.exe" -importcfg "$WORK\\b001\\importcfg.link" -buildmode=pie -buildid=e-AHcHe-BzPb0s
ZCqmwN/lOPVa87WZrfyX4ZeZ1C-/kldYnFxwV0NcvzN0F8v_/e-AHcHe-BzPb0sZCqmwN -extld=gcc "$WORK\\b001\\_pkg_.a"
"D:\\go 1.16\\go1.16.windows-amd64\\go\\pkg\\tool\\windows_amd64\\buildid.exe" -w "$WORK\\b001\\exe\\a.out.exe" # internal
cp $WORK\b001\exe\a.out.exe main.exe
rm -r $WORK\b001\

go build 和 go run

_ _.PKGDEF 文件的用途

为了验证编译目标文件和链接目标文件的用途我们先定义一个简单的hello包和一个使用hello包的main包:

# hello包下的hello.go
package hello

func Hello(){
	println("hello")
}

# main包下的main.go
package main
import "hello"

func main() {
	hello.Hello()
}

使用go tool compile命令对hello包进行编译,同时将两种目标文件解压并命名为hello__.PKGDEFhello_go_.o

go tool compile hello.go
ar x hello.o
mv __.PKGDEF hello__.PKGDEF
mv _go_.o hello_go_.o

我们先尝试下只编译 main.go,提示我们找不到导入的包 hello,发生这个错误的原因是编译器需要 hello包当中的函数定义等信息来确定函数的调用方法。

$ go tool compile main.go
main.go:3:8: can't find import: "hello"

为了解决这个错误,我们手工编写一个第一节出现过的importcfg,内容格式为packagefile <包名>=<路径>。直接在 goland 上操作就行。

  1. hello__.PKGDEF复制到 main 包下
  2. 在 main 包下创建一个文件,名为 importcfg,内容如下:
packagefile hello=./hello__.PKGDEF
  1. 执行 go tool compile -importcfg ./importcfg main.go

结果显示成功编译出了 main.o。说明编译过程只需要__.PKGDEF文件,编译过程和C语言编译过程类似,编译只需要头文件来查找定义不需要具体实现代码。

_ go_.o 文件的用途

接下来我们尝试将main.o链接成可执行文件。

$ go tool link -buildmode=exe main.o
E:\...\pkg\tool\windows_amd64\link.exe: cannot open file E:\...\pkg\windows_amd64\hello.a: open E:\...\pkg\windows_amd64\hello.a: The system cannot find the file specified.

同样因为找不到依赖包报错。根据报错结果,可以了解到编译器在目录E:\...\pkg\tool\windows_amd64 中尝试查找hello包。我们可以直接将我们的包直接拷贝到查找路径,也可以和编译过程类似,提供一个配置文件来指定每个包的路径。创建一个importcfg.link配置文件,指定hello包的路径为hello_go_.o,同时需要包含 hello.go 依赖的其他包。

  1. hello_go_.o复制到 main 包下
  2. 在 main 包下创建一个文件,名为 importcfg.link,内容如下:
#我们的库文件
packagefile hello=./hello_go_.o

#hello.go 依赖的其他包
packagefile runtime=E:\...\pkg\windows_amd64\runtime.a
packagefile internal/bytealg=E:\...\pkg\windows_amd64\internal\bytealg.a
packagefile internal/cpu=E:\...\pkg\windows_amd64\internal\cpu.a
packagefile runtime/internal/atomic=E:\...\pkg\windows_amd64\runtime\internal\atomic.a
packagefile runtime/internal/math=E:\...\pkg\windows_amd64\runtime\internal\math.a
packagefile runtime/internal/sys=E:\...\pkg\windows_amd64\runtime\internal\sys.a
  1. 执行 go tool link -o main.exe -importcfg ./importcfg.link -buildmode=exe main.o

可以发现成功的生成了 main.exe,运行一下可以成功打印:

D:\workspace\MyPro\src\main>main.exe
hello

至此,小结一下:

  1. 编译目标文件(__.PKGDEF)内容包含 package 导出的函数、变量等信息,用于编译过程。
  2. 链接目标文件(_go_.o)内容包含具体的代码实现,用于链接过程。

参考:

zhuanlan.zhihu.com/p/107665658

《程序员的自我修养:链接、装载与库》

《程序是怎样跑起来的》