Devin.KR

SPI - 빠른 주변장치 통신

개발자KR 조회 4

이 장에서 배우는 것

온습도 센서와 대화하는 방법을 I2C로 익힌 앞 장에 이어, 이번 장은 그보다 훨씬 빠른 속도가 필요한 상황에서 쓰는 SPI(serial peripheral interface)를 다룬다. 스마트 화분에 화면을 붙여 상태를 보여주거나, 센서 기록을 매 순간 저장할 메모리를 붙이는 순간 SPI가 필요해진다. I2C가 두 가닥의 선으로 여러 장치와 느긋하게 대화했다면, SPI는 신호선을 더 쓰는 대신 훨씬 빠르고 단순한 규칙으로 움직인다.

  • SPI의 네 신호(SCLK, MOSI, MISO, CS)가 하는 역할을 구분한다
  • 클럭 극성(CPOL)과 클럭 위상(CPHA)이 만드는 네 가지 모드를 표로 정리한다
  • 전이중(full-duplex) 전송이 I2C의 반이중 방식과 어떻게 다른지 설명한다
  • 칩 선택(CS, chip select) 신호로 한 버스에 여러 장치를 구분해 연결한다
  • 메모리 장치와 화면 장치 두 가지 예제로 SPI 트랜잭션의 흐름을 구현한다

문제 상황

화분에 붙일 기능이 늘었다. 하나는 온습도와 펌프 동작을 기록해 두는 저장 장치이고, 다른 하나는 흙의 수분 상태를 막대그래프로 보여주는 작은 화면이다. 두 장치 모두 I2C로도 만들 수 있지만, 화면은 한 번에 보낼 데이터 양이 많고, 저장 장치는 기록이 쌓일 때마다 지연 없이 써야 한다. I2C의 수백 kHz 대역에서는 화면 한 번 갱신하는 데도 체감될 만큼 시간이 걸린다.

SPI를 쓰면 같은 하드웨어에서 몇 배 빠른 클럭으로 데이터를 주고받을 수 있다. 다만 신호선이 늘어나고, 장치마다 클럭 극성과 위상이 다를 수 있으며, 여러 장치를 한 버스에 물릴 때 칩 선택 신호를 직접 관리해야 한다는 새로운 책임이 생긴다. 이번 장은 이 책임을 코드로 어떻게 나타내는지 보여준다.

SPI 신호선과 클럭 모드

SPI는 보통 네 개의 신호로 이루어진다. SCLK(serial clock)는 마스터가 만들어내는 클럭이고, MOSI(master out slave in)는 마스터에서 장치로 가는 선, MISO(master in slave out)는 장치에서 마스터로 오는 선이다. 여기에 장치마다 하나씩 필요한 CS(chip select)가 더해진다. I2C의 주소 지정과 달리, SPI는 "이 장치와 지금 대화한다"는 사실을 CS 선을 낮춰서 알린다.

클럭이 쉬고 있을 때 높은 레벨인지 낮은 레벨인지를 정하는 값이 클럭 극성(CPOL, clock polarity)이고, 데이터를 클럭의 첫 번째 에지에서 읽을지 두 번째 에지에서 읽을지를 정하는 값이 클럭 위상(CPHA, clock phase)이다. 이 둘의 조합으로 네 가지 모드가 나온다. 같은 버스에 물린 장치라도 모드가 다르면 마스터가 장치를 바꿀 때마다 모드를 다시 설정해야 한다.

SPI 클럭 모드별 CPOL·CPHA와 샘플링 시점
모드CPOLCPHA샘플링 에지
모드 000클럭 대기=로우, 첫 에지에서 읽음
모드 101클럭 대기=로우, 두 번째 에지에서 읽음
모드 210클럭 대기=하이, 첫 에지에서 읽음
모드 311클럭 대기=하이, 두 번째 에지에서 읽음
CPOL과 CPHA 조합이 클럭 대기 레벨과 샘플링 에지를 정하며 예제는 모드 0과 모드 3을 함께 쓴다

모드를 고르는 기준은 장치의 데이터시트다. 직접 만든 장치가 아니라면 마음대로 고를 수 없고, 장치가 요구하는 값을 그대로 맞춰야 한다. 이번 장 예제에서는 저장 장치는 모드 0, 화면 장치는 모드 3을 쓰는 것으로 가정한다.

전이중 전송과 칩 선택

I2C는 한 순간에 한쪽만 선을 끄는 반이중 방식이지만, SPI는 클럭 한 번에 MOSI와 MISO가 동시에 값을 주고받는 전이중 방식이다. 함수 하나가 "1바이트 보내기"와 "1바이트 받기"를 동시에 처리한다는 뜻이다. 그래서 SPI 전송 함수는 대개 uint8_t transfer(uint8_t out)처럼 값을 넣으면서 값을 돌려받는 모양을 한다. 장치에 보낼 말이 없어도 더미 바이트를 내보내야 응답을 받을 수 있고, 장치가 보낼 응답이 없어도 마스터가 보낸 값이 그대로 장치 내부에 쌓인다.

여러 장치를 한 버스에 묶을 때는 SCLK, MOSI, MISO는 공유하되 CS만 장치마다 따로 둔다. 마스터는 대화하려는 장치의 CS를 낮추고, 트랜잭션이 끝날 때까지 그 상태를 유지한 뒤에야 다시 올린다. 두 장치의 CS를 동시에 낮추면 두 장치가 동시에 MISO에 값을 실어 신호가 충돌하므로, 한 번에 하나의 CS만 활성화하는 규칙을 코드로 강제해야 한다.

SPI는 하나의 클럭 구간에서 MOSI와 MISO가 동시에 오가며 CS는 트랜잭션이 끝날 때까지 로우로 유지된다

완성 코드

실제 보드 없이 macOS·Linux에서 바로 돌려볼 수 있도록, SPI 버스 뒤에 저장 장치(메모리)와 화면 장치 두 개를 흉내 내는 시뮬레이션 계층 hal_sim을 둔다. 저장 장치는 명령-주소-데이터 순서로 움직이는 단순한 프로토콜을 쓰고, 화면 장치는 한 줄(8칸)의 밝기 값을 받으면서 이전 프레임 값을 그대로 돌려주어 전이중 동작을 눈으로 확인할 수 있게 했다.

hal_sim.h

#ifndef HAL_SIM_H
#define HAL_SIM_H

#include <stdint.h>
#include <stddef.h>

#define CS_FLASH   0u
#define CS_DISPLAY 1u

typedef struct {
    uint8_t cpol;
    uint8_t cpha;
} spi_mode_t;

void hal_spi_init(void);
void hal_spi_set_mode(spi_mode_t mode);
spi_mode_t hal_spi_get_mode(void);
void hal_cs_select(unsigned cs_pin);
void hal_cs_deselect(unsigned cs_pin);
uint8_t hal_spi_transfer(uint8_t out_byte);
void hal_spi_transfer_buf(const uint8_t *tx, uint8_t *rx, size_t len);

#endif

hal_sim.c

#include "hal_sim.h"
#include <string.h>

#define FLASH_SIZE 256u

typedef enum {
    DEV_IDLE,
    DEV_FLASH_CMD,
    DEV_FLASH_ADDR,
    DEV_FLASH_READ,
    DEV_FLASH_WRITE,
    DEV_DISP_CMD,
    DEV_DISP_DATA
} dev_state_t;

static uint8_t s_flash[FLASH_SIZE];
static uint8_t s_display_row[8];
static unsigned s_active_cs = 0xFFu;
static dev_state_t s_state = DEV_IDLE;
static uint8_t s_flash_cmd;
static uint8_t s_flash_addr;
static uint8_t s_disp_idx;
static spi_mode_t s_mode;

void hal_spi_init(void) {
    memset(s_flash, 0xFF, sizeof(s_flash));
    memset(s_display_row, 0, sizeof(s_display_row));
    s_active_cs = 0xFFu;
    s_state = DEV_IDLE;
    s_flash_cmd = 0;
    s_flash_addr = 0;
    s_disp_idx = 0;
    s_mode.cpol = 0;
    s_mode.cpha = 0;
}

void hal_spi_set_mode(spi_mode_t mode) {
    s_mode = mode;
}

spi_mode_t hal_spi_get_mode(void) {
    return s_mode;
}

void hal_cs_select(unsigned cs_pin) {
    s_active_cs = cs_pin;
    if (cs_pin == CS_FLASH) {
        s_state = DEV_FLASH_CMD;
    } else {
        s_state = DEV_DISP_CMD;
        s_disp_idx = 0;
    }
}

void hal_cs_deselect(unsigned cs_pin) {
    (void)cs_pin;
    s_active_cs = 0xFFu;
    s_state = DEV_IDLE;
}

uint8_t hal_spi_transfer(uint8_t out_byte) {
    uint8_t in_byte = 0xFFu;

    if (s_active_cs == CS_FLASH) {
        switch (s_state) {
        case DEV_FLASH_CMD:
            s_flash_cmd = out_byte;
            s_state = DEV_FLASH_ADDR;
            in_byte = 0x00u;
            break;
        case DEV_FLASH_ADDR:
            s_flash_addr = out_byte;
            s_state = (s_flash_cmd == 0x03u) ? DEV_FLASH_READ : DEV_FLASH_WRITE;
            in_byte = 0x00u;
            break;
        case DEV_FLASH_READ:
            in_byte = s_flash[s_flash_addr];
            s_flash_addr = (uint8_t)(s_flash_addr + 1u);
            break;
        case DEV_FLASH_WRITE:
            s_flash[s_flash_addr] = out_byte;
            s_flash_addr = (uint8_t)(s_flash_addr + 1u);
            in_byte = 0x00u;
            break;
        default:
            break;
        }
    } else if (s_active_cs == CS_DISPLAY) {
        switch (s_state) {
        case DEV_DISP_CMD:
            s_state = DEV_DISP_DATA;
            in_byte = 0xAAu;
            break;
        case DEV_DISP_DATA:
            if (s_disp_idx < 8u) {
                in_byte = s_display_row[s_disp_idx];
                s_display_row[s_disp_idx] = out_byte;
                s_disp_idx++;
            }
            break;
        default:
            break;
        }
    }
    return in_byte;
}

void hal_spi_transfer_buf(const uint8_t *tx, uint8_t *rx, size_t len) {
    size_t i;
    for (i = 0; i < len; i++) {
        uint8_t r = hal_spi_transfer(tx[i]);
        if (rx != NULL) {
            rx[i] = r;
        }
    }
}

main.c

#include <stdio.h>
#include <stdint.h>
#include "hal_sim.h"

typedef struct {
    uint8_t temp_c;
    uint8_t humidity_pct;
    uint8_t pump_on;
} plant_log_t;

static void flash_write_log(uint8_t addr, const plant_log_t *log) {
    spi_mode_t mode0 = {0, 0};
    hal_spi_set_mode(mode0);
    hal_cs_select(CS_FLASH);
    hal_spi_transfer(0x02u);
    hal_spi_transfer(addr);
    hal_spi_transfer(log->temp_c);
    hal_spi_transfer(log->humidity_pct);
    hal_spi_transfer(log->pump_on);
    hal_cs_deselect(CS_FLASH);
}

static void flash_read_log(uint8_t addr, plant_log_t *log) {
    spi_mode_t mode0 = {0, 0};
    hal_spi_set_mode(mode0);
    hal_cs_select(CS_FLASH);
    hal_spi_transfer(0x03u);
    hal_spi_transfer(addr);
    log->temp_c = hal_spi_transfer(0xFFu);
    log->humidity_pct = hal_spi_transfer(0xFFu);
    log->pump_on = hal_spi_transfer(0xFFu);
    hal_cs_deselect(CS_FLASH);
}

static void display_send_row(const uint8_t brightness[8], uint8_t prev_echo[8]) {
    spi_mode_t mode3 = {1, 1};
    hal_spi_set_mode(mode3);
    hal_cs_select(CS_DISPLAY);
    uint8_t ack = hal_spi_transfer(0x10u);
    printf("표시 장치 응답(ACK): 0x%02X\n", ack);
    for (int i = 0; i < 8; i++) {
        prev_echo[i] = hal_spi_transfer(brightness[i]);
    }
    hal_cs_deselect(CS_DISPLAY);
}

int main(void) {
    hal_spi_init();

    plant_log_t log_out = {24u, 55u, 1u};
    flash_write_log(0x00u, &log_out);
    printf("플래시 모드: CPOL=%u, CPHA=%u\n",
           hal_spi_get_mode().cpol, hal_spi_get_mode().cpha);

    plant_log_t log_in = {0, 0, 0};
    flash_read_log(0x00u, &log_in);
    printf("플래시에서 읽은 기록: 온도 %u C, 습도 %u %%, 펌프 %s\n",
           log_in.temp_c, log_in.humidity_pct,
           log_in.pump_on ? "켜짐" : "꺼짐");

    uint8_t bars1[8] = {10u, 20u, 30u, 40u, 50u, 60u, 70u, 80u};
    uint8_t prev1[8] = {0};
    display_send_row(bars1, prev1);
    printf("표시 장치 모드: CPOL=%u, CPHA=%u\n",
           hal_spi_get_mode().cpol, hal_spi_get_mode().cpha);
    printf("첫 전송에서 되돌아온 이전 프레임: %u %u %u %u %u %u %u %u\n",
           prev1[0], prev1[1], prev1[2], prev1[3],
           prev1[4], prev1[5], prev1[6], prev1[7]);

    uint8_t bars2[8] = {15u, 25u, 35u, 45u, 55u, 65u, 75u, 85u};
    uint8_t prev2[8] = {0};
    display_send_row(bars2, prev2);
    printf("두 번째 전송에서 되돌아온 이전 프레임: %u %u %u %u %u %u %u %u\n",
           prev2[0], prev2[1], prev2[2], prev2[3],
           prev2[4], prev2[5], prev2[6], prev2[7]);

    return 0;
}

줄별 해설

hal_sim.h의 spi_mode_t는 CPOL·CPHA를 묶어 둔 구조체다. 실제 장치가 아니라 시뮬레이션이므로 값을 저장만 하고 클럭 파형을 만들지는 않지만, 어떤 트랜잭션이 어떤 모드로 진행되는지 코드에 남기기 위해 존재한다.

hal_sim.c의 dev_state_t는 CS가 활성화된 뒤 지금까지 몇 바이트를 주고받았는지를 나타내는 상태 기계다. hal_cs_select가 호출되면 어떤 CS인지에 따라 DEV_FLASH_CMD 또는 DEV_DISP_CMD로 시작 상태를 정한다. 이 상태 값이 바로 다음에 오는 바이트를 "명령"으로 볼지 "주소"로 볼지 "데이터"로 볼지를 결정한다.

hal_spi_transfer의 저장 장치 분기는 명령 바이트 0x02(쓰기)와 0x03(읽기)를 구분해 DEV_FLASH_READ 또는 DEV_FLASH_WRITE로 넘어간다. 이후 들어오는 바이트마다 s_flash_addr가 1씩 증가하므로, 마스터가 연속으로 여러 바이트를 보내면 여러 주소에 걸쳐 쓰거나 읽는 동작이 자연스럽게 이어진다.

화면 장치 분기는 첫 바이트(명령)에 대해 항상 0xAA를 돌려주고, 이후 8바이트 동안은 s_display_row에 저장돼 있던 이전 값을 돌려주면서 동시에 새 값을 그 자리에 저장한다. 같은 hal_spi_transfer 호출 안에서 "새 값 쓰기"와 "이전 값 읽기"가 함께 일어난다는 점이 전이중 통신을 코드로 보여주는 부분이다.

main.c의 flash_write_log와 flash_read_log는 모드 0으로 설정한 뒤 CS를 선택하고, 트랜잭션이 끝날 때까지 CS를 내리지 않은 채 명령·주소·데이터 바이트를 순서대로 보낸다. display_send_row는 모드 3으로 바꾼 뒤 명령 바이트로 ACK를 받고, 이어서 8개의 밝기 값을 보내면서 돌아오는 이전 프레임 값을 prev_echo에 담는다.

실행 결과

$ cc -std=c11 -Wall -Wextra -o spi_demo hal_sim.c main.c
$ ./spi_demo
플래시 모드: CPOL=0, CPHA=0
플래시에서 읽은 기록: 온도 24 C, 습도 55 %, 펌프 켜짐
표시 장치 응답(ACK): 0xAA
표시 장치 모드: CPOL=1, CPHA=1
첫 전송에서 되돌아온 이전 프레임: 0 0 0 0 0 0 0 0
표시 장치 응답(ACK): 0xAA
두 번째 전송에서 되돌아온 이전 프레임: 10 20 30 40 50 60 70 80

첫 화면 전송에서는 이전 프레임이 없으므로 모두 0이 돌아오고, 두 번째 전송에서는 방금 전에 보낸 밝기 값 10~80이 그대로 돌아온다. 같은 클럭 구간에서 새 값을 보내는 동시에 이전 값을 받는다는 사실이 숫자로 확인된다.

실무에서 자주 틀리는 것

트랜잭션 중간에 CS를 내렸다 올리기

명령과 주소를 보낸 뒤 CS를 한 번 해제하고 다시 선택해 데이터를 보내면, 장치는 새로운 트랜잭션으로 인식해 상태를 처음부터 다시 시작한다.

/* 틀린 코드: 주소 뒤에 CS를 해제해 버림 */
hal_cs_select(CS_FLASH);
hal_spi_transfer(0x02u);
hal_spi_transfer(addr);
hal_cs_deselect(CS_FLASH);      /* 여기서 트랜잭션이 끊긴다 */
hal_cs_select(CS_FLASH);
hal_spi_transfer(data);
hal_cs_deselect(CS_FLASH);
/* 고친 코드: CS를 한 번만 선택하고 끝까지 유지 */
hal_cs_select(CS_FLASH);
hal_spi_transfer(0x02u);
hal_spi_transfer(addr);
hal_spi_transfer(data);
hal_cs_deselect(CS_FLASH);

모드를 한 번만 설정하고 넘어가기

버스 초기화 시점에 모드를 한 번 정해 두고 장치를 바꿀 때마다 다시 설정하지 않으면, 나중에 붙인 장치가 앞선 장치의 모드로 동작해 응답이 어긋난다.

/* 틀린 코드: init에서 한 번만 설정 */
hal_spi_set_mode(mode0);
/* ... 프로그램 내내 flash와 display를 번갈아 사용 ... */
/* 고친 코드: 장치를 선택하기 직전에 매번 설정 */
hal_spi_set_mode(mode0);
hal_cs_select(CS_FLASH);
/* ... */
hal_spi_set_mode(mode3);
hal_cs_select(CS_DISPLAY);

MISO로 돌아온 값을 그냥 버리기

쓰기만 하면 된다고 생각해 hal_spi_transfer의 반환값을 무시하면, 장치가 오류나 준비 안 됨을 알리는 응답을 보내도 알아채지 못한다.

/* 틀린 코드: 반환값을 무시 */
hal_spi_transfer(0x10u);
/* 고친 코드: 응답을 받아서 확인 */
uint8_t ack = hal_spi_transfer(0x10u);
if (ack != 0xAAu) {
    /* 오류 처리 */
}

다른 장치의 CS를 내리지 않고 새 장치 선택하기

이전 장치의 CS가 아직 낮은 상태에서 다음 장치의 CS까지 낮추면 두 장치가 동시에 MISO에 값을 올려 신호가 겹친다.

/* 틀린 코드: flash CS를 해제하지 않은 채 display 선택 */
hal_cs_select(CS_FLASH);
hal_spi_transfer(0x03u);
hal_cs_select(CS_DISPLAY);      /* flash CS가 아직 낮다 */
/* 고친 코드: 반드시 먼저 해제한 뒤 다음 장치를 선택 */
hal_cs_select(CS_FLASH);
hal_spi_transfer(0x03u);
hal_cs_deselect(CS_FLASH);
hal_cs_select(CS_DISPLAY);

한눈에 보기

SPI 핵심 개념 요약
개념핵심 내용
SCLK·MOSI·MISO클럭과 양방향 데이터 두 선으로 이루어진 3선 데이터 경로
CS장치마다 하나씩 두어 트랜잭션 시작과 끝을 표시하는 선택 신호
전이중같은 클럭에서 MOSI 송신과 MISO 수신이 동시에 일어남
모드(CPOL·CPHA)클럭 대기 레벨과 샘플링 에지를 정하며 장치마다 요구 값이 다름
CS 유지트랜잭션 도중에는 CS를 내렸다 올리지 않고 끝까지 유지
PC 시뮬레이션과 실제 보드의 차이
항목PC 시뮬레이션STM32ESP32·아두이노
CS 제어함수 호출로 상태만 바꿈소프트웨어 GPIO 또는 하드웨어 NSS 핀대부분 소프트웨어 GPIO로 직접 제어
클럭 속도개념적으로만 존재, 실제 지연 없음레지스터로 수십 MHz까지 설정라이브러리 함수 인자로 MHz 단위 설정
전송 방식바이트 단위 함수 호출폴링·인터럽트·DMA 선택 가능주로 라이브러리 내부에서 폴링 처리
다중 장치배열 인덱스로 장치 구분NSS 여러 개 또는 GPIO 여러 개GPIO 여러 개, 라이브러리가 순서 관리

연습 문제

  1. SPI에서 클럭 한 번에 MOSI와 MISO가 동시에 오가는 이유를 설명하고, 이것이 한쪽만 값을 보내는 I2C의 반이중 방식과 어떻게 다른지 서술하라.
  2. 예제 코드에서 명령 바이트 0x02와 0x03이 각각 무엇을 뜻하는지, 그리고 그 뒤에 오는 바이트 수가 프로토콜의 어느 부분에서 정해지는지 설명하라.
  3. 두 번째 display_send_row 호출이 돌려주는 값이 왜 첫 번째 호출에서 보낸 밝기 값과 같은지 설명하고, 만약 화면 장치의 CS를 바이트마다 선택·해제하도록 바꾸면 어떤 문제가 생기는지 예상하라.
  4. 모드 0을 쓰는 저장 장치와 모드 3을 쓰는 화면 장치를 같은 SPI 버스에 함께 연결할 때, 마스터 쪽 코드가 장치를 바꿀 때마다 반드시 지켜야 할 순서를 정리하라.

정답과 해설

1. SPI는 마스터가 만든 클럭에 맞춰 한 비트씩 MOSI와 MISO의 값을 동시에 옮긴다. 보낼 말이 없어도 더미 바이트를 내보내야 응답을 받을 수 있는 구조다. 반면 I2C는 한 순간에 한쪽만 선을 잡고 말하는 반이중 방식이라, 읽기와 쓰기가 항상 순서대로 일어난다.

2. 0x02는 쓰기, 0x03은 읽기 명령이다. hal_spi_transfer의 DEV_FLASH_ADDR 분기에서 이 명령값을 보고 다음 상태를 DEV_FLASH_WRITE나 DEV_FLASH_READ로 정하며, 이후 바이트 수는 마스터가 CS를 유지한 채 몇 번 더 hal_spi_transfer를 호출하느냐로 결정된다. 프로토콜 자체에는 길이 제한이 없고, 호출 횟수만큼 주소가 계속 증가한다.

3. s_display_row는 CS가 유지되는 동안 이전에 쓴 값을 그대로 보관하고 있다가, 다음 트랜잭션에서 새 값을 쓰는 순간 그 값을 MISO로 돌려준다. 만약 화면 CS를 바이트마다 선택·해제한다면 hal_cs_select가 호출될 때마다 s_disp_idx가 0으로 되돌아가므로, 8개의 값을 한 줄로 이어 쓰지 못하고 매번 같은 첫 칸만 덮어쓰게 된다.

4. 이전 장치의 CS를 먼저 해제하고, 다음 장치에 맞는 모드를 hal_spi_set_mode로 설정한 뒤에야 새 CS를 선택해야 한다. 두 CS가 동시에 낮은 상태로 겹치는 구간이 없어야 하고, 모드 전환은 반드시 CS 선택 이전에 끝내 두어야 한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.