Lecture 9: 指针 (Pointers)
Lecture 9: 指针 (Pointers)
概述
本讲解决的问题是:C 程序如何通过”值的存放位置”而不是”值本身”来操作数据——这是”修改调用者的变量”“表示字符串” “表示结构化数据”三类需求的共同基础。为此 C 引入了指针类型 X*、解引用运算符 (dereference operator) * 与取地址运算符 (address operator) &;在机器层面它们只是”把一个地址装进寄存器或内存,再用它做一次内存访问”。 指针是 ECE 220 从 LC-3 汇编走向 C 的枢纽:上一讲的栈帧与调用约定解释了参数为何是值传递 (call by value), 本讲说明要改调用者的变量就必须传地址,而下一讲会看到数组名本身就是地址,于是”数组”与”指针”被缝成同一件事。
核心概念与底层机制图解
- 指针 (Pointer) 就是内存地址:指针是一个变量,它的值是某个内存地址,程序用这个值去”指名”另一块存储。
- 直观解释:指针像门牌号,不是房子本身。把门牌号抄给朋友(值传递),朋友去的是同一栋房子, 但他手里那张纸并不是房子。
- 底层机制图解:
*p在机器上就是”把p的值装入地址寄存器,再发起一次访存”。LC-3 里只有两条指令:LDR R2,R1,#0读出*p(R1 装p),STR R2,R1,#0写入*p。 64 位机器(EWS 实验室机器)上一个指针占 8 字节,LC-3 上占 1 个字(16 位地址)。C: int32_t value = 42; int32_t *iptr = &value; 机器: M[&value] ← 42 M[&iptr] ← &value LDR R1,R5,#-1 ; R1 ← iptr(一个地址) LDR R2,R1,#0 ; R2 ← M[R1] = 42 ← 这次间接访问就是"指针" - 作用域与存储期:指针变量本身与普通变量一样(函数内 automatic、加
static则 static), 但被指向对象有独立存储期:字符串常量活到程序结束,局部数组随栈帧消失(于是产生悬垂指针)。
- 指针类型
X*从右往左读 (read pointer types right to left):int*是 “pointer to int”,char**是 “pointer to pointer to char”。- 直观解释:英文里重心在最后被修饰的名词:
a pointer to a pointer to char,真正存放的是char。 - 底层机制图解:类型决定”解引用取几个字节”。同一个地址
p,*(char*)p取 1 字节,*(int32_t*)p取 4 字节; 编译器为int32_t*生成的p + 1是”地址 + 4”,为char*生成的是”地址 + 1”。 类型信息只存在于编译期,运行时的内存里没有类型,只有位。 - 作用域与存储期:类型属于声明,不占运行时空间;
sizeof (int32_t*)与sizeof (char*)都是 8。
- 直观解释:英文里重心在最后被修饰的名词:
- 声明指针只为指针分配空间 (declaring a pointer only makes space for the pointer):
int32_t* iptr;只创建了一个”能装地址”的变量,不创建被指向的对象。- 直观解释:买了信封不等于买了房子;信封上没写门牌号(未初始化)时按它去找房子必然出乱子。
- 底层机制图解:
iptr(8 字节,内容未定义)有存储,而??? 被指向的对象不存在,必须另行声明或malloc。 - 作用域与存储期:
iptr是 automatic;它指向的对象可能是 static(字符串常量)、automatic(别的局部变量) 或 allocated(malloc),三者的生命周期互不相干。
- 解引用
*与取地址&:二者都是一元运算符,对可取地址的对象互为逆运算。- 直观解释:
&是”问门牌号”,*是”按门牌号上门取东西”。 - 底层机制图解:
&x不产生访存,编译期就算出地址(栈帧偏移、全局符号,或LEA);*p才产生真实访存。这解释了scanf ("%d", &value)为什么必须写&:被调用者需要地址才能写回来。 - 作用域与存储期:
&只作用于有存储位置的对象 (lvalue);函数返回后,其局部变量的地址即失效。
- 直观解释:
- 陷阱:
*绑定到变量而不是类型 (int *A, B;):声明符里的*属于被声明的变量,不属于类型关键字。- 直观解释:
int *A, B;读作”*A是 int,B是 int”,所以A是int*,B只是int。 - 底层机制图解:
int *A, B;中sizeof A为 8、sizeof B为 4;写成int *A, *B;则两者都是 8。 编译器不会为此报错,只会静默地少一层间接——这是最快的自检手段。 - 作用域与存储期:两个变量的作用域与存储期完全相同,差异只在类型。
- 直观解释:
char*与字符串常量 (string constants):char* cptr = "My favorite string";中字符串是常量, 由编译器放在全局数据区 (global data area);cptr只指向它的第一个字符。- 直观解释:
cptr是写着地址的便条,字符串是印刷好的标语牌;便条可以换,标语牌不能涂改。 - 底层机制图解:两处存储、两种存储期:
全局数据区(static,只读)0x402008 处: 'M' 'y' ' ' 'f' ... '\0' ← cptr 的值指向这里 栈(automatic) : cptr(8 字节)= 0x402008 ← &cptr 就是这 8 字节的地址 - 作用域与存储期:字符串常量 static,
cptrautomatic;因此返回指向局部字符数组的指针一定是 bug, 而返回指向字符串常量的指针是安全的。
- 直观解释:
- 指向指针的指针 (
char**) 与 LDI/STI 类比:把”指针的地址”也存起来,就是两级间接。- 直观解释:门牌号本身被写在另一张纸条上。幻灯片的玩笑很准确:指针的指针到处有用; 指针的指针的指针是考查学生懂不懂指针的好工具,此外没用。
- 底层机制图解:LC-3 的间接寻址
LDI/STI正是硬件版的**(两次访存):LDR R1,R5,#-2 ; R1 ← cptr_ptr LDR R1,R1,#0 ; R1 ← *cptr_ptr = cptr LDR R2,R1,#0 ; R2 ← **cptr_ptr = 'M' LDI R2,CPTR ; 若 cptr 在全局数据区,硬件一次完成两次访存 - 作用域与存储期:
cptr_ptr(automatic)、cptr(automatic)、被指向的字符数组(static) 是三种不同的存储期,这是理解双指针的关键。
- NULL 与空指针 (null pointer):NULL 是”不指向任何对象”的特殊指针值,位模式全 0。
- 直观解释:门牌号那一栏写着”无”——不是随便一个号,而是一个可检测的”无”。
- 底层机制图解:没有 NULL,函数就无法用返回值表示”没找到”,因为几乎任何位模式都可能是合法地址; 全 0 的好处是可以直接参与判断:
if (NULL != p)编译成LDR+BRz。 别混淆四个都”像 0”的东西:NUL是 ASCII 字符'\0',NULL是指针值,null只是英文单词,0是数值。幻灯片还提醒:在很多微控制器上解引用 NULL 不会崩溃。 - 作用域与存储期:NULL 来自
<stdio.h>/<stdlib.h>的宏;把free后的指针赋为 NULL, 能让后续误用立刻暴露,而不是静默破坏堆。
- 指针是让函数修改调用者变量的手段:C 用值传递,形参是实参的副本。
- 直观解释:把门牌号抄一份给被调用者:他换不掉你的纸条(
w = ...无效), 却能改房子里面的东西(*w = ...有效)。 - 底层机制图解:幻灯片
string_equal里s1++、s2++只改副本,调用者的w、x不变; 而*s1 = ...会真的改写调用者能看到的内存。要改调用者的指针变量本身,必须传&pointer(形参T**):调用者栈帧 被调用者栈帧 +-------------+ +------------------+ \| x = 3 |◄─&x─┐| a = &x (8 字节)|──→ 指向 x | y = 8 |◄─&y─┼| b = &y (8 字节)|──→ 指向 y +-------------+ └+------------------+ *a = *b 改的是调用者的变量,不是形参 a、b 自身。 - 作用域与存储期:形参
a、b随被调用者栈帧销毁,被指向的x、y属于调用者且活得更久, 所以”写回”合法;反之返回指向自身局部变量的指针就是错误。
- 直观解释:把门牌号抄一份给被调用者:他换不掉你的纸条(
&不能作用于临时值:&(value + 1)必然是编译错误。- 直观解释:”值 43”这种中间结果没有被存放在任何地方,自然没有门牌号。
- 底层机制图解:
value + 1的结果可能只存在于寄存器里,甚至在编译期被折叠成常量;&要求操作数是 lvalue。同理&&cptr(对&cptr再取地址)也是错误,但*(*(&cptr))合法且等于*cptr。 - 作用域与存储期:这是 C 存储模型的一部分——只有具有存储期的对象才有地址。
内存布局总图:
高地址 +------------------------------+ 栈 (automatic)
| cptr_ptr (8 字节)→ &cptr | ← &cptr 合法
| cptr(8)→ 0x402008 / value(4)= 42 / iptr(8)→ &value
+------------------------------+
| ... 空闲 ... | 堆 (allocated),malloc 从这里向上要空间
低地址 +------------------------------+ 全局数据区 (static):"My favorite string\0"(只读,cptr 指向它)
代码 (text) 在更低地址
代码示例与底层机制分析
示例 1:指针的读写、类型大小与”重新指向”
代码 (C)(/tmp/ece220_ptr/01_pointer_basics.c,用 gcc -g -std=c99 -Wall -Werror 01_pointer_basics.c -o 01_pointer_basics 实测):
#include <stdint.h>
#include <stdio.h>
int
main (void)
{
int32_t value = 42;
int32_t other = 7;
int32_t* iptr = &value;
int32_t* jptr = &other;
printf ("value = %d\n", value);
printf ("*iptr = %d\n", *iptr);
printf ("iptr == &value -> %d\n", iptr == &value);
printf ("&iptr = %p (address of the POINTER variable)\n", (void*) &iptr);
printf ("sizeof (int32_t) = %d, sizeof (int32_t*) = %d\n",
(int) sizeof (int32_t), (int) sizeof (int32_t*));
printf ("sizeof (value) = %d, sizeof (iptr) = %d\n",
(int) sizeof value, (int) sizeof iptr);
*iptr = 100;
printf ("after *iptr = 100: value = %d, *iptr = %d\n", value, *iptr);
iptr = jptr;
printf ("after iptr = jptr: *iptr = %d, value is still %d\n",
*iptr, value);
return 0;
}
实际输出:
value = 42
*iptr = 42
iptr == &value -> 1
&iptr = 0x7ffd7cfb5238 (address of the POINTER variable)
sizeof (int32_t) = 4, sizeof (int32_t*) = 8
sizeof (value) = 4, sizeof (iptr) = 8
after *iptr = 100: value = 100, *iptr = 100
after iptr = jptr: *iptr = 7, value is still 100
【代码做什么?】
- 栈上分配 4 字节放
value = 42,另 4 字节放other = 7。 iptr、jptr各占 8 字节,分别写入&value、&other。*iptr打印 42(解引用产生一次访存);&iptr打印指针变量自己在栈上的地址,与iptr的内容不同。*iptr = 100通过指针写入,value变成 100——”修改外层变量”的最小形态。iptr = jptr只改指针自己的 8 字节;value仍为 100,说明重新指向不搬动任何数据。
【底层机制透视】 sizeof (int32_t) = 4 而 sizeof (int32_t*) = 8,说明”指针的存储”与”被指向对象的存储”是两件独立的事。 iptr == &value 为 1,因为 &value 在编译期就是”R5(帧指针)+ 固定偏移”,运行期与 iptr 中的位模式逐位相同。 iptr = jptr 后 *iptr 为 7,正是幻灯片 string_equal 中 s1++、s2++ 不影响调用者的同一机制:被复制的只是地址这个值。
【内存布局图解】(地址为示意值)
栈
0x7ffd..e4 +---------------------+ value = 100(被 *iptr 改写)
| 42 → 100 |
0x7ffd..e8 +---------------------+
| iptr = 0x7ffd..e4 |──────┐ 解引用走这条箭头,读/写 4 字节
0x7ffd..f0 +---------------------+ ↓
| jptr = 0x7ffd..e0 |─────→ other = 7
0x7ffd..f8 +---------------------+
iptr = jptr 之后 iptr 的内容变成 0x7ffd..e0,*iptr 读出 7。
注意 &iptr = 0x7ffd..e8(指针变量住哪)≠ iptr = 0x7ffd..e4(它指向哪)。
【与汇编的对应】(LC-3;局部变量在 R5 帧指针下方,R6 为栈指针)
; ---- int32_t value = 42; int32_t other = 7; ----
AND R0,R0,#0
ADD R0,R0,#15
ADD R0,R0,#15
ADD R0,R0,#12 ; R0 = 42
STR R0,R5,#0 ; value (R5+0)
AND R0,R0,#0
ADD R0,R0,#7
STR R0,R5,#-1 ; other (R5-1)
; ---- int32_t *iptr = &value; int32_t *jptr = &other; ----
ADD R1,R5,#0 ; R1 = &value(栈上取地址用 R5+offset)
STR R1,R5,#-2 ; iptr
ADD R2,R5,#-1 ; R2 = &other
STR R2,R5,#-3 ; jptr
; ---- *iptr 读 / 写 ----
LDR R1,R5,#-2 ; R1 ← iptr(一个地址)
LDR R2,R1,#0 ; R2 ← M[R1] = value = 42 ← 解引用 = 一次 LDR
; R3 ← 100(由若干 ADD 构造)
STR R3,R1,#0 ; M[iptr] ← 100,改的是 value
; ---- iptr = jptr; ----(只动 8 字节的指针副本)
LDR R2,R5,#-3
STR R2,R5,#-2
; 全局/静态对象用 LEA Rd,LABEL 取地址;栈上局部变量没有汇编期标号,只能 ADD Rd,R5,#offset 后再 LDR/STR。
示例 2:char*、字符串常量的位置与 char** 的两级间接
代码 (C)(/tmp/ece220_ptr/02_string_and_pointer_to_pointer.c):
#include <stdio.h>
int
main (void)
{
char* cptr = "My favorite string";
char** cptr_ptr = &cptr;
printf ("*cptr = %c\n", *cptr);
printf ("cptr = %p -> \"%s\"\n", (void*) cptr, cptr);
printf ("&cptr = %p (where the pointer variable lives)\n", (void*) &cptr);
printf ("cptr + 3 = \"%s\"\n", cptr + 3);
printf ("*(cptr + 3) = %c\n", *(cptr + 3));
printf ("**cptr_ptr = %c\n", **cptr_ptr);
printf ("*cptr_ptr == cptr -> %d\n", *cptr_ptr == cptr);
printf ("*(*(&cptr)) = %c\n", *(*(&cptr)));
printf ("sizeof (cptr) = %d, sizeof (cptr_ptr) = %d\n",
(int) sizeof cptr, (int) sizeof cptr_ptr);
return 0;
}
实际输出:
*cptr = M
cptr = 0x402008 -> "My favorite string"
&cptr = 0x7fffdc4790d0 (where the pointer variable lives)
cptr + 3 = "favorite string"
*(cptr + 3) = f
**cptr_ptr = M
*cptr_ptr == cptr -> 1
*(*(&cptr)) = M
sizeof (cptr) = 8, sizeof (cptr_ptr) = 8
【代码做什么?】
- 编译器把
"My favorite string"放进全局数据区并取得地址(本次运行是0x402008),写进局部变量cptr。 *cptr读出'M';cptr + 3前进 3 个字符,得到"favorite string"。cptr_ptr存放&cptr;**cptr_ptr做两次解引用得到'M',而*cptr_ptr恰好等于cptr。
【底层机制透视】 &cptr(栈地址)与 cptr(静态数据地址)处于完全不同的地址区域,这就是”两种存储期”的直接证据: 函数返回后 cptr 消失而字符串仍在。%p 要求实参为 void*,必须显式转换(-Wall -Werror 的硬性要求)。 指针算术按元素大小缩放:char* 加 3 是加 3 字节,若换成 int32_t* 加 3 就是加 12 字节。
【内存布局图解】
全局数据区(只读,static) 栈(automatic)
0x402008 +----+----+----+----+ ... +----+ 0x7fff..c8 +------------------+
|'M' |'y' |' ' |'f' | |\0 | | cptr_ptr = &cptr |
+----+----+----+----+ ... +----+ +------------------+
^ 0x7fff..d0 | cptr = 0x402008 |
└────────────────────────────────────────────────────── +------------------+
cptr_ptr ──(*cptr_ptr)──→ cptr;**cptr_ptr 沿两级箭头到达 'M'
【与汇编的对应】(LEA 与 LDI 的用法)
; ---- char* cptr = "My favorite string"; ----
LEA R0,STR_FAV ; R0 = 字符串常量地址(标号汇编期已知 → LEA)
STR R0,R5,#-1 ; cptr
; ---- *cptr ----(一次间接:LDR)
LDR R1,R5,#-1 ; R1 ← cptr
LDR R2,R1,#0 ; R2 ← 'M'
; ---- char** cptr_ptr = &cptr; 然后 **cptr_ptr ----
ADD R1,R5,#-1 ; R1 = &cptr(栈上取地址用 R5+offset)
STR R1,R5,#-2 ; cptr_ptr
LDR R1,R5,#-2 ; R1 ← cptr_ptr
LDR R1,R1,#0 ; R1 ← *cptr_ptr = cptr
LDR R2,R1,#0 ; R2 ← **cptr_ptr = 'M'(两次间接 = LDI)
; 若 cptr 位于全局数据区,硬件一步完成两次访存:LDI R2,CPTR_SLOT ; R2 ← M[M[CPTR_SLOT]]
STR_FAV .STRINGZ "My favorite string"
示例 3:用指针交换两个整数,并用指针”返回”第二个值
代码 (C)(/tmp/ece220_ptr/03_swap_and_second_return.c):
#include <stdint.h>
#include <stdio.h>
static void
swap (int32_t* a, int32_t* b)
{
int32_t temp = *a;
*a = *b;
*b = temp;
}
static int32_t
divmod (int32_t num, int32_t den, int32_t* remainder)
{
*remainder = num % den; /* side effect on the caller's variable */
return num / den; /* the one real return value */
}
int
main (void)
{
int32_t x = 3;
int32_t y = 8;
int32_t q;
int32_t r;
printf ("before swap: x = %d, y = %d\n", x, y);
swap (&x, &y);
printf ("after swap: x = %d, y = %d\n", x, y);
q = divmod (47, 5, &r);
printf ("47 / 5 = %d remainder %d\n", q, r);
/* The addresses the callee received are the addresses of x, y, r. */
printf ("&x = %p, &y = %p, &r = %p\n",
(void*) &x, (void*) &y, (void*) &r);
return 0;
}
实际输出:
before swap: x = 3, y = 8
after swap: x = 8, y = 3
47 / 5 = 9 remainder 2
&x = 0x7ffd06ed2468, &y = 0x7ffd06ed2464, &r = 0x7ffd06ed2460
【代码做什么?】
swap (&x, &y)把两个地址按值传入形参a、b。- 函数体先用
temp = *a保存x的值,否则第一条写入就把它覆盖了。 *a = *b、*b = temp通过指针写回,调用者的x、y完成交换。divmod (47, 5, &r)把商作为返回值,余数经*remainder写回调用者的r——C 里”返回多个值”的标准做法; 最后一行打印三个变量的地址,可见\|&x - &y\| = 4、\|&y - &r\| = 4(三个int32_t相邻)。
【底层机制透视】 swap 改动的是调用者栈帧里的 4 字节,而形参 a、b 自身是被调用者栈帧里的 8 字节, 两者通过”写入到 a 所指地址”联系起来。这也解释了 swap (x, y)(漏写 &)为什么不行: 若把 int 值当地址用,函数会去写地址 3 和地址 8。地址相差 4 说明同一函数内的 automatic 变量被紧凑排布, 但相对顺序与是否相邻由编译器决定,程序不应依赖。
【内存布局图解】
调用者 (main) 栈帧 被调用者 (swap) 栈帧
高地址 +----------------+ 高地址 +------------------+
| 返回地址 (R7) | | 返回地址 (R7) |
+----------------+ +------------------+
| x = 3 → 8 |◄──&x──┐ | a = &x(8 字节) |──→ 指向 x
+----------------+ │ +------------------+
| y = 8 → 3 |◄──&y──┼──┐ | b = &y(8 字节) |──→ 指向 y
+----------------+ │ │ +------------------+
| r(divmod 写 2)| │ │ | temp(4 字节) | *a = *b 写前者的 x
低地址 +----------------+ │ │ +------------------+ *b = temp 写前者的 y
【与汇编的对应】(幻灯片”函数可以修改按值传入的地址上的比特”的机器版本)
; ---- 调用者:swap (&x, &y) ----
ADD R0,R5,#0 ; R0 = &x (参数经 R0–R3 传递)
ADD R1,R5,#-1 ; R1 = &y
JSR SWAP ; R7 ← 返回地址;返回后 x、y 已被改写
; ---- 被调用者 SWAP ----
SWAP ; 进入时 R7 = 返回地址;若本子程序还要调用别人,必须先把 R7 压栈保存
LDR R2,R0,#0 ; R2 = *a (= x)
LDR R3,R1,#0 ; R3 = *b (= y)
STR R3,R0,#0 ; *a = R3 → 调用者的 x = 旧 y
STR R2,R1,#0 ; *b = R2 → 调用者的 y = 旧 x
RET ; JMP R7
; ---- divmod:进入时 R0 = num, R1 = den, R2 = remainder 的地址 ----
; ... LC-3 无除法指令,商/余数由库子程序算好,设在 R3/R4 ...
STR R4,R2,#0 ; *remainder = 余数 ← "第二个返回值"
ADD R0,R3,#0 ; R0 = 商 ← 真正的返回值
RET
示例 4:NULL 的用法与 int *A, B; 陷阱
代码 (C)(/tmp/ece220_ptr/04_null_and_declaration_pitfall.c):
#include <stdint.h>
#include <stdio.h>
static int32_t*
find (int32_t* data, int32_t n, int32_t value)
{
int32_t i;
for (i = 0; i < n; i++) {
if (data[i] == value) {
return &data[i];
}
}
return NULL;
}
int
main (void)
{
int32_t data[5] = {10, 20, 30, 40, 50};
int32_t* hit;
int32_t* miss;
hit = find (data, 5, 30);
miss = find (data, 5, 31);
if (NULL != hit) { /* always test before use */
printf ("found %d at index %lu\n", *hit,
(unsigned long) (hit - data));
}
if (NULL == miss) {
printf ("31 is not in the array (find returned NULL)\n");
}
printf ("hit is %s, miss is %s\n",
(NULL != hit ? "valid" : "NULL"),
(NULL != miss ? "valid" : "NULL"));
/* ---- the declaration pitfall ---- */
{
int *A, B; /* A is int*, but B is a plain int! */
A = &B;
*A = 5;
printf ("sizeof (A) = %d, sizeof (B) = %d\n",
(int) sizeof A, (int) sizeof B);
printf ("B was set through A: B = %d\n", B);
}
return 0;
}
实际输出:
found 30 at index 2
31 is not in the array (find returned NULL)
hit is valid, miss is NULL
sizeof (A) = 8, sizeof (B) = 4
B was set through A: B = 5
【代码做什么?】
find遍历数组,找到就返回该元素的地址(&data[i]),否则返回 NULL。- 调用者先判断
NULL != hit再解引用——这是所有返回指针的函数的调用契约。 hit - data是同类型指针相减,得到”相隔几个元素“(2),不是字节数。- 第二个代码块演示声明陷阱:
int *A, B;中A占 8 字节,B只占 4 字节;A = &B; *A = 5;合法且真的改了B。
【底层机制透视】 find 的返回类型是 int32_t*,所以”没找到”必须借助一个带外 (out-of-band) 的地址值,标准选定全 0 的 NULL。 这也说明指针的本质:任何非零位模式都可能是合法地址,不能靠”看起来奇怪”判断有效性,只能靠约定。 指针相减得到 2,是因为编译器生成”字节差 ÷ sizeof (int32_t)“;且两指针必须指向同一数组(或其末尾下一位)才有定义。
【内存布局图解】
data 数组(main 的栈帧) 指针变量
0x7ffd..e0 +------+ +--------------------+
| 10 | ← data[0] | hit = 0x7ffd..e8 |──┐
+------+ +--------------------+ │
0x7ffd..e4 | 20 | ← data[1] | miss = NULL(全 0)| │
+------+ +--------------------+ │
0x7ffd..e8 | 30 | ← data[2] ◄────────────────────────────┘
+------+ hit - data = (0x7ffd..e8 - 0x7ffd..e0) / 4 = 2
| 50 | ← 一维数组必须连续,否则 &data[i] 之后的指针算术没有意义
+------+
【与汇编的对应】(NULL 判断就是条件码 Z 的判断)
; ---- hit = find (data, 5, 30); ----
LEA R0,DATA ; R0 = 数组首地址(数组是全局对象 → LEA)
AND R1,R1,#0
ADD R1,R1,#5 ; R1 = 长度 5
AND R2,R2,#0
ADD R2,R2,#15
ADD R2,R2,#15 ; R2 = 30
JSR FIND
STR R0,R5,#-1 ; hit ← 返回的地址
; ---- if (NULL != hit) { *hit ... } ----
LDR R1,R5,#-1 ; R1 ← hit
BRz SKIP ; 全 0 位模式 → Z=1 → 跳过;NULL 判断就是 BRz
LDR R2,R1,#0 ; R2 ← *hit(只有非 NULL 才敢解引用)
SKIP ; FIND 内部循环 LDR 比较,失败时用 AND R0,R0,#0 造出 NULL 再 RET
演示(仅供演示、请勿模仿):两种典型的未定义行为 幻灯片里的经典 bug 是
int* ptr; scanf ("%d", ptr);:ptr是 automatic 变量且从未赋值, 里面是栈上的旧比特,scanf会往那个随机地址写数据。int32_t* ptr; /* 未初始化 */ scanf ("%d", ptr); /* UB:写入随机地址 */ int32_t value = 42; int32_t* bad = &(value + 1); /* 编译错误:临时值没有地址 */实测(gcc 12.2.0,x86-64 Linux):
-Wall -Werror下第一种编译失败,报error: 'ptr' is used uninitialized [-Werror=uninitialized];去掉-Werror后可编译, 运行时以段错误(退出码 139)结束。第二种报error: lvalue required as unary '&' operand。 这属于未定义行为,结果随编译器、优化级别与平台而异,不能推理成”一定会崩溃”。
常见错误与调试技巧
- 用未初始化的指针:
int32_t* p; *p = 1;。现象是随机段错误,或悄悄破坏别的变量后在别处爆炸。 调试:-Wall -Werror以-Werror=uninitialized直接拒绝编译;valgrind --track-origins=yes ./prog定位来源;gdb中p p、bt、watch *p。 int *A, B;声明陷阱:以为B也是指针,B = &value;报类型错误(32 位平台上更隐蔽)。 调试:gdb -tui --args ./prog后ptype A、ptype B;或打印(int) sizeof A与(int) sizeof B(8 与 4 立刻暴露)。scanf忘记取地址:scanf ("%d", value);会把变量的值当地址写进去。调试:-Wall报format '%d' expects argument of type 'int *';已崩溃时gdb的bt看是否停在scanf内,p &value与p value对比。- 修改字符串常量或返回局部变量地址:
char* s = "hi"; s[0] = 'H';段错误(只读段);return &local;是”有时能跑”的悬垂指针。 调试:gdb中x/s s与info proc mappings确认只读映射;gcc -Wall报function returns address of local variable;再用-fsanitize=address -g复核。 - 混淆
NUL、NULL、0,或%p实参不是void*:字符串循环不结束,或地址打印错乱。 调试:gdb中x/16xb str确认末尾是否真有 0 字节;统一写printf ("%p", (void*) p);并开启-Wall -Werror。
关键要点
- 指针就是一个带类型的地址:
X*从右往左读;类型只影响编译期两件事——解引用取几个字节、指针算术按几字节缩放,运行时的内存里没有类型,只有位。 - 声明指针不等于创建对象:
int32_t* p;只提供装地址的空间;被指向的对象必须另行声明、来自字符串常量、或由malloc分配。 - C 是值传递:函数改不了调用者的变量本身,但可以改”调用者变量地址上的内容”; 要改调用者的指针变量就传
&pointer(形参T**)——这正是 LC-3LDI/STI在 C 里的形态。 &要有存储,*要有有效地址:这两条规则覆盖本讲绝大多数 bug。NULL 是让”无对象”可检测的约定 (一条BRz即可判断),但要记住NUL(字符)、NULL(指针)、0(数值)不是同一个东西。
思考题(带答案)
问题 1:下面两段代码,哪一段能把调用者的 p 改成指向新分配的内存?为什么?
static void alloc_a (int32_t* p) { p = malloc (10 * sizeof (int32_t)); }
static void alloc_b (int32_t** p) { *p = malloc (10 * sizeof (int32_t)); }
答案:只有 alloc_b。alloc_a 的形参 p 是调用者指针值的副本:函数内让 p 指向新块, 调用者的指针毫无变化,而且那块内存立刻泄漏。alloc_b 收到的是”指针变量的地址”, *p = ... 写入的是调用者的指针变量本身。对应到 LC-3,就是被调用者并没有换掉调用者栈帧里那个指针槽, 而是写入到它所指向的槽(STR Rd,Rbase,#0)。
问题 2:char* cptr = "My favorite string"; 之后,*cptr、&cptr、**&cptr、&*cptr 各是什么?哪个是编译错误?
答案:*cptr 是 'M';&cptr 是 cptr 这个指针变量的地址(类型 char**);**&cptr 等价于 *cptr,即 'M'; &*cptr 等价于 cptr 本身,类型 char*。真正报错的是 &&cptr:&cptr 的结果是临时值,没有存储位置 (gcc:lvalue required as unary '&' operand)。
问题 3:幻灯片里 string_equal 把 if (*s1 != *s2) { return 0; } 改成 if (*s1 != *s2) { *s1 = *s2 = '\0'; return 0; } 后,为什么 printf ("%s %s\n", w, x) 打印的仍然是 word1 word2?
答案:其一,w、x 是 main 的局部变量,s1、s2 是被调用者的形参副本, 函数内对指针本身的任何修改都不影响 w、x(只有 *s1 才可能影响调用者看到的内容); 其二,它们指向的是字符串常量(static、只读),*s1 = ... 是未定义行为,在把字面量放入只读段的平台上会被硬件拒绝。 另外,想知道两个指针是否指向同一段字符,不能写 s1 == s2,必须逐字符比较(strcmp 做的事)。
