들어가며

시리즈 마지막 편이다. 2편에서 “살아있는 척"하는 동결을 봤고, 3편에서는 펌웨어로 못 고치는 무응답이 HW 잔존 결함으로 남았다. 이번 편은 그 위에 안전 계층을 세우고, 캘리브레이션을 정착시키는 이야기다.

결론부터 말하면, 만들었던 안전 감시 하나를 도로 껐다. 그 결정이 왜 정당한 엔지니어링인지가 이 편의 핵심이다.

FORCE_STALE — 신선도 감시의 도입

힘 데이터는 과부하 E-STOP의 입력이다. 그런데 이 시리즈에서 봤듯 힘 데이터는 여러 방식으로 죽을 수 있다. 모듈이 옛 값을 반복할 수도(2편), 프레임이 유실될 수도(3편), CAN 경로가 끊길 수도 있다. 입력이 죽은 줄 모르고 돌아가는 안전 기능은 안전 기능이 아니다.

그래서 마스터 쪽에 힘 데이터 신선도 감시(FORCE_STALE)를 넣었다. 설계는 단순하다. 힘 프레임(20Hz)이 마지막으로 갱신된 뒤 경과 시간을 재고, stale 임계 300ms를 넘기면 E-STOP을 건다. 감시 tick이 50ms라 최악 지연은 350ms 부근이다.

CAN 커넥터를 실제로 뽑아서 검증했다. 분리 후 약 365ms에 트립. 임계 300ms에 감시 tick 한 주기가 얹힌, 산수대로의 결과다.

힘 데이터 두절 시 두 감시 계층의 반응 속도

이 365ms가 의미 있는 이유는 비교 대상 때문이다. CANopen 노드 생존 감시(heartbeat)는 2Hz HB에 여유를 둔 3,000ms 타임아웃이라, 노드가 죽었다는 판정까지 3초가 걸린다. FORCE_STALE은 약 8배 빠르다. 노드 생존과 데이터 신선도는 다른 층위의 문제고, 안전 입력에는 후자의 감시가 따로 필요하다는 게 2편 교훈의 구현이었다.

같은 시기에 과부하 E-STOP(임계 150N)도 함께 하드웨어 검증을 마쳤다. 분동과 수동 가압으로 임계를 넘기면 즉시 정지. 이쪽은 문제없이 통과했다.

그리고 은퇴 — 오탐도 위험이다

여기까지는 교과서적이다. 문제는 현장 조건이다.

3편에서 LC2 채널의 무응답이 HW 잔존 결함으로 남아 있다고 했다. 트랜시버 없는 직결 배선에서 프레임이 통째로 유실되는 경우인데, 펌웨어 자기복구로 오정렬은 0%가 됐지만 무응답은 배선을 고치기 전까지 사라지지 않는다. 이 무응답이 길게 이어지면 FORCE_STALE 입장에서는 데이터 두절과 구분이 안 된다.

벤치에서 측정한 오탐률이 0.26회/s였다. 몇 초에 한 번꼴로, 아무 문제 없이 동작 중인 장비가 갑자기 비상정지할 수 있다는 뜻이다.

이제 저울에 올릴 두 위험이 생겼다.

  • 감시를 켜둘 때: 동작 중 갑작스러운 오탐 정지. 정지 자체가 사용자에게 물리적 위험이 될 수 있고, 반복되면 운용자가 E-STOP을 신뢰하지 않게 된다
  • 감시를 껐을 때: 실제 데이터 상실을 350ms에 못 잡고 heartbeat의 3초까지 기다리게 됨

분석 결과는 전자가 더 무거웠다. 그래서 FORCE_STALE은 비활성으로 결정했다. 단, 통째로 들어내는 게 아니다.

  • 감시 로직과 stale 플래그, 로깅은 유지한다 — 데이터는 계속 쌓이고, 배선 수리 후 재활성화는 플래그 하나다
  • 과부하 E-STOP(150N)은 보존한다 — 오탐 원인과 무관하고 검증을 통과했다
  • 잔여 위험(데이터 상실 감지가 3초로 늦어짐)은 위험관리 문서에 등재했다

“안전 기능을 끈다"는 문장만 보면 후퇴 같지만, 오탐 비용과 미감지 비용을 정량화해서 비교하고, 결정의 근거와 잔여 위험을 문서로 남기고, 복구 경로를 열어뒀다면 그건 정당한 엔지니어링 결정이다. 반대로 분석 없이 켜두는 게 오히려 “안전해 보이는” 무책임일 수 있다.

캘리브레이션의 정착 — 원자성이 전부다

2편에서 ADC raw 자체 환산으로 넘어가면서 캘리브레이션이 우리 쪽 일이 됐다. 처음 버전은 벤치 셀 기준 고정상수를 코드에 박은 MVP였다. 동작은 하는데, 로드셀을 교체하면 펌웨어를 다시 플래시해야 한다. 현장에서 쓸 물건이 아니다.

그래서 CANopen SDO로 런타임 tare/span 명령을 받고, 결과를 STM32 flash 페이지에 영속화하는 구조로 갔다. 여기서 코드 리뷰가 잡아준 디테일들이 알짜였다.

캘리브레이션 영속화 — 나쁜 순서와 적용한 순서

save-then-commit

직관적으로 짜면 이렇게 된다. 캘리 명령 수신 → RAM에 즉시 적용 → flash에 저장. 이 순서의 함정은 flash 기록이 실패했을 때다. 동작값(RAM)은 새 캘리인데 저장값(flash)은 옛 캘리. 당장은 멀쩡히 돌다가 전원을 다시 넣는 순간 조용히 옛 캘리로 복귀한다. 현장에서 가장 찾기 어려운 부류의 버그다.

그래서 순서를 뒤집었다. flash 기록과 읽기 재검증이 성공한 뒤에만 RAM에 적용하고 ACK를 보낸다. 어느 단계에서 실패해도 동작값과 저장값이 항상 일치한다.

// validate → save → commit
sdo_abort_t cal_apply(const lc_calib_t *new_cal)
{
    // 1. 검증 — 0-gain 차단
    if (fabsf(new_cal->scale_n_per_count) < SCALE_MIN)
        return SDO_ABORT_INVALID_VALUE;

    // 2. flash 기록 + 읽어서 재검증 (실패 시 RAM 무변경)
    if (!flash_write_verify(CAL_PAGE, new_cal, sizeof(*new_cal)))
        return SDO_ABORT_HW_FAILURE;

    // 3. 성공 시에만 동작값 갱신
    g_calib = *new_cal;
    return SDO_ABORT_NONE;
}

validate-then-commit

채널이 둘이라 생기는 변형도 있다. 두 채널 tare를 한 명령으로 처리할 때, 한쪽은 성공하고 한쪽은 실패하면? 절반만 적용된 상태가 제일 나쁘다. 두 채널 모두 검증을 통과했을 때만 전체를 반영하고, 부분 실패면 전체 무변경으로 처리했다.

0-gain 차단

span 캘리에서 분모(카운트 차이)가 0 부근이면 거부한다. 이걸 안 막으면 scale이 0이 되면서 모든 힘이 0으로 읽히는 dead-sensor가 만들어진다. 과부하 E-STOP 입장에서는 영원히 트립하지 않는 입력이다. 캘리브레이션 검증이 곧 안전 검증이다.

검증 단계의 안정 윈도우(값이 출렁이는 동안의 tare 거부)까지 합치면, 캘리 명령 하나에 검증 → 저장 → 커밋의 3단 관문이 생겼다. 실패하면 사유 코드와 함께 ABORT 응답이 가고, 시스템 상태는 명령 이전과 동일하다.

시리즈를 닫으며 — 전체 교훈

2주 디버깅에서 남은 것들을 한 줄씩 정리한다.

  1. 문서보다 실측 — 매뉴얼의 에코 바이트부터 틀려 있었다. raw 덤프가 먼저다. (1편)
  2. 타이밍 가설은 산수로 검증된다 — 89 ≈ 11+10+50+10. (1편)
  3. 공유 폴링 루프에서 죽은 채널이 산 채널을 굶긴다 — 채널별 비블로킹 상태머신으로. (1편)
  4. 레이어 격리로 원인 부위를 확정하라 — CAN 캡처 → 코드 리뷰 → 직결. 한 층씩 지운다. (2편)
  5. valid 프레임 ≠ fresh 데이터 — 살아있는 척하는 디바이스는 값-변화 감시로 잡는다. (2편)
  6. 스마트 모듈의 가공값 대신 raw를 받아라 — 블랙박스 필터를 우회하고 통제권을 회수한다. (2편)
  7. 파서는 깨진 입력에서의 복구까지가 설계다 — 헤더 동기 + 라인 배수. (3편)
  8. 펌웨어 완화 ≠ HW 수리 — 무엇이 제거됐고 무엇이 잔존하는지 구분해 기록한다. (3편)
  9. 안전 기능의 오탐도 위험이다 — 끄는 결정도 분석과 문서화가 따르면 엔지니어링이다. (4편)

돌아보면 모듈 하나 붙이는 일이었다. 로드셀 두 개, 시리얼 모듈 두 개, UART 두 채널. 그 안에 프로토콜 실측, 타이밍 분석, 레이어 격리, 리버스엔지니어링, 파서 복구 설계, 안전 trade-off, flash 원자성이 전부 들어 있었다. 부품이 단순하다고 일이 단순하지 않다는 게, 어쩌면 이 시리즈의 진짜 교훈이다.