ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从GPIO点灯学透Linux字符设备驱动开发全流程

从GPIO点灯学透Linux字符设备驱动开发全流程 1. 为什么把“GPIO点灯”当作Linux设备驱动开发的完整起点做嵌入式Linux开发的人迟早都会面对一个问题设备驱动到底该怎么学网上一搜I2C、SPI、USB、PCIe、中断、DMA、regmap各种框架铺天盖地新手很容易一头扎进复杂子系统里折腾两周连个能用的模块都没编译出来。我的建议一直很明确——从GPIO LED驱动开始而且不是简单地跑一下内核自带的leds-gpio例子而是自己从零手写一个字符设备驱动完整走一遍“模块初始化、注册设备、操作GPIO、应用层交互、编译加载”的流程。这个项目看着小实际覆盖的知识密度相当大。你写完这一个驱动基本就把Linux设备驱动开发的骨架摸清楚了模块加载卸载原理、file_operations接口、设备号分配、cdev注册、设备节点自動创建、GPIO子系统调用、内核态与用户态的数据交换还有Makefile的编写和模块的动态加载。这些技能放到任何外设驱动开发里都一样用I2C设备、SPI设备、PWM背光、传感器驱动套路完全相同只是换了具体的总线接口和协议处理。咱们这个项目不只是跑通一个灯而是把“驱动开发”这四个字拆开揉碎了讲透。我会带你从硬件准备开始到驱动代码逐行解读再到编译加载、应用层控制最后把常见的坑都列出来。不管你是刚接触嵌入式Linux的学生还是从单片机转Linux开发的工程师这篇文章都适合你按步骤操作一遍而且每一步我都会解释“为什么要这么做”。毕竟驱动开发最怕的就是照抄代码、跑完就忘理解了背后的设计逻辑你才能真的举一反三。1.1 项目目标与硬件准备先明确我们要做什么实现一个简单的Linux字符设备驱动通过设备文件控制一块GPIO引脚输出高低电平从而点灭LED灯。应用层可以写数据来控制灯的开关也可以读取当前灯的状态就像操作一个普通的文件一样。硬件方面我使用的是基于i.MX6ULL的开发板核心板自带的LED接到了GPIO1_IO04上。这个GPIO拉低时LED点亮拉高时LED熄灭。当然不同的板子引脚和电平逻辑不一样比如有些板子是拉高点亮、拉低熄灭你要根据自己的原理图来定。如果手里是树莓派或者其它ARM开发板方法也完全一样无非是GPIO编号和设备树节点不同。软件环境我用的内核版本是Linux 4.9.88交叉编译工具链是arm-linux-gnueabihf-gcc。这个组合在嵌入式开发里非常常见很多开发板厂商的SDK都是基于类似版本。你不需要纠结具体用哪个版本重要的是理解整个流程。另外有条件的话建议在虚拟机上装一个Ubuntu作为开发主机把内核源码解压好后面编译模块要使用内核源码树里的头文件和编译配置。在正式动笔写码之前还有一件很重要的事要明白“设备驱动”到底在驱动什么。Linux下一切皆文件应用层操作硬件也是通过文件接口来进行的。驱动要做的就是把底层的硬件操作封装成open、read、write、release这些用户熟悉的方法。LED点亮这个动作在驱动里就是调用GPIO子系统提供的接口去操作寄存器。这个思路捋顺了后面的代码就有章法了。1.2 字符设备驱动的基本骨架Linux驱动按设备类型可以分成字符设备、块设备和网络设备三大类。LED这种按字节流访问、顺序读写的数据设备天然属于字符设备也是最容易理解和实现的类型。字符设备驱动的核心是提供一个file_operations结构体里面挂载了应用层访问该设备时需要执行的回调函数。一个最小可用的字符设备驱动至少需要以下几个环节。模块加载函数使用module_init指定入口在里面完成设备号的申请和字符设备的注册。模块卸载函数使用module_exit指定出口释放所有申请的资源。file_operations结构体填充open、release、read、write等接口这是驱动和应用层交互的桥梁。cdev结构体内核用这个结构体来管理字符设备需要初始化并添加到内核中。设备节点注册成功后可以通过mdev/udev自动创建/dev/下的设备文件应用层通过这个文件操作设备。这五个要素里设备号分配和字符设备注册是比较核心的环节。设备号分为主设备号和次设备号主设备号标识设备对应的驱动程序次设备号标识同一个驱动管理的不同设备。在现代内核中我们更推荐使用动态分配设备号由内核自动分配一个空闲的主设备号避免手动指定导致冲突。很多初学者容易把“设备文件”和“设备节点”混为一谈。设备文件在/dev目录下但它并不是一个真实的文件而是用户态和内核态之间的一个接口。当你打开/dev/led时VFS层会根据设备文件的类型和主设备号找到对应驱动注册的cdev然后调用其中注册的open函数。这个过程有点像电话总机设备文件是分机号码cdev是座席你拨通号码总机帮你转接到对应的人。理解了这张骨架接下来要做的就是把GPIO操作塞进这个框架里。既然要控制LED驱动里就必须先获得GPIO引脚的控制权然后把引脚配置成输出模式。内核的GPIO子系统已经帮我们把这些工作标准化了只需要调用几个API就行。1.3 GPIO子系统的作用与选择GPIO子系统是Linux内核中统一管理通用输入输出引脚的一套框架。它把底层硬件寄存器操作全部封装起来给驱动开发者提供了简洁的API不用关心你的芯片是ARM还是其它架构也不用关心引脚控制寄存器具体在哪个地址。在较老的内核版本中常用的接口是gpio_request、gpio_direction_output、gpio_set_value这一套。而在较新的内核版本中内核社区推荐使用基于描述符的gpiod_*接口配合设备树中的gpio属性来使用。两者最大的区别在于旧接口用整数GPIO编号来标识引脚新接口用gpio_desc指针并且在设备树解析上更安全、更直观。很多开发板的BSP提供的例程还是用的旧接口而且因为简单直观用来教学也完全够用。这篇文章我主要用gpio_request系列接口来写因为逻辑链路清晰每一行的意图一目了然适合初学者吃透驱动的工作过程。如果你在公司项目里用的是新内核我后面也会单独讲一下gpiod接口的简单用法让你两个方向都知道。GPIO子系统的另一个价值在于引用计数。如果没有调用gpio_request就贸然去操作引脚内核日志会出现警告而且其他模块可能也在使用同一个引脚极容易造成混乱。这就像两个人同时用一把钥匙开同一扇门必须有一个登记机制来保证同一个引脚在同一时刻只能被一个驱动占用。GPIO子系统帮我们把这个登记机制做好了你只管在驱动里申请和释放就行。2. 驱动代码拆解从module_init到file_operations现在开始进入正题写驱动。这一节我先把核心代码分段拆开把每一段的作用、背后的机制、以及容易出错的地方都讲清楚。文末我会给出一个完整的可编译文件。2.1 头文件与模块加载函数先看代码文件顶部的头文件引入和模块入口。#include linux/module.h #include linux/kernel.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #include linux/gpio.h #include linux/delay.h这些头文件每个都不能少。module.h和kernel.h提供模块框架的基础定义fs.h提供文件操作相关的结构体和函数cdev.h是字符设备管理的核心头文件device.h用来支持设备类和自动创建设备节点uaccess.h提供内核态和用户态内存拷贝的函数gpio.h自然就是GPIO子系统的接口。再看模块入口函数。#define LED_GPIO 4 // 假设使用的是GPIO1_IO04 static int __init led_drv_init(void) { int ret; printk(KERN_INFO led_drv: init\n); ret alloc_chrdev_region(led_dev_num, 0, 1, led); if (ret 0) { printk(KERN_ERR led_drv: failed to alloc dev num\n); return ret; } printk(KERN_INFO led_drv: major%d, minor%d\n, MAJOR(led_dev_num), MINOR(led_dev_num)); cdev_init(led_cdev, led_fops); led_cdev.owner THIS_MODULE; ret cdev_add(led_cdev, led_dev_num, 1); if (ret 0) { unregister_chrdev_region(led_dev_num, 1); return ret; } ret gpio_request(LED_GPIO, led-gpio); if (ret 0) { printk(KERN_ERR led_drv: failed to request gpio %d\n, LED_GPIO); cdev_del(led_cdev); unregister_chrdev_region(led_dev_num, 1); return ret; } gpio_direction_output(LED_GPIO, 1); gpio_set_value(LED_GPIO, 1); // 创建设备类用来自动生成 /dev/led led_class class_create(THIS_MODULE, led_class); if (IS_ERR(led_class)) { gpio_free(LED_GPIO); cdev_del(led_cdev); unregister_chrdev_region(led_dev_num, 1); return PTR_ERR(led_class); } led_device device_create(led_class, NULL, led_dev_num, NULL, led); if (IS_ERR(led_device)) { class_destroy(led_class); gpio_free(LED_GPIO); cdev_del(led_cdev); unregister_chrdev_region(led_dev_num, 1); return PTR_ERR(led_device); } return 0; }这段代码值得细看。第一步alloc_chrdev_region是动态获取设备号返回的主设备号由内核随机分配避免了和其他驱动冲突。dev_t是一个32位的类型高12位是主设备号低20位是次设备号MAJOR和MINOR两个宏就是用来从dev_t中提取这两个值的。很多人搞不懂dev_t其实可以把它想象成一个压缩包里面装着主次设备号两个数据使用前先解压。申请完设备号之后紧接着是cdev_init和cdev_add。cdev结构体代表一个字符设备cdev_init负责把led_fops关联到这个设备上cdev_add则是把这个设备真正注册到内核的设备管理表中。到了这一步系统里就有了一个可以识别的字符设备只不过还没有设备文件节点。后面再通过class_create和device_create两个函数配合用户态的mdev/udev就能在/dev目录下自动生成led设备节点了。模块卸载函数正好是加载函数的逆过程顺序相反先删除设备节点和设备类然后释放GPIO再删除cdev最后注销设备号。如果你是靠死记硬背来记这个顺序我建议换一个思路你在init里依次占用了哪些资源在exit里就要按照相反的顺序一一归还。这就像出门前要检查门窗先关窗再锁门不能乱套。2.2 file_operations里的open、read、write、release接下来是驱动最重要的部分也就是应用层直接能感知到的接口实现。static int led_open(struct inode *inode, struct file *filp) { printk(KERN_INFO led_drv: open\n); return 0; } static int led_release(struct inode *inode, struct file *filp) { printk(KERN_INFO led_drv: release\n); return 0; } static ssize_t led_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { int val; char kbuf[4]; val gpio_get_value(LED_GPIO); kbuf[0] val ? 1 : 0; kbuf[1] \n; kbuf[2] \0; if (copy_to_user(buf, kbuf, 3)) { return -EFAULT; } return 3; } static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { char kbuf[4]; if (count 1 || count 3) { return -EINVAL; } if (copy_from_user(kbuf, buf, count)) { return -EFAULT; } if (kbuf[0] 1) { gpio_set_value(LED_GPIO, 0); // 低电平点亮 } else if (kbuf[0] 0) { gpio_set_value(LED_GPIO, 1); // 高电平熄灭 } else { return -EINVAL; } return count; } static struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .read led_read, .write led_write, .release led_release, };read和write是这里的核心。注意一个非常关键的点函数参数里用户态的buf是__user指针内核态代码不能直接访问它必须通过copy_to_user和copy_from_user来搬运数据。这是因为内核态和用户态拥有不同的地址空间映射直接解引用用户空间指针轻则得到错误数据重则引起内核崩溃。你可以把内核态和用户态想象成两个隔离的房间相互不能直接看到对方的东西只能通过一个传送窗口传递物品这个传送窗口就是copy_to_user/copy_from_user。再来看write的实际逻辑。应用层如果写入字符1驱动调用gpio_set_value(LED_GPIO, 0)让引脚输出低电平。在我的硬件上低电平点亮LED所以写入1代表开灯。同样写入0就是关灯。如果你用的板子是拉高点亮那逻辑正好反过来。其实从应用层的语义上看应该是写入1表示开启、写入0表示关闭硬件电平映射关系放在驱动里做适配这是一个比较规范的设计思路。read接口的实现相对简单通过gpio_get_value读取当前引脚电平打包成字符返回给应用层。比如LED处于点亮状态时gpio_get_value返回的是0read出来就是字符0。应用层拿到的信息就是当前用户视角下的状态而不是原始的电平值。这里做了一层逻辑转换让应用层读到的内容语义更清晰。2.3 为什么需要设备类和自动创建节点如果你只运行到cdev_add这一步其实驱动已经注册成功了但应用层没办法很方便地访问它。因为设备节点/dev/led不存在你需要自己在命令行手动执行mknod /dev/led c 250 0来创建设备文件而且主设备号必须是alloc_chrdev_region实际分配到的那个数字如果动态分配的结果变了mknod也得跟着改。这在实际使用中非常不方便。class_create和device_create这对组合就是为了解决这个问题。class_create创建了一个设备类device_create在这个类下面创建了一个具体设备。内核会在sysfs中生成对应的属性信息用户态的udev或mdev监听到新的设备事件后自动在/dev目录下创建对应的设备节点。你在嵌入式板子的文件系统里通常能看到一个mdev/udev的配置和启动脚本它就是干这个活的。自动创建设备节点需要文件系统里的热插拔工具正常工作如果板子上没有配置mdev或者udev你发现/dev/led并没有自动出现大概率就是这个问题。处理方式要么补全脚本配置要么老老实实用mknod手动创建。其实很多内核自带的子系统也是通过类似机制创建节点的比如input子系统会在你插上USB鼠标时自动生成/dev/input/event0。理解了这套机制不仅对LED驱动有用对整个嵌入式Linux的用户态设备管理都会有更深的体会。3. 从GPIO编号到设备树Linux 4.x内核下的资源管理前面代码里直接用数字4来表示GPIO1_IO04对应的引脚这是最简单的写法也是很多老教程里的习惯。但在实际项目中这样写往往不是最佳实践。原因有两方面一是硬编码的GPIO编号可读性差不同板子、不同引脚要改动驱动代码重新编译二是现代内核更推荐通过设备树来描述硬件资源让驱动代码与硬件配置解耦。接下来我们聊清楚这个演进过程。3.1 传统整数GPIO接口与gpiod描述符接口老式接口中gpio_request的第一个参数是一个全局的GPIO编号。这个编号如何计算以i.MX6ULL为例GPIO1有32个引脚GPIO1_IO04通常对应编号32044GPIO2_IO10则对应3211042。这里面有一条隐含规则就是同一时刻只能有一个驱动成功请求同一个编号如果另一个驱动已经占用了gpio_request会返回失败。后来内核社区发现这种全局编号方式存在一些问题主要是动态性和可扩展性不足。于是gpiod接口应运而生。gpiod接口不再使用整数编号而是使用gpio_desc指针作为句柄配合设备树中的gpios属性驱动请求引脚时直接通过device_node来查找对应引脚。gpiod接口的基本用法是这样的struct gpio_desc *led_gpio; led_gpio gpiod_get(pdev-dev, led, GPIOD_OUT_LOW); if (IS_ERR(led_gpio)) { return PTR_ERR(led_gpio); } gpiod_set_value(led_gpio, 1); gpiod_put(led_gpio);对比下来gpiod接口在代码层面更简洁但前提是需要设备树配合。设备树里声明led-gpio { compatible led-gpio; pinctrl-names default; pinctrl-0 pinctrl_led; led-gpios gpio1 4 GPIO_ACTIVE_LOW; status okay; };这里的GPIO_ACTIVE_LOW是一个非常有用的标志它表示这个GPIO是低电平有效。如果设备树中标注了低有效gpiod_set_value(led_gpio, 1)会输出低电平驱动代码直接按照逻辑意义来控制灯亮灯灭不需要关心底层实际的电平极性。这个抽象级别明显更高也更安全。3.2 使用pinctrl子系统配置引脚复用在芯片上同一个物理引脚往往有多个复用功能比如某个引脚既可以做GPIO又可以做UART的TXD还可以做I2C的SCL。要让它作为GPIO使用就必须先通过pinctrl子系统把引脚复用功能配置成GPIO模式。在设备树中pinctrl-0引用了pinctrl_led这个节点它的实际内容通常定义在SoC的dtsi文件中具体长这样pinctrl_led: ledgrp { fsl,pins MX6UL_PAD_GPIO1_IO04__GPIO1_IO04 0x80000000 ; };0x80000000是引脚配置的寄存器和属性值不同厂商的格式不太一样但作用都一样设置引脚的复用功能、上下拉、驱动能力等。如果在使用GPIO时发现引脚电平不对或者引脚被复用成别的外设功能了首先应该检查的就是pinctrl配置。如果你还是习惯用整数GPIO接口开发可以用gpio_request_one来代替gpio_request它能顺便设置方向和初始电平。不过无论如何现在新的芯片BSP基本都已经切换到设备树方式描述硬件了建议尽早适应这种模式。3.3 完整驱动代码清单为了让你能直接上手这里给出一份完整的、可以编译运行的传统整数GPIO接口版本驱动。这个版本在4.9.88内核上测试通过。#include linux/module.h #include linux/kernel.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #include linux/gpio.h #define LED_GPIO 4 static int major; static struct class *led_class; static struct device *led_device; static struct cdev led_cdev; static dev_t led_dev_num; 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 ssize_t led_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { int val; char kbuf[4]; val gpio_get_value(LED_GPIO); kbuf[0] val ? 1 : 0; kbuf[1] \n; kbuf[2] \0; if (copy_to_user(buf, kbuf, 3)) { return -EFAULT; } return 3; } static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { char kbuf[4]; if (count 1 || count 3) { return -EINVAL; } if (copy_from_user(kbuf, buf, count)) { return -EFAULT; } if (kbuf[0] 1) { gpio_set_value(LED_GPIO, 0); } else if (kbuf[0] 0) { gpio_set_value(LED_GPIO, 1); } else { return -EINVAL; } return count; } static struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .read led_read, .write led_write, .release led_release, }; static int __init led_drv_init(void) { int ret; ret alloc_chrdev_region(led_dev_num, 0, 1, led); if (ret 0) { return ret; } cdev_init(led_cdev, led_fops); led_cdev.owner THIS_MODULE; ret cdev_add(led_cdev, led_dev_num, 1); if (ret 0) { unregister_chrdev_region(led_dev_num, 1); return ret; } ret gpio_request(LED_GPIO, led-gpio); if (ret 0) { cdev_del(led_cdev); unregister_chrdev_region(led_dev_num, 1); return ret; } gpio_direction_output(LED_GPIO, 1); led_class class_create(THIS_MODULE, led_class); if (IS_ERR(led_class)) { gpio_free(LED_GPIO); cdev_del(led_cdev); unregister_chrdev_region(led_dev_num, 1); return PTR_ERR(led_class); } led_device device_create(led_class, NULL, led_dev_num, NULL, led); if (IS_ERR(led_device)) { class_destroy(led_class); gpio_free(LED_GPIO); cdev_del(led_cdev); unregister_chrdev_region(led_dev_num, 1); return PTR_ERR(led_device); } printk(KERN_INFO led_drv: init success, major%d\n, MAJOR(led_dev_num)); return 0; } static void __exit led_drv_exit(void) { device_destroy(led_class, led_dev_num); class_destroy(led_class); gpio_free(LED_GPIO); cdev_del(led_cdev); unregister_chrdev_region(led_dev_num, 1); printk(KERN_INFO led_drv: exit\n); } module_init(led_drv_init); module_exit(led_drv_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple GPIO LED driver);这大概70多行代码麻雀虽小五脏俱全。我建议你不要直接复制粘贴就完事而是对照着前面讲的内容一行一行敲进去。驱动开发是一个容错率很低的活只有亲自输入过、编译过、报错过、修改过才能真正掌握。4. 编译、加载与应用层测试代码写完了接下来是编译、加载、写应用测试程序。这里面的坑也不少比如内核头文件路径、Makefile写法、模块签名、驱动与应用的数据格式等我都给你排一遍。4.1 Makefile的写法与常见编译错误编译外部内核模块最标准的方式是使用内核的kbuild系统。Makefile看起来很简单obj-m : led_drv.o KERNELDIR : /home/user/linux-imx-rel_imx_4.9.88_2.1.0 CROSS_COMPILE : arm-linux-gnueabihf- CC : $(CROSS_COMPILE)gcc all: $(MAKE) -C $(KERNELDIR) M$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M$(PWD) clean这里的关键在于KERNELDIR必须指定为完整的内核源码树路径而且这个内核源码树必须是编译过的也就是里面有.config文件和编译生成的相关头文件。如果只用了一个裸的内核源码目录编译时通常会报缺少“generated/autoconf.h”之类的错误这就是因为你没有先对内核进行配置编译。最简单的方法是在开发板对应的SDK里直接使用厂商已经配置好的内核源码。如果你编译的是和开发板上运行的内核版本完全一致的源码编译出来的.ko文件直接可以加载。如果内核版本对不上insmod时会出现版本信息不匹配的提示报错里会列出vermagic信息这就需要重新编译内核或者使用modprobe配合depmod来处理。当然我们在开发板上通常使用同版本内核新编译好的模块拷贝过去insmod就能用。一个比较容易忽略的问题Makefile里的obj-m命名必须和源码文件名一致。比如你的源文件叫led_gpio.c那开头就得写成obj-m : led_gpio.o。如果源文件更名为my_led_drv.c那这行也要同步修改。刚开始学驱动开发的人在这个小问题上卡很久的并不少见。编译完成后当前目录会生成led_drv.ko。通过NFS挂载或者scp拷贝到开发板上先确认权限然后执行insmod led_drv.ko ls /dev/led dmesg | tail如果一切正常你会在/dev下看到led设备节点dmesg里也能看到init成功的日志。这时候驱动已经在工作了只是应用层还没去操作它。4.2 应用层测试程序的写法驱动是给应用层用的测试程序自然也是应用层的活。写一个简单的点灯测试程序名字叫led_test.c#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h int main(int argc, char *argv[]) { int fd; char buf[4]; if (argc 2) { printf(Usage: %s [on|off|status]\n, argv[0]); return -1; } fd open(/dev/led, O_RDWR); if (fd 0) { perror(open /dev/led); return -1; } if (strcmp(argv[1], on) 0) { write(fd, 1, 1); printf(LED ON\n); } else if (strcmp(argv[1], off) 0) { write(fd, 0, 1); printf(LED OFF\n); } else if (strcmp(argv[1], status) 0) { memset(buf, 0, sizeof(buf)); read(fd, buf, sizeof(buf)); printf(LED status: %s, buf); } else { printf(Unknown command\n); } close(fd); return 0; }编译这个程序要用交叉编译器命令是arm-linux-gnueabihf-gcc led_test.c -o led_test。拷贝到开发板上后运行./led_test on如果一切正常LED灯亮运行./led_test offLED灯灭运行./led_test status能看到当前状态。整个测试过程看着简单其实已经把驱动开发的完整链路都跑通了应用层open驱动、write数据、驱动调用gpio_set_value、硬件电平翻转、LED点亮。如果这个流程走通了说明你已经掌握了Linux设备驱动开发的基础链路。4.3 static编译进内核的方式动态加载.ko模块适合开发调试阶段使用但产品发布时往往会直接把驱动编译进内核减少启动时的模块加载环节也避免根文件系统里放一堆.ko文件。要把驱动编译进内核只需要修改两处。第一处把led_drv.c放到内核源码目录下的drivers/char/或者你自己的驱动目录里比如drivers/misc/。第二处在对应目录的Kconfig文件中添加一项配置并在Makefile里添加编译目标。实际上很多驱动工程师的日常工作之一就是把各种外设驱动加到内核里做裁剪和配置系统裁剪优化说的就是这个过程。静态编译的优点是启动时驱动自动探测、设备节点自动创建用户体验好缺点是每次改驱动都要重新编译内核并烧录迭代效率低。所以常规做法是开发阶段用模块动态加载功能稳定后改造成静态编译进入最终镜像。这个思维对以后做产品非常有价值前期调试要快后期切换要稳。5. 常见问题排查与经验总结驱动开发最让人头疼的就是出了问题不知道怎么查。这一节我把实践中最常遇到的问题整理出来每个问题都附上典型的日志特征和排查思路希望能帮你少走弯路。5.1 模块加载失败与GPIO被占用问题insmod时报错最常见的原因之一是GPIO已经被其他驱动或内核代码占用。日志里通常会看到“gpio_request: gpio-4 (led-gpio) status -16”之类的提示。出现这个情况先查一下设备树中是否已经有其他节点引用了同一个GPIO比如某些板子的SD卡检测、按键等外设可能默认占用了这个引脚。你可以先在设备树中屏蔽相关节点再尝试加载。另外如果你在驱动里使用了pinctrl而pinctrl配置不正确也会导致GPIO复用失败引脚始终是其他外设的功能。这时候连gpio_request都可能成功但gpio_set_value后电平没有反应。要仔细检查设备树中pinctrl节点的配置确保护展的引脚确实被复用成了GPIO模式。还有一种情况是insmod成功但/dev/led没有生成。上面提到过大概率是mdev或udev机制没有工作。临时解决方法是先用dmesg查看主设备号然后手动mknod。长期解决方法是确保根文件系统里有mdev或者udev的配置并且在启动脚本中启动了相关服务进程。很多开发板的/etc/init.d/rcS脚本里会有“echo /sbin/mdev /proc/sys/kernel/hotplug”这一行它的作用就是让内核在创建设备时通知mdev自动生成节点。5.2 write数据后LED不动作的排查最让人沮丧的是系统日志一切正常write也返回了正确字节数LED就是不亮。这种时候先说一句先确认你的LED硬件电路接的是哪个GPIO是不是驱动里写的是4实际板子上在GPIO5_IO03千万不要想当然很多开发板的LED并不在GPIO1上建议先看原理图再动手。除了硬件接错还有一种常见问题GPIO的极性搞反了。你以为拉低点亮实际上这个板子拉高才亮。测试时不妨把两个逻辑都写进去跑一下确认极性再固化代码。还有可能是GPIO子系统配置了其他外设功能。比如有些芯片的引脚同时支持PWM和GPIO设备树里已经配置成PWM模式这时你即使拿到了GPIO句柄实际引脚的电气特性可能还是PWM功能。排查思路是去掉或修改设备树里相关的pwm节点重启再试。如果你用的内核开启了GPIO sysfs接口也就是/sys/class/gpio的export功能可以先通过echo导出这个引脚手动操作一下direction和value看灯是否有反应。这个操作可以不经过驱动直接测试底层GPIO是否正常。如果sysfs操作灯能亮说明硬件没问题问题在驱动代码如果sysfs操作灯也不亮那就是硬件连接、设备树或引脚复用的问题。这个方法能快速缩小排查范围。5.3 从GPIO LED驱动到复杂设备驱动的进阶思路写完这个GPIO LED驱动你的下一步是什么我的建议是按这样的顺序展开。先给驱动加一个定时器实现LED每秒闪烁一次的效果这会让你接触内核定时器、内核线程、以及并发控制的概念。然后尝试使用wait_queue把头函数实现“应用层read时阻塞等待GPIO中断到来时才返回”的机制这是大多数按键输入驱动的基本模型。再往后可以试试把同一套驱动代码从GPIO LED改造成一个简单的蜂鸣器驱动复用file_operations的骨架只是把gpio_set_value换成pwm_config接口你会理解驱动架构的魅力。如果你接下来想学I2C设备的驱动比如EEPROM、温湿度传感器这时你会发现I2C子系统、设备树probe机制、regmap接口会成为重点。但底层的安装、注册、应用层访问逻辑依然是你已经学会的那一套。想搞懂platform总线的话也可以把当前的驱动改造成platform_driver的形式把GPIO编号都放到设备树的platform_device里通过probe回调获取资源彻底告别硬编码。驱动开发的进阶路径就是一层层拆掉“魔法”逐渐看清内核各子系统之间的协作关系。GPIO LED是那个让你第一次尝到甜头的小项目但它给你的技能树打下的地基几乎能支撑后面所有复杂驱动的学习。5.4 关于学习路径的一些个人体会我见过不少工程师一上来就抱着《Linux设备驱动开发详解》啃前几章还能坚持到内核内存管理、并发控制就开始打瞌睡最后放弃了。其实驱动开发真正的敲门砖就是一个小而完整的项目目标明确反馈直接。GPIO LED驱动最大的价值不是“点灯”这个结果而是让你在最短时间内经历一次完整的驱动开发流程建立全局观。在这个过程里你会碰到很多新概念内核模块、设备号、cdev、file_operations、copy_to_user、GPIO子系统、设备树、mdev、交叉编译。每个概念单独看都很抽象但当你把它们串在同一个小项目里时知识就会连点成线很多疑问会豁然开朗。学驱动没有捷径但一定有高效路径就是从一个真实可运行的小项目开始不懂就查错了就debug亲手把它跑通。最后再分享一个小习惯每次完成一个驱动我都会顺手写一个简单的README记录硬件连接、编译命令、加载步骤、测试方法和踩过的坑。几个月后再回来看这些笔记的价值远超想象。很多杂乱的调试细节过了时间就忘了但一旦再遇到类似问题翻翻笔记就能快速定位。这个习惯我从学驱动一直保持到现在也算是我最想推荐给你的实践心得。
返回列表