简介:ESP32与ADS1299联用的生物电采集方案,面向嵌入式开发者、物联网工程师及医疗电子爱好者,解决高精度多通道ECG/EEG信号采集与无线传输需求。资源基于PlatformIO工程结构,共9个文件、约70KB,包含C++主程序、platformio.ini工程配置、VS Code及JSON配置文件、3份readme说明文档,以及一份xmind代码解析思维导图,可快速理解工程结构和SPI通信逻辑。项目以ESP32为主控,通过SPI接口读取ADS1299的24位低噪声数据,并利用内置Wi-Fi/蓝牙实现远程数据监控与移动端交互,适用于病人监护、远程医疗、便携式监测等场景。已有266人浏览学习,对想上手模拟前端AFE与无线数据链路的开发者而言,这是一份小而完整的参考实例,涵盖引脚配置、寄存器读写、数据协议处理到无线传输的完整链路。工程还涉及电源管理设计,要求提供稳定低噪声电源,并注意PCB布局布线以保证信号完整性,这些都在压缩包内的文档与思维导图中有所呈现。
1. 看到 _1_ESP32_ADS1299.zip,我在期待什么
有人丢给我一个压缩包,文件名写着 _1_ESP32_ADS1299.zip,不用解压也能猜到八九成:这是一个用 ESP32 驱动 ADS1299 做生物电采集的方案,把脑电、肌电、心电这类微弱信号的采集、放大、模数转换和无线传输全部装进同一个工程。先说一个反直觉的结论:这个组合真正的门槛不在“信号能不能采”,而在“时间同步”和“数据搬运”。我见过不少项目,芯片本身很乖,最后坏在 SPI 时序和 WiFi 任务打架上。适合谁?做脑机接口、睡眠监测、肌电假肢原型验证的人,预算不高但想快速拿到稳定数据。这篇文章不铺大而全,就照着这个压缩包通常会有的结构,把硬件接线、寄存器配置、读取流程和踩过的坑一次说清楚。
2. 先把链路搭稳:ESP32 与 ADS1299 的接线和电平匹配
2.1 为什么偏偏是这两颗芯片,而不是“MCU 加运放自己搭”
ADS1299 是 8 通道、24 位、自带模数转换的模拟前端,专门为脑电这类弱信号设计。它把仪表放大器、可编程增益、低通滤波、偏置驱动、导联脱落检测和内部参考电压全做进去了。也就是说,你不需要在外部搭一堆运放和滤波器,只要用 SPI 读寄存器配置它,就能拿到多通道同步采样数据。而 ESP32 的价值在于:它有完整的 SPI 外设、足够用的双核 CPU,还内置 Wi-Fi 和蓝牙。一颗芯片同时解决控制、采集和无线传输,这对做原型验证来说实在太省事。
常见替代方案我也试过几轮。用 AD8232 做单通道心电采集,芯片是小,但每个通道都要自己设计放大链路,做 8 通道就非常痛苦;用 STM32 做主控完全没问题,但要传数据到上位机还得外接 Wi-Fi 模块,调试起来多一层复杂度。相比之下,ESP32 和 ADS1299 都是数字接口,接线干净,社区资料也多,遇到问题至少能搜到一堆人踩过的坑。
我一般不推荐新手直接上树莓派加 ADS1299 的方案。树莓派不是实时系统,读 SPI 时容易被打断,脑电这种微伏级信号对采样连续性很敏感,ESP32 这种裸跑单片机的时序确定性反而更好。这个组合的定位很清楚:做一个低成本、可移动的无线多通道生物电采集节点。
2.2 接线表和电源处理:八个连接,错一个都会让你怀疑人生
我第一次搭这个电路时,以为只要把 SPI 的四根线接上就行,结果 DRDY 引脚一直不出下降沿,排查了整整一下午。后来才发现问题出在供电上:ADS1299 的模拟部分和数字部分对电源噪声的容忍度完全不同。
下面是我现在的新版连线表,按重要程度排列:
| 信号 | ESP32 | ADS1299 | 说明 |
|---|---|---|---|
| 电源正 | VIN(5V 输入) | AVDD | 模拟电源,建议外部 LDO 单独供电 |
| 电源正 | 3V3 | DVDD | 数字电源,ESP32 的 3V3 可以直接用 |
| 地 | GND | AVSS / GND | 模拟地和数字地单点汇接 |
| SPI 时钟 | GPIO18 | SCLK | 硬件 SPI 默认引脚 |
| SPI 数据出 | GPIO23 | DIN | 主出从入 |
| SPI 数据入 | GPIO19 | DOUT | 主入从出 |
| SPI 片选 | GPIO5 | CS | 软件控制拉低 |
| 数据就绪 | GPIO14 | DRDY | 必须接中断或轮询引脚 |
| 时钟 | 不接 | CLK | 使用内部时钟时留空 |
ADS1299 的 DVDD 是 3.3V 时,SPI 输入输出电平和 ESP32 的 3.3V IO 是匹配的,不需要电平转换。真正的问题在 AVDD。ESP32 开发板上的 VIN 是 USB 5V 直接来的,纹波不小,直接给 ADS1299 的 AVDD 供电,数据里很容易出现规律的干扰包络。我的做法是:AVDD 用一个独立的低压差线性稳压器供电,输出端并 10µF 钽电容和 100nF 陶瓷电容,远离 ESP32 的开关噪声。加上模拟地和数字地只在电源入口处单点连接,采集到的信号会干净很多。
接线时还要注意,DRDY 不是 SPI 信号,但要离远一点。我踩过线序混乱导致的错误,后来把 SPI 四根线用双绞形式走线,DRDY 单独走一侧,再配合屏蔽线做电极输入,噪声水平比第一次搭的面包板好了不少。
2.3 点亮 SPI:用“读 ID 寄存器”验证接线是否正确
拿到板子先别急着配寄存器,第一步是确认 SPI 通没通。ADS1299 的 ID 寄存器地址是 0x00,读回来正确值,说明电源、CS、SCLK、DIN、DOUT 这些链路全部没问题。这一步能把硬件问题和软件问题彻底分开。
#include <SPI.h> #define ADS_CS 5 #define ADS_DRDY 14 #define ADS_CLK 25 // 如果不用内部时钟,可以用 LEDC 输出方波接 CLK uint8_t readReg(uint8_t reg) { uint8_t val; SPI.beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE1)); digitalWrite(ADS_CS, LOW); SPI.transfer(0x20 | reg); // RREG 命令,地址从低五位取 val = SPI.transfer(0x00); // 同时产生时钟,从 DOUT 读回数据 digitalWrite(ADS_CS, HIGH); SPI.endTransaction(); return val; } void setup() { Serial.begin(115200); pinMode(ADS_CS, OUTPUT); digitalWrite(ADS_CS, HIGH); SPI.begin(); // 复位 ADS1299 digitalWrite(ADS_CS, LOW); SPI.transfer(0x06); // RESET 命令 digitalWrite(ADS_CS, HIGH); delay(10); Serial.print("ADS1299 ID = 0x"); Serial.println(readReg(0x00), HEX); } void loop() {}这段代码的逻辑很直接:先把片选拉低,发送 0x06 复位命令,然后读 ID 寄存器。读寄存器时发送的第一个字节是高三位为 001 的读取命令,第二个字节是空写操作,用来产生 SPI 时钟,芯片会在时钟边沿把数据放到 DOUT 上。参数方面,SPI 模式必须用 SPI_MODE1,即时钟极性为低、第二个边沿采样。ADS1299 对这个要求很严格,用 SPI_MODE0 经常读到一堆 0xFF。
如果串口打印出来的是 0x3E,说明 SPI 链路完全正常。如果读到 0xFF,先查 CS 有没有拉低;如果读到 0x00,查 DIN 和 DOUT 是否接反。这一步没问题,再进入配置阶段。注意初始 SPI 频率不要给太高,2MHz 比较稳,等验证通过后再慢慢往上升。
3. 软件栈搭建:从寄存器配置到多通道数据读出
3.1 三个关键寄存器:采样率、增益、参考源是数据质量的起点
ADS1299 有几十个寄存器,但真正每次都要调的其实就那么几个。我一般会在初始化时按顺序处理 CONFIG1、CONFIG2、CONFIG3,然后是 8 个通道的通道设置寄存器。顺序不能乱,因为寄存器之间互相影响,比如你先把通道设置好了,再关掉连续采集模式,芯片状态可能不对。
先看 CONFIG1,它决定时钟来源和采样率。很多现成驱动里会给一个默认值,比如 0x96,这个值对应的是分频器和超采样倍率的组合。关键一点:如果你不接外部时钟,必须确认 CONFIG1 里的内部时钟使能位是打开的,否则 DRDY 永远不会工作。采样率不是越高越好,脑电一般用 250Hz 到 1kHz,肌电需要更高一点。采样率改变后,抗混叠滤波器的截止频率也会变,需要一起考虑。
CONFIG3 主要管参考源。打开内部参考,同时使能参考缓冲和偏置驱动缓冲。偏置驱动是生物电采集的关键,它会把所有输入通道的平均电位反馈回人体,能大幅抑制共模干扰。如果不打开偏置驱动,你采集到的脑电里会叠着一个巨大的 50Hz 工频波形,后面滤波都救不回来。
通道设置寄存器 CHnSET 每个通道一个,默认值 0x60 代表增益 24 倍,对头皮脑电来说属于比较合适的起点。肌电信号幅度大,可以降到 8 倍或 12 倍,避免输出满幅。下面是完整配置函数:
void writeReg(uint8_t reg, uint8_t val) { SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE1)); digitalWrite(ADS_CS, LOW); SPI.transfer(0x40 | reg); // WREG 命令 SPI.transfer(0x00); // 写入的寄存器数量减一,这里写 1 个 SPI.transfer(val); digitalWrite(ADS_CS, HIGH); SPI.endTransaction(); } void setupADS1299() { // 确保退出连续读取模式,否则寄存器配置会被忽略 digitalWrite(ADS_CS, LOW); SPI.transfer(0x11); // SDATAC 命令 digitalWrite(ADS_CS, HIGH); delay(1); writeReg(0x01, 0x96); // CONFIG1:设置采样率和内部时钟 writeReg(0x02, 0xC0); // CONFIG2:测试信号关闭,其它默认 writeReg(0x03, 0xEC); // CONFIG3:开内部参考和偏置缓冲 writeReg(0x0B, 0x00); // 偏置正输入选择,暂不接任何通道 writeReg(0x0C, 0x00); // 偏置负输入选择 writeReg(0x0D, 0x00); // 杂项控制 // 8 个通道统一设置:增益 24 倍,输入正常接到内部短接测试点 for (uint8_t ch = 0; ch < 8; ch++) { writeReg(0x10 + ch, 0x60); } // 启动转换 digitalWrite(ADS_CS, LOW); SPI.transfer(0x08); // START 命令 digitalWrite(ADS_CS, HIGH); }这里的参数有几个值得注意。CONFIG1 的值要根据你实际用的时钟频率去查数据手册,不同板子上的晶振不同,直接套用一个值不一定对。我建议先把 0x96 当作验证起点,之后按波形再微调。CONFIG3 的 0xEC 打开内部参考和偏置驱动后,输入的共模抑制能力会明显改善。通道寄存器的 0x60 里,高三位控制增益,低四位控制输入模式,0 表示正常输入。
配置完成后,如果你用示波器看 DRDY 引脚,应该能观察到稳定的下降沿。如果 DRDY 一直保持高电平,先回头看 CONFIG1 的时钟使能位和 RESET 后的等待时间。
3.2 数据读取流程:DRDY 下降沿触发,一口气读 27 字节
ADS1299 在连续转换模式下,每个采样周期会产生一帧数据:3 字节的状态字,加上 8 个通道各 3 字节的 24 位采样值,加起来正好 27 字节。读取的关键是时机:必须在 DRDY 引脚出现下降沿之后再发起 SPI 读操作,否则你读到的可能是上一次的数据,或者新旧数据混在一起。
下面是单帧读取的核心函数。先把 DRDY 下降沿当作触发信号,然后连续读取 27 字节,最后手动做 24 位到 32 位的符号扩展。ADS1299 的数据是有符号数,如果忽略符号位,负半轴的信号会变成巨大的正数,波形直接错乱。
int32_t channelData[8]; uint8_t frameBuffer[27]; void readFrame() { // 等待 DRDY 下降沿 while (digitalRead(ADS_DRDY) == HIGH) { delayMicroseconds(2); } SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE1)); digitalWrite(ADS_CS, LOW); for (int i = 0; i < 27; i++) { frameBuffer[i] = SPI.transfer(0x00); // 空写产生时钟,同时读取数据 } digitalWrite(ADS_CS, HIGH); SPI.endTransaction(); // 前 3 字节是状态字,可用来判断导联脱落、信号饱和等 // 从第 4 字节开始才是通道数据 for (int ch = 0; ch < 8; ch++) { int32_t raw = (frameBuffer[3 + ch * 3] << 16) | (frameBuffer[3 + ch * 3 + 1] << 8) | (frameBuffer[3 + ch * 3 + 2]); if (raw & 0x800000) { raw |= 0xFF000000; // 24 位有符号数扩展为 32 位 } channelData[ch] = raw; } }这个函数每调用一次,就拿到全部 8 个通道的同一次采样结果。注意我在等待 DRDY 时用的是轮询而不是中断,因为中断服务函数里做 SPI 传输有风险,容易干扰 Wi-Fi 协议栈。轮询里加 delayMicroseconds 是为了避免死循环把 CPU 占满。CS 在整个 27 字节读取过程中必须保持拉低,中途拉高会让芯片认为这次通信结束了,后面的数据全部错位。
实际调试时我建议用一个 8 通道同时短接到 GND 的方向验证:理想情况下所有通道都接近 0,如果有某个通道读出满幅或固定偏差,大概率是通道设置寄存器的顺序写错了。还有一种常见问题是把状态字当成了通道 0 的数据,导致所有通道整体错位,看波形时表现为延迟了三个采样点。
3.3 无线搬运:UDP 加双核任务,别让 Wi-Fi 拖垮采样时序
读到了数据,怎么传出去是 ESP32 方案的灵魂。网络传输有很多种选择,我一般用 UDP 而不是 TCP。原因是脑电数据对实时性要求高,但对个别丢包不敏感,画波形时丢几个点不影响观察,而 TCP 一旦重传,队列阻塞反而让采样任务越跑越偏。UDP 的代码也简单,一个 beginPacket、一个 write、一个 endPacket 就结束了。
数据帧格式要固定,上位机才好看。我习惯用 4 字节帧头加 8 路浮点数据,总长 36 字节。帧头放一个 0xAA 标记和两位序列号,上位机可以用它检测丢包率。
#include <WiFi.h> #include <WiFiUdp.h> WiFiUDP udp; IPAddress remoteIP(192, 168, 1, 100); const uint16_t remotePort = 12345; uint16_t frameIndex = 0; float adcToVolt(int32_t raw) { const float vref = 4.5f; const float gain = 24.0f; const float lsb = (2.0f * vref / gain) / 8388608.0f; return raw * lsb; } void sendFrame() { uint8_t packet[4 + 8 * 4]; packet[0] = 0xAA; packet[1] = (frameIndex >> 8) & 0xFF; packet[2] = frameIndex & 0xFF; packet[3] = 0x01; int offset = 4; for (int ch = 0; ch < 8; ch++) { float value = adcToVolt(channelData[ch]); memcpy(packet + offset, &value, 4); offset += 4; } udp.beginPacket(remoteIP, remotePort); udp.write(packet, offset); udp.endPacket(); frameIndex++; }参数的选法有讲究。参考电压 4.5V 是内部参考的典型值,增益 24 就是通道寄存器里设置的倍数,8388608是 2 的 23 次方,对应 24 位有符号数的满幅。这样算出来的浮点值单位是伏特,实际脑电信号在微伏级,你上位机再乘 1000000 转成微伏就行。
Wi-Fi 发送包的时候会占用 CPU,如果你的主循环里一边等 DRDY 一边调 sendFrame,很可能出现 DRDY 下降沿来了但你没及时响应的情况。我的做法是把两件事拆到两个核:ESP32 是双核,核 0 专门负责读 ADS1299,核 1 专门负责打包发送。两者之间用一个简单的队列传递帧数据。
QueueHandle_t frameQueue; void adcTask(void*) { while (1) { readFrame(); xQueueOverwrite(frameQueue, channelData); } } void wifiTask(void*) { int32_t data[8]; while (1) { if (xQueueReceive(frameQueue, &data, portMAX_DELAY)) { sendFrame(); } } } void setupTasks() { frameQueue = xQueueCreate(1, sizeof(channelData)); xTaskCreatePinnedToCore(adcTask, "adc", 8192, NULL, 2, NULL, 0); xTaskCreatePinnedToCore(wifiTask, "wifi", 8192, NULL, 1, NULL, 1); }队列深度我故意只设了 1,用 Overwrite 语义。这样如果 Wi-Fi 发送卡了一下,新数据直接覆盖旧数据,而不是让队列堆积导致读出越来越滞后。对实时波形监控来说,跳过一帧比延迟几十帧好得多。这个取舍是血泪经验换来的:第一次做的时候队列设了 16 深,结果网络一抖动,延迟直接半秒,看起来就像信号整体右移了。
4. 避坑:ESP32 驱动 ADS1299 的 5 个常见翻车点
4.1 现象:DRDY 一直不拉低,程序卡死在等待循环里
原因:最常见的有三种。第一是复位后没等芯片稳定就立刻读寄存器,芯片还在初始化,内部逻辑根本没跑起来;第二是 CONFIG1 里时钟配置不对,芯片没有转换时钟;第三是之前的代码让它停在连续读取模式,后续配置命令全部被忽略。解决:每次上电按固定时序走:先拉低 CS 发 RESET 命令,等待至少 10ms 再读 ID 寄存器。配置前发一条 SDATAC 命令退出连续模式,配置完再发 START。我调试的时候会加一个开机自检函数,依次打印 ID、CONFIG1、CONFIG3 的当前值,一眼就能看出芯片有没有按预期工作。
4.2 现象:读到的数据全是 0xFFFFFF 或 0x000000
原因:SPI 模式不对。ADS1299 要求 SPI_MODE1,也就是时钟极性为低、第二个边沿采样,很多例程里用的是设备默认的 SPI_MODE0,结果芯片在错误的边沿采样,读到的基本是噪声。还有一个原因是 CS 拉低时间太早,SCLK 还没准备好就开始传输。解决:每次 SPI 通信都用SPI.beginTransaction显式指定模式,不要相信上一次SPI.begin()留下的默认状态。如果数据是 0x000000,重点查 DIN 和 DOUT 是不是接反了,这种错误方向性和前一种完全不同。
4.3 现象:波形上叠着明显的 50Hz 周期性包络
原因:偏置驱动没开,或者参考电压没接对。生物电采集里,人体就是一根大天线,50Hz 工频干扰无处不在。只有偏置驱动回路把人体电位钳制到参考地,共模干扰才会被有效抑制。之前的图纸里 CONFIG3 设成 0xEC 就是干这个的。如果配置没问题,再看电源:AVDD 直接从 ESP32 的 5V 引脚取电,开关噪声会直接混进模拟前端。解决:AVDD 换低压差线性稳压器供电,并串一个磁珠再进芯片。如果板子上有开关电源,务必让它离模拟输入插座远一点。
4.4 现象:每个通道都有波形,但通道 1 到通道 8 的顺序整体错位
原因:帧解析的位置算错了。ADS1299 的数据帧开头是 3 字节状态字,有些人会把状态字当成通道 0 数据,导致所有通道向前错一位。另一个原因是 CS 在读取过程中被意外拉高,SPI 传输被截断,后续数据全部提前。解决:先确认解析代码里从 frameBuffer[3] 开始取通道数据。再检查 CS 引脚有没有和别的外设共用,ESP32 的硬件 SPI 如果同时挂了别的设备,CS 被另一个库拉高是常有的事。可以在数据帧里写一个固定测试波形来验证顺序,最简单的是给所有通道输入同一测试信号,看 8 个通道是否同时变化。
4.5 现象:系统跑着跑着突然看门狗重启,串口打印 “Task watchdog got triggered”
原因:readFrame 里的等待循环在连续模式里没有退出条件,一旦 DRDY 因为干扰消失,循环就死等,低优先级任务被饿死,触发看门狗。另外 Wi-Fi 协议栈中断频繁,SPI 传输如果长时间占用临界区,也会拖垮系统。解决:等待 DRDY 的循环里加超时保护,超过 10ms 就放弃本次读取,打印错误计数。然后把读取和发送拆到两个核心,尽量不让 SPI 通信和 Wi-Fi 协议栈抢同一个核。再加一条防线:ADS1299 的 DRDY 引脚接 ESP32 外部中断,连续 5 次超时就主动给 ADS1299 发复位命令,这个自愈逻辑我保留在所有原型里。
5. 进阶验证:用 ADS1299 内置测试信号确认整条链路可信
测试信号是 ADS1299 最容易被忽略但最实用的功能之一。它不需要外接信号发生器,只要改 CONFIG2 寄存器,就能在芯片内部把一路已知信号送到所有通道的输入端。我用它做两件事:验证采样链路是否完整,以及验证通道之间是否同步。
具体步骤是:先把 CONFIG2 的测试信号使能打开,并选择方波输出。然后跑一段采集程序,把波形打印出来或者传到上位机。如果一切正常,所有 8 个通道应该同时看到同样幅度的方波,幅值约为参考电压的一半。这一步能直接暴露上面说的帧错位问题:如果某个通道的方波和别的通道错开,说明你的解析逻辑有 bug。
验证时要注意采样率和测试信号频率的匹配。测试方波频率比较高,如果采样率只设在 250Hz,从数字采样角度根本看不出来方波的形状,看到的只是混叠后的乱跳点。我一般会把采样率临时提高,或者用直流测试信号观察各通道的相对偏置。
我还有一个习惯:拿到任何新板子,都先跑一个整晚的测试信号采集。第二天醒来检查数据里有没有规律性的丢帧、通道偏移和幅度衰减。硬件老化、焊点虚接、电源热漂移这些问题,长时间跑测试信号会暴露得特别快。这套验证流程跑通以后,再换真实电极片做脑电采集,我对数据的信任度会高很多。
用 Python 快速验证 UDP 数据流的代码我也贴在下面。它监听固定端口,解析 36 字节数据包,直接把 8 个通道画出来。
import socket import matplotlib.pyplot as plt from collections import deque udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp.bind(("0.0.0.0", 12345)) udp.settimeout(2) channels = [deque([0.0] * 500, maxlen=500) for _ in range(8)] fig, axes = plt.subplots(8, 1, sharex=True, figsize=(10, 12)) while True: data, addr = udp.recvfrom(36) if data[0] != 0xAA: continue ch0 = int.from_bytes(data[4:8], "little", signed=True) / 1e6 channels[0].append(data[0]) off = 4 for ch in range(8): val = int.from_bytes(data[off:off+4], "little") * 1000000 channels[ch].append(val) off += 4 for ch in range(8): axes[ch].plot(channels[ch], color="C0") plt.pause(0.01)这段脚本的解析做了两件关键的事。第一,用data[0] != 0xAA丢弃错位的数据包,保证进入绘图的每一帧都在正确的边界上。第二,把所有通道放在同一个坐标轴里,如果某个通道出了异常,一眼就能看出来。实际测试时,先跑测试信号看方波,再跑空接输入看噪声底,最后再把电极贴到头皮上。每一步都有明确的判断标准,而不是“看起来好像有信号”。
我现在的习惯是,每做一版新的无线采集板,第一件事不是连电极,而是开测试方波跑通宵。第二天看数据,如果方波还在、幅度没变、通道没有错位,我才敢把板子往设备上装。这套流程帮我改掉了“采集到奇怪波形就怀疑芯片有问题”的毛病。希望帮到你。
本文还有配套的精品资源,点击获取