6 điểm bởi GN⁺ 2025-06-21 | 1 bình luận | Chia sẻ qua WhatsApp
  • Makefile là công cụ giúp đơn giản hóa tự động hóa build C/C++ và quản lý dependency
  • Hoạt động theo cơ chế phát hiện tệp thay đổi bằng timestamp, chỉ chạy bước biên dịch khi cần
  • Giải thích cấu trúc cốt lõi như rule, command, prerequisite kèm ví dụ
  • Cũng đề cập thực tế đến các tính năng nâng cao như automatic variables, pattern rules, variable expansion
  • Giới thiệu tầm quan trọng của khả năng mở rộng và bảo trì thông qua mẫu Makefile thực chiến cho dự án quy mô trung bình

Giới thiệu hướng dẫn tutorial về Makefile

  • Makefile là công cụ cốt lõi phụ trách tự động hóa build dự án và quản lý dependency
  • Do có nhiều quy tắc ngầm và ký hiệu khác nhau, lúc mới tiếp cận có thể thấy phức tạp, nhưng hướng dẫn này tổng hợp các nội dung chính bằng những ví dụ ngắn gọn và có thể chạy trực tiếp
  • Có thể hiểu từng phần thông qua các ví dụ thực hành theo từng mục

Bắt đầu

Mục đích tồn tại của Makefile

  • Makefile được dùng để chỉ biên dịch lại những phần đã thay đổi trong các chương trình lớn
  • Ngoài C/C++, nhiều ngôn ngữ cũng có công cụ build chuyên dụng riêng, nhưng Make vẫn được dùng rộng rãi trong các kịch bản build tổng quát
  • Cốt lõi là logic phát hiện tệp thay đổi và chỉ chạy những tác vụ cần thiết

Các hệ thống build thay thế cho Make

  • Nhóm C/C++: có nhiều lựa chọn như SCons, CMake, Bazel, Ninja
  • Nhóm Java: Ant, Maven, Gradle
  • Go, Rust, TypeScript cũng cung cấp công cụ build riêng
  • Các ngôn ngữ thông dịch như Python, Ruby, JavaScript không cần biên dịch nên nhu cầu quản lý riêng kiểu Makefile thường thấp hơn

Phiên bản và các loại Make

  • Có nhiều implementation của Make, nhưng hướng dẫn này được tối ưu cho GNU Make (chủ yếu dùng trên Linux, MacOS)
  • Các ví dụ tương thích với cả GNU Make 3 và 4

Cách chạy ví dụ

  • Sau khi cài make trong terminal, lưu từng ví dụ vào tệp Makefile rồi chạy lệnh make
  • Các dòng lệnh trong Makefile bắt buộc phải được thụt đầu dòng bằng ký tự tab

Cú pháp cơ bản của Makefile

Cấu trúc của rule

  • target: prerequisite(s)

    • command
    • command
  • Target: tên tệp kết quả build (thường là một tệp)

  • Command: shell script thực thi thực tế (bắt đầu bằng tab)

  • Prerequisite: danh sách tệp phải sẵn sàng trước khi target được build


Bản chất của Make

Ví dụ Hello World

hello:  
	echo "Hello, World"  
	echo "This line will print if the file hello does not exist."  
  • Target hello không có dependency và chạy 2 command
  • Khi chạy make hello, nếu tệp hello không tồn tại thì các lệnh sẽ được thực thi. Nếu tệp đã tồn tại thì sẽ không chạy
  • Thông thường target được viết trùng với tên tệp

Ví dụ cơ bản biên dịch tệp C

  1. Tạo tệp blah.c (nội dung int main() { return 0; })
  2. Viết Makefile sau
blah:  
	cc blah.c -o blah  
  • Khi chạy make, nếu target blah chưa có thì quá trình biên dịch sẽ chạy và tạo ra tệp blah
  • Ngay cả khi blah.c thay đổi thì cũng không tự động biên dịch lại → cần thêm dependency

Cách thêm dependency

blah: blah.c  
	cc blah.c -o blah  
  • Giờ đây nếu blah.c vừa được thay đổi, target blah sẽ được build lại
  • Việc phát hiện thay đổi dựa trên timestamp của tệp
  • Nếu can thiệp timestamp một cách tùy ý thì có thể dẫn tới hành vi ngoài ý muốn

Thêm ví dụ

Ví dụ target liên kết và dependency

blah: blah.o  
	cc blah.o -o blah   
  
blah.o: blah.c  
	cc -c blah.c -o blah.o   
  
blah.c:  
	echo "int main() { return 0; }" > blah.c   
  • Dependency được lần theo dạng cây và quá trình tạo ở từng bước được tự động hóa

Ví dụ target luôn được chạy

some_file: other_file  
	echo "This will always run, and runs second"  
	touch some_file  
  
other_file:  
	echo "This will always run, and runs first"  
  • other_file không thực sự được tạo thành tệp nên command của some_file sẽ chạy mỗi lần

Make clean

  • Target clean thường được dùng để xóa các output build
  • Đây không phải từ khóa dành riêng trong Make, nên cần tự định nghĩa bằng command
  • Nếu có tệp tên là clean thì có thể gây nhầm lẫn, vì vậy nên dùng .PHONY

Ví dụ:

some_file:   
	touch some_file  
  
clean:  
	rm -f some_file  

Xử lý biến

  • Biến luôn là chuỗi.
  • Thường nên dùng :=, ngoài ra còn có nhiều kiểu gán như =, ?=, +=
  • Ví dụ sử dụng:
files := file1 file2  
some_file: $(files)  
	echo "Look at this variable: " $(files)  
	touch some_file  
  
file1:  
	touch file1  
file2:  
	touch file2  
  
clean:  
	rm -f file1 file2 some_file  
  • Cách tham chiếu biến: $(variable) hoặc ${variable}
  • Dấu ngoặc kép trong Makefile không có ý nghĩa với chính Make (nhưng vẫn cần trong shell command)

Quản lý target

Target all

  • Nếu muốn chạy nhiều target cùng lúc, hãy gán vai trò đó cho target đầu tiên (mặc định)
all: one two three  
  
one:  
	touch one  
two:  
	touch two  
three:  
	touch three  
  
clean:  
	rm -f one two three  

Nhiều target và automatic variable

  • Có thể chạy command riêng cho từng target trong nhiều target. $@ chứa tên target hiện tại
all: f1.o f2.o  
  
f1.o f2.o:  
	echo $@  

Automatic variable và wildcard

Wildcard *

  • * sẽ dò trực tiếp tên trên file system
  • Nên luôn bọc nó trong hàm wildcard khi sử dụng
print: $(wildcard *.c)  
	ls -la  $?  
  • Không nên dùng trực tiếp * trong định nghĩa biến
thing_wrong := *.o  
thing_right := $(wildcard *.o)  

Wildcard %

  • Chủ yếu dùng trong pattern rules, có thể trích xuất và mở rộng theo mẫu đã chỉ định

Fancy Rules

Implicit rules

  • Make tích hợp sẵn nhiều quy tắc mặc định ẩn liên quan đến build C/C++
  • Các biến tiêu biểu: CC, CXX, CFLAGS, CPPFLAGS, LDFLAGS
  • Ví dụ C:
CC = gcc   
CFLAGS = -g   
  
blah: blah.o  
  
blah.c:  
	echo "int main() { return 0; }" > blah.c  
  
clean:  
	rm -f blah*  

Static Pattern Rules

  • Có thể viết gọn nhiều rule cùng theo một pattern
objects = foo.o bar.o all.o  
all: $(objects)  
	$(CC) $^ -o all  
  
$(objects): %.o: %.c  
	$(CC) -c $^ -o $@  
  
all.c:  
	echo "int main() { return 0; }" > all.c  
  
%.c:  
	touch $@  
  
clean:  
	rm -f *.c *.o all  

Static Pattern Rules + hàm filter

  • Dùng filter để chỉ chọn những đối tượng khớp với pattern phần mở rộng cụ thể
obj_files = foo.result bar.o lose.o  
src_files = foo.raw bar.c lose.c  
  
all: $(obj_files)  
.PHONY: all  
  
$(filter %.o,$(obj_files)): %.o: %.c  
	echo "target: $@ prereq: $

1 bình luận

 
GN⁺ 2025-06-21
Ý kiến trên Hacker News
  • Có người kể rằng vào năm 1985 đã tận mắt thấy một người ở phòng thí nghiệm Graphics của Boston University dùng Makefile để tạo một trình kết xuất 3D cho hoạt hình. Người đó là một lập trình viên Lisp, đang làm hệ thống sinh thủ tục ban đầu và hệ thống diễn viên 3D, và đã viết một Makefile thực sự thanh lịch chỉ khoảng 10 dòng. Cấu trúc này tự động tạo ra hàng trăm hoạt ảnh chỉ bằng phụ thuộc vào mốc thời gian tệp rất đơn giản. Hình dạng 3D của từng khung hình được tạo bằng Lisp, còn Make thì sinh ra các khung hình. Vào năm 1985, khác với ngày nay khi 3D và hoạt hình đã là chuyện hiển nhiên, lúc đó mọi người đều kinh ngạc; và người đó sau này chính là Brian Gardner, người phụ trách trình kết xuất 3D cho Iron Giant và Coraline

    • Bày tỏ thắc mắc không biết có phải là người trong 3d-consultant.com/bio.html hay không

    • Xác nhận xem có đúng là đang nói về bộ phim Coraline không

  • Giới thiệu một vài cờ hữu ích nhưng ít được biết đến khi dùng Make

    • --output-sync=recurse -j10: cờ này gom stdout/stderr lại và chỉ in ra khi công việc của từng target kết thúc; nếu không thì log sẽ bị trộn lẫn và khó phân tích
    • Trên hệ thống bận rộn hoặc môi trường nhiều người dùng, có thể dùng --load-average thay cho -j để điều tiết tải hệ thống khi chạy song song (make -j10 --load-average=10)
    • Tùy chọn --shuffle, vốn xáo trộn ngẫu nhiên lịch chạy các target build, rất hữu ích trong môi trường CI để phát hiện vấn đề phụ thuộc trong Makefile
    • Có nhắc đến ý tưởng nếu tổng hợp chính thức các tùy chọn khác nhau của make dưới dạng văn bản hoặc tài liệu rồi tích hợp vào chương trình thì sẽ dễ tiếp cận hơn

    • Tùy chọn người đó hay dùng là cờ -B để ép build lại toàn bộ

    • Vì đã thường xuyên thấy các vấn đề do make -j gây ra trên máy DOS nên coi hiện tượng đó là bug

    • Hỏi liệu vấn đề song song hóa trên hệ thống bận rộn hay môi trường nhiều người dùng chẳng phải là việc hệ điều hành và bộ lập lịch của nó phải xử lý sao

    • Dù là các cờ hữu ích, nhưng vì những tùy chọn này không portable nên khuyên không nên dùng ngoài các dự án riêng tư chỉ phục vụ cho bản thân

  • Có ý kiến cho rằng việc bỏ qua .PHONY trong tutorial chỉ vì không dùng đến là một cái cớ yếu; đúng ra nên dạy cách dùng công cụ cho chuẩn

    • Trong nhóm đã từng tranh luận vì dùng Make làm task runner và thêm, duy trì .PHONY cho mọi recipe
    • Giới thiệu hướng dẫn phong cách Makefile của Clark Grubb (clarkgrubb.com/makefile-style-guide)
    • Chia sẻ đã trải qua nhiều phong cách khác nhau giữa việc khai báo .PHONY cho từng recipe và gom lại một lần ở đầu tệp, đồng thời mong có linter để ép buộc
    • Sau khi đọc thì thấy đây là tài liệu ổn, nhưng có vài điểm không đồng ý
      • Áp dụng -o pipefail một cách máy móc là có vấn đề; khi dùng grep trong pipeline chẳng hạn có thể bị hỏng, nên tùy tình huống mà áp dụng
      • Đánh dấu .PHONY cho target không phải tệp thì đúng về mặt chặt chẽ, nhưng hầu như không cần thiết và chỉ làm Makefile dài dòng hơn, nên chỉ dùng khi cần
      • Recipe tạo ra nhiều tệp đầu ra trước đây thường dùng tệp giả, nhưng từ GNU Make 4.3 gần đây đã có hỗ trợ chính thức cho grouped targets (xem tại đây)
  • Có ý kiến cho rằng Make là công cụ chuyên cho việc build các codebase C lớn

    • Có người nói mình thích dùng nó như job runner theo từng dự án, nhưng Make không hợp để làm job runner và cả những thứ như điều kiện cũng bị làm cho trở nên khó khăn
    • Cũng từng thấy trường hợp thất bại khi cố dùng nó để bọc các công cụ như Terraform
    • Có ý kiến rằng Make, hơn là job runner, là một công cụ shell đa dụng chuyển các shell script tuyến tính sang dạng phụ thuộc khai báo

    • Cũng có lập trường cho rằng cách nhìn Make như công cụ build chỉ dành cho codebase C không còn đúng nữa. Thực tế là trong 20 năm qua đã xuất hiện nhiều hệ thống build vững chắc và rõ ràng hơn. Cần cập nhật góc nhìn này

    • Hỏi công cụ nào là job runner tốt. (Sau đó có xin lỗi vì bản thân đã hiểu nhầm ý nghĩa của job runner)

  • Giới thiệu just như một công cụ thay thế hiện đại cho những phần khiến Makefile trở nên phức tạp

    • just tốt để thay thế một danh sách shell script, nhưng không thể thay thế chức năng cốt lõi của Make là “chỉ chạy lại những rule cần chạy lại”

    • Ngoài ra còn có các lựa chọn thay thế khác

    • Các công cụ thay thế tự nhận là thay thế Make, nhưng có người cho rằng chúng hoàn toàn khác nhau và khó mà so sánh trực tiếp. Cốt lõi của Make là tạo ra artifact và không build lại những gì đã build rồi. Trong khi đó just chỉ đóng vai trò công cụ chạy lệnh đơn giản

    • Ưu điểm của việc dùng Make làm công cụ thực thi lệnh là tính ổn định của một công cụ chuẩn gần như được cài ở khắp mọi nơi. Dù các lựa chọn thay thế có thể được thiết kế tốt hơn, vẫn không thấy cần thiết phải dùng vì còn vướng việc cài đặt riêng

    • Có người đang dùng Task khá tốt cho các dự án sở thích đơn giản viết bằng C, nhưng vẫn khó đánh giá nó có phù hợp với các dự án lớn hay không (trang chủ chính thức của Task)

  • Có người thấy thú vị khi gần đây CMake kết luận Makefile không phù hợp để hỗ trợ module C++20 và chọn ninja làm mặc định (hướng dẫn CMake)

    • Trên thực tế, việc định nghĩa phụ thuộc target một cách tĩnh gần như là bất khả thi, nên họ dùng cách phân tích động bằng công cụ như clang-scan-deps (slide kỹ thuật)
    • Có ý kiến cho rằng ràng buộc này thực ra là quyết định từ phía CMake hoặc do không có người hỗ trợ cho Makefile generator. Bản thân ninja cũng không hỗ trợ trực tiếp C++ modules (vấn đề liên quan), thậm chí ninja còn ít tính năng hơn Make và có vấn đề là mọi phụ thuộc đều phải được khai báo tĩnh

    • Có ý kiến cho rằng bản thân việc đưa module vào đã phức tạp và rối rắm

  • Hỏi có ai từng dùng tup chưa. (tài liệu chính thức)

    • tup là một hệ thống build có thể tự động xác định phụ thuộc dựa trên truy cập hệ thống tệp, nên áp dụng được với bất kỳ compiler/công cụ nào
  • Có người giới thiệu bản thân là người tạo ra và maintainer chính của Task, một công cụ thay thế Make. Đã phát triển hơn 8 năm và vẫn tiếp tục tiến hóa

    • just cũng được gợi ý như một công cụ thay thế Make khác (GitHub của just)

    • Một sự trùng hợp thú vị là có người dùng Task rất thường xuyên và sáng nay còn vừa mở issue

  • Có ý kiến cho rằng tutorial này có những vấn đề nguy hiểm và tinh vi

    • Khi parse tùy chọn trong MAKEFLAGS, để xử lý long option hoặc short option rỗng thì nên làm như sau
      ifneq (,$(findstring t,$(firstword -$(MAKEFLAGS))))
    • Nếu cần tương thích với bản make cũ đi kèm mặc định trên OS X thì sẽ thiếu khá nhiều tính năng hoặc có nhiều khác biệt tinh vi
    • Các vấn đề khác phần lớn là lỗi gõ hoặc vi phạm style tốt nhất, nên bỏ qua
    • Ngoài ra, load portable hơn guile, và trong môi trường cross-compilation thì cần chỉ định compiler cho chính xác
    • Khuyên nhất định nên đọc Paul’s Rules of Makefiles(ở đây), GNU make manual(ở đây) và các tài liệu liên quan
    • Cũng đang duy trì một dự án Makefile demo đơn giản (demo github)
  • Có thói quen luôn đặt Makefile trong mọi repo GitHub

    • Vì rất dễ quên các lệnh mỗi lần quay lại, nên lưu chúng trong Makefile giúp dễ dàng thêm cả các bước phức tạp; chỉ cần chạy make là có thể ngay lập tức thực thi hành vi mong muốn cho từng dự án mà không cần nhớ riêng