FreeRTOS进程通信与消息队列
这篇标题里写的是“进程通信”,在 FreeRTOS 里更准确地说是任务间通信。任务之间不能只靠全局变量硬传数据,否则很容易出现读写时序问题。FreeRTOS 提供的队列、信号量、互斥量、事件组、任务通知、流缓冲区和消息缓冲区,本质上都是为了解决两个问题:数据怎么传过去,任务什么时候被唤醒。
比如 ADC 中断把采样数据写入缓冲区,缓冲区满后通知数据处理任务。中断里不做滤波和显示,只发一个“数据好了”的信号;任务原本阻塞等待,收到信号后进入就绪态,等调度器选中后再处理数据。这个模式比任务一直轮询标志位可靠,也更省 CPU。
一、通信方式怎么选
这些通信机制不是互相替代的关系,重点是先判断传递的是数据、事件、资源所有权,还是一段字节流。
队列:传递固定大小的数据项。适合按键事件、命令结构体、传感器采样包等。
二值信号量:传递一个“事件发生”的信号。适合 ISR 通知任务、任务之间同步。
计数信号量:表示多个同类资源或者累计发生次数。适合资源池、多个缓冲块、生产者累计通知。
互斥量:保护共享资源,解决多个任务访问同一个外设或数据结构的问题。适合串口打印、LCD 刷屏、文件系统访问。
事件组:用位表示多个事件,可以等待任意一个事件,也可以等待多个事件全部发生。适合状态组合、广播通知、多任务同步。
任务通知:直接通知某一个任务,不创建额外内核对象。适合一对一通知、轻量计数、简单事件位。
流缓冲区:传递连续字节流,没有消息边界。适合 UART 字节流、DMA 数据流。
消息缓冲区:传递变长消息,有消息边界。适合一条一条的协议包、日志包、命令包。
如果只是 ISR 唤醒一个固定任务,优先考虑任务通知;如果要传结构体数据,用队列;如果多个任务都要响应同一组事件,用事件组;如果要保护外设访问,用互斥量。先把这个选择想清楚,后面的 API 才不会乱用。
二、消息队列
队列用于传递固定大小的数据项。写入队列时,FreeRTOS 会把数据复制到队列内部存储区;读取时,再复制到接收方提供的缓冲区。因此队列适合传递小结构体、枚举、指针或句柄。
xQueueCreate() 本质上是对底层通用创建函数的封装。FreeRTOS 里队列、信号量、互斥量有不少底层结构是共用的,所以经常能看到队列相关的通用函数:
1 | QueueHandle_t xQueueGenericCreate(const UBaseType_t uxQueueLength, |
uxQueueLength 是队列里有多少个存储单元,uxItemSize 是每个单元多少字节,ucQueueType 用来区分普通队列、二值信号量、计数信号量、互斥量等类型。平时写应用层代码基本用宏封装后的 API,不需要直接调用这个底层函数,但知道它能帮助理解为什么信号量和互斥量很多地方也用 QueueHandle_t 作为句柄类型。
动态创建队列:
1 | QueueHandle_t keyQueue; |
xQueueCreate(8, sizeof(KeyEvent_t)) 表示队列最多存 8 个元素,每个元素大小是 sizeof(KeyEvent_t)。如果队列创建失败,一般是 FreeRTOS 堆空间不够。
静态创建队列时,队列存储区和控制块都由用户提供:
1 |
|
静态创建不依赖 FreeRTOS 堆,适合内存来源必须明确的工程。注意 keyQueueStorage 是字节数组,大小要等于“队列长度 × 每个元素大小”。
1. 任务中发送和接收
任务中发送数据一般用 xQueueSend()、xQueueSendToBack() 或 xQueueSendToFront()。xQueueSend() 通常等价于发到队尾,也就是 FIFO。
几个发送函数的区别:
xQueueSendToBack():写到队尾,正常 FIFO 队列最常用。xQueueSendToFront():写到队首,适合紧急消息插队,但不要滥用,否则队列顺序会变得难分析。xQueueOverwrite():只适合长度为 1 的队列,用新值覆盖旧值。常用于“只关心最新状态”的场景,例如最新传感器状态、最新按键状态。
1 | void KeyScanTask(void *argument) |
接收任务可以阻塞等待:
1 | void KeyProcessTask(void *argument) |
xQueueReceive() 的第三个参数是等待时间。队列为空时,如果等待时间为 0,函数立即返回;如果是 portMAX_DELAY,任务会一直阻塞到有数据到来。阻塞不是卡死,而是任务让出 CPU,等队列有数据后再被唤醒。
xQueuePeek() 可以读取队首元素但不删除,适合只想查看当前消息、不想消费掉它的场景。
队列的读取逻辑可以理解为:接收方总是从队首取数据,取出后该元素从队列中移除,后面的元素继续保持 FIFO 顺序。应用层不需要关心内部是否真的“移动内存”,只需要记住队列语义是先进先出。
按键这类事件用枚举会比直接传数字清楚:
1 | typedef enum |
如果事件来自 GPIO,也可以把 HAL 的 GPIO_PinState 一起放进结构体里:
1 | typedef struct |
GPIO_PIN_RESET 通常表示低电平,GPIO_PIN_SET 表示高电平。把这些字段组织成结构体后,队列里传的是完整事件,接收任务不需要再回头读 GPIO 状态,逻辑会稳一些。
2. ISR 中发送队列
ISR 中不能调用 xQueueSend(),要用 xQueueSendFromISR():
1 | void USART1_IRQHandler(void) |
这里的 xHigherPriorityTaskWoken 表示是否唤醒了一个比当前被中断任务优先级更高的任务。如果是 pdTRUE,portYIELD_FROM_ISR() 会在中断退出后触发一次任务切换。
3. 队列传指针
队列会复制数据。如果数据很大,不适合直接把整个大结构体塞进队列,可以传指针:
1 | typedef struct |
传指针时要特别注意生命周期。指针指向的内存不能是生产者任务栈上的局部变量,否则函数下一轮执行后数据可能被覆盖。常见做法是使用静态缓冲池,或者配合计数信号量管理多个缓冲块。
三、信号量
信号量传递的是“可用次数”或“事件发生”。它不携带具体数据。信号量常用于任务同步、中断通知任务、资源计数。
1. 二值信号量
二值信号量只有 0 和 1 两种状态。为 0 表示没有信号或资源不可用;为 1 表示可以被获取。一个典型场景是 DMA 传输完成后通知任务处理数据。
1 | static SemaphoreHandle_t dmaDoneSem; |
ISR 中释放信号量:
1 | void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) |
任务中获取信号量:
1 | void AdcProcessTask(void *argument) |
二值信号量更像“事件来了”。如果事件连续来了很多次,但任务还没来得及处理,二值信号量最多只能保持为 1,多出来的次数不会继续累加。需要累计次数时,应该用计数信号量或任务通知计数模式。
从实现上看,二值信号量可以理解为一个长度为 1、元素大小为 0 的特殊队列。释放信号量不需要写入实际数据,只是改变内部计数状态。宏展开后大致能看到它和队列的关系:
1 |
这里的 NULL 表示不需要复制用户数据;semGIVE_BLOCK_TIME 为 0,因为释放信号量一般不应该阻塞等待。这个细节能解释为什么信号量句柄类型和队列句柄关系很近。
xSemaphoreTake() 获取信号量时,第二个参数是等待时间:
1 | BaseType_t xSemaphoreTake(SemaphoreHandle_t xSemaphore, |
xTicksToWait = 0 表示不等待,拿不到就立即返回 pdFALSE。portMAX_DELAY 表示一直等待,直到信号量可用。其他正数表示最多等待对应 Tick 数。任务成功获取后返回 pdTRUE。
ISR 中也有 xSemaphoreTakeFromISR(),但实际项目里不常用。中断里通常是释放信号量通知任务,而不是从信号量里申请资源。并且 ISR 版本仍然不能用于互斥量。
2. 计数信号量
计数信号量保存一个计数值,常用于多个同类资源的管理。比如有 4 个缓冲块,任务每拿一个缓冲块就 take 一次,用完后 give 还回去。
1 |
|
使用时:
1 | void FrameProducerTask(void *argument) |
计数信号量达到最大值后继续 xSemaphoreGive() 会失败并返回 pdFALSE。二值信号量已经为 1 时继续 give,通常仍会保持为 1。这里不要把二值信号量和计数信号量的返回行为混为一谈。
3. 信号量和 ISR
ISR 中可以释放二值信号量和计数信号量:
1 | BaseType_t xHigherPriorityTaskWoken = pdFALSE; |
pxHigherPriorityTaskWoken 是一个输出参数。xSemaphoreGiveFromISR() 执行时,如果释放信号量导致某个等待任务从阻塞态变成就绪态,并且这个任务优先级高于当前被中断打断的任务,它就会把该变量置为 pdTRUE。ISR 退出前再把这个变量传给 portYIELD_FROM_ISR(),这样高优先级任务可以尽快运行。
写法上一般不要把 portYIELD_FROM_ISR() 放在 if 里面也没问题,宏内部会判断参数;但为了看得清楚,也可以写成:
1 | BaseType_t xHigherPriorityTaskWoken = pdFALSE; |
但互斥量不能在 ISR 中使用。原因是互斥量有优先级继承机制,而 ISR 不是任务,不能参与任务优先级继承,也不能阻塞等待资源。
uxSemaphoreGetCount() 可以查询当前信号量计数值。调试时可以用它判断信号量是否一直被占用,或者计数是否一直涨到最大值。
二值信号量和计数信号量在 give 到上限时表现不完全一样。二值信号量已经为 1 时继续 give,通常仍然保持为 1;普通计数信号量达到 uxMaxCount 后继续 give 会失败并返回 pdFALSE。如果用计数信号量统计事件次数,这个返回值要留意,否则事件可能已经被丢掉但代码没有发现。
四、互斥量
互斥量用于保护共享资源。它和二值信号量看起来像,都是 take/give,但设计目的不同。信号量偏同步,互斥量偏资源保护。
创建互斥量:
1 | static SemaphoreHandle_t uartMutex; |
多个任务共享串口打印时:
1 | void DebugPrint(const char *msg) |
这里互斥量保证同一时间只有一个任务使用串口。否则两个任务同时打印,输出内容可能交叉在一起。
互斥量的关键点是优先级继承。假设低优先级任务拿着互斥量,高优先级任务也想拿这个互斥量而阻塞,此时 FreeRTOS 会临时提高低优先级任务的优先级,让它尽快运行并释放互斥量。释放后,低优先级任务恢复原来的优先级。这个机制用于缓解优先级翻转。
优先级翻转可以按这个顺序理解:
低优先级任务 L 获取互斥量,开始访问共享资源。
高优先级任务 H 也要访问这个资源,但互斥量被 L 持有,所以 H 阻塞。
如果这时中优先级任务 M 一直运行,L 就没机会释放互斥量,H 虽然优先级最高,却一直等着。
互斥量的优先级继承会临时把 L 提升到 H 的优先级,让 L 尽快运行并释放资源。
这个机制只能缓解优先级翻转,不是完全消除所有等待。高优先级任务仍然要等低优先级任务把临界资源访问完,所以持有互斥量的代码也要尽量短。
调试互斥量时,xSemaphoreGetMutexHolder() 可以查看当前互斥量持有者:
1 | TaskHandle_t owner = xSemaphoreGetMutexHolder(uartMutex); |
它适合调试阶段判断到底哪个任务一直拿着锁。正式业务逻辑不要过度依赖它来做复杂判断,否则任务间关系会变得很绕。
递归互斥量用于同一个任务需要多次获取同一把锁的场景:
1 | static SemaphoreHandle_t recursiveMutex; |
递归互斥量每 take 一次,就必须 give 一次。普通互斥量不能被同一个任务重复 take,否则容易把自己阻塞住。递归互斥量同样不能在 ISR 中使用。
五、事件组
事件组用一个整数的多个 bit 表示多个事件。每一位可以代表一个状态,比如 WiFi 已连接、MQTT 已连接、时间同步完成、传感器初始化完成等。任务可以等待某一位,也可以等待多个位同时满足。
在 STM32 这类 32 位平台上,EventBits_t 通常是 32 位,但高位会被内核保留。configUSE_16_BIT_TICKS = 0 时,通常可用事件位是低 24 位;如果使用 16 位 tick,可用事件位更少。实际使用时,把事件位控制在低位比较稳。
创建事件组:
1 | static EventGroupHandle_t sysEventGroup; |
设置事件位:
1 | void WifiTask(void *argument) |
等待多个事件全部成立:
1 | void CloudTask(void *argument) |
xClearOnExit 如果设置为 pdTRUE,任务退出等待时会自动清除对应事件位;如果是 pdFALSE,事件位会保留。xWaitForAllBits 为 pdTRUE 表示等待所有位都满足;为 pdFALSE 表示任意一位满足即可。
等待任意一个事件:
1 | EventBits_t bits = xEventGroupWaitBits(sysEventGroup, |
事件组有广播效果。多个任务等待同一个事件位时,设置这个事件位可以让多个任务都解除阻塞。这和队列、信号量不一样,队列和信号量通常只唤醒一个等待任务。
事件组和队列/信号量的差别可以这样看:
| 机制 | 更适合处理 | 唤醒特点 |
|---|---|---|
| 队列 | 带数据的消息 | 通常唤醒一个等待任务 |
| 信号量 | 单个同步事件或资源计数 | 通常唤醒一个等待任务 |
| 事件组 | 多个事件位组合 | 可以同时唤醒多个等待同一事件的任务 |
读取事件组当前值可以用 xEventGroupGetBits()。它本质上等价于“清除 0 个 bit”,也就是只读不改:
1 | EventBits_t bits = xEventGroupGetBits(sysEventGroup); |
清除事件位用 xEventGroupClearBits():
1 | EventBits_t oldBits = xEventGroupClearBits(sysEventGroup, EVT_MQTT_READY); |
返回值是清除前的事件组值。这个返回值有时能用于判断清除前事件是否真的存在。
1. 事件组同步
xEventGroupSync() 用于多个任务在同一个点集合,等所有任务都到达后再继续运行。它把“设置自己的 bit”和“等待其他 bit”做成一个原子操作,比手动 SetBits 后再 WaitBits 更稳。
1 |
|
如果用 xEventGroupSetBits() 和 xEventGroupWaitBits() 分两步实现同步,中间可能被调度打断;xEventGroupSync() 适合这种“我到了,同时等别人”的场景。
2. ISR 中设置事件位
ISR 中可以调用 xEventGroupSetBitsFromISR(),但它不是直接在 ISR 中修改事件组,而是把操作投递给定时器服务任务处理。
1 | void EXTI1_IRQHandler(void) |
因为这个操作要通过定时器服务任务的命令队列转发,所以需要注意定时器服务任务优先级和队列长度。如果 TIMER_QUEUE_LENGTH 太小,ISR 中投递事件位可能失败。对实时性要求很高的一对一通知,不一定要用事件组,任务通知通常更直接。
这里容易误解:xEventGroupSetBitsFromISR() 返回成功,只表示“置位请求已经成功发送到定时器服务任务的命令队列”,不表示事件位已经立刻被设置。真正执行 xEventGroupSetBits() 的是定时器服务任务。也就是说,从 ISR 发出请求到等待事件的任务被唤醒,中间可能还要等定时器服务任务被调度运行。
pxHigherPriorityTaskWoken 在这里比较的是定时器服务任务和当前被中断任务的优先级。如果定时器服务任务优先级更高,就可能需要在 ISR 退出后立刻切到定时器服务任务,让它尽快处理事件位置位请求。因此软件定时器那篇里的 TIMER_TASK_PRIORITY、TIMER_QUEUE_LENGTH 和事件组 FromISR 用法是有关联的。
六、任务通知
任务通知是直接写到任务控制块里的通信机制。它不需要额外创建队列、信号量或事件组,速度快,占用内存少。常见用法有三类:当二值信号量用、当计数信号量用、当事件位用。
每个任务至少有一个通知值和一个通知状态。较新的 FreeRTOS 版本支持通知数组,但普通工程里先按一个通知值理解就够用。任务通知只能发给某一个任务,不能广播给多个任务。
1. 当二值信号量或计数信号量用
ISR 通知任务:
1 | static TaskHandle_t adcTaskHandle; |
任务中等待:
1 | void AdcTask(void *argument) |
ulTaskNotifyTake(pdTRUE, ...) 在退出时把通知值清零,行为接近二值信号量。若用 pdFALSE,退出时通知值减一,更接近计数信号量。
1 | uint32_t count = ulTaskNotifyTake(pdFALSE, portMAX_DELAY); |
如果 ISR 连续通知 3 次,任务还没处理,通知值会累加。任务每次 ulTaskNotifyTake(pdFALSE, ...) 会消耗一次。
2. 当事件位用
xTaskNotify() 可以按位设置通知值,类似一个轻量事件组,但只能通知一个任务。
1 |
|
任务等待通知位:
1 | void CommTask(void *argument) |
ulBitsToClearOnEntry 是进入等待前清哪些位,ulBitsToClearOnExit 是成功收到通知后清哪些位。常见写法是进入时不清,退出时清掉自己关心的位。
任务通知的限制也要记住:只能通知任务,不能通知 ISR;不能广播;一次通知值通常是 32 位,不适合传大数据。需要传数据时,用队列、流缓冲区或消息缓冲区。
xTaskNotify() 的 eAction 参数决定通知值怎么变化:
eAction |
含义 |
|---|---|
eNoAction |
只发送通知,不修改通知值 |
eSetBits |
对通知值执行按位或,常用来模拟事件位 |
eIncrement |
通知值加 1,常用来模拟计数信号量 |
eSetValueWithOverwrite |
直接覆盖通知值,即使前一个通知还没处理 |
eSetValueWithoutOverwrite |
只有当前没有挂起通知时才写入,避免覆盖未处理数据 |
如果既要发送通知,又想知道修改前的通知值,可以用 xTaskNotifyAndQuery():
1 | uint32_t oldValue; |
这个函数适合调试或某些状态机逻辑:发送新通知的同时,拿到目标任务旧的通知值。
xTaskNotifyWait() 的行为要结合“通知状态”理解:
1 | BaseType_t xTaskNotifyWait(uint32_t ulBitsToClearOnEntry, |
如果任务已经有挂起通知,函数会立即返回
pdTRUE,把通知值写入pulNotificationValue,然后按ulBitsToClearOnExit清位。此时不会执行ulBitsToClearOnEntry。如果任务没有挂起通知,函数先按
ulBitsToClearOnEntry清位,再进入阻塞等待。等待期间收到通知则返回pdTRUE,并执行ulBitsToClearOnExit。如果超时返回pdFALSE,不会执行退出清位。
这个细节影响事件位写法。常见写法是进入时不清位,退出时清掉已处理的位:
1 | xTaskNotifyWait(0, |
任务通知的“Pending”状态和任务生命周期里的“Suspended”不是一回事。通知 Pending 表示任务有待处理通知;任务 Suspended 表示任务被 vTaskSuspend() 挂起,不参与调度。
Pending 不会让任务停止运行,它只是表示“有通知没处理”。Suspended 是调度器层面的挂起,任务不会参与调度。一个被挂起的任务仍然可以被其他任务写入通知值,但它不会因为通知自动恢复运行,除非再调用 vTaskResume()。
七、流缓冲区
流缓冲区用于传递连续字节流,没有消息边界。发送 20 个字节,接收方可以一次读 5 个,也可以一次读 20 个。它适合串口数据流、音频数据流、ADC 原始字节流等。
创建流缓冲区:
1 | static StreamBufferHandle_t uartStream; |
第一个参数是缓冲区大小,单位是字节。第二个参数是触发水平,表示接收任务至少等到多少字节才被唤醒。触发水平为 1 时,只要有 1 字节到来就可以唤醒接收任务。
底层通用创建函数是:
1 | StreamBufferHandle_t xStreamBufferGenericCreate(size_t xBufferSizeBytes, |
xBufferSizeBytes 是缓冲区总容量;xTriggerLevelBytes 是触发水平;xIsMessageBuffer 用来区分流缓冲区和消息缓冲区。创建流缓冲区时,这个参数相当于 pdFALSE;创建消息缓冲区时,相当于 pdTRUE。
触发水平要小于等于缓冲区大小。设置为 0 时,FreeRTOS 会按 1 处理。运行过程中也可以重新设置触发水平:
1 | xStreamBufferSetTriggerLevel(uartStream, 8); |
这表示接收任务阻塞等待时,至少积累到 8 个字节才唤醒。串口协议如果每帧长度固定,可以适当提高触发水平;如果希望来一个字节就处理,触发水平就设 1。
ISR 中写入流缓冲区:
1 | void USART1_IRQHandler(void) |
任务中读取:
1 | void UartStreamTask(void *argument) |
流缓冲区默认设计是单写者、单读者。一个 ISR 写、一个任务读是合适的;如果多个任务同时写,或者多个任务同时读,需要额外保护。官方建议多写者/多读者场景下,用临界段或互斥保护,并且把发送/接收函数的阻塞时间设为 0,避免在持锁时阻塞。
容易出问题的写法:
1 | taskENTER_CRITICAL(); |
如果缓冲区满了,xStreamBufferSend() 会阻塞等待空间,但此时还在临界段里,系统很容易卡住。需要保护时,临界段里只能做非阻塞操作:
1 | size_t sent; |
流缓冲区常用查询函数:
1 | size_t used = xStreamBufferBytesAvailable(uartStream); |
也可以判断是否为空或已满:
1 | BaseType_t empty = xStreamBufferIsEmpty(uartStream); |
xStreamBufferReset() 只有在没有任务阻塞等待发送或接收时才适合调用。否则重置会破坏正在等待的任务语义。
这里的“没有任务阻塞”包含两边:没有任务因为缓冲区空而阻塞在 xStreamBufferReceive(),也没有任务因为缓冲区满而阻塞在 xStreamBufferSend()。如果系统里还有任务正在等这个缓冲区,不要直接 reset。
FreeRTOS 里还有发送完成、接收完成相关的宏钩子,主要给特殊平台或多核场景扩展使用。单核 STM32 工程通常不需要改这些宏。
八、消息缓冲区
消息缓冲区和流缓冲区底层关系很近,但语义不同。消息缓冲区按“完整消息”收发,有消息边界;流缓冲区只是字节流。
可以把两者区别记成一句话:流缓冲区关心“现在有多少字节”,消息缓冲区关心“现在有没有一条完整消息”。串口原始字节流更适合流缓冲区,带长度字段或固定包结构的上层协议更适合消息缓冲区。
创建消息缓冲区:
1 | static MessageBufferHandle_t logMsgBuffer; |
发送一条变长消息:
1 | typedef struct |
接收消息:
1 | void LogConsumerTask(void *argument) |
消息缓冲区内部会为每条消息保存长度字段。这个长度字段大小由 configMESSAGE_BUFFER_LENGTH_TYPE 决定,32 位 STM32 工程里常见是 4 字节,但不要在代码里强行假设所有平台都是 4 字节。实际可用空间会小于创建时传入的总字节数。
宏定义大致可以理解为:
1 |
触发水平传 0,是因为消息缓冲区不靠“攒够多少字节”唤醒,而是靠“一条完整消息可用”唤醒。
消息缓冲区保证消息边界。发送一条 20 字节消息,接收方要么收到这一整条消息,要么收不到;不会像流缓冲区那样先读 5 字节再读 15 字节。如果接收缓冲区太小,通常会接收失败并返回 0,因此接收缓冲区要按最大消息长度准备。
发送函数返回的是用户消息体的字节数,不包含内部长度头。接收函数返回的也是消息体字节数,不包含长度头。这个点调试时很容易误判:创建 256 字节消息缓冲区,并不代表能连续塞满 256 字节用户数据,因为每条消息都要额外占长度字段。
如果接收缓冲区太小,下一条消息不会被拆开读一半。消息缓冲区要保持消息完整性,所以应该扩大接收缓冲区,或者限制单条消息最大长度。
ISR 中也可以发送消息:
1 | void SomeIrqHandler(void) |
消息缓冲区也按单写者、单读者设计。多个写者或多个读者时,要额外加保护,或者改用队列。队列适合固定大小消息,消息缓冲区适合变长消息。
九、常见组合写法
实际工程里,经常是多个机制组合使用。
- ISR + 任务通知
适合一对一唤醒,例如 ADC DMA 完成通知固定的数据处理任务。开销小,代码短。
- ISR + 队列
适合中断里产生小数据,比如串口字节、按键事件、编码器事件。队列可以保存多个事件,不会只保留一个标志。
- 队列 + 计数信号量
适合缓冲池。计数信号量表示空闲缓冲块数量,队列传递已经填好的缓冲块指针。
- 互斥量 + 外设
适合多个任务共享串口、LCD、SPI Flash、文件系统。互斥量保护访问顺序,避免多个任务同时操作外设。
- 事件组 + 系统状态
适合等待多个条件组合。比如网络已连接、时间已同步、配置已加载,三个条件都满足后再启动业务任务。
- 流缓冲区/消息缓冲区 + 通信协议
字节流协议用流缓冲区,带长度的消息包可以用消息缓冲区。需要多个生产者或多个消费者时,要考虑额外保护。
十、注意事项
队列传的是数据副本;传大数据时可以传指针,但要保证指针指向的内存生命周期足够长。
信号量用于同步,互斥量用于保护共享资源。互斥量有优先级继承,信号量没有。
互斥量不能在 ISR 中使用,递归互斥量也不能在 ISR 中使用。
事件组适合多个事件组合和广播;xEventGroupSetBitsFromISR() 会通过定时器服务任务延后处理,不是直接在 ISR 中置位。
任务通知适合一对一通知,速度快、内存少,但不能广播,也不适合传大数据。
流缓冲区没有消息边界,消息缓冲区有消息边界。串口字节流一般用流缓冲区,变长协议包可以用消息缓冲区。
流缓冲区和消息缓冲区默认按单写者、单读者设计。多写者或多读者场景需要额外保护。
不要在临界段、关中断区间、持有互斥量时调用可能长时间阻塞的通信 API。这个问题比 API 本身更容易造成死锁。
所有 FromISR API 都要注意 pxHigherPriorityTaskWoken,ISR 结尾根据它调用 portYIELD_FROM_ISR()。







