1. 这条无线 EEG 原型链路到底在做什么
脑电采集这件事,很多人第一反应是"离我很远",其实拆开看,它无非就是三件事:把头皮上微弱的电位变化抓下来、把数字化的数据传出去、把数据画成能看的波形。我这次做的项目,就是把这三件事用两块很便宜的开发板串起来——前端用 BW16 负责无线转发,后端用 ESP32-CYD 负责接收、解析、显示,同时把数据推到网页上。
先说清楚这套东西能干什么。它不是一个医疗级设备,也不追求科研级精度,它的定位是原型验证链路:验证从脑电模块出来的串口数据,能不能经过 BLE 无线传输,稳定地落到另一块板子的屏幕上,并且同步在浏览器里实时刷新。适合谁看?适合手里已经有脑电采集模块(比如常见的单通道或双通道干电极模块)、想快速搭一条无线链路做实验的人;也适合单纯想学 BLE + UART 透传 + 屏幕显示这套组合拳的嵌入式爱好者。
核心关键词就五个:BW16、ESP32-CYD、EEG、BLE、UART。这条链路的数据流向非常清晰:脑电模块通过 UART 把数据吐给 BW16,BW16 把串口数据打包成 BLE 特征值广播/通知出去,ESP32-CYD 作为 BLE 中心设备订阅这个特征值,拿到数据后一边刷 TFT 屏幕画波形,一边起一个轻量 Web 服务把数据推给浏览器。整条链路里,UART 是"有线段",BLE 是"无线段",屏幕和网页是"呈现段"。
为什么值得单独写一篇?因为这里面有几个坑非常典型:BLE 的 MTU 和分包、UART 的波特率与帧同步、EEG 数据的采样率与屏幕刷新率的匹配、网页端用什么方式推流。这些问题在纯有线方案里不明显,一旦上了无线就全冒出来了。我踩过一遍之后,把能复现的步骤和参数都整理下来,你照着做基本能少走大半弯路。
提示:本文所有参数和代码都是基于常见实践给出的可复现方案,具体数值需要根据你手上的脑电模块手册和开发板版本做微调,不要无脑照抄波特率和采样率。
2. 整体方案设计与选型背后的取舍
2.1 为什么是 BW16 做无线前端,而不是直接上 ESP32
很多人会问:既然 ESP32-CYD 本身就有 BLE,为什么还要多一块 BW16?直接用 ESP32 同时做采集和显示不行吗?
这里的关键在于分工和实时性。脑电模块出来的数据是连续流,采样率通常在 250Hz 到 1kHz 之间,每个采样点可能带 3 字节或更多数据。如果让一块板子同时干"读串口 + 跑 BLE 协议栈 + 刷屏幕 + 跑 Web 服务",在数据量大时很容易出现丢包或者屏幕卡顿。BW16 基于 RTL8720DN,双频无线能力不错,把它单独用作"串口转 BLE 的透明桥",职责单一,稳定性明显更好。
另一个现实原因是电气隔离和位置自由。脑电模块通常贴在人身上或者头戴设备上,线缆越短越好,减少工频干扰和运动伪影。BW16 体积小、功耗低,可以跟采集模块一起放在近端,只把无线信号发出去;ESP32-CYD 放在桌面上负责显示和联网,物理上分开,干扰源也分开了。
2.2 ESP32-CYD 的角色:显示 + 网页双通道
ESP32-CYD 这块板子(CYD 是 Cheap Yellow Display 的缩写)最大的价值是自带 2.8 寸 TFT 和触摸,价格却很亲民。我让它承担两个呈现任务:
- 本地屏幕:画实时波形,看信号质量、有没有明显漂移或饱和,方便现场调试。
- 网页端:通过 WiFi 起一个 HTTP 服务,把数据用 Server-Sent Events 或者 WebSocket 推给浏览器,方便在电脑上放大看、录数据。
为什么两个都要?因为屏幕刷新率有限,看整体趋势方便,但看细节不够;网页端可以用 Canvas 画更长的窗口、加网格、加滤波开关。两者互补,调试效率高很多。
2.3 数据链路的协议分层
把整条链路按层拆开,逻辑会清楚很多:
| 层级 | 承担者 | 协议/方式 | 关键参数 |
|---|---|---|---|
| 采集层 | 脑电模块 | 模拟前端 + ADC | 采样率、增益 |
| 有线传输层 | 脑电模块 → BW16 | UART | 波特率、数据帧格式 |
| 无线传输层 | BW16 → ESP32-CYD | BLE GATT Notify | MTU、连接间隔 |
| 呈现层 | ESP32-CYD | TFT + HTTP/WS | 刷新率、窗口长度 |
这个分层的好处是:任何一层出问题,你都能单独定位。比如波形乱跳,先看 UART 帧同步对不对;如果 UART 正常但无线端丢包,就查 BLE 的 MTU 和连接参数。
2.4 方案选型的几个关键取舍
取舍一:BLE 还是 WiFi 直传?BLE 功耗低、配对简单,适合近端短距离;WiFi 带宽大但功耗高、配网麻烦。原型阶段我选 BLE,因为脑电数据量其实不大(单通道 250Hz × 3 字节 ≈ 750 字节/秒),BLE 完全够用。
取舍二:Notify 还是 Indicate?Notify 不需要接收方确认,吞吐更高,适合连续流;Indicate 有确认机制但会拖慢速度。EEG 这种连续数据必须用 Notify。
取舍三:网页端用轮询还是推流?轮询实现简单但延迟高、浪费带宽;推流(SSE/WebSocket)实时性好。我最终用 SSE,因为它是单向的、基于 HTTP,实现比 WebSocket 简单,浏览器兼容性也好。
3. 核心细节解析与实操要点
3.1 UART 段:帧同步是第一个大坑
脑电模块通过 UART 输出的数据,最常见的是二进制帧,而不是 ASCII 文本。为什么?因为二进制效率高,一个采样点用 3 字节(24 位有符号)就能表示,ASCII 要十几个字符。但二进制帧带来一个问题:你怎么知道一帧从哪里开始?
常见做法是帧头 + 帧尾 + 校验。比如很多模块用0xAA 0x55作为帧头,后面跟数据长度、数据体、校验和。BW16 在转发时,如果只是简单地把串口字节流原样塞进 BLE 通知,接收端就必须自己做帧同步。
我的做法是在 BW16 侧做一次帧解析和重组,而不是纯透传。原因很实际:BLE 的每次 Notify 有长度限制(默认 MTU 23 字节,实际载荷 20 字节),如果直接把串口流切片发送,接收端拿到的数据边界是乱的,帧同步会非常痛苦。在 BW16 侧按完整帧打包,接收端每次收到的就是一个或多个完整帧,解析逻辑简单很多。
UART 参数方面,我用的配置是:
波特率:115200 数据位:8 停止位:1 校验位:无 流控:无为什么是 115200 而不是更高?因为脑电模块的固件通常默认这个值,而且 115200 在 250Hz 采样率下绰绰有余。算一下:假设每帧 10 字节,250Hz 就是 2500 字节/秒,115200 波特率理论能传约 11520 字节/秒,余量充足。如果你把采样率提到 1kHz,帧长 10 字节就是 10000 字节/秒,接近上限了,这时候要么提高波特率到 460800,要么压缩帧格式。
注意:UART 的波特率误差要控制在 2% 以内,否则长时间传输会累积错位。BW16 的串口时钟源要确认一下,有些固件默认分频会导致实际波特率偏差。
3.2 BLE 段:MTU、连接间隔与分包策略
BLE 这一段是整条链路最容易出问题的地方。三个参数必须搞清楚:
MTU(最大传输单元):默认 23 字节,其中 3 字节是 ATT 头,实际能用的载荷是 20 字节。你可以通过协商把 MTU 提到 247 甚至 512,但要看两端支持情况。BW16 和 ESP32 都支持 MTU 协商,我实测能稳定协商到 247,载荷 244 字节。
连接间隔(Connection Interval):决定了两端多久通信一次,单位是 1.25ms。默认可能是 30ms 到 50ms。间隔越小延迟越低但功耗越高。EEG 实时显示对延迟敏感,我把它设到 15ms(即 12 个单位的 1.25ms)。注意从机延迟(Slave Latency)要设为 0,否则从机会跳过若干次连接事件,增加延迟。
分包策略:如果一帧数据超过 MTU 载荷,就必须分包。我的策略是按帧边界分包,绝不把一帧拆到两个 Notify 里。如果一帧 10 字节,MTU 载荷 244 字节,那一次 Notify 可以塞 24 帧。这样接收端每次拿到的都是整数帧,解析零负担。
计算一下吞吐是否够:连接间隔 15ms,每次连接事件至少能发一个 Notify(244 字节),理论吞吐约 244 / 0.015 ≈ 16266 字节/秒。实际因为协议开销和调度,打个对折也有 8000 字节/秒,远超 EEG 需求的 2500 字节/秒。
3.3 EEG 数据特性:采样率、分辨率与去噪
虽然本文重点是链路,但 EEG 数据本身的特性直接决定了链路参数。几个必须知道的点:
采样率:常见 250Hz、500Hz、1000Hz。采样率决定了数据速率,也决定了你能看到多高的频率成分。按奈奎斯特采样定理,250Hz 采样能看到 125Hz 以下的信号,而脑电主要能量在 0.5Hz 到 50Hz,所以 250Hz 够用。
分辨率:常见 24 位 ADC,但实际有效位数(ENOB)可能只有 20 位左右。数据用 3 字节有符号整数表示,范围约 ±8388608。
去噪:原始 EEG 里混着工频干扰(50Hz/60Hz)、基线漂移、眼电伪影。链路本身不做去噪,但显示端可以加简单的滑动平均或者带通滤波。我在网页端加了一个可开关的 50Hz 陷波和 0.5Hz 高通,效果立竿见影。
提示:不要在 BW16 或 ESP32 上做复杂滤波,算力有限且会引入延迟。把原始数据传上去,滤波放在网页端或上位机做,灵活且不占嵌入式资源。
3.4 屏幕与网页的刷新节奏
屏幕刷新和网页推流都要考虑一个核心问题:数据速率和刷新率不匹配怎么办?
EEG 是 250Hz,也就是每 4ms 一个点。TFT 屏幕如果每来一个点就刷一次,根本来不及,而且 SPI 刷屏本身耗时。我的做法是攒一批再刷:每 40ms 刷一次,一次画 10 个点。这样屏幕刷新率 25fps,视觉上完全流畅。
网页端同理,用 SSE 每 50ms 推一批数据(约 12 个点),浏览器 Canvas 一次画一批。这样既不会让浏览器忙于重绘,也不会让数据积压。
4. 实操过程与核心环节实现
4.1 硬件连接与供电
先把硬件接起来。脑电模块的 TX 接 BW16 的 RX,RX 接 BW16 的 TX,GND 共地。BW16 用 USB 或者 3.3V 供电。ESP32-CYD 单独用 USB 供电,它自带屏幕,不需要额外接线。
这里有个细节:脑电模块和 BW16 的电平要匹配。大多数模块是 3.3V 逻辑,BW16 也是 3.3V,直接连没问题。如果你的模块是 5V 逻辑,必须加电平转换,否则可能烧掉 BW16 的串口引脚。
供电方面,脑电模块对电源噪声很敏感。如果发现波形上有规律的尖刺,先检查是不是 USB 供电的纹波导致的。我试过给脑电模块单独用一个低噪声 LDO 供电,波形干净不少。
4.2 BW16 侧:串口转 BLE 的固件逻辑
BW16 的固件我用 Arduino 环境开发(也可以用官方的 SDK)。核心逻辑分三块:
第一块:串口接收与帧解析。用一个环形缓冲区接收串口字节,然后状态机找帧头、读长度、收数据体、校验。
// 简化的帧解析状态机 enum ParseState { WAIT_HEAD1, WAIT_HEAD2, READ_LEN, READ_DATA, READ_CHECK }; ParseState state = WAIT_HEAD1; uint8_t buf[64]; uint8_t dataLen = 0; uint8_t idx = 0; void parseByte(uint8_t b) { switch (state) { case WAIT_HEAD1: if (b == 0xAA) state = WAIT_HEAD2; break; case WAIT_HEAD2: state = (b == 0x55) ? READ_LEN : WAIT_HEAD1; break; case READ_LEN: dataLen = b; idx = 0; state = READ_DATA; break; case READ_DATA: buf[idx++] = b; if (idx >= dataLen) state = READ_CHECK; break; case READ_CHECK: // 校验通过则入队待发 if (checksum(buf, dataLen) == b) enqueueFrame(buf, dataLen); state = WAIT_HEAD1; break; } }第二块:BLE 服务搭建。建一个自定义 GATT 服务,里面放一个 Notify 特征值。UUID 自己定义,注意不要跟标准服务冲突。
第三块:批量发送。主循环里检查队列,攒够一定数量或者超过时间阈值就发一次 Notify。
void loop() { while (Serial1.available()) parseByte(Serial1.read()); if (millis() - lastSend > 15 && frameCount > 0) { bleNotify(batchBuffer, batchLen); frameCount = 0; batchLen = 0; lastSend = millis(); } }4.3 ESP32-CYD 侧:BLE 中心 + 屏幕 + 网页
ESP32-CYD 的固件分三个任务,我建议用 FreeRTOS 分开跑,避免互相阻塞:
任务一:BLE 扫描与订阅。扫描到 BW16 的设备名或服务 UUID,连接,订阅 Notify 特征值。收到数据后丢进一个队列。
任务二:屏幕刷新。从队列取数据,攒批,画波形。TFT 用 TFT_eSPI 库,画线用drawLine,为了性能可以只重绘变化区域,但原型阶段全屏重绘也够用。
任务三:Web 服务。用 ESPAsyncWebServer 起一个 HTTP 服务,一个端点返回 HTML 页面,另一个端点用 SSE 推数据。
// SSE 推送示例 AsyncEventSource events("/events"); void pushData(float* samples, int n) { String msg = "data:"; for (int i = 0; i < n; i++) { msg += String(samples[i], 2); if (i < n - 1) msg += ","; } msg += "\n\n"; events.send(msg.c_str()); }网页端用 Canvas 画,收到 SSE 消息就解析、入队、重绘。加一个环形缓冲区保存最近几秒的数据,方便回看。
4.4 参数计算:从采样率到缓冲区大小
这里给一个完整的参数推导,你可以照着算自己的配置。
假设:采样率 250Hz,每采样点 3 字节,帧头帧尾校验共 4 字节,则每帧 7 字节。
- 数据速率:250 × 7 = 1750 字节/秒
- BLE 连接间隔 15ms,每次发一批,一批攒 15ms 的数据:1750 × 0.015 ≈ 26 字节,约 4 帧
- 屏幕刷新 40ms 一次,一次画 10 个点,需要缓冲区至少 10 个 float
- 网页推送 50ms 一次,一次推 12 个点
缓冲区大小建议留 2 倍余量,防止突发。比如屏幕缓冲区开 32 个点,网页缓冲区开 64 个点。
注意:如果采样率提高到 500Hz,上面所有数字翻倍,BLE 连接间隔要相应缩小到 7.5ms 或者每批多攒几帧。别让队列积压,积压意味着延迟越来越大。
4.5 网页端实现细节
网页端我尽量做得轻量,不引入任何框架,纯原生 JS + Canvas。核心是一个环形缓冲区和requestAnimationFrame重绘循环。
const buf = new Float32Array(1000); let head = 0; const es = new EventSource('/events'); es.onmessage = (e) => { const vals = e.data.split(',').map(Number); for (const v of vals) { buf[head] = v; head = (head + 1) % buf.length; } }; function draw() { ctx.clearRect(0, 0, w, h); ctx.beginPath(); for (let i = 0; i < buf.length; i++) { const idx = (head + i) % buf.length; const x = i / buf.length * w; const y = h / 2 - buf[idx] * scale; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.stroke(); requestAnimationFrame(draw); } draw();这个实现简单但有效,1000 点的窗口在 250Hz 下是 4 秒,足够看清节律变化。
5. 常见问题与排查技巧实录
5.1 波形乱跳或者完全没数据
这是最常见的问题,排查顺序建议如下:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全无数据 | 串口接反 | 交换 TX/RX 再试 |
| 完全无数据 | 波特率不对 | 用示波器或逻辑分析仪看波形 |
| 数据乱码 | 帧同步失败 | 打印原始字节,找帧头 |
| 波形乱跳 | 电源噪声 | 换低噪声供电,加滤波电容 |
| 波形乱跳 | 电极接触不良 | 检查电极阻抗,重新贴 |
| 数据断续 | BLE 丢包 | 看连接间隔和 MTU 设置 |
我遇到过一次波形周期性跳变,查了半天发现是 USB 充电器的开关噪声通过地线串进来了。换了个电池供电立刻干净。所以电源质量对 EEG 的影响,怎么强调都不过分。
5.2 BLE 连接不稳定或者频繁断开
BLE 断连通常有几个原因:连接间隔设得太激进、从机延迟不为零、信号被遮挡、或者两端 MTU 协商失败。
我的经验是:先把连接间隔设保守一点(比如 30ms),确认稳定后再逐步调小。另外,BW16 作为从机时,广播间隔也要合理,太短费电,太长连接慢。我一般设 100ms 到 200ms。
还有一个隐蔽的坑:有些手机或电脑的 BLE 助手会抢占连接。调试时如果发现 ESP32 连不上,先确认没有其他中心设备连着 BW16。
5.3 屏幕刷新卡顿或者撕裂
屏幕卡顿一般是 SPI 速率不够或者刷新逻辑太重。CYD 的 TFT 走 SPI,默认速率可能只有 20MHz 到 40MHz。可以试着提到 80MHz,但要注意走线质量,太快会花屏。
撕裂是因为边画边显示。解决办法是双缓冲或者只在垂直消隐期刷新。原型阶段如果不想搞复杂,就降低刷新率,40ms 一次基本看不出撕裂。
5.4 网页延迟越来越大
这是典型的生产者快于消费者问题。数据来得快,网页画得慢,队列越积越长,延迟就越来越大。
解决办法有两个:一是丢旧数据,队列满了就丢最老的;二是降低推送频率,让网页端轻松点。我一般两个都用,队列设上限 200 个点,超了就丢,推送频率 50ms 一次。
提示:实时显示场景下,"最新数据"永远比"完整数据"重要。宁可丢几个点,也不要让延迟累积到几秒。
5.5 独家避坑清单
- 串口线尽量短:超过 30cm 的杜邦线在 115200 下就可能出错,能短则短。
- 共地一定要牢:很多诡异问题都是地线接触不良。
- 先跑通有线再上无线:先用 USB-TTL 直接连电脑确认数据正常,再加 BW16,问题定位快很多。
- 给 BLE 特征值加时间戳:接收端可以据此判断有没有丢包和延迟。
- 网页端加个暂停按钮:调试时能冻结画面看细节,非常实用。
- 记录原始数据:网页端加个"下载 CSV"按钮,方便事后分析。
6. 这条链路还能怎么扩展
原型跑通之后,扩展方向其实很多。往采集端走,可以加多通道,但要注意 BLE 带宽,多通道数据量大,可能要考虑压缩或者换 WiFi。往呈现端走,可以加简单的频谱分析,网页端用 FFT 把时域转频域,看 alpha、beta 节律。往存储走,可以在 ESP32 上挂 SD 卡,边显示边录,方便离线分析。
我自己下一步打算在网页端加一个简单的带通滤波和频谱图,这样不用上位机就能看基本的脑电特征。另外想试试把数据同时推给多个浏览器,方便多人同时观察。
这条链路最大的价值不是它有多精密,而是它把"采集—传输—显示"这条完整路径用很低的成本跑通了。你理解了每一段的参数和坑,换成别的模块、别的板子,思路是一样的。真正难的不是接线,是理解数据在每一层是怎么被处理、被限制、被呈现的。把这一层想清楚,后面做什么都顺。