第一章:嵌入式C语言基础:指针与内存管理深度解析

大家好,我是你们的老朋友。在嵌入式这行摸爬滚打了十几年,我见过太多新手在指针和内存上栽跟头。说实话,这两个东西要是搞不明白,后面写驱动、调系统,那真是寸步难行。今天咱们就好好聊聊这个核心话题。

1.1 指针:不仅仅是地址

很多教科书上说,指针就是地址。这话没错,但太片面了。我个人习惯把指针看作是一把「钥匙」——它不光告诉你房间号(地址),还告诉你这个房间里放的是什么东西(类型)。

核心理解:指针的类型决定了你从这把钥匙指向的位置能读出多少数据,以及怎么解释这些数据。

举个例子,你想想看:

int a = 0x12345678;
int *p = &a;
char *q = (char *)&a;

printf("p指向的值: 0x%x\n", *p);  // 输出 0x12345678
printf("q指向的值: 0x%x\n", *q);  // 输出 0x78 (小端模式)

同一个地址,用 int* 读出来是4个字节,用 char* 读出来只有1个字节。这就是类型的威力。我在项目中遇到过有人把结构体指针强转成 int* 去操作,结果数据全乱了,查了两天才发现是类型不匹配的问题。

1.2 指针运算:别踩坑

指针加减运算,很多人以为就是地址加1、减1。其实不然。指针加1,实际上是加上它指向类型的大小。

int arr[5] = {10, 20, 30, 40, 50};
int *p = arr;

printf("p = %p\n", p);       // 假设 0x1000
printf("p+1 = %p\n", p+1);   // 0x1004,不是0x1001

我曾经踩过的坑:在写一个通信协议解析模块时,我直接用 (uint8_t*) 指针加1去遍历一个 uint32_t 数组,结果每次只跳过一个字节,数据全读错了。后来才意识到,应该用 (uint32_t*) 指针,或者手动计算偏移量。

这里有个小技巧:如果你不确定指针跳了多少,可以用 (void*) 强制转换后再做加减,这样每次只跳1个字节。但要注意,void* 不能直接解引用。

1.3 数组与指针的暧昧关系

数组名和指针,很多人搞混。说白了,数组名是一个常量指针,指向数组的第一个元素。你不能给数组名赋值,但可以给指针变量赋值。

int arr[5];
int *p = arr;

arr = p;  // 编译错误!数组名是常量
p = arr;  // 正确

但有个例外:当数组名作为 sizeof 的操作数时,它代表整个数组的大小。这一点我经常在面试时考别人。

int arr[10];
printf("%zu\n", sizeof(arr));   // 40,整个数组大小
printf("%zu\n", sizeof(&arr));  // 8(64位系统),指针大小

1.4 动态内存管理:嵌入式里的双刃剑

在嵌入式系统里,动态内存分配是个敏感话题。很多单片机项目甚至禁止使用 malloc/free。为什么?说白了,嵌入式系统的资源太有限了,而且对实时性要求高。

我的建议:在资源受限的MCU上,尽量使用静态分配。如果非要用动态内存,一定要实现自己的内存池管理,而不是直接调用标准库的 malloc。

来看一个简单的内存池实现思路:

#define POOL_SIZE 1024
static uint8_t memory_pool[POOL_SIZE];
static uint8_t *pool_ptr = memory_pool;

void *my_malloc(size_t size) {
    // 简单的对齐处理
    size = (size + 3) & ~3;
    
    if (pool_ptr + size > memory_pool + POOL_SIZE) {
        return NULL;  // 内存不足
    }
    
    void *ptr = pool_ptr;
    pool_ptr += size;
    return ptr;
}

void my_free(void *ptr) {
    // 简单实现不支持释放,实际项目中需要更复杂的管理
    // 这里只是演示思路
}

1.5 内存泄漏:看不见的杀手

内存泄漏在嵌入式系统里特别致命。因为系统可能连续运行几个月甚至几年,一点点泄漏积累起来,最终会导致系统崩溃。

我遇到过最惨的一次:一个数据采集设备,每采集一次数据就 malloc 一次,但忘记 free 了。设备运行了大概两个月后,突然死机。现场工程师拆回来一查,内存全耗光了。

避坑指南:每次 malloc 之后,立即写下对应的 free 代码。养成这个习惯,能避免90%的内存泄漏问题。

1.6 函数指针:驱动开发的灵魂

在底层驱动开发中,函数指针无处不在。中断向量表、回调函数、设备驱动接口,都离不开它。

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

// 注册回调函数
void gpio_register_callback(gpio_callback_t cb) {
    // 保存回调函数指针
    this->callback = cb;
}

// 中断服务函数中调用
void GPIO_IRQHandler(void) {
    if (this->callback) {
        this->callback(interrupt_pin);
    }
}

嗯,这里要注意:函数指针的调用开销比直接函数调用大一些,在中断服务函数里要谨慎使用。我一般只在非关键路径上用函数指针,中断里尽量用直接调用。

1.7 const 与指针:保护你的数据

const 和指针的组合,很多人觉得绕。其实记住一个口诀就行:const 修饰的是它左边的东西,如果左边没有,就修饰右边。

写法 含义
const int *p 指向常量的指针,不能通过 p 修改值
int * const p 常量指针,p 本身不能变
const int * const p 都不能变

在驱动开发中,我习惯把硬件寄存器地址用 const 修饰,防止不小心修改了指针值。

// 硬件寄存器基地址,不允许修改
#define GPIOA_BASE  ((GPIO_TypeDef *)0x40020000)

// 只读访问
const uint32_t *id_reg = (uint32_t *)0x1FFF7A10;

1.8 实战经验总结

说了这么多,最后给大家几个实在的建议:

  • 初始化指针为 NULL:每次定义指针变量,立即初始化为 NULL。这样即使误用,也能快速定位问题。
  • 释放后置 NULL:free 之后,立即把指针置为 NULL。防止野指针。
  • 检查返回值:malloc 之后一定要检查是否为 NULL。嵌入式系统内存紧张,分配失败是常有的事。
  • 使用静态分析工具:像 PC-Lint、Coverity 这些工具,能帮你发现很多潜在的内存问题。

一个小习惯:我每次写完代码,都会花10分钟专门检查所有指针操作。这个习惯帮我避免了好几次线上事故。

好了,指针和内存管理这块,今天就聊到这儿。这些东西看似基础,但真正用好、用对,需要大量的实践积累。下一章咱们聊聊中断和定时器,那又是另一番天地了。