数组越界
例子
C/C++ 中的内置数组没有任何边界检查,这使得访问数组的时候必须十分小心,如果出现越界访问,那么行为将是不可确定的。这里我们展示一个越界访问的例子:
{% highlight cpp linenos %}
#include <iostream>
int main(int argc, char *argv[]) {
int array[5] = { 0 };
for(int i = 0; ; ++i) {
std::cout << i << '\t' << array[i] << std::endl;
}
return 0;
}
{%endhighlight%}
解说
代码中声明了一个大小为 5 的数组,为了观察访问越界的行为,我们用一个循环进行越界访问,因为打印出来的结果可能会很长,所以这里只说下结论:
- 对于前五个元素,打印结果是正常的,正如我们初始化的一样,array[i] 为 0 .
- 对于第五个之后的元素,一般来说,会打印出一个不确定的整数,直到在打印某个元素的时候,出现了 "segmentation fault".
我们用 gdb 可以看到 segmentation fault 出现的堆栈情况,下面是我一次实际运行的情况:
Program received signal SIGSEGV, Segmentation fault.
0x0000000100001214 in main (argc=1, argv=0x7fff5fbffb70) at eg1.cpp:6
6 std::cout << i << '\t' << array[i] << std::endl;
(gdb) where
#0 0x0000000100001214 in main (argc=1, argv=0x7fff5fbffb70) at eg1.cpp:6
(gdb) p i
$1 = 140604
(gdb)
可以看到,这里 i 的值已经相当大了,所以这里需要强调的就是,数组越界不是必然导致段错误,有时候只是访问到了一些脏数据,这会导致重现该段错误的重现可能会很困难,配置好环境以便在出现段错误的时候能够保存有检查错误用的 core dump 文件非常重要。
空指针
例子 1
代码
{% highlight cpp linenos %}
int main(int argc, char argv[]) {
int p = NULL;
std::cout << *p << std::endl;
return 0;
}
{%endhighlight%}
解说
上面是一个最小的空指针异常的例子,当我们尝试对一个空指针解引用时,就会导致段错误。用 gdb 查看崩溃时栈信息如下:
Program received signal SIGSEGV, Segmentation fault.
0x0000000100000d09 in main (argc=1, argv=0x7fff5fbffb70) at eg2.cpp:6
6 std::cout << *p << std::endl;
(gdb) p p
$1 = (int *) 0x0
(gdb)
例子2
代码
{% highlight cpp linenos %}
#include <iostream>
class Test {
public:
void nonVirtualFunc() {
std::cout << "non virtual function called" << std::endl;
}
void nonVirtualFuncWithStaticMember() {
std::cout << "non virtual function with static member called, " << static_number << std::endl;
}
void nonVirtualFuncWithMember() {
std::cout << "non virtual function with member called" << non_static_number << std::endl;
}
virtual void virtualFunc() {
std::cout << "virtual function called" << std::endl;
}
static void staticFunc() {
std::cout << "static function called" << std::endl;
}
int non_static_number {0};
static int static_number;
};
int Test::static_number = 0;
int main(int argc, char argv[]) {
Test p = NULL;
p->staticFunc(); // ok
p->nonVirtualFunc(); // ok
p->nonVirtualFuncWithStaticMember(); // ok
p->nonVirtualFuncWithMember(); // error
p->virtualFunc(); // error
return 0;
}
{%endhighlight%}
解说
这个例子考虑类 Test 的对象空指针在什么情况会导致段错误。这里分为 5 种情况:
- 访问类的静态成员函数. 对应函数 staticFunc.
- 访问类的成员函数,但是成员函数不访问类的成员. 对应函数 nonVirtualFunc.
- 访问类的成员函数,成员函数访问类的静态成员. 对应函数 nonVirtualFuncWithStaticMember.
- 访问类的成员函数,成员函数访问类的普通成员. 对应函数 nonVirtualFuncWithMember.
- 访问类的虚函数. 对应函数 virtualFunc.
相应的情况标注在代码中,error表示会出现段错误。从原理上说,是否会出现段错误,关键是是否访问了指针所指向的内存。细分来说,有以下几点:
- 静态成员是独立于每一个类对象存在的,所以只访问静态成员或者调用静态函数不会导致段错误。
- 非虚成员函数,如果不访问类成员,也不需要访问类对象内存,不会导致段错误。
- 如果访问了类成员,必然导致段错误。
- 如果成员函数是虚函数,那么由于需要访问对象的虚函数表,也需要访问类对象内存,所以不管虚函数本身是否访问了类成员,都会导致段错误。
prinf
问题
printf/sprinf 一类与字符串格式化相关的 C 语言风格函数即使在 c++ 中也常有应用. 也有很多日志库也以类似的风格设计,于是就出现一个问题,如果在格式化中,原本应该是一个字符串的地方放置了一个整形,或者是一个整形的位置放置了一个字符串,会出现什么情况。比如以下例子:
{% highlight cpp linenos %}
#include<stdio.h>
int main(int argc, char *argv[]) {
int i; //不赋值,表示 i 可以为任意整形数
printf("%s", i);
return 0;
}
{% endhighlight %}
解说
类似问题中的例子,出现的行为是不确定的,因为 i 会被强制转型为一个 char* 来解释,随着 i 的值的不同,行为会有所不同.
- 如果 i 所指向地址可以访问,则打印该地址解释为字符串之后的内容。当然,也有可能地址可以访问,但是无法解释为 c 风格的字符串,也可能产生段错误。
- 如果 i 指向地址不可访问,则产生段错误。
除了以字符串形式去解释整形,printf 还可能出现其他错误:
- 以整形解释字符串,应该出现 %s 的地方出现了 %d (或者其他整形的格式符).
- 误用了不同长度的整形的描述符,该用 %ld 的地方使用了 %d,或者相反.
- 格式符和参数数量不匹配。
对于头两种情况,准确的说法,行为都是不确定的,也可能出现段错误,但是大部分时候出现的是打印错误,因为访问到不可访问的内存的可能性较低. 对最后一种情况,如果是格式符多的话,出现段错误可能性就很大了,反过来倒是没什么问题,但也是一种错误。
总结
不管怎样,printf 一类函数误用格式符都是非常危险的。利用编译器的警告可以检查出这种问题,只要不偷懒,这一类问题还是相当容易避免的。如果是自己封装的类似的函数,gcc也有相应的扩展可以检查到(可以搜索 "attribute format" ).