4. 嵌入式C语言进阶:指针与内存管理、函数指针、回调机制、volatile与const的深度用法

大家好,我是你们的老朋友。今天咱们聊点硬核的——C语言里那些让新手头疼、老手也容易翻车的东西。指针、内存、回调、还有那两个看着简单用着坑的volatileconst

说实话,我当年从单片机转到Linux驱动开发时,最大的坎就是这些。你以为你懂了?写个驱动试试,分分钟蓝屏给你看。嗯,咱们一个一个来。

4.1 指针:不只是“指向”那么简单

指针是什么?说白了就是地址。但嵌入式里,地址就是一切。你操作寄存器、管理内存、传递大数据,全得靠它。

4.1.1 指针的运算与类型

我见过不少新手写代码,int *pchar *p搞混,然后指针加1,结果地址跳了4个字节还是1个字节,完全没概念。记住:指针加1,跳过一个元素的大小

int arr[4] = {10, 20, 30, 40};
int *p = arr;
p++;  // 地址增加4字节(int大小)
printf("%d", *p); // 输出20

我在项目中遇到过一个问题:用void*传参数,然后强转成结构体指针,结果因为对齐问题,直接硬件异常。所以,别乱用void*,除非你真的知道自己在干什么

4.1.2 多级指针

二级指针、三级指针,看着吓人,其实就一句话:你想修改指针本身的值,就得传指针的地址

比如你要在函数里分配内存,然后让外面的指针指向这块内存:

void alloc_mem(int **pp, int size) {
    *pp = (int *)malloc(size);
}

int *p = NULL;
alloc_mem(&p, 100); // 传指针的地址

如果不传二级指针,函数里改了p的值,外面根本不知道。这个坑,我踩过不止一次。

4.2 内存管理:malloc/free 的陷阱

嵌入式系统内存有限,动不动就几KB、几十KB。你写个malloc,堆空间不够怎么办?碎片化怎么办?

4.2.1 堆与栈的区别

特性
分配方式 自动分配/释放 手动分配/释放
速度 快(寄存器操作) 慢(链表查找)
大小 小(几KB) 大(取决于RAM)
碎片问题 严重

我个人习惯:能不用堆就不用堆。尤其是中断服务函数里,绝对不要malloc。为什么?因为malloc不是可重入的,而且可能触发系统调用,导致中断延迟。

4.2.2 内存泄漏与野指针

我曾经调试过一个设备,运行几天后突然死机。查了三天,最后发现是一个free之后没有把指针置NULL,后面又用这个指针写了数据,把系统关键数据覆盖了。

警告:free之后一定要把指针置NULL,否则就是野指针。野指针比空指针危险100倍,因为它指向的是你无法预测的内存区域。
int *p = (int *)malloc(10 * sizeof(int));
// ... 使用 p ...
free(p);
p = NULL; // 必须!

4.2.3 静态分配 vs 动态分配

在单片机裸机开发中,我几乎不用动态分配。所有缓冲区都在编译时确定大小。到了Linux驱动里,偶尔用kmalloc,但也要注意GFP标志(比如在中断里只能用GFP_ATOMIC)。

你想想看,一个系统如果到处都是mallocfree,你怎么保证实时性?怎么保证不会内存耗尽?

4.3 函数指针:把函数当参数传递

函数指针,说白了就是函数的地址。你可以把它存在变量里,也可以把它当作参数传给另一个函数。

4.3.1 声明与使用

// 声明一个函数指针,指向返回int、参数为两个int的函数
int (*func_ptr)(int, int);

// 赋值
func_ptr = &add;

// 调用
int result = func_ptr(3, 5);

我在项目中用函数指针最多的场景就是状态机。每个状态对应一个处理函数,状态切换时直接换函数指针,比写一堆switch-case优雅多了。

4.3.2 函数指针数组

这个在驱动开发里很常见。比如一个设备支持多种操作,你可以把每个操作的处理函数放在一个数组里,用索引调用。

typedef void (*handler_t)(void);

handler_t handlers[] = {
    handle_open,
    handle_close,
    handle_read,
    handle_write
};

// 调用
handlers[op_code]();

4.4 回调机制:让代码更灵活

回调,说白了就是你调用我,我调用你传进来的函数。在Linux内核里,回调无处不在——中断处理、定时器、文件操作、网络协议栈……

4.4.1 回调的实现

// 定义回调函数类型
typedef void (*callback_t)(int event, void *data);

// 注册回调
void register_callback(callback_t cb) {
    // 保存回调函数指针
    g_callback = cb;
}

// 触发回调
void trigger_event(int event, void *data) {
    if (g_callback) {
        g_callback(event, data);
    }
}

我个人习惯:回调函数里不要做耗时操作。比如在中断回调里打印日志?那系统就别想跑了。回调应该只做最核心的事,复杂逻辑放到任务上下文里处理。

4.4.2 回调的陷阱

我曾经写过一个网络驱动的回调,里面调用了kmalloc,结果在中断上下文里直接崩溃。后来查资料才知道,kmalloc在中断里只能用GFP_ATOMIC,而且不能睡眠。

提示:写回调函数时,一定要清楚它会在什么上下文中被调用。中断上下文?进程上下文?能否睡眠?能否持有锁?这些决定了你能在回调里做什么。

4.5 volatile:别让编译器“优化”掉你的代码

volatile,这个关键字看着简单,但用错的人太多了。它的作用就一句话:告诉编译器,这个变量的值可能会被意外改变,别优化它

4.5.1 什么时候用volatile

  • 硬件寄存器:比如GPIO状态寄存器,值可能被硬件改变
  • 中断中修改的变量:主循环和中断共享的变量
  • 多线程共享变量:虽然C11有_Atomic,但嵌入式里还是volatile常见
// 硬件寄存器,必须用volatile
volatile uint32_t *gpio_reg = (uint32_t *)0x40020000;

// 中断标志
volatile int irq_flag = 0;

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

void main_loop(void) {
    while (!irq_flag) {
        // 如果没有volatile,编译器可能把irq_flag优化成寄存器变量
        // 导致永远看不到中断的修改
    }
}

4.5.2 volatile的误区

很多人以为volatile能保证原子性,这是错的!volatile只保证编译器不优化,不保证多核或中断间的原子访问。比如对一个32位变量赋值,在8位单片机上可能需要多条指令,volatile管不了这个。

重点:volatile ≠ 原子操作。需要原子性时,用关中断、自旋锁或原子操作API。

4.6 const:不只是“只读”

const,很多人以为就是“不能改”。其实它在嵌入式里有很多妙用。

4.6.1 const与指针的组合

const int *p;      // 指向的内容不能改,但指针可以改
int * const p;     // 指针不能改,但内容可以改
const int * const p; // 都不能改

我在项目中习惯:函数参数如果是只读的,尽量加const。这样编译器能帮你检查,而且调用者一看就知道这个函数不会修改数据。

4.6.2 const与硬件寄存器

有些寄存器是只读的,比如设备ID寄存器。这时候用const可以防止误写。

const volatile uint32_t *device_id = (uint32_t *)0xE000ED00;

注意这里constvolatile同时出现。为什么?因为寄存器是只读的(const),但硬件可能改变它的值(volatile)。

4.6.3 const与代码优化

把常量数据放在const段,编译器可以把它放到ROM或Flash里,节省宝贵的RAM。这在资源受限的MCU上特别有用。

// 这个字符串会放在Flash里,不占RAM
const char *welcome_msg = "System Ready!";

4.7 综合实战:一个简单的驱动框架

说了这么多,咱们来点实际的。下面是一个简单的字符设备驱动框架,用到了上面所有的知识点。

// 设备结构体
struct my_device {
    const char *name;           // 设备名,只读
    volatile uint32_t *reg;     // 寄存器地址,硬件可能改变
    int (*open)(struct my_device *dev);  // 函数指针
    void (*close)(struct my_device *dev);
};

// 回调函数类型
typedef void (*event_callback_t)(int event, void *data);

// 全局回调
static event_callback_t g_event_cb = NULL;

// 注册回调
int register_event_callback(event_callback_t cb) {
    if (cb == NULL) {
        return -1;
    }
    g_event_cb = cb;
    return 0;
}

// 设备打开函数
static int device_open(struct my_device *dev) {
    if (dev == NULL) return -1;
    // 操作硬件寄存器
    *dev->reg |= 0x01; // 使能设备
    if (g_event_cb) {
        g_event_cb(DEVICE_OPENED, (void *)dev);
    }
    return 0;
}

// 使用示例
const struct my_device dev = {
    .name = "my_uart",
    .reg = (volatile uint32_t *)0x40011000,
    .open = device_open,
    .close = device_close
};

你看,const保护了设备名,volatile处理了寄存器,函数指针实现了多态,回调机制让事件处理更灵活。这就是嵌入式C语言的精髓。

4.8 避坑指南

最后,我把自己这些年踩过的坑总结一下,希望能帮大家少走弯路。

  • 我曾经在中断里用malloc,结果系统死锁。记住:中断里别动态分配内存。
  • 我曾经忘了给volatile变量加修饰,结果调试了一整天,最后发现是编译器优化掉了。
  • 我曾经在回调函数里调用了可能睡眠的函数,导致系统响应变慢。回调里只做轻量操作。
  • 我曾经const int *pint * const p搞混,导致代码逻辑错误。建议用typedef明确语义。

嗯,今天就聊到这儿。指针和内存管理是嵌入式开发的基石,函数指针和回调是构建灵活系统的关键,volatileconst则是保障代码正确性的利器。把这些吃透了,你离Linux内核开发就不远了。

下一章,咱们聊聊中断与异常处理——这可是嵌入式系统的灵魂。到时候见!