Arduino 怎么测心率?脉搏传感器与 MAX30102 完整指南

Cartoon of a blue Arduino Uno wired to a purple MAX3010x module on a white mini breadboard, beside a round black pulse sensor with yellow, red and black leads resting on a fingertip

这一页上的两款传感器,量的都是光,不是心跳。一颗 LED 把光打进你的指尖,检测器读回来的那一点点光,而所有 BPM 数字都是从那道晃动的光强时间间隔里推算出来的。模拟式的脉搏传感器五分钟就能在 Uno 上画出波形,连库都不必装 —— 黄线接 A0、红线接 5 V、黑线接 GND,再把 analogRead() 丢进串口绘图器。MAX3010x 模块要久一点:它的排针得先焊上去,而且在你装任何库之前,还得先读一个寄存器。

这些东西算不算医疗器材?

不算。脉搏传感器和 MAX3010x 模块都是玩家与科学展用的零件,不是医疗仪器。两者都没有医疗级的准确度,都没有任何认证,它们跑出来的任何数字都不可以拿来诊断病情、监测状况,或者对任何人的健康下判断。身体有疑虑,请去看医生 —— 不是找 Arduino。

把这个作品跑出来的每一个数字,都当成一次光学原理的演示。这不是免责声明,而是这套硬件本来就只做到这里,本文接下来会一路说明为什么。血氧百分比尤其如此:一个站得住脚的 SpO₂ 数值,需要一个用认证仪器校准并验证过的红光对红外光吸收比,所以请把心率和原始波形做成主角,并且在报告里如实写清楚。硬件安全的部分很短:全程是 USB 线供电的低压直流,不碰任何市电,VIN 只接 5 V 或 3.3 V,而脉搏传感器的红线只能接在 5 V 或 3.3 V 电源上,别的地方一律不接。

所谓的“心率传感器”,到底在感测什么?

脉搏传感器和 MAX3010x 做的都是光电容积描记法(PPG),名字很长,原理很简单。一颗 LED 把光推进你的指尖;大部分被吸收和散射掉,只有一小部分回到光电二极管上。每一次心跳都会让那块组织里的动脉胀大,于是更多光被吸收,回来的光就少了一点点。

那道涟漪大约只有它底下那个稳定光强的百分之一,所以那块黑色圆形的脉搏传感器板,绝大部分根本不是传感器:板上那颗芯片是一个放大器,把稳定的部分剥掉,再把剩下的那一点点放大。两颗贴片电阻决定放大出来的结果有多大 —— 一颗喂 LED,一颗给检测器当负载。放大后的涟漪要能往上摆也要能往下摆,所以这块板把输出停在电源电压的一半,让心跳骑在上面 —— 这就是为什么 A0 看到的是接近中间刻度的电压,5 V 板上 analogRead() 大约 512,然后每一拍冒出一个包,而不是一个从零开始的读数。

这个信号里没有任何东西是「每分钟几下」。BPM 是你的代码猜出来的:量两个包之间的间隔,再拿 60 000 ms 去除。所以下面先给图、后给数字;也所以手指一动结果就乱掉 —— 指节抖那一下产生的包,代码根本分不出跟心跳有什么不同。

卡通剖面图:一根指尖里,红色 LED 把光打进组织,检测器接住返回的光,旁边是一条青色的脉搏波形
光进去,回来的光变少。心跳从来不是被直接量到的 —— 它只是骑在一个大得多的稳定光强上的那一点点涟漪。

选配 —— 把两件麻烦事直接拿掉

ItemPriceQty
Soldering Service for Circuit Boards & Electronics - 5 Pins Soldering ServiceSoldering Service for Circuit Boards & Electronics - 5 Pins Soldering ServiceSOLSE05RM0.80
XL830L Digital Multimeter with backlight Portable Multimeter Electronics ProjectXL830L Digital Multimeter with backlight Portable Multimeter Electronics ProjectMULTIMERM25.95

代焊服务刚好涵盖五支针,也刚好就是 MAX3010x 模块需要的数量;这项服务是先咨询后下单的,加进购物车之前请先用 WhatsApp 跟我们说一声。已经有万用表的就跳过 —— 它列在这里,是因为下面 I2C 那一节里,SDA 上的一次读数就是整个诊断。

两款传感器怎么共用一块 Arduino Uno?

脉搏传感器是简单的那一半:它的三条线各自结束在自己的单针插头上,插头里是公针,而 Uno 板上是母座,所以直接压进去就行。MAX3010x 是麻烦的那一半 —— 五个裸的镀通孔,袋子里另附一条松着的五针排针;而排针焊上去之后,模块伸出来的是一排五支公针,可是它要接的那四支 Uno 引脚,分在两条不同的排母上。所以它插不进 Uno,只能用线接。而从模块的公针接到 Uno 的母座,一条线就得一端是母、一端是公。

170 孔迷你面包板把这件事解决掉,顺便还做了第二件事。它的连接孔是母的,所以模块的针可以压进去,再由普通的公对公杜邦线把那四条连接送到 Uno 的母座上 —— 每条线的两端公母都对得上。它没有电源轨 —— 只有中央凹槽两侧的连接孔列 —— 在这里刚好没差,因为每一列的五个孔,底下是同一片金属夹连起来的。把模块的五支针插在相邻的五列上,每一支针就各自还剩四个空孔,于是 VIN 所在的那一列,就成了你的 5 V 汇流点:把脉搏传感器的红线插头压进这一列的一个空孔,再从另一个空孔拉一条公对公杜邦线到 Uno 的 5V 脚,两个零件就共用一支电源脚。

零件 线或引脚 接到
脉搏传感器 黄线(S) Uno A0 —— 直接压进母座
脉搏传感器 红线(+) 面包板上模块 VIN 那一列的空孔
脉搏传感器 黑线(−) Uno POWER 排母上的 GND —— 直接压进去
MAX3010x VIN 从那一列拉一条杜邦线到 Uno 5V
MAX3010x GND 拉一条杜邦线到 D13 旁边那支 Uno GND
MAX3010x SCL 拉一条杜邦线到 Uno A5
MAX3010x SDA 拉一条杜邦线到 Uno A4
MAX3010x INT 不接

两款传感器能一起挂在 Uno 上,是因为它们占的引脚不一样 —— 一支模拟输入,对上 A4 和 A5 那条两线总线 —— 所以这是一个作品,不是两个。线接一次就好,之后换 sketch 就行;下面每一段代码各驱动一款传感器。(数字排母最左边标着 SDA 和 SCL 的那两支,跟 A4、A5 在板子内部是同一条线 —— 用哪一对都可以。)VIN 吃 5 V,尽管封装里那颗芯片本身跑在 1.8 V,因为模块自己带了一串稳压:VIN 先进一颗 3.3 V 稳压器,它的输出再进一颗 1.8 V 稳压器喂芯片。这就是为什么这支脚叫 VIN 而不是 VCC,也是为什么 Uno 自己的两条电源轨哪一条都能用 —— 3.3 V 或 5 V,再高就不行。VIN 后面全是按逻辑电压设计的小稳压器,所以 9 V 或 12 V 的电源该接 Uno 的圆形电源座,绝不能接这支脚。

卡通特写:小块紫色 MAX3010x 传感器板上五个裸露的银色焊孔,旁边是一条松着的黑色五针排针和一支烙铁
袋子里真正装的东西:一块紫色 MAX3010x 转接板,五个裸的镀通孔,还有一条得有人焊上去的五针排针。

焊五支针是十五分钟的活 —— 我们的排针焊接指南把这一排从头走了一遍,诀窍是先把排针立在面包板上,这样点第一个焊点的时候板子是平的。这里唯一真正的危险:烙铁头的温度在 350 °C 上下,你还没感觉到它就已经烫伤你了,所以桌面要耐热、两个焊点之间烙铁一定放回架子、开一扇窗把助焊剂的烟带走。或者直接用五针代焊服务,这五个焊点它包了。这个动作值得看一次:

你手上这块,到底是哪一颗 MAX3010x?

MAX3010x 这一家共用同一个转接板外形,而大多数项目就卡在这里。GY 这个编号定的是外形 —— 同样的紫色长方块、同样的五个焊盘、同样的丝印 —— 所以那块板材告诉你的是你手上是哪一块,不是那颗黑色光学封装里键合的是哪一颗芯片。用看的是看不出来的,所以 Maxim 干脆在硅片里放了一个身份寄存器。

寄存器 0xFF 就是那个身份,只读。Maxim 的 MAX30105 数据手册寄存器表0xFF 列为 PART_ID,值是 0x15;SparkFun 那个库 —— 是把 MAX30102 和 MAX30105 写在一起的 —— 也把两者当同一颗来对待;比较旧的 MAX30100 回答的是 0x11,也就是 oxullo 的 MAX30100 库写死在里面的那个预期 ID。整个家族都在 7 位地址 0x57 上应答:数据手册给的写地址是 0xAE、读地址是 0xAF,最低位是读写标志,所以 0xAE 右移一位,就得到 Arduino Wire 库要的那个 0x57

这件事之所以要紧,是因为两个库遇到不对的芯片都会拒绝,然后把帐算到你的接线头上。SparkFun 的 begin() 在 ID 不是 0x15 时回传 false;oxullo 的对 0x11 也一样。它们自己的示例确实有检查这个布尔值,但换来的那句话是 SparkFun 的 MAX30105 was not found. Please check wiring/power.,或者 oxullo 光秃秃的一句 FAILED —— 两句都会把你送回去查那些从来就不是问题的杜邦线。先跑这一段:

// 你的转接板上,到底是哪一颗 MAX3010x?
// 接线:VIN -> Uno 5V,GND -> GND,SCL -> A5,SDA -> A4。
#include <Wire.h>

const uint8_t MAX3010X_ADDR   = 0x57;   // 整个家族都在这个地址应答
const uint8_t REG_REVISION_ID = 0xFE;
const uint8_t REG_PART_ID     = 0xFF;

bool readReg(uint8_t reg, uint8_t &value) {
  Wire.beginTransmission(MAX3010X_ADDR);
  Wire.write(reg);
  if (Wire.endTransmission(false) != 0) return false;   // 重复起始,不发停止
  if (Wire.requestFrom(MAX3010X_ADDR, (uint8_t)1) != 1) return false;
  value = Wire.read();
  return true;
}

void setup() {
  Serial.begin(115200);
  while (!Serial) { }
  Wire.begin();

  Serial.println(F("Scanning the I2C bus..."));
  uint8_t found = 0;
  for (uint8_t addr = 1; addr < 127; addr++) {
    Wire.beginTransmission(addr);
    if (Wire.endTransmission() == 0) {
      Serial.print(F("  device answered at 0x"));
      Serial.println(addr, HEX);
      found++;
    }
  }
  if (found == 0) {
    Serial.println(F("Nothing answered. Fix the bus before you install any library."));
    return;
  }

  uint8_t part = 0, rev = 0;
  if (!readReg(REG_PART_ID, part) || !readReg(REG_REVISION_ID, rev)) {
    Serial.println(F("0x57 answered the scan but the ID read failed."));
    return;
  }

  Serial.print(F("Part ID 0x"));
  Serial.print(part, HEX);
  Serial.print(F(", revision 0x"));
  Serial.println(rev, HEX);

  if (part == 0x11) {
    Serial.println(F("-> MAX30100. Install MAX30100lib by OXullo Intersecans."));
  } else if (part == 0x15) {
    Serial.println(F("-> MAX30102 or MAX30105. Install SparkFun MAX3010x."));
  } else {
    Serial.println(F("-> Not a MAX3010x part ID."));
  }
}

void loop() { }

然后只装一个:0x11 装 OXullo Intersecans 的 MAX30100lib0x15SparkFun MAX3010x Pulse and Proximity Sensor Library。名字请整串打进去 —— 搜“MAX30”会跳出一整屏,大多是分支版本,它们的类名跟你找到的示例代码对不上。我们的 MPU6050 指南开头也是同一段总线扫描。

每一条线都接对了,为什么 I2C 扫描什么都找不到?

I2C 是一条开漏总线,光这一件事,就能解释一个会吞掉整个晚上的故障。ATmega328P 自己的数据手册这么写:「A high level is output when all TWI devices tri-state their outputs, allowing the pull-up resistors to pull the line high.」—— 只有当所有 TWI 器件都把输出设成高阻态、让上拉电阻把线拉高时,线上才会出现高电平。没有任何东西会去驱动这条线变高;器件只会把它拉低,或者松手。所以那个高电平不是 Uno 的 5 V —— 上拉电阻接在哪条电源轨上,它就是多少。

这些转接板上,SDA、SCL 和 INT 的 4.7 kΩ 上拉电阻,坐在模块自己内部的某一条电源轨上,坐哪一条要看版本。有一批被广泛回报的板子 —— Reed Paper 把它记录了下来 —— 把上拉接到了 1.8 V:「They are connected to the supply + 1.8V!!! If such a module is connected to a 5V logic of Arduino board – it will not be visible on the I2C bus because the logic levels are too low.」—— 这样的模块接到 5 V 逻辑的 Arduino 上,会因为逻辑电平太低而在 I2C 总线上完全看不见。

算一下就看得到它是怎么发生的。Wire.begin() 会打开 ATmega 在 A4 和 A5 上的内部上拉 —— 就是出厂 core 里那两行 digitalWrite(SDA, 1); digitalWrite(SCL, 1); —— 而 ATmega328P 数据手册给这些内部上拉的值是 20–50 kΩ。其中一颗从 5 V 拉上去,对上模块那颗接到 1.8 V 的 4.7 kΩ,线就停在一个分压点上:取最弱的 50 kΩ,3.2 V 的压差跨在 54.7 kΩ 上推出 58 µA,把这个节点从 1.8 V 抬高 0.28 V,于是它停在约 2.1 V;取最强的 20 kΩ,大约 2.4 V。

决定胜负的是接收端。同一份数据手册把 ATmega328P 的高电平输入门槛,在普通 I/O 上定为 0.6 × VCC,在两线总线那张表里则更严,是 0.7 × VCC —— 在 5 V 的 Uno 上就是 3.0 V 和 3.5 V。停在 2.1 V 的线两个都够不到,于是 Uno 一直读到低电平,没有任何器件被认出来,扫遍整个地址空间的结果就是什么都没有:没有报错、没有超时、接线完美。就连 3.3 V 那个版本也压在 3.5 V 这个数字下面,这就是为什么同一款模块,有人用得好好的,有人怎么都不行。

卡通图:橙色胶套万用表用红黑表笔,在白色迷你面包板上的紫色 MAX3010x 模块处量一条杜邦线上的空闲电压
拿表量三十秒,胜过重插一小时杜邦线:板子通着电,把 SDA 对地量一下,读那个空闲电平。

于是一整晚变成一次测量。让扫描 sketch 保持烧录着并且在跑,这样 Uno 自己的上拉是开着的,然后万用表转到直流电压档,黑表笔搭 GND、红表笔搭 SDA 那条杜邦线 —— 我们的万用表使用指南讲了旋钮怎么转。三种读数,三个不同的答案。接近 5 V,表示模块上根本没有任何东西在把那条线往下拉 —— 在一块自己带 4.7 kΩ 上拉的转接板上,这指向的是连接,不是电平:VIN 没通电、一个虚焊,或者杜邦线插错了一列。大约 3.3 到 3.5 V 是好的那个版本,上拉接在模块自己的 3.3 V 轨上 —— 高过 0.6 × VCC 那个数字,却刚好差一点点到两线总线那张表要的 3.5 V,这就是为什么这类板子大多数时候能用、偶尔闹脾气;这种情况下,换一块 3.3 V 逻辑的主板,例如 ESP32,就没得吵了(可以看我们的开发板对比),而如果在那个电平下扫描还是什么都找不到,那就该去看电源和焊点,不是逻辑电平。大约 2 V 是 1.8 V 轨的那个版本,那里唯一老实的路,是那个有据可查的改板做法:把上拉飞线改接到 3.3 V —— 这是要放大镜才做得来的细活,不适合当第一次焊接 —— 因为 ESP32 数据手册把它自己的高电平门槛定在 0.75 × 3.3 V,大约 2.5 V,2 V 一样过不了。

不该做的,是那个很流行的偏方:另外挂一颗上拉电阻到 5 V。芯片的引脚是撑得住 —— 数据手册给「All Other Pins to GND」这一栏(SDA 和 SCL 就住在这一栏)的范围是 −0.3 V 到 +6.0 V —— 但模块自己那两颗 4.7 kΩ 还是坐在那条内部电源轨上,所以再挂一颗 4.7 kΩ 到 5 V,只能把线抬到大约 3.4 V,同时却把大约 340 µA 倒灌回一个只能输出、不能吸收电流的稳压器输出端。而 VDD 的绝对最大值是 +2.2 V。

怎么先拿到波形,再拿到数字?

脉搏传感器第一次上传就有结果,所以它属于有截止日期的项目。动手之前有一件事:手指要放在光学的那一面,不是我们产品照上你看到的那一面 —— 那一面是元件和三个焊点,所以先贴一片绝缘胶带盖住它。潮湿的指尖压在裸露的焊点上,就是这些小放大器的死法。然后上传下面这段,打开 Tools → Serial Plotter(串口绘图器)设 115200,用稳定而轻的力道把指尖放在那块圆板上 —— 稳到不会滑动,轻到不会把血挤走:

// 脉搏传感器:原始 PPG 波形,给 Arduino IDE 的 Serial Plotter 看。
// 这块模块的接线:黄 -> A0,红 -> 5V,黑 -> GND。不用装库。
// 这是一次光学信号的演示,不是医疗量测。

const uint8_t PPG_PIN = A0;
const unsigned long SAMPLE_US = 10000UL;   // 每 10 ms 采样一次 = 100 Hz

void setup() {
  Serial.begin(115200);
}

void loop() {
  static unsigned long lastSample = 0;
  if (micros() - lastSample < SAMPLE_US) return;   // 把采样率稳住
  lastSample += SAMPLE_US;

  Serial.println(analogRead(PPG_PIN));   // 0..1023;5 V 的 Uno 上停在 512 附近
}

那个 100 Hz 是选出来的,不是随手写的。静息心跳大约 1 Hz,但每一拍那道陡峭的上升沿、还有它后面那个小小的切迹,细节一路延伸到大约 10 Hz,所以每秒 100 个采样,等于在那个最快成分的每一个周期上放了大约十个点 —— 而且这很便宜:115200 baud 每秒搬得动大约 11 520 个字节,那一百行短短的数字只需要大约 600 个。

把那条曲线变成 BPM,还差一个想法。固定门槛行不通,因为静息电平会随压力和皮肤漂移,所以下面这段 sketch 记住最近两秒里的最高和最低读数,然后在你的手指当下产生的那个摆幅的 60 % 高度上触发。300 ms 的不应期窗口把上限压在 200 BPM,让一个毛刺边沿不会被数成两拍;超过 2000 ms 的间隔则当成重新开始。这段「记忆」有一个代价,值得先知道,而不是回头去 debug:每两秒重置之后,摆幅要重新长回来才会有一拍越得过门槛,所以偶尔会漏掉一拍,接下来那一行就印出大约只有真实心率一半的数字。要看一连串互相吻合的数字,永远不要只看一行。

// 脉搏传感器:把那条波形变成一个 BPM 数字。
// 黄 -> A0,红 -> 5V,黑 -> GND。不用装库。串口监视器 115200。
// 这是光学原理的演示。不是医疗量测。

const uint8_t      PPG_PIN        = A0;
const unsigned long SAMPLE_US     = 10000UL;  // 每秒 100 个采样
const unsigned long WINDOW_MS     = 2000UL;   // 一个极值记多久
const unsigned long REFRACTORY_MS = 300UL;    // 超过 200 BPM 的一律不理
const unsigned long SLOWEST_MS    = 2000UL;   // 低于 30 BPM:当成重新开始
const int           MIN_SWING     = 20;       // 计数值;低于这个就是没放手指

int  hi = 0, lo = 1023;
unsigned long windowStart = 0;
unsigned long lastBeat = 0;
bool above = false;

void setup() {
  Serial.begin(115200);
}

void loop() {
  static unsigned long lastSample = 0;
  if (micros() - lastSample < SAMPLE_US) return;
  lastSample += SAMPLE_US;

  int reading = analogRead(PPG_PIN);
  unsigned long now = millis();

  if (reading > hi) hi = reading;
  if (reading < lo) lo = reading;

  // 触发电平不是一个常数。它坐在这根手指此刻产生的那个摆幅的
  // 60 % 高度上,所以直流电平漂移了它照样有效。
  int span = hi - lo;
  int threshold = lo + (span * 3) / 5;

  if (span >= MIN_SWING && reading > threshold && !above &&
      now - lastBeat > REFRACTORY_MS) {
    above = true;
    unsigned long interval = now - lastBeat;
    bool firstEver = (lastBeat == 0);   // 第一拍没有东西可以拿来相减
    lastBeat = now;
    if (!firstEver && interval < SLOWEST_MS) {
      Serial.print(F("beat: "));
      Serial.print(interval);
      Serial.print(F(" ms since the last one -> "));
      Serial.print(60000UL / interval);
      Serial.println(F(" BPM (demo only)"));
    }
  }
  if (reading < threshold) above = false;   // 让下一个上升沿重新武装

  // 忘掉旧的极值,否则抖那一下会把标准撑高一整场。
  if (now - windowStart > WINDOW_MS) {
    windowStart = now;
    hi = reading;
    lo = reading;
  }
}

手指的力道是文字最难说清楚的一段,所以值得看一次:

我们从真实客户身上看到的常见错误

还没查 part ID 就先装库。代码编译过、上传成功,然后宣布找不到传感器 —— 请检查接线和电源。于是接下来一小时,全花在本来就正确的接线上。begin() 读了寄存器 0xFF,不喜欢那个答案,却没有办法把话说清楚。先读 ID。

接线从来就不是问题,却查了接线一小时。一次静悄悄的扫描,感觉起来跟一条松掉的杜邦线一模一样,所以人们把每一条线反复重插。一次表笔读数就能把它定案,而且顺便抓到另一个同样安静的元凶:一个看起来焊好了、里面却藏着冷焊的焊盘。

手指还在动,却在追一个稳定的 BPM。那条曲线是光强,所以移动中的手指本身就是一个真实的信号,跟心跳真的分不出来。把手放在桌上,等绘图器看起来规律了,再去读数字。

把整个项目建在一个 SpO₂ 百分比上。看起来很合理的读数才是陷阱。请把演示建在心率和波形上,把任何血氧数字都当成一个未经验证的实验,并且如实写出来 —— 这本来就是更强的科学展作品。我们的 RBT 电子专题点子页面还有更多评审吃这一套的作品。

常见问题

MAX30102 模块能准确量我的血氧吗?

不能。它是一块玩家用的转接板,不是医疗器材,它印出来的任何数字都不该拿去判断任何人的健康。一个站得住脚的 SpO₂ 数值,需要一个用认证仪器校准并验证过的红光对红外光吸收比。请改成演示心率和原始波形。

接线明明是对的,为什么 I2C 扫描什么都找不到?

I2C 从来不去驱动线变高 —— 做这件事的是上拉电阻,所以那个高电平是上拉坐在哪条电源轨上就是多少。这款转接板低电压轨的那个版本,总线空闲时停在大约 2 V,而 5 V 的 Arduino Uno 至少要 3.0 V 才认得出高电平,于是它一直读到低电平、什么都不回报。把 SDA 对 GND 量一下就能确认。

这个传感器该装哪一个 Arduino 库?

先读寄存器 0xFF0x11 是 MAX30100 —— 装 OXullo Intersecans 的 MAX30100lib。0x15 是 MAX30102 或 MAX30105 —— 装 SparkFun MAX3010x Pulse and Proximity Sensor Library。两个库遇到不对的芯片都会拒绝,然后把它报成接线故障。

MAX3010x 模块出货时排针焊好了吗?

没有 —— 它出货是五个裸的镀通孔,另附一条松着的五针排针。这五个焊点自己焊,或者加购我们的五针代焊服务;那项服务是先咨询后下单的,加进购物车之前请先跟我们说一声。

一块 Arduino Uno 能同时跑两款传感器吗?

可以。脉搏传感器用一支模拟输入,MAX3010x 用 A4 和 A5,所以它们不会撞在一起。透过迷你面包板共用 5 V 和 GND,一条 USB 线就带得动两个。

最后更新:2026年8月。有问题?WhatsApp 联系我们

Leave a Reply

Your email address will not be published. Required fields are marked *