
在开发机和工作流里泡久了你会发现“Makefile Python脚本”这对组合其实比很多花哨的框架都来得实在。不管你是做嵌入式、后端、数据分析还是搞自动化测试只要手上有重复性操作就大概率用过Makefile来统一入口再用Python补足那些Shell写起来肉疼的逻辑。这篇文章就是我的随手记不系统、不平滑但保证是从实际项目里趟出来的经验适合需要快速搭一套可维护自动化流程的人参考。先说清楚这东西能解决什么问题Makefile负责“编排”告诉你什么时候该跑什么、哪些步骤能跳过Python负责“干活”处理复杂的数据流、调用各种库、写清晰可维护的业务逻辑。两者一结合你就能用一个make xxx命令替代一长串手动操作并且每次执行的结果都可预期、可重跑。下文我会从组合的适用场景讲起再拆Makefile的核心语法和常见坑接着聊Python脚本的实操要点最后给一个完整可抄的例子和一份问题排查清单。1. 为什么是“Makefile Python”这个组合1.1 老工具为什么还没过时很多人一听到Makefile就想到C/C编译觉得这是上个世纪的东西。但Makefile本质上是个“任务编排器”它的核心能力是描述文件之间的依赖关系并且按需执行。只要你的工作流里有多个步骤、步骤之间有先后依赖用Makefile来声明这种关系就非常自然。举个例子一个常规的数据处理流程可能是拉取数据 - 清洗 - 分析 - 出报告。每一步都要跑Python脚本而且数据更新后你只想重跑后面的步骤不想从头再来。Makefile天然支持这种逻辑每个目标对应一个产物文件如果产物文件已经存在并且比依赖新就直接跳过。这个“增量执行”的能力是很多脚本框架要写半天才能模拟出来的。Python在这里的角色则是“真正的工人”。Shell脚本写多了你会发现字符串处理还能忍一旦涉及JSON解析、Excel操作、HTTP请求、并发控制Shell就会变得极度痛苦。Python的库生态把这些变成了几行代码的事。而且Python脚本本身更清晰、更容易做单元测试团队成员接手成本也低。所以这套组合本质上是用Makefile做“骨架”用Python做“肌肉”。骨架决定动作的顺序和条件肌肉负责具体的力量输出。脱离具体场景去争论谁更厉害没有意义重点是它们合在一起能覆盖从编译到数据处理的绝大多数自动化需求。1.2 什么场景真正需要这套组合不是所有项目都需要MakefilePython。如果你只是偶尔手动跑一个python script.py那根本不用上升到构建工具层面。但下面这几类场景我强烈建议用这套组合来收拢多步骤、有固定顺序的任务链比如“初始化环境 - 拉取依赖 - 跑测试 - 打包产物”。需要增量更新的场景比如数据管道中原始数据变了只重跑受影响的部分。Makefile根据文件时间戳判断比你自己记状态靠谱得多。跨平台、跨机器执行的环境用Makefile统一命令入口新人不用学一堆内部命令。带清理和重建要求的构建流程比如设备老化测试脚本、固件编译流程make clean make all就是最经典的重来操作。我自己见过一个设备老化测试项目测试流程分阶段预热、加压、数据采集、结果汇总。每个阶段都是独立的Python脚本阶段之间还隔着时间等待和设备状态检查。最初负责人写了一个巨大的Bash脚本加上循环和条件判断改一次要半小时。后来重构为Makefile编排Python分阶段脚本每个阶段独立可跑、可单独重试整体可维护性上了一个台阶。这个例子说明项目的复杂度到了一定程度你就需要一个“调度层”Makefile正是这个调度层的轻量实现。2. Makefile核心知识拆解2.1 一次弄懂目标、依赖、规则Makefile最基础的三要素就是目标、依赖、规则。语法上是这样target: dependencies commands意思是如果目标文件不存在或者依赖文件比目标文件新就执行下面的命令来生成目标。我第一次接触时总觉得这东西玄乎其实用大白话说它就是一个“按需更新的菜谱”。有一段经典的入门示例output.txt: input.txt python process.py input.txt output.txt这里的逻辑是只要input.txt比output.txt新或者output.txt不存在就执行python process.py重新生成。如果你连续跑两次make output.txt第二次会提示“make: output.txt is up to date”什么都不做。这里有个新手最容易忽略的坑命令前必须是一个Tab字符不能是空格。很多人在IDE里用空格替代Tab然后收获一个missing separator错误。这个错误极其常见我甚至觉得每个写过Makefile的人都被它坑过至少一次。目标不一定是文件也可以是“伪目标”比如clean、test、install这类动作。伪目标需要用.PHONY声明否则Make会去找同名文件找不到就默认执行但存在同名文件时会直接跳过。常见的写法是.PHONY: all clean test all: test test: python -m pytest clean: rm -rf build dist *.pyc2.2 变量、通配符和自动变量Makefile的变量系统是它灵活的关键。基本语法是变量名 值使用时写$(变量名)。还有一种:赋值区别在于是递归展开:是立即展开。日常使用中我基本都用:因为它的行为更符合直觉不容易出现变量互相引用的意外。比如PYTHON : python3 VENV_DIR : .venv PIP : $(VENV_DIR)/bin/pip install: $(PYTHON) -m venv $(VENV_DIR) $(PIP) install -r requirements.txt自动变量是另一个高频使用的东西。几个最常用的$当前目标的名称。$第一个依赖文件。$^所有依赖文件列表。假设你要把src/下的多个.py文件打包成zip可以写出下面这种风格dist/package.zip: $(wildcard src/*.py) mkdir -p dist zip $ $^这里的$(wildcard src/*.py)是自动收集所有匹配的Python文件$是目标dist/package.zip$^是所有依赖。这样每次新增源文件不需要改Makefilewildcard会自动把新文件纳进来。2.3 Makefile和CMake怎么选热词里总有人问“cmake和makefile区别”简单说几句。CMake是生成构建系统文件的工具它本身不构建项目而是根据CMakeLists.txt生成Makefile在Unix系下或其他构建文件。也就是说CMake站在Makefile的上层解决的是跨平台和复杂依赖的问题。什么时候该手写Makefile什么时候该用CMake我的判断标准是如果你的项目只在本地跑、结构简单、或者主要任务是“调度Python脚本”那手写Makefile完全够用别引入CMake增加心智负担。但如果你做的是需要分发到多平台的C/C库或者项目涉及大量第三方依赖和编译选项直接用CMake它自动处理很多Makefile手写很难搞的细节比如头文件路径、平台差异等。举个热词里的例子“makefile 头文件路径 rv1106”这是嵌入式开发常见的问题。在Makefile里加头文件路径其实很简单就是在编译命令里加-I参数比如CFLAGS : -I./include -I./third_party/rv1106/include build: gcc $(CFLAGS) -o app main.c但如果你用的是同一个库的多版本、多工具链手写-I就会变得很痛苦此时CMake的find_package和target_include_directories确实是更省心的选择。所以不要把Makefile和CMake看成互斥选项它们解决的问题层级不同。3. Python脚本的实操要点3.1 什么时候用Python而不是Shell这是我在团队里被问得最多的问题之一。我的经验是命令的“编排”用Shell或Makefile没问题但“逻辑”尽量用Python。具体来说涉及下面这些情况别用Shell硬扛数据解析JSON、YAML、XML、CSV、Excel。Python有标准库和pandasShell处理这些简直是折磨。HTTP请求和API调用curl也能做但带重试、带鉴权、带动态参数拼接时Python的requests库明显更舒服。文件批量操作如果涉及正则替换文件名、按规则移动文件、处理编码问题Python的pathlib和re比一堆Shell管道清晰很多。异常处理要求高的场景比如设备老化测试中串口断连、超时重试这些用Shell写容易漏边界情况Python的try/except配合日志能处理得更周全。多线程/并发Python的concurrent.futures可以简单实现并发下载或者并发测试Shell想实现这个就要写得比较绕了。举个小例子遍历目录下所有*.log文件把包含“ERROR”的行汇总到一个报告里。用Shell写大概是一段awk和grep的管道组合看起来简洁但一旦要加时间过滤、按模块分类、输出成表格Shell代码就会成倍膨胀。Python这边只需要几行循环加一个列表清晰度和可扩展性完全不同。3.2 参数解析、环境与依赖管理Python脚本跑在自动化流程里参数解析是躲不开的。能用argparse就别手写sys.argv。argparse不仅帮你解析参数还自带帮助信息生成和参数校验团队协作时特别省事。一个标准的脚本入口我习惯这样写import argparse def parse_args(): parser argparse.ArgumentParser(description数据处理脚本) parser.add_argument(--input, requiredTrue, help输入文件路径) parser.add_argument(--output, defaultreport.csv, help输出文件路径) parser.add_argument(--verbose, actionstore_true, help显示详细信息) return parser.parse_args() if __name__ __main__: args parse_args() print(f处理 {args.input} - {args.output})环境管理上我强烈建议在Makefile里就把Python虚拟环境建好然后把所有Python操作都指向虚拟环境里的解释器。这样不会出现“我这能跑你那不能跑”的经典问题。在Makefile里我是这样处理的VENV : .venv PYTHON : $(VENV)/bin/python setup: python3 -m venv $(VENV) $(VENV)/bin/pip install --upgrade pip $(VENV)/bin/pip install -r requirements.txtWindows用户要注意虚拟环境下的Python路径是.venv\Scripts\python.exe所以如果团队里有跨平台需求最好在Makefile里做一次环境判断。Linux/macOS走binWindows走Scripts这个坑我见过不少次。3.3 脚本级别的容错与日志自动化流程里脚本中途挂了最让人头疼。为了减少排查时间我习惯在Python脚本里做三件事一统一log格式。用标准库logging而不是print。区别在于logging可以控制输出级别可以把日志同时写到控制台和文件可以用时间戳定位问题。一个简单的配置只需要几行import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(name)s: %(message)s, handlers[ logging.StreamHandler(), logging.FileHandler(pipeline.log) ] )二给关键步骤加状态标记。比如每个阶段完成时输出一行类似[OK] 清洗完成共处理1234条记录的日志这样配合Makefile的增量逻辑你能快速知道哪些步骤已经跑过、哪些需要重跑。三错误码的正确传递。Makefile会依据命令的退出码来判断成功与否。Python脚本里如果中途出现异常但没有正确退出Make会认为任务成功这会让后续步骤拿到损坏的产物。所以脚本结尾一定要有合理的退出逻辑import sys def main(): # 业务逻辑 return 0 if __name__ __main__: sys.exit(main())在异常处理里捕获到错误后打印日志并return 1这样Makefile就能感知失败停止后续链条。4. 一个能直接抄的MakefilePython项目4.1 项目背景与整体结构为了把上面的内容串起来我构建了一个实际可运行的示例项目一个“数据报告自动生成器”。它的任务是从原始日志文件里提取指标清洗后生成一张汇总报表最后把报表发给接口。这个场景浓缩了读取、处理、输出、集成这四类最典型的自动化需求。项目结构是这样pipeline/ ├── Makefile ├── requirements.txt ├── scripts/ │ ├── fetch_data.py # 拉取原始日志 │ ├── process_data.py # 清洗和聚合数据 │ └── generate_report.py # 生成报表并调用接口 └── data/ ├── raw/ # 原始日志 └── output/ # 生成产物用Makefile管理的好处是每一步都有明确的输入和输出增量执行自然生效。比如data/output/summary.csv依赖data/raw/*.log如果日志没变就不会重复跑清洗流程这在数据量大的场景下能省大量时间。4.2 Makefile的关键实现先看完整的Makefile.PHONY: all setup fetch process report clean VENV : .venv PYTHON : $(VENV)/bin/python PIP : $(VENV)/bin/pip all: report setup: python3 -m venv $(VENV) $(PIP) install --upgrade pip $(PIP) install -r requirements.txt data/raw/fetched.log: scripts/fetch_data.py $(PYTHON) scripts/fetch_data.py --output data/raw/fetched.log data/output/summary.csv: scripts/process_data.py data/raw/fetched.log $(PYTHON) scripts/process_data.py --input data/raw/fetched.log --output data/output/summary.csv report: data/output/summary.csv $(PYTHON) scripts/generate_report.py --input data/output/summary.csv clean: rm -rf $(VENV) data/raw/*.log data/output/*.csv关键点逐个说。all: report是默认入口。如果你直接敲make它等价于make all也就是最终只关心report这个目标。这是一个很实用的设计把最顶层的交付物作为最终目标其他东西都是它的依赖。setup负责初始化虚拟环境和安装依赖。它不算在依赖链里因为环境是“基础设施”不是“产物”。每次跑项目前手动make setup一次就行之后所有目标都依赖这个环境里的Python。data/raw/fetched.log这个目标有意思。它依赖的是scripts/fetch_data.py一旦脚本本身改动原始拉取结果就会被判断为过期需要重新执行。这是Makefile的经典思路把“生成逻辑”也作为依赖跟踪的一部分。data/output/summary.csv依赖原始日志和处理脚本逻辑很直白输入变了或者处理代码变了就重新生成。report目标没有文件产物它的作用是执行报表生成脚本然后把结果发送到内部接口。因为它没有对应文件需要靠.PHONY声明才能保证每次执行都会运行而不是被“已更新”的状态跳过。clean是重置用的。自动化项目最怕的就是状态残留make clean一把梭回初始状态是重跑时最稳妥的方式。4.3 Python脚本的关键实现fetch_data.py的核心职责是把散落各处的日志统一抓到一个文件里。为了演示我简化成从一个标准输入读取若干日志合并输出import argparse from pathlib import Path def main(): parser argparse.ArgumentParser() parser.add_argument(--output, requiredTrue) args parser.parse_args() log_sources [data/raw/device1.log, data/raw/device2.log] merged_lines [] for source in log_sources: path Path(source) if path.exists(): merged_lines.extend(path.read_text(encodingutf-8).splitlines()) Path(args.output).parent.mkdir(parentsTrue, exist_okTrue) Path(args.output).write_text(\n.join(merged_lines), encodingutf-8) print(f完成日志合并: {len(merged_lines)} 行) if __name__ __main__: main()process_data.py做核心清洗。比如统计每个设备的错误条数、平均响应时间并输出成CSVimport argparse import csv from collections import defaultdict from pathlib import Path def parse_line(line): # 假设日志格式: device_name,timestamp,level,message parts line.split(,) if len(parts) ! 4: return None return {device: parts[0], time: parts[1], level: parts[2], msg: parts[3]} def main(): parser argparse.ArgumentParser() parser.add_argument(--input, requiredTrue) parser.add_argument(--output, requiredTrue) args parser.parse_args() stats defaultdict(lambda: {error: 0, total: 0}) for line in Path(args.input).read_text(encodingutf-8).splitlines(): item parse_line(line) if item: stats[item[device]][total] 1 if item[level] ERROR: stats[item[device]][error] 1 Path(args.output).parent.mkdir(parentsTrue, exist_okTrue) with open(args.output, w, encodingutf-8, newline) as f: writer csv.writer(f) writer.writerow([device, total, error, error_rate]) for device, values in sorted(stats.items()): writer.writerow([device, values[total], values[error], f{values[error] / values[total]:.2%}]) print(f清洗完成写入 {args.output}) if __name__ __main__: main()generate_report.py读取CSV并生成摘要。在实际的“设备老化测试全自动执行脚本”场景里这一步往往是输出一份可读的测试报告或者推送通知。示例中我简化为读取和打印import argparse import csv from pathlib import Path def main(): parser argparse.ArgumentParser() parser.add_argument(--input, requiredTrue) args parser.parse_args() with open(args.input, encodingutf-8) as f: reader csv.DictReader(f) print(设备错误率报告) print() for row in reader: print(f{row[device]}: 错误率 {row[error_rate]} ({row[error]}/{row[total]})) if __name__ __main__: main()这套示例看似简单但够你观察Makefile和Python之间是怎么传参、怎么依赖的。你完全可以把其中的文件路径、处理逻辑替换成自己的业务代码骨架可以直接复用。5. 常见问题与排查技巧实录5.1 问题速查表下面这些是我在实战和帮同事排查时遇到频率最高的问题列成表格方便你直接对照。错误现象根本原因解决思路make: Nothing to be done for allall目标没有依赖或者依赖全部已更新检查all的依赖链看顶层目标是否声明为.PHONYmissing separator命令前用了空格而不是Tab完全删除行首空白重新按Tab键make: No rule to make target xxx依赖文件路径写错或不存在打印文件列表核对路径拼写[Errno 2] No such file or directoryPython脚本输出目录不存在脚本里用Path(...).parent.mkdir(parentsTrue, exist_okTrue)修改Python代码后make不重跑Makefile没有把脚本文件加入依赖把对应脚本路径加到目标的依赖列表里虚拟环境路径在Windows上报错Windows的Python可执行文件在Scripts目录使用$(VENV)/Scripts/python或加平台判断make: command not found未安装make安装build-essential或对应系统工具包5.2 独家避坑心得第一Makefile里所有“动作目标”都养成加.PHONY的习惯。像test、clean、install这类目标如果不声明一旦目录里出现同名文件make就会认为目标已是最新然后什么都不干。这个坑隐蔽程度极高因为平时目录里不会突然冒出同名文件但一旦有人为了调试创建了一个clean文件后续的make clean就全部失效。第二Python脚本的参数要全部显式传给Makefile目标。什么意思比如make process INPUTxxx在Makefile里写成$(PYTHON) scripts/process_data.py --input $(INPUT)。别在脚本里写死路径也别只依赖Makefile内部的默认值。显式传参能让Makefile真正成为一个“唯一入口”团队里其他人不需要打开脚本才知道参数从哪来。第三日志和状态信息要带唯一标记。我在前面脚本示例里刻意让每个脚本最后打印如“清洗完成写入xxx”这样的内容。好处是配合Makefile的增量逻辑跑完make之后扫一眼日志就能判断哪些步骤是最新跑的哪些被跳过了。这个习惯在你连续重跑多轮设备老化测试时简直救命。第四注意“大文件”场景下的增量判断陷阱。Makefile默认用时间戳判断新旧但如果你从外部同步文件比如git pull或者网盘下载文件的修改时间可能保持原始值此时Make可能误判为“已最新”而跳过。这种场景下建议在目标里增加一个“强制重跑”的方式比如增加一个force目标或者在clean里覆盖相关文件。像我之前的设备老化测试脚本每次跑完历史数据归档后我都会make clean再跑下一轮绝不在半脏状态下直接重跑。第五Windows环境下跑Makefile通常是借助MinGW或者WSL。如果你用的是Windows原生PythonMakefile里的虚拟环境路径一定要按Scripts目录处理我上面表格里也提到了。建议项目里放一个简单的检测逻辑最省事的就是在Makefile开头写一个ifeq判断OS类型再设置PYTHON变量。以下是我多年实践下来的一个更通用的处理模板可供你直接参考OS : $(shell uname -s 2/dev/null || echo Windows) ifeq ($(OS),Windows) PY : $(VENV)/Scripts/python PIP : $(VENV)/Scripts/pip else PY : $(VENV)/bin/python PIP : $(VENV)/bin/pip endif这样一来在不同系统上make setup的行为就能保持一致不再被环境路径问题打断节奏。5.3 让调试变轻松的两个小技巧最后分享两个我每次都会用到的调试技巧。一个是make -n也就是干跑模式。它会打印将执行的命令但不会真的执行。这个命令在你改动Makefile后特别有用能直观看到依赖解析是否正确、哪些目标会触发哪些命令。很多时候我被奇怪的“Nothing to be done”搞懵都是先跑一下make -n看看一下就明白了。另一个是make -p它会打印出Makefile的所有变量、规则和隐含规则。这个命令输出非常长但你可以用grep过滤。比如我想看某个变量最终被展开成了什么路径就执行make -p | grep -E ^PYTHON|^PIP这比来回改文件加$(info)之类的调试语句快得多。写在最后的经验我个人在实际操作中的体会是MakefilePython这套组合真正值钱的地方不是语法而是“让人不用重复做判断”。依赖关系只要声明一次之后每次触发重跑都自动判断业务逻辑只需要写一个脚本之后任何调用入口都走同一套代码。把这两件事做好自动化流程的维护成本会大幅下降。如果你还在用一长串Bash命令手动拉取、处理、上传不妨花半天时间把这套骨架搭起来后面省下的时间远不止这点成本。