【C 语言】字符函数和字符串函数:从 ctype 到 strtok、strerror 全面解析

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者。

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。


🔥 本文定位 :系统梳理 <ctype.h> 与 <string.h> 中最常用的字符、字符串函数,不只介绍"怎么调用",还把终止符、容量、可写性、重叠、隐藏状态和返回值契约讲清楚。

💡 学习目标 :能正确使用字符分类与转换函数;理解并模拟实现 strlen、strcpy、strcat、strcmp、strstr;分清 strncpy、strncat、strncmp 中 n 的不同含义;掌握 strtok、strerror、perror 的状态与副作用。

📌 核心前提 :C 字符串不是一种独立类型,而是以空字符 \0 结尾的字符序列。标准字符串函数通常只收到起始地址,并不知道目标数组的总容量,因此边界责任始终在调用者一侧。


文章目录


一、先建立统一模型:字符串函数到底在操作什么

在 C 语言中,下面两个对象都可以保存 "hello",但语义并不相同:

c 复制代码
char array[] = "hello";
const char *pointer = "hello";

array 是一个可修改数组,内部共有 6 个字符:

text 复制代码
'h' 'e' 'l' 'l' 'o' '\0'

pointer 指向字符串字面量。字面量不能被程序修改,所以使用 const char * 更能表达真实约束。把它交给 strcpy 的源参数没有问题,但绝不能把它当作可写目标。

面对任意字符或字符串函数,可以先问五个问题:

  1. 参数是一个字符值,还是一个字符地址?
  2. 函数是否会读取到 \0,这个终止符在有效对象范围内吗?
  3. 函数是否会写入,目标对象可修改吗?
  4. 目标空间能容纳所有字符以及新的 \0 吗?
  5. 源与目标是否可能重叠,函数是否保存了隐藏状态?

这五个问题比死记函数名重要。许多字符串漏洞并不是函数"不工作",而是调用者没有满足它的前置条件。


二、字符分类函数:用 ctype.h 读懂一个字符

字符分类函数声明在 <ctype.h> 中,常见接口如下:

函数 判断内容
isalnum 字母或十进制数字
isalpha 字母
isblank 空格或横向制表等空白
iscntrl 控制字符
isdigit 十进制数字 0~9
isgraph 有图形表示的可打印字符,不含空格
islower 小写字母
isprint 可打印字符,包含空格
ispunct 标点字符
isspace 空格、换行、制表、回车等空白字符
isupper 大写字母
isxdigit 十六进制数字字符

2.1 返回值只有"零"和"非零"两类

以 islower 为例:

c 复制代码
int islower(int c);

若 c 属于小写字母,函数返回非零值;否则返回 0。标准没有承诺"真"一定等于 1,因此不要写:

c 复制代码
if (islower(ch) == 1) { /* 不推荐 */
}

正确写法是直接把返回值当作逻辑条件:

c 复制代码
if (islower((unsigned char)ch)) {
    puts("lowercase");
}

2.2 为什么经常需要转成 unsigned char

ctype 系列函数的参数类型是 int,但合法实参范围不是"任意 int",而是:

  • 宏 EOF 的值;
  • 或者能够表示为 unsigned char 的值。

若当前平台的 char 默认有符号,一个高位为 1 的字节可能先变成负数。把这个负数直接传给 isalpha、toupper 等函数会产生未定义行为。处理普通字节流时,更稳妥的写法是:

c 复制代码
unsigned char uc = (unsigned char)ch;

if (isalpha(uc)) {
    /* ... */
}

若字符来自 fgetc,则应先用 int 保存返回值,以便区分所有无符号字符值和 EOF:

c 复制代码
int c;

while ((c = fgetc(stdin)) != EOF) {
    if (isdigit(c)) {
        putchar(c);
    }
}

2.3 练习:统计字符串中的字符类型

c 复制代码
#include <ctype.h>
#include <stdio.h>

int main(void)
{
    const char text[] = "C17: hello, world!\n";
    size_t letters = 0;
    size_t digits = 0;
    size_t spaces = 0;

    for (size_t i = 0; text[i] != '\0'; ++i) {
        unsigned char ch = (unsigned char)text[i];

        if (isalpha(ch)) {
            ++letters;
        } else if (isdigit(ch)) {
            ++digits;
        } else if (isspace(ch)) {
            ++spaces;
        }
    }

    printf("letters=%zu digits=%zu spaces=%zu\n",
           letters, digits, spaces);
    return 0;
}

三、字符转换函数:tolower 与 toupper

<ctype.h> 还提供两个字符转换函数:

c 复制代码
int tolower(int c);
int toupper(int c);

tolower 把大写字母转换为对应小写字母;toupper 把小写字母转换为对应大写字母。若参数不属于需要转换的类别,函数返回原值。

下面把字符串中的小写字母转为大写,其他字符保持不变:

c 复制代码
#include <ctype.h>
#include <stdio.h>

int main(void)
{
    char text[] = "Test String 9!";

    for (size_t i = 0; text[i] != '\0'; ++i) {
        unsigned char ch = (unsigned char)text[i];
        text[i] = (char)toupper(ch);
    }

    puts(text);
    return 0;
}

输出:

text 复制代码
TEST STRING 9!

不要通过 ch - 32 或 ch + 32 手工转换。ASCII 编码里大小写字母确实相差 32,但 C 语言接口表达的是字符类别和当前区域设置下的转换语义。使用标准库函数更清晰,也更可移植。


四、strlen:长度来自第一个空字符

strlen 声明在 <string.h>:

c 复制代码
size_t strlen(const char *str);

它从 str 指向的位置开始读取,直到遇到第一个 \0,返回此前的字符个数,不包含终止符本身。

c 复制代码
char buffer[8] = {'c', 'o', 'd', 'e', '\0', 'X', 'X', 'X'};

printf("%zu\n", strlen(buffer)); /* 4 */
printf("%zu\n", sizeof buffer);  /* 8 */

这段代码同时出现了三个不同概念:

概念 本例结果 含义
数组容量 8 字节 整个对象有多大
字符串长度 4 第一个 \0 之前有几个字符
剩余空间 3 字节 终止符之后仍属于数组的区域

4.1 size_t 的无符号陷阱

strlen 返回 size_t,它是无符号整数类型。因此下面的比较有问题:

c 复制代码
if (strlen("abc") - strlen("abcdef") < 0) {
    puts("shorter");
}

数学上是 3 - 6,但无符号运算会回绕为一个很大的正数。不要先相减,直接比较:

c 复制代码
if (strlen("abc") < strlen("abcdef")) {
    puts("shorter");
}

打印 size_t 使用 %zu。

4.2 三种模拟实现

计数器版本:

c 复制代码
#include <assert.h>
#include <stddef.h>

size_t my_strlen(const char *str)
{
    size_t count = 0;

    assert(str != NULL);
    while (*str != '\0') {
        ++count;
        ++str;
    }
    return count;
}

指针相减版本:

c 复制代码
size_t my_strlen_pointer(const char *str)
{
    const char *end = str;

    assert(str != NULL);
    while (*end != '\0') {
        ++end;
    }
    return (size_t)(end - str);
}

递归版本也能表达定义:

c 复制代码
size_t my_strlen_recursive(const char *str)
{
    assert(str != NULL);
    return *str == '\0' ? 0 : 1 + my_strlen_recursive(str + 1);
}

递归写法适合帮助理解,但长字符串会产生很深的调用栈,工程代码应优先使用循环。

最重要的前置条件是:str 必须指向一条有效 C 字符串。若对象范围内没有 \0,strlen 会越界读取,行为未定义。


五、strcpy:复制时必须把终止符一起带走

原型:

c 复制代码
char *strcpy(char *destination, const char *source);

strcpy 把 source 指向的字符串复制到 destination,并把结尾的 \0 一起复制。返回值是目标起始地址 destination。

c 复制代码
#include <stdio.h>
#include <string.h>

int main(void)
{
    char destination[16];

    strcpy(destination, "cat");
    puts(destination);
    return 0;
}

调用者必须同时保证:

  • source 在有效范围内以 \0 结束;
  • destination 可写;
  • 目标容量至少为 strlen(source) + 1;
  • 两个对象不能以不允许的方式重叠。

下面两种写法都错误:

c 复制代码
char small[4];
strcpy(small, "hello"); /* 需要 6 字节,目标溢出 */

char *literal = "hello";
strcpy(literal, "hi");  /* 尝试修改字符串字面量 */

5.1 模拟实现 strcpy

c 复制代码
#include <assert.h>

char *my_strcpy(char *destination, const char *source)
{
    char *result = destination;

    assert(destination != NULL);
    assert(source != NULL);

    while ((*destination++ = *source++) != '\0') {
        ;
    }
    return result;
}

循环中的赋值表达式先完成复制,再判断刚复制的字符是否为 \0。因此终止符也会被写入,指针随后停止前进。

assert 只能帮助调试空指针,不能验证目标数组究竟有多大。真正的接口若要可靠控制边界,必须让调用者显式提供容量,或在更高层封装中保存长度信息。


六、strcat:先找末尾,再从终止符处追加

原型:

c 复制代码
char *strcat(char *destination, const char *source);

strcat 先寻找 destination 中原有的 \0,再从这个位置开始复制 source,最后写入新的 \0。

c 复制代码
char text[16] = "sea";

strcat(text, "food");
printf("%s\n", text); /* seafood */

所需总容量为:

text 复制代码
strlen(destination) + strlen(source) + 1

对于 "sea" + "food",需要 3 + 4 + 1 = 8 字节。

6.1 模拟实现 strcat

c 复制代码
#include <assert.h>

char *my_strcat(char *destination, const char *source)
{
    char *result = destination;

    assert(destination != NULL);
    assert(source != NULL);

    while (*destination != '\0') {
        ++destination;
    }

    while ((*destination++ = *source++) != '\0') {
        ;
    }
    return result;
}

第一段循环找到目标字符串尾部;第二段循环与 strcpy 的复制逻辑相同。

"字符串自己给自己追加"不是一道值得依赖执行结果的技巧题:

c 复制代码
char text[32] = "abc";
strcat(text, text); /* 源和目标重叠,行为未定义 */

如果确实要基于同一缓冲区构造新内容,应先保存原长度,仔细计算容量,并使用允许重叠的字节搬运方案或独立临时缓冲区。


七、strcmp:比较字典序,不比较长度

原型:

c 复制代码
int strcmp(const char *str1, const char *str2);

strcmp 从头开始比较两条字符串的对应字符:

  • 第一处不同字符决定大小;
  • 若此前都相同,则较早遇到 \0 的字符串更小;
  • 两条字符串完全相同则返回 0。

标准只保证返回值的符号:

结果 含义
< 0 str1 小于 str2
== 0 两条字符串相等
> 0 str1 大于 str2

不要假设小于必然返回 -1,大于必然返回 1。

c 复制代码
if (strcmp("apple", "apply") < 0) {
    puts("apple comes first");
}

7.1 模拟实现 strcmp

c 复制代码
#include <assert.h>

int my_strcmp(const char *str1, const char *str2)
{
    assert(str1 != NULL);
    assert(str2 != NULL);

    while (*str1 != '\0' && *str1 == *str2) {
        ++str1;
        ++str2;
    }

    return (unsigned char)*str1 - (unsigned char)*str2;
}

最后转换为 unsigned char,是为了按标准字符串比较的无符号字符值语义计算首个差异。判断字符串相等时直接检查结果是否为 0,而不是比较两个指针地址。


八、带 n 的三个函数:strncpy、strncat、strncmp

这三个函数都有一个 size_t n,但 n 的含义并不相同。把它们统称为"安全版函数"会制造新的误解。

8.1 strncpy:最多写 n 个字符,但不保证总有终止符

原型:

c 复制代码
char *strncpy(char *destination, const char *source, size_t n);

规则分两种:

  1. 若 source 长度小于 n,复制终止符后继续用 \0 填充,直到一共写满 n 个字符;
  2. 若 source 长度大于或等于 n,只写前 n 个字符,结果可能没有 \0。
c 复制代码
char buffer[5];

strncpy(buffer, "abcdef", sizeof buffer);
/* buffer 中是 a b c d e,没有终止符,不能直接当字符串输出 */

若目的是得到截断后仍可用的字符串,可以预留一个字节:

c 复制代码
char buffer[5];

strncpy(buffer, "abcdef", sizeof buffer - 1);
buffer[sizeof buffer - 1] = '\0';

这段模式要求目标容量大于 0,而且可能产生截断;调用者仍需决定截断是否可接受。

8.2 strncat:n 限制追加的源字符数,终止符另写一个

原型:

c 复制代码
char *strncat(char *destination, const char *source, size_t n);

它最多追加 source 的前 n 个字符;若源串更短,则在源终止符处提前停止。无论追加多少字符,最终都会再向目标写一个 \0。

c 复制代码
char text[20] = "To be ";

strncat(text, "or not to be", 6);
puts(text); /* To be or not */

目标所需容量至少为:

text 复制代码
原目标长度 + min(n, 源字符串长度) + 1

因此,n 不是目标数组容量。若把整个 sizeof destination 直接当作 n,往往会多算已有内容,造成溢出。

8.3 strncmp:最多比较 n 对字符

原型:

c 复制代码
int strncmp(const char *str1, const char *str2, size_t n);

它最多比较 n 个位置;若提前遇到差异或 \0,立即结束。返回值同样只看符号。

c 复制代码
strncmp("version-1", "version-2", 8); /* 0,前 8 个字符相同 */
strcmp("version-1", "version-2");     /* 小于 0 */

前缀相同不代表整串相同。若业务要求完整相等,使用 strcmp,或在限长比较之外继续验证长度与终止条件。


九、strstr:在主串中寻找子串

原型:

c 复制代码
char *strstr(const char *haystack, const char *needle);

strstr 返回 needle 在 haystack 中第一次出现位置的指针;找不到返回 NULL。若 needle 是空字符串,则返回 haystack 起始位置。

c 复制代码
#include <stdio.h>
#include <string.h>

int main(void)
{
    char text[] = "This is a simple string";
    char *position = strstr(text, "simple");

    if (position != NULL) {
        printf("offset=%td, suffix=%s\n", position - text, position);
    }
    return 0;
}

9.1 朴素模拟实现

c 复制代码
#include <assert.h>
#include <stddef.h>

const char *my_strstr(const char *haystack, const char *needle)
{
    assert(haystack != NULL);
    assert(needle != NULL);

    if (*needle == '\0') {
        return haystack;
    }

    for (const char *candidate = haystack;
         *candidate != '\0';
         ++candidate) {
        const char *h = candidate;
        const char *n = needle;

        while (*h != '\0' && *n != '\0' && *h == *n) {
            ++h;
            ++n;
        }

        if (*n == '\0') {
            return candidate;
        }
    }
    return NULL;
}

算法有两层:外层把候选起点逐个右移,内层从候选位置开始与子串同步比较。若子串走到 \0,说明完整匹配;若主串先结束或字符不同,则换下一个候选起点。

这个朴素算法在重复字符很多时会重复比较,最坏复杂度可达到 O(nm)。学习标准库模拟实现时它足够直观;更大规模的文本搜索可考虑 KMP 等算法。


十、strtok:会改写源字符串的分词器

原型:

c 复制代码
char *strtok(char *str, const char *delimiters);

delimiters 不是一个完整分隔串,而是分隔字符集合 。例如 ",;" 表示逗号或分号任意一个都可作为分隔符。

strtok 的工作方式是:

  1. 首次调用传入可写字符串;
  2. 跳过开头连续出现的分隔符;
  3. 找到一个标记,并把标记后的分隔符改写为 \0;
  4. 返回指向标记起点的指针,同时保存下一次扫描位置;
  5. 后续调用把第一个参数传为 NULL,继续同一字符串。
c 复制代码
#include <stdio.h>
#include <string.h>

int main(void)
{
    char address[] = "192.168.6.111";

    for (char *token = strtok(address, ".");
         token != NULL;
         token = strtok(NULL, ".")) {
        puts(token);
    }
    return 0;
}

输出:

text 复制代码
192
168
6
111

10.1 三个必须记住的副作用

第一,strtok 会修改输入,所以不能传字符串字面量:

c 复制代码
strtok("a,b,c", ","); /* 错误:字面量不可写 */

第二,返回的标记并不是新分配的字符串,它直接位于原缓冲区中。原数组失效后,标记指针也失效。

第三,strtok 保存内部扫描状态。两组分词过程交错进行时,后一组调用会覆盖前一组状态;并发场景也不宜依赖这种隐式状态。POSIX 环境可了解 strtok_r,支持相应接口的实现可了解 strtok_s,追求跨平台时也可以编写显式保存当前位置的解析器。


十一、strerror、errno 与 perror:读懂系统错误

许多库函数失败时会通过全局宏 errno 提供错误码。strerror 把错误码转换为人可读文字:

c 复制代码
char *strerror(int error_number);

perror 则直接向标准错误流输出"自定义前缀 + 冒号 + 当前 errno 对应消息"。

11.1 正确顺序:先判断失败,再保存 errno

c 复制代码
#include <errno.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    FILE *file = fopen("unexist.ent", "r");

    if (file == NULL) {
        int saved_errno = errno;
        fprintf(stderr, "open failed: %s\n", strerror(saved_errno));
        return 1;
    }

    fclose(file);
    return 0;
}

为什么要尽快保存?因为后续库调用也可能改变 errno。先把错误码复制到普通变量,再做格式化与日志输出,能避免诊断信息与原始失败脱节。

不要在函数成功后读取 errno 判断"之前有没有错误"。errno 不会在每次成功调用后自动清零,只有当某个函数的文档说明失败并设置它,而且返回值确实表示失败时,该值才有意义。

11.2 perror 的简洁写法

c 复制代码
#include <stdio.h>

int main(void)
{
    FILE *file = fopen("unexist.ent", "r");

    if (file == NULL) {
        perror("open unexist.ent");
        return 1;
    }

    fclose(file);
    return 0;
}

典型输出类似:

text 复制代码
open unexist.ent: No such file or directory

错误码数字、消息文字和语言都可能随系统与 C 库实现变化。程序逻辑应依据函数返回值和规范化错误码,不能通过匹配某一句英文错误文本来决定流程。


十二、统一检查清单与高频问答

12.1 调用前的八步检查

  1. 参数类型:传入的是字符值、字符地址,还是数组首地址?
  2. 终止符 :读字符串的函数能否在有效范围内遇到 \0?
  3. 目标可写:目标是字符数组,还是不可修改的字符串字面量?
  4. 剩余容量 :计算时是否包含最终 \0,是否扣除了已有内容?
  5. 重叠关系:源与目标是否重叠,当前函数允许重叠吗?
  6. n 的语义:它限制复制、追加、比较,还是表示整个缓冲区容量?
  7. 隐藏状态:函数是否修改输入或保存后续调用位置?
  8. 返回契约 :返回长度、地址、NULL,还是只看正负号的比较结果?

12.2 高频问答

Q1:字符数组一定是字符串吗?

不一定。只有在有效对象范围内存在可达的 \0,才能把相应字符序列作为 C 字符串交给 strlen、strcpy、strcmp 等函数。

Q2:为什么字符串函数普遍容易越界?

传统接口通常只接收指针,没有同时携带数组容量。函数可以沿 \0 判断字符串结束,却无法知道目标数组还剩多少空间。

Q3:strncpy 是安全版 strcpy 吗?

不是。它确实限制最多写 n 个字符,但源串长度大于等于 n 时不会自动补终止符;源串很短时又会把剩余位置全部填零。是否适合取决于具体需求。

Q4:strcmp 为什么不能写成 == -1?

标准只规定负、零、正三种关系,没有规定具体数值。正确判断方式是 < 0、== 0、> 0。

Q5:strtok 为什么要传 NULL?

第一次调用已经保存了当前缓冲区中的扫描位置。后续传 NULL 表示继续该状态,而不是开始解析新字符串。

Q6:errno 是否需要每次先清零?

多数"返回值明确表示失败"的接口不需要先清零,只需在失败分支立即读取。少数接口的返回值本身可能与正常结果重合,这时应按该接口文档要求先把 errno 设为 0 再调用。

Q7:assert 能防止字符串函数越界吗?

不能。assert(pointer != NULL) 只能排除空指针,并不知道指针后有多少有效字节。容量必须由接口设计或调用上下文另外保证。

Q8:什么时候应该使用 memmove?

当任务本质是已知字节数的数据搬运,并且源与目标可能重叠时,memmove 才是对应工具。它不寻找 \0;下一讲会系统讨论内存函数。

12.3 动手练习

  1. 编写函数,把字符串中的字母转为大写,数字、空格和标点保持原样,并正确处理 unsigned char。
  2. 为 my_strlen、my_strcpy、my_strcat 分别写出空串、普通字符串和边界容量测试。
  3. 预测 strncpy(buffer, "abc", 8) 对 8 字节目标的全部写入结果。
  4. 设计一个不依赖隐藏状态的分词接口,让调用者显式保存当前位置。
  5. 使用 strstr 找出一条字符串中某个子串的所有非重叠出现位置。
  6. 打开编译器警告,并在支持的平台启用 AddressSanitizer,观察缺少 \0、目标溢出和写字面量时的诊断差异。

总结

这一讲看似列出了很多函数,实质上只有几条核心规律。

字符分类与转换函数处理的是单个字符值,返回值只区分零与非零,参数还必须满足 EOF 或 unsigned char 值域约束。strlen、strcpy、strcat、strcmp、strstr 处理的是以 \0 终止的字符序列;只要终止符缺失、目标不可写、容量不足或对象重叠,就可能进入未定义行为。

strncpy、strncat、strncmp 虽然都带 n,含义却分别是"写入字符数""追加源字符数""比较字符数",不能因为函数名相似就套用同一套容量计算。strtok 通过改写分隔符和保存内部位置完成切分,方便的代价是修改源串并带来隐藏状态。strerror 与 perror 则提醒我们:错误信息必须建立在明确的失败返回值和及时保存的 errno 上。

可以把整讲压缩成一句话:先证明参数满足对象、终止符与容量契约,再调用函数;先读懂返回值和副作用,再使用结果。

相关推荐
for_ever_love__36 分钟前
缓存穿透、击穿、雪崩:三个经典问题与完整解决方案
java·数据库·redis·缓存·哈希算法·布隆过滤器·雪崩
Helix25036 分钟前
Python与其他编程语言优劣势对比:2026初学者选型指南
java·python·教程·编程语言·入门·初学者编程选型
且从容.1 小时前
JS篇-----
java·javascript·jvm
夜之眷属1 小时前
记一次战斗服务器 CPU 打满 100% 且“无法恢复“的排查
java·linux·运维·服务器·后端·性能优化
starzy19901 小时前
Flink ResourceManager启动流程源码深度剖析:从ClusterEntrypoint到Slot分配的完整链路
java·大数据·flink
悠仁さん1 小时前
【C++】auto
java·前端·c++
郝学胜-神的一滴1 小时前
C++ Templates 07:编译报错和调试手段与实战技巧
开发语言·c++·后端·软件工程·visual studio
害人终害己1 小时前
Redisson 连接 Redis 失败 报错解决方案
java·开发语言
学编程就要猛1 小时前
Spring AI Alibaba(上)
java·人工智能·spring·springaialibaba