1. 为什么选择串口通信?一个机器人开发者的真实考量

如果你正在捣鼓一个自己的机器人项目,比如一台智能小车或者一个机械臂,你大概率会遇到一个核心问题:大脑和手脚怎么对话? 这里的大脑,通常是一台运行着ROS2的树莓派,它负责高级决策、路径规划、视觉处理;而手脚,则可能是一块STM32微控制器,它直接驱动电机、读取编码器、采集传感器数据。它们俩需要一个可靠、实时、简单的沟通渠道。

你可能听说过 micro-ROS,它确实是一个强大的方案,能让STM32这类微控制器“原生”接入ROS2的世界。但说实话,对于很多初学者或者追求极致轻量化的项目来说,micro-ROS的学习曲线和资源开销(无论是芯片的Flash/RAM,还是你的学习时间)可能有点重。这就好比为了在家里两个房间传句话,你非得安装一套完整的内部电话系统,有时候,扯开嗓子喊一声(串口通信)反而更直接有效。

我自己的好几个机器人项目,从最初的循迹小车到后来的复合移动底盘,都选择了基于串口的轻量级通信方案。它的好处太明显了:极其简单、几乎零额外开销、实时性足够好、调试直观。STM32本身就有强大的硬件串口外设,树莓派也原生带有串口(无论是硬件UART还是通过USB转串口),两者之间的物理连接就是三根线(TX, RX, GND)。这种方案的稳定性,在我经历过各种电机干扰、电源波动的实战环境中,被证明是相当可靠的。

所以,这篇文章就是把我踩过的坑、验证过的流程,从头到尾给你捋清楚。我们的目标很明确:不依赖任何复杂的中间件,只用最基础的串口,让STM32和树莓派上的ROS2系统稳定地交换数据,为后续的机器人控制打下坚实的地基。无论你是刚入门嵌入式,还是想快速搭建机器人原型,跟着步骤走,你都能搞定。

2. 硬件连接:别让第一步就“翻车”

硬件接线是通信的物理基础,这一步错了,后面所有代码都是白费。我见过太多初学者在这里栽跟头,其实核心就记住两点:电平匹配和交叉连接。

2.1 认识你的通信接口

首先,明确你手头的设备:

  • 树莓派4B:它有几个串口资源。最常用的是 /dev/ttyAMA0,这是板载的硬件UART,默认可能被蓝牙占用。另一个更推荐给通信用的是通过 USB转TTL串口模块 创建的端口,比如 /dev/ttyUSB0。我们通常选择后者,因为它不占用系统关键资源,即插即用。
  • STM32F407(或其他型号):我们通常使用其USART1(PA9/PA10)或USART2(PA2/PA3)等硬件串口。这些引脚输出的是 3.3V TTL电平。

注意:树莓派的GPIO引脚(包括/dev/ttyAMA0对应的引脚)也是3.3V电平,但直接连接可能面临驱动能力和隔离问题。使用独立的USB转TTL模块是更稳妥、隔离性更好的选择。

2.2 三线制接法详解

你需要一个 USB转TTL串口模块(常用芯片如CH340、CP2102、FT232等)。连接时,请死死记住一个口诀:TX接RX,RX接TX,GND共地。

让我们画一个清晰的连接图(用文字描述):

  1. USB转TTL模块的TX引脚 -> 连接到 STM32的RX引脚(例如USART1的PA10)。
  2. USB转TTL模块的RX引脚 -> 连接到 STM32的TX引脚(例如USART1的PA9)。
  3. USB转TTL模块的GND引脚 -> 连接到 STM32的GND引脚。

为什么一定要交叉?想象一下,TX是“嘴巴”,RX是“耳朵”。A的嘴巴(TX)必须对着B的耳朵(RX)说话,才能听清。如果两个嘴巴对在一起(TX接TX),那就谁也听不到谁了。

电平确认:务必确保你的USB转TTL模块支持 3.3V电平。很多模块有跳线帽可以选择3.3V或5V,必须设置为3.3V,以防止高压损坏STM32的IO口。接好后,给STM32和树莓派上电。

2.3 硬件自检与端口确认

在写代码之前,先做硬件自检。将USB转TTL模块插入树莓派的USB口。然后,在树莓派终端里输入:

ls /dev/ttyUSB*

或者

ls /dev/ttyACM*

你应该能看到类似 /dev/ttyUSB0 的设备出现。如果没看到,可能是驱动问题(CH340/CP2102驱动通常Linux内核已集成),或者模块有问题。

一个更专业的检查方法是使用 dmesg | grep tty 命令,查看系统内核日志,通常插入设备后最后几行会显示识别的串口设备信息。

3. STM32端固件编写:打造可靠的数据收发器

STM32端的代码,我们的目标是建立一个稳定、可扩展的数据收发基础。不仅仅是让LED闪烁,而是要为后续传输复杂的传感器数据和控制指令做好准备。

3.1 串口初始化与配置

初始化是重中之重,STM32和树莓派两边的串口参数必须像对暗号一样完全一致,否则收到的就是乱码。主要参数有四个:波特率、数据位、停止位、校验位。在机器人通信中,我强烈推荐使用 115200波特率、8位数据位、1位停止位、无校验位。这个组合在速度和稳定性上取得了很好的平衡。

下面是一个针对STM32F407 USART1的初始化函数,我加上了详细的注释,帮你理解每一行在做什么:

/**
  * @brief  初始化USART1
  * @param  bound: 波特率,例如115200
  * @retval 无
  */
void uart1_init(u32 bound) {
    GPIO_InitTypeDef GPIO_InitStructure;
    USART_InitTypeDef USART_InitStructure;
    NVIC_InitTypeDef NVIC_InitStructure;

    // 1. 开启时钟——不给时钟,外设就不工作
    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); // 使能GPIOA时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);// 使能USART1时钟

    // 2. 配置GPIO引脚复用功能
    // PA9 复用为 USART1_TX, PA10 复用为 USART1_RX
    GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_USART1);
    GPIO_PinAFConfig(GPIOA, GPIO_PinSource10, GPIO_AF_USART1);

    // 3. 配置GPIO模式
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9 | GPIO_Pin_10;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF;        // 复用功能
    GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;      // 推挽输出(对于TX很重要)
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;   // 高速
    GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;        // 上拉(增强抗干扰)
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    // 4. 配置串口参数(核心!必须与上位机匹配)
    USART_InitStructure.USART_BaudRate = bound;                 // 波特率
    USART_InitStructure.USART_WordLength = USART_WordLength_8b; // 8位数据
    USART_InitStructure.USART_StopBits = USART_StopBits_1;     // 1位停止位
    USART_InitStructure.USART_Parity = USART_Parity_No;        // 无校验
    USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; // 无硬件流控
    USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; // 收发模式
    USART_Init(USART1, &USART_InitStructure);

    // 5. 配置串口接收中断(用于实时接收数据)
    USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); // 使能“接收缓冲区非空”中断

    // 6. 配置NVIC(嵌套向量中断控制器)
    NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; // 抢占优先级
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;        // 子优先级
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);

    // 7. 使能串口
    USART_Cmd(USART1, ENABLE);
}

3.2 中断接收与数据缓冲

在机器人系统中,上位机(树莓派)发送控制指令的时机是不确定的,因此STM32必须随时待命。使用中断方式接收是标准做法。但切记,中断服务函数(ISR)要快进快出,只做最必要的操作——通常是把数据从一个硬件寄存器搬到一个软件缓冲区里。

这里我引入一个简单的环形缓冲区概念,这是实战中避免数据丢失的关键。我们先看一个基础的中断服务函数,它只设置标志位:

// 定义全局变量
uint8_t uart1_rx_buf = 0; // 单字节接收缓存
volatile uint8_t uart1_rx_flag = 0; // 接收完成标志,volatile防止编译器优化

void USART1_IRQHandler(void) {
    if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { // 判断是否是接收中断
        uart1_rx_buf = USART_ReceiveData(USART1); // 读取一个字节
        uart1_rx_flag = 1; // 设置标志位
        USART_ClearITPendingBit(USART1, USART_IT_RXNE); // 清除中断标志
    }
}

然后在主循环中检查这个标志位:

extern volatile uint8_t uart1_rx_flag;
extern uint8_t uart1_rx_buf;

int main(void) {
    uart1_init(115200);
    printf("STM32 UART1 Ready!\r\n"); // 上电发送一个提示

    while(1) {
        if(uart1_rx_flag) {
            uart1_rx_flag = 0;
            // 处理接收到的字节 uart1_rx_buf
            // 例如,直接回传
            printf("I received: %c (0x%02X)\r\n", uart1_rx_buf, uart1_rx_buf);
        }
        // 这里可以执行其他任务,如传感器读取、电机控制等
    }
}

这个方案对于测试是OK的,但如果数据来得很快(比如一帧包含多个字节的指令),就可能丢失数据。更健壮的做法是使用环形缓冲区,在中断里将数据存入缓冲区,在主循环里解析。由于篇幅所限,这里先建立中断接收的概念,后面设计协议时会再深化。

3.3 发送数据与printf重定向

发送数据相对简单。除了使用库函数 USART_SendData(),更便捷的方法是重定向 printf 到串口。这样你就可以用 printf(“Speed:%d\r\n”, speed) 这样的方式格式化发送了,调试信息会非常直观。

重定向需要实现 fputc 函数(或者对于某些编译器是 _write 函数):

// 在某个源文件(如usart.c)中添加以下函数
#include <stdio.h>

// 重定向C库的printf函数到USART1
int fputc(int ch, FILE *f) {
    while((USART1->SR & 0x40) == 0); // 等待上一次发送完成(检查TXE位)
    USART1->DR = (uint8_t)ch;        // 写入数据寄存器,启动发送
    return ch;
}

确保你的工程设置中勾选了“Use MicroLIB”(对于Keil)或类似选项,以使用这个重定向的小型库。现在,你就可以在代码中随意使用 printf 了,所有输出都会自动从串口1发送出去。

4. 树莓派端环境配置与基础测试

树莓派作为上位机,我们需要做两件事:一、用工具验证硬件连接和STM32固件是否正常工作;二、为后续的ROS2节点编程准备好串口访问环境。

4.1 使用CuteCom进行手动测试

在编写任何ROS2代码之前,强烈建议先用一个图形化串口工具进行“握手”测试。这能快速隔离问题:是硬件连接问题,还是STM32代码问题,抑或是树莓派配置问题。

安装CuteCom非常简单:

sudo apt update
sudo apt install cutecom

安装完成后,在终端输入 sudo cutecom 以管理员权限打开(因为串口设备通常需要root权限)。

配置步骤:

  1. 在CuteCom界面,点击 Settings 或 打开设备。
  2. Device:选择你的串口设备,通常是 /dev/ttyUSB0。如果列表里没有,回到终端用 ls /dev/ttyUSB* 确认。
  3. Baudrate:设置为 115200,必须与STM32完全一致。
  4. Data Bits:8
  5. Stop Bits:1
  6. Parity:None
  7. Flow Control:None

配置好后,点击 Open 打开串口。如果一切正常,你应该能看到STM32上电时通过 printf 发送过来的提示信息,比如 “STM32 UART1 Ready!”。

测试收发:

  • 接收测试:在CuteCom的主界面,你应该能持续看到STM32发送的数据(如果你在主循环里让STM32定时发送一些传感器数据的话)。
  • 发送测试:在CuteCom下方的输入框里,键入一些字符(比如“A”然后回车),观察接收区。如果STM32代码中有回显功能(就像我们上面写的,收到什么就打印什么),你应该会立刻看到STM32返回的信息,例如 “I received: A (0x41)”。

这个环节通过了,恭喜你!最基础的物理层和字节流通信已经打通了。但这只是万里长征第一步,因为我们现在传输的是零散的字符,而机器人需要的是有结构、有意义的数据包。

4.2 设置串口权限(避免每次都sudo)

每次都用 sudo 运行CuteCom或自己的Python脚本很不方便,而且ROS2节点通常不以root身份运行。我们需要将当前用户加入到 dialout 组,从而获得串口的读写权限。

sudo usermod -a -G dialout $USER

执行这条命令后,必须注销当前用户并重新登录,或者重启树莓派,这个改动才会生效。之后,你就可以直接用 cutecom 命令(不加sudo)打开串口工具了。

4.3 Python串口编程初探

在进入ROS2之前,我们先写一个简单的Python脚本,用代码来替代CuteCom进行通信测试。这能帮你理解后续ROS2节点里串口部分是如何工作的。

首先安装Python的串口库:

pip install pyserial

然后创建一个测试脚本 test_uart.py:

#!/usr/bin/env python3
import serial
import time

# 配置串口参数,必须与STM32匹配
ser = serial.Serial(
    port='/dev/ttyUSB0',  # 你的串口设备
    baudrate=115200,
    bytesize=serial.EIGHTBITS,
    parity=serial.PARITY_NONE,
    stopbits=serial.STOPBITS_ONE,
    timeout=1  # 读超时时间(秒)
)

try:
    if ser.is_open:
        print(f"串口 {ser.port} 已打开。")

    # 发送一个字符串
    send_data = "Hello STM32!\n"
    ser.write(send_data.encode('utf-8'))
    print(f"发送: {send_data.strip()}")

    # 等待并读取回显
    time.sleep(0.1)
    while ser.in_waiting:
        received_data = ser.readline().decode('utf-8', errors='ignore').strip()
        if received_data:
            print(f"接收: {received_data}")

except Exception as e:
    print(f"发生错误: {e}")

finally:
    if ser and ser.is_open:
        ser.close()
        print("串口已关闭。")

运行这个脚本 python3 test_uart.py,你应该能看到类似CuteCom中的收发效果。这个脚本包含了打开串口、配置参数、发送数据、读取数据的基本流程,这就是我们后续ROS2节点中串口通信模块的核心。

5. 设计轻量级通信协议:从字节流到数据包

前面我们实现了单个字符的收发,但这对于机器人控制是远远不够的。想象一下,树莓派需要发送“左轮速度100,右轮速度-80”这样的指令,STM32需要上报“电量78%,左编码器计数1200,右编码器计数1180”这样的状态。这些信息不能靠零散的字符拼凑,我们需要一个协议来给数据流划分边界、定义含义。

5.1 为什么需要自定义协议?

串口本身只传输原始的字节流,它不知道一个数据从哪里开始,到哪里结束,哪个字节代表速度,哪个字节代表角度。没有协议,接收方就无法正确解析,容易产生粘包(两次发送的数据被合并接收)或拆包(一次发送的数据被分两次接收)的问题。一个简单的自定义协议能解决这些问题,它的核心要素通常包括:

  • 帧头:标志一帧数据的开始,通常是固定的一个或几个字节,如 0xAA、0x55,或者 ‘S’、‘T’、‘M’。
  • 数据长度:指明这帧数据中有效载荷的长度,方便接收方准确收取。
  • 命令字/数据类型:指明这帧数据是干什么用的(是控制指令还是状态查询)。
  • 有效载荷:实际要传输的数据内容。
  • 校验和:用于验证数据在传输过程中是否出错,最简单的是将所有字节相加取低8位。

5.2 一个实战协议设计示例

我们来设计一个用于小车控制的简单协议。一帧数据看起来像这样: [帧头1][帧头2][数据长度][命令字][数据载荷...][校验和]

  • 帧头:2字节,固定为 0xAA 和 0x55,用于在数据流中同步。
  • 数据长度:1字节,表示 命令字+数据载荷 的字节数。
  • 命令字:1字节。例如,0x01 代表设置电机速度,0x02 代表查询传感器数据。
  • 数据载荷:可变长度,由命令字定义其格式。
  • 校验和:1字节,从数据长度字节开始,到数据载荷最后一个字节为止,所有字节相加后取低8位。

举例:树莓派发送“设置左轮速度100,右轮速度-80” 假设速度用两个有符号16位整数(short,占2字节)表示。

  1. 命令字:0x01
  2. 数据载荷:左轮速度 100 -> 0x00 0x64,右轮速度 -80 -> 0xFF 0xB0(补码形式)。
  3. 数据长度:命令字(1) + 载荷(4) = 5字节 -> 0x05
  4. 校验和计算:0x05 + 0x01 + 0x00 + 0x64 + 0xFF + 0xB0 = 0x213,取低8位 0x13。
  5. 最终帧:AA 55 05 01 00 64 FF B0 13

5.3 STM32端的协议解析实现

在STM32端,我们需要在串口中断服务函数中实现一个状态机,来解析这个协议。状态机是处理流式协议的神器。

// 协议解析状态定义
typedef enum {
    UART_STATE_WAIT_HEADER1,
    UART_STATE_WAIT_HEADER2,
    UART_STATE_WAIT_LENGTH,
    UART_STATE_WAIT_CMD,
    UART_STATE_RECEIVING_DATA,
    UART_STATE_WAIT_CHECKSUM
} uart_parse_state_t;

// 协议帧结构体
typedef struct {
    uint8_t cmd;
    uint8_t data_len;
    uint8_t data_buf[32]; // 数据缓冲区
    uint8_t data_index;
    uint8_t expected_checksum;
    uart_parse_state_t state;
} uart_protocol_t;

uart_protocol_t protocol = {0};

// 在串口中断中
void USART1_IRQHandler(void) {
    if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) {
        uint8_t rx_byte = USART_ReceiveData(USART1);
        
        switch(protocol.state) {
            case UART_STATE_WAIT_HEADER1:
                if(rx_byte == 0xAA) protocol.state = UART_STATE_WAIT_HEADER2;
                break;
            case UART_STATE_WAIT_HEADER2:
                if(rx_byte == 0x55) protocol.state = UART_STATE_WAIT_LENGTH;
                else protocol.state = UART_STATE_WAIT_HEADER1; // 同步失败,重置
                break;
            case UART_STATE_WAIT_LENGTH:
                protocol.data_len = rx_byte;
                protocol.data_index = 0;
                protocol.expected_checksum = rx_byte; // 开始计算校验和
                protocol.state = UART_STATE_WAIT_CMD;
                break;
            case UART_STATE_WAIT_CMD:
                protocol.cmd = rx_byte;
                protocol.expected_checksum += rx_byte;
                if(protocol.data_len > 1) {
                    protocol.state = UART_STATE_RECEIVING_DATA;
                } else {
                    protocol.state = UART_STATE_WAIT_CHECKSUM;
                }
                break;
            case UART_STATE_RECEIVING_DATA:
                protocol.data_buf[protocol.data_index++] = rx_byte;
                protocol.expected_checksum += rx_byte;
                if(protocol.data_index >= (protocol.data_len - 1)) { // -1是因为CMD已算在长度内
                    protocol.state = UART_STATE_WAIT_CHECKSUM;
                }
                break;
            case UART_STATE_WAIT_CHECKSUM:
                if(rx_byte == protocol.expected_checksum) {
                    // 校验通过!一帧完整数据接收完毕
                    process_received_frame(&protocol); // 调用处理函数
                }
                // 无论校验是否通过,都回到初始状态,准备接收下一帧
                protocol.state = UART_STATE_WAIT_HEADER1;
                memset(&protocol, 0, sizeof(protocol)); // 清空结构体
                break;
            default:
                protocol.state = UART_STATE_WAIT_HEADER1;
                break;
        }
        USART_ClearITPendingBit(USART1, USART_IT_RXNE);
    }
}

在主循环中,process_received_frame 函数会根据 protocol.cmd 来执行相应的操作,比如解析速度数据并设置电机PWM。这样,我们就实现了从原始字节流到结构化指令的转换。

6. 融入ROS2世界:创建串口通信节点

打通了底层串口并设计了协议,最后一步就是让树莓派上的ROS2系统能够使用这个通道。我们将创建一个ROS2节点,它扮演桥梁的角色:一方面订阅ROS2话题(如 /cmd_vel 速度指令),将其打包成协议格式通过串口发送给STM32;另一方面,从串口读取STM32上传的数据包,解析后发布到ROS2话题(如 /battery、/odom 等)。

6.1 创建ROS2功能包与节点

假设你的ROS2工作空间是 ~/ros2_ws。

cd ~/ros2_ws/src
ros2 pkg create --build-type ament_python stm32_uart_bridge --dependencies rclpy geometry_msgs std_msgs
cd stm32_uart_bridge/stm32_uart_bridge

创建一个节点文件 uart_bridge_node.py。这个节点将做以下几件事:

  1. 初始化时打开串口。
  2. 创建一个订阅者,监听ROS2控制指令。
  3. 创建一个定时器,周期性地从串口读取数据并解析。
  4. 解析STM32上传的数据,并发布到对应的ROS2话题。

6.2 编写串口通信ROS2节点

以下是节点代码的核心框架,我加入了详细的注释:

#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist
from std_msgs.msg import Int32, Float32
import serial
import struct
import threading
import time

class UartBridgeNode(Node):
    def __init__(self):
        super().__init__('uart_bridge_node')
        self.get_logger().info('STM32串口桥接节点启动...')

        # 串口配置参数(可通过ROS2参数服务器配置)
        self.declare_parameter('port', '/dev/ttyUSB0')
        self.declare_parameter('baudrate', 115200)
        port = self.get_parameter('port').get_parameter_value().string_value
        baudrate = self.get_parameter('baudrate').get_parameter_value().integer_value

        # 初始化串口
        try:
            self.ser = serial.Serial(
                port=port,
                baudrate=baudrate,
                bytesize=serial.EIGHTBITS,
                parity=serial.PARITY_NONE,
                stopbits=serial.STOPBITS_ONE,
                timeout=0.1  # 非阻塞读取,超时0.1秒
            )
            if self.ser.is_open:
                self.get_logger().info(f'成功打开串口: {port}')
        except Exception as e:
            self.get_logger().error(f'无法打开串口 {port}: {e}')
            rclpy.shutdown()
            return

        # 创建订阅者:订阅ROS2速度指令话题
        self.cmd_vel_sub = self.create_subscription(
            Twist,
            'cmd_vel',  # 标准ROS话题名
            self.cmd_vel_callback,
            10
        )

        # 创建发布者:发布从STM32读取的数据,例如电池电压
        self.battery_pub = self.create_publisher(Float32, 'battery_voltage', 10)

        # 创建定时器:周期性地读取和解析串口数据
        self.timer = self.create_timer(0.02, self.timer_callback) # 50Hz

        # 协议解析相关状态变量
        self.rx_buffer = bytearray()
        self.parse_state = 'WAIT_HEADER1'

    def cmd_vel_callback(self, msg):
        """
        收到ROS2速度指令时的回调函数。
        将Twist消息转换为自定义协议格式,并通过串口发送。
        """
        # 假设我们只使用线速度x和角速度z
        linear_speed = msg.linear.x
        angular_speed = msg.angular.z

        # 根据你的机器人运动学模型,将线速度和角速度转换为左右轮速度
        # 这里是一个简单的差分驱动模型示例(需根据你的机器人参数调整)
        wheel_separation = 0.3  # 轮距,单位米
        wheel_radius = 0.05     # 轮子半径,单位米
        left_wheel_speed = (linear_speed - angular_speed * wheel_separation / 2.0) / wheel_radius
        right_wheel_speed = (linear_speed + angular_speed * wheel_separation / 2.0) / wheel_radius

        # 转换为整数(例如,单位是 转/分 或 编码器计数/秒)
        left_speed_int = int(left_wheel_speed * 100)  # 放大100倍发送,保持精度
        right_speed_int = int(right_wheel_speed * 100)

        # 按照第5章设计的协议打包数据
        cmd_byte = 0x01  # 设置电机速度命令
        # 使用struct.pack将两个16位整数打包为字节
        data_payload = struct.pack('<hh', left_speed_int, right_speed_int) # '<'表示小端字节序
        data_len = 1 + len(data_payload)  # 命令字1字节 + 数据载荷长度

        # 构建帧
        frame = bytearray()
        frame.append(0xAA)  # 帧头1
        frame.append(0x55)  # 帧头2
        frame.append(data_len)
        frame.append(cmd_byte)
        frame.extend(data_payload)

        # 计算校验和(从data_len开始到数据载荷结束)
        checksum = sum(frame[2:]) & 0xFF  # 取低8位
        frame.append(checksum)

        # 通过串口发送
        try:
            self.ser.write(frame)
            self.get_logger().debug(f'发送速度指令: L={left_speed_int}, R={right_speed_int}')
        except Exception as e:
            self.get_logger().warn(f'串口发送失败: {e}')

    def timer_callback(self):
        """
        定时器回调函数,用于读取串口数据并解析。
        """
        if self.ser.in_waiting:
            try:
                data = self.ser.read(self.ser.in_waiting) # 读取所有可用数据
                self.rx_buffer.extend(data)
                self.parse_uart_data() # 解析缓冲区中的数据
            except Exception as e:
                self.get_logger().warn(f'串口读取失败: {e}')

    def parse_uart_data(self):
        """
        解析串口接收缓冲区,使用状态机寻找完整的数据帧。
        """
        i = 0
        while i < len(self.rx_buffer):
            byte = self.rx_buffer[i]
            i += 1

            if self.parse_state == 'WAIT_HEADER1':
                if byte == 0xAA:
                    self.parse_state = 'WAIT_HEADER2'
                else:
                    # 不是帧头,丢弃这个字节,状态不变
                    pass
            elif self.parse_state == 'WAIT_HEADER2':
                if byte == 0x55:
                    self.parse_state = 'WAIT_LENGTH'
                    self.current_frame = bytearray([0xAA, 0x55])
                else:
                    self.parse_state = 'WAIT_HEADER1' # 同步失败,回溯
            elif self.parse_state == 'WAIT_LENGTH':
                self.data_len_expected = byte
                self.current_frame.append(byte)
                self.checksum_calc = byte
                self.data_received = 0
                if self.data_len_expected > 0:
                    self.parse_state = 'RECEIVING_DATA'
                else:
                    self.parse_state = 'WAIT_CHECKSUM'
            elif self.parse_state == 'RECEIVING_DATA':
                self.current_frame.append(byte)
                self.checksum_calc = (self.checksum_calc + byte) & 0xFF
                self.data_received += 1
                if self.data_received >= self.data_len_expected:
                    self.parse_state = 'WAIT_CHECKSUM'
            elif self.parse_state == 'WAIT_CHECKSUM':
                received_checksum = byte
                if received_checksum == self.checksum_calc:
                    # 校验成功,处理完整帧
                    self.process_frame(self.current_frame)
                else:
                    self.get_logger().warn('校验和错误,丢弃帧。')
                # 无论成功与否,都回到初始状态,准备下一帧
                self.parse_state = 'WAIT_HEADER1'
                # 从缓冲区中移除已处理的数据
                self.rx_buffer = self.rx_buffer[i:]
                i = 0  # 重置索引,因为缓冲区内容变了
                break # 跳出循环,等待下一次timer_callback处理剩余数据

        # 如果循环结束但还没处理完,说明缓冲区里没有完整帧了,清理已扫描过的部分
        # 这里简化处理,实际可能需要更精细的缓冲区管理
        if i > 0 and self.parse_state == 'WAIT_HEADER1':
            self.rx_buffer = self.rx_buffer[i:]

    def process_frame(self, frame):
        """
        处理一个完整的、校验通过的数据帧。
        """
        # 帧结构: [AA][55][len][cmd][data...]
        cmd = frame[3]
        data_section = frame[4:-1] # 去掉帧头、长度、命令字和校验和

        if cmd == 0x02:  # 假设0x02是上报电池电压
            # 假设数据是1个float (4字节)
            if len(data_section) >= 4:
                voltage = struct.unpack('<f', data_section[:4])[0] # 小端解包float
                battery_msg = Float32()
                battery_msg.data = voltage
                self.battery_pub.publish(battery_msg)
                self.get_logger().info(f'电池电压: {voltage:.2f}V')
        # 可以添加更多命令字处理...

    def destroy_node(self):
        if hasattr(self, 'ser') and self.ser.is_open:
            self.ser.close()
            self.get_logger().info('串口已关闭。')
        super().destroy_node()

def main(args=None):
    rclpy.init(args=args)
    node = UartBridgeNode()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()

if __name__ == '__main__':
    main()

6.3 编译、运行与测试

  1. 编译功能包:

    cd ~/ros2_ws
    colcon build --packages-select stm32_uart_bridge
    source install/setup.bash
    
  2. 运行节点:

    ros2 run stm32_uart_bridge uart_bridge_node
    

    你可以通过 --ros-args -p port:=/dev/ttyUSB1 -p baudrate:=57600 这样的方式在命令行覆盖串口参数。

  3. 测试通信:

    • 打开一个新终端,使用 ros2 topic list 应该能看到 /cmd_vel 和 /battery_voltage 等话题。
    • 手动发布一个速度指令测试:
      ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.2, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.1}}" -1
      
      观察STM32端的反应(比如LED变化,或者通过另一个串口助手看STM32的调试输出)。
    • 如果STM32按照协议上传了电池电压数据,你应该能在终端看到节点打印的电压信息,并且可以通过 ros2 topic echo /battery_voltage 来查看。

7. 避坑指南与性能优化

走到这一步,你的STM32和树莓派ROS2应该已经能愉快地对话了。但在实际项目中,我踩过不少坑,这里分享几个关键点,能帮你节省大量调试时间。

1. 波特率误差与稳定性 115200是常用波特率,但对时钟精度有要求。如果发现数据偶尔出错,特别是长时间运行后,可以尝试降低波特率到9600或57600试试。确保STM32的系统时钟(HCLK)和串口时钟(APBx)配置正确,计算出的波特率误差最好在2%以内。

2. 电源与接地噪声 电机驱动、舵机等是大电流设备,会在电源上产生噪声。务必确保STM32和树莓派(或USB转TTL模块)有共地,并且电源干净。可以在电源入口加大的电解电容(如100uF)和小的去耦电容(0.1uF)滤除噪声。如果条件允许,为数字部分和电机驱动部分使用独立的电源或加磁珠隔离。

3. 数据解析的鲁棒性 前面给出的状态机解析器是一个基础框架。在实际使用中,需要考虑帧丢失和错误恢复。例如,在 WAIT_HEADER1 状态,如果长时间(比如超过一帧最大可能时间)没收到0xAA,应该主动清空缓冲区并重置状态机,防止因一个字节错误导致后续所有数据错位。

4. ROS2节点的实时性与阻塞 我们的节点使用了定时器回调来读取串口。timeout=0.1 的设置是非阻塞读取的关键。不要让任何回调函数(如 cmd_vel_callback 或 timer_callback)执行时间过长,否则会影响ROS2节点的响应。复杂的协议解析或数据处理可以考虑放入单独的线程,但要注意线程安全。

5. 协议的可扩展性 一开始设计协议时,就为命令字和数据长度留出余地。比如命令字用1个字节,可以支持255种不同指令。数据长度字段最好能覆盖你未来可能用到的最大数据包。可以考虑在帧尾增加帧尾标识,进一步提高帧边界的可靠性。

6. 调试利器:数据打印与日志 在STM32端,除了通过串口发回数据,可以灵活使用一些IO口来指示状态。例如,在进入串口中断时点亮一个LED,在解析完一帧数据时让另一个LED闪烁。在树莓派ROS2节点中,充分利用 self.get_logger().debug() 来输出收发数据的十六进制,在调试时非常有用。可以设置日志级别来开关这些调试信息。

这套从硬件接线到协议设计,再到ROS2集成的流程,是我在多个小型机器人项目上反复验证过的。它可能没有micro-ROS那样“优雅”和“标准”,但它带来的极致的可控性、轻量级的资源消耗和快速的开发迭代速度,对于很多实际项目来说,恰恰是最需要的。当你看到通过ROS2的一个 rostopic pub 命令,就能让远端的STM32驱动电机精准运动时,那种一切尽在掌握的感觉,正是嵌入式机器人开发的乐趣所在。

更多推荐