
做机器人开发几乎没人能绕开串口。电机驱动器、IMU、激光雷达、底盘控制板这些下位机跟ROS主机之间的数据往来绝大多数都靠一条USART。问题是ROS里并没有一个叫“串口话题”的原生功能serial库拿到手的只是一串没有任何结构的字节流。于是协议解析就成了悬在每个开发者头上的问题——用什么帧格式、怎么校验、拆包逻辑怎么写、遇到粘包丢包怎么处理。这篇文章把我过去几年在几个实际机器人项目里沉淀的串口通信协议方案、解析代码和经验教训整理出来供做AGV、机械臂或者传感器接入的同学参考。1. 聊聊ROS里为什么要自研一套串口协议1.1 ROS没有“原生串口”这件事很多刚入门的朋友会找“ROS串口库”翻来翻去发现官方并没有提供serial_msgs或类似的东西。原因很简单ROS1的节点之间通信走的是TCP/UDP域链路层根本不关心你是通过什么物理口把数据送上来的。ROS要的是“发布者”和“订阅者”至于数据来自网卡还是串口它一概不管。但机器人物理世界里串口遍地都是。STM32底盘返回的速度、电流、里程惯导模块丢出来的姿态数据电调上报的转速和温度全都从USART出来。上位机要找这些数据就得有一个节点专门干“把串口字节变成ROS话题”这件事。这个节点看起来不难做起来全是细节。你要处理三件事第一读串口不能阻塞ROS主循环第二从连续的字节流里找到一条完整帧的边界第三把错帧和坏帧优雅地滤掉。这三件事合在一起就是一套通信协议。1.2 字节流到话题消息中间差了哪些东西串口本质是字节流管道发端一个字节一个字节地吐收端一个字节一个字节地攒。它没有“消息”概念更没有“这一包是传感器数据”“那一包是心跳”这种语义。协议要做的事就是在字节流里画出边界再把边界内的字节翻译成有意义的结构。我用一个生活类比串口就像一条传送带上面不断滚过写着数字的乒乓球协议解析就是一条分拣线。分拣线得先认出“这是第几个球”再按标志把它们装进不同的箱子最后把箱子贴上标签送上ROS的消息总线。没有约定好的装箱规则传送带上来的就只能是一堆散球。所以当你决定做串口通信时第一件事不是写代码而是和嵌入式那边的同事或者自己的下位机逻辑对齐一套“装箱规则”也就是帧格式。别急着用波特率115200连上是本事真正的功夫在帧格式设计上。1.3 协议设计的目标与原则什么样的串口协议算是好协议我在实际项目里总结了四条下位机开销要低。底盘用的MCU资源有限很多还都是裸机跑你让它上CRC32和加密算法它可能连主循环都转不动。协议尽量用移位、加法和简单查表就能实现。容错要强。电机和电调带来的电磁干扰经常让串口线上出现毛刺帧头丢字节、多字节、错字节都很常见解析端必须有自恢复能力。扩展性要好。今天协议里只有底盘速度明天要加电池电压、后天要加编码器累计值不能每次加字段都要推翻整个帧结构。调试要方便。最好能直接靠逻辑分析仪或者串口助手把一帧数据读懂这对现场排障太重要了。基于这四点我最后落地的方案非常朴素帧头 长度 命令字 数据域 校验。下面这章我会把每个字段的来历讲清楚。2. 帧格式怎么定字段、校验与字节序2.1 一帧数据的基本骨架我在项目里最长用的帧结构长这样字段长度说明帧头11字节固定值 0xAA帧头21字节固定值 0x55帧长1字节从命令字开始到数据域结束的字节数命令字1字节用来区分帧类型例如0x01表示底盘数据数据域0~N字节具体业务数据校验1字节从长度字节到数据域末端的和校验举一个实际帧的例子下位机要上报一条“前进速度0.52 m/s”我规定数据域用两个字节表示速度单位是0.01m/s那么0.52就编码成整数52十六进制是0x0034。整帧就是AA 55 03 01 00 34 38其中03是长度表示命令字01加上数据域两个字节一共3个字节最后那个38就是校验和是03010034的结果。这个帧结构展开以后读起来非常直观。2.2 帧头为什么推荐两个互补字节帧头的作用是让解析端知道“从这里开始算一帧”。只用一个固定字节行不行行但误匹配的概率高。0xAA二进制是101010100x55是01010101两个字节每一位都相反业务数据里同时出现连续这两字节的概率极低。帧头用两个字节其实是在字节流里种下一道非常Low的“哨兵”。我试过只用单个帧头0xAA结果在一个传感器消息密集的项目里业务数据里偶尔蹦出一个0xAA解析端把数据中间当成帧头整帧直接错位后面一连串校验失败排查了整整一个下午。换成双字节帧头之后这类问题基本绝迹。帧头匹配还有一个细节如果第一个字节匹配到0xAA第二个字节不是0x55这时候是回到“重新等0xAA”还是把当前这个字节也当成可能的0xAA再来一遍答案是后者。这个“半匹配陷阱”我放在第四章节专门讲因为它最容易被人忽略。2.3 校验方式怎么选和校验、CRC8还是CRC16校验字段是整个协议的体检医生坏了的数据帧能不能被有效拦截全靠它。常见三种方式方式计算思路检测能力下位机开销适用场景和校验所有字节累加取低8位能发现单字节跳变和多字节叠加的偶数错极低帧短、环境简单CRC8查表/移位多项式求余能发现数据中单比特错误和多数突发错误低工业传感器常用CRC16查表/移位多项式求余对常见干扰模式几乎全检出中数据域长、环境恶劣在AGV小车这种应用里帧长通常控制在10个字节以内我更喜欢用和校验。不是说CRC不好而是和校验在这条链路上已经够用下位机实现只要几行加法。如果你做的项目数据域动不动超过30字节或者现场有大功率电机频繁启停我建议直接上CRC16。Modbus RTU那套CRC16算法是现成的网上到处都是直接拿来用即可。无论选哪种最怕的是发送端和接收端对“校验范围”的定义不一致。我的约定是校验起始字节是帧长那个位置一直到数据域末尾。也就是说帧头两个字节不参与校验。也可以把帧头加进去两种都行但两边必须严格统一。文档里一定要把这个范围写死。2.4 数据域的编码结构体在内存里没那么可靠很多C/C工程师下意识写一个struct里面塞一个float加两个int16然后memcpy整包发出去。这样做快是快但坑也大。第一个坑是字节序x86和ARM默认是小端下位机如果是大端直接发struct会造成数字变乱。第二个坑是结构体对齐编译器会在字段之间插入padding两边用的编译器如果对齐规则不一样解析出来全是错的。我折腾过两次之后在项目里定了一条死规矩协议里数据域全部用显式定义的整数类型且明确字节序为小端。浮点怎么办两个稳妥路子一用联合体union把float拆成4个uint8再逐字节发送接收端再拼回去二更推荐的做法是转成定点整数比如速度保留两位小数乘以100之后再编码成int16接收端再除以100还原浮点。定点整数方案对调试更友好因为串口助手里直接看到的是一个整数而不是一堆长得都差不多的十六进制字节。你的上位机被测出来速度是5200脑子里立刻知道这是52.00方便很多。3. 串口驱动与ROS节点封装从serial库到话题发布3.1 串口参数的风水波特率、超时、缓冲区定了协议开始写节点第一步是配置串口参数。这里有几个参数容易踩坑波特率这是双方必须一字的。115200是大多数电机板和IMU模块出厂默认值也是平衡速度和稳定性的选择。9600抗干扰好但传输效率低适合数据量极小、线缆很长的场合。460800以上对线材和USB转串口芯片的品质都有要求普通工控机自带的USB口经常丢数据我一般不推荐。数据位、停止位、校验位基本都用8N1即8个数据位、1个停止位、无校验。注意这里的“校验位”和前面说的帧校验是两码事别搞混。超时设置。Python的pyserial里timeout只影响读取时的最长等待时间我习惯设置成0.05秒也就是50毫秒。设置太长read会傻等ROS主循环卡住设置太短又可能没攒够数据就返回也没关系因为我们的解析只能靠状态机累积每次读到多少都喂进去就好。3.2 一个可复用的发布节点实现下面是完整的Python节点参考了我在实际项目里的写法。它做的事是打开串口、不断读取字节、喂给状态机解析器、解析到完整帧后按命令字组织成ROS话题发布。#!/usr/bin/env python3 import serial import rospy import struct from std_msgs.msg import Float32MultiArray FRAME_HEAD1 0xAA FRAME_HEAD2 0x55 class FrameState: WAIT_HEAD1 0 WAIT_HEAD2 1 WAIT_LEN 2 WAIT_DATA 3 WAIT_CHECK 4 class SerialParser: def __init__(self, port, baud, pub): self.ser serial.Serial(portport, baudratebaud, timeout0.05) self.state FrameState.WAIT_HEAD1 self.frame_len 0 self.payload [] # 存储长度之后的字节 self.received_cnt 0 self.pub pub def read_and_parse(self): # 一次性把串口缓冲区里的字节全部读走避免残留导致粘包 data self.ser.read(self.ser.in_waiting or 1) for b in data: self._feed_byte(b) def _feed_byte(self, b): b b if isinstance(b, int) else ord(b) if self.state FrameState.WAIT_HEAD1: if b FRAME_HEAD1: self.state FrameState.WAIT_HEAD2 elif self.state FrameState.WAIT_HEAD2: if b FRAME_HEAD2: self.state FrameState.WAIT_LEN elif b FRAME_HEAD1: # 半匹配状态错过一个字节后仍可能连续两个帧头 self.state FrameState.WAIT_HEAD2 else: self.state FrameState.WAIT_HEAD1 elif self.state FrameState.WAIT_LEN: self.frame_len b self.payload [] self.received_cnt 0 # 帧长为0是非法状态回到起点 self.state FrameState.WAIT_DATA if self.frame_len 0 else FrameState.WAIT_HEAD1 elif self.state FrameState.WAIT_DATA: self.payload.append(b) self.received_cnt 1 if self.received_cnt self.frame_len: self.state FrameState.WAIT_CHECK elif self.state FrameState.WAIT_CHECK: total 0 # 校验范围长度字节开始到数据域最后 for byte in [self.frame_len] self.payload: total byte if (total 0xFF) b: self._dispatch(self.payload) self.state FrameState.WAIT_HEAD1 def _dispatch(self, payload): # payload[0] 是命令字后面的都是数据域 cmd payload[0] data payload[1:] if cmd 0x01 and len(data) 4: # 例数据域为 2个int16单位0.01 speed_raw struct.unpack(h, bytes(data[0:2]))[0] voltage_raw struct.unpack(h, bytes(data[2:4]))[0] msg Float32MultiArray() msg.data [speed_raw / 100.0, voltage_raw / 100.0] self.pub.publish(msg) def main(): rospy.init_node(serial_protocol_node, anonymousTrue) port rospy.get_param(~port, /dev/ttyUSB0) baud rospy.get_param(~baud, 115200) pub rospy.Publisher(chassis_data, Float32MultiArray, queue_size10) parser SerialParser(port, baud, pub) rate rospy.Rate(100) while not rospy.is_shutdown(): try: parser.read_and_parse() except serial.SerialException as e: rospy.logerr_throttle(10, fSerial error: {e}) rate.sleep() if __name__ __main__: main()这段代码里有两个细节值得注意。一是read_and_parse里用了in_waiting or 1意思是如果缓冲区里有数据就全读走没有就最少读一个字节等它超时。这样设计是为了不让主循环空转也不会因为每次都只读一个字节而放大系统调用开销。二是校验失败后状态直接回到WAIT_HEAD1不会卡在WAIT_DATA里等死这是自恢复的关键。3.3 C节点的参考写法如果你是用C写ROS节点serial库是常见选择。配置串口的方式如下#include ros/ros.h #include serial/serial.h #include std_msgs/Float32MultiArray.h #include vector serial::Serial ser; bool parse_one_byte(uint8_t b, std::vectoruint8_t out_frame) { static int state 0; static int frame_len 0; static int cnt 0; static std::vectoruint8_t payload; switch (state) { case 0: if (b 0xAA) state 1; break; case 1: if (b 0x55) state 2; else if (b 0xAA) state 1; else state 0; break; case 2: frame_len b; payload.clear(); cnt 0; state (frame_len 0) ? 3 : 0; break; case 3: payload.push_back(b); cnt; if (cnt frame_len) state 4; break; case 4: { uint8_t total frame_len; for (auto x : payload) total x; if (total b) { out_frame payload; state 0; return true; } state 0; break; } default: state 0; break; } return false; } int main(int argc, char** argv) { ros::init(argc, argv, serial_protocol_cpp_node); ros::NodeHandle nh(~); std::string port nh.param(port, std::string(/dev/ttyUSB0)); int baud nh.param(baud, 115200); ser.setPort(port); ser.setBaudrate(baud); serial::Timeout timeout serial::Timeout::simpleTimeout(50); ser.setTimeout(timeout); ser.open(); ros::Publisher pub nh.advertisestd_msgs::Float32MultiArray(chassis_data, 10); ros::Rate rate(100); while (ros::ok()) { size_t available ser.available(); if (available 0) { std::vectoruint8_t buf ser.read(available); for (auto b : buf) { std::vectoruint8_t frame; if (parse_one_byte(b, frame)) { if (frame.size() 5 frame[0] 0x01) { int16_t speed_raw (frame[2] 8) | frame[1]; std_msgs::Float32MultiArray msg; msg.data.push_back(speed_raw / 100.0f); pub.publish(msg); } } } } ros::spinOnce(); rate.sleep(); } ser.close(); return 0; }注意C版本里我解析int16的时候用了小端还原(frame[2] 8) | frame[1]这就是为什么协议文档里要明确字节序。如果你把顺序写反数据全对但数字永远不对这类问题查起来最折磨人。4. 拆包与校验状态机是串口解析的命门4.1 为什么不能简单地“等一帧读一帧”初学阶段我干过一件傻事在read函数里等固定字节数凑够了就解析。比如认为底盘数据固定8字节就每次read(8)。看着没问题但串口是流一次read(8)返回的数据可能是上半帧加下一帧的头部也可能是一帧半。等你自以为拿到一帧时里面的字节已经错位了。更隐蔽的是你的下位机如果偶尔发了一条错误帧长度字段写错接收端就会死等一个不存在的字节。然后真正的数据帧陆续到来缓冲区爆掉整个链路就停摆了。所以解析端必须是一个增量状态机每个新字节进来都基于当前状态做一次判断而不是依赖“一次性读到完整帧再干活”。这也是整套串口通信方案里我认为最核心的代码逻辑。4.2 状态机的三种实现境界拆包状态机的写法有三层境界。第一层是函数式写法一个公共函数接收可变参数函数内部通过临时变量硬凑一帧。这种代码在个人小项目里能跑但状态会散落在函数的局部变量里多帧并发或异常场景一来就乱。第二层是switch状态机就是我前面代码里的写法。它把解析状态拆成WAIT_HEAD1、WAIT_HEAD2、WAIT_LEN、WAIT_DATA、WAIT_CHECK五个状态每个字节都进入状态机走一遍。逻辑清晰调试时打印状态变量一目了然。第三层是状态机对象化。把解析器封装成独立类输入是原始字节流输出是完整帧回调。这种方式可复用性最好你换一个机器人项目把帧协议改一改就能直接搬过去。我目前在项目里用的就是这种因为同时要解析IMU、雷达、底盘三路串口共用一套代码框架维护成本低很多。无论哪一层核心思想都一样基于当前状态和当前字节做转移遇到错误数据要能回到初始状态。4.3 校验计算与边界条件处理校验这里有不少边界条件容易写错我挨个说。第一校验范围。我建议从帧长字节开始算到数据域结束。有些人容易把帧头也算进去两边没对齐就会出现“所有数据都对但校验永远失败”的诡异问题。一旦遇到先查校验首字节到底从哪开始这是最常见的低级错误。第二帧长字段的合法性。长度字段如果是0说明这帧是无效的直接回到等待帧头状态。长度字段如果远大于数据域的合理值可能导致接收端一直处于WAIT_DATA所以最好定一个协议最大帧长比如64字节超过就丢弃。这个上限在初版协议里就要写清楚。第三半匹配陷阱。我前面提到过在WAIT_HEAD1收到0x55这个值本身时应该进入WAIT_HEAD2等待0xAA而不是傻等0xAA。因为如果前面一个字节被干扰丢了剩下的字节流可能是55 AA 55只有把0x55当作潜在的帧头2才能在那条不完整链路里找回正确的帧头组合。这个细节不处理你就可能莫名其妙地丢掉第一帧然后在下一帧又正常表现成“每隔一会儿就丢一条数据”。第四校验失败后的回退。一个态度不明确的解析器在WAIT_DATA状态下会死等把后面所有数据都吞掉。正确做法是校验失败立刻回到WAIT_HEAD1从失败帧的下一个字节开始重新找帧头。这样最坏的情况只是丢掉当前错的帧下一帧只要能对上帧头就能恢复。5. 调参、踩坑与线上问题排查5.1 数据对不上先怀疑这三件事串口连上但数据不对是最常遇到的问题。我的排查顺序永远是权限、设备名、波特率。权限问题在Linux下最常见。插上USB转串口后ls -l /dev/ttyUSB0发现属于dialout组而你的用户不在这个组里读写都会报权限错误或不返回数据。解决办法是一次性把用户加进组sudo usermod -a -G dialout $USER然后重新登录。设备名问题容易被忽略。多个USB转串口设备插上去系统分配的端口可能是ttyUSB0、ttyUSB1顺序不一定固定。重启后还可能交换。稳妥的做法是用udev规则按设备序列号生成固定软链接或者每次启动时动态扫描/dev/ttyUSB*匹配到能打开的串口再绑定。波特率不一致的症状很典型能收到数据但全是乱码或者解析偶尔成功偶尔失败。检查两边配置是不是都8N1速率是不是同一数值。5.2 偶发丢包不是代码慢是制度问题数据只丢几条、不规律性丢失这类问题最难排查因为它可能不是某一行代码的锅。先看接收端读得快不快。如果ROS节点里read间隔太长串口内核缓冲区里数据会被新数据覆盖。串口驱动缓冲区默认不算大大流量下丢字节是现实。我的做法是保证读取循环足够频繁比如100Hz以上并且每次把in_waiting里所有数据全部取完不要只读固定的几个字节。再看线材和电气环境。电机PWM调速时丢包率明显上升多半是电源地上有噪声。这种时候可以降低波特率或改用带屏蔽的串口线或把下位机串口的地和上位机串口的地连得更好。有一回我在项目里加了一个磁环丢包率直接从千分之一下降到零这种经验很难从教科书里学到。再看下位机缓冲区。很多MCU的串口接收是中断驱动的如果主循环里处理任务时间太长中断响应不过来硬件接收缓冲溢出就会丢字节。这种丢包往往表现为“连续的帧里偶尔缺一两个字节”而不是整帧丢失。下位机侧的解决办法是把接收逻辑放到中断里或者用DMA搬运。5.3 实战排查清单下面这张表是我在现场排查问题时常用的速查清单直接照着查能省大量时间现象可能原因排查办法完全收不到数据权限/设备名/线序ls -l /dev/ttyUSB*dmesg收到但全是乱码波特率/数据位不一致双边核对串口参数用十六进制显示看原始字节数据偶发缺失缓冲区丢数据/下位机溢出提高读取频率减小下位机发送间隔观察丢包节奏校验老失败帧长字段算错/校验范围不一致打印原始字节hex人工按协议手算一遍校验数值正确但正负反/高低位反字节序错误小端协议下高字节在后低字节在前运行几小时后卡死串口被异常关闭/句柄泄漏检查程序退出逻辑用lsof看端口占用另外提一个我强烈建议的做法在节点里加一个十六进制日志开关需要的时候可以按字节打印收到的原始数据。很多人调试串口喜欢看转换后的业务数值一旦数据不对就两眼一抹黑。我习惯先看原始bytes再在脑子里或者纸上手算一遍帧长和校验基本能定位九成问题。5.4 关于ROS的时钟与话题调试串口节点发布话题后常用rostopic echo来检查数据。要注意的是如果你的节点发布频率很高rostopic echo默认会丢消息看起来像偶发丢包。想确认是不是真的丢包用rostopic hz /topic_name看发布频率只要频率稳定、数值正常问题就不在节点上。还有一个不太起眼但影响很大的点ROS节点的日志级别。调试串口时会打很多日志但生产环境里每一条log都可能是性能隐患。我一般在调试时候才打开verbose日志确认没问题后关掉只保留错误级别。6. 这套方案后续可以怎么扩展串口协议的意义不止于串口本身。我后来在项目里发现这套“帧头长度命令字校验”的骨架换成CAN总线、UDP组播甚至Modbus RTU思路几乎完全一样。状态机拆包的本质是“从无结构流中提取有结构帧”这个能力在低速总线时代极其值钱在以太网时代同样有用。比如你要加一块新的激光雷达它输出的是自定义UDP包ROS里只需要拿到那个端口的字节流解析逻辑依然是状态机只是把serial.read换成socket.recv而已。我最近写过一个同时对接四路异构传感器的中间层里面用到的拆包器就是从串口解析这个类直接改过来的。我个人在实际操作中的体会是串口通信没有玄学绝大多数问题都能还原成一个字段、一个状态、一个字节序的事。最怕的从来不是问题难而是协议文档没写清楚、代码状态散乱、排查没有章法。把这套帧结构和状态机逻辑沉淀成自己团队的标准件后续不管接什么设备都会轻松一大截。最后再分享一个小技巧协议里给每条上行数据帧加一个从0开始的帧序号比如放在命令字之后。这个序号如果产生跳变说明中间丢了帧你可以精确统计丢包率而不是靠猜。它花掉的只是数据域一两个字节回报却是让整条链路的健康状况变得可观测。这是很多新手协议里没有、但老项目中几乎都有的字段。