Linux字符设备与I2C驱动开发:设备树实战指南

发布时间:2026/9/13 20:39:33
Linux字符设备与I2C驱动开发:设备树实战指南 搞Linux设备驱动开发这几年我最大的感受是驱动这玩意儿看着高大上其实剥开来看就是“让内核认识你的硬件然后给应用层开个口子”。很多人一上来就啃《Linux设备驱动开发详解》这类大部头结果啃到一半就放弃了——不是书不行而是没找到抓手。现在嵌入式平台、开源硬件、各种开发板大量落地Linux设备驱动开发确实是很多人的刚需但网上的资料要么太陈旧要么太零散不少人问的问题都落在同一个地方我到底该从哪儿开始写这篇文章我打算换个讲法不完全按照教科书的顺序来而是按真实项目推进的路径先把字符设备驱动框架看透再把设备树配置讲明白接着用一个完整的LED字符设备驱动串起从写代码到加载验证的全流程然后深入I2C设备驱动这种真实项目里高频出现的总线设备驱动最后分享一些我实际踩过的坑和排查思路。适合刚入门的朋友也适合做了一两年嵌入式Linux开发但总觉得知识体系不完整的人。看完你就能照着动手把基础驱动跑起来。1. 先把问题摆正字符设备驱动框架到底在干什么1.1 驱动与应用的分工谁在跟硬件打交道先聊聊“驱动”这个词。不少初学者听到驱动开发第一反应就是操作寄存器、控制硬件感觉特别底层特别神秘。但实际上在Linux里驱动的核心任务就两个一个是对外把硬件能力抽象成应用层能调用的接口另一个是对内配合内核的各个子系统完成资源分配和管理。拿最简单的LED来说。硬件上LED接在某一个GPIO引脚上你要让它亮本质上就是把那个引脚的电平置高或者置低。但应用层程序通常不应该也不被允许直接操作物理地址原因很实际一是安全问题用户态进程一旦能直接改物理寄存器的值整个系统的稳定性就谈不上二是资源竞争问题多个程序同时操作同一个硬件如果没有内核来统一管理不出乱子才怪。Linux把这种访问统一收口到内核态由驱动负责操作硬件应用层通过open、read、write、ioctl这些标准接口向驱动发请求。这就是“字符设备”这个名字的来历——设备像字符流一样数据按字节顺序读写设备节点就是/dev/led这样的文件。对应用层来说操作一个硬件设备和读写一个普通文件没什么两样。这个抽象非常关键它能让你在应用层写的代码完全不关心底层硬件的差异这也是“一切皆文件”设计哲学的实际体现。1.2 字符设备驱动绕不开的三个核心对象搞清分工之后来看字符设备驱动里必须搞明白的三样东西设备号、file_operations结构体、设备节点。设备号是设备在内核中的“身份证号”由主设备号和次设备号组成。主设备号用来区分驱动类型次设备号用来区分同一个驱动下面的多个设备。注册的时候可以静态指定也可以动态分配。实战里我基本都用动态分配让内核去选一个空闲的主设备号省得自己拍脑袋定一个数然后跟别人的驱动撞车——这种冲突排查起来很让人头大。file_operations是这个驱动真正的“灵魂”它里面放的是各种回调函数指针open对应打开设备、release对应关闭、read对应读数据、write对应写数据、unlocked_ioctl对应自定义命令。应用层调用open()打开/dev/led时内核会通过虚拟文件系统层找到这个设备号对应的file_operations然后调用你注册的led_open函数。设备节点则是设备在文件系统里的表现。传统方式可以用mknod手动创建现在的主流方式是在驱动注册时通过device_create自动创建设备节点配合udev机制驱动一加载/dev下面就会自动出现对应的设备文件省心很多。1.3 为什么file_operations是灵魂这里多说一句为什么file_operations的地位这么高。因为Linux的设计哲学里奉行“一切皆文件”文件这个抽象非常强悍它让应用层的代码可以不关心底层硬件长什么样读U盘数据和读温湿度传感器数据在应用层看起来都是read()一个文件描述符控制串口和控制LED都是write()一个文件描述符。驱动开发者的核心工作就是把这些千奇百怪的硬件操作翻译成文件读写的语义。比如一个带FIFO的串口设备你得让read()在数据没来的时候阻塞等待数据来了再返回一个温度传感器你得让read()去触发一次采样等转换完成后把结果拷贝到用户空间。这中间的翻译工作就是file_operations里各个回调函数需要实现的逻辑。把这块理解透了后面看什么驱动都能提纲挈领。2. 设备树嵌入式驱动绕不开的“硬件说明书”2.1 设备树到底是个什么东西讲完字符设备框架接着必须说设备树。现在做嵌入式Linux凡是新一点的平台设备树都是绕不开的坎。它解决的问题很现实老内核时代硬件信息大量靠板级文件里的C代码来定义一个平台一个board文件一个文件里写满了注册平台设备、添加I2C设备、配置GPIO的代码。后来处理器型号越来越多同一个内核要支持几十上百种板卡板级代码越堆越多维护成本高到让人崩溃。设备树就是来收拾这个烂摊子的它把硬件描述从内核代码中剥离出来用一个独立的.dts文本文件描述“这块板子上有哪些硬件、分别挂在哪个总线哪个地址、需要什么配置”编译成.dtb二进制后由bootloader在启动时传给内核解析。可以把它理解成硬件资源的配置清单内核拿到清单后就知道树上有哪些节点然后根据节点的compatible属性去匹配对应的驱动程序。这里有个容易忽略的点设备树不只是描述硬件配置它还承载了“让同一个内核镜像支持多个板子”的能力。只要bootloader传不同的dtb同一个zImage就能在不同的硬件上跑起来。这也是现在几乎看不到以前那种“一个板子一套内核补丁”做法的重要原因。2.2 一个compatible属性引发的“血案”设备树节点里最重要的字段就是compatible。驱动侧有一个of_match_table设备树节点的compatible只要跟of_match_table里任何一个字符串匹配上驱动就会被触发probe——也就是进入驱动的初始化探测流程。这里最容易踩的坑就是compatible匹配不上。两边字符串看着挺像就是少了一个逗号或者大小写不一致结果板子启动后驱动就是不probe设备节点也不出现。我以前调一个触摸屏驱动折腾了整整一个下午最后发现设备树里写的是“goodix,gt911”驱动匹配表里写的是“goodix,GT911”仅仅大小写不一样。从那以后我每次写设备树节点都养成了一个习惯直接去驱动源码头文件里复制compatible字符串绝对不手敲。这种低级错误在排查时真的是最浪费时间的。另外还要注意同一个设备节点可以有多个compatible字符串用空格分隔。内核在匹配的时候会依次尝试。这在做兼容设计时很好用比如你给新版硬件写驱动又想让它兼容老版固件就可以把新老两个兼容字符串都写上。2.3 驱动里怎么解析设备树信息驱动代码里通过struct device_node和struct property来访问设备树信息。常用的API有这么几个of_find_node_by_name用来按名字找节点of_property_read_u32用来读32位整数属性of_property_read_string用来读字符串属性of_get_named_gpio用来取GPIO编号。这些API在内核文档里都有详细说明但实际用的时候有一个经验值得注意我推荐的做法是一进probe就把需要的资源全部解析出来解析失败马上返回错误不要拖到后面真正用的时候才去查。因为probe阶段是整个驱动初始化的黄金窗口这时候错误能干净利落地暴露出来日志也清晰。如果你把设备树解析放在read或者write函数里一旦出错不仅bug难重现还可能在中断上下文里碰到各种限制。struct device_node *np dev-of_node; u32 val; if (of_property_read_u32(np, max-speed, val)) { dev_err(dev, failed to get max-speed\n); return -EINVAL; }设备树解析接口返回值要检查这算是内核开发的基本素养。返回值非零说明属性不存在或者类型不匹配这时候如果没有错误处理后面所有临时变量都会是未初始化的垃圾值bug特别难抓。3. 实操手写一个LED字符设备驱动的完整过程3.1 工程结构和Makefile怎么组织光讲概念不够我直接拿一个最简单的LED驱动来走一遍全流程。这个驱动麻雀虽小五脏俱全设备树描述GPIO、platform_driver匹配、字符设备注册、应用层读写控制该有的框架全都有。工程目录我就用最简单的方式组织led_drv/ ├── led_drv.c ├── led.dtsi └── MakefileMakefile的关键是调用内核的Kbuild系统来编译外部模块我通常这么写obj-m : led_drv.o KERNELDIR ? /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M$(PWD) clean两个注意点。第一KERNELDIR这里指的是目标平台内核源码的build目录如果是开发板上用要指向你已经配置好的内核源码树如果是交叉编译编译前需要export ARCHarm和CROSS_COMPILEarm-linux-gnueabihf-这类环境变量。第二目标板的内核源码必须先编译过一次否则Kbuild系统很多中间文件都不存在外部模块编译会直接报错。3.2 完整驱动代码逐段拆解下面是精简但能跑的LED驱动代码。为了控制篇幅我做了些压缩但错误处理逻辑是完整的可以直接照着用。#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/of.h #include linux/of_gpio.h #include linux/gpio.h #include linux/uaccess.h #include linux/platform_device.h #define LED_OFF 0 #define LED_ON 1 static int led_major; static struct class *led_class; static struct cdev led_cdev; static int led_gpio -1; static ssize_t led_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { char val gpio_get_value(led_gpio) ? 1 : 0; if (copy_to_user(buf, val, 1)) return -EFAULT; return 1; } static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { char kbuf; if (copy_from_user(kbuf, buf, 1)) return -EFAULT; if (kbuf 1) gpio_set_value(led_gpio, LED_ON); else if (kbuf 0) gpio_set_value(led_gpio, LED_OFF); else return -EINVAL; return count; } static int led_open(struct inode *inode, struct file *filp) { return 0; } static int led_release(struct inode *inode, struct file *filp) { return 0; } static const struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .release led_release, .read led_read, .write led_write, }; static int led_probe(struct platform_device *pdev) { struct device *dev pdev-dev; dev_t led_devno; int ret; led_gpio of_get_named_gpio(dev-of_node, led-gpios, 0); if (led_gpio 0) { dev_err(dev, invalid led gpio\n); return led_gpio; } ret gpio_request(led_gpio, led); if (ret) { dev_err(dev, gpio request failed\n); return ret; } gpio_direction_output(led_gpio, LED_OFF); ret alloc_chrdev_region(led_devno, 0, 1, led_drv); if (ret) { dev_err(dev, alloc chrdev region failed\n); goto err_gpio; } led_major MAJOR(led_devno); cdev_init(led_cdev, led_fops); led_cdev.owner THIS_MODULE; ret cdev_add(led_cdev, led_devno, 1); if (ret) { dev_err(dev, cdev add failed\n); goto err_region; } led_class class_create(THIS_MODULE, led_class); if (IS_ERR(led_class)) { ret PTR_ERR(led_class); goto err_cdev; } device_create(led_class, NULL, led_devno, NULL, led); dev_info(dev, LED driver probed, gpio%d\n, led_gpio); return 0; err_cdev: cdev_del(led_cdev); err_region: unregister_chrdev_region(led_devno, 1); err_gpio: gpio_free(led_gpio); return ret; } static int led_remove(struct platform_device *pdev) { dev_t led_devno MKDEV(led_major, 0); device_destroy(led_class, led_devno); class_destroy(led_class); cdev_del(led_cdev); unregister_chrdev_region(led_devno, 1); gpio_free(led_gpio); return 0; } static const struct of_device_id led_of_match[] { { .compatible myboard,led }, { } }; MODULE_DEVICE_TABLE(of, led_of_match); static struct platform_driver led_driver { .probe led_probe, .remove led_remove, .driver { .name led_drv, .of_match_table led_of_match, }, }; module_platform_driver(led_driver); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple LED char driver);这段代码的核心逻辑拆开看就几条线led_probe先用of_get_named_gpio从设备树拿GPIO编号再请求GPIO并设为输出。GPIO操作是驱动里最基础也最高频的操作gpio_request和gpio_direction_output是标配。字符设备注册的流程是alloc_chrdev_region分配设备号、cdev_init初始化、cdev_add添加到内核、class_create创建设备类、device_create在/dev下创建设备节点。read和write分别实现读取当前电平、设置电平的功能。注意从用户空间拷数据必须用copy_to_user和copy_from_user不能直接解引用用户空间指针否则会产生安全问题或访问非法地址。3.3 设备树里怎么描述LED要让这个驱动在实际板子上工作设备树里要有对应的节点。比如这样/ { leds { compatible myboard,led; led-gpios gpio0 3 0; status okay; }; };这里led-gpios属性中gpio0表示GPIO控制器3是引脚编号0是GPIO标志位。如果LED是低电平点亮这里的标志位可能要改成GPIO_ACTIVE_LOW对应的值具体看SoC的GPIO控制器怎么定义。不同厂家的GPIO编号基址可能不一样有的从0开始有的带一个比较大的起始偏移不能想当然。查硬件手册或者看设备树里gpio-controller节点的#gpio-cells定义最靠谱。status属性在调试中很有用改成disabled可以让驱动不识别这个设备适合在系统启动早期做排除法看是硬件问题还是驱动问题。3.4 编译、加载、验证一条龙编译完成后把led_drv.ko拷贝到目标板上然后执行insmod led_drv.ko dmesg | tail # 查看设备节点是否生成 ls -l /dev/led # 点亮 echo 1 /dev/led # 熄灭 echo 0 /dev/led # 读回状态 cat /dev/led如果一切正常LED会跟着你的指令明灭。这个简单的链路走通以后后面无论多复杂的驱动基本骨架都是一样的设备树里描述硬件驱动里解析资源、注册设备、提供文件操作接口应用层通过文件接口操作硬件。很多朋友第一次看到设备节点没出来就慌了其实排查思路特别简单insmod之后先dmesgprobe函数里有没有打印、报错在哪个环节一眼就能看出来。4. I2C设备驱动从总线框架到真实传感器通联4.1 I2C子系统的多层结构字符设备是基础框架但真实项目里更多的驱动是挂在总线上的I2C就是最典型的一种。你看现在开发板上的外设温度传感器、陀螺仪、触摸屏、EEPROM、电源管理芯片十有八九都是I2C接口。所以I2C驱动这块内容长期是大家搜索和学习的重点完全有道理。Linux的I2C子系统可以分成几个层次I2C核心负责管理总线和设备的匹配I2C总线驱动也就是适配器驱动由SoC厂商提供负责跟硬件I2C控制器打交道真正需要我们动手写的是设备驱动层也就是跟具体芯片对应的客户端驱动。平时说“写一个I2C传感器驱动”实际就是在这一层工作。这个分层设计的好处很明显适配器驱动相对固定不会频繁变动客户端驱动聚焦具体芯片的逻辑。两者通过Linux的设备和驱动模型自动完成匹配互不干扰。这样我们写新传感器驱动时不用关心底层的I2C控制器怎么工作的直接用内核提供的API就能完成通信。4.2 设备树中的I2C设备声明在设备树里I2C设备以子节点形式挂在I2C控制器节点下面。每个子节点必须有reg属性表示设备在总线上的7位地址。地址不对probe永远不会被调用。比如一个0x48地址的温度传感器设备树节点可以写成这样i2c1 { status okay; clock-frequency 100000; tmp10848 { compatible ti,tmp108; reg 0x48; }; };这里clock-frequency是I2C总线的频率100kHz是标准模式400kHz是快速模式具体能用多快取决于芯片手册和板子布线质量。在开发阶段我一般先用100kHz稳定之后再考虑提高频率。还有一点值得提醒节点名里的48只是名字的一部分真正决定地址的是reg属性二者最好保持一致不然调试的时候看着特别扭。4.3 i2c_driver注册与probe匹配机制I2C设备驱动的注册入口用i2c_add_driver它的结构跟平台驱动非常像区别在于匹配方式更灵活。除了跟设备树compatible匹配还支持id_table老式匹配。static const struct of_device_id tmp108_of_match[] { { .compatible ti,tmp108 }, { } }; MODULE_DEVICE_TABLE(of, tmp108_of_match); static const struct i2c_device_id tmp108_id[] { { tmp108, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, tmp108_id); static struct i2c_driver tmp108_driver { .driver { .name tmp108, .of_match_table tmp108_of_match, }, .probe tmp108_probe, .remove tmp108_remove, .id_table tmp108_id, }; module_i2c_driver(tmp108_driver);probe函数里会拿到一个struct i2c_client指针通过client-addr可以读取设备地址通过client-adapter可以拿到当前设备所在的总线适配器。有一点我多说一句i2c_driver的.remove返回值在新版内核里已经改成了void写驱动时先确认一下你内核版本的API避免编译告警或报错。4.4 与I2C设备通信的read/write实战跟I2C设备通信有两种常见方式一是直接用i2c_transfer发送完整的读写序列二是用i2c_smbus_read_byte_data这类便捷函数。后者更适合寄存器型的传感器代码更简洁static int tmp108_probe(struct i2c_client *client) { struct i2c_adapter *adapter client-adapter; s32 val; if (!i2c_check_functionality(adapter, I2C_FUNC_SMBUS_READ_BYTE_DATA)) return -EOPNOTSUPP; val i2c_smbus_read_byte_data(client, TMP108_REG_TEMP); if (val 0) { dev_err(client-dev, read temperature failed\n); return val; } dev_info(client-dev, temperature raw: 0x%04x\n, val); return 0; }这里i2c_check_functionality用来确认适配器是否支持你要用的SMBus操作类型不支持就直接返回不然运行时会报一些让人摸不着头脑的错。再说说i2c_transfer和i2c_smbus的区别。i2c_transfer更底层传输由struct i2c_msg数组来描述可以灵活构造复合时序比如带重复起始位的“先写寄存器地址再读数据”适合比较复杂的传感芯片。i2c_smbus是SMBus规范定义的子集覆盖了大多数寄存器型外设的场景代码也清晰。我的习惯是先用i2c_smbus把流程调通遇到特殊时序需求再换i2c_transfer不迟。很多I2C芯片对连续读写有时序要求比如寄存器地址多字节、数据多字节需要交换字节序这些必须对着datasheet仔细查。真出问题的时候大概率不是驱动框架的问题而是时序或者寄存器配置的问题。5. 常见问题与排查技巧实录5.1 驱动加载成功但probe不执行怎么办驱动模块insmod成功但probe没被调用这个现象十有八九是compatible不匹配。排查手段其实很朴素先看/sys/firmware/devicetree/base/下有没有对应节点确认设备树里确实编译进去了。用ls /sys/bus/platform/drivers/led_drv/查看驱动有没有绑定设备。节点存在但没绑定就把of_match_table里的字符串和设备树里的compatible逐个字符对比。这里有个小命令特别好用直接读设备树节点里的compatible值cat /proc/device-tree/leds/compatible对照驱动里的of_device_id一眼就能看出差别。我遇到过不少次问题就出在dtsi文件include的顺序上某个公共dtsi把compatible覆盖了导致最终生效的设备树跟预期不一致。5.2 设备树改完不生效多半是链路问题设备树改了不生效是另一个高频问题。这个坑主要出在编译和加载链条上——.dts编译成的.dtb放在哪里、bootloader加载的是哪一份、缓存里的旧dtb有没有被清掉每一环都可能出问题。我自己在开发阶段的做法是在bootloader里明确指定dtb路径每次改完设备树就clean编译一次不要图省事用增量编译。因为有时候你以为重新编译了实际编译系统因为时间戳判断规则没触发新的生成白折腾半天。还有一个更隐蔽的问题同名节点覆盖。设备树里允许多个.dtsi文件中的同名节点进行合并和覆盖有时候你在主.dts里改了某个属性但某个.dtsi在后面的include中又把值覆盖回去了最后生效的配置跟你的预期完全不一样。遇到这种问题最靠谱的办法是把编译好的.dtb反编译回dts反编译出来的内容就是内核真正解析到的内容机器不会骗你。5.3 I2C通信异常的定位方法I2C通信报错时先分清是哪个层面的问题设备不在总线上、地址不对、时钟频率不匹配、还是电气问题。最常用的定位工具是i2cdetect命令行扫描一下总线i2cdetect -y 1这条命令会扫描I2C总线1上所有7位地址能看到哪些地址有设备应答。如果扫描不到设备先查硬件连接和供电再在软件层面确认设备树节点里的地址是否跟实际硬件一致。如果i2cdetect能扫到但驱动里读数据返回错误那就看dmesg里有没有NACK、总线仲裁之类的日志。NACK往往说明地址对了但设备的某个操作状态不对或者是总线频率太高超出了器件能力。还有一种情况是总线挂死SCL和SDA被拉死。这种多半是软件时序问题导致I2C状态机跑偏最简单的办法是给设备断电重启或者让GPIO模拟I2C的停止位来复位总线。真实硬件上调试示波器是最直观的没有示波器就靠i2cdetect加dmesg组合排查也能解决大部分问题。5.4 系统裁剪优化先保留调试能力再瘦身最后聊聊系统裁剪和优化这也是很多嵌入式Linux项目里跟驱动开发同等重要的一块工作。裁剪的本质是砍掉非必要功能腾出存储空间和内存。常用手段包括精简内核配置、去掉用不到的驱动和子系统、关闭调试日志、选用更小的文件系统、只保留必需的用户空间库和工具。做裁剪时最容易犯的错误是过度裁剪。我有一次为了压缩镜像把内核的printk全关了、debugfs也关了结果后面调一个新驱动时完全两眼一抹黑只能又花时间把日志功能加回来。我的建议是先把功能全部跑通日志保留等系统稳定了再做裁剪。而且每次只裁一项做一次回归测试不要一口气全砍完出了问题都不知道是哪个配置引起的。性能调优方面驱动的瓶颈往往卡在中断处理和数据拷贝上。能用DMA传输就别让CPU一条一条搬数据能用mmap共享内存就别反复copy_to_user。适当调整中断触发方式、使用tasklet或者workqueue来延后处理非紧急任务都能明显降低系统延迟。当然这些都是后续进阶的内容先把基础驱动跑利索再考虑优化不迟。写到这里回头看Linux设备驱动开发这条路的起点其实就集中在几件事上字符设备框架、平台驱动模型、设备树解析、总线子系统的匹配机制。把这些吃透再去看各种具体驱动本质上都是在同一个骨架上套不同的硬件操作而已。我个人的体会是学驱动开发光看文档肯定不行必须得在真板子上跑起来。最开始哪怕只是控制一个LED点亮熄灭也比对着书看十遍有用。真遇到问题先查设备树对不对再查probe有没有跑最后查通信时序这条排查路径能解决绝大多数问题。最后再分享一个小技巧开发阶段给你的驱动多留一点日志和调试接口线上出问题的时候这些“不起眼”的日志能救你的命。等产品稳定了再裁剪不迟千万别一上来就追求代码精简和镜像瘦身调试能力比那点存储空间值钱得多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询