1. 为什么我建议零基础从蓝牙BLE切入ESP32
很多人拿到ESP32开发板的第一反应是连WiFi、点灯、跑个Web服务器。但如果你问我,零基础入门ESP32最应该先玩什么,我会毫不犹豫地说:蓝牙BLE。原因很直接——它不需要路由器、不需要配网、不需要知道IP地址,手机装个调试APP就能直接跟板子通信。对于刚接触嵌入式的朋友来说,这种“即开即用”的反馈感太重要了。
ESP32这颗芯片本身就内置了WiFi和蓝牙双模,蓝牙部分同时支持经典蓝牙和低功耗蓝牙(BLE)。我们这里重点聊BLE,因为它的协议栈更轻、功耗更低、手机端支持也更统一。你手上那块十几块钱的ESP32开发板,配合MicroPython固件,十几行代码就能让手机收到板子发来的数据,反过来手机也能控制板子上的LED。这个过程中你会自然理解GATT、Service、Characteristic这些BLE核心概念,比死记硬背协议文档高效得多。
这篇文章面向的是完全没有蓝牙开发经验的朋友,默认你会一点Python基础(哪怕只是知道变量和函数),会用电脑烧录固件,手机是安卓或iOS都行。我会从环境搭建讲到代码实现,再到手机端调试和常见问题排查,每一步都给出我实际踩过坑之后总结的操作方法。你不需要买额外的蓝牙模块,ESP32自带的就是完整方案。
2. 动手前的环境准备与固件选择
2.1 硬件清单与开发板选型建议
先把手头的东西理清楚。你需要的硬件其实很少:
- 一块ESP32开发板(ESP32-WROOM-32最经典,ESP32-S3也完全没问题)
- 一根支持数据传输的USB线(注意,很多线只能充电不能传数据,这个坑我踩过)
- 一台电脑(Windows、macOS、Linux都行)
- 一部手机(安卓或iOS)
开发板选型上,如果你是纯新手,我建议直接买ESP32-WROOM-32的核心板,引脚兼容性好,社区资料最多。ESP32-S3是 newer 的芯片,性能更强、支持USB OTG,但如果你只是学BLE,两者在蓝牙API层面几乎没区别。价格上WROOM-32通常更便宜,坏了也不心疼。
注意:买板子的时候留意USB转串口芯片型号。CP2102和CH340是最常见的两种,Windows下CH340可能需要手动装驱动,CP2102一般免驱。如果你插上板子电脑没反应,先换根线,再检查驱动。
2.2 MicroPython固件烧录全流程
ESP32出厂一般带的是AT固件或者Arduino默认固件,我们要用MicroPython,所以第一步是刷固件。整个过程分三步:下载固件、安装烧录工具、执行烧录。
下载固件:去MicroPython官网的下载页面,找到ESP32对应的固件。注意区分ESP32和ESP32-S3,固件不通用。一般下载最新的稳定版.bin文件即可,文件名类似esp32-20230426-v1.20.0.bin。国内下载速度可能慢,可以找国内镜像源,但一定要确认固件版本和芯片型号匹配。
安装烧录工具:推荐用esptool,这是Python写的一个命令行工具,安装很简单:
pip install esptool装完之后,把ESP32用USB线连到电脑,确认串口号。Windows在设备管理器里看“端口”,macOS用ls /dev/tty.*,Linux用ls /dev/ttyUSB*。
执行烧录:先擦除Flash,再写入固件。命令如下:
# 擦除Flash(把COM3换成你的串口) esptool.py --chip esp32 --port COM3 erase_flash # 烧录固件(地址固定0x1000) esptool.py --chip esp32 --port COM3 --baud 460800 write_flash -z 0x1000 esp32-20230426-v1.20.0.bin烧录过程中如果卡住,大概率是波特率太高,把460800降到115200再试。烧完之后按一下板子上的EN键复位,如果固件正常,你会看到板载LED闪一下(部分板子没有LED,属正常)。
2.3 串口终端与文件传输工具配置
固件刷好后,你需要一个能跟MicroPython交互的串口终端。我常用的是mpremote,它是MicroPython官方推荐的命令行工具,安装:
pip install mpremote连接板子:
mpremote connect COM3进去之后你会看到>>>提示符,说明MicroPython已经跑起来了。你可以直接敲print("hello")测试。要传文件的话,mpremote也支持:
mpremote connect COM3 fs cp main.py :main.py这条命令把电脑上的main.py复制到板子的文件系统里。板子重启后会自动执行main.py,这是MicroPython的默认行为。
如果你更喜欢图形化工具,Thonny IDE是个不错的选择,它内置了MicroPython支持和文件管理功能,对新手很友好。但命令行工具更灵活,后面调试BLE的时候你会感受到。
3. BLE核心概念一次讲透
3.1 GATT、Service、Characteristic到底是什么关系
BLE的通信模型跟传统的串口透传完全不一样,它是以“属性”为中心组织的。你可以把BLE设备想象成一个酒店,酒店里有多个楼层(Service),每个楼层有多个房间(Characteristic),每个房间里放着具体的东西(Value)。手机作为客户端,想要什么就去对应的房间拿或者放。
具体来说:
- GATT(Generic Attribute Profile):是整个通信的顶层框架,规定了数据怎么组织、怎么读写。
- Service:一个服务代表一类功能,比如“电池服务”专门管电量,“设备信息服务”管厂商名称。每个Service有一个UUID。
- Characteristic:服务下面的具体数据点,比如电池服务里有一个“电量等级”特征,它的值就是当前电量百分比。每个Characteristic也有自己的UUID,并且定义了读、写、通知等权限。
UUID是128位的标识符,标准服务用16位短UUID(比如电池服务是0x180F),自定义服务一般用完整的128位UUID。在MicroPython里,我们可以自己生成UUID,只要保证手机端和板子端一致就行。
3.2 手机端与ESP32的通信流程
整个通信流程是这样的:
- ESP32启动后开始广播,广播包里包含设备名称和部分服务信息。
- 手机打开蓝牙扫描,发现这个设备后发起连接。
- 连接建立后,手机查询ESP32支持哪些Service和Characteristic。
- 手机根据Characteristic的权限进行读、写或订阅通知。
- 数据通过Characteristic的Value字段双向传输。
这里面最关键的是通知(Notify)机制。ESP32可以主动向手机推送数据,不需要手机轮询。比如你接了个温度传感器,ESP32每隔一秒把温度值写入Characteristic,并触发通知,手机就能实时收到。反过来,手机写Characteristic的值,ESP32收到后可以执行相应动作,比如点亮LED。
3.3 MicroPython的蓝牙模块能力边界
MicroPython的bluetooth模块提供了BLE的基本功能,包括:
- 创建BLE对象并配置广播
- 注册Service和Characteristic
- 处理连接、断开事件
- 读写Characteristic的值
- 发送通知
但它也有一些限制。比如不支持经典蓝牙的SPP协议,不能做蓝牙音频,外设角色(Peripheral)支持得比较好,中心角色(Central)虽然也支持但相对复杂。对于我们“手机控制ESP32”这个场景,ESP32做外设、手机做中心,正好是MicroPython最擅长的方向。
实操心得:MicroPython的BLE API在不同版本间有过调整,建议用1.19以上的固件。如果你在网上找到的代码跑不通,先检查固件版本,再看API是否变了。
4. 从零编写第一个BLE控制程序
4.1 定义UUID与创建BLE对象
我们先写一个最简版本:ESP32广播一个自定义服务,里面有一个可读写的Characteristic,手机写1点亮板载LED,写0熄灭。
import bluetooth import machine import time # 板载LED,不同板子引脚可能不同,ESP32-WROOM-32通常是2 led = machine.Pin(2, machine.Pin.OUT) # 自定义UUID,随便生成但两端要一致 _SERVICE_UUID = bluetooth.UUID("6E400001-B5A3-F393-E0A9-E50E24DCCA9E") _CHAR_UUID = bluetooth.UUID("6E400002-B5A3-F393-E0A9-E50E24DCCA9E") # 定义Characteristic,权限为可读可写 led_char = (bluetooth.UUID(_CHAR_UUID), bluetooth.FLAG_READ | bluetooth.FLAG_WRITE) # 定义Service,包含上面的Characteristic led_service = (bluetooth.UUID(_SERVICE_UUID), (led_char,)) # 初始化BLE ble = bluetooth.BLE() ble.active(True) # 注册Service,返回句柄 ((led_handle,),) = ble.gatts_register_services((led_service,)) # 设置初始值 ble.gatts_write(led_handle, b'0')这段代码里,gatts_register_services返回的是一个嵌套元组,里面是各个Characteristic的句柄。我们只有一个Characteristic,所以用((led_handle,),)解包。句柄是后续读写操作的凭证,一定要保存好。
4.2 配置广播包与扫描响应
BLE设备必须先广播,手机才能发现它。广播包里可以放设备名称、UUID等信息。MicroPython提供了gap_advertise方法来配置:
def adv_encode_name(name): # 把设备名编码成广播格式 return bytes([len(name) + 1, 0x09]) + name.encode() def adv_encode_uuid(uuid): # 把UUID编码成广播格式 b = bytes(uuid) return bytes([len(b) + 1, 0x07]) + b name = "ESP32_LED" adv_data = adv_encode_name(name) + adv_encode_uuid(_SERVICE_UUID) ble.gap_advertise(100000, adv_data)100000是广播间隔,单位微秒,这里表示100毫秒。广播数据有长度限制,最多31字节,所以别塞太多东西。如果放不下,可以把UUID放到扫描响应里,用gap_advertise的第二个参数传扫描响应数据。
4.3 中断回调处理手机端读写
手机连接、断开、写数据这些事件,MicroPython通过中断回调通知我们。我们需要注册一个事件处理函数:
def ble_irq(event, data): if event == 1: # 连接建立 conn_handle, addr_type, addr = data print("手机已连接") elif event == 2: # 连接断开 conn_handle, addr_type, addr = data print("手机已断开") # 重新开始广播 ble.gap_advertise(100000, adv_data) elif event == 3: # 手机写了数据 conn_handle, attr_handle = data value = ble.gatts_read(attr_handle) print("收到数据:", value) if value == b'1': led.value(1) elif value == b'0': led.value(0) ble.irq(ble_irq)事件编号的含义:1是连接,2是断开,3是写操作。断开后一定要重新调用gap_advertise,否则手机再也搜不到设备。这个坑我踩过,调试了半天才发现是忘了重新广播。
把以上代码保存为main.py传到板子,复位后ESP32就开始广播了。
5. 手机端调试与数据交互实战
5.1 安卓与iOS调试APP选型对比
手机端我们需要一个能扫描BLE、连接、读写Characteristic的通用调试工具。这类APP很多,我列几个常用的:
| APP名称 | 平台 | 特点 | 适合场景 |
|---|---|---|---|
| nRF Connect | 安卓/iOS | 功能最全,界面专业 | 深度调试,查看所有Service |
| LightBlue | 安卓/iOS | 界面简洁,上手快 | 快速验证读写 |
| BLE Scanner | 安卓/iOS | 轻量,广告少 | 日常调试 |
| 蓝牙调试助手 | 安卓 | 中文界面 | 英文不好的朋友 |
我个人最推荐nRF Connect,它能完整展示设备的GATT结构,每个Characteristic的UUID、权限、当前值都一目了然。LightBlue也不错,但免费版有广告。
5.2 连接ESP32并验证读写
打开nRF Connect,点击扫描,你应该能看到名为ESP32_LED的设备。点击连接,然后展开Service列表,找到我们自定义的UUID6E400001-...,点进去会看到Characteristic6E400002-...。
点击Characteristic右侧的向上箭头(写操作),输入1,选择写入类型。这里有个细节:BLE写操作分“带响应写”和“无响应写”。带响应写会等ESP32确认,更可靠;无响应写速度快但不保证送达。调试阶段建议用带响应写。
写完之后,板子上的LED应该亮了。再写0,LED熄灭。如果没反应,先看串口终端有没有打印“收到数据”,有打印说明通信正常,问题在LED引脚;没打印说明手机没写成功,检查UUID是否一致。
5.3 用通知实现ESP32主动上报数据
前面是手机控制ESP32,反过来ESP32也能主动给手机发数据。比如我们加一个Characteristic,每隔一秒上报一次运行时间。
import bluetooth import machine import time _NOTIFY_UUID = bluetooth.UUID("6E400003-B5A3-F393-E0A9-E50E24DCCA9E") notify_char = (bluetooth.UUID(_NOTIFY_UUID), bluetooth.FLAG_NOTIFY) led_char = (bluetooth.UUID(_CHAR_UUID), bluetooth.FLAG_READ | bluetooth.FLAG_WRITE) led_service = (bluetooth.UUID(_SERVICE_UUID), (led_char, notify_char)) ble = bluetooth.BLE() ble.active(True) ((led_handle, notify_handle),) = ble.gatts_register_services((led_service,)) conn_handle = None def ble_irq(event, data): global conn_handle if event == 1: conn_handle, _, _ = data elif event == 2: conn_handle = None ble.gap_advertise(100000, adv_data) elif event == 3: conn_handle, attr_handle = data value = ble.gatts_read(attr_handle) if value == b'1': led.value(1) elif value == b'0': led.value(0) ble.irq(ble_irq) ble.gap_advertise(100000, adv_data) count = 0 while True: if conn_handle is not None: count += 1 ble.gatts_notify(conn_handle, notify_handle, str(count).encode()) time.sleep(1)手机端在nRF Connect里点击notify Characteristic旁边的三个点,选择“Enable notifications”,就能看到每秒收到的计数值。这个机制在实际项目中非常有用,比如传感器数据上报、状态监控等。
6. 常见问题排查与避坑指南
6.1 手机搜不到设备怎么办
这是新手遇到最多的问题。排查顺序如下:
- 确认板子真的在广播:串口终端里看看有没有报错,代码是否执行到了
gap_advertise。 - 确认手机蓝牙已开启:听起来很傻,但确实有人忘了开。
- 检查广播数据是否超长:超过31字节会导致广播失败,精简一下。
- 距离和干扰:BLE有效距离一般10米以内,中间有墙会衰减。靠近点试试。
- 手机缓存问题:安卓有时会缓存旧的BLE设备信息,关掉蓝牙再开,或者重启手机。
注意:iOS对广播包格式要求更严格,如果广播数据格式不规范,安卓能搜到但iOS搜不到。建议用标准的广播格式,设备名用0x09类型,UUID用0x07类型。
6.2 连接后立刻断开是什么原因
连接不稳定通常有几个原因:
- 供电不足:ESP32在蓝牙工作时电流会突然增大,如果USB口供电能力弱,可能导致复位。换一个USB口或者用带供电的Hub。
- 广播间隔太短:设成20ms以下会增加功耗和冲突概率,建议100ms左右。
- 代码里有阻塞操作:比如
time.sleep(10)这种,会阻塞BLE协议栈的处理。用非阻塞的方式写循环。 - 手机端主动断开:有些APP在后台会自动断开连接,保持APP在前台。
6.3 写入数据没反应怎么排查
按这个顺序查:
- 串口有没有打印“收到数据”?没有的话,说明写操作没到达ESP32。
- 检查Characteristic的权限是否包含
FLAG_WRITE。 - 检查手机端写入的UUID是否和板子端一致,一个字符都不能差。
- 写入的值格式对不对?我们代码里判断的是
b'1',如果你手机端发的是字符串"1",在BLE层面都是字节,应该没问题。但如果发了0x01这个字节,就不等于b'1'了。 - 试试带响应写,无响应写可能丢包。
6.4 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 搜不到设备 | 未广播/广播数据超长/手机缓存 | 检查代码、精简广播、重启蓝牙 |
| 连接后立即断开 | 供电不足/广播间隔太短/阻塞操作 | 换USB口、调大间隔、改非阻塞 |
| 写入无反应 | 权限不对/UUID不匹配/写类型不对 | 加WRITE权限、核对UUID、用带响应写 |
| 通知收不到 | 未使能通知/conn_handle为空 | 手机端Enable notifications、检查连接状态 |
| 板子反复重启 | 供电不足/代码异常 | 换电源、看串口报错信息 |
7. 进阶方向与项目扩展思路
7.1 多Characteristic协同控制
实际项目里往往需要多个控制通道。比如一个Characteristic控制LED,一个控制蜂鸣器,一个上报温度。你只需要在Service里注册多个Characteristic,分别处理各自的句柄即可。注意句柄的顺序要和注册时的顺序一致,解包的时候别搞错。
led_char = (bluetooth.UUID("...0002..."), bluetooth.FLAG_READ | bluetooth.FLAG_WRITE) buzzer_char = (bluetooth.UUID("...0004..."), bluetooth.FLAG_WRITE) temp_char = (bluetooth.UUID("...0005..."), bluetooth.FLAG_READ | bluetooth.FLAG_NOTIFY) service = (bluetooth.UUID("...0001..."), (led_char, buzzer_char, temp_char)) ((led_h, buzzer_h, temp_h),) = ble.gatts_register_services((service,))这样手机端就能分别操作三个通道,互不干扰。
7.2 结合传感器做数据采集上报
ESP32接一个DHT11温湿度传感器,把数据通过BLE通知发给手机,就是一个完整的物联网小项目。DHT11的驱动代码网上很多,注意MicroPython的dht模块用法:
import dht import machine sensor = dht.DHT11(machine.Pin(4)) while True: sensor.measure() temp = sensor.temperature() humi = sensor.humidity() if conn_handle: ble.gatts_notify(conn_handle, temp_handle, f"{temp},{humi}".encode()) time.sleep(2)手机端收到的就是“温度,湿度”格式的字符串,解析一下就能显示。
7.3 低功耗优化与电池供电
如果项目要用电池供电,BLE的低功耗特性就派上用场了。几个优化点:
- 调大广播间隔,比如500ms甚至1s
- 连接间隔参数可以协商,手机端一般会自动优化
- 不用的时候让ESP32进入light sleep,有事件再唤醒
- 关闭不必要的板载LED和稳压器
实测下来,一块1000mAh的锂电池,配合合理的休眠策略,ESP32做BLE外设可以跑好几天甚至几周。
7.4 从BLE到WiFi的混合方案
BLE适合近距离、低功耗、即连即用的场景,WiFi适合大数据量、远程访问。两者可以结合:用BLE做配网,把WiFi的SSID和密码通过BLE传给ESP32,ESP32连上WiFi后再走网络通信。这种方案在智能家居产品里很常见,手机APP先通过BLE配网,之后通过云平台控制。
我在实际项目中用过这个思路,BLE配网的代码量不大,但用户体验比AP热点配网好很多,因为不需要手动切换手机WiFi。
8. 我踩过的坑与实操心得
最后分享几个我在实际调试中总结的经验,都是文档里不会写的。
第一,LED引脚别搞错。ESP32-WROOM-32的板载LED通常是GPIO2,但有些板子是GPIO5或者其他引脚。如果你写1灯不亮,先查板子原理图,或者用万用表量一下。我有一块板子LED接在GPIO2上,但它是低电平点亮,写1反而灭,这种也要注意。
第二,MicroPython的BLE API在不同固件版本间有差异。比如早期版本gatts_notify的参数顺序跟现在不一样。如果你从网上抄的代码跑不通,先看固件版本,再去官方文档核对API签名。别死磕,换个版本可能就好了。
第三,手机APP的缓存很坑。安卓的BLE扫描会缓存设备信息,有时候你改了设备名或者UUID,手机还是显示旧的。解决办法是关蓝牙再开,或者在APP里清除缓存。iOS相对好一点,但也有类似问题。
第四,广播数据超过31字节会静默失败。不会报错,就是搜不到。我当初把设备名、UUID、厂商数据全塞进去,结果超了,排查了半天。后来只放设备名和UUID,问题解决。记住31字节是硬限制。
第五,调试BLE最好用两个工具对照。串口终端看ESP32的日志,手机APP看通信状态,两边对照才能快速定位问题。只看一边容易误判。
第六,别在中断回调里做耗时操作。ble_irq是在BLE协议栈的上下文里执行的,如果你在里面time.sleep或者做复杂计算,会阻塞整个蓝牙通信。正确的做法是在回调里设置标志位,主循环里再处理。
第七,供电真的很重要。我遇到过ESP32连上蓝牙后随机重启,换了三个USB口才找到一个是供电稳定的。如果你用电池供电,记得加电容滤波,蓝牙发射瞬间的电流尖峰很容易把电压拉低。
这个项目后续还可以这样扩展:加一个OLED屏幕显示当前连接状态和收到的数据;用多个ESP32组网,手机轮流连接;把控制逻辑做成Web配置页面,通过BLE写入WiFi信息后自动切换。BLE是ESP32最实用的功能之一,把这套流程跑通,后面做智能家居、可穿戴设备、无线传感器节点都会轻松很多。