需求

linux资源不够,我想通过spi把linux和单片机连接起来,其中要操作单片机的 10路串口,还有一些IO,还有一个私有通道进行传输其它数据。我的想法是,单片机spi是从机,然后把单片机的串口映射到linux的 /dev/mcu-ttySx,其中私有通信通道就是 /dev/mcu-ttyC0 ,

SPI 连接 Linux 与 MCU 实现多路串口映射方案分析

一、整体架构判断

你的核心需求是把 MCU 当作一个"多串口扩展芯片 + IO 扩展 + 私有通道"的协处理器,通过 SPI 暴露给 Linux。本质上你要实现的是一个 自定义的 SPI multiplexing 协议,类似商用的 MAX14830(4路 UART over SPI)或 SC16IS752,但你是用 MCU 软件实现的、通道更多的版本。

这条路完全可行,而且是工业界常见做法。关键决策点在三处:SPI 工作模式(全双工/半双工 + 中断)、数据链路层协议设计、Linux 端驱动形态


二、物理层 / SPI 链路设计

1. 必须增加一根 MCU→Linux 的中断线(关键)

MCU 是 SPI 从机,从机无法主动发起传输。当 MCU 的某路串口收到数据时,它需要通知 Linux 来读。如果没有中断线,Linux 只能轮询(polling),会带来高延迟和 CPU 浪费(尤其 T113 双核 A7 资源本就紧张)。

所以物理连接应该是:

SCLK / MOSI / MISO / CS   ← 标准 SPI 4 线
IRQ (MCU GPIO → Linux GPIO)  ← MCU 有数据时拉低,触发 Linux 中断

Linux 端把 IRQ 注册为 GPIO 中断,中断到来后发起一次 SPI 事务去读 MCU 的状态和数据。这是整个方案的脊柱。

2. SPI 速率与全双工利用

T113 的 SPI 控制器支持 DMA,MCU 这颗 Cortex-M4 @240MHz 也有 16 通道 DMA + 6 个 SPI。建议:

  • SPI 时钟先从 10–20MHz 起步验证,稳定后视布线再提速。理论上 MCU 从机 SPI 可以跑到几十 MHz,但从机模式下 MCU 的 DMA 响应和帧间处理是瓶颈,不要一上来追极限。
  • 充分利用全双工:SPI 每次传输是双向的。设计协议时,Linux 发出去的 MOSI 帧里带"我要写哪路串口的数据",同一个时钟周期 MISO 回来 MCU"哪路串口有多少待读数据"。一次事务同时完成读和写,效率翻倍。

3. 带宽估算(务必先算这一步)

10 路 USART,假设每路 115200 bps:

10 × 115200 ≈ 1.15 Mbps ≈ 144 KB/s(满载,双向则 288 KB/s)

加上协议头开销和私有通道,SPI 物理带宽需求并不大,10MHz SPI(理论 1.25MB/s)绰绰有余。瓶颈不在 SPI 带宽,而在事务调度延迟MCU 从机的实时响应。如果你的某些串口要跑到 921600 或更高,要重新算这笔账。


三、数据链路层协议设计(方案核心)

这是你"目前不确定的底层方案"中最该认真设计的部分。建议设计一个固定帧头 + 通道号 + 长度 + payload + CRC 的帧结构,并支持一次 SPI 事务里携带多通道数据(多路复用)。

帧结构建议

┌────────┬────────┬────────┬─────────┬──────────────┬───────┐
│ MAGIC  │ CHAN   │ TYPE   │ LEN     │ PAYLOAD      │ CRC16 │
│ 1B     │ 1B     │ 1B     │ 2B      │ N bytes      │ 2B    │
└────────┴────────┴────────┴─────────┴──────────────┴───────┘
  • CHAN:0–9 映射 10 路 USART,某个保留值(如 0x80)给私有通道 ttyC0,另一段值给 GPIO 操作。
  • TYPE:数据 / 控制(配置波特率、查询状态、IO 读写)/ ACK。
  • 帧内可以打包多个子帧,一次 SPI 事务批量传多路串口数据,降低事务次数。

流控与状态交换

每次事务的开头几个字节做状态字交换:MISO 回传一个 bitmap,标识 10 路串口各自的 RX FIFO 是否有数据、TX FIFO 是否可写。Linux 据此决定下一次事务读哪路、能否写。这避免了盲读盲写。这本质上是在模仿 SC16IS752 那类芯片的寄存器读写语义。

关键风险:从机帧同步

MCU 作为 SPI 从机,最容易出问题的是 CS 拉低后第一个字节的对齐Linux 与 MCU 对"当前在传第几字节"的认知不一致。务必:

  • 每次事务用 CS 边沿做硬复位,MCU 在 CS 上升沿用 DMA + 中断重置接收状态机。
  • 加 MAGIC 头和 CRC,任何不一致直接丢帧重传,不要试图容错恢复半帧。

四、Linux 端驱动形态(三选一)

这决定你 /dev/mcu-ttySx 怎么来的,是工程量大头。

方案 A:标准内核 serdev / tty 驱动(最正统)

在内核里写一个 SPI 驱动,对每路 USART 调用 uart_add_one_port() 注册成标准 tty 设备,自动生成 /dev/ttyMCU0..9。私有通道注册成一个独立 misc/char 设备 /dev/mcu-ttyC0

  • 优点:对上层应用完全透明,任何用 /dev/ttyS* 的程序(minicom、pppd、你的业务进程)无需改动直接用;有内核的 tty 层做缓冲和流控。
  • 缺点:内核驱动开发门槛高,调试需要 T113 的内核源码树和交叉编译环境,出 bug 难定位。
  • 参考:直接读 Linux 内核里 drivers/tty/serial/max310x.c(MAX14830 over SPI 驱动)和 sc16is7xx.c,你的需求和它们几乎一模一样,强烈建议以 max310x.c 为蓝本改造。这能省掉你 80% 的设计弯路。

方案 B:用户态守护进程 + PTY(最快出原型)

写一个用户态 daemon,用 /dev/spidev 操作 SPI,用 openpty() 创建 10 个伪终端,把 PTY 的 master 端在 daemon 里和 SPI 数据来回搬运,slave 端通过 symlink 暴露成 /dev/mcu-ttyS0..9

  • 优点:纯用户态,不碰内核,开发调试极快,崩了不会 panic。资源紧张的 T113 上也容易控制。
  • 缺点:有用户态/内核态拷贝开销和调度延迟;PTY 的 termios(波特率设置等)需要你自己解释并转发给 MCU。
  • 适合:先用这个跑通整个链路验证 MCU 协议,再决定要不要上方案 A。

方案 C:tty_port + 自定义 char 设备(折中)

不走完整 serial core,自己在内核写轻量 char 驱动。灵活但失去 termios 兼容性,一般不推荐,除非应用层是你自己完全可控的。

我的建议

先 B 后 A:用方案 B 在两三天内打通 SPI 协议、中断、帧同步、私有通道,验证 MCU 固件正确性和带宽;链路稳定后,如果对性能或 termios 兼容性有要求,再以 max310x.c 为模板迁移到方案 A。私有通道 /dev/mcu-ttyC0 无论哪个方案都建议做成独立 char 设备,不要硬塞进 tty 框架。


五、MCU 固件侧要点

  • 每路 USART 配独立的环形缓冲 + DMA 收发,10 路全开 DMA,Cortex-M4 + 16 通道 DMA 足够。注意 16 个 DMA 通道要在 10 路 UART、SPI 之间分配,可能需要让部分低速 UART 共享或用中断收。
  • SPI 从机用 DMA 收发整帧,CS 中断里切换缓冲、解析、组织回传数据。240MHz 主频处理这点协议毫无压力。
  • 私有通道在固件里就是另一个逻辑 endpoint,按 CHAN 号路由即可。
  • IO 操作走控制帧,Linux 发"读/写某 GPIO"命令,MCU 执行后 ACK。

六、给你的下一步行动清单

  1. 加 IRQ 线——如果硬件还没定,务必留出这根线,这是成败关键。
  2. 算清最坏带宽——确认 10 路串口各自最高波特率,定 SPI 时钟。
  3. 定帧协议——MAGIC+CHAN+TYPE+LEN+CRC,支持多通道打包和状态 bitmap。
  4. 读 max310x.c——这是和你需求最接近的现成内核驱动,精读它的寄存器读写、IRQ 处理、tty port 注册逻辑。
  5. 方案 B 起步——spidev + PTY 用户态 daemon 先打通,再决定是否进内核。

要不要我帮你具体展开某一块?比如帧协议的详细字节定义、max310x.c 的关键逻辑拆解,或者方案 B 那个用户态 daemon 的代码骨架。

我可能有多个mcu要操作,也就是说,通过CS来选多个mcu,

多 MCU(多 CS)架构的影响分析

这个变化不小,它把"一个 SPI 从设备"变成了"一条 SPI 总线挂多个从设备",需要重新审视几个层面。好消息是大部分设计可复用,关键改动集中在中断聚合、命名映射、驱动多实例这三处。


一、物理层:中断线是最大的新问题

CS 可以共总线,但 IRQ 不能简单共用

多个 MCU 共享 SCLK/MOSI/MISO,各自一根独立 CS,这是标准做法,没问题。T113 的 SPI 控制器本身支持多片选,或者你用普通 GPIO 手动控制 CS。

真正的麻烦是中断线。每个 MCU 都需要在自己有数据时通知 Linux。你有三种处理方式:

方式 1:每 MCU 一根独立 IRQ(推荐,如果 GPIO 够用)

MCU0 ─ IRQ0 → Linux GPIO_A
MCU1 ─ IRQ1 → Linux GPIO_B
MCU2 ─ IRQ2 → Linux GPIO_C

最干净,中断到来直接知道是哪个 MCU,延迟最低。T113 有约 72 个 GPIO,如果 MCU 数量不多(比如 ≤4 个),优先用这个。

方式 2:IRQ 线"线与"汇聚 + 轮询定位
所有 MCU 的 IRQ 做成开漏(open-drain)并联到一根线上,任一 MCU 拉低都触发 Linux 中断。但 Linux 不知道是谁,必须依次 CS 选中每个 MCU 查询状态字来定位。

  • 优点:只占 1 个 GPIO。
  • 缺点:每次中断都要轮询所有 MCU,MCU 多了延迟和 SPI 事务数上升。
  • 适合 MCU 较多、GPIO 紧张的场景。

方式 3:混合——比如 4 个 MCU 分两组,每组共享一根 IRQ。在 GPIO 数量和定位效率间折中。

我的建议

MCU 数量少(2–4 个)就用方式 1,一人一线最省心。超过这个数或 GPIO 吃紧再考虑方式 2。在硬件定板前一定要把这个数量和 GPIO 预算确定下来。


二、总线时序:CS 切换的新约束

多从机共享 MISO 时,要注意:

  • MISO 必须三态:每个 MCU 在自己 CS 未选中时,MISO 引脚必须是高阻态(Hi-Z),否则会和其他 MCU 打架。确认你的 MCU SPI 从机外设在 CS 释放后会自动释放 MISO——大多数会,但务必实测,这是多从机最经典的坑。
  • CS 切换间隔:从 MCU0 切到 MCU1,中间要留足建立时间,让前一个 MCU 释放总线、后一个进入接收态。Linux 端在两次 SPI 事务间天然有间隔,一般够,但高速时要留意。
  • 事务不可被打断:对某个 MCU 的一帧传输必须 CS 全程拉低完成,不能中途切到别的 MCU。Linux 的 SPI 子系统对每个 spi_device 串行化处理,天然满足。

三、命名映射:你的设备节点规划要扩展

原来的 /dev/mcu-ttySx 现在要体现"哪个 MCU 的哪路串口"。建议二维命名:

/dev/mcu0-ttyS0 .. /dev/mcu0-ttyS9    ← MCU0 的 10 路串口
/dev/mcu1-ttyS0 .. /dev/mcu1-ttyS9    ← MCU1 的 10 路串口
...
/dev/mcu0-ttyC0                        ← MCU0 的私有通道
/dev/mcu1-ttyC0                        ← MCU1 的私有通道

如果 N 个 MCU,就是 N×10 个串口节点 + N 个私有通道。这只是命名规则,实现上每个 MCU 是一个独立的"逻辑控制器实例"。


四、Linux 驱动形态:从单实例到多实例

这是软件层最实质的改动。无论方案 A 还是 B,核心思路都是把"一个 MCU"抽象成一个控制器实例,实例化 N 次

方案 A(内核驱动):天然支持多实例

这正是内核 SPI + Device Tree 模型的设计初衷。在设备树里声明多个 SPI 从设备节点:

&spi0 {
    mcu@0 {
        compatible = "yourname,mcu-uart";
        reg = <0>;              /* CS0 */
        interrupt-parent = <&pio>;
        interrupts = <...>;     /* MCU0 的 IRQ */
    };
    mcu@1 {
        compatible = "yourname,mcu-uart";
        reg = <1>;              /* CS1 */
        interrupts = <...>;     /* MCU1 的 IRQ */
    };
};

内核会为每个节点调用一次你的 probe(),每次 probe 注册该 MCU 的 10 个 tty port + 1 个私有 char 设备。你的驱动代码只写一份,设备树决定实例化几次。 这又一次说明为什么 max310x.c 是好蓝本——它本就是按"每个 SPI 从设备一个实例,每实例多个 UART 子通道"的模型写的,你照搬这个结构,多 MCU 几乎是免费获得的。

port 编号上,用 uart_add_one_port 时给每个 MCU 实例分配不同的 line 号区间(MCU0 占 0–9,MCU1 占 10–19),或者注册不同的 tty driver name 前缀来区分。

方案 B(用户态 daemon):用一个 daemon 管理多个 MCU

一个 daemon 进程,内部维护一个 MCU 列表,每个 MCU 一组数据结构(CS 编号、IRQ fd、10 个 PTY、1 个私有 PTY、各自的收发缓冲)。主循环用 epoll 同时监听所有 MCU 的 IRQ GPIO(通过 gpio 的 sysfs/chardev 中断 fd)和所有 PTY 的可读事件。

epoll 监听:
  ├─ MCU0 IRQ fd  → 选中 CS0,读 MCU0 数据,分发到对应 PTY
  ├─ MCU1 IRQ fd  → 选中 CS1,读 MCU1 数据,分发到对应 PTY
  ├─ 各 PTY master 可读 → 知道要往哪个 MCU 的哪路 UART 写
  └─ ...

SPI 事务对所有 MCU 串行化(一条总线本来就只能串行),daemon 内部加锁保证同一时刻只有一个 CS 活动。注意这里的并发瓶颈:所有 MCU 共享一条 SPI 总线,如果多个 MCU 同时有大量数据,它们要排队,总带宽是被所有 MCU 瓜分的。


五、带宽要重新算(重要)

这是多 MCU 最容易被忽视的点。一条 SPI 总线的带宽现在要被 N 个 MCU 瓜分:

单 MCU 满载(10×115200 双向) ≈ 288 KB/s
4 个 MCU 全满载 ≈ 1.15 MB/s

如果 SPI 跑 10MHz(理论 1.25MB/s),4 个 MCU 满载就快到顶了,再算上协议开销和 CS 切换损耗,余量很小。应对:

  • 提高 SPI 时钟到 20–30MHz(需布线和实测支持)。
  • CS 切换 + 状态字优先:每次中断先快速读状态字判断该 MCU 有多少数据,按需读取,不空转。
  • 公平调度:daemon 里对多个 MCU 做轮转或按数据量加权调度,避免某个高流量 MCU 饿死其他 MCU。
  • 如果带宽确实不够,考虑用 T113 的第二个 SPI 控制器分流——把 MCU 分到两条物理 SPI 总线上,带宽翻倍且中断定位也更简单。T113 引出了多个 SPI,这是一个很实在的扩展手段。

六、更新后的行动清单

  1. 先定 MCU 数量——这直接决定 IRQ 方案(独立线 vs 汇聚线)、GPIO 预算、要不要用第二条 SPI 总线。
  2. IRQ 优先一人一线——MCU ≤4 个时用独立 IRQ,最省心。
  3. 确认 MISO 三态——多从机第一大坑,MCU 未选中时 MISO 必须 Hi-Z,实测验证。
  4. 重算总带宽——N×单 MCU 流量 vs SPI 总线带宽,余量不足就提速或上第二条总线。
  5. 驱动按"控制器实例 ×N"设计——方案 A 用设备树多节点,方案 B 用 daemon 管理 MCU 列表;代码只写一份逻辑。
  6. 命名用二维规则——/dev/mcuN-ttySx,清晰区分 MCU 和通道。

要展开的话,我可以帮你具体写其中一块:比如多 MCU 场景下方案 B daemon 的 epoll 主循环骨架(含多 IRQ 监听和 CS 调度),或者设备树多节点 + 内核驱动 probe 多实例的结构,或者带宽/调度的量化建模。你倾向先深入哪个?

主要是单片机这边spi slave我不熟悉,你帮我具体展开分析,同时写一个架构图。主要是你提到的 从机帧同步

MCU 侧 SPI Slave 帧同步详解

这是整个方案最容易翻车的地方,我帮你把原理、坑、和具体实现拆透。先讲清楚为什么从机帧同步这么难,再给你一套可落地的 DMA + CS 中断方案,最后配架构图。


一、为什么 SPI Slave 的帧同步是个难题

主机(Linux/T113)掌握时钟,它想发就发,天生是节奏的主导者。从机(你的 MCU)是被动的——时钟不是你产生的,你只能跟着 SCLK 的节拍被动移入/移出数据。这带来三个核心问题:

问题 1:字节边界对齐(bit slip)

SPI 是按 bit 移位的。从机的移位寄存器满 8 个 bit 才算一个字节。如果某次 CS 没干净拉低、或 SCLK 受干扰多/少了一个脉冲,从机就会永久错位——之后所有字节的 bit 都错排,且自己无法察觉。一个 bit 的错位会让整条数据流全部变成垃圾,而且不会自动恢复。

对策:用 CS 边沿做硬复位。SPI 从机外设的设计就是 CS 拉低=一帧开始,CS 拉高=一帧结束并复位移位逻辑。所以你必须保证一次完整的逻辑帧 = 一次 CS 拉低到拉高的周期,绝不能在一次 CS 周期里塞多个独立逻辑帧再指望自己切分。

问题 2:从机"来不及响应"(从机的死穴)

主机一拉 CS、一给时钟,数据立刻开始移入。如果你 MCU 还在用 CPU 一个字节一个字节地处理上一帧、没准备好接收缓冲,新数据来了就直接覆盖/丢失。从机没有"等一下"的能力(除非你额外设计 READY 握手线)。

对策:全程 DMA。CS 一拉低,DMA 自动把 MISO/MOSI 数据搬进搬出,CPU 完全不参与字节级搬运,只在 CS 拉高(整帧结束)后才被中断叫醒去处理。这样无论主机给多快的时钟,DMA 都跟得上。

问题 3:全双工的"时间差"

SPI 全双工意味着:主机这次发给你的同时,你回给主机的是上一次就准备好的数据。从机来不及"现读现回"——主机第 N 字节的时钟边沿到来时,你 MISO 上必须已经摆好了要回的 bit。

对策:采用 "请求-应答错开一帧" 模型。主机第 N 次事务发出请求,MCU 在事务间隙准备好应答,主机第 N+1 次事务把它读回。不要试图在同一次事务里"问了立刻得到答案"。


二、推荐方案:CS 中断 + DMA 双缓冲

核心思想一句话:CPU 永不碰字节流,只在帧边界(CS 上升沿)被唤醒处理整帧;DMA 负责全部字节搬运;用 CS 引脚的外部中断而非 SPI 外设中断来界定帧。

关键技巧:把 CS 同时接到两个地方

主机 CS 信号 ──┬──→ MCU 的 SPI 硬件 NSS 引脚(让 SPI 外设知道选中)
              └──→ MCU 的一个 EXTI 外部中断引脚(让 CPU 感知帧起止)

很多人只接前者,结果只能依赖 SPI 外设的 RXNE/TC 中断,在从机模式下这些中断的时序很难精确界定"一帧结束了"。把 CS 额外引到一个 EXTI 上,用边沿中断来精确捕获帧的开始和结束,是从机帧同步最实用的技巧。(如果你的 MCU SPI 外设本身提供"NSS 上升沿事件中断",可以用它替代外部 EXTI,效果一样。)

工作时序

主机:   CS↓ ════ 时钟+数据 ════ CS↑          CS↓ ════ ... ════ CS↑
         │                        │            │
MCU:   EXTI下降沿              EXTI上升沿     EXTI下降沿
       启动RX/TX DMA          DMA停止,        启动下一帧
       (指向接收缓冲)          整帧到齐,
                              触发处理

双缓冲(Ping-Pong)避免覆盖

CPU 处理刚收到的 A 帧时,主机可能马上发 B 帧。所以准备两个接收缓冲区交替使用:DMA 往 buffer[1] 写新帧的同时,CPU 处理 buffer[0] 里的旧帧。


三、伪代码骨架

下面是平台无关的逻辑骨架(寄存器名按你的 MCU HAL 替换):

#define FRAME_MAX   512
#define MAGIC       0xA5

uint8_t  rx_buf[2][FRAME_MAX];   // 双缓冲:DMA 接收
uint8_t  tx_buf[2][FRAME_MAX];   // 双缓冲:DMA 发送(回给主机)
volatile uint8_t cur = 0;        // 当前 DMA 使用的缓冲索引
volatile uint8_t frame_ready = 0;

/* ---------- CS 下降沿:一帧开始 ---------- */
void EXTI_CS_Falling_IRQHandler(void)
{
    // 复位 SPI 从机移位逻辑,丢弃任何残留,保证字节对齐
    SPI_Slave_Reset();                // 关 SPI、清 FIFO、再开 SPI

    // 把本帧要回给主机的应答数据放进 tx_buf[cur](上一轮已备好)
    // 启动 DMA:接收进 rx_buf[cur],发送从 tx_buf[cur] 出
    DMA_Start_RX(rx_buf[cur], FRAME_MAX);
    DMA_Start_TX(tx_buf[cur], FRAME_MAX);

    SPI_Enable();
}

/* ---------- CS 上升沿:一帧结束 ---------- */
void EXTI_CS_Rising_IRQHandler(void)
{
    // 主机已停止时钟,本帧字节全部到齐
    uint16_t received = FRAME_MAX - DMA_Get_RX_Remaining(); // 实际收到的字节数
    DMA_Stop();
    SPI_Disable();

    // 通知主循环:rx_buf[cur] 里有一整帧待解析
    last_len = received;
    frame_ready = 1;
    proc_idx = cur;

    // 切换到另一个缓冲,准备下一帧
    cur ^= 1;
}

/* ---------- 主循环:帧级处理(非中断上下文) ---------- */
void main_loop(void)
{
    while (1) {
        if (frame_ready) {
            frame_ready = 0;
            parse_frame(rx_buf[proc_idx], last_len);   // 校验 MAGIC + CRC + 分发
            prepare_next_response(tx_buf[cur]);        // 为下一帧准备应答
        }
        usart_dma_service();   // 服务 10 路串口的环形缓冲(独立于 SPI)
    }
}

/* ---------- 帧解析 ---------- */
void parse_frame(uint8_t *buf, uint16_t len)
{
    if (len < 7 || buf[0] != MAGIC) return;            // 帧头不对,整帧丢弃
    if (crc16(buf, len-2) != read_crc(buf, len)) {     // CRC 错
        mark_need_retransmit();                        // 让主机重发
        return;
    }
    uint8_t chan = buf[1];      // 0-9=USART, 0x80=私有通道, 0xC0=GPIO
    uint8_t type = buf[2];
    uint16_t plen = (buf[3]<<8) | buf[4];
    uint8_t *payload = &buf[5];

    switch (chan_class(chan)) {
        case CH_UART:    usart_tx_enqueue(chan, payload, plen); break;
        case CH_PRIVATE: private_channel_handle(payload, plen); break;
        case CH_GPIO:    gpio_cmd_handle(payload, plen);        break;
    }
}

几个要点:

  • CS 下降沿先 reset SPI——这是保证每帧字节对齐的关键动作,把上一帧任何残留 bit 清掉。
  • 用 DMA 剩余计数算实际收到字节数,因为从机不知道主机会发多长,DMA 按最大长度开,主机发多少就收多少。
  • CRC 失败不尝试修复,直接请求重传——半帧容错是无底洞,丢掉重来最可靠。
  • 串口服务和 SPI 服务解耦:10 路 USART 各自 DMA + 环形缓冲在主循环里跑,SPI 只是把环形缓冲里的数据打包搬运,两者通过缓冲区交接,互不阻塞。

四、架构图

让我画一张完整的系统架构图,把 Linux 侧、SPI 总线、MCU 内部数据流都呈现出来。下面是完整的系统架构图,从 Linux 应用层一直到 MCU 内部的 SPI Slave DMA 与帧同步机制。
2026-06-17T00:36:43.png

图里有几个关键设计点值得再点一下:

CS 接两处(图中黄色框)是整个从机帧同步的核心技巧。SPI 的 NSS 引脚让硬件知道"被选中了",而额外引到 EXTI 上的那一路,是让 CPU 在帧的起止边沿被精确叫醒。只靠 SPI 外设自己的 RXNE/TC 中断,在从机模式下很难干净地判断"一帧结束了"。

红色的两个 CS 中断处理函数就是前面伪代码里的那两个 IRQHandler。CS↓ 时先复位移位器再启 DMA,这一步保证每帧字节对齐;CS↑ 时用 DMA 剩余计数算出实际收了多少字节,然后切缓冲。CPU 全程不碰字节流,只在帧边界醒来。

右下角的 IRQ 回路(黄色长条)解决从机的根本矛盾:MCU 是被动的,串口收到数据后没法主动推给 Linux,只能拉低 IRQ 线请主机发起一次事务来取。多 MCU 时,这条线就是前一轮讲的"一人一线"或"开漏汇聚"。


一个容易踩的实操细节补充:不同 MCU 的 SPI 从机外设,在 CS 拉高后 MISO 是否真的回到高阻差异很大,有些需要你在固件里显式配置 NSS 管理模式才会释放。多 MCU 共享 MISO 时这是第一大坑,务必拿示波器实测 CS 释放后该 MCU 的 MISO 是不是 Hi-Z。

另外那个 "CS↓ 先复位 SPI" 的动作,在某些 MCU 上关闭再开启 SPI 外设需要几个时钟周期,如果主机 CS 拉低后立刻就甩时钟,可能赶不及。两个应对:要么主机在 CS 拉低后插入一两微秒延迟再发时钟,要么用一根 READY 线让 MCU 准备好后才示意主机开始——后者更稳但多占一根线。

要不要我接着帮你把 CS↓ 中断里那段 SPI 复位 + DMA 重启的寄存器级时序(针对你这颗 MCU 的 SPI 从机外设具体怎么配 NSS、DMA 双缓冲怎么挂)展开?或者先把 MCU 固件整体的任务划分(SPI 帧服务 vs 10 路串口服务怎么在主循环和中断里分工、DMA 通道怎么分配)理清楚?