一开始接触C语言编程,很多人的第一站就是Visual Studio。环境装好、教材翻开、照着敲几行代码,结果在scanf这行被卡住了:编译报错,红字写着C4996,后面跟着一句“'scanf': This function or variable may be unsafe”。我见过不少初学者卡在这个报错上反复怀疑人生,甚至有人认为是自己编译器装坏了,重装了好几遍问题还在。这个C4996的报错很典型,但理解起来并不难。它本质上不是语法错误,而是一条被“升级”成错误的安全警告,是Visual Studio默认启用的安全检查机制在拦你。
我打算在这篇文章里把C4996讲透:它到底是什么、scanf为什么会撞上它、有哪些可行方案能绕过或解决,以及不同方案背后的代价和适用场景。无论是刚学C语言的初学者,还是在维护老项目的开发者,这篇内容都可以当做一个完整的排查参考。文章里所有代码和操作都以Windows下的Visual Studio为背景,但不限定版本,2017、2019、2022这几个版本的操作路径基本一致。
1. C4996究竟是一个什么报错
1.1 完整的错误现场
先用最简单的方式复现一下。新建一个控制台项目,在源文件里写:
#include <stdio.h> int main(void) { char name[20]; scanf("%s", name); printf("Hello, %s\n", name); return 0; }不要做任何额外配置,直接按F7编译,输出窗口会给出类似下面的信息:
error C4996: 'scanf': This function or variable may be unsafe. Consider using scanf_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.这一条提示里面,其实包含了两份关键信息:第一,scanf被认为“可能不安全”,编译器建议你使用scanf_s;第二,如果确实不想理会这个警告,可以定义_CRT_SECURE_NO_WARNINGS来屏蔽。很多人到这里就卡住了,不知道是该听前半句去改成scanf_s,还是听后半句去加个宏定义,甚至有人两个都试了还是报错,后面我会分别说明。
需要先说明一点,这里的“error C4996”在严格意义上并不是语法错误,它是编译器的一条warning(警告)。但是Visual Studio在默认的新项目里会开启一项叫做“安全开发生命周期检查”的选项,简称SDL。当这条检查被开启时,部分安全类警告会被强制视为error,结果就是警告升级成了错误,代码无法通过编译。这个设计思路本身是好的,但对于初学C语言、只想按书上的例子跑通程序的人来说,确实有点过于严格了。
1.2 C4996是一个编号,也是一种标记机制
C4996不是scanf独有的错误编号,而是一类问题的统称。编译器内部维护了一张“不安全/已废弃API”的清单,只要你的代码调用了清单里的函数,就会产生C4996。最常见的触发对象包括scanf、strcpy、strcat、sprintf、gets、fopen等。注意,这里面的get、gets这类函数在C语言标准里本身就已经被移除了,而scanf和strcpy这些则是“C标准还在,但编译器厂商认为风险过高”的典型。
我自己的理解是:C4996其实是编译器在替你做一个“代码安全雷达”。它会扫描你调用的函数,检查这些函数是否有众所周知的隐患,如果有,就给出编号明确、内容可读的提示。这种做法早年是C++开发者社区里常用的“废弃标记”(deprecation)机制的延伸,VS把它推广到了安全层面。遇到这个报错,你首先要建立的心理模型是:不是代码写错了,是编译器觉得这个写法存在安全风险,并且默认拦截了。
1.3 scanf为什么会被拉黑
这里要先说一个听起来有点矛盾的事实:scanf是C语言标准库里的正规函数,在绝大多数非Windows编译器上都能正常使用,比如gcc、Clang都不会报错。那Visual Studio为什么“拉黑”它?
关键在于scanf这个函数在读取输入时,不检查缓冲区边界。你给它一个%s格式说明符,它就会往目标数组里一直写入,直到遇到空白字符为止。如果用户输入的内容比数组长度还长,多出来的部分就会直接写到数组后面的内存区域去。这种现象被称为缓冲区溢出,是很多安全漏洞的根源。举一个实际例子:
char name[10]; scanf("%s", name);用户如果输入了20个字符,那10个字符的缓冲区会被写满,另外10个字符会覆盖到相邻内存。这个时候程序可能不会立刻崩溃,但行为已经不可控了,后续数据被破坏、程序在某个诡异的时机崩溃、甚至被利用执行其他代码,都可能发生。Visual Studio不是不信任你这个开发者,而是这个函数的设计本身就不具备“防御能力”,它没法告诉调用方“我装不下了”。
有人可能会问,那输入短一点不就行了?问题是,程序无法控制用户的输入。只要程序运行起来,输入权就在用户手里。在真实场景里,一个输入框、一个网络请求、一个配置文件,都是攻击者可以反复试探的入口。为了让标准输入转化为安全的代码,编译器厂商选择了一步到位的策略:提供拥有长度参数的替代函数scanf_s,并用C4996压制旧函数的使用。
2. 缓冲区溢出是问题的真正内核
2.1 用一段代码理解缓冲区溢出
缓冲区溢出这个概念,听起来很专业,但可以用一个生活化的类比来解释。缓冲区就像是一个固定大小的停车位,scanf就是你指挥一辆车停进来的动作。如果车位只能停10米长的车,来的车却长20米,你硬要指挥它倒进去,最终车头就越过了车位界线,占到了别人的通道上。程序内存里的“别人的通道”就是相邻变量、返回地址、控制结构等数据。
下面这段代码可以让你直观感受一下:
#include <stdio.h> int main(void) { char password[8] = "secret"; char input[8] = ""; scanf("%s", input); printf("input: %s\n", input); printf("password: %s\n", password); return 0; }如果运行后输入一段超过8个字符的内容,比如“AAAAAAAAAABBBBCCCC”,你会发现原本在内存中排在前面的password数组内容也有可能会被改变。虽然具体输出依赖于编译器的内存布局,但基本现象就是:输入内容“越界”了,覆盖到了不属于它的空间。这就是缓冲区溢出最直接的体现。
2.2 受影响的不只是scanf
在VS的安全清单里,跟scanf命运相似的还有一批函数。我简单列一个表格,方便大家查阅:
| 原函数 | 风险点 | VS建议替代函数 |
|---|---|---|
| scanf | 输入写入时不检查目标缓冲区大小 | scanf_s |
| gets | 从标准输入读一行,完全不限制长度 | gets_s / fgets |
| strcpy | 字符串复制没有长度限制 | strcpy_s / strncpy |
| strcat | 字符串拼接没有长度限制 | strcat_s / strncat |
| sprintf | 格式化输出到字符数组,不检查空间 | sprintf_s / snprintf |
| fopen | 文件路径和权限处理不够严格 | fopen_s |
这里要提示一个很容易被忽略的问题:如果你为了消除C4996,把scanf改成了strcpy、gets等,那你只是把报错从一处挪到了另一处。真正需要建立的意识是:这些旧式字符串/输入函数都有相似的问题——缺少长度限制。
2.3 为什么C语言标准还保留scanf
讲了这么多风险,你可能会好奇:那为什么不直接从C标准里删掉scanf?这就要说到兼容性的分量了。C语言是一门非常看重向后兼容的语言,全世界有海量存量代码正在依赖scanf这些函数运行。如果标准把这些函数删掉,那这些老代码在严格模式下全部会编译失败,代价极其巨大。
所以C标准组织的做法是:保留scanf作为标准函数,但通过标准库引入更多安全选择,比如C11标准里的scanf_s只是作为可选扩展,而fgets、snprintf这类函数在标准里一直可以正常使用。Visual Studio的做法则更激进一些,直接在编译器层面用C4996把风险函数标记出来。你可以把VS看作一个“对自己要求更严格”的编译器,它默认认为你没有足够的能力保证scanf安全使用,所以你必须换一个更安全的接口,或者明确告诉它“我知道风险,我接受”。
3. 四大解决方案的选择逻辑
3.1 方案对比全景图
根据你目前的角色和项目背景,处理C4996有四种主流手段。我把它们的关键信息先放在表格里:
| 方案 | 操作方式 | 代码改动量 | 安全性 | 适用场景 |
|---|---|---|---|---|
| 屏蔽警告 | 定义_CRT_SECURE_NO_WARNINGS | 几乎为零 | 维持原状 | 学习练习、快速验证小段代码 |
| 编译选项关闭SDL | 项目属性中取消SDL检查 | 为零 | 维持原状 | 维护旧项目、接受实际风险 |
| 改用scanf_s | 替换函数并调整参数 | 小 | 提高 | 学习阶段、Windows专属项目 |
| 改用fgets/sscanf | 重写输入读取逻辑 | 中等 | 提高 | 可移植性要求高的项目 |
选择方案之前,最好先判断自己的目标是什么。如果只是在学校作业里想快速跑通代码,那方案一就够了,毕竟你并不打算面向攻击者开放输入接口。如果正在学习C语言、想养成好习惯,值得认真用scanf_s或者fgets把输入逻辑写好。如果是跨平台项目或者会被复用的模块,建议放弃scanf,全面采用fgets配合sscanf的方式,因为scanf_s是Visual Studio特有的增强函数,拿到Linux或者macOS上编译时,并不一定被支持。
3.2 先搞懂“屏蔽警告”和“解决问题”的区别
关于C4996,有一个理解误区:很多人把“屏蔽警告”等同于“解决问题”。从编译器角度来说,你在文件顶部加一行#define _CRT_SECURE_NO_WARNINGS,编译确实就通过了。但你要知道,这行宏只是关闭了编译器对你发出的安全提醒,并没有让scanf变得安全。这就像拆掉烟雾报警器来防止它误报,但厨房里的安全隐患依然存在,甚至因为报警器被拆掉了,风险变得更加隐蔽。
相比之下,把scanf换成scanf_s,才是真正意义上把“无边界输入”改成了“有边界输入”。这两种思路对后续的工程实践影响差异很大,也直接决定了这篇文章后续内容的走向:前几种方案我会讲清楚怎么样操作最快捷,后两种方案我会重点解释怎么改写才不出问题。
4. 方案一实操:三类屏蔽警告的方法
4.1 在源文件顶部添加宏定义
最直接的方式是在包含任何头文件之前,加上这样一行:
#define _CRT_SECURE_NO_WARNINGS注意“在任何头文件之前”这个前提非常关键。因为头文件里可能会声明函数,编译器在处理到相关声明时,就需要知道是否要附加C4996警告。如果宏定义放在头文件之后,那么编译器在解析头文件的时候还没有看到这个宏,警告仍然会被触发。
我见过不少人在第一行写上这条宏之后,死活还是有C4996报错,仔细一看,他前面先写了#include <stdio.h>,宏在include之后。这种顺序错了,效果就是等于没写。正确的完整代码一般长这样:
#define _CRT_SECURE_NO_WARNINGS #include <stdio.h> int main(void) { char name[20]; scanf("%s", name); printf("Hello, %s\n", name); return 0; }这种做法的优点是清晰、局部化,只影响当前源文件。缺点是如果项目里有几十个.cpp/.c文件,就需要在每个文件里都加这一行,非常麻烦,也很容易遗漏。
4.2 在项目属性里统一配置
更推荐的做法是对整个项目统一配置。右键项目名称,选择“属性”,然后在左侧导航里找到“C/C++” -> “预处理器”,点击“预处理器定义”右侧的下拉框,选择“编辑”,在弹出来的列表里添加_CRT_SECURE_NO_WARNINGS,确认保存。
这个操作实际效果相当于在编译命令行里加了一个全局的宏定义,作用范围覆盖项目里所有源文件。不管是刚新建的.cpp文件还是很久以前的老文件,都能一次性解决问题。这个办法是我处理旧代码时最常用的,简单高效又不容易出错。
有一点需要说明:这里的“项目属性”窗口在不同版本的VS里可能有细微差异,但基本路径一致。如果你用的是非英文版VS,名称可能会显示为“预处理器定义”或“预处理器宏”,但添加的内容都是同一个宏。
4.3 直接关闭SDL检查
还有一种一劳永逸的办法:在项目属性的“C/C++” -> “SDL检查”选项里,把默认的“是(/sdl)”改成“否(/sdl-)”并确认。这时候,C4996这条警告就不会再被强制当作错误处理了,甚至很多别类安全警告也会被降级为普通warning,不再阻断编译。
这个方案适合什么样的场景呢?最常见的是两类:一类是维护老项目,代码里有大量旧函数调用,短期根本改不完,只能先通过关闭SDL保证能编译继续开发;另一类是某些教学场景,主要目的是让学生先把注意力放在语法和逻辑上,暂不考虑安全加固。但我会建议不要长期依赖这个方法,因为SDL检查是VS整个安全模型里的重要一环,关掉它等于同时放弃了其他多项安全防御,而不仅仅是放过scanf这一个问题。
4.4 三种屏蔽方式的选择建议
| 方式 | 代码/配置量 | 作用范围 | 推荐指数 |
|---|---|---|---|
| 文件顶部宏定义 | 每文件一行 | 当前源文件 | 适合单文件练习 |
| 项目预处理器定义 | 配置一次 | 整个项目 | 适合老项目快速处理 |
| 关闭SDL检查 | 配置一次 | 整个项目 | 最彻底但代价最大 |
如果你只是因为一个作业卡住了,我建议直接用第一种或第二种,并以第二种优先。因为你迟早会面对多文件项目,现在学会项目属性配置,后面省很多事。
5. 方案二实操:学会用scanf_s
5.1 scanf_s的基本调用格式
如果你愿意花几分钟适应scanf_s的参数变化,它其实是很友好的。scanf_s和scanf在大部分情况下格式很接近,区别在于需要为部分格式说明符额外提供缓冲区大小。比如原来的scanf("%s", name)要改写成:
char name[20]; scanf_s("%s", name, (unsigned)sizeof(name));这里第三个参数就是告诉编译器或者运行库:这个缓冲区最多能接受多少字符。运行库在写入的时候会限制长度,避免溢出。如果用户输入超过19个字符,后续内容不会写到缓冲区里,这就从机制上杜绝了经典的栈溢出问题。
有一点容易犯迷糊:字符型数组和有符号整数的结合。sizeof运算符返回的是size_t类型,属于无符号类型,而scanf_s的函数原型要求这个大小参数在某些格式下是无符号整数。如果你直接写sizeof(name),很多警告可能会消失,但为了更规范,也可以写成:
#define _CRT_SECURE_NO_WARNINGS #include <stdio.h> int main(void) { char name[20]; scanf_s("%s", name, (unsigned)sizeof(name)); printf("Hello, %s\n", name); return 0; }5.2 scanf_s比scanf多出来的那些参数
scanf_s并不是简单地把scanf改个名字,它对参数的数量和类型都有更严格的要求。最常见的两种格式需要特别注意。
第一种是%c,也就是读取单个字符。原来的写法是scanf("%c", &ch),改成scanf_s之后必须补充缓冲区大小参数:
char ch; scanf_s("%c", &ch, 1);这里第三个参数1表示缓冲区容量为1个字符,实际上根本没有多余空间来存放额外字符。如果省掉这个参数,编译器会直接报错,提示参数不足。
第二种是%s,读取字符串。除了要提供缓冲区的字符容量以外,还要明确这个容量不能小于实际写入的字符数。简单说,你要确保缓冲区有足够空间,否则读取会失败或者产生运行时报错。很多人刚开始用scanf_s时会在%c和%s这两个格式上翻车,建议把这两个特例记下来。
5.3 参数字面量大小应该怎么给
这里有一个需要注意的小细节:scanf_s的大小参数,和sizeof运算符的结果,在针对字符数组的时候是匹配的,但如果你用指针而不是数组,sizeof就失效了。例如:
char *p = (char *)malloc(20); scanf_s("%s", p, 20);在这种动态分配的情况下,sizeof(p)求出来的是指针本身的字节数,而不是它指向的缓冲区大小。此时必须手动传入缓冲区真实容量。我在审查代码时经常看到有人统一写成sizeof,结果动态缓冲区场景下悄悄埋了雷。用scanf_s的原则是:大小参数必须代表目标缓冲区真实可用的字符容量,而不是变量的字节大小。
6. 方案三实操:放弃scanf,用fgets和sscanf代替
6.1 用fgets处理输入
如果说scanf_s是Visual Studio里的“官方补救”,那fgets加sscanf就是跨平台和工程界更通用的正路。fgets从输入流里读取一行文本,并限制读取的最大字符数,这就确保它不会写入超出缓冲区范围的内容。基础用法是:
#include <stdio.h> int main(void) { char line[100]; if (fgets(line, sizeof(line), stdin) != NULL) { printf("你输入的是: %s\n", line); } return 0; }这里sizeof(line)传入的是缓冲区最大容量,fgets最多读取sizeof(line) - 1个字符,并留一个位置给字符串结尾的\0。当用户输入的内容超过这个长度时,fgets会截断输入并保留多余的字符留在输入流里。这一点和scanf不同,读取逻辑更可控。
6.2 用sscanf做格式化解析
有些人觉得fgets只能读取整行,那我想解析“数字 数字”这种格式怎么办?答案是用sscanf。它和scanf几乎一样,只是把输入源从“键盘”换成了“字符串”。比如:
#include <stdio.h> #include <string.h> int main(void) { int a = 0; int b = 0; char line[100]; if (fgets(line, sizeof(line), stdin) != NULL) { if (sscanf(line, "%d %d", &a, &b) == 2) { printf("a + b = %d\n", a + b); } } return 0; }这里有几个重要的差别。第一,sscanf的输入源是已经驻留在内存中的字符串,它不会直接接触用户输入缓冲区,所以不存在溢出问题。第二,fgets已经控制了缓冲区长度,字符串内容最多是缓冲区大小。第三,sscanf同样可以限制目标缓冲区。当我们用%s从line中提取单词时,同样应该带上限,比如sscanf(line, "%19s", word),这里19表示到word缓冲区的最大写入长度。
6.3 用字符串拆分函数辅助
对于更复杂的输入解析,还可以考虑strtok_s、strtol等更底层的函数。不过对于初学者和大部分日常需求,fgets加sscanf的组合已经能覆盖90%的场景。我之所以倾向于推荐这组方案,是因为它不依赖任何编译器的专有扩展,在gcc、Clang、MSVC上都能编译通过。如果你今天写了scanf_s,明天把代码挪到Linux平台上,编译依然会报错,因为scanf_s并不可移植。而fgets和sscanf是C标准库中的函数,换平台不用改代码。
7. 常见问题与避坑实录
7.1 加了宏定义为什么还有C4996
头文件顺序问题是最常见的原因。#define _CRT_SECURE_NO_WARNINGS必须出现在#include之前,因为头文件内含相关函数声明,编译器处理这些声明时就要确定是否附加警告。还有一个容易被忽略的情况:如果你使用了预编译头(通常名为pch.h或stdafx.h),那么#define和#include的顺序更加微妙,好的做法是在预编译头文件里定义这个宏,确保所有包含此头的源文件都能生效。
7.2 为什么别人的电脑上没报错
经常有同学把代码放到另外一台电脑或用在线编译器运行时,发现scanf没有任何问题。原因可能是对方使用的不是MSVC,或者对方项目没有开启SDl默认配置。不同编译器对安全函数的处理策略不一样,这属于正常现象,不代表你的VS有问题。如果哪天你把代码迁移到gcc环境,scanf_s反而会报错。理解了这一点,你就能明白为什么我在前面说,短期的屏蔽问题不大,长期维护还是应该养成可移植的好习惯。
7.3 scanf_s一定要传缓冲区大小吗
分格式看。对于%s和%c,必须提供大小参数;对于%d、%f等数值类型,不需要提供。这是因为数值类型写入的是固定大小的变量,不存在溢出问题,缓冲区类型才会受容量约束。还有个细节:%s这个大小参数必须是目标缓冲区的容量,不是输入的期望宽度。两者概念完全不同,容量是“最多能写多少”,宽度是“期望输入多少”。写错了程序行为会变得很奇怪。
7.4 明明用了scanf_s,为什么还提示错误
很可能是格式不对。比如:
scanf_s("%s", name);如果你的name是字符数组,这个写法依然会报错,因为你缺少了第三个参数。scanf_s要求%s对应两个参数:缓冲区和大小。另外一个常见错误是大小参数写成了sizeof(name),但name实际上是char*类型,这时传的大小不是缓冲区真实容量,函数在运行期可能因为容量不足而拒绝读取。检查这类问题时的思路是:逐个格式说明符确认参数数量和类型,不要只看调用的第一行参数。
7.5 汉字字符集影响scanf吗
C4996这个错误本身和字符集无关,但使用scanf_s时如果定义的是宽字符或中文字符输入,情况会更复杂。比如wscanf_s处理宽字符时也会要求缓冲区大小按字符个数而不是字节数来算。初学者如果遇到scanf处理中文输入出现乱码,通常不是C4996的锅,而是控制台代码页或字符集设置的问题,可以单独排查,不用跟C4996混淆。
7.6 练完这段代码之后怎么做
处理完编译报错只是第一步。真正值得培养的习惯是:每次调用一个输入或字符串处理函数之前,先在脑子里过一遍“它有没有限制长度”。养成这个习惯之后,哪怕不再依赖C4996,也能写出更安全的代码。我个人的建议是,刚开始就按方案二的scanf_s练习,熟悉长度参数;等代码量上来之后,逐渐过渡到fgets和sscanf的用法,这样以后做跨平台项目、写工具代码都会顺畅很多。
最后说一个自己实际使用中的体会:C4996在初学者看来是绊脚石,在工程现场其实是一个非常好用的提醒器。它逼着每个人在写代码的时候认真考虑“我的输入可能长什么样”,而不是默认用户会乖乖配合。如果你把它当成一道普通警告来处理,记住#define _CRT_SECURE_NO_WARNINGS这一招,那解决不了任何深层问题;如果你愿意顺着报错往下挖一层,理解缓冲区、长度参数、标准库演进这些东西,那这个报错反倒是你编程路上一个不错的起点。