go version 是如何实现的?
in Go with 0 comment

go version 是如何实现的?

in Go with 0 comment

go version是什么?

  1. 可以用来查看当前的go版本

    > go version
    
    go version go1.16.2 darwin/amd64
    
  2. go1.13之后,还支持对二进制和目录进行操作

    The go version command now accepts arguments naming executables and directories. When invoked on an executable, go version prints the version of Go used to build the executable. If the -m flag is used, go version prints the executable's embedded module version information, if available. When invoked on a directory, go version prints information about executables contained in the directory and its subdirectories.

    对于可执行二进制,会打印用于构建可执行文件的go版本。

    如果使用了-m标志,则go version将打印可执行文件的嵌入式module版本信息(如果有)。

    当在目录上调用go版本时,它会打印有关该目录及其子目录中包含的可执行文件的信息。

go version是如何实现的?

go 二进制是通过 $GOROOT/src/cmd/go/main.go这个文件编译生成的。

go version的主要实现逻辑在 $GOROOT/src/cmd/go/internal/version/version.go

在Goland中可以这样设置进行调试该功能,这种方式同样适用于调试go的编译器等工具。

image-20210513235417160

除去注释和错误检查的后的代码如下

func runVersion(ctx context.Context, cmd *base.Command, args []string) {
	// go vervion后面无参数时的处理逻辑
	if len(args) == 0 {
		fmt.Printf("go version %s %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
		return
	}
	// go version后面有参数时的处理逻辑
	for _, arg := range args {
		info, err := os.Stat(arg)
		if info.IsDir() {
			scanDir(arg)	// 递归扫描文件夹
		} else {
			scanFile(arg, info, true) // 扫描二进制文件
		}
	}
}

go vervion后面无参数时的处理逻辑

如上节所示,会打印三个信息:

后面两个是写在指定文件中的,跟随官方源码一起发布的,重点关注runtime.Version()

// $GOROOT/src/runtime/extern.go
func Version() string {
	return sys.TheVersion
}

// $GOROOT/src/runtime/internal/sys/zversion.go

// Code generated by go tool dist; DO NOT EDIT.

package sys

const TheVersion = `go1.16.2`
const Goexperiment = ``
const StackGuardMultiplierDefault = 1

这样看来,这个也是固定写到文件中的?

别急,再观察一下,这个文件是不被git管理的,而且在官方代码库中这个文件也是不存在的。

// $GOROOT/src/cmd/dist/build.go

// gentab records how to generate some trivial files.
var gentab = []struct {
	nameprefix string
	gen        func(string, string)
}{
	{"zdefaultcc.go", mkzdefaultcc},
	{"zosarch.go", mkzosarch},
	{"zversion.go", mkzversion},
	{"zcgo.go", mkzcgo},

	// not generated anymore, but delete the file if we see it
	{"enam.c", nil},
	{"anames5.c", nil},
	{"anames6.c", nil},
	{"anames8.c", nil},
	{"anames9.c", nil},
}

// $GOROOT/src/cmd/dist/buildruntime.go

func mkzversion(dir, file string) {
	var buf bytes.Buffer
	fmt.Fprintf(&buf, "// Code generated by go tool dist; DO NOT EDIT.\n")
	fmt.Fprintln(&buf)
	fmt.Fprintf(&buf, "package sys\n")
	fmt.Fprintln(&buf)
	fmt.Fprintf(&buf, "const TheVersion = `%s`\n", findgoversion())
	fmt.Fprintf(&buf, "const Goexperiment = `%s`\n", os.Getenv("GOEXPERIMENT"))
	fmt.Fprintf(&buf, "const StackGuardMultiplierDefault = %d\n", stackGuardMultiplierDefault())

	writefile(buf.String(), file, writeSkipSame)
}

这个文件实际上是在编译go的过程中自动生成的。

TheVersion相关的是findgoversion函数

// $GOROOT/src/cmd/dist/build.go

// findgoversion determines the Go version to use in the version string.
func findgoversion() string {
  // 查找 $GOROOT/VERSION文件是否存在,如果存在直接返回该文件中的信息
  // 该文件在正式有tag号的版本中都是存在的,但是对于master和一些实验性分支是没有的
	path := pathf("%s/VERSION", goroot)
	if isfile(path) {
		b := chomp(readfile(path))
		if b != "" {
			if strings.HasPrefix(b, "devel") {
				// 看注释是交叉编译相关的处理,暂不关心
				if hostType := os.Getenv("META_BUILDLET_HOST_TYPE"); strings.Contains(hostType, "-cross") {
					fmt.Fprintf(os.Stderr, "warning: changing VERSION from %q to %q\n", b, "builder "+hostType)
					b = "builder " + hostType
				}
			}
			return b
		}
	}

	// 这是一个缓存文件,生成该文件的逻辑在下方,避免每次都调用git去重新查找版本信息
	path = pathf("%s/VERSION.cache", goroot)
	if isfile(path) {
		return chomp(readfile(path))
	}

	// 预期$GOROOT应该是一个git仓库
	if !isGitRepo() {
		fatalf("FAILED: not a Git repo; must put a VERSION file in $GOROOT")
	}

	// 调用git获取当前分支
	branch := chomp(run(goroot, CheckExit, "git", "rev-parse", "--abbrev-ref", "HEAD"))

	// What are the tags along the current branch?
	tag := "devel"
	precise := false

	// 如果当前是release-branch.XXX分支,寻找该分支上最近的一个tag号,但是当前仓库中release-branch分支也都是有VERSION这个文件的,理论上当前这是一个不会走到的if分支
	if strings.HasPrefix(branch, "release-branch.") {
		tag, precise = branchtag(branch)
	}
	// 对于master等分支,使用git信息生成版本号
	if !precise {
		// Tag does not point at HEAD; add hash and date to version.
		tag += chomp(run(goroot, CheckExit, "git", "log", "-n", "1", "--format=format: +%h %cd", "HEAD"))
	}

	// 写入版本信息到缓存文件,上方会优先读取缓存文件中的版本号
	writefile(tag, path, 0)

	return tag
}

对于master分支,go version的输出如下

go version devel go1.17-4a7effa418 Wed Apr 28 14:01:59 2021 +0000 darwin/amd64

go version后面有参数时的处理逻辑

> go version gin-vue-admin

gin-vue-admin: go1.16.2

go version -m 的输出,会将go mod的依赖也一些打印

> go version -m gin-vue-admin

gin-vue-admin: go1.16.2
	path	gin-vue-admin
	mod	gin-vue-admin	(devel)
	dep	github.com/360EntSecGroup-Skylar/excelize/v2	v2.3.2	h1:MHu5KWWt28FzRGQgc4Ryj/lZT/W/by4NvsnstbWwkkY=
	dep	github.com/Knetic/govaluate	v3.0.1-0.20171022003610-9aa49832a739+incompatible	h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw=
	dep	github.com/KyleBanks/depth	v1.2.1	h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
	dep	github.com/PuerkitoBio/purell	v1.1.1
……

如何从文件中查找版本信息?

// $GOROOT/src/cmd/go/internal/version/version.go

func scanFile(file string, info fs.FileInfo, mustPrint bool) {
	// 需要是一个可执行文件
	if !isExe(file, info) {
		if mustPrint {
			fmt.Fprintf(os.Stderr, "%s: not executable file\n", file)
		}
		return
	}
	// 打开该文件
	x, err := openExe(file)
	if err != nil {
		if mustPrint {
			fmt.Fprintf(os.Stderr, "%s: %v\n", file, err)
		}
		return
	}
	defer x.Close()
	// 查找version和module信息
	vers, mod := findVers(x)
	if vers == "" {
		if mustPrint {
			fmt.Fprintf(os.Stderr, "%s: go version not found\n", file)
		}
		return
	}
	// 打印版本信息
	fmt.Printf("%s: %s\n", file, vers)
	// 如果开启了-m,打印module信息
	if *versionM && mod != "" {
		fmt.Printf("\t%s\n", strings.ReplaceAll(mod[:len(mod)-1], "\n", "\n\t"))
	}
}


// buildInfo section 的魔数信息
var buildInfoMagic = []byte("\xff Go buildinf:")

// findVers finds and returns the Go version and module version information
// in the executable x.
func findVers(x exe) (vers, mod string) {
	// Read the first 64kB of text to find the build info blob.
	text := x.DataStart() // 对于elf格式的文件,查找.go.buildinfo section的起始地址
	data, err := x.ReadData(text, 64*1024) // 从起始地址读取64KB信息
	if err != nil {
		return
	}
	// 找到 "\xff Go buildinf:" 所在位置
	for ; !bytes.HasPrefix(data, buildInfoMagic); data = data[32:] {
		if len(data) < 32 {
			return
		}
	}

	// Decode the blob.
	ptrSize := int(data[14]) // 读取指针大小
	bigEndian := data[15] != 0 // 大小端
	var bo binary.ByteOrder
	if bigEndian {
		bo = binary.BigEndian
	} else {
		bo = binary.LittleEndian
	}
	var readPtr func([]byte) uint64
	if ptrSize == 4 {
		readPtr = func(b []byte) uint64 { return uint64(bo.Uint32(b)) }
	} else {
		readPtr = bo.Uint64
	}
	// 从data[16:16+ptrSize] 读取记录version字符串metadata信息的地址 metadataAddr
	// 从metadataAddr中读取实际字符串的地址和长度信息
	// 根据上方读到的信息读取真实字符串
	vers = readString(x, ptrSize, readPtr, readPtr(data[16:]))
	if vers == "" {
		return
	}
	// 从data[16+ptrSize:16+2*ptrSize] 读取module字符串metadata信息的地址
	// 与上方读取version字符串的流程一致
	mod = readString(x, ptrSize, readPtr, readPtr(data[16+ptrSize:]))
	if len(mod) >= 33 && mod[len(mod)-17] == '\n' {
		// Strip module framing.
		mod = mod[16 : len(mod)-16]
	} else {
		mod = ""
	}
	return
}

// readString returns the string at address addr in the executable x.
func readString(x exe, ptrSize int, readPtr func([]byte) uint64, addr uint64) string {
	// 从指定的Addr向后读取两个指针大小的数据
	hdr, err := x.ReadData(addr, uint64(2*ptrSize))
	if err != nil || len(hdr) < 2*ptrSize {
		return ""
	}
	// 解析字符串真实地址
	dataAddr := readPtr(hdr)
	// 解析字符串的长度
	dataLen := readPtr(hdr[ptrSize:])
	// 读取字符串数据
	data, err := x.ReadData(dataAddr, dataLen)
	if err != nil || uint64(len(data)) < dataLen {
		return ""
	}
	// 转换为string并返回
	return string(data)
}

// $GOROOT/src/encoding/binary/binary.go
// 大端情况下,获取地址信息
func (bigEndian) Uint64(b []byte) uint64 {
	_ = b[7] // bounds check hint to compiler; see golang.org/issue/14808
	return uint64(b[7]) | uint64(b[6])<<8 | uint64(b[5])<<16 | uint64(b[4])<<24 |
		uint64(b[3])<<32 | uint64(b[2])<<40 | uint64(b[1])<<48 | uint64(b[0])<<56
}

// 小端情况下,获取地址信息
func (littleEndian) Uint64(b []byte) uint64 {
	_ = b[7] // bounds check hint to compiler; see golang.org/issue/14808
	return uint64(b[0]) | uint64(b[1])<<8 | uint64(b[2])<<16 | uint64(b[3])<<24 |
		uint64(b[4])<<32 | uint64(b[5])<<40 | uint64(b[6])<<48 | uint64(b[7])<<56
}

跟随上方流程,使用readelfobjdump来手动查找该信息

Note:只关注有用的信息,mac 的可执行文件为 Mach-O 而非 ELF,这里的内容是在Linux中操作。

  1. 查看该二进制都有哪些Section

    > readelf -S ./gin-vue-admin
    
    There are 36 section headers, starting at offset 0x270:
    
    Section Headers:
      [Nr] Name              Type             Address           Offset
           Size              EntSize          Flags  Link  Info  Align
      ……
      [15] .gopclntab        PROGBITS         000000000135c160  00f5c160
           000000000050f118  0000000000000000   A       0     0     32
      [16] .go.buildinfo     PROGBITS         000000000186c000  0146c000
           0000000000000020  0000000000000000  WA       0     0     16
      [17] .dynamic          DYNAMIC          000000000186c020  0146c020
           0000000000000130  0000000000000010  WA      10     0     8
      ……
    
  2. Dump .go.buildinfo Section的信息

    > objdump -s -j .go.buildinfo  ./gin-vue-admin
    
    ./gin-vue-admin:     file format elf64-x86-64
    
    Contents of section .go.buildinfo:
     186c000 ff20476f 20627569 6c64696e 663a0800  . Go buildinf:..
     186c010 f06fd002 00000000 3070d002 00000000  .o......0p......
    

    根据源码可知

    • [0:14)区间内应为Magic信息 : "\xff Go buildinf:"
    • 第 15 个字符为指针大小:08,指针大小为8
    • 第 16 个字符为大小端信息:00,不为0是大端,为0是小端,当前为小端
    • [16:16+ptrSize) 记录version字符串metadata信息的地址:ptrSize在上方已找出,为8,且为小端。原始信息为f06fd002,对应的地址信息应为 0x02d06ff0
    • [16+ptrSize:16+2*ptrSize) 记录module字符串metadata信息的地址:ptrSize在上方已找出,为8,且为小端。原始信息为3070d002,对应的地址信息应为 0x02d07030
  3. Dump 字符串metadata信息

    0x02d06ff0向后dump16个字节的信息,0x02d06ff0 + 0x10 = 0x02d07000

    > objdump -s --start-address 0x02d06ff0 --stop-address 0x02d07000 ./gin-vue-admin
    
    ./gin-vue-admin:     file format elf64-x86-64
    
    Contents of section .data:
     2d06ff0 95340b01 00000000 08000000 00000000  .4..............
    

    对应字符串的地址信息为95340b01,根据小端原则,字符串真实地址为0x010b3495

    对应字符串的长度信息为08000000,长度为8

    > objdump -s --start-address  0x010b3495 --stop-address 0x010b349d ./gin-vue-admin
    
    ./gin-vue-admin:     file format elf64-x86-64
    
    Contents of section .rodata:
     10b3495 676f31 2e31362e 32                   go1.16.2
    

    获取到了版本信息为 go1.16.2

    对于module信息,方法一致

    > objdump -s --start-address 0x02d07030 --stop-address 0x02d07040 ./gin-vue-admin
    
    ./gin-vue-admin:     file format elf64-x86-64
    
    Contents of section .data:
     2d07030 60e40f01 00000000 c0200000 00000000
    
    > objdump -s --start-address 0x010fe460 --stop-address 0x1100520 ./gin-vue-admin
    
    ontents of section .rodata:
     10fe460 3077af0c 92740802 41e1c107 e6d618e6  0w...t..A.......
     10fe470 70617468 0967696e 2d767565 2d61646d  path.gin-vue-adm
     10fe480 696e0a6d 6f640967 696e2d76 75652d61  in.mod.gin-vue-a
     10fe490 646d696e 09286465 76656c29 090a6465  dmin.(devel)..de
     10fe4a0 70096769 74687562 2e636f6d 2f333630  p.github.com/360
     10fe4b0 456e7453 65634772 6f75702d 536b796c  EntSecGroup-Skyl
     10fe4c0 61722f65 7863656c 697a652f 76320976  ar/excelize/v2.v
     10fe4d0 322e332e 32096831 3a4d4875 354b5757  2.3.2.h1:MHu5KWW
     10fe4e0 74323846 7a524751 67633452 796a2f6c  t28FzRGQgc4Ryj/l
     10fe4f0 5a542f57 2f627934 4e76736e 73746257  ZT/W/by4NvsnstbW
     10fe500 776b6b59 3d0a6465 70096769 74687562  wkkY=.dep.github
     ……
    

该信息实际上是从二进制中读取的,那么还有最后一个问题,是何时写入的?

这里实际上是go的链接器做的事情了

// $GOROOT/src/cmd/link/internal/ld/main.go
// Main is the main entry point for the linker code.
func Main(arch *sys.Arch, theArch Arch) {
	// 开始写入buildinfo Section
	bench.Start("buildinfo")
	ctxt.buildinfo()
}

// $GOROOT/src/cmd/link/internal/ld/data.go
func (ctxt *Link) buildinfo() {
	if ctxt.linkShared || ctxt.BuildMode == BuildModePlugin {
		// -linkshared and -buildmode=plugin get confused
		// about the relocations in go.buildinfo
		// pointing at the other data sections.
		// The version information is only available in executables.
		return
	}

	ldr := ctxt.loader
	s := ldr.CreateSymForUpdate(".go.buildinfo", 0)
	// On AIX, .go.buildinfo must be in the symbol table as
	// it has relocations.
	s.SetNotInSymbolTable(!ctxt.IsAIX())
	s.SetType(sym.SBUILDINFO)
	s.SetAlign(16)
	// The \xff is invalid UTF-8, meant to make it less likely
	// to find one of these accidentally.
	const prefix = "\xff Go buildinf:" // 14 bytes, plus 2 data bytes filled in below
	data := make([]byte, 32)
	// 写入Magic
	copy(data, prefix)
	// 写入指针大小
	data[len(prefix)] = byte(ctxt.Arch.PtrSize)
	data[len(prefix)+1] = 0
	// 写入大小端信息
	if ctxt.Arch.ByteOrder == binary.BigEndian {
		data[len(prefix)+1] = 1
	}
	s.SetData(data)
	s.SetSize(int64(len(data)))
	r, _ := s.AddRel(objabi.R_ADDR)
	r.SetOff(16)
	r.SetSiz(uint8(ctxt.Arch.PtrSize))
	// 写入 runtime.buildVersion 符号的地址
	r.SetSym(ldr.LookupOrCreateSym("runtime.buildVersion", 0))
	r, _ = s.AddRel(objabi.R_ADDR)
	r.SetOff(16 + int32(ctxt.Arch.PtrSize))
	r.SetSiz(uint8(ctxt.Arch.PtrSize))
	// 写入 runtime.modinfo 符号的地址
	r.SetSym(ldr.LookupOrCreateSym("runtime.modinfo", 0))
}

// $GOROOT/src/runtime/proc.go
var buildVersion = sys.TheVersion

知道了原理能做什么?

> strip gin-vue-admin
> readelf -S ./gin-vue-admin

There are 27 section headers, starting at offset 0x2942388:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [15] .go.buildinfo     PROGBITS         000000000186c000  0146c000
       0000000000000020  0000000000000000  WA       0     0     16

可以看到,在使用strip去除二进制的调试信息后,.go.buildinfo Section仍然是存在的。

那么如果你拿到了一个没有符号表的Go二进制,就可以通过上方的流程,来进行”逆向“,不过go version也可以直接操作去除符号信息的二进制,哈哈哈。

那么,就这?

定制 Go 版本信息

那么理解了原理,就可以开始魔改了,如果定制Go版本信息,可以在那里修改呢?

$GOROOT/src/runtime/internal/sys/zversion.go中修改?

这个文件每次重新编译都会重新生成的,所以是无法生效的。

runVersion中修改?

这个是依赖于运行 go verison时的go二进制的,可以改变go version无参数时的输出信息。

该信息不会被写入到二进制中,所以编译出的二进制,使用正常go发行版去检查版本信息时,无法达到预期的效果。

我选择在 $GOROOT/src/cmd/dist/buildruntime.gomkzversion中进行修改

func mkzversion(dir, file string) {
	var buf bytes.Buffer
	fmt.Fprintf(&buf, "// Code generated by go tool dist; DO NOT EDIT.\n")
	fmt.Fprintln(&buf)
	fmt.Fprintf(&buf, "package sys\n")
	fmt.Fprintln(&buf)
	// 修改这一行
	fmt.Fprintf(&buf, "const TheVersion = `%s Powered by LeonardWang`\n", findgoversion())
	fmt.Fprintf(&buf, "const Goexperiment = `%s`\n", os.Getenv("GOEXPERIMENT"))
	fmt.Fprintf(&buf, "const StackGuardMultiplierDefault = %d\n", stackGuardMultiplierDefault())

	writefile(buf.String(), file, writeSkipSame)
}
> go version

go version go1.16.2 Powered by LeonardWang darwin/amd64

> go build -o main ./main.go
> go version main 

main: go1.16.2 Powered by LeonardWang

参考资料

从 Go 的二进制文件中获取其依赖的模块信息