MTK平台GPIO模拟I2C驱动STK3310接近传感器实战
2026/9/15 16:06:51 网站建设 项目流程

简介:针对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~100ms
IRED脉冲数脉冲越多灵敏度越高,功耗越大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.8V,MTK平台的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这种传感器完全够用。

时序上有三个点必须卡住:

  1. Start条件:SCL为高时,SDA由高到低。
  2. 数据稳定:SCL为高电平期间,SDA要保持稳定。
  3. 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); /* 0=ACK,1=NACK */ }

第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 48

5.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波形真实可靠。波形上如果有毛刺,滤波只会掩盖问题,等波形干净了再来调滤波窗口,出来的效果才稳定。

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

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

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

立即咨询