ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于BW16与ESP32-CYD的无线EEG原型链路:BLE+UART实时传输与显示

2026/9/29 12:21:36 拓冰建站 浏览量
基于BW16与ESP32-CYD的无线EEG原型链路:BLE+UART实时传输与显示 1. 这条无线 EEG 原型链路到底在做什么脑电采集这件事很多人第一反应是离我很远其实拆开看它无非就是三件事把头皮上微弱的电位变化抓下来、把数字化的数据传出去、把数据画成能看的波形。我这次做的项目就是把这三件事用两块很便宜的开发板串起来——前端用 BW16 负责无线转发后端用 ESP32-CYD 负责接收、解析、显示同时把数据推到网页上。先说清楚这套东西能干什么。它不是一个医疗级设备也不追求科研级精度它的定位是原型验证链路验证从脑电模块出来的串口数据能不能经过 BLE 无线传输稳定地落到另一块板子的屏幕上并且同步在浏览器里实时刷新。适合谁看适合手里已经有脑电采集模块比如常见的单通道或双通道干电极模块、想快速搭一条无线链路做实验的人也适合单纯想学 BLE UART 透传 屏幕显示这套组合拳的嵌入式爱好者。核心关键词就五个BW16、ESP32-CYD、EEG、BLE、UART。这条链路的数据流向非常清晰脑电模块通过 UART 把数据吐给 BW16BW16 把串口数据打包成 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采样率、增益有线传输层脑电模块 → BW16UART波特率、数据帧格式无线传输层BW16 → ESP32-CYDBLE GATT NotifyMTU、连接间隔呈现层ESP32-CYDTFT HTTP/WS刷新率、窗口长度这个分层的好处是任何一层出问题你都能单独定位。比如波形乱跳先看 UART 帧同步对不对如果 UART 正常但无线端丢包就查 BLE 的 MTU 和连接参数。2.4 方案选型的几个关键取舍取舍一BLE 还是 WiFi 直传BLE 功耗低、配对简单适合近端短距离WiFi 带宽大但功耗高、配网麻烦。原型阶段我选 BLE因为脑电数据量其实不大单通道 250Hz × 3 字节 ≈ 750 字节/秒BLE 完全够用。取舍二Notify 还是 IndicateNotify 不需要接收方确认吞吐更高适合连续流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每次连接事件至少能发一个 Notify244 字节理论吞吐约 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 的 RXRX 接 BW16 的 TXGND 共地。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 卡边显示边录方便离线分析。我自己下一步打算在网页端加一个简单的带通滤波和频谱图这样不用上位机就能看基本的脑电特征。另外想试试把数据同时推给多个浏览器方便多人同时观察。这条链路最大的价值不是它有多精密而是它把采集—传输—显示这条完整路径用很低的成本跑通了。你理解了每一段的参数和坑换成别的模块、别的板子思路是一样的。真正难的不是接线是理解数据在每一层是怎么被处理、被限制、被呈现的。把这一层想清楚后面做什么都顺。