3. C语言基础回顾:指针与数组、结构体与联合体、位运算、函数指针、volatile关键字、内存管理(堆栈)

好,咱们直接进入正题。这一章是C语言基础回顾,但你别小看它。我在嵌入式这行干了十几年,见过太多人栽在这些基础细节上。说白了,单片机开发玩的就是底层,C语言功底不扎实,后面写驱动、调bug会非常痛苦。

今天咱们把六个核心知识点过一遍。我尽量用实际项目中的例子来讲,而不是教科书式的罗列。

3.1 指针与数组:剪不断理还乱

指针和数组,在C语言里是天生一对。很多人觉得数组名就是指针,其实不完全对。

数组名是常量指针,它指向数组首元素。但你不能对它做自增自减操作。比如:

int arr[5] = {1, 2, 3, 4, 5};
int *p = arr;  // p指向arr[0]

// arr++  // 错误!数组名不是左值
p++;        // 正确,p现在指向arr[1]

我在项目中遇到过一个问题:用sizeof(arr)和sizeof(p)得到的结果不一样。前者是整个数组的大小(20字节),后者是指针本身的大小(4或8字节)。这个坑,新手经常踩。

关键点:数组作为函数参数传递时,会退化为指针。所以函数内部用sizeof得不到数组长度,必须额外传一个长度参数。

指针运算也很实用。比如你要遍历一个缓冲区:

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

for(int i = 0; i < 256; i++) {
    *(ptr + i) = 0x00;  // 等价于 buffer[i] = 0x00
}

我个人习惯用指针方式,因为编译后生成的汇编代码往往更高效。当然,可读性上数组方式更好,看场景取舍吧。

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

结构体(struct)和联合体(union)是嵌入式开发中组织数据的核心工具。

结构体用来把不同类型的数据打包在一起。比如描述一个传感器数据:

typedef struct {
    uint16_t temperature;  // 温度值,单位0.1°C
    uint16_t humidity;     // 湿度值,单位0.1%RH
    uint8_t  crc;          // 校验字节
} SensorData_t;

这里要注意内存对齐的问题。结构体成员之间可能有填充字节,导致实际大小不是你想象的那样。我曾经调试一个通信协议,就是因为结构体对齐问题,导致发送和接收端数据解析不一致,折腾了大半天。

小技巧:使用#pragma pack(1)可以取消对齐,让结构体紧凑排列。但代价是访问效率降低,有些MCU甚至不支持非对齐访问。

联合体就更有意思了。它让多个变量共享同一块内存。这在解析通信协议时特别有用:

typedef union {
    uint32_t value;
    uint8_t  bytes[4];
} Uint32Converter_t;

// 用法:把一个32位整数拆成4个字节发送
Uint32Converter_t data;
data.value = 0x12345678;
// data.bytes[0] = 0x78, data.bytes[1] = 0x56, ...

嗯,这里要注意大小端问题。不同MCU的字节序可能不一样,写跨平台代码时要小心。

3.3 位运算:单片机开发的看家本领

位运算在单片机开发中太常用了。操作寄存器、设置标志位、数据压缩,都离不开它。

常用的位运算操作符:

运算符 含义 示例
& 按位与 清除特定位
| 按位或 设置特定位
^ 按位异或 翻转特定位
~ 按位取反 反转所有位
<< 左移 乘以2的幂
>> 右移 除以2的幂

实际项目中操作GPIO寄存器:

// 设置GPIOB的第5脚为高电平
GPIOB->BSRR = (1 << 5);

// 清除GPIOB的第5脚
GPIOB->BRR = (1 << 5);

// 读取GPIOB第3脚的状态
uint8_t status = (GPIOB->IDR >> 3) & 0x01;

避坑指南:我曾经在写位操作时,把(1 << 31)写成了(1 << 32),结果编译器警告,但实际运行结果完全不对。对于32位系统,移位量超过31位是未定义行为。

3.4 函数指针:回调机制的基石

函数指针,说白了就是指向函数的指针。它让程序更灵活,是实现回调函数、状态机、驱动层抽象的关键。

定义方式:

// 定义一个函数指针类型,指向返回void、参数为int的函数
typedef void (*Callback_t)(int);

// 使用
void myHandler(int value) {
    printf("Value: %d\n", value);
}

Callback_t cb = myHandler;
cb(42);  // 调用函数

我在写按键驱动时经常用函数指针。不同的按键事件绑定不同的处理函数,代码结构清晰多了:

typedef struct {
    uint8_t      pin;
    Callback_t   pressHandler;
    Callback_t   releaseHandler;
} Button_t;

Button_t btn = {
    .pin = 5,
    .pressHandler   = ledOn,
    .releaseHandler = ledOff
};

你想想看,如果没有函数指针,你得写一堆switch-case,维护起来多痛苦。

3.5 volatile关键字:别让编译器优化掉你的代码

volatile是嵌入式开发中最重要的关键字之一,没有之一。它告诉编译器:这个变量的值可能会在程序之外被改变,别给我优化掉。

典型使用场景:

  • 硬件寄存器:比如读取状态寄存器
  • 中断服务程序中修改的变量
  • 多线程/多任务共享的变量

看个例子:

// 错误写法:没有volatile
uint8_t flag = 0;

void interrupt_handler() {
    flag = 1;  // 中断中修改
}

void main_loop() {
    while(!flag) {
        // 编译器可能优化成:读取一次flag,然后死循环
    }
    // 处理事件
}

加上volatile后:

volatile uint8_t flag = 0;  // 每次循环都重新读取flag的值

血的教训:我曾经调试一个电机控制程序,电机死活不响应停止指令。查了两天,最后发现是中断中修改的停止标志没加volatile,编译器把它优化到寄存器里了,主循环永远读不到新值。

3.6 内存管理(堆栈):嵌入式开发的命脉

嵌入式系统的内存资源非常有限,管理不好就会出大问题。

栈(Stack)

  • 由编译器自动管理
  • 存放局部变量、函数参数、返回地址
  • 大小固定,一般在启动文件中配置
  • 速度快,但空间有限

堆(Heap)

  • 由程序员手动管理(malloc/free)
  • 用于动态分配大块内存
  • 容易产生碎片
  • 在单片机中要慎用

避坑指南:我在一个产品中用了malloc分配缓冲区,运行几个月后系统突然崩溃。查到最后是内存碎片导致分配失败。从那以后,我定了个规矩:单片机项目尽量不用动态内存分配,改用静态数组或内存池。

推荐的内存管理方式——内存池

#define POOL_SIZE 10
#define BLOCK_SIZE 64

static uint8_t memoryPool[POOL_SIZE][BLOCK_SIZE];
static uint8_t poolUsed[POOL_SIZE] = {0};

void* poolAlloc(void) {
    for(int i = 0; i < POOL_SIZE; i++) {
        if(!poolUsed[i]) {
            poolUsed[i] = 1;
            return memoryPool[i];
        }
    }
    return NULL;  // 池已满
}

void poolFree(void *ptr) {
    for(int i = 0; i < POOL_SIZE; i++) {
        if(memoryPool[i] == ptr) {
            poolUsed[i] = 0;
            return;
        }
    }
}

这种方式没有碎片问题,分配和释放都是O(1)复杂度,非常适合单片机。

个人建议:在STM32等MCU上开发,栈大小一般设为1-4KB就够了。如果用到RTOS,每个任务栈大小要根据实际调用深度仔细计算。我习惯留30%的余量,然后通过实际运行监控栈使用情况来调整。

好了,这一章的内容就到这里。这些C语言基础看似简单,但每个点背后都有不少坑。你在实际项目中多踩几次,自然就记住了。下一章咱们开始讲单片机外设驱动开发,那才是真正好玩的地方。