1. 问题背景
工业现场的数字量输入信号——行程开关、光电传感器、接近开关——在物理触点切换的瞬间会持续产生 机械抖动(bouncing):信号在高低电平之间来回跳变数十毫秒后才稳定。如果不做任何处理,一次按压可能被解释为几十次触发。
flowchart TB
subgraph HARD[硬件层]
H1[GPIO 输入]
H2[机械按键/编码器]
end
subgraph FILTER[消抖层]
F1[延时采样]
F2[边沿检测]
F3[长按/短按识别]
end
subgraph EVENT[事件层]
E1[按键事件队列]
E2[协议帧封装]
end
HARD --> FILTER --> EVENT
style HARD fill:transparent,stroke:#8dc7ff,color:#eaf4ff
style FILTER fill:transparent,stroke:#8dc7ff,color:#eaf4ff
style EVENT fill:transparent,stroke:#8dc7ff,color:#eaf4ff常见的应对方案有两种:硬件 RC 滤波(在 GPIO 引脚上加电容电阻构成低通滤波器)和 软件消抖(在固件代码中通过时间窗口判断信号的稳定状态)。
本文讨论的是一个纯软件消抖的实现方案,运行在 STM32F103 平台上,对应的抽象层名为 GENERIC_INPUT_CONTROLLER。该模块负责管理最多 4 路物理数字输入,提供带消抖的稳定状态接口、原始电平旁路接口以及状态重同步机制。
2. 整体架构
GENERIC_INPUT_CONTROLLER 位于 Service 层,不直接调用 HAL 的 GPIO 接口,而是通过 bsp_gpio_read_pin() 间接读取电平。每一路输入的管脚映射、有效电平极性、消抖时间等配置均以 编译期常量表 的形式定义在 app_define.h 中。
模块的核心 API 接口如下:
/* 模块初始化 — 根据配置表建立输入通道 */
void generic_input_init(void);
/* 周期性更新 — 必须在主循环/任务中以固定周期调用 */
void generic_input_update(uint32_t now_ms);
/* 获取消抖后的稳定状态 */
bool generic_input_is_active(uint8_t ch);
/* 获取原始电平(绕过消抖,用于急停等场景) */
bool generic_input_is_raw_active(uint8_t ch);
/* 强制重同步 — 将稳定态与候选态对齐到当前硬件电平 */
void generic_input_resync(uint8_t ch);
3. 消抖数据结构
每个物理输入通道维护 4 个状态变量:
typedef struct {
uint8_t channel_id; /* 通道编号 */
uint8_t raw_level; /* 本次采样到的原始电平 */
uint8_t candidate_level; /* 候选稳定电平 */
uint32_t candidate_start_tick; /* 候选电平首次出现的时间戳 */
uint8_t stable_level; /* 经过消抖确认的稳定电平 */
uint8_t active_level; /* 有效电平极性(ACTIVE_HIGH / ACTIVE_LOW) */
uint16_t debounce_ms; /* 消抖时间窗口,默认 40 ms */
} generic_input_ch_t;
关键设计思路:不依赖硬件定时器,所有时间窗口计算都基于外部传入的 now_ms 时间戳。这意味着模块本身不关心时间基从哪来,只要调用方在每个周期传入单调递增的 ms 级时间即可。典型场景下由 RTOS 的 Task 循环或 Systick 计数器提供。
4. 消抖算法实现
算法本质上是一个 两级状态机:
- 原始电平变化 → 立即刷新
candidate_level并重置candidate_start_tick - 候选电平保持超过 debounce_ms → 将
stable_level更新为candidate_level
核心更新逻辑如下:
void generic_input_update(uint32_t now_ms) {
for (int i = 0; i < GENERIC_INPUT_CH_MAX; i++) {
generic_input_ch_t *ch = &channels[i];
/* 跳过未配置的通道 */
if (ch->port == NULL) continue;
/* 1. 读取原始电平 */
uint8_t new_raw = bsp_gpio_read_pin(ch->port, ch->pin);
ch->raw_level = new_raw;
/* 2. 检测到跳变 — 立即刷新候选 */
if (new_raw != ch->candidate_level) {
ch->candidate_level = new_raw;
ch->candidate_start_tick = now_ms;
continue; /* 等待下一次更新再判断 */
}
/* 3. 候选已稳定且超过消抖阈值 — 更新稳定态 */
if (now_ms - ch->candidate_start_tick >= ch->debounce_ms) {
ch->stable_level = ch->candidate_level;
}
}
}
这里有一个容易被忽视的细节:在步骤 2 检测到跳变后,立即 continue,不进入时间窗口判断。这意味着一个通道从跳变开始至少等待一个完整的 debounce_ms 窗口才能改变稳定态。这是防止「刚跳变就在同一周期内被判为稳定」的关键。
5. 状态查询 API
稳定态读取需要结合 active_level 进行极性转换:
bool generic_input_is_active(uint8_t ch_idx) {
generic_input_ch_t *ch = &channels[ch_idx];
if (ch->port == NULL) return false; /* 未配置通道始终返回 inactive */
return (ch->stable_level == ch->active_level);
}
bool generic_input_is_raw_active(uint8_t ch_idx) {
generic_input_ch_t *ch = &channels[ch_idx];
if (ch->port == NULL) return false;
return (ch->raw_level == ch->active_level);
}
is_raw_active() 的存在是为了应对 急停(E-Stop) 等需要零延迟响应的高优先级输入。它绕过消抖层,直接返回当前物理电平。对于常规的限位开关、到位传感器等信号,则应始终使用 is_active() 以避免误触发。
6. 重同步机制
在实际项目中还发现一个边界场景:假设设备正在执行某个动作(如气缸伸出),动作的完成信号由 IN1 提供。在动作启动之前,IN1 可能处于任意状态(过去残留的稳定态可能仍然是「激活」)。如果直接用 is_active() 判断动作是否完成,就会因为历史状态错误而立即认为完成了。
解决方案是在动作开始前对相关输入做 一次强制重同步:
void generic_input_resync(uint8_t ch_idx) {
generic_input_ch_t *ch = &channels[ch_idx];
if (ch->port == NULL) return;
uint8_t hw_level = bsp_gpio_read_pin(ch->port, ch->pin);
/* 将三个层级全部对齐到当前硬件电平 */
ch->raw_level = hw_level;
ch->candidate_level = hw_level;
ch->stable_level = hw_level;
ch->candidate_start_tick = 0; /* 强制候选为「已稳定」状态 */
}
重同步的典型调用时机:进入一个新的工艺步骤之前,对步骤相关的所有传感器输入做一次 resync(),确保消抖状态与物理世界之间没有历史残留。
7. 通道配置表
所有通道参数在编译期通过宏展开生成实例:
/* app_define.h — 传感器映射表 */
#define GENERIC_INPUT_CFG_TABLE \
ENTRY(GPIOA, GPIO_PIN_0, ACTIVE_HIGH, 40), /* CH0: 气缸到位传感器 */ \
ENTRY(GPIOA, GPIO_PIN_1, ACTIVE_HIGH, 40), /* CH1: 物料在位传感器 */ \
ENTRY(GPIOA, GPIO_PIN_2, ACTIVE_LOW, 50), /* CH2: 校准光电传感器 */ \
ENTRY(GPIOA, GPIO_PIN_3, ACTIVE_HIGH, 40), /* CH3: 料斗光电传感器 */
/* 初始化时通过 X-Macro 展开为结构体数组 */
static const generic_input_cfg_t input_cfg[GENERIC_INPUT_CH_MAX] = {
#define ENTRY(p, n, a, d) { .port = p, .pin = n, .active_level = a, .debounce_ms = d },
GENERIC_INPUT_CFG_TABLE
#undef ENTRY
};
对于 未使用的通道,将配置设为 {NULL, 0},模块在 update() 循环中会跳过这些端口。此时 is_active() 和 is_raw_active() 均返回 false(inactive)。
8. 禁用通道的处理
如果某个物理输入被禁用(比如硬件上没有连接或者被功能裁剪),只需要在配置表中将其项注释或设置为无效:
#define GENERIC_INPUT_CFG_TABLE \
ENTRY(GPIOA, GPIO_PIN_0, ACTIVE_HIGH, 40), /* CH0 */ \
/* ENTRY(GPIOA, GPIO_PIN_1, ACTIVE_HIGH, 40), */ \
ENTRY(NULL, 0, ACTIVE_HIGH, 40), /* CH1 — 禁用,占位 */ \
ENTRY(GPIOA, GPIO_PIN_2, ACTIVE_LOW, 50), /* CH2 */ \
ENTRY(NULL, 0, ACTIVE_HIGH, 40), /* CH3 — 禁用,占位 */
这种做法使得模块在硬件裁剪时不需要修改任何逻辑代码,只需修改配置表即可。对于被禁用的通道,bsp_gpio_read_pin(NULL, 0) 的实现直接返回非激活电平值。
9. 与 RTOS 的集成方式
模块本身 不依赖 RTOS——它是一组纯计算函数,无阻塞、无临界区。唯一的外部输入是 now_ms 时间戳。典型的调用模式:
/* PackerTask 主循环 */
void packer_task(void *param) {
generic_input_init();
while (1) {
uint32_t now = get_system_tick_ms();
/* 步骤 1: 更新所有输入状态 */
generic_input_update(now);
/* 步骤 2: 读取稳定后的输入做工艺逻辑 */
if (generic_input_is_active(CH_CYLINDER_OUT)) {
/* 气缸已伸出到位,执行下一步 */
}
/* 步骤 3: 特殊信号走原始电平 */
if (generic_input_is_raw_active(CH_ESTOP)) {
emergency_stop();
}
osDelay(10); /* 10 ms 周期 */
}
}
消抖周期设为 40 ms,而 Task 调度周期为 10 ms,这意味着稳定态切换最多需要 4 个更新周期 + 40 ms 候选窗口才能反映最新物理状态——这在绝大多数工业场景下完全可以接受。
10. 总结
纯软件消抖方案在 STM32F103 这类资源受限的 MCU 上是可靠且经济的选择:
- 零 BOM 成本:不需要电容电阻,省 PCB 面积也省物料
- 参数可调:每个通道独立配置消抖时间,适应不同传感器特性(机械开关通常 20–50 ms,光电传感器可以更短)
- 无阻塞设计:所有函数都是 O(1) 纯计算,不会干扰 RTOS 的调度延迟
- 重同步机制:解决了状态机切换过程中的历史状态残留问题,是生产环境中一个非常实用的技巧
- Service 层隔离:通过 BSP 抽象层屏蔽 HAL 细节,换 MCU 平台时只需重写 BSP 层,消抖逻辑完全不变
这套设计已经在实际产线上运行,稳定处理了累计数百万次的数字输入采样。如果你正在设计一个同样需要多路数字量输入的嵌入式系统,纯软件消抖 + 重同步的架构值得参考。
Comments NOTHING