简介:基于ESP32打造的智能手环完整工程,面向毕业设计、课程设计、工程实训及项目开发等场景,集成心率血氧监测、联网获取时间天气与B站粉丝数、闹钟提醒喝水吃药、秒表、计步等功能,适合单片机学习者从基础到进阶动手实践。资源包共16个文件,压缩包约23.52MB,涵盖C++源码(.cpp/.h)、工程配置(.json/.ini)、Fritzing接线图(.fzz)、PCB预览图、固件工具及说明文档,能够覆盖方案设计、硬件连接、程序烧录到结果验证的全流程。已有445人学习浏览,足见其参考价值。资源内包含完整源码、工程文件与使用说明,目录区分固件与软件模块;即使不会画PCB,也可按接线图用面包板加杜邦线搭建,烧录程序即可复现。项目经严格测试功能正常,可直接运行,也便于在此基础上扩展更多智能交互功能。
1. 用ESP32做智能手环,先把功耗和体积的矛盾想清楚
很多人把ESP32智能手环做成了“带屏幕的计时器”:功能列了一整页,真正戴在手上的体验却不行。问题出在一开始就把方案定成了ESP32-WROOM-32开发板 + 18650电池 + 1.3寸大屏,这套组合是开发板挂手上,不是手环。按课设、毕设、电赛和实训能交卷、也经得起答辩追问的路线,主控应选ESP32-C3或S3,IMU管姿态与计步,MAX30102管心率血氧,0.91寸OLED做显示,再用深度睡眠把待机电流压到µA级。
手环的技术难点不在“功能多”,而在“数据准不准、电够不够用、体积戴不戴得住”。以下方案按可复现的工程流程展开,从硬件选型、传感器采集、显示低功耗到BLE与OTA,每条都能直接落到板子上。
2. 硬件选型与电源树:ESP32手环的地基
手环和开发板最大的区别是硬件必须一次画对。开发板可以用杜邦线改,手环两厘米见方的主板改一次就要报废一版。一次画对依赖三件事:主控选型合理、电源树算得清、布局守纪律。
2.1 主控选型的三条硬约束
手环主控要同时回答三个问题:蓝牙版本能不能撑起低功耗连接,深度睡眠电流能不能进到10µA以内,模块封装和天线适不适合窄长主板。对照这三条,三款常用芯片的差异就很明显。
| 主控 | 核心 | SRAM | 蓝牙 | 深度睡眠电流 | 封装特点 |
|---|---|---|---|---|---|
| ESP32-WROOM-32 | LX6 双核 | 520KB | BLE 4.2 | 约10µA | 18×25.5mm,带PCB天线,偏胖 |
| ESP32-C3 | RISC-V 单核 | 400KB | BLE 5.0 | 约5µA | 13×16.2mm,适合手环主板 |
| ESP32-S3 | LX7 双核 | 512KB | BLE 5.0 | 约7µA | 算力强,但整机功耗偏高 |
我一般会选ESP32-C3。手环的显示、心率、姿态计算都吃不满160MHz主频,C3的160MHz RISC-V在息屏态降到40MHz跑传感器任务完全够用;BLE 5.0的Long Range和Coded PHY虽然手环用不上,但连接功耗比BLE 4.2低一截。S3的优势在本地语音唤醒或摄像头识别,这些功能塞进50g以下的手环不现实。
选完芯片看引脚。ESP32-C3的GPIO2、GPIO8、GPIO9是strapping引脚,上电瞬间的电平决定启动模式,不能接容易拉高拉低的器件。手环常用引脚就那么几个:I2C两条、OLED复位一根、马达一根、电池采样一根,避开这三个引脚后布局很宽松。查引脚图时重点看是否有RTC唤醒能力,C3的所有GPIO都能做ext0/ext1唤醒,比ESP32老型号灵活很多。
2.2 电源树:先算峰值电流再定器件
手环电池一般是3.7V聚合物锂电池,容量150mAh到200mAh。电源树从电池到负载只有两级:电池直接给马达和LED供电,电池再经LDO降到3.3V给主控、传感器和OLED。这套结构简单,但每个节点的压降都要算清楚。
| 电源轨 | 电压范围 | 主要负载 | 峰值电流 |
|---|---|---|---|
| VBAT | 3.0~4.2V | 振动马达、MAX30102 LED | 120mA |
| 3V3 | 3.3V | ESP32-C3、OLED、IMU | 60mA |
充电管理用TP4054或TP4056,注意TP4054最大充电电流设定电阻要按电池容量的0.5C来配,150mAh电池配到75mA充电电流比较安全。LDO别用AMS1117,静态电流和压差都太大,XC6206P332MR或RT9013这类低压差LDO才适合电池场景。
电池电压检测用两个100kΩ电阻分压后送ADC。ESP32-C3的ADC参考电压在出厂时有eFuse校准值,Arduino里直接调analogReadMilliVolts(),IDF里用adc_cali接口读校准系数,比手动按3.3V反推准得多。分压电阻带来约14µA的静态损耗,对150mAh电池来说可接受,但如果要做到5µA级深睡,电阻分压电路必须加MOS管开关,深睡时彻底断掉。
// 硬件引脚映射:ESP32-C3手环最小系统 #define PIN_I2C_SCL 5 // I2C时钟,避开strap引脚 #define PIN_I2C_SDA 4 // I2C数据 #define PIN_OLED_RST 3 // OLED复位 #define PIN_BATT_ADC 0 // 电池分压采样,ADC1_CH0 #define PIN_VIB_MOTOR 2 // 振动马达,低电平驱动 static const float BATT_DIVIDER = 2.0f; // 100k+100k分压,外部再并0.1uF电容滤波 static const float BATT_LOW = 3.5f; // 低于3.5V进低电量告警,留出0.2V压降余量引脚映射里最重要的一条是避开上电约束脚,GPIO2在这里只做马达输出,就算上电瞬间被拉低也不影响启动。电池采样放在GPIO0是因为它靠近ADC1通道,走线短,分压电阻放芯片附近采样更稳。BATT_LOW取3.5V不是按放电截止电压3.0V算的,而是考虑LDO压降和OLED在临界电压下的闪烁。
2.3 PCB布局:天线净空、IMU位置、OLED隔离
手环PCB布局有三个容易踩的坑。第一是天线的净空区,ESP32-C3的PCB天线正下方和周围一圈不能铺铜,不能走高频线,电池支架最好也别压在天线上方。第二是IMU位置,加速度计要放在主板靠近手腕中心的位置,戴在腕上时离心加速度误差最小。第三是MAX30102和OLED之间的隔离,OLED的电荷泵和驱动电流会干扰PPG模拟前端,两者之间加地隔离带,传感器底下铺完整地平面。
四层板成本不高,信号层、地层、电源层、底层四层结构对手环这种密度很合适。IMU的I2C走线加串联电阻抑制振铃,OLED电源引脚放0.1µF和10µF两只电容,马达两端并一个续流二极管,这些都是老生常谈但决定稳定性的细节。
3. 传感器采集:IMU姿态、心率与温度的落地实现
传感器的数据质量决定手环功能的可信度。这一章按IMU、MAX30102、任务调度三个层面讲,重点放在采样参数和滤波上,这两点是最直接影响数据结果的因素。
3.1 MPU6050六轴读取与互补滤波
MPU6050虽然老,但胜在资料全、寄存器透明,课设和竞赛用它最稳妥。用现成库开发很快,但手环要控采样时序,自己读寄存器更灵活。上电先复位,然后配置量程和数字低通滤波器。
#include <Wire.h> #define MPU6050_ADDR 0x68 static void imu_write_reg(uint8_t reg, uint8_t val) { Wire.beginTransmission(MPU6050_ADDR); Wire.write(reg); Wire.write(val); Wire.endTransmission(); } static void imu_read_xyz(int16_t *ax, int16_t *ay, int16_t *az, int16_t *gx, int16_t *gy, int16_t *gz) { Wire.beginTransmission(MPU6050_ADDR); Wire.write(0x3B); // ACCEL_XOUT_H Wire.endTransmission(false); // 不发停止位,连续读 Wire.requestFrom(MPU6050_ADDR, 14); if (Wire.available() < 14) return; *ax = (Wire.read() << 8) | Wire.read(); // 大端,先高字节 *ay = (Wire.read() << 8) | Wire.read(); *az = (Wire.read() << 8) | Wire.read(); Wire.read(); Wire.read(); // 跳过温度寄存器 *gx = (Wire.read() << 8) | Wire.read(); *gy = (Wire.read() << 8) | Wire.read(); *gz = (Wire.read() << 8) | Wire.read(); } void imu_init() { Wire.begin(4, 5); imu_write_reg(0x6B, 0x80); // 复位整个芯片 delay(100); imu_write_reg(0x6B, 0x01); // 退出睡眠,时钟源选PLL X轴陀螺 imu_write_reg(0x1A, 0x03); // DLPF带宽44Hz,滤掉高频振动 imu_write_reg(0x1B, 0x08); // 陀螺仪±500dps,灵敏度65.5 LSB/dps imu_write_reg(0x1C, 0x08); // 加速度±4g,灵敏度8192 LSB/g }说几个参数的含义。DLPF设0x03后加速度带宽44Hz、陀螺仪带宽42Hz,走路步频大概2Hz,手势翻转在5Hz以内,44Hz足够保留有效信号,又能滤掉电机和电网的50Hz干扰。加速度量程选±4g而不是±2g,是因为手环佩戴时走路产生的冲击接近3g,±2g容易饱和。
姿态角用互补滤波就够了,不需要上卡尔曼。互补滤波的优点是计算量小、参数直观,实时性反而比卡尔曼好。
// 互补滤波:陀螺积分负责短时平滑,加速度计负责长时校正 // 采样定时器每10ms调用一次update_attitude static float pitch = 0, roll = 0; static const float dt = 0.01f; // 10ms static const float alpha = 0.98f; // 陀螺仪权重 void update_attitude(float gx, float gy, float gz, float ax, float ay, float az) { pitch += gy * dt; // 陀螺积分的pitch增量 roll += gx * dt; float acc_pitch = atan2f(ax, az) * 57.2958f; // 转角度 float acc_roll = atan2f(ay, az) * 57.2958f; pitch = alpha * pitch + (1.0f - alpha) * acc_pitch; roll = alpha * roll + (1.0f - alpha) * acc_roll; }alpha取0.98的意思是陀螺积分占98%,加速度计校正占2%。这个值不是拍脑袋定的,它对应的时间常数为alpha*dt/(1-alpha)约0.5秒,意味着加速度计需要约半秒去修正陀螺漂移。手环姿态更新是短时的,0.5秒的校正时间足以覆盖10秒级的积分漂移。如果采样率改了,alpha要用时间常数公式重新算,直接抄会出问题。
3.2 MAX30102心率血氧:采样参数决定信号能不能用
MAX30102的原始信号噪声大,能不能算出稳定心率,关键在采样配置。手上戴紧一点可以减少运动伪差,但更重要的是把ADC量程和LED电流配到合适档位。寄存器0x09是SpO2配置字节,同时决定采样率和脉宽;0x0A和0x0C分别控制红光和红外LED电流。
// MAX30102关键参数配置 max30102_write_reg(0x09, 0x13); // 采样率100Hz,脉宽411us,ADC量程2048nA max30102_write_reg(0x0A, 0x24); // 红光LED电流约7.2mA max30102_write_reg(0x0C, 0x24); // 红外LED电流约7.2mA max30102_write_reg(0x08, 0x07); // FIFO:每个采样点包含红光+红外,写满进FIFO采样率100Hz对应每个心跳周期60到100个采样点,波谷检测精度足够。脉宽411µs是ADC对LED反射光积分的时间,脉宽越长信噪比越高,但功耗也越大。ADC量程2048nA是中间档,肤色浅的人信号会偏小,肤色深的人容易饱和,实操时先看原始波形再决定加减档。
心率计算的代码不复杂,但滤波才是核心。原始PPG信号里叠加了大量低频基线漂移和高频噪声,先做0.5到5Hz的带通滤波,再在滑动窗口里找波峰,相邻波峰间隔换算成BPM。
// 红外通道IR做心率计算,红光通道RED只用于血氧比计算 // 带通滤波后用简单的峰值计数法:窗口内找局部最大值,间隔小于0.35s则忽略 int bpm_from_ir_waveform(int32_t ir_samples[], int n) { int peaks = 0; for (int i = 1; i < n - 1; i++) { if (ir_samples[i] > ir_samples[i-1] && ir_samples[i] > ir_samples[i+1]) { peaks++; // 波峰计数,1分钟后的波峰数除以时间得到BPM i += 5; // 跳过一个不应期,避免抖动导致重复计数 } } return peaks * 60 / (n / 100); // n对应秒数,按100Hz采样率折算 }这个简化版在做课设时够用,但要留意两个坑。一是运动状态下PPG信号会被动作干扰,计步和心率同时出现时,心率结果必须和加速度计数据做相关性判断,运动强度超过阈值就显示“运动干扰”而不是硬算。二是LED电流不能一直开最大,每次测量前先跑一个自动增益校准,用信号峰值去反推LED电流,这是手环行业里的通用做法。
3.3 FreeRTOS任务划分:数据流别用全局变量串
手环的传感器任务、显示任务、BLE任务并行跑,用全局结构体共享数据容易出两类问题:任务切换时读到半更新状态,或者多个任务同时写一个变量导致数据错乱。底层FreeRTOS提供了队列和信号量,按任务职责划分数据流比裸机轮询干净得多。
| 任务 | 优先级 | 栈大小 | 周期/触发 | 职责 |
|---|---|---|---|---|
| SensorRead | 3 | 2048 | 10ms | 读IMU和MAX30102,打包入队 |
| Display | 2 | 3072 | 消息触发 | 刷新OLED页面 |
| BleServer | 1 | 4096 | 事件触发 | 维护连接,通知数据 |
| PowerMonitor | 4 | 1024 | 1s | 采样电池电压,切换工作状态 |
QueueHandle_t sensorQueue; typedef struct { int16_t ax, ay, az, gx, gy, gz; uint32_t ir, red; uint32_t timestamp; } SensorPacket; void sensorTask(void *arg) { SensorPacket pkt; for (;;) { imu_read_xyz(&pkt.ax, &pkt.ay, &pkt.az, &pkt.gx, &pkt.gy, &pkt.gz); max30102_read_fifo(&pkt.ir, &pkt.red); pkt.timestamp = millis(); xQueueOverwrite(sensorQueue, &pkt); // 覆盖旧包,只保留最新数据 vTaskDelay(pdMS_TO_TICKS(10)); } }xQueueOverwrite和xQueueSend的区别在于,用overwrite时队列永远只保留最新一包,显示任务读到的是当前时刻的数据,不会被旧的积压数据追赶。Display任务里用xQueuePeek读数据但不弹出,因为显示逻辑可能多次读取同一包来做局部刷新。
4. OLED显示与低功耗策略:手环续航的关键在状态机
手环待机功耗大头在OLED和蓝牙。OLED每个亮着的像素都是一个电流点,时钟常亮跑下来5到15mA,这个数字在150mAh电池面前吃不住。续航的思路不是把屏幕调暗,而是让系统在“显示”“待机”“深睡”之间切换。
4.1 0.91 OLED的驱动与刷新策略
0.91寸OLED 128×32,SSD1306驱动,I2C接口,正好是搜“esp32 oled 128*32”时最常见的型号。Adafruit SSD1306库初始化很简单,但要注意复位引脚处理,C3上复位脚不能悬空。
#include <Adafruit_SSD1306.h> #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 32 Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, PIN_OLED_RST); void oled_init() { if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { // 常见原因是I2C地址不是0x3C,而是0x3D // 或者OLED的RESET引脚没有接高电平 } display.clearDisplay(); }显示策略上最值得做的是局部刷新。SSD1306是按页寻址的,128×32分成4个8像素高的页,更新一个心率数字只需要改两个页里的几个字节。Adafruit库的display()会把整块缓冲推给屏幕,这反而比手动操作寄存器慢。
// 局部刷新:只清掉数字所在的区域,保留图标和边框 display.fillRect(30, 8, 48, 24, SSD1306_BLACK); // 清掉旧数字 display.setTextSize(2); display.setCursor(32, 8); display.print(bpm); // 打印新心率值 display.display();这个片段看起来平淡,但对手环很重要。全屏clearDisplay()再重画的耗时能到30ms以上,局部更新只需要5ms左右,每按一次按键少耗一些CPU时间,这些时间在低功耗状态下直接转化为续航。页面切换时用整页更新,同一个页面内的数字变化用局部更新,这算是我推荐的默认策略。
4.2 手环低功耗状态机:四个状态按需切换
手环不能只有“亮屏”和“熄屏”两个状态,要按外设工作状态拆成四档。从RUN到DEEP_SLEEP逐级降低功耗,每降一级都要付出唤醒时间的代价。
| 状态 | CPU与外设 | 典型电流 | 进入条件 | 退出方式 |
|---|---|---|---|---|
| RUN | OLED亮,传感器50Hz,Wi-Fi关 | 40~60mA | 用户操作 | 无操作5s |
| SENSE | OLED灭,IMU 5Hz,BLE保持 | 1.5~3mA | 自动灭屏 | 抬腕中断 |
| LIGHT_SLEEP | CPU暂停,SRAM保持,IMU运动唤醒 | 100~300µA | 静止30s | 加速度计中断 |
| DEEP_SLEEP | RTC保留,OTA标志保存 | 5~10µA | 夜间或长待机 | 外部RTC定时唤醒 |
#include "esp_pm.h" #include "esp_sleep.h" void enter_light_sleep() { esp_pm_config_esp32c3_t pm_cfg = { .max_freq_mhz = 80, // 运行时最高频率限到80MHz .min_freq_mhz = 40, // 空闲自动降频 .light_sleep_enable = true }; esp_pm_configure(&pm_cfg); esp_sleep_enable_ext0_wakeup(GPIO_NUM_2, 0); // IMU中断脚低电平唤醒 esp_light_sleep_start(); }进LIGHT_SLEEP前要把没必要的外设关掉,特别是UART和Wi-Fi。BLE如果开着,要让协议栈进Modem Sleep模式,Wi-Fi在这种场景下直接关闭。C3从LIGHT_SLEEP唤醒只需要几毫秒,唤醒后SRAM里的数据都在,传感器任务能无缝续跑,所以灭屏后的短暂静止用LIGHT_SLEEP很合适。
DEEP_SLEEP省的是那100多µA的差距,但代价是唤醒后要重新初始化外设、重新分配网络资源。手环一般在检测到夜里长时间不动后切进DEEP_SLEEP,用ESP32内部RTC定时器每天早上定时唤醒一次,或者保留一个GPIO让用户按键唤醒。
4.3 抬腕唤醒与防误触
抬腕唤醒不能靠陀螺仪数据的简单阈值。走路时手臂摆动幅度很大,抬手看表却是一个先抬后转的复合动作,光看加速度幅值容易误触发。我一般用MPU6050的运动中断做第一层唤醒,等系统从LIGHT_SLEEP醒来后,主控再算一次姿态变化是否符合抬腕特征。
// MPU6050运动中断配置,三个寄存器配合使用 imu_write_reg(0x1F, 10); // MOT_THR:运动阈值 imu_write_reg(0x20, 5); // MOT_DUR:持续5ms,抗抖动 imu_write_reg(0x38, 0x40); // INT_ENABLE:使能MOT中断唤醒后主控用3.1节的互补滤波姿态角做二次判断。仰角从负值变成接近0°,再配合一个横滚角变化,判定为“抬腕看表”,点亮OLED。这个两层判断把走路摆臂的误触发压到很低,也是竞赛答辩时能讲出亮点的设计点。
5. BLE通信、OTA升级与功耗实测
最后这部分是手环项目从“能跑”到“能交付”的分水岭。BLE服务设计、OTA分区和实测方法这三点,直接决定产品演示时是否可靠。
5.1 BLE GATT服务怎么设计才省电
手环和手机建立BLE连接后,连接间隔是功耗的第一关键参数。连接间隔30ms时功耗是200ms的好几倍,手环这种数据量很小的场景,200ms的间隔完全够用,一次通知发心率、步数、电量三个值,每包负载也就20字节。
#include <NimBLEDevice.h> NimBLEService *svc = NimBLEDevice::createService("180D"); NimBLECharacteristic *hrChr = svc->createCharacteristic("2A37", NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); NimBLECharacteristic *cmdChr = svc->createCharacteristic("FF01", NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR);心率用标准0x2A37特征,手机端App不用专门适配就能识别。自定义指令走FF01,用Write而不是Write No Response的话,手机能确认手环收到的指令,适合OTA触发这类关键操作。BLE芯片上NimBLE库比传统bluedroid库省约100KB内存和一部分连接功耗,手环这种资源紧张场景更合适。
5.2 双分区OTA:现场改代码不用拆壳
OTA对手环不是锦上添花,是刚需。手环装进壳子后没有USB口,固件出问题只能拆壳烧录。用双OTA分区方案,从手机下发固件包,升级失败还能回滚到上一个版本。
# 4MB Flash 双OTA分区表 nvs, data, nvs, 0x9000, 0x6000, otadata, data, ota, 0xf000, 0x2000, app0, app, ota_0, 0x11000, 0x1C0000, app1, app, ota_1, 0x1d1000,0x1C0000, coredump, data, coredump,0x391000,0x10000, spiffs, data, spiffs, 0x3A1000,0x5F000,const esp_partition_t *ota_part = esp_ota_get_next_update_partition(NULL); esp_ota_handle_t ota_handle; esp_ota_begin(ota_part, OTA_SIZE_UNKNOWN, &ota_handle); // 每收到1KB就对esp_ota_write写入,写完后校验整个镜像 esp_ota_write(ota_handle, buf, len); esp_ota_end(ota_handle); esp_ota_set_boot_partition(ota_part); esp_restart();C3的4MB Flash跑一对1.75MB的app分区绰绰有余,手环固件编译出来通常不到1MB。OTA写flash时要关中断,否则BLE数据会丢包,写之前告诉手机端暂停发送,写完一个块再恢复。这个细节在实际传输大固件时非常影响成功率。
5.3 用10Ω采样电阻实测各状态电流
续航不能只靠芯片手册上的数字,手册里的低功耗值是裸芯片状态,外设全挂上去后要重测。手边没有专业功耗分析仪时,用10Ω采样电阻加示波器就能看到各状态的真实电流。
测法不复杂:采样电阻串在电池正极,示波器探头跨在电阻两端测电压,1mV对应0.1mA。万用表串联测平均电流,示波器看瞬时波形,两者互相印证。我自己的手环实测参考值:RUN亮屏40mA,SENSE待机2mA,LIGHT_SLEEP 180µA,DEEP_SLEEP 7µA。不同品牌模块之间差20%是正常的,拿到板子先测状态切换的毛刺。
续航估算按一天的使用场景算:亮屏累计30分钟,SENSE态16小时,LIGHT_SLEEP 7小时,DEEP_SLEEP 30分钟。150mAh电池的理论续航约29小时,再乘0.85的放电效率系数,实际在24小时左右。如果这个数值达不到预期,优先看两个地方:MAX30102的LED电流是不是一直在最大档,以及BLE连接间隔是不是被手机端拉到了非常短。把功耗曲线放稳之后,再回头微调LED电流到信号刚够用的档位,每降一档电流通常能换来10%以上的续航提升。
本文还有配套的精品资源,点击获取