关于Clang [[trivial_abi]]

最后,我写了一篇关于[[trivial_abi]]的帖子!

这是Clang干线中的一项新的专有功能,于2018年2月新增。这是C ++语言的供应商扩展,它不是标准的C ++,它不受GCC干线的支持,据我所知,WG21没有积极的提议将其包括在C ++标准中。



我没有参与此功能的实现。 我只是查看了cfe-commits邮件列表上的补丁,并默默地为自己鼓掌。 但这是一个很酷的功能,我想每个人都应该知道。

因此,我们首先要开始:这不是标准属性,而Clang干线不支持属性[[trivial_abi]]的标准拼写。 相反,您应该使用旧样式编写它,如下所示:

__attribute__((trivial_abi)) __attribute__((__trivial_abi__)) [[clang::trivial_abi]] 

并且,由于这是一个属性,因此编译器对粘贴位置非常挑剔,并且如果将其粘贴到错误的位置,则会被动地主动保持沉默(因为无法识别的属性将被忽略而没有消息)。 这不是错误,而是功能。 正确的语法是这样的:

 #define TRIVIAL_ABI __attribute__((trivial_abi)) class TRIVIAL_ABI Widget { // ... }; 


这能解决什么问题?



还记得我在04/17/2018上发布的两个课程版本的帖子吗?

注意事项 佩雷夫(Perev):由于04/17/2018的帖子量很小,因此我没有单独发布它,而是将其插入扰流器下方。
从2018年4月17日发布

缺少琐碎的析构函数调用的缺点


请参阅C ++标准提案邮件列表。 foo或bar这两个函数中的哪个函数将具有由编译器生成的最佳代码?

 struct Integer { int value; ~Integer() {} // deliberately non-trivial }; void foo(std::vector<int>& v) { v.back() *= 0xDEADBEEF; v.pop_back(); } void bar(std::vector<Integer>& v) { v.back().value *= 0xDEADBEEF; v.pop_back(); } 


使用GCC和libstdc ++进行编译。 猜对吧?

 foo: movq 8(%rdi), %rax imull $-559038737, -4(%rax), %edx subq $4, %rax movl %edx, (%rax) movq %rax, 8(%rdi) ret bar: subq $4, 8(%rdi) ret 


这就是这里发生的事情:GCC足够聪明,可以理解,当一个内存区域的析构函数启动时,它的生存期结束,并且该内存区域的所有先前条目都是“死的”。 但是GCC也足够聪明,可以理解琐碎的析构函数(例如伪析构函数〜int())什么也不做,也不产生任何效果。

因此,bar函数调用pop_back,后者运行〜Integer(),这使vec.back()失效,并且GCC完全删除了0xDEADBEEF的乘法。

另一方面,foo调用pop_back,它启动〜int()伪析构函数(它可以完全跳过该调用,但不能完全跳过),GCC看到它为空,并且忘记了它。 因此,GCC不会看到vec.back()已死,也不会删除与0xDEADBEEF相乘的结果。

对于平凡的析构函数会发生这种情况,但对于〜int()这样的伪析构函数则不会发生这种情况。 将〜Integer(){}替换为〜Integer()=默认值; 看看不道德的指示又如何出现了!

 struct Foo { int value; ~Foo() = default; // trivial }; struct Bar { int value; ~Bar() {} // deliberately non-trivial }; 

在那篇文章中,给出了代码,其中编译器为Foo生成的代码比为Bar生成的代码差。 值得讨论为什么这是意外的。 程序员直观地期望“平凡”的代码比“非平凡”的代码更好。 在大多数情况下就是这种情况。 特别是在我们进行函数调用或返回时就是这种情况:

 template<class T> T incr(T obj) { obj.value += 1; return obj; } 

incr 编译为以下代码:

 leal 1(%rdi), %eax retq 

(leal是x86 命令的意思是“ add”。)我们看到4字节的obj被传递到%edi寄存器中的incr,然后将其值加1并将其返回给%eax。 输入四个字节,输出四个字节,简单易行。

现在让我们看一下incr(具有非平凡析构函数的情况)。

 movl (%rsi), %eax addl $1, %eax movl %eax, (%rsi) movl %eax, (%rdi) movq %rdi, %rax retq 

在这里,尽管这里的4个字节具有相同的语义,但obj没有传递到寄存器中。 在这里,obj被传递并返回到该地址。 在这里,调用方为返回值保留了一些空间,并在rdi中向我们传递了指向该空间的指针,而调用方在下一个参数%rsi的寄存器中为我们提供了返回值obj的指针。 我们从(%rsi)中提取值,将其加1,保存回(%rsi)中以更新obj本身的值,然后(平凡地)将obj的4个字节复制到插槽中,以获取%rdi所指向的返回值。 最后,我们将调用者传递的原始指针从%rdi复制到%rax,因为x86-64 ABI文档(第22页)告诉我们要执行此操作。

Bar与Foo如此不同的原因是Bar具有非平凡的析构函数,而x86-64 ABIp。19 )明确指出:

如果C ++对象具有非平凡的复制构造函数或非平凡的析构函数,则会通过不可见的链接传递该对象(该对象将替换为参数列表中的指针)。

稍后的Itanium C ++ ABI文档定义了以下内容:
如果出于调用目的参数类型很重要,则调用者必须分配一个临时位置,并将链接传递到该临时位置:
[...]
在以下情况下,出于调用目的,一种类型被认为是非平凡的:

它具有非平凡的复制构造函数,移动的构造函数,析构函数,或者其所有移动和复制构造函数均被删除。

这就说明了一切:Bar的代码生成较差,因为它是通过不可见的链接传递的。 由于发生了两个独立情况的不幸组合,因此它通过不可见的链接进行传输:
  • ABI文件说具有非平凡析构函数的对象通过不可见链接传递
  • Bar具有非平凡的析构函数。

这是经典的三段论 :第一点是主要前提,第二点是私密性。 结果,Bar通过不可见的链接传输。

让别人给我们一个三段论:
  • 所有人都是凡人
  • 苏格拉底是个男人。
  • 因此,苏格拉底是凡人。


如果我们要驳斥“苏格拉底是凡人”的结论,我们必须驳斥其中一个前提:要么驳斥主要事物(也许有些人不是凡人),要么驳斥私人(也许苏格拉底不是一个人)。

为了使Bar在寄存器(如Foo)中通过,我们必须驳斥两个前提之一。 标准的C ++路径是给Bar一个琐碎的析构函数,从而破坏了私有前提。 但是还有另一种方式!

[[trivial_abi]]如何解决问题


新的Clang属性破坏了主要前提。 Clang扩展了ABI文档,如下所示:
如果出于调用目的参数类型很重要,则调用者必须分配一个临时位置,并将链接传递到该临时位置:
[...]
如果一个类型被标记为[[trivial_abi]]并被视为:
它具有非平凡的复制构造函数,移动的构造函数,析构函数,或者其所有移动和复制构造函数均被删除。

即使具有非平凡的构造函数或析构函数的类出于调用的目的而被视为平凡的,但如果将其标记为[[trivial_abi]],则该类也是如此。

所以现在,使用Clang,我们可以这样写:

 #define TRIVIAL_ABI __attribute__((trivial_abi)) struct TRIVIAL_ABI Baz { int value; ~Baz() {} // deliberately non-trivial }; 

编译incr <Baz>,并获得与incr <Foo>相同的代码!

警告#1:[[trivial_abi]]有时什么都不做


我希望我们可以对标准库类型进行“琐碎的调用”包装,如下所示:

 template<class T, class D> struct TRIVIAL_ABI trivial_unique_ptr : std::unique_ptr<T, D> { using std::unique_ptr<T, D>::unique_ptr; }; 

las,这行不通。 如果您的类具有“对于调用而言不平凡”的任何基类或非静态字段,则现在编写形式的Clang扩展将使您的类“不可逆转不平凡”,并且该属性将无效。 (不会发出诊断消息。这意味着您可以在类模板中使用[[trivial_abi]]作为可选属性,并且该类将是“有条件的琐碎”,这有时很有用。缺点当然是,您可以将该类标记为琐碎的,然后发现编译器已对其进行静默修复。)

如果您的类具有虚拟基类或虚拟函数,则忽略该属性而不会显示消息。 在这些情况下,它可能不适合寄存器,而且我不知道您想通过按值传递值来获得什么,但是您可能知道。

因此,据我所知,将TRIVIAL_ABI用于“标准实用程序类型”(例如可选<T>,unique_ptr <T>和shared_ptr <T>)的唯一方法是
  • 从头实现它们并应用属性,或者
  • 进入libc ++的本地副本,然后用手将属性插入其中

(在开源世界中,这两种方法本质上是相同的)

警告2:破坏者的责任


在Foo / Bar的示例中,该类具有空的析构函数。 让我们的班级实际上有一个非平凡的析构函数。

 struct Up1 { int value; Up1(Up1&& u) : value(u.value) { u.value = 0; } ~Up1() { puts("destroyed"); } }; 

这是您应该熟悉的,这是unique_ptr <int>,简化为限制,删除时会显示消息。

如果没有TRIVIAL_ABI,incr <Up1>就像incr <Bar>:

 movl (%rsi), %eax addl $1, %eax movl %eax, (%rdi) movl $0, (%rsi) movq %rdi, %rax retq 


使用TRIVIAL_ABI,incr看起来更大更可怕

 pushq %rbx leal 1(%rdi), %ebx movl $.L.str, %edi callq puts movl %ebx, %eax popq %rbx retq 


在传统的调用约定中,具有非平凡析构函数的类型始终通过不可见的链接传递,这意味着接收方(在这种情况下为incr)始终接受指向参数对象的指针,而不拥有该对象。 该对象归调用者所有,这使得省略工作变得可行!

当将具有[[trivial_abi]]的类型传递到寄存器中时,我们实际上是在复制参数对象。

由于x86-64只有一个寄存器要返回(掌声),因此被调用函数无法在最后返回对象。 所调用的函数应该拥有我们传递给它的对象的所有权! 这意味着被调用的函数必须在完成时调用参数对象的析构函数。

在我们前面的示例Foo / Bar / Baz中,调用了析构函数,但它为空,并且我们没有注意到它。 现在在incr <Up2>中,我们在调用函数的一侧看到了析构函数生成的其他代码。

可以假定在某些用户情况下可能会生成此附加代码。 但是,相反,析构函数的调用不会出现在任何地方! 因为在调用函数中调用它,所以在incr中调用它。 通常,价格和收益将保持平衡。

警告3:析构函数顺序


具有琐碎ABI的参数的析构函数将由被调用函数而不是被调用函数调用(警告编号2)。 理查德·史密斯(Richard Smith)指出,这意味着它不会按其他参数的析构函数所在的顺序被调用。

 struct TRIVIAL_ABI alpha { alpha() { puts("alpha constructed"); } ~alpha() { puts("alpha destroyed"); } }; struct beta { beta() { puts("beta constructed"); } ~beta() { puts("beta destroyed"); } }; void foo(alpha, beta) {} int main() { foo(alpha{}, beta{}); } 

此代码打印:

 alpha constructed beta constructed alpha destroyed beta destroyed 

将TRIVIAL_ABI定义为[[clang :: trivial_abi]]时,将输出:

 alpha constructed beta constructed beta destroyed alpha destroyed 

与“可重定位” /“移动重定位”对象的关系


没有关系...,对吗?

如您所见,对于[[trivial_abi]]类没有任何要求,可以为移动的构造函数,析构函数或默认构造函数具有任何特定的语义。 任何特定的类都可能是琐碎可重定位的,仅仅是因为大多数类都是琐碎可重定位的。

我们可以简单地设置offset_ptr类,以使其无法轻易重定位:

 template<class T> class TRIVIAL_ABI offset_ptr { intptr_t value_; public: offset_ptr(T *p) : value_((const char*)p - (const char*)this) {} offset_ptr(const offset_ptr& rhs) : value_((const char*)rhs.get() - (const char*)this) {} T *get() const { return (T *)((const char *)this + value_); } offset_ptr& operator=(const offset_ptr& rhs) { value_ = ((const char*)rhs.get() - (const char*)this); return *this; } offset_ptr& operator+=(int diff) { value_ += (diff * sizeof (T)); return *this; } }; int main() { offset_ptr<int> top = &a[4]; top = incr(top); assert(top.get() == &a[5]); } 

这是完整的代码。
定义TRIVIAL_ABI时,Clang干线在-O0和-O1处通过了此测试,但在-O2处通过了此测试(即,当它尝试内联对trivial_offset_ptr ::运算符+ =和复制构造函数的调用时),它在断言时崩溃。

所以再警告一遍。 如果您的类型使用this指针做了如此疯狂的事情,则您可能不想在寄存器中传递它。

错误37319 ,实际上是对文档的请求。 在这种情况下,事实证明,没有办法使代码按程序员希望的方式工作。 我们说value_的值应该取决于this指针的值,但是在调用函数和被调用函数之间的边界上,对象位于寄存器中,并且指向它的指针不存在! 因此,调用函数将其写入内存,然后再次传递此指针,被调用函数应如何计算正确的值才能将其写入value_? 也许最好问一下它在-O0下如何工作? 此代码根本不起作用。

因此,如果要使用[[trivial_abi]],应避免使用成员函数(不仅是特殊的,而且还包括一般情况下的成员函数)在很大程度上依赖于对象自己的地址(“基本”一词具有未定义的含义)。

直观地,当一个类被标记为[[trivial_abi]]时,只要您希望复制,就可以获取copy plus memcpy。 同样,当您希望搬家时,您实际上可以得到搬家加上memcpy。

当一个类型是“可重定位的”(由我在C ++ Now中定义)时,那么只要您希望复制和销毁它,实际上就可以得到memcpy。 同样,当您期望位移和破坏时,您实际上可以得到memcpy。 实际上,如果我们谈论“琐碎的重定位”,则对特殊功能的调用将丢失,但是当类具有Clang的[[trivial_abi]]属性时,调用不会丢失。 除了您期望的呼叫之外,您还可以获得(原样)memcpy。 这种(某种)memcpy是您为更快的电话注册约定支付的价格。

链接以供进一步阅读:


2017年11月以来的Hat中明晃(Akira Hatanaka)的cfe-dev线程
官方Clang文档
单元测试trivial_abi
错误37319:trivial_offset_ptr可能无法工作

Source: https://habr.com/ru/post/zh-CN468759/


All Articles