1. 心电采集仪的整体方案设计思路
1.1 为什么选择STM32作为主控
做心电采集这个方向,主控选型其实没有太多悬念。心电信号的采样率要求通常在250Hz到1kHz之间,加上还要做数字滤波、心率计算、波形显示或者串口上传,对MCU的运算能力和外设资源有一定要求。STM32F103系列在这类生物电信号采集项目里出现频率极高,原因很直接:Cortex-M3内核跑72MHz,自带12位ADC,多路定时器和DMA,串口、SPI、I2C一应俱全,关键是资料多、价格低、开发工具链成熟。
我实际对比过几种方案。用51单片机也能做心电采集,但ADC外置、RAM太小、做不了实时滤波,波形出来毛刺多得没法看。用ESP32的话WiFi功能是加分项,但模拟部分的电源噪声控制会更麻烦,而且ADC线性度在精密采集场景下不如STM32稳定。所以STM32F103C8T6这个最小系统板就成了性价比最高的选择,几十块钱就能搞定核心板。
1.2 模拟前端的核心架构
心电信号的本质是什么?体表电极采集到的差分电压信号,幅度在0.5mV到4mV之间,频率主要集中在0.05Hz到100Hz。这个信号有两个特点让电路设计变得棘手:一是极其微弱,二是叠加了大量共模干扰(主要是50Hz工频干扰)。
所以模拟前端需要完成三件事:差分放大、共模抑制、滤波。典型架构是三级处理:第一级用仪表放大器做差分放大和共模抑制,第二级用运放做高通滤波去掉直流偏置,第三级做低通滤波限制带宽并进一步放大。
这里就涉及到AD620和OP07的配合。AD620是经典的仪表放大器,增益用一个外部电阻就能设定,共模抑制比(CMRR)在G=100时能达到100dB以上,输入阻抗高,噪声低,非常适合做第一级。OP07则是精密运放,失调电压极低(典型值10μV),温漂小,适合做后续的滤波和放大级。这两个器件的组合在低成本心电采集方案里非常经典。
1.3 整体信号链规划
从电极到STM32的ADC输入,完整的信号链是这样的:
- 体表电极(三导联:RA、LA、RL)采集原始心电信号
- 进入AD620差分放大级,增益设为约100倍
- 经过OP07构成的高通滤波器(截止频率约0.05Hz),去除电极极化产生的直流偏置
- 再经过OP07构成的低通滤波器(截止频率约100Hz),滤除高频噪声
- 可选加一级50Hz陷波器抑制工频干扰
- 信号送入STM32的ADC引脚进行采样
- STM32内部做数字滤波、QRS检测、心率计算
- 通过OLED或串口输出结果
这个链路看起来简单,但每一级的参数选择都有讲究,后面会逐一拆解。
2. 模拟前端电路的深度解析与参数计算
2.1 AD620差分放大级的设计要点
AD620的增益公式是G = 1 + 49.4kΩ / Rg,其中Rg是接在1脚和8脚之间的增益电阻。要得到G=100,计算一下:Rg = 49.4kΩ / (100-1) ≈ 499Ω。实际选标准值499Ω或者510Ω的金属膜电阻,精度至少1%,温漂越小越好。
这里有个容易踩的坑:很多人直接把AD620的增益设到1000倍,觉得放大越多越好。实际上第一级增益太高会导致饱和——电极刚接触皮肤时会有较大的瞬态偏置,放大1000倍直接撞轨。而且AD620在增益很高时带宽会下降,G=1000时带宽只剩几十kHz,虽然对心电信号够用,但噪声增益也会同步放大。我的经验是G=100左右最稳妥,后续再用OP07补增益。
AD620的5脚是REF引脚,决定了输出信号的参考电平。在单电源供电场景下,这个引脚不能接地,要接到一个中间电平(比如VCC/2),否则负半周信号会被削掉。我一般用两个等值电阻分压加一个跟随器来产生这个参考电压,保证低阻抗输出。
注意:AD620的电源去耦非常关键。每个电源引脚旁边必须放0.1μF陶瓷电容和10μF钽电容,且尽量靠近芯片引脚。我在第一版PCB上忽略了这点,结果波形上叠加了明显的开关噪声,排查了半天才发现是去耦不足。
2.2 OP07高通滤波器的参数选择
高通滤波器的目的是去掉直流偏置。心电信号的最低频率成分大约在0.05Hz,所以高通截止频率要低于这个值,通常设在0.03Hz到0.05Hz之间。
用OP07搭一阶有源高通滤波器,截止频率公式是fc = 1 / (2πRC)。取fc=0.05Hz,如果C=10μF,则R = 1/(2π×0.05×10×10^-6) ≈ 318kΩ。这个电阻值比较大,要注意OP07的输入偏置电流在电阻上产生的额外失调。OP07的输入偏置电流典型值是±2nA,在318kΩ上产生的压降约0.6mV,相对于心电信号幅度来说可以接受,但如果用更高阻值就要小心了。
电容的选择也有讲究。10μF的电容如果用陶瓷电容,容值随电压和温度变化较大,而且有压电效应,振动会产生噪声。我推荐用钽电容或者薄膜电容,稳定性好得多。之前用0805封装的X7R陶瓷电容做这个位置,结果用手碰一下板子波形就抖,换成钽电容后问题消失。
2.3 OP07低通滤波器的实现
低通滤波器的作用是限制带宽,滤除100Hz以上的高频噪声。截止频率设在100Hz到150Hz之间比较合适,既能保留心电信号的主要成分,又能有效抑制高频干扰。
同样用OP07搭一阶有源低通,fc = 1/(2πRC)。取fc=100Hz,如果R=100kΩ,则C = 1/(2π×100×100×10^3) ≈ 16nF。选标准值15nF或18nF都可以。
一阶滤波器的滚降斜率是-20dB/十倍频程,对高频噪声的抑制能力有限。如果对波形质量要求高,可以用二阶Sallen-Key结构,滚降变成-40dB/十倍频程。但二阶电路对元件匹配要求更高,调试也更麻烦。我的建议是先用一阶跑通,确认整体链路没问题后再考虑升级。
2.4 50Hz工频陷波器的取舍
工频干扰是心电采集最大的敌人。虽然AD620的共模抑制比很高,但电极不平衡、导线走线不对称等因素会把共模干扰转化成差模信号,最终出现在输出端。
陷波器可以有效抑制50Hz干扰,但它也有副作用:陷波器在50Hz附近引入相位失真,而心电信号的QRS波群频谱成分恰好覆盖这个频段,可能导致波形畸变。而且陷波器的Q值越高,对元件精度的要求就越苛刻,稍微偏差一点陷波频率就偏了。
我的实际做法是:先不加陷波器,通过良好的电极接触、屏蔽导线、合理的PCB布局来降低工频干扰。如果实测下来50Hz干扰仍然明显,再考虑加陷波器。加的话用双T网络配合OP07做有源陷波,Q值不要设太高,3到5左右比较合适。
3. STM32端的软件设计与实操过程
3.1 开发环境搭建与工程配置
STM32的开发环境选择上,Keil MDK和STM32CubeIDE是主流。Keil的优势是编译速度快、调试方便,但需要手动安装芯片包。CubeIDE免费且集成了CubeMX配置工具,生成初始化代码很方便。
如果用Keil,安装完MDK后需要下载STM32F1系列的Device Family Pack。这一步经常有人卡住——打开Keil后找不到STM32的器件选项,就是因为芯片包没装。下载好.pack文件后双击安装即可。另外注意Keil5默认不兼容C51,如果之前装过Keil4做51开发,两者可以共存但需要分别安装在不同目录。
新建工程时,我习惯用标准库而不是HAL库。标准库的代码量小、执行效率高,对F103这种资源有限的芯片更友好。工程目录结构一般是:
Project/ ├── CMSIS/ # 内核相关文件 ├── FWLIB/ # 标准外设库 ├── USER/ # 用户代码 │ ├── main.c │ ├── stm32f10x_it.c │ └── ... ├── HARDWARE/ # 硬件驱动 │ ├── adc.c │ ├── oled.c │ └── ... └── SYSTEM/ # 系统文件 ├── delay.c ├── usart.c └── ...3.2 ADC采样与DMA传输配置
心电采集对ADC的要求是:采样率稳定、不丢点、噪声低。STM32F103的ADC最高采样率是1MSPS(12位),但实际用的时候不需要那么快。心电信号带宽100Hz,按照奈奎斯特采样定理,采样率至少200Hz,实际取250Hz到500Hz就够了。
我用定时器触发ADC采样,这样采样率最稳定。具体配置:TIM2设置为PWM模式,频率设为500Hz,用TIM2的更新事件触发ADC规则通道转换。ADC配置为连续转换模式,开启DMA传输,把转换结果自动搬到内存数组里。这样CPU完全不用干预采样过程,只需要在DMA传输完成中断里处理数据。
ADC的参考电压直接用VDDA,所以模拟部分的电源质量直接影响采集精度。建议VDDA和VSSA单独走线,在靠近芯片的地方加磁珠和电容滤波。如果对精度要求高,可以用外部基准电压芯片如REF3033提供3.3V基准。
// ADC初始化关键代码 void ADC_Init_Config(void) { ADC_InitTypeDef ADC_InitStructure; DMA_InitTypeDef DMA_InitStructure; // DMA配置 DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr = (u32)&ADC1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (u32)&adc_buffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = ADC_BUFFER_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_Init(DMA1_Channel1, &DMA_InitStructure); DMA_Cmd(DMA1_Channel1, ENABLE); // ADC配置 ADC_DeInit(ADC1); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_CC2; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_ExternalTrigConvCmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); // ADC校准 ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); }3.3 数字滤波与QRS检测算法
ADC采到的原始数据噪声很大,必须做数字滤波。我一般用两级滤波:先做一个IIR低通滤波器去掉高频噪声,再用一个滑动平均滤波器进一步平滑波形。
IIR低通滤波器的差分方程是:
y[n] = α × x[n] + (1-α) × y[n-1]
其中α = dt / (RC + dt),dt是采样周期,RC是时间常数。采样率500Hz时,dt=2ms,如果截止频率取40Hz,RC = 1/(2π×40) ≈ 4ms,则α = 2/(4+2) ≈ 0.33。
QRS检测是心率计算的核心。我用的是一种简化的阈值法:先对滤波后的信号做差分平方运算,增强QRS波群的斜率特征,再用移动窗口积分得到包络信号,最后用自适应阈值判断QRS波群的位置。
具体步骤:
- 对滤波后的信号做五点差分:d[n] = x[n+2] - x[n-2]
- 平方:s[n] = d[n] × d[n]
- 移动窗口积分(窗口宽度约150ms,即75个采样点):y[n] = (1/N) × Σs[i]
- 自适应阈值:threshold = 0.5 × max(y[n-100:n])
- 当y[n]超过阈值且在一定不应期内(200ms),判定为一次QRS波群
心率计算公式:HR = 60 / RR间期(秒)。为了稳定显示,我一般取最近8个RR间期的平均值来计算心率。
3.4 OLED显示与串口数据上传
显示部分用0.96寸OLED(SSD1306驱动,I2C接口)就够了。屏幕上分两行显示:第一行显示实时心率值,第二行画一个简易的心电波形。波形绘制就是把最近128个采样点映射到屏幕的Y轴范围,用画点函数逐点绘制。
串口上传是为了在电脑上做进一步分析。STM32的USART1配置为115200波特率,8位数据位,1位停止位,无校验。每采完一帧数据(比如256个点),通过串口发送一个数据包,格式为:帧头(0xAA 0x55)+ 数据长度 + 数据内容 + 校验和。上位机用Python或MATLAB接收并实时绘图。
// 串口发送数据包 void Send_ECG_Frame(u16 *data, u16 len) { u8 header[2] = {0xAA, 0x55}; u8 checksum = 0; USART_SendData(USART1, header[0]); while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, header[1]); while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); for(u16 i = 0; i < len; i++) { u8 high = (data[i] >> 8) & 0xFF; u8 low = data[i] & 0xFF; USART_SendData(USART1, high); while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, low); while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); checksum += high + low; } USART_SendData(USART1, checksum); while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); }4. 常见问题排查与实操避坑指南
4.1 波形噪声大、基线漂移严重
这是心电采集最常遇到的问题。波形上叠加高频毛刺,或者基线像波浪一样上下漂移。排查思路要按信号链逐级定位。
先看电源。用示波器交流耦合档测模拟电源轨上的纹波,如果纹波超过几mV,说明电源滤波不够。解决办法是在每个运放电源引脚旁加0.1μF陶瓷电容和10μF钽电容,模拟部分和数字部分用磁珠隔离供电。
再看电极。电极接触不良是基线漂移的头号原因。一次性心电电极的导电膏干了之后接触阻抗急剧上升,导致信号漂移。建议用质量好一点的电极,贴之前用酒精棉擦拭皮肤去除油脂。如果只是做实验,可以用镀金电极片配合导电膏。
最后看PCB布局。模拟走线和数字走线要分开,不能平行走线。AD620的输入引脚走线要尽量短且对称,减少共模干扰转化为差模信号的机会。地平面要完整,模拟地和数字地单点连接。
4.2 ADC采样值跳动大
如果ADC读出来的值一直在跳,先确认参考电压是否稳定。用万用表测VDDA引脚,如果电压波动超过几mV,采集精度肯定受影响。可以在VDDA引脚加一个100μF电解电容和一个0.1μF陶瓷电容。
另一个原因是ADC采样时间设置太短。STM32的ADC输入阻抗和采样电容需要足够的充电时间。如果信号源阻抗较高(比如经过大电阻分压后),采样时间要设长一些。我一般用55.5个周期的采样时间,对应约1μs的采样窗口,对大多数场景够用。
还有一点容易忽略:ADC的输入电压范围是0到VDDA,如果模拟前端输出的信号超出了这个范围,ADC会削顶。所以要确保AD620的REF引脚电压设置正确,使输出信号始终在0到3.3V之间。
4.3 心率计算不准或跳变
心率跳变通常是因为QRS检测算法把噪声误判为QRS波群,或者漏检了真实的QRS波群。改进方向有几个:
- 调整差分窗口大小。窗口太窄对噪声敏感,太宽会漏掉窄QRS波。一般取采样率的10%到20%比较合适。
- 增加不应期。两次QRS波群之间的最小间隔设为200ms,防止T波被误判。
- 用双阈值检测。高阈值确保不漏检,低阈值配合搜索回溯机制防止漏检。
- 对RR间期做中值滤波。连续8个RR间期去掉最大最小值后取平均,能有效抑制偶发的误检。
我在实际调试中发现,如果波形质量本身不好,再怎么优化算法也是事倍功半。所以优先把模拟前端的信号质量做好,算法只是锦上添花。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 波形全是噪声 | 电极未接好或脱落 | 检查电极接触 | 重新贴电极,用导电膏 |
| 基线漂移严重 | 电极极化或接触阻抗变化 | 观察漂移频率 | 更换电极,加高通滤波 |
| 50Hz干扰明显 | 电源干扰或走线不对称 | 示波器看频谱 | 加陷波器,改善屏蔽 |
| ADC值跳动大 | 参考电压不稳或采样时间短 | 测VDDA纹波 | 加去耦电容,增加采样时间 |
| 心率跳变 | QRS误检或漏检 | 打印RR间期 | 优化阈值和不应期 |
| 串口数据乱码 | 波特率不匹配 | 检查两端配置 | 统一波特率设置 |
| OLED不显示 | I2C地址错误或接线问题 | 用逻辑分析仪抓I2C | 确认地址0x78或0x7A |
4.5 几个容易被忽略的实操细节
电极导线的屏蔽:从电极到AD620输入端的导线一定要用屏蔽线,屏蔽层接模拟地。普通杜邦线做心电采集,工频干扰会大到没法看。我试过用屏蔽线和不屏蔽线的对比,效果差距非常明显。
AD620的输入保护:人体可能带静电,直接接触电极时瞬间高压可能损坏AD620。在输入端串联一个10kΩ电阻,再并联两个反向的二极管到电源和地,可以起到保护作用。虽然AD620内部有一定保护,但外部再加一层更保险。
STM32的JTAG引脚复用:STM32F103的PA15、PB3、PB4默认是JTAG功能,如果要用作普通GPIO需要禁用JTAG。但禁用后就不能用JTAG调试了,只能用SWD。所以建议一开始就用SWD模式,保留PA15、PB3、PB4作为普通IO使用。
延时函数卡死:如果用SysTick做延时,在中断里调用延时函数会导致卡死。因为SysTick中断优先级如果低于当前中断,中断嵌套时SysTick无法触发,延时函数就死循环了。解决办法是在中断里不要用延时,或者用其他定时器做延时。
电源上电顺序:模拟部分和数字部分如果分别供电,要注意上电顺序。先给数字部分上电再给模拟部分上电,或者同时上电。如果模拟部分先上电,运放输出可能通过保护二极管向数字部分漏电流,导致异常。
5. 从原型到成品的优化方向
5.1 硬件层面的改进空间
原型阶段用模块搭出来的电路,做到成品需要考虑更多。PCB布局上,模拟部分和数字部分要分区布局,AD620和OP07尽量靠近电极接口,减少输入走线长度。电源部分用LDO单独给模拟供电,比如用AMS1117-3.3给数字部分,再用一个低噪声LDO给模拟部分。
如果要做便携式,锂电池供电是必然选择。但锂电池的电压范围是3.0V到4.2V,需要升降压转换。这里可以用STM32配合Buck-Boost电路实现,不过要注意开关电源的纹波对模拟电路的干扰。我的做法是开关电源先升到5V,再用LDO降到3.3V给模拟部分,这样纹波能压到很低。
5.2 软件层面的功能扩展
基础版本跑通后,可以加不少实用功能。比如用STM32的内部RTC加时间戳,把心电数据存储到SD卡里,方便后续回放分析。或者加一个蓝牙模块,把数据无线传到手机APP上显示。
如果要做心律失常的简单分类,可以在STM32上跑一个轻量级的神经网络。不过F103的RAM只有20KB,跑神经网络比较吃力,可以考虑换F4系列。或者把特征提取放在STM32上做,把特征值传到上位机做分类。
5.3 标定与验证方法
心电采集仪做出来之后,怎么验证它准不准?最直接的方法是用标准信号源输入已知幅度和频率的正弦波,看采集出来的波形幅度和频率是否一致。比如输入1mV峰峰值的100Hz正弦波,增益设为1000倍,ADC读到的值应该在1V左右(对应ADC值约1240)。
另一个方法是用商用心电模拟器,它能输出标准的心电波形,包括各种心律失常模式。对比采集波形和标准波形,可以评估系统的保真度。如果没有这些设备,至少要和商用心电监测设备做同步对比,看心率读数是否一致。
我在实际测试中发现,系统在静态下的噪声水平大约在20μV峰峰值左右,对于心电信号(典型1mV)来说信噪比约34dB,足够看清P波、QRS波群和T波。如果要看清更微弱的信号细节,比如心房颤动时的f波,还需要进一步降低噪声,这就对电源和布局提出了更高要求。
最后分享一个调试小技巧:在AD620的输出端接一个测试点,用示波器直接观察模拟前端的输出波形。这样可以把问题定位在模拟部分还是数字部分。如果模拟输出干净但ADC读出来脏,问题在ADC配置或电源;如果模拟输出本身就脏,问题在前端电路。这个分界点能帮你省下大量排查时间。