资讯详情

Qt低功耗蓝牙实战:从扫描到特征值读写的完整例程与避坑指南

📅 2026/10/26 21:20:30 | 华诺云谱 👁 阅读
Qt低功耗蓝牙实战:从扫描到特征值读写的完整例程与避坑指南
简介这份资源是面向 Qt 开发者的低功耗蓝牙BLE特征值读写测试例程适合已掌握 C 与 Qt 基础、希望快速上手物联网设备通信的中级开发者。它围绕 GATT 协议展开演示如何扫描并连接 BLE 设备、获取服务列表、定位目标特征值并完成异步读取与写入操作同时涉及连接丢失、设备无响应等异常处理思路可作为开发健康监测、智能家居类应用时的参考骨架。压缩包共 11 个文件约 9KB以 cpp 与 h 源码为主体辅以 pro/pri 工程配置、ui 界面文件和 README 说明结构紧凑便于直接导入 Qt Creator 编译运行。目前已有 942 人学习下载读者可借助示例代码理解 Qt Bluetooth 模块的调用方式对照自身项目快速验证特征值读写流程并在此基础上扩展自定义 BLE 服务端与客户端功能。1. 从一块连不上的心率带说起这个 Qt 低功耗蓝牙例程到底能干什么手头有一块 BLE 心率带手机 App 秒连换到桌面端用 Qt 写个客户端扫描能扫到、连接也能连上可一到读特征值就卡住回调不触发界面干等。这种“连得上但读不出”的窘境几乎是每个第一次碰 Qt 低功耗蓝牙的开发者都会撞上的墙。这份 Qt 低功耗蓝牙测试例程干的就是把墙拆掉这件事它用 Qt Bluetooth 模块搭了一个最小可跑的桌面端 BLE 调试工具覆盖设备扫描、服务发现、特征值读写、通知订阅这条完整链路适合正在做桌面端蓝牙外设对接、想找一个能直接编译运行的参考骨架的开发者。关键词 qt 低功耗蓝牙 在这里不是标签而是这份例程每一行代码都在回答的问题——Qt 的 BLE 抽象层到底怎么用特征值读写的手感到底在哪。2. 技术选型与工程骨架为什么是 Qt Bluetooth 而不是自己撸协议栈2.1 Qt Bluetooth 模块的定位与版本差异Qt 对低功耗蓝牙的支持集中在 Qt Bluetooth 模块里核心类就那么几个QBluetoothDeviceDiscoveryAgent负责扫描QLowEnergyController负责连接与service发现QLowEnergyService负责服务内的特征值操作QLowEnergyCharacteristic描述单个特征值。这套抽象把各平台底层的蓝牙栈Linux 上的 BlueZ、Windows 上的 WinRT、macOS 上的 CoreBluetooth包了一层写一份代码多平台跑这是选它的最大理由。版本上有个血泪经验必须提前说Qt 5.14 之前QLowEnergyController在部分平台上对特征值写入的完成信号支持不完整写操作发出去没有回执Qt 5.15 之后characteristicWritten信号才在各平台稳定下来。所以这份例程的工程配置里QT bluetooth之外CMakeLists.txt里通常会把最低版本卡在 5.15避免新手在旧版本上调试到怀疑人生。常见做法是在find_package(Qt5 5.15 REQUIRED COMPONENTS Core Quick Bluetooth)里显式写版本号编译期就把不兼容挡掉。cmake_minimum_required(VERSION 3.16) project(BleTest LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) find_package(Qt5 5.15 REQUIRED COMPONENTS Core Quick Bluetooth) add_executable(BleTest main.cpp blectrl.cpp blectrl.h ) target_link_libraries(BleTest PRIVATE Qt5::Core Qt5::Quick Qt5::Bluetooth )这段配置里三个点值得说清CMAKE_AUTOMOC ON是必须的因为BleCtrl类里要用Q_OBJECT宏和信号槽不开启 moc 会报 vtable 未定义Qt5::Bluetooth链接的是模块本体漏了它编译能过、链接报一堆 undefined reference版本号 5.15 是下限不是上限Qt6 下模块名变成Qt6::Bluetooth类名基本不变但部分枚举改了命名空间二次开发时按实际 Qt 版本调整。2.2 工程目录与类职责划分例程的目录结构刻意做得很薄就是为了让人一眼看完BleTest/ ├── CMakeLists.txt ├── main.cpp # 入口注册 QML 类型 ├── blectrl.h # 控制器类声明 ├── blectrl.cpp # 扫描/连接/读写全部逻辑 └── qml/ └── main.qml # 设备列表 特征值操作界面BleCtrl是唯一的状态机持有者继承QObject把QLowEnergyController、QLowEnergyService、QLowEnergyCharacteristic全部收在自己手里通过Q_PROPERTY和信号把状态抛给 QML。这种“C 管逻辑、QML 管展示”的分法在 BLE 场景下特别合适因为蓝牙回调是异步的状态变化频繁用信号驱动界面比在 QML 里直接调 C 方法清晰得多。class BleCtrl : public QObject { Q_OBJECT Q_PROPERTY(QStringList deviceList READ deviceList NOTIFY deviceListChanged) Q_PROPERTY(QString statusText READ statusText NOTIFY statusTextChanged) public: explicit BleCtrl(QObject *parent nullptr); QStringList deviceList() const { return m_deviceList; } QString statusText() const { return m_statusText; } Q_INVOKABLE void startScan(); Q_INVOKABLE void connectToDevice(int index); Q_INVOKABLE void readCharacteristic(const QString uuid); Q_INVOKABLE void writeCharacteristic(const QString uuid, const QByteArray data); signals: void deviceListChanged(); void statusTextChanged(); void characteristicValueRead(const QString uuid, const QByteArray value); private slots: void onDeviceDiscovered(const QBluetoothDeviceInfo info); void onScanFinished(); void onServiceDiscovered(const QBluetoothUuid uuid); void onServiceStateChanged(QLowEnergyService::ServiceState state); void onCharacteristicChanged(const QLowEnergyCharacteristic c, const QByteArray value); void onCharacteristicWritten(const QLowEnergyCharacteristic c, const QByteArray value); private: QBluetoothDeviceDiscoveryAgent *m_discovery nullptr; QLowEnergyController *m_controller nullptr; QLowEnergyService *m_service nullptr; QStringList m_deviceList; QListQBluetoothDeviceInfo m_devices; QString m_statusText; };声明里几个设计取舍值得展开。Q_INVOKABLE修饰的方法才能从 QML 直接调用这是 Qt 的硬规矩漏了就是“方法不存在”的运行时报错。m_devices单独存一份QBluetoothDeviceInfo列表是因为deviceList只给界面显示名字真正连接时需要完整信息地址、地址类型只存字符串会丢数据。onCharacteristicChanged和onCharacteristicWritten分开两个槽是因为通知回调和写回执在协议层是两条路径混在一起处理会在调试时把日志搅乱。3. 核心链路拆解扫描、连接、发现服务到特征值读写3.1 扫描与设备过滤扫描这一步的坑不在代码量而在过滤策略。QBluetoothDeviceDiscoveryAgent默认会把周围所有经典蓝牙和 BLE 设备都报上来桌面环境里一堆耳机、键鼠混进来列表瞬间几十条。例程的做法是在onDeviceDiscovered里按coreConfigurations()过滤只留 BLE 设备。void BleCtrl::startScan() { if (!m_discovery) { m_discovery new QBluetoothDeviceDiscoveryAgent(this); // 低功耗扫描模式省电但发现稍慢桌面调试可换成 Aggressive m_discovery-setLowEnergyDiscoveryTimeout(8000); connect(m_discovery, QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, BleCtrl::onDeviceDiscovered); connect(m_discovery, QBluetoothDeviceDiscoveryAgent::finished, this, BleCtrl::onScanFinished); } m_deviceList.clear(); m_devices.clear(); emit deviceListChanged(); m_discovery-start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod); } void BleCtrl::onDeviceDiscovered(const QBluetoothDeviceInfo info) { // 只保留 BLE 设备经典蓝牙一律丢弃 if (!(info.coreConfigurations() QBluetoothDeviceInfo::LowEnergyCoreConfiguration)) return; m_devices.append(info); m_deviceList.append(info.name().isEmpty() ? info.address().toString() : info.name()); emit deviceListChanged(); }start()的参数LowEnergyMethod是关键不传这个参数在部分平台上会走经典蓝牙扫描路径BLE 设备根本出不来。setLowEnergyDiscoveryTimeout(8000)设 8 秒超时是因为 BLE 广播间隔通常在 100ms 到 1s 之间8 秒足够覆盖大多数外设设太短会漏设备设太长界面卡着不动。coreConfigurations()返回的是位标志用按位与判断这个写法比字符串匹配名字靠谱得多——很多外设名字是空的只能靠地址显示。3.2 连接与服务发现的状态机连接之后不能立刻读写必须先发现服务服务发现完还要等QLowEnergyService进入DiscoveredState这中间是两级异步。例程把这两级串成一条链controller的connected信号触发discoverServices()serviceDiscovered信号里对目标服务调createServiceObject再监听 service 的stateChanged。void BleCtrl::connectToDevice(int index) { if (index 0 || index m_devices.size()) return; const QBluetoothDeviceInfo info m_devices.at(index); if (m_controller) { m_controller-disconnectFromDevice(); m_controller-deleteLater(); } m_controller QLowEnergyController::createCentral(info, this); connect(m_controller, QLowEnergyController::connected, this, [this]() { m_statusText QStringLiteral(已连接开始发现服务); emit statusTextChanged(); m_controller-discoverServices(); }); connect(m_controller, QLowEnergyController::serviceDiscovered, this, BleCtrl::onServiceDiscovered); connect(m_controller, QLowEnergyController::discoveryFinished, this, [this]() { m_statusText QStringLiteral(服务发现完成); emit statusTextChanged(); }); connect(m_controller, QLowEnergyController::errorOccurred, this, [this](QLowEnergyController::Error err) { m_statusText QStringLiteral(控制器错误: %1).arg(static_castint(err)); emit statusTextChanged(); }); m_controller-connectToDevice(); } void BleCtrl::onServiceDiscovered(const QBluetoothUuid uuid) { // 心率服务 0x180D按需替换成目标外设的服务 UUID if (uuid ! QBluetoothUuid(QBluetoothUuid::HeartRate)) { return; } m_service m_controller-createServiceObject(uuid, this); if (!m_service) return; connect(m_service, QLowEnergyService::stateChanged, this, BleCtrl::onServiceStateChanged); connect(m_service, QLowEnergyService::characteristicChanged, this, BleCtrl::onCharacteristicChanged); connect(m_service, QLowEnergyService::characteristicWritten, this, BleCtrl::onCharacteristicWritten); m_service-discoverDetails(); }createCentral是 Qt 5.14 之后推荐的入口比直接 newQLowEnergyController更安全内部会处理平台差异。discoverServices()之后必须等discoveryFinished再操作服务提前调createServiceObject会拿到空指针这是新手最常见的翻车点。discoverDetails()是第二级发现只有调了它服务内的特征值列表才完整characteristics()才返回有效数据。errorOccurred信号在 Qt 5.15 里替代了旧的error信号旧代码迁移时注意改名。3.3 特征值读取与写入的完整实现读写是这份例程的核心。读操作调readCharacteristic结果从characteristicRead信号回来写操作调writeCharacteristic回执从characteristicWritten回来。通知订阅则是先writeDescriptor打开 CCCD客户端特征配置描述符之后数据从characteristicChanged推送。void BleCtrl::readCharacteristic(const QString uuid) { if (!m_service || m_service-state() ! QLowEnergyService::ServiceDiscovered) return; const QLowEnergyCharacteristic ch m_service-characteristic(QBluetoothUuid(uuid)); if (!ch.isValid()) { m_statusText QStringLiteral(特征值不存在: %1).arg(uuid); emit statusTextChanged(); return; } if (!(ch.properties() QLowEnergyCharacteristic::Read)) { m_statusText QStringLiteral(该特征值不支持读取); emit statusTextChanged(); return; } m_service-readCharacteristic(ch); } void BleCtrl::writeCharacteristic(const QString uuid, const QByteArray data) { if (!m_service || m_service-state() ! QLowEnergyService::ServiceDiscovered) return; const QLowEnergyCharacteristic ch m_service-characteristic(QBluetoothUuid(uuid)); if (!ch.isValid()) return; // 按特征值属性选择写模式有 WriteNoResponse 优先用延迟更低 QLowEnergyService::WriteMode mode (ch.properties() QLowEnergyCharacteristic::WriteNoResponse) ? QLowEnergyService::WriteWithoutResponse : QLowEnergyService::WriteWithResponse; m_service-writeCharacteristic(ch, data, mode); } void BleCtrl::onCharacteristicChanged(const QLowEnergyCharacteristic c, const QByteArray value) { emit characteristicValueRead(c.uuid().toString(), value); }properties()返回的是位标志读之前先判断Read位写之前判断Write或WriteNoResponse位这个前置检查能省掉大量“为什么没反应”的排查时间——很多外设的特征值只支持通知不支持读硬读就是静默失败。写模式的选择有讲究WriteWithoutResponse不等待对端确认吞吐高但可能丢包WriteWithResponse每包等回执可靠但慢。例程优先用无回执模式调试阶段如果发现数据对不上再切回有回执模式定位问题。通知订阅的代码单独拎出来因为它是读写之外最常用的操作void BleCtrl::enableNotification(const QString uuid, bool enable) { const QLowEnergyCharacteristic ch m_service-characteristic(QBluetoothUuid(uuid)); if (!ch.isValid()) return; const QLowEnergyDescriptor cccd ch.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration); if (!cccd.isValid()) return; // 0x0001 开启通知0x0002 开启指示0x0000 关闭 m_service-writeDescriptor(cccd, enable ? QByteArray::fromHex(0100) : QByteArray::fromHex(0000)); }CCCD 的写入值是两字节小端0100表示开启通知0200表示开启指示Indication带确认0000关闭。这个字节序写反了是经典翻车现场写0001在部分外设上会被当成非法值直接忽略表现就是“订阅了但没数据”。4. 避坑与排查BLE 调试里那些静默失败4.1 扫描不到设备现象点扫描列表一直是空的日志里也没有错误。原因通常有三个一是start()没传LowEnergyMethod走了经典蓝牙路径二是 Linux 下 BlueZ 服务没起或者当前用户不在bluetooth组权限不足时扫描静默返回空三是外设已经被手机连走BLE 设备同一时间通常只接受一个中心连接被占用后广播可能停止。解决顺序是先确认bluetoothctl命令行能扫到再回来查代码命令行都扫不到就是环境问题跟 Qt 无关。4.2 连上了但服务列表为空现象connected信号触发了discoverServices()也调了但serviceDiscovered一个都没来。原因多半是服务发现还没完成就去调createServiceObject或者目标外设用了自定义 128 位 UUID代码里却按 16 位标准 UUID 去匹配。解决方法是把discoveryFinished里的服务 UUID 全部打印出来对照外设文档逐个核对自定义 UUID 必须完整写全 128 位少一段都匹配不上。4.3 读特征值没有回调现象readCharacteristic调了characteristicRead信号不触发。原因特征值属性里没有Read位或者服务状态还停在DiscoveringState没进DiscoveredState。前者靠properties()检查能提前发现后者要在读之前断言m_service-state() QLowEnergyService::ServiceDiscovered。还有一种隐蔽情况是特征值 UUID 传了字符串但格式不对QBluetoothUuid解析失败得到无效对象isValid()返回 false代码里如果没检查就直接调读就是静默无操作。4.4 写入成功但外设没反应现象characteristicWritten回执来了外设行为却没变化。原因通常是写模式选错——外设只接受WriteWithResponse代码用了WriteWithoutResponse协议层发出去了但对端没处理或者写入的数据长度超过 MTU被截断。默认 MTU 是 23 字节有效载荷 20 字节超过就得分包或者先协商 MTU。排查时把写入的QByteArray长度和十六进制内容打出来对照外设协议文档逐字节核对十有八九是长度或字节序的问题。4.5 通知订阅后收不到数据现象CCCD 写了0100characteristicChanged一直不触发。原因CCCD 描述符 UUID 拿错或者写入的值字节序反了。QBluetoothUuid::ClientCharacteristicConfiguration是标准常量用它拿描述符最稳值必须是0100不是0001。另外部分外设在订阅后需要额外写一个使能命令到控制点特征值才会开始推送数据这个要看外设文档不是 Qt 的问题。5. 二次开发改造把例程变成你自己的 BLE 调试工具拿到这份例程直接跑通只是第一步真正省时间的是把它改成能复用的调试工具。我一般会做三处改造。第一处是把硬编码的服务 UUID 抽成配置。例程里写死了心率服务0x180D实际项目里外设千差万别改成从 JSON 配置文件读服务 UUID 和特征值 UUID 列表启动时加载界面按配置动态生成操作按钮。这样换一个外设只改配置不改代码验证方法很简单换一块不同服务的外设配置文件改完能直接读写就算改造成功。第二处是加一层十六进制收发视图。BLE 调试里九成时间在看字节QML 界面里加一个TextArea显示收发日志每条带时间戳、方向、UUID、十六进制内容比在qDebug里翻日志高效得多。实现上在BleCtrl里加一个logMessage(QString)信号读写回调和通知回调里都发一条QML 侧追加到列表。第三处是把连接状态做成可重入的。例程里connectToDevice每次新建 controller旧的要deleteLater但如果在连接过程中重复点击会出现两个 controller 同时存活、信号重复触发的玄学问题。改造方法是加一个m_connecting标志位连接未完成时忽略新的连接请求errorOccurred和disconnected里复位标志。这个改动不大但能省掉大量“为什么回调触发了两次”的排查。改造点改动位置验证方式UUID 配置化blectrl.cpp构造与onServiceDiscovered换外设只改 JSON 能跑通十六进制日志BleCtrl新增信号 QML 列表读写通知均有带时间戳记录连接防重入connectToDevice加标志位连点连接按钮不产生重复回调最后说个习惯。BLE 调试最怕的不是报错是静默失败——信号不来、回调不触发、外设没反应全靠猜。从那以后我每次接新外设都强制走一遍“命令行先扫、UUID 全打印、属性先检查、字节序对照文档”这四步一步不省。这份例程的价值就在于它把这四步的骨架都摆好了你照着填自己的 UUID 和业务逻辑就行。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑