欢迎光临
我们一直在努力

【Linux】gdb/cgdb 实战

【Linux】gdb/cgdb 实战

概览

  • gcc -g 是什么,不加 -g 时 gdb 会退化成什么样
  • cgdb 的上下分屏、Esc 与 i 切窗、在源码窗直接用空格下断点
  • 调试命令体系:break、run、next、step、finish、print、display、watch、bt、info locals、until
  • 条件断点:一千次循环里只停在出事的那一次
  • 函数指针与 typedef 的读法,以及回调的本质
  • 进度条的三次进化:写死循环、职责分离、回调契约
  • 进度条为什么能原地刷新:\\r、%-100s、fflush 和行缓冲、块缓冲
  • static 局部变量的作用与代价
  • 一个越界 bug 的三种死法:算错、卡死、段错误

内容铺垫

一、gdb 与 cgdb 基础知识

环境与四套源码

先确认工具齐全。

# 所属目录:/home/cocatrice/lab7/progressbar
rpm -q gcc gdb cgdb make epel-release
find . -type f | sort

cgdb 不在 CentOS 的基础源里,要先装 EPEL 再装它:

sudo yum install -y epel-release
sudo yum install -y gdb cgdb

四套源码放在四个目录里,从耦合一路走到解耦,最后一份是故意改坏的版本,专门留给后面调试:

progressbar/
├── Makefile 四套通用
├── v1_coupled/ V1:进度条自己写循环(耦合)
├── v2_split/ V2:职责分离,但业务里写死函数名
├── v3_callback/ V3:回调契约,显示方式运行期可替换
└── bug/ V3 改坏一处:current += speed 挪到了回调之前

gcc -g 到底多给了什么

不加 -g 也能编译出能跑的程序,差别要到出问题的时候才显出来。

# 所属目录:/home/cocatrice/lab7/progressbar/v1_coupled
gcc main.c process.c -o pb_nodebug # 不带调试信息
gcc -g main.c process.c -o pb_debug # 带调试信息
ls -l pb_nodebug pb_debug
file pb_nodebug
readelf -S pb_debug | grep -c debug

-g 不改动一行代码,只是把符号表、行号、变量名和类型信息一起写进可执行文件。多出来的 2616 字节就是这些信息,换来的是调试器能按源码行和变量名工作。

真正的差别在调试器里:

gdb -q -batch -ex 'break process_v1' ./pb_nodebug
gdb -q -batch -ex 'break process_v1' ./pb_debug
gdb -q -batch -ex 'info line process_v1' ./pb_debug

没有调试信息时,断点只能落在裸地址上,gdb 连这是哪个文件的第几行都说不出来。file 里那句 not stripped 说明符号表还在,所以函数名 process_v1 还认得出来,但行号和变量名是没有的。readelf -S 数出来的 5 个 debug 段,就是 -g 塞进去的东西。

以后见到「断点打上了但单步的时候乱跳」,第一件事就是确认编译时加没加 -g。

cgdb:把 gdb 变好看

gdb 是纯命令行的,看代码要靠 list 一段一段翻。cgdb 在 gdb 外面套了一层界面,上面是源码窗,下面是原来的 gdb 命令窗,两边同步:命令窗里停下来,源码窗自动滚到对应那一行并高亮。

┌──────────────────────────────────────────────────────────────┐
│ 1│ #include "process.h" │ 源码窗
│ 2│ │ 光标在这里时
│ 3│ int main(void) │ 按空格下断点
│ 4│ { │
│ 5│ process_v1(); │
│ 6│ return 0; │
│ 7+>} │
│ │
│ /home/cocatrice/lab7/progressbar/v1_coupled/main.c │ 文件名栏
├──────────────────────────────────────────────────────────────┤
│ GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-120.el7 │ 命令窗
│ (gdb) break process_v1 │ 原来怎么写
│ Breakpoint 1 at 0x4006f5: file process.c, line 12. │ 现在还怎么写
│ (gdb) _ │
└──────────────────────────────────────────────────────────────┘

窗口之间用 Esc 和 i 切换:按 Esc 光标跳到上面的源码窗,这时方向键翻代码,光标停在哪一行就操作哪一行;按 i 回到下面的命令窗继续敲 gdb 命令。这两个键会一直用,cgdb 里最常见的困惑就是敲了命令没反应,其实是人还在源码窗。

源码窗里最值的两个键是空格和回车:光标移到某一行按空格,就在这一行下断点,再按一次取消;按回车下的是临时断点,命中一次就自动删掉,专门用来追一个只跑一次的地方。

界面偶尔会被程序自己的输出冲乱,Ctrl+L 重画一遍就好。

调试命令:什么时候用哪一条

命令本身不难记,难的是知道什么时候该用哪一条。

想做的事命令什么时候用
下断点 break 函数名 / break 文件:行号 知道大概在哪出错时
带条件下断点 break 函数名 if 条件 循环里只有某一次是错的
跑起来 run 每次改完代码重新跑
单步不进函数 next 只关心自己这层的走向
单步进函数 step 怀疑问题在被调用的函数里
跑完当前函数 finish 进了个不想细看的函数,想赶紧出来
打印变量 print 变量 / p 变量 想知道此刻它的值
打印类型和大小 p sizeof(数组) 判断有没有越界的依据
每次停下自动打印 display 变量 同一个值要反复看
值变了就停下 watch 变量 不知道谁改坏了它
看所有局部变量 info locals 不确定该看哪个
看调用栈 bt 想知道是谁把我调进来的
看内存 x/16bx 地址 怀疑越界、踩内存
跑到某一行 until 行号 中间几行确定没问题
运行时改变量 set var 变量 = 值 验证猜想,不用改代码重编
看断点 info break / delete 编号 断点多了要清理
关掉分页 set pagination off 写脚本抓输出时必加

在这里插入图片描述 图 1 cgdb 的界面分区

这里要单独说 display 和 watch 的区别:display 是每次停下来无脑打印一遍,watch 是盯着这个变量,一旦它的值变了就自动停下来,并告诉你是哪一行改的。前者解决「我要一直看」,后者解决「我不知道谁改的」。

条件断点

这次的 bug 出现在第 101 次循环里。硬按 continue 一百次不现实,这就是条件断点存在的理由:

break FlushProcess if current > total

意思是「进 FlushProcess 的时候检查 current 和 total,只有 current 比 total 大了才停下来」。条件写在那里,gdb 每次进函数就替你判断一次,命中之前程序是全速跑的,一次都不用手动按。

判断条件里能用的东西和源码里一样:局部变量、参数、全局变量都能写。条件写错时 gdb 会提示 No symbol xxx in current context,看到这句先回头看拼写。

二、函数指针与 typedef

回调这个词听着玄,语法上其实只有一条变形规则:把函数声明里的函数名换成 (*p)。

void FlushProcess (double total, double current); /* 普通函数声明 */
void (*p) (double total, double current); /* 把名字换成 (*p) */

两处括号都不能丢。void *p(double) 是「一个叫 p 的函数,参数是 double,返回 void 指针」,void (*p)(double) 才是「一个指针,指向参数是 double、返回 void 的函数」。前者是函数,后者是指针,完全是两样东西。

typedef 做的事是把「变量名所在的位置」变成「类型名所在的位置」,分三级看最清楚:

typedef int INT; /* 把 int 叫成 INT */
typedef int *pint; /* 把 int * 叫成 pint */
typedef void (*callback_t)(double, double); /* 把这种函数指针叫成 callback_t */

第三行和第一行是同一件事:原来 INT 那个位置在没 typedef 的时候写的变量名,现在写的是类型名。callback_t 从此就是一个类型,可以拿来声明变量、当参数、当返回值。

容易混的几种写法放在一起对照:

写法是什么怎么用
void (*p)(double) 函数指针变量 p = FlushProcess; p(1.0, 2.0);
void *p(double) 返回 void 指针的函数 p(1.0) 得到指针
typedef void (*cb)(double) 函数指针类型 cb p; cb q; 拿来声明
typedef void cb(double) 函数类型 cb *p; 还得自己加星号
void (*table[3])(double) 函数指针数组 table0 按编号分发

最后两行是多带一个星号和少带一个星号的差别,实际写的时候统一用指针类型别名最省心,声明处不用再想星号该放哪。

回调的本质一句话:框架决定什么时候调,调用者决定调什么。DownLoad 手里攥着循环,什么时候该报进度它说了算;至于报进度是画横条还是写日志,它不管,谁传进来的谁说了算。

这里还埋一个伏笔:callback_t 只约束了「两个 double」,没约束「哪个是总量、哪个是当前量」。参数顺序写反,编译器一个警告都不会给。

三、进度条知识初步

进度条要做成什么样

目标只有一句话:在终端的一行里,把「已经完成多少」画成一条长度固定的横条,后面跟上百分比和一个转圈的光标。跑起来是这样:

[================================================== ][45.0%][/]

整条横条宽度固定在一百个字符,等号随进度往右长,空格把剩下的位置填满,所以百分比和光标的位置永远不动。程序一共四个版本,从耦合一路写到解耦,最后一份故意改坏,留作第三部分的素材。

里面用到哪几个函数

代码不长,用到的标准库函数也就几个,但每一个都有它非在不可的理由:

函数在干什么少了它会怎样
printf 把整帧格式化成一行 没有输出
fflush 把缓冲区里的内容推到终端 画面不实时;程序崩了输出直接丢
memset 把 buffer 整块清零 字符串没有结束符,printf 一路读下去
strlen 量出光标字符表的长度 取余没有模,光标转不起来
usleep 控制每一帧之间的间隔 进度条一闪而过,看不见过程

除了这几个函数,还有两个格式细节要记住。一个是 \\r,回车,把光标送回本行开头但不换行,下一帧从这里重新画就盖住了上一帧。另一个是 %-100s 里的减号和 100,减号表示左对齐,100 是最小宽度,不够的部分自动补空格,这样整行长度才不会随着进度变化。

这两条合起来就是进度条能「原地刷新」的全部秘密,下面单独展开。

进度条为什么能原地刷新

三个东西凑在一起才有这个效果。

\\r 负责回到行首。它只动光标不动内容,所以新写的内容会覆盖旧的。\\n 是回车加换行,两件事一起做,用在进度条上就成了一帧一行。

%-100s 负责宽度固定。用 %s 直接打,字符串有多长就打多长,进度条会一格一格往右长,后面的百分比跟着往右跑;加了最小宽度,右边自动补空格,长度就定住了。

fflush(stdout) 负责及时送出去。这一条最容易被忽略,也最值得单独讲。

C 标准库的输出是带缓冲的。缓冲策略取决于 stdout 连到哪里:连到终端时是行缓冲,遇到换行就刷;被重定向到文件或管道时变成块缓冲,攒够一块(通常是 4096 字节)才真正写下去。进度条那一行没有换行,全靠 \\r,所以在重定向的场景下它会一直待在缓冲区里。

正常退出时程序会把缓冲区刷出去,所以重定向到文件后内容和终端上其实一样多,区别只在写入的时机:不加 fflush 时进度不是实时的,而是攒够一块才落盘。真正致命的是程序没有正常退出的时候,比如崩溃或者被杀死,缓冲区里的东西直接没了。

这一点有实测:把 fflush 那一行删掉再编一份,让程序在打印几帧之后崩掉。

# 所属目录:/home/cocatrice/lab7/progressbar/bug
gcc -g -O2 main_slow.c process.c -o slow_with # 保留 fflush
gcc -g -O2 main_slow.c process_nof.c -o slow_without # 删掉 fflush
./slow_with > w1.txt 2>&1
./slow_without > w2.txt 2>&1
wc -c w1.txt w2.txt
tr '\\r' '\\n' < w1.txt | tail -3

两份程序跑的是同一份逻辑、崩在同一处,唯一的差别是那一行 fflush:

  • 有 fflush 的那份留下了 391 字节,崩溃前画过的三帧都在,最后一帧停在 150.0%;
  • 没有 fflush 的那份文件是 0 字节,前面几帧全部随着进程一起消失了。

所以 fflush 不只是「让画面动起来」,它是崩溃现场能不能留下证据的问题。

static 局部变量的作用与代价

FlushProcess 里那个 static int cnt 管的是光标符号转到第几个。普通的局部变量每次进函数都会重新初始化为 0,光标就永远停在 | 上不动;static 让它在程序启动时初始化一次,之后跨调用一直活着。

代价有三条。一是隐藏状态:函数的行为不再只由参数决定,同样的参数在不同时刻可能画出不同的帧,这给测试和复现添麻烦。二是不可重入:如果两个线程同时调它,cnt++ 会互相踩。三是多实例困难:想同时显示两条进度条,一个静态变量不够用。

改成用参数传状态就没这些毛病:

/* 状态由调用者持有,函数变成纯函数 */
void FlushProcess(double total, double current, int *cnt)
{
const char *label = "|/-\\\\";
int len = strlen(label);
...
printf("[%-100s][%.1f%%][%c]\\r", buffer, rate * 100, label[*cnt % len]);
(*cnt)++;
fflush(stdout);
}

代价是调用方要多维护一个变量。小工具里用 static 省事,正式工程里更推荐让状态显式一点。

LogProcess 里的 static int last 是同一个套路,只不过它记的是「上次打过的百分比」,用来去重。

测试代码用例

三次进化对应三个问题,一次解决一个。

V1 进度条自己写循环 问题:节奏被写死,别人插不上手

V2 抽出 FlushProcess() 问题:业务里写死了函数名,编译期耦合

V3 加 callback_t 参数 问题:契约只约束类型,不约束语义 ← bug 从这来

在这里插入图片描述 图 2 进度条的三次进化

V1:进度条自己把循环也攥在手里

// 文件:v1_coupled/process.c
#include "process.h"
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#define NUM 101 /* 100 个 '=' + 1 个字符串结束符 '\\0' */
#define STYLE '='

void process_v1(void)
{
char buffer[NUM];
memset(buffer, 0, sizeof(buffer)); /* 全部清零,buffer[100] 保持 '\\0' */
const char *label = "|/-\\\\"; /* 实际 4 个字符:| / – \\ */
int len = strlen(label);

int cnt = 0;
while (cnt <= 100)
{
/* \\r 回车不换行 → 下一帧覆盖上一帧,进度条原地刷新
* %-100s 左对齐、最小宽度 100 → 进度条长度固定
* 这一行必须在写入 buffer[cnt] 之前,见本章分析题 */

printf("[%-100s][%d%%][%c]\\r", buffer, cnt, label[cnt % len]);
fflush(stdout); /* 不刷新就看不到实时效果 */

buffer[cnt] = STYLE;

cnt++;
usleep(50000); /* 50ms 一步,约 5 秒走完 */
}

printf("\\n"); /* 把光标从被覆盖的行里放出来 */
}

逐行看几个点。buffer 是 101 个字节,前 100 个放等号,最后一个必须是 '\\0',因为 printf 的 %s 靠它判断字符串在哪结束。memset 先把整块清零,就是给这个结束符留位置。

\\r 是回车,作用是把光标移回本行开头但不换行。下一帧从这里重新画,就把上一帧盖掉了。如果写成 \\n,每一帧都会另起一行,屏幕上就是一堵墙。

%-100s 里的 – 是左对齐,100 是最小宽度。buffer 里现在只有 cnt 个等号,右边的空位由 printf 自动补空格补满 100 个字符,所以整行长度始终不变,百分比和光标符号的位置也就不会左右乱跳。

label[cnt % len] 让 |、/、-、\\ 四个字符轮流出现,这就是那个转圈的光标。取余是为了在 0 到 100 之间循环时不会越出这 4 个字符。

问题也在这段代码里:进度条自己写了 while (cnt <= 100),它只认 0 到 100 这 100 步。业务那边的进度是多少、几步走完,它一概不知,也没法把值喂进来。想显示别的进度,只能再抄一个几乎一样的函数。

V2:把节奏交出去

V2 把循环从进度条里拿掉,只剩「给我两个数,我画一帧」。

// 文件:v2_split/process.c
void FlushProcess(double total, double current)
{
char buffer[NUM];
memset(buffer, 0, sizeof(buffer));
const char *label = "|/-\\\\";
int len = strlen(label);

/* static:只初始化一次,生命周期贯穿整个进程
* 作用:跨多次调用记住 cnt,让光标 |/-\\ 一直转下去
* 隐患:隐藏状态、不可重入,正式工程更推荐用参数传状态 */

static int cnt = 0;

/* 当前进度对应多少格 '=' */
int num = (int)(current * 100 / total);

int i = 0;
for (; i < num; i++)
{
buffer[i] = STYLE;
}

double rate = current / total;
cnt %= len;
printf("[%-100s][%.1f%%][%c]\\r", buffer, rate * 100, label[cnt]);
cnt++;
fflush(stdout);
}

num 是这一帧要画几格等号,由 current 和 total 算出来;rate 是给百分比用的。两个数都是参数,进度条从此不关心是谁在下载、下了多久。

复用问题解决了,但业务那边长这样:

// 文件:v2_split/main.c
void DownLoad(void)
{
double current = 0;
while (current <= total)
{
FlushProcess(total, current); /* ← 硬编码:显示方式写死在业务里 */
usleep(3000);
current += speed;
}

printf("\\ndownload %.2lfMB Done\\n", current);
}

DownLoad 里出现了 FlushProcess 这个名字,意味着它必须认识进度条这个模块。想把进度换成日志、想同时输出到网页,只能回来改 DownLoad 的源码再重编。这就是编译期耦合。

V3:把函数名换成参数

V3 的核心改动只有两处,先看头文件多出来的那一行:

// 文件:v3_callback/process.h
typedef void (*callback_t)(double total, double current);

改动一:DownLoad 的形参多了一个 callback_t cb。

// 文件:v3_callback/main.c
void DownLoad(callback_t cb)
{
double current = 0;
while (current <= total)
{
cb(total, current); /* 谁来干,由调用者决定 */
usleep(3000);
current += speed;
}
printf("\\ndownload %.2lfMB Done\\n", current);
}

改动二:原来写死的 FlushProcess(total, current),换成 cb(total, current)。

两处改完,依赖方向反了过来。以前是 DownLoad 依赖 FlushProcess,现在是 DownLoad 和 FlushProcess 都依赖 callback_t 这一份契约,谁也不认识谁。装配的活交给 main:

// 文件:v3_callback/main.c
int main(void)
{
DownLoad(FlushProcess); /* 插进度条 */
DownLoad(FlushProcess);
...

DownLoad(LogProcess); /* 插日志:DownLoad 零改动 ← 解耦验收 */
return 0;
}

main 在这里的角色叫组合根:整个程序里只有它同时认识业务和具体实现,由它决定把谁插给谁。

验收:换一个实现,业务不变

日志版实现是这么写的:

// 文件:v3_callback/process.c
void LogProcess(double total, double current)
{
static int last = 1; /* 记住上次输出的百分比 */
int pct = (int)(current * 100 / total);

if (pct == last) return; /* 百分比没变就不打印,避免刷屏 */
last = pct;

printf("[log] %.2lfMB / %.2lfMB (%d%%)\\n", current, total, pct);
}

它和进度条共享同一个签名,所以能被同一个 DownLoad 调用。DownLoad 那一行代码没动过,输出就从一行不断刷新的横条变成了逐行追加的日志。这就是解耦要达成的效果。

三版对照与精确 diff

版本核心改动解决了什么还留着什么问题
V1 进度条自己写 while 无,只是跑通 节奏写死,无法复用
V2 抽出 FlushProcess(total, current) 复用:任何循环都能调 业务里写死函数名,编译期耦合
V3 加 typedef 回调类型 解耦:显示方式运行期可替换 契约只约束类型,不约束语义

V2 到 V3 的精确 diff:

void DownLoad(callback_t cb) /* 改动一:多一个参数 */
{
double current = 0;
while (current <= total)
{
– FlushProcess(total, current); /* 改动二:函数名换成参数 */
+ cb(total, current);
usleep(3000);
current += speed;
}
}

整篇代码里真正为了解耦而改的,就这两行。

Makefile 逐行讲

四套目录共用一份 Makefile,把它拷进任意一个目录就能用。

# 文件:progressbar/Makefile
SRC=$(wildcard *.c) # 当前目录下所有 .c 文件
OBJ=$(SRC:.c=.o) # 把 .c 后缀替换成 .o
BIN=processbar # 最终可执行文件名

$(BIN):$(OBJ) # 目标 : 依赖
gcc -o $@ $^ # $@ = 目标名 $^ = 所有依赖文件

%.o:%.c # 模式规则:任何一个 .o 由同名 .c 生成
gcc -c $< # $< = 第一个依赖

.PHONY: clean
clean:
rm -f $(OBJ) $(BIN)

$(wildcard *.c) 把当前目录里所有 .c 列出来,加一个文件不用改 Makefile。$(SRC:.c=.o) 是做后缀替换,把列表里的 .c 全换成 .o,得到目标文件列表。

%.o:%.c 是模式规则,含义是「任何一个 .o,都可以由同名的 .c 生成」。% 是通配的那一段,$< 是这条规则里第一个依赖,也就是当前那个 .c;$@ 是当前要生成的目标。少了模式规则就要给每个源文件写一条,文件一多没法维护。

两个坑要记住。第一条,命令行前面的缩进必须是真正的制表符,用空格会直接报 missing separator,而且看起来完全一样,只能靠 cat -A 把制表符显示成 ^I 才看得出来。第二条,.PHONY 后面必须跟目标名:.PHONY: 这样只写冒号等于什么都没声明,.PHONY: clean 才是把 clean 声明成伪目标。目录里恰好有个叫 clean 的文件时,少了这行清理命令就永远不会执行。

三套版本的运行结果

下面都是这台 CentOS 7 上跑出来的真实回显。

先看工具版本和目录结构。

[cocatrice@hcss-ecs-4cd1 progressbar]$ rpm -q gcc gdb cgdb make epel-release
gcc-4.8.5-44.el7.x86_64
gdb-7.6.1-120.el7.x86_64
cgdb-0.6.8-1.el7.x86_64
make-3.82-24.el7.x86_64
epel-release-7-14.noarch
[cocatrice@hcss-ecs-4cd1 progressbar]$ cd v1_coupled && cp ../Makefile . && make
gcc -c main.c
gcc -c process.c
gcc -o processbar main.o process.o
[cocatrice@hcss-ecs-4cd1 v1_coupled]$ ls
Makefile main.c main.o process.c process.h process.o processbar

V1 是进度条自己循环的版本,一秒钟 20 帧,五秒走完。

[cocatrice@hcss-ecs-4cd1 v1_coupled]$ ./processbar | tr '\\r' '\\n' | head -3
[ ][0%][|]
[= ][1%][/]
[== ][2%][-]
[cocatrice@hcss-ecs-4cd1 v1_coupled]$ ./processbar | tr '\\r' '\\n' | tail -4
[================================================================================================== ][98%][-]
[=================================================================================================== ][99%][\\]
[====================================================================================================][100%][|]

[cocatrice@hcss-ecs-4cd1 v1_coupled]$ ./processbar | tr '\\r' '\\n' | wc -l
102

终端上看到的是同一行被反复覆盖,这里把它接进管道,再把每帧末尾的回车换成换行,才能一帧一行地摊开看。102 行是 0 到 100 加最后一行的换行。

管道里能看清是因为程序自己调了 fflush,这一点后面单独讲。

V2 把节奏交给业务方,循环条件变成 current <= total,进度条从 0 画到 100。

[cocatrice@hcss-ecs-4cd1 v2_split]$ ./processbar | tr '\\r' '\\n' | tail -5
[=================================================================================================== ][99.8%][/]
[=================================================================================================== ][99.9%][-]
[====================================================================================================][100.0%][\\]

download 1025.00MB Done

注意最后那行 download 1025.00MB Done。总大小写的是 1024,这里报 1025,因为循环退出前又加了一次 speed:current 涨到 1025 时才不满足 <= total,打印的正是这个已经超出去的值。

V3 换一种实现验收解耦:同一份 DownLoad,传日志函数进去,输出立刻变成逐行追加。

[cocatrice@hcss-ecs-4cd1 v3_callback]$ ./processbar | tr '\\r' '\\n' | tail -6
[log] 994.00MB / 1024.00MB (97%)
[log] 1004.00MB / 1024.00MB (98%)
[log] 1014.00MB / 1024.00MB (99%)
[log] 1024.00MB / 1024.00MB (100%)

download 1025.00MB Done

LogProcess 里那句去重逻辑在这里看得最清楚:1024 步循环只输出了 101 行日志,百分比没变的时候直接返回,否则屏幕上会刷出上千行。

试验中遇到的几个报错

  • 调试信息没加,断点就落不到源码行上。
  • [cocatrice@hcss-ecs-4cd1 v1_coupled]$ gcc main.c process.c -o pb_nodebug
    [cocatrice@hcss-ecs-4cd1 v1_coupled]$ gdb -q -batch -ex 'break process_v1' -ex run ./pb_nodebug
    Breakpoint 1 at 0x4006f1

    Breakpoint 1, 0x00000000004006f1 in process_v1 ()

    断点确实停住了,但停在一个十六进制地址上,没有文件名也没有行号。加 -g 重编之后同样一条命令:

    [cocatrice@hcss-ecs-4cd1 v1_coupled]$ gdb -q -batch -ex 'break process_v1' ./pb_debug
    Breakpoint 1 at 0x4006f5: file process.c, line 12.
    [cocatrice@hcss-ecs-4cd1 v1_coupled]$ gdb -q -batch -ex 'info line process_v1' ./pb_debug
    Line 10 of "process.c" starts at address 0x4006ed <process_v1> and ends at 0x4006f5 <process_v1+8>.

    同一个函数,地址从 0x4006f1 变成 0x4006f5,多出来的 file process.c, line 12 就是 -g 的功劳。info line 还能把源码行和指令地址的对应关系直接问出来。

  • Makefile 里命令前用了空格,报的是这个:
  • [cocatrice@hcss-ecs-4cd1 bug]$ cat -A Makefile.bad | head -8
    SRC=$(wildcard *.c)$
    OBJ=$(SRC:.c=.o)$
    BIN=processbar$
    $
    $(BIN):$(OBJ)$
    gcc -o $@ $^$
    $
    %.o:%.c$
    [cocatrice@hcss-ecs-4cd1 bug]$ make -f Makefile.bad; echo rc=$?
    Makefile.bad:6: *** missing separator. Stop.
    rc=2

    cat -A 把行尾显示成 $,把那行开头的四个空格原样显示出来;正常应该是 ^I。报错里的 6 就是出问题的那一行,返回码 2 是 make 的通用失败码。

  • 回调签名不匹配,编译器会给出警告。
  • 故意写一个参数类型不对的函数传进去:

    [cocatrice@hcss-ecs-4cd1 bug]$ gcc -g -Wall wrong.c process.c -o wrong 2>&1 | head -8; echo rc=$?
    wrong.c: In function 'main':
    wrong.c:23:5: warning: passing argument 1 of 'DownLoad' from incompatible pointer type [enabled by default]
    DownLoad(WrongShow);
    ^
    wrong.c:8:6: note: expected 'callback_t' but argument is of type 'void (*)(int, int)'
    void DownLoad(callback_t cb)
    ^
    rc=0

    note 那行把契约两边都打了出来:形参要的是 callback_t,实参给的是 void (*)(int, int)。这一类错误编译器能替你把关,虽然只是警告不是错误,返回码仍然是 0,但看到它就该改,别顺着往下编。

    边界情况

    total 为 0 时程序不会崩,但输出已经不是人话了。

    [cocatrice@hcss-ecs-4cd1 bug]$ gcc -g m0.c process.c -o m0 && timeout 5 ./m0 | tr '\\r' '\\n' | tail -3
    [ ][inf%][|]

    download 3.00MB Done
    rc=0

    浮点除法不报错,current / total 直接得到无穷大,printf 把它打印成 inf%。同一行里 (int)(current * 100 / total) 把无穷大强转成整数属于未定义行为,编译器的警告选项也管不到这里。真正的下载器里 total 是先请求拿到大小才知道的,拿不到就该提前返回,而不是让它一路算进这个函数。

    static int cnt 在多线程下会打架,这也是为什么 FlushProcess 被标注成不适合并发调用。同一个进程里开两个线程各下载一个文件,两条进度条会互相覆盖光标,因为 \\r 只管回到行首,不管这一行该归谁。

    buffer 的大小是 101,一旦 num 超过 100 就会越界写栈。这不是边界情况,这是下一章的正式开始。

    [cocatrice@hcss-ecs-4cd1 bug]$ ./processbar | tr '\\r' '\\n' | tail -3
    [================================================================================================================333333?\\?x][120.0%][\\]

    download 12.00MB Done

    进度条冲到 120.0%,等号后面冒出一串不该出现的字符,而程序返回码是 0,一声不响地正常退出了。这就是最危险的那一类 bug。

    调试实战

    bug 版相对 V3 只改了一处:current += speed 从回调之后挪到了回调之前。

    void DownLoad(callback_t cb)
    {
    double current = 0;
    while (current <= total)
    {
    – cb(total, current); /* 正确顺序:先报进度 */
    – usleep(3000);
    – current += speed; /* 后累加 */
    + current += speed; /* ← BUG:先累加 */
    + cb(total, current); /* ← 再报进度,此时已经超了 */
    + usleep(3000);
    }
    }

    改之前,回调收到的 current 永远不超过 total,最后帧是 100%;改之后,最后一次回调收到的是加过一次 speed 的值,冲过了总量。FlushProcess 里没有任何地方检查这件事,于是 num 算出 120,循环往只有 101 字节的数组里写 120 个等号。

    用两个参数组合看这件事。用例一是 total = 10.0、speed = 3.0,越界 19 字节;用例二是 total = 1.0、speed = 100.0,num 直接到 10000。

    用例一:先别急着修,抓住现行

    程序不崩,只是结果不对。这种 bug 靠 printf 很难查,因为你不知道该在哪一行打印。用条件断点直接停在出事的那一帧:

    break FlushProcess if current > total

    cgdb 里的实际操作是这样的:命令窗里敲上面这行,然后 run。程序跑到第 101 次调用时才满足条件,之前一百次都是全速跑过去的。

    4|
    5| #define NUM 101
    6| #define STYLE '='
    7|
    8| void FlushProcess(double total, double current)
    9| {
    10| char buffer[NUM];
    11+> memset(buffer, 0, sizeof(buffer));
    12| const char *label = "|/-\\\\";
    13| int len = strlen(label);
    14|
    15| static int cnt = 0;
    16|
    17| int num = (int)(current * 100 / total);
    /home/cocatrice/lab7/progressbar/bug/process.c
    Breakpoint 2, FlushProcess (total=10, current=12) at process.c:11
    Missing separate debuginfos, use: debuginfo-install glibc-2.17-326.el7_9.3.x86_64
    (gdb)

    源码窗自动滚到 process.c 第 11 行,行首那个 +> 就是 cgdb 标出来的当前位置。

    逐步取证

    停下来之后不急着看代码,先把数字摆出来。先 until 18 走过几条声明,让 num 算出来。

    11| memset(buffer, 0, sizeof(buffer));
    12| const char *label = "|/-\\\\";
    13| int len = strlen(label);
    14|
    15| static int cnt = 0;
    16|
    17| int num = (int)(current * 100 / total);
    18+> int i = 0;
    19| for (; i < num; i++)
    20| {
    21| buffer[i] = STYLE;
    22| }
    23|
    24| double rate = current / total;
    /home/cocatrice/lab7/progressbar/bug/process.c
    (gdb) p current
    $1 = 12
    (gdb) p total
    $2 = 10
    (gdb) p num
    $3 = 120
    (gdb) p sizeof(buffer)
    $4 = 101
    (gdb)

    四个数字连起来看,结论已经出来了:当前量 12,总量 10,超了两个单位;要写 120 个等号,而数组只有 101 字节。越界 19 字节。

    这一步不需要理解代码逻辑,四个数之间的关系就说明了一切。p 命令后面可以跟表达式,sizeof 也能直接问,这是判断越界的固定动作。

    在这里插入图片描述 图 3 越界写栈的过程

    看内存:越界的第一刀砍在哪

    接着让程序往下跑,只在下标正好走到 100 的那一次停下来:

    break process.c:21 if i == 100
    continue

    14|
    15| static int cnt = 0;
    16|
    17| int num = (int)(current * 100 / total);
    18| int i = 0;
    19| for (; i < num; i++)
    20| {
    21+> buffer[i] = STYLE;
    22| }
    23|
    24| double rate = current / total;
    25| cnt %= len;
    26| printf("[%-100s][%.1f%%][%c]\\r", buffer, rate * 100, label[cnt]);
    27| cnt++;
    /home/cocatrice/lab7/progressbar/bug/process.c
    Breakpoint 3, FlushProcess (total=10, current=12) at process.c:21
    (gdb) p i
    $5 = 100
    (gdb) x/8bx &buffer[96]
    0x7fffffffe340: 0x3d 0x3d 0x3d 0x3d 0x00 0x00 0x00 0x00
    (gdb) bt
    #0 FlushProcess (total=10, current=12) at process.c:21
    #1 0x00000000004006ed in DownLoad (cb=0x400739 <FlushProcess>) at main.c:14
    #2 0x0000000000400732 in main () at main.c:22
    (gdb)

    x/8bx 从 buffer[96] 开始打 8 个字节:前四个是 0x3d,也就是等号 = 的 ASCII 码;第五个 0x00 是 memset 留下的字符串结束符,位置正好是 buffer[100]。

    再往下的指令会把 0x3d 写进 buffer[100],结束符就没了。printf 的 %s 找不到结尾,只能一路往后读栈上的字节,直到碰见下一个 0x00。前面看到的那串 333333? 后面跟着几个读不出来的字节,就是这么来的:它不是乱码,是栈上别人的数据被当成字符串打了出来。

    bt 给出调用链:main 调 DownLoad,DownLoad 通过函数指针调到 FlushProcess。三层里只有最下面这层在写内存,但它手里的 current 是上面那层给的。这就是为什么修的时候要先问「该由谁兜底」。

    用例二:同一个 bug,换了参数,挂法不同

    换成 total = 1.0、speed = 100.0,num 变成 10000,越界 9900 字节。按直觉应该直接段错误,实测不是。

    [cocatrice@hcss-ecs-4cd1 bug]$ timeout 8 ./crash_plain; echo rc=$?
    rc=124

    返回码 124 是 timeout 的返回码,意思是 8 秒到了程序还没结束。它既没崩也没退出,卡住了。用 Ctrl+C 打断它,看它此刻在哪:

    (gdb) run
    Starting program: /home/cocatrice/lab7/progressbar/bug/./crash_plain
    ^C
    Program received signal SIGINT, Interrupt.
    FlushProcess (total=1, current=100) at process.c:21
    21 buffer[i] = STYLE;
    (gdb) info locals
    buffer = '=' <repeats 101 times>
    label = 0x3d3d3d3d3d3d3d3d <Address 0x3d3d3d3d3d3d3d3d out of bounds>
    len = 1027423549
    cnt = 0
    num = 1027423549
    i = 129
    rate = 1.0387856152602562e-13
    (gdb) p num
    $2 = 1027423549
    (gdb)

    num 是 1027423549,换成十六进制就是 0x3d3d3d3d,也就是四个等号。越界写的那些 0x3d 盖在了栈上,把循环自己的上界 num 也一起盖掉了:本来要写 10000 次,现在变成十亿次,循环永远走不完。label 这个指针同样被盖成了 0x3d3d3d3d3d3d3d3d,len 也一起遭殃。

    这就是未定义行为的样子:越界之后程序的行为不再由源码决定,而由「你踩坏了哪几个字节」决定。

    换一个优化级别,程序又挂了

    -O0 下 i 和 num 都住在栈上,所以被自己踩坏;把它们挪进寄存器,越界就踩不到了。编译时加 -O2 就会这样:

    [cocatrice@hcss-ecs-4cd1 bug]$ gcc -g -O2 main_crash.c process.c -o crash_o2p
    [cocatrice@hcss-ecs-4cd1 bug]$ timeout 10 ./crash_o2p; echo rc=$?
    Segmentation fault (core dumped)
    rc=139

    同一个源文件、同一行 bug,-O0 是无限循环,-O2 是段错误。10000 次写全部做完之后,函数的返回地址已经被盖成了 0x3d3d3d3d3d3d3d3d,ret 一跳就跳到了不存在的地址上。

    顺手把栈保护也验一下。习惯上会说 gcc 默认开了栈保护,但这台机器上的 gcc 4.8.5 并没有:

    [cocatrice@hcss-ecs-4cd1 bug]$ gcc -Q –help=common | grep -i 'stack-protector' | head -3
    -Wstack-protector [disabled]
    -fstack-protector [disabled]
    -fstack-protector-all [disabled]
    [cocatrice@hcss-ecs-4cd1 bug]$ objdump -d p_def | sed -n '/<FlushProcess>:/,/^$/p' | grep -c 'fs:0x28'
    0

    fs:0x28 是栈保护的 canary 从线程控制块里取值的那条指令,一个都搜不到,说明 FlushProcess 里根本没有检查代码。所以在这台机器上既看不到 *** stack smashing detected ***,也谈不上用 -fno-stack-protector 把它关掉,加不加编译出来的东西是一样的。

    到了新版本的编译器上默认值会变,换成 Ubuntu 或者高版本 GCC 时,同一份代码可能先被 canary 拦下来报栈被打碎,那也是一种罪证,只是报的地点从崩溃现场挪到了函数返回处。

    事后验尸:让 core 文件说话

    程序崩了但来不及看,或者崩在别人机器上,就靠 core 文件。系统默认把 core 大小限制成 0,先放开:

    [cocatrice@hcss-ecs-4cd1 bug]$ ulimit -c unlimited
    [cocatrice@hcss-ecs-4cd1 bug]$ ./crash_o2p
    Segmentation fault (core dumped)
    [cocatrice@hcss-ecs-4cd1 bug]$ ls -l core.*
    -rw——- 1 cocatrice cocatrice 249856 Sep 21 22:07 core.23437
    [cocatrice@hcss-ecs-4cd1 bug]$ file core.23437
    core.23437: ELF 64-bit LSB core file x86-64, version 1 (SYSV), SVR4-style,
    from '============', real uid: 1000, …

    file 那行有个细节值得停下来看一眼:程序名本该是 crash_o2p,core 文件里记的却是 '============'。因为越界的等号把栈上的 argv[0] 指向的字符串也写坏了,core 文件记录进程名时读到的就是这串等号。还没开始调试,core 文件自己就把案情说了一半。

    打开现场:

    [cocatrice@hcss-ecs-4cd1 bug]$ gdb -q ./crash_o2p core.23437
    (gdb) bt
    #1089 0x3d3d3d3d3d3d3d3d in ?? ()
    #1090 0x3d3d3d3d3d3d3d3d in ?? ()

    #1097 0x3d3d3d3d3d3d3d3d in ?? ()
    (gdb) info registers rip rsp
    rip 0x4006d80x4006d8 <FlushProcess+72>
    rsp 0x7ffd2d011d400x7ffd2d011d40
    (gdb) x/6i $pc
    => 0x4006d8 <FlushProcess+72>:movb $0x3d,(%rax)
    0x4006db <FlushProcess+75>:add $0x1,%rax
    0x4006df <FlushProcess+79>:cmp %rdx,%rax
    0x4006e2 <FlushProcess+82>:jne 0x4006d8 <FlushProcess+72>
    0x4006e4 <FlushProcess+84>:divsd %xmm0,%xmm1

    bt 打出了一千多层回溯,每一层的返回地址都是 0x3d3d3d3d3d3d3d3d,整条调用链被同一串等号铺满,这就是越界的规模。info registers 和 x/6i $pc 给出出事时正在执行的指令:movb $0x3d,(%rax) 把 0x3d 写进 %rax 指向的地址,后面跟着自增、比较、跳回去,正是那个 for 循环。

    有了这几样,即便程序是别人的、机器已经重启,也能把当时发生了什么讲清楚。

    三种修法,以及该由谁兜底

    第一种是最小改动,把顺序还原:

    void DownLoad_A(callback_t cb)
    {
    double current = 0;
    while (current <= total)
    {
    cb(total, current); /* 先报进度 */
    usleep(3000);
    current += speed; /* 后累加,current 永远 <= total */
    }
    printf("\\ndownload %.2lfMB Done\\n", current);
    }

    改完跑一遍,进度条不再冲过头,但最后停在 90%:

    [cocatrice@hcss-ecs-4cd1 bug]$ ./fix1 | tr '\\r' '\\n' | tail -3
    [========================================================================================== ][90.0%][\\]

    download 12.00MB Done

    因为最后一次回调拿到的是 9,回调之后才加到 12 退出循环,所以永远画不到 100%。这不是 bug,是「先报进度后干活」这个顺序的必然结果,要画到满格就得在循环结束后补一次回调。

    第二种是业务方限流,报出去的进度不超过总量:

    void DownLoad_B(callback_t cb)
    {
    double current = 0;
    while (current <= total)
    {
    current += speed;
    cb(total, current > total ? total : current); /* 业务方兜底 */
    usleep(3000);
    }
    printf("\\ndownload %.2lfMB Done\\n", current);
    }

    [cocatrice@hcss-ecs-4cd1 bug]$ ./fix2 | tr '\\r' '\\n' | tail -3
    [====================================================================================================][100.0%][\\]

    download 12.00MB Done

    进度条画到了 100%,尾部那句 download 12.00MB Done 还是老样子,因为它打的是业务自己的 current,没有跟着被夹住。

    第三种是显示方自己防守,在 FlushProcess 里加两道检查:

    if (current > total) current = total;
    int num = (int)(current * 100 / total);
    if (num > 100) num = 100; /* 双保险 */

    三种改法都能让程序不再越界,但它们的性质完全不同:

    • 只改业务方,显示方依然是脆的:换个调用者、换个参数,同样的事故会再来一次;
    • 只改显示方,错误被悄悄吞掉:业务把 12 当成进度报上来这件事永远暴露不出来,界面上看起来一切正常;
    • 工程上的常见做法是两边都设防:接口内部做边界检查保证不崩,业务方同时修掉真正的逻辑错误保证不掩盖。

    这件事的根子在契约上。typedef void (*callback_t)(double, double) 只约束了「两个 double」,没有约束「第一个是总量、第二个不能超过第一个」。类型能查的,编译器替你查;类型查不出的,只能靠命名、注释和断言。

    再往前走一步:把 cb(current, total) 的两个参数写反,编译器会报错吗?

    [cocatrice@hcss-ecs-4cd1 bug]$ gcc -g -Wall -Wextra rev.c process.c -o rev; echo rc=$?
    rc=0
    [cocatrice@hcss-ecs-4cd1 bug]$ timeout 5 ./rev | tr '\\r' '\\n' | tail -2
    [ ][inf%][|]

    -Wall -Wextra 全开着,一个警告都没有,返回码 0,程序照编不误。两个参数都是 double,类型上完全合法,至于哪个是总量、哪个是当前量,编译器无从判断。运行起来的结果是进度条显示 inf%:FlushProcess 拿第一个参数当总量,而它收到的其实是当前量 0。这就是前面那句伏笔的答案:类型能查的编译器帮你查,类型查不出的只能靠命名和约定。

    踩坑点

    • cgdb 里敲命令没反应,先看人在哪个窗口,源码窗要按 i 回到命令窗;界面被程序输出冲乱就按 Ctrl+L 重画。
    • 断点打上了但单步乱跳、变量打印不出来,八成是编译时忘了加 -g,重新编译再调试,别急着怀疑 gdb。
    • 越界之后程序的行为和源码逻辑无关,只和踩坏了哪几个字节有关,-O0 和 -O2 能给出完全不同的结局,分析这类问题必须交代优化级别。
    • -g 只是往可执行文件里加符号表和行号,不改代码逻辑;不加时断点能停在函数上,但停在裸地址上,看不出是哪一行。
    • 函数指针声明的括号不能省,void (*p)(double) 是指针,void *p(double) 是返回指针的函数,两回事。
    • typedef 是把变量名的位置换成类型名,typedef void (*cb)(double) 之后 cb 就是类型,再声明变量时不用重复写星号和括号。
    • 回调契约只约束类型不约束语义,参数顺序写反编译器不会报错,这类约定要靠命名和注释补上。
    • \\r 只把光标送回行首,不换行;要盖住上一帧还得保证这一行长度固定,所以 %-100s 和它是一对。
    • fflush(stdout) 不只是让画面实时刷新,程序异常退出时缓冲区里的内容会直接丢失,少这一行可能连崩溃前的输出都看不到。
    • static 局部变量跨调用保住了状态,代价是函数不再只由参数决定行为,不可重入也不能多实例。
    • Makefile 的命令行缩进必须是制表符,.PHONY 后面必须写目标名,这两条都属于「写错了看起来也没问题」的类型。

    本篇总结(模拟面试问题)

    问:gcc -g 做了什么,不加会怎样? 核心要点:把调试信息(符号表、行号、变量名、类型)写进可执行文件。不加时程序照样能跑,但 gdb 只能按地址下断点,看不到源码行号,也打印不出变量名。

    问:gdb 里 next 和 step 有什么区别? 核心要点:next 把函数调用当成一步走完,step 会钻进被调用的函数里。怀疑问题在被调函数里时用 step,只想看自己这层流程时用 next。

    问:display 和 watch 分别解决什么问题? 核心要点:display 每次停下来自动打印一次,适合反复看同一个值;watch 在那个值发生变化时自动停下并指出改它的位置,适合查「谁把它改坏了」。

    问:条件断点怎么写,什么场景用? 核心要点:break 函数名 if 条件,条件是普通的 C 表达式。循环里只有某一次出错时用它,程序在条件不满足时全速运行,不用手动按 continue。

    问:函数指针的声明怎么读? 核心要点:把函数声明里的函数名换成 (*p),void f(double) 变成 void (*p)(double)。括号不能丢,丢了就从函数指针变成返回指针的函数。

    问:回调的本质是什么? 核心要点:框架决定什么时候调,调用者决定调什么。被调用的函数通过函数指针参数传进来,框架只依赖这个类型,不依赖任何具体实现。

    问:为什么加了回调就算解耦?解耦的到底是谁和谁? 核心要点:解耦前业务函数直接写出显示函数的名字,依赖是编译期的;解耦后两者都只依赖 callback_t 这个类型,业务函数不再认识任何具体实现,显示方式可以在运行期由 main 决定插哪一个。

    问:-O2 之后段错误,-O0 却是死循环,怎么解释? 核心要点:-O0 下循环变量和上界都放在栈上,越界写把上界 num 改成了 0x3d3d3d3d,循环条件永远成立;-O2 把它们放进寄存器,越界踩不到,循环跑完 10000 次,返回地址被覆盖成 0x3d3d3d3d3d3d3d3d,ret 一跳就崩。未定义行为的表现取决于编译器怎么安排内存。

    问:契约为什么会漏掉「current 不能大于 total」这种约定? 核心要点:类型系统只能表达「两个 double」,表达不了取值范围和参数含义。这类约束得靠命名、注释、断言或者把它收进接口内部检查。

    问:core 文件里进程名显示成 '============' 说明了什么? 核心要点:越界的等号把栈上 argv[0] 指向的字符串也覆盖了,内核写 core 记录进程名时读到的就是被写坏的内容,等于从侧面证实了越界的规模。

    问:char buffer[101] 里填了 100 个字符,最后一个字节要放什么? 核心要点:放 '\\0'。%s 靠它判断字符串结束,缺了它会一路读到后面的内存。

    问:\\r 和 \\n 有什么区别? 核心要点:\\r 只把光标移回本行开头,\\n 是回车加换行。进度条原地刷新用前者。

    问:typedef void (*cb)(double) 里的 cb 是什么? 核心要点:是类型名,表示「指向返回 void、接受一个 double 的函数的指针」,可以直接用来声明变量或当参数。

    问:把 fflush(stdout) 删掉,输出重定向到文件时会发生什么? 核心要点:stdout 变成块缓冲,输出攒够一块才落盘,进度不再是实时的;程序正常退出时缓冲区会被刷出去,内容不丢;程序崩溃或被杀死时,没刷出去的部分就丢了。

    问:break FlushProcess if current > total 里的条件在什么时候求值? 核心要点:每次进入 FlushProcess 时求值一次,条件为真才停下。条件为假时程序继续全速运行,代价只有一次比较。

    问:x/8bx &buffer[96] 这三个部分分别是什么意思? 核心要点:x 是查看内存,8 是显示 8 个单元,b 是每个单元按字节显示,x 是十六进制输出,后面跟地址表达式。

    参考

    • GDB 官方说明,命令和扩展都在这:https://sourceware.org/gdb/documentation/
    • GDB 手册页,命令速查:https://man7.org/linux/man-pages/man1/gdb.1.html
    • cgdb 官方站点,界面操作说明:https://cgdb.github.io/
    • GCC 在线说明,-g 和各优化级别的含义:https://gcc.gnu.org/onlinedocs/
    • ELF 文件格式说明,core 和可执行文件都按它组织:https://man7.org/linux/man-pages/man5/elf.5.html
    • 函数指针与回调的官方说明:https://man7.org/linux/man-pages/man3/qsort.3.html
    赞(0)
    未经允许不得转载:171主机测评 » 【Linux】gdb/cgdb 实战
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址