
1. 从零到一为什么我们需要亲手写一个SPI驱动如果你在嵌入式Linux领域摸爬滚打了一段时间大概率已经用过不少现成的驱动。比如要驱动一个SPI接口的OLED屏幕你可能会在设备树里加个节点然后在内核配置里勾选上对应的驱动模块编译、加载一气呵成。这感觉很好一切似乎都封装好了你只需要知道怎么用就行。但不知道你有没有遇到过这种情况一个冷门的SPI芯片内核里没有现成的驱动或者一个看似简单的SPI设备按照手册配置好了却怎么也通信不上示波器抓到的波形怎么看怎么不对劲。这时候你盯着/dev/spidev0.0这个设备文件或者内核里那堆复杂的spi.h头文件可能会感到一阵无力——驱动层到底发生了什么这就是我决定动手写这个实验的初衷。不是为了重复造轮子而是为了“拆轮子”。只有亲手从零搭建一个Linux SPI字符设备驱动你才能真正理解从用户空间的一个write()或ioctl()调用到SPI控制器硬件引脚上出现精确时序的整个数据流。你会明白设备树Device Tree里那几个神秘的属性字串到底是如何被内核解析并转化为驱动可用的参数的你会看到struct spi_device和struct spi_driver是如何牵手成功的更重要的是你会亲身体验到驱动开发中最磨人也最有价值的部分——调试。当你的代码第一次让MOSI线上出现预期的数据波形时那种成就感是单纯调用API无法比拟的。这个实验适合所有已经了解Linux驱动基本概念比如模块、设备模型、文件操作集file_operations并且对SPI协议有初步认识的开发者。我们不会使用任何第三方框架或取巧的spidev尽管它会作为对比出现而是严格按照Linux内核驱动模型的规范从头构建。通过这个过程你收获的将不仅仅是一个能用的驱动而是一套理解任何Linux外设驱动的方法论。2. 实验环境搭建与核心硬件选型工欲善其事必先利其器。一个稳定、可重复的实验环境是成功的第一步。这个实验不挑食几乎任何一款搭载了Linux内核的流行嵌入式开发板都能胜任比如树莓派Raspberry Pi、BeagleBone或是像i.MX6UL、STM32MP1这类常见的工业级平台。我个人的实验环境是一块树莓派4B内核版本为5.10.y。选择它的原因很简单资料丰富、社区活跃其Broadcom BCM2711芯片的SPI控制器稳定且功能完整足以覆盖我们实验所需的所有特性。2.1 硬件连接理解SPI的物理链路SPISerial Peripheral Interface是一种全双工、同步的串行通信总线。我们的实验需要一个SPI主设备我们的开发板和一个SPI从设备。为了聚焦于驱动本身而不是复杂的设备逻辑我们选择最简单的从设备一片74HC595移位寄存器。这是一个经典的数字芯片价格低廉通过SPI输入数据可以控制其8位并行输出口的高低电平我们可以用LED来直观地显示通信结果这比用逻辑分析仪看波形要直观得多。连接方式如下SCLK Serial Clock 主设备输出时钟连接74HC595的SH_CP第11脚。MOSI Master Out Slave In 主设备数据输出连接74HC595的DS第14脚。CS/SS Chip Select 片选信号低有效连接74HC595的ST_CP第12脚。注意有的地方也称之为CE或SS。MISO Master In Slave Out 74HC595没有数据回传功能此引脚可以不接。但在驱动中我们仍需保留该信号线的定义以符合框架。VCC 和 GND 连接电源和地。注意务必确认开发板SPI接口的电压电平与74HC595兼容通常都是3.3V。如果开发板是5V电平可能需要电平转换电路。为什么选74HC595因为它是一个“哑”设备。它没有复杂的寄存器需要配置通信协议简单到极致——你给它8个时钟脉冲它就从MOSI线依次移入8位数据然后在片选信号的上升沿将数据锁存到输出端。这让我们可以完全专注于驱动框架和数据流本身而不必分心去解析特定的设备协议。2.2 软件准备内核源码与配置驱动开发离不开内核源码。你需要获取与你开发板上运行的内核版本相匹配的源码树。对于树莓派可以从其GitHub仓库获取。你需要配置内核确保以下选项是开启的make menuconfigDevice Drivers --- [*] SPI support --- * BCM2835 SPI controller (即你的SPI控制器驱动) * User mode SPI device driver support (这个就是spidev我们先启用它作为参考和备份)编译内核模块的命令通常是make modules。更重要的是你需要准备好交叉编译工具链如果你的开发板是ARM架构并确保Makefile能正确指向内核源码路径和架构。3. 驱动框架解剖设备、驱动与总线Linux内核驱动遵循一种称为“设备模型”的优雅设计。对于SPI这类总线驱动其核心是总线Bus、设备Device、驱动Driver三者分离又协作的模型。理解这个模型是写出正确驱动的前提。3.1 设备树Device Tree硬件的软件描述在现代ARM Linux中硬件信息不再硬编码在内核里而是通过一种叫设备树.dts文件的结构化数据来描述。我们要做的第一件事就是在设备树中声明我们的SPI设备节点。假设我们使用树莓派的SPI0总线片选信号使用CS0对应物理引脚GPIO8。我们需要在/boot/overlays/目录下编辑或创建一个覆盖层overlay文件或者在主设备树文件中添加。以下是一个示例片段/dts-v1/; /plugin/; / { compatible brcm,bcm2835; fragment0 { target spi0; __overlay__ { #address-cells 1; #size-cells 0; status okay; pinctrl-names default; pinctrl-0 spi0_pins spi0_cs_pins; cs-gpios gpio 8 1; // 使用GPIO8作为自定义片选高电平有效 myspi_device: myspi_device0 { compatible embeded,spi-demo; reg 0; // 片选编号对应CS0 spi-max-frequency 1000000; // 最大时钟频率1MHz spi-cpol 0; // 时钟极性0表示空闲时为低电平 spi-cpha 0; // 时钟相位0表示在第一个时钟边沿采样 }; }; }; };我们来拆解一下关键属性compatible embeded,spi-demo; 这是驱动匹配的关键。它定义了一个字符串内核中的SPI驱动会用它来识别这个设备是否由自己来驱动。格式通常是“制造商,设备型号”。reg 0; 指定该设备使用哪个片选线CS。这里的0通常对应硬件控制器的第一个片选。如果我们使用cs-gpios自定义的GPIO这个值通常与GPIO数组的索引对应。spi-max-frequency 设备支持的最大SCLK频率。驱动不应超过此限制。spi-cpol和spi-cpha 这两个参数定义了SPI的四种工作模式Mode 0-3。我们的74HC595通常工作在Mode 0CPOL0 CPHA0。这是最容易出错的地方之一必须与从设备的数据手册严格对应。设备树编译用dtc工具并加载后内核在启动时就会解析到这个节点并创建一个struct spi_device对象来代表这个硬件设备。3.2 驱动骨架模块初始化与注销驱动本身是一个内核模块。它的入口是module_init宏指定的函数。在这个函数里我们主要做一件事向SPI总线核心注册我们的驱动。#include linux/module.h #include linux/spi/spi.h static int spi_demo_probe(struct spi_device *spi); static int spi_demo_remove(struct spi_device *spi); static const struct of_device_id spi_demo_of_match[] { { .compatible embeded,spi-demo }, {}, }; MODULE_DEVICE_TABLE(of, spi_demo_of_match); static struct spi_driver spi_demo_driver { .driver { .name spi-demo, .of_match_table spi_demo_of_match, .owner THIS_MODULE, }, .probe spi_demo_probe, .remove spi_demo_remove, }; module_spi_driver(spi_demo_driver); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple SPI character device driver demo);这里有几个核心结构spi_demo_of_match 这是一个匹配表。当内核发现一个compatible属性为embeded,spi-demo的SPI设备时就会尝试将这个设备与我们的驱动绑定。spi_demo_driver 描述了我们的驱动。.probe和.remove是回调函数分别在设备绑定和解绑时被调用。module_spi_driver宏是一个便利宏它同时帮我们注册了模块的init和exit函数。匹配过程 内核启动或加载模块后总线核心会遍历所有已注册的SPI设备。对于每个设备它会用设备的compatible属性去匹配所有已注册驱动的of_match_table。一旦匹配成功就调用该驱动的.probe函数并将对应的struct spi_device指针传递进去。这个spi_device对象包含了所有从设备树解析出来的配置信息频率、模式等。3.3 探针Probe函数设备的初始化现场.probe函数是驱动逻辑开始的地方。它的任务是初始化这个特定的设备实例。static int spi_demo_probe(struct spi_device *spi) { int ret; struct spi_demo_dev *dev; // 1. 检查并设置SPI模式 spi-mode SPI_MODE_0; // 强制设置为Mode 0覆盖设备树设置如果需要 spi-bits_per_word 8; // 设置数据位宽为8位 ret spi_setup(spi); if (ret 0) { dev_err(spi-dev, Failed to setup SPI device\n); return ret; } // 2. 分配并初始化我们的私有设备结构体 dev devm_kzalloc(spi-dev, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev-spi spi; spi_set_drvdata(spi, dev); // 将私有数据与spi_device关联 // 3. 初始化互斥锁等 mutex_init(dev-lock); // 4. 创建设备节点字符设备 // ... 这部分代码将在下一章节详细展开 ret spi_demo_create_cdev(dev); if (ret) { dev_err(spi-dev, Failed to create char device\n); return ret; } dev_info(spi-dev, SPI demo device probed successfully at %d Hz\n, spi-max_speed_hz); return 0; }在probe函数中spi_setup(spi)是至关重要的一步。这个函数调用会最终将配置模式、频率、字长下发给硬件的SPI控制器驱动。即使设备树里配置了spi-cpol和spi-cpha你也可以在这里根据实际情况覆盖它们。务必在调用spi_setup后再进行实际的SPI传输。4. 字符设备驱动向用户空间敞开大门驱动在内核里初始化好了但用户程序还无法访问它。我们需要提供一个接口这就是字符设备驱动的工作。我们将创建一个设备文件比如/dev/spidemo用户程序可以通过标准的文件操作open,read,write,ioctl,close来与我们的SPI设备交互。4.1 定义文件操作集file_operations这是驱动与用户空间交互的协议。我们需要实现一个struct file_operations结构体并填充我们关心的回调函数。static const struct file_operations spi_demo_fops { .owner THIS_MODULE, .open spi_demo_open, .release spi_demo_release, .read spi_demo_read, .write spi_demo_write, .unlocked_ioctl spi_demo_ioctl, .llseek no_llseek, // 我们的设备不支持寻址 }; static int spi_demo_create_cdev(struct spi_demo_dev *dev) { int ret; dev_t devno; // 1. 动态申请一个主设备号或使用静态的 ret alloc_chrdev_region(devno, 0, 1, spidemo); if (ret 0) { dev_err(dev-spi-dev, Failed to allocate chrdev region\n); return ret; } dev-major MAJOR(devno); dev-minor MINOR(devno); // 2. 初始化cdev结构并将其与file_operations关联 cdev_init(dev-cdev, spi_demo_fops); dev-cdev.owner THIS_MODULE; // 3. 将cdev添加到内核 ret cdev_add(dev-cdev, devno, 1); if (ret) { dev_err(dev-spi-dev, Failed to add cdev\n); unregister_chrdev_region(devno, 1); return ret; } // 4. 在/sys/class下创建类方便udev自动创建设备节点 dev-class class_create(THIS_MODULE, spidemo); if (IS_ERR(dev-class)) { ret PTR_ERR(dev-class); cdev_del(dev-cdev); unregister_chrdev_region(devno, 1); return ret; } // 5. 在/dev下创建设备节点 device_create(dev-class, NULL, devno, NULL, spidemo); return 0; }这个过程是Linux字符设备驱动的标准流程。关键在于cdev_init将我们的操作函数集spi_demo_fops与这个具体的字符设备cdev绑定。当用户程序打开/dev/spidemo时内核就会根据这个绑定关系调用我们实现的spi_demo_open等函数。4.2 实现核心的读写操作read和write是最基本的操作。对于SPI设备write通常意味着向从设备发送数据而read意味着从从设备读取数据。由于SPI是全双工的每次传输实际上同时完成了发送和接收。但为了简单我们先实现一个只写的驱动。static ssize_t spi_demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) { struct spi_demo_dev *dev filp-private_data; ssize_t ret; u8 *tx_buf; if (count MAX_BUF_SIZE) count MAX_BUF_SIZE; tx_buf kmalloc(count, GFP_KERNEL); if (!tx_buf) return -ENOMEM; // 从用户空间拷贝数据到内核空间 if (copy_from_user(tx_buf, buf, count)) { kfree(tx_buf); return -EFAULT; } mutex_lock(dev-lock); // 执行SPI同步传输 ret spi_write(dev-spi, tx_buf, count); mutex_unlock(dev-lock); kfree(tx_buf); if (ret 0) return ret; // 返回成功写入的字节数 *f_pos count; return count; }这里有几个关键点用户空间与内核空间的数据交换 驱动运行在内核态不能直接访问用户态指针buf。必须使用copy_from_user将数据拷贝到内核空间分配的内存tx_buf中。这是一个常见的错误来源。互斥锁mutexdev-lock用于保护对SPI设备的并发访问。因为SPI总线可能被多个进程或线程共享如果不加锁两个并发的spi_write调用可能会交织在一起导致数据混乱。在驱动的probe函数中初始化这个锁至关重要。spi_write函数 这是SPI核心层提供的API用于发起一次同步写操作。它会阻塞直到传输完成。其内部会处理好片选信号的拉低和拉高、时钟的生成等硬件细节。对于74HC595我们调用spi_write发送1个字节的数据它就会在8个时钟周期后出现在输出引脚上。你可以写一个简单的用户程序发送0xFF点亮所有LED发送0x00关闭所有LED来验证驱动的基本功能。5. 深入SPI传输同步、异步与消息机制简单的spi_write和spi_read适用于大多数场景但SPI核心提供了更强大、更灵活的spi_message机制。理解它你才能处理更复杂的通信需求比如需要同时收发、或者在一次片选有效期间发送多个数据段的情况。5.1 SPI传输数据结构剖析一次完整的SPI传输可能包含多个“段”transfer这些段共享同一个片选信号即片选在一次消息传输中只动作一次。struct spi_transfer描述了一个数据段struct spi_transfer { const void *tx_buf; // 发送缓冲区指针 void *rx_buf; // 接收缓冲区指针 unsigned len; // 缓冲区长度字节数 // ... 其他字段如速度、延迟等 };如果只想发送就设置tx_bufrx_buf为NULL只想接收则反之但通常需要发送dummy数据来产生时钟全双工则两者都设置。多个spi_transfer通过struct spi_message组织起来struct spi_message { struct list_head transfers; // 挂载transfer的链表 struct spi_device *spi; // ... 状态、完成回调等字段 };5.2 实现一个完整的收发示例假设我们需要向一个SPI设备先发送一个1字节的命令0xAA然后紧接着读取4字节的状态数据。用spi_message可以这样实现static int spi_demo_command_read_status(struct spi_demo_dev *dev, u8 *status) { int ret; struct spi_message msg; struct spi_transfer xfer[2]; u8 cmd 0xAA; // 读状态命令 // 初始化message spi_message_init(msg); // 准备第一个transfer发送命令 memset(xfer[0], 0, sizeof(xfer[0])); xfer[0].tx_buf cmd; xfer[0].len 1; spi_message_add_tail(xfer[0], msg); // 准备第二个transfer接收状态需要发送dummy数据来产生时钟 memset(xfer[1], 0, sizeof(xfer[1])); xfer[1].rx_buf status; xfer[1].len 4; // 注意这里没有设置tx_buf但控制器可能需要一个dummy tx buffer。 // 更安全的做法是分配一个全0的tx_buf或者使用spi_transfer的.tx_nbits等字段。 // 对于许多控制器rx_buf非空而tx_buf为空是允许的硬件会自动发送0。 spi_message_add_tail(xfer[1], msg); mutex_lock(dev-lock); ret spi_sync(dev-spi, msg); // 同步执行 mutex_unlock(dev-lock); if (ret) { dev_err(dev-spi-dev, SPI transfer failed: %d\n, ret); } return ret; }spi_sync是同步调用会阻塞直到整个消息中的所有transfer都完成。片选信号会在spi_sync开始时被拉低在所有transfer完成后拉高。这就保证了命令和读数据是在同一次片选有效期间完成的符合很多SPI设备芯片的时序要求。5.3 异步传输与性能考量对于高吞吐量或低延迟场景可以使用spi_async。它接受一个spi_message和一个完成回调函数提交传输请求后立即返回传输完成后在中断上下文调用回调函数。这避免了进程阻塞但编程模型更复杂需要小心处理并发和内存生命周期。在我们的实验驱动中我建议先实现同步接口。稳定后可以尝试增加一个ioctl命令让用户选择使用同步还是异步模式并体会两者的差异。一个重要的经验是对于单次传输数据量很小比如几个字节的情况同步调用的开销可以接受且编程简单。当需要连续传输大量数据时异步模式或结合DMA如果控制器支持才能发挥SPI总线的最大性能。你可以通过ioctl暴露SPI控制器的配置参数如是否启用DMA、传输位宽等让用户空间程序能进行更精细的调优。6. 调试、排错与性能优化实战驱动写完了insmod加载也成功了/dev/spidemo也出现了但一执行测试程序LED没反应或者数据完全不对。别慌这是驱动开发的常态。下面是我在调试这个SPI驱动时踩过的坑和总结的方法。6.1 调试基础设施printk与内核日志printk是内核开发者的“眼睛”。在驱动的关键路径如probe,open,write,ioctl加入不同级别的打印信息。dev_dbg(dev-spi-dev, Writing %zu bytes to SPI device\n, count); // 调试信息默认可能不打印 dev_info(dev-spi-dev, Device opened by process %d\n, current-pid); // 普通信息 dev_err(dev-spi-dev, SPI transfer failed with error %d\n, ret); // 错误信息使用dmesg命令查看内核日志。可以通过echo 8 /proc/sys/kernel/printk临时提高内核日志级别确保dev_dbg的信息也能被看到。更高级的做法是使用动态调试Dynamic Debug可以在运行时通过echo file spi_demo.c p /sys/kernel/debug/dynamic_debug/control来精确控制某个源文件的打印。6.2 硬件问题排查三板斧当软件层面看起来一切正常时问题很可能出在硬件或底层配置。确认引脚复用Pin Mux 这是最隐蔽的坑。开发板的GPIO引脚通常有多种功能如普通GPIO、SPI、I2C等。你必须确保在设备树或板级初始化代码中将对应的引脚配置为SPI功能。在树莓派上我们使用的pinctrl-0 spi0_pins spi0_cs_pins就是做这个的。如果配置错误引脚可能处于高阻态或错误模式根本没有信号输出。可以通过cat /sys/kernel/debug/pinctrl/*/pingroups之类的调试接口查看引脚状态取决于具体平台。测量物理波形逻辑分析仪或示波器是硬件调试的终极武器。连接探头到SCLK、MOSI、CS引脚。运行测试程序观察CS片选信号 在传输开始时是否拉低传输结束后是否拉高是否有多余的毛刺SCLK时钟信号 频率是否符合预期1MHz是否连续是否存在畸变MOSI数据信号 数据位是否在正确的时钟边沿根据CPHA保持稳定发送的数据如0x55二进制01010101是否在波形上清晰可辨我遇到过一种情况驱动代码和设备树配置都正确但波形就是不对。最后发现是硬件连接中MISO引脚悬空而某些SPI控制器在MISO悬空时行为异常。将MISO接地或上拉后问题解决。对比已知正确的驱动 内核自带的spidev是一个极好的参考。你可以先配置并使用spidev驱动你的74HC595。如果spidev能工作而你的驱动不能那问题肯定在你的驱动代码上。你可以用spidev测试出正确的SPI模式、频率、字长等参数然后确保你的驱动配置与之完全一致。使用ioctl(SPI_IOC_RD_MODE, ...)等命令可以查询spidev的当前配置。6.3 性能分析与优化点驱动基本功能正常后可以考虑优化。传输速度 在probe或通过ioctl设置spi-max_speed_hz。但要注意这个速度受限于控制器、从设备、PCB走线三者中的最低者。过高的速度会导致数据错误。逐步提高频率并用逻辑分析仪验证波形质量。DMA的使用 对于大数据量传输使用DMA可以极大减轻CPU负担。这通常需要控制器驱动支持。在你的驱动中如果spi_transfer的len超过一定阈值例如32字节内核的SPI核心和控制器驱动可能会自动启用DMA。你可以查看控制器驱动的源码或文档来确认。减少内核-用户空间拷贝 如果传输的数据块很大且固定可以考虑实现mmap将内核缓冲区映射到用户空间实现零拷贝。但这增加了复杂性需要仔细管理内存和同步。中断与轮询 我们的驱动使用同步spi_sync它是阻塞的。对于简单的单向写入这没问题。但如果需要等待从设备的中断信号例如一个SPI接口的传感器数据就绪信号你就需要将对应的GPIO配置为中断输入并在驱动中实现中断处理程序。在中断处理中再发起SPI读取操作。这涉及到中断上下文、工作队列workqueue或线程化中断threaded IRQ等更高级的主题是驱动进阶的必经之路。7. 从实验到生产安全、稳定与可维护性一个能在实验板上跑通的驱动离能在产品中稳定运行还有距离。以下是一些需要关注的工程化细节。7.1 错误处理与资源管理内核编程必须对错误处理保持极度警惕。任何可能失败的操作内存分配、设备注册、锁获取都必须检查返回值。dev-buffer kmalloc(BUF_SIZE, GFP_KERNEL); if (!dev-buffer) { ret -ENOMEM; goto err_free_cdev; // 跳转到错误处理标签 } ... return 0; err_free_cdev: cdev_del(dev-cdev); err_unregister_region: unregister_chrdev_region(devno, 1); return ret;使用devm_系列函数如devm_kzalloc,devm_gpio_request可以自动管理资源生命周期。当设备被卸载或probe失败时这些资源会被自动释放可以减少很多goto清理代码。7.2 并发控制与锁的粒度我们用了mutex_lock来保护整个SPI传输函数。这确保了同一时间只有一个执行路径能访问SPI总线是安全的。但这也限制了性能。如果驱动需要处理多个独立的SPI设备比如多路复用器切换或者有大量并发的读/写/ioctl请求可能需要更精细的锁策略。例如用一个读/写信号量rw_semaphore来允许多个读操作并发如果SPI控制器支持但写操作独占。锁的粒度需要在安全性和性能之间权衡没有绝对的最佳实践需要根据具体场景设计。7.3 电源管理对于电池供电的设备电源管理至关重要。驱动应该支持电源管理回调比如suspend和resume。在挂起suspend时驱动应该将SPI设备置于低功耗模式如果支持并可能关闭时钟或电源域。在恢复resume时需要重新初始化设备到之前的状态。这需要设备提供相关的操作支持并在驱动中实现struct dev_pm_ops。7.4 代码风格与文档最后但并非最不重要的是代码的可读性和可维护性。遵循内核的编码风格用checkpatch.pl脚本检查给函数和复杂逻辑添加清晰的注释。在驱动的开头用MODULE_DESCRIPTION和MODULE_VERSION等宏说明驱动用途和版本。良好的代码习惯会让未来的你或者接手的同事感激不尽。通过这个从零开始的SPI驱动实验我们不仅实现了一个功能设备更走完了Linux设备驱动开发的一个标准流程从硬件理解、设备树描述、驱动框架注册、字符设备接口实现到核心数据传输、调试排错和初步优化。每一个步骤中遇到的困难和解决方案都是比最终代码更宝贵的经验。下次当你再面对一个陌生的外设芯片时这套方法论将帮助你快速拆解问题找到切入点最终让它在内核的掌控下稳定运行。