gcov报告中析构函数中的分支是什么意思?

41

当我使用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:}

注意这一行:"branch 0 taken 0% (fallthrough)"。

是什么原因导致了这个分支,我需要在代码中做什么才能使这里达到100%?

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

我如何才能得到100%的答案,这个问题仍未得到解答。 - Eddy Pronk
请看我更新的答案,详细解释了这里发生的事情。 - AnT stands with Russia
这是在低级代码上插入分支(根据语言语义插入)的结果,而不是直接在源代码上插桩。GCov之所以这样做是因为对于GCov来说很方便,而不是对你有帮助;了解编译器生成的分支的测试覆盖率没有任何价值,因为它们支持一个经过充分测试的编译器。如果您使用插桩源代码的测试覆盖工具,则不会获得此类虚假覆盖数据。(请查看我的个人简介以获取一种选择)。 - Ira Baxter
3个回答

62
在典型的实现中,析构函数通常有两个分支:一个用于非动态对象销毁,另一个用于动态对象销毁。通过调用方向析构函数传递的隐藏布尔参数来选择特定的分支。它通常作为0或1通过寄存器传递。
我猜测,在您的情况下,销毁是针对非动态对象的,因此不会执行动态分支。尝试添加一个使用new创建并delete删除的Foo类对象,第二个分支也应该被执行。
这种分支的必要性源于C ++语言规范。当某个类定义自己的operator delete时,选择调用特定的operator delete就好像从类析构函数内部查找一样。其最终结果是,对于具有虚拟析构函数的类,operator delete的行为就像是一个虚函数(尽管形式上是类的静态成员)。
许多编译器实现了这种行为,字面意义上从析构函数实现中直接调用正确的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;
} 

当使用默认优化设置且使用GCC 4.3.4编译时,A类的析构函数代码将如下所示:

__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 的析构函数,除了将 movl $0, %eax 指令替换为 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生成的代码有很多无意义的无法访问的分支,就像这个例子一样。

2
@Eddy: 不要忘记,如果newdelete出现在同一作用域中,那么编译器可能足够聪明以推断对象的真实动态类型并取消虚析构函数的调用。 - Matthieu M.
@Eddy Pronk:好的,这可能意味着GCC使用“两个非分支析构函数”的方法,而不是“分支析构函数”的方法。在这种情况下,我不知道那个分支在那里做什么。它可能只是未来插入某些东西的占位符吗?或者只是为了改善管线/对齐/分支预测而添加的东西? - AnT stands with Russia
@Adam Mitz:嗯……是的。正如我所说,实现此行为的流行方法是从析构函数中调用 operator delete。这样,operator delete 的“虚拟性”就简单地建立在析构函数的虚拟性之上。但是,你需要知道什么时候调用 operator delete,什么时候不要调用 operator delete。显然,您不应该为自动对象调用它。这通常通过隐藏的析构函数参数和分支实现,如上所述。 - AnT stands with Russia
1
@AndreyT 令人印象深刻的回答和讨论。谢谢! - Eddy Pronk
很有趣的是你多次提到了优化。要运行良好的覆盖测试,你需要做的一件事就是使用 -O0。我知道编译器和汇编器仍然会进行优化,但不会像其他情况下那样多。 - Alexis Wilke
显示剩余10条评论

7
在析构函数中,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>
[...]

有什么想法为什么它没有被优化掉吗?似乎无条件跳转会更好。 - Matthieu M.
在这种特殊情况下,我没有给GCC任何-O选项,但即使使用-O,仍然存在一些“有趣”的控制流模式(例如调用在调用函数中间的地址)。我想你也可以说,如果没有-O,它不应该生成这样的代码--但也许他们有他们的理由? - Adam Mitz
我不知道汇编语言,因为我从未深入研究过它,对我来说它仍然是一个神话般的怪兽。 - Matthieu M.
1
刚才关于在调用函数的中间调用地址的那段话请忽略:我看错了反汇编输出(在链接和解决重定位之前)。 - Adam Mitz
2
天啊!这里在做什么 je 62 <_ZN3FooD0Ev+0x62>?给函数基地址加上偏移量? :-/ - Nawaz
是的,相对跳转目标是通过它们相对于函数开头的偏移量来表示的。 - Adam Mitz

0

对于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 ..."生成覆盖率,如这里所述。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接