C++ 段错误(Segmentation Fault)

在使用指针或数组编写 C++ 代码时,很多初学的小伙伴都遇到过这样一种情况:代码编译时没有任何报错,程序一运行却突然中断退出。此时终端可能会直接报出一句 Segmentation fault,也就是常说的 “段错误”。

第一次碰到这种不提示行号的崩溃时,大家很容易不知道应该从哪里查起。其实段错误并没有那么神秘。只要我们先弄清楚哪几类操作会触碰非法内存,再顺着函数调用关系追查,很快就能找出问题根源。

什么是段错误?

段错误的英文全称是 Segmentation Fault,有时也简称为 Segfault。简单来说,段错误就是程序越界去读写了不属于自己的非法内存区域,结果被操作系统强制终止了。

在不同的操作系统中,段错误的报错表现不太一样。在 Linux、macOS 或在线编译器中,终端通常会直接打印 Segmentation fault (core dumped)。而在 Windows 的 PowerShell 或 CMD 终端下运行 .exe 程序时,屏幕一般不会打印报错文字,程序会直接闪退回到命令行。

你可以把计算机内存想象成一栋公寓大楼。程序启动时,操作系统会分配给它专属的几个房间,也就是合法的内存空间。只要代码在自己的房间里读写数据,程序就能正常运行。

一旦指针闯进 0 号地址这类系统禁区或其他程序的房间,操作系统就会立即把当前程序关停,如下图所示。

用公寓房间理解内存合法读写与越界访问

注意 段错误是操作系统层面的进程崩溃现象,而不是 C++ 标准库里的异常类。在标准 C++ 中,try...catch 只能捕获通过 throw 抛出的异常对象,无法捕获段错误。

引发段错误的 3 类常见原因

在实际开发中,引发段错误的代码大多与指针、下标和对象的存活时间有关,最常见的有以下 3 类原因:

  • 解引用空指针或野指针:指针没有指向任何有效对象,却强行使用 * 或 -> 去读写数据。
  • 数组或容器下标越界:访问元素时下标超出了有效范围,冲进了受保护的非法内存区。
  • 访问已释放或已销毁的内存:对象早已被 delete 释放或离开作用域销毁了,却继续拿着旧地址去读写。

1. 解引用空指针或野指针

在 C++ 中,当指针的值为 nullptr(即空指针)时,表示它当前不指向任何有效对象。如果此时对它使用解引用运算符 * 去读写数据,就会直接引发段错误。

示例 1:解引用空指针

#include <iostream>

int main() {
    int* price = nullptr;
    std::cout << *price << '\n';    // 解引用空指针触发段错误
    std::cout << "程序正常结束\n";
    return 0;
}

运行结果如下。

(报错)Segmentation fault

分析:

在上面代码中,int* price = nullptr; 在语法上完全合法,因此编译能够顺利通过。但程序运行到 *price 时尝试读取空地址里的数据,操作系统会立即终止程序,排在后面的 "程序正常结束" 根本没有机会输出。如果在 Windows 终端下运行,程序则会直接闪退且没有任何输出。

为了避免这类崩溃,当函数的指针参数有可能传入 nullptr 时,我们在使用 * 解引用之前应该先加上判空检查。

示例 2:解引用前先判空

#include <iostream>

void showPrice(const int* price) {
    if (price == nullptr) {
        std::cout << "指针为空,取消读取\n";
        return;
    }
    std::cout << "商品价格:" << *price << '\n';
}

int main() {
    int value = 99;
    showPrice(&value);
    showPrice(nullptr);
    return 0;
}

运行结果如下。

商品价格:99
指针为空,取消读取

分析:

在这个例子中,showPrice() 在解引用指针前先通过 if (price == nullptr) 检查指针是否为空。第 2 次调用传入 nullptr 时,函数打印提示后直接 return 返回,避开了非法的 *price 操作。

除了空指针之外,只声明却没有初始化的野指针里面保存的是随机垃圾地址,直接解引用同样很容易引发段错误。因此定义指针时如果暂时没有明确指向的对象,我们应该先将它初始化为 nullptr。

// 错误方式
int* p;
*p = 100;

// 正确方式
int* p = nullptr;

2. 数组或容器下标越界

无论是普通数组,还是标准库的 vector 容器,在使用方括号 [] 访问元素时默认都不做下标边界检查。如果因为循环条件写错或下标计算失误,访问了一个跨度很大的越界下标,就会冲出合法内存区并触发段错误。

示例 3:大跨度下标越界

#include <iostream>

int main() {
    int nums[4] = {10, 20, 30, 40};
    std::cout << nums[100000] << '\n';    // 访问大跨度越界下标
    return 0;
}

运行结果如下。

(报错)Segmentation fault

分析:

nums 数组只有 4 个元素,有效下标是 0 到 3。代码直接读取 nums[100000],计算出的内存地址远远超出了当前栈空间的范围。一旦触碰到操作系统的受保护内存区,程序就会直接崩溃。

如果下标来自外部输入或动态计算,可以把普通数组换成 std::vector,并改用带边界检查的 at() 成员函数访问元素。这样一旦越界就会抛出可捕获的 std::out_of_range 异常,而不是直接读写非法内存。

3. 访问已释放或已销毁的内存

第 3 类常见原因是使用悬空指针。也就是说,指针原本指向的对象已经销毁、内存已经被回收了,但指针变量里还留着以前的旧地址。此时再去读写这块内存,就相当于 “房间已经退租收回了,还拿着旧钥匙开门进去”。

最典型的两种情况是:函数返回了内部局部变量的地址,或者 delete 释放动态内存后继续解引用原指针。下面我们先来看返回局部变量地址的情况。

示例 4:返回局部变量的地址

#include <iostream>

int* createNumber() {
    int num = 100;
    return &num;                // 返回局部变量地址
}

int main() {
    int* p = createNumber();
    std::cout << *p << '\n';    // 访问已销毁的栈内存
    return 0;
}

运行结果如下。

(报错)Segmentation fault

分析:

createNumber() 内部的 num 是一个局部变量,一旦函数执行完毕返回,它占用的栈内存就会被自动回收。main() 拿到的指针 p 指向的是已经失效的内存,在主流编译器下执行 *p 就会触发段错误崩溃,或者读出不可控的垃圾值。

同样地,对动态分配的内存执行 delete p; 之后,也不能再次访问 *p。为了防止后续误用,手动 delete 之后应该立即把指针置为 nullptr。

// 错误方式
int* p = new int(90);
delete p;
std::cout << *p << '\n';

// 正确方式
int* p = new int(90);
delete p;
p = nullptr;

在实际开发中,为了从源头上减少手动 delete 带来的悬空指针风险,我们更推荐使用 unique_ptr 等智能指针或标准库容器来管理动态内存。此外,编写递归函数时如果漏写了终止条件,无限递归把栈空间耗尽后同样会触发段错误。

段错误与未定义行为的区别

可能有些小伙伴会觉得奇怪:“为什么写 nums[100000] 会直接报段错误,而只越界 1 个位置的 nums[4] 有时却没崩溃,只是打印出一个奇怪的数字?”

这就涉及到 C++ 中的 “未定义行为(Undefined Behavior,简称 UB)”,指的就是代码执行了 C++ 标准明确禁止、结果不再可控的违规操作。除了指针与下标越界外,读取未初始化的局部变量、有返回值的函数漏写 return 语句,也都属于典型的未定义行为。

当代码触发未定义行为时,如果错指的地址刚好碰上了操作系统的受保护内存区,就会直接引发段错误崩溃。如果像 nums[4] 这样轻微越界的地址刚好还在当前程序合法的栈空间内,操作系统不会拦截,程序就会把旁边内存里的随机垃圾值读出来。这几个常见错误概念的区别如下表所示。

三种错误概念对比
概念名称 本质定位 程序表现
标准异常 C++ 标准库定义的错误传递机制 可控:通过 throw 抛出,可用 try...catch 捕获
未定义行为(UB) 违反 C++ 语言规则的操作总称 失控:可能触发段错误崩溃,也可能读出垃圾值
段错误(Segfault) 操作系统级非法内存访问崩溃 直接闪退:触碰受保护内存时被系统强制终止

如何排查段错误?

当程序因段错误突然退出时,终端默认不会直接显示是在第几行代码崩溃的。这时我们可以配合调试器来定位问题,下一节 “VSCode 调试 C++” 会展开介绍具体操作。日常排查段错误一般按以下 4 步依次进行:

  • 定位崩溃行:使用调试器运行程序,查看程序暂停报错的那一行是不是正在通过 *p、p-> 解引用指针,或通过 arr[i] 访问下标。
  • 检查当前变量值:观察崩溃行里的指针是不是 nullptr 或异常地址,数组下标 i 是不是负数或超出了有效长度。
  • 顺着调用栈向上追查:顺着函数调用链往上一层层找,看看这个空指针、越界下标或失效地址最早是在哪个函数里产生的。
  • 核对近期修改的代码:重点检查刚刚修改过的指针赋值、delete 释放语句以及循环边界条件。

在上面这 4 步中,小伙伴们最需要留意的是第 3 步:程序崩溃的那一行很多时候只是 “报错现场”,而不一定是 “真正根因”。下面我们来看一个例子。

示例 5:跨函数追查崩溃根因

#include <iostream>

// 报错现场:解引用空指针崩溃
void printPrice(const int* price) {
    std::cout << "商品价格:" << *price << '\n';
}

// 真正根因:产生并传入了空指针
void processOrder() {
    int* price = nullptr;
    printPrice(price);
}

int main() {
    processOrder();
    return 0;
}

运行结果如下。

(报错)Segmentation fault

分析:

当这段程序运行崩溃时,真正触发段错误的位置是 printPrice() 函数里的 *price。如果我们只盯着 printPrice() 内部看,根本看不出指针为什么是空的。

只要在调试器中启动运行,程序就会自动停在 *price 报错现场。接着查看调试面板里的调用堆栈并追查到上一层的 processOrder(),就能清楚看到原来是它把一个 nullptr 空指针传了进来。

提示 平时使用 g++ 编译代码时,建议加上 -Wall 和 -Wextra 警告参数。这样编译器就能在编译阶段提前提示变量未初始化、函数漏写 return 或返回局部变量地址等常见隐患。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号