☰
BCB6上云案例:用WinSock手写MQTT客户端实现物联网通信
2026/10/18 9:23:03 网站建设 项目流程

简介:面向需要在 Borland C++ Builder 6.0 中接入 MQTT 的 C++ 开发者,这份案例基于 Eclipse Paho MQTT C 库,演示了客户端创建、服务器连接、消息发布、主题订阅与断开连接等完整流程,能够帮助理解发布/订阅模型和轻量级通信协议在 Windows 桌面程序中的实际落地。压缩包共 15 个文件,大小仅 225KB,包含主源码、DLL 动态库、配套 LIB 静态库与头文件,并提供可执行程序、连接配置和工程文件,基本覆盖从编译链接到运行配置所需的全部内容。目前已有 462 人学习下载。对于想快速在 BCB6 上跑通 MQTT 的入门者,其中既提供可直接阅读的参考代码,又附带已编译好的库和演示程序,省去自行搭建 Paho 环境的繁琐过程;同时可作为扩展物联网消息功能、制作小型客户端或课程设计的实用模板。案例中的主源码还给出了关键函数调用顺序,配套目录便于直接配置工程,对入门者尤其友好。

1. 老 IDE 连新总线:BCB6 的 MQTT 案例到底能解决什么问题

我手上维护的上位机里,BCB6 写出来的占了将近一半,跑在现场工控机上,串口、Modbus 逻辑都稳定,唯一尴尬的是“上云”这一步。新平台的物联网接口基本只认 MQTT,而 BCB6 没有官方 MQTT 组件,网上搜到的教程又全是 C#、Python 的,真正落在 C++ Builder 工程里的案例少得可怜。这套以 BCB6 为运行环境的 MQTT 案例,核心目标就是让你不改原有架构,只新增一个基于 WinSock 的 MQTT 客户端类,就能把采集到的数据发布到 Broker,也能订阅到云端下发的指令。它把 CONNECT、PUBLISH、SUBSCRIBE、心跳和断线重连都封装进几段可以编译的代码里,适合正在维护老项目又被物联网协议卡住的工程师,也适合想在 BCB6 里亲手摸一遍 MQTT 报文字节流的学习者。

2. MQTT 协议选型:在 BCB6 里手撸报文比引第三方库更实际

2.1 第三方 MQTT 库进入 BCB6 前的现实成本

很多刚接触这个组合的人,第一反应是找一个现成的 MQTT 库引进来。我在一批案例里跑过这个路线,最后基本都放弃。原因不是库不好,是 BCB6 的落伍超出了多数库作者的预期。

首先,BCB6 编译器对 C++98 的兼容是主流,但现代 MQTT 库在写的时候默认你用的是新编译环境,里面摞了 move 语义、lambda、模板泛型之类的东西。把源码拖进 BCB6 里编一遍,屏幕上会冒出一堆模板报错。改这些错误消耗的时间,足够自己把 MQTT 包的构造逻辑吃透。我在这类项目上的体会是:给老 IDE 引新库,往往是给自己挖坑。

其次,运行期依赖。部分库编译出来还需要额外的动态库,部署到客户现场时,那个文件夹里又会多出好几个 DLL。老现场的工控机上系统权限管得严,允许装什么程序都有清单,这种额外的安装动作经常被客户拒绝。纯静态、零依赖的方案在老产线里有天然的落地优势。

最后,从需求最小化的角度看,MQTT 客户端最常用的报文其实就那么几种:CONNECT、CONNACK、PUBLISH、SUBSCRIBE、SUBACK、PINGREQ、PINGRESP、DISCONNECT。这些报文格式在协议文档里写得清清楚楚,手动填字节完全可以控制。选型时我通常先问一句:是引一个库进来引入更多不确定因素,还是把七八个报文的字节格式定义好,自己写一个轻量类。答案在后者的项目占多数。所以这套案例里的做法就是:不用第三方库,直接基于 WinSock 实现一个 MQTT 客户端类,类里包含 socket 句柄、收发缓冲区、报文解析状态机,再加上连接、发布、订阅、心跳四个方法。这样既照顾了 BCB6 的编译环境,也方便拿到现场直接改。

2.2 需要认识的报文集合:连接、发布、订阅、心跳

MQTT 3.1.1 中每个报文的第一字节叫固定头,高四位表示报文类型,低四位是标志位。类型值从 1 到 14,真正在案例里用到的我列了一张表:

报文类型类型值作用方向
CONNECT1客户端发起登录请求客户端到 Broker
CONNACK2Broker 返回连接结果Broker 到客户端
PUBLISH3发布应用消息双向
SUBSCRIBE8订阅主题过滤器客户端到 Broker
SUBACK9确认订阅成功及授予的 QoSBroker 到客户端
PINGREQ12心跳请求客户端到 Broker
PINGRESP13心跳响应Broker 到客户端
DISCONNECT14客户端主动断开客户端到 Broker

从这张表能看出典型的上云流程:先发 CONNECT 完成握手,收到 CONNACK 确认;然后 SUBSCRIBE 等待云端下发指令;周期发送 PINGREQ 保持链路;数据采集后 PUBLISH 上报;程序退出时 DISCONNECT。对做过串口和 Modbus 的工程师来说,MQTT 最难适应的不是报文多,而是所有数据都是字节流。没有结构体映射,没有内存对齐,要求你按协议规定顺序往缓冲区里填。这也是案例代码里大量使用 AnsiString 拼接的原因,BCB6 对这种字符串操作支持得很顺。

2.3 MQTT 包结构:固定头、剩余长度和可变头的关系

任何 MQTT 报文都由固定头、可变头、有效载荷组成。固定头里最核心的两个字段是报文类型和剩余长度。剩余长度指的是“当前字节之后到报文结束的所有字节数”,不包含固定头本身,采用连续编码:每个字节的最高位表示是否还有下一个字节,低 7 位表示实际值。报文长度小于 128 时一个字节够用,超过 127 就要用两个字节。

我习惯在代码里单独封装一个剩余长度编码函数,因为后面构造 PUBLISH 长报文时会反复用到。案例里我把编码函数放在 MQTT 类的私有方法里,每次发包前统一调用。这样做的坑在于:接收入口也必须对剩余长度做边界检查。很多老代码直接在 recv 后用一个固定长度去解析,收到大报文时缓冲区越界,这是很典型的隐患。我一般在解析状态机里先读一个字节判断剩余长度要占几字节,再决定后续 recv 多少次。

提示:如果你是从串口改造到 MQTT,最值得先打通的是 QoS0 的收发链路,等字节流方向确认了,再考虑 QoS1 和 QoS2 的重试与去重。BCB6 这类老 VCL 工程,稳定跑到 QoS0 已经能覆盖绝大多数温湿度、电量、开关状态上报场景。

3. 从 TCP 到 CONNACK:在 BCB6 里把第一个 MQTT 连接跑通

3.1 初始化 WinSock 并建立 TCP 通道

BCB6 自带 VCL 的 Socket 组件,不过这套案例里我建议直接用 WinSock API。原因是 MQTT 对连接状态控制要求高,需要自己设置收发超时,而 VCL 组件的 OnRead、OnWrite 事件在老工程里处理起来绕。直接调用 WinSock,代码量反而更少,调试也更直观。建立连接的代码在案例中是这样写的:

// 案例代码:初始化 WinSock 并连接 Broker // 参数:hostIP 为 Broker 地址,port 一般为 1883 bool TMqttClient::ConnectToBroker(AnsiString hostIP, int port) { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) return false; FSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (FSocket == INVALID_SOCKET) return false; SOCKADDR_IN addr; addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.S_un.S_addr = inet_addr(hostIP.c_str()); int ret = connect(FSocket, (SOCKADDR*)&addr, sizeof(addr)); if (ret == SOCKET_ERROR) { closesocket(FSocket); FSocket = INVALID_SOCKET; return false; } return true; }

这段代码的逻辑依次是:WSAStartup 初始化 Socket 环境,创建 TCP 套接字,填充 Broker 地址并发起 connect。我把 connect 做成同步调用,因为案例需求简单,同步方式错误处理最直观,连不上就返回 false,调用方弹一个提示即可。参数上注意两点:hostIP 用了 inet_addr,只支持点分十进制的 IP,如果现场要填域名就必须先调用 gethostbyname 解析;port 默认填 1883,如果 Broker 开了 TLS 会变成 8883,但 BCB6 老工程处理 TLS 还要引 OpenSSL 动态库,我建议先在明文链路把功能跑通,加密留作后续增强。

注意:connect 在同步模式下没有超时参数,Broker 地址不可达时,程序可能卡在 connect 上十几秒甚至更久。案例默认用阻塞模式,但代码里预留了设置非阻塞模式的注释位置,现场网络差的时候可以打开。

3.2 构造 CONNECT 包:把协议版本和 CleanSession 填对

TCP 建好后,发送的第一条报文必须是 CONNECT,否则 Broker 会直接断开连接。CONNECT 的固定头是 0x10,可变头里包含协议名 MQTT、协议级别、连接标志和 KeepAlive。案例里的构造代码如下:

// MQTT 3.1.1 协议级别为 4 // 案例代码:构造并发送 CONNECT 包 bool TMqttClient::SendConnect(AnsiString clientId, int keepAlive) { AnsiString packet; int protocolNameLen = 4; // "MQTT" int remainLen = 2 + protocolNameLen + 1 + 1 + 2 + 2 + clientId.Length(); packet += (char)0x10; // 固定头:CONNECT packet += (char)remainLen; // 剩余长度 packet += (char)0x00; packet += (char)protocolNameLen; packet += "MQTT"; // 协议名 packet += (char)0x04; // 协议级别 MQTT 3.1.1 packet += (char)0x02; // CleanSession=1 packet += (char)(keepAlive >> 8); // KeepAlive 高字节 packet += (char)(keepAlive & 0xFF); // KeepAlive 低字节 packet += (char)0x00; packet += (char)clientId.Length(); packet += clientId; // 客户端 ID int sent = send(FSocket, packet.c_str(), packet.Length(), 0); return (sent == packet.Length()); }

这段代码有三个易错点。第一,剩余长度计算。我这里写的是 2(协议名长度字段)加 4(协议名)加 1(协议级别)加 1(连接标志)加 2(KeepAlive)加 2(客户端 ID 长度字段)加 clientId.Length()。如果只写 10 加 clientId.Length(),最后的报文会少两字节,Broker 解析长度不匹配就会不回 CONNACK。第二,连接标志字节。0x02 表示 CleanSession=1,不使用遗嘱、用户名、密码。如果 Broker 要求认证,需要把对应位拉高,再在载荷中追加用户名和密码字段,这部分我建议拆成单独的方法。第三,KeepAlive 是两字节大端序,代码里用移位和与运算拆开。很多新手直接把 60 填成一个字节,Broker 解析出的保活时间会变成 0,等于关闭保活机制。BCB6 的 AnsiString 拼接 char 型变量没有编码问题,但移位时最好显式把 keepAlive 转成 unsigned char,避免符号位干扰。

3.3 接收 CONNACK:状态机与超时处理

CONNECT 发出去后不能立刻发布数据,必须等 Broker 回 CONNACK。CONNACK 完整报文只有 4 字节:0x20、剩余长度 2、连接确认标志、返回码。最简单的接收函数如下:

// 案例代码:接收 CONNACK,返回 0 表示连接成功 int TMqttClient::WaitConnack() { char buf[4]; // 设置 3 秒接收超时 DWORD timeout = 3000; setsockopt(FSocket, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeout, sizeof(timeout)); int n = recv(FSocket, buf, 4, 0); if (n < 4) return -1; // 超时或数据不完整 if ((buf[0] & 0xF0) != 0x20) return -2; // 收到非 CONNACK 报文 return buf[3]; // 返回码,0 表示成功 }

这段代码有个边界情况容易踩:recv 返回的 n 不一定等于 4。TCP 是流协议,数据可能分批到达,也可能一次到达。如果 Broker 延迟大,第一次 recv 只收到 2 字节,程序把 n < 4 当成错误返回,就误判了。稳妥做法是把接收动作写成一个循环,累计收满指定字节数再返回。案例源码里封装了一个 ReadBytes 方法,内部循环处理粘包和半包,后续解析 PUBLISH、SUBACK 也要复用。

返回码的判断同样要仔细。buf[3] 等于 0 表示连接成功,等于 1 表示协议版本不对,等于 2 或 5 表示客户端标识、用户名或密码被拒绝。看到非零返回码时不要反复重连,优先查 Broker 日志。通过模拟项目 X 联调时,我遇到过返回码 5 的案例,最后定位是用户名密码标志位置位了但载荷里没拼密码字段,Broker 鉴权失败。

注意:把 SO_RCVTIMEO 设成 3000 毫秒后,等待 CONNACK 期间界面会阻塞。简单案例可以接受,一旦牵扯大量发布订阅,接收逻辑就必须挪到独立线程里,这部分在第四章详细展开。

4. 发布和订阅都要跑通:一个可收可发的完整循环

4.1 PUBLISH 包:QoS0 与 QoS1 的构造差异

连接成功后,发布消息是最常用的动作。PUBLISH 固定头类型值是 3,QoS 不同,低四位标志不同,QoS0 是 0x30,QoS1 是 0x32,QoS2 是 0x34。这套案例把 QoS0 作为默认,因为大多数传感器上报场景允许丢一两条数据,QoS0 的包结构最简单,也不用管消息标识符。

// 案例代码:发布一条 QoS0 消息 bool TMqttClient::PublishQos0(AnsiString topic, AnsiString payload) { AnsiString packet; int payloadLen = payload.Length(); int topicLen = topic.Length(); int remainLen = 2 + topicLen + payloadLen; packet += (char)0x30; // 固定头:QoS0 PUBLISH packet += (char)remainLen; packet += (char)0x00; packet += (char)topicLen; // 主题长度 packet += topic; // 主题 packet += payload; // 载荷 return send(FSocket, packet.c_str(), packet.Length(), 0) == packet.Length(); }

发布包的剩余长度比 CONNECT 清晰:主题长度字段占 2 字节,主题 topicLen 字节,载荷 payloadLen 字节。这里提醒一个老项目常见问题:如果载荷里含中文字符,AnsiString 在 BCB6 里的默认编码是 ANSI,直接拼接没问题,但订阅方拿到字节在 UTF-8 环境下会显示成乱码。实际项目里我会在采集时把中文字段先转成 UTF-8 再发布。

升级到 QoS1 时,固定头改成 0x32,主题和载荷之间插入一个 2 字节消息标识符,Broker 收到后会回 PUBACK,类型值是 4。客户端必须等到 PUBACK 才能重发或继续,这就需要在接收线程里维护一张“已发送未确认消息表”,复杂度上一个台阶。所以案例先以 QoS0 为主,链路完全稳定后再考虑升级。

4.2 SUBSCRIBE 包:订阅主题的正确姿势

订阅报文的固定头是 0x82。这个字节很容易让新手误解,低四位 0x02 不是 QoS2,而是协议规定的 SUBSCRIBE 固定标志,必须等于 0x02。案例里的订阅方法实现:

// 案例代码:订阅一个主题过滤器 bool TMqttClient::Subscribe(AnsiString topicFilter) { AnsiString packet; int remainLen = 2 + 2 + topicFilter.Length() + 1; packet += (char)0x82; // SUBSCRIBE packet += (char)remainLen; packet += (char)0x00; packet += (char)0x01; // 消息标识符=1 packet += (char)0x00; packet += (char)topicFilter.Length(); packet += topicFilter; packet += (char)0x00; // 请求 QoS0 return send(FSocket, packet.c_str(), packet.Length(), 0) == packet.Length(); }

消息标识符不能在同一连接上重复使用,每发一个 SUBSCRIBE 要递增。案例里硬编码为 1,如果订阅多个主题,需要把它改成成员变量,每次加一。订阅之后,Broker 会回 SUBACK:固定头 0x90,剩余长度 3,包含消息标识符 2 字节和返回码 1 字节。返回码 0x00 表示订阅成功且授予的 QoS 是 0,0x80 表示订阅失败。处理 SUBACK 的逻辑和 CONNACK 一样,在接收循环里按状态机解析。

协议对 SUBSCRIBE 固定头有硬性要求,写成 0x80 会被 Broker 视为协议违规直接断连。我给某跨平台系统接 MQTT 时遇到过这种问题,发送端看起来一切正常,Broker 日志里只有一条连接关闭记录。用抓包工具对比字节流,才发现问题出在固定头低四位。

4.3 心跳与重连:KeepAlive 的良心参数选择

MQTT 的 KeepAlive 是连接参数,客户端在空闲时必须按这个时间间隔发出 PINGREQ,否则 Broker 会认为链路失效并断开。PINGREQ 固定头是 0xC0,剩余长度 0,只有两字节,也是 MQTT 里最简短的报文:

// 案例代码:发送 PINGREQ 心跳 void TMqttClient::SendPing() { char pingReq[2] = { (char)0xC0, 0x00 }; send(FSocket, pingReq, 2, 0); }

Broker 收到后无条件回一个 PINGRESP,固定头 0xD0,同样两字节。KeepAlive 的值在 CONNECT 包里已经协商,案例默认 60 秒。实际项目里,如果现场网络不稳定或中间有 NAT 网关,我会把 KeepAlive 缩短到 20 到 30 秒。因为 NAT 会话表有老化时间,KeepAlive 比它长时,连接会被网关悄然切断,客户端还不知道,直到下次发送数据才发现 Socket 已失效。

重连逻辑上,我建议“断开重试加固定间隔”的组合。重连前先关闭旧 Socket,等待两秒,再重新走 TCP 连接和 CONNECT 流程。MQTT 服务器在相同 ClientID 重连时可能会顶掉旧连接,也可能拒绝新连接,这取决于 Broker 实现。案例里我做了一个简单的重连函数:

// 断线重连,重连成功后恢复订阅 void TMqttClient::Reconnect() { CloseSocket(); Sleep(2000); ConnectToBroker(FHost, FPort); if (SendConnect(FClientId, FKeepAlive) && WaitConnack() == 0) ResubscribeAll(); }

ResubscribeAll 会把之前订阅过的主题再走一遍 SUBSCRIBE。这个动作不能省,因为 Broker 在连接断开后会清除旧的订阅关系。

4.4 消息分发:把收到的报文塞回 VCL 界面

BCB6 的 VCL 是单向线程模型,界面必须在主线程操作。MQTT 接收线程如果直接改 Edit 的 Text 属性,轻则闪烁,重则崩溃。案例里的做法是用 PostMessage 把收到的完整报文投递给主窗口句柄:

// 接收线程核心逻辑,简化展示 TMemoryStream* stream = new TMemoryStream; // 从 Socket 读到 stream,交给主线程处理 PostMessage(FMainFormHandle, WM_MQTT_DATA, (WPARAM)stream, (LPARAM)0);

主窗口在处理 WM_MQTT_DATA 时,再去解析 PUBLISH 报文,取出主题和载荷更新界面。这个方案的关键点是 PostMessage 异步,不会阻塞接收线程;但流对象要在主线程用完后释放,不能在线程里 delete。如果不喜欢 PostMessage,也可以用 TThread.Synchronize,但它会阻塞接收线程直到主线程执行完回调,数据量大时容易丢包。我在案例里最后选用了 PostMessage,这套机制在 BCB6 老工程中抗压能力最强。

5. 避坑排查:连接超时、收不到回包和界面卡死的五个现场

5.1 现象:CONNACK 迟迟不来,抓包却看到 CONNECT 包已经发出

调试现场,Broker 地址填的是域名,客户端解析没问题,TCP 也连上了,但 CONNACK 就是不回来。抓包看 CONNECT 包已经出现在网络上,Broker 日志也没有异常报错。原因多半是 CONNECT 包里的协议名字段写成了 MQIsdp。这是 MQTT 3.1 的协议名,老系统偶尔会混用,Broker 如果只支持 3.1.1,收到 MQIsdp 会不认。解决方法是统一把协议名写成 MQTT,协议级别字节填 0x04,同时检查客户端 ID 是否为空,MQTT 3.1.1 不允许空客户端 ID。

5.2 现象:SUBACK 收不到,Broker 忽然断开

订阅消息发出去后,程序一直等 SUBACK,等了几秒 Socket 变成断开状态。我排查过一次,是 SUBSCRIBE 报文的固定头低四位被写成了 0x00,也就是把 0x82 写成了 0x80。协议规定 SUBSCRIBE 的固定标志位必须是 0b0010,Broker 看到标志非法就会关闭连接。解决方法是严格按协议写 0x82,发送后记录一条十六进制日志,把固定头原始字节打印出来,一眼就能定位。

5.3 现象:客户端能收数据,但界面卡死或闪烁

Socket 接收线程每收到一条 PUBLISH 就直接调用 Form 里的 Edit 组件赋值,运行几分钟后界面失去响应。原因很明确:VCL 组件必须在主线程操作,接收线程直接改组件属性违反了线程模型,造成界面句柄竞争,最终卡死。解决方式就是第四章的 PostMessage 分发,把数据投递到主窗口,主线程处理函数里再做界面更新。这个改法耗时不多,但能根治闪烁和卡死问题。

5.4 现象:发布 QoS1 消息后 Broker 立即断开连接

QoS0 发布一切正常,改成 QoS1 后消息发出去不到一秒,Broker 就把 TCP 连接关了。原因是 QoS1 的 PUBLISH 包里缺少消息标识符字段。固定头改成了 0x32,但剩余长度和实际内容还是 QoS0 的排列,导致报文长度和内容对不上。解决办法是 QoS1 的剩余长度里必须加上 2 字节消息标识符,并且发出后等待 PUBACK。如果没做 PUBACK 处理,还不如继续用 QoS0。

5.5 现象:重连后旧连接没释放,新连接被拒绝

客户端断线后立刻重连,Broker 没有把旧连接踢掉,新连接的 CONNACK 返回一个非 0 值。原因是旧连接处于半开状态,服务器端没有完全释放,部分 Broker 的清理策略保守,会先拒绝相同 ClientID 的新连接。解决办法是重连前主动关闭旧 Socket 并等待两秒,让 Broker 完成旧连接清理;或者重连时临时换用不同 ClientID。案例源码里选用前者,对上层业务更透明。

6. 验证与收尾:用本地 Broker 日志确认链路,再改造成自己的协议

调试 MQTT 客户端,建议做法是在本机跑一个测试 Broker,把日志级别调到调试模式,然后逐条对收发记录里的字节流。这样环境隔离,出问题时基本可以确定是协议层的错误,而不是设备或网络干扰。验证样例时,我一般分两步走。

第一步,用命令行测试工具订阅一个验证主题,比如 test/verify,然后在 BCB6 样例里向这个主题发布一串固定字符。观察测试工具能不能收到。能收到说明 PUBLISH 方向已经通。第二步,反向操作,在测试工具里发布一条指令,观察 BCB6 样例的接收线程是否触发回调。两边都能对上,基础链路就没有问题。

抓包验证只用看两个点:一是 CONNECT 包的协议名和协议级别字节,二是 SUBSCRIBE 和 PUBLISH 的固定头低四位。这两个点做二进制对比就行,不需要理解深层实现。如果 Broker 日志和抓包都能对上,MQTT 链路基本可以被认定为正确。我还会把 KeepAlive 临时改成 20 秒,让客户端在空闲时多发几组 PINGREQ 和 PINGRESP,观察日志里是否成对出现。如果心跳没成对,说明网络中间有设备在过滤数据包。

按我手上的经验,从 BCB6 上车 MQTT,最难的不是协议本身,而是你愿不愿意按老工程的节奏把每个字节都写清楚。想走捷径引库的人,最后大多绕回来自己拼包。因此拿到这类案例后,我会先跑一遍上述验证路径,然后把 CONNECT 和 SUBSCRIBE 两个包的长度校验单独提出来,发送前用断言检查。从那以后,我每次在 BCB6 里改 MQTT 都会强制走这个流程,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询