李太吉的技术博客

人生到处知何似 应似飞鸿踏雪泥

0%

LLVM

LLVMLow Level Virtual Machine (低级虚拟机)的首字母缩写, LLVM 发展至今已不再是“低级”虚拟机了,而是一个编译器的基础设施系统框架,提供程序分析、代码优化、机器代码生成等功能。

LLVM 工具链

LLVM 有专门的文件格式 .ll(可读的 LLVM 字节码文件,即 LLVM IR 文件)、 .bcLLVM 字节码文件),同时 LLVM 也有其他的配套工具链用于编译、优化、链接等。.bc 文件比 .ll 文件多进行了汇编阶段,因此我们可以通过反汇编 .bc 文件得到 .ll 文件。下面我们以 main.cppfib.cpp 为例介绍 LLVM 工具链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// fib.cpp
long long fib(int n)
{
if (n < 2)
return n;
long long result = 0;
long long f0 = 0;
long long f1 = 1;
for (int i = 1; i < n; i++)
{
result = f0 + f1;
f0 = f1;
f1 = result;
}
return result;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// main.cpp
#include <cstdio>

extern long long fib(int n);

int main()
{
int n;
printf("Please enter the order number: ");
scanf("%d", &n);
printf("The %dth Fibonacci number: %lld\n", n, fib(n));
return 0;
}

llvm-as

.ll 文件汇编为 bc 文件:

1
llvm-as main.ll -o main.bc

llvm-dis

.bc 文件反汇编为 .ll 文件:

1
llvm-dis main.bc -o main.ll

传统的编译器一般是对 obj 文件进行链接,LLVM 也可以对 .ll.bc 文件(以下统称 LLVM 字节码文件)进行链接。

llvm-link 接受 .ll.bc 文件,进行链接并可输出 .ll.bc 文件:

1
2
llvm-link main.ll fib.ll -S -o linked.ll
llvm-link main.bc fib.bc -o linked.bc

lli

LLVM 可以直接运行 .ll.bc 文件。

lli 执行 .ll.bc 文件:

1
2
lli linked.ll
lli linked.bc

注:lli 工具使用 JIT (即时编译)作为执行 LLVM 字节码文件的默认方法,若你的源码中包含对库函数或其他外部函数的调用,lli 运行时一般会出错。因为正常编译时,链接器会处理这些调用的外部函数,而 LLVM 字节码文件尚未经过链接,这些外部函数在 LLVM 字节码文件中只是一些符号,直接通过 lli 运行会出现未定义错误。

llc

LLVM 可以将 .ll.bc 文件编译为通用汇编语言。

llc 编译 LLVM 源文件到用于指定的体系结构的汇编语言:

1
llc main.ll -o main.s

opt

LLVM 还可以对 .ll.bc 进行优化,LLVM 的优化能力也是 LLVM 的一项突出能力。

注:

  1. 当使用 -O0 编译(默认即是 -O0 )时,Clang 向每个函数添加 optnone 属性,这阻止了以后的进一步优化,为了防止这种情况,可以添加 -Xclang -disable-O0-optnone 选项。

    1
    clang++ fib.cpp -Xclang -disable-O0-optnone -emit-llvm -S -o fib.ll
  2. 当希望进行调试时,最好使用 -O0-O1 编译,因为优化可能会改变控制流,导致指令执行顺序发生变化;还可能直接将一些潜在的 bug 优化掉(尽管 bug 没有了,但这是编译器优化解决的,不代表代码正确,你甚至不知道 bug 的存在,当你更换编译选项或者编译器时,bug 就又出现了)。
  3. 越高的优化级别生成的代码一般执行速度更快,但代码大小也普遍更大。
  4. clang 是一个驱动程序,给 clang 传入优化选项实质是传给了 opt

opt 可以对 .ll.bc 文件进行优化,opt 可以接受的优化选项很多,这里不再赘述。opt 还可以生成控制流图(Control Flow Graphic ):

1
2
3
4
> clang++ fib.cpp -emit-llvm -fno-discard-value-names -S -o fib.ll
> opt -analyze -dot-cfg-only fib.ll
Writing '._Z3fibi.dot'...
Printing analysis 'Print CFG of function to 'dot' file (with no function bodies)' for function '_Z3fibi':

你会得到一些 .dot 文件,你需要配置 Graphviz ,执行 dot ._Z3fibi.dot -Tpng -o fib.png

cfg

opt 还支持以下的可视化图形帮助理解分析逻辑:

1
2
3
4
5
6
7
8
9
--view-callgraph                                  - View call graph
--view-cfg - View CFG of function
--view-cfg-only - View CFG of function (with no function bodies)
--view-dom - View dominance tree of function
--view-dom-only - View dominance tree of function (with no function bodies)
--view-postdom - View postdominance tree of function
--view-postdom-only - View postdominance tree of function (with no function bodies)
--view-regions - View regions of function
--view-regions-only - View regions of function (with no function bodies)

opt 可以根据硬件平台的不同执行不同的优化,可以参看编译器优化做指令调度时是怎么考虑不同的微架构下对同一个指令的执行周期数是不同的? - RednaxelaFX的回答 - 知乎以及使用clang: how to list supported target architectures?查看 LLVM 支持的平台和 CPU

lld

LLVM 拥有配套的链接器 LLD,可以进行链接时优化(Link Time Optimization ),而且相对于 GNU ld ,链接速度更快,编译输出更小,具体请查看 LLD 官网

可以在编译时添加 -fuse-ld=lld 选项来指定 LLD 链接器:

1
clang++ main.cpp -fuse-ld=lld -o main.exe

LLVM 通过 LLVM IR 来实现 LTO,如果想使用 LTO ,需要在编译每个待链接的文件以及链接这些文件时都添加 -flto 选项:

1
2
3
clang++ main.cpp -flto -O1 -c -o main.o
clang++ factorial.bc -flto -O1 -c -o factorial.o
clang++ mian.o factorial.o -flto -fuse-ld=lld -o main.exe

关于 LTO ,可以查看官网上 LLVM LTO 的介绍,以及 GCC LTO的介绍。

注意:

  1. ClangGCC 都支持 LTO,但由于 LTO 是通过中间表示(GCC 上为 GIMPLEClang上为 LLVM IR )实现的,所以不能 ClangGCCLTO 不通用。
  2. 若要使用 LTO ,建议使用相同的选项编译参与链接的所有文件,且必须在编译和链接时添加选项 -flto 。但优化标志 -Og-O2-Os 可以作为优化属性传递,而不会受限于编译时和链接时间标志应该相同的情况。
  3. 在链接时传递的优化和目标选项将被忽略。
  4. 有时需要在编译 .obj 文件时添加 -O1 等优化选项,才会启用 LTO

lldb

LLVM 也有对应的调试器 LLDBLLDBGDB 功能类似,但命令更加友好,而且 LLDB 具有与 Clang 相同的优点,也就是它可以高亮显示调试和错误信息。可以登录 LLDB 官网,学习 LLDB教程,以及查看 LLDBGDB 命令的对照,你也可以首先学习 GDB 的教程 RMS's gdb Debugger Tutorial

LLVM IR 文件的布局

Target Information

1
2
3
4
; ModuleID = 'main.cpp'
source_filename = "main.cpp"
target datalayout = "e-m:w-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-w64-windows-gnu"

target datalayouttarget 的数据布局,target tripletarget 的平台信息。

target

1
i64:64 // 指定支持的整型的对齐单位,这里即是指支持64bit的整型,且以64bit对齐(存储整型的起始地址必须是128的倍数)
1
f80:128 // 指定支持的浮点类型的对齐单位,这里即是指支持80bit的浮点类型,但是以128bit对齐(存储浮点数的起始地址必须是128的倍数)
1
n8:16:32:64 // 指定一组支持的以位为单位的整数类型

其他具体信息,参看datalayout

我的 target triple"x86_64-w64-windows-gnu",即 Windows x64 操作系统上以 MinGW 为运行时环境(一般 Windows 系统上以 MSVC 为运行时环境, Windows 系统和 GCC 搭配来使用 LLVM 较为麻烦,但我觉得 MSVC 实在是太臃肿了。不过电脑配置如果满足要求,还是建议在 Windows 系统上使用 MSVC,因为许多软件在 Windows 系统上支持甚至唯一支持 MSVC 。使用 MSVC不仅省去了不少麻烦,而且 Windows 开发人员早晚跳不过 Visual Studio 。)。

可以通过 clang -v 查看 Clang 的版本和 target 信息,例如我的 Clang具体信息为

1
2
3
4
clang version 9.0.0 (tags/RELEASE_900/final)
Target: x86_64-w64-windows-gnu
Thread model: posix
InstalledDir: D:\LLVM\bin

Clang 编译时可以通过 --target 选项指定编译的目标平台,从而实现交叉编译,当然你需要有对应的运行时库(也就是说,假如你在 Linux 平台上下载了 MSVC 的运行时库,你就可以在 Linux 平台上编译 MSVC 支持的程序)。还可以将不同语言编译到 LLVM IR 层面进行链接,实现多语言的相互调用。这是 LLVM 非常大的一个优势,借助于 LLVM IRLLVM 实现了平台独立性和灵活性。

crosscompile

Clang 支持下列 target triple的组合,具体可以查看 Clang文档

1
2
3
4
5
6
The triple has the general format <arch><sub>-<vendor>-<sys>-<abi>, where:
arch = x86, arm, thumb, mips, etc.
sub = for ex. on ARM: v5, v6m, v7a, v7m, etc.
vendor = pc, apple, nvidia, ibm, etc.
sys = none, linux, win32, darwin, cuda, etc.
abi = eabi, gnu, android, macho, elf, etc.

常用的 target triple 有:

1
2
3
4
5
6
7
8
9
10
11
12
arm-none-eabi
armv7a-none-eabi
arm-linux-gnueabihf
arm-none-linux-gnueabi
i386-pc-linux-gnu
x86_64-apple-darwin10
i686-w64-windows-gnu # same as i686-w64-mingw32
x86_64-pc-linux-gnu # from ubuntu 64 bit
x86_64-unknown-windows-cygnus # cygwin 64-bit
x86_64-w64-windows-gnu # same as x86_64-w64-mingw32
i686-pc-windows-gnu # MSVC
x86_64-pc-windows-gnu # MSVC 64-BIT

LLVM IR 文件的架构

layout

LLVM IR 文件从高到低由 MoudleFunctionBasic BlockInstruction四个层次组成。

SSA (静态单分配)

定义

SSA 形式是指程序中的每个变量必须且只能在定义时初始化。

1
2
3
4
5
int x = 10;
int y = 20; // SSA

int x;
x = 10; // Not SSA

编译器为了进行代码优化,会对变量的定义和使用进行分析,主要有两种:

  • 使用-定义链( Use-­Definition (UD) Chains ):

    对于给定的变量 x 的定义,它的所有使用是什么?

  • 定义-使用链( ­Definition-Use (UD) Chains ):

    对于给定的变量 x 的使用,它的所有可达性定义是什么?

不幸的是,UDDU 检查的花费可能会非常昂贵。

check

这是由于 x 可以重复赋值(也即重定义)导致的,自然地,我们可以想到让每个变量只能定义一次(可以类比 Java 中的 static final 常量,但略有不同,Java 中的 static final 常量可以延迟赋值,把赋值语句放在 static 块中)。

ssa

SSA 在处理控制流分支时存在一个问题:

ssacontrol

变量每次赋值在 SSA 中都成为了一个新的变量,在线性运行时没有问题,但在遇到分支时就无法判断要使用那个新变量了。我们需要$\Phi$ 节点来实现控制流。

$\Phi$ 函数

$\Phi$ 函数将各个控制流分支路径上的定义合并为一个单一的定义

merge

传统指令集并不支持 $\Phi$ 函数(即 LLVM IR 中的 phi 指令)的概念,LLVM 会对 phi 指令进行 Phi destruction ,将 phi 指令变为底层支持的汇编命令,例如我们可以通过在各个控制流分支路径上插入语句定义一个共享变量来实现 $\Phi$ 节点,也可以将因赋值而新定义的变量分配到同一个寄存器,从而实现在 LLVM IR 层次上保持 SSA 形式,而在寄存器层次上实质为同一变量。可以阅读llvm的reg2mem pass做了哪些事情? - 蓝色的回答 - 知乎

implement

初级 SSA

  • 每个赋值都会生成一个新的变量。
  • 在每个插入点为所有分支中的新变量插入 $\Phi$ 节点。

最小化 SSA

  • 每个赋值都会生成一个新的变量。
  • 在每个插入点为处于活跃期的分支中的新变量插入 $\Phi$ 节点。活跃期定义是从变量第一次被定义(赋值)开始,到它下一次被赋值前的最后一次被使用为止。
什么时候插入 $\Phi$ 函数

对于变量 x ,我们当且仅当以下情况时在 Z 中插入 $\Phi$ 函数:

cfg

  • 变量 x 在各个分支( if.thenif.else)总共定义了多于一次。
  • 变量 x 新定义所在的块都可以到达块 Z ,且块 Z 是变量 x 新定义所在块的最先公共后继。

可以应用 Lengauer-Tarjan 算法计算支配树和支配边界判断插入 $\Phi$ 函数的块,关于支配树和支配边界还可以阅读构造Dominator Tree以及Dominator Frontier

SSA 在编译优化中的作用

  • 常量传播( constant propagation

    当 v $\leftarrow$ c 或 v $\leftarrow$ $\Phi$ (c, c, c) 时将 v 替换为 c ,并将 v $\leftarrow$ c 和 v $\leftarrow$ $\Phi$ (c, c, c) 语句删除。

  • 复写传播( copy propagation

    当 x $\leftarrow$ y 或 x $\leftarrow$ $\Phi$ (y, y, y) 时将 x 替换为 y ,并将 x $\leftarrow$ y 和 x $\leftarrow$ $\Phi$ (y, y, y) 语句删除。

  • 常量折叠( constant folding

    当 v $\leftarrow$ expression(c1, c2, …) 时可以将右值表达式计算出结果以替换右值表达式。

  • 无用代码消除( dead code elimination

    • 假设所有变量都是常量,直到该变量值改变。
    • 假设所有基本块都无法执行,直到该块被执行。

    通过可达性分析,将常量进行折叠,并消除无用代码。

SSA 参看书目

LLVM IR 基本语法

简要介绍 LLVM IR 的基本语法常用的指令,详细文档请参看LLVM Language Reference Manual

基本语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
; Function Attrs: noinline nounwind optnone uwtable
define dso_local i64 @_Z3fibi(i32 %n) #0 {
entry:
%retval = alloca i64, align 8
%n.addr = alloca i32, align 4
%result = alloca i64, align 8
%f0 = alloca i64, align 8
%f1 = alloca i64, align 8
%i = alloca i32, align 4
store i32 %n, i32* %n.addr, align 4
%0 = load i32, i32* %n.addr, align 4
%cmp = icmp slt i32 %0, 2
br i1 %cmp, label %if.then, label %if.end

if.then: ; preds = %entry
%1 = load i32, i32* %n.addr, align 4
%conv = sext i32 %1 to i64
store i64 %conv, i64* %retval, align 8
br label %return

if.end: ; preds = %entry
store i64 0, i64* %result, align 8
store i64 0, i64* %f0, align 8
store i64 1, i64* %f1, align 8
store i32 1, i32* %i, align 4
br label %for.cond

for.cond: ; preds = %for.inc, %if.end
%2 = load i32, i32* %i, align 4
%3 = load i32, i32* %n.addr, align 4
%cmp1 = icmp slt i32 %2, %3
br i1 %cmp1, label %for.body, label %for.end

for.body: ; preds = %for.cond
%4 = load i64, i64* %f0, align 8
%5 = load i64, i64* %f1, align 8
%add = add nsw i64 %4, %5
store i64 %add, i64* %result, align 8
%6 = load i64, i64* %f1, align 8
store i64 %6, i64* %f0, align 8
%7 = load i64, i64* %result, align 8
store i64 %7, i64* %f1, align 8
br label %for.inc

for.inc: ; preds = %for.body
%8 = load i32, i32* %i, align 4
%inc = add nsw i32 %8, 1
store i32 %inc, i32* %i, align 4
br label %for.cond

for.end: ; preds = %for.cond
%9 = load i64, i64* %result, align 8
store i64 %9, i64* %retval, align 8
br label %return

return: ; preds = %for.end, %if.then
%10 = load i64, i64* %retval, align 8
ret i64 %10
}

注释

; 表示单行注释的开始。

标识符

LLVM IR 中的标识符分为两种类型:全局的和局部的。全局的标识符包括函数名和全局变量,会加一个 @ 前缀,局部的标识符会加一个 % 前缀。一般地,可用标识符对应的正则表达式为:

1
[%@][-a-zA-Z$._][-a-zA-Z$._0-9]*

函数

1
define dso_local i64 @_Z3fibi(i32 %n) #0

定义了一个函数,其中 i64 代表 64 位整数,即 C/C++ 中的 int@_Z3fibi 是函数名且代表函数是全局的;括号内是参数列表。#0 是指向函数属性的标记符。

数据类型

数组

语法
1
[<elementnumber> x <elementtype>]

数据元素在内存中是连续存储的。对于索引超出静态类型所指定的数组末端没有限制(尽管在某些情况下对于索引超出已分配对象的边界有限制)。这意味着一维”可变大小数组“寻址可以在零长度数组类型的 LLVM 中实现。例如,LLVMpascal 风格数组的实现可以使用 {i32, [0 x float]} 类型。

结构体

语法
1
2
%T1 = type { <type list> }     ; Identified normal struct type
%T2 = type <{ <type list> }> ; Identified packed struct type

结构类型用于表示内存中的数据成员集合。结构的元素可以是任何具有大小的类型。

通过使用 getelementptr 指令获得指向字段的指针,可以使用 loadstore 访问内存中的结构。使用 extractvalueinsertvalue 指令访问寄存器中的结构。

结构可以选择“打包”结构,这表示结构的对齐方式是一个字节,并且元素之间没有填充。在非打包结构中,字段类型之间的填充是由模块中的 DataLayout 字符串定义的,这是匹配底层代码生成器所期望的内容所必需的。

常用指令

alloca

语法
1
<result> = alloca [inalloca] <type> [, <ty> <NumElements>] [, align <alignment>] [, addrspace(<num>)]     ; yields type addrspace(num)*:result

返回一个指针。分配的内存是未初始化的,从未初始化的内存中加载会产生一个未定义的值。如果分配的堆栈空间不足,则操作本身未定义。

alloca 指令也可以用来分配结构体。

load

语法

load 的语法较为复杂,具有多种形式,但常用的形式一般如下:

1
%0 = load i32, i32* %n.addr, align 4 // 从地址%n.addr中读取i32型数据

store

语法

store 的语法较为复杂,具有多种形式,但常用的形式一般如下:

1
store i32 %n, i32* %n.addr, align 4 // 向地址%n.addr中读取i32型数据%n

call

语法
1
2
<result> = [tail | musttail | notail ] call [fast-math flags] [cconv] [ret attrs] [addrspace(<num>)]
<ty>|<fnty> <fnptrval>(<function args>) [fn attrs] [ operand bundles ]
1
%call2 = call i64 @_Z3fibi(i32 %1) // 调用fib函数,返回值赋给%call2

ret

语法
1
2
ret <type> <value>       ; Return a value from a non-void function
ret void ; Return from void function
1
ret i64 %10 // 返回%10

br

语法
1
2
br i1 <cond>, label <iftrue>, label <iffalse>
br label <dest> ; Unconditional branch

在执行条件 br 指令时,将对 i1 参数求值。如果该值为真,则控制流到 iftrue 标签参数。如果 cond 为假,则控制流到 iffalse 标签参数。br 指令的无条件形式以单个 label 值为目标。

phi

语法
1
<result> = phi [fast-math-flags] <ty> [ <val0>, <label0>], ...

phi 指令在逻辑上接受在当前块之前执行的前任基本块对应所指定的值。

phi

phi 指令主要用来解决 SSA (静态单赋值)带来的问题。不过 SSA 带来的变量不能重复赋值问题也能通过指针来解决,可以向一个不变的地址上多次执行 store 指令从而实现多次赋值。

要想在 LLVM IR 中使用 phi 指令,可以应用 -mem2reg 优化:

1
2
clang++ fib.cpp -Xclang -disable-O0-optnone -emit-llvm -S -o fib.ll 
opt -mem2reg -S fib.ll -o fib-opt.ll

注意仍然要添加 -Xclang -disable-O0-optnone 编译。

同理要想在 LLVM IR 中屏蔽 phi 指令,可以应用 -reg2mem 优化(默认情况下即使不使用 phi 指令):

1
2
clang++ fib.cpp -Xclang -disable-O0-optnone -emit-llvm -S -o fib.ll 
opt -reg2mem -S fib.ll -o fib-opt.ll

getelementptr (GEP)

语法
1
2
3
<result> = getelementptr <ty>, <ty>* <ptrval>{, [inrange] <ty> <idx>}*
<result> = getelementptr inbounds <ty>, <ty>* <ptrval>{, [inrange] <ty> <idx>}*
<result> = getelementptr <ty>, <ptr vector> <ptrval>, [inrange] <vector index type> <idx>

第一个 ty是第一个索引使用的基本类型,第二个 ty 表示其后的基址 ptrval的类型。<ty> <idx> 是第一组索引的类型和值,<ty> <idx>可以出现多次,其后出现的就是第二组、第三组等等索引的类型和值。要注意索引的类型和索引使用的基本类型是不一样的,索引的类型一般为 i32i64,而索引使用的基本类型确定的是增加索引值时指针的偏移量。

理解第一个索引
  1. 第一个索引不会改变返回的指针的类型,也就是说 ptrval 前面的 * 对应什么类型,返回就是什么类型
  2. 第一个索引的偏移量的是由第一个索引的值和第一个 ty 指定的基本类型共同确定的。

gep1

上图中第一个索引所使用的基本类型是 [6 x i8],值是1,所以返回的值相对基址 @a_gv 前进了 6 个字节。由于只有一个索引,所以返回的指针也是 [6 x i8]* 类型。

理解后面的索引
  1. 后面的索引是在 Aggregate Types 内进行索引。
  2. 每增加一个索引,就会使得该索引使用的基本类型和返回的指针的类型去掉一层。

gep2

我们看 %elem_ptr = getelementptr [6 x i8], [6 x i8]* @a_gv, i32 0, i32 0 这一句,第一个索引值是 0,使用的基本类型 [6 x i8] , 因此其使返回的指针先前进 0 x 6 个字节,也就是不前进,第二个索引的值是 1,使用的基本类型就是 i8[6 x i8]去掉左边的 6 ),因此其使返回的指针前进一个字节,返回的指针类型为 i8*[6 x i8]*去掉左边的 6 )。

GEP如何作用于结构体

gep3

只有一个索引情况下, GEP 作用于结构体与作用于数组的规则相同,%new_ptr = getelementptr %MyStruct*, %MyStruct* @a_gv, i32 1使得 %new_ptr 相对 @a_gv 偏移一个结构体 %MyStruct 的大小。

gep4

在有两个索引的情况下,第二个索引对返回指针的影响跟结构体的成员类型有关。譬如说在上图中,第二个索引值是 1,那么返回的指针就会偏移到第二个成员,也就是偏移 1 个字节,由于第二个成员是 i32 类型,因此返回的指针是 i32*

gep5

如果结构体的本身也有 Aggregate Type 的成员,就会出现超过两个索引的情况。第三个索引将会进入这个 Aggregate Type 成员进行索引。譬如说上图中的第二个索引是 2 ,指针先指向第三个成员,第三个成员是个数组。再看第三个索引是 0 ,因此指针就指向该成员的第一个元素,指针类型也变成了 i32*

注:GEP 小节引用了知乎用户@ZRN-BF的文章A Tour to LLVM IR(下)

Clang 介绍

上文已经提到 Clang只是前端的一个 Driver ,从编译器架构上来说,Clang 只是用来进行词法分析、语法分析、语义分析、中间代码生成的编译器前端。 Clang 需要借助其他编译器后端来实现机器代码生成。这也是 ClangTarget Information 的作用。 WindowsClang 可以与 MSVCMinGW 搭配起来构成一个完整的编译器。

Clang 生成中间代码(也就是 LLVM 字节码),并在此基础上进行一系列优化操作,再进一步生成可执行文件。由于 Clang 需要和其他的编译器后端组合,所以在编程源码时就会出现许多由于前后端不搭配导致的问题。

Clang 交叉编译

前后端的 target 不同,可以在编译时添加 --target 选项来指定后端 target ,具体参看Target Information

Clang 异常处理模型

首先介绍一下 MinGW-w64 的异常模型的异同,可以参看GCC WikiStackoverflow

  • SJLJsetjmp / longjmp ): 支持 32 位和 64 位系统。传统的异常处理模型,性能较差,即使没有抛出异常,也会导致较小的性能损失(严重异常代码的性能损失约为 15% ),但有时这种损失可能更大。
  • SEHStructured Exception Handling ): 只支持 64 位系统。性能更加优异,当从不使用 SEH 的库中抛出异常时,SEH 异常将导致非常严重的错误。
  • DWARF :只支持 32 位系统。需要使用 DWARF-2(或 DWARF-3 )调试信息。DW2 EH 会导致可执行文件稍微膨胀,因为大型的调用堆栈展开表必须包含在可执行文件中。

一般情况下,x86_64 可选为 sehsjlji686dwarfsjlj 。你可以通过这个回答中的方法查看 ClangGCC 的当前的异常处理模型,也可以阅读 Exceptions Handling in LLVM

普通 Windows 用户在使用 Clang 时可能更倾向于使用官网预编译的二进制版本直接安装,官网的编译版本通常是:

1
2
Target: x86_64-pc-windows-msvc
Thread model: posix

预编译 Clang 默认的异常处理模型为 seh 。如果你使用 MSVC 作为后端,那么可能没有问题,因为 MSVC 同样使用 seh 。但如果你使用 MinGW 作为后端,就需要查看 MinGW 的异常处理模型是否与 Clang 默认的异常处理模型相同,如果不同, 可以添加 -fdwarf-exceptions-fsjlj-exceptions-fseh-exceptions 来指定异常处理模型,或者添加 -fno-exceptions 禁用异常机制(一般不建议)。

Clang 编译流程

process1
process2

  1. ClangClang++ (以下以 clang++ 为例说明,如有两者不同的特殊情况会专门指出)对待编译的源文件 main.cpp 进行预处理,例如将 #include 的文件复制到源文件中、展开宏定义、插入内联函数以及处理 #if#endif#ifndef 等命令。使用 -E 选项指定编译器只进行预处理。
1
clang++ main.cpp -E -o main.i
  1. Clang++main.i 编译为汇编代码文件。使用 -S 选项指定编译器只进行预处理和编译。若添加上 -emit-llvm 选项,则会生成 LLVM IR(一种 LLVM 专用的中间表示,类似于汇编的可读的字节码)文件,否则会生成一般汇编代码文件。

    注: ClangReleaseDebug 版生成的 .ll 文件略有不同,Release 版默认生成的 .ll 文件会丢弃变量的名字,你可以添加 -fno-discard-value-names 选项指定 Clang 保留原有的标签和标识符以增加可读性。

    1
    2
    clang++ main.cpp -S -o main.s
    clang++ main.cpp -S -emit-llvm -o main.ll
  2. Clang++ 将汇编代码文件进行汇编生成 LLVM bitcode (专指 .bc 文件)文件。使用 -c 选项指定编译器只进行预处理、编译和汇编。若添加上 -emit-llvm 选项,则会生成 LLVM bitcode 文件,否则会生成 .obj 文件。

    1
    2
    clang++ main.cpp -c -o main.o
    clang++ main.cpp -c -emit-llvm -o main.bc

    compile

  3. Clang++ 将多个 .obj 文件或 LLVM bitcode 文件链接起来,形成一个完整的文件。使用 -r 选项指定编译器只进行符号链接,把多个 .obj 文件链接为一个总的 .obj 文件。

    1
    clang++ main.o factorial.o -r -o linked.o

    link

    注:

    1. libstdc++libc++msvcrt 都是 C++ 标准库的一个实现。 libstdc++LinuxGCC 的默认运行时库; libc++Mac OSClang 的配套运行时库; msvcrtWindowsVS 的默认运行时库。
    2. C++ 会进行 Name ManglingName Mangling 是将函数名和变量名编码为惟一的名称,以便链接器能够将语言中的名称区分开,以便实现重载。 Name Mangling 按照一定规则根据函数名和函数参数列表生成混淆后的函数名。

      使用以下命令可以得到混淆前的函数签名:

      1
      c++filt -n <mangled-name>
  4. Clang++.obj 文件编译生成可执行文件。

    1
    clang++ linked.o -o main.exe

注意:

  1. clangclang++ 只是前端的一个 Driver (驱动程序),clangclang++ 对源文件的处理本质上都是通过调用 LLVM 工具链实现的。Clang 的命令行用法参看文档
  2. clangclang++ 在预处理、编译和汇编阶段是完全相同,clang++ 本质上是 clang 的一个软连接,它通过后缀名来判断是 C 还是 C++-x <language> 会指定文件语言类型 。不同的是 clang++ 既可以链接 C++ 标准库也可以链接 C 标准库,clang 只能链接 C 标准库。

    1
    2
    3
    4
    clang++ test.c -o test.exe // correct
    clang test.cpp -o test.exe // error
    clang test.cpp -c -o test.o // correct, because clang works same as clang++ during preprocess, compile and assemble steps
    clang test.cpp -stdc++ -o test.exe // correct, clang complie successfully after specifing link library

Clang 编译选项

Clang 兼容 gcc 的所有编译选项,同时 Clang 有附带许多功能,我们可以通过添加编译选项来使用。

-m32-m64

使用 32 位的 clanggcc 时默认生成 32 位的程序;使用 64 位的 clanggcc 时默认生成 64 位的程序。

Windows 下使用 64 位的 gcc 编译 32 位的程序时,必须将对应的动态链接库地址加入到 PATH 环境变量( Linux 下可以设置 LD_LIBRARY_PATH )中,或者在编译时选择静态链接。

注: clang32 位和 64 位交叉编译配置较为麻烦,远不如直接使用 MinGW-w64 。配置交叉编译的选项最好使用专门的构建工具(如 CMake , Makefile ),直接配置环境变量不仅费时费力,而且环境变量是全局的,对编译其他程序极不友好,很容易造成冲突。

-ftime-trace

Clang 9.0.0 增加了 -ftime-trace , 这能够以友好的格式生成时间跟踪分析数据,对于开发人员更好地理解编译器将大部分时间花在何处以及其他需要改进的领域非常有用。

1
2
3
> clang++ test.cpp -ftime-trace -c -o test.o
Time trace json-file dumped to test.json
Use chrome://tracing or Speedscope App (https://www.speedscope.app) for flamegraph visualization

speedscope中你可以看到以下的交互可视化图形:

visualize1

visualize2

Clang Build Analyzer工具有助于聚合来自多个编译的时间跟踪报告,并输出关于“什么花了最多的时间”的信息摘要。

--analyze

1
2
clang --analyze -Xclang -analyzer-checker="cplusplus" test.cpp
clang --analyze -Xanalyzer -analyzer-checker="cplusplus" test.cpp

--analyze 选项启动 Clang 的静态代码分析,能够检查代码中存在的错误与缺陷。 Clang 可以检查进行特定的检查( checker ),Clang 内置的 checker 查看available_checks

Clang 拓展

clang-format

clang-format 是一个代码格式化工具,clang-format 内建了 LLVMGoogleChromiumMozillaWebKit 五种格式,可以通过 --style= 指定,也可以使用 --style=file.clang-format 文件中加载自定义代码格式配置( clang-format 的配置文件名必须是 .clang-format )。

1
clang-format --style=LLVM -i main.cpp

-i 选项指定就地更改 main.cpp

clang-tidy

clang-tidy 是一个基于 clangC++ linter工具。它的目的是提供一个可扩展的框架,用于诊断和修复典型的编程错误,如样式违规、接口误用或可以通过静态分析推断出的 bugclang-tidy 是模块化的,提供了一个方便的接口来编写新的插件。clang-tidy 提供了许多 check,可以在 -check= 中指定一个或多个 check (用 , 隔开)其他详细用法查看官网

1
clang-tidy -checks=-*,clang-analyzer-*,-clang-analyzer-cplusplus* test.cpp --

注:

  1. Windows 下使用要在命令行最后添加 --
  2. Windowsclang-tidy-check-config 选项组合在一起可能会出现问题,可以把所有选项都放在 -config 中。

clang-check

clang-checkclang-tidy 功能类似,在没有显示指定任何选项的情况下运行 clang-check 将运行 -fsyntax-only 模式(检查语法是否正确)。只有在指定 -analyze 时,才会执行静态分析工具,但不能同时指定 -fsyntax-only-analyze-check 选项可以参考官网

clang-check 还可以输出代码的 AST ,具体用法执行 clang-check -help

LLVM 优化

clang 一般只有在开启优化时才会内联函数,如果使用 -fno-inline 选项或 -O0 优化级别(默认优化级别),GCC 将不内联任何函数,可以使用 -Winline 选项来确定函数是否没有内联以及为什么没有内联。此时可以使用 __attribute__ 机制。

在函数声明末尾 ; 之前添加 __attribute__((always_inline)) ,可以强制编译器内联函数(尽可能内联,必须满足内联函数要求)。

C++ 中内联编译限制:

  1. 不能存在任何形式的循环语句。
  2. 不能存在过多的条件判断语句。
  3. 函数体不能过于庞大。
  4. 不能对函数进行取址操作。
  5. 内联函数声明必须在调用语句之前。

Clang 使用中遇到的问题

  1. MinGW-w64float.h 不兼容:

    1
    2
    G:\mingw64\x86_64-w64-mingw32\include\float.h:28:15: fatal error: 'float.h' file not found
    #include_next <float.h>

    查看 'float.h' file not found-StackOverflow

  2. member [...] in archive is not an object 一般有2个原因:

    1. 链接的对象或库文件位数不一致,例如 x64x86 混合。
    2. 使用 LTO 时没有在编译和链接时都添加上 flto
  3. 链接静态库时出现 error: undefined reference to 'xxx' ,一般是由于缺少库文件或者链接顺序错误。被链接的库应该放在最后面。

_参考资料_

[1] RednaxelaFX Blog

与编译器编译输出大小相关的某些因素

问题背景

在编译一个极其简单的 C++ 入门示例( test.cpp )的时候,发现 Code::Blocks 编译的结果比 Visual Studio 编译的结果大许多。

研究过程

我们使用如下工具,对 test.cpp 文件进行编译测试,探究与编译器编译输出大小相关的因素。

测试环境

  • Code::Blocks 17.12GCC 5.1.0
  • Visual Studio 2017MSVC++ 15.0
  • GCC 8.1.0
1
2
3
4
5
6
7
8
9
10
//test.cpp

#include <iostream>
using namespace std;

int main()
{
cout << "Hello World!" << endl;
return 0;
}

问题重现

一般来说,不同的 IDE 都有不同的默认编译选项。我们先用命令行复原模拟 Code::Blocks 17.12Visual Studio 2017 的编译过程。

1
2
3
// Code::Blocks 17.12
mingw32-g++ test.cpp -c -o test.o
mingw32-g++ test.o -o test.exe

输出大小为 1528KB 。对于如此简单的代码,这个大小可以说相当大了。
VS 的编译过程较为复杂,我们不再用命令行模拟,但 VS 的编译输出大小只用 193KB

问题定位

问题可以重现,也就代表这个问题背后有较为稳定的原因,多半是代码造成的结果,而不是玄学导致的,我们也就不用玄学排错了。由于很难用命令行模拟VS编译,我们下面就主要使用 GCC 5.1.0 进行测试。

很容易想到是不是静态链接和动态链接的原因,我们先用 -static 编译选项看一下静态链接编译输出的大小。

1
2
3
// GCC 5.1.0
mingw32-g++ test.cpp -c -o test.o
mingw32-g++ test.o -static -o test.exe

输出大小为 1528KB ,和之前完全一样。为什么静态链接会和动态链接的编译输出一样大?我们查看 GCC 的安装目录,我们可以看到它压根就没有动态库,全部是 .a 静态链接库文件。

image

至此可以说仅就这个问题来说算是找到答案了。 Code::Blocks 下载时配套的编译器为GCC 5.1.0,而该编译器没有携带动态库,只支持静态链接(也就是说不管添不添加-static编译选项,都是静态链接)。相反,VS下载时一并下载了Windows SDK,里面包含了 C Runtime Library (C运行时库),而且既有静态运行时库,也有动态运行时库,因此 Visual Studio 支持静态链接和动态链接, Visual Studio 使用动态链接编译的结果显然比Code::Blocks使用静态链接 编译的结果小许多。

我们用携带动态链接库的 GCC 8.1.0 编译该文件,得到的输出结果大小只有56KB,甚至比 VS 的还要小(当然通过修改编译选项, VS 的输出结果也能做到这么小,甚至更小,毕竟GCC开源, VS 有微软的技术支撑,而且 GCC 本来就不是 Windows 平台的原生编译器,对 Windows 的支持一定没有微软自家好)。

拓展研究

ReleaseDebug

我们比较ReleaseDubug两个版本的编译输出。

1
2
3
4
5
// GCC 8.1.0
// Debug
g++ test.cpp -g -o test.exe
// Release
g++ test.cpp -s -o test.exe

Debug 版有 75KBRelease 版有 56KB 。对于我们的测试代码差别不大,但大型项目的 ReleaseDebug 版本大小就会相差悬殊。

编译器的版本

不同的编译器或者同一编译器的不同版本生成的编译输出大小可能就不同。

1
2
// GCC 8.1.0
g++ test.cpp -static -o test.exe

生成的编译输出大小为2552KB,比GCC 5.1.0还要大。猜测可能是因为要支持更新的C++ 20标准,链接了更多的文件。

IDE和编译器添加的其它优化

总结

与编译器编译输出大小相关的因素一般有如下几个原因

  1. 编译器的链接选项不同,静态链接比动态链接更小(支持动态链接的前提是你要有动态库,不然编译选项是不管用的)。
  2. 编译器可以生成 Release 版和 Debug 版,后者包含调试信息,一般来说体积更大。
  3. 编译器不同,更新版本的编译器生成的代码可能更小更小或更大。
  4. IDE 和编译器添加了其他的优化选项。

Windows bat 语法

注释

命令 作用
:: 注释无回显
rem 注释有回显

命令帮助

命令 作用
<command> /? 返回 <command> 命令的帮助

基础操作

echo

命令 作用
echo text 在命令行中回显 text
echo off 从下一行开始关闭回显
@echo off 从本行开始关闭回显
echo on 从下一行开始开启回显
@echo on 从本行开始开启回显

dir

命令 作用
dir 显示当前目录中的文件和子目录
dir /a 显示当前目录中的文件和子目录,包括隐藏文件和系统文件
dir /a:d 显示当前目录中的目录
dir /a:-d 显示 当前目录中的文件
dir /b/p /b 只显示文件名,/p 分页显示
dir *.exe /s 显示当前目录和子目录里所有的 .exe 文件。通配符 *?

cd

命令 作用
cd .. 返回上一级目录
cd /d <path> 同时改变盘符和目录

目录操作

命令 作用
`md mkdir ` 创建目录(包含子目录)
`rd rmdir ` 删除空目录
rd /q/s <path> /q 安静模式 ,/s 递归
`ren rename ` 将目录 oldName 重命名为 newName

pushd 和 popd

1
pushd <path>

保存当前目录,并切换当前目录为 <path>

1
popd

恢复当前目录为栈顶目录。

文件操作

命令 作用
cd > test.txt 创建文件 test.txt ,可以使用 > 重定位操作符创建文件并写入数据
type test.txt 查看文件 test.txt 内容
del test.txt 删除文件 test.txt ,不能是隐藏、系统、只读文件
del /q/a/f d:\temp\*.* 删除 d:\temp 文件夹里面的所有文件,包括隐藏、只读、系统文件,不包括子目录
del /q/a/f/s d:\temp\*.* #删除 d:\temp 及子文件夹里面的所有文件,包括隐藏、只读、系统文件,不包括子目录

copy

命令 作用
copy key.txt c:\doc 将当前目录下的 key.txt 拷贝到 c:\doc 下(若 doc 中也存在一个 key.txt 文件,会询问是否覆盖)
参数 作用
/Y 不使用确认是否要覆盖现有目标文件
/-Y 使用确认是否要覆盖现有目标文件的提示

xcopy

xcopy命令类似copy,但功能更加强大。

move

move指令的用法基本同上。

时间操作

date

显示和设置当前日期。在 bat 文件中用 %date% 来调用当前日期,格式一般为 yyyy/MM/dd 周一

time

显示和设置当前日期。在 bat 文件中用 %time% 来调用当前日期,格式一般为 HH:mm:ss.cccc0.01s

可以使用

1
2
3
4
5
6
7
8
:: 均为中文系统下
set year = %date:~,4%
set month = %date:~5,2%
set day = %date:~8,2%
set dayofweek = %date:~11,2%
set hour = %time:~0,2%
set minute = %time:~3,2%
set second = %time:~6,2%

控制流操作

if else

1
2
3
IF [NOT] ERRORLEVEL number command
IF [NOT] string1 == string2 command
IF [NOT] EXIST filename command

根据条件决定是否执行 command

注:

  1. ELSE 子句必须出现在同一行上的 IF 之后
  2. ELSE 命令必须与 IF 命令的尾端在同一行上。
  3. 最好将 IFELSE 子句中的 command() 包围起来。
1
IF [/I] string1 compare-op string2 command

其中,/I 指定比较不区分大小写;compare-op可以是:

操作符 作用
EQU 等于
NEQ 不等于
LSS 小于
LEQ 小于或等于
GTR 大于
GEQ 大于或等于

for

1
FOR %variable IN (set) DO command [command-parameters]

在批处理程序中使用 FOR 命令时,指定变量请使用 %%variable ,而不要用 %variable。变量名称是区分大小写的,所以 %i 不同于 %I

1
FOR /D %variable IN (set) DO command [command-parameters]

如果集中包含通配符,则指定与目录名匹配,而不与文件名匹配。

1
FOR /R [[drive:]path] %variable IN (set) DO command [command-parameters]

检查以[drive:]path 为根的目录树,指向每个目录中的 FOR 语句。
如果在 /R 后没有指定目录规范,则使用当前目录。如果集仅为一个单点(.)字符,则枚举该目录树。

1
FOR /L %variable IN (start,step,end) DO command [command-parameters]

该集表示以增量形式从开始到结束的一个数字序列。因此,(1,1,5) 将产生序列 1 2 3 4 5(5,-1,1) 将产生序列(5 4 3 2 1)

1
2
3
FOR /F ["options"] %variable IN (file-set) DO command [command-parameters]
FOR /F ["options"] %variable IN ("string") DO command [command-parameters]
FOR /F ["options"] %variable IN ('command') DO command [command-parameters]

或者,如果有 usebackq 选项:

1
2
3
FOR /F ["options"] %variable IN (file-set) DO command [command-parameters]
FOR /F ["options"] %variable IN ("string") DO command [command-parameters]
FOR /F ["options"] %variable IN ('command') DO command [command-parameters]

fileset 为一个或多个文件名。继续到 fileset 中的下一个文件之前,每份文件都被打开、读取并经过处理。处理包括读取文件,将其分成一行行的文字,然后将每行解析成零或更多的符号。然后用已找到的符号字符串变量值调用 For 循环。 以默认方式,/F 通过每个文件的每一行中分开的第一个空白符号。跳过空白行。你可通过指定可选 "options" 参数替代默认解析操作。这个带引号的字符串包括一个或多个指定不同解析选项的关键字。这些关键字为:

关键字 作用
eol=c 指一个行注释字符的结尾(就一个)
skip=n 指在文件开始时忽略的行数。
delims=xxx 指分隔符集。这个替换了空格和制表符的默认分隔符集。
tokens=x,y,m-n 指每行的哪一个符号被传递到每个迭代的 for 本身。这会导致额外变量名称的分配。m-n格式为一个范围。通过 nth 符号指定 mth。如果符号字符串中的最后一个字符星号,那么额外的变量将在最后一个符号解析之后分配并接受行的保留文本。
usebackq 指定新语法已在下类情况中使用:在作为命令执行一个后引号的字符串并且一个单引号字符为文字字符串命令并允许在 file-set中使用双引号扩起文件名称。

注:

  1. 在批处理程序中使用 FOR 命令时,指定变量请使用 %%variable ,而不要用 %variable。变量名称是区分大小写的,所以 %i 不同于 %I

  2. FOR 变量是单一字母、分大小写和全局的变量; 而且,不能同时使用超过 52 个。

  3. 对于带有空格的文件 名,你需要用双引号将文件名括起来。为了用这种方式来使

     用双引号,还需要使用 `usebackq` 选项,否则,双引号会被理解成是用作定义某个要分析的字符串的。
    

& 、&& 和 ||

& 顺序执行多条命令,而不管命令是否执行成功。

&& 顺序执行多条命令,当碰到执行出错的命令后将不执行后面的命令。

|| 顺序执行多条命令,当碰到执行正确的命令后将不执行后面的命令。

管道操作

|

1
dir *.* /s/a | find /c "".exe""

管道命令表示先执行 dir命令,对其输出的结果执行后面的 find 命令。命令行结果:输出当前文件夹及所有子文件夹里的 .exe 文件的个数。

> 和 >>

> 清除文件中原有的内容后再写入。

>> 追加内容到文件末尾,而不会清除原有的内容。
主要将本来显示在屏幕上的内容输出到指定文件中,指定文件如果不存在,则自动生成该文件。

高级操作

start

启动一个单独的窗口以运行指定的程序或命令。

call

从批处理程序调用另一个批处理程序。

命令行参数

命令 作用
%0 批处理文件本身
%1 第一个参数
%9 第九个参数
%* 从第一个参数开始的所有参数