STM32心电监测实战:ADC采样、蓝牙透传与Android显示
简介面向电子、嵌入式相关方向学生的毕业设计资料资源围绕基于STM32的心电图监测蓝牙传输系统展开完整覆盖单片机端与Android端软硬件设计方案。包内文件共99个以C/H源码、Keil工程配置、原理图PDF及说明文档为主压缩包仅413KB体积精简但目录结构清晰方便按模块查阅。硬件部分实现了STM32ADC实时采集心电信号、DMA高效搬运与HC-05蓝牙无线发送软件部分提供Android Studio开发的App工程能实时绘制心电数据图表、保存历史记录及截图。此外还包含HARDWARE、CORE、SYSTEM等典型工程目录可结合原理图深入学习心电采集链路。已有423人学习/下载适合毕业设计选题或嵌入式蓝牙应用开发入门参考。1. 从胸导联到手机屏幕一条心电数据的完整链路心电监测这个题目最容易翻车的地方不在算法而在数据链路的时序配合。STM32 的 ADC 采样率、DMA 的搬运节奏、HC-05 的串口波特率、Android 端的读取循环任何一处不匹配手机上画出来的曲线都会变成一团噪声。这个项目把这条链路完整拆开了硬件端用 STM32F103 的 ADC 采集心电电压信号经 DMA 直接写入内存主循环再把数据交给 HC-05 蓝牙模块透传软件端用 Android Studio 2021 编写的 App 通过 SPP 协议接收字节流实时绘制心电图、保存历史记录和截图。资料包里包含完整的 STM32 工程、原理图 PDF 和 APP 源码适合做毕业设计也适合想搞懂传感器采集到手机显示这条技术栈的嵌入式工程师。2. STM32 端心电采集ADC 采样、DMA 搬运与内存缓冲设计2.1 心电信号为什么必须走 ADCDMA 而不是 GPIO 中断心电信号经前置放大和电压抬升后进入 STM32 的 ADC 引脚。STM32F103 的 ADC 是 12 位逐次逼近型输入范围 0~3.3V在 3.3V 参考电压下 1LSB 约 0.8mV。表面看分辨率足够真正的问题是采样节奏心电信号的典型频率范围是 0.05~100Hz按奈奎斯特准则采样率至少 200Hz实际设计里通常做到 500Hz 甚至 1kHz。如果直接在 main 循环里调用ADC_GetConversionValue()时间片会被按键扫描、OLED 刷新、蓝牙发送等代码抢占采样间隔漂移几十毫秒画出来的波形就会忽宽忽窄。用 DMA 把 ADC 转换结果自动搬运到内存是嵌入式采集心电的标准做法。ADC 每转换完一次就触发一次 DMA 请求不需要 CPU 干预main 循环只负责从缓冲数组里取最新的数据点通过 HC-05 发出去。这样采样时序由 ADC 硬件保证抖动小得多。项目工程里HARDWARE目录下的 adc.c 和 dma.c 就是干这个的SYSTEM目录里则提供串口和延时等基础支持。2.2 ADC 与 DMA 的初始化顺序和参数选择初始化顺序有个容易踩坑的点必须先开 DMA 时钟并配置通道再配置 ADC最后调用ADC_DMACmd()开启 DMA 请求。反过来配置时DMA 可能捕获不到 ADC 的第一个转换完成事件。下面是一段常见配置基于 STM32F10x 标准外设库__IO uint16_t ecg_buf[256]; void ECG_ADC_DMA_Init(void) { ADC_InitTypeDef adc; DMA_InitTypeDef dma; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); DMA_DeInit(DMA1_Channel1); dma.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; dma.DMA_MemoryBaseAddr (uint32_t)ecg_buf; dma.DMA_DIR DMA_DIR_PeripheralSRC; dma.DMA_BufferSize 256; dma.DMA_PeripheralInc DMA_PeripheralInc_Disable; dma.DMA_MemoryInc DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; dma.DMA_Mode DMA_Mode_Circular; dma.DMA_Priority DMA_Priority_High; DMA_Init(DMA1_Channel1, dma); adc.ADC_Mode ADC_Mode_Independent; adc.ADC_ScanConvMode DISABLE; adc.ADC_ContinuousConvMode ENABLE; adc.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; adc.ADC_DataAlign ADC_DataAlign_Right; adc.ADC_NbrOfChannel 1; ADC_Init(ADC1, adc); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_239Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); DMA_Cmd(DMA1_Channel1, ENABLE); }这里几个参数需要单独说明。ADC_SampleTime_239Cycles5表示采样时间最长档为了降低输入阻抗变化带来的采样误差在 72MHz 主频、ADC 预分频 6 分频即 12MHz 时单个通道的转换周期约 12.5239.5 个换算下来采样率大约是 12MHz / 252 ≈ 47.6kHz。心电不需要这么高实际工程里可以改用ADC_SampleTime_71Cycles5配合 DMA 循环搬运和主循环里的软件分频把发送到蓝牙的包率控制在 500Hz 以内。DMA_Mode_Circular是循环模式缓冲写满 256 个点后自动回卷相当于一个环形缓冲区。主循环不需要知道 DMA 当前写到哪个位置只需要记录上一次处理到的位置两者差就是新产生的数据量。注意ADC_ExternalTrigConv_None表示软件触发转换这要求每次转换完成后重新调用ADC_SoftwareStartConvCmd。如果你希望完全由定时器触发可以改用ADC_ExternalTrigConv_T2_CC2但项目里用软件触发已经足够因为 DMA 循环模式会持续搬运。常用参数和影响范围整理如下参数典型值影响ADC 预分频6 分频12MHz超出 14MHz 会明显降低线性度采样时间71.5 / 239.5 周期越长输入阻抗越稳但采样率降低DMA 缓冲区128~512 个 HalfWord太小容易丢点太大导致显示延迟DMA 模式Circular避免搬运中断保证连续采集2.3 主循环如何消费 DMA 缓冲DMA 负责把数据写进ecg_buf主循环负责把它发出去。常见做法是用一个位置记录上一次读到哪里然后用 DMA 当前计数算出新增数据uint16_t ecg_tail 0; void ECG_PollAndSend(void) { uint16_t dma_pos 256 - DMA_GetCurrDataCounter(DMA1_Channel1); if (dma_pos ecg_tail) { ECG_SendFrame(ecg_buf ecg_tail, dma_pos - ecg_tail); } else { ECG_SendFrame(ecg_buf ecg_tail, 256 - ecg_tail); ECG_SendFrame(ecg_buf, dma_pos); } ecg_tail dma_pos; }DMA_GetCurrDataCounter()读出 DMA 当前剩余计数缓冲区总长 256 减去它就是最新写入位置。注意这里不能直接用ecg_tail和dma_pos做精确同步因为 DMA 写入和串口发送存在竞争好在 HC-05 的蓝牙串口本身是低速外设发送 200~500 字节/秒不会追不上 ADC 的产生速度。真正要关注的是两边波特率一致这一点放到下一章展开。2.4 常见误用在 main 循环里做转换很多刚接触标准库的代码会写成查询标志位然后调用ADC_GetConversionValue这在小批量温度采集没问题但对心电这种连续波形不适用原因有两个一是查询轮询会把 CPU 占满二是每次读到的值其实来自不同时刻相位信息丢失。用 DMA 后 CPU 占用率降到很低USMART组件也方便在调试时直接调函数观察缓冲区内容。STM32_ECGMonitoring-master工程里的 CORE、SYSTEM、HARDWARE 分层和正点原子风格一致改起来也容易。3. HC-05 蓝牙透传波特率配置、主从配对与连接失败排查3.1 先把 HC-05 调到 AT 模式再谈通信HC-05 是这套系统里最容易出现认知偏差的模块。它有两套完全不同的行为AT 指令模式和透传模式。上电前按住模块上的按键再把电源接上模块进入 AT 模式此时串口波特率固定为 38400可以用 USB 转 TTL 板在电脑上配置上电前不按按键模块进入透传模式串口波特率由 AT 指令里设置的UART参数决定。很多hc05蓝牙模块连接不上的情况本质上是把透传模式的波特率当成 AT 模式的来用或者反过来。AT 模式下最常用到的指令如下每一条都要以回车换行结尾AT 指令作用常用设置ATROLE1设置角色1 主、0 从、2 回环手机 App 作主设备模块设从机ATUART9600,0,0透传波特率、停止位、校验位与 STM32 串口必须一致ATPSWD1234配对密码Android 配对时输入ATNAMEECG-Monitor修改蓝牙名称方便在手机里识别建议在电脑上配完以后把模块断电重上电再进入透传模式测试一遍。配置完成后不需要每次重新设置模块会把参数写入内部 Flash。这里有个容易混淆的点ATUART修改的是透传模式的波特率AT 模式永远是 38400所以千万不要用 9600 波特率去连 AT 模式。3.2 串口接线和 Keil 里的波特率匹配STM32 和 HC-05 之间接线很直接HC-05 的 RXD 接 STM32 的 TXDHC-05 的 TXD 接 STM32 的 RXDGND 共地。需要注意 HC-05 的 VCC 虽然标注 3.6~6V但 RXD 引脚电平在有些版本上不是严格 3.3V用 5V 供电时最好串一个 1k 电阻或在 RXD 上加分压防止长时间高压反灌。Keil5 工程里对应的是HARDWARE下的串口驱动核心就是设置 USART1 的波特率和中断void USART1_Config(uint32_t baudrate) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Pin GPIO_Pin_9; // TXD gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); gpio.GPIO_Pin GPIO_Pin_10; // RXD gpio.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, gpio); usart.USART_BaudRate baudrate; usart.USART_WordLength USART_WordLength_8b; usart.USART_StopBits USART_StopBits_1; usart.USART_Parity USART_Parity_No; usart.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, usart); USART_Cmd(USART1, ENABLE); }这个配置里的USART_BaudRate要和 HC-05 透传波特率完全一致。如果 HC-05 设成 9600STM32 也必须是 9600设成 115200两边一起改。项目源码STM32_ECGMonitoring-master中默认值以 README 为准实际测试时建议先用 9600因为 HC-05 在较低波特率下误码率更容易控制。这里要注意的是“稳定”这个概念低波特率不代表绝对可靠反而因为吞吐量不足会限制采样率第 5 章会专门算这笔账。3.3 手机连接前的配对顺序手机连接 HC-05 时系统会先要求配对配对码就是 AT 指令里ATPSWD设置的值。Android 端的经典蓝牙使用 SPP 协议连接 UUID 固定为00001101-0000-1000-8000-00805F9B34FB。要注意 Android 蓝牙权限在 6.0 以上需要动态申请这部分放到第 4 章详细讲这里想强调的是顺序先在系统设置里扫描并配对再在 App 里发起连接。如果 App 里直接connect()很多手机会因为底层没有配对记录而返回超时。3.4 HC-05 连接失败排查清单现象大概率原因处理方式手机搜索不到模块模块没进入透传模式或天线被金属遮挡确认按键未按住靠近后重试搜索得到但配对失败密码错误或模块仍处于 AT 模式重做ATPSWD退出 AT 模式配对上但 App 连接超时SPP UUID 不匹配使用标准 SPP UUID用createRfcommSocketToServiceRecord能连上但数据显示乱码STM32 与 HC-05 波特率不一致检查ATUART与USART1_Config参数能连上但断断续续供电不足或串口地线未共地用 5V/500mA 以上电源单独给 HC-05 供电这一节最后补充一个容易忽略的点hc05蓝牙模块连接不上这个搜索热词背后超过一半的情况是模块处于 AT 模式但用户以为它在透传模式或者ATUART设置后被误认为会修改 AT 波特率。记住结论AT 模式永远是 38400透传模式才是用户设置的波特率。排查时先看指示灯状态模块透传模式下指示灯大约每 2 秒闪一次连接成功后变为快闪这个现象比串口调试更直观。4. Android 蓝牙 AppSPP 协议、字节流解析与实时折线图4.1 权限、设备选择与 RFCOMM 连接的搭建Android 端访问 HC-05 使用的是经典蓝牙对应 SPP 协议。Android 6.0 开始蓝牙权限分为普通权限BLUETOOTH和危险权限BLUETOOTH_ADMIN、ACCESS_FINE_LOCATIONAndroid 12 又引入了BLUETOOTH_CONNECT和BLUETOOTH_SCAN。用 Android Studio 2021 编译时Manifest 里要同时声明新老权限uses-permission android:nameandroid.permission.BLUETOOTH android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN android:maxSdkVersion30 / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT /运行时申请核心是ACCESS_FINE_LOCATION和BLUETOOTH_CONNECT。由于connect()是阻塞调用必须放在子线程蓝牙适配器通过getDefaultAdapter()获取但需要先确认手机蓝牙已开启再调用getRemoteDevice(mac)拿到远端设备。连接用的 UUID 是 SPP 的标准值不是 App 随机生成的00001101-0000-1000-8000-00805F9B34FB。HC-05 在配对成功后Android 系统会自动创建 RFCOMM 通道App 用它建立 socket。private BluetoothSocket connect(String mac) throws IOException { BluetoothDevice device BluetoothAdapter.getDefaultAdapter().getRemoteDevice(mac); BluetoothSocket socket device.createRfcommSocketToServiceRecord( UUID.fromString(00001101-0000-1000-8000-00805F9B34FB)); socket.connect(); return socket; }createRfcommSocketToServiceRecord与createInsecureRfcommSocketToServiceRecord的区别在于前者必须走系统配对流程后者跳过配对但安全性较低。本项目选择前者因为 HC-05 出厂默认就要配对用 insecure 通道反而容易因未配对而失败。注意connect()之前最好先调用BluetoothAdapter.cancelDiscovery()因为设备发现过程会占用蓝牙底层资源直接连接容易出现超时。4.2 读线程与字节流解析蓝牙 socket 拿到InputStream后不能直接在界面上循环读否则 UI 线程会阻塞。常见做法是单独起一个线程用read()把字节读入缓冲区然后交给后台解析。STM32 端每轮发送的心电数据可以设计成下面的帧结构字段长度说明帧头2 字节0xAA 0x55用于找边界数据高字节1 字节ADC 值高 8 位数据低字节1 字节ADC 值低 8 位校验1 字节前 4 字节累加和如果项目原版只发裸数据也建议在 App 端做“字节对齐”而不是按固定间隔切分。因为蓝牙传输有延迟字节流没有消息边界直接按两个字节切分可能导致相位错位。下面这段状态机把输入字节逐个喂进去private int state 0; private int sum 0; private final byte[] frame new byte[5]; public Integer onByte(byte b) { switch (state) { case 0: if ((b 0xFF) 0xAA) state 1; break; case 1: if ((b 0xFF) 0x55) { state 2; sum 0xAA 0x55; } else state 0; break; case 2: frame[0] b; sum (b 0xFF); state 3; break; case 3: frame[1] b; sum (b 0xFF); state 4; break; case 4: if ((sum 0xFF) (b 0xFF)) { int value ((frame[0] 0xFF) 8) | (frame[1] 0xFF); state 0; return value; } state 0; break; } return null; }状态机的好处是即使读到一半断开重连也不会把后续数据全错位。sum从0xAA和0x55就开始累加校验字节是前 4 字节累加和的低 8 位这个协议简单到 STM32 端只要十几行代码就能实现对 HC-05 这种低吞吐通道非常友好。解析后的value是 0~4095 的 ADC 原始值直接给图表使用缩放放在绘制层做避免丢失精度。4.3 实时折线图用自定义 View 还是第三方库实时显示心电波形有两条路一是引用 MPAndroidChart 这类图表库二是写一个几十行的自定义 View。前者省事但刷新高频数据时存在额外开销后者更适合理解数据怎么变成像素。这里给一个简化版自定义 View 的核心思路在onDraw里根据 ADC 值映射 Y 坐标X 坐标用递增索引超出一屏后整体左移。Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); for (int i 1; i values.size(); i) { float x1 (i - 1) * stepX; float y1 mapY(values.get(i - 1)); float x2 i * stepX; float y2 mapY(values.get(i)); canvas.drawLine(x1, y1, x2, y2, paint); } }mapY()里需要做一次归一化ADC 值范围 0~4095先除以 4095 得到 0~1再乘 View 高度最后用中值对齐把波形放到屏幕中间private float mapY(int value) { float normalized value / 4095f; return getHeight() * (1f - normalized) * 0.8f getHeight() * 0.1f; }这一步看起来简单却直接影响观感。很多 App 画出来波形贴顶或贴底就是因为没做中值偏置。心电信号在静息状态下有一个基线基线对应 ADC 值大概在 1.65V 偏置电压附近数值大约是 2048映射时需要把这个点放在屏幕 50% 到 60% 的位置。0.8f和0.1f相当于把有效绘制区域压缩到 View 高度的 60%~90% 区间留出上下余量避免过冲削顶。5. 让心电曲线更可靠采样率匹配、丢帧处理与相册保存技巧5.1 先算带宽再定采样率很多教程默认心跳数据量很小但用 HC-05 传心电时必须认真算一笔账。假设帧格式是 5 字节2 字节帧头 2 字节数据 1 字节校验如果每秒发 500 个点数据量就是 2500 字节/秒。HC-05 在 9600 波特率下理论极限约 960 字节/秒算上串口起始位和停止位实际可用吞吐量不到 800 字节/秒。也就是说 500Hz 采样在 9600 波特率下根本跑不动。心电信号的频率范围是 0.05~100Hz按奈奎斯特采样率 200Hz 已经足够临床设备常用 250Hz 或 500Hz 是为了波形还原度。对 HC-05 来说200Hz × 5 字节 1000 字节/秒9600 波特率依然紧张所以有两个方案把帧压缩成 3 字节去掉帧头只靠定时对齐或者把 HC-05 的透传波特率改成 115200。后者更省事115200 波特率可以提供约 11520 字节/秒的理论吞吐跑 500Hz 都富余。实际项目里如果发现 App 曲线跟不上采样速度优先检查波特率而不是怀疑 ADC 或 DMA。5.2 用帧序号缓解丢帧误判HC-05 是无线透传偶尔丢一两个字节很正常。如果不加序号App 只会看到一次校验失败没法区分是丢了数据还是波形本身跳变。建议在帧里加一个 1 字节序号发送端每发一帧加 1frame[0] 0xAA; frame[1] 0x55; frame[2] seq; frame[3] (ecg_value 8) 0xFF; frame[4] ecg_value 0xFF; frame[5] (seq frame[3] frame[4]) 0xFF;App 端如果发现连续两次的seq不连续就在缺帧位置做一次线性插值。这样曲线不会出现肉眼可见的断裂历史记录里也能保留原始丢帧信息而不是把丢帧数据当成真实值存库。5.3 相册保存的兼容性做法保存心电图到相册Android 10 以后直接用MediaStore.Images插入不需要申请存储权限。把View先绘制到Bitmap再写进系统相册public void saveToGallery(View view) { Bitmap bitmap Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888); Canvas canvas new Canvas(bitmap); view.draw(canvas); ContentValues values new ContentValues(); values.put(MediaStore.Images.Media.DISPLAY_NAME, ECG_ System.currentTimeMillis() .png); values.put(MediaStore.Images.Media.MIME_TYPE, image/png); Uri uri getContentResolver().insert( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); try { OutputStream out getContentResolver().openOutputStream(uri); bitmap.compress(Bitmap.CompressFormat.PNG, 90, out); out.close(); } catch (IOException e) { e.printStackTrace(); } }view.draw(canvas)会把自定义 View 当前画面原样截取包括曲线和背景。注意要在波形停止刷新时再做否则Canvas正在绘制同一张位图会抛异常。这个截图方案比直接保存传感器原始数组更直观也满足保存心电图到相册的需求。如果你还希望历史心电数据记录可回放可以在写入时把原始 ADC 值同步存进 SQLite再用同一个自定义 View 从数据库读取重绘界面层的绘制代码完全复用。本文还有配套的精品资源点击获取