Lecture 11: 字符串与多维数组 (Strings; Multi-Dimensional Arrays)

目录 · ← l10 · l12 →

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 字节)
      
    • 作用域与存储期ap 都是 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) = 6sizeof (pointer_form) = 8sizeof ("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 字节,源太长时不补 NULstrchr (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 (否则泄漏),通常要先把每行的指针保存好再释放外层数组。

代码示例与底层机制分析

示例 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)

【代码做什么?】

  1. strcpy"Hello" 复制进 buf(含 NUL),strcat 两次追加得到 "Hello, world"
  2. strlen 返回 12:最后一个字符下标是 11,NUL 不计入;sizeof buf 是 32——数组大小 ≠ 字符串长度
  3. "Zebra" < "apple" 是因为 'Z'(0x5A) < 'a'(0x61),strcmp 比的是编码。
  4. strchr (buf, 'w') 返回 buf + 7,指针相减得到下标;没找到时返回 NULL。
  5. 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

【代码做什么?】

  1. 在三种粒度上取 sizeof:整体 48、一行 16、一个元素 4;用比值算出”3 行 4 列”。
  2. 打印每行的字节间距(16)与”以 int 为单位”的间距(4)——同一个差值,单位不同
  3. 逐元素比较”实测字节偏移”与公式 (i*4 + j)*4,两列完全一致,验证行主序。
  4. row_ptr + 1 前进 16 字节(跨一行),elem_ptr + 1 前进 4 字节(跨一个元素)——类型决定步长
  5. 最后一行用三种等价写法读出同一个元素: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)

【代码做什么?】

  1. sizeof m = 48(二维数组)、sizeof ap = 24(指针数组:3×8)、sizeof pa = 8(一个指针)。
  2. m[1] - m[0] = 4(元素单位),而 ap 指向的 r0/r1/r2 相距 16 字节且地址递减,所以打印 -16: 独立对象的相对位置由编译器决定,不能依赖。
  3. m[1][2] = ap[1][2] = (*pa)[6] = 7:三种语法访问同一数据,x[i][j] 只是”加法 + 解引用”的语法糖。
  4. ap[1][0] = 50 改的是 r1[0](被指向的行),ap 本身(24 字节)没变。
  5. 名字数组的”交换”只搬动两个指针,字符一个都没动——这正是”索引排序”的原理。

【底层机制透视】 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-truncationgdbx/16xb dst 看有无 0 字节;valgrind 抓越界读。
  • 混淆 char s[]char *s:对 char* s = "..."; 执行 s[0] = 'H'。现象是段错误(只读段)。 调试:打印 (int) sizeof s(6 还是 8)瞬间判定;gdbinfo proc mappings 确认只读映射。
  • scanf ("%s", ...) 不带字段宽度:超长输入冲垮相邻变量与返回地址。调试:改成 scanf ("%19s", name) (宽度 = 数组长度 − 1);用 gcc -fsanitize=address -g 复现;gdbbt 看崩溃是否发生在函数返回处。
  • 字符串比较用 ==,或二维数组下标写反:前者比的是地址(改用 strcmp,”内容相同却判不等”即此); 后者越界却不报错。调试gdbp 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 或运行时崩溃。 调试gdbptype 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" 合法。

问题 2int32_t m[3][4];sizeof (m)sizeof (m[0])sizeof (m[0][0]) 各是多少? mm[0]&m[0][0]类型分别是什么?

答案48164。三者的值相同(都是数组首字节的地址),但类型不同: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,offsetoffset = r*5 + c)算出格子地址、LDR R0,R4,#0 取出内容,再用 BRz 判断是否为 NULL: 是 NULL 就打印空白,否则把 R0 当作字符串地址调用打印子程序。