Device Tree 이해

같은 SoC를 쓰는 보드라도 UART/GPIO 배선, 메모리 크기, 인터럽트 번호는 보드마다 다르다. 과거 ARM Linux는 이런 정보를 보드별 C 코드(board file)로 하드코딩했는데, 지원 보드가 수백 종으로 늘면서 커널 트리 유지보수가 감당이 안 되는 지경에 이르렀다. Device Tree는 이 하드웨어 구성 정보를 커널 코드에서 분리해 별도의 데이터로 표현하는 방식으로, 같은 커널 이미지에 보드에 맞는 Device Tree Blob만 갈아 끼우면 되게 만든다. 이 글에서는 Device Tree의 노드/속성 구조를 실제 커널 소스의 DTS 파일과 dtc 컴파일 결과로 살펴본다.

구조: 노드와 속성

Device Tree는 name@address 형식의 노드가 트리로 중첩된 구조이고, 각 노드는 키-값 형태의 속성(property)을 가진다. ARM Versatile Express(Cortex-A9) 보드의 실제 커널 소스 일부를 보면 구조가 바로 눈에 들어온다.

memory@60000000 {
	device_type = "memory";
	reg = <0x60000000 0x40000000>;
};

cpus {
	#address-cells = <1>;
	#size-cells = <0>;

	A9_0: cpu@0 {
		device_type = "cpu";
		compatible = "arm,cortex-a9";
		reg = <0>;
		next-level-cache = <&L2>;
	};
};

주요 속성의 의미는 다음과 같다.

속성의미
compatible매칭할 드라이버를 찾는 키. 문자열 리스트로, 가장 구체적인 것부터 나열된다.
reg주소/크기 쌍. 몇 개의 32비트 셀로 해석할지는 부모의 #address-cells/#size-cells가 결정한다.
interrupts인터럽트 번호(및 인터럽트 컨트롤러별 추가 셀).
&label다른 노드를 가리키는 참조. 컴파일되면 정수 phandle로 바뀐다(아래 실습에서 직접 확인).

실제 DTS 컴파일해보기

vexpress-v2p-ca9.dts#includevexpress-v2m.dtsi를 끌어오므로, 커널 빌드 시스템과 동일하게 cpp로 먼저 전처리한 뒤 dtc에 넘긴다.

$ cpp -nostdinc -undef -x assembler-with-cpp -I . -I ~/linux/include \
    vexpress-v2p-ca9.dts | dtc -I dts -O dtb -o vexpress.dtb -
vexpress-v2m.dtsi:88.21-96.6: Warning (simple_bus_reg): /bus@40000000/motherboard-bus@40000000/flash@0,00000000: simple-bus unit address format error, expected "0"
...
$ ls -la vexpress.dtb
-rw-rw-r-- 1 noble noble 14329 ...  vexpress.dtb

simple-bus 주소 형식 경고는 이 보드의 iofpga 버스 노드들이 실제로 갖고 있는 기존 경고라 무시해도 되고, .dtb는 정상 생성됐다. 이걸 다시 디컴파일해서 컴파일러가 무엇을 해석/치환했는지 확인한다.

$ dtc -I dtb -O dts vexpress.dtb | grep -A5 'serial@9000\|cpu@0'
serial@9000 {
    compatible = "arm,pl011\0arm,primecell";
    reg = <0x9000 0x1000>;
    interrupts = <0x05>;
    clocks = <0x09 0x05>;
    clock-names = "uartclk\0apb_pclk";
};
cpu@0 {
    device_type = "cpu";
    compatible = "arm,cortex-a9";
    reg = <0x00>;
    next-level-cache = <0x11>;
};

두 가지가 소스와 다르게 보인다. compatible"arm,pl011", "arm,primecell" 두 문자열이 널 바이트로 이어진 하나의 문자열 리스트로 저장돼 있고(디컴파일러가 \0으로 구분해 보여줌), 소스에서 &L2였던 next-level-cache0x11이라는 정수 phandle로 바뀌어 있다. 소스 코드의 라벨(&L2)은 컴파일 편의를 위한 것이고, 실제 바이너리에는 phandle 정수만 남는다는 걸 직접 확인한 셈이다.

부트로더의 역할

커널은 스스로 하드웨어를 찾지 못하므로, 부트로더가 커널과 .dtb를 같은 메모리에 올려두고 그 주소를 커널에 알려준다. U-Boot 기준 명령은 다음과 같다.

bootm <kernel_address> - <dtb_address>

부팅이 끝난 뒤에는 커널이 파싱한 결과를 /proc/device-tree에서 노드/속성 파일 형태로 그대로 볼 수 있다(디바이스 트리로 부팅한 ARM 보드에서만 존재하고, 이 글의 실습 환경인 x86 호스트에는 없다).

주의사항

  • compatible은 배열이다. 드라이버는 가장 구체적인 문자열부터 매칭을 시도하므로, 여러 개를 나열할 때는 순서가 곧 우선순위다.
  • 노드의 reg 셀 개수는 노드 자신이 아니라 부모#address-cells/#size-cells가 정한다. 부모를 안 보고 자식 노드만 보면 셀 개수를 잘못 해석하기 쉽다.
  • dtc#include(cpp 전처리)와 /include/(dtc 네이티브)를 모두 지원하지만, dt-bindings/*.h 매크로를 쓰는 최신 DTS는 반드시 cpp 전처리를 거쳐야 한다 — dtc에 원본 .dts를 바로 넣으면 #include 자체를 문법 오류로 보고 실패한다.

마무리

Device Tree는 결국 노드-속성 트리와 그걸 정수 phandle 기반 바이너리로 굳히는 dtc, 그 바이너리를 로드해 커널에 넘기는 부트로더 세 조각으로 이루어져 있다. 실제 라즈베리파이처럼 오버레이로 런타임에 노드를 추가/수정하는 방식은 이어지는 글에서 다룬다.

참고

답글 남기기