技术: Linux 编程三大件儿

main tools of linux programming(gcc, gdb, make)

在刷apue或者学习Linux系统编程的时候, 最开始学习的是? 三大件儿:

  • gcc
  • gdb
  • makefile

正好礼拜天, 有时间总结一下(笑).

引子

gcc, gdb, makefile当前在Linux下编程比windows下编程困难的, 莫过于先跨过他们.
本文是一个常用选项的总结(不是教程), 说的都是工作经验, 工程实践.

btw: 感谢 《Linux C一站式编程这本书》, 当初由这本书入坑…

正文

gcc

gcc 是 GNU Compiler Collection 的缩写, gcc 是按模块化设计的, 可以加入新语言和新 CPU 架构的支持.
(g++也涵盖在此范围内)

编译过程

GCC的编译过程:

  • 预处理(Pre-Processing)
  • 编译(Compiling)
  • 汇编(Assembling)
  • 链接(Linking)

步骤示例:

Gcc *.c –o 1exe (总的编译步骤)
Gcc –E 1.c –o 1.i //宏定义 宏展开
Gcc –S 1.i –o 1.s
Gcc –c 1.s –o 1.o
Gcc 1.o –o 1exe

文本: 源程序, 预处理后被修改的源程序, 汇编替换后的程序
二进制: 可重定向的目标程序(.o), 可执行的目标程序(真正的可执行程序)

注意一下第二个阶段是编译阶段而不是汇编阶段, 将预处理过的代码编译成汇编代码, 第三阶段才是汇编, 由汇编指令生成机器代码.

编译选项

gcc常用编译选项:

-o 产生目标(.i、 .s、 .o、 可执行文件等)
-c 通知 gcc 取消链接步骤, 即编译源码并在最后生成目标文件
-E 只运行 C 预编译器
-S 告诉编译器产生汇编语言文件后停止编译, 产生的汇编语言文件扩展名为.s
-Wall 使 gcc 对源文件的代码有问题的地方发出警告(-W打开输出警告)
-Idir 将 dir 目录加入搜索头文件的目录路径
-Ldir 将 dir 目录加入搜索库的目录路径
-llib 链接 lib 库
-g 在目标文件中嵌入调试信息(地址和符号建立关联) 以便 gdb 之类的调试程序调试
-D 编译时提供宏(gcc -D__DEBUG__)

-L只能指定编译时路径, 动态库在运行时可能还要提供环境变量支持.

举例:
gcc -E hello.c -o hello.i(预处理)
gcc -S hello.i -o hello.s(编译)
gcc -c hello.s -o hello.o(汇编)
gcc hello.o -o hello(链接)

以上四个步骤, 可合成一个步骤:

  • gcc hello.c -o hello
    (直接编译链接成可执行目标文件)

一般都是需要中间文件的.o的, 因为一般都是多文件编程, 避免一个文件的改动导致需要全盘重新编译.

  • gcc -c hello.c 或 gcc -c hello.c -o hello.o
    (编译生成可重定位目标文件)

其他一些疑惑的问题:

  • 可重定向? 链接的时候链接器ld会检查一个可重定向的目标文件是否有其他文件提供其所需要的符号.
  • 只编译不链接会不会编译时报错? 如果没有用到具体的定义则不会, 比如ClassA *a; 但是ClassA *a = new ClassA则会报错(实例化没有定义的声明类型)
  • gcc调用编译工具链, 前一个命令的执行结果会传递给下一个命令, 可以使用 -pipe 选项查看在 /tmp目录下生成的临时文件.

    1
    2
    3
    4
    #!/bin/bash
    g++ main.cpp &
    sleep 0.05
    ls --color=auto /tmp/cc*
  • debug版本和release版本需要靠__DEBUG__宏来控制.

优化相关

调用函数之后, 返回值压栈, 之后需要给另外函数传参的时候又出栈到寄存器, 之后调用完成在压栈.
一系列过程, 汇编代码看起来有很多, 但是内存和寄存器来回其实浪费很多时间资源, 比如说入栈和出栈的过程.

编译器完全可以在编译期间进行代码优化以及编译过程优化:
一共有三级: -O1, -O2, -O3; (-O0是默认的)
低级别的时候就是进行的编译过程优化, 高级别的时候可能涉及修改代码编译优化(例如循环过程的步长增长优化)
并且级别越高, 需要编译器做的优化工作也就越多, 需要的运行内存也可能更多, 那么编译时间越长.

执行效率和编译时间之间, 就看你怎么选了.

  • -O1 提高运行效率, 减少可执行文件大小
  • -O2 进一步提高代码执行效率(优化器几乎全打开了)
  • -O3 优化器全部打开

一般O2就好; 但是你要debug时, 不要加优化.

链接原理

这一部分主要涉及两个内容:

  • 符号解析(声明引用和符号定义相联系)
  • 重定向((运行时)模块定义的绝对地址替换)

要说清楚, 就要说符号表, ELF文件格式(包括其各个段, 运行时加载的段);
具体可以参考ELF格式


gdb

gdb部分还是内容蛮多的, 基本的就一笔带过(其实也蛮多), 重点在于 多进程多线程的调试.
但是法无常法, 每个人都可能采用不同的手段进行, 这里只是说一些通用的, 常用的手段.

查看源码

l/list
list <line-number>
list <function>
list <line1, line2>

这里没有用 info <line-num> 这种查看源码对应内存地址的方式.

带参数运行

run/r
run

如果启动gdb运行可执行文件时忘记传入参数(gdb已经启动), 这个时候可以设置参数

set args 参数1 参数2 ...

(设置以后再run/r)
查看参数 show args

或者使用file命令
file <excutable> 载入文件, 之后在 run

继续执行

  • continue/c 运行到下一个断点或者观察点
  • next/n 运行下一个语句
  • step/s 步入函数执行
  • finish 跳出函数执行

断点

break/b
(断电通常设置在关键执行语句上, 不要设置在空行和注释上)

  • 设置断电

    break
    break (break main 表示在进入main函数就中断)
    break if

一般用在控制循环的次数 例如: b 16 if i==9 , 如果已经在16行设置了断点, 那么可以用condition设置变量 condition 16 if i==9 .
(不加条件则是取消断点)

多文件的时候设置断点?

break <filename:line-num>
break <filename:function>
  • 删除断点

    delete/d
    delete breakpoint

  • 禁用启用断点

    enable breakpoint
    disable breakpoint
    (简写 d b )

  • 清除断点

    clear 删除所有断点

    clear <breakpiont-num>
    

观察点

watch

观察点是cpu对某地址读写时触发中断, 通俗说就是 condition里设置的变量值改变时中断; 断点则是cpu执行某指令时.
设置观察点, 一般是设置对某个变量的预判条件. 观察点的其他命令(禁用启用,删除,查看等)都和断点类似.

查看信息

i/info

1
2
3
4
5
info locals  查看局部变量(特别是你尝试用print/call func(...)主动调用某函数时)
info break 查看断电(注意前面的编号)
info display 查看display设置的自动显示信息
info registers <reg-num> 查看寄存器
info all-registers

p/print

1
2
3
print <variable>  查看任何变量
print <file::variable> 指定查看全局变量
print <function::variable> 指定查看局部变量

例如:

1
2
p 'main.cpp':func::count
p func::count

(查看main.cpp中func函数中count的值)
(不用担心和c++的域作用福重复)

1
2
3
print func_name(arg1, arg2, ...) 进行函数调用
print <expression> 查看表达式的值(可以带上变量) 例如 print a-b
print <array-name/string-name> 查看数组或者容器的值 (但是不能查看指定长度, 看到的是全部)
  • (指定)内存查看
    使用@, 具体说: 第一个地址的值@长度

    print <*pointer-name/array-name>@len

(一般用于查看动态分配的,或者指定长度的)

  • print 输出格式

p/格式 <variable-name/array-name/string-name>
其中格式:

x/a 十六进制
u 十六进制无符号
d 十进制
o 八进制
t 二进制
c 字符类型
f 浮点格式

(查看一个变量的值, 之后可以用 print $num 的形式查看其历史值)

  • 查看内存(一般用于汇编调试)

    examine/x
    x[/nfu]

(多个参数可以一起使用, address可以是变量地址名)

  • 自动显示
    每次停住都会显示变量或者表达式的值

    display[/格式]

这里多一个格式 i 表示汇编指令
display/i $pc 显示gdb的环境变量(汇编指令的形式)

  • 取消自动显示

    undisplay 删除所有自动显示
    delete display 删除指定编号display信息(info display 可以查看到具体的信息)

(可以一次性写多个num)

  • 启用/禁用自动显示

    enable display

    disable display <num>
    

(不加时是作用于所有)

  • 设置显示选项

    set print address on/off 默认print的时候是显示地址的
    show print address 查看当前是否打开显示地址选项

  • 查看数组元素地址设置(默认不显示地址)

    set print elements on/off
    show print elements

  • 设置结构体/联合的显示
    (打开时是具体显示每个字段, 否则可能省略性显示)

    set print union on/off
    show print union

  • 设置分行显示

    set pretty on/off
    show print pretty

  • 设置虚函数表显示(特别是指针指向子类对象的时候)默认是关闭的

    set print object on/off
    show print object

  • 是否显示虚函数表 (默认关闭)

    set print vtbl on/off
    

    show print vtbl

  • 设置显示类的静态成分设置

    set print static-members on/off
    show print static-members

  • 设置环境变量

    set
    print

主要是为了调试方便, 需要临时量的时候. 表达式里面可以包含赋值 set $variable_num= *ptr , 之后也可以做一些运算 print array[$i]
(注意: 一旦包含了对变量的赋值, 可能改变程序的执行流程)

  • 查看某变量的类型

    whatis

使用shell

`shell [command]`

找出问题之后, 可以直接在gdb里面使用shell命令修改源程序 然后重新编译

shell gcc -g main.cpp -o main

编译后再运行, run.

栈帧

backtrace/bt和frame
一个栈就是一次函数调用, 一般跟踪栈的原因是:

  • info locals 查看调用某函数时该作用域的局部变量(比如查看main函数调用别的函数时, main函数内的变量)
  • 切换到具体的调用栈, 查看问题

特别是用bt查看栈帧之后, 就可以初步判断, 适当frame <num> 到具体编号的栈上.
(frame到具体的栈编号时, 已经可以看到调用信息和该调用的源码, 当然也可以借助 info locals 来看)

绑定

attach 绑定到已经运行的进程上:

gdb <excutable> <pid>

一般是拿来调试多进程程序或者后台运行的守护程序的. gdb进去之后, bt 可以看调用栈, 然后切换到你关心的地方进行查看, frame和info locals, print等. 已经载入gdb的可执行文件, 也可以使用attach <pid> 在gdb内部绑定.

调试完毕detach 脱离绑定, 让进行继续执行 或者 kill结束当前进程.

core文件

ulimit -c ulimited (Ubuntu平台默认不产生core dump文件, 所以要设置产生)

特别是在命令行运行的时候, 发生错误应该让它产生core dump文件. 运行时载入:

gdb <excutable> [core-file]

这样生成的调试信息就在你指定的core文件中. (gdb中仍旧可以查看info locals, print, info args) (如果一个程序有异常报错, gdb加载进去之后, 就直接run就行, 自然会停在报错的位置)

其他部分

  • 跳转执行(特别在循环里)

    jump

(跳转不会修改栈的内容, 但是跨函数跳跃的时候则难说, 一般都在一个函数里面跳跃)
(jump的本质是修改保存当前执行代码地址的寄存器的值, 相当于 set $pc=下一条指令的地址 )

  • 信号处理

    signal 一般是1-15号信号, 写可以写SIGINT这种程序名字

(外部发送的信号是被gdb截获的, 但是这里signal发送的直接发给被调试的程序)
一般用于程序中处理信号的代码调试.

hanle <sig-name> <what-to-do>

what-to-do, 可以把接收到的信号发送给程序, 打印一条信息, 停止程序或者只是停止程序而发送:

nostop 接收到信号, 不要停止, 自然也不发送给程序
stop  停止(不发送), 方便调试
print 接收到信号打印一条消息
noprint 收到信号不要显示消息
pass    发送消息, 但具体停止与否自行处理
nopass 停止运行, 但是不发消息

一般使用的是停止, 具体发不发送要看具体的需求. 例如: handle SIGPIPE stop print

  • 强制调用

    call func-name(arg1, arg2…)

  • 强制返回(某值)

    return [value]

  • gdb内搜索相关源码
    search regexprreverse-search regexpr
  • 程序源码语言相关

    show language
    set [language-name] 或者 set language [language-name]
    info frame 当前调用函数的程序语言
    info source 当前文件的程序语言

高级调试

这部分适合高级玩家, 对于计算机原理, 汇编语言比较熟悉的, 而不仅仅涉及栈帧层面.

info <line/func-name/file:line/file:func-name>  查看对应源码的内存地址

查看4个通用寄存器(使用 print 查看)

  • $ip 程序当前运行的指令地址
  • $pc 程序计数器
  • $fp 当前堆栈帧
  • $sp 栈指针
  • $pc 处理器状态

查看某函数的具体汇编代码:
disassemble <function>


makefile

make 这个东西, 从上学的时候, 开始就学过, 期间不断的巩固, 有些规则还是学了忘学了忘. 后来我总结了一条: 到用的时候再去查看手册吧.
btw: make在c/c++中的地位和maven,ant在java中地位相当, 一般都是用于工程管理, 不过现在用Cmake或者Xmake的比较多, 然而Makefile必须熟悉.

下面我以一个案例去简单说说其注意点:

核心内容

(主要牢记变量,包括自动变量(系统预定义的符号); 规则; 函数(主要是内置函数))

  • make的作用
    • 通过读入 Makefile 文件来执行大量的编译工作
    • 自动管理器能根据文件时间自动发现更新过的文件而减少编译的工作量
  • make的格式
    1
    2
    target: dependency_files //目标项:依赖项
    command //必须以 tab 开头, command 编译命令

一个最简单的案例: (main函数调用一个简单的打印函数, main.cpp, func.cpp)

1
2
3
4
5
6
main:main.o func.o
g++ -o main main.o func.o
main.o:main.cpp
g++ -c main.cpp
func.o:func.cpp
g++ -c func.cpp

放心吧, 工程项目中绝对没有这么简单, 这里只是讲一个demo. 但是有一点是一样的, make的执行流程都是: 为目标文件的依赖文件.

  • 特殊处理(伪目标)
    .PHONY 是 makefile 文件的关键字, 表示它后面列表中的目标均为伪目标, 例如:
1
2
3
.PHONY:sub_target
sub_target:
echo ‘b’ //通常用@echo “hello”

实体目标是为了编译出具体的文件, 那么伪目标呢?
通常用于清理文件, 强制重新编译等情况.

1
2
3
4
5
6
7
8
9
10
11
main:main.o func.o
g++ -o main main.o func.o
main.o:main.cpp
g++ -c main.cpp
func.o:func.cpp
g++ -c func.cpp

.PHONY:rebuild clean
rebuild:clean main
clean:
rm –rf main.o func.o main

最后的俩目标 rebuild 和 clean 都是伪目标, rebuild又依赖clean和main这俩目标(并且先执行clean 在执行main, 即在下一次编译之前先清理上一次的编译结果)

  • 执行目标
    1
    2
    3
    4
    5
    make  //直接make表示执行 makefile文件的第一个目标
    make clean //执行具体的目标, 此处是clean目标
    make func.o //执行具体的目标, 此处是func.o目标
    make rebuild //执行为目标 rebuild
    make -f xxx clean //当你的makefile指定为xxx名字时, -f来指定具体的规则文件

下面说 变量, 规则, 函数, 这三个机制是保证makefile更加通用, 提升编译效率的手段.


  • 变量
    变量用来代表字符串(通常可以是目标, 或者需要编译的文件等)
  • 定义变量:
    • 变量名=变量值 递规变量展开, 真正引用值得时候才展开//不推荐, 容易引发问题
    • 变量名:=变量值 立即展开 //通常采用这种形式
  • 引用变量
    • $(变量名) 和shell类似, 不同的是, 可以做左值

最开始的那个例子, 就可以写成:

1
2
3
4
5
6
7
8
9
10
11
OBJS:=main.o func.o
EXE:=main

$(EXE):$(OBJS)
g++ -o $(EXE) $(OBJS)
main.o:main.cpp
g++ -c main.cpp –o main.o
func.o:func.cpp
g++ -c func.cpp –o func.o
clean:
rm –rf $(EXE) $(OBJS)

(有过编程经验的人就能体会这样写的对于后续维护的好处, 我不说了)

变量种类:

  • 用户自定义变量, 预定义: 量, 自动变量, 环境变量
  • 自动变量: 指在使用的时候, 自动用特定的值替换

自动变量, 常用的有:

  • $@ 当前规则的目标(文件)
  • $< 当前规则的第一个依赖(文件)
  • $^ 当前规则的全部依赖文件, 以逗号分隔
  • $? 规则中日期新于目标文件的所有相关文件(即做过修改的文件), 以逗号分隔
  • $(@D) 目标文件的目录名部分
  • $(@F) 目标文件的文件名部分

还是上面那个例子, 再修改试试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
OBJS:= main.o func.o
EXE:=main
CFLAGS:=-Wall -g -O2 -fPIC
LIBFUNCSO:= libfunc.so
LIBFUNCA:= libfunc.a

$(EXE):$(OBJS) $(LIBFUNCSO) $(LIBFUNCA)
main.o: main.c
gcc -c $(CFLAGS) $< -o $@
func.o: func.c
gcc -c $(CFLAGS) $< -o $@
libfunc.a: func.o
ar srcv $@ $<
libfunc.so: func.o
gcc -shared -o $@ $<
#cp -f $@ /lib

.PHNOY:rebuild clean
rebuild:clean $(EXE)
clean:
rm -rf $(EXE) $(OBJS) $(LIBFUNCSO) $(LIBFUNCA)

内部预定义的变量:

  • AR 库文件打包程序默认为 ar
  • AS 汇编程序,默认为 as
  • CC c 编译器默认为 cc
  • CPP c 预编译器,默认为$(CC) –E
  • CXX c++编译器,默认为 g++
  • RM 删除,默认为 rm –f
  • ARFLAGS 库选项,无默认
  • ASFLAGS 汇编选项,无默认
  • CFLAGS c 编译器选项,无默认
  • CPPFLAGS c 预编译器选项,无默认
  • CXXFLAGS c++编译器选项

(注意预处理器cpp)
一般用c少不了 CCCFLAGS, 用c++少不了 CXXCXXFLAGS .

现在再修改一下上面的例子:

1
2
3
4
5
6
7
8
9
OBJS:=main.o func.o
CC:=g++

main:$(OBJS)
$(CC) $^ -o $@
main.o:main.cpp
$(CC) -c $^ -o $@
func.o:func.cpp
$(CC) -c $^ -o $@

  • 规则

规则主要分为: 普通规则, 隐含规则, 模式规则.说起来也简单, 下面总结:

  • 普通规则就是你写的规则, 非常明确指定的规则
  • 隐含规则就是你不写的时候默认(生成)的规则
1
2
3
4
5
6
7
8
OBJS:=main.o fun.o
CFLAGS:=-Wall -O2 -g
CC:=gcc

main:$(OBJS)
$(CC) $^ -o $@
#后面不用写了, *.o 文件自动依赖*.c 或*.cc 文件
#所以可以省略 main.o:main.cpp 等
  • 模式规则
    就是使用了通配符, 模式匹配的规则 : %匹配 1 或多个任意字符串, 例如: %.o: %.cpp . 任何.o依赖相应的.cpp.
1
2
3
4
5
6
7
OBJS:=main.o fun.o
CFLAGS:= -Wall –O2 –g

main.exe:$(OBJS)
gcc $^ -o $@
%.o:%.cpp
gcc -o $@ -c $^
  • 函数
    你是否承认, 这个部分是最难记忆的一部分, 我只说几个最最常用的(wildcard, patsubst, addprefix), 其他的你查查表吧.

  • wildcard wildcard 搜索当前目录下的文件名, 结合提供的参数, 展开成一列所包含参数的文件名; 多个文件之间用空格分隔.
    例如 SOURCES = $(wildcard *.cpp) , 把当前目录下所有’.cpp’文件存入变量 SOURCES 里

  • patsubst 字符串替换, 一般是这么用 $(ptsubst, 要查找的子串, 替换后的目标子串, 源字符串) , 其中源字符串是以空格分隔的
    例如: OBJS = $(patsubst %.cpp, %.o, $(SOURCES)) , 把 SOURCES 中’.cpp’ 替换为’.o’ .
  • addprefix 添加前缀, $(addprefix 前缀,源字符串), $(addprefix -l, $(LIBS); 一般用引用静态或者动态库 $(addprefix -l, $(LIBS)
    例如: $(addprefix -l, $(LIBS)

一个完整的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
DIR := ./debug
EXE := $(DIR)/main
CC := g++
LIBS :=
SRCS := $(wildcard *.cpp) $(wildcard *.c) $(wildcard *.cc)

OCPP := $(patsubst %.cpp, $(DIR)/%.o, $(wildcard *.cpp))
#OC := $(patsubst %.c, $(DIR)/%.co, $(wildcard *.c))
#OCC := $(patsubst %.cc, $(DIR)/%.cco, $(wildcard *.cc))

#OBJS := $(OC) $(OCC) $(OCPP)
OBJS := $(OCPP)
RM := rm -rf
CXXFLAGS := -Wall -g -O2

start : mkdebugdir $(EXE)
mkdebugdir :
@if [ ! -d $(DIR) ]; then mkdir $(DIR); fi;

$(EXE) : $(OBJS)
$(CC) -o $@ $^ $(addprefix -l,$(LIBS))
$(DIR)/%.o : %.cpp
$(CC) -c $(CXXFLAGS) $< -o $@

#$(DIR)/%.co : %.c
# $(CC) -c $(CXXFLAGS) $< -o $@
#$(DIR)/%.cco : %.cc
# $(CC) -c $(CXXFLAGS) $< -o $@

.PHONY : clean rebuild
clean :
$(RM) $(DIR)/main $(DIR)/*.o #$(DIR)/*.co $(DIR)/*.cco
rebuild: clean start

(注意第一个目标是start, 而不是$(exe))

说过了函数比较多, 不说了, 查手册去.

最后一点, 怎么调试make?

  • make常用命令
  • -C dir 指定执行makefile的目录
  • -f file 指定make需要的file (通常用于不是makefile的文件)
  • -i 忽略所有执行的错误
  • -I dir 指定被包含的makefile所在目录
  • -n dry-run, just-print 只打印,不真正执行
  • -p 显示make编译库和隐藏规则
  • -s slient静默编译, 编译时不显示命令
  • -w 如果make执行中改变目录, 则打印当前目录名字
  • -d 打印debug信息

详细讲解

实际上编写 makefile 就是为了提高我们的工作效率, 而不是增加我们的工作量.

我上面只是说了核心内容, 还有很多细节没有说, 也没有那么多时间去说.

这方面写的好的是 陈硕 大佬的 《跟我学Makefile》, 敬请参考.

尾巴

写的好辛苦, 明明是很基础的东西, 写出来, 整理出来, 真的比看别人的累太多了.
就这样, 以后有时间再慢慢修正吧.

文章目录
  1. 1. 引子
  2. 2. 正文
    1. 2.1. gcc
      1. 2.1.1. 编译过程
      2. 2.1.2. 编译选项
      3. 2.1.3. 优化相关
      4. 2.1.4. 链接原理
    2. 2.2. gdb
      1. 2.2.1. 查看源码
      2. 2.2.2. 带参数运行
      3. 2.2.3. 继续执行
      4. 2.2.4. 断点
      5. 2.2.5. 观察点
      6. 2.2.6. 查看信息
      7. 2.2.7. 使用shell
      8. 2.2.8. 栈帧
      9. 2.2.9. 绑定
      10. 2.2.10. core文件
      11. 2.2.11. 其他部分
      12. 2.2.12. 高级调试
    3. 2.3. makefile
      1. 2.3.1. 核心内容
      2. 2.3.2. 详细讲解
  3. 3. 尾巴
|