搞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 ARCH=arm和CROSS_COMPILE=arm-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之后先dmesg,probe函数里有没有打印、报错在哪个环节,一眼就能看出来。
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>; tmp108@48 { 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有没有跑,最后查通信时序,这条排查路径能解决绝大多数问题。
最后再分享一个小技巧:开发阶段给你的驱动多留一点日志和调试接口,线上出问题的时候,这些“不起眼”的日志能救你的命。等产品稳定了再裁剪不迟,千万别一上来就追求代码精简和镜像瘦身,调试能力比那点存储空间值钱得多。