Linux内核与驱动:14.SPI子系统

在现代嵌入式系统开发中,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" 控制其复位。

所以我们撰写一个复位函数:

相关推荐
福大大架构师每日一题1 小时前
openclaw v2026.4.24 发布:Google Meet 深度集成、DeepSeek V4 上线、浏览器自动化与插件架构全面升级
运维·架构·自动化·openclaw
Gary Studio1 小时前
安卓HAL C++基础-智能指针
开发语言·c++
还是阿落呀2 小时前
基本控制结构2
c++
yipiantian2 小时前
在Claude项目中实现跨目录访问Skills
linux·运维·服务器
多思考少编码2 小时前
PAT甲级真题1001 - 1005题详细题解(C++)(个人题解)
c++·python·最短路·pat·算法竞赛
Agent产品评测局2 小时前
生产排期与MES/ERP系统打通,实操方法详解 —— 2026企业级智能体自动化选型与实战指南
java·运维·人工智能·ai·chatgpt·自动化
cen__y2 小时前
Linux07(信号01)
linux·运维·服务器·c语言·开发语言
MT5开发2 小时前
Linux安装MariaDB
linux·运维·mariadb
Lentou3 小时前
日志轮询策略
linux·服务器·网络