5、字符串安全:strcpy/strcat的危险性、strncpy/strncat的正确使用、snprintf替代sprintf、安全的字符串处理函数。

字符串处理,是C语言里最容易翻车的地方。我见过太多线上事故,最后定位到根因,就是一行简单的strcpy或者sprintf。说白了,C语言把内存的缰绳交到你手里,但没告诉你马会尥蹶子。

这一章,我们就来聊聊字符串的那些“坑”,以及怎么填平它们。

5.1 strcpy/strcat:看似简单,实则危险

strcpystrcat,是C标准库最早期的字符串函数。它们的设计思路很单纯:你给我源字符串,我给你复制/拼接过去。但问题在于——它们不做边界检查

⚠ 核心风险: 目标缓冲区大小未知,源字符串可能任意长。一旦源字符串长度超过目标缓冲区,就会发生缓冲区溢出,覆盖相邻内存数据。

我在项目中遇到过这样一个案例:一个嵌入式设备,运行了几个月后突然重启。查了三天,最后发现是某处日志打印用了strcat,拼接了一个异常长的设备名称,直接把栈上的返回地址给覆盖了。嗯,从那以后,我对strcat就彻底失去了信任。

看一个典型的问题代码:

char buf[16];
char *input = "This is a very long string that will overflow!";
strcpy(buf, input);  // 危险!buf只有16字节,input远超16字节

这段代码编译时不会有任何警告,运行时也不会立即崩溃。但buf后面的内存已经被污染了。这种bug,你想想看,最难查的就是它——症状和病因往往不在同一个地方。

strcat同理:

char buf[32] = "Hello";
char *suffix = ", this is a very long suffix that will overflow the buffer!";
strcat(buf, suffix);  // 危险!buf剩余空间可能不够

为什么会这样?因为strcat不知道buf还剩多少空间,它只管往buf'\0'位置开始复制,直到遇到源字符串的'\0'

5.2 strncpy/strncat:有限度的安全,但别掉以轻心

为了解决上述问题,C标准引入了strncpystrncat。它们多了一个参数——最大复制长度。听起来靠谱多了,对吧?但实际用起来,还是有不少细节需要注意。

5.2.1 strncpy 的正确用法

strncpy(dst, src, n) 最多复制 n 个字符到 dst。但有个坑:如果 src 长度大于等于 ndst 不会以 '\0' 结尾

✅ 安全做法: 调用 strncpy 后,手动在 dst[n-1] 位置写入 '\0'

我个人习惯这样写:

char dst[32];
strncpy(dst, src, sizeof(dst) - 1);
dst[sizeof(dst) - 1] = '\0';  // 确保以'\0'结尾

注意这里用的是 sizeof(dst) - 1,而不是 sizeof(dst)。留一个字节给终止符。

5.2.2 strncat 的正确用法

strncat 的行为和 strncpy 略有不同。它的第三个参数表示最多追加的字符数,不包括终止符。而且 strncat 会自动在结果末尾加 '\0'

char dst[32] = "Hello";
char *src = " World!";
strncat(dst, src, sizeof(dst) - strlen(dst) - 1);

这里计算剩余空间时,减去了 strlen(dst)1(留给 '\0')。

💡 小技巧: 我建议封装一个安全宏,避免每次手动计算:
#define SAFE_STRNCPY(dst, src) do { \
    strncpy(dst, src, sizeof(dst) - 1); \
    dst[sizeof(dst) - 1] = '\0'; \
} while(0)

5.3 snprintf:sprintf 的替代者

sprintfstrcpy 是同一类问题——不检查输出长度。格式化输出时,如果目标缓冲区太小,一样会溢出。

我曾经在调试一个通信协议栈时,发现日志里偶尔出现乱码。查了半天,原来是 sprintf 拼接的字符串超过了缓冲区大小,把相邻的协议状态变量给覆盖了。从那以后,我代码里所有的 sprintf 都被替换成了 snprintf

snprintf 的原型:

int snprintf(char *str, size_t size, const char *format, ...);

它保证最多写入 size - 1 个字符,并在末尾加 '\0'。返回值是“如果缓冲区足够大,应该写入的字符数”(不包括终止符)。

标准用法:

char buf[64];
int n = snprintf(buf, sizeof(buf), "Value: %d, Name: %s", val, name);
if (n >= sizeof(buf)) {
    // 输出被截断了,可以处理或记录日志
}

注意检查返回值。如果返回值大于等于缓冲区大小,说明输出被截断了。你可以选择报错、重新分配更大的缓冲区,或者直接忽略(如果截断可接受)。

✅ 最佳实践: 所有格式化输出到固定缓冲区的场景,一律使用 snprintf 替代 sprintf。没有例外。

5.4 安全的字符串处理函数

除了上面提到的函数,C11标准还引入了一批“安全版”字符串函数,定义在 string.h 中(需要定义 __STDC_WANT_LIB_EXT1__ 宏)。比如:

  • strcpy_s
  • strcat_s
  • strncpy_s
  • sprintf_s
  • snprintf_s

这些函数的特点是:明确接收缓冲区大小参数,并在溢出时返回错误码或调用约束处理函数

举个例子:

errno_t err = strcpy_s(dst, sizeof(dst), src);
if (err != 0) {
    // 处理错误
}

不过要注意,这些安全函数并非所有平台都支持。嵌入式开发中,很多编译器(尤其是老旧的)并不实现 Annex K。所以我的建议是:

  • 如果平台支持:优先使用 _s 系列函数
  • 如果不支持:用 snprintf + 手动 '\0' 终止的方式,封装自己的安全函数

下面是我在项目中常用的一套封装:

// 安全字符串复制
static inline void safe_strcpy(char *dst, size_t dst_size, const char *src) {
    if (dst_size == 0) return;
    size_t src_len = strlen(src);
    size_t copy_len = (src_len < dst_size - 1) ? src_len : dst_size - 1;
    memcpy(dst, src, copy_len);
    dst[copy_len] = '\0';
}

// 安全字符串拼接
static inline void safe_strcat(char *dst, size_t dst_size, const char *src) {
    size_t dst_len = strlen(dst);
    size_t remaining = dst_size - dst_len;
    if (remaining <= 1) return;
    safe_strcpy(dst + dst_len, remaining, src);
}

你想想看,多写这几行代码,能省去多少排查内存问题的痛苦?

5.5 本章知识体系

下面这张图,帮你梳理字符串安全的核心逻辑:

字符串安全处理决策树 字符串操作 ❌ 危险函数 ✅ 安全函数 strcpy / strcat / sprintf 不检查缓冲区大小 → 缓冲区溢出 → 安全漏洞 strncpy / strncat / snprintf 指定最大长度,防止溢出 注意手动加 '\0' 终止 C11安全函数 (_s 系列) 平台支持时优先使用 必须替换为安全版本 封装自己的安全函数

说白了,字符串安全的核心就一句话:永远假设输入比你预期的长。在这个假设下,所有不检查长度的函数都是定时炸弹。

💡 我的个人习惯: 在新项目中,我会在代码规范里直接禁止使用 strcpystrcatsprintf。代码审查时看到这三个函数,直接打回。你想想看,这能省多少事。

记住:防御式编程不是让你写更复杂的代码,而是让你写更不容易出错的代码。字符串安全,就是防御式编程的第一道防线。


公众号:蓝海资料掘金营,微信deep3321