Device Tree와 Linux 플랫폼 드라이버 작성 입문

Device Tree를 단순한 설정 파일로 외우지 않고, 실제 드라이버가 하드웨어를 발견하고 자원을 준비하는 과정까지 연결해 보자. 예제는 메모리 맵 레지스터와 IRQ, 클록, 전원을 가진 가상의 보드 장치다.

읽는 방법 / How to read this article

각 절에서 먼저 “하드웨어 관점에서 무엇이 연결되어 있는가”를 보고, 이어서 DTS와 C 드라이버가 그 연결을 어떻게 표현하는지 확인한다. 예제 주소와 IRQ 번호는 설명을 위한 값이며, 실제 보드의 데이터시트 값으로 바꿔야 한다.

1. Device Tree는 왜 필요한가? / Why do we need Device Tree?

커널 드라이버가 보드마다 다음처럼 주소를 직접 들고 있다고 가정해 보자.

/* board-a.c */
#define MY_DEVICE_BASE 0x40000000
#define MY_DEVICE_IRQ  42

/* board-b.c */
#define MY_DEVICE_BASE 0x50000000
#define MY_DEVICE_IRQ  73

장치가 같은 종류인데 보드가 바뀔 때마다 드라이버 소스를 복사하거나 #define을 바꿔야 한다. 유지보수가 어려워지고, 커널과 보드의 결합도도 높아진다.

Device Tree는 이 하드웨어 차이를 데이터로 분리한다. 드라이버는 “나는 이런 종류의 장치를 지원한다”고 선언하고, 보드의 DTS는 “이 장치는 이 주소·IRQ·클록·전원을 사용한다”고 설명한다.

하드웨어 설계
  ├─ register base / size
  ├─ interrupt line
  ├─ clock source
  └─ power rail
        ↓  Device Tree
  platform device
        ↓  compatible matching
  platform driver
        ↓  probe()
  실제 장치 초기화

2. DTS, DTB, binding / DTS, DTB, and bindings

DTS(Device Tree Source) : 사람이 읽고 수정하는 텍스트 소스다. 공통 SoC 설명은 .dtsi로, 보드별 연결은 .dts로 나누는 경우가 많다.

DTB(Device Tree Blob) : dtc가 DTS를 컴파일한 바이너리다. 부트로더가 커널에 전달하고, 커널은 초기 부팅 때 이를 메모리 구조로 펼친다.

Binding : 노드와 속성의 의미를 정한 계약서다. 어떤 compatible 문자열을 쓰고, reg·clocks·interrupts를 어떤 형식으로 적는지 정의한다.

최근 커널은 binding을 YAML 스키마로 관리하고 dtbs_check로 DTS가 규칙을 지키는지 검증한다. “컴파일만 된다”는 것과 “binding에 맞는다”는 것은 다르다.

3. 예제 하드웨어를 DTS로 표현하기 / Describing the hardware in DTS

가상의 장치가 다음 자원을 가진다고 하자.

자원 / Resource예시 값 / Example용도 / Purpose
MMIO registerbase 0x40000000, size 0x1000제어·상태 레지스터 접근
IRQ42장치 이벤트를 CPU에 알림
clock&clk 3장치 동작 클록 공급
power&reg_3v3전원 레일 제어
mydev: sensor@40000000 {
    compatible = "example,my-sensor-v1";
    reg = <0x40000000 0x1000>;
    interrupts = <42>;
    clocks = <&clk 3>;
    clock-names = "bus";
    vdd-supply = <&reg_3v3>;
    status = "okay";
};

노드 이름의 @40000000은 unit address다. 보통 reg의 첫 주소와 맞춰 적는다. 단, 실제 주소 셀과 크기 셀의 개수는 부모 버스의 #address-cells#size-cells가 결정한다.

reg를 단순히 두 숫자로 외우지 말 것 / Do not memorize reg as two numbers

부모가 64비트 주소를 요구하면 reg = <0x0 0x40000000 0x0 0x1000>;처럼 네 셀이 될 수 있다. 어떤 형식이 맞는지는 해당 버스의 binding과 상위 노드를 확인해야 한다.

4. compatible가 드라이버를 찾는 과정 / How compatible finds a driver

Device Tree 노드가 있다고 자동으로 C 코드의 probe()가 호출되는 것은 아니다. 대략 다음 두 목록이 맞아야 한다.

DTS node:
    compatible = "example,my-sensor-v1";

driver match table:
    { .compatible = "example,my-sensor-v1" }

match 성공 → platform device와 platform driver 연결 → probe(pdev)

Platform device는 CPU에 직접 연결된 메모리 맵 장치를 Linux 장치 모델 안에 표현한 객체다. Platform driver는 이런 장치를 제어하는 드라이버다. PCI처럼 버스가 장치를 열거해 주는 방식과 달리, 플랫폼 장치는 Device Tree나 ACPI가 장치 정보를 제공한다.

compatible는 가능한 한 구체적으로 작성한다. 새 하드웨어가 이전 하드웨어의 상위 호환이라면 드라이버가 여러 문자열을 매칭하도록 fallback을 둘 수 있지만, 단순히 제품군 이름을 와일드카드처럼 쓰면 안 된다.

5. 드라이버의 기본 생명주기 / The driver lifecycle

module load / built-in init

driver registration

Device Tree node appears as platform_device
        ↓ compatible match
probe(pdev)
  ├─ allocate private data
  ├─ map MMIO
  ├─ enable clock / regulator
  ├─ request IRQ
  ├─ initialize registers
  └─ publish interface

normal runtime: read/write/IRQ/workqueue

remove() or device-managed cleanup

**probe()**는 “드라이버 파일이 로드됐다”는 함수가 아니라, 특정 장치 하나를 실제로 사용할 준비를 하는 함수다. 자원을 하나씩 준비하다가 실패하면 이미 얻은 자원을 되돌리고, 아직 준비되지 않은 공급자가 있으면 -EPROBE_DEFER를 반환해 나중에 다시 시도하도록 할 수 있다.

6. 실제 플랫폼 드라이버 뼈대 / A practical platform-driver skeleton

아래 코드는 설명을 위한 최소 뼈대다. 실제 제품에서는 레지스터 정의, 전원 시퀀스, 오류 복구, locking, suspend/resume, ABI 설계를 더 추가해야 한다.

#include <linux/clk.h>
#include <linux/interrupt.h>
#include <linux/io.h>
#include <linux/module.h>
#include <linux/platform_device.h>
#include <linux/of.h>
#include <linux/regulator/consumer.h>

struct my_sensor {
    void __iomem *base;
    struct clk *bus_clk;
    struct regulator *vdd;
    int irq;
};

static irqreturn_t my_sensor_irq(int irq, void *data)
{
    struct my_sensor *sensor = data;
    u32 status = readl(sensor->base + 0x20);

    /* acknowledge only the bits defined by the hardware manual */
    writel(status, sensor->base + 0x24);
    return IRQ_HANDLED;
}

static int my_sensor_probe(struct platform_device *pdev)
{
    struct device *dev = &pdev->dev;
    struct my_sensor *sensor;
    int ret;

    sensor = devm_kzalloc(dev, sizeof(*sensor), GFP_KERNEL);
    if (!sensor)
        return -ENOMEM;

    sensor->base = devm_platform_ioremap_resource(pdev, 0);
    if (IS_ERR(sensor->base))
        return PTR_ERR(sensor->base);

    sensor->bus_clk = devm_clk_get(dev, "bus");
    if (IS_ERR(sensor->bus_clk))
        return dev_err_probe(dev, PTR_ERR(sensor->bus_clk),
                             "failed to get bus clock\\n");

    sensor->vdd = devm_regulator_get(dev, "vdd");
    if (IS_ERR(sensor->vdd))
        return dev_err_probe(dev, PTR_ERR(sensor->vdd),
                             "failed to get vdd\\n");

    ret = regulator_enable(sensor->vdd);
    if (ret)
        return dev_err_probe(dev, ret, "failed to enable vdd\\n");

    ret = clk_prepare_enable(sensor->bus_clk);
    if (ret)
        goto disable_vdd;

    sensor->irq = platform_get_irq(pdev, 0);
    if (sensor->irq < 0) {
        ret = sensor->irq;
        goto disable_clk;
    }

    ret = devm_request_irq(dev, sensor->irq, my_sensor_irq,
                           0, dev_name(dev), sensor);
    if (ret)
        goto disable_clk;

    platform_set_drvdata(pdev, sensor);
    writel(0x1, sensor->base + 0x00); /* enable, per the datasheet */
    return 0;

disable_clk:
    clk_disable_unprepare(sensor->bus_clk);
disable_vdd:
    regulator_disable(sensor->vdd);
    return ret;
}

static void my_sensor_remove(struct platform_device *pdev)
{
    struct my_sensor *sensor = platform_get_drvdata(pdev);

    writel(0x0, sensor->base + 0x00);
    clk_disable_unprepare(sensor->bus_clk);
    regulator_disable(sensor->vdd);
}

static const struct of_device_id my_sensor_of_match[] = {
    { .compatible = "example,my-sensor-v1" },
    { }
};
MODULE_DEVICE_TABLE(of, my_sensor_of_match);

static struct platform_driver my_sensor_driver = {
    .probe  = my_sensor_probe,
    .remove = my_sensor_remove,
    .driver = {
        .name = "my-sensor",
        .of_match_table = my_sensor_of_match,
    },
};
module_platform_driver(my_sensor_driver);

MODULE_LICENSE("GPL");

이 코드는 그대로 복사해 제품에 넣는 코드가 아니다 / The skeleton is not production-ready

커널 버전에 따라 .remove의 반환형이나 clock/regulator API가 달라질 수 있다. 실제 트리의 header와 기존 드라이버 스타일을 기준으로 맞춰야 한다. 특히 IRQ status를 무조건 다시 쓰는 동작은 장치 데이터시트의 acknowledge 방식과 다를 수 있다.

7. 코드 한 덩어리씩 해석하기 / Reading the skeleton line by line

7-1. private data와 devm 자원 / Private state and device-managed resources

struct my_sensor는 이 장치 인스턴스 하나의 상태를 담는다. devm_kzalloc(), devm_platform_ioremap_resource(), devm_request_irq()처럼 devm_ 접두사가 붙은 함수는 장치가 제거될 때 커널이 자원을 자동으로 정리하도록 연결한다.

단, clk_prepare_enable()regulator_enable()은 자동으로 짝을 맞춰 주지 않으므로 성공한 경로와 실패 경로에서 직접 disable해야 한다. “devm이면 모든 cleanup이 자동”이라고 생각하면 안 된다.

7-2. MMIO 접근 / Accessing MMIO

devm_platform_ioremap_resource(pdev, 0)는 Device Tree의 첫 번째 reg 리소스를 매핑한다. 반환값은 일반 RAM 포인터가 아니라 I/O 메모리를 가리키는 __iomem 포인터다.

u32 status = readl(sensor->base + STATUS_OFFSET);
writel(value, sensor->base + CONTROL_OFFSET);

커널에서는 MMIO를 일반 포인터 역참조나 임의의 volatile 변수로 처리하지 않는다. readl()/writel() 같은 접근자가 아키텍처에 필요한 접근 의미와 순서를 표현한다. 레지스터 offset과 write-one-to-clear 같은 동작은 반드시 데이터시트 기준으로 작성한다.

7-3. Register bit, mask, RMW, W1C / Register bits, masks, RMW, and W1C

하드웨어 레지스터 하나에 여러 기능이 들어 있으므로, 기능 하나만 바꾸려면 bit mask를 사용한 **Read-Modify-Write(RMW)**가 필요하다. 예를 들어 START bit만 켜면서 IRQ_EN을 보존하려면 레지스터 전체에 1을 덮어쓰면 안 된다.

#define CTRL_START   BIT(0)
#define CTRL_RESET   BIT(1)
#define CTRL_IRQ_EN  BIT(2)

u32 val = readl(sensor->base + CTRL_OFFSET);
val |= CTRL_START;                 /* only set START */
writel(val, sensor->base + CTRL_OFFSET);

반대로 모든 레지스터가 RMW에 안전한 것은 아니다. W1C(Write One to Clear) 레지스터는 “1을 써서 해당 상태 bit를 지운다”는 의미이므로, 읽은 값을 그대로 다시 쓰면 의도하지 않은 bit를 지울 수 있다. RO, WO, RW, W1C, W1S, RC 같은 접근 속성을 데이터시트에서 확인하고 각각의 acknowledge 방법을 따로 구현한다.

u32 irq = readl(sensor->base + IRQ_STATUS);
if (irq & IRQ_DONE)
    writel(IRQ_DONE, sensor->base + IRQ_STATUS); /* W1C: write 1, not irq */

7-4. MMIO ordering과 DMA descriptor / MMIO ordering and DMA descriptors

NPU·Ethernet·스토리지 드라이버는 보통 일반 RAM에 descriptor를 채운 다음 MMIO doorbell을 써서 장치에 “읽어도 된다”고 알린다. 장치가 doorbell을 먼저 관찰하면 아직 완성되지 않은 descriptor를 읽을 수 있으므로, CPU·캐시·DMA 관점의 순서를 명시해야 한다.

desc->addr = dma_addr;
desc->len  = length;

/* exact ordering depends on the DMA API and device protocol */
dma_wmb();
writel(QUEUE_START, sensor->base + DOORBELL_OFFSET);

readl()/writel()은 MMIO 접근을 표현하는 architecture-aware accessor다. writel_relaxed()는 더 약한 ordering을 의도적으로 사용하므로 성능만 보고 바꾸면 안 된다. wmb(), dma_wmb(), acquire/release 연산 중 무엇이 필요한지는 “누가 어떤 데이터를 언제 관찰해야 하는가”와 DMA API 문서를 기준으로 결정한다. Cache coherency가 있다고 해서 ordering 문제가 자동으로 해결되는 것은 아니다.

7-5. regmap: register 접근의 추상화 / regmap as a register-access abstraction

regmap은 register read/write와 bit update를 공통 API로 감싼다. MMIO뿐 아니라 SPI·I2C peripheral에서도 같은 드라이버 코드를 유지하기 쉽고, register cache·trace·lock 같은 공통 기능도 활용할 수 있다.

/* control path: change only selected bits */
regmap_update_bits(sensor->regmap,
                   CTRL_OFFSET,
                   CTRL_START | CTRL_IRQ_EN,
                   CTRL_START | CTRL_IRQ_EN);

/* fast path: a doorbell may remain direct MMIO */
writel(queue_id, sensor->base + DOORBELL_OFFSET);

regmap_update_bits(map, reg, mask, value)는 mask에 포함된 bit만 바꾼다. 그러나 W1C나 WO doorbell처럼 읽기 자체가 의미 없거나 RMW가 위험한 register에는 무작정 적용하지 않는다. 느린 설정·전원·reset 경로에는 regmap이 편리하고, 고성능 queue 제출 경로에는 직접 MMIO가 더 적합할 수 있다. 이것은 “어느 API가 더 좋은가”가 아니라 장치 프로토콜과 경로의 성격을 구분하는 문제다.

7-6. clock와 regulator / Clock and power

DTS의 clock-names = "bus"vdd-supply가 드라이버의 devm_clk_get(dev, "bus"), devm_regulator_get(dev, "vdd")와 연결된다. 문자열 하나가 다르면 -ENOENT 또는 공급자 준비 지연이 발생할 수 있다.

dev_err_probe()는 오류 코드와 장치 이름을 함께 기록하고 -EPROBE_DEFER 같은 재시도 가능한 상태를 지나치게 시끄럽게 출력하지 않도록 돕는다.

7-7. IRQ와 실행 맥락 / IRQ context

devm_request_irq()로 등록한 핸들러는 인터럽트 컨텍스트에서 실행될 수 있다. 이 안에서는 sleep할 수 없는 경우가 많으므로, 오래 걸리는 작업은 workqueue나 threaded IRQ로 넘긴다.

인터럽트 핸들러에서는 먼저 장치의 상태 레지스터를 읽어 정말 내 장치가 발생시킨 IRQ인지 확인하고, 필요한 acknowledge를 한 뒤 최소한의 일을 한다. 공유 큐를 만진다면 IRQ와 다른 CPU의 동시 접근을 고려해 spinlock 또는 적절한 lock을 사용한다.

8. 오류 경로가 실력을 만든다 / Error paths are part of the driver

드라이버는 성공 경로보다 실패 경로를 더 자주 만난다. 클록이 없거나 regulator가 아직 준비되지 않았거나, IRQ 번호가 잘못됐거나, Device Tree가 비활성화되어 있을 수 있다.

증상 / Symptom먼저 볼 것 / First checks
probe가 호출되지 않음status, compatible, driver가 빌드됐는지, match table이 등록됐는지
-EPROBE_DEFERclock/regulator/GPIO/PHY 공급자가 나중에 준비되는지
-EINVAL 또는 MMIO faultreg cell, bus address translation, resource size, 접근 폭
IRQ가 계속 발생status clear/ack 방식, level·edge 설정, interrupt-parent
장치가 켜지지 않음전원 순서, reset, pinctrl, clock rate, enable bit

로그는 “에러가 났다”보다 “어느 자원을 얻는 단계에서 어떤 errno가 났는가”를 남겨야 한다. dev_err_probe()와 장치 이름을 활용하면 여러 보드 장치를 동시에 볼 때도 추적하기 쉽다.

9. 드라이버가 사용자 공간과 만나는 방법 / Choosing a userspace interface

하드웨어를 초기화했다고 사용자 프로그램이 바로 접근할 수 있는 것은 아니다. 어떤 기능을 외부에 공개할지 인터페이스를 설계해야 한다.

기존 subsystem 사용 : GPIO, IIO, RTC, input, hwmon, MMC 등 이미 맞는 커널 subsystem이 있으면 그 모델에 맞추는 것이 우선이다.

character device : 장치 고유 명령과 read/write/ioctl이 필요할 때 사용한다. ioctl은 장기 ABI가 되므로 구조체 크기·호환성·권한을 신중하게 설계한다.

sysfs : 간단한 상태·설정 속성을 노출하는 표준 경로다. 바이너리 스트림이나 복잡한 명령 프로토콜을 억지로 넣는 곳은 아니다.

debugfs : 디버깅용이며 안정적인 제품 ABI로 약속하면 안 된다.

GPIO처럼 이미 표준 subsystem이 있는 기능을 임의의 character device로 다시 만들면 사용자 공간과 커널 생태계의 장점을 잃는다. 반대로 새 장치의 고유 기능을 subsystem에 억지로 끼워 넣어도 안 된다.

10. Device Tree와 드라이버를 함께 디버깅하기 / Debugging the pair

한쪽만 보면 원인을 놓친다. 아래 순서로 양쪽을 같이 확인한다.

  1. DTS 소스와 실제 부트된 DTB가 같은가?
  2. status = "okay"인가? 상위 버스가 활성화되어 있는가?
  3. compatible 문자열이 driver match table과 정확히 일치하는가?
  4. reg, interrupts, clocks, *-supply, pinctrl의 이름·순서·cell 형식이 binding과 맞는가?
  5. 커널 설정에서 driver가 y 또는 필요한 m으로 들어갔는가?
  6. probe 로그의 첫 실패 errno가 무엇인가?
  7. probe 이후 실제 레지스터와 핀 신호가 데이터시트 기대와 같은가?
# 실행 중인 보드에서 확인할 때의 예
ls /sys/bus/platform/devices
ls /sys/bus/platform/drivers/my-sensor
dmesg | grep -i -E "my-sensor|probe|defer|irq"
cat /proc/interrupts

# 빌드 트리에서 설정과 DT 검증을 확인할 때의 예
grep CONFIG_MY_SENSOR .config
make dtbs_check
make dt_binding_check

위 명령의 경로와 대상은 커널 버전·빌드 시스템에 따라 달라질 수 있다. 중요한 것은 “DTS를 고쳤다”에서 멈추지 않고, 부트된 DTB와 커널 로그가 실제로 바뀌었는지 확인하는 것이다.

11. 다음 단계: 더 큰 드라이버로 확장하기 / Where to go next

이 예제는 단순한 platform driver지만, 실제 BSP 장치에도 같은 질문이 반복된다.

  • USB: regulator/VBUS, PHY, reset, role switch, hub 전원
  • eMMC: bus width, pinctrl, clock, DMA, tuning, power sequence
  • CAN/SPI/UART: subsystem API, FIFO, IRQ, DMA, locking
  • NPU/PCIe: MMIO와 IRQ에 더해 DMA/IOMMU, firmware, power·thermal 관리

드라이버가 실행되는 CPU 쪽의 System Call, task, scheduler, IRQ context, spinlock, atomic, memory ordering을 함께 복습하려면 Linux Kernel 핵심 흐름 글을 이어서 읽으면 된다.

따라서 새로운 장치를 만났을 때 “어떤 함수부터 외울까?”보다 아래 순서를 먼저 세우면 좋다.

회로도 / 데이터시트
  → binding과 DTS
  → compatible matching
  → probe에서 자원 획득
  → register / IRQ 초기화
  → subsystem 또는 userspace ABI
  → 로그·파형·성능 측정으로 검증

핵심 정리 / Key takeaways

  1. Device Tree는 드라이버 코드와 보드별 하드웨어 차이를 분리하는 데이터다.
  2. compatible 매칭이 성공해야 platform driver의 probe()가 특정 장치에 대해 실행된다.
  3. reg·IRQ·clock·regulator·GPIO는 DTS와 드라이버의 이름·cell·순서가 함께 맞아야 한다.
  4. MMIO는 readl()/writel()로 접근하고, IRQ 컨텍스트에서는 sleep 가능 여부를 반드시 구분한다.
  5. 좋은 드라이버는 성공 경로뿐 아니라 -EPROBE_DEFER, 전원 실패, IRQ 오류, remove 경로까지 설계한다.

공식 참고 자료 / Official references

#DeviceTree

#LinuxKernel

#PlatformDriver

#BSP

#DeviceDriver

#MMIO