들어가며
1편에서 프로토콜을 실측으로 교정하고 폴링을 비블로킹으로 바꿨다. 값도 맞고 갱신도 빠르다. 이제 끝났다고 생각했는데, 이번 시리즈의 하이라이트가 여기서 시작된다.
증상: 부하를 주는 동안 force 값이 안 바뀐다. 조작부를 누르고 있으면 화면의 힘 표시가 “렉 걸린 것처럼” 멈춰 있다가, 손을 떼고 한참 지나면 돌아온다.
이 힘 데이터는 과부하 비상정지의 입력이다. 힘이 실제로 걸리는 동안 값이 안 움직인다는 건, 정확히 보호가 필요한 순간에 보호 입력이 죽는다는 뜻이다. 단순 버그가 아니라 안전 문제다.
이번 편의 재현과 검증은 전부 벤치에서 진행했다. 빔타입 로드셀(정격 100kg, 1.9831mV/V)을 알루미늄 지그에 물리고 P7001-2를 연결한 구성이다.

용의자가 너무 많다
데이터 경로를 펼쳐보면 용의자가 줄을 선다.
로드셀 → P7001-2 → UART → STM32 펌웨어 → CAN(TPDO) → 리눅스 마스터 → 표시
어디서든 값이 멈출 수 있다. CAN 프레임이 끊겼을 수도, 펌웨어가 stale 버퍼를 재사용할 수도, 마스터 쪽 수신 루프가 밀릴 수도, 모듈이 멈췄을 수도 있다. 이럴 때 가장 빠른 길은 그럴듯한 가설 하나를 찍어서 고치는 게 아니라, 레이어를 한 층씩 배제하는 거다.
레이어 1 — CAN 캡처
마스터에서 candump로 TPDO를 캡처하면서 동결을 재현했다.
- TPDO 프레임은 약 12Hz로 끊김 없이 들어온다
- 동결 구간 2.66초 동안 펌웨어의 폴 성공 카운터는 +32회 증가했다
- 그런데 같은 구간의 force 값은 bit-exact로 동일하다
CAN은 살아 있고, 펌웨어도 매 주기 모듈 폴링에 성공하고 있다. 타이밍 문제도, CAN 문제도 아니다. 펌웨어가 받아오는 값 자체가 안 변하고 있다는 뜻이다.
레이어 2 — 펌웨어 코드 리뷰
다음 용의자는 펌웨어의 stale-read다. 이전에 받은 프레임을 새 응답인 줄 알고 재해석하는 버그는 폴링 코드에서 흔하다. 코드를 다시 봤다.
- 매 폴마다 요청 전에 RX 버퍼를 드레인한다
- 응답은 체크섬 검증을 통과해야만 값으로 반영된다
요청 전에 버퍼를 비우니 직전 프레임을 다시 읽을 수 없는 구조다. 펌웨어의 stale-read도 아니다.
레이어 3 — 시리얼 직결
남은 용의자는 모듈이다. STM32를 완전히 배제하고, PC에서 모듈에 직접 폴링하는 스크립트를 돌리면서 동결을 재현했다.

결과가 결정적이었다. 모듈이 체크섬까지 유효한 정상 프레임으로 12.30g를 139프레임, 약 14초 동안 bit-exact로 반복하다가 스스로 회복했다. 중간에 끊기지도, 깨지지도 않는다. 그냥 같은 값을 계속 보낸다.
동결은 100% 모듈 측이다.
“살아있는 척"이 무서운 이유
여기서 이 문제의 본질이 드러난다. 모듈이 죽으면 차라리 낫다. 응답이 끊기면 타임아웃으로 잡으면 된다. 그런데 이 모듈은 살아있는 척을 한다. 프레임은 계속 오고, 체크섬도 유효하고, 폴은 계속 성공한다. 값만 옛날 거다.
이게 무슨 뜻이냐면, 폴 성공률이나 heartbeat 기반의 신선도 감시는 이 상황을 원천적으로 못 잡는다는 거다. 통신 계층에서 보면 모든 게 정상이니까. 만약 높은 하중 값에서 동결되면, 실제 힘이 빠진 뒤에도 시스템은 계속 큰 힘이 걸려 있다고 믿는다. 반대로 낮은 값에서 동결되면 과부하가 걸려도 E-STOP이 안 뜬다. 안전 로직이 통째로 우회당하는 구조다.
valid 프레임과 fresh 데이터는 다르다. 이 둘을 구분하려면 값-변화 기반 감시가 별도로 필요하다.
돌파구 — 매뉴얼에 없는 명령
동결이 모듈 내부 어디서 생기는지가 다음 질문이었다. 후보는 둘이다. ADC 프런트엔드가 멈추는 건지, ADC 뒤의 무게-변환 단계가 멈추는 건지.
매뉴얼에 있는 명령은 무게 읽기(0x31), 영점(0x32), 스팬(0x33)이 전부다. 그런데 제조사 유틸리티 화면에는 ADC raw 항목이 있다. 유틸이 표시할 수 있다면 모듈에서 읽어오는 명령이 있다는 뜻이다. 명령 공간을 순서대로 두드려봤더니 B0 34 E4가 응답을 뱉었다.
그런데 응답이 무게 응답과 골격부터 다르다. 실측 프레임을 나란히 놓고 보면:
무게 읽기 (B0 31 E1 → 8바이트)
b0 33 01 00 00 00 be a2
│ │ │ └────┬────┘ └─ 체크섬 (앞 7B 합 & 0xFF)
│ │ │ 무게×100, BE signed (0xBE=190 → 1.90g)
│ │ unit (0x01 = g)
│ 에코 0x33
헤더
ADC raw (B0 34 E4 → 6바이트)
b0 00 80 4a 56 d0
│ └────┬────┘ └─ 체크섬 (앞 5B 합 & 0xFF, 0xD0 ✓)
│ raw, BE unsigned (0x00804A56)
헤더 — 에코도 unit도 없다
두 응답이 공유하는 건 헤더(0xB0)와 체크섬 규칙뿐이다. ADC raw 응답은 에코 바이트도 unit 바이트도 없이 헤더 바로 뒤가 데이터고, 길이도 6바이트로 짧다. 미문서 명령이라 read 계열의 골격을 따를 거라는 가정 자체가 틀렸던 건데, 처음에 8바이트를 기다리다 타임아웃 나는 걸 보고서야 알았다. 미문서 명령을 다룰 때는 프레임 길이부터 가정하면 안 된다.
값의 구조도 단서를 준다. 데이터 필드는 4바이트지만 상위 바이트가 항상 0x00이다. 24비트 컨버터 값이 32비트 필드에 담겨 오는 거다. 그리고 무부하 기준점이 24비트 미드스케일(0x800000)이라, 실제 카운트는 raw − 0x800000으로 계산한다. 위 실측 프레임이면 0x804A56 − 0x800000 = +19,030 counts. 양방향(인장/압축) 측정을 고려한 전형적인 바이폴라 ADC 구성이다.
파서는 이렇게 된다.
// ADC raw 응답 파싱 (6바이트 — 에코/unit 없음)
// [0]=0xB0 [1..4]=ADC raw BE unsigned [5]=checksum
static bool p7001_parse_adc(const uint8_t *buf, int32_t *counts)
{
if (buf[0] != 0xB0)
return false;
uint8_t sum = 0;
for (int i = 0; i < 5; i++)
sum += buf[i];
if (sum != buf[5])
return false;
uint32_t raw = ((uint32_t)buf[1] << 24) | ((uint32_t)buf[2] << 16) |
((uint32_t)buf[3] << 8) | (uint32_t)buf[4];
*counts = (int32_t)(raw - 0x800000u); // 24bit 미드스케일 기준
return true;
}
1편의 비블로킹 상태머신도 명령별로 기대 프레임 길이를 달리 받도록 손봤다 (무게 8B, ADC 6B). 1편의 자작 툴에 ADC raw 항목을 넣을 수 있었던 게 이 명령 덕분이다.
결정적 증거 — ADC는 살아 있다
이제 ADC raw와 무게를 같은 타임라인에서 동시에 캡처할 수 있다. PC에서 0x31과 0x34를 번갈아 폴링하면서 손으로 다섯 번 눌렀다.
결과:
- 손누름 5회 모두, ADC raw는 수백만 counts로 즉시 추종한다. 무게는 누르는 내내 동결이다.
- 정적 3.3kg을 올려두면 ADC는 ±0.016%로 안정되고 무게도 정상으로 나온다
- ADC-무게 선형성은 약 70.6 counts/g, 측정 범위 내 포화 없음
그림이 완성됐다. ADC 프런트엔드는 완벽히 라이브다. 동결은 그 뒤, 모듈 내부의 무게-변환 단계에만 있다. 정황상 모션/안정(stability) 필터로 보인다. 값이 빠르게 변하는 동안 “안정될 때까지” 출력을 홀드하는 로직인데, 전자저울 용도라면 합리적인 설계다. 표시값이 출렁이는 저울은 팔기 어려우니까. 하지만 동적 힘을 실시간으로 봐야 하는 용도에서는 정확히 반대로 동작하는 기능이다.
설정으로 끌 수 있으면 좋겠지만 매뉴얼에 그런 명령은 없다. 블랙박스다.
해결 — 가공값을 버리고 raw를 받는다
블랙박스 필터를 못 끄면, 필터를 안 거치면 된다. 펌웨어가 무게 명령(0x31) 대신 ADC raw(0x34)를 읽고, 환산은 직접 한다.
// 2점 선형 캘리브레이션
// zero_counts: 무부하 ADC, scale: 분동 기준 N/count
typedef struct {
int32_t zero_counts;
float scale_n_per_count;
} lc_calib_t;
static float lc_convert(int32_t adc_counts, const lc_calib_t *cal)
{
return (float)(adc_counts - cal->zero_counts) * cal->scale_n_per_count;
}
캘리브레이션은 두 점이면 된다. 무부하에서 zero_counts를 잡고, 무게를 아는 분동을 올려 scale을 구한다. 미드스케일 오프셋은 두 점 차이에서 자연히 상쇄되니 신경 쓸 것도 없다. 모듈의 영점/스팬 캘리브레이션이 하던 일을 우리 쪽으로 가져온 것뿐이다.
이 전환의 의미는 동결 우회 하나로 끝나지 않는다. 필터·캘리브레이션·단위 환산의 통제권 전체가 우리 펌웨어로 넘어온다. 필요하면 우리가 원하는 특성의 필터를 직접 설계해서 넣을 수 있고, 무엇보다 그 필터가 뭘 하는지 우리가 안다.
검증
바꾸고 나서 동결 재현 조건을 다시 걸었다.
- 기존에 14초 동결이 재현되던 손누름 패턴에서, ADC 기반 force는 끊김 없이 라이브로 추종한다
- 정적 3.3kg에서 자체 환산값이 모듈 무게값과 일치한다
동결은 원천적으로 사라졌다. 모듈의 무게-변환 단계를 아예 안 지나가니까.
정리
- 격리 테스트가 왕이다. CAN 캡처 → 펌웨어 코드 리뷰 → 시리얼 직결. 각 단계가 용의선상에서 한 층씩 지웠고, 마지막에 모듈만 남았다.
- valid 프레임 ≠ fresh 데이터. “살아있는 척"하는 디바이스는 폴 성공 기반 감시를 우회한다. 신선도는 값-변화 기반으로도 감시해야 한다.
- 미문서 명령은 골격부터 의심하라. ADC raw 응답은 문서화된 read 계열과 달리 에코·unit 없는 6바이트였다. 프레임 길이조차 가정하면 안 된다.
- 스마트 모듈의 가공값 대신 raw를 받아라. 블랙박스 필터의 동결을 원천 우회하고, 변환 통제권을 회수했다.
다음 편은 또 다른 채널에서 터진 문제다. 한쪽 채널만 폴 실패가 버스트로 쏟아지는데, 뜯어보니 RS-232 출력을 트랜시버 없이 3.3V TTL 핀에 직결한 배선이 원인이었다. 깨진 바이트 하나가 펌웨어에서 수백 ms 장애로 증폭되는 메커니즘과, 파서의 자기복구 설계를 다룬다.