字符串数组在C语言里看起来很简单:不就是一堆字符串装进一个数组吗?但真上手写几行代码,很多人会发现不对劲——为什么有时候能改字符串里的字符,有时候一改就崩溃?为什么char *names[]和char names[][20]看着差不多,行为却完全不同?这些问题都指向同一个底层真相:C语言没有字符串类型,所谓字符串数组,本质上是在管理字符串的内存地址或内存副本。这篇文章想把这个关键点拆开讲透。
C语言中的字符串是用char数组承载的,并且必须以\0结尾。字符串数组,就是一个集合容器,容器里装的不是“字面字符串本身”,而是这些字符串的内存位置或副本。理解这一层,再去看各种写法和报错,就会清晰很多。
1. 先搞懂:字符串数组不是一种语法,而是一组内存布局
1.1 C语言没有真正的字符串对象
很多从 JavaScript、Python、Java 转过来的朋友,第一次在 C 里写字符串数组时会愣住:为什么char *s = "hello"不能直接s[0] = 'H'?为什么必须靠strcpy?原因很简单:C语言把字符串当成一个约定,而不是一个类型。所谓的字符串,不过是一段连续的内存,里面存了若干字符,并以\0收尾。编译器不帮你管理内容,也不帮你保管长度。
所以,当你听到“字符串数组”时,先不要急着写char strs[100]。问自己一个问题:这个“数组”里装的到底是什么?如果装的是每个字符串的第一个字符地址,那它是指针数组;如果装的是每个字符串的完整字符序列,那它是二维字符数组。两种布局完全不同,但它们都被叫作“字符串数组”。
1.2 二维字符数组:每个字符串是定长的行
常见写法:
char names[3][16] = { "alice", "bob", "chen" };这表示一个有 3 行、每行 16 个字符的“矩阵”。每一行都是一段连续内存,能容纳最多 15 个字符加上结尾的\0。names[0]指向第一行首地址,names[0][1]是'l'。这里没有“指针数组”的一层抽象,数组本身就是每个字符串的家。
它的优点是:内存连续,申请和释放都不需要你操心,直接定义在栈上或者静态区。缺点是:行宽必须固定,存储“短字符串”会浪费空间,存储超过 15 个字符的字符串又会编译报错或者被截断。适合字符串数量不多、长度范围可控的情况。
1.3 指针数组:数组元素是字符串入口
另一种更常见的写法:
char *names[3] = { "alice", "bob", "chen" };这里names是一个数组,有三个元素,每个元素是一个char *,也就是一个地址。第一个元素保存的是字符串字面量"alice"所在的位置。这种形式的“字符串数组”,在遍历时同样可以用names[0]拿到字符串,但内存模型和二维数组完全不一样。
如果后面把names[1]指向malloc分配的新内存,那么数组元素的地址就指向堆区。也就是说,指针数组本身只负责存“到哪找”,并不负责保存字符串内容。内容到底在哪,由初始化方式和赋值逻辑决定。
1.4 两种形态的记忆地图
可以用一张表收拢区别:
| 对比项 | 二维字符数组char a[N][M] | 指针数组char *a[N] |
|---|---|---|
| 每个字符串存储位置 | 数组自身的行内 | 由指针指向的外部内存 |
| 内存是否连续 | 整体连续 | 指针数组本身连续,字符串分散 |
| 修改字符串内容 | 通常可以直接改 | 取决于指针指向的内存是否可写 |
| 空间利用率 | 有浪费,行宽固定 | 按需分配,较省 |
| 移动/排序代价 | 需要逐行拷贝 | 只需交换指针,很快 |
| 生命周期管理 | 由所在作用域管理 | 可能需要手动free |
这张表不是让你背下来,而是给你一个判断入口:当你想写“字符串数组”时,实际上是在选择一个内存布局方案。后面的一切报错和优化,都是在为这个布局买单。
2. 初始化与访问:同一行代码,背后是不同的内存契约
2.1 字符串字面量初始化默认“只读”
先看这个例子:
char *fruits[] = {"apple", "banana", "cherry"}; fruits[1][0] = 'B';这样做大概率会段错误,因为"banana"是字符串字面量,存储在只读区。C标准并没有保证它一定放在只读区,但在主流平台上,修改它属于未定义行为。这里的fruits只保存了这些字面量的地址,你拿到的是一把指向受保护内存的钥匙,却想往里写数据。
如果你确实想改内容,可以改成二维字符数组:
char fruits[][8] = {"apple", "banana", "cherry"}; fruits[1][0] = 'B';fruits[1]是一份拷贝,存的是可修改的 8 字节空间,所以改起来没有问题。这一条,是区分两种写法的基本分水岭。
2.2 使用strcpy或scanf填充二维数组
二维字符数组更常见的初始化方式,是在运行期从输入中读入。例如:
#include <stdio.h> #include <string.h> #define MAX_WORDS 100 #define MAX_LEN 64 int main(void) { char words[MAX_WORDS][MAX_LEN]; int n = 0; while (n < MAX_WORDS && scanf("%63s", words[n]) == 1) { n++; } for (int i = 0; i < n; i++) { printf("%s\n", words[i]); } return 0; }这段代码里,words[n]本身就是char *,可以直接作为scanf的目标缓冲区。%63s限制读入长度不超过 63 个字符,避免越界。这样读入的字符串存放在数组自己的内存里,生命周期与words一致,不用手动释放。
注意:用
scanf读字符串时,如果不限宽度,很容易造成缓冲区溢出。任何从外部输入进入固定数组的内容,都要先想清楚“最长可能有多长”。
2.3 使用strdup或malloc挂到指针数组
当字符串数量不固定,或者字符串长度差异很大时,指针数组更合适。常见用法:
#include <stdio.h> #include <stdlib.h> #include <string.h> #define MAX_LINES 1000 int main(void) { char *lines[MAX_LINES]; int count = 0; char buf[256]; while (count < MAX_LINES && fgets(buf, sizeof buf, stdin)) { buf[strcspn(buf, "\n")] = '\0'; /* 去掉末尾换行 */ lines[count] = strdup(buf); /* 复制字符串到堆上 */ if (lines[count] == NULL) break; count++; } for (int i = 0; i < count; i++) { printf("%s\n", lines[i]); } for (int i = 0; i < count; i++) { free(lines[i]); } return 0; }这里使用了strdup函数,它相当于一次malloc加一次strcpy,把一个字符串复制到堆上的新内存中。如果编译器没有提供strdup,你可以自己写一个。使用它的目的是让lines[i]指向每块独立分配的可写内存,而不是指向buf——buf是栈上的临时缓冲区,循环继续读入后内容会被覆盖,所以必须先复制一份。
2.4 不要急着把指针数组指向同一个缓冲区
新手常见错误是:
char buf[100]; char *s[3]; for (int i = 0; i < 3; i++) { scanf("%s", buf); s[i] = buf; // 错误:所有元素都指向同一个 buf,最后内容都一样 }因为s[i]保存的是地址,而地址始终指向同一个buf,所以循环结束后,s[0]、s[1]、s[2]的内容都是最后一次scanf的结果。解决方法是,每次读入后立即用strdup复制,或者换用二维数组。
这一章其实是在讲同一个道理:初始化决定了字符串数组的存储位置和可写性。不要只记住某个写法,要记住每种写法背后交出了哪块内存的控制权。
3. 实战:写一个字符串排序器,把两种数组都跑一遍
3.1 需求很简单,但不简单在“交换字符串”
题目很常见:输入若干单词,按字典序输出。按说用 C 写排序不难,但“交换字符串”这个动作,在不同内存布局下差异很大。二维数组里,字符串是整块内容,交换必须整行拷贝;指针数组里,字符串是地址,交换只需要交换指针。这个差别,在数据量变大后会非常显著。
先看二维数组版本,我为了把原理讲清楚,用选择排序:
#include <stdio.h> #include <string.h> #define MAX_WORDS 100 #define MAX_LEN 64 void sort_2d(char arr[][MAX_LEN], int n) { char tmp[MAX_LEN]; for (int i = 0; i < n - 1; i++) { int min = i; for (int j = i + 1; j < n; j++) { if (strcmp(arr[j], arr[min]) < 0) { min = j; } } if (min != i) { strcpy(tmp, arr[i]); strcpy(arr[i], arr[min]); strcpy(arr[min], tmp); } } } int main(void) { char words[MAX_WORDS][MAX_LEN]; int n = 0; while (n < MAX_WORDS && scanf("%63s", words[n]) == 1) { n++; } sort_2d(words, n); for (int i = 0; i < n; i++) { printf("%s\n", words[i]); } return 0; }注意函数参数char arr[][MAX_LEN]:二维数组传参时,第一维可以省略,第二维必须写上,否则实参和形参的步长对不上。strcmp(arr[j], arr[min])中,arr[j]会退化成指向这一行首元素的指针,正好作为字符串传给strcmp。
3.2 指针数组版本:交换只花一个指针的工夫
再看指针数组版本:
#include <stdio.h> #include <stdlib.h> #include <string.h> #define MAX_WORDS 100 #define MAX_LEN 64 void sort_ptr(char *arr[], int n) { char *tmp; for (int i = 0; i < n - 1; i++) { int min = i; for (int j = i + 1; j < n; j++) { if (strcmp(arr[j], arr[min]) < 0) { min = j; } } if (min != i) { tmp = arr[i]; arr[i] = arr[min]; arr[min] = tmp; } } } int main(void) { char *words[MAX_WORDS]; int n = 0; char buf[MAX_LEN]; while (n < MAX_WORDS && scanf("%63s", buf) == 1) { words[n] = strdup(buf); if (!words[n]) break; n++; } sort_ptr(words, n); for (int i = 0; i < n; i++) { printf("%s\n", words[i]); free(words[i]); } return 0; }这里words是char *数组,传给函数时,形参写成char *arr[]或char **arr是等价的。交换字符串时,只交换tmp这个指针变量,不需要搬动整个字符串。当单词长度很长、数量很大时,优势不止一点。
3.3 排序的关键不是语法,是strcmp
无论哪种布局,比较两个字符串都要用strcmp(arr[j], arr[min]) < 0,而不能写arr[j] < arr[min]。原因在于,数组名或指针变量保存的是内存地址,直接写<只是比较两个地址的数值大小,不是字典序。这是很多新手的隐性错误——编译能通过,结果却完全不对。
字符串排序只是字符串数组的一个入口。你会惊喜地发现,学会了这几种布局,后面再写按字符串过滤、去重、统计、分组逻辑时,思路会顺很多,因为你的操作对象已经从“语法”变成了“一串内存和一个入口地址”。
4. 几个绕不开的坑:只读区、数组退化、返回局部数组
4.1 字符串字面量不能写,但很多人写了
最容易在深夜调崩溃的问题,就是给char *p指向的字符串赋值。比如:
const char *a = "hello"; a[0] = 'H'; // 运行时多半崩溃有的编辑器或者编译器会直接报错:向只读位置赋值。这不算缺陷,而是定义时就没有声明要改它。如果你拿到的是一个char *,先仔细看它是从哪里来的:如果来自字符串字面量,默认别改;如果来自malloc或栈上的字符数组,才可以改。
4.2 二维数组传参时“列信息”会丢失
很多人写void foo(char **arr)想接收char arr[3][16],结果编译报错。原因是一个二维字符数组本质上是一个数组的数组,传参时退化成指向“第一个元素”的指针,而第一个元素是一个长度为 16 的数组,所以正确写法是:
void foo(char arr[][16], int rows); // 或等价的 void foo(char (*arr)[16], int rows);这里的char (*arr)[16]是数组指针,不是指针数组,题目里常被拿来和char *arr[16]比较。两者的区别是:一个指向数组,一个是数组里存着指针。可以用一句话记:[]和*的优先级以及结合方向不同,不要把char *arr[16]当成二维数组参数类型。
4.3 函数返回值不能是局部数组
曾经有人这样写:
char **get_names(void) { char *names[3]; names[0] = "a"; names[1] = "b"; names[2] = "c"; return names; // 错误:返回了已失效的栈地址 }names是局部数组,函数一返回,这块栈内存就可能被其他调用覆盖。与其返回栈上的指针数组,不如把数组作为参数传入,或者在函数内部用malloc分配:
char **build_names(int n) { char **names = malloc(sizeof(char *) * n); if (!names) return NULL; for (int i = 0; i < n; i++) { char buf[64]; snprintf(buf, sizeof buf, "item-%d", i); names[i] = strdup(buf); if (!names[i]) { while (i > 0) free(names[--i]); free(names); return NULL; } } return names; }释放时,先释放每个names[i],最后再释放names本身。顺序反了就会造成内存泄漏,或者访问已释放内存。
4.4 别把strcmp的结果当成明确大小
strcmp返回负值、0 或正值,不能说一定等于 -1 或 1。在不同 C 库实现里,返回值可能是字符差的值。所以在排序里写strcmp(...) < 0是稳妥的,写== -1则不可靠。这个细节虽然小,但如果在单元测试里死等 -1,会踩到兼容性问题。
提醒:排查运行时崩溃,先看“我到底在改哪块内存”,再看“这块内存是不是允许写”。不要在没弄清布局时不断加打印。
5. 工程选择:这个场景到底该用哪种“字符串数组”
5.1 一张决策清单
面对项目需求时,可以按下面四个问题依次判断:
- 字符串数量是否基本固定?
- 每条字符串的长度范围是否稳定?
- 是否需要频繁排序、移动字符串?
- 是否希望只分配一次内存,或者必须完全掌握每块内存的释放时机?
如果数量固定、长度上限很小,二维字符数组更简洁。比如一个固定的颜色表、错误码表。
如果数量可变、长度差异大、需要频繁排序,指针数组更灵活。比如读取一个未知长度的文件,按行排序。
如果既要统一管理,又要避免指针数组遍历时多一次间接跳转,可以考虑结构体数组加内部缓冲区,这是另一个话题。
5.2 二维数组适合什么
适合小型、静态、每个字符串长度接近的场景。典型例子:月份名、星期名、状态名。
const char month[][10] = { "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" };这种表通常不会被修改,也不需要动态增长。这里我也写了const,表示不打算修改它。加上const后,编译器能帮你挡住误写。
5.3 指针数组适合什么
适合从外部读取大量行、字符串长度参差不齐、需要排序或去重的场景。典型例子:日志文件的行列表、命令行参数、单词表。
它的代价是多了一层指针,且每块字符串都需要单独分配和释放。如果忘记释放,程序内存只增不减。
5.4 一个更少见但值得知道的思路:连续大缓冲区 + 索引
当字符串数量很多,而且每条都很短时,指针数组的每行分配会产生大量小块内存,堆碎片多。工程上有时会选择一次性申请一个大块连续内存,把所有字符串拼接在里面,然后另建一个char *数组指向每个字符串的起始位置。这样内存连续,缓存友好,释放也简单。
这个方案代码更复杂,通常出现在编译器、词法分析器、配置解析器等对性能敏感的程序里。普通业务场景用指针数组已经够了。知道这个方向,是为了避免把“指针数组”当成唯一答案。
6. 常见错误的排查路径,以及一条值得长期练习的路线
6.1 按步骤排查字符串数组问题
遇到崩溃或乱码,不要急着猜。先按下面顺序排查:
- 看现象:段错误?打印乱码?死循环?还是排序结果不对?这决定了下一步方向。
- 看定义:用的是二维数组还是指针数组?指针是指向字面量、临时缓冲区,还是堆内存?
- 看输入:输入是否可能超过数组容量?
scanf有没有限制宽度?fgets是否处理了换行? - 看赋值:每个元素是否真的指向了独立内存?有没有所有元素都指向同一个
buf的情况? - 看生命周期:字符串内存的释放顺序是否正确?有没有在函数返回后使用局部缓冲区?
- 看工具:用
printf打印指针地址,用gdb看崩溃点,用-fsanitize=address编译跑一遍,比对着代码空想高效得多。
6.2 让工具替你做内存检查
一个很省事的习惯是,在调试阶段给编译器打开 AddressSanitizer:
gcc -g -fsanitize=address -Wall -Wextra main.c -o main它会在运行期检测越界、释放后使用、泄漏等问题。如果是 Linux 环境,配合valgrind也能找到内存问题的线索。字符串数组问题,绝大多数都是内存问题,工具往往比人眼更快。
6.3 从练习到熟练的建议
如果你是初学者,我建议用下面这条路线把这块内容彻底练扎实:
- 第一步:用二维字符数组写一个“输入 10 个单词,倒序输出”的小程序。
- 第二步:把它改成指针数组版本,读入时使用
strdup,程序结束前释放全部内存。 - 第三步:把排序函数封装起来,分别测试二维数组版本和指针数组版本的排序结果。
- 第四步:把字符串数组作为参数传给多个函数,观察传参写法在两种布局下的差异。
- 第五步:自己实现一个简化版
strdup:用malloc分配长度 + 1 的内存,再strcpy。做完这一步,你对“字符串数组为什么需要复制”的理解会深一层。
这条路线不复杂,但每一步都会逼迫你面对内存布局、只读区、数组退化、生命周期这些核心概念。比刷 100 道语法题有用得多。
回到开头那句话:字符串数组从来不是一个语法问题,而是一道内存管理题。你不需要一开始就记住所有规则,只需要在每次写代码前先问一句:这里存的到底是字符串本身,还是指向字符串的地址?这串内存归谁管?想清楚这两个问题,C语言的字符串数组基本就通关了。