實踐:GNU構建系統

P_Chou水冗發表於2019-05-11

原文:實踐:GNU構建系統

在上一篇概念:GNU構建系統和Autotool,我對GNU構建系統從使用者視角和開發者視角分別進行了闡述。本篇從我的實踐總結的角度,並闡述如何從頭開始規劃一個基於GNU構建系統的專案。事實上,隨著開發者對跨平臺認知的深入和完善,才能逐漸掌握GNU構建。注意:本文的例子不依賴於任何IDE和編輯器。這樣讀者可以從根本上認識到每個檔案的作用。

安裝autotools

需要安裝的工具包括autoconf、automake、libtool。

目錄結構規劃

首先,我們需要規劃專案的目錄結構。假設,我們的專案叫gnu-build。設想如下目錄結構:

gnu-build
 |---build(用於編譯)
 |---src
      |---common
           |---Makefile.am
           |---pool.c
           |---alloc.c
           |---list.c
           |...
      |---core
           |---Makefile.am
           |---main.c
           |...
      |---test
           |---Makefile.am
           |---test.c
           |...
      |---Makefile.am
 |---configure.ac
 |---Makefile.am
 |---.gitignore

從上面的目錄結構可以看出:

  1. 根目錄有一個configure.ac,這是構建系統的核心檔案之一,描述整個構建的依賴和輸出,是configure指令碼的原型。

  2. 每個目錄(包括根目錄)都有一個Makefile.am,這些檔案是生成Makefile的主要來源。使用Makefile.am的優點是可以結合configure.ac、比手動編寫Makefile方便很多。

  3. src目錄下放置原始碼,原始碼被分成commoncoretestcommon用來實現一些可重用的程式碼,比如通用資料結構,記憶體管理,異常的封裝;core用來放置直接編譯成可執行程式的程式碼,比如main.c等;test用於編寫單元測試程式。

  4. build目錄用於存放編譯過程中的臨時檔案和編譯得到了目標檔案。一般我們總是cdbuild目錄中,並執行../configureconfigure,並在build目錄下make。這樣的話,由configure產生的檔案不會汙染原始碼空間。我們需要做的只是在.gitignore中新增build/

在使用autoreconf的過程中,還將在各個目錄下生成其他的檔案(尤其是根目錄)。現在我們只需要建立上述必要檔案。

configure.ac可以通過在根目錄下執行autoscan程式生成。如果你已經有一些程式碼了,使用autoscan生成configure.ac是個不錯的開始。

configure.ac的基本編寫

通用巨集

每個configure.ac都需要如下兩行。分別說明需要的autoconf的最低版本,以及程式的包名、版本、bug反饋郵件地址。

AC_PREREQ(2.59)
AC_INIT([gnu-build], [1.0], [support@gnubuild.org])

configure.ac通篇幾乎都是採用這種類似函式呼叫的語法編寫,這些稱為巨集的語句,會被autoconf工具識別,並展開成相應的shell指令碼,最終成為configure指令碼。除此之外,也可以混合地直接編寫shell指令碼。autoconf預置了很多實用的巨集,可以減少工作量,後面你將看到巨集的價值。

可以直接編寫shell指令碼,但是推薦儘量使用巨集。因為shell程式有很多種(sh,bash,ksh,csh…),想要寫出可移植的shell並不是件容易的事情。

接著,通常使用AC_CONFIG_SRCDIR來定位一個原始碼檔案,如此一來,autoconf程式會檢查該檔案是否存在,以確保autoconf的工作目錄的正確性。這裡,我們指向src/core/main.c

AC_CONFIG_SRCDIR([src/core/main.c])

定義輸出的巨集

一般來說,都會編寫一個header輸出定義。這是我們用到的第一個輸出指令。輸出指令告訴configure,需要生成哪些檔案。AC_CONFIG_HEADERS的含義是在指定的目錄生成.h,一般叫做config.h,你也可以指定其他名字。

AC_CONFIG_HEADERS([src/common/config.h])

那麼這個config.h究竟有什麼用呢?回憶一下,configure程式的主要目的是檢測目標平臺的軟硬體環境,從而在實際呼叫make命令編譯程式前,對編譯工作進行一個預先的配置,這裡的配置落實到底,主要就是生成Makefileconfig.h

Makefile.am --> Makefile.in --> Makefile
                             |
                           configure*
                             |
                config.h.in --> config.h

那麼我們的程式必需要通過某種方式,得知環境的不同,從而通過預編譯做出響應。這裡的響應主要分兩塊:

  1. 對於原始碼而言,通過config.h中的巨集定義,來改變編譯行為。

  2. 對於Makefile.am而言,通過configure.ac匯出的變數,來動態改變Makefile。

在後面的敘述中,可以通過程式碼體會這兩點。所以這裡,為了讓我們的原始碼有能力根據環境來改變編譯行為,生成config.h通常是必要的。

另一個輸出巨集是AC_CONFIG_FILES,針對這個例子,告訴autoconf,我們需要輸出Makefile檔案:

AC_CONFIG_FILES([Makefile
                 src/Makefile
                 src/core/Makefile
                 src/common/Makefile
                 src/test/Makefile
                 ])
AC_OUTPUT

注意到每個目錄都需要由對應的Makefile檔案,這是automake多目錄組織Makefile的通用做法。後面會講到如何編寫各個目錄下的Makefile.am

AC_CONFIG_FILES一般跟AC_OUTPUT一起寫在configure.ac的最後部分。

automake宣告

為了配合automake,需要用AM_INIT_AUTOMAKE初始化automake:

AM_INIT_AUTOMAKE([foreign])

這裡foreign是個可選項,設定foreign跟呼叫automake --foreign是等價的,前一篇有講到。

libtool宣告

配合使用libtool,需要加入LT_INIT,這樣autoreconf會自動呼叫libtoolize

LT_INIT

編譯器檢查

configure可以幫助我們檢查編譯和安裝過程中需要的系統工具是否存在。一般在進行其他檢查前,先做此類檢查。例如下面是一些常用的檢查:

# 宣告語言為C
AC_LANG(C)

# 檢查cc
AC_PROG_CC

# 檢查預編譯器
AC_PROG_CXX

# 檢查ranlib
AC_PROG_RANLIB

# 檢查lex程式,gnu下通常叫flex
AC_PROG_LEX

# 檢查yacc,gnu下通常叫bison
AC_PROG_YACC

# 檢查sed
AC_PROG_SED

# 檢查install程式
AC_PROG_INSTALL

# 檢查ln -s
AC_PROG_LN_S

針對這個例子我們只需要檢查cccxx就可以了。

Makefile.am的基本編寫

Makefile.am檔案是一種更高層次的Makefile,抽象程度更高,比Makefile更容易編寫,除了相容Makefile語法外,通常只需包含一些變數定義即可。automake程式負責解析,並生成Makefile.in,而Makefile.in從表現上與Makefile已經十分接近,只差變數替換了。configure指令碼執行後,Makefile.in將最終轉變成Makefile。

子目錄引用

在本例中每個目錄下都有Makefile.am。根目錄的Makefile.am生成的Makefile將是make程式的預設入口,但是根目錄實際上並不包含任何需要構建的檔案。對於需要引用子目錄的Makefile來構建的時候,使用SUBDIRS羅列包含其他Makefile.am的子目錄。因此,對於根目錄的Makefile.am只需要寫一行:

SUBDIRS = src

同理,src目錄下的Makefile.am只需要

SUBDIRS = common src test

定義目標

對於包含有原始碼檔案的目錄。首先,我們需要定義編譯的目標,目標可能是庫檔案或可執行檔案,目標又分為需要安裝和不需要安裝兩種。例如對於common目錄
下的原始碼,我們希望生成一個不需要安裝的庫檔案(使用libtool),因為這個庫檔案只在本專案內使用,那麼common/Makefile.am應當這樣寫:

noinst_LTLIBRARIES = libcommon.la
libcommon_la_SOURCES = pool.c alloc.c list.c

定義了一個目標libcommon.la。由於使用libtool,所以庫檔案必須以lib開頭,字尾為.la

目標的基本格式為where_PRIMARY = targets ... where表示安裝位置,可選擇bin、lib、noinst、check(make check時構建),還可以自定義。我們著重討論前三種:

  • bin:表示安裝到bindir目錄下,這種情況下會編譯出動態庫

  • lib:表示安裝到libdir目錄下,這種情況下會編譯出動態庫

  • noinst:表示不安裝,這種情況下會編譯出靜態庫,在其他目標引用該目標時將進行靜態連結

PRIMARY可以是PROGRAMS LIBRARIES LTLIBRARIES HEADERS SCRIPTS DATA。著重討論前三種:

  • PROGRAMS:表示目標是可執行檔案

  • LIBRARIES:表示目標是庫檔案,通過字尾來區別靜態庫或動態庫

  • LTLIBRARIES:表示是libtool庫檔案,統一字尾為.la

與Makefile的思想一樣,目標的生成需要定義來源,通常目標是有一些源程式檔案得到的。Makefile.am中只需定義xxx_SOURCES,後面跟隨構建xxx這個目標需要的原始碼檔案列表即可。注意到xxx是目標的名字,並且.字元需要使用_代替。

定義編譯選項

core目錄下需要生成可執行目標,但是在連結時,需要用到libcommon.la,此時core/Makefile.am可以寫成

bin_PROGRAMS = gnu-build
GNU_BUILD_SOURCES = main.c
GNU_BUILD_LIBADD = $(top_builddir)/src/common/libcommon.la

這裡多了一行GNU_BUILD_LIBADDtarget_LIBADD的形式表示為target新增庫檔案的引用,這種引用是靜態的還是動態的取決於引用的庫檔案是否支援動態庫,如果支援動態庫,libtool優先採用動態連結。而由於libcommon.la指定為noinst,所以不可能以動態連結的形式存在,這裡必然是靜態連結。

$(top_builddir)引用的是make發生時的工作目錄,上文提到,我們將在build目錄下進行構建,那麼庫檔案會生成在build目錄下,而不是原始碼根目錄下,所以$(top_builddir)實際就是gnu-build/build目錄,而這樣可以很好的支援在另一個目錄中編譯程式。與之相對應的是$(top_srcdir)對應的是原始碼的根目錄,即gnu-build目錄。

還有多個可以配置用於改變編譯和連結選項的配置項:

  • xxx_LDADD:為連結器增加引數,一般用於第三方庫的引用。比如-L -l

  • xxx_LIBADD:宣告庫檔案引用,一般對於本專案中的庫檔案引用採用這種形式。

  • xxx_LDFLAG:連結器選項

  • xxx_CFLAGS:c編譯選項,如-D -I

  • xxx_CPPFLAGS:預編譯選項

  • xxx_CXXFLAGS: c++編譯選項

如果xxx是AM,則表示全域性target都採用這個選項。

安裝路徑

剛剛提到的bindirlibdir是configure目錄體系下的,類似的路徑還有:

prefix                /usr/local
exec-prefix            {prefix}
bindir                {exec-prefix}/bin
libdir                {exec-prefix}/lib
includedir            {prefix}/include
datarootdir            {prefix}/share
datadir             {datarootdir}
mandir                {datarootdir}/man
infodir                {datarootdir}/info
...

可以看到prefix在這裡的地位是一個頂層的路徑,其他的路徑直接或間接與之有關。而prefix的預設值為/usr/local。所以可執行程式預設總是安裝在/usr/local/bin。使用者總是可以在呼叫configure指令碼時通過--prefix指定prefix。更詳細的路徑列表可以通過./configure --help瞭解。

開始構建

填充一些原始碼後,就可以使用autoreconf了,只需要在根目錄下執行autoreconf --install即可。

[root@xxx gnu-build]# autoreconf --install

前一篇中,對autoreconf的整個過程和產生的檔案做了詳盡的分析和闡述,讀者也應該十分清楚這裡將得到若干Makefile.incommon/config.h.in檔案。

如果這個過程順利的話,就可以在build目錄下構建了:

# cd build
# ../configure
# make

這裡configure後,會在build目錄下生成對應位置的Makefile和common/config.h檔案,而不是生成在原始碼目錄中從而汙染原始碼

至此,你已經完成了一個專案的基本構建框架,後面的事情,就是逐步完善構建對環境的依賴。

在configure.ac中配置環境檢查

autoconf為程式設計師提供的最為重要的功能就是提供了一種便捷、穩定、可移植的方式,讓程式能在特定目標平臺和目標環境上安全的編譯執行程式。不過,autoconf只是提供了一些巨集,用來簡化環境檢查。而究竟要檢查些什麼,如何合理的利用這些巨集完成目的,依舊是需要大量的積累的。筆者在這裡對一些常用的巨集進行一些介紹。

可執行檔案檢查

有些第三方庫在安裝到系統後,會附帶安裝若干可執行程式,並可在環境變數的支援下直接執行。有時,我們通過檢查此類可執行程式是否存在,來初步判斷該第三方庫是否已經安裝在目標平臺。其中一種常用的巨集是AC_CHECK_PROGS

# 宣告一個變數PERL,檢查perl程式是否存在並可執行
# 如果不存在$PERL變數將是NOTFOUND,如果存在$PERL變數將是perl
AC_CHECK_PROGS([PERL], [perl], [NOTFOUND])

# 宣告一個變數TAR,檢查tar和gtar程式是否存在並可執行
# 如果不存在$TAR變數將是:,如果存在,第一個可用的程式名將賦值給$TAR
AC_CHECK_PROGS([TAR], [tar gtar], [:])

GNU軟體有一種利用pkg-config,來進行自描述的機制。即可以通過註冊軟體自身(通常提供庫檔案的軟體),讓pkg-config能夠返回庫檔案的安裝路徑等資訊,以便以一種統一的方式提供給呼叫程式。有些庫軟體附帶有獨立的config程式,比如pcre-configapr-1-config。如果對這類庫提供軟體需要檢查依賴和編譯連結,通常可以通過AC_CHECK_PROGS來檢查config程式,從而得到編譯連結選項。

列印訊息巨集

列印訊息可以作為除錯手段,同時也可以在使用者在configure過程中,給予提示資訊。

# error將終止configure
AC_MSG_ERROR([zlib is required])

# warn不會終止configure
AC_MSG_WARN([zlib is not found, xxx will not be support.])

注意到AC_MSG_ERROR將中斷configure的執行,一般用於必需的編譯環境無法滿足時。

庫檢查巨集

檢查某庫是否存在是最重要的功能,因為我們程式往往需要這些庫,甚至是庫中的某個函式的支援才能正確的執行。

使用AC_CHECK_LIB檢查庫以及其中的函式是否存在,該巨集的原型為:

AC_CHECK_LIB (library, function, [action-if-found],[action-if-not-found], [other-libraries])
  • library:需要檢查的庫名,無需lib字首,比如為了檢查libssl是否存在,這裡需要傳入ssl

  • function:這個庫中的某個函式名

  • action-if-found:如果找到執行某個動作,這個動作可以是另一個巨集,可以是shell指令碼。如果不指定這個引數,預設在LIBS環境變數中增加-l選項,從而將在連結過程中將這個庫連結進來。比如-lssl。並且在config.h中定義一個巨集HAVE_LIBlibrary,例如HAVE_LIBSSL。我們的程式碼可以根據這個巨集得知當前編譯環境是否提供libssl

  • action-if-not-found:如果找不到則執行某個動作

通過下面幾個巨集可以檢查系統是否包含某些標頭檔案,以及是否支援某些函式:

  • AC_CHECK_FUNCS:檢查是否支援某些函式。作為檢查的副作用,在config.h中會定義一個巨集HAVE_funcs(全大寫)

  • AC_CHECK_HEADERS:檢查是否支援某些標頭檔案。作為檢查的副作用,在config.h中會定義一個巨集HAVE_header_H(全大寫)

來舉個例子,大家知道libiconv是一個可以在不同字符集間進行轉化的庫,如果我們的程式希望能夠在不同字符集間轉化的字串的話,可以使用該庫。然而,在不同平臺上,該庫的移植方式有些區別。

gnu的標準c庫(glibc)在很早的時候就把libiconv整合到了glibc中,因此在linux上可以無需額外的庫支援即可使用iconv。然而,在非linux上,很可能需要額外的libiconv庫。那麼如果在非linux的平臺上編寫可移植的程式,可以參考如下的巨集組合:

AC_CHECK_FUNCS(iconv_open, HAVE_ICONV=yes, [])
if test "x$HAVE_ICONV" = "xyes"; then
     AC_CHECK_HEADERS(langinfo.h, [], AC_MSG_WARN([langinfo.h not found]))
     AC_CHECK_FUNCS([nl_langinfo], [], [AC_MSG_WARN([nl_langinfo not found])])
else
    AC_CHECK_LIB([iconv], [libiconv_open], [HAVE_ICONV=yes], [AC_MSG_WARN([no iconv found, will not build xm_charconv])])
    if test "x$HAVE_ICONV" = "xyes"; then
        LIBICONV="-liconv"
        SAVED_LIBS=$LIBS
        LIBS="$LIBS $LIBICONV"
        AC_CHECK_HEADERS(langinfo.h, 
                     AC_CHECK_FUNCS([nl_langinfo], [], [AC_MSG_ERROR([nl_langinfo not found in your libiconv])]), 
                     AC_CHECK_FUNCS([locale_charset], [], [AC_MSG_ERROR([no langinfo.h nor locale_charset found in libiconv])]))
        LIBS=$SAVED_LIBS
    fi
fi

在這個例子中,我們可以看到許多技巧。我們來逐一解讀一下:

  1. 首先通過AC_CHECK_FUNCS檢查iconv_open函式,如果在Linux平臺上,通常該函式可以在沒有任何額外庫的情況下提供,所以HAVE_ICONV這個臨時變數將設定為yes

  2. 接著通過shell的if測試判斷臨時變數HAVE_ICONV是否為yes

  3. 如果已經檢測到iconv,那麼進一步檢查langinfo.h標頭檔案和nl_langinfo函式,無論是否能檢查通過,由於使用了AC_MSG_WARN,所以configure並不會失敗退出,最多隻是提示使用者警告。更重要的是,我們可以通過config.h中的巨集,在程式碼中得知是否支援標頭檔案和函式,從而調整編譯分支。具體的在這個例子中這兩個巨集分別為HAVE_LANGINFO_HHAVE_NL_LANGINFO

  4. 在非linux下可能需要額外的libiconv庫,所以在else分支中,立刻採用AC_CHECK_LIB檢測iconv庫,以及其中的libiconv_open函式。同樣的,如果存在,HAVE_ICONV這個臨時變數將設定為yes

  5. 在接下來的if測試中,使用到了$LIBS變數,這是一個由編譯器支援的變數,表示在連結階段的額外庫引數。當我們檢測到libiconv後,就給這個變數臨時地新增-liconv。這樣接下來的AC_CHECK_FUNCS時,可以利用$LIBS在額外的庫中查詢函式。

  6. 檢查langinfo.h標頭檔案,如果存在則再檢查nl_langinfo函式;如果不存在,則檢查locale_charset函式。從邏輯上看,要麼langinfo.hnl_langinfo同時存在,要麼有locale_charset函式,否則就終止configure。

  7. 最後重置$LIBS變數。

變數匯出

configure指令碼的檢測結果應當有兩個主要出口,一是config.h,它幫助我們在原始碼中建立編譯分支;二是Makefile.am,我們可以在Makefile.am中基於這些匯出的變數,改變構建方式。

有些巨集可以自動幫我們匯出到config.h,關於這一點上文已經有所闡述了。而希望匯出到Makefile.am則需要我們自己手動呼叫相關巨集。這裡主要有兩個巨集:

  • AC_SUBST:將一個臨時變數,匯出到Makefile.am。實際是在Makefile.in中宣告一個變數,並且在生成Makefile時,由configure指令碼對變數的值進行替換。

  • AM_CONDITIONAL:由automake引入,可進行一個條件測試,從而決定是否匯出變數。

例如,針對上面iconv的例子,我們有個臨時變數HAVE_ICONV,如果iconv在當前平臺可用,此時HAVE_ICONV將會是yes。所以可以使用AM_CONDITIONAL匯出變數:

AM_CONDITIONAL([HAVE_ICONV], [test x$HAVE_ICONV != x])

或者無論如何都匯出HAVE_ICONV

AC_SUBST(HAVE_ICONV)

在Makefile.am中,我們可以對變數進行引用,這樣xm_charconv.la就將在HAVE_ICONV匯出的情況下構建:

if HAVE_ICONV
  xm_charconv_LTLIBRARIES = xm_charconv.la
  ...
endif

提供額外使用者引數支援

很多軟體都支援使用者在configure階段,可通過--with-xxx --enable-xxx等命令列選項對軟體進行模組配置或編譯配置。以--with-xxx為例,我們需要AC_ARG_WITH巨集:

AC_ARG_WITH(configfile,
  [  --with-configfile=FILE   default config file to use],
  [ ZZ_CONFIGFILE="$withval"],
  [ ZZ_CONFIGFILE="${sysconfdir}/zz.conf"]
  )

AC_SUBST(ZZ_CONFIGFILE)

FILE定義該引數的值應當是一個檔案路徑(DIR要求一個目錄路徑),該巨集需要提供一個預設值,這個例子中是${sysconfdir}/zz.conf${sysconfdir}引用了${prefix}/etc,而$withval從命令列中引用--with-configfile的值。

最後我們通過AC_SUBST匯出一個臨時變數。

上一節提到,匯出的臨時變數可以在Makefile.am中引用,所以我們可以在Makefile.am中通過-D傳遞給程式碼,從而在程式碼中通過巨集來引用:

CFLAGS    += -DCONFIGFILE="$(ZZ_CONFIGFILE)" 

總結

本文以一個例子,一步步使用GNU構建系統來建立一個專案,並介紹了一些常用的檢測巨集。事實上,autotool還有很多巨集,甚至可以自定義巨集。能否合理利用autotool取決於程式設計師對可移植性這個問題的經驗和理解。

相關文章