一、什么是 make/makefile
在我们以后的工作环境中,一个工程中的源文件不计数,其按类型、功能、模块分别放在若干个目录中;那么如何对这些源文件进行管理呢?比如哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译,甚至于进行一些更复杂的功能操作。
Linux 提供了项目自动化构建工具 -- makefile 来帮助我们解决这个问题;makefile 定义了一系列的规则来指定如何对众多的源文件进行管理;makefile带来的好处就是 -- "自动化编译",即 makefile 一旦写好,以后我们就只需要一个 make 命令,整个工程就可以完全自动编译,极大的提高了软件开发的效率。
在一个企业中,会不会写makefile,从一个侧面说明了一个人是否具备完成大型工程的能力。
什么是 make
make 是一个用来解释 makefile 中指令的命令工具,一般来说,大多数的 IDE 都有这个命令,比如:Delphi的 make,Visual C++的 nmake,Linux下 GNU 的 make;可见,makefile 已经成为了一种在工程方面的编译方法。
总结:make是一条命令,makefile是一个文件,二者搭配使用,实现项目自动化构建。
二、如何编写 makefile
编写 makefile,最重要的是编写 依赖关系和依赖方法;依赖关系是指一个文件依赖另外一个文件,即想要得到一个文件,目录下必须先有另外一个文件;依赖方法则是指如何根据依赖文件来得到目标文件。
在编写 makefile 时有几个需要注意的地方:
- makefile 的文件名必须是 makefile/Makefile,不能是其他名称,否则 make 识别不了;
- 依赖文件可以有多个,也可以没有;
- 依赖方法必须以 [Tab] 键开头,特别注意不能是四个空格;
- 下面我们一个C语言的例子来说明应如何编写 makefile:
下面我们一个C语言的例子来说明应如何编写 makefile:
test.c:
cpp
#include <stdio.h>
int main()
{
printf("hello makefile\n");
return 0;
}
要写makefile要先自己创建出一个makefile文件
test.c 对应的 makefile:
cpp
test.out:test.c #依赖关系
gcc test.c -o test.out #依赖方法
.PHONY:clean #伪目标
clean:
rm -f test.out
如上:test.out 依赖 test.c,依赖方法是 gcc 编译;clean 不依赖任何文件,依赖方法是 rm -f 指令;其中 .PHONY 修饰 clean 表示其是一个伪目标,总是被执行 (具体细节下文解释)。
三、make 的工作原理
3.1、make 的使用
在Linux下,我们输入 make 命令后,make 会在当前目录下找寻名为 "Makefile" 或 "makefile" 的文件;如果找到,它会把文件中的第一个目标文件作为最终的目标文件;如果找不到,就打印提示信息。
在上面的C语言例子中,makefile 中一共有两个目标文件 -- test.out 和 clean(这个我后面在伪目标那里细讲);如下,我们输入 make 它默认只会执行第一个目标文件;当然,我们也可以通过指定多个目标文件来让它形成多个目标文件;

3.2、make 的依赖性
依赖关系和依赖方法
- 依赖关系的是以:进行分隔的,冒号的左侧是目标文件,冒号的右侧是依赖文件列表,这个依赖文件列表中可以有多个依赖文件也可以没有依赖文件,即目标目标依赖于依赖文件才能够生成
- 依赖方法:依赖方法其实就是一行指令,以下面为例,即使用了gcc编译器去编译我们的源文件 test.c gcc编译器的使用
同时clean目标文件的依赖关系中不需要依赖文件,因为clean是对我们生成的临时文件或可执行文件进行清理删除,其不需要依赖文件,直接使用依赖方法中的 rm 指令直接对临时文件或可执行文件进行清理删除即可
关于 make 的依赖性,我们还是以上面这个例子来说明,只不过我们需要修改它的 makefile 文件:
cpp
test.out:test.o
gcc test.o -o test.out
test.o:test.s
gcc -c test.c -o test.o
test.s:test.i
gcc -S test.i -o test.s
test.i:test.c
gcc -E test.c -o test.i
.PHONY:clean
clean:
rm -f test.i test.s test.o test.out

我们知道,我们输入 make 命令后,make 会在当前目录下找寻名为 "Makefile" 或 "makefile" 的文件;如果找到,它会把文件中的第一个目标文件作为最终的目标文件 (上面例子中的 test.out),但是如果 test.out 所依赖的 test.o 文件不存在,那么 make 会在当前文件中找目标为 test.o 文件的依赖性,再根据该一个规则来生成 test.o 文件
如果 test.o 的依赖文件也不存在,则继续执行该规则,直到找到存在依赖文件的目标文件,得到目标文件后层层返回形成路径上的其他目标文件;或者最后被依赖的文件找不到,直接退出并报错;
这就是整个 make 的依赖性,make 会一层又一层地去找文件的依赖关系,直到最终编译出最开始我们需要的目标文件。
在上面的例子中,test.out 依赖的 test.o 不存在,make 会去寻找以 test.o 为目标文件的依赖关系;test.o 依赖的 test.s 也不存在,make 又会去找 以 test.s 为目标文件的依赖关系;然后 test.s 依赖 test.i,最后,test.i 的依赖文件 test.c 终于存在了,make 就会根据 test.i 的依赖方法形成 test.i,再逐步形成 test.s、test.o,直到最后形成 test.out。(类似于数据结构栈 -- 后进先出);

注意:这个是为了演示方便正常就可以了
cpp
gcc test.c -o test.out
3.3、项目清理
一个工程是需要清理的,在 makefile 中,我们常用 clean 来作为项目清理的目标文件,同时,由于项目清理不需要依赖其他文件,所以 clean 也不存在依赖关系。
另外,由于 clean 没有被第一个目标文件直接或间接关联,那么它后面所定义的命令将不会被自动执行,所以我们需要显示指定 -- make clean;

最后,像 clean 这种目标文件,我们一般都会用 .PHONY 将其设置为伪目标,伪目标的特性是:该目标文件可以多次被执行。
3.4、多次 make的问题
当我们对同一个源文件多次 make,我们会发现第一次程序正常编译,但第二次及以后就不再编译,而是不能在make了;

但是当我们把 test.c 中的内容修改过后,我们发现尽管可以再次 make 了,但是仍然不能多次 make:

实际上,上面这种现象是 make 为了防止我们对已经编译好且未做修改的源文件重复编译而浪费时间;也就是说,如果 test.c 已经编译得到了 test.out,并且我们并没有对 test.c 做改动,那么我们再次 make 时 make 不会被执行;实际上 make 这样做是很有必要的,因为在工作中,编译一个工程往往需要几十分钟甚至几个小时,如果我们 make 每次都重新编译,势必会浪费很多时间。
那么 make 是如何判断源程序不需要重新编译的呢?答案是根据文件的修改时间 (modify time) 来判定。
3.5、 .PHONY 的原理 三种时间
在Linux中,文件一共有三种时间:
- 访问时间 (Access):当我们查看文件内容后该时间改变,比如 cat、vim、less;
- 修改时间 (Modify):当我们修改文件内容后改时间改变,比如 nano、vim;
- 改动时间 (Change):当我们修改文件属性或权限后改时间改变,比如 nano/vim (文件大小改变),chmod/chown/chown (文件权限改变);

- 访问时间
实际上,我们访问文件内容并不一定会改变文件的访问时间,主要有以下两方面的原因:
1、在 Linux 下,访问文件内容的操作十分频繁,而修改文件的访问时间是需要对文件进行 IO 操作的,如果我们每次访问文件都修改文件的访问时间,会增大系统的负担;
2、一个文件是否能被读取是由文件的权限决定的,而既然该文件是可读的,那么说明文件的拥有者/所属组并不在意我们对文件进行读取,所以也没必要每次都修改文件的访问时间;
基于上面这两点,Linux 下并不会每次访问文件内容都更新件的访问时间,而是累积一定访问次数或者累积一段时间才更新:
- 修改时间和 改动时间


make 判断源文件是否需要重新编译只与源文件的修改时间变动有关,与源文件的内容改动无关,我们可以通过 touch 命令来验证:(touch file :如果 file 已存在,则更新 file 的所有时间)

在了解了 make 是如何判断是否要重新执行依赖方法形成目标文件之后,.PHONY 的原理和作用也显而易见了 -- 被 .PHONY 修饰的目标文件不根据文件的修改时间先后来判断是否需要重新执行,从而达到总是被执行的效果

我们也可以使用 .PHONY 来修饰 test.out,使得 test.out 每次都被重新编译:

3.6、 特殊符号 @和^
特殊符号@代表依赖关系中的冒号前面的目标文件,
^代表的是依赖关系中冒号后面的依赖文件列表中的依赖文件。
在进行编写的时候,我们可以使用这两个特殊符号来进行一定的替代,所达成的效果和普通编写方式并无不同
四、版本控制器Git 前言
不知道你工作或学习时,有没有遇到这样的情况:我们在编写各种文档时,为了防止文档丢失,更改失误,失误后能恢复到原来的版本,不得不复制出一个副本,比如:
...
"报告-v1"
"报告-v2"
"报告-v3"
"报告-确定版"
"报告-最终版"
"报告-究极进化版"
...
每个版本有各自的内容,但最终会只有一份报告需要被我们使用 。
但在此之前的工作都需要这些不同版本的报告,于是每次都是复制粘贴副本,产出的文件就越来越多,文件多不是问题,问题是:随着版本数量的不断增多,你还记得这些版本各自都是修改了什么吗?
文档如此,我们写的项目代码,也是存在这个问题的!!
五、Git使用
5.1、Git的准备工作
Linux下输入
bash
sudo yum install git -y

5.2、在Gitee / Github上创建项目
登陆成功后, 进入个人主页, 点击右上角的 新建仓库 按钮新建仓库

然后跳转到的新页面中输入仓库名称(注意, 名称不能重复, 系统会自动校验. 校验过程可能会花费几秒钟). 校验完毕后, 根据提示填写信息,初始化仓库后,确认创建.
在创建好的项目页面中复制项目的链接, 以备接下来进行下载.
将远端目录拷贝到本地(采用刚刚复制的链接)
此时我们会发现文件中多了Linux,打开,会发现其内容就是在Gitee中的内容


5.3、上传
配置个人信息
bash
git config --global user.email "你的邮箱@example.com"
git config --global user.name "你的用户名"
- git add
将代码放到刚才下载好的目录中
bash
git add [文件名]
#添加指定文件
git add .
#所有未添加的文件
- git commit -m " " 命令
将提交改动的信息到本地目录,将暂存区的文件提交到本地
git commit - m "xxx" (xxxx表示你改动的信息或者备注)
提交的时候应该注明提交日志,描述改动的详细内容
bash
git commit -m "XXX" #添加描述
- git push 命令
把本地仓库推送到远端仓库
bash
git push
5.4、其他命令
| 命令 | 作用 | 常用参数/示例 | 适用场景 |
|---|---|---|---|
| git pull | 拉取远程更新并合并本地分支 | git pull origin main | 协作开发时同步他人代码 |
| git fetch | 仅下载远程更新不自动合并 | git fetch --all | 查看远程变化后再决定是否合并 |
| git log | 查看提交历史 | git log --oneline --graph -n 5 | 回溯代码修改记录 |
| git reflog | 查看所有操作记录 | git reflog show --date=iso | 恢复误删的分支/提交 |
| git stash | 临时保存工作区修改 | git stash push -m "备注" git stash pop | 紧急切换分支时保留未完成的工作 |
| git status | 查看工作区状态 | git status -s (简洁模式) | 快速确认修改/暂存文件 |
| git diff | 查看文件修改内容 | git diff HEAD~1 (与上一版本对比) | 代码审查或确认变更 |
| git reset | 版本回退 | git reset --hard HEAD~1 (彻底回退) git reset --soft (保留修改) | 撤销提交或合并 |
| git branch | 分支管理 | git branch -a (查看所有分支) git branch -d feat (删除分支) | 功能开发/修复Bug |
| git checkout | 切换分支或恢复文件 | git checkout -b new-feat (创建并切换) git checkout -- file.txt (丢弃修改) | 多任务并行开发 |
| git cherry-pick | 选择性合并提交 | git cherry-pick | 移植特定提交到其他分支 |
| git rebase | 变基操作(整理提交历史) | git rebase -i HEAD~3 (交互式变基) | 合并提交记录保持线性历史 |
六、Linux第一个小程序 - 进度条
6.1、\r && \n
对于 '\n' 想必大家已经很熟悉了,因为在C语言的 printf 函数中我们会频繁的用到它,但是实际上我们C语言学习的 '\n' 是 '\r' + 'n';
- '\r':回车,即将光标移动到当前行的行首;
- '\n':换行,即将光标移动到下一行;
可以看到,我们C语言中的 '\n' 的作用是 回车 + 换行,而不仅仅是换行,这也是为什么许多台式机的 enter 键是下面这样的:

6.2、行缓冲
在C语言 getchar 函数的正确使用 中我们就已经知道 -- 我们从键盘输入的字符以及向显示器输出的内容,并不会直接读入或输出,而是会先被存放到输入缓冲区与输出缓冲区中,待缓冲区刷新时数据才会才会被读入或输出;
而行缓冲是缓冲区类型的一种,在行缓冲下,当 在输入和输出中遇到换行符时,才执行真正的I/O操作;即我们输入的字符会先存放在缓冲区,等按下回车键时才进行真正的I/O操作。
我们可以用两份不同的代码来验证上述结论的正确性
test1.c
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("测试行缓冲\n");
sleep(5);
return 0;
}
test2.c
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("测试行缓冲");
sleep(5);
return 0;
}



我们知道c语言程序的执行是从上往下开始进行执行的但是可以看到,test2.c 的数据 printf 后并没有直接显示到终端上,而是待程序结束缓冲区刷新后才显示;而 test1.c 中的数据由于 '\n' 可以刷新行缓冲,所以直接显示到了终端。
那么我们有没有办法可以强制刷新缓存区的内容呢?
有的,可以使用fflush函数强制刷新缓冲区的内容,fflush函数需要传参对应的流,流分为标准输入流(stdin),标准输出流(stdout),标准错误流
由于是将hello刷新输出到我们的屏幕上,所以需要传入标准输出流(stdout)即 stdout 作为fflush的参数

使用gcc进行编译链接为可执行程序之后,运行之后由于c语言程序是逐语句进行执行,hello被存储在了缓冲区,接着fflush函数会立即刷新缓冲区,那么缓冲区的内容hello就被刷新出来并立即显示到了屏幕上
6.3、进度条
有了回车换行和行缓冲的概念之后,我们就可以编写我们的进度条代码了;
进度条的英文是progressbar,这里给文件起名要对应,我们建立两个源文件progress.c文件用于编写进度条函数的实现,main.c文件用于编写进度条函数和基本框架逻辑,一个progress.h用于编写头文件和函数声明和宏定义
同时为了便于使用make/makefile的自动化构建,我们还要创建一个makefile文件用于配置编译和清理工作
- makefile
cpp
1 progress:main.c progress.c
2 gcc main.c progress.c -o progress
3 .PHONY:clean
4 clean:
5 rm -f progress

- progress .h
#pragma once是条件编译,防止头文件被重复展开
要使用到memset函数其对应的头文件是#include <string.h>,要使用到usleep函数其对应的头文件是#include <unistd.h>,这里的usleep的单位是微秒,我们可以更为精细的控制程序执行完成的总时间,1秒等于1000毫秒,1毫秒等于1000微秒,所以1秒等于1000000微秒,我们在调用进度条函数的时候要传入每次打印的时间,一般是设置为50000微秒,程序总执行100次,那么就是5000000微秒,即程序被我们设定为了5秒跑完
进度条的身体'#'这里我们将其设置为宏,便于修改
函数声明前也可不加extern,但是全局变量的声明必须加extren

- main.c
main.c文件中包含着调用进度条函数,是整个程序的基本框架的主逻辑。

- progress.c
-
我们要实现的是一个带有进度条并且带有百分之多少的进度并且带有一个类似于加载旋转的三个功能的程序,我们使用[ ]进行分隔这三个功能
-
进度条我们可以使用字符数组来进行模拟,从0个字符到100个字符,并且打印我们使用printf打印字符串%s的打印方式,遇到'\0'打印字符串才会停止,由于我们要展示100个字符,在第100个字符的后面我们还应该放一个'\0',用于终止字符串的打印,所以我们的数组中要先开101个空间并且使用memset函数全部初始化为'\0',但是实际上要开102个空间,具体原理请继续往下阅读
-
我们的过程由于是要向屏幕上打印100个'-'用于表示进度加载完成,最开始为0个,那么就有101次循环,我们定义一个变量为0,当它小于等于100的时候执行代码逻辑,那么这样就可以实现出有101次循环。
-
我们的模拟加载旋转的字符这里就采用|/-\进行模拟,其中\是特殊的转义字符,我们要使用双\才能进行显示,所以这里的lable指针指向的字符串虽然我使用了五个字符,但是实际上表示完之后是4个字符,我们要进行根据cnt的变化来对应完成对字符|/-\的按顺序打印,那么接下来我们使用cnt%4,那么不就会随着cnt的++,进而逐个产生0,1,2,3之间的数字吗,我们使用label[cnt%4],不就正好可以去模拟加载旋转的字符了
-
剩下的细节注意好每次输出字符串的时候由于没有'\n'的存在,缓冲区不会被刷新,使用fflush强制刷新缓冲区
-
注意好使用usleep在每次刷新完缓冲区将字符串显示在屏幕上之后进行休眠50000微秒即可
-
最后为了我们的进度条在经过回车之后,在程序结束之后,bash命令行要进行显示,那么就会把我们的进度条给覆盖,为了进度条不被覆盖,我们人为加一个换行即可
-
printf("[%-100s][%d%%][%c]\r",str,cnt,lable[cnt%len]),进行打印编写的时候一定要注意将\r放在最后面,\r会默认将第一个%d输出的位置对齐屏幕的起始位置,但是这里的起始位置由于我们放置了[,所以对齐到[的后面,只有这样才可以正常执行,并且由于%也是一个特殊符号我们输入一个%无法将其正常显示在屏幕上,那么我们需要输入两个%%才可以在屏幕上显示一个%,有人好奇,这里你为什么不使用转义字符'\'呢,因为这个转义字符的使用在linux中可能会出现报错,并且在linux中在屏幕上显示一个特殊字符%主流的写法就是使用两个%%进行编写。
-
同时例如在下载应用的时候想一下进度条通常都是有预留空间的,由于我们的最终是想要在屏幕上打印100个字符'-'作为进度条的最终结束状态,所以这里我们使用[%100s]的方式进行在屏幕上占位100个字符,并且使用[ ]的中间作为我们输出进度条的空间,同时由于占位之后编译器默认是右对齐,不符合我们的阅读习惯,所以我们修改一下默认对齐方式修改成左对齐即[%-100s]即可
