本帖最后由 classroom 于 2024-6-12 10:41 编辑
1. 为什么需要任务间同步
实时操作系统中,每个任务都是一个独立的裸机程序,但是这些任务共享同一个CPU、内存空间、外设,操作系统如何解决这个问题呢?
1.1. 如何共享同一个CPU
任务是否在使用CPU,体现在该任务是否处于运行状态。
RTOS的内核采用“分时复用CPU”的思想,根据抢占式调度规则、时间片轮转调度规则、中断调度规则来安排系统中的任务分别在不同的时刻运行,这也是我们前面四篇文章中重点讲述的内容。
1.2. 如何共享同一块内存空间
每个任务的任务栈是独立的,所以每个任务运行的时候,在自己的栈空间内玩的很happy。
如果把整个内存空间看作一栋公寓的话,每个任务会分配有自己的一间房子,自己可以在里面尽情的玩耍,至于你干什么事,其它房间的人根本没法知道,也没必要知道。
在一些情况下,可能有一些特殊的内存空间,比如某一个全局变量、或者某一块全局变量数组,每个任务都可以访问并进行操作,堆空间也是如此,每个任务都可以使用malloc和free进行申请和释放。
这些内存空间统一称之为共享内存,它只有一个特性,既是优点也是缺点:「系统中所有的任务都可以随意访问和修改」。
映射到生活中,就类似于厨房、卫生间、浴室、杂物间这种共享房间,如果你在使用的时候别人要强行使用,岂不是乱套了~
如果加以规则和限制,共享内存的优点就可以被利用起来:
优点:因为任务可以随意访问修改,所以可以用作一些「标志位、计数量」,进而引申出信号量这个东东。
缺点:任务可以随意访问修改,所以用作共享数据缓冲区的时候,必须要对前来访问和修改的任务加以控制,保护数据不被随意篡改,进而引申出「互斥锁」这个东东。
至于堆空间,可以全部交由系统管理,由「系统提供统一的malloc和free接口」,轻松解决,将在后续文章中重点讲解。
1.3. 如何共享同一个外设
因为嵌入式系统的特殊性,系统中还存在大量外设,比如GPIO(包括用GPIO模拟各种软件协议的情况)、串口、I2C接口、SPI接口等。
所有的任务都可以随意的使用这些外设,在task1使用I2C接口操作OLED时,高优先级任务task2因为一定原因突然唤醒,抢占task1,如果task2也通过I2C接口操作OLED,那么在回到task1执行时,肯定无法正常显示内容了(之前与OLED的通信数据全部丢失)。
在操作系统层面,必须要对外设的使用加以权限,即:「当一个任务在使用外设的时候,其它任务无法使用,必须要等该任务使用结束后,其它任务方可正常使用」。
显然,系统管理所有的外设资源就是在开国际玩笑,系统只需提供「互斥锁机制」,对外设加权限的操作留给开发者,简单方便。
2. pend-post机制
共享内存(eg. 全局变量)的特性既是优点也是缺点:系统中所有的任务都可以随意访问和修改。
「在全局变量的基础上,系统添加一些额外的机制和数据成员,封装为结构体,就是RTOS内核中用于任务间通信的所有东西」,包括:信号量、互斥锁、事件、完成量、计数锁、栅栏,这些东西提供给上层应用的功能都不相同,各有各的特点,供应用在不同的情况下选择使用。
种类很多,但是都具有一个不变的机制:pend-post机制。TencentOS-tiny中的实现在 pend.h 和 pend.c 中。
2.1. 等待列表对象
先不管它是干嘛的,知道它长什么样就好,就是一个双向链表节点:
typedef struct pend_object_st {
k_list_t list;
} pend_obj_t;
2.2. pend等待机制实现
pend的英文释义为“等候判决”,我觉得非常贴切,实现机制也很简单,当任务需要等待这个量时,就挂载到「这个量的等待列表对象上」,源码如下:
__KNL__ void pend_task_block(k_task_t *task, pend_obj_t *object, k_tick_t timeout)
{
readyqueue_remove(task);
task->pend_state = PEND_STATE_NONE;
pend_list_add(task, object);
if (timeout != TOS_TIME_FOREVER) {
tick_list_add(task, timeout);
}
}
❝
如果不是阻塞等待的话,则同时将任务挂载到延时列表上,到点自动唤醒,延时列表在上一篇文章中已经详细讲述。
❞
① 「任务如何挂载到等待列表上?」
在任务控制块中有一个数据成员
/**
* task control block
*/
struct k_task_st {
//……不相关的都省略了
k_list_t pend_list; /**< when we are ready, our pend_list is in readyqueue; when pend, in a certain pend object's list. */
pend_obj_t *pending_obj; /**< if we are pending, which pend object's list we are in? */
pend_state_t pend_state; /**< why we wakeup from a pend */
};
其中双向链表节点pend_list在任务就绪的时候挂载到就绪列表上,在任务等待某一个任务同步量时挂载到等待列表上。
② 「等待列表上的任务具有有优先级吗?」
假设同时有5个任务都在等待同一个互斥锁,一旦互斥锁被释放,按照优先级抢占式调度的规则,肯定是唤醒5个任务里优先级最高的那个任务执行。
TencentOS-tiny中进行了算法优化,在向等待列表上插入任务时,「是按照优先级从高到低排布的」,这样唤醒任务时,***把最头部的任务拉起就OK,实现源码如下:
__STATIC__ void pend_list_add(k_task_t *task, pend_obj_t *pend_obj)
{
k_task_t *iter;
/* keep priority in descending order, the boss(task with highest priority,
numerically smallest) always comes first
*/
TOS_LIST_FOR_EACH_ENTRY(iter, k_task_t, pend_list, &pend_obj->list) {
if (task->prio < iter->prio) {
break;
}
}
tos_list_add_tail(&task->pend_list, &iter->pend_list);
// remember me, you may use me someday
task->pending_obj = pend_obj;
task_state_set_pend(task);
}
❝
知道任务是按照优先级从高到低排列的就行,看不懂源码实现没有影响。
❞
2.3. post唤醒机制的实现
当任务同步量被post时,将该任务同步量的等待列表上优先级最高的任务(等待列表上第一个任务)唤醒。
唤醒机制也很简洁,将任务控制块从等待列表上移除,从延时列表中移除,加入到就绪列表中,源码如下:
__KNL__ void pend_task_wakeup(k_task_t *task, pend_state_t state)
{
if (task_state_is_pending(task)) {
// mark why we wakeup
task->pend_state = state;
pend_list_remove(task);
}
if (task_state_is_sleeping(task)) {
tick_list_remove(task);
}
if (task_state_is_suspended(task)) {
return;
}
readyqueue_add(task);
}
需要注意,在调用此API的时候,「state参数用来指示任务被唤醒的原因」,供上层应用程序使用,存储在任务控制块的pend_state成员中,有以下任务状态值:
/**
* The reason why we wakeup from a pend.
* when we wakeup, we need to know why.
*/
typedef enum pend_state_en {
PEND_STATE_NONE, /**< nothing. */
PEND_STATE_POST, /**< someone has post, we get what we want. */
PEND_STATE_TIMEOUT, /**< a post has never came until time is out. */
PEND_STATE_DESTROY, /**< someone has destroyed what we are pending for. */
PEND_STATE_OWNER_DIE, /**< the pend object owner task is destroyed. */
} pend_state_t;
3. 丰富的任务同步量
TencentOS-tiny中提供了非常丰富的任务同步量,适合于各种各样的情况,本文不讲述具体有哪些API以及使用demo,需要阅读的话请移步TencentOS-tiny仓库中的doc目录下的文档:04-TencentOS tiny开发指南。
本文只讲述这些任务同步量在全局变量+pend-post机制的基础上,封装了哪些东西以实现不同的功能。
3.1. 信号量
信号量控制块如下:
typedef struct k_sem_st {
#if TOS_CFG_OBJECT_VERIFY_EN > 0u
knl_obj_t knl_obj;
#endif
pend_obj_t pend_obj;
k_sem_cnt_t count;
k_sem_cnt_t count_max;
} k_sem_t;
可以看到其中多了count成员和count_max成员,有什么用呢?
① 提供二值信号量功能
将count_max设为1,信号量的count值就只有0和1,所以称为二值信号量,通常都是当做一个flag来用。
② 提供资源计数功能
将count_max设为最大计数值,count就表示当前计数值,可以对资源进行计数,pend成功count会-1,post成功之后count会+1,比如典型的生产者与消费者的例子。
3.2. 互斥锁
互斥锁控制块的源码如下:
typedef struct k_mutex_st {
#if TOS_CFG_OBJECT_VERIFY_EN > 0u
knl_obj_t knl_obj;
#endif
pend_obj_t pend_obj;
k_nesting_t pend_nesting;
k_task_t *owner;
k_prio_t owner_orig_prio;
k_list_t owner_anchor;
} k_mutex_t;
其中pend_nesting成员表示互斥锁是上锁还是解锁状态,同时还表示互斥锁嵌套值,「防止发生互斥锁嵌套」(已经pend成功互斥锁了,还要再次pend),是一个整型:
typedef uint8_t k_nesting_t;
owner成员用来记录获取到互斥锁的任务,使得只有获取到互斥锁的那个任务才可以释放互斥锁,其它任务不行。
owner_anchor成员是一个双向链表节点,因为一个可以同时使用多个锁,所以此成员用来「挂载到」任务控制块的 mutex_own_list 列表上。
owner_orig_prio成员记录互斥锁拥有者的任务优先级,用于防止优先级翻转。
任务优先级翻转其实就一句话(虽然有点长):
任务优先级 A > B > C(最低),当A在挂起等待C释放任务同步量时,结果C由于某种原因被B抢占,导致C释放不了任务同步量,进而导致A得不到任务同步量,造成「低优先级的任务B在跑,而高优先级的A却在等」,在实时系统中这可能会导致非常严重的后果,都是这个B干的好事~
所以「当任务获取到互斥锁之后,会暂时将优先级提到最高,以防止被其它任务打断,释放锁之后恢复原来的优先级」。用户编写的时候也应该尽快的使用完互斥锁并释放。
3.3. 事件
事件控制块的源码如下:
typedef struct k_event_st {
#if TOS_CFG_OBJECT_VERIFY_EN > 0u
knl_obj_t knl_obj;
#endif
pend_obj_t pend_obj;
k_event_flag_t flag;
} k_event_t;
相比之下,只多了一个数据成员flag,用来记录事件标志,表示事件发生与否:
typedef uint32_t k_event_flag_t;
一个事件中包含了一个旗标,这个旗标的每一位表示一个“事件”,flag最大可以标志32个事件。
同时,一个任务可以等待一个或者多个“事件”的发生,其他任务在一定的业务条件下可以通过写入特定“事件”唤醒等待此“事件”的任务,实现一种类似信号的编程范式。
3.4. 完成量
完成量控制块的源码如下:
typedef struct k_completion_st {
#if TOS_CFG_OBJECT_VERIFY_EN > 0u
knl_obj_t knl_obj;
#endif
pend_obj_t pend_obj;
completion_done_t done;
} k_completion_t;
其中添加了done成员,用以标志是否完成:
typedef uint16_t completion_done_t;
完成量相对来说,是一种简单的任务间通信机制,用以在任务间同步某一事件是否已“完成”的信息。
3.5. 计数锁
计数锁控制块的源码如下:
typedef struct k_countdownlatch_st {
#if TOS_CFG_OBJECT_VERIFY_EN > 0u
knl_obj_t knl_obj;
#endif
pend_obj_t pend_obj;
k_countdownlatch_cnt_t count;
} k_countdownlatch_t;
其中添加了一个count成员,用于存放计数值。
计数锁提供了一种“计数信息”同步的概念,计数锁创建的时候会指定一个计数值,每当有任务执行tos_countdownlatch_post时,该计数锁的计数值减一,直到计数锁的计数值为零时,等待此计数锁的任务才会被唤醒。
3.6. 栅栏
栅栏控制块的源码如下:
typedef struct k_barrier_st {
#if TOS_CFG_OBJECT_VERIFY_EN > 0u
knl_obj_t knl_obj;
#endif
pend_obj_t pend_obj;
k_barrier_cnt_t count;
} k_barrier_t;
其中添加的数据成员count表示计数值。
栅栏提供了一种设置任务阻塞屏障的机制,栅栏创建的时候会指定一个计数值,每当有任务执行tos_barrier_pend时,该计数锁的计数值减一,直到计数锁的计数值为零时,所有阻塞在tos_barrier_pend点上的任务才可以往下运行。
4. 总结
按照惯例,对本文的内容作以回顾。
本文主要讲述了RTOS内核中,各种任务同步量的实现多种多样,万变不离其宗,本文中讲述了两个“宗”。
① 全局变量具有可以被所有任务访问、修改的特性。
② 每个任务同步量都有「自己的等待列表」,用来挂载当前等待该任务同步量的所有任务,这也是pend-post机制实现的核心,同时需要注意,在只唤醒一个任务时,「唤醒的是等待列表中优先级最高的任务」。
基于这两个“宗”,加上不同的数据成员就实现了丰富的任务同步量,其中信号量、互斥锁、事件比较常用,比较如下:
信号量:任何任务都可以获取、释放,可以用作「二值」信号标志,也可以用作计数;
互斥锁:任何任务都可以获取,但「只能由拥有者释放」,并且在获取锁之后将拥有者的优先级提至最高,防止优先级翻转,用作外设的保护和共享内存的保护;
事件:任何任务都可以获取、释放,用作标志位,「最大支持32个事件标志」;
其余的一些,看你的选择来用了~
最后还有一点,在创建这些任务同步量控制块时,都是「全局变量」哦,这些各种各样的任务同步量只是在普通全局变量的基础上扩展了功能,如果你的场景可以直接用普通全局变量解决,Why not?怎么方便怎么来!
本期文章就到这里啦,如果以后再遇到有国家二级抬杠运动员,非要和你杠,有全局变量不用为什么要用这些任务同步量,请把本文甩给他!
|