2、编译和常用命令

编译过程

编译基础知识

抽象语法树

在计算机科学中,抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

之所以说语法是抽象的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现。而类似于 if else 这样的条件判断语句,可以使用带有两个分支的节点来表示。

以算术表达式1+3*(4-1)+2为例,可以解析出的抽象语法树如下图所示:

抽象语法树

抽象语法树可以应用在很多领域,比如浏览器,智能编辑器,编译器。

静态单赋值

在编译器设计中,静态单赋值形式(static single assignment form,通常简写为 SSA form 或是 SSA)是中介码(IR,intermediate representation)的属性,它要求每个变量只分配一次,并且变量需要在使用之前定义。在实践中我们通常会用添加下标的方式实现每个变量只能被赋值一次的特性,这里以下面的代码举一个简单的例子:

x := 1
x := 2
y := x

从上面的描述所知,第一行赋值行为是不需要的,因为 x 在第二行被二度赋值并在第三行被使用,在 SSA 下,将会变成下列的形式:

x1 := 1
x2 := 2
y1 := x2

从使用 SSA 的中间代码我们就可以非常清晰地看出变量 y1 的值和 x1 是完全没有任何关系的,所以在机器码生成时其实就可以省略第一步,这样就能减少需要执行的指令来优化这一段代码。

在中间代码中使用 SSA 的特性能够为整个程序实现以下的优化:

因为 SSA 的主要作用就是代码的优化,所以是编译器后端(主要负责目标代码的优化和生成)的一部分。

指令集架构

指令集架构(Instruction Set Architecture,简称 ISA),又称指令集或指令集体系,是计算机体系结构中与程序设计有关的部分,包含了基本数据类型,指令集,寄存器,寻址模式,存储体系,中断,异常处理以及外部 I/O。指令集架构包含一系列的 opcode 即操作码(机器语言),以及由特定处理器执行的基本命令。

指令集架构常见种类如下:

不同的处理器(CPU)使用了大不相同的机器语言,所以我们的程序想要在不同的机器上运行,就需要将源代码根据架构编译成不同的机器语言。

go编译原理

Go语言编译器的源代码在 cmd/compile 目录中,目录下的文件共同构成了Go语言的编译器,编译器的前端一般承担着词法分析、语法分析、类型检查和中间代码生成几部分工作,而编译器后端主要负责目标代码的生成和优化,也就是将中间代码翻译成目标机器能够运行的机器码。

img

Go的编译器在逻辑上可以被分成四个阶段:词法与语法分析、类型检查和 AST 转换、通用 SSA 生成和最后的机器代码生成。

词法与语法分析

所有的编译过程其实都是从解析代码的源文件开始的,词法分析的作用就是解析源代码文件,它将文件中的字符串序列转换成 Token 序列,方便后面的处理和解析,我们一般会把执行词法分析的程序称为词法解析器(lexer)

而语法分析的输入就是词法分析器输出的 Token 序列,这些序列会按照顺序被语法分析器进行解析,语法的解析过程就是将词法分析生成的 Token 按照语言定义好的文法(Grammar)自下而上或者自上而下的进行规约,每一个 Go 的源代码文件最终会被归纳成一个 SourceFile 结构:

SourceFile = PackageClause ";" { ImportDecl ";" } { TopLevelDecl ";" }

标准的 Golang 语法解析器使用的就是 LALR(1) 的文法,语法解析的结果其实就是抽象语法树(AST),每一个 AST 都对应着一个单独的Go语言文件,这个抽象语法树中包括当前文件属于的包名、定义的常量、结构体和函数等。

如果在语法解析的过程中发生了任何语法错误,都会被语法解析器发现并将消息打印到标准输出上,整个编译过程也会随着错误的出现而被中止。

类型检查

当拿到一组文件的抽象语法树 AST 之后,Go语言的编译器会对语法树中定义和使用的类型进行检查,类型检查分别会按照顺序对不同类型的节点进行验证,按照以下的顺序进行处理:

通过对每一棵抽象节点树的遍历,我们在每一个节点上都会对当前子树的类型进行验证保证当前节点上不会出现类型错误的问题,所有的类型错误和不匹配都会在这一个阶段被发现和暴露出来。

类型检查的阶段不止会对树状结构的节点进行验证,同时也会对一些内建的函数进行展开和改写,例如 make 关键字在这个阶段会根据子树的结构被替换成 makeslice 或者 makechan 等函数。

其实类型检查不止对类型进行了验证工作,还对 AST 进行了改写以及处理Go语言内置的关键字,所以,这一过程在整个编译流程中是非常重要的,没有这个步骤很多关键字其实就没有办法工作。

中间代码生成

当我们将源文件转换成了抽象语法树,对整个语法树的语法进行解析并进行类型检查之后,就可以认为当前文件中的代码基本上不存在无法编译或者语法错误的问题了,Go语言的编译器就会将输入的 AST 转换成中间代码。

Go语言编译器的中间代码使用了 SSA(Static Single Assignment Form) 的特性,如果我们在中间代码生成的过程中使用这种特性,就能够比较容易的分析出代码中的无用变量和片段并对代码进行优化。

在类型检查之后,就会通过一个名为 compileFunctions 的函数开始对整个Go语言项目中的全部函数进行编译,这些函数会在一个编译队列中等待几个后端工作协程的消费,这些 Goroutine 会将所有函数对应的 AST 转换成使用 SSA 特性的中间代码。

机器码生成

Go语言源代码的 cmd/compile/internal 目录中包含了非常多机器码生成相关的包,不同类型的 CPU 分别使用了不同的包进行生成 amd64、arm、arm64、mips、mips64、ppc64、s390x、x86 和 wasm,也就是说Go语言能够在几乎全部常见的 CPU 指令集类型上运行。

编译器入口

Go语言的编译器入口是 src/cmd/compile/internal/gc 包中的 main.go 文件,这个 600 多行的 Main 函数就是Go语言编译器的主程序,这个函数会先获取命令行传入的参数并更新编译的选项和配置,随后就会开始运行 parseFiles 函数对输入的所有文件进行词法与语法分析得到文件对应的抽象语法树:

func Main(archInit func(*Arch)) {
    // ...

    lines := parseFiles(flag.Args())
}

接下来就会分九个阶段对抽象语法树进行更新和编译,整个过程会经历类型检查、SSA 中间代码生成以及机器码生成三个部分:

重新回到词法和语法分析后的具体流程,在这里编译器会对生成语法树中的节点执行类型检查,除了常量、类型和函数这些顶层声明之外,它还会对变量的赋值语句、函数主体等结构进行检查:

for i := 0; i < len(xtop); i++ {
    n := xtop[i]
    if op := n.Op; op != ODCL && op != OAS && op != OAS2 && (op != ODCLTYPE || !n.Left.Name.Param.Alias) {
        xtop[i] = typecheck(n, ctxStmt)
    }
}
for i := 0; i < len(xtop); i++ {
    n := xtop[i]
    if op := n.Op; op == ODCL || op == OAS || op == OAS2 || op == ODCLTYPE && n.Left.Name.Param.Alias {
        xtop[i] = typecheck(n, ctxStmt)
    }
}
for i := 0; i < len(xtop); i++ {
    n := xtop[i]
    if op := n.Op; op == ODCLFUNC || op == OCLOSURE {
        typecheckslice(Curfn.Nbody.Slice(), ctxStmt)
    }
}
checkMapKeys()
for _, n := range xtop {
    if n.Op == ODCLFUNC && n.Func.Closure != nil {
        capturevars(n)
    }
}
escapes(xtop)
for _, n := range xtop {
    if n.Op == ODCLFUNC && n.Func.Closure != nil {
        transformclosure(n)
    }
}

类型检查会对传入节点的子节点进行遍历,这个过程会对 make 等关键字进行展开和重写,类型检查结束之后并没有输出新的数据结构,只是改变了语法树中的一些节点,同时这个过程的结束也意味着源代码中已经不存在语法错误和类型错误,中间代码和机器码也都可以正常的生成了。

initssaconfig()
peekitabs()
for i := 0; i < len(xtop); i++ {
    n := xtop[i]
    if n.Op == ODCLFUNC {
        funccompile(n)
    }
}
compileFunctions()
for i, n := range externdcl {
    if n.Op == ONAME {
        externdcl[i] = typecheck(externdcl[i], ctxExpr)
    }
}
checkMapKeys()

在主程序运行的最后,会将顶层的函数编译成中间代码并根据目标的 CPU 架构生成机器码,不过这里其实也可能会再次对外部依赖进行类型检查以验证正确性。

源码文件

img

命令源码文件

声明自己属于 main 代码包、包含无参数声明和结果声明的 main 函数。

命令源码文件被安装以后,GOPATH 如果只有一个工作区,那么相应的可执行文件会被存放当前工作区的 bin 文件夹下;如果有多个工作区,就会安装到 GOBIN 指向的目录下。

命令源码文件是 Go 程序的入口。

同一个代码包中最好也不要放多个命令源码文件。多个命令源码文件虽然可以分开单独 go run 运行起来,但是无法通过 go build 和 go install。

库源码文件

库源码文件就是不具备命令源码文件上述两个特征的源码文件。存在于某个代码包中的普通的源码文件。

库源码文件被安装后,相应的归档文件(.a 文件)会被存放到当前工作区的 pkg 的平台相关目录下。

测试源码文件

名称以 _test.go 为后缀的代码文件,并且必须包含 Test 或者 Benchmark 名称前缀的函数。

Go的命令

go env # 用于打印Go语言的环境信息。
go run # 命令可以编译并运行命令源码文件。
go get # 可以根据要求和实际情况从互联网上下载或更新指定的代码包及其依赖包,并对它们进行编译和安装。
go build # 命令用于编译我们指定的源码文件或代码包以及它们的依赖包。
go install # 用于编译并安装指定的代码包及它们的依赖包。
go clean # 命令会删除掉执行其它命令时产生的一些文件和目录。
go doc # 命令可以打印附于Go语言程序实体上的文档。我们可以通过把程序实体的标识符作为该命令的参数来达到查看其文档的目的。
go test # 命令用于对Go语言编写的程序进行测试。
go list # 命令的作用是列出指定的代码包的信息。
go fix # 会把指定代码包的所有Go语言源码文件中的旧版本代码修正为新版本的代码。
go vet # 是一个用于检查Go语言源码中静态错误的简单工具。
go tool pprof # 交互式的访问概要文件的内容。

通用命令标记:

-a	# 用于强制重新编译所有涉及的 Go 语言代码包(包括 Go 语言标准库中的代码包),即使它们已经是最新的了。该标记可以让我们有机会通过改动底层的代码包做一些实验。
-n	# 使命令仅打印其执行过程中用到的所有命令,而不去真正执行它们。如果不只想查看或者验证命令的执行过程,而不想改变任何东西,使用它正好合适。
-race	# 用于检测并报告指定 Go 语言程序中存在的数据竞争问题。当用 Go 语言编写并发程序的时候,这是很重要的检测手段之一。
-v	# 用于打印命令执行过程中涉及的代码包。这一定包括我们指定的目标代码包,并且有时还会包括该代码包直接或间接依赖的那些代码包。这会让你知道哪些代码包被执行过了。
-work	# 用于打印命令执行时生成和使用的临时工作目录的名字,且命令执行完成后不删除它。这个目录下的文件可能会对你有用,也可以从侧面了解命令的执行过程。如果不添加此标记,那么临时工作目录会在命令执行完毕前删除。
-x	# 使命令打印其执行过程中用到的所有命令,并同时执行它们。

go run

专门用来运行命令源码文件的命令,注意,这个命令不是用来运行所有 Go 的源码文件的!

go run 命令只能接受一个命令源码文件以及若干个库源码文件(必须同属于 main 包)作为文件参数,且不能接受测试源码文件。它在执行时会检查源码文件的类型。如果参数中有多个或者没有命令源码文件,那么 go run 命令就只会打印错误提示信息并退出,而不会继续执行。

go run main.gogo run *.go的区别:例如,有同属main包的两个文件main.gotest.go,如果main.go调用了test.go中的方法或变量,那么需要使用go run *.go来执行,才会将所有文件一起编译,在windows下需要使用go run ./

go build

go build 命令主要是用于测试编译。在包的编译过程中,若有必要,会同时编译与之相关联的包。

  1. 如果是普通包,当你执行go build命令后,不会产生任何文件,只测试语法是否正确。
  2. 如果是main包,当只执行go build命令后,会在当前目录下生成一个可执行文件。如果需要在$GOPATH/bin目录下生成相应的可执行文件,需要执行go install 或者使用 go build -o 路径/可执行文件
  3. 如果某个文件夹下有多个文件,而你只想编译其中某一个文件,可以在go build之后加上文件名,例如go build a.gogo build 命令默认会编译当前目录下的所有go文件。
  4. 你也可以指定编译输出的文件名。比如,我们可以指定go build -o targetPath *.go,默认情况是你的package名(非main包),或者是第一个源文件的文件名(main包)。
  5. go build 会忽略目录下以_或者.开头的go文件。
  6. 如果你的源代码针对不同的操作系统需要不同的处理,那么你可以根据不同的操作系统后缀来命名文件。

当代码包中有且仅有一个命令源码文件的时候,在文件夹所在目录中执行go build命令,会在该目录下生成一个与目录同名的可执行文件。

打包不同平台的可执行文件

执行go build命令前,修改环境变量可以设置打包平台

Windows下使用set GOARCH=[arch]set GOOS=[os]进行设置

Linux和Mac下使用export GOARCH=[arch]export GOOS=[os]进行设置

常见平台参数如下

平台 GOARCH GOOS
Windows x86_64 amd64 windows
Linux x86_64 amd64 linux
MacOS intel amd64 darwin
MacOS Apple Silicon arm64 darwin

go install

go install 命令是用来编译并安装代码包或者源码文件的。

go install 命令在内部实际上分成了两步操作:第一步是生成结果文件(可执行文件或者.a包),第二步会把编译好的结果移到$GOPATH/pkg或者$GOPATH/bin

可执行文件: 一般是go install带main函数的go文件产生的,有函数入口,所以可以直接运行。

.a应用包: 一般是go install不包含main函数的go文件产生的,没有函数入口,只能被调用。

go install 用于编译并安装指定的代码包及它们的依赖包。当指定的代码包的依赖包还没有被编译和安装时,该命令会先去处理依赖包。与go build命令一样,传给 go install 命令的代码包参数应该以导入路径的形式提供。并且,go build命令的绝大多数标记也都可以用于go install上,go install 命令只比go build命令多做了一件事,即:安装编译后的结果文件到指定目录。

安装代码包会在当前工作区的 pkg 的平台相关目录下生成归档文件(即.a文件)。 安装命令源码文件会在当前工作区的 bin 目录(如果 GOPATH 下有多个工作区,就会放在 GOBIN 目录下)生成可执行文件。

同样,go install 命令如果后面不追加任何参数,它会把当前目录作为代码包并安装。这和 go build 命令是完全一样的。

go install 命令后面如果跟了代码包导入路径作为参数,那么该代码包及其依赖都会被安装。

go install 命令后面如果跟了命令源码文件以及相关库源码文件作为参数的话,只有这些文件会被编译并安装。

Makefile

Makefile 是一个强大且灵活的构建工具,具备自动化构建、处理依赖关系、任务管理和跨平台支持等优点。通过编写和使用 Makefile,开发者可以简化项目的构建过程,提高开发效率,并实现自动化的构建和发布流程。

在许多开源项目和工具中,Makefile 被广泛选择作为构建工具。它的灵活性主要体现在其具有 target(目标)的概念,相比于仅使用 Shell 脚本,Makefile 可以更好地组织和管理构建过程。

此外,Makefile 还能够与其他工具和语言进行集成,例如与 C/C++ 编译器、Go 工具链等配合使用。通过定义适当的规则和命令,可以实现与其他构建工具的无缝集成,进一步提高构建过程的灵活性和效率。

Makefile最初是用来解决C语言的编译问题的,所以和C的关系特别密切,但并不是说Makefile只能用来解决C的编译问题,也可以用来作为其他语言的编译工具,例如Java、Go等

Makefile 是由 GNU Make 工具解析执行的配置文件。要调用 Makefile,需要在命令行中使用make命令,并指定要执行的目标或规则。

Makefile可以简单的认为是一个工程文件的编译规则,描述了整个工程的自动编译和链接的规则。

语法格式

# 这是注释
target: prerequisites
[tab]commands

**注意:**每个命令行前面必须是一个 Tab 字符。

make命令

在命令行中使用 make 命令调用 Makefile,并指定要执行的目标。如果未指定目标,默认会执行 Makefile 中的第一个目标。

make [target]

-f <filename>:指定要使用的 Makefile 文件名,例如 make -f mymakefile

-C <directory>:指定 Makefile 的工作目录,例如 make -C src

例子1

all:
	@echo "Hello all"
test:
	@echo "Hello test"

# 执行结果如下
$ make
Hello all
$ make all
Hello all
$ make test
Hello test

调用 make 命令时,我们得告诉它我们的目标是什么,即要它干什么。当没有指明具体的目标是什么时,那么 make 以 Makefile 文件中定义的第一个目标作为这次运行的目标。这第一个目标也称之为默认目标(和是不是all没有关系)。

命令前加了一个@, 这一符号告诉 make,在运行时不要将这一行命令显示出来。

例子

例子2

all: test
	@echo "Hello all"
test:
	@echo "Hello test"

# 执行结果如下
$ make
Hello test
Hello all

会发现当运行 make 时,test 目标也被构建了。这里需要引入 Makefile 中依赖关系的概念,all 目标后面的 test 是告诉 make,all 目标依赖 test 目标,这一依赖目标在 Makefile 中又被称之为依赖。出现这种目标依赖关系时,make工具会按从左到右的先后顺序先构建规则中所依赖的每一个目标。如果希望构建 all 目标,那么make 会在构建它之前得先构建 test 目标,这就是为什么我们称之为依赖的原因。

在实际使用中,通常一个编译过程需要生成多个目标文件(中间文件),如下

all:main.o foo.o
	gcc -o simple main.o foo.o
main.o:
	gcc -o main.o -c main.c
foo.o:
	gcc -o foo.o -c foo.c
clean:
	rm simple main.o foo.o

# 执行结果如下
$make
gcc -c main.c -o main.o
gcc -c foo.c -o foo.o
gcc -o simple main.o foo.o

$make
gcc -o simple main.o foo.o

上面的这个就是一个常见的C语言编译的过程,如果要生成可执行文件simple,那么需要main.ofoo.o,所以我们需要提前编译这两个文件。同时我们还增加了一个目标clean,用于清理编译产生的所有文件。

第二次编译并没有构建目标文件的动作,但有构建simple可执行程序的动作,我们需要了解 make 是如何决定哪些目标(这里是文件)是需要重新编译的。为什么 make会知道我们并没有改变 main.c foo.c 呢?通过文件的时间戳,也就是foo.c文件的时间戳小于foo.o。当 make 在运行一个规则时,我们前面已经提到了目标和依赖之间的关系,make 在检查一个规则时,采用的方法是:如果依赖目标中相关的文件的时间戳大于依赖文件的时间戳,依赖的文件比目标更新,则知道有变化,那么需要运行规则当中的命令重新构建目标。

那为什么会执行一次gcc -o simple main.o foo.o呢?因为all文件在我们的编译过程中并不生成,即 make 在第二次编译时找不到,所以又重新编译了一遍。如果我们把all改成simple,那么就不会重新编译了。

伪目标

我们现在有一个目标

clean:
	rm simple main.o foo.o

如果此时在工作目录中存在一个clean文件,那么我们在执行make clean的时候,make会提示'clean' is up to date.。这是因为 make 将 clean 当作文件,且在当前目录找到了这个文件,加上 clean 目标没有任何依赖,当我们要求 make 为我们构建 clean 目标时,它就会认为 clean 是最新的。

采用.PHONY关键字声明一个目标后,make 并不会将其当作一个文件来处理,而只是当作一个概念上的目标。对于假目标,我们可以想像的是由于并不与文件关联,所以每一次 make 这个假目标时,其所在的规则中的命令都会被执行。

.PHONY:clean

clean:
	rm simple main.o foo.o

变量

系统变量

$*:不包括扩展名的目标文件名称;

$+:所以的依赖文件,以空格分隔;

$<:表示规则中的第一个条件;

$?:所有时间戳比目标文件晚的依赖文件,以空格分隔;

$@:目标文件的完整名称;

$^:所有不重复的依赖文件,以空格分隔;

$%:如果目标是归档成员,则该变量表示目标的归档成员名称;

定义变量

在 Makefile 中,变量的定义需要使用:=进行赋值操作,变量通过$()进行访问

# 定义变量
GREETING := "Hello, World!"

# 输出变量
variable:
	@echo "$(GREETING)"

# 执行make,输出Hello, World!

在 Makefile 中,?= 是一个预定义的变量赋值方式,被称为 “延迟求值”(Lazy Evaluation)。

具体来说,这个符号用于设置一个变量的默认值,只有当该变量没有被显式设置时才会使用默认值。如果变量已经被设置了,那么 ?= 将不会起作用,而是保留原来的值。

# 定义变量
GREETING := "Hello!"
GREETING ?= "Hello, World!"

# 输出变量
variable:
	@echo "$(GREETING)"
# 执行make,输出Hello!

数组

访问数组时,第一个元素可以使用$(firstword $(arr)),也可以使用$(word 1,$(arr))来访问,最后一个元素可以使用$(lastword $(arr)),也可以使用$(word index,$(arr))来访问。

# 定义数组
names := java go python
# 访问数组中元素
show:
	@echo "Index1 : $(firstword $(names))"
	@echo "Index2 : $(word 2,$(names))"
	@echo "Index4 : $(lastword $(names))"

遍历数组

# 定义数组
names := java go python
# 访问数组中元素
show:
	@for name in $(names);do\
		echo "$$name";\
	done