aosp 빌드 과정 - soong, kati, ninja
AOSP 빌드를 처음 접하면 Soong, kati, Ninja가 동시에 등장한다. 이 셋이 각각 어떤 역할을 하는지 모르면, 빌드 에러가 났을 때 어디서부터 봐야 할지 감이 안 잡힌다.
구조는 생각보다 단순하다.
Soong과 kati는 “무엇을 어떻게 만들지”를 정리해서 그래프로 만들고, Ninja는 그 그래프를 실제로 실행한다.
Soong: Android.bp를 기반으로 빌드 그래프 생성
Soong은 Android.bp를 읽어서 모듈을 분석하고, 각각의 조건에 맞는 형태로 확장한다. (arch, partition, sdk 같은 것들)
그리고 각 모듈이 어떤 명령으로 빌드되어야 하는지를 정리한 뒤, Ninja가 실행할 수 있는 형태로 변환한다.
이 과정에서 만들어지는 결과가 out/soong 아래에 쌓이고 최종적으로는 .ninja 파일 형태의 빌드 그래프가 생성된다.
즉 Soong은 실제로 컴파일을 수행하는 도구가 아니라 "어떤 컴파일이 이루어져야 하는지”를 정의하는 단계에 가깝다.
예를 들어 hello.cpp를 빌드하는 아주 단순한 C++ 바이너리가 있다고 해보자.
cc_binary {
name: "hello",
srcs: ["hello.cpp"],
shared_libs: ["liblog"],
cflags: ["-Wall"],
}
이 Android.bp를 Soong이 읽으면, 내부적으로는 대략 이런 정보를 정리하게 된다.
- 모듈 이름은 hello
- 소스 파일은 hello.cpp
liblog에 의존-Wall옵션을 넣어 컴파일- 최종 결과물은 실행 파일
Soong은 이 선언을 바탕으로 어떤 컴파일 명령과 링크 명령이 필요한지 계산해서, Ninja가 실행할 수 있는 규칙으로 바꾼다.
Android.bp는 빌드 의도를 선언하는 파일에 가깝다는 걸 알 수 있다.
variant가 붙는 경우
AOSP에서는 같은 모듈이라도 대상 환경에 따라 여러 형태로 나뉜다.
cc_library {
name: "libsample",
srcs: ["sample.cpp"],
vendor_available: true,
shared_libs: ["libbase"],
}
이 경우 Soong은 단순히 libsample 하나만 보는 게 아니라
- system에서 쓰는 변형
- vendor에서 쓰는 변형
- arm / arm64 같은 아키텍처별 변형
이런 식으로 내부적으로 여러 variant를 만든다.
그래서 빌드 에러를 볼 때도 “모듈이 안 된다”가 아니라 어느 variant에서 안 되는가를 봐야 한다.
예를 들어 이런 에러가 뜬다면:
error: vendor variant of "libsample" cannot link against "libplatform_only"
이건 그냥 라이브러리 에러가 아니라 vendor variant 규칙 위반이다.
kati: Android.mk를 Ninja로 변환
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := hello
LOCAL_SRC_FILES := hello.cpp
LOCAL_SHARED_LIBRARIES := liblog
LOCAL_CFLAGS := -Wall
include $(BUILD_EXECUTABLE)
AOSP에는 아직도 Android.mk 기반 코드가 남아 있다. 이걸 그대로 Make로 실행하면 속도와 관리 측면에서 문제가 생긴다.
그래서 kati가 중간에 들어간다.
kati는 Android.mk를 읽어서 내부의 변수, 조건문, include 등을 모두 해석한 뒤 어떤 파일을 어떤 방식으로 빌드해야 하는지를 계산하고, 그 내용을 Ninja 규칙으로 변환한다.
정리하면 kati는 Make를 실행하는 도구가 아니라, Make를 “Ninja가 이해할 수 있는 형태로 바꿔주는 변환기”다.
왜 Soong과 kati를 거쳐서 Ninja로 갈까??
이 구조는 실행 엔진을 하나로 통일하기 위해 만들어졌다.
Make는 대규모 프로젝트에서 해석 비용이 크고, 증분 빌드나 병렬 처리 측면에서도 Ninja만큼 효율적이지 않다.
그래서
- Soong → Ninja
- kati → Ninja
이렇게 두 경로를 모두 Ninja로 가게 하고, 실제 빌드는 Ninja가 담당하도록 구성되어 있다.
Soong이나 kati를 거치고 나면 최종적으로 Ninja가 읽을 수 있는 규칙이 만들어진다. 형태는 대략 이런 느낌이다.
rule cxx
command = clang++ -c $in -o $out $cflags
description = Compile $in
rule link
command = clang++ $in -o $out $ldflags
description = Link $out
build out/obj/hello.o: cxx hello.cpp
cflags = -Wall
build out/hello: link out/obj/hello.o
ldflags = -llog
rule은 어떤 명령을 실행할지를, build는 어떤 입력으로 어떤 출력을 만들지 정하는 것이다.
Ninja는 여기 적힌 내용을 그대로 실행한다.
Soong이나 kati처럼 “해석”을 오래 하는 게 아니라 이미 정리된 그래프를 따라가기만 한다.
입력 파일이 변경되었는지 확인하고 변경된 부분에 영향을 받는 타깃만 다시 빌드하는 구조 덕분에 증분 빌드가 빠르게 동작한다.
중요한 점은 증분 빌드를 “가능하게 만드는 것”은 Soong과 kati지만 실제로 증분 빌드를 수행하는 것은 Ninja라는 점이다.
clean이 필요한 상황이 생기는 이유
가끔 변경사항이 있는데도 빌드가 다시 되지 않는 경우가 있다.
내 경우인데, 같은 소스를 받았다고 생각했는 데 다른 브랜치에서 소스를 가져온 게 파일이름이 겹쳐 잘못 sync된 게 있었다.
이렇게 되면 빌드가 안된다..!! 이 문제는 대부분 Ninja가 변경을 감지하지 못했기 때문이다.
예를 들어 이런 규칙이 있다고 해보자.
build out/obj/hello.o: cxx hello.cpp
build out/hello: link out/obj/hello.o
이 상태에서 hello.cpp를 수정하면 out/obj/hello.o가 다시 빌드되고, 그에 따라 out/hello도 다시 링크될 것이다.
반대로 아무 입력도 안 바뀌면 Ninja는 아무 것도 안 한다.
이게 증분 빌드의 기본 원리다.
문제는 의존성이 빠져 있으면 이런 식으로 된다.
build out/obj/hello.o: cxx hello.cpp
원래는 hello.h도 포함하고 있는데 depfile이 누락돼서 그래프에 안 잡혀 있다고 가정하면 hello.h를 바꿔도 Ninja는 그 사실을 모른다. 그래서 out/obj/hello.o를 다시 만들지 않게 되는 것이다.
이게 바꿨는데 빌드가 안 따라가는 원인이다...
기존 산출물과 현재 소스 상태가 서로 안 맞는 상태가 돼서 Ninja가 “변경 없음”으로 판단하기 때문에 재빌드가 일어나지 않기 때문에 clean을 통해 강제로 전체를 다시 빌드해야 한다.