【开源之夏2023】Compound Literals特性LLVM兼容性增强
问题描述
Compound Literals是一个C99特性,GCC和LLVM都对其做了拓展,支持在C++中使用该特性,但在具体的实现上有一定的差异,这也导致了LLVM与GCC存在一定的兼容性问题。例如下述代码,GCC可以编译通过,LLVM则会编译报错。项目主页
1 | typedef struct FormatInfo_ { int * a = nullptr; bool isa = false; } FormatInfo; |
请分析GCC及LLVM对Compound Literals的C++拓展的实现,通过修改LLVM的实现,解决上述问题。并基于上述场景,分析LLVM与GCC在该特性的C++拓展实现上的差异,分析改特性是否存在其他场景也存在兼容性问题,给出对应的分析文档。若有其他存在兼容性问题的场景,修改实现解决。
一点碎碎念:之前在那一科技实习时就曾听同事强烈推荐过开源之夏活动,在向师兄们打听了本组的实习条件后也感觉可以作为消失的研一暑期实习的平替。客观来说,这次选的项目本身比较基础,我之前也有过类似需求的开发经验,因而过程基本上比较顺利,对我个人能力的提升也比预想地要少一些,主要收获在于被迫阅读了部分GCC的源码。如果明年还有机会参加的话,大概会希望选择更有挑战性一些的项目。但无论如何,参加这样的活动总归是会有收获的。所以,也非常欢迎各位之后有兴趣报名开源之夏的同学来找我交流。另外,在选题方面,OpenEular社区给出的项目在今年有不少遇到了爆冷,因此在竞争难度上相对会小很多。但这些项目也都经过精心设计,并且有来自企业或者社区的导师进行指导,如果不知道要选什么的化推荐优先考虑。毕竟你不参与开源项目的话也很难去跟那些报名之前就已经在代码库里传了巨多提交的奆佬们竞争,不是吗。
最小例程
可以验证,将上述例程化简至如下最小例程后,LLVM(Clang)与GCC的表现均与化简前相同:
1 | struct DT { int * el;}; |
初步观察
用LLVM对上述最小例程进行编译,得到的报错提示如下:
1 | test.cc:3:14: error: initializer element is not a compile-time constant |
报错提示编译器拒绝将变量pa
接受为一个初始化器的元素,因为pa
并非一个编译期常量。尝试修改DT va = (DT){pa};
至DT va = {pa};
,则能够通过编译。另一个观察是,将int * pa = 0;
修改为constexpr int * pa = 0;
或者int * const pa = 0;
,则也均能通过编译。
GCC的处理方式
首先尝试界定出GCC的能力范围。将上述最小例程进行复杂化。对于如下例程,LLVM与GCC的表现均与复杂化前相同:
1 | struct DT { int * el;}; |
显然,此时变量pa
已经很难被称作是一个compile-time constant
。此时,若在其类型声明前增加关键字constexpr
,则两个编译器都拒绝接受该例程。而奇怪的现象却发生了:若在变量名前增加const
关键字,则LLVM依旧会拒绝这段例程,但GCC却能让其通过编译。在此,先将这一现象简单总结为:在GCC中const
关键字对全局变量的限制要比在LLVM中更少一些。
处理过程定位
那么GCC具体是怎么处理这里的待初始化元素的?接下来尝试从其实现代码层面(基于本文写作时GCC官方代码仓库的releases/gcc-13
分支上的最新提交c7d995dfb155
)上进行分析。从直觉上来看,该特性的实现应该位于编译器前端。在GCC中,编译前端涉及到的阶段有:预处理、分词、语法分析、语义分析等。在GCC的C语言前端处理过程中,源码会被依次转换为GENERIC(AST)、GIMPLE。
那么首先试着定位出该特性生效的位置。在编译最小例程时对GCC使用参数-fdump-tree-all-raw
:
1 | export SRC=test.cc |
随后编译器会将许多中间过程写入文件。先从未经优化的GIMPLE看起。
1 | // test.cc.006t.gimple |
可以发现,此时的程序结构已经与源码文件大相径庭。函数外部的变量声明代码都消失了,取而代之的是两个函数,其前缀分别为__static_initialization_and_destruction
(本文之后缩写为SIAD
函数)与_GLOBAL__sub_I
(本文之后缩写为GSI
函数)。真正初始化的过程被移动到了前一个函数内,而后一个函数又会调用前者。
首先在GCC源代码中检索前一个函数前缀,发现其被以宏SSDF_IDENTIFIER
的形式定义于gcc/cp/decl2.cc
。对于后一个函数前缀,直接在GCC源码中进行检索时并未发现任何定义,但查阅资料得知其实际为提供给链接器使用的特殊前缀,用于在main
函数执行之前被执行,以初始化本翻译单元内所有静态存储变量(Static Storage Variable)。
回到处理过程的定位问题上。既然在GIMPLE表示中,代码源文件中使用的声明形式就已消失(被转换至函数内),那么说明处理过程一定不会发生在这之后的编译优化环节中。考虑向前追溯,观察其在GENERIC阶段的形式。
在GCC中,GENERIC阶段的表示实际上就是抽象语法树(Abstract Syntax Tree),对应的是GCC源码中的tree_node
结构体。继续使用先前提到的调试输出参数时,会以类似N元组列表的形式输出该表示,也可以通过源码gcc/print-tree.cc
中的debug_tree
函数来向标准错误流打印出树形结构的内容。这里选取前者的结果作为示例。
1 | # test.cc.005t.original |
观察可知,此时声明形式也已经被转化至函数内,因此需要去GENERIC的生成阶段寻找处理过程。从前往后看的话,显然预处理、词法分析和语法分析阶段都不可能进行这样的处理,那么转换过程只可能会发生在语义分析环节中。通过对源码各个阶段追加输出日志,可以观察到SIAD
函数与GSI
函数的创建过程发生于语法分析的末尾阶段。具体来说,分别位于代码文件gcc/cp/decl2.cc
中定义的partition_vars_for_init_fini
、emit_partial_init_fini_fn
与generate_ctor_or_dtor_function
这三个函数中。
处理过程分析
首先,partition_vars_for_init_fini
函数会将静态存储变量按不同优先级组成多个列表。
1 | void |
随后,emit_partial_init_fini_fn
函数会为各个静态存储变量生成对应的SIAD
函数的声明与定义。
1 | // 针对每个变量生成对应的初始化与销毁函数(即SIAD函数) |
最后,generate_ctor_or_dtor_function
函数则会生成GSI
函数的定义,也即依次调用先前生成的SIAD
函数。
1 | static void |
那么,在回顾完整个过程后,便可以回到前面提到的那个奇怪现象,也即为什么“在GCC中const
关键字对全局变量的限制要比在LLVM中更少一些”。容易发现,如果修改前面给出的复杂化例程,将变量va的声明移至任意函数作用域内,则使用LLVM编译时也会顺利通过编译。这表明了LLVM针对静态存储变量的初始化也许采取了与GCC差异较大的方式,也即并没有像GCC那样通过创建SIAD
函数的形式来为其进行初始化。
语义分析阶段的处理
上面对语法分析阶段前对GCC的行为进行了跟踪,但GCC在语法分析阶段也会进行一些相关的检查(针对C语言代码)。比如在包含了C语言前端的一些特定错误检查功能的源文件中,存在如下片段:
1 | /* IN gcc/c-family/c-common.cc */ |
在以上代码段中可以发现,如果当前情况下flag_isoc99
的值为真(也即启用ISO C99
)时,会给出一个pedantic warning
,来提示开发者此时初始化器的元素应该是常量。但如果没有启用ISO C99
这一方言时,则不进行任何处理。可以看到,GCC对于相关检查的态度是相对开放的,也即只有需要检查的时候才会进行简直。
LLVM的处理方式
对于Clang来说,由于其会直接拒绝该例程,所以可以根据报错提示对其进行定位。首先通过检索错误提示,发现对于该类型错误进行检查的要求直接来源于C99标准(ISO/IEC 9899:1999)的6.5.2.5 Compound literals
这一节。原文的描述如下:
Constraints
……
If the compound literal occurs outside the body of a function, the initializer list shall consist of constant expressions.
可以看到,这是一个C99中的约束,其所针对的是函数体之外声明的复合字面量的初始化列表。但在GCC的处理方式下,在AST中相关表达式就已经被放入了一个新增的函数的函数体之中,因而不再符合这一条件。那么,接下来从代码角度对Clang的处理过程进行定位。
处理过程定位
与分析GCC时类似,首先依然从编译前端的各个阶段进行分析。相对于GCC,Clang对于的AST的结构化输出更加方便也更加直观。虽然原本的例程无法通过编译,因而无法获得完整的AST。但笔者猜测该特性只是一个针对规范的合规性检查,并不会实际影响编译的过程,因此可以通过绕过该检查的方式使其输出完整的AST。通过搜索报错相关关键字,发现在clang/lib/AST/Expr.cpp
这一源文件的Expr::isConstantInitializer
函数中,存在与该问题直接相关的检查。因此,首先尝试使其永远返回真值,从而跳过该检查步骤。果不其然,此时编译器便能够构建出最小例程所对应的AST。其部分内容如下(省略部分无关细节):
1 | TranslationUnitDecl |
可以看到的是,在Clang的AST中,初始化语句的位置与GCC是完全不同的,其并没有像后者处理的那样被放入了一个函数中,而是被保留为了翻译单元(即TranslationUnitDecl
)的直接子结点。因此,对于Clang的处理方式的分析的重点也与GCC不同,需要侧重于构建出AST之后的语义分析阶段。
处理过程分析
前面提到,isConstantInitializer
中存在着与该错误直接相关的检查,因此首先尝试修改该函数。通过观察AST结构并对代码进行调试,发现例程中代码被拒绝的直接原因是在调用该函数时,传入的DeclRefExpr
并未被其中任何分支所捕获,因而直接被认定未非编译期确定的常量。因此非常直观的修复方式是,尝试增加对应的判断分支,为满足条件的变量在检查其是否是常量时进行特殊处理。简单来说,就是在函数Expr::isConstantInitializer
进行判断时,为符合条件的DeclRefExpr
进行特判处理,如果其满足 1. 其对应的VarDecl
是一个全局变量,或者可用于常量表达式(由hasConstantInitialization
函数确定),且 2. 其对应的VarDecl
的初始化语句其使用了常量初始化(由isConstantInitializer
函数确定),那么认为该DeclRefExpr
本身也满足常量初始化。按照这一思路,便有了下述实现:
1 | diff --git a/clang/lib/AST/Expr.cpp b/clang/lib/AST/Expr.cpp |
使用该实现时,确实可以覆盖题目中提供的例子,并给出正确的结果,且不会违反任何已有测试用例。但笔者随后手动构造了一些更加复杂的例子,比如DeclRefExpr
位于UnaryOperator
/BinaryOperator
之中时(即使用更复杂的表达式时),却发现依然会无法通过编译,原因是isConstantInitializer
函数是一个递归函数,对于DeclRefExpr
的特判可能会被其语法树上的父结点的处理过程给拦截掉,因此如果希望能够处理比如DeclRefExpr
的父结点是函数的情况时,需要对isConstantInitializer
函数进行较大的修改,会涉及到该函数对许多语法树结点的处理方式。
然而上文提到,对于GCC来说,在AST阶段该表达式就已经被放入了函数之中;并且后来通过阅读代码与调试,观察到GCC在处理C++代码时,其实并不仅仅是允许在全局变量的初始化语句中使用静态存储变量(即该方式中的DeclRefExpr
),而是完全没有进行相关的检查(即可以使用包含非编译期常量的表达式,也可以使用包含非常量的表达式)。在与项目导师深入交流后,出于提高与GCC的兼容性,同时简化实现方式,因而考虑将相关逻辑进行外提,试着在函数Expr::isConstantInitializer
的调用者中寻找更适合的修改位置。
通过观察调用栈,发现clang/lib/Sema/SemaExpr.cpp
中的Sema::BuildCompoundLiteralExpr
函数包含了对该项规范进行检查的直接入口,其相关代码如下:
1 | ExprResult |
因此合理的实现方案也就呼之欲出了:将相应的特判逻辑从Expr::isConstantInitializer
外提到Sema::CheckForConstantInitializer
再进一步外提到Sema::BuildCompoundLiteralExpr
函数中。该方案即为本问题最后的选择的实现,其也能够应对更多比原例程更加复杂的情况(体现在了测试用例中)。
完整实现
前文分析对比了GCC与LLVM对于相关特性的处理方式,本章则会以前文为基础,给出针对该问题的完整实现。
期望行为
相关变更的预期行为是,提供一个默认开启的新语言选项,在该选项被开启后,Clang会在该特性上兼容GCC的表现。该选项需要可以被显式关闭,此时保持原本的行为。该选项不应改变C语言的编译过程。因此,从单元测试的角度来看,完整测试该变更需要至少考虑2
(C/C++)*3
(默认也即非显式启用或关闭选项/开启选项/关闭选项)=6
个用例。
语言选项
语言选项是编译器能够接受的一系列命令行参数,用于控制编译过程中各个阶段的行为。在GCC中,不同类型的语言选项有着不同的前缀,比如f是术语旗标(Flag)的缩写,通常用于描述与机器无关的代码生成约定。Clang在这类标志上对GCC进行了兼容。许多旗标选项同时有着打开和关闭两种形式,以便显式指定其状态。比如对于某个旗标选项foo
,其开启形式是-ffoo
,而对应的关闭形式则是-fno-foo
。
本工作需要增加一个默认开启的选项,当该选项开启时,前文提到的兼容增强功能需要生效,反之,当该选项被关闭时,该兼容增强功能禁止生效。首先,需要用宏定义的形式在代码中显式声明该语言选项的标识符、类型、默认值与作用描述。具体来说,可以在clang/include/clang/Basic/LangOptions.def
中加入如下代码:
1 | LANGOPT(NonConstantGlobalInitializers, 1, 1, "allow use of non-constant static storage variables in global initializers, to enhance compatibility with gcc.") |
注意,该代码文件的后缀名为.def
,但其本质上是个C++头文件。增加了该宏定义后,就可以通过getLangOpts().NonConstantGlobalInitializers
来在代码中获得该选项在当前情况下的值。不过,光这里定义还不够,还需要让Clang能够从命令行中解析出对应选项。在这部分实现中,Clang采用了TableGen来描述编译选项的解析方式。因此,我们需要在clang/include/clang/Driver/Options.td
中加入上面提到的这个选项。仿照其它类似选项,在命令行中指定时,使用allow-non-const-global-init
作为参数。具体新增代码如下:
1 | defm allow_non_const_global_init : BoolFOption<"allow-non-const-global-init", |
其中,BoolFOption
表示该选项是一个布尔旗标选项,LangOpts
中的内容对应了代码文件中该选项的标识符,使得命令行解析模块能够将获得的值对应到该选项中。DefaultTrue
表示该选项默认开启。PosFlag
、NegFlag
和BothFlags
这三个内容共同描述了这个选项在Clang命令行界面中被打开或者关闭时显示的描述信息。
功能实现
前面提到,通当处理C++代码中的Compound Literals时,GCC不会像Clang那样严格按照C99标准,要求其表达式都是编译期常量。GCC跳过了这个检查,选择将正确性交给使用者来保证。需要注意的是,在这样做时,实际上是允许了全局初始化器使用非常量静态存储变量。这个特性对于单个 TU 的情况通常是无害的,但如果初始化的过程中,使用了其他源文件中的变量,那么由于链接阶段链接器行为的不确定性,可能会导致静态初始化顺序失败(Static
Initialization Order Fiasco)的发生。接下来,按前文所述对clang/lib/Sema/SemaExpr.cpp
中的Sema::BuildCompoundLiteralExpr
函数进行修改,判断当在处理C++代码且上文提到的语言选项被开启时,跳过对相关表达式是否是编译期常量的检查。具体改动如下:
1 | @@ -7290,9 +7290,15 @@ Sema::BuildCompoundLiteralExpr(SourceLocation LParenLoc, TypeSourceInfo *TInfo, |
修正回归测试
由于Clang原本的表现是一个期望行为,因而在回归测试集中理应存在对应的测试用例。事实也确实如此,在进行上述修改并执行回归测试后会发现,产生了一例失败的测试结果。根据日志信息可定位至clang/test/SemaCXX/compound-literal.cpp
中的如下位置:
1 | // RUN: %clang_cc1 -fsyntax-only -std=c++03 -verify -ast-dump %s > %t-03 |
经测试,该例程(节选)在GCC中能通过编译,在修改后的Clang下也能进行编译(尽管按注释似乎会生成并不正确的代码,或者说未定义行为)。期望的报错并不会出现,使得该测试失效了。因此,可以显式关闭在这两个测试过程中对应的语言选项,即将其前两行修改成如下:
1 | // RUN: %clang_cc1 -fsyntax-only -fno-allow-non-const-global-init -std=c++03 -verify -ast-dump %s > %t-03 |
此时便可以通过Clang的完整的回归测试集。
增加新测试用例
目前,还未提供对编译结果正确性进行检查的测试,仅针对新增的语言选项的开关的有效性进行了测试。由于修改后被跳过的是语义分析阶段的一项检查,该检查并不会影响语义分析的过程,因此笔者在直觉上认为其正确性是不受影响的。笔者也手工测试了几组样本下修改后的Clang与GCC编译得到的程序的执行结果,暂时没有发现任何差异。后续会继续与导师、Reviewer沟通,可能考虑加入几组更加严格的测试以对语义上的正确性进行测试。在目前的测试中,使用的例程如下:
1 | int* f(int *); |
在Clang中,可以使用-verify
模式来对C/C++前端的功能进行测试。因此,只要针对上述例程提供对应的RUN line和描述了期望结果的注释,即可将其加入至回归测试集中。另外,在代码评审过程中,Reviewer提到可以通过使用FileCheck工具,将针对C/C++的不同情况在同一个测试文件中进行描述,使得测试结果更加清晰,方便对比查看区别。目前,使用上述方案提供了一共六个测试。其具体内容如下:
- 用
-verify
实现报错检查
1 | // RUN: %clang_cc1 -x c -fsyntax-only -fno-allow-non-const-global-init %s -verify |
- 用
FileCheck
与-x
实现单文件分别进行C/C++测试
1 | // The first and second runs check that nothing has been changed for C |