07. 系统定时器
一、什么是定时器
定时器是单片机内部集成的功能,它能够通过编程进行灵活控制。单片机的定时功能依赖于内部的计数器实现,每当单片机经历一个机器周期并产生一个脉冲时,计数器就会递增。定时器的主要作用在于计时,当设定的时间到达后,它会触发中断,从而通知系统计时完成。在中断服务函数中,我们可以编写特定的程序以实现所需的功能。
定时器的主要作用包括但不限于:(1)、执行定时任务。(2)、时间测量。(3)、精确延时。(4)、PWM 信号生成。(5)、事件触发与监控。
定时器既可通过硬件实现,也可基于软件进行设计,二者各具特色,适用于不同场景:
硬件定时器,依托微控制器的内置硬件机制,通过专门的计时/计数器电路达成定时功能。其显著优势在于高精度与高可靠性,这是因为硬件定时器的工作独立于软件任务和操作系统调度,故而不受它们的影响。在追求极高定时精确度的场合,如生成 PWM 信号或进行精确时间测量时,硬件定时器无疑是最佳选择。其工作原理确保即便主 CPU 忙于其他任务,定时器也能在预设时间准确触发相应操作。
而 软件定时器,则是通过操作系统或软件库模拟实现的定时功能。这类定时器的性能受系统当前负载和任务调度策略制约,因此在精度上较硬件定时器稍逊一筹。然而,软件定时器在灵活性方面更胜一筹,允许创建大量定时器,适用于对时间控制要求不那么严格的场景。
软件定时器在某些情况下可能面临定时精度问题,特别是在系统负载较重或存在众多高优先级任务时。
二、系统定时器框架
系统定时器内置两个计数器 UNIT0 和 UNIT1 以及三个比较器 COMP0、COMP1、COMP2。比较器用于监控计数器的计数值是否达到报警值。
【1】、计数器
UNIT0、UNIT1 均为 ESP32 S3 的系统定时器内置的 52 位计数器。计数器使用 XTAL_CLK 作为时钟源(40MHz)。XTAL_CLK 经分频后,在一个计数周期生成频率为 fXTAL_CLK/3 的时钟信号,然后在另一个计数周期生成频率为 fXTAL_CLK/2 的时钟信号。因此,计数器使用的时钟CNT_CLK,其实际平均频率为 fXTAL_CLK/2.5,即 16MHz。每个 CNT_CLK 时钟周期,计数递增 1/16µs,即 16 个周期递增 1µs。
用户可以通过配置寄存器 SYSTIMER_CONF_REG 中下面三个位来控制计数器 UNITn,这三个位分别是:
SYSTIMER_TIMER_UNITn_WORK_EN // 来启动计数器
SYSTIMER_TIMER_UNITn_CORE0_STALL_EN // 当CPU0暂停工作时,计数器会停止计数;一旦CPU0恢复,计数器会继续计数
SYSTIMER_TIMER_UNITn_CORE1_STALL_EN // 当CPU1暂停工作时,计数器会停止计数;一旦CPU1恢复,计数器会继续计数
当计数器 UNITn 工作时,计数值递增;停止时则保持不变。初始计数值的低 32 位和高 20 位从寄存器 SYSTIMER_TIMER_UNITn_LOAD_LO
和 SYSTIMER_TIMER_UNITn_LOAD_HI
中加载。写入 1
到 SYSTIMER_TIMER_UNITn_LOAD
会 触发重新加载事件,当前计数值立即更新;若 UNITn 正在工作,计数器将从新加载值继续计数。
此外,若向 SYSTIMER_TIMER_UNITn_UPDATE
写入 1
会 触发更新事件,则当前计数值的低 32 位和高 20 位将锁定到寄存器 SYSTIMER_TIMER_UNITn_VALUE_LO
和 SYSTIMER_TIMER_UNITn_VALUE_HI
中,并将 SYSTIMER_TIMER_UNITn_VALUE_VALID
置为有效。值在下一次更新事件之前保持不变。
【2】、比较器
COMP0、COMP1、COMP2 均为 ESP32 S3 系统定时器内置的 52 位比较器。比较器同样使用 XTAL_CLK 作为时钟源(40MHz)。计数器生成的计时值实时传递到比较器。
在上述过程中用到一个计数器(Timer Countern)和一个比较器(Timer Comparatorx),比较器将根据比较结果,生成报警中断。
【3】、触发中断信号
当计数器的值达到比较器设置的目标值时,比较器会生成一个 Timer Interrupt 警报信号,并将该信号发送至 CPU 中断矩阵(CPU Interrupt Matrix)。中断矩阵负责处理中断信号,并触发相应的中断服务程序(ISR)。
每个 COMPx 的工作模式可通过配置寄存器 SYSTIMER_TARGETx_PERIOD_MODE
来选择,主要包括 周期模式 和 单次模式:
- 周期模式:警报周期(δt)由寄存器
SYSTIMER_TARGETx_PERIOD
提供。假设当前计数值为 t1,当计数值达到(t1 + δt)时,会生成一个警报中断;当计数值再次达到(t1 + 2*δt)时,另一个警报中断将生成,从而实现周期性警报。 - 单次模式:警报值(t)的低 32 位和高 20 位由
SYSTIMER_TIMER_TARGETx_LO
和SYSTIMER_TIMER_TARGETx_HI
提供。假设当前计数值为 t2(t2 ≤ t),当计数值 t2 达到警报值(t)时,将生成一次警报中断。与周期模式不同,单次模式仅生成一次警报中断。
此外,SYSTIMER_TARGETx_TIMER_UNIT_SEL
用于选择用于比较生成警报的计数值来源:
- 1 表示使用来自 UNIT1 的计数值。
- 0 表示使用来自 UNIT0 的计数值(。
设置 SYSTIMER_TARGETx_WORK_EN
可启用 COMPx 进行计数值比较。在单次模式下,COMPx 将与警报值(t)进行比较;在周期模式下,则与起始值加上 n 倍警报周期(t1 + n * δt)进行比较。
警报生成的条件是,当计数值等于警报值(t)时(在单次模式)或等于起始值加上 n 倍警报周期(在周期模式)时,警报将被触发。如果寄存器中设置的警报值(t)小于当前计数值,即目标已过,当当前计数值大于警报值(t)并在范围(0 ~ 251 – 1)内时,也会立即生成警报中断。
无论是在目标模式还是周期模式,实际警报值的低 32 位和高 20 位始终可以从
SYSTIMER_TARGETx_LO_RO
和SYSTIMER_TARGETx_HI_RO
读取。
三、系统定时器常用函数
ESP-IDF 提供了一套 API 来配置高精度定时器。要使用此功能,我们需要在 CMakeLists.txt 文件中导入 esp_timer
依赖库,然后还需要导入必要的头文件:
# 注册组件到构建系统的函数
idf_component_register(
# 依赖库的路径
REQUIRES esp_timer
)
#include "esp_timer.h"
3.1、创建一个定时器
我们可以使用 esp_timer_create()
函数来 创建一个定时器,其函数原型如下:
/**
* @brief 创建一个定时器
*
* @param args 指向arg外设结构体的指针
* @param out_handle 指定使能的中断
* @return esp_err_t ESP_OK 表示创建成功
* ESP_ERR_INVALID_ARG 表示某些create_args无效
* ESP_ERR_INVALID_STAT 表示esp_timer库尚未初始化
* ESP_ERR_NO_MEM 表示内存分配失败
*/
esp_err_t esp_timer_create(const esp_timer_create_args_t* args, esp_timer_handle_t* out_handle);
成员 args
是 指向 esp_timer_create_args_t 结构体的成员变量的指针,该结构体的成员变量描述如下:
typedef struct
{
esp_timer_cb_t callback; // 定时器到期时执行的回调函数
void * arg; // 传递给回调的参数
esp_timer_dispatch_t dispatch_method; // 从任务或ISR调度回调的方法,如果未指定,将使用esp_timer任务
const char * name; // 定时器名称,在esp_timer_dump()函数中使用
bool skip_unhandled_events; // 设为跳过在轻睡眠状态下的周期性定时器未处理事件
} esp_timer_create_args_t;
回调函数的格式如下:
void (*esp_timer_cb_t)(void* arg);
3.2、一次触发
我们可以使用 esp_timer_start_once()
函数 单次启动系统定时器,其函数原型如下:
/**
* @brief 单次启动系统定时器
*
* @param timer 使用esp_timer_create()创建的定时器句柄
* @param period_us 计时器周期,以微秒为单位
* @return esp_err_t ESP_OK表示开启定时器成功
* ESP_ERR_INVALID_ARG 表示句柄无效
* ESP_ERR_INVALID_STATE 表示定时器已经在运行
*/
esp_err_t IRAM_ATTR esp_timer_start_once(esp_timer_handle_t timer, uint64_t timeout_us);
3.3、周期触发
我们可以使用 esp_timer_start_periodic()
函数 周期启动系统定时器,其函数原型如下:
/**
* @brief 周期启动系统定时器
*
* @param timer 使用esp_timer_create()创建的定时器句柄
* @param period_us 计时器周期,以微秒为单位
* @return esp_err_t ESP_OK表示开启定时器成功
* ESP_ERR_INVALID_ARG 表示句柄无效
* ESP_ERR_INVALID_STATE 表示定时器已经在运行
*/
esp_err_t IRAM_ATTR esp_timer_start_periodic(esp_timer_handle_t timer, uint64_t period_us);
3.4、停止定时器
我们可以使用 esp_timer_stop()
函数 停止运行的定时器,其函数原型如下:
/**
* @brief 停止定时器
*
* @param timer 使用esp_timer_create()创建的定时器句柄
* @return esp_err_t ESP_OK 表示停止成功
* ESP_ERR_INVALID_STATE 表示定时器未在运行
*/
esp_err_t IRAM_ATTR esp_timer_stop(esp_timer_handle_t timer);
3.5、重新启动定时器
我们可以使用 esp_timer_restart()
函数 重新启动系统定时器,其函数原型如下:
/**
* @brief 重新启动系统定时器
*
* @param timer 使用esp_timer_create创建的定时器句柄
* @param period_us 计时器周期,以微秒为单位
* @return esp_err_t ESP_OK表示开启定时器成功,其它表示开启失败
*/
esp_err_t IRAM_ATTR esp_timer_restart(esp_timer_handle_t timer, uint64_t timeout_us);
3.6、删除一个定时器
我们可以使用 esp_timer_delete()
函数 删除一个 esp_timer 实例,其函数原型如下:
/**
* @brief 删除一个定时器
*
* @param timer 使用esp_timer_create()创建的定时器句柄
* @return esp_err_t ESP_OK 表示删除成功
* ESP_ERR_INVALID_STATE 表示定时器仍在运行
*/
esp_err_t esp_timer_delete(esp_timer_handle_t timer);
四、实验例程
这里,我们在【components】文件夹下的【peripheral】文件夹下的【inc】文件夹(用来存放头文件)新建一个 bsp_timer.h
文件,在【components】文件夹下的【peripheral】文件夹下的【src】文件夹(用来存放源文件)新建一个 bsp_timer.c
文件。
#ifndef __BSP_TIMER_H__
#define __BSP_TIMER_H__
#include "esp_timer.h"
#include "driver/gpio.h"
extern esp_timer_handle_t g_esp_timer_handle;
void bsp_esp_timer_init(esp_timer_handle_t *handle);
#endif // !__BSP_TIMER_H__
#include "bsp_timer.h"
static void esp_timer_callback(void *arg);
esp_timer_handle_t g_esp_timer_handle;
/**
* @brief 系统定时器初始化
*
* @param handle 系统定时器句柄
*/
void bsp_esp_timer_init(esp_timer_handle_t *handle)
{
esp_timer_create_args_t esp_timer_create_args = {0};
esp_timer_create_args.callback = esp_timer_callback;
esp_timer_create_args.arg = NULL;
esp_timer_create(&esp_timer_create_args, handle);
}
/**
* @brief 系统定时器回调函数
*
* @param arg 传入系统定时器回调函数的参数
*/
static void esp_timer_callback(void *arg)
{
gpio_set_level(GPIO_NUM_0, !gpio_get_level(GPIO_NUM_0));
}
然后,我们修改【components】文件夹下【peripheral】文件夹下的 CMakeLists.txt
文件。
# 源文件路径
set(src_dirs src)
# 头文件路径
set(include_dirs inc)
# 设置依赖库
set(requires
driver
esp_timer
)
# 注册组件到构建系统的函数
idf_component_register(
# 源文件路径
SRC_DIRS ${src_dirs}
# 自定义头文件的路径
INCLUDE_DIRS ${include_dirs}
# 依赖库的路径
REQUIRES ${requires}
)
# 设置特定组件编译选项的函数
# -ffast-math: 允许编译器进行某些可能减少数学运算精度的优化,以提高性能。
# -O3: 这是一个优化级别选项,指示编译器尽可能地进行高级优化以生成更高效的代码。
# -Wno-error=format: 这将编译器关于格式字符串不匹配的警告从错误降级为警告。
# -Wno-format: 这将完全禁用关于格式字符串的警告。
component_compile_options(-ffast-math -O3 -Wno-error=format=-Wno-format)
修改【main】文件夹下的 main.c
文件。
#include "freertos/FreeRTOS.h"
#include "bsp_timer.h"
#include "led.h"
// app_main()函数是ESP32的入口函数,它是FreRTOS的一个任务,任务优先级是1
// main()函数是C语言入口函数,它会在编译过程中插入到二进制文件中的
void app_main(void)
{
led_init(GPIO_NUM_0);
bsp_esp_timer_init(&g_esp_timer_handle);
esp_timer_start_periodic(g_esp_timer_handle, 1000000);
while (1)
{
// 将一个任务延迟给定的滴答数,IDF中提供pdMS_TO_TICKS可以将指定的ms转换为对应的tick数
vTaskDelay(pdMS_TO_TICKS(10));
}
}