任务 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 列。后面是问题描述,方括号里是警告的名字。下面两行把出问题的代码原样打出来,用 ^ 指出具体位置。
逐条看:
- 有符号和无符号比较:
i是int(有符号),v.size()返回的是size_t(无符号)。如果i是负数,比较时会被转成一个巨大的正数,结果出错。改法:把int i改成size_t i,或者用范围 for 循环。 - 变量没用到:
unused声明了但没用,删掉。 - 没有 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)
逐行解释:
cmake_minimum_required:要求 CMake 至少是 3.16 版本。每个 CMakeLists.txt 第一行都这么写。project(stats_demo CXX):项目名叫stats_demo,用 C++(CXX)。set(CMAKE_CXX_STANDARD 17):用 C++17。set(CMAKE_CXX_STANDARD_REQUIRED ON):编译器不支持 C++17 就报错,而不是悄悄降级。add_compile_options(-Wall -Wextra):所有文件都加这两个参数。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 更简洁,主流编译器都支持。