Skip to content

数组:一排挨着的格子,以及它怎么悄悄变成指针

引言:同类型、连续、长度写死

写到这里,我们手里的变量都是「一个名字对一个值」——一个 int、一个 char、一个 double。可现实的数据几乎总是一打一打地来:一个班 40 个人的成绩、传感器的 128 个采样点、一幅图的三百多万个像素。要是老老实实写 int score0, score1, score2, ...;,40 个还行,40 万个直接放弃。数组就是 C 给的答案:一段连续的、同类型的存储,一个名字管一整排,靠下标(位置)挑出其中任意一个。

C 的数组有三个铁一样的脾气,提前知道它们,后面就不会一直撞墙:第一,元素类型必须一致——一整个 int a[10] 全是 int,不能混装;第二,元素在内存里挨个连续排布,没有缝隙(这正是下标能瞬间定位的原因);第三,长度在声明时就定死、之后不能改,C89 要求长度是编译期常量(C99 放宽到允许变长数组 VLA,但那是后话,本章末尾提一句)。这一章我们就顺着「声明 → 下标 → 初始化 → 求长度」走一遍,然后撞上数组最出名的一个特性——它会在你眼皮底下「退化」成一个指针,顺带把长度信息丢得一干二净。这个「退化」是数组这一章真正的核心,也是后面阶段 2 指针要展开讲的东西,先在这里把它认全。

声明与下标

声明一个数组,要告诉编译器两件事:元素的类型,和元素的个数。比如 int a[10] 就是要了 10 个连续的 int,名字叫 a。下标从 0 开始数(不是 1),所以这 10 个格子是 a[0]a[1]、……、a[9]——注意没有 a[10],最后一个的下标是「个数减一」。这是新手最容易踩的差一错误(off-by-one),后面专门真跑一次越界给你看后果。

a[i] 这种带方括号的表达式是一个左值(lvalue,能被赋值的东西),所以它能像普通变量一样被读写:

c
#include <stdio.h>

int main(void) {
    int a[10];
    a[0] = 42;          /* 写 */
    printf("%d\n", a[0]); /* 读 */
    a[0]++;             /* 自增,和普通变量一样 */
    return 0;
}

这里其实藏着一个比「数组」更深的事实:方括号 [] 在 C 里根本不是数组专属的语法糖,它就是个指针运算的简写。标准(ISO/IEC 9899:2011 §6.5.2.1)规定,a[i] 这个表达式完全等价于 *(a + i)——「取地址 a 往后挪 i 个元素的位置上的东西」。a 在这里被当成「指向首元素的指针」,加上 i 就是往后数 i 个元素,再 * 解引用拿出来。这件事一旦想通,数组一大半的怪现象就不怪了。不信你看下面这行,真跑能过:

c
#include <stdio.h>

int main(void) {
    int a[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
    /* a[i] === *(a+i); 加法可交换,所以 *(a+i) === *(i+a),于是 i[a] 也成立 */
    printf("a[1]=%d, *(a+1)=%d, 1[a]=%d\n", a[1], *(a + 1), 1[a]);
    return 0;
}
text
$ gcc -std=c11 -Wall subscript.c -o sub && ./sub
a[1]=1, *(a+1)=1, 1[a]=1

三种写法打出来全是 11[a] 长得像鬼画符,但它是完全合法的 C,因为编译器眼里 1[a] 就是 *(1 + a)、和 *(a + 1) 是同一个东西。当然,工程里千万别这么写——它除了让读你代码的人血压升高之外没有任何好处,这里只是拿它证明「下标就是指针加法的皮」。

初始化:花括号里的学问

光声明不初始化的话,局部数组里装的是栈上的垃圾值(第 9 章说过,自动存储期的变量不初始化就是垃圾)。最常见的是在声明时用花括号给初值:

c
int a[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; /* 给满 10 个 */

好玩的来了:初值列表比数组短,剩下的全部自动补 0(§6.7.9p21)。于是清零整个数组有个地道的写法——int a[10] = {0};,它只给了第一个元素 0,编译器就把后面 9 个也全填成 0(注意这跟「把整个数组初始化成 0」是从同一个规则推出来的,不是因为 {0} 有什么魔法)。初值列表比数组长则是直接编译错误,别想偷偷多塞。

初值列表还能反过来替你数个数:方括号里留空,编译器会按花括号里元素的个数确定长度:

c
int b[] = {10, 20, 30, 40}; /* b 的长度自动定为 4 */

再来一个 C99 加进来的好东西——指派初始化器(designated initializer,§6.7.9p6)。当你要的只是零星几个位置有值、其余全 0 时,它比数一长串 0 省事得多:[2] = 9 就是「把下标 2 那个元素填 9」。把这些凑一起真跑一遍:

c
#include <stdio.h>

int main(void) {
    int a[10] = {1, 2, 3};       /* 部分初始化:后面 7 个自动补 0 */
    int b[] = {10, 20, 30, 40};  /* 省略长度:编译器数出 4 */
    int c[5] = {[2] = 9};        /* C99 指派初始化:其余 0 */

    printf("a =");
    for (int i = 0; i < 10; i++) printf(" %d", a[i]);
    printf("\n");
    printf("b 的长度 = %zu\n", sizeof(b) / sizeof(b[0]));
    printf("c =");
    for (int i = 0; i < 5; i++) printf(" %d", c[i]);
    printf("\n");
    return 0;
}
text
$ gcc -std=c11 -Wall subscript2.c -o sub2 && ./sub2
a = 1 2 3 0 0 0 0 0 0 0
b 的长度 = 4
c = 0 0 9 0 0

a 给了前三个、后面七个乖乖补 0;b 的长度编译器替我们数成了 4;c[2] = 9 只点了下标 2,其余全是 0。指派初始化器还有个附带好处:初值之间的书写顺序不再重要{[14] = 48, [9] = 7, [2] = 29} 完全合法,对稀疏数组(大多数元素是 0、只有几个位置有值)特别友好。

顺带提一句 char 数组的一个特殊初始化方式,因为下一章就要讲 C 字符串,先在这儿打个照面:可以直接拿一个字符串字面量喂给 char 数组:

c
#include <stdio.h>

int main(void) {
    char s1[] = "hi";     /* 编译器数出 'h','i','\0' → 3 个元素 */
    char s2[10] = "hi";   /* 显式 10:前 3 个是 'h','i','\0',后 7 个补 '\0' */

    printf("sizeof(s1) = %zu, sizeof(s2) = %zu\n", sizeof(s1), sizeof(s2));
    printf("s1 字节:");
    for (size_t i = 0; i < sizeof(s1); i++) printf(" %d", s1[i]);
    printf("\n");
    return 0;
}
text
$ gcc -std=c11 -Wall charinit.c -o ci && ./ci
sizeof(s1) = 3, sizeof(s2) = 10
s1 字节: 104 105 0

"hi" 两个字符,可 sizeof(s1)3 不是 2——因为 C 的字符串字面量末尾自带一个看不见的 \0(空字符)当结尾标记,所以 'h''i''\0' 三个字节都装进了数组。这里千万别手滑少算这个 \0,凡是和 C 字符串打交道,结尾那个 \0 永远是第一个要记牢的坑(具体放到下一章细聊)。

用 sizeof 求数组长度

刚才 b 的长度我们没手写、是编译器数的;可一旦数组已经声明好了、你想在某段代码里反查它的长度怎么办?答案是 sizeof(第 3 章见过这个编译期运算符)。对一个数组名用 sizeof,得到的是整个数组的字节数;对它的一个元素用,得到的是单个元素的字节数;一除就是元素个数(§6.5.3.4):

c
int a[10];
sizeof(a);          /* 整个数组:10 * 4 = 40 字节 */
sizeof(a[0]);       /* 一个 int:4 字节 */
sizeof(a) / sizeof(a[0]);  /* 长度:40 / 4 = 10 */

sizeof(a) / sizeof(a[0]) 这个写法在工程里非常常见,它让循环的上界跟着数组长度自动变,改数组大小不用改循环。这条式子看着平平无奇,但请注意一个细节——它之所以管用,全靠 sizeof(a) 在这一处真的拿到了「整个数组」的 40 字节。这件事听起来天经地义,可一旦你把数组名传进一个函数,sizeof 在函数里就拿不到 40 了。为什么?因为数组偷偷退化成了指针。下一节就是它。

退化:数组名怎么变成了指针

这是数组这一章最该记住的特性,ISO 标准给它起了个名字叫「数组到指针的转换」,大家习惯叫它 decay(退化)。规则原文(§6.3.2.1p3)大意是:一个数组类型的表达式,除了出现在三种语境里,其余都会被自动转换成「指向它首元素的指针」。这三种豁免是:作为 sizeof 的操作数、作为 &(取地址)的操作数、以及用来初始化一个 char 数组的字符串字面量——除此之外,数组名一旦被用起来,编译器就把它当成「指向第 0 号元素的指针」。

后果最戏剧化的一幕在「把数组传给函数」时上演。看看这个程序:

c
#include <stdio.h>

void show_sizeof(int a[10]) {
    /* 参数明明写的是 int a[10],编译器却把它当成 int* a */
    printf("  函数内  sizeof(a) = %zu\n", sizeof(a));
    printf("  函数内  长度估计 = %zu\n", sizeof(a) / sizeof(a[0]));
}

int main(void) {
    int a[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    printf("main 里  sizeof(a) = %zu\n", sizeof(a));
    printf("main 里  长度估计 = %zu\n", sizeof(a) / sizeof(a[0]));
    printf("--- 传给函数 ---\n");
    show_sizeof(a);
    return 0;
}
text
$ gcc -std=c11 -Wall decay.c -o decay
decay.c: In function 'show_sizeof':
decay.c:5:49: warning: 'sizeof' on array function parameter 'a' will return size of 'int *' [-Wsizeof-array-argument]
    5 |     printf("  函数内  sizeof(a) = %zu\n", sizeof(a));
      |                                                 ^
decay.c:6:48: warning: 'sizeof' on array function parameter 'a' will return size of 'int *' [-Wsizeof-array-argument]
    6 |     printf("  函数内  长度估计 = %zu\n", sizeof(a) / sizeof(a[0]));
      |                                                ^
$ ./decay
main 里  sizeof(a) = 40
main 里  长度估计 = 10
--- 传给函数 ---
  函数内  sizeof(a) = 8
  函数内  长度估计 = 2

同样的 a、同样的 sizeof(a),在 main 里是 40、进了函数变成 8——因为这个机器是 64 位,一个指针正好占 8 字节。函数参数表里那个 int a[10] 是个彻头彻尾的谎言:编译器根本不把它当成「10 个元素的数组」,而是把它悄悄改写成了 int* a(一个指向 int 的指针),数组在传参的那一刻就退化成了首元素的地址,长度 10 这个信息在跨越函数边界时被丢掉了。于是函数里 sizeof(a)/sizeof(a[0]) 算出来是 84=2,一个荒唐的数字。

连编译器都看不下去了,gcc 和 clang 都会专门甩一个 -Wsizeof-array-argument 警告出来,告诉你「这个 sizeof 量的是指针不是数组」;如果你还顺手写了 sizeof(a)/sizeof(a[0]) 这种除法,clang 会再追加一个 -Wsizeof-pointer-div。这俩警告平时看见了就当救命稻草——只要它一响,基本就是你以为在算数组大小、其实算的是指针大小。

这就解释了一条 C 的「长相奇怪的」工程铁律:函数要处理一个数组,长度必须另开一个参数传进来,绝不能指望函数自己用 sizeof 去问。所以你到处能看到这样的函数签名:

c
/* 对长 n 的数组求和:n 必须显式传,函数内部没法自己量 */
int sum(const int* a, size_t n) {
    int total = 0;
    for (size_t i = 0; i < n; i++) {
        total += a[i];
    }
    return total;
}

调它的人自然也得分两半:sum(arr, sizeof(arr) / sizeof(arr[0]))——长度在「数组还活着、还没退化」的地方算好,再把数字传进去。参数表里写 int a[10]int a[] 还是 int* a,编译器一视同仁全当成 int*,那对中括号只是给读代码的人看的、对编译器毫无约束力(这也是为什么 C 函数没法靠参数表强制「必须传 10 个元素的数组」)。

退化还连带解释了数组的另一个「不能用」:数组不能整体赋值int a[3]; int b[3]; a = b; 是编译错误,因为在赋值号右边的 b 退化成了指针、而左边那个 a 也不是「可被赋值的左值」(数组名本身不可修改)。真试一下,两个编译器都拦你:

c
int main(void) {
    int a[3] = {1, 2, 3};
    int b[3] = {4, 5, 6};
    a = b;  /* 编译不过:数组不能整体赋值 */
    return 0;
}
text
$ gcc -std=c11 -Wall noassign.c -o na
noassign.c: In function 'main':
noassign.c:2:9: warning: variable 'a' set but not used [-Wunused-but-set-variable=]
    2 |     int a[3] = {1, 2, 3};
      |         ^
noassign.c:4:7: error: assignment to expression with array type
    4 |     a = b;  /* 编译不过:数组不能整体赋值 */
      |       ^

clang 的措辞更直白一点,直接说 error: array type 'int[3]' is not assignable——意思一样:数组类型没法用 = 整体赋值。

要拷贝数组,老老实实写个循环挨个赋值(或者用 memcpy,那也是按字节挨个搬,本质一样)。数组名「能当指针用、却不能当整体对象用」这副别扭脾气,根源全在退化这一条规则上。

越界访问:编译器不替你守门

数组的下标有效范围是 0n-1,可 C 的编译器根本不检查你有没有越界——它只管算 *(a + i) 那个地址,至于这个地址是不是还在数组那块合法内存里,它一概不管(§6.5.2.1、§6.5.6)。越界读写的后果是未定义行为(UB):可能读到一段别人内存里的垃圾值、可能程序当场崩、也可能「看起来没事」地继续跑(最可怕的一种,因为 bug 被藏起来了)。拿一个 5 元素数组去读 a[5] 试试:

c
#include <stdio.h>

int main(void) {
    int a[5] = {10, 20, 30, 40, 50}; /* 有效下标 0..4 */
    printf("a[0]=%d, a[4]=%d, a[5]=%d(越界,UB)\n", a[0], a[4], a[5]);
    return 0;
}

裸跑(不加任何检测)时它多半会打印出一个莫名其妙的数字、装作什么都没发生——这正是 UB 的阴险之处。好在第 11 章那一套 sanitizer 门禁在这里正好派上用场,开 UBSan 当场就抓:

text
$ gcc -std=c11 -Wall -fsanitize=undefined oob.c -o oob && ./oob
oob.c:5:67: runtime error: index 5 out of bounds for type 'int [5]'
oob.c:5:5: runtime error: load of address 0x7ffedcf50214 with insufficient space for an object of type 'int'
a[0]=10, a[4]=50, a[5]=0(越界,UB)

UBSan 一字一句告诉你:index 5 out of bounds for type 'int [5]'——下标 5 超出了 5 元素数组的范围;第二条 load of address ... with insufficient space 是它在说「这个地址上根本没装下一个完整 int 的合法内存」。末尾打印的 a[5]=0 别当真——那只是越界读到栈上某个恰巧为 0 的字节,换个编译器或运行环境、它就可能是任意一个完全不同的怪数字(这正是 UB 的标志:结果不可预期,你永远不该依赖这个值)。有意思的是这里退出码还是 0,因为 UBSan 默认是「recover」模式(报错但继续跑,呼应阶段 0 第 11 章);换成 ASan,态度就硬多了,直接判死刑:

text
$ gcc -std=c11 -Wall -fsanitize=address oob.c -o oob_asan && ./oob_asan
==97584==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x793258ff0034
  This frame has 1 object(s):
    [32, 52) 'a' (line 4) <== Memory access at offset 52 overflows this variable
==97584==ABORTING

ASan 报的是 stack-buffer-overflow,而且点名了是哪个变量'a' (line 4),访问偏移 52 溢出了这个从偏移 32 到 52 的变量(正好是 5 个 int、20 字节,52 这个位置是数组尾部紧挨着的「红区」);开头的 ==97584== 是进程号、那个长地址是运行期地址,这俩每次跑都不一样,但变量名和偏移是稳定的。退出码是 1,进程被 abort 掉。顺便一提,要是越界的下标是个编译期常量(比如这里的字面量 5),clang 还能在编译阶段就甩一个 -Warray-bounds 警告给你:

text
$ clang -std=c11 -Wall oob.c -o oob
oob.c:5:66: warning: array index 5 is past the end of the array (that has type 'int[5]') [-Warray-bounds]
    5 |     printf("a[0]=%d, a[4]=%d, a[5]=%d(越界,UB)\n", a[0], a[4], a[5]);
      |                                                                ^ ~
1 warning generated.

连运行都不用就揪出来——但下标是变量时(运行期才知道是几)它就无能为力了,那种只能靠 sanitizer 在运行期抓。所以日常写数组循环,要么把长度做成宏/sizeof 自动跟着走、要么老老实实 for (size_t i = 0; i < n; i++),把边界当成自己的责任,别指望编译器替你守。

多维数组:其实是「数组的数组」

很多语言有「真正的二维数组」,C 没有这种东西——C 的多维数组是搭出来的int m[3][5] 在 C 里的精确含义是:一个有 3 个元素的数组,每个元素又是一个有 5 个 int 的数组(§6.2.5p20)。换句话说,它是「数组的数组」,外层 3 行、每行是个 5 元素的内层数组。这件事理解透了,二维数组那一堆怪现象就都顺了。

先看它在内存里长什么样。因为是「3 个『5 个 int』」,每个内层数组 5×4=20 字节,3 个挨着排,总共 60 字节,按行主序连续存放(先放完第 0 行的 5 个、再放第 1 行的 5 个、再第 2 行):

展开代码 (共 22 行)收起代码
c
#include <stdio.h>

int main(void) {
    int m[3][5] = {
        {1, 2, 3, 4, 5},
        {6, 7, 8, 9, 10},
        {11, 12, 13, 14, 15},
    };

    printf("sizeof(m) = %zu  (3*5*4)\n", sizeof(m));
    printf("sizeof(m[0]) = %zu  (一行 5 个 int)\n", sizeof(m[0]));
    printf("行数 = %zu, 列数 = %zu\n",
           sizeof(m) / sizeof(m[0]),
           sizeof(m[0]) / sizeof(m[0][0]));

    /* 按行主序:把二维数组当成连续的一维 int 流来读 */
    int* p = &m[0][0];
    printf("按内存顺序读 15 个 int:");
    for (int i = 0; i < 15; i++) printf(" %d", p[i]);
    printf("\n");
    return 0;
}
text
$ gcc -std=c11 -Wall multi.c -o multi && ./multi
sizeof(m) = 60  (3*5*4)
sizeof(m[0]) = 20  (一行 5 个 int)
行数 = 3, 列数 = 5
按内存顺序读 15 个 int: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15

sizeof(m) 是整个 60 字节;sizeof(m[0]) 是「一行」也就是 5 个 int 共 20 字节——这两个一除就是行数 3、再用 sizeof(m[0][0]) 一除 m[0] 就是列数 5,所以「数组的数组」这套结构让二维的行数列数都能用 sizeof 套出来。把首元素地址 &m[0][0] 当成一个 int* 顺着往后读 15 个,得到的就是 1 到 15 连续递增——证明 60 字节里没有任何缝隙、整块内存就是一个一维 int 流。

m[i][j] 的取值,套用前面「下标即指针加法」的规则展开,就是 *(*(m + i) + j)m 先退化成「指向第 0 行的指针」(注意是行指针,类型是 int(*)[5]),m + i 挪到第 i 行,*(m + i) 拿出第 i 行这个内层数组(它又退化成指向首元素的 int*),再 + j 挪到该行第 j 个、* 取出来。真跑验证两个写法等价:

c
#include <stdio.h>

int main(void) {
    int m[3][5] = {
        {1, 2, 3, 4, 5},
        {6, 7, 8, 9, 10},
        {11, 12, 13, 14, 15},
    };
    printf("m[1][2] = %d, *(*(m+1)+2) = %d\n", m[1][2], *(*(m + 1) + 2));
    return 0;
}
text
$ gcc -std=c11 -Wall multi2.c -o multi2 && ./multi2
m[1][2] = 8, *(*(m+1)+2) = 8

m[1][2] 是第 1 行第 2 个,按上面那串值是 8;*(*(m + 1) + 2) 也是 8。这就是「数组的数组」的全部秘密:外层下标走「行」(跳一整行 5 个 int)、内层下标走「列」(在行内挪一个 int)。这也顺带预告了——把二维数组传进函数时,同样会退化(外层退化成行指针),所以函数参数通常写成 int (*m)[5]int m[][5],第二维那个 5 是死也不能省的(编译器算行内偏移就靠它)——这套留到阶段 2 指针细拆。

最后补一句变长数组(VLA)。C99 允许声明一个长度在运行期才确定的数组,比如 int n = 一个运行期算出来的值; int a[n];,对「长度事先不知道、但用了就扔、又不想上 malloc」的场景挺顺手。可它有实在的代价:长度是运行期的、所以 sizeof 不能在编译期算(VLA 的 sizeof 是运行期求值);更大的坑是它在栈上分配,长度一大就可能爆栈,而且出错时几乎没有体面的恢复手段。正因为这些,VLA 在不少工程规范(以及 MISRA 这类安全标准)里是被禁用的。这里知道有这么个东西就行,平时老老实实用「编译期常量长度的数组」,要运行期灵活长度,等阶段 2 学了 malloc/free 再上动态数组,那才是更受控的方案。

小结

数组是「同类型、连续、长度在声明时定死」的一排格子,声明要给元素类型和个数(int a[10]),下标从 0 数到 n-1(没有 a[n]),而方括号 a[i] 本质是指针运算 *(a + i) 的简写——所以连 1[a] 这种写法都成立(加法可交换)。初始化时初值列表短了会自动补 0({0} 清零整个数组就是这么来的)、长了是编译错误、方括号留空则让编译器替你数个数;C99 还给了 [2] = 9 这种指派初始化器,对稀疏数组格外顺手;char 数组能用字符串字面量初始化,但要记得字面量末尾自带一个 \0(下一章细讲)。在数组还没退化时,sizeof(a) / sizeof(a[0]) 能可靠反推长度。这一章真正的主角是退化(decay,§6.3.2.1p3):除了 sizeof&、字符串字面量初始化这三处,数组名一旦被用就自动变成「指向首元素的指针」,长度信息随之丢失——最戏剧化的后果是把数组传进函数后,sizeof(a) 从 40 变成 8(指针大小),所以函数永远拿不到数组长度、必须另传一个长度参数(参数表里的 int a[10] 是骗人的、编译器全当 int*),连 gcc/clang 都会用 -Wsizeof-array-argument 提醒你;退化还连带让数组不能整体赋值(要拷贝得循环或 memcpy)。越界访问是 UB,编译器不替你守边界,得靠 sanitizer:UBSan 报 index 5 out of bounds、ASan 报 stack-buffer-overflow 点名变量,下标是编译期常量时 clang 还能用 -Warray-bounds 在编译期就抓。多维数组在 C 里其实是「数组的数组」(int m[3][5] 是 3 个「5 个 int」),按行主序连续存放、整块无缝隙,m[i][j] 展开就是 *(*(m+i)+j)(外层走行、内层走列)。变长数组 VLA 虽然能运行期定长度,但栈分配有爆栈风险、规范里常被禁用,知道有它即可。下一章我们正式走进 C 字符串——char 数组那位带 \0 的老朋友。

参考资源

  • ISO/IEC 9899:2011 §6.2.5p20(数组类型)、§6.3.2.1p3(数组到指针的转换/退化)、§6.5.2.1(下标运算)、§6.5.3.4(sizeof 运算符)、§6.7.9(初始化,含 p6 指派初始化器、p21 部分初始化补 0)
  • K. N. King《C Programming: A Modern Approach》第 8 章 Arrays(一维/多维/初始化/sizeof 求长度)、第 12 章 Pointers and Arrays(退化与下标的指针本质)
  • Robert C. Seacord《Effective C》第 2 章·Arrays 小节(a[i] === *(a+i)、多维是数组的数组)
  • 第 3 章:整型提升、溢出与回绕(sizeofsize_t)、第 8 章:函数(参数是副本)、第 9 章:作用域、存储期与 static
  • 阶段 0·第 11 章:Sanitizer 门禁(UBSan 抓越界、ASan 抓栈越界的 recover/abort 区别,本章越界验证正是用这套)
  • 第 11 章:C 字符串与不安全 libc(char 数组与 \0、越界读写的真实后果)、阶段 2:指针与内存(退化的彻底拆解、指针算术、malloc/free 动态数组)