Android N技術解析之Kati內部結構

譯自《Kati internals》

這是關于Kati內部結構的非正式文檔。本文不是一個Kati或GNU make的綜合文檔。本文檔解釋了其他程序員可能感興趣的一些隨機主題。

動機

Kati的動機是加快Android平臺構建。特別是其增長的構建時間是主要的焦點。 Android平臺的構建系統是一個非常獨特的系統。它提供了一個DSL (ab),使用Turing完整的GNU make。 DSL允許開發人員以描述性的方式編寫構建規則,但缺點是復雜和緩慢。

當我們說構建系統緩慢時,我們考慮 “空構建(null build)” 和 “完整構建(full build)”。 空構建是一個不做任何事情的構建,因為所有的輸出文件都是最新的。完整構建是構建一切的構建,因為沒有任何東西已經構建好。日常開發中的實際構建位于空構建和完整構建之間。下面的大多數基準測試都是基于空構建。

對于Android,我的相當強大的工作站,使用GNU make,空構建花了約100秒。這意味著你需要等待?100秒來查看更改單個C文件時是否有編譯錯誤。公平地說,事情并沒有那么糟糕。因為有稱為mm/mmm的工具。它們允許開發人員構建一個單獨的模塊。當它們忽略模塊之間的依賴關系時,它們很快。但是,你需要有足夠的經驗才能正確使用它們。你應該知道哪些模塊將受到你的更改的影響。如果你更改一些東西時可以只要輸入“make”,那將會更好。

這就是為什么我們開始這個項目的原因。我們決定從頭創建一個GNU make克隆,但還有一些其他選項。一個選擇是用更好的格式替換所有的Android.mk文件。實際上這是一個長期的項目。Kati計劃成為一個短期項目。另一個選擇是hack GNU make,而不是開發克隆。我們沒有采取這個選擇,因為我們認為由于歷史原因,GNU make的源代碼有點復雜。它是用老式C編寫的,對于某些未知的架構等都有很多ifdef。

目前,Kati的主要模式是--ninja模式。 Kati自己不用執行build命令,而是生成build.ninja文件,而ninja實際上運行命令。在Kati成為當前形式之前,有一些往返。一些實驗成功,其他一些失敗。我們甚至改變了Kati的語言。起初,我們在Go中寫了Kati。我們天真地期望我們可以用Go獲得足夠的性能。我猜到以下至少一個語句是真的:

  1. GNU make對于計算重的Makefile不是非常優化,2. 為我們的目的選擇Go有點快了,或3. 我們可以提出一些Android的構建系統的優化技巧。至于3,一些這樣的優化成功了,但是性能的提升沒有取消Go的緩慢。

Go的性能表現會有點有趣的話題。我沒有詳細研究性能差異,但是似乎我們使用Go的方式,以及Go語言本身使得Kati的Go版本變慢。對于我們的錯,我認為Go版本比C++版本有更多的不必要的字符串分配。至于Go本身,似乎GC是主要的展示器。例如,Android的構建系統定義了大約一百萬個變量,緩沖區將永遠不會被釋放。 IIRC,這種分配格局對于非代際GC(non-generational)是不利的。

Go版本和測試用例是由ukai和我寫的,C++重寫主要由我完成。本文檔的其余部分主要是關于C++版本。

整體架構

Kati由以下組件組成:

  • 解析器(Parser)
  • 評估器(Evaluator)
  • 依賴構建器(Dependency builder)
  • 執行器(Executor)
  • Ninja生成器(Ninja generator)

Makefile有一些由零個或多個表達式組成的語句。有兩個解析器和兩個評估器, 一個用于語句,另一個用于表達式。

GNU make的大部分用戶可能不太關心評估器。但是,GNU make的評估器非常強大,并且是圖靈完整的。對于Android的空構建(null build),大部分時間都花在這個階段。其他任務,例如構建依賴關系圖和調用構建目標的stat函數,并不是瓶頸。這將是一個非常具體的Android特性。 Android的構建系統使用了大量的GNU make黑魔法。

評估器輸出構建規則(build rules)和變量表(variable table)的列表。依賴構建器從構建規則列表中創建依賴圖(dependency graph)。注意這一步不使用變量表。

然后將使用執行器或Ninja生成器。無論哪種方式,Kati再次為命令行運行其評估器。該變量表再次用于此步驟。

我們將仔細查看每個組件。 GNU make是一種與現代語言不同的語言。讓我們來看看。

語句分析器

我不是100%肯定,但我認為GNU make同時進行解析和評估Makefile,但是Kati有兩個階段用于解析和評估。這種設計的原因是為了性能。對于Android構建,Kati(或GNU make)需要讀取?3k個文件?50k次。讀取最多的文件需要讀取?5k次。浪費時間一次次地解析這些文件。當需要再次評估Makefile時,Kati可以重新使用已解析的結果。如果我們停止緩存已解析的結果,則對于Android的構建,Kati將會慢兩倍。緩存已解析的語句在file_cache.cc中完成。

語句解析器在parser.cc中定義。在Kati中,有四種語句:

  • 規則(Rules)
  • 賦值(Assignments)
  • 命令(Commands)
  • Make指令(Make directives)

它們的數據結構在stmt.h中定義。以下是這些語句的示例:

    VAR := yay!        # An assignment
    all:                      # A rule
        echo $(VAR)  # A command
    include xxx.mk   # A make directive (include)

除了include指令之外,還有ifeq/ifneq/ifdef/ifndef指令和export/unexport指令。另外,在Kati內部還使用“解析錯誤語句(parse error statement)”。由于GNU make不顯示不被采用的分支中的解析錯誤,因此我們需要將解析錯誤延遲到評估時間。

上下文相關解析器

解析make語句的棘手之處在于,解析取決于評估時的上下文。請參閱以下Makefile塊:

    $(VAR)
        X=hoge echo $${X}

$(VAR)被評估之前,你無法判斷第二行是否為命令或賦值。如果$(VAR)是一個規則語句,則第二行是一個命令,否則它是一個賦值。如果在此之前的上一行是

    VAR := target:

第二行將成為一個命令。

由于某些原因,僅用于規則,GNU make會在決定語句類型之前展開表達式。將賦值或指令(assignments or directives)存儲在變量中將使其不能用作賦值或指令。例如

    ASSIGN := A=B
    $(ASSIGN):

不為A賦值B,但定義了一個構建規則,其目標是A = B

無論如何,因為以tab字符開頭的一行可以是一個命令語句(command statement)或其他語句,這取決于上一行的評估結果,所以有時Kati的解析器不能分辨一行的語句類型。在這種情況下,Kati的解析器推測創建一個命令語句對象,也保留原始行。如果事實證明該行實際上不是命令語句,則評估器將重新運行解析器。

行連結和注釋

在大多數編程語言中,反斜杠字符的行連接和注釋在語言實現的早期階段處理。然而,GNU make根據解析/評估上下文(parse/eval context)改變了它們的行為。例如,以下Makefile輸出“has space”和“hasnospace”:

    VAR := has\
    space
    all:
        echo $(VAR)
        echo has\
    nospace

GNU make通常在行之間插入空格,但對于命令行,它不會。正如我們在上一小節中看到的,有時候Kati不能分辨一行是一個命令語句。這意味著我們在評估語句后應該處理它們。類似的討論也適用于注釋。 GNU make通常去掉(trims)在'#'之后的字符,但對命令行中的'#'不起作用。

我們在Kati的代碼庫的testcase目錄中有一堆關于注釋/反斜杠相關的測試用例。

表達式的解析器

一個語句可能有一個或多個表達式。語句中的表達式數目取決于語句的類型。例如,

    A := $(X)

這是一個賦值語句,它有兩個表達式 :A$(X)。表達式及其解析器的類型在expr.cc中定義。像其他編程語言一樣,一個表達式(an expression)是一棵表達式的樹(a tree of expressions)。葉子表達式(leaf expression)的類型是文字(literal),變量引用(variable reference),替代引用(substitution references)或make函數(make functions)。

如所寫的,反斜杠和注釋根據上下文來改變它們的行為。在這個階段,Kati處理他們。ParseExprOpt是上下文的枚舉。

作為舊系統的一個本質,GNU make是非常寬容的。由于某種原因,它允許某種沒有成對匹配的括號對(unmatched pairs of parentheses)。例如,GNU make不認為$($ foo)是一個錯誤 - 這是對變量$(foo)的引用。如果你有一些解析器的經驗,你可能會想知道怎么會實現這樣的解析器能允許這樣的表達式。似乎GNU有意地允許這樣:

http://git.savannah.gnu.org/cgit/make.git/tree/expand.c#n285

沒有人會有意使用這個功能。然而,不幸的是,由于GNU make允許這一點,一些Makefile有不匹配的括號,所以Kati不應該為他們引發一個錯誤。

GNU make有一堆功能。大多數用戶只能使用簡單的例如$(wildcard ...)$(subst ...)。還有更復雜的功能,如$(if ...)$(call ...),這使得GNU make是圖靈完整的。 make函數定義在func.cc中。雖然func.cc不短,但實現相當簡單。關于函數,我記得只有一個比較奇怪的地方。 GNU make對$(if ...)$(and ...)$(or ...)的解析稍作了改變。請參閱func.h中的trim_spacetrim_right_space_1st,以及如何在expr.cc中使用它們。

語句的評估器

語句的評估器定義在eval.cc中。如書面所述,有四種語句:

  • 規則(Rules)
  • 賦值(Assignments)
  • 命令(Commands)
  • Make指令(Make directives)

命令和指令之間沒有什么棘手的。規則語句有一些不同形式,在第三個解析器評估表達式之后應該進行解析。這將在下一節討論。

GNU make中的賦值有點棘手。 GNU make中有兩種變量 - 簡單變量和遞歸變量。請參閱以下代碼段:

    A = $(info world!)   # recursive
    B := $(info Hello,)  # simple
    $(A)
    $(B)

此代碼按此順序輸出“Hello,”和“world!”。遞歸變量的評估被延遲,直到引用變量。所以第一行是遞歸變量的賦值,不輸出任何內容。在第一行之后,變量$(A)的內容將是$(info world!)。第二行中的賦值使用:=,這意味著這是一個簡單變量賦值。對于簡單變量,右側將立即進行評估。所以“Hello,”將被輸出,$(B)的值將是一個空字符串,因為$(info ...)返回一個空字符串。然后,當第三行$(A)被評估時,將顯示“world!”,最后第四行不執行任何操作,因為$(B)是一個空字符串。

還有兩種賦值(即+=?=)。這些賦值保留原始變量的類型。只有當賦值的左側已經被定義并且是一個簡單變量時,才能對它們進行評估。

規則解析器

評估規則語句后,Kati需要解析評估結果。規則聲明實際上可以是以下四件事:

  • 一個規則
  • 一個目標特定變量
  • 一個空行
  • 一個錯誤(沒有冒號的非空格字符)

解析它們主要是在rule.cc中完成的。

規則

一個規則就像all: hello.exe。你應該熟悉它。有幾種規則,如模式規則(pattern rules),雙冒號規則(double colon rules)和僅順序依賴(order only dependencies),但它們不會使規則解析器復雜化。

使解析器復雜化的一個特性是分號。你可以在規則的同一行上編寫第一個構建命令。例如,

    target:
        echo hi!

    target: ; echo hi!

具有相同的含義。這是棘手的,因為在實際調用命令之前,Kati不應該評估命令中的表達式。因為表達式評估的結果,可以出現分號,有一些邊角情形。一個棘手的例子:

    all: $(info foo) ; $(info bar)
    $(info baz)

應該按此順序輸出foobaz,然后bar,但是

    VAR := all: $(info foo) ; $(info bar)
    $(VAR)
    $(info baz)

輸出foobar,然后baz

再次,對于分號后面的命令行,Kati還應該更改反斜杠和注釋的處理方式。

    target: has\
    space ; echo no\
    space

上面的例子說,target依賴于兩個目標,hasspace,而為了構建targetecho nospace應該被執行。

目標特定變量

你可能不熟悉目標特定變量(target specific variables)。此功能允許你定義只能從指定目標的命令中引用的變量。請參閱以下代碼:

    VAR := X
    target1: VAR := Y
    target1:
        echo $(VAR)
    target2:
        echo $(VAR)

在這個例子中,target1顯示Ytarget2顯示X。我認為這個功能有點類似于其他編程語言中的命名空間。如果為非葉目標(non-leaf target)指定了目標特定變量,則即使在先決條件目標(prerequisite targets)的構建命令中也將使用該變量。

一般來說,我喜歡GNU make,但這是唯一不喜歡的GNU make的功能。請參閱以下Makefile:

    hello: CFLAGS := -g
    hello: hello.o
        gcc $(CFLAGS) $< -o $@
    hello.o: hello.c
        gcc $(CFLAGS) -c $< -o $@

如果你為目標hello運行make,則CFLAGS會應用于兩個命令:

    $ make hello
    gcc -g -c hello.c -o hello.o
    gcc -g hello.o -o hello

但是,當你只構建hello.o時不會使用給helloCFLAGS

    $ make hello.o
    gcc  -c hello.c -o hello.o

當具有不同目標特定變量的兩個目標取決于相同的目標時,事情可能會更糟。構建結果將不一致。我認為沒有對非葉目標(non-leaf targets)的這個功能的有效使用案例。

我們回到解析上來。像分號一樣,我們需要延遲遞歸變量賦值的右側的評估。它的實現與分號非常相似,但是賦值和分號的組合使得解析有點棘手。一個例子:

    target1: ;X=Y echo $(X)  # A rule with a command
    target2: X=;Y echo $(X)  # A target specific variable

表達式的評估器(Evaluator for expressions)

表達式的評估在expr.ccfunc.cccommand.cc中完成。這個步驟的代碼量相當大,特別是因為GNU make函數的數量。然而,他們的實現是相當簡單的。

一個棘手的功能是$(wildcard ...)。似乎GNU make正在為此功能進行某種優化,命令中的$(wildcard ...)似乎在命令的評估階段之前進行評估。 C++ Kati和Go Kati兩者都以不同的方式與GNU make的行為不同,但似乎這種不兼容性對于Android構建還是OK的。

對Android進行了一個重要的優化。 Android的構建系統有很多$(shell find ...)調用來創建一個目錄下的所有.java/.mk文件的列表,它們很慢。為此,Kati有一個GNU find的內置仿真器。該find仿真器遍歷目錄樹并創建內存中的目錄樹(in-memory directory tree)。然后,該find仿真器使用緩存的樹返回find命令的結果。對于我的環境來說,find命令模擬器使得Kati比AOSP快了1.6倍。

命令中的一些IO相關功能的實現在Ninja生成模式下是棘手的。這將在后面描述。

依賴構建器(Dependency builder)

現在我們得到一個規則列表和一個變量表。dep.cc使用規則列表構建依賴圖。我認為這個步驟是GNU make對普通用戶應該做的。

這個步驟與其他組件類似,相當復雜,但沒有什么奇怪的。 GNU make有三種規則:

  • 明確規則(explicit rule)
  • 隱式規則(implicit rule)
  • 后綴規則(suffix rule)

以下代碼顯示了三種類型:

    all: foo.o
    foo.o:
        echo explicit
    %.o:
        echo implicit
    .c.o:
        echo suffix

在上面的例子中,所有這三個規則都匹配目標foo.o。 GNU make首先確定明確規則。當目標沒有明確的規則時,它將使用具有較長模式字符串的隱式規則。后綴規則僅在沒有明確/隱式規則時使用。

Android有超過一千個隱式規則,有成千上萬的目標。使用一個天真的O(NM)算法來匹配它們太慢了。Kati用一個特技加速這一步。

多個沒有命令的規則應該合并到有命令的規則中。例如:

    foo.o: foo.h
    %.o: %.c
        $(CC) -c $< -o $@

foo.o不僅取決于foo.c,還取決于foo.h

執行器(Executor)

C++ Kati的執行器很簡單。這在exec.cc中定義。這僅對于測試是有用的,因為它缺少構建系統的一些重要功能(例如并行構建)。

命令中的表達式在這個階段進行了評估。當他們進行評估時,應考慮目標特定變量(target specific variables)和一些特殊變量(例如$<$@)。由command.cc處理它們。該文件由執行器和Ninja生成器使用。

當涉及+=和目標特定變量時,此階段的評估是棘手的。這是一個示例代碼:

    all: test1 test2 test3 test4
    
    A:=X
    B=X
    X:=foo
    
    test1: A+=$(X)
    test1:
        @echo $(A)  # X bar
    
    test2: B+=$(X)
    test2:
        @echo $(B)  # X bar
    
    test3: A:=
    test3: A+=$(X)
    test3:
        @echo $(A)  # foo
    
    test4: B=
    test4: B+=$(X)
    test4:
        @echo $(B)  # bar
    
    X:=bar

這里test3$(A)是一個簡單變量。雖然全局范圍內的$(A)是簡單變量,但test1中的$(A)是一個遞歸變量。這意味著全局變量的類型不會影響目標特定變量的類型。但是,test1的結果(“X bar”)顯示目標特定變量的值連接到(concatenated)全局變量的值。

Ninja生成器(Ninja generator)

ninja.cc使用其他組件的結果生成一個ninja文件。這一步實際上是相當復雜的,因為Kati需要將GNU make的功能映射到Ninja。

GNU make中的構建規則可能有多個命令,而ninja始終是單個命令。為了減輕這種情況,Ninja生成器將多個命令轉換為(cmd1) && (cmd2) && ...。Kati也應該轉義一些ninja和shell的特殊字符。

更麻煩的是在命令中的$(shell ...)。當前Kati的實現將其轉換為shell的$(...)。這適用于許多情況。但是當$(shell ...)的結果傳遞給另一個make函數時,這種方法將無法正常工作。例如

    all:
        echo $(if $(shell echo),FAIL,PASS)

應該輸出PASS,因為$(shell echo)的結果是一個空字符串。 GNU make和Kati的執行器模式正確輸出PASS。然而,Kati的Ninja生成器生成出一個顯示失敗的ninja文件。

我為這個問題寫了幾個實驗補丁,但是它們的運行不好。目前的Kati實現具有Android特定的解決方法。有關詳細信息,請參閱func.cc中的HasNoIoInShellScript

Ninja再生(Ninja regeneration)

C++ Kati有--regen標志。如果指定了此標志,則Kati會檢查你的環境中的任何內容是否在上次運行后發生更改。如果Kati認為它不需要重新生成Ninja文件,它會很快完成。對于Android,第一次運行Kati需要?30秒,但第二次運行只需要?1秒。

Kati認為,當更改以下任一項時,需要重新生成Ninja文件:

  • 傳遞給Kati的命令行標志
  • 用于生成上一個ninja文件的Makefile的時間戳
  • 評估Makefile時使用的環境變量
  • $(wildcard ...)的結果
  • $(shell ...)的結果

快速做最后一個檢查不是微不足道的。由于$(shell find ...)的緩慢,在Android的構建系統中運行$(shell ...)需要?18秒。因此,對于由Kati的find仿真器執行的查找命令,Kati通過跟find命令本身一起存儲遍歷目錄的時間戳。對于每個查找命令,Kati檢查它們的時間戳。如果它們沒有改變,則Kati跳過重新運行find命令。

在此檢查過程中,Kati不運行$(shell date ...)$(shell echo ...)。前者總是改變,所以沒有意義重新運行它們。 Android使用后者創建一個文件,其結果是空字符串。我們不想更新這些文件以獲取空字符串。

待做事項(TODO)

一個大的TODO是由$(MAKE)調用的sub-makes。我寫了一些實驗性的補丁,但是還沒有什么值得可以用來一寫的。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,791評論 6 545
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,795評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,943評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,057評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,773評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,106評論 1 330
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,082評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,282評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,793評論 1 338
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,507評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,741評論 1 375
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,220評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,929評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,325評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,661評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,482評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,702評論 2 380

推薦閱讀更多精彩內容

  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,836評論 18 139
  • linux資料總章2.1 1.0寫的不好抱歉 但是2.0已經改了很多 但是錯誤還是無法避免 以后資料會慢慢更新 大...
    數據革命閱讀 12,203評論 2 33
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,798評論 25 708
  • 來自陳浩的一片老文,但絕對營養。 示例工程:3 個頭文件*.h,和 8 個 C 文件*.c。 初 編譯過程,源文件...
    周筱魯閱讀 4,727評論 0 17
  • 等待一場雨 洗去塵埃與浮躁 讓低垂陰云覆蓋藍色 驕陽失去光熱 在厚重的黑暗里 看見真實 等待一場雨 狂風與暴雨
    xixihahalelehe閱讀 158評論 0 1