[RTOS前期准备]以Systick作为时基源+基本定时器编写延时函数(基于STM32F407+CubeMX+HAL)

实验缘由

和裸机开发不同,在RTOS实时操作系统中,我们需要选取一个定时器作为单片机时基源,成为系统“跑”起来的心跳。在这里,选取M4内核的Systick系统定时器来保持RTOS的心跳,当然,也可以选用其他定时器作为RTOS的心跳,目前许多官方源码和软件都是基于Systick系统定时器做的BSP板级支持包,为了移植的方便,我们就老老实实选它作为单片机的时基源。
同时问题也来了:Systick系统定时器拿去做RTOS的心跳了,原本基于Systick系统定时器的delay_us函数已经不能用了,delay_ms函数也被做成了软件延时,精度和稳定性都下降了一些。STM32的TIM定时器那么多,我们一般不可能全部用上,完全可以像裸机开发那样,把TIM拿来硬件定时,做到和Systick一样的效果。通用定时器和高级定时器功能强大,用来写延时函数太浪费了,故本篇文章选取基本定时器TIM7(TIM6亦可)。


本篇以STM32F407VET6为基础进行论述。

一、什么是晶振?晶振频率与外设时钟频率?

这里抛开那种无意义的介绍不谈。单片机可以产生各种各样的信号,但是,这些信号的产生源头到底是谁???是它相应的时钟源!在CubeMX的图形化界面配置时,可以很清楚地看到,单片机可以选择自己的系统时钟源,对它进行各种倍频,分频,再分支管理,分配给各个总线上的外设来达到自己的功能需求,没有时钟,无论哪个外设都跑不起来。外设的时钟源来源于系统时钟源,是其分支管理后的结果。单片机有自己的内部时钟源,但当选择外部时钟来作为自己的时钟源时,这个角色就是晶振,说白了,晶振就是个时钟源,与外设时钟源不同,他是整个系统的时钟源
倍频和分频,它调整的属性正是频率。这里频率的概念就是物理上频率的概念,f=1/T,即1s钟内进行单次操作的次数,T就是进行一次完整操作所需的时间,f越大,T就越小,意味着单片机某个外设进行单次完整操作所需的时间就越少,干活就越快。
注意:本文所说的单位操作是指在特定外设下的运行
这和我们人体活动类似,人运动越剧烈,心跳就会越快,但人没有心脏,就没有生命运作的前提,更不用谈运动的快慢了,所以我们常比喻晶振就是单片机的心脏
在这里插入图片描述时钟树配置情况



M(兆)在数字上是指10的6次方
在了解上述重要概念的基础上,Systick系统定时器和TIM定时器的运行速度取决于它最终被分配到的时钟的频率。Systick系统定时器挂载在AHB总线上,最终得到的时钟频率是在168M下进行分频获得的,这里不分频,得到的就是168M的时钟频率。
在这里插入图片描述

而TIM基本定时器挂载在APB1,是在AP1时钟频率下通过倍频得到的,这里得到了2倍的倍频系数,最终得到84M的时钟频率。显然Systick系统定时器的运行速度比基本定时器快。
在这里插入图片描述

二、TIM7基本定时器

1.设计思想

上文已经提到TIM7获得的时钟频率是84MHz,意味着什么?它1s可以进行单位操作84000 000次!换句话说就是延时1s需要计数84000 000次!而计数一次就是芯片的单位操作之一。如果要做1ms的延时,我们必须让他计数84000;1us延时,只需要计数84次。
然而基本定时器计数值存储的寄存器是低16位有效的,意味着65535以上的计数不可行,怎么实现1ms延时?没事,我们还可以对他进行分频(通过PSC预分频器)。
在这里插入图片描述

假如我们把84MHz分频8400,最终得到的就是10000Hz的频率,那他计数一次不就是100us吗?计数10次不就是1ms吗?
在此强调:了解好外设的时钟分配,运行机制,并进行自主的数学运算非常重要
其他种类芯片的定时器计算也是按照这个思想进行。
在计数值为10的倍数的原则下,ms的延时函数我们采取8400分频,计数10次来设计以1ms为单位的毫秒延时;us的延时函数我们采取84分频,计数1次来设计以1us为单位的微秒延时函数。


这里不需要使用定时器的中断。理论上若开启中断,通过设计可以作为单片机的副时基源,用来解决其他需求。但在这里我们只是为了解决延时函数的空缺。
在这里插入图片描述TIM初始化情况

2.代码

                                      TIM7的初始化
void MX_TIM7_Init(void)
{
   
   
  TIM_MasterConfigTypeDef sMasterConfig = {
   
   0};

  htim7.Instance = TIM7;
  htim7.Init.Prescaler = 84-1;						/*初始化时候的分频*/
  htim7.Init.CounterMode = TIM_COUNTERMODE_UP;		/*基本定时器只能向上计数*/
  htim7.Init.Period = 65535;							/*初始化时ARR为65535*/
  htim7.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
  if (HAL_TIM_Base_Init(&htim7) != HAL_OK)
  {
   
   
    Error_Handler();
  }
  sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET;
  sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
  if (HAL_TIMEx_MasterConfigSynchronization(&htim7, &sMasterConfig) != HAL_OK)
  {
   
   
    Error_Handler();
  }
	HAL_TIM_Base_Stop(&htim7);						//TIM7只在要进行延时操作的时候运行
}
                       				us延时函数
void delay_us(uint16_t us)
{
   
   
	uint16_t compare=(0XFFFF-us-5);
	
	__HAL_TIM_SET_PRESCALER(&htim7,84-1);		//重置为84分频
	htim7.Instance->EGR|=0x0001;				//软件触发更新事件,将重配的分频值同步到影子寄存器
	__HAL_TIM_SetCounter(&htim7,compare);		//重置计数值
	
	HAL_TIM_Base_Start(&htim7);
	while(compare<0XFFFF-5)
	{
   
   
		compare=__HAL_TIM_GetCounter(&htim7);	//轮询检查
	}
		HAL_TIM_Base_Stop(&htim7);
}

为什么是赋值0XFFFF-us-5这个奇怪的数字呢?这就取决于基本定时器的计数方式了,他只能向上计数,这里我们选择65530(0XFFFF-5)作为基准值,往前倒减所产生的间隔us,就是我们要延时的us时间。

                                       ms延时函数
void delay_ms(uint16_t ms)
{
   
   
	uint16_t compare=(0XFFFF-10*ms-5);
	
	__HAL_TIM_SET_PRESCALER(&htim7,8400-1);		//重置为8400分频
	htim7.Instance->EGR|=0x0001;				//软件触发更新事件,将重配的分频值同步到影子寄存器			
	__HAL_TIM_SetCounter(&htim7,compare);		//重置计数值
	
	HAL_TIM_Base_Start(&htim7);
	
	while(compare<0XFFFF-5)
	{
   
   
		compare=__HAL_TIM_GetCounter(&htim7);	//轮询检查
	}
	HAL_TIM_Base_Stop(&htim7);
}

这里同样以65530(0XFFFF-5)作为基准值。但要注意:在8400分频下,计数一次是100us,计数10次才算1ms,所以这里我们规定以计数10次为单位,所以这个往前倒减的间隔是10*ms;

3.那些坑~

1.为什么分频比要减1?
硬件是从0开始计数的,而我们人却习惯于从1开始计数,难免刚开始思想会转不过来。
试想我们的要求是数10次。从1开始计,到10才算数了10次;
如果从0开始数到10呢?就数了11次,就不满足数10次的要求,所以要-1
2.要在TIM停止运作时再对它的寄存器值进行修改
3.何为影子寄存器?
预分频器寄存器 (TIMx_PSC)和自动重载寄存器 (TIMx_ARR)都是影子寄存器。
影子寄存器这个名字取得真是不好理解。我来自定义一下吧:
通俗来说,以PSC预分频器举例,它有一个存值寄存器,一个是生效寄存器;生效寄存器不可写,存值寄存器可写。生效寄存器他存的值可以立即让硬件生效工作,而存值寄存器只是存值和供生效寄存器更新(复制过来);在调用:







__HAL_TIM_SET_PRESCALER(&htim7,8400-1);

__HAL_TIM_SET_PRESCALER(&htim7,84-1);

时,只是更改了存值寄存器的值,生效寄存器的值没有变,所以即使用了这个函数,TIM还是没有按照新的分频比运作。那咋办?
在这里插入图片描述
原来要产生更新事件。但更新事件怎么产生?
在这里插入图片描述
从官方手册可知,要么等待他上溢,要么软件触发,但是延时函数我们要即改即用,不能先用原来的分频等他上溢后再更新,这样造成的误差太大了。因此我们自己动手,丰衣足食:



htim7.Instance->EGR|=0x0001;				//软件触发更新事件,将重配的分频值同步到影子寄存器			
__HAL_TIM_SetCounter(&htim7,compare);		//重置计数值

注意:更新事件产生后CNT会自动清0,所以这两段代码不能调换位置。

4.演示效果

选取两个IO进行高低电平跳转,上下分别为延时100us和30ms的效果
在这里插入图片描述
在这里插入图片描述

三、SysTick系统定时器

1.配置项

在这里插入图片描述Serial Wire表示SW调试,如果你的下载接口是JTAG就需要更改。
时基源Timebase Source选择SysTick.

2.CubeMX的设计思想

<1>.SysTick系统定时器概述

上文已经介绍到本例SysTick系统定时器获得的时钟频率是168Mhz,与基本定时器不一样,它向下计数,没有预分频器,不能自由选择自己喜好的分频系数;而SysTick系统定时器的重装载寄存器LOAD和计数寄存器VAL是24位的,最大存值为16777215。在LOAD存满的情况下,最大每进行一次单位操作可达约0.1s。在定时器每次都从LOAD满值计到0的情况下,根据数学计算,如果单位操作的时间只需要1us,那么LOAD只需要存168即可;要达到单位操作时间长达1ms,LOAD需要存值168000,它小于16777215,所以存得下,对比基本定时器只有16位的有效存值,这里就方便多了。

<2>.初始化

在HAL库自动生成的代码中,SysTick系统定时器的初始化是这样的路径:
HAL_Init()——>HAL_InitTick(形参)——>HAL_SYSTICK_Config(形参) ——>SysTick_Config(形参)
我们看SysTick_Config就一步到位。

__STATIC_INLINE uint32_t SysTick_Config(uint32_t ticks)
{
   
   
  if ((ticks - 1UL) > SysTick_LOAD_RELOAD_Msk)
  {
   
   
    return (1UL);   /* Reload value impossible */
  }

  SysTick->LOAD  = (uint32_t)(ticks - 1UL);  /* set reload register */
  NVIC_SetPriority (SysTick_IRQn, (1UL << __NVIC_PRIO_BITS) - 1UL); /* set Priority for Systick Interrupt */
  SysTick->VAL   = 0UL;      /* Load the SysTick Counter Value */
  SysTick->CTRL  = SysTick_CTRL_CLKSOURCE_Msk |
                   SysTick_CTRL_TICKINT_Msk   |
                   SysTick_CTRL_ENABLE_Msk;  /* Enable SysTick IRQ and SysTick Timer */
  return (0UL);                              /* Function successful */
}

这里我们只关注LOAD和VAL的值,LOAD决定了他的计算周期,进而可以计算出单位操作一次所需的时间;而VAL就是当前的计数值,是我们可以操纵的量。
我们来看默认对他的配置:

SystemCoreClock=168000000; //系统时钟频率
uwTickFreq=1;//节拍频率
HAL_SYSTICK_Config(SystemCoreClock / (1000U / uwTickFreq)) 

显然通过这个函数最终传递给SysTick_Config的值是168000,所以CubeMX默认生成的SysTick代码里面设置计数周期为1ms,uwTickFreq节拍频率的值也会影响计数周期,但我们一般默认让它为1.

<3>.时基?系统心跳?

我们要选取一款定时器作为时基源,然后用这个定时器产生时基,晶振给单片机提供了心脏,而心脏的心跳,就从时基中来。
上面这句话是不是听得云里雾里?没事,听我慢慢道来:

①节拍

在基本定时器中,没有开启中断,只是拿来做延时函数。而SysTick系统定时器作为时基源,与基本定时器最大的不同,就是开启了中断。
问题显而易见,中断拿来干什么???

/**
  * @brief This function handles System tick timer.
  */
void SysTick_Handler(void)
{
   
   
  /* USER CODE BEGIN SysTick_IRQn 0 */

  /* USER CODE END SysTick_IRQn 0 */
  HAL_IncTick();
  /* USER CODE BEGIN SysTick_IRQn 1 */

  /* USER CODE END SysTick_IRQn 1 */
}
void HAL_IncTick(void)
{
   
   
  uwTick += uwTickFreq;//uwTick是节拍变量
}

SysTick的中断里面对uwTick进行了以uwTickFreq为单位的累加;一次完整的计数周期是1ms,每1ms产生一次中断,对uwTick进行累加,可以理解为节拍就是记录系统到达一个计数周期次数的量,对,它是一个量;
uwTickFreq是节拍频率,上文已经提到uwTickFreq=1,节拍uwTick累加的幅度取决于节拍频率uwTickFreq。
普通定时器和时基源,两者相差的,仅有一个中断和节拍计算
系统心跳:定时器开启中断,并在中断函数里面记录它的节拍,节拍的值就是它心跳的次数


②节拍频率

节拍频率决定了系统心跳的快慢,它的含义完全可以从频率的定义去分析

3.HAL_Delay函数

#define HAL_MAX_DELAY      0xFFFFFFFFU
void HAL_Delay(uint32_t Delay)
{
   
   
  uint32_t tickstart = HAL_GetTick();
  uint32_t wait = Delay;

  /* Add a freq to guarantee minimum wait */
  if (wait < HAL_MAX_DELAY)
  {
   
   
    wait += (uint32_t)(uwTickFreq);//若Delay=0,至少让他延时一个节拍
  }

  while((HAL_GetTick() - tickstart) < wait)
  {
   
   
  }
}

CubeMX生成的HAL_Delay(uint32_t Delay)以实时获取节拍来延时,所以最短延时时间就是它的计数周期,这里为1ms

4.试写SysTick的us延时函数

①对比野火官方的us延时例程

static __IO u32 TimingDelay;
 
/**
  * @brief  启动系统滴答定时器, 10us中断一次
  * @param  无
  * @retval 无
  */
void SysTick_Init(void)
{
   
   
	/* SystemFrequency / 1000    1ms中断一次
	 * SystemFrequency / 100000	 10us中断一次
	 * SystemFrequency / 1000000 1us中断一次
	 */
	if (HAL_SYSTICK_Config(SystemCoreClock / 100000))
	{
   
    
		/* Capture error */ 
		while (1);
	}
}
/**
  * @brief   us延时程序,10us为一个单位
  * @param  
  *		@arg nTime: Delay_us( 1 ) 则实现的延时为 1 * 10us = 10us
  * @retval  无
  */
void Delay_us(__IO u32 nTime)
{
   
    
	TimingDelay = nTime;	

	while(TimingDelay != 0);
}
/**
  * @brief  获取节拍程序
  * @param  无
  * @retval 无
  * @attention  在 SysTick 中断函数 SysTick_Handler()调用
  */
void TimingDelay_Decrement(void)
{
   
   
	if (TimingDelay != 0x00)
	{
   
    
		TimingDelay--;
	}
}
/**
  * @brief  中断服务函数
  * @param  无
  * @retval 无
  * @attention  
  */
void SysTick_Handler(void)
{
   
   
	TimingDelay_Decrement();
}

野火的官方代码设计思想和HAL库生成的代码设计思想并无二异,不同的只是节拍计数的情景和计数方式;野火的代码只有在运行延时函数时才进行节拍的计数,而节拍是向下递减的。但是这样的设计思想容易误导新人,下面我们理所当然地试想这样设计ms延时函数是不是很容易?

/**
  * @brief   ms延时程序,1ms为一个单位
  * @param  无
  * @retval  无
  */
void Delay_ms(__IO u32 nTime)
{
   
    
	while(nTime--)
	 Delay_us(100);//10*100us=1ms
}

上面的代码是很典型的阻塞式嵌套,是非常占用CPU的,极易出现系统“卡死”现象
因此这样的设计思想不可取,试想:Delay_us函数要每10us运行一次,我阻塞了100次才到此1ms.现在我还要进行嵌套阻塞,CPU在Delay_ms函数运行时间的占比就会大大提高,其他操作经常被中断打断,导致其他操作的现象出不来,就是这个原因,因为其他操作的运行时间的占比被强制拉低了,还经常被打断,影响到其他设备的时序初始化也不奇怪。
我们要做的,就是避免纯软件阻塞式的嵌套

②设计思想

1ms延时函数CubeMX默认生成的代码已经帮我们实现了。为了避免不必要的麻烦,我们不动重装载寄存器LOAD的值,仅通过修改计数寄存器VAL的值来达到目的。
我们类比上述基本定时器us延时函数的设计思想:168Mhz的频率下,我们只需要让SysTick定时器计数168次就可以达到1us的延时效果了。结合它向下计数的特性,我们把最开始获得的VAL计数值作为基准,在这个值的基础上往前倒推168的正整数倍,其间隔就是实际总计数值,倍数就是用户设置的us延时值。

③代码

void SysTick_us(uint32_t us)
{
   
   
	uint32_t start_val,real_val,JianGe;
	start_val=SysTick->VAL;              //把最开始获得的计数值作为基准值
	do{
   
   
	 real_val=SysTick->VAL;				//获取当前值
	 if(start_val>real_val)				//应对计时器提前下溢
	 {
   
   
	 	JianGe=start_val-real_val;
	 }
	 else
	 {
   
   
	   JianGe=SysTick->LOAD+start_val-real_val;
	 }
	}while(JianGe<us*168);			 //判断是否到达间隔
} 

最后再来看看RT-Thread官方是怎么处理us函数,思路是不是完全一样???

void rt_hw_us_delay(rt_uint32_t us)
{
   
   
    rt_uint32_t start, now, delta, reload, us_tick;
    start = SysTick->VAL;
    reload = SysTick->LOAD;
    us_tick = SystemCoreClock / 1000000UL;
    do {
   
   
        now = SysTick->VAL;
        delta = start > now ? start - now : reload + start - now;
    } while(delta < us_tick * us);
}

④注意事项: 计时下溢

假如我们要延时5us,那么计数间隔就是168×5,但当前计数值是168(作为基准VAL),从168减到0只有168×1个间隔,当计数到0时VAL的值又回到了LOAD的值,这就是计时器提前下溢,那怎么办?
首先我们不能放弃原本已经记下168×1个间隔,在回到LOAD值的场景下,实际VAL=LOAD-168×4,这两个间隔累加起来就是168×5;
实际间隔=基准VAL-实际VAL=168-(LOAD-168×4)=168×5-LOAD,
目标间隔=实际间隔+LOAD
在这里可见数学运算的魅力,太直观了!我们轻易就能知道把LOAD加上就能得到想要的间隔!
脑子瞎想没用,一张纸一支笔就能解决你的困惑!




⑤演示效果

以下是延时30us IO口电平跳变的效果
在这里插入图片描述

知识小卡片

1.U,L,UL:
U:无符号型数据
L:长整型数据
UL:无符号长整型数据
例如:10UL,说明10这个值是无符号长整型
2.__IO修饰符
__IO其实就是volatile特征修饰符。
在了解这个修饰符之前我们要知道一个事实: 访问寄存器比直接访问内存快
通常编译器会对程序进行优化,把变量(如char i)所在内存的值先存到单片机的特殊寄存器中,当程序在已知的情况发现变量的值改变时,就会把值重新更新到内部特殊寄存器中,这样以后我们对变量的读取就直接从寄存器读取。
但是遇到未知的情况时,变量值发生了改变,检测变量的函数却无法得知,访问的还是寄存器里的值,也就是变量原来的“备份”,就容易出问题。而中断函数,就属于位置情况;RTOS中的线程,也会成为未知情况。这时候变量就要做修饰,如 volatile char i;








/**
  * @brief   us延时程序,10us为一个单位
  * @param  
  *		@arg nTime: Delay_us( 1 ) 则实现的延时为 1 * 10us = 10us
  * @retval  无
  */
void Delay_us(__IO u32 nTime)
{
   
    
	TimingDelay = nTime;	

	while(TimingDelay != 0);
}
/**
  * @brief  获取节拍程序
  * @param  无
  * @retval 无
  * @attention  在 SysTick 中断函数 SysTick_Handler()调用
  */
void TimingDelay_Decrement(void)
{
   
   
	if (TimingDelay != 0x00)
	{
   
    
		TimingDelay--;
	}
}
/**
  * @brief  中断服务函数
  * @param  无
  * @retval 无
  * @attention  
  */
void SysTick_Handler(void)
{
   
   
	TimingDelay_Decrement();
}

上述代码中,TimingDelay_Decrement()函数在对TimingDelay检测前并没有对TimingDelay进行任何写操作,如果不加__IO修饰,在产生中断后,TimingDelay递减后的值不会实时同步到Delay_us()函数中,而在另一个文件的SysTick_Handler()中断函数,也不会知道另一个文件Delay_us()函数对TimingDelay的重新赋值,两者都在用之前的“备份”值,产生混乱。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章