一个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里的指令和数据加载到内存中。
进程的建立需要做三件事情:
- 建立一个独立的虚拟内存地址(先共享父进程的内存页. 即COW机制)
- 读取ELF Header. 建立虚拟内存地址与可执行文件的映射关系(在子进程需要加载新elf文件时)
- 将CPU的指令寄存器设置成可执行文件的入口地址, 启动运行.
工具, 通过
binutils
readelf 工具用于分析 ELF 的信息 objdump 工具用来显示二进制文件的信息 hexdump 工具用来查看文件的十六进制编码 nm 工具用来显示关于指定文件中符号的信息
- 进程与虚拟地址
操作系统会给进程分配一个虚拟地址。所有进程看到的这个地址都是一样的,里面的内存都是从 0 开始编号。在程序里面,指令写入的地址是虚拟地址。例如,位置为 10M 的内存区域,操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。
当程序要访问虚拟地址的时候,由内核的数据结构进行转换,转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。
如果系统是32位, 则系统会为每个进程分配2^32=4G内存地址空间.
虚拟空间会分为两部分: 内核空间与用户空间
内核空间: 其中0xC0000000
到0xFFFFFFFF
这个地址段是留给操作系统使用, 主要用于系统(Linux内核)与进程通信和交换数据使用.
用户空间: 用户可以使用3GB的空间从0x00000000
到0xBFFFFFFF
.
- 如果系统是64位, 在系统会为每个进程分配2^48G = 256T 内存地址空间.
- 用户空间, 内核空间的分界在32位与64位下不同.
- 这里所说的内存指虚拟内存. 不是实际的物理内存.