제4부 프로그래밍 가이드
4.3 GPIO·센서 예제 모음
이 장은 UNO Q의 MCU(STM32U585)에서 디지털 입출력·PWM·아날로그 입력을 다루는 기본 패턴과, 초음파 거리 센서·I2C IMU 두 가지 실전 센서 예제를 코드로 정리한다. 핀아웃의 하드웨어적 배경(신호 도메인, 커넥터 배치)은 2.2에서 상세히 다루므로, 이 장은 프로그래밍 관점에서 필요한 최소한의 핀 정보만 표로 정리하고 바로 예제로 들어간다. 모든 예제는 순수 Arduino IDE 스케치(4.1)로 작성했다 — Bridge로 MPU와 연동하는 방법은 4.2을 참조.
4.3.1 시작하기 전에 — 전압 규칙
UNO Q의 JDIGITAL1·JANALOG1 헤더는 3.3V 로직이다. 공식 데이터시트의 핀아웃 경고를 그대로 옮기면 다음과 같다.
All MCU GPIOs are 3.3 V logic and 5 V tolerant, except A0 and A1 (not 5 V tolerant).
즉 디지털 핀(D0~D13, D20/D21)은 대부분 입력으로는 5V도 허용하지만, 출력은 항상 3.3V다. 반면 A0(PA4)·A1(PA5)는 STM32U585의 ADC에 직결된 핀이라 5V를 인가하면 안 된다 — 절대 최대 정격은 VDD + 0.3V(≈3.6V)다. A2~A5도 ADC로 쓸 때는 같은 제약(0~VREF+, 약 3.3V)을 받는다. ~D3(PB0)은 별도로 더 엄격해서, TT-type I/O 구조라 5V가 아니라 3.6V까지만 허용된다(어떤 모드에서도).
실전 규칙: 5V 로직 센서·모듈은 출력 핀(TRIG 등)에는 3.3V 신호를 그대로 연결해도 대개 동작하지만, 그 모듈이 UNO Q로 보내는 신호(ECHO 등)가 5V라면 A0/A1과 ~D3를 제외한 일반 디지털 핀에 연결한다. 애매하면 로직 레벨 시프터를 쓴다.
다음은 JDIGITAL1·JANALOG1의 프로그래밍에 필요한 최소 핀맵이다(공식 데이터시트 요약, 전체 핀맵과 커넥터 사양은 2.2).
| 핀 | MCU 핀 | 주요 기능 | 비고 |
|---|---|---|---|
| D0 | PB7 | USART1_RX, TIM4_CH2 | UART RX |
| D1 | PB6 | USART1_TX, TIM4_CH1 | UART TX |
| D2 | PB3 | TIM2_CH2 | 순수 GPIO로 흔히 씀 |
| ~D3 | PB0 | OPAMP2_OUTPUT, TIM3_CH3 | PWM. 3.6V까지만, 5V 비허용(모든 모드) |
| D4 | PA12 | FDCAN1_TX, TIM1_ETR | |
| ~D5 | PA11 | FDCAN1_RX, TIM1_CH4 | PWM(TIM1 공유) |
| ~D6 | PB1 | TIM3_CH4 | PWM(TIM3 공유) |
| D7 | PB2 | TIM8_CH4N | |
| D8 | PB4 | TIM3_CH1 | PWM(TIM3 공유) |
| ~D9 | PB8 | TIM4_CH3 | PWM(TIM4 공유) |
| ~D10 | PB9 | SPI2_SS, TIM4_CH4 | PWM(TIM4 공유) |
| ~D11 | PB15 | SPI2_MOSI, TIM1_CH3N | PWM(TIM1 공유) |
| D12 | PB14 | SPI2_MISO, TIM1_CH2N | PWM(TIM1 공유) |
| D13 | PB13 | SPI2_SCK, TIM1_CH1N | PWM(TIM1 공유) |
| D20 | PB11 | I2C2_SDA, TIM2_CH4 | |
| D21 | PB10 | I2C2_SCL, TIM2_CH3 | |
| A0/D14 | PA4 | ADC, DAC0 | 5V 비허용, direct ADC |
| A1/D15 | PA5 | ADC, DAC1 | 5V 비허용, direct ADC |
| A2/D16 | PA6 | ADC, OPAMP2_INPUT+ | |
| A3/D17 | PA7 | ADC, OPAMP2_INPUT− | |
| A4/D18 | PC1 | ADC, I2C3_SDA | 표준 Wire |
| A5/D19 | PC0 | ADC, I2C3_SCL | 표준 Wire |
~ 표시가 PWM 지원 핀이다 — 클래식 UNO와 동일하게 D3, D5, D6, D9, D10, D11이다. PWM 비고에 적힌 타이머(TIM1/TIM3/TIM4) 공유 관계는 4.3.3의 PWM 주의사항에서 다시 쓰인다.
4.3.2 디지털 입출력 — 버튼과 LED
가장 기본적인 패턴이다. 버튼은 내부 풀업을 켜서 별도 저항 없이 배선한다.
const int BUTTON_PIN = 2; // D2, 버튼의 다른 한쪽은 GND
const int LED_PIN = 7; // D7, LED + 저항을 GND로
void setup() {
pinMode(BUTTON_PIN, INPUT_PULLUP); // 안 누르면 HIGH, 누르면 LOW
pinMode(LED_PIN, OUTPUT);
Serial.begin(115200);
}
void loop() {
bool pressed = (digitalRead(BUTTON_PIN) == LOW);
digitalWrite(LED_PIN, pressed ? HIGH : LOW);
static bool last = false;
if (pressed != last) {
Serial.println(pressed ? "PRESSED" : "RELEASED");
delay(30); // 간단한 디바운스
}
last = pressed;
}
기계식 버튼은 접점이 순간적으로 개폐를 반복하는 바운스 현상이 있다. 위 코드는 상태가 바뀔 때만 delay(30)으로 짧게 안정화하는 최소한의 디바운스다. 인터럽트 기반 정밀 디바운스나 타이머 크리티컬 설계는 8.2에서 다룬다.
4.3.3 PWM — LED 밝기 조절과 타이머 공유 주의사항
analogWrite()로 PWM 핀(~ 표시)의 듀티비를 0~255 범위로 조절한다. 아래는 밝기가 서서히 오르내리는 "숨쉬기" LED 예제다.
const int LED_PIN = 9; // ~D9
void setup() {
pinMode(LED_PIN, OUTPUT);
}
void loop() {
for (int b = 0; b <= 255; b++) {
analogWrite(LED_PIN, b);
delay(4);
}
for (int b = 255; b >= 0; b--) {
analogWrite(LED_PIN, b);
delay(4);
}
}
⚠️ 알려진 이슈 — PWM 핀이 다른 GPIO를 방해할 수 있다. Arduino 포럼에 실제로 보고된 사례(2026년, "Enabling PWM prevents some GPIO pins from working")에 따르면, PWM 핀은 내부적으로 STM32 타이머 채널을 공유하기 때문에 한 핀에서 analogWrite()를 켜면 같은 타이머를 쓰는 다른 핀이 멈추는 현상이 있었다.
~D3(TIM3_CH3)에서 PWM 활성화 →D6(TIM3_CH4),D8(TIM3_CH1) 동작 불가~D11(TIM1_CH3N)에서 PWM 활성화 →~D5(TIM1_CH4),D12(TIM1_CH2N),D13(TIM1_CH1N) 동작 불가
위 표에서 "PWM(TIMx 공유)" 비고가 있는 핀들을 함께 쓸 계획이라면 이 관계를 먼저 확인한다. 이 문제는 ArduinoCore-zephyr 0.55.2 릴리스에서 해결되었다고 보고되었으므로, 실습 전 4.1.2.1에서 안내한 대로 보드 패키지를 최신으로 갱신한다. 또한 초기 코어 버전에서는 pinMode() 호출 위치가 analogWrite()의 PWM 출력에 영향을 주는 문제도 보고되었다 — 최신 코어는 런타임에 핀 제어를 재적용하도록 개선되었다고 하나, 의심되면 analogWrite() 직전에 pinMode(pin, OUTPUT)을 다시 호출해 두면 안전하다(포럼 워크어라운드).
4.3.4 아날로그 입력 — 가변저항 읽기
const int POT_PIN = A0; // 주의: A0는 5V 비허용 — 3.3V와 GND 사이에만 배선
void setup() {
Serial.begin(115200);
}
void loop() {
int raw = analogRead(POT_PIN); // 확인 필요: 기본 분해능(10비트 0~1023 가정)
float voltage = raw * (3.3 / 1023.0); // VREF+ ≈ 3.3V
Serial.print("raw=");
Serial.print(raw);
Serial.print(" V=");
Serial.println(voltage, 3);
delay(200);
}
가변저항(포텐셔미터)의 양끝을 3.3V 핀과 GND에 연결하고, 와이퍼(가운데 핀)를 A0에 연결한다. 클래식 UNO 관행대로 5V 핀에 연결하면 A0 절대 최대 정격(≈3.6V)을 넘겨 MCU의 ADC를 손상시킬 수 있다. ⚠️ 검증 필요: analogRead()의 기본 분해능이 클래식 AVR과 같은 10비트(0~1023)인지, 아니면 STM32U585 ADC의 네이티브 12비트가 그대로 노출되는지는 이번 조사로 확정하지 못했다 — 실측하거나, 필요하면 analogReadResolution()(존재 여부 확인 필요)으로 명시적으로 설정한다.
4.3.5 센서 ① — 초음파 거리 센서 HC-SR04
HC-SR04는 TRIG 핀에 10µs 펄스를 주면 초음파를 송신하고, ECHO 핀이 반사파가 돌아올 때까지 HIGH를 유지한다. 그 HIGH 구간의 길이(µs)로 거리를 계산한다.
const int TRIG_PIN = 7; // D7 — 일반 디지털 출력(5V 허용 입력이므로 ECHO에도 안전)
const int ECHO_PIN = 8; // D8
float readDistanceCm() {
digitalWrite(TRIG_PIN, LOW);
delayMicroseconds(2);
digitalWrite(TRIG_PIN, HIGH);
delayMicroseconds(10);
digitalWrite(TRIG_PIN, LOW);
// 30ms 타임아웃(≈최대 5m) — 무응답 시 무한 대기 방지
long duration = pulseIn(ECHO_PIN, HIGH, 30000);
if (duration == 0) return -1.0; // 타임아웃
return duration * 0.0343 / 2.0; // 음속 343m/s → 0.0343cm/us, 왕복이므로 /2
}
void setup() {
pinMode(TRIG_PIN, OUTPUT);
pinMode(ECHO_PIN, INPUT);
Serial.begin(115200);
}
void loop() {
float d = readDistanceCm();
if (d < 0) {
Serial.println("no echo");
} else {
Serial.print(d);
Serial.println(" cm");
}
delay(100);
}
pulseIn()은 Zephyr 기반 Arduino Core에서도 그대로 지원되며, 실물 UNO Q에서 이 방식으로 HC-SR04를 검증했다는 커뮤니티 기록이 있다(prahari.net, 커뮤니티 검증 — 저자가 직접 재현하지는 못했다). D7/D8은 데이터시트가 "5V 허용"이라 명시한 일반 디지털 핀이라, HC-SR04를 5V로 구동해도 ECHO 신호를 안전하게 받을 수 있다. HC-SR04의 VCC는 5V 핀(JANALOG1의 +5V USB VBUS 또는 JSPI1의 5V)에서 공급받는다 — TRIG/ECHO만 3.3V 로직 핀에 연결하면 된다.
이 예제를 Bridge로 MPU와 연동해 임계값 알림을 만드는 전체 App은 4.2.5를 참조.
4.3.6 센서 ② — I2C IMU(가속도·자이로)
MPU6050류 6축 IMU는 I2C로 연결된다. UNO Q의 표준 I2C는 A4(SDA)/A5(SCL) — 내부적으로 I2C3다. 다만 표준 Wire(A4/A5)가 I2C 장치를 인식하지 못한다는 이슈가 실제로 보고되었다(arduino/ArduinoCore-zephyr 저장소 GitHub Issue #301, "UNO Q: I2C: Wire not reading I2C devices", 2026년). 같은 장치를 Qwiic 커넥터(Wire1, 내부적으로 I2C4)로 옮기면 정상 인식되었다는 보고다 — 원인은 표준 핀의 디바이스 트리 오버레이 누락으로 추정되며, 이 조사 시점에는 수정 PR(#355)이 진행 중이었다.
서드파티 IMU 라이브러리는 4.1.5.1에서 경고한 AVR 전용 아키텍처 태그 함정이 있을 수 있으므로, 여기서는 표준 Wire API로 레지스터를 직접 읽는 최소 예제를 든다(MPU6050 공개 레지스터맵 기준 — I2C 자체는 UNO Q 특유의 문제가 아니라 범용 프로토콜이라 안전하다).
#include <Wire.h>
const uint8_t MPU_ADDR = 0x68; // MPU6050 기본 주소(AD0=GND)
void mpuWriteReg(uint8_t reg, uint8_t val) {
Wire.beginTransmission(MPU_ADDR);
Wire.write(reg);
Wire.write(val);
Wire.endTransmission();
}
int16_t mpuRead16(uint8_t reg) {
Wire.beginTransmission(MPU_ADDR);
Wire.write(reg);
Wire.endTransmission(false); // 리피티드 스타트
Wire.requestFrom((int)MPU_ADDR, 2);
int16_t v = (Wire.read() << 8) | Wire.read();
return v;
}
void setup() {
Serial.begin(115200);
Wire.begin(); // 표준 I2C(A4/A5). 장치가 인식되지 않으면 Wire1.begin()으로 교체(Qwiic, I2C4)
mpuWriteReg(0x6B, 0x00); // PWR_MGMT_1 = 0 → 슬립 해제
delay(100);
}
void loop() {
int16_t ax = mpuRead16(0x3B);
int16_t ay = mpuRead16(0x3D);
int16_t az = mpuRead16(0x3F);
int16_t gx = mpuRead16(0x43);
int16_t gy = mpuRead16(0x45);
int16_t gz = mpuRead16(0x47);
Serial.print("accel ");
Serial.print(ax); Serial.print(",");
Serial.print(ay); Serial.print(",");
Serial.print(az);
Serial.print(" gyro ");
Serial.print(gx); Serial.print(",");
Serial.print(gy); Serial.print(",");
Serial.println(gz);
delay(100);
}
⚠️ 검증 필요: 이 스케치는 저자가 실물 보드·실물 MPU6050으로 검증하지 못했다. I2C 레지스터맵 자체는 MPU6050의 공개 데이터시트에 근거하지만, Wire(A4/A5)가 위 이슈로 막혀 있을 경우 Wire1로 교체해야 하며, 이 조사 시점 기준 코어 버전에 따라 결과가 달라질 수 있다. Qwiic 생태계 커넥터(QWIIC1, I2C4, PD13/PD12)를 통한 연결은 10.3에서 더 다룬다.
4.3.7 센서 ③ — SPI로 외부 ADC 읽기(MCP3008)
MCP3008은 8채널 10비트 SPI ADC로, A0/A1(direct ADC, 5V 비허용)에 여러 아날로그 신호를 안전하게 확장해 연결하고 싶을 때 흔히 쓰인다. 4.3.1 핀맵의 D10~D13(SPI2_SS/MOSI/MISO/SCK)이 그대로 UNO Q의 두 번째 SPI 버스이므로, 표준 SPI 라이브러리로 접근한다.
#include <SPI.h>
const int CS_PIN = 10; // ~D10 (SPI2_SS) — MCP3008의 CS/SHDN
uint16_t readMcp3008(uint8_t channel) {
// 시작비트(1) + SGL/DIFF(1, 싱글엔디드=1) + 채널번호(3비트) + 패딩
uint8_t cmd = 0x18 | (channel & 0x07); // 0b0001 1ccc
digitalWrite(CS_PIN, LOW);
SPI.transfer(0x01); // 시작비트
uint8_t hi = SPI.transfer(cmd << 4); // 채널 선택, 상위 비트 수신
uint8_t lo = SPI.transfer(0x00); // 하위 8비트 수신
digitalWrite(CS_PIN, HIGH);
return ((hi & 0x03) << 8) | lo; // 10비트 결과
}
void setup() {
Serial.begin(115200);
pinMode(CS_PIN, OUTPUT);
digitalWrite(CS_PIN, HIGH);
SPI.begin();
SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0)); // MCP3008: 최대 ~1.35MHz(VDD=3.3V 기준)
}
void loop() {
uint16_t raw = readMcp3008(0); // CH0
float voltage = raw * (3.3 / 1023.0);
Serial.print("CH0 raw=");
Serial.print(raw);
Serial.print(" V=");
Serial.println(voltage, 3);
delay(200);
}
배선은 D13(SCK)→MCP3008 CLK, D11(MOSI)→DIN, D12(MISO)→DOUT, D10(SS, 소프트웨어 CS)→CS/SHDN, MCP3008의 VDD·VREF는 UNO Q의 3.3V(5V 아님 — MCP3008 자체는 5V도 지원하지만, 그러면 DOUT이 5V 로직이 되어 UNO Q MISO 핀에 그대로 물릴 수 없다)에 연결한다. SPISettings의 클럭은 MCP3008 데이터시트가 명시한 VDD별 최대값(3.3V일 때 약 1.35MHz)을 넘지 않게 여유 있게 설정했다.
⚠️ 검증 필요: 이 스케치는 저자가 실물 보드·실물 MCP3008로 검증하지 못했다. SPI 프로토콜과 MCP3008 통신 프레임 자체는 공개 데이터시트에 근거하지만, Zephyr 기반 Arduino Core의 SPI 라이브러리 구현이 이 타이밍·모드 조합을 그대로 지원하는지는 이번 조사에서 UNO Q 전용 검증 사례를 찾지 못했다. 또한 4.3.3이 이미 경고한 PWM-타이머 공유 문제가 이 배선에도 그대로 적용된다 — D12(SPI2_MISO)·D13(SPI2_SCK)은 ~D11(SPI2_MOSI)과 같은 TIM1 그룹이라, 같은 프로젝트에서 ~D11을 PWM 출력으로도 쓰면 이 MCP3008 배선의 MISO/SCK가 멈출 수 있다. 이 예제처럼 D10~D13을 SPI 전용으로만 쓰고 PWM에는 다른 타이머 그룹(TIM3·TIM4 등, 4.3.1)의 핀을 쓰면 이 충돌을 피한다.
4.3.8 엣지케이스 정리
| 상황 | 증상 | 대응 |
|---|---|---|
| 서드파티 라이브러리 설치는 되는데 동작 안 함 | AVR 전용 라이브러리가 아키텍처 태그를 *로 잘못 선언 | GitHub 이슈에서 "UNO Q"·"zephyr" 검색, 안 되면 표준 API로 직접 구현(4.1.5.1) |
A4/A5(Wire)로 I2C 장치가 안 잡힘 | Issue #301 — 표준 I2C3 핀 디바이스 트리 오버레이 문제 | Qwiic(Wire1, I2C4)로 배선 변경 시도 |
| PWM 핀 하나를 켜니 다른 디지털 핀이 멈춤 | 같은 STM32 타이머를 공유하는 핀들의 충돌(코어 0.55.2 미만) | 보드 패키지 최신화, 또는 analogWrite() 직전 pinMode() 재호출 |
| A0/A1에 5V 신호를 연결해 오작동·손상 우려 | ADC 직결 핀은 5V 비허용(절대 최대 ≈3.6V) | 레벨 시프터 사용, 또는 5V-허용 디지털 핀으로 배선 변경 |
| DHT11/22처럼 마이크로초 단위 타이밍에 민감한 프로토콜 | Zephyr 스케줄러의 지터가 AVR 폴링 루프와 다를 수 있음(검증 필요) | 이 책은 DHT 계열 예제를 검증하지 못해 신지 않았다 — I2C 기반 온습도 센서(SHT31, BME280 등)가 타이밍 부담이 없어 더 안전한 대안이다 |
교차참조: 2.2(핀아웃·커넥터 하드웨어 상세) · 3.2(Zephyr 기반 Arduino Core) · 4.1(첫 스케치, 라이브러리 호환성) · 4.2(Bridge로 MPU와 연동) · 8.1~8.2(실시간성·타이밍 크리티컬 설계) · 10.3(Qwiic 생태계)