라즈베리파이에서 I2C 센서 데이터 읽기 — python-smbus2 실전

센서마다 GPIO 핀을 하나씩 배정하는 방식은 핀 개수 제한과 배선 복잡도 때문에 금방 한계에 부딪힌다. I2C는 SDA/SCL 두 핀만으로 여러 센서를 동시에 연결하는 버스 프로토콜이고, BME280 같은 저가형 온습도/기압 센서는 대부분 I2C를 표준 지원한다. 다만 레지스터 레벨 프로토콜이라 주소·레지스터·보정(calibration) 계수를 모르면 디버깅이 막막해진다. 이 글에서는 python-smbus2로 라즈베리파이 I2C 버스를 직접 다루며 BME280 레지스터를 읽고 보정 계수를 적용해 실제 값을 뽑아내는 과정을 정리한다.

I2C 버스 기본 개념

SDA(데이터)/SCL(클록) 두 선을 쓰고, 라즈베리파이 GPIO 헤더에는 풀업이 내장돼 있어 대부분 별도 저항이 필요 없다. 각 장치는 7비트 주소(0x03~0x77)를 가지며 마스터(라즈베리파이)가 주소를 보내면 일치하는 슬레이브만 응답하는 구조다. 대부분의 센서는 “이 주소의 이 레지스터를 읽어라/써라” 패턴으로 통신하고, smbus2read_byte_data(addr, register) 같은 API가 이를 그대로 반영한다. 40핀 헤더에 노출된 버스는 보통 1번(/dev/i2c-1)이며, smbus2는 이 디바이스 파일에 대한 ioctl 호출을 감싼 라이브러리다.

I2C 인터페이스 활성화와 장치 확인

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

# 헤드리스 환경이면 /boot/firmware/config.txt(구버전 /boot/config.txt)에 직접 추가
dtparam=i2c_arm=on
sudo apt update
sudo apt install -y i2c-tools python3-pip python3-venv
i2cdetect -y 1

BME280(SDA=GPIO2/핀3, SCL=GPIO3/핀5)을 연결 후 스캔하면 아래처럼 나온다. SDO를 GND에 연결하면 0x76, VDDIO에 연결하면 0x77.

$ i2cdetect -y 1
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:          -- -- -- -- -- -- -- -- -- -- -- -- --
...
70: -- -- -- -- -- -- 76 -- -- -- -- -- -- -- -- --

python-smbus2로 레지스터 read/write

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

SMBus를 컨텍스트 매니저로 열면 read_byte_data/write_byte_data/read_i2c_block_data로 레지스터에 직접 접근한다.

from smbus2 import SMBus

BME280_ADDR = 0x76

with SMBus(1) as bus:
    chip_id = bus.read_byte_data(BME280_ADDR, 0xD0)  # BME280은 0x60
    print(f"chip id: 0x{chip_id:02X}")

    bus.write_byte_data(BME280_ADDR, 0xF2, 0x01)

    block = bus.read_i2c_block_data(BME280_ADDR, 0x88, 24)  # burst read
    print(block)

측정 전 세 레지스터 설정이 필요하다: 0xF2 습도 오버샘플링, 0xF4 온도/기압 오버샘플링+모드, 0xF5 standby/IIR 필터.

REG_CTRL_HUM = 0xF2
REG_CTRL_MEAS = 0xF4
REG_CONFIG = 0xF5

bus.write_byte_data(BME280_ADDR, REG_CTRL_HUM, 0x01)   # 습도 오버샘플링 x1
bus.write_byte_data(BME280_ADDR, REG_CTRL_MEAS, 0x27)  # 온도/기압 x1, normal mode
bus.write_byte_data(BME280_ADDR, REG_CONFIG, 0xA0)     # standby 1000ms, filter off

원시 데이터 읽기와 보정 계수 적용

raw ADC 값은 그대로 못 쓴다 — 센서마다 공장에서 측정한 고유 보정 계수를 레지스터에서 읽어 데이터시트 공식에 대입해야 실제 값이 나온다.

레지스터이름설명
0x88–0x9Fdig_T1~T3, dig_P1~P9온도/기압 보정 계수 (24바이트)
0xA1dig_H1습도 보정 계수 1바이트
0xE1–0xE7dig_H2~H6습도 보정 계수 (7바이트, 일부 12비트 packed)
0xF7–0xF9press_msb/lsb/xlsb기압 raw 값 (20비트)
0xFA–0xFCtemp_msb/lsb/xlsb온도 raw 값 (20비트)
0xFD–0xFEhum_msb/lsb습도 raw 값 (16비트)

0xF7~0xFE 8바이트를 한 번에 burst read하면 세 값을 동시에 얻는다(개별 read보다 시점 차이 없이 정확). 보정 계수 파싱부터 최종 값 계산까지 전체 스크립트:

import time
import struct
from smbus2 import SMBus

I2C_BUS = 1
BME280_ADDR = 0x76  # SDO를 GND에 연결한 경우. VDDIO에 연결하면 0x77

REG_CTRL_HUM = 0xF2
REG_CTRL_MEAS = 0xF4
REG_CONFIG = 0xF5
REG_CALIB_00 = 0x88
REG_CALIB_26 = 0xE1
REG_DATA = 0xF7


class BME280:
    def __init__(self, bus, addr=BME280_ADDR):
        self.bus = bus
        self.addr = addr
        self._load_calibration()
        self._configure()

    def _load_calibration(self):
        calib1 = self.bus.read_i2c_block_data(self.addr, REG_CALIB_00, 26)
        calib2 = self.bus.read_i2c_block_data(self.addr, REG_CALIB_26, 7)

        (self.dig_T1, self.dig_T2, self.dig_T3,
         self.dig_P1, self.dig_P2, self.dig_P3, self.dig_P4,
         self.dig_P5, self.dig_P6, self.dig_P7, self.dig_P8,
         self.dig_P9) = struct.unpack('<HhhHhhhhhhhh', bytes(calib1[0:24]))

        self.dig_H1 = calib1[25]
        self.dig_H2, self.dig_H3 = struct.unpack('<hB', bytes(calib2[0:3]))
        e4, e5, e6 = calib2[3], calib2[4], calib2[5]
        self.dig_H4 = self._to_signed12((e4 << 4) | (e5 & 0x0F))
        self.dig_H5 = self._to_signed12((e6 << 4) | (e5 >> 4))
        self.dig_H6 = struct.unpack('<b', bytes([calib2[6]]))[0]

    @staticmethod
    def _to_signed12(value):
        if value & 0x800:
            value -= 1 << 12
        return value

    def _configure(self):
        self.bus.write_byte_data(self.addr, REG_CTRL_HUM, 0x01)   # 습도 x1
        self.bus.write_byte_data(self.addr, REG_CTRL_MEAS, 0x27)  # 온도/기압 x1, normal
        self.bus.write_byte_data(self.addr, REG_CONFIG, 0xA0)     # standby 1000ms
        time.sleep(0.1)

    def _read_raw(self):
        data = self.bus.read_i2c_block_data(self.addr, REG_DATA, 8)
        adc_p = (data[0] << 12) | (data[1] << 4) | (data[2] >> 4)
        adc_t = (data[3] << 12) | (data[4] << 4) | (data[5] >> 4)
        adc_h = (data[6] << 8) | data[7]
        return adc_t, adc_p, adc_h

    def read(self):
        adc_t, adc_p, adc_h = self._read_raw()
        temperature, t_fine = self._compensate_temperature(adc_t)
        pressure = self._compensate_pressure(adc_p, t_fine)
        humidity = self._compensate_humidity(adc_h, t_fine)
        return temperature, pressure, humidity

    def _compensate_temperature(self, adc_t):
        var1 = (adc_t / 16384.0 - self.dig_T1 / 1024.0) * self.dig_T2
        var2 = ((adc_t / 131072.0 - self.dig_T1 / 8192.0) ** 2) * self.dig_T3
        t_fine = var1 + var2
        return t_fine / 5120.0, t_fine

    def _compensate_pressure(self, adc_p, t_fine):
        var1 = t_fine / 2.0 - 64000.0
        var2 = var1 * var1 * self.dig_P6 / 32768.0
        var2 = var2 + var1 * self.dig_P5 * 2.0
        var2 = var2 / 4.0 + self.dig_P4 * 65536.0
        var1 = (self.dig_P3 * var1 * var1 / 524288.0 + self.dig_P2 * var1) / 524288.0
        var1 = (1.0 + var1 / 32768.0) * self.dig_P1
        if var1 == 0:
            return 0.0
        pressure = 1048576.0 - adc_p
        pressure = (pressure - var2 / 4096.0) * 6250.0 / var1
        var1 = self.dig_P9 * pressure * pressure / 2147483648.0
        var2 = pressure * self.dig_P8 / 32768.0
        pressure = pressure + (var1 + var2 + self.dig_P7) / 16.0
        return pressure / 100.0  # hPa

    def _compensate_humidity(self, adc_h, t_fine):
        var_h = t_fine - 76800.0
        var_h = (adc_h - (self.dig_H4 * 64.0 + self.dig_H5 / 16384.0 * var_h)) * (
            self.dig_H2 / 65536.0 * (1.0 + self.dig_H6 / 67108864.0 * var_h *
            (1.0 + self.dig_H3 / 67108864.0 * var_h))
        )
        var_h = var_h * (1.0 - self.dig_H1 * var_h / 524288.0)
        return max(0.0, min(100.0, var_h))


if __name__ == "__main__":
    with SMBus(I2C_BUS) as bus:
        sensor = BME280(bus)
        while True:
            temp, pressure, humidity = sensor.read()
            print(f"온도: {temp:.2f} C, 기압: {pressure:.2f} hPa, 습도: {humidity:.2f} %")
            time.sleep(1)

주의할 점: dig_H4/dig_H5는 12비트 단위로 니블이 겹쳐 저장돼 데이터시트 비트 배치를 정확히 따라야 습도 값이 안 튄다. dig_T1/dig_P1은 unsigned, 나머지는 signed라 struct.unpack 포맷(H/h)을 정확히 구분해야 한다.

$ python3 bme280_reader.py
온도: 26.43 C, 기압: 1012.87 hPa, 습도: 47.12 %
온도: 26.44 C, 기압: 1012.85 hPa, 습도: 47.09 %

주의사항

  • PermissionError: [Errno 13]sudo usermod -aG i2c $USER 후 로그아웃/재로그인(또는 newgrp i2c). sudo로 스크립트 실행은 venv 꼬임 등 부작용이 있어 비권장.
  • OSError: [Errno 121] Remote I/O error — 배선(SDA/SCL 바뀜), 전원, 주소(0x76 vs 0x77) 확인.
  • 동일 주소 센서 충돌 — BME280은 SDO로 0x76/0x77 두 주소만 가능. 3개 이상이면 TCA9548A 멀티플렉서나 별도 버스 필요.
  • t_fine 계산 순서 — 온도 보정을 가장 먼저 호출해야 한다. 기압/습도 공식이 그 t_fine을 재사용하므로 순서를 바꾸면 값이 완전히 틀어진다.
  • 클록 스트레칭 불안정 — BCM283x 하드웨어 I2C가 일부 장치의 클록 스트레칭을 완벽히 지원 못 함. dtparam=i2c_arm_baudrate=10000으로 속도를 낮추거나 i2c-gpio 오버레이로 우회.

마무리

I2C는 “주소로 장치를 고르고 레지스터 번호로 읽거나 쓰는” 규칙의 반복이고, smbus2는 이를 얇게 감싼 API일 뿐이다. BME280에서 익힌 “제어 레지스터 설정 → burst read → 보정 계수 적용” 흐름은 다른 I2C 센서에도 레지스터 맵만 바꿔 그대로 재사용할 수 있다.

참고

답글 남기기