[Device Driver] 6편. DT Overlay

나우히즈·2026년 1월 5일

이번 게시글에서는 디바이스 트리 오버레이 방식을 통해 PCF8591 디바이스를 라즈베리파이가 자동적으로 인식할 수 있게끔 하고, UART 연결을 통해 부팅로그를 확인해보도록 하겠습니다.


디바이스 트리

1. Device Tree란 무엇인가

디바이스 트리는 “이 보드에 어떤 하드웨어가 어떻게 연결되어 있는지”를 커널에게 알려주는 하드웨어 기술서다.

  • 커널은 하드웨어를 자동으로 스캔하지 않는다
  • 대신 DT를 보고:
    • 어떤 버스가 있는지
    • 그 버스에 어떤 디바이스가 연결됐는지
    • 어떤 주소를 쓰는지

를 정적인 정보로 인지한다

즉, DT에 없으면 = 커널 입장에서는 존재하지 않는 하드웨어 라고 볼 수 있다.

2. Device Tree Overlay란?

라즈베리파이 같은 보드는:

  • 기본적인 디바이스 트리는 공용으로 제공되고
  • 라즈베리파이를 사용하는 유저마다 연결하는 센서/디바이스가 다르다

그래서 전체 DT를 수정하지 않고,
기존 DT 위에 “추가 설정만 덮어씌우는 방식”이 필요했다.
→ 이 방식이 Device Tree Overlay


진행 방식

이에, PCF8591 디바이스를 라즈베리파이가 자동적으로 인식할 수 있게끔, DT Overlay를 진행해보도록 하자.

1. overlay 소스파일

// pcf8591-overlay.dts

/dts-v1/;
/plugin/;


/ {
    compatible = "brcm,bcm2711";

    fragment@0 {
        target = <&i2c1>;
        __overlay__ {
            #address-cells = <1>;
            #size-cells = <0>;

            pcf8591@48 {
                compatible = "nxp,pcf8591";
                reg = <0x48>;
                status = "okay";
            };
        };
    };
};

오버레이 소스파일의 경우 dts 확장자를 가진다.

그러면 pcf8591-overlay.dts 파일은 "이미 존재하는 디바이스 트리에 PCF8591이라는 I2C 디바이스가 있다는 사실을 추가로 알려주는 파일" 이 된다.

위에 기술한 디바이스 트리 상세내용들을 하나씩 확인해보도록 하자.

1) /dts-v1/

  • Device Tree Source 문법 선언 버전을 의미한다.
  • 이 파일은 DTS 문법을 따른다는 선언이라고 보면 된다.
  • 이 파일을 본 커널이 디바이스 트리 소스임을 인식하게 하는 부분.

2) /plugin/

  • 이 멘트가 있어야, "Device Tree" 자체가 아니라 Overlay로서 역할을 한다는 걸 명시한다.
  • 기존 DTB를 대체하지 않고, 해당 파일 내 노드만 수정/추가 하겠다는 의미를 가진다.

3) 최상위 노드 /{...}

/ {
	compatible = "brcm,bcm2711";
  • 이 오버레이가 어떤 SoC 에서 유효한지를 명시함.
  • bcm2711 = Raspberry Pi 4 Soc.

커널이 보고, 이 오버레이는 bcm2711에서만 적용된다고 판단하게 함.

compatible: 커널이 드라이버를 찾는 "열쇠".

compatible 속성은 하드웨어의 '이름표' 이다.
커널은 이 이름표를 보고 어떤 소프트웨어를 실행할지 결정한다.

따라서 최상위 노드에는 해당 보드의 가장 상위 하드웨어인 CPU 가 적히게 된다.

이를 통해 커널은 현재 "라즈베리파이 4(BCM2711 SoC)" 환경임을 알 수 있게 된다. 자동적으로 BCM2711 SoC에 맞는 설정과 드라이버를 불러오게 된다.

디바이스 트리는 계층 구조(Hierarchy)를 가지기 때문에, 하위 노드들에도 compatible을 명시하여 어떤 하드웨어가 매칭되는지를 적게된다.

최상위 노드: "이 판은 BCM2711 보드다." (SoC/플랫폼 매칭)
↓
I2C 컨트롤러 노드: "이것은 BCM2835 방식의 I2C 버스다." (버스 드라이버 매칭)
↓
PCF8591 노드: "이 주소에는 PCF8591 센서가 있다." (센서 드라이버 매칭)

4) fragment@0

fragment란? 오버레이의 실제 단위
DT의 어느 부분을 어떻게 수정할지를 정의하는 블록이다.
하나의 overlay는 여러 fragment를 가질 수 있음.

지금은 하나의 fragment만 있는 단순한 오버레이임.

🧩 fragment: 디바이스 트리의 '수정 단위' 혹은 '퍼즐 조각'
디바이스 트리 오버레이(DTO)는 한 장의 도면이 아니라, 기존 도면에 붙이는 여러 장의 투명한 스티커라고 생각하면 쉽습니다. 이때 스티커 한 장 한 장이 바로 fragment.

  1. fragment의 구조: "어디를(Target), 어떻게(Overlay)"
    하나의 fragment는 반드시 두 가지 핵심 정보를 담고 있어야 한다.
fragment@0 {
    target = <&i2c1>;      // 1. 어디를 수정할 것인가? (대상)
    __overlay__ {          // 2. 어떤 내용을 덮어씌울 것인가? (내용)
        /* 추가하거나 수정할 속성들 */
    };
};

fragment@n: 프래그먼트의 번호입니다. 보통 0번부터 시작하며, 여러 개가 있을 경우 순서대로 처리됩니다.

target: 수정하려는 기존 디바이스 트리의 목적지입니다. (예: i2c1, gpio, spi0 등)

__overlay__: 이곳에 적힌 내용이 실제 target 노드 안으로 '병합(Merge)'됩니다.

5) target = <&i2c1>

"기존 Device Tree에 있는 i2c1 노드를 타겟으로 수정 및 덮어쓰기를 하겠다" 는 의미.

  • &i2c1 : 기존 DT에 이미 정의된 I2C 컨트롤러 노드(즉, SoC 내부의 I2C 버스)

여기에 __overlay__ {...} 를 통해 target으로 지정한 노드에 아래 내용을 추가/덮어쓰기 한다는 의미를 가지게 됨.

6) #address-cells, #size-cells

#address-cells = <1>; // 자식 디바이스 주소 1칸.
#size-cells = <0>;	  // 크기 정보 없음

자식 노드 주소 표현 방식의 정의.

#address-cells#size-cells는 자식 장치의 '번지수(reg)'를 몇 개의 숫자로 표현할지 정하는 약속이다.

I2C는 32비트 숫자 하나면 주소 표현이 충분(0x48)하고, 메모리처럼 범위를 가지지 않기 때문에 <1>과 <0>을 공식 템플릿처럼 사용한다.

이 두 속성은 디바이스 트리에서 "자식 노드의 주소를 어떤 형식으로 적을 것인가?"를 결정하는 규칙판과 같습니다. 처음 접하면 생소할 수 있지만, 리눅스 커널이 하드웨어의 주소를 해석하는 방식을 이해하는 데 핵심적인 부분이다.


1. 'Cell(셀)'이란 무엇인가?

디바이스 트리에서 1 Cell32비트(4바이트) 데이터 한 칸을 의미한다.

  • #address-cells = <1>; → "주소를 적을 때 32비트 한 칸을 써라."
  • #size-cells = <0>; → "크기(범위)를 적을 때는 0칸을 써라(필요 없다)."

2. 왜 I2C에서는 <1><0> ?

이 속성들은 바로 밑에 붙는 자식 노드의 reg 속성이 어떻게 구성될지를 결정한다.

#address-cells = <1>; (주소는 1칸)

I2C 장치의 주소(0x48 등)는 7비트 혹은 10비트입니다. 이는 32비트 한 칸(1 cell) 안에 충분히 들어가는 크기입니다. 그래서 주소를 나타내기 위해 딱 한 칸만 사용하겠다고 선언하는 것이다.

#size-cells = <0>; (크기는 0칸)
  • 메모리(RAM)SoC 내부 레지스터는 '시작 주소'와 '길이(범위)'가 필요하다. (예: 0x100번지부터 0x500번지까지 총 0x400만큼의 크기)

  • 하지만 I2C 장치는 그냥 '고유 번호'일 뿐, 어떤 주소 범위를 점유하지 않는다. 0x48번지라는 지점(Point)만 알면 통신 가능.

  • 따라서 "길이나 크기 정보는 필요 없으니 0칸을 쓰겠다"라고 명시하는 것.


3. reg 속성과의 상관관계

이 설정값들은 자식 노드의 reg 값이 몇 개의 숫자로 이루어질지를 결정.

// 부모 노드(i2c1)의 설정
#address-cells = <1>;
#size-cells = <0>;

// 자식 노드(pcf8591)의 주소 적기
pcf8591@48 {
    reg = <0x48>;  // 1개의 숫자(주소)만 적음. 크기 정보는 0개라 생략됨.
};

만약 메모리 장치처럼 #size-cells = <1>;이었다면, reg = <0x48 0x10>; 처럼 (주소, 길이) 두 개의 숫자를 적어야 했을 것이다.

7) pcf8591@48

실제 디바이스 선언.

<노드이름>@<주소>

노드 이름: pcf8591
주소 : 0x48 (I2C 슬레이브 주소)

이 노드의 정체 : "I2C 주소 0x48에 있는 PCF8591 디바이스"

8) compatible = "nxp,pcf8591"

compatible = "nxp,pcf8591";

이 문자열은 "I2C core가 드라이버를 찾을 때 쓰는 키.
앞에 nxp는 pcf8591 칩을 제조한 제조사명이다. 다른 회사와 모델명이 겹치는 것을 방지하여 이렇게 <제조사명>,<칩셋명> 형식으로 작성한다.

내가 작성한 pcf8591 드라이버가 오버레이한 하드웨어와 매칭이 되면, 드라이버의 probe()가 호출되는 것임.

9) reg = <0x48>;

  • 이 디바이스의 I2C 주소
  • i2c1 버스에서 0x48

이 값으로, /sys/bus/i2c/devices/1-0048 가 생성된다.

10) status = "okay";

이 디바이스는 활성화된 상태라는 의미.

  • "disabled" : 무시
  • "okay" : 커널이 사용

이 문자열이 없거나 disabled 면, 노드가 있어도 probe 되지 못함.


즉, 해당 소스파일은,

“BCM2711 SoC에서,
i2c1 버스에,
주소 0x48에
nxp,pcf8591 디바이스가 존재하며,
이 디바이스는 활성화 상태다.”

라는 말을 커널에 해주게 되고, 커널은 이를 통해

  • i2c1 활성화
  • 0x48 디바이스 생성
  • 드라이버 매칭
  • probe 호출
  • /dev/pcf8591, /dev/pcf8591_dac 생성으로 이어짐.

2. dtbo 로 컴파일

디바이스 트리 소스코드 또한 컴파일 과정이 필요함.
커널이 이해할 수 있는 이진 데이터(Binary)인 .dtbo 파일로 변환하는 과정이 필요한데, 이때 사용하는 도구가 바로 dtc (Device Tree Compiler)이다.

1) 명령어 옵션 파헤치기

dtc -@ -I dts -O dtb -o pcf8591.dtbo pcf8591-overlay.dts
  • dtc: Device Tree Compiler의 약자로, 컴파일러 본체입니다.
  • -@ (가장 중요!): 심볼(Symbol) 생성 옵션입니다.
    • 오버레이는 기존 시스템의 i2c1, gpio 같은 라벨을 참조해야한다. 이 옵션이 있어야 나중에 커널이 오버레이를 로드할 때 "아, &i2c1이 실제 하드웨어 주소 어디구나!"라고 연결(Resolve)할 수 있는 정보를 포함하게 된다.
  • -I dts: Input 형식을 지정. 여기서는 텍스트 파일인 dts.
  • -O dtb: Output 형식을 지정. 커널이 읽는 이진 형태인 dtb(Device Tree Blob)로 출력하겠다는 뜻.
  • -o pcf8591.dtbo: 출력될 파일의 이름을 지정. 오버레이 파일은 관례상 .dtbo (Device Tree Blob Overlay) 확장자를 사용.

2) 왜 .dtb가 아니라 .dtbo인가요?

  • .dtb (Device Tree Blob): 보드 전체의 하드웨어 정보를 담은 메인 지도이다. 부팅될 때 딱 한 번 읽음.

  • .dtbo (Device Tree Blob Overlay): 메인 지도 위에 덧붙이는 투명 스티커. 부팅 중 혹은 운영체제 실행 중에 동적으로 하드웨어 정보를 추가할 때 사용.

3) 컴파일 시 주의사항 (트러블슈팅)

  1. 오타 주의: DTS 문법은 세미콜론(;) 하나만 빠져도 컴파일 에러가 난다. C 언어와 비슷하지만 더 엄격하다.

  2. 라벨 참조: target = <&i2c1>; 처럼 앰퍼샌드(&)를 사용해 라벨을 참조했다면, 반드시 -@ 옵션을 넣어 컴파일해야 한다. 안 그러면 커널이 이 오버레이를 올릴 때 "i2c1이 뭔지 모르겠어!"라며 거부.


3. 시스템 overlays 폴더로 복사

라즈베리파이 OS 기준으로,
/boot/firmware/overlays/ 에 관련한 오버레이들이 저장된다.

해당 디렉토리로 컴파일한 dtbo 파일을 복사한다.

sudo cp pcf8591.dtbo /boot/firmware/overlays/

4. config.txt 에 overlay 등록

/boot/firmware/config.txt 에,

dtoverlay=pcf8591

라는 한 줄을 넣어주어, 오버레이해야할 대상이 존재함을 커널에 알린다.

5. 재부팅 후 자동 생성 확인

ls /sys/bus/i2c/devices/ | grep 1-0048

이렇게 1-0048 이 보인다면, DT Overlay를 통해 디바이스 생성이 완료된 것이다.

6. 드라이버 바인딩 확인

모듈을 올렸을 때, 자동적으로 붙는지를 체크한다.

sudo insmod pcf8591.ko
dmesg | tail

했을 때, 관련한 커널 메시지로 모듈에서 출력한 내용이 나와야한다.

ls -l /dev/pcf8591 /dev/pcf8591_dac

추가로, 장치 파일들이 잘 생성되었는지 체크한다.


드라이버 - DT 연결 원리

i2c 드라이버를 보면, 아래와 같은 코드가 있다.

static const struct i2c_device_id pcf8591_id[] = {
    { "pcf8591", 0 },
    { }
};

// driver 구조체:
.driver = {
    .name = "pcf8591",
},

DT overlay에서 선언한:

compatible = "nxp,pcf8591";
  • 커널 내부에서 compatible → driver 매칭이 일어나고
  • pcf8591_probe() 가 호출됨

즉 흐름은:

DT overlay 적용
  ↓
/sys/bus/i2c/devices/1-0048 생성
  ↓
i2c core가 compatible 확인
  ↓
pcf8591 드라이버 매칭
  ↓
probe() 호출

정리

Device Tree Overlay는 하드웨어용 '포스트잇'이다.

커널은 스스로 하드웨어를 찾지 못한다. 작성한 DTS(Overlay) 파일을 통해 "I2C 1번 도로에 새로운 장치가 생겼어!"라고 커널에게 알려주어야 비로소 하드웨어를 인식한다.

compatible은 하드웨어와 드라이버를 잇는 '이름표'다.

"nxp,pcf8591"이라는 하드웨어의 이름표가 드라이버와 일치할 때, 커널은 비로소 "드라이버"와 "실제 하드웨어"를 매칭시켜 probe() 함수를 호출한다.

정적인 코드 대신, 동적인 설정을 지향한다.

드라이버 코드 안에 I2C 주소를 직접 적는(Hard-coding) 대신, 디바이스 트리를 사용함으로써 하드웨어 구성이 바뀌어도 드라이버 수정 없이 설정 파일(.dts)만으로 유연하게 대처할 수 있게 되었다.

다음 시간에는 UART 연결 진행을 통해 부팅 로그를 확인한 방법에 대해 정리해보도록 하겠습니다.

0개의 댓글