[干货]Windows核心编程学习笔记(7)初识新的安全字符串函数

在这里插入图片描述大家好,我是米兰,一个五年级开始编程的小伙。我曾经研究过各种技术,在代码底层摸爬打滚,经历过无数次从入门到放弃的生活,感受过黑暗与毒打。如果你也有相同的经历和探究的问题,欢迎关注我,咱们共同探究,共同进步。在这里插入图片描述
今天我要分享给朋友们的笔记是《Windows核心编程学习笔记(7)初识新的安全字符串函数》。为了方便各位朋友,下面我截取了本书的相关章节。
在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述因为这个版本是第四版的,所以有差错请谅解。而我阅读的是第五版,内容方面与第四版完全相同,所以各位读者不必担心。废话不多说,送货!在这里插入图片描述
在本节内容开始之前,首先谈一下我的本节内容的心得,由于学业问题,所以这几周比较繁忙,这一节的难度也比较高,主要体现在涉及了很多领域和术语。其实作者也不应该把本章的字符和字符串处理的内容放在前面,虽然作者是为了强调突出Unicode字符集在Windows中的重要地位,并且的确本章也是围绕此中心展开的,但对于一些Windows编程和API基础比较陌生的朋友,还是有一定鸿沟的。米兰君在研读本节内容是也存在一些问题,其实这些疑问在对后面的章节展开阅读时就会茅塞顿开。所以在本节开始前,为了方便某些朋友更好的理解本节内容,下面列出几个知识点的补充,已经了解的朋友可以直接跳过,注意本节内容的笔记中的机理分析仅仅代表本人的观点,如有错误务必指出,大家共同进步。下面开始本节内容,本节也算是我探索Windows编程的一个过程的缩影。

C Runtime Library

众所周知,当今世界上存在许多不同的编译器,其中针对C/C++的就有几十种。在Windows平台下,为我们熟知的有Microsoft Visual Studio、Dev-cpp、C-Free等,其实这些编译器提供的方法接口和方法具体内部实现并不完全相同,这取决于编译器本身。换言之,不同的编译器,有自己不同的内部实现,造成这种现象是因为C标准ANSI C)在发行各版本时,其标准仅提供了函数原型,并没有提供具体的内部实现代码,而是把实现工作交给各大编译器厂商,编译器开发人员通常在实现提供的标准函数原型后,还实现了许多不同功能的额外函数,这些函数有的是跨平台的,有的是针对当前操作系统的,例如Windows API接口。简而言之,编译器实现了标准的超集,成为“C Runtime Library”,简称CRT,VC++提供的CRT库在支持C标准函数的同时,还支持一系列Windows函数。C++ Runtime LibraryMicrosoft Visual Studio中编写项目时可以选择不同的运行库类型,不同的运行库类型在生成项目输出文件时会有所不同,可以满足在不同场景中的应用。如要切换模式,可以打开项目工程属性 ->C/C++ ->代码生成 ->运行库,这是一个可以选择的下拉列表,选择对应的类型需要我们结合实际开发需求。

MT选项

选择该模式将要求编译系统采用发行版构建处理当前项目,在生成解决方案时会链接LIB版C/C++ Runtime Library,生成程序后,静态版的C/C++ Runtime Library就会被集成的程序中,成为程序二进制代码的一部分。与DLL版相比较,该模式输出的程序对外部的依赖较少,对集成和后程序的体积将会膨胀

MTD选项

后缀D(debug)代表在该模式下输出程序将使用调试版构建,并且采用链接LIB版C/C++ Runtime Library。简而言之,该选项就是MT选项的调试版本。本节将会使用该模式,并且使用该模式下支持的一系列调试函数。

MD选项

同样地,选择该模式将要求编译系统采用发行版构建处理当前项目,在生成解决方案时会设定程序使用当前环境下DLL版本C/C++ Runtime Library。在程序生成时,程序并不能独立完成所有功能,必须在运行时动态加载其功能相对应的动态链接库。选择该模式下,编译系统输出文件体积较小,但对外部环境依赖较大,若移植的目标系统相关的DLL不存在或者因为运行库版本有所差异导致名称不匹配,程序将无法实现相关功能,或者直接无法运行。

MDD选项

同样地,后缀D(debug)代表在该模式下输出程序将使用调试版构建,并且采用告知编译系统该项目程序使用动态版C/C++ Runtime Library。简而言之,该选项就是MD选项的调试版本

新安全字符函数

下面进入正文。上节提到,常用的旧字符串处理函数多包含在String.h头文件中,与之相对应的新安全字符函数搜集在StrSafe.h头文件中。实际上,在使用预处理器包含StrSafe.h头文件时,String.h头文件也会自动包含到程序源文件中,如果下文直接或者间接地调用了String.h头文件中的遗留函数,在编译阶段将会引发警告,可以通过警告信息来检查在程序中是否使用安全性较低的字符串函数。
在包含StrSafe.h头文件之后,String.h头文件中的遗留函数都有与之相对应的新安全字符函数。新旧版本的函数相比较其功能没有太多变动,但新安全字符函数的安全性检查机制比较完善,新安全字符函数的函数名继承了遗留函数的名称,以方便程序员快速由遗留函数过渡到新安全字符函数,后缀_s表示保护(secure),用于区分以及说明该函数,打算继续沿用书本的_tcscpy()、_tcscat()以及与之相对应的_tcscpy_s()、_tcscat_s()。相比于遗留函数,新安全字符函数继承了遗留函数的参数列表,并且在这个基础上新增了一个size_t类型的变量用于接收目标缓冲区最大可容纳长度,这个最大长度指的是目标缓冲区可接收的最大字符元素的个数,可以使用stdlib.h中的*
_countof宏来获取,_countof宏返回查询目标所包含的元素个数,与sizeof关键字不同的是,sizeof返回的是查询目标在内存中实际的占用空间,注意不要混合使用,虽然两者返回结果可以通过运算相通。
另外,新安全字符函数在处理传入的源数据时,始终使用const类型修饰的参数来接收。这确保了在任何情况下,都不会意外更改源数据,这个机制进一步加强了函数的安全性。新安全字符函数在接收到所需的参数后,会进行参数验证,在没有成功验证确定所有参数的有效的情况下,新安全字符函数不会执行实际操作。函数会验证当前所有的工作条件,确保其万无一失,例如参数取值是否有效、指针是否为NULL、目标缓冲区是否有足够容纳写入数据的空间等等。在执行验证过程中,实际会执行很多条件语句,为了增强代码的简洁性和排除错误的方便性,这些函数内部都会执行以下语句:

type fuction(type value0,type value1,type value2...{
assert(value0...);
assert(value1...);
...
}

断言函数assert

前文提到,在传入参数值时,函数会先进行参数验证,所谓验证就是一个判断的过程,参数的实际有效性将会影响程序的执行流。如果大量使用if条件语句块会使的代码的可读性降低,使代码量增大,增大代码维护的难度。断言函数assert的功能相当于if-else语句块。该函数会验证传入函数的条件表达式的布尔值,如果传入的表达式的布尔值为TRUE表明程序当前执行正常,一旦检测到传入的条件表达式的布尔值为FALSEassert函数就会立即将其视为程序执行发生异常,程序执行流会被暂停。并且地,assert函数将会执行预定义的语句块:即记录当前发生错误的行数,错误发生所在函数名称,就是调用assert函数的函数,以及传入assert函数的错误表达式,这些错误信息可以帮助开发者更好地排除错误。完成所有工作之后,assert函数会向标准错误流stderr插入一条错误信息,同时调用abort函数结束程序运行,防止程序继续执行以引发用户数据的损失或者更大的错误。在我们实际开发自己的函数时,也应该使用断言机制验证相关参数的有效性。assert函数的具体语法及其说明如下:
头文件:assert.h
原型:void assert( int expression );
说明:该函数将检查传入参数,若其值为FALSE,函数将向标准错误流stderr打印一条错误信息用于开发者排查和诊断相关错误,同时在内部调用abort函数终止程序运行,程序结束后释放异常信号。
注意当DEBUG宏未定义时,表明当前项目的程序使用发行版构建所以的调试函数都会因此失效,因为调试函数在工作过程中打印的错误信息最终应该向开发者呈现,方便开发者用于开展相关错误排查工作,以协助开发者修复程序中隐含的错误漏洞,这也是断言函数存在的价值和意义。下面列出assert函数的缺点以及相关使用的注意事项,因为这里的内容不属于本节的中心,所以读者只需要略微了解一下即可,你也可以选择直接跳过,进入下一个环节,这里的内容仅是帮助读者拓展自己的知识框架,在以后读者开发实际项目时提高自己程序的稳定性和健壮性。
缺点:在程序内部频繁调用该函数会增加资源的开销,影响程序性能。若不想在程序中使用该函数,可以在包含头文件assert.h之前插入预处理指令#define NDEBUG从而禁止下文所有被调用的assert函数。相关使用:
(1)每次调用assert函数进行条件验证时,尽量做到仅检查一个条件,这样可以在错误发生时每次都能准确锁定程序代码中的问题所在,以进行及时的修复。
(2)在书写assert函数括号中的表达式时,不可以使用企图更改当前环境数据的语句,否则会使得程序出现问题,例如以下语句是错误的:assert(i++<100);若这个表达式的值为FALSE,此时assert函数会立即暂停程序,并且抛出断言,此时决不能更改当前上下文的数据,因为一旦发生错误,很可能当前语句的执行会导致程序的数据被破坏,这将导致用户数据的损失。所以发生错误后,首要任务是确保数据的安全,确保我们的数据没有被相关代码朝着我们不希望的方向更改了。简而言之,后置++运算符的操作不会被执行,因为在assert函数看来这个操作存在危害用户数据安全的风险。
(3)断言函数assert仅用于检查数据的有效性,而并非准确性。十分值得注意的就是异常和断言并不是同一个概念,断言是用于捕获程序员在开发周期中程序代码存在的一系列遗留疏忽导致的漏洞,使用断言可以帮助开发者更好地诊断和排除程序的漏洞所在,所以错误信息最终也不应该向用户显示,因为用户不持有程序源代码的修改权,排除开源项目,这是极少数的情况。而异常通常是用于捕捉用户在使用程序过程中可能犯的错误以及当前环境的错误,在使用断言异常机制时要注意区分,应该结合实际情况具体选用,切忌混乱使用这两种不同的机制。
(4)若想使用断言,必须在使用当前编译系统的debug模式,因为在Release模式下,默认_DEBUG宏是不定义的,该种情况下使用断言是无效的。

C Runtime Library Assert

同样地,CRT库提供相应的断言用于开发工作,方便新安全字符函数进行传入参数的验证,CRT库提供 _ASSERT和_ASSERTE两种断言函数,这两种断言函数均在头文件crtdbg.h中定义,两者在机制上几乎一致,但_ASSERTE函数输出错误信息会更加详细,其具体表现在错误信息较_ASSERT增加了断言失败的表达式,用于开发者诊断错误发生的具体原因,但在提供便利的同时,也会使得程序中保存大量关于断言失败的表达式字符,编译后输出文件体积将会膨胀。在使用新安全字符函数时,会进行传入参数的验证,一旦检测到参数无效,就会打印错误信息,前提是_DEBUG宏定义才会有效,错误信息会通过弹出首句为Debug Assertion Failed的对话框进行显示。如果是书本所描述的发行版构建,即Release模式,受限于该模式,程序会直接退出,不带任何提示。
事情真的就那么简单吗?其实不然,米兰君在刚开始以为仅仅如此,随着深入探究,米兰君也发现了问题所在,事实上新安全字符函数并不直接应用断言函数进行参数验证,无论是_ASSERT还是_ASSERTE,仔细观察书本中的Debug Assertion Failed对话框可以发现实际结果与上述的机制并不相符合,Debug Assertion Failed对话框的错误信息中的Expression显示发生错误的表达式竟然是(L"Buffer is too small"&&0), 这是一段英文文段,意为缓冲区空间不足,可见新安全字符函数其实使用类似断言函数的机制的真正目的是为了方便开发者进行诊断工作,而不是直接使用其进行参数验证工作。因为新安全字符函数它知道抛出断言的机制,如果直接使用条件表达式在断言函数中进行判断,当检测到无效参数时,弹出的Debug Assertion Failed对话框会直接显示条件表达式,这会使得错误信息变得十分难以理解,增加开发者排查错误原因的难度。并且地,在检查到无效参数时,新安全字符函数不会直接执行抛出断言的操作,因为如果有机会返回控制时必须为开发者提供错误信息,所以新安全字符函数会首先设置局部于线程的异常变量errno,在以值为假的附带错误信息的条件表达式插入到错误信息中抛出断言,使得Debug Assertion Failed对话框中的Expression正确显示,看似简单的对话框,程序默默地在背后其实做了很多隐含的必须的工作。

错误信息

刚才讲到,一旦函数内部检测到无效参数,就会弹出Debug Assertion Failed对话框显示错误信息,同时终止程序运行,其实这是一种方便我们诊断的保护机制,但我们希望返回程序时,却发现程序以及无法恢复到原来的状态了,并且我们的数据随着程序的终止也被操作系统内存回收机制销毁了。对于这个问题,这种保护机制似乎并不友好,甚至是令人无奈的,但微软对于这个烦恼早有解决方案。事实上在程序开始执行时我们可以指定属于自己的无效参数处理器,只要注册了这个异常处理函数,当CRT库的函数检测到调用者传入无效参数时就会自动调用我们的无效参数处理函数,在自己的异常处理函数中可以检查发生异常的函数设置局部于线程的异常变量errno及其他相关数据的保存工作,将函数发生错误带来数据以及其他方面上的损失降到最低。errnoC标准库errno.h定义的整形变量。在程序开始执行时,异常变量errno的值被系统设置为初始值0,当使用C标准库的函数发生异常时,发生异常的函数会通过修改errno变量的值来指名该函数内部发生的具体错误,这些值均是非零的宏值,每个值在errno.h头文件中都有唯一与之对应的错误宏定义。例如,ERANGE表示一个范围的错误,指的是传入参数超出了函数自身规定的范围从而导致的错误。相应的,用户在编写自己的函数时也可以通过修改异常变量errno的值来指明错误或者重置其值为0,使errno变量恢复原始状态。注意在新的函数被调用时也有发生异常的可能,这可能会导致原来的异常值被新的异常值覆盖,所以在异常发生后,如有机会返回程序原来的状态,要尽快检查程序发生异常的具体内容。
此外,我们还可以去通过另一种途径诊断新安全字符函数内部发生的错误,在对比遗留函数与对应的新安全字符函数的原型可以看出,新安全字符函数返回值的类型为errno_t类型,在注册了程序的无效参数处理器之后,一旦新安全字符函数内部检验到无效参数时,会调用我们预定的无效参数处理函数,注意调用者与被调用者之间的关系。当预定的异常处理函数执行完毕之后,程序的执行流会返回上一层函数中继续往下执行语句,即发生错误的新安全字符函数,由于该函数内部发生了非预期性的错误,受到保护机制的作用,该函数必须停止执行一切相关工作,返回一个errno_t的值来指明该函数发生的错误。与局部于线程的异常变量errno不同,新安全字符函数执行成功后也会返回一个errno_t值,在排查工作开展的过程可以检查其返回值判断函数是否执行成功,只有返回S_OK值,才能确定函数执行是成功的,返回其他值则会表示函数发生了不同类型的错误。异常变量errno在函数执行成功后,其值不会被更改,注意在实际开发中进行区分。

无效参数处理器

现在我们要来实现这一个异常处理机制,注册属于自己的异常处理函数,使得新安全字符函数一旦检测到无效参数时,就调用异常处理函数开展相关错误诊断工作。要使程序可以继续保持原来的执行状态,起码可以继续执行下去,保留所需要的数据和错误信息,异常处理函数就必须具备以下几个参数:

参数类型说明
expressionconst wchar_t*检测到的错误表达式
functionconst wchar_t*发生错误的函数名
fileconst wchar_t*发生错误的源文件文件名
lineunsigned int发生错误的行号值
pReserveduintptr_t保留参数

Release模式,无效参数处理函数所有的参数都将被系统设置为NULL,因为在该模式下将执行发行版构建,不支持所有调试操作。只有DEBUG宏定义时,参数才能接受到有效值,在程序开头定义无效参数处理函数后,需要使用
_set_invalid_parameter_handle函数注册无效参数处理函数,该函数用法如下:
头文件:stdlib.h
原型:_invalid_parameter_handler _set_invalid_parameter_handler( _invalid_parameter_handler pNew);
参数:pNew
_invalid_parameter_handler类型的函数指针pNew指向无效参数处理函数,_set_invalid_parameter_handler函数会通过函数指针pNew查询到要注册的函数,并将查询到的函数设置为无效参数处理函数。返回值_set_invalid_parameter_handler函数返回一个_invalid_parameter_handler类型的函数指针,该函数指针指向前无效参数处理函数,即返回前一个被覆盖的无效参数处理函数的函数指针,使用该函数设置无效参数处理函数一次只能设置单一无效参数处理函数,所以每次调用_set_invalid_parameter_handler函数会返回上一个无效参数处理函数的首地址,新的无效参数处理函数会覆盖原来无效参数处理函数的地位。
说明:当CRT函数接受到无效参数时,通常会抛出断言并终止程序,但当设置了无效参数处理函数时,表明当前程序允许继续执行,则发生错误的CRT函数会设置局部于线程的局部异常变量errno并调用_set_invalid_parameter_handler注册的无效参数处理函数,无效参数处理函数结束运行返回发生错误的CRT函数后,该函数会返回错误代码并终止当前函数的执行。使用_set_invalid_parameter_handler函数注册当程序发生致命错误时,应总是要求异常处理函数保存所有有用的数据,不应该允许程序继续执行,除非确保程序发生的错误是可以恢复的,否则不应该返回原来的执行流,以免造成更大的损失。在定义无效参数处理函数,并调用_set_invalid_parameter_handler函数注册了无效参数处理函数后,在机理上分析看似已经完成了。但事实上顽固的Debug Assertion Failed对话框还是会继续弹出,此时我们需要调用** _CrtSetReportMode函数**屏蔽所有可能弹出的Debug Assertion Failed对话框,该函数用法如下:
头文件:crtdbg.h
原型:int _CrtSetReportMode( int reportType, int reportMode);
参数

reportType:该参数指定报告类型,使用预选的宏定义切换报告模式。在实际开发中,可以选用以下几种宏定义作为参数传入:

宏定义说明
_CRT_WARN不立即关注警告信息
_CRT_ERROR相关错误,不可恢复的问题必须立即关注
_CRT_ASSERT断言失败

reportMode:指定新报告模式或者报告模式reportType。
返回值:_CrtSetReportMode函数执行成功后返回上一个报告模式的类型,即返回上一个报告模式reportType宏定义值。若检测到无效参数,则会设置异常变量errno值到EINVAL,并且调用无效参数处理器,EINVAL是errno.h头文件中包含的一个宏定义,当异常变量errno被设置该值时,则指明程序在调用函数时检测到无效参数发生了相关错误,若无效参数处理函数允许并且控制程序执行流返回到该函数,则该函数会返回值-1来表面函数在执行时发生了错误。
注意:在宏DEBUG未定义的情况下,该函数调用将在预处理阶段删除,当程序为执行调用并且更改错误报告模式时,程序默认按照以下报告模式:
1.断言失败和错误被定向到调试消息窗口。
2.Windows 应用程序的警告将发送到调试器的输出窗口。
3.未显示来自控制台应用程序的警告。
以下演示程序来着Microsoft官方文档MSDN,该程序很好地实现了本节的核心内容。

// crt_set_invalid_parameter_handler.c
// compile with: /Zi /MTd
#include <stdio.h>
#include <stdlib.h>
#include <crtdbg.h>  
// For _CrtSetReportMode
void myInvalidParameterHandler(
const wchar_t* expression,   
const wchar_t* function,    
const wchar_t* file,    
unsigned int line,    
uintptr_t pReserved)
{   
wprintf(L"Invalid parameter detected in function %s."     
L" File: %s Line: %d\n", function,file, line);   
wprintf(L"Expression: %s\n", expression);   
abort();
}
int main( )
{   
char* formatString;   
_invalid_parameter_handler oldHandler, newHandler;   
newHandler = myInvalidParameterHandler;   oldHandler = _set_invalid_parameter_handler(newHandler);   
// Disable the message box for assertions.   
_CrtSetReportMode(_CRT_ASSERT, 0);   
// Call printf_s with invalid parameters.   
formatString = NULL;   printf(formatString);
}

回到本文的话题,错误发生后,目标字符串储存的数据其实还是会被更改,其主要表现就是目标字符串缓冲区的数据会被清空,目标字符串缓冲区的第一个字符被设置为终止符\0,其他所有的字节都包含0xfd填充符。显而易见,程序的数据内存没有被破坏,相比于遗留函数的操作机制,在不确定绝对安全的情况下,最好使用新安全字符函数。这种观点并不等同于直接否定遗留函数的字符串处理能力,并不说明遗留函数提供的功能不能安全完成所有工作,只是在存在保护机制的情况下完成工作,有有助于我们更安全保险的实现相同的效果,一旦发生错误,可以尽快排查诊断出现错误的地方以及程序存在的漏洞,缩短程序开发的周期,提高程序开发的效率。
今天的内容到这里已经接近尾声了,为了写这篇文章,米兰君花费了整整两周的时间,因为书本上讲述的内容实在存在一定的跳跃性和抽象性,所以在一开始给我的理解带来了一定的障碍。但作为Windows程序员,既然选择这个爱好或者职业,朋友们就要坚持相信自己的能力,相信在不断学习下,你的知识体系又有了新的巩固与拓展,因为条件和时间问题,下一节的《处理字符串时如何获得更多控制》这一节就不再和大家深入探究了,因为这是拓展性内容,大家就选择性学习,合理分配时间。下面我将下一节的内容放在本节的结尾,以方便朋友们学习。在这里插入图片描述在这里插入图片描述在这里插入图片描述
在这里插入图片描述今天的笔记就到这里了,更多教程和笔记请关注我,喜欢的朋友别忘了一键三连,最后再次感谢Jeffrey RichterChristophe Nasarre大师的亲笔著作,咱们下期不见不散。
本文参考文章:
_CrtSetReportMode
_set_invalid_parameter_handler
终于理解了什么是c/c++运行时库,以及libcmt msvcrt等内容

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值