对于任何类型的应用,调试和分析都是开发工作流的重要组成部分。 在嵌入式环境中,这些任务需要开发人员特别注意。 嵌入式应用运行在与开发人员的工作站可能非常不同的系统上,而且通常资源和用户界面功能有限。
开发人员应该提前计划如何在开发阶段调试他们的应用,以及如何确定生产环境中问题的根本原因以及如何修复这些问题。
通常,解决方案是使用目标设备的仿真器以及嵌入式系统供应商提供的交互式调试器。 然而,对于更复杂的系统,完整而准确的仿真几乎是不可行的,而远程调试是最可行的解决方案。
在许多情况下,使用交互式调试器是不可能的,或者根本不实用。 程序在断点处停止后,硬件状态可能会在几毫秒内发生变化,开发人员没有足够的时间对其进行分析。 在这种情况下,开发人员必须使用大量日志记录进行根本原因分析。
在本章中,我们将重点介绍基于SoC(片上系统的缩写)和运行 Linux 操作系统的更强大系统的调试方法。 我们将介绍以下主题:
-
在gdb(**GNU Project Debugger 的缩写)**中运行应用
-
使用断点
-
使用核心转储
-
使用 gdbserver 进行调试
-
添加调试日志记录
-
使用调试版本和发布版本
这些基本的调试技术在使用本书中的食谱以及任何类型的嵌入式应用时都会有很大帮助。
在本章中,我们将学习如何在ARM(Acorn RISC Machines)平台仿真器中调试嵌入式应用。 此时,您应该已经在笔记本电脑或台式机上运行的虚拟化 Linux 环境中配置了两个系统:
- Docker 容器中的 Ubuntu Linux 作为构建系统
- Debian Linux 在QEMU(Quick EMUlato)ARM 仿真器中作为目标系统
要学习交叉编译的理论并设置开发环境,请参考第 2 章、设置环境中的配方。
在本食谱中,我们将学习如何在目标系统上的调试器中运行示例应用,并尝试一些基本的调试技术。
Gdb是一个开源且广泛使用的交互式调试器。 与作为集成开发环境**(IDE)产品一部分的大多数调试器不同,GDB 是一个独立的命令行调试器。 这意味着它不依赖于任何特定的 IDE。 正如您在示例中看到的,您可以使用纯文本编辑器处理应用的代码,同时仍然能够交互调试它、使用断点、查看变量和堆栈跟踪的内容,等等。**
**GDB 的用户界面是极简主义的。 使用它的方式与使用 Linux 控制台的方式相同-通过键入命令并分析其输出。 这种简单性使其非常适合嵌入式项目。 它可以在没有图形子系统的系统上运行。 如果只能通过串行连接或 ssh shell 访问目标系统,则它特别方便。 由于它没有花哨的用户界面,因此可以在资源有限的系统上运行。
在本食谱中,我们将使用一个因异常而崩溃的人工样本应用。 它不会记录任何有用的信息,并且异常消息过于模糊,无法确定崩溃的根本原因。 我们将使用 GDB 来确定问题的根本原因。
现在,我们将创建一个简单的应用,该应用在特定条件下崩溃:
- 在您的工作目录
~/test
中,创建一个名为loop
的子目录。 - 使用您喜欢的文本编辑器在
loop
子目录中创建loop.cpp
文件。 - 让我们将一些代码放到
loop.cpp
文件中。 我们首先介绍的内容包括:
#include <iostream>
#include <chrono>
#include <thread>
#include <functional>
- 现在,我们定义了我们的程序将包含的三个函数。 第一个是
runner
:
void runner(std::chrono::milliseconds limit,
std::function<void(int)> fn,
int value) {
auto start = std::chrono::system_clock::now();
fn(value);
auto end = std::chrono::system_clock::now();
std::chrono::milliseconds delta =
std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
if (delta > limit) {
throw std::runtime_error("Time limit exceeded");
}
}
- 第二个函数是
delay_ms
:
void delay_ms(int count) {
for (int i = 0; i < count; i++) {
std::this_thread::sleep_for(std::chrono::microseconds(1050));
}
}
- 最后,我们添加入口点函数
main
:
int main() {
int max_delay = 10;
for (int i = 0; i < max_delay; i++) {
runner(std::chrono::milliseconds(max_delay), delay_ms, i);
}
return 0;
}
- 在
loop
子目录中创建名为CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(loop)
add_executable(loop loop.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "-g --std=c++ 11")
set(CMAKE_C_COMPILER /usr/bin/arm-linux-gnueabi-gcc)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
- 现在,切换到构建系统终端,并通过运行以下命令将当前目录更改为
/mnt/loop
。
$ cd /mnt/loop
- 按如下方式构建应用:
$ cmake . && make
- 切换回您的本地环境,在
loop
子目录中找到loop
输出文件,并通过 ssh 将其复制到目标系统。 使用用户帐户。 切换到目标系统终端。 如果需要,使用用户凭据登录。 现在,使用gdb
运行loop
可执行二进制文件:
$ gdb ./loop
- 调试器已启动,并显示命令行提示符(
gdb
)。 要运行该应用,请键入run
命令:
(gdb) run
- 您可以看到,由于运行时异常,应用异常终止。 异常消息
Time limit exceeded
给了我们一个线索,但没有指出它是在什么特定条件下发生的。 让我们试着确定这一点。 首先,让我们检查一下崩溃应用的堆栈跟踪:
(gdb) bt
- 这显示了从顶层函数
main
到库函数__GI_abort
的七个堆栈帧,库函数__GI_abort
实际上终止了应用。 正如我们可以看到的,只有帧7
和6
属于我们的应用,因为只有它们是在loop.cpp
中定义的。 让我们仔细看看frame 6
,因为这是抛出异常的函数:
(gdb) frame 6
- 运行
list
命令查看附近的代码:
(gdb) list
- 正如我们所看到的,如果增量变量的值超过了限制变量的值,就会抛出异常。 但这些价值观是什么呢? 以下是变量‘Delta’和‘Limit’的值,运行
info locals
命令即可解决此问题:
(gdb) info locals
- 我们在这里看不到 LIMIT 变量的值。 使用
info args
命令查看:
(gdb) info args
- 现在,我们可以看到极限是
10
,增量是11
。 当调用函数时将fn
参数设置为delay_ms
函数,并将value
参数的值设置为7
,则会发生崩溃。
该应用是故意创建的,以便在某些情况下崩溃,并且没有提供足够的信息来确定这些情况。 该应用由两个主要函数组成-runner
和delay_ms
。
runner
函数接受三个参数-时间限制、一个参数的函数和函数参数值。 它运行作为参数提供的函数,将值传递给它,并测量运行时间。 如果时间超过了时间限制,则会抛出异常。
delay_ms
函数执行延迟。 但是,它的实现不正确,认为每毫秒由 1,100 微秒组成,而不是 1,000 微秒。
main
函数运行loop
目录中的 Runner,提供 10 毫秒的固定值作为时间限制,并提供delay_ms
作为运行函数,但会增加value
参数的值。 在某些情况下,delay_ms
函数超过时间限制,应用崩溃。
首先,我们为 ARM 平台构建应用,并将其传输到仿真器运行:
将-g
参数传递给编译器非常重要。 此参数指示编译器将调试符号添加到生成的二进制文件中。 我们将其添加到CMakeLists.txt
文件的CMAKE_CXX_FLAGS
参数中,如下所示:
SET(CMAKE_CXX_FLAGS "-g --std=c++ 11")
现在,我们运行调试器并将应用可执行文件名作为其参数进行传递:
应用不会立即运行。 我们使用run
gdb 命令启动它,并观察到它很快就崩溃了:
接下来,我们使用backtrace
命令查看堆栈跟踪:
对堆栈跟踪的分析表明,frame 6
应该为我们提供更多信息来揭示根本原因。 在接下来的步骤中,我们切换到frame 6
并查看相关的代码片段:
接下来,我们分析局部变量和函数参数的值,以确定它们与时间限制的关系:
我们确定,当传递给delay_ms
的值达到7
,而不是11
时,就会发生崩溃,这在正确实现延迟的情况下是可以预期的。
Gdb 命令通常接受多个参数来微调其行为。 使用help
gdb 命令了解有关每个命令的更多信息。 例如,下面是help bt
命令的输出:
这将显示有关bt
命令的信息,该命令用于查看和分析堆栈跟踪。 同样,您可以获得有关 GDB 支持的所有其他命令的信息。
在本食谱中,我们将在使用 gdb 时学习更高级的调试技术。 我们将使用相同的示例应用并使用断点来查找实际延迟对delay_ms
参数值的依赖关系。
在 GDB 中使用断点类似于在集成到 IDE 中的调试器中使用断点,唯一的区别是开发人员必须学会显式使用行号、文件名或函数名,而不是使用内置编辑器来导航源代码。
这不如点击运行调试器方便,但其灵活性允许开发人员创建功能强大的调试方案。 在本食谱中,我们将学习如何在 GDB 中使用断点。
在这个配方中,我们将使用与第一个配方相同的环境和测试应用。 请参考在 gdb 配方中运行您的应用的中的步骤 1 到 9,以构建应用并将其复制到目标系统:
- 我们想调试我们的
runner
函数。 我们来看一下它的内容。 在 gdb shell 中,按如下方式运行程序:
(gdb) list runner,delay_ms
- 我们希望看到增量在每次迭代中是如何变化的。 让我们在该行设置一个断点:
14 if (delta > limit) {
- 使用
break 14
命令在第 14 行设置断点:
(gdb) break 14
- 现在,运行该程序:
(gdb) run
- 检查
delta
的值:
(gdb) print delta
$1 = {__r = 0}
- 通过键入
continue
或仅键入c
继续执行程序:
(gdb) c
- 再次检查
delta
的值:
(gdb) print delta
- 正如我们预期的那样,
delta
的值在每次迭代时都会增加,因为delay_ms
需要越来越多的时间。 - 每次运行
print delta
都不方便。 让我们使用名为command
的命令将其自动化:
(gdb) command
- 再次运行
c
。 现在,在每次停止后都会显示delta
的值:
(gdb) c
- 但是,输出过于冗长。 让我们通过再次键入
command
并编写以下指令来使 gdb 输出静默。 现在,多次运行c
或continue
命令以查看差异:
(gdb) command
Type commands for breakpoint(s) 1, one per line.
End with a line saying just "end".
>silent
>print delta
>end
(gdb) c
- 我们可以使用
printf
命令使输出更加简洁,如下所示:
(gdb) command
Type commands for breakpoint(s) 1, one per line.
End with a line saying just "end".
>silent
>printf "delta=%d, expected=%d\n", delta.__r, value
>end
(gdb) c
现在,我们可以看到两个值,计算的延迟和预期的延迟,并可以看到它们随时间的变化情况。
在这个配方中,我们想要设置一个断点来调试runner
函数。 由于 gdb 没有内置编辑器,我们需要知道行号来设置断点。 虽然我们可以直接从文本编辑器获得它,但另一种方法是查看 GDB 中的相关代码片段。 我们使用带有两个参数(函数名)的gdb
命令列表来显示函数运行器的第一行和delay_ms
函数的第一行之间的代码行。 这将有效地显示函数运行器的内容:
在步骤 4,使用break 14
命令在第14
行设置断点,然后运行程序。 执行在断点处停止:
我们使用print
命令检查delta
变量的值,并使用continue
命令继续执行程序,由于在循环中调用了runner
函数,因此它再次在同一断点处停止:
接下来,我们尝试一种更高级的技术。 我们定义了一组要在触发断点时执行的 gdb 命令。 我们从一个简单的print
命令开始。 现在,每次继续执行时,我们都可以看到delta
变量的值:
接下来,我们使用silent
命令禁用辅助 gdb 输出,以使输出更加简洁:
最后,我们使用printf
命令用两个最有趣的变量格式化消息:
如您所见,GDB 为开发人员提供了很大的灵活性,即使缺少图形界面也能使调试变得舒适。
重要的是要记住,优化选项-O2
和-O3
可能会导致编译器完全删除某些代码行。 如果将断点设置为此类行,则永远不会触发这些断点。 若要避免此类情况,请关闭调试版本的编译器优化。
在第一个配方中,我们了解了如何使用交互式命令行调试器确定应用崩溃的根本原因。 但是,也有应用在生产环境中崩溃的情况,在测试系统上运行 GDB 下的应用不可能或不切实际地重现相同的问题。
Linux 提供了一种机制来帮助分析崩溃的应用,即使它们不是直接从 GDB 运行的。 当应用异常终止时,操作系统将其内存的映像保存到名为core
的文件中。 在本食谱中,我们将学习如何配置 Linux 来为崩溃的应用生成核心转储,以及如何使用 GDB 进行分析。
我们将确定未在 GDB 中运行的应用崩溃的根本原因:
- 在这个配方中,我们将使用与第一个配方相同的环境和测试应用。 请参考第一个配方的步骤 1至7来构建应用并将其复制到目标系统。
- 首先,我们需要为崩溃的应用启用核心转储的生成。 在大多数 Linux 发行版中,此功能在默认情况下处于关闭状态。 运行
ulimit -c
命令检查当前状态:
$ ulimit -c
- 前面命令报告的值是要生成的核心转储的最大大小。 零表示没有核心转储。 要增加限制,我们需要首先获得超级用户权限。 运行
su -
命令。 提示输入Password
时,键入root
:
$ su -
Password:
- 运行
ulimit -c unlimited
命令以允许任何大小的核心转储:
# ulimit -c unlimited
- 现在,通过按Ctrl+D或运行
logout
命令退出根 shell。 - 前面的命令仅更改了超级用户的核心转储限制。 要将其应用于当前用户,请在用户外壳中再次运行相同的命令:
$ ulimit -c unlimited
- 确保更改了限制:
$ ulimit -c
unlimited
- 现在,像往常一样运行应用:
$ ./loop
- 它将崩溃,并出现异常。 运行
ls
命令检查当前目录中是否创建了核心文件:
$ ls -l core
-rw------- 1 dev dev 536576 May 31 00:54 core
- 现在,运行
gdb
,将可执行文件和core
文件作为参数传递:
$ gdb ./loop core
- 在 gdb shell 中,运行
bt
命令查看堆栈跟踪:
(gdb) bt
- 您可以看到与从
gdb
内部运行的应用相同的堆栈跟踪。 但是,在本例中,我们可以看到核心转储的堆栈跟踪。 - 此时,我们可以使用与第一个配方中相同的调试技术来缩小崩溃原因的范围。
核心转储功能是 Linux 和其他类 Unix 操作系统的标准功能。 然而,并不是在所有情况下都创建核心文件是可行的。 由于核心文件是进程内存的快照,因此它们在文件系统中可能占到兆字节甚至千兆字节。 在许多情况下,这是不可接受的。
开发人员需要明确指定操作系统允许生成的核心文件的最大大小。 在其他限制中,可以使用ulimit
命令设置此限制。
我们运行两次ulimit
来移除限制,首先是超级用户 root,然后是普通用户/开发人员。 由于普通用户限制不能超过超级用户限制,因此需要两个阶段的过程。
在取消了核心文件大小的限制之后,我们在没有 gdb 的情况下运行测试应用。 不出所料,它崩溃了。 崩溃后,我们可以看到在当前目录中创建了一个名为core
的新文件。
当我们运行应用时,它会崩溃。 正常情况下,我们无法追踪坠机的根本原因。 但是,由于我们启用了核心转储,操作系统会自动为我们创建一个名为core
的文件:
核心文件是所有进程内存的二进制转储,但如果没有其他工具,很难对其进行分析。 值得庆幸的是,GDB 提供了必要的支持。
我们运行 gdb,传递两个参数-可执行文件的路径和核心文件的路径。 在此模式下,我们不从 gdb 内部运行应用。 在核心转储发生崩溃的那一刻,我们已经冻结了它的状态。 Gdb 使用可执行文件将core
文件中寻址的内存绑定到函数和变量名:
因此,即使应用不是从调试器运行的,也可以在交互式调试器中分析崩溃的应用。 当我们调用bt
命令时,gdb 会显示崩溃时刻的堆栈跟踪:
这样,即使应用最初没有在调试器中运行,我们也可以确定应用崩溃的根本原因。
对于嵌入式应用,使用 GDB 分析核心转储是一种广泛使用且有效的实践。 但是,要使用 gdb 的全部功能,应用构建时应该支持调试符号。
但是,在大多数情况下,嵌入式应用在部署和运行时没有调试符号,以减小二进制大小。 在这种情况下,核心转储的分析变得更加困难,可能需要了解特定体系结构的汇编语言和数据结构实现的内部结构。
嵌入式开发环境通常涉及两个系统-构建系统和目标系统,或仿真器。 虽然 gdb 的命令行界面即使对于低性能的嵌入式系统也是一个很好的选择,但在许多情况下,由于远程通信的高延迟,在目标系统上进行交互调试是不切实际的。
在这种情况下,开发人员可以使用 GDB 提供的远程调试支持。 在此设置中,使用 gdbserver 在目标系统上启动嵌入式应用。 开发人员在构建系统上运行 gdb,并通过网络连接到 gdbserver。
在本食谱中,我们将学习如何使用 gdb 和 gdbserver 开始调试应用。
按照第 2 章、设置环境、中的连接到嵌入式系统配方,使hello
应用在目标系统上可用。
*# 怎么做……
我们将使用前面配方中使用的相同应用,但现在我们将在不同的环境中运行 gdb 和应用:
- 切换到目标系统窗口,键入Ctrl+D从现有用户会话注销。
- 以
user
身份登录,使用user
密码。 - 在
gdbserver
下运行hello
应用:
$ gdbserver 0.0.0.0:9090 ./hello
- 切换到构建系统终端,将目录切换到
/mnt
:
# cd /mnt
- 运行
gdb
,将应用二进制文件作为参数传递:
# gdb -q hello
- 通过在 gdb 命令行中键入以下命令来配置远程连接:
target remote X.X.X.X:9090
- 最后,键入
continue
命令:
continue
程序现在运行,我们可以看到它的输出并进行调试,就像它在本地运行一样。
首先,我们以超级用户身份登录到我们的目标系统并安装 gdbserver,除非它已经安装。 安装完成后,我们使用用户凭据再次登录并运行 gdbserver,传递要调试的应用的名称、IP 地址和要侦听传入连接的端口作为其参数。
然后,我们切换到构建系统并在那里运行 gdb。 但是,我们不是直接在 gdb 中运行应用,而是指示 gdb 使用提供的 IP 地址和端口发起到远程主机的连接。 之后,您在 gdb 提示符下键入的所有命令都将传输到 gdbserver 并在那里执行。
日志记录和诊断是任何嵌入式项目的一个重要方面。 在许多情况下,使用交互式调试器是不可能或不切实际的。 程序在断点处停止后,硬件状态可能会在几毫秒内发生变化,开发人员没有足够的时间对其进行分析。 对于高性能、多线程、时间敏感的嵌入式系统,收集详细的日志数据并使用工具进行分析和可视化是一种更好的方法。
日志记录本身会带来一定的延迟。 首先,格式化日志消息并将其放入日志流需要时间。 其次,日志流应该可靠地存储在永久存储器(如闪存卡或磁盘驱动器)中,或者发送到远程系统。
在本食谱中,我们将学习如何使用日志记录而不是交互式调试来查找问题的根本原因。 我们将使用不同日志级别的系统来最小化日志记录带来的延迟。
我们将修改应用以输出对根本原因分析有用的信息:
- 转到您的工作目录
~/test
,并复制loop
项目目录。 将副本命名为loop2
。 将目录更改为loop2
。 - 使用文本编辑器打开
loop.cpp
文件。 - 再添加一个
include
:
#include <iostream>
#include <chrono>
#include <thread>
#include <functional>
#include <syslog.h>
- 通过向
syslog
函数添加调用来修改runner
函数,如以下代码片段中突出显示的那样:
void runner(std::chrono::milliseconds limit,
std::function<void(int)> fn,
int value) {
auto start = std::chrono::system_clock::now();
fn(value);
auto end = std::chrono::system_clock::now();
std::chrono::milliseconds delta =
std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
syslog(LOG_DEBUG, "Delta is %ld",
static_cast<long int>(delta.count()));
if (delta > limit) {
syslog(LOG_ERR,
"Execution time %ld ms exceeded %ld ms limit",
static_cast<long int>(delta.count()),
static_cast<long int>(limit.count()));
throw std::runtime_error("Time limit exceeded");
}
}
- 同样,更新
main
函数以初始化并最终确定syslog
:
int main() {
openlog("loop3", LOG_PERROR, LOG_USER);
int max_delay = 10;
for (int i = 0; i < max_delay; i++) {
runner(std::chrono::milliseconds(max_delay), delay_ms, i);
}
closelog();
return 0;
}
- 切换到构建系统终端。 转到
/mnt/loop2
目录并运行程序:
# cmake && make
- 将生成的
binary
文件循环复制到目标系统并运行:
$ ./loop
调试输出非常详细,并提供了更多上下文来查找问题的根本原因。
在本配方中,我们使用标准日志记录工具syslog
添加了日志记录。 首先,我们通过调用openlog
来初始化日志记录:
openlog("loop3", LOG_PERROR, LOG_USER);
接下来,我们将日志记录添加到runner
函数。 有不同的日志记录级别可帮助筛选日志消息,从最严重到最不严重。 我们使用LOG_DEBUG
级别记录delta
值,该值指示运行器调用的函数实际运行多长时间:
syslog(LOG_DEBUG, "Delta is %d", delta);
此级别用于记录详细信息,这些信息对应用调试有帮助,但在生产中运行应用时可能会被证明过于冗长。
但是,如果增量超过限制,我们将使用LOG_ERR
级别记录此情况,以指示此情况不应正常发生,这是一个错误:
syslog(LOG_ERR,
"Execution time %ld ms exceeded %ld ms limit",
static_cast<long int>(delta.count()),
static_cast<long int>(limit.count()));
在从应用返回之前,我们关闭日志记录以确保所有日志消息都已正确保存:
closelog();
当我们在目标系统上运行应用时,我们可以在屏幕上看到我们的日志消息:
因为我们使用标准的 Linux 日志记录,所以我们还可以在系统日志中找到消息:
正如您所看到的,日志记录并不难实现,但它对在调试和正常操作期间查找应用中各种问题的根本原因非常有帮助。
有许多记录库和框架可能比标准记录器更适合于特定任务;例如,Boost.Log,位于https://theboostcpplibraries.com/boost.log,以及SPDLOG,位于https://github.com/gabime/spdlog,Boost.Log,位于https://theboostcpplibraries.com/boost.log,以及spdlog,位于https://github.com/gabime/spdlog。 与syslog
的通用 C 接口相比,它们提供了更方便的 C++ 接口。 在开始处理项目时,请检查现有的日志库,并选择最适合您需求的一个。
正如我们在前面的食谱中了解到的,日志记录有相关的成本。 它引入延迟来格式化日志消息,并将其写入永久存储或远程系统。
通过跳过将某些消息写入日志文件,使用日志级别有助于降低开销。 但是,消息通常在传递给log
函数之前进行格式化。 例如,在出现系统错误的情况下,开发人员希望将系统报告的错误代码添加到日志消息中。 尽管字符串格式化通常比将数据写入文件的成本要低,但对于高负载系统或资源有限的系统来说,它可能仍然是一个问题。
编译器添加的调试符号不会增加运行时开销。 但是,它们会增加生成的二进制文件的大小。 此外,编译器进行的性能优化可能会使交互式调试变得困难。
在本食谱中,我们将学习如何通过分离调试和发布版本并使用 C 预处理器宏来避免运行时开销。
我们将修改前面配方中使用的应用的构建规则,使其具有两个构建目标-调试和发布:
- 转到您的工作目录
~/test
,并复制loop2
项目目录。 将副本命名为loop3
。 将目录更改为loop3
。 - 使用文本编辑器打开
CMakeLists.txt
文件。 替换以下行:
SET(CMAKE_CXX_FLAGS "-g --std=c++ 11")
- 前面的一行需要替换为以下行:
SET(CMAKE_CXX_FLAGS_RELEASE "--std=c++ 11")
SET(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_RELEASE} -g -DDEBUG")
- 使用文本编辑器打开
loop.cpp
文件。 通过添加突出显示的行来修改文件:
#include <iostream>
#include <chrono>
#include <thread>
#include <functional>
#include <cstdarg>
#ifdef DEBUG
#define LOG_DEBUG(fmt, args...) fprintf(stderr, fmt, args)
#else
#define LOG_DEBUG(fmt, args...)
#endif
void runner(std::chrono::milliseconds limit,
std::function<void(int)> fn,
int value) {
auto start = std::chrono::system_clock::now();
fn(value);
auto end = std::chrono::system_clock::now();
std::chrono::milliseconds delta =
std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
LOG_DEBUG("Delay: %ld ms, max: %ld ms\n",
static_cast<long int>(delta.count()),
static_cast<long int>(limit.count()));
if (delta > limit) {
throw std::runtime_error("Time limit exceeded");
}
}
- 切换到构建系统终端。 转到
/mnt/loop3
目录并运行以下代码:
# cmake -DCMAKE_BUILD_TYPE=Release . && make
- 将生成的
loop
二进制文件复制到目标系统并运行:
$ ./loop
- 如您所见,应用不会生成任何调试输出。 现在让我们使用
ls -l
命令检查它的大小:
$ ls -l loop
-rwxr-xr-x 1 dev dev 24880 Jun 1 00:50 loop
- 生成的二进制文件的大小为 24KB。 现在,让我们构建
Debug
版本并进行比较,如下所示:
$ cmake -DCMAKE_BUILD_TYPE=Debug && make clean && make
- 检查可执行文件的大小:
$ ls -l ./loop
-rwxr-xr-x 1 dev dev 80008 Jun 1 00:51 ./loop
- 现在,可执行文件的大小为 80KB。 它比发布版本大三倍多。 以与之前相同的方式运行它:
$ ./loop
如您所见,现在的输出不同了。
我们从用于添加调试日志记录配方的项目副本开始,并创建两个不同的构建配置:
- Debug:支持交互式调试和调试日志记录的配置
- 版本:高度优化的配置,在编译时禁用所有调试支持
为了实现它,我们利用了CMake
提供的功能。 它支持开箱即用的不同构建类型。 我们只需要为发布和调试版本分别定义编译选项。
我们为发布版本定义的唯一构建标志是要使用的 C++ 标准。 我们明确要求代码符合 C++ 11 标准:
SET(CMAKE_CXX_FLAGS_RELEASE "--std=c++ 11")
对于调试版本,我们重用了与发布版本相同的标志,将它们引用为${CMAKE_CXX_FLAGS_RELEASE}
,并添加了另外两个选项。 -g
指示编译器向目标可执行二进制文件添加调试符号,-DDEBUG
定义预处理器宏DEBUG
。
我们在loop.cpp
的代码中使用DEBUG
宏来在LOG_DEBUG
宏的两个不同实现之间进行选择。
如果定义了DEBUG
,则将LOG_DEBUG
扩展为调用fprintf
函数,该函数在标准错误通道中执行实际记录。 但是,如果未定义DEBUG
,则将LOG_DEBUG
扩展为空字符串。 这意味着在这种情况下,LOG_DEBUG
不会生成任何代码,因此不会增加任何运行时开销。
我们在 Runner 函数体中使用LOG_DEBUG
来记录实际延迟和限制值。 请注意,在LOG_DEBUG
周围没有if
-格式化和记录数据或什么也不做的决定不是由程序在运行时做出的,而是由代码预处理器在构建应用时做出的。
要选择构建类型,我们调用cmake
,将构建类型的名称作为命令行参数传递:
cmake -DCMAKE_BUILD_TYPE=Debug
CMake
仅生成Make
文件来实际构建我们调用make
所需的应用。 我们可以在单个命令行中组合这两个命令:
cmake -DCMAKE_BUILD_TYPE=Release && make
当我们第一次构建和运行我们的应用时,我们选择发布版本。 因此,我们看不到任何调试输出:
之后,我们使用调试构建类型重新构建我们的应用,并在运行它时看到不同的结果:
使用调试和发布版本,您可以获得足够的信息来进行舒适的调试,但请确保生产版本不会有任何不必要的开销。
在复杂项目中的发布版本和调试版本之间切换时,请确保所有文件都已正确重新生成。 要做到这一点,最简单的方法是删除所有以前的构建文件。 当使用make
时,这可以通过调用make clean
命令来完成。
它可以与cmake
和make
一起作为命令行的一部分添加:
cmake -DCMAKE_BUILD_TYPE=Debug && make clean && make
将所有这三个命令合并到一行中可以使开发人员更加方便。***