从一颗F103C8T6开始的完整心电采集方案:PCB、固件到C#上位机的全链路实现
先说结论:想用STM32F103C8T6做出一套能看见真实心电波形、能算心率、能连续存储回放的全套系统,完全可行。TI杯那套开源资料我已经重新梳理过一遍,这篇文章会把我个人在复现和改造过程中踩过的坑、验证过的电路、改过三版才稳定的代码逻辑,全部讲透。
这周刚好有个学生问我:"为什么照着网上开源资料焊出来的心电板子,示波器上一片噪声,连基线都是飘的?"我让他把PCB拍照发过来,扫了一眼就发现问题了——模拟前端的地和数字地混在一起,AD8232的输出引脚直接拉了一根十几厘米的飞线到STM32的ADC引脚。这种情况神仙来了也救不了。
所以这篇文章不止是讲"怎么做",更想把"为什么这么做"讲清楚。无论你是要准备电赛、做毕设,还是单纯想给自己做个便携心电仪,建议按这个顺序把链路吃透:模拟前端选型 → PCB设计 → STM32采样与滤波 → C#上位机解析与显示。四个环节环环相扣,任何一个掉链子,出来的波形都没法看。
1. 整体方案架构:为什么是F103C8T6,又为什么需要独立模拟前端
1.1 选型时的核心权衡
STM32F103C8T6这颗芯片在2025年的今天依然活跃在各类项目里,不是没有道理的。它内置3个12位ADC,最快采样率能到1MHz,RAM有20KB,Flash有64KB,售价在国产替代方案里已经压到几块钱。对心电采集这个场景来说,ADC分辨率12位、采样率500Hz就够用,F103C8T6的性能完全在舒适区里。
有人可能会问:ECG信号那么微弱,直接用STM32内部ADC采样行不行?答案是可以,但前提是前端必须有一颗专用的模拟前端芯片或者运放电路来完成信号调理。因为心电信号的特点是:
- 幅度只有0.5mV~4mV,典型的R波峰值在1mV左右;
- 频率范围集中在0.05Hz~100Hz,QRS波群的主要能量集中在10Hz左右;
- 叠加了50Hz工频干扰、肌电干扰(30Hz~300Hz)、电极极化电压(最高可达±300mV)。
如果不做前端放大和滤波,直接把电极信号送进ADC,那12位的分辨率根本不够用——整个信号都被淹没在直流偏置和噪声里。
1.2 我最终采用的双芯片方案
信号链路是:电极 → 模拟前端(仪表放大器+右腿驱动) → 主控ADC → 串口 → 上位机
这里有必要专门说下模拟前端的选型。市面上常见的有两颗芯片:ADI的AD8232和TI的ADS1292R。我的建议是:
| 对比维度 | AD8232 | ADS1292R |
|---|---|---|
| 输出形式 | 模拟电压输出 | 24位数字SPI输出 |
| 硬件复杂度 | 低,外围元件较少 | 中,需要SPI时序和寄存器配置 |
| 功耗 | 典型170μA | 典型335μA |
| 导联数 | 单导联 | 双导联/多导联 |
| 适用场景 | 入门、快速出波形 | 需要高精度、多通道的进阶项目 |
| 成本 | 约10~15元 | 约25~40元 |
如果目标是TI杯这类竞赛,想快速跑通并且让评审看到清晰的波形,AD8232是最稳的选择。它内部集成了仪表放大器、二级放大、高通滤波和低通滤波,只需要极少的外围器件就能输出一个可直接进ADC的模拟信号。唯一的注意点是它内部的高通滤波器截止频率固定,用默认配置时大约0.5Hz,信号需要几百毫秒到几秒才能稳定,这是正常现象,不是故障。
我手头这块板子的具体方案是:AD8232输出引脚经一个RC低通滤波器(100Ω + 1μF,截止频率约1.6kHz)后,接入STM32的PA4引脚,ADC由TIM2的TRGO事件触发,采样率配置为500Hz。这个采样率对心电来说不但够用,而且为后续软件滤波留了足够的频域空间。
2. 硬件设计的关键细节:从原理图到PCB布局的实战经验
2.1 原理图设计中的几个容易忽视的点
原理图部分,很多开源资料里都直接给了AD8232的参考电路,照着画一般不会有大问题。但有几个细节如果不注意,后面调试时会非常痛苦。
第一,右腿驱动(RLD)引脚必须接。心电采集最怕的是共模干扰——人体相对于大地相当于一个天线,50Hz的工频信号会以共模形式进入两个输入电极。AD8232的右腿驱动引脚可以输出一个反相的共模信号到人体右腿,形成一个前馈抵消回路,能把共模干扰抑制掉40dB以上。不接这个引脚也能出波形,但波形会带着明显的50Hz毛刺,后期算法再厉害也难完全干净。
第二,电源去耦要对AD8232单独对待。F103C8T6的供电和模拟前端最好采用两个LDO分别供电,模拟部分用AMS1117-3.3再加一级LC滤波(10μH电感+10μF电容),数字部分直接LDO输出就行。如果共用一路电源,STM32的IO翻转噪声会顺着电源线耦合进模拟前端,表现出来就是波形上有周期性的小毛刺。
第三,电极输入端的保护电阻和滤波电容一定不能省。两个输入引脚各串一个100kΩ的电阻,对地并联一个100pF的电容。这两个RC组合既能构成EMI滤波器,又能在电极意外脱落或人体静电放电时保护芯片输入端。
提示:AD8232的输出引脚之前串联的电阻值建议选100Ω,不要选太大。选大了会抬高源阻抗,影响后级ADC的采样保持电容充电时间,可能导致采样值偏差。
2.2 PCB布局的黄金法则:把模拟区域隔离出来
PCB设计这块是整篇内容里水分最少、含金量最高的部分。我见过太多人把原理图画得漂漂亮亮,一打样回来就是一片噪底。
核心原则只有一句话:所有模拟元件(AD8232及其外围)必须集中在一片区域,数字走线不允许穿越这片区域,模拟地和数字地在电源输入端单点连接。
具体布局时,我按照以下顺序操作:
- 在PCB上明确划分模拟区(左侧)和数字区(右侧),中间用一条实体地线隔离带分开;
- AD8232放在模拟区的正中心,两个电极输入插座(我用的标准3.5mm音频插座做导联接口)放在板边,走线尽量短;
- 右腿驱动信号走线包裹在顶层和底层的GND敷铜之间,这是最容易被忽略的细节——如果RLD走线旁边平行着其他数字信号线,共模抑制效果会大打折扣;
- STM32最小系统放在数字区,晶振尽量靠近芯片,且晶振底下不要走模拟信号线;
- 整板底层大面积敷铜接地,顶层模拟区周围用一圈接地过孔围起来,形成一个"法拉第笼"效果。
这块板子我实际打样了三次。第一版问题出在AD8232输出走线与SPI通信线(预留的调试口)平行走了3cm,波形上直接叠加了周期性尖峰。第二版把走线彻底分开、单独加屏蔽地线后,底噪从±20mV降到了±3mV左右。所以说布局布线不是玄学,全是实打实的电磁兼容问题。
2.3 电源与电池供电的考量
用USB供电时,电脑开关电源的噪声会通过USB地线传导进来,表现为波形上有宽频带噪底。测试时可以用充电宝或锂电池供电,波形会瞬间干净一个量级。考虑到便携场景,这套系统建议用一节3.7V锂电池加低 dropout LDO供电。实测下来整机工作电流约40mA(包含STM32全速运行、LED指示、AD8232、串口芯片),一块500mAh的电池可以连续工作10小时以上。
3. STM32固件开发:CubeMX配置、中值滤波与心率计算
3.1 用CubeMX生成工程时需要注意的配置项
现在用STM32CubeMX配置工程已经是主流做法,F103C8T6的库支持非常完善。关键配置项如下,每一项都有我标注的原因:
- RCC:HSE晶振8MHz,这是经典配置,F103最高72MHz主频,8MHz经过PLL×9倍频即可。
- ADC1:开启连续转换模式,分辨率12位,采样时间拉到最大(239.5周期)。心电是低速信号,不需要追求采样速度,反而要保证采样电容充分充电。
- 触发源:选TIM2 Trigger Out事件。用定时器触发ADC的好处是采样间隔可以由硬件精确控制,不占用CPU。我把TIM2的时钟配置为72MHz/(72×2000)=500Hz,即每2ms一次触发。
- DMA:ADC1的DMA设置为Circular模式(循环模式),数据宽度Half Word,方向Peripheral to Memory。这样ADC连续采样的数据会通过DMA自动搬运到内存数组里,CPU完全不用干预。
- USART1:波特率115200,8N1。串口发送采用DMA方式,避免在发送过程中被打断。
- PA4:配置为ADC1_IN4模拟输入,注意PA4在F103上是ADC1的第4通道。
这里有个很多教程不会提的坑:DMA缓冲区的数组大小必须设置为2的幂,比如256或512。原因是DMA循环模式下,当传输计数用完之后需要重新装载,如果缓冲区不是2的幂,会出现数据错位,导致后续滤波算法拿到的是一个错乱的时间序列。我一开始用的233,波形出来以后负责心率检测的阈值完全乱掉,换成256之后立刻正常。
3.2 软件滤波:中值滤波、去基线漂移与工频陷波
ADC原始数据进来之后,直接拿去画波形是不行的。实际采到的信号里包含几个成分:心电信号本身的AC分量、呼吸引起的基线漂移(频率通常0.1Hz~0.5Hz)、50Hz工频干扰、电极运动伪迹。我的处理链路是三级滤波:
第一步:滑动窗口中值滤波(抑制运动伪迹和尖峰噪声)
实现方法:维护一个长度为5的滑动窗口,每次新的采样点进来自动丢弃最老的点,取中间值作为当前输出。中值滤波对脉冲噪声(比如电极接触不良产生的尖峰)有奇效,而且不会像均值滤波那样把QRS波群的尖锐边缘吃掉。在代码里我开了一个长度为5的数组,每来一个新数据就做一次插入排序取中间值,开销极小。
第二步:高通滤波(去基线漂移)
这里用一阶IIR高通滤波器,截至频率设为0.5Hz。差分方程如下,我直接写在代码注释里方便大家抄:
// y[n] = α * (y[n-1] + x[n] - x[n-1]) // α = 1 / (1 + 2π * fc / fs) // fc = 0.5Hz, fs = 500Hz, 则 α ≈ 0.9937 float alpha = 0.9937f; float ecg_hp = alpha * (ecg_hp_prev + ecg_raw - ecg_raw_prev); ecg_raw_prev = ecg_raw; ecg_hp_prev = ecg_hp;这个滤波器的作用是把0.5Hz以下的缓慢漂移全部砍掉。呼吸动作、身体轻微移动造成的基线漂移主要集中在这一频段。
第三步:50Hz IIR陷波器(去工频干扰)
前端的右腿驱动已经滤掉了大部分共模工频干扰,但导线上的差模干扰依然存在,所以软件里再加一道保险。采用二阶IIR陷波器,中心频率50Hz,Q值设为1.5。Q值不能太高,太高会伤到QRS波群中50Hz附近的频率成分,导致波形变形。
// 采样率500Hz下,50Hz陷波器系数(由matlab fdatool生成后手工填入) float b[3] = {0.9647f, -0.9999f, 0.9647f}; float a[3] = {1.0000f, -0.9999f, 0.9294f};三级滤波全部跑完后,波形已经具备以下特征:基线水平稳定在0附近、R波尖锐可辨、T波圆滑可见。我在实验里对比过滤波前后的R波幅值,滤波会造成约5%的幅度衰减,这是IIR滤波器的正常群延迟代价,不影响心率算法。
3.3 R波检测与心率计算:动态阈值法实现
心率检测最怕的是阈值固定——姿势变化、电极接触松动都可能导致信号幅值大幅波动。我采用的方法是动态滑动阈值:
维护两个滑动窗口:一个是信号峰值历史的最大值(用于计算阈值),一个是RR间隔的历史(用于计算瞬时心率和平均心率)。R波检测逻辑如下:
- 对滤波后的信号计算一阶差分,R波下降沿是强烈负斜率;
- 设定阈值 = 0.6 × 最近8个R波峰值的滑动平均值;
- 信号从阈值以下上升穿过阈值时,标记为一个可能的R波候选;
- 为避免T波被误检为R波,加入300ms的空白期(绝对不应期),在这个时段内不进行新的检测。
RR间隔 = 相邻两个R波的时间差。瞬时心率 = 60000 / RR间隔(单位ms),平均心率 = 最近8个RR间隔的平均值转化为每分钟心率。
这套逻辑在正常窦性心律下非常稳定,误差基本能控制在正负2次/分钟以内。要说局限,就是它对心律失常(比如室性早搏的宽大畸形QRS波)不敏感,但作为学习项目和小型可穿戴设备足够用了。
4. C#上位机开发:从串口数据解析到波形绘制
4.1 通信协议设计:帧格式必须一开始就定好
固件和上位机之间的通信协议,第一步就要把帧格式写死,不要想着后面再改。我用的协议如下,简洁可靠:
| 字节偏移 | 内容 | 说明 |
|---|---|---|
| 0 | 帧头 0xAA 0x55 | 两个固定字节,用于同步 |
| 2 | 数据长度 | 固定为2,即后面两个字节为有效数据 |
| 3 | ECG数据高字节 | 12位ADC值右移4位后的高8位 |
| 4 | ECG数据低字节 | 12位ADC值低8位 |
| 5 | 心率值 | 0~255,单位bpm |
| 6 | 校验和 | 从帧头开始到心率值所有字节的异或值 |
帧长固定为7字节,每2ms发送一帧,一秒钟500帧,每帧的数据净荷只有4字节(ECG高、低+心率+校验),串口负载仅为 7×500×10bit = 35kbps,115200波特率下占空比小于三分之一。余量很足,如果后续要加体温、血氧数据也完全加得下。
4.2 串口接收与解析:VS2019、VS2015兼容性处理
C#上位机这块,我可以明确告诉你:VS2019开发的工程用VS2015打开是可以做到的,但有条件。关键在于你创建项目时选择.NET Framework版本。如果你在VS2019里选的是.NET Framework 4.6.2或更低版本(VS2015默认最高支持4.6.2),那么用VS2015打开时基本没有障碍。但如果你选了.NET Core或.NET 5/6/7/8,那VS2015完全打不开,因为它不支持新式SDK风格项目。考虑到很多评委老师和学生电脑还停留在VS2015,我的项目是直接在VS2015里新建的WinForms工程,目标框架设为.NET Framework 4.6.2,这样在VS2019里也能打开,走的是"向下兼容"的路子。
串口解析的逻辑,建议用后台线程+队列的方式,不要直接在串口DataReceived事件里做UI操作。这会引发跨线程访问控件的异常(虽然可以Invoke解决,但频繁的UI刷新会卡死界面)。我的实现方案是:
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e) { byte[] buffer = new byte[1024]; int n = serialPort.Read(buffer, 0, buffer.Length); lock (syncLock) { for (int i = 0; i < n; i++) { receiveQueue.Enqueue(buffer[i]); } ParseQueue(); // 在这里逐字节解析帧 } }解析状态机是常规的逐字节状态跃迁:等待AA → 等待55 → 读取数据长度 → 读取数据载荷 → 读取校验 → 校验通过后把ECG数据推入波形缓存、心率和RR值用于UI显示。如果校验失败,状态机回到初始状态继续找帧头,这样即使偶尔串口丢数据也能很快恢复同步。
4.3 实时波形绘制:双缓冲与增量绘制
波形绘制是上位机的"门面"环节,做得好看不好看直接影响答辩观感。这里有两个性能优化手段是必须掌握的。
双缓冲绘制:把波形画在一个Bitmap上,绘制完成后一次性显示到PictureBox上。如果不做双缓冲,波形刷新时会出现明显的闪烁。WinForms里可以设置控件属性DoubleBuffered = true,但更推荐的做法是自己创建Bitmap再Graphics.DrawImage。
增量绘制:每次定时器触发(我用的是30Hz刷新率)只画新到达的数据点,不重画整条曲线。具体思路是:维护一个List,每次定时器把新数据追加进去,只从上一帧画到当前帧。大部分教程里的做法是每帧把整条线画一遍,性能低且会出现滞后感。
private void timerRefresh_Tick(object sender, EventArgs e) { using (var g = Graphics.FromImage(bmp)) { g.Clear(Color.FromArgb(30, 30, 30)); // 画网格线 DrawGrid(g); // 把波形数据缩放到画布高度(取最近3000个点) float scaleY = paintHeight / 1000f; // 假设信号范围±500mV Pen penWave = new Pen(Color.LimeGreen, 1.5f); for (int i = lastPointIndex; i < pointList.Count - 1; i++) { g.DrawLine(penWave, (i - startIndex) * scaleX, paintHeight / 2 - pointList[i] * scaleY, (i + 1 - startIndex) * scaleX, paintHeight / 2 - pointList[i + 1] * scaleY); } lastPointIndex = pointList.Count - 2; pictureBox1.Image = bmp; } }另外,画心率数字时用大号蓝色字体,可以在黑底绿波的界面上非常醒目。很多开源项目不太注重这个细节,但实际答辩时,一个随时跳动的心率数值带来的观感,比一张漂亮的波形图更能说明系统的可靠性。
4.4 数据存储与回放功能
需求里如果有"回放"要求(TI杯这类竞赛经常有),上位机就需要把原始数据落盘。我用最简单的CSV格式:每行记录一个时间戳和一个ECG原始值。回放时开一个Timer,按固定时间间隔读取CSV里对应行数的数据喂给显示区,就能复现当时的实时绘制效果。
这个功能看着不起眼,但在调试算法的时候特别有用。我在调中值滤波参数时就是靠回放数据反复试验,不用让被测者一直坐在那里重复采集。
5. 系统联调:噪声排查、地环路处理与数据验证方法
5.1 一套标准化的调试验证流程
每次改完硬件或固件,我都会按这个顺序检查,能极大缩短定位时间:
- 短路测试:用1kΩ电阻短接两个电极输入,此时正确的波形应该是近似一条平线。如果波形有波动,问题一定在前端或电源,不是软件滤波的事。
- 自检信号测试:AD8232有一个导联脱落检测输出(LO+和LO-),可以验证电极接触是否正常。
- 单手触碰测试:单手指轻轻触碰左/右电极输入,观察波形应该有一个大幅度的50Hz抖动。这个测试用来初步判断共模抑制是否生效——正常情况下抖动幅度很小,如果幅度过大,说明右腿驱动没接好或者供电不干净。
- 正常人体测试:双手分别按住左/右电极(保持湿润),保持静止,此刻波形应该能稳定显示P波、QRS波群和T波。呼吸时会看到基线平滑漂移,这属于正常现象。
- 长时稳定性测试:记录10分钟数据,观察心率数值漂移范围。正常情况下,静息心率的波动应该在±3bpm以内。
5.2 我最难忘的一次排查:神秘周期干扰
做第二版PCB测试时,波形上总是出现频率大约2Hz的周期性干扰,幅度有0.5mV,不属于心电信号。我检查了供电(干净)、检查了电极(换全新)、检查了AD8232配置(没问题),一度以为是芯片坏了。
最后发现,问题出在F103的PB2引脚(BOOT1)上。我在调试时给这个引脚接了LED指示灯,而它的翻转频率恰好约2Hz。LED电流走的地回路刚好穿过模拟区,造成地电位微小波动。把LED的限流电阻和走线改到板边单独走线后,干扰立刻消失。
这个坑让我彻底明白了:任何数字状态变化(IO翻转、PWM、LED闪烁、SPI通信)都会通过地电流干扰模拟前端,唯一的解决办法是物理隔离和分区布线。这也解释了为什么成熟的心电采集方案会使用独立的ADC和模拟前端芯片,把模拟和数字彻底分开。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 波形完全平线 | 电极接触不良 | 更换电极贴片,检查导联线 |
| 波形平线且有心率值 | ADC通道配置错误 | 检查CubeMX引脚与代码是否一致 |
| 波形有50Hz正弦叠加 | 共模抑制失效 | 检查右腿驱动接线,检查电源滤波 |
| 波形低频大幅漂移 | 基线漂移没有滤除 | 检查高通滤波器参数,检查电极极化电压 |
| 波形有随机尖峰 | 数字串扰或静电 | 检查布局分区,增加输入RC滤波 |
| 心率数值跳动大 | R波检测阈值不合适 | 调整阈值系数和空白期时长 |
| 串口上位机收不到数据 | 波特率不匹配或帧协议不一致 | 检查串口配置,确认帧头字节 |
6. TI杯参赛与资料复现的工程化经验
最后这部分是给准备拿这个项目去参赛的同学写的。TI杯评审的关注点,跟平时实验室用完全是两回事:
评审更看重系统级稳定性和指标的实测结果,而不是用了多高级的芯片。我当年答辩的时候,评委问的第一个问题就是"你的噪声指标是多少",第二个问题是"如果有人心率失常,你的系统能报警吗"。所以建议在准备材料时,把这三件事做扎实:
- 量化指标:把基线噪声(实测±3mV以下)、心率误差(静息状态下±2bpm)、系统功耗(约40mA)实测记录下来,做成表格放进报告。光写"稳定准确"没人信,有数据才有说服力。
- 故障注入测试:演示拔掉一个电极时系统能检测到导联脱落并报警(用AD8232的LO-引脚实现);手拍打桌面模拟运动伪迹,观察系统能恢复正常而不会死机。这两个现场演示在答辩时非常加分。
- 界面友好度:上位机的界面不要只画波形,建议把心率数值、导联连接状态(正常/脱落)、数据存储状态、时间轴都显示出来。细节决定评审印象分。
至于TI杯开源的资料包,我在复现时发现它给的原理图和C源码是能直接用的,但PCB布局文件年代较早,模拟地平面处理得不够干净。如果你时间充裕,强烈建议按本文第2章的布局原则重新布线打样一块,波形质量会有质的提升。如果时间紧张,直接用原版PCB数据打样,再搭配本文的软件滤波链路,也能得到可接受的结果。
还有一个很实际的经验:给板子留一个SWD调试接口(4线:SWDIO、SWCLK、GND、3.3V),不要只靠串口下载程序。调试滤波参数和R波检测逻辑时,用J-Link或DAP-Link在线调试能省一半时间。我用的是顺手做的DAP-Link下载器,F103C8T6自带USB,刷个DAPLink固件就能当下载器用,而且C8T6的USB引脚PA11/PA12刚好空闲,不占额外资源。
另外,F103C8T6作为老牌芯片,国产替代方案已经很成熟。如果采购困难或者想控制成本,完全可以选用同封装的国产兼容型号,引脚和寄存器地址完全一致,Keil下直接选对应型号编译即可。不过要留意国产芯片的ADC温漂参数普遍不如ST原厂,长时间连续采集时可能会发现基线有缓慢的偏移,这时上位机端再加一档数字高通滤波或者定期校准就能解决。
这套链路全部打通之后,下一步可以玩的方向很多:把FreeRTOS移植进来做多任务调度(波形采集、心率计算、数据显示各占一个任务)、用蓝牙模块替换串口线实现无线传输、用SPI接口的Flash存储离线数据、甚至给系统加一个触摸屏做成手持版本。基础架构只要铺好了,往上叠加功能都是水到渠成的事。