简介面向希望快速上手 Qt 低功耗蓝牙BLE开发的初学者和物联网工程师这份测试例程聚焦特征值读写涵盖 BLE 设备扫描、连接、GATT 服务发现和数据交换可直接作为蓝牙智能设备原型验证与二次开发的参考起点。压缩包共 11 个文件以 3 个 C 源文件和 2 个头文件为主分别承载功能实现与接口声明同时包含 UI 界面文件、pro/pri 工程配置、README 说明和 gitignore 等整体大小约 9 KB结构清晰便于导入 Qt Creator 快速阅读和改造。已有 938 人浏览学习。示例以 blecontroller-master 项目代码为核心突出展示了 QBluetoothGattService 和 QBluetoothGattCharacteristic 的调用链路以及 valueChanged、writeCompleted 等异步信号的处理方式并给出了连接失败、设备无响应等常见异常的应对思路。通过运行和阅读源码可系统掌握 BLE“扫描—连接—发现服务—特征值读写”的完整流程获得一套精简、可复用的 Qt BLE 通信代码骨架对后续开发心率手环、传感标签等低功耗设备有直接帮助。 做嵌入式或者其他硬件上位机开发的朋友应该都有过这种体会凡是和通信协议打交道的活儿最怕的就是链路通了一半、数据拿不全你根本不知道是设备发错了还是自己没读对。最近我刚好在调一个低功耗蓝牙BLE传感器模块顺手用Qt写了一套低功耗蓝牙测试例程核心就是设备的扫描、连接、GATT服务的发现以及特征值的读写和通知订阅。这种例程看着简单真正落地的时候从Qt的API设计到各平台蓝牙栈的差异到处是坑。这篇文章把这套例程的思路、代码和踩坑记录都整理出来给正在搞Qt BLE开发的同行做个参考。1. 先理思路BLE特征值读写到底是在干什么1.1 Qt的BLE相关类用一张关系图讲清楚低功耗蓝牙和经典蓝牙最大的区别在于它的数据交换不是靠建立一个持续的数据流而是围绕GATTGeneric Attribute Profile这套属性协议来做。你在设备里能看到一个或多个Service每个Service下面挂若干Characteristic也就是特征值真正收发数据的地方就在这些特征值上。特征值还能带Descriptor最常用的就是CCCDClient Characteristic Configuration Descriptor用来开启设备的通知上报。Qt从5.11开始把低功耗蓝牙的API收归到一个比较完整的状态核心就是这几个类QBluetoothDeviceDiscoveryAgent负责扫描周围的蓝牙设备可以指定只扫低功耗设备。QLowEnergyController这是整个BLE会话的“遥控器”负责连接设备、断开连接、发现服务。QLowEnergyService对应GATT里的一个Service拿到它之后才能访问下面的特征值。QLowEnergyCharacteristic对应一个特征值有UUID、属性可读、可写、可通知等和当前值。QLowEnergyDescriptor特征值的描述符最常用的就是CCCD用来开启Notify或Indicate。这套API总体上分层清晰但有个特点几乎都是异步的。你调用readCharacteristic它不会立刻返回数据而是等蓝牙底层把数据拿回来了再通过QLowEnergyService的characteristicRead信号告诉你。所以写这类例程本质上是在搭一个状态机每个操作都要等对应的信号回来才能做下一步。1.2 例程的整体结构设计我建议不要把所有逻辑都堆在MainWindow里面看起来省事后面扩展起来非常痛苦。我这边习惯单独封装一个BLEManager类负责扫描、连接、服务发现、特征值读写这些底层操作对外只暴露几个信号。这个类大概长这样扫描接口startScan()发出deviceDiscovered信号把设备名、MAC地址、信号强度RSSI上报给界面。连接接口connectToDevice(QBluetoothDeviceInfo)内部创建QLowEnergyController。服务发现接口discoverServices()把扫描到的Service列表、每个Service下的特征值列表整理出来。特征值读写接口readData(QBluetoothUuid serviceUuid, QBluetoothUuid charUuid)、writeData(...)。通知接口enableNotify(...)内部写CCCD。UI层只负责展示和调用不直接碰蓝牙API。这样有个好处以后你换一套界面或者把这个类接到命令行工具、自动化测试脚本上都不用改蓝牙逻辑。线程模型这里特别提醒一句QLowEnergyController不要随便丢到子线程里去new。它的信号槽是依赖Qt事件循环的虽然跨线程用QueuedConnection也不是不行但BLE操作本身状态复杂一旦出现时序问题排查成本极高。我实测下来主线程事件循环里直接处理完全够用除非你的界面里做了特别重的同步操作导致事件循环卡死否则不建议搞多线程。2. 环境准备和工程配置先把坑排掉再写代码2.1 pro文件要加什么Qt低功耗蓝牙模块属于Qt Bluetooth的一部分在pro文件里加一行就行QT core gui bluetooth如果你的工程还需要用到蓝牙之外的串口、网络等功能就继续往QT变量里加模块名。注意Qt 5.11之前BLE相关API还不稳定有些类名和信号名跟现在不太一样建议直接上5.12以上的版本我这边用的是5.15.2跑得很稳。编译环境方面Windows下建议用MSVC编译器Qt官方对MSVC的预编译包做得比较完整MinGW的蓝牙模块有时在运行时会出现一些怪问题。Linux下需要安装BlueZ相关的开发包比如libbluetooth-dev以及Qt的蓝牙插件。macOS和iOS上Xcode工程需要在Info.plist里加蓝牙使用描述。Android的权限配置稍微麻烦一点。除了常规的蓝牙权限Android 6.0以上还必须申请定位权限否则扫描不到低功耗设备。我当时的做法是在AndroidManifest.xml里加uses-permission android:nameandroid.permission.BLUETOOTH/ uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN/ uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION/同时在代码里用Qt的权限API动态申请不然只写在manifest里面6.0以上系统一样会拒绝。2.2 Windows和Linux平台的适配差异Windows上的低功耗蓝牙有个比较隐蔽的问题很多情况下设备必须先通过系统设置里的“蓝牙和其他设备”完成配对你的Qt程序才能正常扫描并连接。不配对的话设备可能不出现在扫描结果里或者即使出现了连接之后服务发现也总是失败。我一开始调试时一直以为是自己代码的问题折腾了半天才发现是Windows蓝牙协议栈的限制。Linux上用BlueZ情况要好一些但如果扫描不到设备先检查一下系统蓝牙服务有没有起来可以用bluetoothctl命令手动看一下设备是否能被发现bluetoothctl scan on如果bluetoothctl能扫到设备而Qt扫不到多半是权限问题可以检查一下当前用户是否在bluetooth组里或者用root权限运行一下试试。另外有朋友在Windows上遇到Qt程序打开后弹“no qt platform plugin could be initialized”这个报错。这不是蓝牙模块的问题是Qt的platform插件路径没找到常见于你把exe单独拷出去运行却忘了带上plugins目录。用windeployqt部署的时候注意打包结束后看一下exe同目录下是否有platforms这个文件夹没有的话手动把Qt安装目录下的plugins/platforms复制过去。3. 核心实现扫描、连接、特征值读写完整流程3.1 扫描低功耗设备扫描这一步没什么复杂的就是创建QBluetoothDeviceDiscoveryAgent设置超时时间然后启动扫描。代码大概是这样// 扫描低功耗设备 m_deviceAgent new QBluetoothDeviceDiscoveryAgent(this); m_deviceAgent-setLowEnergyDiscoveryTimeout(10000); connect(m_deviceAgent, QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, BLEManager::onDeviceDiscovered); connect(m_deviceAgent, QBluetoothDeviceDiscoveryAgent::finished, this, BLEManager::onScanFinished); m_deviceAgent-start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod);在onDeviceDiscovered里你会拿到一个QBluetoothDeviceInfo对象里面有设备名、MAC地址、RSSI信号强度等信息。注意设备名可能是空的很多低功耗传感器为了省电广播包里根本不广播名字或者名字藏在厂商自定义数据里这时候界面上就只能显示MAC地址。测RSSI的时候也别太认真低功耗蓝牙的广播功率低隔着几堵墙数值会断崖式下跌这在室内定位类项目里非常明显当个参考就行。3.2 连接设备与服务发现设备选定之后用QLowEnergyController建立连接// 创建控制器并连接 m_controller QLowEnergyController::createRemoteController(deviceInfo, this); connect(m_controller, QLowEnergyController::connected, this, BLEManager::onControllerConnected); connect(m_controller, QLowEnergyController::disconnected, this, BLEManager::onControllerDisconnected); connect(m_controller, QLowEnergyController::errorOccurred, this, BLEManager::onControllerError); m_controller-connectToDevice();connected信号触发后调用discoverServices()去扫描设备上的GATT服务。这里有个细节连接建立不等于马上就能读到服务一定要等服务发现完成信号再去做后续操作不然拿到的是空列表。void BLEManager::onControllerConnected() { // 连接成功开始发现服务 connect(m_controller, QLowEnergyController::serviceDiscovered, this, BLEManager::onServiceDiscovered); connect(m_controller, QLowEnergyController::serviceDiscoveryFinished, this, BLEManager::onServiceDiscoveryFinished); m_controller-discoverServices(); }服务发现完成后遍历m_controller-services()可以得到所有Service。但注意这个时候服务对象还是“空壳”特征值列表还没加载出来你需要先调用discoverDetails()去拉取详情void BLEManager::onServiceDiscoveryFinished() { const QListQBluetoothUuid uuids m_controller-services(); for (const QBluetoothUuid uuid : uuids) { QLowEnergyService *service m_controller-createServiceObject(uuid, this); if (!service) continue; connect(service, QLowEnergyService::stateChanged, this, BLEManager::onServiceStateChanged); connect(service, QLowEnergyService::characteristicRead, this, BLEManager::onCharacteristicRead); connect(service, QLowEnergyService::characteristicWritten, this, BLEManager::onCharacteristicWritten); connect(service, QLowEnergyService::characteristicChanged, this, BLEManager::onCharacteristicChanged); service-discoverDetails(); } }有个地方要注意createServiceObject返回的service对象是new出来的需要自己管理生命周期。如果你只是临时测试不打算长期保存最好在程序退出时清理掉避免内存泄漏。我一般把这个service指针直接存到成员变量里后续读写和界面展示都要用它。3.3 特征值的读、写、通知一个完整的示例服务详情加载完成之后就可以遍历特征值了。拿到QLowEnergyCharacteristic对象后先看它的properties()属性判断它是可读、可写还是可通知Read可以主动去读特征值的当前值。Write / WriteNoResponse可以写入数据。响应模式适合可靠性要求高的场景无响应模式适合高频发送、偶尔丢一包也能接受的场景。Notify / Indicate设备主动上报数据需要先写CCCD使能。读取特征值// 假设service和targetChar都已经准备好 if (targetChar.properties() QLowEnergyCharacteristic::Read) { service-readCharacteristic(targetChar); }数据回来之后会走到onCharacteristicReadvoid BLEManager::onCharacteristicRead(const QLowEnergyCharacteristic c, const QByteArray value) { if (c.uuid() targetChar.uuid()) { emit dataRead(value); // 把数据交给UI层 } }写特征值类似void BLEManager::writeData(const QByteArray data) { if (!service || !targetChar.isValid()) return; QLowEnergyService::WriteMode mode QLowEnergyService::WriteWithResponse; service-writeCharacteristic(targetChar, data, mode); }写完成之后会触发characteristicWritten信号。注意如果你的设备对写入速度敏感比如一个指令发出去必须等设备ACK之后再发下一条那就要用响应模式并且在characteristicWritten信号里继续发下一条而不是连续调用writeCharacteristic。我实测过连续快速调用写操作有些设备会直接丢掉后面的数据尤其是那些用串口透传芯片跑的BLE模块。开启通知上报需要写CCCD描述符void BLEManager::enableNotify() { if (!service || !targetChar.isValid()) return; // 找到Client Characteristic Configuration Descriptor QLowEnergyDescriptor cccd targetChar.descriptor( QBluetoothUuid::ClientCharacteristicConfiguration); if (!cccd.isValid()) { qWarning() CCCD not found; return; } // 0x0100 表示启用Notify0x0200 表示启用Indicate service-writeDescriptor(cccd, QByteArray::fromHex(0100)); }写CCCD成功之后设备一旦产生数据就会通过characteristicChanged信号推上来。有些设备通知频率很高比如心率传感器每秒能推几十个包你要确保UI层处理数据的槽函数足够轻量不要在槽函数里去做重型计算或者磁盘写入否则Qt事件循环被卡住后面的数据包就会堆积延迟看起来就像“设备突然不发了”。下面是这套例程里一个比较完整的测试流程片段你可以直接参考void BLEManager::testCharacteristic(QLowEnergyService *s, const QLowEnergyCharacteristic c) { QLowEnergyCharacteristic::PropertyTypes props c.properties(); // 可读就读一次 if (props QLowEnergyCharacteristic::Read) { s-readCharacteristic(c); } // 可写就写一组测试数据 if (props QLowEnergyCharacteristic::Write) { QByteArray testData; testData.resize(8); for (int i 0; i testData.size(); i) { testData[i] static_castchar(0x30 i); } s-writeCharacteristic(c, testData, QLowEnergyService::WriteWithResponse); } // 可通知就开启CCCD if (props QLowEnergyCharacteristic::Notify) { QLowEnergyDescriptor cccd c.descriptor( QBluetoothUuid::ClientCharacteristicConfiguration); if (cccd.isValid()) { s-writeDescriptor(cccd, QByteArray::fromHex(0100)); } } }这里把读、写、通知一次性一起测了打印结果到控制台基本就能判断这个特征值是否工作正常。3.4 大数据的MTU和分包问题低功耗蓝牙在链路层有个MTU的概念默认情况下一个ATT包能承载的用户数据长度很有限。旧一些的设备只有23字节扣除ATT头的3个字节实际一包能写进去的应用数据只有20字节。如果你的特征值要写超过20字节的数据必须自己做分包。我一般会先查询连接后的MTU值如果支持协商Qt底层在连接建立时会尽量协商更大的MTU像很多新设备能到247字节。但代码上不能假设对方一定支持稳妥的做法是主动判断单包长度超过限制就循环分包发送。void BLEManager::writeLongData(QLowEnergyService *service, const QLowEnergyCharacteristic c, const QByteArray data) { const int maxPacket 20; // 保守值按20字节分包 int offset 0; while (offset data.size()) { QByteArray chunk data.mid(offset, maxPacket); service-writeCharacteristic(c, chunk, QLowEnergyService::WriteWithResponse); offset chunk.size(); // 注意这里只是演示。真实工程要在characteristicWritten信号里发下一包。 } }上面这段代码有个隐藏问题没有真正等待写完成信号就发了下一包这在一些设备上会翻车。更可靠的写法是维护一个待发送队列每次收到characteristicWritten信号后弹出队首继续发。你根据自己的设备实测来定如果模块对连续写入比较宽容一次性循环发也不是不能用。4. 实测中遇到的坑和排查方法4.1 连接成功但服务列表为空这个现象很经典controller已经connected了discoverServices也调了serviceDiscoveryFinished也触发了但services()返回空列表。大概率是设备不支持当前连接模式或者Windows端没有正确配对。Windows上的解决办法是到系统蓝牙设置里把设备删掉重新配对一次然后再跑程序。Linux上可以先用bluetoothctl pair手动配对再回来跑Qt程序。Android上则多半是权限问题检查动态权限有没有申请到位。4.2 写完数据设备没反应如果writeCharacteristic调用后一直没收到characteristicWritten先确认你用的模式对不对。有的设备只支持WriteNoResponse你却用WriteWithResponse设备端就直接忽略了。反过来如果你用NoResponse发数据期望设备回复结果设备回了一个Response那也会出问题。先用设备的协议文档确认它支持哪种写模式再在代码里固定下来。另外写完数据后如果你马上关闭程序或者断开连接数据可能根本没发出去。BLE的写操作是异步的Qt API调用返回不代表数据已经到了设备。这个在调试的时候尤其容易误导人我建议在测试例程里加上操作日志把“调用写入”“写入成功”“设备确认”这几个节点区分开打出来。4.3 通知功能开启后收不到数据收不到通知先检查CCCD写的是不是0x0100。有些朋友图省事直接写了一个“01”结果设备端解析的时候最高位缺失通知根本没开启。正确写法是QByteArray::fromHex(0100)这是16位小端格式。还有一种情况是CCCD写成功了但设备压根没有主动上报的能力它的Notify只是个摆设。这种要仔细读设备的协议文档有些传感器需要你先写某个控制特征值启动采集它才开始按照频率上报数据。碰到这种情况光靠Qt代码是没法解决的得先把设备端的行为摸清楚。4.4 常见报错和崩溃场景速查现象常见原因解决办法扫描列表为空权限不足或系统蓝牙关闭检查定位/蓝牙权限确认系统蓝牙可用连接后立即断开Windows配对缓存异常删除设备重新配对services()为空设备不响应GATT发现换设备测试或检查连接参数写入无任何信号使用了设备不支持的写模式切换WriteModeCCCD写失败描述符UUID不对检查是否用ClientCharacteristicConfiguration程序崩溃QLowEnergyController提前析构用new创建确保生命周期覆盖整个会话no qt platform pluginQt插件路径缺失部署时带上platforms目录或设置Qt环境变量这些坑我都挨个踩过每次排查到最后都发现不是多么高深的问题更多是平台特性和设备兼容性。所以建议你在写例程时日志打得越细越好。我在BLEManager里每一个信号回调都会打印一条带时间戳的日志这样出问题的时候翻日志就能定位到是哪一步断了。5. 把测试例程扩展成真正的调试工具5.1 特征值数据接到qcustomplot实时画波形如果你测的是传感器类设备比如加速度、心电、温度会发现纯看十六进制数据太抽象了。一个很实用的扩展就是把特征值收到的数据解析成double数组然后丢给qcustomplot画实时波形。qcustomplot这个库不属于Qt官方但用起来非常顺手。大概思路是先在界面上放一个QCustomPlot控件初始化曲线对象然后在characteristicChanged的槽函数里把数据追加到QVector里调用replot()刷新。要控制刷新频率一般每秒刷新10到20次就够了不用每个包都去重绘否则CPU占用率会飙升。5.2 时域波形转频域kissfft直接做FFT有时候光看时域波形看不出问题比如振动传感器你需要看频率成分的变化。Qt本身不带FFT但集成了一个kissfft的接口在QtMath或者Qt频谱相关的例程里经常能看到。你也可以直接在工程里加入kissfft源码把采集到的数据做一次FFT画出频谱图。实现思路不复杂把特征值收到的时域数据填进数组调用kissfft的transform得到频域复数结果取模后按频率轴画出来。需要注意的是采样率这个参数必须搞清楚设备的真实上报频率决定了FFT结果的频率分辨率如果设备上报频率是20Hz那你最多只能看到10Hz以内的成分做频谱分析时心里得有数。这块我还没来得及做成一个完整的工具但已经把基础链路跑通了BLE数据进来时域波形在qcustomplot上实时刷新点一个按钮切换显示频域曲线。对嵌入式调试来说这基本上就是一个简易的无线示波器了。6. 最后再说几句这套测试例程我现在还留着每次拿到一个新的BLE模块第一件事就是跑一遍扫描、连接、服务发现、特征值读写四步流程确认设备基本功能没问题再开始写具体的业务逻辑。整个过程最费时间的其实不是Qt API本身而是不同设备的GATT服务实现差异太大。你写的代码要足够健壮兼容那些不按常理出牌的厂商实现比如服务列表不标准、特征值属性声明不准确、通知使能方式特殊等等。建议手里打算做Qt BLE开发的同行先把这套最基础的特征值读写例程跑通别急着上业务界面。链路层走通了后面的应用层开发才有底气不然数据都拿不到界面做得再好看也没用。本文还有配套的精品资源点击获取