第4章:可复用构建脚本——Makefile的基本结构与实战
说实话,在RTL验证的日常里,重复劳动是最磨人的。你今天用手动敲命令行仿真通过了,明天同事换了台机器,路径不一样,又得折腾半天。我个人习惯,从一开始就写个靠谱的Makefile,把这套流程固化下来。
Makefile 说白了就是一套自动化规则集。你只需要定义好目标、依赖和命令,剩下的就交给 make 自己去判断,哪些文件需要重新编译。这在CI流水线里尤其关键——流水线每次跑完,出来的结果必须是一致的,不能因为谁忘了一个参数就得出不同的结论。
4.1 Makefile 基本结构
我刚开始带团队的时候,经常有人问我:“Makefile是不是就得写得很复杂?”其实不然。一个能用的Makefile,核心结构就那么几块。我们先拆开看:
- 目标(Target):你要生成什么?比如
sim、compile、clean。 - 依赖(Prerequisites):生成目标需要哪些文件?比如
top.sv、testbench.sv。 - 命令(Recipe):如何从依赖生成目标?比如
vlog top.sv testbench.sv。
举个例子,一个最简单的仿真Makefile可能长这样:
sim: work
vsim work.top -do "run -all"
work: top.sv testbench.sv
vlib work
vlog top.sv testbench.sv
clean:
rm -rf work transcript vsim.wlf
嗯,这里有个小坑。默认情况下,make只会检查sim和work这两个“文件”是否存在。但clean和sim其实并不是真实文件,它们是所谓的“伪目标”。如果不声明.PHONY,万一你工作目录里碰巧有个叫clean的文件,make就傻了——它觉得目标已经存在,不会去执行删除命令。我在项目里就遇到过同事忘记声明.PHONY,结果怎么make clean都清不掉旧文件,排查了好久。
.PHONY 声明所有非文件目标。我曾经吃过这个亏,浪费了一整个下午。
那正确写法是这样的:
.PHONY: sim clean
4.2 变量管理——让脚本更灵活
你做仿真,总不能把所有的文件名、选项都硬编码在Makefile里吧?那复用性太差了。变量管理是Makefile进阶的第一关。
举个例子,我们经常需要指定不同的仿真工具。我个人喜欢用这样的方式组织:
# 项目基本信息
PROJECT_NAME = my_design
TB_TOP = top_tb
SIM_TOOL = vcs # 可以换成 questa, xcelium 等
# 文件路径
SRC_DIR = ./rtl
TB_DIR = ./testbench
OUT_DIR = ./output
# 编译选项
VCS_OPTS = -full64 -sverilog +v2k -debug_acc+all
# 设计文件列表
DESIGN_FILES = $(wildcard $(SRC_DIR)/*.sv)
TB_FILES = $(wildcard $(TB_DIR)/*.sv)
ALL_FILES = $(DESIGN_FILES) $(TB_FILES)
你想想看,这样一来,如果换项目了,我只需要改最上面那几行就行。底层逻辑不用动。而且,用$(wildcard)这种函数自动搜集文件,省得你手动一个一个列。
变量展开方式也需要注意。Makefile里有两种:
| 展开方式 | 定义符号 | 特点 | 我常用的场景 |
|---|---|---|---|
| 递归展开 | = | 延迟展开,使用时才计算 | 定义文件名列表时,后期可能追加更多文件 |
| 简单展开 | := | 立即展开,定义时就计算 | 定义路径、工具路径等固定内容 |
我会根据自己的习惯:凡是工具路径、编译选项这种不会变的,用:=。凡是文件列表、目标依赖这种可能被其他变量引用的,用=。其实区别很细微,但用错了可能在并行构建时遇到莫名其妙的空值。
4.3 仿真命令封装
仿真工具的命令行参数又多又长。每次都要敲一遍?不现实。封装成Makefile的单个目标,把常用的命令抽象成“一键运行”。
我习惯这样封装:
.PHONY: compile sim wave all clean
compile: $(OUT_DIR)/simv
$(OUT_DIR)/simv: $(ALL_FILES) | $(OUT_DIR)
$(SIM_TOOL) $(VCS_OPTS) -o $(OUT_DIR)/simv $(ALL_FILES)
sim: compile
$(OUT_DIR)/simv -l $(OUT_DIR)/sim.log +UVM_TESTNAME=$(TESTNAME)
wave: sim
dve -vpd $(OUT_DIR)/vcdplus.vpd &
all: compile sim wave
$(OUT_DIR):
mkdir -p $(OUT_DIR)
clean:
rm -rf $(OUT_DIR) *.log *.vpd csrc* *.key
这里我用了 | $(OUT_DIR) 这个“仅顺序依赖”特性。意思是:先确保 output 目录存在,但不依赖它的时间戳。这个小技巧我也是从开源项目里学来的,很好用。
还有一个细节:$(TESTNAME) 变量可以运行时传入:
make sim TESTNAME=test_basic_correctness
这样一来,同一个Makefile可以跑不同的测试用例。在CI流水线里,每个测试用例就是一个 make sim TESTNAME=xxx 的调用,非常好扩展。
4.4 多目标并行构建
这一点在CI里特别重要。你想想看,一个大的设计,可能有十几个测试用例。如果串行跑,一次CI要折腾半小时。如果并行跑,可能5分钟就出结果了。Makefile原生支持并行执行。
基本用法是 make -j N,其中 N 是并行任务数。我曾经见过团队直接设置 -j 8,结果服务器直接挂了。我们推荐的做法是:
- 物理核心数 + 1:比如你服务器是4核,用
-j 5,既能跑满CPU,又不会过度抢资源。 - 用
MAKEFLAGS传递:在CI流水线的配置里,通过环境变量传入MAKEFLAGS=-j4,Makefile里不用改。
另外,还有个小技巧是“定义多目标规则”。比如我需要同时跑多个测试用例,可以写成:
TESTS = test_a test_b test_c test_d
.PHONY: run_all_tests
run_all_tests: $(addprefix run_, $(TESTS))
run_%:
make sim TESTNAME=$*
那么 make run_all_tests -j4 就能并发跑4个测试。这在大规模的回归测试里太实用了。
$(OUT_DIR)/$(TESTNAME) 隔离。
嗯,关于Makefile的这几个模块,基础已经够用了。我最后画个图总结一下这章的核心逻辑:
记住这个框架:变量管理让你改得少,命令封装让你敲得少,并行构建让你等得少。下一步,你可以在自己的项目里把这三个模块都装上试试。嗯,先从最基础的那个 compile 目标开始,然后慢慢加上并行。相信我,一旦习惯了这套流程,你会觉得没有Makefile的日子真的没法过。