拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Linux字符设备驱动实战:从设备树到I2C调试与系统裁剪

做Linux驱动开发这几年我最大的感受是网上资料遍地都是但能把“框架搭起来”和“真正跑通硬件”之间那段距离讲清楚的文章太少。很多人卡住的地方不是看不懂file_operations里每个函数怎么填而是不知道驱动和硬件之间到底是怎么“匹配”上的也不知道设备树、总线、内核配置这些环节在整条链路上扮演什么角色。这篇东西我想换个讲法不按教科书顺序从内存管理讲到并发控制而是沿着一个字符设备从零到能用的实际路径把框架、设备树、I2C实战、调试优化和系统裁剪串起来讲每一段都带上我实际踩过的坑和验证过的做法。如果你是刚接触嵌入式Linux或者已经能改改驱动、但总觉得对整体流程缺乏掌控感这篇文章应该对你有用。1. 字符设备驱动框架一切外设控制的基石1.1 从file_operations理解用户态和内核态的桥梁字符设备是Linux驱动里最基础、也最常用的一种设备类型。它适合那些按字节流读写的外设比如串口、GPIO控制的传感器、LED灯、继电器等等。理解字符设备驱动核心就是理解struct file_operations。这个结构体定义了用户态程序可以对设备文件执行哪些操作。你写一个驱动本质上就是实现一组回调函数然后告诉内核“当有人打开/dev/mydev这个文件时请调用我的my_open函数当他写入数据时请调用我的my_write函数”。static struct file_operations my_fops { .owner THIS_MODULE, .open my_open, .read my_read, .write my_write, .release my_release, };这个结构体是驱动和用户态程序的唯一契约。用户态调用open()、read()、write()VFS虚拟文件系统会通过主设备号找到对应的驱动然后调用你注册的这组函数。设计驱动时先把这张函数表想清楚你的设备需要哪些操作不需要哪些不要图省事把不支持的接口也填进去内核里-EINVAL、-ENOTTY这些错误码很多就是在这种思考不周的情况下产生的。1.2cdev注册与设备节点的自动创建有了file_operations接下来需要把它和字符设备关联起来。传统做法是分配一个设备号然后用cdev_init和cdev_add把字符设备添加到内核。dev_t devno; int major 240; // 主设备号实际开发不要硬编码 struct cdev my_cdev; devno MKDEV(major, 0); cdev_init(my_cdev, my_fops); my_cdev.owner THIS_MODULE; cdev_add(my_cdev, devno, 1);但是这里有个问题即使cdev_add成功用户态也看不到任何文件。你还需要手动mknod /dev/mydev c 240 0创建设备节点。这在开发机上当然可以但产品化的时候总不能每次启动都手动敲一遍。所以现代驱动普遍采用class_createdevice_create这套组合让内核在驱动加载时自动在/dev下生成设备节点卸载时自动删除。static struct class *my_class; // 在probe或模块初始化中 my_class class_create(THIS_MODULE, my_class); device_create(my_class, NULL, devno, NULL, mydev);class_create的第二个参数是类名device_create的第五个参数是设备节点名。这两个名字可以不同但建议语义清晰。device_create内部其实做了很多事情注册设备、在sysfs中建立条目、通知udev或mdev创建设备节点。我在实际项目中使用mdevBusyBox环境时甚至不需要额外配置只要内核配置了CONFIG_DEVTMPFS和CONFIG_DEVTMPFS_MOUNT/dev下的节点就会自动出现。1.3 用miscdevice简化开发追求简单的话还有一个更轻量的选择miscdevice。它本质上是主设备号10的字符设备内核帮你管理次设备号分配你不用关心cdev_add也不用手动注册设备号。static struct miscdevice my_misc { .minor MISC_DYNAMIC_MINOR, .name mydev, .fops my_fops, }; misc_register(my_misc);用miscdevice的好处显而易见代码量少设备节点自动创建在/dev/mydev不需要在/etc/udev/rules.d里做任何配置。我在早期的项目里为了省事很多传感器、LED、按键这类简单外设都用了miscdevice框架直到需要处理多个同类型设备比如两路I2C转GPIO扩展芯片时才切回标准字符设备框架用alloc_chrdev_region动态分配设备号。所以我的建议是只有单个设备、功能简单用miscdevice需要考虑设备数量、动态分配、多个子设备用标准cdev流程。另外不管你用哪种方式一定要记得在模块卸载时释放资源否则第二次insmod会直接报Device or resource busy。2. 设备树配置驱动与硬件解耦的关键机制2.1 为什么需要设备树2.6时代的内核平台设备信息全靠板级文件里一个个platform_device结构体硬编码。换一个板子就要改内核源码重新编译非常痛苦。设备树Device TreeDT把“硬件是什么样”从内核源码中剥离出来用文本文件描述硬件资源内核启动时解析这个文件根据匹配结果自动加载对应的驱动。设备树文件通常有三种.dts源文件、.dtsi公共头文件类似C的头文件、.dtb编译后的二进制。编译命令很简单dtc -I dts -O dtb -o myboard.dtb myboard.dts实际工作中一般是在内核源码目录里执行make dtbs因为设备树文件往往依赖内核里的一些头文件定义。2.2 compatible匹配驱动怎么找到硬件设备树的核心机制是compatible字符串匹配。在.dts里描述一个设备i2c1 { status okay; my_sensor48 { compatible mycompany,my-sensor; reg 0x48; interrupt-parent gpio1; interrupts 19 IRQ_TYPE_EDGE_FALLING; }; };驱动里声明自己的of_match_tablestatic const struct of_device_id my_sensor_of_match[] { { .compatible mycompany,my-sensor }, { } }; MODULE_DEVICE_TABLE(of, my_sensor_of_match); static struct i2c_driver my_sensor_driver { .driver { .name my_sensor, .of_match_table my_sensor_of_match, }, .probe my_sensor_probe, .remove my_sensor_remove, };匹配成功后内核调用probe函数。匹配规则有优先级一般靠compatible进行字符串精确匹配。这里有个小坑compatible里的厂商名部分最好和芯片原厂命名一致不要自己乱起。比如TI的芯片就用ti,开头NXP就用nxp,。虽然内核匹配不看这个前缀但维护设备树的人一看前缀就知道是哪家的东西出问题排查时省很多精力。2.3 在probe里读取硬件资源probe函数的参数是struct i2c_client *clientI2C设备或者struct platform_device *pdev平台设备。设备树里配置的资源需要在这时通过一组of_开头的API读出来。static int my_sensor_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct device_node *np client-dev.of_node; u32 threshold; int irq; if (of_property_read_u32(np, mycompany,threshold, threshold)) { dev_warn(client-dev, threshold property missing, use default\n); threshold 50; } irq irq_of_parse_and_map(np, 0); if (!irq) { dev_err(client-dev, failed to get irq\n); return -EINVAL; } // 申请GPIO等资源 gpiod devm_gpiod_get_optional(client-dev, reset, GPIOD_OUT_LOW); if (IS_ERR(gpiod)) return PTR_ERR(gpiod); }如果你看内核里老驱动的源码会发现很多用的是platform_get_resource、platform_get_irq这类接口。这些是配合板级文件时代的platform_device用的但在设备树模式下依然工作因为它们内部也是从设备树节点中解析资源。区别在于老的资源定义在C代码里新的定义在.dts里。掌握了这个思路你看任何平台的驱动源码都不会觉得陌生。我自己经常用的几个APIof_property_read_u32/of_property_read_string读数字和字符串属性of_get_named_gpio/devm_gpiod_get获取GPIOirq_of_parse_and_map获取中断号of_find_node_by_path/of_parse_phandle遍历和设备树节点引用2.4 设备树坑位排查设备树写错是嵌入式开发最常见的故障源但排错其实不难关键是养成几个习惯status属性很重要设备树里很多节点默认status disabled你如果只加了自己的节点但忘了把对应的控制器节点设成okay驱动probe根本不会触发。排查时先grep status看看。看内核日志启动时加上earlycon和loglevel8内核会打印设备树解析过程中没匹配到驱动的设备。用# ls /sys/firmware/devicetree/base检查树结构和内存中解析结果是否一致。中断号冲突多个外设共用一个中断号时如果驱动不支持共享中断IRQF_SHARED后加载的那个驱动会申请失败。这类问题在/proc/interrupts里一眼就能看出来。引脚复用设备树里配好了I2C控制器地址但引脚的pinmux没配置或配置错误总线就是不通。这种问题最坑因为设备树解析正常I2C控制器驱动也正常加载但i2cdetect一个设备都扫不到。排查方法检查pinctrl子系统是否生效对比原理图上引脚对应的mux模式是否和设备树pinctrl-0配置一致。3. I2C设备驱动实战从驱动框架到数据读写3.1 I2C子系统三件套I2C是嵌入式Linux里最常见的总线协议之一外接传感器、EEPROM、RTC几乎都是I2C设备。整个I2C子系统可以这样理解struct i2c_adapterI2C控制器就是芯片里的I2C硬件模块负责在物理总线上产生时钟和数据信号struct i2c_client挂在总线上的从设备地址是多少挂在哪个控制器下struct i2c_driver驱动负责匹配client实现读写逻辑设备树中my_sensor48这个节点会被内核解析成一个i2c_client注册到对应的i2c_adapter总线上。i2c_driver的id_table或of_match_table匹配上之后probe被调用。整个过程不需要你手动创建任何设备实例。3.2 注册函数的正确用法老内核源码里常看到i2c_add_driver它是i2c_register_driver的封装现在标记为deprecated。新代码直接用i2c_register_driver或者继续用i2c_add_driver也不会有编译错误只是编译时会提示。我一般在新项目里用i2c_register_driver但为了兼容老内核模块加载和卸载的写法基本固定module_i2c_driver(my_sensor_driver);module_i2c_driver是一个宏展开后自动生成module_init和module_exit调用i2c_register_driver和i2c_unregister_driver。如果你需要在驱动注册前做更多初始化比如分配全局缓冲区就不要用这个快捷宏而是手写static int __init my_sensor_init(void) { // 其他初始化... return i2c_register_driver(my_sensor_driver); } module_init(my_sensor_init); static void __exit my_sensor_exit(void) { i2c_unregister_driver(my_sensor_driver); // 其他清理... } module_exit(my_sensor_exit);大多数情况下module_i2c_driver就够了。但了解手动写法有个好处调试时可以在中间加打印或者在需要多个驱动共同初始化时控制顺序。3.3 用i2c_transfer读写寄存器驱动probe之后核心工作就是通过I2C协议和芯片通信。内核提供的底层接口是i2c_transfer它可以同时发送多个消息中间不产生STOP条件很多芯片的“写寄存器读数据”操作就是这个时序。static int my_sensor_read_reg(struct i2c_client *client, u8 reg, u8 *val) { struct i2c_msg msgs[2]; int ret; msgs[0].addr client-addr; msgs[0].flags 0; msgs[0].len 1; msgs[0].buf reg; msgs[1].addr client-addr; msgs[1].flags I2C_M_RD; msgs[1].len 1; msgs[1].buf val; ret i2c_transfer(client-adapter, msgs, 2); if (ret ! 2) { dev_err(client-dev, i2c read failed: %d\n, ret); return ret 0 ? ret : -EIO; } return 0; }这里注意i2c_transfer的返回值是成功传输的消息数量不是错误码。所以判断时要说if (ret ! 2)而不是if (ret 0)。这是很多新手最容易犯的错误传输失败时ret可能是-EIO、-ENXIO也可能是1第一个消息成功、第二个失败只判断ret 0会漏掉部分失败场景。如果只是简单地从寄存器读一个值也可以用内核封装好的i2c_smbus_read_byte_data(client, reg)它在内部帮你构造消息。SMBus协议和I2C基本兼容大部分传感器都支持。但要注意某些特殊芯片不支持SMBus的部分操作类型比如I2C_SMBUS_BLOCK_DATA这时候老老实实用i2c_transfer更保险。3.4 实测I2C驱动的完整链路以我手头一个温度传感器为例完整链路是这样的设备树里加节点挂在i2c2总线上地址0x48驱动装载probe里用i2c_verify_client确认client有效用i2c_smbus_read_byte_data读芯片ID寄存器验证通信是否正常配置传感器写配置寄存器注册hwmon接口或input接口向上层暴露数据调试这个流程时i2cdetect是利器。内核加载了i2c-dev驱动后用户态可以直接访问I2C总线i2cdetect -y 2 i2cget -y 2 0x48 0x0f i2cset -y 2 0x48 0x01 0x00这三个命令分别负责扫总线、读寄存器、写寄存器。如果i2cdetect能看到设备但在自己的驱动里read失败基本可以排除设备树和总线问题专心查驱动代码。4. 性能调优与调试手段从dev_dbg到frace4.1 什么时候需要用户态驱动方案我遇到过一个项目需要控制一个高速ADC采样率要求100ksps每次采集还要做简单的数字滤波。一开始按传统思路写内核驱动但调试过程非常痛苦——每次改算法都要重新交叉编译、拷到板子、insmod、再写测试程序验证。后来我换了个思路用内核自带的IIO框架把ADC数据通过/dev/iio:device0暴露给用户态算法和滤波全放用户态处理性能完全够用。这并不是说内核驱动没用而是提醒大家在方案设计阶段就想清楚“数据通路”怎么走。有些场景根本不需要写字符驱动单纯的GPIO输入输出用libgpiod用户态库SPI Flash读写用mtd设备I2C传感器用i2c-devioctlPWM输出/sys/class/pwm/pwmchip0导出的sysfs接口但反过来涉及高速数据搬移比如USB摄像头、千兆网卡、需要低延迟响应中断的场景就必须内核态驱动。如果数据量大还涉及持续搬运可以直接用DMA引擎来减轻CPU负担。4.2 并发与同步的选型决策驱动跑到一定复杂度并发问题就来了。用户态可能同时有多个进程在读写中断处理函数也可能访问共享数据。选哪种同步机制取决于你的临界区有多长、上下文是什么。场景推荐机制原因短暂临界区仅CPU之间竞争自旋锁spinlock不会睡眠适合原子性要求极高的场景临界区较长可能阻塞互斥锁mutex可睡眠避免长期空转CPU生产者和消费者模型等待队列waitqueue completion标准做法支持超时唤醒中断上下文操作共享数据spin_lock_irqsave需要关中断防止死锁我见过不少人在驱动的read函数里用udelay做忙等等硬件状态寄存器变成某个值。这种做法在短期内能用但会白白占用CPU。更好的方案是使用wait_event_interruptible_timeout等待硬件中断唤醒超时了做超时处理。一个实际例子单片机通过SPI往Linux板卡传数据每包数据到达时SPI控制器产生中断驱动在中断里把数据搬到缓冲区然后唤醒等待的用户态进程。static irqreturn_t spi_irq_handler(int irq, void *dev_id) { struct my_dev *dev dev_id; // 从硬件FIFO读数据到dev-buf // 更新dev-data_len wake_up_interruptible(dev-wq); return IRQ_HANDLED; } static ssize_t my_read(struct file *file, char __user *buf, size_t count, loff_t *pos) { struct my_dev *dev file-private_data; int ret; ret wait_event_interruptible(dev-wq, dev-data_len 0); if (ret) return ret; // 拷贝数据到用户态... }这个模型的性能远好于轮询而且响应时间可以预期。用wait_event_interruptible时要注意如果没有任何数据用户态进程会进入睡眠kill掉它需要用kill命令发信号wait_event_interruptible会在收到信号时返回-ERESTARTSYS驱动要正确处理这个返回值。4.3 系统化调试从printk到ftrace调试驱动第一个工具当然是printk但它不能乱用。开发阶段随便加打印无所谓产品化之前一定要清干净。我的习惯是使用dev_dbg替代printk因为dev_dbg可以在运行时通过dynamic_debug动态打开不用重新编译内核频繁调用的路径比如每次read都打印数据用trace_printk或干脆不要打印通过dmesg -n 8控制内核日志级别动态调试的打开方式echo file my_driver.c p /sys/kernel/debug/dynamic_debug/control这样能按文件打开所有dev_dbg输出不用改代码重新编译非常方便。再往上走ftrace可以跟踪内核函数调用。比如你想看my_sensor_probe到底有没有被调用或者I2C传输函数内部执行了哪些流程echo function_graph /sys/kernel/debug/tracing/current_tracer echo i2c_transfer /sys/kernel/debug/tracing/set_ftrace_filter echo 1 /sys/kernel/debug/tracing/tracing_on cat /sys/kernel/debug/tracing/traceftrace还有个重要用途是看函数执行耗时如果某个函数执行时间异常基本可以判断是该用异步机制而不是继续在中断里做重活了。用户态配合的调试工具strace跟踪系统调用看用户态程序是否真的调用了read/write参数是什么perf top看内核热点函数如果某个驱动程序占CPU很高优先检查是不是忙等没有睡眠/proc/interrupts看中断触发次数如果远超每秒预期值说明中断风暴下冲沿/上升沿配置可能有问题/proc/iomem确认寄存器物理地址映射范围是否正确4.4 性能调优思路内核驱动的性能瓶颈通常不在CPU而在数据通路。我在嵌入式项目里常用的优化顺序是先确认硬件本身的上限。比如SPI时钟能不能到20MHzI2C能不能上400kHz再看软件路径有没有多余的拷贝。copy_to_user不可避免但一个64字节的寄存器读没必要每次分配缓冲区考虑批量传输。I2C读100个字节一次i2c_transfer传2个消息比循环100次i2c_smbus_read_byte_data快得多高频外设使用threaded_irq把中断处理中耗时的I2C访问放到内核线程上下文不要在硬中断里做总线传输第一次优化时一定要先量基准再动手。我在一个项目里花了半天把I2C从100kHz调成400kHz结果发现传感器本身每次转换需要10ms总线速度的提升对整体吞吐几乎没影响。优化的方向应该放在“怎么在转换完成的第一时间读到数据”上而不是提高总线速率。5. 内核配置、模块加载与系统裁剪5.1 Kconfig和Makefile驱动如何编入内核驱动代码写完还需要让内核构建系统认识它。以放到drivers/misc目录下的驱动为例# drivers/misc/Makefile obj-$(CONFIG_MY_SENSOR) my_sensor.o对应的Kconfigconfig MY_SENSOR tristate My Sensor driver depends on I2C help Support for MySensor I2C chip.tristate表示有三种选择n不编译、y编入内核、m编译成模块。开发阶段用m频繁改驱动可以只编译模块、拷到板子insmod不用重新烧写内核镜像。产品阶段看情况如果需要开机自启且根文件系统在只读分区上编入内核更省事。5.2 交叉编译与部署流程我现在的开发板是arm64架构交叉编译器前缀是aarch64-linux-gnu-。在内核源码目录下执行export ARCHarm64 export CROSS_COMPILEaarch64-linux-gnu- make myboard_defconfig make -j8 zImage dtbs modules编译出的模块散落在各个源码目录里用make modules_install INSTALL_MOD_PATH$PWD/modules统一装到一个目录然后拷贝到板卡上。模块依赖关系也要一起处理depmod -b /path/to/rootfs这个命令很多人会漏掉直接导致modprobe时提示模块依赖错误。5.3 系统裁剪优化实录热搜词里有一个“系统裁剪优化”我展开讲讲。有一块eMMC只有256MB的开发板跑完整Debian根文件系统非常勉强我做过一轮裁剪内核部分关掉不需要的驱动和子系统。把蓝牙、WiFi板子没有对应硬件、PCIe、音频框架等全部# CONFIG_XXX is not set。最终内核镜像从9MB减到4.5MB启动时间从8秒降到3秒文件系统部分用BusyBox替代完整coreutils整个根文件系统压到16MB以内服务部分关掉systemd里用不到的服务开机只保留mdev用于动态设备节点管理日志部分/var/log做成tmpfs防止频繁写flash影响寿命裁剪的原则是先按“用不到”删再按“可以不用”删最后才考虑“功能降级”方案。我见过有人为了把镜像做小连modprobe都砍了结果驱动加载只能靠insmod调试模块依赖时非常痛苦。一个有用的检查命令size vmlinux内核源码顶层目录执行能看到整个内核镜像的各段大小。如果你想知道具体是谁占了大头用make menuconfig里按/搜索符号或者看System.map里地址范围。5.4 模块参数和断点排查的实用技巧驱动里使用module_param可以动态调整某些参数不需要重新编译static int debug_enable 0; module_param(debug_enable, int, 0644);加载时insmod my_sensor.ko debug_enable1运行时调整echo 1 /sys/module/my_sensor/parameters/debug_enable内核模块的断点调试可以用kgdb内核调试器但需要内核开启CONFIG_KGDB并且需要串口或网络连接。如果是纯代码流程排查问题我更推荐一种“穷人调试法”在关键路径上放不依赖printk的标记比如给某个全局变量赋值然后在导出到sysfs的属性里把它读出来。这种方式在时间紧迫、又不想反复编译内核时特别管用。6. 驱动开发中的常见问题与经验收尾6.1 排查思路从现象倒推链路驱动出问题时我一般按这个顺序排查模块加载成功了吗没有就dmesg看失败原因insmod: error inserting基本是符号依赖或资源冲突设备节点存在吗不存在说明device_create没执行或者udev规则有问题用户态访问返回什么错误-EACCES是权限-ENXIO是设备不存在-EINVAL多半是参数或ioctl命令号不对硬件层面的通信通吗I2C用i2cdetectSPI用spidev_test内核源码里有GPIO用gpioget/gpioset数据对不对不对就看寄存器配置、时序、字节序这套顺序适用90%以上的嵌入式驱动问题。大多数人卡在第三步到第四步之间因为用户态和设备树都能“看起来正常”问题其实是硬件时序或寄存器初始化顺序不对。6.2 硬件相关的环境问题软件写得再对硬件焊接有问题也白搭。我遇到过I2C总线上拉电阻没焊导致通信时通时断的问题也遇到过中断引脚虚焊导致按键驱动只能检测到按下、检测不到释放的问题。遇到这类问题可以用示波器看一眼总线时序是否完整没有示波器就用逻辑分析仪几十块钱的就能用。看I2C波形时重点看起始条件SDA在SCL高电平期间拉低、停止条件SDA在SCL高电平期间拉高、应答位第9个时钟后SDA被拉低是否正常。如果波形里多了毛刺先用万用表确认电源是不是稳的。6.3 把驱动开发当成系统工程来做最后分享一点感触。很多新手学驱动开发把精力全放在语法和API上但真正支撑起一个产品级驱动的是对整个系统运转逻辑的理解用户态请求怎么进来、VFS怎么调度、设备树怎么描述硬件、中断子系统如何分发事件、DMA如何搬运数据、电源管理怎么在睡眠和唤醒之间切换。我的学习路径是先找一个真实的开发板和一个简单的传感器把字符设备框架跑通然后逐步引入设备树、中断、内核线程、DMA每加一个特性就想想它在整个数据链路中处于什么位置。这个过程急不得但一旦建立起了“全链路视角”后面看任何外设驱动都会觉得顺理成章。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门