2、C语言基础与进阶:指针与内存管理、结构体与联合体、位运算与嵌入式优化

嵌入式开发,说白了就是跟硬件打交道。而C语言,就是我们跟芯片对话的“普通话”。很多初学者觉得C语言语法简单,刷刷题就能过。但真到了嵌入式项目里,你会发现——坑全在细节里。

我个人习惯把C语言分成两个阶段:能写会优化。今天聊的这几个主题,就是帮你从“能写”跨到“会优化”的关键。

2.1 指针:不只是“指向地址”那么简单

指针这东西,教科书上说“指针就是地址”。对,但也不全对。在嵌入式里,指针是操作硬件的直接手段。

2.1.1 指针的本质与类型

我见过不少新手写代码,定义一个 uint32_t *p,然后直接 p = 0x40020000。编译报警告?不管。运行出错了?一脸懵。

嗯,这里要注意:指针的类型决定了它怎么解读内存。同样是地址0x40020000,uint32_t * 会读取4个字节,而 uint8_t * 只读1个字节。你想想看,如果寄存器是32位的,你用 uint8_t * 去读,读到的数据肯定不对。

核心原则:指针的类型不是摆设,它告诉编译器“从这块内存里取出多少数据”。

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

数组名是不是指针?是,也不是。数组名是一个常量指针,你不能给它赋值。但你可以用指针的方式去访问数组元素。

uint8_t buffer[64];
uint8_t *p = buffer;  // 等价于 p = &buffer[0]

// 下面三种写法完全等价
buffer[5] = 0xAA;
*(buffer + 5) = 0xAA;
p[5] = 0xAA;          // 指针也能用下标!

我在项目中遇到过一个问题:用 sizeof(buffer) 得到64,但用 sizeof(p) 只得到4(32位系统)。这就是数组名和指针的区别——sizeof 对数组名返回整个数组大小,对指针只返回指针本身大小。

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

嵌入式里经常用回调函数,比如定时器中断、按键扫描。函数指针就是实现回调的核心。

// 定义一个函数指针类型
typedef void (*button_callback_t)(uint8_t pin_state);

// 注册回调
void button_register_callback(button_callback_t cb) {
    // 保存回调函数地址
}

// 使用回调
void button_isr_handler(void) {
    if (cb_function) {
        cb_function(read_pin());
    }
}

我个人习惯在驱动层大量使用函数指针。这样上层应用只需要注册回调,底层驱动完全解耦。改硬件?换一个回调函数就行,不用动主逻辑。

小技巧:函数指针的声明看起来复杂,可以用 typedef 把它“包装”成一个普通类型名,代码会清爽很多。

2.2 内存管理:嵌入式里的“精打细算”

PC上内存不够?加条内存条就行。嵌入式里?Flash和RAM都是按KB算的。你浪费1KB,可能就得换更贵的芯片。

2.2.1 栈、堆、全局区的“地盘划分”

区域 特点 典型问题
栈(Stack) 自动分配释放,大小固定(通常几KB) 栈溢出(递归太深、局部变量太大)
堆(Heap) 手动分配释放(malloc/free) 内存碎片、忘记释放
全局区(静态区) 程序启动分配,程序结束释放 多线程/中断重入问题

我曾经在一个项目中,因为一个局部数组 uint8_t buf[2048] 直接写在函数里,导致系统跑着跑着就死机。查了半天,发现栈大小默认只有1KB,数组直接溢出了。从那以后,大缓冲区我全用全局变量或者动态分配。

2.2.2 动态内存:用还是不用?

很多嵌入式老手说“嵌入式里别用malloc”。这话有道理,但不绝对。

  • 裸机或RTOS下:尽量不用。malloc可能产生碎片,而且不确定耗时。
  • Linux嵌入式:随便用,有MMU帮你管理。
  • 非要用的场景:比如协议栈解析,数据包大小不确定。这时候可以用,但一定要检查返回值!

避坑指南:我曾经在FreeRTOS里用malloc分配内存,结果任务调度一频繁,系统就随机崩溃。后来发现FreeRTOS有自己的 pvPortMalloc,用这个才稳定。记住:RTOS环境下,用RTOS自己的内存管理函数

2.2.3 内存对齐:性能与兼容性的隐形杀手

ARM Cortex-M3/M4 要求32位数据必须4字节对齐,否则会触发硬件异常。结构体里字段顺序没排好,可能白白浪费空间。

// 不好的写法:浪费空间
typedef struct {
    uint8_t  a;    // 1字节
    uint32_t b;    // 4字节,需要从偏移4开始
    uint8_t  c;    // 1字节
} __attribute__((packed)) BadStruct;  // 总大小:9字节(但访问b可能不对齐)

// 好的写法:按大小排序
typedef struct {
    uint32_t b;    // 4字节
    uint8_t  a;    // 1字节
    uint8_t  c;    // 1字节
} GoodStruct;      // 总大小:8字节(自然对齐)

我个人习惯在定义结构体时,把大的成员放前面,小的放后面。这样既省空间,又避免对齐问题。

2.3 结构体与联合体:数据组织的“瑞士军刀”

2.3.1 结构体:把相关数据“打包”

结构体在嵌入式里太常用了。一个传感器,有温度、湿度、状态,用结构体一包,清晰又好维护。

typedef struct {
    int16_t temperature;   // 温度,单位0.1°C
    uint16_t humidity;     // 湿度,单位0.1%
    uint8_t  status;       // 状态位
    uint32_t timestamp;    // 时间戳
} sensor_data_t;

// 使用
sensor_data_t data;
data.temperature = 255;   // 25.5°C

2.3.2 联合体:同一内存,不同解读

联合体是嵌入式里的“变脸”高手。同一块内存,你可以把它当字节数组,也可以当结构体。这在解析通信协议时特别有用。

typedef union {
    uint32_t value;        // 当作32位整数
    uint8_t  bytes[4];     // 当作4个字节
    struct {
        uint8_t b0;
        uint8_t b1;
        uint8_t b2;
        uint8_t b3;
    } byte_field;          // 当作结构体
} reg32_t;

// 应用场景:读写32位寄存器
reg32_t reg;
reg.value = 0x12345678;
// 现在 reg.bytes[0] = 0x78(小端模式)
// reg.byte_field.b2 = 0x34

我在项目中遇到过一个问题:用联合体解析串口收到的数据包。数据包是4字节的,但不同帧类型含义不同。用联合体,同一块缓冲区,按不同结构体去解读,代码量直接减半。

注意:联合体的大小等于最大成员的大小。所有成员共享同一块内存,修改一个成员会影响其他成员。这是特性,也是陷阱。

2.4 位运算:嵌入式优化的“杀手锏”

位运算在嵌入式里无处不在。设置寄存器、打包数据、状态标志……用好了,代码又小又快。

2.4.1 基本位操作:与、或、异或、取反、移位

操作 运算符 典型用途
置位 |= 设置某位为1
清位 &= ~ 设置某位为0
翻转 ^= 切换某位状态
移位 << / >> 乘2、除2、提取位域
// 实际例子:操作GPIO输出寄存器
#define GPIO_OUT_REG  (*(volatile uint32_t *)0x40020C14)
#define LED_PIN       (1 << 5)   // 第5位

// 点亮LED(置位)
GPIO_OUT_REG |= LED_PIN;

// 熄灭LED(清位)
GPIO_OUT_REG &= ~LED_PIN;

// 翻转LED
GPIO_OUT_REG ^= LED_PIN;

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

C语言允许在结构体里定义位域,精确控制每个成员占几位。这在操作硬件寄存器时特别方便。

// 定义一个控制寄存器位域
typedef struct {
    uint32_t enable : 1;     // bit0: 使能
    uint32_t mode   : 2;     // bit1-2: 模式选择
    uint32_t speed  : 3;     // bit3-5: 速度
    uint32_t reserved : 26;  // bit6-31: 保留
} ctrl_reg_t;

// 使用
ctrl_reg_t *ctrl = (ctrl_reg_t *)0x40021000;
ctrl->enable = 1;
ctrl->mode = 2;

嗯,这里要注意:位域的顺序和内存布局是编译器相关的。不同编译器可能不一样。如果你要写跨平台代码,最好用宏定义加移位操作,别依赖位域。

2.4.3 位运算优化:用空间换时间

你想想看,一个 if (x % 2 == 0)if (x & 1),哪个快?肯定是位运算快。因为取模运算在底层要调用除法指令,而位运算一条指令就搞定。

// 常见优化技巧
// 1. 判断奇偶
if (value & 1)  // 奇数

// 2. 乘2/除2
value <<= 1;   // 乘2
value >>= 1;   // 除2(注意:有符号数右移是算术移位)

// 3. 取模2的幂
value & 0x07;  // 等价于 value % 8

// 4. 交换两个变量(不用临时变量)
a ^= b; b ^= a; a ^= b;

避坑指南:我曾经在优化一个音频算法时,用 >> 1 代替 / 2。结果有符号负数右移后,符号位扩展了,数值完全不对。记住:有符号数的右移是算术移位,会保留符号位。无符号数才是逻辑移位。

2.5 嵌入式优化:从“能跑”到“跑得漂亮”

代码能跑,和代码跑得高效,中间差着十万八千里。嵌入式资源有限,优化是必修课。

2.5.1 编译优化选项

GCC的 -O1-O2-Os 不是摆设。我建议开发阶段用 -O0 方便调试,发布版本用 -Os-O2

  • -O0:不优化,调试方便
  • -O1:基本优化,减少代码大小
  • -O2:更多优化,提高速度
  • -Os:以减小代码体积为目标

2.5.2 用const和static减少RAM占用

常量数据放在Flash里,别占RAM。查表、字符串、配置参数,统统加 const

// 错误:占用RAM
uint8_t sin_table[256] = {0, 1, 2, ...};

// 正确:放在Flash
const uint8_t sin_table[256] = {0, 1, 2, ...};

我个人习惯把所有不会变的数据都加上 const。这样编译器会把它放到Flash段,RAM就能省下来给动态数据用。

2.5.3 循环展开与查表法

对于一些固定次数的循环,手动展开可以减少循环开销。对于复杂计算,提前算好存成表,运行时直接查。

// 查表法:计算CRC
const uint8_t crc8_table[256] = { /* 预计算好的值 */ };

uint8_t crc8_calc(uint8_t *data, uint16_t len) {
    uint8_t crc = 0;
    while (len--) {
        crc = crc8_table[crc ^ *data++];
    }
    return crc;
}

小技巧:查表法是典型的“空间换时间”。如果你的Flash够用,多用查表法。我在做LED渐变效果时,就把正弦波值预计算好存成表,MCU只需要查表输出PWM,省去了实时计算的开销。

2.6 本章小结

指针、内存、结构体、位运算——这四个东西,是嵌入式C语言的“四根柱子”。

  • 指针让你直接操作硬件,但要注意类型和越界。
  • 内存管理是嵌入式开发的“紧箍咒”,省着点用。
  • 结构体与联合体帮你把数据组织得井井有条。
  • 位运算是优化利器,用好了代码又快又小。

下一章我们会聊到“中断与定时器:系统响应的核心机制”。到时候你会发现,今天学的指针和位运算,在中断处理里会频繁用到。打好基础,后面才走得稳。