11 個 Makefile 實戰技巧

吳章金發表於2019-08-16

本文首次發表在 11 個 Makefile 實戰技巧 -- 泰曉科技

過去數月,筆者一直在重構開發了數年的開源專案:Linux Lab(Linux 核心實驗室)。

在開發過程中,需要跟 Makefile 打交道,完善功能,優化速度,提升體驗。

數月下來,積累了不少 Makefile 實戰技巧。回過來看,之前對 Makefile 的熟知程度只能說是幼兒園託班水平 ;-)

本文篇幅較長,請先看大綱。建議從大綱中選擇感興趣的部分閱讀(文末有彩蛋 ^_^):

1. 立即賦值(:=)和延遲賦值(=)
2. 變數賦值 和 目標執行 之間的時序關係
3. 如何獲取 Make 傳遞的所有引數和編譯目標
4. Makefile 除錯與跟蹤方法一覽
5. Makefile 與 Shell 中的檔名處理差異
6. 在 Makefile 表示式中使用逗號和空格變數
7. 在 Makefile 中對軟體版本號做差異化處理
8. 修改預設執行目標的簡單方法
9. 檢查檔案是否存在的兩種方法
10. 如何類似普通程式一樣把目標當變數使用
11. Makefile 例項模板
複製程式碼

本文彙總了諸多 Makefile 進階用法,便於提升 Makefile 閱讀和編寫效率。

立即賦值(:=)和延遲賦值(=)

  • :=: 強制按先後順序執行,立即賦值。
  • =:賦值的結果會等到整個路徑執行完再決定,後面的會覆蓋前面的,延遲賦值。

按照常規邏輯,建議預設選用 ":="。

例項如下:

$ cat Makefile

a = foo
b1 := $(a) bar
b2 = $(a) bar
a = xyz

all:
	@echo b1=$(b1)
	@echo b2=$(b2)

$ make
b1=foo bar
b2=xyz bar
複製程式碼

變數賦值 和 目標執行 之間的時序關係

這裡再看看變數賦值和編譯目標之間的關係,以及不同的變數傳遞和設定方式。

先看看通常可能會傳遞引數的方式,大家覺得哪個會生效呢?

$ make a=b target
$ make target a=b
$ a=b make target
$ export a=b && make target
複製程式碼

另外,這種情況下,target1 和 target2 列印的變數一樣嗎?

a = aaa

test1:
	echo $a

a = bbb

test2:
	echo $a
複製程式碼

下面看一個案例(注意:target 下命令縮排必須是一個 TAB)。

$ cat Makefile

a ?= aaa
b := $(a)
c = $(a)

a_origin = $(origin a)
b_origin = $(origin b)
c_origin = $(origin c)

all:
	@echo all:$(a)
	@echo all:$(b)
	@echo all:$(c)
	@echo all:$(a_origin)
	@echo all:$(b_origin)
	@echo all:$(c_origin)

a = bbb
b := $(a)
c = $(a)

test1:
	@echo test1:$(a)
	@echo test1:$(b)
	@echo test1:$(c)
	@echo test1:$(a_origin)
	@echo test1:$(b_origin)
	@echo test1:$(c_origin)

a = ccc
b := $(a)
c = $(a)

test2:
	@echo test2:$(a)
	@echo test2:$(b)
	@echo test2:$(c)
	@echo test2:$(a_origin)
	@echo test2:$(b_origin)
	@echo test2:$(c_origin)

a = ddd
複製程式碼

看看執行情況。

關於 變數賦值 和 目標中的變數引用 的順序

首先,執行預設 target,也就是第一個出現的 target,這裡是 "all":

$ make
all:ddd
all:ccc
all:ddd
all:file
all:file
all:file
複製程式碼

比較奇怪的是?為什麼 "all" 目標剛好在這三條之後,卻拿到了 ddd, ccc 和 ddd 呢?

a ?= aaa
b := $(a)
c = $(a)
複製程式碼

為什麼不是 aaa, aaa 和 aaa 呢?

接著,執行 test1, test2:

$ make test1
test1:ddd
test1:ccc
test1:ddd
test1:file
test1:file
test1:file

$ make test2
test2:ddd
test2:ccc
test2:ddd
test2:file
test2:file
test2:file
複製程式碼

發現,test1, test2 都一樣?所以,結論是,Makefile 中所有變數賦值的語句在所有 target 之前完成,跟變數賦值與 target 的相對位置無關。

另外,我們可以看到 b 沒有跟上 c 的節奏,拿到 ccc 就不再跟 c 一樣去拿最後設定的 ddd 了,體現了 “:=” 的 “立即賦值”,而 c 一直等到了 Makefile 最後的 a。另外,三個變數最後的值都是檔案內部賦值,所以 origin 是 file.

通過命令列賦值

$ make a=fff
all:fff
all:fff
all:fff
all:command line
all:file
all:file
複製程式碼

發現命令列覆蓋了 Makefile 中所有的變數賦值,a 的優先順序很高。

$ make b=fff
all:ddd
all:fff
all:ddd
all:file
all:command line
all:file
複製程式碼

由於 a 和 c 沒用引用 b,所以這裡只有 b 發生了變化。

$ make c=fff
all:ddd
all:ccc
all:fff
all:file
all:file
all:command line
複製程式碼

同樣,a 和 b 沒有引用 c,只有 c 發生了變化。

通過環境變數賦值

$ a=xxx make
all:ddd
all:ccc
all:ddd
all:file
all:file
all:file
複製程式碼

發現並沒有生效,還是用的 make 的內部賦值語句。

$ a=xxx make -e
all:xxx
all:xxx
all:xxx
all:environment override
all:file
all:file
複製程式碼

確實都改了,所以要讓環境變數生效,得給 make 傳遞 -e

$ b=xxx make -e
all:ddd
all:xxx
all:ddd
all:file
all:environment override
all:file
複製程式碼

這個的效果同樣:

$ export b=fff
$ make -e
all:ddd
all:fff
all:ddd
all:file
all:environment override
all:file
複製程式碼

只是建議不要隨便用 -e,萬一有人在 .bashrc 或者 .profile 提前 export 了一個環境變數,自己沒有主動設定的話,可能就會懷疑人生了,程式行為可能會出人意料而很難 debug。

環境變數和命令列哪個優先

$ b=xxx make -e b=yyy
all:ddd
all:yyy
all:ddd
all:file
all:command line
all:file
複製程式碼

可以看到 命令列 優先。

小結一下:

  • 所有變數語句的執行在 target 下的語句之前(每個 target 所屬語句有一個 TAB 的縮排)。
  • 變數 override 優先順序:command line > environment override > file

最後佈置一個小作業?這個的結果是什麼呢?

$ b=xxx make -e b=yyy all b=zzz test2 b=mmm
複製程式碼

如何獲取 make 傳遞的所有引數和編譯目標

先來看看這樣一個問題:

$ make test1 test2 test3 a=123 b=456
複製程式碼

如何在 Makefile 中獲取 make 命令後面的所有引數呢?

在 Shell 指令碼里頭這個是很常用的,引數列表:$1, $2, $3, $4 ... $@

同樣地,在 Makefile 中有這樣的需求,比如說想看看到底有沒有傳進來某個引數,根據引數不同做不一樣的動作。

make 後面的引數有兩種型別,一種是命令列變數,一種是編譯目標。

這兩個分別存放在 MAKEOVERRIDESMAKECMDGOALS 變數中。

判斷有沒有傳遞某個編譯目標,可以這麼做:

ifeq ($(filter test1, $(MAKECMDGOALS)), test1)
    do something here
endif
複製程式碼

上述程式碼實際是也相當於可以用來把一些變數賦值放到目標相關的程式碼塊中。這個可以大幅提升大型 Makefile 的執行效率,在執行特定的目標時,不去執行無關的程式碼塊。

判斷有沒有傳遞某個引數,可以這麼做:

ifeq ($(origin a), command line)
    do something here
endif
複製程式碼

當然,也可以從 MAKEOVERRIDES 中做 findstring 檢查,只是沒有用 origin 來得簡單。

Makefile 除錯與跟蹤方法一覽

Debugging

$ make --debug xxx
複製程式碼

展開整個 make 解析和執行 xxx 的過程。

Tracing

$ make --trace xxx
複製程式碼

展開 xxx 目的碼的執行過程,有點像 Shell 裡頭的 set -x。該功能在 make 4.1 及之後才支援。

Logging

$(info ...)
$(warning ...)
$(error ...)
複製程式碼

error 列印日誌後立即退出,非常適合已經復現的錯誤。

Environment dumping

$ make -p xxx > xxx.data.dump
複製程式碼

開啟 xxx.data.dump 找到 xxx 的位置可以檢視相關變數是否符合預期。

Makefile 與 Shell 中的檔名處理差異

Makefile 中有類似 Shell 的 dirnamebasename 命令,它們是:dir, basename, notdir,但是用法有差異,千萬別弄混,下面來一個對比。

$ cat Makefile
makefile:
	@echo $(dir $a)
	@echo $(basename $a)
	@echo $(notdir $a)

shell:
	@echo $(shell dirname $a)
	@echo $(shell basename $a)

$ make makefile a=/path/to/abc.efg.tgz
/path/to/
/path/to/abc.efg
abc.efg.tgz
$ make shell a=/path/to/abc.efg.tgz
/path/to
abc.efg.tgz

$ make makefile a=/path/to/
/path/to/
/path/to/

$ make shell a=/path/to/
/path
to

$ make makefile a=/path/to
/path/
/path/to
to
複製程式碼

通過對比,可以看到,Makefile 的 dirbasename 跟 Shell 中的 dirnamebasename 有非常微妙的差異。如果理解成等價,那就很麻煩了,因為拿到的結果並不如預期。

對於檔案,有如下等價關係:

引數 動作 Makefile Shell
/path/to/abc.efg.tgz 取目錄 dir dirname
同上 取檔名 notdir basename

並且需要注意,Makefile 的 dir 取到的目錄帶有 / 字尾,而 Shell 的 dirname 結果不帶 /。對於目錄,兩者的認知千差萬別,Makefile 的 dirbasename 拿到的都是目錄,而 Shell 能夠拆分出父目錄和字目錄的檔名。如果要對齊到 Makefile,用 dirnotdir 起到類似 Shell dirnamebasename 的效果,得先 strip 掉後面的 '/'。

下面改造一下:

$ cat Makefile
makefile:
	@echo $(patsubst %/,%,$(dir $(patsubst %/,%,$a)))
	@echo $(notdir $(patsubst %/,%,$a))

shell:
	@echo $(shell dirname $a)
	@echo $(shell basename $a)

$ make makefile a=/path/to/abc.efg.tgz
/path/to
abc.efg.tgz
$ make shell a=/path/to/abc.efg.tgz
/path/to
abc.efg.tgz

$ make shell a=/path/to/
/path
to
$ make makefile a=/path/to/
/path
to
複製程式碼

可以看到,改造完以後,結果跟 Shell 結果對齊了。

在 Makefile 表示式中使用逗號和空格變數

逗號和空格是 Makefile 表示式中的特殊符號,如果要用它們的願意,需要特別處理。

empty :=
space := $(empty) $(empty)
comma := ,
複製程式碼

在 Makefile 中對軟體版本號做差異化處理

Makefile 通常需要根據軟體版本傳遞不同的引數,所以經常需要對軟體版本號做比較。

例如,在 Linux 4.19 之後刪除了 oldnoconfig,並替換為了 olddefconfig,所以之前用到的 oldnoconfig 在新版本用不了,直接改掉老版本又用不了,得做差異化處理。

大家覺得應該怎麼處理呢?先思考一下再看答案吧。

下面貼出關鍵片段:

LINUX_MAJOR_VER := $(subst v,,$(firstword $(subst .,$(space),$(LINUX))))
LINUX_MINOR_VER := $(subst v,,$(word 2,$(subst .,$(space),$(LINUX))))

ifeq ($(shell [ $(LINUX_MAJOR_VER) -lt 4 -o $(LINUX_MAJOR_VER) -eq 4 -a $(LINUX_MINOR_VER) -le 19 ]; echo $$?),0)
    KERNEL_OLDDEFCONFIG := oldnoconfig
else
    KERNEL_OLDDEFCONFIG := olddefconfig
endif
複製程式碼

類似地,如果要同時相容不同版本的 GCC,得根據 GCC 版本傳遞不同的編譯選項,也可以像上面這樣去做識別,Linux 原始碼下就有很多這樣的需求。

不過它用了 try-run 的方式實現了一個 cc-option-yn (見 linux-stable/scripts/Kbuild.include),它是試錯的方式,避免了堆積大量的判斷程式碼,不過這裡用的版本判斷不多,而且呼叫這類 target 開銷較大,沒必要,直接加判斷即可。

需要注意的是,考慮到版本號命名的潛在不一致性,比如說,後面加個 -rc1,再加點別的什麼,判斷的複雜度會增加不少,所以,這類邏輯可以替換為其他方式,比如說,這裡可以直接去 linux-stable/scripts/Makefile 下用 grep 查詢 olddefconfig 是否存在:

KCONFIG_MAKEFILE := $(KERNEL_SRC)/scripts/kconfig/Makefile
KERNEL_OLDDEFCONFIG := olddefconfig
ifeq ($(KCONFIG_MAKEFILE), $(wildcard $(KCONFIG_MAKEFILE)))
  ifneq ($(shell grep olddefconfig -q $(KCONFIG_MAKEFILE); echo $$?),0)
    ifneq ($(shell grep oldnoconfig -q $(KCONFIG_MAKEFILE); echo $$?),0)
      KERNEL_OLDDEFCONFIG := oldconfig
    else
      KERNEL_OLDDEFCONFIG := oldnoconfig
    endif
  endif
endif
複製程式碼

修改預設執行目標的簡單方法

如果不指定目標直接敲擊 make 的話,Makefile 中的第一個目標會被執行到。這個是比較自然的邏輯,但是有些情況下,比如說,在程式碼演化以後,如果需要調整執行目標的話,得把特定目標以及相應程式碼從 Makefile 中搬到檔案開頭,這個改動會比較大,這個時候,就可以用 Makefile 提供的機制來修改預設執行目標。

來看看上面那個例子:

$ make -p | grep makefile | grep -v ^#
.DEFAULT_GOAL := makefile
makefile:
複製程式碼

可以看到,makefile 被賦值給了 .DEFAULT_GOAL 變數,通過 override 這個變數,就可以設定任意的目標了,把預設目標改為 shell 看看。

$ make -p .DEFAULT_GOAL=shell a=/path/to/abc.efg.tgz | grep ^.DEFAULT_GOAL
.DEFAULT_GOAL = shell
複製程式碼

確實可以改寫,這個要永久生效的話,直接加到 Makefile 中即可:

override .DEFAULT_GOAL := shell
複製程式碼

檢查檔案是否存在的兩種方法

在 Makefile 中,通常需要檢查一些環境或者工具是否 Ready,檢查檔案是否存在的話,可以用 wildcard 展開再匹配,也可以用 Shell 來做判斷。

ifeq ($(TEST_FILE), $(wildcard $(TEST_FILE)))
    $(info file exists)
endif

ifeq ($(shell [ -f $(TEST_FILE) ]; echo $$?), 0)
    $(info file exists)
endif
複製程式碼

第二種方法比較自由,可以擴充套件用來檢查檔案是否可執行,也可以呼叫 grep 做更多複雜的文字內容檢查。在複雜場景下,通過第二種方法呼叫 Shell 是比較好的選擇。

如何類似普通程式一樣把目標當變數使用

如果執行 make test-run arg1 arg2 想達到把 arg1 arg2 作為 test-run 目標的引數這樣的效果該怎麼做呢?可以用 eval 指令,它能夠動態構建編譯目標。

通過 eval 指令把 arg1 arg2 這兩個目標變成空操作,即使有 arg1 arg2 這樣的目標也不再執行, 然後執行 test-run 執行。

大概實現為:

$ cat Makefile

# Must put this at the end of Makefile, to make sure override the targets before here
# If the first argument is "xxx-run"...
first_target := $(firstword $(MAKECMDGOALS))
reserve_target := $(first_target:-run=)

ifeq ($(findstring -run,$(first_target)),-run)
  # use the rest as arguments for "run"
  RUN_ARGS := $(filter-out $(reserve_target),$(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)))
  # ...and turn them into do-nothing targets
  $(eval $(RUN_ARGS):;@:)
endif

test-run:
    @echo $(RUN_ARGS)


$ make test-run test1 test2
複製程式碼

這個的實際應用場景有,比如說想在外面的目標中呼叫核心的編譯目標,通常得進入核心原始碼,再執行 make target,可能得寫很多條這樣的目標:

kernel-target1:
	@make target1 -C /path/to/linux-src

kernel-target2:
	@make target2 -C /path/to/linux-src
複製程式碼

有了上面的支援,就可以實現成這樣:

kernel-run:
	@make $(arg1) -C /path/to/linux-src
複製程式碼

使用時也不復雜,核心的各種目標都可以作為引數傳遞進去:

$ make kernel-run target1
$ make kernel-run target2
複製程式碼

雖然說,上述 arg1,也可以這樣寫:

$ make kernel-run arg1=target1
$ make kernel-run arg1=target2
複製程式碼

但是在使用效率上明顯不如前者來得直接。

Makefile 例項模板

本文的內容大部分都彙整到了 Linux Lab: examples/makefile/template

送您一枚免費體驗卡

更多 Linux 精彩歡迎透過下方免費體驗卡訪問『Linux 知識星球』:

『Linux 知識星球』免費體驗卡

相關文章