1. 项目概述:为什么要在C++里折腾HDLC?
如果你在嵌入式、工业通信或者一些老牌通信设备公司的代码里翻过,大概率会碰到HDLC(高级数据链路控制)协议。乍一看,这协议名字里带个“高级”,但其实是上个世纪70年代由ISO标准化组织定下的老古董了。那为什么我们今天还要用C++去实现它?直接原因很现实:大量现存的工业设备(比如电力SCADA系统、铁路信号系统、甚至一些金融交易的老旧前置机)、传统的电信传输设备(如SDH/PDH),它们的底层帧封装和链路管理,依然顽固地运行在HDLC或其变种(如Cisco HDLC、PPP in HDLC-like帧)上。
我接手过不少这类项目的维护和升级,发现一个共性:很多团队要么直接调用厂商提供的封闭库(调试起来像黑盒),要么就是网上找点代码片段缝缝补补,链路不稳的时候,根本不知道问题出在协议栈的哪一层。用C++重新实现一遍HDLC,远不止是“造轮子”,而是一次彻底的“协议解剖”。它能让你对帧同步、透明传输、CRC校验、链路管理有肌肉记忆般的理解。当你用C++的类把帧结构、状态机、缓冲区管理清晰地封装起来后,再遇到“偶尔丢帧”、“CRC校验老失败”这种玄学问题,你就有能力从内存比特位开始排查,那种掌控感是完全不同的。
这个项目适合谁呢?首先是需要与传统工业设备打交道的嵌入式软件工程师;其次是学习网络协议、想深入理解数据链路层“枯燥”细节的学生或爱好者;再者,对于那些面临老旧系统现代化改造,需要剥离旧有通信库、构建更可控通信中间件的团队,这也是一个绝佳的练手项目。我们将从协议原理开始,用C++面向对象的思想,一步步构建一个可读、可测、可用的HDLC协议栈,并探讨它在实际场景中的应用与调试技巧。
2. HDLC协议核心原理与C++映射
2.1 帧结构:不止是头尾那么简单
HDLC的帧结构是它的骨架,教科书上都会画那个经典的图:标志序列(F)、地址字段(A)、控制字段(C)、信息字段(I)、帧校验序列(FCS)、标志序列(F)。但用C++实现时,你不能只把它看成几个字节的拼接,而要理解每个字段在链路控制中的角色。
标志序列 (Flag, 0x7E):这是帧的分隔符,也是实现“透明传输”的关键。因为信息字段(I)里如果出现了0x7E,接收方就会误认为是帧结束。所以HDLC规定了“比特填充”规则:发送端在连续5个‘1’后,自动插入一个‘0’;接收端则删除连续5个‘1’后的那个‘0’。在C++里,这要求我们的发送/接收缓冲区不能是简单的字节数组,而需要支持比特级的操作。一个高效的实现是维护一个比特缓冲区,或者使用位操作在字节流中动态插入和删除。
地址字段 (Address):在点对点链路中,这个字段常被用来区分命令和响应,而非真正的地址。比如,一端固定发送0x03,另一端发送0x01。在C++类设计中,我们可以将其作为一个可配置的成员变量。
控制字段 (Control):这是HDLC的“大脑”,决定了帧的类型:信息帧(I-frame)用于传输数据、监控帧(S-frame)用于流量控制和差错恢复(如RR, RNR, REJ)、无编号帧(U-frame)用于链路建立和拆除(如SABM, DISC, UA)。在C++中,最好的方式是用一个enum class来定义所有这些帧类型,并通过一个ControlField类来解析和构建这个字节。
信息字段 (Information):就是上层(通常是IP层)传下来的数据包。这里的关键是,HDLC本身不关心其内容,我们的C++实现需要提供一个灵活的接口,可以承载任意长度的数据。
帧校验序列 (FCS):通常使用CRC-16-CCITT(多项式0x1021)或CRC-32。这里有个大坑:HDLC的FCS计算范围是地址、控制和信息字段,而不包括标志序列和为实现透明传输而插入的“0”。这意味着我们的CRC计算模块,必须能处理经过比特填充/删除后的“净荷”数据。我建议将CRC计算器单独封装成一个类,提供update(逐字节或逐比特更新)和finalize方法。
注意:很多初学者实现的HDLC CRC校验失败,就是因为直接在原始的、未做比特填充的字节流上计算,或者错误地包含了标志位。务必让CRC计算与比特填充/删除流程在同一个数据流水线上。
2.2 链路操作与状态机:协议活起来的灵魂
HDLC不是静态的帧格式,它是一套动态的链路管理规则,核心是一个链路层状态机。常见的状态包括“断开”、“已建立”、“繁忙”等。状态之间的转换由U帧(如SABM建立链路)和S帧(如RR确认)触发。
用C++实现这个状态机,我强烈推荐使用“状态模式”。不要用一堆if-else或者switch-case来硬编码状态逻辑,那样代码会很快变得难以维护。我们可以定义一个抽象的HdlcState基类,然后派生出DisconnectedState、AwaitingEstablishmentState、ConnectedState等具体状态类。HdlcProtocol这个主类持有一个当前状态对象的指针。当收到一帧或发生超时事件时,就调用当前状态对象的handleFrame或handleTimeout方法。这样,每个状态的逻辑被封装在独立的类里,清晰且易于扩展。
例如,在DisconnectedState的handleFrame方法里,如果收到一个SABM(置异步平衡模式)命令帧,它会验证地址字段,然后构造一个UA(无编号确认)响应帧发送回去,并将协议主类的状态切换到ConnectedState。
class HdlcState { public: virtual ~HdlcState() = default; virtual void handleFrame(HdlcProtocol& context, const HdlcFrame& frame) = 0; virtual void handleTimeout(HdlcProtocol& context) = 0; // ... 其他事件 }; class ConnectedState : public HdlcState { public: void handleFrame(HdlcProtocol& context, const HdlcFrame& frame) override { switch(frame.getType()) { case FrameType::I_FRAME: // 处理信息帧,向上层传递数据,并发送RR确认 context.deliverData(frame.getInformation()); context.sendSupervisoryFrame(SupervisoryType::RR); break; case FrameType::S_FRAME: // 处理监控帧,如RR表示对方可以接收,可以滑动发送窗口 context.updateSendWindow(); break; // ... 其他类型处理 } } };3. C++实现HDLC协议栈的关键模块设计
3.1 帧的封装与解析器
这是最核心的模块,负责在原始字节流和逻辑上的HdlcFrame对象之间进行转换。设计上应该分为HdlcEncoder(编码器)和HdlcDecoder(解码器)。
编码器 (Encoder)的工作流程:
- 输入:一个包含地址、控制类型、信息数据的
HdlcFrame对象。 - 构建原始字段:将地址、控制字段字节和信息字段拼接成一个临时缓冲区。
- 计算FCS:对上述缓冲区计算CRC,并将2字节的FCS附加在后面。
- 比特填充:对整个“地址+控制+信息+FCS”的数据块进行扫描,遇到连续5个‘1’就插入一个‘0’。这是算法核心,需要仔细处理边界情况(跨字节边界)。
- 添加标志位:在填充后的数据块首尾各加上一个0x7E标志字节。
- 输出:返回一个完整的、可供发送的字节数组(
std::vector<uint8_t>)。
解码器 (Decoder)的工作流程更复杂,因为它要处理连续的、可能包含错误的字节流:
- 状态保持:解码器需要内部状态,因为一帧可能分多次从串口或Socket读到。我常用一个
DecoderState枚举,包括SEARCHING_FLAG(寻找帧开始)、READING_FRAME(接收帧内容)、ESCAPING(处理比特填充的删除)等。 - 逐字节处理:提供一个
feedByte(uint8_t byte)方法。在SEARCHING_FLAG状态,持续丢弃非0x7E的字节,直到找到0x7E,进入READING_FRAME状态。 - 比特删除与缓冲区积累:在
READING_FRAME状态,对每个字节进行比特流分析,实现“零删除”逻辑,并将删除后的比特重组为字节,存入一个帧内容缓冲区。 - 帧定界与验证:再次遇到0x7E时,认为帧结束。对帧内容缓冲区(此时应已去除首尾标志和填充比特)进行FCS校验。如果校验通过,则解析出地址、控制、信息字段,构造一个
HdlcFrame对象通知上层;如果失败,则丢弃该帧,并可能通过S帧(REJ)请求重传。 - 处理异常:帧过长(超过最大长度限制)、缓冲区溢出、长时间找不到结束标志等情况都需要考虑,并重置解码器状态。
3.2 发送与接收窗口管理(滑动窗口协议)
HDLC在异步平衡模式(ABM)下,通过滑动窗口协议实现全双工通信和流量控制。这意味着我们需要维护两个关键变量:V(S)(发送状态变量)和V(R)(接收状态变量)。
V(S):表示下一个要发送的I帧的序号(N(S))。每发送一个新的I帧,V(S)加1(模某个模数,通常是8或128)。V(R):表示期望收到的下一个I帧的序号(N(R))。当收到一个序号正确的I帧时,V(R)加1。
在C++实现中,我们需要一个SendWindow类来管理已发送但未确认的I帧。这个窗口有大小限制(比如7)。当发送一个I帧时,将其副本存入窗口并启动一个重传定时器。当收到对方发来的S帧(如RR,且其N(R)大于某个已发送帧的序号),就意味着该帧及之前的所有帧已被确认,可以从窗口中移除并停止相应的定时器。如果定时器超时,则需要重传窗口中最旧的那个未确认帧。
接收端则需要一个RecvWindow来管理按序接收。如果收到的I帧序号N(S)正好等于V(R),就接受它并递交给上层,同时V(R)加1。如果序号不对(比如收到一个未来的帧),可能需要缓存它(如果支持选择性重传SREJ,但标准HDLC常用REJ,即回退N帧),或者直接丢弃并发送REJ帧请求重传。
class SendWindow { public: bool canSend() const { return (nextSeqNum - lastAckSeqNum) % MODULUS < WINDOW_SIZE; } void markSent(uint8_t seq, const std::vector<uint8_t>& frameData) { // 存储帧数据和启动定时器 window[seq] = {frameData, std::chrono::steady_clock::now()}; } void ackUpTo(uint8_t ackSeq) { // 确认直到ackSeq(不含)的所有帧,清理窗口 // ... } std::optional<std::vector<uint8_t>> getFrameForRetransmission() { // 检查超时,返回需要重传的帧 // ... } private: static constexpr uint8_t MODULUS = 8; static constexpr uint8_t WINDOW_SIZE = 7; uint8_t nextSeqNum = 0; uint8_t lastAckSeqNum = 0; std::array<SentFrameInfo, MODULUS> window; // 环形缓冲区 };3.3 定时器与重传机制
可靠的链路离不开定时器。在HDLC实现中,至少需要两种定时器:
- T1 重传定时器:为每个已发送的I帧启动。如果超时前未收到确认,则触发重传。
- T2 应答延迟定时器:收到I帧后,不必立即回复RR确认,可以等待一段时间,看是否有反向的I帧可以“捎带”确认(piggybacking)。如果T2超时还没有数据要发,则单独发送一个RR帧。
在C++中,不建议为每个帧都开一个线程去睡眠。通常的做法是使用一个时间轮或**优先队列(最小堆)**来管理所有定时事件。主循环(或一个独立的IO线程)定期检查这个队列,取出所有已超时的事件进行处理。例如,HdlcProtocol类可以有一个checkTimers()方法,在主循环中每10ms或50ms调用一次。
class TimerManager { public: using TimerCallback = std::function<void()>; using TimerId = uint64_t; TimerId addTimer(std::chrono::milliseconds duration, TimerCallback cb); void cancelTimer(TimerId id); void poll(); // 在主循环中调用,执行所有已超时的回调 private: struct TimerItem { std::chrono::steady_clock::time_point expiry; TimerCallback callback; TimerId id; bool operator>(const TimerItem& other) const { return expiry > other.expiry; } }; std::priority_queue<TimerItem, std::vector<TimerItem>, std::greater<>> queue_; TimerId nextId_ = 1; };4. 从零构建:一个简易HDLC协议栈的实操步骤
4.1 环境准备与项目结构
我们选择跨平台的C++17标准进行开发,这样代码可以在Linux(常用于嵌入式网关)和Windows(用于测试工具)上运行。依赖项尽量少,主要就是标准库。可以使用CMake作为构建系统。
项目目录结构可以这样组织:
hdlc_protocol_stack/ ├── CMakeLists.txt ├── include/ │ ├── hdlc/ │ │ ├── frame.hpp // 帧数据结构定义 │ │ ├── encoder.hpp // 编码器 │ │ ├── decoder.hpp // 解码器 │ │ ├── state_machine.hpp // 状态机接口 │ │ ├── protocol.hpp // 主协议类 │ │ └── types.hpp // 枚举和常量 │ └── utils/ │ ├── crc.hpp │ ├── timer.hpp │ └── sliding_window.hpp ├── src/ │ ├── hdlc/ │ │ ├── frame.cpp │ │ ├── encoder.cpp │ │ └── ... │ └── utils/ │ └── crc.cpp └── examples/ ├── serial_example.cpp // 基于串口的示例 └── socket_example.cpp // 基于TCP Socket的示例(模拟链路)首先实现基础工具类,比如CRC。这里给出一个CRC-16-CCITT的典型实现:
// utils/crc.hpp #pragma once #include <cstdint> #include <vector> class Crc16Ccitt { public: Crc16Ccitt() : crc(INITIAL_VALUE) {} void update(const uint8_t* data, size_t length); void update(uint8_t byte) { crc = (crc >> 8) ^ TABLE[(crc ^ byte) & 0xFF]; } uint16_t get() const { return crc ^ FINAL_XOR_VALUE; } // HDLC通常使用CRC初始值0xFFFF,最终异或0x0000 void reset() { crc = INITIAL_VALUE; } private: static constexpr uint16_t INITIAL_VALUE = 0xFFFF; static constexpr uint16_t FINAL_XOR_VALUE = 0x0000; static const uint16_t TABLE[256]; uint16_t crc; };4.2 帧编码器与解码器的核心实现
编码器实现要点: 比特填充算法是效率关键。一个直观但低效的方法是先转换成比特流再操作。更高效的方法是直接在字节流上操作,维护一个比特计数器。下面是一个经过优化的bitStuff函数示例,它直接处理字节向量:
std::vector<uint8_t> HdlcEncoder::bitStuff(const std::vector<uint8_t>& input) { std::vector<uint8_t> output; output.reserve(input.size() + input.size() / 5 + 1); // 预分配空间,最多每5比特插一个0 uint8_t currentByte = 0; int bitCount = 0; int oneCount = 0; for (uint8_t byte : input) { for (int i = 7; i >= 0; --i) { // 从最高位开始处理 bool bit = (byte >> i) & 0x01; currentByte = (currentByte << 1) | bit; bitCount++; if (bit) { oneCount++; if (oneCount == 5) { // 插入一个'0' currentByte <<= 1; // 左移一位,低位补0 bitCount++; oneCount = 0; } } else { oneCount = 0; } if (bitCount == 8) { output.push_back(currentByte); currentByte = 0; bitCount = 0; } } } // 处理最后不满一个字节的数据(如果有) if (bitCount > 0) { currentByte <<= (8 - bitCount); // 左移到高位对齐 output.push_back(currentByte); } return output; }解码器实现要点: 解码器采用状态机模式。HdlcDecoder类内部维护一个状态、一个比特缓冲区、一个字节缓冲区以及CRC计算器。
class HdlcDecoder { public: enum class State { Idle, Receiving, StuffingError }; struct Result { bool frameReady; HdlcFrame frame; std::string error; }; Result feedByte(uint8_t byte); void reset(); private: State state_ = State::Idle; std::vector<uint8_t> rawBuffer_; // 存放去除标志位但未去填充的原始比特流(按字节存储) std::vector<uint8_t> destuffedBuffer_; // 存放去填充后的数据 int bitPosition_ = 0; // 当前字节中已处理的比特数 uint8_t currentByte_ = 0; int consecutiveOnes_ = 0; Crc16Ccitt crcChecker_; void processBit(bool bit); };feedByte方法是核心,它处理每个输入的字节。在Idle状态,寻找0x7E。找到后进入Receiving状态,并开始用processBit方法处理后续字节的每一个比特。processBit会执行零删除逻辑,并将删除后的比特组装成字节存入destuffedBuffer_。当再次遇到0x7E标志时,认为一帧接收完成,对destuffedBuffer_中除最后两个字节(FCS)外的数据进行CRC校验。校验通过则解析帧,否则丢弃。
4.3 集成与测试:构建一个可运行的示例
有了编码器、解码器、状态机、定时器和滑动窗口,就可以组装成HdlcProtocol类。这个类向上提供sendData(const std::vector<uint8_t>& data)接口,向下提供一个feedReceivedBytes(const uint8_t* data, size_t len)接口,并定期调用checkTimers()。
一个基于串口的简单测试示例:
// examples/serial_example.cpp #include "hdlc/protocol.hpp" #include <serial/serial.h> // 使用一个串口库,如serial #include <iostream> #include <thread> int main() { serial::Serial ser("/dev/ttyUSB0", 9600, serial::Timeout::simpleTimeout(1000)); HdlcProtocol hdlc; // 设置回调:当收到上层数据时,打印出来 hdlc.setDataReceivedCallback([](const std::vector<uint8_t>& data) { std::cout << "Received data: "; for (auto b : data) printf("%02x ", b); std::cout << std::endl; }); // 设置回调:当有帧需要发送到物理层时,写入串口 hdlc.setFrameSender([&ser](const std::vector<uint8_t>& frame) { ser.write(frame); }); std::vector<uint8_t> buffer(1024); while (true) { // 读取串口数据并喂给协议栈 if (ser.available()) { size_t bytes_read = ser.read(buffer.data(), buffer.size()); hdlc.feedReceivedBytes(buffer.data(), bytes_read); } // 驱动协议栈定时器 hdlc.checkTimers(); // 模拟上层应用每隔5秒发送一次数据 static auto lastSend = std::chrono::steady_clock::now(); auto now = std::chrono::steady_clock::now(); if (now - lastSend > std::chrono::seconds(5)) { std::vector<uint8_t> testData = {0x48, 0x65, 0x6c, 0x6c, 0x6f}; // "Hello" hdlc.sendData(testData); lastSend = now; } std::this_thread::sleep_for(std::chrono::milliseconds(10)); } return 0; }5. 实战应用场景与深度调试技巧
5.1 典型应用场景剖析
- 工业Modbus RTU over HDLC:很多人以为Modbus RTU就是简单的串口协议。但在一些复杂的多站网络或需要长距离可靠传输的场景中,Modbus RTU报文会被封装在HDLC帧的信息字段中传输。HDLC提供了帧定界、差错校验和基本的链路管理,弥补了纯RTU协议的不足。我们的C++实现可以作为底层驱动,上层再挂接一个Modbus协议解析库。
- IP over HDLC:在一些古老的专线网络或路由器串行接口上,IP报文直接使用HDLC封装(如Cisco路由器默认的串行链路封装)。此时,信息字段(I)就是一个完整的IP数据包。实现时,我们的
HdlcProtocol在收到完整的I帧并校验通过后,需要将信息字段提取出来,直接交给系统的IP协议栈(通过TUN/TAP设备)或自己的IP处理模块。 - 作为自定义可靠传输层的基础:如果你需要在两个嵌入式设备间建立一条可靠的双工数据通道,又觉得TCP太重,UDP不可靠,那么基于HDLC来实现是一个经典选择。你可以在其基础上,定义自己应用层的报文格式和会话管理。
5.2 调试:从字节流到逻辑帧
调试HDLC协议栈,光看日志不够,必须能“看见”线上的原始数据。我常用的方法如下:
第一步:十六进制转储任何发送和接收的原始字节流,都必须以十六进制格式打印出来。一个简单的函数就能实现。重点关注标志位0x7E是否出现、帧长度是否合理。
第二步:实现“窥探”模式在编码器和解码器的关键节点插入钩子函数。例如,在编码器比特填充前后、解码器零删除前后,都把缓冲区的数据打印出来。对比发送前的数据和接收后解析前的数据,能立刻定位是填充/删除逻辑出错,还是CRC计算范围不对。
class HdlcEncoder { public: void setDebugCallback(std::function<void(const std::string&, const std::vector<uint8_t>&)> cb) { debugCallback_ = std::move(cb); } private: std::function<void(const std::string&, const std::vector<uint8_t>&)> debugCallback_; // ... 在bitStuff函数内部 if (debugCallback_) { debugCallback_("After bit stuffing", stuffedData); } };第三步:状态机轨迹日志为状态机的每个状态转换添加日志。记录触发转换的事件(如“收到SABM帧”)和动作(如“发送UA帧,状态转至已建立”)。当链路无法建立或异常断开时,这个日志是救命稻草。
第四步:模拟损坏与压力测试编写测试用例,模拟各种异常:
- 比特错误:随机翻转发送字节中的某个比特,测试CRC校验是否能发现。
- 标志位误出现:在信息字段中插入0x7E,测试透明传输机制。
- 帧丢失与重传:在发送回调中随机“丢包”,测试滑动窗口和重传定时器是否能恢复。
- 大流量压力:持续高速发送数据,测试缓冲区管理和内存使用是否正常。
5.3 性能优化与资源考量
在资源受限的嵌入式环境中,我们的实现需要格外注意:
- 避免动态内存分配:在
feedByte或sendData这类频繁调用的路径上,使用固定大小的环形缓冲区(如std::array)代替std::vector,可以避免堆内存分配带来的不确定性和碎片。可以在类构造时一次性分配好所需的最大帧缓冲区。 - 计算优化:CRC查表法是标准做法,确保表放在ROM或静态区域。比特填充/删除算法是热点,确保内层循环尽可能高效。如果CPU能力很弱,可以考虑使用硬件CRC外设(如果MCU支持)和DMA进行串口数据搬运。
- 定时器精度与开销:在裸机或无RTOS环境下,
checkTimers()的调用可能依赖于一个硬件定时器中断。要确保中断服务程序执行时间短,不要在里面做复杂的逻辑(如组帧),只设置标志位,在主循环中处理。 - 配置化:通过模板参数或配置结构体,将最大帧长、窗口大小、定时器超时时间、地址字段等变成可配置的。这样同一份代码可以适配不同厂商设备的细微差别(比如有的设备用CRC-16,有的用CRC-32)。
6. 常见问题排查与解决实录
在实际部署中,你会遇到各种各样奇怪的问题。下面是我踩过的一些坑和解决方案:
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 链路无法建立,反复发送SABM无响应 | 1. 物理链路不通(线接错、波特率不对)。 2. 地址字段不匹配。对方期望的地址不是0x03或0x01。 3. 对方设备不是HDLC模式,可能是PPP或其他协议。 | 1. 用逻辑分析仪或串口调试助手抓取发送出的原始字节,确认是否有数据发出,波形是否正确。 2. 确认对方设备的HDLC地址配置。尝试交换地址(主站/从站)。 3. 查阅对方设备手册,确认链路层协议类型。 |
| CRC校验持续失败 | 1. CRC计算范围错误(包含了标志位或填充比特)。 2. CRC多项式或初始值与对方设备不匹配。 3. 物理层干扰导致数据位错误。 | 1.最有效的方法:在编码后(发送前)和解码后(校验前)分别打印出用于计算CRC的原始数据(地址+控制+信息),进行比对。 2. 确认协议规范,是CRC-16-CCITT (0x1021) 初始值0xFFFF,还是CRC-32。 3. 降低波特率测试,或检查硬件连接、接地。 |
| 收到数据不完整或粘包 | 1. 解码器状态机逻辑有误,未能正确识别帧结束标志。 2. 发送方未正确进行比特填充,导致接收方在信息字段内误判0x7E为帧尾。 3. 串口读取缓冲区大小设置不当,导致一帧数据被分多次读取。 | 1. 打开解码器的详细调试日志,观察每次接收到0x7E时的状态和缓冲区内容。 2. 对比发送方填充前后的数据,确认填充规则被正确执行。 3. 确保 feedReceivedBytes能够一次性接收到尽可能多的数据,或者实现一个更底层的字节流缓冲队列。 |
| 通信速度很慢,吞吐量低 | 1. 滑动窗口大小设置过小(比如为1),变成停等协议。 2. 重传定时器T1设置过长。 3. 确认帧(RR)过于频繁,没有利用好“捎带确认”。 | 1. 在链路质量好的情况下,适当增大发送窗口(如从1增至7)。 2. 根据链路往返时间(RTT)动态调整T1,或设置为一个合理的经验值(如200ms)。 3. 优化T2定时器,允许更长的延迟等待反向数据来捎带确认。 |
| 长时间运行后内存缓慢增长 | 1. 发送窗口中的帧在被确认后没有及时释放。 2. 解码器或状态机中某些异常路径导致资源(如缓冲区)未释放。 3. 定时器回调中捕获了大的上下文对象,导致无法释放。 | 1. 检查ackUpTo逻辑,确保已确认的帧从发送窗口容器中移除。2. 使用Valgrind (Linux) 或类似工具进行内存泄漏检测。 3. 审查所有回调函数和 std::function,确保没有意外的循环引用或持有大对象的shared_ptr。 |
一个真实的调试案例:在一次与某品牌PLC的对接中,我们的设备发送数据正常,但永远收不到PLC的响应。抓包发现,我们发送的SABM帧格式完全正确。后来在解码器日志中发现,我们收到了连续的0xFF字节流。原来,该PLC在链路未建立时,会持续发送“链路空闲”信号(连续标志位0x7E或连续‘1’)。而我们的解码器在Idle状态只认单个0x7E,遇到连续‘1’(0xFF是8个‘1’)就卡住了,状态机没有复位。解决方案是在Idle状态,不仅检测0x7E,也检测到一定数量的连续非0x7E字节后,主动重置解码器内部状态,重新开始搜寻帧头。这个细节在标准协议文档里很少提及,却是实际兼容性中必须处理的。