承接上篇:rtos原理(上):调度(基于cortex-M3内核)_ch tmos源码是实时的吗-CSDN博客
上一篇以cortex-M3为例子介绍freertos的os启动,调度过程等,理解mcu在如何启动os,如何实现从一个"while(1)"到多个"while(1)"并发,既然多线程能让业务“隔离”开来,那他们之间又是如何通信?
一、有os和无os的风格区别
以一个简单的业务为例子,对比在逻辑和rtos下实现的方式区别,有两个个业务:
1、业务1:扫描按键,检测按键的单击,双击等,
2、业务2:检测按键状态发生变化后,打印按键变化的情况
1.1、无os
简单的伪代码如下:
================================== app_key.c ===============================
static key_status g_status;
/* 按键扫描业务,记录当前最新的按键状态,例如当前按键触发双击也能查询到 */
void key_scan_process(key_status* status)
{
...
*status = g_status;
...
}
============================ app_print.c ================================================
app_print.c
static key_status g_status_print;
/* 打印当前按键最新的状态,有变化才打印 */
void key_status_print_process(key_status* status)
{
...
if(memcmp(status, g_status_print, sizeof(key_status))) {
key_status_print(&g_status_print)
}
...
}
============================== app_main.c ======================================
app_main.c
g_key_status;
void main(void)
{
...
while(1) {
key_scan_process(&g_key_status);
key_status_print_process(&g_key_status);
}
}
大概的内容是:
1、按键模块不停的去轮询io状态变化,然后有个结构体去记录当前按键状态是否变化。
2、打印按键状态的业务模块则查询如果按键状态变化的时候就打印变化的结果。
3、大循环则是循环调用这些模块
当然也有裸机代码写的比较复杂比较秀的,这里只是简单举例
1.2、有OS
同样的逻辑,有rtos的时候,这里可以对业务更好的模块化,定义好按键业务跟打印按键业务的通讯消息结构体,然后各管各的业务,
伪代码如下:
================================= app_key.c ==========================================
/* 信号量 */
os_sem_t g_key_sem;
/* 有按键io的状态发生变化,则会发布一个信号量触发一次扫描 */
static void key_act_callback(void* arg)
{
...
os_sem_give(&g_key_sem);
...
}
static void key_scan_thread(void* arg)
{
...
while(1)
{
os_sem_take(&g_key_sem, WAIT_FOREVER);
/* 扫描逻辑 */
key_scan(xx);
/* 如果逻辑检测到有新状态产生,那就发送一个消息出去 */
/* 数据内容可以是包含以下内容:哪个按键有变化?新状态是什么(单击、双击)? */
os_msg_send(xxx);
}
}
================================= app_print.c ==========================================
static void key_print_thread(void* arg)
{
...
while(1)
{
os_msg_rece(xxx, WAIT_FOREVER);
key_status_print_process(xxx);
}
}
大概的内容是:
1、按键状态发生变化的时候(可以是中断检测)发送信号量让按键扫描逻辑启动
2、按键扫描逻辑发现有新按键状态出现的时候则打包一个消息发送
3、打印按键状态的业务模块则根据实际收到的消息,打印出按键的状态
这里有个好处,可以看到在没动作的时候,系统没用户线程需要执行的时候,会执行idle线程,在一些低功耗的soc能降低功耗。
写再复杂一点,业务多的时候,例如还有个业务需要按键的状态,可以使用观察者模式,把按键业务增加一个订阅的函数,
哪些业务需要订阅按键状态,订阅即可,按键业务只管批量给各个模块发消息。
上面看到 key_print_thread 这个线程只有收到消息后才会解除阻塞,否则则一直挂起,那这里就是本文的重点,调用线程通讯接口的时候,
发生了什么,为什么接收方可以一直挂起知道收到才解除阻塞,发送方又是怎么发送?
二、freertos的线程通讯接口
这里以freertos为例子,通过freertos的通讯接口来介绍通讯是如何实现的。
有以下几个通讯接口,下面是对应的创建接口
事件组: xEventGroupCreate
二值信号量:vSemaphoreCreateBinary
计数信号量:xSemaphoreCreateCounting
互斥量: xSemaphoreCreateMutex
递归互斥量:xSemaphoreCreateRecursiveMutex
队列: xQueueCreate
队列集: xQueueCreateSet
这里不仔细介绍其对应的使用,官网或者网上能找到对应的使用例子。
其中 队列集、互斥量、递归互斥量、计数信号量、二值信号量都是通过对队列的接口进行扩展封装后得到;
而事件组的实现跟队列类似,对于一些简单的场景,不需要事件排队的情况,可以使用事件组,因为其且资源占用小(体现在创建的时候),逻辑简单(通过看代码可理解),所以效率也就最高(对于现在动不动就几十兆上百兆的mcu的主频,个人觉得差别不大)。
三、队列
3.1、队列的创建
//宏定义
#define xQueueCreate( uxQueueLength, uxItemSize ) xQueueGenericCreate( ( uxQueueLength ), ( uxItemSize ), ( queueQUEUE_TYPE_BASE ) )
参数说明:
uxQueueLength 代表创建的几个队列元素。
uxItemSize 则代表每个队列的元素多大。
例如你想这个队列用来传递结构体,且最多可能有10个这样的结构体在排队:
typedef struct
{
uint8_t key_id;
uint8_t key_opcode;
} key_status_t;
可以创建队列
xQueueCreate(10, sizeof(key_status_t))
对队列操作的主要是两个函数,这是queue.c的内部函数,prvCopyDataFromQueue,prvCopyDataToQueue。
顾名思义,一个是从队列里读数据,一个是写数据进队列,这里假设读者对队列这种结构有了解,不了解的自行百度。
prvCopyDataFromQueue 函数为了兼容各种queue的扩展类型例如互斥量,信号量,加了一些特殊的情况处理,例如互斥量会恢复持有线程的原优先级。
3.2、队列的接收
出于个人习惯下面注释里,把创建的队列然后得到的这个句柄xQueue 叫 “队列”,而当有线程调用了一次发送接口的时候,“队列”得到的东西我管它叫数据,代码为了方便阅读有删减。
/*
参数:
xQueue:队列的句柄,需要操作的具体队列
pvBuffer:需要读取队列里数据的缓存
xTicksToWait:如果队列里没数据的时候需要阻塞的时间,portMAX_DELAY则代表一直阻塞
xJustPeeking:为true则代表本次接收数据为peek操作,也就是只读取数据但是不消耗队列里的内容
*/
BaseType_t xQueueGenericReceive( QueueHandle_t xQueue, void * const pvBuffer, TickType_t xTicksToWait, const BaseType_t xJustPeeking )
{
BaseType_t xEntryTimeSet = pdFALSE;
TimeOut_t xTimeOut;
int8_t *pcOriginalReadPosition;
Queue_t * const pxQueue = ( Queue_t * ) xQueue;
//这是个死循环,方便一些需要“try again的逻辑”,这个函数不需要再执行了则会return
for( ;; )
{
//进入临界段
taskENTER_CRITICAL();
{
const UBaseType_t uxMessagesWaiting = pxQueue->uxMessagesWaiting;
//如果这个队列有数据待接收,例如有地方往这个队列发送了数据
if( uxMessagesWaiting > ( UBaseType_t ) 0 )
{
//记录队列被‘读’之前的索引,用于选择peek接收的情况下对它做复原,因为peek不会消耗队列里的数据
pcOriginalReadPosition = pxQueue->u.pcReadFrom;
//队列创建的时候有 uxItemSize 的才会去copydata,先验证队列本次读取有没有越界,没有则拷贝队列的数据到buffer
prvCopyDataFromQueue( pxQueue, pvBuffer );
//如果是非peek的操作
if( xJustPeeking == pdFALSE )
{
//非peek,这里减一,代表消耗了一个单位的队列数据
pxQueue->uxMessagesWaiting = uxMessagesWaiting - 1;
if( pxQueue->uxQueueType == queueQUEUE_IS_MUTEX )
{
// 互斥量的话接收对应的是上锁,需要记录持有这个锁的线程
pxQueue->pxMutexHolder = ( int8_t * ) pvTaskIncrementMutexHeldCount();
}
//如果有线程因为发送消息而挂起,那么现在将它退出阻塞状态,如果优先级高于当前线程的话会触发调度,得到cpu
if( listLIST_IS_EMPTY( &( pxQueue->xTasksWaitingToSend ) ) == pdFALSE )
{
//将xTasksWaitingToSend链表里挂着的最高优先级的线程退出阻塞状态,返回true代表这个线程优先级高于当前的
if( xTaskRemoveFromEventList( &( pxQueue->xTasksWaitingToSend ) ) != pdFALSE )
{
//执行这个函数将触发调度,优先级高的线程得到执行,本线程将被打断
queueYIELD_IF_USING_PREEMPTION();
}
}
}
else
{
//是peek操作,执行prvCopyDataFromQueue这个copy操作后会修改读索引,这里做一次复复原
pxQueue->u.pcReadFrom = pcOriginalReadPosition;
//数据保留在队列里,接下来看看有没有其他线程需要获取这个数据
if( listLIST_IS_EMPTY( &( pxQueue->xTasksWaitingToReceive ) ) == pdFALSE )
{
if( xTaskRemoveFromEventList( &( pxQueue->xTasksWaitingToReceive ) ) != pdFALSE )
{
//执行这个函数将触发调度,优先级高的线程得到执行,本线程将被打断
queueYIELD_IF_USING_PREEMPTION();
}
}
}
//退出临界段
taskEXIT_CRITICAL();
return pdPASS;
}
else
{
//本次调用读接口没数据可读,那可能需要阻塞调用该接口的线程,如果 xTicksToWait 为0代表不需要阻塞
if( xTicksToWait == ( TickType_t ) 0 )
{
//退出临界段 返回错误值 errQUEUE_EMPTY
taskEXIT_CRITICAL();
return errQUEUE_EMPTY;
}
else if( xEntryTimeSet == pdFALSE )
{
//到这里代表需要线程需要因为这个队列而阻塞了,更新一下线程全局的超时参数到xTimeOut,用于后面计算这个线程需要挂起多久
vTaskSetTimeOutState( &xTimeOut );
xEntryTimeSet = pdTRUE;
}
}
}
//退出临界段
taskEXIT_CRITICAL();
vTaskSuspendAll();
//队列锁,后面再解释
prvLockQueue( pxQueue );
//执行到这里判断是否需要因为没超时而阻塞线程
if( xTaskCheckForTimeOut( &xTimeOut, &xTicksToWait ) == pdFALSE )
{
// 再判断一次该队列里有没有收到数据,如果该队列没收到数据
if( prvIsQueueEmpty( pxQueue ) != pdFALSE )
{
//并且队列的类型的是互斥量类型
if( pxQueue->uxQueueType == queueQUEUE_IS_MUTEX )
{
taskENTER_CRITICAL();
{
//则需要提升持有这个互斥量的线程优先级,即‘优先级继承’,降低优先级反转的危害
vTaskPriorityInherit( ( void * ) pxQueue->pxMutexHolder );
}
taskEXIT_CRITICAL();
}
//把这个因为接收数据而导致即将挂起的线程挂到链表 xTasksWaitingToReceive 里,同时记录需要解除阻塞的时间 xTicksToWait
vTaskPlaceOnEventList( &( pxQueue->xTasksWaitingToReceive ), xTicksToWait );
//队列锁,后面再解释
prvUnlockQueue( pxQueue );
if( xTaskResumeAll() == pdFALSE )
{
//触发一次调度,本线程将执行完这个被挂起
portYIELD_WITHIN_API();
}
}
else
{
prvUnlockQueue( pxQueue );
//不需要阻塞线程,恢复调度器,这里没有返回,因为外层一个for循环会重新执行一遍
( void ) xTaskResumeAll();
}
}
else
{
prvUnlockQueue( pxQueue );
( void ) xTaskResumeAll();
if( prvIsQueueEmpty( pxQueue ) != pdFALSE )
{
//不需要阻塞线程,且队列还没有数据,就返回 errQUEUE_EMPTY
traceQUEUE_RECEIVE_FAILED( pxQueue );
return errQUEUE_EMPTY;
}
}
}
}
总结:循环处理下面两步
1、接收数据、解除因为该队列而阻塞线程
2、处理阻塞该线程的逻辑
3.3、队列的发送
/*
参数:
xQueue:队列的句柄,需要操作的具体队列
pvItemToQueue:需要发送到队列里数据的
xTicksToWait:如果队列里没位置填充数据的时候需要阻塞的时间,portMAX_DELAY则代表一直阻塞
xCopyPosition:填充的方向,默认是queueSEND_TO_BACK即往队列的末尾填充,
也可以选择往队列的头部填充选择queueSEND_TO_FRONT,例如队列里的数据为需要紧急发送的时候,类似插队操作
当队列满了的时候可以选择覆盖的操作,就是无论队列是否满都能执行写操作,在freertos只适用于队列里元素个数为1的情况
*/
BaseType_t xQueueGenericSend( QueueHandle_t xQueue, const void * const pvItemToQueue, TickType_t xTicksToWait, const BaseType_t xCopyPosition )
{
BaseType_t xEntryTimeSet = pdFALSE, xYieldRequired;
TimeOut_t xTimeOut;
Queue_t * const pxQueue = ( Queue_t * ) xQueue;
for( ;; )
{
taskENTER_CRITICAL();
{
// 队列里有'坑'可以填充发送的数据 或者 是因为覆盖写的方式发送数据
if( ( pxQueue->uxMessagesWaiting < pxQueue->uxLength ) || ( xCopyPosition == queueOVERWRITE ) )
{
// 拷贝数据到队列,如果是互斥量还需要检查是否需要复原优先级
// 互斥量的话这里相当于解锁动作,前面有高线程等待该互斥量的时候可能提升了获取该互斥量线程的优先级,
// prvCopyDataToQueue 里会执行 xTaskPriorityDisinherit 恢复线程为原优先级
xYieldRequired = prvCopyDataToQueue( pxQueue, pvItemToQueue, xCopyPosition );
// 用的是队列集,这里暂不说明
if( pxQueue->pxQueueSetContainer != NULL )
{
if( prvNotifyQueueSetContainer( pxQueue, xCopyPosition ) != pdFALSE )
{
queueYIELD_IF_USING_PREEMPTION();
}
}
else
{
// 如果有线程因为等待这个消息队列而挂起
if( listLIST_IS_EMPTY( &( pxQueue->xTasksWaitingToReceive ) ) == pdFALSE )
{
// 有线程在等待并且线程优先级比当前的更高将它移出这个等待链表并且触发一次调度,接收该队列数据而挂起的线程将得到执行,本线程将被打断
if( xTaskRemoveFromEventList( &( pxQueue->xTasksWaitingToReceive ) ) != pdFALSE )
{
queueYIELD_IF_USING_PREEMPTION();
}
}
else if( xYieldRequired != pdFALSE )
{
// 特殊情况,如果队列是互斥量类型 且在前面prvCopyDataToQueue 里执行了 xTaskPriorityDisinherit 才会执行到这里
// 这时候有更高优先级的线程解除阻塞,则需要触发一次调度,本线程将被打断
queueYIELD_IF_USING_PREEMPTION();
}
}
taskEXIT_CRITICAL();
return pdPASS;
}
else
{
// 队列里没'坑'了,并且当前队列不需要发送阻塞,则马上返回一个errQUEUE_FULL
if( xTicksToWait == ( TickType_t ) 0 )
{
taskEXIT_CRITICAL();
return errQUEUE_FULL;
}
else if( xEntryTimeSet == pdFALSE )
{
// 默认就是走到这里,线程接下来可能需要阻塞,更新这个 xTimeOut 结构体后面方便计算超时挂起时间
vTaskSetTimeOutState( &xTimeOut );
xEntryTimeSet = pdTRUE;
}
}
}
taskEXIT_CRITICAL();
vTaskSuspendAll();
prvLockQueue( pxQueue );
// 执行到这里判断是否需要因为没超时而阻塞线程
if( xTaskCheckForTimeOut( &xTimeOut, &xTicksToWait ) == pdFALSE )
{
// 队列还是满了 接下来需要阻塞线程
if( prvIsQueueFull( pxQueue ) != pdFALSE )
{
//把这个因为发送数据而导致即将挂起的线程挂到链表 xTasksWaitingToSend 里,同时记录需要解除阻塞的时间 xTicksToWait
vTaskPlaceOnEventList( &( pxQueue->xTasksWaitingToSend ), xTicksToWait );
prvUnlockQueue( pxQueue );
if( xTaskResumeAll() == pdFALSE )
{
//触发一次调度,本线程将执行完这个被挂起
portYIELD_WITHIN_API();
}
}
else
{
// 继续执行这个for循环
prvUnlockQueue( pxQueue );
( void ) xTaskResumeAll();
}
}
else
{
/* 返回 errQUEUE_FULL,例如是因为设置了超时时间,然后时间到了执行到了这里 */
prvUnlockQueue( pxQueue );
( void ) xTaskResumeAll();
traceQUEUE_SEND_FAILED( pxQueue );
return errQUEUE_FULL;
}
}
}
总结:和接收数据类似,也是循环处理以下两步:
1、发送数据、解除因为该队列而阻塞线程
2、处理阻塞该线程的逻辑
3.4、发送和接收的例子
下图根据上面的队列接收发送接口,描述一个情况:
线程B的优先级低于线程A,然后线程A调用队列的接收接口,但是队列里没有数据,因此线程A被阻塞,线程B调用了队列的发送接口,队列A得到了队列的数据后开始从挂起处继续执行
需要注意的是,在上面3.2和3.3,描述发送跟接收的总结那,发送跟接收的第一步跟第二步的区别是,第一步处于临界段处理,第二步则是挂起全部线程第二步但是不处于临界段,则代表只是不会当前线程在恢复全部线程之前不会被打断保证其他线程当前不会操作这个队列,但是其他的例如中断是可以操作这个队列的。
3.5、队列锁
上面贴的队列发送和接收的代码里,有如下两个函数:
prvLockQueue和 prvUnlockQueue,它们对 xQUEUE 里定义的两个变量做操作:
volatile int8_t cRxLock; /*< Stores the number of items received from the queue (removed from the queue) while the queue was locked. Set to queueUNLOCKED when the queue is not locked. */
volatile int8_t cTxLock; /*< Stores the number of items transmitted to the queue (added to the queue) while the queue was locked. Set to queueUNLOCKED when the queue is not locked. */
这里管他叫Rx锁和Tx锁,由注释可知道它们的作用是用于记录当 该队列被锁住的时间内该队列被执行了多少次发送或者接收操作,发送和接收分别对应TX锁和RX锁。
前面总结队列的发送数据和接收数据的接口,由代码可知,第二步的逻辑处理之前是挂起全部线程,然后调用了 prvLockQueue( pxQueue )然后离开这一步之前会调用 prvUnlockQueue 然后再恢复调度器。
这两个锁默认的值是 queueUNLOCKED(-1),当执行 prvLockQueue 的时候,两个锁如果为queueUNLOCKED(-1),那就会变成 queueLOCKED_UNMODIFIED(0),代表已经上锁了。
上锁的时候如果有中断操作(因为上锁之前挂起了其他线程,不会被其他线程打断),例如调用了发送接口 xQueueGenericSendFromISR、xQueueGiveFromISR(这两个是freertos定义的在中断函数里的操作接口),那cTxLock则会加1,例如上锁的时间内队列被执行了一次发送操作,那 cTxLock 就变成1,两次则变成了2。
这样在调用解锁接口 prvUnlockQueue 的时候,因为 cTxLock 为1就会去判断一次是否有线程因为等待该队列的而挂起,有则将其解除阻塞,如果它优先级更高,那就会 调用 vTaskMissedYield,这样在调用了 xTaskResumeAll后,下一个系统时钟节拍来的时候会马上触发切换线程。cTxLock为2的话 则会执行 两次前面的判断操作。
cRxLock同理,则是 中断操作了 接收接口,在解锁接口里处理逻辑和 cTxLock 一样。
四、其他通讯接口
前面介绍了 关于 xQUEUE 的原理,其他的几种扩展接口则类似。
计数信号量:常见的接口,其他rtos信号量默认指的是这种,
前面的xQueue创建接口: #define xQueueCreate( uxQueueLength, uxItemSize ) 当 uxItemSize 为0,即不需要传递数据,只需要使用队列的发送收发通知功能,那就变成了 计数信号量。
二值信号量:同理,二值信号量只有0或者1两种结果,类似那就是是 xQueueCreate( uxQueueLength, uxItemSize )的 uxQueueLength为1,uxItemSize为0
互斥量:同二值信号量类似,uxQueueLength为1,uxItemSize为0,增加的逻辑是互斥量会去判断上锁和解锁是否为同一线程,只有持有锁的线程才能解锁,上锁的接口对应 xQueue的接收,解锁对应释放,互斥量创建的时候 队列里会接收一个单元的xQueue,这样
上锁才不会因为 调用 xQueue 接收而阻塞。
递归互斥量:基于互斥量再扩展,增加的逻辑是同一个线程可以多次上锁,然后同理得解锁对应的次数,否则其他线程上锁则会因为该互斥量没解锁完而阻塞。
队列集:队列的扩展,个人觉得比较鸡肋,功能是同个线程可以等待多个队列的消息,用户大可以自己扩展。
事件组:freertos单独实现的一个通讯方式,原理类似队列,但是逻辑和占用的资源简单很多,没有排队的功能,发送接口则是对一个变量的某个位做修改代表有发送操作执行,这里不展开,感兴趣可看源码。