들어가며
2편에서 ADC raw 우회로 동결을 잡았다. 이제 LC1 채널은 멀쩡하다. 그런데 LC2를 연결하니 다른 종류의 문제가 나왔다.
증상: LC2 채널만 폴 실패가 버스트로 쏟아진다. 한 번 실패하기 시작하면 최대 1.2초씩 연속으로 실패한다. LC1은 같은 펌웨어, 같은 모듈인데 멀쩡하다.
감으로 때려잡기 전에 일단 숫자부터 만들었다. 60초 벤치를 돌리면서 채널별로 폴 결과를 분류했다.
| 채널 | 정상 | 오정렬 | 무응답 |
|---|---|---|---|
| LC1 | 81% | 2% | 17% |
| LC2 | 22% | 46% | 32% |

LC2는 폴 5번 중 4번이 실패하고 있었다. 그중 절반 가까이가 오정렬 — 바이트는 왔는데 헤더 위치가 틀렸거나 체크섬이 깨진 경우다. 같은 펌웨어에서 채널별로 이렇게 갈린다면, 차이는 코드가 아니라 그 바깥에 있다.
근본 원인 — 배선이 다르다
두 채널의 신호 경로를 따라가 봤다.
LC1: P7001-2 (RS-232) ── SP3232 트랜시버 ── STM32 USART1 ← 정상 경로
LC2: P7001-2 (RS-232) ──────── 직결 ─────── STM32 USART2 ← 문제
LC1은 RS-232↔TTL 트랜시버(SP3232)를 경유한다. 그런데 LC2는 보드의 블루투스 모듈용 커넥터 자리를 재활용해서, 트랜시버 없이 모듈의 RS-232 출력을 3.3V TTL 핀에 직결해놨다.
RS-232와 TTL UART는 전압 레벨도, 마진 규격도 다른 물건이다. 직결하면 STM32 입력단의 보호 다이오드가 규격 밖 전압을 클램핑하면서 신호가 어찌어찌 들어오긴 하는데, 레벨 마진이 사실상 없는 상태라 경계 조건에서 비트가 간헐적으로 깨진다. “가끔만 깨지는” 게 오히려 이 문제의 전형적인 얼굴이다. 아예 안 되면 배선부터 의심했을 텐데, 대충 동작하니까 소프트웨어를 먼저 의심하게 된다.
증폭 메커니즘 — 한 바이트가 600ms 장애가 되기까지
그런데 통계를 다시 보면 이상한 점이 있다. 비트 깨짐이 간헐적이라면 실패도 드문드문 발생해야 하는데, 실제로는 600ms 이상 가는 연속 실패 버스트로 나타났다. HW 결함 1회가 SW에서 수십 배로 증폭되고 있다는 뜻이다.
타임라인을 따라가 보면 메커니즘이 보인다. 프레임 하나가 지각 도착하면 응답 타임아웃(90ms)이 난다. 여기까진 1회 실패다. 문제는 그 다음이다. 뒤늦게 도착한 잔여 바이트(6B 프레임, 9600bps에서 약 6.25ms)가 라인과 버퍼에 남아 있다가 다음 응답 앞에 걸쳐 붙는다(straddle). 그러면 헤더 0xB0이 버퍼 선두가 아닌 1번, 2번 인덱스로 밀리고, 선두 바이트를 헤더로 검사하는 파서는 헤더 불일치/체크섬 실패를 연발한다. 매 폴마다 어긋난 채로 수신이 이어지니, 한 번 꼬이면 우연히 경계가 다시 맞을 때까지 600ms 이상 실패가 이어진다.

정상 입력만 가정한 파서는 이렇게 동작한다. 깨진 바이트 자체는 HW 탓이지만, 그걸 수십 배로 키운 건 SW다.
해결 — 파서에 자기복구를 넣는다
근본 수리는 배선이다(뒤에서 다시 말한다). 하지만 펌웨어도 깨진 입력에서 스스로 복구할 수 있어야 한다. 두 가지 메커니즘을 넣었다.
1. 헤더 헌트 — 0xB0 동기식 수신
수신 누적 단계에서 버퍼의 첫 바이트가 0xB0이 아니면 그냥 버린다.
// 수신 누적: 첫 바이트가 0xB0이 될 때까지 폐기
static void lc_accumulate(lc_channel_t *ch, uint8_t byte)
{
if (ch->rx_len == 0 && byte != 0xB0)
return; // 헤더 동기 전 바이트 폐기
if (ch->rx_len < sizeof(ch->rx_buf))
ch->rx_buf[ch->rx_len++] = byte;
}
straddle로 밀린 머리가 자동으로 버려지고, 다음 0xB0에서 프레임 경계가 재정렬된다. “페이로드 안에 우연히 0xB0이 있으면 오동기 아니냐"는 걱정이 들 수 있는데, 그 경우는 체크섬이 걸러준다. 헤더 동기는 1차 필터, 체크섬은 2차 필터로 역할을 나누는 defense-in-depth다.
2. FLUSH 상태 — 타임아웃 후 라인 배수
헤더 헌트만으로는 부족하다. 타임아웃 직후 바로 다음 요청을 보내면, 그 사이 지각 도착하는 꼬리가 또 straddle을 만든다. 그래서 상태머신에 FLUSH 상태를 추가했다. 타임아웃이 나면 바로 다음 폴로 가지 않고, 라인이 조용해질 때까지 잔여 바이트를 배수한 뒤에 넘어간다.
typedef enum {
LC_IDLE,
LC_WAIT_RESP,
LC_FLUSH, // 타임아웃 후 라인 정숙 대기
} lc_state_t;
case LC_FLUSH:
if (uart_rx_available(huart)) {
uart_discard_rx(huart);
ch->t_last_rx = HAL_GetTick(); // 바이트가 오는 동안 계속 갱신
}
// 3ms 정숙이 확인되거나, 상한 20ms를 넘기면 복귀
if (HAL_GetTick() - ch->t_last_rx >= FLUSH_IDLE_MS ||
HAL_GetTick() - ch->t_flush_in >= FLUSH_MAX_MS) {
ch->state = LC_IDLE;
}
break;
정숙 판정 3ms는 9600bps에서 약 3바이트 시간이다. 상한 20ms는 라인이 계속 시끄러운 비정상 상황에서 폴링 자체가 굶지 않게 하는 안전장치다. straddle의 재료가 되는 잔여 바이트를 원천에서 치우는 셈이다.
검증
수정 후 같은 60초 벤치를 다시 돌렸다.
- 오정렬: raw 프레임 기준 4.4% → 0.0%. 완전 제거.
- 한 번 실패가 다음 폴로 번지는 버스트도 사라졌다. 실패는 발생한 폴 1회로 격리된다.
다만 정직하게 기록해야 할 게 있다. 무응답은 남아 있다. 모듈이 아예 답을 안 하는 경우는 펌웨어가 어떻게 할 수 있는 영역이 아니다. 레벨 마진이 없는 배선에서 프레임 전체가 유실되는 HW 잔존 결함이고, 진짜 수리는 트랜시버를 경유하도록 배선을 고치는 것이다(보드 개정 사항으로 등재).
“고쳤다” 한 줄로 덮으면 나중에 반드시 다시 만난다. 오정렬은 제거, 무응답은 HW 잔존 — 무엇이 사라졌고 무엇이 남았는지 구분해서 기록하는 게 펌웨어 완화와 HW 수리를 섞지 않는 방법이다. 이 잔존 무응답이 다음 편의 안전 감시 설계에서 다시 등장한다.
정리
- 같은 코드인데 채널별로 증상이 갈리면, 원인은 코드 바깥에 있다. 통계를 먼저 만들고(81/2/17 vs 22/46/32), 신호 경로를 따라가니 트랜시버 유무가 나왔다.
- 프로토콜 파서는 “깨진 입력에서 어떻게 복구하는가"까지가 설계다. 정상 입력만 가정한 파서는 한 바이트 깨짐을 수백 ms 장애로 증폭시킨다. 헤더 동기 + 타임아웃 후 라인 배수, 두 메커니즘으로 오정렬을 0%로 만들었다.
- 펌웨어 완화와 HW 수리를 구분해서 기록하라. 오정렬은 제거됐지만 무응답은 배선을 고쳐야 사라진다.
다음 편이 마지막이다. 힘 데이터가 끊기면 비상정지를 거는 신선도 감시를 도입했다가, 이 편의 잔존 무응답이 만드는 오탐 때문에 도로 비활성화한 이야기. “안전 기능을 끈다"는 결정이 어떻게 정당한 엔지니어링이 되는지, 그리고 캘리브레이션을 flash에 영속화하면서 배운 원자성 이야기까지 정리하고 시리즈를 닫는다.