边沿检测与脉冲展宽:上升沿/下降沿检测、单脉冲生成与窄脉冲拉宽
更新时间:2026-09-24。本文是「集成电路大类 · 芯片设计」进阶层(intermediate) 的一篇。按键、中断脚、外部触发、来自其他时钟域的事件——这些信号进芯片时大多只是「电平变了一下」,而数字电路想要的却是「在变化的那一刻,干一件事」。把「电平变化」翻译成「一次脉冲」的电路就是边沿检测(edge detector);当这个脉冲太窄、下游采样不到时,再把它拉宽的电路叫脉冲展宽(pulse stretcher)。两者都只有几级触发器加几行组合逻辑,却是中断控制器、输入同步器、跨时钟域接口里反复出现的骨架。异步输入为什么要先打两拍,见 CDC 基础;亚稳态的定量分析见 MTBF 计算与测量。
本文要回答的问题
- 上升沿、下降沿、双沿检测的逻辑式分别是什么,为什么必须「延迟一拍后再比较」?
- 异步输入为什么不能直接进检测逻辑,两级触发器同步是怎么防亚稳态的?
- 怎样保证输出脉冲恰好只有一个时钟周期宽,连续两个周期高电平不被误判成两次?
- 脉冲展宽用移位寄存器和计数器两种写法各有什么取舍,宽度按什么算?
- 跨时钟域传脉冲时,单纯展宽为什么不够,握手和电平翻转怎么补?复位释放瞬间的假沿从哪来、怎么消?
一、边沿检测:同步、延迟、组合求沿
边沿检测的核心思想一句话:把信号打一拍存起来,然后让「当前值」和「上一拍的值」做组合比较。两拍值不一致的那个周期,就是电平发生跳变的周期;按跳变方向再做区分,就能分别得到上升沿、下降沿和任意变化。下面是一份带异步输入同步、可直接综合的实现:
module edge_detect (
input logic clk, rst_n,
input logic in, // 可能是异步输入
output logic rise, // 上升沿:0 -> 1
output logic fall, // 下降沿:1 -> 0
output logic change // 双沿:任意跳变
);
logic in_d1, in_d2;
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) begin
in_d1 <= 1'b0; // 同步级 + 求沿级都给初值
in_d2 <= 1'b0;
end else begin
in_d1 <= in; // 第一拍:同步
in_d2 <= in_d1; // 第二拍:延迟值
end
assign rise = in_d1 & ~in_d2;
assign fall = ~in_d1 & in_d2;
assign change = in_d1 ^ in_d2;
endmodule代码里 in_d1 同时承担了「同步异步输入」和「求沿的当前值」两个角色,in_d2 是它再延迟一拍的镜像。三个输出都是纯组合信号,每当 in_d1 与 in_d2 不同时拉高,且只持续一个时钟周期——下一个沿到来时 in_d2 追上 in_d1,差异自动消失。电路结构如下图。
读图抓住数据的两条腿:输入经 D1、D2 逐级延迟,形成「当前」和「过去」两个时间断面;组合逻辑每个周期都把这两个断面并排比较一次。波形注释里可以看到,rise 的脉冲恰好嵌在 in_d1 已高、in_d2 尚未变高的那一个周期里,这就是「延迟一拍比较」的几何含义。如果省掉 D2、直接拿 in 去和它自己比,逻辑上永远相等,电路什么沿也抓不到。
二、三种沿的逻辑对比与使用场合
三种检测的差别只在比较式,但选错会直接造成功能错误。下面把表达式、触发时刻和典型用途放在一起对比。
| 检测类型 | 组合表达式 | 拉高的条件 | 典型用途 |
|---|---|---|---|
| 上升沿检测 rise | in_d1 & ~in_d2 | 上一拍为 0、当前为 1 | 按键按下、中断置起、使能开启、计数器启动 |
| 下降沿检测 fall | ~in_d1 & in_d2 | 上一拍为 1、当前为 0 | 按键释放、忙信号结束、传输完成(低有效) |
| 双沿检测 change | in_d1 ^ in_d2 | 两拍值任意不同 | 测量信号周期/频率、电平翻转同步、唤醒事件 |
有两个细节值得强调。其一,rise 和 fall 在同一时刻不可能同时有效,change = rise | fall,所以双沿检测不会比单沿多抓事件,只是不区分方向;想统计「事件次数」用 change,想知道「来的是哪一种」就用 rise/fall。其二,输入必须在目标时钟域里保持至少一个完整周期,沿才会被看见——一个比时钟周期还窄的毛刺会被同步器整段吞掉。这既是限制也是福利:同步器天然滤除了亚采样宽度的噪声,但真正的窄事件也因此需要先展宽,这是第五节的主题。
用一个测量的例子把「双沿计数」坐实。假设被测方波接到 100MHz 时钟域的 in,用 change 去驱动一个事件计数器,每两个 change 对应一个完整方波周期:若一秒内数到 20000 次跳变,方波频率就是 10kHz,周期 100μs。这个方案测低频信号很省心,但被测频率接近采样时钟一半时,多拍同步和偶尔的采样相位滑动会让计数失真,因此高频测量要走专门的高频采样或时间内插方案,而不是无限提高检测时钟。
检测、单脉冲、展宽这三个常常被混为一谈的概念,用途其实层层递进:
| 电路 | 输入 | 输出 | 回答的问题 |
|---|---|---|---|
| 边沿检测 | 任意电平信号 | 跳变那一拍的标志 | 信号什么时候变了?朝哪个方向变? |
| 单周期脉冲生成 | 长电平/按钮状态 | 恰好一个周期宽的脉冲 | 怎样把「按了一下」变成「执行一次」? |
| 脉冲展宽 | 窄脉冲(常来自快域/异步) | 至少 N 拍宽的脉冲 | 下游时钟太慢,怎样保证它一定采得到? |
三、异步输入为什么必须先两级同步
如果 in 来自芯片管脚或者另一个时钟域,它的跳变时刻与本时钟没有任何相位关系,完全可能落在触发器的建立/保持窗口附近。此时触发器采到的既不是干净的 0 也不是干净的 1,输出会进入亚稳态(metastability):在一段时间内停在非法电平上,再随机收敛到 0 或 1。更麻烦的是,收敛结果与真实输入无关——同一信号这次被采成 0、下次被采成 1 都有可能。若把这种信号直接送去求沿,后级就会看到根本不存在的跳变,造成误中断、误启动。
两级同步的做法是让异步输入先经过 D1:D1 的输出即使亚稳,也有整整一个时钟周期的时间在传向 D2 之前收敛;等到 D2 采样时,信号以极高概率已经是合法电平。它并不能让亚稳态「不可能发生」,而是把 MTBF(平均故障间隔时间)拉长到远超设备寿命。同步器的级数、触发器速率和工艺都影响这个概率,定量关系见 MTBF 计算与测量,建立/保持时间的物理成因见 建立时间、保持时间与亚稳态。
工程上要给同步器加约束:两级触发器要在布局布线上贴在一起、中间不插任何组合逻辑,综合工具一般通过 (* ASYNC_REG = "true" *) 属性识别这条链。注意同步付出的代价是延迟——异步事件要两拍之后才体现在 rise 上,做精确时间测量时这两拍延迟要算进账;另外,慢于采样的窄脉冲可能被漏掉,这正是脉冲展宽要解决的问题。
同步级数也是个工程选择,而不是「两级是神谕」。每增加一级,亚稳态输出被再次采样、从而又获得一个完整周期收敛时间,MTBF 指数级改善,但代价是多一拍延迟、多一个触发器。绝大多数 SOC 接口取两级;高速工艺、高翻转率输入或可靠性要求苛刻的汽车设计里常见三级。反过来,只在同一个时钟域内部传递的信号绝不要画蛇添足地加同步器——同源信号本就不可能亚稳,加了只白带延迟。判断一条寄存器链是不是同步器,看的是它的数据起点是否在另一个时钟域或管脚,而不是信号名字。
四、从电平到单周期脉冲:防重复、防粘连
第一节的 rise 本身就是单周期脉冲,但实际输入常常不配合:机械按键按下后电平会持续几十毫秒,中间还带着抖动;使能位可能被软件一直置住。如果直接用「电平有效」去触发累加、启动传输,操作会每个时钟周期重复执行。上升沿检测天然解决了这个问题——电平持续高多久,rise 都只在 0→1 的那一拍出现一次。按键场景在检测前还应加去抖(在若干毫秒内要求电平稳定),否则一次按压的抖动会被当成多次沿。
还有一种常见写法是「启动—完成」式状态脉冲:状态位被任意来源置起,由本时钟逻辑在响应后的同一个周期自动清零。下面的示例中 start 可以是异步置位请求,响应脉冲 do_it 保证只宽一拍:
logic start_req;
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) start_req <= 1'b0;
else if (start_set) start_req <= 1'b1; // 外部请求置位
else start_req <= 1'b0; // 下一拍自动清除
wire do_it = start_req; // 恰好一个周期宽要点是「置位」和「清除」在同一时钟过程里裁决,脉冲宽度由寄存器保证,不依赖外部信号长什么样。切忌用两个触发器输出的边沿去拼组合脉冲再当另一个模块的时钟使能之外的用途——组合毛刺在深组合路径上随时可能出现。
按键去抖值得单独交代。机械触点在合拢和弹开的几毫秒内会快速通断,边沿检测忠实地把每一次抖动都报成「按了一次」,菜单选择就会连跳好几格。标准做法是在检测前加去抖窗口:电平变化后启动一个若干毫秒(常用 5~20ms)的计数器,期间只要电平再变就重新计时,连续稳定到窗口结束才认可这次变化,然后才进入求沿逻辑。去抖时间要按按键的机械特性选,窗口太短压不住抖动,太长则手感迟钝。FPGA 板卡上的用户键、嵌入式产品的面板键,几乎无一例外都带这一级。
五、脉冲展宽:移位寄存器与倒数计数器
当脉冲来自更快的时钟域,或者只存在几纳秒,下游慢时钟可能整段跳过它。脉冲展宽(pulse stretcher) 的思路是:检测到一次触发,就把输出强行维持至少 N 个周期。第一种实现是移位寄存器「装填—移出」:
module pulse_stretch_sr #(parameter W = 8) (
input logic clk, rst_n,
input logic trig_pulse,
output logic wide_pulse
);
logic [W-1:0] stretch;
always_ff @(posedge clk or negedge rst_n)
if (!rst_n)
stretch <= '0;
else if (trig_pulse)
stretch <= '1; // 触发:全部装填为 1
else
stretch <= stretch << 1; // 逐拍左移,补 0
assign wide_pulse = |stretch; // 任一位为 1 就保持高
endmodule触发后移位寄存器像一节节漏掉的水管,高电平从 W 个周期逐拍缩短到 0。第二种实现是「置位—倒数」计数器,面积随宽度按对数增长,适合展得很宽的场合:
module pulse_stretch_cnt #(parameter W = 16) (
input logic clk, rst_n,
input logic trig_pulse,
output logic wide_pulse
);
logic [$clog2(W+1)-1:0] cnt;
always_ff @(posedge clk or negedge rst_n)
if (!rst_n)
cnt <= '0;
else if (trig_pulse)
cnt <= W; // 重新装填(支持展宽期续期)
else if (cnt != 0)
cnt <= cnt - 1'd1;
assign wide_pulse = (cnt != 0);
endmodule注意计数器版本里 trig_pulse 的优先级最高:展宽期间再次触发会重新装填 W,脉冲自动续期;移位寄存器版本同理,连续事件不会丢失成「半截」。两种实现的时序如下图。
图中最值得看的是 trig_pulse 与 wide_pulse 的宽度比:一个周期的事件被换成四个周期的电平。宽度 W 不是拍脑袋定的,要按接收方时钟算:为保证慢时钟域至少采样到一次,展宽后高电平时间应覆盖慢时钟一个完整周期再加裕量,经验上取慢时钟周期的 1.5~2 倍对应的快域拍数。若展宽是给软件中断或轮询用,还要把软件最坏响应时间算进去。
两种写法的取舍:移位寄存器结构简单、每拍延迟均匀,但 W 个触发器随宽度线性增长;计数器只用 log₂W 个触发器,却带减法器和比较器,W 很小时反而不划算。一般 W≤16 用移位寄存器,更宽用计数器。
把宽度数字算一遍。假设事件发生在 100MHz 快域(周期 10ns),接收逻辑在 10MHz 慢域(周期 100ns)。慢域两个采样沿相隔 100ns,展宽电平若只维持 50ns,就存在两个采样沿恰好都落在它两侧之外的可能;取 2 倍慢周期加裕量,W 设为 25 拍(250ns),慢域在任何相位关系下都至少采到一次高电平,随后还能再采到一次回落,沿信息完整。若下游是软件中断,还要把中断延迟和关中断最长时间叠上去——比如最坏关中断 10μs,展宽就得做到 15μs 以上,否则中断重新使能时事件已经消失,中断控制器的边沿配置又会漏掉它。给 CPU 的中断脚因此常用电平中断配合展宽,而不是边沿中断。
六、跨时钟域:展宽加握手,以及复位假沿
必须清醒:单纯展宽只能降低漏采概率,不保证脉冲一个不丢。快域连续来两个脉冲时,展宽电平在慢域看来仍是一次高电平;慢域采样时刻若恰好骑在展宽脉冲的边界上,亚稳态风险依旧。对丢失零容忍的场合有两条正路:
- 电平翻转加反馈握手(toggle/握手同步):快域每来一个脉冲就翻转一根请求电平,慢域对该电平做两级同步、检测其沿(用的正是本文的电路!),处理完再翻转一根应答电平回快域;请求方看到应答才允许翻转下一次。每个事件都有确认,天然不丢。
- 异步 FIFO:事件可以携带数据、允许背靠背连续发生时,把请求写进异步 FIFO,用格雷码指针跨域传递读写地址。入门级脉冲同步的完整模板见 脉冲同步器,通用原则见 CDC 基础。
展宽在跨域场合仍有价值:它常作为握手链路上的物理层——先把毛刺级事件拉成干净电平,再交给同步器和握手逻辑。
最后一个隐蔽坑是复位释放造成的假沿。异步复位释放时刻与时钟沿无关,各级触发器退出复位的时间还有先后:若 in_d1、in_d2 初值不一致,或者复位撤销恰好在某个时钟沿附近,求沿逻辑会在开机头几拍看到一个并不存在的跳变。对策有三层:一是两级触发器都在复位分支给相同初值(本文代码已这么做),让「当前」与「过去」从同一电平起步;二是复位释放与时钟同步(复位同步器),让所有触发器在同一个时钟沿之后整齐离开复位;三是下游对开机头一两拍的事件做屏蔽,等流水线灌满再放行。软件可读的状态寄存器也应在复位后读到确定值,避免首次轮询误判「事件已发生」。
这一篇在知识体系里的位置
本篇属于芯片设计进阶层的时序基础,回答「怎样把一次电平变化可靠地变成一次、且仅一次操作」。向前读,电路的两块地基是计数器与移位寄存器和建立时间、保持时间与亚稳态;横向配套,单拍脉冲怎么安全跨域见脉冲同步器与 CDC 基础,亚稳态概率量化见 MTBF 计算与测量,连续携带数据的事件走异步 FIFO和格雷码指针;向后读,这套「求沿—展宽—同步」的组合会反复出现在中断控制器、握手总线和低功耗唤醒电路里。
总结
一句话总结:边沿检测把信号同步、延迟一拍后让当前值与上一拍值做组合比较——in_d1 & ~in_d2 抓上升沿、~in_d1 & in_d2 抓下降沿、in_d1 ^ in_d2 抓双沿,输出恰好一个时钟周期宽的脉冲,从而把「电平一直有效」变成「只执行一次」;异步输入必须先经两级触发器同步,把亚稳态收敛时间隔离在求沿逻辑之前;脉冲展宽用移位寄存器装填 W 个 1 或计数器装入 W 后倒数,把窄脉冲拉宽到至少 W 拍,宽度按慢时钟周期加裕量计算,展宽期再触发要能续期;但展宽只降低漏采概率、不保证不丢,严格场合应走电平翻转加反馈握手或异步 FIFO;复位释放时各级初值必须一致并最好同步撤离复位,否则开机头几拍的假沿会触发不存在的事件。