STM32外部中断与定时器编码器模式实现传感器精准计次
1. 项目概述:从“数数”到精准感知
在嵌入式开发,尤其是基于STM32的项目中,“计次”是一个看似基础却至关重要的功能。它不仅仅是简单地累加一个数字,更是连接物理世界与数字世界的桥梁。无论是流水线上飞速通过的零件,还是旋转设备精确的角度定位,都需要可靠、准确的计数机制。这次,我们把目光聚焦在两个非常经典且实用的传感器上:对射式红外传感器和旋转编码器。前者常用于非接触式物体通过检测,比如自动门的防夹、传送带的产量统计;后者则广泛应用于旋钮、电机转速测量、位置反馈等场景。通过STM32来驱动它们并实现稳定的计次,是掌握单片机外部中断、定时器、GPIO输入捕获等核心外设的绝佳实践。很多朋友在初次接触时,可能会觉得配置几个引脚、读一下电平就能搞定,但实际做下来,抖动干扰、计数方向判断、高速信号处理这些“坑”一个都不会少。这篇文章,我就结合自己实际调试的经验,把这两个传感器的计次原理、STM32的驱动方案,以及那些容易出错的细节,掰开揉碎了讲清楚。
2. 核心传感器原理与选型考量
2.1 对射式红外传感器:光束的“通”与“断”
对射式红外传感器,顾名思义,由一个红外发射管和一个红外接收管相对布置组成。发射管持续发出调制过的红外光(通常为38kHz,用以避免环境光干扰),接收管在接收到足够强度的红外光时,输出低电平;当有物体穿过,阻挡了光束,接收管收不到光,则输出高电平。
关键特性与电路要点:
- 输出信号:大多数模块为了方便单片机直接读取,会集成比较器,最终输出的是数字信号(高/低电平)。当无障碍时输出低电平(0V),有障碍时输出高电平(例如3.3V或5V)。这一点务必查阅模块手册确认,它直接决定了我们后续编程中的判断逻辑。
- 响应速度:普通模块对射频率不高,但对于高速通过的细小物体(如小螺丝),需要考虑光束的聚焦能力和接收管的响应时间。工业级模块会明确标出响应频率(如>1kHz)。
- 抗干扰能力:环境中的日光、白炽灯都含有红外成分,因此调制与解调技术是关键。发射管发射的是被38kHz方波调制的红外光,接收管端的解调芯片(如HS0038B)只对38kHz的红外信号敏感,从而有效滤除干扰。如果你的模块在阳光下不稳定,首先要检查它是否具备调制解调功能。
在STM32上的连接与思考: 连接非常简单,通常只需将传感器的输出脚连接到STM32的一个GPIO引脚。但这里就引出了第一个设计选择:使用查询方式还是中断方式?
- 查询方式:在主循环中不断读取该GPIO的电平。实现简单,但会大量占用CPU时间,并且在检测到状态变化时,可能因为程序正在处理其他任务而错过。
- 中断方式:将GPIO配置为外部中断模式,当电平发生跳变(上升沿、下降沿或双边沿)时触发中断,在中断服务函数中进行计数。这是更高效、更可靠的方式,尤其适合不确定何时会有物体通过的场景。我们通常选择中断方式。
2.2 旋转编码器:不仅仅是“转了几圈”
旋转编码器分为绝对式和增量式。我们项目中最常用的是增量式旋转编码器,它通常输出两路相位差90度的方波信号(A相和B相),以及一个每圈一个脉冲的索引信号(Z相,不一定都有)。
核心工作原理——正交解码: A、B两相信号的相位关系决定了旋转方向。
- 正转:假设A相超前B相90度。当A相上升沿时,检查B相电平为低;当A相下降沿时,检查B相电平为高。
- 反转:B相超前A相90度。当A相上升沿时,检查B相电平为高;当A相下降沿时,检查B相电平为低。
因此,通过检测A相信号的边沿,并同时采样B相的电平状态,我们就能判断出转动方向,从而进行加计数或减计数。
关键类型与选型:
- 机械编码器 vs 光学编码器:
- 机械式:内部是物理触点,有寿命限制(通常几十万次),转动时有段落感,容易产生抖动(触点弹跳)。优点是价格低廉,接口简单。
- 光学式:通过光栅和光电传感器产生信号,无物理接触,寿命长,分辨率可以做得非常高,抖动小。但价格更贵,电路可能稍复杂。
- 分辨率:指旋转一圈产生的脉冲数(PPR, Pulse Per Revolution)。例如,100PPR的编码器,转一圈会产生100个A相脉冲和100个B相脉冲。通过四倍频技术(在A、B相的每个上升沿和下降沿都计数),理论上可以将分辨率提高到400 counts/rev。
- 输出电路:常见的有集电极开路输出和推挽输出。集电极开路需要外接上拉电阻,而推挽输出可以直接连接单片机GPIO。务必确认编码器的工作电压(5V or 3.3V)与STM32的GPIO电平是否兼容,对于5V输出的编码器,可能需要电平转换电路或选择支持5V容忍的STM32引脚。
在STM32上的实现策略:
- 外部中断法:将A相配置为外部中断,在中断服务函数中读取B相电平判断方向。这种方法编程简单,但在高速旋转时,频繁的中断可能造成CPU负载过重甚至丢失脉冲。
- 定时器编码器接口模式:这是STM32为旋转编码器量身定做的“硬件外挂”!将A、B两相信号分别接到定时器的两个输入通道(如TIMx_CH1和TIMx_CH2),然后将定时器配置为“编码器模式”。在此模式下,定时器的计数器会根据A、B相的相位关系自动进行加/减计数,完全由硬件完成,不占用CPU资源,且抗抖动能力极强,是最推荐、最专业的实现方式。
3. 硬件电路设计与连接实战
3.1 对射式红外传感器接口电路
虽然传感器模块通常输出已经是数字信号,但为了系统稳定,我们仍需考虑以下几点:
- 电源去耦:在模块的VCC和GND引脚附近,并联一个0.1uF的陶瓷电容,用于滤除高频噪声。
- 上拉/下拉电阻:如果模块输出是集电极开路型,STM32的GPIO引脚需要启用内部上拉电阻或外接一个上拉电阻(如4.7kΩ~10kΩ)到3.3V,以确保空闲时为确定电平。
- 连接示例:以STM32F103C8T6和常见3.3V输出传感器模块为例。
- 传感器VCC -> 3.3V
- 传感器GND -> GND
- 传感器OUT -> PA0 (我们将使用PA0的外部中断功能)
注意:有些传感器模块输出电平可能与供电电压相同(如5V供电则输出5V)。STM32大部分IO口不能直接承受5V电压,长期接入会损坏芯片。此时必须使用电平转换电路(如分压电阻、电平转换芯片TXS0108E等),或者选择仅用3.3V给传感器供电(需确认传感器在3.3V下工作正常)。
3.2 旋转编码器接口电路
以常见的EC11型机械编码器(带按键)和STM32定时器编码器模式为例:
- 引脚识别:EC11通常有5个引脚:两个固定脚(C1, C2),A相、B相和按键(SW)。A、B相是旋转信号,按键是独立的轻触开关。
- 电路连接:
- A相-> PA0 (TIM2_CH1)
- B相-> PA1 (TIM2_CH2)
- 按键-> PA2 (配置为普通输入,可带上拉)
- C1, C2-> 通常接地或接固定电平,具体需查编码器数据表。对于EC11,C1和C2是内部开关的公共端和常开端,用于按键,不接旋转信号。
- 消抖考虑:
- 硬件消抖:对于机械编码器,在A、B相各自对地并联一个0.1uF电容,可以滤除一部分高频抖动。但电容值不宜过大,否则会影响高速信号边沿。
- 软件消抖:在中断法中,可以在中断触发后延时几毫秒再采样,但会丢失高速脉冲。因此,对于机械编码器,最优雅的方案仍然是使用定时器的编码器模式,其内部滤波器可以很好地抑制抖动。
- 上拉电阻:编码器的A、B相输出通常是开漏的,必须在STM32端启用内部上拉电阻,或者在外部接上拉电阻(如10kΩ)到3.3V。
4. STM32软件驱动与代码实现
我们将使用STM32标准外设库(Standard Peripheral Library)进行讲解,其原理与HAL库相通。
4.1 对射式红外传感器计次(外部中断法)
核心思路:配置GPIO为外部中断,在光束被遮挡(假设输出从低变高)的上升沿触发中断,在中断服务函数中累加计数器。
步骤详解:
GPIO与中断初始化
// 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_Mode_IPD) GPIO_Init(GPIOA, &GPIO_InitStructure); // 3. 将PA0连接到EXTI0中断线 GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0); // 4. 配置EXTI0线 EXTI_InitTypeDef EXTI_InitStructure; EXTI_InitStructure.EXTI_Line = EXTI_Line0; EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Rising; // 根据传感器实际输出设置:遮挡时上升沿则用Rising,下降沿则用Falling EXTI_InitStructure.EXTI_LineCmd = ENABLE; EXTI_Init(&EXTI_InitStructure); // 5. 配置NVIC(嵌套向量中断控制器) NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel = EXTI0_IRQn; // PA0对应EXTI0中断 NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0x01; // 抢占优先级 NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0x01; // 子优先级 NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure);编写中断服务函数
volatile uint32_t objectCount = 0; // 使用volatile防止编译器优化 void EXTI0_IRQHandler(void) { if(EXTI_GetITStatus(EXTI_Line0) != RESET) // 检查是否是EXTI0线中断 { // 清除中断标志位,非常重要!否则会连续进入中断 EXTI_ClearITPendingBit(EXTI_Line0); // 简单的延时消抖(软件消抖),适用于低速场景 // delay_ms(2); // 需要自己实现一个毫秒延时函数 // 再次确认电平,避免抖动误触发 if(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == SET) // 假设高电平为遮挡 { objectCount++; // 物体计数加一 } } }实操心得:中断服务函数(ISR)要尽可能短小精悍。这里做了两件事:清标志和计数。复杂的处理(如更新显示、数据打包)应该放到主循环中,通过设置标志位来触发。
volatile关键字告诉编译器这个变量可能被意外改变(如中断),不要对它做优化,确保每次读取都从内存中获取最新值。主循环中的处理
int main(void) { // ... 系统时钟、外设初始化 InfraredSensor_Init(); // 封装好的传感器初始化函数 // ... 其他初始化(如串口、显示屏) while(1) { // 可以定期打印或显示计数值 if(someCondition) // 例如定时1秒 { printf("Objects counted: %lu\r\n", objectCount); // 或者通过OLED显示 } // 其他任务... } }
4.2 旋转编码器计次(定时器编码器模式)
这是更高效、更可靠的方法。我们以TIM2为例。
定时器编码器模式初始化
void RotaryEncoder_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; // 1. 开启时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // 2. 配置PA0、PA1为输入模式(上拉,因为编码器是开漏输出) GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOA, &GPIO_InitStructure); // 3. 将PA0、PA1复用为TIM2的通道1和2 GPIO_PinRemapConfig(GPIO_Remap_TIM2, ENABLE); // 注意:有些芯片TIM2的CH1/CH2默认就在PA0/PA1,无需重映射。具体查数据手册。 // 4. 初始化定时器时基结构体 TIM_TimeBaseStructure.TIM_Period = 65535; // 自动重装载值,16位定时器最大值。计数值在此范围内变化。 TIM_TimeBaseStructure.TIM_Prescaler = 0; // 预分频器,0表示不分频。编码器模式此参数有时不影响计数。 TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; // 在编码器模式下,此设置可能被覆盖,但按惯例设为Up TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); // 5. 配置编码器接口模式 // 方法1:使用TIM_ICInitStructure单独配置两个通道 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 = 0x06; // 滤波器值,0x00~0x0F。越大滤波效果越强,可抑制抖动。根据编码器类型和速度调整。 TIM_ICInit(TIM2, &TIM_ICInitStructure); TIM_ICInitStructure.TIM_Channel = TIM_Channel_2; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_IndirectTI; // 间接映射到TI2 TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter = 0x06; TIM_ICInit(TIM2, &TIM_ICInitStructure); // 方法2(更简洁):直接设置定时器的编码器模式 // TIM_EncoderInterfaceConfig(TIM2, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); // 此函数内部会完成上述通道配置。TIM_EncoderMode_TI12表示在TI1和TI2的边沿都计数(四倍频)。 // 6. 使能定时器 TIM_Cmd(TIM2, ENABLE); }关键参数解析:
TIM_ICPolarity:设置为TIM_ICPolarity_Rising或TIM_ICPolarity_Falling,在TIM_EncoderInterfaceConfig中,两个参数分别对应TI1和TI2的极性。通常两个都设为Rising,表示在两个信号的上升沿和下降沿都计数(四倍频),分辨率最高。TIM_ICFilter:这是对付机械编码器抖动的利器。它是一个4位值,定义了以f_CK_INT频率采样多少次相同的电平才被认为有效。值越大,抗抖动能力越强,但也会引入微小延迟。对于EC11这类机械编码器,设置为0x06或0x08通常效果不错。可以通过实验调整:快速旋转时,如果计数偶尔跳变(多计或少计),可以适当增大此值。TIM_Period:自动重装载值。编码器计数值会在这个范围内循环。例如设为999,则计数器从0计数到999后,再下一个脉冲会变成0(向上计数)或999(向下计数)。可以根据最大计数值需求设置。如果担心溢出,可以开启定时器更新中断,在中断中处理溢出。
读取计数值与方向判断
int32_t getEncoderCount(void) { static int16_t lastCount = 0; static int32_t totalCount = 0; // 用于处理定时器溢出,扩展计数范围 int16_t currentCount = TIM_GetCounter(TIM2); int16_t diff = currentCount - lastCount; // 处理16位有符号数溢出/下溢的差值计算 // 由于计数器是0-65535循环,当从65535到0时,diff应为+1,但直接相减会得到负值。 // 更稳健的方法是使用带溢出处理的差值计算函数,这里简化处理: // 假设速度不会快到一个周期内转过半圈以上(即|diff| < 32768) if(diff > 32767) { diff -= 65536; } else if(diff < -32768) { diff += 65536; } totalCount += diff; lastCount = currentCount; return totalCount; } // 更简单的方法:直接读取,适用于不关心溢出、范围小的场合 int16_t getEncoderCountSimple(void) { return (int16_t)TIM_GetCounter(TIM2); // 直接返回当前计数值 } // 判断方向:根据计数器的变化趋势。如果`getEncoderCount()`返回值持续增加,为正转;持续减少,为反转。注意事项:
TIM_GetCounter()返回的是uint16_t,但计数器在编码器模式下是有符号变化的(可加可减)。当反转时,计数器会从0减到65535(对于16位定时器)。因此,如果我们将其直接转换为int16_t,0减1会变成-1,这是正确的。但计算差值时要小心处理溢出。上面的getEncoderCount()函数实现了一个简单的溢出处理逻辑。主函数中的应用
int main(void) { // ... 初始化 RotaryEncoder_Init(); int32_t lastEncoderPos = 0; while(1) { int32_t currentPos = getEncoderCount(); if(currentPos != lastEncoderPos) { printf("Encoder Position: %ld\r\n", currentPos); // 可以计算速度:速度 = (currentPos - lastEncoderPos) / 采样时间间隔 lastEncoderPos = currentPos; } // ... 其他任务 } }
5. 高级话题与性能优化
5.1 高速计数与定时器溢出处理
当编码器高速旋转时,计数器可能很快达到上限(对于16位定时器是65535)或下限(0)。如果只是简单读取,会丢失溢出信息,导致长距离累计计数错误。
解决方案:
- 使用32位计数器:选择STM32中具有32位计数器的定时器(如TIM2或TIM5在某些系列是32位的)。这样计数范围高达42亿,绝大多数应用都不会溢出。
- 开启定时器更新中断(UIE)处理溢出:对于16位定时器,我们可以开启更新中断,在中断中维护一个软件扩展的高位计数器。
volatile int32_t encoderOverflowCount = 0; // 扩展的高16位 void TIM2_IRQHandler(void) // 假设TIM2用于编码器 { if(TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); if(TIM_GetCounter(TIM2) > 30000) { // 正向接近溢出 encoderOverflowCount += 65536; } else if(TIM_GetCounter(TIM2) < 30000) { // 反向接近下溢 encoderOverflowCount -= 65536; } // 更精确的方法:根据计数方向标志判断 } } int64_t getFullEncoderCount(void) { int64_t count = (int64_t)encoderOverflowCount + (int64_t)TIM_GetCounter(TIM2); return count; } - 使用DMA读取计数器:对于极高速度的应用,可以使用DMA定期将定时器计数器的值搬运到内存中,完全解放CPU。
5.2 抗干扰与信号完整性
在工业环境或电机附近,电磁干扰可能严重影响传感器信号。
- 对射传感器:使用屏蔽线连接传感器;在接收端信号线对地并联一个小电容(如10-100pF)滤除高频噪声;确保传感器供电稳定(LDO而非开关电源,或加强滤波)。
- 旋转编码器:使用双绞线或屏蔽电缆连接A、B相信号;在STM32的输入引脚处增加RC低通滤波(如串联一个100Ω电阻,再对地接一个100pF电容);确保编码器接地良好。
5.3 低功耗设计考虑
对于电池供电设备,需要关注功耗。
- 对射传感器:红外发射管是耗电大户。可以将其控制引脚连接到STM32的GPIO,仅在需要检测时短暂开启发射管,采用脉冲式检测而非持续发射。
- 旋转编码器:编码器本身耗电极微。但STM32的定时器和GPIO时钟需要开启。在休眠模式下,可以将编码器引脚配置为外部唤醒源(EXTI),当旋转发生时产生中断唤醒MCU,唤醒后再初始化定时器进行精确计数。
6. 调试技巧与常见问题排查
6.1 调试工具与手段
- 逻辑分析仪/示波器:这是最强大的调试工具。用它直接测量传感器输出引脚和STM32输入引脚的波形,可以直观看到:
- 对射传感器:遮挡前后电平是否干净利落地跳变?是否有抖动?
- 旋转编码器:A、B两相波形是否规整的方波?相位差是否是90度?转动时是否有毛刺(抖动)?
- STM32的GPIO翻转:在中断服务函数或关键代码位置,用一个空闲的GPIO引脚进行电平翻转,然后用示波器观察这个引脚,可以测量中断响应时间、代码执行时间。
- 串口打印:最常用的方法。在中断或主循环中打印计数值、方向标志等。注意:避免在高速中断中频繁调用
printf,它非常慢,会严重干扰系统。可以设置标志位,在主循环中打印。
6.2 常见问题速查表
| 现象 | 可能原因 | 排查与解决方法 |
|---|---|---|
| 对射传感器计数值乱跳 | 1. 环境光干扰(日光、灯光) 2. 传感器输出信号抖动 3. 中断触发方式设置错误 | 1. 检查传感器是否带调制解调功能。用手快速晃动遮挡,用示波器看输出波形是否干净。 2. 在中断服务函数中加入简单的延时消抖(如 for(i=0;i<100;i++);)。3. 确认传感器空闲和遮挡时的输出电平,正确配置EXTI的触发边沿(上升沿/下降沿)。 |
| 旋转编码器计数方向反了 | A、B相引脚接反 | 交换连接到STM32的A、B相引脚。或者在软件中,交换定时器编码器模式初始化中两个通道的配置(DirectTI和IndirectTI互换)。 |
| 编码器旋转时计数不准确,有漏计或多计 | 1. 机械编码器抖动 2. 旋转速度过快,CPU或定时器处理不过来 3. 滤波器设置不当 | 1.首要检查:用示波器看A、B相信号,观察边沿是否有毛刺。 2. 增大定时器的输入滤波器值( TIM_ICFilter),如从0x00调到0x06或0x08。3. 对于高速应用,确认定时器时钟频率足够高,并考虑使用32位定时器或DMA。 |
| 编码器单向旋转正常,反向不计数或乱计数 | 其中一个信号线接触不良或损坏 | 用万用表或示波器检查A、B相在正反转时的波形是否都正常。检查焊接和连接。 |
| 使用定时器编码器模式,但计数器不变化 | 1. 定时器时钟未开启 2. GPIO未正确复用 3. 定时器未使能( TIM_Cmd) | 1. 检查RCC_APB1PeriphClockCmd是否已调用。2. 检查 GPIO_PinRemapConfig是否正确(有些引脚默认复用,无需重映射)。3. 单步调试,查看定时器控制寄存器(CR1)的CEN位是否被置1。 |
| 计数值偶尔发生巨大跳变 | 中断标志未及时清除,导致连续进入中断 | 确保在中断服务函数开始或判断后立即清除对应的中断标志位(EXTI_ClearITPendingBit或TIM_ClearITPendingBit)。 |
| 低功耗模式下,编码器无法唤醒MCU | EXTI未正确配置或唤醒源设置错误 | 1. 进入休眠前,确保编码器对应GPIO的EXTI中断已使能,且NVIC已配置。 2. 根据休眠模式(Sleep/Stop/Standby),确认EXTI是否支持唤醒该模式。 |
6.3 一个综合实验:同时计次与转速测量
我们可以结合定时器的编码器模式和另一个定时器的定时中断,来实现转速测量。
- 硬件:TIM2用于编码器模式计数(位置)。
- 软件:TIM3配置为1ms的定时中断。
- 逻辑:在TIM3的中断中,每隔固定时间(如100ms)读取一次TIM2的计数器差值,差值即为这段时间内的脉冲数。转速(RPM)= (脉冲数 / 每转脉冲数PPR) / 时间(分钟)。例如,100ms内计数400个脉冲,编码器为100PPR(四倍频后为400 counts/rev),则转速 = (400 / 400) / (0.1/60) = 150 RPM。
这个实验能很好地融合定时器、中断、编码器三个知识点,也是很多实际项目(如电机控制)的基础。调试时,可以先用PWM模拟一个已知频率的方波信号给编码器接口,验证计数和测速的准确性。
