关于空指针问题探究

  1. 关于空指针
  2. 空指针缘起
    1. 最简单的空指针问题
    2. 函数参数所引起的争议
    3. 指针的使用浅谈
    4. 堆栈——坚强而又脆弱
    5. 乱序与并行

关于空指针

对初学者来说,空指针是如梦魇般折磨每个人身心的老大难问题,对于老鸟来讲,空指针也是如针般扎在人心中的一根刺,仿佛永远也拔不掉似的。

空指针问题对于linux开发者来说可以说是一个不可避免的问题,没有一个人敢说在开发中不会遇到空指针所导致的问题。很多时候当出现空指针问题时,可能就会成为你苦思冥想都很难找到问题根源的毒瘤。因此每当谈到空指针,可以说很多人都是畏之如虎。

但在这儿,我们就做做那明知山有虎,偏往虎山行之事,来好好捋捋空指针问题发生的时机及其根源。

空指针缘起

最简单的空指针问题

引起空指针问题最直观的表现就是对空指针直接进行读或写所造成程序段错误,若追究到系统内核,那就是由缺页所造成的系统性错误。我们可以最直接地模拟出空指针所造成的问题,如下代码所示:

1
2
void *p = NULL;
*p = 1;

上面同时涉及到了对空指针的读和写,如果一个程序中出现了如上代码,那么必然会立即导致程序崩溃。虽说程序的崩溃对系统而言只是一个小小的异常得到了解决,但对应用开发者而言却是致命的,这是影响程序运行的稳定性和安全性的最大隐患。因此对于这类问题来说,往往是一旦出现,就必须彻底找出其根本原因的,否则就会像随时会终结你性命的魔鬼一样,偷偷潜伏着系统中,可能在关键时刻,就会造成不可估量的损失。

当然像这样直观而且易于追踪的空指针问题都是非常容易解决的,如果空指针问题都是这么简单,那么这也不会成为我们今天所讨论的对象了。

函数参数所引起的争议

函数参数是我们理解函数功能所必需关注的部分,也是函数功能实现所不可或缺的部分。对于特定的函数,我们深刻理解每个参数所代表的含义以及取值范围是我们能够充分利用函数已实现的功能来完成我们自己的功能的必然条件。

很多常见bug的发生都是出现在我们对于函数及其参数的理解不深刻不充分所造成的误用导致的。比较常见的一个例子如strlen函数的使用:

1
2
3
4
5
6
7
8
9
10
void do_something(const char *s) {
int n = strlen(s);
...
}

int main() {
char *s = "helloworld";
...
do_something(s);
}

我们都知道strlen函数需要一个字符串指针做参数,但如果不能理解若指针为空时会出现什么情况而不做任何判断处理的话,那么可能在某些情况下就会导致程序崩溃,甚至是由于编程时的疏忽导致传入异常的参数。

因此一个好的程序猿通常都会对传入参数做合法性校验,这在很多地方都是必须的。但是这又造成了一些争议,有人认为任何一个函数都必须做参数的合法性校验;另一些人认为只需在关键节点做合法性校验。当然,对于这个争议在不同的场合有不同的答案。比如一个设计结构清晰,接口规范的系统,往往能够使得很多函数的合法性校验限制在函数调用的顶层接口处,这样不仅减少了冗余代码,而且或多或少也有利于性能提升。但是对于各调用关系复杂混乱的函数蔟来说,尽量做到函数自身内部的合法性校验是必不可少的。

到这里,我们可以明白一个问题,空指针异常也可能是由于函数参数传递不合理所导致的,因为函数参数的实际来源异常复杂,因此在碰到这样的问题时往往会追溯到函数调用栈的上面很多层。这看似是一个复杂且不可控的追踪过程,因为很多时候参数可能来源于其它系统,但实际上却是由于编程时缺乏参数的合法性校验所造成的。因此一个完整的无bug的系统,必然包含健全且完善的合法性校验,即不显冗余,也不会遗漏,是考虑到了参数输入的所有可能情形的系统。

但要做到那么一个健全完善的系统对于程序猿的要求也是比较高的。要做到理论可验证的完全无错的系统,在目前来说,很多都是在实验室开发中,即使商业上很成功的系统,其内部存在的各种未知bug也是非常多,很多只是现阶段还未暴露罢了,就像芯片设计上关于分支预测与Cache缓存上面同样也存在bug,但过去几十年都未暴露,但如今仍然暴露了出来。

因此要避免空指针问题,我们在做到了不犯基本的直接对空指针进行操作这样的操作外,还应该充分地做到对使用的函数的参数进行理解,避免因误解而造成的函数滥用误用,同时也要在必要的地方做好参数合法性校验,避免来源未知的参数因不可控导致传入错误参数而造成系统全面崩溃。

指针的使用浅谈

指针的使用非常灵活,在c语言中几乎无处不在,无时不有,但是一旦使用不当却会造成非常严重的后果。曾经在一个项目中就曾因为指针使用不当而造成非常奇怪的bug而困扰很久。大概如下:

一个链表管理一组资源,在程序的另一处由于将int型指针步进大小与char型指针混用了,导致链表在程序运行一段时间后总是会莫名出现空指针或无效指针而导致程序崩溃。

对于上面的问题,一开始出现空指针时检查过问题发生点的所有参数以及代码逻辑,却完全看不出异常,甚至增加空指针判断后,崩溃问题虽然延后发生,但仍然会崩溃,而崩溃的原因也不再是空指针,而是无效的非空指针。

面对这样的情况,问题的筛查似乎陷入了停滞,最后不得已将所有代码全面review了好几次,才发现问题的关键。

因此指针虽然方便好用,但也要谨慎对待,不要产生误解而误用,否则将会使得问题变得越加复杂。

堆栈——坚强而又脆弱

堆栈在系统的概念上是一段连续且向下扩展的地址空间,在程序的概念上,是存储临时变量和函数返回地址的唯一地方。堆栈的设计巧妙地解决了参数传递和函数调用栈回朔问题,其就像是支持各种函数的坚强堡垒,永远屹立于系统之中,在需要的时候总是能发挥其巨大的作用。但是如果我们对系统,对进程,对堆栈抱着无条件信任的态度的话,现实可能将会狠狠地扇下耳光。

堆栈溢出,这个词对于很多初学者来说可能非常陌生,但是当你经历过一次又一次的程序崩溃,一遍又一遍的代码review,一轮又一轮的调试过后,仍然被程序崩溃的bug所折磨得无所适从时,可能就会理解吃透堆栈的原理是多么重要。

我们知道系统中每个线程都有其对应的堆栈,而堆栈空间默认情况是有限的,当在编程不注意这点导致堆栈溢出,在内存空间足够大的情况下,可能很难看出其不良影响,但在内存空间有限的场合,缺失很容易复现那种全局变量值被莫名修改,函数参数莫名变成空指针等这样的奇怪问题。

通常在linux下,我们可以用如下指令查看关于堆栈大小限制相关的信息:

1
2
3
4
5
6
7
8
9
10
11
12
# ulimit -a
core file size (blocks, -c) 0
data seg size (kbytes, -d) unlimited
file size (blocks, -f) unlimited
max locked memory (kbytes, -l) unlimited
max memory size (kbytes, -m) unlimited
open files (-n) 256
pipe size (512 bytes, -p) 1
stack size (kbytes, -s) 8192
cpu time (seconds, -t) unlimited
max user processes (-u) 1392
virtual memory (kbytes, -v) unlimited

乱序与并行

关于乱序与并行所导致空指针的问题,在应用程序设计中主要还是关注编译乱序,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct test {
int a;
int b;
int c;
};
struct test *gt = NULL;

void xxx_write(...) {
...
p = kmalloc(sizeof (*p), GFP_KERNEL);
p->a = 1;
p->b = 2;
p->c = 3;

gt = p;
...
}

void xxx_read(...) {
...
p = gt;
if (p != NULL) {
function(p->a, p->b, p->c);
}
...
}

对于c语言顺序的“p->a = 1;p->b = 2;p->c = 3;gt = p;”而言,经过编译后gt的赋值可能发生在a,b,c的赋值之前。这是编译器的乱序优化能力造成的。编译器可以对访存的指令进行乱序,减少逻辑上不必要的访存,以尽量提高Cache的命中率和CPU的LOAD/STORE的执行效率。因此,如果在打开编译优化的选项后,其生成的汇编及机器码是乱序的这是正常的

但是对于应用程序的正确执行而言,这却成为了一个随时会爆炸的定时炸弹。因为这在多线程程序中如果xxx_write与xxx_read发生并行执行的情况,这可能会导致程序在function那儿发生空指针异常出现程序崩溃,可能对于大多数开发者来说,关于编译乱序所造成的问题并不是很了解甚至是往往直接将其忽视掉,但这实际上却是一个不能被忽视的问题,这也是某些程序在打开编译优化之前执行结果是正确的,但打开编译优化后却立马出现程序崩溃等问题的根源,甚至可能在程序测试阶段这问题很难被被测试出来,而当产品上线后,却发生问题,这可能导致项目的失败,因此知道,理解,甚至掌握编译乱序与并行执行所可能引发的空指针问题甚至是其它问题是有用且必要的。

解决编译乱序问题,可以使用编译屏障barrier()。在代码中设置屏障barrier()可以保证屏障前的语句和屏障后的语句不会乱序。

1
#define barrier() __asm__ __volatile__("": : :"memory")

其中编译屏障的使用方法如下:

1
2
3
4
5
6
7
8
p = kmalloc(sizeof (*p), GFP_KERNEL);
p->a = 1;
p->b = 2;
p->c = 3;

barrier();

gt = p;

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 yxhlfx@163.com

文章标题:关于空指针问题探究

本文作者:红尘追风

发布时间:2018-01-15, 12:15:53

原始链接:http://www.micernel.com/2018/01/15/%E5%85%B3%E4%BA%8E%E7%A9%BA%E6%8C%87%E9%92%88%E9%97%AE%E9%A2%98%E6%8E%A2%E7%A9%B6/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录