learn.ochresign
阶段 0 · 补前置

任务 2:学会 g++、gdb,写出第一个 CMakeLists.txt

不用 IDE 的“运行”按钮,自己在命令行里编译、看懂报错、用 gdb 找出程序崩溃的那一行、用 CMake 管理多文件项目。所有输出都是实际运行的结果。

前提: 已经完成 任务 1,能打开 Ubuntu 终端和 VS Code。

这篇做完你会: 用 g++ 编译单文件和多文件程序;看懂编译警告和链接错误;用 gdb 打断点、单步、看变量、定位段错误;用 AddressSanitizer 抓越界;用 CMake 构建项目。

预计用时: 3 到 4 小时。每一段代码都请自己敲一遍,不要复制粘贴,敲错了正好练习看报错。

为什么要学这些

刷算法题时,你写一个文件,点一下“运行”,或者交给 OJ 判题。做项目不一样:

  • 代码会拆成几十个文件,要告诉编译器怎么把它们组合起来,这就是 CMake 的工作。
  • 服务器跑着跑着崩了,没有 OJ 告诉你哪个测试点错了,你要自己找到崩在哪一行,这就是 gdb 的工作。
  • 很多 bug 不会立刻崩溃,而是悄悄读到错误的内存,结果看起来“差不多对”,这就要靠 AddressSanitizer 抓出来。

先在终端里建一个目录专门放这次的练习:

mkdir -p ~/code/cpp-practice/task2
cd ~/code/cpp-practice/task2
code .

第 1 部分:g++ 编译单个文件

1.1 最基本的编译

新建 warn.cpp,敲入下面的代码。这段代码有好几个问题,先别改:

#include <vector>

int count(const std::vector<int>& v) {
    int unused = 0;
    int c = 0;
    for (int i = 0; i < v.size(); ++i) c += v[i];
}

int main() { return count({1, 2}); }

编译:

g++ -o warn warn.cpp

什么都没输出,编译“成功”了。但这段代码其实有 bug:count 函数声明了返回 int,却没有 return 语句。运行它的结果是不确定的。

1.2 打开警告:-Wall -Wextra

g++ -Wall -Wextra -o warn warn.cpp

这次输出了三个警告:

warn.cpp: In function 'int count(const std::vector<int>&)':
warn.cpp:5:23: warning: comparison of integer expressions of different signedness: 'int' and 'std::vector<int>::size_type' {aka 'long unsigned int'} [-Wsign-compare]
    5 |     for (int i = 0; i < v.size(); ++i) c += v[i];
      |                     ~~^~~~~~~~~~
warn.cpp:3:9: warning: unused variable 'unused' [-Wunused-variable]
    3 |     int unused = 0;
      |         ^~~~~~
warn.cpp:6:1: warning: no return statement in function returning non-void [-Wreturn-type]
    6 | }
      | ^

怎么读一条警告: warn.cpp:5:23 表示文件 warn.cpp 的第 5 行第 23 列。后面是问题描述,方括号里是警告的名字。下面两行把出问题的代码原样打出来,用 ^ 指出具体位置。

逐条看:

  1. 有符号和无符号比较:i 是 int(有符号),v.size() 返回的是 size_t(无符号)。如果 i 是负数,比较时会被转成一个巨大的正数,结果出错。改法:把 int i 改成 size_t i,或者用范围 for 循环。
  2. 变量没用到:unused 声明了但没用,删掉。
  3. 没有 return:这是真 bug,补上 return c;。

改好后的版本:

#include <vector>

int count(const std::vector<int>& v) {
    int c = 0;
    for (size_t i = 0; i < v.size(); ++i) c += v[i];
    return c;
}

int main() { return count({1, 2}); }

再编译一次,应该没有任何输出。然后运行并查看返回值:

./warn
echo $?

echo $? 打印上一条命令的退出码,也就是 main 的返回值,应该是 3。

规矩:以后每次编译都加 -Wall -Wextra,并且把所有警告都改掉。 警告往往就是 bug。

1.3 常用参数一览

参数 作用 什么时候用
-o 名字 指定输出文件名,不写默认叫 a.out 总是
-Wall -Wextra 打开常见警告 总是
-std=c++17 使用 C++17 标准 总是(g++ 13 默认是 C++17,但写上更明确)
-g 生成调试信息,gdb 才能看到行号和变量名 调试时
-O0 不优化,代码按你写的顺序执行 调试时
-O2 开优化,运行快很多 压测和发布时
-fsanitize=address 打开 AddressSanitizer,检查内存错误 调试和测试时
-c 只编译成 .o 目标文件,不链接 多文件时

第 2 部分:多个文件怎么编译

项目里的代码会拆成很多文件。最常见的拆法:.h 头文件写声明(有哪些函数、参数是什么),.cpp 源文件写实现(函数体)。

2.1 建三个文件

stats.h:

#pragma once
#include <vector>

double average(const std::vector<int>& nums);
int maxOf(const std::vector<int>& nums);

#pragma once 的意思是“这个头文件在一次编译里只包含一次”,防止被 #include 多次导致重复定义。每个头文件第一行都写上它。

stats.cpp:

#include "stats.h"

double average(const std::vector<int>& nums) {
    if (nums.empty()) return 0;
    long long sum = 0;
    for (int x : nums) sum += x;
    return static_cast<double>(sum) / nums.size();
}

int maxOf(const std::vector<int>& nums) {
    int best = nums[0];
    for (int x : nums) {
        if (x > best) best = x;
    }
    return best;
}

main.cpp:

#include <iostream>
#include "stats.h"

int main() {
    std::vector<int> scores = {90, 85, 78};
    std::cout << "average = " << average(scores) << std::endl;
    std::cout << "max = " << maxOf(scores) << std::endl;
    return 0;
}

注意 #include <iostream> 用尖括号,表示系统头文件;#include "stats.h" 用双引号,表示自己写的头文件,先在当前目录找。

2.2 只编译 main.cpp 会怎样

g++ -o app main.cpp

报错:

/usr/bin/ld: /tmp/ccCnif7b.o: in function `main':
main.cpp:(.text+0x8e): undefined reference to `average(std::vector<int, std::allocator<int> > const&)'
/usr/bin/ld: main.cpp:(.text+0xda): undefined reference to `maxOf(std::vector<int, std::allocator<int> > const&)'
collect2: error: ld returned 1 exit status

这是链接错误,不是编译错误。看开头的 /usr/bin/ld,ld 是链接器。undefined reference to average(...) 的意思是:main.cpp 里调用了 average,头文件也说了有这个函数,所以编译通过了;但到链接时,链接器在所有给它的文件里都找不到 average 的函数体。

以后看到 undefined reference,就去检查: 这个函数的 .cpp 有没有加进编译命令(或 CMakeLists.txt)里;函数签名在 .h 和 .cpp 里是否完全一致;是否忘了链接某个库。

2.3 正确的编译方式

把两个 .cpp 都给 g++:

g++ -Wall -Wextra -g -o app main.cpp stats.cpp
./app

输出:

average = 84.3333
max = 90

2.4 分开编译,再链接

上面一条命令其实做了两件事:先把每个 .cpp 编译成 .o,再把 .o 链接成可执行文件。也可以分开做:

g++ -c main.cpp -o main.o
g++ -c stats.cpp -o stats.o
g++ main.o stats.o -o app

分开的好处是:只改了 stats.cpp 的话,只需要重新编译 stats.o,再链接一次,main.o 不用重新编译。项目有几十个文件时,这能省很多时间。CMake 帮你自动做的就是这件事。

第 3 部分:gdb 定位段错误

3.1 一个会崩溃的程序

新建 find.cpp:

#include <cstring>
#include <iostream>

struct Student {
    const char* name;
    int score;
};

// 按名字查找学生,找不到返回空指针
Student* findStudent(Student* students, int n, const char* name) {
    for (int i = 0; i < n; ++i) {
        if (std::strcmp(students[i].name, name) == 0) {
            return &students[i];
        }
    }
    return nullptr;
}

int main() {
    Student cls[] = {{"alice", 90}, {"bob", 85}};
    Student* s = findStudent(cls, 2, "carol");
    std::cout << "score = " << s->score << std::endl;
    return 0;
}

编译时一定要加 -g -O0,否则 gdb 里看不到行号和变量:

g++ -g -O0 -o find find.cpp
./find

输出:

Segmentation fault

段错误(Segmentation fault)是说程序访问了不属于它的内存,被操作系统杀掉了。只有这一行,没告诉你是哪里。

3.2 用 gdb 跑一遍

gdb ./find

进入 gdb 后,提示符变成 (gdb)。输入 run(或简写 r)回车:

(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x00005555555552b5 in main () at find.cpp:22
22      std::cout << "score = " << s->score << std::endl;

gdb 直接告诉你:崩在 find.cpp 第 22 行,并把那一行打了出来。

输入 bt(backtrace,调用栈),看是怎么一层层调用到这里的:

(gdb) bt
#0  0x00005555555552b5 in main () at find.cpp:22

这里只有一层,因为崩溃就发生在 main 里。在真正的项目里,调用栈通常有十几层,#0 是崩溃的位置,往下是谁调用了它。

看看变量 s 是什么:

(gdb) print s
$1 = (Student *) 0x0

0x0 就是空指针。s->score 对空指针取成员,所以崩了。原因找到了:"carol" 不在数组里,findStudent 返回了 nullptr,main 没检查就直接用了。

也可以看整个数组:

(gdb) print cls
$2 = {{name = 0x555555556004 "alice", score = 90}, {name = 0x55555555600a "bob", score = 85}}

输入 quit(或 q)退出 gdb,它问是否杀掉程序,输入 y。

修复:使用指针前先检查。

    if (s == nullptr) {
        std::cout << "not found" << std::endl;
    } else {
        std::cout << "score = " << s->score << std::endl;
    }

3.3 断点和单步:看程序一步步怎么跑

新建 average.cpp,这里面有两个 bug:

#include <iostream>
#include <vector>

// 计算平均值。这里故意留了一个 bug
double average(const std::vector<int>& nums) {
    int sum = 0;
    for (size_t i = 0; i <= nums.size(); ++i) {
        sum += nums[i];
    }
    return sum / nums.size();
}

int main() {
    std::vector<int> scores = {90, 85, 78};
    std::cout << "average = " << average(scores) << std::endl;
    return 0;
}
g++ -g -O0 -o average average.cpp
./average

输出 average = 84。正确答案是 (90 + 85 + 78) / 3 = 84.333...,结果不对,但程序也没崩。这种 bug 最难找。用 gdb 一步步看:

gdb ./average

在 average 函数开头打断点,然后运行:

(gdb) break average
Breakpoint 1 at 0x129a: file average.cpp, line 6.
(gdb) run
Breakpoint 1, average (nums=std::vector of length 3, capacity 3 = {...}) at average.cpp:6
6       int sum = 0;

程序停在第 6 行,这一行还没执行。用 next(简写 n)执行一行:

(gdb) next
7       for (size_t i = 0; i <= nums.size(); ++i) {
(gdb) next
8           sum += nums[i];
(gdb) next
7       for (size_t i = 0; i <= nums.size(); ++i) {

现在第一轮循环执行完了。看看变量:

(gdb) print sum
$1 = 90
(gdb) print i
$2 = 0
(gdb) print nums
$3 = std::vector of length 3, capacity 3 = {90, 85, 78}
(gdb) info locals
i = 0
sum = 90

info locals 一次打印所有局部变量。

一行行按 next 太慢了。我们怀疑循环多跑了一轮,可以打一个条件断点:只在 i == 3 时停在第 8 行。然后用 continue(简写 c)一直运行到下一个断点:

(gdb) break 8 if i == 3
Breakpoint 2 at 0x5555555552ab: file average.cpp, line 8.
(gdb) continue
Breakpoint 2, average (nums=...) at average.cpp:8
8           sum += nums[i];
(gdb) print i
$4 = 3
(gdb) print nums[i]
$5 = 0

第一个 bug 找到了: 循环条件写的是 i <= nums.size(),i 能取到 3,而数组只有下标 0、1、2。nums[3] 越界了,读到了一个不属于数组的值(这次碰巧是 0,换台机器可能是任何数)。应该写 i < nums.size()。

再用 finish 运行到当前函数返回,看返回值:

(gdb) finish
Run till exit from #0 ...
0x00005555555553c0 in main () at average.cpp:15
15      std::cout << "average = " << average(scores) << std::endl;
Value returned is $6 = 84

第二个 bug: sum / nums.size() 两边都是整数,做的是整数除法,253 / 3 = 84,小数部分被扔掉了。要先转成 double:static_cast<double>(sum) / nums.size()。

3.4 gdb 命令速查

命令 简写 作用
run r 开始运行程序
break 函数名 / break 行号 b 打断点
break 行号 if 条件 条件断点
next n 执行一行,遇到函数调用不进去
step s 执行一行,遇到函数调用会进去
continue c 继续运行到下一个断点
finish 运行到当前函数返回
print 表达式 p 打印变量或表达式的值
info locals 打印所有局部变量
bt 打印调用栈
frame 编号 f 切换到调用栈的某一层,再用 print 看那一层的变量
info breakpoints 列出所有断点
delete 编号 d 删除断点
quit q 退出

直接按回车会重复上一条命令,连续 next 时很方便。

第 4 部分:AddressSanitizer,让越界当场报错

刚才的越界 bug,程序没崩,是我们怀疑结果不对才去查的。AddressSanitizer(简称 ASan)能让这类错误一发生就报错。

用还没修复的 average.cpp,加上 -fsanitize=address 编译:

g++ -g -O0 -fsanitize=address -o average_asan average.cpp
./average_asan

输出(省略了一部分):

==3186==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x50200000001c ...
READ of size 4 at 0x50200000001c thread T0
    #0 0x55d4085b8477 in average(std::vector<int, std::allocator<int> > const&) average.cpp:8
    #1 0x55d4085b86f6 in main average.cpp:15

0x50200000001c is located 0 bytes after 12-byte region [0x502000000010,0x50200000001c)
allocated by thread T0 here:
    ...
    #6 0x55d4085b8697 in main average.cpp:14

逐行读:

  • heap-buffer-overflow:堆上的缓冲区越界(vector 的数据在堆上)。
  • READ of size 4:读了 4 个字节,也就是一个 int。
  • #0 ... average.cpp:8:出错的位置在第 8 行,sum += nums[i];。
  • located 0 bytes after 12-byte region:读的位置正好在一块 12 字节(3 个 int)的内存后面紧挨着的地方,也就是 nums[3]。
  • allocated by thread T0 here: ... main average.cpp:14:这块内存是第 14 行的 scores 分配的。

以后写网络库和 mini-Redis 时,调试版本一律开 ASan。 它能抓:数组越界、使用已经 delete 的内存(use-after-free)、重复 delete、内存泄漏。代价是程序慢 2 倍左右、内存多用一些。

第 5 部分:CMake

5.1 为什么要 CMake

项目有 20 个 .cpp 时,你不会想每次都敲一长串 g++ 命令,也记不住哪些文件改过需要重新编译。CMake 的做法是:你在 CMakeLists.txt 里写清楚“有哪些源文件、编译成什么、用什么参数”,CMake 帮你生成 Makefile,再由 make 完成编译,并且只重新编译改过的文件。

5.2 写一个最小的 CMakeLists.txt

回到第 2 部分的 stats.h、stats.cpp、main.cpp 所在的目录,新建 CMakeLists.txt(文件名必须一字不差,注意大小写):

cmake_minimum_required(VERSION 3.16)
project(stats_demo CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_compile_options(-Wall -Wextra)

add_executable(app main.cpp stats.cpp)

逐行解释:

  1. cmake_minimum_required:要求 CMake 至少是 3.16 版本。每个 CMakeLists.txt 第一行都这么写。
  2. project(stats_demo CXX):项目名叫 stats_demo,用 C++(CXX)。
  3. set(CMAKE_CXX_STANDARD 17):用 C++17。
  4. set(CMAKE_CXX_STANDARD_REQUIRED ON):编译器不支持 C++17 就报错,而不是悄悄降级。
  5. add_compile_options(-Wall -Wextra):所有文件都加这两个参数。
  6. add_executable(app main.cpp stats.cpp):用这两个源文件生成一个叫 app 的可执行文件。以后加了新的 .cpp,要加到这里,否则就会遇到前面的 undefined reference。

5.3 构建

CMake 的习惯是在单独的 build 目录里构建,生成的一堆文件不会弄乱源码目录:

mkdir build
cd build
cmake ..
make
./app

cmake .. 的 .. 表示“CMakeLists.txt 在上一级目录”。输出:

-- The CXX compiler identification is GNU 13.3.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done (0.3s)
-- Generating done (0.0s)
-- Build files have been written to: /home/xiao/code/cpp-practice/task2/build

然后 make:

[ 33%] Building CXX object CMakeFiles/app.dir/main.cpp.o
[ 66%] Building CXX object CMakeFiles/app.dir/stats.cpp.o
[100%] Linking CXX executable app
[100%] Built target app

可以看到它先分别编译两个 .o,再链接,和我们 2.4 手动做的一样。./app 输出和之前相同。

5.4 改代码后重新构建

在 stats.cpp 里随便改点东西(比如加一行注释),保存,然后在 build 目录里再执行 make:

[ 33%] Building CXX object CMakeFiles/app.dir/stats.cpp.o
[ 66%] Linking CXX executable app
[100%] Built target app

只重新编译了 stats.cpp。改了代码只需要 make;改了 CMakeLists.txt 也只需要 make,它会自动重新运行 cmake。 只有 build 目录坏了才需要删掉重来:rm -rf build。

5.5 Debug 和 Release

cmake -DCMAKE_BUILD_TYPE=Debug ..     # 等于加 -g,用来调试
cmake -DCMAKE_BUILD_TYPE=Release ..   # 等于加 -O3 -DNDEBUG,用来压测

习惯上用两个目录分开:build(Debug)和 build-release(Release)。

5.6 别忘了 .gitignore

把 build/ 加进仓库的 .gitignore,然后提交:

cd ~/code/cpp-practice
git add -A
git commit -m "Practice g++, gdb, ASan and CMake"
git push

检查清单

  • 能说出 -Wall -Wextra、-g、-O0、-O2、-c、-o 分别是干什么的
  • 看到 undefined reference 知道是链接错误,知道去检查什么
  • 能用 gdb 找到 find.cpp 崩溃的行,并用 print 确认是空指针
  • 能用条件断点找到 average.cpp 的越界
  • 能用 ASan 直接定位越界,并读懂报告
  • 写出 CMakeLists.txt,在 build 目录里构建成功

自测

编译错误和链接错误怎么区分?

看报错是谁打出来的。带着 文件名:行号:列号: error: 的是编译错误,说明语法或类型有问题;带着 /usr/bin/ld 或 collect2: error: ld returned 1 exit status 的是链接错误,通常是函数只有声明没有实现,或者实现所在的文件没参与编译。

为什么调试时要 -g -O0,压测时要 -O2?

-g 让 gdb 能对应到源码的行号和变量名;-O0 关闭优化,代码按写的顺序一行行执行。开了 -O2 之后,编译器会重排、合并、删除代码,gdb 里变量显示 <optimized out>,单步会乱跳。压测时则必须开优化,否则测出来的性能没有意义。

next 和 step 有什么区别?

遇到函数调用时,next 把整个函数当一行执行完,step 会进入函数内部的第一行。想看某个函数里面发生了什么就用 step,不关心就用 next。

新加了一个 .cpp 文件,CMakeLists.txt 要改什么?

把它加到 add_executable(或者 add_library)的源文件列表里。忘了加的话,编译能通过,链接时报 undefined reference。

面试常问

从源码到可执行文件经历了哪几步?

参考回答: 四步。预处理:展开 #include 和宏,去掉注释(g++ -E);编译:把 C++ 翻译成汇编(-S);汇编:把汇编变成目标文件 .o(-c);链接:把多个目标文件和库合在一起,把函数调用和函数定义对上,生成可执行文件。undefined reference 就是链接阶段找不到定义。

程序段错误了,你怎么定位?

参考回答: 先用 -g 重新编译,在 gdb 里运行,崩溃后用 bt 看调用栈,frame 切到出问题的那一层,print 看相关变量。如果是线上程序,先 ulimit -c unlimited 打开 core dump,崩溃后用 gdb ./server core 分析。如果是越界、use-after-free 这类问题,崩溃的位置往往不是出错的位置,这时用 -fsanitize=address 编译再跑一次,ASan 会报出第一次非法访问的位置,以及那块内存在哪分配、在哪释放。

可能的追问: 段错误的常见原因?空指针解引用、数组越界、访问已释放的内存、栈溢出(比如递归太深)。

头文件里为什么要写 #pragma once?

参考回答: 防止同一个头文件在一次编译中被包含多次。比如 a.h 和 b.h 都包含了 common.h,main.cpp 又同时包含了 a.h 和 b.h,没有保护的话 common.h 里的类定义会出现两次,编译报重复定义。传统写法是 #ifndef XXX_H / #define XXX_H / #endif 的头文件保护,#pragma once 更简洁,主流编译器都支持。