gcov 报告的析构函数中的分支是啥?

Posted

技术标签:

【中文标题】gcov 报告的析构函数中的分支是啥?【英文标题】:What is the branch in the destructor reported by gcov?gcov 报告的析构函数中的分支是什么? 【发布时间】:2011-11-04 04:32:24 【问题描述】:

当我使用 gcov 测量 C++ 代码的测试覆盖率时,它会报告析构函数中的分支。

struct Foo

    virtual ~Foo()
    
    
;

int main (int argc, char* argv[])

    Foo f;

当我在启用分支概率 (-b) 的情况下运行 gcov 时,我得到以下输出。

$ gcov /home/epronk/src/lcov-1.9/example/example.gcda -o /home/epronk/src/lcov-1.9/example -b
File 'example.cpp'
Lines executed:100.00% of 6
Branches executed:100.00% of 2
Taken at least once:50.00% of 2
Calls executed:40.00% of 5
example.cpp:creating 'example.cpp.gcov'

困扰我的部分是“至少服用一次:2 的 50.00%”。

生成的 .gcov 文件提供了更多详细信息。

$ cat example.cpp.gcov | c++filt
        -:    0:Source:example.cpp
        -:    0:Graph:/home/epronk/src/lcov-1.9/example/example.gcno
        -:    0:Data:/home/epronk/src/lcov-1.9/example/example.gcda
        -:    0:Runs:1
        -:    0:Programs:1
        -:    1:struct Foo
function Foo::Foo() called 1 returned 100% blocks executed 100%
        1:    2:
function Foo::~Foo() called 1 returned 100% blocks executed 75%
function Foo::~Foo() called 0 returned 0% blocks executed 0%
        1:    3:    virtual ~Foo()
        1:    4:    
        1:    5:    
branch  0 taken 0% (fallthrough)
branch  1 taken 100%
call    2 never executed
call    3 never executed
call    4 never executed
        -:    6:;
        -:    7:
function main called 1 returned 100% blocks executed 100%
        1:    8:int main (int argc, char* argv[])
        -:    9:
        1:   10:    Foo f;
call    0 returned 100%
call    1 returned 100%
        -:   11:

注意“分支 0 采用 0% (fallthrough)”这一行。

是什么导致了这个分支,我需要在代码中做什么才能在此处获得 100%?

g++ (Ubuntu/Linaro 4.5.2-8ubuntu4) 4.5.2 gcov (Ubuntu/Linaro 4.5.2-8ubuntu4) 4.5.2

【问题讨论】:

我如何在这里获得 100% 仍然没有答案。 查看我的更新答案,详细了解这里发生的情况。 这是检测低级代码(根据语言语义插入分支)而不是直接检测源代码的结果。 GCov这样做是因为它方便GCov,而不是因为它对你有帮助;您了解支持可能经过良好测试的编译器的编译器生成的分支的测试覆盖率没有任何价值。如果你得到一个检测源的测试覆盖率工具,你就不会得到这种虚假的覆盖率数据。 (查看我的简历中的一个选项)。 【参考方案1】:

在析构函数中,GCC 为一个永远不会为真的条件生成条件跳转(%al 不为零,因为它只是被分配了一个 1):

[...]
  29:   b8 01 00 00 00          mov    $0x1,%eax
  2e:   84 c0                   test   %al,%al
  30:   74 30                   je     62 <_ZN3FooD0Ev+0x62>
[...]

【讨论】:

知道为什么它没有被优化掉吗?似乎无条件跳转会更好。 在那种特殊情况下,我没有给 GCC 任何 -O 选项,但即使使用 -O 也有一些“有趣”的控制流模式(比如调用位于调用中间的地址功能)。我想您也可以提出一个案例,即没有 -O 它不应该生成这样的代码——但也许他们有他们的理由? 我不知道,汇编程序对我来说仍然是神话般的野兽,因为我从未真正挖掘过:) 最后一点关于在调用函数中间调用地址:我正在查看错误的反汇编输出(在链接和解析重定位之前)。 天哪! je 62 &lt;_ZN3FooD0Ev+0x62&gt; 到底在做什么?向函数基地址添加偏移量? :-/【参考方案2】:

在典型的实现中,析构函数通常有两个分支:一个用于非动态对象销毁,另一个用于动态对象销毁。特定分支的选择是通过调用者传递给析构函数的隐藏布尔参数来执行的。它通常作为 0 或 1 通过寄存器传递。

我猜想,因为在您的情况下,销毁是针对非动态对象的,所以不会采用动态分支。尝试添加 new-ed 和 delete-ed 类 Foo 的对象,第二个分支也应该被占用。

之所以需要这种分支,是源于 C++ 语言的规范。当某个类定义了自己的operator delete 时,选择要调用的特定operator delete 就像从类析构函数内部查找一样。这样做的最终结果是,对于具有虚拟析构函数 operator delete 的类,它的行为就好像它是一个 virtual 函数(尽管形式上是该类的一个 static 成员)。

许多编译器实现了这种行为字面意思:直接从析构函数实现内部调用正确的operator delete。当然,operator delete 应该只在销毁 动态 分配的对象(不适用于本地或静态对象)时调用。为此,将对operator delete 的调用置于由上述隐藏参数控制的分支中。

在您的示例中,事情看起来很简单。我希望优化器删除所有不必要的分支。但是,它似乎以某种方式在优化中幸存下来。


这里有一点额外的研究。考虑这段代码

#include <stdio.h>

struct A 
  void operator delete(void *)  scanf("11"); 
  virtual ~A()  printf("22"); 
;

struct B : A 
  void operator delete(void *)  scanf("33"); 
  virtual ~B()  printf("44"); 
;

int main() 
  A *a = new B;
  delete a;
 

这是A 的析构函数代码在默认优化设置下使用 GCC 4.3.4 编译时的样子

__ZN1AD2Ev:                      ; destructor A::~A  
LFB8:
        pushl   %ebp
LCFI8:
        movl    %esp, %ebp
LCFI9:
        subl    $8, %esp
LCFI10:
        movl    8(%ebp), %eax
        movl    $__ZTV1A+8, (%eax)
        movl    $LC1, (%esp)     ; LC1 is "22"
        call    _printf
        movl    $0, %eax         ; <------ Note this
        testb   %al, %al         ; <------ 
        je      L10              ; <------ 
        movl    8(%ebp), %eax    ; <------ 
        movl    %eax, (%esp)     ; <------ 
        call    __ZN1AdlEPv      ; <------ calling `A::operator delete`
L10:
        leave
        ret

(B的析构函数有点复杂,这就是为什么我在这里使用A作为例子。但就问题分支而言,B的析构函数也是如此方式)。

然而,在这个析构函数之后,生成的代码包含同一个类A的另一个版本的析构函数,它看起来完全一样,除了@ 987654337@ 指令替换为movl $1, %eax 指令。

__ZN1AD0Ev:                      ; another destructor A::~A       
LFB10:
        pushl   %ebp
LCFI13:
        movl    %esp, %ebp
LCFI14:
        subl    $8, %esp
LCFI15:
        movl    8(%ebp), %eax
        movl    $__ZTV1A+8, (%eax)
        movl    $LC1, (%esp)     ; LC1 is "22"
        call    _printf
        movl    $1, %eax         ; <------ See the difference?
        testb   %al, %al         ; <------
        je      L14              ; <------
        movl    8(%ebp), %eax    ; <------
        movl    %eax, (%esp)     ; <------
        call    __ZN1AdlEPv      ; <------ calling `A::operator delete`
L14:
        leave
        ret

注意我用箭头标记的代码块。这正是我所说的。寄存器al 用作隐藏参数。这个“伪分支”应该根据al 的值调用或跳过对operator delete 的调用。然而,在第一个版本的析构函数中,这个参数像往常一样被硬编码到主体中0,而在第二个版本中,它像往常一样被硬编码到1

B 类也有为其生成的析构函数的两个版本。所以我们最终在编译后的程序中有 4 个不同的析构函数:每个类有两个析构函数。

我可以猜测,一开始编译器在内部考虑的是单个“参数化”析构函数(它的工作方式与我在上面的中断中描述的完全一样)。然后它决定将参数化析构函数拆分为两个独立的非参数化版本:一个用于硬编码参数值0(非动态析构函数),另一个用于硬编码参数值1(动态析构函数)。在非优化模式下,它确实做到了这一点,通过在函数体内分配实际参数值并保持所有分支完全不变。我猜这在非优化代码中是可以接受的。这正是您正在处理的问题。

也就是说,你的问题的答案是:在这种情况下,不可能让编译器取走所有的分支。没有办法达到 100% 的覆盖率。其中一些分支是“死的”。只是在这个版本的 GCC 中生成非优化代码的方法相当“懒惰”和“松散”。

我认为,可能有一种方法可以防止非优化模式下的拆分。我只是还没有找到它。或者,很可能,它无法完成。旧版本的 GCC 使用真正的参数化析构函数。也许在这个版本的 GCC 中,他们决定切换到两个析构函数的方法,并在这样做的同时以一种快速而肮脏的方式“重用”现有的代码生成器,期望优化器清除无用的分支。

当您在启用优化的情况下进行编译时,GCC 将不允许自己在最终代码中进行无用分支等奢侈品。您可能应该尝试分析优化的代码。未经优化的 GCC 生成的代码有很多像这样的无意义的不可访问的分支。

【讨论】:

我尝试了不同的优化级别,但对于这种情况,它没有任何影响。 添加 new-ed 然后 delete-ed 类 Foo 的对象使其同时接触两个 dtor 符号,但不影响分支。 @Eddy:不要忘记如果newdelete 出现在同一范围内,那么编译器可能足够聪明,可以推断出对象的真实动态类型并将对象去虚拟化调用析构函数。 @Eddy Pronk:好的,这可能意味着 GCC 使用“两个非分支析构函数”方法而不是“分支析构函数”方法。在那种情况下,我不知道那个分支在那里做什么。它可能只是将来插入某些东西的某种占位符吗?或者只是为了改进流水线/对齐/分支预测而添加的东西? @AndreyT 令人印象深刻的回答和讨论。谢谢!【参考方案3】:

gcc 5.4.0 版的析构函数问题仍然存在,但 Clang 似乎不存在。

测试:

clang version 3.8.0-2ubuntu4 (tags/RELEASE_380/final)
Target: x86_64-pc-linux-gnu
Thread model: posix
InstalledDir: /usr/bin

然后使用“llvm-cov gcov ...”生成覆盖率,如here 所述。

【讨论】:

以上是关于gcov 报告的析构函数中的分支是啥?的主要内容,如果未能解决你的问题,请参考以下文章

从 C++ 中的析构函数中恢复对象?

在 C# 中,类中的析构函数和 Finalize 方法有啥区别?

cpp中的析构函数会自动调用吗?即使析构函数没有提及非动态变量,它们是不是也会被删除?

C++中的析构函数

虚拟继承中的析构函数

类中的析构函数是否是虚函数