基于ESP-IDF的ESP32-S3蓝牙透传实现:从环境搭建到数据收发(ESP-IDE开发平台)
最近在折腾ESP32-S3的开发,选用了ESP-IDE软件配合ESP-IDF插件进行环境搭建。之所以没有用Arduino,主要还是因为项目里用到了LVGL,而官方提供的示例代码基本都基于ESP-IDF,索性就沿着这个路径把ESP-IDF也一并学了。我的设备为ESP32-S3-N16R8版本。
在学习蓝牙透传功能的过程中,确实踩了一些坑,也摸索出了一些调试经验。这篇文章就分享一下如何在ESP-IDF环境下实现蓝牙通信,尤其适合刚开始接触ESP32的同学。
对于初学者来说,在嵌入式开发中实现设备间的数据传输,常用方式一般有串口、WiFi和蓝牙等。而ESP32系列芯片的一大优势,就是同时集成了WiFi和蓝牙功能,使用起来非常方便。
1. 导入蓝牙组件
在ESP-IDF环境中,蓝牙功能已经以组件的形式提供,复制 ESP-IDF 官方 NimBLE 版 SPP 代码,以下是他的路径,在你电脑本地就有:
esp-idf-v5.3.1/examples/bluetooth/nimble/ble_spp/main/ble_spp.c
如果你的工程中没有相关文件,可以通过以下网址获取:
https://gitee.com/EspressifSystems/esp-idf/tree/master/examples/bluetooth/bluedroid/ble/ble_spp_server/main
文件如图(打红框是我们需要的):
接下来,在您的main.c文件同级目录下新建一个文件夹,用于存放蓝牙相关的代码文件。将之前下载好的蓝牙库文件放入此文件夹中。
此时系统还无法自动识别这些文件,因此需要配置CMakeLists.txt,确保编译器能够扫描到该目录。
在工程根目录的CMakeLists.txt中添加以下内容,以包含新建的蓝牙文件夹:
# 递归搜索main目录下所有.c文件
file(GLOB_RECURSE SOURCES_C
"*.c" # main根目录的.c文件
"utils/*.c" # utils文件夹下的.c文件
"utils/*/*.c" # utils子文件夹的.c文件
"bluetooth/*.c"
"bluetooth/*/*.c"
)
# 递归搜索main目录下所有.cpp文件
file(GLOB_RECURSE SOURCES_CPP
"*.cpp"
"utils/*.cpp"
"utils/*/*.cpp"
"bluetooth/*.cpp"
"bluetooth/*/*.cpp"
)
# 注册ESP32组件,关键:INCLUDE_DIRS添加"ui"目录(解决头文件找不到)
idf_component_register(
SRCS ${SOURCES_C} ${SOURCES_CPP}
INCLUDE_DIRS "." "utils" "bluetooth" # 必须加"bluetooth",否则#include会报错
)
配置完成后,点击编译,检查是否有报错。
我们可以发现还是报错了(无法避免)!这是为什么呢?
其实是我们漏掉了启用我们的蓝牙功能。请打开工程中的 sdkconfig 文件,找到蓝牙相关的配置选项并将其启用。
配置过程中请仔细关注各项设置,建议按照以下示例进行勾选,特别注意标红框的配置项,这些对功能实现较为关键。


现在再次点击编译,正常情况下应当不再出现报错。确认编译成功后,即可开始修改具体的代码内容。
构建完成(0 个错误,20 个警告)
D:\Software\ESP32IDF\Espressif\python_env\idf5.3_py3.11_env\Scripts\python.exe D:\Software\ESP32IDF\Espressif\frameworks\esp-idf-v5.3.1\tools\idf_size.py D:/Code/LVGL_Exp/COdy/Cody/build/lvgl_demo.map
Memory Type Usage Summary
+--------------------------------------------------------------------------------+
| Memory Type/Section | Used [bytes] | Used [%] | Remain [bytes] | Total [bytes] |
|---------------------+--------------+----------+----------------+---------------|
| Flash Code | 110672 | 1.32 | 8277904 | 8388576 |
| .text | 110672 | 1.32 | | |
| DIRAM | 76739 | 22.45 | 265021 | 341760 |
| .text | 59771 | 17.49 | | |
| .data | 13420 | 3.93 | | |
| .bss | 2392 | 0.7 | | |
| .vectors | 1027 | 0.3 | | |
| Flash Data | 46384 | 0.14 | 33508016 | 33554400 |
| .rodata | 46128 | 0.14 | | |
| .appdesc | 256 | 0.0 | | |
| RTC FAST | 280 | 3.42 | 7912 | 8192 |
| .rtc_reserved | 24 | 0.29 | | |
+--------------------------------------------------------------------------------+
2. 初始化蓝牙控制器
在使用任何蓝牙功能之前,需先初始化蓝牙控制器,由于我们使用的是官方库,无需自行实现蓝牙初始化。只需查看库文件,找到主函数入口,将该函数重命名为便于调用的名称(例如改为 init_ble()),并在头文件中进行声明,即可方便后续调用。:
将上述app_main改为
void init_ble(void){
// 代码
}
头文件添加如下声明代码:
void init_ble(void);
接下来在主函数调用我们的初始化函数:
#include <stdio.h>
#include <inttypes.h>
#include "sdkconfig.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_chip_info.h"
#include "esp_flash.h"
#include "esp_system.h"
#include "ble_spp_server_demo.h" // 插入头文件
void app_main(void)
{
printf("Hello world!\n");
init_ble(); // 调用
}
编译一下吧
构建完成(0 个错误,0 个警告)
至此,蓝牙初始化已完成。
3. 配置
现在回到库函数部分,我们可以找到以下两个函数。第一个函数uart_task主要用于单片机向手机发送数据,第二个函数gatts_profile_event_handler则是接收从蓝牙传来的信息的回调句柄。通过该函数,我们能够获取手机发送的数据。不过,当前代码仍需根据具体需求进行适当修改和定制。
重点看如下代码
uart_task void (void *)
gap_event_handler void (esp_gap_ble_cb_event_t, esp_ble_gap_cb_param_t *)
gatts_profile_event_handler void (esp_gatts_cb_event_t, esp_gatt_if_t, esp_ble_gatts_cb_param_t *)

由于本次教程主要以接收手机数据为主,为了简化教学难度,我们先来看一下gatts_profile_event_handler句柄的回调配置方式。
static void gatts_profile_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param)
{
esp_ble_gatts_cb_param_t *p_data = (esp_ble_gatts_cb_param_t *) param;
uint8_t res = 0xff;
switch (event) {
case ESP_GATTS_REG_EVT:
ESP_LOGI(GATTS_TABLE_TAG, "GATT server register, status %d, app_id %d, gatts_if %d", param->reg.status, param->reg.app_id, gatts_if);
esp_ble_gap_set_device_name(SAMPLE_DEVICE_NAME);
esp_ble_gap_config_adv_data_raw((uint8_t *)spp_adv_data, sizeof(spp_adv_data));
esp_ble_gatts_create_attr_tab(spp_gatt_db, gatts_if, SPP_IDX_NB, SPP_SVC_INST_ID);
break;
case ESP_GATTS_READ_EVT:
ESP_LOGI(GATTS_TABLE_TAG, "Characteristic read");
break;
case ESP_GATTS_WRITE_EVT: {
// ESP_LOGI(GATTS_TABLE_TAG, "Characteristic write, conn_id %d, handle %d", param->write.conn_id, param->write.handle);
res = find_char_and_desr_index(p_data->write.handle);
if (p_data->write.is_prep == false) {
if (res == SPP_IDX_SPP_COMMAND_VAL) {
uint8_t * spp_cmd_buff = NULL;
spp_cmd_buff = (uint8_t *)malloc((spp_mtu_size - 3) * sizeof(uint8_t));
if(spp_cmd_buff == NULL){
ESP_LOGE(GATTS_TABLE_TAG, "%s malloc failed", __func__);
break;
}
memset(spp_cmd_buff, 0x0, (spp_mtu_size - 3));
memcpy(spp_cmd_buff, p_data->write.value, p_data->write.len);
xQueueSend(cmd_cmd_queue, &spp_cmd_buff, 10/portTICK_PERIOD_MS);
} else if (res == SPP_IDX_SPP_DATA_NTF_CFG) {
if ((p_data->write.len == 2) && (p_data->write.value[0] == 0x01) && (p_data->write.value[1] == 0x00)) {
ESP_LOGI(GATTS_TABLE_TAG, "SPP data notification enable");
enable_data_ntf = true;
} else if ((p_data->write.len == 2) && (p_data->write.value[0] == 0x02) && (p_data->write.value[1] == 0x00)) {
ESP_LOGI(GATTS_TABLE_TAG, "SPP data indication enable");
enable_data_ntf = true;
} else if ((p_data->write.len == 2) && (p_data->write.value[0] == 0x00) && (p_data->write.value[1] == 0x00)) {
ESP_LOGI(GATTS_TABLE_TAG, "SPP data notification/indication disable");
enable_data_ntf = false;
}
} else if (res == SPP_IDX_SPP_STATUS_CFG) {
if ((p_data->write.len == 2) && (p_data->write.value[0] == 0x01) && (p_data->write.value[1] == 0x00)) {
ESP_LOGI(GATTS_TABLE_TAG, "SPP status notification enable");
} else if ((p_data->write.len == 2) && (p_data->write.value[0] == 0x00) && (p_data->write.value[1] == 0x00)) {
ESP_LOGI(GATTS_TABLE_TAG, "SPP status notification disable");
}
}
#ifdef SUPPORT_HEARTBEAT
else if (res == SPP_IDX_SPP_HEARTBEAT_CFG) {
if ((p_data->write.len == 2) && (p_data->write.value[0] == 0x01) && (p_data->write.value[1] == 0x00)) {
ESP_LOGI(GATTS_TABLE_TAG, "SPP heartbeat notification enable");
enable_heart_ntf = true;
} else if ((p_data->write.len == 2) && (p_data->write.value[0] == 0x00) && (p_data->write.value[1] == 0x00)) {
ESP_LOGI(GATTS_TABLE_TAG, "SPP heartbeat notification disable");
enable_heart_ntf = false;
}
} else if (res == SPP_IDX_SPP_HEARTBEAT_VAL) {
if ((p_data->write.len == sizeof(heartbeat_s)) && (memcmp(heartbeat_s, p_data->write.value, sizeof(heartbeat_s)) == 0)) {
heartbeat_count_num = 0;
}
}
#endif
else if (res == SPP_IDX_SPP_DATA_RECV_VAL) {
#ifdef CONFIG_EXAMPLE_ENABLE_RF_EMC_TEST_MODE
ESP_LOG_BUFFER_HEX("RX", p_data->write.value, p_data->write.len);
#else
uart_write_bytes(UART_NUM_0, (char *)(p_data->write.value), p_data->write.len);
#endif
} else {
//TODO:
}
} else if ((p_data->write.is_prep == true) && (res == SPP_IDX_SPP_DATA_RECV_VAL)) {
store_wr_buffer(p_data);
}
break;
}
case ESP_GATTS_EXEC_WRITE_EVT: {
ESP_LOGI(GATTS_TABLE_TAG, "Execute write");
if (p_data->exec_write.exec_write_flag) {
print_write_buffer();
free_write_buffer();
}
break;
}
case ESP_GATTS_RESPONSE_EVT:
break;
case ESP_GATTS_MTU_EVT:
ESP_LOGI(GATTS_TABLE_TAG, "MTU exchange, MTU %d", param->mtu.mtu);
spp_mtu_size = p_data->mtu.mtu;
break;
case ESP_GATTS_CONF_EVT:
if (param->conf.status) {
ESP_LOGI(GATTS_TABLE_TAG, "Confirm received, status %d, handle %d", param->conf.status, param->conf.handle);
}
break;
case ESP_GATTS_UNREG_EVT:
break;
case ESP_GATTS_DELETE_EVT:
break;
case ESP_GATTS_START_EVT:
ESP_LOGI(GATTS_TABLE_TAG, "Service start, status %d, service_handle %d",
param->start.status, param->start.service_handle);
break;
case ESP_GATTS_STOP_EVT:
break;
case ESP_GATTS_CONNECT_EVT:
ESP_LOGI(GATTS_TABLE_TAG, "Connected, conn_id %u, remote "ESP_BD_ADDR_STR"",
param->connect.conn_id, ESP_BD_ADDR_HEX(param->connect.remote_bda));
spp_conn_id = p_data->connect.conn_id;
spp_gatts_if = gatts_if;
is_connected = true;
memcpy(&spp_remote_bda,&p_data->connect.remote_bda,sizeof(esp_bd_addr_t));
#ifdef SUPPORT_HEARTBEAT
uint16_t cmd = 0;
xQueueSend(cmd_heartbeat_queue,&cmd,10/portTICK_PERIOD_MS);
#endif
break;
case ESP_GATTS_DISCONNECT_EVT:
ESP_LOGI(GATTS_TABLE_TAG, "Disconnected, remote "ESP_BD_ADDR_STR", reason 0x%02x",
ESP_BD_ADDR_HEX(param->disconnect.remote_bda), param->disconnect.reason);
spp_mtu_size = 23;
is_connected = false;
enable_data_ntf = false;
#ifdef SUPPORT_HEARTBEAT
enable_heart_ntf = false;
heartbeat_count_num = 0;
#endif
esp_ble_gap_start_advertising(&spp_adv_params);
break;
case ESP_GATTS_OPEN_EVT:
break;
case ESP_GATTS_CANCEL_OPEN_EVT:
break;
case ESP_GATTS_CLOSE_EVT:
break;
case ESP_GATTS_LISTEN_EVT:
break;
case ESP_GATTS_CONGEST_EVT:
break;
case ESP_GATTS_CREAT_ATTR_TAB_EVT:{
ESP_LOGI(GATTS_TABLE_TAG, "The number handle %x",param->add_attr_tab.num_handle);
if (param->add_attr_tab.status != ESP_GATT_OK) {
ESP_LOGE(GATTS_TABLE_TAG, "Create attribute table failed, error code 0x%x", param->add_attr_tab.status);
}
else if (param->add_attr_tab.num_handle != SPP_IDX_NB) {
ESP_LOGE(GATTS_TABLE_TAG, "Create attribute table abnormally, num_handle (%d) doesn't equal to HRS_IDX_NB(%d)", param->add_attr_tab.num_handle, SPP_IDX_NB);
}
else {
memcpy(spp_handle_table, param->add_attr_tab.handles, sizeof(spp_handle_table));
esp_ble_gatts_start_service(spp_handle_table[SPP_IDX_SVC]);
}
break;
}
default:
break;
}
}
以上代码可以通过AI大模型辅助理解。我们的重点是在接收到信息后,对数据进行定制化处理。因此需要找到响应处理的部分。以下代码展示了最终接收数据并进行打印处理的实现位置。
else if (res == SPP_IDX_SPP_DATA_RECV_VAL) {
#ifdef CONFIG_EXAMPLE_ENABLE_RF_EMC_TEST_MODE
// 调试模式:打印16进制数据(仅日志输出)
ESP_LOG_BUFFER_HEX("RX", p_data->write.value, p_data->write.len);
#else
// 核心输出:把蓝牙接收的数据写入UART0(串口)→ 这是最终输出!
uart_write_bytes(UART_NUM_0, (char *)(p_data->write.value), p_data->write.len);
#endif
}
这段代码的作用是:如果之前通过宏定义了 CONFIG_EXAMPLE_ENABLE_RF_EMC_TEST_MODE,则进入调试模式;否则,直接通过串口打印输出。
可以看出,我们可以在此处对接收到的信息进行判断和处理。如有相关需求,可以直接在此处修改,或编写一个函数将接收到的数据传入处理。至此,蓝牙接收配置已经完成,接下来便可通过手机进行连接。
4. 手机连接
我们可以打开手机上的任意蓝牙调试器应用,刷新后应当能够检测到当前设置的蓝牙名称。
值得一提的是,蓝牙名称本身支持自定义修改。我们只需在代码中调整对应的宏定义,即可更改蓝牙设备对外显示的名称。
#define SAMPLE_DEVICE_NAME "ESP_SPP_SERVER" //The Device Name Characteristics in GAP
接下来,在配置界面中找到蓝牙设置项,点击右侧的加号按钮,此时会显示一个设置图标。请注意,这里的通信并非采用标准蓝牙协议,而是基于特定自定义协议,因此需要根据实际情况对相关参数进行配置。完成以上设置后,我们便可回到代码部分继续后续操作。
透传服务 UUID
改成:0000ABF0-0000-1000-8000-00805F9B34FB
透传 TX 特征的 UUID(模块→手机)
改成:0000ABF2-0000-1000-8000-00805F9B34FB
透传 RX 特征的 UUID(手机→模块)
改成:0000ABF1-0000-1000-8000-00805F9B34FB
为什么呢?
你看到的这些UUID格式(比如 0000ABF0-0000-1000-8000-00805F9B34FB),本质是蓝牙标准的128位UUID格式,而代码里用的 0xABF0/0xABF1/0xABF2 是16位短UUID —— 前者是后者的「完整扩展形式」,这么改是为了让手机APP能正确识别代码里定义的蓝牙服务/特征,核心原因和蓝牙UUID的规范直接相关。
一、先搞懂蓝牙UUID的两种格式(核心原因)
蓝牙UUID分为「16位短UUID」和「128位完整UUID」,二者是一一对应的关系,手机APP通常只认128位格式,所以需要把代码里的16位短UUID扩展成128位:
1. 蓝牙官方的128位UUID扩展规则
所有自定义的16位短UUID(0xXXXX),扩展成128位的固定格式是:0000XXXX-0000-1000-8000-00805F9B34FB
(这是蓝牙SIG规定的「Base UUID」,所有非官方16位UUID都要基于这个基底扩展)
2. 对应到透传UUID
| 用途 | 代码里的16位短UUID | 扩展后的128位完整UUID(手机APP) |
|---|---|---|
| 透传服务UUID | 0xABF0 | 0000ABF0-0000-1000-8000-00805F9B34FB |
| 透传TX特征(模块→手机) | 0xABF2 | 0000ABF2-0000-1000-8000-00805F9B34FB |
| 透传RX特征(手机→模块) | 0xABF1 | 0000ABF1-0000-1000-8000-00805F9B34FB |
代码里的核心UUID定义:
// 透传服务16位UUID
static const uint16_t spp_service_uuid = 0xABF0;
// RX特征(手机→模块)16位UUID
#define ESP_GATT_UUID_SPP_DATA_RECEIVE 0xABF1
// TX特征(模块→手机)16位UUID
#define ESP_GATT_UUID_SPP_DATA_NOTIFY 0xABF2
按照蓝牙扩展规则,把 0xABF0/0xABF1/0xABF2 代入Base UUID的 XXXX 位置,就得到了APP里需要的128位UUID —— 这是唯一正确的对应方式,改其他格式都会导致手机和模块的UUID不匹配。


现在我们就能看到串口助手打印出来hello的字样了
[0;32mI (44821) GATTS_SPP_DEMO: BLUETOOTH REC: hello[0m
[0;32mI (44831) GATTS_SPP_DEMO: GetData![0m
PS
当然,在实际操作中,部分同学可能会遇到输出异常,或者完全没有输出的情况。此时,我们可以尝试使用其他函数进行调试打印。例如,可以使用以下方式:
char recv_buff[512] = {0}; // 定义足够大的缓冲区
if (p_data->write.value != NULL && p_data->write.len > 0) {
// 拷贝数据到缓冲区,确保以\0结尾(避免乱码)
memcpy(recv_buff, p_data->write.value, p_data->write.len < 511 ? p_data->write.len : 511);
recv_buff[p_data->write.len < 511 ? p_data->write.len : 511] = '\0'; // 手动添加结束符
// 直接打印字符串(无格式化陷阱)
ESP_EARLY_LOGI(GATTS_TABLE_TAG, "BLUETOOTH REC: %s", recv_buff);
} else {
ESP_EARLY_LOGI(GATTS_TABLE_TAG, "BLUETOOTH REC: Empty data (len=%d)", p_data->write.len);
}
ESP_EARLY_LOGI(GATTS_TABLE_TAG, "GetData!");
ESP_EARLY_LOGI 是乐鑫(Espressif)物联网开发框架 ESP-IDF 中的一个早期日志输出函数。
它的主要特点和用途如下:
-
“早期”:它可以在系统初始化的非常早的阶段(例如,在操作系统调度器尚未启动、堆分配器还不可用的时候)就进行日志输出。而普通的
ESP_LOGI函数在这些早期阶段可能无法正常工作。 -
适用场景:主要用于调试系统底层启动过程、驱动程序初始化、或是在内存分配等核心服务完全就绪之前发生的错误。
-
限制:由于是在非常早的阶段运行,它通常有更多限制,例如可能无法支持所有格式或功能。
简单来说,你可以把它理解为一种专用于系统启动初期进行调试的日志打印工具,功能与常见的 ESP_LOGI 类似,但能在更底层的环境中运行。
结语
在我们进行蓝牙开发的过程中,与 Arduino 相比,ESP-IDF 确实具备一定的挑战性。例如,我们无法直接使用一些简单的库函数来获取时间,而需要更面向底层的方式来实现。不过,这也为我们提供了更大的发挥空间——通过深入理解底层代码的运行逻辑,我们能够获取更多信息,从而更高效地进行验证。
本次实现蓝牙驱动的过程也花费了不少时间。首先,官方的 IDE 在使用体验上并不算特别友好,很多人可能会选择转向 VScode。但如果我们同时需要兼容 Arduino 和 ESP-IDF 开发,直接使用 VScode 可能并不是一个明智的选择,因为两者基于 CMake 的构建环境容易产生冲突。因此,我最终选择单独安装官方 IDE 进行配置。尽管过程中遇到了一些问题,但一步步按照指引操作、不断尝试,最终也都得以解决。
当前的蓝牙教程还只是一个初步的版本,后续会持续更新补充。感谢大家的支持!
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)