资讯详情

C语言字符数组与字符串详解:从内存本质到常见坑点

📅 2026/10/13 22:30:03 | 华诺云谱 👁 阅读
C语言字符数组与字符串详解:从内存本质到常见坑点
大学里学C语言绕不开的一块硬骨头就是字符串。我在带学生的过程中发现很多人学到指针之前都还挺顺利一到字符数组和字符串就开始懵字符数组和字符串到底是不是一个东西为什么明明定义对了一运行就乱码为什么比较字符串用不行其实这些问题背后都指向同一个核心——你还没真正理解字符数组在内存里的样子。这篇文章我就用保姆级的方式把字符数组和字符串从头到尾掰开揉碎讲一遍配合代码、内存示意图、常见坑点和课后练习争取让你看完之后不仅会写还能明白每一行代码为什么这么写。这篇文章适合正在学C语言的大学生、准备期末考试的读者以及那些觉得自己“好像会了但一写就错”的同学。不管你现在学到哪个阶段只要跟着走一遍把示例代码亲手敲一遍基本就能把这块的知识漏洞补上。1. 为什么字符数组是C语言入门的“第一道分水岭”1.1 几乎所有程序都要处理文本而C语言没有“字符串”类型很多同学第一次接触C语言时会有个疑问明明C语言里能写printf(hello)为什么说它没有字符串类型这正是问题的起点。C语言不像其他高级语言那样提供内置的string类型它只有char字符类型一个char变量只能存一个字符比如char ch A而一串字符就需要用“字符数组”来存放。可以这样理解单个字符是一个字而字符串是一句话。C语言表达一句话的方式就是把多个字符按顺序排在一个数组里然后在末尾加一个特殊的结束标记\0空字符表示“话说到这里就完了”。这套设计继承自C语言早期的系统编程背景为了极致的底层效率和灵活性它把“字符串”的实现细节完全交给了程序员。可以说“没有字符串类型”既是C语言的缺点也是它的魅力所在——你被迫去理解数据在内存中到底是怎么存储的而这种理解恰恰是后面学指针、学数据结构的地基。1.2 帮你建立“内存视角”的第一课初学者写代码的时候往往只盯着语法对不对很少去想“数据到底存在哪里、占多大空间、怎么被读取”。字符数组是第一批真正逼迫你建立“内存视角”的知识点。举例来说定义一个数组char word[10]意味着你向系统申请了一块连续的内存空间总共能放10个char每个char占1个字节。你可以把这块空间想象成一条10个格子的储物柜每个格子有一个编号下标从0到9。当你往格子里放内容时只能从0号格子开始依次放不能跳过、不能插队。一旦你理解了数组就是“连续排列的格子”很多问题就迎刃而解为什么数组下标从0开始因为第一个格子的位置就是起始位置偏移量为0。为什么越界访问是危险的因为你走出了自己申请的格子范围可能踩到别人的储物柜。这些概念在字符数组这一章全部都会遇到而且是必须面对的。所以我说字符数组是C语言入门的“第一道分水岭”跨过去了后面的指针、内存管理、链表都会顺畅很多跨不过去越学越糊涂。2. 字符数组与字符串一字之差背后的内存实情2.1 字符数组和字符串的异同先给结论字符串是一个以\0结尾的字符数组。换句话说字符串是字符数组的一种特殊形态。这种说法听起来简单但我在实际教学中发现很多同学的错误都源于对这句话理解不透。来看两段代码char arr1[] {H, e, l, l, o}; // 字符数组没有\0 char arr2[] {H, e, l, l, o, \0}; // 字符串有\0 char arr3[] Hello; // 字符串编译器自动加\0arr1只是5个字符的普通数组不是字符串arr2和arr3是字符串。麻烦的地方在于如果你用%s去打印arr1程序会从数组首地址开始逐个输出字符直到碰见某个内存里的\0才停下来。但arr1后面紧跟着的内存是什么没人知道。可能是其他变量的数据也可能是无意义的乱码。所以printf(%s, arr1)的结果极有可能是Hello乱码乱码或者更诡异的东西。这里有一个关键认知\0不是业务数据它是“到此结束”的标志。当你需要处理文本时必须保证字符串以\0结尾否则一切以%s、strlen、strcpy为代表的字符串操作都会“读过头”导致未定义行为。这是我见过的初学者最常见的错误之一。2.2 初始化方式的底层区别初始化字符数组有几种常见写法它们的底层行为并不完全一样char s1[] hello; // 长度自动设为65个字符 1个\0 char s2[10] hello; // 数组长度10前6个元素为 h e l l o \0其余为\0 char s3[5] hello; // 错误没有空间放\0编译警告/未定义行为 char s4[] {h,e,l,l,o}; // 长度只有5没有\0 char *s5 hello; // 指向字符串字面量存储在只读区我逐个说一下。s1是最推荐的写法之一让编译器根据初始化的内容自动确定数组大小你不需要数个数也不容易出错。s2指定了长度10后面5个位置自动补\0这种写法适合你确定要预留空间做拼接或填充的场景。s3非常危险hello实际上需要6个字节你只给了5个编译器为了容纳字符串字面量会提示错误或在栈上越界写入这是典型的缓冲区问题。s4严格说不是字符串只是一堆字符。s5就要特别注意了它定义的是指针指向编译器放在只读数据区的字符串字面量试图通过s5[0] H去修改内容在绝大多数平台会直接崩溃或产生不可预料的后果。很多同学刚开始不区分s2和s5觉得都是“存了个字符串”其实一个是“自己家的可修改储物柜”一个是“公共区域的只读展品”。在后续写代码过程中如果你打算修改字符串内容请用字符数组如果只是读取、传递用指针也可以。这个区别到指针章节会进一步放大但现在就要有印象。2.3 数组长度与字符串长度别混为一谈C语言里有两个“长度”经常把人绕晕一个是数组大小能容纳多少个char一个是字符串长度不含\0的有效字符数。char str[20] hello; // 数组大小20个字节 // 字符串长度5h e l l o // sizeof(str)20 // strlen(str)5sizeof是运算符在编译期就能算出数组占用的总字节数所以sizeof(str)的结果是20。而strlen是函数它在运行期从首地址开始数直到遇到\0为止所以结果是5。不少同学在写循环时习惯用for (int i 0; i sizeof(str); i)来遍历字符串这在特定场景下并非完全错误但如果你只是想逐个处理有效字符应该用strlen(str)作为边界。反过来还有一个常见错误用strlen去判断一个字符数组有没有越界。strlen根本不知道数组的边界它只会一直往后找\0如果这个\0在数组之外它还会继续读越界内存直到碰巧遇到一个字节为0才停。这种“读到停不下来”的现象是C语言未定义行为的典型代表——程序看起来偶尔正常偶尔抽风换个编译器又不一样了。3. 字符串的输入输出从scanf到gets的坑一路排过来3.1 printf输出格式化符%s的本质输出字符串最简单的方式是printf(%s, str)但你可能没想过%s匹配的到底是什么——它匹配的是一个char *指针或者说数组名数组名在表达式中会退化为指向首元素的指针。printf拿到这个指针后就从该地址开始逐字节输出直到遇到\0。这意味着两件事第一printf(%s, str)会打印从str首地址开始直到\0之前的所有字符第二如果字符串没有\0它就会输出到数组之外表现出乱码。使用printf输出时尽量别用str的单个元素形式比如printf(%s, str[0])在语法上也能工作因为str[0]也是指向首元素的指针但可读性差得多你按正常str写就好。此外还要注意%c和%s的傻傻分不清。printf(%c, str[0])输出第一个字符printf(%s, str)输出整个字符串printf(%s, str[0])同样输出整个字符串。新手最容易在“要不要加取地址符”这个问题上纠结我告诉你结论printf里输出字符串直接用数组名输出字符用str[i]。数组名本身就是地址不要再加。3.2 scanf输入为什么用scanf(%s, str)而不是scanf(%s, str)很多教材在讲scanf输入字符串时写的是scanf(%s, str)不少同学疑惑为什么不需要加因为数组名str在表达式中会退化为指向首元素的指针类型是char *而%s需要的正是char *所以直接写str是对的。如果你写了scanf(%s, str)会变成char (*)[20]类型的指针严格说类型不匹配而且语义也变了。虽然在很多编译器上因为地址值恰好相同程序凑合能跑但这是一种错误写法。反过来在读单个字符时比如scanf( %c, ch)必须加因为你需要把值写入ch所对应的内存位置。记住一个原则scanf需要的是地址至于这个地址你是用数组名直接给还是用变量取取决于变量的类型。scanf(%s, str)还有几个隐藏的坑。第一个坑是它不能读入空格遇到空格、换行、Tab都会停止。比如输入hello world它只会读入hello剩下的world还留在缓冲区里稍不注意就成为下一个scanf的输入导致程序行为诡异。第二个坑是它不检查缓冲区大小如果输入超过数组容量会直接写到越界内存这就是传说中的“缓冲区溢出”不光程序会崩还可能被利用来执行恶意代码。虽然在本课程范围内只是考试和作业但确实不是好习惯。3.3 更安全的输入方案fgets的引入既然scanf(%s)毛病这么多那用什么实操里最常用的是fgets。char buf[100]; fgets(buf, sizeof(buf), stdin);fgets读取一行包括开头和中间的空格最多读sizeof(buf)-1个字符并自动在末尾加上\0。如果一行太长了怎么办fgets会截断把剩下的内容留在输入流里且字符串末尾没有换行符。如果读的行较短它会保留换行符\n。这就带来一个常见小麻烦你拿到手的数据末尾多了一个\n经常要手动去掉。size_t len strlen(buf); if (len 0 buf[len-1] \n) { buf[len-1] \0; }这段代码很常用建议背下来。先看末尾是不是换行符如果是就用\0覆盖它。注意strlen的结果包含这个换行符所以数组下标len-1正好是换行符的位置。有的教材还会提gets我在这里明确说不要再使用gets。它已经被标准从C11里面删除原因就是完全无法限制输入长度缓冲区溢出风险极高。如果你在看某个旧教材或网上的老代码看到gets请跳过它换成fgets。3.4 输入输出的综合示例来看一个完整的例子把前面内容串起来#include stdio.h #include string.h int main(void) { char name[50]; printf(请输入你的名字); fgets(name, sizeof(name), stdin); // 去掉末尾换行 size_t len strlen(name); if (len 0 name[len-1] \n) { name[len-1] \0; } printf(你好%s\n, name); printf(你的名字有%zu个字符。\n, strlen(name)); return 0; }注意%zu是打印size_t类型的正确格式符很多初学者用%d打印strlen的结果严格说是类型不匹配在某些平台上会输出错误值。虽然size_t本质上通常是unsigned long或unsigned long long但直接用%zu最稳妥。4. 字符串处理函数的正确打开方式这四个函数先吃透C语言标准库提供了一批字符串处理函数声明在string.h头文件中。初学阶段最常用的就是strlen、strcpy、strcmp、strcat这四个。把它们吃透作业和考试已经能应对大部分了。4.1 strlen数一下有多少个有效字符原型size_t strlen(const char *s);作用返回字符串s的有效长度即从首地址开始到第一个\0之前的字符个数不包含\0本身。它不会检查字符串指针是否合法、数组边界在哪里它只是盲目地数下去直到碰见\0。我在批改作业时见过一个很有代表性的错误有人想计算“字符串数组能存多少字符”他写了int len strlen(arr);然后以为len就是数组长度。实际上这是两码事。如果arr[20]里存了hellostrlen给出5如果要预留空间应该用sizeof(arr)也就是20。还有一个容易混淆的点strlen和sizeof对同一个字符串字面量的结果不同。char *p hello; sizeof(p); // 指针本身大小通常8字节64位系统或4字节32位系统 strlen(p); // 5因为p是指针sizeof(p)算的是指针变量的大小而不是字符串长度。这是特别容易考、也特别容易错的地方。4.2 strcpy拷贝字符串而不是赋值原型char *strcpy(char *dest, const char *src);作用把src指向的字符串包括\0复制到dest指向的数组里。注意dest必须有足够的空间来容纳src的全部内容否则会越界写入造成缓冲区溢出。一个最常见的错误是试图用等号直接赋值char a[10] hello; char b[10]; b a; // 错误在C语言里数组名是常量地址你不能给一个数组整体赋值。b a试图让b这个地址值变成a的地址这在语法上就是不成立的。正确做法是strcpy(b, a);有些同学会问那我用循环一个个复制可以吗当然可以而且理解循环复制对理解strcpy的底层很有帮助。strcpy本质上就是这么做的从src逐个取出字符写入dest直到遇到\0为止并把这个\0也复制过去。自己动手实现一遍能加深印象char dest[100]; const char *src hello; int i 0; while (src[i] ! \0) { dest[i] src[i]; i; } dest[i] \0;注意循环结束后一定要补一个\0否则dest就不是一个合法的字符串。另外strcpy返回的是dest的指针这个返回值通常被忽略但它支持链式写法比如printf(%s, strcpy(dest, src))理解即可。4.3 strcmp比较的是内容不是地址原型int strcmp(const char *s1, const char *s2);作用按字典序比较s1和s2指向的字符串返回负值表示s1小于s2返回0表示相等返回正值表示s1大于s2。很多初学者用比较字符串比如if (s1 s2) { ... }这行代码比较的是两个指针变量的值即两个地址是否相同而不是两个字符串的内容是否相同。即使两个字符串内容完全一样只要存储位置不同s1 s2就是假。在写C语言时除非你确实想比较两个指针是否指向同一个位置否则不要用比较字符串。正确写法是strcmp(s1, s2) 0表示相等。我给学生讲的时候会强调strcmp的返回值设计成负数/零/正数是为了同时支持“等于”和“排序”两种需求。如果你只想判断是否相等用 0如果你想比较大小顺序直接看返回值符号。一个常见隐患是某些库或平台可能把strcmp的返回值统一返回0或1野值但只要和0比较就没问题。最规范的做法就是只判断它与0的关系而不是判断它是否等于某个具体数字比如if (strcmp(s1, s2) 1)就不严谨应当避免。4.4 strcat拼接字符串需要空间保障原型char *strcat(char *dest, const char *src);作用把src追加到dest末尾覆盖dest原来的\0然后在拼接后的新字符串末尾再补一个\0。举例char a[20] hello; char b[5] world; strcat(a, b); printf(%s\n, a); // helloworld使用时最关键的一点是dest数组要有足够的剩余空间来容纳src的内容。假设a的大小是20而它已经有hello了有效长度5那么它还能容纳14个额外字符20-5-1减掉1是留给\0的位置。如果src比这个剩余空间还长就会发生缓冲区溢出。在我的经验里strcat是四兄弟中出问题最多的一个因为strcpy至少是覆盖而strcat是“追加”很多人没算清楚剩余容量。另一种更安全的版本是strncat它可以指定最多追加多少个字符但这属于进阶内容先知道有它就行。5. 新手最容易翻车的五个字符串错误现场这一节很重要都是我在实际批改和答疑中反复遇到的“经典现场”。每一个我都标注了错误代码、出错原因和修复方法。5.1 现场一忘记预留\0的位置char str[5] hello;表面上看hello是5个字符放到大小5的数组里刚好但编译器实际需要6个字节5个字符1个\0。所以这行代码要么产生编译警告要么在运行时触发数组越界写。正确的空间应该是6char str[6] hello;更稳妥的做法是不指定大小char str[] hello;让编译器替你算出合适的大小这是最推荐的初始化方式。如果你需要预留空间也要额外考虑\0例如计划存hello并可能追加内容那就声明成char str[30]之类的更大空间并在使用时留意实际长度。5.2 现场二字符串比较用了char password[20] admin123; if (password admin123) { printf(登录成功\n); }这段代码永远输出不了“登录成功”因为password是数组首地址admin123是另一个字符串字面量的地址两者不相等。而且编译器通常会给出警告“comparison with string literal results in unspecified behavior”。应改成if (strcmp(password, admin123) 0) { printf(登录成功\n); }这类错误在登录验证、命令解析、菜单选择等场景中特别常见。一旦你的程序出现“明明输入对了但就是进不去”的诡异现象第一个就检查是不是用了比较字符串。5.3 现场三\0和0傻傻分不清\0是字符常量ASCII码值为0表示空字符用作字符串结束标志。0是数字字符ASCII码值为48。0是无符号整数零。这三个东西在内存里的字节内容完全不同千万别混。我见过同学写这样的代码char name[20] {j, a, c, k, 0};这会导致字符串内容变成jack0而不是jack因为结尾放的是字符0而不是\0。此外有人会用str[i] ! 0来判断是不是到了末尾这种写法在语法上正确因为\0和整数0在C语言中相等但在可读性上较差建议统一写str[i] ! \0让人一看就知道你在和字符串结束标志比较。5.4 现场四修改字符串字面量char *p hello; p[0] H; // 很多平台直接崩溃字符串字面量在C标准里是只读的修改它是未定义行为。我建议的区分方法是char str[] hello;— 可修改存于栈区char *p hello;— 不可修改存于只读区如果你有修改字符串的需求比如把大写转小写、把某些字符替换务必用字符数组初始化不要用指针指向字面量。有一段代码我之前批改过很多次学生写了个函数想把字符串内容大写化但形参用的是char *s然后调用时传了字符串字面量程序跑起来直接段错误。原因就是字面量不能改。解决办法是调用前把字符串复制到可修改的数组中。char buf[100]; strcpy(buf, hello); to_upper(buf); // 函数内可以安全修改buf的内容5.5 现场五动态拼接时没算清容量来看一段反面教材char a[5] abc; char b[] defghijklmn; strcat(a, b); // 灾难现场a总共只有5个字节abc已经用了4个abc \0剩余空间只有1个字节而b有12个有效字符。strcat会继续往a后面写直接越界。修复思路有几种把a声明得足够大比如char a[50];在追加前用strlen检查剩余空间用strncat(a, b, 剩余空间-1)限制追加长度。实际开发中这种问题比你想的常见得多。我做项目时有一条习惯所有通过输入或其他函数得到的字符串在拼接、复制前先确认目标缓冲区的大小。不管函数怎么方便空间不够一切白搭。6. 保姆级综合案例用字符数组和字符串写一个“单词计数器”理论讲了这么多现在把它们揉进一个完整的例子里。我们的目标是读入一行英文文本统计其中有多少个单词。这里我假设单词之间用空格、逗号、句号等非字母字符分隔。你不用考虑特别复杂的规则重要的是把字符串遍历和字符判断用起来。6.1 核心思路先定义好“单词”的判定规则如果一个字符是字母且它前面不是字母或者是字符串开头就说明一个新的单词开始了。翻译成代码逻辑遍历字符串中的每个字符用isalpha判断当前字符是否字母记录“前一个字符是否是字母”的状态当遇到字母且前一个不是字母时count每次循环结束更新“前一个字符”的状态。这里用isalpha需要包含ctype.h头文件。如果你还没学到把它当成“判断字符是不是英文字母”的函数即可。6.2 完整代码#include stdio.h #include string.h #include ctype.h #define MAX_LINE 1024 int main(void) { char line[MAX_LINE]; printf(请输入一行英文文本\n); fgets(line, sizeof(line), stdin); // 去掉末尾换行 size_t len strlen(line); if (len 0 line[len - 1] \n) { line[len - 1] \0; } int word_count 0; int prev_is_letter 0; for (int i 0; line[i] ! \0; i) { int cur_is_letter (isalpha((unsigned char)line[i]) ! 0); if (cur_is_letter !prev_is_letter) { word_count; } prev_is_letter cur_is_letter; } printf(单词个数%d\n, word_count); return 0; }这里有几个值得说明的细节。第一个是fgets读取带来的换行符问题我在第3节专门讲过。我们在统计前先把换行符去掉不然它会被当作一个非字母字符虽然它不参与计数逻辑但会让末尾状态变得不干净。第二个是isalpha((unsigned char)line[i])。isalpha这类函数通常要求参数是unsigned char或EOF如果直接传入char在某些平台上是“有符号char”对某些非ASCII字符取值时会产生负数导致未定义行为。这里用(unsigned char)强制转换是正规写法。第三个是遍历条件line[i] ! \0。因为line是合法字符串所以用\0判断结束完全安全。如果你用i strlen(line)每轮循环都要重新计算一次strlen效率偏低。我习惯在字符串遍历时用\0判断既简洁又高效。6.3 测试与边界情况来几组测试输入输入输出说明hello world2基本情形hello1只有一个单词one,two.three3没有空格也能识别leading spaces1开头的空格不影响abc123 def2数字被当作非字母分隔符空行0没有字母0个单词这个案例虽然简单但涵盖了字符串遍历、字符判断、状态记录的完整流程。你可以试着扩展它统计字母出现频率、反转字符串、判断回文、提取所有单词并打印这些都是很好的课后练习。7. 课后练习与调试建议7.1 五道趁热打铁的练习题第一题输入一个字符串将其中所有小写字母转为大写字母大写字母转为小写字母其他字符不变。练习点是字符串遍历和修改数组内容。第二题判断输入字符串是否是回文。忽略空格和标点只比较字母部分。练习点是双指针思路和字符过滤。第三题不调用strlen自己实现一个函数计算字符串长度。练习点是认识\0的本质。第四题输入两个字符串按字典序比较大小不调用strcmp自己实现。练习点是逐字符比较的逻辑。第五题输入一句话找出其中最长单词的长度。练习点是状态记录和长度统计和案例里的单词计数器思路一致。7.2 调试字符串代码的几条实用经验第一多使用printf打印中间结果。最好在关键位置打印字符串和它的strlen值看看输入输出是否符合预期。字符串程序绝大多数问题都是“数据和我想的不一样”打印中间状态比盯着代码空想有效得多。第二注意编译器的警告。很多字符串错误在编译阶段就露出了苗头例如“format specifies type char but the argument has type char ()[20]”这类提示直接指出了scanf(%s, str)的问题。不要无视警告把它们当成线索。第三如果程序崩溃优先怀疑越界。段错误最常见的场景就是strcpy、strcat往小数组里塞了太多内容或者修改了字符串字面量。用调试器跑到崩溃位置看那个附近的变量长度和容量很快就能定位。第四不要慌着乱试。我知道有的同学一运行不对就把代码改来改去越改越乱。更好的做法是先复制一份代码注释掉可疑操作或者简化输入场景从最小用例开始排查。比如先固定输入hello确认基础流程正确再加入复杂输入。最后再分享一个小技巧如果你在一段程序里同时处理多个字符串在纸上先画出每个数组的长度、内容、\0的位置然后跟着代码走一遍大多数错误都能在一分钟内发现。字符数组这一章本质上是C语言向你展示“内存到底长什么样”的一扇窗把这扇窗推开你后面的路会越走越顺。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑