1. 单片机基础巩固:从LED闪烁到寄存器操作,理解MCU的底层工作原理
大家好,我是你们的老朋友。今天咱们聊聊单片机的基础。
很多人学嵌入式,上来就搞Linux驱动,结果遇到中断、内存映射就懵了。为啥?说白了,底层基础没打牢。单片机就是最好的入门砖。你想想看,一个LED闪烁,背后藏着时钟、GPIO、寄存器、位操作——这些都是MCU最核心的东西。
1.1 从点亮一个LED开始
我记得刚入行那会儿,第一个项目就是让LED闪烁。当时觉得简单,不就是给个高电平嘛。但真正做起来,发现门道不少。
先看个最基础的代码:
// 51单片机,点亮P1.0口的LED
#include <reg51.h>
sbit LED = P1^0;
void main() {
while(1) {
LED = 0; // 低电平点亮
delay(500);
LED = 1; // 高电平熄灭
delay(500);
}
}
这段代码,初学者都能看懂。但我要问几个问题:
P1^0到底是什么?LED = 0是怎么让引脚变低的?delay(500)真的准吗?
嗯,这里要注意。很多新手写了好几年代码,都没搞明白这些问题。我个人习惯,每写一行代码,都要问自己:这行代码在硬件上干了什么?
1.2 寄存器操作——真正控制MCU的方式
上面那个sbit,其实是编译器帮我们封装了。真正的底层操作,是直接读写寄存器。
拿STM32F103来说,点亮PC13引脚上的LED:
// 直接操作寄存器
#define GPIOC_BASE 0x40011000
#define GPIOC_CRH (*(volatile unsigned int *)(GPIOC_BASE + 0x04))
#define GPIOC_ODR (*(volatile unsigned int *)(GPIOC_BASE + 0x0C))
void LED_Init(void) {
// 设置PC13为推挽输出,50MHz
GPIOC_CRH &= ~(0xF << 20); // 先清零
GPIOC_CRH |= (0x3 << 20); // 设置为推挽输出
}
void LED_On(void) {
GPIOC_ODR &= ~(1 << 13); // 低电平点亮
}
void LED_Off(void) {
GPIOC_ODR |= (1 << 13); // 高电平熄灭
}
看到没?这才是MCU的本来面目。没有库函数,没有封装,就是直接往内存地址写数据。
核心概念:MCU本质上就是一个特殊的内存映射设备。每个外设(GPIO、定时器、UART)都对应一段内存地址。你往这些地址写数据,就是在控制硬件。
1.3 位操作——嵌入式工程师的基本功
我在项目中遇到过一个问题:一个GPIO口同时控制LED和按键,结果LED闪烁时按键检测失灵。查了半天,发现是位操作没做好,影响了其他位。
位操作有四个基本动作:
| 操作 | C语言写法 | 说明 |
|---|---|---|
| 置1 | REG |= (1 << n) |
将第n位置1,其他位不变 |
| 清0 | REG &= ~(1 << n) |
将第n位清0,其他位不变 |
| 读取 | bit = (REG >> n) & 1 |
读取第n位的值 |
| 翻转 | REG ^= (1 << n) |
将第n位取反 |
小技巧:我习惯用宏定义封装位操作,比如 #define SET_BIT(reg, n) (reg |= (1 << n))。这样代码可读性高,也不容易出错。
1.4 时钟系统——MCU的心脏
很多人忽略时钟。但时钟不对,一切都不对。
我曾经调试一个I2C设备,死活通信不上。用示波器一看,SCL时钟频率是110kHz,而我配置的是100kHz。差这么一点,设备就不认了。
时钟配置的典型流程:
- 选择时钟源(内部RC、外部晶振、PLL)
- 配置分频系数
- 等待时钟稳定
- 切换系统时钟
以STM32为例,配置系统时钟为72MHz:
void SystemClock_Config(void) {
// 1. 使能HSE(外部高速晶振)
RCC->CR |= RCC_CR_HSEON;
while(!(RCC->CR & RCC_CR_HSERDY)); // 等待HSE就绪
// 2. 配置FLASH预取指
FLASH->ACR = FLASH_ACR_PRFTBE | FLASH_ACR_LATENCY_2;
// 3. 配置PLL:HSE 8MHz * 9 = 72MHz
RCC->CFGR |= RCC_CFGR_PLLSRC_HSE | RCC_CFGR_PLLMULL9;
RCC->CR |= RCC_CR_PLLON;
while(!(RCC->CR & RCC_CR_PLLRDY));
// 4. 切换系统时钟为PLL输出
RCC->CFGR &= ~RCC_CFGR_SW;
RCC->CFGR |= RCC_CFGR_SW_PLL;
while((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);
}
注意:时钟切换时,一定要等待就绪标志位。我见过有人没等就绪就往下执行,结果系统跑飞了。这种bug最难查,因为不是每次都复现。
1.5 中断——让MCU学会"响应"
轮询方式太浪费CPU了。比如按键检测,你总不能一直死循环读引脚吧?这时候就需要中断。
中断的配置步骤:
- 配置NVIC(嵌套向量中断控制器)
- 使能外设中断
- 编写中断服务函数
看个外部中断的例子:
// 配置PA0为外部中断,下降沿触发
void EXTI0_Config(void) {
// 1. 使能时钟
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_AFIOEN;
// 2. 配置PA0为输入
GPIOA->CRL &= ~(0xF << 0); // 输入模式
// 3. 配置EXTI
AFIO->EXTICR[0] &= ~AFIO_EXTICR1_EXTI0;
EXTI->IMR |= EXTI_IMR_MR0; // 不屏蔽
EXTI->FTSR |= EXTI_FTSR_TR0; // 下降沿触发
// 4. 配置NVIC
NVIC_EnableIRQ(EXTI0_IRQn);
NVIC_SetPriority(EXTI0_IRQn, 1);
}
// 中断服务函数
void EXTI0_IRQHandler(void) {
if(EXTI->PR & EXTI_PR_PR0) {
// 处理按键事件
EXTI->PR = EXTI_PR_PR0; // 清除中断标志
}
}
这里有个坑:中断服务函数里,一定要先判断中断标志,再清除。顺序反了,可能会漏掉中断。
1.6 从单片机到Linux的思维转变
学完这些,你会发现:单片机就是一个小型Linux系统。它有CPU、内存、外设,只是资源少得多。
两者的对应关系:
| 单片机概念 | Linux对应 | 说明 |
|---|---|---|
| 寄存器 | 设备寄存器(ioremap) | 都是通过内存地址访问硬件 |
| 中断 | 中断请求(IRQ) | 处理机制类似,Linux更复杂 |
| GPIO | GPIO子系统 | Linux封装了更高级的接口 |
| 定时器 | hrtimer、clockevents | 精度更高,功能更丰富 |
说白了,你在单片机上写的每一行寄存器操作,到了Linux里,就是驱动开发的基础。我见过很多Linux驱动工程师,遇到硬件问题就抓瞎,就是因为不懂底层。
我的建议:别急着学Linux驱动。先在单片机上把GPIO、中断、定时器、UART、I2C、SPI这些外设都玩一遍。每个外设都自己写寄存器操作,不要用库函数。等你把MCU摸透了,再去看Linux驱动,会发现很多东西都是相通的。
1.7 实战:用寄存器实现呼吸灯
最后,咱们来个综合练习。用PWM实现呼吸灯效果,全部用寄存器操作:
// 呼吸灯:LED从暗到亮,再从亮到暗
void BreathLight(void) {
uint16_t duty = 0;
uint8_t dir = 1; // 1: 增加, 0: 减少
// 配置定时器2的CH1为PWM输出
// ...(省略配置代码,读者自行完成)
while(1) {
// 更新占空比
TIM2->CCR1 = duty;
// 延时(实际应用中用定时器中断)
for(volatile uint32_t i = 0; i < 1000; i++);
// 改变占空比方向
if(dir) {
duty++;
if(duty >= 1000) dir = 0;
} else {
duty--;
if(duty == 0) dir = 1;
}
}
}
这个例子,把GPIO、定时器、PWM、循环控制都用上了。你能独立写出来,说明单片机基础已经过关了。
好了,这一章就到这里。下一章,咱们聊聊中断的深入应用,以及如何用状态机来管理复杂的程序逻辑。到时候见。