라즈베리파이에서 SPI 통신으로 센서/디스플레이 다루기

라즈베리파이에서 ADC나 LED 매트릭스처럼 빠른 응답과 큰 데이터 전송량이 필요한 주변장치를 다룰 때 I2C(배선 2개, 수백 kHz, 슬레이브 주소 기반)는 속도와 주소 충돌 면에서 답답하다. SPI(Serial Peripheral Interface)는 MOSI/MISO를 따로 둔 전이중 통신에 수 MHz 이상 클록을 쓰고, 장치 선택도 주소 대신 CS(Chip Select) 핀으로 한다. 이 글에서는 spidev로 라즈베리파이의 SPI 버스를 활성화하고, MCP3008(ADC 센서)과 MAX7219(LED 디스플레이)를 예로 실제 통신 프레임을 구성하는 방법을 정리한다.

SPI 프로토콜 기초

SPI는 마스터가 클록(SCLK)을 생성하고, MOSI(Master Out Slave In)로 데이터를 보내는 동시에 MISO(Master In Slave Out)로 슬레이브 응답을 받는 전이중 방식이다. 슬레이브는 주소가 아니라 CS(Chip Select, SS라고도 함) 핀이 Low가 되는 동안만 활성화되므로, 장치를 여러 개 붙이려면 장치마다 별도의 CS 핀이 필요하다(MAX7219처럼 데이지체인을 지원하는 장치는 예외). I2C와 비교하면 다음과 같다.

항목I2CSPI
배선 수2 (SDA, SCL)4 (MOSI, MISO, SCLK, CS) + 장치당 CS 1개
장치 구분7비트 슬레이브 주소CS 핀 (주소 없음)
통신 방향반이중(half-duplex)전이중(full-duplex)
일반적 속도100k~400kHz수 MHz~수십 MHz
다중 장치 확장주소만 다르면 버스 공유장치마다 CS 핀 필요(또는 데이지체인)
디바이스 파일/dev/i2c-N/dev/spidevB.D

SPI는 클록의 idle 상태와 데이터를 샘플링하는 에지를 조합해 4가지 모드를 정의하는데, 이를 CPOL(Clock Polarity)CPHA(Clock Phase)라 부른다. 장치 데이터시트가 요구하는 모드를 spidevmode 값(0~3)에 그대로 맞춰야 통신이 된다.

ModeCPOLCPHA클록 idle 상태데이터 샘플링 에지
000Low상승 에지
101Low하강 에지
210High하강 에지
311High상승 에지

MCP3008과 MAX7219는 둘 다 Mode 0(CPOL=0, CPHA=0)을 쓰는 흔한 장치라 이 글의 예제도 Mode 0으로 통일한다.

spidev 활성화와 MCP3008/MAX7219 실전 코드

라즈베리파이 OS는 SPI 인터페이스도 기본으로 꺼져 있다. raspi-config로 켜거나, /boot/firmware/config.txt(구버전은 /boot/config.txt)에 직접 한 줄을 추가한다.

sudo raspi-config
# Interface Options -> SPI -> Yes 선택 후 재부팅

# 또는 헤드리스 환경이라면 config.txt에 직접 추가
echo "dtparam=spi=on" | sudo tee -a /boot/firmware/config.txt
sudo reboot

재부팅 후 디바이스 파일이 생겼는지 확인하고 spidev 파이썬 패키지를 설치한다. 기본 SPI0 컨트롤러는 CE0/CE1 두 개의 하드웨어 CS 핀을 지원하므로 /dev/spidev0.0, /dev/spidev0.1 두 개가 보통 나타난다.

ls /dev/spidev*
# /dev/spidev0.0  /dev/spidev0.1

python3 -m venv venv
source venv/bin/activate
pip install spidev

MCP3008은 8채널 10비트 ADC로, 아날로그 센서(가변저항, 조도센서, 아날로그 온도센서 등)를 라즈베리파이의 디지털 GPIO에 연결할 때 흔히 쓴다. 명령은 3바이트로 구성된다: start bit(1) + SGL/DIFF(1) + 채널 3비트를 담은 0x01/상위 니블 바이트를 보내고, 세 번째 바이트는 응답을 받기 위한 더미(dummy) 클록이다. 응답 3바이트 중 두 번째 바이트의 하위 2비트와 세 번째 바이트를 합치면 10비트 값(0~1023)이 나온다.

import time
import spidev

spi = spidev.SpiDev()
spi.open(0, 0)          # /dev/spidev0.0 (CE0)
spi.max_speed_hz = 1350000
spi.mode = 0b00          # MCP3008은 Mode 0


def read_channel(channel: int) -> int:
    if not 0 <= channel <= 7:
        raise ValueError("channel must be 0-7")
    cmd = [0x01, (0x08 | channel) << 4, 0x00]
    resp = spi.xfer2(cmd)
    return ((resp[1] & 0x03) << 8) | resp[2]


def raw_to_voltage(raw: int, vref: float = 3.3) -> float:
    return raw / 1023.0 * vref


if __name__ == "__main__":
    try:
        while True:
            raw = read_channel(0)
            print(f"channel0 raw={raw:4d}  voltage={raw_to_voltage(raw):.3f}V")
            time.sleep(0.5)
    finally:
        spi.close()

명령 프레임 생성과 응답 디코딩은 순수 비트 연산이라 하드웨어 없이도 검증 가능하다. spi.xfer2() 응답을 mock으로 넣어 read_channel의 디코딩 로직만 떼어 실행했다. 실제 MOSI/MISO 통신은 라즈베리파이와 MCP3008 실물이 있어야 확인할 수 있어 검증 범위 밖이다.

def cmd_bytes(channel):
    return [0x01, (0x08 | channel) << 4, 0x00]

def decode(resp):
    return ((resp[1] & 0x03) << 8) | resp[2]

print("channel 2 cmd:", [hex(b) for b in cmd_bytes(2)])
print("fullscale ->", decode([0x00, 0x03, 0xFF]))
print("half ->", decode([0x00, 0x02, 0x00]))
channel 2 cmd: ['0x1', '0xa0', '0x0']
fullscale -> 1023
half -> 512

MAX7219는 8×8 LED 매트릭스나 7세그먼트 디스플레이 8자리를 3개 GPIO 신호(DIN/CLK/CS)만으로 구동하는 드라이버 칩이다. 통신 단위는 16비트(2바이트) 프레임이며 상위 바이트가 레지스터 주소, 하위 바이트가 데이터다. 밝기(intensity), 스캔 자릿수(scan limit), 디코드 모드, 셧다운 해제 등을 먼저 설정해야 출력이 뜬다.

import spidev

REG_DECODE_MODE = 0x09
REG_INTENSITY = 0x0A
REG_SCAN_LIMIT = 0x0B
REG_SHUTDOWN = 0x0C
REG_DISPLAY_TEST = 0x0F
REG_DIGIT0 = 0x01

spi = spidev.SpiDev()
spi.open(0, 1)           # /dev/spidev0.1 (CE1), MCP3008과 CS 핀 분리
spi.max_speed_hz = 10000000
spi.mode = 0b00


def send(register: int, data: int) -> None:
    spi.xfer2([register, data])


def init_display() -> None:
    send(REG_DISPLAY_TEST, 0x00)   # 테스트 모드 끄기
    send(REG_SCAN_LIMIT, 0x07)     # 8자리 모두 사용
    send(REG_DECODE_MODE, 0x00)    # 직접 세그먼트 제어(no BCD decode)
    send(REG_INTENSITY, 0x08)      # 밝기 중간
    send(REG_SHUTDOWN, 0x01)       # 정상 동작 모드로 전환


def clear() -> None:
    for digit in range(8):
        send(REG_DIGIT0 + digit, 0x00)


if __name__ == "__main__":
    init_display()
    clear()
    send(REG_DIGIT0, 0b01111110)  # 첫 자리에 '0' 모양 세그먼트 패턴 출력

여러 모듈을 데이지체인으로 연결하면 특정 모듈에만 값을 넣기 위해 나머지 모듈 자리를 NOOP(0x00, 0x00)으로 채워야 한다(체인의 첫 워드가 가장 마지막 모듈로 들어가므로 순서를 거꾸로 계산). 3개 모듈 체인에서 두 번째 모듈에만 데이터를 보내는 프레임을 mock으로 구성한 결과다.

def build_chain_frame(n_modules, target_index, register, data):
    frame = []
    for i in range(n_modules):
        if i == target_index:
            frame += [register, data]
        else:
            frame += [0x00, 0x00]
    return frame

print([hex(b) for b in build_chain_frame(3, 1, 0x01, 0xFF)])
['0x0', '0x0', '0x1', '0xff', '0x0', '0x0']

이 프레임을 실제 LED로 출력해 눈으로 확인하는 것은 하드웨어 없이는 불가능해 검증 범위 밖이다.

주의사항

  • CS 핀 충돌 — MCP3008과 MAX7219처럼 SPI 장치를 두 개 이상 붙일 때는 spi.open(0, 0)/spi.open(0, 1)처럼 서로 다른 CE 핀을 써야 한다. 하드웨어 CS가 2개뿐이라 세 번째 장치부터는 소프트웨어 GPIO를 CS로 직접 제어하거나 데이지체인을 지원하는 장치를 골라야 한다.
  • MOSI/MISO 배선 반전 — 배선이 꼬여 MOSI와 MISO가 뒤바뀌면 통신 자체가 안 되거나 항상 같은 값만 돌아온다. i2cdetect 같은 스캔 도구가 없으니 배선표를 데이터시트와 대조해 눈으로 확인하는 수밖에 없다.
  • CPOL/CPHA 모드 불일치spi.mode를 장치 스펙과 다르게 설정하면 클록 타이밍이 어긋나 데이터가 한 비트씩 밀려 들어오는 증상이 흔하다. 값이 이상하게 튄다면 가장 먼저 모드부터 의심한다.
  • max_speed_hz 과다 설정 — 배선이 길거나 브레드보드 점퍼선을 여러 개 거치면 노이즈에 취약해진다. 데이터시트 최대 클록보다 낮게(예: MCP3008은 최대 3.6MHz지만 1.35MHz 정도로) 설정하면 안정성이 올라간다.
  • PermissionError: [Errno 13] — I2C와 마찬가지로 /dev/spidev*도 그룹 권한이 필요하다. sudo usermod -aG spi $USER 실행 후 로그아웃/재로그인해야 반영된다.

마무리

SPI는 “주소로 찾는” I2C와 달리 “CS로 고르고, 클록에 맞춰 바이트를 쏟아붓는” 방식이라 장치마다 프레임의 바이트 배치만 다를 뿐 다루는 패턴은 비슷하다. MCP3008의 “명령 프레임 → xfer2 → 응답 비트 추출” 흐름과 MAX7219의 “레지스터/데이터 2바이트 프레임 → 데이지체인 NOOP 채우기” 흐름은 다른 SPI ADC·디스플레이 드라이버에도 거의 그대로 재사용할 수 있다. 명령 프레임 생성/디코딩 로직은 mock으로 검증했지만, spidev가 실물 하드웨어와 주고받는 부분은 실제 라즈베리파이·MCP3008·MAX7219가 있어야 확인 가능해 이번에는 검증하지 못했다.

참고

답글 남기기