Skip to content

Latest commit

 

History

History
201 lines (171 loc) · 8.35 KB

1-source.md

File metadata and controls

201 lines (171 loc) · 8.35 KB

如何寻找内建函数源码位置

你有没有遇到过下面这些问题:

  • 想查看Go内建函数的实现,IDE跟踪进去后却发现只有函数声明,没有实现代码
  • 想了解不同的写法Go编译器是如何进行处理的
  • 内建函数被展开之前和之后都做了些什么工作
  • 看程序的汇编代码时,同样都是make(map),怎么有时候会被翻译为runtime.makemap,有时候却什么都找不到

本篇文章可以帮助你解决这些问题。

为了帮助基础薄弱的同学,每个小节都会加上实际的代码进行分析。

注:本文写作时的Go版本为:
go version go1.17.5 linux/amd64

查看Go内建函数的实现

初学Go时,你是否好奇过make函数的实现

// make slice
slice := make([]int,20)
// make channel
channel := make(chan int,3)

这个神奇的函数能用来创建好多东西,但当你在IDE里点进去后,却发现它只有一行函数声明:

func make(t Type, size ...IntegerType) Type

于是你的探索就止步于此了...

现在让我们看看,有哪些寻找内建函数的源码:

方法1:使用汇编

场景代码

// foo.go
func foo()[]int {
    slice := make([]int, 3)
    return slice
}

查看方式
使用以下方式查看foo.go的汇编指令

$ go tool compile -S foo.go

下方是该命令生成的结果(省略了部分)

...
0x0023 00035 (./foo.go:4) PCDATA  $1, $0
0x0023 00035 (./foo.go:4) CALL    runtime.makeslice(SB)
0x0028 00040 (./foo.go:5) MOVL    $3, BX
...

我们要找的答案就在CALL runtime.makeslice这一行,说明我们的make([]int,3)最后调用的是runtime.makeslice函数(当然,除了这个函数外,编译器也做了一些别的工作,我们后面会介绍。)

或者使用反汇编将编译好的文件重新转换为汇编指令:

$ go tool objdump -s "foo" foo.o

标志-s支持正则表达式,动手试一下寻找上文的runtime.makeslice吧!

这里你可能会疑惑,foo.o这个文件是怎么出现的?

foo.o其实是上一步go tool compile的产物,在实际使用中,你可以用go build xx.go后的xx.exe来代替foo.o。(这里可能有点绕口,意思就是你可以直接go build main.go,然后会生成一个main.exe,再使用go tool objdump -s "main\.main" main.exe就可以查看main函数对应的汇编指令了)。

经常会有人问,go tool compile -sgo tool objdump产生的汇编有什么区别?仔细观察两个命令的输出可以发现,go tool compile生成的是还未链接的汇编指令,只有偏移量,并未赋予实际的内存地址。而go tool objdump由于是从编译好的二进制文件反汇编而成的,所以是有这些东西的。

方法2:查看SSA

场景代码

// main.go
func foo() (string, bool) {
    // 大小尽量大一些,否则如果map分配到栈上,后面的内容可能对不上
    bootun := make(map[string]string,10)
    bootun["pet"] = "大黄"
    dream, ok := bootun["dream"]
    return dream, ok
}

查看方式

$ GOSSAFUNC=foo go build main.go

该命令在目录下生成一个ssa.html,里面记录着数十个SSA的优化过程以及最终的SSA:

我们可以点击源码进行颜色标记,其他阶段对应的代码位置也会标记上相应的颜色。如下图所示:

我将几个关键的指令用红框圈了出来。可以看到,make([]int, 10)变成了runtime.makemap,对map的赋值底层调用了runtime.mapassign函数,两个返回值的map访问底层调用了runtime.mapaccess2函数,该函数有两个返回值(对,你猜没错,单返回值调用的是runtime.mapaccess1,该函数只有一个返回值,访问map的两种方式其实对应了不同的函数,这只是Go提供给我们的语法糖罢了)。

方法3:跟踪Go编译器源码

场景代码

func foo() ([]int,[]int) {
    s1 := make([]int,10)
    s2 := []int{1,2,3,4,5}
    return s1,s2
}

查看方式
这种方法需要你对编译原理Go编译器有一些了解,不过别担心,跟着我一步一步走,我会带你了解内置函数翻译过程中的几个主要节点

Go编译器源码的位置在$GOROOT/src/cmd/compile/internal/gc目录下(此处的gc指Go compiler),入口函数是/cmd/compile/internal/gc/main.go中的Main函数,篇幅原因,这里我们只介绍上述代码所经过的几个关键节点,如果想详细了解Go编译过程,可以参考《Go语言设计与实现》一书。

语法分析和类型检查的入口在上述Main函数的这一行:

// Parse and typecheck input.
noder.LoadPackage(flag.Args())

这个函数内可以看到语法分析和类型检查的不同阶段:

// Phase 1: const, type, and names and types of funcs.
// This will gather all the information about types
// and methods but doesn't depend on any of it.
//
// We also defer type alias declarations until phase 2
// to avoid cycles like #18640.
// TODO(gri) Remove this again once we have a fix for #25838.

// Don't use range--typecheck can add closures to Target.Decls.
base.Timer.Start("fe", "typecheck", "top1")
for i := 0; i < len(typecheck.Target.Decls); i++ {
    n := typecheck.Target.Decls[i]
    if op := n.Op(); op != ir.ODCL && op != ir.OAS && op != ir.OAS2 && (op != ir.ODCLTYPE || !n.(*ir.Decl).X.Alias()) {
        typecheck.Target.Decls[i] = typecheck.Stmt(n)
    }
}

无需阅读源码,通过注释我们就能看出来第一阶段处理的内容,因为代码相似,这里只展示第一阶段的代码。

此处我们只需要关注 typecheck.Target.Decls[i] = typecheck.Stmt(n)这行代码,这里是进入类型检查的入口,该函数其实是cmd/compile/internal/typecheck/typecheck.gotypecheck函数的包装函数,typecheck函数又会调用typecheck1函数,我们的要找的第一部分内容就在这里啦。

typecheck1函数下有这样一处地方:

case ir.OMAKE:
    n := n.(*ir.CallExpr)
    return tcMake(n)

表示当前处理的节点是OMAKE时的情景,让我们跟踪tcMake函数,看看里面都做了些什么:

// tcMake typechecks an OMAKE node.
func tcMake(n *ir.CallExpr) ir.Node {
...
switch t.Kind() {
    default:
      base.Errorf("cannot make type %v", t)
    case types.TSLICE:
      ...
      nn = ir.NewMakeExpr(n.Pos(), ir.OMAKESLICE, l, r)
    case case types.TMAP:
      ...
    case types.TCHAN:
      ...
...
}

这里我们可以看到,make能够创建的类型在这里都有处理,我们只需要关注slice的逻辑,tcMake函数将当前节点的op更改为了OMAKESLICE以便于之后进一步的处理。

后面追踪起起来函数太多了,这里只放最后两个,第一个是处理op为OMAKESLICE时的场景:

func walkExpr1(n ir.Node, init *ir.Nodes) ir.Node {
  ...
  switch n.Op() {
    ...
  case ir.OMAKESLICE:
      n := n.(*ir.MakeExpr)
		  return walkMakeSlice(n, init)
  ...
}

第二个就是上面函数调用的walkMakeSlice函数:

// walkMakeSlice walks an OMAKESLICE node.
func walkMakeSlice(n *ir.MakeExpr, init *ir.Nodes) ir.Node {
    ...
    fnname = "makeslice"
    ...
}

PS:这个函数下面就是copy的walk函数,当时在切片剖析一节我们提到了copy函数底层调用了memmove,在这里也能看到。

此时我们终于看到,make在这里展开变为了makeslice,这个函数还有展开前后的处理,这里省略掉了。使用这个方法同样可以查看mapchannel等类型的展开过程。

有了上述的这些方法,我们就可以愉快的查阅源码啦。

课后练习

1.奇怪的翻译机制

有时候你会发现,同样是map/channel/slice,同样是make函数,但是不同条件最终展开的代码可能并不一样。 比如map有时会被展开为runtime.makemap_small,有时则是runtime.makemap,甚至有时候什么都没有(比如被分配到栈上),查阅相关函数,看看究竟是哪些因素影响了这一结果。

2.slice字面值展开过程(较难)

方法3中我们只分析了make(slice)的展开过程,对于使用字面值初始化的方式我们没有解决,请试着寻找编译器对应的处理方法
关键词:OSLICELIT