在智能指针普及之前,很多人使用裸指针,使用裸指针时,如果我们访问内存没有做好恰当的边界检查,就可能导致非法内存访问。随之而来的问题是,为什么程序有时候会因此触发段错误(segmentation fault),有时候却又安然无恙?
今天,我们就借助一个具体的例子来聊聊这个现象。
首先看下面这段C++代码:
#include <vector>
#include <iostream>
void access(const std::vector<int>& vec, size_t offset){
size_t index = vec.size() - 1 + offset;
std::cout << "Accessing index: " << index << std::endl;
int value = vec[index]; // Intentional out-of-bounds access
}
int main(){
std::vector<int> vec(32, 0);
std::cout << "Vector Size: " << vec.size() << std::endl;
access(vec, 1);
access(vec, 10);
access(vec, 100);
access(vec, 1000);
access(vec, 10000);
access(vec, 100000);
}
代码的作用是演示访问 std::vector 越界时可能发生的行为(Undefined Behavior),并尝试通过不断增大越界偏移量来提高触发 segmentation fault(段错误) 的概率。
编译并运行上述代码,可能的输出如下:
Vector Size: 32
Accessing index: 32
Accessing index: 41
Accessing index: 131
Accessing index: 1031
Accessing index: 10031
Accessing index: 100031
Segmentation fault
我们可以看到一个有趣的现象:当程序尝试访问那个长度为32的向量中,索引为32、41、131、1031和10031的元素时,它并没有崩溃。但是,当尝试访问索引为100031的元素时,程序终于因为段错误而崩溃了。
这是为什么呢?关键点在于,并不是所有的非法访问都会被操作系统立刻捕捉。这就涉及到了程序内存分配的边界。
借助 Valgrind 这样的内存调试工具,我们可以获得程序实际分配的内存情况。使用 Valgrind 运行同一个程序,会得到更详细的报告:
Vector Size: 32
Accessing index: 32
==15073== Invalid read of size 4
==15073== at 0x400AAE: access(std::vector<int, std::allocator<int> > const&, unsigned long) (in /home/lj7/a.out)
==15073== by 0x400B3C: main (in /home/lj7/a.out)
==15073== Address 0x5ba6100 is 0 bytes after a block of size 128 alloc'd
...
Accessing index: 41
==15073== Invalid read of size 4
==15073== at 0x400AAE: access(std::vector<int, std::allocator<int> > const&, unsigned long) (in /home/lj7/a.out)
==15073== by 0x400B4D: main (in /home/lj7/a.out)
==15073== Address 0x5ba6124 is 28 bytes before an unallocated block of size 4,120,224 in arena "client"
...
Accessing index: 131
...
Accessing index: 1031
...
Accessing index: 10031
...
Accessing index: 100031
==15073== Invalid read of size 4
==15073== at 0x400AAE: access(std::vector<int, std::allocator<int> > const&, unsigned long) (in /home/lj7/a.out)
==15073== by 0x400B91: main (in /home/lj7/a.out)
==15073== Address 0x5c07b7c is 399,932 bytes inside an unallocated block of size 4,120,224 in arena "client"
...
==15073== HEAP SUMMARY:
==15073== in use at exit: 0 bytes in 0 blocks
==15073== total heap usage: 2 allocs, 2 frees, 73,856 bytes allocated
从 Valgrind 的 HEAP SUMMARY 可以看到,程序总共分配了 73,856 字节的堆内存。假设每个 int 占4字节,这部分内存大约可以容纳 18,464 个整数。这就解释了现象:
- 访问索引 100031 的操作,已经远远超出了程序被分配的这 73,856 字节内存的范围,因此操作系统会介入并触发段错误。
- 而访问索引 32、41、131、1031 和 10031 的操作,虽然对于
std::vector 对象本身来说是越界的,但这些访问的目标地址,恰好还落在程序被分配的那块大内存内部。操作系统只关心程序有没有“越界使用分配给它的整片内存”,并不关心程序内部一个vector的边界。因此,操作系统不会触发段错误。
值得注意的是,Valgrind 检测并报告了所有6次越界访问(索引32到100031),而不仅仅是触发段错误的那一次。这是因为 Valgrind 运行了一个虚拟内存管理器,它能追踪每一个具体对象的分配和释放。它知道向量 vec 只分配了128字节(32个int),所以任何超出这个范围的访问,在 Valgrind 看来都是非法的。
我们可能会想用一些“技巧”绕过 Valgrind 的检查,比如这样写:
float const* ptr{&vec[31]}; ptr[1];
这行代码在效果上等同于 vec[32]。但这样做是徒劳的——因为 Valgrind 追踪的是指针的来源(Provenance),而不仅仅是它的目标地址。Valgrind 会知道 ptr 来源于 vec,因此 ptr[1] 仍然被判定为对 vec 的越界访问。
简单来说,操作系统的内存保护机制作用于整个程序的层面,而 Valgrind 这样的工具则工作在单个对象的层面。因此,后者能更精准地检测出此类非法内存访问。
这个例子清晰地表明:操作系统并不总是会对非法内存访问触发段错误。这完全取决于这次非法访问的地址,是否还停留在操作系统分配给该程序的那片内存区域内。如果超出了这片区域,就会触发段错误,这反而能让开发者警觉问题;但如果仍在这片区域内,就不会触发段错误,程序会继续运行但进入未定义行为(Undefined Behavior)的状态,这种隐藏的问题往往更加棘手,也更难调试。
对C++内存安全和调试技术感兴趣的开发者,欢迎到云栈社区的C/C++板块深入探讨。了解内存模型的底层原理,对于编写健壮的系统程序至关重要,这也是计算机基础知识的重要组成部分。