尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

SPI主机帧结束检测的可靠实现:从硬件同步到软件标志位

SPI主机帧结束检测的可靠实现:从硬件同步到软件标志位 做嵌入式或者FPGA的同学都知道SPI看起来是四根线的事SCLK、MOSI、MISO、CS。但真到了做主机的时候你会发现最头疼的根本不是怎么把0x55发出去而是怎么确定“这一帧已经结束了”。尤其是当从机对帧边界特别敏感、或者你需要紧接着发下一帧、或者主机和从机之间还夹着电平转换、隔离芯片这类额外延迟的时候一个不靠谱的帧结束检测轻则数据错位重则直接把从机状态机搞死。这篇文章就围绕“SPI master怎么可靠检测end of frame”这个主题展开。我会把硬件侧和软件侧的方案都拆开讲给出一套我实际用过、也调过坑的检测思路最后附上常见问题的排查速查表。适合正在写SPI控制器逻辑的FPGA工程师也适合用MCU的SPI外设却总觉得时序不太对劲的同学。1. 先把这个问题的本质看透1.1 帧结束到底指什么SPI没有像UART那样的起始位和停止位数据能不能被从机正确解析全靠主从两侧对“片选”和“时钟”的默契。所谓一帧通常就是从CS拉低开始到CS拉高结束。从机在这个窗口内根据SCLK的边沿采样或输出数据窗口一关这帧就算翻篇了。所以“检测到帧结束”这句话翻译成具体的信号行为就是主设备在发送完最后一个SCLK脉冲之后要把CS拉高并且要能确认三点——第一最后一个数据位已经被从机正确采样第二从机已经有足够时间释放MISO总线第三主设备的控制状态机已经从“传输中”安全回到“空闲”状态。只要这三点里有一点不满足那“帧结束”就是一个假信号。很多人在这一步犯的错误是以为“代码执行到CS拉高”就等于“帧结束了”。但对于高速SPI代码执行到CS拉高这个动作本身需要时间而SCLK可能早就跑完最后一个脉冲了。更隐蔽的是如果你用软件去翻转CS而硬件又还在移位寄存器的最后一级里没吐完数据那这一拉就相当于把从机正在读的字节拦腰截断。1.2 为什么“可靠检测”会成为一个问题如果你只在1MHz下跑SPI长度不超过几个字节那随便写个延时都能蒙对。但实际情况是时钟上到几十兆、帧长不定、DMA搬运、中断嵌套、从机还需要CS拉高之后额外花几个周期做内部处理。这些因素叠加起来帧结束检测就不再是“拉一下CS”那么简单了。可靠性本质上是两个维度的权衡一是时间精度检测到帧结束的时刻要和总线上真实的传输结束时刻足够接近二是抗干扰能力不能因为一根毛刺、一次中断延迟、或者一次DMA没及时触发就把帧结束给漏掉或者提前误报。这两点往往互相矛盾追求极致的精度就可能牺牲容错而加容错又可能让帧间隙变得过长拖慢整体吞吐。我自己在调试一块从机芯片时遇到过这样的问题主机的SCLK已经停了CS也拉高了但示波器上看MISO还拖着一截尾巴。从机手册里写着“CS high after last SCK edge, MISO tri-state within 100ns”但实际板子上因为线缆电容和末端电阻的问题这个释放时间被拖到了将近500ns。主设备如果在这500ns之内就认为帧结束并立刻拉低CS去发下一帧两条帧就会粘在一起从机的内部状态根本来不及复位。2. 硬件侧实现同步采样与边沿检测2.1 为什么不能直接把CS信号接进状态机就完事直接拿内部的CS信号作为状态机的结束条件在低速场合也许能跑但问题在于CS的拉高动作本身也是由状态机产生的两者之间天然存在反馈路径。如果状态机在同一个时钟沿既拉高CS又判断CS是否为高那综合出来就是一个组合逻辑环或者至少是时序违例。更常见的场景是CS来自外部引脚控制逻辑甚至可能是异步输入的。这时候直接把异步信号接进状态机就会引入亚稳态风险。运气好的时候一切正常运气不好的时候状态机会在一个非预期的周期内跳变表现出来的就是“偶发多跑一个字节”或者“偶发丢一个字节”这种bug极难复现。正确的思路是把“CS拉高”作为一个外部事件来对待先用同步器打两拍消除亚稳态再做边沿检测产生一个单周期的脉冲用这个脉冲去驱动状态机的转移。这一步的原理和按键消抖是同一套思路只不过SPI场景下对延迟的要求严格得多。2.2 一种可复用的帧结束检测模块下面这段Verilog是我在几个项目里反复用过的结构逻辑很简单但很实用。它接受经过同步的CS信号输出一个单周期的frame_end脉冲和当前帧状态module spi_frame_detect ( input wire clk, input wire rst_n, input wire cs_raw, // 片选信号可能是内部逻辑产生或外部输入 output wire frame_end, // 单周期脉冲表示一帧结束 output wire frame_active // 高电平表示CS处于有效状态 ); reg cs_d1; reg cs_d2; reg cs_d3; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cs_d1 1b1; // 默认高电平即片选无效 cs_d2 1b1; cs_d3 1b1; end else begin cs_d1 cs_raw; cs_d2 cs_d1; cs_d3 cs_d2; end end wire cs_sync cs_d2; // 同步后的CS wire cs_rising cs_d2 ~cs_d3; // 上升沿即帧结束事件 assign frame_active ~cs_sync; assign frame_end cs_rising; // 单周期高脉冲 endmodule这里有个关键细节cs_d1和cs_d2是两级同步器负责把异步CS信号拉到本地时钟域。cs_d3这级单独拿出来是为了做边沿检测它需要在cs_d2稳定之后再去比较否则会出现毛刺。如果你把cs_rising写成cs_d1 ~cs_d2也不是不行但在高时钟频率下容易抓到一个中间态的误触发我不推荐。在状态机里用的时候我习惯这样接主状态机在发送完最后一个SCLK后不急着回IDLE而是先进入一个WAIT_CS_DEASSERT状态等到frame_end脉冲到来才真正回到IDLE。这样一来发送逻辑和帧结束检测逻辑解耦CS的管理变成了一种握手信号而不是“发送完了顺便拉一下CS”。2.3 滤波窗口与关键时序参数光有上升沿检测还不够。真实板子上CS信号可能会有毛刺比如因为走线过长、邻居信号串扰、或者从机在帧内短暂释放CS。虽然正常情况下CS一旦拉低就要保持完整帧但毛刺会直接破坏这个假设。解决办法是在边沿检测之后加一个“持续有效确认”。具体来说检测到CS上升沿后不要立刻认定帧结束而是开启一个宽度为N个时钟周期的窗口要求CS在整个窗口内保持高电平才认为这是一个真的结束事件。这个N怎么取最好根据SCLK周期来定一般取大于主时钟的4到8个周期即可既能滤掉毛刺又不会让状态机长时间卡在等待里。这里就要看从机手册里的时序参数了最常用的是CS high time一般叫tCSH和CS setup timetCSS。主设备的帧结束检测延迟必须满足从最后一个SCLK边沿到CS拉高之间的间隔不小于从机要求的tCSSCS拉高后的保持时间不小于tCSH。如果你的检测逻辑引入的额外延迟过长那高吞吐场景下帧间隙会变大反之如果延迟太短从机可能还没准备好下一帧。我一般在FPGA里会做一个可配置的计数器把帧结束确认窗口做成寄存器可调。调试的时候先用示波器实测CS和MISO的释放关系再回头把窗口值定下来这样比拍脑袋设一个值靠谱得多。3. 软件侧实现标志位、DMA与超时兜底3.1 轮询状态位和延时之间的区别在MCU上做SPI主机很多人习惯用“发送完了延时几个微秒再拉高CS”。这个做法在低速、单任务、没有中断抢断的场景下也许没什么问题但一旦系统里跑着RTOS或者有高优先级中断延时就不是你代码里写的那个延时了。中断来了CPU去跑中断处理CS就一直悬着从机可能因为CS持续有效时间过长而进入错误状态这比CS拉晚了更隐蔽。所以MCU侧的第一条原则是不要靠裸延时判断帧结束而是靠外设的状态标志。几乎所有SPI外设都会有“发送空”、“传输完成”、“忙”这类标志位。STM32上的BSY标志就是典型代表它表示移位寄存器是否还在工作。你在拉高软件CS之前应该先等BSY清零再拉CS。// 发送一帧并等待总线完全空闲 static void spi_send_frame(SPI_TypeDef *spi, const uint8_t *data, uint16_t len) { // 拉低片选 GPIO_ResetBits(CS_PORT, CS_PIN); for (uint16_t i 0; i len; i) { // 等发送缓冲区空 while (SPI_I2S_GetFlagStatus(spi, SPI_I2S_FLAG_TXE) RESET); SPI_I2S_SendData(spi, data[i]); } // 关键等BSY清零确保移位寄存器已经移完所有位 while (SPI_I2S_GetFlagStatus(spi, SPI_I2S_FLAG_BSY) SET); // 此时才能安全拉高片选 GPIO_SetBits(CS_PORT, CS_PIN); }这段代码的逻辑顺序很重要。如果把BSY等待放在前面发完数据直接拉CS那在高速时钟下最后一个字节可能还滞留在移位寄存器里。你这边CS一拉高从机那边还在等剩下的时钟沿帧结束时序就乱了。3.2 软件CS的释放时机硬件片选和软件片选是SPI里一个经典选择。硬件片选由外设自动控制SCLK结束的同时会自动拉高CS延迟极小但是灵活性差比如想做帧间隔微调、想在CS拉高之后再插入几个空时钟周期作为从机处理时间硬件片选就不太好实现。软件片选灵活但所有时序全靠代码掌控这时“释放CS的时机”就成了最考验人的地方。我见过一个真实案例工程师用软件片选传输完成中断里直接拉高CS看起来没问题。但那个芯片的SPI外设DMA传输完成中断实际是在最后一个数据从DMA搬到发送寄存器时就触发了而不是在移位寄存器完全移完后触发。结果就是DMA中断响了CS拉高了最后两个bit其实还在线上跑从机收到半个字节数据全乱。这个坑在STM32F1系列上特别常见因为老手册里对BSY标志的描述不够清晰。解决方式就是上面代码里的“等BSY清零再拉CS”。不同厂家外设的标志位命名不一样但思路一致找“忙”标志等它清零。如果寄存器里没有BSY那可以用“发送完成”标志但要确认这个标志的定义是指移位结束而不是数据进入移位寄存器这个必须翻参考手册确认。3.3 DMA完成中断不等于帧结束DMA参与SPI传输时帧结束检测的复杂度会上一个台阶。DMA完成中断只代表你要发送的N个字节已经全部从内存搬到了SPI发送寄存器并不代表SPI已经把数据全部移出了引脚。正确做法是用DMA的完成中断去启动“等待BSY清零”的逻辑而不是在DMA完成中断里直接拉CS。如果外设支持“传输完成中断”很多芯片的SPI在移位结束后会产生一个独立的传输完成事件那用这个中断会更直接。比如新唐的NUC系列、ST的STM32H7系列都有比较清晰的TC事件。伪代码思路大致是void spi_dma_tc_isr(void) { // DMA搬运完成但总线可能还没移完 spi_wait_busy_idle(); // 等待BSY清零或等待TC事件 spi_cs_high(); // 这时再释放片选 spi_set_flag(FRAME_DONE); // 告知业务层本帧结束 }这里有个取舍如果每次都等BSY清零那在高速传输下主循环会浪费很多CPU时间。所以实际项目里我会区分对实时性要求高的短帧用中断里轮询BSY的方式因为轮询时间很短对大数据块传输则预先规划好让DMA的结束事件和CS释放之间只隔一个中断响应时间这样效率最高。4. 实操实录一个完整案例的调试过程4.1 需求与参数确定我之前做过一块板子主控是XC7A35T从机是一颗SPI接口的ADC工作在20MHz SCLK下要求每一帧固定读8字节帧间隔不小于1us。主控内部有一个内部寄存器需要记录“上一帧已结束”因为它要决定是否启动下一轮的采样触发。硬件上CS由FPGA内部逻辑产生没有经过外部引脚再返回所以在FPGA内部看起来是同步信号。但为了保险我还是按异步信号处理先做同步再检测。这里的参数是主时钟100MHzSCLK 20MHz所以一个SCLK周期是5个主时钟周期。帧结束确认窗口我取了16个主时钟周期也就是3.2个SCLK周期既能滤掉亚稳态带来的不确定又不会明显拖延帧间隔。还有一个关键参数是CS拉高后的保持时间。ADC手册要求CS高电平至少维持100ns也就是10个主时钟周期。因此状态机在确认帧结束后不能立刻允许下一次CS拉低而是要再等一个100ns的定时器。这两个时间参数加起来帧间隔约260ns满足1us的要求绰绰有余。4.2 硬件方案落地状态机的设计分四拍IDLE、WAIT_CS_LOW、TRANSFER、WAIT_FRAME_END。IDLECS默认高检测到start信号后跳WAIT_CS_LOW。WAIT_CS_LOW拉低CS等CS稳定后再等几个时钟周期满足tCSS然后跳TRANSFER。TRANSFER产生20MHz SCLK和MOSI数据发送完8字节后不直接回IDLE而是跳WAIT_FRAME_END。WAIT_FRAME_END等待frame_end脉冲到来然后启动100ns定时器完成后回IDLE。核心检测模块就是前面写的spi_frame_detect。它的输入cs_raw接的是状态机自己产生的CS寄存器但因为经过了同步器和边沿检测所以状态机看到的是一个经过处理的“事件”而不是直接看到自己拉高的CS信号。// 状态机片段 localparam IDLE 3d0; localparam WAIT_CS_LOW 3d1; localparam TRANSFER 3d2; localparam WAIT_FRAME_END 3d3; reg [2:0] state; wire frame_end; wire frame_active; spi_frame_detect u_frame_detect ( .clk (clk), .rst_n (rst_n), .cs_raw (cs_reg), .frame_end (frame_end), .frame_active(frame_active) ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin cs_reg 1b1; state IDLE; end else begin case (state) IDLE: begin if (start_pulse) begin cs_reg 1b0; state WAIT_CS_LOW; end end WAIT_CS_LOW: begin // 等待几个周期确保从机准备就绪 if (cs_settle_cnt CS_SETTLE_MAX) begin state TRANSFER; end else begin cs_settle_cnt cs_settle_cnt 1b1; end end TRANSFER: begin // 发送逻辑发送完成后拉高CS if (byte_done bit_done) begin cs_reg 1b1; state WAIT_FRAME_END; end end WAIT_FRAME_END: begin // 等待帧结束检测模块给出确认 if (frame_end) begin wait_cnt 0; state IDLE; end end endcase end end这里最关键的一点是TRANSFER状态里一发送完就把CS拉高了但状态机并没有立刻认为帧结束而是等检测模块的frame_end脉冲。这个脉冲来自同步后的CS上升沿也就是说状态机至少会多等几个时钟周期确保了CS信号在总线上已经稳定为高而不是刚拉高还没稳定就被当成“结束”了。4.3 软件方案落地在同一块板子上我还用一颗MCU做辅助监视MCU和FPGA之间用另一条SPI线通信。MCU侧我直接采用上面那套BSY轮询逻辑但在BSY等待之前加了超时保护。原因是如果FPGA因为某种原因没有正确释放CSMCU的BSY标志可能会一直不清如果代码死等整个系统就挂死了。超时的具体做法是用DWT时钟计数器或者SysTick在进入等待前记录当前时间如果超过某个阈值比如总线上允许的最长字节传输时间加20%余量还没有BSY清零就主动拉高CS并报告错误。这跟看门狗的思路一样不是为了提高可靠性而是为了把故障限制在一个可控范围内不至于让整机失去响应。uint32_t timeout 10000; // 根据SCLK和帧长计算 while (SPI_I2S_GetFlagStatus(SPIx, SPI_I2S_FLAG_BSY) SET) { if (--timeout 0) { SPI_Cmd(SPIx, DISABLE); // 强制停止SPI GPIO_SetBits(CS_PORT, CS_PIN); return ERR_SPI_TIMEOUT; } } GPIO_SetBits(CS_PORT, CS_PIN);这个超时机制在调试阶段帮了我大忙。有一版固件因为时钟配置错误SCLK实际频率只有预期的一半导致所有帧的BSY等待时间都变长。如果没有超时现象只会是偶发的CS提前拉高很难定位有了超时报警一下子就看到了“每个帧都接近超时阈值”的异常顺着查才发现时钟树配置出了问题。5. 常见问题速查与排障技巧5.1 典型故障现象与原因故障现象可能原因排查方向最后一字节偶发错误或丢失软件CS拉高过早移位寄存器未移完检查是否等待BSY清零后再拉CSCS拉高后MISO仍有毛刺从机释放MISO时间过长主设备未留足够帧间隙查看从机手册的tri-state时间增加帧间隔偶发多读一个字节CS毛刺导致帧结束检测误触发在检测逻辑中增加滤波窗口帧间隔抖动明显时大时小帧结束检测依赖软件延时而非硬件标志改用状态标志、DMA完成事件或硬件检测逻辑DMA传输完成但数据仍然不对DMA完成中断在移位结束前触发确认外设TC事件定义增加BSY等待提高SCLK频率后整帧数据全乱CS拉低到第一个SCLK沿的建立时间不足检查tCSS在CS拉低后增加延迟代码运行一段时间后CS一直为低从机异常拉低CS或主机状态机卡死增加CS空闲超时监测强制复位恢复5.2 排查思路与工具排查帧结束问题时示波器是必备工具但只看CS和SCLK是不够的必须要同时看MISO。三个通道的时序关系能告诉你CS拉高时最后一个bit是否已经稳定出现在MISO上CS拉高后MISO是否按预期释放为高阻SCLK最后一个脉冲到CS上升沿的间隔是否满足从机要求。用示波器时把触发设在CS下降沿观察完整的帧周期然后重点看帧末尾。如果CS上升沿和MISO最后一个有效位之间有明显的重叠毛刺那就是释放时间不够。如果CS上升沿发生后下一个SCLK才姗姗来迟那就是帧结束检测逻辑延迟过大导致帧间隙拉长影响吞吐。如果手头有逻辑分析仪可以同时抓主设备和从设备两侧的信号。因为逻辑分析仪输入阻抗高对总线影响小适合观察片选释放和从机响应的延迟。我调试那块ADC时就是靠逻辑分析仪抓到了从机MISO释放延迟超过了预期进而调整了帧间隙参数。另外不要忽略代码审查之外的“硬件级”排查。CS线上的过冲和振铃也可能导致虚假的沿。如果CS线走线过长建议在从机端加一个几十pF的小电容略微减缓边沿能有效降低误触发概率。时钟线的串阻同理一般几十欧姆就能明显改善信号质量。5.3 独家避坑技巧分享几个文档里不会写的经验。第一使用软件CS时CS的拉高动作最好用位带操作或者直接寄存器写不要用库函数里的GPIO_SetBits加一层函数调用。中断里调用库函数会拖长从“确认帧结束”到“执行CS拉高”的时间这个时间在示波器上看起来很短但可能正好把从机的时序推向临界点。第二如果你用的MCU支持SPI的“自动CS释放”功能不要以为万事大吉。自动片选通常跟着发送缓冲区的状态走而不是跟着移位寄存器走和DMA完成中断是同一个坑。很多芯片有“SOF/EOF”事件可以产生真正的帧结束中断这类事件才值得依赖。第三在FPGA侧帧结束检测模块的复位值要特别小心。CS在复位期间必须是无效电平也就是高电平。如果复位时CS恰好为低状态机会在复位释放后立刻检测到一个上升沿误判为一帧结束。我之前就把复位值写反过导致上电后第一个frame_end脉冲时序完全不对。6. 一些进阶想法6.1 多字节帧与连续传输上面讨论的例子都是固定字节长度的帧。如果一帧长度不固定比如先从设备发来长度字节再决定后续读多少字节那帧结束检测就不能只靠CS事件了。这种情况下我会把“帧结束”拆成两级一级是字节级每个字节传输完成后都产生一个字节完成事件另一级是帧级在收到长度字节后软件或状态机计算出还需要多少个字节等这些字节的完成事件都到齐之后再主动拉高CS、等待CS上升沿确认。这里容易出现的问题是多字节帧时CS在整个帧期间持续为低帧结束检测模块如果只在CS上升沿触发那它并不知道“这是哪一帧的结束”因为它只是个脉冲信号。所以状态机里要维护当前帧的ID或者剩余字节计数在确认结束后做一次校验看是否和预期相符。如果CS提前拉高了而字节计数还没到那说明传输出错立刻报错而不是沉默。6.2 跨时钟域与总线释放主设备如果和从设备不在同一个地平面或者中间有隔离器件那么CS、SCLK、MISO之间的相位关系会变得更复杂。隔离芯片会引入几十纳秒的传播延迟而且CS路径和SCLK路径的延迟可能不一致导致从机看到的CS和SCLK相对关系与主机侧不一样。这种情况下我建议在帧结束检测之后额外增加一个“总线静默”窗口。在确认CS拉高之后等足够的时间让隔离芯片、电平转换电路、总线上最后的电荷都消散干净再把总线状态标记为空闲。这个窗口不要用固定延时最好用从机手册里最大的CS高到MISO释放时间再加20%的余量。虽然牺牲了一点帧间隔但换来的是系统的确定性。6.3 检测可靠性怎么量化聊了这么多最后说说怎么衡量检测方案靠不靠谱。我通常用两个指标一个是误报率也就是在没有结束事件的时候错误产生结束脉冲的概率另一个是漏报率也就是真正结束但没检测到、要等到超时或者下一帧才发现问题的概率。这两个指标在统计意义上很难做到绝对为零但可以通过设计来压到极低。误报率主要靠滤波窗口和边沿检测的严谨性来控制。漏报率主要靠超时兜底和状态机监控来控制。具体测试方法也不复杂让主设备长时间连续发帧比如跑一晚统计从机报错次数再人为在CS线上注入毛刺看检测模块会不会误触发最后把SCLK频率以5%的步进往上调直到出现错误记录临界点。通过这些测试拿到的数据比任何静态分析都有说服力。我在实际测试中连续跑24小时、传输几十万帧之后如果一帧错误都没有基本可以认为这套帧结束检测方案在当前的硬件环境下是可靠的。但这不代表换一块PCB布局还能保持同样表现换板之后一定要重新跑一遍同样的压力测试因为走线长度、地平面噪声都会改变信号边沿的质量进而影响检测模块的实际裕量。最后聊点我的经验最容易被低估的不是检测逻辑本身而是CS拉高之后的那一段“看似无事发生”的时间。很多从机在CS拉高之后还要做内部数据的锁存和转换如果你没给够这段安静时间下一个帧再来的时候从机可能还停在上一帧的处理里。所以每次设计SPI主设备我都会在帧结束检测之后故意多留一点空白宁可让帧间隔稍微长一点也不去赌从机内部的响应速度。这个习惯帮我躲掉了不少数据偶发错误的坑。
返回列表