从做室内小车避障到无人机悬停测试我对“怎么知道设备动了、往哪动、动了多少”这个问题折腾了很久。编码器只能测轮子转速IMU会随时间漂移超声波和红外测距根本测不了二维位移。最后把目光落在ESP32搭配PMW3901MB光流传感器这套组合上——一颗芝麻大小的芯片竟然能像光学鼠标一样输出X/Y方向的相对位移数据。这篇内容我打算把硬件连接、寄存器读取、运动检测逻辑和完整代码一次讲透尤其会重点聊聊那些在数据手册里根本找不到、只有实际焊线写代码才会踩的坑。1. 为什么在众多“移动检测”方案里选了PMW3901MB1.1 从鼠标传感器说起PMW3901MB本质上就是一颗被“拆出来”的光学鼠标传感器。光学鼠标底部那颗镜头持续拍摄桌面图像通过对比相邻两帧图像的像素移动计算出鼠标的位移向量。PMW3901MB就是把这个核心组件单独拿出来做成一颗通过SPI接口直接输出X/Y位移的传感器。和普通鼠标传感器最大的区别在于PMW3901MB的配套镜头设计成了长焦结构可以在距离检测面大约20到80毫米的范围内工作而不是像鼠标那样必须贴着桌面。这个特性让它天然适合装在无人机底部、小车底盘或者机械臂末端用于测量相对地面的运动。在正式选用之前我对比过几类常见方案这里把当时做的选型分析记录下来方案测量维度典型精度主要问题编码器轮子转动很高打滑时完全失真IMU加速度/陀螺仪加速度/角速度中积分漂移严重超声波/红外测距Z轴高度中无法测量平面位移普通鼠标传感器X/Y位移高必须贴近表面PMW3901MB光流X/Y位移高对表面纹理有要求关键在于如果你的目标是“知道车体在平面上怎么移动”编码器会受打滑影响IMU积分几分钟内就会漂出好几米只有光流这种直接对地面取像的方案能够给出真正意义上的相对位移。这也是为什么很多室内定位无人机项目都会挂一颗PMW3901MB的原因。1.2 这枚传感器到底适合什么场景PMW3901MB输出的是一帧相对上一帧的位移增量delta_x、delta_y使用场景非常聚焦室内无人机悬停辅助飞控通过光流反馈修正水平漂移。机器人循迹和里程计补充轮式机器人结合光流修正打滑误差。机械臂末端微位移检测检测末端执行器相对工件的微小移动。手势识别实验在一定高度范围内挥动手掌传感器输出对应的运动轨迹。这套方案并不适合需要绝对位置的场景它只能告诉你“相对上次读数移动了多少”无法告诉你“现在在房间的哪个坐标点”。这是光流传感器的固有限制做项目选型时一定要心里有数。2. 硬件连接先别急着写代码把这几根线处理好2.1 引脚分配与接线表PMW3901MB模块目前市面上常见的封装是16引脚邮票孔核心引脚就六个VCC、GND、SCK、MOSI模块上标注为SDIO、MISO模块上标注为SDA、CS片选。部分模块还会引出INT中断脚实际应用中可以不用。我用的ESP32开发板是标准的30引脚版本SPI默认走VSPI通道接线如下PMW3901MBESP32引脚说明VCC3V3传感器供电3.3VGNDGND共地SCKGPIO18SPI时钟MOSI/SDIOGPIO23主机输出MISO/SDAGPIO19主机输入CSGPIO5片选低电平有效这里要特别提醒一点不要把MOSI和MISO接反。PMW3901MB模块上丝印有的写SDIO、有的写SDA实际上SDIO就是MOSI方向SDA就是MISO方向。我第一次接线时想当然认为SDA是I2C那根数据线结果SPI完全读不到数据。2.2 供电和电平的几个细节PMW3901MB工作电压是3.3VESP32的开发板一般也支持3.3V对外输出电平兼容没有太大问题。但这颗芯片工作时内部图像采集和计算对电源纹波比较敏感实测下来有两个经验劣质USB线供电时读取到的数据会周期性出现异常跳变尤其在小车电机启动瞬间。建议单独用5V/2A以上的电源给开发板供电不要依赖电脑USB口。在模块的VCC和GND之间并联一个0.1uF陶瓷电容和10uF电解电容能明显减少电机启停带来的电压跌落。很多成品模块上只焊了一个小电容条件允许的话建议自己补一颗。还有一点容易被忽略的是接线长度。SPI时钟在1MHz下走杜邦线问题不大但插针到插针之间超过15厘米以后波形质量会明显下降表现为偶发性读到0xFF或者位移数据偶尔跳到离谱值。如果实在需要加长建议使用屏蔽线或双绞线并降低SPI时钟到500kHz。3. 寄存器读取PMW3901MB 是怎么“算”出位移的3.1 读懂数据手册里的关键寄存器很多人买了PMW3901MB后第一件事就是翻数据手册结果被一堆寄存器吓住。其实日常使用不需要理解全部寄存器只要抓住下面几个0x00 Product_ID产品标识PMW3901MB一般读出0x49部分PMW3900型号读出0x3F。0x02 Motion运动状态寄存器bit7为1表示检测到运动。0x16 Motion_Burst一次性连续读出运动数据包括Delta_X_L、Delta_X_H、Delta_Y_L、Delta_Y_H、SQUAL、Shutter_Upper、Shutter_Lower等。0x07 SQUAL表面质量值代表当前画面纹理质量数值越高说明传感器“看”得越清楚。Motion_Burst是日常读取最常用的寄存器它用一次片选拉低就能把整组数据连续传输回来比一个一个寄存器单独读取效率高得多而且能保证同一时刻的快照数据一致性。3.2 SPI 时序与读写函数PMW3901MB的SPI通信规则并不复杂。读寄存器时主机先发送一个字节高7位是寄存器地址第8位最高位置1表示读操作随后主机继续发送一个任意字节同时从MISO读取传感器返回的数据。写寄存器则相反最高位置0表示写操作然后紧接着发送要写入的字节。这里有一个很关键的坑PMW3901MB使用的不是常见的SPI Mode 0而是SPI Mode 3即时钟空闲为高电平、数据在第二个边沿采样。如果用默认的Mode 0去读大概率会读到全0xFF或者恒定的0x00。完整读写函数在Arduino环境下如下#include SPI.h #define PMW_CS 5 void pmw_writeReg(uint8_t addr, uint8_t data) { digitalWrite(PMW_CS, LOW); SPI.transfer(addr 0x7F); SPI.transfer(data); digitalWrite(PMW_CS, HIGH); } uint8_t pmw_readReg(uint8_t addr) { uint8_t val; digitalWrite(PMW_CS, LOW); SPI.transfer((addr 0x7F) | 0x80); val SPI.transfer(0x00); digitalWrite(PMW_CS, HIGH); return val; }注意片选操作要严格包裹整个传输过程不能在两次transfer中间拉高再拉低否则传感器内部会认为一次事务结束了。3.3 用 Product_ID 验证接线是否成功写完上面两个函数之后我建议第一件事不要急着做运动检测而是读取0x00寄存器的Product_ID并打印到串口。void setup() { Serial.begin(115200); SPI.begin(18, 19, 23, PMW_CS); // SCK, MISO, MOSI, CS pinMode(PMW_CS, OUTPUT); digitalWrite(PMW_CS, HIGH); SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE3)); uint8_t pid pmw_readReg(0x00); Serial.print(Product_ID 0x); Serial.println(pid, HEX); }如果串口打印出0x49说明接线、SPI模式和读函数全部正确。如果读到0xFF或者乱跳的数字优先排查的是SPI Mode是否设成了Mode 3其次是MOSI和MISO是否接反。这一步排查能帮你省下大量调试时间因为后续所有问题都是建立在“能读到传感器数据”这个前提上的。4. 完整代码实战初始化、轮询、运动事件判断4.1 代码结构总览我写的完整示例代码分三层底层是SPI读写函数中间是光流数据读取函数上层是运动检测逻辑。这样分层的好处是后续如果要接飞控或者串口屏只需要替换上层逻辑底层驱动不用动。#include SPI.h #define PMW_CS 5 #define PMW_SCK 18 #define PMW_MISO 19 #define PMW_MOSI 23 #define PMW_INT 4 #define SQUAL_THRESHOLD 30 #define MOTION_THRESHOLD 20 #define WINDOW_MS 200 struct MotionData { bool motion; int16_t dx; int16_t dy; uint8_t squal; };4.2 初始化序列初始化时只需要对传感器做一次Power_Up_Reset然后延时等待内部固件完成启动。PMW3901有个特点硬件复位后内部会自动加载默认配置大部分情况下默认配置直接可用不需要像早期鼠标传感器那样手动写入几十个配置寄存器。void pmw_init() { pmw_writeReg(0x3A, 0x5A); // Power_Up_Reset delay(5); uint8_t pid pmw_readReg(0x00); Serial.print(Product_ID 0x); Serial.println(pid, HEX); uint8_t rid pmw_readReg(0x01); Serial.print(Revision_ID 0x); Serial.println(rid, HEX); }如果Power_Up_Reset之后读Product_ID仍然不是0x49可以试试再延时50毫秒重新读取。部分批次的模块内部固件加载时间偏长。4.3 运动读取与事件触发中间层用Motion_Burst一次性读取整组数据。从0x16开始连续读取时返回的字节依次是Motion寄存器、Delta_X_L、Delta_X_H、Delta_Y_L、Delta_Y_H、SQUAL、Shutter_Upper、Shutter_Lower。MotionData pmw_readMotionBurst() { MotionData m; uint8_t raw[7]; digitalWrite(PMW_CS, LOW); SPI.transfer((0x16 0x7F) | 0x80); uint8_t motion SPI.transfer(0x00); raw[0] SPI.transfer(0x00); raw[1] SPI.transfer(0x00); raw[2] SPI.transfer(0x00); raw[3] SPI.transfer(0x00); raw[4] SPI.transfer(0x00); raw[5] SPI.transfer(0x00); raw[6] SPI.transfer(0x00); digitalWrite(PMW_CS, HIGH); m.motion (motion 0x80) ! 0; m.dx (int16_t)((raw[1] 8) | raw[0]); m.dy (int16_t)((raw[3] 8) | raw[2]); m.squal raw[4]; return m; }4.4 串口输出效果为了验证传感器是否正常工作我先把数据直接打印到串口人工观察静止和移动时数值的变化。这里借助WINDOW_MS窗口累积位移避免逐次打印时数值零散看不出规律。void loop() { static long lastWindowTime 0; static int windowDX 0; static int windowDY 0; MotionData m pmw_readMotionBurst(); if (m.squal SQUAL_THRESHOLD) { windowDX 0; windowDY 0; delay(10); return; } windowDX m.dx; windowDY m.dy; if (millis() - lastWindowTime WINDOW_MS) { if (abs(windowDX) MOTION_THRESHOLD || abs(windowDY) MOTION_THRESHOLD) { Serial.print(MOTION ); Serial.print(windowDX); Serial.print( ); Serial.println(windowDY); } else { Serial.println(IDLE); } windowDX 0; windowDY 0; lastWindowTime millis(); } delay(10); }把传感器拿在手上静止时串口基本一直输出IDLE慢慢左右平移时会看到MOTION后面的第一个数字跟着手的方向变化。这一步就说明整套硬件和代码链路已经打通了。5. 运动检测策略阈值、方向判断与表面质量5.1 基础阈值怎么定阈值设置是整个运动检测最需要调的部分。PMW3901在静止状态下输出并不是严格为零而是会有一个±2左右的随机波动这主要是图像传感器噪声和光照变化引起的。如果阈值设置过低比如1那么静止时也会频繁触发运动事件设置过高比如50那么轻微移动就会被过滤掉。我推荐的做法是以200毫秒窗口内累计位移作为判断标准然后观察静止状态下的最大波动值在这个基础上留2到3倍余量。大多数室内环境下静止波动在±5以内因此20这个阈值是比较稳妥的起点。如果你希望灵敏度更高可以逐步下调到10甚至8前提是SQUAL过滤做得足够好。5.2 方向判断与多轴运动光流传感器输出的是相对位移增量方向判断其实非常简单。dx为正表示传感器相对表面向右移动或者说表面相对传感器向左移动dy为正表示向前移动。在具体项目里需要结合实际安装方向做坐标映射。如果你把PMW3901MB斜着安装比如无人机上为了避开起落架会旋转45度安装那么输出数据就需要做一次坐标旋转。这个转换在做无人机水平纠偏时尤其重要否则飞控收到的X/Y方向和实际机身坐标对不上会越修越偏。5.3 用 SQUAL 判断表面是否可靠SQUAL全称是Surface Quality反映的是当前画面纹理的可用程度。这个值在光滑白色桌面、玻璃表面会掉到十几甚至个位数而在纹理丰富的木质地板、地毯、哑光纸面上能到80以上。我踩过一个很深的坑在光滑桌面上调试时PMW3901输出的位移呈现出一种缓慢但有规律的漂移哪怕传感器完全静止dx/dy累积值也会慢慢变大。一开始我以为是传感器坏了后来打印SQUAL才发现只有18。这种情况下传感器实际上是在“猜”画面移动猜出来的结果自然不可信。所以运动检测里一定要加SQUAL过检if (m.squal SQUAL_THRESHOLD) { // 表面质量太差这段数据不可信 continue; }顺便说一句SQUAL值本身变化很慢不需要每次循环都打印可以在运动事件触发时顺带输出方便排查问题。5.4 高度和视角对检测结果的影响PMW3901MB的最佳工作距离不是贴地越近越好。模块底部到检测面的高度太近时传感器视野内能看到的纹理颗粒过大画面变化剧烈反而导致输出跳变高度太高时纹理又太小SQUAL下降。实测下来推荐的工作距离大约在30到50毫米。比如测小车位移时直接把PMW3901模块粘在底盘下面离地35毫米左右效果最好。室内无人机悬停时通常安装在机架正下方离地高度本来就会在30厘米以上这种距离下镜头焦距刚好合适问题不大。6. 避坑实录从“读不到ID”到“数据疯跳”6.1 坑一SPI 模式错了读不到寄存器这个坑我开头就警告过但还是值得单独拎出来说。PMW3901MB的SPI是Mode 3但Arduino的SPI库默认是Mode 0。如果你忽略了这一点读寄存器得到的结果永远是0xFF或者0x00全零完全没法判断是接线问题还是芯片问题。正确的启动语句是SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE3));另外SPI时钟频率不建议超过2MHz。虽然手册上标称支持更高频率但实际使用杜邦线和面包板时高频噪声很容易让数据误码。6.2 坑二供电不足导致的随机跳变有一段时间我把ESP32和PMW3901都接在同一个面包板的5V转3.3V稳压器上铜线又细又长。结果静止状态下数据完全正常只要一开小车电机位移数据立刻疯跳甚至出现瞬时几千的离谱值。排查了半天最后用示波器量到电机启动瞬间3.3V电压跌落了将近0.5V正好超过了PMW3901的工作电压下限。解决方案是给传感器独立用一个低压差稳压器或者至少在模块旁边加大电容。此后数据立刻就稳定了。6.3 坑三离地高度和表面材质的影响不同表面、不同高度下同一颗PMW3901的表现差异极大。木质桌面静止时几乎零漂移SQUAL高达90换到白色亚克力板后SQUAL掉到40静止漂移增大到±8。如果你的项目需要更换使用环境一定要在现场重新校验SQUAL和静止漂移情况。比如做室内无人机起飞前可以读取SQUAL低于30就直接报警提示“当前地面不适合光流工作”这比起飞后乱飘再处理要省事得多。6.4 坑四杜邦线飞线的干扰把PMW3901模块和ESP32之间用一束5根杜邦线连起来每根线约20厘米长在电机没有启动时一切正常但一旦有强电磁干扰源靠近比如开关电源、电机驱动线数据偶发异常就会明显增加。这个问题根源是飞线成了天线把SPI时钟信号和片选信号搞出了毛刺。处理办法很朴素缩短线距到10厘米以内SPI时钟降到500kHz并且在CS引脚对地加一个10kΩ上拉电阻防止CS悬空误触发。把这几步都做了之后干扰问题基本消失。我个人在实际操作中体会最深的是这种小传感器对周边环境的要求往往比芯片本身的功能更值得关注。PMW3901MB不是插上就能用的“黑盒”它的输出质量高度依赖供电、高度、表面纹理这些看起来很小、实际上决定项目成败的因素。如果你也准备拿ESP32搭配PMW3901MB做运动检测建议按这个顺序来做先调通SPI读到Product_ID再观察SQUAL确认表面质量然后打印静止数据确定阈值最后才接电机关联运动逻辑。把这四步走完整个项目就稳了一大半。