简介:面向STM32F7系列嵌入式开发者的PAJ7620手势传感器驱动工程,以STM32F740为主控,演示通过I2C/SPI接口完成传感器初始化、手势数据读取与动作识别。压缩包内含234个文件,其中122个.h头文件与108个.c源文件覆盖HAL库配置、外设驱动和业务逻辑,另附Keil工程文件、hex固件及启动汇编,整体1.66MB,可直接编译烧录。已有144人浏览学习。工程完整包含GPIO引脚配置、时钟与总线初始化、PAJ7620寄存器写入、手势采集解析及响应处理代码,并给出中断或主循环两种应用思路,适合智能家居、人机交互等项目参考,也可作为学习ARM Cortex-M7外设编程和I2C/SPI通信的实战案例。
1. 为什么说 STM32 驱动 PAJ7620 传感器做手势检测,难点不在算法
STM32 驱动 PAJ7620 传感器做手势检测,最常见的误区是把它当成摄像头方案,一头扎进图像算法里。PAJ7620 是 PixArt 的光学手势芯片,内部把红外收发和手势识别算法全封装好,通过 I2C 直接吐出手势编号:上下左右、前推后拉、顺逆时针加摆动,共九种。F7 要做的只是把寄存器读回来映射成语义,真正的坎在初始化序列、Bank 切换和 I2C 时序上,这正是这份工程包解决的事。
工程适合想在非接触交互上快速出原型的工程师,也适合想在 STM32F7 上跑通 HAL 驱动参考的初学者。标题里的 F740 在 ST 官方型号表里并不存在,工程按 F7 系列通用方式组织,F746、F767 都能直接套。下面按拆包顺序把接线、初始化和排错一次讲清。
2. 硬件连接与 I2C 初始化:F7 引脚怎么接、参数怎么配
2.1 PAJ7620 的引脚定义:3.3V、开漏 INT 和上拉电阻
PAJ7620 模块的引脚一般是 VCC、GND、SCL、SDA、INT,部分板子还会引出 AD_SEL。VCC 接 3.3V,优先按模块丝印来,如果模块板载了 LDO 也可以接 5V;没有 LDO 的模块直接上 5V 就等着冒烟。SCL/SDA 接 F7 的 I2C 引脚,我习惯用 I2C1 的 PB8/PB9,CubeMX 里选 I2C1 后会自动列出所有可用引脚,挑一组顺手的就行。
INT 是开漏输出,检测到手势后会拉低或者拉高,具体电平取决于模块设计。开漏意味着它本身没有主动驱动高电平的能力,必须接上拉电阻,常规模块上已经贴了 4.7k 或 10k。自己画板的话这个电阻一定要补,否则中断模式根本读不到信号。I2C 走的是 3.3V 电平,直接连 F7 的 GPIO 即可,不需要额外电平转换。
表格:F7 与 PAJ7620 接线表
| PAJ7620 引脚 | STM32F7 引脚 | 说明 |
|---|---|---|
| VCC | 3.3V | 无板载 LDO 时严禁接 5V |
| GND | GND | 与 F7 共地 |
| SCL | PB8(I2C1_SCL) | 外部上拉 4.7k 到 3.3V |
| SDA | PB9(I2C1_SDA) | 外部上拉 4.7k 到 3.3V |
| INT | PC13 或任意 EXTI 引脚 | 开漏输出,需上拉 |
| AD_SEL | GND | 接地保持默认从机地址 |
SCL 和 SDA 的上拉电阻是最容易忽略的一环。F7 内部虽然也有上拉,但强度有限,而且芯片手册不建议拿内部上拉当 I2C 上拉用。只要模块没有自带电阻,就外挂两个 4.7k 到 3.3V,总线空闲时才能稳定拉高。上了拉之后用万用表量一下 SCL/SDA 的空闲电平,应该都在 3.3V 附近,低于 2.5V 就要怀疑上拉没贴好或者引脚配置成了推挽。
2.2 CubeMX 里的 I2C1 参数:速率、地址位宽与超时
打开工程里的 .ioc 文件,或者自己新建一个 STM32F746/F767 工程,把 PAJ7620 挂在 I2C1 上。CubeMX 里的配置并不复杂,但有几个参数会直接影响能不能通信起来。我第一次直接选了 400kHz,结果初始化就丢 ACK,后来降到 100kHz 一切正常。这个传感器的 I2C 时序窗口没有想象中宽,初次调通建议先按标准模式跑。
表格:CubeMX I2C1 关键参数
| 参数项 | 建议值 | 说明 |
|---|---|---|
| I2C Speed Mode | Standard Mode | 先跑 100kHz,稳定后再试 Fast Mode |
| Clock Speed (Hz) | 100000 | 总线负载重时保持 100k 最稳 |
| Addressing Mode | 7-bit | PAJ7620 是 7 位从机地址 |
| Own Address | 0x00 | 本机作为主机用不到,保持默认 |
CubeMX 生成代码后,I2C1 的 GPIO 会自动配成复用开漏模式,GPIO 速度可以选 High。这里有个 F1 和 F7 的差别:F1 时代很多人习惯把 SCL/SDA 配成推挽输出再靠外部上拉,F7 上直接选开漏复用更符合 HAL 的习惯。走查一遍 HAL_I2C_MspInit 函数,确认 GPIO 配置里确实是 GPIO_OPEN_DRAIN,如果拷贝了 F1 的工程过来,经常这里还是 GPIO_PUSH_PULL,虽然偶尔也能跑,但时序余量会差不少。
2.3 HAL 读写封装:DevAddress 到底该填 0x39 还是 0x73
HAL 库 I2C 传输函数的 DevAddress 参数填的是 7 位从机地址,而 PAJ7620 数据手册里给的 0x73/0x74 是 8 位写读地址。很多从 Arduino 移植过来的例程直接拿 0x73 传给 HAL,结果就是第一个字节发出去从机不 ACK。7 位地址就是 0x39,HAL 内部会自己左移一位,千万不要再手动左移,否则发出去的地址变成 0xE6,整个 I2C 总线上根本没人认。
#define PAJ7620_I2C_ADDR (0x39) /* 7-bit address,别传 0x73 */ #define PAJ7620_REG_BANK_SEL (0xEF) HAL_StatusTypeDef paj7620_i2c_write_reg(uint8_t reg, uint8_t val) { uint8_t buf[2] = { reg, val }; return HAL_I2C_Master_Transmit(&hi2c1, PAJ7620_I2C_ADDR, buf, 2, 100); } HAL_StatusTypeDef paj7620_i2c_read_reg(uint8_t reg, uint8_t *val) { HAL_StatusTypeDef st; st = HAL_I2C_Master_Transmit(&hi2c1, PAJ7620_I2C_ADDR, ®, 1, 100); if (st != HAL_OK) { return st; } return HAL_I2C_Master_Receive(&hi2c1, PAJ7620_I2C_ADDR, val, 1, 100); }这段封装是后面所有驱动代码的基础。写寄存器时把寄存器地址和值拼成一个两字节数组一次发出去;读寄存器时先发寄存器地址,再发起一次读。HAL 的 Master_Transmit 和 Master_Receive 每次传输都会带 STOP 条件,对 PAJ7620 这种时序敏感的设备来说,比用 HAL_I2C_Mem_Read 的连续传输方式更稳。超时时间 100ms 对单字节操作足够,I2C 总线异常挂死时能及时返回错误。
提示:不要在这里用 HAL_I2C_Mem_Read,虽然它能少写一行代码,但部分 F7 型号的 I2C 外设对 PAJ7620 的 Restart 时序兼容性不好,偶发读空。分段 Master_Transmit/Receive 是我验证过最稳的写法。
3. 驱动源码核心:Bank 切换和初始化序列,逐条拆给你看
3.1 Bank0 和 Bank1:为什么 PAJ7620 要分两层寄存器
PAJ7620 内部寄存器分两个 Bank,通过地址 0xEF 写入的值来切换。Bank0 是系统、感光和输出配置,Bank1 是手势检测配置与结果。这个设计和大多数传感器不一样,也是第一次看它驱动代码时最容易懵的地方:寄存器地址看着连续从 0x00 到 0xFF,但同一个地址在不同 Bank 下含义完全不一样。0x43 在 Bank1 是手势结果,在 Bank0 里可能是完全不相干的其他配置。
读手势结果前要先写 0xEF=0x01 切到 Bank1,改配置时又要写 0xEF=0x00 切回 Bank0。如果初始化完 Bank1 后直接去读 0x43 没问题,但中途一旦写过 Bank0 的寄存器,忘了切回去,读到的就是另一层的数据。常见表现是手势 ID 恒为 0xFF 或者数值乱跳,跟传感器坏了似的。理解了 Bank 机制,这类问题大多能定位到 "当前在哪个 Bank" 这一个问题上。
3.2 初始化序列:按照寄存器表走一遍
PAJ7620 上电后不能马上初始化,典型要求是等 250ms 左右再动 I2C。工程里 paj7620_init() 的第一件事就是延时而不是写寄存器,这个顺序很多人忽略,结果第一片 NACK 就来了。初始化序列本身接近一百项,官方把它作为一张表给出来,驱动代码里最常见的组织方式就是 const 数组逐条写。
typedef struct { uint8_t reg; uint8_t val; } paj7620_reg_t; static const paj7620_reg_t s_init_seq[] = { /* Bank0:基础配置 */ { 0xEF, 0x00 }, /* 切到 Bank0 */ { 0x00, 0x00 }, /* 唤醒传感器 */ { 0x04, 0x00 }, { 0x40, 0x21 }, /* 手势模式相关,vendor 默认值 */ /* ... 中间约 100 项,完整数组见 paj7620.c ... */ /* Bank1:手势检测块 */ { 0xEF, 0x01 }, /* 切到 Bank1 */ { 0x02, 0x0F }, /* 手势使能,vendor 默认 */ { 0x03, 0x00 }, /* ... 中间项见完整数组 ... */ { 0xEF, 0x00 }, /* 切回 Bank0,准备读取 */ }; int paj7620_init(void) { uint16_t i; HAL_Delay(300); /* 等 PAJ7620 内部起振和算法加载完成 */ for (i = 0; i < sizeof(s_init_seq) / sizeof(s_init_seq[0]); i++) { if (paj7620_i2c_write_reg(s_init_seq[i].reg, s_init_seq[i].val) != HAL_OK) { return -1; } HAL_Delay(1); /* 给每个寄存器写入留一点间隔 */ } return 0; }这段代码有三个值得留意的点。第一,最前面的 HAL_Delay(300) 是为了避开上电不稳定窗口,不是随便加的,PAJ7620 内部有成像和算法链路,上电到 I2C 就绪需要时间。第二,每写一个寄存器后延时 1ms,这个延时在单独挂 PAJ7620 时不是必须的,但当 I2C 线上还挂了 OLED、总线负载较重时,连续快速写会偶发丢 ACK,留 1ms 能明显降低概率。第三,0xEF 的两次切换不是配置,而是上下文切换,它决定后面一串寄存器落在哪个 Bank。切错 Bank 的话,后面所有写入都写到了错误的地方,初始化看起来成功但实际没生效。
3.3 初始化失败怎么定位:ACK 错误和回读校验
初始化失败先要分两类:一是在某个寄存器上返回 HAL_ERROR,二是全流程返回成功但手势读不出来。前者大概率是地址或 Bank 切换问题,后者要看是不是真的写进去了。回读校验是最直接的验证手段,写完关键寄存器立刻读回来比对,能快速缩小排查范围。
int paj7620_reg_verify(uint8_t reg, uint8_t expect) { uint8_t val = 0; if (paj7620_i2c_read_reg(reg, &val) != HAL_OK) { return -1; /* 读失败,多半是 I2C 时序或地址问题 */ } return (val == expect) ? 0 : -2; /* 值不对,可能 Bank 没切对 */ }验证时一定要确认当前在哪个 Bank。比如切到 Bank0 读 0x00,期望值是 0x00;切到 Bank1 读手势结果寄存器,期望值应该在 0x00 到 0x09 之间。如果读回来恒为 0xFF,先检查 Bank;如果读回来恒为 0x00,先怀疑 I2C 地址是不是写成了 0x73。这套回读逻辑可以直接加在 paj7620_init() 尾部,把每一段的第一个和最后一个寄存器都验一遍,初始化失败时能立刻知道是第几段挂了。
4. 手势读取与中断模式:轮询、EXTI 和九种手势编号映射
4.1 从 Bank1 的 0x43 读出九种手势
手势结果在 Bank1 的 0x43 寄存器,低 4 位是手势编号。读取前先写 0xEF=0x01 切到 Bank1,读完再切回 Bank0,这样后续任何写配置的代码都不会踩错 Bank。这个"读完切回"的习惯是我吃了亏之后养成的,因为主循环里往往还有 LED、蜂鸣器之类的控制代码,它们都依赖 Bank0。
#define PAJ7620_REG_BANK_SEL (0xEF) #define PAJ7620_GESTURE_RESULT (0x43) #define GES_UP 0x01 #define GES_DOWN 0x02 #define GES_LEFT 0x03 #define GES_RIGHT 0x04 #define GES_FORWARD 0x05 #define GES_BACKWARD 0x06 #define GES_CLOCKWISE 0x07 #define GES_ANTI_CLOCKWISE 0x08 #define GES_WAVE 0x09 int paj7620_get_gesture(uint8_t *ges) { uint8_t val = 0; if (paj7620_i2c_write_reg(PAJ7620_REG_BANK_SEL, 0x01) != HAL_OK) { return -1; } if (paj7620_i2c_read_reg(PAJ7620_GESTURE_RESULT, &val) != HAL_OK) { return -2; } paj7620_i2c_write_reg(PAJ7620_REG_BANK_SEL, 0x00); /* 切回 Bank0 */ *ges = val & 0x0F; /* 高四位在不同版本含义不一致,只取低四位 */ return 0; }注意最后的掩码 0x0F。PAJ7620 的手势结果寄存器低 4 位是手势编号,高 4 位在不同批次和版本里含义不完全一致,直接整个字节拿去比对很容易误判。只取低四位是目前兼容性最好的做法。切回 Bank0 那一步即使失败也不要忽略返回值,但主逻辑里一般不用卡死,继续跑下一帧就行,毕竟手势是连续事件,丢一帧不影响。
表格:手势编号与触发场景
| 编号 | 宏 | 触发场景 |
|---|---|---|
| 0x01 | GES_UP | 手掌在传感器上方 5-15cm 上移 |
| 0x02 | GES_DOWN | 手掌下移 |
| 0x03 | GES_LEFT | 手掌左移 |
| 0x04 | GES_RIGHT | 手掌右移 |
| 0x05 | GES_FORWARD | 手掌向前推近传感器 |
| 0x06 | GES_BACKWARD | 手掌向后远离 |
| 0x07 | GES_CLOCKWISE | 在传感器上方顺时针画圈 |
| 0x08 | GES_ANTI_CLOCKWISE | 逆时针画圈 |
| 0x09 | GES_WAVE | 手掌在传感器前晃动 |
4.2 轮询方案:用 HAL_Delay 或定时器周期读
轮询是最简单的方案,适合原型验证。PAJ7620 内部检测频率约 150Hz,手势本身持续几百毫秒,主循环 50ms 轮询一次完全够用。轮询间隔太短反而容易读到中间态,间隔太长会漏掉快手势。
uint8_t gesture = 0; while (1) { if (paj7620_get_gesture(&gesture) == 0 && gesture != 0) { gesture_dispatch(gesture); /* 分发到具体业务 */ } HAL_Delay(50); /* 约 20Hz 轮询 */ }这里有个容易被忽视的点:手势结果寄存器读到之后并不会自动清零,PAJ7620 是不断更新这个值。如果一直拿到同一个非零手势,gesture_dispatch 会被反复触发。解决方式要么在 dispatch 里做边沿检测,只处理手势编号变化的时刻;要么每次读出后用一个变量记住上一帧,连续相同不重复触发。轮询方案的劣势就是 CPU 空转,但在裸机项目里 20Hz 的 I2C 轮询开销极低,大部分场景完全够用。
4.3 中断方案:用 INT 引脚配合 EXTI,事件驱动省 CPU
想省 CPU 就用 INT 引脚,PAJ7620 检测到手势后 INT 输出有效电平。CubeMX 里把 INT 引脚配成 GPIO_EXTI,触发沿根据模块确定,常见模块是低电平有效,配下降沿触发。HAL 统一走 HAL_GPIO_EXTI_Callback 回调,在里面读取手势结果。
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { uint8_t ges = 0; if (GPIO_Pin == PAJ7620_INT_PIN) { if (paj7620_get_gesture(&ges) == 0 && ges != 0) { gesture_dispatch(ges); /* 只置标志位,别做耗时操作 */ } } }回调里跑 I2C 读写其实不太推荐,因为 I2C 在中断上下文里可能被更高优先级中断打断,偶发超时。常见做法是回调里只置一个g_gesture_ready = 1的标志,主循环检测到标志后再去读手势结果。PAJ7620 的 INT 是可重复触发的,这次丢了下一帧还会再来,所以不用担心中断丢失导致功能永久失效。中断方案适合低功耗或任务较多的场景,轮询则适合代码尽量简单的场景,选哪种取决于你的主循环还有多少事要做。
5. 避坑:STM32F7 上驱动 PAJ7620 最常翻车的五个点
5.1 I2C 一直 NACK:DevAddress 填了 0x73 而不是 0x39
现象:HAL_I2C_Master_Transmit 返回 HAL_ERROR,逻辑分析仪上看到主机发出地址字节后,从机没有拉低 SDA 应答。反复检查接线和供电都正常。
原因:PAJ7620 数据手册和大多数 Arduino 例程用的是 8 位地址 0x73/0x74,HAL 库要求填 7 位地址。直接填 0x73,HAL 内部再左移一位,总线上实际发出的地址是 0xE6,从机当然不应答。更迷糊的是网上有些文章建议把 0x73 左移一位传给 HAL,等于把 8 位地址左移成 0xE6,错得更远。
解决:DevAddress 统一填 0x39。从别处移植代码时,把宏定义改成 0x39,再全局搜一遍所有传地址的地方,确认没有二次左移。
5.2 初始化成功但手势结果全是 0xFF:Bank 没切对
现象:paj7620_init() 全流程返回 0,但读 0x43 永远拿到 0xFF,手在传感器前怎么挥都没反应。代码看起来没问题,I2C 通信也正常。
原因:当前实际停在 Bank0,0x43 在 Bank0 是另一个寄存器。初始化序列里有几处 Bank 切换,只要中间任何一次 0xEF 写入被跳过或者写失败,后续的配置就错位了。还有一种情况是其他模块的代码在初始化之后又写了 0xEF,把 Bank 改乱了。
解决:每次读手势结果前强制先写 0xEF=0x01,读完立刻写回 0x00。不要依赖"之前切过所以现在是 Bank1"这种假设。拿到工程后第一件事就是全局搜索 0xEF 的操作,确认只有初始化里和读取函数里动了 Bank 选择。
5.3 上电立刻初始化失败:PAJ7620 的启动窗口
现象:按下复位键后马上执行 paj7620_init,第一个寄存器就 NACK;等两三秒再初始化又完全正常。断电重新上电必现,复位后则偶发。
原因:PAJ7620 内部上电后需要时间完成晶振起振和算法加载,这个期间 I2C 从机逻辑还没准备好。F7 主频高,从复位到 main() 可能只用了十几毫秒,传感器还没醒过来。
解决:init 里的 HAL_Delay(300) 不能省,而且延时期间不要碰 I2C。如果项目里有其他外设需要在开机时尽快配置,可以把 PAJ7620 的初始化放到最后,给传感器留足上电时间。
5.4 F7 的 I2C 状态卡死:一次 NACK 之后所有传输全部超时
现象:某次 I2C 通信出错后,后续所有 Master_Transmit 都返回 HAL_BUSY 或 HAL_TIMEOUT,不复位不恢复,只能重新上电。
原因:HAL 库的 I2C 驱动在收到 NACK 或总线错误后,外设状态寄存器还停留在 BUSY。这是 F7/F4 上 HAL I2C 的经典问题,不是 PAJ7620 独有的,但在传感器热插拔或者供电抖动时很容易被触发。
解决:每次 I2C 返回错误后,对外设做一次软复位。
static void paj7620_i2c_reset(void) { HAL_I2C_DeInit(&hi2c1); HAL_Delay(1); HAL_I2C_Init(&hi2c1); }在 paj7620_i2c_write_reg 和 paj7620_i2c_read_reg 的错误分支里调用这个函数,比单纯重新发送数据可靠得多。逻辑上就是"出错先复位再重试",代价是几十微秒的延时,对几十毫秒一次的手势读取毫无影响。
5.5 I2C 总线上挂了 OLED 等设备:速率和时序互相干扰
现象:PAJ7620 单独跑一切正常,跟 OLED 挂同一条 I2C 总线后偶尔丢手势,OLED 花屏,甚至初始化失败。单独测试两个设备都正常。
原因:总线电容变大,400kHz 下沿不够陡。另一个原因是某些 I2C 设备在连续传输时对 Restart 条件敏感,PAJ7620 和 OLED 交替访问时,F7 的 I2C 外设产生的时序和单个设备独占时不一样。网上常见的 HAL 库驱动 OLED 代码默认按自己的时序来,两个设备叠加后就出问题。
解决:总线频率先降到 100kHz,这是最简单的一招。每个设备访问完成后确保 STOP 条件完整,HAL 的 Master_Transmit 天然带 STOP,用 Mem_Read 时要注意部分写法会把 STOP 吞掉。如果还不行,把 OLED 换到软件模拟 I2C,硬件 I2C 留给 PAJ7620,软件 I2C 跑 OLED 这种低速设备绰绰有余。
6. 把九种手势变成可用交互:状态机消抖、串口调试与结果验证
6.1 连续三帧一致才上报
原始手势数据直接用于产品会误触,手掌抖一下就可能丢出一个左移。工程里常见做法是做一个连续帧消抖:同一手势连续出现三帧才判定有效。帧间隔取 50ms,三帧约 150ms,既不会迟钝,也不会漏掉快速滑动。
void gesture_sm(uint8_t ges) { static uint8_t last_ges = 0; static uint8_t cnt = 0; if (ges == last_ges) { if (ges != 0 && cnt < 3) { cnt++; } if (cnt >= 3) { cnt = 0; gesture_action(last_ges); /* 稳定三帧,执行动作 */ } } else { last_ges = ges; /* 手势变化,重新计数 */ cnt = 0; } }这个状态机放在轮询循环里每次调用即可。gesture_action 里做具体业务,简单点就是点 LED 或者蜂鸣器,复杂一点可以驱动 WS2812B 灯环,看项目需求。消抖的本质是牺牲 150ms 延迟换取稳定性,对于菜单切换、翻页这类交互完全可接受。
6.2 串口输出与手势验证
手势 ID 打出来是最快的验证方式。F7 工程里配一个调试串口,重定向 printf 后用串口助手观察。电脑侧如果是 CH340/CP2102 这类 USB 转串口,驱动装好后就能识别;用 ST-Link/J-Link 的话,调试器和虚拟串口的驱动也一并装好,省一根外接 USB-TTL。
printf("gesture=0x%02X\r\n", gesture);波特率统一用 115200 8N1,串口没输出时先查波特率和 TX/RX 是否接反。验证手势时,手掌在传感器前方 5-15cm 距离做标准动作,不要贴太近,PAJ7620 的红外窗口对距离有要求,贴太近反而进入盲区。如果上下左右规律性混淆,先检查传感器的安装朝向,再回头看代码,方向问题九成是硬件装反了。
我从第一次在这个项目上被 0x73 卡了一整天之后,现在不管换到哪颗 F7、哪个手势传感器,第一件事都强制追问一句:这个地址是 7 位还是 8 位?同一份初始化序列也会先过逻辑分析仪确认 Bank 切换,再谈其他排查。这套顺序看着啰嗦,但每次都能把手势驱动的调试时间压到半天以内。工程包拿到后,先把 I2C 地址宏和 Bank 切换两个点核对一遍再编译,基本就能直接跑起来。希望帮到你。
本文还有配套的精品资源,点击获取