3、C语言基础回顾(上):指针与内存管理、结构体与联合体、位运算技巧

各位同学,咱们今天聊聊C语言里几个硬核的东西。做嵌入式,说白了就是跟内存、寄存器、数据打包打交道。这三样玩不转,后面算法移植基本寸步难行。我当年刚入行时,就因为一个指针没处理好,让整个系统跑飞了,找bug找了整整两天……嗯,从那以后,我对这些基础就再也不敢马虎了。

3.1 指针与内存管理:嵌入式开发的“命根子”

指针这东西,很多初学者觉得玄乎。其实你把它想象成“门牌号”就行。变量是房子,指针就是门牌号。你拿着门牌号,就能找到房子,往里面放东西或者拿东西。

但在嵌入式里,指针的意义远不止于此。我们经常要直接操作寄存器地址、管理动态内存、处理数据缓冲区。这些场景,指针是绕不开的。

3.1.1 指针的本质与声明

指针变量存的是地址。32位单片机里,地址是4字节;64位里是8字节。声明时,*告诉编译器:“我要存地址”。

int a = 10;
int *p = &a;   // p存的是a的地址
*p = 20;        // 通过p修改a的值,现在a=20

我个人习惯,声明指针时一定初始化,要么给有效地址,要么给NULL。野指针是嵌入式的大忌。

⚠️ 我曾经踩过的坑: 定义了一个指针没初始化,直接拿来用。结果它指向了一个随机地址,写数据时把系统关键数据覆盖了,导致死机。从那以后,我所有指针定义都养成习惯:int *p = NULL;

3.1.2 指针与数组的“暧昧关系”

数组名就是首元素地址。所以 arr&arr[0] 是等价的。但注意,arr 是常量指针,不能自增自减。

int arr[5] = {1,2,3,4,5};
int *p = arr;   // p指向arr[0]
p++;            // 现在p指向arr[1]
printf("%d", *p); // 输出2

你想想看,遍历数组时用指针比下标快吗?其实编译器优化后差别不大。但有些场景,比如操作DMA缓冲区、处理协议栈数据包,指针的灵活性就体现出来了。

3.1.3 动态内存管理:malloc/free的“正确打开方式”

嵌入式里,动态内存分配要慎用。尤其是资源受限的MCU,频繁malloc/free会导致内存碎片。我建议:

  • 能用静态分配就别用动态:全局数组、静态缓冲区,简单可靠。
  • 如果非要用,记得检查返回值:malloc可能返回NULL。
  • 谁分配谁释放:避免野指针和内存泄漏。
int *buf = (int *)malloc(100 * sizeof(int));
if (buf == NULL) {
    // 处理错误,不能继续往下走
    return -1;
}
// 使用buf...
free(buf);
buf = NULL;  // 释放后置空,防止野指针
💡 我的小技巧: 在free之后立即把指针置NULL。这样即使后面不小心用了,程序会直接崩溃在NULL指针上,而不是随机地址,调试起来容易得多。

3.2 结构体与联合体:数据打包的艺术

传感器数据往往不是单一类型。比如一个IMU数据,有加速度、角速度、温度。用结构体打包,清晰又高效。

3.2.1 结构体的内存对齐

这是个大坑。结构体成员不是紧挨着存的,编译器会按对齐规则填充空白。为什么?因为CPU访问对齐数据更快。

成员类型 对齐字节数
char 1
short 2
int 4
指针 4(32位)/ 8(64位)
struct SensorData {
    char id;       // 1字节
    // 这里会填充3字节
    int value;     // 4字节
    short flag;    // 2字节
    // 这里会填充2字节
};  // 总大小12字节,而不是1+4+2=7

我在项目中遇到过,用结构体直接跟硬件寄存器映射,结果因为对齐问题,读出来的数据全是错的。后来加了 __attribute__((packed)) 才解决。

// 取消对齐,按实际大小排列
struct __attribute__((packed)) SensorData {
    char id;
    int value;
    short flag;
};  // 现在大小是7字节
⚠️ 注意: packed虽然省空间,但访问未对齐成员时,某些MCU会触发硬件异常,或者性能下降。权衡使用。

3.2.2 联合体:同一内存的不同“视角”

联合体所有成员共享同一块内存。大小由最大成员决定。这在解析协议数据、处理寄存器位域时特别好用。

union Data {
    uint32_t word;
    uint8_t  bytes[4];
};

union Data d;
d.word = 0x12345678;
// d.bytes[0] = 0x78 (小端模式)
// d.bytes[1] = 0x56
// d.bytes[2] = 0x34
// d.bytes[3] = 0x12

你看,一个联合体,既能当32位整数用,又能按字节拆开。我经常用它来解析传感器返回的原始数据包,省去了手动移位拼接的麻烦。

3.3 位运算技巧:嵌入式优化的“瑞士军刀”

位运算直接操作二进制位,效率极高。在寄存器配置、状态标志管理、数据压缩中无处不在。

3.3.1 基本操作:置位、清零、翻转、提取

uint8_t reg = 0b00001111;

// 置位第3位(从0开始)
reg |= (1 << 3);   // reg = 0b00001111 | 0b00001000 = 0b00001111

// 清零第0位
reg &= ~(1 << 0);  // reg = 0b00001111 & 0b11111110 = 0b00001110

// 翻转第2位
reg ^= (1 << 2);   // reg = 0b00001110 ^ 0b00000100 = 0b00001010

// 提取第1-3位
uint8_t bits = (reg >> 1) & 0x07;  // 取第1、2、3位

这些操作,说白了就是单片机编程的“日常”。配置GPIO、设置定时器、读写外设寄存器,全是这套路。

3.3.2 位域:结构体里的“位操作”

C语言允许在结构体里按位定义成员。这在描述硬件寄存器时非常直观。

struct StatusReg {
    uint8_t enable : 1;   // 位0
    uint8_t mode   : 2;   // 位1-2
    uint8_t flag   : 1;   // 位3
    uint8_t reserved : 4; // 位4-7
};

struct StatusReg sr;
sr.enable = 1;
sr.mode = 2;

不过要注意,位域的顺序和内存布局是编译器相关的。跨平台移植时,我建议还是用宏定义加移位操作更稳妥。

💡 我的经验: 在写底层驱动时,我习惯用宏定义来操作寄存器位。比如 #define SET_BIT(reg, bit) ((reg) |= (1 << (bit)))。这样代码可读性强,而且移植到不同编译器都没问题。

3.3.3 位运算的实用技巧

  • 判断奇偶if (x & 1)if (x % 2) 快。
  • 交换两数a ^= b; b ^= a; a ^= b; 不用临时变量。
  • 计算2的幂1 << n 就是2的n次方。
  • 取模2的幂x & (n-1) 等价于 x % n(n为2的幂)。

这些技巧看着简单,但在循环里用上,性能提升是实打实的。我记得有一次优化一个传感器数据滤波算法,把取模运算换成位与,整个处理时间降了30%。

小结

指针、结构体、位运算,是嵌入式C语言的三大基石。指针让你掌控内存,结构体帮你组织数据,位运算让你高效操作硬件。这三样东西,光看书不行,得多写多练。我建议你找个开发板,对着寄存器手册,用指针操作外设,用结构体打包数据,用位运算配置寄存器。实践几回,自然就熟了。

下一节咱们接着聊C语言的其他重点,函数指针、链表、回调机制,这些都是算法移植中常用的。到时候见。