🤖 roboto_origin_03 Wiki
首页 / IMU 子模块 / 串口通信与 UART 接收线程

本页聚焦 IMU 驱动中的串口通信子系统,从 POSIX 串口配置、实时接收线程的调度策略,到字节流状态机解析与线程安全的数据交付,逐层拆解 UART 侧的数据通路。如果你已了解项目整体模块划分与工厂创建机制,阅读本页将帮助你掌握串口独占式封装的设计取舍及其与 CAN 总线共享模型的根本差异。

Sources: serial_port.hpp, imu_driver.cpp

串口通信的架构定位

roboto_imu 的通信协议层中,串口与 CAN 被抽象为两种互斥的底层传输介质。当 IMUDriver::create_imu() 接收 interface_type="serial" 时,工厂模式 会实例化 HipnucIMUDriver,后者通过 IMUSerialPort::open() 获取一个独占的串口会话。与 CAN 侧的 SocketCAN 单例与实时接收线程 不同,串口模型不共享文件描述符,每个 IMU 实例拥有独立的 fd_ 与独立的 rx_thread_,这意味着串口侧天然避免了总线仲裁与多设备 ID 过滤的复杂性,但也要求每个串口设备消耗一个内核线程。

classDiagram
    class IMUDriver {
        +create_imu()
        +get_quat()
        +get_ang_vel()
        +get_lin_acc()
    }
    class HipnucIMUDriver {
        -serial_rx_cbk()
        -serial_ : shared_ptr~IMUSerialPort~
        -imu_mutex_ : shared_mutex
    }
    class IMUSerialPort {
        -fd_ : int
        -rx_thread_ : thread
        -running_ : atomic~bool~
        -callback_ : SerialCbkFunc
        +open()$ shared_ptr~IMUSerialPort~
        +set_serial_callback()
        +close()
    }
    IMUDriver <|-- HipnucIMUDriver
    HipnucIMUDriver --> IMUSerialPort : owns
    HipnucIMUDriver ..> IMUSerialPort : std::bind serial_rx_cbk

IMUSerialPort 被设计为不可拷贝的 RAII 资源持有者:拷贝构造函数与赋值运算符被显式删除,确保每个串口句柄在生命周期内唯一对应一个内核文件描述符与一条接收线程。这种设计消除了多实例竞争同一 fd_ 的风险,同时也通过 std::shared_ptr 的引用计数让 HipnucIMUDriver 与外部调用者可以安全地传递串口所有权而不触发悬空句柄。

Sources: serial_port.hpp, hipnuc_imu_driver.cpp

POSIX 串口配置与 termios 参数

IMUSerialPort::init() 在构造阶段完成全部 POSIX 终端配置,调用流程遵循经典的 open → tcgetattr → 修改 c_cflag/c_lflag/c_iflag/c_oflag → tcsetattr 四步范式。打开标志使用 O_RDWR | O_NOCTTY | O_NDELAY,其中 O_NOCTTY 防止串口成为控制终端,O_NDELAY 使 open() 非阻塞。波特率通过离散映射表转换为 termios 的速度常量,当前支持 9600 至 921600 八档标准速率,未匹配值默认回落到 115200。

控制模式 (c_cflag) 被硬编码为 8N1 格式:字符长度 8 位 (CS8)、无校验 (~PARENB)、1 位停止位 (~CSTOPB),并启用本地连接与接收器 (CLOCAL | CREAD),同时禁用硬件流控 (~CRTSCTS)。本地模式 (c_lflag) 关闭规范输入、回显及信号字符处理,使接口处于原始模式;输入模式 (c_iflag) 屏蔽所有软件流控与特殊字符转换;输出模式 (c_oflag) 关闭所有 post-processing。VMIN = 0VTIME = 1 的组合使 read() 在至少有一个字节到达时立即返回,否则在 100 ms 后超时,避免阻塞式读取对接收线程退出信号的延迟响应。不过实际读取策略并未依赖该超时,而是由外层 select() 的 1 ms 超时接管,因此 VTIME 在此主要充当 termios 的保守兜底。

Sources: serial_port.cpp

实时接收线程:select 模型与调度策略

串口数据接收不是阻塞式 read() 的简单循环,而是采用 select() I/O 多路复用模型。rx_thread_init() 中通过 lambda 启动,主循环以 1 ms 为周期轮询可读事件。该设计带来三个关键收益:第一,select()tv_usec = 1000 的超时设置下,使 running_.load() 的检查频率达到 1 kHz,从而保证 close() 设置 running_ = false 后,线程能在毫秒级内感知并退出,避免长时间挂起;第二,select() 错误路径对 EINTR 的显式处理确保了信号中断不会导致线程异常终止;第三,单文件描述符场景下 select() 的复杂度开销极低,且比 epoll() 更简洁,比纯轮询更节省 CPU。

线程启动后立即通过 pthread_setname_np 将 OS 线程名设为 "serial_rx",便于在 top -Hps -L 中快速定位。更为关键的是实时调度策略的注入:线程被显式设置为 SCHED_FIFO,优先级 80。SCHED_FIFO 属于先进先出的实时调度类,在同优先级下不会被普通 SCHED_OTHER 线程抢占,从而保证 IMU 数据流在高系统负载下仍能以低抖动延迟被读入用户态。需要指出的是,该调用失败不会终止线程,仅记录错误日志,这是因为某些运行环境(如未配置 rtprio 的容器或普通用户会话)可能缺乏实时调度权限,驱动在此选择了降级运行的容错策略。

flowchart TD
    A[IMUSerialPort::init] -->|启动 lambda| B[rx_thread_]
    B --> C[pthread_setname_np: serial_rx]
    B --> D[pthread_setschedparam: SCHED_FIFO, 80]
    D --> E{select fd_ with 1ms timeout}
    E -->|ret > 0| F[read fd_ into buf]
    E -->|ret == 0| E
    E -->|ret < 0 & EINTR| E
    E -->|ret < 0 & other| G[break loop]
    F --> H{callback_ set?}
    H -->|yes| I[invoke callback_]
    H -->|no| E
    I --> E
    G --> J[thread exits]
    K[IMUSerialPort::close] --> L[running_ = false]
    L --> M[join rx_thread_]
    M --> N[::close fd_]

线程退出路径由 close() 控制:running_ 原子变量被置为 false 后,若 rx_thread_ 可汇合则执行 join(),随后关闭文件描述符并记录日志。该路径在析构函数中自动调用,因此串口资源的生命周期与 IMUSerialPort 实例严格绑定。

Sources: serial_port.cpp

从字节流到结构化数据:回调与协议解码

IMUSerialPort 本身只做物理层搬运,不触碰协议语义。上层通过 set_serial_callback() 注册 std::function<void(const uint8_t*, size_t)> 类型的回调。在 HipnucIMUDriver 的构造阶段,该回调被绑定为成员函数 serial_rx_cbk,于是 UART 每读取一包原始字节,便直接流入驱动实例的私有上下文。

serial_rx_cbk 持有一个 std::unique_lock<std::shared_mutex> 写锁,随后将字节流逐字节喂给 C 语言实现的 hipnuc_input() 状态机。该解码器采用双字节同步头 0x5A 0xA5 进行帧对齐,接着读取 6 字节固定头部(含 2 字节载荷长度),待完整帧到达后执行 CRC16 校验,最终调用 parse_data()0x910x920x810x83 等不同 PID 的数据包映射到 hipnuc_raw_t 结构体的相应字段。解码成功后,HipnucIMUDriverraw_.hi91 中的四元数、角速度、线加速度等数据转存到 sensor_data_,期间执行单位换算(加速度乘以 GRA_ACC 9.8,角速度乘以 DEG_TO_RAD)。

值得注意的是,回调在串口接收线程的上下文中同步执行,这意味着 hipnuc_input() 的解析耗时将直接增加 UART 读循环的延迟。当前实现中解析逻辑为纯内存操作,无动态分配与阻塞调用,因此该同步模型在典型 HI91 包长下不会构成瓶颈;但若未来协议变得复杂,可考虑在回调内部仅做零拷贝入队,将解析逻辑后移到独立的工作线程。

Sources: hipnuc_imu_driver.cpp, hipnuc_dec.c

线程安全:shared_mutex 的读写分离

HipnucIMUDriver 使用 C++17 std::shared_mutex 作为传感器数据的并发原语。串口接收线程在写入 sensor_data_ 前获取独占锁 (unique_lock),而 get_quat()get_ang_vel()get_lin_acc()get_temperature() 等查询接口则获取共享锁 (shared_lock)。这种多读单写模型非常适合 IMU 场景:数据写入频率由 UART 波特率与包率决定(通常数百 Hz),而读取频率可能来自 ROS 2 节点或 Python 应用的主循环,共享锁允许多个读取者并发访问而不互相阻塞。

sequenceDiagram
    participant U as User Thread
    participant S as serial_rx Thread
    participant M as shared_mutex
    participant D as sensor_data_

    S->>M: unique_lock (write)
    S->>D: update quat/gyr/acc
    S->>M: unlock
    U->>M: shared_lock (read)
    U->>D: copy quat vector
    U->>M: unlock

不过需要意识到,该设计提供的是快照一致性而非跨字段原子一致性:如果读取者在共享锁保护下分别调用 get_quat()get_ang_vel(),两次调用之间串口线程可能已完成一次新的写操作,导致返回的四元数与角速度来自相邻但不同时刻的采样。对于要求严格时间对齐的下游算法,建议后续扩展一次性读取全部字段的批量接口,在单次共享锁内完成拷贝。

Sources: hipnuc_imu_driver.hpp, hipnuc_imu_driver.cpp

串口与 CAN 的设计差异速览

为了帮助架构决策,下表从资源模型、线程策略、数据分发三个维度对比串口与 CAN 在 roboto_imu 中的实现差异:

维度 串口 (IMUSerialPort) CAN (IMUSocketCAN)
文件描述符 每个 IMU 实例独占一个 fd_ 全进程共享一个 SocketCAN 套接字
接收线程 每个实例独立 rx_thread_ 单例内部仅一个全局接收线程
实时调度 SCHED_FIFO,优先级 80 SCHED_FIFO,优先级 80
I/O 模型 select(),1 ms 超时轮询 select() / poll() 轮询
数据分发 回调直接绑定到 HipnucIMUDriver 通过 can_id 掩码路由到多个回调
多设备支持 每个设备需独立物理串口 同一 CAN 总线可挂载多设备
协议解析 hipnuc_input() 字节流状态机 hipnuc_j1939_parse_frame() 帧解析

串口模型的优势在于隔离性强、配置简单、无需总线仲裁;劣势是可扩展性受物理 UART 端口数量限制,且每个设备带来一个内核线程的调度开销。CAN 模型则更适合多传感器级联场景,但引入了单例生命周期管理与 ID 冲突规避的复杂度。

Sources: serial_port.cpp, hipnuc_imu_driver.cpp

生命周期管理与异常处理

IMUSerialPort 的异常策略遵循构造即初始化、异常即终止的严格语义。init() 中的每一步失败——open() 返回负值、tcgetattr()tcsetattr() 出错——都会立即记录 spdlog 错误日志,关闭已打开的文件描述符,并抛出 std::runtime_error。这种设计确保了一个处于半初始化状态的串口对象不会被交付给上层使用。由于构造过程可能抛异常,工厂方法 open() 使用 new IMUSerialPort() 在堆上分配,再由 std::shared_ptr 接管,避免栈对象在异常时产生未定义行为。

close() 方法实现了幂等的资源释放:running_ 被置 false,线程被汇合,fd_ 被关闭并设为 -1。即使多次调用 close(),由于 fd_ < 0 的守卫判断和 joinable() 的检查,也不会触发双重关闭或重复 join。析构函数仅委托给 close(),因此无论是显式调用 close() 还是 shared_ptr 引用计数归零,资源回收路径完全一致。

Sources: serial_port.cpp

延伸阅读与下一步

串口通信与 UART 接收线程只是完整数据通路的前半段。要理解字节如何被解码为物理量,请继续阅读 超核私有协议解码;若你的部署场景使用 CAN 而非串口,请参阅 SocketCAN 单例与实时接收线程CANopen TPDO 解析。对于关心传感器数据并发访问细节的开发者,传感器数据访问与线程安全 提供了更全局的锁策略分析。最后,若需要调整波特率或自动化串口初始化,可参考 波特率配置与初始化脚本