调试C++:用户界面的噩梦
如果你是一名调试器的维护者或开发者,请不要生气,我并不是在针对你或你的产品。我花了很多时间在调试上,我知道这些工具可以有效地解决问题。事实上,我花了如此多的时间在调试上,以至于我决定自己写一个调试器。就像每个值得做的项目一样,它比预期的要难得多。我不想啰嗦,我将限制自己详细阐述三个痛点:
- 有名字的实体
- 没有名字的实体
- 没有实体的名字
有名字的实体
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)
还有十个类似的函数。
我的调试器如何以人类可读的方式呈现这些内容?常见的方法有:
- 这是你需要的,所以这就是你得到的。接受它(gdb, lldb)。
- 这超出了80个字符的限制,所以我会为你缩短它(GNU binutils readelf)。
boost::beast::de[...]
- 我会为你压缩所有模板参数(kcachegrind)
boost::beast::detail::asio_handler_invoke<...>
对于一个通用的调试器(即处理多种语言)来说,没有专门为C++设计的复杂逻辑,这是你能期望的最好结果:
- 显示长名字
- 不加修饰地缩短长名字
- 稍微费力地缩短名字
在我看来,选项3看起来是较小的恶,除了:
- 它需要解析C++代码中的反混淆函数签名。C++解析是困难且容易出错的,而且_这段代码已经被编译器成功解析过了_。
- 这是
invoke
。实际的内容在模板参数中,告诉我们_应该调用什么_。让我们显示第一个模板参数:boost::asio::detail::binder2
。好吧,不是这个。其他的呢?compose_op
,compose_work
,bind_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
。
我计划实现以下功能来使这更易于管理:
- 我不会尝试重新解析编译器生成的C++类型。相反,我将依赖调试信息来重建这些名字:合成C++比分析它要容易得多。
- 用户可控制的模板参数压缩,带有合理的默认值,如果这是可能的话。
- 用户可控制的命名空间前缀省略,带有合理的默认值。这显然更容易,因为我们处于
boost
命名空间中,可以从模板参数和参数中删除它,混淆的风险很低。它在源代码中有效,在调试器中应该也有效。 - 尽可能使用_常见名字_。
我不喜欢添加用户可控制的选项,这会使调试器更复杂、有状态且更难正确使用,但我看不到任何方法可以神奇地开箱即用。
常见名字?
考虑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::vector
和std::string
,但涉及几个步骤。
- 将
std::basic_string<char,std::char_traits<char>,std::allocator<char>>
简化为std::basic_string<char>
。类型的DWARF调试信息可以记录模板参数,并且可以记录模板参数已被默认。如果我们只有符号表,我们无能为力,但有了完整的调试信息,就可以识别默认的模板参数并将其从类型签名中删除。 - 将
std::basic_string<char>
简化为std::string
。调试信息还可以记录typedef
,调试器可以识别以下事实:std::string
是std::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
是一个无效的标识符,但使用$
创建不会与任何其他内容冲突的标识符是很常见的。如果你使用nm
或readelf
进行任何类型