单片机工程由什么组成
“点亮一颗 LED”是单片机的 Hello World,但它已经包含了一个 MCU 工程的全部要素:时钟要先启动、外设要先使能、引脚要先配置成正确模式,最后才是那一行电平输出。初学者最容易犯的错误是把顺序颠倒——外设时钟没开就去写寄存器,结果程序不报错、灯也不亮。
裸机开发的三件事:把时钟配好、把引脚配好、把事件接进中断。顺序不能反,因为每一条外设寄存器的写入都以“该外设时钟已使能”为前提。
下面以最常见的 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否则编译器可能把主循环里的读取优化掉,导致标志永远读不到更新。
工具链与烧写:从 IDE 到命令行
用图形 IDE 点按钮很方便,但一旦需要批量编译或接入 CI,命令行工具链就是必备能力。Cortex-M 的通用组合是 arm-none-eabi-gcc + make + openocd。
# Makefile 关键片段
TARGET = firmware
CC = arm-none-eabi-gcc
CPU = -mcpu=cortex-m3 -mthumb
DEFS = -DUSE_HAL_DRIVER -DSTM32F103xB
CFLAGS = $(CPU) $(DEFS) -Os -Wall -ffunction-sections -fdata-sections
LDFLAGS = $(CPU) -TSTM32F103C8Tx_FLASH.ld -Wl,--gc-sections -specs=nano.specs -specs=nosys.specs
all: $(TARGET).elf $(TARGET).hex $(TARGET).bin
$(TARGET).elf: $(OBJS)
$(CC) $(OBJS) $(LDFLAGS) -o $@
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
$(TARGET).hex: $(TARGET).elf
arm-none-eabi-objcopy -O ihex $< $@
$(TARGET).bin: $(TARGET).elf
arm-none-eabi-objcopy -O binary $< $@
flash: $(TARGET).elf
openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \
-c "program $(TARGET).elf verify reset exit"
size: $(TARGET).elf
arm-none-eabi-size $<
几条实战经验:链接脚本里的 Flash 与 RAM 大小必须和实际型号一致,用错型号会在运行期表现为莫名其妙的复位;打开 --gc-sections 能明显减小体积;每次发版都跑一次 size,把 Flash/RAM 占用纳入监控,比等到“加不上功能”时才发现超标要好。
调试与常见故障
裸机调试最有效的工具其实是串口。把 printf 重定向到 UART,你能看到的信息比单步调试多得多。
#include <stdio.h>
/* 方式一:重定向 _write(配合 -specs=nano.specs 使用) */
int _write(int fd, char *buf, int len)
{
for (int i = 0; i < len; i++) {
while (!(USART1->SR & USART_SR_TXE)) { } /* 等发送寄存器空 */
USART1->DR = (uint8_t)buf[i];
}
return len;
}
/* 方式二:使用 HAL 的阻塞发送,适合少量日志 */
void log_printf(const char *fmt, ...)
{
char line[128];
va_list ap; va_start(ap, fmt);
vsnprintf(line, sizeof(line), fmt, ap);
va_end(ap);
HAL_UART_Transmit(&huart1, (uint8_t *)line, strlen(line), 100);
}
| 现象 | 常见原因 | 排查动作 |
|---|---|---|
| 程序卡在 HardFault | 访问未使能时钟的外设、数组越界、栈溢出 | 看 LR/PC 寄存器,查栈是否溢出,确认外设时钟 |
| LED 一闪不闪 | 时钟没切到 PLL、HAL_Delay 依赖的 SysTick 未初始化 | 读 RCC->CFGR 的 SWS 位确认系统时钟源 |
| 串口乱码 | 波特率与实际时钟不匹配 | 核对 SystemCoreClock 与 UART 初始化参数 |
| 复位后立即重启 | 看门狗未喂、供电不足、链接脚本 RAM 超限 | 先排除电源,再看 IWDG 配置与 map 文件 |
| 中断只进一次 | 标志未清除、优先级配置错误 | 确认调用了 HAL_GPIO_EXTI_IRQHandler 清标志 |
把这套“时钟 → GPIO → 输入 → 中断 → 工具链 → 调试”的路线走完一遍,你就已经具备接手绝大多数单片机项目的基础能力。接下来要学的不是更多外设,而是如何让多个任务并行不冲突——那是 RTOS 要解决的问题。