-  C 标准库提供了名为 assert的断言宏;
-  C# 语言提供了名为 Debug.Assert的断言方法;
-  Java 语言提供名为 assert的断言关键字。
主流编程语言不约而同的在语言层面上提供了 断言 机制。
-  David R. Jamson,编译器 Icc 的开发者之一,在他的《C 语言接口与实现——创建可重用软件的技术》一书中,教授如何实现断言(assert)接口,这是其它接口的基础; 
-  Stephen A.Maguire,Excel 开发者和领导者,在他的《编程精粹:Microsoft 编写优质无错 C 程序秘诀》一书中,用一章来讲述,如何自己设计并使用断言(assert); 
-  Andy Hunt,著名顾问,敏捷宣言成员之一,在他和 Dave Thomas 合著的《程序员修炼之道》一书中,介绍断言式编程,认为这是注重时效的编程方法。 
编程专家们不约而同的提倡使用 断言。
-  ST 外设驱动固件库中随处可见 assert_param断言宏;
-  网络协议栈 lwip 中随处可见 LWIP_ASSERT断言宏;
-  操作系统 FreeRTOS 中随处可见 configASSERT断言宏。
优秀的代码不约而同的已经使用了 断言。
即使是第一次听说断言,你也应该意识到,这个东西应该挺重要。那么接下来的问题是,什么是断言(assert)?
断言,就是明确且坚定的指出某事是真的!
to state clearly and firmly that sth is true 《牛津词典》
在 C 语言环境中,断言是一个宏,如果其参数的计算结果为假,就中止调用程序的执行。
 
就这?
听上去好像很一般嘛,不就是用来做参数检查的嘛,就这也值得特意开个专题?
很值得!
随着技术人员见解的增长,他们都会使用断言,或早或晚,殊途同归。
与其说这是技术水平不断提升的结果,不如说这是编程思想转变的结果。这个转变是:从被动的调试 BUG,开始转变为主动的发现 BUG!
检查不可能发生的情况
每一个程序员似乎都必须在其职业生涯的早期记住一句咒语。它是计算机技术的基本原则,也是我们所做的每一件事情的核心信仰。那就是:
 这绝不会发生… 1
比如设计一个处理字符串的内部函数:
void StrDoSomething(char* str);
{
    ...
}
参数 str 绝不应该是 NULL,或许你会说,调用这个函数时,我决不会让 str 为空!
醒醒吧,我们不要这样自我欺骗,特别是在编码时。如果它不可能发生,用断言确保它不会发生!
If It Can’t Happen, Use Assertions to Ensure That It Won’t
就像这样:
void StrDoSomething(char* str);
{
    ASSERT(str != NULL);
    ...
}
如果你认为参数 str 不可能为空,就用断言 ASSERT(str != NULL) 来确保它不会为空;
如果你认为变量 count 不可能为负,就用断言 ASSERT(count >= 0) 来确保它不会为负;
如果你认为 switch 的 default 分支不可能执行,就在 default 分支中用 ASSERT(false) 来确保它不会执行…
这些宏是无价的财富,你和使用该函数的人都将受益,如果将来某个程序员错误的使用了这个函数,函数自己会以明确的方式告知:嗨,你犯了个错误!

对函数参数进行确认
设计一个将无符号数转为字符串的函数,转换后的字符串可以是二进制、十进制、十六进制样式,函数为:
void UnsignedToStr(unsigned u, char* str, unsigned base);
{
    ...
}
参数 str 指向转换后的结果,必须非空;
参数 base 指定何种进制样式,可能的值为 2、10、16,分别表示二进制、十进制、十六进制样式。
我们可以使用断言对参数进行确认,之后这个函数在每个调用点都会对参数进行检查,如果用户发生了错误,就可以很快的、自动的把它们检查出来:
void UnsignedToStr(unsigned u, char* str, unsigned base);
{
    ASSERT(str != NULL);
 	ASSERT(base == 2 || base == 10 || base == 16);
    ...
}
这里值得注意的是,断言确认并不能代替异常判断,如果 UnsignedToStr 是一个外部使用的函数,并且外部调用时 str 有可能为空,则必须对这种异常情况做明确的处理:
void UnsignedToStr(unsigned u, char* str, unsigned base);
{
 	ASSERT(base == 2 || base == 10 || base == 16);
    
    if(str == NULL)
    {
        //卫语句,对异常做处理
        ...
    }
    ...
}
使用断言来消除未定义行为
memcpy 是定义在 string.h 中的一个库函数,函数原型为:
void *memcpy(void *s1, const void *s2, size_t n)
函数 memcpy 从 s2 指向的对象中复制 n 个字符到 s1 指向的对象中。如果复制发生在两个重叠的对象中,这种行为未定义。2
行为未定义 在 C/C++ 中很常见,如果某些行为标准没有明确规定、也不限制编译器的具体实现,那么这些行为就是未定义的。因此,未定义行为的执行结果取决于编译器,可能各家编译器都不相同,理论上,即使执行结果把你的硬盘格式掉责任都在你方。未定义行为就相当于非法行为3,我们可以用断言来消除未定义行为。
对于 memcpy 函数,可以使用断言来进行重叠检查:
/*封装内存拷贝函数*/
void *s_memcpy(void *s1, const void *s2, size_t n)
{
    ASSERT(s1 != NULL && s2 != NULL);
    /*检查内存重叠*/
    ASSERT((char *)s1 >= (char *)s2 + n || (char *)s2 >= (char *)s1 + n);
    
    memcpy(s1, s2, n);		//调用库函数
}
利用断言检查隐式假设的正确性
如果假设 long 占用 4 个字节,可以使用以下断言来检查假设是否正确:
ASSERT(sizeof(long) == 4);
这里困难的不是理解这句话的意思,而是如何意识到自己的代码是基于了某个假设!
罗伯特 B.西奥迪尼(Robert B.Cialdini)在他的《影响力》一书中指出:如果你是个售货员,那么当顾客准备购买毛衣和套装时,你应该总是先给顾客看套装然后再给顾客看毛衣。这样做的理由是可以增加销售额,因为在顾客买了一件 $500 元的套装之后,相比之下,一件 $80 元的毛衣就显得不那么贵了。但是如果你先给顾客看毛衣,那么 $80 元一件的价格可能会使其无法接受,最后也许你只能卖出一件 $30 元的毛衣。
任何人只要花 30 秒的时间想一想,就会明白这个道理。**可是,又有多少人花时间想过这一问题呢? **
在编写函数时,要进行反复的思考,并且自问:“我打算做哪些假设?”
一旦确定了相应的假设,就要使用断言对所做的假设进行检验,或者重新编写代码去掉相应的假设。
在进行防御性编程设计时,利用断言进行报警
防御性编程设计通常是一种很好的编码风格,他可以让程序更加健壮,当出现异常时,能以一种优雅的方式退出或者降级运行。比如在恶劣电磁环境中,从存储器中读出的数据通常认为是不可靠的,所以我们会对数据进行校验,然后将数据和校验值一起写入存储器。
使用数据时,把数据和校验一起读取出来,然后再次计算数据的校验值,将新的校验值与读出的校验值做对比,如果不相等,表示数据遭到了破坏,则进行相应的异常处理:
/*从存储器读出数据*/
lwnvrb_peek(&resume_nvrb_s, RESUME_LEN_BYTES, len, resume_read_buf);
crc16 = to_uint16_low_first(&resume_read_buf[len - 2]);
/*防御性编码*/
if(crc16 != cal_sensor_crc16(resume_read_buf, len - 2))
{
    clear_resume();		//错误处理
    return 0;
}
这是一种典型的防御性编程设计,你能看出这样的代码隐含着什么问题吗?
它安静的处理了异常!那些本该在设计阶段就应该规避的异常,比如硬件设计失误、软件设计失误,被隐瞒了!
看上去风平浪静,实则暗涛汹涌,代码撒了谎!
想象你正在设计一个温度传感器,温度不会突变。所以你对 ADC 采集的数据做了防御性编程设计:在计算真实温度前,忽略了突变的 ADC 数据。这是一个常规的操作,称为滤波(滤除干扰)。
但正是这种操作,掩盖了设计缺陷,由于信号调理链路的设计问题,会周期的产生尖脉冲,但是防御性编程的存在,这个问题被隐瞒了。其结果就是,信号调理硬件问题一直没能解决,温度传感器的精度始终差强人意。
那我们还要不要防御性编程设计?
当然要,不过要做一点改动,在进行防御性编程设计的同时,用断言对错误进行报警。
回到存储器读取数据的例子。在开发阶段,办公室环境中,我们判断电磁环境良好,因此认为存储器是可靠的,我们在保留防御性编程的基础上,增加了一条断言,如果进入防御性代码,则让程序“死”在这里:
/*从存储器读出数据*/
lwnvrb_peek(&resume_nvrb_s, RESUME_LEN_BYTES, len, resume_read_buf);
crc16 = to_uint16_low_first(&resume_read_buf[len - 2]);
/*防御性编码*/
if(crc16 != cal_sensor_crc16(resume_read_buf, len - 2))
{
    ASSERT(0);		//如果进入防御代码,则触发断言,通常程序会停在这句代码中
    clear_resume();	
    return 0;
}
死掉的代码不会撒谎!
这段代码在联调阶段,触发了断言,给出了触发断言的文件名和位置(第几行)。经过调试,很快锁定了触发断言的原因,不是存储器不可靠,而是软件逻辑的问题。如果没有断言,这个问题将会被隐瞒,并以其它的形式困扰我很久,且难以查找。
 
利用断言检查契约
想象一下这种场景:你的设备通过一套协议和上位机软件通讯,协议规定了数据的格式。我们以 设置报警 为例:协议规定,报警类型占 1 个字节,可能的值为 0 (无报警)、1 (上限报警)和 2 (下限报警)。传统编程这样解析上位机发来的数据:
switch(alarm_type)
{
    case ALARM_NONE:		//无报警
        alarm_value = ...;
        break;
    case ALARM_UPPER::		//上限报警
       	alarm_value = ...;
   		break;
    case ALARM_LOWER:		//下限报警
        alarm_value = ...;
        break;
    default:
        break;
}
但是,第一次联调时,你用上位机软件设置了报警值,随后发现你的设备并没有按照预期报警。这种事情在联调阶段很常见,问题在哪里?是发送的协议不正确还是你的报警逻辑不正确,不得而知。你只好抓取上位机下发的数据,然后对照协议分析,费时费力。
让我们换一种思路。
这种使用协议进行通讯的场景,是典型的契约式编程。协议即契约,契约作用于双方。作为契约的一方,你必须履行契约,而且有责任检查对方是否遵守契约,如果契约被破坏,必须以合适的方式处理。触发断言,就是一种很好的处理方式。
还是以上面的设置报警为例,使用断言检查契约,代码为:
switch(alarm_type)
{
    case ALARM_NONE:		//无报警
        alarm_value = ...;
        break;
    case ALARM_UPPER::		//上限报警
       	alarm_value = ...;
   		break;
    case ALARM_LOWER:		//下限报警
        alarm_value = ...;
        break;
    default:
        ASSERT(false);		//如果数据不合法,触发断言
        break;
}
修改后的代码仅仅多了一行断言(ASSERT(false)),但意义迥然不同。这句断言可以证明对方遵守了契约,或者在对方违反契约时主动报告错误。
还是第一次联调,你在上位机软件上刚点击了设置报警值的下发按钮,就发现你的设备显示屏上输出了一行断言:

你按照文件名和行号,找到这句断言,立刻明白,上位机程序员出错了,因为数据格式违反了契约。
如何实现断言
认识到断言的好处后,我们自然想知道如何在项目中实现断言。这里给出一个断言接口的实现。
assert.h 提供对外接口,也就是 ASSERT 宏,代码如下所示:
#ifndef __assert_h__
#define __assert_h__
#include "app_assert_cfg.h"
#define __STR(x)  __VAL(x)
#define __VAL(x)  #x
#ifdef  ASSERTS
    extern void _Assert(char *str_file, char *str_line);
#define ASSERT(e)  ((e) ? (void)0 : _Assert(__FILE__ ":", __STR(__LINE__)))
#else
#define ASSERT(e)
#endif      //#ifdef ASSERTS
#endif      //#ifndef __assert_h__
在设计上,这个宏需要一些技巧:
- 宏依赖一个外部函数 _Assert,用于输出断言信息。这个函数需要你自己实现,因为信息的显示依赖特定硬件。嵌入式设备没有标准输出,有的设备使用显示屏,有的使用 UART,各不相同,根据硬件而定。
- 代码要紧凑。通常编译器提供的符号 __LINE__是一个十进制常量,这里用__STR宏在编译阶段转换成字符串类型,避免在代码中做格式转换,这是一个小技巧。
- 为了生成的代码最小,这里没有输出测试条件,对于断言 ASSERT(str != NULL),其中str != NULL称为测试条件。C 标准提供的断言会输出这个测试条件,但是嵌入式系统通常存储容量不大,并且根据文件和行号能容易的找到测试条件,因此本接口不输出测试条件
- 为了生成的代码最小,没有把函数 _Assert的参数str_file和str_line粘连在一起,而是分成了两个参数。C 标准实现是将这两个参数在编译阶段粘连成一个mesg参数。这是因为如果在同一个文件中定义了多个断言,那么本接口的实现方法生产的代码更小(文件名__FILE__只会存储一份)。
- 接口需要一个外部头文件 app_assert_cfg.h,在这个头文件中可以使能或者禁用断言。如果想使能断言,则在这个头文件中定义宏ASSERTS,否则不要定义宏ASSERTS。为什么不在assert.h文件中定义或取消宏ASSERTS呢?这涉及到模块化代码的一个原则:模块化代码应该是只读的。具体参见 随想007:模块化代码。
函数 _Assert 的实现比较简单,给出一个在 LCD 屏上显示断言信息的例子:
#include "lcd_func.h"
/*用于LCD打印输出的接口*/
#define ASSERT_LCD_BUF_NUM          65
void _Assert(char *str_file, char *str_line)
{    
    char  lcd_buf[ASSERT_LCD_BUF_NUM];
    
    snprintf(lcd_buf, ASSERT_LCD_BUF_NUM, "%s%s", str_file, str_line);
    disp_txt_by_specify_location(LCD_ROW_1, LCD_COLUMN_1, lcd_buf);
    
    while(1)
    {
        WWDT_Feed();
    }
}
读后有收获,资助博主养娃 - 千金难买知识,但可以买好多奶粉 (〃‘▽’〃)
 
- 《程序员修炼之道》 ↩︎ 
- 《C 标准库》 ↩︎ 
- 《编程精粹》 ↩︎ 



















