基于ESP32与BW16的无线脑电采集方案:从串口到WiFi实时波形 前几天我把桌上的脑电采集设备从 USB 线缆里解放了出来搭了一条完整的无线 EEG 链路脑电模块负责采集头皮信号BW16 模块把串口数据转成 WiFi 数据流另一端的 ESP32-CYD 一边在自带屏幕上实时画波形一边作为一个小型 Web 服务器把同一路波形推送到浏览器里。全程没有电脑参与手机和笔记本连上热点就能看。这条链路解决的是 EEG 实验里非常实际的问题传统方案里脑电模块必须用线接着电脑被测试者活动范围被限制在工位旁边线缆本身还会成为工频干扰的天线稍微一动波形就飘。这套原型把采集端和显示端彻底分离无线传输距离在室内轻松覆盖十几米屏幕端和网页端的波形基本同步延迟在几十毫秒级别。文章会从硬件分工、两端代码、无线协议设计到实测排坑完整复现整条链路。适合正在做脑机接口入门、可穿戴设备原型验证或者单纯想把某路传感器数据从串口里搬到 WiFi 上的人参考。不需要高深的背景有一点 Arduino 基础就能跟下来。1. 为什么非要把脑电模块搬上无线从一根线说起1.1 有线采集的麻烦只有真做过才懂脑电信号本身是微伏级别的生物电信号幅度通常在 10~100 微伏特别容易被环境电磁场污染。传统方案里电极线、放大器的 USB 线、电脑的地线构成了一根巨大的天线50Hz 工频干扰和运动伪迹全都被耦合进来。你可以在软件里做 50Hz 陷波但运动过程中线缆晃动产生的基线漂移和接触噪声是后处理很难完全去掉的。这时候最简单的物理办法就是把线变短让前端和数据接收端中间只隔空气。无线 EEG 采集的意义不只是少一根线而是从源头上砍掉一大截干扰路径。被测试者戴上电极帽之后可以在房间里自由活动甚至可以走到另一个房间只要 WiFi 信号能穿透波形就不会断。演示场景也更自然给朋友展示脑电波形的时候不用让人家坐那一动不动盯着屏幕。1.2 这条链路到底解决了什么问题整套系统的数据流是单向的电极信号进入脑电模块模块内部的模拟前端完成放大和 ADC 采样之后通过 UART 串口以特定的数据帧格式输出。BW16 读取串口数据通过 WiFi TCP Socket 发送出去。ESP32-CYD 作为 WiFi 接入点接收数据帧解析后在 TFT 屏幕上绘制滚动波形同时在内部跑一个 WebSocket 服务把原始采样值广播给所有浏览器客户端。采集端是独立供电的和接收端没有任何电气连接彻底隔离。显示端有两块屏幕——硬件屏和网页两者显示同一路数据。网页端可以同时被多台设备访问适合课堂演示或者多人观看。接收端没有用电脑一个 ESP32 开发板全部搞定。1.3 这条链路适合哪些场景如果你只是想在桌面上看个波形这条路确实绕远了。它的价值在于移动采集、多人观看、以及原型验证。我搭这条链路时的核心诉求是在后续做 EEG 源定位和最小范数估计这类算法研究之前先把数据采集的最后一米跑通保证原始数据能实时、稳定地从电极帽到达算法端。对做 BCI 方向的学生、做可穿戴硬件验证的工程师以及纯粹想玩玩脑电的极客来说这条链路都是很好的基础架构。2. 硬件选型BW16 当搬运工ESP32-CYD 当接收展示终端2.1 两个芯片的分工逻辑这套系统里两个核心硬件各司其职没有重叠和冗余。BW16 是瑞昱 RTL8721DM 方案的模组双核 Cortex-M4 加 Cortex-M33主频 125MHz支持 2.4GHz WiFi 802.11 b/g/n 和 BLE 5.0。它的核心优势是UART、I2C、SPI 等外设资源充足Ameba Arduino 支持度好用起来和普通 Arduino 几乎没区别而且作为数据转发节点功耗不算高。它的任务非常纯粹从串口扒数据通过 WiFi 发出去不干别的。ESP32-CYD 的全名是 ESP32-2432S028R核心是 ESP32-WROOM-32双核 240MHz板载 2.8 寸 320x240 TFT 触摸屏SPI 接口驱动 ILI9341。它需要同时干三件事做 WiFi 接入点接收 BW16 的数据、驱动 TFT 屏幕画滚动波形、跑 WebSocket 服务器给浏览器推数据。ESP32 的双核架构在这里很合适一个核跑 WiFi 协议栈和 Web 服务另一个核处理屏幕绘制任务分工清楚。2.2 为什么不是 ESP32 直接采集也不是蓝牙串口模块有一种更精简的做法在 ESP32-CYD 上直接接脑电模块的串口线省掉 BW16。听起来更简单但有几个实际痛点。CYD 的屏幕已经占了大量 GPIO剩余的引脚位置布线很不方便更重要的是脑电模块的模拟前端和 ESP32 开发板的电源、地线离得太近开关电源的纹波会直接串进微弱信号里。用 BW16 把采集端和显示端在物理上分开其实是把信号完整性的问题用结构设计解决了比在 PCB 上花心思做隔离省事得多。蓝牙串口模块比如 HC-05也能做无线透传但带宽和连接数都是硬伤。普通蓝牙串口模块的默认波特率下传输 512Hz 采样率、16bit 精度的单通道数据勉强够用但只能一对一连接网页端想同时让多台设备看波形就做不到。WiFi 方案天然支持多客户端WebSocket 广播几乎是零成本实现的。2.3 完整器材清单器件作用接口/关键参数脑电模块任意 UART 输出型我手里这块是 57600 波特率输出采集 EEG 信号并完成放大和 ADCUART TX/RX, 3.3V, 采样率 512HzBW16 模组读取串口数据并通过 WiFi 发送UART RX, WiFi STA 模式连接 ESP32 热点ESP32-CYD接收数据、绘制屏幕波形、提供 Web 服务WiFi AP 模式, TFT SPI 屏, WebSocket Server锂电池或 5V 电源采集端供电3.3V LDO 稳压后给脑电模块和 BW16你手里的脑电模块如果串口协议不同只需要改 BW16 端的数据解析函数其他部分完全不用动。3. BW16 采集端实战串口数据如何变成 WiFi 数据流3.1 接线与电平问题先处理物理连接。脑电模块输出的是 3.3V TTL 电平BW16 的 UART 引脚也是 3.3V 电平直接连接没有问题。接线方式BW16 的 RX 接脑电模块的 TXBW16 的 TX 接脑电模块的 RX如果不使用模块的配置通道也可以只接一根 TX。电源上我用了锂电池接 LDO 降到 3.3V 给两个模块共电实测纹波在可接受范围。如果手头电路接触不良优先检查公共地有没有接好串口通信的地比信号更重要。一个容易忽略的细节是脑电模块的 UART 波特率必须和 BW16 端配置一致。很多脑电模块默认是 115200 或者 57600你要在数据手册里确认。我的模块是 57600BW16 的 Serial1 初始化就用的这个值。如果你用的是 NeuroSky TGAM 这类模块注意它输出的是标准脑电原始数据包格式帧头是 0xAA 0x04解析逻辑要按对应协议来写。3.2 数据帧设计不要直接透传要加一层自己的协议一开始我想偷懒把串口收到的字节原封不动通过 WiFi 发出去结果在接收端解析数据痛不欲生。WiFi 传输是流式的TCP 会把你的数据切包、粘包接收端根本分不清哪里是帧头。正确做法是在 BW16 端设置一个固定长度的采样窗口攒够一小批数据加上自定义帧头、序列号、负载长度和校验组装成一帧再发送。我设计的帧格式如下字段长度说明帧头2 字节0xAA 0x55负载长度1 字节后续数据的字节数序列号2 字节递增计数用于检测丢包EEG 采样值N x 2 字节每样本 16bit 有符号整数一帧 10 个样本校验1 字节前面所有字节异或值这样一帧总长 26 字节212201在 512Hz 采样率下每 20ms 产生一帧。接收端只要按帧头同步、按长度切帧、用校验和过滤损坏数据就能干净地还原出原始采样序列。序列号则是排查丢包的关键依据后面会细讲。3.3 WiFi 传输TCP 比 UDP 更省心在局域网内传 EEG 数据我用 TCP 而不是 UDP。原因很实际脑电采样率只有 512Hz每 20ms 一帧 26 字节的数据量TCP 的吞吐完全够用还能获得可靠传输和乱序重排。UDP 虽然实时性上限更高但丢包后你要自己处理重传和排序对只是做原型验证的项目来说纯属增加工作量。实测局域网内 TCP 的延迟非常低完全满足实时波形显示需求。// BW16 (Ameba Arduino) - EEG 数据转发核心逻辑 #include WiFi.h const char* ssid EEG-Link; const char* password 12345678; const char* host 192.168.4.1; // ESP32-CYD 的热点 IP const uint16_t port 8266; WiFiClient client; uint8_t rxBuf[64]; uint8_t frameBuf[32]; uint16_t seq 0; void setup() { Serial1.begin(57600); // 接脑电模块 WiFi.begin(ssid, password); while (WiFi.status() ! WL_CONNECTED) delay(100); client.connect(host, port); } void loop() { // 从串口读取原始字节逐字节匹配帧头 int n Serial1.available(); while (n-- 0) { uint8_t b Serial1.read(); if (findEEGFrame(rxBuf, b)) { // 在 rxBuf 中匹配到完整模块帧 packAndSend(rxBuf); // 攒够 10 个样本组帧并发送 } } } void packAndSend(uint8_t* samples) { frameBuf[0] 0xAA; frameBuf[1] 0x55; frameBuf[2] 22; // 负载长度 frameBuf[3] highByte(seq); frameBuf[4] lowByte(seq); seq; memcpy(frameBuf[5], samples, 20); // 10 个 16bit 样本 uint8_t checksum 0; for (int i 0; i 25; i) checksum ^ frameBuf[i]; frameBuf[25] checksum; client.write(frameBuf, 26); }注意这里有一个重要的工程细节BW16 端不要用delay()来控制发送节奏。脑电模块按照自身的采样时钟连续输出数据BW16 只需要在串口缓冲区里取数据、攒满一帧就发。你用millis()定时取数一旦系统调度抖动就会出现采样间隔不均匀波形上能看到明显的毛刺。让数据自身节奏驱动发送是最稳定的方式。3.4 低功耗与稳定性小窍门采集端如果要做到头戴式供电功耗是绕不开的话题。BW16 在持续 WiFi TCP 发送时电流约 100mA 左右脑电模块大约 10mA加起来 110mA。拿一块 800mAh 锂电池供电实验两三个小时完全没问题。如果要做成长时间佩戴的版本可以开启 WiFi 的 DTIM 节能模式让模块在数据间隙休眠但要注意这会增加延迟睡眠唤醒后的第一个包可能会迟到是否值得要看你的应用场景。还有一个非常容易被忽略的点天线位置。BW16 模组的天线区域不要贴地线铜箔也不要被金属外壳完全包裹。我第一次打样的铝合金外壳直接屏蔽了信号丢包率飙升到 30%。把天线侧暴露在塑料外壳开孔处之后问题立刻消失。4. ESP32-CYD 接收端屏幕波形绘制的几个关键细节4.1 热点模式与 TCP 服务端ESP32-CYD 这边要做的事情更多。首先它要建立一个 WiFi 热点让 BW16 连上来。我设置了 ESP32 同时开启 AP 和 Web 服务热点 IP 固定为 192.168.4.1。BW16 端连上热点后会向这个 IP 的 TCP 端口发起连接。TCP 接入的解析逻辑要处理好流式数据的问题。WiFi TCP 收到的数据可能一次只有半个帧也可能一次到了好几帧。我的处理方式是维护一个环形缓冲区不断把收到的字节追加进去然后循环尝试从缓冲区头部解析完整的 EEG 帧。只有校验通过、长度吻合才把帧里的采样值提取出来写入波形缓冲区。// ESP32-CYD 接收端 - TCP 数据解析 // 伪代码关键是环形缓冲 帧同步的思路 uint8_t ringBuf[256]; uint16_t ringHead 0, ringTail 0; void onTCPData(uint8_t* data, size_t len) { for (size_t i 0; i len; i) { ringBuf[ringHead] data[i]; ringHead (ringHead 1) % sizeof(ringBuf); } while (tryParseFrame()); // 不断尝试解析完整帧 } bool tryParseFrame() { if (ringHead ringTail) return false; // 缓冲区空 // 查找帧头 0xAA 0x55 // 校验长度、校验和 // 若成功提取 10 个采样值到 waveBuf返回 true // 若失败跳过 1 字节继续找下一帧 }这里最怕的就是死等一个完整帧。如果缓冲区头部的字节是半个帧头程序必须跳过它继续往后找而不是阻塞等待。我之前用阻塞式解析遇到 WiFi 丢包后整个程序卡死波形直接不动了。4.2 TFT 屏幕滚动波形怎么画才不闪320x240 的屏幕我划分了一个 320x160 的波形区剩下区域显示采样值、丢包率和连接状态。波形绘制是滚动式的新数据从右边出现旧数据往左边移动。TFT_eSPI 库本身不含滚动波形功能需要自己实现。最简单的滚动画法每次来新帧先把整个波形区左移若干像素再把新采样绘制在最后一列。实现上有两种做法做法一是用fillRect把最左侧一列擦掉然后把之前的所有列重新画一遍。这个方法代码最简单但刷新率略低实测 512Hz 采样约 25fps 的刷新率下能看到画面轻微闪烁。做法二是移动屏幕窗口。TFT_eSPI 支持通过setScrollMargins和scrollTo实现纵向滚动但横向滚动没有直接 API。我最终用了另一种方案双缓冲区。在内存里维护一个 320x160x2 字节的波形位图每次新数据到达时在内存里把位图左移一列然后把新采样值画到最右列最后通过pushImage一次性把整块位图推到屏幕上。这个方法比逐像素fillRect快得多实测刷新率稳定在 30fps 以上画面干净不闪烁。// 双缓冲滚动逻辑 void updateWaveform(int16_t newValue) { memmove(waveCanvas, waveCanvas 2, (320 - 1) * 160 * 2); // 在 waveCanvas 最右列绘制 newValue 对应的线段 drawLineToRightColumn(waveCanvas, newValue); tft.pushImage(0, WAVE_Y, 320, 160, (uint16_t*)waveCanvas); }这里有个细节memmove是内存拷贝320x160x2 字节约 100KBESP32 的 SRAM 足够但要在空闲任务里做不能在 WiFi 中断回调里做。我把解析和绘制拆成两个任务解析任务只负责往队列里放采样值绘制任务从队列里取数据刷新屏幕CPU 双核正好干这事。4.3 刷新率与屏幕撕裂问题测了一下实际刷新率屏幕绘制大约 31fps也就是每 32ms 刷新一屏。比起专业脑电软件动辄 60fps 的实时刷新这个数值不算高但用于波形形态观察足够了。屏幕撕裂现象在某些帧频率下会出现上半屏和下半屏显示的不是同一时刻的数据。双缓冲天然解决撕裂因为pushImage是在一帧时间内一次性把完整位图推到屏幕的不存在上下分屏。如果你坚持不用双缓冲也有一个折中方案只在数据到达时刷新屏幕的四分之一区域让撕裂现象限于局部。但我建议直接上双缓冲代码量没多多少体验完全不一样。4.4 显示内容和状态监控屏幕除了波形我还显示了三行状态信息实时采样值、帧序列号、错帧计数。序列号一旦跳变说明 WiFi 链路丢包了校验失败计数增加说明数据帧被破坏。屏幕上实时看到这些指标比事后用串口调试省事很多。我后来排查 WiFi 干扰问题时就是靠屏幕上的丢包率数字定位了天线位置的问题。5. 网页端实现让手机和电脑浏览器看到同一路波形5.1 为什么选 WebSocket而不是 HTTP 轮询接收端除了画屏幕还要把数据送到浏览器。两种方案HTTP 轮询和 WebSocket。HTTP 轮询就是浏览器每隔几百毫秒向服务器请求一次最新数据实现简单但延迟高而且客户端多了之后 HTTP 请求会挤占 TCP 连接资源。WebSocket 是长连接服务器有数据就主动推给浏览器端到端延迟低多客户端共享一条数据通道也很自然。实测对比HTTP 轮询在 200ms 间隔下肉眼可见波形有跳格感WebSocket 推送下波形和屏幕端几乎同步浏览器端延迟大约 30~50ms完全够实时监控使用。5.2 ESP32-CYD 上的 Web 服务器搭建ESP32 端我用了 ESP32 Arduino 生态里的WebServer库提供静态页面WebSocketsServer库处理 WebSocket 连接。静态页面直接编译成字符串存放在 Flash 里浏览器访问http://192.168.4.1就能加载页面和 JavaScript 代码。WebSocket 端口单独开一个 8266 之外的端口比如 8080避免和 TCP 数据端口冲突。// ESP32-CYD - WebSocket 数据广播 WebSocketsServer webSocket(8080); void setup() { // ... 热点启动、TCP Server 启动 ... webSocket.begin(); webSocket.onEvent(webSocketEvent); } void loop() { webSocket.loop(); // 当波形队列有新采样时构造二进制消息并广播给所有客户端 if (newSampleAvailable) { uint8_t msg[4]; msg[0] 0xAA; msg[1] highByte(sampleValue); msg[2] lowByte(sampleValue); msg[3] 0x55; webSocket.broadcastBIN(msg, sizeof(msg)); } }WebSocket 的二进制消息里我直接塞了原始采样值不做过多的 JSON 封装。二进制的解析开销小对 Canvas 绘制更友好。如果你要传的数据结构复杂多通道、带时间戳再考虑 JSON。5.3 前端 Canvas 滚动波形绘制网页端的 JavaScript 核心是维护一个采样值环形数组收到 WebSocket 二进制消息后先做字节序转换ESP32 是小端模式JS 端 ArrayBuffer 的 DataView 要按小端读取把采样值追加到数组尾部。绘制时用 Canvas 2D 的putImageData做整幅滚动原理和 TFT 端双缓冲一模一样。// 前端简化逻辑 const ws new WebSocket(ws://192.168.4.1:8080); const canvas document.getElementById(waveform); const ctx canvas.getContext(2d); const imageData ctx.createImageData(320, 160); let offset 0; ws.binaryType arraybuffer; ws.onmessage (event) { const view new DataView(event.data); // AA 55 前处理 const value view.getInt16(1, true); // 小端 16bit // 将 imageData 整体左移一像素把 value 画到最右列 scrollCanvas(imageData, value); ctx.putImageData(imageData, 0, 0); };5.4 手机和平板的适配CYD 的热点默认 IP 是 192.168.4.1手机连上热点后浏览器直接访问这个地址即可。页面里的 Canvas 宽度我用固定 320px和 CYD 屏幕一致但手机上可以用 CSS 缩放适配整个屏幕。实测 iPhone、安卓手机、笔记本都能正常打开只要浏览器支持 WebSocket无需插件。有个小技巧为了让网页端波形显示比例协调我在前端把采样值做了二次缩放——屏幕上显示的是原始 ADC 值直接映射到 160 像素高度网页端我加了自动幅度调节根据最近 100 个采样点的最大最小值动态调整纵轴范围这样波形不会一上来就顶到上下边缘。音频设备上的 AGC自动增益控制干的是类似的事人耳听感舒服波形看起来也舒服。6. 实测数据与问题排查延迟、丢包、干扰一个都没少6.1 实测性能指标指标数值备注采集端采样率512 Hz脑电模块 ADC 固定单帧大小26 字节10 个 16bit 采样点帧间隔约 20ms由采样率决定屏幕端端到端延迟约 25ms不含屏幕刷新等待网页端端到端延迟约 30~50ms含 WebSocket 传输与浏览器绘制室内稳定丢包率 0.1%TCP 重传后几乎无损有效传输距离室内 15m隔一堵墙仍稳定ESP32 和 BW16 之间是 TCP 连接所以丢包的真正含义是重传带来的延迟而不是数据彻底丢失。波形上偶尔会出现一个台阶其实是延迟抖动造成的采样点到达间隔不均匀不是真正的丢点。6.2 踩过的坑TCP 粘包与半包这个问题几乎每个做 WiFi 透传的人都会碰到。TCP 是流式协议底层可能把两个数据包合并成一个包发送也可能把一个包拆成两次发送。如果接收端直接按收到一次数据就是完整一帧来解析大概率会出错。我的解法就是前面提到的环形缓冲区加帧头同步无论底层怎么粘、怎么拆接收端都能从字节流里正确切出每一帧。帧头 0xAA 0x55 的选取也有讲究EEG 采样值本身不会频繁出现 0xAA 0x55 连续组合误同步概率低如果连续多帧校验失败就主动丢弃这个可疑帧头重新同步。6.3 踩过的坑WebSocket 莫名断开浏览器端的 WebSocket 连接会间歇性断线尤其在手机浏览器锁屏再解锁之后。原因通常是浏览器的省电策略切断了后台 WebSocket。处理方式前端在onclose事件里自动重连重连间隔 1 秒ESP32 端对每个 WebSocket 客户端做了活跃时间检查超过 30 秒没收到 ping 就主动释放连接。这样即使客户端断线用户刷新页面或者重新打开浏览器几秒内就能恢复波形显示。6.4 踩过的坑工频干扰隔空传染虽然采集端和接收端没有物理连线但两边的电源插头仍然来自同一个市电网络50Hz 干扰依然可能通过空间辐射耦合。实际测试发现当 ESP32-CYD 的充电器离脑电模块电极线较近时波形上能看到明显的 50Hz 正弦叠加。解决方案有三个按效果优先级排列把采集端和电极线尽量远离任何电源适配器保持 30cm 以上距离。采集端电池供电时优先用线性 LDO 而非开关电源开关电源的纹波频带更宽更容易耦合进微弱信号。软件上做 50Hz 陷波滤波器我在 BW16 端没有做而是在 ESP32 端用一个简单的 IIR 陷波器滤掉 50Hz 分量再送去画波形和推网页。但要注意陷波器本身会带来相位延迟如果你是做数据记录的一定要保存原始采样值滤波数据只用于显示。6.5 判断链路是否健康的一把尺子这条链路我给自己的体检标准就是中帧序列号。BW16 端每一帧都有递增的序列号ESP32 端解析后统计收到序列号和期望序列号的差值。差值为 0 说明链路完美差值大于 0 说明发生了 TCP 重传差值里面有空洞。我把这个数字显示在 CYD 屏幕上长时间跑下来基本稳定在个位数以内。如果你的场景里差值持续增长优先排查 WiFi 信道拥挤问题给 ESP32 热点换一个空闲信道比如从 1 换到 6 或者 11效果立竿见影。7. 这套链路还能往哪些方向扩展7.1 从单通道到多通道单通道只是验证链路真正做脑电分析通常需要 8、16 甚至 32 通道。BW16 的串口资源足够扩展多路脑电模块但带宽要重新算一笔账。32 通道、512Hz 采样率、16bit 精度原始数据率是 32 x 512 x 2 32KB/sTCP 完全扛得住但 ESP32-CYD 侧的解析和屏幕绘制压力会变大。屏幕刷新建议从整屏滚动改成只显示其中几个通道或者把数据全部推给网页端屏幕只做状态监控。7.2 加入数据记录功能脑电实验的数据记录非常关键。目前链路里数据是实时流水没有落盘。后续可以在 ESP32-CYD 上挂一张 MicroSD 卡把解析出来的原始采样值和序列号写入 CSV 或者 BDF 格式文件。这样实验结束之后还能离线做源定位、最小范数估计等分析不依赖在线处理链路。7.3 脑电源定位和更高层分析的对接这条链路最终喂给算法的数据格式是干净的原始采样序列这意味着它可以直接对接 EEG 源定位、最小范数估计、事件相关电位分析等后续处理。实时网页只是链路的前端可视化真正的价值在于你有了一个随时随地能提供干净 EEG 数据流的无线管道。管线后面的算法想怎么折腾都行。从我个人的实际操作感受来说这套链路最难的不是任何一段单独的技术而是把四段串在一起时保持节奏一致采样时钟、WiFi 传输、屏幕刷新、网页推送每一环的定时抖动都会在最终波形上留下痕迹。好在这条链路每一环的时序约束都比较宽松只要掌握好数据驱动发送和缓冲区分帧这两个原则就不会出大问题。最后分享一个小技巧整套系统调试的时候先用一个简单的正弦波信号源代替真实的脑电模块做联调确认 BW16→CYD→屏幕和网页整条链路的数据一致性和波形正确性之后再接上脑电模块。否则一旦波形异常你根本分不清是生物电信号本身的问题还是无线链路的问题。信号源测试十分钟能帮你省下一下午的排错时间。