从电极到浏览器:基于BW16与ESP32-CYD的无线EEG实时波形链路搭建
年初我手头同时到了两块板子:一块是安信可的 BW16,另一块是带 2.8 寸 TFT 屏的 ESP32-CYD。原本只是打算各自跑跑 demo,后来一个念头冒出来:能不能让它们合作,把一路脑电信号从传感器上取下来,经过无线传输,既显示在 ESP32-CYD 的屏幕上,又同步推到手机浏览器的网页里实时画波形?于是就有了这条藏在桌面乱线堆里的 EEG 原型链路。花了一个周末打通,整个过程暴露出来的坑不算少,但把逻辑理顺之后,会发现它其实是一条很规矩的数据管道。
这篇文章就是完整的搭建记录。我会从选型逻辑、数据流设计、固件思路、网页可视化到实测踩坑,一条条讲清楚。无论你是刚接触生物电采集的硬件爱好者,还是想在嵌入式设备上做实时 Web 可视化的人,都能在这条链路里找到值得拿走的东西。
1. BW16 与 ESP32-CYD 的选型逻辑:为什么这条链路这样搭
1.1 采集端用 BW16:低功耗、双频 Wi-Fi、够用的外设
先说说为什么采集端选了 BW16。BW16 是瑞昱 RTL8720DN 方案的模组,双核 Cortex-M4F,主频 125MHz 左右,支持 2.4G/5G 双频 Wi-Fi 和 BLE 5.0。这个芯片放在 EEG 采集场景里有几个天然优势。
第一个是低功耗。脑电采集往往要贴着头部或者戴在头上,如果用一块开发板拖着 USB 线跑,那纯属自找麻烦。BW16 支持多种低功耗模式,射频关掉之后电流能压到很低,后面接锂电池或者纽扣电池都有余量。第二个是外设接口够用。EEG 模块一般输出模拟电压或者串口数据,BW16 的 ADC、UART、SPI、I2C 都能接,不用像某些小模块那样还得靠软串口硬凑。第三是无线能力不弱。RTL8720DN 的 Wi-Fi 吞吐在几十 Mbps 级别,传一路 16bit、256Hz 采样的 EEG 数据,连零头都用不到,但它胜在协议栈完整、丢包重连逻辑能自己控制。
我实测下来,BW16 作为采集节点有一个容易被忽略的优点:它可以在 Wi-Fi 和 BLE 之间灵活切换。如果你不想依赖路由器,完全可以把 BLE 通道打开,让手机直接接收数据。不过这条链路里我用了 Wi-Fi,原因是 ESP32-CYD 要同时充当 Web 服务器,走同一局域网可以把数据广播和网页访问合并到一张网里,整体架构最干净。
1.2 接收端用 ESP32-CYD:一块低成本的带屏网关
ESP32-CYD 这个名字你可能听着陌生,它全称是 ESP32-2432S028R,因为那块黄色的 PCB 在圈子里得了个外号叫 Cheap Yellow Display。它本质上是一块集成了 ESP32 主控和 2.4 英寸电阻触摸屏的小板子,屏幕分辨率 320x240,主控是经典的 ESP32 双核 240MHz。
为什么接收端选它而不是另一块 BW16?核心原因有三个。
第一,ESP32 的内存和 Flash 更大,跑 Web 服务器不吃力。BW16 的 RAM 相对局促,又当采集端又当服务器会非常紧张,而 ESP32 的 320KB RAM 加上 4MB Flash,足够塞下一个小型 HTTP 服务器、TCP 客户端连接池和波形绘图缓冲。第二,板载屏幕让调试效率翻倍。数据通没通、波形长什么样、信号有没有饱和,抬眼就能看到,根本不用每次打开串口监视器。第三,ESP32 的 Arduino 生态极其成熟,TFT_eSPI 库驱动这块屏幕只需要改一个配置文件,WebServer 库又是官方自带的,上手门槛比你想的低很多。
所以这条链路的分工很明确:BW16 负责靠近电极采集信号,ESP32-CYD 负责收数据、画屏幕、开网页服务。两个低成本的模组拼在一起,完成了一条完整的数据管道。
1.3 链路全景:EEG 电极到浏览器页面要经过哪几步
在动手之前,先把整条链路的逻辑写在前面,后面所有操作都不会跑偏。
EEG 电极采到的微伏级信号,先经过前端放大和滤波,变成可被 ADC 采样的电压信号。BW16 以固定采样率读取这个电压值,加上帧头、序号和校验字节,打包成 UDP 数据包发到局域网。ESP32-CYD 在同一个局域网里监听固定端口,收到 UDP 包之后解析出原始幅值。这个幅值有两路去向:一路直接画在板载 TFT 屏幕上,一路暂存在内存环形缓冲区里。手机或电脑浏览器访问 ESP32-CYD 的 IP 地址,网页通过 HTTP 轮询接口每隔 100ms 拉取缓冲区里的最新数据,然后用 Canvas 画成波形。
整个过程没有云服务器,没有 MQTT broker,就是两个设备加一个浏览器。最妙的是,无论屏幕端还是网页端,读到的都是同一份数据源,所以你可以一边盯着板子上的波形,一边看手机浏览器里的动态曲线,两者的趋势完全一致。这为后面做算法验证打下了很好的基础。
2. 链路设计中的四个关键取舍
2.1 脑电信号接入:串口模块还是模拟前端?
EEG 信号本身的特征决定了接入方式不能随便选。头皮上测到的脑电信号幅度一般在 10 到 100 微伏,频率主要集中在 0.5Hz 到 50Hz 之间,周围环境里的 50Hz 工频干扰往往比信号大好几个数量级。这种量级的信号,如果直接怼进单片机的 ADC,你读到的基本是噪声。
所以第一步要解决信号调理。目前常见的做法有两种。
第一种是使用集成了放大滤波甚至 ADC 的串口输出模块。这类模块内部已经把模拟前端做完了,通过 UART 输出数字化的原始数据或者压缩后的波形值,单片机只需要负责读取和转发。上手最简单,适合第一次搭 EEG 原型的人。我在这个项目里用的就是这种方式,EEG 模块内部完成前置放大、带通滤波和 24bit ADC 采样,输出的是数字格式的数据帧。
第二种是自己搭模拟前端,用仪表放大器加运放滤波器,再把信号送进 BW16 的片内 ADC。这种方式自由度最高,但对硬件功底要求高,任何一个环节接地处理不好,波形都会被噪声吞掉。如果你是第一次接触生物电信号,我建议先走串口模块路线,把无线链路和网页可视化跑通,等有了经验再去折腾模拟前端。
2.2 无线传输协议:UDP 才是实时波形的好朋友
无线传输协议的选择是这条链路里最核心的取舍之一。很多人一上来就会想用 TCP,因为 TCP 可靠、不会丢包。但实时波形场景下,TCP 的可靠有时候反而是负担。
TCP 为了保证数据包不丢不重,会在丢包时触发重传。重传意味着什么?意味着后面的数据包要排队等待,网络稍一波动,整条波形就会周期性卡顿。对于 EEG 这种连续采样、天然容忍少量丢点的数据,TCP 的可靠性其实是过度保障。
我最后选了 UDP,并且自己做了一层轻量校验。每次发送的数据包很小,只有几个字节,即使丢了 1% 的包,反映在波形上也只是个别采样点的横向缺失,肉眼根本分不出来。更重要的一点是,UDP 没有连接状态,ESP32-CYD 不需要为每一个采集节点维护 TCP 连接,这对嵌入式设备的内存和 CPU 都非常友好。
这里可以顺带算一笔账:单通道 EEG,采样率 256Hz,每个采样点 16bit,每秒产生的原始数据只有 512 字节。哪怕把协议开销翻一倍,也才 1KB/s。Wi-Fi 的带宽对这个量级毫无压力,真正的瓶颈从来不是带宽,而是协议设计的复杂度和节点的处理能力。
2.3 数据包格式:小帧、序号、CRC 一个都不能少
有了 UDP 这个传输底座,数据包格式就得自己设计了。我用的数据帧非常简单,结构如下:
| 字段 | 长度 | 含义 |
|---|---|---|
| 帧头 | 2 字节 | 固定为 0xA5 0x5A,用于同步 |
| 序号 | 1 字节 | 0~255 循环递增,用于检测乱序和丢包 |
| 采样值高字节 | 1 字节 | 16bit 采样值的高 8 位 |
| 采样值低字节 | 1 字节 | 16bit 采样值的低 8 位 |
| 状态位 | 1 字节 | 携带信号饱和、电极脱落等标志 |
| CRC8 | 1 字节 | 对前 6 字节做简单 CRC 校验 |
这个包总共 7 字节,加上 UDP 头也不过 35 字节左右。放在 256Hz 采样率下,每秒发送 256 个这样的包,网络开销也就 9KB/s,完全可以接受。
序号字段一开始觉得多余,后来实测发现是必须的。Wi-Fi 环境下偶尔会出现包乱序,如果没有序号,你没法判断当前画的数据是否比上一个帧更旧。CRC 校验同样不能省,嵌入式环境里电磁干扰可能导致个别字节跳变,一个粗校验就能过滤掉绝大多数坏帧。
2.4 网页实时显示:为什么用 HTTP 轮询而不是 WebSocket
网页端的数据展示是这条链路里最有意思的部分。ESP32-CYD 既要画屏幕,又要当 HTTP 服务器,开发资源非常有限。很多教程会推荐 WebSocket,实时性好、全双工,但代价是嵌入式端要维护长连接、解析 WebSocket 帧、处理心跳,代码量和内存占用都不小。
我在项目里选择了一个“土但有效”的方案:HTTP 轮询。ESP32-CYD 只暴露两个接口,根路径返回 HTML 页面,/latest 接口返回最近 64 个采样点的数组。前端 JavaScript 每 100ms 用 fetch 拉一次数据,然后直接画到 Canvas 上。
轮询的延迟是 100ms 级别,对脑电波形观察完全够用。EEG 主要能量集中在 0.5Hz 到 50Hz,100ms 的刷新间隔相当于每秒更新 10 次画面,已经能清楚看到 alpha 波的起伏轮廓。而且 HTTP 轮询不占长期连接,ESP32 的 WebServer 库处理完一次请求就释放连接,内存管理压力小很多。
如果你后续想把刷新率提到 30fps 以上,可以考虑升级为 SSE 或者 WebSocket,但作为原型验证,轮询方案是我认为性价比最高的选择。
3. 完整实操记录:从接线到网页画波形
3.1 硬件接线与供电,先把地线处理好
硬件接线是整个项目最枯燥也最容易出错的一步。我的连接方式是这样:EEG 模块的信号输出接 BW16 的 UART 串口,模块供电用 3.3V,信号地和 BW16 的地必须共地。如果是模拟输出,则把输出脚接到 BW16 的 ADC 引脚。
这里有一条必须强调的经验:生物电采集最忌讳地线环路。EEG 模块如果同时接了 USB 供电和外部电池,可能会形成两个参考地,导致工频干扰暴涨。我最早用 USB 给 BW16 供电,波形里 50Hz 噪声几乎把信号完全淹没,后来改成锂电池独立供电,噪声立刻下去了。浮地供电对抑制工频干扰的效果,比你加再多滤波算法都明显。
ESP32-CYD 端的供电相对省心,我直接用 USB 或者充电宝供电。两块板子之间没有任何物理连接,完全靠 Wi-Fi 通信,这保证了采集端可以真正脱离地面电网独立工作。
3.2 BW16 端固件:定时采样、打包、UDP 发送
BW16 端用 Arduino 框架开发,核心流程是初始化串口和 Wi-Fi,然后在主循环里读取 EEG 模块的数据帧,加头加序号之后通过 UDP 发出去。
下面是这个项目的核心代码片段,你可以直接参考:
#include <WiFi.h> #include <WiFiUdp.h> const char* ssid = "EEG_Lab"; const char* pass = "12345678"; const IPAddress serverIP(192, 168, 1, 100); // ESP32-CYD 的 IP const unsigned int serverPort = 2199; WiFiUDP udp; uint8_t frameCount = 0; void setup() { Serial1.begin(57600); // 接 EEG 模块的 UART WiFi.begin(ssid, pass); while (WiFi.status() != WL_CONNECTED) { delay(100); } udp.begin(2198); } void loop() { // 读取 EEG 模块输出的 4 字节:帧头、高字节、低字节、状态位 if (Serial1.available() >= 4) { uint8_t h = Serial1.read(); if (h == 0xA5) { uint8_t data[7]; data[0] = h; data[1] = frameCount++; data[2] = Serial1.read(); // 采样值高字节 data[3] = Serial1.read(); // 采样值低字节 data[4] = Serial1.read(); // 状态位 data[5] = 0; // CRC 占位 for (int i = 0; i < 5; i++) { data[5] ^= data[i]; // 最简单的异或校验 } udp.beginPacket(serverIP, serverPort); udp.write(data, 6); udp.endPacket(); } } }这段代码有几个细节值得说明。第一,串口波特率要跟 EEG 模块匹配,常见的模块有 57600 和 115200 两种,接上之后先拿串口监视器看一眼输出再往下走。第二,CRC 我用的是异或校验,虽然不如 CRC8 严谨,但足够过滤掉绝大多数单比特错误。第三,如果 EEG 模块本身输出的就是完整数据帧,那你完全可以跳过解析,直接把帧尾加上序号和校验转发出去,代码会更简单。
Wi-Fi 连接部分要提醒一句:BW16 连接路由器需要一点时间,我在 setup 里用了阻塞等待,实际项目中如果对功耗敏感,可以改成超时重连机制。但原型阶段怎么简单怎么来。
3.3 ESP32-CYD 端:接收解析、屏幕描点与 HTTP 服务
ESP32-CYD 端的代码要干三件事:接收 UDP 数据、把采样值画到屏幕上、提供 HTTP 接口给网页拉数据。
这里我直接用官方 WebServer 库,TFT 屏幕用 TFT_eSPI 驱动。TFT_eSPI 在使用前需要修改 User_Setup.h 文件,把屏幕驱动型号改成 CYD 对应的那款,常见是 ILI9341 或者 ST7789,引脚定义也要按照板子标注填好。很多新手在这里卡住,其实只要在库目录里找到 User_Setup.h,搜索你的屏幕型号并取消注释即可。
核心代码框架如下:
#include <WiFi.h> #include <WebServer.h> #include <WiFiUdp.h> #include <TFT_eSPI.h> const char* ssid = "EEG_Lab"; const char* pass = "12345678"; WebServer server(80); WiFiUDP udp; TFT_eSPI tft; #define HISTORY_LEN 64 uint16_t history[HISTORY_LEN]; int historyIndex = 0; int historyCount = 0; void addSample(uint16_t v) { history[historyIndex] = v; historyIndex = (historyIndex + 1) % HISTORY_LEN; if (historyCount < HISTORY_LEN) historyCount++; } void drawWave(uint16_t v) { static uint32_t x = 0; uint16_t y = map(v, 200, 800, 220, 20); tft.drawPixel((x / 2) % 320, y); x++; } void handleLatest() { String body = ""; for (int i = 0; i < historyCount; i++) { if (i) body += ","; body += String(history[(historyIndex + i) % HISTORY_LEN]); } server.send(200, "text/plain", body); } void setup() { WiFi.begin(ssid, pass); while (WiFi.status() != WL_CONNECTED) delay(100); udp.begin(2199); server.on("/", []() { server.send(200, "text/html", PAGE_HTML); }); server.on("/latest", handleLatest); server.begin(); tft.init(); tft.setRotation(1); } void loop() { server.handleClient(); int packetSize = udp.parsePacket(); if (packetSize >= 6) { uint8_t buf[6]; udp.read(buf, 6); if (buf[0] == 0xA5) { uint16_t raw = (buf[2] << 8) | buf[3]; addSample(raw); drawWave(raw); } } }注意 tft.drawPixel((x / 2) % 320, y) 这行,我做了简单的横向缩放,让波形在 320 像素宽的屏幕上以两像素为步进移动。如果你觉得屏幕上的波形移动太快或太慢,可以调整这个除数。实际跑起来,屏幕会从左到右一列列画出波形,视觉上就像一台迷你脑电图机。
3.4 浏览器端:用原生 JavaScript 画实时曲线
网页端没有引入任何框架,一个 HTML 文件加几十行 JavaScript 就够用了。核心思路是每隔 100ms 请求一次 /latest 接口,拿到逗号分隔的采样值数组,然后逐个绘制到 Canvas 上。
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>BW16 EEG Monitor</title> </head> <body> <canvas id="c" width="600" height="300" style="background:#000;width:100%"></canvas> <script> const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); const W = canvas.width; const H = canvas.height; let x = 0; async function update() { try { const resp = await fetch('/latest'); const text = await resp.text(); const values = text.split(','); for (let i = 0; i < values.length; i++) { const v = Number(values[i]); const y = H - ((v - 200) / 600) * H; ctx.fillStyle = '#0f0'; ctx.fillRect(x, y, 2, 2); x = (x + 1) % W; } } catch (e) { // 网络还没准备好,等一下轮询 } setTimeout(update, 100); } update(); </script> </body> </html>这段代码里的 y 坐标映射关系跟屏幕上的一致,都假设信号范围在 200 到 800 之间。这个范围取决于你 EEG 模块的输出量程,实测后如果波形顶到天花板或者压成一条直线,改 map 上下限就行。
用手机浏览器打开 ESP32-CYD 的 IP 地址,就能看到和板载屏幕几乎同步的波形曲线。由于历史数据缓冲了 64 个点,网页端刷新时会先补一段最近的数据,不会出现白屏等待的尴尬。
4. 实测效果与避坑清单
4.1 无线传输丢包与队列积压
我最初跑起来的时候,屏幕上波形每隔几秒就会出现一条断线或者毛刺。分析后定位到两个原因。
第一个原因是 Wi-Fi 环境干扰。我的路由器同时连着十几台设备,2.4GHz 频段拥塞严重。后来把 BW16 和 ESP32-CYD 都切到 5GHz 频段,丢包率肉眼可见地下降。BW16 支持双频的优势在这里体现得很充分。
第二个原因是 UDP 发送频率过高导致接收端处理不过来。我一开始把采样率设成 512Hz,每 2ms 发一个包,ESP32-CYD 在 handleClient 和 UDP 接收之间切换,偶尔会错过几个包。解决办法是降低采样率到 256Hz,或者在 ESP32-CYD 端把 UDP 接收改成中断驱动。原型阶段降采样率最直接,数据量小一半,波形时域轮廓几乎看不出差别。
4.2 内存告急:ESP32 的 Web 服务要轻薄
ESP32 的内存虽然比 BW16 大,但也经不起折腾。我一开始想在前端页面里塞入一个画网格的背景图和一些图表库,结果页面还没加载完,ESP32 的堆内存就被字符串拼接吃掉了不少。
解决思路有两条。第一,HTML 页面不要用模板字符串里嵌大段 CSS,尽量写精简内联样式。第二,/latest 接口返回的字符串不要无限增长,我的 HISTORY_LEN 控制在 64,每次最多返回 64 个数,算下来几百字节,完全在安全范围。
还有一个容易被忽略的点:WebServer 的 client 连接在 handleClient 之后会自动释放,但如果前端页面频繁刷新,旧的 TCP 连接会进入 TIME_WAIT 状态,长时间操作后 ESP32 的连接表会慢慢占满。实际使用中,你不需要频繁刷新页面,Canvas 的动画已经足够实时。
4.3 信号里的工频干扰和运动伪迹怎么处理
当你第一次看到原始波形,八成会失望:波形里全是毛刺,几乎看不出脑电特征。这时候要做的不是加数字滤波,而是先检查硬件端。
电极贴合位置、皮肤清洁度、是否使用导电膏,这些因素对信号质量的影响远大于算法。我实测中最立竿见影的一步,是在 EEG 模块的参考电极和右腿驱动电极上涂了导电膏,工频干扰立刻降了一个量级。
硬件优化之后,如果还想让波形更平滑,可以在固件里做一个很简单的预处理。比如对连续三个采样点取平均,等效于一个低通滤波,能将高频噪声压下去不少。更专业的做法是加 50Hz 陷波器和 0.5Hz 高通滤波器,但这两者在 8 位单片机上的实时实现需要小心处理溢出。对于原型演示,滑动平均已经能获得相当干净的曲线了。
4.4 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 屏幕无波形 | ESP32-CYD 没收到 UDP | 检查两块板子是否在同一网段,IP 是否写对 |
| 波形全是竖线毛刺 | 工频干扰严重 | 改用锂电池供电,检查电极贴合,使用导电膏 |
| 网页白屏 | IP 地址输错或端口没开放 | 浏览器直接访问 http://ESP32_IP/ ,确认 LED 指示灯 |
| 波形横向移动太慢 | history 缓冲太小 | 增大 HISTORY_LEN,或降低横向步进除数的分母 |
| 每几秒出现断线 | Wi-Fi 拥塞 | 切换到 5GHz 频段,或降低 UDP 发送频率 |
| 屏幕显示花屏 | TFT_eSPI 引脚配置错误 | 检查 User_Setup.h,确认屏幕型号和引脚定义 |
| 采集端频繁掉线 | 路由器连接数限制 | 改用静态 IP 配置,关闭其他高带宽设备 |
5. 可以从原型进一步扩展的方向
5.1 多通道同步采集与离线源定位
这条链路的单通道版本跑通之后,最自然的扩展方向就是增加通道数。脑电信号本质上是一个空间场,单通道只能看到某一个位置的电活动,如果要定位信号从哪里发出,就需要多点同步采集。
多通道带来的第一问题是数据量成倍增长。4 通道、每通道 256Hz、16bit 采样,每秒数据量大约 2KB,UDP 依然能轻松承载。第二个问题是时间同步。每个 BW16 节点独立运行,它们的采样时刻并不对齐,这会严重影响后续的源定位计算。
在原型层面,可以先用一个主节点同时读取多路 ADC 或者多个串口模块,保证每包数据里包含同一时刻的多个通道。更专业的做法是给每个采样点打上硬件时间戳,利用 PTP 或者简单的 beacon 同步协议对齐节点时钟。至于源定位中的最小范数估计,那是离线分析阶段的事,把原始数据通过网页接口导出成 CSV,再导入 MATLAB 或者 Python 做处理,完全不需要在嵌入式端跑。
5.2 便携化:锂电池供电和无线充电
我给 BW16 采集端换上 18650 锂电池之后,整个节点就完全脱离了线缆束缚。空闲状态下,BW16 进入轻度休眠,只在采样时唤醒射频发送数据,一节 2000mAh 电池撑上半天没有问题。
如果想彻底摆脱充电线,可以在采集端加一个无线充电接收模块。市面上常见的 Qi 协议模块非常便宜,输出 5V,配合 TP4056 充电板就能给锂电池充电。这样每次实验前把采集端往充电底座上一放,充满就能拿下来用一整天,体验很接近消费级可穿戴设备了。
5.3 更实时:从轮询升级到 SSE 或 WebSocket
如果你把前端刷新率当成一个正经优化项,可以考虑从 HTTP 轮询升级为 SSE。SSE 只需要服务器单向推送,ESP32 端用 WiFiServer 手动维持一个流式连接,比 WebSocket 节省资源不少。升级之后,网页端可以做到 20ms 级别的刷新间隔,波形的平滑度会上一个台阶。
升级过程中要注意两点。第一,SSE 连接需要服务器在响应头里带上 Content-Type: text/event-stream,并且保持连接不关闭。第二,连接数不能太多,我实测 ESP32-CYD 同时维持 3 路 SSE 连接已经是比较舒适的上限,再多就需要用异步服务器了。
就我个人体验来说,这条链路的最大价值不是“脑电波上网页”这个噱头,而是把采集、传输、嵌入式渲染和 Web 可视化这四块完全不同的技术栈串成了一个完整闭环。你可以在任何一环上把它换成更专业的解决方案,但骨架不变。如果你手头刚好也有这两块板子,按这个思路搭一遍,哪怕只是看着自己眨眼的伪迹在屏幕上跳出一个小谷,也会觉得这个周末花得值。