Wiki
串口通信串口包协议实例
串口是机器人里最常见的通信方式之一:上位机 ROS2 / Linux 程序通过 USB-TTL、USB-CAN 转串口、CH340、CP2102、FT232 等设备和 STM32、ESP32、下位机控制板通信。
本节依旧按照“先准备环境,再看怎么调用,最后运行观察结果”的顺序:
克隆 Serial_Pack
先学 ROS2 端串口驱动:打开、接收、发送
再学跨平台串口包协议:注册命令、打包、解包
最后用虚拟串口或 STM32 验证
串口接收和 ROS2 定时器等异步回调统一使用 std::bind。协议的命令处理函数需要明确的参数类型,直接注册普通函数或成员函数。
1.获取项目
第一次拿到项目,先只克隆这一个仓库。下面统一以克隆到用户主目录的 ~/Serial_Pack 为例:
cd ~
git clone https://github.com/tungchiahui/Serial_Pack.git
cd Serial_Pack
本教程会用到以下文件,路径都从 Serial_Pack/ 算起:
example/ros2_ws/src/serial_comm/ ROS2 演示节点
example/ros2_ws/src/serial_transport/ PC 端串口驱动
example/ros2_ws/src/wire_protocol/ 不依赖 ROS2 的 C++20 串口包协议
example/stm32_hal/ STM32CubeMX + HAL 接入例程
只需要这个仓库。PC 端使用现成的 ROS2 工作区;STM32 端从 example/stm32_hal/TEST.ioc 用 CubeMX 生成工程,再接入同目录的 applications、bsp 和 cmake/user。
2.Linux 下准备ASIO库
2.1.本库只依赖standalone asio
Ubuntu / Debian:
sudo apt install libasio-dev
Fedora:
sudo dnf install asio-devel
2.2.选装完整的boost库(没啥必要针对于完整的程序)
Ubuntu / Debian:
sudo apt install libboost-all-dev
Fedora:
sudo dnf install boost-devel
3.Linux 下准备虚拟串口测试环境
如果你手里没有真实 STM32,可以用 socat 创建一对虚拟串口。
3.1.安装 socat
Ubuntu / Debian:
sudo apt install socat
Fedora:
sudo dnf install socat
3.2.创建一对虚拟串口
新开一个终端,运行:
socat -d -d \
PTY,raw,echo=0,link=/tmp/ttyV0 \
PTY,raw,echo=0,link=/tmp/ttyV1
你会看到类似输出:
2026/05/24 12:00:00 socat[12345] N PTY is /dev/pts/3
2026/05/24 12:00:00 socat[12345] N PTY is /dev/pts/4
2026/05/24 12:00:00 socat[12345] N starting data transfer loop with FDs [5,5] and [7,7]
这表示:
/dev/pts/3 和 /dev/pts/4 是一对互通的虚拟串口
程序写 /dev/pts/3,另一个终端读 /dev/pts/4 就能看到
程序读 /dev/pts/3,另一个终端写 /dev/pts/4 就能发给程序
同时我也给了他们别名
/dev/pts/3 和 /tmp/ttyV0是同一个东西
/dev/pts/4 和 /tmp/ttyV1是同一个东西
下面示例使用 /tmp/ttyV0 和 /tmp/ttyV1,不用猜测每次变化的 /dev/pts/N。保持这个 socat 终端运行。
4.第一部分:ROS2 端串口驱动 serial_transport
4.1.程序目标
先只关心一件事:ROS2 节点怎样打开一个串口、收到一段字节、发出一段字节。此时不要求看懂串口包的内容;下一部分才把“字节”解释为命令和字段。
PC 端公开给我们使用的主要是两个类型:
serial_transport::Serial_Config:保存设备名、波特率、数据位、校验位、停止位和流控。本例使用 115200 8N1、无流控。serial_transport::SerialTransport:提供start(config, 回调)、async_write(字节)和stop()。
4.2.先看调用顺序(伪代码)
这里故意只写使用顺序,不展开 Asio 线程和串口驱动源码:
ROS2 节点初始化:
从参数读取 port 和 baud_rate
填写 Serial_Config:设备名、115200、8N1、无流控
driver.start(config, std::bind(本节点的接收函数, this, 第一个参数))
接收函数(本次收到的字节片段):
把片段交给后面的协议 feed()
需要发送时:
driver.async_write(待发送的字节)
节点退出时:
driver.stop()
这些调用对应 example/ros2_ws/src/serial_comm/src/serial_comm.cpp 中的 serial_config_ 和 serial_driver_。示例从 ROS2 参数 port、baud_rate 读取设备名和波特率,其余配置固定为 8N1、无流控。
4.3.关键函数说明
4.3.1.SerialTransport::start()
函数原型:
bool start(
Serial_Config config,
std::function<void(std::span<const uint8_t>)> receive_callback);
作用:启动串口驱动,尝试按配置打开设备,并开始异步接收。收到字节后调用接收回调。
参数:
config:Serial_Config,串口配置,包含设备名port_name_、波特率baud_rate_、数据位character_size_、校验位parity_、停止位stop_bits_和流控flow_control_。receive_callback:std::function<void(std::span<const uint8_t>)>,收到字节时执行的函数。它的参数是本次收到的字节片段,不是保证完整的一帧。std::span不拥有数据,只在本次回调中使用;需要留存时自行复制。
返回值:bool。首次启动返回 true;同一个驱动对象已经启动时返回 false。true 只表示启动流程已提交,不代表物理串口已经打开。设备暂时不存在时驱动会尝试重连,要以终端的 串口打开成功 日志确认实际打开。
4.3.2.SerialTransport::async_write()
函数原型:
void async_write(std::span<const uint8_t> frame);
作用:把一段字节提交给串口驱动异步发送。
参数:
frame:std::span<const uint8_t>,表示待发送字节的起始位置和长度。可以传pack()返回的std::array;当前驱动在调用时会复制这段数据,所以局部帧数组在函数返回后可以销毁。
返回值:void,没有发送成功状态。这次调用不等待对端确认;如果物理串口尚未打开,提交的帧不会在重连后补发。
4.3.3.SerialTransport::stop()
函数原型:
void stop();
作用:停止接收、关闭串口并等待驱动线程退出。
参数:无。
返回值:void。SerialTransport 析构时也会调用它;接收回调使用的节点对象必须活到驱动停止以后。
到这里,我们只知道怎样搬运字节。接下来才解决“哪些字节属于同一条命令、收到后交给谁处理”。
5.第二部分:跨平台串口包通信协议 wire_protocol
wire_protocol 是纯 C++20:ROS2/Linux 程序可以使用,具备 C++20 支持的 STM32 工程也可以使用。两端用同一套 pack()、feed()、set_unpack_callback(),各自用自己的串口驱动搬运字节。使用者不需要自己处理半包、粘包和校验,也不用阅读协议解析源码。
5.1.程序目标:先不接串口,验证一次打包和解包
下面是可以单独编译的真实 C++ 代码。pack() 生成一帧,再把这一帧喂给 feed(),观察注册的函数是否被调用。在 Serial_Pack/ 根目录新建 protocol_demo.cpp:
#include <wire_protocol/protocol.hpp>
#include <cstdint>
#include <iostream>
void on_mode(std::uint32_t seq, std::int32_t mode)
{
std::cout << "收到 mode:seq=" << seq << " mode=" << mode << '\n';
}
int main()
{
wire_protocol::CallbackProtocol protocol;
// 0x02 命令收到后,按 on_mode 的参数类型解包。
if (!protocol.set_unpack_callback(0x02, on_mode))
{
std::cerr << "注册命令失败\n";
return 1;
}
const std::uint32_t seq = 1;
const std::int32_t mode = 2;
const auto frame = protocol.pack(0x02, seq, mode);
protocol.feed(frame);
}
在仓库根目录编译运行(需要支持 C++20 的编译器):
cd ~/Serial_Pack
g++ -std=c++20 \
-I example/ros2_ws/src/wire_protocol/include \
protocol_demo.cpp \
example/ros2_ws/src/wire_protocol/src/protocol.cpp \
-o protocol_demo
./protocol_demo
预期输出:
收到 mode:seq=1 mode=2
这里的 feed(frame) 是为了先练习协议 API。真正接串口时,传给 feed() 的是串口每次收到的字节片段,不要求刚好一整帧。
5.2.关键函数说明
5.2.1.CallbackProtocol::set_unpack_callback()
函数原型(有两种调用形式):
template <typename Callable>
bool set_unpack_callback(std::uint8_t command, Callable&& callback) noexcept;
template <typename Method, typename Object>
bool set_unpack_callback(
std::uint8_t command, Method method, Object* object) noexcept;
作用:在开始 feed() 之前,为一条命令注册解包后的处理函数。收到该命令的有效帧时,协议按处理函数的参数类型取出字段并调用它。
参数:
command:std::uint8_t,命令号,例如0x02。callback:普通函数、函数指针或符合限制的可调用对象。它必须返回void,字段参数要按值传递且类型明确,例如void on_mode(std::uint32_t seq, std::int32_t mode)。method:使用成员函数形式时传入成员函数指针,例如&Serial_Node::handle_set_mode。object:使用成员函数形式时传入所属对象指针,例如this;对象在接收期间必须保持有效。
返回值:bool。注册成功返回 true;命令重复、8 个回调槽位已满,或传入空函数/对象指针时返回 false。不受支持的 handler 签名会在编译期报错。这里直接传函数或成员函数指针,不用 std::bind 包装协议 handler。
5.2.2.CallbackProtocol::pack()
函数原型:
template <WireField... T>
auto pack(std::uint8_t command, const T&... fields) const noexcept;
作用:把命令号和字段打包成一帧可以交给串口发送的字节。
参数:
command:std::uint8_t,要发送的命令号。fields...:字段列表,类型由模板参数T...推导;类型和顺序要与接收侧的处理函数一致。本例pack(0x02, seq, mode)对应void on_mode(uint32_t seq, int32_t mode)。
返回值:源码中写作 auto,实际类型是 std::array<std::uint8_t, N>,数组拥有帧数据;N 在编译期由字段占用的字节数加 7 字节帧开销确定。例如两个 32 位整数的 pack(0x02, seq, mode) 返回 15 字节数组。另一个示例命令是 0x01:
std::uint32_t seq = 7;
float vx = 1.0f;
float vy = 0.0f;
float wz = 0.5f;
auto frame = protocol.pack(0x01, seq, vx, vy, wz);
对应的接收函数签名为 void on_cmd_vel(uint32_t seq, float vx, float vy, float wz)。不要把 float 写成 double,也不要把 int32_t 写成编译器宽度不明确的其他类型。两端必须约定相同命令号、字段类型和顺序。
5.2.3.CallbackProtocol::feed()
函数原型:
void feed(std::span<const std::uint8_t> bytes);
作用:把串口本次收到的字节交给协议;遇到完整且有效的帧时,自动调用已注册的命令处理函数。一次调用可能没有回调,也可能触发多个回调。
参数:
bytes:std::span<const std::uint8_t>,表示这次收到的字节片段。它可以是半帧,也可以包含几帧,不需要先凑成完整帧。
返回值:void。一个串口应长期保留一个协议对象,接收过程中不要重新注册回调。
5.2.4.CallbackProtocol::reset()
函数原型:
void reset() noexcept;
作用:丢弃尚未收齐的残留字节,例如串口断开后不想让旧半帧影响新连接时调用;已注册的处理函数会保留。
参数:无。
返回值:void。
5.3.接到 ROS2 示例节点上
serial_comm.cpp 把两个库连接起来,关键的真实代码是:
// Serial_Node 的成员:先声明协议,再声明驱动。
wire_protocol::CallbackProtocol protocol_;
serial_transport::Serial_Config serial_config_;
serial_transport::SerialTransport serial_driver_;
// 构造节点时,先注册协议命令,再启动串口接收。
if (!protocol_.set_unpack_callback(0x01, &Serial_Node::handle_cmd_vel, this))
{
return;
}
if (!protocol_.set_unpack_callback(0x02, &Serial_Node::handle_set_mode, this))
{
return;
}
serial_driver_.start(serial_config_,
std::bind(&Serial_Node::serial_receive_callback, this, std::placeholders::_1));
// 接收回调:只负责把收到的片段交给协议。
void serial_receive_callback(std::span<const std::uint8_t> bytes)
{
protocol_.feed(bytes);
}
// 需要发 0x02 命令时:先打包,再交给驱动。
void send_mode(std::uint32_t seq, std::int32_t mode)
{
auto frame = protocol_.pack(0x02, seq, mode);
serial_driver_.async_write(frame);
}
上面是类成员和构造函数中的使用片段,不是另一个完整节点;完整代码在仓库的 serial_comm.cpp。protocol_ 声明在 serial_driver_ 前面,是为了节点销毁时先停串口,再销毁协议对象。
现成示例的两条命令如下:
| 命令 | 收发字段 | 示例行为 |
|---|---|---|
0x01 |
uint32_t seq, float vx, float vy, float wz |
模拟速度,PC 周期发送并可接收 |
0x02 |
uint32_t seq, int32_t mode |
模拟模式,PC 周期发送并可接收 |
5.4.在 STM32 上使用同一份协议
这里直接使用仓库里的 example/stm32_hal/,不必另找一份 STM32 工程。该目录不是已经生成完毕的完整固件工程:它保存了 TEST.ioc、C++ 应用文件和用户 CMake 文件,先用 CubeMX 生成 HAL、FreeRTOS 和顶层构建文件。
5.5.第一步:用 CubeMX 生成 STM32 工程
打开 example/stm32_hal/TEST.ioc,在 CubeMX 中检查目标芯片与自己的板卡是否匹配,然后在这个目录生成代码。例程的 .ioc 选择了 STM32F103C8Tx、CMake 工具链、FreeRTOS CMSIS V2、USART1,以及名为 serialTask、ledTask 的任务;USART1 TX/RX 分别是 PA9/PA10。若你的板卡引脚不同,先在 CubeMX 中调整再生成。
生成后,目录中应出现 Core/、Drivers/、顶层 CMakeLists.txt 等文件;原有的 applications/、bsp/、cmake/user/ 继续保留。请在 CubeMX 的设置中保留 USER CODE,以免以后重新生成时丢失手动修改。
5.6.第二步:接入 C++ 构建文件
在刚生成的顶层 CMakeLists.txt 中,保留 CubeMX 原有的 C 标准设置,在 project() 之前加入:
set(CMAKE_CXX_STANDARD 20 CACHE STRING "C++ language standard")
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS ON)
找到已有的 CubeMX 子目录调用,在它后面新增用户目录调用:
add_subdirectory(cmake/stm32cubemx) # CubeMX 生成的原有行
add_subdirectory(cmake/user) # 需要新增的行
cmake/user/CMakeLists.txt 已经把 applications/Src/cpp_interface.cpp、serial.cpp、protocol.cpp 等文件加入目标,并设置所需头文件目录;所以这里必须添加 cmake/user,只把 C++ 标准设为 20 还不够。以后如果新增 .cpp,也要在 cmake/user/CMakeLists.txt 的源文件列表中加入它。顶层 CMake 的具体放置位置和 CubeMX 再生成时的注意事项,可对照该目录的 README-zh_CN.md。
5.7.第三步:在 main.c 调用 C++ 入口
在生成的 Core/Src/main.c 中,把头文件放进 USER CODE BEGIN Includes,并在外设初始化之后调用 cpp_main():
/* USER CODE BEGIN Includes */
#include "cpp_interface.h"
/* USER CODE END Includes */
/* main() 中,MX_USART1_UART_Init() 之后 */
/* USER CODE BEGIN 2 */
cpp_main();
/* USER CODE END 2 */
cpp_interface.h 已通过 extern "C" 暴露 cpp_main(),因此 C 文件可以直接调用。例程里 isRTOS 为 1:cpp_main() 在 FreeRTOS 模式下不会进入裸机死循环;串口逻辑由 applications/Src/serial.cpp 定义的 StartSerialTask 执行。.ioc 中已经配置了同名的 FreeRTOS 任务,不需要自己再建一个。
5.8.第四步:看例程怎样调用协议 API
STM32 端的 applications/Inc/protocol.hpp 与 ROS2 端是同一套协议接口;applications/Src/protocol.cpp 已由 cmake/user/CMakeLists.txt 加入编译。下面摘出 applications/Src/serial.cpp 中的关键调用,分别位于任务、UART 接收完成回调和发送循环中:
wire_protocol::CallbackProtocol protocol_;
std::array<uint8_t, 1> rx_buffer;
// StartSerialTask 中:先注册,再启动串口接收。
if (!protocol_.set_unpack_callback(0x01, cmd_vel_callback) ||
!protocol_.set_unpack_callback(0x02, set_mode_callback))
{
Error_Handler();
}
HAL_UART_Receive_IT(&huart1, rx_buffer.data(), rx_buffer.size());
// HAL_UART_RxCpltCallback 中:收到一个字节就交给协议,再继续接收。
protocol_.feed(rx_buffer);
HAL_UART_Receive_IT(&huart1, rx_buffer.data(), rx_buffer.size());
// StartSerialTask 的循环中:打包 0x01,再用 HAL 串口发送。
auto frame = protocol_.pack(0x01, seq, vx, vy, wz);
HAL_UART_Transmit(&huart1, frame.data(), frame.size(), osWaitForever);
这些是例程中不同函数的使用片段,不是要粘贴到同一个函数里的完整程序。STM32 端收到 0x01、0x02 后,回调分别更新 cmd_vel、set_mode;它自身周期性发送 0x01,没有周期性回发 0x02。例程接收回调在中断中调用 feed(),自己的业务回调应保持简短;如果改为多个任务或中断共同访问同一个协议对象,要把协议操作集中到一个任务中。
最后,用已安装的 ARM 工具链和 Ninja 构建 CubeMX 生成的工程。如果生成了 CMakePresets.json,可以在 example/stm32_hal/ 下运行:
cmake --preset Debug
cmake --build --preset Debug
再用你的调试器或下载器把生成的固件烧录到板卡。接线时板卡 PA9/TX 接 USB-TTL 的 RX,PA10/RX 接 USB-TTL 的 TX,并共地;两端统一为 115200 8N1、无流控。PC 端把 port 换成实际设备名,例如 /dev/ttyUSB0。
6.编译运行 ROS2 示例,观察收发结果
6.1.编译
安装好 ROS2、colcon 和 standalone Asio 开发头文件(Ubuntu/Debian 包名 libasio-dev)。以下以 ROS2 humble 为例;如果你安装了其他发行版,替换 humble:
cd ~/Serial_Pack/example/ros2_ws
source /opt/ros/humble/setup.bash
colcon build --packages-select wire_protocol serial_transport serial_comm
source install/setup.bash
6.2.用虚拟串口运行
保持前面的 socat 终端运行。在两个新终端分别执行;每个新终端都要进入工作区并加载环境:
# 终端 2:打开 /tmp/ttyV0
cd ~/Serial_Pack/example/ros2_ws
source /opt/ros/humble/setup.bash
source install/setup.bash
ros2 run serial_comm serial_comm --ros-args -r __node:=serial_a -p port:=/tmp/ttyV0
# 终端 3:打开 /tmp/ttyV1
cd ~/Serial_Pack/example/ros2_ws
source /opt/ros/humble/setup.bash
source install/setup.bash
ros2 run serial_comm serial_comm --ros-args -r __node:=serial_b -p port:=/tmp/ttyV1
两个节点都运行时,每个节点发出的串口包会到达另一端。正常情况下,终端先出现 串口打开成功,随后出现 [RX cmd_vel] 和 [RX set_mode]。数值和出现时间会随运行情况变化。结束时在两个节点和 socat 终端按 Ctrl+C。
6.3.用真实串口运行
按 example/stm32_hal/ 的步骤生成、构建并烧录 STM32 固件后,PC 端只运行一个节点:
ros2 run serial_comm serial_comm --ros-args -p port:=/dev/ttyUSB0 -p baud_rate:=115200
把 /dev/ttyUSB0 改为自己的设备名;也可以修改 serial_comm/config/serial_comm.yaml 的 port 后使用 ros2 launch serial_comm serial_comm.launch.py。如果没有使用 colcon build --symlink-install,修改配置后要重新构建,安装目录中的配置才会更新。
板卡例程会发送 0x01,所以 PC 应看到 [RX cmd_vel]。PC 发出的 0x01 和 0x02 会分别更新板卡端的 cmd_vel 和 set_mode,可用调试器观察;因为板卡例程不发送 0x02,PC 不会因此看到 [RX set_mode]。
6.4.没有收到预期结果时
串口打开失败:检查设备名、USB-TTL 是否插好、当前用户是否有串口权限。- 已打开串口但没有协议回调:检查 TX/RX 是否交叉、是否共地、两端波特率是否一致,以及命令号和字段类型是否对应。
set_unpack_callback()返回false:检查命令是否重复、是否超过 8 个回调,或是否传入空函数。
本节最重要的调用关系就是:ROS2/STM32 各自的串口驱动负责收发字节;两端用同一份 wire_protocol 负责 pack() 和 feed()。