☰
STM32+VL53L1X单点激光测距工程:I2C驱动到料位检测
2026/10/26 17:06:48 网站建设 项目流程

简介:STM32_VL53.zip 是一份面向嵌入式初学者与 STM32 开发者的激光测距实践工程,围绕 STM32F103VET6 主控与 VL53L1X ToF 传感器展开,重点解决 I2C 接线、传感器初始化、轮询读取距离及异常处理等入门问题,可用于室内导航、物体检测与自动对焦等场景的原型验证。压缩包共 417 个文件,约 19.83MB,包含 C 源码、头文件、编译中间文件、工程配置与可执行输出等,其中 c/h 文件便于阅读驱动与业务逻辑,o/crf/d 及 axf/hex 等反映编译构建与烧录结果,uvprojx 工程可直接导入开发环境。已有 2719 人学习下载,说明其作为参考工程具有一定认可度。读者可据此梳理 PA2/PA3 的 SDA/SCL 连接、PA4 的 XShut 控制思路,理解单次与连续测距配置、数据读取流程及通信错误排查方法,并借助完整工程结构快速复现非接触式测距实验,为后续传感器融合与低功耗设计积累可复用的调试经验。

1. 一个料位检测场景引入的单点测距工程

去年帮一个做小料仓的团队改测距方案,要求把探头装在仓顶,量程 40 厘米到 1.5 米,环境有粉尘。原来的超声波探头被粉尘糊住以后回波明显漂移,换成了 VL53L1X 单点激光模组,主控还是最普通的 STM32F103。这个压缩包里是那套裸机工程的收敛版:I2C 驱动、初始化、单次和连续测距、串口打印,全部在一个可以直接编译的 Keil 工程里。它不涉及复杂的算法,也不依赖 RTOS,就是把「STM32 + VL53L1X 激光测距」这一条链路完整打通。适合正在做接近检测、液面/料位、简易避障的嵌入式开发者;如果你手上刚好一块 F1 或者 F4 的板子加一个 ToF 模组,拿过去改几个宏定义就能跑。

2. 技术选型与工程骨架:I2C 单点测距最小闭环

2.1 硬件连接:VDDIO、XSHUT 和上拉电阻一个都不能省

VL53L1X 是 16 位寄存器地址、数据大端的 I2C 从机,7 位地址默认 0x29,HAL 库里要写成 0x52(左移一位)。模组本身有两路供电:一路是内部激光和模拟部分的 AVDD,另一路是 IO 侧的 VDDIO。大部分成品小板自带 LDO,只引出一组 3.3V 和一个 VDDIO 跳线;如果你买的是裸模组,VDDIO 必须和主控的 IO 电平一致,不能拿 5V 直接灌。

接线上有四根是必须的:SCL、SDA、GND、VDDIO。另外两根强烈建议接上:XSHUT 接一个普通推挽 GPIO,GPIO1 接一个带外部上拉的输入。XSHUT 是硬复位脚,拉低超过 1 微秒器件就掉电重启,多模组组网时全靠它做地址分配;GPIO1 是中断输出,默认配置下测距完成会拉低,用它可以把轮询从主循环里拿掉。

I2C 上拉别省。400kHz 下 4.7k 是常见值,但如果你的排线超过 20 厘米,建议降到 2.2k,同时把速率降到 100kHz 试试。我遇到过 SCL 上的波形在上升沿有明显的台阶,逻辑分析仪上看是典型的上升时间不够,换小阻值上拉后立刻正常——这种问题在示波器上看一眼就能定位,但在代码里会表现为随机的 HAL_BUSY,非常像玄学。

2.2 三层切分:ULD 内核、平台适配、应用逻辑

工程按三层来分,这是官方 ULD(Ultra Lite Driver)驱动包本来的结构,也是我建议你保留的结构:

最底下是平台层VL53L1_platform.c,只做一件事:把抽象的「读/写 16 位寄存器地址」翻译成 HAL 的HAL_I2C_Mem_Read/Write。这一层是唯一跟具体芯片型号、具体 I2C 外设绑定的一层,换 MCU 只改这里。

中间是驱动内核,包括vl53l1x_api.c和一大堆vl53l1x_*.c。这里面封装了VL53L1X_SensorInit、VL53L1X_StartRanging、VL53L1X_GetDistance这些接口,寄存器地址、时序延时的最小值、参数配置表的解析,全在这里。这一层不要动,除非你有明确理由。

最上面是应用层,一个app_range.c加上main.c里的主循环。它负责什么时候开始测、多久读一次、读到的值怎么滤波、串口怎么上报。业务逻辑全部收在这里,将来从料位改成避障只改这一层。

分层的价值在多模组时候特别明显:驱动内核是被多个实例共用的,只有平台层需要额外接受一个「当前操作哪个地址」的参数。如果没有分层,两个模组就意味着两套几乎一样的驱动文件,后面维护起来十分麻烦。

2.3 目录结构与文件职责

解压后大致是下面这个结构,Keil 工程文件在根目录:

路径职责是否需要改
Core/Src/main.c外设初始化、主循环、串口上报需要,业务入口
Core/Src/i2c.cI2C1 初始化,400kHz按板子改引脚
Core/Src/gpio.cXSHUT、GPIO1、串口引脚按板子改引脚
Drivers/VL53L1X/vl53l1_platform.c寄存器读写适配 HAL基本不改
Drivers/VL53L1X/vl53l1x_api.c对外的初始化/测距接口不改
Drivers/VL53L1X/vl53l1x_core.c内部寄存器配置与计算不改
Drivers/VL53L1X/vl53l1x_platform.h平台层函数声明不改
App/app_range.c测距流程封装、状态判定需要,主要改这里

真正需要动手的基本只有三处:main.c的引脚和外设、app_range.c的测距策略、i2c.c的速率。其余文件当成黑盒用就行,除非你要换成别的 ToF 型号。这也是我判断一个驱动工程好不好用的标准——需要改的文件越少,说明它的边界切得越干净。

3. 初始化链路:从上电复位到读出 0xEA

3.1 XSHUT 时序与 boot 等待

上电顺序错了,后面所有代码都是白写。VL53L1X 内部有一小块固件,XSHUT 释放以后它需要时间从内部 ROM 搬固件、初始化模拟前端,这期间它不响应 I2C。如果主控在这时候就去读寄存器,会稳定收到 NACK,很多人第一反应是地址错了或者上拉不对,其实是时序没等够。

正确的做法是:XSHUT 先拉低,保持至少 1 微秒,实际工程里给 10 毫秒更稳妥;然后释放,等大约 1.2 毫秒固件启动完成;最后不要盲等,直接去轮询 ID 寄存器。

/* bsp_vl53l1x.c —— 硬复位时序 */ #include "bsp_vl53l1x.h" #include "main.h" void VL53LX_HardReset(void) { /* 拉低 XSHUT,器件掉电,I2C 从机失效 */ HAL_GPIO_WritePin(VL53_XSHUT_GPIO_Port, VL53_XSHUT_Pin, GPIO_PIN_RESET); HAL_Delay(10); /* 手册最小值 1us,留足余量 */ /* 释放复位,内部固件开始从 ROM 启动 */ HAL_GPIO_WritePin(VL53_XSHUT_GPIO_Port, VL53_XSHUT_Pin, GPIO_PIN_SET); HAL_Delay(2); /* 启动约需 1.2ms,这里给 2ms */ } /* 轮询 0x010F 直到读到 0xEA,说明固件已经跑起来了 */ uint8_t VL53LX_WaitBoot(uint32_t timeout_ms) { uint8_t model_id = 0; uint32_t tick = HAL_GetTick(); while ((HAL_GetTick() - tick) < timeout_ms) { if (VL53L1_RdByte(VL53L1_I2C_ADDR, 0x010F, &model_id) == 0) { if (model_id == 0xEA) { /* 0xEA 是 VL53L1X 的固定型号 ID */ return 0; } } HAL_Delay(1); } return 1; /* 超时,通常是供电或 I2C 硬件问题 */ }

这段代码有两个细节值得单独说。HAL_Delay(2)是保守值,官方示例里给的是 1.2 毫秒级别的等待,但不同批次的模组冷启动时间有差异,多等 1 毫秒不会有人抱怨,少等就可能偶发失败。超时用HAL_GetTick()而不是循环计数,这样即使主频改了你也不用重新调参。返回值为 1 的时候不要去猜,直接量波形:SCL 有没有时钟、器件有没有 ACK、VDDIO 有没有真的到 3.3V。

3.2 SensorInit 展开:软复位、参数装载与中断清理

硬复位之后还要走一遍软初始化。VL53L1X_SensorInit()内部做的事比看起来多:写 0x0000 寄存器触发软复位;等待固件状态位;重新确认型号 ID;把一整张默认配置表按顺序刷进寄存器;设置默认的 timing budget 和 distance mode;配置 GPIO1 为中断输出;最后清理一次中断标志,让第一次测距从一个干净的状态开始。

刷配置表那一步耗时最长,因为里面有些寄存器之间对写入顺序有要求,中间还夹着若干十毫秒级的等待。整个SensorInit在 400kHz 下大概要几十毫秒,所以不要在中断里调用它,也别在主循环的实时任务中间插进去。

/* app_range.c —— 初始化封装,把返回值拆成阶段码方便定位 */ uint8_t Range_Init(void) { VL53LX_HardReset(); /* 1. 硬复位 + 等启动 */ if (VL53LX_WaitBoot(100) != 0) return 0x11; if (VL53L1X_SensorInit(VL53L1_I2C_ADDR) != 0) return 0x12; /* 2. 软初始化 */ /* 3. 测距模式:1 = 长距,2 = 短距,以模组实际表现选 */ if (VL53L1X_SetDistanceMode(VL53L1_I2C_ADDR, 2) != 0) return 0x13; /* 4. 时间预算 100ms,抗噪与刷新率的折中 */ if (VL53L1X_SetTimingBudgetInMs(VL53L1_I2C_ADDR, 100) != 0) return 0x14; /* 5. 两个参数必须成对写,顺序不能反 */ if (VL53L1X_SetInterMeasurementInMs(VL53L1_I2C_ADDR, 100) != 0) return 0x15; /* 6. ROI 收到 8x8,视场角变窄,减少边框反射干扰 */ if (VL53L1X_SetROI(VL53L1_I2C_ADDR, 8, 8) != 0) return 0x16; return 0; }

这里每个失败分支返回不同的码,是我踩过坑之后养成的习惯。早期图省事统一返回 1,结果现场出问题是「初始化失败」,只能靠猜是哪一步。分成阶段码之后,串口打印一个0x14就知道是 timing budget 没写进去,直接跳到对应寄存器查,省掉大半天。

还要注意VL53L1X_SetTimingBudgetInMs和VL53L1X_SetInterMeasurementInMs的依赖关系:timing budget 不能大于 inter-measurement,否则模组会返回非法参数。顺序上先设 inter-measurement 再设 timing budget,或者两者设成相等,都不容易出问题。

3.3 三个参数决定精度:Timing Budget、Distance Mode、ROI

调这三个参数基本等同于调整个测距系统的性格。它们之间互相牵制,不能各自单独设:

参数取值范围调大之后调小之后
Timing Budget20/33/50/100/200/500 ms信噪比升,量程和稳定性上升,刷新率下降刷新率上升,噪声明显变大
Distance Mode1 长距 / 2 短距量程更远,近距离容易截断抗环境光更好,量程缩短
ROI4x4 到 16x16视场角大,容易打到边框产生串扰定位更准,对安装对准要求高

经验区间是这样的:长距模式配 100ms 时间预算,白墙大概能到 3 米;50ms 时噪声肉眼可见,静止目标的读数会在 ±15 毫米里抖;20ms 基本只能当「有没有东西」用。短距模式在 100ms 以内就一直很稳,但量程被压到 1.3 米左右,适合近距离高刷新率的场景。

ROI 这个参数最容易被忽略。默认 16x16 是整个视场角全开,如果你的模组装在一个开孔的壳子里,孔边沿会把激光反射回来,形成固定的近距离串扰,表现为不管前面有没有东西都报一个几十毫米的值。把 ROI 缩到 8x8 或者 4x4,相当于把视场角收窄,边框就被排除在外面了。我一般先把 ROI 收到 8x8 再调其他参数,因为串扰是系统性偏差,靠滤波滤不掉。

4. 一次测距的完整链路:从 StartRanging 到毫米数

4.1 触发与就绪判定

连续测距模式下,VL53L1X_StartRanging只是把模组启动,它会按照 inter-measurement 设定的节奏自己循环测量,主控要做的就是「等一帧好了没有」。判断方式有两种:轮询 GPIO 状态寄存器,或者等 GPIO1 的中断。

轮询的写法是读 0x0031 这个寄存器的最低位,为 0 表示有新数据。这个寄存器名字里带 GPIO,实际反映的是中断线的电平状态。读到 0 之后再调VL53L1X_GetDistance拿结果,拿完必须显式清一次中断(写 0x0086),否则电平不会恢复,下一轮就永远等不到。

/* app_range.c —— 阻塞式取一帧,超时返回错误码 */ uint8_t Range_ReadOnce(uint16_t *distance_mm, uint8_t *status) { uint8_t ready = 0; uint32_t tick = HAL_GetTick(); /* 等中断线拉低,最多等 timing_budget + 50ms 的余量 */ while ((HAL_GetTick() - tick) < (RANGE_BUDGET_MS + 50)) { if (VL53L1X_GetMeasurementDataReady(VL53L1_I2C_ADDR, &ready) != 0) { return 0x21; /* I2C 通信异常 */ } if (ready == 1) break; HAL_Delay(1); } if (ready != 1) return 0x22; /* 超时,模组没出数据 */ VL53L1X_GetDistance(VL53L1_I2C_ADDR, distance_mm); /* 单位 mm */ VL53L1X_GetRangeStatus(VL53L1_I2C_ADDR, status); /* 0 表示可信 */ /* 关键一步:不清中断,下一帧就再也等不到 ready */ VL53L1X_ClearInterrupt(VL53L1_I2C_ADDR); return 0; }

超时余量给budget + 50ms是保守估计。HAL_Delay(1)的轮询粒度意味着最多会多等 1 毫秒,加上 I2C 读寄存器的开销,一帧的总耗时通常在 budget 的 1.1 倍左右。如果你的主循环里还有别的活,超时值要相应放宽,否则会偶发0x22。

4.2 读结果:距离、状态、信号强度

GetDistance只是把 0x0096 这个寄存器的两个字节拼成 16 位整数,单位毫米。但这个值不能直接用。VL53L1X 在很多异常情况下仍然会返回一个数字:没有目标时返回 8190 或 8191,信号太弱时返回一个明显偏小的值,相位异常时甚至会给一个看起来完全合理的距离。

真正的可信度判断在 range status 上。0 表示这一帧数据可信,非 0 对应信号强度不足、噪声超限、近距离截断、相位异常等情况,具体的编码含义以驱动头文件里的宏定义为准,不同版本的 ULD 名字略有差异。工程里我一般写一个白名单,只接受 0,其他全部丢弃。

除了这两个,还有两个值值得一起读出来:0x0098 的峰值信号强度和 0x009A 的环境光强度。前者反映目标反射率,白纸和黑绒布能差一个数量级;后者反映背景光干扰,太阳直射下会飙到很高。把这两个值打到串口上,调试时基本能一眼看出问题出在目标太暗还是环境太亮。

/* app_range.c —— 带质量阈值的取值,返回 0 才算一帧有效数据 */ uint8_t Range_ReadValid(uint16_t *distance_mm) { uint16_t dist = 0; uint8_t status = 0; uint16_t peak = 0; if (Range_ReadOnce(&dist, &status) != 0) return 1; /* 丢弃无效帧:状态非 0 或者距离是饱和值 */ if (status != 0) return 2; if (dist >= 8190) return 3; VL53L1X_GetSignalRate(VL53L1_I2C_ADDR, &peak); if (peak < 1) return 4; /* 回波太弱,多半是深色目标 */ *distance_mm = dist; return 0; }

这个函数返回非 0 的时候,调用方要做的是「跳过这一帧,继续下一帧」,而不是停下来报错。ToF 测量本来就是概率性的,连续 10 帧里丢 1 到 2 帧很正常,靠时间上的多数表决比靠单帧的阈值可靠得多。

4.3 轮询、中断与 DMA 的取舍

工程默认是轮询,好处是逻辑直白,坏处是阻塞。100ms 的时间预算意味着主循环里有一百毫秒被占着,如果同时还要刷屏、处理串口命令,就会明显卡顿。

改成 GPIO1 外部中断是最划算的一步。把中断配成下降沿触发,中断服务函数里只置一个标志位,主循环看到标志位再去读数据。这样主循环可以自由安排其他任务,测距变成了后台行为。

再往上是用 DMA 做 I2C 传输,把读寄存器也从 CPU 手里拿走。实际收益有限,因为一次读只有两三个字节,I2C 的瓶颈在总线速率上而不在 CPU,除非你同时挂了好几个传感器,否则不建议一上来就上 DMA,调试复杂度会陡增。

下面这几条判断标准可以直接照用:刷新率要求低于 10Hz 且主循环空闲,就用轮询;刷新率高于 10Hz 或者主循环有别的实时任务,上中断;总线挂了三颗以上从机且都是 400kHz,再考虑 DMA。

5. 避坑与排查:读不到 ID、数据跳变、地址冲突的现场记录

5.1 现象:I2C 读寄存器永远返回 HAL_ERROR

现象很统一:仿真器单步进去,HAL_I2C_Mem_Read返回HAL_ERROR或者HAL_TIMEOUT,串口打印的型号 ID 一直是 0x00 或者 0xFF。换一块板子有时又好了。

原因通常有三个。第一是地址写错,7 位地址 0x29 在 HAL 里要左移一位变成 0x52,很多人直接把 0x29 传进去,HAL 会把它当成 8 位地址的低 7 位用,实际总线上出现的是别的从机地址。第二是 XSHUT 悬空,模组一直在复位状态,从机根本不响应。第三是 VDDIO 没接或者接到了 5V,IO 侧没电,SDA/SCL 上没有有效电平。

解决办法按顺序排查:先用逻辑分析仪抓一次起始信号后的第一个字节,看总线上真实出现的是 0x52 还是 0x29,这一步能直接确诊地址问题;然后量 XSHUT 的静态电平,释放后应该是高;最后量 VDDIO 和 SDA 的上拉电压是否一致。三样都对了还读不到,就把 I2C 速率降到 100kHz 再试,排除上升时间不足。

5.2 现象:能读到 0xEA,但测距值恒为 8190/8191

初始化全部通过,型号 ID 也对,一启动测距读回来就是 8190 或者 8191,从来没变过。这个值在 VL53L1X 里是「没有有效回波」的饱和标记。

第一个原因是没清中断。中断标志在初始化时可能已经是置位状态,StartRanging之后第一次读 ready 立刻返回 1,此时读到的其实是上一次残留的空结果。修正方法是在StartRanging之后、进入循环之前,先无条件调一次ClearInterrupt。

第二个原因是目标不在有效量程内。短距模式的有效量程只有 1.3 米左右,如果你的测试距离是 2 米,读回来必然是饱和值。这时候看 range status 会是「信号强度不足」相关的编码,一看就能区分。

第三个原因是分钟级别的低级错误:模组的保护膜没撕。上面那层半透明贴膜对红外激光的衰减非常厉害,撕掉之后量程能差一倍。这个坑我见过不止一次,血泪经验是拿到新模组第一件事就是撕膜。

5.3 现象:静止目标读数在 0 和真实值之间乱跳

目标完全不动,串口打出来的距离却在 0 毫米和真实值之间来回跳,偶尔还夹着几个明显偏小的值。

最可能的原因是光学串扰。模组装在开孔外壳里,孔壁或者盖板玻璃把发射光直接反射回接收端,形成一条固定的短光路。这条光路的距离是固定的、很近的,所以它会被算成一个接近 0 的读数,和真实目标交替出现。判断方法是把模组从壳子里拆出来裸测,如果跳变消失,就是串扰无疑。

解决办法有两个:物理上加大开孔、把模组往孔里缩进一点、或者在窗口贴一层针对 940nm 的窄带滤光片;软件上把 ROI 收小到 8x8 甚至 4x4,让视场角避开孔壁,同时把 range status 非 0 的帧全部丢弃。极端情况下还可以设置一个最小距离门限,小于这个值的直接判为无效——反正是料位检测,40 厘米以内的数据本来就没意义。

5.4 现象:两个模组挂在同一条总线上,只有一个能用

两个模组的 XSHUT 都直接拉到 VDD,上电后两个器件同时启动,地址都是 0x29,读谁都是读同一个。

VL53L1X 支持改地址,但前提是同一时刻总线上只有一个器件响应 0x29。正确流程是:把所有模组的 XSHUT 全部拉低,保持住;然后逐个释放,释放一个就立刻把它地址改成新地址(写 0x0001 寄存器),改完再做下一个;全部改完之后,每个模组都有独立地址,才能正常并发工作。

/* multi_vl53.c —— 逐个释放并分配新地址 */ static const uint8_t addr_table[2] = {0x2A, 0x2B}; /* 7 位新地址 */ uint8_t VL53_MultiInit(void) { /* 1. 全部按在复位态 */ HAL_GPIO_WritePin(XSHUT1_PORT, XSHUT1_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(XSHUT2_PORT, XSHUT2_PIN, GPIO_PIN_RESET); HAL_Delay(10); for (uint8_t i = 0; i < 2; i++) { /* 2. 只放开当前这一颗 */ if (i == 0) HAL_GPIO_WritePin(XSHUT1_PORT, XSHUT1_PIN, GPIO_PIN_SET); else HAL_GPIO_WritePin(XSHUT2_PORT, XSHUT2_PIN, GPIO_PIN_SET); HAL_Delay(2); /* 3. 用默认地址 0x29 访问,改成表里的新地址 */ if (VL53L1X_SetI2CAddress(0x29, addr_table[i]) != 0) return 1; /* 4. 改完立刻用新地址验证一次,避免后面才发现问题 */ if (VL53L1X_SensorInit(addr_table[i]) != 0) return 2; } return 0; }

这里有个细节容易翻车:SetI2CAddress写完之后器件立刻切到新地址,之后的任何一次通信都必须用新地址,包括紧接着的SensorInit。我第一次写这段的时候忘了改,第二颗模组的初始化一直失败,查了半天以为是地址分配冲突,实际上是自己在用旧地址跟它说话。

6. 二次开发:多模组地址分配与中断化改造

把单点工程改成多点,核心改动其实只有两处,但每一处都要验证到位。第一处是地址分配,上面那段VL53_MultiInit可以直接拿去用,注意addr_table里的值要避开总线上已有的从机地址,比如常见的 EEPROM 是 0x50、OLED 是 0x3C,别撞上。地址分配完之后给每个模组存一个句柄结构体,里面放 7 位地址、对应的 XSHUT 引脚、最近一次有效距离和状态计数,上层按句柄访问,代码就不会乱。

第二处是把轮询改成中断。两个模组的 GPIO1 分别接两个 EXTI 引脚,中断服务函数里只做一件事:把对应的volatile uint8_t flag置 1。主循环里扫这两个标志,谁置位就读谁、清谁。这里有个坑是共用中断线的情况——如果两个引脚落在同一个 EXTI 线上,服务函数里必须先判断是哪个引脚触发的,否则会出现「清了一个中断,另一个永远等不到」的假死。

改造完怎么验证?我一般分三步走。第一步静态验证:目标固定在 200mm、500mm、1000mm 三个位置,每个位置连测 100 帧,统计有效帧率和标准差,标准差超过 20mm 说明参数还没调好。第二步动态验证:拿一块白板从远到近匀速移动,看距离曲线是不是单调的,中间有没有掉帧或者跳点。第三步干扰验证:用手机手电筒直射模组窗口,模拟强环境光,看有效帧率掉多少,掉到 50% 以下就得考虑加滤光片或者缩 RO,别等到装到现场才发现白天不能用。

从那以后我每次改完测距参数,都会强制走一遍这三步验证,尤其是第一步的 100 帧统计——它比盯着串口看十条数据靠谱得多,很多偶发的跳变只有靠统计才暴露得出来。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询