freertos学习笔记12--个人自用-第16章 软件定时器(software timer)

软件定时器 (Software Timer) 是能够处理很多"延时执行"或"周期性执行"的任务,而不需要占用宝贵的硬件资源。

补充知识点学习:

回调函数:

"回调函数" (Callback Function) 就是你写好一个函数,但是你不用去调用它,而是把这个函数像"电话号码"一样留给系统,让系统在"特定事情发生"的时候回头去调用它。

目录

[一:. 什么是软件定时器?](#一:. 什么是软件定时器?)

[2. 软件定时器 vs 硬件定时器](#2. 软件定时器 vs 硬件定时器)

二:软件定时器的特性

[1. 时基依赖性 (Tick Dependency)](#1. 时基依赖性 (Tick Dependency))

[2. 两种核心模式](#2. 两种核心模式)

[3. 执行上下文:守护任务 (Daemon Task)](#3. 执行上下文:守护任务 (Daemon Task))

3.1概念

[3.2 为什么需要守护任务?](#3.2 为什么需要守护任务?)

[3.3. 它是如何工作的?(核心机制)](#3.3. 它是如何工作的?(核心机制))

3.4定时器回调函数决不能阻塞!

总结

3.5守护任务的优先级和队列长度

[1. 守护任务的优先级 (configTIMER_TASK_PRIORITY)](#1. 守护任务的优先级 (configTIMER_TASK_PRIORITY))

[2. 定时器命令队列长度 (configTIMER_QUEUE_LENGTH)](#2. 定时器命令队列长度 (configTIMER_QUEUE_LENGTH))

3.为什么队列长度很重要?

4.总结:调度示意图

4.命令队列机制 (Command Queue)

[5. 两种状态:休眠与运行 (Dormant vs Running)](#5. 两种状态:休眠与运行 (Dormant vs Running))

[6. 高效的各种 Reset 机制](#6. 高效的各种 Reset 机制)

总结:初学者避坑指南

三:软件定时器的函数

[1. 创建定时器 (xTimerCreate)](#1. 创建定时器 (xTimerCreate))

[2. 启动定时器 (xTimerStart)](#2. 启动定时器 (xTimerStart))

[3. 停止定时器 (xTimerStop)](#3. 停止定时器 (xTimerStop))

[4. 复位定时器 (xTimerReset)](#4. 复位定时器 (xTimerReset))

[5. 修改周期 (xTimerChangePeriod)](#5. 修改周期 (xTimerChangePeriod))

6.删除

[7. 紧急通道:中断版本 (FromISR)](#7. 紧急通道:中断版本 (FromISR))

四:总结代码示例

4.1常规

4.2中断版本

["按键触发 LED 延时熄灭"。](#“按键触发 LED 延时熄灭”。)

[1. 第一步:准备工作(句柄与回调函数)](#1. 第一步:准备工作(句柄与回调函数))

[2. 第二步:创建定时器(在任务或 main 中)](#2. 第二步:创建定时器(在任务或 main 中))

[3. 第三步:中断服务函数 (ISR) ------ 核心重点](#3. 第三步:中断服务函数 (ISR) —— 核心重点)

逐行深度解析(初学者必读)


一:. 什么是软件定时器?

在单片机里,我们通常有两种定时器:

  • 硬件定时器 (Hardware Timer): 这是芯片里面实实在在的电路(比如 STM32 的 TIM1, TIM2)。它们极其精准,通常用于产生 PWM 波形或者极短时间的精确计时(纳秒/微秒级)。缺点是数量有限。

  • 软件定时器 (Software Timer): 这是 FreeRTOS 用代码模拟出来的定时器。它基于系统的 "Tick"(心跳)来计时。优点是只要内存够,你想创建多少个都可以。

    通俗比喻:

  • 硬件定时器 就像是一个专业的秒表,适合用来测百米赛跑。

  • 软件定时器 就像是日常的闹钟,适合用来提醒你"10分钟后关灯"或者"每隔1秒闪烁一下LED"。

想象一下,你(作为 CPU)正在看书(执行主程序)。你需要做两件事:

  1. 30分钟后去关火(单次任务)。

  2. 每隔 1 小时喝一次水(周期任务)。

你不会一直盯着墙上的钟表看(那是轮询,非常浪费精力)。相反,你在手机上设了两个闹钟。闹钟响了,你就停下看书,去关火或喝水。


2. 软件定时器 vs 硬件定时器

初学者容易混淆这两者,它们的区别很重要:

特性 硬件定时器 (TIM) 软件定时器 (FreeRTOS Timer)
资源来源 单片机内部的硬件外设 基于系统滴答 (SysTick) 的软件模拟
精度 极高 (微秒/纳秒级) 一般 (依赖于系统节拍,通常是毫秒级)
执行环境 中断服务函数 (ISR) 中 RTOS 的守护任务 (Daemon Task) 中
用途 电机控制、高频采样 按键消抖、LED闪烁、低频周期任务
数量限制 只有几个 (如 TIM1-TIM8) 理论上无限 (受内存限制)

二:软件定时器的特性


1. 时基依赖性 (Tick Dependency)

这是软件定时器精度的根本来源。

  • 特性: 软件定时器不是靠晶振直接计数的,它是靠 系统节拍 (SysTick) 来计数的。

  • 意味着什么:

    • 它的精度取决于你在 FreeRTOSConfig.h 里设置的 configTICK_RATE_HZ(通常是 1000Hz,即 1ms)。

    • 限制:无法创建一个 0.5ms 或者 100us 的软件定时器。最小单位就是 1 个 Tick。

    • 抖动: 如果系统极其繁忙,导致 SysTick 处理稍有延迟,软件定时器的触发也可能产生极其微小的抖动(通常可忽略)。

2. 两种核心模式

就像手机闹钟一样,FreeRTOS 的定时器也有两种最常用的模式 :

  • 单次定时器 (One-shot timers):

    • 特点: "响"一次就结束了。

    • 场景: 比如设备启动 5秒后关闭欢迎界面。

    • 状态变化: 启动 -> 运行(Running) -> 时间到执行回调 -> 变为冬眠(Dormant)不再运行。

  • 自动加载定时器 (Auto-reload timers):

    • 特点: "响"完之后自动重置,过一段时间再"响",无限循环。

    • 场景: 比如每隔 1秒闪烁一次 LED 灯,或者每隔 1小时保存一次数据。

    • 状态变化: 启动 -> 运行 -> 时间到执行回调 -> 自动重新启动 -> 继续运行。

3. 执行上下文:守护任务 (Daemon Task)

这是最重要、也是最容易出错的特性。

3.1概念

  • 特性: 当定时器时间到了,你的回调函数不是 在中断里跑,也不是 在你的 main 函数或创建它的任务里跑,而是跑在一个叫 RTOS 守护任务 (Daemon Task/Timer Service Task) 的专用任务里。

  • FreeRTOS 为了安全和效率,专门创建了一个后台任务,叫做 "守护任务" (Daemon Task)

  • 守护任务: 所有的软件定时器回调函数,都是在这个任务里"排队"执行的。

  • 命令队列: 当你调用 xTimerStart() 启动定时器时,其实是往一个 "定时器命令队列" 里发了一条命令。守护任务从队列里取出命令,去处理定时器的启动、停止或复位 。

注意: 因为所有定时器都共用这个"守护任务",所以你的定时器回调函数决不能写死循环 ,也不能调用会导致阻塞的函数 (比如 vTaskDelay),否则会卡死其他所有的定时器 。

3.2 为什么需要守护任务?

你可能会问:"为什么不直接在系统时钟中断(Tick Interrupt)里执行定时器回调函数呢?"

  • 原因: 硬件中断(ISR)必须非常快,不能耗时太久。如果你的定时器回调函数里有一些耗时的操作(比如打印日志、计算数据),放在中断里执行会严重影响系统的实时性,甚至导致系统崩溃 。

  • 解决方案: FreeRTOS 专门创建了一个任务(即守护任务),把这些"杂活"从中断里剥离出来,放在这个任务里慢慢做。

3.3. 它是如何工作的?(核心机制)

你可以把"守护任务"想象成一个**"定时器管家"**。它主要做两件事:

  1. 处理命令: 当你在其他任务中调用 xTimerStart(启动)、xTimerStop(停止)等函数时,本质上是在给这个"管家"发指令 。

  2. 执行回调: 当定时器时间到了,"管家"会负责去调用你写好的回调函数 。

交互流程:定时器命令队列 (Timer Command Queue)

当你调用 API 函数时,其实是通过一个队列与守护任务通信的。

  • 你的任务: 调用 xTimerStart() -> 发送命令到"定时器命令队列" -> 任务继续运行(或者因队列满而阻塞)

  • 守护任务: 从"定时器命令队列"取出命令 -> 真正地去启动定时器 -> 如果时间到了,执行回调函数 。

    关键点: 这就是为什么 xTimerStart 函数里有一个 xTicksToWait 参数。这个参数不是等待定时器启动的时间,而是等待命令写入队列的时间。如果队列满了,你的任务需要等待"管家"把队列里的旧命令处理完,腾出空间 。

3.4定时器回调函数决不能阻塞!

  • 绝对禁止: 调用 vTaskDelay()

  • 绝对禁止: 调用会阻塞的 xQueueReceive(除非等待时间设为0)。

  • 后果: 如果你在回调函数里"睡"了 1 秒,那么守护任务就会停工 1 秒。在这 1 秒内,系统中所有其他的软件定时器都无法被触发,命令队列也无法处理,整个定时器系统就会"卡死"。

总结

  1. 身份: 它是 FreeRTOS 自动创建的一个后台任务,专门管理软件定时器。

  2. 通信: 我们通过"命令队列"发指令指挥它干活。

  3. 禁忌: 它的回调函数里千万不能有阻塞代码

3.5守护任务的优先级和队列长度

在 FreeRTOS 中,守护任务(Daemon Task,旧称 Timer Server)虽然负责处理所有的软件定时器,但它本质上仍然是一个普通的 FreeRTOS 任务 。这意味着它的运行完全遵循 FreeRTOS 的标准调度规则:只有当它是就绪态中优先级最高的任务时,它才会运行

守护任务有两个核心配置参数决定了它的行为表现:优先级队列长度

1. 守护任务的优先级 (configTIMER_TASK_PRIORITY)

守护任务的优先级在 FreeRTOSConfig.h 中通过 configTIMER_TASK_PRIORITY 进行配置 。这个优先级的设置直接决定了定时器命令处理的"实时性"。

文档中使用了两个生动的例子来说明优先级的影响:

情况 A:守护任务优先级 低于 当前用户任务

这是"你忙完了我再做"的模式。

  1. 发送命令: 你的任务(Task1)调用 xTimerStart() 启动定时器。

  2. 入队: "启动"命令被放入命令队列,守护任务从阻塞态变为就绪态 。

  3. 延迟执行: 但因为守护任务优先级 ,它抢不过 Task1,所以它只能在就绪列表中排队。Task1 继续运行,直到它自己阻塞(比如调用 vTaskDelay)或者时间片用完 4。

  4. 最终处理: 只有等 Task1 让出 CPU,守护任务才得以运行,从队列取出命令并真正启动定时器

  5. 后果: 定时器的实际启动时间点(tX)会比你调用函数的时刻晚,导致计时存在误差。

情况 B:守护任务优先级 高于 当前用户任务

这是"插队立刻做"的模式。

  1. 发送命令: 你的任务(Task1)调用 xTimerStart()

  2. 抢占: 命令一入队,高优先级的守护任务立刻就绪,并抢占(Preempt)了 Task1 的运行权 7。

  3. 立即处理: 守护任务马上处理命令,启动定时器。处理完后,它重新阻塞,Task1 才恢复运行 。

  4. 后果: 定时器几乎在你调用 API 的瞬间就启动了,计时非常精准。

最佳实践: 为了保证定时器的准时性,通常建议将 configTIMER_TASK_PRIORITY 设置得相对较高。

2. 定时器命令队列长度 (configTIMER_QUEUE_LENGTH)

守护任务不只是盯着时间看,它还要处理来自其他任务的"指令"(如启动、停止、复位定时器)。这些指令是通过一个队列 传送的,这个队列的深度由 configTIMER_QUEUE_LENGTH 定义。

3.为什么队列长度很重要?

当你调用 xTimerStart(xTimer, xTicksToWait) 时,实际上是在往这个队列里写数据。

  • 如果队列没满: 命令写入成功,函数返回 pdPASS

  • 如果队列满了:

    • 这说明守护任务来不及处理堆积的命令(可能是因为守护任务优先级太低,或者瞬间爆发了太多定时器操作)。

    • 此时,你的任务会进入阻塞状态 ,等待队列腾出空间。等待的时间由参数 xTicksToWait 决定 。

    • 如果超时还没空间,函数返回 pdFAIL,定时器启动失败。

4.总结:调度示意图

守护任务的工作就是不断在"处理命令"和"执行回调"之间循环:

  1. 处理命令: 从队列里取出 start, stop 等命令并执行。

  2. 执行回调: 检查是否有定时器超时,如果有,执行其回调函数 。

配置项 建议 影响
优先级 设为较高 决定了启动/停止命令的响应速度,以及回调函数是否准时执行。
队列长度 根据业务量 决定了短时间内能并发处理多少个定时器操作命令。设太小会导致 API 调用阻塞甚至失败。

4.命令队列机制 (Command Queue)

当你调用 xTimerStart()xTimerStop() 时,其实并不是立即修改定时器的状态。

  • 特性: 这些函数其实是向一个 "定时器命令队列" 发送了一条消息(命令)。

  • 流程:

    1. 你的任务调用 xTimerStart()

    2. 这个命令被扔进队列。

    3. 守护任务从队列里取出命令。

    4. 守护任务实际去修改定时器的链表和状态。

  • 意味着什么:

    • 阻塞时间: xTimerStart(handle, 100) 中的 100 不是定时器的延时,而是如果队列满了,你的任务愿意等多久把命令塞进去。

    • 异步性: 严格来说,启动操作有一点点极微小的滞后(直到守护任务读取命令),但在毫秒级应用中完全感觉不到。

5. 两种状态:休眠与运行 (Dormant vs Running)

不像硬件定时器那样始终通电就在跑,软件定时器非常节省资源。

  • 休眠态 (Dormant):

    • 当你创建了定时器 (xTimerCreate) 但还没启动,或者单次定时器跑完了一次。

    • 此时它仅仅占用一点内存来保存结构体,完全不占用 CPU 时间,系统调度器根本不理它。

  • 运行态 (Running):

    • 调用 xTimerStart 后。

    • 此时它被挂在了一个"激活链表"上,系统每次 Tick 中断都会检查它是否到期。

6. 高效的各种 Reset 机制

软件定时器不仅仅是"倒计时",它非常灵活。

  • 特性: 如果一个定时器已经在跑(比如剩 2秒 触发),你再次调用 xTimerReset(),它会重新装载初始值(变回 10秒)。

  • 经典应用场景: "看门狗"或"背光控制"

    • 例如:手机背光设置为"10秒无操作熄灭"。

    • 用户按一下键,你就调一次 xTimerReset

    • 只要用户一直按键,定时器就一直被复位,永远到不了 0,背光就一直亮。

    • 一旦用户停手,10秒后定时器到期 -> 回调函数 -> 关灯。


总结:初学者避坑指南

基于以上特性,送你三个编写代码的建议:

  1. 回调函数要短: 别在里面 delay,因为你阻塞的是"守护任务",会卡死所有其他定时器。

  2. 优先级要注意: 确保 configTIMER_TASK_PRIORITY 设置得够高,否则高负载下定时器可能不准。

  3. 栈空间要够: 如果回调函数逻辑稍微复杂,记得去 FreeRTOSConfig.hconfigTIMER_TASK_STACK_DEPTH 调大一点。

三:软件定时器的函数

你要时刻记住一个核心机制:调用这些函数,本质上都是在给那个"守护任务(Daemon Task)"发短信(发命令)。

我们将这些函数分为四类:造闹钟、用闹钟、改闹钟、在中断里用闹钟

没问题。作为初学者,看到函数原型(Prototype)能让你更清楚需要传什么参数、会得到什么返回值。

我将按照你最常用的顺序,结合文档中的定义,为你逐一讲解这些核心函数。请记住,除了创建函数,其他控制函数(启动、停止等)本质上都是在向定时器命令队列发送命令 1。


1. 创建定时器 (xTimerCreate)

这是"造闹钟"的步骤。你必须先创建,得到句柄(Handle),才能控制它。

函数原型:

cs 复制代码
TimerHandle_t xTimerCreate( 
    const char * const pcTimerName,         // 1. 定时器名字(字符串)
    const TickType_t xTimerPeriodInTicks,   // 2. 周期(以Tick为单位)
    const UBaseType_t uxAutoReload,         // 3. 模式:自动重载还是单次?
    void * const pvTimerID,                 // 4. 定时器ID(标签)
    TimerCallbackFunction_t pxCallbackFunction // 5. 回调函数
);

初学者参数指南:

  • pcTimerName : 随便起个名字,比如 "LED_Timer"。主要用于调试,FreeRTOS 内部基本不用。

  • xTimerPeriodInTicks : 定时时长。如果你想定 1000ms,建议使用 pdMS_TO_TICKS(1000) 宏来转换,这样更通用。

  • uxAutoReload:

    • pdTRUE: 自动重载(周期性,一直响)。

    • pdFALSE: 单次(只响一次就进入冬眠)。

  • pvTimerID : 给定时器贴个数字标签,比如 (void *)1。如果回调函数是专用的,这里填 NULL 就行。

  • pxCallbackFunction: 时间到了要执行哪个函数?填函数名。

定时器ID

定时器的结构体如下,里面有一项 pvTimerID ,它就是定时器ID:

怎么使用定时器ID,完全由程序来决定:

  • 可以用来标记定时器,表示自己是什么定时器
  • 可以用来保存参数,给回调函数使用

它的初始值在创建定时器时由 xTimerCreate() 这类函数传入,后续可以使用这些函数来操作:

  • 更新ID:使用 vTimerSetTimerID() 函数
  • 查询ID:查询 pvTimerGetTimerID() 函数

这两个函数不涉及命令队列,它们是直接操作定时器结构体。

函数原型如下:

cs 复制代码
/* 获得定时器的ID
 * xTimer: 哪个定时器
 * 返回值: 定时器的ID
 */
void *pvTimerGetTimerID( TimerHandle_t xTimer );

/* 设置定时器的ID
 * xTimer: 哪个定时器
 * pvNewID: 新ID
 * 返回值: 无
 */
void vTimerSetTimerID( TimerHandle_t xTimer, void *pvNewID );

提问: pvTimerID 就是一个身份证号码便签纸 。你可以往这个参数里塞任何你想要的数据(通常是一个整数)。


2. 启动定时器 (xTimerStart)

这是"装电池"的步骤。如果定时器已经在运行,调用它等同于复位(Reset),即重新开始计时。

函数原型:

cs 复制代码
BaseType_t xTimerStart( 
    TimerHandle_t xTimer,       // 1. 定时器句柄
    TickType_t xTicksToWait     // 2. 等待命令写入队列的时间
);

初学者参数指南:

  • xTimer : xTimerCreate 返回的那个句柄。

  • xTicksToWait : (重点)不是定时器的延时时间!

    • 这是指:如果后台的"命令队列"满了,你的任务愿意等多久让它腾出空间?

    • 0: 队列满了我就立刻返回失败,不等了。

    • portMAX_DELAY: 死等,直到命令成功发出去。

  • 返回值 : pdPASS 表示命令发送成功;pdFAIL 表示队列满,发送失败。


3. 停止定时器 (xTimerStop)

这是"拔电池"的步骤。定时器进入冬眠状态,不再计数。

函数原型:

cs 复制代码
BaseType_t xTimerStop( 
    TimerHandle_t xTimer,       // 1. 定时器句柄
    TickType_t xTicksToWait     // 2. 等待时间
);

初学者参数指南:

  • 参数含义与 xTimerStart 完全一致。

提问


4. 复位定时器 (xTimerReset)

这是"重置倒计时"的步骤。常用于看门狗或背光控制(比如每次按键都把灭屏倒计时重置回 10秒)。

函数原型:

cs 复制代码
BaseType_t xTimerReset( 
    TimerHandle_t xTimer,       // 1. 定时器句柄
    TickType_t xTicksToWait     // 2. 等待时间
);

初学者参数指南:

  • 如果定时器是冬眠的,这个函数会让它启动(变身 xTimerStart)。

  • 如果定时器正在运行,这个函数会让它重新开始倒数。


5. 修改周期 (xTimerChangePeriod)

这是"改闹钟时间"的步骤。注意,这个函数会自动启动 定时器 。("自动启动"的意思是:你只要改了它的时间,无论它之前是死是活,它都会立马复活并开始按新时间倒数。)

函数原型:

cs 复制代码
BaseType_t xTimerChangePeriod( 
    TimerHandle_t xTimer,       // 1. 定时器句柄
    TickType_t xNewPeriod,      // 2. 新的周期
    TickType_t xTicksToWait     // 3. 等待时间
);

初学者参数指南:

  • xNewPeriod : 新的时长,同样建议用 pdMS_TO_TICKS(ms)

  • 不管定时器原来是跑着还是停着,调用这个后,它都会按照新周期立马开始跑。


6.删除

动态分配的定时器,不再需要时可以删除掉以回收内存。删除函数原型如下:

cs 复制代码
/* 删除定时器
 * xTimer: 要删除哪个定时器
 * xTicksToWait: 超时时间
 * 返回值: pdFAIL表示"删除命令"在xTicksToWait个Tick内无法写入队列
 *        pdPASS表示成功
*/
BaseType_t xTimerDelete( TimerHandle_t xTimer, TickType_t xTicksToWait );

定时器的很多API函数,都是通过发送"命令"到命令队列,由守护任务来实现。

如果队列满了,"命令"就无法即刻写入队列。我们可以指定一个超时时间 xTicksToWait ,等待一会。


7. 紧急通道:中断版本 (FromISR)

普通版本(如 xTimerStart)和中断版本(如 xTimerStartFromISR)最大的区别在于参数:

  • 普通版:xTicksToWait 参数。意味着如果命令队列满了,我可以**阻塞(睡觉)**一会,等有空位。

  • 中断版:

    没有 xTicksToWait 中断不能阻塞,如果队列满了,命令发不出去就是发不出去,函数会立刻返回失败(pdFAIL)。

  • 多了 pxHigherPriorityTaskWoken 这是一个用于通知系统是否需要进行"任务切换"的参数。

(1) 启动/复位/停止函数

cs 复制代码
/* 启动定时器 (ISR版本) */
BaseType_t xTimerStartFromISR(
    TimerHandle_t xTimer, 
    BaseType_t *pxHigherPriorityTaskWoken 
);

/* 停止定时器 (ISR版本) */
BaseType_t xTimerStopFromISR(
    TimerHandle_t xTimer, 
    BaseType_t *pxHigherPriorityTaskWoken 
);

/* 复位定时器 (ISR版本) */
BaseType_t xTimerResetFromISR(
    TimerHandle_t xTimer, 
    BaseType_t *pxHigherPriorityTaskWoken 
);

参数讲解:

  • xTimer

    • 你想要操作的那个定时器的句柄(Handle)。
  • pxHigherPriorityTaskWoken (重要)

    • 这是一个指针 ,指向一个 BaseType_t 类型的变量。

    • 作用: 当你在中断里发送命令(比如"启动定时器")给守护任务(Daemon Task)时,如果守护任务的优先级高于当前被中断打断的任务,那么守护任务应该立刻执行。

    • 结果: 如果需要切换任务,函数会将这个变量的值改为 pdTRUE。你需要根据这个值在中断结束时进行任务切换。

  • 返回值

    • pdPASS:成功。命令已经成功发送到命令队列。

    • pdFAIL:失败。命令队列已满,命令没发出去(因为中断不能等,所以直接失败)。

(2) 修改周期函数

这个函数多了一个参数,在文档 中有描述。

函数原型:

cs 复制代码
/* 修改定时器周期 (ISR版本) */
BaseType_t xTimerChangePeriodFromISR( 
    TimerHandle_t xTimer, 
    TickType_t xNewPeriod, 
    BaseType_t *pxHigherPriorityTaskWoken 
);

参数讲解:

  • xNewPeriod:新的周期时间(单位是 Tick)。

  • 其他参数与上面一致。

  • 注意: 调用这个函数也会自动启动定时器(如果它原本是停止的)。

初学者参数指南:

  • 没有 xTicksToWait: 中断里不能等,队列满了就直接返回失败。

  • pxHigherPriorityTaskWoken:

    • 这是一个"输出参数"。你定义一个变量传进去。

    • 如果函数执行后这个变量变成了 pdTRUE,说明守护任务(处理定时器的任务)优先级很高,急着要运行。

    • 你需要在中断结束前调用 portYIELD_FROM_ISR() 来切换任务。


四:总结代码示例

4.1常规

这是一个典型的在任务中创建并启动定时器的代码片段:

复制代码
/* 1. 回调函数 */
void LED_Callback(TimerHandle_t xTimer) {
    printf("时间到了!\n");
}

/* 2. 任务函数 */
void AppTask(void) {
    TimerHandle_t myTimer;

    // 创建:名字"LED", 周期1000ms, 自动重载(pdTRUE), ID无(NULL)
    myTimer = xTimerCreate("LED", pdMS_TO_TICKS(1000), pdTRUE, NULL, LED_Callback);

    if (myTimer != NULL) {
        // 启动:如果队列满了,等待 100 个 Tick
        if (xTimerStart(myTimer, 100) == pdPASS) {
            printf("定时器启动成功\n");
        } else {
            printf("命令队列满了,启动失败\n");
        }
    }
}

4.2中断版本

"按键触发 LED 延时熄灭"

  1. 平时: LED 灯是灭的。

  2. 动作: 当你按下一个按键(触发硬件中断),LED 立刻亮起。

  3. 定时: 此时启动一个 2秒 的软件定时器。

  4. 结果: 2秒时间一到,定时器回调函数执行,自动关闭 LED。

  5. 特殊情况: 如果灯还亮着的时候你又按了一次键,倒计时应该重置(重新数 2秒),灯保持常亮。


我们将代码分为三个部分:回调函数初始化部分中断服务函数(ISR)

1. 第一步:准备工作(句柄与回调函数)

这部分代码通常放在 main.c 或任务文件中。这里定义了"时间到了该干什么"。

cs 复制代码
/* 引入头文件 */
#include "FreeRTOS.h"
#include "timers.h"

/* 定义定时器句柄 (全局变量,因为ISR也要用) */
TimerHandle_t xBacklightTimer = NULL;

/* 定义回调函数:这就是"厨师"要做的菜 (关灯) */
/* 注意:这里千万不能有 vTaskDelay 等阻塞代码! */
void AutoTurnOffCallback(TimerHandle_t xTimer)
{
    // 实际的硬件操作:关灯
    printf("时间到了,自动关灯!\r\n");
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET); // 假设LED接在PA5
}
2. 第二步:创建定时器(在任务或 main 中)

在使用中断之前,必须先确保存放定时器的内存已经申请好了。

cs 复制代码
void App_Init(void)
{
    // 创建软件定时器
    // 名字: "Backlight"
    // 周期: 2000ms (使用 pdMS_TO_TICKS 转换)
    // 模式: pdFALSE (单次模式,响一次就停,因为我们只需要它关一次灯)
    // ID: NULL (不需要ID)
    // 回调: AutoTurnOffCallback
    xBacklightTimer = xTimerCreate("Backlight", 
                                   pdMS_TO_TICKS(2000), 
                                   pdFALSE, 
                                   NULL, 
                                   AutoTurnOffCallback);

    if (xBacklightTimer == NULL) {
        // 创建失败,可能是内存不足
        printf("定时器创建失败!\r\n");
    }
}
3. 第三步:中断服务函数 (ISR) ------ 核心重点

这是你最需要学习的标准模板

注意点1:if (xBacklightTimer != NULL)

这个 NULL 在这里起到了一个**"安全检查"**的作用。

用最通俗的话来说,这句话的意思是: "先确认一下这个闹钟(定时器)是不是真的造出来了?如果造好了,我再按按钮;如果连闹钟都没有,我就别瞎按了。"

注意点2:if (xTimerResetFromISR(xBacklightTimer, &xHigherPriorityTaskWoken) != pdPASS)

这句话翻译成大白话就是: "尝试给后台发个'重置闹钟'的命令,如果发不出去(失败了),就进到这个 if 里面来。"

注意点3:在 C 语言中,把函数写在 if 的括号里,是一个非常经典且标准的写法。它的执行逻辑是:先干活,再根据干活的结果来决定怎么走。

if 里面调用函数,并不会 改变函数的行为。函数依然会完整地执行一遍,if 只是顺便拿它执行后的返回值来做个路标判断而已。

这就好比你去自动售货机买水: if (投币买水() != 成功) 意思是: 做"投币买水"这个动作,如果 没掉出水来(不等于成功),那么我就踢机器一脚。

补充:可以先result = xTimerResetFromISR(.......);然后再来判断

cs 复制代码
/* 假设这是按键的外部中断服务函数 */
void EXTI0_IRQHandler(void)
{
    /* ---------------- 1. 变量定义 ---------------- */
    /* 定义一个变量,用来标记"是否需要进行任务切换" */
    /* 初始值必须设为 pdFALSE,表示"暂时不需要切换" */
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;

    /* ---------------- 2. 清除中断标志 ---------------- */
    /* (这是硬件相关的操作,防止中断一直触发,不同芯片写法不同) */
    __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);

    /* ---------------- 3. 业务逻辑:开灯 ---------------- */
    /* 只要按键按下,先把灯点亮 */
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);

    /* ---------------- 4. 发送命令:重置/启动定时器 ---------------- */
    /* 使用 FromISR 版本! */
    /* 逻辑:如果定时器没跑,这就启动它;如果正在跑,这就重置倒计时为2秒 */
    if (xBacklightTimer != NULL) 
    {
        /* 参数1:定时器句柄 */
        /* 参数2:核心变量的地址 (&xHigherPriorityTaskWoken) */
        if (xTimerResetFromISR(xBacklightTimer, &xHigherPriorityTaskWoken) != pdPASS)
        {
            /* 只有当定时器命令队列满时,才会进这里 */
            /* 在这里可以做一个错误计数,或者闪烁红色LED报警 */
        }
    }

    /* ---------------- 5. 上下文切换 (退出前的最后一步) ---------------- */
    /* 告诉 FreeRTOS:如果刚才那个变量变成了 pdTRUE,请立刻切换任务 */
    /* 这一步保证了守护任务能立刻响应你的定时器命令 */
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

逐行深度解析(初学者必读)

复制代码
if (xTimerResetFromISR(xBacklightTimer, &xHigherPriorityTaskWoken) != pdPASS)

动作 1:执行函数(干活) CPU 首先 会跳进 xTimerResetFromISR 这个函数内部去运行。

  • 它尝试把"重置"命令塞进队列。

  • 它顺便检查了守护任务的优先级,如果需要切换,它把 xHigherPriorityTaskWoken 改成了 pdTRUE

  • 注意: 这一步是实打实地发生了,不管后面 if 判决结果如何,这个活已经干完了(或者尝试干过了)。

动作 2:拿到回执(返回值) 函数运行结束,带回来一个结果(返回值)。

  • 要么是 pdPASS(成功)。

  • 要么是 pdFAIL(失败)。

动作 3:进行对比(判决) CPU 拿着刚才带回来的那个结果,跟 pdPASS 进行比较:

  • "刚才带回来的结果 不等于 (!=) pdPASS 吗?"

动作 4:决定路线(跳转)

  • 如果是真(True): 说明刚才返回的是 pdFAIL(失败了),于是进入 { ... } 里面去执行错误处理代码。

  • 如果是假(False): 说明刚才返回的是 pdPASS(成功了),于是跳过 { ... },继续往下执行。

2. portYIELD_FROM_ISR(xHigherPriorityTaskWoken);

它的字面意思是什么?

  • xHigherPriorityTaskWoken:这是我们在中断里一直维护的那个"便签条"。

    • 如果它是 pdFALSE(假):说明刚才的操作(比如重置定时器)并没有唤醒比当前任务更高级的任务。

    • 如果它是 pdTRUE(真):说明刚才的操作(比如给守护任务发命令)导致守护任务(它的优先级很高)从"睡觉"变成了"想干活"的状态。

  • portYIELD_FROM_ISR(...):这是一个宏(Macro)。

    • 它的逻辑是:如果参数是 pdTRUE,就触发一次**"上下文切换" (Context Switch)**。

究竟"跳"到了哪里?

这是你最关心的问题。它不是直接像 goto 那样乱跳,而是把 CPU 的控制权交还给调度器

流程演示:

  1. 中断前 :CPU 正在运行一个低优先级的任务(比如 Task_Low,正在闪烁 LED)。

  2. 发生中断 :CPU 暂停 Task_Low,跳进 ISR(服务员)处理按键。

  3. ISR 内部 :你调用 xTimerResetFromISR。FreeRTOS 内核发现:"哎哟,你发了命令,导致守护任务 (Daemon Task) 醒了!而且守护任务的优先级比 Task_Low 高!"

    • 此时,内核把 xHigherPriorityTaskWoken 改为 pdTRUE
  4. 执行 portYIELD_FROM_ISR

    • ISR 结束。

    • 关键点来了: CPU 不会 回到 Task_Low 去继续闪烁 LED。

    • 而是跳转到: FreeRTOS 的调度器 (Scheduler)

  5. 调度器裁决 :调度器看了一眼任务列表:"现在谁优先级最高且想干活?哦,是守护任务。"

  6. 最终结果 :CPU 开始执行 守护任务 (处理你的定时器命令)。等守护任务干完活睡着了,CPU 才会回到 Task_Low

  • 这一行发生了什么?

    • 如果 xHigherPriorityTaskWoken 还是 pdFALSE:这就只是一行空代码,中断正常结束,CPU 回到刚才被打断的那个任务继续跑。

    • 如果 xHigherPriorityTaskWoken 变成了 pdTRUE:这就相当于发出了一个**"强行调度"**指令。中断结束后,CPU 不会 回到刚才被打断的那个低优先级任务,而是直接跳到 守护任务 去执行。

提问

五:总结

需求特征 推荐方案 理由
需要纳秒/微秒级精度 硬件定时器 软件定时器受任务调度影响,有抖动(Jitter)。
产生高频 PWM 波 硬件定时器 软件做不到高频且稳定的翻转。
"如果X秒没发生,就做Y" 软件定时器(单次) 这是 xTimerReset 的拿手好戏。
"每隔X秒做一次简单的事" 软件定时器(自动) 省去创建一个新 Task 的内存开销(TCB+Stack)。
事情很繁重,耗时很久 普通任务(Task) 千万别在定时器回调里做耗时操作,会卡死守护任务!
相关推荐
江苏世纪龙科技7 小时前
开启汽车实训新维度:基于真实标准的虚拟仿真教学软件
学习
玩具猴_wjh7 小时前
12.13 学习笔记
笔记·学习
雾岛听风眠7 小时前
运放学习笔记
笔记·学习
肥大毛7 小时前
C++入门学习---结构体
开发语言·c++·学习
likeshop 好像科技7 小时前
新手学习AI智能体Agent逻辑设计的指引
人工智能·学习·开源·github
副露のmagic7 小时前
更弱智的算法学习 day11
学习
jimmyleeee8 小时前
人工智能基础知识笔记二十七:构建一个可以搜索本地文件的Agent
笔记
冲,干,闯8 小时前
CH32V307以太网学习
学习
SadSunset8 小时前
(16)Bean的实例化
java·数据库·笔记·spring