STM32计数系统实战:红外传感器与旋转编码器的硬件连接与软件滤波

📅 2026/8/6 3:30:55
STM32计数系统实战:红外传感器与旋转编码器的硬件连接与软件滤波
1. 项目缘起从“数数”到精准感知在嵌入式开发尤其是像STM32这类MCU的应用中“计数”是一个看似基础却无处不在的核心功能。它不仅仅是简单地累加一个数字更是连接物理世界与数字世界的桥梁。比如你想知道传送带上过去了多少件产品或者一个旋钮被拧了多少格再或者电机轴旋转了多少圈。这些场景的背后都需要一个可靠的“计次”系统。最近在做一个智能仓储小车原型时我就遇到了两个典型的计数需求一是通过对射式红外传感器统计经过特定通道的货物数量二是通过旋转编码器精确测量驱动轮的转速和转动方向进而计算行驶距离。这两个需求恰好对应了两种主流的非接触式计数方案事件触发计数和相位差计数。网上资料虽多但往往要么只讲其一要么代码片段零散缺乏从硬件连接到软件滤波、从原理到避坑的完整链路。于是我决定结合STM32F103C8T6也就是常说的“蓝色药丸”Blue Pill把这两个传感器的计次实现过程彻底梳理一遍记录下那些数据手册不会告诉你的实操细节。这篇文章就是这份笔记的整理。无论你是正在学习STM32的新手还是需要在项目中快速实现计数功能的老手我希望这份结合了原理、代码和“踩坑实录”的总结能让你少走弯路快速搭建起稳定可靠的计数模块。我们将从最基础的传感器原理和硬件接线开始逐步深入到STM32的定时器编码器模式、外部中断的滤波处理最终完成一个能够抗干扰、辨方向的完整计次系统。2. 硬件选型与连接给信号一个回家的路在写第一行代码之前正确的硬件连接是成功的基石。这一步错了后面所有的调试都是徒劳。我们先分别看看这两种传感器该如何与STM32正确“握手”。2.1 对射式红外传感器关键在于稳定的电平对射式红外传感器通常包含一个发射管和一个接收管。当中间无障碍物时接收管持续接收到红外光输出一种状态常开型输出高电平常闭型输出低电平当有物体穿过阻断光束时输出状态翻转。关键点在于输出类型常见的有NPN常开、NPN常闭、PNP型等。对于STM32的GPIO口我们期望传感器输出的是一个标准的、干净的数字电平信号0V或3.3V。我手头的是一个NPN常开型传感器其棕色线接VCC5V-24V我用的5V蓝色线接GND黑色线是信号输出线。接线方案黑色信号线不能直接接到STM32的GPIO因为其输出是“集电极开路”输出需要上拉电阻才能形成高电平。幸运的是STM32的GPIO内部可以配置为上拉输入模式相当于内部集成了一个上拉电阻。我的连接传感器VCC接5VGND接GND信号线黑直接连接到STM32的PA0引脚。然后在代码中将PA0配置为上拉输入。这样当无遮挡时传感器内部三极管截止PA0被内部上拉电阻拉到3.3V高电平当有遮挡时三极管导通PA0被拉低到接近0V低电平。一个完整的“遮挡-通过”过程就会在PA0上产生一个下降沿和上升沿。注意务必确认你的传感器电压与STM32的IO电平兼容。如果传感器工作电压是24V输出信号必须经过电平转换如光耦或电平转换芯片才能接入3.3V的STM32否则会烧毁芯片2.2 旋转编码器读懂A、B相和Z脉冲旋转编码器分绝对式和增量式我们常用的是增量式旋转编码器比如EC11。它输出两组相位差90度的方波信号A相和B相有的还带一个每圈一次的零位信号Z相。通过分析A、B相的相对相位关系不仅可以计数还能判断旋转方向。硬件连接的核心是抗干扰编码器输出的同样是数字信号但在电机等干扰源附近信号容易产生毛刺。直接读取可能会导致误计数。引脚连接以常见的5引脚编码器为例带按键的EC11。VCC接3.3VGND接GND。A相和B相信号线分别接到STM32的两个GPIO上例如PA6和PA7。这两个引脚必须支持定时器的编码器接口功能查看芯片数据手册的“Alternate function”映射。为什么是PA6和PA7因为STM32F103C8T6的TIM3定时器的通道1和通道2其默认复用引脚就是PA6和PA7。使用定时器的硬件编码器模式可以让计数和方向判断完全由硬件完成CPU零开销且抗噪能力远强于软件模拟。硬件滤波为了进一步稳定信号可以在A、B相引脚到地之间各接一个10nF~100nF的电容构成简单的RC低通滤波器滤除高频毛刺。也可以在信号线上串联一个100欧姆左右的电阻限制瞬态电流。3. 对射式红外传感器计次软件消抖的艺术硬件连接妥当后我们首先实现相对简单的对射传感器计次。核心思路是利用STM32的外部中断来捕获传感器的状态跳变。3.1 外部中断配置与初始计次我们使用PA0引脚它对应着外部中断线0EXTI0。// 1. 启用GPIOA和AFIO时钟AFIO用于外部中断引脚重映射虽然这里不用但好习惯是开启 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); // 2. 配置PA0为上拉输入 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入模式 GPIO_Init(GPIOA, GPIO_InitStructure); // 3. 配置外部中断线0连接到PA0 GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0); // 4. 初始化EXTI结构体配置为下降沿触发假设物体进入遮挡时计数 EXTI_InitTypeDef EXTI_InitStructure; EXTI_InitStructure.EXTI_Line EXTI_Line0; EXTI_InitStructure.EXTI_Mode EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger EXTI_Trigger_Falling; // 下降沿触发 EXTI_InitStructure.EXTI_LineCmd ENABLE; EXTI_Init(EXTI_InitStructure); // 5. 配置NVIC嵌套向量中断控制器 NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel EXTI0_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; // 抢占优先级 NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; // 子优先级 NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); // 6. 在文件开头定义一个全局变量用于计数 volatile uint32_t infrared_count 0;中断服务函数里进行计数void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { infrared_count; // 计次 EXTI_ClearITPendingBit(EXTI_Line0); // 清除中断标志位 } }至此一个最基础的计次功能就完成了。但如果你直接把代码下载进去测试可能会发现一个问题当物体缓慢通过或者传感器安装不稳固时计数值可能会疯狂增加远多于实际物体数量。这就是抖动。3.2 深入消抖从延时到状态机机械触点和光电传感器在状态切换的瞬间会产生一系列快速的、非预期的电平跳变这就是抖动。对于对射传感器物体边缘不规则、光线散射等也可能引起类似抖动的多次触发。初级消抖——延时法不推荐在主循环中用 在中断函数里检测到下降沿后延时10-20ms再次读取引脚电平如果仍然是低电平则确认是一次有效的遮挡。void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { delay_ms(15); // 阻塞延时严重影响系统实时性 if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) 0) { infrared_count; } EXTI_ClearITPendingBit(EXTI_Line0); } }这种方法有严重缺陷在中断服务程序中进行毫秒级延时是致命的它会阻塞所有同级和低优先级中断导致系统响应迟缓甚至死机。高级消抖——定时器辅助的状态机法推荐 这是更可靠、更专业的做法。我们利用一个基本定时器如TIM6产生固定的时基例如5ms在主循环或定时器中断中执行一个消抖状态机。// 定义传感器状态 typedef enum { IR_STATE_IDLE, // 空闲无遮挡 IR_STATE_DEBOUNCE, // 消抖确认中 IR_STATE_BLOCKED // 确认遮挡 } IR_State_t; static IR_State_t ir_state IR_STATE_IDLE; static uint32_t debounce_tick 0; volatile uint32_t infrared_count 0; // 在5ms定时器中断中调用此函数 void IR_Debounce_Handler(void) { uint8_t current_level GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0); switch (ir_state) { case IR_STATE_IDLE: if (current_level 0) { // 检测到潜在下降沿 ir_state IR_STATE_DEBOUNCE; debounce_tick 0; // 开始消抖计时 } break; case IR_STATE_DEBOUNCE: debounce_tick; if (debounce_tick 4) { // 持续20ms (4 * 5ms) if (current_level 0) { // 20ms后仍是低电平确认遮挡 infrared_count; ir_state IR_STATE_BLOCKED; } else { // 期间恢复高电平认为是抖动忽略 ir_state IR_STATE_IDLE; } } break; case IR_STATE_BLOCKED: if (current_level 1) { // 检测到上升沿物体离开 ir_state IR_STATE_IDLE; // 回到空闲等待下一个物体 } break; } }同时我们需要修改外部中断的触发方式将其改为双边沿触发或者干脆在状态机中完全通过定时器轮询引脚电平从而禁用外部中断。对于低速计数轮询法更简单可靠。对于高速计数则需要结合定时器捕获功能这又是另一个话题了。4. 旋转编码器计次解锁定时器的硬件编码器模式相比对射传感器旋转编码器的计次要复杂一些因为它涉及方向和速度。幸运的是STM32的定时器内置了编码器接口模式可以硬件自动完成计数和方向判断极大地简化了软件设计并提高了可靠性。4.1 TIM3编码器模式配置详解我们使用TIM3并将PA6、PA7分别映射到TIM3的通道1和通道2。// 1. 开启时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); // 2. 配置PA6、PA7为上拉输入编码器输出推挽较强也可浮空输入上拉更稳定 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入 GPIO_Init(GPIOA, GPIO_InitStructure); // 3. 配置TIM3工作在编码器模式 TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; // 时基单元配置预分频器决定计数频率这里不分频 TIM_TimeBaseStructure.TIM_Period 65535; // 自动重装载值16位计数器最大值 TIM_TimeBaseStructure.TIM_Prescaler 0; // 不分频 TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; // 此模式在编码器模式下无效 TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); // 配置通道1和通道2为输入捕获模式用于连接编码器 TIM_ICInitStructure.TIM_Channel TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_Rising; // 极性在编码器模式下通常双边沿 TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; // 直接映射到TI1 TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; // 不分频每个边沿都捕获 TIM_ICInitStructure.TIM_ICFilter 0x6; // 滤波器值非常重要用于硬件消抖 TIM_ICInit(TIM3, TIM_ICInitStructure); // 通道2配置注意ICSelection为间接映射到TI2 TIM_ICInitStructure.TIM_Channel TIM_Channel_2; TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_IndirectTI; // 间接映射到TI2 TIM_ICInit(TIM3, TIM_ICInitStructure); // 4. 设置编码器模式 // TIM_EncoderMode_TI1: 仅在TI1边沿计数 // TIM_EncoderMode_TI2: 仅在TI2边沿计数 // TIM_EncoderMode_TI12: 在TI1和TI2的边沿都计数分辨率最高最常用 TIM_EncoderInterfaceConfig(TIM3, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); // 5. 使能定时器 TIM_Cmd(TIM3, ENABLE);关键参数解读TIM_EncoderMode_TI12这是最常用的模式在A相和B相的每个边沿上升沿和下降沿都计数。对于一个线数为N的编码器物理上一圈有N个脉冲此模式下旋转一圈计数器将变化4 * N次这就是所谓的4倍频大大提高了角度分辨率。TIM_ICFilter这是硬件滤波器值范围0x0到0xF。它设定了一个采样频率和数字滤波器长度可以有效消除信号线上的毛刺。例如设置0x6表示以定时器输入时钟的某个分频进行采样并连续6次采样值相同才认为有效。这个配置是解决编码器误读的关键需要根据你的编码器信号质量和转速调整。如果发现计数不准可以尝试增大此值。TIM_Period自动重装载值。当计数器达到此值后会溢出或下溢。对于编码器我们通常将其设为最大值如65535然后通过处理溢出事件来扩展计数范围。4.2 读取计数值与方向判断配置完成后硬件会自动更新计数器TIM3-CNT和方向寄存器位。我们只需要定期读取即可。// 获取当前的计数值有符号数方便处理反转 int16_t get_encoder_count(void) { return (int16_t)(TIM3-CNT); // 强制转换为有符号16位整数 } // 获取当前旋转方向 // 根据TIMx_CR1寄存器的DIR位判断0向上计数正转1向下计数反转 int8_t get_encoder_direction(void) { if ((TIM3-CR1 TIM_CR1_DIR) 0) { return 1; // 正转 } else { return -1; // 反转 } } // 更常用的获取从上次读取以来的增量带方向 int32_t g_total_count 0; // 扩展的32位总计数 int16_t g_last_count 0; void update_encoder_total_count(void) { int16_t current_count get_encoder_count(); int16_t diff current_count - g_last_count; // 处理计数器溢出/下溢 // 因为CNT是16位无符号数但我们已经转为有符号当从65535到0时diff会是负的很大的数 // 当从0到65535时diff会是正的大数。我们需要修正。 if (diff 32767) { diff - 65536; // 修正正向溢出 } else if (diff -32768) { diff 65536; // 修正负向下溢 } g_total_count diff; g_last_count current_count; // 现在 g_total_count 就是一个不受16位限制的、带方向的累计计数值 // 旋转一圈的计数变化量 4 * 编码器线数 }在主循环中定期调用update_encoder_total_count()然后g_total_count就包含了从开始以来的净计数。将其除以4*线数就得到了旋转的圈数。方向信息也隐含在diff的正负中。5. 进阶处理与抗干扰实战基本的计次功能实现后在复杂的实际环境中尤其是电机驱动场景我们还需要解决一些进阶问题。5.1 长距离传输与信号整形如果编码器安装在电机上而控制板有一定距离信号线会像天线一样接收干扰。除了之前提到的RC滤波还可以使用双绞线将A相、B相各自与地线双绞能有效抑制共模干扰。使用差分信号编码器一些工业编码器输出RS422差分信号如A, A-, B, B-抗干扰能力极强需要专用的差分接收芯片如AM26LS32转换成单端信号再接入STM32。增加屏蔽层对于特别恶劣的环境使用带屏蔽层的电缆并将屏蔽层单点接地。5.2 高速计数与溢出处理当电机高速旋转时计数器TIM3-CNT可能会快速溢出从65535跳回0或反之。我们的update_encoder_total_count函数中的溢出修正逻辑就是用来处理这个的。但这里有个细节必须在计数器溢出发生前后足够快地读取CNT值否则如果两次读取间隔内发生了多次溢出我们的简单算法就会出错。对于极高速度的应用可以开启定时器的更新中断溢出中断在中断里用一个32位或64位的软件计数器对溢出次数进行累加。// 开启TIM3更新中断 NVIC_InitStructure.NVIC_IRQChannel TIM3_IRQn; ... // 配置优先级 NVIC_Init(NVIC_InitStructure); TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); // 使能更新中断 volatile int32_t encoder_overflow 0; void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) ! RESET) { if ((TIM3-CR1 TIM_CR1_DIR) 0) { encoder_overflow; // 向上计数时溢出 } else { encoder_overflow--; // 向下计数时下溢 } TIM_ClearITPendingBit(TIM3, TIM_IT_Update); } } // 获取扩展的64位计数值 int64_t get_extended_encoder_count(void) { int64_t count; // 注意在读取overflow和CNT时应禁止中断或确保原子操作防止读取过程中发生溢出。 // 这里为简化未加保护。实际应用可使用__disable_irq()和__enable_irq()。 count (int64_t)encoder_overflow * 65536LL (int64_t)get_encoder_count(); return count; }5.3 电源噪声与共地问题电机启停、PWM调速会产生巨大的电源噪声。务必确保编码器和STM32使用干净、稳定的电源最好用独立的LDO为编码器供电。同时STM32的地和电机驱动板的地、编码器的地必须在一点可靠连接形成“星型接地”避免形成地环路引入干扰。6. 系统集成与调试心得将两个计次模块集成到实际项目中时还有一些全局性的注意事项。6.1 资源分配与优先级规划对射传感器使用了外部中断EXTI0。如果系统中有其他更紧急的中断如电机控制PID计算需要合理设置NVIC优先级避免计次中断被长时间阻塞导致漏计数。旋转编码器使用了TIM3的编码器模式和可能的中断。定时器中断的优先级通常可以设置得比外部中断低一些因为硬件计数是实时的中断仅用于处理溢出。GPIO冲突PA0和PA6、PA7被占用。要确保项目中其他功能如USART2、ADC等没有复用这些引脚。仔细查阅STM32F103C8T6的引脚复用映射表。6.2 调试技巧与问题排查示波器是王道当计次不准时第一件事就是用示波器看传感器输出引脚的实际波形。看看有没有毛刺抖动上升/下降沿是否陡峭高低电平是否达到标准。这是定位硬件问题最快的方法。软件模拟信号在调试初期可以暂时不接传感器用两个GPIO口模拟输出A、B相的相位差方波来验证你的编码器读取代码是否正确。这样可以排除传感器本身的质量问题。打印调试信息通过串口实时打印g_total_count和get_encoder_direction()的值手动旋转编码器观察计数变化是否符合预期正转递增反转递减。对于对射传感器可以打印每次中断触发的时间戳分析触发间隔是否合理。检查滤波器设置如果编码器计数在静止时自己跳动大概率是TIM_ICFilter设置太小干扰被当成了有效边沿。尝试逐步增大这个值直到跳动停止。但注意过大的滤波器值会降低最高响应频率。供电检查用万用表测量STM32和传感器供电引脚的电压尤其在电机动作时看电压是否有大幅跌落或毛刺。不稳定的电源是许多灵异问题的根源。6.3 从计次到应用计算转速与位移得到可靠的计数值后就可以衍生出更多应用转速计算在固定的时间间隔Δt如100ms内读取编码器计数的增量Δcount。转速 (Δcount/ (4 * 线数)) /Δt。单位是圈/秒。注意处理Δt时间内的溢出。位移计算对于轮子总位移 (g_total_count/ (4 * 线数)) * 轮子周长。这常用于里程计。对射传感器流量统计结合时间可以计算单位时间通过物体的数量频率用于简单的流量监控。7. 总结与代码框架建议回顾整个实现过程从硬件连接到软件消抖再到利用STM32强大的定时器硬件编码器模式我们一步步构建了一个鲁棒的计次系统。核心经验在于理解传感器原理是基础正确的硬件连接是前提而软件上的抗干扰处理硬件滤波软件状态机/滤波器则是稳定可靠的关键。最后提供一个简单的、将上述功能模块化的代码框架思路你可以基于此进行扩展// encoder.h #ifndef __ENCODER_H #define __ENCODER_H #include stm32f10x.h void Encoder_TIM3_Init(void); int32_t Encoder_Get_TotalCount(void); void Encoder_Clear_TotalCount(void); #endif // infrared.h #ifndef __INFRARED_H #define __INFRARED_H void Infrared_Init(void); void Infrared_Debounce_Handler(void); // 在5ms定时器中断中调用 uint32_t Infrared_GetCount(void); void Infrared_ClearCount(void); #endif // main.c #include encoder.h #include infrared.h #include stdio.h // 用于串口打印 int main(void) { // 初始化系统时钟、延时等 SystemInit(); // 初始化串口用于调试 USART1_Init(); // 初始化编码器模块 Encoder_TIM3_Init(); // 初始化红外传感器模块 Infrared_Init(); // 初始化一个5ms的定时器如SysTick或TIM6在其中断里调用Infrared_Debounce_Handler printf(System Start.\r\n); while(1) { // 定期更新编码器总计数例如每10ms // 实际上update_encoder_total_count()的逻辑可以整合到Encoder_Get_TotalCount()中 int32_t motor_pos Encoder_Get_TotalCount(); uint32_t item_count Infrared_GetCount(); // 你的应用逻辑比如通过串口发送数据或者根据计数控制其他设备 printf(Motor Pos: %ld, Items: %lu\r\n, motor_pos, item_count); delay_ms(100); } }在实际项目中你可能还需要考虑临界区保护关中断操作、更精细的速度测量算法如M法测速等。但掌握了本文所述的硬件连接、配置方法和核心处理思想你已经具备了解决绝大多数STM32计次需求的能力。剩下的就是在具体的项目实践中去打磨和优化了。