指针基础与内存模型:理解指针的本质

指针,是 C 语言的灵魂,也是很多嵌入式工程师的噩梦。我见过不少写了三五年代码的同事,一碰到指针就头皮发麻。其实说白了,指针没那么玄乎——它就是地址,一个数字而已。

你想想看,我们平时用的变量,比如 int a = 10,这个 a 在内存里占 4 个字节。那这 4 个字节在哪儿?总得有个门牌号吧?指针就是用来存这个门牌号的。

内存地址与变量

嵌入式开发里,我们天天跟内存打交道。STM32 的 SRAM 从 0x20000000 开始,这是固定的。每个变量都住在某个地址上。

概念 说明 嵌入式场景举例
内存地址 字节在内存中的编号,从 0 开始 0x20000000 ~ 0x2001FFFF
变量 编译器为数据分配的命名空间 uint32_t counter
地址空间 CPU 可寻址的范围 32 位 MCU 是 4GB

我刚开始做嵌入式时,犯过一个低级错误:在中断里直接修改了一个全局变量,没加 volatile。结果优化一开,变量死活不更新。后来查反汇编才明白——编译器以为没人改它,直接优化到寄存器里了。这就是没理解「变量在内存里」这个基本事实。

指针的声明与初始化

声明指针很简单:类型 * 变量名。但这里有个坑——* 的位置。

int* p;      // 我习惯这样写,强调 p 是指针
int *p;      // 也有人这样写,强调 *p 是 int
int* p, q;   // 注意!p 是指针,q 是 int

我个人习惯把 * 紧贴类型名,这样一眼就能看出「这是个指针」。但不管你怎么写,第三行那个坑一定要记住——int* p, q 只有 p 是指针,q 是普通 int。我见过不止一个新手在这里栽跟头。

避坑指南: 我曾经在代码审查时发现一个同事写了 uint8_t* ptr1, ptr2;,结果 ptr2 被当指针用了,编译没报错,运行时直接 hard fault。所以我的建议是:一行只声明一个指针变量。

取地址符 & 与解引用 *

这两个操作符是指针的左右手。& 取地址,* 解引用。看个例子:

uint32_t value = 0x12345678;
uint32_t *p = &value;   // p 存的是 value 的地址

// 解引用:通过 p 找到 value
*p = 0x87654321;        // 等价于 value = 0x87654321

// 看看地址长什么样
printf("value 的地址: 0x%08X\n", (unsigned int)&value);
printf("p 的值:       0x%08X\n", (unsigned int)p);

这里有个细节:&value 拿到的是 value 首字节的地址。如果 value 在 0x20000000,那 p 就是 0x20000000。解引用时,CPU 从 0x20000000 开始读 4 个字节。

小技巧: 调试时我经常用 &变量 来看变量在内存里的实际位置。如果两个全局变量地址差很多,说明它们不在同一个段里。这在分析内存碎片时特别有用。

指针的大小

指针本身也占内存。在 32 位 MCU 上,指针是 4 字节;在 64 位上是 8 字节。跟它指向什么类型无关。

printf("char* 大小: %zu 字节\n", sizeof(char*));
printf("int*  大小: %zu 字节\n", sizeof(int*));
printf("void* 大小: %zu 字节\n", sizeof(void*));
// 在 32 位 MCU 上,输出都是 4

嗯,这里要注意:sizeof 返回的是 size_t,打印用 %zu。嵌入式环境里如果编译器不支持 C99,可以用 %u 强转。

指针与数组的暧昧关系

数组名就是首元素地址。但数组名不是指针——它是常量地址,不能自增自减。

uint8_t buffer[16];
uint8_t *p = buffer;      // 等价于 p = &buffer[0]

buffer++;                 // 编译错误!数组名不是左值
p++;                      // 合法,p 指向 buffer[1]

为什么数组名不能自增?因为编译器需要知道数组的基地址来做偏移计算。如果允许改数组名,那 buffer[i] 的寻址就乱套了。

核心理解: 指针是变量,可以改;数组名是常量,不能改。但两者在大多数表达式中可以互换使用。这个区别在函数传参时尤其重要——数组传参本质上是传指针。

指针运算

指针加减整数,不是简单的地址加减。它是以「指向类型的大小」为步长的。

uint32_t arr[4] = {10, 20, 30, 40};
uint32_t *p = arr;

p + 1;   // 地址增加 4 字节(sizeof(uint32_t))
p + 2;   // 地址增加 8 字节

// 等价关系
*(p + 2) == p[2];   // 都是 arr[2]

这个特性在遍历数组时特别方便。但要注意:指针相减得到的是元素个数,不是字节数。

uint32_t *start = &arr[0];
uint32_t *end   = &arr[3];
ptrdiff_t count = end - start;   // count = 3,不是 12

我曾在一次 DMA 传输调试中,用指针相减算剩余数据量,忘了除以元素大小,结果 DMA 少传了 3/4 的数据。嗯,从那以后我每次写指针减法都会多看一眼。

void* 指针

void* 是「无类型指针」,可以指向任何类型。但它不能解引用——编译器不知道要读几个字节。

void *pv = &value;
// *pv = 10;           // 错误!不能解引用 void*
uint32_t *pi = (uint32_t*)pv;  // 必须强转
*pi = 10;                      // 现在可以了

嵌入式里 void* 最常见的用法是:

  • DMA 传输缓冲区的通用指针
  • RTOS 任务参数的传递
  • 内存管理函数(malloc/free)的返回值
经验之谈: 写驱动接口时,我习惯用 void* 做参数,这样上层不用关心具体类型。但内部实现一定要强转成实际类型,否则编译器会报警告。

本章知识结构

下面这张图把指针的核心概念串起来了。我建议你把它存下来,每次写指针代码前扫一眼。

指针基础与内存模型 · 知识结构图 指针的本质:地址 声明与初始化 & 取地址 / * 解引用 指针运算 关键点 • 类型 * 变量名 • 一行只声明一个指针 • 必须初始化,否则野指针 关键点 • & 取首字节地址 • * 按类型宽度读内存 • void* 必须强转后解引用 关键点 • 步长 = sizeof(指向类型) • 相减得元素个数 • 数组名是常量地址 核心:指针是变量,存的是地址;解引用是通过地址访问数据 嵌入式场景 寄存器映射 · DMA缓冲区 · RTOS任务参数 · 内存管理

常见误区总结

最后,我把新手最容易踩的坑列出来。这些我基本都踩过,你注意绕开。

误区 错误示例 正确做法
未初始化就解引用 int *p; *p = 10; 先让 p 指向合法地址
混淆数组名与指针 arr++; 用临时指针遍历
指针类型不匹配 int *p = &char_var; 类型必须一致或强转
忘记指针步长 p+1 以为加 1 字节 记住步长 = sizeof(类型)
void* 直接解引用 *pv = 10; 先强转再解引用
最重要的警告: 我曾经在一个电机控制项目里,因为指针没初始化就写数据,导致随机覆盖了 PWM 寄存器的配置。电机直接飞车,差点把实验台掀了。从那以后,我每次声明指针都会顺手初始化为 NULL,或者指向一个合法地址。这个习惯救了我很多次。

指针这东西,刚开始觉得绕,写多了就顺了。关键是脑子里要时刻有「内存地图」——每个变量在哪儿,指针指向哪儿,解引用时读几个字节。把这个想清楚,指针就没什么可怕的。