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;
}几个关键点:
- 声明时给的字面量末尾会自动补结束符,你写
"Hi"实际占 3 字节。 - 指定大小时,剩余位置会被编译器自动补 0——所以
char s[10] = "Hello";后面 4 个字节也是 0。 - 逐字符初始化时不会自动补结束符,要自己加,否则
printf用%s输出会越界读到相邻内存。
二、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;
}注意 strncat 和 strncpy 行为不对称: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(从字符串读取)
sscanf 是 scanf 的"字符串版":从一个已有字符串里按格式提取数据。解析固定格式的日期、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 成了推荐写法。总结几条铁律:
- 任何接收外部输入的字符数组都要限定长度,用
fgets或带长度限制的scanf。 - 拷贝和拼接优先选带
n的版本:strncpy、strncat、snprintf。 - 缓冲区大小尽量用
sizeof或宏常量,不要散落写魔数。 - 永远别用
gets,现代编译器会直接警告甚至拒绝编译。
十、字符字面量的性质
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 结构体 →