很多人用了好一阵子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 00strlen、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