很多人第一次用 Linux 做开发,最懵的不是语言本身,而是"我到底该怎么在这个黑压压的终端里把代码搞出来、又搞明白"。在 Windows 上,你点开 Visual Studio,装个东西就是一个安装向导,写代码有语法高亮,编译点个按钮就运行。可到了 Linux,一切都是分开的:装软件要包管理器,写代码要学 vim,编译要懂 gcc,出错了要会 gdb,工程大了还得靠 make 自动化。这一篇,我们就把这几位"Linux 开发五虎将"挨个请出来,掰开揉碎地讲清楚。
这篇文章的目标很朴素:让你看完之后,能在一台全新的 Linux 机器上,从装工具开始,独立写出、编译、调试、自动化构建、甚至用 git 托管一个项目。 我会大量用命令演示,每一条命令都尽量能复制粘贴直接运行。如果你手头有台虚拟机或者云服务器,跟着敲一遍效果最好——学工具类的东西,"看过"和"敲过"完全是两回事。
先说好:本文以 CentOS/RHEL 系的 yum 为主线讲解(这也最贴合网课出镜率),但同时我会在所有关键节点给出 Ubuntu/Debian 系的 apt 对照,因为这两大阵营你在面试、工作中都绕不开。
你应该有的知识准备
在往下读之前,我假设你已经有下面这些底子。它们大多来自 Linux 入门和 C 语言课,这里我只做一句话回顾:
- shell 与基本命令:知道
ls、cd、cat、rm、touch是干嘛的,能看懂sudo是拿管理员权限。 - 文件权限:Linux 用
rwx三组九位表示权限,普通用户写不进系统的/usr、/etc目录,所以装软件才要sudo。 - C 语言编译的四个阶段:预处理、编译、汇编、链接——这是 gcc 那一节的地基,如果你还算清楚但记不牢,正文我会再帮你完整铺一遍。
- 进程与程序的区别:程序是磁盘上的一堆文件,进程是它跑起来后在内存里的"活的"实体。聊 gdb 和动态链接时会用到。
只要这几样有个概念,本文对你就是零门槛。哪条不熟,恰好说明这课对你有用。
软件包管理器:yum 与 apt
什么是软件包
在 Linux 下装软件,最原始的办法是:把程序源代码下载下来,自己编译,得到可执行程序再安装。这一套下来麻烦得不得了,你得先装编译器、装依赖,编译还可能报一堆错。于是有人思考:为什么不把那些常用的软件提前编译好,做成一个"安装包"放在服务器上,谁要谁直接下载安装?
这个提前编译好的东西就叫软件包(package)。你可以把它理解成 Windows 上的"安装程序"(.msi、.exe 安装器)。而负责下载、安装、卸载这些软件包的工具,就是软件包管理器(package manager)。
软件包和软件包管理器,就好比"App"和"应用商店"的关系:App 是软件包,应用商店是包管理器。 你不需要知道 App 内部怎么写的,打开应用商店搜一下、点个安装,它自己就把依赖装齐、配置好,直接能用了。yum 帮你做的,就是"把软件包下载下来、把所有依赖一起解决掉、装到系统里"这件事。
几个常见发行版的对应关系要记牢:
| 发行版系 | 包管理器 | 你自己用哪种 |
|---|---|---|
| CentOS / RHEL / Fedora | yum(现多为 dnf) | 本文主线 |
| Ubuntu / Debian | apt(Advanced Package Tool) | 全文对照 |
| Arch | pacman | 了解即可 |
其中 yum 的全称是 Yellow dog Updater, Modified(黄狗更新器·修订版),最初源于一款叫 Yellow Dog Linux 的发行版,后来被 RedHat/CentOS 系广泛采用。这个名字很多人第一次见到都会愣住,其实就是个历史彩蛋。
Linux 软件生态与镜像源
你可能会问:谁来维护这些软件包?答案是社区与发行版团队。Linux 生态最大的魅力之一,就是有大量志愿开发者免费把自己写的软件开源、编译成软件包、上传到服务器供人下载。一个操作系统好不好用,除了好不好看,更关键的是"生不生态"——有多少软件能一键装上。这也是为什么会有那么多人愿意免费提供软件、还倒贴服务器带宽给你下载:开源社区的运转靠的是"我为人人、人人为我",你自己有能力时也可以回报社区。
这里面有个绕不开的话题:软件包依赖。一个软件往往要依赖很多其他的库才能跑起来(比如你的程序要调用 printf,就得依赖 libc 这个 C 标准库)。手动管理依赖是地狱,包管理器最大的价值就是自动帮你把所有依赖递归地找齐装好。你在后面 gcc 章节会亲眼看到 ldd hello 打印出一个程序依赖的动态库列表,那就是依赖关系的冰山一角。
再一个实际问题是:Linux 官方源服务器可能在国内访问很慢甚至连不上。解决办法是换用国内镜像源,也就是把官方源的地址替换成国内高校或云服务商维护的镜像站点。常用官方镜像站有:
- 阿里云开源镜像站:
https://developer.aliyun.com/mirror/ - 清华大学镜像站:
https://mirrors.tuna.tsinghua.edu.cn/ - 中国科学技术大学镜像站:
http://mirrors.ustc.edu.cn/ - 网易镜像站:
http://mirrors.163.com/
(镜像站状态随时可能有调整,换源前建议访问对应官网或社区确认当前最新链接。)想临时装一个"扩展源"里的软件,最省事的是安装扩展源包,例如 CentOS 下的 epel-release:
# CentOS/RHEL 安装 EPEL 扩展源
sudo yum install -y epel-release
# Ubuntu 下没有 EPEL,但可以用 universe 等软件仓,
# 一般默认已启用,需要时用 add-apt-repository 添加 PPA查看软件包:yum list
yum list 可以罗列出当前安装源里一共有哪些软件包。包的数目可能数以万计,所以我们几乎总是配合 grep 来筛选关心的包。以传文件工具 lrzsz(提供 rz/sz 命令,用于和终端互传文件)为例:
# CentOS:列出所有包,再筛选出名字含 lrzsz 的
$ yum list | grep lrzsz
lrzsz.x86_64 0.12.20-36.el7 @base
# Ubuntu 对应做法:先搜索,再查看详情
$ apt search lrzsz
Full Text Search... Done
lrzsz/focal,now 0.12.21-10 amd64 [installed]
Tools for zmodem/xmodem/ymodem file transfer
$ apt show lrzsz
Package: lrzsz
Version: 0.12.21-10
Priority: optional
Section: universe/comm
...看那一长串包名 0.12.20-36.el7,拆开讲很有讲究,它是按这样拼起来的:主版本号.次版本号.源程序发行号-软件包发行号,再加上平台和架构信息。具体到 lrzsz.x86_64 0.12.20-36.el7 @base:
x86_64:表示这是 64 位系统的安装包;i686表示 32 位。选包时一定要和你的系统架构匹配,32 位系统装不上 64 位包。0.12.20:软件的本体版本号。-36:软件包自身的修订号。el7:表示这套包面向的发行版版本。el7=CentOS7/RHEL7,el6=CentOS6/RHEL6,以此类推。跨大版本用错包会导致依赖混乱。@base:最后一列是"软件源"的名字。@base前面的@表示这个包已经从该源安装过了(在库里还能看到候选版本);base对应 CentOS 的标准源。软件源的概念类似"小米应用商店"、"华为应用商店"这样的命名区分。
安装软件:yum install
一条命令就能把 gcc 装好(gcc 是 C 编译器,g++ 是 C++ 编译器,后面专门讲):
# CentOS
sudo yum install -y lrzsz
# Ubuntu
sudo apt install -y lrzsz-y 是"自动回答 yes"的意思。装软件时 yum 会先分析出一串需要下载安装的包(包含被依赖的包),正常情况下它会询问你是否确认,加了 -y 就跳过确认直接装。装成功一般会看到 complete 字样,或者全过程没出现报错。
有几个坑必须提前讲清楚:
- 权限:安装软件要向系统的
/usr、/bin等目录写入内容,普通用户没权限,必须sudo或切 root。用sudo时系统会提示你输入自己的密码。 - 串行锁:yum/apt 同一时刻只能执行一个安装任务。如果你在装 A 的过程中又去开另一个终端装 B,会报错(提示有另一个 package manager 正占用锁文件)。所以装东西要有耐心,一个一个来。
- 网络:yum/apt 装包必须联网下载,网络不通会失败。装前可以先验证连通性:
ping www.baidu.comping能通,说明主机网络基本没问题。当然 yum 也支持离线安装(下载好的.rpm文件直接装),但那不是当前关注的重点。
卸载软件:yum remove
卸载同样是干净利落的一条命令:
# CentOS
sudo yum remove [-y] lrzsz
# Ubuntu
sudo apt remove [-y] lrzsz同样建议加 sudo,-y 可加可不加。注意 remove 只是移除软件本体,如果想让依赖它的、已经没有别的程序用到的库也一并清掉,CentOS 下可以进一步用 yum autoremove(Ubuntu 下 apt autoremove),这在把系统收拾干净时很管用。
思考题 1:我在 CentOS 上执行
yum list | grep lrzsz,看到输出lrzsz.x86_64 0.12.20-36.el7 @base,只靠这几个字段,我能知道它是 32 位还是 64 位的包吗?el7表示什么?详解答案:能。字段里的
x86_64就是架构标识,x86_64后缀代表 64 位安装包,i686前缀/后缀代表 32 位安装包,选包时必须和系统架构匹配。el7表示这套包面向的发行版大版本,el7对应 CentOS7/RHEL7,el6对应 CentOS6/RHEL6——它回答的是"这套包是给哪个大版本系统用的",避免你跨版本装出依赖混乱。
编辑器 Vim
写代码总得有个编辑器。Linux 终端下最常见的编辑器就是 vim。
vi 与 vim 的区别
先说清楚 vi 和 vim 的关系:它们都是多模式编辑器,vim 是 vi 的升级版。vim(Vi IMproved,增强版 vi)不仅完全兼容 vi 的全部指令,还增加了语法高亮、可视化操作、跨窗口等功能。它既能在纯终端里跑,也能运行在 X Window、macOS、Windows 上。正规的 Linux 发行版,vi 这个命令往往软链接指向的就是 vim。我们课上统一按 vim 讲。
vim 的三种模式(基础概念)
刚进 vim 的人最崩溃的一点是:为什么我敲键盘屏幕没反应? 因为 vim 是"多模式编辑器",同一个按键在不同模式下含义完全不同。入门阶段你必须掌握三种模式:
- 正常/普通/命令模式(Normal mode):vim 刚打开的默认模式。这里不能直接输入文字,而是用来控制光标移动、删除字符/字/行、复制粘贴区段,以及切换到插入模式或末行模式。
- 插入模式(Insert mode):唯一可以输入文字的模式。按
i等键进入,按Esc退回正常模式。这是你实际打字时待的最久的模式。 - 末行模式(last line mode):也叫底行/命令模式。用冒号
:进入,用来保存文件、退出、替换字符串、查找、显示行号等"批处理"操作。
其实 vim 的模式远不止这三种(一共 12 种:6 种 BASIC 模式 + 6 种 ADDITIONAL 模式)。想亲眼看看全套清单,vim 打开后按 : 输入 help vim-modes 回车即可。但现阶段你只要把上面三模式切换玩熟就够用了。
模式切换的几个关键操作要背下来:
进入 vim: $ vim test.c # 打开(不存在则新建)文件
正常模式 → 插入:按 i / a / o # 三种进入法,区别见下面
插入模式 → 正常:按 Esc # 最常用的"回退"键
正常模式 → 末行:按 Shift + :(即输入 :) # 然后就输入 w / q 等
vim 的基本操作
进入 vim 后处于正常模式,想打字必须先切成插入模式。怎么进"插入模式"有讲究,三个键各有侧重:
- 按
i:从光标当前位置开始输入。 - 按
a:从光标所在位置的下一个位置开始输入。 - 按
o:在光标所在行的下面新开一行,从行首开始输入。
在插入模式里如果发现输错了字想往回删,一个常见的坏习惯是拿方向键慢慢挪——其实更高效的做法是先按 Esc 回到正常模式,用后面要讲的光标移动和删除命令精准操作。
退出和保存是在末行模式做的:
# 正常模式下按 : 进入末行模式,然后:
:w # write,保存当前文件但不退出
:wq # write + quit,保存并退出
:q! # quit + !,不保存强制退出(丢掉所有未保存改动)注意这里有三种退出姿势,q(不保存退出)、q!(强制退出)、wq(保存退出),别记混。如果文件没保存就想直接退,vim 会不让你 q,这时就得 q! 放弃改动,或者 wq 存盘。
vim 正常模式命令集
正常模式下全是"快捷键"。把下面这组高频命令背熟,你就能脱离鼠标在代码里飞了。我按功能分组:
插入模式的三种入口(前面提过,这里再集中放一次):
i # 从光标当前位置前插入
a # 从光标当前位置后插入
o # 在当前行下方新建一行并进入插入移动光标(正规 vim 用字母,不用方向键,因为手可以一直放在字母区):
h j k l # 光标 左/下/上/右 各移动一格
G # 光标移动到文章最后一行
gg # 光标移动到全文开头
$ # 移动到光标所在行的"行尾"
^ # 移动到光标所在行的"行首"
w # 跳到下一个单词的开头
e # 跳到下一个单词的结尾
b # 回到上一个单词的开头
#l # 跳到该行第 # 列,如 5l 行 5l 也是数 5 次 l, 更常用的是 5l
Ctrl+b # 屏幕向后翻一页(PageUp)
Ctrl+f # 屏幕向前翻一页(PageDown)
Ctrl+u # 屏幕向后翻半页
Ctrl+d # 屏幕向前翻半页删除文字:
x # 每按一次,删除光标所在位置的 1 个字符
#x # 例如 6x = 删除光标所在位置(含自己)开始往后的 6 个字符
X # 大写,删除光标所在位置的"前面"1 个字符
#X # 例如 20X = 删除光标所在位置"前面"20 个字符
dd # 删除光标所在的一整行
#dd # 例如 3dd = 从光标所在行开始往下删 3 行复制与粘贴(所有 y 开头的复制都必须配 p 才能完成复制粘贴):
yw # 复制光标所在处到该单词结尾的字符
#yw # 例如 2yw = 复制 2 个单词
yy # 复制光标所在的一整行
#yy # 例如 6yy = 复制从光标所在行往下数 6 行
p # 把缓冲区内容粘贴到光标位置替换:
r # 替换光标所在位置的这一个字符(按 r 后按新字符)
R # 连续替换,直到按 Esc 才停止
cw # 更改光标所在处的单词(从光标到词尾)
c#w # 例如 c3w = 更改 3 个单词撤销与恢复:
u # undo,撤销上一步操作,连按多次可层层撤销
Ctrl + r # redo,撤销的撤销(恢复被 u 撤掉的步骤)跳到指定行:
Ctrl + g # 在底部列出当前光标所在行的行号和文件信息
#G # 例如 15G = 跳到第 15 行的行首vim 末行模式命令集
进入末行模式前,先按 Esc 确保在正常模式,再按 : 就可输入末行命令:
# 显示行号
set nu # number 的缩写,给每一行前显示行号
# 跳到某一行(在冒号后直接输入数字)
15 # 输入数字 15 回车,跳到第 15 行
# 查找字符
/关键字 # 往后查找;找到的不是想要的,继续按 n 往后找
?关键字 # 往前查找;找到的不是想要的,继续按 n 往前找
# 保存与退出
w # 保存
q # 退出(有未保存改动时会阻止)
wq # 保存并退出
q! # 强制退出,不保存思考题 2:vim 里末行模式查找,
/关键字和?关键字有什么区别?分别在什么场景下用?详解答案:
/关键字是从光标所在处向前(文件末尾方向) 查找,匹配到后高亮,按n键继续向前找下一个;?关键字是从光标所在处向后(文件开头方向) 查找,按n键继续向后找。一句话记忆:/朝"斜杠箭头指向的右边/下方"(行号增大方向)找,?朝"左边/上方"(行号减小方向)找。场景上:你在文件中部、想找当前代码之后才会出现的定义时用/;想回看刚经过的某处(比如回去确认函数声明)时用?。n是"重复上一次查找",方向遵循你所选的/或?。
vim 的简单配置(了解)
默认 vim 丑、也没行号,可以通过配置文件个性化。配置文件的规则有两层:
- 系统级:
/etc/vimrc,对系统里所有用户有效。 - 用户级:每个用户主目录下的
~/.vimrc,只对该用户有效,且优先级高于系统级。root 用户通常已经有一个,不存在就自己建。
配置方法就是编辑 ~/.vimrc,里面写一条条的选项:
# 切回自己的普通用户(如果能),并进入主目录
su 用户 # 切换用户
cd ~ # 进入主目录
vim .vimrc # 打开(或新建) 个人配置文件
# 在 .vimrc 里写入常用选项:
syntax on # 开启语法高亮
set nu # 显示行号
set shiftwidth=4 # 设置缩进宽度为 4 个空格
set mouse=a # 额外:允许鼠标操作(现实里很常用)
set tabstop=4 # 额外:Tab 显示宽度要多漂亮的功能,原生配置不够,可以装插件,比如 TagList(标签列表)、WinManager(文件与窗口管理)等。装插件的大致套路是:下载 zip → 解压 → 把 doc 里的内容拷到 ~/.vim/doc,把 plugin 里的内容拷到 ~/.vim/plugin → 在 ~/.vimrc 里加相关配置(如 let Tlist_Show_One_File=1、nmap wm :WMToggle<cr> 等)。这些细节现在可以只看看,等你真需要时再回来看。新手入门最好的练习是系统自带的教程:在终端直接敲 vimtutor,它会手把手带你走一遍 vim。 强烈建议你照做。
编译器 gcc / g++
装好编辑器能写代码了,但要让它变成能运行的程序,还得靠编译器。Linux 下 C/C++ 的编译器事实标准是 gcc / g++。
背景:编译的四个阶段
一个 .c 源文件变成可执行程序,中间要经过四个阶段。理解这四个阶段,是你理解后面所有编译选项的钥匙:
- 预处理(Preprocessing):做宏替换、去注释、条件编译、头文件展开等。处理以
#开头的预处理行。 - 编译(Compilation):把预处理后的代码翻译成汇编语言,同时做语法检查。
- 汇编(Assembly):把汇编代码转成机器可识别的二进制目标文件。
- 链接(Linking):把目标文件与库文件链接,生成可执行文件或库文件。
gcc 编译选项与四阶段实操
gcc 的通用格式是 gcc [选项] 要编译的文件 [选项] [目标文件]。下面我们用一个 hello.c 走完整四步。为了真实,先写出源文件:
$ vim hello.c # 用 vim 编辑,内容如下// hello.c
#include <stdio.h> // 引入标准输入输出头文件
#define MAX 100 // 定义一个宏,用于演示预处理
int main() // 程序入口
{
printf("MAX = %d\n", MAX); // 打印宏的值
return 0; // 正常结束
}第一步:预处理,选项 -E
预处理就是做宏展开(把 MAX 替换成 100)、头文件包含、条件编译、去注释等。注意:预处理只展开 # 开头的指令行,printf 这种函数调用它不管。
$ gcc -E hello.c -o hello.i # -E: 预处理后即停止, -o 指定输出文件名为 hello.i用 -E 时 gcc 会在预处理结束就停下来,不进入编译。.i 文件就是经过预处理的 C 原始程序。你可以打开 hello.i 拖到最底下看,会发现原来文件里只有几十行的 hello.c,被展开成了几百上千行——因为 stdio.h 的内容被整个"拷贝"进来了,MAX 也被替换成了 100。这就直观说明了"头文件展开、宏替换"到底干了什么。
第二步:编译,选项 -S
这个阶段做两件事:检查代码规范性和语法错误,然后翻译成汇编语言。
$ gcc -S hello.i -o hello.s # -S: 编译成汇编语言就停, 输出 hello.s打开 hello.s,你会看到 main:、mov、call printf 这样的汇编指令。这里有个初学者常问的点:为什么非要把 C 翻译成汇编这一站? 因为汇编是 CPU 指令的"人类可读近亲",编译器先生成它,既便于调试排查,也让上层语言和底层机器之间有了一个清晰的中间表达。它也是理解"高级语言如何被一步步降维成机器指令"的最佳观测窗口。
第三步:汇编,选项 -c
把 .s 汇编代码转成机器可识别的目标文件。
$ gcc -c hello.s -o hello.o # -c: 编制到目标代码就停, 输出二进制 hello.ohello.o 已经是二进制了,cat hello.o 会看到乱码,用 file hello.o 能确认它是一个 ELF ... relocatable 的重定位目标文件(暂时不关心和最后一步产物的区别)。
第四步:链接,无特殊选项
把目标文件和用到的库链接成可执行文件:
$ gcc hello.o -o hello # 链接, 生成可执行文件 hello
$ ./hello # 运行它
MAX = 100 # 输出把这四步串起来,正是后面的 make 一节要大力利用的链条。你也可以一条命令 gcc hello.c -o hello 直接出结果,gcc 会自动做完这些阶段。
动态链接和静态链接
实际开发中,代码不可能只放在一个源文件里,于是产生了链接。一个 .c 文件独立编译出一个 .o,多个 .o 之间可能有依赖关系(比如 A 调用了 B 里定义的函数),把它们"接到一起"成可执行程序的过程就是链接。
链接分两种,这是本章核心。
静态链接:把所有需要的目标文件"焊死"进可执行程序。缺点很明显:
- 浪费空间:每个可执行程序里都有它所依赖目标文件的一份副本。多个程序都调用了
printf,那内存里就有多份printf相关的代码,白白占用。 - 更新困难:库代码一旦改了一个函数,所有静态链接它的可执行程序都得重新编译链接一遍。
但它也有优点:可执行程序自带一切,不依赖外部库文件,运行时速度更快(不用找库、加载库)。
动态链接:针对静态链接的毛病而生。基本思想是把程序按模块拆成各个相对独立的"部件",在程序运行时才把它们链接到一起,而不是像静态链接那样一开始就焊成一个整体。运行时由"动态加载器"把用到的动态库加载进内存,多个程序可以共享同一份内存中的动态库,既省空间,库升级时也不用全部重编可执行程序。
现实里动态链接远比静态链接常用。看一个 hello 可执行程序依赖了哪些动态库,用 ldd 命令:
$ ldd hello
linux-vdso.so.1 => (0x00007fffeb1ab000)
libc.so.6 => /lib64/libc.so.6 (0x00007ff776af5000)
/lib64/ld-linux-x86-64.so.2 (0x00007ff776ec3000)ldd(list dynamic dependencies)命令就是用来打印程序或库文件所依赖的共享库列表的。输出里那个 libc.so.6 就是大名鼎鼎的 C 标准库动态库。
这里引出一个重要概念:库(library)。你的 C 程序里根本没定义 printf 的实现,头文件 stdio.h 里也只有它的声明(告诉编译器"有这么一个函数,长这样"),那它到底实现哪去了?答案:系统把 printf 的实现做进了叫 libc.so.6 的库文件里。没有特别指定时,gcc 会到默认搜索路径 /usr/lib 下去找,最终链接到这个 libc.so.6,于是 printf 就能用了——这就是链接的作用。
静态库和动态库
- 静态库:编译链接时,把库文件的代码全部加进可执行文件。生成的程序较大,但运行时不再需要库文件。Linux 下静态库后缀名一般是
.a(archive)。 - 动态库:编译链接时不把库代码加进可执行文件,而是在程序执行时由"运行时链接器"加载库。节省系统开销,多个程序可共享。后缀一般是
.so(shared object),libc.so.6就是动态库。gcc 默认采用动态链接。
验证一个 gcc 默认产物到底是不是动态链接的,用 file 命令:
$ file hello
hello: ELF 64-bit LSB executable, x86-64, ... dynamically linked ...看到 dynamically linked,就说明默认就是动态链接的。
平台后缀对照(务必记清):
| 平台 | 动态库 | 静态库 |
|---|---|---|
| Linux | xxx.so | xxx.a |
| Windows | xxx.dll | xxx.lib |
补充一个现实坑:云服务器默认一般没装 C/C++ 的静态库,想编译静态链接的程序会报"找不到 libc.a"之类。解决办法是按发行版装上静态库:
# CentOS / RHEL
yum install glibc-static libstdc++-static -y
# Ubuntu(一般自带,若缺则装)
sudo apt install libc6-dev libstdc++-6-dev一个帮助理解的"网吧记忆法":静态链接像把整套装备背在自己身上出门,走得快、但重;动态链接像带一张会员卡,进去临时调用网咖(系统)里公共的机器(动态库),轻便、大家共享。唯一要求是运行环境里得有那家"网咖"。
gcc 其它常用选项(了解为主)
除了四阶段选项,还有一批高频选项,先混个脸熟,后面用到再细究:
-E # 只做预处理不生成文件,通常要配合 -o 重定向到输出文件
-S # 编译到汇编语言,不做汇编和链接
-c # 编译到目标代码(.o),不做链接
-o 名字 # 指定输出文件名
-static # 对生成的文件采用静态链接(尽量用静态库)
-g # 生成调试信息,供 GDB 调试器使用(下一章 gdb 的关键)
-shared # 尽量使用动态库,生成文件小但运行时需要系统有对应动态库
-O0 .. -O3 # 优化级别 4 档: -O0 无优化, -O1 默认级别, -O3 优化最高
-w # 不生成任何警告信息
-Wall # 生成所有警告信息(强烈建议平时都加这个)特别说明下优化级别:-O0 不优化、可调试性最好;-O3 优化最激进、运行最快但可能改变程序行为(对未定义行为尤其危险)。默认是 -O1。调试阶段用 -O0 免得变量被优化掉,发布阶段才开 -O2/-O3。
思考题 3:运行
gcc -E hello.c -o hello.i后,我把hello.i里的内容拖到最底,发现它比原来的 hello.c 多出几百行。这些多出来的行是哪来的?这证明了预处理的哪些功能?详解答案:多出来的行绝大部分来自
#include <stdio.h>这一行:预处理把stdio.h头文件的内容完整展开(拷贝)进了hello.i,于是文件瞬间膨胀。这正是预处理"头文件展开"功能的直接体现。同时,如果你在hello.i里搜索MAX,会发现它已经找不到MAX = 100这种用法,而是变成了printf("MAX = %d\n", 100);——MAX被替换成了100,这就是"宏替换"。所以这一段展开同时演示了预处理的两个核心能力:头文件展开 + 宏替换(去注释、条件编译也在这个阶段完成)。
自动化构建:make / Makefile
代码多了、工程大了,每次编译都要敲那一长串 gcc 命令,人会疯。于是有了 make 和 Makefile。
背景:为什么要自动化
会不会写 Makefile,常常被当作衡量一个人是否具备大型工程能力的一个侧面标准。一个工程里源文件往往不计其数,按类型、功能、模块分散在若干目录里。Makefile 定义了一系列规则,指明哪些文件先编译、哪些后编译、哪些需要重新编译,甚至能做更复杂的功能操作。它的核心价值就一个字——自动化:Makefile 一旦写好,只需要一个 make 命令,整个工程自动编译,极大提高效率。
- make 是一条命令,是一个解释 Makefile 中指令的命令工具。
- Makefile 是一个文件,里面写着构建规则。
- 两者搭配,完成项目自动化构建。
几乎每家大型 IDE 都内置了"make"这个思路的命令:Delphi 的 make、Visual C++ 的 nmake、Linux 下 GNU 的 make。可见 makefile 已经是一种工程界通用的编译方法。
基本使用
先给一个最朴素的"依赖"理解:依赖 = "要什么才有什么"。 就像月底要发工资,工资依赖你当月上班的表现;一个可执行文件依赖它对应的源文件。
我们写一个程序 myproc:
// myproc.c
#include <stdio.h> // 引入标准库
int main() // 入口
{
printf("hello Makefile!\n"); // 打印一行
return 0;
}对应的最简 Makefile:
# Makefile
myproc:myproc.c # 目标:依赖 —— myproc 依赖 myproc.c
gcc -o myproc myproc.c # 依赖方法(命令): 用 gcc 生成 myproc
.PHONY:clean # 声明 clean 为伪目标(见后文)
clean: # clean 目标
rm -f myproc # 删除可执行文件编译运行:
$ make # 查找 Makefile 并执行第一个目标
gcc -o myproc myproc.c
$ ./myproc
hello Makefile!然后清理:
$ make clean # 显式执行 clean 目标
rm -f myproc这里有两个核心概念你必须搞清:
依赖关系:冒号左边是目标(target),右边是依赖文件。myproc 依赖 myproc.c,意思是"要得到 myproc 这个文件,得有 myproc.c"。
依赖方法:目标下缩进写的命令,就是"如何去实现这个依赖关系"。gcc -o myproc myproc.c 就是生成 myproc 的方法。
注意 Makefile 里命令行的缩进必须是一个 Tab 字符,不能是空格!这是新手第一坑,用空格缩进 make 会报 missing separator。如果你复制的代码缩进变成了空格,可以用 cat -A Makefile 查看,Tab 会显示为 ^I。
项目清理与 .PHONY 伪目标
工程是需要清理的(留下的中间文件、可执行文件要能一键删掉)。像上面 clean 这个目标,它没有被第一个目标 myproc 直接或间接关联——意思是只敲 make 时它不会被自动执行。想执行就得显式 make clean。
但有个更妙的机制:伪目标(phony target),用 .PHONY 声明。伪目标的特性是总是被执行。
要理解"总是被执行",得先懂 make 判断"要不要重新编译"的底层依据——文件时间戳。先看 stat 命令能揭示什么:
$ stat myproc
File: 'myproc'
Size: 987 Blocks: 8 IO Block: 4096 regular file
Modify: 2024-10-25 17:05:25.940595116 +0800 # 内容变更时间
Change: 2024-10-25 17:05:25.940595116 +0800 # 属性变更时间
Access: 2024-10-25 17:10:01.123456789 +0800 # 最近访问时间文件 = 内容 + 属性。理解这三个时间戳,是吃透 make 的关键:
- Modify(mtime):内容被修改时更新。你改代码、重新编译,它变。
- Change(ctime):属性(权限、文件名、硬链接数、内容也会连带上次)变更时更新。
- Access(atime):文件最近一次被访问的时间。注意我们不能依赖 atime——早期 Linux 每当访问就更新,会带来大量磁盘 IO,所以现在很多系统用
relatime等机制优化,具体更新规则各系统略有差异,不必深究。
make 的现代构建机制恰恰是拿"目标"和"依赖"的 mtime 作比较:如果依赖比目标新(或目标不存在),说明源文件改过了,需要重新编译;如果目标比依赖新,说明已经是最新的,就"跳过",什么也不做。再敲一次 make 你就懂了:
$ make # 第一次:编译
gcc -o myproc myproc.c
$ make # 第二次:nothing to be done —— 目标已是最新
make: 'myproc' is up to date.那 clean 要面对的尴尬是:如果恰好磁盘上有个文件叫 clean,且它比任何依赖都新,那 make clean 就会判断"clean 已是最新",从而不执行清理命令——这显然不是我们想要的。把 clean 声明成 .PHONY 伪目标,就告诉 make:别拿文件时间戳来判断我,反正每次都执行。 一句话总结:
思考题 4:
.PHONY:clean到底在做什么?为什么我们用touch测试文件时间戳能触发重新编译?详解答案:
.PHONY把clean声明为"伪目标",含义是:让 make 忽略源文件和目标文件的 mtime 时间对比,永远无条件执行clean的命令。这样即使磁盘上存在一个叫clean的普通文件,make clean也一定会去删除目标、执行清理逻辑。而touch之所以能"骗过 make 触发重编译",是因为 make 检查"是否需要重新构建"的标准就是目标与依赖的 mtime:touch myproc.c会把myproc.c的 mtime 更新成"现在",让它比已生成的myproc更"新",于是 make 判定"依赖比目标新、源文件有变化",就重新执行gcc -o myproc myproc.c。这正是 make 增量构建(只重建有改动的部分)的核心原理。
make 的推导过程
make 最强大的能力是递归解析依赖。看一个把四步编译铺开的 Makefile:
# Makefile —— 完整展示 make 的自上而下递归解析
myproc:myproc.o # myproc 依赖 myproc.o
gcc myproc.o -o myproc # 用 myproc.o 链接
myproc.o:myproc.s # myproc.o 依赖 myproc.s
gcc -c myproc.s -o myproc.o # 汇编: .s -> .o
myproc.s:myproc.i # myproc.s 依赖 myproc.i
gcc -S myproc.i -o myproc.s # 编译: .i -> .s
myproc.i:myproc.c # myproc.i 依赖 myproc.c
gcc -E myproc.c -o myproc.i # 预处理: .c -> .i
.PHONY:clean
clean:
rm -f *.i *.s *.o myproc # 一键清理所有中间产物执行 make,你会看到它自动按依赖顺序把四步全跑了一遍:
$ make
gcc -E myproc.c -o myproc.i # 预处理
gcc -S myproc.i -o myproc.s # 编译
gcc -c myproc.s -o myproc.o # 汇编
gcc myproc.o -o myproc # 链接看到过程你就懂 make 干活的逻辑了。默认只敲 make,它按下面这套流程走:
- 在当前目录找名为
Makefile或makefile的文件。 - 找到后,读文件里的第一个目标(target),在上面例子里就是
myproc,把它作为最终目标。 - 如果
myproc不存在,或者它依赖的myproc.o的修改时间比myproc更新(可以用touch测试验证),就执行后面定义的命令去生成myproc。 - 如果
myproc.o也不存在,make 会在文件中找以myproc.o为目标的那条规则,看它的依赖(myproc.s),套同样的逻辑——这有点像"进栈-出栈"的递归。 - 一直往下刨,直到找到源文件恰好存在为止,然后从最底层一步一步"弹栈"式地把上层目标构建出来,直到最终目标
myproc(文档里写成 hello 是同一回事)。 - 这就是 make 的递归依赖解析:一层层找依赖,直到编译出第一个目标。
- 如果过程中某个被依赖的文件根本找不到,make 直接退出报错;但注意,make 对"命令执行失败了"这种错误不负责——它只负责文件的依赖关系是否满足。
- 一句话:make 只管文件依赖。如果它指定要的依赖文件找不到,它就罢工。
适度扩展语法
真实工程里不会手写那么多重复规则,要用变量和模式规则让它"自动挡"。看你工程里最典型的写法:
# Makefile(进阶版)
BIN=proc.exe # 定义变量 BIN, 值是可执行文件名
CC=gcc # CC 变量存编译器名
#SRC=$(shell ls *.c) # 方式一: 用 shell 函数取当前目录所有 .c
SRC=$(wildcard *.c) # 方式二: 用 wildcard 函数取所有 .c 文件
OBJ=$(SRC:.c=.o) # 把 SRC 里所有 .c 后缀替换成 .o, 得到目标列表
LFLAGS=-o # 链接选项
FLAGS=-c # 编译选项
RM=rm -f # 引入删除命令
$(BIN):$(OBJ) # BIN 依赖所有 .o
@$(CC) $(LFLAGS) $@ $^ # $@=目标名, $^=全部依赖; @前缀=不回显命令
@echo "linking ... $^ to $@"
%.o:%.c # 模式规则: 任意一个 .o 都可由同名 .c 生成
@$(CC) $(FLAGS) $< # $<=第一个依赖(.c), 逐个交给 gcc 编译
@echo "compling ... $< to $@"
.PHONY:clean # 伪目标
clean:
$(RM) $(OBJ) $(BIN) # $(RM) 会被替换成 rm -f
.PHONY:test
test: # 测试目标, 用来看 SRC/OBJ 到底展开成啥
@echo $(SRC)
@echo $(OBJ)用到的几个自动变量和函数,单独拎出来讲——这几行是全篇最容易懵的地方:
$@:代表目标文件名。$^:代表依赖列表中全部文件。$<:代表依赖列表中第一个文件(模式规则里常用)。$(VAR):取变量VAR的值。$(wildcard *.c):wildcard函数,把当前目录匹配*.c的所有文件名作为结果。$(shell ...):把命令行的输出作为结果。$(SRC:.c=.o):一种"替换引用",把SRC里每个元素的.c后缀改成.o。- 命令前的
@:不回显(不打印)这一条命令本身,只显示执行结果。 %.o:%.c:模式规则,%是通配,表示"任取一个名字",.c依赖.o一一对应。
试着运行 make test 和 make,你会亲眼看到变量展开和模式规则如何工作。
思考题 5:进阶 Makefile 里
OBJ=$(SRC:.c=.o)这行的作用是什么?$@、$^、$<三个自动变量在这份 Makefile 里分别代表什么?详解答案:
OBJ=$(SRC:.c=.o)是"替换引用":它把变量SRC里每一个文件名的.c后缀批量替换成.o。比如当SRC是main.c add.c时,OBJ就变成main.o add.o,从而由"源文件列表"直接推导出"需要生成的目标文件列表",避免了手写一长串目标。三个自动变量:$@表示当前规则的目标名(在$(BIN):$(OBJ)里是proc.exe);$^表示当前规则的全部依赖(那里的$^是所有.o);$<表示当前规则依赖列表里的第一个依赖(在%.o:%.c中就是一个个被展开的具体.c文件)。@前缀(写在命令最前面、与$无关)表示"不回显这条命令本身"。
Linux 第一个系统程序:进度条
工具学得差不多了,咱们动个手。这个经典的小作业——命令行进度条——会用到很多细节,是理解"终端如何显示动态界面"的极好入口。
补充:回车与换行、\r 与 \n
先讲两个概念,是这次作业的根源:回车(carriage return) 和 换行(line feed)。
- 回车:把光标移回当前行的行首(开头)。
- 换行:把光标移到下一行(但可能还保持在当前列)。
在早期的打字机上,回纸、进纸是两个独立动作,分别对应"回车"和"换行"。现代键盘上的 Enter 键按下的事件里,Windows/Linux/macOS 的表示并不同:
- Windows 的换行是
\r\n(回车+换行两字符)。 - Linux/macOS(现代) 只用一个
\n(换行)。 - 老式 Mac 是
\r。
而在 C 语言里,这两个转义字符的意义很纯粹:\n 是换行,\r 是回车(把光标打回行首)。这正是进度条的核心 trick:我们用 \r 每次把光标拉回行首,再用新内容覆盖旧内容,就能在同一行上做出"动态刷新"的效果,而不是一行一行往下刷屏。
行缓冲区:为什么有的内容不立刻显示
Linux 的 printf 输出默认会经过一个用户态缓冲区(标准库行的缓冲策略)。这里先看三个现象,你亲自跑一遍会对"缓冲"有深刻体感。
现象一:printf("hello bite!\n"); sleep(3); —— 先立刻显示,再睡 3 秒。因为 \n 触发了"行缓冲刷新"。
现象二:printf("hello bite!"); sleep(3);(去掉 \n)—— 你会看到 3 秒内屏幕上什么都没有,直到 sleep 结束才突然显示。因为字符串没换行、缓冲没满,它就攒在缓冲区里没落屏。
现象三:printf("hello bite!"); fflush(stdout); sleep(3); —— 又立刻显示了。因为 fflush(stdout) 强制把缓冲区内容刷到屏幕。
三个程序:
// 现象一: 有 \n, 立刻显示
#include <stdio.h>
#include <unistd.h> // sleep 函数的声明
int main()
{
printf("hello bite!\n"); // 末尾有换行, 触发行缓冲刷新
sleep(3); // 睡 3 秒
return 0;
}// 现象二: 没有 \n, 要等程序结束或缓冲满才显示
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("hello bite!"); // 没有换行, 内容滞留在缓冲区
sleep(3); // 这 3 秒屏幕上啥也没有
return 0;
}// 现象三: 手动 fflush 强制刷新
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("hello bite!"); // 仍不换行
fflush(stdout); // 但手动强制把缓冲区刷到终端
sleep(3); // 立刻就显示
return 0;
}结论:现代 Linux 终端下,stdout(标准输出)默认是行缓冲——遇到换行符就刷;没换行或有大量数据要分批写,内容就会在缓冲区里攒着。fflush(stdout) 可以随时手动强制刷新。进度条必须动态刷新,所以每个 printf 后面都得跟 fflush(stdout),否则进度条不会"动"。
练手:倒计时程序
先做一个倒计时,把 \r 和 fflush 用起来:
// countdown.c 倒计时 10..0, 每行原位刷新
#include <stdio.h>
#include <unistd.h> // sleep
int main()
{
int i = 10; // 从 10 开始倒数
while(i >= 0) // 到 0 为止
{
printf("%-2d\r", i); // %-2d 左对齐宽2; \r 回行首覆盖旧数字
fflush(stdout); // 每步强制刷新, 否则不显示
i--; // 计数递减
sleep(1); // 暂停 1 秒
}
printf("\n"); // 结束补一个换行
return 0;
}编译运行 gcc countdown.c -o countdown && ./countdown,屏幕同一个位置不断显示 10、9、8……这就是"原位刷新"的雏形。
进度条代码
进阶版的进度条 processbar,会用到上面所有技巧。我们拆成三个文件(这也顺带练多文件编译和 Makefile):
process.h(头文件,只放声明与常量):
// process.h
#pragma once // 防止头文件被重复包含
#include <stdio.h>
void process_v1(); // 版本1: 简单进度条
void FlushProcess(double total, double current); // 版本2: 按"总量/当前量"刷进度process.c(实现):
// process.c
#include "process.h"
#include <string.h>
#include <unistd.h>
#define NUM 101 // 缓冲区大小(放 100 个进度轴 + 结尾'\0')
#define STYLE '=' // 进度条填充字符
// 版本1: 纯演示, 自己循环把进度从 0 打到 100%
void process_v1()
{
char buffer[NUM]; // 进度条缓冲区
memset(buffer, 0, sizeof(buffer)); // 先清零
const char *lable = "|/-\\"; // 旋转动画字符
int len = strlen(lable); // 字符数=4
int cnt = 0; // 当前进度(0..100)
while(cnt <= 100)
{
// [%-100s] 左对齐宽100; [%d%%] 显示百分比; [%c] 旋转指针; \r 回行首
printf("[%-100s][%d%%][%c]\r", buffer, cnt, lable[cnt % len]);
fflush(stdout); // 强制刷新
buffer[cnt] = STYLE; // 填上当前位置的 =
cnt++; // 下一格
usleep(50000); // 休息 0.05 秒
}
printf("\n"); // 结束后换行
}
// 版本2: 由外部给"总量 total"和"当前量 current", 计算进度百分比来刷
void FlushProcess(double total, double current)
{
char buffer[NUM]; // 进度条缓冲区
memset(buffer, 0, sizeof(buffer)); // 清零
const char *lable = "|/-\\"; // 旋转动画
int len = strlen(lable);
static int cnt = 0; // 静态变量: 记录旋转到了第几个字符(跨调用保留)
// 不自己循环, 而是根据已下载比例填充已满的 '='
int num = (int)(current * 100 / total); // 换算成"已满多少格"(0..100)
int i = 0;
for(; i < num; i++) // 往 buffer 前 num 格填满
{
buffer[i] = STYLE;
}
double rate = current / total; // 进度比例
cnt %= len; // 旋转索引取模, 保证不越界
printf("[%-100s][%.1f%%][%c]\r", buffer, rate * 100, lable[cnt]);
cnt++; // 下次旋转到下一个字符
fflush(stdout); // 刷屏
}main.c(模拟一个下载过程反复调用进度条):
// main.c
#include "process.h"
#include <stdio.h>
#include <unistd.h>
double total = 1024.0; // 模拟总量: 1024MB
double speed = 1.0; // 每轮增加的量(模拟下载速度)
void DownLoad() // 模拟一次下载
{
double current = 0; // 已经从 0 开始
while(current <= total) // 没下载完就继续
{
FlushProcess(total, current); // 刷一格进度
usleep(3000); // 模拟网络延时(也当作"下载数据")
current += speed; // 进度推进
}
printf("\ndownload %.2lfMB Done\n", current); // 结束输出结果
}
int main()
{
DownLoad(); // 连续调 8 次演示完整/重复刷新
DownLoad();
DownLoad();
DownLoad();
DownLoad();
DownLoad();
DownLoad();
DownLoad();
return 0;
}配套的 Makefile(用前面进阶语法):
# Makefile —— 进度条工程
SRC=$(wildcard *.c) # 取当前目录所有 .c
OBJ=$(SRC:.c=.o) # 转成 .o 目标列表
BIN=processbar # 可执行文件名
$(BIN):$(OBJ) # BIN 依赖所有 .o
gcc -o $@ $^ # 链接
%.o:%.c # 每个 .o 由同名 .c 编译
gcc -c $< # 只编译不链接
.PHONY:clean
clean:
rm -f $(OBJ) $(BIN) # 清理产物运行:
$ make # 编译
$ ./processbar # 运行, 看到 8 段动态进度条思考题 6:进度条里把
\n换成\r,效果立刻从"换行刷新"变成"同一行覆盖刷新",为什么?如果我把fflush(stdout)全部拿掉,进度条会变成什么样?详解答案:
\n是换行(光标下移到下一行),会让每次输出都落在新的一行,屏幕不断往下滚,形不成"同一根进度条增长"的效果;\r是回车(光标回到当前行首),这样下一次输出从行首开始覆盖之前的字,配合动态填充字符,就实现了"同一行原位增长"的进度条动画。如果把fflush(stdout)拿掉,问题就来了:因为stdout是行缓冲,而我们用的是\r(不是\n),缓冲区不满足"遇到换行"的刷新条件,进度字符就会一直积在缓冲区里,直到缓冲区满或程序结束才一次性刷出来——表现就是进度条"卡死不动",最后突然瞬移出最终结果,完全丧失动态效果。所以进度条每一格都必须fflush(stdout)。
版本控制器 Git
代码写到能跑了,下一步是管理代码版本、和别人协作。这就要说 Git。
版本控制器:为什么需要它
回想一下你平时写文档:为了防止丢失、改坏了想回退,是不是复制出一个个"报告-v1""报告-v2""报告-最终版""报告-究极进化版"?文件越来越多,最致命的问题是——你还记得每个版本到底改了什么吗? 代码项目面临同样的问题,甚至更严重。
版本控制器(version control system) 就是来解决这个问题的:它是一个能让你了解一个文件的完整历史以及其发展过程的系统。通俗讲,就是一个可以记录工程每一次改动和版本迭代的管理系统,同时方便多人协同作业。目前最主流的版本控制器就是 Git。Git 可以管理电脑上所有格式的文件(doc、excel、dwg、dgn、rvt……),对开发者而言,最重要的是它帮我们管理项目源代码。
git 简史
Git 诞生于一段极具戏剧性的往事。Linux 内核开源项目参与者众多,2002 年之前,大量的内核维护工作都耗在提交补丁、保存归档的繁琐事务上。2002 年,项目组开始用一款专有的分布式版本控制系统 BitKeeper 管理代码。到了 2005 年,开发 BitKeeper 的公司与 Linux 内核社区合作结束,收回了社区免费使用 BitKeeper 的权利。这让 Linux 社区(尤其是缔造者 Linus Torvalds,林纳斯·托瓦兹)被迫基于使用 BitKeeper 的经验教训,着手开发自己的版本系统。他们为这个新系统定下几条硬指标:
- 速度(要快)
- 简单的设计
- 对非线性开发模式的强支持(允许成千上万个并行开发的分支)
- 完全分布式
- 有能力高效管理像 Linux 内核这样超大规模的项目(速度和数据量)
自 2005 年诞生以来,Git 日臻完善,既高度易用,又保留了最初的这些目标——速度快、极其适合大项目、有着难以置信的非线性分支管理能力。
安装 git 与三板斧
先安装并确认:
# CentOS
sudo yum install -y git
# Ubuntu
sudo apt install -y git
# 查看版本
$ git --version
git version 2.39.3 # 你机器上的版本号可能不同, 只要没报错即可一台全新的机器上,第一次提交前还要配一下身份(否则 commit 会报错):
git config --global user.name "你的名字"
git config --global user.email "you@example.com"在 GitHub 创建项目并克隆到本地
- 注册 GitHub 账号(按官网提示,需要邮箱校验)。
- 登录后,进入个人主页,点左下角的 New repository 新建项目。
- 输入项目名称(名称不能全局重复,系统自动校验,可能要等几秒),点 Create repository 确认。
- 在项目页面复制项目的链接(URL),备用。
然后在本机建一个放代码的目录并克隆下来:
$ git clone [url] # url 就是刚才复制的项目链接clone 会把整个仓库(含历史)拉到本地当前目录,随后你就能在克隆出的文件夹里写代码了。
三板斧:add / commit / push
Git 日常用的核心流程俗称"三板斧":
第一斧:git add —— 把改动告知 git
# 把需要被 git 管理的文件(改动)加进"暂存区"
git add [文件名] # 加指定文件
git add . # 加当前目录下所有改动(注意最后的点)add 是把你改动的文件先"登记"到暂存区,告诉 git "这次提交要包含这些"。注意这一步只登记,还没真正记录版本。
第二斧:git commit —— 提交改动到本地
# 把暂存区内容正式提交成一个本地版本
git commit -m "XXX"-m 后面是提交日志,要写清楚"这次改了什么"。这是给未来的自己/队友看的,一定要具体、描述改动内容。注意 commit 是提交到本地仓库,此刻远端 GitHub 上还没变化。
第三斧:git push —— 同步到远端
# 把本地提交推送到 GitHub
git push首次 push 会要求输入 GitHub 用户名密码/令牌(现在多用 Personal Access Token)。成功后刷新 GitHub 页面就能看到代码更新了。
如果想省去每次输密码,可以配置免密提交(生成 SSH 密钥并添加到 GitHub 的 SSH keys 里,或用 gh auth login),具体按官方文档操作。
其它常用命令
git log # 查看提交历史(谁在什么时候改了什么)
git status # 查看工作区当前状态(哪些改了、哪些暂存了、哪些没提交)
git pull # 把远端最新改动拉到本地(多人协作时很常用).gitignore:一个特殊文件,写进去的路径/规则不会被 git 跟踪。比如 build/、*.o、.exe、.env(含密钥的文件)应该忽略,免得把临时产物或敏感信息提交上去。
思考题 7:
git commit之后,我在 GitHub 网页上刷新,代码却还是旧的。这是为什么?还需要执行哪条命令?详解答案:因为
git commit只是把改动提交到了本地仓库,并没有同步到远端 GitHub。Git 是分布式版本控制,本地和远端各有一份仓库历史,"本地提交"和"远端更新"是两个独立动作。要想让 GitHub 仓库也更新到最新,必须再执行git push,把本地提交推送到远端。同理,如果你直接改了本地文件但既没 add 也没 commit,那push也推不上一个"未提交的版本"——所以正确顺序永远是"改代码 →git add→git commit→git push"。
调试器 gdb / cgdb
程序能编译能跑了,但结果不对,怎么办?这时候就该上调试器了。Linux 下的调试器事实标准是 gdb(GNU Debugger,GNU 调试器)。初学者看到全黑屏的 gdb 常常发怵,其实只要掌握几个命令就够日常用了;嫌黑屏难受,还可以用它的分屏升级版 cgdb。
样例代码
讲命令前,先准备一个能调试的程序。我们写一个从 s 累加到 e 的函数:
// mycmd.c
#include <stdio.h>
int Sum(int s, int e) // 计算 s + ... + e
{
int result = 0;
for(int i = s; i <= e; i++) // 循环累加
{
result += i;
}
return result;
}
int main()
{
int start = 1;
int end = 100;
printf("I will begin\n");
int n = Sum(start, end); // 1+2+...+100 = 5050
printf("running done, result is: [%d-%d]=%d\n", start, end, n);
return 0;
}预备:debug 与 release
要知道一个关键前提:程序有 debug(调试) 和 release(发布) 两种发布方式。Linux 下 gcc/g++ 出来的二进制,默认是 release 模式(做过优化、不带完整的调试信息,文件里看不出源代码),默认不支持 gdb 调试。
要能调试,编译时必须加 -g 选项,把调试信息写进二进制。对比一下:
# 默认 release, 不带调试信息
$ gcc mycmd.c -o mycmd
$ file mycmd
mycmd: ELF 64-bit LSB ..., dynamically linked, ..., not stripped
# debug 模式, 加了 -g
$ gcc mycmd.c -o mycmd -g
$ file mycmd
mycmd: ELF 64-bit LSB ..., dynamically linked, ..., with debug_info, not stripped注意 file 输出里,加了 -g 后出现了 with debug_info——这就是调试信息被打进去了的标志。不加 -g,gdb 里 list 看不了源码、断点也会怪怪的。
(顺带一提:"not stripped" 指符号表未剥离。strip 可移除符号表以减小体积、增加逆向难度,但会让调试信息丢失,属另一种话题。)
gdb 常见命令
启动和退出:
gdb binFile # 开始调试一个可执行文件
# 进入后退出: 按 Ctrl + d, 或在 (gdb) 提示符下输入 quit常用命令速查表(后面会有实战演示):
| 命令 | 作用 | 示例 |
|---|---|---|
list / l | 显示源代码,从上一次位置开始每次列 10 行 | list 10 / list main |
list/l 文件名:行号 | 显示指定文件某处代码 | l mycmd.c:1 |
run / r | 从程序开始连续执行 | run |
next / n | 单步执行,不进入函数内部(逐过程,类似 F10) | next |
step / s | 单步执行,进入函数内部(逐语句,类似 F11) | step |
break / b [文件名:]行号 | 在指定行设断点 | break 20 / break test.c:10 |
break/b 函数名 | 在函数开头设断点 | break main |
info break / info b | 查看当前所有断点信息 | info break |
finish | 执行到当前函数返回再停止 | finish |
print / p 表达式 | 打印表达式的值 | print start+end |
p 变量 | 打印变量值 | p x |
set var 变量=值 | 修改某个变量的值(用来测试假设) | set var i=10 |
continue / c | 从当前位置继续连续执行 | continue |
delete/d breakpoints | 删除所有断点 | delete breakpoints |
delete/d breakpoints n | 删除序号为 n 的断点 | delete breakpoints 1 |
disable/enable breakpoints | 禁用/启用断点 | disable breakpoints |
display 变量 | 跟踪显示变量,每次停止时自动打印 | display x |
undisplay 编号 | 取消变量的跟踪显示 | undisplay 1 |
until 行号 | 一直执行直到到达指定行 | until 20 |
backtrace / bt | 查看调用栈(各级函数调用及参数) | backtrace |
info locals | 查看当前栈帧局部变量值 | info locals |
quit | 退出 gdb | quit |
经验提醒:发布前想过一遍逻辑、或出了 bug 想定位,优先在调试期用 -g 编译;确认没问题、要发布给用户跑了再编译一次无 -g 的 release 版本——两层版本各有用途。也有同学图省事只编一次,但 debug/release 分离是一种工程纪律。
gdb 实战演示
我们配一个调试流程感受 gdb 怎么用。源码 mycmd.c、编译 gcc mycmd.c -o mycmd -g。
演示A:单步 + 断点 + step 进入函数 + display
$ gdb mycmd
(gdb) l main # 看 main 函数源码
(gdb) b 20 # 在第 20 行(int n = Sum(...))设断点
(gdb) r # 运行, 停在断点
Breakpoint 1, main () at mycmd.c:20
(gdb) s # step, 进入 Sum 函数内部
Sum (s=32767, e=-7136) at mycmd.c:5 # 注意: 从 main 进来时参数还没按 1/100 传?
(gdb) n # 逐过程
(gdb) display i # 跟踪显示 i, 以后每次停止自动打印 i 的值
(gdb) n # 继续单步, 看到 1: i = 1、2、3...上面有个非常值得注意的细节:step 进入 Sum 时,gdb 显示 Sum (s=32767, e=-7136)——参数看起来是些"垃圾值"。这不是程序错了,而是 gdb 刚跳进函数体第一行(int result = 0; 之前),寄存器还没被正确地反映到参数的符号上,属于打印时机问题;等真正执行到用参数的代码处再 p s,你看到的就是正确的 1 和 100。初学遇到别慌,先往后再单步几步再查参数。
演示B:用 set var 验证"是不是 flag 的锅"
改造 mycmd.c,加入一个可能出错的分支:
// mycmd.c —— 故意让 flag 为 0, 导致结果恒为 0
#include <stdio.h>
int flag = 0; // 故意错误: 应为 1
//int flag = -1;
//int flag = 1;
int Sum(int s, int e)
{
int result = 0;
for(int i = s; i <= e; i++)
{
result += i;
}
return result * flag; // 乘了 flag, flag=0 结果就是 0
}
int main()
{
int start = 1;
int end = 100;
printf("I will begin\n");
int n = Sum(start, end);
printf("running done, result is: [%d-%d]=%d\n", start, end, n);
return 0;
}(本段是"演示用"的另一个文件版本,行号与上面那份基本一致、略有偏移,下面 gdb 里的 b 24、until 16 等数字请对应你实际文件的行号微调。)
调试后发现结果 [1-100]=0,怀疑是 flag 的锅。验证思路:进 Sum 后看 result 和 flag:
(gdb) b 24 # 在 int n = Sum(start, end); 处断点
(gdb) r
(gdb) s # step 进 Sum
(gdb) until 16 # 不一步步走完循环, 直接跳到 return result*flag
(gdb) p result # 看累加结果
$1 = 5050 # result 是对的!
(gdb) p flag # 再看 flag
$2 = 0 # flag 是 0, 结果当然 0
(gdb) set var flag=1 # 临时把 flag 改成 1, 验证假设
(gdb) p flag
$3 = 1
(gdb) n # 单步执行 return result*flag
(gdb) n
running done, result is: [1-100]=5050 # 结果立刻正常了, 确认就是 flag 的锅这就是 set var 的威力:不改源码、不重新编译,直接在调试会话里改变量的值,用来验证"是不是这个变量导致的问题",从而快速定位罪魁祸首,极大加快排查节奏。
演示C:条件断点(两种写法)
循环 100 次的函数,手动一步步 next 太累。可以设条件断点——只在满足条件的时刻停下。
写法一(新增时直接带条件):
(gdb) b 9 if i == 30 # 在第 9 行新增断点, 且只有当 i==30 才停
(gdb) info b # 看到: stop only if i == 30
(gdb) finish # 跑到 Sum 返回前, 断点会在 i==30 时精准命中写法二(给已有断点追加条件):
(gdb) b 9 # 先在第 9 行设一个普通断点
(gdb) condition 2 i==30 # 给 2 号断点追加条件 i==30 (这里没有 if 关键字!)
(gdb) info b # 看到: 2 号断点 stop only if i==30注意两种语法区别,非常容易写错:
- 新增带条件:
b 行号/文件名:行号/函数名 if 条件,比如b 9 if i == 30,关键字是if。 - 给已有断点追加条件:
condition 断点编号 条件,比如condition 2 i==30,没有if关键字。
cgdb:分屏调试
gdb 虽然强大,但纯黑屏看不到代码上下文很不爽。推荐装 cgdb,它把屏幕分成上下两块:上面显示带高亮的源码(还能标记当前停在哪一行),下面还是 gdb 提示符。
# Ubuntu
sudo apt-get install -y cgdb
# CentOS
sudo yum install -y cgdb习惯用法:在 cgdb 里按 Esc 进入代码屏(可以上下翻源码),按 i 回到 gdb 屏输入命令。这样"看代码"和"输命令"能同时进行,调试体验提升一大截。
思考题 8:我想在循环里只在
i等于 30 的那一次停下来看看内部变量。请写出两种实现条件断点的命令,并指出两者语法的关键差别。详解答案:两种方式如下。方式一(新增时直接带条件):
b 9 if i == 30——在行号/文件名/函数名之后用一个if关键字接条件。方式二(给已有断点追加条件):先b 9建立普通断点,再condition 2 i==30——这里的condition命令后面直接跟"断点编号 + 条件表达式",没有if关键字(condition这个词本身已经暗示"增加条件"了)。两种方法都能让 gdb 只在i==30时停下;它们的本质区别是"建断点时就带上条件"vs"先建普通断点再补加条件",实际中都常用,关键是别把是不是要写if记混。
Linux 开发常见坑位汇总
这一节把前面散落的坑集中列一份"避坑清单",方便你以后当速查表用:
- 装软件权限不足:yum/apt 装到系统目录要
sudo(或切 root),提示无权限八成是没加sudo。 - yum/apt 报"另一个程序占用":同一时刻只能跑一个装机任务,等待当前任务结束或排查残留进程。
- make 报
missing separator:Makefile 的命令行缩进必须是 Tab,不能用空格。检查方法:cat -A Makefile,Tab 显示为^I。 - make 说 "nothing to be done":目标已是最新(目标 mtime 比依赖新)。想强制重编就
touch依赖文件,或make clean && make。 - gdb 里
list看不到源码:编译时没加-g。必须gcc -g才带调试信息。 - 进度条不刷新:
\n/缓冲没刷。动态界面要靠\r+fflush(stdout)。 git commit后远端没变:还差git push(以及第一次记得先git add)。- gcc 编译报找不到静态库:云服务器常缺
glibc-static libstdc++-static,装上即可。 - 运行程序报"cannot open shared object file":程序运行时要找的动态库路径不在默认目录,可能要用
LD_LIBRARY_PATH指定,或把库装入/usr/lib:/lib。 - 换行符差异导致乱码/版本冲突:Windows 是
\r\n、Linux 是\n。跨平台协作时用 git 的core.autocrlf或编辑器的"统一换行符"避免不必要的全文件 diff。
总结
这一篇我们从"在 Linux 上怎么开始写一个能跑的项目"这个朴素问题出发,把开发链条从头到尾走了一遍:用包管理器(yum/apt)装好开发工具,用 vim 写代码,用 gcc/g++ 把代码分四步编译成可执行程序并理解动态/静态链接,工程大了用 make/Makefile 自动化构建(并看懂它基于 mtime 的增量构建与 .PHONY 伪目标),跑起来的效果不对就上 gdb/cgdb 设断点、看变量、设条件断点、用 set var 验证假设,最后还能用 git 守住代码历史和版本协作。 中间还穿插写出了第一个"有系统样貌"的终端程序——进度条,亲身体会了回车/换行和行缓冲的底层规则。
这些工具单独看都只是"命令",但它们组合起来,就是 Linux 工程师日常真实的工作流。工具类的东西最见效的学习方式永远是动手:把上面的命令一个个敲一遍,哪怕故意把缩进换成空格、把 -g 去掉、把进度条的 \r 误写成 \n、把 fflush 删掉,亲眼看看会报什么错、出什么诡异现象——踩过的坑本身,就是你最牢固的知识。
下一篇,我们继续往 Linux 系统编程的深处走,去看看这些工具背后"进程、文件、内存"这些系统级的概念。准备好了吗?
还没有评论 — 第一条由你来留。