基于 ESP32 的 BLE 长连接下,如何用连接事件间隙调度 ADC 采样而不丢包

1. 问题背景与挑战

在嵌入式物联网设备中,BLE 长连接(如心率监测、环境传感)常需同时进行高频 ADC 采样。ESP32 的 BLE 协议栈运行在 CPU 核心上,每个连接事件(Connection Event)会占用 CPU 处理射频数据,若此时 ADC 中断触发,可能被延迟或丢失,导致采样数据不连续。此外,BLE 连接间隔(通常 7.5ms~4s)决定了射频活动的周期,若 ADC 采样频率与连接间隔冲突,丢包率会显著上升。

2. 原理剖析:BLE 连接事件与 CPU 调度

2.1 BLE 连接事件时序

BLE 连接中,主从设备在每个连接间隔的起始点进行数据交换,称为连接事件。事件持续时间取决于数据包长度和 PHY 速率,通常为 1~3ms。在事件期间,ESP32 的蓝牙控制器会占用 CPU(或专用协处理器,但协议栈仍会占用资源)。

2.2 冲突根源

  • 中断抢占:ADC 采样通常使用定时器触发中断,若中断优先级低于蓝牙协议栈,则可能被延迟。
  • DMA 缓冲溢出:若采样数据未及时搬运,DMA 缓冲会溢出,导致数据丢失。
  • 任务调度延迟:FreeRTOS 中,若 ADC 处理任务优先级低于蓝牙任务,则处理不及时。

2.3 解决思路:时分复用

利用连接事件之间的空闲间隙(即连接间隔减去事件持续时间),将 ADC 采样调度到这些间隙中。具体做法:

  • 配置 ADC 采样频率为连接间隔的整数倍,使采样点落在事件间隙内。
  • 使用 DMA 环形缓冲,即使 CPU 忙碌也能暂存数据。
  • 通过 FreeRTOS 任务同步,在事件间隙处理数据。

3. 硬件与软件准备

  • 硬件:ESP32 开发板(如 ESP32-WROOM-32)
  • 软件:ESP-IDF v4.4+,或 Arduino-ESP32 核心(本文以 ESP-IDF 为例)
  • 外设:ADC1 通道(如 GPIO34)

4. 配置步骤

4.1 初始化 ADC 与 DMA

使用 ADC 连续模式(Continuous Mode)配合 DMA,可自动将采样结果存入缓冲区,无需 CPU 干预。

#include "esp_adc/adc_continuous.h"

#define ADC_CHANNEL ADC_CHANNEL_6  // GPIO34
#define ADC_ATTEN ADC_ATTEN_DB_12
#define ADC_SAMPLE_RATE 1000       // 1kHz 采样率

adc_continuous_handle_t adc_handle;

void adc_init(void) {
    adc_continuous_handle_cfg_t handle_cfg = {
        .max_store_buf_size = 1024,
        .conv_frame_size = 256,
    };
    adc_continuous_new_handle(&handle_cfg, &adc_handle);

    adc_digi_pattern_config_t pattern = {
        .atten = ADC_ATTEN,
        .channel = ADC_CHANNEL,
        .unit = ADC_UNIT_1,
        .bit_width = ADC_BITWIDTH_12,
    };
    adc_digi_config_t digi_cfg = {
        .pattern_num = 1,
        .adc_pattern = &pattern,
        .sample_freq_hz = ADC_SAMPLE_RATE,
        .conv_mode = ADC_CONV_SINGLE_UNIT_1,
        .format = ADC_DIGI_OUTPUT_FORMAT_TYPE1,
    };
    adc_continuous_config(adc_handle, &digi_cfg);
    adc_continuous_start(adc_handle);
}

4.2 配置 BLE 连接参数

在 BLE 连接中,设置连接间隔为 30ms(即 30 个 1.25ms 单位),事件持续约 2ms,则间隙为 28ms,足够进行多次采样。

// 在 BLE 连接回调中设置参数
uint16_t interval = 30; // 单位 1.25ms,实际 37.5ms
uint16_t latency = 0;
uint16_t timeout = 400; // 4s
esp_ble_gap_update_conn_params(&conn_params);

4.3 调度策略:定时器与任务同步

使用一个定时器,其周期等于连接间隔,在定时器回调中触发 ADC 数据读取(通过 DMA 缓冲),并处理数据。

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/timers.h"

TimerHandle_t sample_timer;
QueueHandle_t data_queue;

void timer_callback(TimerHandle_t xTimer) {
    // 读取 DMA 缓冲中的数据
    uint8_t result[256];
    uint32_t len = 0;
    adc_continuous_read(adc_handle, result, sizeof(result), &len, 0);
    // 发送到处理任务
    xQueueSend(data_queue, result, 0);
}

void adc_task(void *arg) {
    uint8_t data[256];
    while (1) {
        if (xQueueReceive(data_queue, data, portMAX_DELAY)) {
            // 处理采样数据(如滤波、存储)
        }
    }
}

void app_main() {
    adc_init();
    data_queue = xQueueCreate(10, 256);
    sample_timer = xTimerCreate("sample", pdMS_TO_TICKS(30), pdTRUE, NULL, timer_callback);
    xTimerStart(sample_timer, 0);
    xTaskCreate(adc_task, "adc_task", 4096, NULL, 10, NULL);
    // 初始化 BLE 并连接
}

4.4 优化:动态调整采样窗口

若连接间隔变化,可动态调整定时器周期。通过 BLE 连接事件回调获取当前连接参数,并更新定时器。

void ble_conn_update_callback(esp_ble_gap_cb_param_t *param) {
    if (param->update_conn_params.status == ESP_GAP_CONN_UPDATE_SUCCESS) {
        uint16_t new_interval = param->update_conn_params.conn_int;
        xTimerChangePeriod(sample_timer, pdMS_TO_TICKS(new_interval * 1.25), 0);
    }
}

5. 完整代码示例

以下为整合后的最小示例(省略 BLE 初始化细节,重点展示调度逻辑):

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/timers.h"
#include "freertos/queue.h"
#include "esp_adc/adc_continuous.h"
#include "esp_ble_gap.h"

// ADC 配置
#define ADC_CHANNEL ADC_CHANNEL_6
#define ADC_ATTEN ADC_ATTEN_DB_12
#define ADC_SAMPLE_RATE 1000

adc_continuous_handle_t adc_handle;
TimerHandle_t sample_timer;
QueueHandle_t data_queue;

// ADC 初始化(同上)
void adc_init(void) { /* ... */ }

// 定时器回调:读取 ADC 数据
void timer_callback(TimerHandle_t xTimer) {
    uint8_t result[256];
    uint32_t len = 0;
    if (adc_continuous_read(adc_handle, result, sizeof(result), &len, 0) == ESP_OK) {
        xQueueSend(data_queue, result, 0);
    }
}

// 数据处理任务
void adc_task(void *arg) {
    uint8_t data[256];
    while (1) {
        if (xQueueReceive(data_queue, data, portMAX_DELAY)) {
            // 解析数据,例如打印平均值
            uint32_t sum = 0;
            for (int i = 0; i < 256; i += 2) {
                sum += (data[i] & 0xFF) | (data[i+1] << 8);
            }
            printf("ADC avg: %lu\n", sum / 128);
        }
    }
}

// BLE 连接参数更新回调
void ble_gap_cb(esp_ble_gap_cb_event_t event, esp_ble_gap_cb_param_t *param) {
    if (event == ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT) {
        if (param->update_conn_params.status == ESP_GAP_CONN_UPDATE_SUCCESS) {
            uint16_t interval = param->update_conn_params.conn_int;
            xTimerChangePeriod(sample_timer, pdMS_TO_TICKS(interval * 1.25), 0);
        }
    }
}

void app_main(void) {
    adc_init();
    data_queue = xQueueCreate(10, 256);
    sample_timer = xTimerCreate("sample", pdMS_TO_TICKS(30), pdTRUE, NULL, timer_callback);
    xTimerStart(sample_timer, 0);
    xTaskCreate(adc_task, "adc_task", 4096, NULL, 10, NULL);

    // 初始化 BLE(省略)
    // 设置连接参数:间隔 30*1.25=37.5ms
    esp_ble_gap_set_conn_params(&conn_params);
}

6. 注意事项

  • 采样率与连接间隔匹配:采样率必须小于连接间隔的倒数,否则无法保证每个采样点都在间隙内。例如,连接间隔 30ms,采样率最高约 33Hz,但实际需留余量,建议采样率 ≤ 20Hz。
  • DMA 缓冲大小:根据采样率和连接间隔计算缓冲大小,避免溢出。若采样率 1kHz,连接间隔 30ms,则每次事件间隙需存储 30 个样本,缓冲至少 60 字节(12位数据占2字节)。
  • 任务优先级:ADC 处理任务优先级应高于 BLE 协议栈任务(通常为 20),但低于定时器服务(优先级 1)。建议设为 10~15。
  • 功耗考虑:频繁唤醒 CPU 会增加功耗,可结合 BLE 的 latency 参数减少事件次数,但需权衡采样实时性。
  • 调试技巧:使用逻辑分析仪抓取 GPIO 翻转信号,验证采样时序是否落在连接事件间隙。

7. 总结

通过理解 BLE 连接事件与 CPU 调度的关系,利用连接间隔的间隙进行 ADC 采样,配合 DMA 缓冲和 FreeRTOS 任务同步,可以显著降低丢包率。本文提供的策略不仅适用于 ESP32,也可推广到其他支持 BLE 和 DMA 的 MCU。实际项目中,需根据具体连接参数和采样需求动态调整,确保系统稳定可靠。