[博客翻译]调试C++是一场UI噩梦


原文地址:https://core-explorer.github.io/blog/c++/debugging/2025/01/19/debugging-c++-is-a-ui.nightmare.html


调试C++:用户界面的噩梦

如果你是一名调试器的维护者或开发者,请不要生气,我并不是在针对你或你的产品。我花了很多时间在调试上,我知道这些工具可以有效地解决问题。事实上,我花了如此多的时间在调试上,以至于我决定自己写一个调试器。就像每个值得做的项目一样,它比预期的要难得多。我不想啰嗦,我将限制自己详细阐述三个痛点:

  1. 有名字的实体
  2. 没有名字的实体
  3. 没有实体的名字

有名字的实体

C++为类型、函数和变量生成了可怕的名字(与个别开发者的命名能力无关)。以下是一个出现在我的Web服务器大多数堆栈跟踪中的函数:

boost::beast::detail::asio_handler_invoke<boost::asio::detail::binder2<boost::beast::basic_stream<boost::asio::ip::tcp, 
boost::asio::any_io_executor, boost::beast::unlimited_rate_policy>::ops::transfer_op<true, boost::asio::mutable_buffer, 
boost::asio::detail::composed_op<boost::beast::http::detail::read_some_op<boost::beast::basic_stream<boost::asio::ip::tcp, 
boost::asio::any_io_executor, boost::beast::unlimited_rate_policy>, boost::beast::basic_flat_buffer<std::allocator<char> >, true>, 
boost::asio::detail::composed_work<void(boost::asio::any_io_executor)>, 
boost::asio::detail::composed_op<boost::beast::http::detail::read_op<boost::beast::basic_stream<boost::asio::ip::tcp, 
boost::asio::any_io_executor, boost::beast::unlimited_rate_policy>, boost::beast::basic_flat_buffer<std::allocator<char> >, true, 
boost::beast::http::detail::parser_is_done>, boost::asio::detail::composed_work<void(boost::asio::any_io_executor)>, 
boost::beast::detail::bind_front_wrapper<void (http_session::*)(boost::system::error_code, long unsigned int), 
std::shared_ptr<http_session> >, void(boost::system::error_code, long unsigned int)>, void(boost::system::error_code, 
long unsigned int)> >, boost::system::error_code, long unsigned int>&>(binder2<boost::beast::basic_stream<boost::asio::ip::tcp, 
boost::asio::any_io_executor, boost::beast::unlimited_rate_policy>::ops::transfer_op<true, boost::asio::mutable_buffer, 
boost::asio::detail::composed_op<boost::beast::http::detail::read_some_op<boost::beast::basic_stream<boost::asio::ip::tcp, 
boost::asio::any_io_executor, boost::beast::unlimited_rate_policy>, boost::beast::basic_flat_buffer<std::allocator<char> >, true>, 
boost::asio::detail::composed_work<void(boost::asio::any_io_executor)>, 
boost::asio::detail::composed_op<boost::beast::http::detail::read_op<boost::beast::basic_stream<boost::asio::ip::tcp, 
boost::asio::any_io_executor, boost::beast::unlimited_rate_policy>, boost::beast::basic_flat_buffer<std::allocator<char> >, true, 
boost::beast::http::detail::parser_is_done>, boost::asio::detail::composed_work<void(boost::asio::any_io_executor)>, 
boost::beast::detail::bind_front_wrapper<void (http_session::*)(boost::system::error_code, long unsigned int), 
std::shared_ptr<http_session> >, void(boost::system::error_code, long unsigned int)>, void(boost::system::error_code, 
long unsigned int)> >, boost::system::error_code, long unsigned int>& f, bind_front_wrapper<void (http_session::*)
(boost::system::error_code, long unsigned int), std::shared_ptr<http_session> >* op)

还有十个类似的函数。
我的调试器如何以人类可读的方式呈现这些内容?常见的方法有:

  1. 这是你需要的,所以这就是你得到的。接受它(gdb, lldb)。
  2. 这超出了80个字符的限制,所以我会为你缩短它(GNU binutils readelf)。boost::beast::de[...]
  3. 我会为你压缩所有模板参数(kcachegrind)boost::beast::detail::asio_handler_invoke<...>

对于一个通用的调试器(即处理多种语言)来说,没有专门为C++设计的复杂逻辑,这是你能期望的最好结果:

  1. 显示长名字
  2. 不加修饰地缩短长名字
  3. 稍微费力地缩短名字

在我看来,选项3看起来是较小的恶,除了:

  • 它需要解析C++代码中的反混淆函数签名。C++解析是困难且容易出错的,而且_这段代码已经被编译器成功解析过了_。
  • 这是invoke。实际的内容在模板参数中,告诉我们_应该调用什么_。让我们显示第一个模板参数:boost::asio::detail::binder2。好吧,不是这个。其他的呢?compose_opcompose_workbind_front_wrapper。一个通用工具无法从这个模板签名中挑出有用的信息。

在你提出“兄弟,如果你不喜欢复杂性,不要用boost::beast,用更简单的东西”之前:
我正在编写一个调试器,它需要处理现有的代码。它不能回应“哈哈,你的代码太烂了。谢谢,再见。”
顺便说一句,这纯粹是一个调试问题。这些长模板签名不会出现在源代码中,源代码看起来像这样:

http::async_read(
		  stream_,
		  buffer_,
		  *parser_,
		  beast::bind_front_handler(
		    &http_session::on_read,
		    shared_from_this()));

显然,源代码也使用了using namespace boost::beast
我计划实现以下功能来使这更易于管理:

  1. 我不会尝试重新解析编译器生成的C++类型。相反,我将依赖调试信息来重建这些名字:合成C++比分析它要容易得多。
  2. 用户可控制的模板参数压缩,带有合理的默认值,如果这是可能的话。
  3. 用户可控制的命名空间前缀省略,带有合理的默认值。这显然更容易,因为我们处于boost命名空间中,可以从模板参数和参数中删除它,混淆的风险很低。它在源代码中有效,在调试器中应该也有效。
  4. 尽可能使用_常见名字_。

我不喜欢添加用户可控制的选项,这会使调试器更复杂、有状态且更难正确使用,但我看不到任何方法可以神奇地开箱即用。

常见名字?

考虑std::vector<std::string>。这是C++中非常常见的类型。它也是一种幻觉。当调试器读取你的二进制文件时,它会像这样出现:std::vector<std::basic_string<char,std::char_traits<char>, std::allocator<char>, std::allocator<std::basic_string<char,std::char_traits<char>, std::allocator<char>>>。可以将其转换回std::vectorstd::string,但涉及几个步骤。

  1. std::basic_string<char,std::char_traits<char>,std::allocator<char>>简化为std::basic_string<char>。类型的DWARF调试信息可以记录模板参数,并且可以记录模板参数已被默认。如果我们只有符号表,我们无能为力,但有了完整的调试信息,就可以识别默认的模板参数并将其从类型签名中删除。
  2. std::basic_string<char>简化为std::string。调试信息还可以记录typedef,调试器可以识别以下事实:
    • std::stringstd::basic_string<char>在命名空间std中的唯一typedef。
    • std::string在用户源代码中比std::basic_string<char>更常用。对于处理template<typename Char> std::basic_string<Char>的通用代码来说,这并不成立,但调试器可以识别这一点。我们可以使用这一点来推断std::string可能是std::basic_string<char>的首选名称,而无需显式硬编码。如果你实际检查生成的调试信息,你会发现它充满了不是首选名称的typedef,比如fast_uint32_t表示unsigned。这将是一个启发式方法。
    • 调试信息的生产者需要忠实地记录我们使用的typedef,并且不能将它们解析为目标,包括模板参数。

如果你自己检查一下([Compiler Explorer](https://core-explorer.github.io/blog/c++/debugging/2025/01/19/https:/godbolt.org/z/n1zxPGd1v)),你会发现我为你简化了事情:因为libstdc++为了符合C++11标准而破坏了与之前版本的ABI,你的字符串模板实际上被称为std::__cx11::basic_string,但__cx11是一个内联命名空间,所以它对源代码是透明的,但调试器必须手动删除它以提供用户识别的名称。如果你使用clang和libc++,你会发现所有标准类型都在内联命名空间__1中,例如std::__1::vector

模板化一切

所以总结一下:它不会很漂亮,但它会工作,当涉及到模板函数和类时。当然,在C++中,我们模板化一切,不仅仅是函数和类。

模板别名的调试信息一团糟

我读过DWARF5标准,我用clang编译了一个简单的例子([Compiler Explorer](https://core-explorer.github.io/blog/c++/debugging/2025/01/19/https:/godbolt.org/z/MGb5eGnYv)),并检查了生成的调试信息。我期望找到一个类型为DW_TAG_template_alias的_调试信息条目_。clang和gcc都不会发出带有该标签的条目,它们生成一个类型为DW_TAG_typedef的条目,就像简单typedef的调试信息一样。据我所知,这是一件好事,引入DW_TAG_template_alias是一个错误。编译器行为反映了如何处理模板函数和类,与非模板条目相比,它们没有特殊类型,而是用子条目(类型如DW_TAG_template_type_parameter)来描述它们的类型和非类型模板参数。不幸的是,clang不会为模板别名生成这些子条目。这意味着我提到的所有关于常见名字的内容目前对模板别名都不起作用。

还能更糟吗?

然后我用gcc编译了相同的代码。像clang一样,它不会在调试信息中记录模板参数作为子条目。与clang不同的是,它甚至不会在类型名称中记录模板参数。编译器为不同的类型生成多个typedef,所有这些类型都有相同的名称。gdb和lldb都不期望这一点,如果你要求它们通过指定名称显示类型,它们会显示错误的类型。我计划使用调试信息来检测ODR违规,但gcc在调试信息中产生了ODR违规,而这些违规在源代码中并不存在。

模板变量

clang不会在变量名称中记录模板参数,而是将它们作为属性添加到调试信息中。对于模板变量,gcc既不在名称中也不在属性中记录模板参数,但如果你的变量具有外部链接(constexpr变量不会有),你可以从混淆的名称中恢复类型,只要它是一个命名类型……
我相信如果编译器在这种情况下生成更好的调试信息,所有调试器都可以提供更好的调试体验。

没有名字的实体

早在我们开始使用C++之前,C语言就已经充满了匿名类型。

struct {
  int c;
  int d;
} a;
struct X {
enum {
  yes = 0
};
};
struct Y {
enum {
  yes = 1
};
};

当然,还有真正的怪物,比如

struct Z {
  union {
    long a;
    char b;
    struct {
      int c;
      int d;
      union {
        float f;
      };
    };
  };
};

C++不同。由于名称混淆,每个未命名的类型如果作为模板参数、函数参数或全局变量(包括函数内部的静态变量)的类型,都需要一个名称。另一方面,记录这些类型没有名称至关重要,ODR规则对待命名类型和匿名类型不同:不同编译单元中的命名类型必须具有相同的定义,否则我们处于_格式错误,无需诊断_的境地。这是一个可怕的境地,所以如果有任何方法可以诊断它并找到出路,那似乎值得做。
这是一个模板函数tfunc接受一个参数并用匿名结构实例化的情况([Compiler Explorer](https://core-explorer.github.io/blog/c++/debugging/2025/01/19/https:/godbolt.org/z/r34WGGTrs)):

  • 结构的调试信息条目没有记录名称,调试信息中的类型,不幸的是,从不记录混淆的名称。
  • 函数的调试信息条目记录了一个名称tfunc<<unnamed struct> >和一个混淆的名称_Z5tfuncI8._anon_0EvT_
  • 你可以尝试解析函数名称以获取模板参数的名称,但这里选择尖括号非常不幸,会混淆简单的解析器。
  • 我们可以反混淆混淆的名称,得到void tfunc<._anon_0>(._anon_0),给我们._anon_0作为匿名结构的内部名称。我发现函数名称与我们通过反混淆得到的名称不同,这很不幸。
  • 单独来看,._anon_0当然是一个无效的标识符,需要特殊处理来识别它。

这是gcc的情况,clang给出了不同的画面:

  • 函数的调试信息条目记录了一个名称tfunc<(unnamed struct at /app/example.cpp:1:1)>和一个混淆的名称_Z5tfuncI3$_0EvT_
  • 你可以尝试解析函数名称以获取模板参数的名称,但尖括号和括号的选择会破坏我们刚刚修复的解析器,使其适用于gcc的命名方案。
  • 我们可以反混淆混淆的名称,得到void tfunc<$_0>($_0),给我们$_0作为匿名结构的内部名称。我发现函数名称与我们通过反混淆得到的名称不同,这很不幸。
  • 单独来看,$_0是一个无效的标识符,但使用$创建不会与任何其他内容冲突的标识符是很常见的。如果你使用nmreadelf进行任何类型的shell脚本编写并且不期望它,可能会引起意外。
  • 结构的调试信息条目没有记录名称,调试信息中的类型,不幸的是,从不记录混淆的名称。

这有点令人失望,但只要我只需要支持两个编译器,我认为我可以应付。除了lambda表达式,显然。
lambda表达式的类型是一个未命名的结构,所以这里没有任何变化,对吧?对吧?([Compiler Explorer](https://core-explorer.github.io/blog/c++/debugging/2025/01/19/https:/godbolt.org/z/99ToETYKd))
这是gcc的情况:

  • 结构的调试信息条目没有记录名称,调试信息中的类型,不幸的是,从不记录混淆的名称。这不是复制粘贴错误,这就是不幸之处。
  • 函数的调试信息条目记录了一个名称tfunc<<lambda(int)> >和一个混淆的名称_Z5tfuncIN1aMUliE_EEvT_。如果你不能在脑海中反混淆,这意味着N1aMUliE_E是lambda的混淆类型名称,反混淆为a::{lambda(int)#1}
  • 名称中的尖括号,再次。但与匿名结构不同。
  • 我们可以反混淆混淆的名称,得到void tfunc<a::{lambda(int)#1}>(a::{lambda(int)#1})。出于某种原因,匿名结构从零开始编号,但lambda表达式从一开始。
  • 这次,我们匿名类型的混淆名称像任何其他混淆名称一样开始,并遵循为lambda表达式制定的一些特殊规则。

这是clang的情况:

  • 结构的调试信息条目没有记录名称,调试信息中的类型,不幸的是,从不记录混淆的名称。
  • 函数的调试信息条目记录了一个名称tfunc<(lambda at /app/example.cpp:1:10)>和一个混淆的名称_Z5tfuncI3$_0EvT_。是的,这与匿名结构的混淆名称完全相同。
  • 注意,这次匿名类型的名称中_没有_尖括号,如果我们编写了一个解析器,我们将不得不再次修复它。

lambda表达式无处不在,包括在其他lambda表达式中。没有办法始终为同一事物赋予相同的名称。顺便说一句,对于我作为C++开发者来说,[](int x){return x;}的名称是[](int x){return x;},如果主体不适合一行,可能会缩短为[](int x) -> int。因此,我脑海中这个东西的类型是decltype([](int x){return x;})
如果你仔细研究生成的调试信息,你会发现:

  • 没有办法判断它是lambda表达式的类型还是匿名结构。
  • 虽然这无关紧要,但gcc将它们作为struct,而clang将它们作为class

好吧,这是介绍,现在让我们谈谈lambda表达式的_问题_([Compiler Explorerer](https://core-explorer.github.io/blog/c++/debugging/2025/01/19/https:/godbolt.org/z/WE38q53xz))。

  • lambda捕获
  • 调用操作符

lambda表达式可以通过值捕获具有用户定义析构函数的类型。这意味着我们的匿名类型现在有一个析构函数。事实上,它有一个命名的析构函数,gcc称之为~<lambda>(),而clang称之为~(lambda at /app/example.cpp:15:14)。对于gcc,tfunc签名变为tfunc<main(int, char**)::<lambda()> >。这次,外部作用域(即main)是名称的一部分,而不仅仅是混淆名称的一部分。我认为如果gcc将析构函数命名为~<lambda()>会更一致。clang与它的函数签名tfunc<(lambda at /app/example.cpp:15:14)>一致,这很酷。无论如何,我们现在可以通过查看构造函数或析构函数的名称来判断我们的类型是lambda表达式的类型。对于没有捕获的lambda表达式不起作用,但我会接受我能得到的。
当然,如果我们在调试,我们希望检查lambda表达式的捕获,它们应该是成员变量。它们是!根据编译器的不同,它们有你期望的名称或该名称前缀为两个下划线。
继续调用操作符:它被称为operator(),正如你所期望的。类或结构的所有非静态方法都有一个隐藏参数作为它们的第一个参数,在调试信息中标记为_artificial_。对于普通类,它被称为this。lambda表达式的调用操作符也有这个参数,但在gcc中被称为__closure,而在使用clang时根本没有名称。调试器在调试时可能需要显示这个参数,所以我们需要为它编一个名称,不妨用__closure。如果你想知道,为什么它们不使用this,那么很可能是因为在非静态方法中定义的lambda表达式捕获this的特殊情况。
目前,gdb和lldb在显示回溯时都可能无法正确显示lambda和封闭作用域:调用操作符显示为operator()而不是main::{lambda()#1}::operator()() const(lambda at /app/example.cpp:15:14)::operator()()
如果你愿意,你可以尝试我的WIP核心转储分析器如何显示[gcc的二进制和调试信息](https://core-explorer.github.io/blog/c++/debugging/2025/01/19/https:/core-explorer.github.io/core-explorer/?download=https://core-explorer.github.io/core-explorer/example/ui_nightmare.gcc)和[clang的二进制和调试信息](https://core-explorer.github.io/blog/c++/debugging/2025/01/19/https:/core-explorer.github.io/core-explorer/?download=https://core-explorer.github.io/core-explorer/example/ui_nightmare.clang

没有实体的名字

我不是在谈论前向声明(尽管我确信,这些迟早会让我头疼),我是在谈论二进制中没有有意义的源代码的函数。
例如

void func() {
  std::vector<int> victor;
  std::set<int> seth;
  std::string sven;
}

最后的}做了很多工作,包括调用三个析构函数。当调试内存损坏时,基于行的源代码调试器没有好的方法让你方便地单步执行析构序列。你必须在汇编上工作,你需要知道目标机器的汇编语言,并且你不能让你的肌肉记忆妨碍并意外地单步执行一行而不是一条指令。
这也是Compiler Explorer擅长的部分,它旨在向你展示C++构造的隐藏成本(或它们的缺失)。UI假设你是汇编专家(开始你的旅程永远不会太晚)。
这是令人讨厌的部分,函数序言、尾声和类似的东西。
如果我们有一个类而不是一个函数呢?

struct Funk {
  std::vector<int> victor;
  std::set<int> seth;
  std::string sven;
};

这个类有一个编译器生成的析构函数,它会出现在你的符号表、调试信息中,它甚至有一个名称Funk::~Funk()。它拥有一切,除了源代码。我们确切地知道它做了什么,它需要以相反的顺序调用析构函数:

{
  sven.~string();
  seth.~set();
  victor.~vector();
}

析构函数还有其他事情要做,特别是对于虚类,但我们可以想象编译器不仅生成代码,还生成析构函数的源代码。它不必有效,它只需要向我们展示我们在析构序列中的位置,以便我们可以使用现有的调试器单步执行它。它们需要行和行号,它们不需要源文件。
这是1998年的技术水平,但从那时起,我们有了编译器生成方法的爆炸式增长:

  • 默认构造函数
  • 默认赋值操作符
  • 默认比较
  • 折叠表达式

所有这些对于_原始开发者_来说都是可怕的调试对象。一个没有源代码或调试信息的朝鲜安全研究员反编译一个已发布的二进制文件会容易得多。他的调试器会在二进制文件上运行反编译器,允许他单步执行所有这些方法。反编译的代码不必正确或可编译。
我知道从源代码到最终二进制文件的旅程:

  1. 我们从源代码开始。让我们忽略预处理器。
  2. 编译器将源代码转换为抽象语法树(AST)。
  3. 可能有优化过程在AST上运行。
  4. 抽象语法树被转换为中间表示,如LLVM IR或GIMPLE。
  5. 有一系列优化过程修改中间表示。
  6. 中间表示被转换为机器代码(技术上来说是汇编)。
  7. 可能有更多的优化过程,这次是在生成的汇编上。
  8. 最后,汇编器将汇编转换为二进制代码(它还发出展开信息和符号表)。

反汇编器成功地从第8步到第7步,这总是有效的,除了混淆的恶意软件。我们的调试信息然后从第7步到第1步,跳过中间的一切。
如果这不起作用,你就只能处理反汇编。你可以尝试将你的机器代码提升回IR并反编译IR,就像安全研究员所做的那样。但这对我来说似乎真的很浪费。
我们有AST,编译器可以转储AST。我们有初始IR,我们有最终IR,编译器可以转储IR。
我们甚至有像Cpp Insights这样的工具,可以将中间编译器阶段转换回C++。
这是我在处理难以调试的问题时希望从未来的调试器中得到的:

  • 它应该能够向我展示源代码
  • 它应该能够向我展示IR
  • 它应该能够向我展示反汇编
  • 我希望能够根据函数和问题在这些抽象层次上单步执行程序。

正如我所说,我正在编写自己的调试器,所以我会以某种方式实现这一点。最终。
但你不必等我。继续实现这一点:使用clang进行可重现的构建,将其编译为带有调试信息的可执行文件。将其编译为LLVM IR。将IR编译为带有调试信息的可执行文件。检查可执行文件是否匹配,并告诉我它在实践中如何工作。
我提到了gcc、clang、gdb和lldb中的几个错误或不足之处。我知道正确的事情是搜索相应的开放问题,如果没有,创建一个新的问题。我还没有做,我会做,我只是需要先发泄一下,然后才能写出简洁的错误报告。谢谢。

Core Explorer

Core Explorer - 动态内存的静态分析器。分析核心转储以查找ODR违规、内存泄漏和使用后释放的危险。列出所有对象、类型、分配和线程,并可视化它们之间的关系。

阅读全文(20积分)