说来“从Golang的二进制程序中获取version”不过是一件很小的事情。大概是去年在学Go和使用eBPF捕获GoTLS加密数据时的一些小tips:
-
为什么会有这个问题和需求?
-
Golang的编译后的二进制文件中存在哪些信息?
-
使用readelf工具读取这些信息、使用C++编写代码读取这些信息。
前·问题
在软件开发的历史长河中,有一个问题是所有人无法躲避的–兼容性。一种是你自己很NB不用,只需要自己兼容自己即可,一种是依赖别人,只要别人有所变动自己也要跟着改动。无论是哪种,能想象得到的,兼容适配的工作并不讨喜。
一个程序对于非开发人员而言,其语义化版本(Semantic Versioning)尤其重要,只有知道了一个01的文件对应的版本号,我们才能知道其变动了什么,特性是什么。
那,如何从Golang的二进制文件中获取其对应的版本号呢?
中·Golang
首先,我们知道有go version
的命令可以查看版本号,比如:
|
|
或者,我们粗暴点用strings
命令查看:
|
|
elf & readelf
但是,如果想要更深层次的了解这些信息是存放在哪里的,那么就需要了解一下Executable and Linkable Format即ELF文件类型,在Linux下主要有如下三种文件:可执行文件(.out)、可重定位文件(.o)和共享目标文件(.so),ELF文件结构大致如下。
图源 - https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
在 Linux 下 Go 编译出来的程序其实就是 ELF 文件类型的。因此,想要剖析 Go 的二进制文件还需要知道大概如下关于 ELF 的知识:
-
ELF header: 包含了magic、大小端、架构是32位还是64位……
-
Section Header Table: 存储了有关section信息,每个section对应section header table中的一个条目;
-
Program Header Table:存储了有关segment的信息,每个segment由一个或多个segment组成,内科在运行时用到这些信息;
-
section & segment区别 :
-
在可执行文件格式(如ELF文件)中,section是指文件的一个逻辑分段,它包含了特定类型的数据。
-
segment通常指的是程序被加载到内存时的一个物理或逻辑单元。在某些可执行文件格式中(如ELF),“segment"更具体地指由一个或多个section组成的,用于描述程序的内存映像的部分。
-
section主要用于编译和链接过程中的组织和管理;segment的划分是为了满足运行时的需求,比如内存保护、共享和权限管理。
-
-
.text : 存放程序的执行代码,只可读;
-
.data: 用于存放程序中已初始化的全局变量和静态变量。这些变量在程序启动之前由编译器赋予初始值;
-
.rodata: 即read-only-data,用于存放只读数据,比如字符串常量和其他任何程序运行时不需要修改的常量数据。
runtime.buildVersion
使用 readelf 可以看到Go二进制文件的section:
|
|
在上面readelf读出的section中,其中.go.buildinfo
包含了一些Go编译时的信息,比如Go的版本号、使用的module等,使用objdump查看.go.buildinfo
的内容:
|
|
其中\xff Go buildinf:
是magic信息,共14个字节,在对应的Go源码中可以看到:
|
|
继续看.go.buildifo
的内容:
-
前14字节为magic,即
\xff Go buildinf:
-
15字节表示指针大小,即
0x08
-
第16字节的可能取值:
0x00
、0x01
和0x02
0x02
表示,go的版本号信息紧跟其后,即为在后面的32个字节,其中前面16个字符为0忽略,从17个字节开始表示大小,在Go里为varint类型(见注释)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
# Go 1.20.7 为例 ff20476f 20627569 6c64696e 663a -> magic 08 -> size of pointer 02 -> follows the 32 bit header 00000000 00000000 00000000 00000000 -> buildinfo header, ignore, skip. 08 -> size of version string 676f31 2e32302e 37 -> version, go1.20.7 # 版本字符串大小即08的解析方法见: src/encoding/binary/varint.go#L7-L25 # This file implements "varint" encoding of 64-bit integers. # The encoding is: # - unsigned integers are serialized 7 bits at a time, starting with the # least significant bits # - the most significant bit (msb) in each output byte indicates if there # is a continuation byte (msb = 1) # - signed integers are mapped to unsigned integers using "zig-zag" # encoding: Positive values x are written as 2*x + 0, negative values # are written as 2*(^x) + 1; that is, negative numbers are complemented # and whether to complement is encoded in bit 0. # # Design note: # At most 10 bytes are needed for 64-bit values. The encoding could # be more dense: a full 64-bit value needs an extra byte just to hold bit 63. # Instead, the msb of the previous byte could be used to hold bit 63 since we # know there can't be more than 64 bits. This is a trivial improvement and # would reduce the maximum encoding length to 9 bytes. However, it breaks the # invariant that the msb is always the "continuation bit" and thus makes the # format incompatible with a varint encoding for larger numbers (say 128-bit).
- 当第16字节不为
0x02
时(这里以Go1.16为例),- 从第17字节起取一个指针(这里是8字节),这是指向version信息的指针
0x00870260
; - 读地址的内容,得到指向字符串的指针即
0x006bb617
和字符串的长度0x06
- 读
0x006bb617
即可得到go1.16
.
- 从第17字节起取一个指针(这里是8字节),这是指向version信息的指针
其实,翻看Go的源码可以发现,版本号信息其实就是runtime.buildVersion
这个对象。所以,其实上述的步骤等价于:
-
先找到
runtime.buildVersion
符号,该符号位于.data
-
读出
runtime.buildVersion
的内容的到字符串指针指向的地址 -
读取该地址的字符串,该地址位于
.rodata
|
|
后·总结
所以,从Go的二进制文件读取版本号信息的思路可以是:
-
先找到
runtime.buildVersion
,有两个方法:-
从
.go.buildinfo
中按上述的步骤得到,具体为:-
先读取
.go.buildinfo
,得到指针大小、大小端信息; -
如果大小端位的值为
0x02
则读取往后的32字节数据即为版本号信息,其中前16字节忽略,然后读取版本号信息的大小,最后读出对应大小的字符串; -
如果为非
0x02
则,先读取runtime.buildVersion
-
-
直接在
.data
中查找.runtime.buildVersion
的符号信息;
-
-
然后,读出
runtime.buildVersion
指向的地址的内容,即为版本号信息,字符串位于.rodata
尾·链接
What Is an ELF File? | Baeldung on Linux
从 Go 的二进制文件中获取其依赖的模块信息 - 知乎 (zhihu.com)