6 คะแนน โดย GN⁺ 2025-06-21 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • Makefile เป็นเครื่องมือที่ช่วยให้การทำ build อัตโนมัติและการจัดการ dependency ของ C/C++ ง่ายขึ้น
  • ใช้วิธี ตรวจจับไฟล์ที่เปลี่ยนแปลงด้วย timestamp เพื่อคอมไพล์เฉพาะเมื่อจำเป็นเท่านั้น
  • อธิบาย โครงสร้างหลัก อย่าง rule, command และ prerequisite พร้อมตัวอย่าง
  • ครอบคลุมฟีเจอร์ขั้นสูงอย่าง automatic variables, pattern rules และ variable expansion ในเชิงปฏิบัติ
  • แนะนำความสำคัญของการขยายระบบและการดูแลรักษาผ่าน เทมเพลต Makefile ใช้งานจริง สำหรับโปรเจ็กต์ขนาดกลาง

แนะนำคู่มือสอน Makefile

  • Makefile เป็นเครื่องมือหลักสำหรับการทำ build อัตโนมัติและจัดการ dependency ของโปรเจ็กต์
  • แม้จะดูซับซ้อนสำหรับผู้เริ่มต้นเพราะมีทั้งกฎและสัญลักษณ์แฝงหลายแบบ แต่คู่มือนี้สรุปประเด็นสำคัญด้วยตัวอย่างที่กระชับและนำไปรันได้ทันที
  • แต่ละส่วนสามารถทำความเข้าใจได้ผ่านตัวอย่างแบบลงมือปฏิบัติ

เริ่มต้นใช้งาน

จุดประสงค์ของ Makefile

  • Makefile ใช้เพื่อ คอมไพล์ซ้ำเฉพาะส่วนที่มีการเปลี่ยนแปลง ในโปรแกรมขนาดใหญ่
  • นอกจาก C/C++ แล้ว ภาษาต่าง ๆ ก็มีเครื่องมือ build เฉพาะของตนเอง แต่ Make ยังใช้ได้กับสถานการณ์การ build ทั่วไป
  • แกนสำคัญคือ ตรวจจับไฟล์ที่เปลี่ยนและรันเฉพาะงานที่จำเป็น

ระบบ build ทางเลือกของ Make

  • สาย C/C++: มีตัวเลือกอย่าง SCons, CMake, Bazel, Ninja เป็นต้น
  • สาย Java: Ant, Maven, Gradle เป็นต้น
  • Go, Rust, TypeScript ก็มีเครื่องมือ build ของตัวเองเช่นกัน
  • ภาษาแบบ interpreter อย่าง Python, Ruby, JavaScript ไม่ต้องคอมไพล์ จึงมีความจำเป็นน้อยกว่าที่จะต้องจัดการแยกด้วย Makefile

เวอร์ชันและชนิดของ Make

  • แม้จะมี Make หลาย implementation แต่คู่มือนี้ปรับให้เหมาะกับ GNU Make (ใช้เป็นหลักบน Linux และ MacOS)
  • ตัวอย่างทั้งหมดใช้ได้กับ GNU Make เวอร์ชัน 3 และ 4

วิธีรันตัวอย่าง

  • ติดตั้ง make ในเทอร์มินัล จากนั้นบันทึกแต่ละตัวอย่างเป็นไฟล์ Makefile แล้วรันคำสั่ง make
  • บรรทัดคำสั่งใน Makefile ต้องย่อหน้าด้วย อักขระแท็บ เท่านั้น

ไวยากรณ์พื้นฐานของ Makefile

โครงสร้างของกฎ (Rule)

  • target: prerequisite(s)

    • command
    • command
  • target: ชื่อไฟล์ผลลัพธ์จากการ build (ปกติมีหนึ่งไฟล์)

  • command: shell script ที่ถูกรันจริง (ขึ้นต้นด้วยแท็บ)

  • prerequisite: รายชื่อไฟล์ที่ต้องพร้อมก่อนจะ build target


แก่นของ Make

ตัวอย่าง Hello World

hello:  
	echo "Hello, World"  
	echo "This line will print if the file hello does not exist."  
  • target hello ไม่มี dependency และรันคำสั่ง 2 บรรทัด
  • เมื่อรัน make hello หากยังไม่มีไฟล์ hello คำสั่งจะถูกรัน แต่ถ้ามีไฟล์อยู่แล้วจะไม่รัน
  • โดยทั่วไปมักเขียนให้ target ตรงกับชื่อไฟล์

ตัวอย่างพื้นฐานของการคอมไพล์ไฟล์ C

  1. สร้างไฟล์ blah.c (มีเนื้อหา int main() { return 0; })
  2. เขียน Makefile ดังนี้
blah:  
	cc blah.c -o blah  
  • เมื่อรัน make หากยังไม่มี target blah ก็จะคอมไพล์และสร้างไฟล์ blah
  • แต่ถึง blah.c จะถูกแก้ไข ก็จะไม่คอมไพล์ใหม่อัตโนมัติ → ต้องเพิ่ม dependency

วิธีเพิ่ม dependency

blah: blah.c  
	cc blah.c -o blah  
  • ตอนนี้ถ้า blah.c มีการเปลี่ยนแปลงใหม่ target blah จะถูก build อีกครั้ง
  • ใช้ timestamp ของไฟล์เป็นเกณฑ์ในการตรวจจับการเปลี่ยนแปลง
  • หากแก้ timestamp เอง อาจทำให้การทำงานไม่เป็นไปตามที่ตั้งใจ

ตัวอย่างเพิ่มเติม

ตัวอย่าง target และ 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 ตามโครงสร้างแบบต้นไม้ และทำแต่ละขั้นตอนโดยอัตโนมัติ

ตัวอย่าง target ที่รันทุกครั้ง

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 ไม่ได้ถูกสร้างเป็นไฟล์จริง คำสั่งของ some_file จึงถูกรันทุกครั้ง

Make clean

  • target clean มักใช้สำหรับลบผลลัพธ์ที่ได้จากการ build
  • ไม่ใช่คำสงวนพิเศษของ Make จึงต้องนิยามคำสั่งเอง
  • หากมีไฟล์ชื่อ clean อาจทำให้เกิดความสับสน จึงแนะนำให้ใช้ .PHONY

ตัวอย่าง:

some_file:   
	touch some_file  
  
clean:  
	rm -f some_file  

การจัดการตัวแปร

  • ตัวแปรเป็น สตริง เสมอ
  • โดยทั่วไปแนะนำให้ใช้ := และยังมีรูปแบบการกำหนดค่าอื่น ๆ เช่น =, ?=, +=
  • ตัวอย่างการใช้งาน:
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  
  • การอ้างอิงตัวแปรใช้ได้ทั้ง $(variable) หรือ ${variable}
  • เครื่องหมายคำพูดใน Makefile ไม่มีความหมายสำหรับ Make เอง (แต่จำเป็นในคำสั่ง shell)

การจัดการ target

target all

  • หากต้องการรันหลาย target พร้อมกัน ให้กำหนด target แรก (ค่าเริ่มต้น) ให้รวมสิ่งที่ต้องการไว้
all: one two three  
  
one:  
	touch one  
two:  
	touch two  
three:  
	touch three  
  
clean:  
	rm -f one two three  

หลาย target และ automatic variables

  • สามารถรันคำสั่งแยกให้แต่ละ target ได้ โดย $@ จะเก็บชื่อ target ปัจจุบัน
all: f1.o f2.o  
  
f1.o f2.o:  
	echo $@  

Automatic variables และ wildcard

wildcard *

  • * ใช้ค้นหาชื่อไฟล์จากไฟล์ซิสเต็มโดยตรง
  • แนะนำให้ครอบด้วยฟังก์ชัน wildcard เสมอเมื่อนำมาใช้
print: $(wildcard *.c)  
	ls -la  $?  
  • ไม่ควรใช้ * ตรง ๆ ในการกำหนดตัวแปร
thing_wrong := *.o  
thing_right := $(wildcard *.o)  

wildcard %

  • มักใช้ใน pattern rules เพื่อดึงและขยายตามแพตเทิร์นที่กำหนด

Fancy Rules

กฎแบบ implicit

  • Make มี กฎพื้นฐานแบบซ่อน หลายอย่างในตัวสำหรับการ build C/C++
  • ตัวแปรที่พบบ่อย: CC, CXX, CFLAGS, CPPFLAGS, LDFLAGS เป็นต้น
  • ตัวอย่าง C:
CC = gcc   
CFLAGS = -g   
  
blah: blah.o  
  
blah.c:  
	echo "int main() { return 0; }" > blah.c  
  
clean:  
	rm -f blah*  

Static Pattern Rules

  • เขียนหลายกฎที่มีแพตเทิร์นเดียวกันให้กระชับขึ้นได้
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 + ฟังก์ชัน filter

  • ใช้ filter เพื่อเลือกเฉพาะรายการที่ตรงกับแพตเทิร์นนามสกุลที่ต้องการได้
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 ความคิดเห็น

 
GN⁺ 2025-06-21
ความคิดเห็นจาก Hacker News
  • มีคนหนึ่งเล่าถึงประสบการณ์ที่เคยเห็นด้วยตาตัวเองในปี 1985 ที่ห้องแล็บกราฟิกของ Boston University ตอนที่มีคนใช้ Makefile สร้าง 3D renderer สำหรับงานแอนิเมชัน คนคนนั้นเป็นโปรแกรมเมอร์ Lisp กำลังทำระบบสร้าง procedure ระยะแรกและระบบ 3D actor อยู่ และได้เขียน Makefile ที่สง่างามมากยาวแค่ประมาณ 10 บรรทัด โครงสร้างนี้ใช้ dependency จากเวลาแก้ไขไฟล์แบบเรียบง่ายเพื่อสร้างแอนิเมชันหลายร้อยชุดโดยอัตโนมัติ รูปร่าง 3D ของแต่ละเฟรมสร้างด้วย Lisp แล้วให้ Make เป็นตัวสร้างเฟรม ในปี 1985 นั้นต่างจากทุกวันนี้ที่ 3D และแอนิเมชันเป็นเรื่องปกติ ทุกคนในตอนนั้นตื่นตะลึงกันมาก และเขาจำได้ว่าคนคนนั้นคือ Brian Gardner ผู้ที่ต่อมารับผิดชอบ 3D renderer ของ Iron Giant และ Coraline

    • มีคนสงสัยว่าคนที่พูดถึงคือคนใน 3d-consultant.com/bio.html หรือไม่

    • มีคนถามยืนยันว่าหมายถึงภาพยนตร์เรื่อง Coraline ใช่ไหม

  • มีการแนะนำแฟล็กที่มีประโยชน์แต่ไม่ค่อยเป็นที่รู้จักบางตัวตอนใช้ Make

    • --output-sync=recurse -j10: เป็นแฟล็กที่รวบรวม stdout/stderr ไว้จนกว่างานของแต่ละ target จะเสร็จแล้วค่อยแสดงผล ไม่อย่างนั้น log จะปนกันจนวิเคราะห์ได้ยาก
    • บนระบบที่งานเยอะหรือมีผู้ใช้หลายคน สามารถใช้ --load-average แทน -j เพื่อควบคุมภาระของระบบระหว่างประมวลผลแบบขนานได้ (make -j10 --load-average=10)
    • ตัวเลือก --shuffle ซึ่งสุ่มลำดับตารางงานของ build target มีประโยชน์ในการจับปัญหา dependency ภายใน Makefile ในสภาพแวดล้อม CI
    • มีการพูดถึงไอเดียว่า ถ้ารวบรวมตัวเลือกต่าง ๆ ของ make อย่างเป็นทางการแล้วใส่ไว้ในโปรแกรมในรูปแบบข้อความหรือเอกสาร ก็จะช่วยให้เข้าถึงการใช้งานได้ง่ายขึ้น

    • มีคนอธิบายว่าออปชันที่ตัวเองใช้บ่อยคือแฟล็ก -B สำหรับบังคับ build ใหม่ทั้งหมด

    • มีคนบอกว่าตัวเองเห็นปัญหาที่เกิดจาก make -j บนเครื่อง DOS บ่อยมากจนมองว่าปรากฏการณ์นั้นเป็นบั๊ก

    • มีคนถามว่าในระบบที่งานเยอะหรือมีผู้ใช้หลายคน ปัญหาเรื่อง parallelization ไม่ควรเป็นสิ่งที่ OS scheduler จัดการหรือ

    • มีคนแนะนำว่าแม้จะเป็นแฟล็กที่มีประโยชน์ แต่ตัวเลือกเหล่านี้ไม่ portable ดังนั้นไม่ควรใช้ยกเว้นในโปรเจกต์ส่วนตัวที่ไม่เปิดเผยของตัวเอง

  • มีความเห็นว่าการข้าม .PHONY ในบทสอนเพียงเพราะไม่ได้ใช้ เป็นข้ออ้างที่อ่อนเกินไป ควรสอนวิธีใช้เครื่องมือให้ถูกต้องมากกว่า

    • ในทีมหนึ่งมีการถกเถียงกันเพราะใช้ Make เป็น task runner แล้วต้องคอยเพิ่มและดูแล .PHONY ให้ทุก recipe
    • มีคนแนะนำคู่มือสไตล์ Makefile ของ Clark Grubb (clarkgrubb.com/makefile-style-guide)
    • มีการแชร์ประสบการณ์หลายสไตล์ระหว่างการประกาศ .PHONY แยกตาม recipe กับการรวบรวมไว้ครั้งเดียวด้านบนของไฟล์ และหวังว่าจะมีการบังคับด้วย linter
    • มีคนบอกว่าอ่านแล้วเป็นเอกสารที่ใช้ได้ แต่มีบางจุดที่ไม่เห็นด้วย
      • การใช้ -o pipefail แบบไม่คิดหน้าคิดหลังเป็นปัญหา และอาจพังเมื่อใช้ grep หรือคำสั่งอื่นใน pipe จึงแนะนำให้ใช้ตามบริบท
      • การทำเครื่องหมาย target ที่ไม่ใช่ไฟล์ด้วย .PHONY อาจเข้มงวดถูกต้องก็จริง แต่แทบไม่จำเป็นและทำให้ Makefile เยิ่นเย้อเกินไป จึงมองว่าควรใช้เมื่อจำเป็นเท่านั้น
      • recipe ที่สร้างไฟล์ผลลัพธ์หลายไฟล์ แต่ก่อนมักใช้ไฟล์ dummy แต่ตั้งแต่ GNU Make 4.3 เป็นต้นมา มีการรองรับ grouped targets อย่างเป็นทางการแล้ว (ดูที่นี่)
  • มีคนอ้างว่า Make เป็นเครื่องมือที่ออกแบบมาเฉพาะสำหรับการ build codebase C ขนาดใหญ่

    • มีคนหนึ่งบอกว่าตัวเองชอบใช้มันเป็น job runner รายโปรเจกต์ แต่ Make ไม่เหมาะกับการเป็น job runner และแม้แต่สิ่งอย่างเงื่อนไขก็ทำให้ยากโดยโครงสร้างของมันเอง
    • ยังเคยเห็นกรณีพยายามเอาไปครอบเครื่องมืออย่าง Terraform แล้วล้มเหลวด้วย
    • มีความเห็นว่า Make ไม่ได้เป็น job runner แต่เป็น shell tool ทั่วไปที่แปลง shell script แบบเชิงเส้นให้เป็นรูป dependency แบบประกาศ

    • มีคนแย้งว่ามุมมองที่เห็น Make เป็นแค่เครื่องมือ build สำหรับ codebase C อย่างเดียวไม่ถูกต้องอีกต่อไป และชี้ว่าตลอด 20 ปีที่ผ่านมาได้มีระบบ build ที่แข็งแรงและชัดเจนกว่านี้ถูกพัฒนาขึ้นแล้ว จึงควรอัปเดตมุมมอง

    • มีคนถามว่า job runner ที่ดีคืออะไร พร้อมขอโทษว่าตัวเองอาจเข้าใจความหมายของคำว่า job runner สับสน

  • มีคนแนะนำ just ว่าเป็นเครื่องมือสมัยใหม่ที่มาแทนส่วนที่ Makefile มักซับซ้อน

    • แต่ก็มีคนบอกว่า just เหมาะกับการแทนรายการ shell script เท่านั้น และไม่สามารถแทนความสามารถแก่นของ Make ที่คือ “รันเฉพาะกฎที่จำเป็นต้องรันใหม่” ได้

    • ทางเลือกอื่น ๆ ก็มี

      • Task(Go) go-task/task
      • Cake(C#) cake-build/cake
      • Rake(Ruby) ruby/rake
      • และ Makedown ซึ่งเป็นแนวคิดที่ต่างออกไปโดยสิ้นเชิง: มีการพูดคุยใน HN discussion
    • มีคนมองว่าเครื่องมือทางเลือกต่างก็ประกาศตัวว่าเป็น Make replacement แต่จริง ๆ แล้วแตกต่างกันโดยสิ้นเชิงจนเทียบกันยาก แก่นของ Make คือการสร้าง artifact และไม่ build ซ้ำสิ่งที่สร้างไปแล้ว ขณะที่ just ทำหน้าที่เป็นตัวรันคำสั่งอย่างง่ายเท่านั้น

    • ข้อดีของการใช้ Make เป็นตัวรันคำสั่งคือความมั่นคงในฐานะเครื่องมือมาตรฐานที่ติดตั้งอยู่แทบทุกที่ ต่อให้ทางเลือกอื่นจะออกแบบมาดีกว่า ก็ยังไม่รู้สึกว่าคุ้มจะต้องติดตั้งเพิ่ม

    • มีคนบอกว่าใช้ Task กับโปรเจกต์งานอดิเรกเล็ก ๆ ที่เขียนด้วย C ได้ดี แต่ยังตัดสินไม่ได้ว่าเหมาะกับโปรเจกต์ขนาดใหญ่ด้วยหรือไม่ (หน้าเว็บทางการของ Task)

  • มีคนเห็นว่าน่าสนใจที่ช่วงหลัง CMake เลือก ninja เป็นค่าเริ่มต้น เพราะมองว่า Makefile ไม่เหมาะกับการรองรับ C++20 modules (คู่มือ CMake)

    • ในทางปฏิบัติแทบเป็นไปไม่ได้เลยที่จะนิยาม target dependency แบบ static จึงต้องใช้วิธีวิเคราะห์แบบ dynamic ด้วยเครื่องมืออย่าง clang-scan-deps (สไลด์ทางเทคนิค)
    • มีคนแย้งว่าข้อจำกัดนี้น่าจะเป็นการตัดสินใจฝั่ง CMake หรือเป็นปัญหาที่ไม่มีอาสาสมัครมาช่วยทำให้ generator ของ Makefile รองรับมากกว่า เพราะ ninja เองก็ยังไม่รองรับ C++ modules โดยตรง (ประเด็นที่เกี่ยวข้อง) และจริง ๆ แล้ว ninja มีความสามารถน้อยกว่า Make อีกทั้งยังบังคับให้ระบุ dependency ทั้งหมดแบบ static ด้วย

    • มีคนแสดงความเห็นว่าการนำ modules เข้ามาเองก็ซับซ้อนและชวนสับสน

  • มีคนถามว่าใครเคยมีประสบการณ์ใช้ tup บ้างไหม (เอกสารทางการ)

    • tup เป็น build system ที่ตรวจจับ dependency อัตโนมัติจากการเข้าถึงระบบไฟล์ จึงใช้ได้กับ compiler/เครื่องมือแทบทุกชนิด
  • มีคนแนะนำตัวว่าเป็นผู้สร้างและผู้ดูแลหลักของ Task ซึ่งเป็นเครื่องมือทางเลือกแทน Make โดยพัฒนามานานกว่า 8 ปีและยังพัฒนาต่อเนื่อง

    • มีคนแนะนำ just เช่นกันว่าเป็นอีกหนึ่งทางเลือกแทน Make (just GitHub)

    • มีคนบอกว่าเป็นเรื่องบังเอิญน่าสนุก เพราะตัวเองใช้ Task บ่อย และเช้าวันนี้ก็เพิ่ง เปิด issue

  • มีความเห็นว่าบทสอนนี้มีปัญหาที่อันตรายและละเอียดอ่อนอยู่

    • เวลา parse ออปชันจาก MAKEFLAGS ถ้าจะรองรับ long option หรือ short option ที่ว่างอยู่ ควรเขียนแบบนี้
      ifneq (,$(findstring t,$(firstword -$(MAKEFLAGS))))
    • ถ้าจำเป็นต้องรองรับ make เวอร์ชันเก่าที่มาพร้อม OS X จะพบว่าฟีเจอร์จำนวนมากไม่มีหรือมีพฤติกรรมต่างออกไปแบบละเอียดอ่อน
    • นอกเหนือจากนี้ ปัญหาส่วนใหญ่เป็นเพียง typo หรือการละเมิด style ที่ดีที่สุด จึงขอละไว้
    • เพิ่มเติมว่า load portable กว่า guile และในสภาพแวดล้อม cross-compilation ควรระบุ compiler ให้ถูกต้องอย่างแม่นยำ
    • มีการแนะนำให้อ่าน Paul’s Rules of Makefiles (ที่นี่) และ GNU make manual (ที่นี่) รวมถึงคู่มือที่เกี่ยวข้องให้ครบ
    • ยังมีโปรเจกต์ Makefile demo แบบเรียบง่ายที่เจ้าตัวดูแลอยู่ด้วย (demo github)
  • มีคนบอกว่าตัวเองติดนิสัยใส่ Makefile ไว้ในทุก GitHub repo เสมอ

    • เพราะมักลืมคำสั่งที่ต้องใช้ พอเก็บไว้ใน Makefile ก็เพิ่มขั้นตอนที่ซับซ้อนได้ง่าย และแค่รัน make ก็ทำงานตามที่คาดหวังของแต่ละโปรเจกต์ได้ทันทีโดยไม่ต้องจำอะไรเพิ่ม