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°C9位高字节整数部分 低字节 bit7 小数部分7位从机地址0x48 ~ 0x4F由 A0/A1/A2 引脚电平决定全接地时为 0x48温度寄存器地址0x00读2字节大端模式配置寄存器地址0x01读/写1字节2.2 I2C 通信关键概念I2C 只需要两根线SCL时钟线和 SDA数据线所有设备并联在总线上靠地址区分。起始信号 STARTSCL 高电平时SDA 由高变低停止信号 STOPSCL 高电平时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.cI2C 驱动 杂项设备4编写测试应用main.copen / read 温度5编写 Makefile驱动 Makefile 应用 Makefile6编译、加载、测试lm75_drv.ko app 可执行文件4. 第一步硬件连接以正点原子 IMX6ULL Mini 板为例LM75 模块接线如下LM75 模块引脚开发板引脚说明VCC3.3V电源GNDGND地SDAI2C1_SDA引脚 42数据线SCLI2C1_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; putelm7548 { 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 宏出发驱动的入口是这一行宏它需要一个参数 drvmodule_i2c_driver(drv);这个宏展开后等价于module_init 里调用 i2c_add_driver(drv)module_exit 里调用 i2c_del_driver(drv)因为它需要 drv所以你必须先定义一个 struct i2c_driver 类型的变量。6.2 定义 struct i2c_driver drvstatic struct i2c_driver drv { .probe myprobe, .remove myremove, .driver { .name ptlm75, .owner THIS_MODULE, .of_match_table match_table, }, .id_table id_table, };这个结构体里引用了 5 个东西逐个反推myprobeprobe 回调函数需要自己实现myremoveremove 回调函数需要自己实现match_tableof_device_id 数组用于设备树匹配id_tablei2c_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_devstruct miscdevice 变量杂项设备结构体6.6 定义全局变量 pcli因为 myprobe 里要保存 clientmyread 里要用它做 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继续反推myfopsstruct file_operations 变量定义 open / read / release 等回调6.8 定义 struct file_operations myfopsstatic struct file_operations myfops { .owner THIS_MODULE, .open myopen, .read myread, .release myrelease, };这里又引用了 3 个函数继续反推myopen打开设备时调用可做初始化myread应用层 read 时调用读取温度myrelease关闭设备时调用可做清理6.9 实现 myopen 和 myreleasestatic 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, reg, 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_tableof_device_id[]设备树匹配表compatible 字符串与 dts 节点匹配id_tablei2c_device_id[]传统 ID 匹配表i2c_driver 必须提供myfopsfile_operations文件操作集绑定 open/read/release 函数misc_devmiscdevice杂项设备注册后自动创建 /dev/ptlm75 节点drvi2c_driverI2C 驱动主体绑定 probe/remove/match_table/id_tablepclii2c_client *全局指针probe 中保存read 中用于 I2C 通信myprobe函数匹配成功后调用保存 client 注册杂项设备myread函数应用 read 时调用I2C 读写温度 copy_to_usermodule_i2c_driver宏展开为 module_init/module_exit i2c_add_driver/i2c_del_driver7. 第四步编写应用层测试程序驱动加载后/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 顶层 MakefileAPP:./app DRV./drv all: $(MAKE) -C $(APP) all $(MAKE) -C $(DRV) all clean: $(MAKE) -C $(APP) clean $(MAKE) -C $(DRV) clean8.2 应用层 Makefileapp/MakefileCC : arm-linux-gnueabihf-gcc SRC : main.c TARGET : app all: $(CC) $(SRC) -o $(TARGET) clean: rm -f $(TARGET)8.3 驱动 Makefiledrv/MakefileCUR_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字节完整温度值wword i2cget -y -f 0 0x48 0x00 w输出示例0x19 → 十进制25即25°C 0xa019 → 小端显示实际大端为0x19a0右移7位5151*0.525.5°C命令功能i2cdetect -l列出所有 I2C 控制器i2cdetect -r -y 总线号扫描指定总线上的设备i2cget -y -f 总线 地址 寄存器读指定寄存器默认1字节加 w 读2字节i2cset -y -f 总线 地址 寄存器 值写指定寄存器i2cdump -f -y 总线 地址dump 该设备所有寄存器11. 常见错误与排错11.1 错误 1insmod 后没有 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 错误 2read 报错 Input/output error现象probe 成功app 运行后 read 返回 -1perror 显示 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 或 short11.4 错误 4编译驱动报错找不到头文件原因KER_DIR 路径不对或者内核没有先编译过。解决确认 KER_DIR 指向正确的内核源码目录并且在内核目录下至少执行过一次 make 生成了必要的头文件和配置。12. 开发环境配置参考示例使用的开发环境项目值交叉编译器arm-linux-gnueabihf-gcc目标 CPUCortex-A7浮点hard float NEON内核版本linux-imx-rel_imx_4.1.15_2.1.0_ga_alientek开发板正点原子 IMX6ULL (Mini 版)
