ARTICLE DETAIL

资讯详情

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

Linux设备驱动开发实战:从内核模块到设备树、I2C/CAN全解析

Linux设备驱动开发实战:从内核模块到设备树、I2C/CAN全解析 Linux设备驱动开发从内核模块到设备树、I2C/CAN的系统路径干嵌入式Linux这行绕不开的一个方向就是设备驱动开发。我见过不少新人,手里拿着一块开发板照着教程把内核编译一遍、烧进SD卡系统能起来了但一碰到我要把这个传感器/屏幕/电机接上去就懵了。也有做了两三年应用层开发的兄弟想往底层转却不知道从哪里切入。这篇文章我打算用一条完整的实践路径来讲先搞懂内核模块怎么写再把设备树这套硬件描述机制吃透最后落到I2C和CAN这两个具体子系统上把一个设备从零到一跑起来。这三块串起来基本就是嵌入式驱动开发最核心的主线了。适合刚入门的学生、做应用层想转驱动的工程师以及被厂商SDK坑过、想搞明白设备树为什么这么配的老哥们。先说清楚一件事驱动开发不是背代码是建立内核如何管理硬件的思维模型。你知道了它为什么这么设计写起来就是水到渠成的事。1. 整条学习路径的设计思路为什么是“模块→设备树→子系统”1.1 设备驱动解决的本质问题Linux下一切皆文件这句话你一定听过。但一个硬件上电后内核凭什么知道它存在、怎么访问它、用什么协议跟它通信设备驱动干的事情就是把硬件能力接入内核的设备模型向用户空间暴露一个文件接口——你打开/dev/i2c-0、往/dev/can0发数据背后是内核在帮你的应用程序和设备打交道。这就带出一个关键问题硬件信息从哪来早期内核的做法是把板卡信息直接写死在C代码里比如某个芯片挂在哪个I2C控制器上、中断号是多少统统用一个结构体在arch/arm/mach-xxx/board-xxx.c里注册进去。这导致了两个麻烦第一换一块板子就要改内核代码重新编译第二ARM平台碎片化严重内核里积累了海量板级文件维护成本奇高。所以设备树Device Tree被引入Linux目的就是把硬件长什么样和驱动怎么写彻底分开。硬件拓扑用DSL语法写进.dts文件编译成二进制的.dtb由bootloader加载后传给内核。内核里的驱动则通过匹配compatible字符串来知道自己该帮哪个设备干活。驱动代码一次写好换板卡只需要改设备树这就是现代嵌入式Linux的主流做法。1.2 为什么I2C和CAN值得单独吃透在Linux内核里每种总线协议都有自己的一套驱动框架。I2C是典型的板级低速总线几乎所有开发板上都有它挂屏幕、传感器、EEPROM、触摸IC全是I2C设备而CAN是工业控制和车载场景的现场总线讲究实时性和可靠性在新能源汽车、机器人控制器上出现频率极高。从驱动开发的角度看这两个子系统一个代表字符设备型驱动思路I2C从设备驱动通过i2c_driver结构体注册一个代表网络设备型驱动思路CAN驱动本质上是net_device驱动走的是SocketCAN框架。你把这俩吃透了以后再碰SPI、USB、PCIe很多概念是相通的——无非是总线上怎么枚举设备、驱动怎么匹配设备、数据怎么交换这几件事。我建议的学习顺序是先写几个不依赖硬件的内核模块练手把模块机制、设备模型基础打牢再啃设备树语法然后带着设备树的知识去看I2C和CAN你会有一种哦原来之前看到的那些配置是这个意思的通透感。2. 内核模块开发驱动的最小单元与基本功2.1 一个最小内核模块到底长什么样内核模块Loadable Kernel Module是一种可以在系统运行期间动态加载/卸载的代码单元驱动开发中大量使用这种形式因为你不用为了加一个驱动就反复重新编译整个内核。#include linux/init.h #include linux/module.h #include linux/kernel.h static int __init hello_init(void) { printk(KERN_INFO hello: module loaded\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO hello: module unloaded\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple test module);这段代码看起来简单但每一行都有讲究。__init和__exit是内核的初始化/退出标记宏带有__init的函数在内核启动或模块加载运行完以后占用的内存会被释放掉这是一个节省内存的小设计。module_init和module_exit是入口点声明编译成模块后insmod时会调用hello_initrmmod时会调用hello_exit。配套的Makefile也很有固定套路obj-m : hello.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这里解释一下原理obj-m : hello.o告诉kbuild系统这个目录里的hello.c要编成外部模块make -C $(KERNELDIR)是切到内核源码目录用内核自己的构建系统来编译外部模块M$(PWD)指明模块源码在哪。你用的kerneldir必须和正在运行的内核版本一致否则加载时会报version magic不匹配这是新手最容易踩的坑。编译完会得到hello.ko加载卸载三板斧sudo insmod hello.ko sudo rmmod hello dmesg | tail2.2 模块参数与设备节点的自动创建真实驱动不可能只是打印一句话它至少要能接收用户传参。比如一个串口驱动波特率得能配那就用module_paramstatic int baudrate 115200; module_param(baudrate, int, 0644); MODULE_PARM_DESC(baudrate, UART baudrate);0644表示这个参数在/sys/module/xxx/parameters/baudrate下可见可读root用户还能在线修改。加载时传参sudo insmod my_uart.ko baudrate9600。还有一个高频需求是自动创建设备节点。传统做法是手动mknod /dev/xxx c 240 0但主设备号是动态分配的每次都手敲很痛苦。现代驱动的标准姿势是用class_create和device_create在内核里注册设备类再让udev帮你自动生成/dev/下的节点static struct class *my_class; static int __init my_init(void) { dev_t devno; alloc_chrdev_region(devno, 0, 1, mydev); my_class class_create(mydev_class); device_create(my_class, NULL, devno, NULL, mydev); return 0; }这样设备加载后/dev/mydev自动出现应用层直接open、read、write操作它。你要记住一个核心概念Linux驱动与应用层通信靠的就是这套字符设备机制file_operations结构体里的open/read/write/ioctl就是你的main函数。2.3 模块开发中必须养成的调试习惯写内核模块和写用户态程序最大的区别是一个段错误就能让整个系统重启。所以每一个指针都要想清楚它属于哪个进程上下文、中断上下文能不能睡眠、锁该怎么拿。这里分享我的几个经验用printk调试时注意级别生产代码别用KERN_DEBUG刷爆dmesg调试多用pr_debug配合动态调试开关insmod失败先dmesg看内核日志内核不会像用户态那样告诉你段错误它只会打印一条init_module from xxx.ko failed真正原因在日志里加载模块前先modinfo xxx.ko看依赖和license信息有vermagic错误说明内核头文件版本不匹配module的exit函数里要把所有申请过的资源释放掉——注册的字符设备、创建的class、分配的内存一个都不能漏否则rmmod后再加载会出各种莫名其妙的问题。等你把模块机制玩熟了就可以进入下一步让驱动和设备真正匹配起来。而这件事在现代内核里十有八九要靠设备树。3. 设备树从“代码写硬件”到“描述硬件”3.1 为什么ARM Linux离不开设备树设备树这个概念你要是做过老平台的BSP比如早期的S3C2440、i.MX6的某些老内核版本感受会特别深。当年每做一个板子都要在内核里加一个board-xxx.c文件把外设资源一个个用platform_device_register注册进去。后来ARM社区统一推进设备树现在主流内核包括瑞芯微、全志、NXP这些厂商的新平台基本都是内核只跑驱动逻辑硬件描述全部走设备树。设备树的历史背景不用背但你得记住它解决的三个问题板级信息与驱动代码解耦驱动代码可以跨平台复用适配新板卡基本只改设备树内核镜像通用化同一个内核配不同的dtb文件就能在完全不同的硬件上启动资源配置可视化外设的地址、中断、时钟、引脚复用用树形结构表达排查问题比翻C代码直观得多。设备树源文件.dts在编译时会通过dtc工具变成二进制.dtbbootloaderU-Boot常见负责把.dtb加载到内存并传给内核。3.2 设备树语法一颗树如何描述一块板子看一个最简单的设备树节点/dts-v1/; / { compatible acme,coyote; #address-cells 1; #size-cells 1; serial10000000 { compatible acme,uart; reg 0x10000000 0x1000; interrupts 0 15 4; clock-frequency 24000000; }; };/是根节点compatible是板级名字内核根目录下有.dtb对应的MACHINE_START或DT_MACHINE_START匹配它。#address-cells和#size-cells决定子节点的reg属性里地址和长度占几个32位整数——这里都是1所以reg 地址 长度各一个数。设备节点中最重要的属性就是compatible和reg。compatible既是设备树的身份证也是驱动匹配的钥匙驱动中of_match_table里的.compatible字符串必须和设备树节点里的完全一致推荐用厂商,型号的格式避免命名冲突。reg表示设备在总线上的地址对I2C从设备来说就是从机地址对内存映射设备来说就是基地址和映射长度。3.3 从实际项目看设备树节点的配置以RK3568为例现在很多工程师做项目会用到瑞芯微的RK3568这类SoC的SDK里已经有个完善的设备树体系了由rk3568.dtsiSoC级包含所有内部外设的默认配置和xxx-board.dts板级包含具体外设连接、引脚配置组成。你在板级dts里经常能看到这样的写法i2c2 { status okay; clock-frequency 100000; ssd1306: ssd13063c { compatible solomon,ssd1306; reg 0x3c; reset-gpios gpio3 5 GPIO_ACTIVE_LOW; reset-delay-ms 10; }; }; spi1 { status okay; spidev0 { compatible rohm,dh2228fv; reg 0; spi-max-frequency 10000000; }; };注意几个细节。i2c2是引用了dtsi里定义过的i2c2节点往里面追加属性的意思这不是新语法而是设备树的追加与覆盖机制——SoC级dtsi把外设默认状态设为disabled板级dts里需要用时再status okay打开。reset-gpios配合reset-delay-ms是很多传感器、屏幕驱动都需要的复位时序配置有的驱动直接在probe里读这个属性然后gpiod_set_value控制复位引脚拉低拉高。再比如你要修改某个外设的复位信号时间不是在驱动里写死msleep而是在设备树里加属性驱动里用of_property_read_u32读出来这样不同板卡不同硬件设计就不用反复改代码。设备树驱动的精髓就在这里凡是硬件相关的可调参数尽量做成属性。3.4 驱动侧如何获取设备树资源设备树描述好了驱动侧就要通过内核API把这些资源抠出来。最常见的有这么几组platform_get_resource(pdev, IORESOURCE_MEM, 0)拿到设备的寄存器物理地址配合devm_ioremap_resource做内存映射platform_get_irq(pdev, 0)拿中断号of_property_read_u32(node, clock-frequency, val)读任意自定义属性devm_gpiod_get(dev, reset, GPIOD_OUT_LOW)按属性名拿GPIO依赖设备树里的reset-gpios。这种基于struct device的托管式资源APIdevm_前缀我个人强烈推荐它意味着你不再需要手动在错误路径里释放资源设备解绑时内核会自动帮你清理能少写一堆眼泪。从内核模块到设备树的这条路走通你已经能把一个普通的memory-mapped设备或platform设备驱动跑起来了。接下来把它落到具体总线上先看I2C。4. I2C子系统实战让一颗OLED屏在你的板子上亮起来4.1 I2C设备驱动的模型与匹配过程I2C是飞利浦现在是NXP搞出来的两线制串行协议一根时钟线SCL、一根数据线SDA板级场景下极常用。Linux内核的I2C子系统分三层控制器驱动I2C adapter硬件相关的底层、核心层维护总线、地址分配、设备驱动处理具体从设备。你要写的通常是从设备驱动注册方式看这个模板static const struct of_device_id ssd1306_of_match[] { { .compatible solomon,ssd1306 }, { } }; MODULE_DEVICE_TABLE(of, ssd1306_of_match); static const struct i2c_device_id ssd1306_id[] { { ssd1306, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, ssd1306_id); static struct i2c_driver ssd1306_driver { .driver { .name ssd1306, .of_match_table ssd1306_of_match, }, .probe ssd1306_probe, .remove ssd1306_remove, .id_table ssd1306_id, }; module_i2c_driver(ssd1306_driver);匹配流程是这样的内核在枚举I2C总线时会在总线树上发现地址0x3c处有一个设备这个信息来自设备树ssd13063c节点然后拿着节点的compatible字符串去和所有已注册的i2c_driver的of_match_table比对匹配成功就调用probe函数。4.2 probe里做的事寄存器映射、GPIO复位、初始化和注册显示设备在probe回调里驱动要为这个设备准备好一切static int ssd1306_probe(struct i2c_client *client) { struct ssd1306_dev *dev; struct gpio_desc *reset_gpio; u32 delay_ms; dev devm_kzalloc(client-dev, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev-client client; reset_gpio devm_gpiod_get(client-dev, reset, GPIOD_OUT_LOW); if (!IS_ERR(reset_gpio)) { of_property_read_u32(client-dev.of_node, reset-delay-ms, delay_ms); gpiod_set_value_cansleep(reset_gpio, 1); msleep(delay_ms ? delay_ms : 10); gpiod_set_value_cansleep(reset_gpio, 0); msleep(100); } ssd1306_write_cmd(client, 0xAF); /* display on */ ... /* 初始化序列 */ dev_set_drvdata(client-dev, dev); return 0; }这个过程你一定要自己Debug一遍才会理解什么叫驱动就是按芯片数据手册初始化设备、然后等着被调用。4.3 I2C通信的几种API和实际选择I2C驱动里最基础的通信API有三个层次i2c_transfer与i2c_master_send/recv最底层的批量读写适合按页写屏幕、读大量数据i2c_smbus_read_byte_data/write_byte_dataSMBus风格的单字节寄存器读写简单传感器最常用i2c_mux相关的总线复用场景才需要考虑不多见。给SSD1306发数据的核心就是一个函数static int ssd1306_write_cmd(struct i2c_client *client, u8 cmd) { u8 buf[2] { 0x00, cmd }; return i2c_master_send(client, buf, 2); }sda上先传控制字节0x00表示后续是命令再传命令本身。发显存数据则用0x40当控制字节。很多I2C从设备都是这种控制字节数据两步走的套路你只要看懂芯片手册里的时序图就能对应上。4.4 用户空间调试技巧i2c-tools 是救命的写驱动之前我会先在用户态用i2c-tools验证硬件通路是否正常sudo apt install i2c-tools i2cdetect -l i2cdetect -y 2i2cdetect -y 2会扫描I2C总线2上的所有设备地址如果SSD1306接好了你就能在0x3c位置看到设备被扫描出来。之后再用i2cget/i2cset发指令验证。我在调试时先确认硬件通路再搞驱动能省掉一半的排错时间——很多驱动没反应其实是接线错了或地址不对。如果成绩出来很诡异比如所有地址都有设备或者扫描过程hang住先怀疑硬件问题上拉电阻没焊、SDA/SCL接反、电平不匹配这些我全踩过。4.5 I2C驱动常见坑地址位数搞混设备树reg 0x3c写的是7位地址但有些芯片手册写的是8位地址比如0x78要在心里换算一下8位地址右移一位就是7位地址。probe没被调用优先检查compatible字符串是否一字不差以及status okay有没有打开对应的I2C控制器节点。读操作前要不要先写寄存器很多传感器需要先写寄存器地址再启动read用i2c_transfer一次性发写读两段消息比分成两次调用更稳。ack错误如果i2c_transfer返回-EREMOTEIO说明设备没应答大概率地址错、设备未上电或线松了。5. CAN子系统开发从SocketCAN框架到一条报文上总线5.1 CAN总线的特点与Linux中的实现方式CANController Area Network是一种多主、双线CAN_H和CAN_L差分信号的串行总线最早是博世为汽车电子搞的现在在工业控制、医疗器械、机器人领域遍地都是。它和UART/I2C最大的区别是没有主从概念任何节点都能在总线空闲时发消息有完整的错误检测与重发机制报文有ID优先级仲裁。在Linux里CAN设备就是网络设备不是你熟悉的字符设备。这意味着你的操作接口是socketint s socket(PF_CAN, SOCK_RAW, CAN_RAW); struct sockaddr_can addr; struct can_frame frame; addr.can_family AF_CAN; addr.can_ifindex if_nametoindex(can0); bind(s, (struct sockaddr *)addr, sizeof(addr)); frame.can_id 0x123; frame.can_dlc 8; memcpy(frame.data, hello, 5); write(s, frame, sizeof(frame));这个方案叫SocketCAN是Linux对CAN的官方解决方案。驱动开发不必把SocketCAN应用层的每个细节都背下来但理解驱动要提供的是一个标准网络接口报文收发走net_device框架很重要。5.2 CAN设备树节点怎么配置不同SoC的CAN控制器设备树写法可能不同但套路都比较固定。拿瑞芯微RK3568为例华芯微特等平台也类似can1 { status okay; pinctrl-names default; pinctrl-0 can1m0_pins; };如果需要修改波特率有些平台的CAN控制器没有自动波特率检测需要通过用户态或者设备树指定ip link set can0 up type can bitrate 500000从驱动开发角度你在probe里通常会读取设备树里的compatible确认控制器型号alloc_candev分配CAN设备设置netdev_ops中的ndo_open/ndo_stop/ndo_start_xmit注册中断、启用时钟register_candev把设备注册进网络子系统。alloc_candev对应的释放函数是free_candev这两个函数是drivers/net/can/dev.c里提供的研究框架时顺着这个文件读下去比在网上零散语料效率高得多。5.3 CAN驱动的核心收发流程CAN驱动里收和发不是直接给应用层文件接口而是通过内核网络协议栈。发报文应用层调用write数据进入can_raw协议层最终到达驱动的ndo_start_xmit回调从这个回调里把struct can_frame里的数据写进控制器的发送缓冲区然后netif_wake_queue唤醒队列收报文CAN控制器的接收中断触发中断处理函数里通过netif_rx或napi_gro_receive把收到的struct sk_buff送进网络协议栈应用层的read才能收到。一个典型发送回调的简化版static netdev_tx_t mycan_start_xmit(struct sk_buff *skb, struct net_device *ndev) { struct mycan_priv *priv netdev_priv(ndev); struct can_frame *cf (struct can_frame *)skb-data; /* 写寄存器把cf-data和cf-can_id发送出去 */ mycan_write_msg(priv, cf); /* 发送完成中断里要调用can_get_echo_skb等函数回显 */ return NETDEV_TX_OK; }新手最容易漏掉的是回显echo环节。SocketCAN设计里自己发出去的报文默认也会被收回来一份用于上层协议栈的确认如果你不回显canfd等工具统计的发送数量就是0应用层write还会阻塞。常见做法是在发送完成中断里调用can_put_echo_skb和can_get_echo_skb这对很多初学的朋友是个盲区。5.4 CAN开发的数据面验证与排障三板斧装好驱动以后验证数据通路有现成的工具sudo apt install can-utils sudo ip link set can0 type can bitrate 500000 sudo ip link set can0 up candump can0 cansend can0 123#DEADBEEFcansend can0 123#DEADBEEF发送ID为0x123、数据为8字节De ad Be ef的报文不够8字节会自动补0candump can0抓取can0上的所有报文cangen can0产生随机报文压力测试。如果ip link set can0 up时报RTNETLINK answers: No such device说明驱动没注册成功或设备树节点没使能如果candump总在报bus error优先查总线两端的120欧终端电阻有没有接——CAN总线最经典的玄学问题十次有八次是终端电阻缺失。调试过程我会配合示波器看CAN_H和CAN_L的差分波形确认波特率对不对。如果没有示波器至少要用ip -details link show can0查看当前状态里有没有ERROR-ACTIVE、bus-off等标志。6. 常见问题排查与实测心得6.1 设备树相关的高频故障设备树有问题现象往往是一类设备没有生成、probe没被调用、某个资源读出来不对。这里有一个我自己屡试不爽的排查顺序确认你改的.dts真的编进了最终的.dtb并确认U-Boot加载的是这个dtb文件开发板经常会有多个dtb烧错文件是最冤的情况内核起来后在/proc/device-tree下找对应节点如果节点不存在说明设备树没生效存在但属性不对就检查语法和追加路径查看/sys/firmware/devicetree/base/它和/proc/device-tree是同一棵树的两种挂载视图用dtc把运行时的dtb反编译出来和你源码对比dtc -I fs -O dts -o runtime.dts /proc/device-tree另外两个高频问题GPIO复用冲突同一个引脚被两个设备节点都用到了以及中断属性里的interrupt-parent没指定导致中断号异常。前者用/sys/kernel/debug/pinctrl查看引脚占用后者仔细读SoC参考手册里的中断控制器映射。6.2 开发环境里那些不省心的小事设备驱动开发不只是写代码围绕开发环境本身也能耗掉大量时间。我顺手把这类问题也整理了预计对不少人实用Linux解压文件乱码Windows下用Zip打包的文件文件名编码是GBKLinux下解压乱码。解决unzip -O CP936 file.zip或者安装unar工具自动识别编码。WSL删除文件后空间没释放在WSL2里删了文件Windows的vhdx虚拟磁盘文件不会自动收缩。解决先wsl --shutdown再在管理员PowerShell执行diskpart的compact vdisk或手动使用Optimize-VHD。虚拟机安Linux开机蓝屏/黑屏多数情况是默认显存不够、显卡虚拟化模型不兼容。解决把VMware/Hyper-V的虚拟显卡模型改成基本显示模式或加nomodeset内核参数启动。外接显示器无画面先确认显示用的是哪张显卡驱动是否加载必要时在initramfs阶段就把对应firmware文件带上。这些小问题单拎出来都不难但在调试驱动的紧张节奏里突然来一下特别容易打断思路记下来能少走弯路。6.3 我的调试哲学先分边界再逐步缩小写驱动这几年最深的体会是驱动调试本质上是划清边界。硬件问题、设备树问题、驱动代码问题、应用层问题你总得先确认它出在哪一层。比如I2C设备没反应我不会直接读驱动代码逐行看而是先用i2cdetect看设备在不在——不在是硬件或设备树问题在再用i2cget/i2cset验证原始通信能力——通信正常才是驱动和上层协议的问题。CAN也是一样先确保ip link set can0 up成功且状态正常再查应用层报文这样排查路径会清晰得多。另外千万别把所有希望寄托在printk上这个阶段看内核源码和芯片手册比看日志更快。遇到奇怪的时序问题就找同系列的vendor驱动对照着看没准厂商的老代码里已经给你标好了坑。6.4 后续还能怎么延伸如果你把这篇文章的主线走通了一遍后续我可以推荐几个扩展方向中断下半部把tasklet、workqueue、threaded irq吃透这对写高吞吐设备驱动是必须的DMA与内存映射很多高速设备收发数据都要走DMA理解dma_alloc_coherent、dma_map_single之后很多带宽瓶颈会豁然开朗并发与同步mutex、spinlock、atomic变量用在哪些场景驱动崩溃十有八九和并发有关。我只是把所有驱动开发里最通用的那条路径讲清楚了。从我带过的工程师和我自己的经历看能力强的人不一定比谁聪明就是琢磨得细、推演得勤踩过足够多的坑下次自然能绕过去。希望这篇文章能让你少踩几个。
返回列表