SoM(SOM(System-On-Module)은 프로세서, 메모리, 통신 회로 등 컴퓨터의 핵심 부품을 하나의 작은 기판에 모아놓은 임베디드 보드) 하나를 사서 자체 캐리어보드에 얹으면 벤더가 리눅스 BSP(BSP(Board Support Package)는 운영체제(OS)가 특정 하드웨어 보드에서 원활히 동작하도록 지원하는 소프트웨어 묶음)를 같이 줍니다. 그런데 그 BSP는 벤더의 평가보드 기준입니다. 우리 보드는 배선이 다르니 디바이스 트리를 다시 써야 합니다.

디바이스 트리 작업은 드라이버를 짜는 일이 아닙니다. 이미 있는 드라이버에게 “이 보드에는 무엇이 어디에 붙어 있다”고 알려주는 일입니다. 그래서 대부분의 작업은 노드를 켜고, 핀을 지정하고, 잘못 켜져 있는 것을 끄는 것으로 끝납니다.

아래는 실제로 밟은 순서입니다. 개념 설명보다 무엇을 어떤 순서로 했고 왜 그렇게 했는지에 초점을 맞췄습니다.

   
대상 MYiR MYC-YA157C-V2 (STM32MP157AAC) + 자체 캐리어보드
커널 Linux 5.4.31 (arm)
시작 상태 벤더 BSP의 stm32mp157c-ya157c-v2.dts — 평가보드 배선 기준
결과 dts 452줄 / pinctrl dtsi 163줄 / 신규 핀맵 그룹 7개 / 비활성화 노드 6개

STEP 1 — 전략 정하기: 복사할까, 덮어쓸까

새 보드 dts를 만드는 방법은 두 가지입니다.

  방식 장단점
A 벤더 파일(부품 만든 회사가 같이 준 파일)들을 전부 복사해서 수정 자유롭지만 파일이 커지고, 벤더가 BSP를 갱신하면 병합이 지옥
B 벤더 dts를 #include하고 다른 부분만 덮어쓰기 짧고 리뷰가 쉽다. 대신 벤더가 켜둔 것을 명시적으로 꺼야 한다

B를 골랐습니다. 결정적인 이유는 벤더 자신이 그렇게 하고 있었기 때문입니다. 벤더의 LCD 변형 dts를 열어보니 이렇게 시작합니다.

/* 벤더의 stm32mp157c-ya157c-lcd-v2.dts */
/dts-v1/;
#include "stm32mp157c-ya157c-v2.dts"    // 베이스를 통째로 include

/ {
        model = "...";
        panel { ... };                   // 다른 부분만 추가
};

같은 방식을 따르면 모듈 레벨 설정(DDR·NAND·eMMC·이더넷 PHY)을 건드릴 일이 없습니다. 결과적으로 파일 두 개로 끝났습니다.

arch/arm/boot/dts/
├── stm32mp157-sc6-pinctrl.dtsi   163줄   우리 보드 전용 핀맵 그룹
└── stm32mp157a-sc6-v1.dts        452줄   보드 정의 (베이스 include + 덮어쓰기)

Makefile 등록을 잊지 말 것

arch/arm/boot/dts/Makefile의 STM32 목록에 dtb 타겟을 추가해야 빌드됩니다. 백슬래시 이어쓰기라 목록 중간에 넣는 게 안전합니다.

        stm32mp157c-ya157c-lcd-v2.dtb \
        stm32mp157a-sc6-v1.dtb \
        stm32mp15xc-ya151c-t.dtb \

STEP 2 — 핀맵 그룹: 재사용할지 새로 쓸지 판단하기

벤더 BSP에는 핀맵 그룹이 123개 정의돼 있습니다. UART5를 쓰려면 uart5_pins_a를 참조하면 될 것 같지만, 반드시 열어봐야 합니다.

$ grep -A6 "uart5_pins_a:" arch/arm/boot/dts/stm32mp157-ya157c-pinctrl.dtsi

uart5_pins_a: uart5-0 {
        pins1 {
                pinmux = <STM32_PINMUX('B', 6, AF12)>;  /* UART5_TX */
        };
        pins2 {
                pinmux = <STM32_PINMUX('B', 5, AF12)>;  /* UART5_RX */
        };
};

우리 보드에서 PB6은 I2C1의 SCL이고 PB5는 LCD 리셋입니다. 그대로 썼으면 디스플레이가 죽었을 겁니다. 이름이 같다고 내용이 같은 게 아닙니다.

전부 확인해보니 12개 중 5개는 그대로 쓸 수 있고 7개는 새로 정의해야 했습니다.

기능 벤더 그룹 판정
UART7 (BT) uart7_pins_myir PF7/PF6/PF8/PF9 — 일치, 재사용
UART8 uart8_pins_mx PE1/PE0 — 일치, 재사용
SPI1 spi1_pins_a PZ0~PZ2 — 일치, 재사용
SD카드 sdmmc1_b4_pins_a PC8~PC12·PD2 — 일치, 재사용
UART5 uart5_pins_a PB6/PB5 — 우리는 PB13/PB12. 신규
I2C1 i2c1_pins_a PD12/PF15 — PF15는 우리 BT 리셋. 신규
I2C6 그룹 자체가 없음. 신규

AF 번호 구하기

신규 그룹을 쓰려면 STM32_PINMUX의 세 번째 인자가 필요합니다. SoM 핀리스트 엑셀에 AF0~AF14 열이 있는데, 이 열 번호가 그대로 매크로 인자였습니다.

PB13 행에서 AF14 열을 읽으면  =  UART5_TX
                    ↓
  STM32_PINMUX('B', 13, AF14)

데이터시트를 따로 뒤질 필요가 없고, 동시에 검증도 됩니다 — 원하는 기능이 그 핀의 AF 목록에 없다면 배선이 잘못된 것이니까요.

실제 그룹 정의

출력 핀과 입력 핀을 나누는 게 관례입니다. 전기적 속성이 다르기 때문입니다.

&pinctrl {
        uart5_pins_sc6: uart5-sc6-0 {
                pins1 {
                        pinmux = <STM32_PINMUX('B', 13, AF14)>;   /* TX */
                        bias-disable;
                        drive-push-pull;
                        slew-rate = <0>;
                };
                pins2 {
                        pinmux = <STM32_PINMUX('B', 12, AF14)>;   /* RX */
                        bias-disable;
                };
        };

        uart5_sleep_pins_sc6: uart5-sleep-sc6-0 {
                pins {
                        pinmux = <STM32_PINMUX('B', 13, ANALOG)>,
                                 <STM32_PINMUX('B', 12, ANALOG)>;
                };
        };
};

defaultsleep을 쌍으로 만듭니다. sleep 그룹을 ANALOG로 두는 건 절전 시 핀을 하이임피던스로 놓기 위해서입니다. I2C 그룹이라면 drive-open-drain을 씁니다.

STM32MP1 함정 — PZ 포트

GPIOA~GPIOK는 &pinctrl에 있지만 GPIOZ는 &pinctrl_z라는 별도 컨트롤러입니다. PZ 그룹을 &pinctrl 안에 넣으면 컴파일은 통과하고 동작만 안 합니다. SPI1이나 I2C4/I2C6처럼 PZ를 쓰는 버스에서 반드시 확인해야 합니다.


STEP 3 — 벤더가 켜둔 것 끄기

여기가 include 방식의 대가이자, 실무에서 가장 많이 놓치는 부분입니다. 베이스 dts를 그대로 가져오면 벤더 평가보드용 노드가 전부 활성 상태로 따라옵니다.

찾는 방법은 단순합니다. 우리가 쓰는 핀 목록을 만들어놓고, 벤더가 status = "okay"로 켜둔 노드들이 그 핀을 건드리는지 대조합니다. 여섯 개가 걸렸습니다.

벤더 노드 점유 핀 우리 보드에서는
&i2c2 + 터치 PH4 / PH5 AC 검출 입력
&cec PA15 PWM 출력
panel_backlight PB8 릴레이 제어
&m4_spi1 PZ0~PZ2 SPI를 A7에서 써야 함
joystick PI11 미사용
&m_can1 PA11 / PA12 미연결

끄는 방법은 두 가지입니다.

/* 1. status로 끄기 — 대부분 이걸로 충분 */
&cec {
        status = "disabled";
};

/* 2. 노드 자체를 지우기 — 같은 핀에 다른 걸 붙일 때 */
/delete-node/ &panel_backlight;   /* 라벨이 있으면 최상위에서 */

/ {
        /delete-node/ joystick;   /* 라벨이 없으면 부모 안에서 */
};

&m4_spi1은 특히 눈에 안 띄는 사례였습니다. 벤더는 SPI1 핀을 Cortex-M4 코어용으로 예약(RSVD)해뒀는데, 이 노드를 안 끄면 아무 에러 없이 그냥 SPI가 안 뜹니다.

&m4_spi1 {
        status = "disabled";     /* M4에서 회수 */
};

&spi1 {
        pinctrl-0 = <&spi1_pins_a>;
        cs-gpios = <&gpioz 3 0>;
        status = "okay";         /* A7에서 사용 */
};

STEP 4 — 노드 붙이기: 유형별 패턴

여기서부터는 반복 작업입니다. 실제로 쓴 것들을 유형별로 정리하면 이렇습니다.

시리얼 — 핀맵만 바꿔 끼우기

&uart5 {
        pinctrl-names = "default", "sleep";
        pinctrl-0 = <&uart5_pins_sc6>;
        pinctrl-1 = <&uart5_sleep_pins_sc6>;
        status = "okay";
};

pinctrl-names순서pinctrl-0, pinctrl-1과 인덱스로 짝지어집니다. aliasesserialN 번호가 /dev/ttySTMN을 결정하므로, 유저 공간이 포트 이름에 의존한다면 함부로 바꾸면 안 됩니다.

I2C — 버스를 열고 슬레이브를 매단다

&i2c6 {
        pinctrl-names = "default", "sleep";
        pinctrl-0 = <&i2c6_pins_sc6>;
        pinctrl-1 = <&i2c6_sleep_pins_sc6>;
        clock-frequency = <400000>;
        status = "okay";

        /* 슬레이브가 커널 드라이버를 쓸 때만 노드를 만든다.
           유저 공간에서 /dev/i2c-N 으로 접근할 거면 버스만 열면 끝. */
};

SPI — 반드시 무슨 칩인지 알아야 한다

SPI에 붙는 물건은 compatible이 정해져야 노드를 쓸 수 있습니다. 우리 보드의 SPI에는 신호 이름만 있고 부품 정보가 없어서, KiCad 넷리스트를 따라가 칩을 특정했습니다.

&spi1 {
        /* ... */
        qca7000: ethernet@0 {              /* spidev가 아니라 네트워크 디바이스 */
                compatible = "qca,qca7000";
                reg = <0>;
                interrupt-parent = <&gpiog>;
                interrupts = <7 IRQ_TYPE_EDGE_RISING>;
                spi-cpha;                  /* 바인딩상 필수 */
                spi-cpol;
                spi-max-frequency = <8000000>;
        };
};

전력선 통신 모뎀이라 뜨고 나면 ethN 인터페이스로 잡힙니다. spidev로 열어뒀다면 영영 동작하지 않았을 물건입니다. “SPI에 붙었으니 spidev”라는 가정이 틀릴 수 있습니다.

바인딩 문서가 사양서다

노드에 무슨 프로퍼티를 넣어야 하는지는 커널 트리 안에 답이 있습니다. 이 커널에만 3,675개가 들어 있습니다.

Documentation/devicetree/bindings/net/qca,qca7000.txt

PWM — 부모 타이머부터 켠다

&timers3 {
        status = "okay";                 /* ← 이걸 빠뜨리면 아래가 무의미 */
        pwm3: pwm {
                pinctrl-0 = <&pwm3_pins_a>;
                status = "okay";
        };
        timer@2 { status = "okay"; };
};

부모가 이깁니다

ST BSP는 타이머를 status = "disabled"로 두고 자식 pwm"okay"로 적어두는 경우가 있습니다. 이러면 부모가 probe되지 않아 자식도 안 뜹니다. MFD 계열 노드를 켤 때 항상 확인해야 합니다.

pwms 참조에서도 실수가 잦습니다. STM32는 #pwm-cells = <3>이라 인자가 세 개고, 채널 인덱스는 0부터 셉니다.

pwms = <&pwm3 1 5000000 0>;
/*             ↑    ↑    ↑  극성 (0 = 정상)
               │    └─ 주기(ns)
               └─ 채널 인덱스: TIM3_CH2 → 1 */

GPIO — 극성은 추측하지 말고 회로에서 유도한다

출력은 gpio-leds, 입력은 gpio-keys로 붙이면 브링업 중에 /sys/class/ledsevtest로 바로 검증할 수 있습니다.

leds {
        compatible = "gpio-leds";
        dout1 {
                label = "dout1";
                gpios = <&gpiog 10 GPIO_ACTIVE_HIGH>;
                default-state = "off";
        };
};

din-keys {
        compatible = "gpio-keys";
        din1 {
                label = "din1";
                linux,code = <KEY_F1>;
                interrupt-parent = <&gpioh>;
                interrupts = <13 IRQ_TYPE_EDGE_BOTH>;
        };
};

ACTIVE_HIGHACTIVE_LOW냐는 감으로 정하면 안 됩니다. 우리 보드는 입력이 옵토커플러를 거쳐 반전 슈미트 트리거로 들어오고 있었습니다. 옵토가 도통하면 노드가 LOW가 되고, 반전되어 MPU 핀은 HIGH가 됩니다. 회로에서 극성이 유도됩니다.

ADC — 기준전압이 곧 눈금이다

&adc {
        vref-supply = <&vrefbuf>;      /* 이 값이 모든 측정치의 스케일 */
        status = "okay";
        adc1: adc@0 {
                st,adc-channels = <0 1>;   /* ANA0, ANA1 */
                status = "okay";
        };
};

ADC는 절대 전압을 재지 못하고 기준전압에 대한 비율만 잽니다. 드라이버는 vref-supply가 가리키는 레귤레이터에서 전압을 읽어 눈금으로 씁니다. 이게 틀리면 에러 하나 없이 모든 값이 그 비율만큼 어긋납니다. 회로도와 반드시 대조해야 하는 자리입니다.


STEP 5 — 빌드: 커널 전체를 다시 만들 필요는 없다

DTS만 고쳤다면 dtbs 타겟만 돌리면 됩니다. 수 초면 끝납니다.

make ARCH=arm CROSS_COMPILE=arm-ostl-linux-gnueabi- \
     O=$HOME/work/build stm32mp157a-sc6-v1.dtb

다만 벤더 SDK가 오래되면 호스트와 충돌합니다. 2020년산 SDK(gcc 9.3)를 Ubuntu 24.04(gcc 13.3)에서 쓰니 이렇게 죽었습니다.

ld: liblto_plugin.so: error loading plugin:
    libc.so.6: version `GLIBC_2.33' not found
make[3]: *** [scripts/basic/fixdep] Error 1

SDK 환경 스크립트가 PATH 맨 앞에 자기 호스트 툴을 넣기 때문에, 커널이 호스트 유틸리티를 빌드할 때 최신 gcc가 4년 묵은 링커를 부르게 됩니다. 해결은 한 줄이었습니다.

. /opt/st/.../environment-setup-cortexa7...
unset LDFLAGS CFLAGS CPPFLAGS CXXFLAGS
export PATH=/usr/local/bin:/usr/bin:/bin:$PATH   # 호스트 툴 우선

호스트 링커·컴파일러는 시스템 것을, 크로스 컴파일러와 flex/bison은 SDK 것을 쓰게 됩니다. 매번 손으로 치면 실수하니 스크립트로 고정해두는 편이 낫습니다.


STEP 6 — 검증: 세 단계로 나눠서

DTS는 실행되는 코드가 없어서 printk를 박을 데가 없습니다. 대신 상태를 대조하는 방식으로 확인합니다.

① 컴파일 — 문법과 참조

라벨 오타나 셀 개수 불일치가 여기서 걸립니다. 실제로 pwms에 인자를 하나 빠뜨렸다가 이 경고로 잡았습니다.

Warning (pwms_property): /backlight:pwms: property size (12) too small for cell size 3

② 역컴파일 — 병합 결과

include가 여러 겹이면 최종값을 소스만 봐서는 알 수 없습니다. dtb를 되돌리면 덮어쓰기가 의도대로 됐는지 확인됩니다.

~/work/build/scripts/dtc/dtc -I dtb -O dts -o /tmp/final.dts \
    ~/work/build/arch/arm/boot/dts/stm32mp157a-sc6-v1.dtb

여기서 확인한 것들입니다.

uart5 pinmux    = 0x1d0f       → PB13/AF14 ✓
i2c2, cec       = "disabled"   → 충돌 노드 꺼짐 ✓
spi1            = "okay"       → M4에서 회수됨 ✓
adc vref-supply = vrefbuf      → phandle 일치 ✓

③ 보드에서 — 핀 소유자

부팅 후 실제로 어떤 드라이버가 핀을 가져갔는지 확인합니다. 핀 충돌 추적의 핵심 도구입니다.

cat /proc/device-tree/model
dmesg | grep -iE "probe|fail|defer|-EBUSY"
cat /sys/kernel/debug/pinctrl/soc:pin-controller@50002000/pinmux-pins

-EPROBE_DEFER는 실패가 아니라 “의존 리소스를 기다리는 중”입니다. 끝까지 해소되지 않으면 보통 *-supply 참조가 잘못된 경우입니다.


놓치기 쉬운 것들

이번 작업에서 실제로 걸렸거나 걸릴 뻔했던 것들입니다.

재사용하기 전에 열어본다
핀맵 그룹 이름이 uart5_pins_a라고 해서 그 안이 우리 보드의 UART5 핀일 이유가 없습니다. 이름은 관례이지 계약이 아닙니다. grep 한 번이면 됩니다.
include했으면 끄는 것도 내 몫이다
베이스 dts를 가져오면 벤더 평가보드용 노드가 전부 켜진 채로 따라옵니다. 우리 핀을 점유하는 것을 찾아 명시적으로 꺼야 합니다.
부모 노드의 status를 확인한다
타이머·MFD 계열은 부모가 disabled면 자식이 okay여도 동작하지 않습니다.
PZ 포트는 다른 컨트롤러다
GPIOZ 그룹을 &pinctrl_z가 아닌 곳에 정의하면 컴파일은 통과하고 동작만 안 합니다.
EXTI는 포트가 달라도 번호가 겹치면 싸운다
EXTI 라인 번호마다 포트를 하나만 고를 수 있습니다. PA3·PG3·PI3는 전부 EXTI3을 두고 경쟁합니다. 소프트웨어로는 못 고치니 배선 확정 전에 세어야 합니다.
조용히 틀리는 것을 의심한다
compatible 오타는 아무 일도 안 일으키고, vref-supply 하나가 틀리면 ADC 값만 조용히 어긋납니다. 컴파일도 부팅도 정상인 오류가 가장 오래 걸립니다.
판단 근거를 주석에 남긴다
“핀리스트가 아니라 회로도를 따랐다” 같은 결정은 다음 사람에게 자명하지 않습니다. 근거를 안 적으면 되돌려집니다.