Make neredeyse hepimizin kullandığı bir build programı. İster büyük bir proje olsun ister birkaç satırlık bir deneme kodu olsun amacımıza uygun bir Makefile yazıp programımızı elle derlemek yerine Make kullanmak kullanıcı (programlamacı) için çok daha konforlu oluyor. Her ne kadar Makefile yazmak yorucu bir işlem olmasa da Make'in görece daha az bilinen bazı özellikleri sayesinde Makefile'ımızı boyut olarak yarısına indirmemiz hatta deneme kodları için hepten Makefile yazmamamız mümkün. Bu özelliklerden bahsetmeden önce Make'in ortaya çıkış hikayesinden ve temel çalışma mantığından bahsetmek istiyorum.

Giriş: Ortak Bir Rahatsızlık

Yıllardan 1976... Steve Johnson, Stuart Feldman'ın Bell Labs'taki ofisine koşarak giriyor ve zaten doğru yazılmış bir programı debug etmek için tüm gününü harcadığı için oldukça öfkeli. Sıkıntı tam olarak şöyle: Steve Johnson, source code üzerinde yaptığı düzeltmeleri object code'a çevirmeyi unutuyor ve bu yüzden cc *.o komutu ile object code'ları linklediğinde programında beklediği değişiklikler olmuyor. Aynı şey bir önceki akşam kendi başına gelmiş olan Stuart Feldman'ın aklına bir fikir geliyor ve o haftasonu ortaya Make çıkıyor.

Modern build sistemlerinin atası diyebileceğimiz Make, sağladığı soyutlamalar ve yeniden derlemele işleminin hangi dosyalarda yapılacağını analiz etme özelliğiyle hem yazılımcının yazacağı komutları basitleştirip hem de derleme süresini kısaltarak yazılımcının hayatını kolaylaştırır. 40 yıldan uzun süredir var olmasına karşın ortaya çıkan türevleriyle geçerliliğini hala korumaktadır. Bu türevlerden bazıları Mk (Bell Labs, Plan 9), nmake (Microsoft), ve GNU Make'tir. Bu yazımızda günümüzde evrensel kabul edilen GNU Make üzerinden Make sisteminin bazı özelliklerinden bahsedeceğiz ve yazı boyunca Make'ten kastımız 1988'de GNU projesi tarafından yazılmış olan bu Make türevi olacaktır.

Make Nedir? Ne İşe Yarar?

Şimdiye kadar hayatınızın bir noktasında karşınıza Make sisteminin çıkmamış olması ne kadar olası olmasa da eğer Make'in ne olduğunu bilmiyorsanız bir önceki bölümde ne olduğu hakkında ufak bir fikir sahibi olmuşsunuzdur. Tam bir tanım verecek olursak da Make "içinde belli hedeflere (Target) ulaşmak için —ki bunlar çoğu zaman dosya adıdır— gerekli birtakım gereksinimler (Prerequisite) ve tariflerden (Recipe) oluşan kural blokları bulunan bir dosyayı tarayan ve bu dosyadan yola çıkarak build işleminin nasıl yapılacağını ve hangi kısımlarının atlanabileceğini basit bir algoritmayla belirleyerek zaman ve berimden tasarruf eden bir shell script'i" şeklinde tanımlanabilir. Bu tanımda anatomisinden kabaca bahsettiğimiz kural bloklarının yer aldığı dosyanın adı genellikle Makefile oluyor. Make, kullanıcı tarafından bir target ismiyle birlikte çalıştırıldığında (örneğin make main.o gibi) dizindeki Makefile'ı tarayıp istenen hedefin nasıl elde edileceğini öğrenip gerekli komutları çalıştırır.

Makefile Yazmak

Daha önce de bahsettiğimiz gibi Make, çalıştırıldığı dizinde bulunan Makefile'ı okuyarak build kurallarını öğrenir. Makefile ise çoğunlukla kurallardan (hedef, gereksinim ve tarif bloğu) ve tekrar tekrar aynı şeyi yazmamak için kullanılan değişken tanımlamalarından oluşur. Bir kural yazılırken önce hedefin adı yazılır. Hedefin adından sonra : işareti konur ve gereksinimler boşluk karakteriyle ayrılacak şekilde o satır boyunca yazılır. Sonraki satırlarda ise o kuralın tarifinde yer alan bütün komutlar girintili bir şekilde yazılır. Bu komutlardan her biri build esnasında shell tarafından sırayla çalıştırılır.

main: main.c
    gcc -o main main.c

Yukarıdaki örnekte verilen örnek kuralda hedef main, gereksinim main.c ve bir sonraki satırda yer alan komut ise tarif oluyor. Bir hedefin birden fazla gereksinimi olabileceğini ve birden fazla satırdan oluşan bir tarifinin olabileceğini hatırlatmak isteriz. Kullanıcı make main komutunu çalıştırdığında Make bu kuralı inceleyerek main hedefinin (gerekirse tekrardan) oluşturulmasının gerekliliğine karar verir ve gereklilik durumunda main.c dosyasını derler.

Karar Verme Algoritması

Make'i çok kullanışlı bir program yapan şey basit ama bir o kadar da etkili olan karar verme algoritmasıdır. Bir hedef adıyla çalıştırıldığında Make şu basamakları uygular:

  • Hedefin bir kuralı olup olmadığını kontrol et.
    • Eğer hedefin bir kuralı varsa devam et.
    • Eğer hedefin bir kuralı yoksa hedefin bir dosya olarak dizinde bulunup bulunmadığını kontrol et.
      • Dosya dizindeyse hedef günceldir, hedef build edilmiştir. (Base case)
      • Dosya dizinde yoksa hedefin build edilemeyeceği varsayılır, hata ver. (Base case)
  • Kuralı olan hedefin bütün gereksinimlerini build et. (Bu recursive bir işlemdir, bütün gereksinimler birer hedef olarak kabul edilir.)
  • Hedefi build etmenin gerekliliğini kabul et.
    • Eğer hedef dizinde yoksa hedefi build et.
    • Eğer hedef dizinde varsa gereksinimlerin ve hedefin son değiştirilmelerini karşılaştır.
      • Eğer gereksinimler daha yeniyse hedef güncel değildir, hedefi build et.
      • Diğer türlü hedef günceldir, hedef build edilmiştir.

Bu algoritmayı bir önceki örneğe uygulayalım. Dizinde main isimli executable dosyanın var olduğunu ve bu executable dosyasının son oluşturulmasından beri main.c dosyasının değiştirildiğini varsayalım.

make main 1. main bir hedef mi? Evet, öyleyse bütün gereksinimleri build et. 2. main.c bir hedef mi? Hayır, öyleyse dizinde bu dosyanın varlığını kontrol et. 1. main.c dizinde var mı? Evet, öyleyse main.c günceldir. 3. main dizinde var mı? Evet, öyleyse hedef ve gereksinimlerin son değiştirilmelerini karşılaştır. 4. main'in son değiştirilmesinden beri main.c değiştirildi mi? Evet, o zaman main güncel değildir. main'i build et. 5. gcc -o main main.c komutu çalıştırılır.

Karar verme aşamasında hedef olmayan bir gereksinimin dizinde mevcut olmaması durumunda Make, hedefin build edilemeyeceği sonucunu çıkarır ve kuralın çalışmasını durdurur. Benzer şekilde tarifte yer alan komutların çalıştırılması esnasında bir hatayla karşılaşıldığında da kural durdurulur.

Makefile Yazmamak

Bir kullanıcının Make'in çalışma mantığını ve Makefile yazmayı öğrendikten sonra öğrenmesi gereken şey Makefile yazmamak yani Make'in özelliklerini kullanarak daha kısa ve öz Makefile'lar yazmaktır. Aşağıda naif bir Makefile örneğimiz var. Şimdi Make'in bahsedeceğimiz özelliklerinden faydalanarak bu dosyayı daha kompakt hale getirmeye çalışacağız.

main: main.o lib1.o lib2.o lib3.o
    gcc -o main main.o lib1.o lib2.o lib3.o

main.o: main.c
    gcc -Wall -O2 -ansi -c -o main.o main.c

lib1.o: lib1.c
    gcc -Wall -O2 -ansi -c -o lib1.o lib1.c
    
lib2.o: lib2.c
    gcc -Wall -O2 -ansi -c -o lib2.o lib2.c

lib3.o: lib3.c
    gcc -Wall -O2 -ansi -c -o lib3.o lib3.c

Variable'lar

Makefile'ımızı kompaktlaştırırken ilk amacımız tekrarlardan kurtulmak olacak. Burada göze çarpan başlıca tekrarların sebebi kullanılan compiler flag'ler, kuralların tekdüze olması ve bazılarının birbirine fazlasıyla benzemesi. Tekrarlamaların saydığımız ilk sebebini inceleyelim. Bu tekrar çeşidinden kurtulmak için de Makefile'larda kullanabildiğimiz variable'lardan faydalanacağız. Makefile'daki variable tanımlamaları Bash'tekine benziyor. Değer atamak için VAR = value syntax'ını, herhangi bir variable'ın içindeki değere erişmek için de $(VAR) syntax'ını kullanabiliriz.

Mevcut Makefile örneğimizde tekrar eden compiler flag'leri sadeleştirmek için yerlerine bir değişken kullanalım.

VAR = -Wall -O2 -ansi

main: main.o lib1.o lib2.o lib3.o
    gcc -o main main.o lib1.o lib2.o lib3.o

main.o: main.c
    gcc $(VAR) -c -o main.o main.c

lib1.o: lib1.c
    gcc $(VAR) -c -o lib1.o lib1.c
    
lib2.o: lib2.c
    gcc $(VAR) -c -o lib2.o lib2.c

lib3.o: lib3.c
    gcc $(VAR) -c -o lib3.o lib3.c

Makefile'ımız kısmen daha sade hale geldi. Benzer bir işlemi main'i elde etmek için yazdığımız kuraldaki .o dosyaları için de kullanabilirdik. Compiler flag'ler için bir variable kullanmak compiler flag'leri değiştirmek istediğimiz zaman bütün kurallar için kolayca değiştirmemizi sağlasa da kural yazma aşamasında pek bir kolaylık sağladığı söylenemez. Tek başına variable'ların yeterli olmadığı durumlarda Make bize çok daha etkili yöntemler sağlıyor ama bunlardan bahsetmeden önce variable'larla ilgili değinmek istediğim bir konu daha var.

Implicit Variable'lar

Make, Makefile'ı okumaya başlamadan önce bazı variable'lara kendisi değerler atar. Bu variable'lara implicit variable denir ve az sonra değineceğimiz implicit rule adını verdiğimiz tanımlamadan kullanabildiğimiz kuralların arkasında implicit variable'lar vardır. C/C++ programlamada en çok kullanacağımız implicit variable'lar CC (C compiler, başlangıçta değeri cc), CFLAGS (C compiler flag'leri, başlangıçta değeri "" yani boş string), CXX (C++ compiler'ı, başlangıçta değeri g++) ve CXXFLAGS'tir (CFLAGS'in C++ karşılığı). Bu variable'ları olduğu gibi kullanabiliriz veya kendi istediğimiz değerleri de verebiliriz, CC = gcc gibi. Burada dikkatinizi çekmesini istediğim küçük bir detay var. CC'nin değerini değiştirmeden de Makefile'ımızda kullanabilirdik. Bu herhangi bir problem oluşturmayacaktı çünkü şimdiye kadar test ettiğim bilgisayarlarda PATH'te gcc'ye işaret eden cc isimli bir symbolic link vardı. Bunun POSIX standartlarına uyulması için yapılmış olabileceğine inanıyorum. Bir sonraki adıma geçmeden önce diğer implicit variable'ların da bulunduğu implicit variable listesine buradan ulaşabilirsiniz.

Implicit Kurallar

Makefile'ımızı kısaltmaya çalışırken önce variable'lardan bahsettik, ardından da implicit variable'lardan bahsettik. Ancak şöyle bir sorun var: Implicit variable'lar tek başına normal variable'ların çözemediği herhangi bir sorunu çözmüyor. İşte tam bu noktada implicit kurallardan bahsetmeye başlıyoruz ve Makefile yazmak çok daha enteresan bir hale geliyor!

Makefile'ımızın ilk haline tekrardan bakalım. Burada iki tip kural var. Birincisi .c dosyalarından .o dosyası elde etmeye yarayan yani derleyen kurallar, diğeriyse .o dosyalarını birbiriyle linkleyerek executable dosyasını elde etmemizi sağlayan linker kuralı. Bu iki tip kural da oldukça tipik hatta mekanik. Bu yüzden Make implicit variable'lardan da yardım alarak bu kuralları kendisi türetebiliyor. Örneğin Make herhangi bir .o dosyasını, mesela test.o, test.c dosyasından nasıl elde edebileceğini kendisi bulabiliyor. Bunun için de şu şemayı kullanıyor: $(CC) $(CPPFLAGS) $(CFLAGS) -c.

Yani eğer Makefile'ımızda test.o için bir kural yoksa, biz Make'ten test.o'yu build etmesini istediysek ve son olarak dizinimizde test.c isimli bir dosya varsa Make kendi kendisine cc -c -o test.o test.c komutunu çalıştırabiliyor. Tahmin ettiğiniz gibi bu implicit kural, içerisinde implicit variable'lar barındırıyor. Makefile'ımızda CC veya CFLAGS variable'larının değerlerini değiştirseydik doğrudan derleme komutunu değiştirmiş olacaktık, gcc -Wall -O2 -ansi -c -o test.o test.c gibi.

Implicit kuralların bir başka güzel özelliği gereksinimlerin çoğu zaman opsiyonel olması. Yani test.o hedefi için test.c'nin gerektiğini Make kendisi bulabiliyor, ama biz alışılmışın dışında gereksinimler de eklemek istiyorsak hedef ve gereksinimleri elle belirtip tarifin türetilmesini Make'e bırakabiliriz, test.o: test.c test.h gibi.

Linker kuralları da benzer şekilde kendiliğinden türetilebiliyor. 'test' dosyasını test.o'dan elde etmek için kullanılan şema da şu şekilde: $(CC) $(LDFLAGS) test.o $(LOADLIBES) $(LDLIBS). Yani make test komutunu çalıştırdığımızda eğer Makefile'ımızda bu hedefe ulaşan bir kural yoksa ve dizinimizde test.o isimli bir dosya varsa Make cc test.o -o test çalıştırarak obje dosyasını linkler.

Verdiğimiz Makefile'da da olduğu gibi bazen birden fazla obje dosyasını linklememiz gerekir. Böyle durumlarda Make gereksinimleri kendisi tahmin edemeyeceği için hedef ve gereksinimleri elle belirtmemiz gerekir.

Bu bilgilerden yola çıkarak Makefile'ımızı gözden geçirelim.

CFLAGS = -Wall -O2 -ansi

# linker kuralda sadece gereksinim ve hedefleri belirtmemiz yeterli
main: main.o lib1.o lib2.o lib3.o

Gördüğünüz gibi .o dosyalarımızın hepsi tamamen tekdüze kurallara sahiplerdi ve Make'in kendi şemasına birebir uyuyordu. Bu yüzden bu kuralların hepsini silip bu kuralları bulmayı Make'e bıraktık. make main komutunu çalıştırdığımızda Make main.o lib1.o lib2.o ve lib3.o dosyalarını build etmek isteyecekti ve bunlar için gerekli kuralları kendisi oluşturacaktı. Bu çok karmaşık olmayan sisteme birazcık aşina olarak Makefile'ımızı 10 satır yazıdan 2 satır yazıya indirdik!

Benzer şekilde deneme amaçlı yazdığımız hello.c dosyasını derlemek için gcc komutunu yazmak yerine Makefile bile yazmadan make hello komutunu çalıştırabiliriz ve make bizim için cc hello.c -o hello komutunu çalıştırır. Farklı compiler flag'leri kullanmak için bir Makefile oluşturup içinde sadece CFLAGS'in değerini değiştirip aynı işlemi tekrarlayabiliriz.

Eğer yazı boyunca üzerinde çalıştığımız örnek üzerinde kendiniz çalışmak isterseniz bu repository'e bakabilirsiniz. master branch'te Makefile'ın ilk hali, experimental branch'te ise Makefile'ımızın son hali yer alıyor.

Bu Bergi yazımızın da sonuna geliyoruz ama Make'in bize sunduğu kolaylıklar burada sona ermiyor. Make sayesinde kendi soyut kurallarımızı oluşturabiliriz (pattern rules) hatta bu soyut kuralları sadece bazı hedefler için geçerli hale getirebilirsiniz (static pattern rules) ancak bu konular bu Bergi yazısının kapsamı dışında kaldığı için bu özellikleri öğrenmek için verilmiş bağlantıları ziyaret etmelisiniz. Ayrıca Make programını ayrıntılı bir şekilde öğrenmek isterseniz GNU'nun sitesinde yer alan dokümentasyonun tamamını okuyabilir veya bilgisayarında info programı yüklüyse info make komutuyla bilgisayarınızda okuyabilirsiniz.

Kendinize çok iyi bakın, görüşmek üzere!