3. 嵌入式基础回顾:C/C++基础语法回顾、内存管理(堆栈、静态区)、中断与定时器、GPIO与外设控制
好,咱们正式开始嵌入式基础回顾。这一章我把它放在课程前面,是因为不管你后面要做多炫的仪表界面,底层跑的还是C/C++。说白了,HMI开发就是“用代码控制硬件画图”。如果基础不牢,后面遇到内存泄漏、中断冲突、外设驱动卡死,你会非常痛苦。
我个人习惯是,每接手一个新项目,先花半天把芯片的内存布局和中断向量表过一遍。这比急着写界面代码重要得多。咱们今天就把这些硬骨头啃下来。
3.1 C/C++基础语法回顾:指针、结构体与volatile
嵌入式C/C++和桌面端开发最大的区别是什么?你得直接操作内存。所以指针和结构体是家常便饭。
3.1.1 指针:不只是地址
很多初学者觉得指针就是地址。嗯,对,但不全对。在嵌入式里,指针还代表了访问权限和数据类型宽度。
// 定义一个指向无符号32位整型的指针
uint32_t *p_reg = (uint32_t *)0x40020000;
// 通过指针写寄存器
*p_reg = 0x01; // 写4个字节
这里要注意,如果你用 uint16_t * 去访问同一个地址,一次只读写2个字节。我在项目中遇到过因为指针类型用错,导致外设寄存器配置不全,屏幕死活不亮的情况。排查了一整天,最后发现是 uint32_t 写成了 uint16_t。
3.1.2 结构体:打包硬件寄存器
仪表开发中,外设寄存器通常是一组连续的地址。用结构体来映射,代码会非常清晰。
typedef struct {
volatile uint32_t CR; // 控制寄存器,偏移0x00
volatile uint32_t SR; // 状态寄存器,偏移0x04
volatile uint32_t DR; // 数据寄存器,偏移0x08
} USART_TypeDef;
#define USART1 ((USART_TypeDef *)0x40011000)
// 使用
USART1->CR = 0x0C; // 使能发送和接收
你想想看,如果没有结构体,你得手动计算每个寄存器的偏移地址,写起来又长又容易错。结构体让代码有了“硬件地图”的感觉。
3.1.3 volatile:别让编译器“优化”掉你的操作
这是嵌入式C里最容易忽略的关键字。volatile 告诉编译器:这个变量的值随时可能被硬件改变,别自作聪明优化它。
3.2 内存管理:堆、栈、静态区
嵌入式系统的内存就那么几百KB到几MB,用错了就是死机。咱们得把内存分区搞清楚。
| 内存区域 | 存储内容 | 生命周期 | 典型大小 |
|---|---|---|---|
| 栈(Stack) | 局部变量、函数参数、返回地址 | 函数调用期间 | 几KB ~ 几十KB |
| 堆(Heap) | 动态分配的内存(malloc/new) | 手动分配到手动释放 | 取决于配置 |
| 静态区(.data/.bss) | 全局变量、static变量 | 整个程序运行期间 | 几十KB ~ 几百KB |
| 代码区(.text) | 程序指令 | 只读,整个运行期 | 几百KB ~ 几MB |
3.2.1 栈:别让局部变量太大
我见过有人直接在函数里定义一个 uint8_t buffer[4096] 的局部数组。结果一调用这个函数,栈就溢出了,程序跑飞。仪表开发里,大缓冲区请用静态区或者堆。
3.2.2 堆:慎用动态分配
在仪表HMI里,我强烈不建议在运行时频繁malloc/free。原因有二:一是堆碎片化,跑几天后分配失败;二是实时性无法保证。我个人的习惯是:所有UI缓冲区、任务栈都在初始化时静态分配好,运行期间绝不动态申请。
3.2.3 静态区:全局变量的双刃剑
全局变量方便,但多任务环境下容易出问题。我的原则是:能用局部变量就不用全局,必须用全局就加锁或者关中断保护。
3.3 中断与定时器:让MCU“分时复用”
仪表HMI里,中断无处不在。按键响应、定时刷新、通信接收,全得靠中断。不理解中断,你就没法做实时系统。
3.3.1 中断优先级:别让低优先级的事打断高优先级
ARM Cortex-M系列支持可嵌套中断。我的经验是:定时器中断(用于刷新屏幕)优先级最高,通信中断次之,按键中断最低。为什么?因为屏幕刷新如果被频繁打断,会出现撕裂感。
// 设置中断优先级(以STM32为例)
NVIC_SetPriority(TIM2_IRQn, 0); // 最高优先级,用于屏幕刷新
NVIC_SetPriority(USART1_IRQn, 1); // 中等优先级
NVIC_SetPriority(EXTI0_IRQn, 2); // 最低优先级,按键
3.3.2 定时器:HMI的心跳
仪表界面需要周期性刷新,比如车速表每20ms更新一次。这个“心跳”就是定时器给的。
void TIM2_IRQHandler(void) {
if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) {
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
// 在这里刷新UI
ui_update_flag = 1; // 置标志位,主循环处理
}
}
3.4 GPIO与外设控制:点亮第一盏灯
GPIO是MCU和外界交互的最基本方式。在仪表上,背光控制、蜂鸣器、按键检测,全得靠GPIO。
3.4.1 GPIO模式:输入、输出、复用、模拟
| 模式 | 用途 | 典型场景 |
|---|---|---|
| 推挽输出 | 驱动LED、蜂鸣器 | 背光控制 |
| 开漏输出 | I2C总线、电平转换 | 与3.3V/5V器件通信 |
| 浮空输入 | 读取外部电平 | 按键检测 |
| 上拉/下拉输入 | 防止引脚悬空 | 未使用的引脚 |
| 复用功能 | 外设引脚(UART、SPI) | 串口通信 |
3.4.2 点亮一个LED:从寄存器到HAL库
咱们直接看代码。先看寄存器版本,再看HAL库版本。理解了底层,用库函数才不心虚。
// 寄存器版本:直接操作GPIO
#define GPIOB_BASE 0x40010C00
#define GPIOB_CRL (*(volatile uint32_t *)(GPIOB_BASE + 0x00))
#define GPIOB_BSRR (*(volatile uint32_t *)(GPIOB_BASE + 0x10))
// 配置PB0为推挽输出,50MHz
GPIOB_CRL = (GPIOB_CRL & ~0x0F) | 0x03;
// 设置PB0输出高电平(点亮LED)
GPIOB_BSRR = 0x01;
// HAL库版本:更易读
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOB_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET);
3.4.3 按键消抖:硬件还是软件?
仪表上的按键,如果不做消抖,按一次可能触发十次。我的做法是:硬件加电容,软件加延时。硬件上在按键两端并联0.1uF电容,软件里检测到电平变化后延时20ms再读一次。
// 软件消抖示例
uint8_t key_scan(void) {
if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) {
HAL_Delay(20); // 延时20ms
if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) {
return 1; // 确认按下
}
}
return 0;
}