简介一套基于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输出确认模块是否带稳压器件未带稳压时严禁接5VGNDF103的GND在ADC引脚附近单点连接避免数字地噪声串入OUTPUTF103的PA0ADC1通道0信号输出接采样保持电路LOF103的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通道0PA0采样时间设最长 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接地再接电极看基线稳定性然后用模拟信号源校准增益最后才上人体采集。这个顺序帮我过滤掉了大多数低级故障希望帮到你。本文还有配套的精品资源点击获取