1. static:嵌入式模块封装的基础
static 是嵌入式开发中非常重要的关键词。它主要有三种用法:
-
修饰局部变量
-
修饰全局变量
-
修饰函数
1.1 修饰局部变量:延长局部变量生命周期
普通局部变量每次进入函数都会重新创建:
void func(void)
{
int count = 0;
count++;
printf("count = %d",count);
}
每次调用 func(),count 都会重新变成 0,永远都是打印count = 1。
如果加上 static:
void func(void)
{
static int count = 0;
count++;
printf("count = %d",count);
}
这个 count 只初始化一次,之后每次调用函数都会保留上一次的值。
第一次打印count = 1,第二次打印count = 2,....,每次加1。
static修饰局部变量的特点:
|------|-----------------|------------------------------------|
| | 普通 局部变量 | static修饰的 局部变量 |
| 生存周期 | 函数执行完毕后死亡 | 生命周期贯穿整个程序运行期间 |
| 作用域 | 函数内部 | 函数内部 |
| 存储空间 | 栈区 | .bss段(未初始化的静态变量) .data段(已初始化的静态变量) |
这在嵌入式里非常适合保存局部状态,例如按键消抖、边沿检测、状态机、滤波器历史值等。
例如按键上升沿检测:
void Key_Scan(void)
{
static uint8_t last_state = 0;
uint8_t now_state = Read_Key();
if (now_state == 1 && last_state == 0)
{
// 检测到按键上升沿
}
last_state = now_state;
}
这里的 last_state 就不应该每次调用都重新初始化,否则永远无法记录上一次的按键状态。
1.2 修饰全局变量:限制变量只在当前 .c 文件内可见
例如:
static uint8_t motor_state = 0;
这表示 motor_state 只能在当前 .c 文件中使用,其他文件不能通过 extern 访问它。
对比普通全局变量:
uint8_t motor_state = 0;
这种变量可以被其他文件通过下面的方式访问:
extern uint8_t motor_state;
但是如果变量被定义为:
static uint8_t motor_state = 0;
它就只属于当前文件。
这在嵌入式模块化开发里非常重要。比如写一个电机模块:
// motor.c
static uint8_t motor_enable = 0;
static int16_t motor_speed = 0;
void Motor_Enable(void)
{
motor_enable = 1;
}
void Motor_SetSpeed(int16_t speed)
{
motor_speed = speed;
}
外部文件只能通过 Motor_Enable()、Motor_SetSpeed() 这些接口访问电机模块,而不能直接修改 motor_enable 和 motor_speed。
这就是 C 语言里最基础的模块封装。
1.3 修饰函数:限制函数只在当前 .c 文件中使用
例如:
static void Motor_UpdatePWM(void)
{
// 电机内部 PWM 更新逻辑
}
这个函数只能在当前 motor.c 文件中调用,其他文件无法访问。
通常来说,.h 文件中声明的是对外接口:
// motor.h
void Motor_Init(void);
void Motor_SetSpeed(int16_t speed);
而 .c 文件中可以包含一些内部辅助函数:
// motor.c
static void Motor_UpdatePWM(void)
{
// 内部实现细节
}
工程上可以这样理解:
非 static 函数 = 对外 API
static 函数 = 当前模块内部函数
这个习惯越早养成,后期项目规模变大时越能减少混乱。
2. volatile:告诉编译器"这个变量可能会突然变化"
volatile 的核心作用是告诉编译器:
这个变量可能会被当前代码流程之外的因素改变,因此每次访问它时都必须真实地从内存或寄存器读取,不能擅自优化。
这里所谓"当前代码流程之外的因素",在嵌入式里通常包括:
-
中断服务函数;
-
硬件寄存器;
-
DMA;
-
多任务环境中的其他任务;
-
外设自动修改的内存区域。
2.1 为什么嵌入式必须有 volatile ?------ 编译器的"好心办坏事"
编译器为了生成高效的代码,默认认为变量只会在程序显式修改时变化。于是它会做这些优化:
-
寄存器缓存:把某个变量读到寄存器中反复使用,不写回内存。
-
冗余读写消除:多次读同一变量,只读一次,后面直接用缓存值。
-
指令重排:在保证单线程语义不变的情况下,调整指令执行顺序。
这些优化在普通程序里没有问题,但在嵌入式系统里,硬件状态、中断事件、DMA操作等会异步地改变内存,如果编译器不知情,就会产生逻辑错误。volatile 就是用来阻止这些优化的。
2.2 volatile 的三大典型应用场景
2.2.1 内存映射的硬件寄存器
这是最经典的用途。所有对硬件寄存器的访问都必须用 volatile。
#define GPIOA_ODR_ADDR 0x40020014
#define GPIOA_ODR (*(volatile uint32_t *)GPIOA_ODR_ADDR)
// 错误:如果没有 volatile
GPIOA_ODR = 0x01;
GPIOA_ODR = 0x02;
// 编译器可能认为连续两次写入,第一次无意义而删掉第一条写操作。
// 硬件却需要这两次顺序的写入(比如先清除再置位)。
一个实际例子:读取状态寄存器直至某个标志置位。
// UART 状态寄存器
#define UART_SR (*(volatile uint32_t *)0x40013800)
#define TXE_FLAG (1 << 7)
void uart_send_byte(uint8_t data) {
// 等待发送空标志
while (!(UART_SR & TXE_FLAG)); // 必须每次重新读寄存器!
// 如果是 while(!(uart_sr_copy & TXE_FLAG)),且 uart_sr_copy 是非volatile的,
// 编译器可能只读取一次 UART_SR,循环就变成死循环。
UART_DR = data;
}
关键点:不只是指针,只要是通过变量名访问的寄存器,该变量定义或引用时必须用 volatile 修饰。
2.2.2 中断服务程序与主程序共享的变量
中断服务程序与主程序共享的变量,该变量必须 volatile。
static volatile bool data_ready = false; // 注意:static + volatile
void ISR_ADC(void) {
// ADC转换完成
data_ready = true;
}
int main(void) {
while (1) {
if (data_ready) {
data_ready = false;
process_adc_data();
}
// 如果没有 volatile,编译器可能把 data_ready 的值缓存在寄存器里,
// 再也看不到中断写入的新值,导致 if 永远不成立。
}
}
注意:即使有 volatile,if (data_ready) 和 data_ready = false 之间也可能被中断打断,如果高要求安全,还需要临界区保护。volatile 只保证读取的是最新值,不保证操作的原子性。
2.2.2 多任务(RTOS)共享的变量
在没有使用互斥锁等保护机制的任务间通信中,用 volatile 防止编译器优化掉读写。
// 两个线程通过标志通信
static volatile int flag = 0;
void task_sensor(void) {
// 当标志为 1 时执行操作
if (flag) {
// ...
}
}
void task_controller(void) {
flag = 1; // 保证写入立即生效
}
强烈建议:多任务环境下,
volatile不能代替同步原语(信号量、互斥锁)。仅当访问是简单的标志位且数据竞争不影响正确性时,才可谨慎使用volatile。
2.3 volatile 与指针的常见形式
定义指向 volatile 对象的指针有几种写法,必须分清楚:
volatile uint32_t *reg; // 指针本身可变,指向的数是 volatile 的
uint32_t volatile *reg; // 同上,写法不同,意义相同
volatile uint32_t * const reg; // 指针不可变(如固定地址),指向 volatile 数据
uint32_t * volatile reg; // 指针本身是 volatile 的(很少用)
对于固定的硬件寄存器地址,我们通常想要:
#define REG (*(volatile uint32_t *)0x40000000)
// 或
volatile uint32_t * const reg = (volatile uint32_t *)0x40000000;
错用:如果写成 uint32_t * volatile reg,那么指针本身是 volatile,但指向的数据不是,读取 *reg 仍可能被优化。
2.4 volatile在嵌入式中最致命的几个误区
volatile 在嵌入式 C 里之所以"致命",是因为它的职责非常狭窄------只禁止编译器优化读写。然而开发者常常把它当成原子操作、内存屏障、互斥锁来用,这些误解会直接导致系统运行出现非常诡异、难以复现的 bug。
2.4.1 用 volatile 保证原子性(最常见的致命错误)
volatile uint64_t tick_count; // 假设在 32 位 MCU 上
// 在 SysTick 中断里
void SysTick_Handler(void) {
tick_count++; // 你以为安全?
}
// 主循环
uint64_t get_tick(void) {
return tick_count; // 你以为能读到完整值?
}
为什么致命: tick_count++ 在 32 位 CPU 上至少是三条汇编指令(加载低 32 位、加载高 32 位、带进位加、存回)。若中断发生在指令中间,主循环读到的 tick_count 可能高 32 位已更新、低 32 位还是旧的,组合成一个幽灵时间戳。系统可能随机颠倒时序,触发严重逻辑错误。volatile 只保证每次去内存读,完全不管操作的不可分割性。
修正方法:
-
关闭中断做临界区保护(如
__disable_irq()/__enable_irq())。 -
使用 C11 原子类型
_Atomic uint64_t或编译器内置的原子操作函数。 -
如果只是递增,有些平台提供原子加指令,用内联汇编或 CMSIS 函数实现。
2.4.2 把 volatile 当互斥锁
volatile int shared_data;
void task_A(void) {
shared_data = compute_value(); // 非原子写入
}
void task_B(void) {
if (shared_data == 3) {
// 做关键操作
}
}
在 RTOS 环境下,即使加了 volatile,任务 B 仍可能在任务 A 写入 shared_data 到一半时被抢占(比如 16 位 CPU 上写 32 位数据)。volatile 仅保证每次访问都穿透缓存,不提供任何任务间互斥。以为 volatile 能替代信号量,必然导致数据竞争和偶发性崩溃。
修正:
必须使用互斥锁、信号量或原子变量。
2.4.3 将 volatile 当作内存屏障
// 配置外设典型操作
volatile uint32_t *cr = (uint32_t*)0x40000000;
volatile uint32_t *sr = (uint32_t*)0x40000004;
*cr = 0x01; // 启动模块
while (!(*sr & 0x02)); // 等待状态位置位
你以为顺序一定对,实际可能致命: 编译器通常不会重排对同一 volatile 变量的访问,但不同 volatile 变量之间的顺序,标准不做保证!某些编译器或激进优化下,*sr 的读取可能先于 *cr 的写入执行。更严重的是多核系统或带写缓冲的 Cortex-M 系列(如 M7),CPU 执行顺序和总线到达外设的顺序可能不同。结果就是:外设根本没收到启动命令,你已经在等状态位了。
修正:
在关键操作间插入编译器屏障和硬件内存屏障:
-
__ASM volatile("" ::: "memory");(编译屏障) -
__DSB();/__DMB();(ARM 硬件屏障) 或者采用 C11 原子操作自带的顺序保证。
2.4.4 指针类型弄错,导致 volatile 根本没生效
// 错误定义:指针是 volatile,指向的数据不是!
uint32_t * volatile reg = (uint32_t *)0x40021000;
*reg = 0x01;
*reg = 0x02; // 编译器可能删掉第一条写操作
这里 volatile 修饰的是 reg 这个指针变量本身,而不是它所指向的硬件寄存器。正确的"固定地址寄存器"指针应该是:
volatile uint32_t * const reg = (volatile uint32_t *)0x40021000;
// 或者
#define REG (*(volatile uint32_t *)0x40021000)
这种错误在代码审查时很难一眼发现,但会导致对寄存器的访问全部被优化,硬件完全不受控制。
2.4.5 结构体映射寄存器时,忘记每个成员都必须是 volatile
typedef struct {
uint32_t CR; // 缺了 volatile
uint32_t SR;
} UART_Reg;
UART_Reg *uart1 = (UART_Reg *)0x40011000;
uart1->CR = 0x01;
while (!(uart1->SR & 0x20)); // 编译器可能只读一次 SR,死循环
致命后果: 即使指针本身加了 volatile,通过结构体成员访问时,成员的类型决定了访问特性。如果成员类型不含 volatile,编译器就会做优化。必须这样定义:
typedef struct {
volatile uint32_t CR;
volatile uint32_t SR;
} UART_Reg;
2.4.6 忽视 const volatile 的意义,导致状态位被优化成常量
const uint32_t *status_reg = (uint32_t *)0x40000010;
// 编译器可能认为 *status_reg 在代码里从不改变,直接优化为常量
正确做法是 const volatile uint32_t *。告诉编译器:程序不能写这个地址(const),但它的值可能被外部改变(volatile)。只有这样,每次读取才会真正访问寄存器。
2.5 volatile 与 const、__IO 等的关系
const volatile:一个只读的硬件状态寄存器,程序不能写,但硬件可能改变。
const volatile uint32_t * const status_reg = (uint32_t *)0x40000010;
// 只读,所以加 const;硬件会变,所以加 volatile。
在 STM32 等头文件中常使用 __IO、__I、__O 等宏来区分:
#define __IO volatile // 可读写
#define __I volatile const // 只读
#define __O volatile // 只写(C没有只写限定,仍用volatile)
本质上就是 volatile 的不同组合,提升代码可读性。
2.6 嵌入式 volatile 最佳实践总结
-
所有硬件寄存器声明或指针必须带
volatile,用__IO之类宏统一。 -
中断和主循环共享的标志、计数、缓冲区指针,一律声明为
static volatile。 -
RTOS 中直接共享的变量,如果没用锁,至少用
volatile保证读取到最新值,但更要考虑原子性。 -
不要指望
volatile解决竞争问题,它只是编译器优化屏障,不是并发同步原语。 -
在多核或需要严格顺序时,配合内存屏障指令使用。
-
不要滥用,仅用于可能异步变化的变量,普通局部变量不要加。
简单记忆:一旦发现变量的值可能来自"视界之外"(硬件、中断、另一个核、DMA),就加上 volatile。
3. const:只读数据与 Flash 资源优化
const 表示只读。
例如:
const uint8_t table[4] = {1, 2, 3, 4};
这表示 table 不应该被程序修改。
3.1 防止函数误修改数据
例如:
void OLED_ShowString(const char *str)
{
while (*str)
{
OLED_ShowChar(*str++);
}
}
这里的 const char *str 表示函数只读取字符串内容,不会修改字符串内容。
这样可以提高接口安全性。
3.2 常量表尽量放到 Flash 中
在 MCU 中,RAM 往往比 Flash 更宝贵。
例如字体表、正弦表、CRC 表、初始化命令表等,通常应该定义为:
static const uint16_t sin_table[360] = {
// ...
};
这里:
static = 只在当前文件可见
const = 只读,通常可以放在 Flash 中
这类写法在嵌入式驱动中非常常见。
说明: 1.静态变量或全局变量存储在.bss段和.data段 (属于 ram ) 2. 局部变量 存储在栈区(属于ram) 3. malloc 开辟的空间位于堆区(ram) 4.常量存储在.rodata段(属于 rom ) 5.程序代码存储在.text段(属于rom)
4. extern:跨文件引用,但不要滥用
extern 用来声明一个变量或函数是在其他文件中定义的。
例如:
// main.c
uint8_t system_ready = 0;//定义:分配内存,给出初值,整个程序只能有一次定义
如果要在 user.c 中使用它:
// user.c
extern uint8_t system_ready;//声明:仅告知类型和名字,不分配内存,可以有多个声明
3.1 典型实践1:头文件声明 + 源文件定义(标准模式)
// uart.h
#ifndef UART_H
#define UART_H
#include <stdint.h>
extern volatile uint32_t uart_tx_done; // 声明:告诉所有人有这个变量
extern volatile uint8_t uart_rx_buf[64];
void uart_init(uint32_t baud);
void uart_send_byte(uint8_t data);
#endif
// uart.c
#include "uart.h"
volatile uint32_t uart_tx_done = 0; // 定义:真正分配内存
volatile uint8_t uart_rx_buf[64]; // 定义
void uart_init(uint32_t baud) { ... }
void uart_send_byte(uint8_t data) { ... }
使用者只需包含头文件:
// main.c
#include "uart.h"
void main(void) {
uart_init(115200);
while (!uart_tx_done); // 正确引用
}
3.2 典型实践2:用 EXTERN 宏避免重复(工程级技巧)
如果项目里有很多全局变量(比如几十个),在头文件里写声明,在 .c 里写定义,维护起来很烦------加一个变量要改两个地方,还容易漏。
于是有了一种聪明办法:用一个头文件同时搞定定义和声明,用宏开关控制。
// globals.h
#ifndef GLOBALS_H
#define GLOBALS_H
// 重点:根据是否定义了 GLOBALS_DEFINE,让 EXTERN 变成 "extern" 或者 ""(空)
#ifdef GLOBALS_DEFINE
#define EXTERN
#else
#define EXTERN extern
#endif
EXTERN volatile uint32_t g_ms_ticks;
EXTERN int g_error_code;
EXTERN uint8_t g_log_buffer[256];
// 任何文件都能看到的变量声明/定义,都放这里
#endif
在唯一的 .c 文件里定义这些变量
// globals.c ------ 唯一的定义文件
#define GLOBALS_DEFINE
#include "globals.h"
// 此时 g_ms_ticks 这行变成: volatile uint32_t g_ms_ticks; ------ 这是定义!
// 同理另外两个也变成定义
其它所有文件只需:
// main.c
#include "globals.h" // 没有定义 GLOBALS_DEFINE,所以 EXTERN 展开为 extern
void main(void) {
g_ms_ticks = 0; // 可以使用
}
原理一句话:同一个头文件,在一个 .c 里被当做定义(因为定义了那个特殊宏),在其他所有 .c 里被当做声明。你只管在 globals.h 里用 EXTERN 前缀加一行变量,再也不用两个地方分别维护了。
这是非常经典的做法,在很多 RTOS(如 FreeRTOS、uC/OS)的示例工程里都能看到。
3.3 典型实践3:extern const ------ 共享只读数据
嵌入式里常有大张的查找表、字库、配置参数,需要存在 Flash 里,供多个文件使用。
// lcd_font.h
#ifndef LCD_FONT_H
#define LCD_FONT_H
#include <stdint.h>
extern const uint8_t font_8x16[][16]; // 注意:二维数组只可省略第一维大小
extern const uint16_t sine_table[256];
#endif
// lcd_font.c
#include "lcd_font.h"
const uint8_t font_8x16[][16] = {
{0x00,0x00,0x00,...}, // 字符 'A'
...
};
const uint16_t sine_table[256] = {
2048, 2098, 2148, ... // 正弦波查找表
};
关键注意:
-
C 语言中
const全局变量默认是外部链接,所以不加extern也会被其他文件可见。但显式写上extern const是更清晰的文档。 -
在 C++ 中
const全局量默认为内部链接,必须用extern const才能跨文件共享。如果项目可能被 C++ 调用,这个习惯可以避免奇怪链接错误。 -
这类数据由链接器安排在
.rodata段,通常烧录在 Flash,不占 RAM。
3.4 典型实践4:extern 函数 ------ 默认已有,显式可增强可读性
函数声明默认就是 extern 的,不必加:
void uart_send_byte(uint8_t data); // 这已经是 extern 了
extern void uart_send_byte(uint8_t data); // 显式写出也行
但在混合 C/C++ 开发时,头文件必须加 extern "C" 以阻止 C++ 的名字修饰:
#ifdef __cplusplus
extern "C" {
#endif
void uart_init(uint32_t baud);
int sensor_read(void);
#ifdef __cplusplus
}
#endif
嵌入式底层库(如 CMSIS、STM32 HAL)的头文件几乎都有这段保护。
3.5 典型实践5:暴露操作句柄而非内部变量(封装实践)
尽量不要把内部变量暴露为全局 extern,而是暴露"句柄"或访问函数。
// 差的实践:
// sensor.h
extern int16_t raw_accel[3]; // 任何人都能直接改
// 好的实践:
// sensor.h
typedef struct AccelData {
int16_t x, y, z;
} AccelData;
void sensor_get_accel(AccelData *out);
// sensor.c
static volatile AccelData accel_data; // 内部 static,绝对安全
void sensor_get_accel(AccelData *out) {
// 可加临界区保护
*out = accel_data;
}
这样外部只能通过 API 访问,数据一致性和安全由模块内部保证。
5. typedef、struct、enum:嵌入式模块建模三件套
5.1 typedef:给类型起别名
typedef 用来给类型起一个新的名字。
例如我们常见的:
uint8_t
uint16_t
uint32_t
int16_t
很多都是通过 typedef 定义出来的。
在嵌入式中,typedef 常和结构体一起使用:
typedef struct {
volatile uint32_t SR;
...
} UART_TypeDef;
UART_TypeDef uart1; // 干净
没有 typedef 的老式写法:
struct UART_Reg {
volatile uint32_t SR;
...
};
struct UART_Reg uart1; // 每次都要带上 struct 关键字
5.2 struct:把相关数据组织成一个对象
例如一个电机对象:
typedef struct
{
uint8_t id;
int16_t target_speed;
int16_t current_speed;
uint8_t enable;
} Motor_t;
使用时:
Motor_t motor_left;
motor_left.id = 1;
motor_left.target_speed = 1000;
motor_left.enable = 1;
结构体在嵌入式中非常适合描述:
-
• 电机对象;
-
• 舵机对象;
-
• 传感器对象;
-
• 通信协议包;
-
• 系统状态;
-
• 控制器参数。
例如一个关节传感器:
typedef struct
{
float angle;
float velocity;
uint8_t online;
} JointSensor_t;
5.3 enum:定义状态和模式
enum 用来定义一组有限状态。
例如电机状态:
typedef enum
{
MOTOR_STOP = 0,
MOTOR_RUN,
MOTOR_ERROR
} MotorState_t;
使用:
MotorState_t state = MOTOR_STOP;
if (state == MOTOR_ERROR)
{
// 处理错误
}
相比直接使用数字,枚举的可读性更好。
不推荐:
if (state == 2)
{
// 这个 2 到底代表什么?
}
推荐:
if (state == MOTOR_ERROR)
{
// 一眼能看懂
}
在状态机中,enum 非常常用:
typedef enum
{
SYS_INIT = 0,
SYS_IDLE,
SYS_RUNNING,
SYS_FAULT
} SystemState_t;
6. union:协议解析和数据转换中的工具
union 是联合体,它的多个成员共用同一块内存。
这决定了 union 在嵌入式中的典型用法:用不同的"视角"看同一块 内存。
例如:
typedef union
{
uint32_t value;
uint8_t bytes[4];
} Data32_t;
Data32_t data;
data.value = 0x12345678;
此时可以通过:
data.bytes[0]
data.bytes[1]
data.bytes[2]
data.bytes[3]
访问这个 32 位数据的每个字节。
在串口、CAN、SPI 协议解析中,union 有时很方便。
但是需要注意大小端问题。STM32 常见为小端模式,因此:
data.value = 0x12345678;
内存中的排列通常是:
78 56 34 12
如果协议规定的是大端格式,就需要额外转换。
7. #define:不仅仅是替换文本
#define 是预处理宏,在编译前进行文本替换。
7.1 对象宏------定义常量与寄存器地址
最朴素的用法:给字面量起个名字。
#define PI 3.14159f
#define BUFFER_SIZE 128
#define FLASH_BASE 0x08000000UL
嵌入式里最经典的应用是寄存器地址映射:
#define GPIOA_ODR (*(volatile uint32_t *)0x40020014)
这样 GPIOA_ODR = 0x01; 就直接写硬件寄存器。如果没有宏,每次写一长串指针转换,代码可读性极差。
注意: 宏只是文本替换,不分配内存,没有类型检查。对比 const 常量,const int buf_size = 128; 有类型,可调试,但占用内存(如果是全局 const 则在 Flash),且无法用在数组长度声明中(C89 不允许变长数组,但 C99 可以,不过很多嵌入式编译器仍要求常量表达式)。因此用 #define 或 enum 定义编译期常量仍是主流。
7.2 函数宏------内联代码的"快刀"
带参数的宏在预处理阶段展开,省去函数调用开销,在频繁操作的底层驱动里很常见:
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define ABS(x) ((x) < 0 ? -(x) : (x))
致命陷阱 1:优先级问题
必须给每个参数和整体加括号,否则展开后逻辑全错:
#define SQUARE(x) x * x // 危险!
int y = SQUARE(3 + 2); // 展开:3 + 2 * 3 + 2 = 11,不是 25
正确写法:#define SQUARE(x) ((x) * (x))
致命陷阱 2:副作用(多次求值)
宏参数如果是带有副作用的表达式,会执行多次:
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int c = MAX(a++, b);
// 展开:((a++) > (b) ? (a++) : (b))
// 如果 a++ 大于 b,则 a 加了两次,莫名其妙的值
解决: 在 C99 以后,如非必要,用 static inline 函数代替函数宏。inline 函数有类型检查、参数只求值一次,且同样在头文件中高效展开。
7.3 嵌入式高级宏技巧
(1) 字符串化 #
把宏参数转换成字符串常量:
#define STR(x) #x
printf(STR(Hello)); // 输出 "Hello"
常用于调试信息:
#define DEBUG_PRINT(val) printf(#val " = %d\n", val)
int err = 5;
DEBUG_PRINT(err); // 输出 err = 5
(2) 连接符 ##
把两个宏参数拼接成一个标识符:
#define GPIO(port, pin) GPIO##port##_PIN##pin
GPIO(A, 5); // 展开为 GPIOA_PIN5
这在批量生成寄存器名称时非常有用,例如用基地址宏拼出不同的外设结构体指针:
#define UART(num) UART##num
UART(1)->DR = 'A'; // UART1->DR
(3) 可变参数宏 VA_ARGS
宏可以接受不定数量的参数:
#define LOG(fmt, ...) printf("[%s:%d] " fmt, __FILE__, __LINE__, ##__VA_ARGS__)
LOG("Temp=%d\n", temp);
##VA_ARGS 是 GCC 扩展,当可变参数为空时,前面的逗号会被吞掉,避免语法错误。C99 标准无此特性,需依赖编译器支持。
(4) 宏的 do { ... } while(0) 包装
假如你定义了一个包含多条语句的函数宏,如果直接写 { ... },在 if-else 中会断裂:
#define SAFE_FREE(p) free(p); p = NULL
if (data)
SAFE_FREE(data);
else
do_something();
// 展开后 else 和 if 配对出错!
正确:用 do { ... } while(0) 包装成"单条语句":
#define SAFE_FREE(p) do { free(p); p = NULL; } while(0)
这样无论放在什么控制流里都安全。
7.1 宏的常见坑
错误写法:
#define SQUARE(x) x * x
调用:
SQUARE(1 + 2)
预处理后会变成:
1 + 2 * 1 + 2
结果是 5,而不是 9。
正确写法:
#define SQUARE(x) ((x) * (x))
所以宏要特别注意括号。
对于较复杂的逻辑,更推荐使用 static inline 函数,而不是宏。
7.2 条件编译
#define USE_DEBUG 1
#if USE_DEBUG
printf("debug message\r\n");
#endif
如果 USE_DEBUG 为 1,这段代码会参与编译;如果为 0,则不会参与编译。
常见用途包括:
-
• 打开或关闭调试日志;
-
• 区分不同硬件版本;
-
• 区分不同传感器配置;
-
• 区分裸机工程和 RTOS 工程。
8. 条件编译:让同一份代码适配多个硬件
条件编译允许你根据宏是否定义或表达式的真假,选择性地保留或剔除代码。这在嵌入式里至少有三大刚需:头文件保护、调试、多平台兼容。
8.1 头文件保护(防止重复包含)
#ifndef UART_H
#define UART_H
// 所有头文件内容
#endif
这是每个 .h 文件的第一条防线,确保一个编译单元包含多次时内容只出现一次。
8.2 #if / #elif / #else / #endif
#if 后跟整型常量表达式,可以组合出复杂的配置逻辑。
调试开关
#define DEBUG_LEVEL 2
#if DEBUG_LEVEL >= 2
#define LOG_DEBUG(...) printf(__VA_ARGS__)
#elif DEBUG_LEVEL == 1
#define LOG_DEBUG(...) printf("Warning: " __VA_ARGS__)
#else
#define LOG_DEBUG(...) // 空,不输出
#endif
硬件平台适配
#define PLATFORM STM32F407
#if PLATFORM == STM32F103
#define LED_PIN GPIO_PIN_13
#elif PLATFORM == STM32F407
#define LED_PIN GPIO_PIN_14
#else
#error "Unsupported platform!"
#endif
配合 #ifdef 更常见的是判断某个宏是否定义:
#ifdef USE_FREERTOS
#include "FreeRTOS.h"
#define delay(x) vTaskDelay(x)
#else
#define delay(x) HAL_Delay(x)
#endif
在 Makefile 或 IDE 中通过 -DUSE_FREERTOS 即可切换。
8.3 defined() 操作符
defined(XXX) 返回 1 或 0,可参与逻辑组合:
#if defined(STM32F4) && !defined(USE_HAL)
#error "F4 only supports HAL library"
#endif
这是最清晰的写法,#ifdef STM32F4 只是 #if defined(STM32F4) 的简写。
8.4 #if 0 ------ 大段代码注释
#if 0
// 这段代码暂时不用,但留着参考
old_implementation();
#endif
可以注释掉包含 /* */ 的代码段,这是 /* */ 注释做不到的。
9. inline:小函数的轻量封装
inline 表示建议编译器将函数展开,而不是产生一条跳转指令,从而减少函数调用开销。
但注意:inline 只是建议,不是命令。编译器完全有权忽略它(尤其是函数体复杂或递归时)。它不改函数的语义,只影响代码生成策略。
9.1 inline(裸 inline,嵌入式中不使用)
它定义了一个内联定义,编译器不会为它生成独立的函数体代码,只在调用处尝试内联展开。如果某个调用点编译器决定不内联(例如函数太复杂、优化级别太低),那么编译器会期望在另一个编译单元中找到该函数的外部定义,否则链接时就会报"未定义的外部符号"。
假设你有一个 math.c:
#include <stdio.h>
inline int square(int x) { return x * x; }
int main(void) {
printf("%d\n", square(5));
return 0;
}
编译链接过程(假设用 gcc -std=c99 或 -std=c11):
-
编译
math.c时,编译器看到square是inline,尝试在调用处内联。 -
如果内联成功,没问题。
-
但如果编译器决定不内联(例如你用了
-O0无优化,或函数被取地址等),它会生成一个对square的函数调用,但此时square没有函数体------因为inline定义不生成外部符号。 -
链接器报告:
undefined reference to `square'
核心矛盾:内联失败时,需要函数体,而 inline 定义恰好不提供函数体。
嵌入式中的问题:这种两段式维护(头文件内联定义 + 一个源文件外部定义)非常繁琐且易出错,一旦忘记提供外部定义,在低优化级别或复杂函数时项目就链接失败。因此嵌入式开发中几乎不使用裸 inline。
9.2 static inline ------ 嵌入式黄金标准
函数具有内部链接。每个包含此定义的 .c 文件都会得到一个独立的函数副本。如果编译器决定内联,则原地展开;如果编译器拒绝内联,它就变成一个普通的 static 函数,在 .o 文件中生成函数体,但对外不可见。
场景 1:只在单个 .c 内使用的辅助函数(适合放在 .c 中)
// adc.c
static inline uint16_t adc_average(uint16_t *buf, int len) {
uint32_t sum = 0;
for (int i = 0; i < len; i++) sum += buf[i];
return sum / len;
}
void adc_task(void) {
// 本文件内多次调用
uint16_t raw = adc_average(samples, 8);
...
}
这是最干净的做法:
-
函数只服务于本模块,外部不需要知道它。
-
static限制了作用域,避免命名污染。 -
inline提示编译器展开,可能消除循环调用开销。
场景 2:跨模块频繁调用的硬件操作(必须放在 .h 中)
// gpio.h
static inline void gpio_set(GPIO_TypeDef *port, uint16_t pin) {
port->BSRR = pin;
}
如果把这个函数定义在某个 .c 文件里,比如 gpio.c,那么 main.c 在调用 gpio_set(GPIOA, 5) 时,编译器在编译 main.c 时根本看不见函数体,只能生成一条函数调用指令,链接时再去找 gpio.c 里的函数体------这就变成了普通函数调用,完全失去了内联的意义。
只有把定义放在头文件中,main.c 通过 #include 直接看到函数体,编译器才有机会把它展开成一两条汇编指令。
10. weak:STM32 HAL 回调函数背后的机制
在 STM32 HAL 库中,经常能看到类似这样的函数:
__weak void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
}
weak 表示弱定义。
可以这样理解:
如果用户没有自己实现这个函数,就使用这个默认版本;如果用户自己实现了同名函数,就使用用户自己的版本。
例如 HAL 库里提供了一个空的弱函数:
__weak void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
}
用户可以在自己的代码里重新实现:
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_0)
{
key_flag = 1;
}
}
链接时,用户自己的强定义会覆盖 HAL 中的弱定义。
这就是为什么很多 STM32 回调函数可以直接重写。
weak在 嵌入式 中的典型应用场景:
1.启动代码中的默认中断处理:
芯片厂商提供的启动文件(startup.s)中,所有中断向量默认指向 weak 定义的 Dummy_Handler。
用户只需在自己的 .c 文件中定义同名函数(strong),即可自动覆盖默认实现,无需修改启动文件。
2.HAL 库的弱回调机制:
STM32 HAL 库大量使用 __weak 属性定义回调函数(如 HAL_UART_RxCpltCallback)。
用户可选择性地实现这些回调,未实现时链接器自动使用 HAL 的默认空实现,避免链接错误。
3.板级支持包(BSP)抽象:
在跨平台驱动设计中,weak 函数提供默认的 GPIO/时钟配置。
具体板级只需重定义关键函数,即可适配不同硬件,无需条件编译或运行时判断。
4.单元测试中的桩函数:
被测模块依赖的底层函数声明为 weak,测试时链接桩实现(stub),生产代码链接真实实现。
同一套测试代码无需修改即可适配不同测试场景。
11. packed 与 aligned:结构体对齐和 DMA 中的重要细节
11.1 packed:取消结构体自动对齐
例如:
typedef struct
{
uint8_t id;
uint32_t value;
} Packet_t;
从直觉上看,这个结构体大小是:
1 + 4 = 5 字节
但由于编译器会进行内存对齐,它的实际大小可能是 8 字节。
如果用于通信协议,这可能导致发送出去的数据和协议定义不一致。
可以使用:
typedef struct __attribute__((packed))
{
uint8_t id;
uint32_t value;
} Packet_t;
这样结构体会紧凑排列,大小变成 5 字节。
不过要注意,某些 MCU 对非对齐访问并不友好,可能导致访问效率下降,甚至出现硬件异常。
11.2 aligned:指定数据对齐方式
例如:
uint8_t dma_buffer[128] __attribute__((aligned(4)));
表示这个数组的地址按照 4 字节对齐。
在 DMA、Cache、USB、以太网、SDIO 等场景中,对齐非常重要。
尤其是 STM32H7 这类带 DCache 的芯片,如果 DMA buffer 没有正确对齐,并且没有处理 Cache 一致性,可能会出现非常隐蔽的数据错误。
12. sizeof:看似简单,但数组退化很容易出错
sizeof 用来获取变量或类型占用的字节数。
例如:
uint32_t a;
sizeof(a); // 通常是 4
sizeof(uint32_t); // 通常是 4
在发送数组时经常使用:
uint8_t buffer[64];
HAL_UART_Transmit(&huart1, buffer, sizeof(buffer), 100);
这里 sizeof(buffer) 是 64。
但如果数组作为函数参数传入:
void Send(uint8_t buf[])
{
sizeof(buf);
}
此时 buf 已经退化为指针,sizeof(buf) 得到的是指针大小,而不是数组长度。
所以更推荐这样写:
void Send(uint8_t *buf, uint16_t len)
{
HAL_UART_Transmit(&huart1, buf, len, 100);
}
调用时:
uint8_t buffer[64];
Send(buffer, sizeof(buffer));
13.工程使用建议
13.1 模块内部变量尽量使用 static
例如:
static uint8_t sensor_online = 0;
static float joint_angle[21];
不要轻易把变量暴露成全局变量。
13.2 中断和主循环共享变量要考虑 volatile
例如:
static volatile uint8_t rx_done = 0;
static volatile uint32_t tick_count = 0;
但如果涉及多字节变量或复合操作,还要配合临界区。
13.3 查表数据尽量使用 static const
例如:
static const uint16_t crc_table[256] = {
// ...
};
这样可以减少 RAM 占用,也可以避免数据被误修改。
13.4 .h 文件放接口,.c 文件放实现
推荐结构:
// motor.h
void Motor_Init(void);
void Motor_SetSpeed(int16_t speed);
// motor.c
static uint8_t motor_enable = 0;
static void Motor_UpdatePWM(void)
{
// 内部实现
}
void Motor_Init(void)
{
// 初始化
}
void Motor_SetSpeed(int16_t speed)
{
// 设置速度
}
这样工程结构会更清晰,也更接近真实产品项目的组织方式。
14.总结
|-------------|---------------------------------------------|---------------|
| 需求 | 推荐关键字/工具 | 备注 |
| 硬件寄存器访问 | volatile + struct/union | 所有成员 volatile |
| 中断共享标志 | static volatile | 非原子操作需加临界区 |
| 模块内部封装 | static | 函数和全局变量 |
| 跨模块共享变量 | extern 在头文件声明,.c 定义 | 类型/限定符完全一致 |
| 短小频繁调用的硬件操作 | static inline | 替代函数宏 |
| 寄存器字/字节拆分 | union + 字节数组/位域 | 确认大小端和位域行为 |
| 状态机/错误码 | enum | 更安全,带类型提示 |
| 通信协议帧映射 | struct + attribute((packed)) | 注意非对齐陷阱 |
| 可覆盖的默认回调 | attribute((weak)) / __weak | 中断入口、HAL 回调 |
| 内存对齐、自定义段 | attribute((aligned(n), section("..."))) | 必须匹配链接脚本 |
| 编译期配置、多平台适配 | 条件编译 #if / #ifdef | 避免宏污染 |
三条黄金法则:
-
能用标准 C 解决的,不用编译器扩展;但硬件控制必然依赖扩展,必须仔细查阅手册。
-
类型安全、无副作用优先:用
static inline代替函数宏,用enum/const代替#define常量。 -
volatile只保证访问不优化,static只保证作用域,weak只解决链接优先级,attribute只提供编译提示------它们都不提供原子性、互斥或顺序保护,切勿混用职责。