6 điểm bởi GN⁺ 2025-06-21 | 1 bình luận | Chia sẻ qua WhatsApp
  • Makefile chủ yếu được dùng để tự động hóa biên dịch C/C++ trong các dự án lớn bằng cách chỉ chạy lại những bước build cần thiết dựa trên thay đổi của tệp
  • Make so sánh dấu thời gian của hệ thống tệp giữa target và dependency, và chỉ chạy lệnh khi target chưa tồn tại hoặc dependency mới hơn
  • Quy tắc cơ bản có dạng targets: prerequisites cùng các lệnh được thụt đầu dòng bằng tab; dùng khoảng trắng để thụt đầu dòng có thể khiến make thất bại
  • Có thể mở rộng luồng build bằng biến, wildcard, biến tự động, pattern rule, điều kiện, hàm, recursive make, include, .PHONY, .DELETE_ON_ERROR
  • Ví dụ Cookbook tạo output trong build/ từ mã nguồn C/C++ trong src/, và cấu hình quản lý dependency tự động bằng -MMD -MP cùng -include

Khi nào cần Makefile

  • Makefile được dùng để xác định những tệp nào cần được biên dịch lại trong các chương trình lớn
  • Trường hợp trung tâm của hướng dẫn là biên dịch tệp C/C++
  • Ngoài biên dịch, Make cũng có thể được dùng cho các công việc cần chạy một chuỗi lệnh tùy theo việc tệp có thay đổi hay không
  • Các công cụ build khác cho C/C++ được giới thiệu gồm SCons, CMake, Bazel, Ninja
  • Với Java có Ant, Maven, Gradle, còn các ngôn ngữ như Go, Rust, TypeScript có công cụ build riêng
  • Các ngôn ngữ thông dịch như Python, Ruby, JavaScript thuần không cần biên dịch lại khi tệp thay đổi, nên các công cụ như Makefile không phải là bắt buộc

Mô hình thực thi cơ bản của Make

  • Ví dụ hoạt động bằng cách tạo tệp Makefile trong terminal đã cài make, rồi chạy make trong thư mục đó
  • Quy tắc cơ bản có dạng sau
    • targets: prerequisites
    • command bắt đầu bằng tab
  • Target thường là tên tệp, và lệnh là các bước để tạo ra target đó
  • Prerequisites là các tệp phải tồn tại trước khi chạy lệnh, còn được gọi là dependencies
  • 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; nếu dùng khoảng trắng thì make sẽ thất bại

So sánh thời gian giữa target và dependency

  • make hello sẽ chạy lệnh gắn với target hello, nhưng nếu tệp hello đã tồn tại thì lệnh sẽ không chạy
  • Target và tên tệp được liên kết trực tiếp; thông thường lệnh của target sẽ tạo ra tệp có cùng tên với target
  • Trong quy tắc blah: blah.c, lệnh biên dịch chỉ chạy khi blah chưa tồn tại hoặc blah.c mới hơn blah
  • Make không trực tiếp theo dõi lịch sử thay đổi mà dùng dấu thời gian hệ thống tệp như một heuristic
  • Nếu sửa tệp rồi đổi thời gian sửa về quá khứ, Make có thể phán đoán sai rằng tệp đó chưa thay đổi

Target mặc định và clean

  • Nếu không chỉ định target khi chạy make, target đầu tiên trong Makefile sẽ được chạy mặc định
  • Target all được dùng khi muốn tạo nhiều target cùng lúc; nếu đặt nó làm quy tắc đầu tiên thì chỉ cần chạy make là toàn bộ target sẽ được build
  • clean thường được dùng làm target để xóa các output build, nhưng không phải là từ khóa đặc biệt được Make dành riêng
  • Nếu clean không phải target đầu tiên và cũng không phải dependency của target khác, thì phải gọi rõ ràng như make clean mới chạy
  • Nếu thực sự có một tệp tên là clean, target clean có thể không chạy; điều này sẽ được xử lý sau bằng .PHONY

Biến và xử lý chuỗi

  • Biến trong Make chỉ có thể chứa chuỗi, và hướng dẫn nhìn chung khuyến nghị dùng :=
  • Tham chiếu biến dùng dạng $(name) hoặc ${name}
  • Dấu nháy đơn và nháy kép không có ý nghĩa đặc biệt trong chính Make, chúng chỉ là ký tự nằm trong biến
  • Có hai cách chính để định nghĩa biến
    • =: biến đệ quy, sẽ tra cứu và mở rộng khi được sử dụng
    • :=: biến mở rộng đơn giản, chỉ mở rộng những giá trị đã được định nghĩa tại thời điểm khai báo
  • ?= chỉ gán giá trị nếu biến chưa được thiết lập
  • += dùng để thêm giá trị vào biến
  • Biến chưa được định nghĩa được xem là chuỗi rỗng
  • Khoảng trắng ở cuối dòng không bị loại bỏ; để tạo biến chứa đúng một dấu cách có thể dùng kỹ thuật như $(nullstring)

Wildcard và biến tự động

  • Trong Make, *% đều được gọi là wildcard nhưng ý nghĩa khác nhau
  • * tìm tên tệp khớp trên hệ thống tệp; an toàn hơn nếu bọc trong hàm wildcard như $(wildcard *.o) thay vì dùng trực tiếp trong định nghĩa biến
  • Nếu * không khớp với tệp nào, nó có thể được giữ nguyên nếu không nằm trong hàm wildcard
  • % được dùng trong quy tắc và một số hàm nhất định, phần khớp được gọi là stem
  • Các biến tự động thường dùng gồm
    • $@: tên target
    • $?: các dependency mới hơn target
    • $^: tất cả dependency
    • $<: dependency đầu tiên

Quy tắc ngầm định và pattern rule

  • Make có quy tắc ngầm định cho biên dịch C, nên việc build vẫn có thể diễn ra ngay cả khi không viết rõ lệnh biên dịch
  • Một số quy tắc ngầm định tiêu biểu có dạng
    • n.o được tạo từ n.c theo dạng $(CC) -c $(CPPFLAGS) $(CFLAGS) $^ -o $@
    • n.o được tạo từ n.cc hoặc n.cpp theo dạng $(CXX) -c $(CPPFLAGS) $(CXXFLAGS) $^ -o $@
    • n được tạo từ n.o bằng lệnh linker
  • Các biến quan trọng trong quy tắc ngầm định là CC, CXX, CFLAGS, CXXFLAGS, CPPFLAGS, LDFLAGS
  • Static pattern rule có dạng targets...: target-pattern: prereq-patterns ..., trong đó stem khớp từ target sẽ được thay vào pattern của dependency
  • Pattern rule chứa % trong target như %.o: %.c, và có thể dùng như quy tắc ngầm định do bạn tự tạo
  • Quy tắc dấu hai chấm kép :: cho phép định nghĩa nhiều quy tắc cho cùng một target nhưng ít khi được dùng

Thực thi lệnh và xử lý lỗi

  • Thêm @ trước lệnh sẽ khiến chính lệnh đó không được in ra
  • Chạy make -s sẽ hoạt động như thể mỗi dòng đều có @ ở đầu
  • Mỗi dòng lệnh có hiệu ứng như được chạy trong một shell mới, nên cd .. ở một dòng sẽ không ảnh hưởng đến dòng tiếp theo
  • Muốn giữ cùng trạng thái shell thì phải viết trên một dòng như cd ..; echo \pwd`` hoặc nối dòng bằng backslash
  • Shell mặc định là /bin/sh, nhưng có thể đổi như SHELL=/bin/bash
  • Để dùng $ của biến shell trong Makefile, cần viết thành $$
  • Cách xử lý lỗi thay đổi theo phương thức thực thi
    • make -k: dù có lỗi vẫn tiếp tục chạy các công việc khác nếu có thể
    • - trước lệnh: bỏ qua lỗi của lệnh đó
    • make -i: áp dụng bỏ qua lỗi cho mọi lệnh
  • Nếu dừng Make bằng ctrl+c, Make sẽ xóa target mới nhất mà nó vừa tạo ra

Recursive make và biến môi trường

  • Khi gọi lại make từ bên trong Makefile, nên dùng biến đặc biệt $(MAKE) thay vì make
  • $(MAKE) truyền tiếp các cờ của make và xử lý ảnh hưởng của chúng một cách phù hợp
  • Khi Make khởi động, các biến môi trường đã được thiết lập lúc chạy sẽ tự động trở thành biến của Make
  • Chỉ thị export đặt biến vào môi trường shell của mọi recipe
  • Muốn dùng biến trong make con thì phải export biến đó
  • Dùng .EXPORT_ALL_VARIABLES có thể export mọi biến
  • Nếu chỉ định nhiều target cùng lúc như make clean run test, chúng sẽ được chạy theo thứ tự clean, run, test

Điều kiện và hàm

  • Câu lệnh điều kiện gồm ifeq, ifneq, ifdef, ifndef, else, endif
  • ifdef không mở rộng tham chiếu biến mà chỉ kiểm tra biến có được định nghĩa hay không
  • Có thể dùng $(MAKEFLAGS)findstring để kiểm tra xem các cờ như make -i có được truyền vào hay không
  • Hàm trong Make chủ yếu dùng cho xử lý văn bản, được gọi theo dạng $(fn, arguments) hoặc ${fn, arguments}
  • Các hàm chính gồm
    • subst: thay thế chuỗi
    • patsubst: thay thế các từ khớp với pattern
    • foreach: biến đổi danh sách từ ngăn cách bằng khoảng trắng
    • if: nếu đối số thứ nhất không rỗng thì dùng đối số thứ hai, ngược lại dùng đối số thứ ba
    • call: gọi biến như một hàm cơ bản và dùng các tham số như $(1), $(2)
    • shell: gọi shell nhưng đổi ký tự xuống dòng thành khoảng trắng
    • filter: chọn phần tử trong danh sách khớp một pattern nhất định
    • filter-out: chọn phần tử không khớp pattern nhất định

include, vpath, .PHONY, .DELETE_ON_ERROR

  • include filenames... cho phép đọc một hoặc nhiều Makefile khác
  • include hữu ích khi đọc các Makefile được tạo bởi tùy chọn như -M của gcc
  • vpath <pattern> <directories> chỉ định các thư mục để tìm tệp dependency cụ thể
  • Biến VPATH có thể thiết lập hành vi đường dẫn tìm kiếm tương tự trên phạm vi toàn cục
  • Dấu backslash \ được dùng để chia một lệnh dài thành nhiều dòng
  • Thêm .PHONY vào target giúp Make không nhầm target đó với tệp cùng tên
  • .DELETE_ON_ERROR sẽ xóa target của quy tắc nếu trong lúc chạy, một lệnh trả về trạng thái thoát khác 0
  • .DELETE_ON_ERROR không chỉ áp dụng cho target đứng ngay trước nó như PHONY, mà áp dụng cho mọi target

Ví dụ Makefile Cookbook

  • Ví dụ Cookbook cung cấp mẫu Makefile phù hợp cho các dự án quy mô vừa
  • Nếu đặt tệp C/C++ vào thư mục src/, Makefile sẽ tự động xác định target biên dịch và dependency
  • Cấu hình biến chính gồm
    • TARGET_EXEC := final_program
    • BUILD_DIR := ./build
    • SRC_DIRS := ./src
  • SRCS dùng find$(shell ...) để tìm các tệp *.cpp, *.c, *.s
  • OBJS thêm tiền tố BUILD_DIR vào từng tệp nguồn và thêm .o để tạo đường dẫn output build
  • DEPS thay .o thành .d để tạo danh sách các Makefile dependency
  • INC_DIRS tìm mọi thư mục bên dưới src/, còn INC_FLAGS thêm tiền tố -I vào từng thư mục
  • CPPFLAGS := $(INC_FLAGS) -MMD -MP cấu hình để GCC tạo các tệp dependency .d
  • Mã nguồn C và C++ dùng các pattern rule riêng để tạo tệp .o dưới build/, và các thư mục cần thiết được tạo bằng mkdir -p $(dir $@)
  • clean được khai báo là .PHONY và xóa thư mục build bằng rm -r $(BUILD_DIR)
  • -include $(DEPS) sẽ include các tệp .d, đồng thời chặn lỗi nếu tệp chưa tồn tại như ở lần build đầu tiên

1 bình luận

 
GN⁺ 2025-06-21
Các ý kiến trên Hacker News
  • Có một số flag make ít được biết đến nhưng hữu ích. Đồng bộ hóa đầu ra giúp stdout/stderr chỉ được in ra sau khi target kết thúc, giảm tình trạng log bị trộn lẫn và khó đọc khi build song song
    make --output-sync=recurse -j10
    Trên các hệ thống nhiều người dùng đang bận, chỉ đặt số job bằng -j có thể là chưa đủ, nên cũng có thể giới hạn mức song song dựa trên load average: make -j10 --load-average=10
    --shuffle, tùy chọn ngẫu nhiên hóa thứ tự lập lịch target, cũng hữu ích trong CI để bắt các dependency bị thiếu trong Makefile: make --shuffle # or --shuffle=seed/reverse

    • Nếu nói là “ít được biết đến”, thì sẽ hay hơn nếu các tác giả của make tổng hợp danh sách tùy chọn ở đâu đó rồi phân phối kèm chương trình. Nếu ở dạng một file văn bản người dùng có thể đọc được, hoặc một ngôn ngữ dàn trang nào đó, khả năng tiếp cận tri thức sẽ tốt hơn nhiều
    • Cái tôi dùng thường xuyên nhất là -B, tùy chọn buộc build lại toàn bộ mà không cần điều kiện
    • Tôi đã thấy make -j vài lần khiến máy rơi vào trạng thái DoS, nên gần như xem đó là bug
    • Nếu là một hệ thống nhiều người dùng đang bận, chẳng phải scheduler của hệ điều hành có thể xử lý việc đó sao?
    • Dù hữu ích, nó không portable. Tốt nhất đừng dùng ngoài các dự án đồ chơi cá nhân không đem phân phối
  • Khoảng năm 1985, ở Boston University Graphics lab, tôi từng thấy một người dùng Makefile để điều phối việc tạo trình render 3D cho hoạt hình. Anh ấy là người bên Lisp, đang làm với sinh tự động theo thủ tục thời kỳ đầu và hệ thống diễn viên 3D; Makefile chỉ khoảng 10 dòng và rất thanh lịch
    Chỉ bằng dependency đơn giản dựa trên ngày tháng file, nó tạo ra hàng trăm animation; Lisp tạo hình dạng 3D cho từng frame, còn Make tạo các frame
    Vì đó là năm 1985, thời điểm hầu như chưa có những thứ mà nay ta xem là hiển nhiên trong 3D và animation, nên việc này khiến mọi người kinh ngạc. Sau đó anh ấy đã viết trình render 3D cho Iron Giant và tôi nhớ là cũng đóng vai trò cốt lõi trong Coraline. Đó là Brian Gardner

  • Make là một trong những công cụ mà tôi thật sự thấy may mắn vì đã học từ sớm trong sự nghiệp. Giờ tôi không dùng nhiều nữa, nhưng nó đã cho thấy sức mạnh của hệ thống khai báo so với hệ thống mệnh lệnh
    Tôi nhận ra ý tưởng này mở rộng tự nhiên sang các công việc khác. Nhìn hình ở đầu trang web này, có vẻ tác giả cũng cảm thấy tương tự: nếu nhìn công thức nấu ăn theo cách khai báo như Makefile, thay vì như chuỗi lệnh kiểu script giống công thức truyền thống, ta có thể hiểu công thức tốt hơn
    Bài liên quan: https://blog.gpkb.org/posts/cooking-with-make/
    Tôi luôn ghi công thức theo cách có thể đọc như Makefile rồi mang vào bếp, và tò mò liệu có ai từng dàn trang hay hiển thị chúng theo kiểu này chưa. Có lẽ sẽ tiết kiệm được nhiều thời gian khi đọc công thức mới, vì không phải tự chuyển script thành Makefile trong đầu

    • Điểm hay của cách tiếp cận này là người dùng về cơ bản tự diễn giải đồ thị dependency và trở thành người thực thi, nên có nhiều quyền kiểm soát hơn. Với ví dụ nấu ăn, cấu trúc khai báo làm rõ hơn những điểm mà bạn còn có nhiều lựa chọn về việc tiếp theo sẽ làm gì, đồng thời cho phép tự do phản ánh cả các ràng buộc bên ngoài không được ghi chính thức trong Makefile, chẳng hạn thứ tự nào giúp rửa bát dễ hơn
      Tất nhiên cái giá phải trả là bạn phải tự giải đồ thị dependency. Khi chỉ muốn làm theo các bước tuần tự đã được tuần tự hóa sẵn, bạn sẽ phải gánh thêm nhiều việc hơn
  • Bài viết nói rằng hầu hết recipe không được đánh dấu .PHONY, và dùng điều đó như lý do để tutorial không nhất thiết phải đề cập, nhưng đó là một cái cớ yếu. Công cụ nên dạy cách dùng đúng
    Trong team, chúng tôi dùng make như một task runner, nên tôi từng bị nhắc nhở vì thêm và duy trì .PHONY cho mọi recipe
    Style guide Makefile của Clark Grubb khá tốt: https://clarkgrubb.com/makefile-style-guide
    Tôi tò mò liệu có ai dùng style guide này không. Cũng muốn biết mọi người đánh dấu recipe phony ngay tại nơi khai báo, hay để một danh sách khổng lồ ở đầu file. Giá mà có một linter để ép quy tắc này

    • Tôi đã đọc và nhìn chung đây là tài liệu tốt. Tuy vậy có vài điểm tôi không đồng ý
      Dùng -o pipefail theo quán tính thì không hay. Pipefail có ích, nhưng nó phá vỡ thao tác phổ biến nhất mà mọi người làm trong pipeline: lọc đầu ra bằng grep. Tốt hơn là chỉ thêm riêng vào những recipe cần nó
      Đánh dấu các target không phải file bằng .PHONY là đúng về mặt nghiêm ngặt, nhưng thường không cần thiết. Nếu có nhiều target, nó chỉ làm Makefile dài dòng không cần thiết, nên theo tôi chỉ nên thêm khi cần
      Với các recipe tạo ra nhiều file đầu ra, trước đây khi pattern rule không phù hợp thì dummy file/flag file là cách chuẩn, nhưng từ GNU Make 4.3 đã hỗ trợ native group target. Nó cũng có trong Ubuntu 22.04 LTS: https://www.gnu.org/software/make/manual/html_node/Multiple-...
  • Điểm thú vị gần đây là CMake đã xác định rằng Makefile không phù hợp với các dự án dùng module C++20, còn Ninja mới là lựa chọn đúng. https://cmake.org/cmake/help/latest/manual/cmake-cxxmodules....
    Về cơ bản, có vẻ họ cho rằng việc định nghĩa tĩnh các phụ thuộc của target là quá khó hoặc bất khả thi. Giờ việc này được xử lý động bằng các công cụ như clang-scan-deps: https://llvm.org/devmtg/2019-04/slides/TechTalk-Lorenz-clang...

    • Nếu tôi sai thì mong được sửa, nhưng hạn chế liên quan đến Makefile này có vẻ hoàn toàn là lựa chọn của CMake, hoặc ít nhất là do thiếu tình nguyện viên bổ sung hỗ trợ cho trình sinh Makefile. Bản thân Ninja cũng không hỗ trợ module C++: https://github.com/ninja-build/ninja/issues/2457
      Thực ra Ninja yêu cầu mọi phụ thuộc phải được định nghĩa tĩnh, nên xét ở điểm này nó còn ít tính năng hơn Make thông thường
    • Nói thật, module gần như là một thảm họa
  • Make có chỗ đứng của nó với tư cách công cụ build cho các codebase C lớn. Nhưng nó thường bị đối xử như một “trình chạy tác vụ theo dự án” đa dụng, và nó không thật sự hợp với việc đó. Vì ngay cả điều kiện đơn giản cũng khó viết
    Ví dụ tôi đã thấy nhiều nỗ lực thiện chí nhằm bọc Terraform bằng Make, nhưng kết cục đều không tốt

    • Make không phải là trình chạy tác vụ đa dụng. Nó gần giống một cách tổng quát để biến script shell tuyến tính thành phụ thuộc khai báo. Nó là một công cụ tổng quát dành cho shell
    • Tôi nghĩ nói nó là công cụ build cho codebase C lớn giờ cũng không còn đúng nữa. Trong 20 năm qua đã có các hệ thống build vững chắc hơn và được định nghĩa tốt hơn, nên đã đến lúc cập nhật
    • Có trình chạy tác vụ đa dụng nào tốt không? Sửa: có vẻ tôi đã hiểu hoàn toàn sai cụm “trình chạy tác vụ”
  • Một công cụ hiện đại tuyệt vời để thay thế phần khiến Makefile trở nên lộn xộn là just: https://github.com/casey/just

    • Nó thay thế khía cạnh “danh sách các script shell ngắn” của Make, nhưng không thay thế phần thực sự hữu ích là “chỉ chạy các rule cần chạy lại”
    • Cũng có các lựa chọn khác: Task (Go) https://github.com/go-task/task, Cake (C#) https://github.com/cake-build/cake, Rake (Ruby) https://github.com/ruby/rake
      Theo một ý tưởng hoàn toàn khác còn có Makedown từng được bàn trên HN 8 tháng trước: https://news.ycombinator.com/item?id=41825344
    • Dù các công cụ này tự định vị mình là lựa chọn thay thế cho make, cá nhân tôi thấy chúng hoàn toàn khác và không phải đối tượng để so sánh. make tập trung vào tạo artifact và không build lại những thứ đã được tạo, còn just là trình chạy lệnh
    • Ưu điểm chính khi dùng Make làm trình chạy lệnh là nó là công cụ chuẩn được cài đặt “ở khắp nơi”. Dù các công cụ thay thế trông dễ dùng hơn, tôi chưa từng cảm thấy chúng mang lại lợi ích đủ lớn để biện minh cho việc cài thêm công cụ
    • Task cũng là một lựa chọn khác. Tuy nhiên cá nhân tôi mới chỉ dùng nó cho các dự án C sở thích đơn giản, nên khó nói nó có trụ tốt khi quy mô lớn hơn không: https://taskfile.dev/
  • Makefile rất tuyệt, nhưng tốt nhất đừng sa đà quá. Vài năm trước tôi từng cố tạo một framework GNU Make thuần, rồi nhận ra về cơ bản mình đang phát minh lại autoconf; khi đó tôi mới hiểu vì sao GNU autotools ra đời
    Makefile là một Turing tarpit kỳ lạ hơi giống Lisp. GNU Make thậm chí còn có cả tính năng metaprogramming, nên rất khó cưỡng lại thôi thúc metaprogram một hệ thống tà đạo ngay trong Makefile. Việc GNU Make được cài đặt cực kỳ rộng rãi cũng làm sức cám dỗ lớn hơn

  • Tôi là người tạo và là một trong các maintainer của Task, một lựa chọn thay thế cho Make. Nó đã tồn tại hơn 8 năm và vẫn tiếp tục phát triển, nên nếu bạn đang tìm thứ gì mới thì có thể thử. Cũng cứ thoải mái đặt câu hỏi
    https://taskfile.dev/
    https://github.com/go-task/task

  • Không biết có ai đã dùng tup chưa: https://gittup.org/tup/ex_dependencies.html
    Đây là hệ thống build tự động xác định phụ thuộc dựa trên truy cập hệ thống tệp, nên có thể dùng với bất kỳ compiler hay công cụ nào