FreeRTOS 学习复盘(四):事件组、软件定时器与任务通知机制

一、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
);

该函数会:

  1. 清除指定事件位;

  2. 返回清除之前的整个事件组值。

例如清除前:

复制代码
事件组 = 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;

和等待多个事件条件。

相关推荐
charlie1145141913 天前
Cinux: 为大内核铺路
开发语言·c++·操作系统·现代c++
饼干哥哥3 天前
每晚300+个爆款疯狂跑,0人值守TikTok工厂开张!!
后端·操作系统·产品
小宇子2B3 天前
文件页怎么回收:page cache、干净页和脏页
操作系统
离凌寒3 天前
十一、在zephyr上使用rtc,rng,counter,crc,wdg等外设的演示
freertos
云纳星辰怀自在4 天前
嵌入式系统栈溢出:从幽灵Bug到精准防控的全链路指南
软件工程·嵌入式软件·栈溢出·stack溢出
m0_547486664 天前
《系统级编程及分布式应用实现技术》 全套PPT课件
操作系统·系统级编程
小宇子2B4 天前
匿名页怎么回收:反向映射、swap entry、swap cache 和换入
操作系统
小宇子2B4 天前
区间树:反向映射的前置数据结构
操作系统
半条-咸鱼4 天前
【FreeRTOS】核心原理与实战速查手册
c语言·操作系统·rtos