
血氧和心率监测这件事几年前还是医院监护仪上的专属功能现在一块十几块钱的MAX30102模块加上一块ESP32开发板就能搞定。我最早接触这个组合是想给家里老人做一个夜间血氧监测的小设备当时翻了不少资料发现大部分教程要么只讲接线不讲原理要么代码跑通了但数据跳得没法看。这篇文章就把我从零搭建这套健康监测系统的完整过程拆开来讲包括传感器选型的思考、I2C通信的底层逻辑、数据滤波的实际处理、温度补偿的必要性以及MicroPython环境下那些文档里不会写的坑。不管你是刚拿到ESP32的新手还是已经玩过几个传感器想进阶的开发者应该都能从中找到有用的东西。1. 为什么选MAX30102和ESP32这个组合1.1 血氧监测的两种技术路线对比市面上能测血氧的传感器方案大致分两类一类是透射式一类是反射式。透射式的工作原理很简单LED光源从手指一侧发射光穿过手指组织后被另一侧的接收器捕获。血液中的氧合血红蛋白和还原血红蛋白对不同波长光的吸收率不同通过计算两种波长下光吸收的比值就能推算出血氧饱和度。这种方案精度高但结构上要求光源和接收器分别位于手指两侧做出来的设备体积偏大医院里的指夹式血氧仪基本都是这个原理。反射式方案则是LED和接收器放在同一侧光进入皮肤组织后经过散射再反射回来被接收。MAX30102走的就是这条路。它的优势在于结构紧凑可以做成贴片式或者集成到可穿戴设备里比如智能手表背面的那个绿光传感器就是类似原理。代价是信号强度比透射式弱不少对信号处理的要求更高。我实际对比过两种方案的数据稳定性反射式在手指轻微移动时噪声明显更大这也是为什么后面要花不少篇幅讲滤波。选MAX30102还有一个很实际的原因它同时集成了红光LED660nm和红外LED880nm这两个波长正好对应氧合血红蛋白和还原血红蛋白的吸收差异峰值。芯片内部还集成了光电探测器、ADC和数字滤波电路通过I2C接口直接输出数字信号不需要外部再搭模拟前端电路。对于不想折腾模拟电路的人来说这个集成度非常友好。1.2 ESP32在这个项目里的角色定位ESP32在这套系统里不只是个读取传感器数据的单片机。它承担了三个核心任务第一是通过I2C总线与MAX30102通信配置寄存器并读取原始数据第二是在本地完成心率、血氧的计算和滤波处理第三是把处理后的数据通过WiFi或蓝牙发送出去方便在手机或电脑上查看。为什么不用Arduino Uno因为血氧计算涉及大量的浮点运算和滑动窗口滤波Uno的ATmega328只有2KB RAM跑起来非常吃力。ESP32有520KB SRAM和双核240MHz的处理器处理这些运算绰绰有余。而且ESP32自带WiFi和蓝牙省去了额外加通信模块的麻烦。我实测过在ESP32上跑128点滑动窗口的移动平均滤波CPU占用率不到5%完全不影响其他任务。另一个考虑是MicroPython的支持。ESP32烧录MicroPython固件后可以用Python语法直接操作I2C和GPIO开发效率比C语言高很多。虽然MicroPython的运行效率不如原生C但对于这种采样率要求不高的健康监测场景MAX30102最高采样率也就1000Hz实际用100Hz就够了性能完全够用。1.3 温度监测为什么也要加进来MAX30102内部其实有一个温度传感器但它的精度只有±1°C而且测的是芯片自身温度不是人体温度。芯片工作时LED会发热导致读数偏高。所以如果要测体温需要外接一个高精度温度传感器比如DS18B20或者MLX90614。我在项目里用的是DS18B20原因是它便宜、精度够±0.5°C、单总线接口占用引脚少。更重要的是体温数据可以用来做血氧计算的温度补偿。血氧饱和度的计算依赖于经验公式而这些公式通常是在特定温度下标定的。当手指温度偏低时比如冬天户外外周血管收缩会导致血氧读数偏低。有了温度数据就可以对计算结果做修正提高准确性。注意MAX30102的内部温度寄存器读出来的值不能直接当体温用它反映的是芯片结温通常比环境温度高3-5°C。如果只是做趋势观察可以凑合用但要做精确体温监测必须外接传感器。2. 硬件连接与I2C通信的底层细节2.1 接线方案与引脚选择MAX30102模块通常有6个引脚VIN、GND、SCL、SDA、INT、RD。VIN接3.3VGND接地SCL和SDA接ESP32的I2C引脚。ESP32默认的I2C引脚是GPIO21SDA和GPIO22SCL但这两个引脚可以重映射到几乎任意GPIO。我习惯用GPIO21和GPIO22因为大部分开发板的丝印都标了这两个位置接线不容易搞错。INT引脚是中断输出MAX30102可以在FIFO数据准备好或者有新数据时拉低这个引脚通知主控。如果不用中断也可以轮询读取但效率低一些。RD引脚是复位引脚一般悬空就行模块内部有上拉。DS18B20的接线更简单VDD接3.3VGND接地DQ接任意GPIO我用的GPIO4然后在DQ和VDD之间接一个4.7kΩ的上拉电阻。这个电阻不能省否则单总线通信会不稳定。模块引脚ESP32引脚备注MAX30102VIN3.3V不要接5VMAX30102GNDGND共地MAX30102SCLGPIO22可重映射MAX30102SDAGPIO21可重映射MAX30102INTGPIO23可选DS18B20VDD3.3VDS18B20DQGPIO4需4.7k上拉DS18B20GNDGND2.2 I2C地址确认与总线扫描MAX30102的I2C地址是0x577位地址。但有些模块出厂时可能被配置成其他地址或者你手头同时接了多个I2C设备这时候就需要先扫描总线确认设备地址。MicroPython里扫描I2C总线的代码很简单from machine import I2C, Pin i2c I2C(0, sclPin(22), sdaPin(21), freq400000) devices i2c.scan() print([hex(d) for d in devices])如果输出里有0x57说明MAX30102被正确识别了。如果没有先检查接线再检查上拉电阻。I2C总线需要上拉电阻大部分MAX30102模块自带了4.7kΩ上拉但有些廉价模块省掉了。如果扫描不到设备用万用表量一下SCL和SDA对VCC的电阻正常应该在4.7kΩ左右。我遇到过一种情况模块单独测试正常但和DS18B20一起接上后就扫描不到了。排查后发现是DS18B20的上拉电阻和MAX30102模块上的上拉电阻并联导致总线等效上拉电阻降到2.35kΩ左右虽然理论上I2C还能工作但某些ESP32的I2C控制器对这个阻值比较敏感。解决办法是把DS18B20的上拉电阻换成10kΩ或者去掉MAX30102模块上的上拉如果模块支持的话。2.3 MAX30102寄存器配置的关键参数MAX30102内部有十几个寄存器但真正需要配置的只有几个关键项。下面这张表是我在实际使用中总结的推荐配置寄存器地址推荐值作用FIFO_WR_PTR0x040x00FIFO写指针复位OVF_COUNTER0x050x00溢出计数器复位FIFO_RD_PTR0x060x00FIFO读指针复位FIFO_CONFIG0x080x4F采样平均4次FIFO满时更新MODE_CONFIG0x090x03血氧模式红光红外SPO2_CONFIG0x0A0x27ADC范围4096nA采样率100Hz脉宽411usLED1_PA0x0C0x24红光LED电流约7.2mALED2_PA0x0D0x24红外LED电流约7.2mALED电流的设置很关键。电流太小信号弱信噪比差电流太大LED发热严重而且可能超出ADC量程导致饱和。0x24对应大约7.2mA这是我实测下来在手指上比较合适的值。如果手指比较粗或者皮肤颜色较深可以适当加大到0x3F约12mA。判断电流是否合适的标准是原始IR信号的值应该在50000到200000之间18位ADC满量程262143。如果接近满量程说明电流太大如果低于30000说明电流太小。SPO2_CONFIG里的采样率设置也有讲究。100Hz的采样率意味着每秒采100个点对于心率监测来说足够了心率一般不超过200bpm即3.3Hz。采样率越高功耗越大FIFO溢出越快。如果你用中断方式读取100Hz下大约每10ms产生一次中断ESP32完全跟得上。3. 从原始数据到血氧值的计算链路3.1 MAX30102的FIFO数据读取机制MAX30102内部有一个32样本深度的FIFO。在血氧模式下每个样本包含两个18位值红光ADC值和红外ADC值。读取时需要从FIFO_DATA寄存器0x07连续读6个字节前3个字节是红光数据后3个字节是红外数据。每个18位值的存储方式是低18位有效高6位补零。具体来说第一个字节是bits[17:10]第二个字节是bits[9:2]第三个字节的bits[7:6]是bits[1:0]其余位无效。解析代码如下def read_fifo(i2c, addr0x57): data i2c.readfrom_mem(addr, 0x07, 6) red ((data[0] 16) | (data[1] 8) | data[2]) 0x03FFFF ir ((data[3] 16) | (data[4] 8) | data[5]) 0x03FFFF return red, ir这里有个容易踩的坑有些教程直接用(data[0] 16) | (data[1] 8) | data[2]而不做掩码结果读出来的值偶尔会异常大。原因是第三个字节的高6位是无效数据如果不屏蔽掉这些位会被当成有效数据参与计算。我一开始就犯了这个错误调试了半天才发现是掩码的问题。FIFO的读写指针管理也很重要。每次读取前要检查FIFO_WR_PTR和FIFO_RD_PTR是否相等如果相等说明FIFO为空。如果OVF_COUNTER大于0说明有数据溢出需要复位FIFO。在MicroPython里我通常用轮询方式每隔10ms检查一次FIFO_WR_PTR如果和上次不同就读取。3.2 心率计算的峰值检测算法从原始IR信号计算心率核心思路是检测信号的周期性峰值。IR信号反映的是血液容积的变化心脏收缩时血液涌入指尖IR吸收增加信号下降心脏舒张时血液回流信号上升。所以IR信号的谷值对应心跳。最简单的峰值检测是阈值法设定一个动态阈值当信号从下方穿过阈值时记录一个峰值。但这种方法对噪声很敏感手指轻微移动就会产生假峰值。我实际用的是动态阈值最小间隔的组合策略class HeartRateDetector: def __init__(self, min_interval_ms300): self.min_interval min_interval_ms self.last_peak_time 0 self.threshold 0 self.peak_intervals [] def update(self, ir_value, timestamp): # 动态调整阈值 if self.threshold 0: self.threshold ir_value else: self.threshold 0.95 * self.threshold 0.05 * ir_value # 检测峰值 if ir_value self.threshold and \ (timestamp - self.last_peak_time) self.min_interval: if self.last_peak_time 0: interval timestamp - self.last_peak_time self.peak_intervals.append(interval) if len(self.peak_intervals) 10: self.peak_intervals.pop(0) self.last_peak_time timestamp return True return False def get_heart_rate(self): if len(self.peak_intervals) 3: return 0 avg_interval sum(self.peak_intervals) / len(self.peak_intervals) return int(60000 / avg_interval)min_interval设为300ms对应最高心率200bpm。这个限制可以过滤掉大部分运动伪影产生的假峰值。动态阈值用指数移动平均来更新系数0.95是我试出来的既能跟上信号变化又不会因为单个异常值而剧烈波动。实际使用中还有一个问题手指按压力度变化会导致基线漂移。按得紧时信号整体下移按得松时信号上移。如果不处理动态阈值会跟着漂移导致峰值检测失效。我的做法是在阈值更新前先做去基线处理用高通滤波截止频率约0.5Hz去掉缓慢变化的趋势分量。3.3 血氧饱和度的R值计算与查表血氧饱和度的计算基于R值R值的定义是R (AC_red / DC_red) / (AC_ir / DC_ir)其中AC是交流分量脉搏波动部分DC是直流分量恒定吸收部分。红光和红外光的AC/DC比值之比就是R值。理论上R值越大血氧饱和度越低。计算AC和DC需要对一段时间的信号做处理。我通常取2秒的窗口200个样本DC取窗口内的平均值AC取窗口内的最大值减最小值。然后代入经验公式SpO2 -45.06 * R^2 30.35 * R 94.85这个公式是MAX30102数据手册里给出的参考公式实际使用时需要根据具体硬件做校准。我用指夹式血氧仪做过对比在95%-99%范围内误差在±2%以内基本可用。但这里有个问题经验公式是在特定条件下标定的不同人的手指厚度、皮肤色素、按压力度都会影响R值。更可靠的做法是查表法预先用标准血氧仪测一组数据建立R值和SpO2的对应表实际测量时用插值法查表。我用的是11点查表R值从0.4到1.4步长0.1对应的SpO2从100%到90%。查表法比公式法更灵活也更容易根据实际硬件调整。提示血氧计算的准确性高度依赖于信号质量。如果IR信号的AC分量太小比如低于DC的0.5%说明信号太弱这时候算出来的血氧值不可信。我的做法是设置一个信号质量阈值低于阈值时显示--而不是给出错误数值。4. 温度补偿与数据融合的实操细节4.1 DS18B20的MicroPython驱动与读数DS18B20用单总线协议MicroPython有内置的ds18x20模块可以直接用。但内置模块的扫描逻辑在某些ESP32固件版本上有bug偶尔会找不到设备。我一般用下面这段代码import onewire, ds18x20, time from machine import Pin ow onewire.OneWire(Pin(4)) ds ds18x20.DS18X20(ow) roms ds.scan() print(Found devices:, roms) def read_temp(): ds.convert_temp() time.sleep_ms(750) for rom in roms: return ds.read_temp(rom) return Noneconvert_temp()之后必须等750ms才能读这是DS18B20的转换时间决定的。12位精度下转换时间最长750ms如果不等够时间就读会读到上一次的转换结果或者85°C的默认值。我一开始没加延时读出来一直是85查了半天才发现是这个原因。如果总线上有多个DS18B20scan()会返回多个ROM地址需要逐个读取。单总线理论上可以挂很多设备但实际超过3个后通信稳定性会下降建议还是一个引脚接一个传感器。4.2 温度对血氧读数的影响机制温度影响血氧读数主要通过两个途径一是影响外周血管的收缩和舒张二是影响血红蛋白的光吸收特性。当手指温度降低时比如低于25°C外周血管收缩指尖血流量减少。这会导致IR信号的AC分量减小DC分量相对增大R值发生变化最终算出的SpO2偏低。我做过一个简单的实验在空调房里把手指温度从32°C降到24°C同一个人的SpO2读数从98%降到了94%降了4个百分点。这个偏差在临床上是不能接受的。补偿的方法有两种一种是基于温度直接修正SpO2值另一种是用温度来判断信号是否可靠。我采用的是混合策略当温度低于28°C时在计算结果上加一个修正量修正量根据温度查表得到当温度低于22°C时直接标记数据不可信。修正表是我用指夹式血氧仪对比测出来的大致规律是温度每降低1°CSpO2读数偏低约0.5%。这个系数因人而异但作为趋势参考足够了。4.3 多传感器数据的时间对齐MAX30102的采样率是100HzDS18B20的转换时间750ms两者时间尺度差很多。如果直接把温度数据和血氧数据拼在一起会出现时间戳不对齐的问题。我的处理方式是温度数据每750ms更新一次血氧数据每2秒计算一次用200个样本的窗口。在输出时用最近一次的温度值来补偿当前的血氧值。由于体温变化很慢正常环境下每分钟变化不超过0.1°C750ms的延迟完全可以接受。数据融合的代码逻辑大致是这样last_temp None last_temp_time 0 def update_temp(): global last_temp, last_temp_time t read_temp() if t is not None: last_temp t last_temp_time time.ticks_ms() def get_compensated_spo2(raw_spo2): if last_temp is None: return raw_spo2 if last_temp 22: return None # 数据不可信 if last_temp 28: correction (28 - last_temp) * 0.5 return min(100, raw_spo2 correction) return raw_spo2这里用min(100, ...)是为了防止修正后超过100%。虽然理论上SpO2最高就是100%但修正量加上去可能会超过需要截断。5. 数据滤波与信号质量判断的实战经验5.1 移动平均与中值滤波的组合使用原始IR信号里混杂着几种噪声高频的电子噪声、工频干扰50Hz、运动伪影、基线漂移。单一滤波器很难同时处理这些问题我实际用的是三级滤波第一级是中值滤波窗口大小5。中值滤波对脉冲噪声特别有效比如手指突然动一下产生的尖峰中值滤波可以直接把它剔除。但中值滤波会稍微展宽信号对峰值检测的精度有一点影响。第二级是移动平均窗口大小8。移动平均进一步平滑信号但会引入约4个样本的延迟40ms at 100Hz。这个延迟对心率计算影响不大因为心率本身是秒级的变化。第三级是带通滤波通带0.5Hz到5Hz。这个频段覆盖了正常心率范围30bpm到300bpm。带通滤波用二阶巴特沃斯实现在MicroPython里可以用biquad滤波器class BiquadFilter: def __init__(self, b0, b1, b2, a1, a2): self.b0, self.b1, self.b2 b0, b1, b2 self.a1, self.a2 a1, a2 self.x1 self.x2 self.y1 self.y2 0 def process(self, x): y self.b0 * x self.b1 * self.x1 self.b2 * self.x2 \ - self.a1 * self.y1 - self.a2 * self.y2 self.x2, self.x1 self.x1, x self.y2, self.y1 self.y1, y return y滤波器的系数可以用Python的scipy库离线计算然后硬编码到MicroPython代码里。我用的系数是采样率100Hz、低通5Hz、高通0.5Hz对应的值。5.2 信号质量指数SQI的计算信号质量指数SQI用来判断当前信号是否可靠。我用的SQI基于三个指标信号幅度、信噪比、峰值规律性。信号幅度IR信号的AC/DC比值正常应该在0.5%到5%之间。低于0.5%说明信号太弱高于5%说明可能有运动干扰。信噪比用带通滤波后的信号功率除以滤波前的信号功率比值越高说明噪声占比越小。峰值规律性最近5个心跳间隔的标准差除以平均值变异系数越小说明节律越稳定。三个指标加权求和得到SQI范围0到1。SQI大于0.7认为信号良好0.4到0.7认为一般低于0.4认为不可信。实际使用时SQI低于0.4就暂停更新显示值保持上一次的有效读数。这套SQI算法是我参考了几篇论文后简化的计算量不大在ESP32上跑一次不到1ms。效果比单纯看信号幅度好很多特别是在手指轻微移动时能及时识别出不可信的数据。5.3 运动伪影的识别与处理运动伪影是血氧监测最大的敌人。手指稍微动一下IR信号就会出现大幅波动如果这时候还在计算心率可能跳到180血氧可能掉到85%。识别运动伪影的方法是比较红光和红外信号的相关系数。正常情况下红光和红外信号的波形高度相关相关系数0.9因为两者反映的是同一个脉搏波。当运动伪影出现时两个信号的相关系数会下降。我用的窗口是1秒100个样本计算红光和红外信号的皮尔逊相关系数。如果相关系数低于0.7就认为当前窗口内有运动伪影丢弃这组数据。这个方法简单有效我实测能过滤掉大部分运动干扰。但有个例外当血氧饱和度极低时比如低于85%红光和红外信号的波形差异会变大相关系数可能自然降到0.8左右。所以相关系数阈值不能设太高0.7是个比较安全的平衡点。6. 从串口输出到无线传输的完整链路6.1 串口调试与数据可视化开发阶段最方便的调试方式是通过串口输出数据然后在电脑上用Python脚本实时绘图。ESP32的串口输出用print()就行格式我习惯用CSVprint(f{timestamp},{heart_rate},{spo2},{temperature},{sqi})电脑端用pyserial读取串口matplotlib实时绘图import serial import matplotlib.pyplot as plt from collections import deque ser serial.Serial(COM3, 115200) data deque(maxlen200) while True: line ser.readline().decode().strip() try: parts line.split(,) hr int(parts[1]) spo2 int(parts[2]) data.append((hr, spo2)) # 绘图代码省略 except: pass串口波特率设115200就够了100Hz的采样率下每10ms输出一行每行约30字节总带宽约3KB/s115200bps约11.5KB/s绰绰有余。6.2 WiFi WebSocket实时推送如果需要无线查看数据ESP32可以起一个WebSocket服务器把数据推送到浏览器。MicroPython有websocket库可以用但更简单的方式是用socket自己实现一个轻量级的HTTP服务器浏览器定时轮询。我实际用的是WebSocket方案因为推送延迟更低。ESP32作为WebSocket服务器浏览器作为客户端。每次计算出新的血氧值就推送一次浏览器端用JavaScript接收并更新图表。import socket import network sta network.WLAN(network.STA_IF) sta.active(True) sta.connect(SSID, password) # WebSocket服务器代码省略核心是accept后解析握手请求 # 然后按WebSocket帧格式发送数据WebSocket的握手和帧格式稍微有点复杂但网上有现成的MicroPython实现可以借鉴。我建议先用简单的HTTP轮询跑通再换成WebSocket。6.3 蓝牙BLE传输的配置要点ESP32的蓝牙BLE也可以用来传输数据。BLE的优势是功耗低适合电池供电的场景。但BLE的传输速率比WiFi低而且需要配对开发调试不如WiFi方便。用MicroPython的bluetooth模块可以快速搭建一个BLE服务import bluetooth ble bluetooth.BLE() ble.active(True) # 定义服务和特征值 SERVICE_UUID bluetooth.UUID(0x1810) # 健康温度计服务 CHAR_UUID bluetooth.UUID(0x2A35) # 温度测量特征 # 注册服务并开始广播BLE的UUID需要遵循标准规范血氧和心率都有对应的标准UUID。如果只是自己用也可以用自定义UUID。手机端用nRF Connect之类的App就能查看数据。注意ESP32的WiFi和蓝牙共用同一个射频模块不能同时使用。如果项目里既要WiFi又要蓝牙需要分时复用或者用ESP32的双核特性一个核跑WiFi一个核跑蓝牙但MicroPython对双核的支持有限原生C开发更合适。7. 实测中的典型问题与排查思路7.1 读数跳变严重的第一排查方向如果你发现血氧读数在90%到100%之间乱跳先别急着改算法。按这个顺序排查第一步检查LED电流是否合适。读原始IR值如果低于30000加大LED电流如果高于200000减小电流。这一步能解决大部分读数跳变问题。第二步检查手指按压力度。按得太紧会阻碍血流信号变弱按得太松会漏光噪声增大。正确的力度是手指轻轻贴在传感器上能看到IR信号有明显的脉搏波动但波形不饱和。第三步检查电源噪声。ESP32的WiFi工作时会产生电源纹波如果MAX30102和ESP32共用一路电源纹波会耦合到模拟电路里。解决办法是在MAX30102的VIN和GND之间加一个10uF的钽电容和一个0.1uF的陶瓷电容。第四步检查I2C通信是否稳定。用逻辑分析仪抓一下SCL和SDA的波形看有没有毛刺或者时钟拉伸。如果I2C速率设得太高比如400kHz某些模块可能跟不上降到100kHz试试。7.2 温度读数85°C的经典问题DS18B20读出来一直是85°C这是新手最常遇到的问题。85°C是DS18B20上电后的默认值说明转换没有完成或者通信失败。原因通常有三个一是convert_temp()之后没有等够750ms二是上拉电阻没接或者阻值不对三是引脚配置错误。排查时先用scan()确认设备是否被识别如果scan()返回空列表说明通信有问题重点检查接线和上拉电阻。如果scan()能识别但读出来是85说明转换时间不够加长延时。还有一个隐蔽的问题如果DS18B20的供电电压低于3.0V转换可能会失败。ESP32的3.3V输出在WiFi工作时可能会有波动如果波动幅度超过0.3VDS18B20就可能工作不正常。解决办法是在DS18B20的VDD和GND之间加一个100uF的电解电容。7.3 FIFO溢出导致的数据错位FIFO溢出是另一个常见问题。当主控读取速度跟不上采样速度时FIFO写满后会覆盖旧数据导致读出来的红光和红外数据不是同一个时间点的血氧计算就会出错。判断FIFO是否溢出的方法是读OVF_COUNTER寄存器0x05如果值大于0说明有溢出。处理方法是复位FIFO依次写FIFO_WR_PTR、OVF_COUNTER、FIFO_RD_PTR为0。预防溢出的根本方法是提高读取频率。100Hz采样率下FIFO深度32理论上每320ms会写满。只要读取间隔小于320ms就不会溢出。我用的轮询间隔是10ms远远小于320ms所以正常情况下不会溢出。但如果主控被其他任务阻塞比如WiFi发送数据可能会导致读取延迟。解决办法是把I2C读取放在高优先级任务里或者用中断方式触发读取。7.4 血氧值始终偏低的校准方法如果你的血氧读数一直比标准血氧仪低3-5个百分点大概率是R值计算的经验公式不适用于你的硬件。校准方法是用标准血氧仪同时测量记录至少5组数据不同的人或者不同的血氧状态。每组数据包括标准血氧仪的读数和你的设备算出的R值。然后用这些数据点拟合一条R值到SpO2的曲线替换掉默认的经验公式。拟合可以用Python的numpy.polyfit做二次拟合import numpy as np R_values [0.5, 0.6, 0.7, 0.8, 0.9] spo2_values [99, 98, 97, 96, 95] coeffs np.polyfit(R_values, spo2_values, 2) print(coeffs) # 输出二次项、一次项、常数项系数把拟合出的系数硬编码到MicroPython代码里重新测试。如果条件允许最好用多个人的数据做拟合这样校准曲线更有普适性。我在实际项目中用这个方法把误差从±5%降到了±2%以内。需要注意的是校准后的公式只适用于同一批硬件如果换了传感器模块或者改了LED电流需要重新校准。8. 写在最后的一些实操体会这套系统我从最初的面包板搭建到后来的PCB打样前后折腾了差不多两个月。最大的体会是血氧监测的难点不在代码而在信号处理和对生理机制的理解。同样的代码手指按压力度不同、环境温度不同、甚至心情不同紧张时外周血管收缩读数都会有差异。如果你刚开始做这个项目我的建议是先把原始IR信号用串口打出来在电脑上画成波形图直观地看看脉搏波长什么样。理解了波形后面的滤波和峰值检测就顺理成章了。不要一上来就追求算法多复杂先把最简单的阈值法跑通再逐步优化。另外MAX30102的模块质量参差不齐我买过5个不同价位的模块便宜的十几块和贵的四五十块在LED亮度上差异明显。如果发现信号始终很弱换一个模块试试可能比调代码更有效。