Lecture 10: 数组 (Arrays)
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 的地址) 实测:®ion[1] - ®ion[0] = 4 字节,®ion[4] - ®ion[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 字节。 - 底层机制图解:幻灯片的问题——若
region是0x12345000,region + 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 == ®ion[0] -> 1但有两个例外:
sizeof (region)得到 20(整块数组),®ion得到”指向整个数组的指针”。 - 作用域与存储期:数组名不是变量,没有自己的存储;它是编译器为那块存储起的名字。
- 直观解释:
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 ("®ion[0] = %p, ®ion[1] = %p\n",
(void*) ®ion[0], (void*) ®ion[1]);
printf ("®ion[1] - ®ion[0] = %lu bytes\n",
(unsigned long) ((unsigned long) (void*) ®ion[1] -
(unsigned long) (void*) ®ion[0]));
printf ("®ion[4] - ®ion[0] = %lu bytes\n",
(unsigned long) ((unsigned long) (void*) ®ion[4] -
(unsigned long) (void*) ®ion[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 == ®ion[0] -> %d\n", region == ®ion[0]);
p = region; /* same as p = ®ion[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;
}
实际输出:
®ion[0] = 0x7fff60c21670, ®ion[1] = 0x7fff60c21674
®ion[1] - ®ion[0] = 4 bytes
®ion[4] - ®ion[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 == ®ion[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
【代码做什么?】
®ion[1] - ®ion[0] = 4、®ion[4] - ®ion[0] = 16:元素连续,每个占 4 字节;region + 5则高 20 字节(不是 5)。region[2]、*(region + 2)、2[region]都得 30;*(region + 2) = 99与region[3] = 77的写入彼此等价。region == ®ion[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 = ®ion[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
【代码做什么?】
- 调用
min_value (my_nums, 4):传的是首地址与长度两个值(数组本身不复制)。 - 被调用者先假设
values[0] = 93最小,然后让check从 1 走到 3。 check=1:93 > 100?否,min仍为 93;check=2:93 > 79?是,min变成 79;check=3:79 > 42?是,min变成 42;循环结束后返回 42。- 调用者打印
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
【代码做什么?】
main里sizeof (data) = 24;同一数组传进show_parameter_size后sizeof (a) = 8——长度信息在函数边界上丢失了。sum_by_pointer (data, data + 6)用”末尾的下一个位置”作终止条件,累加得到 21。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]都打印0,neighbour仍为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直接拒绝编译; 已在运行的代码用gdb的ptype a确认参数类型是int32_t *,并把长度作为额外参数传入。 - 误以为
p + 1前进 1 字节:对int32_t*而言是 4 字节;现象是索引算错 4 倍。调试:gdb中p p、p p+1、p (char*)(p+1) - (char*)p三步核对,或临时改成(char*)做字节运算验收。 - 越界读写或解引用”一过末尾”的指针:
for (i = 0; i <= n; i++) a[i] = ...;多写一个元素;*end = 1;更直接。 调试:gcc -fsanitize=address -g立即报stack-buffer-overflow;valgrind --track-origins=yes ./prog报 invalid write;gdb中x/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;步长不同(下一讲详述)。 调试:gdb的ptype p、ptype 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 不检查边界,只允许”末尾的下一个位置”作为比较用的指针;越界读写是未定义行为,攻击者可借此覆盖返回地址, 防御手段是限制长度的输入函数与显式的范围检查。
思考题(带答案)
问题 1:int32_t region[20];,已知 region 的地址是 0x12345000。region + 5 的值是多少? 为什么幻灯片说”不一定是 0x12345005“?如果把数组类型换成 char region[20] 呢?
答案:region + 5 = 0x12345000 + 5 × sizeof (int32_t) = 0x12345000 + 20 = 0x12345014。 幻灯片强调”不一定是 0x12345005“,是因为指针算术按所指类型的大小缩放:步长取决于”一个元素占多少地址”。 换成 char region[20] 时 sizeof (char) = 1,region + 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] 连形成都已经是未定义行为。
