Go代码的编译与反编译
文章目录
编译过程
词法分析(Lexical Analysis)
语法分析(Syntax Analysis)
可参考网站: https://astexplorer.net/
语义分析(Semantic Analysis)
在抽象语法树 AST 上做类型检查
中间代码(SSA)生成与优化
SSA(Single Static Assignment)的两大要点是:
- Static: 每个变量只能赋值一次(因此应该叫常量更合适);
- Single: 每个表达式只能做一个简单运算,对于复杂的表达式
a*b+c*d
要拆分成: t0=a*b
; t1=c*d
; t2=t0+t1
; 三个简单表达式;
可以参考网站: https://golang.design/gossa
机器码生成
可以参考网站: https://godbolt.org/
链接过程
最重要的就是进行虚拟地址重定位(Relocation)
编译后,所有函数地址都是从 0 开始 每条指令是相对函数第一条指令的偏移
链接后 所有指令都有了全局唯一的地址
理解可执行文件
可执行文件在不同的操作系统上规范不一样
Linux 的可执行文件 ELF(Executable and Linkable Format) 为例, ELF 由几部分构成:
- ELF header
- Section header
- Sections
使用`go build -x 可以观察到main.go生成可执行文件的过程:
|
|
操作系统执行可执行文件的步骤(以 linux 为例)
如何得到汇编代码
有多种方式可以获得Go程序的汇编代码, 尽管输出的格式有些不同,但是都是方便阅读的汇编代码,可以帮助我们更好的了解程序的底层运行方式。
我们看下面一段代码, 它是sync.Once的实现,去掉了不必要的注释,复制出来用来研究的一段小代码。
once.go
|
|
对于写好的 go 源码,生成对应的 Go 汇编,大概有下面几种
方法 1
- 先使用
go build -gcflags "-N -l" main.go
生成对应的可执行二进制文件 再使用go tool objdump -s main.main main
反编译获取对应的汇编 - 反编译时
"main.main"
表示只输出 main 包中 main 方法相关的汇编.
方法 2
使用 go tool compile -S -N -l main.go
这种方式直接输出汇编
方法 3
使用go build -gcflags="-N -l -S" main.go
直接输出汇编
注意:在使用这些命令时,加上对应的 flag,否则某些逻辑会被编译器优化掉,而看不到对应完整的汇编代码
-l 禁止内联 -N 编译时,禁止优化 -S 输出汇编代码
go tool compile
使用go tool compile -N -l -S once.go
生成汇编代码:
|
|
go tool objdump
首先先编译程序: go build -gcflags "-N -l" once.go
,
使用go tool objdump once
反汇编出代码:
|
|
go build -gcflags -S
使用go build -gcflags -S once.go
也可以得到汇编代码:
|
|
总结
go tool compile 和 go build -gcflags -S 生成的是过程中的汇编,和最终的机器码的汇编可以通过go tool objdump生成。
go语言静态库的编译和使用
本文主要介绍go语言静态库的编译和使用方法,以windows平台为例,linux平台步骤一样,具体环境如下:
>echo %GOPATH%
E:\share\git\go_practice\
>echo %GOROOT%
C:\Go\
>tree /F %GOPATH%\src
卷 work 的文件夹 PATH 列表
卷序列号为 0009-D8C8
E:\SHARE\GIT\GO_PRACTICE\SRC
│ main.go
│
└─demo
demo.go
在%GOPATH%\src目录,有demo包和使用demo包的应用程序main.go,main.go代码如下:
|
|
demo包中的demo.go代码如下:
|
|
由于demo.go是%GOPATH%\src目录下的一个包,main.go在import该包后,可以直接使用,运行main.go:
>go run main.go
call demo ...
现在,需要将demo.go编译成静态库demo.a,不提供demo.go的源代码,让main.go也能正常编译运行,详细步骤如下:
-
编译静态库demo.a
>go install demo
在命令行运行go install demo命令,会在%GOPATH%目录下生相应的静态库文件demo.a(windows平台一般在%GOPATH%\src\pkg\windows_amd64目录)。
-
编译main.go
进入main.go所在目录,编译main.go:
>go tool compile -I E:\share\git\go_practice\pkg\windows_amd64 main.go
-I选项指定了demo包的安装路径,供main.go导入使用,即E:\share\git\go_practice\pkg\win dows_amd64目录,编译成功后会生成相应的目标文件main.o。
-
链接main.o
>go tool link -o main.exe -L E:\share\git\go_practice\pkg\windows_amd64 main.o
-L选项指定了静态库demo.a的路径,即E:\share\git\go_practice\pkg\win dows_amd64目录,链接成功后会生成相应的可执行文件main.exe。
-
运行main.exe
>main.exe call demo ...
现在,就算把demo目录删除,再次编译链接main.go,也能正确生成main.exe:
>go tool compile -I E:\share\git\go_practice\pkg\windows_amd64 main.go
>go tool link -o main.exe -L E:\share\git\go_practice\pkg\windows_amd64 main.o
>main.exe
call demo ...
但是,如果删除了静态库demo.a,就不能编译main.go,如下:
>go tool compile -I E:\share\git\go_practice\pkg\windows_amd64 main.go
main.go:3: can't find import: "demo"
go语言动态库的编译和使用
Go从1.5版本开始支持动态链接库。目前官方工具链仅在linux-amd64平台支持动态链接;gccgo则支持更多的平台。
本文主要介绍go语言动态库的编译和使用方法,具体环境如下:
$ echo $GOPATH
/media/sf_share/git/go_practice
$ echo $GOROOT
/usr/lib/golang/
$ tree $GOPATH/src
/media/sf_share/git/go_practice/src
|-- demo
| `-- demo.go
`-- main.go
1 directory, 2 files
在$GOPATH/src目录,有demo包和使用demo包的应用程序main.go,main.go代码如下:
|
|
demo包中的demo.go代码如下:
|
|
由于demo.go是$GOPATH/src目录下的一个包,main.go在import该包后,可以直接使用,运行main.go:
$ go run main.go
call demo ...
在此之前,Go的所有程序都采用静态链接。一个很简单的”hello, world”程序,因为引入了fmt库(这个库进一步依赖其他代码),所以最终得到的可执行文件也比较大。如果将Go的标准库编译为动态链接库,就可以减小Go生成的可执行文件的大小。要将标准库编译为动态链接库,需要以root权限执行.
现在,需要将demo.go编译成动态库libdemo.so,让main.go以动态库方式编译,详细步骤如下:
-
将go语言标准库编译成动态库
go install -buildmode=shared -linkshared std
在命令行运行go install -buildmode=shared -linkshared std命令,-buildmode指定编译模式为共享模式,-linkshared表示链接动态库,成功编译后会在$GOROOT目录下生标准库的动态库文件libstd.so,一般位于$GOROOT/pkg/linux_amd64_dynlink目录:
$ cd $GOROOT/pkg/linux_amd64_dynlink $ ls libstd.so libstd.so
-
将demo.go编译成动态库
$ go install -buildmode=shared -linkshared demo $ cd $GOPATH/pkg $ ls linux_amd64_dynlink/ demo.a demo.shlibname libdemo.so
成功编译后会在$GOPATH/pkg目录生成相应的动态库libdemo.so。
-
以动态库方式编译main.go
$ go build -linkshared main.go $ ll -h total 25K drwxrwx---. 1 root vboxsf 4.0K Apr 28 17:30 ./ drwxrwx---. 1 root vboxsf 4.0K Apr 28 17:22 ../ drwxrwx---. 1 root vboxsf 0 Apr 28 08:37 demo/ -rwxrwx---. 1 root vboxsf 16K Apr 28 17:30 main* -rwxrwx---. 1 root vboxsf 58 Apr 28 08:37 main.go* $ ./main call demo ...
从示例中可以看到,以动态库方式编译生成的可执行文件main大小才16K。如果以静态库方式编译,可执行文件main大小为1.5M,如下所示:
$ go build main.go $ ll -h total 1.5M drwxrwx---. 1 root vboxsf 4.0K Apr 28 17:32 ./ drwxrwx---. 1 root vboxsf 4.0K Apr 28 17:22 ../ drwxrwx---. 1 root vboxsf 0 Apr 28 08:37 demo/ -rwxrwx---. 1 root vboxsf 1.5M Apr 28 17:32 main* -rwxrwx---. 1 root vboxsf 58 Apr 28 08:37 main.go* $ ./main call demo ...
以动态库方式编译时,如果删除动态库libdemo.so或者动态库libstd.so,运行main都会由于找不到动态库导致出错,例如删除动态库libdemo.so:
$ rm ../pkg/linux_amd64_dynlink/libdemo.so $ ./main ./main: error while loading shared libraries: libdemo.so: cannot open shared object file: No such file or directory
以上就是go语言动态库的编译和使用方法,需要注意的是,其他go程序在使用go动态库时,必须提供动态库的源码,否则会编译失败。例如,这里将demo.go代码删除,再以动态库方式编译main.go时,会编译失败:
$ go install -buildmode=shared -linkshared demo
$ rm demo/demo.go
$ go build -linkshared main.go
main.go:3:8: no buildable Go source files in /media/sf_share/git/go_practice/src/demo
动态库编译方式和静态库不一样,静态库可以不提供源码,直接使用静态库编译,而动态库不行。
内联
在 Go 中,函数调用有固定的开销;栈和抢占检查。
硬件分支预测器改善了其中的一些功能,但就功能大小和时钟周期而言,这仍然是一个成本。
内联是避免这些成本的经典优化方法。
内联只对叶子函数有效,叶子函数是不调用其他函数的。这样做的理由是:
- 如果你的函数做了很多工作,那么前序开销可以忽略不计。
- 另一方面,小函数为相对较少的有用工作付出固定的开销。这些是内联目标的功能,因为它们最受益。
还有一个原因就是严重的内联会使得堆栈信息更加难以跟踪。
内联 - 例1
|
|
我们再次使用 -gcflags = -m
标识来查看编译器优化决策。
|
|
编译器打印了两行信息:
- 首先第3行,
Max
的声明告诉我们它可以内联 - 其次告诉我们,
Max
的主体已经内联到第12行调用者中。
内联是什么样的?
编译 max.go
然后我们看看优化版本的 F()
变成什么样了。
|
|
一旦Max
被内联到这里,这就是F的主体 - 这个函数什么都没干。我知道屏幕上有很多没用的文字,但是相信我的话,唯一发生的就是RET
。实际上F
变成了:
|
|
注意: 利用 -S
的输出并不是进入二进制文件的最终机器码。链接器在最后的链接阶段进行一些处理。像FUNCDATA
和PCDATA
这样的行是垃圾收集器的元数据,它们在链接时移动到其他位置。 如果你正在读取-S
的输出,请忽略FUNCDATA
和PCDATA
行;它们不是最终二进制的一部分。
调整内联级别
使用-gcflags=-l
标识调整内联级别。有些令人困惑的是,传递一个-l
将禁用内联,两个或两个以上将在更激进的设置中启用内联。
-gcflags=-l
,禁用内联。- 什么都不做,常规的内联
-gcflags='-l -l'
内联级别2,更积极,可能更快,可能会制作更大的二进制文件。-gcflags='-l -l -l'
内联级别3,再次更加激进,二进制文件肯定更大,也许更快,但也许会有 bug。-gcflags=-l=4
(4个-l
) 在 Go 1.11 中将支持实验性的 中间栈内联优化。
死码消除
为什么a
和b
是常数很重要?
为了理解发生了什么,让我们看一下编译器在把Max
内联到F
中的时候看到了什么。我们不能轻易地从编译器中获得这个,但是直接手动完成它。
Before:
|
|
After:
|
|
因为a
和b
是常量,所以编译器可以在编译时证明分支永远不会是假的;100
总是大于20
。因此它可以进一步优化 F
为
|
|
既然分支的结果已经知道了,那么结果的内容也就知道了。这叫做分支消除。
|
|
现在分支被消除了,我们知道结果总是等于a
,并且因为a
是常数,我们知道结果是常数。 编译器将此证明应用于第二个分支
|
|
并且再次使用分支消除,F
的最终形式减少成这样。
|
|
最后就变成
|
|
死码消除(续)
分支消除是一种被称为死码消除的优化。实际上,使用静态证明来表明一段代码永远不可达,通常称为死代码,因此它不需要在最终的二进制文件中编译、优化或发出。
我们发现死码消除与内联一起工作,以减少循环和分支产生的代码数量,这些循环和分支被证明是不可到达的。
你可以利用这一点来实现昂贵的调试,并将其隐藏起来
|
|
结合构建标记,这可能非常有用。
进一步阅读
- Using // +build to switch between debug and release builds
- How to use conditional compilation with the go build tool
编译器标识练习
编译器标识提供如下:
|
|
研究以下编译器功能的操作:
-S
打印正在编译的包的汇编代码-l
控制内联行为;-l
禁止内联,-l -l
增加-l
(更多-l
会增加编译器对代码内联的强度)。试验编译时间,程序大小和运行时间的差异。-m
控制优化决策的打印,如内联,逃逸分析。-m
打印关于编译器的想法的更多细节。-l -N
禁用所有优化。
注意: If you find that subsequent runs of go build ...
produce no output, delete the ./max
binary in your working directory.
Further reading
参考
条件编译
Go语言可以通过go/build包里定义的tags和命名约定来让Go的包可以运行不同的代码。
在源代码里添加标注,通常称之为编译标签(build tag)。编译标签采用靠近源代码文件顶部用注释的方式添加。go build在构建一个包的时候会读取这个包里的每个源文件并且分析编译便签,这些标签决定了这个源文件是否参与本次编译。
使用方法
- 构建约束以一行+build开始的注释。在+build之后列出了一些条件,在这些条件成立时,该文件应包含在编译的包中;
- 约束可以出现在任何源文件中,不限于go文件;
- +build必须出现在package语句之前,+build注释之后应要有一个空行。
- 只允许是字母数字或_.
|
|
语法规则
- 编译标签由空格分隔的编译选项(options)以”或”的逻辑关系组成(a build tag is evaluated as the OR of space-separated options)。多个条件之间,空格表示OR;逗号表示AND;叹号(!)表示NOT.
- 每个编译选项由逗号分隔的条件项以逻辑”与”的关系组成( each option evaluates as the AND of its comma-separated terms).
- 每个条件项的名字用字母+数字表示,在前面加!表示否定的意思(each term is an alphanumeric word or, preceded by !, its negation)
注:一个源文件可以有多个编译标签,多个编译标签之间是逻辑“与”的关系,一个编译标签可以包括由空格分割的多个标签,这些标签是逻辑“或”的关系。例子:
|
|
等价于
|
|
预定义了一些条件: runtime.GOOS、runtime.GOARCH、compiler(gc或gccgo)、cgo、context.BuildTags中的其他单词
如果一个文件名(不含后缀),以 *_GOOS
, *_GOARCH
, 或 *_GOOS_GOARCH
结尾,它们隐式包含了 构建约束
当不想编译某个文件时,可以加上// +build ignore
。这里的ignore可以是其他单词,只是ignore更能让人知道什么意思
更多详细信息,可以查看go/build/build.go文件中shouldBuild和match方法。
文件后缀
使用这种方案比编译标签要简单,go/build可以在不读取源文件的情况下就可以决定哪些文件不需要参加编译。文件命名约定可以在go/build包里找到详细的说明,简单来说如果你的源文件包含后缀:$GOOS.go,那么这个源文件只会在这个平台下编译,$GOARCH.go也是如此。 这两个后缀可以结合在一起使用,顺序只能为:$GOOS$GOARCH.go
例子如下:
|
|
源文件不能只提供条件编译后缀,还必须有文件名,_linux.go、_freebsd_386.go 这两个源文件在所有的平台下都会被忽略掉,因为go/build将会忽略所有以下划线或者点开头的源文件 。
举例:json库
背景
Golang提供的标准json解析库——encoding/json,在开发高性能、高并发的网络服务时会产生性能问题。替代的方案是使用高性能的json解析库,比如json-iterator和easyjson。在正式引用高性能的json解析库(以json-iterator为例)通常的做法是小范围的进行测试,此时就会出现两个库并存的时候,解决方案是使用标签编译选择运行的解析库
统一的JSON库
现在我们需要两个库并存,所以我们先得统一这两个库的用法(参考适配器模式),这里我们使用一个自定义的json包来适配encoding/json和json-iterator
json/json.go
|
|
json/jsoniter.go
|
|
目录结构如下:
|
|
注: 上述例子中如果编译标签 和包的声明 之间没有空行隔开,编译标签会被当做包声明的注释而不是编译标签,切记空行必须要有。
main函数
此处引用的json包下的两个go文件中都有MarshalIndent函数定义,并且签名一致,但它们又是使用不同的json解析库实现,此处相当于做了统一的适配包装,所以调用可以统一。
|
|
直接运行go run main.go结果:
|
|
使用标签编译运行
|
|
|
|
总结
标签编译的关键在于 -tags=jsoniter , -tags 这个标志,是Go语言为我们提供的条件编译方式之一。如果我们不是运行,而是编译构建的话,改为go build -tags=jsoniter 即可生成调用了对应包的可执行文件。
|
|
表示,tags不是jsoniter的时候编译这个Go文件。
|
|
表示,tags是jsoniter的时候编译这个Go文件。
这两行是Go语言条件编译的关键。+build可以理解标签编译tags的声明关键字,后面跟着tags的条件。
举例:不同环境的配置
比如,项目中需要在测试环境输出Debug信息,一般通过一个变量(或常量)来控制是测试环境还是生产环境,比如:if DEBUG {}
,这样在生产环境每次也会进行这样的判断。在golang-nuts邮件列表中有人问过这样的问题,貌似没有讨论出更好的方法(想要跟C中条件编译一样的效果)。下面我们采用Build constraints来实现。
-
文件列表:main.go logger_debug.go logger_product.go
-
在main.go中简单的调用Debug()方法。
-
在logger_product.go中的Debug()是空实现,但是在文件开始加上
// +build !debug
-
在logger_debug.go中的Debug()是需要输出的调试信息,同时在文件开始加上
// +build debug
这样,在测试环境编译的时传递-tags参数:go build/install -tags “debug” logger
。生产环境:go build/install logger
就行了。
对于生产环境,不传递-tags时,为什么会编译logger_product.go呢?因为在go/build/build.go
中的match方法中有这么一句:
|
|
也就是说,只要有!(不能只是!),tag不在BuildTags中时,总是会编译。
交叉编译
通俗地讲就是在一种平台上编译出能运行在体系结构不同的另一种平台上的程序,比如在PC平台(X86 CPU)上编译出能运行在以ARM为内核的CPU平台上的程序,编译得到的程序在X86 CPU平台上是不能运行的,必须放到ARM CPU平台上才能运行,虽然两个平台用的都是Linux系统。
交叉编译这种方法在异平台移植和嵌入式开发时非常有用。
本地编译
相对与交叉编译,平常做的编译叫本地编译,也就是在当前平台编译,编译得到的程序也是在本地执行。
用来编译跨平台程序的编译器就叫交叉编译器,相对来说,用来做本地编译的工具就叫本地编译器。
所以要生成在目标机上运行的程序,必须要用交叉编译工具链来完成。在裁减和定制Linux内核用于嵌入式系统之前,由于一般嵌入式开发系统存储大小有限,通常都要在性能优越的PC上建立一个用于目标机的交叉编译工具链,用该交叉编译工具链在PC上编译目标机上要运行的程序。
交叉编译工具链是一个由编译器、连接器和解释器组成的综合开发环境,交叉编译工具链主要由binutils、gcc和glibc 3个部分组成。
有时出于减小 libc 库大小的考虑,也可以用别的 c 库来代替 glibc,例如 uClibc、dietlibc 和 newlib。
Golang 的跨平台交叉编译
Go语言是编译型语言,可以将程序编译后在将其拿到其它操作系统中运行,此过程只需要在编译时增加对其它系统的支持。
问题
golang如何在一个平台编译另外一个平台可以执行的文件。比如在mac上编译Windows和linux可以执行的文件。那么我们的问题就设定成:如何在mac上编译64位linux的可执行文件。
解决方案
golang的交叉编译要保证golang版本在1.5以上,本解决方案实例代码1.9版本执行的。
我们想要编译的文件hello.go
hello.go
|
|
在mac上编译64位linux的命令编译命令
bash:
GOOS=linux GOARCH=amd64 go build hello.go
上面这段代码直接在命令控制台里面运行就可以生成64位linux的可执行程序。
参数解析
这里用到了两个变量:
$GOARCH 目标平台(编译后的目标平台)的处理器架构(386、amd64、arm)
$GOOS 目标平台(编译后的目标平台)的操作系统(darwin、freebsd、linux、windows)
OS | ARCH | OS version |
---|---|---|
linux | 386 / amd64 / arm | >= Linux 2.6 |
darwin | 386 / amd64 | OS X (Snow Leopard + Lion) |
freebsd | 386 / amd64 | >= FreeBSD 7 |
windows | 386 / amd64 | >= Windows 2000 |
编译其他平台的时候根据上面表格参数执行编译就可以了。
Golang交叉编译步骤
首先进入$GOROOT/go/src 源码所在目录,执行如下命令创建目标平台所需的包和工具文件
### 如果你想在Windows 32位系统下运行
$ cd $GOROOT/src
$ CGO_ENABLED=0 GOOS=windows GOARCH=386 ./make.bash
### 如果你想在Windows 64位系统下运行
$ cd $GOROOT/src
$ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 ./make.bash
### 如果你想在Linux 32位系统下运行
$ cd $GOROOT/src
$ CGO_ENABLED=0 GOOS=linux GOARCH=386 ./make.bash
### 如果你想在Linux 64位系统下运行
$ cd $GOROOT/src
$ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 ./make.bash
执行完上面的操作后,现在可以编译将要在目标操作系统上运行的程序了
### 如果你想在Windows 32位系统下运行
$ CGO_ENABLED=0 GOOS=windows GOARCH=386 go build test.go
### 如果你想在Windows 64位系统下运行
$ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build test.go
### 如果你想在Linux 32位系统下运行
$ CGO_ENABLED=0 GOOS=linux GOARCH=386 go build test.go
### 如果你想在Linux 64位系统下运行
$ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build test.go
上面命令中的 CGO_ENABLED = 0 表示设置CGO工具不可用;
GOOS 表示程序构建环境的目标操作系统(Linux、Windows);
GOARCH 表示程序构建环境的目标计算架构(32位、64位);
现在你可以在相关目标操作系统上运行编译后的程序了。
扩展阅读
在网络上的诸多教程中可能会看到下面的编译命令
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build hello.go
其中CGO_ENABLED=0的意思是使用C语言版本的GO编译器,参数配置为0的时候就关闭C语言版本的编译器了。自从golang1.5以后go就使用go语言编译器进行编译了。在golang1.9当中没有使用CGO_ENABLED参数发现依然可以正常编译。当然使用了也可以正常编译。比如把CGO_ENABLED参数设置成1,即在编译的过程当中使用CGO编译器,我发现依然是可以正常编译的。
实际上如果在go当中使用了C的库,比如import “C"默认使用go build的时候就会启动CGO编译器,当然我们可以使用CGO_ENABLED=0来控制go build是否使用CGO编译器。
参考
文章作者 Forz
上次更新 2021-08-08