Morse's Site
2502 字
13 分钟
Go程序是如何运行起来的?
2021-05-18

一个xx.go源码到一个可执行的文件. 中间到底进行了怎样的过程? 本文主要就是分析这个过程.

以下分析都是基于Linux, windows/MacOS会有不同. 请知悉.

xx.go到一个可执行文件.#

xx.go到一个可执行文件. 主要分为以下几个步骤:

::源码 -> 编译 -> 链接 -> 可执行文件::

  • 源码: 就是我们平时编写的xx.go文本文件.
  • 编译 -> 连接 -> 可执行程序: 这步其实对于我们来说只对应了一个命令, 即: go build main.go(文件不一定叫main.go, 正确的说应该叫包含main函数的文件).

go build首先会将*.go文件编译成目标代码, 即*.a文件, 这一步就是::编译::(个人觉得叫翻译更好理解).

这时形成*.a还不能运行, 还需要将程序依赖其他代码与其合成在一起. 这一步叫做::链接::. 链接后的文件就时我们需要的::可执行文件::.

下面我们做个实验, 看下整个过程.

# 新建一个.go文件, 功能打印一个HelloWorld
> cat main.go
package main

import "fmt"

func main() {
    fmt.Println("HelloWorld")
}

# 编译
> go build -x -a main.go
  • -a: 强制重新编译, 如果不加这个参数, go编译器会使用之前编译好缓存文件*.a. 加快编译过程, 我们这里为了做实验, 看清楚编译过程, 所以加了这个参数.

  • -x: 打印命令, 此参数会将整个编译过程的命令打印出来, 方便我们了解整个编译过程.

Go编译过程分析#

编译过程涉及一些术语和专业知识. 比如: AST(Abstract Syntax Tree, 抽象语法树). 指令集(cpu架构不同, 对应的指令集不同, 所以我们需要为不同平台(如x86, arm等)编译不同版本的应用).

我们看到截图编译命令用到了complie, 对应源代码在src/cmd/compile目录中. 它是Golang的编译器.

虽然我们在截图上看到了编译就一条命令, 但是编译器却做了很多工作. 总的来说编译器编译的过程分为4个阶段: ::词法和语法分析::, ::类型检察和AST转换::, ::通用SSA(Static Single Assigment, 静态单赋值)生成::和最后的::机器代码生成::.

  • 词法和语法分析: 简单的理解就是分析源码文件(x.go), 将源码中字符串转为Token序列. 这一步叫词法分析. 词法分析结束后, 把分析结果作为输入. 给语法分析, 由语法分析形成::SourceFile结构::, 即AST语法树.

每个单独的go文件对应一个AST语法树.

  • 类型检查: 遍历检查AST语法树的类型. 同时::改写一些内建函数::, 如: make函数.

  • 中间代码生成: 中间代码的生成过程是从 AST 抽象语法树到 SSA 中间代码的转换过程,在这期间会对语法树中的关键字再次进行改写,改写后的语法树会经过多轮处理转变成最后的 ::SSA 中间代码::,相关代码中包括了大量 switch 语句、复杂的函数和调用栈,阅读和分析起来也非常困难。

  • 机器代码生成: Go 语言源代码的 src/cmd/compile/internal 目录中包含了很多机器码生成相关的包,将为不同类型的 CPU 分别使用了不同的包生成机器码,其中包括 amd64、arm、arm64、mips、mips64、ppc64、s390x、x86 和 wasm.

总的来说. 这一步就是将将::SSA代码转换为目标架构特定的机器指令::, (SSA->汇编->特定目标机器码)

> go tool compile -S main.go

Linux 可执行文件分析#

可执行文件是一个二进制文件. 既然是文件必然有自己的格式要求. 不然操作系统怎么知道如何解析并运行这个文件呢? Linux系统上的可执行文件格式为ELF. 其他操作系统如Window, MacOS有不同的可执行文件格式. 这里不做讨论.

注意: 并非所有的ELF文件都是可执行文件. 可被共享的对象文件(Shared object file, 比如动态链接库, 即.so文件), 可重定位的对象文件(Relocatable file, 由汇编器生成的.o文件)

ELF: Executable and Linkable Format, ELF格式, 是Linux的可执行文件格式. ELF分为以下几个部分.

从上图可以看到, ELF主要分为几个部分:

  • ELF Header: 文件头, 包含固定长度的文件信息;
  • Section Header Table: 包含::链接::需要用到的信息;
  • Program Header Table: 包含::运行时::加载程序需要的信息;

ELF Header#

glibc中elf源码定义elf/elf.h

#define EI_NIDENT (16)

typedef struct
{
  unsigned char	e_ident[EI_NIDENT];	/* Magic number and other info */
  Elf32_Half	e_type;			/* Object file type */
  Elf32_Half	e_machine;		/* Architecture */
  Elf32_Word	e_version;		/* Object file version */
  Elf32_Addr	e_entry;		/* Entry point virtual address */
  Elf32_Off	e_phoff;		/* Program header table file offset */
  Elf32_Off	e_shoff;		/* Section header table file offset */
  Elf32_Word	e_flags;		/* Processor-specific flags */
  Elf32_Half	e_ehsize;		/* ELF header size in bytes */
  Elf32_Half	e_phentsize;		/* Program header table entry size */
  Elf32_Half	e_phnum;		/* Program header table entry count */
  Elf32_Half	e_shentsize;		/* Section header table entry size */
  Elf32_Half	e_shnum;		/* Section header table entry count */
  Elf32_Half	e_shstrndx;		/* Section header string table index */
} Elf32_Ehdr;

比较重要的几个参数e_type: 表示ELF文件类型. e_entry: 表示程序入口虚拟地址, 注意不是main函数的地址. 而是.text段的首地址_start.

# 以下使用Ubuntu
# 读取可执行文件ELF header
> sudo apt-get install binutils gdb
> readelf -h main

Program Header Table#

Program Header Table用来保存程序加载到内存中所需要的信息, 使用段(Segment)表示.

  • elf程序最终目的是被加载到内存, Program Header Table 告诉被调用者怎样把自己加载到内存,加载到什么地址,拷贝多长的数据,这些是elf存在的意义,program header table就提供这个信息。

Section Header Table#

section可以有不同的作用,用于放代码的section,用于放数据的section,一个section有多长,它在elf文件的什么位置,这些都需要元数据去描述,Section Header Table就是这个作用。

Linux下程序是如何运行起来的?#

shell视角#

  • shell运行一个程序, 父shell进程生成子进程, 子进程通过execve系统调用启动加载器.

  • 内核中实际执行execv()execve()系统调用的程序是do_execve(),这个函数先打开目标映像文件,并从目标文件的头部(第一个字节开始)读入若干(当前Linux内核中是128)字节(实际上就是填充ELF文件头)

  • 然后调用另一个函数search_binary_handler(),在此函数里面,它会搜索我们上面提到的Linux支持的可执行文件类型链表,而elf文件类型会通过调用load_elf_binary()函数加载.

execve系统调用: 用户空间ELF文件加载函数调用栈(/fs/exec.c):

sys_execve/sys_execveat
|
|--> do_execve
			|-->  do_execveat_common
					|-->  _do_execve_file
								|--> exec_binprm
										|--> search_binary_handler
												|--> load_elf_binary

最终通过load_elf_binary函数将ELF可执行文件到内存中.

  • load_elf_binary() 过程
[1] 填充并且检查目标程序ELF头部
[2] load_elf_phdrs加载目标程序的程序头表
[3] 如果需要动态链接, 则寻找和处理解释器段
[4] 检查并读取解释器的程序表头
[5] 装入目标程序的段segment
[6] 填写程序的入口地址
[7] create_elf_tables填写目标文件的参数环境变量等必要信息
[8] start_thread 准备进入新的程序入口

进程角度#

elf是静态文件,程序执行时所需要的指令和数据必需在内存中才能够正常运行。load_elf_binary 就是将elf里的指令和数据加载到内存中。

进程的建立需要做三件事情:

  1. 建立一个独立的虚拟内存地址(先共享父进程的内存页. 即COW机制)
  2. 读取ELF Header. 建立虚拟内存地址与可执行文件的映射关系(在子进程需要加载新elf文件时)
  3. 将CPU的指令寄存器设置成可执行文件的入口地址, 启动运行.

工具, 通过binutils readelf 工具用于分析 ELF 的信息 objdump 工具用来显示二进制文件的信息 hexdump 工具用来查看文件的十六进制编码 nm 工具用来显示关于指定文件中符号的信息

  • 进程与虚拟地址

操作系统会给进程分配一个虚拟地址。所有进程看到的这个地址都是一样的,里面的内存都是从 0 开始编号。在程序里面,指令写入的地址是虚拟地址。例如,位置为 10M 的内存区域,操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。

当程序要访问虚拟地址的时候,由内核的数据结构进行转换,转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。

如果系统是32位, 则系统会为每个进程分配2^32=4G内存地址空间.

虚拟空间会分为两部分: 内核空间与用户空间

内核空间: 其中0xC00000000xFFFFFFFF这个地址段是留给操作系统使用, 主要用于系统(Linux内核)与进程通信和交换数据使用.

用户空间: 用户可以使用3GB的空间从0x000000000xBFFFFFFF.

  1. 如果系统是64位, 在系统会为每个进程分配2^48G = 256T 内存地址空间.
  2. 用户空间, 内核空间的分界在32位与64位下不同.
  3. 这里所说的内存指虚拟内存. 不是实际的物理内存.

引用#

Go程序是如何运行起来的?
https://fuwari.vercel.app/posts/golang/how-run-golang/
作者
Morse Hsiao
发布于
2021-05-18
许可协议
CC BY-NC-SA 4.0