news 2026/9/16 15:08:33

STM32心电监测蓝牙传输App设计:从ADC采样到实时绘图全链路解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32心电监测蓝牙传输App设计:从ADC采样到实时绘图全链路解析

简介:这是一份面向电子信息、自动化、计算机等专业学生与毕业设计者的STM32心电监测蓝牙传输完整项目源码。资源包含硬件端STM32单片机程序与Android端App源码,硬件部分利用ADC实时捕获心电电压信号并经DMA传输,再通过HC-05蓝牙模块无线发送;软件部分基于Android Studio开发,实现蓝牙连接、实时心电图表展示、历史记录查询与保存图片等功能,可作为课程设计或毕设的可靠参考。压缩包共有98个文件,以C语言源码(41个.h、40个.c)为主,辅以Keil工程文件(.uvprojx/.uvoptx)、启动文件、hex烧录文件、原理图PDF和项目说明md,整体仅414KB,目录结构清晰,便于按模块查阅。目前已有505人学习浏览,具备不错的参考热度。整套代码经毕设评审验证,可直接运行调试,适合需要完整嵌入式+移动端联调方案的学习者深入研读或二次开发。

1. 一条心电数据是怎么从STM32走到手机App的

拿到这个基于STM32心电图监测蓝牙传输app设计(毕设源码).zip,第一眼看到的是 Keil5 工程、STM32F10x 固件库、HC-05 蓝牙模块和 Android Studio 工程混在一个包里,很多人会误以为重心在“算法分析”上。实际上这个项目真正的技术主线只有一条:STM32 通过 ADC 实时采集心电模拟信号,DMA 把采样值搬到内存,主循环里经过简单处理后塞给 HC-05 蓝牙模块,Android 手机 App 连接蓝牙后解析数据并绘制心电图,同时把历史数据落到 SQLite 数据库。整条链路的难点不在单片机裸机点亮外设,而在“实时采集 - 无线传输 - 端侧解析”这三段之间的时序匹配和数据一致性问题。这个包适合两类人:一类是把毕设题目定在医疗电子方向的本科生,另一类是刚接触“单片机 + 蓝牙 + Android”三段式开发、想完整过一遍联调流程的嵌入式开发者。阅读时建议对照Schematic_heart_2022-05-16.pdf原理图看代码,因为所有外设引脚分配都在原理图里。

2. 硬件端采集链路:ADC触发、DMA搬运与HC-05无线发射

2.1 心电信号的采样率选择与定时器触发

心电信号的能量集中在 0.05Hz 到 100Hz 之间,其中 QRS 波群的主要频率成分在 0.5Hz 到 40Hz 附近。按照奈奎斯特定理,理论上 200Hz 采样就能恢复波形,但考虑到后续要平滑显示、去噪和特征点识别,工程上建议把采样率定在 500Hz 左右。采样率太低,R 波峰值容易被淹没;采样率太高,蓝牙帧数据量成倍增加,HC-05 在 115200 波特率下很容易出现缓冲区溢出。这个工程的 ADC 触发方式不是简单的 while 循环连续采样,而是用定时器更新事件作为触发源,这样每个采样点的时间间隔是确定的,后续做心率计算时时间轴才可信。

定时器触发 ADC 的核心配置如下:

void TIM2_Init(void) { TIM_TimeBaseInitTypeDef TIM_Init; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_Init.TIM_Prescaler = 71; /* 72MHz 时钟分频到 1MHz */ TIM_Init.TIM_CounterMode = TIM_CounterMode_Up; TIM_Init.TIM_Period = 1999; /* 1MHz / 2000 = 500Hz */ TIM_Init.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, &TIM_Init); TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update); TIM_Cmd(TIM2, ENABLE); }

这段代码的关键在于TIM_SelectOutputTrigger,它允许定时器更新事件作为内部触发输出,接到 ADC 的外部触发引脚。TIM_Prescaler = 71先把 72MHz 分频到 1MHz,TIM_Period = 1999再分频得到 500Hz 的触发频率,两个参数共同决定最终采样率。如果要改为 250Hz,把TIM_Period改成 3999 即可;要改为 1000Hz,就把TIM_Period改成 999。

2.2 ADC配置与DMA循环缓冲

ADC 配置上,项目用的是 STM32F103 系列的 ADC1,采样通道对应心电前级放大电路的输出脚。需要注意:ADC 时钟不能直接取 72MHz,一般用 APB2 分频到 12MHz 左右,否则采样精度会明显下降。下面这段配置把 ADC1 设置为外部触发、规则通道、数据右对齐,然后使能 DMA 请求。

void ADC_DMA_Config(void) { ADC_InitTypeDef ADC_Init; DMA_InitTypeDef DMA_Init; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); ADC_Init.ADC_Mode = ADC_Mode_Independent; ADC_Init.ADC_ScanConvMode = DISABLE; ADC_Init.ADC_ContinuousConvMode = DISABLE; /* 定时器触发,不连续转换 */ ADC_Init.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_CC2; ADC_Init.ADC_DataAlign = ADC_DataAlign_Right; ADC_Init.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_Init); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); DMA_Init.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; DMA_Init.DMA_MemoryBaseAddr = (uint32_t)adc_buf; DMA_Init.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_Init.DMA_BufferSize = ADC_BUF_SIZE; /* 128 */ DMA_Init.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_Init.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_Init.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_Init.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_Init.DMA_Mode = DMA_Mode_Circular; DMA_Init.DMA_Priority = DMA_Priority_High; DMA_Init(DMA1_Channel1, &DMA_Init); DMA_Cmd(DMA1_Channel1, ENABLE); DMA_ITConfig(DMA1_Channel1, DMA_IT_HT | DMA_IT_TC, ENABLE); }

ADC_SampleTime_239Cycles5是较长的采样周期,对高阻源的心电信号来说能减少内部采样电容带来的电压跌落误差。DMA 配成循环模式后,缓冲区adc_buf会被写满一半或全部,分别触发半传输中断和传输完成中断。以下代码在中断里只置标志位,不处理数据,数据处理全部放到主循环:

void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_HT1)) { dma_half_done = 1; DMA_ClearITPendingBit(DMA1_IT_HT1); } if (DMA_GetITStatus(DMA1_IT_TC1)) { dma_full_done = 1; DMA_ClearITPendingBit(DMA1_IT_TC1); } }

注意,DMA 中断和 ADC 中断不要混用。我见过有人直接在 ADC 中断里做滤波算法,结果中断频率一高,主循环里的 LCD 刷新和蓝牙发送全被卡死。500Hz 采样率下,128 个采样点大约 256ms 填满一轮缓冲区,主循环在下一个半满中断到来之前有充足时间处理。

2.3 HC-05蓝牙模块的AT指令配置与接线

HC-05 模块在工程里挂在 USART3 上,发送引脚接 HC-05 的 RXD,接收引脚接 TXD,注意交叉连接且必须共地。模块上电后,按住按键再上电会进入 AT 模式,此时波特率固定为 38400,可以用 USB 转 TTL 直接连电脑配置。常用配置指令如下:

指令含义示例
AT+NAME=<name>设置蓝牙名称AT+NAME=HeartMonitor
AT+ROLE=<0/1>0从机、1主机AT+ROLE=0
AT+UART=<baud>,<stop>,<parity>设置串口参数AT+UART=115200,0,0
AT+CMODE=<0/1>0指定地址连接、1任意地址连接AT+CMODE=1
AT+PSWD=<pwd>设置配对密码AT+PSWD=1234

配置完成后重新上电,HC-05 会以 115200 波特率工作,此时必须保证 STM32 端 USART3 初始化也是 115200。这个项目里 STM32 端用的是标准外设库的 USART3 配置,初始化时重点检查两处:一是USART_InitTypeDef里的USART_BaudRate是否与 HC-05 一致,二是使能发送完成中断或直接在主循环等待发送寄存器空,否则蓝牙模块会频繁丢字节。

3. 数据帧与校验:蓝牙链路上如何保证不丢包不乱序

3.1 数据帧结构设计

HC-05 本质是一个串口转蓝牙模块,它不感知数据内容,所以协议层完全由自己定义。这个项目用的是固定帧头 + 数据长度 + 心电值 + 校验字节的组合。我建议把帧结构固定成这样:

字节偏移字段名长度说明
0帧头11固定0xAA
1帧头21固定0x55
2数据长度1后面有效载荷字节数
3..N心电载荷N2 字节一个采样点,大端序
N+1校验和1所有载荷字节累加和取低 8 位

STM32 端每次发送一个数据帧,帧内包含 32 个采样点共 64 字节,加上帧头和校验一共 68 字节。为什么选 32 个点?因为 500Hz 采样率下,32 个点代表 64ms,蓝牙端 App 每 64ms 收到一帧,绘制曲线时既不显得卡顿,又不会因为帧太长导致手机端缓冲区频繁拆包。

发送端的主要逻辑可以放在主循环里,DMA 半满中断置标志后,把半段缓冲区中的采样点按帧格式打包:

void pack_and_send(uint16_t *src, uint16_t len) { uint8_t frame[70]; uint8_t chk = 0; uint8_t i; frame[0] = 0xAA; frame[1] = 0x55; frame[2] = (uint8_t)(len * 2); for (i = 0; i < len; i++) { frame[3 + i * 2] = (uint8_t)(src[i] >> 8); frame[4 + i * 2] = (uint8_t)(src[i] & 0xFF); chk += frame[3 + i * 2] + frame[4 + i * 2]; } frame[3 + len * 2] = chk; for (i = 0; i < 3 + len * 2 + 1; i++) { while (USART_GetFlagStatus(USART3, USART_FLAG_TXE) == RESET); USART_SendData(USART3, frame[i]); } }

这里每个采样点都拆成高字节和低字节发送,接收端按同样顺序重组。累加校验只覆盖载荷字节,帧头不参与计算,这样可以快速定位帧边界。用USART_FLAG_TXE判断发送数据寄存器是否为空,比直接USART_SendData后不加等待更可靠。

3.2 Android端的状态机拆包解析

蓝牙串口是流式协议,数据包之间没有天然间隔,所以手机端最常见的坑是粘包与半包。解决办法不是加大缓冲区,而是用一个状态机逐字节解析。以下代码是从输入流读取字节后调用的核心解析方法:

private int state = 0; private int payloadLen = 0, payloadIndex = 0; private byte[] payload; private int checksum = 0; private void onByteReceived(byte b) { int v = b & 0xFF; switch (state) { case 0: if (v == 0xAA) state = 1; break; case 1: if (v == 0x55) { state = 2; } else if (v != 0xAA) { state = 0; } break; case 2: payloadLen = v; checksum = payloadLen; payloadIndex = 0; payload = new byte[payloadLen]; state = 3; break; case 3: payload[payloadIndex++] = (byte) v; checksum += v; if (payloadIndex == payloadLen) state = 4; break; case 4: if ((checksum & 0xFF) == v) { handleFrame(payload); } state = 0; break; } }

这段状态机把数据流切分成帧,校验失败的一帧直接丢弃,等待下一个0xAA重新定位。注意 case 1 里如果第二个字节既不是0x55也不是0xAA,状态要回到 0 而不是进入 2,否则遇到连续的0xAA 0xAA 0x55时会把第二个0xAA误认为帧头。实际调试中发现的另一个问题是:payloadLen为 0 的帧会导致new byte[0]直接跳到 state 4,所以发送端必须保证长度至少为 2。

3.3 链路带宽余量估算

HC-05 在 115200 波特率下,由于串口是 8 位数据位、1 位停止位、无校验,实际有效字节速率是 11520 字节每秒。当前协议每帧 68 字节,500Hz 采样下每秒产生约 15.6 帧,总数据量约 1060 字节,远低于链路上限。这个余量足够支撑未来把采样率提高到 1000Hz,或者把帧内采样点从 32 降到 16 来降低手机端绘图延迟。带宽分析在毕设答辩时经常被问到,可以把这组数字直接写进论文的通信模块设计章节。

4. Android端的蓝牙解析、实时绘图与SQLite落库

4.1 蓝牙串口连接与兼容性处理

Android 端使用 AndroidStudio2021 开发,编译目标建议设为 API 30 以下,避免蓝牙权限在 Android 12 上需要额外申请的麻烦。连接代码的核心是拿到 HC-05 的 MAC 地址,然后通过 SPP UUID 建立 RFCOMM 通道:

BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); BluetoothDevice device = adapter.getRemoteDevice(macAddress); BluetoothSocket socket; try { UUID sppUuid = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"); socket = device.createRfcommSocketToServiceRecord(sppUuid); socket.connect(); } catch (IOException e) { // 部分国产手机对 createRfcommSocketToServiceRecord 兼容性差,改用反射 Method m = device.getClass().getMethod("createRfcommSocket", new Class[]{int.class}); socket = (BluetoothSocket) m.invoke(device, Integer.valueOf(1)); }

createRfcommSocketToServiceRecord是 Android 官方推荐做法,但某些定制 ROM 上连接 HC-05 时会报Service discovery failed,此时反射调用createRfcommSocket(1)往往能绕过。连接成功后,要单独开一个线程读InputStream,把读到的每个字节交给上面的状态机处理。不要在 UI 线程里做流读取,否则界面会 ANR。

4.2 实时曲线绘制与平移

绘图部分这个项目用的是自定义 View,我觉得这种做法比引入 MPAndroidChart 更适合毕设展示,因为你可以在答辩现场直接对着代码讲 View 的绘制流程。核心思路是维护一个环形缓存队列保存最近 600 个采样点,onDraw里连接这些点画折线:

@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); canvas.drawColor(Color.WHITE); Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); paint.setColor(Color.parseColor("#E53935")); paint.setStrokeWidth(2f); paint.setStyle(Paint.Style.STROKE); Path path = new Path(); float stepX = getWidth() / 600f; for (int i = 1; i < pointsQueue.size(); i++) { float x0 = (i - 1) * stepX; float y0 = getHeight() / 2f - pointsQueue.get(i - 1) / 4096f * getHeight() / 2f; float x1 = i * stepX; float y1 = getHeight() / 2f - pointsQueue.get(i) / 4096f * getHeight() / 2f; if (i == 1) path.moveTo(x0, y0); path.lineTo(x1, y1); } canvas.drawPath(path, paint); canvas.drawLine(0, getHeight() / 2f, getWidth(), getHeight() / 2f, paint); }

热点事项:pointsQueue每次收到完整数据帧后更新,而invalidate()不能在蓝牙线程里连续调用,最好用postInvalidateOnAnimation或通过 Handler 定时 50ms 刷新一次。曲线横向宽度固定 600 点,这样无论屏幕分辨率多少,波形占满整个 View,看起来比用真实时间轴缩放更直观。

4.3 SQLite表结构与历史记录存储

App 的 SQLite 数据库是这段设计中容易被低估的部分。毕设源码包里明确写了sql数据库,实际作用就是把心电数据按会话存储起来。表结构我建议按会话和设备维度设计:

CREATE TABLE ecg_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_name TEXT, duration_ms INTEGER, data_points TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );

data_points字段存的是采样点以逗号分隔的字符串,比如"1024,1035,1041,1058,..."。单个会话按 500Hz 采样、记录 1 分钟就是 30000 个采样点,文本量约 150KB,SQLite 处理这个量级没有任何压力。不要把每个采样点单独插一行,那样写入事务开销太大,回放时查询也慢。App 里每收到一帧就往一个 StringBuilder 里追加数据,点击停止录制时一次性写入一条记录。需要查看历史数据时,按created_at倒序查询,然后把字符串拆成数组再直接复用onDraw的绘图逻辑。

String[] arr = record.dataPoints.split(","); List<Integer> replayPoints = new ArrayList<>(); for (String s : arr) { replayPoints.add(Integer.parseInt(s)); } graphView.setReplayData(replayPoints);

5. 联调与排错:用PC串口和USMART快速定位问题

5.1 用USB转TTL抓取HC-05发出的原始帧

整个项目联调的第一步不是打开手机 App,而是把 HC-05 的 TXD 和 GND 用杜邦线引出,接一个 USB 转 TTL 模块到电脑,打开串口助手看原始数据。正常情况下应该看到AA 55 40 03 E8 03 E9 ...这样的规律数据,其中40是 64 字节载荷长度。如果串口助手里出来的全是乱码,先检查波特率是否为 115200,再确认模块有没有重新上电生效 AT 配置;如果看到有数据但不是预期的帧头,检查 STM32 端发送字节的顺序和大小端。

用 USB 转 TTL 同时还有另一个作用:验证 STM32 端 USART3 的发送引脚有没有接错。HC-05 的 RXD 接的是 STM32 的 TXD,如果交叉接反,电脑端收到的会是大量0xFF0x00,这种问题光看代码很难发现,但理论上只要线序正确就不会出现。

5.2 蓝牙配对常见问题

手机搜不到 HC-05 时,先看模块上指示灯的状态:快闪表示未连接、慢闪表示已连接、常亮表示已配对。搜不到信号通常有几个原因:HC-05 还停留在 AT 模式没有重新上电;模块处于主模式并且AT+CMODE=0指定了目标地址;手机蓝牙缓存了旧的设备信息。此时用 Android 手机清除蓝牙配对记录,重新扫描即可。连接后 App 如果拿到的是空白数据,优先怀疑波特率不匹配,回到串口助手看 HC-05 输出是否正常,就能把问题范围缩小到手机端还是单片机端。

连接成功后还有一类隐蔽问题:HC-05 的 RXD 只接受 3.3V 电平,如果 STM32 板子的串口引脚是 5V TTL 电平,长时间运行有概率损坏模块,表现为连接正常但手机端偶尔断流。现在多数 STM32F103 开发板和 HC-05 模块都是 3.3V 供电,一般不用额外加电平转换,但组装时最好用万用表确认。

5.3 USMART 串口调试组件的妙用

工程源码里包含了USMART文件夹,这是一个类似 Shell 的串口调试组件,通过重映射fputcfgets,你可以在串口助手里直接输入函数名和参数来调用单片机上的任意函数。这个组件在调试 ADC 原始值的时候非常高效:在usmart_config.c里注册 ADC 读取函数,然后在串口助手里输入read_adc()就能立刻看到当前通道的采样值,不需要重新烧录程序。用这个方法可以快速确认问题出在模拟前端还是数字链路。

我一般这么排查噪声:先用 USMART 读取静态 ADC 值,如果读到的数值在无信号输入时反复跳变几百个单位,说明模拟电路干扰较大;如果数值稳定,则问题在后续的蓝牙发送或 App 绘图缩放上。这一步可能帮你省下半天拆线时间。

6. SQLite历史数据回放与导出:答辩前的最后一步

历史数据不只能看,还能回放成动画效果,这样比单纯贴一张静态截图更有说服力。回放思路是读取 SQLite 中的data_points字符串,按 10ms 间隔逐点写入绘图队列并刷新界面:

Handler handler = new Handler(Looper.getMainLooper()); Runnable playback = new Runnable() { private int index = 0; @Override public void run() { if (index < replayList.size()) { graphView.addPoint(replayList.get(index)); index++; handler.postDelayed(this, 2); // 等价于 500Hz 回放 } } }; handler.post(playback);

回放间隔和实际采样率保持一致,例如原始数据是 500Hz,就每 2ms 推一个点。这个细节评委老师会注意到。

另一个实用功能是把历史记录导出成 CSV 文件,PC 端用 Python 或 MATLAB 做后续频域分析。在 Android 10 以下可以写外部存储,新版本建议用 MediaStore:

ContentValues values = new ContentValues(); values.put(MediaStore.Downloads.DISPLAY_NAME, "ecg_session_" + date + ".csv"); values.put(MediaStore.Downloads.MIME_TYPE, "text/csv"); Uri uri = getContentResolver().insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values); OutputStream os = getContentResolver().openOutputStream(uri); StringBuilder sb = new StringBuilder("index,value\n"); for (int i = 0; i < points.size(); i++) { sb.append(i).append(',').append(points.get(i)).append('\n'); } os.write(sb.toString().getBytes()); os.close();

导出文件的表头index,value比纯数据多一行原因,让 Excel、Python pandas 都能直接读,不用额外指定列名。如果要做心率变异性分析,导出数据的时间戳字段要保留,否则只能重新从蓝牙抓一份带时间的帧。CSV 导出的数据配合scipy.signal做 50Hz 工频陷波和基线漂移校正后,还可以画出频谱图放进论文,把整套毕设从“能显示波形”提升为“能说明波形”。末尾把 SQLite 里的数据按时间分组,统计段落时长和采样点数,用来验证无线链路是否存在丢帧,比肉眼看波形靠谱得多。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 15:06:44

SSM整合协同过滤电影推荐系统实战

简介&#xff1a;这是一套基于Java SSM框架与Vue前端实现的协同过滤算法电影推荐系统完整源码&#xff0c;面向计算机专业本科生、毕业设计学生及Java全栈初学者&#xff0c;解决个性化电影推荐系统从算法落地到前后端集成的实践难题。资源包共844个文件&#xff0c;涵盖128个核…

作者头像 李华
网站建设 2026/9/16 15:03:10

MAA 下载安装完整指南:3 分钟选对版本,装到能跑

MAA 下载安装完整指南&#xff1a;3 分钟选对版本&#xff0c;装到能跑 【免费下载链接】MaaAssistantArknights 《明日方舟》小助手&#xff0c;全日常一键长草&#xff01;| A one-click tool for the daily tasks of Arknights, supporting all clients. 项目地址: https:…

作者头像 李华