你有没有好奇过,为什么 C 语言里没有"字符串类型"?Python 有 str,Java 有 String,C++ 有 std::string,唯独 C 语言是用字符数组来凑合。很多人觉得这是 C 语言设计上的一个缺陷——其实恰恰相反,这体现了 C 语言"给你最大控制权"的设计哲学。既然你已经掌握了数组和指针(没忘吧?数组本质上就是一段连续的内存,指针就是追踪内存地址的工具),那字符串对你来说不过就是"末尾带 '\0' 的字符数组"而已。

带着这个认知,我们今天来看看 C 标准库给我们准备了哪些"字符串处理工具箱"——从判断一个字符是数字还是字母,到拷贝、拼接、查找、切割字符串,应有尽有。这些函数看似简单,但其中暗藏的指针操作逻辑和边界安全问题,足够让你从"能写出字符串代码"跨越到"写的代码不会随时爆炸"。

字符串的本质:一段带终止符的内存

先说清楚 C 语言里字符串的本质:"hello" 这个字面量在内存中实际占 6 个字节——'h', 'e', 'l', 'l', 'o', '\0'。最后这个 '\0'(ASCII 码为 0,也叫空字符 null character)就是字符串的"句号"。C 语言里几乎所有字符串函数都依赖它来判断终点——遇到 '\0' 就停,没有 '\0' 就一直往前冲、直到撞上什么奇怪的东西然后崩溃。

/* 观察字符串在内存中的真实模样 */
#include <stdio.h>
 
int main()
{
    /* 注意:编译器会自动在末尾补一个 '\0',所以实际占 6 个字节 */
    char str[] = "hello";
 
    printf("sizeof(str) = %zu\n", sizeof(str));   /* 输出 6,包含 '\0' */
    printf("strlen(str) = %zu\n", strlen(str));   /* 输出 5,不含 '\0' */
 
    /* 逐个打印每个字节的 ASCII 码,看看字符串的"真身" */
    int i;
    for (i = 0; i < sizeof(str); i++)
    {
        printf("str[%d] = %d (0x%02X)\n", i, str[i], (unsigned char)str[i]);
    }
    /* 输出:
       str[0] = 104 (0x68)   'h'
       str[1] = 101 (0x65)   'e'
       str[2] = 108 (0x6C)   'l'
       str[3] = 108 (0x6C)   'l'
       str[4] = 111 (0x6F)   'o'
       str[5] = 0   (0x00)   '\0'  ← 字符串的"句号" */
 
    return 0;
}

一个极其容易混淆、但又必须分清楚的细节是:sizeof 和 strlen 的区别。

  • sizeof(str) 是编译器在编译期算出来的"数组占多少字节"——它看的是数组声明,是 char[6],所以是 6。
  • strlen(str) 是运行时数出来的"有多少个字符在 '\0' 之前"——它要看内存里的实际内容,所以是 5。
  • 如果 str 是一个指针(比如函数参数 char *s),sizeof(s) 是 4 或 8(指针大小),而不是字符串长度!这是初学者最常踩的坑之一。

还有一个写代码时必须清楚的区别:char str[] = "hello"; 和 char *str = "hello"; 是两回事。

写法存储位置内容能否修改
char str[] = "hello";栈上(或全局数据段,取决于声明位置)的数组可以修改
char *str = "hello";常量区(只读数据段),str 只是一个指针不能修改

如果你写了 char *s = "hello"; 然后试图 s[0] = 'H';,大概率会触发段错误——因为你在修改只读内存。这个坑,很多新手都要掉进去一次才能记住。更隐蔽的是:char *s1 = "hello"; char *s2 = "hello"; 在某些编译器上 s1 == s2 可能为真(编译器把两个字面量合并成一份),但这属于实现细节,别依赖它。

字符分类函数

在 <ctype.h> 中,C 语言提供了一组字符分类函数,它们的签名非常统一:接收一个 int 类型的字符(char 会被隐式提升为 int),返回非零值表示"是",返回 0 表示"否"。注意返回值是 int 不是 bool——C 语言里没有真正的布尔类型(C99 才引入 _Bool),非零即真。

函数判断条件通俗理解
isalpha(c)是否为字母 (A-Z, a-z)"这是英文字母吗?"
isdigit(c)是否为数字 (0-9)"这是数字字符吗?"
isalnum(c)isalpha 或 isdigit"这是字母或数字吗?"
islower(c)是否为小写字母"这是小写吗?"
isupper(c)是否为大写字母"这是大写吗?"
isspace(c)是否为空白字符(空格、换行、制表等)"这是空白吗?"
ispunct(c)是否为标点符号"这是标点吗?"
isprint(c)是否可打印(含空格)"屏幕上能看到吗?"
isgraph(c)是否可打印(不含空格)"能看到且不是空白?"
iscntrl(c)是否为控制字符(如 '\0'、'\n'、退格)"是不可见的控制码吗?"
isxdigit(c)是否为十六进制数字 (0-9, A-F, a-f)"是十六进制数字吗?"
isblank(c)是否为空白(空格或制表符 \t)"是空格或 Tab 吗?"(C99)

其中 isgraph 和 isprint 只差一个空格:空格属于 isprint 但不属于 isgraph。isspace 和 isblank 的区别:isspace 认空格、\t、\n、\v、\f、\r 六种;isblank 只认空格和 \t 两种。顺带一提:'\n'(换行)的 ASCII 码是 10,'\0'(空字符)是 0——注意区分这两个容易看混的转义序列。

这里有一个容易被忽视的使用细节:这些函数(以及后面的 toupper/tolower)的参数虽然声明成 int,但合法取值只有 EOF 和能被 unsigned char 表示的值。换句话说,你不能随便传一个负的 char 进去——比如某个字节恰好是 0x80 以上的值,在 char 为有符号的平台上它是个负数,直接传给 islower(c) 属于未定义行为。所以把 char 喂给字符函数前,最好先转成 unsigned char,后面 stristr 的代码里你会看到这个习惯用法。

这些函数在实际项目中远比想象的常用。写个配置文件解析器,你需要判断当前读到的是数字还是字母;实现一个简易的词法分析器,你要区分标识符和运算符——这些都离不开字符分类函数。来看一个例子:

#include <stdio.h>
#include <ctype.h>
 
int main()
{
    char str[] = "Hello World! 123";
    int i = 0;
    /* 遍历字符串中的每个字符 */
    while (str[i])
    {
        char c = str[i];
        /* 依次判断字符类型并打印对应标签 */
        if (islower(c))
            printf("'%c' 是小写字母\n", c);
        else if (isupper(c))
            printf("'%c' 是大写字母\n", c);
        else if (isdigit(c))
            printf("'%c' 是数字\n", c);
        else if (isspace(c))
            printf("'%c' 是空白字符\n", c);
        else if (ispunct(c))
            printf("'%c' 是标点符号\n", c);
        i++;
    }
    return 0;
}

用字符分类函数做一个小工具:统计文本构成

上面的例子只是"逐个打印标签",实际项目中我们更常做的是统计。比如统计一段文本里有多少个字母、多少个数字、多少个空白:

#include <stdio.h>
#include <ctype.h>
 
int main()
{
    char text[] = "C language, born in 1972! It's still alive.\n";
    int letters = 0, digits = 0, spaces = 0, puncts = 0, others = 0;
    int i = 0;
 
    while (text[i])
    {
        unsigned char c = (unsigned char)text[i];  /* 注意转 unsigned char */
        if (isalpha(c))       letters++;
        else if (isdigit(c))  digits++;
        else if (isspace(c))  spaces++;
        else if (ispunct(c))  puncts++;
        else                  others++;
        i++;
    }
 
    printf("字母: %d, 数字: %d, 空白: %d, 标点: %d, 其他: %d\n",
           letters, digits, spaces, puncts, others);
    return 0;
}

这其实就是很多文本分析工具(wc 命令、词法分析器)的雏形。你看,isalpha 比手写 (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') 清晰得多,而且对非 ASCII 扩展字符的处理也更符合标准。

字符分类函数的底层实现原理

这些函数的内部实现其实就是一个"查表"操作:标准库在初始化时构建一张 256 项(0~255)的查找表,每个表项记录该字符的属性位。isalpha(c) 等价于查表然后检查"字母位"是否被设置。这也是为什么它们要求参数必须是 unsigned char 能表示的值——超出范围就是数组越界访问了。C 语言标准特意规定:这些函数在收到 EOF 时返回 0,所以你可以放心地把 getchar() 的返回值(它可能是 EOF = -1)直接喂给它们。

/* 用位掩码模拟字符分类函数的查表思路(示意,非真实实现) */
#include <stdio.h>
 
#define IS_DIGIT (1 << 0)   /* 第 0 位:是数字 */
#define IS_ALPHA (1 << 1)   /* 第 1 位:是字母 */
#define IS_SPACE (1 << 2)   /* 第 2 位:是空白 */
 
/* 256 项的查找表:下标是字符的字节值(0~255),值是属性位集合 */
static const unsigned char table[256] = {0};
 
/* 初始化查找表:把 ASCII 码落在对应区间的字符标上属性位 */
static void init_table(void)
{
    int c;
    for (c = 0; c < 256; c++)
    {
        if (c >= '0' && c <= '9')       table[c] |= IS_DIGIT;
        if ((c >= 'A' && c <= 'Z') ||
            (c >= 'a' && c <= 'z'))     table[c] |= IS_ALPHA;
        if (c == ' ' || c == '\t' ||
            c == '\n' || c == '\v' ||
            c == '\f' || c == '\r')     table[c] |= IS_SPACE;
    }
}
 
/* 三个"分类函数"都只是查表 + 与位掩码 */
int my_isdigit(int c) { return table[(unsigned char)c] & IS_DIGIT; }
int my_isalpha(int c) { return table[(unsigned char)c] & IS_ALPHA; }
int my_isspace(int c) { return table[(unsigned char)c] & IS_SPACE; }
 
int main()
{
    init_table();
    printf("%d %d %d\n", my_isdigit('7'), my_isalpha('g'), my_isspace('\t'));
    /* 输出: 1 1 1 */
    return 0;
}

真实的标准库实现思路与此类似:一张属性表 + 位掩码,isalpha(c) 就是查表后与"字母位"做与运算。这也从底层解释了为什么参数必须落在 unsigned char 的取值范围内——下标越界就是访问表外内存。

字符转换函数

跟字符分类紧密相关的是两个转换函数:tolower(c) 把大写字母转成小写,toupper(c) 反之。传入非字母字符时,它们原样返回。虽然我们知道在 ASCII 表上 'a' - 'A' = 32,直接 ±32 也能完成大小写转换,但直接写数字的代码可读性太差了——六个月后你自己都看不懂为什么有个 -32 在那里。更重要的是:ASCII 表上大小写字母恰好差 32 是巧合(因为 ASCII 把大小写字母设计成只有第 5 位不同),但 EBCDIC 编码里这个规律不成立。用标准函数才可移植。

#include <stdio.h>
#include <ctype.h>
 
int main()
{
    int i = 0;
    char str[] = "Test String.\n";
    char c;
 
    /* 遍历到字符串末尾('\0')为止 */
    while (str[i])
    {
        c = str[i];
        /* 如果是小写字母,则转换为大写 */
        if (islower(c))
            c = toupper(c);
        putchar(c);  /* 输出当前字符 */
        i++;
    }
    /* 输出: TEST STRING. */
    return 0;
}

注意 tolower/toupper 的返回值类型是 int,和分类函数一样,参数范围同样限定为 unsigned char 可表示的值加 EOF。

strlen

字符分类和转换讲完了,接下来我们进入字符串函数的"主菜"。先从最简单的开始——strlen,字符串的"尺子"。它定义在 <string.h> 中。

size_t strlen ( const char * str );

strlen 的原理非常直白:从给定地址开始,一直往后数,直到遇见 '\0' 为止,返回 '\0' 之前出现的字符个数。但这里面藏着两个重要的细节。

第一,strlen 不关心指针指向的内存是否有效,它只管一路向前。 如果你传了一个没有 '\0' 结尾的字符数组,strlen 会一直读取下去,直到在某处碰巧遇到一个字节值为 0 的位置,或者触发段错误。这就是为什么你经常在调试器里看到 strlen 返回一个离奇大的数字——它在野指针的世界里迷路了。这也解释了一个常见的困惑:为什么下面这段代码的结果是"未定义"——

char buf[10] = "hello";   /* buf[5..9] 是 '\0' 吗? */
printf("%zu\n", strlen(buf));  /* 输出 5,因为 buf[5] 恰好是 '\0'(初始化时补的) */

char buf[10] = "hello"; 时,编译器会把剩下的 5 个字节全部补成 '\0',所以 strlen 是 5。但如果你写 char buf[10]; strcpy(buf, "hello");,buf[5] 是 '\0',后面 buf[6..9] 是垃圾值——不过 strlen 遇到 buf[5] 就停了,所以还是 5。真正危险的是 char buf[10]; buf[0]='h'; buf[1]='i'; 这种手工构造——没有终止符,strlen 就不知道什么时候停。

第二,它的返回值是 size_t——一个无符号整数类型。 这一点引起的 bug 可能比你想象的要多。看下面这段代码:

#include <stdio.h>
#include <string.h>
 
int main()
{
    const char *str1 = "abcdef";  /* 6 个字符 */
    const char *str2 = "bbb";     /* 3 个字符 */
 
    /* strlen 返回 size_t(无符号),3 - 6 = 一个巨大的正数 */
    if (strlen(str2) - strlen(str1) > 0)
    {
        printf("str2 > str1\n");  /* 会打印这行!因为无符号溢出 */
    }
    else
    {
        printf("str1 > str2\n");
    }
    return 0;
}

3 - 6 = -3,但因为是无符号运算,-3 变成了一个巨大的正数(约 43 亿),于是 > 0 成立,打印了 str2 > str1——这显然不是你想要的结果。正确做法是直接写 strlen(str2) > strlen(str1),不要用减法。类似地,for (i = strlen(s) - 1; i >= 0; i--) 也是一个死循环——strlen(s) - 1 当 strlen(s) 为 0 时变成 (size_t)(-1) 即 4294967295。这也是为什么倒序遍历字符串要小心空串。

strlen 的模拟实现:三种经典写法

strlen 的模拟实现也是指针操作的经典练习题,三种写法各有特色:

#include <stdio.h>
#include <assert.h>
 
/* 方式1:计数器方式——最直观 */
size_t my_strlen1(const char *str)
{
    size_t count = 0;
    assert(str);          /* 防御性编程:检查空指针 */
    while (*str != '\0')  /* 遇到 '\0' 停止 */
    {
        count++;
        str++;            /* 指针后移 */
    }
    return count;
}
 
/* 方式2:递归方式——不创建临时变量 */
size_t my_strlen2(const char *str)
{
    assert(str);
    if (*str == '\0')
        return 0;
    else
        return 1 + my_strlen2(str + 1);  /* 每次递归处理一个字符 */
}
 
/* 方式3:指针相减——利用同数组内指针差等于元素个数 */
size_t my_strlen3(const char *str)
{
    const char *p = str;
    assert(str);
    while (*p != '\0')
        p++;
    return p - str;  /* 末尾指针 - 起始指针 = 字符个数 */
}
 
int main()
{
    const char *msg = "Hello";
    printf("strlen1: %zu\n", my_strlen1(msg));
    printf("strlen2: %zu\n", my_strlen2(msg));
    printf("strlen3: %zu\n", my_strlen3(msg));
    return 0;
}

三种方式哪个好?方式 1 最直观,方式 2 练递归但会爆栈(字符串很长时递归深度等于字符串长度),方式 3 的"指针相减"是最接近 C 语言本质的写法——C 标准规定:两个指向同一数组内元素的指针相减,结果等于它们之间的元素个数。这也是 &arr[5] - &arr[2] == 3 的原因。注意:指针相减的结果类型是 ptrdiff_t(带符号),而不是 size_t。

思考题:my_strlen3 中,如果 str 是一个空字符串 "",p - str 是 0 吗?是的——p 一开始就停在 '\0' 上,差为 0,逻辑自洽。

优化版 strlen:一次处理 4 个字节

标准库的实际实现远没有上面这么"老实"。一个常见优化是:先逐字节检查对齐,然后每次读 4 个字节(一个 unsigned long),用位运算判断这 4 个字节里有没有 0,有 0 再逐个字节定位。这样对于长字符串,循环次数减少到原来的 1/4。这就是"空间换时间""向量化"思想的雏形。你不用现在就写出这种代码,但要理解:标准库函数不是玩具,它们的性能经过了数十年的打磨。

strcpy

有了 strlen 做铺垫,我们来看看最常用也最危险的字符串拷贝函数:

char* strcpy ( char * destination, const char * source );

strcpy 把源字符串(包括 '\0')完整复制到目标地址。它要求目标空间足够大,且源字符串必须以 '\0' 结尾——目标空间还必须可修改,你不能把字符串拷进一个字符串字面量,比如 char *d = "hello"; strcpy(d, "world"); 是未定义行为(字面量通常存放在只读区,运行时大概率崩溃)。如果你把 100 字节的字符串拷到一个 10 字节的缓冲区里——恭喜你,一个经典的缓冲区溢出漏洞就这样诞生了。在安全领域,这种溢出是攻击者最爱的入口:覆盖栈上的返回地址,执行任意代码。

为什么 strcpy 要返回 char* 而不是 void?

你可能注意到 strcpy 的返回值是 char *,而且返回的就是 destination 本身。既然我们已经知道目标在哪了,为什么还要返回它?两个原因:

  1. 链式调用:strcpy(strcpy(buf, "a"), "b") 可以嵌套。虽然这种写法可读性差,但它允许你把 strcpy 当作一个"表达式"嵌入到更大的表达式里。
  2. 与接口设计一致性:C 库函数(memcpy、strcat、strcpy)都返回目标指针,这是 C 库的传统——"函数既是操作,又返回结果"。

标准库原型的第一个参数是 char *destination(非 const),第二个是 const char *source(只读)。const 的位置传递了信息:dest 会被改写,src 只读。模仿标准库写自己的函数时,也要遵守这个 const 约定。

模拟实现 strcpy:一行代码的艺术

来看看它的模拟实现,这个 while 循环是很多公司的面试题:

#include <stdio.h>
#include <assert.h>
 
/* 模拟实现 strcpy:
   参数顺序:目标在前,源在后
   功能:将 src 拷贝到 dest,包含 '\0'
   返回值:目标地址,方便链式调用 */
char *my_strcpy(char *dest, const char *src)
{
    char *ret = dest;   /* 保存起始地址用于返回 */
    assert(dest != NULL);
    assert(src != NULL);
 
    /* 这个 while 循环是经典写法:
       先 *dest = *src,再判断赋值结果是否为 '\0' */
    while ((*dest++ = *src++))
    {
        ;  /* 空循环体,所有工作在条件表达式中完成 */
    }
    return ret;
}
 
int main()
{
    char dest[20];
    my_strcpy(dest, "Hello World");
    printf("拷贝结果: %s\n", dest);
    return 0;
}

注意那个 while ((*dest++ = *src++)) ——它先是把 *src 的值赋给 *dest,然后两个指针各自后移,再判断"刚刚赋的值是不是 '\0'"。如果是,循环结束;如果不是,继续。所有操作浓缩在一行代码里——这就是典型的 C 风格:紧凑、高效,但也需要你仔细琢磨才能完全理解。

拆解一下这行代码的执行顺序(这是面试最爱问的点):

  1. 计算 *src:取出 src 当前指向的字符。
  2. 赋值 *dest = *src:把该字符写入 dest 指向的位置。
  3. dest++ 和 src++:两个指针自增(后缀 ++ 在赋值之后执行)。
  4. 整个赋值表达式 (*dest++ = *src++) 的值是"被赋进去的那个字符"。
  5. 用这个值作为 while 的条件:非 '\0' 继续循环,是 '\0' 退出。

关键点:'\0' 也被拷贝进去了,然后循环才结束。所以 strcpy 一定把终止符一起拷走。这也意味着:循环结束时,src 和 dest 都指向了各自字符串末尾之后的位置——如果你后面还想用 dest 的起始地址,必须在循环前保存(char *ret = dest;)。

缓冲区溢出演示:strcpy 到底有多危险

#include <stdio.h>
#include <string.h>
 
int main()
{
    char small_buf[5];   /* 只有 5 个字节! */
    const char *long_str = "This string is way too long for the buffer!";
 
    /* 危险!strcpy 不管目标有多大,一路拷贝到 '\0' 为止 */
    strcpy(small_buf, long_str);
 
    /* 程序可能在这里还能"正常"运行,但栈已经被破坏了。
       在某些编译器/平台上会直接崩溃,在其他地方可能默默破坏
       相邻变量——这就是缓冲区溢出漏洞的根源。 */
    printf("small_buf = %s\n", small_buf);
 
    return 0;
}

这段代码不要运行——它就是教学用的反面教材。在真实攻击中,攻击者精心构造 long_str 的内容,让溢出恰好覆盖函数的返回地址,程序就会跳转到攻击者指定的代码。C 语言不会阻止你这么做,因为 C 信任程序员——这份信任既是力量的源泉,也是罪案的温床。

strcat

字符串拼接函数,先找到目标字符串的 '\0' 位置,然后从那里开始追加源字符串:

#include <stdio.h>
#include <assert.h>
 
char *my_strcat(char *dest, const char *src)
{
    char *ret = dest;
    assert(dest != NULL);
    assert(src != NULL);
 
    /* 第一步:找到 dest 的 '\0' 位置 */
    while (*dest)
    {
        dest++;
    }
    /* 第二步:从该位置开始拷贝 src,包含 '\0' */
    while ((*dest++ = *src++))
    {
        ;
    }
    return ret;
}
 
int main()
{
    char str[30] = "Hello ";
    my_strcat(str, "World!");
    printf("拼接结果: %s\n", str);
    return 0;
}

注意第一步的循环:while (*dest) dest++; 和 while (*dest != '\0') dest++; 完全等价——字符本身非零就意味着不是 '\0'。循环结束后 dest 停在 '\0' 的位置,第二步从这里开始覆盖式追加。这其实就是"strcpy 的前半段"。

strcat 有一个绝对不能碰的红线:你不能用 strcat 把一个字符串追加到它自身。标准明确规定源和目标不能重叠,自追加的行为是未定义的。为什么?你自己推演一遍:strcat(s, s) 中,第一步先找到 s 的 '\0'——但第二步边拷贝边把源指针往前移,源指向的内容正在被自己的追加动作覆盖,出现"自己踩自己"的错位。某些编译器上它可能"碰巧能工作",但换一个平台就可能直接崩溃——永远不要依赖未定义行为。

空间余量:strcat 之前,dest 必须预留 strlen(dest) + strlen(src) + 1 字节的空间。很多缓冲区溢出事故都是忘了这个公式。

strcmp

字符串比较函数,逐字符比较两个字符串的 ASCII 码值:

#include <stdio.h>
#include <assert.h>
 
int my_strcmp(const char *str1, const char *str2)
{
    assert(str1 != NULL);
    assert(str2 != NULL);
 
    /* 当对应字符相等且不为 '\0' 时继续 */
    while (*str1 == *str2)
    {
        if (*str1 == '\0')   /* 同时到达末尾,说明完全相等 */
            return 0;
        str1++;
        str2++;
    }
    /* 返回第一个不同字符的 ASCII 差值 */
    return *(unsigned char *)str1 - *(unsigned char *)str2;
}
 
int main()
{
    printf("abc vs abc: %d\n", my_strcmp("abc", "abc"));    /* 0 */
    printf("abc vs abd: %d\n", my_strcmp("abc", "abd"));    /* 负数 */
    printf("abd vs abc: %d\n", my_strcmp("abd", "abc"));    /* 正数 */
    return 0;
}

这里有一个跨编译器的陷阱:strcmp 标准只规定返回值"大于 0 / 等于 0 / 小于 0",不规定具体数值。VS 可能返回 -1, 0, 1,而 GCC 返回的是实际 ASCII 差值。所以千万不要写 if (strcmp(a, b) == 1) 来判断大小关系——这在不同的编译器上行为可能不一致。正确的写法是 if (strcmp(a, b) > 0)。

还有个细节:return *(unsigned char *)str1 - *(unsigned char *)str2; 为什么要强转成 unsigned char?因为标准规定 strcmp 按无符号字符比较。如果某个字符的字节值大于 127(比如扩展字符),直接相减可能因为符号扩展而算出错误的正负号。这里 str1 是 const char *,转成 const unsigned char * 再解引用才稳妥——上面为了简洁写成了 (unsigned char *),你自己写的时候记得加 const。

strcmp 的比较逻辑:先比第一个字符,不同就出结果;相同就比较第二个……直到某一方到 '\0'。所以 "abc" < "abd"(因为 c(99) < d(100)),"abc" < "abcd"(因为前者先到 '\0',空字符比任何字符都小)。字典序就是这个函数定义的顺序。

受限字符串函数三兄弟:strncpy、strncat、strncmp

前面讲的 strcpy、strcat、strcmp 被称为"不受限"(unbounded)字符串函数——因为它们没有长度参数来限制操作范围,全凭 '\0' 来判断终点。接下来的一组函数加了 num 参数,看起来更安全,但各自有自己的"隐藏条款"。先看总览:

函数签名最坏写入自动补 '\0'?备注
strncpystrncpy(dest, src, num)num 字节仅当 strlen(src) < num源太长时不补,是最大的坑
strncatstrncat(dest, src, num)num+1 字节总是补从 dest 的 '\0' 处开始追加
strncmpstrncmp(s1, s2, num)无写入-只比较前 num 个字符

strncpy:一个充满陷阱的"安全"函数

char * strncpy ( char * destination, const char * source, size_t num );

strncpy 从源字符串拷贝最多 num 个字符到目标。如果源长度小于 num,剩余位置会被填充 '\0';但如果源长度大于或等于 num,目标不会自动以 '\0' 结尾——这意味着你可能得到一个"不是字符串"的字符数组。

#include <stdio.h>
#include <string.h>
 
int main()
{
    char dest[10];
    /* 将 "HelloWorld123" 的前 5 个字符拷贝到 dest */
    strncpy(dest, "HelloWorld123", 5);
    /* 问题:strncpy 不会自动加 '\0',如果源长度 >= num */
    dest[5] = '\0';  /* 手动补 '\0',否则 printf 会越界 */
    printf("strncpy 结果: %s\n", dest);
 
    /* 演示自动补零:源长度(5) < num(10) */
    char dest2[10] = {'A','A','A','A','A','A','A','A','A','A'};
    strncpy(dest2, "Hi", 5);
    /* dest2 前2个字符是 'H','i',后面3个都是 '\0' */
    printf("自动补零演示: %c %c %d %d\n",
           dest2[0], dest2[1], dest2[2], dest2[3]);  /* 输出 H i 0 0 */
    return 0;
}

这个设计真的非常微妙——只有当你传的 num 严格大于源字符串长度时,strncpy 才会把剩下的位置用 '\0' 填满(比如源是 "Hi"、num 是 5,目标得到 'H' 'i' '\0' '\0' '\0');而当 num 小于或等于源长度时,目标都不会自动以 '\0' 结尾。特别留意"等于"这个边界:源是 "Hi"、num 恰好为 2,目标只得到 'H' 'i' 两个字符,依然没有终止符,printf 一上来就会越界。每次使用 strncpy 后,最好手动确保终止符的存在:dest[num-1] = '\0'; 是一个常见的安全习惯。

下面是 strncpy 的模拟实现,让你彻底看清它的行为:

#include <stdio.h>
#include <assert.h>
 
char *my_strncpy(char *dest, const char *src, size_t num)
{
    char *ret = dest;
    size_t i;
    assert(dest != NULL);
    assert(src != NULL);
 
    for (i = 0; i < num; i++)
    {
        /* 如果源还没结束,拷贝源字符;否则填充 '\0' */
        if (*src)
            *dest++ = *src++;
        else
            *dest++ = '\0';
    }
    return ret;
}
 
int main()
{
    char buf[10];
    my_strncpy(buf, "Hi", 5);
    printf("前4个字节的值: %d %d %d %d\n",
           (unsigned char)buf[0],
           (unsigned char)buf[1],
           (unsigned char)buf[2],
           (unsigned char)buf[3]);  /* 输出 72 105 0 0 */
    return 0;
}

strncat:比 strncpy 靠谱的追加

相比 strncpy 的"别扭",strncat 的行为就直观多了:

#include <stdio.h>
#include <string.h>
 
int main()
{
    char str1[20];  /* 确保足够大 */
    char str2[20];
 
    strcpy(str1, "To be ");
    strcpy(str2, "or not to be");
 
    /* 从 str2 追加 6 个字符到 str1 末尾,并自动加 '\0' */
    strncat(str1, str2, 6);
    printf("%s\n", str1);  /* 输出: To be or not */
 
    return 0;
}

strncat 追加最多 num 个字符,但总会自动追加一个 '\0'。如果源长度大于等于 num,则追加 num 个字符再补 '\0';如果源长度小于 num,则追加完整个源字符串(已经包含 '\0')就停止。相比之下 strncat 的行为更符合直觉,用起来也不必担心终止符的问题。

不过它同样不检查目标空间够不够——最坏情况下它往目标里写了 num + 1 个字符(num 个字符外加一个 '\0'),所以目标缓冲区至少要有 strlen(dest) + num + 1 个字节的余量。很多人以为加了 n 就万事大吉,其实 num 限制的只是"追加多少个字符",跟目标缓冲区的大小没有半毛钱关系。

strncmp:比较前缀

比较最多 num 个字符。如果在前 num 个字符内发现差异,立即返回;如果前 num 个字符完全相同,返回 0。用法和 strcmp 几乎一样,只是多了一个 num 参数来控制比较范围。当你想比较字符串的前缀时,strncmp 就是天然的选择。

#include <stdio.h>
#include <string.h>
 
int main()
{
    /* 判断是否以 "https://" 开头 */
    const char *url = "https://example.com";
    if (strncmp(url, "https://", 8) == 0)
        printf("是 HTTPS 链接\n");
    else if (strncmp(url, "http://", 7) == 0)
        printf("是 HTTP 链接\n");
    else
        printf("未知协议\n");
 
    /* 只比较前 3 个字符:不看后面的差异 */
    printf("strncmp(\"abcxyz\", \"abcdef\", 3) = %d\n",
           strncmp("abcxyz", "abcdef", 3));  /* 输出 0,前3个相同 */
    printf("strncmp(\"abcxyz\", \"abcdef\", 4) = %d\n",
           strncmp("abcxyz", "abcdef", 4));  /* 非0,第4个不同 */
 
    return 0;
}

注意 strncmp 在源或目标提前到达 '\0' 时也会正常停止——它不会越界读取,因为 '\0' 的参与比较就结束了。这一点和 strncpy 完全不同,strncmp 没有"不补终止符"的烦恼。

字符串查找函数家族:strchr、strrchr、strstr、strpbrk、strspn、strcspn

除了拷贝拼接,字符串查找是另一个高频需求。C 标准库为此准备了一个完整的函数家族:

函数功能返回值
strchr(s, c)在 s 中从左往右找字符 c 第一次出现指向该位置的指针,找不到返回 NULL
strrchr(s, c)在 s 中从右往左找字符 c 最后一次出现同上
strstr(s1, s2)在 s1 中找子串 s2 第一次出现指向子串开头的指针,找不到返回 NULL
strpbrk(s1, s2)在 s1 中找 s2 中任意一个字符第一次出现的位置指针,找不到返回 NULL
strspn(s1, s2)返回 s1 开头连续由 s2 中字符组成的长度长度值(size_t)
strcspn(s1, s2)返回 s1 开头连续不含 s2 中字符的长度长度值(size_t)
#include <stdio.h>
#include <string.h>
 
int main()
{
    const char *s = "hello world, hello C";
 
    /* strchr:找第一个 'l' */
    char *p1 = strchr(s, 'l');
    if (p1) printf("strchr 第一个 'l' 位置: %s\n", p1);  /* llo world... */
 
    /* strrchr:找最后一个 'l' */
    char *p2 = strrchr(s, 'l');
    if (p2) printf("strrchr 最后一个 'l' 位置: %s\n", p2);  /* llo C */
 
    /* strstr:找子串 "hello" 第二次出现的位置 */
    char *p3 = strstr(s + 1, "hello");  /* 从第二个字符开始找 */
    if (p3) printf("strstr 第二次 'hello': %s\n", p3);  /* hello C */
 
    /* strpbrk:找 "wo" 中任意字符第一次出现 */
    char *p4 = strpbrk(s, "wo");
    if (p4) printf("strpbrk 找到 'w' 或 'o': %s\n", p4);  /* world... */
 
    /* strspn:s 开头连续由 "helo "(注意含空格)组成的长度
       "hello " 共 6 个字符都在集合里(第 6 个是空格),
       第 7 个字符 'w' 不在集合,所以结果是 6 */
    size_t n1 = strspn(s, "helo ");
    printf("strspn(\"%s\", \"helo \") = %zu\n", s, n1);  /* 6 (hello ) */
 
    /* strcspn:s 开头连续不含 "wd" 的长度
       "hello " 6 个字符都不含 'w'/'d',第 7 个字符 'w' 命中,所以结果是 6 */
    size_t n2 = strcspn(s, "wd");
    printf("strcspn(\"%s\", \"wd\") = %zu\n", s, n2);  /* 6 (hello_ ) */
 
    return 0;
}

其中 strchr 有一个冷知识:strchr(s, '\0') 返回的是指向字符串末尾 '\0' 的指针,而不是 NULL。因为 '\0' 是字符串的一部分(终止符)。这一点在实现"定位字符串末尾"时非常有用:char *end = strchr(s, '\0'); 等价于 char *end = s + strlen(s);。

strchr 的模拟实现简单到几乎就是一层包装:

#include <stdio.h>
 
/* strchr 的模拟实现:从左往右找字符 */
char *my_strchr(const char *s, int c)
{
    /* 注意:c 以 int 传入,但要与 unsigned char 比较 */
    while (*s != '\0' && *s != (char)c)
    {
        s++;
    }
    /* 如果 *s == c 且 c != '\0',返回 s;
       如果 c == '\0',上面循环在 *s=='\0' 时退出,同样返回 s(指向末尾) */
    if (*s == (char)c)
        return (char *)s;
    return NULL;
}
 
int main()
{
    char *p = my_strchr("Hello", 'e');
    if (p) printf("找到: %s\n", p);  /* ello */
    return 0;
}

strstr 的模拟实现是经典的"暴力匹配"算法教学案例——两层循环,外层以主串的每个字符为起点,内层逐一比较:

#include <stdio.h>
 
char *my_strstr(const char *str1, const char *str2)
{
    char *cp = (char *)str1;
    char *s1, *s2;
 
    /* 如果查找的是空字符串,直接返回 str1 */
    if (!*str2)
        return (char *)str1;
 
    /* 外层循环:以 str1 的每个字符为起点尝试匹配 */
    while (*cp)
    {
        s1 = cp;
        s2 = (char *)str2;
        /* 内层循环:逐一比较当前起点的后续字符 */
        while (*s1 && *s2 && !(*s1 - *s2))
        {
            s1++;
            s2++;
        }
        /* 如果 s2 走到了 '\0',说明完全匹配 */
        if (!*s2)
            return cp;
        cp++;  /* 当前起点不匹配,后移一位 */
    }
    return NULL;
}
 
int main()
{
    char *result = my_strstr("hello world", "wor");
    if (result)
        printf("找到子串: %s\n", result);  /* 输出: world */
    else
        printf("未找到\n");
    return 0;
}

Knuth–Morris–Pratt(KMP)算法能在 O(n+m) 时间内完成子串查找,比暴力匹配的 O(n*m) 更优,但暴力匹配的代码更直观,也更适合教学——先理解它,再学 KMP 就不难了。顺便说一句,标准库的实现通常也不是暴力匹配,而是针对不同长度选择不同的算法(BM 算法、Two-Way 算法等)。

这里顺便展示一个实用的扩展——把 strstr 和 tolower 组合起来,实现大小写不敏感的字符串查找:

#include <stdio.h>
#include <string.h>
#include <ctype.h>
 
/* 大小写不敏感的 strstr 实现(非标准函数,自己写的) */
char *stristr(const char *haystack, const char *needle)
{
    const char *h, *n;
    if (!*needle) return (char *)haystack;
 
    while (*haystack)
    {
        h = haystack;
        n = needle;
        /* 使用 tolower 忽略大小写差异 */
        while (*h && *n && tolower((unsigned char)*h) == tolower((unsigned char)*n))
        {
            h++;
            n++;
        }
        if (!*n) return (char *)haystack;
        haystack++;
    }
    return NULL;
}
 
int main()
{
    char *result = stristr("Hello WORLD", "world");
    if (result)
        printf("大小写不敏感查找结果: %s\n", result);  /* WORLD */
    return 0;
}

注意这里 tolower((unsigned char)*h)——为什么要转 unsigned char?因为 char 可能是有符号的,如果 *h 是扩展字符(字节值 > 127),作为负数传给 tolower 就是未定义行为。这是 ctype 函数使用的标准姿势,务必养成习惯。

strtok

这可能是在所有字符串函数中最让新手困惑的一个——因为它的行为方式非常"反直觉"。

#include <stdio.h>
#include <string.h>
 
int main()
{
    char arr[] = "192.168.6.111";
    char *sep = ".";    /* 分隔符集合 */
    char *str = NULL;
 
    /* 第一次调用传 arr,后续传 NULL */
    for (str = strtok(arr, sep); str != NULL; str = strtok(NULL, sep))
    {
        printf("%s\n", str);  /* 依次输出 192、168、6、111 */
    }
    /* 此时 arr 已被修改:'192\0168\06\0111' */
    return 0;
}

strtok 有三点"反常规"的行为需要特别注意:

第一,它会直接修改原字符串——在分隔符位置填入 '\0'。所以你传的必须是可修改的字符数组,不能是字符串字面量。char *str = "192.168.1.1"; strtok(str, "."); 会直接段错误,因为 "192.168.1.1" 存放在只读数据段。如果你需要保留原字符串,请先 strcpy 一份副本再操作。

第二,它使用静态变量保存上次处理的位置。第一次调用传入 str,后续调用传入 NULL 来继续切割同一字符串。这个设计意味着 strtok 是不可重入的——不能在两个不同的字符串上同时调用 strtok,不能在多线程环境中安全使用,也不能在 strtok 循环内部调用另一个也使用了 strtok 的函数。C11 附录 K(可选的边界检查接口)定义了 strtok_s 作为安全替代,它要求显式传入一个保存状态的上下文指针;微软的 VS 也提供同名函数,但参数签名和 C11 附录 K 并不一致,移植时要格外小心。

具体来说:

  • C11 附录 K 的 strtok_s 有 4 个参数:char *strtok_s(char *restrict str, rsize_t *restrict strmax, const char *restrict delim, char **restrict ptr);
  • MSVC 的 strtok_s 有 3 个参数:char *strtok_s(char *str, const char *delimiters, char **context);——它的签名其实和 POSIX 的 strtok_r 一致。

所以同样的代码在两个平台上签名不同,跨平台时要格外小心。POSIX 系统上更常见的是 strtok_r(可重入版本)。

第三,如果没有更多 token,返回 NULL。所以那个 for 循环的条件写得很紧凑:for (str = strtok(arr, sep); str != NULL; str = strtok(NULL, sep))——先获取第一个 token,然后持续调用 NULL 获取后续 token,直到返回 NULL 为止。

另外两个 strtok 的细节:

  1. 分隔符是"集合"而非"单个字符":strtok(str, ",;") 表示逗号或分号都算分隔符。连续的分隔符会被当作一个(中间的"空 token"被跳过)。
  2. 每段调用可以换分隔符:你可以在第二次调用时传入不同的分隔符集合——这有时是特性,有时是坑(因为你会忘记)。

strtok 的模拟实现思路

要理解 strtok 为什么不可重入,最好的办法是看它的模拟实现思路:它需要两个静态变量——static char *save_ptr 记录"上次停在哪",以及(在某些实现中)一个用于跳过前导分隔符的标志。

/* 简化版 strtok 模拟(演示"静态变量保存状态"的核心机制) */
char *my_strtok(char *str, const char *delim)
{
    static char *save;      /* 静态变量!所有调用共享 */
    char *token_start;
 
    if (str != NULL)
        save = str;         /* 首次调用:从 str 开始 */
    if (save == NULL || *save == '\0')
        return NULL;        /* 没有剩余内容了 */
 
    /* 跳过前导分隔符 */
    while (*save && strchr(delim, *save))
        save++;
    if (*save == '\0')
        return NULL;
 
    token_start = save;     /* 记录 token 起点 */
 
    /* 找下一个分隔符,替换为 '\0',更新 save */
    while (*save && !strchr(delim, *save))
        save++;
    if (*save)              /* 遇到了分隔符 */
    {
        *save = '\0';       /* 用 '\0' 截断 */
        save++;             /* save 指向下一个 token 的开头 */
    }
    return token_start;
}

看到 static char *save 了吗?这就是不可重入的根源——如果有两个线程同时调用 my_strtok,或者你在一个 strtok 循环里调用了另一个使用 strtok 的函数,save 会被互相覆盖。这就是 strtok_r / strtok_s 存在的原因:把状态放在调用者提供的指针里。

strerror

接下来是"翻译官"——把冰冷的错误码翻译成人话。

#include <stdio.h>
#include <string.h>
#include <errno.h>
 
int main()
{
    /* 打印前 10 个错误码对应的描述信息 */
    int i;
    for (i = 0; i <= 10; i++)
    {
        printf("错误码 %d: %s\n", i, strerror(i));
    }
 
    /* 实际使用场景:打开不存在的文件 */
    FILE *pFile = fopen("不存在的文件.txt", "r");
    if (pFile == NULL)
    {
        /* 方式一:手动拼接 strerror */
        printf("方式一 - 错误: %s\n", strerror(errno));
 
        /* 方式二:perror 直接打印 */
        perror("方式二 - 错误");
 
        /* 两种方式输出内容等价 */
    }
    return 0;
}

strerror 接收一个错误码(通常是全局变量 errno 的值),返回对应的错误描述字符串。errno 定义在 <errno.h> 中——当库函数执行失败时,会把 errno 设置为对应的错误码。配合 strerror(errno) 或直接使用 perror("前缀"),你就能得到诸如 "No such file or directory"、"Permission denied" 这样的人类可读信息,而不是干巴巴的数字。

几个使用要点:

  1. errno 不是自动清零的。程序启动时 errno 为 0,但库函数调用成功后不会把 errno 归零——只有失败时才设置。所以检查 errno 前,先确认函数确实失败了(如 fopen 返回 NULL)。
  2. errno 是线程局部变量(C11 及现代平台)。在多线程程序中,每个线程有自己的 errno,不会互相干扰。
  3. strerror 的返回值指向静态缓冲区,下一次调用可能覆盖上一次的内容——所以如果需要长期保存,请先拷贝副本(或者用 strerror_r)。perror 则把描述直接打印到 stderr。
  4. 错误码的具体数值是实现定义的(0、1、2……在不同平台可能不同),但标准保证 strerror 能翻译任何合法的 errno 值。

实战综合示例一:字符串 trim(去除两端空白)

现在,让我们用一个实用的综合示例收尾——实现字符串的 trim 操作(去掉两端的空白字符),这在实际项目中几乎是必写的工具函数:

#include <stdio.h>
#include <string.h>
#include <ctype.h>
 
/* 去除字符串两端的空白字符 */
char *trim(char *str)
{
    char *end;
 
    /* 跳过开头的空白字符 */
    while (isspace((unsigned char)*str)) str++;
 
    if (*str == 0)  /* 全是空白字符 */
        return str;
 
    /* 从末尾向前跳过空白字符 */
    end = str + strlen(str) - 1;
    while (end > str && isspace((unsigned char)*end)) end--;
 
    /* 在最后一个非空白字符后写入 '\0' */
    *(end + 1) = '\0';
 
    return str;
}
 
int main()
{
    char str[] = "   hello world   ";
    printf("trim 前: [%s]\n", str);
    printf("trim 后: [%s]\n", trim(str));
    return 0;
}

注意几个细节:strlen(str) - 1 是最后一个字符的下标(不能是空串,前面的 if (*str == 0) 已经拦截了);end > str 防止回退过头;isspace((unsigned char)*end) 的 unsigned char 转换已经是老朋友了。trim 修改的是原字符串——如果你不想破坏原串,先拷贝一份。

实战综合示例二:解析简单的配置文件

把本讲的知识串起来,实现一个"key = value"格式的配置解析器(迷你版)。这综合了 strtok、strchr、trim、字符分类、strncpy 等几乎所有学过的函数:

#include <stdio.h>
#include <string.h>
#include <ctype.h>
 
/* 解析一行 "key = value" 配置,输出 key 和 value(去除空白) */
int parse_line(char *line)
{
    char *eq;      /* '=' 的位置 */
    char *key;
    char *value;
 
    /* 跳过开头的空白(也跳过空行) */
    while (isspace((unsigned char)*line)) line++;
    if (*line == '\0' || *line == '#')   /* 空行或注释 */
        return 0;
 
    /* 找 '=' 号 */
    eq = strchr(line, '=');
    if (eq == NULL)
    {
        printf("格式错误(缺少 = ): %s\n", line);
        return -1;
    }
 
    /* 把 '=' 替换为 '\0',把一行切成两段 */
    *eq = '\0';
    key = trim(line);
    value = trim(eq + 1);
 
    if (*key == '\0' || *value == '\0')
    {
        printf("格式错误(key 或 value 为空): %s\n", line);
        return -1;
    }
 
    printf("key = \"%s\", value = \"%s\"\n", key, value);
    return 1;
}
 
int main()
{
    /* 模拟一个配置文件的内容(每行用 \n 分隔) */
    char config[] =
        "# 服务器配置\n"
        "host = 192.168.1.1\n"
        "port = 8080\n"
        "   \n"
        "timeout = 30\n";
 
    /* 用 strtok 逐行切分,再逐行解析 */
    char *line = strtok(config, "\n");
    while (line != NULL)
    {
        parse_line(line);
        line = strtok(NULL, "\n");
    }
    return 0;
}

这个例子演示了真实项目里最常见的组合拳:strtok 切行 → strchr 找分隔符 → trim 去空白 → 检查空行和注释。代码很短,但涵盖了本讲 80% 的知识点。

字符串函数全家福总结

最后,把 <string.h> 中常用的字符串函数汇总成一张表,方便你查阅(也是面试复习清单):

类别函数一句话说明
长度strlen数到 '\0' 为止的字符数
拷贝strcpy / strncpy拷贝整个串 / 最多拷贝 n 个字符
拼接strcat / strncat追加到末尾 / 最多追加 n 个字符
比较strcmp / strncmp整体比较 / 比较前 n 个字符
查找字符strchr / strrchr找字符第一次 / 最后一次出现
查找子串strstr找子串第一次出现
字符集合strpbrk / strspn / strcspn找集合中任一字符 / 连续命中长度 / 连续未命中长度
切割strtok按分隔符切 token(破坏原串、不可重入)
错误strerror错误码 → 描述文字

安全铁律:写给每一个 C 程序员

写到这里,你会发现所有这些字符串函数的模拟实现都遵循着同一个底层模式:用 while 循环驱动指针逐字节前进,直到遇到 '\0' 或满足其他条件。这不是巧合——这就是 C 语言的底层编程范式。掌握了这种"指针思维",你从头写任何字符串处理逻辑都会得心应手。

但在实际项目中,光会写还不够。几十年来的血泪教训告诉我们:永远要知道你的缓冲区有多大,优先使用带 n 的受限版本(strncpy/strncat/strncmp),并且永远对用户输入做边界检查。C 语言不会帮你兜底——你对每一块内存负责,编译器只是执行你的指令。这份自由,既是 C 语言最大的力量,也是它最大的责任。

一句话总结本讲:字符串函数本质上是"指针 + 字节 + '\0' 约定"的排列组合——strlen 是数,strcpy/strcat 是搬,strcmp 是比,strchr/strstr 是找,strtok 是切。它们共享同一套底层思维,学会了这套思维,你就掌握了一半的 C 标准库。

思考题

  1. strlen("") 返回多少?sizeof("") 呢?(提示:后者包含 '\0')
  2. char s[] = "abc"; char *p = "abc"; 中 sizeof(s) 和 sizeof(p) 各是多少?为什么?
  3. 为什么 strcmp 的模拟实现要把字符转成 unsigned char 再相减?如果直接用 *str1 - *str2 会有什么问题?
  4. strncpy(dest, src, n) 在什么情况下不会以 '\0' 结尾?如何安全地使用它?
  5. 用 strtok 切割字符串前必须满足什么条件?它为什么不能处理字符串字面量?
  6. 写一个函数 reverse_str(char *s) 原地反转字符串(提示:双指针头尾交换)。
  7. 写一个函数 count_words(const char *s) 统计单词个数(单词 = 非空白字符的连续序列)。

思考题参考答案

1. strlen("") 返回多少?sizeof("") 呢?

strlen("") 返回 0——空字符串中 '\0' 前面没有任何字符。sizeof("") 返回 1——"" 实际是一个长度为 1 的 char[1] 数组,存放的就是那个唯一的 '\0'。这正好印证了正文强调的区别:sizeof 看的是数组声明的字节数(含终止符),strlen 数的是运行时 '\0' 之前的字符数。

2. char s[] = "abc"; char *p = "abc"; 中 sizeof(s) 和 sizeof(p) 各是多少?为什么?

  • sizeof(s) 是 4:s 是被初始化为字面量 "abc" 的数组,编译器为其分配 char[4](3 个字符 + 1 个 '\0'),所以 sizeof 在编译期算出 4。
  • sizeof(p) 取决于指针宽度,32 位平台是 4,64 位平台是 8:p 只是存放地址的指针变量,它的"大小"是地址的字节数,跟它指向的字符串长度毫无关系。

涉及数组退化为指针时最容易错:一旦数组名作为函数参数或赋值给指针,sizeof 就不再等于数组字节数。这也是为什么字符串函数只能靠 '\0' 判断终点、而不能靠 sizeof。

3. 为什么 strcmp 的模拟实现要把字符转成 unsigned char 再相减?

因为标准规定 strcmp 按无符号字符比较。若 char 在有符号平台上表示扩展字符(字节值 ≥ 0x80),直接 *str1 - *str2 时该字符会被当作负数参与运算(符号扩展),从而算出错误的正负号。

举例:str1 的字节为 0x80,str2 的字节为 0x7F。按字节值,0x80 > 0x7F,str1 应更大。但若把二者都当作有符号 char,0x80 变成 -128,于是 -128 - 127 = -255,得出 str1 更小的错误结论。先转成 unsigned char 再相减,0x80 - 0x7F = 1,符号才正确。str1 是 const char *,写成 *(const unsigned char *)str1 更严谨。

4. strncpy(dest, src, n) 在什么情况下不会以 '\0' 结尾?如何安全地使用它?

当 strlen(src) >= n 时,strncpy 恰好拷满 n 个字符就把目标填满了,不会在末尾补 '\0'。此时 dest 只是一个"没有终止符的字符数组",不是合法字符串,printf("%s", dest) 会继续越界读内存。只有当 strlen(src) < n 时才会在剩余位置补 '\0'。特别注意 strlen(src) == n 这一边界同样不补。

安全做法:给目标预留 n + 1 字节,拷贝后手动 dest[n] = '\0';;或者拷贝前先确认 strlen(src) < n。常用的缩略写法 dest[n-1] = '\0';(在 n > 0 时)可以保证无论拷了多少内容,目标都是合法字符串。

5. 用 strtok 切割字符串前必须满足什么条件?它为什么不能处理字符串字面量?

必须满足:传入的字符串是可修改的(放在可写内存中,如数组或 malloc 出来的缓冲区),且以 '\0' 结尾。因为 strtok 会直接把分隔符位置改写为 '\0' 来切分,所以源串会被破坏。

它不能处理字符串字面量(如 strtok("192.168.1.1", ".")),因为字符串字面量一般存放在只读数据段,试图向其中写入 '\0' 会触发段错误(未定义行为)。所以若只想观察切割结果、又不想破坏原串,必须先拷贝一份副本:char tmp[64]; strcpy(tmp, origin); strtok(tmp, sep);。同时由于它用静态变量保存进度,它是不可重入的——不能多线程并发调用,也不能在一个 strtok 循环里再调另一个同样用 strtok 的函数。

6. 写一个函数 reverse_str(char *s) 原地反转字符串。

用双指针:一个指向开头,一个指向末尾,头尾交换并相向移动,相遇即停止。

#include <stdio.h>
#include <string.h>
 
/* 原地反转字符串(会修改原串) */
void reverse_str(char *s)
{
    if (s == NULL)          /* 防御:空指针 */
        return;
    char *left = s;
    char *right = s + strlen(s) - 1;  /* 指向最后一个有效字符 */
 
    while (left < right)    /* 相遇或交错即停 */
    {
        char tmp = *left;
        *left = *right;
        *right = tmp;
        left++;
        right--;
    }
}
 
int main()
{
    char s[] = "Hello World";
    reverse_str(s);
    printf("%s\n", s);      /* 输出: dlroW olleH */
    return 0;
}

注意:right 必须用 strlen(s) 先算出末尾,空串时 strlen(s) - 1 为 -1,left < right 不成立,循环体不执行,函数正确返回;所以无需单独判空串。

7. 写一个函数 count_words(const char *s) 统计单词个数。

用"状态机"思路:维护 in_word 标志表示当前是否已处在某个单词内部。遇到空白则置 in_word = 0;遇到非空白且此前不在单词内,说明一个新单词开始,计数加一:

#include <stdio.h>
#include <ctype.h>
 
/* 统计单词个数:单词 = 非空白字符的连续序列 */
int count_words(const char *s)
{
    int count = 0;
    int in_word = 0;          /* 当前是否已在一个单词内部 */
 
    while (*s)
    {
        if (isspace((unsigned char)*s))
        {
            in_word = 0;      /* 遇空白,单词结束 */
        }
        else if (!in_word)
        {
            in_word = 1;      /* 非空白且刚进入新单词 */
            count++;
        }
        s++;
    }
    return count;
}
 
int main()
{
    const char *s = "  hello   world, C   language  ";
    printf("%d\n", count_words(s));  /* 输出: 4 */
    return 0;
}

这里同样记得 isspace((unsigned char)*s)——把 char 转成 unsigned char 再交给 ctype 函数,避免有符号 char 传负数导致未定义行为。连续空白会被合并处理:in_word 已经为 1 时再遇空白只置零,下次非空白会正确开启新单词。