简介一套基于STM32F103的心电信号ECG采集系统完整工程面向嵌入式开发、生物医学工程及物联网方向的初学者和进阶者可帮助理解从AD8232模拟前端到微控制器采集、串口传输、上位机显示的完整链路。压缩包共191个文件约3.74MB以C源码、头文件、Keil工程文件为主并包含编译生成的hex、axf及调试辅助文件可直接用于工程编译与烧录验证。已有902人学习项目规模适中适合作为课程设计或毕业设计参考。工程不仅给出STM32F103的GPIO、USART2等外设配置代码还展示了AD8232经PA3接入的数据读取方式配合vofa上位机可实时查看心电波形同时涵盖标准外设库的定时器、ADC、I2C等模块为后续扩展数字滤波、心率检测等功能提供了可复用的工程基础。此外工程内已配好USART2通信参数并保留完整项目目录结构便于快速切换到自己IDE中进行二次开发。1. 心电采集不是简单放大stm32F103 这个平台到底在解决什么问题心电信号是贴在皮肤表面的电极捕获的毫伏级电位波动幅度只有 0.05mV 到 5mV频率集中在 0.05Hz 到 150Hz。很多人第一次做这个项目时拿单片机 ADC 直接怼到电极上结果读到的不是心电而是密集的 50Hz 工频干扰和缓慢飘移的基线。基于 stm32F103 的心电信号采集系统难点不在 ADC而在把模拟前端调理 数字采样滤波 数据上传这一整条链路在低功耗、低成本的平台上跑通。本文按照硬件选型、固件实现、上位机显示到调试避坑的顺序展开给出一个可以复现的最小系统。适合做便携健康监测、生物电实验或课程设计的工程师要求会点亮 STM32 的 ADC 和串口但不需要扎实的模拟电路功底。2. 硬件链路从电极到 STM32F103 的模拟前端设计2.1 心电信号的电气特征决定了前端必须用仪表放大器心电信号最麻烦的地方是源阻抗高且不匹配。皮肤与电极接触的阻抗可以到几十千欧甚至上百千欧而且左右两侧电极的阻抗往往不对称。如果用普通运放做差分放大输入阻抗不够高会分掉宝贵的电信号同时人体相当于一根巨大的天线会把环境里的 50Hz 工频以及周围设备的电磁辐射耦合进来形成很强的共模干扰。仪表放大器天然有两个优势极高的差分输入阻抗通常上百兆欧和优良的共模抑制比。它内部是三运放结构前端两级对称缓冲把输入阻抗抬高再把共模信号抵消掉。常见的分立方案是拿三个单运放搭例如 LM324 或 TL074但毫不客气地说在 2025 年还搭分立三运放调试心电纯属给自己找麻烦。更省心做法是直接用集成生物电模拟前端比如 AD8232 这类专为心电设计的芯片。AD8232 内部集成仪表放大、右腿驱动、导联脱落检测、滤波器只需外接几个电阻电容就能组成带通滤波器。选它还有一个原因single-supply 供电2.0V 到 3.5V 都能工作和 STM32F103 的 3.3V 系统无缝对接。当然如果要求低噪声到微伏级别ADI 的 AD8421 配高通/低通分立电路也可以但成本和 PCB 面积都会上去。对大多数可穿戴、实验开发场景AD8232 是性价比最高的选择。2.2 用 AD8232 做心电前端的最小接线与参数设定我一般用 STM32F103C8T6 的小板子配上 AD8232 模块最小系统只需要三根线电源、GND、输出。不过要拿到稳定波形必须正确连接右腿驱动和导联脱落检测。AD8232 常用引脚如下表这是实际接线时的参考映射。AD8232 引脚功能连接到 STM32F103 / 外部3.3V模拟电源经过 LC 滤波后的 3.3VGND模拟地单点接入系统地IN正差分输入电极左臂/胸导联IN-负差分输入电极右臂RLD右腿驱动输出通过 100kΩ 电阻接到右腿电极OUT放大输出STM32 PA1ADC 通道REF基准输出接 0.1μF 电容到 GND悬空也可LOD / LOD-导联脱落检测接 STM32 GPIO可选供电这里有个容易忽略的细节模拟前端和 STM32 共用一颗 LDO 稳压器可以但 LDO 电源输入端要加磁珠和 10μF 钽电容否则 ADC 采集的直流偏置会跟着电源纹波跑。AD8232 的输出默认偏置在参考电压附近我习惯让 REF 稳压在电源电压的一半再把 OUT 直接接入 STM32 的 ADC。因为 AD8232 输出范围是 0V 到 VDD刚好适配 STM32 的 3.3V ADC。滤波电容的取值决定通带。心电诊断带宽通常是 0.05Hz 到 150Hz但便携设备为了抑制基线漂移高通截止频率会提高到 0.5Hz 到 1Hz。AD8232 的典型接法用一个 10nF 电容对应约 0.5Hz 高通一个 0.1μF 电容对应约 40Hz 低通。如果要看房颤等高频细节低通可以放到 100Hz。这里不要盲目追求高带宽否则肌电噪声会串进来波形毛糙得像砂纸。2.3 供电与隔离为什么电池供电比 USB 供电更可靠第一版实验时图省事直接用 USB 转串口的小板子给整个系统供电结果波形上全是周期性毛刺频率恰好是 USB 总线的握手信号。换了锂电池供电后毛刺立刻消失。原因很简单USB 的 5V 电源是从计算机的开关电源出来的高频噪声和地环路干扰都很难滤干净。心电信号是毫伏级前端增益通常 100 倍以上任何微小的电源噪声都会被放大成明显的波形畸变。一个稳妥的供电树是锂电池 → LDO3.3V→ 磁珠分两路一路给 STM32F103 及数字部分一路给 AD8232 模拟部分。模拟地和数字地要单点相连最好在 ADC 采样位置附近汇合避免数字开关噪声通过地平面窜入模拟前端。反正我的经验是第一版不要碰蓝牙和一键开关先把电池供电、串口输出跑通再加花活。3. 在 STM32F103 上实现 ADC 采样与数据处理3.1 用定时器触发 ADC 的连续采样一次 DMA 传输的配置写法STM32F103 的 ADC 最大采样率约 1Msps但心电只需要几百赫兹的采样率。直接用 ADC 的连续转换模式虽然简单但采样间隔受软件循环影响导致波形时基不准。正确做法是用定时器产生固定频率的触发信号ADC 每次触发转换一次然后由 DMA 把结果搬到内存全程不占用 CPU。这样不仅采样率精确CPU 还能腾出来跑数字滤波和串口发送。以 500Hz 采样率为例配置时基 2ms 一次。第一步是初始化触发源定时器第二步配置 ADC 为外部触发模式第三步配置 DMA 循环模式。这里给一段基于 HAL 库的初始化代码多数 F103 开发板可以直接套用。// 定时器2配置500Hz 触发ADC void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig {0}; TIM_MasterConfigTypeDef sMasterConfig {0}; htim2.Instance TIM2; htim2.Init.Prescaler 72 - 1; // 72MHz / 72 1MHz htim2.Init.Period 2000 - 1; // 1MHz / 2000 500Hz htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(htim2); sMasterConfig.MasterOutputTrigger TIM_TRGO_UPDATE; // 更新事件作为触发输出 sMasterConfig.MasterSlaveMode TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(htim2, sMasterConfig); } // ADC1配置单通道定时器2触发 void MX_ADC1_Init(void) { hadc1.Instance ADC1; hadc1.Init.ScanConvMode DISABLE; // 单通道 hadc1.Init.ContinuousConvMode DISABLE; // 外部触发不连续 hadc1.Init.DiscontinuousConvMode ENABLE; // 每次触发后停止等待下次触发 hadc1.Init.ExternalTrigConv ADC_EXTERNALTRIGCONV_T2_TRGO; // 定时器2触发 hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion 1; HAL_ADC_Init(hadc1); sConfig.Channel ADC_CHANNEL_1; // PA1 sConfig.Rank 1; sConfig.SamplingTime ADC_SAMPLETIME_239CYCLES_5; // 采样时间约3μs HAL_ADC_ConfigChannel(hadc1, sConfig); }这段配置的关键是两个参数定时器预分频 72-1 和自动重载 2000-1两者相乘得到 500Hz 触发频率。ContinuousConvMode必须置 DISABLE否则一次触发后 ADC 会连续转换多轮导致 DMA 缓冲区被塞满。DiscontinuousConvMode置 ENABLE 是为了让每次转换都严格等待定时器触发。SamplingTime我选了最长档因为心电信号源阻抗高采样电容充电需要时间太短的采样时间会导致采到的值偏低。启动流程也值得一提。初始化后先启动 DMA 再启动 ADC最后打开定时器。uint16_t ecg_buffer[256]; // 256点环形缓冲 HAL_ADC_Start_DMA(hadc1, (uint32_t *)ecg_buffer, 256); HAL_TIM_Base_Start(htim2);DMA 配置成循环模式每隔 256 次采样触发一次传输完成中断但更实用的是开启 DMA 半传递中断。在中断里处理前半段 128 个数据CPU 同时在处理后段形成流水线。这样在 500Hz 采样下每 256ms 才需要处理一轮数据实时性完全够。3.2 数字滤波的实用组合50Hz 陷波与滑动平均的实现模拟前端把信号放大到 ADC 范围内但 50Hz 工频干扰往往还会残留。有人迷信模拟电路的陷波器但电感大、元件误差大效果反而不如数字处理稳定。我的习惯是预留模拟低通压高频然后用软件做 50Hz 陷波和滑动平均。50Hz 陷波最简单的方法是双二阶 IIR 滤波器结构如下// 50Hz陷波采样率500HzQ10 float notch_state1 0.0f, notch_state2 0.0f; float notch_filter(float input) { float b0 0.99137f, b1 -1.84476f, b2 0.99137f; float a1 -1.84476f, a2 0.98274f; // a0归一化为1 float output b0 * input b1 * notch_state1 b2 * notch_state2 - a1 * notch_state1 - a2 * notch_state2; notch_state2 notch_state1; notch_state1 output; return output; }这里系数是根据采样率 500Hz、中心频率 50Hz、带宽约 5Hz 计算的。如果采样率改成 1000Hz系数必须重新计算。问我要计算公式的同事很多但说实话用在线滤波器计算工具直接生成系数更靠谱自己手算容易在浮点舍入上翻车。对于 STM32F103 这种没有 FPU 的 Cortex-M3float 运算也能跑只是尽量控制在每样本十几条指令内。陷波之后再做一个窗口长度 15 点的滑动平均。滑动平均不是最优滤波但用在心电上可以压掉高频毛刺让波形更平滑。注意窗口太大会把 QRS 波群的尖锐边沿抹掉反而影响心率计算。我的经验是采样率 500Hz 时窗口不超过 15 点。#define WINDOW_SIZE 15 uint16_t ring_buf[WINDOW_SIZE]; uint8_t ring_index 0; uint32_t sum 0; uint16_t moving_average(uint16_t sample) { sum - ring_buf[ring_index]; sum sample; ring_buf[ring_index] sample; ring_index (ring_index 1) % WINDOW_SIZE; return (uint16_t)(sum / WINDOW_SIZE); }这个实现的优点是只做加法和移位不涉及除法在无 FPU 的芯片上非常快。初始化时要把ring_buf清零否则第一次输出会有直流偏置。另外ADC 的 12 位数据范围是 0 到 4095整数求和时sum的类型至少是 uint32_t否则 15 个 4095 相加会溢出。3.3 采样率与缓冲区大小怎么定很多教程上来就设 1000Hz 采样显得专业但真没必要。心电诊断带宽上限是 150Hz奈奎斯特定律要求至少 300Hz实际上留 1.5 到 2 倍裕量500Hz 完全够。过高的采样率只会增加数据量和功耗对波形质量没有本质改善。缓冲区大小和采样率配合。每次 DMA 传输的块大小我常用 256对应 512ms 数据。这个长度足够让 QRS 波形的检测算法看到完整心跳也方便在显示器上滚动。如果采用半传递中断则每个中断间隔 256ms即便做性能较差的心率计算也能轻松跑完。为了避免浮点处理器缺失带来的性能问题ADC 原始数据可以按 uint16 存储滤波时转成 float 只跑陷波器滑动平均继续用整数。这样既保精度又不至于 CPU 占用过高。实际测量过在 72MHz 主频的 F103C8T6 上500Hz 采样、两种滤波加串口发送CPU 占用率不到 10%还有大量余量给后续算法。4. 数据上传与上位机显示串口协议与波形绘制4.1 用 DMA 串口把采样值发出去协议帧的设计原始的滤波后数据是 12 位整型范围 0 到 4095。如果直接按二进制发接收端很难定位数据边界如果按文本发效率低且容易粘包。我常用一个简单可靠的帧协议// 帧格式AA 55 01 [DataH] [DataL] [CheckSum] void send_ecg_frame(uint16_t ecg_value) { uint8_t frame[6]; frame[0] 0xAA; frame[1] 0x55; frame[2] 0x01; // 通道号 frame[3] (ecg_value 8) 0xFF; frame[4] ecg_value 0xFF; frame[5] frame[2] ^ frame[3] ^ frame[4]; // 简单校验 HAL_UART_Transmit_DMA(huart1, frame, 6); }这里用了HAL_UART_Transmit_DMA每采样一个点发一帧因为 DMA 发送是非阻塞的。但它要求发送缓冲区在发送完成之前不能被改写所以对单次短帧没问题但要保证两次发送之间间隔足够。500Hz 下每帧 6 字节波特率 115200 时每秒约 690 字节远低于最大传输能力不会积压。有人为了省流量把数据打包成 16 帧发一次比如一包 96 字节。这种做法的代价是上位机要自己处理拆包。我认为初学者先用单帧协议最稳调试时每 6 字节自己对齐不容易错位。校验和 XOR 虽然敌不过 CRC但对课堂演示和原型验证足够能挡住串口线上的偶发字节丢失。4.2 上位机用 Python 读取串口并画波形最小实现上位机只需要三个功能读串口、找帧头、绘图。Python 搭配pyserial和matplotlib可以快速验证数据流。下面是一个最小绘图脚本它先同步帧边界再按逐点刷新。import serial import matplotlib.pyplot as plt import numpy as np ser serial.Serial(COM3, 115200, timeout0.1) plt.ion() fig, ax plt.subplots() y np.zeros(500, dtypenp.uint16) line, ax.plot(y) ax.set_ylim(0, 4095) buf bytearray() while True: data ser.read(256) if data: buf.extend(data) # 在缓存里查找帧头 while True: idx buf.find(b\xaa\x55) if idx -1: buf buf[-2:] # 保留末尾2字节防止跨包边界 break if len(buf) - idx 6: break frame buf[idx:idx6] del buf[:idx6] if frame[2] 0x01: if (frame[5] (frame[2] ^ frame[3] ^ frame[4])): val (frame[3] 8) | frame[4] y[:-1] y[1:] y[-1] val line.set_ydata(y) plt.pause(0.002)这段代码把新的点 push 进数组尾部形成滚动波形。plt.pause(0.002)是刷新间隔实际只影响显示帧率不影响数据接收。如果发现波形卡顿可以降低绘制频率比如每 50 个点刷新一次而不是逐点刷。帧同步逻辑值得提防一个坑搜索帧头后如果剩余长度不足 6 字节必须跳出内层循环继续读串口不能清空缓冲。我最初把缓冲整个清空结果每次串口分包都导致大量帧丢失波形变成虚线。保留末尾 2 字节再找帧头是很多组协议调试里最常见的解法。4.3 蓝牙透传与移动端显示的取舍做便携设备时很多人想把 STM32F103 系统里的波形实时发到手机。常见方案是外挂一个蓝牙串口模块比如 HC-05 或 HM-10把 UART 的 TX/RX 透传过去。这本身不难但无线传输给协议带来新约束蓝牙低功耗模块的 MTU 通常只有 20 字节一个 6 字节的帧虽然塞得下但频繁广播会大量耗电。我的建议是原型阶段先别上蓝牙用 USB 线调试好模拟前端和算法再考虑无线。如果非要上可以在 STM32 内部做 10:1 降采样再发送或者在手机上只显示心率数和两三秒的子采样波形。帧率永远没有稳定性重要数据丢包后波形全是锯齿会被误判成采集系统有问题。5. 心电采集系统的避坑指南5 个让波形翻车的细节5.1 波形全是 50Hz 毛刺模拟地漂了还是数字滤波失效现象采集到的波形像刺猬密密麻麻的高频尖刺还能看到明显的正弦波包络频率正好是 50Hz。原因有两个层次首先看电极线有没有屏蔽层如果用了普通杜邦线当导联线整根线都是天线其次看系统地和人体有没有形成地环路比如示波器探头夹子接了大地会把工频直接注入人体。解决换成带屏蔽层的三导联线屏蔽层单端接地系统电池供电不接任何大地面在 AD8232 的输出端先做 100Hz 低通模拟滤波再到 MCU。数字陷波只能补刀不能救人。5.2 ADC 采样值跳变供电不稳和参考电压噪声现象静止不动时ADC 数值忽大忽小相邻点差几十个 LSB。原因是 STM32 的 VREF 与 VDDA 共用时如果 VDDA 上有高频毛刺参考电压跟着抖采样结果自然跳。解决用万用表量一下 VDDA 纹波如果超过 20mV就在 VDDA 引脚旁边加 10μF 和 0.1μF 电容同时把 ADC 的采样时间调到最大。另一个容易忽视的点是 STM32 内部 ADC 漂移环境温度变化时基准会缓慢偏离对心电这种直流耦合系统影响明显可以让程序每 10 秒对 GND 通道多采一点扣除漂移。5.3 基线漂移到屏幕外面高通滤波器截止频率选低了现象波形整体缓慢上下移动像波浪有时直接推出屏幕。心电信号本身包含从 0.05Hz 开始的低频成分但人体运动和电极极化产生的基线漂移通常低于 0.5Hz。若模拟高通截止频率设成 0.05Hz则漂移也进来了。解决把 AD8232 的外置高通电容从 10nF 改成 3.3nF或换 1μF 配合合适电阻让高通转折到 0.5Hz 到 1Hz。代价是丢失一部分 ST 段的真实低频信息但对非诊断设备完全没问题。如果漂移还是很大看一眼电极是不是粘歪了重新清洁皮肤再贴。5.4 串口波形偶尔断帧环形缓冲区没做完整现象上位机波形每隔几秒跳一下或者偶发整段缺失但 MCU 侧逻辑分析仪看不出问题。原因是串口发送用了阻塞式HAL_UART_Transmit在发送时如果 DMA 或中断没有处理好下一帧的数据会被丢弃。解决改用 DMA 发送且发送缓冲区不要用局部变量发送频率高时用双缓冲一个 buffer 在发送另一个在填数据。我还在调试时犯过一个低级错误HAL_UART_Transmit_DMA只能连续调用一次第二次调用前要等UART_DMATransmitCplt回调置标志否则会缓冲区冲突。5.5 心电信号太小电极位置和右腿驱动接线不对现象波形有起伏但幅度只有几十 mV看起来像噪声看不到明显 QRS 波群。这通常是两个原因导致一是电极贴得离心脏太远常见的是双臂导联位置不准二是右腿驱动没有接人体处于浮空状态共模干扰没有反馈回路。解决把 RLD 引脚通过 100kΩ 电阻连接右腿电极但注意 RLD 输出有驱动能力限制直接短路到电极会引起过载。电极位置按标准为右臂红色、左臂黄色、右腿黑色贴之前用酒精棉片擦拭皮肤等待 30 秒让导电膏渗透。若仍偏小检查 AD8232 的增益配置确认放大倍数在 100 倍左右。6. 从能出波形到能成系统导联脱落检测与低功耗运行技巧当你能在屏幕上看到稳定的心电波形下一步就是让它像个产品而不是开发板。导联脱落检测是最先需要补上的功能。AD8232 的 LOD 和 LOD- 引脚会输出电极脱落状态把它们接到 STM32 的两个 GPIO通过 EXTI 中断或者查询模式上报。我习惯每 100ms 读一次这两个引脚如果任一引脚为高就停止发送数据并点亮提示灯避免上位机看到一堆无意义噪声。低功耗方面STM32F103 的停止模式配合外部 RTC 或定时器唤醒可以让待机电流降到微安级。做法是采样间隔不必要时可以关闭 ADC 和定时器只保留一个 1kHz 的低频定时器用于按键唤醒。数据存储可以加个 SPI 接口的 Flash在跌倒或导联脱落时把前后 10 秒数据存下来等充电时再传输。但要注意F103 的 Flash 擦写次数有限不能像 EEPROM 一样频繁写日志只能存关键事件。我交过的一份方案里就因为没做导联脱落检测导致采集系统在电极滑动后持续输出饱和噪声上位机还傻傻地计算心率算出一个每小时 300 次的可笑数字。后来我把 LOD 信号接了中断波形异常超过 3 秒就自动停止分析。这个教训让我明白任何生物电采集系统数据可信度永远优先于算法复杂度。心电采集的坑多半在模拟前端和地线不在 MCU 代码。另外给一个验证习惯拿到整机后先测标准方波源而不是直接贴人体。把信号发生器输出 1mV、1Hz 的方波送入 AD8232看输出波形是否干净。如果方波边沿不抖说明模拟前端和数字链路都正常再接电极贴自己。这样能快速隔离问题是来自前端还是后续软件。希望这些思路能帮你在 stm32F103 上把心电采集系统做稳少走几趟我走过的弯路。本文还有配套的精品资源点击获取