Computer Science
탄탄한 기반 실력을 위한
전공과 이론 지식 모음
Today I Learned!
배웠으면 기록을 해야지
TIL 사진
Flutter 사진
Flutter로 모바일까지
거꾸로캠퍼스 코딩랩 Flutter 앱개발 강사
스파르타코딩클럽 즉문즉답 튜터
카카오테크캠퍼스 3기 학습코치
프로필 사진
박성민
임베디드 세계에
발을 들인 박치기 공룡
임베디드 사진
EMBEDDED SYSTEM
임베디드 SW와 HW, 이론부터 실전까지
ALGORITHM
알고리즘 해결 전략 기록
🎓
중앙대학교 소프트웨어학부
텔레칩스 차량용 임베디드 스쿨 3기
애플 개발자 아카데미 1기
깃허브 사진
GitHub
프로젝트 모아보기
Instagram
인스타그램 사진

Embedded System/Embedded Linux

[Embedded Linux] Linux의 디바이스 트리

sm_amoled 2025. 11. 29. 19:41

ㅤㅤ

이번에 U-Boot에서 시작된 커널의 초기화 및 실행 과정을 쭉 따라가면서 어떤 일들이 일어나는지 찾아봤는데, 그 과정에서 디바이스 트리에 대한 정보(.dtb)를 특정 메모리 위치에 로드해주면서 커널을 실행하는 것을 확인했다. 디바이스 트리가 도대체 무엇이길래! 이렇게나 중요한 커널과 어깨를 나란히 하면서 메모리에 로드되고 커널 초기화 시에 어떤 역할을 하는지 간략하게 찾아봤었는데, 이 녀석은 좀 자주 나오는 키워드 같아서 이 참에 조금 더 자세히 살펴보려고 한다.

ㅤㅤ

디바이스 트리가 뭐냐!

Device Tree 라는건 하드웨어의 구성을 나타내는 데이터 구조이다. 다음의 정보들을 트리 구조로 표현한 것!

  • 보드에 연결된 하드웨어 목록
  • 각 하드웨어가 매핑된 메모리 주소
  • 어떤 인터럽트를 사용하는지
  • 어떻게 연결이 되어있는지

ㅤㅤ

Device Tree Source 파일의 형태

디바이스 트리는 먼저 보드의 설계자가 작성한다. (물론 이걸 손수 작성하지는 않겠지…만, 이 내용들은 사람이 설계해서 넣어준다) 이런 내용들이 파일에 포함되어야 한다.

  • compatible : 어떤 드라이버와 매칭될 것인가 → 매우 중요
  • reg : 레지스터 주소와 크기
  • interrupts : 인터럽트 주소
  • status : 이 디바이스의 활성화 여부
  • 전달이 필요한 추가 정보들이 들어올 수 있다
/ {
    compatible = "raspberrypi,4-model-b", "brcm,bcm2711";

    memory@0 {
        device_type = "memory";
        reg = <0x0 0x0 0x40000000>;  // 1GB RAM
    };

    uart0: serial@7e201000 {
        compatible = "arm,pl011", "arm,primecell";
        reg = <0x7e201000 0x200>;     // 레지스터 베이스 주소
        interrupts = <57>;             // 인터럽트 번호
        clock-frequency = <48000000>;  // 클럭 주파수
        status = "okay";
    };
};

ㅤㅤ

Device Tree Blob 파일이란?

.dts 파일을 dtc 컴파일러로 컴파일하면 .dtb 파일이 된다. 즉, 디바이스 트리 소스파일의 바이너리 버전이라고 생각하면 된다. 이 정보를 컴퓨터가 읽고 파싱해야하기 때문에 바이너리 파일이 필요하다.

ㅤㅤ

커널을 처음에 실행하기 위해서 넣어주던 dtb 파일이 바로 이 컴파일된 디바이스 트리 파일이다.

ㅤㅤ

어떻게 트리 구조냐면

아래처럼 루트 노드로부터 카테고리에 따라 점점 내려가면서 세분화되어 매핑된다. 여기에서 각각의 노드는 하나의 HW 디바이스 또는 버스를 표현한다.

/ (루트 노드)
├── memory@0
├── cpus
│   ├── cpu@0
│   ├── cpu@1
│   ├── cpu@2
│   └── cpu@3
├── soc (System on Chip)
│   ├── uart0@7e201000
│   ├── i2c0@7e205000
│   ├── spi0@7e204000
│   └── gpio@7e200000
└── chosen

ㅤㅤ

내 라즈베리파이의 디바이스 트리는 어떻게 생겼나

디바이스 트리가 왜 필요하냐면

과거에는 Board File 이라는걸 썼드랩죠

2011년까지의 리눅스 커널에서는 Board File 을 사용했다. (와 이거 얼마 안됐다 생각했는데 벌써 15년 전임)

// arch/arm/mach-xxx/board-xyz.c (과거 방식)

static struct platform_device uart_device = {
    .name = "uart-pl011",
    .id = 0,
    .num_resources = 2,
    .resource = (struct resource[]) {
        {
            .start = 0x10009000,  // UART0 베이스 주소
            .end   = 0x10009fff,
            .flags = IORESOURCE_MEM,
        },
        {
            .start = 37,          // IRQ 번호
            .end   = 37,
            .flags = IORESOURCE_IRQ,
        },
    },
};

void __init board_xyz_init(void)
{
    platform_device_register(&uart_device);
    // GPIO, I2C, SPI 등 수십~수백 개 디바이스 등록...
}

ㅤㅤ

board 파일은 C언어로 보드에 대한 정보를 쭉 작성해서 커널 소스코드에 포함시켜 빌드를 하는 방식이였다. 즉, 커널 내부에 보드 하드웨어에 대한 정보들을 모두 알고있었다. 이로 인해서

  • 보드마다 각각의 장치를 위한 C 파일을 가지고 있어야함
    • linux 커널 소스코드를 보면 ARM 아키텍처만 해도 수천개의 보드에 대한 정보가 포함되어 있었음
    • Too Heavy
  • HW 정보가 커널 내부에 하드코딩 되어있음
  • 그래서 하드웨어가 바뀌면 커널을 새로 빌드해야함
  • 빌드해둔 커널을 재활용하기 너무 빡셈

ㅤㅤ

그래서 등장한 디바이스 트리

하드웨어에 대한 정보를 커널 외부로 빼낸 것이 이 디바이스 트리 파일이다. 커널 내부에서 이 장치와 주소에 대해서 보관하지 말고, 커널을 실행할 때 하드웨어 정보들을 불러와 사용한다면 훨씬 더 유연하게 하드웨어 정보들을 사용할 수 있다.

  • 동일한 커널에 다른 디바이스 트리 정보를 전달해주면 그에 맞게 하드웨어 정보를 매핑해준다. 덕분에 보드가 달라져도 동일한 커널을 사용할 수 있다.
  • 리눅스 커널 소스코드에서도 보드에 대한 정보가 빠져 훨씬 가벼워진다.

ㅤㅤ

STM32 펌웨어에서 GPIO 제어 등을 할 때, 다음과 같이 코드를 작성했었다.

// 예: GPIOA 핀 5를 출력으로 설정
GPIOA->MODER |= (1 << 10);

ㅤㅤ

여기에서 나는 GPIOA의 주소도 모르고 MODER 레지스터의 offset도 모르지만, CMSIS 덕분에 symbol을 통해 HW 들을 제어할 수 있었다. 이와 동일하다. 커널도 HW에 대해서 모든 것들을 알고있지는 않지만 어떤 HW 들이 연결되어 있는지, HW 제어를 위한 메모리 주소가 어디인지에 대해서 디바이스 트리 파일을 통해 알 수 있다.

ㅤㅤ

디바이스 트리를 사용하는 방식의 Trade-Off

x86을 사용하는 PC에서는 놀라우리만큼 편리한 Plug-and-Play 방식으로 주변 장치를 이용할 수 있다. 메모리맵? 그런건 모르겠고 그냥 USB나 포트에다가 냅다 꽂으면 그걸 시스템이 자동으로 인식하고 디바이스 드라이버를 찾아서 바로 사용이 가능하다. 그런데 라즈베리파이의 핀에다가 버튼을 연결하면 왜 이걸 시스템이 자동으로 인식하지 못할까?

ㅤㅤ

x86 에서는 USB 장치가 PC와 연결되면 USB 컨트롤러가 이 장치가 어떤 놈인지를 판단한다. 컨트롤러가 장치에게 정체를 물으면 장치는 컨트롤러에게 “마우스, Vender는 XXX, 제품 정보는 000” 를 보내준다. 그러면 이 정보를 보고 OS가 적합한 디바이스 드라이버를 불러와서 자동으로 로딩해준다. 즉 런타임에 하드웨어를 발견하고 드라이버를 연결해 그대로 사용할 수 있다.

ㅤㅤ

ARM 은 방식이 다르다. 여기에서는 Device Tree 를 사용하기 때문에, 보드를 구성할 때 Device Tree Source (DTS) 파일에다가 미리 “내가 어떤 HW를 사용할거고, 이 HW는 메모리 주소 0xZZZZ_ZZZZ에 매핑될거야” 라고 등록해주어야 한다. 그러면 시스템이 부팅하면서 DTB를 보고 드라이버를 매칭한다. 즉, 부팅 전에 이미 하드웨어에 대한 구성이 완료된다.

ㅤㅤ

Plug & Play 방식을 사용할 때에는 런타임에 장치를 추가/제거할 수 있고 하드웨어 구성을 변경하더라도 컴파일을 다시 하거나 설정을 변경할 필요가 없어서 매우 편리하다. 대신 하드웨어가 훨씬 복잡해지고, 버스의 초기화 시간이나 전력 소비량도 늘어나게 된다. 또, UART나 GPIO 같은 매우 심플한 녀석들은 그저 레지스터 덩어리이기 때문에 자신이 UART임, GPIO 임을 보드에게 알리지 못하기 때문에 SoC 에는 적합하지 않다.

ㅤㅤ

Device Tree 방식을 사용하면 하드웨어가 단순해지고 (저비용, 저전력) 커널과 디바이스를 분리해 하나의 가벼운 커널로 여러 보드를 지원할 수 있어 유리하다. 보드를 설계할 때 이미 HW 구성이 결정되어 고정된 주소를 가지는 SoC 내부 디바이스에 적합하다. 대신 만약 HW 구성을 하나라도 변경하고 싶다면 DTB를 다시 컴파일해서 넣어주는 등의 수고로움이 필요하다.

ㅤㅤ

ARM에서도 USB는 Plug and Play 를 지원한다. USB 포트에 마우스나 키보드를 꽂으면 알아서 잘 동작한다. PCI Express나 HDMI 도 Plug and Play가 되기 때문에, 사실상 하이브리드 방식이라고 봐야하긴 한다. 대신 GPIO 핀에는 Plug and Play가 어렵다.

ㅤㅤ

디바이스 트리와 커널 드라이브의 연동

물론 앞서 CMSIS를 이용하면 쉽게 개발이 가능했다! 라고 언급했지만, 한 번만 파일을 타고 들어가보면 GPIOA 라는 녀석은 GPIOA_BASE 랑 연결되어있고, 이 주소는 파일에 하드코딩 되어있었다. 혹시 엄청난 엔지니어가 GPIOA의 매모리 매핑을 변경하면 이 CMSIS는 무용지물이 되게된다.

#define GPIOA_BASE  0x40020000
GPIOA->MODER |= (1 << 10);

ㅤㅤ

리눅스의 커널 드라이브에서는 트리에서 자신이 제어할 HW 를 찾는 코드가 대략적으로 아래처럼 구성되어있다. 여기에서 platform_get_resource 함수를 통해 HW의 주소값을 가져와 사용하는데, 이 값이 바로 디바이스 트리를 통해 얻은 정보이다.

// 리눅스 GPIO 드라이버 (간략화)
static int gpio_probe(struct platform_device *pdev)
{
    struct resource *res;
    void __iomem *base;

    // 디바이스 트리에서 주소 정보 읽기
    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    base = devm_ioremap_resource(&pdev->dev, res);

    // 이제 base를 사용해서 GPIO 제어
    writel(value, base + GPIO_OFFSET);
}

여기에서 드라이버 코드가 자신과 바인딩 될 HW 를 디바이스 트리 상에서 찾는 방법은 compatible 속성 값을 이용하는 것이다. 자신과 동일한 compatible 을 가지는 모듈을 찾아 링크로 연결된다. 이에 대해서는 아래 실습에서 확인 가능하다.

ㅤㅤ

이렇게 디바이스 드라이버와 디바이스의 바인딩을 위해서는 커널의 부팅 초기 과정에서 디바이스 트리에 대한 초기화를 수행해주어야 한다.

ㅤㅤ

디바이스 트리의 로딩

커널의 부팅 시점에 DTB를 메모리에 올리고, 커널에게 DTB의 주소를 전달한다. 커널을 부팅할 때 이 파일을 이용해서 하드웨어 정보를 파악할 수 있는 디바이스 트리를 구축한다. 커널을 초기화하기위해 호출하는 함수인 start_kernel() 에서 setup_arch() 라는 함수를 호출하고, 이 함수 내에서 FDT(Flatten Device Tree — DTB)에 대한 설정과 트리구조로 파싱하는 unflatten_device_tree() 를 호출한다.

// init/main.c
void start_kernel(void)
{
    char *command_line;
    char *after_dashes;

    ...
    setup_arch(&command_line);
    ...

}

// arch/arm64/kernel/setup.c
void __init __no_sanitize_address setup_arch(char **cmdline_p)
{    
    ...

    // Flatten Device Tree Pointer (DTB의 메모리 주소) 를 이용해 
    // DTB 파일의 유효성 검사 및 전체 크기 확인과 메모리 영역을 처리
    setup_machine_fdt(__fdt_pointer);

    // acpi는 주로 x86 에서 사용하는 방식
    // 라즈베리파이는 arm
    if (acpi_disabled)
        // FDT Blob을 트리 구조로 파싱
        // device_node 로 변환하여 of_root에 저장한다
        unflatten_device_tree();

    ...
}

ㅤㅤ

unflatten_device_tree() 함수를 한 번 보면, 위에 주석으로 “create tree of device_nodes from flat blob” 이라고 자랑스럽게 작성되어있다. 이 함수가 실행되고나면 device_node 구조체 기반의 트리가 만들어진다.

/**
 * unflatten_device_tree - create tree of device_nodes from flat blob
 *
 * unflattens the device-tree passed by the firmware, creating the
 * tree of struct device_node. It also fills the "name" and "type"
 * pointers of the nodes so the normal device-tree walking functions
 * can be used.
 */
void __init unflatten_device_tree(void)
{
    void *fdt = initial_boot_params;

    /* Save the statically-placed regions in the reserved_mem array */
    fdt_scan_reserved_mem_reg_nodes();

    /* Populate an empty root node when bootloader doesn't provide one */
    if (!fdt) {
        fdt = (void *) __dtb_empty_root_begin;
        /* fdt_totalsize() will be used for copy size */
        if (fdt_totalsize(fdt) >
            __dtb_empty_root_end - __dtb_empty_root_begin) {
            pr_err("invalid size in dtb_empty_root\n");
            return;
        }
        of_fdt_crc32 = crc32_be(~0, fdt, fdt_totalsize(fdt));
        fdt = copy_device_tree(fdt);
    }

    __unflatten_device_tree(fdt, NULL, &of_root,
                early_init_dt_alloc_memory_arch, false);

    /* Get pointer to "/chosen" and "/aliases" nodes for use everywhere */
    of_alias_scan(early_init_dt_alloc_memory_arch);

    unittest_unflatten_overlay_base();
}

ㅤㅤ

디바이스 드라이버를 디바이스 트리에 등록해보자

디바이스를 제어하기 위해서는 디바이스 드라이버를 통해야한다. 우리가 지금까지 보고있었던 내용이 결국 다음 내용이다.

  • 디바이스 트리 소스코드 (DTS) 에서 HW 디바이스 정보를 작성하고 DTB로 컴파일
  • DTB를 커널 init 시점에 전달해 HW의 (메모리 상의) 위치를 커널에게 알려주기
  • 디바이스 드라이버에서 이 디바이스 트리의 정보를 보고 HW와 바인딩 하기

위 단계를 수행하고 나면 디바이스 드라이버를 통해 실제 HW 디바이스를 제어할 수 있게 된다.

ㅤㅤ

이 과정에 대해서 실습을 진행해봤는데, 내용이 너무 길어져서 글을 따로 빼냈다. 아래 링크를 참고하자!

[Embedded Liinux] 디바이스 드라이버를 작성해보자

 

[Embedded Linux] 디바이스 드라이버 작성해보기

라즈베리파이에서 디바이스 드라이버 만들어보기!디바이스 드라이버 만들어보기디바이스 트리에 대해서 공부를 하다보니 갑자기 디바이스 드라이버 이야기가 나왔고, 실습을 진행하다가 정신

etst.tistory.com

 

320x100