×
嵌入式 > 技术百科 > 详情

用编译时断言在早期发现错误

发布时间:2021-05-27 发布时间:
|

一段时间以来,笔者一直在讨论如何在C和C++中使用结构来定义存储器映射器件寄存器的布局,并曾讨论了可以用来为相应寄存器给每个结构成员以合适的尺寸和排列。然而,不同的平台对数据的排列和填充不一样。因此,一个特定的结构定义对一个平台能正确布局结构成员,但对另外一个不同的平台进行编译时,可能会产生错误的布局。

一种不正确的布局结构在编译时常常没有告警出现,但是最终的程序在运行时不能按所期望的那样工作。你可以改进代码而不用费时费力地调试,这样编译器能发现布局错误。其中的技巧就是利用断言(assertion),断言能在结构成员出现尺寸或者排列错误时生成明显的编译时(compile-time)错误。

C和C++提供了实现断言的不同方法。笔者偏爱于那些能提供标准C断言宏编译时对等量的方法。我们从简要了解一下这个宏开始。

运行时(Run-time)断言

断言宏在标准的C头文件和标准的C++头文件中定义。形式为:

assert(condition);

的调用扩展到测试条件的代码。如果条件为真(产生一个非零值),将什么也不会发生。即在宏调用之后,程序继续执行下一个语句。另一方面,如果条件为假(等于 零),程序写一个诊断消息到stderr(标准错误流),并通过调用标准中断函数终止程序的执行。

断言宏能帮助在程序中发现逻辑错误。例如,假设调用get_token(f, t, n)扫描来自FILE f的输入,并将扫描的输入拷贝到字符数组中,以t开头,长度为n,可以在get_token中调用断言来发现错误的自变量值,否则将导致产生不确定的行为,如下面的函数:

bool get_token(FILE f, char t, size_t n)

{

...

assert(f != NULL);

assert(t != NULL);

assert(n >= 2);

...

}

如果程序不经意地用一个空指针作为第一个自变量来调用get_token函数,第一个断言将向stderr写入一个消息,并中断执行。对于大多数编译器来说,消息看起来类似于:

Assertion failed: f != NULL, file get_token.c, line 18

将断言写入到代码中能帮助进行归档,并提高开发代码的成功率。然而,因为是写到stderr,在缺乏对 标准C I/O系统支持的嵌入式环境中,标准的断言宏毫无用处。然而,写你自己的断言宏版、在别的地方显示消息并不那么困难。

尽管断言宏可以是一种有用的调试辅助手段,但并不适合于处理在已付运的终端用户产品中的运行时错误。已付运的产品应该产生对于一般终端用户来说很有意义的诊断消息。它还应该比通过调用中断函数更可靠地恢复或关断程序的执行。因此,提供了一种在源代码很少或不改变的情况下使所有断言无效的简单方法。开发者可以在代码中保留断言作为文档,但是应该使它们不会产生代码。

如果在包入之前在源文件中定义NDEBUG宏,断言宏将这样简单地定义:

#define assert(cond) ((void)0)

因此,随后的调用诸如:

assert(f != NULL);

扩展为:

((void)0);

编译器能够优化这个语句,使其根本不会产生代码。

可以在包入指令前,将NDEBUG的定义写入到源代码中:

#define NDEBUG

#include }

这种方法的问题是,每次想打开或者关闭断言的时候必须修改源程序。大多数编译器允许在调用编译器的时候,通过使用命令行自变量来定义宏,通常选择D选项。例如,像下面的命令行出现在源程序的第一行之前,则将断言关断:

cc -DNDEBUG get_token.c

compiles get_token.c as if:

#define NDEBUG

使用预处理程序的编译时断言

开发者可以使用断言来验证存储器映射结构成员具有正确的尺寸和排列。例如,假设像下面这样为一个定时器定义器件寄存器:

typedef struct timer timer;}

struct timer

{

uint8_t MODE;

uint32_t DATA;

uint32_t COUNT;

};

可以使用一个断言和offsetof宏来验证DATA成员在结构内部具有偏移4,如下:

assert(offsetof(timer, DATA) == 4);

在标准C头文件和标准C++头文件中定义了tffsetof宏。一种fsetof(t, m)形式的表达式从结构类型t的开始处返回成员m的偏移,偏移占若干字节。

这个断言确实解决了一个潜在的排列问题,但是并不十分理想。使用断言来检查一个结构成员的偏移将应该在编译时完成的检查延迟到运行时。对断言的调用只可以出现在函数内,因此不得不将调用包含在一个函数里,将这个函数作为程序启动的一部分调用,或者紧接着程序启动调用这个函数。

这里需要澄清的是,笔者并不是建议每个断言都可以在编译时检查。例如,像测试一个变量的值的断言如:

assert(f != NULL);

必须在运行时完成。然而,测试一个常数表达式的值的断言,例如一个结构成员的尺寸和偏移,可以在编译时完成。

对于只涉及常数表达式的断言来说,一些C和C++编译器将允许使用一个预处理程序条件语句来测试断言,如在下面的语句中:

#if (offsetof(timer, DATA) != 4)}

#error DATA must be at offset 4 in timer

#endif

利用这种方法,编译器在编译时评估这个条件—实际上是在预处理期间。如果断言失败(#if条件为真),预处理程序执行#error指令,这个指令显示一个包含指令中的文本的消息,并结束编译。不同的编译器的消息形式各不相同,但可发现一些相似之处:

timer.h, line 14: #error: DATA must be at offset 4 in timer

使用#error指令使你能写出非常清楚的诊断消息。

因为这种方法在编译时评估断言,因此断言不会存在任何运行时的代价,因此你可以不必将断言关闭。一个程序的编译时断言失败只会导致编译失败。

与断言调用不同的是,预处理指令可以出现在任何地方—全局、局部,或者甚至是在一个类或者结构定义内部,而断言调用只能出现在函数体内。

尽管有这些优势,使用#if指令实现断言至少会有几个问题。第一个问题不太严重,即必须对#if语句中的断言条件求反,在这个语句中通常你会使用断言宏来写。例如,为测试定时器的DATA成员的偏移为4,按下面的语句写运行时断言:

assert(offsetof(timer, DATA) = = 4);

为测试在编译时的相同条件,需要用运算符!=替换= =,如:

#if (offsetof(timer, DATA) != 4)}

#error ...

#endif

或者在逻辑上对整个条件求反,如:

#if (!(offsetof(timer, DATA) == 4))

#error ...

#endif

或者不管条件,将#error指令放到#else部分,如:

#if (offsetof(timer, DATA) == 4)}

#else

#error ...

#endif

第二个问题是关于使用#if指令来实现断言,这个问题比较严重:标准C和C++不识别在#if条件中的sizeof 和offsetof。它们也不能识别在#if条件中的枚举常数。一些编译器允许在#if中作为扩展存在sizeof、offsetof和枚举常数,但是大多数编译器是不允许的。庆幸的是,你可以以另外一种没有这种限制的方式来写编译时断言。

编译时断言的无效声明

在C和C++中,规定一个数组声明中元素数量的一个常数表达式必须具有一个正值。例如:

int w[10];

int x[1];

是有效的数组声明,而int y[0]; 不是。一个常数维数组具有多个操作数和运算符,包括sizeof 和offsetof子表达式,例如:

int z[2 sizeof(w) ⁄sizeof(w[0])];

这声明数组z具有两倍于数组w的元素。

开发者可以利用常数数组的维数必须是正数的要求,来以宏的形式实现编译时断言:

#define compile_time_assert(cond) \

char assertion[(cond) ? 1 : 0]

如果x是一个评估为真的表达式,于是调用:

compile_time_assert(x);

扩展到一个有效的数组声明(一维)。否则,扩展到一个无效的数组声明(0维),这个数组声明产生一个编译时诊断消息(错误或者告警)。

然而,当断言失败时错误消息的文本对于不同的编译器是不同的。笔者看到过的消息如“数组必须至少具有一个元素”,或者“无效的脚本或者脚本过大”。

如果幸运,编译器产生包含数组名的一个消息,例如“数组大小‘断言’为零”。在那种情况下,它帮助使数组名成为一个额外的宏参数,如下:

#define compile_time_assert(cond, msg) \

char msg[(cond) ? 1 : 0]

然后,你可以使用数组名来描述断言失败的原因。例如,如果调用:

compile_time_assert(offsetof(timer, DATA) == 4,}

DATA_must_be_at_offset_4);

造成一个断言失败,那么将可能看到一个错误消息,类似于:

size array DATA_must_be_at_offset_4 is zero

如上所述,这个宏有一个很容易解决的小问题。这个问题是,在某些情况下,数组声明可能是分配存储的一个定义。可以将数组声明转变为typedef来避免这个问题,如:

#define compile_time_assert(cond, msg) \

typedef char msg[(cond) ? 1 : 0]

在相同的范围内,两个typedefs不能具有相同的名字,因此必须使用msg参数来给每个typedef一个独特的名字。如果不愿意采用msg参数,那么可以将数组声明为外部数组,如下所示:

#define compile_time_assert(cond) \

extern char assertion[(cond) ? 1 : 0]

然而,如果采用这种方法,将不能在C++类中使用宏,因为你不能将C++类成员声明为外部量。你可以发现编译器不会对0大小的数组提出“抱怨”。在这种情况下,你可能尝试将0改变为-1,如下所示:

#define compile_time_assert(cond, msg) \

typedef char msg[(cond) ? 1 : -1]

Boost库()为C++程序员提供了另一种以称为BOOST_STATIC_ASSERT的宏的形式实现编译时断言的方法。利用C++模板可以巧妙地实现这个宏。如果你是一个C++程序员,而且你理解模板的特殊性,你可以进行尝试。

-->

『本文转载自网络,版权归原作者所有,如有侵权请联系删除』

热门文章 更多
联发科高端芯片系列出新品Helio P10 中文名[曦力"