用STM32定时器测量信号频率——测频法和测周法[原创cnblogs.com/helesheng]

工业测试与控制系统中,经常需要对未知信号的频率进行测试。对于10MHz以下的信号,用单片机(MCU)定时器完成这项任务显然是最常见和最佳的选择。目前性价比最高的单片机STM32拥有功能强大且数量众多的定时器,能够轻松的胜任各种频率信号的测试工作。但也正是由于STM32的定时器功能过于强大和完善,常见的技术书籍往往将篇幅专注于STM32定时器的定时、PWM和触发DMA传输等常见功能,而对于测频率所需的计数和捕捉等功能往往一笔带过,更不会专门针对具体应用给出定时器的配置方法。本文分别介绍用STM32通用定时器,实现“测频法”(又称“计频法”)和“测周法”(又称“计时法”)这两种最常见方法的代码和步骤。[原创cnblogs.com/helesheng]
在开始正文前还要总结一下“测频法”和“测周法”两种测频方法的设计思路。
一、测频法(又称“计频法”)
测频法的思路,是对较长的一段“标准时间”内被测信号的脉冲数量进行计数。如下图所示,给出“标准时间”的标准信号周期为Tc1,频率为fc1;外部输入的被测信号周期为Tx1,频率为fx1。
图1 测频法测量原理
如果计数器测得标准时间内Tc1时间内,被测信号共出现了N1个脉冲,则被测信号的频率为:
                                                     (1)
采用测频法,造成测量误差的唯一原因是:计数器只能进行整数计数,而在Tc1时间窗口内,却不一定刚好有整数个被测信号周期。因此测频法造成的最大测量误差为±1个被测信号,参考上面的(1)式,若计数结果为N1,则频率的最大可能值为fx1+=(N1+1)fc1;最小可能值为fx1-=(N1-1)fc1。相对频率误差为:
ef=±1/N1×100%                                                           (2)
由(2)可知,N1越大,相对频率误差越小。
而这和我们的直觉相符:当标准信号频率远低于被测信号频率时,Tc1窗口内的被测信号脉冲很多(N1很大),测频法得到的结果就越准确。也就是说,实际应用中,测频法(计频法)的“用武之地”是在被测信号频率较高时。而当被测信号频率较低时,为获得足够高的测量精度增大N1,就有可能导致测量时间加长,测试的实时性降低。
被测信号频率较低时,合理的测试方法是测周法(又称“计时法”)。
二、测周法(又称“计时法”)
测周法的测量对象是频率较低的被测信号,因此颠倒了图1中标准信号和被测信号频率之间的对比关系。图2所示的测周法,使用比被测信号高得多的标准信号,在被测信号周期Tx2内对标准信号进行计数。
图2 测周法测量原理
如果Tx2内得到的计数值为N2,则被测信号周期为:
                                                                       (3)
采用测周法,造成测量误差的原因也是:计数器只能进行整数计数,而在Tx2时间窗口内,却不一定刚好有整数个标准信号周期。因此测周法造成的最大测量误差为±1个标准信号周期,参考上面的(3)式,若计数结果为N2,则测量周期的最大可能值为Tx2+=(N2+1)Tc2;最小可能值为Tx2+=(N2+1)Tc2。相对周期误差为:
eT=±1/N2×100%                                                                       (4)
由(4)可知,N2越大,相对周期误差越小。
当标准信号频率远高于被测信号频率时,Tx2窗口内的被测信号脉冲很多(N2很大),测周法得到的结果就越准确。也就是说,实际应用中,测周法法(计数法)的“用武之地”是在被测信号频率较高时
三、两种测量方法的选择——以STM32为例
上面的理论分析得到的结论是:测频法用在被测信号的频率“较低”时,测周法用在被测信号的频率“较高”时,那到底多少频率可以称为“较低”,多少频率又算“较高”呢?——答案是由标准信号频率fc1和fc2,以及被测频率fx决定。以最常用的STM32F1系列@72MHz为例:假设被测频率为1KHz左右,需要100ms刷新一次测量结果(即测量时间分辨率为100ms)。
如果使用测频法,为提高精度,需要使标准信号fc1相对于被测信号(1KHz左右)尽可能的“低”,用定时器产生100ms的测量周期,再在该测量周期内对被测信号进行计数。对照图1,在100ms测量周期Tx1内约有N1=100ms/(1/1KHz)=100个脉冲,代入公式(2),得到相对误差为:ef=1×10-2
如果使用测周法,为提高精度,需要使标准信号fc2相对于被测信号(1KHz左右)尽可能的“高”,所以直接使用定时器最高工作时钟72MHz作为fc2。对照图2,在被测信号周期Tx2内约有N2=(1/1KHz)/(1/72MHz)=72_000个脉冲,代入公式(4),得到相对误差为:eT=1.39×10-5
显然,对于这个具体问题,测周法的测量精度远远高于测频法的测量精度。
四、用STM32定时器实现测频法(计频法)的思路和源码
根据图1所示的测频法原理,需要在标准时段(Tc1)内对外部输入的被测信号脉冲进行计数。因此,测频法需要两个STM32定时器,第一个工作在定时器模式——对STM32内部已知的系统时钟进行计数,产生标准时间长度Tc1;第二个工作在计数器模式——对外部输入的被测信号脉冲数N1进行计数。第一个定时器可以是系统定时器SysTick、通用定时器(TIM2、TIM3、TIM4、TIM5)、高级定时器(TIM1和TIM8)或基本定时器(TIM6和TIM7)中的任何一个。而第二个定时器则只能使用具有外部时钟输入管脚的通用定时器或高级定时器。
STM32的通用定时器和高级定时器都支持两种外部时钟源模式:外部时钟源模式1,通过定时器输入通道1和2(TMRxCH1、TMRxCH2)获得外部时钟;外部时钟源模式2,通过专用的TMRxETR管脚获得外部时钟。图3所示的是两种模式下,定时器得到时钟的通路,红色箭头为外部时钟源模式1外部时钟脉冲输入通路,蓝色为外部时钟源模式2外部时钟脉冲输入通路。
图3 STM32通用和高级定时器的两种外部时钟源模式
下面分别给出两种模式实现测频法(计频法)的程序设计思路和部分源代码。
1、外部时钟源模式1
通过外部时钟源模式1(TMRxCH1、TMRxCH2管脚输入外部测频脉冲)的代码大致如下: 
1)使能相关定时器和GPIO时钟,配置GPIO

1 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); 
2 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); 
3 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);     //使能porta
4 //PA1-> TIM2_CH2外部时钟输入
5 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1;//PA1
6 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;    
7 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_10MHz;    //10M时钟速度
8 GPIO_Init(GPIOA, &GPIO_InitStructure);

使能相关定时器和GPIO时钟,配置GPIO

2)配置用于产生标准时长的TIM3的时基单元和中断 

 1 TIM_TimeBaseInitTypeDef  TIM_TimeBaseStructure;
 2 NVIC_InitTypeDef NVIC_InitStructure;
 3 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); //时钟使能
 4 //!!!!!定时器3,用于产生标准时长的对外部脉冲计数的窗口,从而计算外部脉冲的频率!!!!!//
 5 TIM_TimeBaseStructure.TIM_Period = arr-1; //设置在下一个更新事件装入活动的自动重装载寄存器周期的值     计数到5000
 6 TIM_TimeBaseStructure.TIM_Prescaler =(psc-1); //设置用来作为TIMx时钟频率除数的预分频值  10Khz的计数频率  
 7 TIM_TimeBaseStructure.TIM_ClockDivision = 0; //设置时钟分割:TDTS = Tck_tim
 8 TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;  //TIM向上计数模式
 9 TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); //根据TIM_TimeBaseInitStruct中指定的参数初始化TIMx的时间基数单位
10 TIM_ITConfig(  //使能或者失能指定的TIM中断
11         TIM3, //TIM3
12         TIM_IT_Update,    //数值溢出更新中断
13         ENABLE  //使能
14         );
15 NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
16 NVIC_InitStructure.NVIC_IRQChannel = TIM3_IRQn;  //TIM2更新中断
17 NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;  //先占优先级0级
18 NVIC_InitStructure.NVIC_IRQChannelSubPriority = 2;  //从优先级3级
19 NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; //IRQ通道被使能
20 NVIC_Init(&NVIC_InitStructure);  //根据NVIC_InitStruct中指定的参数初始化外设NVIC寄存器
21 TIM_SetCounter(TIM3,0);
22 TIM_Cmd(TIM3, ENABLE);  //使能TIMx外设

配置用于产生标准时长的TIM3

3)配置用于对外部被测脉冲进行计数的TIM2的时基单元和中断

 1 TIM_TimeBaseInitTypeDef  TIM_TimeBaseStructure;
 2 NVIC_InitTypeDef NVIC_InitStructure;
 3 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); //时钟使能
 4 //!!!!!定时器3,用于产生标准时长的对外部脉冲计数的窗口,从而计算外部脉冲的频率!!!!!//
 5 TIM_TimeBaseStructure.TIM_Period = arr-1; //设置在下一个更新事件装入活动的自动重装载寄存器周期的值  计数到5000
 6 TIM_TimeBaseStructure.TIM_Prescaler =(psc-1); //设置用来作为TIMx时钟频率除数的预分频值  10Khz的计数频率 
 7 TIM_TimeBaseStructure.TIM_ClockDivision = 0; //设置时钟分割:TDTS = Tck_tim
 8 TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;  //TIM向上计数模式
 9 TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); //根据TIM_TimeBaseInitStruct中指定的参数初始化TIMx的时间基数单位
10 TIM_ITConfig(  //使能或者失能指定的TIM中断
11         TIM3, //TIM3
12         TIM_IT_Update,    //数值溢出更新中断
13         ENABLE  //使能
14         );
15 NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
16 NVIC_InitStructure.NVIC_IRQChannel = TIM3_IRQn;  //TIM2更新中断
17 NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;  //先占优先级0级
18 NVIC_InitStructure.NVIC_IRQChannelSubPriority = 2;  //从优先级3级
19 NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; //IRQ通道被使能
20 NVIC_Init(&NVIC_InitStructure);  //根据NVIC_InitStruct中指定的参数初始化外设NVIC寄存器
21 TIM_SetCounter(TIM3,0);
22 TIM_Cmd(TIM3, ENABLE);  //使能TIMx外设

配置用于对外部被测脉冲计数的TIM2

这里使用了全局变量top_watch,对外部脉冲计数器溢出次数进行清零,防止在标准时间长Tc1内发生计数溢出。

4)编写标准时长定时器TIM3的中断服务程序

 1 unsigned char i=0;
 2 unsigned int frq[10];//连续存取10次的测频法得到的频率
 3 unsigned int pul_num;//标准时间内的脉冲数量
 4 unsigned int cnt;//读取当前计数值
 5 unsigned int last_cnt=0;//上一次的计数值
 6 void TIM3_IRQHandler(void)   //TIM3中断,达到定时时间
 7 {
 8     if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) //检查指定的TIM中断发生与否:TIM 中断源 
 9         {
10             TIM_ClearITPendingBit(TIM3, TIM_IT_Update);  //清除TIMx的中断待处理位:TIM 中断源 
11             cnt = TIM_GetCounter(TIM2);//读取定时器2对外部脉冲的计数结果
12             if(cnt >= last_cnt)//如果发生过溢出,当前计数结果就有可能比上次的计数结果还小
13                 pul_num = (unsigned int)(top_watch<<16)+ (unsigned int)(cnt - last_cnt);
14             else
15                 pul_num = (unsigned int)((top_watch-1)<<16) + (unsigned int)(65536 + cnt - last_cnt);
16             last_cnt = cnt;//将当前计数结果复制到上次的复制结果寄存,方便下次计算
17             frq[i] = pul_num * 100;//由于定时器3是1/100秒溢出一次,所以频率时脉冲个数的100倍
18             i++;
19             if(i == 10)
20                 i=0;
21             top_watch=0;
22         }
23 }

TIM3的中断服务程序

这里使用数组 frq[10]连续存取10次的测频法得到的频率。每次执行上面的中断服务程序,意味着时间刚好过去了一个Tc1(在这里是10ms,即1/100秒),可以通过函数TIM_GetCounter(TIM2);读取定时器2对外部被测脉冲的计数结果。但该方法的问题是:用于对外部被测脉冲进行计数的TIM2有可能在临近的两次Tc1中断之间发生一次乃至多次溢出/更新,从而造成被测脉冲数计算错误。解决的办法是:在考虑外部被测脉冲计数定时器TIM2计数值的同时,也考虑TIM2溢出的次数top_watch(如上所述,top_watch会在每次TIM2溢出时自动加1)。另外,当前计数值(cnt)和上一次TIM3定时中断发生时的计数值(last_cnt)来计算标准时间Tc1内的外部被测脉冲。
注意:a、当Tc1内发生过TIM2计数溢出时,当前计数值cnt可能小于上一次的计数值last_cnt。
b、这里没有在中断服务程序中对计数器TIM2清零,而是任由TIM2自由计数,反而采用当前计数值和上次计数值求差的办法。这种方法看似繁琐,但保证了TIM2能够不间断的连续计数,而不会使从发生TIM3定时中断,到进入TIM3中服务程序对TIM2清零,这两个事件间的脉冲数被漏记,从而提高了计数精度。

 2、外部时钟源模式2 
STM32的每个通用定时器和定时器,除了支持从输入通道1和2(TMRxCH1、TMRxCH2)输入外部时钟外,还各自拥有一个单独的外部时钟输入管脚TIMx_ETR。例如,TIM1_ETR在PA12,TIM2_ETR在PA0,TIM3_ETR在PD2,TIM4_ETR在PE0。我最初也对这种设计感到奇怪——既然已经支持从TMRxCH1、TMRxCH2输入外部时钟,为什么还要支持使用专用管脚输入?随着使用的深入才逐渐明白STM32设计者的初衷,是想支持用外部时钟驱动的捕获输入和比较输出。因为如果TMRxCH1、TMRxCH2管脚被占用为外部时钟输入,则定时器的捕获输入和比较输出管脚就会变少。
外部时钟源模式1和外部时钟源模式2之间的关系既然是这样的,那么用两种外部时钟源模式来实现测频法(计频法)测量信号频率的思路和代码就几乎相同了。不同点只是在配置定时器时钟源的一句: 
TIM_TIxExternalClockConfig(TIM2,TIM_TIxExternalCLK1Source_TI2,TIM_ICPolarity_Rising,0);
要更改为: 
TIM_SelectInputTrigger(TIM2,TIM_TS_ETRF);
如果外部时钟模式仍然使用定时器2,由于PA0管脚既是TIM2的通道1管脚,又是TIM2的ETR管脚,那么连管脚的配置都可以省去。也就是说,代码其他部分和前面采用外部时钟源模式1进行测频法(计频法)完全相同。
 
五、用STM32定时器实现测周法(计时法)的思路和源码

 根据图2所示的测周法原理,需要在被测信号周期(Tx2)内对STM32内部的最高频率72MHz(为获得最高测量精度和分辨率)的时钟进行计数。最简单的方式将被测信号作为外部中断源,并在外部中断服务程序中读取定时器中的计数值,但这样做会使中断入口时间也计算在Tx2以内。因此实现测周法的最佳方案,是使用STM32通用定时器或高级定时器的捕获功能(Input Capture)。STM32的输入捕获电路框图如图4所示,它能在定时器的某个通道TIMx_CHy发生指定脉冲边沿的时刻及时地将此时的计数器计数值锁存在“捕获/比较寄存器”中,从而有效地避免了上面提到的方法中进入中断时延造成的计时误差。

通过定时器输入捕获实现测周法的代码大致如下: 
1)使能定时器和GPIO时钟,配置GPIO

1 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM5, ENABLE); //使能通用定时器TIM5时钟
2 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);  //使能GPIOA时钟

配置GPIO

2)接下来还要将TIM5CH1的输入管脚PA0配置成输入模式,这里不再赘述。

1 TIM_TimeBaseStructure.TIM_Period = 65535; //设定计数器溢出值(自动重装值)
2 TIM_TimeBaseStructure.TIM_Prescaler = 0;        //预分频器  
3 TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; //设置时钟分割因子
4 TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;  //向上计数模式
5 TIM_TimeBaseInit(TIM5, &TIM_TimeBaseStructure);
6 //根据TIM_TimeBaseInitStruct中指定的参数初始化TIMx的时间基单元

时基单元配置

这里将自动重装值设置为16位计数器的最大值65535(0xFFFF),以增加计数器计数范围,降低自动重装次数。

 3)配置输入捕获器 

标准外设库使用时输入捕获器初始化结构体来配置其参数,该结构体的声明代码为: 

1 TIM_ICInitTypeDef  TIM5_ICInitStructure;  //定义输入捕获器初始化结构体
2 对初始化结构体TIM5_ICInitStructure中的参数赋值,例如如下代码:
3 TIM5_ICInitStructure.TIM_Channel = TIM_Channel_1; //选择输入捕获通道为TIM5_CH1
4 TIM5_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising;     //上升沿捕获
5 TIM5_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; //映射到TI1上
6 TIM5_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; //配置输入捕获脉冲分频,不分频
7 TIM5_ICInitStructure.TIM_ICFilter = 0x00;//配置输入滤波器 不滤波
8 TIM_ICInit(TIM5, &TIM5_ICInitStructure);

配置输入捕获器

4)使能定时器中断 

1 TIM_ITConfig(TIM5,TIM_IT_Update|TIM_IT_CC1,ENABLE);//允许更新中断 ,允许CC1IE捕获中断

使能定时器中断

其中,第二个参数代表触发中断事件,其可选参数已经在前面定时模式介绍过了,这里使用了更新中断或输入捕捉通道1中断都会触发中断的方式。这意味着,在中断服务程序中应检测到底是定时器自动重装更新引起的中断还是捕获引起的中断,并采取相应的应对措施。

 5)配置向量中断控制器NVIC 

1 NVIC_InitStructure.NVIC_IRQChannel = TIM5_IRQn;  //TIM5中断
2 NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2;  //先占优先级2级
3 NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;  //从优先级0级
4 NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; //IRQ通道被使能
5 NVIC_Init(&NVIC_InitStructure);  //根据NVIC_InitStruct中指定的参数初始化外设NVIC寄存器

配置向量中断控制器NVIC

6)使能定时器

1 TIM_Cmd(TIM5, ENABLE);  //使能TIM5

使能定时器

7) 编写定时器中断服务程序(含寄出和捕获的操作)

 1 unsigned short i=0;
 2 unsigned int pul_width[10];//脉冲周期
 3 unsigned int pul_frq[10];//对应的脉冲频率
 4 unsigned short ov_num;//定时器溢出的次数,用于记录之前溢出的次数
 5 unsigned short last_cap_val=0,cur_cap_val;//当前捕获到的数值和上一次捕获到的数值
 6 //定时器5中断服务程序(可以能由捕获或定时器溢出)    
 7 void TIM5_IRQHandler(void)
 8 {
 9   if (TIM_GetITStatus(TIM5, TIM_IT_Update) != RESET)
10 //为了增加测量频率的动态范围,定时器溢出次数也要计算,相当于增加了定时器的位数
11      ov_num++ ;//溢出次数加一
12   if (TIM_GetITStatus(TIM5, TIM_IT_CC1) != RESET)//捕获1发生捕获事件
13   {
14      cur_cap_val = TIM_GetCapture1(TIM5);//读取当前捕获发生时的定时器数值
15      if(cur_cap_val >= last_cap_val)//如果发生过溢出,当前捕获结果就有可能比上次的捕获结果还小
16         pul_width[i] = (unsigned int)(ov_num<<16)+ (unsigned int)(cur_cap_val - last_cap_val);
17      else
18         pul_width[i] = (unsigned int)((ov_num-1)<<16) + (unsigned int)(65536 + cur_cap_val - last_cap_val);
19         pul_frq[i] = 72000000 /(float)pul_width[i] + 0.5;
20         //折算为频率,加0.5是为了防止强制类型转换带来的舍弃误差
21         last_cap_val = cur_cap_val;/将当前捕获结果复制到上次的捕获结果寄存,方便下次计算
22         ov_num = 0;
23         i++;
24         if(i == 10)
25           i = 0;
26   }    
27   TIM_ClearITPendingBit(TIM5, TIM_IT_CC1|TIM_IT_Update); //清除中断标志位
28 }

编写定时器中断服务程序(含寄出和捕获的操作)

上述中断服务程序用于测量从TIM5_CH1输入的最近10个脉冲信号的周期和频率。方法是用定时器的输入捕获模式每个上升沿到来的时刻,从而得到两个上升沿之间的时间间隔即脉冲周期(pul_width),并进而通过计算得到脉冲对应的频率(pul_frq)。该方法最大的问题是:用于捕获的TIM5有可能在临近的两个输入脉冲的上升沿之间发生一次乃至多次溢出/更新,从而造成时间间隔计算错误。解决的办法是:同时允许更新中断和捕获中断,并在中断服务程序中对中断源进行判断。如果是溢出/更新引发的中断,则对全局变量ov_num加一。直至下一个输入上升沿引发捕获中断,则可以通过ov_num的值,以及本次捕获发生时的定时器数值cur_cap_val和上一捕获发生时的定时器数值last_cap_val来计算两次捕获发生之间的时间间隔。注意,这里没有在捕获后对计数器清零,而是任由计数器自由计数,反而采用当前计数值和上次计数值求差的办法。这种方法看似繁琐,但保证了TIM5能够不间断的连续计时,而不会使从被捕获的上升沿到进入中断对TIM5清零,这两个事件间的时间被漏记,提高了捕获时间的精度。