很多人用了好一阵子 printf("%s", str) 之后才意识到,C 语言根本没有"字符串类型"。char 数组加一个 \0 才是字符串,缺了那个 \0,你调 strlen 会读到哪里去,没人知道。
这篇把字符串的底层机制说清楚,顺带讲几个能把程序搞崩的坑。
字符串的本质:字符数组 + \0
C 语言的字符串不是一个专门的类型,本质就是一块连续的内存,最后一个字节存着值为 0 的 \0(空字符,null terminator)。
char name[6] = {'h', 'e', 'l', 'l', 'o', '\0'};
等价写法:
char name[6] = "hello";
"hello" 这个字符串字面量本身就含 \0,编译器会把它展开成 6 个字节,不是 5 个。
内存布局:
地址: 1000 1001 1002 1003 1004 1005
内容: 'h' 'e' 'l' 'l' 'o' '\0'
十六进: 68 65 6C 6C 6F 00
strlen、printf %s、strcpy 这些函数都从起始地址开始往后读,碰到 \0 就停。这就是为什么 \0 是字符串的核心------没有它,这些函数不知道字符串在哪里结束,会一直往后读,进入它不该进的内存区域。
声明方式:三种写法的差异
写法一:字符数组(可修改)
char str[] = "hello"; /* 编译器自动算长度:6 个字节 */
str[0] = 'H'; /* 合法,数组内容可修改 */
str 存的是局部变量,放在栈上,内容可以改。
写法二:指针指向字符串字面量(危险)
char *p = "hello";
p[0] = 'H'; /* 运行时崩溃或静默失败 */
"hello" 是字符串字面量,编译器把它放在只读数据段 (.rodata)。p 指向那块只读内存,写入操作是未定义行为------在 Windows 上通常是访问违规崩溃,在某些平台上可能什么也不发生但结果是错的。
VS2013 编译这行不报错,但运行时会弹出一个访问违规的对话框。正确写法是加 const:
const char *p = "hello"; /* 明确告诉自己和编译器:这块内存不能改 */
写法三:固定长度数组
char buf[64]; /* 声明一个 64 字节的缓冲区 */
buf 里的内容是未初始化的垃圾值,没有 \0。直接 printf("%s", buf) 会打印出一堆乱码,直到碰到内存里某个恰好是 0 的字节才停。
初始化方式:
buf[0] = '\0'; /* 方法一:把第一个字节清零,等价于空字符串 */
memset(buf, 0, 64); /* 方法二:全部清零,更安全 */
常用字符串函数:用法与陷阱
strlen:求长度,不含 \0
#include <string.h>
char s[] = "hello";
size_t len = strlen(s); /* 返回 5,不是 6 */
性能陷阱 :strlen 每次调用都从头扫到 \0,时间复杂度 O(n)。把它放在循环条件里是典型的性能问题:
/* 错误写法:每次循环都调一次 strlen,O(n²) */
for (i = 0; i < strlen(s); i++) { ... }
/* 正确写法:先算好长度 */
int len = strlen(s);
for (i = 0; i < len; i++) { ... }
字符串不长的时候看不出差别,字符串一旦上了几千字节,循环里的 strlen 会让程序明显变慢。
strcpy:复制字符串
char dst[10];
strcpy(dst, "hello"); /* 把 "hello\0" 复制到 dst */
strcpy 不检查目标缓冲区够不够大,是经典的缓冲区溢出来源:
char dst[4];
strcpy(dst, "hello"); /* 写了 6 个字节进 4 字节的空间,溢出 */
溢出会覆盖 dst 后面的内存,可能改掉其他变量,可能破坏返回地址,可能什么都不发生------但这正是最危险的情况,因为 bug 不在当场发作,而是在你根本不想到的地方崩溃。
VS2013 的安全版本:
/* strcpy_s 会检查目标缓冲区大小,超出时触发错误处理 */
strcpy_s(dst, sizeof(dst), "hello");
strcmp:比较字符串
这里是最常见的初学者错误:用 == 比较两个字符串。
char a[] = "hello";
char b[] = "hello";
if (a == b) { ... } /* 错!比较的是两个数组的地址,永远不相等 */
if (strcmp(a, b) == 0) { ... } /* 对!比较的是内容 */
a 和 b 是两个不同的数组,它们的地址不同,a == b 永远是假。strcmp 返回 0 表示两个字符串内容相同,正数/负数表示大小关系(按字典序)。
strcat:拼接字符串
char buf[20] = "hello";
strcat(buf, " world"); /* buf 变成 "hello world" */
同样不检查目标缓冲区大小,同样有溢出风险。安全版本用 strncat(指定最多追加多少字节)或 VS2013 的 strcat_s。
完整示例:字符串处理的正确姿势
#include <stdio.h>
#include <string.h>
#define BUF_SIZE 64
int main() {
char src[] = "Hello, C language";
char dst[BUF_SIZE];
int i;
int len;
/* 安全复制 */
strcpy_s(dst, BUF_SIZE, src);
printf("复制结果:%s\n", dst);
/* 统计长度,先存起来避免循环里重复调用 */
len = (int)strlen(dst);
printf("长度:%d\n", len);
/* 遍历字符,把小写字母转大写 */
for (i = 0; i < len; i++) {
if (dst[i] >= 'a' && dst[i] <= 'z') {
dst[i] = dst[i] - 'a' + 'A';
}
}
printf("转大写:%s\n", dst);
/* 字符串比较 */
if (strcmp(src, dst) != 0) {
printf("两个字符串内容不同\n");
}
return 0;
}
在 VS2013 里按 F5,输出:
复制结果:Hello, C language
长度:17
转大写:HELLO, C LANGUAGE
两个字符串内容不同
避坑清单
坑 1:数组太小,没给 \0****留位置
char s[5] = "hello"; /* 错!"hello" 需要 6 个字节,s 只有 5 个 */
VS2013 会报警告或错误。规则:数组大小至少是字符串长度 + 1。
坑 2:忘记初始化,直接当字符串用
char buf[64];
printf("%s\n", buf); /* buf 里是垃圾,可能打出一堆乱码 */
声明字符数组后先 memset(buf, 0, sizeof(buf)) 或 buf[0] = '\0'。
坑 3:用 **==**比较字符串内容
已在上面说过,== 比的是地址,不是内容。一律用 strcmp。
坑 4:向只读字符串字面量写入
char *p = "hello";
p[0] = 'H'; /* 运行时崩溃 */
需要修改的字符串,用 char [] 而不是 char *。
坑 5: strcpy****目标缓冲区不够大
源字符串多长你得自己算,strcpy 不帮你检查。在 VS2013 里优先用 strcpy_s。
最佳实践
-
字符数组大小用
#define定义常量,方便统一修改,也防止sizeof和硬编码不一致 -
所有字符数组在声明后立即用
memset清零,而不是依赖局部变量的初始值 -
在 VS2013 环境下,用
strcpy_s、strcat_s、sprintf_s代替不安全的老版本 -
循环遍历字符串时先把
strlen结果存到变量,不要每次循环都调 -
函数接收字符串参数时,同时传入缓冲区大小,永远不要假设调用方传进来的缓冲区够大
标签 :#C语言 #字符串 #内存安全 #缓冲区溢出 #VS2013
