机械臂蓝牙代码优化:把 ESP32 写事件做轻后,15ms 连续发送如何实现

月亮慢半拍
月亮慢半拍
发布时间:2026-08-24| 80人浏览过
原创

前言

自制机械臂接网页控制时,蓝牙链路一旦处理过重,就很容易在高频写入下出现阻塞甚至 BTC stack overflow。这篇文章围绕 ESP32 GATT 写事件的精简思路展开,结合连接参数和网页端写入方式调整,说明为什么把逻辑做轻之后,发送间隔能从低于 200ms 易报错提升到稳定 15ms。

自制机械臂要想顺畅接收网页控制指令,蓝牙链路不能只“能用”,还得足够轻、足够快。这次改动的核心,不是继续给 ESP32 叠复杂机制,而是回到实际需求:网页发指令,单片机解析后转成电机帧并发送。文章会按问题现象、代码删改点和性能提升路径展开,帮助你判断这些优化是否也适合自己的 BLE 控制项目。

优化后达到了什么效果

先看结果。修改之后,网页端现在可以每隔 15ms 向单片机发送一条蓝牙消息,而不会再出现阻塞;优化之前,只要发送间隔低于 200ms,就容易报错。

换算下来,当前链路大约能做到每秒 66 条消息。对于后续准备引入贝塞尔曲线来优化机械臂电机运动姿态的场景,这个吞吐量已经基本够用。

核心改动:把写事件处理缩到最小闭环

这次优化最关键的地方,是重新梳理 ESP_GATTS_WRITE_EVT 的处理逻辑。目标很明确:收到网页传来的字节流后,只做当前业务真正需要的事,即解析出帧 ID 与帧数据,然后交给 TWAI 发送。

蓝牙写事件精简后的数据处理流程图,展示网页指令进入 ESP_GATTS_WRITE_EVT 后,如何解析 frameId、提取 frameData、调用 emitMsg 并按需回复客户端
写事件精简后的主处理路径把写事件处理缩短为“解析、转发、按需响应”三步后,BLE 服务端的停留时间显著下降。

修改后的主逻辑如下:

case ESP_GATTS_WRITE_EVT: {
        //ESP_LOGI(GATTS_TAG, "蓝牙写入事件 GATT_WRITE_EVT, conn_id %d, trans_id %" PRIu32 ", handle %d", param->write.conn_id, param->write.trans_id, param->write.handle);
        esp_gatt_status_t status = ESP_GATT_OK;

        //ESP_LOGW(GATTS_TAG, "GATT_WRITE_EVT, value len %d, value :", param->write.len);
        //esp_log_buffer_hex(GATTS_TAG, param->write.value, param->write.len);

        // 将接收到的蓝牙数据做处理,提取出想要格式的帧id和帧数据
        unsigned int frameId = 0;
        int frameData[8];
        for(int i = 0; i < param->write.len; i++) {
          // printf("遍历蓝牙%d值: %d", i, param->write.value[i]);
          if(i < 4) {
            frameId = frameId * 16 * 16 + param->write.value[i];
            // ESP_LOGI(GATTS_TAG, "----蓝牙 16进制 转换帧id:  %u", frameId);
          }else {
            frameData[i - 4] = param->write.value[i];
          }
        }
       // printf("frameId%u值: %d, --- %d  n", frameId, frameData[0], frameData[7]);
        
        // ESP_LOGI(EXAMPLE_TAG, "-----发送can数据指令------");
        emitMsg(frameId, frameData);
        // ESP_LOGI(EXAMPLE_TAG, "-----发送can数据指令结束-----");

        // 客户端的请求是需要需要回复时候
        if(param->write.need_rsp) {
          esp_ble_gatts_send_response(gatts_if, param->write.conn_id, param->write.trans_id, status, NULL);
        }
        break;

这段代码做的事情可以拆成三步:

1. 从写入数据中拆出 frameId 和 frameData

前 4 个字节用于组装帧 ID,后续字节写入 frameData[8]。也就是说,蓝牙层在这里不承担过多协议职责,只负责把网页侧传来的数据整理成后续总线发送所需的格式。

2. 直接调用发送函数

整理完数据后,马上执行 emitMsg(frameId, frameData);,把消息交给 TWAI 发送。原作者提到,这一层未来也许可以继续用观察者模式再做解耦,但当前目标是先把路径缩短,减少写事件中的停留时间。

ESP32 蓝牙阻塞原因与精简项对比图,展示原示例中 notify、indicate、长消息处理等分支如何拉长写事件处理时间
阻塞来源与可删除分支对比示例代码里的通用分支越多,写事件占用时间越长;对单一控制链路而言。

3. 仅在需要时回复客户端

如果 param->write.need_rsp 为真,再调用 esp_ble_gatts_send_response。这一点很重要,因为写事件里任何额外处理,都会直接占用 BLE 服务端的执行时间。

机械臂蓝牙性能优化结果图,展示连接间隔调整和 writeValueWithoutResponse 改动后,发送间隔从低于200ms易报错提升到15ms稳定发送
连接参数与发送方式带来的提升连接间隔更短、写入无需等待响应后,网页到 ESP32 的连续控制指令更容易稳定跑到 15ms。

原来为什么会报错:两个典型问题的定位思路

优化前,蓝牙部分主要有两个明显问题:

  1. 单片机会频繁报错:BTC stack overflow。
  2. 蓝牙发送速度稍快,就会出现阻塞报错。

什么是 BTC

文中引用了 ESP32 蓝牙架构 的说明:

BTC 层主要负责向应⽤层提供接⼝⽀持、处理基于 GATT 的规范、处理杂项等,并向应⽤层提供以“esp”为前缀的接⼝。所有的 API 都在 ESP_API 层,开发者应当使⽤“esp”为前缀的蓝牙 API(特殊的除外)。

简单理解,BTC 就是蓝牙控制路径里承接应用调用的重要一层。开发者平时配置广播、连接、服务和特征,背后都会经过这一层处理。因此,写事件逻辑太重、调用链太长,就更容易把这里拖慢,甚至引发栈溢出。

问题一:BTC stack overflow

这个问题大致有两类处理方式。

第一类是增大相关内存分配,这能缓解,但通常只是把问题往后拖,并没有改变代码路径过长、处理过重的根因。

第二类就是回头检查自己的蓝牙业务逻辑,看看是不是在官方示例基础上保留了并不需要的处理分支。本文最终采用的是这一条路线,优化后已经不再出现蓝牙栈溢出。

问题二:高频发送时阻塞

从报错现象看,核心原因是上一条蓝牙请求还没有处理完,下一条请求就已经到达,结果后续写入被阻塞。

这个问题的定位方法也很直接:检查蓝牙服务端代码,找出哪些逻辑占用了写事件的处理时间。作者对照示例项目 README 后发现,问题并不在“蓝牙本身不够快”,而在于原代码里保留了不少当前项目根本用不上的通用处理。

删掉哪些示例逻辑,为什么能提速

原始示例的 ESP_GATTS_WRITE_EVT 处理相对完整,适合教学或通用 GATT Server 场景,但放在“网页控制机械臂”这个单一链路里,就显得偏重了。

原代码中包含了如下这类逻辑:

case ESP_GATTS_WRITE_EVT: {
    ESP_LOGI(GATTS_TAG, "GATT_WRITE_EVT, conn_id %d, trans_id %" PRIu32 ", handle %d", param->write.conn_id, param->write.trans_id, param->write.handle);
    if (!param->write.is_prep){
        ESP_LOGI(GATTS_TAG, "GATT_WRITE_EVT, value len %d, value :", param->write.len);
        esp_log_buffer_hex(GATTS_TAG, param->write.value, param->write.len);
        if (gl_profile_tab[PROFILE_A_APP_ID].descr_handle == param->write.handle && param->write.len == 2){
            uint16_t descr_value = param->write.value[1]<<8 | param->write.value[0];
            if (descr_value == 0x0001){
                if (a_property & ESP_GATT_CHAR_PROP_BIT_NOTIFY){
                    ESP_LOGI(GATTS_TAG, "notify enable");
                    uint8_t notify_data[15];
                    for (int i = 0; i < sizeof(notify_data); ++i)
                    {
                        notify_data[i] = i%0xff;
                    }
                    //the size of notify_data[] need less than MTU size
                    esp_ble_gatts_send_indicate(gatts_if, param->write.conn_id, gl_profile_tab[PROFILE_A_APP_ID].char_handle,
                                            sizeof(notify_data), notify_data, false);
                }
            }else if (descr_value == 0x0002){
                if (a_property & ESP_GATT_CHAR_PROP_BIT_INDICATE){
                    ESP_LOGI(GATTS_TAG, "indicate enable");
                    uint8_t indicate_data[15];
                    for (int i = 0; i < sizeof(indicate_data); ++i)
                    {
                        indicate_data[i] = i%0xff;
                    }
                    //the size of indicate_data[] need less than MTU size
                    esp_ble_gatts_send_indicate(gatts_if, param->write.conn_id, gl_profile_tab[PROFILE_A_APP_ID].char_handle,
                                            sizeof(indicate_data), indicate_data, true);
                }
            }
            else if (descr_value == 0x0000){
                ESP_LOGI(GATTS_TAG, "notify/indicate disable ");
            }else{
                ESP_LOGE(GATTS_TAG, "unknown descr value");
                esp_log_buffer_hex(GATTS_TAG, param->write.value, param->write.len);
            }

        }
    }
    example_write_event_env(gatts_if, &a_prepare_write_env, param);
    break;
}

这部分多做了什么

可以看到,示例代码里处理了 notify、indicate、描述符写入状态切换,以及 example_write_event_env(gatts_if, &a_prepare_write_env, param); 这种长消息准备写入流程。

这些逻辑本身没有问题,但前提是你的项目确实要用到它们。如果当前蓝牙职责只是“网页发一段控制数据,ESP32 收到后立刻解析并转发”,那这部分处理就会变成额外负担。

为什么删除后更稳定

原因很简单:写事件回调是高频入口,里面做的每一步都在累计延迟。把不需要的通知、指示、长消息处理和日志输出删掉后,服务端处理一条写请求的时间明显缩短,后续请求自然更不容易排队阻塞。

作者的结论也很明确:example_write_event_env(gatts_if, &a_prepare_write_env, param); 这类用于长消息的处理,如果业务根本不用长消息,直接删除即可。

进一步提升吞吐:连接周期和写入方式一起调

在精简服务端逻辑之外,文中还做了两项直接影响吞吐的调整。

1. 降低蓝牙连接间隔

连接参数改动如下:

  // 从机连接的最小连接间隔 0x0006 * 1.25ms = 7.5ms    slave connection min interval, Time = min_interval * 1.25 msec
    .min_interval = 0x0006, 
    // 从机连接的最大连接间隔 0x0010 * 1.25ms = 16 * 1.25 = 20ms   slave connection max interval, Time = max_interval * 1.25 msec
    .max_interval = 0x000A, 

这里的含义是,把 BLE 连接事件之间的等待时间压低,让主从设备有更高频率交换数据。文中原注释里保留了时间换算方式:连接间隔单位是 1.25ms,因此 0x0006 对应 7.5ms。

对于这类需要连续控制电机姿态的场景,较短的连接间隔通常更有利于提高指令发送密度。不过连接参数是否最终生效,也仍受主设备和系统蓝牙栈协商结果影响。

2. 改用 writeValueWithoutResponse

网页端原先如果直接调用 characteristic.writeValue,通常要等待蓝牙响应返回;而在高频写入场景里,这个等待会显著拉低吞吐。

改成 characteristic.writeValueWithoutResponse 后,发送端不再等待每条消息的应答,更适合这种单向、连续、低负载的控制指令写入。

对应代码如下:

function rotateMotor({motorId, limit_spd, loc_ref}) {
  // motorId, limit_spd;
  // let a =  loc_ref * 0.017;
  // debugger;
  let rad = loc_ref * Math.PI / 180
  // let cmdArr = Loc_Director({motorId, limit_spd, loc_ref: loc_ref * 0.017});
  let cmdArr = Loc_Director({motorId, limit_spd, loc_ref: rad});
  console.log('---cmdArr: ', cmdArr);
  for(let i = 0; i < cmdArr.length; i++) {
    setTimeout(() => {
      try {  
        // BleCharacteristic.writeValue(cmdArr[i]);
        BleCharacteristic.writeValueWithoutResponse(cmdArr[i]);
        console.log("---指令发送成功---", cmdArr[i]);
        recordCmd.value.push({type: 'send', data: cmdArr[i], status: 'success'});
      } catch (error) {
        console.log("---指令发送失败---", cmdArr[i]);
        recordCmd.value.push({type: 'send', data: cmdArr[i], status: 'fail'});
      }
    }, 15);
  }
}

这段实现还有两个值得注意的点:

  1. 发送前先把角度 loc_ref 转成弧度 rad,再传给 Loc_Director 生成指令数组。
  2. 通过 setTimeout(..., 15) 控制发送节奏,与前面实测的 15ms 稳定间隔相对应。

文中没有给出更完整的基准测试,因此“具体能提升多少性能”仍未量化到更细的指标;但从现象上看,服务端简化和无响应写入叠加后,已经足以解决此前的阻塞问题。

参考资料与延伸阅读

相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。