在编程实践中,空指针引起的错误屡见不鲜,指针解引用时遇到了空指针,说明程序有严重的错误,底层一般会通过某种机制通知上层模块,抛出空指针异常就是一种常见的方式。比如,在Java语言中就有空指针异常,如果程序在运行过程中,对空指针进行了访问,JVM就会抛出一个 NullPointerException 类型的异常,表示程序访问了空指针。如果程序不对这个异常进行处理,程序一般会崩溃,导致JVM进程终止。因此,在编程时为了代码安全,防止程序崩溃,并且能够从中恢复正常的话,一般会捕捉这个空指针异常并进行恢复处理。

比如下面就是一段捕获 NullPointerException 异常的 Java 代码片段:

try {
	// 业务处理
} catch (NullPointerException e) {
    e.printStackTrace();
    // 进行恢复操作
}

try语句块中的代码访问到空指针后,会抛出 NullPointerException ,随后在 catch 语句块捕获这个异常,并在语句块中进行处理。因为代码捕获了这个异常,从而避免了程序发生崩溃。

我们知道, C++ 也支持异常,那么如果遇到了空指针,能否像 Java 那样捕获空指针异常呢?先编写一段代码测试一下:

static void catch_null_except() {
	int *p = nullptr;
	try {
		int x = 100 + *p; 
	} catch (...) {
		puts("catch exception!");
	}
}

int main() {
	catch_null_except();
}

因为p是一个空指针,对它进行解引用,肯定会导致内存违法访问。如果编译后并运行,会发现程序会崩溃:Segmentation fault (core dumped)。可见,语句块 catch (...) 并没有捕获到任何异常,由此可见,在 C++ 中是无法捕获空指针异常的!

这是为什么呢?

我们知道,空指针实际上指向的是虚拟内存地址为 0 的位置,它是一个特殊的位置,操作系统内核是不会为应用程序在这个 0 地址上分配物理内存页的。因此当应用进程访问这个位置时,内核不会像访问常规内存那样:发现该处地址没有分配物理页面,会产生一个缺页异常,然后异常处理程序为它分配一个物理页,并建立页表项,而是直接向进程抛出一个内存段错误的信号:SIGSEGV。我们知道,这个信号的缺省处理是终止进程并生成 coredump 文件,因此,当程序访问空指针时,内核会直接终止进程,也就是应用程序根本不会有抛出异常的机会,实际上应用程序压根就不知道它访问了空指针,因为它自己判断不了,抛异常也就无从谈起,所以尽管上述 C++ 程序使用了 catch 语句块,也没有异常可捕捉。同样,如果程序访问一个指向不属于进程地址空间的指针(也可以说是野指针,通常是编程错误造成的),它所指向的内存位置是无效线性地址,同样操作系统内核也会直接产生一个 SIGSEGV 信号,终止进程。

我们不妨做个实验,在 Linux 环境下编写一个信号处理函数来处理 SIGSEGV 信号,并修改前面的 main 函数,看看会发生什么?代码如下:

static void handler(int signo) {
	std::cout << "signal no:" << signo << std::endl;
	exit(-1);
}

int main() {
	signal(SIGSEGV, handler);
	catch_null_except();
}

程序运行时会输出:signal no:11,编号为11的信号正是SIGSEGV#define SIGSEGV 11。可见,尽管 C++ 无法捕获空指针异常,可以借助于信号机制来判断是否发生了空指针引起的段错误异常。

但仅仅判断是否访问了空指针还不够,还得要想法让程序从内存违例的异常中恢复正常才有意义。

我们先看一下常规操作是怎么规避空指针风险的,为了便于说明问题,可以设想这样一个例子,假设有一个函数,它的功能是统计一个整型指针数组的各个数组成员,并计算它们所指向的整数值的和。
如下所示:

int sum(int **array, size_t num) {
	int i = -1;
	int sum = 0;

	while (++i < num) {
		if (array[i] == NULL) {
			continue;
		}
		sum += *array[i];
	}

	return sum;
}

int main() {
	int x=1, y=2, z=3;
	int *array[4];
	array[0] = &x;
	array[1] = NULL; // 空指针 
	array[2] = &y;
	array[3] = &z;
	
	int s = sum(array, 4);
	printf("sum=%d\n", s);
	puts("exit main");
}

sum() 的参数 array 数组,它里面存放的数据成员是整型指针,因为它是作为输入参数由外面传入进来的,不能保证里面没有空指针,为了加固程序的健壮性,一般会进行防御性编程,比如在每次解引用指针前,先判断是否是空指针,如果是,就忽略不计。

因此,在 sum() 函数内的 while 循环中,需要每次判断从数组取得的元素是否是空指针。

if (array[i] == NULL) {
	continue;
}

虽然传入的数组参数包含空指针的概率极低,但为了安全起见,这个过程仍不得不进行。概率很低,但又不得不用,就像一块狗皮膏药一样贴在那儿,而且几乎就是全程在做无用功,那么,在保证代码安全的前提下,有没有方法来去掉这个发生概率很低的逻辑判断?

如果要达到这个目的,一个是要能够检测到空指针,显然可以使用前面介绍的 SIGSEGV 信号处理机制来实现,另一个是检测到空指针之后,能让程序跳过这个指针,让程序不再访问它就行了。当然,检查空指针不能在while循环中, 如果每次循环都有额外的开销,还不如直接使用if语句判断呢!

可见,方案的关键使用一定的方法跳过这条空指针,即如何在信号 handler() 中来通知函数 sum() 遇到了空指针,在下一次循环时跳过这个空指针?Linux 系统中为信号机制提供了一对函数:siglongjmp() 和 sigsetjmp(),它们实现了信号处理程序的流程进行非局部跳转的功能(所谓非局部跳转是指可以从一个函数内直接跳转到另一个函数的内部某处位置)。可以在检测到空指针时,使用它们让程序指令跳转到预定的目标地址,从而跳过那段访问空指针的代码,接着使用 C++ 的异常机制,通过 throw 一个异常的方式通知上层调用模块。

修改代码如下:

class invalid_ptr {
	const char *message;
public:
	invalid_ptr(const char *msg) : message(msg) {
	}
	
	const char *what() const {
		return message;
	}
};

static sigjmp_buf jmpbuf;
static volatile sig_atomic_t jumpok = 0;

static void handler(int signo) {
	if (jumpok == 0) return;
	puts("meet the invalid ptr");
	siglongjmp(jmpbuf, 1);
	puts("nerver print this message");
	return;
}

int sum(int **array, size_t num) {
	typedef void (*sighandler_t)(int);
	sighandler_t old = signal(SIGSEGV, handler);

	if (sigsetjmp(jmpbuf, 1)) {
		puts("return to the main loop after skip the invalid pointer");
		signal(SIGSEGV, old);
		throw invalid_ptr("exception: dereference a invalid pointer!");
	} else
		jumpok = 1;

	int sum = 0;
	for (int i = 0; i < num; i++) {
		sum += *array[i];
	}

	signal(SIGSEGV, old); // 恢复旧的处理方法
	return sum;
}

int main() {
	int x=1, y=2, z=3;
	int *array[4];
	array[0] = &x;
	array[1] = NULL; // 空指针 
	array[2] = &y;
	array[3] = &z;
	
	try {
		int s = sum(array, 4);
		printf("sum=%d\n", s);
	} catch (const invalid_ptr &ex) {
		cout << ex.what() << endl;
	}
	
	puts("exit main");
}

程序运行结果如下:

meet the invalid ptr
return to the main loop after skip the invalid pointer
exception: dereference a invalid pointer!
exit main

该程序的关键在于siglongjmp() 和 sigsetjmp()的组合使用,sigsetjmp(jmpbuf, 1) 用来设置程序跳转的目标处,调用它时,会把此处的上下文信息保存在 jmpbuf 参数中,并返回 0,说明不是从 siglongjmp() 跳过来的。当发生 SIGSEGV 异常时,调用信号处理程序 handler,它调用 siglongjmp(jmpbuf, 1) 时不再从handler中返回,而是直接跳转到 jmpbuf 参数保存的目标地址处,也就是 sigsetjmp() 的返回处,同时让第二个参数1从 sigsetjmp() 处作为它的返回值,程序流程转到此处继续运行,因为返回值不为 0,说明是从 handle r跳过来的,即发生了 SIGSEGV 异常,抛出 invalid_ptr 异常。

上述例子虽然程序遇到异常没有让程序崩溃,只是粗暴的放弃,比较生硬,并没有任何恢复的逻辑操作。有没有更好的方案呢?比如检测到空指针之后,不是直接抛异常,而是能让程序跳过这个指针,从下一个数组成员开始,显然这是比较好的一种方案,也就是能从异常中恢复正常。

修改代码如下:

int sum(int **array, size_t num) {
	typedef void (*sighandler_t)(int);
	sighandler_t old = signal(SIGSEGV, handler);

	volatile int i=-1;
	volatile int sum = 0;

	if (sigsetjmp(jmpbuf, 1))
		puts("return to the main loop after skip the invalid pointer");
	else
		jumpok = 1;

	while (++i < num) {
		sum += *array[i];
	}

	signal(SIGSEGV, old); // 恢复旧的处理方法
	return sum;
}

int main() {
	int x=1, y=2;
	int *array[4];
	array[0] = &x;
	array[1] = NULL; // 空指针 
	array[2] = &y;
	array[3] = (int *)(0x12345678); // 模拟一个野指针 
	
	int s = sum(array, 4);
	printf("sum=%d\n", s);
	puts("exit main");
}

编译并执行这段代码,输出的 log 如下:

meet the invalid ptr
return to the main loop after skip the invalid pointer
meet the invalid ptr
return to the main loop after skip the invalid pointer
sum=3
exit main

根据前面的分析,当发生了 SIGSEGV 异常之后,程序流程跳转到 sigsetjmp() 的返回位置,然后继续执行,此时,数组索引再次加1之后,刚好跳过了空指针的位置,也就忽略了此处的空指针。

在main()函数中,还专门模拟了一个“野指针”,即array数组中的第四项:array[3] = (int *)(0x12345678); 加上第二项空指针array[1] = NULL;,程序运行时会发生两次内存段错误,一次空指针,一次野指针,经过信号处理程序接收 SIGSEGV 信号和 siglongjmp 跳转,可以跳过这两个错误项。从输出的 log 中也可以看出,程序先后两次遇到了无效指针的错误,都被程序正确处理了,两个有效的数组元素参与了计算,计算结果 sum=3,正好是这两个元素所指整数的和。可见,该方案同第一个方案相比,它的功能还有所增强,可以判断出某种形式的野指针,并忽略它;同第二个方案相比,遇到错误不是粗暴地把结果丢弃,而是忽略无效的数据,显然这样更符合函数的目的,达到了第一种方案的目的。其次,不依赖于特定语言的异常机制,像C语言也可以使用此方案,调用者对发生异常是无感的,不像前面,还得要使用 try…catch 来捕捉。

程序的核心功能是 while 循环块,代码非常利索,没有了判断空指针的逻辑,去掉了那块狗皮膏药,也没有增加任何开销。当然准备和收尾工作还是有开销的,但它们只执行一次,而开销最大的核心功能部分降低了的开销,但前面的方案,不管有没有空指针,每次循环时都有进行一次空指针判断的开销。如果不发生内存段错误事件,每次循环操作它都会痛痛快快地执行完,毫不拖泥带水,中间没有任何逻辑判断,也就没有跳转指令来打断指令流水线,只有当发生了内存段错误(当然概率很低),才会有额外的开销。如果发生了内存段错误,会触发信号的处理机制,并通过 siglongjmp 跳转到一个正确的位置继续执行,相当于程序在运行过程中发生异常后跑飞了,siglongjmp 又把它拉回到正常的 sigsetjmp 轨道上,避免了程序崩溃。

当然,为了说明问题,这个例子中有许多指针在循环中遍历,如果只有一个指针被访问,直接使用 if 语句来判断空指针显然是最简单的方案。何况这个例子使用了 sigsetjmp 和 siglongjmp,让程序从一个函数的内部直接跳转到另一个函数的内部,有点黑科技的味道,违反了结构化编程,代码让人不易理解,而且容易出现 bug。大家可能也注意到了它的局部变量 i 和 sum 都使用volatile修饰了,使用voaltile修饰局部变量在一定程度上有性能损失,如果不加以修饰,编译时如果打开优化选项,如-O2,这些局部变量可能会优化掉,替换为寄存器,当使用 longjmp 跳转时,会用 jmpbuf 里面存放的寄存初始值来设置这些寄存器,导致程序状态不一致。其次,移植性也不好,在Windows、Linix、Unix平台的信号处理机制有一些差异性。因此,并不提倡使用,在本例中,使用逻辑判断空指针是最简单也最容易理解的方法,当然,如果能够保证程序没有错误,在某些应用场合也不失为一种优雅的解决方案。…