信号量为什么“不占CPU“

这是一个非常深刻的问题!理解这个问题,就能真正掌握RTOS的核心设计思想。让我从多个角度解释信号量为什么"不占CPU"

一、本质:信号量不是"东西",而是"规则"

信号量在内存中只是一个整数变量 (如0或1),它本身并不运行,也不消耗CPU时间。真正影响CPU的是等待信号量的任务

cpp 复制代码
// 信号量本质上就是一个整数
typedef struct {
    int count;           // 信号量值
    List_t waitingList;  // 等待该信号量的任务列表
} Semaphore_t;

二、关键:等待机制让任务"主动让出CPU"

当任务执行 xSemaphoreTake() 时:

cpp 复制代码
void flashLED(void *pvParam) {
    while (1) {
        // 这里发生了什么?
        if (xSemaphoreTake(xSemaLED, portMAX_DELAY) == pdTRUE) {
            // 执行业务逻辑
        }
    }
}

内部执行流程:

cpp 复制代码
// 简化的信号量Take实现
BaseType_t xSemaphoreTake(SemaphoreHandle_t xSemaphore, TickType_t xTicksToWait) {
    // 1. 进入临界区(关中断)
    taskENTER_CRITICAL();
    
    // 2. 检查信号量值
    if (xSemaphore->count > 0) {
        // 有信号量,直接拿走
        xSemaphore->count--;
        taskEXIT_CRITICAL();
        return pdTRUE;  // 立即返回,继续执行
    }
    
    // 3. 没有信号量,需要等待
    if (xTicksToWait == 0) {
        // 不等待,直接返回失败
        taskEXIT_CRITICAL();
        return pdFALSE;
    }
    
    // 4. 【关键】将当前任务从就绪列表移到等待列表
    vListRemove(&(pxCurrentTCB->xStateListItem));  // 从就绪列表移除
    vListInsertEnd(&(xSemaphore->waitingList),     // 插入等待列表
                   &(pxCurrentTCB->xStateListItem));
    
    // 5. 设置任务的阻塞超时时间
    pxCurrentTCB->xTicksToWait = xTicksToWait;
    
    taskEXIT_CRITICAL();
    
    // 6. 【核心】触发任务切换,让出CPU
    taskYIELD();  // 这行代码让当前任务停止运行!
    
    // 7. 当任务再次被唤醒时,从这里继续执行
    // 此时信号量已经获得,直接返回成功
    return pdTRUE;
}

三、核心机制:任务状态切换

RTOS中的任务状态机:

复制代码
                    ┌──────────────┐
                    │   运行态     │
                    │ (Running)    │
                    └──────┬───────┘
                           │
            xSemaphoreTake │ xSemaphoreGive
            (无信号量)     │ (中断或其他任务)
                           ↓
                    ┌──────────────┐
                    │   阻塞态     │◄──────┐
                    │ (Blocked)    │       │
                    └──────┬───────┘       │
                           │               │
              超时或信号到来│               │
                           ↓               │
                    ┌──────────────┐       │
                    │   就绪态     │───────┘
                    │ (Ready)      │   调度器选择
                    └──────────────┘   最高优先级任务运行

具体过程:

cpp 复制代码
// 场景1:信号量可用(count=1)
任务执行 xSemaphoreTake() → count变为0 → 任务继续运行 → CPU被占用

// 场景2:信号量不可用(count=0)
任务执行 xSemaphoreTake() → 任务从"运行态"→"阻塞态" → 触发任务切换 → 
当前任务暂停 → CPU切换到其他就绪任务 → 当前任务不再占用CPU

四、直观类比:银行柜台

想象一个银行柜台(CPU)和等待办理业务的人(任务):

场景1:没有信号量(轮询)

cpp 复制代码
// 糟糕的设计:任务不断检查
void flashLED() {
    while(1) {
        if (buttonPressed) {  // 不断轮询检查
            toggleLED();
        }
        // CPU一直在空转,浪费电!
    }
}

就像一个人站在柜台前,每隔1秒就问"按钮按了吗?",即使没人按,他也一直站着(占用CPU)。

场景2:使用信号量(阻塞)

cpp 复制代码
// 好的设计:等待信号
void flashLED() {
    while(1) {
        xSemaphoreTake(buttonSemaphore, portMAX_DELAY);  // 等待
        toggleLED();  // 有人按才执行
    }
}

就像取号系统:

  • 没有人按按钮时,任务去休息区坐着(阻塞态)
  • CPU可以去服务其他任务(或者进入低功耗模式)
  • 按钮按下时,叫号(发出信号),任务才去柜台(运行态)

五、实际运行示例

假设系统中有三个任务:

cpp 复制代码
// 任务1:LED控制(优先级2)
void taskLED(void *pvParam) {
    while(1) {
        xSemaphoreTake(sem, portMAX_DELAY);  // 等待信号
        toggleLED();
    }
}

// 任务2:数据显示(优先级1)
void taskDisplay(void *pvParam) {
    while(1) {
        updateDisplay();
        vTaskDelay(100);  // 每秒更新10次
    }
}

// 任务3:串口通信(优先级1)
void taskSerial(void *pvParam) {
    while(1) {
        processSerialData();
        vTaskDelay(50);   // 每秒更新20次
    }
}

CPU占用情况:

复制代码
没有信号量时(按钮未按):
┌─────────────────────────────────────────────────────────┐
│ taskDisplay │ taskSerial │ taskDisplay │ taskSerial │ ... │
│   (运行)    │   (运行)   │   (运行)    │   (运行)   │     │
└─────────────────────────────────────────────────────────┘
CPU 100% 忙碌

使用信号量时(按钮未按):
┌─────────────────────────────────────────────────────────┐
│ taskDisplay │ taskSerial │ taskDisplay │ taskSerial │ ... │
│   (运行)    │   (运行)   │   (运行)    │   (运行)   │     │
└─────────────────────────────────────────────────────────┘
taskLED处于阻塞态,不占用CPU,其他任务正常执行

按钮按下后:
┌─────────────────────────────────────────────────────────┐
│ taskLED │ taskDisplay │ taskSerial │ taskLED │ ...      │
│ (运行)  │   (运行)    │   (运行)   │ (运行)  │          │
└─────────────────────────────────────────────────────────┘
信号量唤醒taskLED,立即抢占CPU(因为优先级更高)

六、为什么轮询会占CPU?

cpp 复制代码
// ❌ 轮询方式:占CPU
void flashLED() {
    while(1) {
        if (digitalRead(22) == LOW) {  // 不断检查
            toggleLED();
        }
        // 即使没有按键,也在空转
        // CPU 100% 被占用
    }
}

// ✅ 信号量方式:不占CPU
void flashLED() {
    while(1) {
        xSemaphoreTake(sem, portMAX_DELAY);  // 阻塞等待
        toggleLED();  // 只有信号来时执行
    }
}

区别

  • 轮询:任务始终在"运行态",CPU被占用
  • 信号量:任务在"阻塞态",不参与CPU调度

七、深入内核:任务控制块(TCB)

每个任务都有一个TCB,记录了任务的状态:

cpp 复制代码
typedef struct tskTaskControlBlock {
    volatile StackType_t *pxTopOfStack;  // 堆栈指针
    ListItem_t xStateListItem;           // 状态列表项
    ListItem_t xEventListItem;           // 事件列表项
    UBaseType_t uxPriority;              // 优先级
    TickType_t xTicksToWait;              // 等待超时时间
    // ... 其他字段
} TCB_t;

当任务等待信号量时:

cpp 复制代码
// 1. 从就绪列表移除
vListRemove(&(pxCurrentTCB->xStateListItem));

// 2. 插入等待列表(信号量的等待队列)
vListInsertEnd(&(xSemaphore->waitingList), 
               &(pxCurrentTCB->xEventListItem));

// 3. 标记任务为阻塞态
pxCurrentTCB->xEventListItem->xItemValue = xTicksToWait;

此时,调度器在寻找下一个运行任务时:

  • 不会扫描等待列表中的任务
  • 这些任务完全不被考虑
  • CPU时间全部给其他任务

八、实验验证

你可以用以下代码验证信号量是否真的不占CPU:

cpp 复制代码
#include <Arduino.h>
#include <esp32-hal.h>

SemaphoreHandle_t sem;
volatile uint32_t idleCounter = 0;

// 空闲任务钩子(统计空闲时间)
void vApplicationIdleHook(void) {
    idleCounter++;  // 空闲任务每运行一次,计数器+1
}

void taskWait(void *pvParam) {
    while(1) {
        // 等待信号量,永远阻塞
        xSemaphoreTake(sem, portMAX_DELAY);
        Serial.println("Received signal!");
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

void setup() {
    Serial.begin(115200);
    sem = xSemaphoreCreateBinary();
    
    // 创建等待任务
    xTaskCreate(taskWait, "Wait", 2048, NULL, 1, NULL);
    
    // 10秒后,给出信号量
    vTaskDelay(pdMS_TO_TICKS(10000));
    xSemaphoreGive(sem);
    
    // 再等10秒看空闲计数器
    vTaskDelay(pdMS_TO_TICKS(10000));
    
    Serial.print("Idle counter: ");
    Serial.println(idleCounter);  // 会看到很大的数值,证明CPU在空闲任务
}

void loop() {}

运行结果会显示:空闲计数器在10秒内增加了数百万次,证明CPU没有空转在等待任务上。

九、总结

信号量不占CPU的本质原因

  1. 信号量只是数据:在内存中只是一个整数变量
  2. 等待是任务主动让出xSemaphoreTake() 会触发任务切换
  3. 状态管理:等待的任务从"运行态"→"阻塞态",不再参与调度
  4. 事件驱动 :只有信号量被Give时,才会唤醒任务

类比

  • 信号量 = 门铃按钮(不耗电)
  • 等待任务 = 在沙发上等门铃的人(不消耗注意力)
  • CPU = 可以做其他事的人(比如看电视、看书)

这就是RTOS能够高效管理CPU资源的核心秘密!

相关推荐
爱喝纯牛奶的柠檬3 小时前
【已验证】基于STM32F103的土壤湿度传感器驱动
stm32·单片机·嵌入式硬件
Zevalin爱灰灰4 小时前
零基础入门学用物联网(ESP8266) 第二部分 MQTT基础篇(三)
单片机·物联网·mqtt·嵌入式·esp8266
AnalogElectronic4 小时前
树莓派pico,VS1838B红外接收实验
嵌入式硬件
llilian_164 小时前
ptp从时钟 ptp授时模块 如何挑选PTP从时钟授时协议模块 ptp从时钟模块
数据库·功能测试·单片机·嵌入式硬件·测试工具
Truffle7电子4 小时前
STM32理论 —— FreeRTOS:中断管理、列表
stm32·单片机·嵌入式硬件·rtos
Zevalin爱灰灰5 小时前
零基础入门学用物联网(ESP8266) 第二部分 MQTT基础篇(四)
单片机·物联网·mqtt·嵌入式·esp8266
贺小涛6 小时前
STM32学习
stm32·单片机·学习
LXY_BUAA6 小时前
《嵌入式操作系统》_GPIOLIB前置知识_20260328
驱动开发·嵌入式硬件
DA02216 小时前
系统移植-STM32MP1_TF-A概述
单片机·系统移植·stm32mp1