基于Arduino与超声波传感器的俯卧撑计数器:从硬件搭建到状态机算法实现
2026/8/27 7:53:37 网站建设 项目流程

1. 俯卧撑计数器的核心价值与实现思路

如果你和我一样,是个喜欢在家锻炼但又容易走神的人,那么“今天到底做了多少个俯卧撑”这个问题,大概率会成为你健身路上的一个小困扰。数着数着,思绪可能就飘到了晚饭吃什么,或者刚才那个动作标不标准,结果就是计数混乱,训练效果大打折扣。为了解决这个痛点,我决定动手做一个简单、可靠且成本极低的俯卧撑计数器。核心思路非常直观:利用超声波传感器来检测身体起伏的距离变化,通过Arduino进行逻辑判断,从而实现自动计数。

这个项目听起来可能有点“极客”,但实际做起来,你会发现它融合了硬件搭建、传感器原理和基础编程逻辑,是一个绝佳的入门级创客项目。它不依赖复杂的图像识别或昂贵的设备,仅需一块最常见的Arduino开发板(比如Uno)、一个HC-SR04超声波模块、一个蜂鸣器或LED,以及几根杜邦线,总成本可能不超过50元。其核心原理在于,超声波传感器会持续测量其正前方物体(也就是你的胸口或头部)的距离。当你做俯卧撑时,身体会规律性地靠近和远离地面传感器,这个距离值就会形成一个周期性波形。我们的任务,就是编写程序,让Arduino智能地识别出每一个完整的“下降-上升”周期,并把它计为一次有效的俯卧撑。

相比于手机APP依赖加速度传感器或摄像头方案,这个超声波方案有几个独特优势:首先是抗干扰性强,它只关心距离,不受手臂摆动、手机放置角度的影响;其次是响应速度快,几乎没有延迟;最后是隐私性好,它不涉及任何图像或视频数据。接下来,我将从硬件选型、电路连接、核心算法设计,到代码调试、参数校准以及实际使用中的各种“坑”,为你完整拆解这个项目的每一个环节。

2. 硬件选型与电路连接:构建可靠的感知基础

一个稳定的硬件平台是所有功能实现的前提。在这个项目中,我们的硬件清单非常精简,但每一件都至关重要。

2.1 核心控制器:为什么是Arduino Uno?

对于此类传感器数据采集与逻辑控制项目,Arduino Uno几乎是无可争议的首选。它基于ATmega328P微控制器,拥有14个数字I/O口(其中6个支持PWM)和6个模拟输入口,性能对于读取超声波传感器和驱动一个提示设备绰绰有余。更重要的是,其庞大的社区和丰富的库资源,意味着你遇到的绝大多数问题都能找到现成的解决方案。像ESP32这类功能更强大的板子当然也可以用,但对于这个单纯的项目来说,Uno的简单、稳定和极低的学习门槛是更大的优势。准备好你的Uno板、USB数据线以及用于供电的5V电源(在调试阶段,USB供电即可)。

2.2 距离感知核心:HC-SR04超声波传感器详解

HC-SR04是目前最流行、性价比最高的超声波测距模块。它的工作原理是“回声定位”:触发引脚(Trig)收到一个至少10微秒的高电平脉冲后,模块会自动发射8个40kHz的超声波脉冲,然后监听回波。当接收到反射回来的声波时,回声引脚(Echo)会输出一个高电平脉冲,该脉冲的宽度与超声波往返的时间成正比。

这里有一个关键计算:声音在空气中的速度约为340米/秒(即0.034厘米/微秒)。距离(厘米)= (高电平时间 * 0.034) / 2。除以2是因为时间是往返的。HC-SR04的测量范围官方标称是2cm到400cm,但实际有效且稳定的距离通常在3cm到200cm之间。对于俯卧撑场景,我们一般将传感器放置在练习者胸口正下方的地面上,测量距离在10cm到30cm之间变化,完全在其最佳工作范围内。

2.3 反馈装置:蜂鸣器与LED的取舍

系统需要给用户一个明确的反馈,通常是在计数成功时发出“滴”的一声或在屏幕上显示。这里我推荐使用有源蜂鸣器。它与无源蜂鸣器的区别在于,有源蜂鸣器内部集成了振荡电路,只要通电就会以固定频率发声,驱动简单(直接给高电平即可);而无源蜂鸣器需要外部提供PWM信号才能发声,可以控制音调,但驱动稍复杂。对于简单的提示音,有源蜂鸣器是最佳选择。当然,你也可以用一个LED来代替,计数时闪烁一下,但在激烈运动时,声音提示的感知度远高于视觉提示。

2.4 电路连接图与接线要点

现在,让我们把它们连接起来。请务必在断电状态下操作。

  1. Arduino Uno 5V引脚->HC-SR04 VCC引脚。为传感器供电。
  2. Arduino Uno GND引脚->HC-SR04 GND引脚,以及蜂鸣器负极(短脚)。共地是关键。
  3. Arduino Uno 数字引脚 9->HC-SR04 Trig引脚。用于发送触发信号。
  4. Arduino Uno 数字引脚 10->HC-SR04 Echo引脚。用于读取回波信号。
  5. Arduino Uno 数字引脚 8->蜂鸣器正极(长脚)。用于控制蜂鸣器发声。

注意:HC-SR04的Echo引脚输出是5V电平,而Arduino Uno的I/O口可以承受5V输入,所以可以直接连接。如果你使用的是3.3V逻辑的板子(如某些ESP32开发板),则需要在Echo信号线上加一个电平转换电路或分压电阻,以防损坏IO口。

连接完成后,硬件部分就准备好了。你可以把它放在一块面包板上,或者直接焊接在一个小洞洞板上,这样更牢固,适合实际运动环境。

3. 核心算法设计:如何让Arduino“看懂”一个俯卧撑

硬件是躯体,算法才是灵魂。让Arduino从一连串的距离数据中准确识别出一次俯卧撑,是整个项目最核心也最有趣的部分。你不能简单地设定“距离小于20cm就计数”,因为人体在最低点会有停顿,传感器读数也会波动,这会导致重复计数或漏计。

3.1 状态机:化连续动作为离散状态

最可靠的思路是引入“状态机”的概念。我们可以将一次俯卧撑抽象为三个状态:

  • 状态0(高位/就绪):身体处于最高点(起始位置)。此时距离读数较大(例如 > 25cm)。
  • 状态1(下降中):检测到距离持续减小,并穿过一个预设的“高位阈值”(例如 20cm)。这意味着用户开始下降身体。
  • 状态2(低位/撑起中):身体到达最低点附近。此时距离读数较小(例如 < 15cm)。当检测到距离开始增加,并穿过一个预设的“低位阈值”(例如 18cm)时,认为用户开始撑起。
  • 完成计数:当状态从2成功返回到0(即距离再次大于高位阈值),则认为完成了一次完整的俯卧撑,计数器加一。

这种状态机模型能有效过滤掉中间的抖动。比如,在最低点轻微晃动,距离值可能在14cm和16cm之间波动,但只要没超过“低位阈值”并开始持续上升,就不会触发状态转换,从而避免了误计数。

3.2 阈值校准与动态适应

“高位阈值”和“低位阈值”不是固定值,它们需要根据用户的身高、臂长以及传感器放置的具体位置进行校准。一个更好的方案是加入“自动校准”功能。在程序启动后的前几秒,让用户保持最高点姿势(平板支撑状),Arduino连续采样距离值,并计算其平均值作为“初始高位参考值”。然后,根据这个参考值动态设置阈值。例如:

  • 高位阈值 = 初始高位参考值 - 5cm
  • 低位阈值 = 初始高位参考值 - 12cm (这个差值大致对应胸部离地5-10cm的高度)

这样,无论高个子还是矮个子,设备都能自适应,大大提升了普适性。

3.3 软件消抖与数据平滑

传感器读数存在固有的噪声。直接使用原始数据会导致状态判断不稳定。因此,我们需要对数据进行平滑处理。最简单有效的方法是使用“移动平均滤波”。即维护一个包含最近N次读数的小数组,每次取平均值作为有效距离。例如:

const int numReadings = 5; // 采样窗口大小 int readings[numReadings]; // 读数数组 int readIndex = 0; long total = 0; int averageDistance = 0; // 在每次循环中 total = total - readings[readIndex]; // 减去最旧的读数 readings[readIndex] = getCurrentDistance(); // 读取新距离 total = total + readings[readIndex]; // 加上最新读数 readIndex = (readIndex + 1) % numReadings; // 移动索引 averageDistance = total / numReadings; // 计算平均值

使用这个averageDistance来进行状态判断,系统的抗干扰能力会显著增强。

4. 代码实现与逐行解析

有了清晰的算法思路,我们就可以开始编写Arduino代码了。下面我将提供一份完整的、带有详细注释的代码,并解释关键部分。

/* * 俯卧撑计数器 - 基于Arduino Uno & HC-SR04 * 引脚定义: * Trig - 引脚9 * Echo - 引脚10 * Buzzer - 引脚8 */ // 引脚定义 const int trigPin = 9; const int echoPin = 10; const int buzzerPin = 8; // 状态机变量 enum PushUpState { READY, DOWN, UP }; PushUpState currentState = READY; // 计数与阈值 int pushUpCount = 0; float highThreshold = 25.0; // 初始高位阈值,单位cm float lowThreshold = 15.0; // 初始低位阈值,单位cm float calibrationBase = 0.0; // 校准基准值 bool isCalibrated = false; const int calibrationSamples = 50; // 校准采样次数 // 滤波相关 const int filterSize = 7; float distanceBuffer[filterSize]; int bufferIndex = 0; // 超声波测距函数 float getDistanceCM() { digitalWrite(trigPin, LOW); delayMicroseconds(2); digitalWrite(trigPin, HIGH); delayMicroseconds(10); // 发送10微秒高脉冲触发 digitalWrite(trigPin, LOW); long duration = pulseIn(echoPin, HIGH, 30000); // 等待高电平脉冲,超时30ms(约5米) // 距离 = (声速 * 时间) / 2 float distance = duration * 0.034 / 2.0; // 如果超时或距离异常,返回一个很大的值 if (distance <= 0 || distance > 200) { return 999.0; } return distance; } // 移动平均滤波函数 float getFilteredDistance() { float rawDist = getDistanceCM(); distanceBuffer[bufferIndex] = rawDist; bufferIndex = (bufferIndex + 1) % filterSize; float sum = 0; int validCount = 0; for (int i = 0; i < filterSize; i++) { if (distanceBuffer[i] < 200) { // 只累加有效数据 sum += distanceBuffer[i]; validCount++; } } if (validCount == 0) return 999.0; return sum / validCount; } // 蜂鸣器提示函数 void beep(int durationMs) { digitalWrite(buzzerPin, HIGH); delay(durationMs); digitalWrite(buzzerPin, LOW); } // 校准函数:在开始时,用户保持高位姿势 void calibrate() { Serial.println("请保持俯卧撑起始姿势(高位),正在校准..."); delay(2000); // 给用户准备时间 float sum = 0; for (int i = 0; i < calibrationSamples; i++) { float d = getDistanceCM(); if (d < 200) { sum += d; } delay(50); } calibrationBase = sum / calibrationSamples; highThreshold = calibrationBase - 5.0; // 下降5cm认为开始下降 lowThreshold = calibrationBase - 12.0; // 下降12cm认为到达低位 isCalibrated = true; Serial.print("校准完成!基准值:"); Serial.print(calibrationBase); Serial.print("cm, 高阈值:"); Serial.print(highThreshold); Serial.print("cm, 低阈值:"); Serial.println(lowThreshold); beep(200); delay(100); beep(200); // 校准完成提示音 } void setup() { Serial.begin(9600); pinMode(trigPin, OUTPUT); pinMode(echoPin, INPUT); pinMode(buzzerPin, OUTPUT); // 初始化滤波缓冲区 for (int i = 0; i < filterSize; i++) { distanceBuffer[i] = 999.0; } calibrate(); // 上电自动校准 Serial.println("俯卧撑计数器就绪!"); } void loop() { if (!isCalibrated) { return; // 未校准不执行 } float currentDist = getFilteredDistance(); if (currentDist > 150) { // 过滤无效数据 return; } // 状态机逻辑判断 switch (currentState) { case READY: if (currentDist < highThreshold) { // 距离低于高阈值,进入下降状态 currentState = DOWN; Serial.println("状态:下降中"); } break; case DOWN: if (currentDist < lowThreshold) { // 距离低于低阈值,到达最低点附近,进入上升状态 currentState = UP; Serial.println("状态:到达低位,开始撑起"); } break; case UP: if (currentDist > highThreshold) { // 距离回升至高阈值以上,完成一次俯卧撑 currentState = READY; pushUpCount++; Serial.print("完成!当前计数:"); Serial.println(pushUpCount); beep(100); // 计数成功提示音 } break; } // 每秒输出一次当前距离和状态,便于调试 static unsigned long lastPrintTime = 0; if (millis() - lastPrintTime > 1000) { lastPrintTime = millis(); Serial.print("距离:"); Serial.print(currentDist); Serial.print("cm | 状态:"); Serial.print(currentState); Serial.print(" | 计数:"); Serial.println(pushUpCount); } delay(50); // 主循环延迟,控制检测频率 }

代码关键点解析:

  1. 枚举类型定义状态:使用enum定义READY,DOWN,UP三种状态,使程序逻辑清晰易读。
  2. 脉冲测量与超时处理pulseIn(echoPin, HIGH, 30000)中的30000微秒超时参数很重要。如果超出测量范围或没有回波,函数会返回0,避免程序卡死。我们据此返回一个无效值(999.0)。
  3. 校准流程calibrate()函数在setup()中自动调用。它采集50个样本求平均,作为用户个人高位基准,并据此推导出两个动态阈值。这省去了手动修改代码的麻烦。
  4. 滤波缓冲区初始化:在setup()中,将滤波缓冲区初始化为999.0,防止初始阶段的无效数据影响平均值。
  5. 状态机切换逻辑:这是核心。注意,从UP状态回到READY状态时,才增加计数并发出提示音。这确保了一个“下降-上升”的完整周期才被记录。
  6. 调试信息输出:通过串口每秒输出一次距离、状态和计数,在开发阶段极其有用。你可以打开Arduino IDE的串口监视器,实时观察传感器数据和状态变化,这对于参数微调和故障排查至关重要。

5. 调试、校准与实战中的避坑指南

代码上传成功后,真正的挑战才刚刚开始。如何让它在你千奇百怪的俯卧撑姿势下稳定工作?以下是来自实战的宝贵经验。

5.1 传感器放置的“黄金位置”

这是影响精度最关键的因素。不要把传感器正对着你的头部。因为做俯卧撑时,头部可能会左右转动或上下移动,轨迹不规律。最佳位置是正对胸口中央。可以将传感器稍微向后(脚的方向)放置一点,测量胸口到地面的垂直距离。这个距离在动作过程中的变化更稳定、更有规律。你可以用手机录下自己的动作,观察胸口起伏的轨迹,来辅助定位。

5.2 串口监视器:你的最佳调试伙伴

务必打开Arduino IDE的串口监视器(工具 -> 串口监视器,波特率设为9600)。你会看到实时输出的距离数据和状态编号。保持高位姿势,观察显示的“基准值”是否稳定(波动在1-2cm内)。然后缓慢做一个俯卧撑,观察距离值的变化曲线,以及状态(0,1,2)是否按预期切换。如果状态切换混乱,大概率是阈值设置不合理。

5.3 阈值微调:应对不同体型与姿势

自动校准是一个很好的起点,但并非万能。如果你发现:

  • 计数过多(太敏感):可能是highThreshold设置得偏高,或者lowThreshold设置得偏低,导致在中间位置反复横跳触发状态转换。可以尝试略微增大highThreshold与基准值的差值(比如从-5cm调到-6cm),或减小lowThreshold与基准值的差值(比如从-12cm调到-11cm),让状态转换的“窗口”变宽。
  • 计数过少(不灵敏):情况相反。可能是阈值差值太大,身体起伏没能触发状态转换。需要减小highThreshold的差值或增大lowThreshold的差值。
  • “半程”俯卧撑的识别:有些人喜欢做半程俯卧撑。这时你需要根据半程的幅度,重新调整lowThreshold。例如,半程时胸口可能只下降8cm,那么lowThreshold就应该设为基准值 - 8

修改阈值后,无需重新校准,只需在代码开头直接修改calibrate()函数里计算highThresholdlowThreshold的公式即可。

5.4 环境干扰与错误数据处理

超声波传感器容易受到软性表面(如地毯、衣物)和复杂声学环境的影响。确保传感器前方一定范围内没有其他移动物体(如宠物、晃动的窗帘)。代码中已经通过滤波和无效数据检测(if (currentDist > 150))做了基本防护。如果环境干扰特别严重,可以考虑:

  1. 增加滤波窗口大小filterSize,但会降低响应速度。
  2. getDistanceCM()函数中,连续测量2-3次取中间值,进一步排除偶然误差。

5.5 供电稳定性与放置安全

长时间使用建议使用9V直流电源适配器通过Arduino的电源接口供电,而不是USB供电,后者可能因线缆松动导致重启。确保所有线路连接牢固,最好用扎带或胶带固定。将整个装置放在一个稳固的盒子或底座里,防止在运动中被碰倒。

6. 功能扩展与进阶玩法

一个基础的计数器已经完成,但创客的乐趣在于不断迭代和扩展。这里有几个方向可以让你的项目变得更酷、更实用。

6.1 添加视觉反馈:OLED显示屏

听觉反馈(蜂鸣器)在嘈杂环境中可能听不清。添加一块I2C接口的0.96英寸OLED显示屏(通常使用SSD1306驱动),可以实时显示计数、当前距离、状态甚至简单的进度条。接线非常方便,只需连接SDA到A4,SCL到A5,VCC到5V,GND到GND。然后在代码中引入Adafruit_SSD1306Adafruit_GFX库,初始化后,在loop()中更新显示内容即可。例如,在计数时除了蜂鸣,还可以在屏幕上显示一个放大的数字动画。

6.2 数据记录与统计分析:连接电脑或手机

如果你想分析自己的训练数据,可以将计数和时间戳通过串口发送到电脑,用Python(pyserial库)或Processing写一个小程序接收并记录到CSV文件中。更进一步,可以尝试用蓝牙模块(如HC-05/HC-06)替换串口有线连接,将数据无线发送到手机APP上。手机APP(可以用MIT App Inventor或AppCan等低代码平台快速开发)可以绘制每日/每周的俯卧撑数量曲线,计算组间休息时间,甚至设定训练计划提醒。

6.3 识别不规范动作:引入姿态判断

目前的方案只计数,不判断动作质量。一个有趣的进阶想法是使用两个超声波传感器。一个放在胸口下方(主计数器),另一个放在臀部下方。标准的俯卧撑要求身体呈一条直线,即胸口和臀部应几乎同步升降。通过比较两个传感器距离变化的同步性,可以在代码中增加判断逻辑。如果臀部抬得过高(塌腰)或下沉不够(撅屁股),传感器读数的差异会超出合理范围,此时可以触发另一种声音提示(比如长鸣),提醒动作不标准。

6.4 打造一体化训练助手:集成按键与模式切换

在面包板上增加一个按键和一个小型旋钮编码器。按键用于手动重置计数,长按进入重新校准模式。旋钮编码器用于在“训练模式”(只计数)和“挑战模式”(例如,必须在规定时间内完成一定数量)之间切换,或者用于直接调整阈值参数,无需连接电脑。这会让你的设备看起来更像一个真正的产品。

从一堆零散的元件,到最终能准确为你计数的智能设备,这个过程充满了发现问题、解决问题的乐趣。这个项目麻雀虽小,却涵盖了创客项目的完整流程:需求分析、硬件选型、电路搭建、算法设计、编程实现、调试优化。它最宝贵的价值不在于计数本身,而在于你亲手将想法变为现实的能力。当你第一次听到蜂鸣器随着你的俯卧撑动作清脆鸣响时,那种成就感是无可替代的。希望这份详细的指南能帮你顺利走完这个过程,甚至激发你更多的改造灵感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询