015-C语言常见坑与调试

plana_lab 发布于 12 天前 65 次阅读



前面已经学习了:

变量
输入输出
判断
循环
函数
数组
字符串
指针
结构体
动态内存
文件
头文件
多文件
Makefile

学习到这里,代码开始变复杂。

于是会经常遇到:

编译报错
链接报错
程序崩溃
输出不对
数据莫名其妙改变

这时候最重要的不是:

“多试几次”

而是学会:

定位问题。


一、什么是 Bug

Bug 就是程序中的错误。

例如:

int a = 10;
int b = 0;

printf("%d\n", a / b);

程序出现错误。

又比如:

int arr[5];

arr[10] = 100;

也是错误。

常见错误可以分成:

编译错误
链接错误
运行时错误
逻辑错误

二、编译错误

编译器无法把 .c 代码编译成目标文件。

例如:

#include <stdio.h>

int main() {
    printf("Hello\n"
    return 0;
}

少了:

)

可能出现:

error: expected ')' before 'return'

这类问题属于:

编译错误。


三、常见编译错误

1. 少分号

错误:

int a = 10

正确:

int a = 10;

2. 括号不匹配

错误:

if (a > 10 {

正确:

if (a > 10) {

3. 大括号不匹配

例如:

int main() {

    if (a > 10) {
        printf("A\n");

    return 0;
}

少一个:

}

4. 拼写错误

例如:

pritnf("Hello");

正确:

printf("Hello");

5. 忘记头文件

例如使用:

malloc(sizeof(int));

却没有:

#include <stdlib.h>

应该补上。


四、警告不是错误

编译时可能出现:

warning

例如:

int a = 10.5;

可能出现类型转换相关警告。

程序也许仍然可以生成。

但是:

不要习惯性忽略 warning。

推荐使用:

gcc -Wall -Wextra

例如:

gcc -Wall -Wextra main.c -o main

这样可以让编译器告诉你更多潜在问题。


五、链接错误

有时候:

.c

可以成功编译。

但是最后生成程序时失败。

例如:

#include "hello.h"

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

hello.h:

void hello();

但是你没有编译:

hello.c

执行:

gcc main.c -o main

可能出现:

undefined reference to `hello`

这属于:

链接错误。


六、编译错误和链接错误

可以这样区分:

编译错误
↓
代码语法/类型等有问题
↓
.c → .o 失败

链接错误
↓
各个 .o 之间找不到需要的东西
↓
.o → 可执行文件失败

例如:

main.c
↓
编译
↓
main.o

成功。

但是:

main.o
↓
寻找 hello()
↓
找不到实现
↓
链接失败

七、运行时错误

程序已经编译成功:

gcc
↓
成功

但是运行以后崩溃。

例如:

int *p = NULL;

printf("%d\n", *p);

程序可能直接:

崩溃

这就是:

运行时错误。


八、段错误

Linux 下经常看到:

Segmentation fault

简称:

Segfault

Windows 环境下可能表现为:

程序崩溃
访问冲突

常见原因:

空指针解引用
野指针
数组越界
use-after-free
写入不可访问内存

九、空指针

错误:

int *p = NULL;

*p = 100;

问题:

p
↓
NULL
↓
没有有效对象

却进行:

*p = 100;

这是非法访问。

正确思路:

if (p != NULL) {
    *p = 100;
}

十、数组越界

例如:

int arr[5];

arr[5] = 100;

合法下标:

0
1
2
3
4

arr[5] 已经越界。

可以记:

int arr[5]
↓
5 个元素
↓
下标 0 ~ 4

十一、数组越界为什么危险

最麻烦的地方是:

越界不一定马上崩溃。

例如:

int arr[5];

arr[100] = 10;

程序可能:

看起来没问题

也可能:

数据损坏

也可能:

直接崩溃

所以:

程序没有崩溃 ≠ 代码正确。


十二、字符串最容易越界

例如:

char name[5];

strcpy(name, "Alice");

"Alice" 实际需要:

A
l
i
c
e
\0

一共:

6 个 char

而:

char name[5];

只有 5 个位置。

因此发生越界。

应该:

char name[6];

strcpy(name, "Alice");

或者根据实际需求预留足够空间。


十三、字符串的 \0

这是 C 字符串最常见的坑之一。

例如:

char str[6] = "Hello";

实际上:

H
e
l
l
o
\0

所以:

"Hello"

需要:

6 个 char

不是 5 个。


十四、scanf 的坑

例如:

char name[20];

scanf("%s", name);

如果输入特别长:

abcdefghijklmnopqrstuvwxyz...

可能导致:

缓冲区溢出。

更安全的基本写法:

scanf("%19s", name);

因为需要留一个位置给:

\0

十五、scanf 和换行问题

例如:

int age;

scanf("%d", &age);

char name[50];

fgets(name, sizeof(name), stdin);

可能发现:

fgets()

直接读取了一个空行。

原因是:

scanf("%d")

读取数字以后:

\n

可能还留在输入缓冲区。

这是初学阶段非常常见的问题。


十六、解决 scanf + fgets

简单场景可以使用:

int c;

while ((c = getchar()) != '\n' && c != EOF) {
}

然后:

fgets(name, sizeof(name), stdin);

完整:

int age;
char name[50];

scanf("%d", &age);

int c;
while ((c = getchar()) != '\n' && c != EOF) {
}

fgets(name, sizeof(name), stdin);

十七、指针错误

例如:

int a = 10;

int *p;

*p = 20;

这里:

p

没有初始化。

它里面可能是一个随机地址。

这就是危险的。

正确:

int a = 10;

int *p = &a;

*p = 20;

十八、野指针

例如:

int *p;

printf("%d\n", *p);

p 没有指向有效内存。

不要这样写。

推荐:

int *p = NULL;

如果暂时没有指向对象:

明确设为 NULL

十九、悬空指针

例如:

int *p = malloc(sizeof(int));

*p = 100;

free(p);

printf("%d\n", *p);

释放以后:

p
↓
旧地址
↓
内存已经释放

这时候使用 *p 就是:

use-after-free。


二十、推荐 free 后置 NULL

free(p);
p = NULL;

这样可以降低误用旧地址的风险。

但注意:

free(p)

已经完成释放。

p = NULL;

只是让指针不再保存那个旧地址。


二十一、double free

错误:

int *p = malloc(sizeof(int));

free(p);
free(p);

同一块内存被释放两次。

这是严重的内存管理错误。

推荐:

free(p);
p = NULL;

并且从设计上明确:

哪一段代码负责释放这块内存。


二十二、内存泄漏

例如:

void test() {

    int *p = malloc(sizeof(int) * 100);

    // 使用 p

}

如果没有:

free(p);

那么申请的内存可能无法再被程序访问。

就是:

内存泄漏。


二十三、malloc 和 free 必须配对

可以养成:

malloc
 ↓
使用
 ↓
free

的习惯。

例如:

int *p = malloc(sizeof(int) * 10);

if (p == NULL) {
    return 1;
}

/* 使用 */

free(p);
p = NULL;

二十四、realloc 的坑

危险写法:

p = realloc(p, new_size);

如果失败:

realloc 返回 NULL

那么原来的指针可能已经无法通过 p 保存。

更推荐:

int *temp = realloc(p, new_size);

if (temp == NULL) {
    // p 仍然有效
} else {
    p = temp;
}

二十五、结构体指针错误

例如:

typedef struct {
    int age;
} Student;

Student *p = NULL;

p->age = 18;

错误。

因为:

p
↓
NULL

不能使用:

p->age

二十六、. 和 -> 写错

结构体变量:

Student s;

s.age = 18;

结构体指针:

Student *p = &s;

p->age = 18;

不要写反:

p.age

也不要在普通结构体变量上随意:

s->age

简单记:

变量
↓
.

指针
↓
->

二十七、文件操作的坑

例如:

FILE *fp = fopen("data.txt", "r");

fscanf(fp, "%d", &x);

如果:

data.txt
不存在

那么:

fp == NULL

继续使用:

fscanf(fp, ...);

就可能出问题。

所以:

if (fp == NULL) {
    printf("打开失败\n");
    return 1;
}

二十八、不要忘记 fclose

打开:

FILE *fp = fopen(...);

结束:

fclose(fp);

文件操作基本习惯:

fopen
↓
检查 NULL
↓
读/写
↓
fclose

二十九、文件模式写错

例如:

fopen("data.txt", "w");

会覆盖原文件。

如果只是想追加:

fopen("data.txt", "a");

如果只想读取:

fopen("data.txt", "r");

所以使用 fopen() 时,要先想清楚:

我要读取?
我要覆盖写?
我要追加?
我要二进制?

三十、逻辑错误

这是最麻烦的一类:

程序不崩溃,但是结果不对。

例如:

int sum = 0;

for (int i = 0; i < 10; i++) {
    sum += i;
}

printf("%d\n", sum);

如果你本来想计算:

1 + 2 + ... + 10

那么结果显然不对。

但是:

编译成功
程序运行
甚至完全不崩溃

这就是:

逻辑错误。


三十一、调试的核心思想

不要看到 Bug 就直接乱改。

推荐:

发现问题
 ↓
复现问题
 ↓
确定错误发生的位置
 ↓
观察变量
 ↓
缩小范围
 ↓
找到原因
 ↓
修改
 ↓
重新测试

这才是调试。


三十二、第一种方法:printf 调试

最简单的调试方法:

printf()

例如:

int a = 10;
int b = 20;

printf("DEBUG: a = %d\n", a);
printf("DEBUG: b = %d\n", b);

int c = a + b;

printf("DEBUG: c = %d\n", c);

通过输出:

DEBUG: a = 10
DEBUG: b = 20
DEBUG: c = 30

逐步观察程序状态。


三十三、为什么 printf 很有用

假设:

for (int i = 0; i < n; i++) {
    arr[i] *= 2;
}

结果不对。

可以:

for (int i = 0; i < n; i++) {

    printf(
        "DEBUG: i=%d arr[i]=%d\n",
        i,
        arr[i]
    );

    arr[i] *= 2;
}

这样就可以看到:

到底是哪一次循环出了问题

三十四、打印地址

学过指针之后,可以打印地址:

printf("%p\n", (void *)p);

例如:

int a = 10;
int *p = &a;

printf("a address = %p\n", (void *)&a);
printf("p value   = %p\n", (void *)p);

应该看到:

a address = ...
p value   = ...

两者应该对应同一个地址。


三十五、打印结构体状态

例如:

printf(
    "name=%s age=%d score=%.2f\n",
    s.name,
    s.age,
    s.score
);

特别适合:

学生管理系统
课程表
配置文件

这类项目。


三十六、第二种方法:GDB

printf 是最简单的调试方式。

更专业的是:

GDB

GDB 是:

GNU Debugger

可以:

设置断点
单步执行
查看变量
查看调用栈
查看内存

三十七、使用 -g 编译

使用 GDB 时:

gcc -g main.c -o main

-g 会加入调试信息。

多文件:

gcc -g main.c student.c file.c -o main

然后:

gdb ./main

Windows 下如果使用 MinGW/GDB,则命令形式通常类似:

gdb main.exe

三十八、GDB 设置断点

进入 GDB:

gdb ./main

设置:

break main

或者:

break 10

然后运行:

run

程序会在断点暂停。


三十九、单步执行

常见命令:

next

执行下一行。

step

进入函数。

continue

继续运行到下一个断点。


四十、查看变量

GDB:

print a

或者:

p a

例如:

(gdb) p a
$1 = 10

查看指针:

p p

查看指针指向的值:

p *p

四十一、查看调用栈

如果程序崩溃,可以使用:

backtrace

或者:

bt

它可以显示:

main()
 ↓
foo()
 ↓
bar()
 ↓
崩溃

这样可以知道:

程序是从什么函数一路走到错误位置的。


四十二、第三种方法:Sanitizer

现代 GCC / Clang 中非常有用的工具:

AddressSanitizer

简称:

ASan

可以帮助发现:

数组越界
use-after-free
double free
部分内存问题

编译:

gcc -fsanitize=address -g main.c -o main

多文件:

gcc -fsanitize=address -g \
    main.c student.c file.c \
    -o main

运行程序时,如果发生很多典型内存错误,Sanitizer 会输出详细报告。


四十三、UndefinedBehaviorSanitizer

还有:

UBSan

可以检测一些:

未定义行为

例如:

gcc -fsanitize=undefined -g main.c -o main

也可以:

gcc -fsanitize=address,undefined -g main.c -o main

开发阶段非常有帮助。


四十四、Valgrind

Linux 环境下还经常使用:

Valgrind

它可以帮助检查:

内存泄漏
非法内存访问
未初始化内存

例如:

valgrind --leak-check=full ./main

不过不同系统上的可用性和安装方式不同。

当前阶段知道:

GDB
Sanitizer
Valgrind

分别用于不同类型的调试即可。


四十五、Makefile 加入调试选项

之前学习了:

CC = gcc
CFLAGS = -Wall -Wextra

调试时可以:

CC = gcc
CFLAGS = -Wall -Wextra -g -fsanitize=address,undefined

然后:

make

这样整个项目都会使用调试选项构建。


四十六、Debug 和 Release

项目较大以后,经常区分:

Debug
Release

Debug:

方便调试
保留调试信息
启用检查

Release:

用于正式发布
通常使用优化
可能关闭部分调试选项

简单 Makefile 可以:

CFLAGS_DEBUG = -Wall -Wextra -g -fsanitize=address,undefined
CFLAGS_RELEASE = -Wall -Wextra -O2

以后再根据需要选择。


四十七、编译优化

例如:

gcc -O2 main.c -o main

-O2 表示开启一定程度的优化。

但是调试阶段通常不建议一开始就追求高优化。

比较适合:

开发 / 调试
↓
-g
↓
Sanitizer

发布
↓
-O2

四十八、遇到 Bug 时先看错误信息

例如:

main.c:25:10:
error: ...

这里包含:

文件
行号
列号
错误信息

所以首先看:

哪个文件?
哪一行?
什么错误?

而不是直接从头到尾乱找。


四十九、错误行不一定是真正原因

例如:

int *p = NULL;

foo(p);

然后:

void foo(int *p) {
    printf("%d\n", *p);
}

程序可能在:

printf

这里崩溃。

但是:

真正的问题可能早在 main() 里传入了 NULL。

所以调试不能只盯着崩溃的那一行。

要沿着:

数据来源
↓
函数调用
↓
变量变化
↓
最终错误

往回追。


五十、最实用的调试思路:缩小范围

假设:

程序输出错误

不要一次改 20 行。

可以:

先确认输入是否正确
↓
确认变量
↓
确认循环次数
↓
确认中间结果
↓
确认最终结果

一次只排除一个可能性。


五十一、二分法调试

程序有 1000 行。

不知道哪里错。

可以先:

检查前 500 行产生的数据

如果正确:

错误在后 500 行

再:

检查 250 行

不断缩小范围。

这就是:

二分定位问题。


五十二、断言 assert

C 标准库提供:

assert()

需要:

#include <assert.h>

例如:

int age = 18;

assert(age >= 0);

如果条件:

true

继续执行。

如果:

false

程序会报告断言失败。


五十三、assert 的用途

例如:

void process(int *p) {

    assert(p != NULL);

    *p = 100;
}

意思是:

我认为进入这个函数时
p 必须不是 NULL

如果程序出现违反这个假设的情况:

立刻停下来

这对定位 Bug 非常方便。


五十四、不要把 assert 当普通输入检查

例如:

int age;
scanf("%d", &age);

assert(age >= 0);

用户输入:

-10

程序就退出。

但实际程序可能应该:

提示错误
要求重新输入

所以:

assert
↓
检查程序员认为“不应该发生”的情况

而不是替代正常的用户输入验证。


五十五、常见坑总结

目前最重要的错误:

数组
├─ 越界

字符串
├─ \0
├─ 缓冲区溢出

指针
├─ 未初始化
├─ NULL
├─ 悬空指针
└─ 错误解引用

动态内存
├─ 忘记 free
├─ double free
├─ use-after-free
└─ realloc 使用不当

结构体
├─ . / ->
└─ NULL 指针

文件
├─ fopen 失败
├─ 忘记 fclose
└─ 模式错误

多文件
├─ undefined reference
├─ 重复定义
└─ 声明 / 定义混乱

五十六、一个完整的调试流程

以后遇到 Bug,可以按照:

① 让 Bug 稳定复现
        ↓
② 看编译器 / 运行时错误
        ↓
③ 确定文件和位置
        ↓
④ 检查输入
        ↓
⑤ 打印关键变量
        ↓
⑥ 检查指针 / 数组边界
        ↓
⑦ 检查内存生命周期
        ↓
⑧ 使用 GDB / Sanitizer
        ↓
⑨ 找到真正原因
        ↓
⑩ 修复后重新测试

五十七、综合示例:找 Bug

下面代码:

#include <stdio.h>
#include <stdlib.h>

int main() {

    int n = 5;

    int *arr = malloc(sizeof(int) * n);

    for (int i = 0; i <= n; i++) {
        arr[i] = i * 10;
    }

    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);
    }

    free(arr);

    return 0;
}

看起来似乎没什么问题。

但是:

for (int i = 0; i <= n; i++)

这里是:

i <= 5

所以会访问:

arr[5]

但是:

arr

只有:

arr[0]
arr[1]
arr[2]
arr[3]
arr[4]

因此应该:

for (int i = 0; i < n; i++) {

五十八、使用 Sanitizer 检查

编译:

gcc -Wall -Wextra -g \
    -fsanitize=address,undefined \
    main.c -o main

运行:

./main

Sanitizer 很可能会报告类似:

heap-buffer-overflow

并指出:

哪一行
什么访问

这比单纯看程序输出更容易发现内存越界。


五十九、本章最重要的习惯

以后写 C 代码,逐渐养成:

编译:
-Wall -Wextra

调试:
-g

内存问题:
-fsanitize=address,undefined

动态内存:
malloc ↔ free

指针:
初始化

数组:
检查边界

字符串:
预留 \0

文件:
fopen → 检查 → 使用 → fclose

代码修改:
一次一个问题

六十、练习

练习 1

找出下面代码的问题:

int arr[5];

for (int i = 0; i <= 5; i++) {
    arr[i] = i;
}

修改为正确代码。


练习 2

找出:

int *p;

*p = 10;

的问题。

要求使用一个真正的变量让 p 正确指向它。


练习 3

修复:

int *p = malloc(sizeof(int));

*p = 10;

free(p);

printf("%d\n", *p);

要求正确处理释放后的指针。


练习 4

故意写一个:

内存泄漏

然后自己修改成正确版本。


练习 5

故意写一个数组越界程序。

使用:

-fsanitize=address

观察编译运行后的错误报告。


练习 6

创建:

main.c
student.c
student.h

故意不参与编译:

student.c

观察:

undefined reference

然后修复。


练习 7

使用 GDB:

gcc -g main.c -o main
gdb ./main

练习:

break main
run
next
print
continue

此作者没有提供个人介绍。
最后更新于 2026-09-18