需求
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。
六、给你的下一步行动清单
- 加 IRQ 线——如果硬件还没定,务必留出这根线,这是成败关键。
- 算清最坏带宽——确认 10 路串口各自最高波特率,定 SPI 时钟。
- 定帧协议——MAGIC+CHAN+TYPE+LEN+CRC,支持多通道打包和状态 bitmap。
- 读 max310x.c——这是和你需求最接近的现成内核驱动,精读它的寄存器读写、IRQ 处理、tty port 注册逻辑。
- 方案 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,这是一个很实在的扩展手段。
六、更新后的行动清单
- 先定 MCU 数量——这直接决定 IRQ 方案(独立线 vs 汇聚线)、GPIO 预算、要不要用第二条 SPI 总线。
- IRQ 优先一人一线——MCU ≤4 个时用独立 IRQ,最省心。
- 确认 MISO 三态——多从机第一大坑,MCU 未选中时 MISO 必须 Hi-Z,实测验证。
- 重算总带宽——N×单 MCU 流量 vs SPI 总线带宽,余量不足就提速或上第二条总线。
- 驱动按"控制器实例 ×N"设计——方案 A 用设备树多节点,方案 B 用 daemon 管理 MCU 列表;代码只写一份逻辑。
- 命名用二维规则——
/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 与帧同步机制。

图里有几个关键设计点值得再点一下:
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 通道怎么分配)理清楚?

