Lecture 11: 字符串与多维数组 (Strings; Multi-Dimensional Arrays)
Lecture 11: 字符串与多维数组 (Strings; Multi-Dimensional Arrays)
概述
本讲解决两组彼此关联的问题:C 如何表示文本(以 NUL 结尾的 char 数组),以及 C 如何表示表格(”数组的数组”)。 字符串部分要讲清 char s[] 与 char *s 这个一字之差、语义完全不同的区分,以及不检查边界的字符串函数 如何导致缓冲区溢出 (buffer overflow);多维数组部分要讲清行主序 (row-major) 布局与地址公式, 以及 int m[3][4]、int *m[3]、int (*m)[4] 三种写法的内存含义。 二者都建立在上一讲的”数组名即地址、指针算术按元素大小缩放”之上,又是课程 MP2(课表)、MP6(方块棋盘)的直接工具。
核心概念与底层机制图解
- C 字符串 (C string) 是以 NUL 结尾的
char数组:字符串的”值”就是起始地址,长度靠扫描到'\0'才知道。- 直观解释:像一列没有编号的储物柜,末尾放一面红旗;要数有多少格,只能一直走到红旗。
- 底层机制图解:
地址 0x402008 0x402009 0x40200A 0x40200B 0x40200C 内容 'H' 'i' '!' '\0' ??(无关字节) strlen = 3(不含 NUL),sizeof ("Hi!") = 4(含 NUL)strlen的机器实现就是”从首地址开始LDR,为 0 就停,否则地址加一”。 - 作用域与存储期:字符串本体没有类型,存储期取决于来源:字面量 static、
char buf[32]automatic、malloc来的 allocated。”字符串”这个概念 = 一个char*+ “以 0 结尾”这个契约。
- 字符串字面量 (string literal) 在静态存储区,且不可修改:编译期放入全局数据区(通常只读),相同字面量可能被合并。
- 直观解释:标语牌是印刷好的;
char* p = "hi";只是拿了一张写着地址的便条,便条可换,标语牌不能涂。 - 底层机制图解:
sizeof ("hello")为 6,说明”字面量本身是一个 6 字节的数组”;出现在表达式里时退化为char*。 写入字面量是未定义行为,在把字面量放进只读段的平台上直接段错误。 - 作用域与存储期:static storage duration,程序整个运行期都在;不要释放也不能释放。
- 直观解释:标语牌是印刷好的;
char s[]与char *s:一个分配数组,一个只分配指针:- 直观解释:
char s[] = "hi";是”照着标语牌抄一份自己的副本”;char *s = "hi";是”只记下标语牌的位置”。 - 底层机制图解:
char a[] = "hello"; char* p = "hello"; 栈(automatic,可写,6 字节) 栈(automatic,8 字节) +---+---+---+---+---+---+ +------------------+ \|'h'\|'e'\|'l'\|'l'\|'o'\|\0 \| \| p = 0x402008 \| +---+---+---+---+---+---+ +------------------+ sizeof (a) = 6 \| sizeof (p) = 8 v 全局数据区(只读) +---+---+---+---+---+----+ \|'h'\|'e'\|'l'\|'l'\|'o'\|\0 \| +---+---+---+---+---+----+ a[0] = 'H'; 合法(改自己的副本) p[0] = 'H'; 未定义行为(改只读数据) a = "bye"; 非法(数组名不可赋值) p = "bye"; 合法(只改这 8 字节) - 作用域与存储期:
a与p都是 automatic,但p指向的数组是 static; 因此char* f (void) { char buf[8] = "hi"; return buf; }是悬垂指针,而return "hi";完全正确。 实测(/tmp/ece220_str/21t_char_array_vs_pointer.c,课程编译命令):sizeof (array_form) = 6、sizeof (pointer_form) = 8、sizeof ("hello") = 6;用双指针原地反转array_form后得到"olleh", 而pointer_form指向的字面量仍然是"hello"(一个字面量、一份数组,两处互不影响)。
- 直观解释:
转义序列 (escape sequences):字符串与字符字面量里,反斜杠引导”无法直接键入”的字符。
转义 含义 转义 含义 \0NUL(字符串结束符) \\反斜杠本身 \n换行 newline \'单引号 \t水平制表符 tab \"双引号 \r回车 carriage return \?问号 \a响铃 alert \ooo1–3 位八进制( \101='A')\f换页 form feed \xhh1–2 位十六进制( \x41='A')- 直观解释:反斜杠是”下一个字符按编码解释、不要按字面理解”的开关。
"a\0b"在内存里是 4 个字节'a' 0 'b' 0——中间的 NUL 让字符串提前结束,strlen只看到 1。 - 作用域与存储期:转义只是书写形式,不改变存储期;
'\0'是值为 0 的int字符常量。
- 直观解释:反斜杠是”下一个字符按编码解释、不要按字面理解”的开关。
- 字符串库 (string library):
<string.h>的函数都以 NUL 为界,都不检查目标缓冲区大小。strlen (s)返回字符数(不含 NUL);strcpy (dst, src)连 NUL 一起复制;strcat (dst, src)从dst的 NUL 处追加;strcmp (s1, s2)逐字符比较,返回负/0/正(比的是字符编码,不是字典序);strncpy (dst, src, n)最多复制 n 字节,源太长时不补 NUL;strchr (s, c)返回第一次出现的指针或 NULL。- 直观解释:这些函数像”搬箱子工人”:你说搬多少就搬多少,它不知道你的仓库有多大。
- 底层机制图解:
strcpy的机器模型就是”读一字节、写一字节、见 0 才停”:LOOP LDR R2,R1,#0 ; R2 ← *src STR R2,R3,#0 ; *dst ← R2 BRz DONE ; 连 NUL 一起复制完,循环结束 ADD R1,R1,#1 ADD R3,R3,#1 BRnzp LOOP幻灯片的缓冲区溢出攻击正是利用这一点:输入超出数组长度时,多余字节会覆盖保存的返回地址。
- 作用域与存储期:这些函数不分配内存;目标缓冲区由调用者提供,其大小是调用者的责任 (
scanf ("%19s", name)用字段宽度把这个责任”部分”交给格式串)。
- 二维数组是”数组的数组”,按行主序 (row-major) 存放:
int32_t m[3][4]是”3 个元素、每个元素是 4 个int32_t“。- 直观解释:像 3 排 4 列的储物柜,物理上是一长排;先排完第 0 行再排第 1 行。
- 底层机制图解:地址公式
&m[i][j] = base + (i * numCols + j) * sizeof (int32_t):int32_t m[3][4]; 行主序展开(一行接一行) m[0][0] m[0][1] m[0][2] m[0][3] base+0 base+4 base+8 base+12 m[1][0] m[1][1] m[1][2] m[1][3] base+16 base+20 base+24 base+28 m[2][0] m[2][1] m[2][2] m[2][3] base+32 base+36 base+40 base+44 行跨距 (row stride) = numCols * 4 = 16 字节;总大小 = 3*4*4 = 48 字节m[i]本身是一个数组(类型int32_t[4]),在表达式里退化为int32_t*; 而m退化为”指向一整行的指针”,类型int32_t (*)[4]——所以m + 1前进 16 字节,m[0] + 1前进 4 字节。 - 作用域与存储期:48 字节是一个 automatic 对象,随栈帧生灭;行与行之间没有独立的生命周期。
- 三种”表格”写法:
int32_t m[3][4]、int32_t* ap[3]、int32_t (*pa)[4]。- 直观解释:第一种是”一整排连着的仓库”;第二种是”3 张写着仓库地址的便条”; 第三种是”一张便条,并写明它指向一整排 4 格”。
- 底层机制图解:
int32_t m[3][4] int32_t* ap[3] int32_t (*pa)[4] (48 字节,连续) (24 字节的指针数组) (8 字节的一个指针) +----+----+----+----+ +----------+ +-----------+ \|行0 \|行1 \|行2 \| \| \| ap[0] ───┼──→ r0[4] \| pa ───────┼──→ m[0][0] +----+----+----+----+ \| ap[1] ───┼──→ r1[4] +-----------+ sizeof (m) = 48 \| ap[2] ───┼──→ r2[4] sizeof (pa) = 8 +----------+ pa + 1 前进 16 字节 sizeof (ap) = 24 ap + 1 前进 8 字节(一个指针) 各行可以不相邻(jagged / ragged)三者都能写
x[i][j],但机器代价不同:m[i][j]是”乘加 + 一次访存”(列数是编译期常量),ap[i][j]是”先取指针再取元素”,多一次指针追逐 (pointer chase)。 - 作用域与存储期:
m的各行是同一对象的组成部分;ap指向的各行可以是独立对象(各有各的存储期);pa只是一个指针,指向谁就属于谁的存储期。
- 字符串数组
char* names[]:这是”指针数组”,每个元素指向一个字符串,字符本体在静态存储区。- 直观解释:像通讯录,每页只写”名字在第几号柜子”。
- 底层机制图解:
char* names[4] = {"Ada", "Grace", "Linus", "Ken"}; names(栈上 32 字节) 静态数据区(只读,长度各不相同) +-----------+ "Ada\0" "Grace\0" "Linus\0" "Ken\0" \| names[0] ─┼───────────────→ | names[1] ─┼─────────────────────────→ | names[2] ─┼──────────────────────────────────→ | names[3] ─┼───────────────────────────────────────────→ +-----------+ sizeof (names) = 32, sizeof (names[0]) = 8 交换两个名字只需交换两个指针(16 字节),一个字符都不用搬 - 作用域与存储期:指针数组常是 automatic,被指向的字符串是 static;若写成
char names[4][16], 则 4×16 字节全在栈上(固定宽度、浪费空间,但内容可修改)。
- 参差不齐的表 (jagged / ragged table):长度不一致的”二维”数据要用”指针数组 + 逐行分配”。
- 直观解释:课程 MP2 的课表是”15 个时段 × 5 天”的指针数组:每个格子存一个指向事件标签的指针 (或 NULL 表示空闲);课程 MP6 的棋盘
space_type_t b[BOARD_HEIGHT][BOARD_WIDTH]则是真正的二维数组, 因为每行长度相同。 - 底层机制图解:
MP2 课表(LC-3 视角):数组的 15 个数组,每个数组 5 个指针 x3800 +----+----+----+----+----+ ← 第 0 个时段(周一..周五) \| p \| p \|NULL\| p \| p \| +----+----+----+----+----+ x3805 \| p \|NULL\| p \| p \|NULL\| ← 第 1 个时段 +----+----+----+----+----+ 行 r、列 c 的地址 = x3800 + r*5 + c(每格一个"字",内容是指向事件标签的地址或 NULL)在 C 里同样可以写
char* schedule[15][5];(固定宽度),或逐行malloc得到可变的参差行。 - 作用域与存储期:固定宽度表的存储期由数组本身决定;逐行
malloc的表是 allocated,必须逐行free(否则泄漏),通常要先把每行的指针保存好再释放外层数组。
- 直观解释:课程 MP2 的课表是”15 个时段 × 5 天”的指针数组:每个格子存一个指向事件标签的指针 (或 NULL 表示空闲);课程 MP6 的棋盘
代码示例与底层机制分析
示例 1:字符串库函数与 NUL 终止
代码 (C)(/tmp/ece220_str/20t_string_library.c,用 gcc -g -std=c99 -Wall -Werror 20t_string_library.c -o 20t_string_library 实测):
#include <stdio.h>
#include <string.h>
int
main (void)
{
char buf[32];
char truncated[8];
char const* found;
/* strcpy copies the NUL terminator; strlen does not count it. */
strcpy (buf, "Hello");
strcat (buf, ", ");
strcat (buf, "world");
printf ("buf = \"%s\"\n", buf);
printf ("strlen (buf) = %lu, bytes used including NUL = %lu\n",
(unsigned long) strlen (buf),
(unsigned long) strlen (buf) + 1);
printf ("sizeof (buf) = %d (the array is 32 bytes, the string is not)\n",
(int) sizeof buf);
/* strcmp returns a negative, zero, or positive value. */
printf ("strcmp (\"abc\", \"abc\") -> %d\n", strcmp ("abc", "abc"));
printf ("strcmp (\"abc\", \"abd\") is %s\n",
(0 > strcmp ("abc", "abd") ? "negative" : "not negative"));
/* 'B' (0x42) < 'a' (0x61): ASCII order is not dictionary order. */
printf ("strcmp (\"Zebra\", \"apple\") is %s (ASCII order!)\n",
(0 > strcmp ("Zebra", "apple") ? "negative" : "positive"));
/* strchr returns a pointer into the string, or NULL. */
found = strchr (buf, 'w');
if (NULL != found) {
printf ("'w' is at index %lu; the rest is \"%s\"\n",
(unsigned long) (found - buf), found);
}
printf ("strchr (buf, 'z') == NULL -> %d\n", NULL == strchr (buf, 'z'));
/* strncpy copies AT MOST n bytes and does NOT terminate the result
* when the source is too long -- you must terminate it yourself. */
strncpy (truncated, "truncated", sizeof truncated - 1);
truncated[sizeof truncated - 1] = '\0';
printf ("strncpy result = \"%s\" (length %lu)\n",
truncated, (unsigned long) strlen (truncated));
return 0;
}
实际输出:
buf = "Hello, world"
strlen (buf) = 12, bytes used including NUL = 13
sizeof (buf) = 32 (the array is 32 bytes, the string is not)
strcmp ("abc", "abc") -> 0
strcmp ("abc", "abd") is negative
strcmp ("Zebra", "apple") is negative (ASCII order!)
'w' is at index 7; the rest is "world"
strchr (buf, 'z') == NULL -> 1
strncpy result = "truncat" (length 7)
【代码做什么?】
strcpy把"Hello"复制进buf(含 NUL),strcat两次追加得到"Hello, world"。strlen返回 12:最后一个字符下标是 11,NUL 不计入;sizeof buf是 32——数组大小 ≠ 字符串长度。"Zebra" < "apple"是因为'Z'(0x5A) <'a'(0x61),strcmp比的是编码。strchr (buf, 'w')返回buf + 7,指针相减得到下标;没找到时返回 NULL。strncpy只复制 7 字节且不写 NUL,所以代码手工补truncated[7] = '\0'。
【底层机制透视】 strlen/strcpy/strcmp/strchr 全都依赖”某个字节为 0”这一约定——它们没有长度参数,必须靠扫描。 这意味着:若字符数组里没有 NUL(例如用 memcpy 复制了整块数据),这些函数会一直读下去, 直到偶然碰到 0,这就是最常见的”字符串越界读”。strncpy 是少数带长度上限的函数,但语义别扭:源比 n 长时不补 NUL,源比 n 短时用 NUL 填满。
【内存布局图解】
buf(栈上 32 字节)
+----+----+----+----+----+----+----+----+----+----+----+----+----+----+ ... +----+
|'H' |'e' |'l' |'l' |'o' |',' |' ' |'w' |'o' |'r' |'l' |'d' |'\0'| ?? | | ?? |
+----+----+----+----+----+----+----+----+----+----+----+----+----+----+ ... +----+
0 1 2 3 4 5 6 7 8 9 10 11 12 ... 31
^
└─ found = buf + 7(strchr 的结果)
strlen = 12(下标 0..11),sizeof = 32;buf[13..31] 从未被写,内容无意义。
【与汇编的对应】(LC-3:strlen 的循环就是”见 0 即止”)
; ---- strlen:R0 = 字符串首地址,返回长度放在 R1 ----
LEN AND R1,R1,#0 ; 计数清零
LENLP LDR R2,R0,#0 ; R2 ← *s
BRz LENDONE ; 遇到 NUL 字节(0)就结束——这就是"字符串"的全部机制
ADD R1,R1,#1
ADD R0,R0,#1 ; 前进一个**字符**(1 字节 = 1 个 LC-3 字)
BRnzp LENLP
LENDONE RET
; ---- strchr:R0 = 字符串,R2 = 目标字符,地址返回在 R3(找不到置 0)----
CHR ADD R3,R0,#0
CHRLP LDR R1,R3,#0
BRz CHRFAIL ; 先到 NUL:没找到
NOT R1,R1
ADD R1,R1,#1 ; R1 = -(*s)
ADD R1,R1,R2 ; R1 = c - *s
BRz CHRDONE ; 差为 0 → 找到
ADD R3,R3,#1
BRnzp CHRLP
CHRFAIL AND R3,R3,#0 ; 返回 NULL(全 0 位模式)
CHRDONE RET
演示(仅供演示、请勿模仿):缓冲区溢出 (buffer overflow) 幻灯片用
char name[20]; scanf ("%s", name);说明”用户输入超过 19 个字符会怎样”: 多余字节顺着栈帧向上覆盖保存的返回地址,攻击者可以让RET跳到任意位置(乃至跳到刚写进栈的代码里)。char small[8]; char big[64] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdef"; strcpy (small, big); /* UB:向 8 字节的数组写入 53 字节 */实测(gcc 12.2.0,x86-64 Linux,未开
-Werror):程序以段错误(退出码 139)结束。 更隐蔽的一例是strncpy (dst, "abcdefghijk", 8):实测 gcc 报warning: 'strncpy' output truncated copying 8 bytes from a string of length 11 [-Wstringop-truncation](加-Werror即编译失败),因为结果没有 NUL 终止。防御手段:fgets、带字段宽度的scanf ("%19s", ...)、strncpy后手工补 NUL——并记住幻灯片的话:”依赖人来维护的防护措施本身就容易出错”。 这些都是未定义行为,实际结果随编译器、优化级别与平台而异。
示例 2:int32_t m[3][4] 的行主序布局与地址公式
代码 (C)(/tmp/ece220_str/22t_two_d_arrays.c):
#include <stdint.h>
#include <stdio.h>
int
main (void)
{
int32_t m[3][4] = {{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}};
int32_t i;
int32_t j;
int32_t (*row_ptr)[4] = m; /* pointer to an array of 4 ints */
int32_t* elem_ptr = m[0]; /* pointer to a single int */
printf ("sizeof (m) = %d, sizeof (m[0]) = %d, sizeof (m[0][0]) = %d\n",
(int) sizeof m, (int) sizeof m[0], (int) sizeof m[0][0]);
printf ("rows = %d, columns = %d\n",
(int) (sizeof m / sizeof m[0]),
(int) (sizeof m[0] / sizeof m[0][0]));
printf ("(char*) &m[1][0] - (char*) &m[0][0] = %ld bytes\n",
(long) ((char*) &m[1][0] - (char*) &m[0][0]));
printf ("m[1] - m[0] = %ld (in units of int, not bytes)\n",
(long) (m[1] - m[0]));
for (i = 0; i < 2; i++) {
for (j = 0; j < 4; j++) {
long offset = (long) ((char*) &m[i][j] - (char*) m);
printf ("m[%d][%d]: offset %2ld = (i*4 + j)*4 = %2ld, value %d\n",
i, j, offset, (long) ((i * 4 + j) * 4), m[i][j]);
}
}
printf ("(char*) (row_ptr + 1) - (char*) row_ptr = %ld bytes\n",
(long) ((char*) (row_ptr + 1) - (char*) row_ptr));
printf ("(char*) (elem_ptr + 1) - (char*) elem_ptr = %ld bytes\n",
(long) ((char*) (elem_ptr + 1) - (char*) elem_ptr));
printf ("(*(row_ptr + 1))[2] = m[1][2] = %d\n",
(*(row_ptr + 1))[2]);
printf ("m[2][3] = %d, *(*(m + 2) + 3) = %d, *(m[2] + 3) = %d\n",
m[2][3], *(*(m + 2) + 3), *(m[2] + 3));
return 0;
}
实际输出:
sizeof (m) = 48, sizeof (m[0]) = 16, sizeof (m[0][0]) = 4
rows = 3, columns = 4
(char*) &m[1][0] - (char*) &m[0][0] = 16 bytes
m[1] - m[0] = 4 (in units of int, not bytes)
m[0][0]: offset 0 = (i*4 + j)*4 = 0, value 1
m[0][1]: offset 4 = (i*4 + j)*4 = 4, value 2
m[0][2]: offset 8 = (i*4 + j)*4 = 8, value 3
m[0][3]: offset 12 = (i*4 + j)*4 = 12, value 4
m[1][0]: offset 16 = (i*4 + j)*4 = 16, value 5
m[1][1]: offset 20 = (i*4 + j)*4 = 20, value 6
m[1][2]: offset 24 = (i*4 + j)*4 = 24, value 7
m[1][3]: offset 28 = (i*4 + j)*4 = 28, value 8
(char*) (row_ptr + 1) - (char*) row_ptr = 16 bytes
(char*) (elem_ptr + 1) - (char*) elem_ptr = 4 bytes
(*(row_ptr + 1))[2] = m[1][2] = 7
m[2][3] = 12, *(*(m + 2) + 3) = 12, *(m[2] + 3) = 12
【代码做什么?】
- 在三种粒度上取
sizeof:整体 48、一行 16、一个元素 4;用比值算出”3 行 4 列”。 - 打印每行的字节间距(16)与”以 int 为单位”的间距(4)——同一个差值,单位不同。
- 逐元素比较”实测字节偏移”与公式
(i*4 + j)*4,两列完全一致,验证行主序。 row_ptr + 1前进 16 字节(跨一行),elem_ptr + 1前进 4 字节(跨一个元素)——类型决定步长。- 最后一行用三种等价写法读出同一个元素:
m[i][j] ≡ *(*(m+i)+j) ≡ *(m[i]+j)。
【底层机制透视】 m[i][j] 被编译成 base + (i * numCols + j) * sizeof (element):一次乘法、一次加法、一次访存。 列数 numCols 是编译期常量,编译器可把 i * 16 优化为移位加法;这也解释了为什么 m 的类型必须是”指向 4 个 int 的数组的指针”——只有知道一行多长,才能算出第 i 行从哪里开始。 若换成”指针的指针”(int32_t**),编译器必须多做一次访存去读第 i 行的地址。
【内存布局图解】
m(栈上 48 字节,行主序,一行紧接一行)
偏移: 0 4 8 12 16 20 24 28 32 36 40 44
+----+----+----+----+----+----+----+----+----+----+----+----+
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
+----+----+----+----+----+----+----+----+----+----+----+----+
└──── 第 0 行 ────┘└──── 第 1 行 ────┘└──── 第 2 行 ────┘
m[0] = base+0 m[1] = base+16 m[2] = base+32
m + 1 = base+16(m 的类型是 int32_t (*)[4])
m[0] + 1 = base+4 (m[0] 的类型是 int32_t*)
【与汇编的对应】(LC-3:base+offset 寻址正是二维数组的天然实现)
; 计算 &m[i][j]:m 在全局数据区,i 在 R1,j 在 R2
; int32_t 在 LC-3 上占 2 个字(4 字节),故偏移(以字计) = (i*4 + j)*2
AND R3,R3,#0
ADD R3,R3,#4 ; R3 = 4(列数)
JSR MULT ; R0 = i * 4(LC-3 无乘法指令,用库子程序)
ADD R0,R0,R2 ; R0 = i*4 + j
ADD R0,R0,R0 ; R0 = 2 * (i*4 + j) ← 每个 int32_t 占 2 个字
LEA R4,M_BASE ; R4 = 数组首地址(全局对象 → LEA)
ADD R4,R4,R0 ; R4 = &m[i][j] ← base + offset
LDR R5,R4,#0 ; R5 = m[i][j]
M_BASE .BLKW 24 ; 3*4 个 int32_t = 24 个字
; 课程为简化常把 int 当作 16 位(1 个字):此时偏移就是 (i*4 + j) 个字;
; 但 int32_t 在 C 里确实是 4 字节,所以真实代码要乘 2——这个"2 倍"正是
; "LC-3 按字寻址、C 按字节寻址"两个世界的换算。
示例 3:三种表格形式与字符串数组(用 sizeof 分辨)
代码 (C)(/tmp/ece220_str/23s_row_forms.c):
#include <stdint.h>
#include <stdio.h>
#include <string.h>
int
main (void)
{
int32_t m[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}};
int32_t r0[4] = {1, 2, 3, 4};
int32_t r1[4] = {5, 6, 7, 8};
int32_t r2[4] = {9, 10, 11, 12};
int32_t* ap[3] = {r0, r1, r2}; /* array of POINTERS */
int32_t (*pa)[4] = m; /* pointer to an array of 4 ints */
char* names[4] = {"Ada", "Grace", "Linus", "Ken"};
int32_t i;
printf ("sizeof (m) = %d, sizeof (ap) = %d, sizeof (pa) = %d\n",
(int) sizeof m, (int) sizeof ap, (int) sizeof pa);
printf ("sizeof (m[0]) = %d, sizeof (ap[0]) = %d, sizeof (names) = %d\n",
(int) sizeof m[0], (int) sizeof ap[0], (int) sizeof names);
printf ("m[1] - m[0] = %ld (row stride in int units)\n",
(long) (m[1] - m[0]));
printf ("gap between ap[0] and ap[1] targets = %ld bytes\n",
(long) ((char*) ap[1] - (char*) ap[0]));
printf ("(*(pa + 2))[1] = %d, m[2][1] = %d\n",
(*(pa + 2))[1], m[2][1]);
/* Both forms index the same way at the source level. */
printf ("m[1][2] = %d, ap[1][2] = %d, (*pa)[6] = %d\n",
m[1][2], ap[1][2], (*pa)[6]);
/* Modifying through ap changes the ROW ARRAY, not ap itself. */
ap[1][0] = 50;
printf ("after ap[1][0] = 50: r1[0] = %d, ap[1][0] = %d\n",
r1[0], ap[1][0]);
printf ("names:");
for (i = 0; i < 4; i++) {
printf (" %s(%lu)", names[i], (unsigned long) strlen (names[i]));
}
printf ("\n");
{
char* temp = names[0];
names[0] = names[2];
names[2] = temp;
}
printf ("after swapping names[0] and names[2]:");
for (i = 0; i < 4; i++) {
printf (" %s", names[i]);
}
printf ("\n(no characters were copied; only two pointers moved)\n");
return 0;
}
实际输出:
sizeof (m) = 48, sizeof (ap) = 24, sizeof (pa) = 8
sizeof (m[0]) = 16, sizeof (ap[0]) = 8, sizeof (names) = 32
m[1] - m[0] = 4 (row stride in int units)
gap between ap[0] and ap[1] targets = -16 bytes
(*(pa + 2))[1] = 10, m[2][1] = 10
m[1][2] = 7, ap[1][2] = 7, (*pa)[6] = 7
after ap[1][0] = 50: r1[0] = 50, ap[1][0] = 50
names: Ada(3) Grace(5) Linus(5) Ken(3)
after swapping names[0] and names[2]: Linus Grace Ada Ken
(no characters were copied; only two pointers moved)
【代码做什么?】
sizeof m = 48(二维数组)、sizeof ap = 24(指针数组:3×8)、sizeof pa = 8(一个指针)。m[1] - m[0] = 4(元素单位),而ap指向的r0/r1/r2相距 16 字节且地址递减,所以打印-16: 独立对象的相对位置由编译器决定,不能依赖。m[1][2] = ap[1][2] = (*pa)[6] = 7:三种语法访问同一数据,x[i][j]只是”加法 + 解引用”的语法糖。ap[1][0] = 50改的是r1[0](被指向的行),ap本身(24 字节)没变。- 名字数组的”交换”只搬动两个指针,字符一个都没动——这正是”索引排序”的原理。
【底层机制透视】 sizeof (m[0]) = 16 是因为 m[0] 的类型是”4 个 int 的数组”,而 sizeof (ap[0]) = 8 是因为 ap[0] 是指针。 访问 ap[i][j] 需要两次访存(先读行地址再读元素),m[i][j] 只需一次——代价换来灵活性: ap 的各行可以是长度不同的独立数组(参差表),m 做不到。char* names[4] 同理:32 字节的指针表 指向静态区 4 个长度各不相同的字符串。
【内存布局图解】
int32_t m[3][4] int32_t* ap[3]
(栈上 48 字节,连续) (栈上 24 字节的指针数组)
base+ 0 +----+----+----+----+ +----------+
0 | 1 2 3 4 | | ap[0] ───┼──→ r0: 1 2 3 4 (栈上 16 字节)
+----+----+----+----+ +----------+
16 | 5 6 7 8 | | ap[1] ───┼──→ r1: 50 6 7 8 (被 ap[1][0] 改)
+----+----+----+----+ +----------+
32 | 9 10 11 12 | | ap[2] ───┼──→ r2: 9 10 11 12
+----+----+----+----+ +----------+
sizeof = 48 sizeof = 24(不含任何行数据)
int32_t (*pa)[4] char* names[4](栈上 32 字节)
+-----------+ +-----------+ 静态数据区
| pa ───────┼──→ m[0][0] | names[0] ─┼──→ "Ada\0"
+-----------+ | names[1] ─┼──→ "Grace\0"
sizeof (pa) = 8 | names[2] ─┼──→ "Linus\0"
pa + 1 前进 16 字节 | names[3] ─┼──→ "Ken\0"
+-----------+
【与汇编的对应】(LC-3:一次访存 vs 两次访存)
; ---- int32_t m[3][4]:算出地址后一次 LDR ----
LEA R4,M_BASE
ADD R4,R4,R0 ; R0 = (i*4 + j)*2 个字
LDR R5,R4,#0 ; R5 = m[i][j] ← 1 次数据访存
; ---- int32_t* ap[3]:先取行指针,再取元素(多一次 pointer chase)----
LEA R4,AP_BASE
ADD R4,R4,R3 ; R3 = i(每个指针占 1 个字)
LDR R4,R4,#0 ; R4 = ap[i] ← 第 1 次访存,得到某一行地址
ADD R4,R4,R2 ; R2 = j
LDR R5,R4,#0 ; R5 = ap[i][j] ← 第 2 次访存
; ---- char* names[4]:取出字符串地址后逐字符输出到 NUL ----
LEA R4,NAMES
ADD R4,R4,R1 ; R1 = i
LDR R0,R4,#0 ; R0 = names[i](一个字符串地址)
JSR PRINT_STRING ; 内部用 LDR + BRz 扫到 NUL
常见错误与调试技巧
- 忘记 NUL 终止:用
strncpy或手写循环复制后没补'\0',之后printf ("%s")一直越界读(乱码或崩溃)。 调试:gcc -Wall报-Wstringop-truncation;gdb中x/16xb dst看有无 0 字节;valgrind抓越界读。 - 混淆
char s[]与char *s:对char* s = "...";执行s[0] = 'H'。现象是段错误(只读段)。 调试:打印(int) sizeof s(6 还是 8)瞬间判定;gdb中info proc mappings确认只读映射。 scanf ("%s", ...)不带字段宽度:超长输入冲垮相邻变量与返回地址。调试:改成scanf ("%19s", name)(宽度 = 数组长度 − 1);用gcc -fsanitize=address -g复现;gdb的bt看崩溃是否发生在函数返回处。- 字符串比较用
==,或二维数组下标写反:前者比的是地址(改用strcmp,”内容相同却判不等”即此); 后者越界却不报错。调试:gdb中p s1/p s2看地址、x/s s1看内容; 打印(char*)&m[i][j] - (char*)m与手算的(i*numCols+j)*4对照,或x/12dw m按内存顺序列出全部元素。 int *m[3]与int (*m)[4]写错,或把二维数组传给int**参数:前者一个是”指针数组”(24 字节)、 一个是”指向数组的指针”(8 字节);后者报incompatible pointer type或运行时崩溃。 调试:gdb的ptype m直接读出真实类型,(int) sizeof m对照预期;正确写法是int32_t (*p)[4] = m;。
关键要点
- C 字符串不是一种类型,而是一种约定:”一串连续
char,最后有一个 0”;所有字符串函数都靠扫描到 0 定界,忘记终止符就等于无限越界读。 char s[]分配数组(可写、sizeof是长度),char *s只分配指针(指向只读静态数据、sizeof为 8);要修改内容用数组,只引用常量用指针。- 没有任何字符串函数会检查目标缓冲区大小:
strcpy/strcat/scanf("%s")是缓冲区溢出的经典来源;要用带长度上限的手段,并记住strncpy不会自动补 NUL。 - 多维数组是”数组的数组”,按行主序连续存放:
&m[i][j] = base + (i*numCols + j)*sizeof (element);m退化为指向一整行的指针,所以m + 1跨一行,m[0] + 1只跨一个元素。 - 参差数据要用”指针的数组”或”指针的指针”:
T m[R][C]只能表示等长行;MP2 的课表(15×5 个字符串指针)与 MP6 的棋盘(固定大小的二维数组)分别是这两种需求的代表。
思考题(带答案)
问题 1:下面两段代码都得到”内容是 hello 的字符串”,它们在内存占用、可修改性、生命周期上有何不同?
char a[] = "hello"; /* (1) */
char* p = "hello"; /* (2) */
答案:(1) 在栈上分配 6 字节的数组,内容可写,sizeof a = 6,存储期到函数返回为止;(2) 只在栈上分配 8 字节的指针,字符串本体在静态数据区(通常只读),sizeof p = 8,存储期是整个程序。所以 a[0] = 'H' 合法, p[0] = 'H' 是未定义行为(常见结果是段错误);反过来 a = "bye" 非法(数组名不可赋值),p = "bye" 合法。
问题 2:int32_t m[3][4]; 中 sizeof (m)、sizeof (m[0])、sizeof (m[0][0]) 各是多少? m、m[0]、&m[0][0] 的值与类型分别是什么?
答案:48、16、4。三者的值相同(都是数组首字节的地址),但类型不同:m 退化后是 int32_t (*)[4], m[0] 退化为 int32_t*,&m[0][0] 也是 int32_t*。类型差异直接体现在步长上:m + 1 前进 16 字节, m[0] + 1 前进 4 字节。这也解释了为什么”用 int32_t** 接收二维数组”是错的。
问题 3:课程 MP2 的课表在 LC-3 上是”15 个数组,每个数组 5 个指针”,格子的地址是 x3800 + r*5 + c。 为什么这里每个格子只占一个内存字,而 C 里 int32_t schedule[15][5] 每个格子占 4 字节?
答案:LC-3 的格子里存放的是”指向事件标签的指针(地址)”,而 LC-3 的地址就是一个 16 位字,所以每格 1 个字; C 里 int32_t 是 4 字节,所以每格 4 字节——格子的大小由格子里放的东西决定,而不是由”数组”这个词决定。 遍历时用两层循环:外层 r 从 0 到 14、内层 c 从 0 到 4,每次 LEA R4,SCHEDULE 取基址、 ADD R4,R4,offset(offset = r*5 + c)算出格子地址、LDR R0,R4,#0 取出内容,再用 BRz 判断是否为 NULL: 是 NULL 就打印空白,否则把 R0 当作字符串地址调用打印子程序。
