接下来的主线为:
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
↓
建立复现
↓
写测试
↓
修复
↓
以后一直保留测试