임베디드 · 기본
MCU·GPIO·통신 기본
I2C - 센서와 대화하기
주소·읽기/쓰기 시퀀스, ACK, 온습도 센서 드라이버 구조
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서는 UART로 화분 상태를 로그로 내보내고 명령을 받는 구조를 만들었다. 화분에 온습도 센서를 새로 달면 문제가 달라진다. 센서는 특정한 주소를 가진 장치이고, 값을 얻으려면 "이 레지스터를 읽겠다"는 요청을 먼저 보내고 응답을 받는 절차를 지켜야 한다. 이 장에서는 이런 절차를 표준화한 I2C(아이스퀘어씨) 통신을 다룬다. 실제 보드 없이 PC에서 버스 동작을 흉내 내는 hal_sim 계층을 두고, 그 위에 온습도 센서 드라이버를 올려 컴파일·실행까지 확인한다.
- I2C 버스가 두 신호선으로 여러 장치를 구분하는 원리를 설명할 수 있다
- START, 주소+R/W, ACK/NACK, STOP으로 이어지는 쓰기·읽기 시퀀스를 코드로 읽을 수 있다
- 레지스터 포인터를 쓰고 반복 시작(Repeated Start)으로 이어 읽는 패턴을 구현할 수 있다
- 버스 제어(HAL)와 센서 드라이버를 분리하는 구조를 설계할 수 있다
- PC 시뮬레이션과 실제 보드에서 I2C 처리가 어떻게 달라지는지 설명할 수 있다
문제 상황
스마트 화분에 온습도 센서 하나를 더 붙이기로 했다고 하자. UART로 연결하려면 센서마다 TX/RX 핀 한 쌍이 필요한데, 다음 장에서 조도 센서까지 얹을 계획이라 핀이 금방 모자란다. 게다가 온습도 센서 칩 자체는 UART가 아니라 두 개의 선(SDA, SCL)만으로 여러 장치와 통신하는 방식을 쓴다. 이 방식에서는 "누구에게 말을 거는지"를 매번 주소로 밝혀야 하고, 상대가 그 주소를 듣고 있는지 여부(ACK/NACK)를 매 바이트마다 확인해야 한다. 배선은 UART보다 단순해지지만, 소프트웨어가 챙겨야 할 절차는 늘어난다. 이 절차를 정확히 구현하지 않으면 센서가 있는데도 "응답 없음"으로 오판하거나, 엉뚱한 레지스터 값을 온도로 읽어버리는 문제가 생긴다.
I2C 버스의 동작 방식
I2C는 SCL(클럭)과 SDA(데이터) 두 선을 여러 장치가 공유하는 버스다. 두 선 모두 풀업 저항으로 전원(VCC)에 연결되어 있고, 장치는 선을 낮은 전압으로 끌어내리는 방식(오픈 드레인)으로만 신호를 만든다. 이 덕분에 여러 장치가 같은 선에 매달려도 서로 충돌하지 않는다. 버스에 물린 각 장치는 7비트 주소를 하나씩 가지며, 마스터(보통 MCU)가 트랜잭션을 시작할 때마다 주소를 실어 보내 "이 주소를 가진 장치만 응답하라"고 지정한다.
UART와 비교하면 차이가 분명해진다. UART는 장치마다 전용 선을 쓰는 대신 응답 확인이 없고, I2C는 선을 공유하는 대신 주소와 응답 확인(ACK)이 필수다.
| 항목 | UART | I2C |
|---|---|---|
| 신호선 | TX/RX 각 1개씩, 장치마다 별도 배선 | SDA/SCL 2개를 여러 장치가 공유 |
| 장치 구분 | 배선(핀) 자체로 구분 | 7비트 주소로 구분 |
| 전송 확인 | 수신 측 확인 없음(옵션으로 체크섬) | 매 바이트마다 ACK/NACK |
| 장치 추가 | 핀과 UART 채널이 추가로 필요 | 같은 두 선에 주소만 다르게 연결 |
읽기/쓰기 시퀀스와 ACK
I2C 트랜잭션은 START 조건으로 시작해 STOP 조건으로 끝난다. 그 사이에 마스터는 7비트 주소와 읽기/쓰기 방향 비트를 함께 보내고, 지정된 장치가 그 주소를 인식하면 ACK(낮은 신호 한 비트)로 응답한다. 아무 장치도 응답하지 않으면 NACK 상태로 남는다. 이는 배선이 끊어졌거나 주소를 잘못 지정했을 때 소프트웨어가 곧바로 알아챌 수 있는 신호다.
센서에서 값을 읽을 때는 보통 두 단계를 거친다. 먼저 "몇 번 레지스터부터 읽을지" 1바이트를 쓰기 모드로 보내고, 이어서 START 조건을 새로 내지 않고 방향만 바꾸는 반복 시작(Repeated Start)으로 같은 장치에게 읽기를 요청한다. 데이터를 여러 바이트 받을 때 마스터는 마지막 바이트를 제외하고 매번 ACK를 보내 "계속 보내라"고 알리고, 마지막 바이트에는 일부러 NACK을 보내 "이제 그만 받겠다"고 알린 뒤 STOP으로 트랜잭션을 마친다.
온습도 센서 드라이버 구조
실제 보드가 없어도 버스 동작을 이해하는 데는 지장이 없다. 이 장에서는 hal_sim.h/.c가 START/ADDR/ACK/DATA/STOP 절차를 함수 호출과 표준 출력으로 흉내 낸다. 드라이버(soil_sensor.h/.c)는 이 HAL 함수만 알고 있고, 버스 절차 대신 "온도 몇 도, 습도 몇 퍼센트"라는 의미 있는 값을 반환한다. 이렇게 계층을 나누면 나중에 실제 보드로 옮길 때 HAL만 바꾸고 드라이버 코드는 그대로 쓸 수 있다.
이번 가상 센서는 6개의 레지스터를 가진다고 가정한다. 온도·습도 원시값은 2바이트씩, 상태 레지스터는 준비 여부를, 소프트 리셋 레지스터는 특정 값을 쓰면 장치를 초기 상태로 되돌리는 역할을 한다.
| 주소 | 이름 | 설명 | 접근 |
|---|---|---|---|
| 0x00 | TEMP_MSB | 온도 원시값 상위 바이트 | 읽기 |
| 0x01 | TEMP_LSB | 온도 원시값 하위 바이트 | 읽기 |
| 0x02 | HUM_MSB | 습도 원시값 상위 바이트 | 읽기 |
| 0x03 | HUM_LSB | 습도 원시값 하위 바이트 | 읽기 |
| 0x04 | STATUS | 준비 상태 플래그(bit0) | 읽기 |
| 0x05 | SOFT_RESET | 0xA5를 쓰면 초기 상태로 복귀 | 쓰기 |
완성 코드
아래 세 소스 파일(헤더 둘 포함)을 같은 폴더에 두고 컴파일한다. hal_sim은 버스 절차와 트랜잭션 로그를 담당하고, soil_sensor는 그 위에서 온도·습도 단위로 변환하는 드라이버를 담당한다.
hal_sim.h
#ifndef HAL_SIM_H
#define HAL_SIM_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
void hal_i2c_init(void);
bool hal_i2c_write(uint8_t addr, const uint8_t *data, size_t len);
bool hal_i2c_read(uint8_t addr, uint8_t *data, size_t len);
#endif
hal_sim.c
#include "hal_sim.h"
#include <stdio.h>
#define SIM_DEV_ADDR 0x44
#define REG_TEMP_MSB 0x00
#define REG_STATUS 0x04
#define REG_SOFT_RESET 0x05
#define REG_COUNT 6
static uint8_t regs[REG_COUNT];
static uint8_t reg_ptr;
static unsigned call_count;
static void sim_load_defaults(void)
{
uint16_t raw_temp = (uint16_t)(33000u + call_count * 400u);
uint16_t raw_hum = (uint16_t)(36000u - call_count * 900u);
regs[REG_TEMP_MSB] = (uint8_t)(raw_temp >> 8);
regs[REG_TEMP_MSB + 1] = (uint8_t)(raw_temp & 0xFFu);
regs[REG_TEMP_MSB + 2] = (uint8_t)(raw_hum >> 8);
regs[REG_TEMP_MSB + 3] = (uint8_t)(raw_hum & 0xFFu);
regs[REG_STATUS] = 0x01;
}
void hal_i2c_init(void)
{
reg_ptr = 0;
call_count = 0;
sim_load_defaults();
}
bool hal_i2c_write(uint8_t addr, const uint8_t *data, size_t len)
{
printf("[I2C] START\n");
printf("[I2C] ADDR 0x%02X W -> %s\n", (unsigned)addr,
addr == SIM_DEV_ADDR ? "ACK" : "NACK");
if (addr != SIM_DEV_ADDR || len == 0) {
printf("[I2C] STOP\n");
return false;
}
reg_ptr = data[0];
printf("[I2C] DATA 0x%02X -> ACK\n", (unsigned)data[0]);
for (size_t i = 1; i < len; i++) {
uint8_t target = (uint8_t)(reg_ptr + i - 1);
if (target == REG_SOFT_RESET && data[i] == 0xA5) {
call_count = 0;
sim_load_defaults();
} else if (target < REG_COUNT) {
regs[target] = data[i];
}
printf("[I2C] DATA 0x%02X -> ACK\n", (unsigned)data[i]);
}
printf("[I2C] STOP\n");
return true;
}
bool hal_i2c_read(uint8_t addr, uint8_t *data, size_t len)
{
printf("[I2C] REPEATED START\n");
printf("[I2C] ADDR 0x%02X R -> %s\n", (unsigned)addr,
addr == SIM_DEV_ADDR ? "ACK" : "NACK");
if (addr != SIM_DEV_ADDR || len == 0) {
printf("[I2C] STOP\n");
return false;
}
for (size_t i = 0; i < len; i++) {
uint8_t target = (uint8_t)(reg_ptr + i);
uint8_t value = (target < REG_COUNT) ? regs[target] : 0xFF;
data[i] = value;
printf("[I2C] DATA <- 0x%02X %s\n", (unsigned)value,
(i + 1 < len) ? "ACK" : "NACK");
}
printf("[I2C] STOP\n");
if (reg_ptr == REG_TEMP_MSB) {
call_count++;
sim_load_defaults();
}
return true;
}
soil_sensor.h
#ifndef SOIL_SENSOR_H
#define SOIL_SENSOR_H
#include <stdbool.h>
#include <stdint.h>
typedef struct {
uint8_t addr;
bool ready;
} soil_sensor_t;
bool soil_sensor_init(soil_sensor_t *dev, uint8_t addr);
bool soil_sensor_read(soil_sensor_t *dev, float *temp_c, float *humidity_pct);
bool soil_sensor_reset(soil_sensor_t *dev);
#endif
soil_sensor.c
#include "soil_sensor.h"
#include "hal_sim.h"
#define REG_TEMP_MSB 0x00
#define REG_STATUS 0x04
#define REG_SOFT_RESET 0x05
bool soil_sensor_init(soil_sensor_t *dev, uint8_t addr)
{
dev->addr = addr;
dev->ready = false;
uint8_t reg = REG_STATUS;
if (!hal_i2c_write(dev->addr, ®, 1)) {
return false;
}
uint8_t status = 0;
if (!hal_i2c_read(dev->addr, &status, 1)) {
return false;
}
dev->ready = (status & 0x01) != 0;
return dev->ready;
}
bool soil_sensor_read(soil_sensor_t *dev, float *temp_c, float *humidity_pct)
{
if (!dev->ready) {
return false;
}
uint8_t reg = REG_TEMP_MSB;
if (!hal_i2c_write(dev->addr, ®, 1)) {
return false;
}
uint8_t raw[4];
if (!hal_i2c_read(dev->addr, raw, sizeof(raw))) {
return false;
}
uint16_t raw_temp = (uint16_t)((raw[0] << 8) | raw[1]);
uint16_t raw_hum = (uint16_t)((raw[2] << 8) | raw[3]);
*temp_c = -40.0f + ((float)raw_temp / 65535.0f) * 165.0f;
*humidity_pct = ((float)raw_hum / 65535.0f) * 100.0f;
return true;
}
bool soil_sensor_reset(soil_sensor_t *dev)
{
uint8_t cmd[2] = { REG_SOFT_RESET, 0xA5 };
return hal_i2c_write(dev->addr, cmd, sizeof(cmd));
}
main.c
#include <stdio.h>
#include "hal_sim.h"
#include "soil_sensor.h"
static void print_step(const char *title)
{
printf("--- %s ---\n", title);
}
int main(void)
{
hal_i2c_init();
soil_sensor_t dev;
print_step("초기화");
if (!soil_sensor_init(&dev, 0x44)) {
printf("센서 초기화 실패\n");
return 1;
}
float temp_c = 0.0f, humidity_pct = 0.0f;
print_step("첫 번째 읽기");
if (soil_sensor_read(&dev, &temp_c, &humidity_pct)) {
printf("온도 %.1f C, 습도 %.1f %%\n", temp_c, humidity_pct);
}
print_step("두 번째 읽기");
if (soil_sensor_read(&dev, &temp_c, &humidity_pct)) {
printf("온도 %.1f C, 습도 %.1f %%\n", temp_c, humidity_pct);
}
print_step("소프트 리셋");
soil_sensor_reset(&dev);
print_step("리셋 후 읽기");
if (soil_sensor_read(&dev, &temp_c, &humidity_pct)) {
printf("온도 %.1f C, 습도 %.1f %%\n", temp_c, humidity_pct);
}
print_step("잘못된 주소로 접근");
soil_sensor_t ghost;
if (!soil_sensor_init(&ghost, 0x45)) {
printf("주소 0x45 응답 없음 (NACK)\n");
}
return 0;
}
줄별 해설
hal_sim.c의 regs와 reg_ptr은 가상 장치의 내부 상태다. hal_i2c_write는 첫 바이트를 항상 "레지스터 포인터 지정"으로 다루고, 두 번째 바이트부터는 그 포인터 위치에 순서대로 써 넣는다. 주소가 SIM_DEV_ADDR와 다르면 ADDR 단계에서 바로 NACK을 출력하고 false를 반환해 버스 위에 그 주소를 가진 장치가 없다는 상황을 흉내 낸다.
hal_i2c_read는 매 바이트마다 ACK를 출력하다가 마지막 바이트에서만 NACK을 출력한다. 이는 실제 I2C에서 마스터가 마지막에 일부러 NACK을 보내 읽기를 끝낸다는 규칙을 그대로 반영한 것이다. 읽기가 온도 레지스터(REG_TEMP_MSB)에서 시작했을 때만 call_count를 늘려 다음 읽기 값을 바꾸는데, 이는 센서 값이 시간에 따라 조금씩 달라지는 상황을 재현하기 위한 장치일 뿐 실제 프로토콜과는 무관하다.
soil_sensor.c는 HAL 함수만 호출하고 버스 세부사항은 전혀 알지 못한다. soil_sensor_init은 STATUS 레지스터를 먼저 읽어 장치가 준비되었는지 확인한 뒤에야 ready를 켠다. soil_sensor_read는 매번 레지스터 포인터를 REG_TEMP_MSB로 다시 지정한 뒤 4바이트를 읽어 16비트 정수 두 개로 합치고, 이를 섭씨 온도와 습도 퍼센트로 바꾼다. soil_sensor_reset은 SOFT_RESET 레지스터에 0xA5를 써서 장치를 초기 상태로 되돌린다.
main.c는 정상 주소(0x44)로 초기화·읽기·리셋을 순서대로 실행한 뒤, 존재하지 않는 주소(0x45)로도 초기화를 시도해 NACK이 어떻게 드러나는지 보여준다.
실행 결과
cc -std=c11 -Wall -Wextra hal_sim.c soil_sensor.c main.c -o smart_pot_i2c
./smart_pot_i2c
--- 초기화 ---
[I2C] START
[I2C] ADDR 0x44 W -> ACK
[I2C] DATA 0x04 -> ACK
[I2C] STOP
[I2C] REPEATED START
[I2C] ADDR 0x44 R -> ACK
[I2C] DATA <- 0x01 NACK
[I2C] STOP
--- 첫 번째 읽기 ---
[I2C] START
[I2C] ADDR 0x44 W -> ACK
[I2C] DATA 0x00 -> ACK
[I2C] STOP
[I2C] REPEATED START
[I2C] ADDR 0x44 R -> ACK
[I2C] DATA <- 0x80 ACK
[I2C] DATA <- 0xE8 ACK
[I2C] DATA <- 0x8C ACK
[I2C] DATA <- 0xA0 NACK
[I2C] STOP
온도 43.1 C, 습도 54.9 %
--- 두 번째 읽기 ---
[I2C] START
[I2C] ADDR 0x44 W -> ACK
[I2C] DATA 0x00 -> ACK
[I2C] STOP
[I2C] REPEATED START
[I2C] ADDR 0x44 R -> ACK
[I2C] DATA <- 0x82 ACK
[I2C] DATA <- 0x78 ACK
[I2C] DATA <- 0x89 ACK
[I2C] DATA <- 0x1C NACK
[I2C] STOP
온도 44.1 C, 습도 53.6 %
--- 소프트 리셋 ---
[I2C] START
[I2C] ADDR 0x44 W -> ACK
[I2C] DATA 0x05 -> ACK
[I2C] DATA 0xA5 -> ACK
[I2C] STOP
--- 리셋 후 읽기 ---
[I2C] START
[I2C] ADDR 0x44 W -> ACK
[I2C] DATA 0x00 -> ACK
[I2C] STOP
[I2C] REPEATED START
[I2C] ADDR 0x44 R -> ACK
[I2C] DATA <- 0x80 ACK
[I2C] DATA <- 0xE8 ACK
[I2C] DATA <- 0x8C ACK
[I2C] DATA <- 0xA0 NACK
[I2C] STOP
온도 43.1 C, 습도 54.9 %
--- 잘못된 주소로 접근 ---
[I2C] START
[I2C] ADDR 0x45 W -> NACK
[I2C] STOP
주소 0x45 응답 없음 (NACK)
두 번째 읽기의 습도가 첫 번째보다 낮게 나오는 것은 sim_load_defaults가 call_count에 비례해 값을 바꾸기 때문이고, 소프트 리셋 뒤에는 call_count가 0으로 돌아가 첫 번째 읽기와 같은 값이 다시 나온다.
실무에서 자주 틀리는 것
printf 형식 지정자에 uint8_t를 그대로 넘기기
가변 인자로 넘기면 uint8_t는 정수 승격되어 int가 되는데, %X는 unsigned int를 기대한다. 컴파일러에 따라 -Wall -Wextra에서 형식 불일치 경고가 뜰 수 있다.
/* 잘못된 코드 */
printf("[I2C] DATA 0x%02X -> ACK\n", data[i]);
/* 고친 코드 */
printf("[I2C] DATA 0x%02X -> ACK\n", (unsigned)data[i]);
ACK/NACK 반환값을 확인하지 않고 진행하기
버스 함수가 bool을 돌려주는 이유는 실패를 알리기 위해서다. 반환값을 무시하면 응답이 없는 상태에서도 초기화된 지역 변수 값을 마치 센서 값인 것처럼 출력하게 된다.
/* 잘못된 코드 */
soil_sensor_read(&dev, &temp_c, &humidity_pct);
printf("온도 %.1f C\n", temp_c);
/* 고친 코드 */
if (soil_sensor_read(&dev, &temp_c, &humidity_pct)) {
printf("온도 %.1f C\n", temp_c);
} else {
printf("센서 응답 없음\n");
}
7비트 주소와 8비트 표기를 혼동하기
일부 자료는 쓰기 주소(7비트 주소를 왼쪽으로 한 칸 옮기고 R/W 비트를 붙인 값)를 그대로 적어 둔다. 이 값을 드라이버 함수에 그대로 넘기면 실제 장치 주소와 달라져 항상 NACK이 난다.
/* 잘못된 코드 - 8비트 쓰기 주소를 그대로 사용 */
soil_sensor_init(&dev, 0x88);
/* 고친 코드 - 7비트 주소 사용 */
soil_sensor_init(&dev, 0x44);
레지스터 포인터를 매번 다시 지정하지 않기
한 번 지정해 둔 포인터가 다음 읽기에도 그대로 남아 있을 것이라고 가정하면, 장치에 따라 STOP 이후 포인터가 초기화되거나 이전 트랜잭션의 위치가 남아 엉뚱한 레지스터를 읽는다.
/* 잘못된 코드 - 쓰기 단계를 생략 */
bool bad_read(soil_sensor_t *dev, uint8_t *raw)
{
return hal_i2c_read(dev->addr, raw, 4);
}
/* 고친 코드 - 읽을 때마다 포인터를 다시 지정 */
bool good_read(soil_sensor_t *dev, uint8_t *raw)
{
uint8_t reg = 0x00;
if (!hal_i2c_write(dev->addr, ®, 1)) return false;
return hal_i2c_read(dev->addr, raw, 4);
}
한눈에 보기
| 항목 | hal_sim(PC) | STM32 | ESP32/아두이노 |
|---|---|---|---|
| 버스 구현 | 배열과 함수 호출로 흉내 | I2C 주변장치 레지스터 직접 제어 | Wire 라이브러리 또는 driver/i2c.h 호출 |
| ACK/NACK 확인 | 함수 반환값(bool) | 상태 레지스터 플래그 또는 HAL 반환 코드 | Wire.endTransmission() 반환값 |
| 타이밍 | 즉시 반환, 지연 없음 | 클럭 스트레칭·타임아웃 직접 설정 | 내부적으로 타임아웃 처리됨 |
| 풀업 저항 | 불필요(가상 신호) | 보드에 없으면 외부 저항 필요 | 대부분 보드에 내장 |
| 개념 | 핵심 내용 |
|---|---|
| 버스 구조 | SDA/SCL 두 선을 여러 장치가 공유, 풀업 저항 필요 |
| 주소 | 7비트 주소로 장치를 구분, 잘못된 주소는 NACK |
| 읽기 시퀀스 | 레지스터 포인터 쓰기 → 반복 시작 → 데이터 읽기 |
| ACK/NACK | 마스터가 마지막 바이트에 NACK을 보내 수신 종료를 알림 |
| 드라이버 구조 | HAL(버스 제어)과 드라이버(단위 변환·상태 확인)를 분리 |
연습 문제
- 같은 버스에 조도 센서(주소 0x76)를 추가하려고 한다. 지금의
hal_sim.c는 무엇을 바꿔야 두 번째 주소도 흉내 낼 수 있는가? - 실행 결과에서 두 번째 읽기의 습도가 첫 번째보다 낮게 나온 이유를 코드에서 찾아 설명하라.
soil_sensor_init이 값을 읽기 전에 STATUS 레지스터부터 확인하는 이유는 무엇인가? 이 확인을 생략하면 어떤 문제가 생길 수 있는가?hal_i2c_read가 마지막 바이트에만 NACK을 보내는 이유를 I2C 프로토콜 관점에서 설명하라.
정답과 해설
1. 지금 코드는 regs 배열 하나와 SIM_DEV_ADDR 상수 하나로 장치 하나만 표현한다. 두 번째 주소를 지원하려면 장치별로 레지스터 배열과 포인터를 따로 갖는 구조체 배열을 두고, hal_i2c_write/hal_i2c_read에서 넘어온 addr로 그 배열 중 하나를 찾아 처리하도록 바꿔야 한다.
2. hal_i2c_read는 레지스터 포인터가 REG_TEMP_MSB일 때 읽기를 마친 뒤 call_count를 늘리고 sim_load_defaults를 다시 호출한다. 이 함수는 습도 원시값을 36000 - call_count * 900으로 계산하므로 호출이 거듭될수록 값이 줄어든다.
3. STATUS 레지스터는 장치가 유효한 데이터를 갖추었는지 나타낸다. 이를 확인하지 않고 바로 읽으면, 전원이 막 켜져 아직 측정이 끝나지 않은 상태에서도 드라이버가 값을 정상으로 취급해 신뢰할 수 없는 온도·습도를 그대로 내보낼 수 있다.
4. I2C에서 슬레이브는 마스터가 ACK를 보내는 한 계속 다음 바이트를 내보낸다. 마스터가 그만 받고 싶을 때 NACK을 보내는 것은 "여기서 전송을 멈추라"는 유일한 신호이며, 곧이어 STOP 조건으로 버스를 놓아준다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.