指针基础与内存模型:理解指针的本质
指针,是 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* 做参数,这样上层不用关心具体类型。但内部实现一定要强转成实际类型,否则编译器会报警告。
本章知识结构
下面这张图把指针的核心概念串起来了。我建议你把它存下来,每次写指针代码前扫一眼。
常见误区总结
最后,我把新手最容易踩的坑列出来。这些我基本都踩过,你注意绕开。
| 误区 | 错误示例 | 正确做法 |
|---|---|---|
| 未初始化就解引用 | int *p; *p = 10; |
先让 p 指向合法地址 |
| 混淆数组名与指针 | arr++; |
用临时指针遍历 |
| 指针类型不匹配 | int *p = &char_var; |
类型必须一致或强转 |
| 忘记指针步长 | p+1 以为加 1 字节 |
记住步长 = sizeof(类型) |
| void* 直接解引用 | *pv = 10; |
先强转再解引用 |
指针这东西,刚开始觉得绕,写多了就顺了。关键是脑子里要时刻有「内存地图」——每个变量在哪儿,指针指向哪儿,解引用时读几个字节。把这个想清楚,指针就没什么可怕的。