22、编写自定义传感器驱动(理论):Linux内核中的传感器驱动框架、I2C通信基础
好,到了这一章,我们终于要碰一碰驱动开发了。
说实话,很多做Android应用层的朋友,一听到「驱动」两个字就头大。我当年也一样,总觉得那是内核大佬才碰的东西。但后来被项目逼着去调一个霍尔传感器,硬着头皮啃了一遍,才发现——驱动开发没那么玄乎,它其实就是一套固定的套路。
今天我们就来拆解这个套路。你不需要成为内核专家,但搞清楚传感器驱动是怎么挂上去的、I2C是怎么读写的,对你理解整个Android传感器体系会非常有帮助。
Linux内核中的传感器驱动框架
Linux内核的驱动模型,说白了就是「分层」和「抽象」。传感器驱动也不例外。
我习惯把传感器驱动分成三层来看:
- 硬件层:芯片本身,比如霍尔传感器AH49E、SS49E这类。它们通过I2C或GPIO和主控通信。
- 核心层:内核提供的通用框架,比如IIO(Industrial I/O)子系统。它帮你把数据上报、sysfs接口、中断处理这些脏活累活都干了。
- 驱动层:你真正要写的部分。负责初始化芯片、配置寄存器、读取数据,然后塞给核心层。
嗯,这里要注意:不是所有传感器都用IIO。霍尔传感器因为数据简单,很多时候直接用input子系统或者自定义misc设备就搞定了。但IIO是趋势,尤其在新版内核里,它专门为传感器类设备设计,接口更规范。
核心观点:写驱动不是从零造轮子,而是把芯片的寄存器操作「翻译」成内核能理解的接口。
I2C通信基础
霍尔传感器最常见的通信接口就是I2C。为什么?因为它只需要两根线(SCL、SDA),引脚少,布线简单。
I2C的通信过程,我总结成三步:
- 起始信号:主机拉低SDA,再拉低SCL,告诉从机「我要开始通信了」。
- 地址+读写位:主机发送7位从机地址,最后一位是读写标志(0表示写,1表示读)。
- 数据收发:每发完一个字节,从机回复一个ACK(应答位)。然后重复,直到主机发送停止信号。
听起来简单,但实际调的时候坑不少。我曾经遇到一个传感器,地址明明是0x48,但读出来全是0xFF。查了半天,发现是地址左移了一位——I2C的7位地址在发送时要左移1位,最后补上读写位。这个细节,手册上写得清清楚楚,但新手很容易忽略。
避坑指南:我曾经因为I2C时钟频率设得太高(400kHz以上),导致传感器数据偶尔跳变。后来降到100kHz,问题消失。不是所有传感器都支持高速模式,先看数据手册再调频率。
驱动代码的核心骨架
下面我贴一段简化版的霍尔传感器驱动代码。它基于IIO框架,通过I2C读取寄存器值。你不需要逐行背下来,但要看懂结构。
#include <linux/module.h>
#include <linux/i2c.h>
#include <linux/iio/iio.h>
#define HALL_SENSOR_ADDR 0x48
#define HALL_DATA_REG 0x00
struct hall_chip {
struct i2c_client *client;
struct iio_dev *indio_dev;
};
static int hall_read_raw(struct iio_dev *indio_dev,
struct iio_chan_spec const *chan,
int *val, int *val2, long mask)
{
struct hall_chip *chip = iio_priv(indio_dev);
struct i2c_client *client = chip->client;
int ret;
// 读取霍尔传感器的数据寄存器
ret = i2c_smbus_read_byte_data(client, HALL_DATA_REG);
if (ret < 0) {
dev_err(&client->dev, "read failed\n");
return ret;
}
*val = ret;
return IIO_VAL_INT;
}
static const struct iio_info hall_info = {
.read_raw = hall_read_raw,
};
static int hall_probe(struct i2c_client *client)
{
struct iio_dev *indio_dev;
struct hall_chip *chip;
indio_dev = devm_iio_device_alloc(&client->dev, sizeof(*chip));
if (!indio_dev)
return -ENOMEM;
chip = iio_priv(indio_dev);
chip->client = client;
indio_dev->info = &hall_info;
indio_dev->name = "hall_sensor";
indio_dev->modes = INDIO_DIRECT_MODE;
// 注册IIO设备
return devm_iio_device_register(&client->dev, indio_dev);
}
static const struct of_device_id hall_of_match[] = {
{ .compatible = "hall,ah49e" },
{ }
};
MODULE_DEVICE_TABLE(of, hall_of_match);
static struct i2c_driver hall_driver = {
.driver = {
.name = "hall_sensor",
.of_match_table = hall_of_match,
},
.probe = hall_probe,
.id_table = hall_id_table,
};
module_i2c_driver(hall_driver);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
你看,核心逻辑其实就两个:probe函数里注册设备,read_raw函数里读寄存器。其他都是模板代码。
驱动框架流程图
为了让你更直观地理解整个流程,我画了一张图。它展示了从设备树匹配到数据上报的完整链路。
设备树与驱动匹配
驱动写好了,怎么让内核知道「这个传感器对应这个驱动」?靠设备树。
在设备树里,你只需要这样写:
&i2c1 {
hall_sensor@48 {
compatible = "hall,ah49e";
reg = <0x48>;
};
};
然后驱动里声明 of_match_table,内核就会自动把两者绑在一起。我刚开始学的时候,总觉得这一步很神奇,其实原理很简单——内核在启动时扫描I2C总线,发现地址0x48上有设备,就去匹配compatible字符串。匹配上了,就调用你的probe函数。
注意:设备树里的reg地址必须是7位I2C地址,不要左移。而驱动里i2c_smbus函数用的地址是8位(左移后的)。这个区别坑过不少人,包括我。
I2C通信的几种常用API
内核里操作I2C,最常用的就这几个函数。我列个表,方便你查阅:
| 函数 | 作用 | 适用场景 |
|---|---|---|
i2c_smbus_read_byte_data |
读一个寄存器的值 | 单字节寄存器读取 |
i2c_smbus_write_byte_data |
写一个寄存器的值 | 配置传感器模式 |
i2c_smbus_read_i2c_block_data |
连续读取多个字节 | 读取多轴数据(如加速度计) |
i2c_transfer |
自定义传输(支持重复起始) | 复杂协议、非标准I2C设备 |
我个人习惯,能用smbus就用smbus。它封装好了错误处理和重试逻辑,代码更简洁。只有遇到特殊时序要求时,才用底层的i2c_transfer。
中断还是轮询?
霍尔传感器通常不需要中断,因为它数据变化不快。但如果你做的是高精度位置检测,比如翻盖手机的角度判断,那可以考虑用中断。
中断驱动的流程是:传感器检测到磁场变化 → 拉高INT引脚 → 内核触发中断处理函数 → 读取数据 → 上报。这样CPU不用一直轮询,省电。
不过,中断处理函数里不能做耗时操作。我曾经犯过一个错,在中断里直接调了i2c_smbus_read,结果因为I2C总线被占用,导致系统卡死。正确的做法是用工作队列(workqueue)把读取操作推迟到进程上下文执行。
小技巧:如果你不确定用中断还是轮询,先轮询。等调通了,再优化成中断。不要一开始就上复杂方案。
总结一下
这一章我们聊了Linux内核的传感器驱动框架,核心就是IIO子系统 + I2C通信。你只要记住:
- 驱动 = 设备树匹配 + probe初始化 + read_raw读数据
- I2C = 地址 + 寄存器 + 读写操作
- 先轮询,后中断,别在中断里做I2C读写
下一章我们会实际动手,在开发板上跑一个霍尔传感器的驱动。到时候你会看到,今天讲的这些理论,全都会变成实实在在的代码和数据。