为什么需要超时——不设看门狗的后果

超时机制,说白了就是给系统装了一个“倒计时闹钟”。

我在调试一个 PCIe 设备的时候遇到过这样的事:主机发了一个读请求,从设备一直在忙,就是不回应。主机就那么干等着,等了一秒、两秒、十秒……整个系统像死了一样。你想想看,如果这个时候有超时机制,主机等到一定时间没回应,直接报错或者重试,那系统还能自己恢复。没有超时,CPU 就被卡死在等待状态里。

所以,超时的核心价值就是:防止系统因为一个外设不响应而整个挂死。

超时的典型应用场景

  • 总线事务等待:PCIe、I2C、SPI 等协议中,主设备发起事务后,从设备必须在规定时间内应答
  • 固件引导:MCU 上电后等待外设初始化,超时则跳过或报错
  • 通信协议重传:网络协议栈中等待 ACK,超时则重发数据包
  • 硬件复位:看门狗定时器溢出时触发系统复位

超时时钟——计时的心脏

超时时钟,就是驱动计数器累加的那个周期信号。它可以是独立的硬件振荡器,也可以从系统时钟分频得到。

我之前在一个项目中就栽过:用了一个 RC 振荡器做超时时钟,结果温度一变,频率漂了 30%。本来设计超时 1ms,低温下变成了 1.3ms,高温下变成了 0.7ms。后来换了晶振,问题才解决。

时钟源的稳定性,直接决定了超时精度的上限。

时钟类型 精度 适合场景 注意点
RC 振荡器 ±20%~±50% 看门狗、粗略超时 温漂大、电压敏感
晶振 ±10ppm~±100ppm 总线超时、精密控制 成本高,起振慢
PLL 分频 取决于参考源 SoC内部外设 需要锁相环稳定时间
系统时钟直接分频 与系统时钟一致 软件定时器 受系统休眠影响

超时计数器——倒计时的实现方式

超时计数器有两种主流实现:递减计数器和递增计数器。我个人更偏爱递减的方式,为什么呢?

你想想看,递减计数器只需要在计数器中预装一个超时阈值,每个时钟周期减 1。减到 0 的时候,产生超时信号。这个过程不需要额外的比较器,硬件开销小。

递增计数器则相反,从 0 开始加,每个时钟周期加 1。加满溢出了,就产生超时信号。这种方式的缺点是,阈值必须和计数器的满量程匹配。如果想调整超时时间,就得改阈值寄存器的值。

两种方式的对比见下表:

特性 递减计数器 递增计数器
硬件复杂度 低(只需预装、减法、零检测) 中(需要加法、满值比较)
超时阈值调整 预装值可任意修改 需修改比较寄存器
精度损失 无额外损失 需考虑比较延迟
重载操作 只需重新写入预装值 需清零计数器与清除比较标志

超时状态机——计数器的骨架

计数器不能自己乱跳,它需要状态机的控制。一个典型的超时状态机有 4 个状态:

  1. 空闲(Idle):计数器不工作,保持复位值
  2. 预装(Load):将超时阈值写入计数器
  3. 计数(Count):每个时钟节拍执行加/减操作
  4. 超时(Timeout):计数器达到阈值/归零,产生中断或标志
// Verilog 示例:32位递减超时计数器
module timeout_decrementer (
    input  wire       clk,
    input  wire       rst_n,
    // 控制接口
    input  wire       start,      // 启动计时
    input  wire       stop,       // 停止计时
    input  wire [31:0] time_val,  // 超时阈值(时钟周期数)
    // 输出
    output reg        timeout,    // 超时标志(高电平有效)
    output reg [31:0] count_val   // 当前计数值
);

    reg [1:0] state, next_state;
    localparam IDLE  = 2'd0,
               COUNT = 2'd1,
               TIMEO = 2'd2;

    // 状态转移
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) state <= IDLE;
        else        state <= next_state;
    end

    // 组合逻辑:下一状态判断
    always @(*) begin
        next_state = state;
        case (state)
            IDLE:  if (start) next_state = COUNT;
            COUNT: if (stop)  next_state = IDLE;
                   else if (count_val == 32'd0) next_state = TIMEO;
            TIMEO: next_state = IDLE;  // 自动返回空闲
        endcase
    end

    // 计数器操作
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            count_val <= 32'd0;
            timeout   <= 1'b0;
        end else begin
            case (state)
                IDLE: begin
                    timeout <= 1'b0;
                    if (start) count_val <= time_val;
                end
                COUNT: begin
                    count_val <= count_val - 32'd1;
                end
                TIMEO: begin
                    timeout <= 1'b1;
                end
            endcase
        end
    end

endmodule

超时事件的响应——超时之后怎么办

计数器归零了,触发了超时标志。然后呢?

我记得有一次调试,发现设备偶尔会报超时,但是超时之后什么都没发生。查了半天,原来中断服务函数里只是清了一下标志,没做任何恢复操作。结果设备一直被卡在等待队列里,永远恢复不了。

超时后的行动一定要明确,常见的有:

  • 重试(Retry):重新发起总线事务,适合瞬态错误
  • 中断复位(Reset):将外设或总线状态恢复到已知点
  • 报错上报(Error Report):记录错误日志,通知上位机
  • 回滚操作:撤销之前的部分写操作,保持数据一致性

我之前在调试一个 PCIe 设备时就遇到过一个坑:重试次数没有做限制,一旦链路出现断连,设备就会无限重试,最终耗尽 DMA 描述符。后来加了最大重试次数(比如 3 次),超限后直接触发硬件复位,问题就解决了。

超时参数设置——不能拍脑袋

我曾经见过一个工程师把 I2C 超时设成了 10us,结果总线上的电容耦合稍微大一点,每次通信都超时。反过来,设得太长又会影响系统实时性。

超时时间设置的黄金法则

  • 下限:大于最差情况下的正常响应时间 + 时钟抖动 + 传输延迟
  • 上限:小于事务对系统可用性的容忍度
  • 一般取正常响应时间的 2~5 倍作为安全余量

警告:别用软件延时替代硬件超时

软件轮询 + nop 延时看起来简单,但一旦 CPU 频率变化、中断被打断、发生上下文切换,那个“延迟”就完全不准了。我用示波器测过,软件延时误差可以达到 100% 以上。硬件定时器是唯一可靠的选择。

超时机制原理 - 体系结构图 超时机制体系核心结构 超时时钟源 RC/晶振/PLL分频 时钟分频器 调整计数节拍 超时计数器 递减/递增模式 32位/16位/8位 预装与重载 状态机控制器 IDLE/LOAD/COUNT/TIMEO 归零/溢出检测 比较器逻辑 超时信号输出 中断/复位/重试 超时参数 时钟频率 预装阈值 安全余量 常见坑点 时钟不稳定 忽略边界值

几种常见超时场景的实测数据

在实际调试中,我积累了不同总线的超时参数,给大家参考:

总线/协议 典型超时值 时钟源 计数器位数 我的建议
I2C(标准模式100kHz) 100ms ~ 1s 系统时钟 / 1000 16位 设成 500ms,兼容慢速设备
SPI(20MHz) 10μs ~ 1ms PLL 分频 16位 最快响应+软件补偿
PCIe(Gen3) 50ms(Completion Timeout) 100MHz 参考时钟 32位 按协议规范设,别改
UART(115200) 1ms ~ 100ms 波特率发生器分频 8位 按帧间隔的 3倍设
看门狗定时器 0.5s ~ 10s 独立 RC/LPO 32位 设成主循环周期的 2倍

小结一下

超时机制,本质上就是一个时钟驱动的计数器在状态机控制下的运转过程。它不是什么高深的技术,但一旦出问题,后果很严重。

我见过太多工程师把超时当成“最后随便设一个参数”的事情。嗯,千万别这么做。时钟源的精度、计数器的位宽、超时后的响应动作、重试次数限制,每一个细节都值得你花时间去推敲。

你想想看,一个 16 位计数器在 100MHz 时钟下,满量程只有 655μs。一次 DMA 传输稍微慢点,超时就到了。如果你不知道这个细节,调试的时候只会觉得“代码没问题啊”,却永远找不到那个隐藏的溢出点。