-
-
[翻译] 如何构建高度可调试的C++二进制文件
-
发表于: 2024-9-10 17:25 1994
-
原文链接:https://dhashe.com/how-to-build-highly-debuggable-c-binaries.html
Published: Fri 19 July 2024 - By David Hashe
This article is tightly scoped to cover one topic with specific, actionable advice: How to configure your C++ toolchain to produce binaries that are highly-debuggable with respect to your current bug. Follow this link to skip the intro text and jump directly to the advice.
本文紧密围绕一个主题展开,提供具体可行的建议:如何配置您的 C++工具链以生成高度可调试的二进制文件,特别是针对当前的 bug。点击此链接跳过介绍文本,直接获取建议。
C++ has a notoriously complicated compilation model, and it has no standard build tooling or package manager. It can be an ordeal to even get a C++ project to compile, and it is even harder to configure one to produce debuggable binaries. I want to help regular C++ programmers improve their debugging experiences.
C++以其臭名昭著的复杂编译模型著称,且缺乏标准的构建工具或包管理器。即便让一个 C++项目成功编译,也堪称一场磨难,而配置项目以生成可调试的二进制文件更是难上加难。我希望能帮助普通 C++程序员提升他们的调试体验。
For an overview of the base C++ compilation model, I highly recommend this article by Fabien Sanglard. It doesn't cover everything, but what it does cover is done exceptionally well.
对于 C++基础编译模型的概述,我强烈推荐Fabien Sanglard 的这篇文章。虽然它并未涵盖所有内容,但其所涉及的部分讲解得非常出色。
I also recommend reading through the manual for your project's build system, if there is one. You will need to understand How your high-level build system maps down to the low-level base compilation model. That abstraction is always leaky. Advanced features of your build system may also be useful when implementing this advice.
我也建议阅读项目构建系统的用户手册,如果有的话。你需要了解高级构建系统如何映射到底层基础编译模型。这种抽象总是存在漏洞。构建系统的高级功能在实施这些建议时也可能有用。
For managing dependencies, I recommend just using whatever your project recommends. That could be the system package manager for a particular Linux distribution, or a specific docker image, or Nix, or Conan. Maybe everything is vendored and built from source. If this is a work project, then there should be some blessed setup somewhere that has everything already installed for you 1. It may be painful at first but trying to go your own way will probably be even more painful. Some of this advice is most useful if you are able to build your dependencies from source.
对于依赖管理,我建议使用项目推荐的方式。这可能是特定 Linux 发行版的系统包管理器,或者是特定的 Docker 镜像,或者是 Nix,或者是 Conan。也许所有内容都是从源代码构建的。如果是工作项目,那么应该有一个已经为你安装好所有内容的受认可的设置1。起初可能会有些痛苦,但试图自行其是可能会更加痛苦。如果你能够从源代码构建依赖项,这些建议中的一些将最为有用。
With regards to debugging, programmers tend to have a strong preference for either interactive debugging (e.g. gdb) or printf-style debugging. I think that it is situational which one is better.
关于调试,程序员往往对交互式调试(如 gdb)或 printf 风格调试有强烈的偏好。我认为哪种更好取决于具体情况。
The advantage of interactive debugging is that it iteratively corrects your understanding of How the program operates as you go along, giving you a solid idea of what the program is actually doing at runtime. The advantage of printf-style debugging is that it is easy to do, even in constrained or unfamiliar environments. The first thing that programmers learn to do in a new language is print to the screen, and it tends to always work 2.
交互式调试的优势在于,它能在你逐步进行的过程中,迭代地修正你对程序运行方式的理解,使你对程序在运行时的实际行为有一个扎实的认识。而 printf 风格调试的优势则在于它易于实施,即便在受限或不熟悉的环境中也能轻松操作。程序员在学习一门新语言时,首先学会的就是打印到屏幕上,而且这种方法往往总是奏效2。
These advantages suggest that interactive debugging is most useful in large unfamiliar legacy projects written in familiar languages (e.g. Chromium), whereas printf-style debugging is most useful in small familiar greenfield projects written in unfamiliar languages (e.g. intro programming class assignments).
这些优势表明,交互式调试在大型不熟悉的遗留项目中最为有用,这些项目使用熟悉的语言编写(例如 Chromium),而 printf 风格的调试在小型的熟悉绿地项目中最为有效,这些项目使用不熟悉的语言编写(例如编程入门课程作业)。
Because the value proposition of C++ these days is mostly maintaining large legacy projects, interactive debugging should be preferred for most C++ programming work.
由于如今 C++的价值主张主要在于维护大型遗留项目,因此对于大多数 C++编程工作,应优先选择交互式调试。
Unfortunately, the default experience of doing interactive debugging on C++ projects is quite bad, and most programmers lack the knowledge to make it better. Printf-style debugging is oddly attractive in C++ simply because you can generally expect it to work, even with optimizations turned on.
遗憾的是,C++项目默认的交互式调试体验相当糟糕,多数程序员缺乏改善它的知识。在 C++中,打印调试方式显得格外诱人,主要是因为即便开启了优化,通常也能正常工作。
Nonetheless, I believe that it is usually possible to generate highly-debuggable C++ binaries that work well with an interactive debugger without sacrificing too much performance. A highly-debuggable binary should do all of the following:
尽管如此,我相信通常可以生成高度可调试的 C++二进制文件,这些文件能够很好地与交互式调试器配合工作,而不会过多牺牲性能。一个高度可调试的二进制文件应做到以下几点:
- any function, variable, or macro that was in scope at a point inside the source code should be available inside the debugger
在源代码中某一点处作用域内的任何函数、变量或宏,在调试器内都应可访问 - the overall performance of the program should be bearable
程序的整体性能应可接受 - backtraces should be complete and accurate
回溯应完整且准确 - standard sanitizers and debug modes should be enabled
应启用standard sanitizers和debug模式
Furthermore, I will share tricks to further enhance debugging at specific sites, and to generally improve How the interactive debugger works with the binary:
此外,我将分享一些技巧,以进一步增强在特定位置的调试效果,并普遍提升交互式调试器与二进制文件的协同工作效率:
- simplification of preprocessor directives and macros
预处理器指令和宏的简化 - native-speed conditional breakpoints
原生速度条件断点 - better stepping behavior 更好的步进行为
- pretty-printers for the stdlib and vocabulary types
标准库和词汇类型的pretty-printers
I have organized this advice into four categories:
我将这些建议分为四个类别:
- General changes to the way that all code in your project is compiled
项目中所有代码编译方式的通用变更 - Semi-specific changes to How certain translation units are compiled
对某些编译单元编译方式的半特化更改 - Specific, targeted source code changes
具体的、有针对性的源代码修改 - Debugger configuration changes
调试器配置更改
And I base my advice on two key principles:
我的建议基于两个关键原则:
- Because C++ is a fully ahead-of-time compiled environment, where everything about the binary is decided at compilation time, and because most C++ projects contain performance-critical code, you have to choose in advance which parts of your binary will be debuggable and which parts of your binary will be fast. You should make this choice considering the particular bug at hand that you are trying to fix. Contrast this situation with interpreted languages, where everything is debuggable by default, or JIT-ed languages, where an optimized thunk of code can be de-optimized at runtime if you suddenly want to step through it.
因为 C++是一个完全的ahead-of-time 编译环境,其中二进制文件的所有内容都在编译时决定,并且由于大多数 C++项目包含性能关键代码,你必须提前选择二进制文件的哪些部分是可调试的,哪些部分是快速的。在做出这一选择时,应考虑你当前试图修复的具体错误。与解释型语言相比,后者默认情况下所有内容都是可调试的,或者与即时编译(JIT)语言相比,如果突然想要逐步执行代码,可以在运行时对优化的代码块进行反优化。 - Even the best generic changes to the way that a C++ project is compiled will not get you to parity with the debugging experience of a scripting language. Targeted, specific source-level changes and custom debugger extensions are necessary to achieve the best possible debugging experience in C++.
即使对 C++项目编译方式进行最佳的通用改进,也无法使您达到与脚本语言调试体验相媲美的水平。为了在 C++中实现最佳的调试体验,必须进行有针对性的、具体的源代码级更改,并定制调试器扩展。
This guide is also specific to using g++ and clang++ on x86_64 GNU/Linux with gdb. Some advice may apply to other platforms.
本指南专为在 x86_64 GNU/Linux 系统上使用 g++ 和 clang++ 并结合 gdb 调试而编写。部分建议可能适用于其他平台。
General Compilation Changes
通用编译更改
Enable the sanitizers
启用sanitizers
Compile 1) your source code and 2) all third-party libraries with a compatible subset of the sanitizers that is relevant to your problem.
编译 1) 您的源代码和 2) 所有第三方库,使用与您问题相关的兼容的 sanitizers 子集。
How
Add one of these to your CFLAGS
and CXXFLAGS
:
在你的CFLAGS
和CXXFLAGS
中添加以下其中一项:
-fsanitize=address,undefined
-fsanitize=thread
- (with clang only)
-fsanitize=memory
With clang on Linux, you might need to reduce the ASLR security level in order to get TSan working. See reference.
在 Linux 上使用 clang 时,可能需要降低 ASLR 安全级别以使 TSan 正常工作。请参阅参考资料。
With MSan, you will need to configure your build system to produce a position-independent executable (PIE).
使用 MSan,您需要配置构建系统以生成位置无关的可执行文件(PIE)。
Why
C++ is not a safe language. If something "wrong" happens, then further execution becomes unpredictable. You really want to be able to catch "wrong" things immediately and the sanitizers are the best tools to do that, even if they aren't always perfect. It's very common in C++ to 1) observe something "weird", 2) read the related source, 3) think "that behavior is impossible", 4) try to debug it for way too long, 5) eventually realize that something "wrong" happened earlier that caused undefined behavior and broke your reasonable mental model of the code.
C++并非一种安全的语言。若发生“错误”情况,后续执行将变得不可预测。你确实希望立即捕捉到这些“错误”,而 sanitizers 正是为此而生的最佳工具,尽管它们并非总是完美无缺。在 C++中,这种情况非常常见:1) 观察到某些“异常”现象,2) 阅读相关源代码,3) 认为“这种行为不可能发生”,4) 花费过多时间尝试调试,5) 最终意识到,之前发生的“错误”导致了未定义行为,破坏了你原本合理的代码心理模型。
The sanitizers are not all compatible with each other, so you will need to test multiple builds with different subsets enabled.
sanitizers并非全部兼容,因此您需要测试多个构建,启用不同的子集。
The sanitizers will not catch everything. There is currently no production-quality C++ toolchain that promises to alert on all undefined behavior. Correctness issues from undefined behavior are currently an unavoidable risk of C++ code, and I don't expect that to change within the next five years.
sanitizers并不能捕捉所有东西。目前还没有一个生产质量的 C++工具链承诺能对所有未定义行为发出警报。未定义行为导致的正确性问题目前是 C++代码中不可避免的风险,我不期望这种情况在未来五年内有所改变。
Ref
- GCC sanitizers
- Clang sanitizers (docs index)
- Raymond Chen: undefined behavior can result in time travel
- Address Sanitizer internals
- Clang + TSan workaround for Linux
Enable "debug mode" or "debug hardening" within your stdlib
在标准库中启用调试模式
Enable "debug mode" for libstdc++ (the g++/linux stdlib implementation), or "debug hardening" for libc++ (the clang++/MacOS stdlib implementation). Note that libc++ used to provide a legacy "debug mode", but it has been removed and you want the new "debug hardening" mode.
为 libstdc++(g++/Linux 标准库实现)启用“debug mode”,或为 libc++(clang++/MacOS 标准库实现)启用“debug hardening”。请注意,libc++ 曾提供一种旧的“debug mode”,但已被移除,您应使用新的“debug hardening”模式。
How
If using libstdc++:
如果使用 libstdc++:
Add this define to your CXXFLAGS
if you are able to recompile your dependencies from source. It will change the ABI: -D_GLIBCXX_DEBUG
如果能够从源码重新编译依赖项,请将此定义添加到您的CXXFLAGS
中。这将改变 ABI:-D_GLIBCXX_DEBUG
Otherwise, add this define to your CXXFLAGS
to keep ABI compatibility: -D_GLIBCXX_ASSERTIONS
否则,将此定义添加到您的CXXFLAGS
以保持 ABI 兼容性:-D_GLIBCXX_ASSERTIONS
If using libc++:
如果使用 libc++:
Add this define to your CXXFLAGS
: -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG
将此定义添加到您的CXXFLAGS
中:- D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG
Hardening modes do not affect the ABI.
Hardening modes不影响 ABI。
Why
This enables various range checks and other assertions for stdlib containers.
这使得可以对标准库容器进行各种范围检查和其他断言。
Note that because the C++ stdlib relies extensively on class templates defined in header files, and template classes are instantiated separately in each translation unit, adding the flags when compiling your application is mostly sufficient to enable them. You don't need to recompile the stdlib.
请注意,由于 C++标准库广泛依赖于头文件中定义的类模板,并且模板类在每个编译单元中单独实例化,因此在编译应用程序时添加这些标志通常足以启用它们。您无需重新编译标准库。
The ABI compatibility story is complicated. libstdc++ provides separate options for whether or not you want to keep ABI compatibility, and gives you better coverage if you break ABI compatibility. libc++ has a single option and will silently enable/disable certain checks depending on the platform ABI.
ABI 兼容性问题颇为复杂。libstdc++提供了是否保持 ABI 兼容的独立选项,若打破 ABI 兼容,它能提供更全面的覆盖。而 libc++则仅有一个选项,会根据平台 ABI 静默启用或禁用某些检查。
Ref
- GCC manual
- GCC discussion of _GLIBCXX_DEBUG vs _GLIBCXX_ASSERTIONS
- Historical docs on Clang's old debug mode
- Clang's new hardening levels
Enable debugging information for preprocessor macros
启用预处理器宏的调试信息
Generate debug info for macros.
生成宏的调试信息。
How
Add the following to your CFLAGS
and CXXFLAGS
: -ggdb3
将以下内容添加到您的CFLAGS
和CXXFLAGS
中:-ggdb3
Why
In certain macro-heavy codebases, where macros are used like functions and call each other, it can be very useful to be able to dynamically evaluate macros as part of expressions inside of gdb. In general, you shouldn't write new code this way, but any large C++ codebase probably has some parts that fit this description.
在某些宏繁重的代码库中,宏被用作函数并相互调用,能够在 gdb 中作为表达式的一部分动态评估宏非常有用。通常情况下,你不应该以这种方式编写新代码,但任何大型 C++代码库可能都有一些符合这种描述的部分。
Note that you will still not be able to step-into macros.
请注意,您仍然无法单步进入宏。
Ref
Enable frame-pointers for all functions
为所有函数启用帧指针
Compile with frame-pointers.
编译时启用帧指针。
How
Add the following to your CFLAGS
and CXXFLAGS
: -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer
将以下内容添加到您的CFLAGS
和CXXFLAGS
中: -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer
Why
For a long time, it was fashionable to omit the frame pointer to save an extra register and generate more efficient code. This made sense for release builds on 32-bit x86, where there weren't many registers. The theory was also that DWARF debug information would provide enough information to reconstruct the call stack. In practice, this never worked very well. For x86_64, there are many more registers and it is worth it to always include the frame-pointer, even for release builds. You definitely want it while debugging. It will make printing backtraces faster and more reliable. Especially since we plan to optimize some translation units, having a frame pointer will ensure that we still get great backtraces even in optimized code.
长期以来,省略帧指针以节省额外寄存器并生成更高效代码的做法颇为流行。这在 32 位 x86 的发布版本中是有意义的,因为寄存器数量有限。理论上也认为,DWARF 调试信息足以重建调用栈。然而在实践中,这一方法效果并不理想。对于 x86_64 架构,寄存器数量大幅增加,因此在发布版本中始终包含帧指针是值得的。在调试时,你绝对需要它。这将使打印回溯更快且更可靠。特别是当我们计划优化某些编译单元时,拥有帧指针将确保即使在优化后的代码中,我们仍能获得出色的回溯信息。
The leaf frame pointer flag may be necessary if you have an old clang, due to a now-fixed bug. It never hurts to add it.
如果使用的是旧版 clang,由于现已修复的错误,可能需要叶帧指针标志。添加它绝不会有坏处。
Ref
Enable asynchronous unwind tables
启用异步展开表
Enable instruction-level unwind tables for every function.
为每个函数启用指令级展开表。
How
Add the following to your CFLAGS
and CXXFLAGS
: -fasynchronous-unwind-tables
将以下内容添加到您的CFLAGS
和CXXFLAGS
中:-fasynchronous-unwind-tables
This will ensure that the .eh_frame
binary section is produced.
这将确保生成.eh_frame
二进制节。
Why
This information is part of the size overhead of C++ exceptions, so projects will sometimes turn it off. However, it also allows for precise stack unwinding (for backtrace generation) inside the debugger.
此信息是 C++异常大小开销的一部分,因此项目有时会关闭它。然而,它也允许调试器内进行精确的栈展开(用于生成回溯)。
We shouldn't mind the performance penalty during debugging. Enabling this flag will complement the frame pointers and give us the best possible backtraces.
在调试过程中,我们不应介意性能上的损失。启用此标志将补充帧指针,为我们提供尽可能最佳的回溯信息。
Ref
Set the build architecture to base x86_64
设置构建架构为基于 x86_64
Set your binary to build for a very old x86_64 machine.
将您的二进制文件设置为构建适用于非常老旧的 x86_64 机器。
How
Add the following to your CFLAGS
and CXXFLAGS
: -march=x86-64
将以下内容添加到您的CFLAGS
和CXXFLAGS
中:-march=x86-64
Why
This may be important if you want to do reversible debugging 3. Reversible debugging requires a detailed model of the hardware ISA. Historically, reverse debuggers have not supported all x86_64 instructions (e.g. AVX). x86-64
is the baseline 64-bit x86 architecture without extensions, which is likely to be well-supported by all tools.
如果你想进行反向调试3,这一点可能很重要。反向调试需要硬件 ISA 的详细模型。历史上,反向调试器并未支持所有 x86_64 指令(例如 AVX)。x86-64
是没有扩展的基本 64 位 x86 架构,所有工具都可能很好地支持它。
Ref
- Gentoo guide to exactly the opposite
- Difference between mtune and march
- x86_64 microarchitecture levels
Ensure that static libraries are fully linked into your binary
完全链接静态库到二进制文件中
Link with whole-archive so that the entire static archive is available.
链接整个归档文件,以便整个静态归档文件可用。
How
Add the following to your LDFLAGS
: --whole-archive
将以下内容添加到您的LDFLAGS
中:--whole-archive
Why
It is reasonable to want to call any function that is available from your source code from your debugger. Unfortunately, many things in C++ conspire to make this tricky. One such thing is that the linker will only pull in object files from a static archive if you use a symbol from that object file. So if you have a static library as a dependency and don't use any functions from one of the objects within the archive, then you won't be able to use that object file from the debugger because it won't be present in your binary.
从调试器中调用源代码中可用的任何函数是合情合理的。然而,C++中的许多因素使得这一操作变得棘手。其中之一是链接器只会从静态存档中提取对象文件,前提是你使用了该对象文件中的符号。因此,如果你依赖一个静态库,并且没有使用存档中某个对象的任何函数,那么你将无法从调试器中使用该对象文件,因为它不会出现在你的二进制文件中。
This is especially annoying if you statically link against your libc, because many libcs put every symbol into its own object file in order to decrease the final binary size, prevent inlining, and allow symbol shadowing. Then, inside the debugger, you find that you can't call e.g. strlen to check the size of a null-terminated string because you never called it in your program. Of course, glibc can't be statically linked, so this is more of a problem for embedded platforms.
如果你静态链接了 libc,这尤其令人烦恼,因为许多 libc 将每个符号放入其自己的目标文件中,以减小最终二进制文件的大小、防止内联,并允许符号遮蔽。然后,在调试器中,你会发现无法调用例如 strlen 来检查以空字符结尾的字符串长度,因为你从未在程序中调用过它。当然,glibc 不能被静态链接,因此这对嵌入式平台来说问题更为突出。
Ref
Semi-Specific Compilation and Source Changes
Semi-Specific编译与源码变更
Partition your TUs into "debuggable" and "fast"
将编译单元划分为“可调试”和“快速”两类
Partition your TUs into "debuggable" and "fast", and compile the "debuggable" TUs with --ggdb3 -O0
and the "fast" TU's with --ggdb3 -O3
4.
将你的编译单元划分为“可调试”和“快速”两类,并使用--ggdb3 -O0
编译“可调试”的编译单元,使用--ggdb3 -O3
编译“快速”的编译单元4。
How
You need to build different sets of TUs with different CFLAGS
and CXXFLAGS
.
您需要构建具有不同CFLAGS
和CXXFLAGS
的不同编译单元集。
This is unfortunately quite specific to your build system, and I am not aware of any that have this as a built-in feature. My recommendation is to hack up your build system so that you can specify a set of "debuggable" TUs, and then either convince your coworkers to let you merge the change or maintain it for yourself on a private branch.
遗憾的是,这非常依赖于您的构建系统,而且我不清楚是否有任何系统内置了此功能。我的建议是修改您的构建系统,以便能够指定一组“可调试”的编译单元,然后要么说服同事让您合并更改,要么在私有分支上自行维护。
Alternatively, you may be able to bypass the build system entirely. If your codebase has a compile_commands.json so that clangd can provide accurate intellisense, then you can re-purpose it to help you. The compilation database will have compiler commands for every TU in your project. You want to write a script that 1) runs a normal optimized build of your project, 2) grabs the compiler commands for your "debuggable" TUs, re-writes them with --ggdb3 -O0
flags, and runs them, and 3) re-runs the linker command to relink the executable with the new debuggable object files.
或者,您可能能够完全绕过构建系统。如果您的代码库有一个 compile_commands.json 文件,使得 clangd 能够提供准确的智能感知,那么您可以重新利用它来帮助您。编译数据库将为项目中的每个TU提供编译器命令。您需要编写一个脚本,该脚本执行以下操作:1) 运行项目的常规优化构建,2) 抓取您的“可调试”编译单元的编译器命令,用--ggdb3 -O0
标志重写它们并运行,3) 重新运行链接器命令,以使用新的可调试目标文件重新链接可执行文件。
Alternatively, gcc, clang, and msvc each provide pragmas that control optimizations for individual functions or ranges of functions: 5
或者,gcc、clang 和 msvc 各自提供了用于控制单个函数或函数范围优化的编译指示:5
For gcc, add #pragma GCC optimize ("O0")
to the top of your "debuggable" source files, and compile the entire project with --ggdb3 -O3
.
对于 gcc,在您的“可调试”源文件顶部添加#pragma GCC optimize ("O0")
,并使用--ggdb3 -O3
编译整个项目。
For clang, add #pragma clang optimize off
to the top of your "debuggable" source files, and compile the entire project with --ggdb3 -O3
.
对于 clang,在您的“可调试”源文件顶部添加#pragma clang optimize off
,并使用--ggdb3 -O3
编译整个项目。
Why
C++ is often used for code that has to be fast. Unoptimized C++ code can be very slow, especially in large projects. Note that when debugging you often have a pretty good idea of roughly where the bug is going to be, even if you don't know exactly what's going wrong. And the ABI of the generated code doesn't depend on the optimization level, so it is possible to link together optimized and unoptimized TUs. So then, a reasonable strategy is to identify the TUs that have to be debugged, and then only compile those without optimizations, and compile the rest of the project with optimizations.
C++常用于需要高速运行的代码。未经优化的 C++代码可能会非常缓慢,尤其是在大型项目中。请注意,在调试时,即使不清楚具体问题所在,通常也能大致猜到错误可能出现的位置。此外,生成代码的应用二进制接口(ABI)并不依赖于优化级别,因此可以将优化与未优化的编译单元链接在一起。因此,一个合理的策略是识别需要调试的编译单元,然后仅对这些单元进行无优化编译,而对项目的其余部分进行优化编译。
Note that we do still want debug information for the optimized TUs. This will always make our backtraces more informative, and we will sometimes be wrong about where the bug is, so it would be nice to poke around in the optimized TUs for a bit to gather information before we recompile (although the debugging experience will be worse).
请注意,我们仍然希望为优化后的编译单元保留调试信息。这将始终使我们的回溯信息更加丰富,而且我们有时可能会误判错误的位置,因此在重新编译之前,在优化后的编译单元中稍作探索以收集信息是很有必要的(尽管调试体验会较差)。
Also note that sometimes a bug will only appear in optimized code. Usually this means that you have triggered undefined behavior, which the optimizer is taking advantage of to generate faster code. Hopefully UBSan is able to catch this for you, and if UBSan can't and the issue isn't obvious from the source then you should look backwards from the error and examine assembly to see where the compiler has done something weird.
另请注意,有时错误仅在优化代码中出现。通常这意味着您触发了未定义行为,优化器利用这一点生成更快的代码。希望 UBSan 能为您捕捉到这一点,如果 UBSan 无法捕捉且问题从源代码中不明显,那么您应从错误处回溯并检查汇编代码,以查看编译器是否做了什么奇怪的事情。
Note that g++ recommends using -Og
instead of -O0
for the best debugging experience. But this will still inline functions and optimize out local variables, so I don't recommend using it. -Og
is probably a decent choice if you have to compile your entire program at a single optimization level, but we can do even better with our split strategy.
请注意,g++ 建议使用 -Og
而不是 -O0
以获得最佳调试体验。但即便如此,它仍会内联函数并优化掉局部变量,因此我不推荐使用。-Og
可能是一个不错的选项,如果你必须在单一优化级别下编译整个程序,但通过我们的分步策略,我们可以做得更好。
Ref
- GCC manual on optimization levels
- GCC manual on optimization pragmas
- Clang manual on optimization pragmas
- MSVC manual on optimization pragmas
Explicitly instantiate important template classes
显式实例化重要的模板类
Explicitly instantiate every template class specialization that you want to debug.
显式实例化每个您希望调试的模板类特化。
How
Add lines like template class std::vector<Foo>;
to a single translation unit (in a cc / source file).
在单个编译单元(在 cc / 源文件中)添加类似 template class std::vector<Foo>;
的行。
Why
In C++, a member function of a template class is only instantiated if it is used, and this implicit instantiation is separate from the implicit instantiation of the surrounding template class. So, for example, if you want to be able to fully debug a std::vector<Foo>
, then you need to have used every member function of std::vector
, specifically on a std::vector<Foo>
. It doesn't count to have used the member function on a std::vector<Bar>
, because each template class is independent.
在 C++中,模板类的成员函数仅在实际使用时才会被实例化,这种隐式实例化与包围模板类的隐式实例化是分开的。因此,例如,如果你想完全调试一个std::vector<Foo>
,那么你需要在std::vector
上,特别是针对std::vector<Foo>
,使用其每一个成员函数。在std::vector<Bar>
上使用成员函数是不算数的,因为每个模板类是独立的。
Confusingly, gdb will suggest that the function "may have been inlined", when the actual problem is that the template member function was never generated in the first place.
令人困惑的是,gdb 会提示该函数“可能已被内联”,而实际问题是模板成员函数根本未曾生成。
I think that it is reasonable to want to use any function from the class template on any specialization while debugging.
我认为在调试时希望对类模板中的任何函数进行任何特化是合理的。
The way to get this behavior reliably is to explicitly instantiate the template class, which will ensure that all member functions are instantiated, even the ones that you do not use.
确保这种行为可靠的方法是显式实例化模板类,这将确保所有成员函数都被实例化,即使是你未使用的那些。
Note that Arthur O'Dwyer, who is substantially more qualified than I am to be giving advice on this, has an article where he explicitly and directly says not to do the thing that I am telling you to do. He is correct that some classes cannot be explicitly instantiated for all valid template arguments, but I am going to do it anyway because 1) it usually works, 2) it is obvious when it doesn't work, and 3) we are writing quick debugging hacks and not doing software engineering. Be aware that it may be a bad idea to leave explicit template instantiations of STL classes in your production code.
请注意,Arthur O'Dwyer 比我更有资格就此事提供建议,他在一篇文章中明确且直接地指出不要做我正在告诉你要做的事情。他正确地指出,某些类对于所有有效的模板参数不能显式实例化,但我还是要这么做,原因有三:1) 通常情况下它有效,2) 无效时显而易见,3) 我们正在编写快速调试的临时方案,而非进行软件工程。请注意,在生产代码中保留 STL 类的显式模板实例化可能是个坏主意。
Ref
Specific Source Changes
具体源代码变更
Evaluate preprocessor ifdef's
评估预处理器 ifdef 指令
Run unifdef to evaluate preprocessor ifdefs.
运行unifdef以评估预处理器的 ifdef。
How
Install unifdef and run it in-place on a subset of your codebase's files using the -D defines that you are going to use for your build.
安装 unifdef 并在代码库文件子集上就地运行它,使用您将在构建中使用的 -D 定义。
Why
As far as I can tell, the main uses for ifdefs are 1) header guards, 2) platform-specific code, and 3) commenting out blocks of debug or otherwise unused code. (1) is almost never confusing and both (2) and (3) are things that you'll know in advance. You might as well use a tool to evaluate them and make the control flow easier to understand.
据我所知,ifdef 的主要用途包括:1) 头文件保护,2) 平台特定代码,3) 注释掉调试或未使用的代码块。其中,(1) 几乎从不令人困惑,而 (2) 和 (3) 则是你事先就会知道的内容。不妨使用工具来评估它们,使控制流程更易于理解。
This tip is especially useful if you are working with a very old codebase that has tons of platform-specific ifdefs that make it difficult to understand the code. The ideal solution would be to drop support for old platforms and delete the ifdefs, but that is often not possible.
此技巧特别适用于处理一个非常古老的代码库,该代码库充斥着大量特定平台的条件编译指令,使得代码难以理解。理想的解决方案是放弃对旧平台的支持并删除这些条件编译指令,但通常这并不可行。
Unfortunately, unifdef is not perfect at parsing modern C++ code. I don't remember the exact issue, but I have had it fail to parse a single file before on a large codebase. So, my recommendation would be to use it selectively on the files that you care about debugging. Alternatively, you can try your luck and use it everywhere.
遗憾的是,unifdef 在解析现代 C++代码方面并非完美无缺。我记不清具体的问题,但在一个大型代码库中,它曾无法解析单个文件。因此,我的建议是,有选择性地在您关心的调试文件上使用它。或者,您也可以尝试在所有地方使用它,碰碰运气。
Ref
Expand complex macros 展开复杂宏
Run the preprocessor manually to expand complex macros.
手动运行预处理器以展开复杂宏。
How
Identify a confusing macro. Use g++ -E
to run the preprocessor on the TU and evaluate the macro. Copy the expanded macro over the original source code.
识别一个令人困惑的宏。使用g++ -E
运行预处理器处理该编译单元并评估宏。将展开后的宏复制到原始源代码中。
Why
With g++ -ggdb3
, you gain the ability to list or evaluate macros. But you don't have the ability to step through them. Expanding the macro within the source gives you the ability to step-through the expanded macro in the debugger, which can be very useful in certain projects.
使用g++ -ggdb3
,您可以列出或评估宏。但您无法单步执行它们。在源代码中展开宏可以让您在调试器中单步执行展开后的宏,这在某些项目中非常有用。
Note that g++ -E
also evaluates all ifdefs, and you can set the values for defines via the command line. I still prefer to use unifdef for evaluating ifdefs because it gives back clean source code that hasn't been fully preprocessed.
请注意,g++ -E
也会评估所有的 ifdefs,并且您可以通过命令行设置定义的值。我仍然倾向于使用 unifdef 来评估 ifdefs,因为它返回的是未经完全预处理的干净源代码。
Ref
Set up fast conditional breakpoints using the x86 INT3 trick
使用 x86 INT3 技巧快速设置条件断点
Modify the source to insert native-speed conditional breakpoints that can be turned on or off from inside the debugger.
修改源代码以插入native-speed的条件断点,这些断点可以在调试器内部启用或禁用。
How
1 2 3 4 5 6 7 8 9 10 | volatile bool breakpoint_1 = false; ... void func() { ... if (breakpoint_1 && (x_id = = 153827 )) { __asm( "int3\n\tnop" ); } ... } (gdb) p breakpoint_1 = true |
The nop
instruction after the int3
helps gdb understand the context of where the breakpoint fired 6 .nop
指令在int3
之后,有助于 gdb 理解断点触发时的上下文6。
Make sure that breakpoint_1
has external linkage (e.g. isn't static and isn't inside of an anonymous namespace) so that you can easily enable/disable it regardless of where your debugger is sitting in the stack.
确保breakpoint_1
具有外部链接(例如,不是静态的,也不位于匿名命名空间内),以便您可以轻松地启用/禁用它,无论调试器在堆栈中的哪个位置。
Why
If you create a conditional breakpoint from inside of gdb, then it will trap on every occurrence, evaluate your break condition, and continue if the break condition is not met. This can be very slow. gdb does it this way because it only requires overwriting a single byte of the binary, which it knows how to do safely.
如果在 gdb 内部创建条件断点,它将在每次出现时捕获,评估你的断点条件,并在条件不满足时继续执行。这可能会非常慢。gdb 之所以这样做,是因为它只需要覆盖二进制文件中的一个字节,并且它知道如何安全地执行此操作。
The way that gdb sets a breakpoint is to temporarily replace an instruction with the single byte int $3
instruction, which has opcode 0xCC. This instruction generates a software interrupt and allows gdb to take over control. Then, once the instruction is hit, gdb makes sure to also evaluate the single instruction that it had to remove.
gdb 设置断点的方式是临时将一条指令替换为单字节指令int $3
,其操作码为 0xCC。该指令产生软件中断,使 gdb 能够接管控制。然后,一旦命中该指令,gdb 确保还要执行它不得不移除的那条单指令。
But nothing stops us from just inserting an int $3
instruction into our binary ourselves. And furthermore, since we are doing this before the program is compiled, it is easy for us to write a condition on when the instruction fires. This can be hugely faster because we are able to evaluate the condition without needing to do a software interrupt on every occurrence.
但没有任何东西阻止我们自己将一个int $3
指令插入到我们的二进制文件中。而且,由于我们在程序编译之前进行此操作,因此很容易为指令触发编写条件。这可以极大地提高速度,因为我们能够在不需要每次都进行软件中断的情况下评估条件。
The condition variable should be volatile so that we can safely update the variable in the debugger in order to enable / disable our breakpoint. Using volatile means that the program will always read the variable from memory before using it.
条件变量应设为 volatile,这样我们才能在调试器中安全地更新该变量,以启用或禁用断点。使用 volatile 意味着程序在使用变量前总会从内存中读取其最新值。
Ref
Debugger Configuration Changes
调试器配置更改
Avoid stepping into irrelevant code
避免踏入无关代码
Configure gdb to step-over the stdlib, third-party libraries, your project's utility code, and maybe all "fast" TUs.
配置 gdb 以跳过标准库、第三方库、项目实用代码以及可能所有“快速”的编译单元。
how
Add lines like gdb skip -gfi /usr/lib/c++
to your global or project-specific .gdbinit
file. Also add lines for any third-party libraries or fast TUs that you would like to always step-over.
在你的全局或项目特定的.gdbinit
文件中添加类似gdb skip -gfi /usr/lib/c++
的行。同时,为任何你希望始终跳过的第三方库或快速编译单元(TUs)添加相应的行。
why
I often want to rapidly step through a function and step-into related code without ever stepping into core layers like the stdlib. When I am debugging my code, it is usually because I have a bug within my code, and I want to treat the stdlib and most parts of the project as a black box by default.
我常常希望快速浏览一个函数并进入相关代码,而不深入核心层,如标准库。在调试代码时,通常是因为我的代码中存在错误,我希望默认将标准库和项目的大部分内容视为黑盒。
This may not sound like a big deal, but it can be really frustrating. For example, let's say that I want to step into a function call that takes a lot of arguments. Before actually stepping into the function call, gdb will step into each of the argument expressions. If one of those expressions calls a constructor, then gdb will step into the constructor and switch to a different file. A simple attempt to step into a function call at point can turn into dozens of step/next/finish commands spanning several files before you get where you want to go. It is often easier to just set a breakpoint on the function and continue.
这听起来可能不算什么大事,但确实会让人非常沮丧。举个例子,假设我想进入一个参数众多的函数调用。在真正进入函数调用之前,gdb 会先进入每个参数表达式。如果其中一个表达式调用了构造函数,那么 gdb 就会进入该构造函数并切换到另一个文件。原本只是想简单地进入一个函数调用,结果却可能需要执行数十次 step/next/finish 命令,跨越多个文件才能到达目的地。通常情况下,直接在函数上设置断点并继续执行会更为简便。
The key insight is that you probably know in advance that you never want to step into most of those argument expression constructors, because they are probably for stdlib classes or small utility classes that you would like to treat as black boxes. After all, if you're constructing a class inside an argument list then it is probably something simple.
关键的洞察在于,你可能事先就知道,你永远不会想要进入大多数这些参数表达式构造函数,因为它们很可能用于标准库类或你希望视为黑盒的小型实用类。毕竟,如果你在参数列表中构造一个类,那么它很可能是一个简单的对象。
Luckily, gdb can be configured to always step-over arbitrary files and directories. We should take advantage of this and blacklist code that we don't usually want to step-into. For example: the stdlib, third-party dependencies, utility classes, custom string or enumeration classes, or classes that make heavy use of template meta-programming.
幸运的是,gdb 可以配置为始终跳过任意文件和目录。我们应充分利用这一点,将通常不想进入的代码列入黑名单。例如:标准库、第三方依赖项、工具类、自定义字符串或枚举类,或大量使用模板元编程的类。
ref
Enable stdlib pretty-printers
启用标准库pretty-printers
Enable gdb pretty-printers for the stdlib containers.
为标准库容器启用 gdb pretty-printers。
How
This depends on your Linux distribution and your version of gdb. Run the following command inside gdb while attached to your running process to see if the stdlib pretty-printers are installed and available.
这取决于您的 Linux 发行版和 gdb 版本。在 gdb 中附加到正在运行的进程时,运行以下命令以查看是否已安装并可使用标准库的pretty-printers。
1 | (gdb) info pretty - printer |
Note that just running info pretty-printer
inside a fresh gdb that is not attached to anything will not tell you if the stdlib pretty-printers are available. The pretty-printers are associated with a particular stdlib version, and so you need to have loaded a binary that is linked with a stdlib.
请注意,仅在一个未附加任何内容的全新 gdb 中运行info pretty-printer
,并不会告知您标准库的pretty-printer是否可用。这些pretty-printer与特定版本的标准库相关联,因此您需要加载一个与标准库链接的二进制文件。
Why
Improve the signal-to-noise ratio when printing stdlib containers inside the debugger. Reduce the mental load of debugging.
在调试器中打印标准库容器时,提高信噪比。减轻调试时的认知负担。
Note that gdb pretty-printers are the partial solution to template hell for debuggers. They complement C++20 concepts, which are the partial solution to template hell for compilers.
请注意,gdb 的pretty-printer是调试器解决模板地狱问题的部分方案。它们补充了 C++20 概念,后者是编译器解决模板地狱问题的部分方案。
Ref
Write pretty-printers for your project's vocabulary types
为项目类型编写pretty-printers
Use the gdb Python API to write pretty-printers for your project's frequently-used classes that have a meaningful short text description that summarizes a complicated and confusing implementation.
使用 gdb Python API 为项目中频繁使用的类编写pretty-printers,这些类具有有意义的简短文本描述,能够概括复杂且令人困惑的实现。
How
Refer to the gdb manual.
参考 gdb 手册。
Why
Improve the signal-to-noise ratio when printing objects inside the debugger. This is analogous to writing a custom __str__
function on a Python object, except less useful because it only works within the debugger.
提高在调试器内打印对象时的信噪比。这类似于在 Python 对象上编写自定义__str__
函数,只是实用性较低,因为它仅在调试器内有效。
Especially consider writing pretty-printers for any core "vocabulary types" within your codebase that have a tricky implementation. A vocabulary type is a type that is commonly passed around across interfaces. Because they are commonly used, there is a high payoff for making them readable. Many vocabulary types will be basic or standard types, but you probably have a few custom ones in your codebase.
特别考虑为代码库中任何核心的“词汇类型”编写pretty-printers,这些类型在实现上可能较为复杂。词汇类型是指那些在接口间频繁传递的类型。由于它们被广泛使用,提升其可读性将带来高回报。许多词汇类型可能是基本或标准类型,但您的代码库中很可能也有一些自定义类型。
Note that gdb pretty-printers are the partial solution to template hell for debuggers. They complement C++20 concepts, which are the partial solution to template hell for compilers.
请注意,gdb 的pretty-printer是调试器解决模板地狱问题的部分方案。它们补充了 C++20 概念,后者是编译器解决模板地狱问题的部分方案。
Ref
*Thank you to Eliot Robson for providing feedback on drafts of this post. All mistakes are my own.
感谢Eliot Robson为本帖草稿提供反馈。所有错误均由本人承担。
- At least, I really hope so. Engineer time is expensive and this is low-hanging fruit. ↩
至少,我真的希望如此。工程师的时间很宝贵,而这正是唾手可得的果实。↩ - Unless you don't see output because the output is buffered and hasn't been flushed, or because stdout is closed or redirected. ↩
除非你未见输出,因为输出被缓冲且尚未刷新,或因标准输出已关闭或重定向。↩ - I needed this for gdb's builtin reversible debugging. HN user
mark_undoio
says here that this is not usually necessary with rr or Undo, which are more powerful, much faster, and have more complete ISA support. I would recommend them over gdb's builtin support. ↩
我需要这个用于 gdb 的内置可逆调试。HN 用户mark_undoio
在这里提到,通常在使用rr或Undo时并不需要这个,因为它们更强大、速度更快,并且对指令集架构的支持更全面。我建议优先考虑它们,而不是 gdb 的内置支持。↩ - Thanks to HN user
dataflow
for catching my typos here. ↩ - Thanks to HN users
forrestthewoods
,o11c
, andbialpio
for pointing out here that these pragmas exist. ↩ - Thanks to HN user
amluto
for suggesting thenop
instruction here. ↩
感谢 HN 用户amluto
在此处建议使用nop
指令这里。↩
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课