哎,先把书放下,咱们聊点实在的。
你肯定见过那种老式的激光测距仪,或者家里的扫地机器人——那个在前面转来转去、或者一闪一闪的小灯,那就是“眼睛”。在极客的世界里,我们管它叫 ToF (Time of Flight),也就是“飞行时间”。
很多人一听ToF就头大,觉得这是物理系的事儿,跟写代码的程序员有啥关系?其实关系大了。作为一名单片机开发者,你不需要造出光子,你只需要懂它怎么工作,然后把它变成数据,喂给你的算法。
今天咱们不扯那些枯燥的教科书定义,我就当你是刚接触这个领域的新手,咱们像老朋友聊天一样,把这事儿掰开了、揉碎了讲清楚。特别是关于飞思卡尔(哦,现在叫恩智浦 NXP 了,但很多老工程师还是习惯叫它飞思卡尔,咱们就顺着这个叫法,毕竟经典模组还在用)的这套逻辑。
一、 别被公式吓跑:ToF到底是个啥?
想象一下,你站在山谷对面,大喊一声“喂!”,然后拿着秒表计时,听回声。
- 声音传过去,撞上山壁,弹回来。
- 你测出声音来回的总时间。
- 已知声音速度(340米/秒),除以2,再乘以时间,距离就出来了。
这就是ToF的最底层逻辑:测时间,算距离。
但是!声音太慢了。测个几米距离,回声都要好几百毫秒,你早就急死了。所以,我们要用光。
光速是 30万公里/秒(准确说是 \(299,792,458\) 米/秒)。
这时候问题来了:如果你测1米距离,光去回来只要 \(2 \times 1 / 3\times10^8 \approx 6.6\) 纳秒。
6.6纳秒是什么概念?
- 你眨眼一次要300,000,000纳秒。
- 你的CPU跑一条指令可能要几个纳秒。
要直接测这么短的时间,对于普通的单片机(比如STM32、51、Arduino)来说,简直是天方夜谭。你的定时器精度根本不够用。
那怎么办?直接测不行,咱们就“间接”测。
这就引出了ToF的两种主流技术路线:
- 脉冲ToF (Pulsed ToF):发射一个极短的激光脉冲,精确计时。这需要高端的示波器级别的硬件,普通单片机玩不转,通常用于军事或高端工业。
- 连续波ToF (CW-ToF) / 相位测距:这是飞思卡尔NXP那些经典模组(如TOF10120, TN9091等)最常用的方式。
咱们重点讲第二种,因为它才是咱们程序员能上手玩的。
相位测距:玩的就是“步调不一致”
想象你在跑步机上跑步。
- 你(传感器)在跑步机上跑,速度恒定。
- 对面有一面墙,墙上的反光板也在动,但是是在原地“闪烁”。
- 你发射一束光,这束光像波浪一样,忽明忽暗(调制的正弦波)。
光打到物体上,反射回来。因为光走了一个来回,所以反射回来的光,跟发射出去的光,会有一定的相位差。
关键点来了:
- 距离越远,光走的时间越长,相位差越大。
- 距离越近,相位差越小。
我们不需要知道光具体飞了多久(因为太快了测不准),我们只需要比较发射波和接收波的波形错位程度。
这就好比两个人握手,一个伸手早,一个伸手晚,你只需要看他们手碰到一起的时候,各自的角度差是多少。
\[ Distance = \frac{c \times \phi}{4 \pi f} \]
别怕,不用背公式。你就记住:相位差 \(\phi\) 和距离 \(D\) 是成正比的。 频率 \(f\) 越高,精度越高,但最大测距范围会变短。
二、 飞思卡尔/NXP的ToF模组:为什么选它?
市面上ToF模组多得是,索尼、ams、ST都有。但为什么咱们要提“飞思卡尔”?
这其实是一个情怀,也是一个实战选择。
在2015-2020年间,飞思卡尔(后被NXP收购)推出了一些非常经典的入门级ToF传感器,比如 TOF10120、TN9091,还有基于SPAD(单光子雪崩二极管)技术的早期原型。这些模组的特点非常鲜明:
- 便宜:几块钱到几十块钱人民币。
- 简单:很多甚至不需要I2C,只需要一个GPIO触发,模拟输出或者PWM输出距离。
- 稳定:相比很多廉价的激光雷达,ToF在测黑衣服、玻璃这类难测物体时,表现相对可预测。
虽然现在飞思卡尔独立品牌已经很少见,但NXP依然保留了相关技术栈,而且很多存量项目、毕业设计、入门教程依然基于这些经典芯片。理解它们的原理,你就理解了整个CW-ToF领域的门道。
三、 硬件连接:别只画原理图,要动手接线
假设你手里有一个经典的 TOF10120 模组(这是飞思卡尔系中最具代表性的入门款)。
1. 引脚定义(通常)
- VCC: 3.3V - 5V(看具体型号,有的支持5V容忍)
- GND: 地
- TRIG: 触发引脚(可选,有的内置自动模式)
- DATA/OUT: 距离数据输出(可能是数字接口,也可能是模拟电压)
2. 接线误区
很多新手上来就接I2C,发现读出来全是0或者乱码。 真相是: 很多低端ToF模组,为了省电和简化设计,根本不提供I2C。它们用的是PWM输出或者模拟电压输出。
- PWM模式:脉冲的宽度代表距离。高电平时间越长,距离越远。
- 模拟模式:输出电压值,0-3.3V对应0-100cm。
建议: 先拿万用表测一下VCC和GND,再测DATA引脚。如果DATA引脚电压随距离变化,那就是模拟输出。如果有一个方波跳变,那就是PWM。
四、 代码实战:如何把光变成数字?
咱们用最常见的 STM32 和 Arduino 两个平台来演示。这两种平台逻辑通用,你可以迁移到ESP32、51单片机或者任何你喜欢的MCU上。
案例1:PWM输出型 ToF (以TOF10120为例)
这是最经典的“程序员友好型”接口。你不需要懂复杂的时序协议,你只需要测量脉冲宽度。
原理:
- 最小距离(比如0cm)对应最小脉冲宽度(比如100us)。
- 最大距离(比如100cm)对应最大脉冲宽度(比如10000us)。
- 比例关系:\(Distance = (Width - MinWidth) / ScaleFactor\)
STM32 HAL库实现
#include "main.h"
#include "tim.h"
#include "gpio.h"
// 假设我们使用TIM2的CH1来捕获外部PWM信号
// TRIG引脚可以接GPIO,如果模组需要触发才工作
uint32_t pulse_width_us = 0;
float distance_cm = 0.0f;
// 配置定时器为输入捕获模式
void TOF_Init(void) {
// 这里省略GPIO初始化,假设TRIG接PA0, DATA接PA1(TIM2_CH1)
__HAL_TIM_SET_CAPTUREPOLARITY(&htim2, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING);
__HAL_TIM_ENABLE_CAPTURE(&htim2, TIM_CHANNEL_1);
__HAL_TIM_ENABLE(&htim2);
}
// 测量单次距离
float TOF_GetDistance(void) {
uint32_t start_time = 0;
uint32_t end_time = 0;
// 1. 触发测量(如果模组需要硬件触发)
HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_SET);
HAL_Delay(1); // 延时满足模组建立时间
HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_RESET);
// 2. 等待上升沿,记录开始时间
while (__HAL_TIM_GET_FLAG(&htim2, TIM_FLAG_UPDATE) == RESET); // 简单轮询,实际项目建议用中断
// 更好的方式:在中断中记录
// 这里为了演示逻辑,假设我们用硬件自动捕获
// 实际上,TOF10120在TRIG拉高期间会自动发送脉冲并输出PWM
// 我们只需要测量PWM的高电平时间
// 重新配置捕获极性为下降沿,等待高电平结束
__HAL_TIM_SET_CAPTUREPOLARITY(&htim2, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING);
// 等待下降沿中断或标志位
// 注意:实际开发中,建议使用定时器输入捕获中断来完成此操作,避免轮询阻塞
// 简化版逻辑:直接读取CCR1的值(假设已经通过中断更新)
// 这里假设有一个全局变量 captured_pulse_width 在中断中更新
}
// 定时器中断回调函数 (关键!)
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) {
if (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) {
static uint32_t start_cnt;
static uint8_t state = 0;
if (state == 0) {
// 捕获到上升沿,记录初始计数值
start_cnt = __HAL_TIM_GET_CAPTURE_CNT(htim->Instance, TIM_CHANNEL_1);
// 切换为下降沿捕获,准备测高电平宽度
__HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING);
state = 1;
} else {
// 捕获到下降沿,计算宽度
uint32_t end_cnt = __HAL_TIM_GET_CAPTURE_CNT(htim->Instance, TIM_CHANNEL_1);
// 处理定时器溢出导致的负数问题(如果计数器回绕)
if (end_cnt >= start_cnt) {
pulse_width_us = (end_cnt - start_cnt) * (1000000.0f / TIM2_CLOCK_FREQ);
} else {
// 简单处理,假设没有溢出或溢出次数很少
pulse_width_us = (65535 - start_cnt + end_cnt) * (1000000.0f / TIM2_CLOCK_FREQ);
}
// 计算距离
// TOF10120典型参数:0cm=100us, 100cm=10000us
// 公式: Distance = (Width - 100) / 99
if (pulse_width_us > 100) {
distance_cm = (pulse_width_us - 100) / 99.0f;
} else {
distance_cm = 0;
}
// 切换回上升沿,准备下一次测量
__HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING);
state = 0;
}
}
}
Arduino 极简实现
如果你用的是Arduino,那就更简单了。Arduino有内置的 pulseIn() 函数。
#define TRIG_PIN 8
#define DATA_PIN 9
void setup() {
Serial.begin(9600);
pinMode(TRIG_PIN, OUTPUT);
pinMode(DATA_PIN, INPUT);
digitalWrite(TRIG_PIN, LOW);
}
void loop() {
// 触发一次测量
digitalWrite(TRIG_PIN, HIGH);
delayMicroseconds(10); // 至少10us高电平
digitalWrite(TRIG_PIN, LOW);
// 测量高电平脉宽,超时时间设为100ms
long duration = pulseIn(DATA_PIN, HIGH, 100000);
if (duration > 0) {
// TOF10120 换算公式
// 距离(cm) = (duration - 100) / 99
float distance = (duration - 100.0) / 99.0;
if (distance >= 0 && distance <= 100) {
Serial.print("Distance: ");
Serial.print(distance, 2);
Serial.println(" cm");
} else {
Serial.println("Out of range or error");
}
} else {
Serial.println("No signal");
}
delay(200); // 采样间隔
}
你看,就是这么简单。 不需要懂量子力学,只需要懂 pulseIn。
案例2:I2C接口型 ToF (以TN9091或更高级的SPAD传感器为例)
如果你用的是更高端的、带有I2C接口的ToF模组(比如NXP后期的一些产品,或者仿照其原理的模组),那就需要读寄存器了。
注意: 飞思卡尔时代的经典ToF很多是模拟/PWM输出,但为了“精通”,我们必须掌握I2C读取的方式,因为现在主流的都是I2C/SPI。
代码逻辑解析
// 假设使用STM32 HAL库 I2C
#define TOF_I2C_ADDR 0x29 // 举例地址
uint16_t TOF_ReadDistance(void) {
uint16_t distance_raw = 0;
uint8_t data[2] = {0};
// 1. 发送读取命令(具体看数据手册,有些需要先写配置寄存器)
HAL_I2C_Mem_Read(&hi2c1, TOF_I2C_ADDR, 0x04, I2C_MEMADD_SIZE_8BIT, data, 2, 100);
// 2. 组合数据(高位在前还是低位在前,看手册)
distance_raw = (data[0] << 8) | data[1];
// 3. 转换为距离
// 不同模组比例不同,有的直接是mm,有的是cm*100
float distance_m = distance_raw * 0.001f; // 假设1mm/LSB
return distance_m * 100; // 返回cm
}
重点提示: 每个ToF模组的I2C地址和寄存器映射都不一样!一定要看数据手册(Datasheet)。这是程序员的基本素养,别指望别人给你写死代码。
五、 避坑指南:为什么你的测距总是飘?
这是最值钱的部分。书上不会写,但实战中你会哭。
1. 目标物体的颜色和黑度
光是有反射率的。
- 白色墙壁:反射率高,测得准。
- 黑衣服、黑布:反射率低,光被吸收了,回来的信号弱,测距可能失败或偏近。
- 镜子、玻璃:光直接透过去或镜面反射走了,传感器收不到回波。
解决办法:
- 软件滤波:多次采样取中值或均值。
- 阈值判断:如果接收信号强度(SSI, Signal Strength Indicator)过低,丢弃该数据。
2. 环境光干扰
太阳光里也有红外成分!如果你在户外大太阳底下用ToF,背景噪声会非常大。
- ToF模组发射的是特定波长的调制光(比如850nm或940nm)。
- 环境光如果是恒定的直流分量,可以通过交流耦合滤除。
- 但如果环境光也有调制(比如某些LED灯),就会干扰相位判断。
解决办法:
- 加遮光罩,只让传感器看到它想看的方向。
- 选择波长与环境光干扰较小的模组。
3. 多路径干扰 (Multipath Interference)
这是ToF的大敌。 想象你在一个狭小的房间里,光打到墙上,反射回来;同时,光打到桌子角,反射回来。传感器接收到了两个回波,相位计算就乱了。 这会导致测出的距离偏近或飘忽不定。
解决办法:
- 避免在强反射、多边缘的环境中使用。
- 使用具有“多径抑制”算法的高端模组(NXP后期的高端产品有这功能)。
- 软件上,如果连续几次读数偏差极大,就进行剔除。
4. 供电噪声
ToF模组内部有激光驱动器或LED驱动,电流变化很大。如果电源噪声大,会干扰内部的模拟电路,导致读数抖动。
- 务必在VCC和GND之间加一个0.1uF的陶瓷电容和一个10uF的电解电容,尽量靠近模组引脚。
六、 进阶:如何融合ToF数据?
单独一个ToF模组,只能测一个点的距离。但你可以用它做很多事。
1. 超声波 vs ToF
- 超声波:便宜,测距远(几米),但速度慢(声波),精度低(厘米级),受温度影响大。
- ToF:贵一点,速度快(光速),精度高(毫米级),但受颜色/材质影响大。
实战应用: 扫地机器人。
- 超声波用于探测大范围的障碍物(比如桌腿)。
- ToF用于悬空检测(避免从楼梯掉下去)和贴边清扫(需要高精度)。
2. 滤波算法:让数据“稳”下来
原始数据通常是抖动的。比如你不动,它也在 100.2cm, 99.8cm, 100.5cm, 100.1cm 之间跳。
**指数移动平均 (EMA) 滤波