1. 项目缘起与整体链路设计
脑电信号采集这件事,早年我在实验室里接触的时候,整套设备动辄十几万,光是电极帽加放大器就占了大半个机柜,数据还得通过并口或者专用采集卡往电脑里灌。后来消费级脑电模块慢慢多起来,像单通道、双通道的干电极模块,几百块就能拿到,输出接口也统一成了串口或者 BLE。这就给个人开发者留出了很大的折腾空间——你可以用一块几十块钱的无线 MCU 把脑电数据接过来,再通过无线链路转发到另一块带屏幕的开发板上,实时画出波形,同时还能开个网页做远程监视。
我这次搭的这条链路,核心就是三块东西:脑电采集模块负责把头皮上的微弱电位差变成数字信号,通过 UART 串口往外吐;BW16作为无线桥接,把串口数据通过 BLE 广播出去;ESP32-CYD(就是那块带 2.4 寸电容触摸屏的廉价开发板)负责接收 BLE 数据,一边在屏幕上画实时波形,一边起一个轻量 Web 服务,让同一局域网内的手机或电脑用浏览器就能看到数据。
为什么选这个组合?先说 BW16。它本质上是 RTL8720DN 模组做的开发板,双频 WiFi 加 BLE5.0,关键是它有两个硬件串口,其中一个可以很干净地接脑电模块的 UART 输出,另一个留给调试打印。市面上很多 ESP32 也能做 BLE 透传,但 ESP32 同时跑 BLE 和 WiFi 的时候射频会打架,丢包率在高速数据流下很难看。BW16 的双频特性让它在 BLE 转发这个单一任务上更稳,功耗也低,适合做纯粹的“数据搬运工”。
再说 ESP32-CYD。这块板子火起来是因为它便宜,带屏幕带触摸,社区资料多。它在这里的角色是“接收端 + 显示端 + 服务端”。ESP32 本身支持 BLE 客户端模式,可以主动去连 BW16 的 BLE 服务,拿到数据后驱动 TFT 屏幕刷新波形,同时用 WebServer 库起一个 HTTP 服务,把最近的数据缓存成 JSON 接口,网页端用 Canvas 或者 WebSocket 拉取。整条链路的数据流向是:
脑电模块 → UART → BW16 → BLE → ESP32-CYD → 屏幕显示 + Web 页面
这条链路最大的好处是解耦。采集端和显示端物理分离,脑电模块可以戴在人头上,BW16 揣在口袋里,ESP32-CYD 放在桌上当监视器,中间没有线缆束缚。对于做睡眠监测、注意力训练、或者简单的脑电波形观察来说,这个原型已经够用了。
适合谁来参考?如果你手上有脑电模块但苦于没有好用的无线方案,或者你想学 BLE 透传和 UART 转接的实际落地,再或者你只是想找个小项目练手 ESP32 的屏幕和 Web 服务,这条链路都能给你一个完整的参考。不需要你懂脑电信号处理,只要会基本的 Arduino 开发和串口调试就行。
2. 硬件选型与接线细节拆解
2.1 脑电模块的 UART 输出特性
我用的脑电模块是常见的单通道干电极方案,输出波特率 57600,数据包格式是固定的帧头加数据加校验。这里有个坑要注意:很多脑电模块的 UART 电平是 3.3V 的,但如果你买的是带 USB 转串口芯片的版本,它可能直接输出 USB 信号,那就没法接 MCU 的串口了。所以选模块的时候一定要确认它引出的是TX/RX/GND 三根线,而不是一个 USB 口。
模块上电后会自动开始往外吐数据,频率大概每秒 50 到 100 个包。每个包的结构通常是:帧头两个字节、数据长度一个字节、有效载荷若干字节、校验和一个字节。具体格式每个厂家不一样,你得先拿 USB 转串口工具接电脑,用串口助手抓一段原始数据,把帧头和数据位对齐了再往下做。这一步千万别跳过,我见过有人直接拿模块接 MCU,结果因为波特率不匹配或者帧头没对齐,读出来的全是乱码,白白折腾一整天。
2.2 BW16 的串口与 BLE 配置
BW16 开发板上有两组 UART。我习惯把UART1留给脑电模块,UART0通过板载的 USB 转串口芯片接电脑做调试打印。接线就三根:脑电模块的 TX 接 BW16 的 RX,脑电模块的 RX 接 BW16 的 TX,GND 对 GND。注意不要接 5V,BW16 的 IO 是 3.3V 电平,接 5V 会烧。
BW16 的 BLE 部分,我把它配置成一个BLE 透传服务。具体做法是定义一个自定义的 GATT 服务,里面放一个 Notify 特征值。BW16 收到串口数据后,把数据打包成 BLE 通知发出去。这里的关键参数是MTU。默认 BLE 的 MTU 是 23 字节,有效载荷只有 20 字节。脑电数据包如果超过 20 字节,就得在 BW16 这边做分包,或者跟 ESP32 协商更大的 MTU。我实测下来,把 MTU 协商到 247 之后,一个包能装 244 字节,脑电数据基本一包就能发完,省去了分包重组的麻烦。
BW16 的 Arduino 环境配置稍微有点绕。你需要先在 Arduino IDE 的板管理器里添加 Realtek 的板支持 URL,然后安装对应的核心包。安装完之后选板子要选RTL8720DN (BW16),烧录的时候按住 BOOT 键再按 RESET,进入下载模式。这个操作我一开始老是忘,后来在板子背面贴了个便签提醒自己。
2.3 ESP32-CYD 的屏幕与 BLE 客户端
ESP32-CYD 的屏幕是 2.4 寸 ILI9341 驱动,分辨率 320x240,SPI 接口。Arduino 环境下用TFT_eSPI库驱动,需要在库的配置文件里把引脚定义改成 CYD 的对应引脚。这块板子的引脚定义网上有现成的,直接抄就行,但要注意背光引脚是 GPIO21,触摸是 GPIO33 和 GPIO32,这些在配置里都要对上。
BLE 客户端这边,ESP32 用NimBLE-Arduino库比原生的 BLE 库更省资源。NimBLE 的扫描和连接流程很清晰:先扫描周围设备,找到 BW16 的 MAC 地址或者设备名,然后发起连接,再注册通知回调。回调函数里拿到数据后,直接丢给一个环形缓冲区,主循环再从缓冲区里取数据做显示和 Web 推送。这里有个细节:BLE 回调是在中断上下文里执行的,里面不能做耗时操作,所以缓冲区写入要快,显示和网络发送都放到主循环里做。
2.4 供电与接地注意事项
整条链路里,脑电模块对电源噪声非常敏感。我试过用同一个充电宝给 BW16 和脑电模块供电,结果波形上全是 50Hz 的工频干扰。后来改成脑电模块单独用一颗纽扣电池或者低噪声 LDO 供电,BW16 用另一路电源,干扰就小了很多。如果你发现波形上有规律的尖峰,先检查电源,再检查接地。脑电模块的 GND 和 BW16 的 GND 一定要共地,否则串口通信会不稳定。
3. 固件实现与核心代码解析
3.1 BW16 端:串口到 BLE 的透传逻辑
BW16 的固件逻辑很直白:初始化串口,初始化 BLE 服务,然后在主循环里不断检查串口缓冲区,有数据就通过 BLE 通知发出去。但这里有个性能问题:如果串口数据来得太快,主循环来不及处理,数据就会丢。我的做法是开一个256 字节的环形缓冲区,串口中断里把数据塞进缓冲区,主循环里再从缓冲区取出来发 BLE。这样即使 BLE 发送有延迟,串口数据也不会丢。
// BW16 串口中断回调 void onUartData() { while (Serial1.available()) { uint8_t b = Serial1.read(); ringBufWrite(&uartBuf, b); } } // 主循环里发送 BLE 通知 void loop() { if (ringBufAvailable(&uartBuf) >= PACKET_SIZE) { uint8_t packet[PACKET_SIZE]; ringBufRead(&uartBuf, packet, PACKET_SIZE); bleNotify(packet, PACKET_SIZE); } }BLE 通知的发送频率也要控制。脑电模块每秒发 100 个包,如果每个包都单独发一次 BLE 通知,射频会一直处于忙状态,功耗高而且容易丢包。我的做法是攒够 4 个包再发一次,这样 BLE 通知频率降到 25Hz,每次发 80 字节左右,既保证了实时性,又降低了射频压力。实测下来,从脑电模块采集到 ESP32 屏幕显示,端到端延迟大概在 80 到 120 毫秒之间,对于波形观察来说完全够用。
3.2 ESP32-CYD 端:BLE 接收与数据解析
ESP32 这边用 NimBLE 扫描并连接 BW16。连接成功后,注册通知回调,回调里把数据写入一个双缓冲区。一个缓冲区给屏幕刷新用,一个给 Web 服务用,避免两边抢数据。数据解析部分,因为脑电模块的帧格式是固定的,我直接在缓冲区里找帧头,找到后按固定长度取出一帧,校验通过后再提取有效载荷。
// NimBLE 通知回调 void onNotify(NimBLECharacteristic* c) { std::string value = c->getValue(); for (char ch : value) { ringBufWrite(&bleBuf, (uint8_t)ch); } } // 主循环里解析帧 void parseEEG() { while (ringBufAvailable(&bleBuf) >= FRAME_SIZE) { uint8_t frame[FRAME_SIZE]; ringBufPeek(&bleBuf, frame, FRAME_SIZE); if (frame[0] == 0xAA && frame[1] == 0x55) { // 校验通过,提取数据 int16_t sample = (frame[3] << 8) | frame[4]; eegBuffer.push(sample); ringBufSkip(&bleBuf, FRAME_SIZE); } else { ringBufSkip(&bleBuf, 1); // 帧头不对,滑动一个字节 } } }这里有个容易踩的坑:BLE 通知的数据长度不固定。虽然我协商了 MTU,但实际收到的通知长度可能因为底层分包而变化。所以解析的时候不能假设一次通知就是一帧,必须把收到的所有字节都塞进缓冲区,然后在缓冲区里做帧同步。我一开始就是假设一次通知一帧,结果偶尔会丢帧,波形上出现断点,后来改成缓冲区滑动匹配才解决。
3.3 屏幕波形刷新策略
ESP32-CYD 的屏幕刷新是性能瓶颈。320x240 的分辨率,如果每来一个数据点就重绘整个屏幕,帧率会掉到个位数。我的做法是只重绘波形区域,并且用TFT_eSPI 的 pushImage函数直接推像素,而不是用 drawPixel 一个个画。具体来说,我维护一个 320 点的波形数组,每次新数据进来就左移一位,然后只更新变化的那一列像素。
// 波形刷新 void updateWaveform(int16_t newSample) { // 数组左移 for (int i = 0; i < 319; i++) { waveBuf[i] = waveBuf[i + 1]; } waveBuf[319] = newSample; // 只重绘最后一列 int y = map(newSample, -2048, 2047, 0, 239); tft.drawPixel(319, lastY, TFT_BLACK); // 擦除旧点 tft.drawPixel(319, y, TFT_GREEN); // 画新点 lastY = y; }但这样有个问题:只画点不画线,波形看起来是断断续续的。后来我改成画竖线,把上一列的点和新列的点之间用线连起来,波形就连续了。再后来发现竖线画法在快速变化时会有锯齿,最终改成每 4 个点画一条线,牺牲一点时间分辨率换取视觉平滑度。这个取舍看你的应用场景,如果是要看波形细节,那就老老实实画点;如果只是看趋势,画线更好看。
3.4 Web 服务与前端页面
ESP32 起 Web 服务用WebServer 库就够了。我开了两个接口:/data返回最近 320 个点的 JSON 数组,/返回一个内嵌的 HTML 页面。页面里用 Canvas 画波形,每 100 毫秒 fetch 一次/data,拿到新数据后重绘。这个方案简单,但有个缺点:每次都要传 320 个点的完整数组,数据量大概 2KB 左右,在局域网里没问题,但如果想远程访问就有点浪费。
后来我改成了WebSocket方案。ESP32 上用ArduinoWebSockets 库,每来一个新数据点就推一次,前端只更新最后一个点。这样数据量小,实时性也更好。WebSocket 的握手和帧解析库都帮你做好了,你只需要在onMessage回调里处理连接和断开就行。前端页面我用的是原生 JavaScript,没有引入任何框架,整个 HTML 文件不到 5KB,直接存在 ESP32 的 Flash 里。
<canvas id="wave" width="320" height="240"></canvas> <script> const ws = new WebSocket('ws://' + location.host + '/ws'); const ctx = document.getElementById('wave').getContext('2d'); let x = 0; ws.onmessage = (e) => { const val = parseInt(e.data); const y = map(val, -2048, 2047, 240, 0); ctx.fillStyle = 'black'; ctx.fillRect(x, 0, 1, 240); ctx.fillStyle = 'lime'; ctx.fillRect(x, y, 1, 1); x = (x + 1) % 320; }; </script>这个前端逻辑很简单:每收到一个数据点,就在当前 x 位置画一个绿点,然后把 x 右移一位,到头了就从 0 重新开始。这样屏幕上会显示最近 320 个点的波形,滚动效果很自然。
4. 联调过程中的典型问题与排查
4.1 BLE 连接不稳定,频繁断开
这个问题我遇到的最多。表现是 ESP32 连上 BW16 之后,过几秒就断开,然后自动重连,循环往复。排查下来有几个原因:一是MTU 协商失败,BW16 和 ESP32 都支持大 MTU,但协商过程中如果有一方没响应,就会回落到默认的 23 字节,数据一多就超时断开。解决办法是在 ESP32 连接成功后主动调用bleClient->setMTU(247),并且检查返回值确认协商成功。
二是射频干扰。BW16 如果同时开了 WiFi 和 BLE,2.4G 频段会互相干扰。我的做法是 BW16 只开 BLE,WiFi 完全关掉。ESP32-CYD 这边因为要起 Web 服务,WiFi 必须开,但它的 BLE 只做客户端连接,数据量不大,干扰可以接受。如果你发现 ESP32 的 WiFi 和 BLE 同时工作时丢包严重,可以试试把 WiFi 信道固定在 1 或 11,避开 BLE 的广播信道。
三是电源噪声。BW16 在 BLE 发送瞬间电流会冲到 100mA 以上,如果供电不足,电压跌落会导致 BLE 复位。我在 BW16 的电源引脚旁边并了一颗 100uF 的电解电容和一颗 0.1uF 的陶瓷电容,断开问题就少了很多。
4.2 串口数据乱码或丢包
串口乱码最常见的原因是波特率不匹配。脑电模块的波特率有时候是 57600,有时候是 115200,还有的是 9600。你必须先确认模块的规格书,或者用串口助手试几个常见波特率,看哪个能读出正常数据。另外,BW16 的串口引脚如果和板载的 USB 转串口芯片共用,可能会有冲突。我建议把脑电模块接在 UART1 上,UART0 留给调试打印,这样互不干扰。
丢包问题通常是缓冲区太小。BW16 的默认串口缓冲区只有 64 字节,脑电模块一秒发 100 个包,每个包 10 字节,就是 1000 字节每秒,64 字节的缓冲区几毫秒就满了。解决办法是在Serial1.begin()之后调用Serial1.setRxBufferSize(512),把缓冲区加大。ESP32 这边同理,NimBLE 的通知回调里如果处理太慢,也会丢包,所以回调里只做缓冲区写入,不做解析。
4.3 屏幕刷新闪烁或撕裂
屏幕闪烁通常是因为刷新频率和屏幕扫描频率不同步。TFT_eSPI 库默认的 SPI 频率是 40MHz,如果你超频到 80MHz,屏幕可能会花屏。我的建议是保持默认频率,不要贪快。撕裂问题是因为你在屏幕正在扫描的时候更新了显存,解决办法是双缓冲:在内存里维护一个完整的屏幕缓冲区,更新完之后一次性推送到屏幕。但 ESP32 的内存有限,320x240x2 字节就是 150KB,加上其他开销,内存会很紧张。折中方案是只对波形区域做双缓冲,其他区域静态显示。
4.4 Web 页面延迟高或卡顿
Web 页面卡顿一般是数据推送频率太高。如果你每来一个数据点就推一次 WebSocket,一秒 100 次,浏览器渲染跟不上。我的做法是在 ESP32 这边做降采样,每 4 个点取一个,推给 WebSocket 的频率降到 25Hz,页面就流畅了。另外,前端 Canvas 的绘制也要优化,不要每帧都清空整个画布,只清空要更新的那一列。
还有一个坑是ESP32 的 WebServer 和 BLE 同时工作时的内存冲突。ESP32 的 RAM 本来就不大,BLE 协议栈占了一部分,WebServer 的缓冲区又占了一部分,如果再加上屏幕的显存,很容易内存不足导致重启。我的解决办法是把 WebServer 的缓冲区调小,并且把 HTML 页面存在 Flash 里而不是 RAM 里,用PROGMEM关键字声明。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| BLE 频繁断开 | MTU 协商失败 | 打印 MTU 值 | 主动 setMTU(247) |
| 串口乱码 | 波特率不匹配 | 换波特率试 | 确认模块规格书 |
| 波形有工频干扰 | 电源噪声 | 单独供电 | 加 LDO 和滤波电容 |
| 屏幕闪烁 | SPI 频率过高 | 降低频率 | 保持 40MHz |
| Web 页面卡顿 | 推送频率过高 | 降低推送频率 | 降采样到 25Hz |
| ESP32 重启 | 内存不足 | 打印剩余内存 | 减小缓冲区,用 PROGMEM |
5. 实测效果与可扩展方向
5.1 端到端延迟与稳定性实测
我把整条链路跑了一整晚,脑电模块贴在额头上,BW16 放在床头,ESP32-CYD 放在桌上连着充电器。实测下来,连续运行 8 小时没有断开,端到端延迟稳定在 100 毫秒左右。丢包率方面,我用序列号统计了一下,8 小时总共发了大概 288 万个包,ESP32 收到 287 万多个,丢包率不到 0.3%。这个丢包率对于波形观察来说完全可以接受,偶尔丢一两个点,波形上看不出来。
功耗方面,BW16 平均电流 15mA 左右,脑电模块 10mA,整条链路用一块 2000mAh 的电池能撑 10 小时以上。ESP32-CYD 因为要驱动屏幕和 WiFi,电流在 120mA 到 180mA 之间波动,用充电宝供电比较合适。
5.2 波形质量与去噪处理
原始脑电信号里混着不少噪声,主要是 50Hz 工频干扰和电极接触噪声。我在 ESP32 这边加了一个简单的滑动平均滤波,窗口大小 4,能滤掉一部分高频噪声。但滑动平均会引入相位延迟,如果你要做实时反馈,延迟会增加。更好的做法是用IIR 陷波滤波器,专门滤 50Hz,但计算量大一些。ESP32 跑 100Hz 采样率的 IIR 滤波完全没问题,我试过用 biquad 滤波器,CPU 占用率不到 5%。
如果你要做更复杂的处理,比如EEG 源定位的最小范数估计,那 ESP32 就算不过来了。这种计算量得放到电脑或者服务器上做。我的做法是 ESP32 只负责采集和显示原始波形,同时把数据通过 WebSocket 推给电脑,电脑上用 Python 做进一步分析。这样分工明确,ESP32 不会过载。
5.3 后续可以怎么扩展
这条链路目前只做了单通道,如果你手上有双通道或者四通道的脑电模块,BW16 的串口带宽完全够用,只要把数据包格式改一下,ESP32 这边多开几个波形缓冲区就行。屏幕可以分区域显示多个通道的波形,Web 页面也可以用多个 Canvas 并排显示。
另一个扩展方向是数据记录。ESP32-CYD 上有个 SD 卡槽,你可以把脑电数据以 CSV 格式写到 SD 卡里,方便后续离线分析。写卡的时候要注意,SD 卡的写入速度有限,如果采样率太高,可能会丢数据。我的建议是先把数据缓存在内存里,攒够一定量再批量写入,减少文件系统开销。
再一个就是触发反馈。如果你做的是注意力训练或者冥想辅助,可以根据脑电波形的特征(比如 alpha 波功率)触发一些反馈,比如屏幕变色、播放声音、或者通过 BLE 控制一个震动马达。ESP32 的 GPIO 很多,接个马达或者 LED 都很方便。
5.4 一些实操心得
最后分享几个我在调试过程中总结的小技巧。第一,先通链路再调细节。不要一上来就追求波形好看,先把串口数据读通,再把 BLE 连上,最后再搞屏幕和网页。每一步都确认数据是对的,再往下走。第二,多用串口打印。BW16 和 ESP32 都有多余的串口,把关键变量打印出来,比猜问题快得多。第三,电源要干净。脑电信号是微伏级别的,电源上的一点纹波都会体现在波形上。我试过用充电宝供电,波形上全是毛刺,换成电池供电就干净了。第四,不要怕改库。TFT_eSPI 和 NimBLE 这些库虽然好用,但默认配置不一定适合你的场景,该改就改,改完记得备份。
这条链路我前后折腾了大概两周,中间踩了不少坑,但跑通之后发现它的可玩性很高。你可以用它做睡眠监测、注意力训练、甚至简单的脑机接口实验。硬件成本加起来不到两百块,比买成品设备便宜太多了。如果你也在做类似的东西,希望这些经验能帮你少走点弯路。