Phần 2 của series Makefile cơ bản, phần 1 mình đã nói về cách tạo ra một Makefile cơ bản nhất, một số rule để compile chương trình count_word. Trong đó mỗi rule sẽ xác định một target. Mỗi file target này thì phụ thuộc vào việc thiết lập prerequisite. Khi được yêu cầu update target, make sẽ thực thi các command script của các rule nếu prerequisite của các prerequisite này có sự thay đổi.

Vì target của một rule có thể là tham chiếu như một prerequisite trong rule khác, tập hợp các target và prerequisite tạo thành một chuỗi hoặc biểu đồ phụ thuộc (dependency graph). Xây dựng và xử lý dependency graph này để cập nhật target là tất cả những gì make phải làm.

Bởi vì rule rất quan trọng trong make, do đó có rất nhiều rule khác nhau

  • Explicit rules
  • Pattern rules
  • Implicit rules
  • Static pattern rules
  • Suffix rules

Bài này đi rule đầu tiên explicit rule cho nhẹ nhàng tình cảm nha ae

Explicit rules

Đây gọi là những quy tắc rõ ràng, rõ như ví dụ dưới đây

vpath.o variable.o: make.h config.h getopt.h gettext.h dep.h

Ta thấy vpath.o và variable.o đều phụ thuộc vào các file header của C (file *.h), cách biểu diễn dưới đây cũng tương tự như cách bên trên

vpath.o: make.h config.h getopt.h gettext.h dep.h
variable.o: make.h config.h getopt.h gettext.h dep.h

Cách này thì 2 target sẽ được quản lý một cách độc lập. Nếu source C có thay đổi thì make sẽ update object file bằng việc thực thi command tương ứng.

Một rule không được định nghĩa để làm “tất cả cùng một lúc”, mỗi lần make có gì mới thì nó lại add vào dependency graph. Với trường hợp bạn dựa vào rất nhiều file để tạo ra target thì có một cách như sau để có thể giúp makefile của bạn có thể dễ nhìn hơn

vpath.o: vpath.c make.h config.h getopt.h gettext.h dep.h
vpath.o: filedef.h hash.h job.h commands.h variable.h vpath.h

Với những trường hợp phức tạp hơn, danh sách prerequisite có thể được quản lý theo một cách khác như sau

# Make sure lexer.c is created before vpath.c is compiled.
vpath.o: lexer.c
...
# Compile vpath.c with special flags.
vpath.o: vpath.c
$(COMPILE.c) $(RULE_FLAGS) $(OUTPUT_OPTION) $<
...
# Include dependencies generated by a program.
include auto-generated-dependencies.d

Rule đầu tiên cho ta thấy rằng vpath.o target phải được update khi lexer.c có update, rule này yêu cầu prerequisite phải luôn được update trước khi target được update.

Sau đó việc compile rule cho vpath.o sẽ được đặt ở 1 rule khác. Command cho rule này sử dụng ba biến với dấu $ ở trước, biến nó là gì thì mình sẽ nói ở bài sau, hiện tại chúng ta cứ mặc định có $ là có biến :D, make đã bắt đầu biến hình phức tạp hơn xíu rồi

Wildcards

Wildcard hay còn gọi là các kí tự đại diện. Makefile bao gồm một list rất nhiều file, để giảm thiểu quá trình thực thi make hỗ trợ wildcard, nói đơn giản nó hỗ trợ các lệnh Bourne shell (sh) như ~, *, ?, […], và [^…].

Wildcard có thể tự động mở rộng bởi make khi nó nằm trong target, prerequisite, hoặc command script, nó rất hữu dụng để tạo ra các makefile có khả năng tương thích, ví dụ thay vì list tất cả các file trong một chương trình rõ ràng theo kiểu file1,file2,file3,…. thì bạn có thể dùng wildcard là *

prog: *.c
    $(CC) -o $@ $^

Tất nhiên là dùng dao thì phải cẩn thận không có ngày đứt tay, dùng wildcard cũng thế, ví dụ

*.o: constants.h

Mục đích rất rõ ràng, tất cả các object sẽ phụ thuộc vào header constants.h, nhưng hãy xem xét làm thế nào để thực hiện nó trong một thư mục trống mà không có object nào

: constants.h

Mô tả lệnh make ở trên sẽ không tự tạo ra lỗi nào cho chính nó, tuy nhiên nó sẽ không cung cấp dependency(phụ thuộc) mà người dùng muốn, cách thích hợp của rule này là thực hiện wildcard trên source file và chuyển nó thành list object file.

Có một lưu ý: là vì wildcard cũng được dùng trong shell, nên với những dự án phức tạp có cả makefile và shell script cùng có wildcard được thực thi thì kết quả ra sẽ khác nhau. Ngoài ra việc dùng wildcard để select file trong chương trình cũng không được khuyến khích vì có thể một source giả cùng tên sẽ được link vào trong chương trình chính.

Phony Target

Hiện tại thì tất cả targets và prerequisites đều có file được tạo ra hoặc update. Đây là trường hợp thông thường, nhưng sẽ hữu ích hơn cho target khi nó có label biểu thị cho một command, với những ví dụ ở trên cũng như bài trước thì label mặc định là all, một chuẩn khác của phony target là clean:

clean:
    rm -f *.o lexer.c

Phony target sẽ luôn được thực thi bởi vì lệnh liên quan tới rule sẽ không tạo ra target name, quan trọng hơn, make sẽ không phân biệt được đâu là file target, đâu là phony target. Nếu ta đổi tên của phony target thành tên của một file, make sẽ đưa file này với phony target name vào trong dependency graph. Ví dụ ta có một file tên là clean, khi thực hiện lệnh make clean thì sẽ không phải là xóa đi các object,.. mà là tạo ra object mới

$ make clean
make: 'clean' is up to date.

Vì hầu hết phony không có prerequisite, clean target sẽ luôn được xem là up to date và không bao giờ được thực thi

Để tránh lỗi này thì GNU make có special target .PHONY, để nói cho make rằng target này là không có thực. Bất kỳ target nào đều có thể được xem là phony bằng việc thêm prerequisite .PHONY, ví dụ

.PHONY: clean
clean:
    rm -f *.o lexer.c

Phony target còn có thể được xem là shell script bên trong makefile. Tạo một phony target một prerequisite cho target khác, ví dụ chúng ta cần kiểm tra không gian còn lại của ổ cứng trước khi thực hiện việc lưu trữ

.PHONY: make-documentation
make-documentation:
    df -k . | awk 'NR = = 2 { printf( "%d available\n", $$4 ) }'
    javadoc ...

Vấn đề ở đây là cuối cùng chúng ta có thể chỉ định các lệnh df và awk nhiều lần theo các target khác nhau, đây là vấn đề bảo trì vì có thể sẽ phải thay đổi mọi trường hợp nếu chúng ta gặp một df trên hệ thống khác có định dạng khác.
Thay vào đó, chúng ta có thể đặt dòng df trong phony target của chính nó

.PHONY: make-documentation
    make-documentation: df
javadoc ...
.PHONY: df
df:
    df -k . | awk 'NR = = 2 { printf( "%d available\n", $$4 ) }'

Giờ thì chúng ta có thể gọi target df của mình trước khi tạo document bằng cách biến df thành prerequisite của make-documentation. Điều này hoạt động tốt bởi vì make-documentation cũng là một target phony. Bây giờ ta có thể dễ dàng sử dụng lại df trong các target khác.

Output của make có thể gây nhầm lẫn khi đọc và debug. Có một số lý do cho việc này: makefiles được viết từ trên xuống nhưng các lệnh được thực thi bằng cách make từ dưới lên; Ngoài ra, không có dấu hiệu cho thấy rule nào hiện đang được thực hiện. Output của make có thể được đọc dễ dàng hơn nhiều nếu các target chính được comment trong output. Phony target là một cách hữu ích để thực hiện điều này. Dưới đây là một ví dụ được lấy từ bash makefile:

$(Program): build_msg $(OBJECTS) $(BUILTINS_DEP) $(LIBDEP)
    $(RM) $@
    $(CC) $(LDFLAGS) -o $(Program) $(OBJECTS) $(LIBS)
    ls -l $(Program)
    size $(Program)
.PHONY: build_msg
build_msg:
    @printf "#\n# Building $(Program)\n#\n"

Có một số phony target chuẩn như sau

Target Function
all Thực hiện build toàn bộ
install Tạo bản cài đặt của ứng dụng từ việc compile binary
clean Xóa binary file được tạo từ source
distclean Xóa tất cả các file được tạo ra mà ko nằm trong source chính
TAGS Tạo bảng tag để editor dùng
info Tạo GNU info file từ textinfo source
check Chạy bất kỳ test nào tương ứng với chương trình

Empty target

Các empty target tương tự như các phony target ở chỗ chính target được sử dụng như một thiết bị để tận dụng các khả năng make. Các phony target luôn lỗi thời, vì vậy chúng luôn thực thi và chúng luôn khiến cho sự phụ thuộc của chúng (mục tiêu liên quan đến prerequisite) được làm lại. Nhưng giả sử chúng ta có một số command, không có output file, đôi khi chỉ cần thực hiện và chúng ta có muốn cập nhật phụ thuộc không? Đối với điều này, chúng ta có thể tạo một quy tắc có target là một tệp trống (đôi khi được gọi là cookie):

prog: size prog.o
    $(CC) $(LDFLAGS) -o $@ $^
size: prog.o
    size $^
    touch size

Lưu ý rule cho size sử dụng lệnh touch để tạo một tệp trống có tên size sau khi hoàn thành.
Tệp trống này được sử dụng cho timestamp của nó để make sẽ chỉ thực hiện rule của size khi prog.o đã được cập nhật. Thêm vào đó, prerequisite của size là prog sẽ không bắt cập nhật prog trừ khi tệp đối tượng của nó mới hơn.

Các tập tin trống đặc biệt hữu ích khi kết hợp với biến tự động $?, biến này là gì ta sẽ tìm hiểu tiếp ở phần dưới đây

Variables

Như đã chém gió ở trên syntax cơ bản nhất của biến sẽ có dạng

$(variable-name)

Một tên biến phải được bao quanh bởi $ () để được được make nhận diện. Trong trường hợp đặc biệt, một tên biến ký tự không yêu cầu dấu ngoặc đơn. Makefile thường sẽ xác định nhiều biến, nhưng cũng có nhiều biến đặc biệt được xác định tự động bằng make. Một số có thể được thiết lập bởi người dùng để kiểm soát hành vi của Make trong khi một số khác được thiết lập bằng cách thực hiện để giao tiếp với người dùng Makefile.

Automatic Variables

Biến tự động được đặt bởi make sau khi rule được khớp. Nó cung cấp quyền truy cập vào các yếu tố từ danh sách target và prerequisite vì thế bạn không thể chỉ định rõ ràng bất kỳ tên tệp nào. Chúng rất hữu ích để tránh trùng lặp code, nhưng rất quan trọng khi xác định các pattern rule.

Có sáu biến automatic “core”

$@ Tên tệp đại diện cho target
$% Phần tử tên tệp của đặc tả thành viên lưu trữ.
$< Tên của prerequisite đầu tiên.
$? Tên của tất cả các prerequisite mới hơn target, được phân cách bằng dấu cách.
$^ Tên tệp của tất cả các prerequisite, được phân cách bằng dấu cách. Danh sách này đã loại bỏ tên tệp trùng lặp vì hầu hết các mục đích sử dụng, chẳng hạn như biên dịch, sao chép, v.v., không muốn sao chép.
$+ Tương tự như $^, đây là tên của tất cả các prerequisite được phân tách bằng dấu cách, ngoại trừ $+ bao gồm các bản sao. Biến này được tạo cho các tình huống cụ thể như đối số cho các trình liên kết trong đó các giá trị trùng lặp có ý nghĩa.
$* $ * Phần gốc của tên tệp đích. Phần gốc thường là một tên tệp không có hậu tố của nó.

Dưới đây là makefile với tên tệp rõ ràng được thay thế bằng automatic variable thích hợp.

count_words: count_words.o counter.o lexer.o -lfl
    gcc $^ -o $@
 
count_words.o: count_words.c
    gcc -c $<
 
counter.o: counter.c
    gcc -c $<
 
lexer.o: lexer.c
    gcc -c $<
 
lexer.c: lexer.l
    flex -t $< > $@

Tìm file với VPATH và vpath

Các ví dụ từ trước giờ của chúng ta tất cả các mã nguồn đều nằm trong một thư mục. Các chương trình thực tế thì thường phức tạp hơn (có khi nào bạn bỏ tất cả các file code của mình vào trong 1 thư mục không?). Giờ thì để cấu trúc lại ví dụ ở trên ta tạo ra một bố cục tệp thực tế hơn bằng cách tạo ra function tên counter với 2 file counter.c

#include <lexer.h>
#include <counter.h>
 
void counter( int counts[4] )
{
    while ( yylex() );
 
    counts[0] = fee_count;
    counts[1] = fie_count;
    counts[2] = foe_count;
    counts[3] = fum_count;
}

Và tạo ra file header counter.h

#ifdef COUNTER_H_
#define COUNTER_H_
 
extern void
counter( int counts[4] );
 
#endif

Đặt các khai báo cho các ký hiệu lexer.l trong lexer.h:

#ifndef LEXER_H_
#define LEXER_H_
 
extern int fee_count, fie_count, foe_count, fum_count;
extern int yylex( void );
 
#endif

File count_words.c như cũ

#include <stdio.h>
#include <counter.h>
 
int main( int argc, char ** argv )
{
    int counts[4];
    counter( counts );
    printf( "%d %d %d %d\n", counts[0], counts[1], counts[2], counts[3] );
    exit( 0 );
}

Tạo xong các file rồi ta bố trí lại cấu trúc thư mục thôi, thư mục của chúng ta sẽ có dạng như sau:

.
├── Makefile
├── include
│   ├── counter.h
│   └── lexer.h
└── src
    ├── count_words.c
    ├── counter.c
    └── lexer.l

Vì các source của ta hiện bao gồm các header file, các dependencies mới này sẽ được ghi lại trong makefile để khi header file được sửa đổi, tệp đối tượng tương ứng sẽ được cập nhật.

Xem makefile và make cái nhẹ thôi

count_words: count_words.o counter.o lexer.o -lfl
    gcc $^ -o $@
 
count_words.o: count_words.c
    gcc -c $<
 
counter.o: counter.c
    gcc -c $<
 
lexer.o: lexer.c
    gcc -c $<
 
lexer.c: lexer.l
    flex -t $< > $@

Kết quả, tạch tach tạch

$ make
make: *** No rule to make target 'count_words.c', needed by 'count_words.o'.  Stop.

Prerequisite đầu tiên là count_words.o. Ta thấy nó thiếu mất file .c, ban đầu explicit rule để tạo ra file .o từ file .c. Nhưng tại sao lại không tìm thấy nó ? Đơn giản thôi, source giờ nằm ở trong src rồi chứ có còn nằm ở ngoài nữa đâu, vậy làm sao để chỉ cho make source nó nằm chỗ nào ? Có một cách, đó là dùng VPATH bằng cách thêm đoạn sau vào makefile

VPATH = src

Tiếp tục make lại

$ make
gcc -c src/count_words.c
src/count_words.c:2:10: fatal error: counter.h: No such file or directory
 #include <counter.h>
          ^~~~~~~~~~~
compilation terminated.
Makefile:6: recipe for target 'count_words.o' failed
make: *** [count_words.o] Error 1

Tạch tạch tạch lần 2 :D, make bây giờ cố gắng biên dịch thành công tệp đầu tiên, điền chính xác đường dẫn tương đối đến source. Có một lý do khác để sử dụng các automatic variable: make không thể sử dụng đường dẫn thích hợp đến source nếu bạn hardcode tên tệp. Không may ở đây, quá trình biên dịch bị tạch vì gcc có thể tìm thấy header file. Ta khắc phục sự cố mới nhất này bằng cách tùy chỉnh với tùy chọn -I thích hợp:

CPPFLAGS = -I include

Makefile cuối cùng

VPATH    = src
CPPFLAGS = -I include
 
count_words: count_words.o counter.o lexer.o -lfl
    gcc $^ -o $@
 
count_words.o: count_words.c counter.h
    gcc $(CPPFLAGS) -c $< -o $@
 
counter.o: counter.c counter.h lexer.h
    gcc $(CPPFLAGS) -c $< -o $@
 
lexer.o: lexer.c lexer.h
    gcc $(CPPFLAGS) -c $< -o $@
 
lexer.c: lexer.l
    flex -t $< > $@

Kết quả giờ là thành công thôi

$ make
gcc -I include -c src/count_words.c -o count_words.o
src/count_words.c: In function ‘main’:
src/count_words.c:7:5: warning: implicit declaration of function ‘counter’ [-Wimplicit-function-declaration]
     counter( counts );
     ^~~~~~~
src/count_words.c:9:5: warning: implicit declaration of function ‘exit’ [-Wimplicit-function-declaration]
     exit( 0 );
     ^~~~
src/count_words.c:9:5: warning: incompatible implicit declaration of built-in function ‘exit’
src/count_words.c:9:5: note: include ‘<stdlib.h>’ or provide a declaration of ‘exit’
gcc -I include -c src/counter.c -o counter.o
flex -t src/lexer.l > lexer.c
gcc -I include -c lexer.c -o lexer.o
gcc count_words.o counter.o lexer.o /usr/lib/x86_64-linux-gnu/libfl.so -o count_words

Warning hơi nhiều mà thôi ko sao, fix sau, build được cái là ăn mừng thôi, hú yeah.

Biến VPATH đã giải quyết vấn đề tìm kiếm file của ta ở trên, nhưng nó cũng có hạn chế. make sẽ tìm kiếm từng thư mục cho bất kỳ tập tin nó cần. Nếu một tệp cùng tên tồn tại ở nhiều vị trí trong danh sách VPATH, nó sẽ lấy tệp đầu tiên. Đôi khi điều này có thể là một vấn đề.
Để đạt được mục tiêu cần có thay đổi một chút cú pháp

vpath pattern directory-list

VPATH ở trên có thể được viết lại thành

vpath %.c src
vpath %.h include

Bây giờ ta đã nói make nên tìm kiếm các tệp .c trong thư mục src và .h trong thư mục include (vì vậy chúng tôi có thể xóa include/ khỏi các prerequisite của header file) . Trong các ứng dụng phức tạp hơn, điều này có thể tiết kiệm rất nhiều thời gian đau đầu để debug

Tạm kết

Vậy là tạm xong được 1 rule đầu tiên, coi như sơ bộ ta đã nắm được một số khái niệm cơ bản như Contents, Explicit rules, Wildcards, Phony Target, Empty target, Variables, Automatic Variables, tìm file với VPATH và vpath, hẹn gặp lại các bạn ở phần tiếp theo với pattern rule

Note: Nếu các bạn muốn tiếp tục tìm hiểu thêm có thể đọc thêm  sách Managing Projects with GNU Make: The Power of GNU Make for Building Anything (Nutshell Handbooks) nhé