ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

嵌入式Linux驱动开发入门:从LED驱动掌握字符设备与设备树

嵌入式Linux驱动开发入门:从LED驱动掌握字符设备与设备树 刚开始接触嵌入式Linux驱动开发的人多半都会从点亮一颗LED灯开始。这个看似简单的需求其实藏着一整套完整的驱动开发链路从字符设备框架、设备树配置到GPIO子系统、应用程序测试再到内核编译与模块加载每一步都是后续做任何复杂驱动比如I2C、SPI、USB、网络设备的地基。我也是从这颗LED灯走过来的。回头再看它之所以能成为驱动开发的“Hello World”恰恰是因为它足够简单又能把Linux设备模型里最核心的概念都串起来。这篇文章就借着GPIO LED灯驱动把Linux字符设备驱动的完整开发流程掰开揉碎讲一遍包括框架怎么搭、设备树怎么写、代码怎么组织、遇到问题怎么排查。不管你是刚入门的学生还是工作中刚转到驱动方向的工程师照着这套思路走一遍基本就能理解驱动开发的全貌了。1. 环境准备与整体设计思路1.1 开发环境怎么搭先说环境。驱动开发不像写应用层代码随便一个IDE跑起来就行它必须依托一套完整的内核编译环境。我这边常用的组合是开发板用i.MX6ULLNXP的Cortex-A7芯片配套的内核源码是4.1.15版本交叉编译工具链用arm-linux-gnueabihf-gcc。这套组合在市面上很常见资料多踩坑也相对少。如果你用的是其他平台比如树莓派、全志、瑞芯微的方案思路完全一致只是设备树节点和GPIO编号映射的细节会有差异。环境准备阶段有几个关键点要特别注意内核源码版本必须与开发板上运行的内核版本一致。这一点是最容易踩坑的地方很多人编译完模块加载时报“version magic”错误就是因为源码版本和运行内核对不上。我习惯在开发板上执行uname -r确认版本然后在源码目录用make menuconfig做了配置之后先编译出一个能用的内核并烧录进去确保源码和运行环境严格匹配。交叉编译工具链的路径要加进PATH环境变量。这个看似小事但经常有人在编译脚本里写绝对路径换台电脑就得改一堆东西。我通常会在~/.bashrc里加一行export省得每次编译都要找工具链。内核源码需要先完成配置并编译通过。这一步是为了生成Module.symvers和头文件相关的中间产物。如果你只是编译设备树dts而不编译内核可以直接编译dtb但要编译内核模块.ko就必须先把内核编译一遍。1.2 为什么用字符设备框架说到驱动Linux驱动大体分三类字符设备、块设备、网络设备。LED灯是最典型的字符设备——按字节流读写顺序访问没有复杂的缓冲区管理需求。字符设备驱动的核心是file_operations结构体它就是驱动和应用程序之间的“接口契约”。你在应用层调用的open、read、write、ioctl、close等函数最终都会通过虚拟文件系统VFS跳转到这个结构体里对应的函数指针上。打个比方VFS就像一家酒店的前台你应用程序说“我要找304房间的客人”前台VFS就帮你把电话转到304房间驱动里对应的函数去。选字符设备框架而不是其他框架还有个现实原因大多数外设驱动GPIO、PWM、RTC、ADC等本质上都是字符设备学会了这个框架后面的路就顺了。虽然现在内核里还有miscdevice这种更轻量的封装也有gpiolib提供的gpio-leds这种现成驱动但从学习角度讲手写一个完整的字符设备驱动才能把底层机制吃透。1.3 怎么规划驱动与设备树的分工这里必须澄清一个新手最容易搞混的概念驱动和设备树不是一回事它们是配合关系。设备树Device Tree描述的是“硬件长什么样”板子上有哪些GPIO这些GPIO是输入还是输出默认电平是高还是低中断接在哪个引脚上驱动描述的是“怎么操作这些硬件”如何向寄存器写入值如何把内核中的数据传给用户空间如何处理中断如何管理并发访问这种分工设计的最大好处是同一份驱动代码可以适配不同板子。比如你的驱动可以拿到任意一个GPIO引脚并用它控制LED至于具体是GPIO1_IO03还是GPIO5_IO01那是设备树里配的驱动代码不用改。举个例子我在设备树里定义一个LED节点指定它用哪个GPIO、初始状态是灭然后驱动通过of_get_named_gpio函数去读取这个节点的属性拿到GPIO编号后再申请和操作这个GPIO。这样一来硬件变了只需要改设备树驱动不需要重新编译。2. 核心知识点拆解字符设备框架与GPIO操作2.1 字符设备驱动的骨架一个完整的字符设备驱动骨架上有几个必选项设备号。Linux用设备号来标识设备主设备号表示设备类型次设备号表示同类型中的不同个体。可以用register_chrdev静态注册直接指定主设备号或者alloc_chrdev_region动态分配由内核帮你选一个空闲的主设备号来注册。现代驱动推荐用后者因为静态指定设备号很容易冲突——你可能不知道别人已经用了哪个号。file_operations结构体。这是驱动的门面里面放一组函数指针static struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .release led_release, .unlocked_ioctl led_ioctl, };不同的驱动会有不同的侧重。像LED这种简单设备甚至可以不实现read和write只用ioctl来控制开关状态就够了。但为了演示完整流程我通常会把open和release也实现出来让读者看清楚“打开设备时做了什么”“关闭设备时做了什么”。class与device。Linux 2.6之后的内核引入了设备模型驱动里除了注册字符设备还要创建class和device。这样做的目的是当模块加载时内核自动在/sys/class/xxx/下生成对应的设备节点并且在/dev/下自动创建设备文件。很多入门教程还在用mknod手动创建设备文件这在实际项目中基本行不通——你不可能让用户每次插拔设备都手动执行mknod。使用class_createdevice_create配合udev或mdev嵌入式环境常用mdev设备节点就能自动出现。模块加载与卸载函数。这是驱动的入口和出口module_init(led_init); module_exit(led_exit);加载模块时内核调用led_init卸载时调用led_exit。在这两个函数里除了注册/注销字符设备还要做GPIO的申请和释放、类的创建和销毁等配套工作。2.2 GPIO操作机制从寄存器到gpiolib老式驱动里直接操作GPIO寄存器比如往GPIO_DR数据寄存器写1或0往GPIO_GDIR方向寄存器配置输入输出。这种做法的缺点是不同芯片的寄存器地址和位定义千差万别代码可移植性极差。现代内核推荐使用gpiolib提供的接口它把硬件的差异封装在底层驱动开发人员只需要用几个标准API就能操作任意平台的GPIOgpio_request(unsigned gpio, const char *label); // 申请GPIO gpio_direction_output(unsigned gpio, int value); // 设置为输出并指定初始电平 gpio_set_value(unsigned gpio, int value); // 设置GPIO电平有些人会觉得gpio_request这一步多余——我直接用gpio_set_value不就行了不行的。gpio_request就像你去租房前先签合同向内核声明“这个GPIO我用了”。如果不申请就直接操作别的驱动如果也用了同一个GPIO互相踩踏问题极难排查。如果用了设备树还有一套带上device_node的APIgpio_request_one(desc-gpio, GPIOF_OUT_INIT_LOW, led); devm_gpio_request_one(dev, desc-gpio, GPIOF_OUT_INIT_LOW, led);带devm_前缀的是“managed”版本好处是驱动卸载或出错时内核会自动帮你释放GPIO省得自己在exit函数里一个个释放。我强烈建议新手直接用devm_开头的版本少踩“资源泄漏”的坑。2.3 GPIO的8种工作模式说到这里顺便把GPIO的工作模式捋一遍。很多人一开始对“GPIO模式”这个概念很模糊其实它就是GPIO引脚内部的电气配置决定了这个引脚怎么工作输入浮空引脚既不上拉也不下拉电平完全由外部电路决定。适合读取外部信号但如果外部悬空读到的电平就是不确定的会乱跳。输入上拉内部接了一个上拉电阻外部不驱动时默认高电平。适合接按键这种“按下接地”的场景。输入下拉默认低电平适合接“上拉到高”的通路。模拟输入引脚不经过数字逻辑直接连到ADC等模拟外设。GPIO模式里最特殊的一种通常只有ADC专用引脚才支持。开漏输出引脚只能拉低不能主动拉高要输出高电平必须外部接上拉电阻。这是I2C总线最喜欢的方式因为可以“线与”。推挽输出既能拉高也能拉低驱动能力强LED灯控制一般都用这个模式。推挽复用引脚交给其他外设比如UART、SPI、PWM来控制不再由GPIO寄存器控制。开漏复用同理但形式是开漏一般用于I2C引脚等场景。在Linux的gpiolib框架下你不需要太关心里面的电气细节因为内核已经帮你把“推挽输出”“开漏输出”等模式封装成了gpio_direction_output这样简洁的API。只有当你直接操作寄存器或者配置pinctrl时才需要关心具体是哪种模式。顺带提一下pinctrl子系统。现代内核3.x以后普遍引入了pinctrl它负责管理引脚的复用和电气属性。设备树里的pinctrl-0属性就是用来指定某个设备使用的引脚配置。就LED驱动而言如果你用的是厂家BSP包里定义好的pinctrl_led节点就不用自己在驱动里操作引脚复用寄存器了。3. 实操过程从零到一实现LED驱动3.1 设备树配置设备树是驱动开发的第一步。假设我们的LED接在GPIO1_IO03上i.MX6ULL这颗芯片的引脚命名方式设备树里需要做两件事定义引脚复用配置和定义LED设备节点。先看引脚复用。在i.MX6ULL的BSP设备树里通常在iomuxc节点下定义自定义引脚组iomuxc { pinctrl_led: ledgrp { fsl,pins MX6UL_PAD_GPIO1_IO03__GPIO1_IO03 0x10b0 ; }; };这个宏MX6UL_PAD_GPIO1_IO03__GPIO1_IO03的意思是把GPIO1_IO03这个引脚复用为GPIO功能而不是UART或者其他外设功能。后面的0x10b0是引脚配置寄存器值设置了上下拉、驱动能力等电气参数0x10b0表示“上拉、中等驱动能力、SION关闭”适用于LED输出场景。接着是LED节点。约定俗成的写法是在根节点/下面/ { led { compatible myled; pinctrl-names default; pinctrl-0 pinctrl_led; led-gpio gpio1 3 GPIO_ACTIVE_LOW; status okay; }; };这里有个细节要解释一下gpio1 3 GPIO_ACTIVE_LOW表示引脚的GPIO控制器是gpio1引脚偏移是3对应IO03低电平有效GPIO_ACTIVE_LOW。如果你的LED是低电平点亮很多开发板都是这样LED一端接电源另一端接GPIO就必须标GPIO_ACTIVE_LOW然后在驱动里就不要关心电平极性问题了用gpio_set_value传1开灯、传0关灯即可内核会帮你做逻辑反转。如果你不做设备树也可以用gpio_request直接写死GPIO编号。但这样做的问题显而易见换一块板子GPIO编号变了就得改代码重新编译。用设备树之后硬件变更只需要改dts文件驱动一行不改。3.2 驱动代码实现与逐段解析下面是一份简化的完整驱动代码结合了字符设备框架和gpiolib操作。我们一边看代码一边讲解关键点。#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/of.h #include linux/of_gpio.h #include linux/platform_device.h #include linux/errno.h #define LED_ON 0x01 #define LED_OFF 0x00 #define LED_IOC_MAGIC L #define LED_IOC_ON _IO(LED_IOC_MAGIC, 0x01) #define LED_IOC_OFF _IO(LED_IOC_MAGIC, 0x02) static int led_gpio; static struct class *led_class; static struct device *led_device; static dev_t led_devno; static struct cdev led_cdev; 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 long led_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { switch (cmd) { case LED_IOC_ON: gpio_set_value(led_gpio, 1); break; case LED_IOC_OFF: gpio_set_value(led_gpio, 0); break; default: return -EINVAL; } return 0; } static const struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .release led_release, .unlocked_ioctl led_ioctl, }; static int led_probe(struct platform_device *pdev) { struct device_node *np pdev-dev.of_node; enum of_gpio_flags flags; int ret; led_gpio of_get_named_gpio_flags(np, led-gpio, 0, flags); if (!gpio_is_valid(led_gpio)) { dev_err(pdev-dev, invalid gpio\n); return -EINVAL; } ret devm_gpio_request_one(pdev-dev, led_gpio, GPIOF_OUT_INIT_LOW, led); if (ret 0) { dev_err(pdev-dev, gpio request failed\n); return ret; } ret alloc_chrdev_region(led_devno, 0, 1, myled); if (ret 0) { dev_err(pdev-dev, alloc chrdev region failed\n); return ret; } cdev_init(led_cdev, led_fops); led_cdev.owner THIS_MODULE; ret cdev_add(led_cdev, led_devno, 1); if (ret 0) { dev_err(pdev-dev, cdev add failed\n); unregister_chrdev_region(led_devno, 1); return ret; } led_class class_create(THIS_MODULE, myled); if (IS_ERR(led_class)) { dev_err(pdev-dev, class create failed\n); cdev_del(led_cdev); unregister_chrdev_region(led_devno, 1); return PTR_ERR(led_class); } led_device device_create(led_class, pdev-dev, led_devno, NULL, myled%d, 0); if (IS_ERR(led_device)) { dev_err(pdev-dev, device create failed\n); class_destroy(led_class); cdev_del(led_cdev); unregister_chrdev_region(led_devno, 1); return PTR_ERR(led_device); } device_set_drvdata(pdev-dev, NULL); dev_info(pdev-dev, LED driver probed\n); return 0; } static int led_remove(struct platform_device *pdev) { device_destroy(led_class, led_devno); class_destroy(led_class); cdev_del(led_cdev); unregister_chrdev_region(led_devno, 1); dev_info(pdev-dev, LED driver removed\n); return 0; } static const struct of_device_id led_of_match[] { { .compatible myled }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, led_of_match); static struct platform_driver led_driver { .probe led_probe, .remove led_remove, .driver { .name myled, .of_match_table led_of_match, }, }; module_platform_driver(led_driver); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple GPIO LED driver);这段代码的信息量比较大逐段说几个关键点。第一这个驱动用的是platform_driver模型而不是直接在module_init里做所有事情。为什么因为我们要配合设备树做设备与驱动的匹配。当内核启动时它会把设备树里的每个节点和设备驱动进行匹配匹配的依据就是compatible属性。of_match_table里写了myled设备树里LED节点也写了compatible myled两者一碰内核就会调用led_probe函数。第二of_get_named_gpio_flags用于从设备树节点中读取led-gpio属性把它解析成GPIO编号。这个函数会在设备树里寻找名字叫led-gpio的属性并提取其中的GPIO控制器指针、引脚偏移和有效电平标志。拿到编号之后还要调用gpio_is_valid检查这个编号是否有效——如果设备树写错了比如引用了不存在的GPIO控制器这里就能发现。第三alloc_chrdev_region动态分配了设备号。设备号分配之后用cdev_init和cdev_add把字符设备和file_operations关联起来。这样当应用层open(/dev/myled0)时VFS才能找到对应的led_ioctl函数。第四class_create和device_create的配合让/dev/myled0这个设备节点可以自动创建。在嵌入式环境里配合mdev或者udev模块加载后设备文件会自动出现在/dev下。这里要留意一点class_create在较新的内核里接收两个参数owner name旧版是三个owner name 可选的class_attrs如果你的内核版本较新还传三个参数编译会报错。第五led_remove里的资源释放顺序和led_probe中的申请顺序恰恰相反。这是内核开发的一个基本素养——先申请的资源后释放后申请的先释放避免出现资源使用错乱。3.3 应用层测试程序驱动写得再好没人调用也是白搭。下面写一个简单的应用层测试程序验证驱动能否正常工作#include stdio.h #include stdlib.h #include fcntl.h #include sys/ioctl.h #include unistd.h #define LED_IOC_MAGIC L #define LED_IOC_ON _IO(LED_IOC_MAGIC, 0x01) #define LED_IOC_OFF _IO(LED_IOC_MAGIC, 0x02) int main(int argc, char *argv[]) { int fd; int i; fd open(/dev/myled0, O_RDWR); if (fd 0) { perror(open); return -1; } for (i 0; i 5; i) { ioctl(fd, LED_IOC_ON); printf(LED ON\n); sleep(1); ioctl(fd, LED_IOC_OFF); printf(LED OFF\n); sleep(1); } close(fd); return 0; }编译测试程序用交叉编译工具链就行不需要内核源码arm-linux-gnueabihf-gcc -o ledtest ledtest.c把编译好的ledtest拷贝到开发板上先加载驱动模块再运行测试程序insmod myled.ko ./ledtest正常情况下你应该看到LED以1秒为间隔闪烁5次。3.4 编译与部署的完整流程设备树编译这一步也值得单独拎出来说。设备树源文件.dts需要通过设备树编译器dtc编译成二进制.dtb。如果是在内核源码目录下可以直接使用make命令make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- dtbs编译后的dtb文件放在arch/arm/boot/dts/目录下。你需要把这个dtb拷贝到开发板的启动分区通常是存放内核镜像的FAT分区或单独的boot分区替换原来的dtb文件。这个过程建议在开发板上先备份好旧dtb万一配置有问题还能恢复。驱动模块的编译通常是放到内核源码目录下创建一个子目录比如drivers/led/然后写一个简单的Makefileobj-m myled.o然后在源码根目录执行make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- Mdrivers/led modulesM参数指定了要编译的模块目录编译完成后会生成myled.ko文件。如果你只是在某个临时目录里放源码自己编译也可以写成这样obj-m : myled.o KDIR : /path/to/kernel/source all: make -C $(KDIR) M$(PWD) modules clean: make -C $(KDIR) M$(PWD) clean然后把myled.ko传到开发板上执行insmod myled.ko加载模块再用lsmod查看是否加载成功用dmesg查看内核打印的日志。如果一切正常你会在dmesg里看到LED driver probed。如果在/dev下看不到myled0多半是mdev或udev没有正确触发可以检查一下开发板上的/etc/mdev.conf或/etc/udev/rules.d/配置。4. 驱动开发的进阶问题与排查技巧4.1 设备号冲突与申请失败的处理写驱动最怕的就是设备号冲突。虽然我们用了alloc_chrdev_region动态分配理论上不会和其他设备冲突但也有例外如果你在别的驱动里已经手动指定了同一个主设备号动态分配时内核会避开已经被占用的号码所以这个问题不大。真正需要担心的是cdev_add失败。常见原因是设备号已经被同一个主设备号下的其他次设备号占用或者file_operations里的.owner没有设置为THIS_MODULE。后者看似小问题但会导致模块卸载时内核还在引用它出现“module is in use”或者内核崩溃。排查优先级建议这样走先看dmesg打印再看/proc/devices里有没有你注册的设备号最后检查/sys/class/下有没有对应的class目录。一条条排除问题总能定位到。4.2 GPIO申请失败与引脚复用冲突GPIO申请失败在实际开发中出现的频率非常高。最常见的场景就是同一个引脚被两个驱动同时使用。比如你在设备树里配了LED节点使用GPIO1_IO03但BSP里另一个设备比如UART或者按键也把这个引脚占用了。此时devm_gpio_request_one会返回-EBUSY。解决思路有两个检查设备树里是否还有别的节点引用了同一个gpio改掉其中一个。检查引脚是否被pinctrl子系统占用看/sys/kernel/debug/pinctrl/目录下的寄存器状态和已占用引脚列表。调试时我习惯在/sys/kernel/debug/gpio需要root权限下查看当前所有GPIO的状态。这个文件会列出每个GPIO的申请者、方向和当前电平信息非常有用。如果你的内核没有打开CONFIG_DEBUG_GPIO就看不到这个文件需要重新配置内核。4.3 设备节点没有自动创建/dev下找不到设备节点这个问题十有八九出现在嵌入式环境里。原因基本就两种一是根文件系统里没有mdev或udev或者没有配置好触发规则二是驱动里的class_create和device_create没有执行成功这时dmesg会打印错误。嵌入式环境里通常用mdev需要在系统初始化脚本里启动它比如在/etc/init.d/rcS里加上echo /sbin/mdev /proc/sys/kernel/hotplug mdev -smdev -s的作用是扫描已经存在的设备节点并自动创建而写入/proc/sys/kernel/hotplug则是在新设备加入时自动调用mdev。如果你用的根文件系统是Buildroot或者Yocto做的这些通常已经配置好了不需要手动处理。如果是自己手工制作的根文件系统比如直接从Ubuntu拷贝过来的就很容易漏掉这一步。4.4 高电平有效与低电平有效的迷惑LED点不亮很多人第一反应是“GPIO没配好”其实更常见的原因是有效电平搞反了。GPIO_ACTIVE_LOW和GPIO_ACTIVE_HIGH的区别需要从电路层面看如果LED一端接GPIO另一端接GND那么GPIO输出高电平LED亮这是高电平有效。如果LED一端接电源VCC另一端接GPIO那么GPIO输出低电平时电流从VCC流经LED再到GPIOLED亮这是低电平有效。很多开发板上为了方便LED都是接在电源和GPIO之间所以本质上是低电平有效。如果你在设备树里标了GPIO_ACTIVE_HIGH但电路实际是低电平有效那么用gpio_set_value(led_gpio, 1)开灯时GPIO输出高电平LED反而熄灭。在我用的i.MX6ULL板子上LED灯是低电平有效的。我在设备树里明确写了GPIO_ACTIVE_LOW这样驱动里就可以放心用“1表示开、0表示关”的逻辑不需要关心电路细节。4.5 insmod时提示版本不匹配模块加载时报类似“version magic 4.1.15 should be 4.1.15-g12ab34c”的错误说明内核源码和运行内核版本不完全一致。这个“-g12ab34c”是内核的Git提交哈希后缀只要源码版本和运行内核不同就会报这个错。解决办法是在编译内核时把CONFIG_LOCALVERSION配置项和运行内核的uname -r保持一致。比如运行内核是4.1.15-g12ab34c那你在make menuconfig里的General setup - Local version - append to kernel release一项里填-g12ab34c重新编译内核并烧录后模块就能正常加载了。还有一种省事的做法编译模块时在Makefile里加一行ccflags-y -Wno-error这只能屏蔽一些警告无法解决版本魔数问题。最靠谱的办法还是版本严格一致。我个人的习惯是驱动开发前先花半小时把内核完整编译一遍并烧录确认能启动后面即使出问题也能排除“内核版本不对”这个因素。4.6 并发访问问题如果你的应用层有多个进程同时操作同一个LED设备ioctl操作可能并发调用虽然GPIO本身简单但这是驱动开发者必须具备的意识。最简单的防护是加互斥锁或者信号量static DEFINE_MUTEX(led_lock); static long led_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { mutex_lock(led_lock); switch (cmd) { case LED_IOC_ON: gpio_set_value(led_gpio, 1); break; case LED_IOC_OFF: gpio_set_value(led_gpio, 0); break; default: mutex_unlock(led_lock); return -EINVAL; } mutex_unlock(led_lock); return 0; }更优雅的做法是在open时用atomic_dec_and_test控制设备只能被一个进程打开。这里不展开讲但遇到多进程并发问题时至少要知道方向。5. 从LED驱动到真实项目框架复用与能力迁移5.1 字符设备框架如何迁移到其他驱动LED驱动虽然简单但它的骨架可以直接迁移到很多其他设备上。我做过的几个典型驱动基本都是在LED驱动基础上扩展出来的按键驱动把gpio_direction_output换成gpio_direction_input再加上中断机制就是完整的按键驱动。硬件上按键按下时电平变化触发中断中断处理程序里上报事件。PWM背光驱动把gpio_set_value换成pwm_config控制占空比就是背光亮度调节。I2C设备驱动在probe函数里通过i2c_get_clientdata获取I2C客户端用i2c_smbus_read_byte_data/i2c_smbus_write_byte_data操作寄存器。字符设备框架部分几乎可以原封不动搬过来。这就是为什么很多人说“第一个驱动学会LED后面就是换外设接口的事了”。你学到的核心能力是Linux设备模型的处理逻辑设备树解析、probe流程、资源申请与释放、用户空间接口封装。这些在所有驱动里都是一样的。5.2 支持的调试手段与内核配置驱动开发过程中调试手段很关键我常用的有这几招第一招printk打印。虽然内核社区不推荐直接用printk调试但对于初学者来说它是最直接的。注意不同日志级别KERN_ERR用于错误、KERN_INFO用于信息、KERN_DEBUG用于调试。dmesg默认只显示一定级别以上的日志如果看不到KERN_DEBUG需要调整/proc/sys/kernel/printk的日志级别。第二招dev_dbg与动态调试。dev_dbg在编译时如果定义了DEBUG宏就会有输出配合dynamic_debug机制可以在运行时通过echo file myled.c p /sys/kernel/debug/dynamic_debug/control动态开启调试信息不用改代码重编译。第三招/sys/kernel/debug。这棵调试树是内核开发者的宝库。GPIO看/sys/kernel/debug/gpiopinctrl看/sys/kernel/debug/pinctrl中断看/sys/kernel/debug/irq/。前提是内核打开了对应的CONFIG_DEBUG_FS和CONFIG_DEBUG_GPIO选项。第四招设备树验证。如果怀疑设备树解析有问题可以在内核启动时加上of_dev调试参数或者直接编译dtb后在host上用dtc -I dtb -O dts反编译检查里面的内容是否和分析的一致。这个反编译操作能验证你的dtb内容避免在板子上浪费大量时间。5.3 使用内核现成驱动LED子系统其实现在内核里已经有一个非常成熟的LED子系统也就是leds-gpio驱动以及配套的/sys/class/leds/接口。如果你只是想在板子上加个呼吸灯、定时闪烁之类的功能完全不需要自己写驱动只需要在设备树里加一个节点/ { leds { compatible gpio-leds; status okay; led0 { label user-led0; gpios gpio1 3 GPIO_ACTIVE_LOW; default-state off; linux,default-trigger heartbeat; /* 可选 */ }; }; };然后应用层直接往/sys/class/leds/user-led0/brightness里写1或0就能控制LED。这不是更省事吗那为什么还要手写驱动因为学习目标不同。用现成的gpio-leds你只能学会写设备树对字符设备框架、file_operations、ioctl这些核心机制完全没有感知。而一旦遇到非标准设备比如需要在上电瞬间做特殊初始化、需要和某个业务逻辑联动的设备现成驱动就不够用了。到那时手写驱动的功底就是你的底牌。我的建议是两道都走一遍。先自己手写驱动把机制搞明白再回头看内核提供的现成实现学学更好的设计比如trigger机制、亮度渐变机制两相对照功力提升会非常快。5.4 国产化替代环境下的Linux驱动开发最近几年国产芯片和国产操作系统在嵌入式领域的应用越来越广很多工程师会接触瑞芯微、全志、地平线、飞腾这些国产平台的Linux系统适配。在驱动开发层面它们本质上还是Linux内核那一套——字符设备框架不变、设备树机制不变、gpiolib的API也不变。你在这颗芯片上写好的驱动框架拿到另一家芯片上改动范围通常只限于设备树里的引脚定义和pinctrl配置驱动的核心逻辑完全可以复用。这也是为什么我特别建议新手把Linux设备驱动的基本功打扎实而不是死记某一个厂家的BSP文档。框架通了换芯片就是换个硬件描述文件的事。6. 写在最后的建议以我的经验学习设备驱动开发最容易犯的三个错误是跳过了环境准备直接看代码、只读不做实验、做实验时一遇到报错就上网搜而不是先分析日志。环境准备这关真的不能省。花一两天时间把交叉编译工具链、内核源码、根文件系统、启动方式全部跑通后面所有驱动实验都会顺很多。如果环境没配好你写再好的驱动也验证不了学起来会非常挫败。动手实验时不用追求一开始就写完整框架。可以分三步走第一步先写一个只有module_init和module_exit的空驱动加载、卸载都正常确认开发环境是通的。第二步加入字符设备框架设备号注册、cdev_add、class_create应用层可以open到设备节点。第三步再接入GPIO操作和设备树解析完成LED点灯功能。每一步都验证通过再进入下一步这样即使出问题也能立刻定位是环境问题、框架问题还是硬件操作问题。最后再分享一个小技巧在/etc/modprobe.d/下创建一个配置文件里面放一行options myled gpio0这样的参数然后用module_param接收参数。这样你可以在不重新编译驱动的情况下通过模块参数指定GPIO编号对调试特别有帮助。我在调硬件时经常这么干比改代码快得多。
返回列表