使用 ChibiOS 在微控制器上实现精确的短和长延迟

系统节拍如何工作

为了理解延迟如何工作,我们首先需要了解系统节拍。虽然 ChibiOS 3.x 支持称为无节拍模式的功能,但为简单起见,我们将坚持简单的周期节拍模型。

系统节拍只是一个定期中断微控制器并执行一些内核管理任务的定时器。例如,对于 1 kHz 系统节拍(systick)频率,程序流每毫秒被中断一次。当中断时,内核做的事情之一是检查当前休眠的线程是否需要被唤醒。换句话说,如果你的线程有这样的代码:

chthd_sleep_example.c
// [...]
chThdSleepMilliseconds(5);
// [...]

并且内核有 1 kHz systick 频率,内核会将你的线程设置为休眠,等待 5 个系统节拍(即 5 ms)然后唤醒

在 ChibiOS 中,chThdSleep(delay)chThdSleepMilliseconds(delay) 没有根本区别:后者定义为 chThdSleep(MS2ST(d))MS2ST() 是一个宏,基本上将毫秒数乘以 systick 频率以获得要延迟的系统节拍数(稍微复杂一些,因为需要保证值向上取整)。

就像 MS2ST() 一样,还有其他宏如用于秒的 S2ST() 或用于微秒的 US2ST()

哪些延迟值有效?

下限

由于 1 kHz 频率下内核每毫秒只检查一次延迟,显然它不能在不到一毫秒内唤醒线程。例如,

chthd_sleep_microseconds_example.c
chThdSleepMicroseconds(500); // 延迟半微秒

不会导致 500 us 的延迟,而是 1 ms 的延迟。原因是(如文档所示)US2ST() 保证值向上取整到完整系统节拍。因为有一个系统节拍。

Also,

chthd_sleep_microseconds_1001.c
chThdSleepMicroseconds(1001);

由于向上取整导致 2 ms 延迟。你可以通过将值放入 US2ST() 公式来验证。

注意如果启用了无节拍模式,即如果 CH_CFG_ST_TIMEDELTA != 0(在 chconf.h 中定义),最小延迟为 CH_CFG_ST_TIMEDELTA * CH_CFG_ST_FREQUENCY,即至少是周期节拍模式中最小延迟的两倍。有关详情请参见 chconf.h 中的文档。

上限

微控制器定时器有固定分辨率。根据微控制器(以及用于 systick 的定时器,参见 mcuconf.hchconf.h),定时器为 8 位、16 位、24 位(罕见)或 32 位宽。

这意味着定时器可以在不溢出的情况下表示 2^8-12^16-12^24-12^32-1 个不同的值。通常,可以假设 RTOS 内核代码不会支持超过定时器位数的 2 的幂减 1 的系统节拍作为延迟(有多种因素导致此情况,但主要因素是使用 16 位定时器时,2^16+5 个系统节拍的延迟无法与 5 个系统节拍的延迟区分,除非有专门设计的内核代码)。因此,更多位的定时器通常更可取(但是,你可能需要将 32 位定时器用于应用程序中的其他任务)。

注意,如果你有 32 位微控制器,你不会自动拥有 32 位定时器。许多较小的 32 位控制器如 STM32F030 只有 16 位定时器。即使你有 32 位定时器,你也需要配置 ChibiOS 来使用它。

假设 1 kHz systick 和 16 位定时器(如上所述)。那么,什么延迟(以秒为单位)对应于此定时器可达到的最大延迟?

systick_calc.c
(2^16-1) / 1000 Hz = 65.5 s

因此,如果你需要超过约 65 秒的延迟,你需要使用如下所述的替代方法。

为什么不直接增加 systick 频率?

在某种程度上,你可以通过简单地增加 systick 频率来改善最小延迟。然而,此方法会导致两个问题。

虽然数字变化很大,我建议 systick 频率设置为不高于 100 kHz 的值。在一般情况下应优先选择 1-10 kHz 左右的频率。

如何获得更短的延迟

中短延迟:轮询延迟

对于比我们上面计算的下限 systick 延迟更短但不显著低于一微秒或对于更快的微控制器约 100 ns 的精确延迟(另请参见下面的注意事项部分),我们可以使用称为轮询延迟的方法来获得我们想要的延迟。

它由三个简单步骤组成(有各种变体,我只展示一个简化版本):

ChibiOS 在 gptPolledDelay() 中包含此策略的实现

示例代码:

gpt_polled_delay.c
static const GPTConfig gpt4cfg = {
  100000, // 1 MHz 定时器时钟。
  NULL, // 无回调
  0, 0
};

gptStart(&GPTD4, &gpt4cfg);
gptPolledDelay(&GPTD4, 10); // 10 us 延迟

在这种情况下,我们使用第四个定时器,例如 STM32 上的 TIM4。你需要启用 halconf.h 中的 GPT 驱动和 mcuconf.h 中的 TIM4 才能编译此示例。gptPolledDelay() 的第二个参数 10 是要等待的节拍数。但请注意:这些不是系统节拍!在此上下文中,我们讨论的是定时器节拍。我们在 gpt4cfg 结构中将 TIM4 的定时器频率定义为 1 MHz,所以 10 个节拍等于 10 微秒。

但请注意,定时器可以运行的频率有硬件限制。通常,定时器频率是内部时钟频率(例如某些 ARM 微控制器上的 HCLK)除以整数(称为预分频器)。根据微控制器,特定定时器可能只接受特定的预分频器(例如,只有 2 的幂),内部时钟频率也有限制。

有关详情请参见你的微控制器数据手册或参考手册。

定时器中断

你可以使用定时器下溢中断(其他中断类型也是可能的),而不是使用忙等待循环(这会使你的微控制器 100% 占用,因此不仅消耗大量功率,还可能阻止其他线程运行)。一旦定时器达到零,就会调用此中断。

在中断处理程序中,你可以直接执行所需的操作或简单地唤醒你的线程。示例:

gpt_interrupt_example.c
static void gpt4cb(GPTDriver *gptp) {
  (void)gptp;
  // 在此处执行你的操作
}

static const GPTConfig gpt4cfg = {
  100000, // 1 MHz 定时器时钟。
  timerCallback, // 无回调
  0, 0
};
gptStart(&GPTD4, &gpt4cfg);
gptStartOneShotI(&GPTD3, 100); // ~100 ns 延迟,然后停止定时器

此方法的详细讨论取决于硬件,超出了本文的范围。有关更多信息,请参见你的微控制器数据手册和参考手册以及 ChibiOS 文档和示例。

注意中断可能会引入硬件和固件特定的延迟,直到中断处理程序被调用。一般可以假设基于定时器中断的延迟不如轮询定时器延迟准确和可重现,特别是在更复杂的微控制器上,但它们是。讨论确切原因超出了本文的范围。

非常短的延迟:NOP 指令序列

如果你想引入几十或几百纳秒的非常短延迟而你的定时器不支持这样的频率怎么办?此问题经常出现在位操作特定二进制协议时,例如 WS2812B1-Wire

首先,注意 8 MHz 的微控制器永远无法实现小于 125 ns 的延迟(1 除以 8 MHz),除非协议有特定的硬件支持。因此你需要检查你的微控制器是否可以实现你的延迟。例如,约 1 纳秒的延迟在微控制器中根本无法实现(因为它们运行在 < 1 GHz),除非使用专用电路。

此方法非常简单:我们将用 NOP 指令(这是一条什么都不做的指令,因此通常只占用一个时钟周期)使微控制器忙于直到特定时间段过去。

如何从 C 代码调用 ǸOP 指令取决于编译器和架构。我将在此示例中使用 ARM Cortex-Mx __NOP() 内部函数

例如,此序列引入约 5 个时钟周期的延迟

nop_sequence.c
__NOP();
__NOP();
__NOP();
__NOP();
__NOP();

为避免用数百个 __NOP() 调用堵塞你的源代码,你也可以使用循环:

nop_loop.c
for(int i = 0; i < 27; i++) {
  __NOP();
  __NOP();
  __NOP();
  __NOP();
  __NOP();
}

理论上此循环会延迟 27 * 5 = 135 个时钟周期,但请注意:它实际上延迟稍长:循环本身在汇编中引入了额外的指令(计数和检查是否满足循环条件)。

有几种方法可以确定 NOP 序列引入的确切延迟:

由于固有的可靠性,我更喜欢经验 A。如果我没有示波器,我会选择经验 B

注意此方法 — 就像轮询延迟忙等待方法一样 — 使你的 CPU 运行同时实际上没有做任何有用的事情(只是等待)。然而,这是唯一的

如何获得更长的延迟

再次,轮询延迟和定时器中断

除了将硬件定时器设置为高计数频率并等待短时间外,我们可以简单地将定时器设置为非常低的频率(例如 0.1 Hz,如果预分频器支持,见上文),为 16 位定时器获得最大定时器值 655350。此方法将我们的最大延迟从 65.5 秒(参见上面的示例)增加到超过 7.5 天

然而,这意味着(尽管抢占式调度程序超出了本文的范围)你的 CPU 将连续检查定时器长达 7 天!这对大多数应用程序不可取。

因此,对于需要精确延迟的中长延迟,建议使用定时器中断(参见上面的定时器中断部分)。就像定时器延迟有下限一样,也有上限,由系统时钟频率和预分频器范围定义。有关替代方案,请参见下面列出的其他方法。

软件预分频器

一种非常简单的长延迟方法是添加软件预分频器,即我们只需执行

我们之前已经展示

chthd_sleep_seconds_example.cpp
chThdSleepSeconds(300); //5 分钟休眠

在 1 kHz 的 16 位 systick 场景下不起作用。然而,可行的是:

chthd_sleep_loop.c
for(int i = 0; i < 5; i++) { //5 次...
    chThdSleepSeconds(60); //1 分钟休眠
}

本质上,使用此方法延迟多长时间没有上限。对于不一定需要非常精确的延迟(即多或少几毫秒是可以接受的,延迟抖动不会有影响),这是首选方法。另请参见下面的注意事项部分。对于 MCU 大部分时间空闲的固件,这是首选的延迟方法之一。

虚拟定时器

如果你的延迟在 systick 定时器允许的范围内,并且你不想使用硬件定时器机制(可能是因为你没有足够的定时器),你可以使用 ChibiOS/RT 提供的虚拟定时器(ChibiOS/NIL 不支持该功能)。

示例代码:

virtual_timer_example.c
static virtual_timer_t vt;

static void onTimer(void *param) {
  (void)param;
  // 如果我们在这里再次设置定时器,此函数
  //  将以 10 ms 间隔调用,即 100 Hz。
  chSysLockFromISR();
  chVTSetI(&vt, MS2ST(10), onTimer, NULL);
  chSysUnlockFromISR();
}

void startTimer() {
  chVTSet(&vt, MS2ST(10), onTimer, NULL);
}

主要优点是你几乎有无限的虚拟定时器。然而,此延迟类型具有与系统节拍相同的缺点。

RTC

对于非常长的延迟,特别是当功耗很重要时,你可以使用内置在较大微控制器中的 RTC(实时时钟,本质上是硬件中的闹钟和日历)(或外部 RTC)。

你可以配置大多数 RTC 使它们在例如 3 个月后唤醒微控制器。这里的主要优点不仅是长延迟,而且 RTC 基本上独立于主 MCU 运行(通常,它也有独立的电池)在单独的低功耗振荡器上(通常是 32.768 kHz 时钟晶体)。这意味着

注意事项

特别是当尝试实现精确延迟时,有几个因素可能会影响你的延迟。建立一个全面的列表几乎不可能,但

时钟源配置

ChibiOS 执行的频率计算取决于主时钟源(例如振荡器、晶体或谐振器)频率的正确配置。如果你使用内部振荡器,几乎没有什么可以做错的,因为 ChibiOS 有宏检查你的配置是否正确(但可能存在错误…)。

如果你使用外部时钟源,你需要确保设置了正确的频率。例如,如果你使用 12 MHz 振荡器但配置了 8 MHz 振荡器,所有延迟都会错误。在某些情况下,这甚至可能损坏你的硬件(如果你的 PLL 输出频率大大超过规定值,参见例如此博客文章

时钟源容差

特别是对于非常长或非常精确的延迟,时钟源的容差、温度漂移甚至老化都会影响实际延迟。内部时钟源通常是 RC 振荡器,在整个温度范围内有多个百分点的容差。

如果你需要精确的时序,可以使用外部晶体。然而,负载电容不匹配、配置错误甚至 EMI 等外部影响可能会影响精度(参见例如此关于晶体的应用笔记)。根据我的经验,使用振荡器或陶瓷谐振器是为 MCU 生成时钟的更简单方法,因为通常故障点更少。但这并不意味着这些是万无一失的或你应该在任何设计中使用它们。

讨论高精度时钟源大大超出了本文的范围 — 如果你需要 OCXO、TCXO 甚至铷参考时钟,本文肯定不够。

注意除了外部时钟源质量,内部设置如大型 MCU(如 STM32F4)上可用的扩频 PLL 下降模式(参见例如 STM32F407 数据手册中的 5.3.11 节)可能会略微影响观察到的频率。

当时钟源出现电气问题时(例如晶体电阻/电容值错误或由于冲击事件而损坏),微控制器内部的振荡器逻辑可能只能识别所有时钟周期的一部分。如果你怀疑可能是这种情况,使用微控制器的 MCO(主时钟输出)功能配合示波器检查数字时钟是否有问题。如果此功能不可用,尝试在循环中切换 GPIO 引脚,切换之间有确定性延迟。

时钟源抖动

抖动 — 短时间间隔内频率的动态变化 — 对大多数应用程序不是问题。然而,如果你需要非常精确但非常短的延迟,观察到的频率可能会略有变化。

观察到的抖动的另一个来源是扩频振荡器(也称为抖动振荡器),包括硬件时钟(如 DS1090)和软件中的,例如上述 STM32F4 PLL 的扩频功能。此 PLL 将 PLL 输出频率改变高达 2% — 这意味着你的延迟可能会变化

人们使用抖动振荡器将窄带发射的 EMI(再次,详细解释将超出本文的范围)转换为宽带 EMI。然而,如果低延迟抖动更可取,建议禁用抖动。

中断和抢占式内核

影响延迟精度的主要因素之一是微控制器(和内核)可能随时中断你的线程(例如由于 systick),从而延迟剩余代码执行。实际上,这会增加观察到的延迟 — 有时显著增加。

例如,如果你的代码在 100 ns 延迟 NOP 序列中间被中断,内核可能在接下来的十秒内执行更高优先级的线程,导致 10.00000001 s 延迟 — 比预期延迟大几个数量级。对于具有循环调度的抢占式内核

此问题对定时器中断的影响不如主动等待方法那么大(特别是因为)。然而,需要考虑中断优先级:更高优先级的中断可能会延迟更低优先级定时器的启动或在执行时中断它。即使在没有优先级中断控制器的 MCU(如 AVR)上,如果一个中断需要大量时间运行,它可能会延迟另一个中断。

有两种通用策略可以解决这些问题:

但请注意:这些解决方案非常危险,绝不能不加思考地应用:特别是对于复杂固件,你不能简单地禁用所有中断数百毫秒而不在通信甚至内核本身中造成重大问题。注意,禁用中断时,甚至内核调度程序都无法运行(只有不可屏蔽中断 — 即 NMI — 可以)。根据应用程序,这可能是期望的或不是。

关于中断优先级,高优先级中断可能会延迟其他时间关键代码区域(当然,除非它们禁用中断),但除非中断被非常频繁地调用,如果中断处理程序非常短,这很少是问题。

闪存延迟

对于高速微控制器,从闪存获取指令成为重要瓶颈。因此更复杂和非常快的内核如 STM32F4 实现了 ART 加速器等功能,执行自适应预取(有关详情请参见参考手册中的 3.5.2 节)。

如果 CPU 不能及时访问适当的指令,会引入等待状态,即它等待直到指令可用。通常,微控制器有最大 0 等待状态频率 — 低于此频率,不需要等待状态和加速器。一些微控制器(特别是像 AVR 这样简单和慢的)通常在此频率以下运行。

虽然 ART 加速器通常导致零等待状态执行,但它也可能引入非确定性,即滞后地取决于近期代码执行历史,执行特定指令块可能需要或不需要等待状态。对于允许抖动很小的非常精确延迟,ART 等功能在某些情况下可能实际上对延迟精度产生负面影响。建议仅在观察到延迟长度抖动时按具体情况检查。


Check out similar posts by category: Electronics, Embedded