深入理解C语言:字符函数与字符串函数全解析及底层模拟实现
在之前的《深入理解指针》系列中,我们系统梳理了指针的核心知识,强调过"指针的核心在于对内存布局和类型步长的精准把控"。数组名退化为指针、指针相减算元素个数、编译期与运行期的区别,这些知识点在处理字符串时体现得淋漓尽致。
本篇博客,我们将围绕C语言标准库中关于字符和字符串的常用函数,从函数的使用、底层原理到模拟实现,带你彻底吃透字符串处理逻辑。所有代码均保证可以直接编译运行。

目录
- 一、字符分类函数
- 二、字符转换函数
- 三、strlen的使用和模拟实现
- [3.1 函数介绍与易错点](#3.1 函数介绍与易错点)
- [3.2 模拟实现的三种方式](#3.2 模拟实现的三种方式)
- 四、strcpy的使用和模拟实现
- 五、strcat的使用和模拟实现
- 六、strcmp的使用和模拟实现
- 七、strncpy函数的使用
- 八、strncat函数的使用
- 九、strncmp函数的使用
- 十、strstr的使用和模拟实现
- 十一、strtok函数的使用
- [十二、strerror 与 perror函数的使用](#十二、strerror 与 perror函数的使用)
- 四、总结
一、字符分类函数
C语言标准库 <ctype.h> 提供了一系列用于判断字符类型的函数。这些函数接收一个整型参数(通常为字符的ASCII码,或为了兼容 EOF 被提升为 int),返回非零值(真)或0(假)。
常见函数及其判断条件:
iscntrl:任何控制字符isspace:空白字符(空格、换页\f、换行\n、回车\r、制表符\t、垂直制表符\v)isdigit:十进制数字0~9isxdigit:十六进制数字(0-9,a-f,A-F)islower:小写字母a~zisupper:大写字母A~Zisalpha:字母a~z或A~Zisalnum:字母或数字ispunct:标点符号isgraph:任何图形字符isprint:任何可打印字符(包括图形字符和空白字符)
代码示例与说明:
c
#include <stdio.h>
#include <ctype.h>
int main(void) {
char ch = 'A';
// islower 判断是否是小写字母,如果是返回非0,不是返回0
if (isupper(ch)) {
printf("%c 是大写字母\n", ch);
}
if (isdigit('5')) {
printf("'5' 是数字字符\n");
}
// 实际应用场景:统计字符串中各类字符的个数
const char *str = "C99 & C11";
int digit_count = 0;
int alpha_count = 0;
while (*str) {
if (isdigit(*str)) digit_count++;
if (isalpha(*str)) alpha_count++;
str++;
}
printf("字符串中共有 %d 个数字字符, %d 个字母字符\n", digit_count, alpha_count);
return 0;
}

二、字符转换函数
C语言提供了两个用于大小写转换的函数:
int tolower(int c):将大写字母转换为小写字母(若参数不是大写字母,返回原值)。int toupper(int c):将小写字母转换为大写字母(若参数不是小写字母,返回原值)。
代码示例与说明:
在初学阶段,我们可能会用小写字母减去32(ASCII码)来实现转大写。但有了转换函数,代码更加规范、可读性更强,且不受平台字符集差异的影响。
c
#include <stdio.h>
#include <ctype.h>
int main(void) {
// 将字符串中的小写字母转大写,其他字符不变
char str[] = "Test String.\n";
int i = 0;
char c;
while (str[i]) {
c = str[i];
if (islower(c)) {
// 使用 toupper 函数直接转换,替代 c -= 32
c = toupper(c);
}
putchar(c);
i++;
}
return 0;
}

三、strlen的使用和模拟实现
3.1 函数介绍与易错点
size_t strlen(const char *str);
- 字符串以
'\0'作为结束标志,strlen返回的是在字符串中'\0'前面出现的字符个数(不包含'\0')。 - 注意 :函数的返回值为
size_t,是无符号整数 (易错)。如果你计算strlen(str2) - strlen(str1),结果永远是非负的,即使str2比str1短,相减也会得到巨大的正数。
c
#include <stdio.h>
#include <string.h>
int main() {
const char* str1 = "abcdef";
const char* str2 = "bbb";
// 易错点:无符号整数相减,结果永远大于0
if (strlen(str2) - strlen(str1) > 0) {
printf("str2>str1\n");
} else {
printf("str1>str2\n");
}
// 实际输出:str2>str1(因为无符号数相减变成了一个巨大的正数)
return 0;
}

3.2 模拟实现的三种方式
方式1:计数器方式
最直观的写法,通过遍历字符数组并递增计数器,直到遇到 \0。
c
#include <assert.h>
int my_strlen(const char * str) {
int count = 0;
assert(str); // 断言防止空指针
while(*str) {
count++;
str++;
}
return count;
}
这段模拟 strlen 的代码可以精炼为以下四个核心关键点:
- 参数安全 :
const修饰防止误改原字符串,assert断言拦截空指针(NULL),避免运行时段错误。 - 循环机制 :利用
while(*str)判断'\0'自动终止;str++利用 char 类型步长为 1 的特性遍历字符串。 - 核心逻辑 :外部计数器
count,每循环一次自增,精准统计'\0'前的字符个数。 - 进阶优化 :严格来说,返回值应改为
size_t(无符号整数),更契合标准库设计,避免大长度溢出及无符号隐式转换陷阱。
方式2:递归方式(不创建临时变量)
利用递归压栈的特性,如果指针不是 \0,就返回 1 + my_strlen(str+1)。
c
#include <assert.h>
int my_strlen(const char * str) {
assert(str);
if (*str == '\0')
return 0;
else
return 1 + my_strlen(str + 1);
}
这段递归版 my_strlen 的代码可以精炼为以下四个核心关键点:
- 参数安全 :
const保护源字符串不被修改,assert拦截空指针(NULL),确保运行安全。 - 递归终止条件 :
*str == '\0'时返回 0,这是递归的"归"点,防止无限递归。 - 核心递推逻辑 :
return 1 + my_strlen(str + 1)。当前字符非\0则长度算作 1,加上从下一个字符开始的子串长度。 - 进阶风险(栈溢出) :工程中严禁用递归实现
strlen。若字符串过长,函数调用层次过深会导致栈溢出(Stack Overflow);且返回值类型int应改为标准库的size_t。
方式3:指针相减(最能体现指针精髓的方式)
记录起始地址,让指针走到末尾,两者相减得到的就是元素个数。
c
#include <assert.h>
int my_strlen(const char *s) {
assert(s);
const char *p = s;
while (*p != '\0') {
p++;
}
// 指针相减,得到中间经历的元素个数(char步长为1,所以个数等同字节数)
return p - s;
}
这段"指针相减"版 my_strlen 的代码可以精炼为以下四个核心关键点:
- 参数安全 :
const保护源字符串不被修改,assert拦截空指针(NULL),确保运行安全。 - 指针备份 :
const char *p = s;保留原起始地址s,用临时指针p去向后遍历,为最后的相减做准备。 - 遍历逻辑 :
while (*p != '\0') p++;让指针p步长为 1 向后移动,直到遇到字符串结束标志\0。 - 指针运算精髓(核心) :
return p - s;利用指针相减得到元素个数 的法则。因为char类型步长为 1 字节,所以这里的元素个数恰好等于字符长度。(注:严格工程中返回值应改为size_t)。
四、strcpy的使用和模拟实现
char* strcpy(char * destination, const char * source);
- 将源字符串(包括终止空字符
\0)复制到目标数组。 - 源字符串必须以
'\0'结束。 - 目标空间必须足够大且可修改。
模拟实现要点 :参数顺序(目标在前,源在后)、参数有效性判断(assert)、返回值(支持链式访问)。
c
#include <stdio.h>
#include <assert.h>
char* my_strcpy(char *dest, const char *src) {
char *ret = dest; // 保存目标地址首地址
assert(dest != NULL);
assert(src != NULL);
// 将 *src 赋值给 *dest,并同时后置递增指针,最后判断赋值结果是否为 \0
while ((*dest++ = *src++)) {
;
}
return ret; // 返回目标起始地址,支持嵌套调用
}
int main() {
char arr1[20] = {0};
const char* arr2 = "hello world";
my_strcpy(arr1, arr2);
printf("%s\n", arr1);
return 0;
}

值得注意的几点:
- 目标空间必须足够大:否则越界写,未定义行为。
- 源串必须以
'\0'结尾:否则循环不停止,继续越界复制。 - 不支持内存重叠 :
dest与src重叠时不能保证正确,应用memmove。 assert发布版可能失效 :定义NDEBUG后断言被移除,不能只靠它判空。- 核心表达式优先级 :
*dest++ = *src++是*(dest++) = *(src++),先赋值再后移。 '\0'也会被复制 :循环结束时'\0'已写入目标。- 必须返回
ret:保存的是目标首地址,不能用移动后的dest返回,否则链式调用会错。
五、strcat的使用和模拟实现
char* strcat(char * destination, const char * source);
- 将源字符串的副本追加到目标字符串的末尾(覆盖目标字符串的
\0,并在最后添加新的\0)。 - 目标字符串必须有
'\0',且目标空间必须足够大。
模拟实现要点 :先找到目标字符串的 \0,再从该位置开始拷贝。
c
#include <stdio.h>
#include <assert.h>
char *my_strcat(char *dest, const char *src) {
char *ret = dest;
assert(dest != NULL);
assert(src != NULL);
// 1. 找到dest末尾的 \0
while (*dest) {
dest++;
}
// 2. 开始追加拷贝,同样用到赋值后的结果判断
while ((*dest++ = *src++)) {
;
}
return ret;
}
int main() {
char arr1[20] = "hello ";
const char* arr2 = "world";
my_strcat(arr1, arr2);
printf("%s\n", arr1); // 输出 hello world
return 0;
}

⚠️深度思考 :字符串自己给自己追加,如何?比如 my_strcat(arr1, arr1);。
这会导致灾难性的后果。在寻找 \0 的过程中,指针指向了末尾,但随后 src 指向的首个字符也是同一位置,此时目标空间开始被覆盖,src 指针永远无法遇到 \0,导致无限循环和内存越界,程序最终崩溃。务必避免自己给自己追加!
六、strcmp的使用和模拟实现
int strcmp(const char * str1, const char * str2);
- 比较两个字符串对应位置字符的 ASCII 码值。
- 第一个字符串大于第二个字符串,则返回大于0的数字;等于则返回0;小于则返回小于0的数字。
模拟实现要点 :直接返回 *str1 - *str2 的差值,为避免平台 char 有符号性差异导致结果异常,通常需要强制转换为 unsigned char。
c
#include <stdio.h>
#include <assert.h>
int my_strcmp(const char * str1, const char * str2) {
assert(str1 != NULL);
assert(str2 != NULL);
while (*str1 == *str2) {
if (*str1 == '\0') {
return 0; // 走到 \0 且相等,说明完全一致
}
str1++;
str2++;
}
// 返回差值,根据正负判断大小
return *(const unsigned char*)str1 - *(const unsigned char*)str2;
}
int main() {
printf("%d\n", my_strcmp("abc", "abc")); // 0
printf("%d\n", my_strcmp("abd", "abc")); // 正数
printf("%d\n", my_strcmp("abc", "abd")); // 负数
return 0;
}

值得注意的点:
unsigned char强转(绝对核心) :
*(const unsigned char*)str1 - *(const unsigned char*)str2这是精髓。若不转,当字符 ASCII 码大于 127(如有符号 char 的负数)相减时会溢出,导致大小判断彻底反转。标准库规定必须按unsigned char比较。- 返回值只关注正负零 :
返回的是字符差值,不一定是1或-1。调用时只能判断<0、0、>0,绝不能写== 1或== -1。 - 循环退出条件 :
while (*str1 == *str2)只有遇到不等字符或双方都是'\0'时退出。内部if (*str1 == '\0')用于捕获"完全一致"的情况并提前返回 0。 - 先判
\0再后移指针 :
顺序极其重要。如果先str1++,就可能跳过'\0'判断,导致越界。
核心是转为 unsigned char 后计算差值,返回值仅凭正负零判断大小。
七、strncpy函数的使用
char * strncpy(char * destination, const char * source, size_t num);
- 拷贝
num个字符从源字符串到目标空间。 - 细节 :如果源字符串的长度小于
num,则拷贝完源字符串(包含\0)之后,在目标的后边追加0,直到满足num个。 - 危险陷阱 :如果源字符串的长度大于等于
num,则不会自动添加\0,此时目标字符串可能不以\0结尾,直接打印会越界。
c
#include <stdio.h>
#include <string.h>
int main() {
char dest[20] = {0}; // 初始化为全0,确保安全
const char* src = "hello world";
// 拷贝5个字符,不会添加 \0
strncpy(dest, src, 5);
printf("dest = %s\n", dest); // 输出 hello
// 测试补充 0 的情况
char dest2[20] = "XXXXXXXXXX";
strncpy(dest2, "abc", 5);
// "abc" 长度小于 5,所以拷贝 abc 后再补两个 \0
printf("dest2 = %s\n", dest2); // 输出 abc
return 0;
}

** 值得注意的地方:**
- 绝不保证
\0结尾(最致命的坑) :
如果src长度 ≥ n \ge n ≥n,strncpy只会拷贝n个字符,不会 在末尾添加\0。此时dest只是一个字符数组,不是合法字符串。直接printf或strlen会越界读取,直到内存中偶然遇到\0。 - 第一段代码是"幸存者偏差" :
第一段代码看似安全,完全依赖char dest[20] = { 0 };这个初始化。如果写成char dest[20];,就是未定义行为。不能依赖这种巧合。 - 主动补
\0特性 :
当src长度 < n < n <n 时,strncpy会用\0填充dest剩余的所有空间(如图中dest2补了 2 个\0)。 - 安全使用标准范式 :
使用strncpy后,必须手动添加\0:
dest[n - 1] = '\0';(或者如果n小于sizeof(dest),在拷贝后手动设置)。 n不能超过dest总大小 :
n是"最大拷贝字节数",但不是目标缓冲区的大小 。如果n > sizeof(dest),同样会缓冲区溢出。- 性能陷阱 :
如果dest空间巨大,而src很短(如n = 4096,src = "a"),strncpy会把剩下 4095 字节全填成\0,造成不必要的性能浪费。
八、strncat函数的使用
char * strncat(char * destination, const char * source, size_t num);
- 将
source指向字符串的前num个字符追加到destination指向的字符串末尾,再追加一个\0字符。 - 如果
source指向的字符串的长度小于num,只会将字符串中到\0的内容追加到末尾。
c
#include <stdio.h>
#include <string.h>
int main() {
char str1[20] = "To be ";
char str2[20] = "or not to be";
// 追加 str2 的前6个字符
strncat(str1, str2, 6);
printf("%s\n", str1); // 输出 To be or not
return 0;
}

值得注意的地方:
- 总是自动补
\0(与strncpy最大区别) :
这是strncat最安全的特性。即使src的长度超过n,截断后它必定会 在结尾添加'\0'。不需要像strncpy那样手动补\0。 n不是目标缓冲区剩余空间 :
n是最多从src拷贝的字符数 。如果dest剩余空间不够(原字符串 + 追加字符 +'\0'超出dest总大小),依然会缓冲区溢出 。它只是限制了追加的最大长度,并不保护dest的边界。src长度小于n时的行为 :
如果src是"ab",n = 6,则只会追加'a','b'和'\0',不会 像strncpy那样用\0填满剩余空间。dest必须是合法字符串 :
strncat从dest的'\0'处开始追加。如果dest没有'\0'(例如是strncpy截断后的字符数组),会越界寻找末尾,导致未定义行为。n为 0 的情况 :不追加任何字符,但可能会在dest末尾写入'\0'(取决于具体实现,标准未完全规定,但通常会追加'\0')。
九、strncmp函数的使用
int strncmp(const char * str1, const char * str2, size_t num);
- 比较
str1和str2的前num个字符,如果相等就继续往后比较,最多比较num个字母。 - 如果提前发现不一样,就提前结束,大的字符所在的字符串大于另外一个。如果
num个字符都相等,就是相等返回0。
c
#include <stdio.h>
#include <string.h>
int main() {
const char* str1 = "abcdef";
const char* str2 = "abcxyz";
// 比较前3个字符
int ret1 = strncmp(str1, str2, 3);
printf("前3个字符比较结果: %d\n", ret1); // 0
// 比较前4个字符
int ret2 = strncmp(str1, str2, 4);
printf("前4个字符比较结果: %d\n", ret2); // 负数 ('d' < 'x')
return 0;
}

值得注意的地方:
- 返回值绝不保证是
1或-1:
标准只保证<0、0、>0。千万不要写if (strncmp(...) == -1),必须写成< 0。 - 遇到
'\0'会提前终止 :
它不会死板地比较满n个字符。如果其中一个字符串提前遇到了'\0',比较就会立刻结束。例如strncmp("ab", "abc", 5),会走到'\0'与'c'比较,返回值取决于'\0'(0) 与'c'的差值。 n为 0 时直接返回 0 :
无论字符串内容是什么,strncmp(a, b, 0)永远返回 0。- 不检查
NULL指针 :
如果str1或str2是NULL,直接崩溃。不要指望它内部有断言。 - 用于前缀比较的绝佳工具 :
常用于判断某个字符串是否以特定前缀开头,如if (strncmp(cmd, "GET ", 4) == 0)。 - 安全性 :相比于
strcmp,strncmp限制了最大比较长度,不容易因为一方缺少'\0'而导致无限越界读取,所以常用于处理可能不安全的字符数组。
十、strstr的使用和模拟实现
char * strstr(const char * str1, const char * str2);
- 返回字符串
str2在字符串str1中第一次出现的位置(指针)。 - 如果没找到,返回
NULL。 - 匹配过程不包含
\0,但以\0作为结束标志。
模拟实现要点 :指针遍历与回溯。利用 cur 指针记录当前主串对比的位置,s1 和 s2 进行逐字符匹配,如果匹配失败,cur 后移,重新开始。
c
#include <stdio.h>
#include <assert.h>
char * my_strstr(const char * str1, const char * str2) {
assert(str1 != NULL);
assert(str2 != NULL);
const char *cur = str1;
const char *s1;
const char *s2;
// 如果子串为空,直接返回主串首地址
if (*str2 == '\0') {
return (char*)str1;
}
while (*cur) {
s1 = cur;
s2 = str2;
// 逐个字符匹配,直到遇到不相等或 \0
while (*s1 && *s2 && (*s1 == *s2)) {
s1++;
s2++;
}
// 如果 s2 走到了 \0,说明匹配成功
if (*s2 == '\0') {
return (char*)cur;
}
// 否则主串从下一个字符重新开始
cur++;
}
return NULL;
}
int main() {
char str[] = "This is a simple string";
char* pch = my_strstr(str, "simple");
if (pch != NULL) {
printf("找到子串,位置开始于: %s\n", pch); // 输出 simple string
}
return 0;
}

值得注意的地方:
- 返回值强转
(char*)及const陷阱 :
形参是const char*,函数返回char*,必须强转(char*)cur。这是 C 标准库的历史遗留设计。但调用者绝不能通过返回的指针去修改原字符串,如果原字符串在只读区,会导致段错误。 - 空子串的边界条件 :
if (*str2 == '\0') return (char*)str1;这一句极易遗漏。标准规定:空子串匹配主串开头。若不处理,外层循环while(*cur)可能会直接返回NULL。 - 匹配成功判定的是
*s2:
内层循环结束后,必须判断*s2 == '\0',而不是*s1。若主串在匹配中途结束(如主串"ab"找子串"abc"),s2不会走到末尾,匹配失败。 - 暴力匹配的效率问题(最坏 O(N*M)) :
这是暴力算法(BF算法)。遇到大量重复字符(如主串"aaaaaaaaab"找子串"aaab")时,cur每次只往后挪一步,效率极低。实际工程或标准库通常使用 KMP、BM 等 O(N) 算法。 - 找不到返回
NULL:
调用者必须判空,如main函数中所示,否则printf("%s", NULL)会崩溃。
十一、strtok函数的使用
char * strtok(char * str, const char * sep);
sep参数指向一个字符串,定义了用作分隔符的字符集合。strtok找到str中的下一个标记,并将其用\0结尾,返回指向这个标记的指针。- 注意 :
strtok会改变被操作的字符串,所以被切分的字符串一般都是临时拷贝的内容并且可修改。 - 第一次调用需传入字符串地址,后续调用需传入
NULL。
c
#include <stdio.h>
#include <string.h>
int main() {
char arr[] = "192.168.6.111";
const char* sep = ".";
char* str = NULL;
// 第一次调用传入数组名
for (str = strtok(arr, sep); str != NULL; str = strtok(NULL, sep)) {
printf("%s\n", str);
}
// 输出:192, 168, 6, 111
return 0;
}

值得注意的点:
- 必须传可修改的字符数组(最大坑) :
strtok会直接修改原字符串 (把分隔符替换为'\0')。所以这里写char arr[]是对的;如果写成char* arr = "192.168...",会因修改只读内存而程序崩溃。 - 首次传原串,后续必须传
NULL(核心用法) :
第一次调用传入arr,函数内部用静态变量 记录当前位置。后续调用只能传NULL,告诉它"从上一次的位置继续切"。传错会导致从头开始或结果异常。 - 不能嵌套调用 :
在遍历strtok的同时,如果内部又调用了strtok处理另一个字符串,外层状态会被破坏。 - 连续分隔符会被忽略 :
例如"192..168",中间连续两个点,strtok会跳过它们,不会返回空字符串。开头和结尾的分隔符也会被忽略。 sep是分隔符集合 :
sep = "."表示"以点号作为分隔符"。如果写成sep = ".,",那么点号和逗号都会被视为分隔符。
十二、strerror 与 perror函数的使用
char* strerror(int errnum);
- 将参数部分错误码对应的错误信息的字符串地址返回。
- 错误码一般放在
<errno.h>头文件中说明。C语言程序启动的时候就会使用一个全局变量errno来记录程序的当前错误码。
c
#include <errno.h>
#include <string.h>
#include <stdio.h>
int main() {
// 我们打印一下 0~10 这些错误码对应的信息
int i = 0;
for (i = 0; i <= 10; i++) {
printf("错误码 %d: %s\n", i, strerror(i));
}
return 0;
}

在 Windows 11 + VS2022 环境下,会输出:No error, Operation not permitted, No such file or directory, 等等。
实际应用:尝试打开一个不存在的文件,打印错误信息。
c
#include <stdio.h>
#include <string.h>
#include <errno.h>
int main() {
FILE *pFile = fopen("unexist.ent", "r");
if (pFile == NULL) {
printf("Error opening file unexist.ent: %s\n", strerror(errno));
}
return 0;
}

perror函数 :相当于把上述代码中的 printf 和 strerror(errno) 结合起来。perror 打印完参数部分的字符串后,再打印一个冒号和一个空格,最后打印错误信息。
c
#include <stdio.h>
#include <errno.h>
int main() {
FILE *pFile = fopen("unexist.ent", "r");
if (pFile == NULL) {
perror("Error opening file unexist.ent");
}
return 0;
}
// 输出:Error opening file unexist.ent: No such file or directory

四、总结
结合之前的指针进阶知识,我们可以提炼出以下字符串处理的通关心法:
- sizeof 与 strlen 不可混用 :
sizeof是编译期操作符,算内存大小;strlen是运行期库函数,找\0算长度。strlen返回size_t无符号数,相减需谨慎。 - 数组名的退化 :在传参时,数组名退化为首元素地址,函数内无法通过
sizeof获取数组真实大小,必须依赖\0或额外的长度参数。 - 指针类型决定步长与运算 :指针相减算元素,在
strlen的第三种模拟实现中体现得淋漓尽致。在strstr等模拟实现中,指针的步进与回溯是匹配逻辑的关键。 - 模拟实现防错 :编写标准库函数的模拟实现时,务必使用
assert进行空指针断言,使用const保护源字符串数据,注意参数顺序和返回值类型。 - 警惕字符串函数陷阱 :
strcat不能自己追加自己。strncpy不一定补\0。strtok会修改原字符串,且使用静态变量,不可重入。strerror和perror是排查系统调用错误的利器。
指针与字符串的结合,不仅是语法游戏,更是对计算机内存模型的深度理解。希望这篇博客能帮你在C语言的海量字符串函数中拨云见日,顺利通关!如果觉得有帮助,欢迎点赞收藏,留个关注支持一下!