掌握了“数据流”和“控制流”的概念后,我们即将踏入嵌入式开发中最具挑战性的领域之一:资源竞争。如果处理不当,这里极易发生“翻车”事故。
想象这样一个场景:你的代码在任务 A 里正通过 I2C 总线发送 10 个字节的数据,发送到一半时,一个高优先级的任务 B 强行插入,抢走了 I2C 的控制权去读取另一个传感器。结果就是 I2C 总线上的波形彻底紊乱,通信失败。这类问题在共享硬件外设(如 I2C、SPI、串口)或软件全局变量时尤为常见。
那么,为什么你的 I2C/SPI 总线会无故死锁或出错呢?背后通常有以下几个原因:
- 重入冲突:许多外设驱动(例如常见的 HAL 库)内部有状态机,不能在操作未完成时被另一个调用者打断并重入。
- 优先级反转:一个经典的死锁陷阱。假设低优先级任务 C 占着串口,高优先级任务 A 在等待串口。此时,一个中优先级任务 B 开始运行,抢占了任务 C 的 CPU 时间,导致实际上任务 A(最高优先级)在等待任务 B(中优先级),系统实时性被破坏。
- 效率低下:如果使用过于粗暴的锁(如长时间关中断),会导致系统无法响应外部事件,失去实时性。
保护共享资源的三种境界
面对资源竞争,嵌入式开发者有几种不同层级的应对策略,我们可以将其理解为三种境界。
第一重:原子操作与关中断(最底层的锁)
- 应用场景:修改一个简单的全局标志位、计数器或状态变量,这些操作本身只需要几条机器指令即可完成。
- 实现手段:在 RTOS 中,通常使用类似
taskENTER_CRITICAL() 和 taskEXIT_CRITICAL() 的宏。其本质是直接关闭所有可屏蔽中断,确保当前执行流不会被中断服务程序或其他任务抢占。
- 核心代价:这是一种“核武器”。在临界区内,整个系统的中断响应被禁用。如果这里的代码执行时间过长,可能导致系统时钟滴答丢失、外部事件无法及时响应,严重损害实时性。因此,务必确保临界区内的代码极其简短。
第二重:互斥锁(Mutex)—— 资源保护的正规军
- 应用场景:保护那些操作相对耗时、需要独占访问的硬件外设,如完整的 I2C/SPI 通信事务、文件系统操作等。
- 实现手段:使用 RTOS 提供的互斥锁 API,如
osMutexAcquire() 和 osMutexRelease()。互斥锁的高级之处在于它支持优先级继承:当一个高优先级任务等待一个被低优先级任务持有的锁时,系统会临时提升低优先级任务的优先级,使其尽快执行完并释放锁,从而有效缓解优先级反转问题。
第三重:守护任务模式(Gatekeeper)—— 老鸟的首选架构
- 核心理念:“既然大家都想直接操作这个资源容易引发冲突,那就谁也别直接碰,专门安排一个‘管家’来统一管理”。所有其他任务(客户端)都将操作请求发送给这个管家任务,由管家串行化地执行。
- 显著优点:从根本上杜绝了资源竞争。此外,这种模式架构清晰,便于在管家任务中添加缓存、批处理、错误重试等高级逻辑,增强了系统的健壮性和可维护性。
实战:构建一个串口“守护任务”
让我们通过一个具体例子来理解守护任务模式。假设我们有一个串口用于打印日志,传统的做法是每个任务直接调用 HAL_UART_Transmit,这很容易造成冲突。现在我们改为建立一个 PrintServer 守护任务。
3.1 守护任务的实现
首先,我们定义一个日志消息结构体,并创建一个消息队列。守护任务的核心是循环等待队列中的消息,然后统一处理。
/* 定义日志消息结构 */
typedef struct {
char *str;
uint32_t value;
} LogEntry_t;
osMessageQueueId_t LogQueueHandle;
void StartLogGatekeeperTask(void *argument){
LogEntry_t log;
LogQueueHandle = osMessageQueueNew(16, sizeof(LogEntry_t), NULL);
for(;;) {
// 阻塞等待:只有有人要打印时,管家才干活
if (osMessageQueueGet(LogQueueHandle, &log, NULL, osWaitForever) == osOK) {
// 在这里统一操作硬件,不存在任何竞争
printf("[LOG] %s: %d\r\n", log.str, log.value);
}
}
}
3.2 客户端任务的调用
其他需要打印日志的任务,不再直接操作串口,而是将日志信息打包后投递到消息队列中,然后立刻返回,继续执行自己的逻辑,实现了非阻塞调用。
void StartAppTask(void *argument) {
LogEntry_t myLog = {"Temp", 25};
for(;;) {
// 投递即走,不阻塞自己的逻辑去等待串口发送完成
osMessageQueuePut(LogQueueHandle, &myLog, 0, 0);
osDelay(1000);
}
}
进阶:使用互斥锁保护 SPI 总线
虽然守护任务模式优雅,但在某些必须由多个任务直接操作复杂外设(例如驱动带帧缓冲的显示屏)的场景下,互斥锁仍然是更直接的选择。
osMutexId_t SpiMutexHandle;
void Init_Resources(void){
// 互斥锁必须在所有使用它的任务开始前创建
SpiMutexHandle = osMutexNew(NULL);
}
void Access_Spi_Device(void){
// 1. 获取锁(拿钥匙)
if (osMutexAcquire(SpiMutexHandle, osWaitForever) == osOK) {
// 2. 临界区:安全地独占操作SPI硬件
// HAL_SPI_Transmit(&hspi1, data, size, 100);
// 3. 释放锁(还钥匙)
osMutexRelease(SpiMutexHandle);
}
}
思考:读写锁模型
在实际应用中,还有一种常见场景:一份配置数据可以被多个任务同时读取(读操作不修改数据),但只允许一个任务进行修改(写操作)。这就是经典的“读写锁”模型。
- 实现技巧:在标准的 RTOS 原语中,可以使用计数信号量(Counting Semaphore)来模拟:初始化值为 N(允许的并发读者数),每个读者获取信号量,写者则尝试获取 N 次信号量。更复杂的实现可以用互斥锁配合一个读者计数器来完成。
- 核心原则:保证写操作的独占性,同时允许多个读操作并发进行,以提高系统效率。
总结
保护共享资源是构建稳定、可靠多任务系统的基石。其核心思路无外乎两种:一是用“锁”(关中断、互斥锁)将资源临时保护起来,形成临界区;二是采用“守护任务”架构,将资源访问通道收归单一任务管理,从设计上规避竞争。
理解这些概念并熟练运用,是嵌入式开发,尤其是 C语言 在 操作系统 环境下进行高级 编程 的关键一步。资源保护做好了,数据流和控制逻辑才能正确无误地运行。然而,一个系统运行数天后突然发生 HardFault,往往不再是单纯的逻辑或资源竞争问题,更深层的陷阱可能隐藏在内存管理与任务栈中——这将是我们下一篇需要探讨的主题。