Linux SPI设备驱动开发实战:从设备树到字符设备驱动
1. 项目概述从零到一构建Linux SPI设备驱动在嵌入式Linux开发中SPISerial Peripheral Interface总线因其全双工、高速、简单的特性被广泛应用于连接各类传感器、存储芯片、显示屏等外设。然而对于许多从单片机开发转向Linux驱动开发的工程师来说如何将一个简单的SPI外设“挂载”到复杂的Linux系统中并为其编写一个稳定、高效的驱动程序常常是第一个拦路虎。特别是现代Linux内核普遍采用的设备树Device Tree机制它取代了传统的板级硬编码使得驱动与硬件描述解耦但同时也增加了初学者的理解门槛。这个项目就是一次彻底的“解剖”与“重建”。我们将以一块常见的SPI接口OLED屏幕如SSD1306驱动芯片或SPI Flash如W25Q128为例手把手带你完成从设备树节点编写、驱动框架搭建、到用户空间测试的全过程。这不是一次简单的代码搬运而是深入理解Linux SPI子系统、设备树绑定规则以及字符设备驱动模型的实战演练。无论你是刚接触Linux驱动的嵌入式软件工程师还是希望深入理解内核机制的内核爱好者通过这次实践你不仅能得到一个可用的驱动更能掌握一套面对任何SPI设备都能从容应对的方法论。2. 核心概念与框架解析2.1 设备树硬件描述的“地图”设备树可以理解为一份描述硬件拓扑结构和资源的“地图”文件以.dts或.dtsi文本形式存在最终会被编译成二进制.dtb文件由Bootloader传递给内核。内核在启动时解析这份“地图”从而动态地知道当前系统上挂了什么硬件、它们的地址、中断号、时钟、寄存器等信息而无需在内核源码中为每一块板子写死这些配置。对于SPI设备设备树的核心作用是告诉内核SPI控制器主机在哪里通常由SoC厂商在核心的.dtsi文件中定义好了。SPI设备从机挂在哪里这是我们需要在板级.dts文件中添加的内容需要指定它连接到了哪个SPI控制器、使用哪个片选CS、通信的时钟频率spi-max-frequency、工作模式spi-cpol, spi-cpha等。这种设计的巨大优势在于可移植性。同一份驱动代码配合不同的设备树文件就能适配不同的硬件平台。驱动开发者只需要关心“如何操作一类设备”而硬件连接细节则交给设备树来描述。2.2 Linux SPI子系统分层的艺术Linux内核的SPI子系统采用了典型的分层设计这有助于代码复用和职责分离。理解这个框架是编写驱动的基础。SPI核心层SPI Core位于drivers/spi/spi.c。它提供核心的数据结构和API如spi_register_driver、spi_message等负责管理SPI控制器和设备的注册、匹配以及提供统一的通信接口。驱动开发者主要与这一层提供的API打交道。SPI控制器驱动层Master/Controller Driver位于drivers/spi/下如spi-bcm2835.c树莓派。这部分通常由SoC厂商或社区维护负责操作具体的SPI控制器硬件寄存器实现底层的数据收发时序。我们的驱动不直接接触这一层。SPI设备驱动层Protocol Driver这就是我们要编写的部分。它通过SPI核心层提供的API与具体的SPI从设备如Flash、传感器通信并向上层如MTD子系统、IIO子系统或字符设备提供访问接口。我们的工作就是编写一个SPI设备驱动它像一个“翻译官”将上层应用的需求如“读取传感器数据”翻译成通过SPI核心层发送给控制器驱动的具体SPI传输消息。2.3 驱动模型设备与驱动的“相亲大会”Linux驱动模型基于“设备Device”和“驱动Driver”分离的思想。设备树中描述的节点在内核初始化时会生成对应的platform_device对于平台设备或spi_device对于SPI设备。而我们编写的驱动模块则包含一个spi_driver结构体。内核就像一个“媒人”负责将spi_device和spi_driver进行匹配。匹配的关键就是compatible属性。设备树节点中的compatible字段必须与驱动中spi_driver.driver.of_match_table里定义的某个字符串一致。匹配成功后驱动的probe函数就会被调用在这里完成设备的初始化、资源申请和接口注册。3. 设备树节点编写详解假设我们使用的平台是瑞芯微RK3568其SPI控制器节点已在rk3568.dtsi中定义。我们要在板级设备树文件如rk3568-evb.dts中在对应的SPI总线节点下添加我们的设备子节点。3.1 节点位置与基本结构首先找到目标SPI控制器的节点。在RK3568中SPI0可能被定义为spi0。我们需要在其节点内或通过引用并添加status “okay”;启用它然后添加子节点。// 在板级.dts文件中 spi0 { status “okay”; // 启用SPI0控制器 pinctrl-names “default”; pinctrl-0 spi0m0_pins; // 引用引脚复用配置 #address-cells 1; #size-cells 0; // 假设我们连接了一个SPI Flash芯片 spi_flash: flash0 { compatible “jedec,spi-nor”; // 关键用于匹配驱动 reg 0; // 片选编号对应CS0 spi-max-frequency 50000000; // 最大时钟频率单位Hz spi-rx-bus-width 1; spi-tx-bus-width 1; }; // 再假设连接了一个SPI OLED屏幕SSD1306 oled: oled1 { compatible “solomon,ssd1306”; // 自定义的兼容字符串需与驱动一致 reg 1; // 使用CS1 spi-max-frequency 10000000; // OLED通常速度较低 spi-cpol; // 根据设备手册设置模式 spi-cpha; dc-gpios gpio1 10 GPIO_ACTIVE_HIGH; // 数据/命令控制引脚非标准SPI信号 reset-gpios gpio1 11 GPIO_ACTIVE_LOW; // 复位引脚 }; };3.2 关键属性释义compatible这是最重要的属性。它是一个字符串列表用于与驱动进行匹配。内核会从第一个字符串开始尝试匹配。通常格式为“制造商,型号”。对于标准器件如SPI NOR Flash使用内核文档Documentation/devicetree/bindings/中定义的标准字符串对于自定义设备可以自己定义但需确保驱动中的字符串与之完全一致。reg从设备的片选Chip Select编号。它表示该设备连接在主机控制器的哪个片选线上。0表示CS01表示CS1以此类推。spi-max-frequencySPI通信的最大时钟频率SCLK。必须根据数据手册设置过高的频率可能导致通信失败。spi-cpol与spi-cpha这两个属性共同定义了SPI的时钟模式Mode。Mode (CPOL 1) | CPHA。具体是0,1,2,3中的哪一种必须严格参照外设数据手册。不设置则默认为Mode 0。dc-gpios / reset-gpios对于像OLED这类除了SPI总线外还有控制引脚的外设需要通过GPIO描述符来指定。这展示了设备树如何灵活描述非标准连接。注意设备树修改后需要重新编译并更新到开发板。命令通常为make dtbs然后将生成的.dtb文件替换掉Bootloader加载的那个。4. SPI设备驱动编写实战我们将以一个虚拟的“SPI字符设备”为例编写一个完整的驱动。这个驱动会创建一个字符设备文件如/dev/my_spi_dev用户空间程序通过对该文件进行读写操作间接触发SPI数据传输。4.1 驱动模块的基本骨架首先包含必要的头文件并定义模块信息。#include linux/module.h #include linux/spi/spi.h #include linux/fs.h #include linux/cdev.h #include linux/uaccess.h #include linux/slab.h #define DEVICE_NAME “my_spi_device” #define CLASS_NAME “myspi” #define N_DEVICES 1 // 设备数量 static int major_number; static struct class *spi_class NULL; static struct cdev spi_cdev; // 我们将自定义一个结构体用于封装每个设备实例的信息 struct my_spi_dev { struct spi_device *spi; struct cdev cdev; dev_t devno; char buffer[256]; // 一个简单的缓冲区 int buf_len; }; static struct my_spi_dev *my_dev;4.2 定义并注册spi_driver这是驱动的主体结构负责与设备树匹配。static const struct of_device_id my_spi_of_match[] { { .compatible “custom,my-spi-device” }, // 必须与设备树中的compatible一致 {}, }; MODULE_DEVICE_TABLE(of, my_spi_of_match); static int my_spi_probe(struct spi_device *spi) { struct my_spi_dev *dev; int ret; printk(KERN_INFO “My SPI Device: Probing device on SPI bus %d, CS %d\n”, spi-master-bus_num, spi-chip_select); // 1. 分配设备实例内存 dev kzalloc(sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; // 2. 初始化SPI设备参数模式、频率、位宽 spi-mode SPI_MODE_0; // 覆盖设备树设置或保持一致 spi-bits_per_word 8; ret spi_setup(spi); if (ret 0) { dev_err(spi-dev, “Failed to setup SPI\n”); kfree(dev); return ret; } // 3. 将spi_device指针保存到私有数据中 dev-spi spi; spi_set_drvdata(spi, dev); // 重要建立spi_device与私有数据的关联 // 4. 初始化字符设备这里先简化实际在open中动态分配 // ... 字符设备相关初始化将在后续步骤完成 my_dev dev; // 简化示例全局变量保存 dev_info(spi-dev, “My SPI Device probe successful\n”); return 0; } static int my_spi_remove(struct spi_device *spi) { struct my_spi_dev *dev spi_get_drvdata(spi); printk(KERN_INFO “My SPI Device: Removing device\n”); // 清理字符设备... if (my_dev dev) { my_dev NULL; } kfree(dev); return 0; } static struct spi_driver my_spi_driver { .driver { .name “my_spi_device”, .owner THIS_MODULE, .of_match_table of_match_ptr(my_spi_of_match), }, .probe my_spi_probe, .remove my_spi_remove, };4.3 实现字符设备操作file_operations为了让用户空间能够访问我们需要实现read,write,open,release等操作。这里以write和read为例展示如何进行SPI同步传输。static ssize_t my_spi_write(struct file *filp, const char __user *buf, size_t len, loff_t *off) { struct my_spi_dev *dev filp-private_data; struct spi_transfer t { .tx_buf dev-buffer, .len len, }; struct spi_message m; int ret; if (len sizeof(dev-buffer)) return -EFBIG; // 1. 从用户空间拷贝数据到内核缓冲区 if (copy_from_user(dev-buffer, buf, len)) return -EFAULT; // 2. 构造并发送SPI消息 spi_message_init(m); spi_message_add_tail(t, m); ret spi_sync(dev-spi, m); // 同步传输会阻塞直到完成 if (ret 0) { printk(KERN_ERR “SPI sync write failed: %d\n”, ret); return ret; } dev-buf_len len; *off len; return len; // 返回成功写入的字节数 } static ssize_t my_spi_read(struct file *filp, char __user *buf, size_t len, loff_t *off) { struct my_spi_dev *dev filp-private_data; struct spi_transfer t { .rx_buf dev-buffer, .len len, }; struct spi_message m; int ret; // 假设我们通过SPI读取数据到缓冲区 memset(dev-buffer, 0, sizeof(dev-buffer)); spi_message_init(m); spi_message_add_tail(t, m); ret spi_sync(dev-spi, m); if (ret 0) { printk(KERN_ERR “SPI sync read failed: %d\n”, ret); return ret; } // 将内核缓冲区的数据拷贝到用户空间 if (copy_to_user(buf, dev-buffer, len)) return -EFAULT; *off len; return len; } static int my_spi_open(struct inode *inode, struct file *filp) { // 简化处理将全局设备实例关联到文件私有数据 filp-private_data my_dev; if (!my_dev) return -ENODEV; try_module_get(THIS_MODULE); // 增加模块引用计数 return 0; } static int my_spi_release(struct inode *inode, struct file *filp) { module_put(THIS_MODULE); // 减少模块引用计数 return 0; } static struct file_operations my_spi_fops { .owner THIS_MODULE, .open my_spi_open, .release my_spi_release, .read my_spi_read, .write my_spi_write, // 可以根据需要实现 .unlocked_ioctl 等 };4.4 模块初始化与退出在模块的init和exit函数中我们需要完成字符设备的注册/注销以及SPI驱动的注册/注销。static int __init my_spi_init(void) { int ret; dev_t dev_num; // 1. 动态申请主设备号 ret alloc_chrdev_region(dev_num, 0, N_DEVICES, DEVICE_NAME); if (ret 0) { printk(KERN_ERR “Failed to allocate char device region\n”); return ret; } major_number MAJOR(dev_num); // 2. 创建字符设备类用于在/sys/class下生成条目 spi_class class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(spi_class)) { unregister_chrdev_region(dev_num, N_DEVICES); return PTR_ERR(spi_class); } // 3. 初始化cdev结构并添加到内核 cdev_init(spi_cdev, my_spi_fops); spi_cdev.owner THIS_MODULE; ret cdev_add(spi_cdev, dev_num, N_DEVICES); if (ret 0) { class_destroy(spi_class); unregister_chrdev_region(dev_num, N_DEVICES); return ret; } // 4. 注册SPI驱动 ret spi_register_driver(my_spi_driver); if (ret 0) { cdev_del(spi_cdev); class_destroy(spi_class); unregister_chrdev_region(dev_num, N_DEVICES); printk(KERN_ERR “Failed to register SPI driver\n”); return ret; } printk(KERN_INFO “My SPI Device driver loaded with major %d\n”, major_number); return 0; } static void __exit my_spi_exit(void) { spi_unregister_driver(my_spi_driver); // 先注销SPI驱动 cdev_del(spi_cdev); class_destroy(spi_class); unregister_chrdev_region(MKDEV(major_number, 0), N_DEVICES); printk(KERN_INFO “My SPI Device driver unloaded\n”); } module_init(my_spi_init); module_exit(my_spi_exit); MODULE_LICENSE(“GPL”); MODULE_AUTHOR(“Your Name”); MODULE_DESCRIPTION(“A simple SPI character device driver”); MODULE_VERSION(“1.0”);5. 编译、加载与测试5.1 编写Makefile在驱动源码目录下创建Makefile。obj-m my_spi_device.o KDIR : /path/to/your/linux-kernel-source # 替换为你的内核源码路径 PWD : $(shell pwd) all: make -C $(KDIR) M$(PWD) modules clean: make -C $(KDIR) M$(PWD) clean5.2 编译与加载编译在驱动目录执行make生成my_spi_device.ko文件。加载模块将.ko文件拷贝到开发板执行insmod my_spi_device.ko。检查加载使用dmesg | tail查看内核日志应该能看到“Probing device…”和“driver loaded”等信息。使用lsmod | grep my_spi确认模块已加载。创建设备节点我们的驱动使用了class_create和自动创建设备节点的机制需内核配置支持CONFIG_SYSFS和CONFIG_DEVICE_FS。加载驱动后通常会在/dev/目录下自动生成设备节点如/dev/my_spi_device0或者可以通过cat /proc/devices找到主设备号然后用mknod手动创建。5.3 用户空间测试编写一个简单的C程序进行测试。// test_spi.c #include stdio.h #include stdlib.h #include fcntl.h #include unistd.h #include string.h int main() { int fd; char write_buf[] “Hello SPI!”; char read_buf[256] {0}; fd open(“/dev/my_spi_device0”, O_RDWR); if (fd 0) { perror(“Failed to open device”); return -1; } // 写入数据触发SPI发送 if (write(fd, write_buf, strlen(write_buf)) 0) { perror(“Write failed”); close(fd); return -1; } printf(“Written: %s\n”, write_buf); // 读取数据触发SPI接收 lseek(fd, 0, SEEK_SET); // 重置文件偏移简化示例 if (read(fd, read_buf, sizeof(read_buf)) 0) { perror(“Read failed”); close(fd); return -1; } printf(“Read: %s\n”, read_buf); close(fd); return 0; }在开发板上交叉编译并运行此测试程序同时观察dmesg输出可以看到驱动中read/write函数被调用并执行了SPI传输。6. 进阶话题与深度优化6.1 SPI传输模式选择与优化同步 vs 异步我们使用了spi_sync进行阻塞式传输。对于实时性要求不高的场景这很简洁。但如果传输大量数据或不想阻塞进程可以使用spi_async进行异步传输并在回调函数中处理完成通知。消息队列一个spi_message可以包含多个spi_transfer。这对于需要先发命令字、再读数据的设备非常有用可以保证一次完整的原子操作中间不释放片选。struct spi_transfer t_cmd { .tx_buf cmd, .len 1 }; struct spi_transfer t_data { .rx_buf data, .len 128 }; spi_message_init(m); spi_message_add_tail(t_cmd, m); spi_message_add_tail(t_data, m); // 命令和数据在一个消息中连续发送 ret spi_sync(spi, m);DMA传输对于大数据量传输如SPI Flash读写启用DMA可以极大减轻CPU负担。这通常在控制器驱动层面支持设备驱动只需确保缓冲区是DMA可访问的如使用kmalloc或dma_alloc_coherent分配并在spi_transfer中设置.tx_dma/.rx_dma地址。6.2 设备树绑定Binding文档如果你开发的驱动希望被主线内核接受或者让其他开发者能正确使用编写设备树绑定文档是必须的。这是一个描述设备树节点中每个属性含义、数据类型、是否必需的YAML文件。例如为我们的custom,my-spi-device创建一个Documentation/devicetree/bindings/spi/custom,my-spi-device.yaml。# SPDX-License-Identifier: GPL-2.0 %YAML 1.2 --- $id: http://devicetree.org/schemas/spi/custom,my-spi-device.yaml# $schema: http://devicetree.org/meta-schemas/core.yaml# title: My Custom SPI Device maintainers: - Your Name your.emailexample.com description: | This binding describes a custom SPI device used for demonstration. properties: compatible: const: custom,my-spi-device reg: maxItems: 1 description: SPI chip select line used by this device. spi-max-frequency: maximum: 10000000 description: Maximum SPI clock frequency in Hz. custom-gpio: $ref: /schemas/types.yaml#/definitions/phandle description: An optional GPIO pin for custom control. required: - compatible - reg additionalProperties: false examples: - | spi0 { #address-cells 1; #size-cells 0; my_device0 { compatible “custom,my-spi-device”; reg 0; spi-max-frequency 5000000; custom-gpio gpio1 12 GPIO_ACTIVE_HIGH; }; };6.3 集成到标准内核子系统我们的示例创建了一个简单的字符设备。但对于标准类型的设备更好的做法是集成到内核现有的子系统中这样可以利用内核提供的丰富功能和统一接口。SPI NOR Flash应实现spi_nor驱动接口并注册为MTDMemory Technology Device设备。这样上层可以直接使用/dev/mtdX进行访问并支持JFFS2、UBIFS等文件系统。SPI传感器如加速度计应实现IIOIndustrial I/O子系统驱动。这样数据可以通过/sys/bus/iio/devices/iio:deviceX/下的文件暴露方便用户空间通过标准方式读取。SPI显示屏可能涉及DRMDirect Rendering Manager或FBFramebuffer子系统。这通常更复杂需要实现具体的显示控制器驱动。集成到这些子系统虽然前期工作量大但能获得更好的兼容性、性能以及社区支持。7. 调试技巧与常见问题排查驱动开发离不开调试。以下是一些针对SPI驱动调试的实用技巧。7.1 内核日志与打印printk是最直接的调试工具。合理使用KERN_DEBUG,KERN_INFO,KERN_ERR等不同级别。在probe、remove、read、write以及关键错误路径上添加打印可以清晰看到驱动执行流程。7.2 使用spidev进行底层验证内核自带了一个通用的SPI用户空间设备驱动——spidev。在确认设备树节点正确后可以先将节点的compatible改为“spidev”并加载spidev模块。它会创建类似/dev/spidevX.Y的设备文件。使用spidev_test工具内核源码tools/spi/目录下或自己编写小程序可以直接发送任意SPI数据从而在编写具体设备驱动前先验证硬件连接和SPI控制器是否工作正常。这是一个硬件调试的利器。7.3 逻辑分析仪抓取波形当软件层面一切看起来正常但设备无响应时逻辑分析仪如Saleae是终极武器。通过抓取SCLK、MOSI、MISO、CS的波形可以确认时钟频率、极性、相位是否正确。片选信号是否在正确的时间有效。发送的数据是否符合设备数据手册的指令格式。设备是否有数据回传MISO线。很多通信问题比如模式不对、指令顺序错误、时序不满足要求通过看波形一目了然。7.4 常见问题速查表问题现象可能原因排查思路insmod失败提示Unknown symbol依赖的内核API未导出或内核版本不匹配。检查编译使用的内核源码版本是否与运行内核一致。使用modinfo查看模块依赖。驱动probe函数未被调用1. 设备树节点compatible不匹配。2. 节点状态status不是“okay”。3. SPI控制器未启用或引脚复用冲突。1. 核对设备树与驱动中的字符串。2. 检查设备树节点状态。3. 检查控制器节点状态和pinctrl配置。用cat /proc/device-tree/查看解析后的设备树。SPI通信失败返回错误码1. SPI模式CPOL/CPHA设置错误。2. 时钟频率过高。3. 硬件连接问题线接反、没供电。1. 仔细对照数据手册确认模式。2. 降低spi-max-frequency再试。3. 使用万用表、逻辑分析仪检查硬件。能写不能读或数据全为0/FF1. 读操作指令格式错误。2. MISO线路问题。3. 设备需要特定时序如命令后延迟。1. 用逻辑分析仪对比波形与数据手册要求。2. 检查MISO线连接。3. 在spi_transfer间或后添加udelay。用户空间open设备失败1. 设备节点未创建主设备号不对。2. 文件权限问题。1. 检查/proc/devices和/dev/下的节点。检查驱动注册代码。2. 使用sudo或修改设备文件权限。7.5 性能分析与优化当驱动功能正常后如果对性能有要求可以关注以下几点传输延迟使用spi_async避免在spi_sync中长时间阻塞。对于小数据包频繁的中断和上下文切换可能成为瓶颈可以测试批量传输。CPU占用率使用top或perf工具观察驱动线程的CPU使用情况。高占用率可能意味着大量CPU时间花在了内存拷贝copy_from/to_user或软件控制GPIO模拟时序上应尽量避免。DMA使用检查控制器驱动是否支持DMA并确保在驱动中启用了它。查看/sys/kernel/debug/spi/spiX.Y/下的信息如果debugfs已挂载。编写Linux SPI驱动是一个系统工程涉及设备树、内核框架、硬件协议和调试技巧。从最简单的字符设备驱动开始逐步理解每个环节再根据实际外设类型向更专业的子系统靠拢是一条稳健的学习路径。最重要的是动手实践修改代码、观察现象、分析问题在这个循环中积累的经验远比阅读文档来得深刻。