
교육용 Arduino 자동분류 키트에서 블루투스와 스마트폰 앱을 떼어내고, 산업용 터치 HMI(Senvas Touch)와 Modbus RTU 유선 통신으로 새로 연결했습니다. 보드는 UNO에서 Mega 2560으로 바꿨고, 원본의 delay() 직렬 시퀀스를 millis() 기반 8단계 상태머신으로 전면 재설계했습니다. 통신 계약은 코드보다 먼저 Holding Register 표(40001~40101)로 확정했습니다. 결과적으로 HMI 한 화면에서 시작/정지/초기화, 공정 단계 애니메이션, R/G/B 분류 누적 수량을 실시간으로 볼 수 있게 됐습니다.
원본 블루투스 키트와 그 한계
교육용 아두이노 자동분류 키트의 원본은 15_conveyer_belt_bluetooth.ino 한 파일에 모든 로직이 들어 있는 전형적인 스케치입니다. 컨베이어 벨트, IR 센서, TCS34725 컬러 센서, 서보 분류기, NeoPixel LED, HC-05 블루투스가 한데 묶여 있고, 앱인벤터로 만든 스마트폰 앱(smart_factory_advancedUI.aia)과 문자 명령(s, 0, 1, r/g/b)을 주고받는 구조입니다.
시연하기엔 충분합니다. 뭐가 돌아가는지 눈에 보이고, 폰으로 제어하는 경험이 직관적입니다. 그런데 “라인 옆에 붙어있는 진짜 HMI처럼 쓰고 싶다”는 생각이 들 때부터 한계가 눈에 띄기 시작합니다.
- 폰을 들고 있어야 합니다. 패널에 고정된 HMI가 아닙니다.
- 누적 분류 수, 공정 단계 진척 같은 정보가 없습니다. 지금 몇 단계인지, R/G/B 각각 얼마나 쌓였는지 알 방법이 없습니다.
- 통신 끊김이나 장치 오류 같은 상태 구분이 없습니다. 블루투스가 끊기면 그냥 아무 반응이 없어집니다.
- 1 페어링이라 두 사람이 동시에 같은 화면을 볼 수 없습니다.
이 네 가지가 모여서 “산업 HMI 같지 않은” 느낌의 실체입니다.
Senvas Touch + Modbus RTU로 바꾸기로
Senvas Touch는 산업용 터치 HMI로, Modbus RTU 마스터 역할을 합니다. HMI 측에서 주기적으로 레지스터를 읽고 쓰는 방식입니다(구체적인 내부 구현은 여기서 다루지 않습니다 — “표대로 읽고 쓴다”는 것이 전부입니다).
Modbus RTU를 고른 이유는 단순합니다. 산업 현장의 공용어이고, 유선이라 신뢰성과 결정성이 무선보다 위입니다. Slave ID와 레지스터 주소로 다대다 확장도 가능합니다(이번엔 1
). 블루투스는 페어링 관리, 재연결 지연, 음영 지역 문제가 있습니다. 유선으로 바꾸면 그 고민이 통째로 사라집니다.
변경 후 구성은 위 블록도처럼 Senvas Touch → USB-to-TTL 컨버터 → Mega Serial1(RX1=핀19, TX1=핀18) → 센서/액추에이터입니다. USB-to-TTL 컨버터가 HMI의 RS-485 신호를 UART 레벨로 바꿔 Mega에 연결합니다.
설계 문서부터: Holding Register 계약
Holding Register 맵 설계는 코드를 열기 전에 design.md와 arduino-modbus-rtu-protocol.md 두 문서로 먼저 확정했습니다. 통신이란 결국 “이 주소에 이 값이 들어 있다”는 계약입니다. 양쪽이 같은 표를 보고 각자 구현하면 됩니다.

| 주소 | 0-base | 방향 | 의미 | 값 |
|---|---|---|---|---|
| 40001 | 0 | Arduino → HMI | 운전 상태 | 0=정지, 1=운전, 99=장치 오류 |
| 40002 | 1 | Arduino → HMI | 공정 단계 | 0=대기 … 7=정지, 99=오류 |
| 40003 | 2 | Arduino → HMI | 최근 색상 | 0=없음, 1=빨강, 2=초록, 3=파랑 |
| 40021~40023 | 20~22 | Arduino → HMI | R/G/B 누적 카운트 | 정수 |
| 40101 | 100 | HMI → Arduino | 순간 명령 | 0=없음, 1=시작, 2=정지, 3=초기화 |
펌웨어에서 이 주소들을 상수로 박아뒀습니다. 이렇게 하면 나중에 표를 수정해도 숫자 하나만 바꾸면 전파됩니다.
const uint16_t REG_RUN_STATE = 0; // 40001 운전 상태
const uint16_t REG_STAGE = 1; // 40002 공정 단계
const uint16_t REG_COLOR = 2; // 40003 최근 색상
const uint16_t REG_RED_COUNT = 20; // 40021
const uint16_t REG_GREEN_COUNT = 21; // 40022
const uint16_t REG_BLUE_COUNT = 22; // 40023
const uint16_t REG_COMMAND = 100; // 40101 HMI→Arduino 순간 명령
40101 명령 레지스터에는 규칙이 하나 있습니다. Arduino가 명령을 실행한 직후 반드시 0으로 되돌려야 합니다. HMI는 레지스터를 주기적으로 쓰는 구조가 아니라 버튼을 누를 때만 값을 넣습니다. 만약 Arduino가 0으로 되돌리지 않으면 다음 폴링 사이클에서 같은 명령이 다시 처리될 수 있습니다. “한 번만 처리” 보장이 이 한 줄에 달려 있습니다.
void handleCommand() {
uint16_t cmd = holdingRegisters[REG_COMMAND];
if (cmd == CMD_NONE) return;
switch (cmd) {
case CMD_START: if (runState != RUN_ERROR) startRunning(); break;
case CMD_STOP: if (runState != RUN_ERROR) stopRunning(); break;
case CMD_RESET: redCount = greenCount = blueCount = 0; break;
}
// 순간 명령은 반드시 즉시 0 으로 (한 번만 처리 보장)
holdingRegisters[REG_COMMAND] = CMD_NONE;
}
통신이 끊기면 HMI는 모든 명령 버튼을 비활성화합니다. 아래 화면이 그 상태입니다. 마지막으로 수신한 카운트 값은 carry-over로 표시되지만 조작 자체가 막힙니다.

연결 설정은 별도 화면에서 합니다. COM 포트, Baud rate, Slave ID, 패리티/스톱 비트를 잡고 저장하면 됩니다.

하드웨어 결정: UNO → Mega 2560
아두이노 Modbus RTU 슬레이브를 처음엔 UNO에서 그냥 SoftwareSerial로 돌리면 되지 않을까 생각했습니다. 해보니까 안 됩니다. 이유는 세 가지가 동시에 필요했기 때문입니다.
- Modbus RTU 시리얼 — 115200 baud, 안정적인 하드웨어 UART 필수. SoftwareSerial은 높은 baud rate에서 오류율이 높고, 다른 인터럽트와 충돌합니다.
- USB 시리얼 — 디버그 출력용. Modbus와 같은 포트를 쓰면 통신이 섞입니다.
- I2C — TCS34725 컬러 센서 연결용.
UNO는 하드웨어 UART가 하나뿐이라 Modbus와 USB 디버그를 동시에 살릴 수 없습니다. Mega 2560은 Serial(USB), Serial1(핀18/19), Serial2(핀16/17), Serial3(핀14/15) 네 개를 분리해서 쓸 수 있고, I2C도 디지털 핀 20/21에 따로 배치돼 있습니다.
핀 배치를 정리하면:
- Modbus(Serial1): TX=핀18, RX=핀19
- I2C(TCS34725): SDA=핀20, SCL=핀21
- DC 방향=13, DC 속도(PWM)=11, 서보=9, NeoPixel=5, 부저=4, IR=A0
Mega로 바꾸고 나서 핀 충돌 걱정이 사라졌습니다.
펌웨어 핵심: delay() 다 들어내고 상태머신으로
delay 대신 상태머신으로 옮기기 전, 원본 코드의 흐름은 delay(2000) → delay(1500) → delay(1500) → delay(1000) 이었습니다. 컨베이어가 이동하고, IR이 감지하고, 컬러 센서가 읽고, 서보가 분류하는 각 단계를 블로킹으로 기다리는 구조입니다.
교육용으로는 읽기 쉽습니다. Modbus 슬레이브에서는 치명적입니다. delay(2000) 동안 loop()가 멈추니까 Modbus 폴링도 멈춥니다. HMI가 연속 몇 번 읽기에 실패하면 “통신 끊김”으로 판단합니다. 결과적으로 동작은 하는데 HMI는 계속 연결 실패를 표시하는 상황이 됩니다.
해결책은 delay()를 전부 들어내고 millis() 기반 상태머신으로 바꾸는 것입니다. loop() 한 사이클이 수 ms 이내에 끝나야 합니다. 시간이 필요한 작업은 “이 단계에 진입한 시각”을 기록해 두고, 다음 사이클에서 경과 시간을 비교해 다음 단계로 전이합니다.

단계를 8개로 쪼갰습니다.
START 명령(40101=1)
│
▼
[0 대기] ──시작──▶ [1 컨베이어 이동] ──IR감지──▶ [2 IR정지(2s)]
│
▼
[3 컬러센서 이동(저속)] ◀──┘
│ sum ≥ 20
▼
[4 색상 측정(1.5s)]
│
▼
[5 서보 분류(1.5s)]
│
▼
[6 배출 재가동(1s)] ──▶ 다시 [1]
STOP 명령(40101=2) → [7 정지]
RESET 명령(40101=3) → 카운터만 0으로 (단계 유지)
상태머신의 한 단계를 들여다보면 이렇게 생겼습니다. 센서 값을 읽어서 임계값을 넘으면 다음 단계로 전이합니다. 이 코드가 실행되는 시간은 마이크로초 단위입니다.
case STAGE_TO_COLOR: {
uint16_t rawR, rawG, rawB, rawC;
tcs.getRawData(&rawR, &rawG, &rawB, &rawC);
int r = map(rawR, 0, 21504, 0, 1000);
int g = map(rawG, 0, 21504, 0, 1000);
int b = map(rawB, 0, 21504, 0, 1000);
if (r + g + b >= COLOR_SUM_THRESHOLD) {
railStop();
if (r > g && r > b) lastColor = COLOR_RED;
else if (g > r && g > b) lastColor = COLOR_GREEN;
else lastColor = COLOR_BLUE;
enterStage(STAGE_COLOR_MEASURE);
}
break;
}
상태머신으로 바꾸고 나서 loop() 안에서 Modbus 폴링과 공정 로직이 경쟁하지 않습니다. 폴링이 밀리는 일도 없습니다.
Modbus RTU 통신 디버깅 3컷
Modbus RTU 통신 디버깅이 이 작업에서 가장 오래 걸린 부분입니다. 기록해 두는 게 나중에 가장 값어치가 있을 것 같습니다.
컷 ①: 라이브러리는 설치했는데 IDE가 못 찾는다
증상은 깔끔했습니다. Adafruit_TCS34725.h: No such file or directory. 분명히 라이브러리 매니저에서 설치했는데 빌드가 안 됩니다.
원인은 경로 불일치였습니다. arduino-cli가 기본 경로(OS가 sketchbook 경로를 자동 동기화 폴더에 넣어둔 곳)에 라이브러리를 설치했는데, Arduino IDE의 sketchbook은 다른 드라이브의 별도 폴더로 잡혀 있었습니다. IDE는 자기 sketchbook 폴더만 뒤집니다.
arduino-cli config dump를 실행해보면(또는 ~/.arduinoIDE/arduino-cli.yaml의 directories.user 항목을 확인하면) 실제 설치 경로를 볼 수 있습니다. 두 경로가 다르면 라이브러리가 “설치는 됐는데 안 보이는” 상태가 됩니다. 해결책은 IDE의 sketchbook 경로를 바꾸거나, 해당 폴더에 직접 라이브러리를 복사해 넣는 것입니다.
컷 ②: 통신이 완전 침묵이다 — 라이브러리 v3.x 의 함정

이게 제일 오래 걸렸습니다. 빌드도 됐고 업로드도 됐습니다. Senvas는 “통신 끊김”만 표시했습니다. 배선을 세 번 확인했습니다. Baud rate도 맞췄습니다. Slave ID도 1로 맞췄습니다. 여전히 침묵.
결국 라이브러리 소스를 직접 열어봤습니다. CMB27의 ModbusRTUSlave v3.x는 modbus.begin()이 시리얼 포트를 자동 시작하지 않습니다. 라이브러리 생성자가 Stream& 참조만 받아서 다형적으로 serial.begin()을 호출할 방법이 없습니다. 사용자가 반드시 먼저 Serial1.begin()을 직접 불러줘야 합니다.
라이브러리 공식 예제(ModbusRTUSlaveExample.ino)를 보면 83~84번째 줄이 명확하게 두 줄로 분리돼 있습니다.
// ⚠️ v3.x 의 함정: 이 한 줄을 빼면 라인이 완전 침묵이다.
Serial1.begin(MODBUS_BAUD, SERIAL_8N1);
modbus.configureHoldingRegisters(holdingRegisters, NUM_HOLDING_REGISTERS);
modbus.begin(MODBUS_SLAVE_ID, MODBUS_BAUD, SERIAL_8N1);
v2에서 v3로 넘어오면서 API가 살짝 바뀐 것입니다. 기억으로 쓴 코드가 이런 변화를 잡아내지 못합니다. 라이브러리 메이저 버전이 바뀌면 공식 예제부터 다시 보는 게 맞습니다.
컷 ③: 통신은 되는데 동작 안 함 → 진단 로그를 깔다
컷 ②를 해결하고 나니 이번엔 다른 증상이 나왔습니다. Senvas가 운전 상태와 카운트를 읽기는 하는데 START 버튼을 눌러도 모터가 안 돕니다.
추측을 시작하기 전에 USB Serial에 진단 로그를 깔았습니다. Mega는 Serial(USB)과 Serial1(Modbus)이 완전히 분리돼 있어서 로그 출력이 Modbus 통신에 영향을 주지 않습니다.
#define DEBUG_USB_SERIAL 1 // 0 으로 두면 컴파일에서 다 사라짐
void serviceHeartbeat() {
DBG(F("[HB] run=")); DBG(runState);
DBG(F(" stage=")); DBG(stage);
DBG(F(" cmd=")); DBG(holdingRegisters[REG_COMMAND]);
DBG(F(" R/G/B=")); DBG(redCount);
DBG(F("/")); DBG(greenCount);
DBG(F("/")); DBG(blueCount);
DBG(F(" IR=")); DBGLN(digitalRead(PIN_IR));
}
Serial Monitor에 이런 출력이 흘렀습니다.
[BOOT] 16_conveyer_belt_modbus 시작
[BOOT] TCS34725 OK (I2C SDA=20 SCL=21)
[BOOT] Modbus RTU Slave id=1 baud=115200 8N1 on Serial1 (Mega: 핀19=RX1, 핀18=TX1)
[HB] run=0 stage=0 color=0 cmd=0 R/G/B=0/0/0 IR=1
[CMD] received=1 runState=0
[STAGE] 0 -> 1
[CMD] received=1이 찍혔습니다. 명령은 들어오고 있었습니다. 그런데 [STAGE] 전이 로그가 안 찍혔습니다. 문제는 명령 처리 로직이 아니라 startRunning() 안의 컬러 센서 초기화에서 I2C가 응답하지 않는 것이었습니다.
여기서 추가로 확인한 것이 있습니다. I2C 핀이 보드마다 다릅니다. UNO는 A4/A5, Leonardo는 2/3, Mega는 20/21입니다. 보드를 Mega로 바꿨는데 배선은 그대로 A4/A5에 꽂혀 있었습니다. 핀을 20/21로 옮기자 컬러 센서가 정상 응답했습니다.
“디버깅의 80%는 어디서 끊겼는지 알아내는 것”이라는 말이 있습니다. 하트비트 로그 한 줄이 30분의 추측을 줄였습니다.
Modbus RTU 자동분류 라인 정리·다음 단계
Modbus RTU 자동분류 라인은 지금 시작/정지/초기화, 공정 단계 8개, R/G/B 분류 누적 표시가 모두 정상 동작합니다. 모터 PWM을 50/40으로 천천히 설정해서 시연에 맞췄습니다(PWM 데드존 아래로 내려가면 모터가 안 도는 구간이 있으니 주의).
이 작업에서 얻은 것을 한 줄로 요약하면, 교육용 키트를 산업용 HMI에 붙이는 건 통신 방식을 바꾸는 것만이 아니었습니다. 시간을 다루는 방식(delay → 상태머신)과 디버깅 도구(USB 로그 + 라이브러리 소스 읽기)까지 같이 바꿔야 했습니다.
다음에 해보고 싶은 것들:
- 카운트 EEPROM 저장 — 전원이 꺼졌다 켜져도 R/G/B 누적값을 유지
- 컬러 임계값 캘리브레이션을 HMI에서 — 지금은 코드에 하드코딩된 값을 HMI 설정 화면에서 조정 가능하게
- 한 HMI에 여러 라인 — Slave ID를 분리해서 2번, 3번 라인을 같은 HMI에서 모니터링
- 알람 이력 별도 화면 — 장치 오류가 몇 번 발생했는지 기록
세 가지 디버깅 일화 중 v3.x 라이브러리 함정이 제일 허탈했습니다. 코드가 아무 문제없어 보이는데 완전 침묵인 상황. 라이브러리 소스를 직접 열어보기 전까지는 원인을 찾을 수가 없었습니다. 다음에도 비슷한 증상이 나오면 배선 확인보다 라이브러리 예제부터 열어볼 것 같습니다.
Modbus RTU 자동분류 라인의 핵심은 레지스터 계약을 코드보다 먼저 확정하고, delay 대신 상태머신으로 폴링을 막지 않는 것입니다.
사용 키트 — 이 프로젝트는 eduino 스마트 팩토리 키트로 제작했습니다.
문의
- Email : [email protected]
- Insta : https://www.instagram.com/going.sen/
- Website : https://intosen.com/kr/consult/
댓글
닉네임만 입력하면 바로 댓글을 남길 수 있어요. Google/GitHub 로그인도 가능합니다.