
写字符串函数这个题目说实话我第一个想到的不是API怎么用而是前两天帮人排查一个诡异的线上崩溃。程序跑着跑着就段错误排查了半天最后定位到是一句strtok把一个只读字符串的字面量给拆了。这个错很典型也很有意思——恰恰说明C语言的字符串函数看似简单实际上处处是坑。从strlen一路数到strtok其实不止是背接口签名的事更重要的是搞清楚每个函数背后的内存模型、边界条件和隐含约束。这篇东西我就从头到尾捋一遍把常用字符串函数的核心机制、典型陷阱和使用心得一次性讲透。1. 字符与字符串的基础认知1.1 字符到底是啥字符串又到底是啥刚接触C语言的人往往搞不清a和a有什么区别。这个区分如果不彻底后面学字符串函数会一直迷迷糊糊。先说字符。在C语言里字符本质就是一个整数类型是char占一个字节8位。你写a编译器把它当成整数值97ASCII码。同理0不是数字0而是整数值48。所以你会发现如果直接printf(%d, 0)输出的是48而不是0。这就是为什么字符和数字之间转换时要c - 0而不是c - 0。再说字符串。C语言没有专门的字符串数据类型字符串是“字符数组 结尾标志”的组合。所谓结尾标志就是空字符\0整数值为0的那个字符。比如你写char s[] hello编译器实际在内存里帮你放的是6个字节h e l l o \0最后一个\0就是终止符告诉函数“到这里字符串就结束了”。这里有一个关键点hello这个字符串字面量在C语言里类型是char[6]而且在大多数编译器里它存放在只读存储区。如果代码里试图修改它比如hello[0] H很多平台直接崩溃这在后面讲strtok的时候会再次踩到。字符串还有一个容易混淆的概念字符串常量和字符数组。char *p hello和char arr[] hello有本质区别。前者p只是指向只读字面量的指针字节内容不能改后者是在栈上拷贝了一份可写的数组。很多初学者就是因为这个区别闹出“改不动字符串”的问题。1.2 字符串为什么非要带个\0结尾很多现代语言比如Java、Python字符串对象里会直接存一个length字段知道这个东西有多长不需要特殊结尾标记。但C语言是上世纪70年代设计的语言那时候内存极其宝贵为了省那几字节的长度字段设计者选择了用“哨兵值”的方式来标注字符串边界——让一个字符\0来表示结束。这个设计决定了后面所有字符串函数的工作原理函数不知道字符串多长只能从头开始一个字节一个字节地读直到遇到\0为止。strlen、strcpy、strcmp全部依赖这个约定。这个设计带来两个后果你需要一直放在心上第一如果你忘记在字符数组末尾留\0的位置或者忘记赋值\0那所有字符串函数都会继续向后读到缓存区外面去直到在某个随机内存位置碰到一个值为0的字节才停下来。这是内存越界和程序崩溃的头号来源。第二字符串里不能随便存\0这个值。处理二进制数据比如图片内容某个字节可能恰好是0就不能用C字符串的方式来做否则数据会在中间被截断。这就是为什么很多网络协议库用的是“长度值 数据指针”的结构而不是裸用char*。用人话说C字符串就是一个以\0为边界的隐式长度结构。它的好处是省内存、实现简单缺点是边界全靠自觉。所有字符串函数的“坑”追根溯源都来自这个设计。2. strlen与sizeof的相爱相杀2.1 strlen到底怎么数长度的strlen大概是每个C程序员第一个接触的字符串函数。它的逻辑极度简单从传入的指针开始一个字节一个字节往后遍历计数直到遇到\0停止返回计数值。所以strlen(hello)返回值是5而不是6。因为它在第6个字节也就是\0处停下不包含终止符本身。但这里就要注意第一个坑了strlen()返回0strlen对空字符串是安全的因为字符串开头的第一个字节就是\0立即返回0。真正不安全的是传入一个没有\0结尾的字符数组比如char buf[5] {h, e, l, l, o}; // 没有\0 printf(%zu\n, strlen(buf)); // 危险会继续读buf后面的内存这个代码能读出什么结果完全看运气。buf后面的栈内存里只要有随机一个字节是0strlen就在那里停下返回的可能是5也可能是53取决于内存布局。更糟的是如果buf恰好分配在内存末尾附近可能一路读到非法地址直接段错误。2.2 sizeof和strlen的区别面试必问热搜词里有一个“sizeof和strlen的区别”这确实是C语言面试问题TOP 3。它俩最大的区别就一句话sizeof是运算符在编译期就能确定结果返回的是“这个类型或变量占多少字节”strlen是函数在运行期执行计数循环返回的是“这个字符串有多长”。看几个对比就很清楚了char s[] hello; printf(%zu\n, sizeof(s)); // 6数组总大小包含\0 printf(%zu\n, strlen(s)); // 5字符串长度不含\0 char *p s; printf(%zu\n, sizeof(p)); // 864位系统指针本身的大小 printf(%zu\n, strlen(p)); // 5因为p指向hello第二个对比尤其关键sizeof作用于指针时得到的是指针变量本身的内存占用64位系统上永远是8而不是它指向的字符串长度。这几乎是所有把“字符串长度”和“缓冲区大小”搞混的人的根源。所以你会看到一些经验丰富的C程序员在拷贝字符串时候特别小心char dst[100]; strcpy(dst, src); // 不推荐这种写法 strncpy(dst, src, sizeof(dst) - 1); // 更安全-1给\0留位置为什么strncpy要用sizeof(dst) - 1因为strncpy最多拷贝n个字符如果源字符串长度超过n它不会自动补\0。你不留一个位置手动设置终止符dst就变成一个没有结尾的“伪字符串”后面调用strlen(dst)就完蛋了。2.3 中文环境下strlen的坑热搜词里有“为什么在utf-8编码中,中文字符通常占用的字节数比英文字符多”这和strlen有什么关系关系大了。ASCII字符集里英文、数字、标点都用一个字节表示最高位是0。而中文在UTF-8编码下通常需要3个字节少数生僻字甚至4个字节每个字节的最高位都是1。这会带来一个直接的认知差异strlen返回的是“字节数”不是“字符数”。char s[] 你好; printf(%zu\n, strlen(s)); // 6不是2如果你要做“统计用户输入了几个字”的功能直接调strlen是不对的。你需要先弄清楚字符串的编码UTF-8、GBK还是别的再做多字节解码。UTF-8下判断逻辑比较直接ASCII字节最高位0算1个字符连续以11xxxxxx开头的字节序列是一个多字节字符的开头后续10xxxxxx字节是续字节。3. 字符串操作四件套拷贝、拼接、比较、查找3.1 strcpy和strncpy拷贝字符串的讲究strcpy(dst, src)把src指向的字符串一字不差地拷贝到dst指向的空间里包含末尾的\0。逻辑很简单但它有一个臭名昭著的前提dst的空间至少要能装下src的字符串非要加上结尾的\0。如果src长度估算错了strcpy会毫不留情地越过dst边界把后续内存也写烂。这就是缓冲区溢出攻击的基础原理也是编译器天天警告你strcpy不安全的原因。所以在实际工程里更稳妥的写法是使用strncpystrncpy(dst, src, n);但strncpy不是银弹它有两个坑第一如果src比n短strncpy会用\0填充剩余空间。比如strncpy(dst, hi, 10)它会在拷贝完h、i、\0之后再用7个\0填满dst。这个填充是有成本的但一般不会出问题。第二如果src比n长strncpy只拷贝前n个字符不补\0。这时dst就没有终止符你必须在拷贝后手动设置dst[n] \0前提是dst至少n1个字节。一个实际常用的安全写法char dst[32]; strncpy(dst, src, sizeof(dst) - 1); dst[sizeof(dst) - 1] \0;这两行保证了两点不会越界写最多写sizeof(dst)-1个字节保证有结尾终止符。虽然有人觉得多一行很啰嗦但我个人建议项目里固定用这个模式省心。3.2 strcat与strncat拼接字符串的细节strcat(dst, src)把src拼接到dst后面从dst的\0处开始写。这个函数的危险性比strcpy更高因为它除了要保证“最终字符串长度不超过dst空间”还得先找到dst的结尾。没人知道dst空间还剩多少所以一旦src过长越界是必然的。strncat(dst, src, n)比strcat稍微安全一点但它的行为容易让人误解不是从src追加n个字符那么简单而是“最多从src拷贝n个字符并且在末尾补一个\0”。这刚好和strncpy相反strncat总会保证终止符的存在。char dst[32] hello ; strncat(dst, world, sizeof(dst) - strlen(dst) - 1); // 结果hello world\0等等这里有点绕。sizeof(dst) - strlen(dst) - 1减掉现有字符串长度再减1留给\0得到的是剩余可用空间。理论上这个写法是对的但它要求strlen(dst)每次都要遍历一次dst在高频调用时要留意性能。实际项目里拼字符串我更推荐先计算好总长度用snprintf一次搞定snprintf(dst, sizeof(dst), %s%s, left, right);snprintf的好处是自带上限检查总会补终止符而且格式化的能力比反复strcat强太多。我会把“字符串拼接首选snprintf”作为一条铁律来用。3.3 strcmp与strncmp比较字符串的规则strcmp(s1, s2)按字典序比较两个字符串逐字节比较它们的ASCII值。返回值的含义是返回0两个字符串完全相同返回负值s1在字典序上小于s2也可以理解为s1的第一个不同字符的ASCII码比s2的小返回正值s1在字典序上大于s2注意不要直接拿返回值和1比较。规范只要求正负零不保证具体数值。有的平台返回-1有的平台返回-5代码里写if (strcmp(a, b) 1)都是不可靠的。if (strcmp(a, b) 0) // 正确的判断相等方式 if (strcmp(a, b) 0) // a在前面strncmp(s1, s2, n)用来比较前n个字符是否相等。这个函数在处理“前缀匹配”时特别好用比如判断一个字符串是否以某个前缀开头if (strncmp(str, http://, 7) 0) { // str以http://开头 }这里还需要提一下strcmp在比较之前不能确定两个字符串的长度它只能一直比下去直到遇到不同字节或者某一方遇到\0。如果其中一个指针指向的内存没有正确终止同样会越界访问。所以strcmp安全的前提和strlen一样传进来的必须是规范终止的字符串。3.4 strstr子串查找strstr(haystack, needle)在haystack里查找needle第一次出现的位置返回指向该位置的指针找不到返回NULL。它是字符串处理里最实用的检索函数。char *p strstr(hello world, world); if (p) { printf(%s\n, p); // 输出 world }注意一个隐蔽点如果needle是空字符串strstr直接返回haystack不会返回NULL。这个行为和很多人的直觉不一样。如果你业务逻辑里认为“空串不算找到”得手动判断。另外strstr只做纯字节匹配不做大小写归一。如果要忽略大小写标准库没有直接提供strcasestr这是GNU扩展不是标准C所以跨平台项目要么自己实现要么先把字符串转成统一大小写再查。4. strtok详解拆分字符串的正确姿势4.1 strtok的工作机制strtok(str, delim)是C标准库里最“拧巴”的字符串函数之一。它做的事是按分隔符拆分字符串。比如apple,banana,orange按逗号拆分能得到三个子串。但它的实现方式非常特别甚至会改变你的原始字符串。第一次调用时你传入待拆分的字符串后续再调用时第一个参数传NULL表示“继续上次没拆完的部分”。char str[] apple,banana,orange; char *token strtok(str, ,); while (token ! NULL) { printf(%s\n, token); // 依次输出 apple, banana, orange token strtok(NULL, ,); }工作机制拆开来看是这样的strtok第一次收到字符串从开头开始找分隔符。找到后把那个分隔符位置改写为\0然后记住这个位置的下一个字节也就是剩余字符串的开头保存在内部静态变量中返回字符串开头。后续传入NULLstrtok从上次保存的位置继续查找下一个分隔符找到后同样改写为\0更新内部静态变量返回当前子串。如果没找到更多分隔符说明剩下的整段就是最后一个子串返回它并把内部静态变量置空。这个设计有一个很关键的含义strtok会直接修改原始字符串。你在调用之后原字符串已经变得面目全非——所有分隔符都变成了\0。所以如果后续还需要原始字符串必须在调用前做备份。4.2 strtok的坑破坏原字符串和静态指针刚才提到strtok破坏原字符串这是它最大的坑。第二个坑是它使用静态内部指针存储拆分进度这意味着它不是线程安全的。两个线程同时在不同字符串上调用strtok彼此会互相覆盖内部状态。这个竞争在并发环境下几乎必然出错。调用时传字符串字面量会崩溃。strtok(apple,banana, ,)在运行时试图修改只读内存里的分隔符在某些平台直接触发段错误。所以传参必须是可以修改的字符数组比如char str[] ...这种方式定义的。strtok遇到连续分隔符时需要特殊对待。例如a,,b按逗号拆分strtok会跳过空字段输出a和b中间的空字符串会被忽略。如果你的业务要求保留空字段比如解析CSVstrtok就完全不适配。有一个很隐蔽的坑strtok不会跳过字符串开头的分隔符而是把它们当作空token处理并跳过。比如,a,b结果仍然是a和b你不会拿到一个空token。在实际项目里用strtok我总是提醒自己一句话传进去的字符串要能改改完不能要了。4.3 strtok_r和strtok_s线程安全的替代方案为了解决静态指针导致的线程安全问题POSIX定义了strtok_rWindows CRT则提供了strtok_s。两者的用法几乎一样多了一个char **saveptr参数用来在调用者手中保存“拆分进度”。线程安全了还能支持多个拆分操作交叉进行char str[] apple,banana;orange; char *saveptr NULL; char *token strtok_r(str, ,, saveptr); while (token) { printf(%s\n, token); token strtok_r(NULL, ,, saveptr); }注意这里其实有个使用局限同一时刻对同一个字符串进行拆分是没问题的但如果你想对一个字符串按,拆同时拆另一个字符串按;拆在两个循环里就必须各自维护自己的saveptr不能混用。在C11标准里有strtok_s但标准C的strtok_s和微软的strtok_s签名还不完全一样。写跨平台代码时一般用宏或者封装函数来兼容两者。我个人更倾向于在工程里自己写一个基于strsep或纯手写循环的split函数尤其当拆分逻辑复杂、字段需要保留空值时。纯手动实现其实也就十几行无非是遍历找到分隔符位置把子串拷贝出去然后移动指针。这样虽然啰嗦了点但行为完全可控没有静态状态的坑。5. 字符处理函数与字符分类5.1 ctype.h三件套isalpha、isdigit、isspacectype.h头文件里提供了大量字符分类函数它们是处理输入校验的利器。我用得最多的是isalpha判断字母、isdigit判断数字、isalnum字母或数字、isspace空白字符包括空格、\t、\n等。#include ctype.h char c 5; if (isdigit(c)) { int v c - 0; // 字符转数字 }这些函数返回值不是简单的0或1而是“非零”表示真。所以判断时不要写成if (isdigit(c) 1)直接if (isdigit(c))就好。让我提一个几乎所有新人都踩过的坑字符分类函数只接受unsigned char类型的值或者EOF不能直接传char。为什么因为char在有些平台上是带符号的。当某个字节的最高位是1比如UTF-8中文字节转换成int时会产生负数而字符分类函数的实现内部通常用这个值做数组下标负数下标会越界行为未定义。正确姿势是强转char c 中; int r isalpha((unsigned char)c); // 安全5.2 toupper和tolower的字符转换toupper(c)把小写字母转为大写字母tolower(c)相反。如果传入的字符本身不是对应的大写/小写字母函数会原样返回不改变。在实际使用中这两个函数最常见的用途是做大小写不敏感的比较int strcasecmp_manual(const char *a, const char *b) { while (*a *b) { int ca tolower((unsigned char)*a); int cb tolower((unsigned char)*b); if (ca ! cb) return ca - cb; a; b; } return (unsigned char)*a - (unsigned char)*b; }注意tolower的入参同样需要强转成unsigned char理由和字符分类函数一致。另外tolower和toupper只能处理单个字符如果一个字符串里包含中文想通过这两个函数做不同语言的大小写转换是不可能的因为中文字符根本不存在大小写概念而且它们也不适合处理多字节字符。5.3 字符的“数字”不等于数字本身这个点我反复在文章里强调但每次带新人时还是发现有人搞混。char c 7;这个字符变量它的内存值是55ASCII码值不是7。要想得到整数值7必须int value c - 0;反过来想格式化输出单个数字字符也要注意拼接方式。比如要把整数5转成字符5正确的做法是char c 0 5;这两个转换是C语言里最高频的细节操作之一。也能解释为什么atoi(123)能正常工作它内部就是把字符串里的字符依次做c - 0运算再按十进制累加。6. 进阶二维字符数组、Unicode与中文处理6.1 二维字符数组与字符串数组二维字符数组是“多个字符串”的直观表达方式。比如char names[3][16] {Alice, Bob, Charlie};这个声明表示一个能存3个字符串的数组每个字符串最多15个有效字符因为第16个字节要放\0。它的优点是内存连续、多层嵌套循环里访问方便坏处是每一行都按最长的来分配空间如果字符串长短差异大内存浪费就非常严重。处理这种数组时要注意sizeof(names[0])是16一行占16字节strlen(names[0])则是这一行字符串的实际长度比如Alice是5。如果字符串数量和长度都不确定更灵活的是指针数组const char *names[] {Alice, Bob, Charlie};这个数组本身只存指针每个8字节字符串字面量存在别的只读区。和二维字符数组最大的区别是这个形式下字符串内容是不可修改的。如果程序要修改字符串内容就只能用二维字符数组或者自己动态分配内存。6.2 中文在UTF-8里为什么不只占一个字节中文在UTF-8编码里占3个字节英文占1个字节这是整个中文文本处理最核心的痛点。UTF-8是一种变长编码规则是单字节字符ASCII最高位为0范围0x00-0x7F双字节字符首字节110xxxxx后续字节10xxxxxx三字节字符首字节1110xxxx后续两个字节10xxxxxx四字节字符首字节11110xxx后续三个字节10xxxxxx中文字符的Unicode码点通常在0x0800-0xFFFF之间落在三字节范围所以UTF-8编码就是3个字节。GBK编码下中文只占2个字节但GBK不是变长编码也不兼容UTF-8。这就是为什么用错了编码中文文本在终端里会显示成“乱码字符”。处理中文时strlen计算的是字节数不是字符数。上面的代码char s[] 你好; strlen(s)的结果是6。如果产品要做“最多输入10个字”的校验直接按字节数截断可能把一个中文字符拦腰截断生成无法解码的孤字节显示出来就是乱码。正确的做法是按UTF-8边界规则遍历统计有效字符数size_t utf8_charlen(const char *s) { size_t count 0; while (*s) { if ((*s 0xC0) ! 0x80) { count; } s; } return count; }这个代码的思路是UTF-8的续字节都以10开头只要发现不是续字节的字节就认为是一个新字符的开头。这个方法在严格有效的UTF-8前提下统计字符数是准确的。6.3 字符串函数在字符设备驱动里的角色热搜词里有“字符设备驱动框架”这看似和字符串函数八竿子打不着但实际上内核驱动里也大量处理字符串。比如调用copy_from_user从用户空间拷贝字符串到内核缓冲区时就要谨慎对待用户传进来的指针和字符串长度——用户空间的字符串可能没有正确终止也可能长度超出预期。驱动代码里常见的做法是限制拷贝长度并手动保证终止char kbuf[64]; if (copy_from_user(kbuf, user_buf, sizeof(kbuf) - 1)) { return -EFAULT; } kbuf[sizeof(kbuf) - 1] \0;这个写法和应用层strncpy后补\0的思路完全一致。字符串边界问题不区分应用还是内核任何一层处理不受信任的外部输入时都必须假设对方可能不按规矩出牌。设备驱动里还会用strcmp来解析用户通过ioctl或sysfs传入的命令字符用strncmp做前缀匹配用strsep解析模块参数——和用户态完全同名。理解了应用层这些函数的机制之后再看内核驱动里的字符串处理思路会顺畅很多。7. 实践心得字符串函数使用的几个铁律翻了这么多函数最后还是想把你拉回到实际工程视角。字符串函数本身简单难的是用对。我梳理了几条自己一直在用、带过很多新人的原则你可以直接抄去用第一能限定长度就不要裸传字符串。现在代码评审时我看到strcpy(dst, src)、strcat(dst, src)、sprintf(dst, %s, src)这类写法第一反应就是要求改成strncpy加手动终止、snprintf加缓冲区大小。裸调用意味着没有任何边界保护任何错误估算都会造成缓冲区溢出。掉进这个坑轻则变量被改重则程序崩、安全漏洞。第二\0是字符串函数的“红线”。所有标准字符串函数的隐含前提都是“字符串必须是以\0结尾的合法序列”。如果你自己构造字符数组千万记住给\0留位置也记得在合适的地方赋\0。常见的缓冲区声明比如char buf[32]你最多只能放31个有效字符第32个要给终止符。第三不要修改字符串字面量。像hello这种字符串直接写在代码里在大多数平台放在只读段。你要修改内容就声明成char arr[] hello;让编译器帮你拷贝到可写内存。尤其是strtok这类会原地修改的函数传字面量进去就是个定时炸弹。第四中文场景优先用UTF-8感知的处理方式。strlen统计字节而不是字符这在涉及用户输入合法性校验、数据库存储长度限制、展示宽度计算时非常重要。建议项目里封装一个utf8_strlen、utf8_substr之类的工具函数按边界规则处理避免截断导致乱码。第五能不用strtok就不用strtok。它的原地修改、静态状态、不保留空字段这三个特性在工程里带来太多隐藏问题。真的需要split优先自己写一个可控的、支持保留空字段的版本或者用strtok_r/strsep这类带上下文的版本。我在实际开发中最大的体会是C语言的字符串函数从strlen到strtok表面上是一串API本质上是一套“内存边界管理哲学”。每个函数的参数和返回值都在跟“有没有终止符”和“缓冲区够不够大”这两个问题较劲。把这两个问题想清楚写出来的字符串代码基本不会出大错。一个小技巧送给你等你接手一个遗留C项目想快速摸清哪些地方用了危险的字符串操作只需要全局搜strcpy、strcat和sprintf看到的每一个调用点就是至少一个潜在风险点。把这些函数改成安全版本的过程往往能让程序的稳定性上一个台阶。