65.9K
CodeProject 正在变化。 阅读更多。
Home

剖析 Visual C++ 中的代码分析

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (28投票s)

2010年11月12日

CPOL

39分钟阅读

viewsIcon

80922

静态代码分析:目的、如何分析以及让你的代码更易于分析!

引言

每个专业的 C++ 程序员都知道 C++ 程序的复杂性,知道代码中隐藏的深层 bug 以及查找和解决这些 bug 的困难方法。即使以最高的警告级别编译代码,一些 bug 仍然会顽固地存在于代码中。因此,需要一个能够以启发式方式分析代码的工具,即通过智能地查看周围代码来查找潜在 bug,这变得不可或缺。

虽然有无数免费和付费的代码分析工具可用于不同平台和编译器,但我将阐述 Visual Studio 2005/2008/2010 中提供的代码分析功能。不幸的是,此功能仅在 Team System 和 Visual Studio 的最终版本中可用。

本文大致分为以下三个逻辑类别。

稍后我们将提供示例代码和一些截图,因此读者必须仔细理解代码。已使用以下 Visual Studio 版本,它们之间存在一些不一致之处,我将在讨论中一一列出。

  • Visual Studio 2005 SP1 - Team Suite,带 Vista 更新。
  • Visual Studio 2008 - Team System,RTM
  • Visual Studio 2010 Ultimate

代码分析的需求

让我们看一段带有轻微 bug 的代码,这段代码很容易被人类、标准编译器(以下简称编译器)和代码分析工具检测到。

using namespace std; // IMPLICIT from next code

int main()
{
   int code, monkey;
   wcin >> monkey;

   if(monkey>10)
        code++;

   wcout << code;
}

是的,变量 'code' 可能未初始化。编译器会为此发出 C4700 警告。这很容易纠正。

在我给出一些编译器无法检测到的好例子之前,让我先启用代码分析来编译相同的代码。为此,您需要在命令行的编译器中提供 /analyze 选项。但等等,有更简单的方法可以做到这一点。在您的项目属性中,找到名为 Code Analysis 的节点,定位到 General,然后启用 Enable Code Analysis for C/C+ +。这样做会将 /analyze 开关添加到编译器选项中。

现在,当您编译相同的代码时,它将显示警告 C6001 以及标准的 C4700 警告。

warning C6001: Using uninitialized memory 'code': Lines: 11, 13, 15, 18

所有代码分析警告都在 C6001 和 C6999 之间。如果您注意到,CA 警告还显示了一组行号,显示了可能的 bug 区域。由于这个很简单,您可能不太欣赏它,但对于其他 CA 警告来说,它变得非常有用了。

有趣的是,当您双击警告时,IDE 会像这样显示您的代码内容。

Code1.JPG

请注意,相关的代码是灰显的。这些与警告中显示的行号相同。

让我们再举一个例子,您将开始欣赏 CA。看这段代码。

int a=10, b=33; 
int sum;
sum = a+b;
 
printf("Addition of %d, %d is %d", a,b); 

编译器将不会显示任何警告,但当您启用 CA 时,它会发出以下警告。

warning C6064: Missing integer argument to 'printf' that corresponds 
        to conversion specifier '3'

这两个代码示例只是为了介绍。代码分析可以检测更多潜在的编码错误。这些包括缓冲区溢出、不满足函数的输入/输出要求、指针算术错误、运算符使用不当以及其他潜在 bug。因此,我将它们分为以下几类:

代码分析警告的最后一类是与代码分析结构本身有关,我将在本文稍后探讨。

执行代码分析

本节将列出 Visual C++ 的代码分析工具可以检测到的所有可能的 bug。分组顺序(如下所示)不受错误发生概率高低或低空的限制。我按自己的方式排序,以方便读者。

运算符和表达式错误

这类错误可能很容易发现,也可能非常难以定位。而且,这些错误的 the impact 可能根本不会影响软件系统,或者可能导致整个软件系统崩溃,可能导致随机/频繁的崩溃。更重要的是,这些错误会产生与程序员意图不同的行为——从而在系统中产生难以查找的逻辑 bug!因此,在我看来,这类编码错误应尽快解决。

让我们从第一个例子开始。

#define  DIRECTORY 1
#define  READONLY 2
#define  SHARED  4
void FileOperation(int nFileFlag) 
{
   if (nFileFlag || SHARED)
   {
     printf("Shared file");
   }
}

这显然是一个潜在的 bug,因为上面的代码没有正确检查文件掩码。文件标志必须与按位 AND 运算符进行比较。这段代码以及其他标准警告会产生以下 CA 警告。

warning C6236: (<expression> || <non-zero constant>) is always a non-zero constant

因为它也伴随着警告。

warning C4127: conditional expression is constant

您可以快速找到并纠正它——甚至在开始 CA 编译之前!因此,您将其更改为。

 if (nFileFlag & SHARED )

而且,如果您错误地键入了。

if (nFileFlag & SHARED != 0)

这是一个潜在的 bug,因为它将 SHARED 与零进行比较,这将是真的(1)。因此,nFileFlag 将始终按位 AND 与 1 进行比较。这段有 bug 的代码会产生以下警告。

warning C4554: '&' : check operator precedence for possible error; 
        use parentheses to clarify precedence


warning C6281: Incorrect order of operations: 
        relational operators have higher precedence than bitwise operators

warning C6326: Potential comparison of a constant with another constant

纠正只需要在正确的位置加上括号。

if ( (nFileFlag & SHARED) != 0)

您可以看到有一系列警告与与常量的比较有关。表达式中的常量可能在左边、右边或两边。以下是与常量相关的示例和不同警告。

void Check(int nValue)
{
    if (nValue=55) // Should be nValue == 55
    {
        printf("It is 55!");
    }
}

这会产生 CA 警告。

warning C6282: Incorrect operator: assignment of constant in Boolean context. 
        Consider using '==' instead

以下代码会产生注释中所示的警告。

if ( 10 && nValue>20 ) {}
//C6239: (<non-zero constant> && <expression>) always evaluates to the result of 
//       <expression>. Did you intend to use the bitwise-and operator?


if ( 10 || nValue>20 ) {}
// C6235: (<non-zero constant> || <expression>) is always a non-zero constant

还有其他类似的警告。我发现了一些有趣的警告,如下所示。

if (nValue == 10 && nValue == 20)
// C6288: Incorrect operator: mutual inclusion over && is always zero. 
//        Did you intend to use || instead?

if (nValue != 10 ||  nValue != 20)
// C6289: Incorrect operator: mutual exclusion over || is always a non-zero constant. 
//        Did you intend to use && instead?

对于以上类型的警告,您需要仔细理解需求,并适当地更改条件表达式。

一些警告也可能由运算符的优先级引起,程序员必须使用括号仔细指定意图。例如。

指针错误

指针!这是该语言中最危险 yet 最强大的结构之一。即使小心编码,在使用 C/C++ 中的指针时,也可能出现一些 bug。让我们从一个非常简单的可能导致崩溃的代码开始。

int *pDangling = NULL;
int n;


cin>>n;

if(n>0)
  pDangling = new int[n];

*pDangling = n;

我不会侮辱您的智商来解释代码的作用以及为什么它可能崩溃。编译器不会为您提供此代码的任何警告,因为变量 pDangling 在使用之前被初始化了。CA 会报告警告 C6011。

warning C6011: Dereferencing NULL pointer 'pDangling': Lines: 148, 149, 151, 153, 156

当您双击此警告时,它会高亮显示导致此警告的代码路径。由于这是非常简单的代码,理解和修复都很容易。要修复此类型警告,您需要检查指针是否为 null,或者返回(或更改代码执行路径)。更改代码路径可能并不总是能避免警告,具体取决于编译、代码分析和执行的代码优化。

当您使用内存分配函数时,也可能出现相同的警告。例如,如果您使用 malloc 分配内存。

// if(n>0)  // ALWAYS
pDangling = (int*)malloc(10);

由于 malloc 在无法分配内存时可能返回 null,您可能会使用一个坏的指针。根据 C++ 标准,operator new 不会返回 null,但在无法分配内存时会抛出异常。此外,其他内存分配例程,如 HeapAlloc,不会产生此警告。malloc 和相关函数被原型化,以便您可以看到这些警告。稍后,当我阐述“使你的代码易于分析”时,我们会看看如何做到这一点。

这个类别的另一个 bug 是使用指针指向内容的 ++-- 运算符。例如。

void test(const char* pBuffer, char x)
{ 
 if(isalnum(x))
  *pBuffer++;

 // Rest of code
}

这里需要澄清的是,缓冲区移动(即指针递增)是否是必需的,还是递增了所指向的内容。上面的代码会产生以下警告。
warning C6269: Possibly incorrect order of operations: dereference ignored
编译器忽略了解引用(*pBuffer)。如果这是意图,代码应更改为。
  pBuffer++;
如果 ++ 运算符要应用于所指向的内容,代码应该是。
  (*pBuffer)++;
使用 new 进行内存分配并使用 delete 释放时,可能会出现以下 bug。
char*pBuffer= new char[10];
delete pBuffer;

float *pFloat = new float;
delete []pFloat;
CA 编译器会报告如下。
warning C6279: 'pFloat' is allocated with scalar new, but deleted with array delete []
warning C6283: 'pBuffer' is allocated with array new [], but deleted with scalar delete 

我显然不需要告诉你该做什么!

如果为类/结构分配了数组,并使用标量 delete 删除,警告会稍有不同。

class T {};


T* pT = new T[10];
delete pT;

'pT' is allocated with array new [], but deleted with scalar delete. 
    Destructors will not be called: Lines: 159, 160

类似地,以下代码会产生警告 C6280,因为分配和 deallocation 例程不同。重要的是要注意,new/deletemalloc/calloc 等 Windows 堆函数都使用不同的堆,并且要求其他堆管理器 deallocate 内存肯定会做一些致命的事情。

void* pMemory1 = new char[100];
free(pMemory1);

void* pMemory2 = new char[100];
LocalFree(pMemory2);

void* pMemory3 = malloc(100);
delete pMemory3;


void* pMemory4 = malloc(100);
LocalFree(pMemory4);

void* pMemory5 = LocalAlloc(0, 100);
free(pMemory5);
报告的警告是。
warning C6280: 'pMemory1' is allocated with 'new []', but deleted with 'free'
warning C6280: 'pMemory2' is allocated with 'new []', but deleted with 'LocalFree'
warning C6280: 'pMemory3' is allocated with 'malloc', but deleted with 'delete'
warning C6280: 'pMemory4' is allocated with 'malloc', but deleted with 'LocalFree'
warning C6280: 'pMemory5' is allocated with 'LocalAlloc', but deleted with 'free'
最后,我列出了一些与指针算术相关的潜在 bug 区域。让我们看一个结构。
struct Packet
{
   short Age;
   char Name[32];
};
此对象通过网络传输,打包在字节序列(char*BYTE*)中——前四个字节是数据包的长度,然后是数据包(Packet)。您尝试将其类型转换为该数据包,并加上四个字节。代码将有助于理解。
void process(const char* pNetworkPacket)
{
    // Get the length
    int nLen;

    nLen = *(int*)pNetworkPacket;
   
    // Get the packet, excluding 4 bytes
    Packet* packet;
    packet = (Packet*)pNetworkPacket + sizeof(int);
}

在这里,从 pNetworkPacket(它只是一个字节缓冲区)开始,尝试通过从基址偏移四个字节来读取实际数据包。由于 Packet 的大小是 34 字节,最后一行中的表达式实际上是在请求一个可能错误的 Packet 数组中的第五个 Packet!因此,该表达式将尝试读取第 137 个字节,而不是第 5 个字节!

使用 CA 编译上面的代码时,编译器会产生警告 C6305。

Potential mismatch between sizeof and countof quantities: Lines: 76, 77, 80, 81

简单而正确的解决方案是将表达式改为如下。

packet = (Packet*)(pNetworkPacket + sizeof(int)); // pNetworkPacket is treated as char*

类似地,在反向模式下,网络数据包本身会出现在某个结构中。可能会传输多个数据包,因此您可能需要进行类型转换,如下所示。

struct NetPacket
{
   int Length;
   char Buffer[100];
};

 
void func4(const void* packet)
{
 int* pnLen;
 
 // Attempt to get length from fifth packet
 pnLen = (int*)(NetPacket*)packet+4;
}

您实际上试图从第五个数据包中获取长度,但错误地读取了第五个字节!最后一行将导致以下警告。

warning C6268: Incorrect order of operations: (<TYPE1>)(<TYPE2>)x + y. Parentheses in 
        (<TYPE1>)((<TYPE2>)x + y) might be missing

这可以通过在适当的位置添加括号来纠正。

// Attempt to length from fifth packet

nLen = (int*) ( (NetPacket*)packet+4 );

格式字符串错误

用于生成字符串,存在一组类,具有重载运算符和相关技术;但是,printf 函数如 sprintfCString::Format 等因其简洁性和函数之间的一致性而被广泛使用。使用 sprintf_s 等安全版本,您可以避免大多数潜在的 bug。我不会在这里争论字符串格式化和其他模板化字符串生成;而是列出它存在的问题。本小节只关注格式字符串,但相同的功能集可能存在以下问题。

  • 应使用以 _s 结尾的安全版本来避免缓冲区溢出。除了始终可用的运行时检查及其在 Debug 版本中的 ASSERT 行为外,CA 工具还可以列出缓冲区大小与指定缓冲区之间的不匹配。缓冲区溢出问题将在下一小节中指出。
  • 参数说明符指定不正确、缺少或多余的参数可能产生负面影响并导致程序崩溃。代码分析可以指出它们。本小节对此进行说明。
  • 由于格式字符串结构是一致的,因此它适用于所有接受格式字符串的函数。因此,您也可以为接受格式字符串和可变数量参数的函数创建原型,以便被代码分析工具分析。我们将在下一节中探讨。

让我们从一个例子开始。

int a=10, b=20;
 printf("Sum of %d and %d is: %d", a,b);

在这里,您要求 printf 使用 %d 说明符打印三个数值。但是您缺少传递第三个参数。即使以最高的警告级别编译,它也能正常工作,但在运行时可能会崩溃!CA 编译可以帮助您检测此 bug。

C6064: Missing integer argument to 'printf' that corresponds to conversion specifier '3'

您可以直接消除此 bug。同样,以下代码也会产生 CA 警告 C6064。

char sBuffer[100];
 sprintf(sBuffer, "A is %d, B is %d", a);

应该理解的是,程序可能不会崩溃,但会给出不可预测的结果。并且printX 函数显示/产生的结果取决于调用堆栈上的内容。它可能是附近的局部变量,或者是相同或不同类型的变量,调用堆栈上的函数地址,或其他任何内容。编译器优化也会使情况变得更糟。因此,最好在进行 Release Build 之前启用 CA,消除所有警告。

让我们再看几个例子。

char name[]="CodeProject";

printf("First letter is %c",name);

这要求打印名称数组的第一个字符,但未能正确传递它。CA 会发出警告。

warning C6274: Non-character passed as parameter '2' when character is 
        required in call to 'printf'

这可能不会引起任何严重影响或程序崩溃。但以下可能会!

 int nLen = strlen(name);
 printf("%s (%d)", nLen, name);

这段错字的代码会产生以下 CA 警告。

warning C6067: Parameter '2' in call to 'printf' must be the address 
        of the string

warning C6273: Non-integer passed as parameter '3' when integer is required in call 
        to 'printf': if a pointer value is being passed, %p should be used

当您使用 CString::Format 函数时,该函数是为代码分析设计的,只有 VS2008 (Team System) 忽略了所有可能出现的 printX 函数的警告。我无法确定为什么它在 VS2005 和 VS2008 中不发出警告。代码示例。

 CString s;
 s.Format("%d, %d", 100);

这会产生(仅在 VS2008 中)。

warning C6064: Missing integer argument to 'ATL::CStringT<..>::Format' 
        that corresponds to conversion specifier '2'

缓冲区溢出 bug

对于 C/C++ 程序和程序员来说,缓冲区溢出是最灾难性的 bug 之一。它需要大量的时间、精力以及对所用数据结构的深入理解才能定位和修复 bug。但要真正找到潜在的致命 bug,在其出现之前,需要更多的时间。并且出现 bug 的可能表现为程序的随机/常规崩溃、数据存储和检索不正确、级联的逻辑 bug,以及允许外部程序通过这些漏洞轻松攻击您的系统。

不幸的是,使用免费工具定位和修复缓冲区溢出对于中型或大型程序来说并不高效。付费工具价格昂贵。尽管如此,它们还需要一些时间来适应这些工具(免费或不免费)。Visual C++ 的代码分析功能并不是查找缓冲区溢出 bug 的非常有效的工具,但它可以极大地帮助查找 bug。

正如我在上一节中提到的,应使用以 _s 结尾的安全版本函数来避免缓冲区溢出。安全版本通过 Debug 版本中臭名昭著的“Debug Assertion Failed”对话框帮助您在运行时找到潜在的 bug,如果您为缓冲区/字符串传递的元素/字节数比实际的少。

首先,我将用正常代码和非安全函数来举例说明缓冲区溢出问题。

一个简单的 bug。

 int Array[4];
 
 Array[4]=10;

这会产生以下 CA 警告。

warning C6201: Index '4' is out of valid index range '0' to '3' for possibly stack 
        allocated buffer 'Array'
warning C6386: Buffer overrun: accessing 'Array', the writable size is '16' bytes, 
        but '20' bytes might be written: Lines: 234, 236

而且最常见的是,它可能与 for 循环一起出现。

for(int nIndex = 0; nIndex <= 4; ++nIndex)
   Array[nIndex] = nIndex * 10;

并且它只需要最小的更改,您知道!只要索引本身是编译时常量,或者可以从常量推导出来,CA 就会帮助您。因此,如果您将上面的代码更改为。

// #define ARRSIZE 4
const int ARRSIZE = 4;

int Array[ARRSIZE]; // This isn't important, it could be 4
 
for(int nIndex = 0; nIndex <= ARRSIZE; ++nIndex)   
  Array[nIndex] = nIndex * 10;

 Array[ARRSIZE] = 10;

上面的代码会产生相同的警告集。不幸的是,以下代码不会。

int nIndex = 4;
Array[nIndex] = 100;

尽管编译器知道第二行中 nIndex 的值,并且编译器可能会对其进行优化并用常量 4 替换变量,从而消除变量本身。CA 不会告诉您这个 bug!

如果数组声明为 static 或全局,则会产生警告 C6200,它类似于 C6201。

// int Array[4];
void BufferOverrun()
{
 static int Array[4];
 Array[4] = 100;
}

warning C6200: Index '4' is out of valid index range '0' to '3' for non-stack buffer 
       'int * Array' 

当您动态分配数组时,CA 也会提供帮助。代码示例。

char* pBuffer = new char[15];
 pBuffer[20]='#';

 int* pArray = new int[10];
 pArray[12]=144;

这显然是一个缓冲区溢出 bug,会产生以下警告集。

warning C6386: Buffer overrun: accessing 'pBuffer', the writable size is '15*1' bytes, 
            but '21' bytes might be written: Lines: 55, 56

warning C6211: Leaking memory 'pBuffer' due to an exception. 
            Consider using a local catch block to clean up memory: Lines:


warning C6386: Buffer overrun: accessing 'pArray', the writable size is '10*4' bytes, 
            but '52' bytes might be written: Lines:58, 59

您可以看到智能 CA 显示了有价值的警告消息。我将简要介绍第二个警告(C6211)。

类似地,如果您使用了 malloc 或其他 CRT 内存分配函数。

int* pArray = (int*)malloc(16); // 16 elements?
 pArray[4] = 16;
 pArray[16] = 256;

将发出以下有用的警告集(我需要告诉您原因吗?)。

warning C6200: Index '4' is out of valid index range '0' to '3' for non-stack buffer 'pArray'
warning C6200: Index '16' is out of valid index range '0' to '3' for non-stack buffer 'pArray'
warning C6386: Buffer overrun: accessing 'pArray', the writable size is '16' bytes, 
        but '20' bytes might be written: Lines: 58, 59
warning C6011: Dereferencing NULL pointer 'pArray': Lines: 58, 59

目前,前三个警告很重要。正如您所见,CA 工具即使对于函数也能报告正确的字节/元素计数。即使您使用其他内存分配函数,如 HeapAllocLocalAlloc

int* pArray = (int*)HeapAlloc(GetProcessHeap(), 0, 16);
pArray[4] = 16;
// warning C6386: Buffer overrun: accessing 'pArray', the writable size is '16' bytes,
// but '20' bytes might be written

int* pArray = (int*)LocalAlloc(LMEM_MOVEABLE, 10);
 pArray[4] = 16; // C6200

警告取决于这些不同函数为 CA 的原型方式,我将在下一节中解释。但思路是一样的:告诉您潜在的缓冲区溢出 bug。

除了直接使用缓冲区外,代码分析还可以帮助您在将这些缓冲区传递给函数时查找 bug。例如。

BYTE Buffer[100];

 memset(Buffer,0, 120);

这会产生以下 CA 结果。

warning C6202: Buffer overrun for 'Buffer', which is possibly stack allocated, 
        in call to 'memset': length '120' exceeds buffer size '100'

即使数组在堆上分配,代码分析也会产生警告。例如,在下面的代码中,程序员错误地将错误的参数传递给了 memset,这被 CA 检测到了。

int* pBuffer = new int[100];
 memset(pBuffer, 100, 1000);


// warning C6386: Buffer overrun: accessing 'argument 1', the writable size is '100*4' 
//    bytes, but '1000' bytes might be written: Lines: 246, 247

让我们进行一些内存复制。

char src[8], dest[10];
memcpy(dest, src, 20); 

这段代码会引发以下警告。

warning C6202: Buffer overrun for 'dest', which is possibly stack allocated, 
       in call to 'memcpy': length '20' exceeds buffer size '10'
warning C6202: Buffer overrun for 'src', which is possibly stack allocated, 
       in call to 'memcpy': length '20' exceeds buffer size '8'

这清楚地表明元素大小会针对两个缓冲区进行检查。当 20 更改为 10 以指定要复制到目标缓冲区的实际字节数时,源缓冲区仍然不足以满足需求。可能会将两个错误的字节写入目标。因此,第二个警告仍然存在(抱怨 8 个字节)。

即使您使用 memset_s,也必须指定源和目标之间较小的缓冲区大小,或者确保两者都足够进行读/写。

memset 上的警告类似,对于动态分配的缓冲区,CA 编译也会针对 memcpy 指出警告。

int *src, *dest;
src = new int[10];
dest = new int[8];

memcpy(dest, src, 100);

//warning C6385: Invalid data: accessing 'argument 2', the readable size is '10*4' bytes, 
// but '100' bytes might be read: Lines: 251, 252, 253, 255

//warning C6386: Buffer overrun: accessing 'argument 1', the writable size is '8*4' bytes,
// but '100' bytes might be written: Lines: 251, 252, 253, 255

在不同的情况下,实际的警告略有不同,但它们都传达了相同的含义。

以 null 结尾的字符串问题

每个 C 和 C++ 程序员都知道 C 字符串是什么:一个字符集,后面跟着一个 null 字符。null 字符的值为零,通常用 NULL 宏表示。好吧,我在这里不是为了宣传 C 字符串,因为存在 std::stringCString 以及来自不同库的其他类。原生 C 字符串在 C/C++ 程序中确实起着重要作用,并且在性能很重要的代码中也确实存在。好吧,您赢了,C 字符串很糟糕,不要使用它们!但我必须涵盖这个讨论的范围。


现在的问题!您对此很清楚:如果字符串没有正确以 null 结尾怎么办?好吧,您可能也知道答案:函数将尝试读取字符串直到遇到值零,并将读取不正确的数据。读取尝试可能超出调用堆栈,并可能损坏堆栈,如果字符串在堆栈上,可能会突然关闭进程。如果字符串存储在堆上,搜索 null 结尾可能会损坏/过度读取相关的堆。

对于 strcpy 等函数,如果源字符串无效,或其内容大于目标字符串,它将损坏堆栈/堆——您知道原因!为了克服这些致命活动,建议使用安全版本,它们通常以 _s 结尾。在这个讨论过程中,我将涵盖字符串函数的两个版本。

让我们从例子开始。

char str[10];
 strncpy(str, "AB",2);

 int nLen = strlen(str);

请记住,如果指定的字符数不少于源字符串,strncpy 将不会在末尾放置 null 字符。因此,strncpy 的原型是这样的,它表示它不会返回以 null 结尾的字符串。在下一节中,我将详细说明这个原型。上面的代码会产生以下 CA 警告,以及其他标准警告。

warning C6053: Call to 'strncpy' might not zero-terminate string 'str': Lines: ...

为了避免此警告和潜在的 bug,您可以将 null 结尾字符放在所需位置,如下所示。

strncpy(str, "AB",2);
str[2]=0;

或者像这样。

char* pLoc;
 pLoc = strncpy(str, "AB",2);
 str[pLoc-str]=0;

我建议使用安全版本。

strncpy_s(str, 10,"AB",2);

strncpy 主要用于控制复制多少字符,并且它比 strcpy 使用得多。字符串在使用前会被零填充,或者在复制操作后显式以 null 结尾。大多数时候,您可以忽略所有这些,直接使用 strcpy_s,它只读取/写入指定字符数。

strcpy_s(str, 10,"AB");

定义不明确的循环 bug

在我看来,这类 bug 很少见,而且会在您匆忙编码时发生;可能是当您的大脑无法处理好逻辑时,或者您的老板在您身后,您需要尽快完成。这个类别不需要更多的讨论,几个代码示例就足够了。

示例

for(int nIndex=10; nIndex<0; nIndex--)
{}

当然,您一眼就能发现这段代码中的 bug,但在它被嵌入到其他地方时就不是这样了。CA 会帮助您。

warning C6294: Ill-defined for-loop: initial condition does not satisfy test. 
        Loop body not executed

您只需要一个字符的更改。

for(int nIndex=10; nIndex > 0; nIndex--)
{}

类似的 bug。

// Print string in reverse
 char szName[] = "CodeProject";
 for(int nIndex= strlen(szName) - 1;
  nIndex >= 0; nIndex++)
 {
  printf("%c", szName[nIndex]);
 }

这段代码会产生两个警告,第一个是关于定义不明确的循环,另一个已经讨论过了。

warning C6292: Ill-defined for-loop: counts up from maximum

warning C6201: Index '2147483647' is out of valid index range '0' to '11' 
     for possibly stack allocated buffer 'szName'

这只需要最小的更改:nIndex--

有一个类似的警告 C6293,它只是上面说明的警告 C6292 的反向。以下代码说明了这一点。

for(BYTE nIndex = 'A'; nIndex <= 'Z'; nIndex--)
 {
   printf("%c\n", nIndex);
 }

它将显示类似如下的字符集,而不是显示 AZ

A
@
?
>
=
<
;
:
9
8
7
6
5

我还听到了一声哔哔声!上面的代码会产生以下警告。

warning C6293: Ill-defined for-loop: counts down from minimum

我不应该侮辱您,告诉您需要进行什么更改来纠正这个 bug!

如果循环控制变量是无符号类型,则会引发以下警告。

warning C6296: Ill-defined for-loop. Loop body only executed once

例如,产生所述警告的代码可以如下粘贴。

unsigned int nLoopIndex;
 for (nLoopIndex= 0; nLoopIndex < 100; nLoopIndex--)
 {}

请注意,如果 nLoopIndex 的类型是 int 而不是 unsigned int,则会引发警告 C6293。

这里是同一行中的第五个警告触发器代码。

BYTE nLoopIndex;
 for (nLoopIndex= 100; nLoopIndex >= 0; nLoopIndex++)
 {}

这会导致无限循环,因为它从 100 开始,一直到 0(一个错误的假设)。当它到达 0 时,这是一个真的条件,它会到达 255,因此是一个永不停止的循环。即使循环索引被递减而不是递增,警告循环结构也将是相同的(无限)。

这段使用 BYTE(它只是 unsigned char,范围从 0 到 255)的代码会产生以下警告。

warning C6295: Ill-defined for-loop: 'unsigned char' values are always of range 
       '0' to '255'. Loop executes infinitely

如果循环控制变量是另一种类型(无符号整数),则会显示范围“0”到“max”。例如,使用无符号 int 循环控制变量,警告将是。

... values are always of range '0' to '4294967295'. Loop executes infinitely.

其他杂项问题

本小节将列出我无法归类的警告。但是,本小节不应被忽视,因为它涵盖了一些可能出现在您代码中最重要的 bug。由于我涵盖了所有剩余的未分类 CA 警告,其中一些可能不会让您满意。

我们开始吧!

我们使用 atol 和其他变体从字符串中获取整数。不废话,这是 bug。

int nNumber; 
atol("1234");

当然,该值应该赋给变量 nNumber,但您忘记了。对于这段代码,您会看到其他警告,即变量未使用,变量未赋值就使用,如果您赋值(例如赋零)并使用它,这些警告可能不会出现。bug 仍然存在于代码中。CA 编译会将其揭示。

warning C6031: Return value ignored: 'atol'

atol 的原型是这样的,它会导致 CA 编译发出此警告,如果返回值被调用者忽略。如前所述,我会讨论如何在下一节中“原型化”您的函数,以便代码分析能够为用户代码调用您的函数。

此警告也会出现在其他函数中,如 fopen,其中返回 FILE* 并且必须放入变量(或至少检查)。不幸的是,只有 C 运行时函数是这样原型化的(大多数),而不是 Windows API 函数,如 HeapAlloc。这意味着代码分析不会为以下代码发出任何警告。

HeapAlloc(GetProcessHeap(), 0, 1024);

另一个常见 bug 可能会出现在您的代码库中。

int nIndex;
 for(int nIndex=0; nIndex<10; nIndex++)
 {
  // Check if this number/index meets requirement
  // and break
 }
 if( nIndex<10 )
 {
  // Do something
 }

循环控制变量 nIndex,在循环内部使用,覆盖了第一个声明的定义。因此,循环后 nIndex 的值将保持未定义(或者保持它被设置的值,因为它是一个不同的变量)。上面的代码会产生以下警告。

warning C6246: Local declaration of 'nIndex' hides declaration of the same name in 
       outer scope. For additional information, see previous declaration at line 
      '348' of 'e:\code\codeanalysis\codeanalysis\codeanalysis.cpp': Lines: 348

变量不应在同一作用域内重新声明——这不仅仅是为了消除警告,更是为了避免任何潜在的 bug。有时,程序员会在多个循环中使用具有相同名称的 STL 迭代器变量,即使它们的用途非常不同,也不应给它们起相同的名称。

如果变量的第一个声明不是局部的,而是全局的,则会发出类似的警告,但代码不同(C6264)。此警告和之前的警告不关心数据类型是否相同或不同。

char Buffer[512];

void Foo() 
{
  void* Buffer = new BYTE[1024*10];
  // Use Buffer
}

警告消息将是。

warning C6244: Local declaration of 'Buffer' hides previous declaration at line '343'...

虽然在这种情况下,可以使用作用域解析运算符(::Buffer)访问全局变量,但不建议给多个变量起相同的名称。

不幸的是,如果一个变量存在于一个类中,而另一个变量在类的成员函数中声明,则不会抛出这种类型的警告。以下代码不会产生任何警告。

class T2
{
 int Age;
public:
 void bar()
 {
  short Age;
  Age=0;
 }
};

(待续)


使你的代码易于分析

到目前为止,您已经看到了一些函数,它们要求检查返回值,参数不能为空,缓冲区大小必须与某个常量或参数匹配等等。现在,您可能想知道如何用您的代码(例如,为导出的 DLL)做到这一点。

例如,假设您编写了一个压缩库,它托管在 DLL 中。少数函数是导出的(原生 C,没有 C++)。像 Windows 句柄一样,您通过句柄公开压缩接口,该句柄声明如下。

DECLARE_HANDLE(COMPRESSION_HANDLE);
少数函数中的第一个是。
int InitializeCompressor(COMPRESSION_HANDLE*);

它初始化一个新的压缩对象,并设置句柄以供进一步使用。请不要在意句柄,它只是 DLL 客户端的不透明接口。DLL 客户端会像这样初始化它。

COMPRESSION_HANDLE hCompHandle;
InitializeCompressor(&hCompHandle);

您还导出两个函数来压缩和解压缩数据。

int CompressData(COMPRESSION_HANDLE hCompHandle, 
                 const void* pInput, int nInputLen, // IN
                 void* pOutput, int* pnOutLen);     // OUT

int UncompressData(COMPRESSION_HANDLE hCompHandle, 
                   const void* pInput, int nInputLen, // IN
                   void* pOutput, int* pnOutLen);     // OUT     

客户端应用程序通常会像这样使用您的压缩库。

COMPRESSION_HANDLE hCompHandle;
 InitializeCompressor(&hCompHandle);

 char SourceData[512] = "This is the data to compress";
 char Compressed[512];
 int nCompressedLen=512;

 CompressData(hCompHandle, SourceData, 512, Compressed, &nCompressedLen);

应用程序也可以以类似的方式解压缩数据。

现在,您希望原型化导出的函数,以便。

  1. 客户端应检查 InitializeCompressor 的返回值,而不是盲目直接使用句柄。
  2. 客户端应用程序应正确传递输入缓冲区及其大小。如果源数据是 100 字节(堆栈或堆),而 200 作为源长度传递,会怎样?
  3. 同样的规则适用于输出数据,其中数据大小作为输入输出参数传递。指针指向的值必须引用正确的缓冲区大小。
  4. 还有一些,我们将看到。

虽然这需要一些讨论(或者说很多,如果我们想深入研究代码分析),但让我先原型化第一个函数 InitializeCompressor

__checkReturn int InitializeCompressor(COMPRESSION_HANDLE*);

符号 __checkReturn 会导致客户端端的 CA 编译器发出以下警告(针对上面粘贴的代码)。

warning C6031: Return value ignored: 'InitializeCompressor'

如果您注意到,奇怪的是,或无意中,函数 atol 的原型有点奇怪。

_CRTIMP __checkReturn long __cdecl atol(__in_z const char *_Str);

其中

  • _CRTIMP 只是 __declspec(dllimport) 的 #define。我假设您非常清楚它的含义。
  • __cdecl 指定了调用约定,这是 C 调用约定。
  • __checkReturn 指定应检查返回值。这是必须将返回值放入变量或至少进行检查的函数之一。因此,这个符号对于这个函数非常有意义。
  • __in_z 表示传递的字符串是 C 字符串,它应该是以 null 结尾的。稍后会详细介绍。

我能猜到您显而易见的疑问,“__checkReturn 和 __in_z 是什么鬼?

好吧,这些符号是代码分析工具的注解,它们属于源注解语言(SAL)。不,您不需要学习新的编程语言!它们仅对代码分析有意义(即,当使用 /analyze 开关编译代码时)。SAL 是 Microsoft 特有的,因此,SAL 注解的头文件可能无法与其他编译器编译。来自 MSDN。

一组注解描述了函数如何使用其参数——它对它们的假设,以及它在完成时所做的保证。头文件 <sal.h> 定义了这些注解。

目前,请不要在意这些符号的确切含义——不同版本的 Visual Studio 含义不同(符号名称也不同)。我故意避免讨论中的复杂性,至少目前是这样。将它们视为宏,并且在使用代码分析编译时,这些宏被定义为空(如 afx_msg)。否则,它们具有不同的含义,CA 会理解它们,并会产生智能警告。

我们继续。缓冲区(包括字符串)可以是输入缓冲区、输出缓冲区或两者兼有。例如,strlen 函数接受输入缓冲区。strcpymemcpy 函数都将第一个参数作为输出缓冲区,第二个作为输入缓冲区。struprmemset 函数接受输入输出缓冲区。C 风格字符串实际上是已 null 结尾的字符串,一种特殊的缓冲区,它们通过嵌入的 null 字符隐式指定其大小(暂时忽略安全函数)。对于普通缓冲区,必须在函数参数中指定缓冲区大小。这就是为什么 strcpy 需要两个参数,而 memcpy 需要三个参数。

此外,缓冲区的大小可以用字节表示,也可以用元素表示——即,字节计数元素计数。例如,memcpy 函数的参数是缓冲区大小的字节数;wcsncpystrncpy 的宽字符版本)的参数是元素计数。

还有一点,在缓冲区参数(或说指针参数)的情况下最为基本——函数总是接受一个有效的指针,还是可以为 null?

让我们开始注解不允许传递 null 缓冲区的函数。为此,我再向我们的压缩库添加一个函数,SetPassword,它为压缩/解压缩设置密码。

bool SetPassword(__in const char* sPassword);

其中 __in 注解指定此参数是输入参数,因此以下代码将给出代码后概述的警告。

SetPassword(hCompHandle, NULL);
/*
warning C6309: Argument '2' is null: this does not adhere to function specification 
  of 'SetPassword'
warning C6387: 'argument 2' might be '0': this does not adhere to the specification 
   for the function 'SetPassword': Lines: 377, 378, 380
*/

不幸的是,这些警告在 Visual C++ 2005 中都没有显示,原因我不得而知。在 VS2008 和 VS2010 下,两者警告都显示了。

嗯,我相信这是我给出 VS2005 和较新版本 Visual C++ 在代码分析编译方面差异的提示的时候了。虽然稍后会更详细介绍,但注解 __in __checkReturn,是尚未探索的众多注解中的两个,它们来自 VS2005。从 VS2008 开始,__in_In_ 取代,而 __checkReturn_Check_return_ 注解所取代。这些名称的差异主要在于前者有两个下划线,后者有一个下划线。更高版本仍然支持旧注解。然而,Windows 头文件在 VS2008 和 VS2010 上使用较新的版本。

回到原讨论。CA 工具足够智能,可以检测到参数可能为 null 的情况。

char* pPassword = (char*)malloc(200);
SetPassword(hCompHandle, pPassword);
// 'malloc' just for illustration, as it may return NULL. 
// 'operator new' doesn't return null, thus warning wont appear.

函数 malloc 仅用于说明,因为它可能返回 NULL,因此 CA 编译器可以进行一些工作。operator new 不会返回 null(它会抛出异常),因此不会显示任何警告。除了这种情况,CA 还检测代码流中的可能情况来查找null 情况。上面的代码给出警告 C6387。

这只是故事的一半。您知道密码实际上是一个字符串,一个以 null 结尾的字符串。为此,我们需要在 __in 后面附加 _z 注解(或者在 _In_ 前面添加 z_)。有一个明确定义的符号,在 <sal.h> 中,它是:__in_z(或_In_z 用于更高版本的 VS)。因此,我们将原型更改为。

bool SetPassword(__in_z const char* sPassword);

这本质上意味着这是一个输入缓冲区,它是以零结尾的。您可能会问有什么好处。好吧,它确保传递给此函数的字符串实际上是以 null 结尾的,而不是由不写入 null 结尾字符的函数(如 strncpy)形成的。用这种方式注解后,以下代码将生成所述警告。

char szPassword[12];
strncpy(szPassword, "CP@1234", 7); // Warning here, but all relevant code would be grayed.
SetPassword(hCompHandle, szPassword);

// warning C6053: Call to 'strncpy' might not zero-terminate string 'szPassword': Lines:

这就是 strlen 函数被注解为。

__checkReturn  size_t __cdecl strlen(__in_z const char * _Str);  // VS2005
_Check_return_ size_t __cdecl strlen(_In_z_ const char * _Str);  // VS2008+

正如您所见,对于以 null 结尾的字符串,缓冲区大小是隐式通过缓冲区本身传递的。代码分析会尝试查找函数调用周围的潜在缺陷,以查看传递的字符串是否确实以 null 结尾。因此,CA 发出的警告可能并不总是正确的——它可能会在没有 bug 时发出警告(即,它确定字符串是以 null 结尾的),或者如果字符串正确结尾可能存在 bug 时不发出警告。因此,最好使用以 _s 结尾的安全函数,因为它们接受额外的参数来处理输入和/或输出字符串缓冲区大小。

那么,您如何为函数注解缓冲区大小?再说一遍,缓冲区可以是以下组合:

  • 输入和/或输出缓冲区。
  • 缓冲区大小可以是字节元素计数
  • 如果缓冲区可以为 null(即,可选)。
  • 缓冲区大小由常量(如 MAX_PATH 或任何常量)指定,或者由另一个参数指定,或由其他机制指定。
  • 对于多级指针(指针的指针,如 BYTE**),究竟是什么定义了缓冲区,是指针还是被指向的指针?
  • 类似地,对于输入/输出缓冲区大小(如 size_t*),缓冲区的大小可以由被指向的值指定。

我相信您无法立即吸收所有观点,您可以稍后回来!我将用好的、有意义的例子来详细阐述所有观点,以便一切都一目了然。

为了继续讨论,让我修改 SetPassword,它将接受一个正好是8个字符(字节)的密码字符串,并且不涉及 null 字符。修改后的函数将如下所示。

bool SetPassword(COMPRESSION_HANDLE, __in_bcount(8) const char* sPassword);

缓冲区是输入的,因此是 __in 注解。注解 _bcount 指定字节中的缓冲区大小。完整的符号通过头文件 sal.h 定义。由于这是有意义的注解之一,因此它被明确定义(如 LPCTSTR,它是多个构造的组合)。然而,您也可以将它们分开使用“__in __bcount(8)”,这几乎意味着相同。

现在,我们用正好 8 个字符的密码调用此函数。

SetPassword(hCompHandle, "1234ABCD");

将不会有任何警告。如果您指定一个 20 个字符的字符串,也不会有警告,因为 SetPassword 已指定它只会读取 8 个字节。如果您像这样调用它呢?

SetPassword(hCompHandle, "XYZ");

这将导致以下警告在构建输出中留下痕迹。

warning C6385: Invalid data: accessing 'argument 2', the readable size is '4' bytes, 
        but '8' bytes might be read: Lines: 383, 384, 390

VC2008 和 VC2010 也会产生以下警告。

warning C6203: Buffer overrun for non-stack buffer '"XYZ"' in call to 'SetPassword': 
       length '8' exceeds buffer size '4'

四个字节?您只传递了 3 个字节,对吧?错了!C 字符串总是以 null 结尾的,当您硬编码它时,编译器会在末尾添加 null 字符。即使您将其类型转换为其他内容,"XYZ" 字符串仍将占用 4 个字节。因此,在上面对 SetPassword 的第一次调用中,字符串 "ABCD1234" 实际上是一个 9 字节字符串,但函数只会读取八个字节(如注解所示)。

由于这只是一个例子,您通常不会传递硬编码的字符串。更常见的是,您会分配一个 char 缓冲区(堆栈或堆),显式初始化它的元素,然后传递给一个函数——一个接受 char 数组但将其视为字节序列的函数。

一些函数,如 GetTempFileName,将常量(MAX_PATH)指定为缓冲区大小。但大多数其他函数会通过另一个参数指定缓冲区大小,例如 GetWindowsDirectory。现在,我将向您展示如何通过另一个参数指定函数缓冲区大小,这非常直接。

bool SetPassword(COMPRESSION_HANDLE, 
                 __in_bcount(nPasswordLen) const char* sPassword, 
                 int nPasswordLen);

虽然您应该使用 size_t 来指定缓冲区大小,但我为了简单起见使用了 int。注解的含义不言自明。有趣的是,SAL 还支持为缓冲区大小指定表达式,因此,以下内容也很好。

__in_bcount(nPasswordLen - 1) const char* sPassword,

可能适合 SetPassword!无论如何,我在此讨论中使用非表达式版本。客户端可以这样调用此函数。

SetPassword(hCompHandle, "ABCD1234", 8);

以下代码传递了无效的大小(或无效的缓冲区)。

char szPwd[]="ABCD";
SetPassword(hCompHandle, szPwd, 8);

将触发 CA 工具产生以下警告。

warning C6202: Buffer overrun for 'szPwd', which is possibly stack allocated, in call to 
       'SetPassword': length '8' exceeds buffer size '5'
warning C6385: Invalid data: accessing 'argument 2', the readable size is '5' bytes, 
       but '8' bytes might be read: Lines: 383, 384, 391, 392

您知道如何纠正这个 bug/警告。

允许我再给 SetPassword 添加两点。

  1. Unicode/宽字符字符串怎么样?
  2. 如果您想将密码作为以 null 结尾的字符串传递,并且缓冲区大小只是最大值的规范(如安全函数),会怎么样?

对于第 2 点,您只需执行此操作。

bool SetPassword(COMPRESSION_HANDLE, 
                 __in_bcount_z(nPasswordLen) const char* sPassword, 
                 int nPasswordLen);

注解 __in_bcount_z 将意味着。

  • 缓冲区不是可选的,不能为 null(__in)。
  • 缓冲区大小在括号中指定(_bcount)。
  • 并且缓冲区应该是正确以 null 结尾的字符串(_z)。

对于修改后的函数 SetPassword 注解,以下代码将发出相关的警告集(未显示)。

char szPwd[10];
strncpy(szPwd,"ABCD",4);// Not null-terminated
SetPassword(hCompHandle, szPwd, 20); // Invalid buffer-size

对于第 2 点,我们需要使用元素计数而不是字节计数作为缓冲区大小规范。首先,我将向您展示另一种不推荐的方法。

 bool SetPassword(COMPRESSION_HANDLE, 
     __in_bcount_z(nPwdLen * sizeof(WCHAR)) const WCHAR* sPassword, 
     int nPwdLen);

这本质上是以字节计数获取缓冲区大小,调用者必须将实际字节数作为缓冲区长度传递。当然,以下内容也是可能的,它可以兼容 Unicode 和 ANSI。

bool SetPassword(COMPRESSION_HANDLE, 
     __in_bcount_z(nPwdLen * sizeof(TCHAR)) const TCHAR* sPassword, 
     int nPwdLen);

不过,它仍然需要字节计数而不是元素计数,可能会让调用者感到困惑。调用者必须像这样调用它。

TCHAR szPwd[10];
_tcscpy(szPwd, _T("ABCD") ); 
SetPassword(hCompHandle, szPwd, _tcslen(szPwd) * sizeof(TCHAR));

这绝对不合适且难以理解。虽然这将在 ANSI 和 Unicode 版本中编译并显示警告,但这样编码更容易出错。(阅读本文了解 TCHAR 等内容)

注意:请忽略您对从 DLL 导出的 ANSI/Unicode 函数的担忧。您绝对可以使用宏来隐藏两个版本,就像 Windows 头文件那样。

以下注解是我们需要的元素计数规范。

bool SetPassword(COMPRESSION_HANDLE, 
     __in_ecount_z(nPwdLen) const TCHAR* sPassword, 
     int nPwdLen);

嵌入式注解 _ecount 指定 sPassword 应被视为缓冲区大小的元素计数。缓冲区大小(以元素为单位)由 nPwdLen 指定。

调用 SetPassword 的正确代码应该是。

  TCHAR szPwd[10];
 _tcscpy(szPwd, _T("ABCD") ); 
 SetPassword(hCompHandle, szPwd, 10);


您也可以使用 _tcslenstrlen 的变体),而不是常量 10。如果调用者使用 sizeof 指定缓冲区大小。

SetPassword(hCompHandle, szPwd, sizeof(szPwd));

代码分析将显示以下警告。

warning C6057: Buffer overrun due to number of characters/number of bytes mismatch 
        in call to 'SetPassword'

因为 sizeof 总是返回字节数,而不是字符数。然而,在 ANSI(MBCS)模式下编译代码时,此警告可能不会显示。因此,应使用以下任一方法将元素数量作为缓冲区大小传递。

SetPassword(hCompHandle, szPwd, _countof(szPwd)); // Defined in STDLIB.H, Mostly available.
SetPassword(hCompHandle, szPwd, sizeof(szPwd) / sizeof(TCHAR)); 

虽然我还没有展示 SetPassword 的代码;但我还是想再加一个承诺:函数还将能够处理 null 指针。也就是说,它将检查传递的指针(第二个参数)是否为 null,并立即返回。为了让程序员和 CA 工具知道这个承诺,我再向缓冲区注解添加一些内容。

bool SetPassword(COMPRESSION_HANDLE, 
     __in_ecount_z_opt(nPwdLen) const TCHAR* sPassword, 
     int nPwdLen)

注意注解中添加的 _opt,它并不意味着参数在编程角度是可选的(如 C++ 中的默认参数),而是缓冲区是可选的。它可以为 null。这样,如果像下面这样调用 SetPassword,将不会生成警告(否则会出现 C6309、C6387,如上所示)。

SetPassword(hCompHandle, NULL, 10); // Third argument is not relevant.

由于函数承诺(或说保证)它会检查 null 参数,CA 编译会要求您履行该承诺。以下是我将要介绍的实现代码的第一个部分。

bool SetPassword(COMPRESSION_HANDLE, 
     __in_ecount_z_opt(nPwdLen) const TCHAR* sPassword, 
     int nPwdLen)
{
 char cLetter;
 cLetter = sPassword[nPwdLen];
 return false;
}

CA 编译将为 SetPassword 提供以下警告。

warning C6385: Invalid data: accessing 'sPassword', the readable size is 'nPwdLen*2' bytes,
        but '4' bytes might be read: 
warning C6011: Dereferencing NULL pointer 'sPassword': Lines: 364, 365

显示的警告顺序并不重要,但警告本身很重要。您通过添加 null 指针检查和 return 来纠正第二个警告(C6011)。

bool SetPassword(...)
{
 if(sPassword == NULL)
   return false;
 ...
}

第一个警告(C6385)需要您进一步解释。我启用了 Unicode 编译了代码,因此它显示“nPwdLen*2”。如果我们将其编译为 ANSI,将数据类型从 TCHAR 更改为 char,它将简单地说“'nPwdLen' 字节...”。这实际上是函数应该读取的字节数(因为函数本身正在做出承诺)。

此外,对于 Unicode 编译,它说“...但可能会读取‘4’个字节”。对于 ANSI 编译,CA 编译器将表示可能会读取 2 个字节。好好想想!
数组是零基索引的;nPwdLen 是指定的缓冲区大小,因此它在逻辑上应该是 >0,并且最近的有效数字是 1。索引 1 处的数据(sPassword[1]),对于 char 缓冲区实际上是读取 2 个字节!类似地,Unicode 字符串在索引 1 处的数据将尝试读取第 4 个(或第 4 个)字节!

到目前为止,您可能对 SetPassword 感到厌烦(至少我是!)。所以,让我们玩玩我们的压缩库导出的其他函数。现在,我将介绍 CompressDataUncompressData,并展示它能做出什么承诺

注解之前,我应该提醒您,到目前为止我们只处理了输入缓冲区,而不是输入、输入输出缓冲区。是时候继续并阐明它们以及其他相关内容了!

让我重新介绍一下我们被遗忘的英雄,它们是我们压缩 DLL 中的重要角色,它们是...

int CompressData(COMPRESSION_HANDLE hCompHandle, 
                 const void* pInput, int nInputLen,
                 void* pOutput, int* pnOutLen);

int UncompressData(COMPRESSION_HANDLE hCompHandle, 
                   const void* pInput, int nInputLen,
                   void* pOutput, int* pnOutLen);

它们使用输入缓冲区、输出缓冲区和一个输入输出参数。

您应该对输入缓冲区注解(您应该!)非常熟悉,因此我直接注解了两个函数的输入缓冲区。

int CompressData(COMPRESSION_HANDLE hCompHandle, 
                 __in_bcount(nInputLen) const void* pInput, 
                 int nInputLen,
                 void* pOutput, int* pnOutLen);
 
int UncompressData(COMPRESSION_HANDLE hCompHandle, 
                __in_bcount(nInputLen) const void* pInput, int nInputLen,
                 void* pOutput, int* pnOutLen);

注解部分简单地表示指针/缓冲区不能为空,并且缓冲区大小为 nInputLen字节。除此之外,以下代码会导致 CA 发出一些信息。

char SourceData[500] = "This is the data to compress";
char Compressed[512];
int nCompressedLen=512;
CompressData(hCompHandle, SourceData, 512, Compressed, &nCompressedLen); 

并且警告,您已经看过无数次了,是。

warning C6385: Invalid data: accessing 'argument 2', the readable size is '500' bytes,
               but '512' bytes might be read

这是一个潜在的 bug,应该纠正。

现在,让我们看看如何为输出缓冲区注解。为此,我们需要使用 __out 注解(或 VS2005 及更高版本的 _Out_)。这比您想象的要简单。

int CompressData(COMPRESSION_HANDLE hCompHandle, 
                 __in_bcount(nInputLen) const void* pInput, 
                 int nInputLen,
                 __out void* pOutput, int* pnOutLen);

这只是表示 pOutput 是一个输出缓冲区,并且不能为空。传递 NULL 作为第四个参数将导致 C6309(“参数为 null,不符合规范”)。

好的!您如何为此参数指定缓冲区大小?目前,让我们假设函数入口处的输入长度和输出长度相同。因此,可以使用以下注解。

int CompressData(COMPRESSION_HANDLE hCompHandle, 
                 __in_bcount(nInputLen) const void* pInput, 
                 int nInputLen,
                 __out_bcount(nInputLen) void* pOutput, 
                 int* pnOutLen);

如果传递不合适的缓冲区大小,这将导致警告。

当然,对于压缩,输入缓冲区和输出缓冲区大小(即使在函数入口处)也不能相同。输入缓冲区是在函数调用时可用/生成的。输出缓冲区将保存压缩数据,通常具有指定的大小。不过,我们可以使用以下注解,它从最后一个参数指定输出缓冲区的(最大)大小。

int CompressData(COMPRESSION_HANDLE hCompHandle, 
                 __in_bcount(nInputLen) const void* pInput,  
                 int nInputLen,
                 __out_bcount(*pnOutLen) void* pOutput, 
                 int* pnOutLen);

请注意间接性。函数入口时 pOutLen 的值将被考虑。因此,以下代码将导致警告,如注释所示。

char SourceData[500] = "This is the data to compress";
char Compressed[512];
int nCompressedLen=514;

CompressData(hCompHandle, SourceData, 512, Compressed, &nCompressedLen); 

/* 
warning C6386: Buffer overrun: accessing 'argument 4', the writable size is '512' bytes, 
        but '514' bytes might be written: Lines: ... 
*/

如果您还记得,COMPRESSION_HANDLE 的数据类型声明为。

DECLARE_HANDLE(COMPRESSION_HANDLE);

这就是大多数 Windows 句柄声明的方式。不,我不会深入研究句柄,或者 DECLARE_HANDLE 宏到底是什么意思;将其视为 void 指针。因此,我们可以注解我们函数的前几个参数(接受 COMPRESSION_HANDLE),以确保调用者传递非 NULL 句柄。这非常容易。

 int CompressData(__in COMPRESSION_HANDLE hCompHandle, ... );
 int UncompressData(__in COMPRESSION_HANDLE hCompHandle, ... );

正如您可以猜到的,将 NULL 传递给这些函数的第一个参数将发出警告,提示参数不能为空。但我们的函数 InitializeCompressor 还没有那么智能,无法知道它可能返回一个 null 句柄,因此不会有警告。稍后会详细介绍。

到目前为止,我已涵盖了如何指定输入缓冲区大小。输出缓冲区大小怎么样?我的意思是,函数返回后有多少字节/元素是有效可读的?例如,在输入时,我们说输入缓冲区是 100 字节,并且最大输出缓冲区大小是 100 字节,压缩数据将被放置在哪里。假设那些 100 字节的压缩大小是 67 字节。

用编码术语来说,这是我的意思。

BYTE data2compress[100];
BYTE compressed_data[100];
int nMaxSize=100;
CompressIt(data2compress, 100, compressed_data, &nMaxSize);


虽然 compressed_data 缓冲区可访问的字节数仍然是 100 字节,但并非所有 100 字节都有效。压缩大小可能只有 67 字节。像下面的代码可能会尝试从压缩数据中读取任意数量的字节;此 bug 应该由代码分析报告。

// After compression
if(compressed_data[90]==144
   compressed_data[91] = 128;

[请接受从 CompressDataCompressIt 函数的偏差,这是暂时的,有原因的。]

压缩缓冲区(或任何输出缓冲区)也可以通过循环读取,我们需要确保缓冲区访问是有效的。为此,我们可以指定函数会在缓冲区中写入多少字节/元素。

好的,经过大量研究,尝试使用 _part 注解(其目的是如此)失败后,以下代码什么也没做。

int CompressIt( void* pInput, 
     int nInputLen,
     __out_bcount_part(nMaxOutLen, *pnOutLen) void* pOutput, 
     int nMaxOutLen, 
     int* pnOutLen)

为简洁起见,省略了其他必需的注解。_part 注解的目的是告诉代码分析工具,第一个参数是输出缓冲区的输入大小(例如,最大大小)(以字节或元素为单位,取决于使用的 bcount/ecount)。第二个参数告诉输出大小(即此函数将修改多少字节)。因此,任何遵循此函数调用的代码,如果尝试读取(而不是写入)超出指定的输出长度,就应该从 CA 引发警告。输出长度指定了有效的缓冲区长度。

但是,Visual C++ 的当前版本(2005、2008 和 2010)不会对类似代码发出任何警告。值得注意的是,VC++ 头文件中的大量函数(无论 VS 版本或安装的 PSDK 如何)都应用了此注解(__out_bcount_part)。其中之一是 ReadFile,它被注解为。

ReadFile(
    __in        HANDLE hFile,
    __out_bcount_part_opt(nNumberOfBytesToRead, *lpNumberOfBytesRead) LPVOID lpBuffer,
    __in        DWORD nNumberOfBytesToRead,
    __out_opt   LPDWORD lpNumberOfBytesRead,
    __inout_opt LPOVERLAPPED lpOverlapped
    );

这有效地说明了 nNumberOfBytesToRead 是要读取的字节数(并且可以读入此缓冲区),并且在返回时通过 *lpNumberOfBytesRead 规范实际读取了多少字节到缓冲区。因此,像下面的代码应该会给出警告,而所有版本的 Visual C++ 编译器都没有报告。

HANDLE hFile;
BYTE Buffer[100];
DWORD nBytesRead=0;
 
ReadFile(hFile,Buffer, 100, &nBytesRead, NULL);
 
if(Buffer[nBytesRead+1]==77)
  Buffer[nBytesRead+2]=99;

这会引发以下一系列警告,我们已经看到了很长时间。

warning C6031: Return value ignored: 'ReadFile'

warning C6001: Using uninitialized memory 'hFile': Lines: 383, 384, 385, 386

如果我将 hFile 初始化为 NULL,第二个警告将更改为 C6387。但这里的重点不是这个。如果我传递了无效的输入缓冲区大小,代码分析会发现这个 bug。

ReadFile(hFile,Buffer, 110, &nBytesRead, NULL);
// C6202: Buffer overrun for 'Buffer', which is possibly stack allocated, 
// in call to 'ReadFile': length '110' exceeds buffer size '100'

这意味着 bcount/ecount 的 IN 规范确实有效,而 OUT 规范无效。

我为什么提到所有这些不起作用的东西?嗯,它是有效的。_part 注解与其他模式配合使用。

  • 当输出大小被指定为常量时。
  • 当输出大小是函数参数前(即在函数入口处的任何整数参数指定)时。
  • 当输出大小通过函数的返回值指定时。

这表明只有函数参数后的大小规范不起作用,尽管 MSDN 和 SAL.H 提到它应该起作用。除了指针,我还尝试了引用(int&),它也不起作用。如果有人对此有任何评论/链接,请告诉我。显然,我已搜索过!

  • 使用常量指定输出缓冲区大小。
void Convert2Hex(int nNumber,
     __out_bcount_part(nInput, 16) BYTE* pConverted,
     int nInput);

这明确说明它将写入 16 个字节。因此,以下代码将发出警告。

BYTE Hex[100];
Convert2Hex( 10, Hex, sizeof(Hex) );

cout << Hex[16];
// warning C6385: Invalid data: accessing 'Hex', the readable size is '16' bytes, 
// but '17' bytes might be read: Lines: 481, 482, 483, 485, 486, 488

此函数的第三个参数不是必需的。稍后会回到这个问题。

  • 使用其他输入参数指定输出长度。

我认为这种方法没有意义,但它确实有效。为什么有人会用输入参数指定输出缓冲区的输出长度——这是一个问题!不过,我可以举出一个原因:另一个函数将返回输出长度,并且可以将其指定到所需的函数中。例如,许多 Windows 函数要求您将 NULL 传递给一个应该包含“信息”的参数,而另一个参数将接收所需的缓冲区大小

无论如何,这里有一个例子。

void Bogus( int nInLen, int nOutLen,
             __out_bcount_part(nInLen, nOutLen) BYTE* pBuffer);

这里是使用示例,后跟警告。

BYTE Buffer[100];
Bogus(100, 40, Buffer);
cout<<Buffer[41];

// C6385: Invalid data: accessing 'Buffer', the readable size is '40' bytes, 
// but '42' bytes might be read
  • 使用函数的返回值指定输出长度。

嗯,我有点喜欢这种方法;虽然无法与不起作用的指针/引用方法相比!这种注解类型有些古怪。您使用关键字‘return’本身来指定输出缓冲区的有效缓冲区长度。一个例子。

int Convert2Binary(int nNumber, 
      __out_bcount_part(nInLen, return) char* pConverted,
      int nInLen);

这说明返回值是该函数调用后程序应读取的输出缓冲区的有效缓冲区长度。当然,您也可以指定 __checkReturn 以确保返回值得到利用。

很奇怪,但标准编译器和 CA 编译器不兼容——返回 void 的函数也可以使用这种方法。注解 __checkReturn 也可以应用于返回 void 的函数!为了消除您可能有的任何困惑,与 SAL 本身相关的警告来自另一个领域,我还没有涵盖。

使用示例。

 char Binary[120];
 Convert2Binary(127, Binary, sizeof(Binary));
 
 cout << Binary[1];

请注意,虽然函数 Convert2Binary 接受一个 char 指针,但它并不将其视为字符串。上面给出的使用代码会产生以下警告。

warning C6385: Invalid data: accessing 'Binary', the readable size is 'return value' bytes, 
   but '2' bytes might be read

正确利用 Binary 数组的一种方法是。

char Binary[120];
int nBinLen;
nBinLen = Convert2Binary(127, Binary, sizeof(Binary));
 
for (int nIndex=0;nIndex<nBinLen;++nIndex)
{
  cout << Binary[nIndex];
}
// OR:
// Binary[nBinLen-1]; // Max!

(待续)

这个主题晦涩、模糊,并且没有得到任何地方的充分解释。我正在收集信息,并将所有内容放在一个地方,用所有可能的方法来使用户/您的代码可分析。请给我一些时间!


历史

  • 2010年11月13日 - 初始帖子
  • 2010年11月14日 - 缓冲区溢出、格式字符串警告
  • 2010年11月17日 - Null 结尾字符串、定义不明确的循环警告
  • 2010年11月18日 - 其他杂项问题(部分)
  • 2010年11月20-22日 - 使你的代码易于分析(部分)
© . All rights reserved.