Appearance
指针的本质:地址与类型
概念
上一篇结尾说:C 的灵魂是"一个变量里装的不是值,而是另一个变量的地址"。 这就是指针。
两个属性缺一不可,而且各管一件事:
| 属性 | 存在哪 | 决定什么 |
|---|---|---|
| 地址(值) | 指针变量里的那串位 | 指向"哪一个"字节 |
| 类型(解释方式) | 编译器眼中(不占运行时空间) | 从那个地址起,读几个字节、怎么解释、p+1 跳多远 |
"类型不占运行时空间"这句要强调:int *p 和 char *q 在内存里长得一模一样(都是一个地址,都是 8 字节)。它们的区别只影响编译出来的指令:
| 表达式 | int *p(p 指向 0x1000) | char *q(q 指向 0x1000) |
|---|---|---|
*p / *q | 读 4 字节(lw) | 读 1 字节(lb) |
p+1 / q+1 | 地址变成 0x1004 | 地址变成 0x1001 |
| 生成指令 | lw $t0, 0($p) | lb $t0, 0($q) |
这就是为什么"指针类型必须匹配":
p+1跳 4 字节还是 1 字节,编译器不知道你的意图,只能照类型算。 类型写错 → 步长错 → 读到别的变量的字节里去了。
本篇在主线上的位置:
lang/02-addressing.md说"MIPS 只有一种访存方式:基址 + 偏移",并且数组、结构体、指针在机器层全是同一件事。 C 的指针就是那件事的"人话版"——指针的类型,就是编译器用来替你算"偏移加多少"的那份说明书。 这一篇把"人话版"和lw/sw彻底对上,之后看任何一行 C 指针代码,脑子里都能直接映出几条汇编。
原理
一、内存是一张巨大的字节数组
先把心智模型钉死(这个模型能解释指针的一切):
地址 → 0x1000 0x1001 0x1002 0x1003 0x1004 0x1005 0x1006 0x1007 ...
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
内存: │ 0x78 │ 0x56 │ 0x34 │ 0x12 │ ... │ ... │ ... │ ... │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
└────────── 一个 int(0x12345678,小端)──────────┘三条推论:
| 推论 | 说明 |
|---|---|
| 地址是"字节的下标" | 每个字节有自己的地址;"一个 int 的地址"= 它 4 个字节里最低那一个的地址 |
取地址 &x 得到的是"起始字节"的下标 | 结构体的地址永远是第一个字节的地址(这是"成员偏移"的基准) |
p+1 到底加几 | 不是加 1 字节,是加 sizeof(*p) 字节——因为"指向下一个同类型对象"才是语义 |
"小端"在图上看得见(回链 lang/02-addressing.md):0x12345678 存进 0x1000,低字节 0x78 落在低地址 0x1000。大端机则反过来。
二、指针变量的宽度:它自己也占空间
"指针是地址"——那地址本身多宽?
| 平台 | sizeof(void*) | 能寻址范围 |
|---|---|---|
| 32 位 | 4 字节 | 0 ~ 4 GiB(2³²) |
| 64 位 | 8 字节 | 0 ~ 16 EiB(2⁶⁴,实际用 48 位) |
三条必须记住的推论:
- 指针不是"零成本的抽象":它自己要占 8 字节。所以"函数传指针比传结构体快"只在结构体比 8 字节大时成立——传一个
int用指针反而更慢(多一次访存)。 sizeof(p)与*p无关:sizeof(int*)和sizeof(char*)都是 8(64 位机上)。类型不影响指针本身的大小。- "两级指针"就是"地址的地址":
int **pp也是一个 8 字节的地址,只不过那个地址指向的地方存着另一个 8 字节的地址。
三、& 和 *:一对互逆运算
变量 x(一个 int) 指针 p(一个 int*)
┌──────────────┐ ┌──────────────┐
│ 42 │ ◄──── & ──│ 0x7FFC1000 │ p = &x
│ 地址0x7FFC1000│ └──────────────┘
└──────────────┘ *p ───────► 回到 x,值是 42| 符号 | 名字 | 作用 | 汇编对应 |
|---|---|---|---|
&x | 取地址 | 把"x 在哪"拿出来 | la / addiu(算地址,不访存) |
*p | 解引用 | 到 p 指的地址去取/写 | lw(读)/ sw(写) |
p | 指针的值本身 | 那个地址数 | 寄存器里的值 |
关键等式:
"互逆"的含义:& 是"从对象算地址"(编译期就地算,不访存);* 是"从地址找对象"(必须访存)。
*p 出现在赋值号左边时是"写":
c
*p = 42; /* 不是"改 p",是"改 p 指向的那个东西" */
p = &y; /* 这才是"改 p 自己" */这两行的区别是初学者最大的坑:*p = 42 要访存(sw),p = &y 只是改寄存器。
为什么指针能"改掉调用者的变量"? 因为参数是"地址的副本",但地址指向的是同一块内存(回链 lang/04-stack.md 的"值传递"):
调用方 被调用方
x @ 0x1000 = 7
│ 传 &x = 0x1000
▼
p = 0x1000(副本)
*p = 99; ──► 写到 0x1000 ──► x 变成 99 ✓一句话:C 只有值传递。指针的"引用传递效果",是靠"传地址"这个技巧骗出来的——被调用方拿到的仍然是"值的副本",只不过那个值正好是个地址。
四、指针算术:步长由 sizeof(*p) 决定
指针的 + - ++ -- 不是整数加法,规则是:
| 表达式 | int *p(p = 0x1000) | char *q(q = 0x1000) | double *d(d = 0x1000) |
|---|---|---|---|
p + 1 | 0x1004 | 0x1001 | 0x1008 |
p + 3 | 0x100C | 0x1003 | 0x1018 |
| 对应指令 | 地址 + 4 | 地址 + 1 | 地址 + 8 |
编译器怎么生成这个"加 sizeof"? 不等号不除,而是把 i 左移——这正是 lang/02 里 sll $t1, $t0, 2(i × 4)的由来。"下标要乘元素大小"这件事,在汇编里就是一次 sll。
指针减法:
| 表达式 | 结果 | 说明 |
|---|---|---|
p - q(同类型) | 相差几个元素 | 不是字节差!(0x100C - 0x1000) / 4 = 3 |
p < q | 地址比较 | 同一数组内才有意义(跨对象比较是 UB) |
p - q的结果类型是ptrdiff_t(有符号)——因为差可以是负的。这又是一个"类型决定解释"的例子。
五、数组名:一个"半退化"的指针
事实:数组名在大多数表达式里会"退化"成"指向首元素的指针":
c
int a[5];
int *p = a; /* 等价于 int *p = &a[0]; */
p == a; /* 真 */
p[2] == a[2]; /* 真 —— 下标运算符对指针也有效 */核心等价式(必须背下来):
"下标"只是"指针加法 + 解引用"的语法糖。
但数组名和指针有两条重要差别:
| 差别 | 数组名 a | 指针 p |
|---|---|---|
sizeof | 整个数组的字节数(5×4 = 20) | 指针本身(8) |
| 能否赋值 | 不能(a = p 编译错误) | 能(p = a 可以) |
&a 的类型 | int (*)[5](指向整个数组) | int ** |
sizeof(a) == 20 而 sizeof(p) == 8——这是"数组名退化成指针"不彻底的地方:在 sizeof 和 & 这两个运算符面前,数组名仍然是数组。 这也解释了一个经典 bug:
c
void f(int a[]) { /* 参数里的 a[] 等价于 a* */
printf("%zu\n", sizeof(a)); /* 打印 8(指针),不是数组大小! */
}函数参数中的数组声明会退化,所以"数组传参会丢长度",必须另外传一个长度参数。这就是标准库要
strlen(s)或到处传n的原因。
六、多级指针、void*、函数指针
多级指针:"指向指针的指针",用途是**"让被调用函数能改调用者的指针"**:
c
void alloc(int **pp) {
*pp = malloc(4 * 10); /* 改的是"调用者的指针变量" */
}
int *arr;
alloc(&arr); /* 必须传 arr 的地址 */为什么必须两级? 因为 "要改 int *arr 这个变量,就得把它的地址传进去"——int *arr 的地址的类型是 int **。 规则是递归的:想改什么,就传什么的地址。
| 想改的东西 | 该传的参数类型 | 例 |
|---|---|---|
int x | int * | swap(&x, &y) |
int *p | int ** | alloc(&arr) |
int **q | int *** | (极少用,说明设计有问题) |
void *——"通用指针":
| 特性 | 说明 |
|---|---|
| 能装任何对象指针 | 不需要强制转换(C 里 void* ↔ 其他指针自动转换) |
| 不能解引用 | *vp 是编译错误——因为它没有"类型",编译器不知道读几字节 |
| 不能做指针算术 | vp + 1 非法(GCC 扩展会当 char*,但标准不允许) |
malloc 的返回类型 | void *——这就是"malloc 返回的东西必须先转型再用"的原因 |
void*是"只有地址、没有类型"的指针——它恰好证明了"类型"和"地址"是两个独立的属性:void*保留了地址,丢掉了类型,于是它就"不能用"了。
函数指针:指针也可以指向代码:
c
int (*fp)(int, int) = &add; /* 指向函数 add */
int r = fp(3, 4); /* 通过指针调用 —— 等价于 (*fp)(3,4) */用途:回调(qsort 的比较函数)、跳转表(switch 的优化实现)、虚函数表(C++ 的底层机制)。在机器层,函数指针就是"存着代码地址的变量",间接跳转就是 jalr(回链 lang/01-isa.md 的跳转指令)。
七、三类坏指针:野、空、悬垂
| 类型 | 是什么 | 后果 |
|---|---|---|
| 野指针(wild) | 未初始化的指针(值随机) | 随机写到某处——可能毁掉别的变量、栈帧,甚至段错误 |
| 空指针(NULL) | 值为 0 的指针(不指向任何地方) | 解引用 → 段错误(可预测的崩溃,是"好事") |
| 悬垂指针(dangling) | 指向已释放/已出作用域的内存 | 读写已回收的内存——最难查,可能"看起来正常" |
三条防御规则:
- 定义时就初始化(
int *p = NULL;而不是int *p;); free(p)之后立刻p = NULL;——否则就是悬垂指针;- 解引用前判空(
if (p != NULL) ...)。
注意"局部变量的地址不能返回":
c
int *bad(void) {
int local = 42;
return &local; /* ✗ 函数返回后栈帧失效,&local 变成悬垂指针 */
}为什么? 因为局部变量在栈上(回链 lang/04-stack.md),函数一返回,那块栈帧就被"回收"了——下一个函数调用会把它覆盖掉。 返回的地址当时还能读到值(因为内存还没被覆盖),但随时可能变——这就是悬垂指针"有时对有时错"的根源。
示例
例 1:同一串地址,不同类型,不同步长
任务:用同一段内存,对比 char*、int*、double* 的 p+1 走多远。
c
#include <stdio.h>
int main(void) {
char buf[16] = {0};
char *pc = buf;
int *pi = (int *)buf;
double *pd = (double *)buf;
printf("pc = %p, pc+1 = %p, 步长 %zu\n", (void*)pc, (void*)(pc+1), sizeof(*pc));
printf("pi = %p, pi+1 = %p, 步长 %zu\n", (void*)pi, (void*)(pi+1), sizeof(*pi));
printf("pd = %p, pd+1 = %p, 步长 %zu\n", (void*)pd, (void*)(pd+1), sizeof(*pd));
printf("sizeof(int*) = %zu, sizeof(char*) = %zu <- 指针本身一样大\n",
sizeof(int*), sizeof(char*));
return 0;
}用 Python 复算步长(把"地址"当成整数,p+1 直接按字节数加):
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
BASE = 0x1000 # 假设 buf 的首地址
# (指针声明, 指向类型的大小)
PTRS = [
("char *pc", 1),
("short *ps", 2),
("int *pi", 4),
("long *pl", 8),
("double *pd", 8),
("int **pp", 8), # 指针的指针:指向"8 字节的地址"
]
print(" " + pad("声明(64 位)", 16) + pad("sizeof(*p)", 11)
+ pad("p 的值", 12) + pad("p+1 的值", 12) + "步长(字节)")
for decl, sz in PTRS:
print(" " + pad(decl, 16) + pad(str(sz), 11)
+ pad("0x%X" % BASE, 12) + pad("0x%X" % (BASE + sz), 12) + str(sz))
print("\n=== 关键对比 ===")
print(f" sizeof(int*) = 8 <- 指针本身")
print(f" sizeof(char*) = 8 <- 一样大!类型不影响指针的宽度")
print(f" sizeof(int) = 4 <- 但 *p 读几个字节、p+1 跳多远,全看它")
print()
print(" 结论:*p 取 4 字节(lw)、10 字节? 不 —— 按类型取 sizeof(*p) 字节")
print(" char* → lb(1 字节);int* → lw(4 字节);double* → 两条 lw 拼 8 字节")
print("\n=== 用同一段内存做下标运算 ===")
buf = [".."] * 16
for idx in (0, 1, 4, 8):
buf[idx] = f"{idx:02d}"
print(" buf: " + " ".join(buf))
print(" pc+0 " + "^" * 1 + " <- char* 的 p+1 跨 1 格")
print(" pi+0 " + "^" * 4 + " <- int* 的 p+1 一次跨 4 格")
print(" pd+0 " + "^" * 8 + " <- double* 的 p+1 一次跨 8 格")
print(" (^ 的个数 = 该类型占几个字节,也就是 p+1 的步长)")预期输出:
声明(64 位) sizeof(*p) p 的值 p+1 的值 步长(字节)
char *pc 1 0x1000 0x1001 1
short *ps 2 0x1000 0x1002 2
int *pi 4 0x1000 0x1004 4
long *pl 8 0x1000 0x1008 8
double *pd 8 0x1000 0x1008 8
int **pp 8 0x1000 0x1008 8
=== 关键对比 ===
sizeof(int*) = 8 <- 指针本身
sizeof(char*) = 8 <- 一样大!类型不影响指针的宽度
sizeof(int) = 4 <- 但 *p 读几个字节、p+1 跳多远,全看它
结论:*p 取 4 字节(lw)、10 字节? 不 —— 按类型取 sizeof(*p) 字节
char* → lb(1 字节);int* → lw(4 字节);double* → 两条 lw 拼 8 字节
=== 用同一段内存做下标运算 ===
buf: 00 01 .. .. 04 .. .. .. 08 .. .. .. .. .. .. ..
pc+0 ^ <- char* 的 p+1 跨 1 格
pi+0 ^^^^ <- int* 的 p+1 一次跨 4 格
pd+0 ^^^^^^^^ <- double* 的 p+1 一次跨 8 格
(^ 的个数 = 该类型占几个字节,也就是 p+1 的步长)三条结论:
| 结论 | 说明 |
|---|---|
| 指针宽度与指向类型无关 | 都是 8 字节(64 位);"类型"不占运行时空间 |
步长 = sizeof(*p) | p+1 是"下一个元素",不是"下一个字节" |
| 指数/步长落到指令上 | 编译成一次 sll(×4)或直接 +1/+8——这就是 lang/02 的地址算术 |
例 2:指针改写调用者的变量
任务:用一段"内存模型"演示 swap 与"通过指针写回"。
c
#include <stdio.h>
void swap(int *a, int *b) {
int t = *a; /* 取 a 指的东西 */
*a = *b; /* 把 b 指的东西写到 a 指的地方 */
*b = t;
}
int main(void) {
int x = 7, y = 42;
printf("调用前 x=%d y=%d\n", x, y);
swap(&x, &y);
printf("调用后 x=%d y=%d\n", x, y);
return 0;
}用 Python 手搭一个"内存模型"复现(这也顺便说明了"值传递"):
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
class Mem:
"""一块小端内存:地址 = BASE + 偏移,4 字节一个 int"""
BASE = 0x7FFC1000
def __init__(self, n=32):
self.b = bytearray(n)
# 值访问(按偏移)
def st(self, off, v):
self.b[off:off+4] = (v & 0xFFFFFFFF).to_bytes(4, 'little')
def ld(self, off):
return int.from_bytes(self.b[off:off+4], 'little')
# 地址访问(按字节地址)
def st_addr(self, addr, v):
self.st(addr - self.BASE, v)
def ld_addr(self, addr):
return self.ld(addr - self.BASE)
m = Mem()
OFF_X, OFF_Y = 0, 4 # 调用者的 x、y
m.st(OFF_X, 7); m.st(OFF_Y, 42)
print("== 内存布局(值为十六进制小端字节)==")
def dump(tag):
row = " ".join("%02X" % b for b in m.b[:12])
print(f" {pad(tag, 22)}[{row}]")
print(" " + " " * 24 + f"x@{m.BASE:#x}={m.ld(OFF_X):<6d} y@{m.BASE+4:#x}={m.ld(OFF_Y)}")
dump("main: x=7, y=42")
print("\n== 调用 swap(&x, &y) —— 传的是地址的副本 ==")
PX, PY = m.BASE, m.BASE + 4
print(f" 形参: a = {PX:#x}(副本), b = {PY:#x}(副本)")
print(f" 注意:a、b 是“地址的值”,但它们指向的位置与 main 的 x、y 相同")
print("\n== swap 内部三步 ==")
t = m.ld_addr(PX)
print(f" ① int t = *a; t = {t}")
v_b = m.ld_addr(PY)
print(f" ② *a = *b; 写到 {PX:#x} ← {v_b}")
m.st_addr(PX, v_b)
print(f" ③ *b = t; 写到 {PY:#x} ← {t}")
m.st_addr(PY, t)
dump("swap 返回")
print("\n== 结论 ==")
print(f" main 里的 x 变成 {m.ld(OFF_X)},y 变成 {m.ld(OFF_Y)} —— 调用者的变量真被改了")
print(" 但 C 仍然是“值传递”:swap 拿到的是 a、b 两个副本(值 = 地址)")
print(" “通过指针改成功”,不是因为“传引用”,而是因为副本里装的是同一个地址")
print("\n== 如果不用指针(swap(x, y),形参是 int)==")
m2 = Mem()
m2.st(OFF_X, 7); m2.st(OFF_Y, 42)
A_OFF, B_OFF = 16, 20 # 形参自己的存储(在 swap 的栈帧里)
m2.st(A_OFF, m2.ld(OFF_X)) # 复制值
m2.st(B_OFF, m2.ld(OFF_Y))
t2 = m2.ld(A_OFF)
m2.st(A_OFF, m2.ld(B_OFF))
m2.st(B_OFF, t2)
print(f" 形参 a={m2.ld(A_OFF)} b={m2.ld(B_OFF)} 换好了")
print(f" 但 main 里 x={m2.ld(OFF_X)} y={m2.ld(OFF_Y)} —— 一个字都没变 ✗")
print(" 因为形参是“值的副本”,改副本影响不到原件")预期输出:
== 内存布局(值为十六进制小端字节)==
main: x=7, y=42 [07 00 00 00 2A 00 00 00 00 00 00 00]
x@0x7ffc1000=7 y@0x7ffc1004=42
== 调用 swap(&x, &y) —— 传的是地址的副本 ==
形参: a = 0x7ffc1000(副本), b = 0x7ffc1004(副本)
注意:a、b 是“地址的值”,但它们指向的位置与 main 的 x、y 相同
== swap 内部三步 ==
① int t = *a; t = 7
② *a = *b; 写到 0x7ffc1000 ← 42
③ *b = t; 写到 0x7ffc1004 ← 7
swap 返回 [2A 00 00 00 07 00 00 00 00 00 00 00]
x@0x7ffc1000=42 y@0x7ffc1004=7
== 结论 ==
main 里的 x 变成 42,y 变成 7 —— 调用者的变量真被改了
但 C 仍然是“值传递”:swap 拿到的是 a、b 两个副本(值 = 地址)
“通过指针改成功”,不是因为“传引用”,而是因为副本里装的是同一个地址
== 如果不用指针(swap(x, y),形参是 int)==
形参 a=42 b=7 换好了
但 main 里 x=7 y=42 —— 一个字都没变 ✗
因为形参是“值的副本”,改副本影响不到原件三条结论:
| 结论 | 说明 |
|---|---|
| C 只有值传递 | 传指针传的也是"值的副本"——只不过那个值恰好是地址 |
| 指针的威力来自"共享内存" | 两个函数拿着同一个地址,就看见了同一块内存(回链 lang/04 的栈帧) |
| 不用指针就改不了调用者 | 形参是栈帧里的新变量,换它不影响原件 |
例 3:同一行 C,落到 MIPS 是哪几条
任务:把 *p 系列操作与 lw/sw 对齐,说明"指针 = 寻址方式"。
c
int x = 7; /* x 在栈上,设 &x = 0x7FFC1000 */
int *p = &x;
int v = *p; /* 读 */
*p = v + 35; /* 写 */对应的 MIPS32(回链 lang/02-addressing.md、lang/04-stack.md):
asm
# int x = 7; (x 在帧内偏移 0)
li $t0, 7
sw $t0, 0($fp) # x = 7
# int *p = &x; ← 注意:算地址用加法,不访存
addiu $t1, $fp, 0 # $t1 = &x (la 的本质就是加法)
sw $t1, 4($fp) # p = &x (p 存在帧内偏移 4)
# int v = *p; ← 解引用 = 再来一次访存
lw $t1, 4($fp) # $t1 = p(把地址从内存取回寄存器)
lw $t2, 0($t1) # $t2 = *p ← 这就是"解引用"
# *p = v + 35;
addiu $t3, $t2, 35 # 计算 v + 35
sw $t3, 0($t1) # 写回 *p ← 地址已在 $t1 里用 Python 把"指令轨迹"列出来:
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
FP = 0x7FFC1000 # 帧指针
mem = {FP + 0: 7, FP + 4: FP + 0} # x = 7 ; p = &x
TRACE = [
("li $t0, 7", "t0 = 7(立即数寻址)", "寄存器"),
("sw $t0, 0($fp)", f"内存[{FP:#x}] = 7 → x = 7", "写内存"),
("addiu $t1, $fp, 0", f"t1 = {FP:#x} → &x(不访存!)", "寄存器"),
("sw $t1, 4($fp)", f"内存[{FP+4:#x}] = {FP:#x} → p = &x", "写内存"),
("lw $t1, 4($fp)", f"t1 = {FP:#x} → 把 p 读回来", "读内存"),
("lw $t2, 0($t1)", "t2 = 7 → *p ★真正的解引用", "读内存"),
("addiu $t3, $t2, 35", "t3 = 42 → v + 35", "寄存器"),
("sw $t3, 0($t1)", f"内存[{FP:#x}] = 42 → *p = 42", "写内存"),
]
print(" " + pad("指令", 20) + pad("效果", 40) + "类别")
memops = 0
for ins, eff, kind in TRACE:
if kind in ("读内存", "写内存"):
memops += 1
print(" " + pad(ins, 20) + pad(eff, 40) + kind)
print(f"\n 共 {len(TRACE)} 条指令,其中访存 {memops} 条")
print(f" ★ 取地址(&x)不访存 —— 它就是一次 addiu")
print(f" ★ 解引用(*p)才访存 —— 它就是一条 lw / sw")
print(f" ★ 所以“指针很慢”是错觉:慢的是多出来的那一次访存,不是指针本身")
print()
print(" 对照 MIPS 的“只有基址+偏移”:")
print(f" lw $t2, 0($t1) ← 基址 $t1 = p 的值,偏移 0")
print(f" 所谓“指针解引用”,在机器层就是一次普通的基址+偏移访存")预期输出:
指令 效果 类别
li $t0, 7 t0 = 7(立即数寻址) 寄存器
sw $t0, 0($fp) 内存[0x7ffc1000] = 7 → x = 7 写内存
addiu $t1, $fp, 0 t1 = 0x7ffc1000 → &x(不访存!) 寄存器
sw $t1, 4($fp) 内存[0x7ffc1004] = 0x7ffc1000 → p = &x 写内存
lw $t1, 4($fp) t1 = 0x7ffc1000 → 把 p 读回来 读内存
lw $t2, 0($t1) t2 = 7 → *p ★真正的解引用 读内存
addiu $t3, $t2, 35 t3 = 42 → v + 35 寄存器
sw $t3, 0($t1) 内存[0x7ffc1000] = 42 → *p = 42 写内存
共 8 条指令,其中访存 5 条
★ 取地址(&x)不访存 —— 它就是一次 addiu
★ 解引用(*p)才访存 —— 它就是一条 lw / sw
★ 所以“指针很慢”是错觉:慢的是多出来的那一次访存,不是指针本身
对照 MIPS 的“只有基址+偏移”:
lw $t2, 0($t1) ← 基址 $t1 = p 的值,偏移 0
所谓“指针解引用”,在机器层就是一次普通的基址+偏移访存三条结论:
| 结论 | 说明 |
|---|---|
| 取地址 = 加法,不访存 | &x 编译成 addiu/la——它只是"算出一个数" |
| 解引用 = 访存 | *p 编译成 lw/sw——基址是 p 的值,偏移 0 |
| 指针没有"魔法" | 它在机器层就是 lang/02 的"基址 + 偏移"寻址,一个字节都不多 |
例 4:空指针与悬垂指针长什么样
任务:把三类坏指针的后果具体化。
c
#include <stdio.h>
#include <stdlib.h>
int *make_dangling(void) {
int local = 42;
return &local; /* ✗ 返回局部变量地址 */
}
int main(void) {
int *p = NULL;
/* printf("%d\n", *p); */ /* ① 空指针解引用 → 段错误 */
/* ② 悬垂指针:free 之后还接着用 */
int *q = malloc(sizeof(int));
*q = 5;
free(q);
/* printf("%d\n", *q); */ /* 未定义行为:可能打出 5,也可能崩 */
/* ③ 野指针:根本没初始化 */
int *r; /* 值随机 */
/* *r = 1; */ /* 危险:写到随机地址 */
int *s = make_dangling();
/* printf("%d\n", *s); */ /* 栈帧已被回收 */
return 0;
}用 Python 模拟"栈帧回收"为什么让悬垂指针"有时对有时错":
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
STACK_BASE = 0x7FFC1000
stack = {} # 地址 → 值(模拟栈内存)
def call_make():
"""make_dangling 的栈帧:local 在帧内偏移 0"""
fp = STACK_BASE - 16 # 栈向下生长(回链 lang/04)
stack[fp] = 42 # local = 42
print(f" make() 帧基 {fp:#x},写 local=42,返回 &local = {fp:#x}")
return fp # 栈帧"结束",但内容还在
addr = call_make()
print(f" 返回后立刻读 *s = {stack[addr]} ← 看起来正常!这就是它难查的原因")
def another_call():
"""下一个函数压栈,帧基同样落在 STACK_BASE-16,覆盖了旧内容"""
fp = STACK_BASE - 16
stack[fp] = 0xDEADBEEF # 别的函数用了同一块栈
print(f" another() 帧基 {fp:#x},也写这个位置 → 旧值被覆盖")
print()
another_call()
print(f" 再读 *s = {stack[addr]:#x} ← 值变了!程序行为“时对时错”")
print("\n=== 三类坏指针对照 ===")
print(" " + pad("类型", 14) + pad("值", 18) + pad("解引用后果", 36) + "能否预测")
rows = [
("野指针", "随机", "写到随机地址,可能毁掉别的数据", "完全不可预测"),
("空指针 NULL", "0x0", "访问 0 页 → 段错误", "可预测(崩溃)"),
("悬垂指针", "已失效的地址", "读到旧值或垃圾值", "不可预测(最危险)"),
]
for t, v, c, p in rows:
print(" " + pad(t, 14) + pad(v, 18) + pad(c, 36) + p)
print("\n=== 三条防御规则 ===")
print(" 1. 定义时就初始化:int *p = NULL; (不写 int *p;)")
print(" 2. free(p) 之后立刻 p = NULL; (否则就是悬垂指针)")
print(" 3. 解引用前判空:if (p != NULL) *p = ...;")
print(" 4. 不要返回局部变量的地址(栈帧会失效)")预期输出:
make() 帧基 0x7ffc0ff0,写 local=42,返回 &local = 0x7ffc0ff0
返回后立刻读 *s = 42 ← 看起来正常!这就是它难查的原因
another() 帧基 0x7ffc0ff0,也写这个位置 → 旧值被覆盖
再读 *s = 0xdeadbeef ← 值变了!程序行为“时对时错”
=== 三类坏指针对照 ===
类型 值 解引用后果 能否预测
野指针 随机 写到随机地址,可能毁掉别的数据 完全不可预测
空指针 NULL 0x0 访问 0 页 → 段错误 可预测(崩溃)
悬垂指针 已失效的地址 读到旧值或垃圾值 不可预测(最危险)
=== 三条防御规则 ===
1. 定义时就初始化:int *p = NULL; (不写 int *p;)
2. free(p) 之后立刻 p = NULL; (否则就是悬垂指针)
3. 解引用前判空:if (p != NULL) *p = ...;
4. 不要返回局部变量的地址(栈帧会失效)三条结论:
| 结论 | 说明 |
|---|---|
| 空指针的崩溃是"好消息" | 它立刻暴露问题,比"悄悄改坏数据"好得多 |
| 悬垂指针的可怕在于"还能用" | 栈帧/堆块被回收,但字节还在——读到的值取决于后来谁用了那块内存 |
| 规则比技巧重要 | 初始化、置空、判空三条能挡掉 90% 的指针事故 |
考点
考点
1. 指针的两个属性(核心)
- 指针 = 地址 + 类型;地址决定"指向哪",类型决定"读几字节、步长多大";
sizeof(T*)与T无关(64 位机上恒 8)——类型不占运行时空间,只影响生成的指令;- 指向类型决定指令:
char*→lb;int*→lw;double*→ 两条lw。
2. 指针算术
p + n= 地址 +n × sizeof(*p)(注意不是n字节);p - q(同类型)得到"元素个数",不是字节数;结果类型是有符号的ptrdiff_t;- 编译器把
p+i编译成"i× 元素大小"——就是sll(回链lang/02)。
3. & 与 *
- 互逆:
*(&x) == x、&(*p) == p; &不访存(编译成加法);*才访存(lw/sw);*p = v是写 p 指向的地方;p = &v是改 p 自己——两者完全不同。
4. 数组与指针
a[i] ≡ *(a + i) ≡ *(p + i) ≡ p[i]——下标只是语法糖;sizeof(a)是数组总字节数,sizeof(p)是指针大小——别混;- 参数里的
int a[]等价于int *a,会退化成指针 → 必须另外传长度; - 数组名不能赋值(
a = p非法),指针可以。
5. 多级指针与 void*
- 想改什么就传什么的地址:改
int传int*,改int*传int**; void*保留地址、丢掉类型 → 不能解引用、不能算术;这就是malloc返回void*且必须先转型的原因;- 函数指针:指向代码的指针;回调、跳转表、虚函数表的底层;机器层是间接跳转(
jalr)。
6. 三类坏指针(易考易错)
| 类型 | 成因 | 后果 |
|---|---|---|
| 野指针 | 未初始化 | 随机写 → 不可预测 |
| 空指针 | 值为 0 | 段错误 → 可预测地崩 |
| 悬垂指针 | 内存已释放 / 栈帧已回收 | 读到垃圾,行为时对时错(最危险) |
free(p)后必须p = NULL;绝不能返回局部变量的地址。
7. 高频易错点小结
sizeof陷阱:参数是数组时sizeof得到指针大小(8),不是数组大小;p+1的步长:必须看*p的类型,别当成"地址 +1";- 指针不能当整数:32 位上
sizeof(int)==sizeof(void*)是巧合,64 位上强转会丢高位; - "指针慢"是错觉:慢的是多出来的访存(
lw),不是指针本身;取地址根本不访存。
小结
- 指针 = 地址 + 类型:地址决定指向哪,类型决定读几字节、
p+1跳多远。 - 指针自己也要占空间:64 位机上恒 8 字节,与指向类型无关;
sizeof(T*)不随T变。 &与*互逆:&是"算地址"(加法,不访存),*是"访存"(lw/sw)。- 指针算术的步长 =
sizeof(*p);p - q得到元素个数(有符号ptrdiff_t)。 - 数组名会退化成指针:
a[i] ≡ *(a+i) ≡ p[i];但sizeof(a)与sizeof(p)不同,数组名不能赋值。 - 参数里的数组会退化 → "数组传参会丢长度",必须另传
n。 - 多级指针的规则:想改什么就传什么的地址;
void*保留地址丢掉类型,所以不能解引用。 - 函数指针指向代码,是回调 / 跳转表 / 虚函数表的底层。
- 三类坏指针:野指针(随机)、空指针(崩溃,可预测)、悬垂指针(最危险);规则:初始化、
free后置空、解引用前判空、不返回局部变量地址。 - 指针在机器层没有任何魔法:它就是
lang/02的"基址 + 偏移"寻址——取地址是一条加法,解引用是一条lw/sw。
回到主线:指针讲清了"一个地址怎么找到一块内存"。但内存里从来不是孤立的一个 int——总是一片、一组、带结构的。
数组是连续的,那"多行多列"怎么摆?结构体里成员为什么有时"排不紧"(中间有空隙)?
sizeof(struct {char; int; char;})为什么是 12 而不是 6?——这些"空隙"不是浪费,是硬件对齐要求逼出来的。下一篇就讲这件事。
下一篇:数组、结构体与内存布局
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。