做SPI调试的人大概都有过这种体验代码写得一丝不苟设备树配得分毫不差结果上板一跑读回来的数据全是0xFF示波器一挂上去四根线安安静静连个毛刺都没有。我第一次在一套移动平台上接一颗SPI接口的传感器时就栽在这上面。当时以为是驱动写错了翻来覆去改了几十遍最后发现问题出在一个谁都懒得看的属性上——模式配反了主机在上升沿采样从机在下降沿吐数据两边各说各话。SPI这个总线接口简单到四根线就能跑起来但真正让它稳定工作的细节全都藏在时序契约、时钟树、DMA通道和引脚复用这些地方。这篇就把MTK平台上SPI驱动开发绕不开的基础知识摊开讲一遍从协议层面的四种模式到控制器硬件资源怎么分配再到Linux SPI子系统那一层层的调用路径最后落到实际写驱动、配设备树和上板排错的完整流程。不管你是刚接手驱动模块的新人还是从别的平台迁过来需要重新熟悉的老手这里面的东西都值得捋一遍。1. 四根线背后的时序契约SPI协议里必须啃透的细节1.1 全双工这三个字到底意味着什么很多人对SPI的第一印象就是四根线、速度快、比I2C简单这个印象不算错但它容易让人忽略SPI最核心的一个特征它是一个由主机时钟驱动的环形移位寄存器结构。主机和从机各自持有一个移位寄存器时钟每跳一次主机移出一位、从机移出一位同时从机移出一位到MISO、主机移入一位。也就是说只要时钟在跑数据的发送和接收就是同时发生的不存在我先发完再收这种半双工逻辑。这个特征带来一个非常实际的影响当你想从从机读一个寄存器时主机同样要发出等长的时钟周期来换回数据。这个过程中MOSI上必然会有内容可能是无效的0x00也可能是你顺手填的0xFF但从机的行为取决于它自己的协议定义——有的器件规定读操作时MOSI必须是任意值有的器件则要求特定的命令字节。我在调试时就遇到过一颗芯片读时序要求MOSI保持高电平驱动里如果填0x00它会把总线当成错误状态直接拉低MISO不响应读回来自然全是0。还有一点常被忽略SPI没有应答机制。I2C至少还有ACK/NACK告诉你从机在不在SPI完全靠你自己判断。从机没焊、焊反、供电没起来、片选没拉对读回来的数据可能都是0xFF或者0x00看起来有数据实际上什么都没发生。所以写SPI驱动时判断通信是否正常的第一条准则永远是读芯片ID而不是读业务数据。1.2 CPOL与CPHA的组合以及为什么mode 3出现得最多SPI的四种工作模式本质上就是两个参数的四组组合CPOL决定时钟空闲时的电平CPHA决定在第一个边沿还是第二个边沿采样。这两句话背起来容易理解起来需要点画面感。CPOL0时钟空闲在低电平工作起来先上升后下降。CPOL1空闲在高电平工作起来先下降后上升。CPHA0表示第一个边沿就采样CPHA1表示第一个边沿只做数据切换第二个边沿才采样。组合起来就是模式CPOLCPHA空闲电平采样边沿数据切换边沿Mode 000低上升沿下降沿Mode 101低下降沿上升沿Mode 210高下降沿上升沿Mode 311高上升沿下降沿Mode 0 和 Mode 3 在采样时序上其实是同一种先切换后采样的节奏区别只在于时钟空闲电平。之所以 Mode 3 在实际项目里出现频率最高有两个很现实的原因。一是很多传感器和存储器厂商在自己的数据手册里默认就选 Mode 3生态惯性导致大家都在用二是CMOS推挽输出在驱动能力上对高电平空闲更友好一些配合上拉电阻时时钟线在空闲态保持高电平抗干扰余量相对更大。但千万别形成默认就是Mode 3的思维定式。我在一个项目里同时挂了两个SPI设备一个是Mode 0的Flash一个是Mode 3的传感器共用一个控制器硬件同事为了省事把它们挂在同一条总线上。结果就是代码里必须每次切换设备前重新设置模式而Linux的SPI子系统恰好支持这种行为——每个 spi_device 有自己的 mode 字段控制器驱动在每次传输前会根据当前设备重配寄存器。如果控制器硬件不支持运行时切换模式那就只能分总线或者用GPIO模拟这个坑在选型阶段就该确认清楚。1.3 片选、时钟频率与那些看不见的时序余量片选线CS是SPI里唯一一根人为约定的信号。它不参与移位但它决定了从机什么时候开始监听总线。这里有几个非常容易翻车的细节。第一CS拉低到第一个时钟沿之间要有建立时间tCSS最后一个时钟沿到CS拉高之间要有保持时间tCSH。大部分芯片这几个参数在纳秒到几十纳秒级别正常走线下问题不大但如果你的控制器在CS拉低后立刻就发时钟就很可能违反了从机的建立时间要求表现就是第一个bit读错进而整包数据错位。第二多设备共享总线时的CS管理。硬件CS由控制器自己控制切换设备时控制器会自动拉高上一个、拉低下一个这部分基本不用操心。但如果用GPIO做片选cs-gpios就要注意在两次传输之间不要出现两根CS同时为低的情况否则两个从机同时驱动MISO形成总线冲突轻则数据错乱重则器件发热。第三频率不是越高越好。spi-max-frequency 这个属性写的是控制器与设备协商的上限但实际SCLK由控制器分频得到往往只能取到接近值。比如你要8MHz控制器时钟源26MHz分频后可能落在6.5MHz这在驱动里查 clk_get_rate 加上寄存器读数就能确认。如果实际频率明显低于从机要求的最小频率通信同样会失败——有些Flash要求SCLK不低于1MHz太慢反而读不出来。提示频率这一项在设备树里写的是期望值控制器驱动会向下取整到可实现的档位。调试时把实际SCLK用示波器量一下比在代码里猜要快十倍。2. MTK平台SPI控制器的资源版图时钟、DMA与引脚复用2.1 控制器数量与片选映射的真实关系移动平台的SPI控制器通常是成组设计的一颗SoC上一般有4到6组SPI控制器每组挂在自己的总线上有自己的寄存器基地址、时钟源和中断号。很多从别的平台转过来的开发者会默认认为一个控制器对应一根片选这个认知在MTK平台上需要修正。实际情况是每组控制器通常支持1到2个硬件片选输出CS0/CS1也就是说一个控制器实例在设备树里可以作为一条spi总线下面挂多个spi_device每个设备占一个片选索引。当你在设备树里写 reg 0 和 reg 1 时框架会去控制器的片选表里取对应编号如果控制器的 num_chipselect 只有1第二个设备在probe阶段就会直接失败报一个类似chipselect out of range的错误。这个错误信息在实际项目中非常容易被误判成硬件没接线我见过有人为此拆了三遍板子。正确的做法是先看控制器节点的 #address-cells 和片选数量配置再看外设节点的 reg 值。有些平台的控制器驱动在dtsi里已经把片选数量写死了你要改就得动dtsi这时候要确认改动不会影响同平台的其他项目。还有一种情况是控制器数量不够硬件同事把两个设备挂到了同一个片选上靠不同的命令字节区分。这种设计在功能上可行但要求驱动里保证两次传输之间CS一定要拉高再拉低否则从机状态机会错乱。Linux的 spi_transfer 结构体里有个 cs_change 字段专门用来控制这次传输结束后是否翻转片选默认是0表示保持置1表示传输完就放开。这个字段在处理命令数据分两段发送的场景时特别有用。2.2 时钟树的走法决定了你能跑多快SPI的SCLK不是凭空产生的它来自平台的时钟树。典型的路径是顶层时钟源比如26MHz晶振经过PLL倍频出来的一路→ 分频器 → 控制器。这中间涉及 clk 子系统的 get/enable/set_rate 三个动作。在驱动里通常是这样拿时钟的spi-clk devm_clk_get(pdev-dev, spi); if (IS_ERR(spi-clk)) return PTR_ERR(spi-clk); ret clk_prepare_enable(spi-clk);拿到之后clk_get_rate 返回的是时钟源频率不是SCLK。真正的SCLK由控制器内部的分频寄存器决定分频系数要么是固定的几档比如2/4/8/16要么是一个连续范围具体看平台手册。举个具体例子帮你建立手感假设时钟源是26MHz你要跑13MHz那就分频2要跑6.5MHz分频4要跑3.25MHz分频8。这种2的幂次分频很常见意味着你实际能拿到的频率是离散的几档。如果你在设备树里写 spi-max-frequency 10000000控制器会选一个不超过10MHz的最大档位结果落在6.5MHz。功能上没问题但如果你的应用对吞吐有要求就得考虑往上调时钟源或者换用支持更细分频的控制器。还有一个隐藏的坑某些平台在低功耗场景下会关闭SPI时钟如果驱动里没有在suspend/resume回调里正确处理 clk_disable_unprepare 和 clk_prepare_enable唤醒后第一次传输就会超时。表现是系统休眠一次之后SPI设备就死了重启才能恢复。这个问题的定位方法很简单在传输函数前后各打一条日志看是不是第一次就返回-ETIMEDOUT再看时钟的enable计数。2.3 DMA通道与FIFO模式的选择逻辑SPI传输的数据量差异极大读一个传感器寄存器可能就2个字节读一块Flash数据可能是64KB。针对这两种场景控制器一般提供两条路径小数据走FIFO也叫PIO模式大数据走DMA。FIFO模式的逻辑是CPU把数据一个个写进发送FIFO硬件自动移位出去接收数据进接收FIFOCPU再读出来。优点是启动快、没有DMA配置开销缺点是CPU占用高数据量一大就变成纯粹的搬运工。对于小于FIFO深度常见是32或64字节的传输走FIFO反而比DMA快。DMA模式的逻辑是配置好源地址、目的地址、传输长度然后让DMA控制器接管。CPU在此期间可以去做别的事传输完成后通过中断通知。代价是每次传输都要做一次DMA映射dma_map_sg有固定的开销。经验值是传输长度超过128字节时DMA优势明显低于32字节时FIFO更快中间地带要看具体平台的实现。在MTK平台上SPI控制器通常挂在AP-DMA或者专门的DMA控制器上驱动里通过 dma_request_chan 或者平台特定的接口拿通道。这里有个实际会踩的坑DMA通道是有限资源如果系统里有多个SPI控制器同时高频工作通道可能不够分配。驱动里在probe阶段拿不到通道时要做降级处理退回到FIFO模式而不是直接返回错误导致整个设备probe失败。我见过一个项目因为DMA通道申请失败导致触摸屏的SPI初始化直接挂了最后只能把日志级别调高才发现是这个原因。2.4 引脚复用移植时最容易漏掉的一环引脚复用pinmux这个问题在所有平台上都存在但它在SPI上格外容易出事因为SPI的信号方向不统一SCLK、MOSI、CS是主机输出MISO是主机输入。如果 pinmux 把这些脚全配成了输出MISO 就永远读不到从机的数据读回来恒为0或者恒为1。MTK平台的pinmux一般在设备树的 pinctrl 节点里配置控制器节点通过 pinctrl-names 和 pinctrl-0 引用。典型写法是把SPI相关引脚归到一个组里标注功能号和上下拉、驱动强度pio { spi_pins_default: spi-default { pins-cmd-dat { pinmux PINMUX_GPIOxx__FUNC_SPI_MOSI, PINMUX_GPIOxx__FUNC_SPI_MISO, PINMUX_GPIOxx__FUNC_SPI_CLK, PINMUX_GPIOxx__FUNC_SPI_CS; drive-strength 4; bias-pull-up; }; }; };这里的 pinmux 宏名字在不同平台间差异很大实际以你手上平台的 pinctrl 头文件为准。我用占位符表示重点在于理解结构一组引脚、四个功能号、驱动强度和上下拉配置。驱动强度这个参数值得多说两句。SPI的SCLK频率高的时候走线容性负载会让边沿变缓驱动能力不足就会导致波形上升沿拖尾采样点落在过渡区。一般建议SCLK超过20MHz时把驱动强度调到中间档位以上同时把走线长度控制在合理范围。上下拉方面CS和SCLK建议开内部上拉防止控制器还没初始化时这两根线浮空导致从机误触发MISO是否上拉取决于从机在CS无效时的输出状态如果从机是高阻输出主机的MISO必须上拉否则读到的就是随机值。注意pinmux配置错误在软件上没有任何报错寄存器能写、中断能来、传输能完成只是数据不对。这类问题只能靠示波器或者逻辑分析仪抓波形确认。3. Linux SPI子系统的四层分工一次spi_sync到底走了多远3.1 从用户态到寄存器的一段漫长旅程写驱动的人最好对整条链路有个完整印象否则出了问题不知道往哪一层看。Linux的SPI子系统大致可以分成四层最上面是用户态接口主要是spidev字符设备对应 /dev/spidevX.Y加上 ioctl 配置模式和速度。这一层适合做硬件验证不适合做正式产品的数据通路因为每次传输都有系统调用开销而且没法参与内核里的电源管理和时序协调。第二层是SPI核心层spi.c它维护着控制器链表和设备链表负责把 spi_device 和 spi_driver 匹配起来提供 spi_sync、spi_async、spi_write、spi_read 这些API并且管理消息队列。所有传输最终都要被包装成 spi_message。第三层是控制器驱动层也就是平台相关的部分负责把 spi_message 里的一串 spi_transfer 翻译成寄存器操作配置时钟、模式、片选启动DMA或者FIFO处理完成中断。MTK平台的这部分代码通常在 drivers/spi/ 目录下文件名以平台名开头。第四层是硬件控制器IP加上外部器件。一次 spi_sync 调用从第二层进来核心层做参数检查和消息排队然后把消息交给控制器驱动的 transfer_one_message 回调。控制器驱动遍历消息里的每个transfer逐个配置并执行全部完成后调用 spi_finalize_current_message 通知核心层核心层再唤醒等待的调用者。中间任何一步超时都会返回 -ETIMEDOUT。理解这条链路的价值在于当传输失败时你可以通过返回值快速定位层级。返回 -EINVAL 多半是参数或配置问题返回 -ETIMEDOUT 是硬件没响应返回 -ENOMEM 是DMA映射失败返回 -EIO 通常是控制器报错。3.2 spi_message 与 spi_transfer 的关系一个消息可以包含多段这是初学者最容易搞混的一对概念。spi_transfer 描述的是一段连续传输包含发送缓冲区、接收缓冲区、长度和若干标志位。spi_message 是一次原子操作里面可以挂多个transfer这些transfer之间共享片选状态除非某个transfer的 cs_change 置位。为什么要设计成多段因为很多器件的协议是先发命令再发地址再收数据这三段在物理上是一次连续通信片选不能中途抬起。如果拆成三次 spi_sync每次都会重新拉片选从机状态机就断了。正确做法是构造一个message把三段transfer挂上去struct spi_message msg; struct spi_transfer xfers[3] {0}; xfers[0].tx_buf cmd; xfers[0].len 1; xfers[1].tx_buf addr; xfers[1].len 2; xfers[2].rx_buf buf; xfers[2].len len; spi_message_init(msg); spi_message_add_tail(xfers[0], msg); spi_message_add_tail(xfers[1], msg); spi_message_add_tail(xfers[2], msg); ret spi_sync(spi, msg);这段代码执行的时候片选只拉低一次三段数据连续发出最后一段的时钟用来把数据收回来。这才是符合大多数器件时序要求的写法。有个细节要注意如果某段transfer只发不收rx_buf 留空只收不发tx_buf 留空此时MOSI上会输出什么取决于控制器实现一般是0x00或者保持上一次的值。如果从机对MOSI有要求就得显式填一个dummy缓冲区。3.3 同步与异步的选择别在中断上下文里调spi_syncspi_sync 是阻塞的它会一直等到传输完成才返回。这意味着它不能在原子上下文中断处理函数、持有自旋锁的代码段里调用。如果你在中断里需要发SPI正确做法是用 spi_async它把消息挂到队列就返回完成时通过 message-complete 回调通知。我自己踩过一次这个坑在一个GPIO中断处理函数里直接调 spi_write 去清中断标志结果系统偶发死机日志里是一串scheduling while atomic。改成在工作队列里处理就正常了。后来养成习惯凡是中断上下文的SPI操作一律往下半部推。spi_async 的使用有几个约束message结构体的生命周期必须延续到complete回调执行完毕通常要动态分配或者放在驱动的全局结构里不能用栈变量complete回调在中断上下文执行里面不能睡眠同一个spi_device的多个异步消息会排队执行但不同设备之间是并行的。3.4 几个必须记住的结构体字段spi_device 结构体里有几个字段在调试时经常用到mode 表示当前工作模式CPOL/CPHA组合加上CS高有效等标志max_speed_hz 是设备树里配的上限chip_select 是片选索引controller 指向所属控制器。控制器驱动在每次传输前读这些字段决定寄存器配置。spi_transfer 里除了 tx_buf/rx_buf/len还有 speed_hz本次传输临时覆盖速度、bits_per_word位宽通常8有些器件要9位或16位、cs_change传输后是否翻转片选、delay_usecs传输后延时某些器件需要、tx_dma/rx_dmaDMA地址核心层填充。spi_controller老内核叫 spi_master里有 bus_num、num_chipselect、mode_bits控制器支持哪些模式这个字段决定设备树里配的模式会不会被拒绝、max_speed_hz、min_speed_hz。mode_bits 这个字段值得单独说。如果控制器只支持 Mode 0 和 Mode 3而设备树里写了 spi-cpol 和 spi-cpha 组合出的 Mode 1核心层在 spi_setup 阶段就会返回 -EINVAL。这个错误发生在probe期间日志里能看到但很容易被后续的其他错误淹没。所以设备树配完第一件事是确认控制器支持的mode_bits范围。4. 设备树节点的每一行都在管事SPI外设的dts写法与踩坑4.1 控制器节点与外设节点的从属关系SPI在设备树里的表示方式和其他总线不太一样外设节点是控制器节点的子节点这种嵌套结构直接表达了挂在哪条总线、用哪个片选的语义。控制器节点大概长这样spi0: spi1100a000 { compatible socvendor,spi-master; reg 0 0x1100a000 0 0x1000; interrupts GIC_SPI 130 IRQ_TYPE_LEVEL_HIGH; clocks topckgen CLK_SPI0; clock-names spi; #address-cells 1; #size-cells 0; pinctrl-names default; pinctrl-0 spi_pins_default; status disabled; };这里的 compatible 字符串在不同平台上是不同的具体取值必须查自己平台的dtsi文件不能照抄。reg 是寄存器基地址和长度。interrupts 是中断号和触发方式。#address-cells 1 和 #size-cells 0 是SPI总线的标准写法表示子节点的reg只有一个cell且没有size这个cell就是片选索引。外设节点挂在里面spi0 { status okay; sensor0 { compatible vendor,sensor-x; reg 0; spi-max-frequency 8000000; spi-cpol; spi-cpha; interrupt-parent pio; interrupts 12 IRQ_TYPE_EDGE_FALLING; vdd-supply mt_pmic_vcamio_ldo_reg; }; };spi-cpol 和 spi-cpha 这两个布尔属性同时存在就表示 Mode 3都不存在表示 Mode 0只有 cpha 表示 Mode 1只有 cpol 表示 Mode 2。这个对应关系建议记牢我在review代码时见过不少把加cpol表示Mode 1搞错的情况。4.2 频率、位宽和那些容易写错的属性spi-max-frequency 是必填项不写的话核心层会用控制器的默认值可能是一个非常保守的低频导致性能不达标。写出期望值即可控制器会向下取整。spi-tx-bus-width 和 spi-rx-bus-width 控制数据线宽度默认是1标准四线SPI。如果器件支持双线或四线模式Dual/Quad SPI可以写2或者4。这两个属性只有在控制器本身支持多线模式时才有意义配错了会在spi_setup阶段报错。spi-cs-high 表示片选高电平有效绝大多数器件是低有效所以这个属性很少用但一旦器件是反逻辑而你没加表现就是完全通信不上。还有一个容易踩的坑是 reg 属性的编号和外设实际接的片选脚不匹配。硬件上接的是CS1设备树里写了 reg 0软件就去拉CS0从机根本没被选中。这种问题在原理图上看不出来得对着PCB和dts一起核对。提示设备树改完之后一定要在 /sys/bus/spi/devices/ 下面确认设备节点有没有出现节点名里的 X.Y 就是总线和片选编号。看不到节点说明匹配或注册环节就失败了这时候去看dmesg比抓波形有用。4.3 用GPIO做片选时的两个约束有时候硬件CS被占用了或者控制器片选数量不够会用GPIO模拟片选。设备树里通过 cs-gpios 属性声明spi0 { cs-gpios pio 45 0, pio 46 0; };这个属性是个数组索引对应reg的值。第一个设备reg0就用第一个GPIO。用GPIO片选有两个约束必须清楚。第一片选翻转的时机由核心层控制但翻转和实际数据传输之间的时序精度不如硬件片选高频场景下可能出现建立时间不足。如果SCLK超过10MHz建议还是想办法用硬件片选。第二GPIO片选在系统休眠期间的状态要保持否则从机可能被误选中。驱动里要在suspend回调里把CS拉高。还有一个小细节cs-gpios 里声明的GPIO在设备树里就不能再被其他节点当普通GPIO用了否则会报资源冲突。我在一个项目里见过触摸屏的复位脚和SPI片选配到了同一个GPIO号编译不报错运行时片选一直拉不低查了两天才发现是dts里的编号写重了。5. 手写一个SPI外设驱动从probe到一次完整读写的落地代码5.1 驱动骨架与匹配逻辑一个标准的SPI外设驱动骨架包含 id_table、probe、remove 和 of_match_table。匹配优先级上设备树存在时走 of_match_table否则走 id_table。两者最好都写上方便在没有设备树的场景下测试。static const struct of_device_id sensor_of_match[] { { .compatible vendor,sensor-x }, { } }; MODULE_DEVICE_TABLE(of, sensor_of_match); static const struct spi_device_id sensor_id[] { { sensor-x, 0 }, { } }; MODULE_DEVICE_TABLE(spi, sensor_id); static int sensor_probe(struct spi_device *spi) { struct sensor_priv *priv; int ret; priv devm_kzalloc(spi-dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv-spi spi; spi_set_drvdata(spi, priv); ret spi_setup(spi); if (ret) { dev_err(spi-dev, spi_setup failed: %d\n, ret); return ret; } /* 读芯片ID确认通信正常 */ ret sensor_read_id(priv); if (ret) { dev_err(spi-dev, read id failed: %d\n, ret); return ret; } return 0; }spi_setup 这一步不能省。它会根据 spi_device 里已有的 mode 和 max_speed_hz 去调用控制器驱动的 setup 回调把硬件配置好同时校验控制器是否支持这些参数。不调用的话某些控制器驱动在第一次传输前才做配置某些则完全依赖setup行为不一致。读芯片ID放在probe里是个好习惯通信不通的话在probe阶段就失败了比等到用户态调接口时才发现要好定位得多。5.2 缓冲区与DMA安全别用栈上的大数组这是SPI驱动里最容易出隐性bug的地方。核心层在做DMA映射时会用 virt_to_page 之类的接口把虚拟地址转成物理页如果缓冲区来自 vmalloc 或者 kmalloc 之外的特殊内存区转换会失败返回 -ENOMEM 或者更糟——映射到错误的物理地址DMA把数据写到了别处。安全做法有三条小缓冲区几十字节以内用栈变量问题不大因为内核栈在线性映射区中等大小用 kmalloc记得用完 kfree大块或者频繁传输的用 dma_alloc_coherent 一次分配长期复用避免每次映射的开销。priv-tx_buf dma_alloc_coherent(spi-dev, BUF_SIZE, priv-tx_dma, GFP_KERNEL); priv-rx_buf dma_alloc_coherent(spi-dev, BUF_SIZE, priv-rx_dma, GFP_KERNEL);用 dma_alloc_coherent 分配的内存天然满足一致性要求CPU和DMA看到的内容一致不用手动做cache flush。代价是这部分内存不能被换出分配时要有心理准备。如果用 kmalloc 分配再走DMA缓存一致性问题就来了CPU写进缓冲区后数据可能还在cache里没落到内存DMA读的是内存读到的是旧数据。这时需要在传输前后调用 dma_sync_single_for_device 和 dma_sync_single_for_cpu。Linux的SPI核心层在 spi_map_buf 里已经处理了映射但cache同步这一块不同架构的实现差异较大稳妥起见使用一致性问题少的分配方式。还有一个非常隐蔽的坑spi_transfer 结构体本身如果放在栈上而你又用了异步传输函数返回后栈被回收DMA还在访问这块内存结果就是随机的数据损坏或者内核崩溃。异步传输的transfer和message必须动态分配。5.3 一次寄存器读写的标准套路大多数SPI器件遵循命令地址数据的协议读寄存器通常是先发一个读命令再发寄存器地址然后收数据。用message把这三段串起来static int sensor_read_reg(struct sensor_priv *priv, u8 reg, u8 *val) { struct spi_transfer xfer[3] {0}; struct spi_message msg; u8 cmd SENSOR_CMD_READ; u8 addr reg; u8 data 0; int ret; xfer[0].tx_buf cmd; xfer[0].len 1; xfer[1].tx_buf addr; xfer[1].len 1; xfer[2].rx_buf data; xfer[2].len 1; spi_message_init(msg); spi_message_add_tail(xfer[0], msg); spi_message_add_tail(xfer[1], msg); spi_message_add_tail(xfer[2], msg); ret spi_sync(priv-spi, msg); if (ret) return ret; *val data; return 0; }这里三个transfer共用一个message片选只拉低一次。如果拆成三个spi_sync每次都会重新拉片选很多器件会把这个当成三次独立的命令处理读出来的数据自然不对。写寄存器的逻辑类似只是第三段变成tx_buf。连续读多个寄存器时把第三段的len设成要读的长度就行前提是器件支持地址自增。5.4 先用spidev验证硬件再写正式驱动这个习惯能省掉大量时间。在写正式驱动之前先用spidev把硬件通路跑通确认模式、频率、片选、接线都没问题然后再写驱动。因为spidev的代码是现成的出问题一定是硬件或者设备树配置的问题排除了这一层后面调试驱动就轻松多了。验证方法是把设备树里的 compatible 临时改成 spidev然后编译一个小工具用 ioctl 配置参数并发数据。内核自带的 spidev_test 就是干这个的交叉编译一下就能用。u8 mode SPI_MODE_3; u32 speed 8000000; int fd open(/dev/spidev0.0, O_RDWR); ioctl(fd, SPI_IOC_WR_MODE, mode); ioctl(fd, SPI_IOC_WR_MAX_SPEED_HZ, speed);模式设好之后发一个读ID的命令看返回的字节对不对。如果spidev能读通正式驱动读不通问题一定在驱动代码里如果spidev也读不通问题在硬件或者设备树这时该上示波器了。这个分界线非常重要它把排查范围一刀切成两半避免在软件和硬件之间反复横跳。6. 上板不通的排查链路从全0xFF到波形正确的完整过程6.1 第一层确认设备到底有没有被probe拿到一个SPI不通的问题我从来不看波形先看设备有没有被注册。方法很简单三条命令ls /sys/bus/spi/devices/ dmesg | grep -i spi cat /proc/interrupts | grep spi第一条看设备节点在不在如果没有说明要么设备树没配对要么控制器没使能要么probe失败。第二条看内核日志里有没有报错重点看probe failed、chipselect、invalid mode这类关键字。第三条看中断有没有注册成功中断没注册上通常意味着控制器初始化就失败了。我曾经遇到过一个案例设备树里把外设节点写在了控制器节点外面看起来缩进是对的实际上是两个平级节点。结果就是设备根本没注册但dmesg里一句报错都没有因为设备树编译器只警告不报错。这种情况看 /proc/device-tree 下面的实际结构最直观。确认设备节点存在之后还要看绑定的驱动对不对。 /sys/bus/spi/devices/spi0.0/driver 这个软链接指向的就是当前绑定的驱动没绑定的话这个链接不存在。6.2 第二层示波器挂上去看四根线设备注册正常但数据不对就该抓波形了。抓波形有个顺序不能一上来四根线全挂那样看起来太乱。正确顺序是先看CS再看SCLK最后看MOSI/MISO。先看CS拉低了吗拉低的持续时间覆盖了整个数据传输吗有没有出现意外的毛刺如果CS根本没动过问题在片选配置或者GPIO复用如果CS拉低了但中间抬起过说明传输被拆成了多段要检查message的构造。再看SCLK有没有波形频率是多少空闲电平是高还是低数一下时钟个数和你期望的字节数乘8是否一致。我遇到过一次实际时钟数比预期多了一个字节原因是控制器在传输结束后多发了一段用于flush FIFO的时钟某些敏感的从机会把这段当成新的命令头。最后看MOSI和MISOMOSI上是不是你发出去的数据MISO上从机有没有响应如果MOSI正常而MISO是一条直线问题在从机侧——供电、复位、片选、模式四个方向逐个排查。如果MISO有波形但数据不对采样边沿可能错了换个模式试试。6.3 第三层模式错了但现象很迷惑模式错误的典型现象是数据看起来是数据的反码或者移位版本。比如你发0xAA10101010读回来如果是0x5501010101说明采样点偏移了半个时钟周期这就是CPHA配反的典型表现。如果读回来的数据整体反转或者高位在前低位在后那是位序问题SPI标准是MSB先发但极少数器件是LSB先发。还有一种更迷惑的现象单字节读写正常多字节读写就错乱。这通常不是模式问题而是字节之间的间隔或者片选行为不符合器件要求。有些器件要求字节之间不能有额外的时钟间隙有些则要求每个字节后必须拉高CS。排查模式问题最快的办法是把CPOL和CPHA的四种组合全试一遍每次读同一个已知的芯片ID寄存器。四种组合里通常只有一种能读对。这个方法土但极其有效比对着数据手册分析时序图快得多。6.4 第四层DMA和缓存带来的间歇性错误前面三层的问题都是稳定复现的如果现象是大部分时候正常偶尔错一包那基本可以怀疑DMA和缓存。典型特征是第一次传输对第二次错或者传输长度超过某个阈值就出错或者系统负载高的时候出错。这类问题的根源通常是缓冲区不在DMA可达的物理地址范围内或者cache没有同步。验证方法很简单把传输模式强制改成FIFO在驱动里加个判断长度小于某值时走PIO如果改成FIFO后问题消失那就确认是DMA路径的问题。然后再回去看缓冲区的分配方式和映射逻辑。还有一个隐蔽的情况是DMA传输完成中断来得比数据真正落内存更早导致读到的是部分数据。这种情况需要在DMA完成中断里读一下控制器的状态寄存器确认传输真的结束了而不是只看中断标志。6.5 一张常见现象对照表把上面几层的经验整理成表格实际排错时可以对照着走能省不少时间现象最可能的原因验证方法读回全0x00MISO未配置为输入、从机未供电万用表量MISO电平查pinmux读回全0xFF从机未响应、CS未选中、MISO上拉抓CS波形确认片选编号数据呈反码或移位CPHA或CPOL配反四种模式逐个试单字节正常多字节错片选被中途抬起检查是否用了多个spi_sync偶发错误、负载相关DMA映射或缓存一致性改FIFO模式对比probe时直接失败片选超出范围、模式不支持看dmesg具体错误码休眠唤醒后失效时钟未恢复、CS状态丢失查suspend/resume回调7. 高频与长包场景下的稳定性调优7.1 信号完整性软件层面能做的几件事SPI跑到几十兆的时候它就不再是接上就能通的简单总线了走线、阻抗、容性负载全都开始说话。软件工程师虽然改不了PCB但有几个参数是能调的。第一是驱动强度。前面提过pinmux里的 drive-strength 直接影响边沿陡峭程度。频率上去了但驱动强度还停在最低档边沿会变缓采样点落在过渡区误码率就上来了。调高一档通常能明显改善代价是EMI会变差需要在两者之间找平衡。第二是传输速度的余量。设备树里写的频率如果正好卡在从机的上限任何一点抖动都可能导致失败。留10%到20%的余量是稳妥做法。我一般会先按上限跑跑通了再往下调一点作为量产配置。第三是CS和SCLK的走线关系。这两根线最好等长且靠近避免CS的边沿耦合到SCLK上产生假时钟。软件上能做的是在CS拉低和第一个时钟之间插入一点延时某些控制器驱动支持通过 delay_usecs 或者专门的寄存器位实现。7.2 长包传输的拆分策略一次传64KB听起来很美实际执行时会遇到几个问题。一是DMA描述符数量有限超长传输会被拆成多个描述符中间可能出现间隙二是传输期间CPU不能及时响应其他中断影响系统实时性三是出错后整包重传的代价太高。比较务实的做法是把长包拆成4KB到16KB一段每段之间检查一下返回值出错就重传这一段而不是全部。这样既保证了吞吐又控制了错误恢复的成本。for (offset 0; offset total; offset CHUNK_SIZE) { len min(CHUNK_SIZE, total - offset); ret spi_sync_transfer(spi, xfer, 1); if (ret) { /* 重试这一段 */ ret spi_sync_transfer(spi, xfer, 1); if (ret) return ret; } }注意这里每次传输之间CS会被抬起如果器件要求整个长包期间CS保持低电平就不能这样拆得在message里挂多个transfer并设置 cs_change 0。7.3 中断模式与轮询模式的取舍控制器完成传输后通知CPU有两种方式中断和轮询。中断模式省CPU但有上下文切换开销短传输时这个开销可能比传输本身还大。轮询模式简单直接适合几字节的小传输但传输时间长的时候会一直占着CPU。内核里一般由控制器驱动自己决定但有些平台提供了配置项。判断标准很朴素如果传输长度经常小于16字节且调用频率很高轮询更划算如果传输长度大或者调用不频繁中断更合适。还有一个实际考虑是电源管理。轮询模式下CPU没法进低功耗状态对续航有影响。移动平台上的传感器读取如果频率不高比如每秒几次用中断模式让CPU尽快回去休眠更合理。提示如果你发现SPI传输的耗时比理论值高出一大截先别怀疑硬件看看是不是每次传输都在等中断唤醒而系统的中断延迟本身就很大。把传输合并成批量操作往往比优化单次传输更有效。写到这里关于MTK平台SPI驱动的基础部分基本铺完了。我个人在实际操作中的体会是SPI的问题百分之八十不在代码里而在配置和时序上。设备树的一个属性写错、pinmux的一个脚配反、时钟分频算错一档表现出来都是数据不对但排查路径完全不同。所以每次接手新的SPI器件我都会按固定顺序走一遍先确认设备节点注册、再用spidev验证硬件通路、然后抓波形确认四根线的行为、最后才写正式驱动。这个顺序看起来慢实际上比东一榔头西一棒子地改代码要快得多。另外提醒一句那些看起来能用就行的配置比如频率卡在上限、模式靠猜、片选编号凭印象往往是量产阶段最容易被放大的隐患前期多花半小时核对后面能省好几天。