第二十八章 错误处理与调试:从崩溃到掌控

写C语言代码,说白了就是跟机器打交道。机器很死板,你写错一个分号,它就翻脸不认人。我刚开始学C的时候,最怕的就是程序跑着跑着突然崩了,屏幕上留下一堆看不懂的乱码。后来我才明白,错误处理和调试不是事后补救,而是编程的一部分

这一章,我们就来聊聊怎么让程序在出错时「优雅地倒下」,以及当它真的崩了时,怎么快速找到元凶。

28.1 errno:系统给你的「暗号」

你有没有遇到过这种情况?调用一个函数,它返回了-1,但你完全不知道哪里出了问题。这时候,errno就是系统留给你的小纸条。

errno是一个全局变量,定义在<errno.h>里。很多系统调用和库函数在出错时,除了返回错误值,还会把具体的错误码塞进errno里。比如:

#include <stdio.h>
#include <errno.h>
#include <string.h>

int main() {
    FILE *fp = fopen("不存在的文件.txt", "r");
    if (fp == NULL) {
        printf("错误码: %d\n", errno);
        printf("错误信息: %s\n", strerror(errno));
    }
    return 0;
}

运行结果会告诉你:错误码是2,对应的信息是「No such file or directory」。嗯,这就很清楚了。

我的习惯:每次调用可能失败的系统函数后,我都会立刻检查返回值。如果出错,马上用 perror()strerror() 打印错误信息。别等到程序崩了再回头查,那时候上下文早就丢了。

28.2 perror:一句话说清楚「为什么」

perror() 是个很贴心的函数。你只需要传一个字符串给它,它就会自动拼接上当前 errno 对应的错误描述,然后打印到标准错误输出(stderr)。

#include <stdio.h>
#include <errno.h>

int main() {
    FILE *fp = fopen("不存在的文件.txt", "r");
    if (fp == NULL) {
        perror("打开文件失败");
    }
    return 0;
}

输出:打开文件失败: No such file or directory

你看,一句话就把「谁出错了」和「为什么出错」都交代清楚了。我在项目中写日志时,几乎每行都离不开 perror

注意:perror() 打印的是当前线程的 errno。如果你在出错后又调用了其他可能修改 errno 的函数,那原来的错误码就被覆盖了。所以,检查错误要趁早

28.3 assert:把「不可能」变成「必须检查」

写代码时,我们经常会有一些「绝对不可能发生」的假设。比如:指针一定不为NULL,数组下标一定不越界。但现实是,这些假设往往就是bug的温床。

assert() 宏就是用来验证这些假设的。它定义在 <assert.h> 里。如果表达式为假,程序会立即终止,并打印出断言失败的位置。

#include <assert.h>

void divide(int a, int b) {
    assert(b != 0);  // 除数不能为0
    printf("%d / %d = %d\n", a, b, a / b);
}

int main() {
    divide(10, 2);  // 正常
    divide(10, 0);  // 触发断言
    return 0;
}

运行到 divide(10, 0) 时,程序会直接崩掉,并告诉你:Assertion failed: b != 0, file test.c, line 5

你可能会问:「这不就是让程序崩溃吗?跟没处理有什么区别?」

区别大了。断言是用来捕捉「不应该发生」的错误的。它不是为了处理用户输入错误,而是为了在开发阶段尽早发现程序内部的逻辑漏洞。我个人的经验是:在函数入口处对参数做断言,能省掉一半的调试时间

避坑指南:我曾经在一个嵌入式项目里,因为没加断言,一个野指针在系统里游荡了三天才被发现。从那以后,我养成了「参数必断言」的习惯。记住:assert() 只在 debug 模式下生效,release 模式下会被 #define NDEBUG 禁用。所以别用它来做真正的错误处理。

28.4 GDB调试入门:让程序「停下来」给你看

打印日志虽然好用,但有些bug就像幽灵,你加再多日志也抓不住。这时候,就该请出GDB了。

GDB(GNU Debugger)是Linux下最强大的调试工具。它能让你在程序运行时「暂停」,查看变量、堆栈、内存,甚至修改运行状态。

先看一个最简单的例子:

// bug.c
#include <stdio.h>

int main() {
    int arr[5] = {1, 2, 3, 4, 5};
    for (int i = 0; i <= 5; i++) {  // 越界了!
        printf("%d ", arr[i]);
    }
    printf("\n");
    return 0;
}

编译时加上 -g 选项:

gcc -g -o bug bug.c

然后启动GDB:

gdb ./bug

在GDB里,你可以这样操作:

命令 作用
break main 在 main 函数入口设断点
run 运行程序,停在断点处
next 单步执行(不进入函数)
step 单步执行(进入函数)
print i 打印变量 i 的值
backtrace 查看函数调用栈
quit 退出GDB

当你用 next 一步步走,看到 i 变成5时,你就知道问题出在哪了——数组下标越界。

一个小技巧:在GDB里,按回车会重复上一条命令。调试循环时,你只需要按一次 next,然后一直按回车,就能快速遍历循环体。

28.5 常见段错误排查:从「一脸懵」到「心中有数」

段错误(Segmentation Fault)是C程序员最熟悉的陌生人。说白了,就是程序试图访问它「不该碰」的内存。

常见的段错误原因有这些:

  • 解引用空指针int *p = NULL; *p = 10;
  • 数组越界:访问了 arr[100] 而数组只有10个元素
  • 栈溢出:递归太深,或者局部变量太大
  • 使用已释放的内存free() 之后还去读写
  • 写只读内存:比如修改字符串常量

遇到段错误,别慌。先用GDB跑一遍:

gdb ./你的程序
run
# 等程序崩溃后,GDB会停在出错的位置
backtrace   # 看调用栈
print 变量名  # 看变量值

如果不想用GDB,也可以用 dmesg 查看系统日志,或者用 addr2line 把地址翻译成代码行号。

我曾经踩过的坑:有一次段错误只在release版出现,debug版一切正常。查了两天才发现,是某个局部变量没初始化,debug版编译器自动填了0,release版填的是随机值。从那以后,我养成了「所有变量必须初始化」的习惯,哪怕你确定它马上就会被赋值。

28.6 本章知识体系

下面这张图,帮你把本章的核心内容串起来:

C语言错误处理与调试 错误处理机制 errno 全局变量 perror() 函数 assert() 断言 调试工具与方法 GDB 调试器 段错误排查 backtrace 常见段错误原因 空指针解引用 数组越界 栈溢出 使用已释放内存

这张图把本章内容分成了三大块:错误处理机制(errno、perror、assert)、调试工具(GDB、段错误排查)、以及常见段错误原因。你可以把它当作一张「调试地图」,遇到问题时,先定位属于哪一块,再对症下药。

好了,这一章的内容就到这里。记住:错误处理和调试不是编程的「售后」,而是编程本身。你花在调试上的每一分钟,都是在为程序的健壮性投资。


公众号:蓝海资料掘金营,微信deep3321