软件定时器 (Software Timer) 是能够处理很多"延时执行"或"周期性执行"的任务,而不需要占用宝贵的硬件资源。
补充知识点学习:
回调函数:
"回调函数" (Callback Function) 就是你写好一个函数,但是你不用去调用它,而是把这个函数像"电话号码"一样留给系统,让系统在"特定事情发生"的时候回头去调用它。
目录
[一:. 什么是软件定时器?](#一:. 什么是软件定时器?)
[2. 软件定时器 vs 硬件定时器](#2. 软件定时器 vs 硬件定时器)
[1. 时基依赖性 (Tick Dependency)](#1. 时基依赖性 (Tick Dependency))
[2. 两种核心模式](#2. 两种核心模式)
[3. 执行上下文:守护任务 (Daemon Task)](#3. 执行上下文:守护任务 (Daemon Task))
[3.2 为什么需要守护任务?](#3.2 为什么需要守护任务?)
[3.3. 它是如何工作的?(核心机制)](#3.3. 它是如何工作的?(核心机制))
[1. 守护任务的优先级 (configTIMER_TASK_PRIORITY)](#1. 守护任务的优先级 (configTIMER_TASK_PRIORITY))
[2. 定时器命令队列长度 (configTIMER_QUEUE_LENGTH)](#2. 定时器命令队列长度 (configTIMER_QUEUE_LENGTH))
[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))
[7. 紧急通道:中断版本 (FromISR)](#7. 紧急通道:中断版本 (FromISR))
["按键触发 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)正在看书(执行主程序)。你需要做两件事:
-
30分钟后去关火(单次任务)。
-
每隔 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. 它是如何工作的?(核心机制)
你可以把"守护任务"想象成一个**"定时器管家"**。它主要做两件事:
-
处理命令: 当你在其他任务中调用
xTimerStart(启动)、xTimerStop(停止)等函数时,本质上是在给这个"管家"发指令 。 -
执行回调: 当定时器时间到了,"管家"会负责去调用你写好的回调函数 。
交互流程:定时器命令队列 (Timer Command Queue)
当你调用 API 函数时,其实是通过一个队列与守护任务通信的。
-
你的任务: 调用
xTimerStart()-> 发送命令到"定时器命令队列" -> 任务继续运行(或者因队列满而阻塞) -
守护任务: 从"定时器命令队列"取出命令 -> 真正地去启动定时器 -> 如果时间到了,执行回调函数 。
关键点: 这就是为什么
xTimerStart函数里有一个xTicksToWait参数。这个参数不是等待定时器启动的时间,而是等待命令写入队列的时间。如果队列满了,你的任务需要等待"管家"把队列里的旧命令处理完,腾出空间 。
3.4定时器回调函数决不能阻塞!
-
绝对禁止: 调用
vTaskDelay()。 -
绝对禁止: 调用会阻塞的
xQueueReceive(除非等待时间设为0)。 -
后果: 如果你在回调函数里"睡"了 1 秒,那么守护任务就会停工 1 秒。在这 1 秒内,系统中所有其他的软件定时器都无法被触发,命令队列也无法处理,整个定时器系统就会"卡死"。
总结
-
身份: 它是 FreeRTOS 自动创建的一个后台任务,专门管理软件定时器。
-
通信: 我们通过"命令队列"发指令指挥它干活。
-
禁忌: 它的回调函数里千万不能有阻塞代码
3.5守护任务的优先级和队列长度
在 FreeRTOS 中,守护任务(Daemon Task,旧称 Timer Server)虽然负责处理所有的软件定时器,但它本质上仍然是一个普通的 FreeRTOS 任务 。这意味着它的运行完全遵循 FreeRTOS 的标准调度规则:只有当它是就绪态中优先级最高的任务时,它才会运行
守护任务有两个核心配置参数决定了它的行为表现:优先级 和队列长度。
1. 守护任务的优先级 (configTIMER_TASK_PRIORITY)
守护任务的优先级在 FreeRTOSConfig.h 中通过 configTIMER_TASK_PRIORITY 进行配置 。这个优先级的设置直接决定了定时器命令处理的"实时性"。
文档中使用了两个生动的例子来说明优先级的影响:
情况 A:守护任务优先级 低于 当前用户任务
这是"你忙完了我再做"的模式。
-
发送命令: 你的任务(Task1)调用
xTimerStart()启动定时器。 -
入队: "启动"命令被放入命令队列,守护任务从阻塞态变为就绪态 。
-
延迟执行: 但因为守护任务优先级低 ,它抢不过 Task1,所以它只能在就绪列表中排队。Task1 继续运行,直到它自己阻塞(比如调用
vTaskDelay)或者时间片用完 4。 -
最终处理: 只有等 Task1 让出 CPU,守护任务才得以运行,从队列取出命令并真正启动定时器
-
后果: 定时器的实际启动时间点(tX)会比你调用函数的时刻晚,导致计时存在误差。
情况 B:守护任务优先级 高于 当前用户任务
这是"插队立刻做"的模式。
-
发送命令: 你的任务(Task1)调用
xTimerStart()。 -
抢占: 命令一入队,高优先级的守护任务立刻就绪,并抢占(Preempt)了 Task1 的运行权 7。
-
立即处理: 守护任务马上处理命令,启动定时器。处理完后,它重新阻塞,Task1 才恢复运行 。
-
后果: 定时器几乎在你调用 API 的瞬间就启动了,计时非常精准。
最佳实践: 为了保证定时器的准时性,通常建议将
configTIMER_TASK_PRIORITY设置得相对较高。
2. 定时器命令队列长度 (configTIMER_QUEUE_LENGTH)
守护任务不只是盯着时间看,它还要处理来自其他任务的"指令"(如启动、停止、复位定时器)。这些指令是通过一个队列 传送的,这个队列的深度由 configTIMER_QUEUE_LENGTH 定义。
3.为什么队列长度很重要?
当你调用 xTimerStart(xTimer, xTicksToWait) 时,实际上是在往这个队列里写数据。
-
如果队列没满: 命令写入成功,函数返回
pdPASS。 -
如果队列满了:
-
这说明守护任务来不及处理堆积的命令(可能是因为守护任务优先级太低,或者瞬间爆发了太多定时器操作)。
-
此时,你的任务会进入阻塞状态 ,等待队列腾出空间。等待的时间由参数
xTicksToWait决定 。 -
如果超时还没空间,函数返回
pdFAIL,定时器启动失败。
-
4.总结:调度示意图
守护任务的工作就是不断在"处理命令"和"执行回调"之间循环:
-
处理命令: 从队列里取出
start,stop等命令并执行。 -
执行回调: 检查是否有定时器超时,如果有,执行其回调函数 。
| 配置项 | 建议 | 影响 |
|---|---|---|
| 优先级 | 设为较高 | 决定了启动/停止命令的响应速度,以及回调函数是否准时执行。 |
| 队列长度 | 根据业务量 | 决定了短时间内能并发处理多少个定时器操作命令。设太小会导致 API 调用阻塞甚至失败。 |
4.命令队列机制 (Command Queue)
当你调用 xTimerStart() 或 xTimerStop() 时,其实并不是立即修改定时器的状态。
-
特性: 这些函数其实是向一个 "定时器命令队列" 发送了一条消息(命令)。
-
流程:
-
你的任务调用
xTimerStart()。 -
这个命令被扔进队列。
-
守护任务从队列里取出命令。
-
守护任务实际去修改定时器的链表和状态。
-
-
意味着什么:
-
阻塞时间:
xTimerStart(handle, 100)中的100不是定时器的延时,而是如果队列满了,你的任务愿意等多久把命令塞进去。 -
异步性: 严格来说,启动操作有一点点极微小的滞后(直到守护任务读取命令),但在毫秒级应用中完全感觉不到。
-
5. 两种状态:休眠与运行 (Dormant vs Running)
不像硬件定时器那样始终通电就在跑,软件定时器非常节省资源。
-
休眠态 (Dormant):
-
当你创建了定时器 (
xTimerCreate) 但还没启动,或者单次定时器跑完了一次。 -
此时它仅仅占用一点内存来保存结构体,完全不占用 CPU 时间,系统调度器根本不理它。
-
-
运行态 (Running):
-
调用
xTimerStart后。 -
此时它被挂在了一个"激活链表"上,系统每次 Tick 中断都会检查它是否到期。
-
6. 高效的各种 Reset 机制
软件定时器不仅仅是"倒计时",它非常灵活。
-
特性: 如果一个定时器已经在跑(比如剩 2秒 触发),你再次调用
xTimerReset(),它会重新装载初始值(变回 10秒)。 -
经典应用场景: "看门狗"或"背光控制"。
-
例如:手机背光设置为"10秒无操作熄灭"。
-
用户按一下键,你就调一次
xTimerReset。 -
只要用户一直按键,定时器就一直被复位,永远到不了 0,背光就一直亮。
-
一旦用户停手,10秒后定时器到期 -> 回调函数 -> 关灯。
-
总结:初学者避坑指南
基于以上特性,送你三个编写代码的建议:
-
回调函数要短: 别在里面
delay,因为你阻塞的是"守护任务",会卡死所有其他定时器。 -
优先级要注意: 确保
configTIMER_TASK_PRIORITY设置得够高,否则高负载下定时器可能不准。 -
栈空间要够: 如果回调函数逻辑稍微复杂,记得去
FreeRTOSConfig.h把configTIMER_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 延时熄灭"。
-
平时: LED 灯是灭的。
-
动作: 当你按下一个按键(触发硬件中断),LED 立刻亮起。
-
定时: 此时启动一个 2秒 的软件定时器。
-
结果: 2秒时间一到,定时器回调函数执行,自动关闭 LED。
-
特殊情况: 如果灯还亮着的时候你又按了一次键,倒计时应该重置(重新数 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 的控制权交还给调度器。
流程演示:
-
中断前 :CPU 正在运行一个低优先级的任务(比如 Task_Low,正在闪烁 LED)。
-
发生中断 :CPU 暂停 Task_Low,跳进 ISR(服务员)处理按键。
-
ISR 内部 :你调用
xTimerResetFromISR。FreeRTOS 内核发现:"哎哟,你发了命令,导致守护任务 (Daemon Task) 醒了!而且守护任务的优先级比 Task_Low 高!"- 此时,内核把
xHigherPriorityTaskWoken改为pdTRUE。
- 此时,内核把
-
执行
portYIELD_FROM_ISR:-
ISR 结束。
-
关键点来了: CPU 不会 回到 Task_Low 去继续闪烁 LED。
-
而是跳转到: FreeRTOS 的调度器 (Scheduler)。
-
-
调度器裁决 :调度器看了一眼任务列表:"现在谁优先级最高且想干活?哦,是守护任务。"
-
最终结果 :CPU 开始执行 守护任务 (处理你的定时器命令)。等守护任务干完活睡着了,CPU 才会回到 Task_Low。
-
这一行发生了什么?
-
如果
xHigherPriorityTaskWoken还是pdFALSE:这就只是一行空代码,中断正常结束,CPU 回到刚才被打断的那个任务继续跑。 -
如果
xHigherPriorityTaskWoken变成了pdTRUE:这就相当于发出了一个**"强行调度"**指令。中断结束后,CPU 不会 回到刚才被打断的那个低优先级任务,而是直接跳到 守护任务 去执行。
-
-

提问

五:总结
| 需求特征 | 推荐方案 | 理由 |
|---|---|---|
| 需要纳秒/微秒级精度 | 硬件定时器 | 软件定时器受任务调度影响,有抖动(Jitter)。 |
| 产生高频 PWM 波 | 硬件定时器 | 软件做不到高频且稳定的翻转。 |
| "如果X秒没发生,就做Y" | 软件定时器(单次) | 这是 xTimerReset 的拿手好戏。 |
| "每隔X秒做一次简单的事" | 软件定时器(自动) | 省去创建一个新 Task 的内存开销(TCB+Stack)。 |
| 事情很繁重,耗时很久 | 普通任务(Task) | 千万别在定时器回调里做耗时操作,会卡死守护任务! |