利用constexpr实现更快、更小、更安全的代码

本文探讨了C++14中constexpr的增强功能,如何通过编译时计算提升性能、减少二进制大小,并借助constexpr-everything工具自动标记合规函数,有效防范未定义行为带来的安全风险。

利用constexpr实现更快、更小、更安全的代码

随着C++14的发布,标准委员会强化了C++最酷的现代特性之一:constexpr。现在,C++开发者可以编写常量表达式,并强制在编译时而非每次用户调用时进行评估。这带来了更快的执行速度、更小的可执行文件,并且出人意料地,更安全的代码。

未定义行为一直是许多安全漏洞的根源,例如Linux内核权限提升(CVE-2009-1897)以及无数因未定义行为而被移除的整数溢出检查实现不佳。C++标准委员会在设计constexpr时决定,标记为constexpr的代码不能调用未定义行为。有关全面分析,请阅读Shafik Yaghmour的精彩博客文章《使用Constexpr探索未定义行为》。

我相信constexpr将演变成C++的一个更安全的子集。我们应该全心全意地拥抱它。为了提供帮助,我创建了一个基于libclang的工具,尽可能多地将代码标记为constexpr,称为constexpr-everything。它会自动将constexpr应用于符合要求的函数和变量。

Constexpr面对未定义行为

最近在我们的内部Slack频道中,一位同事试图创建一个可利用的二进制文件,其中漏洞是一个未初始化的栈局部变量,但他正在与编译器斗争。编译器拒绝生成易受攻击的代码。

 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
/* clang -o example example.cpp -O2 -std=gnu++14 \
   -Wall -Wextra -Wshadow -Wconversion
 */
typedef void (*handler)();

void handler1();
void handler2();
void handler3();

handler handler_picker(int choice) {
    handler h;
    switch(choice) {
    case 1:
        h = handler1;
        break;
    case 2:
        h = handler2;
        break;
    case 3:
        h = handler3;
        break;
    }

    return h;
}

使用现代编译器(clang 8.0)编译示例代码时,编译器会静默消除易受攻击的情况。如果调用者指定了switch未处理的选择(如0或4),函数将返回handler2。这在优化级别大于-O0时成立。在Compiler Explorer上亲自尝试!

我的默认警告集(-Wall -Wextra -Wshadow -Wconversion)在clang上完全不会警告这一点(尝试一下)。在gcc上它会打印警告,但仅在启用优化时(-O0 vs -O1)!

注意:如果你想打印clang知道的所有警告,在开发时在clang上使用-Weverything。

周期性公告:-Wall并不包括所有警告。 -Wextra也不包括。 在clang上使用-Weverything,但预计它会变化,所以不要将其与-Werror配对使用。 — Ryan Stortz (@withzombies) 2019年2月21日

这的原因当然是未定义行为。由于未定义行为不能存在,编译器可以自由地对代码做出假设——在这种情况下假设handler h永远不会未初始化。

现在编译器静默接受这段糟糕的代码,并假设我们知道我们在做什么。理想情况下,它会报错。这就是constexpr拯救我们的地方。

 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
/* clang -o example example.cpp -O2 -std=gnu++14 \
   -Wall -Wextra -Wshadow -Wconversion
 */
typedef void (*handler)();

void handler1();
void handler2();
void handler3();

constexpr handler handler_picker(int choice)
{
    handler h;
    switch(choice) {
    case 1:
        h = handler1;
        break;
    case 2:
        h = handler2;
        break;
    case 3:
        h = handler3;
        break;
    }

    return h;
}

https://gcc.godbolt.org/z/gKrZV3

:9:13: error: variables defined in a constexpr function must be initialized handler h;

1 error generated. Compiler returned: 1

constexpr在这里强制了一个错误,这正是我们想要的。它在大多数形式的未定义行为上有效,但编译器实现中仍然存在差距。

constexpr一切!

在深入研究clang源代码后,我意识到我可以使用libclang在语义分析期间用于确定某物是否可以constexpr的相同机制,自动将函数和方法标记为constexpr。虽然这不会直接检测更多未定义行为,但它将帮助我们尽可能多地将代码标记为constexpr。

最初我开始编写一个clang-tidy传递,但在可用API和传递中的可用上下文方面遇到了麻烦。我决定创建自己的独立工具:constexpr-everything。它可在我们的GitHub上获得,并应与最近的libclang版本配合使用。

我写了两个访问者,一个试图识别函数是否可以标记为constexpr。这结果相当简单;我迭代当前翻译单元中的所有clang::FunctionDecls,并使用clang::Sema::CheckConstexprFunctionDecl、clang::Sema::CheckConstexprFunctionBody和clang::Sema::CheckConstexprParameterTypes询问它们是否可以在constexpr上下文中评估。我跳过已经是constexpr或不能是(如析构函数或main)的函数。当分析检测到可以constexpr但尚未标记的函数时,它会发出诊断和FixIt:

$ ../../../build/constexpr-everything ../test02.cpp constexpr-everything/tests/02/test02.cpp:13:9: warning: function can be constexpr X(const int& val) : num(val) {

    constexpr

constexpr-everything/tests/02/test02.cpp:17:9: warning: function can be constexpr X(const X& lVal)

    constexpr

constexpr-everything/tests/02/test02.cpp:29:9: warning: function can be constexpr int getNum() const { return num; }

    constexpr

3 warnings generated.

FixIts可以通过-fix命令行选项自动应用。

应用constexpr变量的麻烦

我们需要将变量标记为constexpr以强制评估constexpr函数。自动将constexpr应用于函数很容易。对变量这样做相当困难。我遇到了以前未标记const的变量通过添加constexpr隐式标记为const的问题。

在尝试尽可能广泛地应用constexpr并与我的测试用例斗争后,我改变了策略,采用了更保守的方法:仅标记已经const限定并具有constexpr初始化器或构造函数的变量。

$ ../../../build/constexpr-everything ../test02.cpp -fix constexpr-everything/tests/02/test02.cpp:47:5: warning: variable can be constexpr const X x3(400);

constexpr constexpr-everything/tests/02/test02.cpp:47:5: note: FIX-IT applied suggested code changes 1 warnings generated.

虽然这种方法不会在所有可能的情况下应用constexpr,但它可以安全地自动应用。

在你的代码库上尝试

在运行constexpr-everything之前和之后基准测试你的测试。不仅你的代码会更快、更小,而且会更安全。标记为constexpr的代码不容易腐烂。

constexpr-everything仍然是一个原型——它还有一些粗糙的边缘。最大的问题是FixIts仅适用于源(.cpp)文件,而不适用于它们的关联头文件。此外,constexpr-everything只能将现有的constexpr兼容函数标记为constexpr。我们正在努力使用提供的机制来识别由于未定义行为而无法标记的函数。

代码可在我们的GitHub上获得。要亲自尝试,你需要cmake、llvm和libclang。试试看,并告诉我们它对你的项目效果如何。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计