Lecture 10: 数组 (Arrays)

目录 · ← l9 · l11 →

Lecture 10: 数组 (Arrays)

概述

本讲讨论 C 中最基础的聚合机制——数组 (array):连续存储的同类型元素,用从 0 开始的下标 (index) 访问。 “连续布局”这一个事实直接决定了访问方式(地址 = 基址 + 下标 × 元素大小),而”数组名在表达式中退化为指向首元素的指针” 这条规则把数组与上一讲的指针缝成同一件事。 它同时是字符串(下一讲)、二维数组、动态数组与 MP4/MP6 的基础,也是本课”理解数组与指针在内存中的表示”这条教学目标的核心。

核心概念与底层机制图解

  • 数组 (Array):连续存储的同类型元素,下标从 0 开始
    • 直观解释:一排紧挨着的储物柜,每个柜子一样大,编号从 region[0] 开始; 第 N 个柜子的位置 = 第 0 个柜子的位置 + N × 柜子大小。
    • 底层机制图解int32_t region[5]5 * 4 = 20 字节,元素紧挨着排:
      int32_t region[5] = {10, 20, 30, 40, 50};
      地址(示意):  base+0   base+4   base+8   base+12  base+16
                   +--------+--------+--------+--------+--------+
      region:      \|   10   \|   20   \|   30   \|   40   \|   50   \|
                   +--------+--------+--------+--------+--------+
                    region[0] region[1] region[2] region[3] region[4]
                    ^
                    └─ 数组名 region 的值就是 base(元素 0 的地址)
      实测:&region[1] - &region[0] = 4 字节,&region[4] - &region[0] = 16 字节
      

      “连续”不是语法规定而是内存布局规定:正因为连续,才能用”加一个偏移量”算出任意元素的位置—— 这就是 LDR Rd, Rbase, #offset 这种”基址 + 偏移”寻址在硬件层面的对应。

    • 作用域与存储期:函数内声明的数组是 automatic,整块 20 字节随栈帧生灭; 加 static 则成为 static 存储期;malloc 得到的则是 allocated 存储期(本讲不涉及)。
  • 方括号是”加法 + 解引用”的语法糖region[N]*(region + N)同一个表达式
    • 直观解释:下标不是”第几个柜子”这种高级概念,只是”从首地址往前走 N 步,然后开门”的简写。
    • 底层机制图解
      region[N]   ≡   *(region + N)
      编译器的步骤:1) 把 region 当成 int32_t*;2) 加上 N * sizeof (int32_t) = 4N 字节;
                    3) 对结果解引用(一次 LDR/STR)
      推论一:既然加法可交换,2[region] 也合法且等于 region[2](实测输出 30)。
      推论二:region[N] 与 N[region] 都只是 *(region + N),写成前者才是人读得懂的代码。
      推论三:*(region + 2) = 99 与 region[2] = 99 改的是同样的 4 字节(实测两者一致)。
      
    • 作用域与存储期region[N] 本身只是一个表达式,不引入存储; 它读写的是数组对象(automatic/static/allocated 由数组自身的声明决定)。
  • 指针算术按元素大小缩放 (stride = sizeof (element))p + 1 是”下一个元素“,不是”下一个字节”。
    • 直观解释:步长由”柜子有多大”决定:int32_t 柜子 4 字节,char 柜子 1 字节,double 柜子 8 字节。
    • 底层机制图解:幻灯片的问题——若 region0x12345000region + 5 是不是 0x12345005不是:答案是”5 个 int 所需要的地址数”,即 0x12345000 + 20
      实测:region + 5 is 20 bytes above region      ← 5 * sizeof (int32_t) = 20
      对照:char*  c; c + 5 会前进 5 字节;double* d; d + 5 会前进 40 字节
      两种寻址方式的换算(LC-3 按"字"寻址,C 按"字节"寻址):
      C 里的 1 个 int32_t = 4 字节 = LC-3 的 2 个字
      

      这就是为什么参数必须带上类型:编译器要靠它把”第 N 个元素”翻译成”地址 + N × 4”。

    • 作用域与存储期:指针算术不改变任何对象的存储期,只是产生一个新的地址值; 但这个新地址必须落在同一个数组对象内(或末尾的下一个位置),否则行为未定义。
  • 数组名是元素 0 的地址,在表达式中”退化”为指针
    • 直观解释region 这个”名字”在大多数场合等价于”首柜子的地址”,而不再是”一整排柜子”。
    • 底层机制图解:C Programming Reference 给出的派生类型规则:数组参数会被编译器立即转换成指针:
      void foo (int x[4]);   立刻被编译器改写成:   void foo (int* x);
      实测:region == &region[0] -> 1
      

      但有两个例外:sizeof (region) 得到 20(整块数组),&region 得到”指向整个数组的指针”。

    • 作用域与存储期:数组名不是变量,没有自己的存储;它是编译器为那块存储起的名字。
  • sizeof (array)sizeof (pointer) 是两件完全不同的事
    • 直观解释sizeof 问的是”这个名字代表的对象有多大”;数组是 20 字节的柜子排,指针只是一张纸条(8 字节)。
    • 底层机制图解
      实测:sizeof (region) = 20      sizeof (region[0]) = 4
            sizeof (p) = 8            (p 是 int32_t*)
            number of elements = sizeof (region) / sizeof (region[0]) = 5
      

      这条规则可以反过来当工具用sizeof (arr) / sizeof (arr[0]) 是”在数组自己的作用域内”求元素个数的标准写法; 而 sizeof 是编译期运算(对定长数组),不产生任何运行时代码。

  • 数组作为参数传递:传的是地址,不是副本;函数无法从参数恢复长度
    • 直观解释:把整排柜子搬到另一个房间(复制)太贵;C 只把”首柜子的地址”告诉被调用者—— 代价是对方不知道这排柜子有几个
    • 底层机制图解:幻灯片的示范函数签名 int32_t min_value (int32_t const values[]);int32_t const* values 完全等价,它只有一个”地址”,因此必须再传一个长度参数
      调用者栈帧                被调用者(min_value)栈帧
      +------------------+      +---------------------------+
      \| my_nums[0..3]    |◄─────| values (1 个地址,8 字节)|
      | 93 100 79 42     |      +---------------------------+
      +------------------+      \| n_values = 4              \|
      整块 16 字节留在调用者栈帧;被调用者只拿到一个地址 → 数组不会被复制,因此被调用者通过 a[i] 的写入会直接改变调用者的数据。
      

      幻灯片特别指出:values(类型 int32_t const*)在栈帧里只占一个内存位置

    • 作用域与存储期:形参是 automatic 的指针,被指向的数组属于调用者; const 只是编译期承诺(”我只读”),不会改变存储期。
  • C 不检查数组边界 (no bounds checking);同时允许”末尾的下一个位置”这个指针存在
    • 直观解释:机器只做”地址 + 偏移”,它不知道柜子排到哪里结束;越界读到的就是邻居的东西。 而”末尾下一个位置”像”停车场最后一个车位之后的那个点”——可以用来比较,但不能停车(不能解引用)。
    • 底层机制图解:两种规则的边界:
      合法: int32_t* end = data + 6;   /* 一过末尾 (one past the end):只比较,不解引用 */
      非法: *end = 1;  data[6] = 1;  data[-1] = 1;   /* 全部是 UB */
      

      幻灯片关于”边界不检查”的经典例子是 char name[20]; scanf ("%s", name);:输入超过 19 个字符会顺着栈帧 覆盖返回地址(缓冲区溢出攻击);scanf ("%19s", name) 能限制长度,但幻灯片提醒”依赖人来维护的防护措施本身容易出错”。

    • 作用域与存储期:越界访问不会创建新对象,它只是访问了别的对象的内存; 那些对象有自己的存储期,被破坏后症状往往在别处才出现。
  • 用指针遍历数组 (walking with a pointer)for (p = a; p != a + n; p++) 是最地道的写法。
    • 直观解释:与其每次用”下标 × 元素大小”重新算地址,不如让游标自己往前走。
    • 底层机制图解
      下标写法: for (i = 0; i < n; i++) { total += a[i]; }
                每次迭代都要算 base + i*4(编译器通常会优化成自增)
      指针写法: for (p = a; p != a + n; p++) { total += *p; }
                p++ 直接加 4,比较用 != 而不是 <,语义是"走到末尾就停"
      实测(12_array_parameters.c): sum_by_pointer (data, data + 6) = 21
      

      两种写法在汇编层面几乎一样:取数、累加、指针加、比较、分支。

    • 作用域与存储期:游标 p 是 automatic;遍历不改变元素的存储期,但可以对它们赋值。
  • LC-3 视角:数组访问就是”基址 + 偏移”寻址
    • 直观解释LEA 算出首地址,LDR/STR 用一个小偏移量读写——这就是”数组”在机器层的全部内容。
    • 底层机制图解:指令编码限制了”一步走多远”:
      LEA  Rd, LABEL      ; PCoffset9:±256 个字,只能指向汇编期已知的标号
      LDR  Rd, Rbase, #o  ; offset6:−32..31,超范围就必须先 ADD 改基址
      运行期下标 i:先 ADD R3,R1,R2 算出地址,再 LDR R0,R3,#0
      编译期常量下标 i ≤ 31:可以直接 LDR R0,R1,#i
      
    • 作用域与存储期:数组通常放在全局数据区(LEA 取址)或栈帧里(R5 + 偏移 取址); 局部数组随栈帧回收,全局数组从程序开始存在到结束。

代码示例与底层机制分析

示例 1:连续性、方括号语法糖与 sizeof 的区别

代码 (C)/tmp/ece220_arr/10_array_basics.c,用 gcc -g -std=c99 -Wall -Werror 10_array_basics.c -o 10_array_basics 实测):

#include <stdint.h>
#include <stdio.h>

int
main (void)
{
    int32_t  region[5] = {10, 20, 30, 40, 50};
    int32_t* p;

    /* 1. The elements are contiguous: each one is 4 bytes above the last. */
    printf ("&region[0] = %p, &region[1] = %p\n",
            (void*) &region[0], (void*) &region[1]);
    printf ("&region[1] - &region[0] = %lu bytes\n",
            (unsigned long) ((unsigned long) (void*) &region[1] -
                             (unsigned long) (void*) &region[0]));
    printf ("&region[4] - &region[0] = %lu bytes\n",
            (unsigned long) ((unsigned long) (void*) &region[4] -
                             (unsigned long) (void*) &region[0]));

    /* 2. Pointer arithmetic multiplies by sizeof (int32_t). */
    printf ("region + 5 is %lu bytes above region\n",
            (unsigned long) ((unsigned long) (void*) (region + 5) -
                             (unsigned long) (void*) region));

    /* 3. region[N] and *(region + N) are the same expression. */
    printf ("region[2] = %d, *(region + 2) = %d, 2[region] = %d\n",
            region[2], *(region + 2), 2[region]);
    printf ("region[2] == *(region + 2) -> %d\n",
            region[2] == *(region + 2));

    /* 4. Writing through either form changes the same 4 bytes. */
    *(region + 2) = 99;
    printf ("after *(region + 2) = 99: region[2] = %d\n", region[2]);
    region[3] = 77;
    printf ("after region[3] = 77: *(region + 3) = %d\n", *(region + 3));

    /* 5. An array name decays to a pointer to element zero... */
    printf ("region == &region[0] -> %d\n", region == &region[0]);
    p = region;                       /* same as p = &region[0];        */
    printf ("*p = %d, p[4] = %d\n", *p, p[4]);

    /* 6. ...but sizeof still sees the ARRAY, not a pointer. */
    printf ("sizeof (region) = %d, sizeof (region[0]) = %d\n",
            (int) sizeof region, (int) sizeof region[0]);
    printf ("sizeof (p) = %d (a pointer is one address, not 5 elements)\n",
            (int) sizeof p);
    printf ("number of elements = %d\n",
            (int) (sizeof region / sizeof region[0]));

    return 0;
}

实际输出

&region[0] = 0x7fff60c21670, &region[1] = 0x7fff60c21674
&region[1] - &region[0] = 4 bytes
&region[4] - &region[0] = 16 bytes
region + 5 is 20 bytes above region
region[2] = 30, *(region + 2) = 30, 2[region] = 30
region[2] == *(region + 2) -> 1
after *(region + 2) = 99: region[2] = 99
after region[3] = 77: *(region + 3) = 77
region == &region[0] -> 1
*p = 10, p[4] = 50
sizeof (region) = 20, sizeof (region[0]) = 4
sizeof (p) = 8 (a pointer is one address, not 5 elements)
number of elements = 5

【代码做什么?】

  1. &region[1] - &region[0] = 4&region[4] - &region[0] = 16:元素连续,每个占 4 字节;region + 5 则高 20 字节(不是 5)。
  2. region[2]*(region + 2)2[region] 都得 30;*(region + 2) = 99region[3] = 77 的写入彼此等价。
  3. region == &region[0] 为 1(数组名退化为首元素地址),而 sizeof (region) 仍是 20、sizeof (p) 是 8。

【底层机制透视】 第 1 步里 region + 5 是全讲最容易被直觉误导的一行:它不是”地址 + 5”,而是”地址 + 5 × sizeof (int32_t)” (幻灯片里 0x12345000 + 5 的错误直觉正是指这个)。第 2、3 步展示了”退化”与”不退化”的分界: 数组名在绝大多数表达式里是 int32_t*,但 sizeof& 看到的是数组类型本身。 sizeof region / sizeof region[0] 是本课推荐的长度计算法,但它只在数组自己的作用域内有效(见示例 3)。

【内存布局图解】

region(main 的栈帧,20 字节连续)
base+0      base+4      base+8      base+12     base+16     base+20
+-----------+-----------+-----------+-----------+-----------+
|    10     |    20     |    99     |    77     |    50     |  ← 被步骤 4/5 改写
+-----------+-----------+-----------+-----------+-----------+
  region[0]   region[1]   region[2]   region[3]   region[4]   ← region+5 指向这里(末尾之后)
  ^ region = &region[0] = base(实测 0x7fff60c21670);region + 2 = base + 8
p(另 8 字节)→ base;p[4] 读的就是 region[4] = 50;region + 5 指向末尾之后(20 字节处)

【与汇编的对应】(LC-3:LEA 取基址、LDR/STR 带偏移访问——”基址 + 偏移”就是数组)

; int32_t region[5] 放在全局数据区(用 LEA 取址),元素按字排列
        LEA  R1,REGION         ; R1 = region 的首地址(标号汇编期已知 → LEA)
        LDR  R2,R1,#0          ; R2 = region[0] = 10   ← 常量下标直接用 offset6
        LDR  R3,R1,#2          ; R3 = region[2] = 30
        ; R4 ← 99(由若干 ADD 构造)
        STR  R4,R1,#2          ; region[2] = 99        ← 与 *(region + 2) = 99 等价
        LDR  R5,R1,#3          ; R5 = region[3] = 77

; 运行期下标 i(放在 R2):必须"先算地址、再访问"
        ADD  R3,R1,R2          ; R3 = region + i(LC-3 上每个 int 1 个字)
        LDR  R4,R3,#0          ; R4 = region[i]
; 注意 offset6 只有 −32..31:i 可能更大,所以不能写 LDR R4,R1,#i

REGION  .FILL #10
        .FILL #20
        .FILL #30
        .FILL #40
        .FILL #50

示例 2:课堂手算示例——求数组最小值

代码 (C)/tmp/ece220_arr/11_min_value.c;这就是幻灯片上的 min_value,手算数据也用幻灯片里的 {93, 100, 79, 42}):

#include <stdint.h>
#include <stdio.h>

/*
 * min_value -- return the smallest element of an array of int32_t.
 * INPUTS: values -- pointer to the first element (an address alone does
 *                   NOT define a length, so the caller must pass one)
 *         n_values -- number of elements in the array
 * OUTPUTS: none
 * RETURN VALUE: the smallest element (undefined if n_values < 1)
 * SIDE EFFECTS: none
 */
static int32_t
min_value (int32_t const values[], int32_t n_values)
{
    int32_t min = values[0];        /* assume the first value is smallest */
    int32_t check;

    for (check = 1; n_values > check; check++) {
        if (min > values[check]) {  /* found something smaller           */
            min = values[check];
        }
    }
    return min;
}

int
main (void)
{
    int32_t my_nums[4] = {93, 100, 79, 42};
    int32_t least;

    least = min_value (my_nums, 4);

    printf ("my_nums = {%d, %d, %d, %d}\n",
            my_nums[0], my_nums[1], my_nums[2], my_nums[3]);
    printf ("min_value (my_nums, 4) = %d\n", least);

    /* The parameter was an address, not a copy of the data. */
    printf ("inside main, sizeof (my_nums) = %d\n", (int) sizeof my_nums);
    printf ("my_nums == &my_nums[0] -> %d\n", my_nums == &my_nums[0]);

    return 0;
}

实际输出

my_nums = {93, 100, 79, 42}
min_value (my_nums, 4) = 42
inside main, sizeof (my_nums) = 16
my_nums == &my_nums[0] -> 1

【代码做什么?】

  1. 调用 min_value (my_nums, 4):传的是首地址长度两个值(数组本身不复制)。
  2. 被调用者先假设 values[0] = 93 最小,然后让 check 从 1 走到 3。
  3. check=193 > 100?否,min 仍为 93;check=293 > 79?是,min 变成 79; check=379 > 42?是,min 变成 42;循环结束后返回 42
  4. 调用者打印 sizeof (my_nums) = 16(4 个 int32_t),并验证 my_nums == &my_nums[0]

【底层机制透视】 形参写成 int32_t const values[] 与写成 int32_t const* values 完全等价: C 的派生类型规则规定,”数组类型”作为函数参数时会被编译器立即改写为”指向元素的指针”。 所以 sizeof (values) 在函数内部只能得到 8(指针大小),函数无法从参数恢复数组长度—— 幻灯片因此把”再加一个 n_values 参数”作为唯一可行的解法。 const 是对调用者的承诺:”这个函数只读数组”,它也让编译器在函数体里拒绝 values[i] = ...。 注意 min = values[0]n_values < 1 时是越界读,所以文档必须写明这一前提——C 的接口约定要写全。

【内存布局图解】

调用者(main)栈帧                     被调用者(min_value)栈帧
高地址 +--------------------+         高地址 +--------------------------+
       | 返回地址 (R7)      |                | 返回地址 (R7)            |
       +--------------------+                +--------------------------+
       | 返回值(42)       |                | 帧指针(旧 R5)          |
       +--------------------+                +--------------------------+
       | my_nums[0] = 93    |◄───────┐       | n_values = 4             |
       | my_nums[1] = 100   |        │       +--------------------------+
       | my_nums[2] = 79    |        │       | values = &my_nums[0] ────┼──┐
       | my_nums[3] = 42    |        │       +--------------------------+  │
低地址 +--------------------+        └───────| min = 42;check = 4      |──┘
       | least(= 42)      |   数组的 16 字节留在调用者栈帧;values 只是 1 个地址
       +--------------------+

【与汇编的对应】(LC-3:min_value 的循环)

; 约定:R0 = 数组地址(values),R1 = 元素个数(n_values),返回值放在 R0
MINVAL  ; 入口先建立自己的栈帧(保存旧 R5、R7 并取局部空间)
        ADD  R6,R6,#-1
        STR  R5,R6,#0          ; 保存调用者的帧指针
        ADD  R5,R6,#0          ; R5 = 本帧基址
        ADD  R6,R6,#-3
        STR  R0,R5,#-2         ; values(只占 1 个地址!)
        STR  R1,R5,#-3         ; n_values
        LDR  R2,R0,#0          ; R2 = min = values[0]
        AND  R3,R3,#0
        ADD  R3,R3,#1          ; R3 = check = 1
MLOOP   NOT  R4,R1             ; 比较 check 与 n_values:check >= n_values 就结束
        ADD  R4,R4,#1
        ADD  R4,R4,R3          ; R4 = check - n_values
        BRzp MDONE
        ADD  R4,R0,R3          ; R4 = &values[check]  ← 基址 + 下标
        LDR  R4,R4,#0          ; R4 = values[check]
        NOT  R5,R4
        ADD  R5,R5,#1
        ADD  R5,R5,R2          ; R5 = min - values[check]
        BRnz MNEXT             ; min <= values[check] → 不更新
        ADD  R2,R4,#0          ; min = values[check]
MNEXT   ADD  R3,R3,#1          ; check++
        BRnzp MLOOP
MDONE   ADD  R0,R2,#0          ; 返回值 = min;随后恢复 R5/R7 并退回 R6(略)
        RET

示例 3:把数组传给函数、以及”一过末尾”的遍历

代码 (C)/tmp/ece220_arr/12_array_parameters.c):

#include <stdint.h>
#include <stdio.h>

/* Declaring the parameter as "int32_t a[]" would be EXACTLY equivalent to
 * "int32_t* a": the compiler rewrites an array parameter into a pointer to
 * its first element.  We write the pointer form here because gcc's -Wall
 * refuses to let us sizeof an array parameter (it would return the size of
 * a pointer, which is a bug in almost every case).                       */
static void
show_parameter_size (int32_t* a)
{
    printf ("inside callee: sizeof (a) = %d (a pointer, not an array)\n",
            (int) sizeof a);
}

/* One-past-the-end walking: we never form a pointer past a + n, and we
 * never dereference a + n.  This is the standard C loop idiom.          */
static int32_t
sum_by_pointer (int32_t const* begin, int32_t const* end)
{
    int32_t const* cursor;
    int32_t        total = 0;

    for (cursor = begin; cursor != end; cursor++) {
        total += *cursor;
    }
    return total;
}

/* Doubling every element: the callee writes THROUGH the pointer, so the
 * caller sees the change.  No copying of the array takes place.         */
static void
double_all (int32_t* a, int32_t n)
{
    int32_t i;

    for (i = 0; i < n; i++) {
        a[i] = 2 * a[i];
    }
}

int
main (void)
{
    int32_t data[6] = {1, 2, 3, 4, 5, 6};
    int32_t i;

    printf ("sizeof (data) in main = %d, elements = %d\n",
            (int) sizeof data, (int) (sizeof data / sizeof data[0]));
    show_parameter_size (data);

    printf ("sum = %d\n", sum_by_pointer (data, data + 6));

    double_all (data, 6);
    printf ("after double_all:");
    for (i = 0; i < 6; i++) {
        printf (" %d", data[i]);
    }
    printf ("\n");

    /* Passing data + 2 gives the callee a "shorter array". */
    double_all (data + 2, 4);
    printf ("after double_all (data + 2, 4):");
    for (i = 0; i < 6; i++) {
        printf (" %d", data[i]);
    }
    printf ("\n");

    return 0;
}

实际输出

sizeof (data) in main = 24, elements = 6
inside callee: sizeof (a) = 8 (a pointer, not an array)
sum = 21
after double_all: 2 4 6 8 10 12
after double_all (data + 2, 4): 2 4 12 16 20 24

【代码做什么?】

  1. mainsizeof (data) = 24;同一数组传进 show_parameter_sizesizeof (a) = 8——长度信息在函数边界上丢失了
  2. sum_by_pointer (data, data + 6) 用”末尾的下一个位置”作终止条件,累加得到 21。
  3. double_all (data, 6)2 4 6 8 10 12(改的是调用者的数组);double_all (data + 2, 4) 只看后 4 个元素,得 2 4 12 16 20 24

【底层机制透视】 第 1 步是本讲最重要的教训:数组参数是指针,所以 sizeof 在函数内部只能看到 8 字节; “函数无法恢复数组长度”不是编译器的缺陷,而是”只传地址”这一设计的必然结果——必须由调用者显式传长度 (或用一个哨兵值,如字符串的 '\0')。 第 3 步的 cursor != end 使用 != 而不是 <,是因为 C 标准只保证”同一数组内的指针可以比较”, 用 != 表达的语义最清楚:走到末尾就停。第 5 步说明”数组的起点”只是一种约定: data + 2 让被调用者看到的是长度 4 的另一段内存,这既是 C 的灵活性,也是它容易出错的地方。

【内存布局图解】

main 的栈帧(data 24 字节)
base+0   base+4   base+8   base+12  base+16  base+20  base+24
+--------+--------+--------+--------+--------+--------+
|   1    |   2    |   3    |   4    |   5    |   6    |   ← 初始值
+--------+--------+--------+--------+--------+--------+
  ^ begin = data                                ^ end = data + 6(一过末尾,不可解引用)
  │  第一次 double_all(data, 6) 之后:2 4 6 8 10 12
  └─ 第二次 double_all(data + 2, 4):只碰 base+8 起的 4 个元素 → 2 4 12 16 20 24
     begin/end 只是两个地址,被调用者不知道这块内存总共有多大

【与汇编的对应】(LC-3:传入的是地址与长度,循环用”指针 != 末尾”)

; ---- 调用者:sum_by_pointer (data, data + 6) ----
        LEA  R0,DATA           ; R0 = begin = data(数组在全局数据区)
        ADD  R1,R0,#6          ; R1 = end   = data + 6(每个 int 占 1 个字)
        JSR  SUMBYPTR          ; 返回值(21)回到 R0

; ---- 被调用者:按指针遍历,直到游标等于 end ----
SUMBYPTR AND R2,R2,#0          ; total = 0
        ADD  R3,R0,#0          ; cursor = begin
SUMLOOP NOT  R4,R1
        ADD  R4,R4,#1
        ADD  R4,R3,R4          ; R4 = cursor - end
        BRz  SUMDONE           ; cursor == end → 结束(只比较,不解引用 end)
        LDR  R5,R3,#0          ; R5 = *cursor
        ADD  R2,R2,R5          ; total += *cursor
        ADD  R3,R3,#1          ; cursor++
        BRnzp SUMLOOP
SUMDONE ADD R0,R2,#0           ; 返回 total
        RET
DATA    .FILL #1
        .FILL #2
        .FILL #3
        .FILL #4
        .FILL #5
        .FILL #6
; begin/end 是两个地址(经 R0–R3 传参);"指针 != 末尾"就是一次减法看 Z 条件码。

演示(仅供演示、请勿模仿):数组越界 C 不检查下标,越界读写会碰到相邻对象:

int32_t region[5] = {10, 20, 30, 40, 50};
int32_t neighbour = 0x5A5A;
printf ("%d\n", region[5]);   /* UB:读一过末尾 */
printf ("%d\n", region[-1]);  /* UB:读下界之外 */
region[5] = 0;                /* UB:写一过末尾,可能覆盖 neighbour,也可能覆盖别的 */

实测(gcc 12.2.0,x86-64 Linux,未加 -Werror):本次运行 region[5]region[-1] 都打印 0neighbour 仍为 0x5A5A——这只是本次编译的巧合:那些位置是填充字节,换个编译选项就会不同;把越界写成循环时, 被写坏的甚至可能是循环计数器本身(本笔记的早期验证版本就因此陷入死循环)。UB 的结果随编译器与平台而异。 作为对照,课程的编译命令能拦住”用 sizeof 求数组参数长度”:实测 gcc 报 error: 'sizeof' on array function parameter 'a' will return size of 'int32_t *' [-Werror=sizeof-array-argument]

常见错误与调试技巧

  • sizeof 用在数组参数上求长度:函数内 sizeof (a) / sizeof (a[0]) 得到 2(8/4)而不是真实元素个数。 调试gcc -g -std=c99 -Wall -Werror 会以 -Werror=sizeof-array-argument 直接拒绝编译; 已在运行的代码用 gdbptype a 确认参数类型是 int32_t *,并把长度作为额外参数传入。
  • 误以为 p + 1 前进 1 字节:对 int32_t* 而言是 4 字节;现象是索引算错 4 倍。调试gdbp pp p+1p (char*)(p+1) - (char*)p 三步核对,或临时改成 (char*) 做字节运算验收。
  • 越界读写或解引用”一过末尾”的指针for (i = 0; i <= n; i++) a[i] = ...; 多写一个元素;*end = 1; 更直接。 调试gcc -fsanitize=address -g 立即报 stack-buffer-overflowvalgrind --track-origins=yes ./prog 报 invalid write;gdbx/8dw a 看相邻变量、watch *end 看是否被写。
  • 数组名赋值a = b;(a 是数组)报 assignment to expression with array type调试:复制内容用 memcpy (a, b, sizeof a) 或逐元素赋值;要”改指向”就改用指针变量。
  • int*int (*)[N] 混用int (*p)[4] = m;int* q = m; 步长不同(下一讲详述)。 调试gdbptype pptype q,或打印 (int) sizeof *p(16 与 4 的差别立刻暴露)。

关键要点

  • 数组 = 连续存储 + 从 0 开始的下标:正因为连续,”地址 = 基址 + 下标 × sizeof (元素)“才成立,这也正是 LC-3 里 LDR/STR 的”基址 + 偏移”寻址形式。
  • 方括号只是加法与解引用的语法糖region[N] ≡ *(region + N)(所以 2[region] 也合法);指针算术的步长是 sizeof (元素)int32_t* p; p + 5 前进 20 字节
  • 数组名退化为指针,但 sizeof& 例外sizeof (arr) 给出整块大小,是在数组作用域内求元素个数的唯一可靠写法。
  • 数组参数就是指针void f (int a[]) 被编译器改写为 void f (int* a),函数无法从参数恢复长度,必须另传长度参数(或用哨兵值)。
  • C 不检查边界,只允许”末尾的下一个位置”作为比较用的指针;越界读写是未定义行为,攻击者可借此覆盖返回地址, 防御手段是限制长度的输入函数与显式的范围检查。

思考题(带答案)

问题 1int32_t region[20];,已知 region 的地址是 0x12345000region + 5 的值是多少? 为什么幻灯片说”不一定是 0x12345005“?如果把数组类型换成 char region[20] 呢?

答案region + 5 = 0x12345000 + 5 × sizeof (int32_t) = 0x12345000 + 20 = 0x12345014。 幻灯片强调”不一定是 0x12345005“,是因为指针算术按所指类型的大小缩放:步长取决于”一个元素占多少地址”。 换成 char region[20]sizeof (char) = 1region + 5 就真的是 0x12345005。 本笔记的实测输出直接验证了这一点:region + 5 is 20 bytes above region

问题 2:下面的函数想返回数组长度,但它错了。错在哪里?给出两种修法。

static int32_t count (int32_t a[])
{
    return (int32_t) (sizeof a / sizeof a[0]);
}

答案a 虽然写成 int32_t a[],但作为函数参数它被编译器立即改写为 int32_t* a, 所以 sizeof a 是 8(指针)而不是整块数组的大小,8 / 4 = 2 是垃圾结果;加 -Wall -Werror 时 gcc 直接以 -Werror=sizeof-array-argument 拒绝编译。修法一:把长度作为参数显式传入(幻灯片的 min_value 就是这样)。 修法二:传”首尾指针”(end - begin 得到元素个数,但两个指针必须指向同一个数组)。 在数组自己的作用域内(例如 main 里),sizeof (a) / sizeof (a[0]) 仍然是正确且推荐的写法。

问题 3:为什么允许 int32_t* end = data + n;(一过末尾)存在,却不允许 *end = 0;

答案:C 标准允许指针指向”数组最后一个元素之后的那一个位置”,因为这是循环终止条件的自然写法: for (p = data; p != end; p++) 需要一个”刚好在末尾”的地址来比较;若连这个地址都不能形成, 遍历就只能改用计数变量。但”允许形成这个地址”不等于”允许访问那里的对象”——那里没有对象*end = 0 会写到相邻对象(或填充字节)上,是未定义行为,可能悄无声息地破坏别的变量,也可能立刻崩溃。 一句话:一过末尾的指针可以比较、不可以解引用;而 data[-1] 连形成都已经是未定义行为。