ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103与AD8232心电采集:信号链设计与噪声抑制实战

STM32F103与AD8232心电采集:信号链设计与噪声抑制实战

简介:一套基于STM32F103的心电信号(ECG)采集与显示系统完整工程,面向嵌入式开发者、生物医学工程学生及医疗设备爱好者,用来学习微控制器如何通过模拟前端芯片完成生理信号的采集、处理与传输。系统以STM32F103为主控,采用AD8232心电模拟前端提取信号,连接至PA3引脚,并借助USART2串口与上位机通信,通过vofa+软件实时绘制波形,整体覆盖硬件接口、固件逻辑到上位机显示的关键链路。包内共191个文件,以C源文件、H头文件、编译生成的O/CRF/HEX/AXF以及Keil工程文件为主,还有MAP映像文件与烧录脚本,可直接编译、烧录并对照学习代码结构,压缩包仅3.74MB,体量精简。已有902人学习,适合需要快速上手嵌入式心电项目的开发者。资料包含完整源码与工程配置,可参考其中串口初始化、ADC读取、滤波处理以及波形输出等模块,对理解STM32F103外部中断、USART、GPIO和定时器用法也有直接帮助。

1. 基于stm32F103的心电信号采集:一块最小系统板和AD8232能做出什么

做模拟项目X的时候,我手头只有两块板子:一块stm32F103最小系统板,一块AD8232单导联心电前端模块。起初我以为难点在算法,等把线接完才发现,真正的坑是让信号链从电极到ADC每一级都靠谱。这套组合是低成本心电采集最常见的起点,能稳定抓出可辨识的PQRST波形,也能跑通心率计算和上位机显示,但它的边界也清晰:适合教学、生物电信号处理入门和原型验证,不适用于临床诊断。这篇文章会把信号链设计、采样时序、噪声抑制和联调中的实际问题拆开讲,新手能照着接线写代码,熟手也能在参数选择和排障思路上少走弯路。

2. 先把信号链理清:AD8232模拟前端与F103外设选型

2.1 为什么是AD8232而不是自己搭仪表放大器

心电信号的特点是幅度小、源阻抗高、共模干扰大。体表心电只有0.5到4mV,皮肤接触阻抗能到几十千欧到几百千欧,而人体又像一根天线,50Hz工频共模电压往往比心电信号大一个数量级。如果自己用分立运放搭仪表放大器,共模抑制比、输入偏置电流和噪声三项指标要同时达标,原理图复杂且元件一致性差,徒手调起来很痛苦。

AD8232这类专用模拟前端把仪表放大器、双运算放大器、右腿驱动和导联脱落检测集成在一块,核心参数经过出厂匹配,只要按数据手册配置增益和时间常数,信号链的模拟部分基本就定住了。它工作电压在2.0到3.5V范围,可以直接由stm32F103的3.3V供电;增益通过反馈网络电阻调节,高通和低通截止频率由引脚上的电容电阻决定。内部还带一个基准电压输出,让输出信号偏置到电源电压中间,方便后级单端ADC采集。

选型时还要考虑F103的外设匹配度。stm32F103内置12位ADC,最快采样率在1Msps左右,而心电采集只需要250到500Hz采样率,余量非常充足。这意味着不需要外挂ADC,直接用MCU内部的定时器触发加DMA搬运数据,就能做到采样间隔稳定。比较过用外部24位ADC的方案,精度确实高不少,但成本和代码复杂度都上去了,对教学和原型验证来说性价比不高。

2.2 三种接入F103的方式与增益带宽匹配

AD8232模块的输出引脚和F103的ADC引脚之间,常见做法有三种接法。

第一种最简单:直接把OUTPUT引脚接到F103的ADC输入引脚,比如PA0或者PC0。F103的ADC输入阻抗在采样保持期间会动态变化,但配合慢速采样和足够长的采样时间,一般不会对心电信号造成明显衰减。这种接法适合先把波形跑出来,验证整条链路是否通畅。

第二种是OUTPUT先经过一级电压跟随器再进ADC。如果AD8232模块输出端的驱动能力偏弱,或者ADC输入走线很长,跟随器能降低源阻抗对采样精度的影响。不过F103的ADC在500Hz采样率下采样时间可以配置得很长,实际项目里很少需要加这一级。

第三种是差分转单端或者直接接差分ADC。F103内置ADC是单端的,AD8232输出本身也是以基准电压为中心的单端信号,所以这个方案要么外挂差分ADC芯片,要么加一级减法电路。成本和复杂度都偏高,我在这个项目里直接排除了。

接法确定后要算增益带宽。常见设置是把AD8232增益调到十几倍到几十倍,让ADC输入端的信号幅度达到几百毫伏到一伏级别,充分利用12位ADC的量程。需要注意高通截止频率不能设太高。心电信号中ST段的低频成分会受到影响,如果高通截止频率设到几十赫兹,波形会严重变形。我一般把高通截止频率控制在0.5Hz左右,低通截止频率设在100到150Hz,覆盖心电信号的主要频段同时抑制高频噪声。

2.3 接线清单与供电约束

实际接线时,把AD8232模块和stm32F103的引脚对应关系列清楚,能省掉一半的排障时间。

模块引脚连接对象说明
3.3VF103的3.3V输出确认模块是否带稳压器件,未带稳压时严禁接5V
GNDF103的GND在ADC引脚附近单点连接,避免数字地噪声串入
OUTPUTF103的PA0(ADC1通道0)信号输出,接采样保持电路
LO+F103的GPIO输入导联脱落检测输出,高电平表示脱落
LO-F103的GPIO输入另一路导联脱落检测输出
RLD右腿驱动电极串联100kΩ电阻后接右腿电极
SDN悬空或接高电平接低电平时模块进入关断状态

供电是入门最容易翻车的环节之一。市面上常见的AD8232模块,有的板载稳压电路可以吃5V,有的没有稳压直接供电,5V灌进去轻则发热重则损坏。我第一次用的时候没看丝印,默认按5V接了,结果模块烫得不敢碰。从那以后我每次都先查模块原理图,确认供电范围再上电。F103的3.3V输出能力通常足够带动AD8232,但要注意模拟供电和数字供电之间做隔离,后文会展开讲。

导联线也要选对。心电导联线是屏蔽线,芯线接电极,屏蔽层一般接RLD而不是GND,这样才能把共模干扰引导到右腿驱动回路里抵消掉。电极用一次性银氯化银电极,贴之前用酒精棉清洁皮肤,接触阻抗能降不少。

3. 硬件底子决定信号上限:布局、地与去耦的实操细节

3.1 模拟地与数字地怎么铺才不打架

stm32F103的ADC精度是12位,理论上能分辨约0.8mV的电压变化,但如果模拟地和数字地处理不好,噪声轻松淹没这个分辨率。常见做法是把模拟地和数字地在物理上分开铺铜,然后单点连接。这里的“单点”不是随便找个位置短接,而是在电源入口处汇合,让数字回路的开关电流不流经模拟信号参考面。

双面板的层叠有限,我习惯这样分:顶层走模拟信号和模拟电源,底层走数字信号和数字地,模拟地单独铺一小块铜皮,在F103的VSSA引脚附近和数字地汇合。AD8232模块的GND引脚尽量就近接到模拟地铜皮上,不要绕远路。如果板子上有DC-DC或者开关稳压器,模拟地和数字地的隔离要更严格,必要时用磁珠连接两地,但磁珠会引入直流压降,心电这种微伏级信号对参考电位敏感,选用前要算好压降。

另一个容易忽视的地方是ADC输入走线。心电信号输出阻抗高,走线越长越容易捡到噪声。我一般把AD8232模块尽量靠近F103的ADC引脚放置,走线控制在2cm以内,并且两侧铺地保护。如果必须走长线,优先在输出端加一级缓冲器降低源阻抗,而不是靠加粗走线解决问题。

3.2 导联脱落检测与右腿驱动电路

导联脱落检测是AD8232一个很实用的功能。LO+和LO-两个引脚输出高低电平,表示三个电极中有没有脱落。这个功能不只是用来提示“电极掉了”,还能在软件里做数据有效性判断。比如某个电极接触变差,LO引脚提前拉高,软件可以暂停采集或者屏蔽这段数据,避免把伪差当成心电波形处理。

接线时LO+和LO-接F103的任意两个GPIO,配置成输入模式,开启内部上拉或者根据模块手册决定是否需要外部上拉。读取状态时注意,导联脱落检测输出的是逻辑电平,不是模拟信号,不能接到ADC引脚上。

右腿驱动是抑制共模干扰的关键。人体作为天线接收到的50Hz共模信号,如果不做处理,会在输入端转换成差模干扰,淹没心电波形。RLD引脚通过一个电阻连接到右腿电极,把共模电压反相放大后反馈到人体,相当于主动抵消共模干扰。这个100kΩ电阻是安全设计的一部分,限制流过人体的电流,不能省略,也不要用更小的电阻替换。

我在第一次调试时把RLD直接悬空了,出来的波形全是50Hz正弦波,心电特征完全看不到。把RLD接上右腿电极后,工频干扰明显减弱,基线变细,这才看到像样的PQRST波形。这个现象也适合用来判断共模干扰来源:如果波形里50Hz分量很重,先查RLD是否接好,再看增益是不是太高。

3.3 电源噪声与屏蔽:让波形线细下来

AD8232对电源噪声比较敏感,供电纹波会直接串到输出端。F103的3.3V通常来自板载LDO或者外部稳压器,纹波本身不大,但数字电路运行时产生的开关噪声可能通过电源线耦合进来。

我一般会在AD8232模块的电源引脚附近加两级去耦:一个100nF陶瓷电容贴近电源引脚,滤高频;一个1uF到10uF的钽电容或者电解电容放在稍远位置,稳住低频。如果是电池供电,电源本身比较干净,去耦电容的压力小一些;如果是USB供电或者开关电源供电,还要检查3.3V上有没有幅度较大的纹波,必要时增加一级LC滤波。

屏蔽方面,心电导联线的屏蔽层要接RLD,这在前面提过。另外,AD8232模块如果自带屏蔽盖,接好地后能进一步降低空间辐射干扰。实际联调时有一个简单判断方法:用手握住导联线但不碰电极,观察波形噪声是否明显变大。如果变大,说明屏蔽没接好,优先检查屏蔽层和RLD回路。

4. 从ADC到干净波形:定时器触发采样与滤波实现

4.1 初始化与定时触发采样:用PWM加DMA把采样间隔焊死

心电信号采样最怕的是时间间隔抖动。如果每次采样都靠定时器中断里软件触发ADC,中断响应延迟和任务调度冲突会带来几个微秒到几十微秒的抖动。对普通波形显示影响不大,但后续做R波检测或者心率变异性分析时,抖动会被当成有用信号处理,干扰结论。

我习惯用定时器的TRGO事件直接触发ADC转换,再用DMA把结果搬运到内存。这样从定时器事件产生到ADC转换完成,是硬件级别的固定时序,不经过CPU干预。F103配置起来也很直接:定时器PWM输出模式产生触发信号,ADC配成外部触发,DMA配成循环模式。采样率500Hz,代码如下。

// 定时器2初始化:72MHz主频,预分频72,重载2000,输出500Hz触发信号 TIM_HandleTypeDef htim2 = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 72 - 1; htim2.Init.Period = 2000 - 1; htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(&htim2); // 配置TIM2的TRGO事件:更新事件触发输出 TIM_MasterConfigTypeDef sMasterConfig = {0}; sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; HAL_TIMEx_MasterConfig_Step(&htim2, &sMasterConfig); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); // ADC1初始化:外部触发,12位分辨率,右对齐 ADC_HandleTypeDef hadc1 = {0}; hadc1.Instance = ADC1; hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4; hadc1.Init.Resolution = ADC_RESOLUTION_12B; hadc1.Init.ScanConvMode = DISABLE; hadc1.Init.ContinuousConvMode = ENABLE; // 外部触发时配合DMA循环使用 hadc1.Init.ExternalTrigConv = ADC_EXTERNAL_TRIG_CONV_T2_TRGO; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; HAL_ADC_Init(&hadc1); // 配置ADC通道0(PA0),采样时间设最长 ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = ADC_REGULAR_RANK_1; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(&hadc1, &sConfig); // DMA循环模式,缓冲区长度500 uint16_t ecg_raw_buf[500]; HAL_ADC_Start_DMA(&hadc1, (uint32_t*)ecg_raw_buf, 500);

逻辑上,定时器每2ms产生一次更新事件,触发ADC采样,DMA把结果写入环形缓冲区,CPU在后台做滤波和显示,全程不参与采样时序。采样率由预分频和重载值决定:72MHz除以72再除以2000,正好500Hz。如果采样率要改成250Hz,把重载值调到4000即可。

连续转换模式配合DMA循环模式有个细节:缓冲区填满后,DMA自动回到起点继续写,相当于一个环形队列。读取数据时要注意读指针和写指针的竞争,简单做法是隔半帧读一次,或者用双缓冲区交替搬运。F103的DMA支持双缓冲,但配置起来略复杂,500点缓冲下单缓冲加半帧延迟已经足够顺滑。

4.2 50Hz工频干扰的抑制:整系数滤波器先顶上

AD8232的硬件滤波能把大部分高频噪声挡掉,但50Hz工频干扰很难完全滤除。这时候软件滤波要接棒。很多资料一上来就让人设计IIR陷波器,实际上对资源有限的F103和入门阶段来说,整系数滤波更合适,不涉及浮点运算,代码短,实时性好。

在500Hz采样率下,50Hz一个周期正好10个采样点。对最近10个点求平均,反映的是信号里接近直流和低频的成分,而50Hz成分在这个窗口内被平均掉。把当前采样值减去这个平均值,就相当于做了一次陷波。代码实现如下。

#define NOTCH_WIN 10 // 500Hz采样率下,10个点对应50Hz的一个周期 static int16_t ecg_notch_buf[NOTCH_WIN] = {0}; static uint8_t notch_index = 0; int16_t notch_filter_50hz(int16_t input) { int32_t sum = 0; ecg_notch_buf[notch_index] = input; notch_index = (notch_index + 1) % NOTCH_WIN; for (int i = 0; i < NOTCH_WIN; i++) { sum += ecg_notch_buf[i]; } int16_t trend = (int16_t)(sum / NOTCH_WIN); return (int16_t)(input - trend); }

逻辑是维护一个10点滑动窗口,每次采样更新窗口中最新值,窗口均值近似为当前信号的低频趋势。用当前值减去趋势,就把50Hz的周期性成分压掉了。注意这个滤波器对低频段也有影响,因为窗口均值包含部分低频信息,所以它同时承担了一部分高通滤波的角色。如果直接用它处理原始信号,基线漂移也能被压掉一部分。

这个整系数方案等效于一个FIR滤波器,群延迟大约为窗口长度的一半,也就是5个采样点,即10ms。对心电显示来说这个延迟可接受,但如果要做同步对比,比如同时显示多导联,就要考虑这个固定延迟。

基线漂移的处理可以用更高阶的高通滤波,或者直接用滑动平均再加一次差分。更讲究一点的做法是估计基线并减去,但对F103来说,浮点运算的代价偏高。整系数方案在代码尺寸和实时性上是最划算的。

4.3 数据封装与串口输出:别在结构体对齐上翻车

滤波后的数据要送出去,最简单的途径是串口。500Hz采样率、每样本2字节,加上协议开销,也就每秒约1KB多,115200波特率完全够用。常见做法是定义一帧数据包含帧头、序列号和采样值,方便上位机同步。

typedef struct { uint8_t header; // 固定 0xAA uint8_t seq; // 帧序号,用于检测丢帧 int16_t value; // 滤波后的心电值 uint8_t checksum; // 简单校验 } ecg_frame_t; void send_ecg_frame(int16_t ecg_value) { static uint8_t sequence = 0; uint8_t sum = 0; ecg_frame_t frame; frame.header = 0xAA; frame.seq = sequence++; frame.value = ecg_value; sum = frame.header + frame.seq + (frame.value & 0xFF) + ((frame.value >> 8) & 0xFF); frame.checksum = sum; // 逐字节发送,避免结构体对齐导致的字节填充 uint8_t* p = (uint8_t*)&frame; for (uint8_t i = 0; i < sizeof(ecg_frame_t); i++) { // 假设底层串口发送函数为 uart_send_byte(p[i]) uart_send_byte(p[i]); } }

代码里用结构体承载帧内容,但发送时强制按字节顺序发送。原因是C语言结构体存在对齐填充,直接编译可能会在字段间插入填充字节,上位机按固定字节流解析时会对不上。加checksum是为了快速识别线缆接触不良或串口干扰造成的数据错误,实际解析时如果校验不对,直接丢帧比硬解析更安全。

参数上,串口配置成115200、8数据位、1停止位、无校验。帧长6字节,每秒500帧,共3000字节每秒,远低于串口理论吞吐量,留出了余量给后续的心率数据一并发送。

5. 联调避坑:从毛刺到基线漂移的五条血泪记录

5.1 排查工具与定位策略

联调时没有示波器也能排查,但思路要清晰。我一般把系统分成四个段:电源、模拟前端、ADC采样、软件滤波。信号从电极进来,逐段检查哪里出了岔子。

电源段看3.3V是否稳定,用万用表量纹波不准,但至少确认电压在AD8232规格范围内。模拟前端段看OUTPUT引脚能否测到有规律的直流偏置,一般应在电源电压的一半附近。ADC段看原始数据是否有明显跳变或恒定值。软件滤波段看输入输出波形差异,确认滤波器没有把信号弄坏。

还有一个很管用的试探方法:用手指轻轻碰触电极夹,观察波形有没有一个明显的扰动脉冲。如果有,说明从电极到ADC的通路是通的。如果完全没有反应,问题多半在模拟前端供电、导联线或者ADC配置上。这个方法在排除“软件写错了”的疑虑时特别好用。

5.2 五条具体踩坑记录

现象一:波形全是50Hz正弦波,看不到心电特征。原因:右腿驱动RLD悬空,共模干扰没有被抑制。解决:把RLD引脚串联100kΩ电阻接到右腿电极,检查电极贴片是否牢固。此步骤经常被忽略,因为上电后波形能显示、数据能采集,看起来一切正常,但信号质量完全达不到分析要求。

现象二:基线上下明显漂移,波形像在波浪上飘。原因:电极与皮肤接触阻抗不稳定,加上高通截止频率太低。解决:用酒精清洁皮肤后贴电极,等约30秒让接触阻抗稳定;检查AD8232的高通截止频率配置,不要低于0.05Hz。若软件侧仍有漂移,把基线估计窗口调大,通带压低。

现象三:模块发热明显,摸上去烫手。原因:供电电压超出模块规格,大概率接了5V。解决:立即断电,查模块原理图确认是否有稳压电路;无稳压时改用F103的3.3V供电。这个坑尤其容易出现在USB转串口小板同时供电的场景,因为5V和3.3V并存时很容易接错。

现象四:R波顶部削平,或者QRS波群形态畸变。原因:信号增益过高,ADC输入饱和。解决:用万用表或示波器看OUTPUT引脚直流偏置和动态范围,调整AD8232增益电阻;如果已将信号放到满量程附近,说明增益偏高,降到原来的三分之一左右再测。

现象五:采样数据间隔不均匀,波形在R波附近出现台阶感。原因:使用定时器中断里软件触发ADC,中断被其他任务延迟;或者DMA配置错误,缓冲区读写竞争。解决:改成定时器TRGO硬件触发加DMA循环模式,按前一章的配置。如果必须用中断方式,至少把ADC中断优先级调到最高级,并在中断服务里只做触发和标志位设置,不做耗时处理。

这五条覆盖了我实际联调中遇到的绝大多数问题。它们的共同点是:现象看起来像软件问题,根因却在硬件连接或配置的细节上。所以调试顺序永远是先查电、再查时序、最后查滤波。

6. 验证系统是否可用:模拟信号源校准与进阶实测

6.1 没有示波器时的替代验证法

验证系统是否真的能采到正确的心电波形,可靠的定量手段是模拟信号源。用信号发生器输出一个幅度2mV、频率1Hz的类心电波形信号,直接注入AD8232的输入端,观察软件滤波后的输出是否保持良好的形态和幅度。2mV是体表心电的典型幅度,这个测试能同时验证增益设置和ADC量程的匹配度。

如果手边没有信号发生器,靠手碰电极只能验证通路,验证不了精度。这时候可以拿一段已知幅度的小信号从电路上注入,比如用电阻分压把一个1V方波衰减到2到5mV,再作为测试信号输入。虽然波形不是标准心电,但能定量检查整条链路的增益和信噪比。我一般对每个新板子都走一遍这个流程,确认增益误差在预期范围内再上人体实测。

6.2 简单R波检测与心率换算

心率换算不复杂,先找到R波,再算相邻R波间隔。常见做法是幅度阈值加不应期:信号幅度超过阈值的点记为R波位置,之后200ms内不再接受新R波,防止T波被误判。阈值可以取滑动窗口最大值的60%,这样能自动适应信号幅度变化。

#define REFRACTORY_MS 200 #define THRESHOLD_RATIO 60 static int16_t window_max = 0; static uint8_t refractory_cnt = 0; uint8_t is_r_wave(int16_t value) { if (value > window_max) { window_max = value; } if (refractory_cnt > 0) { refractory_cnt--; return 0; } if (value >= window_max * THRESHOLD_RATIO / 100) { refractory_cnt = REFRACTORY_MS * 500 / 1000; window_max = value; return 1; } return 0; }

逻辑是维护一个滑动窗口最大值,信号超过其60%就认为可能到了R波,然后进入不应期。不应期按采样率换算成采样点数,200ms对应100个采样点。这个方法对形态正常的窦性心律效果好,但对心律不齐和噪声干扰的鲁棒性一般。更可靠的做法是找QRS主波的能量特征,或者用模板匹配,但代码量和运算量都会增加。

6.3 把系统往前推一步

做到这一步,系统已经从原始ADC数据变成干净的波形序列和可以显示的心率值。剩下来的扩展方向有两个:低功耗和无线传输。

低功耗的关键是把空闲时间让给休眠模式。AD8232的关断引脚SDN可以在测量间隙拉低,F103进入停止模式,只在需要采样的时间段唤醒。如果做一个5秒一次的定时测量,平均功耗能降一个数量级。无线传输方面,常见做法是接BLE透传模块,串口直接发数据,但BLE一次最多传约20字节,500Hz的原始数据流传不过去,只能在端侧先检测R波、计算心率,然后只把结果发给手机或者上位机。

从那以后我每次搭心电采集板,都强制走一遍同样的流程:先检查供电范围和RLD接地,再接电极看基线稳定性,然后用模拟信号源校准增益,最后才上人体采集。这个顺序帮我过滤掉了大多数低级故障,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进