1. 项目概述:这条无线脑电链路到底在做什么
几个月前我搭了一条无线 EEG 原型链路,一端是脑电模块,中间是 BW16,另一端是 ESP32-CYD 的屏幕和局域网内的网页。这条链路解决了我很长时间的痛点:想在人头上采集脑电波形,又不想被数据线绑死,更不想在采集端堆一堆笨重外设。用一句话概括就是:EEG 模块负责采集脑电信号,BW16 负责把信号无线发出去,ESP32-CYD 负责接收、显示和提供网页访问。说白了,就是一套「低成本的无线脑机接口原型」。
这套东西适合谁?想入门脑机接口的学生、做注意力监测小工具的创客、或者单纯想体验一把「用眼睛盯着屏幕看自己脑波形」的硬件爱好者。它不需要几千块的专业脑电设备,也不需要复杂的屏蔽房,普通实验室环境就能跑起来。我自己的应用场景是想做一套可穿戴的注意力监测 demo,脑袋上不想拖线,就选了无线方案。
当时选择这条路线的动机很直接:脑电信号是连续流式的,采集端无法断开连接,传统做法是直接把电极引线拉到放大器再进电脑,但那样人一动就全是运动伪迹。把采集端做成小节点、用无线把数据搬出去,既不影响信号质量,又能解放头部活动范围,后续想升级成头戴式设备也算留好了接口。更重要的是,这一条链路里压缩了完整的嵌入式数据链路知识点:传感器读取、协议封装、无线传输、接收端渲染、再到网页可视化,每一步都能独立拆出来讲。
2. 硬件选型:BW16 和 ESP32-CYD 各自顶什么用
2.1 BW16:专注采集的前端小无线节点
BW16 是 Realtek RTL8720DN 方案的开发板,双频 Wi-Fi、支持 Bluetooth LE,体积比 ESP32 小不少,价格也亲民。它在这条链路里扮演的是「前端采集节点」的角色:直接从脑电模块读数据,做最简单的校验和打包,然后通过 Wi-Fi 发出去。选择它而不是直接用 ESP32,原因有两个:第一,采集端要靠近头部,尺寸和重量尽量小,BW16 的板型比 ESP32 DevKit 苗条很多;第二,脑电信号本身极其微弱,前端主控的电源纹波和射频干扰越少越好,BW16 的射频部分和串口读取逻辑相对独立,干扰好控制一些。
当然,BW16 也有缺点:Arduino 生态不像 ESP32 那么完善,很多库要自己翻源码适配;串口只有一个硬件 UART,调试时要用 SoftSerial 或者干脆共用串口输出。因此在实际接线时,我把 BW16 的日志输出和 EEG 模块的数据读取分开,用硬件串口读 EEG,用软件模拟串口打印日志,这样两条数据流互不干扰。
2.2 ESP32-CYD:兼任屏幕、接收端和网页服务器的「三合一中控」
ESP32-CYD 指的是 ESP32-2432S028R,也就是常说的 Cheap Yellow Display,集成了 ESP32、2.8 英寸 ILI9341 触摸屏和一点扩展电路,二十几块钱一块,性价比很高。它在这条链路里是「显示中枢」:接收 BW16 无线送来的脑电数据,在屏幕上画波形,同时启一个 WebSocket 服务,让局域网里的手机或电脑打开网页实时查看。
选择 CYD 而不是单纯用 ESP32 加独立屏幕,纯粹是为了省事。ILI9341 这类屏用 TFT_eSPI 驱动很成熟,排线少,代码量低。CYD 上还带了触摸芯片 XPT2046,虽然这个项目暂时用不到触摸,但后续如果想加「点击屏幕记录事件标记」,硬件上已经有了基础。
2.3 接线表与供电注意事项
链路里的接线分两段:BW16 到 EEG 模块、ESP32-CYD 到电源与网络(无线,不用接网线)。下面是我实际用的引脚分配:
| 连接位置 | 引脚/接口 | 说明 |
|---|---|---|
| 脑电模块 TX | BW16 A0(RX) | 读取脑电原始数据流 |
| 脑电模块 GND | BW16 GND | 共地,必须同参考 |
| 脑电模块 VCC(3.3V) | BW16 3V3 | 供电,注意电流 |
| ESP32-CYD 屏幕 | 板载已连接 | ILI9341 SPI 接口 |
| 外部电池 | ESP32-CYD 5V 脚 | 建议独立供电 |
注意:脑电模块务必和 BW16 共地,这是无线链路里最容易忽略的坑。共地不稳,串口数据全是乱码,而且脑电信号本身是微伏级,如果前端供电纹波过大,波形上会出现明显的周期性干扰。
供电上我有一个很深的教训:一开始用同一个充电宝同时给 EEG 模块和 CYD 供电,波形上立刻出现 12Hz 左右的振荡尾巴,查了半天是充电宝的开关管噪声通过地线串了进来。后来改成 EEG 模块用独立的 3.3V LDO 供电、CYD 单独用 5V 适配器,干扰立刻小了很多。做生物电相关的东西,电源隔离几乎是必须考虑的问题。
3. 采集端设计:EEG 数据打包与无线发送
3.1 脑电模块输出的是什么,为什么不能直接塞进 TCP
市面上的低成本脑电模块输出一般分两类:一类直接输出原始脑电波形采样,另一类输出经过芯片处理的专注度、冥想度等参数。我这里用的是能输出原始采样值的模块,通常以固定波特率通过 UART 持续吐数据流。
要理解为什么不能直接把这个串口数据塞进 Wi-Fi,得先看数据形态:原始脑电采样率一般是 512Hz,每个点可能是 16bit 或 24bit 的小端整数,加上模块自己的 FIFO 缓冲和校验位,数据流不是严格等间隔的。如果直接用 TCP 或 UDP 一包一包转发,接收端很难判断每一包是哪一段时域数据,很容易丢失对齐信息。更现实的问题是,Wi-Fi 传 UDP 本身就可能丢包,接收端必须能根据包序号知道丢了多少点,才能决定是补零还是丢弃。
我的做法是在 BW16 上做一层轻量封装,把连续的脑电数据切成固定大小的数据块,每块加上帧头、序列号、采样计数和 CRC 校验,再走 UDP 发出去。这样接收端即使丢了几包,也能明确知道丢在哪里,不至于造成数据错位。
3.2 自定义协议帧与 BW16 端实现
我定义的帧结构很简单,16 字节定长:
| 偏移 | 长度 | 含义 |
|---|---|---|
| 0 | 2 | 帧头 0xAA55 |
| 2 | 2 | 数据包序号 |
| 4 | 2 | 块起始采样计数 |
| 6 | 8 | 8 个脑电采样点(每个 16bit) |
| 14 | 2 | 对前 14 字节的 CRC16 |
这里把 8 个采样点打包为一个 UDP 包,是因为脑电数据量不大,没必要用大包,小包反而能降低单次丢包的影响。BW16 端核心代码形如:
void loop() { while (serialEEG.available() >= 2) { int16_t sample = serialEEG.read() | (serialEEG.read() << 8); if (fifoIndex < 8) { fifo[fifoIndex++] = sample; } else { sendEEGFrame(fifo, 8); fifoIndex = 0; frameSeq++; } } }这段代码看似简单,但有一个关键点是 BW16 的硬件串口缓冲很小,如果主循环稍有延迟就可能丢字节。因此我开了串口空闲中断,把脑电数据直接搬进 RingBuffer,再在循环里消费 RingBuffer,保证高负荷时不会丢字节。
3.3 采样率、丢包阈值与调参心得
默认 512Hz 的脑电采样,意味着每秒钟 BW16 要发约 64 个 UDP 包。在局域网环境,这个包量对 BW16 是零压力,真正要关注的是实时性:每一包从脑电模块到 CYD 屏幕的端到端延迟大概在 10ms 以内,这里包括了串口读取时间、打包时间和 Wi-Fi 传输时间。
我测试过不同的丢包率对波形的影响:低于 1% 的丢包,屏幕上基本看不出明显断点,只要把缺失点做简单的线性插值即可;超过 5%,波形会出现毛刺和跳变,这时候应该默认丢弃那一段而不是强行补值,否则会让频谱分析结果完全不可信。所以我在协议里特意放了「块起始采样计数」,接收端可以用它检测连续性和丢失长度。
实操心得:如果你后续要做脑电特征提取,比如算 alpha 波功率,千万不要用插值后的数据去做频谱分析。插值会引入低频分量,让结果失真。宁可标记出一个时间段内的丢包数,在算法里剔除这段数据,也不要缝缝补补。
还有一个经常被忽略的细节:Wi-Fi 的省电模式默认会周期性休眠,导致发送延迟抖动。我在 BW16 上关闭了 WLAN 省电模式,实测端到端延迟抖动从最高 30ms 降到 5ms 以内。代价是功耗上升,对固定供电的 bench demo 无所谓,但对以后做电池供电的穿戴设备就要重新取舍。
4. 接收端到屏幕与网页:ESP32-CYD 的发挥
4.1 接收与缓存策略:不让波形一卡一卡
ESP32-CYD 端要干三件事:收 UDP 包、解码、画屏幕。这三件事如果在一个 while 循环里顺序执行,一旦屏幕绘制耗时过长,UDP 缓冲区就会被填满,丢包率直线上升。
我的做法是在 CYD 上开一个 UDP 接收任务(FreeRTOS Task),专门负责收包、解帧和把解出的采样点写入环形缓存;主循环只负责从缓存取数据画屏幕。接收任务的优先级设得比主循环高,但允许被系统任务抢占。环形缓存的容量设成 4096 个采样点,也就是 8 秒的数据,这样即使屏幕偶尔卡一下,也不会立即丢数据。
void udpTask(void *param) { uint8_t buf[16]; while (1) { int len = udp.parsePacket(); if (len == 16) { udp.read(buf, 16); if (buf[0] == 0xAA && buf[1] == 0x55) { uint16_t seq = (buf[2] | (buf[3] << 8)); // decode 8 samples and push to ring buffer for (int i = 0; i < 8; i++) { int16_t s = (int16_t)(buf[6 + i * 2] | (buf[7 + i * 2] << 8)); ring.push(s); } lastSeq = seq; } } vTaskDelay(1); } }这个任务模型是整条链路的骨架。没有它,屏幕刷新时 CPU 被 TFT_eSPI 的 SPI 传输占满,UDP 包就只能被动丢弃,表现就是波形频繁断线。
4.2 ILI9341 屏幕波形绘制的关键细节
CYD 自带 2.8 寸 ILI9341,分辨率为 320x240。画脑电波形最基础的方法是:每收到一个新采样点,把旧点擦掉、新点画上。但这种方式在 512Hz 采样率下有两个问题:一是每秒钟要刷新 512 次,SPI 总线根本忙不过来;二是 EEG 波形本来就在均值附近快速抖动,逐点绘制会非常闪烁。
我改成了「分块滚动绘制」:把屏幕横向分成 320 个像素列,每一列代表一个时间切片,缓存里每积累 16 个采样点,就计算这一列的平均值或极值,画成一个竖线。这样屏幕一屏显示约 320×16 / 512 ≈ 10 秒的波形,视觉上非常稳,刷新压力也小很多。
绘制时用了 TFT_eSPI 的 pushImage 局部刷新,只更新当前列之前的 1 像素宽区域,实测屏幕刷新率能到 30fps 左右,不会出现明显的撕裂感。波形颜色我用绿色画原始信号,再用红色画一个滑动平均的包络线,方便直观看到 alpha 波的起伏。
4.3 网页端实时显示方案
屏幕只是本地显示,网页端才是真正方便分享的部分。Espressif 官方提供了 WebSocket 库,我在 CYD 上挂了一个轻量 HTTP 服务,根路径返回一个 HTML 页面,页面里的 JavaScript 通过 WebSocket 与 CYD 保持连接,实时接收脑电采样值并在 Canvas 上绘制波形。
这里有一个选择:是让网页端直接从 BW16 拿数据,还是从 CYD 中转?我选择了从中转,原因有两个:一是 BW16 端的资源非常紧张,再挂一个 WebSocket 服务会影响采集稳定性;二是 CYD 上已经有完整的数据通路,中转只需要多一次内存拷贝,非常轻量。
网页端 JavaScript 的核心大约是:
const ws = new WebSocket('ws://192.168.1.100/ws'); const buffer = []; ws.onmessage = (e) => { const data = new Uint8Array(e.data); const samples = new Int16Array(data.buffer, 6, 8); // skip header for (let i = 0; i < samples.length; i++) { buffer.push(samples[i]); } if (buffer.length >= 32) { drawWave(buffer.splice(0, 32)); } };页面里我用 Canvas 把采样值映射到 0~255 的像素高度,再顺着 x 轴画折线。最原始的方案大概 80 行代码就能跑通。网页端虽然在视觉效果上没有屏幕端细腻,但它有一个不可替代的价值:任何人只要拿到局域网地址,打开浏览器就能看到实时波形,不需要安装任何驱动或 IDE,这对展示 demo 非常有用。
5. 踩坑实录:从信号噪声到网页卡顿的处理
5.1 常见问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 波形出现周期性 50Hz 干扰 | 供电纹波或地环路 | 独立 LDO 供电,共地单点 |
| 屏幕波形频繁断线 | UDP 接收任务被屏幕刷新阻塞 | 将 UDP 接收拆成独立任务,提高优先级 |
| BW16 偶发乱码 | 串口波特率不匹配或共地不稳 | 先测模块实际输出波特率,再确认共地 |
| 网页打开后波形卡顿 | WebSocket 消息频率过高 | 在服务端聚合采样点,降低 ws 发送频率 |
| 数据丢包率 >5% | Wi-Fi 干扰或距离过远 | 换 5GHz 频段(BW16 支持),或靠近路由器 |
| 画波形时屏幕闪烁 | 全屏刷新太频繁 | 改成分块滚动 + pushImage |
这些坑里大部分不是「理论没学到位」,而是现实的嵌入式系统问题:优先级设置、供电隔离、不同模块间的时序配合。我第一次调通整条链路时,屏幕和网页都有波形,但两者的波形差了几百毫秒,排查了很久才发现是 CYD 的 WebSocket 服务端默认每收到一包就转发一包,而屏幕端是攒了 16 个点才画一次,导致时间基准不同。后来统一为「CYD 内部先攒 16 个点,再同时输出到屏幕和 WebSocket」,两者就完全同步了。
5.2 三个最影响效果的隐藏细节
第一个是脑电模块的参考电极位置。很多初次尝试的人把参考电极随便夹在耳垂或手腕上,波形看起来全是噪声。实际上参考电极的接触阻抗要尽量低,最好涂导电膏,否则共模干扰会被放大器放大,整条链路调得再好也没用。
第二个是 Wi-Fi 信道选择。如果整套链路在办公室或实验室跑,周围 2.4GHz 设备非常多。我把路由器的 Wi-Fi 信道固定到 9 或 13(具体看周边占用情况),再配合 BW16 的 5GHz 频段,丢包率明显下降。CYD 的 Wi-Fi 只有 2.4GHz,所以如果路由器开了双频合一,我通常建议把 IoT 设备单独放进「仅 2.4GHz」的 SSID,避免频段切换导致的闪断。
第三个是 CYD 的电源电流。CYD 板载的 AMS1117 稳压器在屏幕全亮加 Wi-Fi 传输时发热明显,长时间工作会烫手。我最终给 CYD 加了散热片,并且把屏幕背光从 100% 调到了 80%,实测温度降了 10 摄氏度左右。这不是功能性 bug,但直接影响长期稳定运行。
5.3 从 EEG 到屏幕的「时间对齐」经验
脑电数据分析里,时间对齐是绕不开的话题。屏幕上显示波形是即时的,但网页端因为要走 WebSocket,数据到达浏览器时和屏幕端可能不在同一时刻。我在协议帧里放了采样计数,接收端就能算出每包的绝对时间戳:设第一个采样点的时间为 T0,后续每个点的时间是 T0 + 采样点序号 × (1000/采样率)。
如果后续想在这个原型上加源定位或者最小范数估计,就必须依赖精确的时间对齐。我的建议是:从一开始就在协议里带上采样计数,而不是单纯靠「收到包的时刻」判断数据时间。后者在丢包、重传和网络抖动时会产生不可控的时间误差。
6. 一点经验总结
整条链路跑通后,我最深的感受是:脑电项目最大的门槛,其实不是算法或硬件选型,而是把「微伏级模拟信号」和「不可靠的无线信道」可靠地接起来。模拟端要做屏蔽和隔离,无线端要做协议封装和丢包容忍,显示端又要考虑刷新率和时间同步,任何一个环节崩溃都会让最终波形惨不忍睹。
我个人在实际操作中的建议是:先别急着上网页、上复杂功能。第一次搭建时,先保证 BW16 到 CYD 的局域网无线传输是通的,用串口监视器直接看波形数据;第二步再接到屏幕;最后才做网页。分阶段推进,你会发现每加一层,问题定位的范围就小很多。
如果你也想搭类似的无线 EEG 原型链路,预算大概在两三百块以内,核心器件就是 BW16、ESP32-CYD 和一个脑电模块。后续想扩展的话,可以在 CYD 上加 MicroSD 卡记录原始数据,或者再引入一个小型算法模块做实时 alpha 波功率计算。这条链路本身不算精致,但作为「从传感器到屏幕再到网页」的完整原型,它已经把脑电项目里最基础的传输和展示问题都走了一遍,剩下的就是往某个方向继续深挖了。