Golang内部构件,第3部分:链接器,目标文件和重定位

在此博客文章中,我们将介绍Go链接器,Go对象文件和重定位。 我们为什么要关心这些事情? 好吧,如果您想学习任何大型项目的内部知识,则需要做的第一件事就是将其拆分为组件或模块。 其次,您需要了解这些模块相互提供的接口。 在Go中,这些高级模块是编译器,链接器和运行时。 编译器提供并链接器使用的接口是一个目标文件,今天我们将在这里进行研究。

生成Go对象文件

让我们做一个实际的实验-编写一个超级简单的程序,对其进行编译,然后查看将生成哪个目标文件。在我们的例子中,程序如下

package main

func main() {
    print(1)
}

现在我们需要对其进行编译

go tool compile -N -l -S ./main.go > main.s

该命令产生test.6目标文件。为了研究其内部结构,我们将使用goobj库。它在Go源代码内部使用,主要用于实现一组单元测试,以验证在不同情况下是否正确生成了目标文件。对于此博文,我们编写了一个非常简单的程序,该程序将从googj库生成的输出打印到控制台。您可以在此GitHub存储库中查看该程序的源代码。

首先,您需要下载并安装该程序。

go get github.com/s-matyukevich/goobj_explorer

然后,执行以下命令。

goobj_explorer -o test.6

现在,您应该能够goob.Package在控制台中看到该结构。

调查目标文件

目标文件中最有趣的部分是Syms数组。这实际上是一个符号表。您在程序中定义的所有内容(函数,全局变量,类型,常量等)均写入此表。让我们看一下与该main函数相对应的条目。(请注意,我们现在已经从输出中剪切了RelocFunc字段。稍后我们将讨论它们。)

&goobj.Sym{
            SymID: goobj.SymID{Name:"main.main", Version:0},
            Kind:  1,
            DupOK: false,
            Size:  48,
            Type:  goobj.SymID{},
            Data:  goobj.Data{Offset:137, Size:44},
            Reloc: ...,
            Func:  ...,
}

goobj.Sum结构中字段的名称非常不言自明。
The names of the fields in the goobj.Sum structure are pretty self-explanatory.

Field Description
SumID 由符号名称和版本组成的唯一符号ID。版本有助于区分名称相同的符号。
Kind 指示符号属于哪种类型(稍后会有更多详细信息)。
DupOK DupOK
Size 符号数据的大小。
Type 对表示一个符号类型(如果有)的另一个符号的引用。
Data 包含二进制数据。对于不同种类的符号,此字段具有不同的含义,例如,功能的汇编代码,字符串符号的原始字符串内容等。
Reloc 重定位列表(稍后将提供更多详细信息)。
Func 包含功能符号的特殊功能元数据(请参见下面的更多详细信息)。

现在,让我们看一下不同种类的符号。所有可能的符号类型都在goobj包中定义为常量(您可以在GitHub存储库中找到它们)。下面,我们复制了这些常量的第一部分。

const (
    _ SymKind = iota

    // readonly, executable
    STEXT
    SELFRXSECT

    // readonly, non-executable
    STYPE
    SSTRING
    SGOSTRING
    SGOFUNC
    SRODATA
    SFUNCTAB
    STYPELINK
    SSYMTAB // TODO: move to unmapped section
    SPCLNTAB
    SELFROSECT
    ...

如我们所见,该main.main符号属于与STEXT常量相对应的种类1 。STEXT是包含可执行代码的符号。现在,让我们看一下Reloc数组。它由以下结构组成。

type Reloc struct {
    Offset int
    Size   int
    Sym    SymID
    Add    int
    Type int
}

每次重定位都意味着[Offset, Offset+Size]应将位于该间隔的字节替换为指定的地址。该地址是通过将Sym符号的位置与Add字节数相加得出的。

了解relocations

go tool compile -N -l -S ./main.go > main.s

让我们浏览一下汇编器并尝试找到主要功能。

"".main t=1 size=48 value=0 args=0x0 locals=0x8
    0x0000 00000 (test.go:3)    TEXT    "".main+0(SB),$8-0
    0x0000 00000 (test.go:3)    MOVQ    (TLS),CX
    0x0009 00009 (test.go:3)    CMPQ    SP,16(CX)
    0x000d 00013 (test.go:3)    JHI    ,22
    0x000f 00015 (test.go:3)    CALL    ,runtime.morestack_noctxt(SB)
    0x0014 00020 (test.go:3)    JMP    ,0
    0x0016 00022 (test.go:3)    SUBQ    $8,SP
    0x001a 00026 (test.go:3)    FUNCDATA    $0,gclocals·3280bececceccd33cb74587feedb1f9f+0(SB)
    0x001a 00026 (test.go:3)    FUNCDATA    $1,gclocals·3280bececceccd33cb74587feedb1f9f+0(SB)
    0x001a 00026 (test.go:4)    MOVQ    $1,(SP)
    0x0022 00034 (test.go:4)    PCDATA    $0,$0
    0x0022 00034 (test.go:4)    CALL    ,runtime.printint(SB)
    0x0027 00039 (test.go:5)    ADDQ    $8,SP
    0x002b 00043 (test.go:5)    RET    ,

在以后的博客文章中,我们将仔细研究此代码,并尝试了解Go运行时的工作方式。目前,我们对以下行感兴趣。

0x0022 00034(test.go:4)CALL,runtime.printint(SB)

该命令在功能数据中的偏移量为0x0022(十六进制)或00034(十进制)。该行实际上负责调用该runtime.printint函数。问题是编译器在编译过程中不知道runtime.printint函数的确切地址。此函数位于编译器不知道的其他目标文件中。在这种情况下,它将使用重定位。以下是与此方法调用相对应的确切重定位(我们从goobj_explorer实用程序的第一个输出中复制了它)。

{
        Offset: 35,
        Size:   4,
        Sym:    goobj.SymID{Name:"runtime.printint", Version:0},
        Add:    0,
        Type:   3,
},

此重定位告诉链接器,从35个字节的偏移量开始,它需要用runtime.printint符号起始点的地址替换4个字节的数据。但是,与主函数数据的偏移量为35个字节实际上是我们先前所见的调用指令的参数。(该指令从一个34字节的偏移量开始。一个字节对应于调用指令代码,而四个字节则指向该指令的地址。)

链接器如何操作

现在我们了解了这一点,我们可以弄清楚链接器是如何工作的。以下架构非常简化,但反映了主要思想。

  • 链接器从主程序包引用的所有程序包中收集所有符号,并将它们加载到一个大字节数组(或二进制映像)中。
  • 对于每个符号,链接器都会在此图像中计算一个地址。
  • 然后,它应用为每个符号定义的重定位。现在很容易,因为链接器知道从那些重定位引用的所有其他符号的确切地址。
  • 链接器为Linux上的可执行和可链接(ELF)格式或Windows上的可移植可执行(PE)格式准备所有必需的标头。然后,它生成一个带有结果的可执行文件。

了解TLS

细心的读者会注意到goobj_explorer utility main方法的输出中发生了奇怪的重定位。它不对应于任何方法调用,甚至指向空符号。

{
        Offset: 5,
        Size:   4,
        Sym:    goobj.SymID{},
        Add:    0,
        Type:   9,
}

那么,这次搬迁有什么用呢?我们可以看到它的偏移量为5个字节,其大小为4个字节。在此偏移量处,有一个命令。

0x0000 00000(test.go:3)MOVQ(TLS),CX

它从偏移量0开始并占用9个字节(因为下一个命令从偏移量9个字节开始)。我们可以猜测,此重定位将奇怪的(TLS)语句替换为某个地址,但是TLS是什么,它使用什么地址?

TLS是“线程本地存储”的缩写。这项技术已在许多编程语言中使用。简而言之,它使我们能够拥有一个变量,该变量在由不同线程使用时指向不同的内存位置。

在Go中,TLS用于存储指向G结构的指针,该G结构包含特定Go例程的内部详细信息(有关更多详细信息,请参见后面的博客文章)。因此,有一个变量(当从不同的Go例程访问时)始终指向具有此Go例程的内部详细信息的结构。链接器知道此变量的位置,而该变量正是上一条命令中移至CX寄存器的内容。TLS可以针对不同的体系结构以不同的方式实现。对于AMD64,TLS是通过FS寄存器实现的,因此我们之前的命令被转换为MOVQ FSCX

为了结束对重定位的讨论,我们将向您展示包含所有不同类型的重定位的枚举类型(enum)。

// Reloc.type
enum
{
    R_ADDR = 1,
    R_SIZE,
    R_CALL, // relocation for direct PC-relative call
    R_CALLARM, // relocation for ARM direct call
    R_CALLIND, // marker for indirect call (no actual relocating necessary)
    R_CONST,
    R_PCREL,
    R_TLS,
    R_TLS_LE, // TLS local exec offset from TLS segment register
    R_TLS_IE, // TLS initial exec offset from TLS base pointer
    R_GOTOFF,
    R_PLT0,
    R_PLT1,
    R_PLT2,
    R_USEFIELD,
};

从该枚举中可以看出,重定位类型3为R_CALL,重定位类型9为R_TLS。这些enum名称完美地解释了我们前面讨论的行为。

在下一篇文章中,我们将继续讨论目标文件。我们还将为您提供更多必要的信息,以使您继续前进并了解Go运行时的工作方式。如果您有任何疑问,请随时在评论中提问。

你可能感兴趣的:(golang)