Chapter 9. I2C LCD 디스플레이 문자열 출력
컴퓨터 모니터 없이도 보드의 현재 상태나 센서 데이터 값을 시각적으로 뿌려줄 수 있는 I2C 규격의 LCD1602 모듈을 통제해 봅니다.
뒷면에 부착된 검은색 I2C 백팩 모듈 덕분에 단 4가닥의 선만으로 화면 제어가 가능합니다.
LCD I2C 백팩: VCC→3V3, SDA→GP8(8번 핀), SCL→GP9(9번 핀), GND→GND. I2C 버스 덕분에 4가닥만으로 16×2 LCD 전체를 제어합니다.
9.1 사전 준비: 구동 라이브러리 파일 업로드 (매우 중요)
LCD를 파이썬 코드로 쉽게 제어하려면, 전 세계 마이크로파이썬 개발자들이 표준으로 사용하는 오픈소스 라이브러리 파일 2개가 Pico 보드 내부에 미리 저장되어 있어야 합니다. 아래 두 코드를 각각 복사하여 Thonny에서 새 파일로 만든 뒤, 지정된 이름으로 저장해 주세요.
첫 번째 파일: 공통 뼈대 역할을 하는 파일입니다. 아래 코드를 lcd_api.py 이름으로 저장하세요.
import time
class LcdApi:
LCD_CLR = 0x01
LCD_HOME = 0x02
LCD_ENTRY_MODE = 0x04
LCD_DISPLAY_CONTROL = 0x08
LCD_CURSOR_SHIFT = 0x10
LCD_FUNCTION_SET = 0x20
LCD_SET_CGRAM_ADDR = 0x40
LCD_SET_DDRAM_ADDR = 0x80
LCD_BLINK_ON = 0x01
LCD_BLINK_OFF = 0x00
LCD_CURSOR_ON = 0x02
LCD_CURSOR_OFF = 0x00
LCD_DISPLAY_ON = 0x04
LCD_DISPLAY_OFF = 0x00
LCD_ENTRY_RIGHT = 0x00
LCD_ENTRY_LEFT = 0x02
LCD_ENTRY_SHIFT_INCREMENT = 0x01
LCD_ENTRY_SHIFT_DECREMENT = 0x00
LCD_MOVE_RIGHT = 0x04
LCD_MOVE_LEFT = 0x00
LCD_8BIT_MODE = 0x10
LCD_4BIT_MODE = 0x00
LCD_2LINE_MODE = 0x08
LCD_1LINE_MODE = 0x00
LCD_5X10_DOTS = 0x04
LCD_5X8_DOTS = 0x00
# I2cLcd가 찾는 리셋 비트 설정들
LCD_FUNCTION_RESET = 0x30
def __init__(self, num_lines, num_columns):
self.num_lines = num_lines
if self.num_lines > 4:
self.num_lines = 4
self.num_columns = num_columns
if self.num_columns > 40:
self.num_columns = 40
self.cursor_x = 0
self.cursor_y = 0
self.implied_newline = False
self.backlight = True
self.display_control = self.LCD_DISPLAY_ON | self.LCD_CURSOR_OFF | self.LCD_BLINK_OFF
self.display_function = self.LCD_4BIT_MODE | self.LCD_1LINE_MODE | self.LCD_5X8_DOTS
if self.num_lines > 1:
self.display_function |= self.LCD_2LINE_MODE
def clear(self):
self.hal_write_command(self.LCD_CLR)
self.hal_write_command(self.LCD_HOME)
self.cursor_x = 0
self.cursor_y = 0
time.sleep_ms(2)
def show_cursor(self):
self.display_control |= self.LCD_CURSOR_ON
self.hal_write_command(self.display_control)
def hide_cursor(self):
self.display_control &= ~self.LCD_CURSOR_ON
self.hal_write_command(self.display_control)
def blink_cursor_on(self):
self.display_control |= self.LCD_BLINK_ON
self.hal_write_command(self.display_control)
def blink_cursor_off(self):
self.display_control &= ~self.LCD_BLINK_ON
self.hal_write_command(self.display_control)
def display_on(self):
self.display_control |= self.LCD_DISPLAY_ON
self.hal_write_command(self.display_control)
def display_off(self):
self.display_control &= ~self.LCD_DISPLAY_ON
self.hal_write_command(self.display_control)
def backlight_on(self):
self.backlight = True
self.hal_backlight_on()
def backlight_off(self):
self.backlight = False
self.hal_backlight_off()
def move_to(self, cursor_x, cursor_y):
self.cursor_x = cursor_x
self.cursor_y = cursor_y
if cursor_x >= self.num_columns:
cursor_x = self.num_columns - 1
elif cursor_x < 0:
cursor_x = 0
if cursor_y >= self.num_lines:
cursor_y = self.num_lines - 1
elif cursor_y < 0:
cursor_y = 0
addr = cursor_x
if cursor_y == 1:
addr += 0x40
elif cursor_y == 2:
addr += self.num_columns
elif cursor_y == 3:
addr += 0x40 + self.num_columns
self.hal_write_command(self.LCD_SET_DDRAM_ADDR | addr)
def putstr(self, string):
for char in string:
if char == '\n':
self.cursor_x = 0
self.cursor_y += 1
if self.cursor_y >= self.num_lines:
self.cursor_y = 0
self.move_to(self.cursor_x, self.cursor_y)
else:
self.hal_write_data(ord(char))
self.cursor_x += 1
if self.cursor_x >= self.num_columns:
self.cursor_x = 0
self.cursor_y += 1
if self.cursor_y >= self.num_lines:
self.cursor_y = 0
self.move_to(self.cursor_x, self.cursor_y)
def custom_char(self, location, charmap):
if location < 0 or location > 7:
return
self.hal_write_command(self.LCD_SET_CGRAM_ADDR | (location << 3))
for i in range(8):
self.hal_write_data(charmap[i])
self.move_to(self.cursor_x, self.cursor_y)
def hal_write_command(self, cmd):
raise NotImplementedError
def hal_write_data(self, data):
raise NotImplementedError
def hal_backlight_on(self):
raise NotImplementedError
def hal_backlight_off(self):
raise NotImplementedError
두 번째 파일: I2C 통신 규격을 다루는 파일입니다. 아래 코드를 pico_i2c_lcd.py 이름으로 저장하세요.
import time
from machine import I2C
from lcd_api import LcdApi
class I2cLcd(LcdApi):
MASK_RS = 0x01
MASK_RW = 0x02
MASK_E = 0x04
SHIFT_BACKLIGHT = 3
SHIFT_DATA = 4
def __init__(self, i2c, i2c_addr, num_lines, num_columns):
self.i2c = i2c
self.i2c_addr = i2c_addr
self.i2c.writeto(self.i2c_addr, bytes([0]))
time.sleep_ms(20)
# 에러 발생 지점 통과를 위한 초기화 프로토콜
self.hal_write_init_nibble(self.LCD_FUNCTION_RESET)
time.sleep_ms(5)
self.hal_write_init_nibble(self.LCD_FUNCTION_RESET)
time.sleep_ms(1)
self.hal_write_init_nibble(self.LCD_FUNCTION_RESET)
self.hal_write_init_nibble(0x20)
super().__init__(num_lines, num_columns)
cmd = self.LCD_FUNCTION_SET | self.LCD_4BIT_MODE | self.LCD_2LINE_MODE | self.LCD_5X8_DOTS
self.hal_write_command(cmd)
cmd = self.LCD_DISPLAY_CONTROL | self.LCD_DISPLAY_ON | self.LCD_CURSOR_OFF | self.LCD_BLINK_OFF
self.hal_write_command(cmd)
self.clear()
cmd = self.LCD_ENTRY_MODE | self.LCD_ENTRY_LEFT
self.hal_write_command(cmd)
def hal_write_init_nibble(self, nibble):
byte = ((nibble >> 4) << self.SHIFT_DATA) | (1 << self.SHIFT_BACKLIGHT)
self.i2c.writeto(self.i2c_addr, bytes([byte | self.MASK_E]))
self.i2c.writeto(self.i2c_addr, bytes([byte]))
def hal_write_command(self, cmd):
self.hal_write_nested(cmd, 0)
def hal_write_data(self, data):
self.hal_write_nested(data, self.MASK_RS)
def hal_write_nested(self, value, mode):
backlight = (1 if self.backlight else 0) << self.SHIFT_BACKLIGHT
# High nibble
high = ((value & 0xF0) >> 4) << self.SHIFT_DATA
self.i2c.writeto(self.i2c_addr, bytes([high | mode | backlight | self.MASK_E]))
self.i2c.writeto(self.i2c_addr, bytes([high | mode | backlight]))
# Low nibble
low = (value & 0x0F) << self.SHIFT_DATA
self.i2c.writeto(self.i2c_addr, bytes([low | mode | backlight | self.MASK_E]))
self.i2c.writeto(self.i2c_addr, bytes([low | mode | backlight]))
def hal_backlight_on(self):
self.i2c.writeto(self.i2c_addr, bytes([(1 if self.backlight else 0) << self.SHIFT_BACKLIGHT]))
def hal_backlight_off(self):
self.i2c.writeto(self.i2c_addr, bytes([(1 if self.backlight else 0) << self.SHIFT_BACKLIGHT]))
💡 I2C란 무엇이고, 왜 선이 2개뿐일까?
I2C(아이스퀘어씨)는 여러 부품을 단 2개의 신호선(SDA=데이터, SCL=클럭)만으로 통신하게 해주는 규격입니다. 원래 LCD1602는 데이터 핀만 6~11개가 필요한 부품이지만, 뒷면에 I2C 백팩(PCF8574 칩)이 추가로 붙어서 그 많은 핀을 SDA/SCL 단 2가닥으로 압축·변환해 줍니다. 그래서 우리는 4가닥(VCC, GND, SDA, SCL)만 연결하면 됩니다.
9.2 I2C 디바이스 주소 스캔하기
라이브러리 준비가 끝났다면 하드웨어가 올바른 물리 채널에 접속되었는지 확인해야 합니다. 아래 코드를 실행하여 내 디스플레이의 고유 16진수 주소(Address)를 알아냅니다.
from machine import I2C, Pin
# Pico 2의 I2C0번 채널 활성화 (SDA: GPIO 8, SCL: GPIO 9)
i2c = I2C(0, sda=Pin(8), scl=Pin(9), freq=400000)
print("I2C 무선 장치 스캔 시작...")
devices = i2c.scan()
if len(devices) == 0:
print("연결된 I2C 부품을 찾지 못했습니다. 배선을 확인하세요.")
else:
for device in devices:
print(f"발견된 디바이스 16진수 고유 주소: {hex(device)}")
9.3 LCD 화면에 실시간 문자열 시각화
위에서 저장한 라이브러리를 임포트(import)하여 LCD 화면을 통제합니다. 화면 상단에는 고정 문자를 찍고, 하단에는 실시간으로 데이터가 변동되는 대시보드 출력 마스터 코드입니다.
from machine import I2C, Pin
import time
# 미리 저장해둔 라이브러리 파일에서 클래스를 불러옵니다.
from pico_i2c_lcd import I2cLcd
i2c = I2C(0, sda=Pin(8), scl=Pin(9), freq=400000)
# 위 스캔에서 발견된 16진수 주소값(보통 0x27) 입력
lcd_address = 0x27
lcd = I2cLcd(i2c, lcd_address, 2, 16)
# 화면 초기화 및 백라이트 점등
lcd.clear()
lcd.backlight_on()
# 1행에 텍스트 고정 출력 (0번방 열, 0번방 행)
lcd.move_to(0, 0)
lcd.putstr("Pico 2 IoT Monitor")
count = 0
while True:
# 2행 0열로 커서 이동 후 실시간 데이터 교체
lcd.move_to(0, 1)
lcd.putstr(f"System Running:{count}")
count += 1
time.sleep(1.0)
9.4 긴 문자열 스크롤 효과 만들기
LCD1602는 한 줄에 16글자밖에 표시하지 못합니다. 그보다 긴 문장을 보여주고 싶다면, 문자열의 시작 위치를 한 글자씩 밀어가며 반복 출력하는 "스크롤(scroll)" 기법을 사용합니다.
from machine import I2C, Pin
import time
from pico_i2c_lcd import I2cLcd
i2c = I2C(0, sda=Pin(8), scl=Pin(9), freq=400000)
lcd = I2cLcd(i2c, 0x27, 2, 16)
lcd.backlight_on()
def scroll_text(lcd, row, text, width=16, delay=0.3):
# 문장 앞뒤에 여백을 붙여서 자연스럽게 화면 밖으로 흘러가도록 만듭니다.
padded = " " * width + text + " " * width
for start in range(len(padded) - width + 1):
frame = padded[start:start + width]
lcd.move_to(0, row)
lcd.putstr(frame)
time.sleep(delay)
lcd.move_to(0, 0)
lcd.putstr("Pico 2 IoT Monitor")
message = "Chapter 9 - I2C LCD 스크롤 예제입니다. 계속 흘러갑니다!"
while True:
scroll_text(lcd, 1, message)
💡 스크롤 원리
padded 문자열은 실제 문장 앞뒤에 16칸(width)만큼의 공백을 붙여둔 형태입니다. start 값을 1씩 늘려가며 그 위치부터 16글자를 잘라 putstr()로 다시 찍으면, 마치 글씨가 왼쪽으로 흘러가는 것처럼 보입니다. lcd.move_to(0, row)로 매번 커서를 줄 맨 앞으로 되돌리는 것이 핵심입니다.
9.5 과제 해결해보기!
과제 1. 카운트다운 타이머 표시하기
1행에는 "Countdown:" 문자를 고정 출력하고, 2행에는 10초부터 0초까지 1초 간격으로 숫자가 줄어드는 타이머를 LCD에 표시해 보세요. 0초에 도달하면 "Time Up!"을 출력합니다.
과제 2. 온도 값에 따라 경고 문구 스크롤하기
임의의 온도 변수 temp = 32가 있을 때, 온도가 30 이상이면 2행에 "WARNING: TOO HOT! PLEASE CHECK THE ROOM."이라는 긴 문장을 9.4에서 배운 scroll_text() 함수로 흘려보내고, 30 미만이면 "Temp OK"를 고정 출력하는 코드를 작성해 보세요.
- 1행에는 항상
f"Temp: {temp}C"를 고정 출력합니다. - 9.4의
scroll_text()함수를 그대로 재사용하면 됩니다.
9.6 Wokwi 시뮬레이션으로 실습하기
Chapter 1.8에서 소개한 Wokwi 온라인 시뮬레이터에서는 I2C LCD1602 모듈도 지원되므로, 실제 부품 없이도 문자열 출력과 스크롤 효과를 미리 확인해 볼 수 있습니다.
💻 실습 순서
- wokwi.com에서 [New Project] → "Raspberry Pi Pico" (MicroPython) 템플릿을 선택합니다.
- 왼쪽 diagram.json 탭에 아래 코드를 붙여넣어 Pico와 I2C LCD1602를 자동으로 연결합니다.
- Wokwi의 파일 트리에서
lcd_api.py,pico_i2c_lcd.py파일을 새로 만들어 9.1의 라이브러리 코드를 각각 붙여넣고,main.py에는 9.3 또는 9.4의 코드를 붙여넣습니다. - 화면 상단의 ▶️ (녹색 재생 버튼)을 클릭하면 시뮬레이션이 시작되고, LCD 화면에 문자열이 출력/스크롤되는 것을 확인할 수 있습니다.
{
"version": 1,
"author": "Pico 2 IoT Guide",
"editor": "wokwi",
"parts": [
{ "type": "wokwi-pi-pico", "id": "pico", "top": 0, "left": 0, "attrs": {} },
{ "type": "wokwi-lcd1602", "id": "lcd1", "top": -40, "left": 180, "attrs": { "pins": "i2c" } }
],
"connections": [
[ "pico:GP8", "lcd1:SDA", "green", [] ],
[ "pico:GP9", "lcd1:SCL", "yellow", [] ],
[ "pico:3V3", "lcd1:VCC", "red", [] ],
[ "pico:GND", "lcd1:GND", "black", [] ]
]
}
⚠️ 참고 사항
Wokwi의 wokwi-lcd1602 부품 이름이나 pins 속성 값은 버전에 따라 달라질 수 있습니다. I2C 주소가 다르게 표시되거나 화면이 나오지 않으면, 에디터 좌측의 부품 검색(parts search)에서 "lcd1602"를 검색해 정확한 부품명을 확인하고 type 값을 알맞게 수정하세요.