Arduino双向访客计数器:基于超声波传感器与状态机的实现

📅 2026/8/19 1:31:58
Arduino双向访客计数器:基于超声波传感器与状态机的实现
1. 项目概述为什么需要一个双向访客计数器在小型零售店、工作室、图书馆或者家庭入口我们常常需要知道一个空间里当前有多少人。一个简单的单向计数器只能告诉你有人进出但无法区分是进入还是离开更无法实时显示室内人数。这就是“双向访客计数器”要解决的问题。它不仅能检测到有人通过门口还能智能判断其行进方向从而精确地增加或减少计数并在显示屏上实时更新。这个项目听起来有点复杂但用Arduino来实现其实非常有趣且富有教育意义。核心思路就是利用一对超声波传感器像两个“电子眼睛”一样一左一右安装在门框上。当有人通过时两个传感器检测到物体的时间顺序会不同通过分析这个时间差我们就能判断人是进入还是离开。整个过程不需要复杂的图像识别成本低廉逻辑清晰是学习传感器应用、状态机编程和解决实际问题的绝佳案例。我做过好几个类似的计数器用在朋友的书店和自家的健身房门口效果很稳定。接下来我会带你从零开始手把手完成这个项目包括硬件连接、代码编写、逻辑调试以及那些容易踩坑的细节。无论你是刚接触Arduino的新手还是想找一个实用项目练手的老玩家这篇内容都能给你提供一份可以直接“抄作业”的完整方案。2. 核心硬件选型与电路设计解析做电子项目第一步永远是搞清楚用什么以及为什么用这些。盲目堆料只会增加成本和复杂度。2.1 主控板为什么是Arduino Uno在众多开发板中我首选Arduino Uno。原因很简单生态成熟、资料丰富、引脚够用且驱动方便。这个项目需要处理两个传感器的数据、驱动一个LCD屏幕并实现逻辑判断。Uno的ATmega328P单片机拥有14个数字I/O口和6个模拟输入口性能完全过剩。它的5V工作电压也完美匹配我们选用的传感器和显示屏省去了电平转换的麻烦。有朋友问能不能用更便宜的Nano或者Mini当然可以它们内核相同只是封装形式不一样。但Uno的扩展板接口和稳定的USB芯片对于调试阶段频繁插拔和测试来说体验更好。对于ESP32这类功能更强的板子在这个项目里属于“大炮打蚊子”不仅增加了无线部分的复杂度在简单的传感器轮询处理上并没有显著优势。2.2 感知核心HC-SR04超声波传感器详解访客检测的核心是传感器。为什么用超声波而不是红外、激光或者摄像头成本与可靠性一对HC-SR04的价格可能不到一杯奶茶钱。它通过发射40kHz的超声波并接收回波来测距不受普通可见光影响在室内光照变化下依然稳定。原理与判断依据它的测距原理发射-接收-时间差决定了我们可以获取一个连续的距离值。当人走过时传感器前方的距离会有一个从大到小再到大的变化过程。通过捕捉这个“波形”结合两个传感器的时序就能判断方向。关键参数它的有效测距是2cm到400cm完全覆盖门道的宽度。测量角度大约15度这意味着我们需要仔细调整传感器的安装角度避免两个传感器波束相互干扰或漏检。这里有一个重要的注意事项HC-SR04的工作电压是5V但它的回波Echo引脚输出也是5V电平。虽然Arduino Uno的5V数字引脚可以耐受5V输入但为了绝对的安全和长期稳定性我建议在Echo引脚和Arduino输入引脚之间串联一个1kΩ的电阻这是一个简单的限流保护。或者你也可以使用分压电路例如两个电阻将5V分压到3.3V左右但经过我的实测直接连接在Uno上工作也是完全正常的只是从最佳实践角度给出建议。2.3 信息展示1602 LCD显示屏I2C接口版我们需要一个设备来显示“进入人数”、“离开人数”和“当前室内人数”。1602液晶屏16字符x2行是最经典的选择。但直接驱动1602需要连接至少6根线RS, EN, D4, D5, D6, D7再加上电源和背光非常占用I/O口。因此务必选择带有I2C接口转换模块的1602屏。这个小模块焊在屏幕背面通过I2C总线仅需SDA和SCL两根信号线加上VCC和GND与Arduino通信。这节省了宝贵的I/O资源也让接线变得异常清爽。购买时认准“1602 LCD I2C”字样。模块上通常有一个蓝色的小电位器用来调节屏幕对比度。2.4 电路连接图与接线表理解了每个部件现在把它们连接起来。下图清晰地展示了所有连接关系接线清单如下Arduino Uno 引脚连接至备注5V超声波传感器1 VCC, 传感器2 VCC, LCD I2C模块 VCC提供5V电源GND超声波传感器1 GND, 传感器2 GND, LCD I2C模块 GND共地数字引脚 D2传感器1 Trig 引脚触发传感器1测距数字引脚 D3传感器1 Echo 引脚接收传感器1回波数字引脚 D4传感器2 Trig 引脚触发传感器2测距数字引脚 D5传感器2 Echo 引脚接收传感器2回波模拟引脚 A4 (SDA)LCD I2C模块 SDA 引脚I2C数据线模拟引脚 A5 (SCL)LCD I2C模块 SCL 引脚I2C时钟线注意给整个系统供电时确保你的USB线或外部电源如9V电池适配器插在Uno的DC口能提供至少500mA的电流以同时驱动两个传感器和带背光的LCD屏。电脑USB口通常足够。3. 核心逻辑与状态机设计硬件是骨架软件才是灵魂。双向计数的核心逻辑是判断“先触发哪个传感器后触发哪个传感器”。我们不能简单地认为传感器被触发就立刻计数那样会因人在门口徘徊而产生大量误计数。3.1 方向判断的基本原理假设我们将两个超声波传感器并排安装在门框一侧传感器A在左室外侧传感器B在右室内侧。进入场景人从门外走进来。他会先经过传感器A靠近室外再经过传感器B靠近室内。系统检测到的顺序是A先检测到物体距离变近然后B再检测到物体。离开场景人从室内走出去。他会先经过传感器B靠近室内再经过传感器A靠近室外。系统检测到的顺序是B先检测到物体然后A再检测到物体。因此我们只需要持续监控两个传感器前方的距离当距离小于一个设定的“触发阈值”例如50厘米时认为该传感器被“激活”。然后记录下两个传感器被激活的先后顺序就能判定方向。3.2 状态机让逻辑清晰且稳定直接编写if-else来判断顺序很容易在复杂情况下比如两人同时进出、有人在门口停留产生混乱。这里我引入一个非常有效的编程思想状态机。我们把系统可能处于的情况定义为几个“状态”并规定在什么条件下从一个状态切换到另一个状态。对于这个项目我们可以定义4个状态状态0空闲- 门口无人两个传感器检测到的距离都大于触发阈值。状态1A触发- 传感器A检测到有人距离小于阈值但传感器B还未触发。此时人可能正从室外进入并刚刚越过A。状态2B触发- 传感器B检测到有人距离小于阈值但传感器A还未触发。此时人可能正从室内离开并刚刚越过B。状态3确认通过- 这是一个中间状态用于防抖和确认。当系统从状态1或状态2等到另一个传感器也被触发时就进入状态3。在状态3中我们会短暂延迟然后根据之前是哪个传感器先被触发来最终判定方向并更新计数最后回到状态0。这个状态机模型完美地处理了“触发-等待-确认”的过程避免了因传感器波动或人在门口犹豫而产生的误判。它是本项目代码逻辑的基石。3.3 防抖与阈值设定技巧超声波传感器读数是会有波动的可能因为空气扰动、被测物体表面材质等原因在临界值附近抖动。如果不处理可能会造成单次通过被误认为是多次触发。软件防抖不要用单次读数做判断。我的做法是连续读取5次距离取中值滤波。这样可以滤掉偶然的尖峰噪声。触发阈值这个值需要你根据实际安装高度和门宽来调整。安装高度建议在1米到1.2米成年人腰部高度。先用串口监视器打印出传感器读数让人正常通过观察距离最小值。将触发阈值设定为比这个最小值稍大一些的值比如人通过时最近距离是30cm阈值可以设为40-50cm留出余量。状态超时如果系统卡在状态1或状态2即只有一个传感器被触发但很长时间比如3秒另一个传感器都没反应这可能是误触发或人又退回去了。此时我们应该让状态机超时自动重置回状态0空闲而不是一直等待。4. 完整代码实现与逐行解析理论讲完了是时候上代码了。我会把完整的代码贴出来并分段详细解释每一部分的作用和编写思路。4.1 库引入与全局变量定义#include Wire.h #include LiquidCrystal_I2C.h // 定义超声波传感器引脚 #define TRIG_PIN_A 2 #define ECHO_PIN_A 3 #define TRIG_PIN_B 4 #define ECHO_PIN_B 5 // 定义LCD I2C地址通常是0x27或0x3F需要根据你的模块调整 LiquidCrystal_I2C lcd(0x27, 16, 2); // (地址 列数 行数) // 全局变量 long duration_A, distance_A; long duration_B, distance_B; int triggerThreshold 50; // 触发距离阈值单位厘米根据实际情况调整 unsigned long lastTriggerTime 0; const unsigned long debounceTime 100; // 防抖时间单位毫秒 const unsigned long stateTimeout 3000; // 状态超时时间单位毫秒 // 访客计数 int inCount 0; int outCount 0; int currentCount 0; // 当前室内人数假设初始为0 // 状态机变量 enum SystemState { IDLE, TRIGGERED_A, TRIGGERED_B, CONFIRMED }; SystemState currentState IDLE; SystemState firstTrigger; // 记录首次触发的是A还是B#include引入必要的库。Wire.h是Arduino的I2C通信库LiquidCrystal_I2C.h是驱动I2C LCD的库你需要通过Arduino IDE的库管理器提前安装。#define用宏定义引脚提高代码可读性和可维护性。想换引脚改这里就行。LiquidCrystal_I2C lcd(...)初始化LCD对象。这里的地址0x27是关键如果屏幕不亮很可能是地址不对。可以使用一个简单的I2C扫描程序来查找正确地址。全局变量定义了存储传感器数据的变量、触发阈值、防抖和时间戳。inCount,outCount,currentCount分别记录进入、离开和当前人数。枚举类型SystemState这就是我们前面设计的状态机用枚举定义四个状态比用数字0,1,2,3更直观。currentState保存当前状态firstTrigger用于在确认状态时回忆是谁先被触发的。4.2 初始化设置setup()void setup() { Serial.begin(9600); // 初始化串口用于调试 // 初始化超声波传感器引脚 pinMode(TRIG_PIN_A, OUTPUT); pinMode(ECHO_PIN_A, INPUT); pinMode(TRIG_PIN_B, OUTPUT); pinMode(ECHO_PIN_B, INPUT); // 初始化LCD lcd.init(); lcd.backlight(); // 打开背光 lcd.clear(); lcd.setCursor(0, 0); lcd.print(Visitor Counter); lcd.setCursor(0, 1); lcd.print(Init...); delay(2000); lcd.clear(); // 初始显示 updateDisplay(); }setup()函数在设备上电时运行一次。这里完成了三件事启动串口调试这是最重要的调试工具后面我们会用它来看传感器原始数据。配置引脚模式Trig引脚是输出用于发射超声波Echo引脚是输入用于接收回波。初始化LCD启动屏幕打开背光显示一个启动信息然后清屏并调用updateDisplay()函数显示初始计数都是0。4.3 核心功能函数测距与显示在进入主循环前我们先封装两个会被反复调用的函数。// 函数获取指定传感器的距离中值滤波 long getDistance(int trigPin, int echoPin) { long readings[5]; for (int i 0; i 5; i) { digitalWrite(trigPin, LOW); delayMicroseconds(2); digitalWrite(trigPin, HIGH); delayMicroseconds(10); digitalWrite(trigPin, LOW); duration pulseIn(echoPin, HIGH, 30000); // 设置超时30ms约5米 readings[i] duration * 0.034 / 2; // 计算距离厘米 delay(10); // 短暂延迟防止连续触发 } // 简单排序取中值 for (int i 0; i 4; i) { for (int j i1; j 5; j) { if (readings[i] readings[j]) { long temp readings[i]; readings[i] readings[j]; readings[j] temp; } } } return readings[2]; // 返回中值 } // 函数更新LCD显示 void updateDisplay() { lcd.clear(); lcd.setCursor(0, 0); lcd.print(In:); lcd.print(inCount); lcd.print( Out:); lcd.print(outCount); lcd.setCursor(0, 1); lcd.print(Inside:); lcd.print(currentCount); }getDistance()函数这是超声波测距的标准流程。digitalWrite(trigPin, LOW); delayMicroseconds(2); ...这段代码是传感器手册要求的触发序列。pulseIn(echoPin, HIGH, 30000)函数会等待Echo引脚变高并测量其高电平持续时间即超声波往返时间第三个参数30000是超时时间微秒防止无人时无限等待。最后用声速约340米/秒即每微秒0.034厘米公式计算距离。我在这里加入了中值滤波连续测5次排序后取中间值返回有效抑制噪声。updateDisplay()函数负责在LCD的两行上格式化显示三个计数。每次计数更新后都要调用它。4.4 主循环loop()与状态机实现这里是整个项目的逻辑中枢它不断循环驱动状态机运转。void loop() { // 1. 读取两个传感器的距离带滤波 distance_A getDistance(TRIG_PIN_A, ECHO_PIN_A); distance_B getDistance(TRIG_PIN_B, ECHO_PIN_B); // 调试输出可选完成后可注释掉 Serial.print(A: ); Serial.print(distance_A); Serial.print( cm | B: ); Serial.print(distance_B); Serial.println( cm); // 2. 状态机逻辑处理 switch (currentState) { case IDLE: handleIdleState(); break; case TRIGGERED_A: handleTriggeredAState(); break; case TRIGGERED_B: handleTriggeredBState(); break; case CONFIRMED: handleConfirmedState(); break; } // 3. 更新显示 updateDisplay(); delay(50); // 主循环延迟控制检测频率 }主循环清晰分为三步读取数据、状态机处理、更新显示。状态机的具体行为被封装到了四个对应的处理函数中这让loop()函数非常简洁。下面我们看看这四个状态处理函数。void handleIdleState() { // 在空闲状态检查是否有传感器被触发 if (distance_A triggerThreshold distance_B triggerThreshold) { // 只有A被触发可能有人进入 currentState TRIGGERED_A; firstTrigger TRIGGERED_A; lastTriggerTime millis(); // 记录进入此状态的时间 Serial.println(State: - TRIGGERED_A); } else if (distance_B triggerThreshold distance_A triggerThreshold) { // 只有B被触发可能有人离开 currentState TRIGGERED_B; firstTrigger TRIGGERED_B; lastTriggerTime millis(); Serial.println(State: - TRIGGERED_B); } // 如果两个同时小于阈值比如人正站在中间我们暂时不处理避免误判 } void handleTriggeredAState() { // 之前是A被触发现在检查B是否也被触发 if (distance_B triggerThreshold) { // B也被触发确认是一次进入动作 currentState CONFIRMED; Serial.println(State: TRIGGERED_A - CONFIRMED (In)); } else if (millis() - lastTriggerTime stateTimeout) { // 超时认为是误触发或人退回重置状态 currentState IDLE; Serial.println(State: TRIGGERED_A - IDLE (Timeout)); } // 如果B一直没触发也没超时就保持在这个状态等待 } void handleTriggeredBState() { // 与TRIGGERED_A逻辑对称 if (distance_A triggerThreshold) { // A也被触发确认是一次离开动作 currentState CONFIRMED; Serial.println(State: TRIGGERED_B - CONFIRMED (Out)); } else if (millis() - lastTriggerTime stateTimeout) { currentState IDLE; Serial.println(State: TRIGGERED_B - IDLE (Timeout)); } } void handleConfirmedState() { // 根据首次触发的是哪个传感器判断方向并更新计数 if (firstTrigger TRIGGERED_A) { // A先B后表示进入 inCount; currentCount; Serial.println(Action: Person IN); } else if (firstTrigger TRIGGERED_B) { // B先A后表示离开 outCount; currentCount--; if (currentCount 0) currentCount 0; // 防止人数为负 Serial.println(Action: Person OUT); } // 短暂延迟让人完全通过检测区域防止连续触发 delay(300); // 重置状态机 currentState IDLE; Serial.println(State: CONFIRMED - IDLE); }这就是状态机的具体实现。每个函数只负责处理在特定状态下该做什么。handleIdleState()负责发现“第一个”被触发的传感器handleTriggeredXState()负责等待“第二个”传感器被触发并处理超时handleConfirmedState()则在确认通过后执行最终的计数更新并延时一小段时间让人完全通过后回到空闲状态。串口输出信息 (Serial.println) 在调试阶段极其有用你可以清晰地看到状态是如何流转的。5. 安装、调试与优化实战指南代码烧录进去只是成功了一半。物理安装和现场调试才是项目成功的关键这里面的坑最多。5.1 传感器安装的“玄学”安装不当是导致计数不准的首要原因。记住以下几个原则高度与角度传感器安装高度在1-1.2米为宜平行于地面安装。千万不要让两个传感器面对面安装它们的声波会互相干扰。应该让它们并排朝同一个方向检测就像两只眼睛一起看门口一样。安装时稍微向外侧倾斜一点比如5-10度让它们的检测区域在门口中央形成一个重叠区这样能更好地捕捉通过的人体。间距两个传感器之间的水平距离建议在15-25厘米。太近容易同时触发难以区分顺序太远则可能在人体较瘦时漏检。固定与减震用热熔胶或螺丝将传感器牢固地固定在门框上。避免晃动晃动会导致距离读数剧烈跳变。环境干扰超声波会被柔软的布料吸收也会被光滑的镜面反射到奇怪的方向。确保传感器前方没有悬挂的窗帘、海报等软质物体。地面如果是光滑瓷砖也可能造成声波反射干扰可以尝试在传感器下方的地面贴一小块地毯。5.2 系统调试三步法调试时一定要有耐心一步一步来。第一步单元测试 - 验证传感器和LCD先写一个简单的程序只让一个传感器工作通过串口监视器打印其距离读数。用手在传感器前移动观察读数变化是否平滑、范围是否合理比如10cm到200cm。同样方法测试另一个传感器。再写一个程序只测试LCD看看能否正常显示你想要的文字。这一步确保每个硬件个体都是好的。第二步逻辑测试 - 验证状态机上传完整代码打开串口监视器。用手模拟人通过先靠近A再靠近B。观察串口输出的状态变化是否和你预期一致IDLE - TRIGGERED_A - CONFIRMED - IDLE并打印“Person IN”。反向测试先B后A应打印“Person OUT”。测试超时功能只触发A然后等待3秒以上看状态是否超时重置回IDLE。第三步集成测试与阈值微调让真人以正常速度步行通过。观察计数是否准确。最常见的两个问题及调整漏检人通过了但没计数。可能原因是触发阈值triggerThreshold设得太小或者人没有经过传感器的有效检测区域。调整方法增大阈值比如从50调到70或者微调传感器角度使其对准通道中央。误检没人通过却自己计数。可能原因是传感器噪声大或者两个传感器安装不当相互干扰。调整方法首先检查安装确保传感器不面对面。其次可以增加getDistance()函数中的滤波次数比如从5次增加到7次或者增加主循环中的delay(50)到delay(80)降低检测频率以稳定读数。还可以在状态判断时加入更严格的条件例如要求距离连续几次小于阈值才算触发。5.3 高级优化与功能扩展当基础功能稳定后你可以考虑以下升级让项目更实用数据持久化目前Arduino断电后数据会丢失。可以增加一个EEPROM存储Uno自带每次更新计数后写入上电时读取。或者连接一个SD卡模块进行数据记录。无线传输与可视化增加一个ESP-01 WiFi模块将进出数据发送到云平台如Blynk、ThingsBoard或者你自己的服务器实现手机远程查看和历史数据图表。增加声光反馈进入时让一个绿色LED闪烁一下并发出“嘀”一声离开时让红色LED闪烁并发出“嘟”一声体验更友好。防尾随与多人判断这是一个挑战。简单的思路是在CONFIRMED状态后设置一个更短的“保护期”在此期间即使检测到新的触发也忽略以防止同一个人因摆动胳膊造成二次计数。对于多人并排现有系统很难区分可能需要更复杂的传感器阵列或算法。6. 常见问题排查与解决方案速查表在实际制作过程中你几乎一定会遇到下面这些问题。我把它们和解决方案整理成了表格方便你快速对照排查。问题现象可能原因排查步骤与解决方案LCD屏幕不亮或无显示1. I2C地址错误2. 对比度问题3. 电源或接线错误1. 运行I2C扫描程序确认地址网上搜“Arduino I2C Scanner”代码。2. 调节LCD背面蓝色电位器直到显示内容清晰。3. 用万用表检查VCC和GND是否有5V电压检查SDA、SCL线是否接对A4, A5。超声波传感器读数始终为0或超大值5001. 接线错误Trig/Echo接反2. 电源供电不足3. 传感器损坏1. 对照接线表确认Trig和Echo引脚没有插错。2. 尝试单独给传感器供电或换用输出电流更大的电源。3. 更换一个传感器测试。计数不准确漏计1. 触发阈值设置过高2. 传感器安装角度/高度不对3. 人行走速度过快1. 通过串口监视距离读数调低triggerThreshold值。2. 调整传感器使其波束对准人体躯干主要部分。3. 适当增加getDistance中的采样次数或略微增加主循环delay。计数不准确多计/误计1. 触发阈值设置过低2. 传感器相互干扰或环境反射3. 软件防抖不足1. 调高triggerThreshold值。2. 确保传感器朝同一方向避开反射面。在传感器表面贴一小块海绵减少扩散。3. 增加中值滤波的采样次数或在状态判断中加入“连续N次低于阈值才触发”的逻辑。状态机卡死串口显示一直处于某个状态1. 超时逻辑未生效2. 距离读数波动大导致状态判断条件反复横跳1. 检查stateTimeout值是否合理检查millis()计时逻辑。2. 加强软件滤波确保读数稳定。检查传感器供电是否稳定。进入和离开判断反了传感器A和B的物理位置定义反了检查代码中TRIGGERED_A代表进入还是离开的逻辑并与实际安装位置对照。如果反了交换handleTriggeredAState和handleTriggeredBState中的计数逻辑。当前人数currentCount出现负数离开人数统计超过了进入人数在handleConfirmedState中更新currentCount--后加入一行判断if(currentCount 0) currentCount 0;。这通常发生在初始人数不为0或系统启动时误触发。这个项目从想法到实现最花时间的往往不是写代码而是调试和优化。我第一个版本在朋友店里测试时因为空调出风口正对传感器导致气流影响超声波误计数很多。后来移动了安装位置就解决了。所以遇到问题多观察环境多用串口打印数据分析思路总会比困难多。希望这份详细的指南能帮你顺利做出一个稳定可靠的双向访客计数器。