
在现代嵌入式系统开发中,SPI(Serial Peripheral Interface)总线因其全双工、高速率的特性,成为了连接各类外设(如 CAN 控制器、OLED 屏幕、各类传感器)的绝对主力。
然而,面对复杂的 SoC 内部资源和多任务的操作系统,如果让每一个外设驱动都直接去操作 CPU 物理底层的寄存器,代码将变得极度臃肿且难以移植。为了解决这个问题,Linux 内核引入了 SPI 子系统。它完美贯彻了"总线-设备-驱动"的分层隔离模型,将繁杂的硬件时序与纯粹的业务逻辑彻底解耦。
一个典型的 SPI 总线包含以下四根信号线:
| 信号线 | 全称 | 说明 |
|---|---|---|
| SCLK | Serial Clock | 由主设备产生的同步时钟信号 |
| MOSI | Master Output, Slave Input | 主设备输出、从设备输入 |
| MISO | Master Input, Slave Output | 主设备输入、从设备输出 |
| CS | Chip Select(又称 SS) | 片选信号,由主设备控制,用于选中特定从设备 |
1.Linux SPI子系统分层架构
Linux SPI 子系统采用典型的三层架构,其设计哲学与 I2C 子系统相似------通过分层和抽象实现控制器驱动与外设驱动的解耦。这样做的优点是:同一个 SPI 外设可以无缝搭配不同厂商的 SPI 控制器,反之亦然。

我们这篇博客只关注SPI设备驱动层:
SPI 设备驱动层是普通驱动开发者日常打交道最多的一层。它基于 SPI 总线设备驱动模型实现,spi_device 来自设备树(由 SPI 控制器驱动解析生成),spi_driver 则由开发者编写。
设备驱动层的具体工作包括:定义设备匹配信息、实现 probe/remove 函数、调用核心层 API 与硬件通信、注册更高层的内核子系统接口(如 IIO、input、MTD 等)。
2.SPI 子系统的核心数据结构
(1)struct spi_controller(struct spi_master)
spi_controller 用于描述一个物理 SPI 控制器(硬件上的 SPI 外设)。在新版内核中 spi_master 是它的别名。
cpp
struct spi_controller {
struct device dev;
struct list_head list;
s16 bus_num; // 总线编号,如 spi0 对应 bus_num=0
u16 num_chipselect; // 片选数量
u16 mode_bits; // 支持的模式掩码(CPOL/CPHA/CS_HIGH...)
u32 max_speed_hz; // 最大通信频率
u32 min_speed_hz; // 最小通信频率
int (*transfer)(struct spi_device *spi, struct spi_message *mesg);
int (*transfer_one)(struct spi_controller *ctlr,
struct spi_device *spi,
struct spi_transfer *transfer);
// ...
};
通常由芯片厂商的 BSP 工程师维护,设备驱动开发者只需要通过上一层 API 间接使用它,一般不需要直接接触。
(2)struct spi_device
spi_device 代表挂载在 SPI 总线上的一个具体从设备,由内核解析设备树中的 SPI 子节点时自动创建。在设备驱动的 probe 函数中它会作为参数传入。
cpp
struct spi_device {
struct device dev;
struct spi_controller *controller;
u32 max_speed_hz; // 该设备的最大通信速率
u8 chip_select; // 片选号(CS0, CS1...)
u8 bits_per_word; // 字长(通常为 8)
u16 mode; // 工作模式(CPOL/CPHA/CS_HIGH 等)
int irq; // 中断号
char modalias[SPI_NAME_SIZE]; // 驱动匹配名称
// ...
};
开发者最需要关注的是其中的 mode、max_speed_hz 和 chip_select。设备树中的 spi-max-frequency 属性会填充到 max_speed_hz 字段,reg 属性则表示使用的是第几路 CS。
(3)struct spi_driver
spi_driver是驱动程序必须注册到系统中的桥梁,结构与标准的 platform_driver 相似。
cpp
static const struct of_device_id dac_of_match[] = {
{ .compatible = "mycompany,spi-dac" },
{ }
};
static struct spi_driver dac_driver = {
.driver = {
.name = "dac",
.of_match_table = dac_of_match,
},
.probe = dac_probe,
.remove = dac_remove,
};
module_spi_driver(dac_driver);
compatible 属性用于与设备树中的 SPI 设备节点进行匹配,匹配成功后 probe 函数就会被调用。
(4) spi_transfer 与 spi_message
这是 SPI 子系统中最贴近硬件传输机制的两个核心结构体。
最小搬运单元: spi_transfer
SPI 的硬件特性是全双工:你在发送数据的同时,硬件时钟也必然会"踩"回同等长度的数据。因此,每一个 spi_transfer 都包含了:
- tx_buf:发送缓冲区(如果没有要发的数据,就发 dummy 字节)。
- rx_buf:接收缓冲区(如果只发不收,可以忽略收到的数据)。
- len:这一次传输的字节长度。
完整的业务会话: spi_message
一个 spi_message 是一个链表,它可以挂载多个 spi_transfer。 为什么要有 Message?核心原因是为了控制片选(CS)引脚的生命周期!
在一次完整的 spi_message 传输期间,SPI 的 CS 引脚会被持续拉低(激活状态)。 假设你需要向外设的 0X12 地址读取 2 个字节:
-
你不能分为两次独立的传输(发地址拉低拉高一次,读数据拉低拉高一次),外设状态机会错乱。
-
正确的做法: 组装两个 spi_transfer(一个装地址,一个装接收容器),将它们按顺序挂进同一个 spi_message 中。内核执行时,会拉低 CS -> 发地址 -> 读数据 -> 拉高 CS,一气呵成。
3. SPI 数据传输API
SPI 核心层为上层设备驱动提供了丰富的 API,全部位于 include/linux/spi/spi.h 中。
3.1简易读写函数
| 函数 | 说明 |
|---|---|
spi_write(spi, buf, len) |
同步写入 len 字节数据 |
spi_read(spi, buf, len) |
同步读取 len 字节数据 |
spi_write_then_read(spi, txbuf, n_tx, rxbuf, n_rx) |
先写后读,适合少量数据 |
上述函数都是同步传输数据,调用的都是 spi_sync 。
3.2 通用的消息传输函数
对于需要更精细控制的传输,可以自己构建 spi_transfer 和 spi_message,内核提供了两种完全不同的提交方式:spi_sync(同步) 和 spi_async(异步)。
1. spi_sync:同步阻塞传输(最常用)
spi_sync 是驱动开发中最常用的 API。顾名思义,它是"同步"的。
工作机制: 当你的驱动代码调用 spi_sync 时,当前执行这段代码的线程会立刻进入睡眠状态(Blocked)。它交出 CPU 的使用权,直到底层的 SPI 硬件把数据老老实实全部发完,并且接收完数据后,这个线程才会被内核唤醒,继续执行下一行代码。
优点:
代码逻辑极度清晰: 线性执行,就像平铺直叙的文章。函数只要返回了,就意味着数据绝对已经在 rx_buf 里准备好了,可以直接拿来用。
内存管理简单: 你的传输缓冲区(tx_buf / rx_buf)可以直接定义在栈上(局部变量),因为函数没结束前,栈内存绝对安全。
**致命限制(使用禁忌):**绝对不能在中断上下文(ISR / 自旋锁)中调用! 因为在 Linux 内核中,中断处理程序是不能睡眠的。如果违规调用,会导致系统直接死机崩溃(Kernel Panic)。
2.spi_async:异步非阻塞传输
spi_async 专为高性能和特殊上下文设计。它是"异步"的,也是"非阻塞"的。
**工作机制:**当调用 spi_async 时,内核的核心层只是把你的 spi_message 丢进一个待发送队列就立刻返回了。你的线程不会睡眠,而是会立刻执行下一行代码。
那什么时候数据发完呢?你需要提前在 spi_message 里注册一个回调函数(msg.complete)。当底层硬件传输完毕触发中断时,内核会在中断或软中断上下文里自动调用你的回调函数。
4.通用SPI外设代码框架
假设我们要写mcp2515的驱动程序,已知mcp2515连接RK3568的SPI接口,我们首先要撰写设备树,设备树的撰写在上一节中已经写过了,在此不再赘述:Linux内核与驱动:GPIO设备树与SPI设备树的区别-CSDN博客
最简单的驱动框架如下:

5.对接用户空间
问:是不是几乎所有的驱动程序都需要在probe中写字符设备/块设备/网络设备?
答案是:绝对不是。
你之所以会有"几乎所有驱动都要注册这三类设备"的错觉,是因为作为应用层(C/C++)开发者,你平时能接触到的、能用来写业务逻辑的接口,全都是这三类设备。
实际上,在庞大的 Linux 内核源码中,有超过一半的驱动程序,在它们的 probe 函数里根本不注册字符设备、块设备或网络设备。
为了理解这一点,我们需要引入 Linux 内核中一个非常核心的思想:"服务对象(Customer)"的区别。
Linux 中的设备驱动分为两大阵营:面向用户空间的驱动 和 面向内核空间的驱动。
所以,只有面向用户空间的驱动程序才需要写为字符设备/块设备/网络设备。
我们在上述的基础上,继续写mcp2515对用户空间的接口,创建字符设备:
cpp
//the name/compatible = "my-mcp2515"
#include <linux/init.h>
#include <linux/module.h>
#include <linux/spi/spi.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/kdev_t.h>
dev_t dev_num;
struct cdev mcp2515_dev;
struct class* mcp2515_class;
struct device* mcp2515_device;
int mcp2515_open (struct inode *, struct file *)
{
return 0;
}
ssize_t mcp2515_read (struct file *, char __user *, size_t, loff_t *)
{
return 0;
}
size_t mcp2515_write(struct file *, const char __user *, size_t, loff_t *)
{
return 0;
}
int mcp2515_release (struct inode *, struct file *)
{
return 0;
}
struct file_operations mcp2515_fops = {
.open = mcp2515_open,
.read = mcp2515_read,
.write = mcp2515_write,
.release = mcp2515_release,
};
int mcp2515_probe(struct spi_device *spi)
{
int ret;
ret = alloc_chrdev_region(&dev_num,0,1,"mcp2515");
if(ret < 0){
printk("alloc dev_num failed\n");
return -1;
}
cdev_init(&mcp2515_dev,&mcp2515_fops);
mcp2515_dev.owner = THIS_MODULE;
ret = cdev_add(&mcp2515_dev,dev_num,1);
if(ret < 0)
{
printk("cdev_add failed\n");
return -1;
}
mcp2515_class = class_create(THIS_MODULE,"spi_to_can");
if(IS_ERR(mcp2515_class))
{
printk("class create failed\n");
return PTR_ERR(mcp2515_class);
}
mcp2515_device = device_create(mcp2515_class,NULL,dev_num,NULL,"mcp2515");
if(IS_ERR(mcp2515_device))
{
printk("class create failed\n");
return PTR_ERR(mcp2515_device);
}
return 0;
}
int mcp2515_remove(struct spi_device *spi)
{
return 0;
}
const struct of_device_id mcp2515_of_match_table[] = {
{.compatible = "my-mcp2515"},
{}
};
struct spi_driver spi_mcp2515 = {
.probe = mcp2515_probe,
.remove = mcp2515_remove,
.driver = {
.name = "mcp2515",
.owner = THIS_MODULE,
.of_match_table = mcp2515_of_match_table,
}
};
static int __init mcp2515_init(void)
{
int ret;
ret = spi_register_driver(&spi_mcp2515);
if(ret < 0)
{
printk("spi_register_driver failed\n");
return ret;
}
return 0;
}
static void __exit mcp2515_exit(void)
{
device_destory(mcp2515_device);
class_destory(mcp2515_class);
cdev_del(&mcp2515_dev);
unregister_chrdev_region(dev_num,1);
spi_unregister_driver(&spi_mcp2515);
}
module_init(mcp2515_init);
module_exit(mcp2515_exit);
MODULE_LICENSE("GPL");
这样用户空间就可以通过 /dev/mcp2515 节点访问mcp2515了。
6.编写mcp2515驱动:复位函数

由mcp2515的手册可知,MCP2515在正常运行之前必须进行初始化,只有在配置模式下才能进行初始化,所以我们需要让mcp2515进入配置模式,在上电或复位时,器件会自动进入配置模式。

由表可知,向mcp2515发送指令 "1100 0000" 控制其复位。
所以我们撰写一个复位函数:
