空指针解引用引起程序奔溃是c/c++中最常见的稳定性错误之一。
显然并非所有使用空指针的语句都会导致奔溃,那什么情况下使用空指针才会引起程序奔溃呢?有一个判断标准:判断空指针是否会导致访问非法内存的情况,如果会导致访问非法内存就会奔溃,否则不会奔溃。
常见的空指针操作
考虑下面的代码,用到空指针test
的6条语句(#1
~#6
)中哪些会引起程序奔溃?
cpp
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
这些语句的内存访问情况,会涉及到一些c++底层知识,也是本文的主要内容。
深入理解
在详细分析之前先看看Test
类的内存结构
注意:虚函数表和虚函数表指针不是必须的,只有定义或者继承了虚函数的类型才会分配这两块内存。
内存分为两部分(可以结合进程的内存结构和ELF文件结构来理解 ):
● 静态内存 :编译阶段确定地址的内存,与实例无关且全局只存在一份。如静态变量、虚函数表、代码段。
● 动态内存 :运行阶段才能确定地址的内存,与实例绑定。如成员变量、虚函数表指针(虚表指针实际上也是成员变量,特殊在它是由编译器添加的)。
所以用到空指针test
的6条语句本身访问内存情况如下
编号 | 操作 | 访问符号 | 符号类型 | 符号地址 | 备注 |
---|---|---|---|---|---|
Test* test = nullptr; |
- | - | - | - | |
#1 | Test copy = *test; |
test |
指针类型局部变量 | - | - |
#2 | int value = test->value; |
Test::value |
成员变量 | 0x8 | value 相对Test 首地址的偏移量为8字节,因此地址为 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(); |
虚函数表指针 | 指针类型成员变量 | 0x0 | 虚函数表指针 相对Test 首地址的偏移量为0字节,因此地址为 0x0 + 0 = 0x0 |
↑ | ↑ | 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,因此会引起程序奔溃。