简介:面向正在学习STM32与嵌入式开发的读者,这份资源完整呈现可穿戴手环的系统级设计方案。项目以ARM Cortex-M内核为控制核心,引入UCOS实时操作系统进行多任务调度,涵盖温湿度采集(DHT/SHT系列)、心率监测(PPG光电容积描记法)以及MPU6050六轴传感器的姿态识别与计步算法,贯穿传感器驱动、数据处理、低功耗管理等嵌入式开发关键环节,可直接作为单片机、RTOS或可穿戴方向的学习参照。
压缩包为RAR格式,约16.41MB,共435个文件,以95个.h头文件和91个.c源码为主体,并含汇编启动文件与Keil工程配置,可直接对照系统初始化、外设驱动与任务调度逻辑;工程按USER与HARDWARE目录分离,便于分模块定位应用代码和底层驱动。
已有3756人学习下载。借助该资源可掌握环境监测、健康追踪与运动数据记录的实现思路,并延伸至低功耗电路设计与布局训练,是一份覆盖源码到工程配置的完整嵌入式实战资料。
1. 基于STM32的可穿戴手环设计:先想清楚它到底难在哪
一块基于STM32的可穿戴手环,九成开发时间不是在写计步算法,而是在跟微安级别的电流较劲。我见过太多团队把功能跑通就庆祝,结果样机戴在手腕上撑不过一天,最后不得不返工改硬件。这个标题看起来是「单片机+传感器」的老套路,但真正决定成败的,是主控选型、电源策略、传感器数据链和蓝牙协议这几块的组合设计。
这篇文章面向的是准备从零做手环、或者已经跑通Demo但续航不达标的开发者。我会把硬件选型、低功耗配置、固件状态机、数据同步和调试排错串成一条完整链路,每步给出能直接抄的参数和代码。读完你至少知道:用哪颗芯片、传感器怎么接、代码怎么组织、电流怎么测、坑在哪里。
2. 硬件底子:主控、传感器与总线的选型逻辑
2.1 主控选型:为什么是 STM32L4 而不是 F1 系列
可穿戴手环对主控的要求不是算力,而是「能睡」和「睡得快」。F1 系列跑起来功耗轻松上 mA 级别,而且没有多级睡眠模式,做低功耗只能靠关外设硬扛,效果很差。我一般会选 STM32L4 系列,它有几个专门为电池设备准备的能力:多种低功耗模式,STOP2 模式电流能压到 1uA 左右;内部有 16 位 ADC 和比较器,不用外挂运放就能读心率模拟信号;168MHz 主频虽然不算高,但跑基础的滤波和特征提取足够。
选型还有一个容易被忽略的点:封装和引脚数。手环 PCB 通常很小,QFN48 或 BGA 封装是常态,画 PCB 的时候要预留测试点。我踩过一次坑——选了 64 引脚的 LQFP,板子面积被迫做大一圈,最后结构装不进壳子,只能改板。所以选型时先把结构尺寸拿到手,再定封装。
2.2 传感器选型与 I2C 总线设计
手环核心传感器通常有三个:加速度计(计步、睡眠监测)、光学心率传感器(心率、血氧)、以及可选的气压计或温度传感器。加速度计我常用 LIS2DH12 或 BMI160,前者是 12C/SPI 接口,后者带内置计步硬件,能省主控算力。心率传感器一般是 Max30102 这类反射式光学模块,通过 I2C 输出。
I2C 总线设计上要特别注意:多个传感器挂同一条总线,地址冲突是常见问题。比如两个传感器默认地址都是 0x68,那就得看芯片是否支持地址引脚拉高拉低来改地址。下面是一段 LIS2DH12 的初始化代码,注意读 WHO_AM_I 确认设备在线,再配置量程和采样率:
uint8_t lis2dh12_init(void) { uint8_t id = 0; i2c_read_reg(LIS2DH12_ADDR, 0x0F, &id, 1); // 0x0F 是 WHO_AM_I 寄存器 if (id != 0x33) return 0; // 校验设备ID,防止通信错线或地址错 uint8_t ctrl1 = 0x57; // 100Hz 采样率,X/Y/Z 轴全部使能 i2c_write_reg(LIS2DH12_ADDR, 0x20, &ctrl1, 1); ctrl1 = 0x00; // 关闭自检和高分辨率模式,降低功耗 i2c_write_reg(LIS2DH12_ADDR, 0x23, &ctrl1, 1); return 1; }这段代码里,采样率放在 0x20 寄存器的低四位,0x57 对应 ODR=100Hz。对于手环计步场景,100Hz 是性价比很高的档位——再高浪费电流,再低会丢失快速走动时的峰值。I2C 读写函数要带超时退出,我一般设 10ms 超时,避免传感器没上电时主控卡死在总线等待上。
2.3 显示、电池与结构件取舍
屏幕是手环功耗的大头。OLED 功耗低但强光下看不清,LCD 需要背光。我实际测试过,0.96 寸 OLED 全亮时电流 15-20mA,而 STM32L4 跑 100Hz 采样加滤波总共才 5mA。所以屏幕刷新策略比选型更关键:只在抬腕亮屏时刷新,平时完全关闭。
电池用 100-200mAh 的软包锂电池比较常见。充电芯片用支持 4.2V 截止的线性充电方案,比如 TP4054。这里有个经验值:电池容量除以整机平均电流,就是理论续航。比如 150mAh 电池,平均电流 1.5mA,理论续航 100 小时,约 4 天,这是用户能接受的下限。
3. 低功耗设计:把电流从毫安压到微安的三板斧
3.1 三种睡眠模式怎么选:SLEEP、STOP2 还是 STANDBY
STM32L4 的睡眠模式可以按「唤醒延迟」和「保留资源」两个维度来分。SLEEP 模式最浅,CPU 停了但时钟和 SRAM 都在,唤醒延迟只有几百纳秒,适合被高频中断唤醒的场景。STOP2 模式是整个芯片基本静止,除了 RTC 和一小块 RAM,其余全部断电,唤醒延迟几十微秒,适合 RTC 定时唤醒的场景。STANDBY 模式最彻底,只有备份域还活着,唤醒后等于复位,代码要从头跑。
手环这种设备,常态应该是 STOP2:加速度计通过外部中断唤醒 MCU,MCU 醒来读取数据后立刻再睡。不要用 SLEEP 模式,因为它只停了 CPU,外设时钟还在跑,电流降不到 10uA 以下。STANDBY 也不适合做常态,因为每次唤醒都是一次冷启动,重新初始化外设的时间足够让实时数据丢好几帧。
3.2 把空闲电流压到 10uA 以下:GPIO 与时钟配置
进入低功耗模式之前,GPIO 的状态必须逐一检查。浮空输入引脚会漏电,输出高电平的引脚会通过外部电路倒灌电流。我的习惯是:所有不用的 GPIO 配置为模拟输入,用到的传感器电源引脚在休眠前关闭。下面这段进入 STOP2 的代码是标准写法:
void enter_stop2_with_rtc_wakeup(void) { // 1. 关闭传感器电源,减少漏电路径 HAL_GPIO_WritePin(SENSOR_PWR_PORT, SENSOR_PWR_PIN, GPIO_PIN_RESET); // 2. 把所有未使用的引脚设为模拟输入,避免浮空漏电 for (int i = 0; i < 16; i++) { GPIO_InitStruct.Pin = (1 << i); GPIO_InitStruct.Mode = GPIO_MODE_ANALOG; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); } // 3. 配置 RTC 唤醒为 500ms 周期 HAL_RTCEx_SetWakeUpTimer_IT(&hrtc, 500, RTC_WAKEUPCLOCK_RTCCLK_DIV16); // 4. 进入 STOP2 模式 HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI); // 5. 唤醒后重新配置时钟和引脚 SystemClock_Config(); sensor_power_on(); }这段代码的关键在于第 2 步。STM32L4 的引脚在复位后默认是浮空输入,如果你在代码里初始化过它们,那进入 STOP2 之前就必须显式改回模拟输入,否则浮空引脚可能产生周期性电流,整机功耗直接翻倍。第 3 步的 RTC 唤醒周期 500ms 是手环的通用心跳周期,用于定时清看门狗或者记录时间戳。唤醒后第一步重新配置时钟是因为 STOP2 退出后系统时钟会回到 MSI,不复位的话外设波特率全乱。
3.3 动态调频:忙时跑满,闲时降频
低功耗不只是睡眠模式的事,运行时的功耗同样要管。STM32L4 支持对系统时钟做动态切换,在传感器数据采样和算法计算时跑最高频率,在数据发送间隙降下来。这里有个思路:把主频从 80MHz 降到 16MHz,运行电流大约能降一半,但完成同样计算任务的时间会拉长。关键是计算「等效电流」——电流乘以时间是电量消耗,跑得快但瞬间电流高,和跑得慢但时间长,最后耗电一样。
我一般用 HAL_RCC_ClockConfig 在两种频率间切换,频率切换要花几十微秒,对实时性几乎没有影响。另外,不要忽略 Flash 等待周期:主频超过 32MHz 时需要 2 个等待周期,超频到 80MHz 时 4 个等待周期,代码执行效率和功耗并不完全线性。
4. 固件架构:状态机驱动的手环软件骨架
4.1 状态机:手环的本质是一个事件驱动系统
手环的软件如果用裸循环「主循环扫一遍所有模块」来写,很快会陷入混乱:按键处理要响、心率数据要采、蓝牙要发包、屏幕要刷新,互相抢占 CPU 时间。我习惯用一个简单的事件状态机,把整个系统切成几个状态:睡眠、计步、心率测量、蓝牙传输、充电。
typedef enum { STATE_SLEEP, STATE_STEP_COUNTING, STATE_HR_MEASURE, STATE_BT_TRANSMIT, STATE_CHARGING } sys_state_t; sys_state_t current_state = STATE_SLEEP; void state_machine_run(void) { switch (current_state) { case STATE_SLEEP: if (wakeup_source == WAKEUP_ACCEL_INT) current_state = STATE_STEP_COUNTING; break; case STATE_STEP_COUNTING: if (accel_idle_time > 5s) current_state = STATE_SLEEP; break; case STATE_HR_MEASURE: if (hr_measure_done) current_state = STATE_BT_TRANSMIT; break; default: break; } }这个状态机的核心思想是:每个状态只做一件事,状态的跳转只由事件触发。比如传感器中断是「被走路唤醒」的事件,蓝牙数据包接收是「手机要求同步」的事件。注意代码里的accel_idle_time是累计时间,超过 5 秒没检测到有效步伐就退回睡眠,这个参数要可调——有的人坐着甩手也会触发加速度中断,阈值太紧会频繁误唤醒。
4.2 传感器数据流:采样、滤波与环形缓冲
加速度计的原始数据直接用于计步会非常毛糙,走路时的波形叠加了手臂摆动和身体晃动。我常用的滤波方式是滑动平均够用的场景就别上卡尔曼,计算量小、调试容易。环形缓冲区用来暂存数据,避免在中断里做耗时的滤波运算:
#define BUF_SIZE 64 int16_t accel_buf[BUF_SIZE][3]; // X, Y, Z 轴数据 uint8_t buf_head = 0, buf_tail = 0; void accel_isr_callback(void) { int16_t x, y, z; lis2dh12_read_accel(&x, &y, &z); accel_buf[buf_head][0] = x; accel_buf[buf_head][1] = y; accel_buf[buf_head][2] = z; buf_head = (buf_head + 1) % BUF_SIZE; if (buf_head == buf_tail) buf_tail = (buf_tail + 1) % BUF_SIZE; // 覆盖旧数据 }这段代码里环形缓冲用取模运算实现,buf_head 指向下一个写入位置,buf_tail 指向最旧的数据。中断里只做写入,主循环里取数据做滤波,保证中断处理时间足够短。如果传感器用 SPI 接口,读取三个轴的数据一次要传输 6 个字节,在 1MHz 时钟下约 48us,放在中断里完全可行。注意:如果用了 DMA 配合采样,环形缓冲的读写指针要加 volatile 修饰,否则编译器优化可能导致数据不同步。
4.3 计步与心率:算法放本地还是放手机
这两种方案我都试过。算法放本地,优点是低功耗,因为手机蓝牙不常开启,缺点是 MCU 资源紧张。算法放手机,手环只负责传原始数据,优点是可随时升级算法,缺点是蓝牙长时间连接导致功耗暴涨。我最终倾向于折中:计步放在本地,因为它需要实时性,且步幅判断逻辑简单;心率和血氧的特征提取运算量太大,建议把原始波形通过蓝牙传到手机端分析。
本地计步的经典方法是检测加速度幅值的过零点和峰值,通过步频阈值判断是否在走路。这个逻辑不要做得太复杂,先判断幅值超过阈值(比如 1.2g),再判断两次峰值间隔在 200-800ms 之间,超过这个范围就认为是非步行动作。代码里用环形缓冲里的数据做差分,找到局部最大值即可。不要把算法做「玄学」,数据可视化才是调试计步算法的捷径——我在开发板上通过蓝牙打印波形到手机,一眼就能看出阈值设得是否合理。
5. 低功耗与通信避坑:五个让我返工的现场问题
5.1 I2C 总线死锁:传感器把 SDA 线拉死后整机卡死
现象:手环跑一段时间后突然死机,复位后恢复正常,但运行几小时又复现。
原因:I2C 设备在通信中途掉电或总线时序被打断,从机可能处于半传输状态,一直占用 SDA 线不放。手环里传感器电源由 GPIO 控制,如果软件在通信过程中关闭传感器电源,总线就挂了。
解决:在每次 I2C 通信前检查总线状态,如果 SDA 被拉低超过一定时间,就做一次总线恢复——切换 GPIO 模式手动产生 9 个时钟脉冲,释放总线。代码上,我给 I2C 读写函数加了超时和总线恢复逻辑:
void i2c_bus_recover(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = I2C_SCL_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(I2C_SCL_PORT, &GPIO_InitStruct); for (int i = 0; i < 9; i++) { HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); delay_us(5); HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET); delay_us(5); } // 重新初始化 I2C 外设 }这个坑的源头是我把传感器电源控制放在 GPIO 中断里,中断优先级设置不当导致 I2C 通信被打断。血泪经验是:I2C 通信期间绝不关外设电源,传感器的电源控制只能放在状态机里,不能在中断服务函数里做。
5.2 ADC 参考电压漂移导致电量显示不准
现象:电量从 80% 直接跳到 30%,然后又跳回 70%。
原因:我早期用 STM32L4 的内部参考电压做 ADC 基准,但手环电池电压随负载变化波动很大,而内部参考电压也会随温度漂移,两个变量叠加导致采样值完全不可信。
解决:改成外部参考电压方案,用一颗低漂移的基准源芯片,或者直接把 ADC 采样设置为「相对测量」——同时采样电池电压和一个已知阻值的分压电阻电压,通过比值计算真实电压。代码上要校准分压系数:
uint32_t read_battery_mv(void) { uint32_t adc_raw = 0; HAL_ADC_Start(&hadc); HAL_ADC_PollForConversion(&hadc, 100); adc_raw = HAL_ADC_GetValue(&hadc); HAL_ADC_Stop(&hadc); return (adc_raw * 3300 * 2) / 4095; // 2倍分压系数,3.3V参考 }注意这段代码的返回结果只做参考,实际生产还要加电池内阻补偿和温度补偿。我后来花了整整两天做了一组不同温度下的电压-电量标定表,才把误差控制在 5% 以内。
5.3 蓝牙天线靠近电池盖后连接掉线
现象:手环戴在手上一切正常,但放进金属外壳的充电底座里,手机 APP 就显示设备断开。
原因:蓝牙天线被金属底座屏蔽,信号强度急剧下降。PCB 天线需要净空区,下面不能铺铜,周围金属件要远离至少 5mm。
解决:改板时把天线区域移到 PCB 边缘,远离电池和充电触点,同时要求结构件在对应位置开槽或使用塑胶材质。如果结构不允许改,只能退而求其次用外部陶瓷天线,用同轴线引出到壳体上。
5.4 Flash 擦写寿命与参数存储:频繁写参数导致掉固件
现象:手环用了一个月后,部分设备出现设置丢失,甚至无法开机。
原因:参数存储直接写在主 Flash 里,每次蓝牙同步都擦写一次,Flash 寿命通常是 10 万次擦写,按每天同步 100 次计算,1000 天就到寿命了。更致命的是擦写过程中掉电,可能破坏整个扇区的代码。
解决:用 STM32L4 内部自带的 Flash 仿真 EEPROM 机制,或者外挂一颗 I2C EEPROM。我的做法是参数区用两页交替写入,每写入一次切换索引,掉电时恢复上一页有效数据。
5.5 低功耗模式下调试器断连
现象:刚进入 STOP2 模式,MDK/IAR 的调试器就报「Cannot access target」,然后只能按住复位键才能重新连接。
原因:STOP2 模式下内核时钟停止,调试接口也失效。这是正常现象,不是 bug。
解决:调试低功耗代码时,在进入 STOP2 前设置一个断点,在唤醒后第一步设置另一个断点,观察唤醒流程是否正常。真正要测电流时,把调试器拔掉、通过电池供电测量。调试状态下电流会多出 1-2mA,这是仿真器的复位引脚和 SWD 时钟带来的额外消耗,测电流时不能用调试器,这是所有人都会踩的坑。
6. 数据同步与收尾技巧:蓝牙协议设计里最容易漏掉的事
6.1 蓝牙协议里给数据加版本号
手环和手机 APP 的通信协议,我建议在数据包头里加一个版本号字段。很多人会用固定协议,比如帧头 + 长度 + 类型 + 数据 + 校验。一旦后续 APP 增加新功能,比如新增血氧监测,协议格式变了,老手环和新 APP 之间就会出现互相不识别的情况。加上版本号,APP 可以先判断版本号再决定怎么解析后续字节,老设备降级处理,新设备走新逻辑,这就是向后兼容的代价。
我习惯用两字节版本号:主版本 + 副版本。结构如下:
typedef struct { uint8_t header; // 0xAA uint8_t version_major; uint8_t version_minor; uint8_t data_type; // 0x01=步数, 0x02=心率 uint16_t data_len; uint8_t *data; uint8_t checksum; } ble_packet_t;如果你是从零开始写手环,这个字段现在就必须加,不然后期 APP 上线后就再也加不上了。
6.2 验证低功耗的一招:测平均电流而不是峰值电流
手环功耗问题,我用最笨也最可靠的方法验证:充满电,戴在手上正常使用 24 小时,记录电量百分比变化。更精细一点,用电流探头测一个完整周期的平均电流:采集 100ms + 处理 50ms + 蓝牙发送 10ms + 睡眠 840ms,计算平均电流要按整个周期算,不能只看睡眠模式下的怠速电流。
我的习惯是固定几个标准测试场景:息屏待机、连续计步、每小时蓝牙同步 10 分钟、抬腕亮屏 100 次。每个场景单独记录数据,最后用加权平均来估算续航。不要直接用万用表测平均电流——手环电流在 1uA 和 20mA 之间跳变,普通万用表响应不过来,测出来的数差得很远。
这块我当年翻过车:以为睡眠电流压到 2uA 就能续航一周,结果一测整机平均电流超过 3mA,原因竟然是你躺着翻身时加速度计中断反复唤醒 MCU,唤醒频率过高,低功耗模式根本没起到作用。从那以后我先把每个场景的唤醒次数统计出来,再优化算法,否则功耗分析就是黑匣子。
做手环这行,把待机电流降下来不算技术,把「怎么睡、什么时候醒、醒来做什么」设计清楚才叫技术,这套东西做完一遍,下一款产品就能少走很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取