Rules trong Makefile
Quy tắc viết (Rules)
Một quy tắc trong Makefile bao gồm 2 phần:
- Phần phụ thuộc (prerequisites) – các file mà target cần.
- Phương thức tạo mục tiêu (commands) – các lệnh để tạo hoặc cập nhật target.
Trong Makefile, thứ tự các rules rất quan trọng vì chỉ nên có một target cuối cùng, và các mục tiêu khác sẽ được liên kết thông qua mục tiêu này.
- Thông thường, Makefile có thể định nghĩa nhiều target, nhưng target trong rules đầu tiên sẽ được coi là target cuối cùng mà lệnh
make
sẽ thực thi. - Nếu rules đầu tiên có nhiều target, thì target đứng đầu sẽ được chọn làm target cuối cùng.
I. Ví dụ
foo.o: foo.c defs.h
gcc -c -g foo.c
Như đã đề cập trước đó:
foo.o
là mục tiêu (target) của chúng ta.foo.c
vàdefs.h
là các tệp nguồn mà mục tiêu phụ thuộc vào (prerequisites).- Quy tắc này chỉ có một lệnh (command) để tạo mục tiêu, bắt đầu bằng phím Tab:
gcc -c -g foo.c
Quy tắc này cho chúng ta biết môt điều quan trọng đó là mối quan hệ phụ thuộc
foo.o
phụ thuộc vào cácfoo.c
vàdefs.h
.- Nếu :
foo.o
chưa tồn tại, Make sẽ tạo file này ngay.foo.c
hoặcdefs.h
được chỉnh sửa sau khi tạofoo.o
, thì Make sẽ hiểu rằngfoo.o
đã "cũ" và cần được cập nhật.foo.o
đã có vàfoo.c
cùngdefs.h
không thay đổi kể từ lần cuối tạofoo.o
, thì Make sẽ không làm gì vì target vẫn còn mới.
💡 Nói cách khác
Make chỉ tạo hoặc cập nhật foo.o
khi cần thiết — tức là khi file chưa có hoặc khi có file phụ thuộc nào đó đã được chỉnh sửa. Điều này giúp tiết kiệm thời gian và công sức vì không phải biên dịch lại tất cả từ đầu mỗi lần chạy make.
II. Cú pháp của Rules
targets : prerequisites
command
...
📝 Giải thích từng thành phần:
targets
- Là tên của file hoặc mục tiêu bạn muốn tạo ra.
- Có thể là một hoặc nhiều file, ngăn cách bằng dấu cách.
- Có thể sử dụng ký tự đại diện (wildcards) nếu cần.
prerequisites
- Là các file mà targets phụ thuộc vào.
- Nếu bất kỳ file nào trong số này mới hơn targets, hoặc nếu targets chưa tồn tại, Make sẽ thực hiện lệnh để cập nhật.
command
- Là các dòng lệnh dùng để tạo hoặc cập nhật targets.
- Mỗi dòng lệnh phải bắt đầu bằng phím Tab (đây là yêu cầu bắt buộc trong Makefile).
III Sử dụng ký tự đại diện (wildcards)
1. Các ký tự đại diện phổ biến trong Makefile
Make hỗ trợ ba ký tự đại diện quen thuộc từ shell Unix:
- " * " — Khớp với bất kỳ chuỗi ký tự nào (bao gồm cả chuỗi rỗng). -- Ví dụ:
*.c
sẽ khớp với tất cả các tệp có phần mở rộng.c
- " ?" — Khớp với một ký tự bất kỳ. -- Ví dụ:
file?.c
sẽ khớp vớifile1.c
,fileA.c
, nhưng không khớp vớifile12.c
. - " ~ " — Đại diện cho thư mục home của người dùng hiện tại. -- Ví dụ: ~/project trỏ đến thư mục project trong thư mục home.
2. Ví dụ đơn giản với ký tự đại diện
- 🧹 Dọn dẹp các file
.o
clean:
rm -f *.o
Khi dùng: make clean
sẽ xóa tất cả các file có phần mở rộng .o
trong thư mục hiện tại.
- Ký tự đại diện trong prerequisites
print: *.c
lpr -p $?
touch print
Lúc này, Make sẽ theo dõi các file.c
này. Nếu bất kỳ file nào được chỉnh sửa sau lần chạy trước, Make sẽ thực thi các lệnh bên dưới.
Ví dụ: Trong thư mục có main.c
utils.c
helper.c
👉 Khi ta chạy make print
lần đầu thì tất cả các file .c
sẽ được in ra. Nếu bây giờ ta chỉnh sửa file utils.c
, thì khi chạy make print
chỉ có utils.c
được in ra.
Một ví dụ khác về việc - sử dụng ký tự đại diện trong biến
Trong thư mục có các file: main.c
utils.c
helper.c
- Liệt kê tất cả file
.c
và lưu vào biến:
sources := $(wildcard *.c)
Khi đó sources = main.c utils.c helper.c
- Chuyển
.c
thành.o
để biên dịch dùng patsubst
objects := $(patsubst %.c,%.o,$(sources))
Khi đó objects = main.o utils.o helper.o
Code Makefile hoàn chỉnh để biên dịch các file.c
# Bước 1: Lấy tất cả file .c
sources := $(wildcard *.c)
# Bước 2: Chuyển thành file .o
objects := $(patsubst %.c,%.o,$(sources))
# Bước 3: Biên dịch thành chương trình
app: $(objects)
gcc -o app $(objects)
Lúc này khi ta dùng make
sẽ tạo được file app.exe
trong win (hay app
trên linux) trong thư mục hiện tại. Để chạy ./app
III. Tìm kiếm file phụ thuộc trong Makefile
Khi làm việc với các dự án lớn, các tệp nguồn thường được lưu trong nhiều thư mục khác nhau. Để Make có thể tự động tìm kiếm các tệp phụ thuộc, chúng ta sử dụng VPATH
hoặc vpath
1. Dùng VPATH
VPATH
là một biến đặc biệt giúp Make tìm kiếm các file trong nhiều thư mục khác nhau.- Nếu không dùng
VPATH
, Make chỉ tìm file trong thư mục hiện tại.
Cú pháp VPATH = <directory_1>:<directory_2>:<directory_3>
Ví dụ: Giả sử ta có một dự án với cấu trúc thư mục như sau:
project/
├── Makefile
├── src/
│ ├── main.c
│ └── helper.c
├── include/
│ └── utils.h
└── lib/
└── math.c
Code Makefile
# Khai báo các thư mục tìm kiếm
VPATH = src:include:lib
# Rules biên dịch
all: main.o helper.o math.o
gcc main.o helper.o math.o -o my_program
# Rules tạo file .o từ .c
%.o: %.c
gcc -c $< -o $@ # Với mỗi file .c, Make sẽ tạo file .o tương ứng.
Quá trình chạy
Make tìm và biên dịch:
src/main.c
→main.o
src/helper.c
→helper.o
lib/math.c
→math.o
Sau đó, Make liên kết các file .o
thành chương trình my_program.
🔍 Bạn có thể kham khảo link này để hiểu dòng rules cuối trong makefile: https://makefiletutorial.com/#automatic-variables
2. Dùng vpath
(linh hoạt hơn)
vpath
giúp Makefile tìm file nguồn nằm trong thư mục khác ngoài thư mục hiện tại.
Cú pháp vpath <pattern> <directories>
<pattern>
: Kiểu file cần tìm (sử dụng ký tự đại diện)%.c
→ tất cảfile.c
%.h
→ tất cảfile.h
<directories>
: Đường dẫn đến thư mục chứa file cần tìm.
Ví dụ:
project/
├── Makefile
├── src/ (chứa các file .c)
│ └── main.c
├── include/ (chứa các file .h)
│ └── main.h
└── lib/
└── math.c
Code Makefile
vpath %.c src:lib # Tìm các file.c trong src và lib
vpath %.h include # Tìm các file.h trong include
all: main.o
gcc main.o -o main
main.o: main.c main.h
gcc -c main.c -o main.o
IV. Phony Targets trong Makefile
Trong một trong những ví dụ sớm nhất mà đã đề cập đến một target "clean", đây là một ví dụ về mục tiêu giả (phony target).
clean:
rm *.o temp
Vì ta không tạo ra file nào tên là clean
khi chạy, nên đây được gọi là một phony target. Phony target không đại diện cho một file thực tế mà chỉ là một nhãn. Do đó, Make không thể kiểm tra phụ thuộc của nó và sẽ luôn thực hiện lệnh khi bạn gọi.
Tại sao cần mục tiêu giả?
Nếu trong thư mục của ta tồn tại một file tên là clean, khi bạn chạy make clean
, Make sẽ nghĩ rằng target đã hoàn thành và sẽ không thực hiện lệnh. Để tránh tình trạng này, chúng ta sử dụng .PHONY
để khai báo mục tiêu là giả:
.PHONY: clean
clean:
rm *.o temp
Dòng .PHONY: clean
giúp Make luôn thực thi lệnh rm *.o temp
khi gọi make clean
, bất kể có file nào tên là clean hay không.
Ví dụ về phony target
all
Nếu ta muốn biên dịch nhiều chương trình cùng lúc, có thể sử dụng phony target all:
.PHONY: all
all: prog1 prog2 prog3
prog1: prog1.o utils.o
cc -o prog1 prog1.o utils.o
prog2: prog2.o
cc -o prog2 prog2.o
prog3: prog3.o sort.o utils.o
cc -o prog3 prog3.o sort.o utils.o
Giải thích:
all
là target mặc định vì nó nằm đầu tiên.- Khi ta chạy
make
, Make sẽ tự động thực thi mục tiêu all, từ đó biên dịch cảprog1
,prog2
vàprog3
. .PHONY: all
đảm bảo rằngmake all
sẽ luôn được thực hiện ngay cả khi có file tên là all.
Mục tiêu dọn dẹp nâng cao
Bạn có thể chia nhỏ các mục tiêu dọn dẹp theo từng loại file:
.PHONY: cleanall cleanobj cleandiff
cleanall: cleanobj cleandiff
rm program
cleanobj:
rm *.o
cleandiff:
rm *.diff
Giải thích:
make cleanall
sẽ xóa tất cả các file.o
,.diff
và cả file thực thiprogram
.- Bạn cũng có thể chạy riêng make cleanobj hoặc make cleandiff để chỉ dọn một phần.
V. Rules với nhiều target
Trong Makefile, một quy tắc có thể có nhiều mục tiêu (multiple targets). Điều này rất hữu ích khi bạn có nhiều file cần được tạo từ cùng một nguồn và sử dụng cùng một lệnh. Thay vì viết nhiều quy tắc riêng lẻ, bạn có thể gộp chúng vào một quy tắc duy nhất.
⚙️ Cách hoạt động:Khi sử dụng nhiều target trong một rules, Makefile sẽ chạy cùng một lệnh cho từng mục tiêu. Để giúp lệnh này biết target nào đang được xử lý, ta sử dụng biến tự động $@
, đại diện cho tên của target hiện tại.
Ví dụ:Giả sử ta muốn tạo 1 file test1.txt
và test2.txt
bằng cùng lệnh touch
.
Makefile
file1.txt file2.txt:
touch $@
Sau khi thực thi ta sẽ có được 2 file mong muốn. $@
ở đây lần lượt mang giá trị file1.txt
và file2.txt
. Đây là 1 ví dụ minh họa cho việc sử dụng này, bạn có thể tùy chỉnh nhiều biến thể khác phù hợp với dự án.
VI. Pattern Rules
Pattern Rules là gì
Thay vì viết từng quy tắt riêng lẻ cho từng file, pattern rules - quy tắt mẫu sẽ áp dụng chung cho các file.
Ví dụ: Nếu không có pattern rules, ta phải viết thủ công như thế này.
foo.o : foo.c
$(CC) -c $(CFLAGS) foo.c -o foo.o
bar.o : bar.c
$(CC) -c $(CFLAGS) bar.c -o bar.o
baz.o : baz.c
$(CC) -c $(CFLAGS) baz.c -o baz.o
Với pattern rules, ta chỉ cần viết 1 dòng duy nhất
%.o : %.c
$(CC) -c $(CFLAGS) $< -o $@
1. Static Pattern Rules
a. Static Pattern Rules là gì?
Static Pattern Rules (Quy tắc mẫu tĩnh) là một dạng quy tắc mẫu có chỉ định danh sách target cụ thể.
Cú pháp
<targets ...> : <target-pattern> : <prereq-patterns ...>
<commands>
Trong đó:
<targets ...>
: tập hợp các file target cần tạo.<target-pattern>
: Mẫu để xác định các file target.<prereq-patterns ...>
: Mẫu để xác định các file phụ thuộc.<commands>
: Các lệnh thực thi để tạo target từ phụ thuộc.
b. So sánh Static Pattern Rules và Pattern Rules
Ví dụ chúng ta muốn biên dịch các file .c
thành .o
. Ta có thể viết như sau:
- Pattern Rules
%.o : %.c
$(CC) -c $(CFLAGS) $< -o $@
👉 Makefile sẽ tự động áp dụng quy tắc này cho tất cả các file .o
có .c
tương ứng có trong thư mục.
- Static Pattern Rules
objects = foo.o bar.o
$(objects) : %.o : %.c
$(CC) -c $(CFLAGS) $< -o $@
👉 Makefile chỉ áp dụng cho foo.o
và bar.o
, dù thư mục có nhiều file .c
khác.
Kết luận: Static Pattern Rules được áp dụng khi mà ta muốn build hoặc lọc một số file cụ thể mà ta mong muốn.
VII. Auto-dependency generation
Trong Makefile, các file phụ thuộc (dependencies) có thể bao gồm nhiều file header. Ví dụ, nếu trong main.c
có dòng #include "add.h"
, thì add.h
phải được tính là một dependency của main.c
.
main.o : main.c add.h
Tuy nhiên, trong các dự án lớn, việc theo dõi file .c
nào bao gồm file header .h
nào có thể trở nên phức tạp. Khi thêm hoặc xóa file header, ta phải cập nhật Makefile một cách thủ công, điều này dễ gây lỗi và khó bảo trì.
main.o: main.c add.h sub.h multi.h div.h .... rất nhiều file.h khác mà main.c dùng
Để tự động hóa quá trình này, chúng ta có thể sử dụng tùy chọn -MM
của trình biên dịch C/C++, giúp tự động tìm kiếm các file header mà file nguồn sử dụng và tạo danh sách phụ thuộc. Ví dụ, nếu ta chạy lệnh sau:
gcc -MM main.c
Đầu ra là:
main.o: main.c add.h
Các phụ thuộc được trình biên dịch tự động tạo ra, do đó bạn không phải viết thủ công các phụ thuộc của nhiều file,trình biên dịch sẽ tự động tạo ra chúng. Một điều cần lưu ý là nếu bạn sử dụng trình biên dịch GNU C/C++, bạn phải sử dụng tham số -MM
, nếu không, tham số -M
cũng sẽ bao gồm một số file header thư viện chuẩn.
Đầu ra của gcc -M main.c
là:
main.o: main.c c:\mingw\include\stdio.h c:\mingw\include\_mingw.h \
c:\mingw\include\msvcrtver.h c:\mingw\include\w32api.h \
c:\mingw\include\sdkddkver.h c:\mingw\include\features.h \
c:\mingw\lib\gcc\mingw32\9.2.0\include\stddef.h \
c:\mingw\include\sys/types.h \
c:\mingw\lib\gcc\mingw32\9.2.0\include\stdarg.h add.h
Cách phổ biến là tạo một file .d
cho mỗi file .c
. File .d
sẽ chứa danh sách các file header liên quan mà file .c
cần để ra .o
.
Sau đó, Makefile có thể đọc các file .d
này để tự động xác định khi nào cần biên dịch lại file .c
. Điều này giúp quản lý các phụ thuộc dễ dàng hơn mà không cần chỉnh sửa Makefile thủ công mỗi khi thêm hoặc sửa, xóa file .h
. Điều này rất có lợi khi sử dụng trong các dự án lớn có số lượng file khổng lồ 🔥.
Chương trình ví dụ
Thư mục của hiện tại
TEST/
├── add.c
├── add.h
├── main.c
├── Makefile
├── sub.c
├── sub.h
- main.c
#include <stdio.h>
#include "add.h"
#include "sub.h"
int main() {
int result_add = add(1, 2, 3);
int result_sub = sub(5,4);
printf("Add: %d\n", result_add);
printf("Sub: %d\n", result_sub);
return 0;
}
- add.c
#include "add.h"
int add(int a, int b, int c) {
return a + b + c;
}
- add.h
int add(int a, int b, int c);
- sub.c
#include "sub.h"
int sub(int a,int b){
return a - b;
}
- sub.h
int sub(int a,int b);
- Makefile
CC = gcc
CFLAGS = -Wall -Wextra
SRCS = main.c add.c sub.c
OBJS = $(SRCS:.c=.o)
DEPS = $(SRCS:.c=.d)
all: main
main: $(OBJS)
$(CC) $(OBJS) -o main
%.o: %.c
$(CC) -c $(CFLAGS) $< -o $@
%.d: %.c
@$(CC) -MM $< -MF $@
clean:
rm -f $(OBJS) $(DEPS) main.exe
# Dòng này để Makefile đọc các tệp .d nếu có
-include $(DEPS)
Khi chạy lệnh make
thì sẽ sinh ra file main
, các file .o
, và file .d
👉 Nếu bạn muốn hiểu rõ quá trình biên dịch và các file trung gian được tạo ra, hãy đọc bài viết này: Quá trình Compile
All rights reserved