第一次在Visual Studio里写C语言、第一次在编译输出里碰到C4996报错、第一次对着“This function or variable may be unsafe”这句英文发懵——这三个“第一次”我相信很多刚开始学C的同学都经历过。scanf明明是C语言教材里写得明明白白的标准库函数为什么到了VS这里就成了“不安全的函数”甚至在某些项目配置下这不是一个能忽略的警告而是会让编译直接中断的红色错误。这篇文章我就从C4996的报错现场开始讲起把它的来龙去脉、不同VS版本下的表现、常用解决方案以及背后的安全设计一次性梳理清楚。适合刚开始学C语言、被编辑器警告劝退的新手也适合手上有老项目需要迁移到新版VS的开发者。内容全部基于我平时实际调试和教学过程中遇到的情况尽量说人话不给空中楼阁的理论。1. C4996报错现场的完整拆解从“编译失败”到“运行的崩溃”1.1 一个最小复现教科书代码在VS里直接红了先看一段再常见不过的C语言入门代码#include stdio.h int main(void) { char name[32]; printf(Please input your name: ); scanf(%s, name); printf(Hello, %s!\n, name); return 0; }这段代码在任何一台安装了GCC的Linux机器上都可以正常编译运行。但在Visual Studio里如果项目开启了安全开发生命周期检查编译后会得到类似下面的输出错误 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_s代替如果想禁用这个弃用提示就定义_CRT_SECURE_NO_WARNINGS宏。很多初学者第一次看到这个信息第一反应是“我抄的代码是不是有问题”然后反复检查语法甚至重装VS结果问题还在。1.2 C4996到底是错误还是警告取决于SDL检查开关C4996的C前缀表明它属于编译器警告体系而不是运行时异常。它也不是语法错误你的代码语法完全没问题。关键在于VS有一个叫“SDL检查”的开关全称是安全开发生命周期检查。当这个开关打开时编译器会把一批与内存安全相关的警告直接升级为错误C4996就是其中之一。所以你在不同电脑、不同VS版本里遇到C4996的表现可能完全不同项目没开SDL检查编译能过但代码编辑器里有绿色波浪线输出窗口显示“警告C4996”。项目开了SDL检查编译直接失败错误列表里红字一片编译器拒绝生成可执行文件。某些旧版本VS默认开启SDL新装好的VS新建项目就报错非常劝退。这就解释了为什么同样是scanf有人抱怨VS“连scanf都不能用”有人却觉得奇怪“我编译怎么没报错”。如果只记住一个结论C4996本质是编译器发出的“潜在漏洞提醒”但它在某些配置下会强制变成编译错误。1.3 这只是“第一个雷”scanf真正的问题在运行时还有一个小点值得提前说。C4996只是在编译阶段给出提示如果你通过某种方式强行忽略掉它程序能编过、能跑但不代表没有隐患。当用户输入超长字符串时程序可能会在scanf执行那一瞬间崩溃或者更隐蔽——不崩溃而是悄悄破坏内存中的其他数据等到程序后面某个地方才突然出现诡异结果。这种“编译能过运行崩溃”的情况比直接报错更让人抓狂。2. 为什么编译器揪住scanf不放安全检查机制与_s函数家族2.1 scanf从设计上就没有“边界”概念scanf来源于C标准库而C标准库在设计年代计算机内存很小攻击手段也没有今天这么丰富所以标准库函数在设计上假定“程序员知道自己写不写得下”。拿scanf(%s, name)来说它只接收一个目标地址name并不知道name指向的缓冲区到底有多大。当用户输入超过31个字符时数据会继续往后写直接覆盖栈上相邻的内存区域。这不是VS在找茬而是所有C编译器的通病。只不过其他编译器选择“睁一只眼闭一只眼”微软选择把这个问题公开摆到你面前。缓冲区溢出是几十年来最经典的C/C内存漏洞来源scanf的%s和gets这类输入函数本质上等于打开大门让外界数据随意写入内存。2.2 MSVC的弃用标记机制__declspec(deprecated)C4996的背后的实现机制并不神秘。微软在提供标准库头文件时给这些“不安全函数”加上了弃用属性标记。编译器在解析到带弃用属性的函数调用时就会生成C4996警告。这属于静态编译期检查不会影响运行时性能纯粹是一种“编译时提醒”。这种机制和C的[[deprecated]]属性很相似。区别在于C/C标准库头文件里的弃用标记通常只在MSVC环境下生效所以同样的代码在GCC、Clang下不会有任何提示。理解了这一点你就能明白一个常见现象网上很多教程用的是Linux或Mac环境代码也没问题但拿到Windows的VS里一编译就飘红这不是代码抄错了而是工具链的安全策略不同。2.3 scanf_s到底比scanf安全在哪scanf_s是微软推出的一套“安全输入函数”中的一员。它的核心变化是但凡涉及缓冲区写入的参数调用者必须额外提供一个“缓冲区大小”参数。scanf_s(%s, name, (unsigned)sizeof(name));这个大小参数让函数知道目标缓冲区最多能容纳多少数据一旦输入超过容量函数会在越界前停止写入从而避免缓冲区溢出。对于一个16字节的数组即使你输入了100个字符scanf_s也只会按容量上限处理并且返回失败状态而不是盲目往内存里灌。微软针对很多常见危险函数都提供了对应的_s版本例如fopen_s、gets_s、strcpy_s、strcat_s、sprintf_s。这套API思路和C11标准里的Annex K边界检查接口有相似之处但并不是完全一致。它本质上是微软的扩展不是标准C库的一部分。因此如果你的代码将来需要在非Windows平台编译scanf_s这些函数不能直接用。3. 解决C4996的几种方案与取舍从关闭警告到换用安全函数3.1 最快上手项目属性里加_CRT_SECURE_NO_WARNINGS如果你只想快速让代码跑起来最直接的办法是在项目属性中定义宏。操作步骤右键点击项目名称选择“属性”。左侧选择“配置属性” - “C/C” - “预处理器”。在“预处理器定义”一栏点击下拉箭头选择“编辑”。添加_CRT_SECURE_NO_WARNINGS。确认时注意把“配置”切到“所有配置”避免只改Debug忘了Release。加了之后编译器会把C4996这类安全弃用警告关掉。这也是网上最常见的答案确实有效但我一般不会把它当首选方案原因后面会说。3.2 源码顶部直接define注意位置必须放在最前面还有一个等价方案是在.c或.cpp文件开头加一行#define _CRT_SECURE_NO_WARNINGS #include stdio.h很多人把宏写在了#include stdio.h之后结果发现没效果。原因是头文件在include时已经根据宏定义决定了是否产生弃用警告宏定义晚于头文件包含编译器根本来不及反应。所以必须放在所有#include的最前面。这个方案的缺点是每个源文件都要写一遍。如果一个项目有几十个.c文件你不可能每个都记得加上漏掉一个就继续报错。相比之下项目属性里配置全局生效省事不少。3.3 正面应对把scanf替换成scanf_s如果项目不需要跨平台只是Windows环境下的练习或内部工具那么换成scanf_s是比关警告更好的选择。它保留了安全检查编译器也会闭嘴。以开头的代码为例改造后是#include stdio.h int main(void) { char name[32]; printf(Please input your name: ); scanf_s(%s, name, (unsigned)sizeof(name)); printf(Hello, %s!\n, name); return 0; }注意看字符串输入比原来多了一个参数(unsigned)sizeof(name)这个参数用来告诉scanf_s缓冲区容量。很多人第一次写会漏掉它结果编译报“参数不足”或者“C6064”这是正常的稍后第4章我会专门讲这些坑。3.4 更推荐的长久之计fgets配合sscanf如果你问我日常更推荐哪一种我的答案是不要用scanf系列而是改用fgets读取一行再用sscanf解析格式。这样既绕开了C4996又解决了scanf本身一堆让人头疼的问题比如缓冲区残留换行符、输入数字后字符串读取异常等。#include stdio.h int main(void) { char line[128]; char name[32]; int age 0; printf(Please input name and age: ); if (fgets(line, sizeof(line), stdin) ! NULL) { if (sscanf(line, %31s %d, name, age) 2) { printf(Hello, %s, age %d!\n, name, age); } } return 0; }fgets限制最多读取多少字节天然不会越界sscanf负责把字符串转换成需要的类型。这套组合是跨平台可移植的无论在VS、GCC还是Clang下都能编译不会再有“Windows限定”的烦恼。唯一要适应的是代码行数会变多但为了安全这点代价很值得。3.5 几种方案怎么选我把常用方案放在一起对比一下方案操作成本是否保留安全检查跨平台可移植适用场景项目属性定义宏关闭警告低否是快速跑通练习代码、老项目临时过渡源码顶部define宏低否是单个源文件快速验证替换成scanf_s中是否仅Windows/MSVCWindows平台内部项目用fgets sscanf中高是是新项目、长期维护、跨平台代码还有一个“隐藏方案”是直接关闭SDL检查让C4996降级成普通警告。它和定义宏思路不同但效果类似。我建议只在学习阶段使用不要把项目长期建立在“关掉安全提示”的基础上。4. scanf_s用起来“不太顺手”的细节尺寸参数、返回值与常见误区4.1 不是所有转换说明都要传缓冲区大小scanf_s最容易让人困惑的地方是参数个数会随着格式符变化。记住下面几条规则就够了%d、%f、%lf这些数字类型不需要额外传尺寸参数因为它们写入的是固定大小的变量编译器能知道目标大小。%s、%c、%[^\n]这些可能写入缓冲区的不定长数据必须紧跟着目标变量传一个缓冲区容量参数。一次读取多个数据时缓冲区大小参数要紧跟在对应的目标变量后面而不是统一放在最后。正确示例int age 0; char name[32]; // 先读数字再读字符串 scanf_s(%d %s, age, name, (unsigned)sizeof(name));这里的顺序是一一对应的第一个格式符%d配age第二个格式符%s配name紧接着的sizeof参数属于name。如果写成下面这样就会出错// 错误示例sizeof位置不对 scanf_s(%d %s, age, (unsigned)sizeof(name), name);4.2 指针和数组的sizeof陷阱很多同学在字符串缓冲区上栽过跟头。假设你动态分配了内存char *p (char *)malloc(64); scanf_s(%s, p, sizeof(p)); // 这里有坑这里的p是一个指针sizeof(p)在64位系统上是8而不是64。编译器以为缓冲区只有8字节输入稍微超过7个字符就被截断甚至触发运行库的错误处理。正确做法是传实际可用的字节数也就是64或者写成动态计算也行。使用数组时sizeof(name)是对的一旦变成指针sizeof就不等于缓冲区大小。这是一个非常隐蔽的坑平时用scanf看不出来换成scanf_s后立刻暴露。4.3 返回值一定要检查scanf_s返回成功赋值的参数个数。比如int n 0; int ret scanf_s(%d, n);如果用户输入了数字ret是1如果用户输入了字母ret是0n保持原样。如果不检查返回值后面直接用n做运算相当于用了一个未知的初始值这种问题在真实项目中比编译警告更危险。我的习惯是每次使用scanf_s时都检查返回值至少也要判断一下是否等于期望读取的项数。很多书籍为了篇幅不写返回值处理但实际工程里这是必要步骤。4.4 读取一行字符串的正确姿势scanf系列的%s默认在空白字符处截断所以输入“Zhang San”时只会读入“Zhang”。如果想读入完整的一行初学者经常用%[^\n]也就是“读除换行符以外的所有字符”。char buf[128]; scanf_s(%[^\n], buf, (unsigned)sizeof(buf));这里有一个很常见的笔误写成%[^\n]s。这个s并不是转换说明的一部分会被当作普通字符去匹配输入结果行为变得很奇怪。更稳妥的方案还是fgets它对普通开发者更友好也不用记这些奇奇怪怪的格式串知识。4.5 关于“宏替换scanf为scanf_s”的误区有些人会想既然scanf_s更安全那我直接在代码里写上#define scanf scanf_s这样不就能把所有scanf自动替换成安全版本吗实际上这行不通。因为scanf和scanf_s的参数个数不一致宏替换只是文本替换不会帮你补上缓冲区大小参数。比如scanf(%s, name)会被替换成scanf_s(%s, name)依然缺少容量参数照样编译不过。这也侧面说明安全函数的调用方式需要程序员真正理解没法“无痛兼容”。5. VS版本差异、跨平台移植与C4996的“近亲”警告5.1 为什么有的VS版本不报错有的却报错很多读者可能会疑惑我用的VS2019新建项目写scanf怎么没报错这个问题我在第1章提过主要原因是VS2019和VS2022的新建项目模板默认不启用SDL检查所以C4996只是警告不影响编译。但如果你打开一个年代较久远的项目或者当年创建项目时勾选了安全相关选项就会看到满屏的C4996错误。想查看当前项目的SDL设置可以在“项目属性” - “配置属性” - “C/C” - “高级”里找到“安全检查”或“SDL检查”。把它关闭后C4996会从错误变回警告如果本就需要开启SDL那就把代码改成安全函数版本不要试图关闭它。我还遇到过一种情况同事从网上下载了一个老项目在VS2022里打开后错误列表里有大量的C4996但生成日志显示“编译成功”。这是因为项目把警告视为错误Treat Warning As Error的配置不同或者错误列表显示的是来自IntelliSense的静态分析结果而不是真正的编译器输出。遇到这种情况先看“输出”窗口里的实际编译信息别被错误列表吓到。5.2 C4996不止scanf一群需要一起处理的“难兄难弟”scanf只是C4996中最显眼的一个。在实际老项目迁移中你会频繁遇到下面这些函数也报C4996危险函数对应安全版本说明fopenfopen_s参数顺序完全不同需要传文件指针的地址getsgets_sgets本身连长度参数都没有比scanf %s更危险strcpystrcpy_s需要传入目标缓冲区大小strcatstrcat_s需要传入目标缓冲区大小sprintfsprintf_s缓冲区大小参数在格式串之前localtimelocaltime_s时间函数也有对应安全版本需要传入结果缓冲区以sprintf_s为例char buf[64]; sprintf_s(buf, sizeof(buf), value %d, count);第二个参数必须是目标缓冲区的字节数这个和scanf_s的思路一致。如果项目中报错函数很多逐个修改工作量不小所以很多人选择直接定义_CRT_SECURE_NO_WARNINGS。但从代码质量角度看把这些函数逐一换成安全版本才是真正的长期解法。5.3 跨平台项目的可移植写法如果项目要在Windows和Linux等多平台编译scanf_s这种微软扩展函数不能直接写进代码里。一种做法是条件编译#ifdef _MSC_VER scanf_s(%s, name, (unsigned)sizeof(name)); #else scanf(%s, name); #endif这种方式能用但代码里到处是#ifdef很不好维护。我更喜欢第二种方案统一用fgets加sscanf这套方案在所有平台表现一致不用区分环境。这也是我为什么反复推荐fgets路线的原因——它既是安全方案也是可移植方案。6. 把编译器警告当安全检查我在日常项目中的处理习惯6.1 别把_CRT_SECURE_NO_WARNINGS当成万能钥匙我在维护项目过程中见过太多人遇到C4996的第一反应就是“把警告关掉”这种做法短期能解决问题长期会埋雷。有一次某个内部工具项目为了图省事在项目属性里全局定义了_CRT_SECURE_NO_WARNINGS结果后续代码里又出现了一堆strcpy和sprintf调用。代码能编译、能运行但代码评审时一眼扫过去根本分不清哪些是主动选择的安全边界哪些是偷懒绕过了检查。等到某次用户输入超长内容导致崩溃排查起来非常痛苦。我的建议是个人练习和快速原型可以关警告因为目标是验证思路但凡代码要提交、要长期维护就不要把安全警告一棍子打死。6.2 我的具体选择按场景分三档处理第一档随手写的算法练习、竞赛代码、临时测试脚本。直接用项目属性加_CRT_SECURE_NO_WARNINGS最快跑通不浪费时间。第二档需要在Windows下长期运行的工具型程序。优先把scanf改成scanf_sfopen改成fopen_s保留安全检查同时保持代码风格统一。第三档潜在的跨平台项目、开源项目、代码库。坚决不用_s系列函数只使用fgets、sscanf、snprintf等标准函数从源头上避免平台差异。6.3 最后分享一个小技巧回到最初的C4996很多人的困惑其实是“这个报错不告诉我哪里错了”。这里给新手一个实用建议看到C4996时不要死磕语法先在报错信息里找到“Consider using xxx instead”这句大部分情况下它已经告诉你替代函数是什么了。编译器不是讨厌你而是在用这种方式提醒你注意边界安全。理解了这一点C4996就不再是拦路虎而是一个帮你避免缓冲区溢出的免费代码审查员。