4. 软件编程基础(C语言回顾):指针、结构体、内存管理、回调函数

做嵌入式仿真联调,说白了就是让软件和硬件在虚拟环境里先“握手”。

但很多联调翻车,根源不在仿真工具,而在C语言基础不牢。

指针乱飞、结构体对齐搞错、内存泄漏、回调函数写得像意大利面条——这些坑,我在项目里都踩过。今天咱们把这四个硬骨头啃下来。

4.1 指针:嵌入式世界的“遥控器”

指针是什么?就是变量的地址。你想想看,硬件寄存器都映射在固定地址上,没有指针,你怎么操作它们?

核心认知:指针不是洪水猛兽,它是嵌入式访问硬件的唯一通道。

4.1.1 指针的本质与声明

声明一个指针,就是告诉编译器:“我要存一个地址,这个地址里放的是某种类型的数据。”

// 声明一个指向整型的指针
int *p;

// 声明一个指向结构体的指针
struct device *dev_ptr;

// 声明一个指向函数的指针(这个后面细说)
void (*callback)(int);

我个人习惯在声明时把 * 紧贴变量名,这样读起来更直观:int *p 表示 *p 是一个 int。

4.1.2 指针与硬件寄存器

在仿真联调中,我们经常需要直接操作硬件寄存器。比如一个UART数据寄存器地址是 0x40001000:

// 定义一个指向该地址的指针
volatile uint32_t *uart_data = (volatile uint32_t *)0x40001000;

// 读取数据
uint32_t rx_byte = *uart_data;

// 写入数据
*uart_data = 0x55;

注意:volatile 关键字不能丢!它告诉编译器不要优化这个变量的访问,每次都要从内存读取。我曾经在调试一个SPI驱动时,忘了加 volatile,结果仿真时数据死活不对——编译器把读操作优化掉了。

4.1.3 指针运算与数组

指针和数组在嵌入式里几乎不分家。数组名就是首元素地址。

uint8_t buffer[256];
uint8_t *ptr = buffer;  // 等价于 &buffer[0]

// 遍历数组
for (int i = 0; i < 256; i++) {
    *(ptr + i) = i;  // 等价于 buffer[i] = i;
}

嗯,这里要注意:ptr + 1 不是地址加1,而是加 sizeof(uint8_t),也就是1个字节。如果指针是 uint32_t *,那加1就是加4个字节。这个细节在操作寄存器数组时特别容易搞错。

4.2 结构体:打包数据的“集装箱”

结构体就是把多个相关变量打包在一起。在嵌入式里,一个外设的寄存器组、一个数据包、一个设备状态——都适合用结构体描述。

4.2.1 结构体定义与使用

// 定义一个UART外设的结构体
typedef struct {
    volatile uint32_t DR;      // 数据寄存器
    volatile uint32_t SR;      // 状态寄存器
    volatile uint32_t CR;      // 控制寄存器
    volatile uint32_t BRR;     // 波特率寄存器
} UART_TypeDef;

// 使用:将结构体指针映射到硬件地址
#define UART1_BASE  (0x40001000)
UART_TypeDef *UART1 = (UART_TypeDef *)UART1_BASE;

// 操作寄存器
UART1->DR = 0x41;  // 发送字符 'A'

你看,这样写代码是不是比直接操作地址清晰多了?结构体让硬件访问有了“语义”。

4.2.2 结构体对齐:一个让我吃过亏的坑

结构体成员在内存中不是紧挨着放的。编译器会按对齐规则插入填充字节。

成员类型 对齐要求 示例偏移
uint8_t 1字节对齐 0
uint16_t 2字节对齐 2(跳过1个填充)
uint32_t 4字节对齐 4
结构体整体 最大成员对齐 8(末尾可能填充)

避坑指南:我曾经在仿真一个CAN协议栈时,结构体成员顺序没排好,导致发送的数据包总是错位。后来用 #pragma pack(1) 强制1字节对齐才解决。但注意,强制对齐会影响访问效率,有些MCU甚至不支持非对齐访问。

// 使用 pragma pack 强制1字节对齐
#pragma pack(1)
typedef struct {
    uint8_t id;
    uint16_t length;
    uint32_t data;
} CAN_Frame;
#pragma pack()

4.3 内存管理:别让嵌入式系统“失忆”

嵌入式系统的内存是稀缺资源。仿真联调时,内存问题往往隐藏得很深——跑几个小时才崩溃一次。

4.3.1 静态分配 vs 动态分配

分配方式 特点 适用场景
静态分配 编译时确定,速度快,无碎片 全局变量、任务栈、固定缓冲区
动态分配(malloc/free) 运行时分配,灵活但有碎片风险 变长数据包、动态创建的任务
池分配 预分配固定大小块,无碎片 网络协议栈、消息队列

我个人在嵌入式项目里很少用 malloc。为什么?因为一旦内存碎片化,系统可能在某次分配时失败,而你又没法预测。我更喜欢用内存池。

4.3.2 内存池实现示例

#define POOL_SIZE 10
#define BLOCK_SIZE 32

typedef struct {
    uint8_t pool[POOL_SIZE][BLOCK_SIZE];
    uint8_t used[POOL_SIZE];  // 0=空闲, 1=已用
} MemPool;

void* pool_alloc(MemPool *mp) {
    for (int i = 0; i < POOL_SIZE; i++) {
        if (!mp->used[i]) {
            mp->used[i] = 1;
            return &mp->pool[i];
        }
    }
    return NULL;  // 池满
}

void pool_free(MemPool *mp, void *ptr) {
    // 计算索引,标记为空闲
    int idx = ((uint8_t*)ptr - (uint8_t*)mp->pool) / BLOCK_SIZE;
    if (idx >= 0 && idx < POOL_SIZE) {
        mp->used[idx] = 0;
    }
}

注意:内存池的块大小要按最大需求设计。如果块太小,大数据放不下;块太大,浪费空间。我在仿真一个MQTT客户端时,就因为这个设计不合理,导致内存利用率不到30%。

4.4 回调函数:让代码“活”起来

回调函数,说白了就是把一个函数的地址作为参数传给另一个函数。在嵌入式里,中断处理、定时器超时、数据接收完成——这些场景都离不开回调。

4.4.1 回调函数声明与使用

// 定义回调函数类型
typedef void (*TimerCallback)(void *arg);

// 定时器结构体
typedef struct {
    uint32_t period;
    TimerCallback callback;
    void *arg;
} Timer;

// 定时器启动函数
void timer_start(Timer *tm) {
    // ... 配置硬件定时器 ...
    // 当定时器超时时,调用回调
    if (tm->callback) {
        tm->callback(tm->arg);
    }
}

// 实际回调函数
void led_toggle(void *arg) {
    uint8_t *pin = (uint8_t *)arg;
    // 翻转GPIO引脚
    GPIO->ODR ^= (1 << (*pin));
}

// 使用
Timer led_timer = {
    .period = 500,
    .callback = led_toggle,
    .arg = &pin_number
};
timer_start(&led_timer);

4.4.2 回调在仿真联调中的价值

仿真联调时,回调函数可以让你“注入”测试行为。比如你想模拟一个传感器在特定条件下返回错误:

// 正常读取函数
uint8_t sensor_read_normal(void) {
    return read_from_hardware();
}

// 测试用回调:模拟错误
uint8_t sensor_read_fault(void) {
    return 0xFF;  // 返回错误值
}

// 在仿真测试中替换回调
sensor.read = sensor_read_fault;  // 注入错误行为

经验之谈:回调函数让测试变得非常灵活。我在做仿真联调时,经常用回调来模拟各种边界条件——超时、数据错误、硬件故障。这样不用改硬件,就能把软件路径全覆盖测试一遍。

4.5 本章知识体系

下面这张图把本章的核心知识点串起来了。你看,指针是基础,结构体是容器,内存管理是保障,回调函数是机制——四者配合,才能写出健壮的嵌入式软件。

第四章知识体系:C语言嵌入式核心 C语言嵌入式核心四要素 嵌入式C编程 指针 结构体 内存管理 回调函数 硬件寄存器访问 数组与指针运算 volatile关键字 外设寄存器映射 结构体对齐 typedef与封装 静态 vs 动态分配 内存池设计 碎片与泄漏防范 函数指针声明 中断与定时器回调 测试行为注入 四者配合:指针访问硬件 → 结构体封装 → 内存池管理 → 回调驱动

指针、结构体、内存管理、回调函数——这四个东西,是嵌入式C语言的四根柱子。你在仿真联调中遇到的绝大多数问题,归根结底都能追溯到这四个方面。

把基础打牢,后面的路就好走了。