2. 按键扫描

任务目标

实现一个按键检测案例:裸机方案采用主循环轮询GPIO电平,RTOS方案采用事件标志组在按键中断中置位、任务阻塞等待。通过LED翻转次数与逻辑分析仪(或示波器)观察CPU占用差异。

硬件环境

  • MCU:STM32F103C8T6(蓝丸板)
  • 按键:PA0接GND(按下为低电平),内部上拉
  • LED:PC13(板上LED),低电平点亮
  • RTOS:FreeRTOS V10.4.6(CMSIS-OS v2封装)

裸机轮询实现

// main.c - 裸机轮询版本
#include "stm32f1xx_hal.h"

void SystemClock_Config(void);
static void GPIO_Init(void);

int main(void) {
    HAL_Init();
    SystemClock_Config();
    GPIO_Init();

    uint8_t key_state = 1;      // 1=未按下, 0=按下
    uint8_t led_state = 0;
    uint32_t last_tick = 0;

    while (1) {
        // 每10ms轮询一次按键
        if (HAL_GetTick() - last_tick >= 10) {
            last_tick = HAL_GetTick();
            uint8_t current = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0);

            // 边沿检测:仅按下沿触发
            if (key_state == 1 && current == 0) {
                led_state ^= 1;
                HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13,
                                  led_state ? GPIO_PIN_RESET : GPIO_PIN_SET);
            }
            key_state = current;
        }
    }
}

static void GPIO_Init(void) {
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_GPIOC_CLK_ENABLE();

    GPIO_InitTypeDef gpio = {0};
    gpio.Pin = GPIO_PIN_0;
    gpio.Mode = GPIO_MODE_INPUT;
    gpio.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOA, &gpio);

    gpio.Pin = GPIO_PIN_13;
    gpio.Mode = GPIO_MODE_OUTPUT_PP;
    gpio.Pull = GPIO_NOPULL;
    gpio.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOC, &gpio);

    HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); // LED灭
}

RTOS事件驱动实现

// app_rtos.c - FreeRTOS事件标志版本
#include "FreeRTOS.h"
#include "task.h"
#include "event_groups.h"
#include "stm32f1xx_hal.h"

#define KEY_EVENT_BIT  (1 << 0)
static EventGroupHandle_t key_evt_group;

// 按键中断服务函数(在stm32f1xx_it.c中调用)
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
    if (GPIO_Pin == GPIO_PIN_0) {
        BaseType_t higher_woken = pdFALSE;
        // 从ISR置位事件标志
        xEventGroupSetBitsFromISR(key_evt_group, KEY_EVENT_BIT,
                                  &higher_woken);
        portYIELD_FROM_ISR(higher_woken);
    }
}

static void KeyTask(void *arg) {
    uint8_t led_state = 0;
    for (;;) {
        // 阻塞等待事件,超时100ms用于低功耗或看门狗喂狗
        EventBits_t bits = xEventGroupWaitBits(
            key_evt_group, KEY_EVENT_BIT, pdTRUE, pdFALSE, pdMS_TO_TICKS(100));

        if (bits & KEY_EVENT_BIT) {
            led_state ^= 1;
            HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13,
                              led_state ? GPIO_PIN_RESET : GPIO_PIN_SET);
        }
    }
}

void RTOS_Init(void) {
    key_evt_group = xEventGroupCreate();
    xTaskCreate(KeyTask, "key", 128, NULL, 2, NULL);

    // 配置PA0外部中断(下降沿触发)
    GPIO_InitTypeDef gpio = {0};
    __HAL_RCC_GPIOA_CLK_ENABLE();
    gpio.Pin = GPIO_PIN_0;
    gpio.Mode = GPIO_MODE_IT_FALLING;
    gpio.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOA, &gpio);

    HAL_NVIC_SetPriority(EXTI0_IRQn, 5, 0);
    HAL_NVIC_EnableIRQ(EXTI0_IRQn);
}

CPU占用对比方法

  1. 在裸机主循环和RTOS空闲任务中分别翻转一个空闲GPIO(如PB0),用逻辑分析仪测量该引脚高电平占比:
    裸机:高电平占比 ≈ 99%(仅每10ms轮询瞬间拉低)
    RTOS:空闲任务在无事件时持续运行,高电平占比接近100%,按键中断处理时间极短(<5μs)
  2. 响应时间测量:从按键按下到LED翻转的延迟。
    裸机:最坏等待一个轮询周期 = 10ms
    RTOS:中断触发到任务唤醒典型延迟 = 中断延迟 + 上下文切换 ≈ 5~20μs

验证方法

  • 编译烧录后,按下按键PA0,LED(PC13)状态翻转,说明功能正确
  • 用逻辑分析仪观察PB0波形:裸机版本每10ms出现一个短暂低脉冲;RTOS版本空闲时持续高电平,仅在按键按下瞬间出现短暂低脉冲
  • 用示波器测量从按键按下到LED翻转的时间差:裸机版本应接近10ms,RTOS版本应小于100μs

关键差异说明

对比项裸机轮询RTOS事件驱动
CPU占用持续运行轮询代码,即使无按键也每10ms唤醒一次无事件时任务阻塞,CPU可进入低功耗或执行其他任务
响应延迟最坏10ms(轮询周期)中断触发,微秒级响应
代码复杂度简单直接需管理事件标志、任务优先级、中断安全API
扩展性按键增多时轮询周期变长或需分时处理每个按键独立中断+事件位,互不阻塞

注意:RTOS版本中EXTI0_IRQHandler需要在启动文件中正确映射到HAL_GPIO_EXTI_IRQHandler,并在该函数中调用HAL_GPIO_EXTI_Callback。若使用CMSIS-OS v2,可将xEventGroupSetBitsFromISR替换为osEventFlagsSetFromISR。