C 字符串(字符数组)

C 语言没有专门的字符串类型——字符串就是"末尾带结束符的字符数组"。这一篇我们来彻底搞懂字符数组、string.h 里那些常用函数、安全输入与缓冲区溢出,以及字符处理函数库 ctype.h。学完你就能写出真正安全的字符串处理代码。

一、字符数组的本质

C 的字符串说白了就是一段连续的 char,约定以一个值为 0 的字符作为结束标记。这个结束符(常写作 NUL)是 C 字符串一切行为的基石——所有字符串函数都是"从开头扫描,遇到结束符就停"。

#include <stdio.h>

int main(void) {
    // 1. 用字符串字面量初始化(自动在末尾补一个空字符)
    char s1[] = "Hi";        // 实际存 3 字节:'H','i','\0'
    // 2. 逐个字符初始化(必须手动加结束符)
    char s2[] = {'H', 'i', '\0'};
    // 3. 指定大小:剩余位置自动补 0
    char s3[10] = "Hello";   // 'H','e','l','l','o','\0','\0',...

    printf("%s\n", s1);       // Hi
    printf("%s\n", s3);       // Hello

    // 手动求长度:遇到结束符就停
    int len = 0;
    while (s3[len] != '\0') len++;
    printf("长度 = %d\n", len);  // 5
    return 0;
}

几个关键点:

二、strlen vs sizeof(常考点)

新手最常混淆的两个概念:strlen 是函数,数"可见字符数";sizeof 是运算符,数"整个数组占的字节数"。

#include <stdio.h>
#include <string.h>

int main(void) {
    char s[] = "Hello";

    // strlen 返回"可见字符数",不含末尾结束符
    printf("strlen = %zu\n", strlen(s));   // 5

    // sizeof 返回整个数组的字节数,包含结束符
    printf("sizeof = %zu\n", sizeof(s));   // 6

    // 空字符串的长度是 0,但仍然占 1 字节(只放结束符)
    char empty[] = "";
    printf("空串 strlen=%zu sizeof=%zu\n", strlen(empty), sizeof(empty));
    // 输出:空串 strlen=0 sizeof=1
    return 0;
}

还有一条隐含规则:数组传给函数后,sizeof 就失效了(退化成指针,sizeof 得到 8)。这也是为什么字符串函数都靠结束符工作,而不是靠长度。

三、strcpy 与 strncpy(拷贝)

strcpy 把一个字符串拷到另一个数组里。它完全不检查目标大小,是缓冲区溢出的高发地。安全替代品是 strncpy,它多一个参数限制最多拷贝多少字节。

#include <stdio.h>
#include <string.h>

int main(void) {
    char src[] = "Hello";
    char dst[20];

    // strcpy:把 src(连同结束符)拷到 dst
    strcpy(dst, src);
    printf("%s\n", dst);           // Hello

    // 危险!目标不够大会缓冲区溢出(下面这行会越界写)
    // char small[3];
    // strcpy(small, "ABCDEFGH");   // 8 个字符塞进 3 字节 -> 越界!

    // strncpy:最多拷贝 n 个字符(更安全),但不保证补结束符
    char buf[5];
    strncpy(buf, "ABCDEFGHIJ", sizeof(buf) - 1);
    buf[sizeof(buf) - 1] = '\0';     // 一定要手动补结束符!
    printf("%s\n", buf);           // ABCD
    return 0;
}

新手必踩的坑:strncpy 如果源串比缓冲区长,它不会自动补结束符。所以用完一定要手动在最后一格写结束符,否则后续 printf 会越读越远。

四、strcat 与 strncat(拼接)

strcat 把第二个串追加到第一个串末尾(覆盖第一个串原本的结束符,再补一个新的)。要求目标数组必须足够大,否则同样溢出。strncat 限制最多追加多少字符,更安全。

#include <stdio.h>
#include <string.h>

int main(void) {
    // 注意:目标数组必须足够大,且初始时末尾要有结束符
    char s[30] = "Hello";

    // strcat:把第二个串追加到第一个末尾
    strcat(s, " World");
    printf("%s\n", s);            // Hello World

    // strncat:最多追加 n 个字符,并自动补结束符
    strncat(s, "!!!extra", 3);    // 只追加 "!!!"
    printf("%s\n", s);            // Hello World!!!
    return 0;
}

注意 strncatstrncpy 行为不对称:strncat 会自动补结束符,而 strncpy 不会。这是 C 标准库被诟病的设计不一致之一,记牢即可。

五、strcmp(比较)

比较两个字符串必须strcmp,绝不能用 ==。因为数组名退化为指针后,s1 == s2 比较的是两个地址,几乎永远不相等。

#include <stdio.h>
#include <string.h>

int main(void) {
    printf("%d\n", strcmp("apple", "apple"));   //  0(相等)
    printf("%d\n", strcmp("apple", "banana"));  // <0(前者小)
    printf("%d\n", strcmp("banana", "apple"));  // >0(前者大)
    printf("%d\n", strcmp("Apple", "apple"));   // <0(大写 ASCII 更小)

    // 规则:逐字符按 ASCII 比较,遇到不同或遇到结束符就停
    // 判等不要写 if (s1 == s2) —— 那是在比较两个地址,永远不相等!
    // 必须用 strcmp(s1, s2) == 0

    // strncmp:只比前 n 个字符
    printf("%d\n", strncmp("hello world", "hello C", 5));  // 0(前 5 个相等)
    return 0;
}

返回值的约定是:返回 0 表示相等,负数表示前者小,正数表示前者大。具体数值不用关心,只用它的符号strncmp 只比前 n 个字符,常用于前缀匹配。

六、sprintf 与 snprintf(格式化到字符串)

printf 是把结果打印到屏幕,sprintf 则是把同样的格式化结果写到一个字符数组里,后续可以再用。这在拼接日志、生成 SQL、组合显示文本时非常有用。

#include <stdio.h>

int main(void) {
    char buf[100];
    int age = 25;
    double pi = 3.14159;

    // sprintf:把格式化结果写到 buf(而不是打印到屏幕)
    sprintf(buf, "姓名:%s,年龄:%d,圆周率:%.2f", "张三", age, pi);
    printf("%s\n", buf);
    // 输出:姓名:张三,年龄:25,圆周率:3.14

    // snprintf:最多写 n-1 个字符,自动补结束符(强烈推荐)
    char small[10];
    snprintf(small, sizeof(small), "ABCDEFGHIJKLMNOPQRSTUVWXYZ");
    printf("%s\n", small);    // ABCDEFGHI(9 个字符 + 结束符)
    return 0;
}

永远优先用 snprintf,它的第二个参数限定了"最多写多少字节",能从根本上杜绝溢出。sprintf 在现代代码里基本不该再出现。

七、sscanf(从字符串读取)

sscanfscanf 的"字符串版":从一个已有字符串里按格式提取数据。解析固定格式的日期、CSV、配置行时特别顺手,而且不依赖键盘输入,便于做单元测试。

#include <stdio.h>

int main(void) {
    char input[] = "2026-08-05 25";
    int year, month, day, age;

    // sscanf:从字符串里按格式读取(相当于"字符串版 scanf")
    int n = sscanf(input, "%d-%d-%d %d", &year, &month, &day, &age);
    printf("成功读取 %d 项\n", n);    // 4
    printf("日期:%04d-%02d-%02d,年龄 %d\n", year, month, day, age);
    // 输出:日期:2026-08-05,年龄 25

    // 返回值 n 是成功匹配的项数,常用来做输入校验
    return 0;
}

它的返回值是"成功匹配并赋值的项数",常用来校验输入格式是否合法——比如期望读 4 项,返回值不是 4 就说明格式错了。

八、fgets:安全读输入

从键盘读字符串,新手最先学到的是 scanf("%s", buf),但它不检查长度,输入一长就溢出。已经被 C11 标准彻底删除的 gets 更是漏洞之王。正确做法是用 fgets

#include <stdio.h>
#include <string.h>

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

    // 危险!gets 不检查长度,是 C 史上最臭名昭著的漏洞来源
    // gets(buf);                  // C11 已彻底删除这个函数,别用!

    // 安全做法:fgets 最多读 n-1 个字符,并自动补结束符
    printf("请输入(最多 15 字符):");
    fgets(buf, sizeof(buf), stdin);

    // 注意:fgets 会把换行符也读进缓冲区,通常需要手动去掉
    size_t len = strlen(buf);
    if (len > 0 && buf[len - 1] == '\n') {
        buf[len - 1] = '\0';
    }
    printf("你输入了:[%s],长度 %zu\n", buf, strlen(buf));
    return 0;
}

记住 fgets 的三个特点:① 第二个参数指定缓冲区大小;② 会把换行符也读进来,通常要手动去掉;③ 读到文件末尾或出错返回 NULL

九、缓冲区溢出与安全编码

缓冲区溢出是 C 程序最经典、最危险的一类漏洞——写入的数据超过了数组的容量,覆盖了相邻内存,轻则程序崩溃,重则被攻击者利用执行任意代码。下面是一份"安全 vs 危险"的对照写法:

#include <stdio.h>
#include <string.h>

int main(void) {
    char password[8] = "secret";
    char input[8];

    printf("输入密码:");
    // 下面这些都是危险写法,输入超长就会溢出:
    // scanf("%s", input);                       // 无长度限制
    // strcpy(input, some_long_user_string);     // 同样危险

    // 正确做法:fgets 或带长度限制的 scanf
    fgets(input, sizeof(input), stdin);
    // 或:scanf("%7s", input);   // 最多读 7 个字符 + 结束符

    // 去掉末尾换行
    input[strcspn(input, "\n")] = '\0';

    if (strcmp(password, input) == 0) {
        printf("密码正确\n");
    } else {
        printf("密码错误\n");
    }
    return 0;
}
// 历史教训:1988 年"莫里斯蠕虫"利用 gets 的溢出,
// 一夜之间感染了当时互联网上约 10% 的计算机。
// 自此 C 社区开始重视边界检查,strncpy/snprintf/fgets 成了推荐写法。

总结几条铁律:

十、字符字面量的性质

C 里的字符字面量(单引号包裹)其实是 int 类型,不是 char——这跟 C++ 不一样。所以 'A' 在 C 里占 4 字节,值是 65。字符和整数可以自由转换、做算术运算。

#include <stdio.h>

int main(void) {
    char c = 'A';
    printf("字符 = %c\n", c);     // A
    printf("ASCII = %d\n", c);    // 65

    // 关键:字符字面量在 C 里其实是 int 类型
    printf("sizeof('A') = %zu\n", sizeof('A'));  // 4(在 C 中是 int)
    // (而在 C++ 里 sizeof('A') == 1,这是两门语言的一个细微差别)

    // 字符与整数可以互相赋值、做运算
    char lower = (char)(c + 32);   // 'A' + 32 = 'a'
    printf("%c\n", lower);        // a

    // 常见转义字符:换行 制表 反斜杠 单引号 双引号 空字符
    printf("换行=[\n] 制表=[\t] 反斜杠=[\\] 空字符=[\0]\n");

    // 注意:字符 'A' 和字符串 "A" 完全不同
    //   'A' 是 int,占 4 字节,值是 65
    //   "A" 是字符数组,占 2 字节,内容是 'A' 和 '\0'
    return 0;
}

区分清楚两个概念:字符 'A' 是一个整数(65);字符串 "A" 是一个 2 字节的数组,内容是字符 'A' 加一个结束符。两者类型完全不同,混用会出问题。

十一、ctype.h:字符分类与转换

处理单个字符时,ctype.h 提供了一组现成函数:判断字母/数字/空白、大小写转换等。它们参数和返回值都是 int,且参数应该传 unsigned char 范围的值或 EOF

#include <stdio.h>
#include <ctype.h>

int main(void) {
    // ctype.h 提供字符分类与大小写转换,参数和返回值都是 int

    char c = 'g';
    printf("isalpha('%c') = %d\n", c, isalpha(c));   // 非 0(是字母)
    printf("isdigit('%c') = %d\n", c, isdigit(c));   // 0(不是数字)
    printf("islower('%c') = %d\n", c, islower(c));   // 非 0(小写)
    printf("toupper('%c') = %c\n", c, toupper(c));   // G

    // 实战:把字符串整体转大写
    char s[] = "Hello World 2026";
    for (int i = 0; s[i] != '\0'; i++) {
        s[i] = (char)toupper((unsigned char)s[i]);
    }
    printf("%s\n", s);    // HELLO WORLD 2026

    // 常用函数清单:
    //   分类:isalpha isdigit isalnum isspace isupper islower
    //         ispunct isxdigit isprint
    //   转换:toupper tolower
    return 0;
}

实战中最常用的两个场景是:① 统计某种字符的数量(比如一行里有多少字母);② 统一字符串的大小写做规范化比较。注意把 char 强转 unsigned char 再传进去,否则遇到负值(中文字符的高字节)会越界访问数组,触发未定义行为。

小结

字符串就是"以结束符结尾的字符数组"。string.h 提供了一整套处理函数,但多数不检查长度,要用就得自己管好缓冲区大小——优先选带 n 的安全版本。sprintf/sscanf 让你灵活地格式化与解析,输入用 fgets。处理单个字符靠 ctype.h。下一篇我们看 C 的结构体——把多个类型不同的数据组合成自定义类型,这是组织复杂数据的关键。

← 上一篇 C 指针(重点)

下一篇 C 结构体

✈️💬