1. 这条无线 EEG 原型链路到底在做什么
脑电采集这件事,很多人第一反应是"电极贴头皮、放大器、电脑上跑分析软件",整套设备动辄几万块,线缆缠一身,被试稍微动一下就是一片工频干扰。我这次想验证的是一个更轻量的思路:把脑电模块采到的原始数据,通过 BLE 无线传到一块带屏幕的开发板上,屏幕实时画出波形,同时这块板子再把数据转发到网页端做可视化。整条链路里没有电脑直连采集卡,被试可以相对自由地坐着,屏幕端和网页端都能看到实时信号。
核心硬件就两块:一块是负责采集和 BLE 发送的BW16模块,另一块是负责接收、显示和转发的ESP32-CYD(CYD 是 Cheap Yellow Display 的缩写,就是那块自带 2.8 寸 ILI9341 屏和触摸的廉价 ESP32 开发板)。中间靠BLE做无线传输,板子与屏幕、板子与网页之间则用UART和 WebSocket 两条路径分发数据。
这套东西适合谁?如果你手上有一个能输出数字信号的脑电前端模块(比如常见的 ADS1299 类采集板,或者某些集成好的 EEG 模块),想快速搭一个"无线 + 本地屏显 + 网页可视化"的原型,又不想一上来就啃复杂的上位机软件,那这条链路值得参考。它不追求医疗级精度,追求的是链路通、延迟低、能看见波形,用来做算法预研、教学演示、交互原型验证都够用。
我先把整条链路的角色分工讲清楚,后面再逐段拆解。BW16 这边负责三件事:从脑电模块读数据、做最基础的去噪和打包、通过 BLE 发出去。ESP32-CYD 这边负责四件事:BLE 接收、UART 转发、屏幕绘制、WebSocket 推送到网页。听起来简单,但每一段都有坑,尤其是 BLE 的 MTU 和 UART 的波特率匹配,后面会重点讲。
提示:这条链路是原型验证性质,不是医疗设备。任何涉及人体采集的操作,都要确保采集前端本身有隔离和安规设计,开发板只做数据搬运,不要让它承担电气安全责任。
2. 为什么选 BW16 加 ESP32-CYD 这个组合
2.1 BW16 做采集端的三个理由
BW16 是瑞昱 RTL8720DN 方案的模组,双频 WiFi 加 BLE 5.0,主频 200MHz,RAM 相对充裕。选它做采集端,主要看中三点。
第一是BLE 和 WiFi 共存。很多廉价模组要么只有 BLE,要么 BLE 和 WiFi 抢资源抢得厉害。BW16 的双频设计让它在只跑 BLE 的时候非常稳,我实测连续传输几个小时没有掉线。第二是UART 资源灵活,它有多组 UART,可以一路接脑电模块,一路留作调试打印,互不干扰。第三是Arduino 生态支持,虽然 BW16 的 Arduino 核心不如 ESP32 成熟,但基本的 BLE 库、串口库都能用,上手成本可控。
这里要解释一个关键选择:为什么采集端不直接用 ESP32?因为 ESP32 我要留给 CYD 做显示和网页转发,那块板子要跑屏幕驱动、WebSocket 服务、BLE 客户端,负载不轻。把采集和显示拆到两块板子上,各司其职,调试的时候也方便——采集端出问题就只看 BW16,显示端出问题就只看 CYD,不用在一堆日志里大海捞针。
2.2 ESP32-CYD 做接收端的天然优势
CYD 这块板子最大的价值是自带屏幕和触摸,还便宜。它用的是 ESP32-WROOM-32,双核 240MHz,4MB Flash,配上 ILI9341 驱动的 2.8 寸 320x240 屏。做波形显示,320 像素宽刚好能画一段有意义的 EEG 波形,240 像素高可以分几个通道。
更关键的是 ESP32 的WebSocket 支持非常成熟。ESPAsyncWebServer 加 AsyncWebSocket 这套库,能在一颗芯片上同时跑 HTTP 服务和 WebSocket 推送,网页端用浏览器打开就能收数据,不需要装任何客户端。这一点对原型验证太重要了——你改一行前端代码,刷新浏览器就能看到效果,迭代速度比写 Qt 上位机快十倍。
还有一点是UART 转发能力。CYD 上引出的 UART 可以接一个 USB-UART 芯片(比如 FT231X 或 FT232R),把数据同时送到电脑串口助手做备份记录。这样屏幕看实时波形,电脑存原始数据,网页做交互展示,三路并行。
2.3 整条链路的数据流设计
我把数据流画成文字版,方便对照:
- 脑电模块 → UART → BW16(采集、去噪、打包)
- BW16 → BLE Notify → ESP32-CYD(接收)
- ESP32-CYD → 屏幕绘制(本地实时波形)
- ESP32-CYD → UART → USB-UART → 电脑(备份记录)
- ESP32-CYD → WebSocket → 浏览器网页(远程可视化)
这个设计里,BLE 是唯一的无线环节,其余都是有线。为什么不让 CYD 也走 WiFi 直接收?因为脑电模块和 CYD 之间如果走 WiFi,功耗和延迟都不如 BLE 稳定,而且 BLE 的配对机制让链路更可控。WiFi 只用在 CYD 到网页这一段,走的是局域网,延迟可以接受。
注意:BLE 和 WiFi 在 ESP32 上是共用射频的,如果 CYD 同时跑 BLE 客户端和 WiFi AP/STA,会有时分复用带来的抖动。我的做法是 BLE 接收用较高优先级任务,WebSocket 推送做缓冲队列,避免射频切换时丢包。
3. 脑电数据从模块到 BW16 的采集细节
3.1 脑电模块的 UART 输出格式约定
大多数集成脑电模块输出的是定长帧,常见格式是帧头加通道数据加校验。我手上这块模块输出的是 24 位 ADC 原始值,每通道 3 字节,加上帧头帧尾,一帧大概几十字节。采样率设的是 250Hz,这个速率对 BLE 来说很轻松,但对 UART 解析的实时性有要求。
这里有个关键点:UART 接收必须用中断加环形缓冲,不能在主循环里轮询。250Hz 意味着每 4ms 就有一帧,如果主循环里还有 BLE 发送、去噪计算,轮询很容易丢字节。我的做法是 UART 中断里只做一件事——把字节塞进环形缓冲,主循环再从缓冲里取帧解析。这样即使主循环偶尔卡顿,缓冲也能扛住。
// BW16 端 UART 环形缓冲的简化示意 #define RING_SIZE 512 volatile uint8_t ringBuf[RING_SIZE]; volatile uint16_t ringHead = 0, ringTail = 0; void onUartRx() { while (Serial1.available()) { uint16_t next = (ringHead + 1) % RING_SIZE; if (next != ringTail) { ringBuf[ringHead] = Serial1.read(); ringHead = next; } else { Serial1.read(); // 缓冲满,丢弃,防止阻塞 } } }3.2 帧同步与校验的实操要点
帧同步是采集端最容易翻车的地方。脑电模块上电瞬间、或者受到干扰时,可能输出半截帧,如果解析器不做同步,后面所有数据都会错位。我的做法是状态机加帧头匹配:先找帧头字节,找到后按固定长度收完一帧,再校验和验证,校验不过就丢弃并重新找帧头。
校验和用简单的累加和就够了,不用上 CRC,因为 UART 本身有奇偶校验(如果开了的话),再加一层累加和能挡住绝大多数错误。实测下来,250Hz 连续跑一小时,错帧率在万分之一以下,对波形显示完全够用。
实操心得:调试帧同步时,先别急着接脑电模块,用电脑串口助手手动发构造好的帧,把解析器调通再上真实数据。这样能把"解析逻辑错误"和"硬件信号问题"分开排查,省大量时间。
3.3 采集端做多少去噪才合适
标题里提到了EEG 去噪,但采集端能做的去噪非常有限。BW16 的算力跑复杂滤波不现实,我的策略是只做最必要的处理:一个简单的滑动平均或者一阶 IIR 高通,去掉直流漂移,剩下的交给上位机。
为什么不在采集端做 50Hz 陷波?因为陷波滤波器对相位影响大,而且如果采集端做了一半,上位机再做一次,反而可能引入伪影。原型阶段的原则是:采集端保真,处理端灵活。原始数据尽量干净地传出去,去噪算法在网页端或电脑端用 JavaScript 或 Python 实现,改起来方便。
如果非要在采集端做点什么,我建议只做降采样。比如模块输出 500Hz,但显示和传输只需要 250Hz,那就每两个点取一个,或者做简单的两点平均。这样能直接减半 BLE 带宽压力,而且降采样是线性操作,不会引入奇怪的伪影。
4. BLE 传输这一段的关键参数
4.1 MTU 协商与数据分包
BLE 默认的 ATT MTU 是 23 字节,实际能用的 payload 只有 20 字节。脑电一帧几十字节,肯定要分包。但分包不是随便切的,MTU 协商决定了每包能装多少。
BW16 作为 BLE 外设,CYD 作为中心设备,连接后第一件事就是协商 MTU。我实测 BW16 能协商到 247 字节的 MTU,payload 能到 244 字节。这个大小足够装下好几帧脑电数据,大大减少了包数量。
// CYD 端连接后请求更大 MTU client->setMTU(247); // 连接事件里确认实际 MTU void onConnect(BLEClient* c) { Serial.print("Negotiated MTU: "); Serial.println(c->getMTU()); }这里有个坑:不是所有手机或中心设备都支持大 MTU。如果 CYD 协商失败,会退回默认 23 字节,这时候就要靠分包逻辑兜底。我的做法是发送端根据协商结果动态调整每包帧数,MTU 大就多装几帧,MTU 小就少装,接收端按帧头重新组装。
4.2 Notify 与 Indicate 的选择
BLE 有两种数据推送方式:Notify和Indicate。Notify 不需要接收方确认,速度快但可能丢包;Indicate 需要确认,可靠但慢。脑电数据是流式的,丢一两包对波形显示影响不大,所以我选Notify。
但 Notify 有个细节:如果发送太快,协议栈的缓冲会满,这时候notify()会返回失败。我的处理是检查返回值,失败就等下一轮,不要死循环重试。实测在 250Hz、每包 5 帧的情况下,Notify 成功率在 99.9% 以上,偶尔丢一包波形上看不出来。
注意:BLE 的连接间隔(Connection Interval)直接影响吞吐。默认可能是 30ms 到 50ms,如果数据量大,要在中心设备端请求更短的间隔,比如 15ms 甚至 7.5ms。但间隔太短会增加功耗,原型阶段插着电用,可以设短一点。
4.3 用 BLE 调试助手快速验证链路
调试 BLE 的时候,别一上来就写 CYD 的代码。先用手机上的BLE 调试助手(比如常见的 nRF Connect 或者各类 BLE 串口助手)连上 BW16,看看能不能收到 Notify 数据。这一步能快速确认:BW16 的 BLE 服务有没有正确建立、特征值有没有正确配置、Notify 有没有正常发。
我踩过的坑是:BW16 的 BLE 服务 UUID 和特征值 UUID 配错了,手机连上却看不到数据。用调试助手一看,特征值的属性里没有 Notify,问题立刻定位。所以先用通用工具验证,再写专用代码,这个顺序能省很多时间。
5. ESP32-CYD 端的接收与屏幕绘制
5.1 BLE 客户端接收的缓冲设计
CYD 作为 BLE 中心设备,收到 Notify 数据后不能直接拿去画屏,因为屏幕刷新和 BLE 回调不在一个节奏上。BLE 回调可能一次来好几包,屏幕刷新是固定帧率。我的做法是:BLE 回调里只把数据塞进一个队列,屏幕刷新任务从队列取数据。
队列用 FreeRTOS 的xQueueSendFromISR或者简单的环形缓冲都行。关键是队列要有溢出保护,如果屏幕刷新跟不上,队列满了就丢最旧的数据,保证显示的是最新波形,而不是卡在历史数据上。
// CYD 端 BLE 回调塞队列 void onNotify(BLERemoteCharacteristic* ch, uint8_t* data, size_t len, bool isNotify) { for (size_t i = 0; i < len; i++) { xQueueSend(eegQueue, &data[i], 0); // 不阻塞,满了就丢 } }5.2 ILI9341 屏幕画波形的刷新策略
320x240 的屏幕画 EEG 波形,最直接的方法是滚动刷新:每来一个新点,整屏左移一列,最右边画新点。但整屏左移在 SPI 屏上很慢,320x240 全屏刷新一次要几十毫秒,根本跟不上 250Hz。
我的优化方案是分块刷新加双缓冲。把屏幕横向分成若干块,每次只刷新最右边一块,用pushImage或者fillRect局部更新。更进一步,用 TFT_eSPI 库的 Sprite(精灵)做离屏缓冲,先在内存里画好一屏,再整块推上去。但 Sprite 占内存大,320x240x2 字节就是 150KB,ESP32 的 RAM 扛不住全屏 Sprite。
折中方案是只对波形区域用 Sprite,比如波形区 320x100,那就是 64KB,可以接受。状态栏、坐标轴这些静态内容直接画在屏幕上,不参与滚动。这样刷新率能到 30fps 以上,波形看起来是流畅的。
实操心得:TFT_eSPI 的配置在 CYD 上要特别注意引脚定义。CYD 的屏幕引脚和标准 ESP32 开发板不一样,User_Setup.h 里要按 CYD 的实际接线改。改错了就是白屏或者花屏,别怀疑代码,先查引脚。
5.3 屏幕端做哪些实时处理
屏幕端不只是画原始波形,还可以做一些轻量实时处理让显示更有意义。我加了两个:一个是简单的阈值检测,波形超过阈值时该段用不同颜色画,方便看伪影;另一个是通道选择,如果模块是多通道的,屏幕上可以切换显示哪个通道。
这些处理都在 CYD 上做,因为 CYD 的算力比 BW16 强,而且处理结果直接影响显示,放在一起延迟最低。但要注意别做太重,屏幕刷新任务本身已经占了不小负载,再加复杂算法会掉帧。
6. 从 CYD 到网页的 WebSocket 推送
6.1 网页端可视化的整体架构
CYD 上跑一个AsyncWebServer,同时提供 HTTP 服务和 WebSocket 端点。网页端就是一个 HTML 文件,里面用 JavaScript 的 WebSocket API 连上 CYD,收到数据后用 Canvas 画波形。整个网页可以存在 CYD 的 SPIFFS 或者 LittleFS 里,浏览器访问 CYD 的 IP 就能打开。
这个架构的好处是零客户端安装。任何能连上局域网的设备,手机、平板、电脑,打开浏览器就能看波形。做演示的时候特别方便,不用在每台设备上装软件。
// 网页端 WebSocket 接收与绘制 const ws = new WebSocket('ws://' + location.host + '/ws'); const canvas = document.getElementById('eeg'); const ctx = canvas.getContext('2d'); let x = 0; ws.onmessage = (evt) => { const data = JSON.parse(evt.data); // data.samples 是本次收到的采样点数组 data.samples.forEach(v => { const y = mapToCanvas(v); ctx.fillRect(x, y, 1, 1); x = (x + 1) % canvas.width; if (x === 0) ctx.clearRect(0, 0, canvas.width, canvas.height); }); };6.2 数据格式与带宽控制
WebSocket 传的是文本还是二进制?我选JSON 文本,因为调试方便,浏览器控制台直接能看。但 JSON 有开销,每个数字都要转成字符串。250Hz 单通道,每个点大概 5 个字符,加上 JSON 结构,每秒几 KB,局域网完全扛得住。
如果通道多、采样率高,就要考虑二进制。WebSocket 支持 ArrayBuffer,把采样点打包成 Int16 数组直接发,带宽能省一半以上。但二进制调试麻烦,原型阶段先用 JSON,等链路稳定了再优化。
注意:CYD 的 WebSocket 推送要限速。如果 BLE 收到多少就往 WebSocket 推多少,网络抖动时可能积压。我的做法是网页端定时请求,或者 CYD 端按固定帧率推送,比如 30fps,每次推最近积累的采样点。这样网页端刷新节奏稳定,不会因为网络波动卡顿。
6.3 网页端做 EEG 去噪和源定位的扩展空间
标题热词里出现了EEG 源定位和最小范数估计,这些是脑电分析的高级话题。原型链路本身不做源定位,但网页端是个天然的扩展点。因为网页端可以用 JavaScript 跑一些轻量算法,或者把数据转发到后端 Python 服务做重计算。
我的思路是:CYD 只负责搬运和显示,网页端负责交互和轻处理,重计算交给后端。比如网页端可以做一个简单的带通滤波显示,用 JavaScript 实现 Butterworth 滤波器;源定位这种需要头模型和导联位置的计算,就通过 WebSocket 再转发到 Python 后端,用 MNE 之类的库处理,结果回传网页显示。
这样分层的好处是每一层职责清晰,改哪一层都不影响其他层。原型阶段先把链路跑通,算法慢慢加。
7. 常见问题与排查技巧实录
7.1 BLE 连不上或频繁断开
这是最常见的问题。排查顺序我总结成一张表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 扫描不到设备 | BW16 没广播 | 检查 BLE 服务是否启动,用调试助手确认 |
| 能扫描连不上 | UUID 不匹配 | 核对服务 UUID 和特征 UUID |
| 连上就断 | 连接参数太激进 | 放宽连接间隔,降低发送速率 |
| 传一会就断 | 射频冲突或供电不足 | 检查 WiFi 是否同时开启,检查供电 |
我遇到过一次连上就断,查了半天是供电问题。BW16 在 BLE 发送瞬间电流会跳变,如果供电不稳,模组会复位。换了个大电容就好了。所以调试 BLE 稳定性,先保证供电干净。
7.2 UART 丢数据或乱码
UART 问题的根源通常是波特率不匹配或者地线没接好。脑电模块和 BW16 之间,除了 TX、RX,地线一定要接,否则电平参考不一致,数据必乱。波特率方面,两边都要设成一样的,常见的是 115200 或 460800。如果模块输出速率高,波特率要相应提高。
还有一个隐蔽的坑:UART 电平。有些模块是 3.3V 电平,有些是 5V,直接对接可能烧或者收不到。用万用表量一下 TX 空闲时的电平,确认是 3.3V 再接。
7.3 屏幕花屏或白屏
CYD 花屏九成是TFT_eSPI 配置错误。检查 User_Setup.h 里的引脚定义,CYD 的屏幕 CS、DC、RST、MOSI、SCLK、背光引脚都要对。另外 CYD 有些版本屏幕驱动是 ST7789 而不是 ILI9341,虽然都是 2.8 寸,但初始化序列不同,选错了就是白屏。
实操心得:拿到 CYD 第一件事不是写业务代码,而是跑一个屏幕测试例程,把颜色、方向、触摸都验证一遍。这一步花十分钟,能避免后面几小时的困惑。
7.4 网页端收不到数据或延迟大
网页端问题先看浏览器控制台。WebSocket 连接失败会有明确报错,比如跨域、端口不对、路径不对。如果连上了但没数据,检查 CYD 端有没有真的在推。延迟大通常是缓冲积压,检查 CYD 的推送队列是不是只增不减。
还有一个容易忽略的点:CYD 的 IP 地址。如果路由器 DHCP 分配变了,网页端连的还是旧 IP,自然收不到。原型阶段建议给 CYD 设静态 IP,省得每次找地址。
8. 几个让链路更稳的实操经验
8.1 供电与接地的处理
整条链路里,BW16 和 CYD 最好各自独立供电,不要互相从对方的 3.3V 取电。BLE 发送和屏幕刷新都是电流跳变大的操作,共用电源容易互相干扰。我用的是两个独立的 USB 供电,地线通过 USB 地连在一起,信号参考一致,但电源噪声隔离了。
脑电模块的供电更要小心,如果模块对电源噪声敏感,最好用线性稳压或者电池供电。原型阶段可以用充电宝给脑电模块供电,彻底隔离市电噪声,波形会干净很多。
8.2 时间戳与数据对齐
多路数据(屏幕、UART、WebSocket)如果要做对齐分析,时间戳很重要。我的做法是在 CYD 收到 BLE 数据时打一个本地时间戳,这个时间戳跟着数据一起进队列、一起转发。这样即使三路输出有延迟,事后也能按时间戳对齐。
时间戳用millis()或者esp_timer_get_time()都行,后者精度更高,微秒级。如果要做长时间记录,注意millis()会溢出,用 64 位的esp_timer_get_time()更省心。
8.3 固件升级与调试接口的保留
原型阶段改代码频繁,保留一个调试串口非常必要。BW16 和 CYD 各留一路 UART 做日志输出,出问题时看日志比猜快得多。另外,CYD 支持 OTA 的话,尽量把 OTA 调通,这样改网页端代码不用每次都插线烧录。
我自己的习惯是:每个模块上电先打印版本号和关键配置,比如 BLE 的 MTU、UART 的波特率、屏幕的分辨率。这样一看日志就知道当前跑的是什么配置,避免"我明明改了怎么没生效"这种问题。
8.4 从原型到可用产品的差距
这条链路跑通只是原型,离可用产品还有距离。主要差距在:抗干扰(真实环境电磁噪声复杂)、长时间稳定性(跑几小时和跑几天不一样)、数据完整性(丢包对分析的影响需要评估)、用户体验(配网、重连、错误提示)。
我的建议是原型阶段就把日志和错误处理做扎实,别等到产品化再补。比如 BLE 断开自动重连、UART 超时报警、WebSocket 断线重连,这些机制在原型阶段加上,后面会省很多事。
最后分享一个我踩过的坑:一开始我图省事,把 BLE 接收、屏幕刷新、WebSocket 推送全塞在一个任务里,结果屏幕一刷新,BLE 就丢包。后来拆成三个任务,用队列通信,各跑各的节奏,链路立刻稳了。ESP32 是双核的,善用多任务,别什么都往一个循环里塞,这是我从这个项目里得到的最实在的一条经验。