单片机工程由什么组成

“点亮一颗 LED”是单片机的 Hello World,但它已经包含了一个 MCU 工程的全部要素:时钟要先启动、外设要先使能、引脚要先配置成正确模式,最后才是那一行电平输出。初学者最容易犯的错误是把顺序颠倒——外设时钟没开就去写寄存器,结果程序不报错、灯也不亮。

裸机开发的三件事:把时钟配好、把引脚配好、把事件接进中断。顺序不能反,因为每一条外设寄存器的写入都以“该外设时钟已使能”为前提。
STM32 入门配图:电路板走线与中央单片机芯片
裸机开发的三件事:把时钟配好、把引脚配好、把事件接进中断

下面以最常见的 STM32F103(Cortex-M3,72MHz 主频)为例,用 HAL 库的写法走一遍完整流程;文末再给出对应的寄存器视角,方便对照理解。

时钟树:先把心跳配准

MCU 上电后默认使用内部高速时钟(HSI,通常 8MHz),精度差、频率低。工程里第一步几乎都是切到外部晶振,并通过 PLL 倍频到目标主频。STM32F103 的常用配置是:8MHz 外部晶振 ×9 = 72MHz 系统时钟,APB1 分频后 36MHz,APB2 保持 72MHz。

#include "stm32f1xx_hal.h"

void SystemClock_Config(void)
{
    RCC_OscInitTypeDef osc = {0};
    RCC_ClkInitTypeDef clk = {0};

    /* 1) 打开 HSE 与 PLL,PLL 源选 HSE ×9 -> 72MHz */
    osc.OscillatorType      = RCC_OSCILLATORTYPE_HSE;
    osc.HSEState            = RCC_HSE_ON;
    osc.HSEPredivValue      = RCC_HSE_PREDIV_DIV1;
    osc.PLL.PLLState        = RCC_PLL_ON;
    osc.PLL.PLLSource       = RCC_PLLSOURCE_HSE;
    osc.PLL.PLLMUL          = RCC_PLL_MUL9;
    if (HAL_RCC_OscConfig(&osc) != HAL_OK) { Error_Handler(); }

    /* 2) 切换总线时钟:AHB/APB2 = 72MHz,APB1 = 36MHz */
    clk.ClockType      = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK |
                         RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2;
    clk.SYSCLKSource   = RCC_SYSCLKSOURCE_PLLCLK;
    clk.AHBCLKDivider  = RCC_SYSCLK_DIV1;
    clk.APB1CLKDivider = RCC_HCLK_DIV2;   /* 36MHz,注意 APB1 最高 36MHz */
    clk.APB2CLKDivider = RCC_HCLK_DIV1;   /* 72MHz */
    if (HAL_RCC_ClockConfig(&clk, FLASH_LATENCY_2) != HAL_OK) { Error_Handler(); }
}

两个细节决定成败:一是 FLASH_LATENCY 必须随主频提高而增加(72MHz 需要 2 个等待周期),否则取指会出错;二是 APB1 的分频系数不能小于 2,超频会在某些外设上表现为随机异常,很难排查。

GPIO:点亮第一颗 LED

以常见的开发板为例,LED 接在 PC13 且为低电平点亮。配置步骤是:使能 GPIOC 时钟、把 PC13 设为推挽输出、然后输出低电平。

static void led_init(void)
{
    GPIO_InitTypeDef io = {0};

    __HAL_RCC_GPIOC_CLK_ENABLE();          /* 先开外设时钟,顺序不能反 */

    io.Pin   = GPIO_PIN_13;
    io.Mode  = GPIO_MODE_OUTPUT_PP;        /* 推挽输出 */
    io.Pull  = GPIO_NOPULL;
    io.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOC, &io);

    HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET);   /* 熄灭(低电平点亮)
                                                              先置高,避免上电瞬间闪一下 */
}

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

    while (1) {
        HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
        HAL_Delay(500);
        /* 注意:HAL_Delay 依赖 SysTick 中断,若在中断里调用会死等 */
    }
}

对应的寄存器视角只有两行:开时钟是 RCC->APB2ENR |= (1U << 4),配置模式是改 GPIOC->CRH 的第 20~23 位。理解寄存器能让你在 HAL 出问题时知道该看哪儿,但日常开发用 HAL 更省时间,两者并不冲突。

按键输入与消抖

按键看起来更简单,坑却更多:机械触点抖动会让一次按下被识别成十几次。正确的做法是“读取 + 延时确认”或“定时器周期采样 + 状态机”,而不是用软件延时死等。

#define KEY_PIN   GPIO_PIN_0
#define KEY_PORT  GPIOA

/* 在主循环中以 5ms 为周期调用:返回 1 表示检测到一次有效按下 */
uint8_t key_scan(void)
{
    static uint8_t last = 1, cnt = 0;

    uint8_t now = HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN);   /* 上拉输入:按下为 0 */
    if (now != last) {                                   /* 电平变化 */
        if (++cnt >= 4) {                                /* 连续 4 次(约 20ms)一致才确认 */
            last = now;
            cnt  = 0;
            if (now == 0) return 1;                      /* 按下沿 */
        }
    } else {
        cnt = 0;
    }
    return 0;
}

这段代码的关键是“连续多次采样一致才认定变化”,它同时解决了抖动与偶发干扰。相比 HAL_Delay(20) 这种写法,它不阻塞主循环,也不会因为按键长按而卡住其他任务。

中断:让事件主动找你

轮询适合慢速、低频的输入;对于按键、编码器、传感器数据就绪这类事件,中断才是不浪费 CPU 的正解。使用外部中断(EXTI)有四个步骤:配置 GPIO 为输入、把引脚映射到 EXTI 线、配置触发边沿、打开 NVIC 中断。

/* 1) 配置 PA0 为下降沿触发的外部中断 */
static void exti_init(void)
{
    GPIO_InitTypeDef io = {0};
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_AFIO_CLK_ENABLE();          /* F1 系列经 AFIO 映射 */

    io.Pin  = GPIO_PIN_0;
    io.Mode = GPIO_MODE_IT_FALLING;
    io.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOA, &io);

    HAL_NVIC_SetPriority(EXTI0_IRQn, 5, 0);   /* 抢占优先级 5 */
    HAL_NVIC_EnableIRQ(EXTI0_IRQn);
}

/* 2) 中断服务函数由启动文件里的名字固定,不能改名 */
void EXTI0_IRQHandler(void) { HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0); }

/* 3) HAL 会在清除标志后回调这里 */
void HAL_GPIO_EXTI_Callback(uint16_t pin)
{
    if (pin == GPIO_PIN_0) {
        g_key_flag = 1;                   /* 只置标志,重活留给主循环 */
    }
}

中断的优先级配置有两层含义。抢占优先级决定“能不能打断别人”,子优先级只在同一抢占级同时到来时决定先后。Cortex-M 的优先级数值越小等级越高,而 NVIC 分组一旦在程序开头设定,运行期间就不应再改。生产项目里最常见的做法是固定分组(例如全部 4 位都给抢占优先级),再用一张表把每个中断的优先级分配写下来,避免两个人给同一组外设配了两个互相打架的等级。

判断该用中断还是轮询的标准也很明确:事件的到来是随机的、且要求尽快响应,就用中断;事件是周期性的、且处理很快,用轮询更简单,还不必担心中断里的各种限制。把两者混用在一个信号上(既开中断又轮询标志位),往往就是“标志位偶尔丢一次”这类偶发问题的来源。

中断里不能做的四件事
  • 不要长时间阻塞不可用 HAL_Delay、等待轮询标志或长循环,会拖垮整个系统的响应。
  • 不要做浮点密集运算没有 FPU 的型号上,中断内的浮点会显著拉长执行时间。
  • 不要调用非重入函数printf、动态内存分配、文件系统操作都应移出中断。
  • 共享变量要加 volatile否则编译器可能把主循环里的读取优化掉,导致标志永远读不到更新。