들어가며
힘 측정이 들어가는 장비를 개발하면서 로드셀 측정 경로에서 2주 정도 디버깅을 했다. 센서값이 이상한 단순 품질 문제가 아니라, 이 힘 데이터가 과부하 비상정지 같은 안전 기능의 입력으로 쓰이는 구조라 끝까지 파야 했다.
겪은 문제가 다섯 가지인데 한 편에 다 쓰기엔 길어서 시리즈로 나눈다. 이번 편은 가장 기본적인 두 가지다.
- 매뉴얼과 실물 모듈의 응답 포맷이 다르다
- 무게 갱신이 89ms 주기로만 된다
시스템 구성
- 로드셀 ×2 + P7001-2 무게 변환 모듈 (24비트 A/D, UART 9600 8N1)
- STM32F103 인터페이스 보드가 두 채널을 폴링 (LC1=USART1, LC2=USART2)
- 측정값은 CAN(CANopen TPDO)으로 리눅스 마스터 보드에 전송
P7001-2는 로드셀의 mV 신호를 24비트 ADC로 변환해서 시리얼로 뱉어주는 모듈이다. HX711처럼 raw count를 직접 받는 방식이 아니라, 모듈이 캘리브레이션과 무게 환산까지 해주고 우리는 명령-응답 프로토콜로 무게값만 받아오는 구조다.

제조사가 측정과 영점/스팬 캘리브레이션용 PC 유틸리티를 제공한다.

다만 디버깅 과정에서 모듈을 직접 두드려볼 일이 많아서 같은 기능을 하는 툴을 따로 만들어 썼다. 제조사 유틸과 마찬가지로 무게와 ADC raw를 같이 띄우게 했는데, 이 ADC raw 항목이 나중에 결정적인 역할을 한다.

모듈이 다 해주니 편할 줄 알았는데, 이 시리즈 전체가 그 대가에 대한 이야기다.
프로토콜은 매뉴얼 기준으로 이렇다.
무게 읽기
송신: B0 31 E1
응답: B0 31 [unit] [d3] [d2] [d1] [d0] [checksum]
- unit: 0=mg, 1=g, 2=kg, 3=t
- d3..d0: 무게 ×100 (big-endian)
- checksum: 헤더부터 직전 바이트까지 합의 하위 8비트
응답 데이터가 50000이면 실제 무게는 500.00g이라는 식이다.
1막. 읽히는 값이 garbage다
펌웨어를 매뉴얼대로 짜서 올렸더니 무게값이 거의 풀스케일 수준의 garbage로 읽혔다. 로드셀에 아무것도 안 올렸는데 수십 kg이 나온다.
이럴 때 파서 코드를 먼저 의심하면서 로직을 한 줄씩 따라가기 쉬운데, 그 전에 할 일이 있다. 모듈이 실제로 뭘 보내는지 raw로 떠보는 거다. PC에서 모듈에 직접 폴링하면서 수신 버퍼를 해석 없이 그대로 hex 덤프했다.

TX: b0 31 e1
RX: b0 33 01 00 00 01 4a 2f → 3.30 g
RX: b0 33 01 00 00 01 86 6b → 3.90 g
RX: b0 33 01 00 00 01 72 57 → 3.70 g
매뉴얼은 read 응답의 명령 에코가 0x31이라고 했는데, 실물 모듈은 0x33을 돌려주고 있었다. 매뉴얼에서 0x33은 스팬 캘리브레이션 명령이다. 처음엔 모듈이 뭔가 꼬였나 싶었는데, 받은 프레임들의 체크섬을 검산해보니 전부 유효했다. 위 첫 프레임으로 직접 해보면 b0+33+01+00+00+01+4a = 0x12F, 하위 8비트 0x2F로 수신값과 일치한다. 모듈은 일관되게, 멀쩡하게, 매뉴얼과 다른 에코를 보내고 있는 거다.
문제는 기존 파서가 매뉴얼의 프레임 그림 기준 오프셋으로 무게를 읽고 있었다는 것. 실측 프레임에서는 unit이 buf[2], 무게 데이터가 buf[3..6]인데, 기존 코드는 buf[2..5]를 무게로 해석하고 있었다. unit 바이트(0x01)가 무게의 최상위 바이트로 들어가니 값이 풀스케일로 튀는 게 당연했다.
파서를 실측 기준으로 교정했다.
// P7001-2 read 응답 파싱 (실측 기준)
// [0]=0xB0 [1]=0x33(에코) [2]=unit [3..6]=무게×100 BE [7]=checksum
static bool p7001_parse(const uint8_t *buf, float *weight_g)
{
if (buf[0] != 0xB0 || buf[1] != 0x33)
return false;
uint8_t sum = 0;
for (int i = 0; i < 7; i++)
sum += buf[i];
if (sum != buf[7])
return false;
int32_t raw = ((int32_t)buf[3] << 24) | ((int32_t)buf[4] << 16) |
((int32_t)buf[5] << 8) | (int32_t)buf[6];
*weight_g = raw / 100.0f; // unit=1(g) 기준
return true;
}
검산. 무부하에서 0 부근, 583.8g 분동을 올리니 583.8g이 나오고, 뉴턴 환산하면 5.72N. 맞다.
여기서 얻은 교훈 하나. 데이터시트는 출발점이지 진실이 아니다. 통신이 이상하면 파서 로직을 의심하기 전에 바이트 스트림을 raw로 덤프해서 문서와 대조하는 게 먼저다. 이 습관은 이후 4막(프레임 깨짐)에서도 결정적인 역할을 한다.
2막. 89ms 지연의 진범
값은 맞게 나오는데 이번엔 갱신이 느렸다. 마스터 쪽에서 힘 표시가 약 89ms 주기로만 바뀐다. 9600bps가 느리긴 해도 8바이트 프레임 전송에 10ms가 안 걸리는데 89ms는 어디서 나온 숫자인가.
처음 든 생각은 “모듈 내부 변환 주기가 느린가?“였다. 모듈 설정을 뒤지러 가기 전에, 일단 타이밍 산수부터 해봤다.
당시 펌웨어의 폴링 구조는 이랬다.
// 기존: 두 채널 교대 블로킹 폴링
while (1) {
p7001_read(LC1, &force1); // 응답 올 때까지 블로킹, 타임아웃 50ms
HAL_Delay(10);
p7001_read(LC2, &force2); // 응답 올 때까지 블로킹, 타임아웃 50ms
HAL_Delay(10);
can_send_force(force1, force2);
}
로직 애널라이저로 재보니 LC1의 실제 응답은 요청 후 약 11ms로 빨랐다. 그런데 당시 벤치에는 LC2가 미연결 상태였다. 그러면 한 루프는 이렇게 된다.
LC1 응답(11ms) + 간격(10ms) + LC2 타임아웃(50ms) + 간격(10ms) + 루프 오버헤드
≈ 89ms
산수가 측정값과 정확히 맞았다. 진범은 모듈이 아니라 미연결 채널의 50ms UART 타임아웃이었다. 블로킹 교대 폴링 구조에서는 죽은 채널의 타임아웃이 루프 전체를 직렬로 잡아먹는다. 살아있는 LC1도, CAN 송신도 같이 굶는다.
지연 가설은 이렇게 산수로 검증할 수 있다. 측정된 지연이 코드에 박혀 있는 타임아웃 상수들의 합으로 설명되면 그게 답이다.
비블로킹 상태머신으로
해결은 구조 교체다. 블로킹 p7001_read() 함수를 없애고, 채널별로 독립적인 비블로킹 상태머신을 돌렸다.
typedef enum {
LC_IDLE, // 다음 폴 대기
LC_WAIT_RESP, // 요청 전송 완료, 응답 수신 중
} lc_state_t;
typedef struct {
lc_state_t state;
uint32_t t_sent; // 요청 송신 시각
uint8_t rx_buf[16];
uint8_t rx_len;
float force_n;
uint32_t ok_cnt, fail_cnt;
} lc_channel_t;
static void lc_poll(lc_channel_t *ch, UART_HandleTypeDef *huart)
{
switch (ch->state) {
case LC_IDLE:
ch->rx_len = 0;
uart_send(huart, (uint8_t[]){0xB0, 0x31, 0xE1}, 3);
ch->t_sent = HAL_GetTick();
ch->state = LC_WAIT_RESP;
break;
case LC_WAIT_RESP:
uart_drain_rx(huart, ch->rx_buf, &ch->rx_len); // 논블로킹
if (ch->rx_len >= 8) {
float g;
if (p7001_parse(ch->rx_buf, &g)) {
ch->force_n = g * 0.00980665f;
ch->ok_cnt++;
} else {
ch->fail_cnt++;
}
ch->state = LC_IDLE;
}
else if (HAL_GetTick() - ch->t_sent > LC_TIMEOUT_MS) {
ch->fail_cnt++;
ch->state = LC_IDLE;
}
break;
}
}
// 메인 루프: 어느 채널도 다른 채널을 기다리지 않는다
while (1) {
lc_poll(&lc1, &huart1);
lc_poll(&lc2, &huart2);
can_task();
}
핵심은 두 가지다.
- 각 채널이 자기 타이머로 자기 타임아웃만 관리한다. LC2가 죽어 있어도 LC1은 자기 페이스로 돈다.
- 메인 루프가 어디서도 멈추지 않으니 CAN 송신 주기도 폴링과 무관하게 유지된다.
교체 후 힘 데이터 갱신 주기는 89ms에서 10~20ms로 줄었다. LC2를 다시 연결해도, 빼도 LC1 갱신 주기는 변하지 않는다.
정리
- 통신이 이상하면 파서를 고치기 전에 raw 덤프부터. 매뉴얼의 에코 바이트(0x31)부터 실물(0x33)과 달랐다.
- 지연 가설은 산수로 검증된다. 89 ≈ 11 + 10 + 50 + 10.
- 공유 폴링 루프 위에서는 죽은 디바이스가 산 디바이스를 굶긴다. 블로킹 폴링은 채널 수만큼 장애를 곱하므로, 채널별 비블로킹 상태머신으로 가야 한다.
여기까지는 가벼운 몸풀기다. 다음 편이 이 시리즈의 하이라이트인데, 부하를 주는 동안 무게값이 멈춰버리는 문제를 다룬다. 모듈이 체크섬까지 유효한 정상 프레임으로 옛 값을 계속 보내는, “살아있는 척"하는 동결이었다.