工业级串口通信设计
1. 工业级串口通信的特点
在工业控制、仪器仪表、传感器数据采集等场景中,串口(UART/RS232/RS485)依然是常用的通信方式。相比消费电子,工业串口通信有几个特点:
-
高可靠性要求
数据丢失或错误可能导致设备停机、生产事故,因此必须有帧同步、长度标识、校验机制。 -
多设备、多协议复用
一个总线(比如 RS485)上可能挂多个设备,每个设备可能有不同的命令/数据类型,需要通过帧头(prefix)区分。 -
环境干扰大
工厂现场存在电磁干扰,串口数据可能出现毛刺、丢字节、错码,因此接收逻辑必须有容错和快速同步能力。 -
实时性要求
控制系统往往要求数据延迟可控,接收解析必须高效,不能阻塞主线程。
2. 帧格式设计
在基于 prefix 的串口协议中,帧结构通常如下(示例):
字段 | 长度(字节) | 说明 |
---|---|---|
STX | 1 | 帧起始符,固定值如 0x55 |
Prefix | 1~2 | 帧类型标识,区分不同命令或设备 |
Length | 1~2 | 后续 Payload 的字节数 |
Payload | N | 实际数据内容 |
Checksum | 1~2 | 校验和(累加和、CRC16、CRC32等) |
设计理由:
- STX 用于快速定位帧的起点。
- Prefix 用于区分不同的消息类型,方便上层分发处理。
- Length 用于动态接收 payload,避免粘包/拆包问题。
- Checksum 用于检测传输错误。
3. 接收流程(状态机思想)
工业级串口接收一般采用状态机来解析数据,避免混乱。流程如下:
-
空闲状态(Idle)
- 不断读取串口字节,直到检测到 STX。
- 一旦找到 STX,进入下一状态。
-
接收 Prefix
- 读取 Prefix 字节(1~2字节)。
- 保存 Prefix,进入接收 Length 状态。
-
接收 Length
- 读取 Length 字节,解析出 payload 长度 N。
- 进入接收 Payload 状态。
-
接收 Payload
- 持续读取 N 个字节存入缓冲区。
- 读完后进入接收 Checksum 状态。
-
接收 Checksum
- 读取 Checksum 字节,并与本地计算值比较。
- 校验正确:将帧数据(含 Prefix、Payload)提交给上层处理,回到 Idle。
- 校验错误:丢弃本帧,回到 Idle,并记录错误日志。
-
异常处理
- 若超时未收完一帧(例如 100ms 无新数据),复位状态机。
- 若在接收过程中再次收到 STX,说明帧同步错误,立即复位并从新 STX 开始。
4. C++实现
下面给出一个工业级可用的串口帧接收类和发送类,包含状态机和缓冲区管理。
帧格式:
[STX] [Prefix] [Length] [Payload] [Checksum]
- STX:固定
0x55
- Prefix:帧类型标识(1字节)
- Length:Payload 长度(1字节)
- Payload:数据内容(长度可变)
- Checksum:所有字节累加和的低8位
(1)接收器代码
#include <cstdint>
#include <vector>
#include <functional>
#include <chrono>
#include <thread>
#include <iostream>// 帧结构定义
struct Frame {uint8_t prefix;std::vector<uint8_t> payload;
};// 环形缓冲区,用于串口数据缓存
template <size_t N>
class RingBuffer {
public:bool push(uint8_t data) {size_t next = (head + 1) % N; //计算下一位的位置if (next == tail) return false; // 满buffer[head] = data;head = next;return true;}bool pop(uint8_t& data) {if (head == tail) return false; // 空data = buffer[tail];tail = (tail + 1) % N;return true;}size_t size() const {return (head - tail + N) % N;}bool empty() const {return head == tail;}private:uint8_t buffer[N];size_t head = 0;size_t tail = 0;
};class SerialFrameReceiver {
public:using FrameHandler = std::function<void(const Frame&)>;SerialFrameReceiver(uint8_t prefix, size_t maxPayloadSize = 256): targetPrefix(prefix), maxPayload(maxPayloadSize) {reset();}// 喂入串口数据(由串口中断或读取线程调用)void feed(uint8_t byte) {rb.push(byte);}// 处理缓冲区中的数据void process() {uint8_t byte;while (rb.pop(byte)) {stateMachine(byte);}}void setFrameHandler(FrameHandler handler) { //handler是一个函数封装器,相当于std::function(函数),用来代指函数(函数对象functors),下文调用的时候把lambda函数传入setFrameHandler函数。this->handler = handler; //给lambda函数创建函数封装器,并将其赋值给成员变量handler}private://状态机设计enum class State {Idle,GotSTX,GotPrefix,GotLength,GettingPayload,GotChecksum};void reset() {state = State::Idle;currentFrame.prefix = 0;currentFrame.payload.clear();payloadLength = 0;received = 0;checksum = 0;lastTime = std::chrono::steady_clock::now();}void stateMachine(uint8_t byte) {switch (state) {case State::Idle:if (byte == STX) {state = State::GotSTX;checksum = byte;}break;case State::GotSTX:if (byte == targetPrefix) {currentFrame.prefix = byte;checksum += byte;state = State::GotPrefix;} else {reset(); // 前缀不匹配,复位}break;case State::GotPrefix:payloadLength = byte;if (payloadLength == 0 || payloadLength > maxPayload) {reset(); // 长度非法break;}checksum += byte;currentFrame.payload.reserve(payloadLength);state = State::GettingPayload;break;case State::GettingPayload:currentFrame.payload.push_back(byte);checksum += byte;received++;if (received == payloadLength) {state = State::GotChecksum;}break;case State::GotChecksum:if (byte == (checksum & 0xFF)) {if (handler) handler(currentFrame);//handler 是通过 setFrameHandler 保存的 lambda函数} else {std::cerr << "Checksum error\n";}reset();break;}}static constexpr uint8_t STX = 0x55;uint8_t targetPrefix;size_t maxPayload;RingBuffer<1024> rb;FrameHandler handler;State state = State::Idle;Frame currentFrame;size_t payloadLength = 0;size_t received = 0;uint8_t checksum = 0;std::chrono::steady_clock::time_point lastTime;
};// 示例:模拟串口数据接收
int main() {SerialFrameReceiver receiver(0xAA, 128);receiver.setFrameHandler([](const Frame& frame) {std::cout << "Received frame, prefix: 0x" << std::hex << static_cast<int>(frame.prefix) << std::dec;std::cout << ", payload size: " << frame.payload.size() << std::endl;});// 模拟一帧数据: STX(0x55) + Prefix(0xAA) + Length(0x03) + Payload(0x01 0x02 0x03) + Checksum(0xFF)uint8_t testFrame[] = {0x55, 0xAA, 0x03, 0x01, 0x02, 0x03, 0xFF};for (auto b : testFrame) {receiver.feed(b);}receiver.process();// 模拟错误帧uint8_t badFrame[] = {0x55, 0xAA, 0x02, 0x11, 0x22, 0x00}; // checksum wrongfor (auto b : badFrame) {receiver.feed(b);}receiver.process();return 0;
}
(2)发送器代码
#include <cstdint>
#include <vector>
#include <functional>
#include <iostream>struct Frame {uint8_t prefix;std::vector<uint8_t> payload;
};class SerialFrameSender {
public:using SendHandler = std::function<void(const uint8_t*, size_t)>;SerialFrameSender(SendHandler sender)//构造函数,实例化的时候要传入SendHandler类型的函数: sendHandler(sender) {}//用sender去初始化成员变量sendHandler// 将 Frame 打包并发送bool sendFrame(const Frame& frame) {if (frame.payload.size() > 255) {std::cerr << "Payload too large (max 255 bytes)\n";return false;}// 组装帧std::vector<uint8_t> buffer;buffer.reserve(4 + frame.payload.size()); // STX + Prefix + Length + Payload + Checksumuint8_t STX = 0x55;buffer.push_back(STX);buffer.push_back(frame.prefix);buffer.push_back(static_cast<uint8_t>(frame.payload.size()));for (uint8_t b : frame.payload) {buffer.push_back(b);}// 计算校验和uint8_t checksum = STX + frame.prefix + static_cast<uint8_t>(frame.payload.size());for (uint8_t b : frame.payload) {checksum += b;}buffer.push_back(checksum);// 调用发送回调(例如串口发送函数)if (sendHandler) {sendHandler(buffer.data(), buffer.size());return true;}return false;}private:SendHandler sendHandler;
};// 示例:模拟串口发送
int main() {// 模拟串口发送函数auto uartSend = [](const uint8_t* data, size_t len) {std::cout << "Sending " << len << " bytes: ";for (size_t i = 0; i < len; ++i) {printf("%02X ", data[i]);}std::cout << std::endl;};SerialFrameSender sender_test(uartSend);// 创建一帧数据Frame frame;frame.prefix = 0xAA;frame.payload = {0x01, 0x02, 0x03};// 发送帧sender.sendFrame(frame);return 0;
}
(3)发送流程
-
检查长度
因为Length
字段是 1 字节,所以 payload 最大长度是 255。 -
组装帧头
- 写入
STX
(0x55) - 写入
Prefix
(帧类型) - 写入
Length
(payload 长度)
- 写入
-
写入 payload
-
计算校验和
从 STX 开始,把所有字节累加,取低 8 位。 -
调用发送回调
用户需要传入一个实际的发送函数(比如write()
到串口)。
(4) 运行示例
假设我们发送:
prefix = 0xAA
payload = {0x01, 0x02, 0x03}
组装后的帧为:
0x55 0xAA 0x03 0x01 0x02 0x03 0xFF
0x55
是 STX0xAA
是 Prefix0x03
是 payload 长度0x01 0x02 0x03
是 payload0xFF
是校验和(累加和 = 0x1FF → 低8位 0xFF)
运行输出:
Sending 7 bytes: 55 AA 03 01 02 03 FF
(5) 扩展建议
- 支持不同校验算法(如 CRC16/CRC32)
- 支持批量发送(多个帧一次性发送)
- 增加发送队列(在多线程环境下安全)
- 支持可变长度的 Length 字段(比如 2 字节表示更大长度)
5. 代码特点与工业适配建议
-
状态机驱动
避免了复杂的 if-else 嵌套,逻辑清晰,容易调试和扩展。 -
环形缓冲区
串口数据可能是中断方式接收,用环形缓冲可以解耦数据接收和解析。 -
可配置 prefix
支持多协议,同一串口可以接收不同类型帧。 -
校验机制
当前是简单累加和,工业中可替换为 CRC16/CRC32 提高抗干扰性。 -
超时处理
可以在process()
中加入超时判断,防止卡死在某个状态。
6. 测试与调试
- 正常帧测试:发送符合格式的数据,验证能否正确解析。
- 错误帧测试:修改 payload 或 checksum,验证是否能丢弃。
- 边界测试:payload 长度为 0、最大长度、STX 出现在 payload 中间等情况。
- 性能测试:在高波特率(例如 115200、921600)下连续发送数据,确保无丢帧。
工业级串口接收流程必须具备:
- 可靠的帧同步(STX + Prefix)
- 动态长度解析(Length字段)
- 数据校验(Checksum/CRC)
- 异常处理与快速恢复
- 高效缓冲与低延迟
最后给出一套liunx系统上C++实现串口发送和接收的实用代码
[STX] [Prefix] [Length] [Payload] [Checksum]
- STX:
0x55
- Prefix:帧类型(1字节)
- Length:Payload 长度(1字节)
- Payload:数据内容
- Checksum:累加和低8位
发送
#include <cstdint>
#include <vector>
#include <iostream>
#include <fcntl.h>
#include <unistd.h>
#include <termios.h>
#include <cstring>
#include <stdexcept>struct Frame {uint8_t prefix;std::vector<uint8_t> payload;
};class LinuxSerialFrameSender {
public:// 打开串口并配置LinuxSerialFrameSender(const std::string& port = "/dev/ttyUSB0", speed_t baud = B115200) {fd = open(port.c_str(), O_WRONLY | O_NOCTTY);//open函数是C接口,不支持std::stirng类型,需要用c_str()转为C风格字符串if (fd < 0) {throw std::runtime_error("无法打开串口设备: " + port);}struct termios tio;memset(&tio, 0, sizeof(tio));// 配置串口tio.c_cflag = baud | CS8 | CLOCAL | CREAD; // 波特率 | 8数据位 | 本地连接 | 允许接收tio.c_iflag = IGNPAR; // 忽略奇偶校验错误tio.c_oflag = 0;tio.c_lflag = 0;tio.c_cc[VTIME] = 0; // 读取超时tio.c_cc[VMIN] = 1; // 最小读取字符数// 清理串口缓存并应用配置tcflush(fd, TCIFLUSH);if (tcsetattr(fd, TCSANOW, &tio) != 0) {close(fd);throw std::runtime_error("无法配置串口");}std::cout << "串口 " << port << " 已打开,波特率 " << baud << std::endl;}~LinuxSerialFrameSender() {if (fd >= 0) {close(fd);std::cout << "串口已关闭" << std::endl;}}// 发送一帧数据bool sendFrame(const Frame& frame) {if (frame.payload.size() > 255) {std::cerr << "Payload 超过最大长度 255\n";return false;}std::vector<uint8_t> buffer;buffer.reserve(4 + frame.payload.size()); // STX(1) + Prefix(1) + Length(1) + Payload(n) + Checksum(1)uint8_t STX = 0x55;buffer.push_back(STX);buffer.push_back(frame.prefix);buffer.push_back(static_cast<uint8_t>(frame.payload.size()));for (uint8_t b : frame.payload) {buffer.push_back(b);}// 计算校验和uint8_t checksum = STX + frame.prefix + static_cast<uint8_t>(frame.payload.size());for (uint8_t b : frame.payload) {checksum += b;}buffer.push_back(checksum);// 发送数据ssize_t n = write(fd, buffer.data(), buffer.size());if (n < 0) {perror("write");return false;}std::cout << "已发送 " << n << " 字节: ";for (size_t i = 0; i < buffer.size(); ++i) {printf("%02X ", buffer[i]);}std::cout << std::endl;return true;}private:int fd = -1; // 串口文件描述符
};int main() {try {LinuxSerialFrameSender sender("/dev/ttyUSB0", B115200);Frame frame;frame.prefix = 0xAA;frame.payload = {0x01, 0x02, 0x03};sender.sendFrame(frame);} catch (const std::exception& e) {std::cerr << "错误: " << e.what() << std::endl;return -1;}return 0;
}
接收
#include <cstdint>
#include <vector>
#include <functional>
#include <iostream>
#include <fcntl.h>
#include <unistd.h>
#include <termios.h>
#include <cstring>
#include <stdexcept>struct Frame {uint8_t prefix;std::vector<uint8_t> payload;
};class LinuxSerialFrameReceiver {
public:using FrameHandler = std::function<void(const Frame&)>;// 打开串口并配置LinuxSerialFrameReceiver(const std::string& port = "/dev/ttyUSB0", speed_t baud = B115200) {fd = open(port.c_str(), O_RDONLY | O_NOCTTY);if (fd < 0) {throw std::runtime_error("无法打开串口设备: " + port);}struct termios tio;memset(&tio, 0, sizeof(tio));// 配置串口tio.c_cflag = baud | CS8 | CLOCAL | CREAD; // 波特率 | 8数据位 | 本地连接 | 允许接收tio.c_iflag = IGNPAR; // 忽略奇偶校验错误tio.c_oflag = 0;tio.c_lflag = 0;tio.c_cc[VTIME] = 0; // 读取超时tio.c_cc[VMIN] = 1; // 最小读取字符数// 清理串口缓存并应用配置tcflush(fd, TCIFLUSH);if (tcsetattr(fd, TCSANOW, &tio) != 0) {close(fd);throw std::runtime_error("无法配置串口");}std::cout << "串口 " << port << " 已打开,波特率 " << baud << std::endl;}~LinuxSerialFrameReceiver() {if (fd >= 0) {close(fd);std::cout << "串口已关闭" << std::endl;}}// 设置帧处理回调函数void setFrameHandler(FrameHandler handler) {this->handler = handler;}// 读取并解析数据void readLoop() {uint8_t byte;ssize_t n;while ((n = read(fd, &byte, 1)) == 1) {stateMachine(byte);}if (n < 0) {perror("read");}}private:enum class State {Idle,GotSTX,GotPrefix,GotLength,GettingPayload,GotChecksum};void reset() {state = State::Idle;currentFrame.prefix = 0;currentFrame.payload.clear();payloadLength = 0;received = 0;checksum = 0;}void stateMachine(uint8_t byte) {switch (state) {case State::Idle:if (byte == STX) {state = State::GotSTX;checksum = byte;}break;case State::GotSTX:currentFrame.prefix = byte;checksum += byte;state = State::GotPrefix;break;case State::GotPrefix:payloadLength = byte;if (payloadLength == 0 || payloadLength > maxPayload) {reset();break;}checksum += byte;currentFrame.payload.reserve(payloadLength);state = State::GettingPayload;break;case State::GettingPayload:currentFrame.payload.push_back(byte);checksum += byte;received++;if (received == payloadLength) {state = State::GotChecksum;}break;case State::GotChecksum:if (byte == (checksum & 0xFF)) {if (handler) handler(currentFrame);} else {std::cerr << "校验和错误\n";}reset();break;}}static constexpr uint8_t STX = 0x55;static constexpr size_t maxPayload = 255;int fd = -1;FrameHandler handler;State state = State::Idle;Frame currentFrame;size_t payloadLength = 0;size_t received = 0;uint8_t checksum = 0;
};int main() {try {LinuxSerialFrameReceiver receiver("/dev/ttyUSB0", B115200);receiver.setFrameHandler([](const Frame& frame) {std::cout << "收到帧, prefix: 0x" << std::hex << static_cast<int>(frame.prefix) << std::dec;std::cout << ", payload 长度: " << frame.payload.size() << std::endl;std::cout << "Payload: ";for (uint8_t b : frame.payload) {printf("%02X ", b);}std::cout << std::endl;});std::cout << "开始接收数据...\n";receiver.readLoop();} catch (const std::exception& e) {std::cerr << "错误: " << e.what() << std::endl;return -1;}return 0;
}
3. 代码说明
3.1 串口初始化
- 用
open()
打开串口设备,O_RDONLY
只读模式,O_WRONLY
只写模式,O_NOCTTY
不要把这个设备当成控制终端 - 用
termios
配置串口参数:- 波特率(默认 115200)
- 8数据位、无校验、1停止位(8N1)
- 忽略奇偶校验错误
- 最小读取 1 字节
3.2 帧解析状态机
- Idle:等待
STX (0x55)
- GotSTX:收到帧头,等待
Prefix
- GotPrefix:收到帧类型,等待
Length
- GettingPayload:循环接收 payload 数据
- GotChecksum:验证校验和,如果正确调用回调
3.3 回调机制
- 用户通过
setFrameHandler()
设置帧处理函数 - 解析成功时调用回调,把完整的
Frame
对象传出去
4. 编译与运行
编译:
g++ serial_receiver.cpp -o serial_receiver
运行(需要串口权限):
sudo ./serial_receiver
如果权限不足:
sudo usermod -aG dialout $USER
然后注销重新登录。
5. 测试方法
- 用 USB 转串口模块连接两台电脑(或一台电脑两个串口)
- 一个终端运行:
./serial_sender
- 另一个终端运行:
./serial_receiver
- 你会看到
serial_sender
发送帧,serial_receiver
收到并打印帧内容
- 这个版本直接在 Linux 下从串口读取字节
- 内置状态机解析帧,和发送器的帧格式完全匹配
- 使用回调机制通知上层处理解析到的帧
- 支持配置串口名和波特率