一、FreeRTOS 任务调度的基本原则
FreeRTOS 的调度原则不是"高优先级任务永远运行",而是:
在所有处于就绪态的任务中,选择优先级最高的任务运行。
任务可能处于以下几种状态:
-
运行态;
-
就绪态;
-
阻塞态;
-
挂起态。
高优先级任务一旦进入阻塞态,低优先级任务就可以运行。
例如:
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
当任务没有收到通知时,该函数会让任务进入阻塞态。
又例如:
vTaskDelay(20);
任务会延时 20 个 Tick,在这段时间内处于阻塞状态。
因此下面三个任务即使优先级不同,也都可能得到运行机会:
Send_Task 优先级 4
Receive2_Task 优先级 3
Receive1_Task 优先级 2
运行过程大致为:
Send_Task 扫描按键
↓
vTaskDelay(20),进入阻塞
↓
Receive2_Task 运行并等待通知
↓
Receive2_Task 阻塞
↓
Receive1_Task 运行并等待通知
↓
Receive1_Task 阻塞
↓
空闲任务运行
所以,低优先级任务能够运行,是因为高优先级任务主动进入了阻塞态。
二、事件组的基本机制
事件组可以理解为一个整数,其中每一位代表一个事件是否发生。
例如:
#define KEY1_EVENT (1UL << 0)
#define KEY2_EVENT (1UL << 1)
对应的二进制为:
KEY1_EVENT = 0000 0001
KEY2_EVENT = 0000 0010
两个事件组合:
KEY1_EVENT | KEY2_EVENT
结果为:
0000 0001
0000 0010
---------
0000 0011
因此,事件组可以使用不同的位记录不同事件。
1. 设置事件位
xEventGroupSetBits(Event_Handle, KEY1_EVENT);
表示将 KEY1 对应的事件位置 1。
连续执行:
xEventGroupSetBits(Event_Handle, KEY1_EVENT);
xEventGroupSetBits(Event_Handle, KEY2_EVENT);
事件组最终为:
0000 0011
这里的事件是可以累积的,并不要求两个物理按键严格同时按下。
更准确地说,它表示:
KEY1 事件和 KEY2 事件都已经发生过。
2. 等待事件位
典型写法:
r_event = xEventGroupWaitBits(
Event_Handle,
KEY1_EVENT | KEY2_EVENT,
pdTRUE,
pdTRUE,
portMAX_DELAY
);
各参数含义如下:
xEventGroupWaitBits(
事件组句柄,
等待的事件位,
条件满足后是否清除,
是否等待全部事件位,
最大等待时间
);
对应本例:
等待 KEY1_EVENT 和 KEY2_EVENT
条件满足后自动清除这两个位
必须两个位全部置 1
无限等待
所以只有两个事件都发生后,函数才会返回。
如果只发生 KEY1:
事件组 = 0000 0001
任务会继续阻塞,不会执行后面的 if。
只有事件组变成:
0000 0011
函数才会返回。
3. 为什么不是事件不满足就进入 else
代码结构是:
r_event = xEventGroupWaitBits(...);
if (...)
{
printf("两个事件都发生");
}
else
{
printf("事件错误");
}
关键在于,程序必须先从:
xEventGroupWaitBits(...)
返回,才会执行下面的 if。
当条件不满足时,任务直接阻塞在函数内部,根本不会执行 else。
可以近似理解为:
while (两个事件没有全部发生)
{
当前任务阻塞;
}
if (...)
{
处理事件;
}
4. 事件判断的正确写法
错误写法:
if ((r_event & (KEY1_EVENT | KEY1_EVENT))
== (KEY1_EVENT | KEY2_EVENT))
因为:
KEY1_EVENT | KEY1_EVENT
仍然只是 KEY1_EVENT。
正确写法:
if ((r_event & (KEY1_EVENT | KEY2_EVENT))
== (KEY1_EVENT | KEY2_EVENT))
{
printf("KEY1与KEY2事件都发生\n");
}
其中:
r_event & (KEY1_EVENT | KEY2_EVENT)
用于提取关心的两个事件位。
再判断结果是否等于:
KEY1_EVENT | KEY2_EVENT
即可确定两个事件是否都存在。
5. xClearOnExit 为什么影响循环行为
如果:
xClearOnExit = pdTRUE
函数返回时会清除等待的事件位。
例如:
返回值 r_event:0000 0011
事件组清除后:0000 0000
下一次调用时,任务会重新阻塞等待。
如果:
xClearOnExit = pdFALSE
事件位不会清除:
返回值 r_event:0000 0011
事件组仍然是:0000 0011
下一次调用时,等待条件仍然满足,所以函数会立即返回。
如果后面的判断有错误,就会出现不断打印错误信息的情况。
其本质是:
事件位没有清除,等待条件一直保持成立。
6. xEventGroupClearBits() 返回什么
示例:
EventBits_t old_bits;
old_bits = xEventGroupClearBits(
Event_Handle,
KEY1_EVENT
);
该函数会:
-
清除指定事件位;
-
返回清除之前的整个事件组值。
例如清除前:
事件组 = 0000 0011
清除 KEY1 后:
old_bits = 0000 0011
当前事件组 = 0000 0010
判断清除前 KEY1 是否存在:
if ((old_bits & KEY1_EVENT) != 0)
{
printf("清除前 KEY1 已置位\n");
}
判断两个事件是否都存在:
if ((old_bits & (KEY1_EVENT | KEY2_EVENT))
== (KEY1_EVENT | KEY2_EVENT))
{
printf("清除前两个事件都存在\n");
}
三、软件定时器
FreeRTOS 软件定时器不是 STM32 的硬件定时器,它由 FreeRTOS 内核中的定时器服务任务统一管理。
需要包含:
#include "timers.h"
典型创建方式:
Swtmr1_Handle = xTimerCreate(
"AutoReloadTimer",
pdMS_TO_TICKS(1000),
pdTRUE,
(void *)1,
Swtmr1_Callback
);
函数原型可以理解为:
TimerHandle_t xTimerCreate(
const char *pcTimerName,
TickType_t xTimerPeriod,
BaseType_t xAutoReload,
void *pvTimerID,
TimerCallbackFunction_t pxCallbackFunction
);
1. 定时周期
pdMS_TO_TICKS(1000)
表示将 1000 ms 转换为系统 Tick 数。
不建议直接写:
1000
因为 1000 的单位是 Tick,不一定是毫秒。
例如:
configTICK_RATE_HZ = 1000
1 Tick = 1 ms
但如果:
configTICK_RATE_HZ = 100
1 Tick = 10 ms
那么 1000 Tick 就等于 10 秒。
2. 自动重装与单次定时器
周期定时器:
pdTRUE
运行过程:
启动
→ 到期
→ 执行回调
→ 自动重新计时
→ 再次到期
单次定时器:
pdFALSE
运行过程:
启动
→ 到期
→ 执行一次回调
→ 停止
3. 定时器 ID 的作用
创建时的:
(void *)1
是定时器 ID,可以理解为应用程序为定时器设置的标签。
例如两个定时器共用一个回调函数:
static void Timer_Callback(TimerHandle_t xTimer)
{
uintptr_t timer_id;
timer_id = (uintptr_t)pvTimerGetTimerID(xTimer);
if (timer_id == 1)
{
printf("定时器1到期\n");
}
else if (timer_id == 2)
{
printf("定时器2到期\n");
}
}
如果每个定时器使用不同的回调函数,也可以将 ID 设置为:
NULL
4. xTimerStart() 的第二个参数
xTimerStart(Swtmr1_Handle, 0);
第二个参数不是定时周期,也不是启动延迟。
它表示:
如果软件定时器命令队列已满,调用任务最多等待多少个 Tick。
设置为 0 表示:
队列有空间 → 立即发送启动命令
队列已满 → 立即返回失败
因此最好检查返回值:
BaseType_t result;
result = xTimerStart(Swtmr1_Handle, 0);
if (result == pdPASS)
{
printf("定时器启动命令发送成功\n");
}
else
{
printf("定时器启动命令发送失败\n");
}
需要注意,返回 pdPASS 表示:
启动命令成功进入软件定时器命令队列。
不表示回调函数已经执行。
5. 启动定时器时的句柄错误
错误写法:
if (Swtmr2_Handle != NULL)
{
xTimerStart(Swtmr1_Handle, 0);
}
这里判断的是定时器 2 是否创建成功,却启动了定时器 1。
正确写法:
if (Swtmr2_Handle != NULL)
{
xTimerStart(Swtmr2_Handle, 0);
}
原代码的实际效果是:
创建定时器1
启动定时器1
创建定时器2
再次启动定时器1
定时器 2 从未启动,所以它的回调函数不会执行。
需要注意,重复调用 xTimerStart() 启动同一个软件定时器,一般并不是绝对非法操作。问题主要在于程序启动错了句柄,与原设计不一致。
6. 软件定时器回调函数
规范写法:
static void Swtmr1_Callback(TimerHandle_t xTimer)
{
LED1_TOGGLE;
}
不建议写成:
static void Swtmr1_Callback(void *parameter)
再通过强制类型转换:
(TimerCallbackFunction_t)Swtmr1_Callback
正确的函数签名可以让编译器进行类型检查,避免强制转换掩盖错误。
软件定时器回调函数应当:
-
快进快出;
-
不执行死循环;
-
不调用长时间阻塞函数;
-
尽量避免复杂处理。
四、Keil 中 Include Path 的作用
Include Path 的作用是:
告诉编译器,遇到
#include "xxx.h"时去哪些文件夹中寻找头文件。
例如代码中:
#include "FreeRTOS.h"
#include "task.h"
#include "bsp_led.h"
#include "bsp_key.h"
编译器只知道文件名,不知道它们在磁盘中的实际位置。
假设:
bsp_key.h 位于 User\key
那么 Include Path 中需要加入:
..\..\User\key
编译器才能找到:
..\..\User\key\bsp_key.h
1. Include Path 添加的是目录
应该添加:
..\..\User\key
而不是:
..\..\User\key\bsp_key.h
因为 Include Path 是头文件搜索目录。
2. .c 文件和 .h 文件的区别
.c 文件需要添加到 Keil 工程中参与编译。
.h 文件所在目录需要加入 Include Path。
例如:
bsp_key.c
bsp_key.h
如果头文件路径没配置,可能出现:
cannot open source input file "bsp_key.h"
如果只包含头文件,却没有把对应 .c 文件加入工程,可能出现链接错误:
Undefined symbol Key_Scan
所以:
Include Path 解决"找不到头文件"
工程中的 .c 文件解决"没有函数实现"
3. 相对路径中的 ..
..
表示返回上一级目录。
..\..\User\key
表示:
返回上一级
→ 再返回上一级
→ 进入 User
→ 进入 key
相对路径比绝对路径更适合工程移植。
不建议使用:
D:\Project\User\key
因为更换电脑或移动工程目录后路径容易失效。
五、任务通知代替二值信号量
任务通知是每个任务自带的一个 32 位通知值。
发送通知:
xTaskNotifyGive(Receive1_Task_Handle);
接收通知:
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
这里:
pdTRUE
表示获取通知后将通知值清零。
portMAX_DELAY
表示没有通知时一直阻塞。
因此它可以实现类似二值信号量的同步效果:
接收任务等待通知
↓
没有通知,任务阻塞
↓
发送任务调用 xTaskNotifyGive
↓
接收任务被唤醒
↓
通知值清零
↓
处理一次事件
因为使用了无限等待,函数正常返回时一定已经接收到通知,所以通常不需要再检查返回值。
六、任务通知代替计数信号量
任务通知值也可以作为计数器使用。
释放一个资源:
xTaskNotifyGive(Take_Task_Handle);
每调用一次,目标任务的通知值加 1:
0 → 1
1 → 2
2 → 3
申请一个资源:
take_num = ulTaskNotifyTake(pdFALSE, 0);
两个参数的作用分别是:
pdFALSE:成功获取后,通知值减 1
0:没有通知时不等待,立即返回
一定要区分:
第一个参数决定成功后如何处理通知值。
第二个参数决定没有通知时等待多久。
1. ulTaskNotifyTake() 返回什么
ulTaskNotifyTake() 返回的是:
清零或减一之前的任务通知值。
使用:
take_num = ulTaskNotifyTake(pdFALSE, 0);
结果如下:
| 调用前通知值 | 返回值 | 调用后通知值 |
|---|---|---|
| 0 | 0 | 0 |
| 1 | 1 | 0 |
| 2 | 2 | 1 |
| 3 | 3 | 2 |
例如当前通知值为 3:
调用前:3
返回值:3
调用后:2
因此剩余停车位可以打印:
take_num - 1
2. 为什么这里需要检查返回值
使用的是:
ulTaskNotifyTake(pdFALSE, 0);
没有通知时会立即返回 0。
因此必须判断:
if (take_num > 0)
{
printf("成功申请停车位\n");
}
else
{
printf("当前没有停车位\n");
}
而二值信号量实验使用:
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
没有通知就一直阻塞,只有真正收到通知后才会返回,所以通常不必检查返回值。
3. 为什么还需要 vTaskDelay(20)
计数信号量实验中的接收任务还负责扫描 KEY1:
if (Key_Scan(...) == KEY_ON)
{
take_num = ulTaskNotifyTake(pdFALSE, 0);
}
这里 ulTaskNotifyTake() 不阻塞,因为第二个参数是 0。
如果不写:
vTaskDelay(20);
任务就会一直高速扫描按键,占用 CPU。
因此 vTaskDelay(20) 的作用主要是:
-
控制按键扫描频率;
-
减少 CPU 空转;
-
辅助按键消抖;
-
给其他任务提供运行机会。
它不是因为"接收通知必须延时"。
七、任务通知代替事件组
任务通知可以通过:
eSetBits
将通知值中的某些位置 1,从而模拟事件组。
定义事件位:
#define KEY1_EVENT (1UL << 0)
#define KEY2_EVENT (1UL << 1)
发送事件:
xTaskNotify(
LED_Task_Handle,
KEY1_EVENT,
eSetBits
);
或者:
xTaskNotify(
LED_Task_Handle,
KEY2_EVENT,
eSetBits
);
eSetBits 的作用相当于:
任务通知值 |= 本次发送值;
例如当前通知值为:
0000 0001
再发送 KEY2:
0000 0010
结果:
0000 0011
1. xTaskNotifyWait() 参数
典型调用:
xReturn = xTaskNotifyWait(
0,
ULONG_MAX,
&r_event,
portMAX_DELAY
);
函数原型:
BaseType_t xTaskNotifyWait(
uint32_t ulBitsToClearOnEntry,
uint32_t ulBitsToClearOnExit,
uint32_t *pulNotificationValue,
TickType_t xTicksToWait
);
各参数含义:
0:
进入函数时不清除任何位
ULONG_MAX:
成功退出时清除所有通知位
&r_event:
保存清除之前的通知值
portMAX_DELAY:
没有通知时无限等待
函数返回值:
pdTRUE:等待期间收到了通知
pdFALSE:等待超时,没有收到通知
2. 为什么需要 last_event
由于:
ULONG_MAX
会在每次成功返回时清除所有任务通知位,因此要使用一个普通变量保存历史事件:
last_event |= r_event;
假设先按 KEY1:
r_event = 0001
last_event = 0000
执行后:
last_event = 0001
之后按 KEY2:
r_event = 0010
last_event = 0001
执行:
last_event |= r_event;
得到:
last_event = 0011
说明两个事件都发生过。
3. 正确的事件判断
错误写法:
if (last_event == (KEY1_EVENT | KEY1_EVENT))
正确写法:
if ((last_event & (KEY1_EVENT | KEY2_EVENT))
== (KEY1_EVENT | KEY2_EVENT))
{
last_event = 0;
printf("KEY1与KEY2都按下\n");
LED1_TOGGLE;
}
4. 为什么 else 不需要重新赋值
原代码:
last_event |= r_event;
if (...)
{
last_event = 0;
}
else
{
last_event = r_event;
}
这里的:
last_event = r_event;
会覆盖历史事件。
正确做法是不写 else:
last_event |= r_event;
if ((last_event & (KEY1_EVENT | KEY2_EVENT))
== (KEY1_EVENT | KEY2_EVENT))
{
last_event = 0;
LED1_TOGGLE;
}
因为不满足时,直接保留已经累计的 last_event 即可。
也没有必要再次写:
last_event |= r_event;
和等待多个事件条件。