资讯详情

资讯详情

Linux驱动开发实战:字符设备、设备树与platform驱动解析

做Linux设备驱动开发这行入门第一感觉往往不是“难”而是“乱”。同是写个hello world应用程序三行代码就能跑驱动模块却要纠结内核版本、编译器、模块签名、设备号还没见到效果就先被各种报错劝退。我刚入行那阵光是让一个LED在开发板上亮起来就踩了整整两天的坑最后发现只是设备树里GPIO编号写岔了。这篇文章不打算把Linux设备驱动开发讲成一本百科全书而是按我从零到一、从踩坑到熟练的真实路径把驱动开发里最核心、最绕不开的东西拆开讲清楚驱动的框架是什么字符设备怎么写platform驱动怎么和设备树配合遇到常见的加载、运行、排查问题怎么处理。内容面向刚接触嵌入式Linux的工程师、准备转驱动方向的开发者也适合那些应用层写多了、想往底层探一探的朋友。就算你暂时没有开发板用一台普通的x86 Linux机器跑模块、做实验这套思路也完全适用。1. 入门之前先把驱动开发的整体思路理清楚1.1 驱动到底是什么很多人一听到“设备驱动”就本能地发怵觉得是特别底层、特别神秘的东西。实际上下个不那么严谨但特别好用的定义驱动就是内核里的一段软件它一边把硬件能力包装成规范接口一边把内核下发的指令翻译成硬件能听懂的寄存器操作。以最简单的LED为例。应用层想点灯它可以不去管这个LED是接在GPIO上还是通过I2C接了一个扩展芯片它只需要向某个设备文件写入一个“1”剩下的工作全部由驱动完成。驱动要做的是拿到这个“1”找到对应的引脚计算寄存器地址把电平拉高。整个过程就是软件和硬件之间的“翻译”。这里有一个初学者最容易迷糊的概念用户态和内核态。CPU为了安全把运行环境分成了两个权限等级。应用跑在用户态权限很有限不能直接操作硬件地址内核跑在内核态有完整的硬件访问权。驱动的代码编译之后是内核模块它以内核态身份运行所以才有资格去读寄存器、申请中断、访问DMA。同时内核又通过系统调用的方式给用户态开了一扇门让应用可以间接使用硬件能力。没有这层隔离任何程序都能乱碰硬件系统早崩了。1.2 驱动的三大类和应用场景按设备类型划分驱动一般分为三类字符设备按字节流读写比如串口、GPIO、按键、ADC。最常见的驱动类型绝大多数入门教程都从这开始。块设备以块为单位读写比如硬盘、SD卡、eMMC核心指标是吞吐量和IO调度。网络设备负责数据包的收发比如以太网卡、WiFi模块它不走设备文件那套接口走的是内核网络协议栈。在实际工作中嵌入式Linux驱动开发接触最多的就是字符设备尤其是在做传感器、显示屏、摄像头、电机控制的场景下一板子下来能挂十几个字符设备。这种驱动不像块设备那么复杂但五脏俱全适合作为学习的主线。1.3 总线-设备-驱动模型驱动开发的地基真正写驱动之前有一个绕不开的概念内核里的“总线-设备-驱动”模型。你打开/sys/bus目录能看到平台总线、SPI总线、I2C总线、PCI总线、USB总线等每一种总线都管理着一批设备和一批驱动。这里的关键是“匹配”机制。设备侧描述“我是什么硬件”驱动侧声明“我支持什么硬件”总线负责在两者之间牵线搭桥匹配上了就调用驱动的probe函数。也就是说驱动和硬件不是靠include头文件绑定到一起的而是由内核在运行时动态匹配出来的。最常见的platform总线也叫平台总线是一种虚拟总线专门用于整理那些不挂在PCI、USB、I2C这类实体总线上的设备比如芯片内部集成的GPIO控制器、UART、DMA控制器等。以前大家习惯在BSP板级文件里硬编码设备资源内核一升级就得跟着改痛苦不堪。后来引入了设备树把硬件描述从内核代码里剥离出来变成一份独立的、可被引导加载程序传递给内核的数据结构。驱动不再关心设备具体在哪块板子上只关心设备树里有没有匹配的节点。这套解耦思路是整个现代Linux驱动开发的基石。2. 字符设备驱动的核心拆解别被术语吓住2.1 字符设备的基本结构一个字符设备驱动就算功能再简单也绕不开这几个东西设备号、file_operations结构体、字符设备对象、设备节点。先看设备号。Linux用主设备号和次设备号来标识一个设备。主设备号表示设备类型对应某个驱动次设备号表示同一个驱动下的不同实例。打个比方主设备号相当于公司名次设备号相当于工号光有公司名定位不到具体的人必须两个配合。再看file_operations。这个结构体就是驱动对外服务的“菜单”。应用调用open、read、write、ioctl、release时虚拟文件系统VFS会根据设备号找到对应的file_operations然后调用你实现的函数。也就是说你在驱动里写的read函数并不是自己决定什么时候执行的而是用户态程序调用了read()系统调用后由内核按图索骥调过来的。最后是设备节点也就是/dev/目录下那个文件。它本身不包含数据只是一个入口记录了设备号。应用打开它等于通过VFS找到了驱动。2.2 一个最朴素的字符设备要写哪些东西拿最精简的代码举例一个miscdevice杂项设备字符设备可以写成这样#include linux/module.h #include linux/miscdevice.h #include linux/fs.h static ssize_t demo_read(struct file *file, char __user *buf, size_t count, loff_t *ppos) { char data[] hello driver\n; if (*ppos 0) return 0; if (copy_to_user(buf, data, sizeof(data))) return -EFAULT; *ppos sizeof(data); return sizeof(data); } static const struct file_operations demo_fops { .owner THIS_MODULE, .read demo_read, }; static struct miscdevice demo_dev { .minor MISC_DYNAMIC_MINOR, .name demo, .fops demo_fops, }; module_misc_device(demo_dev); MODULE_LICENSE(GPL);这段代码虽然短但信息量不小。demo_read是真正干活的函数用户态执行read(fd, buf, sizeof(buf))时数据通过copy_to_user从内核缓冲区拷贝到用户缓冲区。这里有一个新手很容易踩的坑你以为可以直接把内核里的一个指针返回给用户态或者直接解引用用户态传进来的指针。不行内核态和用户态的内存是隔离的必须用内核提供的copy_to_user和copy_from_user来搬运数据。这也是驱动开发和普通应用开发最大的思维差异之一。module_misc_device(demo_dev)是一个简化宏它帮你完成了module_init和module_exit的注册。如果用misc子系统次设备号设置为MISC_DYNAMIC_MINOR内核会自动分配一个空闲的次设备号省去自己管理设备号的麻烦。设备节点在驱动加载后由misc子系统自动创建为/dev/demo不需要你手动mknod。编译这个模块只需要一个简单的Makefileobj-m : demo.o KDIR : /lib/modules/$(shell uname -r)/build all: make -C $(KDIR) M$(PWD) modules clean: make -C $(KDIR) M$(PWD) clean然后执行make就会生成demo.ko。insmod demo.ko加载再看/dev/demo是否存在用cat /dev/demo就能读到那行“hello driver”。2.3 为什么现在都推荐用miscdevice可能有人会问教材里不都是先register_chrdev_region、再cdev_add、再class_create、再device_create吗这套流程没错但步骤多容易错。misc设备相当于内核给你做好的一个封装它本质还是一个字符设备但内核已经帮你处理了设备号分配、设备节点创建这些样板活。因为misc设备有固定的一组主设备号内核内部已经注册好了对应的字符设备和class你只需要提供一个名字和file_operations就够了。当年我刚开始学的时候照着老教程一路register_chrdev、cdev_init、cdev_add、device_create写下来每次都在设备节点创建那块出问题要么忘了创建class要么device_create参数写错。后来才知道大部分教学场景用miscdevice完全够用代码量少一半还不容易出错。当然如果设备特别多、需要自管理主设备号和多个次设备号再走完整的那套流程也不迟。2.4 write/read在驱动里做的事read和write在驱动里干的事情总结起来就三件参数检查、数据传输、状态更新。参数检查包括用户传入的缓冲区指针是否有效、读取位置是否越界、请求的字节数是否合理解释。比如demo_read里检查*ppos 0就直接返回0表示没有更多数据了这是read系统调的约定返回0表示EOF。数据传输是核心。写操作通常需要从用户态拿数据进来驱动拿到这些数据后可以存入缓冲区、配置寄存器、设置某个硬件状态。读操作则是把硬件状态、寄存器值、缓冲区内容拷贝给用户态。数据量小就用copy_to_user/copy_from_user数据量大、性能要求高的场景会用到mmap、DMA但那已经是进阶玩法了。状态更新稍微隐蔽一点但同样重要。很多驱动在read之后会更新文件偏移*ppos在write之后会记录累计写入字节数在open的时候递增模块引用计数在release的时候递减。这些细节直接决定了设备的行为是否符合应用层的预期。3. 实操从零写一个platform驱动的LED例子3.1 需求定义和方案选型纸上谈兵聊够了下面来一个真实的例子在嵌入式板子上用设备树描述一个GPIO LED然后写一个platform驱动把它点亮。为什么用platform驱动而不是直接写个字符设备因为这里涉及一个核心原则驱动只负责“逻辑”不负责“硬件资源写死”。LED接在哪个GPIO、有没有上拉、默认电平是高有效还是低有效这些都属于硬件描述信息应该放在设备树里由驱动运行时去读取。如果把这些写死在代码里换一块板子、换一个引脚就得改代码重新编译维护成本极高。GPIO API的选型也有讲究。老的gpio_request/gpio_direction_output这套API在如今的内核里还能用但已经属于遗留接口。我建议新驱动一律使用gpiod_系列API也就是GPIO描述符接口。它更安全、更灵活而且天然支持设备树里gpios属性里的active-low标志。什么意思呢比如你接的LED是低电平点亮设备树里标了GPIO_ACTIVE_LOW驱动用gpiod_set_value(desc, 1)就可以直接点亮它内核会自动帮你反转电平不用在驱动里自己判断。3.2 设备树配置设备树源文件dts里为我们的LED设备添加一个节点/ { myled { compatible myvendor,myled; gpios gpio4 17 GPIO_ACTIVE_HIGH; status okay; }; };逐行解释一下。compatible是设备树匹配驱动的关键字符串格式通常是“厂商,设备名”。平台总线匹配时内核会拿设备树节点的compatible和驱动of_match_table里的compatible逐一比较一致就算匹配成功。gpios属性定义了引脚。这里gpio4 17 GPIO_ACTIVE_HIGH的意思是这个LED挂在gpio4控制器下编号17的引腿上高电平有效。设备树里GPIO编号不是0-31的物理引脚号而是控制器内部编号具体对应哪个物理引脚要看芯片手册里的GPIO bank定义。这一步非常容易错我当年就吃过亏设备树里写了一个看起来合理的编号结果亮的是旁边另一颗灯。status okay是显式声明节点使能。这个字段可以方便地在不同板型上关闭或打开某个设备而不用删除节点本身。改完dts之后需要重新编译设备树并替换原有的dtb文件。这一步很多人会忘导致改了设备树却没有效果。编译命令一般是make dtbs然后把生成的新dtb拷贝到启动分区覆盖旧文件重启后执行ls /proc/device-tree/myled确认节点已经存在。3.3 驱动代码实现看过设备树再看驱动代码就顺理成章了#include linux/module.h #include linux/platform_device.h #include linux/of.h #include linux/gpio/consumer.h #include linux/err.h static struct gpio_desc *led_gpio; static int myled_probe(struct platform_device *pdev) { led_gpio devm_gpiod_get(pdev-dev, NULL, GPIOD_OUT_LOW); if (IS_ERR(led_gpio)) { dev_err(pdev-dev, failed to get gpio\n); return PTR_ERR(led_gpio); } gpiod_set_value(led_gpio, 1); dev_info(pdev-dev, led on\n); return 0; } static int myled_remove(struct platform_device *pdev) { if (led_gpio) gpiod_set_value(led_gpio, 0); return 0; } static const struct of_device_id myled_of_match[] { { .compatible myvendor,myled }, { } }; MODULE_DEVICE_TABLE(of, myled_of_match); static struct platform_driver myled_driver { .probe myled_probe, .remove myled_remove, .driver { .name myled, .of_match_table myled_of_match, }, }; module_platform_driver(myled_driver); MODULE_LICENSE(GPL);probe函数是驱动的核心入口。它的执行时机是platform总线在注册驱动时遍历已注册的所有设备一旦发现某个设备的compatible和我们驱动的of_match_table匹配就会调用probe。probe里做的事情通常是获取硬件资源、初始化寄存器、注册中断、创建设备文件等。这里用的devm_gpiod_get是带资源管理的API。它的好处是不需要手动释放GPIO——当设备与驱动分离时内核会自动释放分配的资源。类似的还有devm_kzalloc、devm_request_irq等等这类devm前缀的API在驱动开发里强烈推荐优先使用能减少很多内存泄漏和资源泄漏问题。module_platform_driver(myled_driver)展开后包含module_init和module_exit分别注册和注销这个platform驱动。3.4 编译、加载和验证Makefile可以直接沿用前面那个模板只需要把模块名改一下obj-m : myled.o KDIR : /lib/modules/$(shell uname -r)/build all: make -C $(KDIR) M$(PWD) modules clean: make -C $(KDIR) M$(PWD) clean编译完拿到myled.ko拷贝到目标板子上执行insmod myled.ko或者modprobe myled。重点提醒一下modprobe会做依赖分析和自动加载但它只会搜索标准模块目录如果你把ko随手丢在/tmp里还是得用insmod。开发阶段我习惯用insmod排查问题更直接。加载之后第一时间做三件事dmesg | tail -20 ls /sys/bus/platform/drivers/myled/ cat /sys/bus/platform/devices/myled/... 2/dev/nulldmesg里应该能看到“led on”的打印说明probe成功了。同时LED应该已经亮起来。如果没有看到优先检查设备树节点用find /proc/device-tree -name myled确认节点在不在系统里。3.5 应用层怎么用这个设备如果只是点亮LED驱动的probe函数已经做了。但实际项目里驱动往往要暴露一些操作接口给应用层。两种常见做法一是走LED子系统注册到/sys/class/leds/目录应用直接echo操作brightness文件二是自己实现file_operations创建/dev/myled设备节点提供read和write。走LED子系统的优势是内核已经帮你处理好了亮灭、闪烁、亮度等级甚至支持triggers触发源比如心跳灯、网络活动灯。对于一个标准LED设备我更推荐直接做成一个LED class设备省心。实现一个自定义字符设备则适合那些需要对LED做复杂控制的场景比如需要知道当前亮了多少小时或者要同时控制一组灯带。一段简单的应用测试程序打开设备节点并控制它#include stdio.h #include fcntl.h #include unistd.h #include string.h int main(int argc, char *argv[]) { int fd; char cmd[16]; if (argc ! 2) { fprintf(stderr, usage: %s on|off\n, argv[0]); return -1; } fd open(/dev/myled, O_WRONLY); if (fd 0) { perror(open); return -1; } if (strcmp(argv[1], on) 0) strcpy(cmd, 1); else strcpy(cmd, 0); write(fd, cmd, 1); close(fd); return 0; }这只是个测试程序工业项目里建议用ioctl传控制命令或者直接使用标准LED子系统接口。4. 常见问题与排查技巧实录4.1 insmod报错Invalid module format这个问题在开发初期出现频率极高基本原因只有一个编译模块用的内核源码、工具链和目标机器上的内核不一致。常见的坑包括本地是Ubuntu发行版内核你去下载了一个与当前内核版本号完全一样、但config不同的内核源码来编模块又或者模块是用主机自带gcc编的而目标板上内核是用交叉编译器编的两者版本不兼容。排查时先看完整报错insmod: ERROR: could not insert module myled.ko: Invalid module format dmesg | tail -5如果看到version magic相关的日志说明版本字符串不匹配。解决办法就是严格使用目标机同版本的内核源码和工具链编译模块。很多新人在虚拟机里学习建议uname -r看一眼然后用apt install linux-headers-$(uname -r)安装匹配的头文件不要自己随便下源码编。4.2 设备文件出现了但open失败open失败通常返回ENXIO没有这个设备或ENODEV设备不存在。这里分两种情况。如果设备节点是手动mknod创建的主次设备号和驱动注册的不一致就会这样。用cat /proc/devices看驱动注册的设备号再ls -l /dev/xxx比对节点的设备号。如果设备树驱动的probe没有执行即使你把设备节点建好了内核也找不到对应的设备实例open同样失败。这时候回看dmesg查一下驱动有没有注册成功、节点compatible是否匹配。4.3 printk看不到输出驱动里printk的print是一个很自然的调试手段但它不像用户态的printf那样一打印就出现在终端上。内核消息有日志级别只有级别高于控制台设定的日志级别时才会打印到串口或终端。实用建议开发阶段直接写pr_info或dev_info比裸写printk更规范。dev_info会带上设备名和驱动名定位问题一目了然。如果终端上看不到任何日志先cat /proc/sys/kernel/printk查看控制台日志级别需要时可以临时调整echo 8 /proc/sys/kernel/printk另外用dmesg总能看到所有日志不管控制台级别如何。4.4 设备树改了没生效设备树修改不生效是嵌入式开发里最常见、也最让人抓狂的问题之一。复盘下来原因基本逃不出这几个dtb没重新编译、dtb没写到正确分区、uboot加载的还是旧dtb。排查路径很明确先ls /proc/device-tree/myled确认节点在不在再cat /proc/device-tree/myled/compatible看属性值对不对如果节点不在大概率是新的dtb没被加载。另外别忘了设备树会被uboot覆盖或追加有些板子的uboot会从固定分区读dtb和内核镜像分开存放改完dtb单独重烧那个分区即可。还有一个容易忽略的问题dts编译时include了dtsi头文件如果改动写在了一个最终没被包含的dtsi里同样不会生效。先grep确认你的节点确实被包含到了最终的dts中。4.5 访问寄存器导致系统崩溃驱动按内核态运行权限高但也意味着一旦出错就是灾难。最常见的崩溃场景是驱动里直接通过某个指针去访问物理地址却没有做地址映射。芯片内部的寄存器物理地址必须通过ioremap或devm_ioremap_resource映射成内核虚拟地址后才能访问。直接拿一个裸的物理地址去解引用轻则触发kernel NULL pointer dereference重则整个系统死机或者触发看门狗复位。另外还需要注意内存屏障。驱动里的寄存器读写编译器可能为了优化乱序导致你写的顺序在硬件层面完全乱掉。访问外设寄存器时使用readl/writel这类带内存屏障的API不要直接操作指针。4.6 中断申请失败中断是驱动里比GPIO更容易踩坑的地方。申请中断用request_irq或devm_request_irq失败最常见的原因是中断号错误和中断冲突。新旧内核的中断号逻辑有差异。旧方式通过gpio_to_irq拿gpio对应的中断号新方式推荐直接用gpiod_to_irq(desc)从gpio描述符直接拿中断号避免手工转换。另一个常见问题是一个引脚同时被两段代码申请了中断或者寄存器配置成了别的复用功能导致中断不触发或直接冲突。排查时先确认中断有没有注册成功再看/proc/interrupts里有没有对应的设备中断计数在增长。如果计数不涨多半是硬件触发条件、电平极性配置的问题。4.7 常用的调试手段驱动开发没有万能的调试工具关键是形成自己的排查套路。我自己的习惯从外到内分几步。看日志。dmesg永远是第一现场开发时尽量把probe、remove、操作的日志打完整宁可多打一点产品发布前再收。看文件节点。/sys/bus/platform/drivers/myled/目录下能看到驱动绑定了哪些设备/sys/bus/platform/devices/能看到所有平台设备和它们匹配到的驱动。看内核状态。/proc/interrupts查中断计数/proc/device-tree查设备树实际生效内容/proc/modules查模块是否加载。有条件的话加一个GPIO调试勾子写一个简单脚本循环翻转引脚配合示波器或万用表量电平能快速把问题定位到硬件还是软件。嵌入式开发里很多时候不是代码不对而是上拉没焊、引脚复用错了、线接反了手边有个表比反复改代码快得多。5. 关于性能调优和框架的一些经验驱动开发做到后面功能通了只是及格性能调优才是真正拉开差距的地方。5.1 中断下半部、线程化中断和延迟工作中断处理程序的要求是“短平快”因为它在关闭本地中断的上下文里执行拖得越久系统的实时性越差。实际项目中我习惯把中断处理分成两段上半部只做和硬件强相关的事比如读取中断状态寄存器、清中断标志下半部再做耗时操作比如数据解析、唤醒等待队列。下半部的选择老内核常用tasklet但现在新代码里tasklet已经逐渐被边缘化推荐用workqueue或者直接线程化中断request_threaded_irq。我后来写的驱动基本都是线程化中断因为线程里有独立栈、可以睡眠、可以调用各种阻塞API写起来心智负担小很多。5.2 休眠与唤醒、阻塞与非阻塞驱动和应用不同很多情况下要“等”。比如一个按键驱动应用调用read想读取键值但用户还没按键驱动总不能一直忙等浪费CPU。正确做法是把进程投入到等待队列里睡眠等中断到来时再唤醒它。内核提供了一套非常成熟的等待队列机制wait_event_interruptible加上wake_up_interruptible是标准用法。需要注意的是使用中断版本也就是带interruptible后缀的接口时必须检查返回值因为进程可能被信号打断。返回值是-ERESTARTSYS时需要恢复到待机状态让应用重新发起系统调用。非阻塞模式下如果设备没有数据驱动应该直接返回-EAGAIN而不是睡眠。5.3 锁的选择并发不是玄学是基本功驱动运行在内核态并发访问是绝对绕不开的问题。同一个驱动可能被多个进程同时打开读操作发生的同时中断服务程序正在写状态CPU多核环境下不同核同时进入同一个驱动函数。没有锁就是数据竞争。驱动里常用的锁按使用场景分自旋锁用于不能睡眠的短临界区比如中断上下文里保护共享标志位互斥锁用于可能睡眠的长临界区比如缓冲区满时等待写入条件。经验法则是能睡就睡能不加锁就不加锁锁的粒度越小越好。以前我写过一个大临界区整个probe和读写操作全加了一把mutex结果一有IO操作其他进程就卡到不行后来把锁拆细性能立刻上来了。用锁还有一个习惯命名和注释要写清楚说明这段锁保护的是谁。不然两个月后回看代码想死的心都有。5.4 系统裁剪和驱动裁剪的关系很多人会问系统优化和驱动开发有什么关系关系很大。嵌入式产品里flash和内存都是稀缺资源。一个没有裁剪的发行版内核光模块就占几百MB跑在工业设备上既不现实也不安全。裁剪思路通常分两层。一层是内核本身裁剪通过menuconfig关掉用不到的子系统、文件系统格式、驱动模块。另一层是驱动模块裁剪把调试代码用内核配置项包起来比如#ifdef CONFIG_DEBUG_FS产品发布时关掉。还有一层是BSP层裁剪删除用不到的dts节点、外设驱动让系统启动更快、内存占用更小。这些工作看起来很杂但做嵌入式驱动开发迟早会碰到。懂一些内核裁剪和优化技巧能让你在方案评审、交付的时候更从容。6. 最后的几句实在话从零开始学Linux设备驱动开发说难是难说不难也不难。门槛主要在环境搭建和术语理解上一旦把“字符设备、平台驱动、设备树、GPIO子系统”这一串概念串起来后面学I2C、SPI、USB、PCIe都是同一个套路换皮而已。我见过不少人学驱动一上来就找《Linux设备驱动开发详解》从头啃啃到第三章就放弃了。我的建议恰恰相反先照着本文这种最小例子把模块编译、加载、卸载、查看日志这套流程跑通再回头看书里的理论理解速度会快很多。内核源码是最好的参考书drivers/gpio、drivers/leds、drivers/misc这些目录下全是短小精悍的真实驱动值得反复读。还有个小技巧送给大家如果遇到一个内核API不会用直接在源码里搜索引用它的驱动比翻文档快得多。内核开发文档有时更新不及时但真实驱动代码永远不会骗人。这条路子走下去你会发现驱动开发其实很有意思它处于软件和硬件交汇的那个点既要懂系统编程也要会看原理图是一个需要长期积累但又非常有成就感的领域。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →