第二讲:嵌入式C语言基础回顾——指针与数组的深入理解、结构体与联合体、位运算与宏定义

好,咱们直接进入正题。这一讲的内容,说白了就是嵌入式C语言的「内功心法」。指针、结构体、位运算这些,你在任何一本C语言教材上都能看到,但到了MTK平台上,它们会以完全不同的面貌出现。我当年刚接手MTK项目时,就因为这些基础不扎实,踩了不少坑。

2.1 指针与数组:别被表象骗了

先问个问题:数组名到底是不是指针?很多人面试时被问到这个,支支吾吾。其实,数组名是地址常量,不是指针变量。它指向数组首元素,但你不能给它赋值。

核心区别:

  • int arr[10]; —— arr是地址常量,&arr[0]等价于arr
  • int *p = arr; —— p是指针变量,可以指向别处
  • sizeof(arr) 返回40(整个数组大小),sizeof(p) 返回4或8(指针本身大小)

我在项目中遇到过一个问题:用sizeof(arr)计算数组长度,然后传给函数,结果函数里再用sizeof就变成了指针大小。嗯,这就是典型的「数组退化为指针」陷阱。

// 错误示范
void process_array(int arr[]) {
    int len = sizeof(arr) / sizeof(arr[0]);  // 这里arr已经退化为指针!
    // len永远等于1(32位系统上)
}

// 正确做法
void process_array(int arr[], int len) {
    for(int i = 0; i < len; i++) {
        // 处理arr[i]
    }
}

我的习惯:在MTK平台上,凡是涉及数组参数,我一定同时传递长度。别偷懒,否则调试时你会哭的。

2.2 指针数组 vs 数组指针

这两个概念,我当年也绕了好久。你想想看:

  • 指针数组:int *p[10]; —— 一个数组,里面存了10个int指针
  • 数组指针:int (*p)[10]; —— 一个指针,指向一个包含10个int的数组

怎么记?看优先级。中括号[]优先级高于星号*,所以int *p[10]先被解释为数组,里面元素是int *。而int (*p)[10]加了括号,*p先结合,所以p是指针。

在MTK的LCD驱动开发中,我经常用指针数组来管理多个显示缓冲区:

// 管理3个显示缓冲区
uint8_t *frame_buffers[3] = {
    (uint8_t *)0x80000000,
    (uint8_t *)0x80010000,
    (uint8_t *)0x80020000
};

// 切换缓冲区
void switch_buffer(int index) {
    LCD_SetAddress(frame_buffers[index]);
}

2.3 结构体与联合体:内存布局的艺术

结构体和联合体,在嵌入式里太重要了。说白了,结构体就是你把一堆变量打包,联合体就是让不同变量共用一块内存。

结构体对齐问题:这是新手最容易忽略的。我曾经因为结构体对齐,导致两个设备通信数据对不上,查了整整一天。

// 默认对齐(4字节对齐)
struct {
    uint8_t a;    // 偏移0
    uint32_t b;   // 偏移4(不是1!)
    uint8_t c;    // 偏移8
    // 总大小12字节(末尾填充到4的倍数)
} __attribute__((packed));  // 加上这个,取消对齐

// 紧凑模式(1字节对齐)
struct __attribute__((packed)) {
    uint8_t a;    // 偏移0
    uint32_t b;   // 偏移1
    uint8_t c;    // 偏移5
    // 总大小6字节
};

注意:在MTK平台上,如果结构体用于硬件寄存器映射,一定要用__attribute__((packed))。否则你的寄存器地址会错位,写进去的数据全乱套。

联合体的妙用:我经常用联合体来解析协议数据包。比如一个数据帧,可能是命令帧、数据帧或状态帧,但头部都一样:

typedef union {
    uint8_t raw[64];  // 原始数据
    
    struct {
        uint8_t header;
        uint8_t cmd;
        uint8_t params[62];
    } command_frame;
    
    struct {
        uint8_t header;
        uint8_t data_len;
        uint8_t data[62];
    } data_frame;
    
    struct {
        uint8_t header;
        uint8_t status;
        uint8_t reserved[62];
    } status_frame;
} Packet;

// 使用方式
Packet pkt;
pkt.raw[0] = 0xAA;  // 填充头部
// 然后根据header判断类型,用对应的成员访问

2.4 位运算:嵌入式工程师的瑞士军刀

位运算在嵌入式里无处不在。设置寄存器、解析状态位、压缩数据……说白了,不会位运算,你连GPIO都控制不了。

常用操作口诀:

  • 置位:REG |= (1 << n);
  • 清零:REG &= ~(1 << n);
  • 翻转:REG ^= (1 << n);
  • 读取:bit = (REG >> n) & 1;

我在MTK平台上写GPIO驱动时,经常要操作多个位:

// 设置GPIO模式为复用功能
#define GPIO_MODE_REG    (*(volatile uint32_t *)0x10001000)
#define GPIO_MODE_SHIFT  4
#define GPIO_MODE_MASK   0x3

// 设置第2个GPIO为复用模式(模式值=2)
GPIO_MODE_REG &= ~(GPIO_MODE_MASK << (2 * GPIO_MODE_SHIFT));
GPIO_MODE_REG |= (2 << (2 * GPIO_MODE_SHIFT));

避坑指南:我曾经在操作32位寄存器时,直接用1 << 31,结果发现符号位问题导致结果不对。后来我统一用1UL << 31,加上UL后缀,确保是无符号操作。

2.5 宏定义:不只是文本替换

宏定义,很多人觉得简单,不就是#define PI 3.14嘛。但在嵌入式里,宏的用法远不止这些。

带参数的宏:要小心副作用!

// 错误示范
#define SQUARE(x) x * x
// SQUARE(a + b) 展开为 a + b * a + b,完全不对!

// 正确做法
#define SQUARE(x) ((x) * (x))

// 更安全的做法(多语句宏)
#define SET_BITS(reg, mask, val) do { \
    (reg) &= ~(mask);                \
    (reg) |= ((val) & (mask));       \
} while(0)

条件编译:在MTK平台上,我们经常用宏来控制不同硬件版本:

#define HW_VERSION_1  1
#define HW_VERSION_2  2

// 在代码中
#if (HW_VERSION == HW_VERSION_1)
    #define LED_PIN   GPIO_PIN_10
#elif (HW_VERSION == HW_VERSION_2)
    #define LED_PIN   GPIO_PIN_12
#else
    #error "Unsupported hardware version!"
#endif

我的经验:宏定义里尽量用do { ... } while(0)包裹多语句。这样不管外面怎么用,加不加分号,都不会出问题。这个习惯帮我避免了好几次编译错误。

2.6 综合实战:寄存器操作封装

最后,咱们把今天学的知识串起来。在MTK平台上,我经常这样封装寄存器操作:

// 寄存器基地址
#define UART_BASE   0x11002000

// 寄存器偏移
#define UART_THR    0x00  // 发送保持寄存器
#define UART_RBR    0x00  // 接收缓冲寄存器
#define UART_LSR    0x14  // 线状态寄存器

// 寄存器访问宏
#define REG32(addr)  (*(volatile uint32_t *)(addr))
#define UART_REG(offset)  REG32(UART_BASE + offset)

// 位定义
#define LSR_DR      (1 << 0)  // 数据就绪位
#define LSR_THRE    (1 << 5)  // 发送保持寄存器空

// 结构体封装(推荐方式)
typedef struct {
    volatile uint32_t THR;    // 0x00
    volatile uint32_t IER;    // 0x04
    volatile uint32_t FCR;    // 0x08
    volatile uint32_t LCR;    // 0x0C
    volatile uint32_t MCR;    // 0x10
    volatile uint32_t LSR;    // 0x14
} UART_TypeDef;

#define UART0  ((UART_TypeDef *)UART_BASE)

// 使用
void uart_send_byte(uint8_t data) {
    // 等待发送缓冲区空
    while(!(UART0->LSR & LSR_THRE));
    UART0->THR = data;
}

嗯,这一讲的内容就到这里。指针、结构体、位运算、宏定义,这些基础东西看似简单,但真正用好它们,需要大量的实践。我建议你拿到MTK开发板后,第一件事就是把这些知识点都跑一遍,看看实际效果。下一讲,咱们聊聊MTK平台的内存管理,那才是真正考验功底的地方。