跳到正文

Wiki

序列埠通訊序列埠包協議例項

約 14 分鐘閱讀

本文由簡體中文內容確定性轉換,並受版本化術語表保護。

序列埠是機器人裡最常見的通訊方式之一:上位機 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()。