C语言字符串数组详解:内存模型与两种主流写法对比
发布时间:2026/9/1 19:58:35来源:尧图网络
C语言字符串数组这一篇把内存模型和常见写法讲透字符串数组是 C 语言里绕不开的一个知识点。很多初学者卡在“字符数组”和“字符串数组”的区别上又分不清char a[10][20]和char *a[10]到底谁适合哪种场景。这次就把字符串数组拆开讲清楚怎么定义、怎么初始化、怎么遍历、怎么排序以及最常见的几个坑在哪。这篇文章适合正在学 C 语言指针和数组的读者也适合准备期末考试或 GESP、CSP 之类竞赛的同学。看完之后你应该能区分两种字符串数组的内存布局能写代码完成字符串排序、查找也知道为什么有些写法会直接段错误。1. 核心概念C 语言里的“字符串数组”到底指什么在 C 语言中字符串本身就是一个以\0结尾的字符数组。所以“字符串数组”本质上是一个二维结构只是有两种常见实现方式定义方式类型特点典型场景char str[5][20]二维字符数组每行固定长度内存连续字符串可原地修改数量固定、长度可控的字符串集合char *str[5]字符指针数组数组里存放指向字符串的指针内存开销小定义灵活字符串数量固定但长度变化较大const char *str[5]指向字符串字面量的指针数组只能读不能写适合存储固定提示语、菜单项只读字符串表char **str指针的指针二级指针通常用于动态分配和函数传参动态字符串列表、命令行参数这里先记住一个最关键的结论到底选二维数组还是指针数组取决于“字符串要不要修改”和“长度是否固定”。2. 两类主流写法的内存模型2.1 二维字符数组连续内存每行固定char names[3][16] { Tom, Jerry, Spike };这段代码在内存中申请了一块连续空间3 行 × 16 字节共 48 字节。每行都固定占用 16 字节即使 Tom 只需要 4 字节剩下的 12 字节也会被占用其中\0之后的内容是未初始化的零值。这种方式的优点是编译器在编译时就能确定总大小。可以原地修改names[0][0]这种元素。传给函数时参数类型是char (*)[16]边界清晰。缺点是如果字符串长度参差不齐浪费空间。如果某一行超过 15 个有效字符就会放不下\0超过部分被截断或溢出。2.2 字符指针数组只存指针字符串另放char *names[3] { Tom, Jerry, Spike };这里names数组只存放 3 个char *指针每个指针 8 字节64 位系统总共 24 字节。字符串本身存在只读数据区数组里的指针指向对应字符串首地址。这种写法的关键点在于字符串字面量不一定能修改。names[0][0] t在多数平台上会触发段错误因为字面量可能被放在只读常量区。不同编译器、不同平台行为不同但标准 C 明确规定修改字符串字面量是未定义行为。适合存放长度差异很大的字符串因为内存按需分配不浪费固定空间。适合把字符串作为整体传递、排序。排序时可以只交换指针不需要复制整段字符数据。2.3 一个典型对比表格操作char a[3][16]char *a[3]存储模型连续二维数组指针数组 独立字符串常量单行最大长度固定 15 字符可用取决于字面量长度排序交换成本需要逐字节拷贝只交换指针速度快修改字符串内容可以指向字面量时不可动态读入长字符串容易越界需要先分配空间函数形参char a[][16]或char (*a)[16]char **a或char *a[]初学者最容易犯的错是把第二种写法当作第一种来修改字符串。记住原则如果字符串是在运行时要被修改的优先用二维数组或动态分配的内存如果只是只读集合用指针数组更省空间、更高效。3. 字符串数组的定义与多种初始化方式3.1 二维数组初始化#include stdio.h int main() { // 方式一直接初始化 char week[7][10] { Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday }; // 方式二省略第一维大小由编译器数出来 char week2[][10] { Monday, Tuesday, Wednesday }; // 方式三不写大小全部由初始化决定 char week3[][10] {Mon, Tue}; printf(%zu\n, sizeof(week) / sizeof(week[0])); // 7 printf(%zu\n, sizeof(week2) / sizeof(week2[0])); // 3 printf(%s\n, week[0]); return 0; }需要特别注意的是sizeof(week) / sizeof(week[0])这种方法只适用于数组本身不能用于函数形参。因为形参传进去后已经退化成指针sizeof结果是指针大小。3.2 指针数组初始化#include stdio.h int main() { // 指向字符串字面量 const char *colors[] {Red, Green, Blue, Yellow}; // 指向可修改的字符数组 char c1[] Red; char c2[] Green; char *colors2[] {c1, c2}; // 动态分配 char *list[3]; list[0] (char *)malloc(20); list[1] (char *)malloc(20); list[2] (char *)malloc(20); for (int i 0; i 3; i) { sprintf(list[i], item-%d, i); } for (int i 0; i 3; i) { puts(list[i]); free(list[i]); } return 0; }这里可以清楚看到指针数组的灵活性每个元素可以来自常量区也可以来自栈上的字符数组还可以来自堆上的动态内存。前提是你必须知道自己管理的是哪一类内存否则free的时候会出错。3.3 运行时逐行输入字符串竞赛或者实际开发中经常需要从标准输入读入多行字符串存在数组里。推荐两种写法。写法一二维数组适合行数和行长都已知。#include stdio.h int main() { char lines[5][128]; int n 0; printf(请输入行数); scanf(%d, n); // 读取缓冲区中残留的换行符 getchar(); for (int i 0; i n; i) { // fgets 会保留换行符这里手动去掉 fgets(lines[i], sizeof(lines[i]), stdin); size_t len strlen(lines[i]); if (len 0 lines[i][len - 1] \n) { lines[i][len - 1] \0; } } for (int i 0; i n; i) { printf(第%d行%s\n, i 1, lines[i]); } return 0; }写法二指针数组 动态分配适合行长不确定、需要节省内存。#include stdio.h #include stdlib.h #include string.h int main() { int n 3; char *lines[3]; char buffer[256]; for (int i 0; i n; i) { fgets(buffer, sizeof(buffer), stdin); buffer[strcspn(buffer, \n)] \0; lines[i] (char *)malloc(strlen(buffer) 1); strcpy(lines[i], buffer); } for (int i 0; i n; i) { puts(lines[i]); free(lines[i]); } return 0; }strcspn(buffer, \n)是一个比较精简的写法作用是找到第一个换行符的位置然后替换成\0。这个函数在字符串处理中很常用比手动遍历更简洁。4. 字符串数组的遍历与访问4.1 二维数组的遍历#include stdio.h int main() { char names[3][16] {Tom, Jerry, Spike}; // 按行遍历注意每行是字符串用 %s 输出 for (int i 0; i 3; i) { printf(%s\n, names[i]); } // 按字符逐个遍历输出每个字符的 ASCII for (int i 0; i 3; i) { for (int j 0; names[i][j] ! \0; j) { printf(%c , names[i][j]); } printf(\n); } return 0; }二维数组的遍历比较直观names[i]得到第 i 行字符串的首地址names[i][j]得到具体字符。4.2 指针数组的遍历#include stdio.h int main() { const char *fruits[] {apple, banana, cherry, date}; // 方式一下标遍历 for (int i 0; i 4; i) { printf(%s\n, fruits[i]); } // 方式二指针自增遍历 const char **p fruits; for (int i 0; i 4; i) { printf(%s\n, *p); p; } // 方式三如果数组以 NULL 结尾可以用 NULL 判断 const char *args[] {-h, --version, NULL}; for (int i 0; args[i] ! NULL; i) { printf(%s\n, args[i]); } return 0; }第三种写法在命令行参数处理里非常常见。以NULL作为结束标记可以避免维护额外的长度变量。4.3 用 sizeof 计算数组长度的注意事项#include stdio.h void print_size(char arr[][20], int rows) { // 形参已退化不能这样算行数 // printf(%zu\n, sizeof(arr) / sizeof(arr[0])); printf(函数内 rows %d\n, rows); } int main() { char arr[3][20] {a, bb, ccc}; printf(数组总大小%zu\n, sizeof(arr)); printf(每行大小%zu\n, sizeof(arr[0])); printf(行数%zu\n, sizeof(arr) / sizeof(arr[0])); print_size(arr, 3); return 0; }这是 C 语言数组和指针最容易混淆的地方。数组作为参数传入函数时第一维会退化成指针sizeof(arr)在函数内部得到的是指针大小不是整个数组大小。因此要在函数外算好行数或者使用NULL结尾的约定。5. 修改字符串数组中的内容5.1 修改二维数组中的字符串#include stdio.h #include string.h int main() { char colors[3][16] {Red, Green, Blue}; // 直接改写单个字符 colors[0][0] r; printf(%s\n, colors[0]); // red // 用 strcpy 整体覆盖 strcpy(colors[1], Dark Green); printf(%s\n, colors[1]); // Dark Green // 只要不超过行容量都可以写入 strcpy(colors[2], DeepSkyBlue); printf(%s\n, colors[2]); return 0; }二维数组“可修改”的本质在于每一行都是存放在栈或全局区的字符数组有独立的可写空间。只要strcpy的长度不超过行大小 - 1就不会越界。5.2 修改指针数组中的字符串#include stdio.h #include stdlib.h #include string.h int main() { char *names[2]; // 动态分配可修改 names[0] (char *)malloc(32); names[1] (char *)malloc(32); strcpy(names[0], Alice); strcpy(names[1], Bob); strcpy(names[0], Alice Smith); printf(%s\n, names[0]); free(names[0]); free(names[1]); return 0; }如果names[0]直接指向字符串字面量比如char *names[2] {Alice, Bob};那么strcpy(names[0], Alice Smith)就是未定义行为。常见结果是段错误因为目标地址在只读区。更稳妥的判断是只要是运行时要修改的字符串要么用二维数组要么用 malloc 分配内存。5.3 排序时交换指针与交换数组的区别假设有一个指针数组需要按字符串顺序排序。交换两个元素时只需要交换指针#include stdio.h #include string.h int main() { const char *words[] {pear, apple, orange, banana}; int n 4; // 选择排序只交换指针 for (int i 0; i n - 1; i) { int min_idx i; for (int j i 1; j n; j) { if (strcmp(words[j], words[min_idx]) 0) { min_idx j; } } if (min_idx ! i) { const char *tmp words[i]; words[i] words[min_idx]; words[min_idx] tmp; } } for (int i 0; i n; i) { printf(%s\n, words[i]); } return 0; }如果换成二维数组交换就需要strcpy到临时数组再互相拷贝成本高很多。6. 字符串数组的常见操作示例6.1 查找指定字符串#include stdio.h #include string.h int find_string(const char *arr[], int n, const char *target) { for (int i 0; i n; i) { if (strcmp(arr[i], target) 0) { return i; } } return -1; } int main() { const char *cities[] {Beijing, Shanghai, Guangzhou, Shenzhen}; int idx find_string(cities, 4, Guangzhou); if (idx ! -1) { printf(找到下标为%d\n, idx); } else { printf(未找到\n); } return 0; }6.2 按字母顺序排序并输出#include stdio.h #include stdlib.h #include string.h int cmp_str(const void *a, const void *b) { // a 和 b 是 const char ** 类型 return strcmp(*(const char **)a, *(const char **)b); } int main() { const char *subjects[] {math, english, physics, chemistry, biology}; int n 5; qsort(subjects, n, sizeof(char *), cmp_str); for (int i 0; i n; i) { printf(%s\n, subjects[i]); } return 0; }qsort的比较函数比较的是“数组元素”而这里数组元素是char *指针。所以a、b实际上是被qsort传进来的指向指针的指针先转成const char **再解引用得到const char *才能交给strcmp。6.3 统计每个字符串长度并找出最长#include stdio.h #include string.h int main() { char words[5][32] {cat, elephant, dog, hippopotamus, bird}; int max_len 0; char *longest NULL; for (int i 0; i 5; i) { int len strlen(words[i]); if (len max_len) { max_len len; longest words[i]; } } printf(最长字符串%s长度%d\n, longest, max_len); return 0; }7. 字符串数组作为函数参数7.1 二维数组传参#include stdio.h // 必须指定第二维大小 void print_names(char names[][32], int rows) { for (int i 0; i rows; i) { printf(%s\n, names[i]); } } // 等价写法指针数组形式 void print_names_2(char (*names)[32], int rows) { for (int i 0; i rows; i) { printf(%s\n, names[i]); } } int main() { char names[3][32] {Alice, Bob, Charlie}; print_names(names, 3); print_names_2(names, 3); return 0; }二维数组传参有一个硬性要求从第二维开始的所有维度必须写明。这是因为编译器需要知道每一行跨多少字节才能计算names[i]的地址。7.2 指针数组传参#include stdio.h void print_list(char **list, int n) { for (int i 0; i n; i) { printf(%s\n, list[i]); } } void print_list_2(char *list[], int n) { for (int i 0; i n; i) { printf(%s\n, list[i]); } } int main() { char *arr[] {one, two, three}; print_list(arr, 3); print_list_2(arr, 3); return 0; }这里char **和char *[]在函数形参中是等价的。从调用者角度看传入的是一个指针数组数组名退化之后就是char **。7.3 二级指针作为输出参数#include stdio.h #include stdlib.h #include string.h void create_strings(char ***result, int n) { *result (char **)malloc(n * sizeof(char *)); for (int i 0; i n; i) { (*result)[i] (char *)malloc(32); sprintf((*result)[i], string-%d, i); } } int main() { char **list NULL; int n 3; create_strings(list, n); for (int i 0; i n; i) { printf(%s\n, list[i]); free(list[i]); } free(list); return 0; }这种写法在封装字符串列表工具函数时很常见。调用方只声明一个char **通过list传地址函数内部分配内存并填充。8. 字符串数组最常踩的坑8.1 修改字符串字面量char *p hello; p[0] H; // 未定义行为常见段错误正确做法用数组保存或在堆上分配空间。8.2 对未初始化的指针 strcpychar *p; strcpy(p, hello); // 错误p 指向未知地址正确做法先分配空间或者改用snprintf、动态内存。8.3 fgets 保留换行符使用fgets读入字符串时换行符会留在缓冲区里导致字符串后面多出一个\n。常见处理buffer[strcspn(buffer, \n)] \0;8.4 越界写入char names[3][10] {123, 456, 789}; strcpy(names[0], This is a very long string); // 溢出names[0]最多容纳 9 个字符加一个\0。超过就会写入到相邻行的内存导致难以排查的内存破坏。建议使用snprintf或strncpy并且始终预留结束符位置。8.5 混淆指针数组行长度const char *p[] {abc, def}; // 错误sizeof(p[0]) 是指针大小不是abc的长度 printf(%zu\n, sizeof(p[0])); // 正确用 strlen 获取字符串长度 printf(%zu\n, strlen(p[0]));8.6 在函数内部错误计算数组长度void func(char *arr[]) { // sizeof(arr) 8不是数组总大小 }解决办法是让调用方传入元素个数或者在数组末尾放NULL哨兵元素。9. 字符串数组的应用场景命令行参数与配置表9.1 main 函数的 argvC 语言程序的main(int argc, char *argv[])本质上就是一个字符串数组。argv[0]是程序名argv[1]到argv[argc-1]是命令行参数。#include stdio.h int main(int argc, char *argv[]) { for (int i 0; i argc; i) { printf(参数 %d%s\n, i, argv[i]); } return 0; }编译运行gcc test.c -o test ./test hello world 123输出参数 0./test 参数 1hello 参数 2world 参数 3123理解字符串数组之后再看argv的传参方式就非常清晰它是一个指针数组每个元素是char *指向一个以\0结尾的字符串。9.2 只读配置表#include stdio.h typedef struct { const char *key; const char *value; } ConfigItem; ConfigItem config[] { {host, 127.0.0.1}, {port, 8080}, {debug, true}, {NULL, NULL} }; const char *get_config(const char *key) { for (int i 0; config[i].key ! NULL; i) { if (strcmp(config[i].key, key) 0) { return config[i].value; } } return NULL; } int main() { const char *port get_config(port); if (port) { printf(port %s\n, port); } return 0; }用字符串数组 哨兵元素组织配置表代码非常干净适合入门项目练习。10. 两道经典练习题的拆解思路很多刷题平台上有字符串数组的练习题比如输入若干单词、按字典序输出。这类题目核心考察点就是二维数组和指针数组的选择。下面给一个通用练习题模板理解后可以套到多数题目上。题目描述第一行输入一个整数 n接下来 n 行每行一个字符串按字典序升序输出。#include stdio.h #include stdlib.h #include string.h #define MAX_LEN 101 #define MAX_N 1000 int main() { int n; char words[MAX_N][MAX_LEN]; scanf(%d, n); getchar(); for (int i 0; i n; i) { fgets(words[i], MAX_LEN, stdin); words[i][strcspn(words[i], \n)] \0; } // 简单选择排序 for (int i 0; i n - 1; i) { int min_idx i; for (int j i 1; j n; j) { if (strcmp(words[j], words[min_idx]) 0) { min_idx j; } } if (min_idx ! i) { char tmp[MAX_LEN]; strcpy(tmp, words[i]); strcpy(words[i], words[min_idx]); strcpy(words[min_idx], tmp); } } for (int i 0; i n; i) { puts(words[i]); } return 0; }如果改成指针数组版本交换时只需要交换指针#include stdio.h #include stdlib.h #include string.h #define MAX_LEN 101 #define MAX_N 1000 int main() { int n; char *words[MAX_N]; char buffer[MAX_LEN]; scanf(%d, n); getchar(); for (int i 0; i n; i) { fgets(buffer, MAX_LEN, stdin); buffer[strcspn(buffer, \n)] \0; words[i] (char *)malloc(strlen(buffer) 1); strcpy(words[i], buffer); } // 只交换指针 for (int i 0; i n - 1; i) { int min_idx i; for (int j i 1; j n; j) { if (strcmp(words[j], words[min_idx]) 0) { min_idx j; } } if (min_idx ! i) { char *tmp words[i]; words[i] words[min_idx]; words[min_idx] tmp; } } for (int i 0; i n; i) { puts(words[i]); free(words[i]); } return 0; }两种方案都能通过。二维数组版更简单但是行数上限和行长受限指针数组版内存利用率高但需要手动管理释放。刷题时优先选择二维数组够用且不容易内存泄漏。实际工程中如果数据量很大再考虑指针数组或链表。11. 常见错误排查清单问题现象可能原因排查方法解决方案段错误修改了字符串字面量检查是否有p[0] ...改为字符数组或动态内存段错误指针未初始化就strcpy检查目标指针是否为 NULL 或野指针先 malloc 再复制输出乱码fgets保留了换行符打印字符串长度或逐字符输出用strcspn去掉换行符死循环用scanf读完数字后未处理换行符检查输入缓冲加一句getchar()程序崩溃字符串越界写入用AddressSanitizer或检查strcpy的长度换成strncpy、snprintf排序结果不对qsort比较函数写错类型打印比较函数的参数内容转成const char **再解引用无法编过形参和实参数组维度不一致检查函数声明第二维大小必须匹配内存泄漏malloc 后没有 free用 valgrind 检测每个 malloc 对应一个 free12. 最佳实践建议第一默认优先使用char arr[][MAX_LEN]。大多数入门和竞赛场景字符串数量和单行长度都是有限的二维数组简单、直观、不容易出错。第二如果字符串长度差异大、数量多再考虑char *arr[]和动态分配。换排序算法、插入删除时指针数组更高效但必须时刻记得释放内存。第三写代码时习惯性留下边界余量。比如最大字符串长度是 100数组长度就写成 128 或者 101 1防止忘记\0导致越界。第四能用fgets就不要用scanf(%s)。scanf(%s)无法限制长度容易缓冲区溢出fgets有上限配合strcspn去掉换行符。这是最稳定的标准输入读取方式。第五涉及字符串复制时优先用snprintf或strncpy并手动保证结束符。char buf[32]; snprintf(buf, sizeof(buf), %s, source);第六函数传参时如果参数是字符串数组最好把数组长度也传进去不要在函数内部用sizeof计算。第七修改完代码记得用编译选项排查问题gcc -Wall -Wextra -g test.c -o test如果怀疑内存越界可以用-fsanitizeaddressgcc -Wall -Wextra -fsanitizeaddress -g test.c -o test第八不要忘记释放动态分配的内存。可以按模块组织代码一个模块负责创建字符串列表另一个模块负责销毁尽量保证每个 malloc 都有配对的 free。13. 总结字符串数组不是一个孤立的知识点它把数组、指针、字符串处理函数和内存管理串在了一起。理解二维字符数组和指针数组的区别就等于理解了 C 语言中“静态存储”和“动态引用”两种思路的差别。建议动手做三件事用两种方式定义字符串数组分别完成一次按字典序排序。写一个函数接收char **遍历并统计字符串总长度。模拟main(int argc, char *argv[])的传参方式自己封装一个可复用的参数解析小函数。上面这些代码可以放到同一个工程里反复编译、修改、测试。字符串数组这块通了后面的链表、树、哈希表都会轻松不少因为核心还是那套“数据在内存里怎么放、指针怎么指”的底子。
网站建设高端定制企业官网