第4章 C语言在嵌入式中的应用:位操作、指针与数组、结构体与联合体、内存管理、volatile关键字

嵌入式C语言,说白了就是“贴着硬件写代码”。你在PC上写个死循环顶多CPU飙高,在嵌入式里写错一个位操作,外设可能直接罢工。我这些年带项目,见过太多“PC上跑得好好的,下到板子就崩”的案例,十有八九都是本章这几个点没吃透。

咱们一个一个来拆。

4.1 位操作:嵌入式工程师的“肌肉记忆”

位操作是嵌入式的基本功。寄存器配置、状态标志判断、中断屏蔽……哪哪都离不开它。我个人习惯,拿到一个新MCU,第一件事就是翻头文件里那些位掩码宏定义怎么写。

4.1.1 常用位操作运算符

运算符名称典型用途
&按位与清位、取指定位
|按位或置位
^按位异或翻转位
~按位取反构造掩码
<<左移乘2、构造位掩码
>>右移除2、提取高位

核心原则:读-改-写三步走。先读寄存器当前值,用掩码修改指定位,再写回去。千万别直接赋值,否则会清掉其他位的配置。

4.1.2 实战:GPIO输出配置

假设你要把GPIOB端口的第5脚设为推挽输出。STM32的GPIO配置寄存器长这样:

// 方式1:直接赋值(不推荐,会破坏其他引脚配置)
GPIOB->CRL = 0x00200000;  // 危险!

// 方式2:读-改-写(推荐)
GPIOB->CRL &= ~(0xF << 20);  // 先清PB5的4位配置
GPIOB->CRL |= (0x3 << 20);   // 再设为推挽输出50MHz

我在项目中遇到过有人图省事直接赋值,结果调试了三天才发现把PB0~PB3的配置全冲掉了。嗯,从那以后我团队里谁写直接赋值,代码Review直接打回。

小技巧:多用宏定义把位掩码封装好,比如 #define GPIO_PIN5_MASK (0xF << 20),代码可读性会好很多。

4.2 指针与数组:C语言的灵魂

指针这东西,初学者觉得玄乎,老手觉得顺手。但在嵌入式里,指针用不好就是段错误的温床。你想想看,一个野指针指到外设寄存器地址上,后果是什么?

4.2.1 数组名与指针的关系

数组名就是首元素地址,这个大家都知道。但有个坑:sizeof(数组名) 返回整个数组大小,而 sizeof(指针) 只返回指针本身大小(4或8字节)。

uint8_t buffer[64];
uint8_t *ptr = buffer;

sizeof(buffer);  // 64
sizeof(ptr);     // 4(32位MCU上)

我曾经在DMA配置时,把 sizeof(buffer) 写成了 sizeof(ptr),结果DMA只传输了4个字节。查了两天才发现……

4.2.2 指针与const的搭配

写法含义典型场景
const uint8_t *p指向常量的指针(数据不可改)字符串常量、查表数据
uint8_t * const p常量指针(地址不可改)外设寄存器基地址
const uint8_t * const p常指针指向常量只读外设寄存器

注意:在RTOS的任务栈里,局部指针变量默认在栈上分配。如果任务栈太小,指针指向的缓冲区可能溢出。我建议大缓冲区用静态分配或动态分配,别放栈上。

4.3 结构体与联合体:数据组织的利器

结构体用来打包不同类型的数据,联合体用来复用同一段内存。两者结合,在协议解析、寄存器映射场景下特别好用。

4.3.1 结构体对齐问题

这是嵌入式面试高频题。结构体成员按最大对齐字节数对齐,中间可能有填充字节。

typedef struct {
    uint8_t  a;  // 偏移0
    uint32_t b;  // 偏移4(不是1!)
    uint8_t  c;  // 偏移8
} __attribute__((packed)) MyStruct;  // 加packed取消对齐

// 不加packed:sizeof = 12
// 加packed:sizeof = 6

我在做Bootloader时,结构体没加packed,结果解析固件头总是错位。后来发现是编译器自动填充了3个字节。嗯,从此以后,所有通信协议相关的结构体,我必加 __attribute__((packed))。

4.3.2 联合体妙用:寄存器位域访问

typedef union {
    uint32_t value;
    struct {
        uint32_t pin0  : 1;
        uint32_t pin1  : 1;
        uint32_t pin2  : 1;
        uint32_t pin3  : 1;
        uint32_t reserved : 28;
    } bits;
} GPIO_ODR_Type;

GPIO_ODR_Type *odr = (GPIO_ODR_Type *)0x40020C14;
odr->bits.pin0 = 1;  // 只操作第0脚

这样写,代码意图一目了然。但要注意:位域的顺序是编译器相关的,跨平台移植时要小心。

4.4 内存管理:嵌入式系统的命脉

嵌入式内存就那么几百K甚至几十K,用错了就是死机。我见过最惨的一次,是产品量产10K后才发现内存泄漏,召回成本够买一套房了。

4.4.1 嵌入式内存分区

区域特点典型用途
栈(Stack)自动分配释放,大小固定局部变量、函数调用
堆(Heap)动态分配,需手动释放malloc/free,慎用
全局/静态区程序生命周期内存在全局变量、static变量
代码区只读,Flash中程序指令、常量

4.4.2 动态内存分配:能用静态就别用动态

在RTOS里,我建议尽量用静态分配。为什么?

  • 确定性:静态分配在编译时就确定了大小,不会运行时失败。
  • 无碎片:动态分配反复malloc/free会产生内存碎片。
  • 可预测:栈溢出可以通过MPU检测,堆溢出很难定位。

我的原则:任务栈、消息队列、信号量等RTOS对象,全部静态创建。只有运行时才知道大小的数据(比如变长协议包),才考虑动态分配,而且必须加超时和失败处理。

4.4.3 内存泄漏检测小技巧

如果你非要用动态分配,我建议封装一层:

// 带计数器的malloc封装
void *my_malloc(size_t size) {
    void *ptr = malloc(size);
    if (ptr) {
        malloc_count++;
    }
    return ptr;
}

void my_free(void *ptr) {
    free(ptr);
    malloc_count--;
}

// 在空闲任务中打印malloc_count
// 如果持续增长,说明有泄漏

4.5 volatile关键字:告诉编译器“别优化我”

volatile是嵌入式里最容易忽略、也最容易出问题的关键字。它的作用就一句话:告诉编译器,这个变量的值可能被“意料之外”的方式修改,每次访问都必须从内存读取。

4.5.1 什么时候必须用volatile?

  • 外设寄存器:硬件可能随时改变寄存器值。
  • 中断服务程序中的全局变量:主循环和中断共享的变量。
  • RTOS中多任务共享的全局变量:一个任务写,另一个任务读。

4.5.2 血泪教训:不加volatile的后果

// 错误示例:中断标志位没加volatile
uint8_t flag = 0;

void ISR_Handler(void) {
    flag = 1;  // 中断里置位
}

void main_loop(void) {
    while (!flag) {
        // 编译器可能优化成:读取一次flag到寄存器,然后死循环
        // 因为编译器认为flag永远不会被修改
    }
    // 处理事件
}

我曾经在电机控制项目里,就因为这个bug,电机死活不响应急停中断。加了volatile之后,问题秒解。你想想看,要是量产了才发现,那得召回多少台?

注意:volatile不能保证原子性。如果多个任务同时读写一个变量,即使加了volatile,也可能出现数据竞争。这时候需要加临界区或互斥锁。

4.6 本章知识体系总览

下面这张图,把本章的核心知识点串起来了。你可以把它当成一个“嵌入式C语言避坑地图”。

嵌入式C语言核心知识点体系 嵌入式C语言核心 位操作 寄存器配置 掩码构造 读-改-写 指针与数组 地址操作 const搭配 边界检查 结构体与联合体 数据打包 内存对齐 位域访问 内存管理 栈/堆/静态区 静态分配优先 泄漏检测 volatile关键字 禁止编译器优化 外设/中断/多任务 不保证原子性 💡 核心原则: 贴近硬件、避免优化陷阱、静态分配优先、读-改-写三步走

本章小结:位操作是基本功,指针是灵魂,结构体联合体是组织数据的利器,内存管理是命脉,volatile是安全锁。把这五个点吃透了,嵌入式C语言这块就算真正入门了。下一章咱们开始聊RTOS的任务管理,到时候你会发现,今天讲的这些知识点,每一个都会反复用到。