C++ Modules 多專案的使用情境(6/N)

| | 0 Comments| 17:02|
Categories:

前面 C++20 Modules 的說明與測試基本上都是單一專案、而且檔案都在同一個目錄的狀況下,所以在編譯指令的部分其實算是相對單純的。

但是實務上真的要開發專案的時候,應該會有很多包含函示庫、應用程式在內的專案;這個時候要怎麼讓編譯器可以找到 BMI 檔案其實會是個問題了。

這篇就是來記錄一下,Heresy 這邊自己在測試多專案的 C++ Modules 時的狀況。

先以結論來說,在以 Heresy 這邊需要考慮 g++(Makefile)/ MSVC 跨平台的狀況下,個人目前覺得比較好的方法可能是:

  • 將 Header Unit 的部分透過一個共用的靜態函式庫專案來處理
  • 在 g++ 的環境下、把產生的 CMI 檔(.gcm)集中到同一個目錄

這邊設計出來比較大的範例方案,可以參考 GitHub 上的「multi_projects」(連結)。

專案的部分,這邊有四個專案:

  • libStdHeader:靜態函式庫、用來處理標準函式庫 header unit 的專案
  • LibMyMath:靜態函式庫、以子模組概念建置的 MyMath 系列模組
  • libMyGraph:靜態函式庫、有用到 MyMath.TypesMyGraph 模組
  • app1:會使用到前面幾個函式庫的應用程式

而模組與 header unit 的相依性如下:

這個方案基本上是透過 Visual Studio 來設計的,但是這邊有做成 MSVC、g++ 和 clang 都可以編譯,完整的編譯指令可以參考 README.md 內的說明。

其中 MSVC 和 clang 都有「把 BMI 放在各自資料夾」和「把 BMI 集中在共用資料夾」兩種指令;而 g++ 的部分礙於本身的設計,感覺上應該沒辦法做到把 BMI 放在各自資料夾的架構?

不過這邊的執行指令只是參考、或是說可行性驗證用的,實際上大型專案不太可能靠這種指令來個別編譯。


由於 Heresy 這邊工作的需求,所以基本上 Windows 會是以 Visual Studio IDE 為主、不會去碰到 CLI 的部分;而 Linux 的部分,則會是透過 Makefile 來搭配 g++ 做建置。

所以這邊這邊也有針對 g++ 寫簡單的 Makefile,所以在 Linux 環境也可以透過 make 這個指令來建置。(Makefile 個人也沒熟到哪去就是了)


Visual Studio IDE

在使用 Visual Studio 的 IDE 環境進行開發的話,要使用 Module 非常簡單,只要把 C++ 語言標準設定成 C++20 以上就可以了。

比較特別的專案設定,只有針對 Header Unit 的部分,在這邊就是按照官方建議、把 header unit 的部分獨立出來成 一個靜態函式庫(static library):libStdHeader

這個函式庫專案是專門用來處理 header unit 的專案的、裡面只有一個 std.cpp,內容是:

import <cmath>;
import <iostream>;

它的用途只是告訴 Visual Studio 需要哪些 header 檔案,所以連真正建置出來的 .lib 檔案都不是很重要。

和一般的專案不同,需要在專案屬性中,把「C/C++」下「一般」的「掃描來源是否具有模組相依性」和「將 Include 轉譯為 Import」這兩個選項都開啟。

這樣 Visual Studio 就會根據專案內程式的需要,去建立出必要的 header unit 的 BMI 檔案了;像這邊他理論上就會去建立 <cmath><iostream> 的 BMI 檔了。(實際上會建立出更多)

之後其他有要匯入 header unit 的專案,只要把這個專案加入參考、就可以找到需要的 header unit BMI 檔案了。
(在「方案總管」中,在專案上按下滑鼠右鍵、選取「加入」、「參考」)

像在這個範例方案裡面,LibMyMathapp1 都有直接匯入 header unit,所以只要讓這兩個專案去參考 libStdHeader,就可以共用同一份 header unit 的 BMI 了。


而如果需要其他第三方函式庫的時候,也可以繼續用這個專案來做處理(不過專案名稱可能就要改一下會比較好了)。

這邊可以另外建立一個 .cpp 檔案、或是繼續沿用 std.cpp 也可以。

要注意的,是如果需要指定 header 檔的路徑的話,會需要設定「C/C++」下「一般」的「其他 Include 目錄」;而且這個設定除了 libStdHeader 這個專案要設定之外,有要匯入 header unit 的專案似乎也需要設定。


g++ 搭配 Makefile

在使用 Visual Studio IDE 的時候算是相當簡單,微軟會自動處理大部分的設定,不太需要擔心設定的問題。

而在使用 g++ 和 Makefile 的時候,其實就必須要自己考慮很多問題了;這邊主要應該會是:CMI(BMI)路徑的設定、編譯順序的相依性。

下面就依序來研究這些問題。


g++ 的 CMI 路徑設定

和 MSVC 與 clang 不同,g++ 會自己去管理產生出來的 .gcm 檔案。他預設是放在「gcm.cache」這個資料夾下,如果是 header unit 的話甚至還會有對應原來路徑的樹狀結構。

因此,當多專案在不同資料夾的時候,要怎麼讓 g++ 找到需要的 .gcm 檔就是一個滿大的問題了…

g++ 本身似乎也沒有提供相對簡單的參數可以指定 CMI 資料夾路徑(MSVC 是「/ifcSearchDir」、clang 是「-fprebuilt-module-path」),需要透過「Module Mapper」(官方文件)才能做到。

研究了好一陣子,最後是覺得這邊比較簡單的方法,可能會是參考《How to modify gcm.cache path when using gcc-11 to build c++20 modules?》這篇的回答,使用感覺很特別的指令來讓他去設定預設的 CMI 快取資料夾:

'-fmodule-mapper=|@g++-mapper-server -r'$(MAPPER_DIR)

這邊基本上應該就是去執行一個 g++-mapper-server 起來,然後透過「-r」來設定路徑。

但是老實說,Heresy 看不懂這個參數的寫法…尤其是那兩個「'」。

完整的指令會像下面的樣子:

g++ -std=c++20 -fmodules-ts \
    '-fmodule-mapper=|@g++-mapper-server -r'../bmi \
    -c -x c++ MyMath.Types.ixx

這樣去編譯 MyMath.Types.ixx 的話,編譯器就會去 ../bmi 這個資料夾找需要的 .gcm 檔案,同時如果有產生 .gcm 檔的話,也會放到 ../bmi 這個資料夾裡面。
而如果是 header unit 的話,也一樣會有對應的樹狀結構。

這樣一來,../bmi 這個資料夾的性質就和本來的 gcm.cache 一樣,只是會變成是所有專案共用同一個資料夾了。

不過,這樣設定雖然對標準模組沒有問題,但是對於 header unit 還是會有問題,這點就之後再提了。


Makefile:一般的專案

在多專案的環境下,由於很多參數設定需要保持一致,所以這邊是在根目錄下放了一個名為 config.mk 的設定檔:

# c++ compiler
C++  = g++
AR   = ar rcs
 
# Path
PATH_LIB  = ../lib
PATH_BMI  = ../bmi
 
# C++ Flags
CFLAGS      = --std=c++20 -fmodules-ts
CMI_MAPPER  = '-fmodule-mapper=|@g++-mapper-server -r'$(PATH_BMI)
CMODULE     = -c -x c++
 
# Helper functions
GCM_LIST  = $(patsubst %.o,$(PATH_BMI)/%.gcm,$(OBJECTS))

這邊基本上是將指令、參數都設定成為變數,方便之後使用與維護。

而最後的 GCM_LIST 則是用來把 OBJECTS 這個檔案清單(全部都是 .o)產生對應的 CMI 檔案列表,是用來給 make clean 清除檔案用的。

如果以比較複雜的 libMyMath 來看的話,他的 Makefile 內容如下:

include ../config.mk
 
TARGET  = libMyMath.a
 
OBJECTS  = \
  MyMath.o \
  MyMath.Types.o \
  MyMath.Compute.o \
  MyMath.IO.o
 
$(TARGET): $(OBJECTS)
  $(AR) $@ $(OBJECTS)
  cp -f $@ $(PATH_LIB)
 
MyMath.o: MyMath.ixx MyMath.Types.o MyMath.Compute.o
  $(C++) $(CFLAGS) $(CMI_MAPPER) $(CMODULE) MyMath.ixx
 
MyMath.Types.o: MyMath.Types.ixx
  $(C++) $(CFLAGS) $(CMI_MAPPER) $(CMODULE) MyMath.Types.ixx
 
MyMath.Compute.o: MyMath.Compute.ixx MyMath.Types.o
  $(C++) $(CFLAGS) $(CMI_MAPPER) $(CMODULE) MyMath.Compute.ixx
 
MyMath.IO.o: MyMath.IO.ixx MyMath.Types.o
  $(C++) $(CFLAGS) $(CMI_MAPPER) $(CMODULE) MyMath.IO.ixx
 
clean:
  rm -f $(OBJECTS) $(TARGET)
  rm -f $(GCM_LIST) $(PATH_LIB)/$(TARGET)

這邊的建置的指令就盡量使用前面 config.mk 定義的變數來寫了。

而在建置規則上,由於 g++ 編譯模組的時候,會同時產生 .o.gcm 檔案,而這邊算是稍微簡化一點,簡單地拿 .o 當作建置的目標以及相依性的參考。

比如說這邊要產生 Math.IO.o 的規則,就是寫他是依賴 MyMath.IO.ixxMyMath.Types.o 兩個檔案,如果這兩個檔案有被修改,那他就會重新建立。

但是實際上,要編譯 MyMath.IO.ixx 的時候其實並不需要 MyMath.Types.o、而是需要 MyMath.Types.gcm;不過因為 .gcm 的檔案會在別的資料夾、處理上比較麻煩,所以這邊就拿 .o 來湊數了。如果要嚴謹一點,也可以改寫成:

MyMath.IO.o: MyMath.IO.ixx $(PATH_BMI)/MyMath.Types.gcm
  $(C++) $(CFLAGS) $(CMI_MAPPER) $(CMODULE) MyMath.IO.ixx

這部分完整的範例可以參考 GitHub 上的檔案(連結)。


而由於建置 .ixx 檔案的指令基本上也是相同的,所以其實也可以用 pattern rules 的形式來簡化:

include ../config.mk
 
TARGET  = libMyMath.a
 
OBJECTS  = \
  MyMath.o \
  MyMath.Types.o \
  MyMath.Compute.o \
  MyMath.IO.o
 
$(TARGET): $(OBJECTS)
  $(AR) $@ $(OBJECTS)
  cp -f $@ $(PATH_LIB)
 
MyMath.o: MyMath.Types.o MyMath.Compute.o
MyMath.Compute.o: MyMath.Types.o
MyMath.IO.o: MyMath.Types.o
 
%.o: %.ixx
  $(C++) $(CFLAGS) $(CMI_MAPPER) $(CMODULE) $<
 
clean:
  rm -f $(OBJECTS) $(TARGET)
  rm -f $(GCM_LIST) $(PATH_LIB)/$(TARGET)

這樣他就會把所有的 .ixx 都用同樣的指令編譯成 .o;而有額外的相依性,也只要針對個別的建置目標來寫就好了。

而如果要進一步共用的話,則也可以把建置的規則移到 config.mk 去、讓他在所有專案共用。

這部分的寫法可以參考 GitHub 上的另一個分支(連結)。


Makefile:Header unit

Header unit 的部分算是相當麻煩的。

在 Visual Studio 的 IDE 環境下,可以讓他去掃描專案內的程式碼、自動幫有用到的 header 檔案產生 BMI 檔案,算是相當方便。

但是在 g++ 中,似乎沒有類似的功能可以使用?所以這邊並不會去用到 std.cpp 這個檔案,而是得把要處理的 header 檔清單寫在 Makefile 內。

目前這邊搭配 ChatGPT 寫出來的 Makefile 內容是下面的樣子:

include ../config.mk
 
SOURCES := cmath iostream
 
TARGETS := $(addsuffix .gcm, $(SOURCES))
FIND_CMD := find $(PATH_BMI) -name
 
all: $(TARGETS)
 
clean:
  @for target in $(TARGETS); do \
    $(FIND_CMD) $(basename $target) -exec rm -f {} +; \
  done
 
%.gcm:
  $(eval GCM_FILE := $(notdir $@))
  $(eval FILE_PATH := $(shell $(FIND_CMD) $(GCM_FILE)))
  @if [ -z "$(FILE_PATH)" ]; then\
    echo "Generating $(GCM_FILE)"; \
    $(C++) $(CFLAGS) $(CMI_MAPPER) -x c++-system-header $*; \
  else \
    echo "Found $(FILE_PATH), skip generate"; \
  fi

要編譯的 header 檔案清單是 SOURCES,如果還有其他 header 檔案也要轉換只要加在後面就可以了。

建置目標的清單是 TARGETS,這邊會把 SOURCES 的所有檔案都再加上 .gcm、產生一份新的清單;以這邊來說就是「cmath.gcm iostream.gcm」。

而實際建置的規則,就是要針對 TARGETS 所有的 .gcm 都執行同樣的指令了。

但是由於這邊要拿來編譯的檔案是標準函式庫的檔案,所以很難去判斷來源路徑、有沒有更新,所以變成所有的建置目標都沒有相依性。

為了避免每次都要重新建置,這邊的做法是透過 FIND_CMD 這個定義好的指令去 PATH_BMI 找有沒有已經產生出來的 .gcm 檔案,如果有的話就跳過、沒有才真的去建立。

而 clean 的條件也是一樣會去 PATH_BMI 找到檔案名稱符合的 .gcm 檔來刪除。


這邊為了相容於其他函式庫,所以也有考慮指定的 header 可能包含路徑的問題,理論上應該是可以在大部分狀況下運作才對。

比如說如果要自己建置的 Boost 放在「/usr/local/include/」、然後想要建置 boost/format.hpp 的話,就可以把 SOURCES 修改成:

SOURCES := cmath iostream boost/format.hpp

然後再額外加上一行:

CFLAGS += -I /usr/local/include/

這樣就可以告訴編譯器要去哪邊找 header 了。

不過這個 Makefile 也不是完美的。由於這邊要去找建置出來的 .gcm 的方法是仰賴 find 來針對檔名來找,所以如果要建置兩個不同函式庫下同名的 header 檔案(例如 libA/hello.hlibB/hello.h),那應該會因為最後要找的檔名是一樣的而出問題。

不過個人是覺得發生機會應該很低,所以姑且先不管吧。


而這邊目前寫法主要的問題,應該是沒辦法對應自己寫、放在專案資料夾裡的 header。

這部份的原因是 g++ 的 CMI 檔案管理,如果使用下面這樣的指令來建置的話:

g++ -std=c++20 -fmodules-ts -fmodule-header lib.h

lib.h.gcm 會放在 CMI 資料夾下的「,」這個資料夾下,代表是目前的資料夾。

由於 g++ 是透過這種相對路徑來做紀錄,所以在透過 module mapper 設定讓所有專案共用一個 CMI 資料夾的時候,其實就已經把這種相對路徑的概念破壞掉了。

比如說使用傳統 header 的型式的時候,app1 要使用 libStdHeader/lib.h 這個 head 的話,通常會通過相對路徑來設定 include path,以這邊來說就是加上「-I ../」這個編譯參數,讓 g++ 往上一層資料夾去找;但是如果改成 header unit 的時候,這樣的參數他需要的 .gcm 檔案會變成是 ,/,,/libStdHeader/lib.h.gcm,所以會變成找不到檔案的狀況…

目前想到兩個可能的解法:

  • 建立出 CMI 檔案後,自己再去搬移檔案到符合需求的資料夾
  • 把這種 header 檔也當成系統的 header、然後用絕對路徑的方式來處理

這兩種方法手動測試都可以,但是要怎麼寫進 Makefile 就要再研究了。


大致上就是這樣了?由於這邊基本上還只是情境模擬,所以真的到實際案例的狀況下,可能還是會有相當的內容需要修改了。

老實說,目前看來問題比較大的部分可能還是 header unit 的部分了…總覺得真的要以 header unit 來使用的話,可能還會有不少問題要處理?

如果某些函式庫的 header 檔案是設計成有相依性、或是有引入的順序的,那可能就得自己寫一個 header 檔案、建立成 header unit 檔案後匯入使用了。


另外,clang 的部分這邊暫時沒打算寫成 Makefile,不過如果要寫的話有幾點可能要注意:

  • clang 在編譯模組的時候可以用兩段式編譯的方法,先把 .ixx 編譯成 .pcm、再把 .pcm 編譯成 .o;官方的說法是因為編譯成 .o 的時間會比較久、所以這樣做可能會比較容易平行化、讓整個建置的速度更快。(參考
  • clang 在使用 header unit 的時候由於需要個別指定 header 的 .pcm 檔案,編譯參數感覺會很難處理。

Leave a Reply

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *