3、C语言基础回顾(上):指针与内存管理、结构体与联合体、位运算技巧
各位同学,欢迎来到第三章。
说实话,很多做嵌入式开发的朋友,C语言基础其实都停留在「能跑就行」的阶段。但做深度学习推理框架,这就不够了。你想想看,模型量化、内存池管理、寄存器配置……哪一样离得开指针和位运算?
这一章,我就带你把这些硬骨头啃下来。咱们不讲虚的,直接上干货。
3.1 指针:不只是地址,更是效率
指针是什么?说白了,就是内存的「门牌号」。但很多新手容易犯一个错——把指针当普通变量用。
我刚开始做项目时,就吃过这个亏。有一次调试一个图像预处理函数,数据死活不对。查了两天,最后发现是二级指针传参时,我忘了给指针本身分配内存。嗯,从那以后,我对指针的「所有权」问题就特别敏感。
3.1.1 指针与数组的「暧昧关系」
在单片机里,数组名其实就是指针常量。但有个坑:sizeof 对数组和指针的处理完全不同。
// 代码示例:数组与指针的区别
uint8_t buffer[128];
uint8_t *ptr = buffer;
// 这里 sizeof(buffer) = 128
// 而 sizeof(ptr) = 4(32位MCU)或 8(64位)
// 我在项目中见过有人用 sizeof(ptr) 来拷贝数据,结果只拷贝了4个字节
避坑指南: 当数组作为函数参数传递时,它会「退化」为指针。这时候 sizeof 就失效了。我建议你始终显式传递长度参数。
3.1.2 函数指针:回调机制的基石
在推理框架里,函数指针用得特别多。比如不同层的激活函数,你可以用函数指针数组来统一调用。
// 函数指针示例:统一调用不同激活函数
typedef float (*activation_func)(float);
float relu(float x) { return x > 0 ? x : 0; }
float sigmoid(float x) { return 1.0f / (1.0f + expf(-x)); }
activation_func activations[] = {relu, sigmoid, tanh};
// 调用时:activations[0](input_value);
个人经验: 我习惯把函数指针类型用 typedef 重命名,这样代码可读性会好很多。你想想看,每次写 void (*func)(int) 多累啊。
3.2 内存管理:堆与栈的博弈
单片机资源有限,内存管理是门艺术。我见过太多人因为 malloc/free 导致内存碎片,最后系统崩溃。
3.2.1 栈:自动但有限
局部变量、函数调用都走栈。栈的大小通常在几KB到几十KB。递归调用要特别小心——我曾经在STM32上写了一个递归的快速排序,结果栈溢出,程序直接跑飞。
关键点: 深度学习推理时,中间层的激活值如果都放栈上,很容易溢出。我建议大数组用静态分配或动态分配。
3.2.2 堆:灵活但需谨慎
动态内存分配在单片机里是个双刃剑。好处是灵活,坏处是碎片和不确定性。
// 内存池示例:避免碎片化
#define POOL_SIZE 4096
static uint8_t memory_pool[POOL_SIZE];
static uint32_t pool_index = 0;
void* pool_alloc(uint32_t size) {
// 对齐到4字节
size = (size + 3) & ~3;
if (pool_index + size > POOL_SIZE) return NULL;
void *ptr = &memory_pool[pool_index];
pool_index += size;
return ptr;
}
我曾经踩过的坑: 在中断服务函数里调用 malloc。中断里不能做动态分配,因为 malloc 不是可重入的。后来我改用内存池,问题就解决了。
3.3 结构体与联合体:数据组织的艺术
结构体和联合体,是嵌入式开发中组织数据的利器。特别是做模型参数解析时,用好了能省不少内存。
3.3.1 结构体对齐:看不见的性能杀手
很多新手不知道,结构体成员之间是有「填充字节」的。这会导致结构体实际大小比你想象的大。
// 结构体对齐示例
struct __attribute__((packed)) ModelConfig {
uint8_t type; // 1字节
uint16_t layers; // 2字节
uint32_t params; // 4字节
}; // 如果不加 packed,这个结构体可能占 8 字节(有填充)
// 加了 packed 后,占 7 字节
我的建议: 在定义结构体时,把大字节的成员放在前面,小字节的放后面。这样可以减少填充字节。但如果你要跟硬件寄存器打交道,最好用 __attribute__((packed)) 强制紧凑排列。
3.3.2 联合体:同一内存的不同解读
联合体在解析协议数据时特别好用。比如你要把一个 float 拆成4个字节发送,用联合体就很简单。
// 联合体示例:float与字节的转换
typedef union {
float value;
uint8_t bytes[4];
} FloatConverter;
FloatConverter fc;
fc.value = 3.14159f;
// 现在 fc.bytes[0] 到 fc.bytes[3] 就是 float 的二进制表示
应用场景: 在模型量化时,我经常用联合体来快速转换 int8 和 float 的表示。省去了手动移位和掩码的麻烦。
3.4 位运算:单片机上的「瑞士军刀」
位运算在深度学习推理中无处不在。量化、激活函数、状态标志……都离不开它。
3.4.1 基本操作:与、或、异或、取反、移位
这些操作在单片机里都是单周期指令,比乘除法快得多。
// 位运算常用技巧
#define SET_BIT(reg, bit) ((reg) |= (1 << (bit)))
#define CLR_BIT(reg, bit) ((reg) &= ~(1 << (bit)))
#define GET_BIT(reg, bit) (((reg) >> (bit)) & 1)
// 实际应用:配置GPIO
SET_BIT(GPIOA->ODR, 5); // 设置PA5为高电平
3.4.2 高级技巧:位域与掩码
在模型参数压缩时,位域特别有用。比如一个权重只需要4位,用位域就能精确控制。
// 位域示例:4位量化权重
typedef struct {
unsigned int w0 : 4;
unsigned int w1 : 4;
unsigned int w2 : 4;
unsigned int w3 : 4;
} QuantWeights;
// 这样两个4位权重可以塞进一个字节里
注意: 位域在不同编译器下的内存布局可能不同。如果你要跨平台,我建议用宏定义和移位操作来代替位域。
3.4.3 实战:快速计算2的幂次
在分配内存池时,经常需要把大小对齐到2的幂次。用位运算可以一行搞定。
// 对齐到2的幂次
#define ALIGN_TO_POW2(x, align) (((x) + (align) - 1) & ~((align) - 1))
// 示例:对齐到16字节
uint32_t size = 100;
uint32_t aligned = ALIGN_TO_POW2(size, 16); // 结果是112
个人习惯: 我写嵌入式代码时,能用位运算的地方绝不用乘除法。不仅快,而且代码更紧凑。你想想看,在只有几KB RAM的单片机上,省一个字节都是好的。
3.5 本章小结
这一章我们回顾了C语言在嵌入式开发中的三个核心点:指针、内存管理、位运算。这些都是搭建深度学习推理框架的基石。
说实话,这些内容看起来基础,但真正用好并不容易。我见过太多人因为指针用错导致内存泄漏,因为位运算写错导致寄存器配置失败。
下一章,我们会继续深入C语言的高级特性,包括函数指针的高级用法、volatile关键字、以及如何编写可重入函数。这些在实时推理系统中都至关重要。
嗯,今天就到这里。记得动手写代码,光看是学不会的。