LLVM-Driver笔记

什么是Driver

在单独编译文件时,我们经常会使用到如下的一些命令

1
clang -ccc-print-phases main.m

这里我们使用的’clang’其实不是我们通常所说的llvm的编译前端clang,而是一个命令行工具,指的就是Clang Driver,它是一个驱动,是面向用户提供接口,内部解析参数,调用编译的中的工具完成编译的过程。

Driver怎么设计

关于这点,clang官方文档有介绍,我这里做个总结

Clang 11 documentation

特点和目标

  1. 与GCC的兼容性

    官方的解释是与GCC的兼容可以让用户在他们的工程中快速使用clang,我理解隐含的意思是为了和GCC抢占用户,这是必须的。

  2. 灵活性

    这个很好理解,和写代码一样,随着clang和LLVM的发展,足够的灵活性保证了新功能的扩展。

  3. 开销小

    和编译工作相比,Driver的工作量并不大,但是也要遵循一些基本的规则来保持它的高效。

    • 尽可能避免内存分配和字符串拷贝
    • 不要多次解析参数
    • 提供一些简单的接口来有效地搜索参数
  4. 简单

    尽管为了兼容GCC给Driver带来了很多复杂的逻辑,但是整个Driver的设计还是尽可能简单。为了达到这个目的,将Driver分为多个独立的阶段而不是一个大的任务。

设计细节

DriverArchitecture

这是一张来自官网的图,介绍了Driver内部设计的几个阶段。其中,橙色的代表数据,绿色代表操作这些数据的阶段,蓝色的代表辅助组件。

  1. Input Strings

    这条不用多解释,就是我们调用clang的时候后面传入的标示和数据

  2. Parse: Option Parsing

    这一步就是将我们传入的String类型输入,解析成具体的参数对象,方便后面使用和传递。关于这点,和其他一些命令行工具其实是差不多的,比如python-fireCLAide。具体解析细节我们后面再介绍,如果像看大概的解析过程,使用-### 就可以。

    1
    2
    3
    4
    5
    6
    7
    $ clang -### -Xarch_i386 -fomit-frame-pointer -Wa,-fast -Ifoo -I foo t.c

    Option 0 - Name: "-Xarch_", Values: {"i386", "-fomit-frame-pointer"}
    Option 1 - Name: "-Wa,", Values: {"-fast"}
    Option 2 - Name: "-I", Values: {"foo"}
    Option 3 - Name: "-I", Values: {"foo"}
    Option 4 - Name: "<input>", Values: {"t.c"}
  3. Pipeline: Compilation Action Construction

    一旦解析了参数,就会构造出后续编译所需要的子任务。这涉及到确定输入文件及其类型,要对它们做哪些工作(预处理、编译、组装、链接等),以及为每个任务构造一个Action实例列表。其结果是一个由一个或多个顶层Action组成的列表,每个Action通常对应一个单一的输出(例如,一个对象或链接的可执行文件)。

    使用-ccc-print-phases 可以打印出这个阶段的内容:

    1
    2
    3
    4
    5
    6
    7
    8
    $ clang -ccc-print-phases -x c t.c -x assembler t.s
    0: input, "t.c", c
    1: preprocessor, {0}, cpp-output
    2: compiler, {1}, assembler
    3: assembler, {2}, object
    4: input, "t.s", assembler
    5: assembler, {4}, object
    6: linker, {3, 5}, image

    当这个阶段完成之后,编译过程将会以被分成多个小的Action,每个Action的阶段就是我们熟知的”预处理→编译→汇编→连接”等。

  4. Bind: Tool & Filename Selection

    这个阶段和后面的Trasnlate一起将将Actions转化成真正的进程。Driver自上而下匹配,将Actioins分配给分配给Tools,ToolChain负责为每个Action选择合适的Tool,一旦选择了Tool,Driver就会与Tool交互,看它是否能够匹配更多的Action。

    一旦所有的Action都选择了Tool,Driver就会决定如何连接工具(例如,使用进程内模块、管道、临时文件或用户提供的文件名)。

    Driver驱动程序与ToolChain交互,以执行Tool的绑定。ToolChain包含了特定架构、平台和操作系统编译所需的所有工具的信息,一次编译过程中,单个Driver调用可能会查询多个ToolChain,以便与不同架构的工具进行交互。

    可以通过-ccc-print-bindings 可以查看Bind的大致情况,以下展示了在i386和ppc上编译t0.c文件Bing过程。

    1
    2
    3
    4
    5
    6
    7
    8
    $ clang -ccc-print-bindings -arch i386 -arch ppc t0.c
    # "i386-apple-darwin9" - "clang", inputs: ["t0.c"], output: "/tmp/cc-Sn4RKF.s"
    # "i386-apple-darwin9" - "darwin::Assemble", inputs: ["/tmp/cc-Sn4RKF.s"], output: "/tmp/cc-gvSnbS.o"
    # "i386-apple-darwin9" - "darwin::Link", inputs: ["/tmp/cc-gvSnbS.o"], output: "/tmp/cc-jgHQxi.out"
    # "ppc-apple-darwin9" - "gcc::Compile", inputs: ["t0.c"], output: "/tmp/cc-Q0bTox.s"
    # "ppc-apple-darwin9" - "gcc::Assemble", inputs: ["/tmp/cc-Q0bTox.s"], output: "/tmp/cc-WCdicw.o"
    # "ppc-apple-darwin9" - "gcc::Link", inputs: ["/tmp/cc-WCdicw.o"], output: "/tmp/cc-HHBEBh.out"
    # "i386-apple-darwin9" - "darwin::Lipo", inputs: ["/tmp/cc-jgHQxi.out", "/tmp/cc-HHBEBh.out"], output: "a.out"
  5. Translate: Tool Specific Argument Translation

    一旦选择了一个Tool来执行一个特定的Action,该Tool必须构建具体的Commands,并在编译过程中执行。该阶段主要的工作是将gcc风格的命令行选项翻译成子进程所期望的任何选项。

    这个阶段的结果是一些列将要执行Commands(包含执行路径和参数字符)。

  6. Execute

    执行阶段,之前上一阶段输出的命令,并且产出结果。


以上关于Driver的设计都可以在LLVM的官网能找到,可能很多介绍比较抽象,下面我介绍下更多的代码相关的细节。

Driver的代码分析

入口

clang/tools/driver/driver.cpp 我们可以找到Driver的入口,其中入口逻辑都集中在main之中。

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
int main(int argc_, const char **argv_) {
// 初始化LLVM
// 1. 设置信号handler,当进程崩溃是可以打印堆栈 2. 处理windows上的参数编码,保证参数传递进去的是utf-8
llvm::InitLLVM X(argc_, argv_);
SmallVector<const char *, 256> argv(argv_, argv_ + argc_);

// 保证标准文件描述符(input, output, error)的正确映射
if (llvm::sys::Process::FixupStandardFileDescriptors())
return 1;
// 初始化LLVM支持的所有目标机器,比如arm,x86等
llvm::InitializeAllTargets();
// 根据传入参数,解析出需要的:
// TargetPrefix: i686-linux-android
// ModeSuffix: g++,gcc,cpp等
// DriverMode:--driver-mode=g++ 或者--driver-mode=gcc等
auto TargetAndMode = ToolChain::getTargetAndModeFromProgramName(argv[0]);

// 省略...

Driver TheDriver(Path, llvm::sys::getDefaultTargetTriple(), Diags);
// 设置clang所在目录
SetInstallDir(argv, TheDriver, CanonicalPrefixes);
// 用之前的targe和mode赋值
TheDriver.setTargetAndMode(TargetAndMode);
// 将target和mode信息插入到argv中
insertTargetAndModeArgs(TargetAndMode, argv, SavedStrings);
// 设置默认的CC_PRINT_OPTIONS/CC_PRINT_HEADERS/CC_LOG_DIAGNOSTICS输出文件,会在环境变量中指定
SetBackdoorDriverOutputsFromEnvVars(TheDriver);
// 返回Compilation对象,包含一个driver调用的一系列任务
std::unique_ptr<Compilation> C(TheDriver.BuildCompilation(argv));
int Res = 1;
if (C && !C->containsError()) {
SmallVector<std::pair<int, const Command *>, 4> FailingCommands;
// 根据参数列表执行编译动作并且返回正确的返回码
Res = TheDriver.ExecuteCompilation(*C, FailingCommands);

// 省略...
}
// 省略...
return Res;
}

可以看到,driver.cpp 中大致做了几件事情:

  1. 做些环境的检测和各自对象数据的准备
  2. 解析参数中target,mode信息
  3. 初始化Driver对象,并对其赋值target,mode等等需要的数据
  4. 通过Driver对象生成Compilation对象并且执行编译命令

BuildCompilation&ExecuteCompilation

上面driver.cpp 中很重要的一步是BuildCompilation,这一步做了很多事情,我们看下代码。

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
60
Compilation *Driver::BuildCompilation(ArrayRef<const char *> ArgList) {
//省略..

// Parse参数
bool ContainsError;
CLOptions = llvm::make_unique<InputArgList>(
ParseArgStrings(ArgList.slice(1), IsCLMode(), ContainsError));

// 如果有来自文件的配置,从文件中加载配置参数
// 会解析来自command line指定的.cfg或者在用户+系统指定的Search Path中找
if (!ContainsError)
ContainsError = loadConfigFile();
bool HasConfigFile = !ContainsError && (CfgOptions.get() != nullptr);

// 合并来自commnad line的参数和来自配置文件的配置
InputArgList Args = std::move(HasConfigFile ? std::move(*CfgOptions)
: std::move(*CLOptions));

// 省略合并过程,最终合并得到Args
// 省略各种参数赋值和处理...

std::unique_ptr<llvm::opt::InputArgList> UArgs =
llvm::make_unique<InputArgList>(std::move(Args));

// Translate
DerivedArgList *TranslatedArgs = TranslateInputArgs(*UArgs);

// 构造ToolChain
const ToolChain &TC = getToolChain(
*UArgs, computeTargetTriple(*this, TargetTriple, *UArgs));

// 将trasnlate结束的参数传递给Compilation
Compilation *C = new Compilation(*this, TC, UArgs.release(), TranslatedArgs,
ContainsError);
// 处理一些立即处理和退出的命令,比如-help,-version这种
if (!HandleImmediateArgs(*C))
return C;

// Construct the list of inputs.
InputList Inputs;
BuildInputs(C->getDefaultToolChain(), *TranslatedArgs, Inputs);

// Populate the tool chains for the offloading devices, if any.
CreateOffloadingDeviceToolChains(*C, Inputs);

// 构造Actions
if (TC.getTriple().isOSBinFormatMachO())
BuildUniversalActions(*C, C->getDefaultToolChain(), Inputs);
else
BuildActions(*C, C->getArgs(), Inputs, C->getActions());

// 来自ccc-print-phases
if (CCCPrintPhases) {
PrintActions(*C);
return C;
}
// 构造Jobs
BuildJobs(*C);
return C;
}

通过省略干扰我们理解的其他代码,可以发现,BuildCompilation主要做了以下几件事情:

  1. 解析参数
  2. 翻译参数
  3. 构造Actions和Jobs

基本和官方设计所提到的几个流程能对应上,同时我们也能看到之前提到的ccc-print-phases 是在这里处理Actions的打印。

ExecuteCompilation的则比较简单,就是我们之前设计图中最后的执行步骤。同时也能看到为什么我们输入-### 能够打印出Jobs的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
int Driver::ExecuteCompilation(
Compilation &C,
SmallVectorImpl<std::pair<int, const Command *>> &FailingCommands) {
// 处理-###的参数
if (C.getArgs().hasArg(options::OPT__HASH_HASH_HASH)) {
C.getJobs().Print(llvm::errs(), "\n", true);
return 0;
}
// 省略...
C.ExecuteJobs(C.getJobs(), FailingCommands);
// 省略...处理失败已经打印一些额外的数据
return Res;
}

Parse

我们根据上面BuildCompilation 中的步骤,来看看Parse这一步是怎么实现的。

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
60
61
62
63
InputArgList Driver::ParseArgStrings(ArrayRef<const char *> ArgStrings,
bool IsClCompatMode,
bool &ContainsError) {
// 省略...
InputArgList Args =
getOpts().ParseArgs(ArgStrings, MissingArgIndex, MissingArgCount,
IncludedFlagsBitmask, ExcludedFlagsBitmask);

// 省略各种校验和错误处理等...
return Args;
}

InputArgList OptTable::ParseArgs(ArrayRef<const char *> ArgArr,
unsigned &MissingArgIndex,
unsigned &MissingArgCount,
unsigned FlagsToInclude,
unsigned FlagsToExclude) const {
InputArgList Args(ArgArr.begin(), ArgArr.end());

MissingArgIndex = MissingArgCount = 0;
unsigned Index = 0, End = ArgArr.size();
while (Index < End) {
// 省略nullptr和空字符串等处理...

unsigned Prev = Index;
Arg *A = ParseOneArg(Args, Index, FlagsToInclude, FlagsToExclude);
assert(Index > Prev && "Parser failed to consume argument.");

// 省略错误校验...

Args.append(A);
}
return Args;
}

Arg *OptTable::ParseOneArg(const ArgList &Args, unsigned &Index,
unsigned FlagsToInclude,
unsigned FlagsToExclude) const {
unsigned Prev = Index;
const char *Str = Args.getArgString(Index);

// 省略...

const Info *Start = OptionInfos.data() + FirstSearchableIndex;
const Info *End = OptionInfos.data() + OptionInfos.size();
StringRef Name = StringRef(Str).ltrim(PrefixChars);

Start = std::lower_bound(Start, End, Name.data());
for (; Start != End; ++Start) {
unsigned ArgSize = 0;
// 省略...
Option Opt(Start, this);
// 省略...
if (Arg *A = Opt.accept(Args, Index, ArgSize))
return A;
// 省略...
}

// 保底
if (Str[0] == '/')
return new Arg(getOption(TheInputOptionID), Str, Index++, Str);
return new Arg(getOption(TheUnknownOptionID), Str, Index++, Str);
}

上面有些代码只是简单封装,主要流程就是从参数String中逐个解析生成Arg 对象,最后组成InputArgListOpt.accept 逻辑我们在这里就不说了,其实就是根据不同类型的参数解析然后返回,我们重点说下Option ,因为Opt.accept 处理的核心需要依赖Option 对象。

1
Option(const OptTable::Info *Info, const OptTable *Owner);

Option的生成需要OptTable::InfoOptTable两个参数,通过跟踪代码,我们可以在Driver的构造函数中找到OptTable的生成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
std::unique_ptr<OptTable> clang::driver::createDriverOptTable() {
auto Result = llvm::make_unique<DriverOptTable>();
return std::move(Result);
}

class DriverOptTable : public OptTable {
public:
DriverOptTable()
: OptTable(InfoTable) {}
};
}

static const OptTable::Info InfoTable[] = {
#define OPTION(PREFIX, NAME, ID, KIND, GROUP, ALIAS, ALIASARGS, FLAGS, PARAM, \
HELPTEXT, METAVAR, VALUES) \
{PREFIX, NAME, HELPTEXT, METAVAR, OPT_##ID, Option::KIND##Class, \
PARAM, FLAGS, OPT_##GROUP, OPT_##ALIAS, ALIASARGS, VALUES},
#include "clang/Driver/Options.inc"
#undef OPTION
};

可以看到,OptTable的生成是依靠预先定义好的InfoTable[] 来生成的。这里以宏的形式定了一个Option应该包含哪些属性,同时引入了Options.inc

1
2
3
4
5
6
7
8
#ifdef OPTION
OPTION(prefix_1, "###", _HASH_HASH_HASH, Flag, INVALID, INVALID, nullptr, DriverOption | CoreOption, 0,
"Print (but do not run) the commands to run for this compilation", nullptr, nullptr)
OPTION(prefix_1, "ast-dump", ast_dump, Flag, Action_Group, INVALID, nullptr, CC1Option | NoDriverOption, 0,
"Build ASTs and then debug dump them", nullptr, nullptr)
OPTION(prefix_1, "ccc-print-phases", ccc_print_phases, Flag, internal_debug_Group, INVALID, nullptr, DriverOption | HelpHidden | CoreOption, 0,
"Dump list of actions to perform", nullptr, nullptr)
#endif // OPTION

我这里截取了几个定义,都是我们比较熟悉和常见的,我们支持的每个命令都能在这里找到定义。

Actions

因为BuildUniversalActions最终会调用BuildActions,所以我们来看看BuildActions的处理逻辑。

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
void Driver::BuildActions(Compilation &C, DerivedArgList &Args,
const InputList &Inputs, ActionList &Actions) const {
// 省略...

// 找到Final Phase
Arg *FinalPhaseArg;
phases::ID FinalPhase = getFinalPhase(Args, &FinalPhaseArg);

// 省略根据FinalPhase等信息构造Actions...
}

phases::ID Driver::getFinalPhase(const DerivedArgList &DAL,
Arg **FinalPhaseArg) const {
Arg *PhaseArg = nullptr;
phases::ID FinalPhase;

// -{E,EP,P,M,MM} only run the preprocessor.
if (CCCIsCPP() || (PhaseArg = DAL.getLastArg(options::OPT_E)) ||
(PhaseArg = DAL.getLastArg(options::OPT__SLASH_EP)) ||
(PhaseArg = DAL.getLastArg(options::OPT_M, options::OPT_MM)) ||
(PhaseArg = DAL.getLastArg(options::OPT__SLASH_P))) {
FinalPhase = phases::Preprocess;

// --precompile only runs up to precompilation.
} else if ((PhaseArg = DAL.getLastArg(options::OPT__precompile))) {
FinalPhase = phases::Precompile;

// -{fsyntax-only,-analyze,emit-ast} only run up to the compiler.
} else if ((PhaseArg = DAL.getLastArg(options::OPT_fsyntax_only)) ||
(PhaseArg = DAL.getLastArg(options::OPT_module_file_info)) ||
(PhaseArg = DAL.getLastArg(options::OPT_verify_pch)) ||
(PhaseArg = DAL.getLastArg(options::OPT_rewrite_objc)) ||
(PhaseArg = DAL.getLastArg(options::OPT_rewrite_legacy_objc)) ||
(PhaseArg = DAL.getLastArg(options::OPT__migrate)) ||
(PhaseArg = DAL.getLastArg(options::OPT__analyze,
options::OPT__analyze_auto)) ||
(PhaseArg = DAL.getLastArg(options::OPT_emit_ast))) {
FinalPhase = phases::Compile;

// -S only runs up to the backend.
} else if ((PhaseArg = DAL.getLastArg(options::OPT_S))) {
FinalPhase = phases::Backend;

// -c compilation only runs up to the assembler.
} else if ((PhaseArg = DAL.getLastArg(options::OPT_c))) {
FinalPhase = phases::Assemble;

// Otherwise do everything.
} else
FinalPhase = phases::Link;

if (FinalPhaseArg)
*FinalPhaseArg = PhaseArg;

return FinalPhase;
}

可以看到,不同的参数决定了我们这一次过程的最后阶段不一样,比如如果我们参数列表里带了-fsyntax-only ,那我们最后久之后走到编译这一步,后面的汇编,链接等阶段是不会涉及的。

Bind

在执行编译的过程中,Bind会为不同的Action选定不同的Tool。

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
InputInfo Driver::BuildJobsForActionNoCache(
Compilation &C, const Action *A, const ToolChain *TC, StringRef BoundArch,
bool AtTopLevel, bool MultipleArchs, const char *LinkingOutput,
std::map<std::pair<const Action *, std::string>, InputInfo> &CachedResults,
Action::OffloadKind TargetDeviceOffloadKind) const {
//...
ToolSelector TS(JA, *TC, C, isSaveTempsEnabled(),
embedBitcodeInObject() && !isUsingLTO());
const Tool *T = TS.getTool(Inputs, CollapsedOffloadActions);
//...
}

const Tool *getTool(ActionList &Inputs,
ActionList &CollapsedOffloadAction) {
// 省略

const Tool *T = combineAssembleBackendCompile(ActionChain, Inputs,
CollapsedOffloadAction);
if (!T)
T = combineAssembleBackend(ActionChain, Inputs, CollapsedOffloadAction);
if (!T)
T = combineBackendCompile(ActionChain, Inputs, CollapsedOffloadAction);
if (!T) {
Inputs = BaseAction->getInputs();
T = TC.SelectTool(*BaseAction);
}

combineWithPreprocessor(T, Inputs, CollapsedOffloadAction);
return T;
}

可以看到,Bind会尝试去绑定不同的Tool,BackCompile/Backend/Compile/Preprocessor等。

Jobs

有了前面的参数,Actions,ToolChain等,Job的构建就水到渠成,具体代码可以在下面找到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void Driver::BuildJobs(Compilation &C) const {
//...
std::map<std::pair<const Action *, std::string>, InputInfo> CachedResults;
for (Action *A : C.getActions()) {
const char *LinkingOutput = nullptr;
if (isa<LipoJobAction>(A)) {
if (FinalOutput)
LinkingOutput = FinalOutput->getValue();
else
LinkingOutput = getDefaultImageName();
}

BuildJobsForAction(C, A, &C.getDefaultToolChain(),
/*BoundArch*/ StringRef(),
/*AtTopLevel*/ true,
/*MultipleArchs*/ ArchNames.size() > 1,
/*LinkingOutput*/ LinkingOutput, CachedResults,
/*TargetDeviceOffloadKind*/ Action::OFK_None);
}
//...
}

Execute

执行的逻辑其实就是在Jobs准备之后,根据相关信息执行相关的命令。

1
2
3
4
5
6
7
8
9
10
11
12
int Driver::ExecuteCompilation(
Compilation &C,
SmallVectorImpl<std::pair<int, const Command *>> &FailingCommands) {
// 省略...
for (auto &Job : C.getJobs())
setUpResponseFiles(C, Job);

C.ExecuteJobs(C.getJobs(), FailingCommands);
// 省略

return Res;
}

我们也可以通过-ftime-report 来查看执行的过程和时间。

小结

虽然Driver这一层还没有涉及到真正的编译,但是作为整个编译过程的驱动,了解Driver对我们后续了解编译的全过程还是有帮助的,特别是Driver的设计,以pipline形式将各个环节的输入输出抽象,解决了参数解析和翻译,任务封装和执行等过程,以满足我们各种各样的使用需求。