ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32+Keil5心电监测蓝牙App链路:从ADC采样到波形绘制全流程

STM32+Keil5心电监测蓝牙App链路:从ADC采样到波形绘制全流程 简介基于STM32Keil5开发的心电图监测蓝牙传输App毕业设计资源面向需要完成嵌入式软硬件结合课题的本科生与开发者。项目以STM32单片机为硬件核心通过AD转换实时采集心脏电压信号再经DMA高效传输并由主程序将心电数值交由HC-05蓝牙模块发送Android客户端采用Android Studio开发支持实时图表展示、历史数据查看与心电图片保存。压缩包共99个文件其中包含41个H头文件和40个C源码文件以及Keil工程配置、启动/脚本文件、原理图PDF、说明文档等整体仅408KB结构紧凑工程目录按硬件驱动、系统底层、用户程序等模块划分便于定位与二次开发。原理图标注了核心连接说明文档梳理了运行流程已有291人学习下载适合作为毕业设计参考或嵌入式蓝牙项目入门模板可直接烧录验证并理解AD采样、DMA传输、蓝牙串口通信及Android端曲线绘制的完整实现思路。1. 用STM32Keil5搭出心电图蓝牙App链路先想清楚这三件事把基于STM32Keil5开发的心电图监测蓝牙传输App当毕业设计本质上不是在做一台医疗设备而是在验证一条信号链路能不能稳定地跑起来。这条链路是心电模拟前端把微伏级信号放大成ADC能采的电压STM32按固定采样率采集并滤波再通过蓝牙模块把打包好的数据送到手机App实时画波形。很多人一开始就把精力放到App界面和图表上结果反复被HC-05连不上、波形乱跳、数据丢失这几个问题卡住。真正合理的顺序是先把采样率和数据帧格式定下来再写STM32工程最后做App端。这篇文章按照信号流动的方向从STM32的ADC配置讲到Keil5下的串口调试方法把最常见的坑和参数设置放在一起讲适合正在开题、或者已经焊好板子但跑不出稳定波形的人。2. 采样端STM32 ADC 与心率特征提取的工程取舍2.1 心电信号为什么难采幅值、干扰与采样率心电信号的主要频率分量集中在0.05Hz到100Hz之间幅值通常只有0.5mV到2mV。STM32内部ADC的参考电压一般是3.3V直接用ADC引脚去采心电信号分辨不出有效变化量所以必须经过仪表放大器放大。常见的做法是用AD8232这类前端芯片把信号放大100倍以上再送入ADC。这里有个容易忽略的细节运放输出要偏置到ADC参考电压的中间值附近否则心电信号的负半周会被截断导致波形下半部分被削掉。采样率这一项要在写代码前定死。按奈奎斯特定理200Hz就能覆盖100Hz以内的信号但R波上升沿比较陡直接用200Hz采峰值点容易落在两个采样点之间影响心率计算的准确性。常见做法是取250Hz到500Hz我用500Hz比较多原因是后续软件滤波会损失一部分高频能量500Hz留出余量T波和P波的形态也更完整。工频干扰和基线漂移是另外两个必须处理的噪声来源。AD8232内部已经带有高通和低通滤波器能挡住大部分高频噪声但50Hz工频耦合很难完全滤掉加上呼吸引起的基线缓慢漂移必须在STM32里再做一次软件处理。工程上的处理顺序是先做一次50Hz陷波或者移动平均再做基线漂移抑制最后做R波检测。2.2 STM32 ADC 最小配置定时器触发和DMA循环模式采样率最稳定的实现方式是让定时器触发ADC转换再由DMA把结果自动搬到内存。这样CPU不需要每次都进中断读寄存器转换完成的数据会自动写入缓冲区只有缓冲区半满或全满时才需要处理效率比在中断里手动读ADC高出不少。下面是一段基于STM32F103C8T6和HAL库的配置代码覆盖定时器、ADC和DMA三个部分/* 定时器3产生触发信号采样率 72MHz / (Prescaler1) / (Period1) */ TIM_HandleTypeDef htim3; htim3.Instance TIM3; htim3.Init.Prescaler 72 - 1; /* 72MHz分频到1MHz每1us计数一次 */ htim3.Init.Period 2000 - 1; /* 计满2000次即2ms对应500Hz采样率 */ HAL_TIM_Base_Init(htim3); TIM_MasterConfigTypeDef sMasterConfig {0}; sMasterConfig.MasterOutputTrigger TIM_TRGO_UPDATE; /* 更新事件作为ADC触发源 */ HAL_TIMEx_MasterConfig_Start(htim3, sMasterConfig); HAL_TIM_Base_Start(htim3); /* ADC1单通道由定时器3触发非连续转换启动DMA搬运 */ ADC_HandleTypeDef hadc1; hadc1.Instance ADC1; hadc1.Init.ScanConvMode DISABLE; hadc1.Init.ContinuousConvMode DISABLE; hadc1.Init.ExternalTrigConv ADC_EXTERNALTRIGCONV_T3_TRGO; HAL_ADC_Init(hadc1); HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_buf, BUF_LEN);这段配置里最关键的是Prescaler和Period的计算关系。STM32F103的ADC时钟挂在APB2上定时器3挂在APB1上这里以72MHz主频为例预分频72得到1MHz的计数时钟定时器每1微秒计一次数计满2000次就产生一次更新事件也就是2毫秒一次得到500Hz采样率。如果改用250Hz只需要把Period改成4000-1。HAL_ADC_Start_DMA启动后ADC每次转换结束都会把结果写入adc_bufDMA工作在循环模式缓冲区写满后自动回到起始位置。BUF_LEN通常取64或128对应500Hz采样率下128毫秒到256毫秒的数据长度这个窗口足够用来做一次R波识别。要注意的是DMA传输完成中断和半传输完成中断分别处理后半段和前半段缓冲区处理数据和DMA搬运错开避免同一块内存边写边读。提示如果HAL_ADC_Start_DMA之后读到的数据全是同一个值先检查ADC引脚有没有悬空再用万用表量运放输出端电压。大多数情况下这是模拟前端供电或共地问题不是代码问题。2.3 软件滤波和R波检测的最简实现ADC原始数据里还残留着工频干扰和基线漂移纯靠硬件不可能滤干净。毕设场景下一个简单的做法是对采样值做滑动平均窗口大小取10到20个点对应500Hz采样率下20到40毫秒这个窗口对R波上升沿的延迟很小又能压掉大部分高频毛刺。基线漂移可以用一阶高通滤波处理也可以直接用一个缓慢变化的基准值动态扣除。R波检测不需要上小波变换或神经网络阈值法配合不应期就够用。下面是常见的动态阈值检测思路#define THRESHOLD_RATIO 0.6f /* 阈值取信号峰值的60% */ #define REFRACTORY_MS 25 /* 不应期25个采样点即50ms 500Hz */ uint16_t signal_max 0; uint8_t refractory_counter 0; void ecg_process_sample(uint16_t value) { /* 在线更新信号峰值峰值会随着基线漂移缓慢变化 */ if (value signal_max) { signal_max value; } /* 超过阈值且不在不应期内认为检测到一个R波 */ if (value (uint16_t)(signal_max * THRESHOLD_RATIO) refractory_counter 0) { /* 这里可以记录R波位置用于计算RR间期和心率 */ refractory_counter REFRACTORY_MS; } if (refractory_counter 0) { refractory_counter--; } /* 峰值缓慢衰减适应信号幅度变化 */ signal_max (signal_max * 99) / 100; }signal_max是整个窗口内的峰值估计虽然只有1%的衰减但几十秒后就会适应新的幅度水平不需要额外的峰值复位逻辑。不应期设成50毫秒能有效防止T波被误判为R波因为正常心电信号里R波到T波之间至少有200毫秒的间隔。这一段的正确性会直接影响最终心率数。校准心电增益不是靠看波形高度而是靠对比已知频率的信号源测量结果下面给出工程上常用的几组参数范围参数推荐范围说明采样率250~500Hz低于250Hz时R波细节丢失心率计算容易偏低ADC位数12bit内置ADC电压分辨率约0.8mV前端增益100~300以运放输出不超过ADC参考电压为准阈值比例0.5~0.7太高漏检太低把T波误判成R波不应期25~40个采样点按当前采样率换算成毫秒3. 蓝牙链路经典蓝牙SPP和BLE的选型以及HC-05的参数配置3.1 为什么毕设里HC-05比BLE更常见心电波形数据是连续流式的速率不高但要求低延迟和稳定的连接。BLE协议在手机上天然被支持功耗也低但它引入了GATT服务、特征值、通知这些概念每次传输还需要组包和处理MTU限制。做毕设的学生通常希望在调试串口时直接把数据透传给手机经典蓝牙的SPP协议和单片机UART天然对齐哪个方案更稳是显而易见的。HC-05模块是SPP方案里最常见的载体。它内部自带蓝牙协议栈外部只需要通过UART收发数据对STM32来说和操作一个串口设备没有区别。硬件连接也只是RX、TX、电源、地四根线省去了一堆协议处理逻辑。BLE方案在产品化方向上更正确但评价一个毕业设计好坏的关键是系统能不能跑通、设计思路是否完整HC-05在这两方面都够用。两种方案的取舍可以简单看这张表对比项HC-05经典蓝牙SPPBLE低功耗蓝牙STM32侧开发量只有串口收发几乎为零需要BLE协议栈和GATT服务配置手机侧连接方式RFCOMM socket连接后当串口读扫描、连接GATT、订阅通知、分包解析功耗较高不适合电池供电长期运行很低适合可穿戴设备平均延迟低数据实时性好受连接间隔影响有一定延迟模块成本十几元左右单模BLE模块略高选HC-05还有一个现实原因调试方便。模块上电后有两个LED状态未连接时快闪连接成功后慢闪手机看不到波形时先看灯的状态就能判断问题出在哪一层比用逻辑分析仪抓BLE包省事得多。3.2 HC-05进入AT模式的三步配置HC-05上电后默认不进入AT模式需要让模块的KEY引脚保持高电平再上电。大多数开发板上这个引脚已经通过按键接好按住按键上电就能进入AT模式模块上的LED会变成慢闪。然后用USB转TTL模块直接连接HC-05的串口注意RX接TX、TX接RX、GND接GND不要接反。串口调试助手设置成9600波特率、8位数据、无校验、1位停止位发送AT如果返回OK说明通信正常。我一般会依次配好四条命令ATUART9600,0,0 # 设置串口波特率为9600停止位1位无校验 ATROLE0 # 设置为从角色等待手机连接 ATNAMEECG-MONITOR # 修改蓝牙名称方便在手机里识别 ATPSWD1234 # 设置配对密码ATUART9600,0,0这条命令最容易踩坑。HC-05出厂波特率不统一可能是9600、38400或115200如果发送AT没反应先把串口助手的波特率换成其他档位逐个试一遍。参数改完后必须重新上电模块才会使用新参数。ATROLE0表示从角色手机主动去连接它如果设成ATROLE1模块会主动去连别的设备反而会连不上手机。注意HC-05在这个模式下是独立工作的不接STM32也能完成所有配置。配置完成后再把模块的RX、TX接回STM32的USART2接线依然要保持交叉。很多人在这一步说“HC-05模块连接不上”实际上模块已经配好了只是STM32串口发送的数据模块根本没收到原因就是RX接RX、TX接TX这两根线焊错了。3.3 数据帧协议采样值怎么打包才不会丢ADC每2毫秒采样一个点如果每个点单独通过蓝牙发送数据的串口开销太大且容易错位接收端也无法判断字节从哪里开始。常见做法是先把采集到的采样值攒成一帧再加上帧头、长度和校验一次性发送。采样率的数值决定了一帧含多少数据点。以500Hz为例一帧可以攒100个点200毫秒的数据也就是5帧每秒每帧200字节这个体量对9600波特率来说有点紧张。工程上建议把波特率提到115200每帧50个点、20帧每秒延迟更小。发送端的打包逻辑如下/* 数据帧结构帧头0xAA 0x55 数据点数 采样数据 校验和 */ uint8_t tx_buffer[108]; tx_buffer[0] 0xAA; tx_buffer[1] 0x55; tx_buffer[2] SAMPLE_POINTS; /* 一帧里的采样点数 */ uint8_t checksum 0; for (uint16_t i 0; i SAMPLE_POINTS; i) { tx_buffer[3 i * 2] (ecg_frame[i] 8) 0xFF; tx_buffer[3 i * 2 1] ecg_frame[i] 0xFF; checksum tx_buffer[3 i * 2] tx_buffer[3 i * 2 1]; } tx_buffer[3 SAMPLE_POINTS * 2] checksum; HAL_UART_Transmit(huart2, tx_buffer, 3 SAMPLE_POINTS * 2 1, 100);帧头用两个字节0xAA 0x55接收端只有在连续收到这两个字节后才认为一帧开始可以避免丢字节导致的整体错位。校验和把一帧内所有数据字节累加后取低8位接收端按同样方式计算并比较不一致就丢弃当前帧。数据按大端方式拆成高低两个字节发送方便STM32和手机端解析时恢复原始数值。HAL_UART_Transmit是阻塞发送如果在一帧数据的采集过程中调用会阻塞128毫秒115200波特率下50字节约需4.3毫秒其实并不久这里说的是常规情况。但要注意不要在串口中断回调里直接调用它否则可能丢数据。通常做法是在主循环里判断采样点数是否攒够再执行发送。4. 手机App端串口透传与波形绘制的实现顺序4.1 App技术选型和权限配置Android原生开发中的BluetoothSocket可以直接建立RFCOMM通道和HC-05通信时只需要拿到蓝牙适配器、远端设备地址和UUID不需要引入第三方蓝牙库。iOS对经典蓝牙SPP支持不友好所以毕设里几乎都选Android做演示端。Android 12开始权限收紧蓝牙相关权限不再是一个BLUETOOTH就能解决。在AndroidManifest.xml里要同时声明旧版和新版的权限uses-permission android:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT /BLUETOOTH_SCAN用于扫描设备BLUETOOTH_CONNECT用于建立连接Android 12以上必须在运行时先申请这两个权限。ACCESS_FINE_LOCATION是Android 6到11之间扫描蓝牙设备的硬性要求因为系统认为蓝牙扫描可能间接暴露位置信息实际定位功能不需要使用。权限申请失败时代码里会抛出SecurityException。提示neverForLocation标志只是告诉系统该应用不用蓝牙推断位置但如果目标SDK版本低于31权限申请流程还是按旧规则走扫描仍然需要位置权限。4.2 建立SPP连接的核心代码蓝牙连接是耗时操作不能在主线程里执行否则会直接触发ANR。需要把连接过程放到子线程连接成功后单独开启读线程循环读取输入流。HC-05使用的UUID是固定的串口服务UUID00001101-0000-1000-8000-00805F9B34FB。private BluetoothSocket connectToHc05(String deviceAddress) throws IOException { BluetoothAdapter adapter BluetoothAdapter.getDefaultAdapter(); BluetoothDevice device adapter.getRemoteDevice(deviceAddress); // RFCOMM是经典蓝牙的串口模拟协议UUID必须匹配HC-05的SPP服务 BluetoothSocket socket device.createRfcommSocketToServiceRecord( UUID.fromString(00001101-0000-1000-8000-00805F9B34FB)); // 连接前先取消扫描否则连接过程会被系统主动打断 adapter.cancelDiscovery(); socket.connect(); // 阻塞调用必须在子线程执行 return socket; }连接成功后从socket.getInputStream()读取数据。读数据要使用单独的线程读取的逻辑是按字节读取拼接到缓冲区里然后交给解析器。蓝牙串口的数据是以流的方式到达的不像UART一帧一帧边界清晰可能一次read只返回几个字节也可能一次返回好几帧的数据所以缓冲区的设计和状态机解析是App端最核心的部分。HC-05连接不上时最常见的原因有三个手机之前已经和模块配对过一次但配对信息里的密钥对不上需要在手机蓝牙设置里删除设备重新配对第二个是模块还在AT模式里没有重启第三个是UUID写成了别的值。用系统蓝牙设置先手动连接一次HC-05如果能连上再打开App调试能省掉不少排查时间。4.3 数据解析和实时波形绘制的两个关键点解析从蓝牙流里到达的数据要按之前STM32定义的帧格式同步两个字节帧头、一个字节长度、数据体、校验和。因为数据在传输过程中没有帧边界需要用状态机来同步帧位置下面是关键代码private int parseState 0; private void parseByte(int b) { switch (parseState) { case 0: if (b 0xAA) parseState 1; // 收到第一个帧头字节 break; case 1: if (b 0x55) parseState 2; // 收到第二个帧头字节 else parseState 0; break; case 2: frameLength b; // 一帧内采样点数 frameIndex 0; frameChecksum 0; parseState 3; break; case 3: frameChecksum b; // 累加数据字节 // 每两个字节拼成一个16位采样值 if (frameIndex % 2 0) { rawValue b; } else { int value (rawValue 8) | b; handler.obtainMessage(MSG_ECG_DATA, value, 0).sendToTarget(); } frameIndex; if (frameIndex frameLength * 2) parseState 0; break; } }状态机的好处是无论从哪个字节开始进入数据流最多丢掉半帧数据下一帧就能恢复同步不需要手工清空缓冲区。校验和的判断在parseByte之外做完整帧到达后再整体验证。第二个关键是UI绘制。不要用TextView高频刷新显示数字也不要每次收到数据都调用invalidate()强制重绘这样会造成界面卡顿甚至ANR。常见做法是用双缓冲的思路收到的采样值先写入ArrayList再放到自定义View里画最近几百个点每秒钟只刷新30次左右既保证波形连续又不会把主线程拖垮。数据缓冲区用固定大小队列超过容量就先丢弃最旧的数据保证内存占用稳定。5. 从“能连上”到“波形不抖”调试顺序和3个必查点5.1 先验串口再验蓝牙的调试顺序蓝牙链路出了问题很难直接定位是STM32发送端、模块配置还是App解析的锅。我建议的调试顺序是先用USB转TTL工具直接把HC-05连电脑在电脑串口助手里确认模块收发正常再把STM32的串口输出接到USB转TTL上在电脑上确认STM32发送的帧格式正确最后才接回HC-05连接手机。每一步单独检验不要一口气把整条链路焊完再调。STM32侧调试时用Keil5打开工程进入Debug界面可以实时查看adc_buf里的数值。如果发现ADC值在停止运行时保持不变这是正常的因为定时器和DMA都停止了。真正需要检查的是HAL_UART_Transmit返回的状态以及串口助手里收到的帧头是不是AA 55。Keil5里有时候烧录后提示类似“error: no stm32 target found”的信息先查ST-Link和板子之间的SWDIO、SWCLK接线再看是不是芯片锁死用ST-Link Utility执行整片擦除可以恢复正常。5.2 三个必查点这三点是我做这类项目时反复检查的位置。第一波特率匹配。HC-05的AT配置和运行时的波特率是两回事AT模式下设置的ATUART9600,0,0只在模块重新上电后生效STM32侧HAL_UART_Init里的波特率必须和这个值完全一致。如果STM32每秒发出来的数据量比蓝牙链路实际承载能力高波形就会周期性丢帧在App上看到的是每隔几秒出现一次断层。500Hz采样率、每帧50个点13字节左右的数据115200波特率下完全够用如果用的是9600波特率帧率要相应降低到每秒几帧。第二粘包和半包处理。App端的InputStream.read()根本不会按帧边界返回数据每次都可能是半个帧头加半个数据体。不要试图通过加大缓冲区来避免这个问题正确做法就是状态机逐字节解析同时用校验和丢弃损坏帧。第三UI刷新路径。解析线程通过Handler把采样值发到主线程后如果在onMessage里直接TextView.setText更新数值每秒几百次操作必然卡顿。建议的刷新路径是解析线程往数据队列里写Choreographer回调或者定时器每30毫秒通知一次View重绘。5.3 用信号源验证心率计算的边界条件心率数值不是画个波形就算完成必须验证R波检测的准确性。最可靠的办法是用函数信号发生器产生一个频率已知的模拟心电信号接到AD8232的输入端STM32检测到的R波次数和信号发生器设定频率做对比。没有信号发生器时可以写一段测试代码让STM32的DAC输出一个固定频率的方波或者正弦波再按正常路径做ADC采样这个方法同样能验证滤波和R波检测逻辑。差分输入端的验证要注意安全任何接触人体的实验装置都要使用干电池供电或经过隔离的电源适配器不能直接接市电整流电路这不是代码问题是底线问题。波形在App端显示仍然抖动时先检查是不是ST-Link调试器还接着板子调试器通过USB把地电位引入了电路这在实验室里经常导致50Hz工频干扰加大拔掉调试器再供电波形通常会干净很多。本文还有配套的精品资源点击获取
返回列表