从工程角度理解嵌入式C语言关键字

1. static:嵌入式模块封装的基础

static 是嵌入式开发中非常重要的关键词。它主要有三种用法:

  1. 修饰局部变量

  2. 修饰全局变量

  3. 修饰函数

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_enablemotor_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 的核心作用是告诉编译器:

这个变量可能会被当前代码流程之外的因素改变,因此每次访问它时都必须真实地从内存或寄存器读取,不能擅自优化。

这里所谓"当前代码流程之外的因素",在嵌入式里通常包括:

  1. 中断服务函数;

  2. 硬件寄存器;

  3. DMA;

  4. 多任务环境中的其他任务;

  5. 外设自动修改的内存区域。


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 永远不成立。
    }
}

注意:即使有 volatileif (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 volatileconst__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 最佳实践总结

  1. 所有硬件寄存器声明或指针必须带 volatile,用 __IO 之类宏统一。

  2. 中断和主循环共享的标志、计数、缓冲区指针,一律声明为 static volatile

  3. RTOS 中直接共享的变量,如果没用锁,至少用 volatile 保证读取到最新值,但更要考虑原子性。

  4. 不要指望 volatile 解决竞争问题,它只是编译器优化屏障,不是并发同步原语。

  5. 在多核或需要严格顺序时,配合内存屏障指令使用。

  6. 不要滥用,仅用于可能异步变化的变量,普通局部变量不要加。

简单记忆:一旦发现变量的值可能来自"视界之外"(硬件、中断、另一个核、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. typedefstructenum:嵌入式模块建模三件套

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 可以,不过很多嵌入式编译器仍要求常量表达式)。因此用 #defineenum 定义编译期常量仍是主流。


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):

  1. 编译 math.c 时,编译器看到 squareinline,尝试在调用处内联。

  2. 如果内联成功,没问题。

  3. 但如果编译器决定不内联(例如你用了 -O0 无优化,或函数被取地址等),它会生成一个对 square 的函数调用,但此时 square 没有函数体------因为 inline 定义不生成外部符号。

  4. 链接器报告: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. packedaligned:结构体对齐和 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 | 避免宏污染 |

三条黄金法则:

  1. 能用标准 C 解决的,不用编译器扩展;但硬件控制必然依赖扩展,必须仔细查阅手册。

  2. 类型安全、无副作用优先:用 static inline 代替函数宏,用 enum/const 代替 #define 常量。

  3. volatile 只保证访问不优化,static 只保证作用域,weak 只解决链接优先级,attribute 只提供编译提示------它们都不提供原子性、互斥或顺序保护,切勿混用职责。

相关推荐
FBI HackerHarry浩1 小时前
在Python中TCP网络程序开发的步骤流程
运维·服务器·开发语言·网络·python·pycharm
方也_arkling1 小时前
【Java-Day16】API篇-Math类/System类/Object类/包装类
java·开发语言
x***r1511 小时前
burpsuite-1.4.07.jar 使用步骤详解(附Java环境配置与Burp Suite抓包教程)
java·开发语言·jar
Cosmoshhhyyy1 小时前
《Effective Java》解读第54条:返回零长度的数组或者集合,而不是null
java·开发语言·python
清风一徐1 小时前
Python函数基础
开发语言·python
代码地平线1 小时前
C++ 入门篇类和对象·上篇:从本质深剖类与对象与C++基本用法
c语言·开发语言·数据结构·c++·笔记·算法
云上码厂1 小时前
R 语言基于 lavaan 包实现结构方程模型 (SEM) 从环境配置到建模绘图全流程实战
开发语言·r语言
zhangfeng11331 小时前
htc 中minconda 明明安装了 Python 3.10显示 python 3.8 因为 `conda activate` 没有真正切换成功
开发语言·python·conda
十五年专注C++开发1 小时前
C++17之类模板实参自动推导CTAD
开发语言·c++·聚合初始化·catd