C语言字符串存取核心:字符数组、‘\0‘与指针的实战要点
发布时间:2026/10/13 22:49:38 锦皓数字建站

学习C语言的朋友十有八九会在字符串上卡一下。原因很简单C语言本身并没有原生字符串类型你要存一串字符得靠一维字符型数组硬造出来。很多教材把这块讲得特别学术一上来就讲ASCII、讲内存布局结果初学者连最基础的问题都没解决——我到底怎么把一句话存进去存进去之后又怎么取出来这篇文章就围绕这两件事展开存取字符串。我会从字符数组的声明、初始化、输入输出、常用库函数到指针纠缠把一维字符型数组这条线捋清楚也把我自己当年踩过的坑、后来给学生上课时反复强调的细节一并写出来。适合刚学C语言、对字符串存取一头雾水的读者也适合那些会写代码但说不清字符数组到底比字符串多藏了什么的老手回炉。1. 为什么C语言里没有单独的字符串类型1.1 从内存视角看字符的存储先问一个最基础的问题一个字符在计算机里长什么样字符不是字它本质上是整数。ASCII编码把每一个字符映射成一个0到127的整数比如大写字母A对应65小写字母a对应97数字字符0对应48。C语言里的char类型其实就是一个占1个字节的小整数类型它既能以%c格式打印成字符也能以%d格式打印成数值。那么问题来了一个字符占1字节人名Zhang San十几个字符怎么存总不能定义十几个独立变量吧。于是数组就上场了——把若干个char类型的元素按顺序排成一列就构成一维字符型数组。这跟int a[10]存10个整数没有任何本质区别无非是元素类型从int变成了char。关键点在这里单个字符有明确的类型和长度但一串字符在C语言里没有对应的类型。C语言的设计者选择了一个朴素的方案——用字符数组来代表字符串再用一个特殊的字符\0空字符ASCII码值为0来标记字符串的结束位置。为什么需要结束标记因为数组的长度是固定的比如char name[20]这支队伍最多能站20个字符但实际你通常不会塞满。如果不做标记程序根本不知道你存的字符串到哪儿算完。\0就是那个哨兵它告诉strlen、printf这些函数看到我就停后面就算还有空间也不是这个字符串的内容了。1.2 字符数组和字符串常量看起来像本质不同初学者最容易混淆的就是这两种写法char str1[] hello; // 字符数组 char *str2 hello; // 字符指针指向字符串常量第一行编译器会创建一个含6个元素的字符数组内容依次是h、e、l、l、o、\0。这个数组在内存里是活的你可以改str1[0] H没有任何问题。第二行str2只是一个指针它指向一个存放在静态存储区的字符串常量。那个hello是只读的如果你试图str2[0] H在很多系统上会直接崩溃或者行为未定义。我用生活化的类比解释一下字符数组就像你租了一个带家具的房间里面的陈设可以随意搬动、更换字符串常量则像美术馆里挂着的名画你可以指给朋友看指针指向它也能读出内容但绝不能上手去改。这个区别贯穿整个字符串学习的始终。很多崩溃问题根源都是拿指针当数组用或者反过来。后面我会在第5章展开细讲。2. 声明与初始化三种写法三种藏起来的细节2.1 逐个字符初始化最笨但最透明的方式char word[6] {h, e, l, l, o, \0};这种写法最直观也最啰嗦但它把一切摊开在明面上数组长度6元素依次是5个字符加1个终止符。如果你恰好写成了{h,e,l,l,o}——少写了一个\0编译器会将其余元素自动补为0也就是\0所以碰巧也能正常工作。但如果你声明的是char word[5] {h,e,l,l,o};长度刚好只够5个字符数组里就没有\0的位置了。此时若调用strlen(word)、printf(%s, word)函数会从word[0]开始一路往后遍历越过数组边界直到在内存里碰巧遇到一个0才停下来。这会产生两后果一是打印会带出一串乱码读到了数组后面的脏数据二是这个行为在标准层面属于未定义结果完全不可预测。提示判断一个字符数组能不能当字符串用就看它有没有\0。没有\0的字符数组只是字符数组不是字符串。2.2 字符串常量初始化最常用也最容易忽略长度char str[] hello; char str2[10] hello;第一种写法没有指定长度编译器自己数5个字符加1个终止符自动分配6个字节。这是最安全、最推荐的写法。第二种写法指定了10实际只用了前6个5个字符加\0剩下4个元素自动初始化为\0。多余长度会被编译器清零这部分空间后来还能用strcat之类的方式填充。但要注意如果你写的是char str2[5] hello;——这行代码根本编不过去或者至少会得到警告因为hello连同\0一共6个字节塞不进5个元素的数组。C标准规定字符串常量后面是隐藏带着\0的这个\0必须算在容量里。2.3 数组长度、字符个数与字符串长度的三角关系我直接给出一张对照表这是这块最实用的总结写法数组元素个数字符串有效字符数能正常调用strlen吗char s[] abc43能返回3char s[4] abc43能返回3char s[10] abc103能返回3char s[3] {a,b,c}3未定义不建议无终止符char *p abc指针1个3能但字符串只读这里最反直觉的点在于数组的容量和字符串的长度是两个量。容量看的是声明时的方括号数字字符串长度看的是\0之前有多少有效字符。用sizeof(s)除以sizeof(char)得到的是容量不是字符串长度——很多人在这里栽过跟头。char s[] hello; printf(%zu\n, sizeof(s)); // 输出6整个数组的字节数 printf(%zu\n, strlen(s)); // 输出5字符串的长度如果你的代码里出现sizeof(str)和strlen(str)混用还不分彼此那程序大概率会有隐蔽的bug。3. 输入输出存取字符串的真正入口存字符串光在代码里写死还不够更多时候要面对运行时输入。输入输出这块的坑是所有初学阶段程序崩溃、输出乱码的最大来源。3.1 scanf与%s它用空格断句你被空格断案scanf(%s, str)是很多人接触的第一个字符串输入方式。理解它要记住一句话%s按非空白字符序列来读取遇到空格、制表符、换行就停止。所以输入hello worldscanf只会把hello存进str剩下的world还留在输入缓冲区里。这是新手最经典的困惑点之一明明输入了一整句话怎么只存进了半个如果你后面又紧跟一个scanf(%s, str2)它会接着读走world看起来下一个变量自动获得了输入其实不过是缓冲区在作祟。另外提醒一点代码里写scanf(%s, str)时str前面不需要加。因为数组名在表达式中代表首元素地址本身就是指针类型加反而变成指向数组的指针类型对不上。这个错误很隐蔽因为有些编译器只是给个警告程序甚至还能跑出正确结果让初学者更加迷惑。3.2 gets与fgets安全与便利的取舍gets函数能读取一整行包括空格看起来比scanf方便多了gets(str); // 从键盘读一行存入str但gets是C标准里明确标注有安全缺陷的函数它不检查目标数组有多大如果用户输入了远超容量的数据gets会把多余内容一路写到数组后面的内存里这叫缓冲区溢出。教材里拿gets演示读取一行非常顺手但实际工程中它早已被废弃。替代方案是fgetschar str[50]; fgets(str, 50, stdin);fgets最多读取49个字符最后一个位置留给\0如果这一行内容没读完剩下的会留在缓冲区。它读到的字符串末尾可能带着一个换行符\n比gets多出的这个换行经常让新手莫名其妙——明明输入了abc怎么字符串长度是4这里需要手动处理size_t len strlen(str); if (len 0 str[len - 1] \n) { str[len - 1] \0; }我给学生讲这段时常说一句话fgets把换行符当成用户真的按下了回车这个事实记录下来而gets假装没这回事。宁可多一步清理也不要让程序留有溢出隐患。3.3 输出端的puts与printf差别输出相对简单但仍有三个容易忽略的细节。printf(%s\n, str); puts(str);输出段位的第一个细节puts在输出字符串后自动补一个换行printf不会。第二个细节printf的%s要求传入一个以\0结尾的地址如果传入的字符数组没有终止符printf会一路打印到天荒地老直到遇到一个值为0的字节。第三个细节puts遇到\0会停止输出但它依然不检查参数是否为NULL指针——传入NULL时大多数实现会崩溃。如果只是简单输出一行我一般推荐puts如果还要格式化拼接其他内容用printf。这不是什么高深原则纯粹是代码可读性问题。4. 核心库函数strlen、strcpy、strcmp、strcat的原理与副作用存取字符串绕不开标准库的字符串处理函数。很多初学者把它们当黑盒子用但不了解内部实现逻辑就永远无法理解为什么有些代码偶尔正常、偶尔崩。4.1 strlen数的是\0前面的字符不是容量strlen的实现逻辑极其朴素从起始地址一个字符一个字符向后扫计数器累加直到碰到\0为止。这意味着两件事字符串必须真的以\0结尾否则strlen会越过数组边界继续扫描返回一个离谱的大数字。strlen算的是用到多少不是能存多少。不要在同一个数组上同时以sizeof和strlen来规划写入量容易造成容量误判。配合缓冲区计算场景时正确姿势是char buf[100]; // 执行到这里buf中已存有一个字符串 size_t used strlen(buf); // 当前已占用 size_t remaining sizeof(buf) - used - 1; // 剩余可用留1位给\0注意那个-1这是很多人写缓冲代码时漏掉的神坑数组最后一个元素必须留给\0你不能把100字节全当成有效内容。4.2 strcpy与strcat它们不做任何安全检查char dst[10]; strcpy(dst, a very very long string);这段代码在绝大多数系统中会正常运行完但已经把a very very long string连带着\0覆盖到了dst数组边界之外的内存。这就像是把一车货硬塞进一个明显装不下的小仓库多出来的货只能堆到隔壁仓库里。程序暂时没报错只是因为还没人发现隔壁仓库的货被换了。strcat同理它执行的是先找到目标串的\0然后从那里开始追加源串。它并不知道目标数组还剩多少空间一旦源串过长溢出必然发生。危险在于这种溢出不一定会立刻导致崩溃而是埋下一颗定时炸弹可能在程序运行很久之后、在你完全想不到的代码路径上炸开。我在自己的项目里凡是string.h能换安全版本的一律换掉。比如strncpy、strncat、snprintf这些带长度限制的版本即便它们用起来麻烦一点、语义上有些微妙差异也远比裸用strcpy、strcat稳妥。4.3 strcmp的比较规则不是比长度是比字典序strcmp比较两个字符串不是比谁长而是从头开始逐字符按ASCII码值比较。遇到第一对不同值字符时谁的值小谁排在前面。strcmp(abc, abd); // 返回负值因为c(99) d(100) strcmp(apple, banana); // 返回负值a b strcmp(abc, abc); // 返回0两串相等 strcmp(abc, abcd); // 返回负值因为较短的串遇到\0(0)而\0小于d(100)注意strcmp返回的不是-1或1这种固定值而是一个小于0、等于0或大于0的整数。很多新手喜欢写if (strcmp(a, b) 1)这在逻辑上是错的——正确写法是判定返回值是否等于0if (strcmp(a, b) 0) { // 两字符串相等 }另外strcmp比较的是内容不是地址。如果是两个内容相同的字符串字面量直接用比较指针比较的是它们的内存地址结果完全取决于编译器是否做了常量合并属于不可依赖的行为。判断字符串相等永远用strcmp。5. 字符数组与字符指针一场纠缠不清的关系5.1 指针指向字符串常量时的只读属性前面已经简单提过这里展开说。char *p hello; p[0] H; // 危险修改只读常量未定义行为在绝大多数现代平台上字符串常量放在只读数据区p[0] H会导致段错误。但可怕的是在某些老旧编译器或特殊选项下它可能碰巧不崩溃这让很多人误以为可以这样写。我自己就见过一份代码在A机器上跑得好好的换到B机器上就崩查了一整天才定位到这行。所以记住一个硬性标准如果你需要对字符串内容做修改必须用字符数组来存储不能用指向字符串常量的指针。需要修改就写成char str[] hello;这里的hello会被拷贝到栈上或静态区是可读写的。5.2 常见的指针与数组混用场景函数参数是一个高频混淆场景。看这个函数void printStr(char *s) { printf(%s\n, s); }你既能传char str[]给它也能传char *p给它甚至能直接传字符串常量hello。因为数组名在函数调用时退化为指向首元素的指针。这是C语言的统一设计传数组进函数本质上传的是地址函数内部根本不知道数组有多长。所以在函数内部千万不要对参数用sizeof想获取数组容量。比如void foo(char s[]) { printf(%zu\n, sizeof(s)); // 输出8或4指针大小不是数组大小 }不管调用方传入多大的数组这里输出的都是指针变量本身的字节数。获取数组容量必须在数组还活着的作用域里做一旦传进函数容量信息就丢了。解决方法是额外传一个长度参数。这也是为什么fgets的重载设计里必须带上size参数——库函数根本无法从指针得知缓冲区容量。5.3 二维字符数组与指针数组存多串字符串的两种思路顺带讲一个进阶问题如果要存多个字符串比如一组姓名怎么办第一种方案二维字符数组每一行是一个字符串char names[3][20] { Alice, Bob, Charlie };这种方案内存连续每一行固定20字节总共占用60字节。缺点是空间可能浪费——Bob只有3个字符却占了20字节优点是内存紧凑规整适合排序、交换等操作。第二种方案指针数组每个元素指向一个字符串常量char *names[] { Alice, Bob, Charlie };这种方案更节省空间字符串常量按实际长度存放但每个字符串是只读的不能修改。如果你需要修改某个姓名就得用第一种二维数组方案。实际项目中我倾向于这样选型字符串内容需要频繁修改用二维字符数组字符串内容基本固定、只做查找和输出用指针数组。两者也可以混合例如用指针数组指向一个二维字符数组的每一行既有可修改的能力又方便做排序时只交换指针不搬动字符串内容。char data[3][20] {Alice, Bob, Charlie}; char *order[3] {data[0], data[1], data[2]}; // 排序order数组时只需交换指针data内容不动这个技巧在字符串数量多、单串很长时能省下大量的内存拷贝开销。6. 实测过的坑与排查思路从崩溃到修复的完整链路技术点讲完我挑两个真实发生过的案例复盘一下当时的排查过程因为这种崩溃但不知道哪行代码导致的经验比概念本身更有价值。6.1 案例一字符串没存进去输出却正常某次帮同学调试一个通讯录程序他的代码大概是char name[20]; scanf(%s, name); printf(%s\n, name);scanf那行多了个。在部分编译器和平台上name与name的值相同都是数组起始地址只是类型不同所以程序运行结果碰巧正确。但这条代码在标准层面是类型错误换一个编译器或者开启更强警告级别就可能报warning甚至直接行为异常。排查路径是这样的程序能编译、能运行输出也正常但代码评审发现warning。不要因为能跑就忽略类型层面的问题。数组名本身就是地址不是变量取它的地址得到的类型是指向数组的指针与scanf期望的指向char的指针不兼容。凡是scanf中看到数组名一律不要加。你不需要取数组的地址因为数组名已经是地址了。6.2 案例二fgets读进来的字符串长度总是多1另一个高频现场用户输入hello然后程序用strlen测量结果返回6而不是5。这是fgets把换行符也存进来了。场景如图你在键盘上敲hi然后回车输入缓冲区里其实是h、i、\n三个字符fgets忠实记录strlen自然就数到3。解决方式前面已经给了把末尾的\n替换为\0。但要注意一个边界如果用户输入的字符数逼近缓冲区上限比如数组大小是10用户敲了9个字符加回车fgets会读走9个字符最后一个位置存\0换行符留在缓冲区里。此时字符串末尾没有\n你按上面的方法检查str[len-1]是否为\n会得到否定的结果属于正常现象不要误判为读取失败。6.3 案例三字符串统计程序里的容量预判写一个统计一串字符中各个字母出现次数的程序最容易犯的错误是这样char line[100]; fgets(line, 100, stdin); int count[26] {0}; for (int i 0; i 100; i) { // 错误循环到100 if (line[i] a line[i] z) { count[line[i] - a]; } }问题在于循环上限用了100而数组实际有效内容只有strlen(line)那么长后面的元素都是\0。虽然这样循环也不会越界数组就是100个元素但\0在if判断里不会命中字母条件所以结果碰巧正确。从代码逻辑上看它统计的是100个元素而不是字符串内容。正确的写法是用strlen(line)作为循环上限或者直接遍历到\0for (int i 0; line[i] ! \0; i) { // 处理line[i] }这种写法的好处是不依赖固定长度也天然避开了数组容量和字符串长度混淆的问题。遍历字符串第一选择永远是判断\0而不是数到某个固定数字。7. 存字符串前先问自己四个问题最后把实践层面的经验浓缩成四个问题。每次要写一段处理字符串的代码我都建议先自问一遍这能挡掉绝大多数隐蔽bug。第一这个字符串存哪里需要修改内容就选字符数组只读引用就用字符指针。不要试图去修改一个字符串常量那不是你的地盘。第二缓冲区多大给\0留位置了吗char buf[10]实际可容纳的有效字符是9个只有把10和9之间的关系想清楚了才能在strcpy、strcat、snprintf这些操作里合理规划空间。第三输入函数选对了吗需要用scanf按词读取、用fgets按行读取还是自定义逐字符读取明确需求再选函数不要因为别人都这么写就照搬。第四函数返回值和边界条件检查了吗strlen可能越界strcpy可能溢出strcmp的结果不是一个固定值fgets可能失败返回NULL。每一处都问一句如果输入不是我想象的那样程序会不会崩比事后调试高效得多。我实际写字符串相关代码时还有个习惯凡是涉及数组下标操作的循环都会特别留意最后一个位置是否是\0。这个习惯帮我挡掉了不少线上问题也希望你能尽早养成。字符串这块知识看起来零散其实主线就一条一切围绕\0展开。存储要留它的位置输入要保证它能被正确写入输出要依赖它来终止函数要利用它来判断边界。把\0当成字符串世界的句号理解起来就顺了。下次再遇到字符串相关的诡异崩溃先别急着怀疑编译器去检查你的数组里有没有那个句号。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。