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:空闲- 门口无人,两个传感器检测到的距离都大于触发阈值。
- 状态1:A触发- 传感器A检测到有人(距离小于阈值),但传感器B还未触发。此时,人可能正从室外进入,并刚刚越过A。
- 状态2:B触发- 传感器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 = i+1; 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或超大值(>500) | 1. 接线错误(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或系统启动时误触发。 |
这个项目从想法到实现,最花时间的往往不是写代码,而是调试和优化。我第一个版本在朋友店里测试时,因为空调出风口正对传感器,导致气流影响超声波,误计数很多。后来移动了安装位置就解决了。所以,遇到问题多观察环境,多用串口打印数据分析,思路总会比困难多。希望这份详细的指南能帮你顺利做出一个稳定可靠的双向访客计数器。