在线实验室

超声波与激光,同场比较

比较 HS-SR04P 与 VL53L0X 激光测距。先观察静止时的波动,再比较移动时的响应。

连接ESP32后设置时长,再开始一次采集;到时自动停止记录,保留曲线。请使用电脑Chrome或Edge。

等待开发板的温湿度补偿参数

显示哪一段数据

设置显示范围。

A · HS-SR04P

—cm

—m/s

等待回波

本组暂无数据

B · VL53L0X 激光

—cm

—m/s

等待回波

本组暂无数据

距离随时间变化 A 蓝色 · B 橙色

速度随时间变化 远离为正 · 靠近为负

0 条记录

每次采集到设定时间自动停止,下一组保留前面的数据。起止时刻控制当前图像,区间以外的数据不参与坐标缩放;冻结仅暂停绘图。缺测和剔除点不会连成有效曲线。

怎样判断远处的跳变?

  1. 两个探头前表面放在同一条起始线上,朝向同一块足够大的平整挡板,保持探头固定、挡板垂直。距离从探头前表面量起。
  2. 依次在约 20、50、70、100cm 停住挡板。每处设置采集10秒,再点击“开始本次采集”,观察两路有效率、标准差和最大最小差,再导出记录。
  3. 先比较静止数据。均值与尺子读数之差反映偏差;标准差与极差反映波动。移动期间的标准差包含真实运动,不能当作传感器噪声。
  4. 若两路都跳变,先检查挡板大小、角度和桌面反射;若主要一路跳变,再交换两探头的位置复测,分清型号差异与摆位差异。
  5. 之后做缓慢移远—停住—移近。当前没有独立速度参照,只比较方向、停住响应和曲线连续性,不据此宣称速度精度。

超声波每100ms触发一次;激光约100ms启动一次测量,时间预算50ms。激光用启动到就绪轮询的中点作为时间近似,超声波用触发加半回波脉宽,两路并非严格同时;移动时不能直接将A−B当作距离误差。

运动时用最近3个有效点拟合速度,100ms采样下跨度约0.2秒。超声波增加一帧确认:前后两点相差小于2cm,而中点偏离它们的插值超过4cm,才剔除该孤立点;单向快速运动不因位移大而剔除。超声波首次速度通常约0.4秒后出现,激光约0.2秒;速度对应拟合窗口中点,不是瞬时值。连续1.2秒内距离极差小于1.5cm时改用长窗口抑制静止噪声。

原始距离始终保留在CSV,剔除点显示缺口,处理后距离带有自己的时间戳。快速往返也可能符合孤立跳点形状,连续错误回波也可能漏过:此规则是可复查的启发式判断。残差超过10mm提示“变速或噪声”,仍给短窗口估计,不再将真实加速直接判成无速度。小于 max(0.003, min(0.01, 2×斜率标准误差)) m/s 的拟合速度显示为0;这仍可能掩盖很慢的真实运动。

为什么快的小车更难测?

速度由距离变化计算:v≈Δd/Δt。2m/s的小车每100ms前进20cm;旧1.2秒窗口需要小车在视野中运动2.4米,常常还没形成速度就出界。短窗口更快,但速度噪声约为 √2×距离噪声/Δt,因此提高采样频率本身不保证稳定。

激光采用库的长距离配置,保留50ms时间预算;它降低回光接收门限,也可能增加噪声,不能保证远距离准确。VL53L0X视场约25°,并非只测一个无限细的点;范围受反射率、目标大小和环境光影响。芯片无有效距离时不推算速度。长距离配置恢复了较远处有效输出;本装置后续观察到超声波超过约80cm误差增加、激光超过约100cm波动较大,不能用标称2m取代实测。

课堂测0.2~2m/s小车:探头沿轨道朝向小车上固定的平整反射板,尽量避免桌面、车轮和斜面进入探测范围。如果目标是可靠测量某段平均速度,两个光电门测 v=L/Δt 更直接;如果要连续速度曲线,需要足够的有效测距轨道和独立测速参照。

规格来源:ST VL53L0X 数据手册。名义工作范围不代表这套装置的已验证范围。

超声波使用开发板锁定的温湿度声速,OLED和网页一致;OLED上下两区分别显示超声波与激光距离、速度,温湿度只在后台参与补偿;激光直接使用芯片距离,不乘声速系数。激光信号不足或状态异常时显示原因,不把无效读数当距离。页面工作范围:超声波2cm–4m,激光2cm–2m;具体可靠范围还受目标、光照和安装影响。

连接:超声波TRIG25/ECHO26,DHT11 DATA27;OLED与VL53L0X共用SDA21/SCL22、3.3V与GND,地址分别0x3C、0x29。原第二路HC-SR04已移除。

传感器接线 · 第一版 v10

先断开USB和电源再接线。GPIO数字是开发板引脚旁的编号,不是排针从左到右的序号;接好后用USB数据线供电。

模块模块引脚 → ESP32
HS-SR04P超声波VCC → 3V3;GND → GND;TRIG → GPIO25;ECHO → GPIO26
DHT11温湿度模块VCC → 3V3;GND → GND;DATA(或S)→ GPIO27
SSD1306 OLED(128×64,I²C)VCC → 3V3;GND → GND;SDA → GPIO21;SCL → GPIO22
GY-VL53L0X激光模块VIN → 3V3;GND → GND;SDA → GPIO21;SCL → GPIO22;XSHUT和GPIO1不接

所有GND接在一起。这里使用可在3.3V供电的HS-SR04P;普通5V HC-SR04的ECHO不可直接接ESP32。换模块时先核对型号、电源和信号电压。

OLED和激光共用21/22两根通信线,地址分别为0x3C和0x29;两模块的SDA都接21,SCL都接22。第一版没有蓝牙和蜂鸣器功能,使用USB连接对比页面。

USB有线烧录 · 选择第一版或第二版

开发板实际使用的代码

Arduino C++,版本 ultrasonic-laser-oled-v10。main.cpp · motion_display.h · laser_sensor.h · platformio.ini

超声波采集与激光调度
#include <Arduino.h>
#include <DHT.h>
#include <esp_timer.h>
#include "motion_display.h"
#include "laser_sensor.h"

// USB 保留原始回波 CSV;OLED 计算补偿距离、一帧跳点确认与自适应窗口速度。
static_assert(ULTRASONIC_TRIG_PIN != ULTRASONIC_ECHO_PIN,
              "TRIG and ECHO must use different pins");
static_assert(DHT_DATA_PIN != ULTRASONIC_TRIG_PIN &&
                  DHT_DATA_PIN != ULTRASONIC_ECHO_PIN,
              "DHT DATA must use a separate pin");
uint32_t kUltrasonicPeriodMs = 100; // 单路100ms;运动3点、静止长窗口;B路已移除
const uint8_t trigPins[2]={25,32},echoPins[2]={26,33};
volatile uint8_t activeChannel=0;
uint32_t channelSeq[2]={0,0};
constexpr uint32_t kEchoTimeoutUs = 40000;
constexpr uint32_t kDhtPeriodMs = 2500;
constexpr uint32_t kTriggerUs = 50;
DHT dht(DHT_DATA_PIN, DHT11);
portMUX_TYPE echoMux = portMUX_INITIALIZER_UNLOCKED;
volatile uint8_t echoState = 0;  // 0 停止,1 等上升沿,2 等下降沿,3 完整
volatile int64_t echoRiseUs = 0;
volatile int64_t echoFallUs = 0;
uint32_t ultrasonicSeq = 0;
uint32_t dhtSeq = 0;


void IRAM_ATTR onEchoChange() {
  const int level = digitalRead(echoPins[activeChannel]);
  const int64_t now = esp_timer_get_time();
  portENTER_CRITICAL_ISR(&echoMux);
  if (echoState == 1 && level == HIGH) {
    echoRiseUs = now;
    echoState = 2;
  } else if (echoState == 2 && level == LOW) {
    echoFallUs = now;
    echoState = 3;
  }
  portEXIT_CRITICAL_ISR(&echoMux);
}

void sampleUltrasonic(uint8_t channel) {
  activeChannel=channel;
  const char kind=channel==0?'U':'V';
  const int64_t triggerUs = esp_timer_get_time();
  ultrasonicSeq=++channelSeq[channel];
  channels[channel].distance=NAN;channels[channel].velocity=NAN;channels[channel].status="NO ECHO";
  if (digitalRead(echoPins[activeChannel]) == HIGH) {
    Serial.printf("%c,%lu,%lld,,,,,,,echo_high_before_trigger\n",
                  kind, static_cast<unsigned long>(ultrasonicSeq), triggerUs);
    channels[channel].reset();
    return;
  }
  portENTER_CRITICAL(&echoMux);
  echoRiseUs = 0;
  echoFallUs = 0;
  echoState = 1;
  portEXIT_CRITICAL(&echoMux);
  digitalWrite(trigPins[channel], HIGH);
  delayMicroseconds(kTriggerUs);
  digitalWrite(trigPins[channel], LOW);

  // DHT 只能在等待结束后读取,避免屏蔽中断时漏掉 ECHO。
  uint8_t state;
  do {
    portENTER_CRITICAL(&echoMux);
    state = echoState;
    portEXIT_CRITICAL(&echoMux);
    if (state == 3) break;
    delayMicroseconds(20);
  } while (esp_timer_get_time() - triggerUs < kEchoTimeoutUs);

  portENTER_CRITICAL(&echoMux);
  state = echoState;
  const int64_t rise = echoRiseUs;
  const int64_t fall = echoFallUs;
  echoState = 0;
  portEXIT_CRITICAL(&echoMux);
  if (state == 3 && fall > rise) {
    const int64_t width = fall - rise;
    channels[channel].update(triggerUs, width);
    // 343 m/s 固定声速基线,后续可用原始脉宽重算温度补偿。
    const double distanceMm = static_cast<double>(width) * 0.1715;
    Serial.printf("%c,%lu,%lld,%lld,%lld,%lld,%.3f,,,ok\n",
                  kind, static_cast<unsigned long>(ultrasonicSeq), triggerUs,
                  rise, fall, width, distanceMm);
  } else if (state == 2) {
    channels[channel].reset();
    Serial.printf("%c,%lu,%lld,%lld,,,,,,echo_fall_timeout\n",
                  kind, static_cast<unsigned long>(ultrasonicSeq), triggerUs, rise);
  } else {
    channels[channel].reset();
    // 缺测留空,不能伪装成零距离。
    Serial.printf("%c,%lu,%lld,,,,,,,echo_rise_timeout\n",
                  kind, static_cast<unsigned long>(ultrasonicSeq), triggerUs);
  }
}

void sampleDht() {
  const int64_t startUs = esp_timer_get_time();
  const float humidity = dht.readHumidity();
  const float temperature = dht.readTemperature();  // 使用同一次校验通过的数据
  ++dhtSeq;
  if (isnan(humidity) || isnan(temperature)) {
    Serial.printf("D,%lu,%lld,,,,,,,read_failed\n",
                  static_cast<unsigned long>(dhtSeq), startUs);
  } else {
    updateEnvironment(temperature, humidity);
    if(environmentLocked) Serial.printf("# environment_locked temp=%.1f rh=%.1f c=%.6f\n",lockedTemp,lockedRh,lockedSpeed);
    Serial.printf("D,%lu,%lld,,,,,%.1f,%.1f,ok\n",
                  static_cast<unsigned long>(dhtSeq), startUs,
                  temperature, humidity);
  }
}

void setup() {
  for(int i=0;i<1;i++){
    digitalWrite(trigPins[i],LOW);pinMode(trigPins[i],OUTPUT);pinMode(echoPins[i],INPUT);
    attachInterrupt(digitalPinToInterrupt(echoPins[i]),onEchoChange,CHANGE);
  }
  dht.begin();
  Serial.begin(115200);
  delay(300);
  setupDisplay();
  if(oledReady)testChineseFont();
  setupLaser();
  Serial.printf("# firmware=ultrasonic-laser-oled-v10; TRIG=%d ECHO=%d DHT=%d; "
                "ultrasonic_ms=%lu; fit=adaptive_3point_static1200; trigger_us=%lu; dht_ms=%lu; c_m_s=343\n",
                ULTRASONIC_TRIG_PIN, ULTRASONIC_ECHO_PIN, DHT_DATA_PIN,
                static_cast<unsigned long>(kUltrasonicPeriodMs),
                static_cast<unsigned long>(kTriggerUs),
                static_cast<unsigned long>(kDhtPeriodMs));
  Serial.println("# channel U=HS-SR04P pins=25/26; L=VL53L0X address=0x29; B=removed");
  Serial.println("kind,seq,t_us,echo_rise_us,echo_fall_us,pulse_us,distance_mm_343,temp_c,rh_pct,status");
}

void loop() {
  while(Serial.available()){
    const char cmd=Serial.read();
    if(cmd=='1'||cmd=='2'){
      kUltrasonicPeriodMs=cmd=='1'?100:200;
      for(auto &c:channels)c.reset();
      Serial.printf("# sample_period_ms=%lu\n",(unsigned long)kUltrasonicPeriodMs);
    }
  }


  static uint32_t lastUltrasonic = 0;
  static uint32_t lastDht = 0;
  if (static_cast<uint32_t>(millis() - lastUltrasonic) >= kUltrasonicPeriodMs) {
    lastUltrasonic = millis();
    sampleUltrasonic(0);
  }
  if (static_cast<uint32_t>(millis() - lastDht) >= kDhtPeriodMs) {
    lastDht = millis();
    sampleDht();
  }
  pollLaser();
  static uint32_t lastDisplay = 0;
  if (millis() - lastDisplay >= 200) { lastDisplay = millis(); drawDisplay(); }
  delay(1);
}
OLED、声速补偿与时间窗口测速
#pragma once
#include <Arduino.h>
#include <U8g2lib.h>
#include <Wire.h>
#include <math.h>
U8G2_SSD1306_128X64_NONAME_F_HW_I2C screen(U8G2_R0,U8X8_PIN_NONE,22,21);
bool oledReady=false,environmentLocked=false;
double lockedSpeed=343;float lockedTemp=NAN,lockedRh=NAN;
float temperatureNow=NAN,humidityNow=NAN,candidateTemp=NAN,candidateRh=NAN;
uint32_t environmentTime=0;
double soundSpeed(double t,double rh) {
 const double T=t+273.15,p=101325,xc=.0004;
 const double psv=exp(1.2811805e-5*T*T-.019509874*T+34.04926034-6353.6311/T);
 const double f=1.00062+3.14e-8*p+5.6e-7*t*t,xw=rh/100*f*psv/p;
 return 331.5024+.603055*t-.000528*t*t+(51.471935+.1495874*t-.000782*t*t)*xw
 +(-1.82e-7+3.73e-8*t-2.93e-10*t*t)*p+(-85.20931-.228525*t+5.91e-5*t*t)*xc
 -2.835149*xw*xw-2.15e-13*p*p+29.179762*xc*xc+.000486*xw*p*xc;
}

// 一帧确认孤立跳点;快运动用最近3点,连续稳定才使用长窗口。
struct MotionChannel {
 struct Point { double t,d; } points[20], raw[3];
 int count=0,rawCount=0;double lastTrigger=0;
 double distance=NAN,velocity=NAN;
 const char* status="WAIT";
 void reset(){count=rawCount=0;lastTrigger=0;distance=velocity=NAN;status="NO ECHO";}
 void update(int64_t trigger,int64_t width){
  if(!environmentLocked){reset();status="WAIT T";return;}
  updateMeasurement(double(trigger)+width/2.,width*lockedSpeed/2000000.,true);
 }
 void updateMeasurement(double time,double measured,bool rejectSpikes){
  if(lastTrigger&&(time<=lastTrigger||time-lastTrigger>300000))reset();
  lastTrigger=time;distance=velocity=NAN;status="WARM";
  if(measured<.02||measured>(rejectSpikes?4.:2.)){reset();status="RANGE";return;}
  Point p={double(time),measured};
  if(rejectSpikes){
   if(rawCount==3){raw[0]=raw[1];raw[1]=raw[2];rawCount=2;}
   raw[rawCount++]=p;if(rawCount<3)return;
   p=raw[1];
   const double predicted=raw[0].d+(raw[2].d-raw[0].d)*(p.t-raw[0].t)/(raw[2].t-raw[0].t);
   if(fabs(raw[2].d-raw[0].d)<.02 && fabs(p.d-predicted)>.04){
    status="JUMP";Serial.printf("# outlier t_us=%.0f raw_m=%.6f\n",p.t,p.d);return;
   }
  }
  distance=p.d;
  while(count && p.t-points[0].t>1250000.){for(int i=1;i<count;i++)points[i-1]=points[i];count--;}
  if(count==20){for(int i=1;i<count;i++)points[i-1]=points[i];count--;}
  points[count++]=p;if(count<3)return;
  double low=points[0].d,high=low;
  for(int i=1;i<count;i++){low=min(low,points[i].d);high=max(high,points[i].d);}
  const bool stable=count>=5&&p.t-points[0].t>=1100000.&&high-low<.015;
  const int first=stable?0:count-3,n=count-first;
  if(p.t-points[first].t>450000.&&!stable)return;
  double ts[20],tm=0,ym=0;
  for(int i=0;i<n;i++){ts[i]=(points[first+i].t-points[first].t)/1e6;tm+=ts[i]/n;ym+=points[first+i].d/n;}
  double num=0,denom=0;
  for(int i=0;i<n;i++){num+=(ts[i]-tm)*(points[first+i].d-ym);denom+=(ts[i]-tm)*(ts[i]-tm);}
  if(denom<=0){reset();status="TIME";return;}
  const double v=num/denom;double sumSq=0;
  for(int i=0;i<n;i++){double e=points[first+i].d-ym-v*(ts[i]-tm);sumSq+=e*e;}
  const double threshold=max(.003,min(.01,2*sqrt(sumSq/(n-2)/denom)));
  velocity=fabs(v)<=threshold?0:v;
  // 曲率/加速度也会产生残差:提示不稳定,仍显示短窗口平均速度。
  status=sqrt(sumSq/n)>.01?"JUMP":velocity==0?"STILL":v>0?"AWAY":"TOWARD";
 }
} channels[2];
void updateEnvironment(float t,float rh){
 temperatureNow=t;humidityNow=rh;environmentTime=millis();
 if(!environmentLocked&&t>=0&&t<=30&&rh>=0&&rh<=100){
  if(isfinite(candidateTemp)&&fabs(t-candidateTemp)<=2&&fabs(rh-candidateRh)<=10){
   lockedSpeed=soundSpeed(t,rh);lockedTemp=t;lockedRh=rh;environmentLocked=true;
   channels[0].reset();
   Serial.printf("# environment_locked temp=%.1f rh=%.1f c=%.6f\n",t,rh,lockedSpeed);
  }
  candidateTemp=t;candidateRh=rh;
 }else if(!environmentLocked){candidateTemp=candidateRh=NAN;}
}
void setupDisplay(){
 Wire.begin(21,22);Wire.setTimeOut(20);Wire.beginTransmission(0x3C);oledReady=Wire.endTransmission()==0;
 Serial.printf("# oled detected=%d addr=0x3C\n",oledReady);
 if(oledReady){screen.setI2CAddress(0x3C*2);screen.setBusClock(400000);screen.begin();}
}
const char* chineseStatus(const char* status) {
 if(!strcmp(status,"AWAY"))return "远离";
 if(!strcmp(status,"TOWARD"))return "靠近";
 if(!strcmp(status,"STILL"))return "近静止";
 if(!strcmp(status,"JUMP"))return "不稳定";
 if(!strcmp(status,"RANGE"))return "超范围";
 if(!strcmp(status,"NO ECHO"))return "无回波";
 if(!strcmp(status,"WAIT T"))return "等温度";
 return "采集中";
}
void testChineseFont(){
 screen.setFont(u8g2_font_wqy12_t_gb2312);
 const uint16_t chars[]={0x58f0,0x6fc0,0x5149,0x5f85,0x6e29,0x5ea6,0x8865,0x507f,0x8fdc,0x79bb,0x9760,0x8fd1,0x9759,0x6b62,0x4e0d,0x7a33,0x5b9a,0x8d85,0x8303,0x56f4,0x65e0,0x56de,0x6ce2,0x7b49,0x91c7,0x96c6,0x4e2d};
 int missing=0;for(auto ch:chars)if(!u8g2_IsGlyph(screen.getU8g2(),ch))missing++;
 Serial.printf("# chinese_font missing=%d label_width=%u available_width=62\n",missing,screen.getUTF8Width("温度补偿"));
}
void drawDisplay() {
 if(!oledReady)return;
 screen.clearBuffer();char d[20],v[20],text[44];
 for(int i=0;i<2;i++){
  auto &c=channels[i];const int top=i*33;
  const bool fresh=c.lastTrigger && esp_timer_get_time()-c.lastTrigger<500000;
  screen.setFont(u8g2_font_wqy12_t_gb2312);
  screen.drawUTF8(0,top+11,i==0?"超声波":"激光");
  screen.drawUTF8(80,top+11,fresh?chineseStatus(c.status):"等待");
  if(fresh&&isfinite(c.distance))snprintf(d,sizeof(d),"%.1fcm",c.distance*100);else snprintf(d,sizeof(d),"--cm");
  if(fresh&&isfinite(c.velocity))snprintf(v,sizeof(v),fabs(c.velocity)<.0005?"%.3fm/s":"%+.3fm/s",fabs(c.velocity)<.0005?0.:c.velocity);else snprintf(v,sizeof(v),"--m/s");
  snprintf(text,sizeof(text),"%s %s",d,v);screen.setFont(u8g2_font_6x12_tf);screen.drawStr(0,top+27,text);
 }
 screen.drawHLine(0,31,128);screen.sendBuffer();
 Serial.printf("# oled A_d=%.6f A_v=%.6f A_status=%s B_d=%.6f B_v=%.6f B_status=%s\n",channels[0].distance,channels[0].velocity,channels[0].status,channels[1].distance,channels[1].velocity,channels[1].status);
}