嵌入式系统是分层级的,分模块的。
使用的硬件资源有:
IMU-IIC
采集-ADC
电源-ADC
外置接口-串口
主控部分使用ESP32-IDF进行开发,因为芯片寄存器较多,而且采集对的实时性有要求,所以选用freeRTOS,在满足实时性的要求上程序的设计也会更简单。
FreeRTOS任务设计
MAX30102采集任务:初始化IIC和MAX30102,在一个死循环里面以50Hz的频率读取红外线和红光传感器的数据,进行简单滤波后存入队列。
MPU6050任务:初始化IIC和MPU6050,连续读取Euler角,以20Hz的频率进行更新,数据处理后存入队列。
ADC采集任务:初始化ADC,以适当采样频率(例如100Hz)采集模拟通道电压,发送到队列。
串口发送任务:优先级最低,从队列中读取数据并打包发送。可以设置一定的数据缓存。
空闲任务:优先级最低,MCU睡眠时运行,用于切换低功耗模式。
数据同步
采用FreeRTOS的队列和信号量机制进行任务间同步。信号量可用于指示队列已满或空。
给每个数据包添加采集时间戳,上位机可以根据时间戳重新同步。
也可以仅在串口发送任务中合并时间戳,不在各个采集任务中添加。
低功耗设计
利用调度器suspend/resume接口暂停任务实现睡眠唤醒。
DMA采集ADC数据,避免CPU占用。
使用内部PERIPH FIFO buffer,减少IIC任务调用。
串口使用DMA传输,CPU仅在发送完一个包后进行复位。
关闭不需要的外设时钟。利用IDLE调度钩子函数实现自动降频。
模块化设计
独立通信模块,内部封装串口通信的复杂度。
采集核心模块只输出统一格式的采集数据。
模块间使用统一的队列/缓存接口进行数据交换。
这里给出采集的样板任务
针对MAX30102的芯片,更多的技术细节是:首先配置传感器工作在FIFO模式下然后周期性读取FIFO,通过1024点的FFT变换得到频域数据,然后选择频带内的最高幅值为心率,通过对比两个幅值的幅度计算出血氧饱和度。通过平均其他频点的差值来标定两个波长数据。
struct compx FFTBUF1[FFT_N + 16];
struct compx FFTBUF2[FFT_N + 16];
uint16_t g_fft_index = 0;
BloodData g_blooddata = {0};
void test(float data1, float data2)
{
static uint8_t str[50];
sprintf((char *)str, "%f,%f\r\n", data1, data2);
HAL_UART_Transmit_DMA(&huart1, str, sizeof(str));
}
// 血液检测信息更新
void blood_data_update(void)
{
static DC_FilterData dc1 = {.w = 0, .init = 0, .a = 0.8};
static DC_FilterData dc2 = {.w = 0, .init = 0, .a = 0.8};
static float data1buf[20];
static uint8_t data1cur = 0;
static float data2buf[20];
static uint8_t data2cur = 0;
uint16_t temp_num = 0;
uint16_t fifo_word_buff[1][2];
temp_num = max30100_Bus_Read(INTERRUPT_REG);
if (INTERRUPT_REG_A_FULL & temp_num)
{
max30100_FIFO_Read(0x05, fifo_word_buff, 1); // read the hr and spo2 data form fifo in reg=0x05
float data1 = dc_filter(fifo_word_buff[0][0], &dc1) + 100.0;
float data2 = dc_filter(fifo_word_buff[0][1], &dc2) + 100.0;
data1buf[data1cur] = data1;
data2buf[data2cur] = data2;
data1 = 0;
data2 = 0;
for (int i = 0; i < 20; i++)
{
data1 += data1buf[i];
data2 += data2buf[i];
}
data1 /= 20;
data2 /= 20;
data1cur = (data1cur < 19) ? data1cur + 1 : 0;
data2cur = (data2cur < 19) ? data2cur + 1 : 0;
g_blooddata.hb = data1 + 50;
g_blooddata.hbo2 = data2 + 50;
// 将数据写入fft输入并清除输出
for (int i = 0; i < 1; i++)
{
if (g_fft_index < FFT_N)
{
FFTBUF1[g_fft_index].real = fifo_word_buff[i][0];
FFTBUF1[g_fft_index].imag = 0;
FFTBUF2[g_fft_index].real = fifo_word_buff[i][1];
FFTBUF2[g_fft_index].imag = 0;
g_fft_index++;
}
}
// 信息更新标志位
g_blooddata.update++;
}
}
// 血液信息转换
void blood_data_translate(void)
{
// 缓冲区写入结束
if (g_fft_index >= FFT_N)
{
// 快速傅里叶变换
FFT(FFTBUF1);
FFT(FFTBUF2);
// 解平方
for (int i = 0; i < FFT_N; i++)
{
FFTBUF1[i].real = sqrtf(FFTBUF1[i].real * FFTBUF1[i].real + FFTBUF1[i].imag * FFTBUF1[i].imag);
FFTBUF2[i].real = sqrtf(FFTBUF2[i].real * FFTBUF2[i].real + FFTBUF2[i].imag * FFTBUF2[i].imag);
}
// 读取峰值点 10-100带通 频率范围30-292次/分钟
uint16_t s1_max_index = find_max_num_index(FFTBUF1, 100);
uint16_t s2_max_index = find_max_num_index(FFTBUF2, 100);
// 检查HbO2和Hb的变化频率是否一致
if (s1_max_index == s2_max_index)
{
// 心率计算
uint16_t Heart_Rate = 60 * SAMPLES_PER_SECOND *
s2_max_index / FFT_N;
g_blooddata.heart = Heart_Rate;
// 血氧含量计算
float sp02_num = (FFTBUF1[s1_max_index].real * FFTBUF1[0].real) / (FFTBUF2[s1_max_index].real * FFTBUF2[0].real);
sp02_num = sp02_num * SAMPLES_PER_SECOND + CORRECTED_VALUE;
g_blooddata.SpO2 = sp02_num;
// 状态正常
g_blooddata.state = BLD_NORMAL;
}
else // 数据发生异常
{
g_blooddata.heart = 0;
g_blooddata.SpO2 = 0;
g_blooddata.state = BLD_ERROR;
}
g_fft_index = 0;
}
}
因为PPG的数据处理是难点,以上给出一段处理代码,但是还有优化的空间。
将采集数据和处理算法分离开来,降低耦合度
可以创建独立的采集模块和处理模块,采集模块专注获取传感器数据,处理模块实现算法逻辑。两者通过统一的数据结构进行交互。这可以提高代码的模块化和可维护性。
优化数据滤波方式
当前的平均滤波可以考虑改为滚动平均滤波,这样可以加快数据更新的响应速度。同时可以引入一阶IIR滤波来平滑数据。
优化FFT实现
可以考虑使用更优化的FFT库,或者直接调用DSP库的FFT函数,提高运算效率。当前的FFTBUFFER可以改为复数数组,简化运算。
血氧算法可进一步优化
血氧计算中使用了简单的比值法,可以参考更复杂的算法来提高精度,比如考虑LED功率补偿等。
添加参数配置接口
例如采样率、FFT长度、滤波参数等可以设计成可配置的,而不是硬编码的数字。这样可以更灵活地调整参数。
优化数据包发送流程
可以考虑使用FreeRTOS队列来缓存要发送的数据,发送任务从队列中获取数据。这可以避免直接在中断中发送造成的阻塞。
增加状态机管理
可以设计一个状态机来管理整个采集和处理的流程,例如初始化状态,检测状态,发送状态等。这可以使代码流程更清晰。