date
icon
password
博客链接
Pin
Pin
Hide-in-Web
Hide-in-Web
网址
type
slug
tags
category
bottom
bottom
Hide-in-Config
Hide-in-Config
comment
status
summary
GCC 是一款非常强大的编译器,主要用于 C 语言以及 C++,GDB 则是与 GCC 相配的调试工具。本篇笔记是 GCC 和 GDB 的入门笔记,帮助大家掌握 GCC 的基础操作,对新手来说,已经能够应付绝大多数的场景。如果想要深入学习某个方向,大家可以自行前往 GCC 官网或是 GDB 的官网。
💡
本篇笔记参考了 B 站的两门课程,在吸纳课程主要内容的基础上,补充了相关的知识。未来更多 GCC 和 GDB 内容的更新也会在这个页面中进行。
笔记中使用的操作系统为 Linux,我使用的版本为 CentOS 7.6,当然 Ubuntu 或是其他的系统也可以。本篇笔记默认您已经拥有 C 语言的基础和 Linux 的基础。
关于 Linux 的学习,欢迎大家观看我的 Linux 笔记: Linux 笔记 — 共享

GCC 入门

GCC 和 GNU 的介绍
GCC 最初的时候表示的是 GNU'S C Compiler ,是 Linux 专门用于服务 C 语言编程的工具,但随着 GCC 的发展,逐渐扩展为 GNU'S Compiler Collections ,即编译器的集合,既能够实现对 C 语言的编程,也能够实现 JAVA,PHP 等语言的编译。
GNU 是 Richard Stallman 发起的一项计划,它名字本身采用的是递归的定义,即 GNU is not Unix 。发起这项计划的原因是因为上世纪 80 年代时,商业公司为了利益,都选择闭门造车,从不会泄漏软件源码,这导致用户根本不知晓软件本身是怎么样的,自己的隐私是否有被侵犯,软件是否真的是为了服务用户而存在的,还是说为了剥削用户。
Richard Stallman 倡导所有用户都拥有使用软件,查看软件源码的自由,他倡导“自由软件”(free software),所谓的自由软件,并非指软件本身是不允许向用户收费的,而是指软件应是为服务用户而生的,它尊重使用者的自由,允许使用者对软件进行开发更改以更好的服务自己,而非受制于软件开发者。
在 GNU 计划的官网中,对于自由软件的描述如下:
notion image
GNU 计划是开源的前身,是非常伟大的计划,如果大家想要进一步了解它,可以访问 GNU 官网
 
安装 GCC
在 CentOS 中,安装 GCC 的指令如下(终端中进行):
安装完成后,再终端中输入 gcc --version 查看是否安装成功,若显示版本号,则安装成功。
C 语言标准
默认情况下,GCC 使用具有 GNU 特性的 C 语言来编译程序,通常称作 “GNU C”。
GNU C 包含了官方的 ANSI/ISO C 标准,同时额外提供了一些有用的 GNU 扩展功能,比如嵌套函数(nested functions)和可变长度数组(variable-size arrays)。
大多数严格按照 ANSI/ISO 标准编写的 C 程序,都可以在不做修改的情况下直接在 GNU C 下编译通过(也有少部分通过不了。比如将 asm 作为关键字需要指定为 -ansi 才能编译通过,gun 特性通过不了)。
GCC 提供了多个命令行选项,用来控制所使用的 C 语言的特性。
常用选项有 -ansi(启用 ANSI 标准兼容模式)和 -pedantic(在严格标准模式下对任何非标准用法发出警告或错误)。
如果编译时同时指定这两个选项,如 gcc -Wall xxx.c -ansi -pedantic -o test ,那么就是按照严格的 C90 标准对程序进行编译。
若想精确指定使用哪个标准(如 C89(1989 年)、C99(1999 年)、C11 等),可以使用 -std= 选项,例如 -std=c99(推荐使用)、-std=gnu11 等。
std 可指定的标准
选项
说明
-std=c89 / -std=c90
严格遵循 C89/C90 标准(禁用 GNU 扩展)。
-std=gnu90
C90 + GNU 扩展(旧项目兼容模式)。
-std=c99
严格遵循 C99 标准(支持 // 注释、变长数组等)。
-std=gnu99
C99 + GNU 扩展(GCC 5.x 前的默认模式)。
-std=c11
严格遵循 C11 标准(支持 _Generic_Static_assert)。
-std=gnu11
C11 + GNU 扩展(GCC 5~12 的默认模式)。
-std=c17 / -std=c18
严格遵循 C17(C18 为同一标准的技术勘误版本)。
-std=gnu17
C17 + GNU 扩展(GCC 13+ 的默认模式)。
-std=c23 / -std=c2c
支持 C23 标准(GCC 13+ 部分支持,如 #embedauto 类型推导等)。
-std=gnu23
C23 + GNU 扩展(逐步完善中)。
代码头文件
程序中包含头文件有两种形式:
  1. #include <xxx.h>
  1. #include "xxx.h"
第一种形式会提示系统去指定的文件目录中查找头文件,一般用于包含 gcc 的系统头文件(标准库);
第二种形式会提示系统先在当前目录下寻找头文件,如果找不到再到系统头文件目录中去寻找,一般是用户自定义的文件。
系统的头文件目录有两个,分别为:
  1. /usr/local/include
  1. /usr/include
GCC 的基本指令
接下来的指令介绍都将在 Linux 系统中进行。
创建 .c 文件
通过 vim xxx.c 创建一个 C 语言文件,比如 vim hello.c ,在里面输入:
接下来介绍的指令都将以上面的 hello.c 文件进行演示。
编译 C 语言程序
通过 gcc xxx.c -o xxx 即可完成源程序到可执行文件的所有流程,包括 预处理→编译→汇编→链接,在后面部分会一一介绍。
  1. gcc 文件名.c
    1. 通过该指令即可对刚刚创建的 .c 文件进行编译了,如 gcc hello.c,编译完成后,会生成 a.out 目标文件,在终端中输入 ./a.out 即可运行该文件,输出 Hello, world!
      图片示例
      notion image
      📌
      一般只有语法错误和部分严重问题会直接输出,警告不会默认输出,需手动启用。
  1. gcc -Wall 文件名.c -o xxx 🌟 (基本上都使用这条指令编译程序)🌟:
    1. -WallWarn all 的简称。
      该指令增添了两个参数,-Wall-o ,其中前者无需传参,后者需要传递一个参数作为输出文件名。
      -Wall 参数可以在编译 C 语言程序时帮助我们检测文件是否有错误并输出报错信息;-o xxx 用于指定输出的文件名(避免生成 a.out 文件)。
      比如 gcc -Wall hello.c -o hello ,此时就会在当前目录下生成 hello 文件,同样使用 ./hello 即可执行文件。
  1. (多文件编译)gcc -Wall xxx.c xxx2.c -o xxx
    1. 可以将多个 .c 文件按顺序进行编译,并得到一个输出文件,如 gcc -Wall hello.c main.c -o newhello
      示例
      运行代码 gcc -Wall hello.c main.c -o newhello 即可输出 Hello, world .
  1. gcc -v xxx.c
    1. -v 参数用于显示编译过程,比如系统如何检索代码头文件等。
GCC 编译 C++ 程序
使用 GCC 编译 C 语言和 C++ 的大部分参数和过程是相同的,但二者编译的过程却不能等同,不能将二者混合起来编译,C 与 C++ 内部调用的库也是不同的。
C ++ 程序使用的指令是 g++ -Wall ... 。需要注意虽然使用 g++ 编译 C++ 程序后产生的目标文件仍然是 .o 文件,但这并不意味着可以使用 gcc 来对目标文件进行链接。
GCC 的编译、汇编与链接(库文件的创建)
编译、汇编与链接的流程
在第一次编译源程序时,输入 gcc -Wall xxx1.c xxx2.c xxx3.c ... -o main 时,gcc自动同时进行编译、汇编与链接的操作:
  1. 对所有源代码依次进行编译,每个源代码都会对应生成一个汇编文件 .s
    1. 这一步是将源代码中的高级语言(C/C++)转换为汇编语言;
      编译器及汇编器会根据源代码中的 #include 指令去系统头文件目录中寻找头文件(编译仅寻找 .h 文件),找不到会报错,找到后会将 .h 文件中的内容插入到源文件里,但函数符号只是未定义的引用,也就说,虽然插入了相关的头文件声明,但具体的函数实现还不能够完成(需要链接操作才可以)。
  1. 再对汇编文件进行汇编,每个汇编文件会对应生成一个目标文件(即 .o 文件)(这一步是将 C 语言转换为汇编语言);
  1. 所有汇编文件(.s 文件)链接为一个完整的汇编项目(gcc 内部会自动调用);
    1. 链接操作是通过一个叫做 Linker 的程序将多个汇编文件(.o 目标文件)链接起来,链接器在生成可执行文件时会填补前面第一步编译时函数符号缺失的引用和地址。
      在链接操作时,Linker 会根据指令去寻找库函数文件(.a.so),将函数的具体实现加入进来,默认寻找的是系统的标准库。
      如果引用的是外部库,需要在指令中写明(后面在“链接外部库”部分会详细说明)。
📌
实际流程:预处理 → 预处理文件(.i) → 编译 → 汇编文件(.s) → 汇编 → 目标文件(.o) → 链接 → 可执行文件
预处理相关的内容会在后面单独提一个部分介绍。
多文件独立编译(重编译重链接)
当一个项目有多个源代码时,每次对其中一个源代码进行了修改,都需要对项目重新进行编译以生成可执行文件,当项目中源代码数量非常多时,这将会非常耗时。
因此,如果我们在每次修改后,能够只对修改过的文件进行重新编译,且能够重新生成一个可执行文件,那这样效率将会大大提升,这种方式称为是“增量编译”。
事实上,gcc 也确实能够帮助我们实现这一点。我们在修改文件后,可以仅编译修改过的文件,然后通过链接操作将该文件与未修改的 .o 文件(过去曾经编译过的)链接起来,从而避免大量编译的造成的时间浪费。

下面着重介绍一下相关的指令:
单独编译:
gcc -Wall -c xxx.c
通过该指令可以对单个文件进行编译,编译后产生 .o 文件,文件名称会被自动设置为与源文件同名的文件。当然也可以加上 -o 参数指定输出的文件名称,如 gcc -Wall -c hello.c -o hello.o
链接操作:
gcc xxx1.o xxx2.o -o xxx3
此时 -Wall 参数可以不用加上,因为前面编译时已经检查过了。比如 gcc hello.o main.o -o program
.o 文件的顺序最好是按照包含关系来定,比如一个文件中如果调用了另一个文件的函数,那么后者应写在前面,但现在大部分编译器都能够自动分配顺序,只有很老的编译器会产生 “Undefined Reference” 的报错。如果有幸遇到这种报错,那么可以试试调调顺序。
💡
在源文件数目非常多时,一个个将 .o 文件都列出来不太现实,有更加方便的工具 Make 能够实现,但 Make 涉及到的内容又是另外一门课了。
Make 的主要是利用“时间戳”来判断哪些文件是需要重新编译的。当源文件被修改后,该源文件的最后编辑时间会晚于其汇编文件的最后编辑时间,而正常来说源代码文件的最后编辑时间是早于汇编文件的,由此就能够判断出文件自上次编译后有进行了修改。
【补充】源程序只运行到编译阶段
前面介绍的 -c 参数能够让源程序执行“预处理→编译→汇编→目标文件(.o)”的过程,也就是说,源程序在编译后会将编译文件进一步汇编得到二进制格式的目标文件,这个目标文件是无法直接查看的。
如果我们想要查看源程序编译后转化的汇编文件,可以使用 -S 参数,执行“预处理→编译→汇编文件(.s)”的过程,汇编文件是 .s 文本文件,可以直接打开阅读。
比如,gcc -Wall hello.c -S hello.s ,然后通过 cat hello.s 查看文件内容。
GCC 引用外部头文件 → 头文件(.h 文件)相关
在使用 #include 指令的时候,include 后面可以写上路径,如 #include "sys/hello.h" ;但这个路径最好不要是绝对路径,因为当你项目移植到其他设备时,其他设备可能没有这个路径,这样会导致编译错误,新建一串路径固然可行,但如果每个项目都这样做,必然增加了文件管理难度。但相对路径是可行的,因为你分享项目时,可以指定一个子文件目录专门用于存放自定义的头文件。

如果头文件未添加到系统的默认的头文件目录中,则可以使用 -I 参数(include 的首字母)进行添加:
-I 用于添加额外的头文件搜索目录,优先于系统默认路径(如 /usr/include)。
使用语法为 gcc -I<路径> ... ,其中路径可以是相对路径,也可以是绝对路径,如 gcc -I./include -I/opt/openssl/include main.c ,编译器会依次搜索当前目录下的 include 目录与 /opt/openssl 目录下的 /include 目录,如果都没找到头文件再去寻找系统库文件目录。
若指定的目录与系统库文件目录中都没找到 main.c 中引用的头文件,则会报错:fatal error: xxx.h: No such file or directory
GCC 链接外部库 → 库文件(.so.a 文件)相关
省流: gcc -Wall xxx.c -L<路径> xxx.c -l<库名>
库(Library)是预编译对象文件的集合,可以链接到程序中。库最常见的用途是提供系统函数,例如 C 数学库中的平方根函数 sqrt
库通常存储在扩展名为 ".a"的特殊归档文件中,被称为静态库
静态库是用一个单独的工具(GNU 归档器 ar)从对象文件创建的,并由链接器在编译时用来解析函数的引用。
标准系统库通常位于 /usr/lib/lib 目录中。如果要使用外部库文件需要在命令行中手动指定。

链接外部库的指令(在最初编译时以及重新链接时进行链接外部数据库的操作):
gcc -Wall xxx.c 库文件路径 -o 输出文件名
  • gcc -Wall main.c /usr/lib/libm.a -o calc ,调用外部库 libm.a 中的库函数文件 <math.h> ,相对于正常的编译指令多出了库函数文件的路径。
gcc -Wall xxx.c -l<name> -o 输出文件名 🌟 (简化指令,推荐):
  • 其中 -l<name> 是对库文件路径的简化,这样就不用写一长串路径了,<name> 需要替换为调用的库函数文件的名字中 lib 后面的部分,比如上述的 libm.a 文件 lib 后面的部分为 m ,于是参数就写为 -lm 。如 gcc -Wall main.c -lm -o calc
💡
注意,只有在链接操作时才会使用到 -l<name> 参数,且链接对象为标准库时,Linker 链接器会自动包含库函数文件,只有链接外部库时才需要指定 -l<name> 。【上述的 libm.a 也是标准库文件,因此在 gcc 中实际上并不需要使用 -lm 参数】
无需在调用 gcc 时显示 -l 的标准库
以下常见标准库头文件,其对应实现都已被链接器自动包含,无需你显式加 -lxxx
  • <stdio.h>
  • <stdlib.h>
  • <string.h>
  • <ctype.h>
  • <errno.h>
  • <assert.h>
  • <stdarg.h>
  • <stddef.h>
  • <stdint.h>
  • <stdbool.h>
  • <inttypes.h>
  • <limits.h>
  • <float.h>
  • <time.h>
  • <signal.h>
  • <setjmp.h>
  • <threads.h> (C11)
  • <math.h>
    • 注意:对 gcc 而言,数学库 <math.h> 不用写对应参数的 -lm,但对一些比较老的编译器,可能还需要写。

对于外部添加的库文件,编译时如何指定其位置:
对于 C 的标准库文件(安装时就带有的库文件),通常存放在 /usr/local/lib/ 目录与 /usr/lib/ 目录下。
如果我们添加了新的库文件,且放在了其他目录中,那么我们可以在运行指令时加上 -L 参数library 的首字母):
-L 参数与 -I 参数用法类似,只不过 -I 参数是指定头文件(.h 文件)的额外检索目录,而 -L 参数是指定库文件(.so.a 文件)的额外检索目录。
使用的语法为: gcc -L<路径> ... 。其中路径既可以是绝对路径,也可以是相对路径。
比如:gcc -L./lib -L/opt/openssl/lib main.c -lcrypto 这条指令:
  • 链接器会依次搜索:
      1. ./lib(当前目录下的 lib 文件夹)
      1. /opt/openssl/lib
      1. 系统默认路径(如 /usr/lib
  • 最终链接名为 libcrypto.so 或 libcrypto.a 的库。
如果最终未找到相应的库文件,会报错: /usr/bin/ld: cannot find -lcrypto
📌
特殊:如果库文件在当前目录下,可以使用 gcc -L. main.c -l<name> ,因为当前目录就是使用 . 表示的,上一个目录使用 .. 表示。
💡
要注意,-L 必须放在 -l<库名> 之前。
外部头文件与外部库指令结合
假设当前的项目结构如下:
如果要编译 main.c 文件,可以运行下面的指令:
  • I./include:告诉编译器在 include 目录中查找 mylib.h
  • L./lib:告诉链接器在 lib 目录中查找 libmylib.so
  • lmylib:链接名为 libmylib.so 或 libmylib.a 的库。
【总结】引用外部头文件及链接外部库的多种方式
引用外部头文件及链接外部库主要有以下三种方式:
  1. gcc 命令行中直接使用 -I-L 参数;
  1. 设置环境变量 C_INCLUDE_PATHLIBRARY_PATH
  1. 在系统默认的头文件目录与库文件目录中添加外部头文件与外部库。
在实际应用中,更推荐使用第一种方式进行。因为第二种方式涉及多个环境变量时,很容易弄错,而且每次都要重新设置,特别繁琐;而第三种方式容易污染系统默认的目录,同时也不利于项目的移植。
第二种方法就是在终端先设置环境变量,比如 export LIBRARY_PATH=/home/xxx:$LIBRARY_PATH ,冒号表示将 /home/xxx 路径添加至 $LIBRARY_PATH 环境变量中,export 的更多用法可以查阅我的 Linux 笔记:
如何创建静态(链接)库(自定义库文件)
静态库中存放的都是目标文件(.o 文件)。动态库的文件以 .so 结尾,静态库的文件以 .a 结尾。
创建静态库的指令如下:
选项中常用可选参数:
无需加上短横线 -
参数
作用
c
创建(Create) 静态库。如果库文件不存在,则新建;若存在,直接覆盖旧库(慎用!)。
r
替换或插入(Replace) 目标文件到静态库。如果库中已有同名文件,替换它;否则新增。
v
详细模式(Verbose),显示操作过程(可选)。
s
生成符号表索引(等同于 ranlib),加速链接时的符号查找(推荐使用)。
t
列出静态库文件内容。
d
删除静态库中的某个目标文件。
x
提取静态库中的某个目标文件到当前目录。
符号表索引:
在创建静态库后,为静态库添加符号表索引是非常必要的步骤,能够大幅提升链接的效率。
  • 静态库本质上是一个由多个 .o 文件组成的归档包。当链接器使用静态库时,需要知道库中是否存在某个符号(如函数名或全局变量)。如果直接遍历所有 .o 文件逐个查找,效率极低。
  • 符号表索引的作用就是为库中所有符号建立一个全局索引(类似书的目录),让链接器能直接通过索引快速定位符号所在的 .o 文件。
符号表索引可以通过 ar s 或 ranlib 命令快速生成,二者等效。
为静态库添加了符号表索引后,静态库中会新增一个名为 __.SYMDEF__.SYMDEF SORTED 的特殊成员(可用 ar t 查看)。
每次修改库内容(增/删/替换 .o 文件)后,必须重新生成索引。
使用示例:
  1. 创建静态库并添加目标文件;
    1. 向静态库中添加目标文件;
      1. 批量添加多个目标文件;
        1. 生成符号链接索引;
          动态库的介绍
          老师说不介绍动态库的创建方法。。。
          动态库的文件以 .so 结尾,静态库的文件以 .a 结尾。
          与共享库(动态库)链接的可执行文件仅包含所需的 符号引用和重定位信息,而非外部函数完整的机器代码。当程序启动时,操作系统通过 内存映射 将动态库的代码加载到进程的虚拟地址空间。这一过程称为 动态链接,其核心优势在于多个进程可 共享同一份物理内存中的库代码,而非简单地将库代码复制到每个进程的内存空间。
          动态库使可执行文件占用的 磁盘空间更小(不包含库代码),若多个程序依赖同一动态库,系统只需在磁盘和内存中保留 一份拷贝,避免静态库因重复嵌入导致的冗余。
          同时,操作系统通过 共享内存页 技术,使所有使用同一动态库的进程共享其物理内存实例,大幅降低内存开销(降低内存开销是非常重要的,磁盘空间到还好)。
          动态库与静态库的更新:
          • 动态库更新:若修改动态库(如修复漏洞),只需替换库文件并确保 ABI 兼容性(函数接口和数据结构不变),依赖程序无需重新编译。
          • 静态库更新:必须重新编译所有依赖它的程序,维护成本较高。
          GCC 的默认行为:
          • 动态库优先:GCC 在编译时默认优先链接动态库(.so),仅在未找到动态库时尝试静态库(.a)。
          • 强制静态链接:可通过 static 参数强制全静态链接(如嵌入式场景)。
          运行时依赖与路径:
          • 动态库搜索路径:程序运行时按以下顺序查找动态库:
              1. LD_LIBRARY_PATH(环境变量)
              1. /etc/ld.so.conf 中配置的路径
              1. 默认系统路径(如 /usr/lib/lib)。
          • 静态库路径:仅在 编译链接阶段 通过 L 或 LIBRARY_PATH 指定,与运行时无关。

          通过 ldd 命令可查看可执行文件或共享库的依赖链(如 ldd a.out 列出其依赖的所有共享库路径),帮助开发者验证库是否存在、排查路径错误或版本冲突,尤其在部署环境时确保依赖完整性。
          自定义头文件(外部头文件)与自定义库(外部库)的存放位置
          一般,我们将会将源程序放在当前目录下,并在当前目录下创建 include 文件夹用于存放头文件,在当前目录下创建 lib 用于存放库文件。
          预处理(宏定义与条件编译)
          预处理阶段介绍
          预处理阶段主要是针对以 # 开头的预处理指令进行处理,主要执行以下这些操作:
          • #include 将头文件的内容插入到当前文件中;
          • #define 将所有宏名称替换成定义的内容;
          • #if 根据条件选择是否编译某段代码;
          • 所有 // 单行注释 和 /* */ 多行注释都会被删除。
          • 如果某一行以 \ 结尾,会和下一行拼接成一行。
          【回忆】基础语法
          宏定义:
          条件编译语法:
          其中,条件表达式必须是预处理器可计算的常量表达式(仅支持宏、字面量、整数运算,不支持变量),如果条件表示式的值为 0,则直接跳转到 #endif 语句之后的指令。
          判断值是否非 0:
          判断宏是否定义:
          多条件编译语法:
          条件编译与 if 表达式一样,也可以多条件执行。
          GCC 相关指令
          宏定义赋值:
          gcc -Dxxx hello.c -o hello
          这里使用了一个新参数 -D ,参数后面写上宏定义的名称,表示将该宏定义默认赋值为 1。当然,也可以指定值,如 -DTEST=0
          代码示例
          预处理操作:
          gcc -E hello.c -o hello
          -E 参数会让程序只运行到预处理阶段,预处理阶段只会进行宏替换(即将代码中宏名替换为 #define 语句中宏定义名后面的内容)。
          代码示例
          查看系统的所有宏定义:
          gcc -dM -E - < /dev/null (Linux)或 gcc -dM -E - < nul(Windows):
          -dM 参数用于打印出所有的宏定义;
          -E 参数:
          • 表示只运行到预处理阶段(Preprocessing),不进入编译、汇编、链接。
          • 输出的内容是展开了 #include#define#if 等指令之后的源代码。
          - 参数:
          • 单独一个短横线 在 GCC 里常被用作“输入文件名”,表示“从标准输入(stdin)读数据”,而不是从磁盘上的某个 .c 文件读。
          • 所以这里 告诉 GCC:你就当成有一个空的源文件从 stdin 进来。
          < 参数:
          • 这是 Shell 重定向(Redirection)语法,不是 GCC 的参数。
          • 把右侧的文件(如 Linux 中的 /dev/null 或 Windows 下的 nul)当成左侧命令的标准输入。
          • 由于我们不想给 GCC 真正的源码,只想“给它一份空输入”让它仅输出宏定义,所以用 < /dev/null< nul来实现“空文件”重定向。

          - 参数的更多介绍(不是特别重要,感兴趣可以看看)
          短横线 - 本质上是一个占位符。
          在 gcc 中,短横线 - 有两种含义: ① 从标准输入(stdin)中读数据; ② 把输出写到标准输出(stdout)。
          第一种情况:
          在本该输入文件名或是数据的地方使用 - (当某个命令期望后面跟一个文件路径)时,使用 - 就代表“从标准输入(stdin)读入”。
          在使用 - 指令时,需要注意,在用于替代文件的 - 参数前面,要先告诉编译器是只做预处理还是从编译到汇编,如果是前者,需要写上参数 -E(只做预处理);如果是后者,需要用 -x参数指定使用的语言, -x <language>(指定源码语言)。
          比如 gcc -Wall -E - -o main.cgcc -Wall -x c - -o main.c(短横线 - 的位置本来应该输入一个源文件),在终端输入该指令后,光标会停留在下一行,等待用户输入源代码,此时,如果我们输入:
          然后按下 Ctrl+D (Linux)或 Ctrl+Z (Windows)回车,gcc 就会将你输入的源码进行编译,生成可执行文件 main。
          当然,也可以通过 cat hello.c | gcc -Wall -x c - -o main 的方式将已经有的 hello.c 通过 cat 输出,然后利用管道指令将输出作为输入,输入给 gcc。
          是不是觉得特别麻烦,所以一般 - 参数只会用于前面我们输出宏定义的场景。
          第二种情况:
          当某个命令允许你指定输出文件名(例如 -o),如果写成 -o -,就代表“把输出写到标准输出(stdout)”。比如 gcc -Wall hello.c -S -o -
          【总结】GCC 所有流程
          所有阶段概览
          GCC 在将 C/C++ 源代码编译成可执行文件的过程中,大致经历以下几个阶段:
          预处理 → 预处理文件(.i) → 编译 → 汇编文件(.s) → 汇编 → 目标文件(.o) → 链接 → 可执行文件
          1. 预处理(Preprocessing)
            1. #include#define、条件编译等指令展开,生成纯 C/C++ 代码(扩展了宏,展开了头文件)。
              预处理后的 C 文件为 xxx.i ,如果是 C++ 文件,则为 xxx.ii
          1. 编译(Compilation)
            1. 把预处理后的代码转换为汇编代码,存放在 xxx.s 文件中。
          1. 汇编(Assembly)
            1. 把汇编代码转换为目标文件(.o),将汇编语言转换为机器语言。
          1. 链接(Linking)
            1. 把一个或多个目标文件和所需的库合并,生成可执行文件或共享库。
          保留中间文件(-save-temps
          默认情况下,GCC 只保留最后一个阶段(链接)输出的可执行文件或目标文件,而中间产生的预处理文件、汇编文件都被临时保存在内存或临时目录,编译结束后即被删除。
          但我们可以通过 -save-temps 参数在当前工作目录下保留每一个源文件对应的中间文件:
          • 预处理结果:源文件.i
          • 汇编结果:源文件.s
          • 目标文件:源文件.o (正常也会生成)
          比如,gcc -save-temps hello.c -o hello
          源文件转换为预处理文件(cpp-E 参数)
          .c.i
          使用方法: cpp xxx.c > xxx.igcc -E xxx.c -o xxx.i
          cpp hello.c > hello.i 可以将源文件 hello.c 中的预处理指令(如宏、头文件包含等)展开,生成预处理后的代码并保存到 hello.i 文件中。
          【直接运行 cpp hello.c 会将结果直接输出到终端】
          cpp 指令执行的核心操作如下:
          • 头文件包含(#include:将 #include <stdio.h> 等语句替换为对应头文件的实际内容。
          • 宏展开(#define:替换所有宏定义(如 #define PI 3.14),删除宏定义本身。
          • 条件编译(#ifdef/#endif:根据条件保留或删除特定代码块(如调试代码)。
          • 删除注释:移除所有单行(//)和多行(/* ... */)注释。
          • 添加行号标记:插入 #line 指令,便于后续编译阶段定位错误(默认启用,可用 P 选项禁用)。
          💡
          可以注意到,cpp 基本上只处理 # 开头的代码和注释。
          预处理文件转换为汇编文件(-S 参数)
          .i.s
          编译器核心的步骤就是这一步,将源程序代码转换为汇编程序的代码,因为后续汇编程序的代码与机器代码基本上是一一对应的,而源程序代码转换为汇编程序代码却决定了代码的运行效率。
          使用方法:gcc -S xxx.i 或者 gcc -Wall -S xxx.c (对源程序代码与预处理代码都可以进行操作)。
          比如 gcc -S hello.igcc -Wall -S hello.c
          💡
          注意,只有在汇编过程中需要用到 -Wall 参数。
          汇编文件转换为目标文件(-o 参数)
          .s.o
          这一步进行的操作是将汇编语言代码转换为机器码,在这一步中,如果代码内有对外部函数的调用,都会留下未定义的引用,等待链接阶段进行填充。
          使用方法: gcc xxx.s -c xxx.oas xxx.s -o xxx.oas 只支持将 .s 文件转换为 .o 文件】,也可以直接将源代码转换为目标文件 gcc xxx.c -o xxx.o
          比如 gcc hello.s -c hello.oas hello.s -o hello.ogcc hello.c -o hello.o
          链接为可执行文件(gcc
          .o → 可执行文件。
          这一步会将所有的未定义引用填充,将所有目标文件合并为一个链接文件。
          使用方法:gcc xxx.o -o xxx ,比如 gcc hello.o -o hello
          💡
          链接为可执行文件时需要注意,gcc 默认生成的可执行文件为 a.out (如果已存在则覆盖),如果要指定为其他名字(有无后缀均可),需要使用 -o 参数。
          gcc hello.c 指令生成的是 a.out 文件,gcc hello.c -o hello 指令生成的是 hello 文件,无后缀,二者都是可执行文件。
          代码的注释
          C 语言中,如果我们想要对某段代码或某个函数加入描述,可以使用 /*xxx*/ 来注释一段内容。在 C99 及之后的版本中,可以使用 // 来注释一行内容。
          但是,使用注释时需要明白一点,C 语言中的注释是不能够嵌套的,比如下面这段代码:
          上述代码发生了嵌套注释,在代码量比较小的时候,我们可能会发现并修改嵌套注释的情况,但在代码量比较大的情况下,假如要一个个修改,不仅需要花费大量的时间,而且重要的说明将不能够被保留。因此,对于大段代码的注释,我们可以考虑采用如下方式进行:
          GCC 调试
          课程中老师对这部分并没有讲的特别细,GCC 更多的调试操作可能需要去了解一下 GDB(后面一部分)。
          gcc -g xxx.c -o xxx
          -g 参数用于在可执行文件中嵌入符号表(变量名、函数名)、源代码行号、数据类型、局部变量地址等元数据,允许调试器(如 GDB)逐步执行代码、查看变量值、设置断点、分析堆栈等。
          比如,gcc -g main.c -o app 通过 -g 参数生成了带有调试信息的可执行文件 app,后续可以使用 GDB 指令打开可执行文件,比如:
          GCC 优化
          优化主要包括两个方面,第一个是在源码层次上的,第二个是在机器码层次上的。
          源码层次上的优化
          通用子表达式的消除(CSE)。
          简单来说就是充分利用运算的中间结果,而不要每次运算时都重新将之前计算过的部分再计算一遍。这样不仅可以提高效率,也能够减小可执行文件的大小。具体方式如下图所示:
          notion image
          函数内嵌(FL,function inline)【C99 之后引入的关键字】。
          当我们去调用函数的时候,系统会将调用该函数前寄存器的状态,下一条指令的地址等信息先压栈保护,等到函数运行完后再弹出。对于调用次数比较少的函数而言,这样的开销是可以接受的。但是对于频繁调用的函数,尤其是该函数内容还比较少的时候,就会拉跨代码的执行效率。
          使用的方法是在调用的函数前面加入 inline 关键字,这样在编译时系统就会建议编译器在调用处展开函数。
          第一种方式(加入 inline):
          notion image
          第二种方式(干脆不使用函数):
          notion image
          循环展开(Loop Unrolling)
          在命令行中使用参数 -funroll-loops 即可实现。
          对于一个循环语句而言,都会有一个条件判断语句,每次执行完循环体的内容都要进行一次条件判断,这样,编译出的机器码就会比较繁杂。
          如果循环的次数预先并不知道,可以采取类似下面的策略进行优化:
          📌
          在循环优化的时候,若 n 不确定,不一定是将 n 分为两半来进行优化(如上面代码所示),如果 n 是 3 的倍数,那么也可以取 n%3 ,后面循环中就改成 i+=3 ;如果 n 是 4 的倍数,那么也可以取 n%4 ,后面循环中就改成 i+=4 ,以此类推,这样提升的效率就是原来的 3 倍,4 倍。当然,要记得第二个循环中 y[]=; 的数目也要进行修改。
          💡
          在源码中,我们仍然可以以循环的形式写代码,但在编译的时候,要采用 -funroll-loops 参数,如 gcc -Wall -funroll-loops main.c -o main
          但无论如何,源码的优化往往都是空间和时间的权衡,要么增加代码的执行时间换取更小的文件体积,要么就提高代码的运行效率但代价是更大的文件体积。一般对 PC 机而言,内存大一些无所谓,更追求效率;而对嵌入式系统而言,由于内存都比较受限,因此更追求小的内存而牺牲一些效率。
          机器码层次上的优化
          这个层次上的优化主要都是 GCC 内部完成的,GCC 可以通过调度(Schduling)进行优化。
          调度指的是编译器在最低优化层级对程序指令进行重新排序,找到单条指令的最佳执行顺序。通过调整指令顺序,让 CPU 尽可能多地并行执行指令,同时确保指令间的数据依赖关系正确。在调整指令顺序的过程中,会确保前一条指令的计算结果能及时传递给后续指令,避免等待(减少 CPU 空闲时间)。
          通过 -O<LEVEL> 指令可指定优化的程度(注意 O 要大写,小写的 o 是指定输出文件名称):
          • O0默认关闭优化,确保编译结果与源代码严格对应,便于调试(保留符号信息,不改变执行顺序)。
          • O1基础优化,在编译时间和性能之间取得平衡,包括删除未使用代码、简化运算等。
          • O2推荐开发与部署级别,启用绝大多数安全优化(如循环展开、内联函数、指令调度),显著提升性能,同时保持合理编译时间(编译时间还不一定会比 O1 快)。
          • O3激进优化,进一步启用向量化、并行化等高级优化,可能增加代码体积和编译资源消耗,适合对性能极致要求的场景(文件体积会比较大)。
          💡
          一般来说,我们开到 O2 就已经足够了。在使用优化时需要注意,不一定是优化等级越高速度就越快,优化等级高还有可能会导致程序中的潜在 bug 暴露出来,出现很多问题。
          可以使用下面的代码进行测试:
          我的运行结果如下:
          我是在 windows 里面编译的,因此窗口可能与 Linux 不一样。
          我是在 windows 里面编译的,因此窗口可能与 Linux 不一样。
          优化与调试
          在 GCC 中,g(调试)与 O2(优化)可同时启用,GCC 允许在编译时,同时保留调试信息(如变量名、源码行号)和执行性能优化(如循环展开、指令重排)。
          这一特性是 GCC 的独特优势,许多其他编译器(如部分商业编译器)会强制要求关闭调试以启用优化。
          GCC 编译器在启用优化时(如-O1/-O2)会通过数据流分析(Data-Flow Analysis)追踪变量状态,从而检测出未初始化变量的使用风险,并产生非优化模式下不出现的额外警告。
          尽管其启发式方法(Heuristics)能识别大多数此类问题,但可能遗漏复杂逻辑或误报,开发者可通过简化代码逻辑消除误警告,同时提升源码可读性。该机制本质是优化策略(如指令调度)的副产品,兼顾性能提升与代码健壮性检查。
          由于优化会改变程序的源码位置,因此建议在调试的时候不要进行优化。
          两个分析工具的介绍
          gprof 通过统计函数调用次数及耗时定位程序性能瓶颈(如耗时占比高的函数),指导优化优先聚焦关键函数;
          gcov 则通过分析代码行执行次数识别未使用或测试未覆盖的代码区域。两者结合可精准定位需优化的具体代码行,帮助开发者从函数级到行级分层优化程序性能,兼顾效率提升与代码质量验证。
          第一个工具的演示
          notion image
          通过加入 -pg 这个可选参数,gprof 工具已经将一些特殊的代码加入了 a.out 中,帮助我们去检查每个函数的运行次数以及时间。
          运行可执行文件 ./a.out ,得到输出,会在当前目录下生成一个 gmon.out 文件:
          notion image
          注意,这个文件为二进制文件,无法直接使用 cat gmon.out 输出,可以使用 gprof a.out 进行查看(会默认读取当前目录下的 gmon.out 文件):
          notion image
          其他的内容老师没有介绍,需要去 GNU 官网上获取相应的手册查看。
          第二个工具的演示
          notion image
          notion image
          通过 vim cov.c.gcov 打开该文件:
          notion image
          可以通过 grep 函数快速过滤出没有执行过的代码:
          notion image
          这两个工具一般是在程序的最后阶段使用,查看程序是否有什么潜在的漏洞。

          GDB 入门

          GDB 相关资源
          安装 GDB
          GDB 如何安装可以自行上网搜寻,CentOS 中是以如下指令安装的:
          CentOS:
          GDB 基本指令
          编译程序的时候写上 -g 选项,gcc -g xxx.c -o xxx ,比如 gcc -g hello.c -o hello
          然后输入 gdb <可执行文件> 即可执行该可执行文件,并进入调试模式,比如 gdb hello

          以下面这份代码为基础介绍指令
          GDB 调试模式中:
          退出调试模式(quitexit
          quitexit 都可以用于退出调试模式,其中 quit 可简写为 q
          显示源码(listl
          如果只使用 list 指令而没有带上任何参数,则会将源程序中 main 函数前后的内容列出(默认显示 10 行),并在每一行标上行号。
          • list nl n: 显示第 n 行前后的代码(第 n 行会尽可能被放置在居中位置);
            • 示例
              notion image
          • list n, ml n, m: 显示第 n 行至第 m 行的代码;
            • 示例
              notion image
          • list <函数名>l <函数名>:显示某个函数名前后的源码(函数名会尽可能被放置在居中位置);
            • 示例
              notion image
          💡
          如果再次输入 list ,则会将光标继续向下移动,比如前一次指令显示的范围是 5-10,那么下一次就从第 11 行开始显示。
          如果当前源代码已经显示到了末尾,可以通过 list .l . 来重置,下一次运行 list 会再次从 main 函数开始。
          可以通过 set listsize n 来修改 list 指令显示的行数,比如 set listsize 15 ,将默认显示的行数修改为 15。
          设置断点(break <函数名或行号>b <函数名或行号>
          在对应的函数名或某一行代码处设置断点,比如 break addbreak 15
          • info breakpointsi b:查看所有断点及其信息,显示断点标号;
            • 示例
              notion image
          • delete <num>d <num>:删除断点,其中 <num> 为设置的断点标号。
          运行程序,调试与断点调试(runr
          运行或重新运行程序。
          在进行(断点)调试前,需要先(设置程序断点并)运行一次代码,执行指令 r
          然后:
          • continuec(断点调试):继续运行代码直到遇到下一个断点;
          • steps(跳转外部):逐行运行代码,如果需要调用当前调试的函数外的函数,则会进入到外部函数继续逐行执行。
            • 示例
              notion image
              比如,我将断点设置为第 23 行,可以看到,这里调用了外部函数 powern() ,因此,如果使用 step 会进入到 powern 函数内部,继续逐行调试。
          • nextn (不跳转):逐行运行代码,但不会跳出当前函数,如果需要调用外部函数,则会直接返回调用结果,继续逐行执行;
            • 示例
              如下图所示,在第 23 行设置断点位置,然后使用指令 n 逐行执行,调试时始终位于函数内部,不会跳出函数。
              notion image
          • finishfin :在 main 函数以外的函数中,直接运行至函数返回结果处。
            • 这条指令的一个应用场景是,如果你进入了一个你不想深入分析的函数(比如标准库函数、自己写的已验证无误的函数),想快速跳回调用它的地方,那么就可以使用该函数。比如,当你在 main 函数或其他函数中进行 step 调试时,如果进入了其他函数,看到了自己想要的结果,可以通过 fin 来直接运行到其他函数的末尾。
              同时,fin 的好处也在于,执行完函数后,GDB 会自动显示该函数的返回值,非常方便调试返回结果。
              示例
              notion image
          需要注意,如果你设置的断点位置为空行,gdb 会将断点自动 “后移” 到最近的下一条有可执行代码的行。如果你指定的断点位置为某个变量的定义位置,在使用 gcc 编译代码的时候很可能会将该变量给优化,因此可能会出现明明某一行源文件处有代码,但是设置断点后仍然将断点位置“后移”的情况。
          断点后移示例
          notion image
          如上图所示,我将断点位置设置为第 19 行,这一行定义了变量 i,但是设置后显示断点位置在 line 21 ,即第 21 行。
          查看变量值(p <变量名>
          通过 printp 指令即可查看某个变量值或地址。
          示例
          notion image
          同时,p 也可以查看变量的地址:
          notion image
          运行终端的命令(shell <指令>
          如果你是在 Linux 中运行 gdb,那么可以使用 shell <指令> 运行 Linux 的指令。
          比如:
          1. shell ls : 列出当前目录下的文件,或 shell ls -al 列出所有的文件;
          1. shell rm test.c :移除当前目录下的 test.c 文件。
          更多的 Linux 指令可以参考我的 🐧 Linux 笔记
          保存日志(set logging enabled on
          set logging on 是已经弃用的指令(目前还能用,但不推荐),现在日志相关的指令如下:
          • set logging enabled on :开启日志记录(推荐写法);
            • GDB 会把后续的输出内容复制一份保存到日志文件(如 gdb.txt)中,指令本身并不会保存
          • set logging enabled off :关闭日志记录;
          • set logging file filename.txt :设置输出文件(默认是 gdb.txt);
          • set logging overwrite on :每次记录前覆盖旧文件(默认是追加);
          • show logging :查看当前日志设置。
          💡
          如果更改了上述指令的默认行为,每次重新打开 gdb 都要重新配置一次。
          监视点的使用(watch <变量名>
          在程序调试过程中,如果我们想要捕捉某个变量的变化,可以使用通过设置 watchpoints 来实现。
          先介绍一下有关监视点的常用指令:
          • watch <变量名> :当 任意写操作(写入、修改)使变量的值变化时触发;
          • rwatch <变量名> :当 任意读操作(读取)访问该变量时触发;
          • awatch <变量名> :同时监视 读写 两种操作。等于同时设置 watchrwatch
          • info breakpointsi b :列出所有的断点与监视点;
            • 示例
              notion image
              hw 表示读相关的监视点,read 表示写相关的监视点。
          • info watchpointsi wat :列出所有的监视点;
          • delete <num>d <num> :删除标号为 num 的断点,标号通过 i wat 查看;
            • 示例
              notion image
          • disable <num>enable <num> :禁用或启用标号为 <num> 的断点;
            • 示例
              notion image

          下面来介绍一下使用监视点前需要做的准备:
          首先,确保程序处于调试状态,且设置了断点(如果不设置断点程序一下就运行完了,变量的变化显示不出来)。
          1. b <行号> && run
            1. notion image
          1. watch <变量名> :比如,watch i 监视变量 i 的变化;
            1. 单步执行程序,会发现当 i 的值发生变化时,会显示出 i 上一次的值与 i 的新值:
              notion image
          调试正在运行的程序(gdb -p <pid>
          首先在终端中输入 ps -ef | grep xxx 指令(Linux 系统)查看正在运行中的程序的 pid,然后通过 gdp -p <pid> 调试这个正在运行中的程序。
          具体的调试方法与前面一致。
          示例:
          假设现在的代码为:
          在终端中运行:
          实际测试:
          1. 编辑文件并使其后台运行;
          notion image
          1. 通过进程号打开调试模式;
          notion image
          1. 监视变量 i 的变化:
          notion image
           
           
           
          如果本篇笔记对你有用,能否『请我吃根棒棒糖🍭 』🤠…
          相关文章
          无线网络技术
          Lazy loaded image
          DSP 应用技术笔记
          Lazy loaded image
          Git 学习笔记
          Lazy loaded image
          编程笔记专区
          Lazy loaded image
          区块链的应用与技术笔记
          Lazy loaded image
          遥感数字图像处理笔记
          Lazy loaded image
          电磁场与电磁波笔记
          Lazy loaded image
          机器学习笔记(吴恩达)
          Lazy loaded image
          数字信号处理笔记
          Lazy loaded image
          通信电子线路笔记(高频电子线路)
          Lazy loaded image
          微机原理和系统设计笔记
          Lazy loaded image
          概率与统计笔记
          Lazy loaded image
          DSP 应用技术笔记Git 学习笔记
          Loading...