简介:这份源码包面向物联网与嵌入式开发者,聚焦乐鑫ESP32-C3在Arduino框架下的BLE无线通信能力,并配套微信小程序端实现,解决硬件设备与移动端快速互联、通过微信生态完成交互的问题。适合智能家居、健康监测、智能穿戴等场景的开发者,也便于初学者理解软硬件协同流程。压缩包共713个文件,约32.22MB,包含22个ino主程序、10个cpp与10个h源码、21个js及9个wxml、13个wxss等小程序页面文件,另有json配置、png与jpg界面素材、mp4演示视频、bin固件及md说明文档,覆盖从硬件编程到用户界面设计的完整链路。目前已有250人学习下载。项目将ESP32-C3的BLE服务与微信小程序端代码整合在同一工程中,读者可据此梳理广播、连接、数据收发与页面交互的实现方式,并参考目录结构快速定位固件与前端模块,提升开发与调试效率。
1. 从一份源码包说起:ESP32-C3 的 BLE 与微信小程序到底怎么打通
很多人第一次拿到「ESP32-C3 BLE + 微信小程序」这类源码包,第一反应是打开 Arduino IDE 直接点上传,结果串口打印正常、小程序却搜不到设备,或者连上三秒就断。问题往往不在代码本身,而在于没搞清楚这条链路上有三个独立角色:ESP32-C3 作为 BLE 外设(Peripheral),微信小程序作为 BLE 中心设备(Central),以及两者之间那套必须严格对齐的 GATT 服务与特征值约定。标题里的「源码」两个字容易让人误以为拿来即用,实际上它更像一份参考实现,真正要跑通,你得理解广播包怎么组、UUID 怎么定、MTU 怎么协商、小程序端 API 的调用时序为什么不能乱。这篇笔记面向的是手里有 ESP32-C3 开发板、想用微信小程序做 BLE 调试或产品原型的嵌入式与前端开发者,从环境搭建一路写到断连排查,把这份源码背后能复现的路径拆开讲清楚。
2. 环境与工具链:把 ESP32-C3 的 Arduino 框架跑起来
2.1 开发板选型与 Arduino 核心版本
ESP32-C3 是 RISC-V 架构,和经典 ESP32 的 Xtensa 不同,Arduino 框架对它的支持依赖 esp32 核心包的版本。常见做法是在 Arduino IDE 的「开发板管理器」里安装 esp32 by Espressif,版本建议 2.0.14 及以上,早期 1.x 版本对 C3 的 BLE 库支持不完整,容易出现BLEDevice::init()后广播发不出来的情况。开发板型号选「ESP32C3 Dev Module」,如果用的是带 USB-JTAG 的板子,USB CDC On Boot 要设为 Enabled,否则串口监视器看不到输出。
安装完成后,在「工具」菜单里确认三件事:Flash Size 按实际模组选(常见 4MB)、Partition Scheme 选 Default 4MB with spiffs、Upload Speed 用 921600 比较稳。有读者反馈上传失败报Failed to connect to ESP32-C3,八成是没进下载模式——按住 BOOT 键再点复位,松开后立刻上传。
2.2 依赖库清单与最小验证工程
BLE 部分不需要额外装库,esp32 核心包自带BLEDevice.h、BLEServer.h、BLEUtils.h等。但如果你要同时跑 WiFi 和 BLE,注意 C3 的射频是共享的,共存时 BLE 广播间隔会被拉长,这是硬件特性不是 bug。
先写一个最小验证工程,确认板子和 BLE 栈都正常:
#include <BLEDevice.h> #include <BLEServer.h> #include <BLEUtils.h> // 自定义服务与特征 UUID,后续小程序端必须与此一致 #define SERVICE_UUID "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" #define CHARACTERISTIC_RX "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" #define CHARACTERISTIC_TX "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" BLECharacteristic *pTxCharacteristic; class MyServerCallbacks : public BLEServerCallbacks { void onConnect(BLEServer* pServer) { Serial.println("Central connected"); } void onDisconnect(BLEServer* pServer) { Serial.println("Central disconnected, restart advertising"); // 断连后必须重新开始广播,否则小程序再也搜不到 pServer->startAdvertising(); } }; void setup() { Serial.begin(115200); delay(1000); BLEDevice::init("ESP32C3_BLE_Demo"); // 广播名称,小程序按此过滤 BLEDevice::setMTU(247); // 协商较大 MTU,减少分包 BLEServer *pServer = BLEDevice::createServer(); pServer->setCallbacks(new MyServerCallbacks()); BLEService *pService = pServer->createService(SERVICE_UUID); // TX:设备向小程序发送数据(Notify) pTxCharacteristic = pService->createCharacteristic( CHARACTERISTIC_TX, BLECharacteristic::PROPERTY_NOTIFY ); pTxCharacteristic->addDescriptor(new BLE2902()); // RX:小程序向设备写数据(Write) BLECharacteristic *pRxCharacteristic = pService->createCharacteristic( CHARACTERISTIC_RX, BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR ); pService->start(); BLEAdvertising *pAdvertising = BLEDevice::getAdvertising(); pAdvertising->addServiceUUID(SERVICE_UUID); pAdvertising->setScanResponse(true); pAdvertising->setMinPreferred(0x06); pAdvertising->setMinPreferred(0x12); BLEDevice::startAdvertising(); Serial.println("BLE advertising started"); } void loop() { // 每秒通过 Notify 发一条计数,验证链路 static uint32_t counter = 0; if (pTxCharacteristic) { char buf[32]; snprintf(buf, sizeof(buf), "tick:%lu", counter++); pTxCharacteristic->setValue((uint8_t*)buf, strlen(buf)); pTxCharacteristic->notify(); } delay(1000); }这段代码的逻辑分四层:初始化 BLE 栈并设置设备名、创建 Server 与 Service、分别建立 Notify 和 Write 两个特征、最后配置广播参数并启动。参数上重点看三个地方。BLEDevice::setMTU(247)是请求值,实际生效值由双方协商,小程序端在 Android 上通常能到 247,iOS 上系统会限制在 185 左右。setMinPreferred(0x06)和0x12分别对应最小和最大连接间隔,单位是 1.25ms,写 0x06 约 7.5ms,追求低延迟可以这么设,但功耗会上去。BLE2902描述符是 Notify 的开关,没有它小程序端notifyBLECharacteristicValueChange会失败,这是最常见的翻车点之一。
烧录后打开串口,看到BLE advertising started就说明外设侧就绪。此时用手机上的通用 BLE 调试 App 应该能搜到ESP32C3_BLE_Demo,连上后能看到两个特征值。这一步过了,再进小程序端。
3. 微信小程序端:BLE 连接、订阅与收发的完整时序
3.1 小程序 BLE API 的调用顺序为什么不能乱
微信小程序的 BLE 接口是一套异步 API,openBluetoothAdapter→startBluetoothDevicesDiscovery→onBluetoothDeviceFound→createBLEConnection→getBLEDeviceServices→getBLEDeviceCharacteristics→notifyBLECharacteristicValueChange→onBLECharacteristicValueChange,这个顺序不能跳。常见错误是搜到设备立刻createBLEConnection,但此时deviceId在某些 Android 机型上还没稳定,连接会随机失败。稳妥做法是在onBluetoothDeviceFound里按name过滤,拿到目标设备后延迟 200ms 再连。
另一个坑是 iOS 和 Android 的deviceId格式不同。iOS 返回的是 UUID 格式,Android 返回的是 MAC 地址。如果你在代码里硬编码deviceId,换平台必挂。正确做法是每次通过onBluetoothDeviceFound动态获取。
3.2 一个可复用的 BLE 封装模块
下面这段是小程序端的核心逻辑,封装成模块方便复用:
// ble.js const SERVICE_UUID = '6E400001-B5A3-F393-E0A9-E50E24DCCA9E'; const TX_CHAR_UUID = '6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; // 设备 Notify const RX_CHAR_UUID = '6E400002-B5A3-F393-E0A9-E50E24DCCA9E'; // 小程序 Write let deviceId = ''; let serviceId = ''; let txCharId = ''; let rxCharId = ''; function openAdapter() { return new Promise((resolve, reject) => { wx.openBluetoothAdapter({ success: resolve, fail: (err) => { // 10001 表示蓝牙未开启,引导用户去设置 reject(err); } }); }); } function startDiscovery() { return new Promise((resolve) => { wx.startBluetoothDevicesDiscovery({ // 按服务 UUID 过滤,减少无关设备干扰 services: [SERVICE_UUID], allowDuplicatesKey: false, success: resolve }); }); } function findDevice(targetName, timeout = 10000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { wx.stopBluetoothDevicesDiscovery(); reject(new Error('scan timeout')); }, timeout); wx.onBluetoothDeviceFound((res) => { const found = res.devices.find(d => d.name === targetName); if (found) { clearTimeout(timer); wx.stopBluetoothDevicesDiscovery(); resolve(found.deviceId); } }); }); } function connect(id) { deviceId = id; return new Promise((resolve, reject) => { wx.createBLEConnection({ deviceId, timeout: 10000, success: resolve, fail: reject }); }); } function discoverCharacteristics() { return new Promise((resolve, reject) => { wx.getBLEDeviceServices({ deviceId, success: (res) => { // 遍历找到目标服务 const target = res.services.find(s => s.uuid.toUpperCase().includes('6E400001')); if (!target) return reject(new Error('service not found')); serviceId = target.uuid; wx.getBLEDeviceCharacteristics({ deviceId, serviceId, success: (cRes) => { cRes.characteristics.forEach(c => { const uuid = c.uuid.toUpperCase(); if (uuid.includes('6E400003')) txCharId = c.uuid; if (uuid.includes('6E400002')) rxCharId = c.uuid; }); resolve(); }, fail: reject }); }, fail: reject }); }); } function enableNotify(onData) { return new Promise((resolve, reject) => { wx.notifyBLECharacteristicValueChange({ deviceId, serviceId, characteristicId: txCharId, state: true, success: () => { // 必须先开启 notify,再注册回调,否则丢第一条数据 wx.onBLECharacteristicValueChange((res) => { const value = new Uint8Array(res.value); const text = String.fromCharCode.apply(null, value); onData(text); }); resolve(); }, fail: reject }); }); } function send(data) { const buffer = new ArrayBuffer(data.length); const view = new Uint8Array(buffer); for (let i = 0; i < data.length; i++) view[i] = data.charCodeAt(i); return new Promise((resolve, reject) => { wx.writeBLECharacteristicValue({ deviceId, serviceId, characteristicId: rxCharId, value: buffer, success: resolve, fail: reject }); }); } module.exports = { openAdapter, startDiscovery, findDevice, connect, discoverCharacteristics, enableNotify, send };这段封装的关键点在于:findDevice里用services过滤而不是扫全部设备,能显著降低 Android 上的搜索耗时;enableNotify里先调notifyBLECharacteristicValueChange再注册onBLECharacteristicValueChange,顺序反了会丢第一条 Notify 数据;send里把字符串转 ArrayBuffer 时用的是charCodeAt,只支持 ASCII,如果要发中文得用TextEncoder或手动 UTF-8 编码。
参数上,createBLEConnection的timeout设 10000ms 是经验值,低于 5000 在部分低端 Android 机上不够。startBluetoothDevicesDiscovery的allowDuplicatesKey设 false 可以减少回调频率,但如果设备广播间隔较长(比如 1s 以上),可能漏掉,这时要设 true。
3.3 页面层调用与数据展示
在页面的onLoad里串起来:
const ble = require('../../utils/ble.js'); Page({ data: { log: [] }, async onLoad() { try { await ble.openAdapter(); await ble.startDiscovery(); const id = await ble.findDevice('ESP32C3_BLE_Demo'); await ble.connect(id); await ble.discoverCharacteristics(); await ble.enableNotify((text) => { this.setData({ log: [...this.data.log, 'RX: ' + text] }); }); // 连接成功后发一条测试数据 await ble.send('hello'); } catch (e) { console.error('BLE init failed', e); } } });这里用 async/await 把回调地狱拉平,可读性高很多。注意onLoad里不要直接await太久,小程序对页面加载有时间限制,如果扫描超过 10s 建议拆到按钮触发。
4. 避坑与排查:BLE 链路上最容易翻车的五个地方
4.1 小程序搜不到设备
现象:串口打印BLE advertising started,但小程序onBluetoothDeviceFound一直不返回目标设备。
原因通常有三个。一是广播包里没带 Service UUID,小程序用services过滤时就匹配不到。检查pAdvertising->addServiceUUID(SERVICE_UUID)是否在startAdvertising之前调用。二是设备名超过 8 字节被截断,ESP32C3_BLE_Demo有 17 个字符,广播包里放不下,实际广播的是缩短版。解决方法是把名字改短,比如C3_Demo,或者把完整名字放到 Scan Response 里。三是 Android 的定位权限没开,微信小程序在 Android 上搜 BLE 设备需要系统定位权限,用户没授权就搜不到。
解决:先确认广播包内容,用通用 BLE 调试 App 看能否搜到;能搜到就是小程序权限或过滤条件问题,搜不到就是广播配置问题。
4.2 连上后立刻断开
现象:createBLEConnection成功回调触发,但马上收到onBLEConnectionStateChange断开事件。
原因多半是 MTU 协商失败或连接参数不兼容。ESP32-C3 默认 MTU 是 23,如果小程序端在连接后立刻请求大 MTU 而设备没准备好,会触发断连。另一个原因是setMinPreferred设得太激进,比如设成 0x06(7.5ms),部分 Android 手机不接受这么短的间隔,直接断。
解决:把setMTU的调用放到onConnect回调里而不是setup里,等连接建立后再协商。连接间隔先设保守值,比如setMinPreferred(0x18)(30ms),稳定后再往下调。
4.3 Notify 收不到数据
现象:小程序端notifyBLECharacteristicValueChange成功,但onBLECharacteristicValueChange不触发。
原因:TX 特征没有加BLE2902描述符,或者小程序端订阅的特征 UUID 搞反了。注意设备端的 TX(Notify)对应小程序端的订阅对象,设备端的 RX(Write)对应小程序端的写入对象,这两个容易混。
解决:在设备端确认pTxCharacteristic->addDescriptor(new BLE2902())存在;在小程序端打印txCharId和rxCharId,确认订阅的是 TX。
4.4 发送中文乱码
现象:小程序发中文到设备,串口打印出来是乱码。
原因:send函数里用charCodeAt逐字符转字节,中文的 Unicode 码点超过 255,截断后就是乱码。
解决:用TextEncoder做 UTF-8 编码:
function send(data) { const encoder = new TextEncoder(); // 小程序基础库 2.10.0+ 支持 const bytes = encoder.encode(data); const buffer = bytes.buffer; return new Promise((resolve, reject) => { wx.writeBLECharacteristicValue({ deviceId, serviceId, characteristicId: rxCharId, value: buffer, success: resolve, fail: reject }); }); }设备端接收时按字节流处理,不要假设是 ASCII。
4.5 断连后无法重连
现象:设备断开一次后,小程序再也搜不到或连不上。
原因:ESP32 的 BLE 栈在断连后不会自动重新广播,必须在onDisconnect回调里手动调startAdvertising()。另外小程序端如果没调closeBLEConnection就退出页面,连接状态会残留,下次连接报10003(连接已存在)。
解决:设备端在onDisconnect里加pServer->startAdvertising();小程序端在onUnload里调wx.closeBLEConnection和wx.closeBluetoothAdapter清理状态。
5. 进阶技巧:用分帧协议把 BLE 吞吐量拉满
BLE 的单包有效载荷受 MTU 限制,即使协商到 247 字节,实际可用也就 244 左右。如果你要传图片或较长配置,必须自己做分帧。我一般会在应用层加一个简单的帧头:1 字节序号 + 1 字节总帧数 + 2 字节长度 + 数据。接收端按序号重组,丢帧就请求重发。
设备端发送分帧的写法:
void sendFramed(uint8_t *data, size_t len) { const size_t chunkSize = 240; // 留出帧头空间 uint8_t total = (len + chunkSize - 1) / chunkSize; for (uint8_t i = 0; i < total; i++) { size_t offset = i * chunkSize; size_t thisLen = min(chunkSize, len - offset); uint8_t frame[244]; frame[0] = i; // 序号 frame[1] = total; // 总帧数 frame[2] = thisLen & 0xFF; frame[3] = (thisLen >> 8) & 0xFF; memcpy(&frame[4], data + offset, thisLen); pTxCharacteristic->setValue(frame, thisLen + 4); pTxCharacteristic->notify(); delay(15); // 给协议栈留出发送窗口,太快会丢包 } }delay(15)这个值不是随便写的。BLE 连接间隔如果是 30ms,连续 notify 太快会导致协议栈缓冲区溢出,表现为小程序端收到的帧序号跳变。15ms 是在 30ms 间隔下实测不丢包的最小值,如果你把连接间隔调到 15ms,这个值可以降到 8ms 左右。
小程序端重组时用一个数组按序号存帧,收齐后拼接:
let frames = []; let totalFrames = 0; function onFrame(text) { const bytes = new Uint8Array(text.split('').map(c => c.charCodeAt(0))); const seq = bytes[0]; totalFrames = bytes[1]; const len = bytes[2] | (bytes[3] << 8); const payload = bytes.slice(4, 4 + len); frames[seq] = payload; if (frames.filter(Boolean).length === totalFrames) { const full = new Uint8Array(frames.reduce((acc, f) => acc + f.length, 0)); let offset = 0; frames.forEach(f => { full.set(f, offset); offset += f.length; }); frames = []; // full 就是完整数据 } }这套分帧逻辑在传 10KB 以内的配置数据时很稳,再大就要考虑加校验和重传机制了。我自己的习惯是先在串口上把分帧收发跑通,再接小程序,因为串口调试能看到每一帧的原始字节,比在小程序里猜要快得多。BLE 这东西玄学的地方在于,同样的代码在不同手机上表现可能不一样,所以关键参数一定要留可调接口,别写死。希望帮到你。
本文还有配套的精品资源,点击获取