☰
字符串函数实战避坑:strtok原理、C语言安全写法与DB2数字判断
2026/10/1 18:47:47 网站建设 项目流程

字符串函数大概是所有搞开发的人最熟悉的陌生人。从C语言的strlen、strcpy,到SQL里的substring、translate,再到Python、Java里那套现成的接口,字符串处理无处不在,但真正能把它们用得顺手、用得安全的人,其实不算多。这篇是“字符串函数”系列的第二篇,上一篇把基础原理和入门用法讲透了,今天换个角度,全部围绕实战中容易踩的坑来展开。主要聊三件事:先把strtok()这个分割函数从原理到坑全部拆干净;然后盘点C语言中复制、拼接、比较、查找这些字符串操作函数的最稳妥写法;最后用案例讲清楚DB2 SQL里判断数字字符串的几种靠谱方案。适合谁看?写过C但总在字符串上翻车的朋友,做后端经常和数据库打交道、需要处理历史脏数据的同学,以及所有想在代码里少一点崩溃、多一点从容的人。

1. strtok()函数:分割字符串的利器与暗礁

1.1 基本用法和第一印象

很多人在第一种编程语言里都做过“用逗号分隔一串名字”这类练习。在C语言里,最常用到的就是strtok()。它的签名长这样:

char *strtok(char *str, const char *delim);

第一次调用时,把待分割的字符串指针和分隔符传进去,比如:

#include <stdio.h> #include <string.h> int main() { char str[] = "apple,banana,cherry,date"; char *token = strtok(str, ","); while (token != NULL) { printf("%s\n", token); token = strtok(NULL, ","); } return 0; }

这段代码会输出apple、banana、cherry、date四行。很多新手第一次看到这里会觉得奇怪:为什么第二次开始,第一个参数传的是NULL?这其实是strtok的设计核心,也正是它所有坑的根源。

1.2 背后的机制:一次只能维护一个游标

strtok之所以第二次调用只需要传NULL,是因为它在内部用一个静态指针记住了“上次分割到哪了”。每次调用时,它会从上次的位置继续向后找分隔符,找到就把分隔符所在位置写成'\0',然后返回该段的起始指针。

这也解释了一个很多新手忽略的细节:strtok会把原字符串直接改掉。“apple,banana”被分割后,内存里实际上变成了“apple\0banana\0”,逗号的位置全部被替换成了字符串结束符。所以如果你之后还想用原始字符串,就得提前备份一份。我见过有人先把字符串复制一份再用strtok分割,结果发现原字符串也变了,排查半天。原因就是忘了strtok修改的是传入的缓冲区本身,复制过来的那份也一样被改。

还有一点容易被忽视:分割完成后,想通过strlen获取原字符串的长度是不行的,因为字符串已经被切成了若干段,strlen只能返回第一段的长度。如果业务逻辑里既要分割又要保留原始完整数据,请务必在调用strtok之前自己另存一份。

1.3 连续分隔符、多分隔符和边界情况

strtok遇到连续分隔符并不会返回空token。比如“apple,,banana,,cherry”,用“,”分割,得到的还是apple、banana、cherry三个段,中间的空段会被直接跳过。这一点和Python的split行为不一样,写多语言切换代码时容易掉坑。

分隔符本身不是单个字符,而是“一个字符集合”。也就是说strtok("abc-def_ghi", "-_"),它会同时用短横线和下划线当分隔符,谁先出现就在谁那里切开。很多教程只说逗号分割,忽略了这一点,实际上这是解析混合分隔符文本的利器。同样,delim参数每次调用都可以重新传入,循环过程中可以动态改变分隔符集合,这在一些语法解析器里非常有用。

边界情况也要注意:如果字符串以分隔符开头,最开始会拿到一个空段吗?不会。strtok会跳过开头的分隔符,直接返回第一个非空token。如果整段都是分隔符,第一次调用就返回NULL。这类边界行为在不同语言的分割函数里其实并不统一,所以在使用前先想清楚“连续分隔符是否要保留空串”,这是很多数据解析bug的源头。

1.4 线程安全版本strtok_r

前面说了,strtok内部维护的是静态变量,这意味着两个线程同时在各自的字符串上调用strtok,会互相污染。更尴尬的是,在同一个线程里,嵌套调用strtok也不安全。比如你在外层的while循环里,又用strtok去切另一个字符串,外层循环立刻出问题。

POSIX系统提供了strtok_r,它多了一个额外的char **saveptr参数,用来手动保存游标:

char *saveptr = NULL; token = strtok_r(str, ",", &saveptr); token = strtok_r(NULL, ",", &saveptr);

这里saveptr由调用方自己分配,每个调用上下文都有一份独立游标,多线程、嵌套分割都可以放心用。Windows上对应的是strtok_s,参数顺序略有差异,但思路一样。如果你写的代码要跨平台,可以自己封装一层宏或函数,内部按平台分别调用strtok_r和strtok_s。

1.5 手写CSV解析的一个小实战

用strtok_r实现一个简单的CSV行解析很常见。下面这个函数把一行逗号分隔文本拆成字段数组:

int split_csv_line(const char *line, char fields[][64], int max_fields) { char buffer[2048]; char *saveptr = NULL; char *token; int count = 0; if (line == NULL || fields == NULL || max_fields <= 0) { return -1; } snprintf(buffer, sizeof(buffer), "%s", line); token = strtok_r(buffer, ",", &saveptr); while (token != NULL && count < max_fields) { snprintf(fields[count], 64, "%s", token); count++; token = strtok_r(NULL, ",", &saveptr); } return count; }

这套代码处理普通CSV够用,但必须提醒一点:标准的CSV格式允许字段内包含逗号和引号,比如“Zhang, Wei”,上面那种简单分割会直接切错。处理那种规范CSV,最好用状态机,或者用一个成熟的CSV库。只要你的数据是自己生成的、格式可控,strtok_r的轻量方案倒是非常省事。

2. C语言字符串操作函数的高频功能盘点

2.1 复制:为什么strcpy不是首选

说到字符串函数,C语言里最经典的就是strcpy。它的定义很简单:把源字符串逐字节复制,直到遇到'\0'。问题也出在这——它完全不检查目标缓冲区够不够大。如果源字符串长度超过目标缓冲区,数据就会一路写到缓冲区外,把相邻变量甚至返回地址全部覆盖掉。

很多系统曾经被这类bug攻破过,所以我们平时写代码要养成习惯:不直接用strcpy,改用strncpy或者snprintf。但strncpy也不是那么让人省心,它最多复制n个字节,但如果源字符串长度大于等于n,它不会自动补一个'\0'结尾。所以标准建议是:

char dest[16]; strncpy(dest, src, sizeof(dest) - 1); dest[sizeof(dest) - 1] = '\0';

手动保证最后一个字节是终止符。还有一个细节,strncpy会在源字符串不足n字节时,用'\0'把剩余空间填满,这在某些场景下反而白白消耗性能。如果只是想安全地复制字符串,snprintf反而是我更喜欢的选择:

snprintf(dest, sizeof(dest), "%s", src);

它一定以'\0'结尾,行为清晰,唯一的代价是要理解好格式化字符串。当你复制的是非文本二进制数据时,就别用snprintf了,老老实实用memcpy,因为它按字节块处理,不关心'\0'。

2.2 拼接:strcat的隐患与替代

strcat和strcpy的问题一模一样,它会从目标字符串的'\0'位置开始,把源字符串接上去,同样不检查边界。两个小字符串拼接,如果忘了给目标缓冲区留足空间,线上立刻给你表演一个崩溃。

strncat是相对安全的替代,但很多人用错了。它的第二个参数不是“目标缓冲区总大小”,而是“还可以追加多少字节”。正确姿势是:

strncat(dest, src, sizeof(dest) - strlen(dest) - 1);

每次拼接前都算一次剩余空间,罗嗦但可靠。另一个常被忽略的点是,strncat不管追加了多少字节,都会在末尾补'\0',这点比strncpy靠谱。所以如果只能在strcpy和strncat之间选一个,后者往往更让人安心。

2.3 比较:strcmp家族的关键细节

字符串比较看起来没有什么技术含量,但有些细节值得聊。

strcmp按字节逐个比较,直到遇到'\0'或者出现差异。它不是比较“长度”,而是逐位ASCII码大小。所以“apple”和“apple2”比较,结果是strcmp("apple", "apple2")返回负值,因为比较到第一个字符串结束后,'\0'的ASCII码0小于'2'的ASCII码50。这一点在排序、去重时尤其关键。

strncmp可以只比较前n个字节。比如判断一个字符串是否以“http”开头,可以这样写:

if (strncmp(url, "http", 4) == 0) { // 匹配 }

memcmp也是按字节比较,但它不关心'\0',适合二进制结构、定长字段。字符串用strcmp,二进制块用memcmp,语义要对得上,混用也不是不行,只是你要清楚自己在比较什么。

2.4 查找、裁剪与其它小工具

这部分函数平时用的频率也很高:

  • strchr:在一个字符串里找某个字符第一次出现的位置。
  • strrchr:从右往左找,返回最后一次出现的位置。
  • strstr:找子串。
  • strpbrk:找一串候选字符集合中,最先出现的是哪一个。

举个裁剪场景:从标准输入读入一行,缓冲区里结尾通常带着'\n',想去掉它,新手习惯用strchr:

char *nl = strchr(line, '\n'); if (nl) *nl = '\0';

这没问题。也可以用更冷门但很好用的strcspn一行搞定:

line[strcspn(line, "\n")] = '\0';

strcspn返回字符串开头到第一个匹配集合字符之间的长度,直接用它当下标把换行覆盖掉。对于要不要保留'\r'这种Windows换行符,记得多看一眼,必要时把"\r\n"两个字符都放进去。

大小写转换没有直接针对字符串的标准函数,常见的实现是循环处理每个字符,调用toupper或者tolower。这两个函数定义在ctype.h,单独处理一个字符时注意传入的字节值不能超过unsigned char的范围,否则行为也是未定义的。

3. DB2 SQL判断数字字符串:三种实用方案对比

3.1 这个需求是怎么冒出来的

做数据清洗、ETL的读者应该特别有共鸣。从Excel导入的电话号码、银行账号、学号等字段,经常是以字符串形式存进数据库的,里面混着空格、短横线、字母甚至全角字符。这时业务方会提出一个需求:帮我筛出那些“一看就是纯数字”的记录。

在SQL Server里,很多人会直接写col LIKE '[0-9]%',但那套字符集匹配规则到了DB2里并不通用。DB2不像SQL Server支持方括号字符集,也不像Oracle有完全一致的正则函数语法。所以这个简单需求,在不同数据库里折腾程度完全不同。下面按DB2 LUW(Linux/Unix/Windows)的主流版本介绍方案。

3.2 方案一:TRANSLATE函数,老版本也能用

DB2的TRANSLATE函数语法是TRANSLATE(expr, replacement, replaceChars),它的作用是把replaceChars中出现过的字符,替换成replacement中对应位置的字符。利用它判断数字字符串的思路非常巧妙:

SELECT col, TRANSLATE(col, '', '0123456789') AS non_digits, CASE WHEN col IS NULL THEN 'IS NULL' WHEN TRANSLATE(col, '', '0123456789') = '' THEN 'ALL_DIGITS' ELSE 'HAS_NON_DIGITS' END AS check_result FROM your_table;

原理是:把col中所有数字字符都删掉,如果剩下的字符为空,说明原来的字符串里除了数字没有别的,自然就是纯数字字符串。这个方案对DB2的老版本也适用,不依赖正则表达式,兼容性非常好。

有几个细节必须提醒:

  • TRANSLATE的第二个参数传空字符串'',在DB2中表示“删除这些字符”,这是它和某些数据库的另一个TRANSLATE实现容易混淆的地方。
  • 如果col本身是NULL,TRANSLATE结果也是NULL,条件判断要用NULL相关的写法,单独处理。
  • 空字符串''会被判断为ALL_DIGITS,这是逻辑上的一个边界决策,业务上到底算不算纯数字,需要自己定义。

3.3 方案二:REGEXP_LIKE,版本够新就用正则

从DB2 10.5开始,DB2 LUW支持REGEXP_LIKE函数。用它判断纯数字字符串就直观很多:

SELECT col, CASE WHEN REGEXP_LIKE(col, '^[0-9]+$') = 1 THEN 'ALL_DIGITS' ELSE 'NOT_ALL_DIGITS' END AS check_result FROM your_table;

正则表达式^[0-9]+$表示:从头到尾,至少一个字符,并且每个字符都在0到9之间。如果要允许数字前面有一个可选的负号,可以写成^[+-]?[0-9]+$。如果要允许小数,可以写^[0-9]+([.][0-9]+)?$。

使用正则方案时注意:REGEXP_LIKE对NULL的处理是返回NULL,不是0,所以CASE WHEN REGEXP_LIKE(col, '...') = 1,在col为NULL时会走到ELSE分支,这点要弄清楚。另外,DB2某些版本中正则匹配选项可能受数据库locale影响,建议在关键处理前拿小样本实际跑一遍,确认模式符合预期。正则方案虽然直观,但中文字段、全角数字等特殊字符不会自动被当成数字处理,这在“判断数字字符串”的需求里恰恰是对的。

3.4 方案三:CAST加异常捕获,适合写存储过程

DB2里还有个思路:直接尝试把字段转成数值类型。如果转换成功,说明它是可以解析成数字的字符串。但问题是,CAST失败会直接抛SQLSTATE 22018异常,普通SQL语句里很难优雅地“试试看”。所以这个方案真正落地时,通常放在存储过程或UDF里,配合DECLARE CONTINUE HANDLER捕获异常:

CREATE PROCEDURE check_digit_string(IN input VARCHAR(100)) BEGIN DECLARE continue_handle_for_cast CONDITION FOR SQLSTATE '22018'; DECLARE CONTINUE HANDLER FOR continue_handle_for_cast SET is_digit = 0; SET is_digit = 1; SET dummy = CAST(input AS BIGINT); END;

这种写法能把“是不是数字字符串”的判断透明化,但涉及存储过程的维护成本,一般只在复杂的清洗逻辑中才值得用。如果只是快速看一下数据,直接用TRANSLATE方案就够了。

3.5 三种方案的取舍建议

判断方案适用版本优点注意点
TRANSLATE + 空串判断DB2 LUW任意版本不依赖正则,兼容性极好要单独处理NULL和空字符串
REGEXP_LIKEDB2 LUW 10.5及以上语义直观,模式可扩展正则对特殊字符敏感,NULL返回NULL
CAST + CASE/存储过程任意版本语义最强,能判断数值范围异常处理需要存储过程或UDF支撑

我的建议是,生产环境优先看版本:10.5以上用REGEXP_LIKE,交付团队也容易理解;旧版本或者不允许多写函数时,TRANSLATE是保底方案。

4. 字符串函数翻车现场:五个常见坑与避坑技巧

4.1 缓冲区溢出:不是危言耸听

在处理字符串函数的开发里,缓冲区溢出是占比最高的线上事故类型之一。比如一个固定大小buf[64],用户从表单传入内容,直接strcpy进去,一旦超过64字节,后面接的变量、堆栈指针甚至返回地址都会被改写。轻则变量数据错乱,重则程序地址跑飞,随机崩溃。调试这类问题很痛苦,因为崩溃点往往不在复制的那一刻,而在后续某个莫名其妙的位置。

我的经验是,所有外部输入进入字符串函数之前,先做长度拦截;能选带n的版本或者snprintf的,绝不用裸的strcpy、strcat;每个缓冲区都在注释里写明最大长度,防止后来人误用。不要嫌麻烦,这和数据量没有关系,只和你敢不敢拿线上稳定性赌一把有关。

4.2 strlen和sizeof的混淆

这是面试常考题,也是实际代码里的常见bug。strlen计算的是“字符个数”,不包括结尾的'\0';而sizeof用在数组上时,返回的是整个数组占用的字节数,包括'\0'。看段代码:

char str[] = "hello"; printf("%zu\n", sizeof(str)); // 6 printf("%zu\n", strlen(str)); // 5

但如果是指针:

char *p = "hello"; printf("%zu\n", sizeof(p)); // 8(64位环境)

sizeof指针返回的是指针本身的大小,不是字符串的长度。很多人在给函数传参时,在函数内部使用sizeof求缓冲区大小,得到的是指针大小,然后传给strncpy,结果截断或者越界。正确做法是,函数内部不能依赖sizeof,必须在外面算好长度一起传进来。

4.3 strncpy可能不补'\0'

前面提过,strncpy最大的坑是源字符串长度不小于n的时候,不会自己补'\0'。这意味着拼接或读取时,读到缓冲区末尾还在继续找终止符,就会读到缓冲区之外,行为完全不确定。

网上很多老代码都建议strncpy之后手动补'\0',这确实是对的。我的习惯是写一个安全复制封装函数,统一处理这个补尾动作,而不是每次调用都手写两行;另外,能走snprintf的地方,尽量走snprintf,少一个需要记忆的特例。

4.4 直接修改字符串字面量

字符串字面量保存的位置,在很多平台上只读数据段。下面的写法是未定义行为:

char *p = "hello"; p[0] = 'H';

我把这行代码放到实际工程里跑过,在一种编译器下程序直接崩溃,在另一种编译器下居然正常修改了,但正因为行为不被标准保证,谁也不能保证它换一台机器还是这样。想要可修改的字符串,应该初始化成数组:

char p[] = "hello"; p[0] = 'H';

这是很多新手最容易忽略的差异。尤其在做字符串分割时,传入strtok的字符串一定要是可修改的数组,如果直接传字符串字面量,strtok尝试写入'\0'的那一下,就可能引发段错误。

4.5 编码不是字符串函数能解决的

很多字符串函数的操作单位是字节,不是字符。对于UTF-8编码的中文,“你好”在内存里是6个字节,strlen返回6而不是2。如果再用strncpy按4字节截断,就可能把一个完整的汉字从中间切掉,截出来的内容变成无法打印的乱码。

处理多字节字符串,要区别对待:

  • 如果只是复制、拼接、比较整个字符串,strcpy、strcat这类函数本身没问题,它在操作字节流,不关心字符边界,不需要太担心。
  • 如果要从中间截断、替换某个“字符”,就必须按多字节规则来处理,或者使用宽字符函数,如wcscpy、wcslen等,先把多字节字符串转换成宽字符数组再操作。

我工作中处理中文字段时,会先用mbstowcs转成wchar_t数组,做逻辑操作,再通过wcstombs转回来。虽然代码啰嗦一点,但比在字符边界上抠来抠去稳妥得多。

5. 三个真实场景:字符串函数组合使用案例

5.1 URL查询参数解析

服务端拿到一个HTTP查询字符串,形如“name=tony&age=30&city=beijing”,要拆出键值对。这里用strtok_r最合适,因为它要在同一个字符串上做两轮分割,第一轮按&切出键值对,第二轮按=切出key和value:

char query[] = "name=tony&age=30&city=beijing"; char *pair_save = NULL; char *pair = strtok_r(query, "&", &pair_save); while (pair != NULL) { char *key, *value; char *kv_save = NULL; key = strtok_r(pair, "=", &kv_save); value = strtok_r(NULL, "=", &kv_save); if (key != NULL && value != NULL) { printf("key=%s, value=%s\n", key, value); } pair = strtok_r(NULL, "&", &pair_save); }

两层strtok_r各自持有独立的saveptr,互不干扰。如果只用strtok,理论上就会在处理内层kv时把外层游标弄丢。另外提醒一句:真实的URL参数还会涉及URL编码,比如空格变成+或者%20,参数里的&会被编码成%26,所以上面的简单实现只适合内部系统自定义协议,对外网收到的输入,要先做URL解码,再解析。

5.2 去除字符串头部和尾部的空白

实现一个trim函数,几乎是每个C工程迟早要写的小工具。综合考虑安全边界和拷贝开销,可以用下面的写法:

char *trim(char *str) { char *start = str; char *end; size_t len; if (str == NULL) { return NULL; } while (*start == ' ' || *start == '\t' || *start == '\n' || *start == '\r') { start++; } if (*start == '\0') { str[0] = '\0'; return str; } end = start + strlen(start) - 1; while (end >= start && (*end == ' ' || *end == '\t' || *end == '\n' || *end == '\r')) { end--; } *(end + 1) = '\0'; if (start != str) { memmove(str, start, strlen(start) + 1); } return str; }

这里的两个细节值得说明:第一,尾部裁剪直接在倒数第一个非空白字符后面写'\0',这是字符串函数里最常用的“人工截止”手法;第二,头部有空白时,用memmove而不是strcpy,因为两个区域可能重叠,memmove能够正确处理重叠拷贝。如果用strcpy,重叠时行为是未定义的,我在老代码里吃过一次亏。

5.3 在DB2中清洗电话号码字段

接一个数据看板项目时,对方给的phone字段里既有“13800138000”这种干净数据,也有“010-88888888”、“138 0013 8000”这种污染数据。需求是先把数字取出来,并判断长度是否为11位。DB2里用TRANSLATE一下把常见分隔符删掉,再判断即可:

SELECT phone_raw, TRANSLATE(phone_raw, '', ' -()') AS digits_only, CASE WHEN TRANSLATE(TRANSLATE(phone_raw, '', ' -()'), '', '0123456789') = '' AND LENGTH(TRANSLATE(phone_raw, '', ' -()')) = 11 THEN 'valid_mobile' ELSE 'invalid_mobile' END AS mobile_check FROM user_phone_tmp;

这里有个很有意思的嵌套:第一步TRANSLATE先去掉空格、短横线、括号这些格式字符,得到digits_only;第二步再把0到9全部删掉,如果剩下的是空字符串,说明digits_only里全是数字。两步配合,就实现了“清洗+校验”两条逻辑。这种写法不需要正则,在DB2任何版本上都跑得动,是我做数据清洗时的常用武器。

6. 一些零碎但重要的实操心得

字符串函数其实不难,难的是边界条件的判断和人的习惯。我个人这几年总结下来的核心准则,其实只有几条:第一,永远假设输入数据的长度和内容都不可控,先校验再操作;第二,优先选择带明确长度参数的函数,用显式长度换安全;第三,涉及到并发、上下文复用的场景,选可重入版本,比如strtok_r、strerror_r这类,不要图省事用全局状态。

另外,写SQL里的字符串判断时,先把“空字符串”“NULL”“全角数字”“前后空格”这四种边界情况在纸上列出来,代码自然就稳妥了。写C语言的字符串处理时,初始化所有变量、时刻检查数组下标、随手在缓冲区尾部补'\0',这些小事看起来琐碎,但每一条都对应着线上一个潜在崩溃点。

最后分享一个小技巧:调试字符串相关崩溃时,别急着断点单步。先打开AddressSanitizer或者Valgrind,让工具帮你定位第一次越界的写入点,往往比人眼一遍遍读代码高效十倍。我在实际项目里靠这个手段救过很多次场,希望对正在排查诡异bug的读者也有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询