4. C++编程核心(下):多线程、文件I/O与设计模式在机器人中的应用
好,我们接着往下聊。上一章我们把C++的现代特性、内存管理这些基础打牢了,这一章要进入真正让机器人“动起来”和“活起来”的核心地带。多线程、文件读写、还有那几个经典的设计模式,在机器人系统里几乎是天天见。我个人觉得,这部分学好了,你才算真正摸到了嵌入式机器人开发的边。
本章核心脉络:多线程让机器人“一心多用”,文件I/O让机器人“记住东西”,设计模式让代码“经得起折腾”。这三板斧砍下去,你的机器人代码才算有了工业级的影子。
4.1 多线程编程:让机器人“一心多用”
你想想看,一个机器人要同时处理传感器数据、控制电机、还要跟主控通信。如果所有事情排队做,那延迟得有多高?多线程就是来解决这个问题的。
4.1.1 std::thread 的基本用法
C++11开始,标准库直接提供了std::thread,跨平台,用起来也简单。我在项目中最早用pthread,后来全面转向了std::thread,代码干净多了。
#include <thread>
#include <iostream>
void motorControl(int id) {
std::cout << "电机 " << id << " 开始运转" << std::endl;
// 模拟控制周期
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
int main() {
std::thread t1(motorControl, 1);
std::thread t2(motorControl, 2);
t1.join(); // 等待线程结束
t2.join();
return 0;
}
嗯,这里要注意:join()一定要调用,否则程序退出时线程还没跑完,会直接崩溃。我曾经在调试一个四足机器人时,忘了join一个传感器线程,结果每次关机都core dump,查了半天才找到原因。
4.1.2 数据竞争与互斥锁
多线程最大的坑是什么?数据竞争。两个线程同时写一个变量,结果不可预测。机器人里最常见的场景就是多个传感器线程往同一个缓冲区写数据。
#include <mutex>
std::mutex mtx;
int sharedData = 0;
void sensorTask() {
std::lock_guard<std::mutex> lock(mtx);
sharedData++; // 安全自增
// lock_guard 析构时自动解锁
}
避坑指南:我曾经在AGV项目中,因为忘记加锁,两个线程同时更新里程计数据,结果机器人定位直接漂移了半米。记住:只要有一个线程在写,所有读写的访问都要加锁。别偷懒,偷懒的代价是机器人在墙上撞出一个坑。
4.1.3 死锁与避免策略
锁用多了,死锁就来了。两个线程各自拿着一把锁,等对方释放,谁也动不了。机器人一旦死锁,那就是原地“死机”。
我个人的习惯是:尽量用std::lock一次性锁多个互斥量,或者按固定顺序加锁。下面这个例子展示了安全的多锁操作:
std::mutex mtx1, mtx2;
void safeTask() {
std::lock(mtx1, mtx2); // 同时锁住两个
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
// 安全操作共享资源
}
4.2 文件I/O与序列化:让机器人“记住”状态
机器人每次启动都要重新标定?那太原始了。配置文件、日志、地图数据,都需要持久化存储。C++的文件流和序列化技术,就是干这个的。
4.2.1 标准文件流操作
std::fstream家族三兄弟:ifstream读、ofstream写、fstream读写。机器人里最常用的是写日志和读配置文件。
#include <fstream>
#include <string>
void writeLog(const std::string& msg) {
std::ofstream logFile("robot.log", std::ios::app);
if (logFile.is_open()) {
logFile << msg << std::endl;
}
}
void readConfig() {
std::ifstream config("config.txt");
std::string line;
while (std::getline(config, line)) {
// 解析配置项
}
}
小技巧:写日志时一定要用std::ios::app追加模式,否则每次启动都会覆盖之前的日志。我见过有人调试机器人时,日志文件永远只有最后一条记录,查历史数据全靠猜。
4.2.2 二进制序列化与反序列化
文本文件虽然可读,但效率低。机器人里传输地图数据、点云数据,必须用二进制。我习惯自己写序列化函数,结构清晰,没有第三方依赖。
#include <cstring>
struct RobotPose {
float x, y, theta;
uint32_t timestamp;
void serialize(char* buffer) const {
memcpy(buffer, &x, sizeof(x));
memcpy(buffer + sizeof(x), &y, sizeof(y));
memcpy(buffer + sizeof(x) + sizeof(y), &theta, sizeof(theta));
memcpy(buffer + sizeof(x) + sizeof(y) + sizeof(theta),
×tamp, sizeof(timestamp));
}
void deserialize(const char* buffer) {
memcpy(&x, buffer, sizeof(x));
memcpy(&y, buffer + sizeof(x), sizeof(y));
memcpy(&theta, buffer + sizeof(x) + sizeof(y), sizeof(theta));
memcpy(×tamp, buffer + sizeof(x) + sizeof(y) + sizeof(theta),
sizeof(timestamp));
}
};
为什么不用JSON或XML?在嵌入式机器人上,解析JSON的开销太大。二进制序列化直接内存拷贝,速度是文本解析的几十倍。当然,前提是你得处理好字节序和内存对齐。
4.3 设计模式在机器人中的应用
设计模式不是花架子,是无数前人踩坑踩出来的经验。机器人系统里,单例、工厂、观察者这三个模式,我几乎每个项目都用。
4.3.1 单例模式:全局唯一的硬件管理器
一个机器人只有一个电机控制器、一个激光雷达。如果到处都能new一个,那不乱套了?单例模式保证全局只有一个实例。
class MotorController {
public:
static MotorController& getInstance() {
static MotorController instance;
return instance;
}
void setSpeed(int rpm) { /* 控制电机 */ }
private:
MotorController() {} // 私有构造函数
MotorController(const MotorController&) = delete;
MotorController& operator=(const MotorController&) = delete;
};
C++11以后,用局部静态变量实现单例是线程安全的。我早期用双检锁,代码又长又容易出错。现在这个写法,简洁、安全、高效。
注意:单例模式虽好,但别滥用。我曾经在一个项目里,把传感器数据缓存也做成单例,结果单元测试时状态互相污染,查了一整天。单例只适合那些“物理上唯一”的资源。
4.3.2 工厂模式:灵活创建不同类型的传感器
机器人可能用激光雷达、超声波、红外线。如果代码里写死new Lidar(),换传感器就得改代码。工厂模式把创建逻辑封装起来,扩展起来特别方便。
class Sensor {
public:
virtual void readData() = 0;
virtual ~Sensor() {}
};
class Lidar : public Sensor {
public:
void readData() override { /* 激光雷达读取 */ }
};
class Ultrasonic : public Sensor {
public:
void readData() override { /* 超声波读取 */ }
};
class SensorFactory {
public:
static std::unique_ptr<Sensor> create(const std::string& type) {
if (type == "lidar") return std::make_unique<Lidar>();
if (type == "ultrasonic") return std::make_unique<Ultrasonic>();
return nullptr;
}
};
你看,加一个新传感器,只需要新增一个类,然后在工厂里加一行。代码符合开闭原则,对扩展开放,对修改关闭。
4.3.3 观察者模式:事件驱动的传感器数据分发
机器人里,传感器数据来了,导航模块要、避障模块要、状态监控也要。如果每个模块都去轮询传感器,CPU全浪费在空转上了。观察者模式让传感器主动通知所有订阅者。
class Observer {
public:
virtual void update(float distance) = 0;
};
class SensorSubject {
std::vector<Observer*> observers;
public:
void attach(Observer* obs) { observers.push_back(obs); }
void notify(float distance) {
for (auto obs : observers) {
obs->update(distance);
}
}
void newDataReady(float dist) {
notify(dist); // 数据来了,通知所有观察者
}
};
我个人习惯用这个模式处理IMU数据。IMU以100Hz的频率输出数据,导航、姿态解算、振动检测各自注册为观察者。数据一来,大家各取所需,互不干扰。
4.4 本章知识体系总览
下面这张图,把本章的核心知识点串起来了。你可以把它当作一个快速索引,复习时对照着看。
4.5 避坑总结与最佳实践
最后,我把这些年踩过的坑和积累的经验,整理成一张表格。你写代码时对照着看,能省不少调试时间。
| 知识点 | 常见坑 | 最佳实践 |
|---|---|---|
| std::thread | 忘记join导致崩溃 | RAII封装,析构时自动join |
| 互斥锁 | 忘记解锁或死锁 | 优先用std::lock_guard |
| 文件I/O | 路径写死,跨平台失败 | 用配置文件定义路径 |
| 二进制序列化 | 字节序不一致 | 统一用小端序,或加字节序标记 |
| 单例模式 | 线程安全问题 | C++11局部静态变量 |
| 工厂模式 | 类型字符串硬编码 | 用枚举或类型注册表 |
| 观察者模式 | 观察者忘记注销导致野指针 | 用weak_ptr管理观察者 |
我的个人建议:刚开始学多线程时,别一上来就搞复杂的锁策略。先用std::lock_guard和std::async把基础功能跑通。设计模式也一样,别为了用模式而用模式。当你的代码出现大量重复的if-else或者全局变量满天飞时,再考虑用模式重构。记住:模式是工具,不是目的。