
写Linux系统编程相关的项目绕不开两样东西一是把多个源文件变成可执行文件的构建过程二是把程序运行状态直观呈现出来的输出技巧。前者最常见的工具就是makefile后者则可以用那个经典到几乎人人写过的“进度条”来演示——一行printf、一个回车符配合定时刷新就能在终端里做出动态效果。这篇笔记我打算把这两个主题放在一起讲因为它们在《Linux系统编程》的“基础开发工具”章节里本来就前后脚出现而且放到同一个实践里效果很好用makefile管理进度条项目的编译进度条项目反过来验证你对缓冲区、回车符和终端控制的理解。无论你是刚接触Linux的C语言新手还是准备系统梳理一遍工具链的开发者这篇内容都可以直接拿去做练习。1. makefile解决的问题不只是“免敲命令”1.1 从手动编译到make为什么要引入构建系统先还原一个很常见的场景。你刚开始写C程序时只有一个main.c编译就是一条命令gcc -o app main.c这没什么问题。但学到多文件编程之后情况开始变味。假设项目里有main.c、add.c、sub.c和几个头文件手动编译就变成了gcc -o app main.c add.c sub.c如果再加-Wall、-g、-lm这些选项命令行会越来越长。更麻烦的是你改了main.c之后add.c和sub.c其实没动过但上面这条命令会把它们也重新编译一遍。项目小的时候无所谓等文件到了几十个、上百个每次全量编译的耗时就是纯粹的浪费。有人会说那我写个shell脚本把这些命令包起来不就行了可以脚本确实解决了“敲很长命令”的问题但它依然每次全量编译。这时候makefile的价值就出来了它要解决的核心问题不是“少敲命令”而是增量构建——谁变了就重新生成谁没变的跳过。我说个真实感受第一次在一个只有6个C文件的项目里用到makefile因为改了其中一个文件剩下的5个编译器理都没理那种感觉就像从手动挡换成了自动挡。这个体验不是在省几秒钟而是让你敢于频繁改动代码不必担心一次编译要等半天。1.2 make的增量构建逻辑和那个经典报错为了理解makefile先理解make这个程序的工作方式。make本质上是一个“任务执行器”它读取makefile里声明的一系列规则然后根据规则判断该做什么。规则的基本形式是目标: 依赖 命令看到这个结构你把make想象成一个管家目标是要生成的东西依赖是生成它所需要的东西命令是生成它的动作。管家每次干活前都会比较时间戳如果依赖比目标新说明依赖最近被改过了那目标就该重新生成如果目标已经存在而且比所有依赖都新说明无事发生命令就不执行。进入Linux命令行如果你直接输入make却不加任何参数它会默认在当前目录下寻找名为GNUmakefile、makefile或Makefile的文件。三个名字优先级依次递减惯例上大家用Makefile。如果这个文件不存在而且你也没给make指定目标就会看到那个劝退无数新手的报错make: *** No targets specified and no makefile found. Stop.这句英文拆开来看就是没有指定目标也没有找到makefile文件。解决方案通常有两种一是确认当前目录到底有没有makefile用ls看一眼二是如果文件名比较特殊可以用-f参数指定make -f build.mk我这边实际工作中也经常遇到另一种变体makefile存在但你在命令行里给出的目标名在文件里根本找不到。比如makefile里只有all和clean两个目标你手滑输了个make build它就会报make: *** No rule to make target build. Stop.这时候别急着怀疑环境先去makefile里搜一下这个目标是否存在。这两个报错几乎占了make新手报错的半壁江山原因都不是语法问题而是“make找不到它该做的事”理解这一点排查方向就对了。2. makefile语法四件事目标、依赖、规则、伪目标2.1 最小可用的makefile长什么样makefile的语法说穿了就四个要素目标、依赖、规则和伪目标。目标是你想生成的东西依赖是前置条件规则是生成动作伪目标是“不生成文件只执行动作”的特殊目标。拿之前提到的最简单项目举例。main.c里调用add函数// main.c #include stdio.h int add(int, int); int main() { printf(%d\n, add(3, 4)); return 0; }add.c里是函数实现// add.c int add(int a, int b) { return a b; }最粗暴的makefile可以写成这样app: main.c add.c gcc -o app main.c add.c第一行说“我想生成app它依赖main.c和add.c”第二行说“生成app的命令是gcc这一条”。注意第二行前面的不是四个空格也不是八个空格而是一个Tab字符。这个Tab非常重要没有它make会直接报错这一点我后面专门讲。编译方式也直观直接执行make它会看到app不存在于是执行后面的gcc命令。再执行一次make它比较时间戳后发现app比main.c和add.c都新就会输出一句make: app is up to date.这个机制能跑但还谈不上好。问题在于一旦main.c里追加了头文件依赖或者gcc命令需要调整这种“整个目标一把梭”的方式会让增量构建失效——只要有一个依赖变了整个app就得重新编译所有源文件都重新编译一次这和手动gcc的差距并不大。2.2 依赖链和时间戳make的聪明之处更好的写法是拆开编译先生成目标文件再链接app: main.o add.o gcc -o app main.o add.o main.o: main.c gcc -c main.c add.o: add.c gcc -c add.c这次make会从app这个目标出发发现app依赖main.o和add.o而main.o依赖main.cadd.o依赖add.c。如果你修改的是main.cmake会判断main.o过期了重新生成main.o然后发现add.o没变于是跳过add.o的编译最后重新链接app。整个过程它只编译了一个文件。这就是增量构建最直观的体现。让我把时间戳的判断逻辑说得再细一点。make比较的不是“内容”而是修改时间。如果main.c的时间戳比main.o新就执行生成main.o的命令如果main.o比main.c旧同样执行。极端情况你改了main.c之后又用手动touch命令把main.o的时间戳改得比main.c还新那make会认为main.o不需要重新生成——尽管它的内容早就过期了。所以某些诡异的“代码改了半天却不重新编译”问题第一反应应该去查时间戳用stat命令看文件的修改时间。依赖关系没写全是大项目里最隐蔽的问题。比如main.c里其实还包含了一个头文件add.h但makefile里只写了main.o: main.c结果你修改add.h后make根本不知道main.o需要重建。这种问题不报错只是行为不对最难排查。避免的方法除了人工认真写依赖还可以用编译器的自动生成依赖功能比如gcc -MM main.c会输出main.o对头文件的实际依赖链再配合include指令手动合并到makefile里这个玩法需要额外功夫但很值得一试。2.3 伪目标为什么clean要声明成.PHONY接下来说clean。几乎每个makefile里都有这样一段clean: rm -f *.o app你执行make clean它会删除所有.o文件和应用本体。但这里埋着一个坑如果在当前目录下恰好有一个名叫clean的文件make会认为目标clean已经存在而且clean没有依赖时间戳上也没什么好比较的结果就是它什么都不会执行直接告诉你“clean已是最新”。解决方案是把它声明为伪目标.PHONY: clean clean: rm -f *.o app.PHONY的意思就是告诉make这个目标不代表真实文件请不要用时间戳去判断它是否需要执行每次都老老实实地把规则跑一遍。除了cleanall、install、test这些常规动作目标都应该声明为伪目标。还有一个默认目标的细节当你在命令行只输入make时make会把makefile里第一个目标当作终极目标。一般项目都会习惯性地把最终可执行文件放在第一个位置或者用all作为第一个目标all: app这样make等价于make all语义更明确。3. 变量与自动化变量让makefile扛得住真实项目3.1 定义变量把编译选项集中管理语法层面熟悉之后再来看真实项目里makefile怎么写得让维护的人舒服。核心手段就是变量。makefile里的变量和C语言里的宏有点像定义的时候赋值使用的时候用$(变量名)展开。CC gcc CFLAGS -Wall -Wextra -g TARGET app SRCS main.c add.c OBJS $(SRCS:.c.o) $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $^ %.o: %.c $(CC) $(CFLAGS) -c $ -o $ .PHONY: clean clean: rm -f $(TARGET) $(OBJS)这段代码里CC是编译器CFLAGS是编译选项TARGET是最终产物名SRCS列源码OBJS通过$(SRCS:.c.o)做了一个后缀替换把main.c变成main.o、add.c变成add.o。这样做的好处是以后想加编译选项只改CFLAGS一处想加文件只改SRCS一行想换调试模式-g拿掉就行不用满文件去翻gcc命令。从最上面那个一把梭的版本到这个版本改动的不只是“美观”而是可维护性。你写的makefile不是给自己看一遍就完事的它和代码一样需要被后续修改集中管理的变量能让后续改动变得极其安全。3.2 自动化变量$、$^、$怎么记上面那段makefile里出现了几个看起来很神秘的符号$、$^、$。它们是make内置的“自动化变量”含义固定记起来有规律。变量含义记忆线索$当前规则的目标名可以看成Target的T$^当前规则所有依赖^像不把所有依赖都“罩进来”$当前规则第一个依赖代表最左边那一个$?比目标新的所有依赖列表问号表示“谁变了”拿两条规则举例。链接那条规则里目标是$(TARGET)也就是app依赖是$(OBJS)所以$就是app$^就是main.o和add.o命令展开后等价于gcc -Wall -Wextra -g -o app main.o add.o。编译规则里对于main.o这条$就是main.c$就是main.o展开后等价于gcc -Wall -Wextra -g -c main.c -o main.o。一开始记不住很正常的我大概写了十多个makefile之后才做到不查。如果你觉得容易混就记住一条写规则的命令时优先用自动化变量代替显式文件名这样规则才能“通用”。尤其是模式规则离开自动化变量根本没法写。3.3 模式规则和函数把重复规则收敛掉上面makefile里的%.o: %.c就是模式规则。%是通配符代表任意前缀。这一条规则意思是任何一个.o文件都由对应的.c文件生成。这让makefile摆脱了“给每个c文件手写一条编译规则”的重复劳动对文件数量不敏感。配合模式规则makefile里还会经常用到几个函数。函数调用语法是$(函数名 参数)常用三个SRCS : $(wildcard src/*.c) OBJS : $(patsubst src/%.c, build/%.o, $(SRCS))wildcard用来获取目录下匹配的文件列表比如把src目录下所有.c文件全部拉进来这样SRCS就不用手动维护了。patsubst做模式替换把src/xxx.c替换成build/xxx.o。notdir去掉路径前缀通常配合patsubst用。不过有一点要注意wildcard和patsubst是在make解析阶段就展开的如果指望“每次执行时动态扫描目录”那还需要配合$(shell ...)和变量重新赋值。基础阶段只要会用wildcard和patsubst再配合模式规则已经足以应付绝大多数中小项目了。3.4 顺便说清makefile、cmake、ninja的关系网上高频的对比问题就是“makefile和cmake到底学哪个”。这里说清楚makefile是make这个工具的输入脚本由你直接编写cmake是一个“生成构建脚本的工具”它自己不编译而是根据CMakeLists.txt配置生成makefile、ninja文件或IDE工程文件ninja则是另一个构建引擎目标就是快很多大型项目用cmake生成ninja格式的构建规则。三者不是同一层的东西。cmake是更高层的项目管理语言makefile是具体的构建规则ninja是替代make的执行引擎。学习顺序上我建议先把makefile搞明白因为它最贴近编译过程对目标、依赖、时间戳这些概念的理解会成为你后面看任何构建系统的底子。底子打好了再看cmake里那些target、dependency的概念会觉得很熟悉。4. 进度条项目前置课缓冲区、回车符和终端控制4.1 printf到底把数据写到哪去了进度条这个项目人人见过但自己用C写出来会卡的第一关不是逻辑而是“为什么printf明明调了屏幕上却没反应”。这就要说到缓冲区的机制。printf是C标准库函数它把数据先写进stdio缓冲区再由缓冲区统一交给操作系统写入终端。缓冲区有三种策略无缓冲数据立刻输出stderr默认就是这种。行缓冲遇到换行符\n才刷新缓冲区stdout在终端环境下默认是这种。全缓冲缓冲区满了才刷新或者程序正常退出时刷新stdout在重定向到文件时默认是这种。所以当你向终端打印字符串却不在末尾加\n时内容很可能一直压在缓冲区里不出来要等程序结束才一次性刷出来。这对一行普通打印可能无所谓但对进度条这种需要“每一帧都刷新”的场景就是致命的。强制刷新缓冲区的方法就是fflush(stdout)。它告诉stdio别等了立刻把当前缓冲区内容交出去。进度条每画一帧都调用一次fflush(stdout)这是整个项目最关键的一行调用。4.2 \r为什么是进度条的灵魂先对比两个字符\n换行把光标移动到下一行。\r回车把光标移动到当前行的行首不换行。这两个概念源自打字机时代在今天的终端里经常被混用很多平台甚至把\n的实现处理成了“换行并回到行首”但在Linux终端里严格控制进度条显示位置时\r才是主角。动态进度条的原理其实极朴素输出一行内容把光标移回行首再输出一行内容覆盖掉刚才那行。整个过程就是“打印 → \r → 打印 → \r”不断循环。因为终端行内容被整体覆盖人眼看起来就像同一个位置在刷新。如果这里用了\n每打印一帧就会新起一行终端会刷出一大屏历史记录而不是一个单独的进度条。顺带一提在C语言里\r和\n的ASCII码分别是13和10可以写个程序同时打印两个值验证。这个知识在后续处理文本协议时也会用到比如行末既有\n又有\r的Windows文本文件在Linux下用工具查看时会带上一个^M本质原因就是两个回车换行字符混在一起。4.3 ANSI颜色和光标控制让进度条好看一点进度条除了动态刷新还能做颜色和光标控制这属于终端控制码的范畴。Linux终端支持ANSI转义序列形态是一串以\033[开头的字符。比如printf(\033[1;32m绿色加粗文本\033[0m\n);\033[1;32m表示开启样式1是加粗32是绿色之后\033[0m恢复默认。背景色在38之后加比如\033[1;32;44m是绿字蓝底。常用颜色码里30到37对应黑红绿黄蓝紫青白背景色是40到47。光标控制也是两组printf(\033[?25l); // 隐藏光标 printf(\033[?25h); // 显示光标进度条在刷新时如果光标一直闪烁会很难看运行前隐藏光标结束前恢复效果会专业很多。还有一个\033[2J清屏和\033[H光标归位用在整屏刷新场景。注意这些转义序列能否正常显示取决于终端类型和输出环境。如果程序输出被重定向到文件或者被管道接走控制码不会生效反而会变成一堆乱码字符。这个坑后面会专门讲。5. 两版进度条代码从静态到动态从能跑到好看5.1 第一版静态打印的原始形态先写一个最原始的“进度条”不做动态刷新就是把50个#一次性打印出来#include stdio.h #include string.h int main() { char bar[51]; memset(bar, #, 50); bar[50] \0; printf([%s]\n, bar); return 0; }输出是[##################################################]。这一版逻辑上没问题但它没有任何“过程感”而且memset一次填满了所有格子根本没有阶段变化。这个版本的价值是让代码结构清晰进度条的本质就是一个字符数组数组里#的个数代表进度。5.2 第二版动态刷新 百分比 旋转光标动态版本要把“填格子”改成“循环填格子”并在每一轮末尾回行首、刷新缓冲区、等待一小段时间#include stdio.h #include string.h #include unistd.h int main() { const char* flash |/-\\; char bar[51]; memset(bar, \0, sizeof(bar)); int i; for (i 0; i 50; i) { bar[i] #; printf([%-50s][%3d%%][%c]\r, bar, (i 1) * 2, flash[i % 4]); fflush(stdout); usleep(50000); } printf(\n); return 0; }这个版本的几个关键点拆开说。[%-50s]里的-表示左对齐50表示宽度。bar的实际字符个数从0到50不断变化如果没有固定宽度右边的]就会跟着内容从左往右跑看起来前后乱跳。固定成50的宽度后右括号始终停在屏幕同一列视觉稳定。%3d是右对齐宽度3打印百分比时可以保证个位百分数、两位百分数都占同样位置。注意%%在printf格式串里代表一个真正的百分号。flash数组存了四个字符竖线、斜杠、横杠、反斜杠。通过i % 4循环取用加上\r回车每帧重新画同一个位置时这个“小棍”看起来就在原地转动。这是终端动画里的经典小技巧很多加载动画用的都是同一招。usleep(50000)让每帧停顿5万微秒也就是0.05秒50格跑完大约2.5秒。如果想让进度条在视觉上更从容可以调到100000也就是0.1秒整个过程5秒刚好适合演示。编译并运行后看到一个进度条从左到右增长百分比从2跳到100左侧小棍一直在转。到这里一个标准动态进度条已经完成了。5.3 加颜色、隐藏光标再配一个makefile把它管起来最后的版本做成稍有观赏性的成品进度条主体绿色头部带一个箭头运行期间隐藏光标结束恢复。为了衬托makefile的价值我把这个程序拆成proc.h、proc.c、main.c三个文件来写。proc.h的内容很简单#pragma once void process();proc.c实现具体逻辑#include proc.h #include stdio.h #include string.h #include unistd.h #define BODY #define HEAD void process() { const char* flash |/-\\; char bar[51]; memset(bar, \0, sizeof(bar)); printf(\033[?25l); int i; for (i 0; i 50; i) { bar[i] BODY; if (i 1 50) { bar[i 1] HEAD; } printf(\033[1;32m[%-50s]\033[0m[%3d%%][%c]\r, bar, (i 1) * 2, flash[i % 4]); fflush(stdout); usleep(50000); } printf(\033[?25h); printf(\n); }这里每一轮先把当前位置填成再把下一个位置临时放成。下一轮开始时所在位置会被新的覆盖同时再往后放一个新的于是就有了一种“箭头推着进度条前进”的视觉感。最后一次循环时i 1 50不再放置箭头整行进度条就是50个等号表示完成。main.c只调用process函数#include proc.h int main() { process(); return 0; }makefile按照前面讲的变量、自动化变量、模式规则组织CC gcc CFLAGS -Wall -Wextra TARGET proc SRCS main.c proc.c OBJS $(SRCS:.c.o) $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $^ %.o: %.c $(CC) $(CFLAGS) -c $ -o $ .PHONY: clean clean: rm -f $(TARGET) $(OBJS)在目录下执行make ./proc终端里会看到一个绿色进度条从空到满带箭头带百分比带旋转光标整个刷新过程行数不增长光标在隐藏状态下不会闪。执行make clean后所有.o和应用都会被清掉再次make就能完整重建。到这里makefile的语法和进度条的实现被一个项目完整串起来了。6. 实战踩坑记录Tab、缓冲区和控制字符三座大山6.1 missing separator所有人都会遇到的第一道坎第一次写好makefile运行十有八九会撞上这个报错Makefile:5: *** missing separator. Stop.原因只有一个规则行的命令没有以Tab开头。make对缩进极其挑剔要求非常明确——必须是Tab不能用空格哪怕你用八个空格对齐也一样报错。这是很多文本编辑器默认把Tab自动转成空格后makefile立刻炸掉的原因。排查手段很直接用cat -A查看文件的隐藏字符cat -A Makefile正常文件里命令行的位置会显示成^I这是Tab在cat -A下的表现。如果你看到的是普通空格就说明编辑器Settings里的自动缩进把Tab吞了。解决方案是在编辑器里设置“插入Tab”而不是“缩进成空格”。vim用户执行:set noexpandtabVSCode用户找到“Editor: Insert Spaces”选项并关掉或者直接在设置里搜detectIndentation相关的项逐个排查。这个坑虽然小但几乎每次帮别人排查makefile都会遇到值得专门写一笔。另外插一句命令行里如果连目标名都拼错了报错信息是No rule to make target和missing separator是两种完全不同的性质前者是找不到目标后者是格式不对排查方向别搞混。6.2 进度条不刷新、花屏、重定向乱码的排查思路进度条的坑大多集中在“看不到动态效果”和“显示异常”上按下面的思路排查基本能定位。第一类现象程序跑完了一次性输出整个进度条而不是逐步增长。这几乎可以断定是忘了fflush(stdout)或者把fflush放错了位置。stdio缓冲区的行缓冲策略只在终端环境生效遇到管道、重定向时会变成全缓冲进度条没有换行、缓冲区又没满内容就会一直攒着直到程序退出。所以每一帧输出后都跟上fflush(stdout)是铁律。第二类现象进度条本来好好的但用./proc log.txt重定向到文件后TXT里出现一堆^[[1;32m之类的字符。这是ANSI控制码在非终端环境下的表现因为控制码对普通文件没有意义只会作为文本原样记录。解决办法是判断当前输出是否为终端标准库的isatty可以胜任#include unistd.h int is_term isatty(fileno(stdout)); if (is_term) { printf(\033[?25l); }这是很多成熟命令行工具里真实在用的手段因为用户可能把输出重定向到文件、管道给其他程序、或者直接丢进日志系统控制码只会污染数据必须靠isatty做区分。第三类现象进度条运行后终端处于“没有光标”的状态。这通常是程序在隐藏光标后异常退出没有机会执行恢复光标那行代码。处理方式也简单直接在终端输入reset回车终端状态会完全恢复。如果程序更健壮也可以在退出信号里统一恢复但作为演示项目记住reset这个救命指令就够了。6.3 依赖没写全增量构建最隐蔽的坑最后说一个makefile里的隐蔽问题它不是报错而是“行为错误”。假设你写的是main.o: main.c gcc -c main.c而main.c里实际包含了add.h。某天你改了add.h里的宏定义再执行make它会判定main.o与main.c相比没有变化跳过编译直接链接旧的目标文件。结果程序行为完全没变你以为改动没生效折腾半天才反应过来是make没感知到头文件变更。解决的标准做法是在依赖里补齐头文件main.o: main.c add.h gcc -c main.c文件数量少的时候人工维护没问题文件多了以后更优雅的方案是用编译器的-MM选项生成依赖关系再通过make的include指令拉进当前makefilegcc -MM main.c add.c它会输出类似main.o: main.c add.h的内容这相当于让编译器自己承认“我最终依赖了哪些头文件”比人肉记忆可靠得多。无论是makefile还是CMake依赖关系残缺都是构建系统里最阴险的一种错误因为它不会让编译失败只会让结果变成“旧版本”。排查思路上凡是你觉得“改了代码但没生效”的诡异问题先去查时间戳、查依赖而不是重新编译一遍试试。最后再分享一点体会这个进度条项目看起来小实际把C程序里几个容易出问题的点全串起来了stdio缓冲区、终端字符控制、回车换行语义以及一套完整的makefile构建流程。我建议拿到代码后别急着跑完就算动手改几个参数把宽度从50改成80百分比公式自己重算一遍把颜色从绿色改成红色把旋转小棍换成水滴字符再把每帧停顿时间调成不等的值观察最终效果。改完一遍你对进度条的掌控就不再是“抄代码”而是“会修代码”。如果makefile哪个细节卡住了我的个人习惯是先用make -n干跑看一眼make准备执行什么命令再用cat -A检查Tab基本能解决九成问题。想往深了挖GNU make官方手册和陈皓写的《跟我一起写Makefile》是两份绕不开的好资料。不过建议别看太厚先把这个进度条玩熟再决定要不要系统啃。