FreeRTOS基础学习_Part3
12 任务通知(Task Notifications)
所谓”任务通知”,你可以反过来读”通知任务”。
使用队列、信号量、事件组等方法时,并不知道对方是谁。使用任务通知时,可明确指定:通知哪个任务。
使用队列、信号量、事件组时,我们都要事先创建对应的结构体,双方通过中间的结构体通信:
使用任务通知时,任务结构体TCB中就包含了内部对象,可以直接接收别人发过来的”通知”,如下图所示,也就是说任务A或者ISR可以直接修改任务B内部结构体TCB:
12.1 任务通知的特性
12.1.1 优势及限制
- 任务通知的优势:
- 效率更高:用任务通知来发送事件、数据给某个任务时,比队列、信号量、事件组等效率都更高。
- 更节省内存:使用其他方法时都要先创建对应的结构体,使用任务通知时无需额外创建结构体。
- 任务通知的限制:
- 不能发送数据给 ISR:
- ISR并没有任务结构体,所以无法使用任务通知的功能给ISR发送数据。
- 但是ISR可以使用任务通知的功能,发数据给任务。
- 数据只能给该任务独享
- 使用队列、信号量、事件组时,数据保存在这些结构体中,其他任务、ISR都可以访问这些数据。使用任务通知时,数据存放入目标任务中,只有它可以访问这些数据。
- 在日常工作中,该限制影响不大。因为很多场合是从多个数据源把数据发给某个任务,而不是把一个数据源的数据发给多个任务。
- 无法缓冲数据
- 使用队列时,假设队列深度为N,那么它可以保持N个数据。
- 使用任务通知时,任务结构体中只有一个任务通知值,只能保持一个数据。
- 无法广播给多个任务
- 使用事件组可以同时给多个任务发送事件。
- 使用任务通知,只能发个一个任务。
- 如果发送受阻,发送方无法进入阻塞状态等待
- 若队列已满,用
xQueueSendToBack()给队列发送数据时,任务可以进入阻塞状态等待发送完成。 - 使用任务通知时,即使对方无法接收数据,发送方也无法阻塞等待,只能即刻返回错误。
- 若队列已满,用
- 不能发送数据给 ISR:
12.1.2 通知状态和通知值
每个任务都有一个结构体:TCB(Task Control Block),里面有 2 个成员:
- 一个是
uint8_t类型,用来表示通知状态 - 一个是
uint32_t类型,用来表示通知值
1 | typedef struct tskTaskControlBlock{ |
- 通知状态有3种取值:
taskNOT_WAITING_NOTIFICATION:任务没有在等待通知taskWAITING_NOTIFICATION:任务在等待通知taskNOTIFICATION_RECEIVED:任务接收到了通知,也被称为 pending(有数据了,待处理)
1 |
- 通知值可以有很多种类型:
- 计数值
- 位(类似事件组)
- 任意数值
12.2 任务通知的使用
使用任务通知,可以实现轻量级的队列(长度为 1)、邮箱(覆盖的队列)、计数型信号量、二进制信号量、事件组。
12.2.1 两类函数
任务通知有 2 套函数,简化版、专业版
- 简化版函数的使用比较简单,它实际上也是使用专业版函数实现的
- 专业版函数支持很多参数,可以实现很多功能
12.2.2 简化版Give和Take函数
在任务中使用xTaskNotifyGive()函数,在 ISR 中使用vTaskNotifyGiveFromISR()函数,都是直接给其他任务发送通知:
- 使得被通知任务的TCB结构体中的通知值加一
- 并使得被通知任务的TCB结构体中的通知状态变为”pending”,也就是
taskNOTIFICATION_RECEIVED,表示有数据了、待处理- 若被通知任务因调用了
ulTaskNotifyTake()而处于阻塞状态(taskWAITING_NOTIFICATION状态),则此时被通知任务被唤醒,通知值减一,通知状态变为初始值taskNOT_WAITING_NOTIFICATION - 若被通知任务没有调用
ulTaskNotifyTake(),则通知任务不会被唤醒,通知状态还是”pending”
- 若被通知任务因调用了
ulTaskNotifyTake() 是 FreeRTOS 中用于接收任务通知的核心函数,它允许任务在阻塞状态下等待通知到达,并支持两种计数模式
- 轻量级二进制信号量:当
xClearCountOnExit=pdTRUE时,每次调用将通知值减1(类似二进制信号量) - 轻量级计数信号量:当
xClearCountOnExit=pdFALSE时,返回当前通知值并清零计数器(类似计数信号量)
该函数的运行原理为:
- 如果通知值等于 0,则阻塞(可以指定超时时间)
- 当通知值大于 0 时,任务从阻塞态进入就绪态
- 在
ulTaskNotifyTake()返回之前,还可以做些清理工作:把通知值减一,或者把通知值清零
使用ulTaskNotifyTake()函数可以实现轻量级的、高效的二进制信号量、计数型信号量。
1 | /** |
12.23 专业版Give和Take函数
xTaskNotify()函数功能更强大,支持多种操作模式(覆盖/置位/递增等),是 FreeRTOS 任务通知机制的核心函数。相比信号量/队列,该函数节省内存且延迟更低 可以使用不同参数实现各类功能,比如:
- 让接收任务的通知值加一:这时
xTaskNotify()等同于xTaskNotifyGive(); - 设置接收任务的通知值的某一位、某些位,这就是一个轻量级的、更高效的事件组;
- 把一个新值写入接收任务的通知值:上一次的通知值被读走后,写入才成功。这就是一个轻量级、长度为1的队列;
- 用一个新值覆盖接收任务的通知值:无论上一次的通知值是否被读走,覆盖都成功。类似
xQueueOverwrite()函数,这就是一个轻量级的邮箱。
xTaskNotify()比xTaskNotifyGive()更灵活、强大,使用上也就更复杂。xTaskNotifyFromISR()是它对应的 ISR 版本。 这两个函数用来发出任务通知,使用哪个函数来取出任务通知呢?
使用xTaskNotifyWait()函数!它比ulTaskNotifyTake()更复杂:
- 可以让任务等待(可以加上超时时间),等到任务状态为”pending”(也就是有数据)
- 还可以在函数进入、退出时,清除通知值的指定位
1 | /** |
| eNotifyAction取值 | 说明 |
|---|---|
| eNoAction | 仅更新通知状态为”pending”,未使用ulValue。也即仅唤醒任务,不修改通知值这个选项相当于轻量级的、更高效的二进制信号量。 |
| eSetBits | 按位或操作(ulValue 为位掩码),也即通知值 = 原通知值│ulValue相当于轻量级的、更高效的事件组。 |
| eIncrement | 通知值 = 原来的通知值 + 1,未使用未使用ulValue。相当于轻量级的、更高效的二进制信号量、计数型信号量。 相当于 xTaskNotifyGive()函数。 |
| eSetValueWithOverwrite | 直接覆盖通知值,也即无论如何,不管通知状态是否为”pendng”,通知值 = ulValue |
| eSetValueWithoutOverwrite | 仅当通知值为0时覆盖,其余情况不覆盖。 若通知状态为”pending”(表示有数据未读),则此次调用 xTaskNotify()不做任何事,返回pdFAIL若通知状态不是”pending”(表示没有新数据),则:通知值 = ulValue。 |
1 | /** |
| xTaskNotifyWait函数的参数 | 说明 |
|---|---|
| ulBitsToClearOnEntry | 在xTaskNotifyWait ()入口处,要清除通知值的哪些位bit?通知状态不是”pending”的情况下,才会清除。 其本意是:我想等待某些事件发生,故先把”旧数据”的某些位清零。 能清零的话:通知值 = 通知值 & ~(ulBitsToClearOnEntry) 比如传 0x01,表示清除通知值的 bit0;传入0xffffffff即ULONG_MAX,表示清除所有位,即把通知值设置为 0 |
| ulBitsToClearOnExit | 在xTaskNotifyWait()出口处,如果不是因为超时推出,而是因为得到了数据而退出时:通知值 = 通知值 & ~(ulBitsToClearOnExit)也即在函数退出前清除指定通知位,在清除某些位之前,通知值先被赋给 *pulNotificationValue比如传入 0x03,表示清除通知值的 bit0、bit1;传入0xffffffff即ULONG_MAX,表示清除所有位,即把通知值设置为 0 |
| pulNotificationValue | 存储接收到的通知值,也即在函数退出时,使用ulBitsToClearOnExit清除之前,把通知值赋给pulNotificationValue。如果不需要取出通知值,可以设为 NULL。???这个值有待商讨 |
| xTicksToWait | 任务进入阻塞态的超时时间,它在等待通知状态变为”pending”。 0:不等待,即刻返回; portMAX_DELAY:一直等待,直到通知状态变为”pending”; 其他值:Tick Count,可以用 pdMS_TO_TICKS()把ms转换为Tick Count |
12.3 示例: 传输计数值
本节源码是 FreeRTOS_22_tasknotify_tansfer_count,基于FreeRTOS_13_semaphore_circle_buffer 修改。
本程序创建2个任务:
- 发送任务:把数据写入唤醒缓冲区,使用
xTaskNotifyGive()让通知值加一 - 接收任务:使用
ulTaskNotifyTake()取出通知值,这表示字符数,打印字符
1 | int main() |
发送任务、接收任务的代码和执行流程如下:
- A:发送任务优先级最高,先执行。连续存入3个字符、发出3次任务通知:通知值累加为3
- B:发送任务阻塞,让接收任务能执行
- C:接收任务读到通知值为3,并把通知值清零
- D:把3个字符依次读出、打印
- E:再次读取任务通知,阻塞
运行结果如下图所示:
本程序使用xTaskNotifyGive/ulTaskNotifyTake实现了轻量级的计数型信号量,代码更简单:
- 无需创建信号量
- 消耗内存更少
- 效率更高
信号量是个公开的资源,任何任务、ISR都可以使用它:可以释放、获取信号量。而本节程序中,发送任务只能给指定的任务发送通知,目标明确;接收任务只能从自己的通知值中得到数据,来源明确。
12.4 示例: 传输任意值
本节源码是FreeRTOS_23_tasknotify_tansfer_value。在上述例子中使用任务通知来传输计数值、传输通知。
本节程序使用任务通知来传输任意数据,它创建2个任务:
- 发送任务:把数据通过
xTaskNotify()发送给其他任务 - 接收任务:使用
xTaskNotifyWait()取出通知值,这表示字符,并打印出来
1 | int main( void ) |
发送任务、接收任务的代码和执行流程如下:
- A:发送任务优先级最高,先执行。连续给对方任务发送3个字符,只成功了1次
- B:发送任务阻塞,让接收任务能执行
- C:接收任务读取通知值
- D:把读到的通知值作为字符打印出来
- E:再次读取任务通知,阻塞
运行结果如下图所示:
本程序使用xTaskNotify()/xTaskNotifyWait()实现了轻量级的队列(该队列长度只有1),代码更简单:
- 无需创建队列
- 消耗内存更少
- 效率更高
队列是个公开的资源,任何任务、ISR都可以使用它:可以存入数据、取出数据。而本节程序中,发送任务只能给指定的任务发送通知,目标明确;接收任务只能从自己的通知值中得到数据,来源明确。
注意:任务通知值只有一个,数据可能丢失,设计程序时要考虑这点。
13 软件定时器(software timer)
软件定时器就是”闹钟”,你可以设置闹钟,比如:
- 在30 分钟后让你起床工作
- 每隔 1 小时让你例行检查机器运行情况
软件定时器也可以完成两类事情:
- 在”未来”某个时间点,运行函数
- 周期性地运行函数
日常生活中我们可以定无数个”闹钟”,这无数的”闹钟”要基于一个真实的闹钟。 在FreeRTOS里,我们也可以设置无数个”软件定时器”,它们都是基于系统滴答中断(Tick Interrupt)。 软件定时器就是在Tick Interrupt中被调用的。
1 | void Tick_Interrupt() |
13.1 软件定时器的特性
我们在手机上添加闹钟时,需要指定时间、指定类型(一次性的,还是周期性的)、指定做什么事;还有一些过时的、不再使用的闹钟。如下图所示:
使用定时器跟使用手机闹钟是类似的,软件定时器的本质就是一个结构体,此结构体中包含的内容有:
- 指定时间:启动定时器和运行回调函数,两者的间隔被称为定时器的周期(period)。
- 指定类型,定时器有两种类型:
- 一次性(One-shot timers):这类定时器启动后,它的回调函数只会被调用一次;可以手工再次启动它,但是不会自动启动它。
- 自动加载定时器(Auto-reload timers):这类定时器启动后,时间到之后它会自动启动它; 这使得回调函数被周期性地调用。
- 指定要做什么事,就是指定回调函数
- 回调函数的参数
- 一个链表,此链表用来串联多个定时器,先到中断的定时器排在前面
- 比如一下子定义了3个软件定时器,定时器A在2个Tick后被调用,定时器B在10个Tick后被调用,定时器C在5个Tick后被调用,那么链表的结构就是:定时器A ➡ 定时器C ➡ 定时器B。
实际的闹钟分为:有效、无效两类。软件定时器也是类似的,它有两种状态:
- 运行(Running、Active):运行态的定时器,当指定时间到达之后,它的回调函数会被调用
- 冬眠(Dormant):冬眠态的定时器还可以通过句柄来访问它,但是它不再运行,它的回调函数不会被调用
定时器运行情况示例如下:
- Timer1:它是一次性的定时器,在 t1 启动,周期是6个Tick。经过6个tick后,在 t7 执行回调函数。它的回调函数只会被执行一次,然后该定时器进入冬眠状态。
- Timer2:它是自动加载的定时器,在 t1 启动,周期是5个Tick。每经过5个tick它的回调函数都被执行,比如在 t6、t11、t16 都会执行。
13.2 软件定时器的上下文
13.2.1 守护任务(Daemon Task)
要理解软件定时器API函数的参数,特别是里面的xTicksToWait(),需要知道定时器执行的过程。 FreeRTOS中有一个Tick中断,软件定时器基于Tick来运行。在哪里执行定时器函数?第一印象就是在Tick中断里执行:
- 在Tick 中断中判断定时器是否超时
- 如果超时了,调用它的回调函数
FreeRTOS是RTOS,它不允许在内核、在中断中执行不确定的代码:如果定时器函数很耗时,会影响整个系统。所以FreeRTOS不在Tick中断中执行定时器函数。
那在哪里执行呢?在某个任务里执行,这个任务就是:RTOS Damemon Task,RTOS守护任务。以前被称为“Timer server”,但是这个任务要做并不仅仅是定时器相关,所以改了名称。
当FreeRTOS的配置项configUSE_TIMERS被设置为1时,在启动调度器时,会自动创建RTOS Damemon Task。 我们自己编写的任务函数要使用定时器时,是通过“定时器命令队列”(timer command queue)和守护任务交互,如下图所示:
prvTimerTask是FreeRTOS中软件定时器的守护任务(Daemon Task)函数,其核心作用如下:
- 核心功能
- 定时器管理中枢
作为所有软件定时器的统一管理者,负责处理定时器的创建、启动、停止、删除等命令队列。- 超时检测与回调
持续检查定时器链表,当检测到定时器超时时调用对应的回调函数。- 周期模式处理
对于周期定时器,自动重新计算下次触发时间并重置定时器。
- 运行机制
- 任务启动时机
在vTaskStartScheduler()中自动创建(需配置configUSE_TIMERS=1)。- 工作流程
- 从命令队列接收定时器操作请求(如
xTimerStart()发送的命令)- 维护定时器链表,按超时时间排序
- 通过
vTaskDelay()阻塞等待最近超时的定时器。- 优先级要求
通常配置为最高优先级(configTIMER_TASK_PRIORITY),确保及时响应
其中,守护任务的优先级为:configTIMER_TASK_PRIORITY ;定时器命令队列的长度为configTIMER_QUEUE_LENGTH。
13.2.2 守护任务的调度
守护任务的调度,跟普通的任务并无差别。当守护任务是当前优先级最高的就绪态任务时,它就可以运行。它的工作有两类:
- 处理命令:从命令队列里取出命令、处理
- 执行定时器的回调函数
能否及时处理定时器的命令、能否及时执行定时器的回调函数,严重依赖于守护任务的优先级。下面使用 2 个例子来演示。
例子1:守护任务的优先性级较低
- t1:Task1 处于运行态,守护任务处于阻塞态。
- 守护任务在这两种情况下会退出阻塞态切换为就绪态:命令队列中有数据、某个定时器超时了。
- 至于守护任务能否马上执行,取决于它的优先级。
- t2:Task1 调用
xTimerStart()- 要注意的是,
xTimerStart()只是把”start timer”的命令发给”定时器命令队列”,使得守护任务退出阻塞态。 - 在本例中,Task1 的优先级高于守护任务,所以守护任务无法抢占Task1。
- 要注意的是,
- t3:Task1 执行完
xTimerStart()- 但是定时器的启动工作由守护任务来实现,所以
xTimerStart()返回并不表示定时器已经被启动了。
- 但是定时器的启动工作由守护任务来实现,所以
- t4:Task1 由于某些原因进入阻塞态,现在轮到守护任务运行。
- 守护任务从队列中取出”start timer”命令,启动定时器。
- t5:守护任务处理完队列中所有的命令,再次进入阻塞。Idel任务是优先级最高的就绪任务,它执行。
- 注意:假设定时器在后续某个时刻 tX 超时了,超时时间是”tX-t2”,而非”tX-t4”,从
xTimerStart()函数被调用时算起。
- t1:Task1 处于运行态,守护任务处于阻塞态。
例子2:守护任务的优先性级较高
- t1:Task1 处于运行态,守护任务处于阻塞态。
- 守护任务在这两种情况下会退出阻塞态切换为就绪态:命令队列中有数据、某个定时器超时了。
- 至于守护任务能否马上执行,取决于它的优先级。
- t2:Task1 调用
xTimerStart()- 要注意的是,
xTimerStart()只是把”start timer”的命令发给”定时器命令队列”,使得守护任务退出阻塞态。 - 在本例中,守护任务的优先级高于Task1,所以守护任务抢占Task1,守护任务开始处理命令队列。
- Task1 在执行
xTimerStart()的过程中被抢占,这时它无法完成此函数。
- 要注意的是,
- t3:守护任务处理完命令队列中所有的命令,再次进入阻塞态。
- 此时Task1 是优先级最高的就绪态任务,它开始执行。
- t4:Task1 之前被守护任务抢占,对
xTimerStart()的调用尚未返回。现在开始继续运行次函数、返回。 - t5:Task1 由于某些原因进入阻塞态,进入阻塞态。Idel 任务时优先级最高的就绪态任务,它执行。
- t1:Task1 处于运行态,守护任务处于阻塞态。
注意,定时器的超时时间是基于调用xTimerStart()的时刻tX,而不是基于守护任务处理命令的时刻tY。假设超时时间是10个Tick,超时时间是”tX+10”,而非”tY+10”。
13.2.3 回调函数
用户自定义的定时器回调函数_ATimerCallback()是FreeRTOS中定时器到期时自动触发的回调函数,属于用户自定义的定时器中断处理逻辑。当软件定时器(由 xTimerCreate() 创建)到达预设周期时,系统prvTimerTask()会自动调用该函数执行预定操作。
定时器的回调函数的原型如下:
1 | /** |
定时器的回调函数是在守护任务中被调用的,守护任务不是专为某个定时器服务的,它还要处理其他定时器。所以,定时器的回调函数不要影响其他人:
- 回调函数要尽快实行,不能进入阻塞状态
- 不要调用会导致阻塞的API函数,比如
vTaskDelay() - 可以调用
xQueueReceive()之类的函数,但是超时时间要设为 0:即刻返回,不可阻塞
13.2.4 守护任务的一个额外用途
中断时间过长不行,因此将硬件中断处理的较为复杂、耗时的工作交给一个单独的任务来做当然顺理成章,不过 FreeRTOS 还提供了一种机制,可以免去创建单独的任务。这就是借助系统的守护任务(Daemon Task)。
xTimerPendFunctionCallFromISR()函数将一个普通函数作为参数“提交”给系统服务,让系统自带的Daemon Task执行这个函数。提交时一并指定两个参数传递给这个函数。Daemon Task受调度器管理,其任务优先级由configTIMER_TASK_PRIORITY指定。Daemon Task何时执行提交的函数,就要看系统是否空闲了,当它获得执行机会时,就会从命令队列里面取出要执行的函数入口地址和参数去执行。
1 | /** |
FreeRTOS的Event Group实现就借用了Daemon Task来处理 ISR 中的操作,例如上面表中列出的 xEventGroupSetBitsFromISR()调用。手册叙述的原因是这不是一个 “deterministic operation”(耗时可能过长)。在 event_groups.h 中定义了#define xEventGroupClearBitsFromISR(xEventGroup, uxBitsToClear)
xTimerPendFunctionCallFromISR(vEventGroupClearBitsCallback,
(void *) xEventGroup, (uint32_t)uxBitsToClear, NULL)
就这样把一个 FromISR 的调用延迟到 Daemon Task 中去执行普通版本调用了。
上图来自参考链接:干货 | FreeRTOS学习笔记——中断与任务切换-电子头条-EEWORLD电子工程世界
13.3 软件定时器的函数
根据定时器的状态转换图,就可以知道所涉及的函数:
13.3.1 创建软件定时器
要使用定时器,需要先创建它,得到它的句柄。 有两种方法创建定时器:动态分配内存、静态分配内存。函数原型如下:
1 | /** |
回调函数的类型是:
1 | /** |
使用 typedef 创建了 TimerCallbackFunction_t 类型,使代码可以这样使用:
1 | TimerCallbackFunction_t myCallback = ATimerCallback; // 将函数赋值给变量 |
13.3.2 删除软件定时器
动态分配的定时器,不再需要时可以删除掉以回收内存。删除函数原型如下:
1 | /** |
定时器的很多 API 函数,都是通过发送”命令”到命令队列,由守护任务来实现。 如果队列满了,”命令”就无法即刻写入队列。我们可以指定一个超时时间xTicksToWait,等待一会。
13.3.3 启动/停止软件定时器
启动定时器就是设置它的状态为运行态(Running、Active)。 停止定时器就是设置它的状态为冬眠(Dormant),让它不能运行。 涉及的函数原型如下:
1 | /** |
注意,这些函数的xTicksToWait表示的是,把命令写入命令队列的超时时间。命令队列可能已经满了,无法马上把命令写入队列里,可以等待一会。xTicksToWait不是定时器本身的超时时间,也不是定时器本身的”周期”。
创建定时器时,设置了它的周期(period)。xTimerStart()函数是用来启动定时器。假设调用xTimerStart()的时刻是tX,定时器的周期是n,那么在tX+n时刻定时器的回调函数被调用。
如果定时器已经被启动,但是它的函数尚未被执行,再次执行xTimerStart()函数相当于执行xTimerReset(),重新设定它的启动时间。
13.3.4 复位软件定时器
从定时器的状态转换图可以知道,使用xTimerReset()函数可以让定时器的状态从冬眠态转换为运行态,相当于使用xTimerStart()函数。
如果定时器已经处于运行态,使用xTimerReset()函数就相当于重新确定超时时间。假设调用xTimerReset()的时刻是tX,定时器的周期是n,那么tX+n就是重新确定的超时时间。 复位函数的原型如下:
1 | /** |
13.3.5 修改周期
从定时器的状态转换图可以知道,使用xTimerChangePeriod()函数,除了能修改它的周期外,还可以让定时器的状态从冬眠态转换为运行态。
修改定时器的周期时,会使用新的周期重新计算它的超时时间。假设调用xTimerChangePeriod()函数的时间tX,新的周期是n,则tX+n就是新的超时时间。 相关函数的原型如下:
1 | /** |
13.3.6 定时器 ID
定时器的结构体如下,里面有一项pvTimerID,它就是定时器 ID:
怎么使用定时器ID,完全由程序来决定:
- 可以用来标记定时器,表示自己是什么定时器
- 可以用来保存参数,给回调函数使用
它的初始值在创建定时器时由xTimerCreate()这类函数传入,后续可以使用这些函数来操作:
- 更新 ID:使用
vTimerSetTimerID()函数 - 查询 ID:查询
pvTimerGetTimerID()函数
这两个函数不涉及命令队列,它们是直接操作定时器结构体。
1 | /** |
13.4 示例: 一般使用
本节程序为 FreeRTOS_24_software_timer。 要使用定时器,需要做些准备工作:
1 | /* 1. 工程中添加 timer.c */ |
main函数中创建、启动了2个定时器:一次性的、周期
1 | static volatile uint8_t flagONEShotTimerRun = 0; |
这两个定时器的回调函数比较简单:
1 | static void vONEShotTimerFunc( TimerHandle_t xTimer ) |
逻辑分析仪如下图所示:
运行结果如下图所示:
13.5 示例: 消除抖动
本节程序为 FreeRTOS_25_software_timer_readkey。 在嵌入式开发中,我们使用机械开关时经常碰到抖动问题:引脚电平在短时间内反复变化。
怎么读到确定的按键状态?
- 连续读很多次,直到数值稳定:浪费 CPU 资源
- 使用定时器:要结合中断来使用
对于第 2 种方法,处理方法如下图所示,按下按键后:
- 在t1产生中断,这时不马上确定按键,而是复位定时器,假设周期时20ms,超时时间为”t1+20ms”
- 由于抖动,在 t2 再次产生中断,再次复位定时器,超时时间变为”t2+20ms”
- 由于抖动,在 t3 再次产生中断,再次复位定时器,超时时间变为”t3+20ms”
- 在”t3+20ms”处,按键已经稳定,读取按键值
main函数中创建了一个一次性的定时器,从来处理抖动;创建了一个任务,用来模拟产生抖动。代码如下:
1 | /*-----------------------------------------------------------*/ |
模拟产生按键:每个循环里调用3次xTimerReset,代码如下:
1 | void vEmulateKeyTask(void* pvParameters) |
定时器回调函数代码如下:
1 | static void vKeyFilteringTimerFunc(TimerHandle_t xTimer) |
在函数中多次调用xTimerReset(),只触发1次定时器回调函数,运行结果如下图所示:
14 中断管理(Interrupt Management)
在 RTOS 中,需要应对各类事件。这些事件很多时候是通过硬件中断产生,怎么处理中断呢?
假设当前系统正在运行Task1时,用户按下了按键,触发了按键中断。这个中断的处理流程如下:
- CPU会停下当前任务执行,转而跳到固定地址去执行代码,这个固定地址通常被称为中断向量,这个跳转是硬件实现的。
- 执行代码做什么?
- 保存现场:Task1 被打断,需要先保存 Task1 的运行环境,比如各类寄存器的值
- 分辨中断、调用处理函数(这个函数就被称为 ISR,interrupt service routine)
- 恢复现场:中断结束后,继续运行 Task1,或者运行其他优先级更高的任务
你要注意到,ISR是在内核中被调用的,ISR执行过程中用户的任务无法执行。ISR要尽量快,否则:
- 其他低优先级的中断无法被处理:实时性无法保证
- 用户任务无法被执行:系统显得很卡顿
如果这个硬件中断的处理,就是非常耗费时间呢?对于这类中断的处理就要分为2部分:
- ISR:尽快做些清理、记录工作,然后触发某个任务
- 某个任务:更复杂的事情放在任务中处理
所以:需要 ISR 和任务之间进行通信 。
要在FreeRTOS中熟练使用中断,有几个原则要先说明:
- FreeRTOS 把任务认为是硬件无关的,任务的优先级由程序员决定,任务何时运行由调度器决定
- ISR 虽然也是使用软件实现的,但是它被认为是硬件特性的一部分,因为它跟硬件密切相关
- 何时执行ISR?由硬件决定
- 哪个 ISR 被执行?由硬件决定
- ISR的优先级高于任务:即使是优先级最低的中断,其优先级也高于任务。任务只有在没有中断的情况下才能执行。
14.1 两套 API 函数
14.1.1 为什么需要两套 API
在任务函数中,我们可以调用各类 API 函数,比如队列操作函数:xQueueSendToBack()。但是在ISR中使用这个函数会导致问题,应该使用另一个函数:xQueueSendToBackFromISR(),它的函数名含有后缀”FromISR”,表示”从 ISR 中给队列发送数据”。
FreeRTOS中很多API函数都有两套:一套在任务中使用,另一套在ISR中使用。后者的函数名含有”FromISR”后缀。 为什么要引入两套API函数?
- 原因1:很多API函数会导致任务计入阻塞状态:
- 运行这个函数的任务进入阻塞状态
- 比如写队列时,如果队列已满,可以进入阻塞状态等待一会
- 原因2:ISR调用API函数时,ISR本身就不是”任务”,ISR不能进入阻塞状态
所以,在任务中、在 ISR 中,这些函数的功能是有差别的。
使用两套函数可以让程序更高效,但是也有一些缺点,比如你要使用第三方库函数时,即会在任务中调用第三方库函数,也会在ISR中调用它。这个第三方库函数用到了FreeRTOS的API函数,你无法修改库函数。这个问题可以解决:
- 把中断的处理推迟到任务中进行(Defer interrupt processing),在任务中调用库函数
- 尝试在库函数中使用”FromISR”函数:
- 在普通任务中、在ISR中都可以调用”FromISR”函数
- 反过来就不行,非”FromISR”函数无法在ISR中使用
- 第三方库函数也许会提供OS抽象层,自行判断当前环境是在任务还是在ISR中,分别调用不同的函数
14.1.2 两套API函数列表
14.1.3 pxHigherPriorityTaskWoken 参数
其实在前面写笔记的时候就发现了,基本上用在ISR中的函数都会有一个参数:pxHigherPriorityTaskWoken,这是一个传出参数,含义是:是否有更高优先级的任务被唤醒了。如果此参数为pdTRUE,那么就意味着有更高优先级的任务被唤醒,执行完ISR后要进行任务切换。
还是以写队列为例,任务A调用xQueueSendToBack()写队列,有几种情况发生:
- 队列满了,任务 A 阻塞等待,另一个任务 B 运行
- 队列没满,任务 A 成功写入队列,但是它导致另一个任务 B 被唤醒,任务B的优先级更高:任务 B 先运行
- 队列没满,任务 A 成功写入队列,即刻返回
可以看到,在任务中调用 API 函数可能导致任务阻塞、任务切换,这叫做上下文切换(context switch)。这个函数可能很长时间才返回,在函数的内部实现了任务切换。
为什么不在”FromISR”函数内部进行任务切换,而只是标记一下而已呢?为了效率!示例代码如下:
1 | // 某个ISR函数 |
ISR 中有可能多次调用”FromISR”函数,如果在”FromISR”内部进行任务切换,会浪费时间。解决方法是:
- 在”FromISR”中标记是否需要切换
- 在ISR返回之前再进行任务切换
示例代码如下:
1 | // 某个ISR函数 |
上述的例子很常见,比如 UART 中断:在 UART 的 ISR 中读取多个字符,发现收到回车符时才进行任务切换。
14.1.4 怎么切换任务
FreeRTOS 的 ISR 函数中,使用两个宏其中的一个进行任务切换:
1 | // 第一个宏 |
这两个宏做的事情是完全一样的,在老版本的FreeRTOS中,
- portEND_SWITCHING_ISR 使用汇编实现
- portYIELD_FROM_ISR 使用C语言实现
新版本都统一使用portYIELD_FROM_ISR。
1 | void XXX_ISR() |
14.2 中断的延迟处理
前面讲过,ISR要尽量快,否则:
- 其他低优先级的中断无法被处理:实时性无法保证
- 用户任务无法被执行:系统显得很卡顿
- 如果运行中断嵌套,这会更复杂,ISR越快执行约有助于中断嵌套
如果这个硬件中断的处理,就是非常耗费时间呢?对于这类中断的处理就要分为2部分:
- ISR:尽快做些清理、记录工作,然后触发某个任务
- 任务:更复杂的事情放在任务中处理
这种处理方式叫”中断的延迟处理”(Deferring interrupt processing),处理流程如下图所示:
- t1:任务1运行,任务2阻塞
- t2:发生中断,该中断的ISR函数被执行,任务1被打断
- ISR函数要尽快能快速地运行,它做一些必要的操作(比如清除中断),然后唤醒任务2
- t3:在创建任务时设置任务2的优先级比任务1高(这取决于设计者),所以ISR返回后,运行的是任务2,它要完成中断的处理。任务2就被称为”deferred processing task”,中断的延迟处理任务。
- t4:任务2处理完中断后,进入阻塞态以等待下一个中断,任务1重新运行
14.3 中断与任务间的通信
前面讲解过的队列、信号量、互斥量、事件组、任务通知等等方法,都可使用。 要注意的是,在ISR中使用的函数要有”FromISR”后缀。
15 资源管理(Resource Management)
在前面讲解互斥量时,引入过临界资源的概念。在前面课程里,已经实现了临界资源的互斥访问。 本章节的内容比较少,只是引入两个功能:屏蔽/使能中断、暂停/恢复调度器。
要独占式地访问临界资源,有2种方法:
- 公平竞争:比如使用互斥量,谁先获得互斥量谁就访问临界资源,这部分内容前面讲过。
- 强制占有:谁要跟我抢,我就灭掉谁:
- 中断要跟我抢?我屏蔽中断
- 其他任务要跟我抢?我禁止调度器,不运行任务切换
15.1 屏蔽中断
屏蔽中断有两套宏:任务中使用、ISR 中使用:
- 任务中使用:
taskENTER_CRITICA()、taskEXIT_CRITICAL() - ISR 中使用:
taskENTER_CRITICAL_FROM_ISR()、taskEXIT_CRITICAL_FROM_ISR()
15.1.1 在任务中屏蔽中断
在任务中屏蔽中断的示例代码如下:
1 | /* 在任务中,当前时刻中断是使能的,执行这句代码后,屏蔽中断 */ |
在taskENTER_CRITICA()、taskEXIT_CRITICAL()之间:
- 低优先级的中断被屏蔽了:优先级低于、等于
configMAX_SYSCALL_INTERRUPT_PRIORITY - 高优先级的中断可以产生:优先级高于
configMAX_SYSCALL_INTERRUPT_PRIORITY- 但是,这些高优先级的中断 ISR 里,不允许使用 FreeRTOS 的API 函数
任务调度依赖于中断、依赖于API 函数,所以:这两段代码之间,不会有任务调度产生
这套
taskENTER_CRITICA()、taskEXIT_CRITICAL()宏,是可以递归使用的,它的内部会记录嵌套的深度,只有嵌套深度变为0时,调用taskEXIT_CRITICAL()才会重新使能中断。
使用taskENTER_CRITICA()、taskEXIT_CRITICAL()来访问临界资源是很粗鲁的方法:
- 中断无法正常运行
- 任务调度无法进行
所以,这两个宏之间的代码要尽可能快速地执行。
15.1.2 在 ISR 中屏蔽中断
要使用含有”FROM_ISR”后缀的宏,示例代码如下:
1 | void vAnInterruptServiceRoutine( void ) |
在taskENTER_CRITICA_FROM_ISR()、taskEXIT_CRITICAL_FROM_ISR()之间:
- 低优先级的中断被屏蔽了:优先级低于、等于
configMAX_SYSCALL_INTERRUPT_PRIORITY - 高优先级的中断可以产生:优先级高于
configMAX_SYSCALL_INTERRUPT_PRIORITY- 但是,这些中断 ISR 里,不允许使用 FreeRTOS 的API 函数
- 任务调度依赖于中断、依赖于API 函数,所以:这两段代码之间,不会有任务调度产生
15.2 暂停调度器
如果有别的任务来跟你竞争临界资源,你可以把中断关掉:这当然可以禁止别的任务运行,但是这代价太大了。它会影响到中断的处理。
如果只是禁止别的任务来跟你竞争,不需要关中断,暂停调度器就可以了:在这期间,中断还是可以发生、处理。 使用下面这2个函数来暂停、恢复调度器:
1 | /** |
上面这2个函数,是可以递归使用的,它的内部会记录嵌套的深度,只有嵌套深度变为0时,调用xTaskResumeAll()才会重新使能中断。
注意,旧版本的Free RTOS中使用
vTaskSuspendScheduler()、xTaskResumeScheduler()这两个宏来实现调度器的暂停。
16 调试与优化
本节视频源码为:28_freertos_example_stats
16.1 调试
FreeRTOS 提供了很多调试手段:
- 打印
- 断言:configASSERT
- Trace
- Hook 函数(钩子/回调函数)
16.1.1 打印
printf:FreeRTOS工程里使用了microlib,里面实现了printf函数。
我们只需实现一下函数即可使用printf:
1 | int fputc( int ch, FILE *f ); |
16.1.2 断言
一般的C库里面,断言就是一个函数:
1 | // assert函数的作用是:确认expression必须为真,如果expression为假的话就中止程序。 |
但是在FreeRTOS里,使用configASSERT()来实现断言,比如:
1 |
我们可以让它提供更多信息,比如:
1 |
|
configASSERT(x)中,如果x为假,表示发生了很严重的错误,必须停止系统的运行。它用在很多场合,比如:
队列操作:
1 | BaseType_t xQueueGenericSend( QueueHandle_t xQueue, |
中断级别的判断:
1 | void vPortValidateInterruptPriority( void ) |
19.1.3 Trace
FreeRTOS中定义了很多trace开头的宏,这些宏被放在系统个关键位置。
它们一般都是空的宏,这不会影响代码:不影响编程处理的程序大小、不影响运行时间。
我们要调试某些功能时,可以修改宏:修改某些标记变量、打印信息等待。
| trace宏 | 描述 |
|---|---|
| traceTASK_INCREMENT_TICK(xTickCount) | 当tick计数自增之前此宏函数被调用。参数xTickCount当前的Tick值,它还没有增加。 |
| traceTASK_SWITCHED_OUT() | vTaskSwitchContext中,把当前任务切换出去之前调用此宏函数。 |
| traceTASK_SWITCHED_IN() | vTaskSwitchContext中,新的任务已经被切换进来了,就调用此函数。 |
| traceBLOCKING_ON_QUEUE_RECEIVE(pxQueue) | 当正在执行的当前任务因为试图去读取一个空的队列、信号或者互斥量而进入阻塞状态时,此函数会被立即调用。参数pxQueue保存的是试图读取的目标队列、信号或者互斥量的句柄,传递给此宏函数。 |
| traceBLOCKING_ON_QUEUE_SEND(pxQueue) | 当正在执行的当前任务因为试图往一个已经写满的队列或者信号或者互斥量而进入了阻塞状态时,此函数会被立即调用。参数pxQueue保存的是试图写入的目标队列、信号或者互斥量的句柄,传递给此宏函数。 |
| traceQUEUE_SEND(pxQueue) | 当一个队列或者信号发送成功时,此宏函数会在内核函数xQueueSend(),xQueueSendToFront(),xQueueSendToBack(),以及所有的信号give函数中被调用,参数pxQueue是要发送的目标队列或信号的句柄,传递给此宏函数。 |
| traceQUEUE_SEND_FAILED(pxQueue) | 当一个队列或者信号发送失败时,此宏函数会在内核函数xQueueSend(),xQueueSendToFront(),xQueueSendToBack(),以及所有的信号give函数中被调用,参数pxQueue是要发送的目标队列或信号的句柄,传递给此宏函数。 |
| traceQUEUE_RECEIVE(pxQueue) | 当读取一个队列或者接收信号成功时,此宏函数会在内核函数xQueueReceive()以及所有的信号take函数中被调用,参数pxQueue是要接收的目标队列或信号的句柄,传递给此宏函数。 |
| … 后面至少还有10个trace开头的宏 … | … |
16.1.4 Malloc Hook函数
编程时,一般的逻辑错误都容易解决。难以处理的是内存越界、栈溢出等。内存越界经常发生在堆的使用过程总:堆,就是使用malloc得到的内存。
并没有很好的方法检测内存越界,但是可以提供一些回调函数:
使用pvPortMalloc失败时,如果在FreeRTOSConfig.h里配置configUSE_MALLOC_FAILED_HOOK = 1,会调用:
1 | /** |
16.1.5 栈溢出Hook函数
在切换任务(vTaskSwitchContext)时调用taskCHECK_FOR_STACK_OVERFLOW来检测栈是否溢出,若溢出会调用:
1 | /** |
怎么判断栈溢出?有两种方法:
- 方法1:
- 当前任务被切换出去之前,它的整个运行现场都被保存在栈里,这时 很可能 就是它对栈的使用到达了峰值。
- 这方法很高效,但是并不精确
- 比如:任务在运行过程中调用了函数A大量地使用了栈,调用完函数A后才被调度。
- 方法2:
- 创建任务时,它的栈被填入固定的值,比如:0xa5
- 检测栈里最后16字节的数据,如果不是0xa5的话表示栈即将、或者已经被用完了
- 没有方法1快速,但是也足够快
- 能捕获 几乎所有 的栈溢出
16.2 优化
在Windows中,当系统卡顿时我们可以查看任务管理器找到最消耗CPU资源的程序。在FreeRTOS中,我们也可以查看任务使用CPU的情况、使用栈的情况,然后针对性地进行优化。
这就是查看”任务的统计“信息。
16.2.1 栈使用情况
在创建任务时分配了栈,可以填入固定的数值比如0xa5,以后可以使用以下函数查看”栈的高水位”,也就是还有多少空余的栈空间:
用于检测任务堆栈的“高水位线”(历史剩余最小值),反映任务运行过程中堆栈空间的最大使用量。该值越小,说明任务堆栈溢出的风险越高
1 | /** |
原理是:从栈底往栈顶逐个字节地判断,它们的值持续是0xa5就表示它是空闲的。
16.2.2 任务运行时间统计
对于同优先级的任务,它们按照时间片轮流运行:你执行一个Tick,我执行一个Tick。是否可以在Tick中断函数中,统计当前任务的累计运行时间?
不行!很不精确,因为有更高优先级的任务就绪时,当前任务还没运行一个完整的Tick就被抢占了。
我们需要比Tick更快的时钟,比如Tick周期时1ms,我们可以使用另一个定时器,让它发生中断的周期时0.1ms甚至更短。
使用这个定时器来衡量一个任务的运行时间,原理如下图所示:
- 切换到Task1时,使用更快的定时器记录当前时间T1
- Task1被切换出去时,使用更快的定时器记录当前时间T4
- (T4-T1)就是它运行的时间,累加起来
- 关键点:在 vTaskSwitchContext 函数中,使用 更快的定时器 统计运行时间
16.2.3 涉及的代码
- 配置
1 |
- 实现宏
portCONFIGURE_TIMER_FOR_RUN_TIME_STATS(),它用来初始化更快的定时器 - 实现这两个宏之一,它们用来返回当前时钟值(更快的定时器)
portGET_RUN_TIME_COUNTER_VALUE():直接返回时钟值portALT_GET_RUN_TIME_COUNTER_VALUE(Time):设置Time变量等于时钟值
代码执行流程:
- 初始化更快的定时器:启动调度器时
- 在任务切换时统计运行时间
获得统计信息,可以使用下列函数
uxTaskGetSystemState:对于每个任务它的统计信息都放在一个TaskStatus_t结构体里vTaskList:得到的信息是可读的字符串,比如vTaskGetRunTimeStats: 得到的信息是可读的字符串
16.2.4 函数说明
uxTaskGetSystemState:获得任务的统计信息
1 | /* TaskStatus_t结构体的内容 */ |
1 | /** |
vTaskList:获得任务的统计信息,形式为可读的字符串。注意,pcWriteBuffer必须足够大
需在FreeRTOSConfig.h中启用:
1 |
1 | /** |
vTaskGetRunTimeStats:获得任务的运行信息,形式为可读的字符串。注意,pcWriteBuffer必须足够大。
硬件依赖:需实现端口层接口portCONFIGURE_TIMER_FOR_RUN_TIME_STATS()和portGET_RUN_TIME_COUNTER_VALUE()。
1 | /** |
可读信息格式如下:































