☰
STM32F103心电采集实战:模拟前端与数字滤波全解析
2026/10/15 16:00:14 网站建设 项目流程

简介:这是一套完整的基于STM32F103与AD8232的心电信号采集系统项目,面向嵌入式开发者和生物医学工程学习者,解决从模拟心电信号获取、微控制器处理到上位机波形显示的全链路实现难题,覆盖硬件接口设计、USART2串口通信和信号处理算法等关键环节。包内共191个文件,以C语言源码(.c/.h)和工程配置文件为主,包含Keil项目(.uvprojx/.uvoptx)可直接打开编译,附带编译生成的.hex/.axf固件、调试记录及辅助脚本,压缩包仅3.74MB,目录结构清晰,便于按模块查阅与移植。已有902人学习,项目将AD8232模拟前端输出接入STM32的PA3引脚,通过USART2与vofa+上位机交互,实现心电波形实时显示,体现了GPIO、定时器、串口中断、数据滤波等知识点的综合应用。开发者可在源码中学习AD8232的滤波放大配置、串口协议的具体定义和上位机数据解析流程,也能参考其整体工程组织方式,作为医疗电子或便携式生理信号监测项目的基础模板。

1. 心电采集看着简单,难点全在模拟前端:一张波形图说服你

第一次给这套基于STM32F103的心电信号采集系统上电时,我满怀信心:ADC读数据、串口发曲线,这不是嵌入式入门基本功吗?结果屏幕上显示的是一条约 50Hz 的粗正弦线,幅度几十毫伏,中间完全找不到 P、QRS、T 波的影子。后来我才意识到,心电采集真正磨人的地方,不在单片机,而在模拟前端和数字滤波——传感器贴在人身上,人体就是天线,工频干扰、肌电噪声、呼吸引起的基线漂移会一并混进来。

这套系统实现的是一条完整的低成本链路:仪表放大器做差分拾取,右腿驱动抑制共模干扰,带通滤波器做频带整形,STM32F103 的定时器触发 ADC 以 500Hz 采样,DMA 循环搬运,再在单片机里做 50Hz 陷波和基线漂移抑制。适合的人群很明确:做便携健康设备的预研、低成本原型验证、相关方向的课程设计。读完之后,你能清楚知道每级放大该给多少增益、采样率为什么取 500Hz、数字滤波的系数怎么算,以及那几个最容易翻车的细节。

2. 模拟前端设计:心电采集的真正门槛

2.1 仪表放大器加右腿驱动:把差分信号从共模噪声里捞出来

心电信号在体表只有 0.5~4mV,而人体同时携带幅度可达几伏的 50Hz 共模干扰。想要从这么大的噪声里捞出有用信号,第一级必须用仪表放大器而不是普通运放。仪表放大器最大的优势是差分输入、高共模抑制比(CMRR),它只放大两个电极之间的电压差,对两个电极上同时出现的共模信号天然免疫。

增益通过一个外部电阻 RG 设定,常见仪表放大器的计算公式为 G = 1 + 49.4kΩ / RG。我一般把第一级设在 20 倍,RG 取 2.61kΩ 标称电阻,这样既不会因为增益太高导致输出饱和,又能在后级继续做频率整形。这里有个容易忽略的细节:仪表放大器的参考端(REF)不是随便接地的,它决定了输出直流电平。我会把 REF 接到一个 1.65V 的偏置上,让输出中点落在 ADC 量程的中间,避免信号只在一半幅度内摆动。

右腿驱动是抑制共模干扰的关键一招。简单说,就是把两个输入电极的共模电压取出来,反相放大之后反馈到人体右腿,形成一个负反馈环路,把人体上耦合进来的共模电压压下去。具体实现是用两个等值电阻(比如 100kΩ)从输入端取共模中点,送入反向放大器,输出经过一个 100kΩ 限流电阻接到右腿电极。这个环路的增益不需要太高,10~50 倍就够了,太高容易振荡。

2.2 带通整形与主放大:把频带和幅度送到 ADC 的甜点区

心电信号的频率范围大约在 0.05~100Hz,其中 QRS 波群的能量集中在 10Hz 附近。模拟前端需要把带外噪声压掉:高通用于去掉电极极化电压和呼吸引起的超低频漂移,低通用于滤掉肌电和部分高频噪声。我常用的做法是:仪表放大器输出后接一阶 RC 高通,C 取 1μF、R 取 1.6MΩ,截止频率约 0.1Hz;再接一个 Sallen-Key 结构的二阶低通,R 取 100kΩ、C 取 10nF,截止频率约 159Hz。

主放大器放在滤波之后,增益取 50 倍。这样整个链路总增益是 20 × 50 = 1000 倍,1mV 的心电信号到 ADC 输入端就变成 1V。STM32F103 的 ADC 参考电压是 3.3V,1V 幅度的信号大概占量程的 30%,既不削顶也不浪费动态范围。如果你的电极贴得更紧、信号更大,可以把主放大增益往下调;如果被测者体脂偏厚、信号偏弱,就需要往上调。这个增益不是死的,最好做成可调。

2.3 分立方案还是专用模拟前端芯片:两条路怎么选

自己搭仪表放大器加运放方案,成本低、器件常见,方便理解每一级的工作点,调试时也能用示波器逐级定位问题,特别适合原型验证和教学场景。缺点是体积大、功耗高、电磁兼容要靠自己调,量产一致性也差一些。

如果目标直接是产品化,我更建议换成专用模拟前端芯片。这类芯片把仪表放大器、右腿驱动、滤波器、24 位 ADC 全部集成在一起,有的还内置了导联脱落检测和威尔逊中央电端,STM32F103 只需要通过 SPI 把数据读出来就行。开发时间能省一大半,功耗也低一个数量级。

对比项分立仪表放大器方案专用模拟前端芯片方案
成本低中
开发难度高,需调增益和滤波低,寄存器配置为主
体积与功耗大、高小、低
学习价值高,能摸到每个环节中,偏应用
适合阶段原型验证、课程设计预研转产品、量产

3. 定时器触发 ADC 多通道采集:采样率怎么定,DMA 怎么收

3.1 采样率 500Hz 和 12 位分辨率:够用且留了余量

先算一笔账。心电诊断设备常把带宽做到 0.05~100Hz,根据奈奎斯特采样定理,采样率至少是最高频率的两倍,也就是 200Hz。实际工程里不会贴着理论值跑,我习惯取 2.5 倍余量,定在 500Hz,也就是每毫秒采一个点。这个频率既能保住 QRS 波的形态细节,又不会给 STM32F103 带来太大的计算压力。

分辨率方面,STM32F103 的 ADC 是 12 位,在 3.3V 参考下每个最小量化步长约 0.8mV。看起来不怎么样,但别忘了前面还有 1000 倍模拟增益,折算到输入端等效分辨率约 0.8μV,对心电采集来说足够了。过度追求高分辨率没有意义,模拟前端的噪声电平往往比量化噪声高得多。

3.2 定时器 TRGO 触发 ADC 加 DMA 循环存储:完整配置

手动在循环里触发 ADC 的问题是采样间隔受主循环代码影响,抖动很大,波形会失真。正确做法是用定时器产生固定频率的触发信号,接 ADC 的外部触发引脚,ADC 完成转换后自动把结果搬运到内存,全程不需要 CPU 干预。以下配置基于标准外设库,先看定时器:

void TIM3_Trigger_Init(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); TIM_TimeBaseInitTypeDef tim; tim.TIM_Prescaler = 72 - 1; // 72MHz / 72 = 1MHz tim.TIM_CounterMode = TIM_CounterMode_Up; tim.TIM_Period = 2000 - 1; // 1MHz / 2000 = 500Hz 采样率 tim.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, &tim); // TIM3 更新事件作为 TRGO,触发 ADC 转换 TIM_SelectOutputTrigger(TIM3, TIM_TRGOSource_Update); TIM_Cmd(TIM3, ENABLE); }

核心逻辑是让 TIM3 每 2ms 产生一次更新事件,通过 TRGO 引脚通知 ADC 启动一次转换。预分频 71 把 72MHz 系统时钟降到 1MHz,重装值 1999 再分频得到 500Hz。想改采样率,只需调整这两个值,比如把重装值改成 999,采样率就是 1kHz。

接下来配 ADC 和 DMA,寄存器配置如下:

#define ECG_BUFFER_SIZE 400 // 500Hz 采样下,0.8 秒一帧 uint16_t ecg_buffer[ECG_BUFFER_SIZE]; void ADC_DMA_Init(void) { GPIO_InitTypeDef gpio; gpio.GPIO_Pin = GPIO_Pin_0; // PA0 接模拟前端输出 gpio.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &gpio); RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); ADC_InitTypeDef adc; adc.ADC_Mode = ADC_Mode_Independent; adc.ADC_ScanConvMode = DISABLE; // 单通道,不扫描 adc.ADC_ContinuousConvMode = DISABLE; // 由定时器外部触发 adc.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T3_TRGO; adc.ADC_DataAlign = ADC_DataAlign_Right; adc.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &adc); // 心电信号源阻抗高,采样时间设为最长的 239.5 周期 ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_ExternalTrigConvCmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); DMA_InitTypeDef dma; dma.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)ecg_buffer; dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = ECG_BUFFER_SIZE; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; dma.DMA_Mode = DMA_Mode_Circular; // 循环模式,采完从头覆盖 dma.DMA_Priority = DMA_Priority_High; DMA_Init(DMA1_Channel1, &dma); DMA_ITConfig(DMA1_Channel1, DMA_IT_TC | DMA_IT_HT, ENABLE); DMA_Cmd(DMA1_Channel1, ENABLE); }

这里最关键的参数是ADC_SampleTime_239Cycles5。很多人忽略采样时间,ADC 内部采样电容需要在规定时间内充到输入电压。心电信号经过模拟前端后源阻抗仍然偏高,如果用默认的 1.5 周期或 7.5 周期,采样电容充不满,读到的数值会系统性偏低且抖动明显。239.5 周期换算一下是 3.3μs,对 500Hz 的采样率绰绰有余。

3.3 用 DMA 半传输中断实现双缓冲:主循环不丢数据

DMA 设成循环模式后,它会循环往数组里写数据。问题来了:如果主循环处理数组时,DMA 正在往数组后半段写新数据,读写就会冲突。解决办法是启用半传输中断和传输完成中断,把缓冲区切成两半交替处理。400 个采样点,前 200 个采满触发半传输中断,后 200 个采满触发传输完成中断,主循环总在处理「当前没被 DMA 写入」的那半块数据。

volatile uint8_t ecg_half_flag = 0; volatile uint8_t ecg_full_flag = 0; void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_HT1)) { ecg_half_flag = 1; DMA_ClearITPendingBit(DMA1_IT_HT1); } if (DMA_GetITStatus(DMA1_IT_TC1)) { ecg_full_flag = 1; DMA_ClearITPendingBit(DMA1_IT_TC1); } }

主循环里只在标志位为 1 且上一次处理已完成时才处理数据,处理完立刻清标志。这里要注意:不要在中断服务函数里做滤波或存储操作,否则中断占用时间过长,下一轮 DMA 触发时可能出现竞争。中断里只置标志位,数据加工全部放到主循环,这是嵌入式开发的通用原则。

4. 数字滤波才是波形好看的关键:50Hz 陷波与基线漂移抑制

4.1 为什么模拟端做了滤波还要数字端再滤一遍

模拟端的低通和高通只能做粗略的频带限制,真正难对付的是 50Hz 工频干扰。右腿驱动能抑制大部分共模干扰,但信号经过长导线传输时,差模路径上仍会感应出工频分量,而且人体内部的电极极化电位也会缓慢变化。模拟滤波要做到 50Hz 处极窄的陷波,需要高精度元件和精心调试的电感或双 T 网络,性价比很低。

所以工程上普遍的做法是:模拟端粗滤,数字端精滤。STM32F103 主频 72MHz,做几阶 IIR 滤波器完全跑得动。数字滤波器没有元件温漂问题,系数定了就是定了,一致性远好于模拟方案。这就是我用数字陷波处理 50Hz 的原因。

4.2 50Hz 陷波器的 C 实现与 Q 值选择

数字陷波滤波器我常用双二阶 IIR 结构,它只保留 5 个系数和 4 个历史状态变量,非常适合在单片机上跑。先看初始化和处理函数,这是可以直接抄走的代码:

#include <math.h> typedef struct { float b0, b1, b2; float a1, a2; float x1, x2, y1, y2; } IIRBiquad; // 初始化 50Hz 陷波器,fs 为采样率,f0 为陷波频率,Q 控制带宽 void notch_init(IIRBiquad *f, float fs, float f0, float Q) { float w0 = 2.0f * PI * f0 / fs; float alpha = sinf(w0) / (2.0f * Q); float a0 = 1.0f + alpha; f->b0 = 1.0f / a0; f->b1 = (-2.0f * cosf(w0)) / a0; f->b2 = 1.0f / a0; f->a1 = (-2.0f * cosf(w0)) / a0; f->a2 = (1.0f - alpha) / a0; f->x1 = f->x2 = 0.0f; f->y1 = f->y2 = 0.0f; } // 逐点滤波,x 为当前采样值,返回滤波结果 float notch_process(IIRBiquad *f, float x) { float y = f->b0 * x + f->b1 * f->x1 + f->b2 * f->x2 - f->a1 * f->y1 - f->a2 * f->y2; f->x2 = f->x1; f->x1 = x; f->y2 = f->y1; f->y1 = y; return y; }

调用方式是在主循环里对每个采样点逐个处理:

IIRBiquad notch; notch_init(&notch, 500.0f, 50.0f, 20.0f); for (int i = 0; i < ECG_BUFFER_SIZE; i++) { ecg_buffer[i] = notch_process(&notch, ecg_buffer[i]); }

Q 值这个参数需要认真对待。Q 值越高,陷波带宽越窄,对 50Hz 附近的信号伤害越小,但动态响应变慢,处理 QRS 波这种陡峭信号时会产生振铃;Q 值太低,比如小于 5,陷波带宽会宽到把 40Hz 和 60Hz 附近的有效频段一起削弱,T 波形态会受影响。我在 500Hz 采样率下常用的 Q 值是 15~30,这个范围能在压制工频和保留波形细节之间取得平衡。

4.3 基线漂移怎么抑制:二阶高通滤波器的边界

基线漂移来自两个来源:一是电极与皮肤接触界面电位缓慢变化,二是呼吸引起的胸部形变让电极位置微动。漂移的频率通常在 0.05~0.5Hz 之间,形态上表现为整条波形上下缓慢浮动。解决办法是加高通滤波,但截止频率必须谨慎选择。

心电信号里的 ST 段频率低至 0.67Hz 左右,如果高通截止频率设到 1Hz 以上,ST 段会被严重压制,波形看上去像是「飘在空中」,临床意义的形态判断会失真。所以基线漂移高通截止频率建议控制在 0.5Hz 以下。以下是一个二阶巴特沃斯高通的实现:

// 初始化高通滤波器,fc 为截止频率 void highpass_init(IIRBiquad *f, float fs, float fc) { float w0 = 2.0f * PI * fc / fs; float alpha = sinf(w0) * 0.7071f; // sqrt(2)/2,巴特沃斯系数 float b0 = (1.0f + cosf(w0)) / 2.0f; float b1 = -(1.0f + cosf(w0)); float b2 = b0; float a0 = 1.0f + alpha; float a1 = -2.0f * cosf(w0); float a2 = 1.0f - alpha; f->b0 = b0 / a0; f->b1 = b1 / a0; f->b2 = b2 / a0; f->a1 = a1 / a0; f->a2 = a2 / a0; f->x1 = f->x2 = 0.0f; f->y1 = f->y2 = 0.0f; }

我在实际测试中取 fc = 0.3Hz,采样率 500Hz,效果是基线在 5 秒内恢复到零点附近,同时 ST 段形状没有明显失真。需要提醒的是,IIR 滤波器有一种固有特性叫「相位失真」,对心电这种时域形态敏感的波形会有影响,但 50Hz 陷波和 0.3Hz 高通带来的失真在原型验证阶段完全可以接受。如果未来要做更严谨的波形分析,可以考虑用零相位滤波,但那需要整帧处理,不适合实时流式传输的场景。

5. 心电采集的 6 个高频踩坑点:从波形发飘到右腿驱动饱和

5.1 波形整体发飘、ADC 读数跳变:先查参考电压和采样时间

现象:输出波形轮廓大体像心电,但毛刺明显,相邻采样点之间随机跳变几十个 LSB,用手靠近板子时更严重。

原因有两个,第一个是 ADC 参考电压。很多板子直接把 3.3V 电源接到 VREF+ 引脚,而这个 3.3V 如果来自 USB 转串口模块或开关电源,纹波可能达到几十毫伏,ADC 量化时会直接把这部分纹波采进去。我踩过这个坑,用示波器看 VREF 引脚,上面叠加着明显的开关噪声。

解决:给模拟部分单独供电,VDDA 和 VREF 之间加磁珠和 10μF 电容,或者干脆用独立基准芯片把参考电压固定住。更省事的办法是测试时改用电池供电,电源纯净度立刻上一个台阶。

第二个原因是 ADC 采样时间配置太短。代码里如果用了默认采样时间,ADC 内部采样开关闭合时间太短,外部源阻抗又高,采样电容电压还没稳定就被断开,每次采的值都带随机误差。解决方法是把采样时间改到 239.5 周期,配合降低源阻抗,在模拟前端输出和 ADC 引脚之间加一个电压跟随器也能显著改善。

5.2 50Hz 干扰压不下去:右腿驱动饱和与地线环路

现象:滤波代码加了陷波器,50Hz 依然明显,波形像一条抖动的水草;陷波器输出的波形虽然变平滑,但 QRS 波幅度明显减小。

原因在于陷波器只能处理数字化后的信号,如果模拟前端在 ADC 之前就已经饱和或失真,数字滤波救不回来。最常见的诱因是右腿驱动饱和:电极接触不良、右腿电极脱落或人体与板子之间阻抗过大时,共模反馈环路失效,仪表放大器的共模抑制能力大幅下降,输出端混入大量 50Hz 信号,幅度可能达到几百毫伏,直接顶到运放输出摆幅边界。

解决:先用万用表确认电极与皮肤之间的阻抗,通常应该低于几十千欧。再查右腿驱动输出,如果它已经接近电源轨,说明反馈环路在拼命工作但没效果。我给右腿驱动输出串了一个 100kΩ 保护电阻,同时在软件里加了一个导联脱落检测:当信号长时间落在 ADC 满量程附近且没有心跳波形时,提示用户重新贴电极。

现象:示波器探头接地夹夹在板子数字地上时,50Hz 干扰异常增大。

原因:示波器探头接地线通过电源地形成一个大环路,环路像天线一样接收工频磁场。这在调试时极具迷惑性,因为硬件没变,只是测量方式变了。解决:调试时使用隔离变压器或差分探头,或者把示波器地线夹到模拟地上,单点连接避免环路。

5.3 DMA 数据错位、波形有断裂:缓冲区和中断时机不对

现象:串口打印波形时,每隔固定间隔会出现一次跳变,波形从中间断开,而且断开位置每次都落在数组边界附近。

原因:DMA 循环模式写满整个数组后从头覆盖,主循环读数据的时候,DMA 已经写到了数组的前半段,二者产生竞争。如果只在传输完成中断里处理数据,处理期间 DMA 继续写入,等处理完成时数组的前半部分已经混合了新老两批数据。

解决:使用半传输中断加传输完成中断的双缓冲机制,第 3.3 节里的代码就是在解决这个问题。另一个容易忽视的点是:滤波处理必须在一帧数据期间完成,如果同一个数组既被 DMA 写又同时被滤波循环读,就把滤波的结果写回另一个缓冲区,处理完再整体拷贝或者通过指针切换。我见过有人在原地做滤波导致数据错乱的案例,后来我在 main 函数里用两个数组来回切换,问题就消失了。

6. 进阶验证技巧:用信号发生器给整条链路做「体检」

原型系统跑起来之后,别急着贴电极测人。先做一步标准测试:用信号发生器输出 1mV、10Hz 的正弦波,串联一个 100kΩ 电阻模拟人体源阻抗,接到仪表放大器输入端。这个测试能暴露 90% 的前端问题。

逐级测输出:仪表放大器输出应该有约 20mV 的正弦波;主放大器输出应该有约 1V 的正弦波;ADC 读到约 3100 个 LSB(参考 3.3V 时 1V 对应约 1241LSB);经过滤波后波形应该纯净无毛刺,幅度基本不变。哪一级不对就查哪一级,这一步能省掉大量贴电极排查的时间。

测试点输入信号预期输出实测
仪表放大器输出1mV / 10Hz 正弦约 20mV 正弦若偏差超 20% 查 RG 焊接
主放大器输出接前级输出约 1V 正弦若波形削顶,降低增益
ADC 原始值同上约 1241 LSB 波动若噪声大,查参考电压
数字滤波后同上波形平滑无毛刺若仍有 50Hz,查陷波系数

验证完链路线性度和噪声底之后,我给这套系统留了三个扩展口:一是把滤波后的数据降采样到 50Hz,存进 SD 卡,方便事后用上位机分析;二是加一个 RTC 时间戳,每次采集事件记录下来,便于统计心率变异性;三是把串口改为低功耗模式,用定时器周期唤醒单片机,满足便携设备的功耗要求。

我每次改完模拟增益都会重新做一轮正弦扫描,而不是直接贴电极。这是血泪经验换来的习惯——人身上的信号太不稳定,拿它当调试依据,只会把问题归咎于玄学。这套 STM32F103 心电采集系统,模拟前端 20 倍加 50 倍增益,500Hz 采样率,50Hz 陷波加 0.3Hz 高通,是一套能跑通、能出图、能让你理解每个环节的最小闭环。在这个基础上加存储、加无线、加算法,都不会走弯路。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询