嵌入式 C 与桌面 C 的差别
语法一模一样,工作环境却完全不同。嵌入式 C 面对的是几十 KB 的 RAM、没有操作系统、需要精确控制的时序,以及“一次错误就变砖”的现场。桌面 C 里那些可以忽略的细节——内存布局、编译器的优化行为、变量是否 volatile——在嵌入式里全都变成了主要矛盾。
嵌入式 C 的功夫都花在看不见的地方:内存布局决定程序能不能跑起来,volatile 决定编译器会不会骗你,位操作决定你写的是不是硬件驱动。
下面按“内存—寄存器—位操作—结构体映射—动态内存—编译行为”的顺序,把嵌入式 C 里最关键的六个话题讲清楚。每个话题都给正反两面的例子,因为这里的坑几乎都是从“看起来能跑”开始的。
内存布局:变量住在哪里
一个 STM32 工程的大小通常表述为“Flash 64KB / RAM 20KB”,这句话背后是链接脚本里的两段地址范围,以及几个约定俗成的段。
| 段 | 存放内容 | 所在位置 | 上电行为 |
|---|---|---|---|
| .text | 代码与常量函数 | Flash | 直接执行 |
| .rodata | const 常量、字符串字面量 | Flash | 只读,不占 RAM |
| .data | 已初始化的全局/静态变量 | 初值在 Flash,运行在 RAM | 启动代码拷贝到 RAM |
| .bss | 未初始化或初值为 0 的全局变量 | RAM | 启动代码清零 |
| heap / stack | 动态分配区与函数调用栈 | RAM | 由启动文件与链接脚本划分 |
/* 链接脚本片段:显式告诉代码 Flash 与 RAM 的边界 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}
SECTIONS
{
.text : { *(.text*) *(.rodata*) } > FLASH
.data : { *(.data*) } > RAM AT> FLASH /* 初值存在 Flash,运行在 RAM */
.bss : { *(.bss*) *(COMMON) } > RAM
_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶从 RAM 末端向下生长 */
}
/* 用 const 把大表放进 Flash,能立刻省下可观的 RAM */
const uint16_t crc_table[256] = { /* … */ }; /* 占 Flash,不占 RAM */
一条实用技巧:如果你突然加了一个大数组导致程序跑飞,先检查它有没有落在栈区——栈向下生长,全局变量向上分配,两者相遇时表现出的往往不是“数组越界”,而是某个不相干函数里的局部变量莫名其妙变了值。
指针与寄存器:volatile 是必须的
访问硬件寄存器时,编译器不知道这个地址会被硬件改变,于是可能把重复读取优化成一次,或者干脆删掉它认为无用的写操作。volatile 的作用就是告诉编译器“每次都老老实实去读/写”。
/* 反面教材:等待标志位,编译器可能把循环优化成死循环 */
while (!(USART1->SR & USART_SR_TXE)) { } /* 若指针未声明 volatile,风险极高 */
/* 正确写法:外设寄存器指针必须是 volatile */
#define REG32(addr) (*(volatile uint32_t *)(addr))
#define USART1_SR REG32(0x40013800)
#define USART1_DR REG32(0x40013804)
#define USART_SR_TXE (1U << 7)
while (!(USART1_SR & USART_SR_TXE)) { } /* 循环体为空,编译器不会优化掉读取 */
USART1_DR = (uint8_t)ch;
/* 与中断共享的标志变量同样需要 volatile,否则主循环可能永远读不到更新 */
static volatile uint8_t g_key_pressed = 0;
void EXTI0_IRQHandler(void) { g_key_pressed = 1; }
volatile 解决的是“编译器优化”,解决不了“原子性”。在多任务或多核场景中,对共享变量的读-改-写仍需临界区保护,这两件事经常被混为一谈。
位操作:把寄存器当开关用
驱动代码里出现频率最高的就是位操作。把整套操作封装成宏,既避免手写出错,也让代码可读性提升一档。
#define BIT(n) (1UL << (n))
#define SET_BIT(reg, n) ((reg) |= BIT(n))
#define CLR_BIT(reg, n) ((reg) &= ~BIT(n))
#define TOG_BIT(reg, n) ((reg) ^= BIT(n))
#define GET_BIT(reg, n) (((reg) >> (n)) & 0x1U)
/* 多位字段(如模式选择占 2 位)要整体替换,而不是逐位改动 */
#define GET_FIELD(reg, mask, pos) (((reg) & (mask)) >> (pos))
#define SET_FIELD(reg, mask, pos, val) ((reg) = ((reg) & ~(mask)) | (((val) << (pos)) & (mask)))
/* 使用示例:把 GPIO 模式字段(假设位于 bit0..1)设置为 0b10 */
SET_FIELD(GPIOC_CR, 0x3U, 0, 0x2U);
if (GET_BIT(GPIOC_ODR, 13) == 0) { /* PC13 为低电平 */ }
/* 用查表代替分支做位反转,比循环更快也更省代码空间 */
static uint8_t reverse8(uint8_t b)
{
b = (uint8_t)((b & 0xF0U) >> 4 | (b & 0x0FU) << 4);
b = (uint8_t)((b & 0xCCU) >> 2 | (b & 0x33U) << 2);
b = (uint8_t)((b & 0xAAU) >> 1 | (b & 0x55U) << 1);
return b;
}
写多重位字段时最容易犯的错是“先清后置”漏掉清除,导致上一状态残留。统一用 SET_FIELD 这样的整体替换宏,能从结构上避免这类 Bug。另外注意:对硬件寄存器做读-改-写(|=、&=、^=)并不是原子操作,中断里同时改同一个寄存器就会出问题。
结构体映射:把寄存器组写成一张表
当外设寄存器有几十个时,逐个定义宏会很痛苦。用结构体对齐映射是工业界的通行做法,Cortex-M 的 CMSIS 头文件就是这么写的。
#include <stdint.h>
typedef struct {
volatile uint32_t CR; /* 0x00 控制寄存器 */
volatile uint32_t SR; /* 0x04 状态寄存器 */
volatile uint32_t DR; /* 0x08 数据寄存器 */
volatile uint32_t BRR; /* 0x0C 波特率寄存器 */
uint32_t RSVD[4]; /* 0x10~0x1C 保留区,保证后续偏移正确 */
} UART_TypeDef;
#define UART1 ((UART_TypeDef *)0x40013800)
void uart_send_byte(uint8_t b)
{
while ((UART1->SR & (1U << 7)) == 0) { } /* 等 TXE */
UART1->DR = b;
}
/* 静态断言:确保结构体布局与硬件手册一致 */
#define STATIC_ASSERT(cond, msg) typedef char static_assert_##msg[(cond) ? 1 : -1]
STATIC_ASSERT(sizeof(UART_TypeDef) == 0x20, uart_size_mismatch);
- 成员必须是 volatile结构体整体声明为指向 volatile 的指针即可覆盖所有成员。
- 保留区不可省用数组补齐偏移,否则后续寄存器地址全错,且编译期不会报错。
- 用静态断言校验把结构体大小写进编译期检查,改动手册映射时立刻暴露。
- 位域谨慎使用位域的字序与对齐由编译器实现决定,跨编译器移植时容易出问题,优先使用移位掩码。
动态内存:为什么嵌入式要谨慎
标准库的 malloc/free 在嵌入式里通常意味着两件事:堆的大小不确定,以及运行久了会产生碎片。对需要连续运行数年的设备来说,这两点都不可接受。更稳妥的做法是静态分配加固定块内存池。
/* 环形缓冲区:无锁(单生产者单消费者场景)、零动态分配 */
typedef struct {
uint8_t buf[256];
volatile uint16_t head; /* 写指针 */
volatile uint16_t tail; /* 读指针 */
} ring_t;
static bool ring_put(ring_t *r, uint8_t v)
{
uint16_t next = (uint16_t)((r->head + 1) % sizeof(r->buf));
if (next == r->tail) return false; /* 满:留一个空位以区分空/满 */
r->buf[r->head] = v;
r->head = next;
return true;
}
static bool ring_get(ring_t *r, uint8_t *v)
{
if (r->head == r->tail) return false; /* 空 */
*v = r->buf[r->tail];
r->tail = (uint16_t)((r->tail + 1) % sizeof(r->buf));
return true;
}
/* 固定块内存池:需要“分配/释放”语义时的替代方案 */
static uint8_t pool_buf[4][64];
static uint8_t pool_used[4];
void *pool_alloc(void)
{
for (int i = 0; i < 4; i++) {
if (!pool_used[i]) { pool_used[i] = 1; return pool_buf[i]; }
}
return NULL; /* 明确失败,而不是把堆撑爆 */
}
void pool_free(void *p)
{
for (int i = 0; i < 4; i++) {
if (p == pool_buf[i]) { pool_used[i] = 0; return; }
}
}
两个真实场景印证这个选择:串口接收用环形缓冲,可以保证无论上层处理多慢都不会丢帧;周期性的报文解析用固定块池,每次处理完立即归还,运行几年也不会产生碎片。
编译器会怎么改你的代码
开启 -O2 之后,代码逻辑与源码之间会出现让新手困惑的差异。下面这些反例都来自真实项目。
/* 反例 1:忙等标志位——若 flag 非 volatile,可能被优化成死循环 */
while (flag == 0) { }
/* 反例 2:想用空循环做延时——会被整体删除 */
for (int i = 0; i < 1000; i++) { } /* 优化后什么都不做 */
/* 修正:延时用硬件定时器或内核提供的延时函数 */
vTaskDelay(pdMS_TO_TICKS(1));
/* 反例 3:返回局部数组的指针 */
char *bad(void) { char buf[16]; return buf; } /* 栈帧销毁后是悬崖 */
/* 反例 4:把结构体整体写到寄存器地址(未考虑对齐与 volatile) */
memcpy((void *)0x40013800, &cfg, sizeof(cfg)); /* 全部寄存器被“按内存”写一遍 */
/* 需要严格控制顺序时,使用编译器屏障 */
#define barrier() __asm volatile ("" ::: "memory")
prepare_dma_buffer();
barrier(); /* 保证缓冲区写完再启动 DMA,避免读到旧数据 */
start_dma();
- static 不只是“文件内可见”,它还能让编译器放心内联与优化,同时避免符号冲突。
- const 全局数据会被放到 Flash,是最省 RAM 的优化手段之一。
- 函数内使用大数组时要留意栈大小;改成 static 或全局可以避免栈溢出,代价是失去重入性。
编码规范与自检清单
嵌入式代码的生命周期往往比 Web 项目长得多,规范带来的收益是复利式的。下面这份清单可以直接放进代码评审环节。
- 所有硬件寄存器访问使用统一宏或结构体映射,禁止裸地址的字面量散落在业务代码里。
- 所有与中断、DMA、多任务共享的变量必须声明 volatile,并在评审时重点检查。
- 不使用标准库的动态内存;需要动态语义时使用固定块池,并在分配失败时给出明确的降级路径。
- 所有等待循环都要有超时与错误返回,禁止 while(1) 式的无退出等待。
- 对外接口用定长类型(uint8_t/uint16_t/uint32_t),不使用 int/long 这类宽度随平台变化的类型。
- 关键模块配单元测试:把不依赖硬件的逻辑(协议解析、CRC、状态机)抽出来,在 PC 上跑测试用例。
- 每次发版记录 Flash 与 RAM 占用,占用突增本身就是一种缺陷信号。
嵌入式 C 的难点从来不是语法,而是你必须同时扮演两个角色:既要写出人类能维护的代码,又要清楚编译器会把它变成什么样的机器指令。理解这两者之间的距离,就是资深与初级的差别。
把这六节的内容落实到项目里,最常见的“改了 A 结果 B 坏了”“跑几天就死机”这类问题会减少一大半。