上周帮一个做工业上位机的朋友看构建日志整个输出窗口被 error C4996 刷了整整两屏清一色指着 strncpy末尾还挂着那句每次看到都想叹气的提示——To disable deprecation, use _CRT_SECURE_NO_WARNINGS。他的第一反应是把警告全关了第二反应是把函数全换成带 _s 的版本结果这两种做法在后面的几天里都还了债前者把真正有价值的安全警告一起埋了后者在一个边界条件下让程序直接闪退。这类问题说大不大但真要处理干净得先搞清楚 VS 为什么盯着这几个老函数不放再决定用哪种姿势和解。这篇就把这个问题从头到尾捋一遍从报错本身的含义、三条主流解决路线的取舍到属性表批量配置、CMake 工程处理、代码跨平台兼容再到我自己踩过的几个坑都摊开来说。刚接触 C 的朋友能照着抄作业手上已经有好几个老工程要维护的朋友也能找到一条一劳永逸的工程化路子。1. 先把这行报错读明白别急着关警告1.1 一条 C4996 里其实塞了三层信息MSVC 的这条诊断信息写得其实挺厚道只是大多数人被红色吓到直接跳过去看最后一句了。完整的形态大概是这样error C4996: strncpy: This function or variable may be unsafe. Consider using strncpy_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.第一层是编号 C4996它属于编译器诊断里弃用声明这一大类意思是你用到的东西被标记为不建议继续使用了。注意它描述的是弃用不是语法错误也不是这个函数现在不能用了。这个区别很关键因为它决定了你有一堆合法的应对手段而不是只能改代码。第二层是具体对象 strncpy。微软从很早就开始把一批 C 运行时函数归入可能不安全的名单理由是这些函数在参数不当时会安静地写坏内存不给你任何提示。strncpy 的经典毛病是当源字符串长度不小于指定的拷贝长度时它不会在目标缓冲区末尾补结束符调用方如果忘了手动补后面任何一次字符串操作都可能越过缓冲区尾部。同类被点名的还有 strcpy、strcat、sprintf、gets、scanf 等等。第三层才是解决方案提示也就是那句 To disable deprecation, use _CRT_SECURE_NO_WARNINGS。它同时给了一个替代建议 strncpy_s。所以这一行报错本质上是在问你你是想关掉这条提示继续用老函数还是换成我建议的新函数两条路微软都认选择权在你。顺带说一句C4996 默认是警告级别而不是错误级别但很多团队在工程配置里开了将警告视为错误或者在较新的 VS 里因为启用了 SDL 检查而被提升为 error这才会出现编译直接失败、生成不了目标文件的情况。搞清楚当前工程到底处于哪种状态是后面选方案的前提。1.2 为什么偏偏是微软的编译器在挑刺很多人第一次遇到这个提示会觉得莫名其妙同一份代码在 GCC 和 Clang 下编译得好好的一个警告都不给怎么到了 MSVC 这里就变成拦路虎了。原因在于各家编译器的安全策略不同微软在 CRT 层面做了额外的加固设计把不安全函数的检测做进了头文件里用 #pragma deprecated 这类机制主动发出提示并且提供了一套 _s 后缀的安全版本作为替代。这套设计诞生于 2005 年前后当时内存安全问题在 Windows 平台上造成的漏洞占比很高微软的思路是在源头掐断只要你在代码里写了这些老函数编译阶段就提醒你一次让你有机会换成带长度校验的版本。对安全要求高的项目来说这个提醒是有价值的但对大量移植过来的老代码、跨平台代码、教学示例来说它就成了一种噪声。还有一个容易被忽略的点Visual Studio 项目模板在创建时默认开启了 SDL 检查在项目属性的 C/C 常规页里能看到这一项。SDL 是微软的一套安全检查开关集合开启后一部分原本只是警告的诊断会被提升成错误其中就包括与安全函数相关的 C4996。所以有时候你明明在预处理器定义里加了 _CRT_SECURE_NO_WARNINGS编译还是失败八成是 SDL 检查在起作用。这一点我在第 4 章会单独展开因为它是排查时最容易绕进去的弯。理解了这层背景你就能明白C4996 不是编译器在刁难你而是两套设计哲学撞在了一起。你的任务不是打败这个警告而是根据自己的项目情况选一条成本最低、后患最小的路。2. 解决思路选型三条路各自适合什么场景2.1 路线一用宏定义关掉弃用提示最直接的做法就是在编译单元里定义 _CRT_SECURE_NO_WARNINGS 这个宏。它的作用非常明确让 CRT 头文件里那段主动触发弃用警告的代码失效于是 C4996 就不再针对这批函数报出来。注意它的作用范围是这批不安全函数相关的弃用提示不是所有 C4996这个边界要清楚。这个宏有三个落地点各有各的适用场合。写在源文件顶部属于最小改动适合临时验证或者只有一两个文件的老代码写进项目属性的预处理器定义里属于工程级配置适合一个项目内大面积使用老函数的情况写进属性表或者更高层的构建配置里则适合一个解决方案下几十个项目都要统一的场景。为什么这个方案值得优先考虑因为它对代码零侵入。你不需要动任何一行业务逻辑不需要重新评估每个 strncpy 调用的语义风险最低、耗时最短。尤其是那种几万行、上游还在持续合并的老代码库改函数的成本高得离谱一个宏就能把编译打通把精力留给真正重要的事情。但它的代价也很明显你把安全提示一起关掉了。原本编译器会提醒你这里有潜在的内存风险现在它闭嘴了。如果你的项目里有大量用户输入、网络报文、文件路径参与字符串拼接关掉提示等于放弃了一道免费的防线。所以我一般建议团队在使用这个宏的同时配套做一轮代码审查把真正危险的调用点单独排查一遍而不是简单地关掉了事。2.2 路线二换成带 _s 后缀的安全版本微软给出的替代方案是 strncpy_s它有几个明确的改进参数里多了目标缓冲区的容量函数内部会检查容量是否够用在容量足够的情况下保证结果以结束符结尾参数非法时会调用无效参数处理程序而不是安静地写坏内存。函数原型大致是这样errno_t strncpy_s( char *strDest, size_t numberOfElements, const char *strSource, size_t count );第三个参数 numberOfElements 是目标缓冲区的总大小第四个参数 count 是要拷贝的最大字符数。这里有个非常实用的取值叫 _TRUNCATE当你把它传给 count 时函数会尽可能多地拷贝字符并在目标缓冲区末尾自动补结束符超出部分直接截断不会触发错误。日常做字符串拷贝绝大多数场景直接传 _TRUNCATE 就够了。比如原来的代码是这样char name[32]; strncpy(name, src, sizeof(name)); name[sizeof(name) - 1] \0;换成安全版本之后可以写成char name[32]; strncpy_s(name, sizeof(name), src, _TRUNCATE);明显更省心也不用再手动补那行结束符。同族的还有 strcpy_s、strcat_s、sprintf_s、memcpy_s 等等命名规律一致。选它的理由也很充分不只是消掉警告而是真的把风险点修掉了。如果你的项目是新建的或者要过安全审计那这条路线基本是唯一选择。缺点有两个一是要动代码调用点多的话工作量不小二是这套函数是微软 CRT 特有的代码一旦要拿到 Linux 上编译就会找不到符号。后面第 3 章会专门讲怎么处理这个兼容问题。2.3 路线三调整工程配置里的检查开关第三条路不走代码也不走宏而是改工程属性里的开关。主要有两个位置值得关注。一个是禁用特定警告项目属性 → C/C → 高级 → 禁用特定警告填上 4996相当于给编译器加了 /wd4996直接把这条编号的警告屏蔽掉。另一个是 SDL 检查项目属性 → C/C → 常规 → SDL 检查改成否。先说 /wd4996 和 _CRT_SECURE_NO_WARNINGS 的区别。前者按警告编号屏蔽属于比较粗的操作会把所有 C4996 都挡掉包括那些跟字符串函数无关的弃用提示后者只针对 CRT 安全函数这一块更精准。能用宏解决的时候我不太推荐用 /wd4996。再说 SDL 检查。关掉它确实能让编译通过但这个开关同时还管着一批别的安全检查比如整数溢出、缓冲区溢出的额外告警。一关了之等于把整套加固措施都拆了。除非你的项目有非常明确的理由比如引入了第三方库那些库本身不符合 SDL 要求导致大量噪音否则我不建议动这一项。真要关也应该先用属性表把它限制在特定项目上而不是在整个解决方案层面全局关闭。三种路线做个横向对比看起来更清楚方案作用范围改动量是否保留安全提示推荐场景定义 _CRT_SECURE_NO_WARNINGS当前编译单元或项目极小否老代码维护、快速打通编译改用 strncpy_s 等安全函数每个调用点中等偏大是新项目、需过安全审计禁用警告 4996 / 关闭 SDL项目或解决方案小否引入第三方库造成大量噪音我个人在实际工程里的做法通常是分层的核心业务模块、涉及外部输入的模块优先换成安全函数边缘的、只处理内部固定字符串的老模块用宏定义把提示压下去但有计划地逐步重构。一刀切看着痛快长期看往往要返工。3. 手把手实操从单文件改到整个解决方案3.1 单文件最小改动宏必须写在所有 include 之前这是最容易翻车的一步因为它太简单了简单到没人会去想它为什么失效。看下面这段代码#include string.h #define _CRT_SECURE_NO_WARNINGS int main(void) { char buf[16]; strncpy(buf, hello, sizeof(buf)); return 0; }这段代码照样会报 C4996。原因在于这个宏是在 CRT 头文件被展开的时候起作用的头文件已经处理完了你再定义它就晚了。正确写法是把定义挪到文件最顶端任何 include 之前#define _CRT_SECURE_NO_WARNINGS #include string.h int main(void) { char buf[16]; strncpy(buf, hello, sizeof(buf)); return 0; }如果项目用了预编译头VS 默认模板里是 pch.h 或者更老版本的 stdafx.h那这个宏要写在预编译头文件的第一行并且在所有其他 include 之前。因为预处理头会把当时的状态固化下来后面再用这个头文件的翻译单元都继承那个状态只有放在最前面才有效。我见过太多次宏明明写了却不生效追下去十有八九是位置放错了或者项目压根没启用预编译头、文件顶部那个定义只对当前文件有效。这里还有个细节值得提醒如果你的源文件里 include 顺序是#include pch.h再#include string.h那把宏写在 pch.h 的第一行就一定生效但如果你写成#include string.h再#include pch.h那 pch.h 里的宏就来不及了。所以项目里最好统一 include 顺序把预编译头永远放第一个。3.2 项目属性里做全局配置预处理器定义的正确填法单文件加宏适合验证真要落到工程上还是得进项目属性。步骤如下右键项目 → 属性或者按 AltEnter。左侧依次展开配置属性 → C/C → 预处理器。在右侧预处理器定义这一行里点下拉箭头 → 编辑。在列表里新增一行 _CRT_SECURE_NO_WARNINGS不要写成#define _CRT_SECURE_NO_WARNINGS只写宏名本身。确认后注意左上角的配置和平台下拉框。默认只改了 Debug|x64 这一组。第五步是很多人忽略的地方。VS 的项目属性是分配置存储的Debug 和 Release 是两套独立的配置数据Win32 和 x64 也是。你只改了 Debug|x64切到 Release 一编译C4996 又冒出来了。稳妥的做法是把配置下拉切到所有配置把平台切到所有平台再改一次属性。检查方法很简单改完之后把两个下拉框分别切到不同组合看预处理器定义那一栏里是不是都有这个宏。如果你要管理的是一个包含几十个 C 工程的解决方案一个个点属性太慢了正确做法是使用属性表。打开视图 → 其他窗口 → 属性管理器能看到每个项目下面分了 Debug|x64、Release|x64 等节点。右键其中一个节点 → 添加新项目属性表存成一个 .props 文件比如 common.props然后在里面设置好预处理器定义。之后其他项目只需要右键 → 添加现有属性表把这个文件挂上去就行。属性表的好处是集中管理哪天要调整改一个文件所有挂载的项目一起生效。已挂载的属性表会在属性管理器的节点下显示出来双击就能编辑比在项目属性窗口里翻来翻去高效得多。3.3 CMake 工程与批量处理的写法现在越来越多的 C 项目用 CMake 管理这类工程的解决方式和 VS 属性窗口完全不同改错了地方等于白改。标准的做法是在 CMakeLists.txt 里给目标加编译定义add_executable(myapp main.c util.c) if(MSVC) target_compile_definitions(myapp PRIVATE _CRT_SECURE_NO_WARNINGS) endif()用 PRIVATE 还是 PUBLIC 取决于这个宏是否需要传递给依赖方。对于这种纯粹压制本模块警告的宏用 PRIVATE 更合适避免污染下游。如果整个目录下的目标都要加也可以在 CMakeLists.txt 里统一写if(MSVC) add_compile_definitions(_CRT_SECURE_NO_WARNINGS) endif()注意 add_compile_definitions 会影响这个 CMakeLists.txt 及其子目录里之后定义的所有目标位置放得太早可能会把它带到不该带的地方所以我一般还是建议按目标逐个添加可控性更强。改完 CMakeLists.txt 之后如果没生效八成是 CMake 缓存的问题。CMake 会把上次配置的结果存在 CMakeCache.txt 里有时候改了文件但没触发重新配置构建依然用旧参数。处理方式是在构建目录下删除 CMakeCache.txt 和 CMakeFiles 目录然后重新执行配置和生成。用 VS 的打开文件夹方式加载 CMake 工程时可以右键 CMakeLists.txt → 删除缓存并重新配置效果一样。顺便提一个跨编译器都通用的写法。把宏定义和编译器判断结合起来能让同一份 CMakeLists 在 Windows 和 Linux 下都正常工作if(MSVC) target_compile_definitions(myapp PRIVATE _CRT_SECURE_NO_WARNINGS) endif()GCC 和 Clang 那边不认识这个宏但因为被 if(MSVC) 包住了也不会造成任何影响。3.4 代码要上 Linux 的话_s 系列不能硬写很多工程存在Windows 上开发、Linux 上部署的情况这时候路线二就要小心了。strncpy_s、strcpy_s、sprintf_s 这些都是微软 CRT 提供的东西glibc 里没有对应的实现直接拿到 Linux 上编译会提示找不到符号。有三种处理方式我按推荐程度排序。第一种是封装一层自己的安全拷贝函数内部用条件编译区分平台#include string.h static void copy_str(char *dst, size_t dstsz, const char *src) { #ifdef _MSC_VER strncpy_s(dst, dstsz, src, _TRUNCATE); #else if (dstsz 0) { return; } size_t n strlen(src); if (n dstsz) { n dstsz - 1; } memcpy(dst, src, n); dst[n] \0; #endif }这样业务代码里统一调 copy_str两边的行为都保证以结束符结尾也保证不会越界。缺点是每个项目都要维护这么一份小工具最好做成公共头文件放进基础库。第二种是干脆不用 _s 系列统一改用标准 C 里跨平台都有的 snprintf。字符串拷贝场景其实用 snprintf 就能覆盖大部分需求char name[32]; snprintf(name, sizeof(name), %s, src);snprintf 保证不会写超过缓冲区大小并且总是以结束符结尾只要大小大于 0GCC、Clang、MSVC 全都支持。唯一的小坑是部分老版本 MSVC 对 snprintf 的实现和 C99 标准有差异但 VS2015 以后已经没有这个问题了。第三种是用第三方库比如某些基础库会提供跨平台的安全字符串封装。如果项目已经引入了这类库直接用现成的就行没必要再造轮子。我个人的倾向是新代码优先 snprintf需要严格控制截断行为的地方用自己封装的 copy_str只有确定永远不出 Windows 的模块才直接用 _s 系列。4. 踩过的坑与问题排查实录4.1 宏明明定义了却不生效的几种典型原因这一类问题我遇到过太多次归纳下来无非是几个位置问题按出现频率从高到低排一下。最常见的是宏写在了 include 之后。前面 3.1 已经说过了这里再强调一遍判断方法如果只有某一个 .c 或 .cpp 文件报错其他文件正常那基本就是这个文件里宏的位置不对。第二常见的是配置和平台没选全。只改了 Debug|x64Release 编译的时候没生效。判断方法是在项目属性的预处理器定义栏里把配置和平台下拉切到各个组合看看是否都有这个宏。第三种是用了预编译头但定义在 .cpp 里。预编译头会把它展开时的宏状态固化后续所有包含它的翻译单元都继承那个状态。如果你把宏写在 .cpp 里而 .h 是靠预编译头进来的那宏就没机会在头文件展开前生效。解决办法就是把宏提到预编译头文件的第一行。第四种是项目引用了静态库或动态库你改了主工程但没改库工程。库在编译时也会触发 C4996而且库的警告不会因为主工程的设置而消失。这时候要用属性表把配置铺开到所有工程或者干脆在库工程里单独加。第五种比较隐蔽工程里存在多个同名宏的冲突定义。比如某个第三方头文件里写了#undef _CRT_SECURE_NO_WARNINGS或者某个 .props 里把它定义成了 0。宏一旦被定义成 0在预处理阶段#ifdef判断依然成立但 CRT 头文件里通常会检查它的值这种情况下就不生效了。排查手段是在预处理器定义里临时加一个测试宏配合#ifdef打印一段信息出来确认它到底有没有被正确带上。4.2 strncpy 本身的坑不补结束符这件事就算你不打算换函数也值得花两分钟看看 strncpy 到底危险在哪因为它和 C4996 的因果关系是绑在一起的。看这段代码char buf[8]; strncpy(buf, hello world, sizeof(buf)); printf(%s\n, buf);strncpy 的规则是拷贝最多 n 个字符如果源字符串长度小于 n则在剩余位置补零如果源字符串长度大于等于 n则拷满 n 个字符并且不补结束符。上面这段代码里源字符串长度 11 大于 n8所以 buf 里 8 个字节全是字符没有结束符。后面 printf 会一直往后读直到在内存某处碰到一个零字节才停读到的内容是什么完全不可控运气不好就是崩溃运气好则是打印出一串乱码。这种问题在测试环境里往往看不出来因为栈上刚好有零字节跑得好好的一到生产环境函数调用层次变了、栈布局变了就突然炸了。这也是为什么这类函数被列入可能不安全名单的核心原因它的失败是安静的、延迟的、和环境相关的。如果暂时不换函数最低限度的自保手段是手动补结束符char buf[8]; strncpy(buf, hello world, sizeof(buf) - 1); buf[sizeof(buf) - 1] \0;注意这里有两个改动长度参数减了 1给结束符预留位置拷贝完之后手动写一个零字节。两件事缺一不可只做前者的话当源字符串刚好等于 sizeof(buf)-1 时结尾依然没有结束符。4.3 strncpy_s 用错反而更容易崩换到安全函数并不意味着万事大吉用错了它比原来的函数更容易出问题因为它的错误处理方式是终止程序。看这个写法char buf[8]; strncpy_s(buf, sizeof(buf), hello world, sizeof(buf));这里第四个参数传的是目标缓冲区大小 8而源字符串长度 11超出了 8-1 的可容纳范围。这种参数组合下strncpy_s 会认为发生了缓冲区不足的严重错误调用无效参数处理程序。默认行为在 Debug 下会弹出断言对话框Release 下会直接终止进程。也就是说一个原本只是字符串被截断的小问题被放大成了程序崩溃。正确写法是传 _TRUNCATEchar buf[8]; strncpy_s(buf, sizeof(buf), hello world, _TRUNCATE);这样函数会拷贝 7 个字符加一个结束符静默截断不报错。我个人的经验是除非业务上明确规定源字符串超长必须报错否则一律用 _TRUNCATE。真要精确控制也得先判断源长度再决定行为而不是把目标缓冲区大小当成拷贝长度传进去。这个参数混淆是我在 code review 里见得最多的错误之一。另外注意 strncpy_s 的参数顺序是目标、目标容量、源、拷贝长度别和某些第三方库的封装搞混。函数返回类型是 errno_t虽然大部分情况下可以忽略返回值但如果你的项目对可靠性要求高检查一下返回值是个好习惯返回 0 表示成功返回其他值表示出现了截断或者参数问题。4.4 常见问题速查表把实际排查中遇到的情况整理成一张表遇到问题可以直接对照现象最可能的原因处理方式加了宏还是报 C4996宏写在 include 之后移到文件或预编译头最顶端只有 Release 报错只改了 Debug 配置配置切所有配置重设一次主工程好了库工程还报库工程没同步配置用属性表挂到所有工程加了宏依然编译失败SDL 检查把警告提升为错误检查 SDL 检查开关或改用安全函数换成 strncpy_s 后程序退出第四参数误传目标缓冲区大小改用 _TRUNCATE字符串打印出乱码strncpy 未补结束符手动补 \0 或换安全函数CMake 改了不生效构建缓存未刷新删除 CMakeCache.txt 重新配置Linux 上找不到 strncpy_s平台相关函数用条件编译封装或改用 snprintf提示排查顺序建议从配置是否选全开始再到宏位置是否正确最后才怀疑 SDL 检查之类的深层开关。按这个顺序走基本十分钟内能定位。4.5 一个被低估的进阶做法C 下自动重载如果你的工程是 C还有一个不太为人知的开关值得了解一下_CRT_SECURE_CPP_OVERLOAD_STANDARD_NAMES。把它定义为 1并保证在包含 CRT 头文件之前生效那么在 C 代码里调用 strncpy 时编译器会自动把它替换成对应的安全版本重载。也就是说你不需要挨个改调用点老代码照样能获得安全版的实现。这个机制的原理是在 string.h 里提供了一组模板重载当参数个数和类型匹配时会优先选中它们从而转发到 _s 版本。它对代码是零侵入的但门槛在于宏的定义位置要求很严格必须在所有 CRT 头文件之前通常只能放在预编译头或者编译选项里。另外它只对 C 有效纯 C 工程享受不到。所以这个做法适合那种代码量巨大、改不动、又是 C 项目、想顺手提升一点安全性的场景。用了之后记得做一轮回归测试因为字符串处理的行为细节可能会发生变化尤其是截断场景下的结果长度。5. 我在实际项目里的取舍做了这么些年维护和重构的工作我对这类问题的态度一直在变。最开始是能关就关一个宏解决一切后来被线上问题教育过几次之后变得谨慎起来倾向于全部替换成安全函数再往后又发现无差别替换的成本实在太高尤其是在大型遗留代码里很多 strncpy 的调用点其实是安全的源字符串长度可控替换的意义有限改动反而引入了新的风险。现在的做法是有选择地处理。判断标准主要看三个维度数据来源、执行频率、出错后果。数据来源如果是外部输入比如配置文件、网络报文、用户输入那必须换而且不只是换个函数名那么简单还要检查长度校验逻辑是否完整。数据来源如果是编译期常量或者内部固定字符串源长度明确可控那用宏压掉警告是可以接受的但我会在代码旁边留个注释说明为什么可以这么做方便后来人复查。执行频率高的路径比如每帧都在跑的渲染循环里的字符串拼接安全性收益更高优先处理。出错后果这一维度最直观如果这段代码崩了只会让日志难看一点和如果会导致界面卡死、数据丢失处理优先级完全不同。另外有个习惯值得分享无论用哪种方案我都会在项目根目录放一份 README 或者注释说明写清楚这个项目为什么用了 _CRT_SECURE_NO_WARNINGS哪些模块已经替换成安全函数哪些还没动。这事儿看着很小但当下一个人接手、或者半年后的自己回头看时能省下大量考古时间。我接手过的一个项目就是因为没留这份说明团队里三个人分别用三种方式处理同一个警告最后配置互相打架排查了整整一天才弄明白。工具层面还有一个我觉得挺顺手的小技巧。VS 的输出窗口里如果只显示错误看不到上下文可以把显示输出来源切到生成并开启详细级别或者在错误列表里按代码筛选 C4996一次性看到所有触发点方便评估工作量。对于调用点特别多的项目可以先用这个方法统计一下总数再决定是走宏定义路线还是逐点替换路线别凭感觉拍脑袋。数字摆出来之后取舍就简单多了。