最近在学Linux下I2C驱动,了解了I2C子系统框架之后,实现LM75温度传感器驱动。
LM75是一款常用I2C接口温度传感器,通过I2C总线读取温度寄存器里的数据,我们这里用I2C子系统 + 杂项设备(miscdevice) 来实现驱动,不用手动申请主设备号,开发简单,很适合I2C驱动入门。
一、整体思路
Linux内核提供I2C子系统,把I2C适配器、I2C设备、I2C驱动做了分层。我们写驱动的时候,不用去操作底层寄存器模拟I2C时序,只需要按照框架写probe、remove函数,通过i2c_msg构造I2C读写消息,调用master_xfer完成I2C通信。
同时用misc杂项设备,自动分配次设备号,生成/dev/lm75_misc设备文件。应用层直接open这个设备,read就能读到传感器原始数据,再在应用程序里换算成实际温度。
整套分为两部分:内核驱动代码 + 用户层测试APP。
二、驱动代码拆解
1. 头文件说明
#include <linux/fs.h> // 文件操作结构体file_operations
#include <linux/cdev.h> // 字符设备,这里没用,misc内部封装了cdev
#include <linux/module.h> // 模块加载、卸载宏
#include <linux/init.h> // __init __exit宏
#include <linux/i2c.h> // I2C子系统核心头文件,i2c_client、i2c_msg都在这里
#include <linux/miscdevice.h>// 杂项设备miscdevice
#include <asm/uaccess.h> // copy_to_user 用户态内核态数据拷贝
module_init/module_exit:驱动入口出口,insmod时执行init,rmmod执行exit。
THIS_MODULE:指向当前模块,防止驱动被卸载时还在被调用。
copy_to_user:内核空间的数据拷贝到用户空间,不能直接在内核里访问用户层指针,会触发内存异常。
2. 全局变量 i2c_client
static struct i2c_client *lm75_client = NULL;
i2c_client代表I2C从设备(也就是我们的LM75),设备树匹配成功后,probe函数会拿到这个结构体,里面存放从机地址、所属I2C适配器等信息。后面I2C通信都依赖它。
3. lm75_read:设备read回调函数
应用层调用read(fd, buf, len),内核就会跑到这个函数。
static ssize_t lm75_read(struct file *fp, char __user *puser, size_t n, loff_t *off)
{
struct i2c_msg sendmsg;
struct i2c_msg recvmsg;
int ret = 0;
unsigned char tmpbuff[2] = {0};
unsigned long nret = 0;
memset(&sendmsg, 0, sizeof(sendmsg));
memset(&recvmsg, 0, sizeof(recvmsg));
sendmsg.addr = lm75_client->addr;
tmpbuff[0] = 0x00; // LM75温度寄存器地址0x00
sendmsg.buf = tmpbuff;
sendmsg.len = 1;
// 发送消息:告诉LM75,我要读0x00寄存器
ret = lm75_client->adapter->algo->master_xfer(lm75_client->adapter, &sendmsg, 1);
if (ret != 1) {
pr_info("master_xfer sendmsg failed ret = %d\n", ret);
return -1;
}
recvmsg.addr = lm75_client->addr;
recvmsg.flags |= I2C_M_RD; // 标记这是I2C读消息
recvmsg.buf = tmpbuff;
recvmsg.len = 2; // LM75温度寄存器是2字节
// 读取2字节温度原始数据
ret = lm75_client->adapter->algo->master_xfer(lm75_client->adapter, &recvmsg, 1);
if (ret != 1) {
pr_info("master_xfer recvmsg failed ret = %d\n", ret);
return -1;
}
// 内核数据拷贝到用户空间
nret = copy_to_user(puser, tmpbuff, sizeof(tmpbuff));
if (nret) {
pr_info("copy_to_user failed\n");
return -1;
}
return sizeof(tmpbuff);
}
i2c_msg:I2C子系统里面,一次I2C通信由一个或多个i2c_msg组成。
第一步写操作:发送寄存器地址0x00,告诉传感器接下来要读哪个寄存器。
第二步读操作:带上I2C_M_RD标志,读取2字节原始温度。>
注意:master_xfer是I2C适配器底层传输函数,实际开发更推荐用i2c_transfer封装接口,我这里直接调用adapter的master_xfer,方便理解底层调用链路。
pr_info内核打印,相当于内核里的printf,dmesg可以查看打印信息。
4. file_operations 和 miscdevice
static struct file_operations fops = {
.read = lm75_read,
};
static struct miscdevice lm75_misc = {
.minor = MISC_DYNAMIC_MINOR,
.name = "lm75_misc",
.fops = &fops,
};
杂项设备本质就是字符设备,主设备号固定为10。MISC_DYNAMIC_MINOR让内核自动分配次设备号,不用自己手动管理设备号,非常适合简单外设驱动。注册成功后,自动生成/dev/lm75_misc。
5. probe 和 remove
static int lm75_probe(struct i2c_client *pclient, const struct i2c_device_id *pdev)
{
int ret = 0;
lm75_client = pclient;
ret = misc_register(&lm75_misc);
if (ret) {
pr_info("misc_register failed\n");
return -1;
}
return 0;
}
static int lm75_remove(struct i2c_client *pclient)
{
int ret = 0;
ret = misc_deregister(&lm75_misc);
if (ret) {
pr_info("misc_deregister failed\n");
return -1;
}
return 0;
}
I2C驱动匹配机制:设备树或者i2c总线里的设备和驱动匹配成功,内核自动调用probe函数。
probe:保存i2c_client指针,注册misc设备。
remove:设备卸载的时候调用,注销misc设备,资源释放。
6. 匹配表 i2c_device_id & of_device_id
static struct i2c_device_id lm75_id_tables[] = {
{.name = "putelm75"},
{},
};
static struct of_device_id lm75_of_match_tables[] = {
{.compatible = "pute,putelm75"},
{},
};
两个匹配表对应两种匹配方式:
of_device_id:设备树匹配,设备树节点compatible属性和这里字符串一致,就匹配。现在嵌入式开发基本都用设备树。i2c_device_id:传统非设备树匹配方式。
7. i2c_driver 结构体、模块入口出口
struct i2c_driver lm75_drv = {
.probe = lm75_probe,
.remove = lm75_remove,
.driver = {
.name = "lm75_drv",
.owner = THIS_MODULE,
.of_match_table = lm75_of_match_tables,
},
.id_table = lm75_id_tables,
};
static int __init lm75_drv_init(void)
{
return i2c_add_driver(&lm75_drv);
}
static void __exit lm75_drv_exit(void)
{
i2c_del_driver(&lm75_drv);
return;
}
module_init(lm75_drv_init);
module_exit(lm75_drv_exit);
MODULE_LICENSE("GPL");
i2c_add_driver:把I2C驱动注册进内核I2C子系统,内核开始拿这个驱动去总线上匹配I2C设备。
MODULE_LICENSE("GPL"):许可证声明,不加会报内核tainted警告,做驱动开发一定要写。
三、用户层测试程序
应用层代码很简单,打开设备文件,循环读取2字节原始数据,换算温度打印。
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
int main(void)
{
int fd = 0;
unsigned char tmpbuff[2] = {0};
fd = open("/dev/lm75_misc", O_RDWR);
if (-1 == fd)
{
perror("fail to open");
return -1;
}
while (1)
{
read(fd, tmpbuff, sizeof(tmpbuff));
printf("temp = %.2lf\n", ((tmpbuff[0] << 8 | tmpbuff[1]) >> 5) * 0.125);
sleep(1);
}
close(fd);
return 0;
}
温度换算公式解释:
LM75输出11位有效温度数据,两个字节:
高字节tmpbuff[0]是温度高8位
低字节tmpbuff[1]里面高3位是有效数据,剩下5位无效
拼接后整体右移5位,得到11bit原始值,分辨率0.125℃,乘以0.125就是实际温度。
四、编译、测试步骤
- 写Makefile,交叉编译内核模块
.ko,还有用户测试程序。 - 把ko文件传到开发板,
insmod lm75_drv.ko加载驱动。 - 设备树节点compatible和驱动匹配成功,触发probe,生成
/dev/lm75_misc。 - 运行测试程序,每隔1秒打印一次温度。
- 调试:
dmesg看内核打印,排查I2C通信失败、注册失败问题。 - 卸载:
rmmod lm75_drv。
五、小结
- 内核态和用户态内存不能直接访问 ,必须用
copy_to_user,忘记会直接Oops崩溃。 - I2C消息顺序不能乱:先发寄存器地址,再发读消息,很多新手这里搞反,读出来全是乱码。
I2C_M_RD标记不能丢,不设置这个标志,master_xfer会当成写操作。- 设备树compatible字符串必须和驱动
of_device_id完全一致,多一个少一个字符都匹配不上,probe函数不会执行。 - 驱动里面不要随便用sleep,内核驱动延时优先用msleep,不要直接sleep(sleep是用户态函数)。