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), 
               &timestamp, 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(&timestamp, 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章:C++编程核心(下)知识体系 多线程编程 文件I/O与序列化 设计模式 std::thread std::mutex 死锁避免 fstream 二进制序列化 单例模式 工厂模式 观察者模式 应用场景:传感器融合、运动控制、状态持久化、模块解耦

4.5 避坑总结与最佳实践

最后,我把这些年踩过的坑和积累的经验,整理成一张表格。你写代码时对照着看,能省不少调试时间。

知识点 常见坑 最佳实践
std::thread 忘记join导致崩溃 RAII封装,析构时自动join
互斥锁 忘记解锁或死锁 优先用std::lock_guard
文件I/O 路径写死,跨平台失败 用配置文件定义路径
二进制序列化 字节序不一致 统一用小端序,或加字节序标记
单例模式 线程安全问题 C++11局部静态变量
工厂模式 类型字符串硬编码 用枚举或类型注册表
观察者模式 观察者忘记注销导致野指针 用weak_ptr管理观察者

我的个人建议:刚开始学多线程时,别一上来就搞复杂的锁策略。先用std::lock_guard和std::async把基础功能跑通。设计模式也一样,别为了用模式而用模式。当你的代码出现大量重复的if-else或者全局变量满天飞时,再考虑用模式重构。记住:模式是工具,不是目的。