嵌入式系统中Delay的妙用:捕捉瞬时边沿、去抖与脉冲展宽
2026/9/10 18:47:08 网站建设 项目流程

做控制的年头多了,你会发现很多问题不是算法多复杂,而是系统根本没有在正确的时间点看到信号。我调试过一套自动化设备的IO输入板卡,客户反馈说设备偶尔会漏检一个传感器信号,现象是故障率大约千分之一,特别难复现。示波器挂上去抓了半天,终于逮到那个“幽灵事件”:传感器输出的有效脉冲宽度只有80微秒,而控制器的输入检测任务每2毫秒才扫描一次。80微秒的脉冲落进2毫秒的扫描周期里,绝大多数情况下都被采样点完美错过。这个案子最后不是靠复杂的算法解决的,而是靠着正确使用Delay——让底层把这根线上的瞬间边沿可靠地捕捉下来,再告诉上层ASW“刚才发生了一件事”。

这篇我想聊的核心就一句话:Delay在嵌入式系统里,不只是用来“拖延时间”的,它是捕捉瞬时边沿的关键手段。如果你平时写驱动、调接口,或者负责把底层信号抽象成语义事件交给应用层软件(ASW)使用,这篇文章应该对你有用。我会从边沿为什么会丢讲起,拆解Delay在采样、去抖、展宽中的三个角色,最后给出可以直接抄的代码和排查经验。

1. 边沿捕捉的底层逻辑:为什么瞬间事件容易被系统漏掉

1.1 边沿到底是什么:从电平跳变说起

边沿这个词,做硬件或者嵌软的都不会陌生。数字信号不是凭空出现的,不管是按键按下、编码器旋转、还是通信总线上的一个起始位,它们在电路层面最终都表现为一根电平线的跳变。从低到高叫上升沿,从高到低叫下降沿。这句话说起来简单,但工程上大家真正关心的其实不是那个跳变本身,而是跳变背后的“事件语义”。

举个具体例子。我在调试一台伺服驱动器的过程中,碰到过编码器Z信号输出的情况。Z信号每转一圈输出一个脉冲,用来给位置计数做归零校准。正常来说这个脉冲宽度在1微秒左右,非常窄。假如你只用普通的GPIO轮询去看这个信号,采样周期哪怕做到了100微秒,理论上也有99%的概率什么都看不到。但如果你理解了“Z信号是一个瞬时边沿事件”,你就会知道,正确做法是用带输入捕获的定时器通道去等这个边沿,并且用硬件记录边沿到达时的计数值。这背后依赖的还是“在正确的时间点采样”这一基本逻辑。

这里的关键点必须说清楚:边沿是一个瞬间量,它没有宽度,或者说宽度极窄。系统如果想要识别它,本质上需要回答两个问题:第一,电平从什么变成了什么;第二,这个变化发生在什么时候。第二个问题往往比第一个问题难得多,因为它要求系统有精确的时间基准。

1.2 轮询、中断、DMA:不同“看见”方式的天花板

既然要知道电平有没有发生跳变,最容易想到的办法就是不停地读这根线的状态。这里就有三种常见手段,各有各的天花板。

轮询最直白,定时去读GPIO电平,然后跟前一次读到的值比较。如果两个相邻采样点之间,信号已经跳了一个来回,程序就什么都没看到。做低功耗产品的时候这个问题尤其突出。设备在休眠模式下会用慢速时钟或低频任务去扫描唤醒引脚,扫描周期可能是几十毫秒,而外部唤醒信号也许只是一个几十毫秒宽的脉冲。采样太稀疏,这个脉冲就会从系统的眼皮底下溜走。所以轮询的瓶颈非常明确:采样频率必须远高于信号的变化频率,否则就漏事件。

中断比轮询跟进得及时,边沿一来就触发ISR。但中断也有代价。频繁的边沿会产生频繁的中断,如果ISR里处理不当,系统大部分时间都在来回切换上下文,整体实时性反而下降。而且瞬态很短、后面又跟了一串噪声的边沿,ISR很难单独判断“这到底是一次有效触发还是抖动”。中断可以告诉你“有东西发生了”,但经常无法告诉你“这件事是否有效”。

DMA和专用外设(比如输入捕获、正交解码器)是更高级的手段,它们本质上是在硬件层面完成电平采样、时间戳记录甚至计数。但外设资源总归有限:通道数量、引脚重映射、和低功耗模式的兼容性,每个都是约束。所以工程上最需要的,其实是利用好“时间”这个资源,配合已有外设,把边沿事件可靠地捡起来——这正是Delay要大展拳脚的地方。

2. Delay在边沿捕捉中的三个典型角色

2.1 采样节拍器:用固定延时做定时采样

第一种用法最基础,把Delay当作采样节拍器。逻辑很直白:每隔固定时间,读取一次引脚电平,把当前值和上一次的值做比较。发生跳变,就说明检测到了边沿。

这里有个问题会让很多新手犯难:固定时间到底选多少?我自己一般按两条约束来推。第一,采样周期必须小于最短脉冲宽度。假设信号中最窄的脉冲是200微秒,那采样周期最好不大于100微秒,至少留一倍余量;脉冲更窄,那就得再加密。第二,要给其他任务留出执行时间。采样太密,CPU会一直干这一件活,系统功耗和响应都会被拖垮。这两个约束之间是一个没有万能答案的取舍点,只能根据实际负载去调。

在实现上,采样节拍既可以靠阻塞式延时(比如常见的delay_ms/delay_us),也可以靠非阻塞的定时器回调。阻塞式延时代码最简单,但有个很大的坑:延时期间CPU被占死,如果此时有一个更高优先级的边沿需要处理,实时性就会被打折。我在第五部分会专门展开。我的建议是,如果工程里已有固定的时基任务(比如1毫秒或10毫秒的周期任务),直接把采样逻辑挂在这个任务里,比单独写一个阻塞延时循环要稳得多。

2.2 去抖过滤器:延时不是等待,而是确认

第二种用法是去抖。按键、继电器触点、霍尔传感器输出,都会因为机械或电气原因产生抖动。抖动在波形上是一连串密集的毛刺边沿,如果不处理,一次物理动作会被系统识别成很多次。

去抖的思路就是用Delay换时间窗口。常见做法是:检测到第一个边沿之后,不立刻确认,而是延时一段时间(比如10毫秒到20毫秒),等到这个窗口结束再重新读一次电平,如果电平仍然保持在目标状态,才认为这是一次有效事件。这种“先等再确认”的思路,本质上是把Delay当作了一个质量过滤器。

这里想强调一个容易被忽略的点:去抖Delay不是拍脑袋定的。它必须要大于抖动持续时间的最大值,同时要小于连续两次有效操作之间的最短间隔。如果太小,去不干净;如果太大,用户快速操作时会明显感觉“卡顿”或“丢操作”。比如某款触控面板,我把去抖时间从15毫秒改到30毫秒,按键明显变钝;改到5毫秒,偶尔就会出现一次操作被识别成两次。所以这个参数没有标准答案,唯一可靠的办法是拿示波器实测抖动波形,再根据操作频率去定。

2.3 脉冲展宽器:让短脉冲活到系统来得及处理

第三种用法是脉冲展宽。有些信号源输出的脉冲非常窄,窄到CPU轮询根本不可能覆盖,也窄到ASW层不可能实时感知。这时候可以借鉴一个经典的电路思想——单稳态触发,在软件里用Delay模拟出来:检测到边沿后,输出一个持续较长时间的高电平(或低电平),系统随后用普通的轮询或中断去处理这个“展宽后的信号”。

这个方法在低速系统里非常实用。我曾经把一个只有30微秒宽的周期性脉冲,用软件展宽到2毫秒,然后让主循环每500微秒去查一次标志位,就能稳定捕捉。核心实现就几句话:边沿到来时置位输出并记录当前时间,每次检查输出是否超时,超时就自动复位。代码我给到第四部分。

要注意的是,展宽会丢失原始的精确时刻信息。如果应用需要知道脉冲的精确到达时间,就必须配合硬件定时器的输入捕获或时间戳。展宽适合“只关心有没有发生”的场景,而时间戳适合“还要关心什么时候发生”的场景。把这两个混在一起,很容易做出一个“看起来能用、实际精度很差”的系统。

这三种角色,对应了Delay应用的三个层次:定时采样、带确认的延时、主动展宽的延时。工程里不是只选一种,而是随着信号特性和系统负载组合使用。

3. 从硬件边沿到ASW事件:一条完整的“瞬间事件”链路

3.1 ASW能看懂什么:事件接口与时间戳

聊到这里就得把ASW请出来了。ASW是Application Software的缩写,也就是应用层软件。在AUTOSAR这类分层架构里,ASW不直接操作寄存器,也不关心某个引脚的电平。它通过RTE和底层BSW通信,拿到的是经过抽象的数据和事件。

这就带来一个很现实的问题:ASW天生就是“看不懂”裸边沿的。你给它一个引脚电平跳变的信息,它不知道这根线是传感器输出、是按键、还是故障指示。它需要的是一个带语义的事件,比如“输入1上升沿有效”、“Z相脉冲到达”、“按钮被按下”。从物理边沿到语义事件,中间隔着一层处理逻辑,而Delay就是处理逻辑里最重要的时间工具。

我见过不少项目把物理边沿直接映射成软件标志位,让ASW轮询去“猜”事件。比如ASW周期判断“如果引脚电平为高,就认为有事件”,这种做法在信号宽裕、时序不敏感的时候勉强能跑,一旦信号变窄或者出现毛刺,ASW就很容易误判,而且问题发生在应用层,排查起来特别痛苦。正确做法是把“电平”和“事件”分开:底层负责把电平边沿翻译成事件,ASW只消费事件。

3.2 底层捕获与上层消费:缓存、队列、回调

底层把边沿捕获、去抖、展宽之后,生成一个标准事件结构体,里面通常包含三个要素:事件ID、事件来源、时间戳。然后通过队列把事件交给上层。队列在这里非常关键。它能把瞬时突发的事件缓存下来,让ASW在它的运行节奏里慢慢消费,而不至于被一次突如其来的边沿打乱节奏。

我实际用过的队列有两种形态。一种是简单的环形缓冲,适合单生产者单消费者的场景,底层在中断里往队列写,ASW任务周期性地从队列读;另一种是带回调通知的队列,底层写入事件后触发一个软中断或设置事件标志,ASW只在有事件时被唤醒。前一种实现简单、开销可控,后一种响应更快、实时性更好。至于选哪种,主要看ASW任务的周期和系统对事件时延的要求。

这里有一个容易翻车的点:如果底层产生事件的速度长期高于ASW消费的速度,队列就会溢出,事件会被丢弃。排查的时候你会看到“明明有事件产生,ASW却偶尔没反应”。所以在设计阶段就得估算峰值事件率,把队列深度留足,同时考虑漏报率指标。没有哪个队列是无限深的,关键是让溢出的概率小到业务可接受。

3.3 时间基准:为什么事件必须带时间戳

事件结构体里的时间戳看似不起眼,其实是整个链路的点睛之笔。ASW拿到“有边沿”还不够,很多时候它要做的是多路事件的时序关系判断。比如判断两个传感器的信号到达间隔是否在合理范围内,或者计算编码器两个脉冲之间的时间差来推算转速。这些计算需要的不是“事件的先后顺序”,而是“事件发生的绝对时刻”。

时间戳可以来自硬件定时器,比如一个自由运行的16位或32位定时器,在边沿到来时触发输入捕获并把计数值锁存下来。这个计数值经过换算,就能得到微秒甚至纳秒级的事件时刻。相比之下,靠软件Delay去估算时间就粗糙得多,因为从边沿到ISR再到事件入队,每一步都有不可控的延迟。

如果你在设计一个对时序有要求的系统,建议从一开始就把时间戳纳入事件结构体。这样ASW在应用层做时序分析的时候,就不用回头改底层驱动。我见过太多项目前期省了这个字段,后期想加,涉及到的改动几乎是牵一发动全身。

4. 工程实现:用Delay实现边沿捕捉与事件上报

4.1 需求场景:短脉冲计数与按键去抖

这一部分我准备用一个实际场景来串起前面讲的思路。假设现在有一块控制板,有两路输入需要处理。第一路是编码器Z信号,输出窄脉冲,宽度约50微秒,要求记录脉冲次数并上报给ASW。第二路是一个机械按键,按键按下时会产生约3毫秒到8毫秒的抖动,要求识别一次有效的按下事件,并上报给ASW。

这个场景不算复杂,但已经同时覆盖了窄脉冲捕捉、去抖、事件上报三件事。我用一个简化的C代码demo来演示,实际工程里你可以在这个基础上扩展成更规范的服务层。

4.2 代码级别的边沿检测实现

先定义事件结构体和队列接口。事件结构体里包含事件ID、时间戳和一个保留字段:

typedef struct { uint16_t event_id; uint32_t timestamp_ticks; uint16_t reserved; } asw_event_t; #define ASW_EVENT_Z_PULSE 0x01 #define ASW_EVENT_KEY_PRESS 0x02 #define EVENT_QUEUE_SIZE 16 static asw_event_t event_queue[EVENT_QUEUE_SIZE]; static volatile uint8_t q_head = 0; static volatile uint8_t q_tail = 0; static void event_queue_push(uint16_t id, uint32_t ts) { uint8_t next = (q_tail + 1) % EVENT_QUEUE_SIZE; if (next == q_head) { return; // 队列满,丢弃 } event_queue[q_tail].event_id = id; event_queue[q_tail].timestamp_ticks = ts; q_tail = next; }

注意这里我用了环形缓冲,长度是16。实际工程里队列长度要根据峰值事件率来定,不能拍脑袋。如果担心事件漏报,可以在丢弃时置一个溢出标志,用于后期统计。

然后是对Z信号的边沿检测。因为Z脉冲宽度只有50微秒,普通轮询做不到,这里用外部中断加输入捕获更合理:

void EXTI_Z_IRQHandler(void) { uint32_t ts = hw_timer_capture(); event_queue_push(ASW_EVENT_Z_PULSE, ts); }

这个中断函数里没有复杂逻辑,只做两件事:读取硬件捕获的时间戳,把事件推入队列。这样整个ISR执行时间可以控制在几百纳秒到几微秒级别,不会影响系统实时性。时间戳精度取决于定时器时钟频率,比如72MHz的定时器,单个tick约为13.9纳秒。

按键去抖则采用状态机加非阻塞延时。我不用delay_ms阻塞等待,因为那样会把MCU死死卡在按键处理上。更好的做法是用一个周期任务来做去抖判断,时间基准来自系统节拍:

#define DEBOUNCE_TICKS_MS 15 typedef enum { KEY_IDLE, KEY_CONFIRM, } key_debounce_state_t; static key_debounce_state_t key_state = KEY_IDLE; static uint32_t key_change_time = 0; void key_scan_task(void) { uint8_t level = gpio_read(KEY_GPIO_PORT, KEY_GPIO_PIN); static uint8_t last_level = 0; switch (key_state) { case KEY_IDLE: if (level != last_level) { key_change_time = system_get_ticks_ms(); key_state = KEY_CONFIRM; } break; case KEY_CONFIRM: // 一直延时到去抖窗口结束 if (system_get_ticks_ms() - key_change_time >= DEBOUNCE_TICKS_MS) { if (level != last_level) { // 去抖窗口结束后电平仍然处于新状态,确认是一次有效边沿 if (level == 1) { event_queue_push(ASW_EVENT_KEY_PRESS, system_get_ticks_ms()); } last_level = level; } key_state = KEY_IDLE; } break; } }

这段代码里的核心技巧是:用system_get_ticks_ms() - key_change_time来判断是否到了延时结束的时机,而不是在检测到变化那一刻调用delay函数去傻等。两种写法实现的功能类似,但非阻塞版本在等待过程中CPU可以继续执行其他任务,系统的实时性和功耗表现会好很多。

4.3 定时器与延时的取舍

上面的代码里出现了一个关键选择:到底用阻塞式延时,还是用非阻塞的时基判断。我在实际项目里基本遵循下面的取舍原则:

方式优点缺点适用场景
阻塞式delay_ms/delay_us逻辑简单、可读性好CPU被占死、实时性差、中断嵌套时容易出问题初始化流程、非实时性的低速逻辑
系统节拍非阻塞判断CPU空闲、实时性好、可扩展代码稍复杂、需要有时基基准去抖、超时判断、周期性采样
硬件定时器输入捕获精度最高、不占CPU外设资源有限、配置复杂窄脉冲、需要精确时间戳的场合

我个人的习惯是:凡是涉及“边沿、事件、超时”这类和瞬间时序强相关的逻辑,一律优先用非阻塞或硬件定时器方案;阻塞式延时只保留在系统初始化和不需要关心实时性的地方。这样做的好处是,整个系统的“响应底板”被抬高了,ASW能够感知的事件粒度更细。

5. 复盘与排查:Delay相关的典型坑

5.1 延时函数卡死的常见姿态

“STM32延时函数delay卡死”是搜索热度特别高的话题,我猜很多人都遇到过。我自己就踩过一个特别经典的坑:在某个外设的中断处理函数里调用了delay_ms,而这个中断优先级比较低。当时系统还有另一个高优先级中断频繁触发,每次高优先级中断都要占一段时间,低优先级中断里的delay又在不断重载SysTick,一来二去,SysTick的中断优先级反而低于当前正在执行的中断,导致系统的时基跳动异常,delay永远等不到结束条件,程序就“卡死”了。

还有一种情况更隐蔽:阻塞式延时使用SysTick作为时基,但某些低功耗模式下,SysTick的时钟源被关闭了,或者延时函数在进入临界区(关中断)期间被调用,系统时间不往前走,delay自然就成了死循环。

排查这类问题,我的建议是先回答三个问题:这段延时是在中断里还是主循环里?系统当前处在什么功耗模式?延时期间CPU是否被允许响应更高优先级的中断?把这三个问题理清楚,80%的延时卡死都能找到根因。不要一上来就怀疑编译器优化或者芯片有问题,大多数时候还是用法不对。

5.2 边沿丢失、误触发、抖动误判

边沿丢失是另一个高频问题,我在文章开头说的80微秒脉冲漏检就属于这一类。排查的时候不要只在应用层找原因,要顺着信号链路一步步回溯:先看物理层波形是否正常,再看底层采样/中断是否真的触发了,最后看事件是否成功入队并被ASW消费。示波器依然是最好用的工具,观测点如果太靠后,问题往往就被中间环节吃掉了。

误触发通常和去抖参数设置不当有关。我遇到过一例静电放电导致的误触发,现象是设备偶尔会无端记录一次非法输入。示波器抓下来发现,ESD事件在信号线上耦合出一串极窄的毛刺,单个毛刺宽度只有几百纳秒。我的底层虽然做了去抖,但去抖窗口只有1毫秒,根本拦不住这串毛刺。后来把去抖窗口加大到10毫秒,问题才消失。这里要提醒一句:去抖时间变大会带来响应延迟,必须和应用层的操作体验放在一起权衡。

抖动误判则经常出现在“边沿计数”类需求里。比如编码器信号如果带有毛刺,软件计数会比实际转数多出不少。此时除了在软件里做去抖,还应该在硬件上增加RC滤波或施密特触发器整形,软硬结合效果最好。

5.3 时序分析中的Delay感知

最后把视角拉远一点。在FPGA设计或SoC时序收敛里,Delay的含义和嵌入式软件里的延时不完全一样,但本质都是“时间差”。做FPGA综合时,input delay指的是信号从外部器件输出到FPGA输入引脚之间需要的建立时间,也就是数据相对时钟的到达时间。如果这个值配置不对,综合工具就无法保证边沿能被正确采样。Vivado里可以导出pin delay,用来做板级时序分析,本质上也是在描述“边沿到底什么时候到达”。

在功耗分析里也有类似概念。PrimeTime PX做功耗分析时可以选择zero delay模型,忽略掉门延迟,直接用理想时钟来评估开关功耗;但更精确的分析会带上真实的cell delay和net delay。这个差异告诉我们:一个系统如果完全不考虑Delay,得到的评估结果和真实行为之间会有不小的偏差。和嵌入式里“不用延时捕捉边沿,ASW就看不懂瞬间事件”其实是同一个道理。

6. 我的实操体会与扩展建议

说句实在话,Delay本身不是什么高深技术,单片机入门课程第一节就会讲。但在真实工程里,Delay用得精不精,直接决定了一个系统能否稳定地感知外部世界。我在好几个项目里都发现,出问题的地方不是算法也不是通信协议,而恰恰是最不起眼的“信号有没有被正确看见”。这个看不见的环节,往往要花掉排障时间的大头。

根据我的个人经验,建议大家在接手一个新平台时,先把几个东西摸清楚:系统节拍是多少,有没有可用的自由运行定时器,各中断的优先级是怎么分配的,低功耗模式和时基之间的关系是什么。这四个问题搞明白之后,你再面对“瞬间发生的事”,就知道该用采样、去抖、展宽还是硬件捕获了。

最后再分享一个小技巧:给ASW上报事件的时候,除了时间戳,尽量再加一个“事件计数器”。也就是从本次上电开始,这类事件总共发生过多少次。这个计数器在排查“疑似漏事件”的现场特别有用。ASW可以拿它和底层驱动的硬件计数做对比,一下子就能判断出是底层漏了事件,还是上层消费丢的事件。多这么一条信息,很多疑难杂症都能少绕一大圈。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询