Lecture 9: 指针 (Pointers)

目录 · ← l8 · l10 →

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”,所以 Aint*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,cptr automatic;因此返回指向局部字符数组的指针一定是 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_equals1++s2++ 只改副本,调用者的 wx 不变; 而 *s1 = ... 会真的改写调用者能看到的内存。要改调用者的指针变量本身,必须传 &pointer(形参 T**):
      调用者栈帧           被调用者栈帧
      +-------------+      +------------------+
      \| x = 3       |◄─&x─┐| a = &x (8 字节)|──→ 指向 x
      | y = 8       |◄─&y─┼| b = &y (8 字节)|──→ 指向 y
      +-------------+     └+------------------+
      *a = *b 改的是调用者的变量,不是形参 a、b 自身。
      
    • 作用域与存储期:形参 ab 随被调用者栈帧销毁,被指向的 xy 属于调用者且活得更久, 所以”写回”合法;反之返回指向自身局部变量的指针就是错误。
  • & 不能作用于临时值&(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

【代码做什么?】

  1. 栈上分配 4 字节放 value = 42,另 4 字节放 other = 7
  2. iptrjptr 各占 8 字节,分别写入 &value&other
  3. *iptr 打印 42(解引用产生一次访存);&iptr 打印指针变量自己在栈上的地址,与 iptr 的内容不同。
  4. *iptr = 100 通过指针写入,value 变成 100——”修改外层变量”的最小形态。
  5. iptr = jptr 只改指针自己的 8 字节;value 仍为 100,说明重新指向不搬动任何数据

【底层机制透视】 sizeof (int32_t) = 4sizeof (int32_t*) = 8,说明”指针的存储”与”被指向对象的存储”是两件独立的事。 iptr == &value 为 1,因为 &value 在编译期就是”R5(帧指针)+ 固定偏移”,运行期与 iptr 中的位模式逐位相同。 iptr = jptr*iptr 为 7,正是幻灯片 string_equals1++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

【代码做什么?】

  1. 编译器把 "My favorite string" 放进全局数据区并取得地址(本次运行是 0x402008),写进局部变量 cptr
  2. *cptr 读出 'M'cptr + 3 前进 3 个字符,得到 "favorite string"
  3. 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'

【与汇编的对应】LEALDI 的用法)

; ---- 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

【代码做什么?】

  1. swap (&x, &y) 把两个地址按值传入形参 ab
  2. 函数体先用 temp = *a 保存 x 的值,否则第一条写入就把它覆盖了。
  3. *a = *b*b = temp 通过指针写回,调用者的 xy 完成交换。
  4. divmod (47, 5, &r) 把商作为返回值,余数经 *remainder 写回调用者的 r——C 里”返回多个值”的标准做法; 最后一行打印三个变量的地址,可见 \|&x - &y\| = 4\|&y - &r\| = 4(三个 int32_t 相邻)。

【底层机制透视】 swap 改动的是调用者栈帧里的 4 字节,而形参 ab 自身是被调用者栈帧里的 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

【代码做什么?】

  1. find 遍历数组,找到就返回该元素的地址&data[i]),否则返回 NULL。
  2. 调用者先判断 NULL != hit 再解引用——这是所有返回指针的函数的调用契约。
  3. hit - data 是同类型指针相减,得到”相隔几个元素“(2),不是字节数。
  4. 第二个代码块演示声明陷阱: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 定位来源; gdbp pbtwatch *p
  • int *A, B; 声明陷阱:以为 B 也是指针,B = &value; 报类型错误(32 位平台上更隐蔽)。 调试gdb -tui --args ./progptype Aptype B;或打印 (int) sizeof A(int) sizeof B(8 与 4 立刻暴露)。
  • scanf 忘记取地址scanf ("%d", value); 会把变量的值当地址写进去。调试-Wallformat '%d' expects argument of type 'int *';已崩溃时 gdbbt 看是否停在 scanf 内,p &valuep value 对比。
  • 修改字符串常量或返回局部变量地址char* s = "hi"; s[0] = 'H'; 段错误(只读段);return &local; 是”有时能跑”的悬垂指针。 调试gdbx/s sinfo proc mappings 确认只读映射;gcc -Wallfunction returns address of local variable;再用 -fsanitize=address -g 复核。
  • 混淆 NULNULL0,或 %p 实参不是 void*:字符串循环不结束,或地址打印错乱。 调试gdbx/16xb str 确认末尾是否真有 0 字节;统一写 printf ("%p", (void*) p); 并开启 -Wall -Werror

关键要点

  • 指针就是一个带类型的地址X* 从右往左读;类型只影响编译期两件事——解引用取几个字节、指针算术按几字节缩放,运行时的内存里没有类型,只有位。
  • 声明指针不等于创建对象int32_t* p; 只提供装地址的空间;被指向的对象必须另行声明、来自字符串常量、或由 malloc 分配。
  • C 是值传递:函数改不了调用者的变量本身,但可以改”调用者变量地址上的内容”; 要改调用者的指针变量就传 &pointer(形参 T**)——这正是 LC-3 LDI/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_balloc_a 的形参 p 是调用者指针值的副本:函数内让 p 指向新块, 调用者的指针毫无变化,而且那块内存立刻泄漏。alloc_b 收到的是”指针变量的地址”, *p = ... 写入的是调用者的指针变量本身。对应到 LC-3,就是被调用者并没有换掉调用者栈帧里那个指针槽, 而是写入到它所指向的槽STR Rd,Rbase,#0)。

问题 2char* cptr = "My favorite string"; 之后,*cptr&cptr**&cptr&*cptr 各是什么?哪个是编译错误?

答案*cptr'M'&cptrcptr 这个指针变量的地址(类型 char**);**&cptr 等价于 *cptr,即 'M'&*cptr 等价于 cptr 本身,类型 char*。真正报错的是 &&cptr&cptr 的结果是临时值,没有存储位置 (gcc:lvalue required as unary '&' operand)。

问题 3:幻灯片里 string_equalif (*s1 != *s2) { return 0; } 改成 if (*s1 != *s2) { *s1 = *s2 = '\0'; return 0; } 后,为什么 printf ("%s %s\n", w, x) 打印的仍然word1 word2

答案:其一,wx 是 main 的局部变量,s1s2 是被调用者的形参副本, 函数内对指针本身的任何修改都不影响 wx(只有 *s1 才可能影响调用者看到的内容); 其二,它们指向的是字符串常量(static、只读),*s1 = ... 是未定义行为,在把字面量放入只读段的平台上会被硬件拒绝。 另外,想知道两个指针是否指向同一段字符,不能写 s1 == s2,必须逐字符比较(strcmp 做的事)。