脑电采集这件事,很多人卡在同一个地方:电极、放大、ADC 都调通了,数据也出来了,但怎么把它从"贴在头上那台设备"稳定地送到"你能盯着看的屏幕或网页"上,反而成了最磨人的一环。我这次做的原型链路,核心就是用BW16做无线桥接、用ESP32-CYD(带屏的那块廉价开发板)做接收和显示,把一段EEG信号从采集端一路送到本地屏幕,再转发到网页上可视化。整条链路涉及BLE、UART两种传输方式,中间还踩了不少坑。这篇就把我实际搭的过程、每个选型背后的理由、以及那些文档里不会写的细节,完整摊开讲一遍。
如果你手上正好有脑电模块(比如带 UART 输出的采集板)、一块 BW16、一块 ESP32-CYD,想快速搭一条能看波形的无线链路,这篇基本可以照着抄。如果你只是想理解"无线 EEG 原型链路"到底该怎么分层设计,也能从里面的取舍逻辑里拿到东西。
1. 先把链路的骨架定下来:为什么是"双 MCU + 双通道"
1.1 单芯片方案为什么被我否掉了
一开始我想的是最省事的做法:脑电模块直接接 ESP32-CYD,一块板子搞定采集接收、屏幕显示、网页服务。听起来很美,但实际一评估就发现两个硬伤。
第一是引脚和算力冲突。ESP32-CYD 这块板子的屏幕走的是 SPI,占了一批 GPIO,加上触摸、背光、SD 卡槽,能腾出来的干净 UART 引脚其实很紧张。脑电模块如果是 UART 输出,波特率通常不低(我用的模块跑 115200 甚至更高),一旦和屏幕刷新、WiFi 协议栈抢资源,丢包和波形卡顿几乎是必然的。
第二是物理位置问题。脑电采集端要贴着人,ESP32-CYD 要放在你能看到的地方,这两者之间如果拉一根 UART 线,那"无线"两个字就白写了。所以链路里必须有一个环节负责"把有线变成无线"。
这就引出了双 MCU 的分工:BW16 贴在采集端附近,负责把脑电模块的 UART 数据通过 BLE 发出去;ESP32-CYD 放在显示端,负责收 BLE、驱动屏幕、再起一个网页服务。两块板子各干各的,互不拖累。
1.2 BW16 和 ESP32-CYD 各自的角色定位
BW16 这块板子用的是 RTL8720DN 方案,双频 WiFi + BLE 5.0,Arduino 环境下有现成库。我选它做发送端,主要看中三点:体积小、BLE 稳定、UART 收发例程成熟。它不需要屏幕、不需要跑网页,只做一件事——把串口进来的字节流打包通过 BLE 特征值发出去,这种"专职"反而让它很稳。
ESP32-CYD 则是典型的"什么都有":2.8 寸或 3.5 寸屏、ESP32 主控、WiFi、蓝牙。它做接收端和显示端非常合适,因为屏幕刷新和网页服务都是它擅长的。这里要注意,ESP32-CYD 的蓝牙和 WiFi 共存时会有资源竞争,所以我在架构上做了一个关键决定:BLE 只负责接收,网页走 WiFi,两者分时或分任务处理,避免在同一个任务里既收蓝牙又推网页。
1.3 整条链路的数据流分层
把链路拆开看,其实是四层:
| 层级 | 承担者 | 传输方式 | 关键职责 |
|---|---|---|---|
| 采集层 | 脑电模块 | 有线(电极/内部) | 输出原始或预处理后的 EEG 数据 |
| 桥接层 | BW16 | UART 收 + BLE 发 | 协议转换,有线转无线 |
| 接收层 | ESP32-CYD | BLE 收 | 解析数据包,缓存 |
| 呈现层 | ESP32-CYD | SPI 屏 + WiFi 网页 | 本地波形 + 远程可视化 |
这个分层的好处是,任何一层出问题都能单独定位。比如波形不动,你先看 BW16 的串口灯有没有闪,再看 BLE 有没有连上,再看 CYD 的缓存有没有进数据,最后才怀疑屏幕或网页。后面排错章节我会按这个顺序讲。
提示:分层不是为了好看,是为了排错时能一刀切。原型阶段最怕的就是"全都连在一起,坏了不知道哪坏"。
2. 发送端 BW16:UART 收字节、BLE 打包发出去
2.1 脑电模块的 UART 参数怎么定
脑电模块输出一般是固定帧格式的字节流,常见的是每帧包含帧头、通道数据、校验。你要做的第一件事不是写代码,而是把模块的串口参数和帧格式搞清楚。我踩过的坑是:模块默认波特率是 115200,但我一开始按 9600 接,结果收到的全是乱码,还以为是模块坏了。
确定参数的方法很土但很有效:先用 USB 转 UART 模块(FT231X、FT232R 这类都行,装好对应驱动)把脑电模块接到电脑上,用串口助手看原始字节。重点确认四件事:
- 波特率(常见 115200、230400、460800)
- 数据位/停止位/校验位(多数是 8N1)
- 帧头字节(比如 0xAA 0x55 之类)
- 帧长是否固定
这一步花十分钟,能省后面几小时的瞎猜。确认之后,BW16 的Serial1就按同样参数初始化。
// BW16 接收脑电模块的串口初始化 void setup() { Serial.begin(115200); // 调试口 Serial1.begin(115200); // 接脑电模块,参数必须和模块一致 // BLE 初始化略 }2.2 BLE 特征值的 MTU 与分包策略
BLE 不是你想发多少就发多少的。默认 ATT MTU 是 23 字节,去掉 3 字节头,实际一次只能带 20 字节。脑电数据帧往往比这长,所以必须处理分包。
我的做法是:在 BW16 侧把 UART 收到的字节流按固定长度切片,每片不超过协商后的 MTU 减 3。如果 ESP32-CYD 侧请求了更大的 MTU(比如 247),那单片能带 244 字节,基本一帧脑电数据一次就发完了。实测下来,把 MTU 协商到 247 之后,丢包率明显下降,因为分包少了,每包之间的间隔抖动也小了。
// 简化的分包发送逻辑 #define BLE_CHUNK 240 void forwardEeg() { static uint8_t buf[BLE_CHUNK]; int n = 0; while (Serial1.available() && n < BLE_CHUNK) { buf[n++] = Serial1.read(); } if (n > 0) { bleChar.writeValue(buf, n); // 通过 BLE 特征值发出 } }这里有个细节:不要每收到一个字节就发一次 BLE,那样通知频率太高,协议栈扛不住。攒够一片或等到一个短超时再发,效率高得多。
2.3 发送端的稳定性经验
BW16 做发送端,我总结了三条实测有效的经验。
第一,串口读取要用非阻塞轮询,别用Serial1.readString()这种会阻塞的调用,否则 BLE 事件回调会被拖住,连接容易掉。
第二,给 BLE 通知加一个轻量缓冲。如果writeValue返回失败(缓冲区满),不要丢数据,先存到环形缓冲里,下一轮再发。脑电数据丢一帧可能就是一个尖峰没了,对分析影响不小。
第三,发送端不要做复杂计算。我一开始想在 BW16 上做简单的去噪或滤波,结果发现它一边跑 BLE 一边算,时序就乱了。后来把所有处理都挪到 ESP32-CYD 侧,BW16 只做"搬运工",稳定性立刻上来了。
3. 接收端 ESP32-CYD:BLE 收数据、屏幕画波形
3.1 BLE 客户端连接与 MTU 协商
ESP32-CYD 作为 BLE 客户端,要主动扫描、连接 BW16、发现服务、订阅通知。这一套流程 Arduino 的 BLE 库都有例程,但有两个地方容易翻车。
一是服务 UUID 和特征 UUID 必须和发送端完全一致。我建议自己定义一组固定的 128 位 UUID,别用随机生成的,否则每次改代码都要两边同步,很容易对不上。二是连接后立刻请求 MTU 协商,ESP32 侧调用BLEDevice::setMTU(247)之类的接口,协商成功后再订阅通知,这样第一批数据就能用大包。
// 连接后请求更大 MTU client->setMTU(247); // 发现特征后订阅通知 notifyChar->registerForNotify(onEegData);3.2 数据缓存与波形刷新节奏
屏幕刷新和 BLE 接收如果放在同一个循环里硬跑,波形会一顿一顿的。我的处理是双缓冲 + 定时刷新:BLE 回调只负责把数据写进一个环形缓冲区,主循环每隔固定时间(比如 30ms)从缓冲区取一批点,更新屏幕。
这样做的理由是:BLE 到达是突发的,屏幕刷新是周期性的,两者解耦之后,即使某一瞬间 BLE 来了很多数据,也只是缓冲区水位上升,不会直接卡住屏幕。缓冲区大小我一般给到 4KB 以上,够扛住几百毫秒的突发。
// 环形缓冲写入(BLE 回调里调用) void onEegData(BLERemoteCharacteristic* c, uint8_t* data, size_t len, bool notify) { for (size_t i = 0; i < len; i++) { ringBuf[writeIdx++ % RING_SIZE] = data[i]; } }3.3 屏幕绘制的性能取舍
ESP32-CYD 的屏幕走 SPI,刷全屏很慢。画波形千万别每帧清屏重画,那样帧率上不去。正确做法是只重绘变化区域,或者用"滚动式"画法:新数据从右往左推,只画最右边一列新点,旧内容整体左移。
我实测下来,滚动式画法在 2.8 寸屏上能跑到很流畅的观感,而全屏重绘会明显拖慢。另外,颜色和线宽也要克制,一条细线比粗线快很多,单色比多色快。原型阶段先保证"能看清波形趋势",美化留到后面。
注意:屏幕刷新任务和 BLE 接收任务最好分到不同核心(ESP32 是双核),用 FreeRTOS 的
xTaskCreatePinnedToCore把显示钉在一个核、BLE 处理钉在另一个核,卡顿会明显改善。
4. 从屏幕到网页:ESP32-CYD 上的本地 Web 可视化
4.1 为什么还要一个网页
屏幕能看波形,但有几个局限:屏幕小、不能远程看、不方便保存。加一个网页服务之后,同一块 ESP32-CYD 既能在本地显示,又能让同一网络下的电脑或手机打开网页看实时波形,甚至可以把数据导出。对原型验证来说,这个"多一个观察窗口"的价值很大。
实现上,ESP32-CYD 起一个轻量 HTTP 服务,提供一个页面,页面里用 WebSocket 或定时轮询从 ESP32 拉数据。我选的是WebSocket,因为它是长连接,推送实时数据比轮询省资源、延迟也低。
4.2 数据从 BLE 缓冲区到网页的通道
关键点是:网页拿到的数据,和屏幕用的是同一份缓冲区。不要让网页单独去读 BLE,那样会打乱节奏。我的结构是:
- BLE 回调 → 环形缓冲区
- 显示任务 → 从缓冲区读 → 画屏
- WebSocket 任务 → 从缓冲区读(或读一份副本)→ 推给网页
这里要注意读写指针的同步,多任务同时读一个环形缓冲,得加个互斥锁或者用无锁的单生产者多消费者结构。我图省事,直接给缓冲区操作加了xSemaphore,实测开销可以接受。
// WebSocket 推送(简化) void pushToWeb() { if (xSemaphoreTake(bufMutex, portMAX_DELAY)) { // 从缓冲区取一批数据 ws.textAll(payload); xSemaphoreGive(bufMutex); } }4.3 网页端的轻量绘制
网页端不用搞复杂,一个 canvas 加一段 JS 就够。收到数据后把点推进数组,超出宽度的点丢掉,然后重绘。为了不卡,用 requestAnimationFrame 控制重绘节奏,别每收到一条消息就重画一次。
// 网页端简化绘制 function draw() { ctx.clearRect(0, 0, w, h); ctx.beginPath(); for (let i = 0; i < points.length; i++) { const x = i * (w / points.length); const y = h / 2 - points[i] * scale; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.stroke(); requestAnimationFrame(draw); }这套下来,网页端延迟可以做到比较低,看实时波形完全够用。
5. 踩坑实录:那些让我熬夜的链路问题
5.1 波形乱跳:先怀疑数据对齐,再怀疑噪声
有段时间屏幕上波形疯狂乱跳,我第一反应是"脑电噪声大",差点去研究去噪算法。后来冷静下来按分层排查:先看 BW16 串口原始字节,发现帧头位置不对——原来是 UART 偶尔丢了一个字节,导致后面所有帧都错位。
这类问题的根因是没有做帧同步。解决办法是在接收端加一个状态机:只有检测到合法帧头才开始收一帧,收满固定长度后校验,校验不过就丢弃并重新找帧头。加了帧同步之后,波形立刻正常。
经验:波形异常时,先确认"数据是不是对齐的",再去谈"信号是不是干净的"。顺序反了会白折腾很久。
5.2 BLE 频繁断连:MTU、通知频率和电源三件事
BLE 断连我遇到三种原因。第一种是通知频率太高,发送端每个字节都 notify,协议栈直接崩。改成攒包发送后解决。第二种是MTU 协商失败后仍按大包发,导致数据被截断或连接异常,后来加了协商结果判断。第三种最隐蔽——供电不足,BW16 在 BLE 发射瞬间电流会冲一下,如果 USB 口供电弱,电压跌落就会复位。换了个供电稳的接口后,断连消失。
排查断连,我建议按这个顺序:先看是不是发送太猛,再看 MTU,最后查电源。电源问题最容易被忽略,但往往最致命。
5.3 屏幕和网页抢资源:任务优先级怎么排
同时开屏幕刷新和 WebSocket 推送后,出现过网页卡、屏幕也卡的情况。原因是两个任务优先级没排好,互相抢占。我的调整是:BLE 接收任务优先级最高(保证不丢数据),显示任务次之,WebSocket 推送最低。这样即使网页偶尔慢一点,也不会影响数据接收和本地显示。
另外,WebSocket 推送不要每条数据都推,可以攒一小批再推,减少网络开销。实测攒 10 到 20 个点推一次,网页观感几乎没差别,但 ESP32 的负担小很多。
6. 让链路更耐用的几个进阶调整
6.1 数据打包格式的规范化
原型跑通之后,我建议把数据格式固定下来,比如每包带一个序号和时间戳。这样接收端能检测丢包,网页端也能做时间对齐。序号不需要很复杂,一个自增的 16 位整数就够。丢包检测对调试特别有用——你能一眼看出是"信号本身有断"还是"传输丢了"。
6.2 采样率与传输带宽的匹配
这里有个容易忽略的账:假设脑电模块 8 通道、每通道 250Hz、每点 3 字节,那原始速率是 8 × 250 × 3 = 6000 字节/秒。BLE 在 MTU 247 下,理论吞吐足够,但实际受连接间隔影响。如果连接间隔是 30ms,那每 30ms 能发一批,6000 字节/秒分摊下来每批约 180 字节,一包就能装下,很轻松。但如果通道数或采样率翻倍,就要重新算这笔账,必要时降低分辨率或做压缩。
把带宽算清楚,能避免"跑着跑着就卡"的玄学问题。
6.3 供电与接地的细节
最后说个不起眼但很重要的点:采集端和显示端如果都插在同一个 USB hub 上,地环路可能引入干扰。我遇到过屏幕波形上叠加周期性纹波,最后发现是供电共地导致的。把两端供电分开,或者用带隔离的供电方案,纹波就干净了。原型阶段不一定要上隔离,但心里要有这根弦。
整条链路从脑电模块到屏幕再到网页,核心其实就是"分层 + 解耦"四个字。BW16 专心搬运,ESP32-CYD 专心接收和呈现,中间用 BLE 连起来,屏幕和网页共享同一份数据缓冲。把这几个边界划清楚,剩下的就是调参数和排错了。我自己的体会是,原型阶段别追求一步到位,先把"数据能从头走到尾"跑通,再去优化延迟、画质和稳定性,这样每一步都有反馈,不容易陷进去。