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()。