1.核心学习方法:从函数反推。写驱动不要从头硬背,而是从"最终要干什么"倒推:要读温度 → 需要 i2c_master_recv → 需要 i2c_client → client 从哪来 → probe 函数参数 → probe 谁调用 → i2c_driver 注册后匹配设备树 → 所以需要填 i2c_driver 结构体 → 需要 of_match_table → 需要设备树 compatible。顺着这条链,每一步需要写什么就清清楚楚了。
2. LM75 与 I2C 基础
2.1 LM75 是什么
LM75 是 NXP(原飞利浦)推出的一款数字温度传感器,通过 I2C 总线与主控通信。它内部有一个温度寄存器,上电后自动持续测量温度,我们只需通过 I2C 读取寄存器即可获得温度值。
| 参数 | 说明 |
|---|---|
| 通信接口 | I2C(标准模式 100kHz / 快速模式 400kHz) |
| 供电电压 | 2.8V ~ 5.5V(开发板常用 3.3V) |
| 测量范围 | -55°C ~ +125°C |
| 分辨率 | 0.5°C(9位,高字节整数部分 + 低字节 bit7 小数部分) |
| 7位从机地址 | 0x48 ~ 0x4F(由 A0/A1/A2 引脚电平决定,全接地时为 0x48) |
| 温度寄存器地址 | 0x00(读,2字节,大端模式) |
| 配置寄存器地址 | 0x01(读/写,1字节) |
2.2 I2C 通信关键概念
I2C 只需要两根线:SCL(时钟线)和 SDA(数据线),所有设备并联在总线上,靠地址区分。
- 起始信号 START:SCL 高电平时,SDA 由高变低
- 停止信号 STOP:SCL 高电平时,SDA 由低变高
- 应答位 ACK:每发完 8 位数据,接收方在第 9 个时钟拉低 SDA 表示收到
- 7位地址 + R/W位:起始后先发 1 字节,高 7 位是从机地址,最低位 0=写、1=读
- 大端模式:多字节数据先发高字节(LM75 温度值就是高字节在前)
电气提醒:I2C 引脚是开漏输出,总线必须外接 4.7kΩ 上拉电阻到 VCC,否则 SCL/SDA 无法被拉高,通信会失败。开发板上通常已经做好了上拉。
2.3 读 LM75 温度的完整 I2C 时序
读取温度寄存器(地址 0x00)需要"先写寄存器地址,再读数据",这是 I2C 传感器最常见的操作模式:
- 主机发 START + 从机地址(0x48) + 写位(0) → 从机回 ACK
- 主机发寄存器地址 0x00 → 从机回 ACK
- 主机发重复 START(不发 STOP)+ 从机地址(0x48) + 读位(1) → 从机回 ACK
- 从机发温度高字节 → 主机回 ACK
- 从机发温度低字节 → 主机回 NACK(表示最后一字节)
- 主机发 STOP,结束
在 Linux 驱动中,上面这套时序被封装成了两个函数:i2c_master_send(写)和 i2c_master_recv(读),不需要手动控制 SCL/SDA 电平。
3. 整体开发流程一览
整个 LM75 驱动开发分为 6 大步,先建立全局印象,后面逐步展开:
| 步骤 | 做什么 | 产物 |
|---|---|---|
| 1 | 硬件接线 | LM75 的 VCC/GND/SDA/SCL 接到开发板对应引脚 |
| 2 | 设备树配置 | 在 imx6ull-alientek-emmc.dts 的 &i2c1 下添加 lm75 节点 |
| 3 | 编写驱动代码 | lm75_drv.c(I2C 驱动 + 杂项设备) |
| 4 | 编写测试应用 | main.c(open / read 温度) |
| 5 | 编写 Makefile | 驱动 Makefile + 应用 Makefile |
| 6 | 编译、加载、测试 | lm75_drv.ko + app 可执行文件 |
4. 第一步:硬件连接
以正点原子 IMX6ULL Mini 板为例,LM75 模块接线如下:
| LM75 模块引脚 | 开发板引脚 | 说明 |
|---|---|---|
| VCC | 3.3V | 电源 |
| GND | GND | 地 |
| SDA | I2C1_SDA(引脚 42) | 数据线 |
| SCL | I2C1_SCL(引脚 43) | 时钟线 |
A0/A1/A2 引脚决定从机地址。如果模块上这三个脚都接地,地址就是 0x48。接线前用万用表确认一下,地址错了后面一切都读不到。
5. 第二步:设备树配置
5.1 为什么要改设备树
Linux 3.x 之后,硬件资源信息从驱动代码中剥离出来,放到设备树(Device Tree)里描述。内核启动时解析设备树,自动为每个 I2C 设备生成一个 i2c_client 结构体并挂到 I2C 总线上。驱动注册后,总线会拿驱动的 compatible 字符串去和设备树节点匹配,匹配成功就调用驱动的 probe 函数。
反推链路:probe 函数需要 i2c_client → client 由内核根据设备树自动生成 → 所以必须在设备树里描述这个设备。
5.2 具体修改
打开设备树文件 arch/arm/boot/dts/imx6ull-alientek-emmc.dts,找到 &i2c1 节点,添加 LM75 子节点:
&i2c1 { clock-frequency = <100000>; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_i2c1>; status = "okay"; putelm75@48 { compatible = "pute,lm75"; reg = <0x48>; }; };i2c1 的引脚复用(pinctrl)已经在设备树里配好了(pinctrl_i2c1 把 UART4_TX/RX 复用为 I2C1_SCL/SDA),所以不需要额外配置引脚。如果用的是其他 I2C 控制器或其他板子,需要确认 pinctrl 配置。
5.3 编译设备树
修改完设备树后,在内核源码根目录执行:
make dtbs cp arch/arm/boot/dts/imx6ull-alientek-emmc.dtb ~/tftpboot/然后通过 tftp 把新的 dtb 下载到开发板,重启生效。
6. 第三步:编写驱动代码(核心,从函数反推)
6.1 从 module_i2c_driver 宏出发
驱动的入口是这一行宏,它需要一个参数 drv:
module_i2c_driver(drv);这个宏展开后等价于:
- module_init 里调用 i2c_add_driver(&drv)
- module_exit 里调用 i2c_del_driver(&drv)
因为它需要 drv,所以你必须先定义一个 struct i2c_driver 类型的变量。
6.2 定义 struct i2c_driver drv
static struct i2c_driver drv = { .probe = myprobe, .remove = myremove, .driver = { .name = "ptlm75", .owner = THIS_MODULE, .of_match_table = match_table, }, .id_table = id_table, };这个结构体里引用了 5 个东西,逐个反推:
- myprobe:probe 回调函数,需要自己实现
- myremove:remove 回调函数,需要自己实现
- match_table:of_device_id 数组,用于设备树匹配
- id_table:i2c_device_id 数组,传统 I2C ID 匹配表
- THIS_MODULE:内核宏,表示当前模块
6.3 定义 of_match_table(设备树匹配表)
因为 drv.driver.of_match_table 需要它,所以定义:
struct of_device_id match_table[] = { { .compatible = "pute,lm75" }, // 必须和设备树里的 compatible 完全一致 {} // 空结尾,必须有 };这个表的作用:内核拿这里的 compatible 字符串去设备树里找匹配的节点,找到就调用 probe。
6.4 定义 id_table(传统 I2C ID 匹配表)
因为 drv.id_table 需要它,所以定义:
struct i2c_device_id id_table[] = { { .name = "ptlm75" }, {} };虽然我们用设备树匹配,但 i2c_driver 要求必须提供这个表,否则编译报错。
6.5 定义 myprobe 函数
因为 drv.probe 需要它,所以定义:
int myprobe(struct i2c_client *pclient, const struct i2c_device_id *id) { int ret = 0; pcli = pclient; // 保存 client 指针,后面 read 函数要用 ret = misc_register(&misc_dev); // 注册杂项设备,创建 /dev/ptlm75 if (ret < 0) { pr_err("myprobe misc_register error\n"); return ret; } pr_info("lm75 probe ok\n"); return 0; }这个函数里又引用了 2 个新东西,继续反推:
- pcli:全局变量,保存 i2c_client 指针
- misc_dev:struct miscdevice 变量,杂项设备结构体
6.6 定义全局变量 pcli
因为 myprobe 里要保存 client,myread 里要用它做 I2C 通信,所以定义全局变量:
struct i2c_client *pcli;i2c_client 是内核根据设备树自动生成的,包含了从机地址(0x48)、适配器指针等信息,i2c_master_send/recv 都需要它。
6.7 定义 struct miscdevice misc_dev
因为 myprobe 里 misc_register 需要它,所以定义:
#define DEV_NAME "ptlm75" static struct miscdevice misc_dev = { .minor = MISC_DYNAMIC_MINOR, // 自动分配次设备号 .name = DEV_NAME, // /dev/ptlm75 节点名 .fops = &myfops, // 文件操作集合 };这里又引用了 myfops,继续反推:
- myfops:struct file_operations 变量,定义 open / read / release 等回调
6.8 定义 struct file_operations myfops
static struct file_operations myfops = { .owner = THIS_MODULE, .open = myopen, .read = myread, .release = myrelease, };这里又引用了 3 个函数,继续反推:
- myopen:打开设备时调用,可做初始化
- myread:应用层 read 时调用,读取温度
- myrelease:关闭设备时调用,可做清理
6.9 实现 myopen 和 myrelease
static int myopen(struct inode *inode, struct file *file) { return 0; } static int myrelease(struct inode *inode, struct file *file) { return 0; }6.10 实现 myread(核心,读温度)
应用层调用 read(fd, buf, 2) 时,内核会调用 myread。读取温度需要两步:先写寄存器地址 0x00,再读 2 字节数据。
static ssize_t myread(struct file *file, char __user *buf, size_t size, loff_t *offset) { int ret; unsigned char reg = 0x00; // 温度寄存器地址 unsigned char data[2] = {0}; // 存放读到的 2 字节温度值 short temp_raw; int temp; // 第 1 步:写寄存器地址 0x00 ret = i2c_master_send(pcli, ®, 1); if (ret < 0) { pr_err("i2c_master_send error\n"); return ret; } // 第 2 步:读 2 字节温度数据(大端:高字节在前) ret = i2c_master_recv(pcli, data, 2); if (ret < 0) { pr_err("i2c_master_recv error\n"); return ret; } // 第 3 步:拼成 16 位原始值,右移 7 位得到 9 位温度值 temp_raw = (short)((data[0] << 8) | data[1]); temp_raw >>= 7; // 第 4 步:转换为摄氏度(分辨率 0.5°C) temp = temp_raw * 500; // 第 5 步:拷贝到用户空间 ret = copy_to_user(buf, &temp, sizeof(temp)); if (ret) { pr_err("copy_to_user error\n"); return -EFAULT; } return sizeof(temp); }6.11 定义 myremove 函数
因为 drv.remove 需要它,所以定义:
int myremove(struct i2c_client *pclient) { misc_deregister(&misc_dev); // 注销杂项设备,删除 /dev/ptlm75 return 0; }驱动卸载(rmmod)时内核调用,做清理工作。
6.12 函数前置声明
C 语言要求函数在使用前必须声明或定义。上面 fops、drv 里引用了 myprobe、myread 等函数,但这些函数定义在后面,所以在前面加声明:
int myprobe(struct i2c_client *, const struct i2c_device_id *); int myremove(struct i2c_client *); int myopen(struct inode *, struct file *); ssize_t myread(struct file *, char __user *, size_t, loff_t *); int myrelease(struct inode *, struct file *);6.13 加头文件
把上面所有用到的内核 API 对应的头文件加上:
#include <linux/module.h> // module_i2c_driver, MODULE_LICENSE #include <linux/init.h> // __init, __exit #include <linux/miscdevice.h> // miscdevice, misc_register, misc_deregister #include <linux/fs.h> // file_operations #include <linux/i2c.h> // i2c_driver, i2c_client, i2c_master_send/recv #include <linux/uaccess.h> // copy_to_user6.14 加 MODULE_LICENSE
最后必须加许可证声明,否则内核会报"内核污染"警告:
MODULE_LICENSE("GPL");6.15 驱动代码结构总览表
用一张表把驱动中各个结构体和函数的关系理清楚:
| 元素 | 类型 | 作用 |
|---|---|---|
| match_table | of_device_id[] | 设备树匹配表,compatible 字符串与 dts 节点匹配 |
| id_table | i2c_device_id[] | 传统 ID 匹配表,i2c_driver 必须提供 |
| myfops | file_operations | 文件操作集,绑定 open/read/release 函数 |
| misc_dev | miscdevice | 杂项设备,注册后自动创建 /dev/ptlm75 节点 |
| drv | i2c_driver | I2C 驱动主体,绑定 probe/remove/match_table/id_table |
| pcli | i2c_client * | 全局指针,probe 中保存,read 中用于 I2C 通信 |
| myprobe | 函数 | 匹配成功后调用,保存 client + 注册杂项设备 |
| myread | 函数 | 应用 read 时调用,I2C 读写温度 + copy_to_user |
| module_i2c_driver | 宏 | 展开为 module_init/module_exit + i2c_add_driver/i2c_del_driver |
7. 第四步:编写应用层测试程序
驱动加载后,/dev/ptlm75 节点就存在了。应用层通过标准文件 IO 读取温度:
#include <stdio.h> // printf, perror #include <unistd.h> // open, read, close, sleep #include <fcntl.h> // O_RDWR int main(int argc, char **argv) { // 打开设备节点,触发驱动的 myopen int fd = open("/dev/ptlm75", O_RDWR); if (fd < 0) { perror("open"); // 打开失败,打印错误原因 return 1; // 异常退出 } char buf[10] = {0}; // 缓冲区,存放读到的温度数据 while (1) // 死循环,持续读取 { read(fd, buf, 2); // 读 2 字节,触发驱动的 myread //printf("tmp:%d\n", buf[0]); // 打印温度整数部分 short raw = ((short)buf[0] << 8) | buf[1]; // 拼成 16 位 raw >>= 7; // 低 7 位是 0,右移 7 位 float temp = raw * 0.5; // 分辨率 0.5°C printf("temp: %.1f C\n", temp); sleep(1); // 每秒读一次 } close(fd); return 0; }应用层逻辑很简单:open 打开设备节点,read 读取 2 字节原始温度数据,拼成 16 位后右移 7 位得到 9 位温度值,再乘以 0.5 得到摄氏度,最后打印出来。
8. 第五步:编写 Makefile
工程目录结构如下:
lm75_project/ ├── Makefile # 顶层Makefile,递归编译app和drv ├── app/ │ ├── Makefile # 应用层Makefile │ └── main.c └── drv/ ├── Makefile # 驱动Makefile └── lm75_drv.c8.1 顶层 Makefile
APP:=./app DRV=./drv all: $(MAKE) -C $(APP) all $(MAKE) -C $(DRV) all clean: $(MAKE) -C $(APP) clean $(MAKE) -C $(DRV) clean8.2 应用层 Makefile(app/Makefile)
CC := arm-linux-gnueabihf-gcc SRC := main.c TARGET := app all: $(CC) $(SRC) -o $(TARGET) clean: rm -f $(TARGET)8.3 驱动 Makefile(drv/Makefile)
CUR_DIR := $(shell pwd) KER_DIR := /home/linux/imx6ull/linux-imx-rel_imx_4.1.15_2.1.0_ga_alientek obj-m = lm75_drv.o all: $(MAKE) -C $(KER_DIR) M=$(CUR_DIR) modules clean: $(MAKE) -C $(KER_DIR) M=$(CUR_DIR) clean驱动 Makefile 关键解释:
- obj-m = lm75_drv.o:告诉内核构建系统把 lm75_drv.c 编译成模块(.ko)
- -C $(KER_DIR):切换到内核源码目录,使用内核的构建体系
- M=$(CUR_DIR):指定模块源码在当前目录
- KER_DIR:必须指向你板子对应的、已经编译过的内核源码目录
9. 第六步:编译、加载与测试
9.1 编译
在工程根目录执行:
make成功后会生成:
- drv/lm75_drv.ko:内核模块文件
- app/app:交叉编译后的 ARM 可执行文件
9.2 加载驱动
把 .ko 文件和 app 传到开发板(通过 tftp / nfs / U盘),然后:
insmod lm75_drv.ko如果看到 lm75 probe ok 的打印,说明驱动和设备树匹配成功,/dev/ptlm75 节点已创建。验证一下:
ls /dev/ptlm75 cat /proc/devices | grep misc9.3 运行测试程序
chmod +x app ./app正常情况下会每秒打印一次温度:
tmp:25 tmp:25 tmp:269.4 卸载驱动
rmmod lm75_drv10. 第七步:用 i2c-tools 调试(不写驱动也能测硬件)
在写驱动之前,建议先用 i2c-tools 工具验证硬件是否正常,这能帮你区分"硬件问题"还是"驱动问题"。
10.1 查看有几个 I2C 控制器
i2cdetect -l输出示例:
i2c-0 i2c 21a0000.i2c I2C adapter i2c-1 i2c 21a4000.i2c I2C adapter10.2 扫描总线上的设备
i2cdetect -r -y 0输出示例(0x48 位置显示 UU 或 48 表示设备在线):
0 1 2 3 4 5 6 7 8 9 a b c d e f 00: -- -- -- -- -- -- -- -- -- -- -- -- -- 10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 40: -- -- -- -- -- -- -- -- -- -- -- -- UU -- -- -- 50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 70: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --UU 是什么意思?UU 表示这个地址被某个内核驱动占用了(也就是我们的驱动已经匹配上了)。如果驱动没加载,会显示具体的地址号如 48。如果显示 -- 说明这个地址没有设备响应,检查硬件接线和地址。
10.3 读取温度寄存器
# 读1字节(温度整数部分) i2cget -y -f 0 0x48 0x00 读2字节(完整温度值,w=word) i2cget -y -f 0 0x48 0x00 w输出示例:
0x19 → 十进制25,即25°C 0xa019 → 小端显示,实际大端为0x19a0,右移7位=51,51*0.5=25.5°C| 命令 | 功能 |
|---|---|
| i2cdetect -l | 列出所有 I2C 控制器 |
| i2cdetect -r -y 总线号 | 扫描指定总线上的设备 |
| i2cget -y -f 总线 地址 寄存器 | 读指定寄存器(默认1字节,加 w 读2字节) |
| i2cset -y -f 总线 地址 寄存器 值 | 写指定寄存器 |
| i2cdump -f -y 总线 地址 | dump 该设备所有寄存器 |
11. 常见错误与排错
11.1 错误 1:insmod 后没有 probe 打印
现象:insmod 成功,但 dmesg 里看不到 "lm75 probe ok"。
原因排查:
- 设备树没更新:确认新的 dtb 已经烧录到板子并重启
- compatible 不匹配:驱动里的 "pute,lm75" 和 dts 里的必须逐字符一致,包括逗号和大小写
- I2C 控制器没启用:确认 dts 中 &i2c1 的 status = "okay"
- 用 ls /sys/bus/i2c/devices/ 看有没有 0-0048 这样的设备节点,没有就是设备树问题
11.2 错误 2:read 报错 Input/output error
现象:probe 成功,app 运行后 read 返回 -1,perror 显示 "Input/output error"。
原因排查:
- SDA/SCL 接线错误(最常见):两根线接反了或接触不良,用万用表确认通断
- 从机地址错误:A0/A1/A2 电平决定地址,用 i2cdetect 扫描确认实际地址
- 上拉电阻缺失:I2C 总线必须有上拉电阻,用示波器看 SCL/SDA 有没有正常电平
- 设备没供电:VCC 没接好
11.3 错误 3:温度值不对(一直是 0 或很大的数)
原因排查:
- 读的寄存器地址不对:LM75 温度寄存器是 0x00,确认 reg_addr = 0
- 字节序搞反了:LM75 是大端模式,高字节在前。i2cget w 模式返回的是小端格式,需要自己转换
- 应用层用 char 接收负数温度:char 在有些编译器默认是 unsigned char,负数会变成很大的正数。用 signed char 或 short
11.4 错误 4:编译驱动报错找不到头文件
原因:KER_DIR 路径不对,或者内核没有先编译过。
解决:确认 KER_DIR 指向正确的内核源码目录,并且在内核目录下至少执行过一次 make 生成了必要的头文件和配置。
12. 开发环境配置参考
示例使用的开发环境:
| 项目 | 值 |
|---|---|
| 交叉编译器 | arm-linux-gnueabihf-gcc |
| 目标 CPU | Cortex-A7 |
| 浮点 | hard float + NEON |
| 内核版本 | linux-imx-rel_imx_4.1.15_2.1.0_ga_alientek |
| 开发板 | 正点原子 IMX6ULL (Mini 版) |