RTOS 上微秒级延时方案

2021-07-20 12:45:53 浏览数 (1)

微秒级延时设计方案

一般 RTOS 系统时钟 1KHz 的情况下,thread_sleep() 的最短时间是 1ms。在实时控制中有些情况需要微秒(us)级延时,这该怎么办呢?

微秒级延时有两种实现思路:一是着情提高系统时钟,二是使用 MCU 的高精度定时器。

一、着情提高系统时钟

之所以说是“着情”提高的原因是:系统时钟越快,单位时间内的线程调度次数越多,也就是说花在调度的时间会大幅增加,这对线程的功能不利。真正做事的是线程函数,如果 CPU 会说话,过快的线程调度将会引起 CPU 的极度不满。线程是 CPU 具体要做的事,刚把 CPU 调过来做事,事没做完就拉跑做另一件事,CPU 会说:“傻瓜,疯了吗?不是让我做事的码,干嘛老是拉着我跑这跑那,就不能让我干完了再走码?!”

二、使用 MCU 片上外设定时器

一般 MCU 都会有片上高精度定时器外设,可以配置到 1us 精度。即然用定时器可以,那就用定时器呗,还写什么文章?当然不只是开启定时器这么简单,RTOS 要实现的是阻塞延时,任务进入延时要交出 CPU 使用权进入阻塞状态。在 RTOS 上用定时器躺平死等是无赖行为,睡眠让权才能实现良好的多线程调度。

虽然 us 级延时时间短,在一个线程处于延时中时另一个线程又要开始延时的情况发生概率不大。但是在多线程情况下延时依旧有可能发生重入,比如一个线程要延时 500us,刚过 100us 另一个线程就要延时 200us,这种情况不但发生了重入,还有“时间覆盖”(200us 覆盖了上一个线程剩余的 400us 里的时间段),这些情况也不是光靠一个硬件高精度定时器就能应对的。

多线程延时工况分析

先来看一张多线程延时工况图,如“图1”所示:

图1. 多线程延时工况01

为了方便阅读以及接下来进一步的设计实现,Sugar 在上图基础上加了一些注释,对多线程的工况进行更细致一点的描述,如“图2”所示:

图2. 多线程延时工况02

为了更好说明 Sugar 选用最近长势正盛的 Microsoft Azure RTOS ThreadX 做基础来实现这个设计。目的在于输出通用方法,具体选什么 RTOS 并不重要,是个多线程就行,比如:RT-Thread、FreeRTOS 等都可以。

图中的 A、B、C 和 High-precision Timer 是 4 个线程。其中 High-precision Timer 线程优先级最高,但不是定时回调的,而是被动触发。下面说说为什么 High-precision Timer 线程优先级要最高,以及如何被动触发。

我们知道线程中用 WAIT_FOREVER 方式等待信号量的时候,若信号量的值为 0 则线程会被挂起在这个信号量下。我们就利用这个特点来完成线程的“被动触发”,即:

1、信号量建立时初值为 0

2、在中断中释放一次信号量(即信号量值加 1)

这样中断发生后就能立刻唤醒挂起在该信号量下的线程,即完成了线程的被动触发。线程转为就绪态后,因其优先级最高,会立即抢占调度器得到执行。在 Hight-precision Timer 线程被信号量唤醒后,立即对延时时间到的线程进行 resume 操作,这样就完成了线程的 us 延时。

我们回看一下上面图中的 A、B、C 三个线程,每条线上都串了两个圈圈,每条线从上往下第一个圈是延时主动挂起,第二个圈是时间到后被 High-precision Timer 线程 resume 回来继续执行。

至此读图的方法基本说清楚了,如果要落实到代码,其实还有个“硬件定时器与 High-precision Timer 线程”的关系。图中标在 High-precision Timer 左边的标签是说:因为硬件定时器产生了中断,才使得 High-precision Timer 线程对延时时间到的线程进行 resume。上面说“被动触发”的时候有说到相关原理,其实上面图的最右边应该再放一列表示“硬件定时器”就更好理解原理了。没有放的原因是这里要考虑“可重入”,这个瓜有点多,一车装不下,装少了说不完善,装多了眼花缭乱,所以就没画“硬件定时器”这一列。

代码实现

为了实现上述设计的阻塞延时,代码要划分为四个部分:

一、 要配置一个 us 级定时器;

二、 要做一个 us 延时的函数接口;

三、 要有一个 High-precision Timer 线程;

四、 要有一个测试用 us 级的普通定时回调线程。

下面 Sugar 以 STM32 为例逐一上代码。

us 级定时器配置
1、 定时器初始化

这里直接使用 CubeMX 生成的函数最方便,一行不改,如下:

代码语言:javascript复制
/**
  * @brief TIM9 Initialization Function
  * @param None
  * @retval None
  */
static void MX_TIM9_Init(void)
{

  /* USER CODE BEGIN TIM9_Init 0 */

  /* USER CODE END TIM9_Init 0 */

  TIM_ClockConfigTypeDef sClockSourceConfig = {0};

  /* USER CODE BEGIN TIM9_Init 1 */

  /* USER CODE END TIM9_Init 1 */
  htim9.Instance = TIM9;
  htim9.Init.Prescaler = 215;
  htim9.Init.CounterMode = TIM_COUNTERMODE_UP;
  htim9.Init.Period = 65535;
  htim9.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
  htim9.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;
  if (HAL_TIM_Base_Init(&htim9) != HAL_OK)
  {
    Error_Handler();
  }
  sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL;
  if (HAL_TIM_ConfigClockSource(&htim9, &sClockSourceConfig) != HAL_OK)
  {
    Error_Handler();
  }
  /* USER CODE BEGIN TIM9_Init 2 */

  /* USER CODE END TIM9_Init 2 */

}

由于我们要使用定时器的定时中断,所以要对 NVIC 设置一下,这部分代码 CubeMX 生成在另一个文件下,Sugar 为了调用方便将之与上面的初始化函数合至一处,如下:

代码语言:javascript复制
void bsp_InitHardTimer(void)
{
    __HAL_RCC_TIM9_CLK_ENABLE();
    HAL_NVIC_SetPriority(TIM1_BRK_TIM9_IRQn, 0, 0);
    HAL_NVIC_EnableIRQ(TIM1_BRK_TIM9_IRQn);
    MX_TIM9_Init();
}

注意,这里调到初始化函数就完了,不要开启定时器,按照设计定时器是需要延时的线程在调用延时函数时才打开的。

2、 打开定时器的函数
代码语言:javascript复制
void bsp_DelayUS(uint32_t n)
{
    n = (n<=30) ? n : (n-30);
    HAL_TIM_Base_Stop_IT(&htim9);
    htim9.Instance->CNT = htim9.Init.Period - n;
    HAL_TIM_Base_Start_IT(&htim9);
}

这里注意是“先关闭再打开”,上面提到了“时间覆盖”的情况下做延时,就必须先关闭正在延时中的定时器。

3、 定时器中断函数
代码语言:javascript复制
/**
  * @brief This function handles TIM1 break interrupt and TIM9 global interrupt.
  */
void TIM1_BRK_TIM9_IRQHandler(void)
{
  /* USER CODE BEGIN TIM1_BRK_TIM9_IRQn 0 */

  /* USER CODE END TIM1_BRK_TIM9_IRQn 0 */
  HAL_TIM_IRQHandler(&htim9);
  /* USER CODE BEGIN TIM1_BRK_TIM9_IRQn 1 */
  tx_semaphore_put(&tx_semaphore_delay_us);
  HAL_TIM_Base_Stop_IT(&htim9);
  /* USER CODE END TIM1_BRK_TIM9_IRQn 1 */
}

这里调用了 Microsoft Azure RTOS ThreadX 释放信号量的 API tx_semaphore_put(),信号量在初始化时建立(省略了建立信号量的代码)。

us 延时的函数接口
代码语言:javascript复制
TX_THREAD       *thread_delay_us;

UINT  tx_thread_sleep_us(ULONG timer_ticks)
{
    TX_THREAD_GET_CURRENT(thread_delay_us)
    bsp_DelayUS(timer_ticks); 
    tx_thread_suspend(thread_delay_us);
    return TX_SUCCESS;
}

这里定义了一个全局变量 thread_delay_us,用 TX_THREAD_GET_CURRENT() 获取调用 us 延时的线程,在打开定时器后将线程通过 tx_thread_suspend() 挂起。

High-precision Timer 线程
代码语言:javascript复制
extern TX_THREAD*      thread_delay_us;

UINT status;
void threadx_task_delay_us_run(ULONG thread_input)
{
    (void)thread_input;

    while(1){
        tx_semaphore_get(&tx_semaphore_delay_us, TX_WAIT_FOREVER);
        if(thread_delay_us){
            status = tx_thread_resume(thread_delay_us);
        }
    }
}

这里同样省略了线程的建立过程,给出了线程主体:与信号量 tx_semaphore_delay_us 一起完成线程的被动触发,以及对 thread_delay_us 线程的 resume。

测试用 us 级的普通定时回调线程
代码语言:javascript复制
#include "pthread.h"

VOID    *pthread_test_entry(VOID *pthread1_input)
{
    while(1) 
    {
        //print_task_information();
        uint64_t now = get_timestamp_us();
        tx_thread_sleep_us(100);
        printf("delay_us: %lldrn", get_timestamp_us() - now);
    }
}

这是以 posix 接口 API 建立的线程,对 posix 有兴趣的可以看 Sugar 写的《Azure RTOS ThreadX 的 posix 接口》。

时间粒度测试

图3. 时间粒度测试1

图4. 时间粒度测试2

ThreadX 据说可以在 200MHz 的 MCU 上达到亚微秒级的上下文切换,Sugar 测试的时间粒度在 150us 时比较稳定。这并不是说 ThreadX 性能不好,而是 STM32F7 定时器一开加一关大约就要 30us,所以定时精度比 30us 更小时不要开关定时器,但这次我们的设计为了应对可能发生的重入情况,必须有定时器的开关才行。

Sugar 怎么知道一开加一关要 30us 的,原因如“图5”。

图5. 时间粒度测试3

“图1”、“图2” 是用 PlantUML 画的,如果想要高清可以生成 SVG 格式的图片。有需求的发文 48 小时内可以在“关于我”页面下方加 Sugar 为好友索取 PlantUML 源文件自行生成图片,时间久了估计 Sugar 就找不到了(没有保存这些的习惯)。

0 人点赞