C++ 工程化学习

接下来的主线为:

复制代码
C++程序能写
    ↓
程序出问题怎么查
    ↓
gdb / core dump / ASan / TSan / Valgrind
    ↓
程序慢在哪里
    ↓
perf
    ↓
工程怎么组织
    ↓
CMake / 静态库 / 动态库 / gtest / CTest
    ↓
Linux系统级能力
    ↓
进程 / 线程 / FD / epoll / systemd / udev
    ↓
实时控制
    ↓
CPU affinity / SCHED_FIFO / mlockall / PREEMPT_RT
    ↓
ROS2系统能力
    ↓
Executor / Lifecycle / Diagnostics / ros2_control
    ↓
Safety Layer

gdb = C/C++ 程序出问题以后,用来"暂停程序、查看现场、一步一步执行"的调试器。

比如你的机器人程序:

复制代码
PNLink
 ↓
mocap_driver
 ↓
GMR
 ↓
robot_output
 ↓
EtherCAT

突然:

复制代码
Segmentation fault

或者:

复制代码
机器人运行 30 秒后程序崩溃

普通运行只能看到:

复制代码
Segmentation fault (core dumped)

你不知道:

复制代码
谁崩了?
↓
在哪个函数?
↓
哪一行?
↓
哪个指针?
↓
哪个线程?
↓
当时变量是什么值?

这就是 gdb 要解决的问题。


1、调试信息

假设你有:

复制代码
int main()
{
    int* p = nullptr;
    *p = 10;
}

编译:

复制代码
g++ main.cpp -o test

虽然也能用 gdb,但是信息可能不好看。

调试时通常:

复制代码
g++ -g main.cpp -o test

这里:

复制代码
-g

就是:

把源代码行号、变量、函数等调试信息放进程序。

所以以后你看到:

复制代码
-g

脑子里直接反应:

复制代码
为了调试

程序:

复制代码
./test

变成:

复制代码
gdb ./test

进去以后:

复制代码
(gdb)

然后:

复制代码
run

也可以简写:

复制代码
r

于是程序开始运行。

如果崩了:

复制代码
Program received signal SIGSEGV

这里非常重要。

SIGSEGV 就是:

复制代码
Segmentation Fault
段错误

通常意味着:

复制代码
非法访问内存

例如:

复制代码
nullptr

或者:

复制代码
数组越界

或者:

复制代码
已经释放的内存还在用

2、gdb

程序崩了以后:

复制代码
bt

意思:

复制代码
backtrace

也就是:

看函数调用栈。

比如:

复制代码
#0 MotionInput::read()
#1 MocapDriver::update()
#2 main()

你应该从下往上理解:

复制代码
main()
  ↓
MocapDriver::update()
  ↓
MotionInput::read()
  ↓
崩溃

所以:

复制代码
#0

通常是出问题现场。

以后面试官问:

程序发生 Segmentation Fault 怎么定位?

你的第一反应就应该有:

复制代码
gdb
↓
run
↓
程序崩溃
↓
bt
↓
看调用栈

怎么查看变量?

假设停在:

复制代码
frame->timestamp

可以:

复制代码
print frame

简写:

复制代码
p frame

可能显示:

复制代码
$1 = (Frame *) 0x0

0x0:

复制代码
nullptr

于是问题基本定位完成:

复制代码
frame 是空指针

继续:

复制代码
p frame_count
p timestamp
p queue.size()

都可以。

所以:

复制代码
print / p

就是:

看变量现在到底是什么。


断点 breakpoint

除了等程序自己崩,我们也可以主动让程序停下来。

比如:

复制代码
break main

或者:

复制代码
b main

运行:

复制代码
run

程序到:

复制代码
main()

就暂停。

也可以:

复制代码
break MocapDriver::update

或者:

复制代码
break driver.cpp:103

所以断点可以按:

复制代码
函数
源代码行
文件:行号

来下。


程序停下来以后怎么走?

最常用的是两个。

next

复制代码
n

意思:

执行下一行,但不要进入函数内部。

例如:

复制代码
readFrame();
processFrame();
sendFrame();

你执行:

复制代码
next

就是一行一行走。


step

复制代码
s

意思:

如果这一行有函数调用,就进入这个函数。

比如:

复制代码
processFrame(frame);

执行:

复制代码
step

直接进入:

复制代码
void processFrame(...)

所以记:

复制代码
next
  ↓
下一行

step
  ↓
进入函数

continue

你已经停在断点:

复制代码
Breakpoint 1

不想一行一行走了:

复制代码
continue

简写:

复制代码
c

程序继续运行。

直到:

复制代码
下一个断点

或者:

复制代码
程序崩溃

现在只记:

复制代码
gdb ./程序

进入 gdb。

然后:

复制代码
run

运行。

复制代码
bt

调用栈。

复制代码
break

断点。

复制代码
next

下一行。

复制代码
step

进入函数。

复制代码
print

查看变量。

复制代码
continue

继续运行。这已经能解决很多实际问题。

这几个工具千万不要混在一起。

复制代码
gdb
│
├─ 程序在哪里崩?
├─ 当时变量是什么?
└─ 调用栈是什么?

而:

复制代码
ASan
│
├─ 越界
├─ use-after-free
└─ 内存错误

TSan
│
└─ 多线程数据竞争

Valgrind
│
├─ 内存泄漏
└─ 非法内存访问

perf
│
└─ CPU时间花在哪里

它们不是互相替代关系,而是:

复制代码
程序崩了
 → gdb

怀疑内存错误
 → ASan

怀疑线程竞争
 → TSan

怀疑泄漏
 → Valgrind

程序没崩但是太慢
 → perf

这个框架你先牢牢记住。

复制代码
gdb ./your_program

会方便很多。

以后你看到:

复制代码
Debug
Release
RelWithDebInfo

先简单理解:

复制代码
Debug
 → 好调试,优化少

Release
 → 优化高,发布使用

RelWithDebInfo
 → 有优化 + 保留调试信息

后面讲 CMake 时会系统讲。

不是背 gdb 命令,而是建立:

复制代码
程序崩溃
    ↓
保留现场
    ↓
gdb
    ↓
bt
    ↓
找到线程
    ↓
找到函数
    ↓
找到代码行
    ↓
检查变量
    ↓
判断根因

以后会继续扩展成:

复制代码
崩溃
├── gdb
├── core dump
└── ASan

线程问题
├── gdb
└── TSan

内存问题
├── ASan
└── Valgrind

性能问题
└── perf

系统调用问题
└── strace

3、 core dump

core dump = 程序崩溃那一刻的"现场快照"。

比如你的机器人程序已经运行了 3 个小时:

复制代码
mocap_driver
   ↓
GMR
   ↓
robot_output
   ↓
EtherCAT

突然某个 C++ 进程:

复制代码
Segmentation fault (core dumped)

问题来了:

复制代码
程序已经死了
↓
当时没开 gdb
↓
现在怎么查?

这时就靠:

复制代码
core dump

core dump 保存了什么?

程序崩溃的时候,Linux 可以把它当时的一部分运行状态保存下来,例如:

复制代码
程序崩溃时
│
├── 寄存器
├── 调用栈
├── 内存状态
├── 线程状态
├── 当前执行位置
└── 部分变量状态

最终生成一个文件。

可能叫:

复制代码
core

或者:

复制代码
core.12345

或者由 systemd 管理,不直接出现在当前目录。

你可以把它理解成:

程序的"事故黑匣子"。


core dump 和 gdb 是什么关系?

这个关系必须搞清楚。

复制代码
gdb

是:

调试工具。

而:

复制代码
core dump

是:

崩溃现场文件。

两者通常组合使用:

复制代码
gdb 可执行程序 core文件

例如:

复制代码
gdb ./mocap_driver core

然后:

复制代码
bt

就可以查看程序死亡之前的调用栈。

所以:

复制代码
core dump
      ↓
交给 gdb
      ↓
事后分析

4、 ulimit -c

Linux 默认可能禁止生成 core 文件。

查看:

复制代码
ulimit -c

如果输出:

复制代码
0

意思:

不允许生成 core dump。

如果输出:

复制代码
unlimited

说明:

core dump 大小不受这个限制。

临时开启:

复制代码
ulimit -c unlimited

然后:

复制代码
./test

程序崩溃时系统才有机会生成 core。

所以以后排查:

复制代码
为什么没有 core?

第一件事之一:

复制代码
ulimit -c

5、 systemd-coredump

这点现在很重要。

现代 Linux 机器上,core dump 往往不是简单地:

复制代码
./core

而是被:

复制代码
systemd

接管。

最常见工具:

复制代码
coredumpctl

查看已有崩溃:

复制代码
coredumpctl list

可能看到:

复制代码
TIME             PID   UID  SIGNAL  COREFILE   EXE
...
12345            ...   ...  SIGSEGV present    /home/user/test

你重点看:

复制代码
PID
SIGNAL
EXE

coredumpctl info

例如:

复制代码
coredumpctl info 12345

可能看到:

复制代码
PID: 12345
Signal: 11 (SEGV)
Command Line: ./test
Executable: /home/user/test

它告诉你:

复制代码
哪个程序
什么时间
哪个 PID
因为什么 signal 崩了

直接进入 gdb

非常实用:

复制代码
coredumpctl gdb 12345

系统会:

复制代码
找到对应 core
+
找到程序
+
启动 gdb

进去之后:

复制代码
bt

就可以开始分析。

所以你以后可以记:

复制代码
coredumpctl list
        ↓
找到 PID
        ↓
coredumpctl info PID
        ↓
coredumpctl gdb PID
        ↓
bt

bt 不够怎么办?

后面你会逐渐用到几个命令。

查看所有线程

复制代码
info threads

机器人软件特别重要,因为可能是:

复制代码
主控制线程
UDP线程
ROS2线程
EtherCAT线程
日志线程

其中某一个崩了。


查看所有线程调用栈

非常实用:

复制代码
thread apply all bt

意思:

把所有线程的调用栈全部打印出来。

这是以后:

复制代码
死锁
多线程异常
程序卡死

经常用的命令。

虽然 core dump 主要针对崩溃,但多线程程序分析也非常有价值。

Debug

复制代码
-g
低优化

优点:

复制代码
变量容易看
调用关系容易看
调试舒服

缺点:

复制代码
性能和正式版本差异可能较大

Release

通常:

复制代码
高优化

但问题:

复制代码
函数可能被 inline
变量可能被优化掉
代码顺序可能改变

gdb 里可能出现:

复制代码
<optimized out>

RelWithDebInfo

可以简单理解:

复制代码
Release性能
+
调试符号

因此机器人软件正式部署时,这种模式经常非常实用。


6、core dump 与日志的关系

不要认为有 core 就不用日志。

应该:

复制代码
日志
+
core dump

一起用。

例如日志:

复制代码
16:21:03 EtherCAT timeout
16:21:03 Entering fault state
16:21:04 reconnect start
16:21:04 Segmentation fault

然后 core:

复制代码
RobotDriver::reconnect()
        ↓
delete connection
        ↓
另外一个线程仍然访问 connection
        ↓
崩溃

两边结合才可能还原完整故事。

比如机器人控制程序:

复制代码
Control Process
     ↓
崩溃

真正危险的问题不是:

复制代码
程序为什么崩?

而是:

程序崩了以后机器人会怎么样?

例如:

复制代码
控制程序崩溃
     ↓
EtherCAT 从站还保持上一条目标?
     ↓
电机继续输出?

这是非常危险的设计。

所以以后 Safety Layer 会要求类似:

复制代码
控制程序
    ↓
周期命令
    ↓
watchdog
    ↓
如果命令超时
    ↓
进入 fault
    ↓
safe disable

因此:

复制代码
core dump

解决的是:

为什么软件崩了?

而:

复制代码
watchdog / timeout / safe disable

解决的是:

软件崩了以后系统如何保持安全。

这两个思路不要混起来。

复制代码
程序崩溃
│
├── 能现场复现
│      ↓
│     gdb
│      ↓
│     run
│      ↓
│     bt
│
└── 已经崩完 / 偶发崩溃
       ↓
   core dump
       ↓
   coredumpctl
       ↓
   gdb
       ↓
   bt

gdb
= 在线/现场调试程序

core dump
= 保存崩溃现场,事后交给 gdb 分析

7、 TSan 到底是干什么的?

TSan 全名:

复制代码
ThreadSanitizer

一句话:

TSan = 专门抓多线程数据竞争(data race)的工具。

如果 ASan 主要解决:

复制代码
内存访问错没错

那 TSan 主要解决:

复制代码
多个线程一起访问数据时,时序有没有问题

怎么开启 TSan?

最简单:

复制代码
g++ -g -fsanitize=thread main.cpp -o test

然后运行:

复制代码
./test

如果发现线程竞争,可能输出:

复制代码
WARNING: ThreadSanitizer: data race

然后告诉你:

复制代码
Write of size 4 at ...

另一个线程:

复制代码
Previous read of size 4 at ...

或者:

复制代码
Previous write ...

并附带:

复制代码
文件
函数
行号
线程

看到一大片日志后,重点看:

复制代码
① WARNING: ThreadSanitizer: data race

然后找:

复制代码
② Current write/read

比如:

复制代码
Write of size 8

然后:

复制代码
③ Previous read/write

再找:

复制代码
④ 两个线程各自在什么代码行

你本质上是在回答:

哪两个线程,在没有正确同步的情况下,访问了同一块共享数据?


8、std::atomic

对于特别简单的数据,比如:

复制代码
bool running;

如果多个线程访问,可以考虑:

复制代码
std::atomic<bool> running{true};

线程 A:

复制代码
running.store(false);

线程 B:

复制代码
if (!running.load())
{
    break;
}

这比:

复制代码
mutex

更适合这种简单状态。

常见:

复制代码
running
stop_requested
fault_flag
frame_counter

可能使用 atomic。

例如:

复制代码
struct Command
{
    double q[23];
    double dq[23];
    double tau[23];
};

不能简单认为:

复制代码
给它加个 atomic 就完事

复杂对象往往需要:

复制代码
mutex
双缓冲
ring buffer
无锁队列
snapshot

等更适合的设计。

所以现在不要形成:

复制代码
多线程 = atomic

这种错误理解。

你以后优先想到 TSan 的情况:

复制代码
程序偶发错误
但没有明显越界

比如:

复制代码
偶发帧数据异常

或者:

复制代码
计数器偶尔对不上

或者:

复制代码
控制命令偶发不一致

或者:

复制代码
切换状态时偶发崩溃

或者:

复制代码
关闭程序时偶发崩溃

这些都可能和:

复制代码
线程同步 / 生命周期

有关。

实际使用时:

通常不要把 ASan 和 TSan 一起启用。

你可以分两个构建版本:

复制代码
Build A
↓
ASan

Build B
↓
TSan

例如:

复制代码
-fsanitize=address

一个版本。

另一个:

复制代码
-fsanitize=thread

原因是它们的运行时机制不同,一般分开运行测试。

你现在记:

复制代码
内存错误 → ASan版本
线程错误 → TSan版本

就够了。

TSan 报告:

复制代码
data race

不代表:

复制代码
程序现在一定已经表现出错误

它可能只是告诉你:

按当前同步关系,这两个访问存在竞争。

这恰恰是它的价值。

因为很多并发 bug 在真正造成机器人异常之前,就可以被工具发现。


9、Valgrind

一句话:

Valgrind 是一套运行时分析工具,最常用来检查 C/C++ 的内存错误和内存泄漏。

你可以先把它放到这个位置:

复制代码
程序崩了
  → gdb / core dump

怀疑越界、use-after-free
  → ASan

怀疑线程竞争
  → TSan

怀疑内存泄漏、非法内存访问
  → Valgrind

程序太慢
  → perf

注意一个问题:

Valgrind 和 ASan 有很大重叠,但不是同一个东西。

这就是今天最重要的内容。


内存泄漏

例如:

复制代码
void foo()
{
    int* data = new int[100];

    // 忘了 delete[]
}

函数结束以后:

复制代码
data 这个指针变量消失了

但是:

复制代码
new int[100]

申请的内存没有被释放。

于是:

复制代码
程序还占着这块内存
但已经没有办法正常访问它

这就叫:

复制代码
memory leak
内存泄漏

如果只发生一次:

复制代码
400 bytes

可能根本看不出来。

但假设你的动捕程序:

复制代码
50 Hz

每一帧泄漏:

复制代码
1 KB

那么:

复制代码
每秒大约 50 KB
每分钟大约 3 MB
每小时大约 180 MB

程序可能:

复制代码
刚启动:200 MB
↓
运行 2 小时:600 MB
↓
运行 8 小时:越来越高
↓
最终内存不足

所以机器人现场非常典型的现象是:

复制代码
程序刚启动正常
↓
运行几个小时正常
↓
内存越来越高
↓
系统开始变慢
↓
甚至 OOM / 崩溃

这时候你应该立刻想到:

复制代码
memory leak

Memcheck

严格来说:

复制代码
Valgrind

不是单一工具。

它是一个工具框架。

其中最常用的是:

复制代码
Memcheck

所以平时执行:

复制代码
valgrind ./test

实际上通常就是:

复制代码
Valgrind + Memcheck

在帮你检查内存问题。

日常最常见:

复制代码
valgrind --leak-check=full ./test

如果还想知道更多泄漏来源:

复制代码
valgrind \
  --leak-check=full \
  --show-leak-kinds=all \
  ./test

你现在不需要背很多参数。

先记:

复制代码
--leak-check=full

就是:

给我详细查内存泄漏。

Valgrind 的输出经常会看到:

复制代码
definitely lost
indirectly lost
possibly lost
still reachable

definitely lost

这个最重要。

意思:

明确泄漏了。

例如:

复制代码
int* p = new int[100];
p = nullptr;

原来那块内存再也找不到了。

这种优先处理。


still reachable

意思大概是:

程序退出的时候这块内存还没有 free,但仍然存在指针能够找到它。

例如某些:

复制代码
全局缓存
第三方库内部缓存
runtime

可能在进程退出时没专门释放。

所以:

复制代码
still reachable

不一定就是严重 bug。

不要看到:

复制代码
Valgrind 不是 0

就认为程序一定有严重泄漏。

先看:

复制代码
definitely lost

它也能抓非法内存访问。

例如:

复制代码
int* p = new int[10];

p[20] = 123;

运行:

复制代码
valgrind ./test

可能报告:

复制代码
Invalid write of size 4

意思:

非法写内存。

例如:

复制代码
delete[] p;

std::cout << p[0];

可能看到:

复制代码
Invalid read of size 4

也就是:

已释放内存之后还在读。

所以它和 ASan 确实有重合。


那为什么有 ASan 还要 Valgrind?

这是今天最关键的问题。

先简单记:

复制代码
ASan
= 编译阶段把检测能力植入程序

Valgrind
= 在运行程序时动态监控

所以使用方式不一样。

ASan:

复制代码
g++ -fsanitize=address ...

必须:

复制代码
重新编译程序

Valgrind:

复制代码
valgrind ./program

很多情况下:

复制代码
已有程序就可以直接跑

当然,有调试符号会更好。

你可以形成这个习惯:

复制代码
日常开发
   ↓
优先 ASan

发现长期内存增长
   ↓
ASan / Valgrind

想详细检查泄漏
   ↓
Valgrind

没有方便重新编译
   ↓
考虑 Valgrind

严格性能测试
   ↓
两者都关掉

为什么我希望你优先 ASan?

因为:

复制代码
ASan 通常明显更快

Valgrind 会让程序:

复制代码
慢很多

有时候甚至:

复制代码
慢几十倍

所以大型机器人程序直接套 Valgrind:

复制代码
可能非常慢

循环重连泄漏

机器人驱动特别容易出现这种 bug。

例如:

复制代码
void reconnect()
{
    connection = new Connection();
}

第一次连接:

复制代码
new Connection A

掉线。

重新连接:

复制代码
new Connection B

但是 A 没释放。

再掉线:

复制代码
new Connection C

于是:

复制代码
A 泄漏
B 泄漏
C 当前正在用

如果网络非常稳定:

复制代码
可能几天都看不出问题

但现场网络不稳定:

复制代码
掉线
重连
掉线
重连

内存就不断增加。

这就是为什么:

异常路径和重连路径特别容易发生资源泄漏。

把这个记牢:

复制代码
ASan
│
├─ 编译时开启
├─ 运行速度相对快
├─ 非法内存访问定位非常强
├─ 适合开发 / CI
└─ 我建议日常优先使用

Valgrind
│
├─ 可以直接分析已有程序
├─ 不一定要求 sanitizer 编译
├─ 泄漏报告详细
├─ 运行非常慢
└─ 适合专项内存分析

不是:

复制代码
谁高级谁低级

而是:

复制代码
场景不同

                     C++程序异常
                          │
          ┌───────────────┼───────────────┐
          ↓               ↓               ↓
        崩溃           内存问题          线程问题
          │               │               │
     gdb / core       ASan / Valgrind      TSan
          │
          ↓
       调用栈

10、 perf

一句话:

perf = 用来分析 Linux 程序"CPU 时间到底花在哪里"的性能分析工具。

前面几个工具关注的是:

复制代码
gdb        → 为什么崩
core dump  → 崩溃现场
ASan       → 内存访问错误
TSan       → 多线程竞争
Valgrind   → 内存泄漏/非法访问

而 perf 关注的是:

复制代码
程序没崩
结果也对
但是太慢
CPU太高
实时循环跑不住

这类问题。


perf 和 top 有什么区别?

这一点很重要。

top 能告诉你:

复制代码
哪个进程 CPU 高

例如:

复制代码
PID    COMMAND       %CPU
3120   gmr_node      98.7

你知道:

gmr_node 很忙。

但你不知道:

复制代码
gmr_node 里面
到底哪个函数最忙?

perf 可以进一步告诉你:

复制代码
GmrSolver::solve()       52%
computeJacobian()        20%
protobuf::Serialize()     8%
memcpy()                  5%
...

所以:

复制代码
top
  ↓
找到哪个进程有问题

perf
  ↓
找到进程里的哪个函数有问题

这个关系要记住。

假设程序:

复制代码
./robot_control

可以直接:

复制代码
perf stat ./robot_control

这不是看具体函数,而是看整体性能统计。

可能看到:

复制代码
1.23 seconds time elapsed

3,200,000,000 cycles
2,100,000,000 instructions
...

你现在不用理解所有指标。

先知道:

perf stat = 看程序整体运行性能概况。


比 perf stat 更实用的是 perf top

如果程序已经在运行:

复制代码
sudo perf top

它会实时显示系统当前最吃 CPU 的函数。

可能看到:

复制代码
Overhead  Symbol

35.2%     GmrSolver::solve
18.7%     Eigen::internal::...
10.1%     memcpy
 7.4%     rclcpp::Executor...

这个东西可以理解成:

函数级的 top。

普通:

复制代码
top

看到:

复制代码
哪个进程忙

而:

复制代码
perf top

看到:

复制代码
哪个函数忙

perf record

假设:

复制代码
程序偶发性能异常

你不想一直盯着 perf top。

可以记录一段时间:

复制代码
perf record -g ./robot_control

这里:

复制代码
-g

表示:

记录调用栈。

运行一段时间以后结束程序。

会生成:

复制代码
perf.data

然后:

复制代码
perf report

打开分析结果。


perf record + perf report

这两个你可以当成一组。

复制代码
perf record
    ↓
采集性能数据
    ↓
perf.data
    ↓
perf report
    ↓
分析

类似我们前面:

复制代码
core dump
    ↓
gdb

这里是:

复制代码
perf.data
    ↓
perf report

sampling

理解 perf 最重要的概念之一。

假设程序运行:

复制代码
10 秒

perf 不会记录:

复制代码
CPU执行的每一条指令

那数据量太大。

它会隔一段时间:

复制代码
看一下 CPU 现在在哪里

比如采样:

复制代码
第1次:solve()
第2次:solve()
第3次:memcpy()
第4次:solve()
第5次:serialize()
...

最后发现:

复制代码
10000 次采样
其中 5000 次在 solve()

那么大致认为:

复制代码
solve() 占了约 50% CPU 时间

所以 perf 的本质之一是:

统计采样。

perf 的思维是:

复制代码
不要先猜
↓
采样
↓
看证据
↓
找到热点函数

这个和我们前面强调的调试原则一致:

先证据,再定位,再优化。


Hotspot

性能分析里经常看到:

复制代码
hotspot

意思不是软件名字。

它指:

CPU 时间集中消耗的热点代码。

例如:

复制代码
GmrSolver::solve        48%
computeJacobian         23%
matrixInverse           11%
logging                  7%
其他                     11%

那么:

复制代码
GmrSolver::solve

就是最大的热点。

优化的时候首先看:

复制代码
48%

而不是去优化:

复制代码
0.2%

的函数。


std::endl 为什么可能比 '\n' 贵?

因为:

复制代码
std::endl

除了换行,还会:

复制代码
flush

即:

强制刷新输出缓冲区。

所以:

复制代码
std::cout << value << std::endl;

高频调用可能比较贵。

而:

复制代码
std::cout << value << '\n';

通常不会每次强制 flush。

这就是一个很典型的:

复制代码
看起来只是日志
实际可能影响实时性

的问题。

整体统计

复制代码
perf stat ./program

实时热点

复制代码
sudo perf top

或者:

复制代码
sudo perf top -p PID

记录

复制代码
perf record -g ./program

查看

复制代码
perf report

够用了。

复制代码
程序 Segmentation Fault
→ gdb

昨天已经崩了
→ core dump

怀疑数组越界 / use-after-free
→ ASan

怀疑两个线程同时操作共享数据
→ TSan

内存一直涨
→ Valgrind

CPU 100% / 程序慢
→ perf

11、CMake

先记一句:

CMake 不是编译器,它是"构建系统生成工具"。

真正编译 C++ 的还是:

复制代码
g++
clang++

CMake做的是:

复制代码
你告诉 CMake:
有哪些 cpp
有哪些库
头文件在哪里
依赖谁
最后生成什么程序
        ↓
CMake 生成构建规则
        ↓
Ninja / Make
        ↓
g++ / clang++ 真正编译

所以不要把:

复制代码
CMake = 编译器

混在一起。

复制代码
PRIVATE
= 我自己用

PUBLIC
= 我自己用,下游也需要

INTERFACE
= 我自己不用,只给下游

先够了。

对于普通 C++ 项目,可以这样:

复制代码
robot_project/
│
├── CMakeLists.txt
│
├── include/
│   └── robot/
│       ├── driver.hpp
│       ├── safety.hpp
│       └── communication.hpp
│
├── src/
│   ├── driver.cpp
│   ├── safety.cpp
│   ├── communication.cpp
│   └── main.cpp
│
├── test/
│   ├── test_driver.cpp
│   └── test_safety.cpp
│
├── config/
│
└── README.md

然后逻辑上:

复制代码
driver
communication
safety
    ↓
robot_core library
    ↓
robot_app

这就比较清晰。再进一步:模块独立,项目更大时:

复制代码
robot_project/
├── mocap/
│   ├── include/
│   ├── src/
│   └── CMakeLists.txt
│
├── gmr/
│   ├── include/
│   ├── src/
│   └── CMakeLists.txt
│
├── safety/
│   ├── include/
│   ├── src/
│   └── CMakeLists.txt
│
├── app/
│   ├── main.cpp
│   └── CMakeLists.txt
│
└── CMakeLists.txt

顶层:

复制代码
add_subdirectory(mocap)
add_subdirectory(gmr)
add_subdirectory(safety)
add_subdirectory(app)

这时候:

复制代码
顶层 CMakeLists

主要管:

复制代码
整个工程由哪些子模块组成

而每个子目录:

复制代码
自己管理自己的 target

这就是大型 CMake 项目的基本思路。

很多初学项目最后:

复制代码
CMakeLists.txt
800 行

里面:

复制代码
各种路径
各种库
各种 flag
各种 if

最后没人敢改。

工程上应该逐渐做到:

复制代码
顶层
↓
工程整体配置

各模块
↓
自己负责自己的构建

和 C++ 类的思想其实类似:

复制代码
职责拆分

Core + Node Wrapper

例如:

复制代码
XsensUdpSource

写成普通 C++ 类:

复制代码
class XsensUdpSource
{
public:
    bool start();
    bool read(Frame& frame);
    void stop();
};

它本身:

复制代码
不依赖 ROS2

然后 ROS2 节点:

复制代码
class MocapDriverNode : public rclcpp::Node
{
    XsensUdpSource source_;
};

只负责:

复制代码
ROS2 Parameter
ROS2 Topic
Diagnostics
Lifecycle

这样:

复制代码
核心驱动逻辑

和:

复制代码
ROS2通信层

分开。

这是非常好的机器人软件结构。

复制代码
底层 SDK
   ↓
抽象接口
   ↓
核心库
   ↓
ROS2 封装
   ↓
系统集成

而不是:

复制代码
所有东西全部塞进一个 ROS2 callback

mocap_core
├── MotionInput
├── XsensUdpSource
├── AxisBvhSource
└── NoitomSource

mocap_ros
└── MocapDriverNode

这就有明显的软件工程味道。

复制代码
robot_system/
│
├── CMakeLists.txt
│
├── cmake/
│
├── include/
│   └── robot_system/
│       ├── mocap/
│       ├── safety/
│       └── communication/
│
├── src/
│   ├── mocap/
│   ├── safety/
│   └── communication/
│
├── apps/
│   └── robot_main.cpp
│
├── test/
│
├── config/
│
└── third_party/

不过注意:

不是说以后所有项目必须长这样。

目录结构只是为了:

复制代码
职责清晰
依赖清晰
测试方便

不是为了追求"目录越多越高级"。


CMake 和 ROS2 ament_cmake

以后 ROS2:

复制代码
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)

然后:

复制代码
add_executable(mocap_driver_node
    src/mocap_driver_node.cpp
)

可能还有:

复制代码
ament_target_dependencies(
    mocap_driver_node
    rclcpp
    sensor_msgs
)

最后:

复制代码
ament_package()

你不要把它看成完全另一套东西。

本质还是:

复制代码
CMake
+
ROS2 自己加的一套构建辅助

也就是说:

先懂普通 CMake,再学 ROS2 CMake,会轻松很多。

静态库 .a 动态库 .so
链接方式 代码进入最终程序 运行时加载库
最终程序体积 往往更大 往往更小
运行时是否需要库文件 通常不需要 需要
部署 简单 要保证 .so 能找到
库升级 通常要重新链接程序 某些情况下只换 .so
多程序共享 每个程序各有一份代码 可共享动态库

CMake 怎么生成静态库?

假设:

复制代码
src/
├── motion_input.cpp
└── xsens_udp_source.cpp

可以:

复制代码
add_library(mocap_core STATIC
    src/motion_input.cpp
    src/xsens_udp_source.cpp
)

编译后大致得到:

复制代码
libmocap_core.a

其中:

复制代码
STATIC

表示:

生成静态库。


怎么生成动态库?

把:

复制代码
STATIC

换成:

复制代码
SHARED

例如:

复制代码
add_library(mocap_core SHARED
    src/motion_input.cpp
    src/xsens_udp_source.cpp
)

得到:

复制代码
libmocap_core.so

所以:

复制代码
STATIC → .a
SHARED → .so

这是最基本的对应关系。程序怎么使用库?

假设:

复制代码
add_library(mocap_core SHARED
    src/xsens_udp_source.cpp
)

然后:

复制代码
add_executable(mocap_app
    src/main.cpp
)

程序需要这个库:

复制代码
target_link_libraries(mocap_app
    PRIVATE
    mocap_core
)

于是:

复制代码
mocap_app
    ↓
依赖
    ↓
mocap_core

这个和上一课 CMake target 的概念就连起来了。


Linux 去哪里找 .so?

先简单理解几个地方:

复制代码
/lib
/usr/lib
/usr/local/lib

以及:

复制代码
LD_LIBRARY_PATH

还有程序自身配置的:

复制代码
RPATH / RUNPATH

现在不用深入。

如果你的库在:

复制代码
/home/skr/robot/lib

临时可以:

复制代码
export LD_LIBRARY_PATH=/home/skr/robot/lib:$LD_LIBRARY_PATH

然后:

复制代码
./mocap_app

系统就会多去这个目录找。

以后编 C++ 你可以粗略理解为:

复制代码
① 预处理 / 编译
.cpp
↓
.o

然后:

复制代码
② 链接
.o + library
↓
executable

然后:

复制代码
③ 运行
如果依赖动态库
↓
动态加载器加载 .so

于是错误也可以分类:

复制代码
编译阶段
→ 头文件找不到 / 语法错误

链接阶段
→ undefined reference

运行阶段
→ libxxx.so: cannot open shared object file

这个框架非常有用。


API 和 ABI 先怎么理解?

这是以后 SDK 产品化非常重要的两个词。

API

复制代码
Application Programming Interface

简单说:

程序员看到、调用的接口。

例如:

复制代码
bool start();
Frame read();
void stop();

这就是 API。


ABI

复制代码
Application Binary Interface

可以先理解:

已经编译出来的二进制程序,和库之间怎么兼容。

涉及:

复制代码
函数符号
参数传递
对象布局
编译器
C++ name mangling

现在不用深挖。

先记:

复制代码
API
= 源代码层面的接口

ABI
= 二进制层面的兼容

gtest / CTest 到底是什么?

先区分两个东西。

复制代码
GoogleTest / gtest
= C++ 单元测试框架

它解决:

"我要怎么写测试代码?"

而:

复制代码
CTest
= CMake 自带的测试执行与管理工具

它解决:

"我有很多测试,怎么统一运行?"

所以可以理解成:

复制代码
gtest
↓
写测试

CTest
↓
组织、运行这些测试

它们经常一起使用,但不是同一个东西


EXPECT 和 ASSERT

这是 gtest 最常见的两个系列。

例如:

复制代码
EXPECT_EQ(a, b);

如果失败:

复制代码
记录失败
↓
继续执行当前测试

而:

复制代码
ASSERT_EQ(a, b);

如果失败:

复制代码
当前测试立即停止

所以简单记:

复制代码
EXPECT
= 错了还能继续往后测

ASSERT
= 这个条件一错,后面没必要测了

例如:

复制代码
ASSERT_NE(ptr, nullptr);

EXPECT_EQ(ptr->size(), 23);

这里非常合理。

因为如果:

复制代码
ptr == nullptr

那后面的:

复制代码
ptr->size()

就不应该继续执行。

复制代码
EXPECT_EQ(a, b);      // 相等
EXPECT_NE(a, b);      // 不相等

EXPECT_TRUE(x);
EXPECT_FALSE(x);

EXPECT_LT(a, b);      // <
EXPECT_LE(a, b);      // <=
EXPECT_GT(a, b);      // >
EXPECT_GE(a, b);      // >=

EXPECT_NEAR(a, b, tolerance);

机器人项目里:

复制代码
EXPECT_NEAR(...)

特别常用。

因为浮点数不要经常直接:

复制代码
EXPECT_EQ(0.1 + 0.2, 0.3);

更常见:

复制代码
EXPECT_NEAR(result, expected, 1e-6);

测试 Command Timeout

例如:

复制代码
如果超过 100 ms 没收到新命令
→ fault

那至少应该测试:

复制代码
50 ms
→ 不 fault

99 ms
→ 不 fault

100/101 ms
→ 按你的规则判断

500 ms
→ fault

注意这里体现一个重要思想:

安全逻辑特别适合自动测试,因为边界条件非常明确。


12、Fixture

如果每个测试都要:

复制代码
SafetyLayer safety;
safety.configure(...);
safety.reset();

会重复很多代码。

gtest 提供:

复制代码
Test Fixture

你可以理解成:

给一组测试准备公共环境。

例如:

复制代码
class SafetyLayerTest : public ::testing::Test
{
protected:
    void SetUp() override
    {
        safety.configure(-2.0, 2.0);
    }

    SafetyLayer safety;
};

之后:

复制代码
TEST_F(SafetyLayerTest, ValidCommand)
{
    EXPECT_TRUE(safety.checkJointPosition(1.0));
}

TEST_F(SafetyLayerTest, InvalidCommand)
{
    EXPECT_FALSE(safety.checkJointPosition(3.0));
}

注意:

复制代码
TEST

变成:

复制代码
TEST_F

F 可以先理解为:

复制代码
Fixture

SetUp 和 TearDown

Fixture 经常有:

复制代码
SetUp()

和:

复制代码
TearDown()

可以简单理解:

复制代码
SetUp
↓
每个测试开始前准备

测试

TearDown
↓
每个测试结束后清理

例如驱动测试:

复制代码
SetUp
→ 创建 mock driver

Test
→ 测 read/start/stop

TearDown
→ 释放资源

mock

这里先简单认识。

你要测试:

复制代码
RobotController

但是它内部依赖:

复制代码
真实 EtherCAT

难道每次测试都连接电机?

当然不应该。

可以做:

复制代码
MockEthercat

假装自己是 EtherCAT。

例如:

复制代码
RobotController
       ↓
EthercatInterface
       ↑
   MockEthercat

测试时:

复制代码
不发真实电机
↓
只记录 controller 想发什么
↓
验证结果

这就是:

复制代码
mock

以后学 ros2_control 的:

复制代码
Mock Hardware

你会再次碰到这个思想。


CMake 怎么接 gtest

典型结构:

复制代码
project/
├── include/
├── src/
├── test/
│   └── test_safety.cpp
└── CMakeLists.txt

CMake:

复制代码
find_package(GTest REQUIRED)

add_executable(test_safety
    test/test_safety.cpp
)

target_link_libraries(test_safety
    PRIVATE
    safety_core
    GTest::gtest_main
)

这样:

复制代码
test_safety

本身就是一个可执行程序。

可以直接:

复制代码
./test_safety

CMake 里启用 CTest

通常:

复制代码
enable_testing()

然后可以:

复制代码
add_test(
    NAME safety_test
    COMMAND test_safety
)

意思:

复制代码
CTest
↓
知道有一个测试叫 safety_test
↓
执行 test_safety

然后:

复制代码
ctest

可能输出:

复制代码
Test #1: safety_test ... Passed
Test #2: queue_test  ... Passed
Test #3: parser_test ... Passed

机器人项目里先建立三层:

复制代码
L1 单元测试
↓
单个函数 / 类

L2 模块测试
↓
driver / queue / safety / parser

L3 集成测试
↓
多个模块连起来

例如你的项目:

复制代码
L1
SafetyLayer::checkCommand()

L2
XsensUdpSource + parser

L3
mocap_driver
↓
GMR
↓
ZMQ

再往后才是:

复制代码
真机测试

你后面正式做时,我建议至少覆盖:

复制代码
正常 command
NaN / Inf
joint position 越界
velocity 越界
torque 越界
command timeout
communication timeout
fault 状态不接受运动命令
reset fault
safe disable

这会让你的安全保护设计从:

复制代码
"代码里写了几个 if"

升级成:

行为有自动化验证。

而且要注意,这依然只是软件安全保护,不等于功能安全认证。


regression

以前修好的问题,后来改代码又出现了。

比如:

复制代码
v1.0 修复 timeout bug

到了:

复制代码
v1.3

有人重构:

复制代码
bug 又回来

如果有回归测试:

复制代码
CI 立即失败

所以一个 bug 的理想处理过程:

复制代码
发现 bug
↓
建立复现
↓
写测试
↓
修复
↓
以后一直保留测试
相关推荐
程序员老陆1 小时前
C++ std::promise深度解析
c++·程序设计
青少儿编程课堂2 小时前
迪杰斯特拉(Dijkstra)算法:单源最短路与赛场路线规划
c++·python·算法·bfs·信息学竞赛
志尊宝2 小时前
Vue3 零基础每日笔记(071):暗黑模式与主题切换——CSS 变量 + Element Plus 暗黑方案
前端·css·笔记·vue·html·前端开发·软件开发
code2cat2 小时前
Java进阶篇之AtomicLongFieldUpdater:把累计额度留在对象字段中
java·cas·并发编程
WeiXin_DZbishe2 小时前
基于springboot城市公交实时运营与调度系统-计算机毕设 附源码【课程设计】66173
java·vue.js·spring boot·后端·python·php·课程设计
泡茶喝茶写代码2 小时前
A股量化数据工程:从 REST 接口到策略信号(第 18 篇):放量缩量异动扫描
java·python·股票数据api·股票数据api接口·股票api数据接口·股票量化数据api·股票量化数据接口
刘发财2 小时前
前端如何生成10000页PDF文件?
前端·javascript·开源
wewowilling3 小时前
认识微服务
java·运维·微服务·springcloud