2륜 로봇을 ROS 2로 모는 코드를 준비하면서, 하드웨어 없이도 끝까지 검증하려고 가상 시리얼 포트를 만들었어요. 파이썬 pty로 포트 한 쌍을 열고 한쪽을 보드처럼 굴리면, 상위 노드는 그게 진짜 아두이노인지 구분하지 못해요.
명령이 프로토콜대로 나가는지, 회신이 오도메트리로 바뀌는지까지 전부 통과했는데 실물에 붙이자 네 군데서 무너졌어요.
pty) 페어

네 결함을 한눈에
책상 위에서 41미터를 후진
재연결이 한 번도 안 걸림
[시뮬(추측항법)]. 포트를 직접 열면 멀쩡해요가다 서다를 반복
값이 오는데 죽어 있던 자이로
[legacy · 실측 · 자이로]41미터를 날아간 좌표
첫 실행에서 로봇은 책상 위에서 조금 움직였는데, 화면의 좌표는 x-41.05를 가리키고 있었어요.
원인은 시리얼 포트를 여는 동작 자체에 있었어요. PC가 포트를 열면 DTR 신호가 아두이노를 리셋시켜요. 펌웨어가 들고 있던 누적 엔코더 카운터는 그 순간 0으로 돌아가는데, 리셋 직전에 버퍼로 흘러 들어온 마지막 값은 여전히 큰 수예요. 상위 노드는 두 값을 이어진 측정으로 보고 차이를 이동거리로 적분해요. 8,000틱쯤 차이가 나면 한 틱이 5.1밀리미터이니 41미터가 나와요.
부팅을 기다리는 것으로는 부족했어요. 2.5초를 기다리고 입력 버퍼를 비워도, 케이블이 흔들리거나 보드가 재시작하면 같은 점프가 다시 생겨요.
if abs(da) > jump or abs(db) > jump:
# 보드 리셋(카운터 0 복귀)이나 통신 유실. 이동이 아니다.
self.get_logger().warn(f'엔코더 점프 {da:+d}/{db:+d} 틱 — 보드 리셋으로 보고 무시합니다')
else:
dl += da * self.m_per_tick문턱값은 보고 주기에서 나와요. 0.1초마다 상태가 오고 바퀴가 아무리 빨라도 그 사이 40센티미터를 갈 수 없으니, 80틱을 넘는 변화는 측정이 아니라 사건이에요. 기준점만 새 값으로 옮기고 이동으로는 세지 않으면, 리셋이 나도 위치가 튀지 않고 그 자리에서 이어져요.
나머지 연산으로 주기를 재면
두 번째는 더 조용했어요. 이전 노드가 포트를 물고 있는 동안 새 노드가 떴고, 그 뒤로 자동 재연결이 한 번도 걸리지 않았어요.
if now % reconnect_s < 1.0 / RATE_HZ: # 2초마다 한 번… 이 될 줄 알았어요
self._connect()now는 초 단위 실수이고 틱은 20 Hz라 0.05초마다 돌아요. 이 식은 “2초 주기의 앞 0.05초 구간에 들어오면”이라는 뜻인데, 틱이 그 좁은 창에 정확히 떨어질 보장이 없어요. 부동소수점 간격이 조금씩 밀리면 창을 계속 건너뛰고, 결과적으로 재시도가 거의 일어나지 않아요.
현재 시각을 나머지 연산으로 쪼개는 대신 마지막 시도 시각을 저장하고 경과 시간을 비교하면, 틱이 언제 오든 2초가 지난 첫 틱에서 반드시 걸려요. 주기 판정에 나머지 연산을 쓰는 습관은 샘플링 간격과 만나면 조용히 실패해요.
이산 인터페이스 위에 연속 제어를 얹기
세 번째는 동작은 하는데 품질이 나쁜 경우였어요. 이 펌웨어는 연속 속도를 받지 않고 “몇 펄스만큼 가라”는 이산 명령만 받아요. 상위에서는 0.5초마다 지금 요청된 속도를 한 조각으로 환산해 보내고 있었어요.
조각의 길이를 주기보다 길게 잡는 것으로 바꿨어요. 0.25초마다 0.55초 분량을 보내면 이전 동작이 끝나기 전에 다음 명령이 도착해 목표가 갱신돼요. 펌웨어 입장에서는 목표 위치가 계속 앞으로 밀리는 셈이라 결과적으로 연속 주행처럼 이어져요.
값이 오는데 죽어 있던 자이로
마지막이 가장 까다로웠어요. 방위를 자이로에서 가져오도록 만들어 둔 게 발단이었어요. 엔코더로 계산한 회전은 바퀴가 미끄러지면 조용히 틀어지는데, 자이로 yaw를 그대로 쓰면 그 드리프트가 없어져요. 그런데 회전을 시켜도 각도는 −6도에서 더 늘지 않았어요.
[1] gyro z = 0.037515773181984735
[2] gyro z = 0.0375157731819847353초 간격으로 뜬 두 표본이 소수점 15자리까지 동일했어요. 자이로가 살아 있으면 로봇이 가만히 있어도 드리프트로 마지막 자리는 흔들려요. 완전히 고정된 값은 센서가 아니라 상수를 보고 있다는 뜻이에요. 펌웨어를 열어 보니 이렇게 되어 있었어요.
Serial.print(mpuOk ? yawDeg : 0.0, 1); // MPU를 못 읽어도 자리는 채워 보낸다I2C 읽기에 실패해도 텔레메트리 형식을 지키려고 0.0을 채워 보내고 있었어요. 상위 노드는 “yaw 필드가 왔다”를 “자이로가 살아 있다”로 해석했고, 그래서 죽은 센서를 계속 신뢰했어요.
고친 방식은 센서를 믿는 대신 행동과 대조하는 것이에요. 회전 명령을 보낸 뒤 1.5초가 지났는데 yaw가 5도도 안 변했다면, 값이 오더라도 그 센서는 지금 회전을 재지 못하고 있어요.
if moved < 5.0:
self.gyro_ok = False
self.get_logger().warn(
f'자이로가 회전을 따라오지 않습니다 (1.5초 동안 {moved:.1f}°, '
f'펌웨어 I2C 실패 {self.gyro_fail_count}회) — 엔코더 방위로 전환합니다')펌웨어가 텔레메트리 끝에 붙여 보내는 I2C 실패 누적값도 함께 읽어 경고에 담았어요. 자이로 배선 문제인지 아닌지가 로그 한 줄로 드러나게 하려는 거예요. 이번 촬영은 방위 계산을 엔코더로 고정해 두고 진행했어요.
네 결함의 공통점
넷 다 가상 포트에서는 재현되지 않았어요. 흉내 낸 보드는 리셋되지 않고, 포트를 다투지 않으며, 명령을 즉시 소화하고, 센서가 죽는 법이 없어요.
| 결함 | 가상 포트에서 | 실물에서 | 무너진 층 |
|---|---|---|---|
| 엔코더 점프 | 리셋이 없음 | DTR이 보드를 리셋 | 입력의 연속성 |
| 재연결 미동작 | 포트 경합 없음 | 이전 노드가 점유 | 타이밍 판정 |
| 가다 서다 | 즉시 소화 | 0.11초에 끝남 | 인터페이스 임피던스 |
| 죽은 자이로 | 센서가 안 죽음 | I2C 실패를 0.0으로 채움 | 값의 유효성 |
시뮬레이터는 정상 경로를 흉내 내지 비정상 경로까지 흉내 내지는 않아요. 계산이 맞는지는 시뮬로 확인할 수 있어요. 실제로 41미터를 날아간 것은 계산이 틀려서가 아니라 입력이 이동이 아니었기 때문이고, 자이로 문제는 계산 이전에 측정이 틀린 경우였어요. 값이 도착한다는 사실만으로 그 값을 신뢰하면, 아래층에서 무너진 것을 위층에서 찾게 돼요.
같은 로봇에서 앞서 겪은 시리얼 문제는 부분 수신과 개루프 서보 부팅 점프 — 시리얼 텔레옵의 함정에, 실차와 시각화를 같이 모는 구조는 실물과 RViz를 한 명령으로 모는 디지털 트윈과 간이 라이다 실측에 정리해 뒀어요. 같은 날 로봇팔 쪽에서 만난 문제는 URDF 파싱으로 관절 정보를 읽는 UI와 기구학에서 치른 대가에 이어져요.