사전 디바이스 트리
포맷

디바이스 트리

gabury1고친 사람 github-actions[bot]

디바이스 트리는 보드에 어떤 장치가 어디에 달려 있는지를 커널에 알려 줍니다. 커널은 이 목록을 읽고 장치마다 맞는 드라이버를 붙입니다. 보드가 바뀌면 커널을 다시 짜지 않고 이 목록 파일만 바꿉니다.

쉽고 빠른 이해

디바이스 트리는 보드 한 장의 장치 목록입니다. 「이 주소에 온도 센서가 하나 달려 있다」 같은 줄이 나무 모양으로 이어집니다.

이 목록이 필요한 까닭은 칩에 붙은 장치 상당수가 자기가 누구인지 스스로 말하지 못하기 때문입니다. 누군가 알려 주지 않으면 커널은 그 장치가 있는 줄도 모릅니다.

어떻게 도나:

  1. 사람이 보드에 달린 장치를 텍스트 파일로 적습니다
  2. 이 파일을 컴퓨터가 읽기 쉬운 이진 파일로 바꿉니다
  3. 부팅할 때 이 파일이 커널에 전달됩니다. 커널은 장치마다 드라이버(장치를 움직이는 커널 코드)를 찾아 붙입니다

대가는 사람이 손으로 적는다는 점입니다. 주소 하나를 틀려도 빌드는 통과합니다. 장치가 조용히 안 보일 뿐입니다.

상세

드라이버는 장치 하나를 움직이는 커널 안의 코드입니다. 커널은 장치마다 맞는 드라이버를 붙여야 그 장치를 쓸 수 있습니다.

예로 드는 보드는 지어낸 것입니다. 칩 하나에 I2C(Inter-Integrated Circuit, 칩 사이를 잇는 직렬 버스) 버스가 하나 있습니다. 그 버스에 온도 센서가 하나 달려 있습니다.

스스로 이름을 못 대는 장치

장치 가운데에는 자기 정보를 스스로 알려 주는 것이 있습니다. USB(Universal Serial Bus)에 꽂는 장치가 그렇습니다. 컴퓨터 안에 확장 카드를 꽂는 PCI(Peripheral Component Interconnect) 버스도 마찬가지입니다. 커널이 버스에 물으면 장치가 제조사와 종류를 답합니다.

그렇지 못한 장치도 많습니다. SoC(System on Chip, 프로세서와 주변 장치를 칩 하나에 모은 것) 안에 든 타이머나 직렬 포트가 그렇습니다. 이런 장치는 정해진 주소 범위를 차지합니다. 프로세서가 그 주소를 읽고 쓰면 메모리 대신 장치가 답합니다.

장치 쪽에서 그 주소에 놓인 작은 저장 칸을 레지스터라고 합니다. 드라이버는 레지스터를 읽고 써서 장치를 움직입니다. 그런데 레지스터를 읽어 봐도 「나는 직렬 포트다」라는 답은 오지 않습니다.

I2C 버스에 달린 센서도 같습니다. I2C 는 선 두 가닥만으로 여러 칩을 잇습니다. 이 버스는 어떤 칩이 어느 주소에 달렸는지 알려 주지 않습니다. 소프트웨어가 미리 알고 있어야 합니다.

결국 누군가 커널에게 목록을 줘야 합니다. 「이 주소에는 직렬 포트가, 저 버스의 이 주소에는 온도 센서가 있다」는 목록입니다. 디바이스 트리가 그 목록입니다.

보드마다 코드를 짜던 시절

디바이스 트리가 널리 쓰이기 전에는 이 목록을 커널 소스 안에 C 코드로 적었습니다. 보드 하나에 소스 파일 하나를 두었습니다. 이것을 보드 파일이라고 했습니다.

이 방식에서는 보드가 늘 때마다 커널 소스도 늘었습니다. 보드가 다르면 커널도 따로 빌드해야 했습니다. 장치 하나의 주소가 바뀌어도 커널을 다시 컴파일했습니다.

디바이스 트리는 이 목록을 코드에서 떼어 데이터 파일로 옮겼습니다. 커널은 「목록을 읽어 드라이버를 붙이는 코드」만 갖습니다. 덕분에 커널 이미지 하나가 여러 보드에서 돕니다. 보드마다 달라지는 것은 곁에 놓는 목록 파일 하나입니다.

노드와 속성으로 이루어진 나무

이름 그대로 이 목록은 나무 모양입니다. 장치 하나가 노드 하나입니다. 노드는 나무의 한 마디로, 그 장치에 대한 설명을 담는 블록입니다.

노드 안에는 속성이 들어갑니다. 속성은 이름 = 값 형식의 한 줄입니다. 「이 장치의 종류는 무엇이다」, 「이 장치의 주소는 몇이다」 같은 설명이 속성으로 적힙니다.

노드가 어디에 매달리는지는 장치가 어디에 붙었는지를 따릅니다. 온도 센서는 I2C 버스에 붙었습니다. 그 버스를 제어하는 칩 안의 부품이 I2C 컨트롤러입니다. 센서 노드는 이 컨트롤러 노드 아래에 매달립니다.

flowchart TD
    R["/ · 뿌리 노드 · 보드 전체"]
    R --> C["cpus · 프로세서"]
    R --> M["memory · 메모리"]
    R --> U["serial · 직렬 포트"]
    R --> I["i2c · I2C 컨트롤러"]
    I --> T["sensor · 온도 센서"]

맨 위의 / 는 뿌리 노드입니다. 보드 전체를 뜻합니다. 노드 이름은 파일 경로처럼 이어 부를 수 있어서, 온도 센서는 /i2c/sensor 쯤에 있는 셈입니다. 실제 소스에서는 이름 뒤에 @주소 가 붙어 /i2c@40005000/sensor@48 이 됩니다.

소스 한 벌 읽기

처음에는 사람이 읽는 텍스트 파일로 적습니다. 이 파일을 DTS(Device Tree Source, 디바이스 트리 소스)라고 합니다. 확장자는 .dts 입니다.

아래는 예로 든 보드에서 I2C 버스와 센서만 떼어 적은 것입니다. acme 는 지어낸 회사 이름입니다.

dts
/ {
  compatible = "acme,board-a";
  #address-cells = <1>;
  #size-cells = <1>;

  i2c@40005000 {
    compatible = "acme,i2c";
    reg = <0x40005000 0x400>; // 주소·크기
    #address-cells = <1>;
    #size-cells = <0>;

    sensor@48 {
      compatible = "acme,temp";
      reg = <0x48>;           // I2C 주소
    };
  };
};

중괄호 한 쌍이 노드 하나입니다. 들여쓰기가 나무의 깊이를 보여 줍니다. 노드 이름의 @ 뒤에 붙은 숫자는 그 장치의 주소입니다. 같은 이름의 노드가 여럿 있어도 주소로 가를 수 있게 붙입니다.

compatible 은 이 장치가 어떤 종류인지 적는 속성입니다. 값은 회사,장치 형식의 문자열입니다. 커널은 이 문자열을 보고 어느 드라이버를 붙일지 정합니다. 장치와 드라이버를 잇는 열쇠가 이 속성입니다.

reg 는 장치의 주소입니다. I2C 컨트롤러 줄은 「0x40005000 번지부터 0x400 바이트를 차지한다」는 뜻입니다. 앞에서 본 것처럼 프로세서가 이 범위를 읽고 쓰면 컨트롤러가 답합니다. 센서 줄의 0x48 은 메모리 주소가 아니라 I2C 버스 위의 주소입니다.

reg 의 숫자 가운데 어디까지가 주소이고 어디부터가 크기인지는 부모 노드가 정합니다. #address-cells 는 주소에 쓰는 칸 수입니다. #size-cells 는 크기에 쓰는 칸 수입니다.

칸 하나는 32비트 값 하나입니다. 꺾쇠 < > 안에 적은 숫자 하나가 칸 하나를 채웁니다. 그러니 <1> 은 숫자 1을 담은 한 칸이고, <0x40005000 0x400> 은 두 칸입니다.

이 규칙으로 두 reg 를 다시 읽어 봅니다. 뿌리 노드가 주소 한 칸, 크기 한 칸을 정했으니 I2C 컨트롤러의 reg 는 두 칸입니다.

I2C 노드는 크기를 0칸으로 정했습니다. 버스 위의 장치는 주소만 있고 크기가 없기 때문입니다. 센서의 reg 는 주소 한 칸뿐입니다.

바인딩, 속성을 적는 약속

같은 compatible 을 단 노드라면 어떤 속성을 어떤 형식으로 적을지가 정해져 있습니다. 이 약속을 바인딩이라고 합니다. 바인딩은 드라이버와 디바이스 트리 사이의 계약입니다.

예를 들어 온도 센서 드라이버는 reg 에서 버스 주소를 읽겠다고 약속합니다. 경보를 보낼 인터럽트 선이 있다면 그 번호를 어느 속성에서 읽을지도 약속합니다. 디바이스 트리를 쓰는 사람은 이 약속에 맞춰 속성을 적습니다.

소스에서 바이너리로

DTS 는 그대로 커널에 넘어가지 않습니다. 먼저 DTC(Device Tree Compiler)라는 컴파일러가 이 텍스트 파일을 이진 파일로 바꿉니다.

그 결과물을 DTB(Device Tree Blob, 디바이스 트리 이진 덩어리)라고 합니다. 확장자는 .dtb 입니다.

이진으로 바꾸는 까닭은 부팅 초기에 읽기 쉬워서입니다. 이때 커널은 아직 메모리 관리도 다 못 갖춘 상태입니다. 텍스트를 쪼개 해석하는 코드보다 정해진 형식의 바이트를 앞에서부터 읽는 코드가 훨씬 작고 단순합니다.

소스는 보통 두 겹으로 나눠 적습니다. .dtsi(Device Tree Source Include, 디바이스 트리 공용 소스)는 여러 보드가 함께 포함해 쓰는 소스입니다. 같은 SoC 를 쓰는 보드는 칩 안의 장치가 같으므로, 칩에 든 장치는 .dtsi 에 한 번만 적어 둡니다. 보드 소스는 이것을 포함한 뒤 그 보드에 연결된 것만 덧붙입니다.

이때 자주 쓰는 속성이 status 입니다. 칩 공통 소스는 장치를 "disabled" 로 꺼 둡니다. 보드 소스는 실제로 선을 이은 장치만 "okay" 로 켭니다. 커널은 꺼진 장치에 드라이버를 붙이지 않습니다.

flowchart TD
    A["칩 공통 소스 · .dtsi"] --> B["보드 소스 · .dts"]
    B --> C["DTC · 디바이스 트리 컴파일러"]
    C --> D["바이너리 · .dtb"]
    D --> E["부팅 때 커널에 전달된다"]

부팅 때 커널에 전달되는 순서

DTB 를 커널에 넘겨 주는 것은 부트로더의 일입니다. 부트로더는 전원이 켜진 뒤 커널을 메모리에 올리고 실행을 넘기는 작은 프로그램입니다. 부트로더는 커널과 DTB 를 메모리에 함께 올립니다. 그리고 DTB 가 놓인 주소를 커널에 알려 줍니다.

sequenceDiagram
    participant 부트로더
    participant 커널
    participant 드라이버
    부트로더->>커널: 커널과 DTB 를 올리고 DTB 주소를 넘긴다
    커널->>커널: 노드를 읽어 장치 목록을 만든다
    커널->>드라이버: compatible 이 맞는 드라이버를 부른다
    드라이버->>커널: reg 와 인터럽트 번호를 읽어 간다
    Note over 커널,드라이버: 맞는 드라이버가 없으면 장치는 목록에만 남는다

커널은 받은 DTB 의 노드를 하나씩 읽어 장치 목록을 만듭니다. 그다음 장치마다 compatible 문자열을 들고, 같은 문자열을 지원한다고 밝힌 드라이버를 찾습니다. 짝이 맞으면 그 드라이버를 장치에 붙입니다.

붙은 드라이버는 노드에서 자기에게 필요한 속성을 읽습니다. reg 에서는 장치의 주소를 얻습니다. 주소 범위를 차지하는 장치라면 레지스터가 놓인 주소이고, I2C 센서라면 버스 주소입니다. 인터럽트를 쓰는 장치라면 그 번호도 노드에서 얻습니다.

드라이버 코드 안에 주소가 하나도 박혀 있지 않은 것도 이 때문입니다. 주소는 전부 디바이스 트리에서 옵니다. 같은 센서가 다른 보드의 다른 주소에 달려도 드라이버는 고칠 것이 없습니다.

부트로더는 DTB 를 넘기기 전에 내용을 조금 고치기도 합니다. 메모리 크기처럼 부팅 때가 되어야 아는 값을 채워 넣습니다. 부팅 인자를 적어 넣기도 합니다. 부팅 인자는 커널을 띄울 때 함께 넘기는 짧은 설정 문자열입니다.

나중에 덧붙이는 조각, 오버레이

보드에 확장 기판을 꽂아 장치를 더 다는 일이 있습니다. 그때마다 보드의 DTS 전체를 고치면 번거롭습니다. 이럴 때는 바뀐 부분만 적은 조각을 따로 만들어 원래 DTB 위에 얹습니다.

이 조각을 오버레이라고 합니다. 오버레이는 「이 노드 아래에 센서 노드를 하나 더 단다」나 「이 장치를 켠다」 같은 고칠 내용만 담습니다. 부트로더나 커널이 원래 나무에 이 조각을 합친 뒤에 장치 목록을 만듭니다.

디바이스 트리가 적지 않는 것

이 목록에는 하드웨어가 어떻게 생겼는지만 적습니다. 「이 직렬 포트를 어떤 속도로 쓸까」 같은 운영 설정은 원칙적으로 적지 않습니다. 그런 값은 커널 밖 프로그램의 설정 파일이나 부팅 인자가 맡습니다.

자기 정보를 스스로 알려 주는 장치도 적지 않습니다. PCI 나 USB 버스라면 버스를 다루는 컨트롤러 하나만 적습니다. 그 버스에 꽂히는 장치는 커널이 부팅한 뒤 버스에 물어 알아냅니다.

쓰는 곳과 안 쓰는 곳

주로 쓰는 곳은 임베디드 보드와 SoC 를 쓰는 기계입니다. 리눅스 커널이 이런 보드에서 하드웨어를 알아내는 기본 방식이 이것입니다. 그런 보드에 흔한 ARM · RISC-V · PowerPC 프로세서는 모두 RISC(Reduced Instruction Set Computer, 명령어를 단순하게 줄인 설계) 계열입니다.

흔한 개인용 컴퓨터와 서버는 다른 길을 씁니다. 이 기계에는 보드의 저장 칩에 심어 두고 전원을 켜면 가장 먼저 도는 프로그램이 있습니다. 이것을 펌웨어라고 합니다.

펌웨어는 ACPI(Advanced Configuration and Power Interface)라는 표로 하드웨어 구성을 운영체제에 넘깁니다. 장치 대부분도 PCI 에 붙어 자기 정보를 스스로 알려 줍니다.

리눅스가 아닌 곳에서도 씁니다. 정해진 시간 안에 반응하도록 만든 작은 운영체제를 실시간 운영체제라고 합니다. 그중 Zephyr 는 빌드할 때 디바이스 트리를 읽어 C 헤더 파일로 바꿉니다. 부팅 때가 아니라 컴파일할 때 하드웨어 정보를 코드에 박는 셈입니다.

손으로 적는 대가

이 파일은 사람이 회로도를 보며 손으로 적습니다. 회로도와 다르게 적으면 장치가 동작하지 않습니다.

DTC 는 문법만 검사합니다. reg 의 주소가 회로도와 맞는지, 속성이 바인딩과 맞는지는 모릅니다. 틀린 주소로도 빌드는 통과합니다.

틀린 결과는 부팅한 뒤에야 드러납니다. 맞는 드라이버를 못 찾아도 커널은 멈추지 않습니다. 그 장치를 비워 둔 채 부팅을 이어 갑니다. 결국 「센서가 안 보인다」는 증상에서 시작해 DTS 의 한 줄까지 거슬러 올라가야 합니다.

관련 항목

디바이스 트리를 읽어 장치를 붙이는 소프트웨어

커널 · 리눅스 커널 · 디바이스 드라이버 · 플랫폼 디바이스 · 드라이버 바인딩

디바이스 트리를 커널에 넘겨 주는 부팅 단계

부트로더 · U-Boot · 커널 명령줄 · 커널 이미지 · 초기 램디스크 · 펌웨어

디바이스 트리 파일의 형식과 그것을 만드는 도구

DTSI · DTC · 디바이스 트리 오버레이 · 바인딩

디바이스 트리 노드에 적히는 속성

compatible · reg · status · phandle · 인터럽트 · 레지스터

디바이스 트리가 적어 주는 버스와 장치

I2C · SPI · UART · GPIO · 버스 · 메모리 맵 입출력

디바이스 트리 대신 하드웨어를 알리는 수단

ACPI · 보드 파일 · Open Firmware · UEFI · PCI · USB

디바이스 트리를 쓰는 프로세서와 운영체제

ARM · RISC-V · PowerPC · SoC · Zephyr · 임베디드 · 라즈베리 파이

다른 이름: Device Tree · devicetree · 장치 트리 · DTS · DTB