1. 项目缘起:当心率传感器遇上微型显示屏
最近在捣鼓一些可穿戴设备原型,核心需求是想实时、直观地看到自己的心率数据。市面上成品的心率手环当然方便,但作为一个喜欢折腾的硬件爱好者,我更想自己动手,从传感器读数开始,一步步把数据“可视化”出来。这个过程中,我选择了两个核心组件:一个模拟输出的Pulse Sensor(脉搏传感器)和一块小巧的0.96英寸OLED显示屏。为了让连接更简洁,避免面包板上飞线如麻的窘境,我决定采用TinyCircuits的Wirelings生态系统。这个项目,就是关于如何将它们三者有机结合,打造一个便携、直观的心率监测显示模块。
Pulse Sensor是一款非常经典的光电容积脉搏波(PPG)传感器,它通过LED照射皮肤,并用光电探测器接收反射光,由于血液流动会吸收部分光线,因此反射光的强度会随心跳周期性地微弱变化。传感器将这个模拟信号输出,我们通过微控制器的ADC(模数转换器)引脚读取,再经过一定的算法处理,就能提取出心率值。而OLED显示屏,以其自发光、高对比度、低功耗的特性,成为便携设备显示的绝佳选择,尤其适合在环境光下清晰展示数字和波形。
那么,为什么选择Wirelings?传统上,我们将传感器和显示屏连接到Arduino或其他主控板时,需要连接多条线(电源、地、数据线),不仅容易接错,而且整个结构显得臃肿,不适合嵌入到紧凑的原型中。Wirelings系统提供了一种微型化的连接方案,每个模块(传感器、显示屏等)都集成在一个微小的PCB上,并通过一个统一的、类似“菊链”的4针接口(包含I2C、电源和地)进行连接。你只需要一个Wirelings适配器板,将其插在主控板(如Arduino Zero、TinyDuino)上,然后其他Wirelings模块就可以像堆叠乐高积木一样,通过微型连接线串联起来,极大地简化了布线和物理结构。
这个项目的目标很明确:搭建一个硬件上简洁可靠、软件上能稳定计算并显示心率(BPM)和脉搏波形的系统。它不仅是学习模拟信号处理、I2C通信和嵌入式显示的好案例,其成果本身也可以作为后续健康监测项目的数据采集前端,或者一个独立的桌面心率监测小工具。接下来,我将从硬件选型与连接、核心算法解析、代码实现与优化,以及实际搭建与调试心得四个方面,详细拆解整个过程。
2. 硬件选型、连接与电路解析
硬件是整个项目的基础,选择正确的组件并理解其连接方式,是成功的第一步。这里的关键在于确保信号通路清晰、电源稳定,并且物理结构紧凑。
2.1 核心组件深度剖析
首先,我们来看看三位“主角”:
Pulse Sensor (Wirelings版本):这不是普通的Pulse Sensor,而是TinyCircuits为其Wirelings生态系统定制的版本。它核心的PPG传感器部分与常见的模拟Pulse Sensor相同,但输出接口被集成到了Wirelings的4针接口中。关键点在于,它输出的仍然是模拟信号。在Wirelings接口定义中,这通常意味着它使用了4针中的某一根线作为模拟信号输出(例如,标为“A”的引脚),而不是数字I2C信号。购买或使用时,务必确认其引脚定义。
OLED Display (Wirelings版本):我选用的是0.96英寸、128x64分辨率的SSD1306驱动芯片的OLED屏,并且是Wirelings接口的。SSD1306通常通过I2C或SPI通信,Wirelings版本几乎无一例外采用I2C,因为Wirelings的4针接口天然包含I2C的SDA和SCL线。I2C通信节省引脚,软件库成熟,是首选。
Wirelings Adapter (适配器板):这是连接主控板与Wirelings模块的桥梁。它本身也是一个Wirelings模块,一端是Wirelings接口,另一端是扩展排针,可以插到主控板(如Arduino Uno, Zero, TinyDuino)的对应引脚上。适配器板会将Wirelings接口的4条线(VCC, GND, SDA, SCL)映射到主控板的特定引脚。这里有一个至关重要的细节:你需要根据你所用的主控板型号,选择正确的适配器板,或者手动确认引脚映射关系。例如,TinyCircuits的适配器板可能默认将SDA/SCL映射到Arduino的A4/A5(Uno的I2C引脚),但如果你用的是ESP32开发板,其I2C引脚可能不同,就需要使用通用适配器并自行接线。
2.2 连接拓扑与电源考量
连接方式非常直观,得益于Wirelings的“菊链”设计:主控板->Wirelings适配器板->Pulse Sensor Wirelings->OLED Display Wirelings。
所有模块通过细小的4针线缆顺序连接。从适配器板出来的VCC和GND为整个链路上的模块供电,SDA和SCL则为I2C设备(这里是OLED屏)提供通信总线。Pulse Sensor作为模拟设备,它只使用VCC、GND和其专用的模拟信号线(假设是“A”线)。
电源稳定性警告:OLED屏在点亮像素,尤其是显示全白画面时,瞬时电流可能较大。而Pulse Sensor内部的LED也需要驱动。如果使用USB供电,一般问题不大。但如果使用电池供电,特别是小型锂聚合物电池,需要注意电压跌落。建议在VCC和GND之间并联一个100μF或更大的电解电容,作为电源轨的“蓄水池”,平滑可能出现的电流峰值,防止系统复位或传感器读数异常。
2.3 主控板的选择与引脚映射
虽然Wirelings简化了模块间连接,但主控板的选择决定了你的编程环境和性能上限。对于这个项目,推荐以下几类:
- Arduino Zero/M0系列:这是与TinyCircuits生态系统兼容性非常好的选择。它们基于ARM Cortex-M0+内核,具有足够的处理能力进行实时的心率算法计算,并且通常具有多个ADC通道。使用官方Wirelings适配器时,引脚映射是预设好的,最省心。
- ESP32系列:如果你希望未来增加蓝牙/Wi-Fi功能,将心率数据无线发送到手机或服务器,ESP32是强力候选。你需要一个通用的Wirelings适配器板,然后手动将它的VCC、GND、SDA、SCL连接到ESP32开发板的对应引脚。例如,ESP32的默认I2C引脚是GPIO21 (SDA)和GPIO22 (SCL)。Pulse Sensor的模拟输出线则需要连接到一个ADC引脚,如GPIO34。
- TinyDuino:这是TinyCircuits自家的超小型主控板,与Wirelings是天作之合,可以构建极其紧凑的系统。但需要注意其处理器性能和IO数量。
关键操作:确认模拟引脚编号。在代码中,你需要指定读取Pulse Sensor的模拟引脚。这个引脚号不是Wirelings接口上的“A”线编号,而是该“A”线最终被适配器板映射到主控板上的那个具体ADC引脚编号。例如,使用Arduino Uno和特定适配器时,“A”线可能被映射到模拟引脚A0。你必须查阅适配器板或主控板的文档来确定这个数字。这是硬件连接中最容易出错的一步。
3. 心率算法核心:从噪声中捕捉心跳
硬件搭建好后,最核心的挑战在于软件:如何从那个充满噪声的、微弱的模拟信号中,准确计算出心率?Pulse Sensor输出的原始信号可以大致分为三部分:直流分量(由组织、骨骼等非脉动部分反射形成)、交流分量(由动脉血液脉动形成,即我们需要的信号),以及各种高频噪声(环境光变化、运动伪影、电路噪声等)。
3.1 信号处理流程拆解
一个稳健的心率检测算法通常包含以下步骤,我将在代码实现部分给出具体示例:
采样与缓存:以稳定的频率(如100Hz至500Hz)读取ADC值。需要一个数组(环形缓冲区)来存储最近一两秒的原始采样数据,用于后续处理。
直流分量去除与放大:这是关键一步。首先计算缓存数据一段时间(如1秒)的平均值,这个平均值近似代表直流分量。然后将每个新的采样值减去这个动态更新的平均值。这样,我们就得到了一个围绕零点波动的交流信号。接着,可以对这个信号乘以一个系数进行放大,使其幅值更易于观察和处理。
// 伪代码示例 long runningTotal = 0; int sampleBuffer[BUFFER_SIZE]; // 环形缓冲区 int bufferIndex = 0; // 每次采样后 int rawValue = analogRead(PULSE_PIN); runningTotal -= sampleBuffer[bufferIndex]; // 减去最旧的值 runningTotal += rawValue; // 加上最新的值 sampleBuffer[bufferIndex] = rawValue; bufferIndex = (bufferIndex + 1) % BUFFER_SIZE; int average = runningTotal / BUFFER_SIZE; // 动态平均值 int normalized = rawValue - average; // 去除直流 int amplified = normalized * 10; // 放大(系数需根据实测调整)低通滤波:为了抑制高频噪声(特别是50/60Hz的工频干扰),需要对放大后的信号进行低通滤波。一个简单有效的方法是使用一阶无限脉冲响应(IIR)低通滤波器,它计算简单,适合嵌入式系统。
// 一阶IIR低通滤波器 float alpha = 0.1; // 平滑系数,越小滤波越强,但延迟越大 static float filteredValue = 0; filteredValue = (alpha * amplified) + ((1 - alpha) * filteredValue);选择合适的
alpha值需要在平滑度和响应速度之间权衡。对于心率信号(通常小于5Hz),一个0.05到0.2的alpha值通常比较合适。峰值检测:在滤波后的波形上检测波峰(即每次心跳对应的点)。算法需要避免噪声引起的误触发。常见的策略包括:
- 阈值法:设定一个动态阈值。只有当信号值超过该阈值,并且是一个局部最大值(比前后若干点都高)时,才被认为是一个候选峰值。
- 阈值更新:阈值不能是固定的。它可以基于最近一段时间信号的最大值和最小值进行动态调整,例如:
threshold = (max + min) * 0.6。 - 不应期:在一次有效心跳被检测后,设置一个“不应期”(例如200-300毫秒),在此期间内忽略任何新的峰值,因为生理上不可能在如此短的时间内发生两次心跳。
心率计算:记录下连续两个有效峰值之间的时间间隔(Inter-Beat Interval, IBI),单位是毫秒。心率(BPM)的计算公式为:
BPM = 60000 / IBI。为了显示稳定,通常不会每次心跳都更新显示值,而是计算最近几次(如5-10次)IBI的平均值,再用其计算平均BPM。
3.2 应对噪声与运动伪影的实战技巧
在实际佩戴,尤其是轻微运动时,信号质量会急剧下降。除了上述的滤波算法,还有一些软硬件技巧:
- 硬件固定:确保传感器紧密、舒适地贴合皮肤(通常指腹或耳垂效果较好),避免环境光直射。可以使用不透光的胶带或定制一个遮光罩。
- 软件容错:在算法中加入“信号质量”评估。例如,计算最近一段时间波形的幅度(最大值-最小值)。如果幅度太小,说明信号太弱;如果IBI变化过于剧烈(如从500ms突然跳到200ms),则很可能是误检测,应丢弃该次结果。
- 状态指示:在OLED屏上,除了显示BPM数字,最好增加一个简单的信号质量指示,比如一个跳动的小点或者“Signal OK”/“No Signal”的文字提示,让用户知道当前读数是否可靠。
4. 代码实现:驱动OLED与整合算法
有了算法思路,我们需要用代码将其实现,并驱动OLED屏进行显示。这里以使用Arduino IDE和常见的库为例。
4.1 库的安装与初始化
首先,需要安装两个核心库:
- Adafruit SSD1306和Adafruit GFX:这是驱动SSD1306 OLED屏最广泛使用的库。可以通过Arduino库管理器搜索安装。
- Wire:Arduino内置的I2C通信库。
初始化代码如下:
#include <Wire.h> #include <Adafruit_GFX.h> #include <Adafruit_SSD1306.h> #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 #define OLED_RESET -1 // 如果屏幕有复位引脚则定义,否则用-1 #define I2C_ADDRESS 0x3C // OLED的I2C地址,常见为0x3C或0x3D Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); // Pulse Sensor 引脚定义 const int pulsePin = A0; // !!!请根据你的实际连接修改 !!! void setup() { Serial.begin(115200); // 初始化OLED if(!display.begin(SSD1306_SWITCHCAPVCC, I2C_ADDRESS)) { Serial.println(F("SSD1306 allocation failed")); for(;;); // 卡住 } display.clearDisplay(); display.setTextSize(2); display.setTextColor(SSD1306_WHITE); display.setCursor(0,0); display.println("Pulse"); display.println("Monitor"); display.display(); delay(2000); display.clearDisplay(); // 初始化用于心率算法的变量... }4.2 主循环逻辑与显示更新
主循环loop()函数需要高效地完成四件事:采样、处理、计算心率、更新显示。为了避免显示刷新(相对较慢)影响采样定时,最好将采样放在一个由定时器或精确delay控制的独立节奏中。这里用一个简单但有效的方法:
// 全局变量 unsigned long lastSampleTime = 0; const int SAMPLE_INTERVAL_MS = 10; // 100Hz采样率 int bpm = 0; bool pulseDetected = false; int rawSignal = 0; int filteredSignal = 0; void loop() { unsigned long currentTime = millis(); // 1. 定时采样 if (currentTime - lastSampleTime >= SAMPLE_INTERVAL_MS) { lastSampleTime = currentTime; rawSignal = analogRead(pulsePin); // 2. 调用信号处理函数(包含去直流、滤波) processSignal(rawSignal); // 3. 调用峰值检测函数,更新BPM detectPeak(filteredSignal); } // 4. 定期刷新显示(例如每秒2-4次,避免闪烁) static unsigned long lastDisplayUpdate = 0; if (currentTime - lastDisplayUpdate >= 250) { // 每250ms更新一次显示 lastDisplayUpdate = currentTime; updateDisplay(); } }4.3 显示界面设计
在updateDisplay()函数中,我们可以充分利用128x64的像素空间:
- 区域1(大字BPM):在屏幕中央用大字体(如
setTextSize(4))显示计算出的心率值,例如“72”。 - 区域2(波形图):在屏幕下方绘制一个实时滚动的脉搏波形图。这需要维护一个用于绘图的数值数组。每次刷新时,将整个图形向左平移一个像素,然后在最右侧绘制最新的
filteredSignal值对应的点。// 波形图绘制示例(简化) #define GRAPH_WIDTH 128 #define GRAPH_HEIGHT 30 #define GRAPH_Y_OFFSET 34 int graphData[GRAPH_WIDTH]; // 在updateDisplay()中 // 1. 平移旧数据 for (int i=0; i<GRAPH_WIDTH-1; i++) { graphData[i] = graphData[i+1]; } // 2. 存入新数据(将滤波后的信号值映射到绘图高度范围内) graphData[GRAPH_WIDTH-1] = map(constrain(filteredSignal, -512, 512), -512, 512, 0, GRAPH_HEIGHT); // 3. 清空图形区域并重绘 display.fillRect(0, GRAPH_Y_OFFSET, GRAPH_WIDTH, GRAPH_HEIGHT, SSD1306_BLACK); for (int i=0; i<GRAPH_WIDTH; i++) { display.drawPixel(i, GRAPH_Y_OFFSET + GRAPH_HEIGHT - graphData[i], SSD1306_WHITE); } - 区域3(状态指示):在屏幕顶部角落,可以画一个小心脏图标,当检测到一次心跳时,让它短暂变大或改变颜色,提供视觉反馈。或者用文字显示“稳定”、“检测中”等状态。
5. 系统集成、调试与性能优化
将所有部分组合起来后,真正的挑战才开始:让系统稳定可靠地工作。
5.1 上电调试与信号观察
首先,不要急于求成显示心率。将rawSignal和filteredSignal通过Serial.println()打印到串口绘图器(Arduino IDE自带工具)。这是最重要的调试手段。
- 将手指稳定地放在传感器上,观察串口绘图器中的波形。你应该能看到一个清晰的、周期性的波动。
- 如果波形是一条直线或噪声极大,检查硬件连接(特别是地线是否共地)、电源电压、以及传感器是否贴紧。
- 调整算法中的参数:放大倍数、低通滤波的alpha值、峰值检测的阈值和不应期。目标是让滤波后的波形平滑,峰值突出,且每次心跳只产生一个峰值。
5.2 优化计算效率与内存使用
嵌入式资源有限,优化很重要:
- 避免浮点数运算:像
alpha * amplified这样的浮点运算在8位AVR单片机(如Uno)上很慢。可以改用整数运算。例如,将alpha设为0.1,等价于filteredValue = (amplified + 9 * filteredValue) / 10。 - 使用更快的ADC:如果主控板支持(如Arduino Zero的ARM芯片),可以提高ADC采样率,并可能启用硬件均值功能来降低噪声。
- 优化显示刷新:
display.display()是全屏刷新,比较耗时。如果只更新了部分区域(如只改了BPM数字),可以考虑使用display.drawBitmap()局部更新,但这更复杂。一个折中方案是降低显示刷新率(如每秒4次),并确保在显示刷新期间不进行复杂的计算或采样。
5.3 提升心率检测的鲁棒性
基础算法在静坐时可能工作良好,但实用中需要更强健:
- 动态阈值算法升级:实现一个更聪明的阈值。例如,跟踪信号的最大值和最小值,但让它们缓慢衰减。当检测到峰值时,阈值可以设置为
(max + min) / 2或min + (max - min) * 0.6。最大值和最小值每隔一段时间(如每秒)衰减1%,以适应信号幅度的缓慢变化。 - IBI有效性检查:计算出的IBI应该在生理合理范围内(例如,对应心率40-200 BPM)。如果超出范围,则忽略这次心跳检测。同时,检查连续几次IBI的方差,如果变化过大,可以认为信号不稳定,显示上一次的有效平均值或“---”。
- 均值与平滑显示:计算BPM时,不要只使用最后一次IBI。维护一个包含最近5-10次IBI的环形缓冲区,计算其平均值后再转换为BPM。对于显示给用户的BPM数值,还可以进行一次滑动平均滤波,避免数字跳动过快。
5.4 物理封装与电源管理
为了让项目更像一个产品原型,可以考虑:
- 3D打印外壳:设计一个可以固定主控板、电池,并让传感器和OLED屏幕露出来的小盒子。这对于指戴式传感器尤其有用。
- 低功耗优化:如果使用电池,考虑在代码中实现休眠。当一段时间没有检测到心率信号时,让微控制器进入低功耗休眠模式,OLED屏幕关闭。通过触摸传感器或按钮唤醒。这需要更复杂的编程,但能极大延长续航。
经过以上步骤,你就能获得一个由自己亲手搭建、代码一行行写出来的心率监测显示模块。它不仅仅是一个显示数字的工具,更是一个融合了模拟电路知识、数字信号处理算法和嵌入式系统开发的综合实践项目。当你看到OLED屏上随着自己心跳而规律跳动的波形和数字时,那种成就感是无可替代的。这个项目也为后续更复杂的生物信号监测(如血氧饱和度)打下了坚实的基础。