☰
ESP32零基础BLE入门:环境搭建、固件编写与手机控制LED实战
2026/10/3 7:00:35 网站建设 项目流程

1. 为什么选ESP32的BLE做入门:它能解决我实际遇到的什么问题

接触ESP32第一周,我就在灯控项目上做了个“反直觉”的选择:没有用WiFi,而是先用蓝牙BLE。当时有朋友劝我,WiFi不是更常见吗?手机连上路由器,发个HTTP请求不就行了?理论上没错,但现实里有个很实际的问题:我手里那台ESP32放在客厅,手机离开WiFi范围或者路由器重启,设备就失联了;而BLE是点对点连接,手机和开发板之间没有中间环节,拿起手机就能连,连上就能控制,完全不需要联网,也不依赖家里网络环境。第二个原因是功耗,BLE设计的目标就是省电,待机电流可以做到微安级别,而WiFi动不动几十毫安,用电池供电的项目差距非常明显。

如果你和我一样是纯零基础,没写过单片机程序,也没接触过无线通信协议,BLE反而是比WiFi更友好的入口。原因很简单:BLE的“连接—发现服务—读写特征值”这套流程是固化的,就那么几个环节,只要把它当成“手机发短信给开发板”来理解,半天就能动手跑通。这个项目做到最后,你会得到一台只要在手机屏幕上点“开灯”“关灯”,LED就会跟着亮的ESP32开发板,而且完全不用为了写上位机去学一堆网页前端的东西。

这篇文章面向的是三种人:刚到手ESP32、不知道该从哪里开始的硬件小白;卡在“能烧录但不能通信”阶段的同学;以及想把BLE做进自己小项目里、但不想看几十页英文文档的爱好者。我会把环境搭建、BLE基本概念、完整可用的固件代码、手机端操作流程和踩坑记录全部写出来,你照着走就能复现。

2. 搭建环境不翻车:Arduino IDE与ESP32支持包的坑

2.1 硬件清单真的不用买太多

做这个项目最低限度的硬件就是一块ESP32开发板、一根Micro USB数据线、一台电脑和一部安卓或苹果手机。LED和电阻属于加分项,用来让控制效果“看得见”,没有的话也可以用板上自带的GPIO2指示灯。

建议别选ESP32-S2、ESP32-C3或者用ESP32-S3那种带屏幕的板子。不是说它们不好,而是入门阶段资料最多的还是经典款ESP32 DevKit V1,双核240MHz,蓝牙BLE和WiFi都在,价格也是几杯奶茶钱。我在淘宝见过的杂牌板子差异很大,有的USB转串口芯片是CH340,有的是CP2102,驱动大都不一样。到手第一件事,插电脑看设备管理器有没有识别出COM口,没识别就先去装驱动,这一步卡的比后面所有步骤加起来都多。

确认开发板是否被电脑识别: 1. 把ESP32通过USB线连电脑 2. 右键“此电脑”->“管理”->“设备管理器” 3. 查看“端口(COM和LPT)”下是否有新的COM口 4. 没有就装CH340驱动或CP2102驱动

2.2 Arduino IDE的安装和那个著名的2.0.11版本

软件环境目前最省心的路子还是Arduino IDE,我用的版本是2.x系列。不要迷信什么“用VSCode才专业”,零基础阶段工具链越简单越好,等你把LED灯点亮了,再决定要不要迁移到PlatformIO不迟。

重点来了:ESP32支持包的安装。Arduino IDE需要额外安装ESP32开发板支持包,官方方式是打开“文件—首选项—附加开发板管理器网址”,填入乐鑫的JSON地址,然后在“开发板管理器”里搜索esp32安装。听着很简单,实际操作中经常出现的是下载到一半卡死、超时、包损坏,尤其国内网络环境下成功率感人。

这里就要提到一个很实用的替代方案:离线安装包。搜索关键词“Arduino IDE ESP32离线包”,能找到热心网友整理的百度网盘版本,我记得解压后放到对应Arduino安装目录里的“hardware/espressif”文件夹下,再重启IDE就能看到ESP32系列开发板。我当时用的就是ESP32 2.0.11这个版本的离线包,一次通过,没有去折腾代理和镜像源。

安装完成后验证一下:开发板管理器里搜esp32,能看到版本号2.0.11;工具—开发板—esp32中选择“ESP32 Dev Module”。然后写一个最简单blink程序,烧录进去,LED开始闪了,环境就算彻底通了。

2.3 烧录参数拿到就顺手抄下来

我第一次烧录时被一串参数整懵了:Flash Size、Partition Scheme、Upload Speed……其实大部分保持默认就行,真正影响成功率的是下面几个:

开发板:ESP32 Dev Module Upload Speed:921600(不稳就降到115200) Flash Mode:DIO Flash Size:4MB(看板子型号,大多数是4MB) Partition Scheme:Default 4MB with spiffs COM口:选择设备管理器认到的那个

需要强调一点:如果烧录时提示“A fatal error occurred: Failed to connect to ESP32: Timed out waiting for packet header”,90%是因为GPIO0没有拉低导致进入了不了下载模式。开发板进入下载模式的方法通常是按住BOOT键不放,点下载按钮,等出现“Connecting……”字样时松开BOOT键。这一步在后面的BLE烧录时同样成立,别到时候把代码调了半天,结果是板子没进下载模式。

3. 懂一点BLE原理:广播、连接、服务与特征值的通俗解释

3.1 把BLE通信想象成公开电话簿

BLE通信里最常听到的几个词是GATT、服务(Service)、特征值(Characteristic)。我第一次看资料时被这些缩写吓到,后来发现它们完全可以类比成一个电话系统:

  • 广播:ESP32开机会“喊话”,告诉周围手机“我在这,我叫ESP32_LED_01”,这就是广播数据包。
  • 扫描:手机打开蓝牙扫描,看到ESP32在喊话,决定要不要去连它。
  • 连接:手机和ESP32建立一条点对点链路,之后数据只在这两个设备之间走。
  • 服务:就像电话簿里的一个大分类,比如说“LED控制服务”。
  • 特征值:分类底下具体的一条条目,比如“开关LED这条数据”。手机往这个条目里写入“on”或“off”,ESP32读到后执行动作。

关键是理解“特征值”读写这条链路。ESP32作为BLE服务器,会提供一个服务UUID(统一资源标识符),服务下面挂一个特征值UUID。手机要找东西的时候,先按服务UUID找服务,再按特征值UUID找那条能写的数据。UUID不用自己编得有多复杂,用那些网上免费生成的UUID格式就行,格式统一是32位十六进制数,中间用短横线隔开,只要保证服务UUID和特征值UUID在你自己的生态里不冲突就够用了。

3.2 服务器和客户端,到底谁是大哥

在BLE体系里,ESP32通常扮演的是GATT Server,手机是GATT Client。Server这个字容易让人误会,觉得服务器应该是很强大的那一方。但在BLE项目里,Server只是“数据的所有者”——数据放在ESP32上,它负责暴露出来;真正发起请求、读写数据的是手机这个Client。这就好比你在餐厅点菜,菜单由餐厅(Server)提供,但做决定、下指令的是你(Client)。

你只需要记住:ESP32的代码是用来创建服务、创建特征值、以及处理特征值被写入之后的回调;手机端是用来发现服务、找到特征值、往里面写数据。通信数据格式默认是一串字节,最常见的做法就是转成ASCII字符串,比如“on”“off”“123”,直观又方便调试。

3.3 为什么有时候手机连不上,广播里却能看到

这里隐藏着一个入门阶段特别容易踩的坑:BLE广播和可连接性是两个独立的概念。ESP32默认广播时,会把服务器UUID广播出来,但不代表手机一定能在连接时正确枚举服务和特征值。实际项目中常见的是“设备列表里能看到,点连接就失败”,或者“连接成功但找不到服务”。这通常不是代码逻辑出问题,而是服务没有正确加入广播数据、或者特征值属性没有设置成可写。

后面第4节里的代码会给你一个可以照抄的完整配置,先把服务加入广播,再把特征值属性设为PROPERTY_WRITE,这两个点只要错一个,手机端就是看不到数据。

4. 核心固件编写:让ESP32变成可被手机控制的BLE服务器

4.1 完整可用代码直接抄

以下代码在Arduino IDE里可以直接编译烧录。它做的事情很简单:启动BLE并广播,创建一个名为“LED控制”的服务,服务下放一个可读可写的特征值,当手机往这个特征值里写入字符串“on”时GPIO2输出高电平,写入“off”时输出低电平。

#include <BLEDevice.h> #include <BLEServer.h> #include <BLEUtils.h> #include <BLE2902.h> #define SERVICE_UUID "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" #define CHARACTERISTIC_UUID "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" #define LED_PIN 2 bool ledState = false; // 回调类:当手机写入数据时,会触发这里的onWrite class MyCallbacks : public BLECharacteristicCallbacks { void onWrite(BLECharacteristic *pCharacteristic) { std::string rxValue = pCharacteristic->getValue(); String received = ""; if (rxValue.length() > 0) { for (int i = 0; i < rxValue.length(); i++) { received += (char)rxValue[i]; } Serial.print("收到数据: "); Serial.println(received); if (received == "on") { digitalWrite(LED_PIN, HIGH); ledState = true; } else if (received == "off") { digitalWrite(LED_PIN, LOW); ledState = false; } else { Serial.println("未知指令,仅支持 on / off"); } } } }; void setup() { Serial.begin(115200); pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, LOW); // 1. 初始化BLE设备,并取一个广播名 BLEDevice::init("ESP32_LED_01"); Serial.println("BLE设备已初始化"); // 2. 创建服务器 BLEServer *pServer = BLEDevice::createServer(); // 3. 创建服务 BLEService *pService = pServer->createService(SERVICE_UUID); // 4. 创建特征值,并设置属性:可读、可写、可开通知 BLECharacteristic *pCharacteristic = pService->createCharacteristic( CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY ); // 5. 给特征值绑定写入回调 pCharacteristic->setCallbacks(new MyCallbacks()); // 6. 初始值 pCharacteristic->setValue("idle"); // 7. 把服务加入广播数据,这样手机能在广播里直接看到服务UUID pServer->getAdvertising()->addServiceUUID(SERVICE_UUID); pServer->getAdvertising()->setScanResponse(true); // 8. 启动服务,并开始广播 pService->start(); pServer->startAdvertising(); Serial.println("BLE服务器已启动,等待手机连接..."); } void loop() { // 空循环即可,核心逻辑都在回调里 delay(1000); }

4.2 每一段代码都在干什么,必须拆清楚

初始化那三行是整套BLE库的“地基”:BLEDevice::init负责初始化协议栈并给设备起名字;createServer相当于把电话总机开通;createService是给总机装上一个分线器,UUID就是这个分线器的编号。后面再往里挂特征值。

PROPERTY_READ | PROPERTY_WRITE | PROPERTY_NOTIFY这三个属性值最容易被人忽略。很多教程只写了READ|WRITE,但我建议把NOTIFY也加上。原因很简单:手机端连接后如果想实时收到状态变化(比如另一台手机把灯关了,这台手机要同步显示),就必须依赖Notify通知机制。不加这个属性,以后想扩展功能就得重新烧录固件,特别被动。

MyCallbacks里的onWrite是整套系统最核心的“事件入口”。当手机端写数据时,库会调用这个函数,getValue()拿到原始字节数组,再转成字符串。判断逻辑直接对字符串比较,不建议用Serial.read()那种串口思路,因为第一次接触的人很容易把BLE输入和串口监视器的输入搞混。

这里我特意没有写状态回传逻辑,也就是当手机写入“on”时,ESP32并没有更新特征值为“led_is_on”。实际上强烈建议你加上,因为手机端很多时候需要确认命令已经执行。在onWrite里执行完动作后,加一行:

pCharacteristic->setValue(ledState ? "led_on" : "led_off"); pCharacteristic->notify();

这样手机在收到通知值后就能更新UI状态。这一点在真正的产品项目中几乎是必须的,因为用户在手机屏幕上点开关,肯定希望看到开关状态和真实硬件状态保持一致,而不是“发了指令但不知道到底亮没亮”。

4.3 关于UUID,一个让我纠结半天的细节

初学者最容易犯的毛病是去网上找一个教程的UUID直接复制,然后两个不同项目用同一套UUID。BLE设备之间是靠UUID区分服务和数据的,你用自己的项目不复用还好,如果你的设备恰好和另一个设备用同一个UUID,手机连接时会发现两套一模一样的服务,根本分不清哪个是哪个。

解决办法很简单:用任意在线UUID生成器生成一个,然后填到宏定义里。格式就是xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,生成完放在SERVICE_UUID,再生成一个放在CHARACTERISTIC_UUID。唯一要注意的是,服务UUID和特征值UUID不要写成一个值。

我在实际项目中习惯写成:

// 服务UUID #define SERVICE_UUID "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" // 读写特征值 #define CHARACTERISTIC_UUID "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" // 通知特征值(如果有两个特征值分开用,可以再加一个) #define CHARACTERISTIC_UUID_NTF "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"

一个服务下可以挂多个特征值,比如一个专门用来收手机命令,一个专门用来向手机发状态。分开的好处是读写逻辑清晰,排查问题的时候也方便,知道问题出在“命令没进去”还是“状态没出来”。

5. 手机上真正“控制”:先用现成APP证明生活,再自己写APP

5.1 最快验证方式的现成APP操作流程

固件烧录完成后,想“看见”BLE工作,不需要急着写手机程序。推荐用Nordic官方的nRF Connect这个App,安卓和iOS都有,对BLE的支持最全,各种属性都看得清清楚楚。

打开App后这样操作:

1. 点击“Scanner”,扫描周围设备 2. 找到名为ESP32_LED_01的设备,点击Connect 3. 连接成功后点击“Generic Attribute”服务列表 4. 找到你自定义的SERVICE_UUID那个服务 5. 点进服务,找到CHARACTERISTIC_UUID那条特征 6. 点击特征右边的下拉箭头,打开“Write value”选项 7. 在输入框里输入“on”(注意不是带引号) 8. 点击Send,观察ESP32板载LED是否亮起 9. 再输入“off”,点击Send,LED熄灭

这一步如果成功了,意味着ESP32的整个BLE收件链路已经通了,剩下的只是换一种“更漂亮”的手机端而已。如果在这步失败,问题几乎都在固件配置上,不是硬件坏了,具体排查方法放在第6节。

5.2 用MIT App Inventor拼一个自己的控制App

网上有大量“蓝牙APP控制ESP32”的源码,但对零基础来说,最亲民的还是MIT App Inventor,因为它不需要写代码,靠拖拽积木块就能生成一个安卓APK。

大致思路是:

  • 添加一个“BluetoothClient”组件用于搜索和连接蓝牙设备
  • 在屏幕初始化时,调用BluetoothClient.StartDiscovery
  • 放置两个按钮“开灯”“关灯”
  • 开灯按钮的事件里调用BluetoothClient.Call Text “on”,也就是向EV3那个特征地址发送文本“on”

注意MIT App Inventor默认面向的是经典蓝牙,BLE应该用“BluetoothLE”扩展组件。这个细节网上教程经常混讲,不少人和我一样在这里卡过很长时间。建议先确认组件列表里有“BluetoothLE”,再按扩展的初始化、扫描、连接、找到服务、订阅特征值的顺序来拼。整体比写Kotlin省力不少。

5.3 如果你真想写一个原生Android的BLE App

如果你已经有一些Android基础,可以试试直接用Kotlin配合系统蓝牙API做一个最小可用的App。核心步骤就四件事:请求蓝牙权限、扫描设备、连接GATT、写特征值。这里给一个能跑通核心逻辑的代码骨架:

// 1. 获取蓝牙管理器 val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val bluetoothAdapter = bluetoothManager.adapter // 2. 动态申请权限 if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions( this, arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT ), 1001 ) } // 3. 扫描设备 bluetoothAdapter.bluetoothLeScanner.startScan( ScanFilter.Builder().setDeviceName("ESP32_LED_01").build(), ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), scanCallback ) // 4. 连接并发现服务、写特征值 private val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_CONNECTED) { gatt.discoverServices() } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { val service = gatt.getService(UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")) val characteristic = service.getCharacteristic(UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E")) characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT characteristic.value = "on".toByteArray(Charsets.UTF_8) gatt.writeCharacteristic(characteristic) } }

这段代码不是完整的Activity,但主流程都在。值得注意的一点是Android 12之后,蓝牙扫描和连接权限拆成了两个,Android 11之前只需要ACCESS_FINE_LOCATION,版本差异很容易导致同一套代码在新手机上闪退。真机调试时建议先把版本适配逻辑写好,不然你会花很多时间找“代码明明一样但一台手机行一台不行”的原因。

6. 实测排查:连接失败、回调不触发、供电不稳的三个教科书级问题

6.1 问题一:手机能扫描到设备,但点连接就秒断

这个是我做BLE项目时第一个遇到的玄学问题,现象很统一:nRF Connect能看到“ESP32_LED_01”,但点击Connect,状态刚变成Connecting,马上又回到Disconnected。起初我以为是代码问题,重刷了无数遍都一样。

后来定位到根因是“服务没启动”和“广播没开启”的执行顺序。在第一节的代码里,顺序是:

pService->start(); pServer->startAdvertising();

一定不能调换,否则广播里虽然能看到设备名,但服务压根不可用,手机连接后枚举不到任何GATT服务,就会立即断开。这是一处最常见的低级错误,很多人写代码时把startAdvertising写到了start前面,表面上看不出来,实际手机就是连不上。

另一个原因是我当时用了一个别人精简过的BLE库版本,那个版本里createServer之后没有BLEDevice::createServer()->setCallbacks。如果你用的是Arduino ESP32核心2.0.11自带的标准库,一般不会有这种问题。排查的时候可以先在onConnect和onDisconnect回调里加打印,确认连接事件到底有没有触达ESP32侧,再有针对性地看代码。

6.2 问题二:nRF Connect写数据没反应,但串口监视器能看到收件箱

我用过一个第三方App叫“BLE调试助手”,界面上有个“写入”按钮,但怎么点数据都发不出去,串口监视器也干干净净。最后发现,问题出在那个App默认写入的特征值属性是「无响应写」(Write Without Response),而我们代码里设置的是标准PROPERTY_WRITE,所以App端按无响应去写,ESP32端按标准写去听,自然对不上。

解决办法有两个:要么在App里手动选择写入类型为“Write”(带响应的写入),要么在固件特征值属性里加上PROPERTY_WRITE_NR:

BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR | BLECharacteristic::PROPERTY_NOTIFY

加了PROPERTY_WRITE_NR之后,无响应写也能触发onWrite回调了。这一点在选型第三方App时是最大的坑,因为不同App对写入方式的支持差别很大,建议手边常备nRF Connect作为标准测试工具,以它的行为为准。

6.3 问题三:用充电宝或劣质USB线供电,写入“on”瞬间整板重启

这个现象非常迷惑,因为板子不动的时候一切正常,一写“on”就重启,串口监视器刷出一堆乱码。排查到最后才发现不是代码逻辑问题,而是GPIO2上接的LED和大功率动作导致瞬间电流波动,加上劣质数据线的压降,ESP32供电电压跌破稳压阈值,于是复位。

解决思路三步走:

  • 换一根短线、粗一点的USB线,不要用那种只能充电不能传数据的细线。
  • 如果你的LED是和板子共用一个电源,确认LED限流电阻是否接对了,我用的是220Ω电阻,直接接GPIO2。
  • 如果项目里电机、舵机、继电器这类大电流负载,绝对不要直接用开发板的3.3V引脚,要单独供电并共地。开关动作瞬间的电流冲击是开发板随机重启的重灾区。

这里有个经验:排查任何“执行动作后板子重启”的问题,先用USB口供电并打开串口监视器看复位原因,ESP32会打印类似“rst:0x3 (SW_RESET)”等字样,比瞎猜代码强很多。

6.4 排查链路方法汇总

我把这三类典型的排查链路整理成一张表,下次遇到同样现象可以直接对照:

现象优先检查项根因方向解决手段
扫描不到设备名代码是否烧录成功;串口型号是否正确未进入广播状态检查startAdvertising();重新进入下载模式烧录
能看到设备但立即断开服务是否start();广播是否在服务后开启GATT服务未就绪确保先pService->start()再startAdvertising()
连接成功但找不到自定义服务UUID是否和代码一致服务UUID不匹配复制nRF Connect里显示的UUID和代码宏比对
写数据无响应/不触发回调特征值属性是否含WRITE写入类型不匹配增加PROPERTY_WRITE_NR;或改App写入类型
动作执行后重启/乱码电源稳定性;负载电流供电不足/串口干扰换供电线;负载独立供电;降低串口波特率

7. 做完开关之后:BLE项目还能往哪走,以及我的几条体会

我做完“手机APP控制LED”第一期之后,最大的感受不是“我会BLE了”,而是终于理解了为什么这么多物联网项目喜欢用它。这里说的“它”不是指某种特定芯片,而是一整套“设备做服务器、手机做客户端、通过特征值收发包”的通信范型。只要你迈过了那层“看到一个UUID就发怵”的心理关,后面做任何BLE项目都是同一个套路。

接下来值得尝试的方向有三个。第一个是把传感器数据传回手机,把温湿度数值通过另一个特征值的Notify机制定时推给手机,手机就能实时显示。代码只比我上面的例子多一个定时读DHT11、setValue、notify的过程。第二个是把命令做得丰满一点,比如不只收发“on/off”,改成“/home/led?value=1”这类带参数的字符串,手机端可以做成一排预设按钮,点击就发一条。第三个是BLE配合WiFi做双通道,比如BLE负责快速调试配网,WiFi负责长距离传输,这在产品开发里非常常见。

再讲一个我自己的心得体会:做BLE入门,别一上来就追求“魔法般的流畅体验”。BLE的实际通信延迟通常在几十到几百毫秒,尤其手机屏幕和硬件之间本来就有感知延迟。第一次你写完这个项目,在手机App上点“开灯”、看到LED亮起来的那一刻,那种哪怕延迟了一秒的成就感,和直接点网页按钮是完全不一样的,因为整条链路是你自己搭的。

如果一定要给新人一个建议,我会说:先用现成App跑通,再动手改固件,最后才写自己的手机端。别把“写App控制开发板”理解成一件事,它其实是一件事的三层:硬件层、传输层、应用层。分层去学,每一层遇到问题都不会拖累另一层。等你把这三层都摸透了,再回头看这份代码,会发现它整个结构就四个词:初始化、建服务、设回调、开广播。记熟这四个词,所有BLE小项目的门就都开了。

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

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

立即咨询