嵌入式 C 与桌面 C 的差别

语法一模一样,工作环境却完全不同。嵌入式 C 面对的是几十 KB 的 RAM、没有操作系统、需要精确控制的时序,以及“一次错误就变砖”的现场。桌面 C 里那些可以忽略的细节——内存布局、编译器的优化行为、变量是否 volatile——在嵌入式里全都变成了主要矛盾。

嵌入式 C 的功夫都花在看不见的地方:内存布局决定程序能不能跑起来,volatile 决定编译器会不会骗你,位操作决定你写的是不是硬件驱动。
嵌入式 C 配图:代码编辑窗口与芯片引脚相连
嵌入式 C 的功夫都花在看不见的地方:内存布局、寄存器位域和编译器的优化行为

下面按“内存—寄存器—位操作—结构体映射—动态内存—编译行为”的顺序,把嵌入式 C 里最关键的六个话题讲清楚。每个话题都给正反两面的例子,因为这里的坑几乎都是从“看起来能跑”开始的。

内存布局:变量住在哪里

一个 STM32 工程的大小通常表述为“Flash 64KB / RAM 20KB”,这句话背后是链接脚本里的两段地址范围,以及几个约定俗成的段。

段存放内容所在位置上电行为
.text代码与常量函数Flash直接执行
.rodataconst 常量、字符串字面量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; }
    }
}

两个真实场景印证这个选择:串口接收用环形缓冲,可以保证无论上层处理多慢都不会丢帧;周期性的报文解析用固定块池,每次处理完立即归还,运行几年也不会产生碎片。