ESP32-S3健康监测手环开发实战(三)
·
ESP32-S3健康监测手环开发实战(三):MAX30102心率血氧算法与PPG信号处理
本篇深入讲解基于MAX30102的心率血氧测量实现,包括PPG信号原理、中断驱动架构、过零检测心率算法、R值血氧公式以及稳定性检测机制。
1. MAX30102传感器原理

1.1 PPG光电容积脉搏波原理
MAX30102是一款集成了红光LED(660nm)和红外LED(880nm)的反射式光电传感器,通过光电容积描记法(PPG)测量心率和血氧。
工作原理:
┌─────────────────────────────────────┐
│ MAX30102 │
│ ┌────┐ ┌────┐ │
│ │红光│ ────► │红外│ │
│ │LED │ 皮肤 │LED │ │
│ └────┘ 反射 └────┘ │
│ ↓ ↓ │
│ ┌────────────────────────┐ │
│ │ 光电二极管 │ │
│ │ (接收反射光) │ │
│ └────────────────────────┘ │
└─────────────────────────────────────┘
心率测量原理:
- 心脏跳动时,血液流经毛细血管
- 血红蛋白吸收光线,导致反射光强度变化
- 光强变化周期 = 心跳周期
- 通过检测脉冲周期计算心率
血氧测量原理:
- 氧合血红蛋白(HbO2)和脱氧血红蛋白(Hb)对红光/红外光吸收率不同
- 红光:Hb吸收更多(脱氧时信号大)
- 红外光:HbO2吸收更多(氧合时信号大)
- 通过两种光的吸收比值计算血氧饱和度
1.2 双通道信号特性
| 通道 | 波长 | 特性 | 用途 |
|---|---|---|---|
| 红光 | 660nm | 脱氧血红蛋白吸收强 | 血氧计算 |
| 红外光 | 880nm | 信号稳定,脉搏明显 | 心率检测 |
1.3 传感器配置
// 硬件配置(独立I2C端口)
#define APP_MAX30102_I2C_PORT I2C_NUM_1 // 独立I2C端口
#define APP_MAX30102_I2C_SDA_PIN GPIO_NUM_5 // SDA
#define APP_MAX30102_I2C_SCL_PIN GPIO_NUM_15 // SCL
#define APP_MAX30102_I2C_FREQ_HZ 100000 // 100kHz
#define APP_MAX30102_INT_PIN GPIO_NUM_3 // 中断引脚
// 算法参数
#define SAMPLE_RATE 100 // 采样率 100Hz
#define BUFFER_SIZE 500 // 5秒缓冲(500样本)
#define FILTER_WINDOW_SIZE 5 // 移动平均窗口 50ms
2. GPIO中断驱动架构
2.1 为什么使用中断驱动?
MAX30102内置FIFO缓冲区,当有新数据时会拉低INT引脚产生中断。中断驱动方式的优势:
- 实时性高:数据就绪立即处理
- CPU占用低:不需要轮询
- 不丢数据:FIFO + 中断确保数据完整
2.2 中断处理架构
┌─────────────────────────────────────────────────────────────┐
│ 中断驱动架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ MAX30102 INT引脚 (GPIO3) │
│ │ │
│ │ 下降沿触发 │
│ ▼ │
│ ┌────────────────┐ │
│ │ gpio_isr_handler │ ISR (中断服务程序) │
│ │ (IRAM_ATTR) │ 在中断上下文执行,必须快速 │
│ └────────┬───────┘ │
│ │ │
│ │ xQueueSendFromISR │
│ ▼ │
│ ┌────────────────┐ │
│ │ 事件队列 │ 异步解耦 │
│ │ (10个元素) │ │
│ └────────┬───────┘ │
│ │ │
│ │ xQueueReceive (阻塞等待) │
│ ▼ │
│ ┌────────────────┐ │
│ │ gpio_intr_task │ FreeRTOS任务 │
│ │ (优先级10) │ 独立栈4KB(支持浮点运算) │
│ │ │ 执行:读FIFO → 滤波 → 算法 │
│ └────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
2.3 中断初始化代码
static esp_err_t gpio_intr_init(void)
{
// GPIO 配置
gpio_config_t io_conf = {
.intr_type = GPIO_INTR_NEGEDGE, // 下降沿触发
.mode = GPIO_MODE_INPUT,
.pin_bit_mask = (1ULL << APP_MAX30102_INT_PIN),
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.pull_up_en = GPIO_PULLUP_ENABLE, // 启用上拉
};
ESP_ERROR_CHECK(gpio_config(&io_conf));
// 创建事件队列(容量10个事件)
s_gpio_evt_queue = xQueueCreate(10, sizeof(uint32_t));
// 创建中断处理任务
xTaskCreate(
gpio_intr_task,
"max30102_intr",
4096, // 栈大小4KB(浮点运算需要)
NULL,
10, // 优先级10(较高)
NULL
);
// 安装GPIO ISR服务并注册处理函数
gpio_install_isr_service(0);
gpio_isr_handler_add(APP_MAX30102_INT_PIN, gpio_isr_handler,
(void *)APP_MAX30102_INT_PIN);
return ESP_OK;
}
2.4 ISR处理函数
// ISR必须加IRAM_ATTR放入IRAM,确保中断时可访问
static void IRAM_ATTR gpio_isr_handler(void *arg)
{
uint32_t gpio_num = (uint32_t)arg;
// 从ISR发送事件到队列(不阻塞)
xQueueSendFromISR(s_gpio_evt_queue, &gpio_num, NULL);
}
2.5 中断处理任务
static void gpio_intr_task(void *arg)
{
uint32_t io_num;
uint32_t red_raw, ir_raw;
bool finger_on;
for (;;) {
// 阻塞等待中断事件
if (xQueueReceive(s_gpio_evt_queue, &io_num, portMAX_DELAY)) {
// 读取FIFO数据
if (max30102_read_fifo(&red_raw, &ir_raw) != ESP_OK) {
max30102_clear_interrupt();
continue;
}
// 检测是否有手指(信号强度判断)
finger_on = (red_raw >= APP_MAX30102_FINGER_THRESHOLD);
// 更新原始数据
s_latest_data.red_raw = red_raw;
s_latest_data.ir_raw = ir_raw;
s_latest_data.finger_on = finger_on;
// 状态机处理
state_machine_update(finger_on, red_raw, ir_raw);
// 清除中断标志
max30102_clear_interrupt();
}
}
}
3. PPG信号预处理
3.1 环形缓冲区设计
// PPG 数据缓冲区结构
typedef struct {
uint32_t red_raw[BUFFER_SIZE]; // 原始红光数据
uint32_t ir_raw[BUFFER_SIZE]; // 原始红外数据
float red_filtered[BUFFER_SIZE]; // 滤波后红光
float ir_filtered[BUFFER_SIZE]; // 滤波后红外
uint16_t write_idx; // 写入索引
uint16_t count; // 有效数据数量
} ppg_buffer_t;
#define BUFFER_SIZE 500 // 5秒 × 100Hz = 500样本
环形缓冲区工作原理:
索引: 0 1 2 3 4 ... 497 498 499
┌───┬───┬───┬───┬───┬───┬───┬───┬───┐
数据: │ D │ D │ D │ D │ D │...│ D │ D │ D │
└───┴───┴───┴───┴───┴───┴───┴───┴───┘
↑
write_idx (循环递增)
当write_idx达到500时,回绕到0,覆盖最老的数据
3.2 移动平均滤波
// 向缓冲区添加数据并进行滤波
static void ppg_buffer_add(uint32_t red_raw, uint32_t ir_raw)
{
uint16_t idx = s_ppg_buffer.write_idx;
// 存储原始数据
s_ppg_buffer.red_raw[idx] = red_raw;
s_ppg_buffer.ir_raw[idx] = ir_raw;
// 移动平均滤波(5点窗口 = 50ms)
float red_sum = 0, ir_sum = 0;
uint8_t filter_count = 0;
for (int i = 0; i < FILTER_WINDOW_SIZE; i++) {
int buf_idx = (idx - i + BUFFER_SIZE) % BUFFER_SIZE;
if (s_ppg_buffer.count > (uint16_t)i) {
red_sum += s_ppg_buffer.red_raw[buf_idx];
ir_sum += s_ppg_buffer.ir_raw[buf_idx];
filter_count++;
}
}
// 计算平均值作为滤波后的值
if (filter_count > 0) {
s_ppg_buffer.red_filtered[idx] = red_sum / filter_count;
s_ppg_buffer.ir_filtered[idx] = ir_sum / filter_count;
}
// 更新索引(环形)
s_ppg_buffer.write_idx = (idx + 1) % BUFFER_SIZE;
if (s_ppg_buffer.count < BUFFER_SIZE) {
s_ppg_buffer.count++;
}
}
移动平均滤波效果:
- 窗口大小:5样本 = 50ms(100Hz采样率)
- 作用:平滑高频噪声,保留心率信号(0.5-3Hz)
- 延迟:25ms(窗口大小的一半)
4. 心率计算算法
4.1 过零检测原理
心率计算的核心是检测PPG信号的周期。本项目采用过零检测法:
信号值
▲
│ ╱╲ ╱╲ ╱╲
│ ╱ ╲ ╱ ╲ ╱ ╲
DC─┼──╱────╲──╱────╲──╱────╲── ←── DC水平线
│ ╱ ╲╱ ╲╱ ╲
│ ↑ ↑ ↑
│ 过零点 过零点 过零点
│ (上升) (上升) (上升)
└─────────────────────────────► 时间
│←─间隔1─→│←─间隔2─→│
心率 = 60 × 采样率 / 平均间隔
4.2 算法实现
static uint8_t calculate_heart_rate(void)
{
if (s_ppg_buffer.count < MIN_SAMPLES_FOR_CALC) {
return 0; // 数据不足3秒,无法计算
}
uint16_t analyze_count = (s_ppg_buffer.count < BUFFER_SIZE) ?
s_ppg_buffer.count : BUFFER_SIZE;
// 1. 计算IR信号的DC分量(平均值)
float dc_sum = 0;
for (uint16_t i = 0; i < analyze_count; i++) {
uint16_t buf_idx = (s_ppg_buffer.write_idx - 1 - i + BUFFER_SIZE) % BUFFER_SIZE;
dc_sum += s_ppg_buffer.ir_filtered[buf_idx];
}
float dc_level = dc_sum / analyze_count;
// 2. 检测过零点(从下到上穿越DC水平)
uint16_t peak_intervals[32];
uint8_t interval_count = 0;
uint16_t last_crossing_idx = 0;
bool below_dc = true;
for (uint16_t i = analyze_count - 1; i > 0; i--) {
uint16_t buf_idx = (s_ppg_buffer.write_idx - 1 - i + BUFFER_SIZE) % BUFFER_SIZE;
float value = s_ppg_buffer.ir_filtered[buf_idx];
if (below_dc && value > dc_level) {
// 检测到上升过零点
if (last_crossing_idx > 0) {
uint16_t interval = (analyze_count - 1 - i) - last_crossing_idx;
// 间隔有效性检查(40-200 BPM范围)
if (interval >= MIN_PEAK_INTERVAL && // 30样本 = 200BPM
interval <= MAX_PEAK_INTERVAL) { // 150样本 = 40BPM
if (interval_count < 32) {
peak_intervals[interval_count++] = interval;
}
}
}
last_crossing_idx = analyze_count - 1 - i;
below_dc = false;
} else if (!below_dc && value < dc_level) {
below_dc = true; // 进入DC以下区域
}
}
if (interval_count < 2) {
return 0; // 峰值太少,无法计算
}
// 3. 计算平均峰值间隔
uint32_t interval_sum = 0;
for (uint8_t i = 0; i < interval_count; i++) {
interval_sum += peak_intervals[i];
}
float avg_interval = (float)interval_sum / interval_count;
// 4. 计算心率:HR = 采样率 × 60 / 平均间隔
float hr = (SAMPLE_RATE * 60.0f) / avg_interval;
// 5. 范围限制
if (hr < HR_MIN || hr > HR_MAX) {
return 0;
}
return (uint8_t)(hr + 0.5f); // 四舍五入
}
4.3 心率计算参数
#define HR_MIN 40 // 最小有效心率
#define HR_MAX 200 // 最大有效心率
#define MIN_PEAK_INTERVAL 30 // 最小间隔 = 100Hz × 60 / 200BPM
#define MAX_PEAK_INTERVAL 150 // 最大间隔 = 100Hz × 60 / 40BPM
5. 血氧饱和度计算
5.1 R值公式原理
血氧计算基于红光和红外光的吸收比值(R值):
R = (AC_red / DC_red) / (AC_ir / DC_ir)
各分量含义:
- DC_red:红光信号的平均值(直流分量)
- AC_red:红光信号的交流分量(脉动部分)
- DC_ir:红外信号的平均值
- AC_ir:红外信号的交流分量
R值与血氧的关系:
- R ≈ 0.4 时,SpO2 ≈ 100%
- R ≈ 1.0 时,SpO2 ≈ 82%
- R ≈ 2.0 时,SpO2 ≈ 50%
5.2 SpO2经验公式
// SpO2 = 104 - 17×R - 11×R² (参考 Maxim AN6409)
float spo2 = 104.0f - 17.0f * R - 11.0f * R * R;
这是一个经验公式,来自Maxim官方应用笔记AN6409,适用于大多数人群。
5.3 算法实现
static float calculate_spo2(void)
{
if (s_ppg_buffer.count < MIN_SAMPLES_FOR_CALC) {
return 0;
}
uint16_t analyze_count = (s_ppg_buffer.count < BUFFER_SIZE) ?
s_ppg_buffer.count : BUFFER_SIZE;
// 1. 计算DC分量(平均值)
float red_dc_sum = 0, ir_dc_sum = 0;
for (uint16_t i = 0; i < analyze_count; i++) {
uint16_t buf_idx = (s_ppg_buffer.write_idx - 1 - i + BUFFER_SIZE) % BUFFER_SIZE;
red_dc_sum += s_ppg_buffer.red_filtered[buf_idx];
ir_dc_sum += s_ppg_buffer.ir_filtered[buf_idx];
}
float red_dc = red_dc_sum / analyze_count;
float ir_dc = ir_dc_sum / analyze_count;
if (red_dc < 1.0f || ir_dc < 1.0f) {
return 0; // 防止除零
}
// 2. 计算AC分量(使用标准差近似)
float red_ac_sum = 0, ir_ac_sum = 0;
for (uint16_t i = 0; i < analyze_count; i++) {
uint16_t buf_idx = (s_ppg_buffer.write_idx - 1 - i + BUFFER_SIZE) % BUFFER_SIZE;
float red_diff = s_ppg_buffer.red_filtered[buf_idx] - red_dc;
float ir_diff = s_ppg_buffer.ir_filtered[buf_idx] - ir_dc;
red_ac_sum += red_diff * red_diff;
ir_ac_sum += ir_diff * ir_diff;
}
float red_ac = sqrtf(red_ac_sum / analyze_count); // RMS
float ir_ac = sqrtf(ir_ac_sum / analyze_count);
if (ir_ac < 0.001f) {
return 0; // AC分量太小,信号无效
}
// 3. 计算R值
float R = (red_ac / red_dc) / (ir_ac / ir_dc);
// 4. 计算SpO2
float spo2 = 104.0f - 17.0f * R - 11.0f * R * R;
// 5. 范围限制
if (spo2 < SPO2_MIN) spo2 = SPO2_MIN; // 70%
if (spo2 > SPO2_MAX) spo2 = SPO2_MAX; // 100%
return spo2;
}
6. 稳定性检测机制
6.1 为什么需要稳定性检测?
PPG信号容易受到干扰:
- 手指移动导致运动伪影
- 环境光干扰
- 接触压力变化
稳定性检测确保输出数据可靠。
6.2 方差判断法
// 稳定性检测历史
typedef struct {
uint8_t hr_history[STABILITY_HISTORY_SIZE]; // 5个心率历史值
float spo2_history[STABILITY_HISTORY_SIZE]; // 5个血氧历史值
uint8_t history_idx;
uint8_t history_count;
} stability_tracker_t;
#define STABILITY_HISTORY_SIZE 5 // 历史记录数量
#define STABILITY_HR_THRESHOLD 10.0f // 心率方差阈值
#define STABILITY_SPO2_THRESHOLD 2.0f // 血氧方差阈值
6.3 稳定性检测实现
static bool stability_check(uint8_t hr, float spo2)
{
// 添加到历史记录
s_stability.hr_history[s_stability.history_idx] = hr;
s_stability.spo2_history[s_stability.history_idx] = spo2;
s_stability.history_idx = (s_stability.history_idx + 1) % STABILITY_HISTORY_SIZE;
if (s_stability.history_count < STABILITY_HISTORY_SIZE) {
s_stability.history_count++;
}
// 需要至少5个样本才能判断
if (s_stability.history_count < STABILITY_HISTORY_SIZE) {
return false;
}
// 计算方差
float hr_sum = 0, hr_sq_sum = 0;
float spo2_sum = 0, spo2_sq_sum = 0;
for (uint8_t i = 0; i < STABILITY_HISTORY_SIZE; i++) {
float hr_val = (float)s_stability.hr_history[i];
float spo2_val = s_stability.spo2_history[i];
hr_sum += hr_val;
hr_sq_sum += hr_val * hr_val;
spo2_sum += spo2_val;
spo2_sq_sum += spo2_val * spo2_val;
}
// 方差 = E[X²] - E[X]²
float hr_mean = hr_sum / STABILITY_HISTORY_SIZE;
float hr_var = (hr_sq_sum / STABILITY_HISTORY_SIZE) - (hr_mean * hr_mean);
float spo2_mean = spo2_sum / STABILITY_HISTORY_SIZE;
float spo2_var = (spo2_sq_sum / STABILITY_HISTORY_SIZE) - (spo2_mean * spo2_mean);
// 检查是否稳定
return (hr_var < STABILITY_HR_THRESHOLD && // 心率方差 < 10
spo2_var < STABILITY_SPO2_THRESHOLD); // 血氧方差 < 2
}
7. 状态机设计
7.1 状态定义
typedef enum {
MAX30102_STATE_IDLE, // 空闲:等待手指
MAX30102_STATE_DETECTING, // 检测中:采集数据等待稳定
MAX30102_STATE_STABLE, // 稳定:输出有效数据
} max30102_state_t;
7.2 状态转换图
┌────────────────────────────────┐
│ │
│ 手指移开 │
▼ │
┌──────────┐ │
│ IDLE │◄──────────────────┐ │
│ (空闲) │ │ │
└────┬─────┘ │ │
│ │ │
检测到手指 │ │
│ │ │
▼ │ │
┌─────────────┐ 手指移开 │
│ DETECTING │─────────────────┘ │
│ (检测中) │ │
└──────┬──────┘ │
│ │
数据稳定 │
│ │
▼ │
┌──────────┐ │
│ STABLE │──────────────────────────┘
│ (稳定) │ 数据不稳定时回到DETECTING
└──────────┘
7.3 状态机实现
static void state_machine_update(bool finger_on, uint32_t red_raw, uint32_t ir_raw)
{
uint32_t current_time = get_time_ms();
switch (s_state) {
case MAX30102_STATE_IDLE:
// 空闲状态:等待手指放上
if (finger_on) {
state_transition(MAX30102_STATE_DETECTING);
}
break;
case MAX30102_STATE_DETECTING:
// 检测中:采集数据并判断稳定性
if (!finger_on) {
state_transition(MAX30102_STATE_IDLE);
break;
}
// 添加数据到缓冲区
ppg_buffer_add(red_raw, ir_raw);
// 尝试计算心率血氧
if (s_ppg_buffer.count >= MIN_SAMPLES_FOR_CALC) {
uint8_t hr = calculate_heart_rate();
float spo2 = calculate_spo2();
if (hr > 0 && spo2 > 0) {
// 检查稳定性
if (stability_check(hr, spo2)) {
stability_get_average(&s_latest_data.heart_rate,
&s_latest_data.spo2);
state_transition(MAX30102_STATE_STABLE);
}
}
}
break;
case MAX30102_STATE_STABLE:
// 稳定状态:定期输出数据
if (!finger_on) {
state_transition(MAX30102_STATE_IDLE);
break;
}
ppg_buffer_add(red_raw, ir_raw);
// 每500ms更新一次输出
if ((current_time - s_last_output_time) >= STABLE_OUTPUT_INTERVAL_MS) {
s_last_output_time = current_time;
uint8_t hr = calculate_heart_rate();
float spo2 = calculate_spo2();
if (hr > 0 && spo2 > 0) {
if (stability_check(hr, spo2)) {
stability_get_average(&s_latest_data.heart_rate,
&s_latest_data.spo2);
s_data_ready = true;
} else {
// 数据不稳定,回到检测状态
state_transition(MAX30102_STATE_DETECTING);
}
}
}
break;
}
}
8. 完整数据流
┌─────────────────────────────────────────────────────────────┐
│ MAX30102 数据处理流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ MAX30102 FIFO ──────► GPIO中断 ──────► ISR │
│ │ │
│ ▼ │
│ 事件队列 │
│ │ │
│ ▼ │
│ 中断处理任务 │
│ │ │
│ ┌─────────────────┴─────────────┐ │
│ ▼ ▼ │
│ 原始数据存储 滤波处理 │
│ (red_raw, ir_raw) (移动平均) │
│ │ │ │
│ └─────────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┴─────────────┐ │
│ ▼ ▼ │
│ 心率计算 血氧计算 │
│ (过零检测) (R值公式) │
│ │ │ │
│ └─────────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ 稳定性检测 │
│ (方差判断) │
│ │ │
│ ┌───────┴───────┐ │
│ ▼ ▼ │
│ 稳定 不稳定 │
│ │ │ │
│ ▼ ▼ │
│ 输出数据 继续采集 │
│ │
└─────────────────────────────────────────────────────────────┘
9. 总结
本篇详细讲解了MAX30102心率血氧测量的完整实现:
- PPG原理:红光/红外双通道测量,反射式光电检测
- 中断驱动:GPIO下降沿中断 + FreeRTOS队列 + 独立任务
- 信号预处理:500样本环形缓冲 + 5点移动平均滤波
- 心率算法:DC水平计算 + 过零点检测 + 平均间隔 → BPM
- 血氧算法:AC/DC分量提取 + R值公式 + SpO2经验公式
- 稳定性检测:5样本历史 + 方差阈值判断
- 状态机:IDLE → DETECTING → STABLE,确保数据可靠
下篇预告
在最后一篇文章中,我们将讲解软件I2C和OLED显示的实现:
- 为什么需要软件I2C
- GPIO模拟I2C协议详解
- SSD1306 OLED驱动开发
- 界面布局与局部刷新优化
系列文章导航:
- 第一篇:项目架构设计与双I2C总线实现
- 第二篇:MPU6050跌倒检测算法与状态机设计
- 本篇:MAX30102心率血氧算法与PPG信号处理
- 第四篇:软件I2C实现与OLED显示优化
开发环境:ESP-IDF 5.3.2 + VS Code
硬件平台:ESP32-S3 + MAX30102
更多推荐
所有评论(0)