一、老生常谈内存违例
内存问题已经在不同的场合不同的文章下反复多次的不断的重复和强调。但仍然觉得这种问题再怎么强调和重复都不为过。这次重点分析说明一下内存违例,Memory Access Violation。违例或违规,从名称就可以知道什么意思,不能干不能做的事,程序做了。做了就要受到处罚啊,结果就是各种异常甚至有可能整体系统蓝屏崩溃。
回到内存违例,就是程序试图操作没有操作权限的内存地址。这必然引起保护措施的异常或陷阱处理,然后就没有然后了。当然,这话也不是绝对的,对于一些木马或病毒可能隐藏的比较好,然后又有适当的手段(如提权等),导致程序仍然可以正常的运行。不过,这里不谈这种问题。
二、内存违例的主要情况
以前在分析内存问题时,对于内存安全的方面只是一语带过,或者只是举个例子,这次较全面的整理一下内存违例的情况:
- 空指针的操作
这种比较典型,比如在使用指针时,未进行空指针判断,导致直接操作指针引起程序的崩溃。类似于下面的代码:
c
A *a = nullptr;
a->setData();
//或是下面的代码
auto b = *a;
- 野指针(悬垂指针)的操作
其实野指针和空指针的使用有着雷同的情况,一个是没有内容,一个是内容已经消失。类似下面的代码:
c
A* a = new A();
delete a;
a->setData();
- 数组越界
这类问题看似简单,但实际应用中出现的频率相当高。主要原因在于各种细节的把控很难到位。比如通过宏定义数组长度,结果后来有维护者改小了。但这个数组经常是应用不到最大长度。结果就是可能在某些特定场景下到达最大长度了。就会数组越界。当然对于新手还有一个问题,就是没有准确理解数组索引是从0开始的,在循环中,直接操作到了数组的长度大小。还有一种常见的现象是数组退化为指针后,没有传入长度到函数参数中,导致函数内应用越界。类似下面的代码:
c
int a[10]={0};
for (int i = 0;i<= 10;i++){
a[i] = i;
}
- 栈溢出
其实也可以理解成数组越界,这种比较常见的是递归失控。而普通的数组越界的栈溢出比较少见。类似下面的代码:
c
void recursiveFunc(){
recursiveFunc();
}
- 多线程未同步操作内存
典型的就是多个线程同时访问一个对象而没有加以控制,类似下面的代码:
c
int g_shared = 0;
void Add() {
for (int i = 0; i < 1000*1000; i++) {
g_shared++; //可能内存访问违例
}
}
thread t1(Add);
thread t2(Add);
- 访问未分配或无权限内存
这种一般是失误比较多,比如误操作了++或--之类的内存指针行为,导致传递到函数内的指针出现问题,或者干脆是直接写错了地址,进入了系统内存范围,结果就显而易见了。
c
int * p = 0x100;
void test(int *p){
*p = 100;
}
三、调试和检测方法
如果出现了上述的情况,解决问题的方法主要是使用调试器或使用相关的工具检测:
-
使用调试器
以gcc为例,在编译时增加-g和-fsanitize=address(AddressSanitize)选项,这样就比较容易解决常见的一些内存违例现象。-g提供符号信息而后者AddressSanitize则是提供内存错误检测包括堆栈溢出及内存错误等。
同时,在编写代码时也可以自定义一些assert来进行处理,这样既保证了可调试性又不影响发布版的效率。
-
使用一些内存检查工具如cppcheck、Valgrind等
这些工具可以帮助开发者较快较准确的定位一些内存的问题
-
自定义一些内存检查方法
比如编写一些带有内存检查的自定义容器类等,都可以处理这些问题。这个最典型的就是C++11未正式普及前有不少牛人写的智能指针。网上这种代码特别多,大家可以有针对性的学习和应用。
四、应对方法
针对上述的内存违例的情况,在编程中是有应对方法的。主要包括:
- 裸指针的操作控制
指针必须初始化,并总是在应用前检查指针是否为空;delete或free释放内存后,应马上将指针置为nullptr,避免出现悬垂指针 - 使用智能指针
这个就不必展开说明了吧 - 边界检查
对数组操作时确保索引在有效范围内 - 递归控制
避免过深的递归调用;另外还要避免在递归调用或函数的嵌套调用中在栈上分配较大的对象或数组 - 尽量避免使用C类型的字符串处理
对于字符串处理应尽量使用C++的std::string,尽量避免使用使用char来操作,且如无必要应该使用const char来声明,避免修改 - 多线程中注意同步的应用
多线程环境中,应当配合使用互斥锁等同步机制来保护共享数据的并发操作 - 尽量使用更高的C++标准
比如一些更加安全容器或算法处理等 - 使用一些C++开发的技巧
最典型的就是RAII,将内存资源置于安全的可控范围内。这也是标准库中常用的方法。
五、总结
内存问题的表象可能千奇百怪,但深究进去,其实就那么几种。至于从表象如何最终映射到那几种情况,可能需要开发者大费周章。至于如何能够少费些周章,除了经验和知识外,安全的标准和框架非常重要。甚至,有些大佬可能觉得无可救药,另起炉灶,也不是没有可能。