第1章:历史遗留问题——不安全函数的替代方案

说实话,每次我看到老项目里还有 gets()strcpy() 这些函数,心里就咯噔一下。这不是矫情,是真的被坑过。

我入行那会儿,C 标准库里的这些函数还是「标配」。大家写代码都这么写,也没觉得有啥问题。直到有一次,我负责的一个嵌入式设备在客户现场跑着跑着就挂了——后来定位到是 sprintf() 缓冲区溢出,直接把栈给踩了。嗯,从那以后,我对这些函数的态度就变成了:能不用,坚决不用。

这一章,我就带你把这些「历史遗留问题」一个一个捋清楚。说白了,就是告诉你哪些函数有坑,用什么来替代。

1. gets() → fgets()

gets() 这个函数,C 标准委员会自己都看不下去了,在 C11 标准里直接把它删了。为什么?因为它不检查输入长度。

// 危险写法
char buf[16];
gets(buf);  // 用户输入超过15个字符,直接溢出

你想想看,用户输入多少字符,完全不可控。攻击者可以利用这个漏洞执行任意代码。这不是危言耸听,历史上很多安全漏洞就是这么来的。

警告:永远不要使用 gets()。它已经被废弃,任何现代编译器都会给出警告。

替代方案很简单:用 fgets()

// 安全写法
char buf[16];
if (fgets(buf, sizeof(buf), stdin) != NULL) {
    // 处理输入
    buf[strcspn(buf, "\n")] = '\0';  // 去掉换行符
}

我个人习惯是,每次用 fgets() 之后,都会手动去掉末尾的换行符。因为 fgets() 会保留换行,而很多场景下我们不需要它。

2. strcpy() / strcat() → strncpy() / strncat() 或 snprintf()

strcpy()strcat() 的问题跟 gets() 类似——不检查目标缓冲区大小。

// 危险写法
char dest[32];
char *src = "这是一段非常非常长的字符串,远远超过32个字节...";
strcpy(dest, src);  // 溢出!
strcat(dest, "更多内容");  // 继续溢出!

我在项目中遇到过这样的 bug:两个字符串拼接后,直接把相邻的变量给覆盖了。调试了整整一天才发现是 strcat() 的问题。

替代方案有两个:

  • strncpy() / strncat():指定最大复制长度
  • snprintf():更灵活,推荐使用
// 安全写法
char dest[32];
char *src = "这是一段很长的字符串...";

// 方法1:strncpy
strncpy(dest, src, sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0';  // 确保以'\0'结尾

// 方法2:snprintf(我更喜欢这个)
snprintf(dest, sizeof(dest), "%s", src);
提示:strncpy() 有个坑——如果源字符串长度大于等于 n,它不会自动添加 '\0'。所以一定要手动加。而 snprintf() 会自动处理,所以我个人更推荐用 snprintf()。

3. sprintf() → snprintf()

sprintf()strcpy() 是「难兄难弟」。它把格式化后的字符串写入缓冲区,但不检查缓冲区大小。

// 危险写法
char buf[32];
int val = 123456;
sprintf(buf, "value = %d", val);  // 如果格式化结果超过32字节,溢出!

我曾经见过一个产品,因为 sprintf() 溢出导致系统随机重启。那个 bug 时有时无,跟用户输入的数据有关,排查起来特别痛苦。

替代方案:用 snprintf(),它多了一个参数指定缓冲区大小。

// 安全写法
char buf[32];
int val = 123456;
int len = snprintf(buf, sizeof(buf), "value = %d", val);
if (len >= sizeof(buf)) {
    // 输出被截断了,需要处理
    fprintf(stderr, "警告:输出被截断\n");
}

注意 snprintf() 的返回值:它返回的是「如果缓冲区足够大,应该写入的字符数」。如果返回值大于等于缓冲区大小,说明输出被截断了。这个信息可以用来做错误处理。

4. atoi() / atol() / atof() → strtol() / strtoul() / strtof()

atoi() 系列函数的问题在于:它没有错误处理机制。如果输入不是有效的数字,它返回 0。但 0 本身也是一个合法值,你分不清是转换成功还是失败。

// 危险写法
char *input = "abc";
int val = atoi(input);  // 返回0,但无法判断是否出错

替代方案:用 strtol()strtoul()strtof() 等函数。它们提供了错误检测能力。

// 安全写法
char *input = "123";
char *endptr;
long val = strtol(input, &endptr, 10);

if (endptr == input) {
    // 没有读取到任何数字
    fprintf(stderr, "错误:无效的数字格式\n");
} else if (*endptr != '\0') {
    // 读取到了数字,但后面还有多余字符
    fprintf(stderr, "警告:输入包含多余字符\n");
} else {
    // 转换成功
    printf("value = %ld\n", val);
}

我个人习惯是,只要涉及用户输入的数字转换,一律用 strtol() 家族。虽然代码多几行,但心里踏实。

知识体系总览

下面这张图总结了本章的核心内容:不安全函数及其替代方案。

不安全函数及其替代方案 不安全函数(有坑) 安全替代方案 gets() fgets() strcpy() / strcat() strncpy() / snprintf() sprintf() snprintf() atoi() / atol() / atof() strtol() / strtoul() / strtof() 核心原则:永远指定缓冲区大小,永远检查返回值

总结

这一章我们聊了四个典型的不安全函数及其替代方案。说白了,核心原则就一条:永远不要假设输入的长度是可控的

我曾经在代码审查时看到有人写 strcpy(dest, "hello"),觉得「反正源字符串很短,不会溢出」。这种想法很危险——代码是会演化的,今天写死的字符串,明天可能就改成从配置文件读取了。到时候忘了改 strcpy,bug 就埋下了。

所以我的建议是:从一开始就用安全版本。多打几个字符,换来的是长期的安心。

避坑指南:

  • 我曾经因为 gets() 导致栈溢出,系统在客户现场崩溃——从那以后我再也没用过 gets()。
  • 我曾经因为 strcat() 覆盖了相邻变量,调试了整整一天——现在我只用 snprintf() 做字符串拼接。
  • 我曾经因为 atoi() 无法区分「转换失败」和「输入为0」,导致业务逻辑出错——现在一律用 strtol()。

下一章,我们会继续聊另一个经典陷阱:数组下标越界。嗯,那也是个大坑。


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