【C语言】字符串的本质是什么,为什么 strlen 会读到野内存

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

strlenprintf %sstrcpy 这些函数都从起始地址开始往后读,碰到 \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) { ... }   /* 对!比较的是内容 */

ab 是两个不同的数组,它们的地址不同,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_sstrcat_ssprintf_s 代替不安全的老版本

  • 循环遍历字符串时先把 strlen 结果存到变量,不要每次循环都调

  • 函数接收字符串参数时,同时传入缓冲区大小,永远不要假设调用方传进来的缓冲区够大


标签#C语言 #字符串 #内存安全 #缓冲区溢出 #VS2013

相关推荐
云泽8081 小时前
从零吃透 C++ 异常:抛出捕获、栈展开、异常重抛与编码规范详解
开发语言·c++·代码规范
灯澜忆梦1 小时前
GO---可见性规则
开发语言·golang
逝水无殇2 小时前
C# 文件的输入与输出详解
开发语言·数据库·后端·c#
这是个栗子2 小时前
前端开发中的常用工具函数(八)
开发语言·前端·javascript
凤凰院凶涛QAQ3 小时前
《Java版数据结构 & 集合类剖析》栈与队列:“push/pop 是栈的灵魂,offer/poll 是队列的骨架——四组 API,两种人生”
java·开发语言·数据结构
研☆香3 小时前
为什么变量声明在赋值前也能使用?—— 深入理解 JavaScript 变量提升与作用域
开发语言·前端·javascript
右耳朵猫AI3 小时前
PHP周刊2026W28 | 安全更新、Laravel 13.18、Twig 3.28
开发语言·php·laravel
second603 小时前
第一部分:快速上手 —— 建立 C++ 基本语法与编程范式
开发语言·c++
郝学胜-神的一滴3 小时前
算法实战:最小k个数——大顶堆的优雅解法
开发语言·数据结构·c++·python·程序人生·算法·排序算法
bksczm3 小时前
linux之线程概念和控制
linux·开发语言·c++