对空指针解引用引发程序奔溃是c/c++中最常见的稳定性错误之一。显然并非所有使用了空指针的语句都会导致奔溃,那什么情况下使用空指针才会导致程序奔溃呢?有一个判断标准:访问非法内存会引起程序奔溃,否则不奔溃。
常见的空指针操作
考虑下面的代码,与空指针test
相关的6条语句(#1
~#6
)中哪些会导致程序奔溃?
struct Test {
void method_01() { }
virtual void method_02() { };
int value;
static void StFunction() { }
static int stValue;
};
int Test::stValue = 1;
int main() {
Test* test = nullptr;
Test copy = *test; // #1
int value = test->value; // #2
int stVAlue = test->stValue; // #3
test->StFunction(); // #4
test->method_01(); // #5
test->method_02(); // #6
}
答案如下
序号 | 代码含义 | 是否会引起程序奔溃 |
---|---|---|
#1 | 对指针取值 | 是 |
#2 | 通过指针访问成员变量 | 是 |
#3 | 通过指针访问静态变量 | 否 |
#4 | 通过指针调用静态函数 | 否 |
#5 | 通过指针调用成员函数 | 否 |
#6 | 通过指针调用虚函数 | 是 |
面对这个答案可能有一些疑问,比如为什么通过空指针test
访问成员变量会奔溃而访问静态变量不会奔溃?为什么通过空指针test
调用静态函数和非虚成员函数不会奔溃而调用虚函数会奔溃?
原因隐藏在“对空指针解引用会引发程序奔溃”这句话的关键词解引用里。怎么理解引用呢?可以简单理解为访问与指针有关的内存地址。
抛开“对空指针解引用会引发程序奔溃”不谈,从程序运行的角度来看,引起程序奔溃的本质是访问了非法内存。所以空指针是否会引起程序奔溃的一个判断标准总结为:访问非法内存会引起程序奔溃,否则不奔溃。接下来对#1
~#6
逐一分析验证这个总结的正确性。
深入理解
在详细分析之前先看看Test类的内存结构
注意:虚函数表和虚函数表指针不是必须的,只有定义或者继承了虚函数的类型才会分配这两块内存。
与类型Test
有关的内存有两部分:
● 静态内存:编译阶段确定地址的内存,与实例无关且全局只存在一份。如静态变量、虚函数表、代码段。
● 动态内存:运行阶段才能确定地址的内存,与实例绑定。如成员变量、虚函数表指针(虚表指针实际上也是成员变量)。
所以空指针test
的6条语句访问内存情况如下
编号 | 操作 | 访问符号 | 符号类型 | 符号地址 | 备注 |
---|---|---|---|---|---|
Test* test = nullptr; | - | - | - | - | |
#1 | Test copy = *test; | test | 指针类型局部变量 | - | - |
#2 | int value = test->value; | Test::value | 成员变量 | 0x8 | value 相对Test 首地址的偏移量为8字节,因此value 的地址为 0x0 + 8 = 0x8 |
#3 | int stVAlue = test->stValue; | Test::stValue | 静态变量 | 固定地址 | 编译阶段分配好的地址,与test 指针无关 |
#4 | test->StFunction(); | Test::StFunction | 静态函数 | 固定地址 | 编译阶段分配好的地址,与test 指针无关 |
#5 | test->method_01(); | Test::method_01 | 非虚成员函数 | 固定地址 | 编译阶段分配好的地址,与test 指针无关 |
#6 | test->method_02(); | Test::method_02 | 虚函数 | 固定地址 | 编译阶段分配好的地址,与test 指针无关 |
了解Test
的内存结构之后,分析空指针test
的6条语句是否会引起程序奔溃就变得清晰很多:
#1
取值操作:Test copy = *test;
空指针test
指向的地址是0x0,取值操作*test
访问的是非法内存地址0x0,所以会引起程序奔溃。
#2
访问成员变量:int value = test->value;
test->value
是在访问非法地址0x8,所以会引起程序奔溃。
#3
访问静态变量:int stVAlue = test->stValue;
访问静态变量和静态函数的方式有2种
● 通过实例访问:例如int stVAlue = test->stValue;
、test->StFunction();
● 通过类名访问:例如int stVAlue = Test::stValue;
、Test::StFunction();
两种访问方式的效果是一样的,实际上通过类名访问的方式更常见。本文使用通过实例访问的方式做示例是为了与其他操作做对比。将示例代码访中问静态变量和静态函数的语句替换成通过类名访问的方式后,会发现访问静态变量和调用静态函数的语句与
test
指针本身没有半毛钱关系。
test->stValue
等价于Test::stValue
,这条语句访问的是stValue
的地址而这个地址必然是有效的,与空指针test
没有任何关系,所以不会引起程序奔溃。
#5
调用非虚成员函数:test->method_01();
成员函数的本质
从内存结构上看成员函数和静态函数似乎没有区别,实际上他俩确实没有区别。可以这样理解:c++是比c语言多了很多特性的增强版,成员函数就是其中一个特性,这个特性类似于语法糖,目的是为了简化调用成员函数(一种特殊的函数)的语法。成员函数特殊在第一个形参一定是this指针(隐式形参,不需要明确定义,编译器会在编译阶段补全),所以我们可以把成员函数退化成等价的c风格全局函数,例如
● 定义退化:成员函数void Test::method_01()
可以退化成全局函数void method_01(Test* self)
● 调用退化:调用成员函数test->method()
可以退化成调用全局函数method_01(test)
同样,test->method_01
相当于method_01(test)
,是在访问method_01
的地址而这个地址必然是有效的,虽然入参test
是空指针,但调用函数这条语句本身不会访问这个空指针的内存,因此不会引起程序奔溃。
注意:调用非虚成员函数这条语句本身不会引起奔溃,但由于通常情况下成员函数的实现都会访问成员变量,所以程序可能会在成员函数内部因为解引用空指针this
(也就是入参test
)而奔溃。最常见具有迷惑性的奔溃现场比如
● 构造函数的内部空指针错误 —— 在访问成员变量或者虚函数的语句奔溃;
● 非虚析构函数内部空指针错误 —— 在访问成员变量或者虚函数的语句奔溃;
构造函数和非虚析构函数是特殊的非虚成员函数,在分析奔溃问题的时候可以把他们当作普通的非虚成员函数一样对待。
#6
调用虚函数:test->method_02();
虚函数调用过程
虚函数是c++多态的核心技术(不知道多态是什么的同学出门右转找个角落自己学习一下),保证在继承结构中能正确调用子类的实现。虚函数表、虚函数表指针就是用来完成虚函数调用的,调用虚函数主要有下面几个步骤:
● 通过虚函数指针访问对应的虚函数表;例如Test
的实例的虚函数指针指向Test
的虚函数表;
● 在虚函数表中找到需要调用的函数;
● 调用这个函数;
调用虚函数的情况与调用非虚函数有所不同,test->method_02()
不会直接访问函数method_02()
的地址,而是首先通过虚函数表指针访问虚函数表,在通过空指针test
访问虚函数指针时会访问非法地址0x0,因此会引起程序奔溃。