Skip to content

Build nhiều file

Bài giới thiệu

Bài này tóm tắt cách tổ chức một project C nhiều file và cách để build tool chỉ build lại đúng phần cần thiết. Cú pháp Makefile và CMake được trình bày riêng ở Makefile và CMake.

Ở bài 1 ta thấy mỗi file .c là một translation unit: được preprocess và compile độc lập thành một .o, rồi linker ghép lại. Hai file chỉ "biết nhau" qua khai báo trong header, còn việc nối thực sự diễn ra ở bước link (bài 3).

Header và source: ai làm gì

Header (.h) Source (.c)
Chứa khai báo: prototype, extern, typedef, struct, macro định nghĩa: thân hàm, biến
Ai include mọi file cần dùng hiếm khi bị include
Sinh ra gì trong .o thường không gì (trừ static inline) code và dữ liệu

Nguyên tắc: header là giao diện (API) của một module, source là cài đặt. Định nghĩa biến/hàm trong header (không phải static/inline) sẽ bị chép vào mọi file include nó, dẫn tới lỗi multiple definition khi link (bài 3).

include/math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H

int square(int x);

#endif /* MATH_UTILS_H */

Include guard (#ifndef/#define/#endif) ngăn header bị chép hai lần vào cùng một translation unit (ví dụ a.h include b.h, main.c include cả hai), vì nếu không, struct/typedef sẽ bị định nghĩa lại. #pragma once ngắn hơn và được mọi compiler chính hỗ trợ nhưng không thuộc chuẩn C.

Linkage: ai nhìn thấy ai

Khai báo Linkage Symbol trong .o
int x = 1; (file scope) external GLOBAL
static int x = 1; internal LOCAL
extern int x; external (chỉ khai báo) UND nếu có dùng
static bên trong hàm không LOCAL, lưu trữ tĩnh

Quy ước thực tế: hàm và biến chỉ dùng trong một file thì khai báo static. Việc này vừa tránh xung đột tên giữa các file, vừa cho compiler thêm cơ hội tối ưu (inline, loại bỏ code không dùng).

Bố cục project

project/
├── include/        # header public
│   └── math_utils.h
├── src/
│   ├── main.c
│   └── math_utils.c
├── Makefile
└── build/          # sinh ra, không commit

Source include header bằng #include "math_utils.h", kèm -Iinclude ở command line, để không phải viết đường dẫn tương đối trong code.

Build incremental với Makefile

Một Makefile tối thiểu, đã thử chạy trên ví dụ trên:

Makefile
CFLAGS := -O2 -g -Wall -Wextra -Iinclude
BUILD  := build
SRCS   := $(wildcard src/*.c)
OBJS   := $(SRCS:src/%.c=$(BUILD)/%.o)
DEPS   := $(OBJS:.o=.d)

app: $(OBJS)
    $(CC) $^ -o $@

$(BUILD)/%.o: src/%.c | $(BUILD)
    $(CC) $(CFLAGS) -MMD -MP -c $< -o $@

$(BUILD):
    mkdir -p $@

clean:
    rm -rf $(BUILD) app

-include $(DEPS)
.PHONY: clean

Điểm chính là cặp -MMD -MP kèm dòng -include $(DEPS).

Vì sao cần -MMD

Rule %.o: %.c chỉ nói .o phụ thuộc vào .c, không biết .c include header nào. Nếu sửa math_utils.h mà make không rebuild, ta có một bản build cũ lệch với header mới. Đây là loại lỗi rất khó tìm (ví dụ sai layout struct giữa hai file).

-MMD bảo gcc ghi thêm một file .d cùng lúc compile, liệt kê header đã include (trừ header hệ thống):

$ cat build/main.d
build/main.o: src/main.c include/math_utils.h
include/math_utils.h:

-include $(DEPS) nạp các file đó vào Makefile. Dòng thứ hai (include/math_utils.h:) do -MP sinh ra: một rule rỗng cho từng header, để khi xoá hoặc đổi tên header, make không báo lỗi No rule to make target.

Kết quả:

$ make
cc -O2 -g -Wall -Wextra -Iinclude -MMD -MP -c src/main.c -o build/main.o
cc -O2 -g -Wall -Wextra -Iinclude -MMD -MP -c src/math_utils.c -o build/math_utils.o
cc build/main.o build/math_utils.o -o app
$ make
make: 'app' is up to date.
$ touch include/math_utils.h && make       # header đổi => file include nó được build lại

Build song song

Vì mỗi .o độc lập với nhau, make -j$(nproc) build song song các file .c. Bước link phải đợi tất cả .o, nên là điểm tuần tự duy nhất.

Cross compile chỉ là đổi biến

Vì Makefile dùng $(CC), đổi toolchain chỉ cần:

$ make clean && make CC=arm-linux-gnueabihf-gcc
$ file app
app: ELF 32-bit LSB pie executable, ARM, EABI5 version 1 (SYSV), ...

Trong build system lớn (Linux kernel, U-Boot, Buildroot), biến này thường là tiền tố CROSS_COMPILE=arm-linux-gnueabihf-, xem bài 6.

Từ Makefile sang CMake

Khi project lớn lên, viết tay danh sách phụ thuộc, quản lý nhiều toolchain, cấu hình -D theo target trở nên khó. CMake mô tả project ở mức cao hơn (target, include directory, link library) rồi sinh ra Makefile hoặc Ninja. Cùng project trên:

CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(app C)

add_executable(app src/main.c src/math_utils.c)
target_include_directories(app PRIVATE include)
target_compile_options(app PRIVATE -Wall -Wextra)

CMake tự sinh dependency header (tương đương -MMD) và hỗ trợ cross compile qua toolchain file, xem CMake.

Tóm tắt

  • Mỗi .c là một translation unit; header chỉ chứa khai báo, định nghĩa ở .c.
  • Include guard chống include trùng; static cho symbol chỉ dùng nội bộ file.
  • Rule %.o: %.c thiếu phụ thuộc header: dùng -MMD -MP + -include $(DEPS).
  • Build incremental và song song (make -j) là lợi ích chính của việc tách compile và link.

Tiếp theo: Cross compile.

References