MTK平台GPIO模拟I2C驱动STK3310接近传感器实战
简介针对STK3310红外距离传感器在MTK平台上的驱动源码包面向嵌入式驱动开发与传感器应用学习者解决无硬件I2C控制器时通过GPIO模拟I2C与传感器通信的难题。驱动覆盖初始化配置、寄存器读写、数据采集与异常处理等关键环节代码保留在MTK平台实测验证的完整逻辑可直接参考或移植。资源共4个文件包含2个头文件与2个C源文件压缩包仅6KB文件划分清晰便于对照驱动框架逐模块研读。目前已有279人学习/下载作者说明在MTK平台验证可用。读者可获得STK3310驱动完整源码、GPIO模拟I2C时序控制方法以及相关芯片手册未明说的排错细节有助于理解传感器驱动从寄存器配置到上层数据上报的全流程。1. 为什么STK3310驱动在MTK平台上偏爱GPIO模拟I2C去年拆一个STK3310X.rar里面只有四个文件STKdriver.c、STKio.c、STKio.h、STKdriver.h。在MTK平台上调这颗距离传感器第一反应往往是去dts里找空闲I2C总线但多数时候总线不是被camera占着就是被TP占着。这时GPIO模拟I2C反而更省事任意两个空闲GPIO就能把STK3310拉起不跟任何总线抢地址特别适合驱动验证阶段。这篇就把这套驱动的寄存器、时序、MTK移植和调试方法完整拆一遍。适合在嵌入式系统里写过传感器驱动、或者正在为接近传感器发愁的工程师哪怕第一次接触照着步骤也能让数据先跑起来。2. STK3310寄存器映射与近距/远距判定参数2.1 先弄清楚STK3310到底在测什么STK3310不是ToF也不是超声波测距它本质是一颗红外接近传感器。内部有一个红外LED发射管和一个光电二极管接收管光线打到物体上反射回来被PD接收后转成ADC读数也就是PS值。物体越近反射光越强PS值越大。但PS值与距离不是线性关系而且很受物体颜色和反射率影响黑色物体贴得再近PS值也不一定比白色物体远距离时高。所以驱动里如果非要把PS值换算成“毫米距离”基本不可靠。正确做法是把STK3310当成一个开关量传感器设定高阈值和低阈值PS值从低向上跨过高阈值认为“靠近”从高向下低于低阈值认为“远离”。阈值之间留出迟滞区间避免物体停在临界位置时状态来回跳。后面STKdriver.c里的状态机本质上就是在做这件事。STK3310X的X后缀通常是版本或封装差异寄存器集与STK3310大体一致但不同批次可能有细微偏移。我手头这份驱动把寄存器偏移写得很集中都在STKio.h里改起来方便。下面我直接以这份STK3310X驱动里的宏定义来讲。2.2 关键寄存器偏移与掩码STKio.h中与PS相关的主要宏如下实际使用以你拿到的数据手册为准#define STK3310_PS_CONF 0x05 /* PS配置周期/脉冲 */ #define STK3310_PS_DATA_L 0x08 /* PS数据低字节 */ #define STK3310_PS_DATA_H 0x09 /* PS数据高字节 */ #define STK3310_PS_THDH 0x0A /* 高阈值 */ #define STK3310_PS_THDL 0x0B /* 低阈值 */ #define STK3310_FLAG 0x10 /* 中断和数据标志 */ #define STK3310_INT_CFG 0x02 /* 中断配置 */ #define STK3310_SW_RESET 0x2A /* 软复位寄存器 */ #define STK3310_FLAG_PS_IF 0x08 /* PS中断标志位 */这些宏的命名习惯是“设备_寄存器_功能”一眼就能看出用途。PS数据为什么分高字节低字节因为内部ADC位宽超过8需要把两个连续寄存器的内容组合成16位数。不同的配置下有效位不同但组合顺序基本都是高字节在前。各寄存器的作用汇总如下寄存器宏名作用驱动里的典型使用STK3310_PS_CONF设置PS测量周期、IRED脉冲数初始化时写入一组默认值STK3310_PS_DATA_L/H读取当前PS ADC值中断触发后读数据STK3310_PS_THDH靠近触发的阈值写入阈值上下限STK3310_PS_THDL远离触发的阈值写入阈值上下限STK3310_FLAG标志中断事件读取后必须清除STK3310_INT_CFG控制INT引脚输出条件使能PS中断STK3310_SW_RESET软复位整个芯片初始化第一步2.3 阈值设置与迟滞设置阈值常用函数如下这段代码在STKdriver.c里会多处调用static void stk3310_set_ps_threshold(struct stk3310_chip *chip, u8 high, u8 low) { stk3310_i2c_write(STK3310_PS_THDH, high); stk3310_i2c_write(STK3310_PS_THDL, low); chip-ps_high high; chip-ps_low low; }参数high和low就是两个阈值。high决定“靠近”的触发点low决定“远离”的解除点。两者之差是迟滞区间。我一般让高阈值比低阈值高出20%到50%。比如环境PS值平时只有0x0A左右把手放上去到0x60那么高阈值设0x40低阈值设0x20。这样手慢慢靠近到0x40触发一次离开后要掉到0x20以下才解除不会在临界点反复抖动。阈值具体数值不能拍脑袋。先写一个临时循环把PS值打印出来收集几个典型场景的数据空载、贴住、黑色物体、白色物体然后从这些数据里挑一个区分度最大的点。2.4 PS配置寄存器的参数取舍STK3310_PS_CONF里有几组可调项常见做法是先使用驱动默认值再根据功耗和灵敏度微调。下面这张表是我在调试时最常调整的三项参数对系统的影响典型取值测量周期周期越短响应越快功耗越大20ms~100msIRED脉冲数脉冲越多灵敏度越高功耗越大1~8驱动电流档位红外发射强度根据参考电路决定注意不同版本芯片的PS_CONF位排列不一样不要拿网上搜到的某个0x86直接套。正确做法是先把寄存器当前值读回来只改需要改的位。我一般先在驱动里加一个dump函数把PS_CONF原始值打印出来确认没有写错位。3. GPIO模拟I2C的实现时序约束与读写函数3.1 接线与上下拉GPIO模拟I2C的前提是电气上正确。STK3310的SCL和SDA是开漏接口需要外部上拉电阻到VDDIO。如果直接用推挽输出的GPIO硬接虽然大多数情况下也能跑但一旦两边同时输出相反电平总线电流会瞬间拉大时间长了可能损坏IO。MTK的GPIO可以配置成开漏模式打开内部上拉外部最好再放一颗2.2k到4.7k的上拉电阻走线短时可以只靠内部上拉但调试时不建议省。另外要注意电平匹配。STK3310X的VDDIO通常是1.8VMTK平台的GPIO bank有很多也是1.8V连接起来没问题。如果你拿到的传感器模块是5V逻辑必须加电平转换否则上电瞬间就会把主控IO打坏。这是GPIO模拟I2C最常见的硬件坑。3.2 I2C时序核心约束I2C标准模式频率100kHz对应SCL半周期5us。GPIO模拟时不能太慢否则传感器可能超时也不能太快GPIO操作本身有开销太快会导致毛刺。STKio.c里用了一个udelay(5)实测SCL频率大约50kHz~80kHz对STK3310这种传感器完全够用。时序上有三个点必须卡住Start条件SCL为高时SDA由高到低。数据稳定SCL为高电平期间SDA要保持稳定。Stop条件SCL为高时SDA由低到高。以及一个最容易出错的ACK第9个时钟周期主控要把SDA方向从输出切换成输入读取从机拉低的ACK信号。如果方向切换晚了读到的是自己输出的电平就会误判成从机应答成功。3.3 STKio.c中的核心读写函数下面这段是STKio.c的核心逻辑我把引脚操作抽出来了实际工程里要按照平台替换成对应的GPIO API#define SCL_GPIO 46 /* 示例MTK引脚号 */ #define SDA_GPIO 47 static void i2c_delay(void) { udelay(5); } static void i2c_start(void) { gpio_set_value(SCL_GPIO, 1); gpio_set_value(SDA_GPIO, 1); i2c_delay(); gpio_set_value(SDA_GPIO, 0); /* SCL高时SDA拉低 Start */ i2c_delay(); gpio_set_value(SCL_GPIO, 0); i2c_delay(); } static void i2c_stop(void) { gpio_set_value(SCL_GPIO, 0); gpio_set_value(SDA_GPIO, 0); i2c_delay(); gpio_set_value(SCL_GPIO, 1); i2c_delay(); gpio_set_value(SDA_GPIO, 1); /* SCL高时SDA拉高 Stop */ i2c_delay(); } static int i2c_write_byte(u8 data) { int i; for (i 0; i 8; i) { gpio_set_value(SCL_GPIO, 0); gpio_set_value(SDA_GPIO, (data (7 - i)) 0x01); i2c_delay(); gpio_set_value(SCL_GPIO, 1); i2c_delay(); } gpio_set_value(SCL_GPIO, 0); gpio_direction_input(SDA_GPIO); /* 释放SDA准备读ACK */ i2c_delay(); gpio_set_value(SCL_GPIO, 1); i2c_delay(); return gpio_get_value(SDA_GPIO); /* 0ACK1NACK */ }第9个周期读ACK前必须先把SDA切到输入模式。MTK的gpio_direction_input会立即生效但为了保险切模式后最好加一个i2c_delay再拉高SCL。函数返回0表示从机应答返回1表示没有设备或地址错误。3.4 寄存器读写包装有了字节操作再封装一层寄存器读写。这里我采用先写寄存器地址再单独发起Start读数据的方式逻辑简单也够用int stk3310_i2c_read_reg(u8 reg, u8 *value) { u8 addr STK3310_SLAVE_ADDR 1; i2c_start(); if (i2c_write_byte(addr 0xFE)) goto err; /* 写方向 */ if (i2c_write_byte(reg)) goto err; i2c_stop(); i2c_start(); if (i2c_write_byte(addr | 0x01)) goto err; /* 读方向 */ *value i2c_read_byte(0); /* NACK结束读取 */ i2c_stop(); return 0; err: i2c_stop(); return -EIO; }STK3310_SLAVE_ADDR一般是0x48具体看原理图上ADDR引脚的电平。addr 1是把它变成8位地址清0最后一位表示写置1表示读。i2c_read_byte(0)中参数0表示master发送NACK告诉从机这是最后一字节不要再发送了。这套代码的缺点是没有总超时保护如果SDA被异常拉低就永远卡在循环里。所以我通常还会在每步加一个循环计数超过100次直接返回错误避免驱动hang住。3.5 与硬件I2C的选型差异对比项GPIO模拟I2C硬件I2C控制器总线速度50~100kHz受GPIO翻转速率限制最高可达400kHz或更高CPU占用每bit都要参与中断里耗时明显控制器硬件处理CPU几乎不参与适配成本任意空闲GPIO不需要改I2C控制器需要空闲总线、地址分配、dts配置调试难度逻辑分析仪抓包方便定位快一旦停住可能是fifo或DMA问题如果你的MTK平台上还有空闲硬件总线我建议优先用硬件I2C性能更稳。GPIO模拟主要用在总线耗尽或前期验证阶段这个取舍要在项目启动时想清楚。4. STKdriver.c分析从初始化到数据上报4.1 四个文件的分工STK3310X的资源包不大但分层很典型文件职责STKio.cGPIO模拟I2C底层时序提供i2c读写函数STKio.h引脚定义、寄存器偏移、设备地址、读写函数声明STKdriver.c传感器初始化、阈值配置、数据读取、中断处理STKdriver.h对上层暴露的接口以及内部状态结构体定义这种分层的好处是换平台只改STKio.c传感器逻辑保持在STKdriver.c不动。我在实际项目里也延续这种做法底层IO和硬件耦合度很高但驱动核心逻辑可以在不同平台间复用。4.2 初始化序列STK3310X的初始化顺序我见过很多人直接抄驱动例程顺序错了也不自知。这里给一个经过验证的序列static int stk3310_init_chip(struct stk3310_chip *chip) { /* 1. 软复位让芯片回到已知状态 */ stk3310_i2c_write(STK3310_SW_RESET, 0x01); msleep(30); /* 2. 配置PS测量周期和脉冲数先用驱动默认值 */ stk3310_i2c_write(STK3310_PS_CONF, chip-default_ps_conf); /* 3. 设置高/低阈值产生迟滞区间 */ stk3310_set_ps_threshold(chip, chip-ps_high, chip-ps_low); /* 4. 开启PS中断INT引脚在事件发生时拉低 */ stk3310_i2c_write(STK3310_INT_CFG, STK3310_PS_INT_EN); /* 5. 清掉遗留中断标志避免probe后立刻进中断 */ stk3310_i2c_write(STK3310_FLAG, 0x00); return 0; }软复位后要等一段时间msleep(30)是一个保守值如果芯片上电时电源不稳最好延长到50ms。PS_CONF的默认值不能直接从例程抄要结合你的光学结构。第5步清理中断标志容易被忽略如果传感器在上电时产生了事件INT引脚已经拉低不清标志会导致驱动加载完成后第一个中断来得莫名其妙。4.3 中断线程与数据读取STK3310X通常使用INT引脚通知主控有事件。由于GPIO模拟I2C耗时较长绝对不能直接在硬中断上下文里读寄存器要注册成threaded irq。中断线程里先读FLAG判断是什么事件再读PS数据static irqreturn_t stk3310_irq_thread(int irq, void *data) { struct stk3310_chip *chip data; u8 flag 0; if (!chip) return IRQ_NONE; mutex_lock(chip-i2c_lock); stk3310_i2c_read_reg(STK3310_FLAG, flag); if (flag STK3310_FLAG_PS_IF) { chip-ps_value stk3310_read_ps(chip); if (chip-ps_value chip-ps_high) stk3310_report_status(chip, STK3310_NEAR); else if (chip-ps_value chip-ps_low) stk3310_report_status(chip, STK3310_FAR); } /* 清除中断标志允许下一次触发 */ stk3310_i2c_write(STK3310_FLAG, 0x00); mutex_unlock(chip-i2c_lock); return IRQ_HANDLED; }读FLAG时如果PS中断标志位置1说明PS数据已经更新。注意FLAG寄存器读完后需要主动清0不清的话INT引脚可能不会恢复高电平后续事件就丢了。清标志时写0x00还是写特定掩码不同版本略有差异我一般先写全部清零再观察中断是否正常。两个阈值判断的顺序很重要先判断大于等于高阈值触发靠近再判断小于等于低阈值解除。中间状态的PS值不触发任何上报这就是迟滞。4.4 数据上报方式在Linux内核驱动开发中接近传感器最常上报成input设备键值用KEY_PROXIMITY或者ABS_DISTANCE。STK3310这种开关量用ABS_DISTANCE的0/1表示static void stk3310_report_status(struct stk3310_chip *chip, int state) { if (chip-last_state state) return; input_report_abs(chip-input_dev, ABS_DISTANCE, state); input_sync(chip-input_dev); chip-last_state state; }每个状态只上报一次看起来很简单但能避免应用层收到重复事件。如果上层需要知道原始PS值还可以通过sysfs节点暴露比如读取/sys/devices/platform/stk3310/ps_value方便调试。4.5 并发保护GPIO模拟I2C不是原子的一次读写可能被中断打断。除了中断线程里用mutex在上层sysfs读取时也要拿同一个锁。如果中断是边沿触发两次中断之间可能夹着一个应用层读取不加锁会导致SDA状态错乱。我的习惯是给整个i2c操作序列加锁而不是只给单字节加锁。5. MTK平台移植GPIO选型、中断注册与吞吐验证5.1 GPIO选型在MTK项目上移植第一步不是写代码而是翻原理图和GPIO分配表。接近传感器一般放在手机顶部听筒附近走线会跨过多个模块。优先选择没有被EINT占用、没有默认复用功能的GPIO。注意有些GPIO bank在睡眠时会断电这种只能做普通输出不能做传感器中断。我调STK3310X时选了三个连续GPIO一个做SCL一个做SDA一个做INT。连续的好处是走线短跨bank会有电平域问题。选好后在STKio.h里把宏改成MTK的GPIO号即可#define STK3310_SCL_GPIO 46 #define STK3310_SDA_GPIO 47 #define STK3310_INT_GPIO 485.2 切换GPIO模式MTK的引脚默认可能是UART、PWM之类的复用功能必须先用平台API切回GPIO功能。常见写法如下mt_set_gpio_mode(STK3310_SCL_GPIO, GPIO_MODE_GPIO); mt_set_gpio_mode(STK3310_SDA_GPIO, GPIO_MODE_GPIO); mt_set_gpio_dir(STK3310_SCL_GPIO, GPIO_DIR_OUT); mt_set_gpio_dir(STK3310_SDA_GPIO, GPIO_DIR_OUT); mt_set_gpio_pull_enable(STK3310_SCL_GPIO, GPIO_PULL_ENABLE); mt_set_gpio_pull_select(STK3310_SCL_GPIO, GPIO_PULL_UP); mt_set_gpio_pull_enable(STK3310_SDA_GPIO, GPIO_PULL_ENABLE); mt_set_gpio_pull_select(STK3310_SDA_GPIO, GPIO_PULL_UP);顺序不能乱先设置mode再设置方向最后设置上下拉。如果先设方向再改mode引脚会瞬间输出不确定电平干扰传感器启动。INT引脚要设置成输入模式并打开上拉否则传感器通过外部上拉才能保证默认高电平。5.3 中断注册与唤醒INT引脚注册采用request_threaded_irq中断触发方式选择下降沿因为STK3310X的INT有效时拉低int stk3310_setup_irq(struct stk3310_chip *chip) { int irq gpio_to_irq(STK3310_INT_GPIO); int ret; ret request_threaded_irq(irq, NULL, stk3310_irq_thread, IRQF_TRIGGER_FALLING | IRQF_ONESHOT, stk3310_proximity, chip); if (ret) return ret; ret irq_set_irq_wake(irq, 1); if (ret) dev_warn(chip-dev, set wake failed\n); chip-irq irq; return 0; }IRQF_TRIGGER_FALLING表示下降沿触发IRQF_ONESHOT保证中断线程执行完之前不会重入。对于接近传感器这个场景IRQF_ONESHOT很重要如果不清中断标志就紧接着再次触发容易丢失事件。irq_set_irq_wake是让这个中断可以唤醒系统。智能手机贴耳灭屏就是靠这个机制屏幕灭掉后系统休眠物体靠近触发INT把AP唤醒再读取距离状态。但要注意GPIO模拟I2C在系统睡眠后总线状态会丢失所以需要注册resume回调。5.4 suspend/resume恢复这是GPIO模拟I2C在MTK上最容易被忽略的点。硬件I2C控制器睡眠后醒来会自动恢复但GPIO模拟I2C的SCL/SDA电平状态完全由驱动控制睡眠前如果停在某个中间状态醒来后就容易错位。我在resume里做了两件事重新初始化GPIO方向重新软复位传感器。示例static int stk3310_resume(struct device *dev) { struct stk3310_chip *chip dev_get_drvdata(dev); stk3310_i2c_gpio_init(); /* 恢复GPIO为推挽/开漏模式 */ stk3310_i2c_start(); /* 产生一个Stop清理总线 */ i2c_stop(); stk3310_init_chip(chip); /* 重写寄存器 */ return 0; }注意这里的恢复不能直接沿用probe里的完整初始化因为probe中还会申请GPIO和中断而resume时这些资源还在。只需要重新拉总线并配置寄存器然后把中断标志清干净。5.5 验证数据吞吐和时序调试阶段我用下面这张表来确认驱动是否真正跑通验证项目工具/方法通过标准SCL时序示波器测量SCL对地波形半周期不小于4us无回勾I2C帧格式逻辑分析仪抓SCL/SDA写寄存器帧完整ACK位为低PS数据变化用手遮挡传感器ps_value明显上升且超过高阈值中断触发cat /proc/interrupts每次遮挡stk3310中断次数增加睡眠唤醒进入待机后靠近传感器中断能唤醒系统唤醒后可读数据如果逻辑分析仪抓到的ACK位为高先量SCL是否有连续时钟输出。常见原因是SDA方向没有切换或者漏写了寄存器地址。还是不行就在i2c_write_byte里加一个printk打印每一位人工对一下时序。5.6 性能观察GPIO模拟I2C一次读两个字节大概要消耗几百us对于接近传感器这种低频事件完全够用。但如果系统里同时有Wi-Fi、BT等高频率中断频繁切换GPIO上下文会把CPU时间打散。我一般还会统计一下每秒进入中断的次数如果超过100次说明阈值设置过低或滤波不够而不是继续优化代码。6. 距离数据滤波与日志调试让STK3310输出更稳6.1 滑动平均滤波PS值在真实环境中会抖动特别是LED电流波动、外界红外光串扰的时候。最简单有效的方法是滑动平均。窗口大小取5既平滑又不会让“靠近”事件延迟太多#define STK3310_FILTER_NUM 5 static int stk3310_filter(struct stk3310_chip *chip, int ps) { int sum 0, i; for (i 0; i STK3310_FILTER_NUM - 1; i) chip-ps_buf[i] chip-ps_buf[i 1]; chip-ps_buf[STK3310_FILTER_NUM - 1] ps; for (i 0; i STK3310_FILTER_NUM; i) sum chip-ps_buf[i]; return sum / STK3310_FILTER_NUM; }这个函数在中断线程里调用每次产生一次过滤后的PS值。窗口大小根据传感器中断频率来定如果中断周期是20ms窗口取5就是100ms平均手快速划过时会感觉反应慢半拍如果中断周期100ms窗口取3就够了。不要盲目加大窗口接近传感器要的是快速反映状态变化过滤过度会出现“已经离开了还在灭屏”的问题。6.2 阈值迟滞再校准阈值不是一次定死的。硬件装壳之后外壳颜色、透明遮挡物都会改变PS值基线。我的做法是保留两个microcode级别的校准参数空载PS值和满量程PS值。然后在驱动里把高阈值设为空载和满量程之间70%低阈值设为空载和满量程之间30%。下面这张是一个参考场景高阈值低阈值手机贴耳0x700x30机器人避障0xA00x60低成本检测0x500x18应用时先通过sysfs把原始PS值读出来遮挡和空载各记录10次取中间值再更新阈值。6.3 动态调试日志调试距离传感器时printk多了刷屏少了不知道内部状态。我习惯用dev_dbg替代printk在需要时动态打开dev_dbg(chip-client-dev, ps%d flag%02x\n, ps, flag);动态调试的开关方式是echo file stkdriver.c p /sys/kernel/debug/dynamic_debug/control这样只有stkdriver.c里的dev_dbg打印输出不会刷屏。probe阶段则会强制dump一次寄存器stk3310_i2c_read_reg(STK3310_PS_CONF, conf); dev_info(chip-dev, stk3310 ps_conf%02x flag%02x\n, conf, flag);这样每次开机都能看到初始化后的寄存器状态是不是默认值一眼就能判断。调试PS值时GPU驱动或串口驱动出问题不会干扰这里的判断因为日志只围绕stk3310文件输出。最后说一个实用技巧STK3310数据抖动时先不要加滤波而是用逻辑分析仪确认I2C波形真实可靠。波形上如果有毛刺滤波只会掩盖问题等波形干净了再来调滤波窗口出来的效果才稳定。本文还有配套的精品资源点击获取