1. 先把这套框架的骨架摸清楚
手上有一块跑着 4.x 内核的 i.MX6ULL 板子,要接一颗 SPI 接口的传感器,从设备树改到用户态读数据,中间被片选和时钟相位折腾了两天。折腾完回头把spi-imx.c从 probe 到中断处理逐行捋了一遍,才发现 i.MX6ULL 的 SPI 主机驱动框架并不复杂,难的是它跟内核 SPI 子系统之间那层约定——你少填一个钩子、少配一个时钟名,框架不会报错,只会安静地把数据传成乱码。这篇就围绕着这套 SPI 主机驱动框架讲,从我实际调试的顺序出发,把分层结构、寄存器配置、分频计算、PIO 与 DMA 两条通路、设备树对接、以及那些只有踩过才知道的坑,一条条摊开。
内容适合两类人看:一类是刚接手 i.MX6ULL 平台、要对接 SPI 外设的嵌入式软件工程师,看完能知道一个字节从应用层ioctl出发,到最后落到ECSPI_CONREG寄存器上到底经过了哪些函数;另一类是已经能跑通外设、但遇到速率上不去、DMA 不生效、多从机抢片选这类问题想深挖的开发者。文中涉及的分频计算、传输模式配置、排查表都可以直接拿去套,代码片段以思路说明为主,具体行号以你手上内核版本为准。
2. 这套框架到底分了几层,为什么这么分
2.1 从一次 read 到寄存器,四层各管什么
内核里的 SPI 子系统是典型的"核心层兜底、主机驱动干活、协议驱动对业务"的三段式,加上最上面的字符设备或具体设备驱动,一共四层。最底下是主机控制器驱动,在 i.MX6ULL 上就是drivers/spi/spi-imx.c,它只关心一件事:怎么把一段 buffer 变成 eCSPI 控制器能认识的 FIFO 写入序列,以及怎么把收到的数据捞回来。往上一层是SPI 核心层drivers/spi/spi.c,负责消息排队、片选拉放、传输拆分、速率上限裁剪这些公共逻辑。再往上是协议驱动,比如 spidev、某个传感器的驱动,它们不碰寄存器,只组织spi_transfer链表。最上面才是应用层的read/write/ioctl。
这个分层的价值在于,换一颗主控,协议驱动一行不用改;换一个外设,主机驱动一行不用改。我第一次看这套结构时觉得绕,一个字节要经过spi_sync→__spi_sync→ 队列 → 工作线程 →transfer_one→ 中断,五六次跳转,比裸机直接写寄存器麻烦太多。但真到了要同时挂三颗 SPI 设备、各自速率和模式都不一样的时候,就明白这层抽象省了多少事——你不需要自己在业务代码里算"现在该拉哪个片选、上一个传输结束了没有"。
2.2 core 层替主机驱动兜了哪些底
很多人写主机驱动时会重复实现一堆东西,其实核心层已经做完了。消息拆分:一个spi_message里的多个spi_transfer可能速率不同、位宽不同,核心层会把它们逐个下发,主机驱动每次只需要处理一段连续的传输。片选管理:如果控制器没有硬件片选或者你想用 GPIO 片选,核心层通过cs_gpiod帮你拉高拉低,主机驱动只要实现set_cs回调或者干脆交给框架。速率裁剪:你申请 50MHz,控制器最高只支持 20MHz,核心层会调用spi_controller->max_speed_hz做限制,不会让非法值落到寄存器里。
还有一个容易被忽略的点:传输串行化。SPI 总线是共享的,同一时刻只能有一个消息在跑。核心层用spi_controller->queue和busy标志保证这一点,主机驱动不需要自己加锁。我在早期一版自己写的驱动里就没搞明白这一点,两个线程同时spi_sync,片选互相打断,数据错得毫无规律,查了两天才定位到是并发问题——后来改成标准框架,问题自然消失。
2.3 主机驱动必须填的那几个钩子
struct spi_controller(老内核里叫spi_master,4.20 之后统一改名,用宏做了兼容)是一张巨大的函数指针表,但真正必须实现的没几个。对 i.MX6ULL 这种支持硬件片选、支持 FIFO 中断的控制器,核心是三个:transfer_one或者transfer_one_message二选一,前者处理单个 transfer,后者接管整条消息;prepare_message做传输前的模式配置;set_cs管片选。再往下还有setup用于初始化,max_transfer_size、min_speed_hz、max_speed_hz、mode_bits、num_chipselect这些描述性字段。
mode_bits特别关键,它声明控制器支持哪些 SPI 模式(CPOL/CPHA 组合)、是否支持 LSB first、是否支持 3-wire。协议驱动在spi_setup阶段会拿设备的mode跟它比对,不支持的组合直接返回-EINVAL。我见过有人设备树里写了spi-cpha但驱动mode_bits里没声明SPI_CPHA,结果 probe 成功、传输静默失败,日志里只有一行不起眼的错误。这种问题在框架层面是"合法拒绝",但在使用者视角就是"什么都没发生",非常难查。
3. i.MX6ULL 的 SPI 硬件底子,决定了驱动该怎么写
3.1 eCSPI 的 FIFO、突发长度和位宽约束
i.MX6ULL 上叫 eCSPI,片上通常有 4 个实例,每个实例带一对 64×32bit 的 TX/RX FIFO。这个 FIFO 深度直接决定了驱动的传输策略:数据量小的时候走 PIO,一个中断服务程序往里灌;数据量大或者要求低 CPU 占用的时候走 DMA,让 DMA 控制器直接搬。FIFO 是 32 位宽,但 SPI 传输可能是 8 位、16 位、32 位,所以驱动里要处理"一个 FIFO 字里塞几个 SPI 帧"的对齐问题。
寄存器里有个BURST_LENGTH字段,控制一次突发传输的位数,必须是 8 的倍数。这个值配小了,FIFO 利用率上不去,中断次数变多;配大了,超过实际传输长度会触发额外时钟。i.MX6ULL 的驱动在spi_imx_config里会根据当前 transfer 的长度动态调整,但对于长度不是 8 倍数的情况要做特殊处理,这也是为什么你在抓波形时会看到最后一个字节后面跟着几个多余的时钟沿——不是驱动写错了,是硬件突发长度对齐的结果。
3.2 时钟分频:60MHz 的 per_clk 怎么切出 1MHz
这是整个驱动里最需要算清楚的地方。eCSPI 的时钟来自 CCM 的per_clk(不同板子配置不同,常见是 60MHz,用cat /sys/kernel/debug/clk/clk_summary能确认),进控制器后经过两级分频:PRE_DIVIDER(0~15,实际分频系数是值加一)和POST_DIVIDER(0~15,实际分频系数是 2 的幂)。最终 SCLK 频率是:
sclk = per_clk / (PRE_DIVIDER + 1) / (2 ^ POST_DIVIDER)拿 60MHz 切 1MHz 举例:需要(pre+1) × 2^post = 60。post 取 0 时 pre+1 要等于 60,超过 15 的上限,不行;post 取 1 时 pre+1 要等于 30,还是超;post 取 2 时 pre+1 等于 15,正好在范围内,所以PRE_DIVIDER = 14、POST_DIVIDER = 2,得到精确的 1MHz。再试试 8MHz:(pre+1) × 2^post = 7.5,除不尽,驱动会选择不超过目标频率的最大组合,实际落到 7.5MHz 左右,误差 6%。这个误差在很多外设上无所谓,但对时序敏感的器件(比如某些 ADC 的转换时钟)就可能超规格,必须手动核对。
驱动里的spi_imx_set_clk就是干这个的:拿到spi_transfer->speed_hz,跟当前配置比较,变了才重新计算,避免每次传输都写寄存器。计算过程是遍历 pre 和 post 的组合,挑实际频率最接近且不高于目标的那组。这里有个实操经验:如果你发现实际波形频率比设备树里写的低很多,先别怀疑驱动,去查per_clk是不是被别的驱动改了。i.MX6ULL 的时钟树是共享的,某个外设调低了自己的时钟源,你的 SPI 分频基准跟着变,算出来的结果自然对不上。
3.3 片选、极性和相位在寄存器里长什么样
SPI 的四种模式(CPOL/CPHA 组合)在 eCSPI 里由CONFIGREG的两个位控制:SCLK_POL决定空闲电平,SCLK_PHA决定采样沿。这两个位必须在每次传输开始前按当前设备的mode配置好,因为同一条总线上可能挂着模式不同的设备。驱动的spi_imx_setupxfer里就有这段逻辑,判断spi->mode跟缓存值不一致才写寄存器。
片选的情况稍微复杂。eCSPI 支持 4 个硬件片选(SS0~SS3),通过CONREG的CHANNEL_SELECT字段选,但很多板子出于布线方便会把片选接到普通 GPIO 上。设备树里cs-gpios属性一写,核心层就会走 GPIO 片选路径,主机的set_cs回调不再被调用。两种方式各有优劣:硬件片选时序精准、CPU 开销低,但引脚固定;GPIO 片选灵活,代价是多一次 GPIO 翻转的延迟,在高速传输时这点延迟可能吃掉片选建立时间。我做高速传输时优先用硬件片选,实在错不开才用 GPIO,并且会在设备树里显式注明。
4. 源码走读:spi-imx.c 里值得反复看的几段
4.1 探测流程与 devtype_data 的作用
spi_imx_probe的骨架很标准:拿 platform_device 的资源、ioremap 寄存器、取时钟、申请中断、注册控制器。值得看的是spi_imx_devtype_data这个结构,它把不同 SoC 之间的差异收敛成一张表。i.MX6ULL 用的匹配项是fsl,imx6ul-ecspi,对应的数据里会标明 FIFO 深度、是否支持 DMA、是否支持 slave 模式、片选数量这些。
static const struct spi_imx_devtype_data imx6ul_ecspi_devtype_data = { .has_dmamode = true, .has_slavemode = true, .devtype = IMX6UL_ECSPI, .fifo_size = 64, .native_cs = true, /* 其余字段随内核版本略有差异 */ };这种设计的好处是,新增一款芯片只要加一条表项和几个分支,不用复制整份驱动。坏处是读代码时容易迷路——你在spi_imx_trigger里看到一个分支判断,得跳到表里对照才知道哪颗芯片会走到这里。我的建议是读的时候先只盯IMX6UL_ECSPI相关的分支,其他的先跳过,等主线理清了再回头看差异。
探测阶段还有两件事要留意。一是时钟名必须对上,设备树里clock-names = "ipg", "per",驱动里按名字取,写错一个字母就是-ENOENT,probe 直接失败;二是DMA 通道是可选的,设备树里没写dmas属性,驱动会退化成纯 PIO 模式,功能正常但 CPU 占用高,很多人以为 DMA 没配好是 bug,其实是自己没在设备树里声明。
4.2 setupxfer:每次传输前的寄存器重配
spi_imx_setupxfer是每次transfer_one都会被调用的函数,它的逻辑很朴素:比较当前 transfer 的speed_hz和bits_per_word跟驱动缓存的值,不一样就重新配置。这里有个细节容易被忽略——缓存的是"上一次配置成功的值",不是"寄存器当前的值"。如果中间有人直接改寄存器(比如调试时用 devmem 写了一把),缓存就跟实际脱节了,后续传输会带着错误的配置跑。
配置过程分两步:先算分频写CONREG,再配模式写CONFIGREG。写寄存器时驱动会读-改-写,不是直接赋值,因为CONREG里的使能位、突发长度字段要保留。这种写法在并发场景下是安全的,因为整个传输流程在核心层是串行的,不存在两个 transfer 同时改寄存器的情况。
4.3 PIO 与 DMA 两条路径怎么选
spi_imx_transfer_one最后会落到spi_imx->devtype_data->trigger,这个函数指针指向两个实现之一:spi_imx_pio_trigger或spi_imx_dma_trigger。选择逻辑大致是:transfer 长度超过某个阈值(驱动里通常跟 FIFO 深度挂钩)、DMA 通道已申请成功、当前不在原子上下文,三个条件都满足才走 DMA。
PIO 路径靠中断驱动,每写完 FIFO 触发一次中断,中断里继续灌数据,直到全部发完。这种方式实现简单,但传输 4KB 数据要进几十次中断,CPU 占用明显。DMA 路径把 buffer 地址和长度交给 DMA 控制器,SPI 控制器每发完一个字就向 DMA 要一个,全程几乎不占 CPU。实测下来,传 1KB 数据,PIO 方式 CPU 占用能到 30% 以上,DMA 方式降到 3% 以下,差距非常明显。
/* 简化示意,真实实现见 spi-imx.c 的触发与中断处理 */ if (spi_imx->usedma) { /* 配 DMA 地址、长度,启动通道 */ spi_imx_dma_transfer(spi_imx, transfer); } else { /* 先填满 FIFO,剩下的靠中断续 */ spi_imx_pio_transfer(spi_imx, transfer); }有个坑要提前说:DMA 模式和 PIO 模式对 buffer 的对齐要求不同。DMA 通常要求源地址和目的地址按字对齐,如果你从用户态传一个未对齐的 buffer 下来,驱动可能走-EINVAL拒绝,也可能悄悄降级成 PIO。遇到"大 buffer 反而比小 buffer 慢"的现象,先查 buffer 是不是从kmalloc出来的对齐内存。
5. 实操:从设备树到示波器,把一条 SPI 通路打通
5.1 设备树与引脚复用
先看控制器节点,i.MX6ULL 的 dtsi 里已经有了,板级 dts 只需要使能并补引脚:
&ecspi1 { pinctrl-names = "default"; pinctrl-0 = <&pinctrl_ecspi1>; cs-gpios = <&gpio4 26 GPIO_ACTIVE_LOW>; status = "okay"; spi_test: spi@0 { compatible = "spidev"; reg = <0>; spi-max-frequency = <1000000>; spi-cpol; spi-cpha; }; };引脚复用组的写法取决于你板子的实际布线,下面这段只是示例,具体引脚名一定要对着原理图和芯片数据手册的 pinmux 表核:
&iomuxc { pinctrl_ecspi1: ecspi1grp { fsl,pins = < MX6UL_PAD_UART4_TX_DATA__ECSPI1_SCLK 0x10b0 MX6UL_PAD_UART4_RX_DATA__ECSPI1_MOSI 0x10b0 MX6UL_PAD_UART5_TX_DATA__ECSPI1_MISO 0x10b0 >; }; };0x10b0这个电气属性值不是随便写的,它编码了驱动强度、上下拉、转换速率等参数。低速短距离走线用默认值基本没问题,但如果你的 SCLK 要跑到十几兆,走线又比较长,就要把驱动强度调高、速率档位调高,否则波形上升沿会塌。我遇到过一次 10MHz 时钟下 MOSI 数据错位,逻辑分析仪上看时钟很干净,数据沿却明显圆滑,最后就是把这个属性值从 0x10b0 调成更强的档位解决的。
5.2 内核配置与启动日志检查
配置上要确认几个选项:CONFIG_SPI、CONFIG_SPI_IMX、CONFIG_SPI_SPIDEV,如果要走 DMA 还得有CONFIG_SPI_IMX_USE_DMA(不同版本名字略有差异)和对应的 DMA 控制器驱动。启动后先看两处:
dmesg | grep -i spi ls /sys/class/spi_master/ ls /dev/spidev*正常的日志会打印控制器注册成功的地址和中断号。如果没有/dev/spidev*,先查spidev有没有被编译进去,再查设备树里那个子节点是不是status = "okay"——父节点使能了不等于子节点使能,我在这上面浪费过半小时。还有一种情况是compatible = "spidev"在你这个内核版本的 spidev 驱动里没有对应匹配项,那就得换成你注册的协议驱动或者自己加一条匹配。
5.3 用户态验证与波形对照
最快验证通路的方式是用 spidev 发几个字节,同时拿逻辑分析仪挂上 SCLK、MOSI、CS 三根线:
#include <linux/spi/spidev.h> #include <sys/ioctl.h> int fd = open("/dev/spidev1.0", O_RDWR); uint8_t tx[] = {0x9F, 0x00, 0x00}; uint8_t rx[3] = {0}; struct spi_ioc_transfer tr = { .tx_buf = (unsigned long)tx, .rx_buf = (unsigned long)rx, .len = sizeof(tx), .speed_hz = 1000000, .bits_per_word = 8, .cs_change = 0, }; ioctl(fd, SPI_IOC_MESSAGE(1), &tr);抓波形时对照四件事:片选拉低到第一个时钟沿之间的建立时间够不够、时钟频率是不是 1MHz(量周期,1μs 一格)、数据在哪个沿变化哪个沿采样(跟 CPOL/CPHA 对得上吗)、最后一个时钟沿之后片选多久拉高。这四项全对上,硬件通路就算通了;有一项不对,数据必然出错,而且错得有规律——相位反了表现为每个字节移位,极性反了表现为首字节丢失。
6. 调试现场:这些现象对应哪些原因
6.1 常见故障现象对照表
| 现象 | 常见原因 | 快速验证方式 |
|---|---|---|
无/dev/spidev* | 子节点未使能、compatible 无匹配、spidev 未编译 | 查 dts 节点 status,dmesg看 probe 日志 |
| 时钟无输出 | 控制器未使能、pinctrl 配错、时钟名不匹配 | 读CONREG的 EN 位,示波器量引脚 |
| 有时钟无数据 | MOSI 引脚复用错、TX FIFO 未填 | 量 MOSI 是否有翻转 |
| 数据全 0 或全 FF | MISO 未接、从机未响应、片选未生效 | 换从机、手动拉片选验证 |
| 数据整体移位一位 | CPHA 配置反了 | 改设备树spi-cpha重测 |
| 首字节丢失 | CPOL 配置反了、片选建立时间不足 | 改spi-cpol,加延时 |
| 大 buffer 传输超时 | DMA 通道申请失败、buffer 未对齐 | 关 DMA 对比,查dmesg |
| 多从机互相干扰 | 片选管理冲突、总线未释放 | 查cs-gpios数量和reg值 |
这张表是我自己调试时整理的,实际遇到的现象可能更隐蔽。比如"数据全 FF"有时候不是 MISO 没接,而是从机的片选一直没被拉低,它压根没驱动总线,上拉电阻把线拉到了高电平。判断方法是看 CS 线有没有动作,别看数据线。
6.2 几个只有踩过才知道的细节
第一个坑:spi_sync不能在原子上下文调用。它内部会睡眠等待传输完成,如果你在中断处理函数或者持有自旋锁的代码里调它,内核直接给你一个BUG: scheduling while atomic。要在这个场景下发 SPI,得用spi_async加回调,或者把工作丢到工作队列里。
第二个坑:传输长度的上限。spi_controller->max_transfer_size如果没实现,核心层会按默认值(通常是 4KB 左右)拆分你的大传输,拆出来的每一段单独拉片选。如果你的外设要求一次连续片选内传完整个命令帧,就必须在主机驱动里正确实现这个回调,返回真实上限,否则拆出来的帧从机不认。
第三个坑:cs_change的语义。这个字段控制传输之间片选是否保持。置 1 表示当前 transfer 结束后释放片选,下一个 transfer 重新拉。很多传感器驱动需要在"写命令"和"读数据"之间保持片选不释放,这时候cs_change必须是 0,而且两个 transfer 要放在同一个spi_message里。我见过有人把它们拆成两次spi_sync,片选中间释放了一下,从机状态机直接复位,读回来全是无效值。
第四个坑:频率设置的粒度。前面算过,分频是离散的,你写 8MHz 实际可能是 7.5MHz。有些外设对时钟频率有上限要求(比如最大 7.5MHz),你按 8MHz 配,实际跑 7.5MHz 侥幸没事;换个板子 per_clk 变成 66MHz,同样的分频算出来就是 8.25MHz,超了。所以关键参数要按实际频率核算,不能只看设备树里写的数字,最好在驱动里打印一行实际配置的频率。
第五个坑:DMA 与 cache 一致性。如果 buffer 在带 cache 的内存里,DMA 搬完之后 CPU 读到的可能是旧数据。内核的 DMA API 会处理这件事,但前提是你用的是dma_map_single这类标准接口,而不是自己算物理地址。自己写驱动时图省事直接virt_to_phys,在 i.MX6ULL 这种带 cache 的核上迟早出问题,而且是那种"跑一天偶尔错一次"的问题,极难复现。
7. 想把这套框架用顺手,还可以关注这几点
7.1 多从机共存时片选怎么管
一条 SPI 总线上挂多个设备是常态。核心层按reg属性给每个设备分配片选号,主机驱动的num_chipselect决定上限。用硬件片选时,注意 eCSPI 的 4 个片选是复用的,某些引脚组合下只能用 SS0,具体看 pinmux 表。用 GPIO 片选时,cs-gpios数组的顺序必须跟子节点reg的编号严格对应,写反了就是"拉 A 的片选、B 应答",数据当然对不上。
还有一个容易忽略的点:片选之间的空闲时间。部分外设要求片选拉高后至少保持若干微秒才能再次拉低,核心层提供了spi_delay和delay_usecs两种机制,前者在传输之间插延时,后者在片选变化后插延时。选错了不起作用,还以为是外设不听话。
7.2 中断处理里那些必须做的事
i.MX6ULL 的 eCSPI 中断服务程序要处理好几类事件:TX FIFO 空、RX FIFO 有数据、RX FIFO 溢出、传输完成。写驱动时最容易漏的是溢出处理——RX FIFO 满了没及时读,硬件会丢数据并且置位错误标志,如果你不清这个标志,后续传输全部异常。查这种问题的方法是看中断状态寄存器的错误位,别只看数据对不对。
另外,中断里能做的事要尽量少。我见过有人在中断里直接处理协议解析,结果一次传输耗时从几十微秒涨到几百微秒,高速传输时直接超时。正确做法是中断里只搬运数据、清标志,业务逻辑丢到线程里去。
7.3 从"能跑"到"跑得稳"的几步
通路打通只是第一步,要真正稳定还得做几件事。加错误重试:SPI 没有硬件应答机制,传输出错只能靠上层校验发现,重试逻辑要写在自己的协议驱动里。限制并发:虽然核心层做了串行化,但如果你的应用层多个线程各自持有独立的 fd 去操作同一个设备,行为依然不可控,最好在应用层加锁。留调试开关:在驱动里加一个模块参数控制是否打印每次传输的长度、速率、模式,出问题时echo 1 > /sys/module/.../parameters/debug就能打开,比重新编译内核快得多。
我自己在实际项目里的体会是,i.MX6ULL 这套 SPI 框架本身足够成熟,九成以上的问题出在设备树配置和硬件通路上,剩下不到一成才是驱动逻辑。所以调试顺序应该是:先确认引脚复用和片选电平,再确认时钟频率和模式,最后才去怀疑代码。把示波器或逻辑分析仪接上,比盯着代码猜要快十倍。另外一个小技巧:如果你手头没有逻辑分析仪,可以用另一个 GPIO 在传输前后翻转,配合示波器看时序大致对不对,虽然粗糙,但排查"到底有没有发出去"这类问题足够用了。