STM32 기반 I2C/SPI 마스터/슬레이브 통신 펌웨어 개발, 주변장치 제어를 위한 단계별 실습 가이드로 임베디드 IoT 실전 능력을 키워보세요.
안녕하세요! 임베디드 개발자라면 한 번쯤 주변장치 통신 때문에 머리 아파 보셨을 텐데요. 특히 센서나 외부 모듈과 데이터를 주고받을 때 I2C나 SPI 같은 직렬 통신 방식은 정말 필수적이죠.
이 통신들이 중요하단 건 알겠는데, 막상 펌웨어를 직접 개발하려니 어디서부터 시작해야 할지 막막했던 경험, 분명 있으실 거예요. 특히 마스터/슬레이브 모드를 오가며 다양한 상황에 대처하는 건 주니어 개발자에게는 꽤 큰 벽처럼 느껴질 수 있거든요.
그래서 오늘은 STM32 마이크로컨트롤러를 활용해서 I2C와 SPI 통신을 마스터와 슬레이브 양쪽 모드에서 개발하는 방법을 단계별로 자세히 알려드리려고 해요. 실제 주변장치 제어 실습 예시를 통해 이론뿐만 아니라 실전 감각까지 꽉 잡아 가실 수 있도록 준비했으니, 차근차근 따라와 보세요!
📑 목차
- 1. STM32에서 I2C/SPI, 왜 중요할까요? (주니어 개발자의 필수 역량)
- 가. I2C (Inter-Integrated Circuit) 통신, 언제 쓸까요?
- 나. SPI (Serial Peripheral Interface) 통신, 언제 쓸까요?
- 2. I2C vs SPI, 어떤 통신을 골라야 할까요? (상황별 선택 가이드)
- 3. STM32CubeMX와 HAL 라이브러리로 마스터 통신 설정하기 (실전 예제)
- 가. I2C 마스터 실습: 온습도 센서 (BME280) 데이터 읽기
- 나. SPI 마스터 실습: OLED 디스플레이 (SSD1306) 제어
- 4. STM32 슬레이브 통신, 어떻게 구현할까요? (외부 MCU와 연동)
- 가. I2C 슬레이브 실습: 외부 마스터 MCU와 데이터 교환
- 나. SPI 슬레이브 실습: 고속 데이터 수신 및 처리
- 5. 통신 디버깅, 이것만 알면 쉬워져요! (주니어 필독)
- 가. 로직 애널라이저 활용하기
- 나. HAL 라이브러리 에러 코드 활용하기
- 다. GPIO 상태 확인 및 Pull-up 저항 점검
- 6. 실전에서 마주칠 팁과 주의사항 (진정한 임베디드 고수로 가는 길)
- 가. 인터럽트 및 DMA 활용의 중요성
- 나. 클럭 속도와 안정성
- 다. 에러 핸들링과 복구 전략
- 마무리하며: 임베디드 IoT의 핵심, 통신 마스터하기!
Image by PublicDomainPictures on Pixabay
1. STM32에서 I2C/SPI, 왜 중요할까요? (주니어 개발자의 필수 역량)
임베디드 시스템에서 주변장치와의 통신은 핵심 중의 핵심이잖아요? 센서 데이터를 읽거나, 액추에이터를 제어하거나, 다른 MCU와 연동할 때 I2C와 SPI는 정말 자주 쓰이는 프로토콜이에요. 이 두 가지를 능숙하게 다룰 수 있다면, 어떤 프로젝트든 자신감을 가지고 접근할 수 있게 된답니다.
가. I2C (Inter-Integrated Circuit) 통신, 언제 쓸까요?
I2C는 필립스(현 NXP)에서 개발한 직렬 동기식 통신 프로토콜이에요. 단 두 가닥의 선(SCL: Serial Clock, SDA: Serial Data)으로 여러 장치와 통신할 수 있다는 점이 가장 큰 특징이죠. 주소 기반 통신이라 여러 슬레이브 장치를 하나의 마스터가 제어할 수 있어서, 다음과 같은 경우에 많이 활용됩니다.
- 온습도 센서 (DHT11, BME280 등)
- 가속도/자이로 센서 (MPU6050 등)
- EEPROM이나 RTC (Real-Time Clock) 모듈
- 작은 OLED/LCD 디스플레이 (SSD1306 등)
보통 100kbps, 400kbps, 1Mbps 정도의 속도로 통신하며, 선이 적고 회로 구성이 간단하다는 장점이 있어요. 여러 장치를 연결할 때 각 장치에 고유한 주소를 할당해서 구분하죠.
나. SPI (Serial Peripheral Interface) 통신, 언제 쓸까요?
SPI는 모토로라에서 개발한 직렬 동기식 통신 프로토콜로, I2C보다 더 빠르고 유연한 통신을 제공해요. 최소 4가닥의 선(SCK: Serial Clock, MOSI: Master Out Slave In, MISO: Master In Slave Out, CS/SS: Chip Select/Slave Select)을 사용하는데요, 각 슬레이브 장치마다 CS(Chip Select) 핀을 따로 사용해서 여러 장치를 연결할 수 있어요. 주로 고속 데이터 전송이나 실시간 응답이 중요한 상황에 적합합니다.
- SD 카드 모듈
- 고해상도 LCD/OLED 디스플레이
- 이더넷 컨트롤러
- 고속 ADC/DAC 컨버터
- 무선 통신 모듈 (NRF24L01 등)
속도는 수 Mbps에서 수십 Mbps까지 매우 빠르고요, 전이중(Full-duplex) 통신이 가능하다는 장점이 있어요. 즉, 데이터를 주고받는 동시에 양방향으로 통신할 수 있다는 거죠. 하지만 각 슬레이브마다 CS 핀이 필요해서 연결하는 장치가 많아질수록 필요한 핀 개수가 늘어난다는 단점도 있답니다.
2. I2C vs SPI, 어떤 통신을 골라야 할까요? (상황별 선택 가이드)
두 프로토콜 모두 직렬 통신이지만, 각각의 장단점이 명확하기 때문에 프로젝트 요구사항에 맞춰 적절히 선택하는 것이 중요해요. 주니어 개발자라면 이 둘의 차이점을 명확히 이해하고 있어야 하겠죠?
아래 표를 보시면 어떤 상황에 어떤 통신이 더 유리할지 한눈에 파악하실 수 있을 거예요.
| 특징 | I2C (Inter-Integrated Circuit) | SPI (Serial Peripheral Interface) |
|---|---|---|
| 핀 개수 | 2개 (SCL, SDA) | 4개 이상 (SCK, MOSI, MISO, CS/SS - 슬레이브 개수만큼 CS 증가) |
| 통신 속도 | 최대 1Mbps (표준 모드 100kbps, 고속 모드 400kbps) | 수십 Mbps 이상 (매우 빠름, MCU 클럭에 따라 다양) |
| 통신 방식 | 반이중(Half-duplex), 주소 기반 | 전이중(Full-duplex), CS(Chip Select) 기반 |
| 다중 슬레이브 | 가능 (고유 주소 사용), 핀 개수 유지 | 가능 (각 슬레이브마다 CS 핀 필요), 핀 개수 증가 |
| 복잡성 | 비교적 간단 (하드웨어 주소 지정, ACK/NACK 처리) | 비교적 간단 (클럭 극성/위상 설정 필요) |
| 주요 용도 | 센서, EEPROM, RTC, 작은 디스플레이 | SD카드, 고속 디스플레이, 무선 모듈, 고속 ADC/DAC |
핵심은 이렇습니다:
- I2C: 핀을 아껴야 하고, 통신 속도가 아주 빠르지 않아도 되며, 여러 장치를 간결하게 연결하고 싶을 때 적합해요. 복잡하지 않은 센서 데이터 수집에 많이 쓰이죠.
- SPI: 속도가 중요하고, 많은 데이터를 빠르게 주고받아야 하며, 풀-듀플렉스 통신이 필요할 때 선택하세요. 예를 들어, 그래픽 데이터 전송이나 데이터 저장에 유용합니다.
3. STM32CubeMX와 HAL 라이브러리로 마스터 통신 설정하기 (실전 예제)
이제 직접 STM32CubeMX를 사용해서 펌웨어를 개발해볼 시간이에요. STM32CubeMX는 STM32 개발의 효율을 극대화해주는 강력한 툴이죠. 핀 설정부터 클럭, 주변장치 초기화 코드를 자동으로 생성해주기 때문에, 주니어 개발자도 쉽게 시작할 수 있답니다.
가. I2C 마스터 실습: 온습도 센서 (BME280) 데이터 읽기
BME280 센서는 I2C 통신으로 온도, 습도, 기압 데이터를 제공하는 아주 유용한 센서예요. 이걸 STM32 마스터 모드로 읽어오는 실습을 해볼게요.
- STM32CubeMX 설정:
- 사용할 STM32 칩을 선택합니다 (예: STM32F401RE).
- System Core > RCC에서 High Speed Clock (HSE)와 Low Speed Clock (LSE)를 설정합니다 (외부 크리스탈 사용 시).
- Connectivity > I2C1을 선택하고, Mode를 I2C로 설정합니다.
- Parameter Settings에서 Speed Mode를 Fast Mode (400kHz)로 설정하고, Addressing Mode는 7-bit로 둡니다. Pull-up 저항은 외부 회로에서 연결하거나, 내부 Pull-up을 활성화할 수 있어요.
- GPIO 핀이 I2C1_SCL, I2C1_SDA로 자동 할당되는지 확인합니다.
- 프로젝트를 생성하고 IDE (Keil, STM32CubeIDE 등)로 오픈합니다.
- 펌웨어 코드 작성 (주요 HAL 함수):
/* main.c */
#include "main.h"
#include <string.h> // for memset
// BME280 I2C 7-bit 주소 (예시: 0x76 또는 0x77)
// HAL 함수는 8-bit 주소를 사용하므로, 7-bit 주소에 0을 붙여 8-bit로 변환 (0x76 << 1 = 0xEC)
#define BME280_ADDRESS (0x76 << 1)
#define BME280_REG_ID 0xD0 // ID Register
#define BME280_REG_CTRL_MEAS 0xF4 // Control Measurement Register
#define BME280_REG_CONFIG 0xF5 // Configuration Register
#define BME280_REG_PRESS_MSB 0xF7 // Pressure MSB Register (데이터 시작 주소)
extern I2C_HandleTypeDef hi2c1; // CubeMX에서 생성된 I2C 핸들러
void BME280_Init(void) {
uint8_t tx_data[2];
uint8_t rx_data;
// 1. BME280 ID 읽기 (통신 확인)
tx_data[0] = BME280_REG_ID;
HAL_I2C_Master_Transmit(&hi2c1, BME280_ADDRESS, tx_data, 1, 100);
HAL_I2C_Master_Receive(&hi2c1, BME280_ADDRESS, &rx_data, 1, 100);
if (rx_data != 0x60) {
// ID가 다르면 에러 처리 (예: LED 깜빡임)
Error_Handler();
}
// 2. BME280 초기 설정 (OverSampling Rate, Mode 설정)
// Standby Time = 1000ms, Filter = OFF, SPI Disable
tx_data[0] = BME280_REG_CONFIG;
tx_data[1] = 0b10100000; // t_sb=1000ms, filter=off, spi3wire=off
HAL_I2C_Master_Transmit(&hi2c1, BME280_ADDRESS, tx_data, 2, 100);
// Humidity oversampling x1, Pressure oversampling x1, Temperature oversampling x1, Normal mode
tx_data[0] = BME280_REG_CTRL_MEAS;
tx_data[1] = 0b00100101; // osrs_t=x1, osrs_p=x1, mode=normal
HAL_I2C_Master_Transmit(&hi2c1, BME280_ADDRESS, tx_data, 2, 100);
}
void BME280_Read_Data(float *temperature, float *pressure, float *humidity) {
uint8_t rx_buffer[8];
uint8_t tx_reg_addr = BME280_REG_PRESS_MSB;
// BME280 데이터 레지스터 시작 주소 전송 후, 8바이트 데이터 수신
HAL_I2C_Master_Transmit(&hi2c1, BME280_ADDRESS, &tx_reg_addr, 1, 100);
HAL_I2C_Master_Receive(&hi2c1, BME280_ADDRESS, rx_buffer, 8, 100);
// TODO: 수신된 데이터 파싱 및 보정 로직 추가 (BME280 데이터시트 참조)
// 이 부분은 BME280 데이터시트의 보정 알고리즘을 따라야 합니다.
// 여기서는 간단히 온도만 예시로 보여드립니다.
// 온도 보정 값은 BME280 캘리브레이션 레지스터에서 읽어와야 합니다.
int32_t adc_T = (rx_buffer[3] << 12) | (rx_buffer[4] << 4) | (rx_buffer[5] >> 4);
// float temp_val = compensate_temperature(adc_T); // 실제 함수 호출
*temperature = (float)adc_T / 1000.0f; // 임시 값
// 압력, 습도도 유사하게 처리
*pressure = 0.0f; // 임시 값
*humidity = 0.0f; // 임시 값
}
int main(void) {
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_I2C1_Init(); // CubeMX에서 생성된 I2C 초기화 함수
BME280_Init(); // BME280 센서 초기화
float temperature, pressure, humidity;
while (1) {
BME280_Read_Data(&temperature, &pressure, &humidity);
// 온도, 압력, 습도 데이터를 활용 (예: UART로 전송, LCD에 표시)
HAL_Delay(1000); // 1초마다 읽기
}
}
위 코드에서 `HAL_I2C_Master_Transmit`은 마스터가 슬레이브로 데이터를 전송할 때, `HAL_I2C_Master_Receive`는 마스터가 슬레이브로부터 데이터를 수신할 때 사용해요. 타임아웃 값(마지막 인자)은 통신이 완료될 때까지 기다리는 최대 시간(ms)을 의미합니다.
나. SPI 마스터 실습: OLED 디스플레이 (SSD1306) 제어
SPI 통신은 디스플레이 제어에 특히 강점을 보이죠. SSD1306 기반의 128x64 OLED 디스플레이를 STM32 마스터 모드로 제어하는 방법을 알아볼게요.
- STM32CubeMX 설정:
- Connectivity > SPI1을 선택하고, Mode를 Full-Duplex Master로 설정합니다.
- Parameter Settings에서 Frame Format은 Motorola, Data Size는 8 Bits, First Bit는 MSB First로 설정합니다.
- Prescaler는 통신 속도를 결정하는데, OLED의 최대 클럭을 고려하여 적절히 설정합니다 (예: /8 또는 /16).
- GPIO 핀이 SPI1_SCK, SPI1_MISO, SPI1_MOSI로 자동 할당되는지 확인합니다. 추가로 OLED 제어에 필요한 DC(Data/Command), RST(Reset), CS(Chip Select) 핀은 GPIO 출력으로 설정합니다.
- 프로젝트를 생성하고 IDE로 오픈합니다.
- 펌웨어 코드 작성 (주요 HAL 함수):
/* main.c */
#include "main.h"
#include <string.h>
// SSD1306 OLED GPIO 핀 정의 (CubeMX 설정에 따라 변경)
#define OLED_CS_PORT GPIOB
#define OLED_CS_PIN GPIO_PIN_6
#define OLED_DC_PORT GPIOB
#define OLED_DC_PIN GPIO_PIN_7
#define OLED_RST_PORT GPIOB
#define OLED_RST_PIN GPIO_PIN_8
extern SPI_HandleTypeDef hspi1; // CubeMX에서 생성된 SPI 핸들러
// OLED 초기화 명령
const uint8_t init_cmds[] = {
0xAE, // Display OFF
0xD5, 0x80, // Set Display Clock Divide Ratio
0xA8, 0x3F, // Set Mux Ratio (1/64)
0xD3, 0x00, // Set Display Offset
0x40, // Set Display Start Line
0x8D, 0x14, // Charge Pump Setting
0x20, 0x00, // Set Memory Addressing Mode (Horizontal)
0xA1, // Set Segment Re-map
0xC8, // Set COM Output Scan Direction
0xDA, 0x12, // Set COM Pins Hardware Configuration
0x81, 0xCF, // Set Contrast Control
0xD9, 0xF1, // Set Pre-charge Period
0xDB, 0x40, // Set VCOM Deselect Level
0xA4, // Entire Display ON/OFF
0xA6, // Set Normal/Inverse Display
0x2E, // Deactivate Scroll
0xAF // Display ON
};
void OLED_WriteCommand(uint8_t command) {
HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_RESET); // CS Low
HAL_GPIO_WritePin(OLED_DC_PORT, OLED_DC_PIN, GPIO_PIN_RESET); // DC Low (Command)
HAL_SPI_Transmit(&hspi1, &command, 1, 100);
HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_SET); // CS High
}
void OLED_WriteData(uint8_t* data, uint16_t size) {
HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_RESET); // CS Low
HAL_GPIO_WritePin(OLED_DC_PORT, OLED_DC_PIN, GPIO_PIN_SET); // DC High (Data)
HAL_SPI_Transmit(&hspi1, data, size, 100);
HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_SET); // CS High
}
void OLED_Init(void) {
// 1. Reset OLED
HAL_GPIO_WritePin(OLED_RST_PORT, OLED_RST_PIN, GPIO_PIN_SET);
HAL_Delay(1);
HAL_GPIO_WritePin(OLED_RST_PORT, OLED_RST_PIN, GPIO_PIN_RESET);
HAL_Delay(10);
HAL_GPIO_WritePin(OLED_RST_PORT, OLED_RST_PIN, GPIO_PIN_SET);
HAL_Delay(10);
// 2. Send initialization commands
for (uint16_t i = 0; i < sizeof(init_cmds); i++) {
OLED_WriteCommand(init_cmds[i]);
}
}
void OLED_Fill(uint8_t pixel) {
// 화면 전체를 특정 픽셀 값으로 채우기 (0x00: 검정, 0xFF: 흰색)
uint8_t buffer[128];
memset(buffer, pixel, sizeof(buffer));
for (uint8_t page = 0; page < 8; page++) {
OLED_WriteCommand(0xB0 + page); // Set Page Address
OLED_WriteCommand(0x00); // Set Lower Column Address
OLED_WriteCommand(0x10); // Set Higher Column Address
OLED_WriteData(buffer, 128);
}
}
int main(void) {
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_SPI1_Init(); // CubeMX에서 생성된 SPI 초기화 함수
OLED_Init(); // OLED 초기화
OLED_Fill(0x00); // 화면을 검정색으로 채우기
while (1) {
// 여기에 OLED에 텍스트나 그래픽을 그리는 로직 추가
// 예를 들어, BME280에서 읽은 온도값을 표시
OLED_Fill(0xFF); // 흰색
HAL_Delay(500);
OLED_Fill(0x00); // 검정색
HAL_Delay(500);
}
}
SPI 통신에서는 각 트랜잭션마다 CS(Chip Select) 핀을 Low로 내렸다가 통신이 끝나면 다시 High로 올려주는 것이 중요해요. 또한, 데이터를 보낼지 명령을 보낼지 구분하기 위해 DC(Data/Command) 핀을 제어해야 한답니다. `HAL_SPI_Transmit` 함수는 데이터를 전송하는 데 사용되죠.
Image by analogicus on Pixabay
4. STM32 슬레이브 통신, 어떻게 구현할까요? (외부 MCU와 연동)
지금까지는 STM32가 마스터로서 주변장치를 제어하는 방법을 봤는데요, 때로는 STM32가 다른 마스터 MCU의 슬레이브가 되어 데이터를 수신하거나 전송해야 할 때도 있답니다. 예를 들어, 메인 프로세서가 서브 프로세서인 STM32에게 특정 작업을 지시하거나, STM32가 처리한 결과를 메인 프로세서에게 보고할 때 유용하죠.
가. I2C 슬레이브 실습: 외부 마스터 MCU와 데이터 교환
STM32를 I2C 슬레이브로 설정하여, 다른 MCU(예: Raspberry Pi, Arduino)가 STM32의 특정 레지스터를 읽거나 쓸 수 있도록 해볼게요.
- STM32CubeMX 설정:
- Connectivity > I2C1을 선택하고, Mode를 I2C로 설정합니다.
- Parameter Settings에서 Own Address 1을 설정합니다 (예: 0x42). 이 주소가 외부 마스터가 STM32를 호출할 때 사용하는 주소가 됩니다.
- Addressing Mode는 7-bit로 둡니다.
- GPIO 핀이 I2C1_SCL, I2C1_SDA로 자동 할당되는지 확인합니다.
- NVIC Settings에서 I2C1 event interrupt와 I2C1 error interrupt를 활성화하여 인터럽트 기반으로 통신을 처리할 수 있도록 합니다.
- 프로젝트를 생성하고 IDE로 오픈합니다.
- 펌웨어 코드 작성 (주요 HAL 함수):
/* main.c */
#include "main.h"
extern I2C_HandleTypeDef hi2c1;
#define STM32_SLAVE_ADDRESS (0x42 << 1) // 7-bit 주소 0x42를 8-bit로 변환
uint8_t slave_rx_buffer[10]; // 마스터로부터 수신할 버퍼
uint8_t slave_tx_buffer[10] = "Hello STM32!"; // 마스터에게 전송할 버퍼
// HAL_I2C_Slave_Receive_IT() 콜백 함수
void HAL_I2C_SlaveRxCpltCallback(I2C_HandleTypeDef *hi2c) {
if (hi2c->Instance == I2C1) {
// 마스터로부터 10바이트 수신 완료
// 수신된 데이터를 활용 (예: LED 제어, 다른 작업 수행)
// 다음 수신을 위해 다시 활성화
HAL_I2C_Slave_Receive_IT(&hi2c1, slave_rx_buffer, 10);
}
}
// HAL_I2C_Slave_Transmit_IT() 콜백 함수
void HAL_I2C_SlaveTxCpltCallback(I2C_HandleTypeDef *hi2c) {
if (hi2c->Instance == I2C1) {
// 마스터에게 10바이트 전송 완료
// 다음 전송을 위해 다시 활성화 (필요시)
// HAL_I2C_Slave_Transmit_IT(&hi2c1, slave_tx_buffer, 10);
}
}
int main(void) {
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_I2C1_Init();
// 슬레이브 모드에서 인터럽트 기반으로 수신 대기 시작
HAL_I2C_Slave_Receive_IT(&hi2c1, slave_rx_buffer, 10);
while (1) {
// 마스터가 특정 명령을 보내면 슬레이브_rx_buffer를 확인하여 처리
// 예를 들어, 마스터가 'L'을 보내면 LED 켜기
if (slave_rx_buffer[0] == 'L') {
HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_SET); // User LED 켜기
slave_rx_buffer[0] = 0; // 초기화하여 중복 실행 방지
}
// 마스터가 데이터를 요청할 경우, slave_tx_buffer의 내용이 전송됨.
// 이 부분은 마스터의 요청에 의해 자동으로 콜백이 호출되므로,
// 메인 루프에서 특별히 할 일은 없습니다.
HAL_Delay(100);
}
}
I2C 슬레이브 통신은 주로 인터럽트 기반으로 구현하는 것이 일반적이에요. `HAL_I2C_Slave_Receive_IT` 함수로 수신 대기를 시작하고, 데이터 수신이 완료되면 `HAL_I2C_SlaveRxCpltCallback` 콜백 함수가 호출됩니다. 전송도 마찬가지로 `HAL_I2C_Slave_Transmit_IT`로 시작하고 `HAL_I2C_SlaveTxCpltCallback`으로 완료를 알리죠. 이렇게 하면 메인 루프가 통신 때문에 블로킹되지 않고 다른 작업을 수행할 수 있어요.
나. SPI 슬레이브 실습: 고속 데이터 수신 및 처리
SPI 슬레이브는 주로 다른 고속 마스터 MCU로부터 대량의 데이터를 실시간으로 수신하거나, DMA(Direct Memory Access)와 연동하여 효율적으로 데이터를 처리할 때 사용됩니다.
- STM32CubeMX 설정:
- Connectivity > SPI1을 선택하고, Mode를 Half-Duplex Slave 또는 Full-Duplex Slave로 설정합니다.
- Parameter Settings에서 마스터와 동일하게 Frame Format, Data Size, First Bit를 설정해야 합니다. 특히 Clock Polarity (CPOL)와 Clock Phase (CPHA)는 마스터와 정확히 일치시켜야 통신 오류가 발생하지 않아요.
- GPIO 핀이 SPI1_SCK, SPI1_MISO, SPI1_MOSI, SPI1_NSS(CS)로 자동 할당되는지 확인합니다.
- NVIC Settings에서 SPI1 global interrupt를 활성화합니다. DMA를 사용할 경우, DMA1 stream X global interrupt도 활성화해야 합니다.
- 프로젝트를 생성하고 IDE로 오픈합니다.
- 펌웨어 코드 작성 (주요 HAL 함수):
/* main.c */
#include "main.h"
extern SPI_HandleTypeDef hspi1;
uint8_t spi_slave_rx_buffer[256]; // 마스터로부터 수신할 버퍼
uint8_t spi_slave_tx_buffer[256] = {0,}; // 마스터에게 전송할 버퍼 (초기화)
// SPI 수신 완료 콜백 함수
void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) {
if (hspi->Instance == SPI1) {
// 마스터로부터 데이터 수신 완료
// 수신된 데이터 처리 로직 (예: 특정 명령 파싱, 상태 업데이트)
// 다음 수신을 위해 다시 활성화
HAL_SPI_Receive_IT(&hspi1, spi_slave_rx_buffer, 256);
}
}
// SPI 전송 완료 콜백 함수
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) {
if (hspi->Instance == SPI1) {
// 마스터에게 데이터 전송 완료
// 다음 전송을 위해 다시 활성화 (필요시)
// HAL_SPI_Transmit_IT(&hspi1, spi_slave_tx_buffer, 256);
}
}
int main(void) {
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_SPI1_Init();
// 전송할 데이터 준비 (예: 현재 STM32의 카운터 값)
for(int i=0; i<256; i++) {
spi_slave_tx_buffer[i] = i;
}
// 슬레이브 모드에서 인터럽트 기반으로 수신 대기 시작
// 마스터가 전송과 동시에 수신할 수도 있으므로, Transmit/Receive 동시에 시작 가능
HAL_SPI_Receive_IT(&hspi1, spi_slave_rx_buffer, 256); // 256바이트 수신 대기
HAL_SPI_Transmit_IT(&hspi1, spi_slave_tx_buffer, 256); // 256바이트 전송 대기
while (1) {
// 수신된 데이터를 주기적으로 확인하거나, 다른 작업 수행
// 예를 들어, spi_slave_rx_buffer[0]에 특정 명령이 들어오면 LED 켜기
if (spi_slave_rx_buffer[0] == 'A') {
HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_SET);
spi_slave_rx_buffer[0] = 0; // 초기화
}
HAL_Delay(50);
}
}
SPI 슬레이브 역시 인터럽트 기반 통신이 효율적이에요. 특히 `HAL_SPI_Receive_IT`와 `HAL_SPI_Transmit_IT`를 함께 사용하면 전이중(Full-duplex) 통신의 이점을 최대한 활용할 수 있죠. 마스터와 슬레이브의 클럭 극성(CPOL)과 클럭 위상(CPHA)을 맞추는 것이 굉장히 중요하니, 이 점 꼭 기억해두세요!
5. 통신 디버깅, 이것만 알면 쉬워져요! (주니어 필독)
펌웨어 개발은 코딩만큼이나 디버깅이 중요하잖아요? 특히 통신 문제는 눈에 보이지 않기 때문에 초보자들이 가장 어려워하는 부분 중 하나일 거예요. 하지만 몇 가지 요령만 알면 훨씬 수월하게 해결할 수 있답니다.
가. 로직 애널라이저 활용하기
로직 애널라이저(Logic Analyzer)는 통신 라인의 신호를 시각적으로 보여주는 장비예요. I2C나 SPI 통신이 제대로 이루어지고 있는지, 데이터는 맞게 오가는지 등을 파형으로 직접 확인할 수 있어서 디버깅에 정말 큰 도움이 됩니다.
- 통신 신호 확인: SCL/SCK, SDA/MOSI/MISO, CS/SS 핀의 신호가 데이터시트나 프로토콜에 맞게 나오는지 확인하세요.
- 데이터 검증: 실제 전송된 데이터 바이트를 디코딩해서, 예상했던 값과 일치하는지 비교할 수 있어요.
- 타이밍 문제 진단: 클럭 속도나 지연 시간 등 타이밍 관련 문제를 파악하는 데 유용합니다.
저렴한 USB 로직 애널라이저도 많으니, 하나쯤 장만해두시면 두고두고 유용하게 쓰실 거예요.
나. HAL 라이브러리 에러 코드 활용하기
STM32 HAL 라이브러리 함수들은 통신 실패 시 HAL_StatusTypeDef 타입의 에러 코드를 반환해요. 이 에러 코드를 활용하면 어떤 문제가 발생했는지 힌트를 얻을 수 있답니다.
HAL_StatusTypeDef status;
status = HAL_I2C_Master_Transmit(&hi2c1, BME280_ADDRESS, tx_data, 1, 100);
if (status != HAL_OK) {
// 통신 실패! HAL_ERROR, HAL_BUSY, HAL_TIMEOUT 등
// 에러 상태에 따라 디버깅 메시지 출력 또는 LED로 알림
if (status == HAL_TIMEOUT) {
// 타임아웃 발생! 슬레이브가 응답하지 않거나 SCL 라인 문제일 수 있음
} else if (status == HAL_ERROR) {
// I2C 버스 에러 (ACK, NACK 등)
}
Error_Handler(); // 에러 처리 함수 호출
}
특히 HAL_TIMEOUT은 슬레이브가 응답하지 않거나, 통신 라인에 문제가 있을 때 자주 발생해요. HAL_ERROR는 NACK 수신 같은 프로토콜 레벨의 에러를 의미할 때가 많죠.
다. GPIO 상태 확인 및 Pull-up 저항 점검
- GPIO 핀 상태: CubeMX에서 설정한 I2C/SPI 핀이 제대로 Alternate Function (AF)으로 설정되었는지, 그리고 해당 핀에 물리적으로 연결된 장치가 없는지 확인하세요.
- Pull-up 저항: I2C 통신에서는 SCL과 SDA 라인에 Pull-up 저항이 필수적이에요. 보통 4.7kΩ ~ 10kΩ 정도의 저항을 사용하는데, 이게 없거나 너무 높거나 낮으면 통신이 제대로 되지 않을 수 있습니다. STM32 내부에 Pull-up 저항을 활성화할 수도 있지만, 안정적인 통신을 위해 외부에 추가하는 것을 권장해요.
- SPI CS(NSS) 핀: SPI 슬레이브의 CS 핀은 항상 마스터에 의해 제어되어야 합니다. 이 핀이 제대로 토글되지 않으면 슬레이브가 통신을 시작할 준비가 되지 않았다고 판단할 수 있어요.
Image by Wildschuetz on Pixabay
6. 실전에서 마주칠 팁과 주의사항 (진정한 임베디드 고수로 가는 길)
이제 기본적인 통신 구현과 디버깅 방법을 익히셨으니, 실전에서 유용하게 쓰일 몇 가지 팁을 알려드릴게요. 이런 디테일들이 주니어와 시니어를 가르는 중요한 차이점이 될 수 있답니다.
가. 인터럽트 및 DMA 활용의 중요성
지금까지는 HAL 함수의 블로킹(Blocking) 모드와 인터럽트(Interrupt) 모드를 혼용해서 설명드렸죠? 하지만 실제 임베디드 시스템에서는 인터럽트나 DMA(Direct Memory Access)를 적극적으로 활용하는 것이 좋아요.
- 인터럽트: 통신이 완료되었을 때만 CPU가 개입하므로, CPU가 다른 작업을 수행할 수 있게 해줍니다. 실시간성이 중요한 시스템에서 필수적이죠.
- DMA: CPU 개입 없이 주변장치와 메모리 간에 데이터를 직접 전송하게 해줍니다. 특히 대량의 데이터를 고속으로 처리해야 하는 SPI 통신에서 DMA를 활용하면 CPU 부하를 크게 줄일 수 있어요. (예: `HAL_SPI_Receive_DMA`, `HAL_SPI_Transmit_DMA`)
처음에는 인터럽트 기반 통신부터 익숙해지는 것이 좋습니다. DMA는 조금 더 고급 기술이지만, 익혀두면 시스템 성능을 비약적으로 향상시킬 수 있답니다.
나. 클럭 속도와 안정성
I2C/SPI 통신은 클럭 속도에 매우 민감해요. 특히 통신 거리가 길어지거나, 노이즈가 많은 환경에서는 클럭 속도를 너무 높게 설정하면 통신 오류가 발생할 확률이 커집니다.
- I2C: 일반적으로 100kHz (Standard Mode)나 400kHz (Fast Mode)를 사용하는데, 안정성을 위해 처음에는 낮은 속도부터 시작해서 점차 올려보는 것을 권장해요.
- SPI: MCU 클럭의 분주비(Prescaler)를 통해 SPI 클럭을 설정합니다. 연결된 슬레이브 장치의 데이터시트를 반드시 확인해서 최대 클럭 속도를 넘지 않도록 주의해야 합니다.
다. 에러 핸들링과 복구 전략
임베디드 시스템은 한 번 배포되면 계속 작동해야 하잖아요? 통신 오류는 언제든 발생할 수 있으니, 이에 대한 에러 핸들링과 복구 전략을 세우는 것이 중요해요.
- 재시도 로직: 통신 실패 시 무조건 에러를 발생시키는 대신, 몇 번의 재시도를 해보는 로직을 추가할 수 있어요.
- 타임아웃 처리: `HAL_I2C_Master_Transmit`나 `HAL_SPI_Transmit` 함수의 마지막 인자(Timeout)를 적절히 설정하여 무한 대기 상태에 빠지는 것을 방지해야 합니다.
- 버스 리셋: I2C 버스가 멈춰버리는 상황(SDA 라인이 Low로 고정되는 등)이 발생하면, 강제로 GPIO를 토글하여 버스를 리셋하는 비상 로직을 구현할 수도 있습니다.
마무리하며: 임베디드 IoT의 핵심, 통신 마스터하기!
오늘은 STM32 기반 I2C/SPI 마스터/슬레이브 통신 펌웨어 개발에 대해 자세히 알아봤어요. I2C와 SPI 통신은 임베디드 IoT 시스템에서 센서, 액추에이터, 외부 모듈과 연동하는 데 필수적인 요소라는 점, 그리고 각각의 장단점과 활용 시나리오를 이해하는 것이 중요하단 걸 아셨을 거예요.
STM32CubeMX와 HAL 라이브러리를 활용하면 마스터/슬레이브 모드를 비교적 쉽게 구현할 수 있으며, 실제 BME280 센서와 SSD1306 OLED 예제를 통해 실전 감각을 키우셨으리라 믿습니다. 또한, 로직 애널라이저나 HAL 에러 코드를 활용한 디버깅 팁, 그리고 인터럽트/DMA, 클럭 안정성, 에러 핸들링 같은 실전 팁까지 다뤄봤는데요.
이 모든 지식들이 여러분이 임베디드 IoT 개발에서 한 단계 더 성장하는 데 큰 도움이 되었으면 좋겠습니다. 처음에는 어렵게 느껴질 수 있지만, 꾸준히 실습하고 디버깅하면서 통신에 대한 감을 익히다 보면 어느새 능숙하게 다루는 자신을 발견하게 될 거예요!
혹시 오늘 다룬 내용 중에서 궁금한 점이 있거나, 추가적으로 다뤄줬으면 하는 주제가 있다면 언제든지 댓글로 남겨주세요. 여러분의 성장을 응원합니다!
📌 함께 읽으면 좋은 글
- [임베디드 IoT] 예측 불가능한 임베디드 제어 지연, 리눅스 커널 심층 분석으로 해결하다
- [데이터 엔지니어링] dbt 테스트, 마법이 아니죠? 숨겨진 SQL 생성 원리 모르면 데이터 품질 보장 못 합니다.
- [임베디드 IoT] RTOS FOTA, 안정성과 보안 90% 높인 펌웨어 무선 업데이트 실전 구현 노하우
이 글이 도움이 되셨다면 공감(♥)과 댓글로 응원해 주세요!
궁금한 점이나 다루었으면 하는 주제가 있다면 댓글로 남겨주세요.
'임베디드 IoT' 카테고리의 다른 글
| 에너지 하베스팅 IoT, 기대와 다른 현실: 성공적인 디바이스 설계를 위한 치명적 오해 (0) | 2026.07.26 |
|---|---|
| 유선 센서 네트워크, 당신의 IoT 전환을 가로막는 함정이다 (0) | 2026.07.25 |
| 임베디드 시스템 안정성 100배 향상 비결: FreeRTOS/Zephyr Task 스케줄링 문맥 전환 마스터 전략 (0) | 2026.07.22 |
| RTOS FOTA, 안정성과 보안 90% 높인 펌웨어 무선 업데이트 실전 구현 노하우 (0) | 2026.07.19 |
| IoT 디바이스 섀도우, 오프라인 환경 데이터 일관성 유지를 위한 동기화 전략 설계 (0) | 2026.07.18 |