BCD 이해하기: 앱·기기 데이터에서 2진화 10진수 파싱

수신한 0x25를 25로 읽어야 하는지 37로 읽어야 하는지는 값의 모양이 아니라 필드 명세가 결정합니다. 이 글은 8-4-2-1 방식으로 한 바이트에 두 자리를 넣은 unsigned packed BCD와 DS1307의 시·분·초 레지스터만 다룹니다. 센서나 BLE 값이 모두 BCD라고 가정하지 않으며, 부호 니블·소수점·다른 RTC의 비트 배치는 별도 계약입니다.

2026년 10월 9일 Windows x64의 Dart 3.13.4 VM에서 바이트 256개, 0~99 왕복, 하루 86,400개 시간의 12·24시간 표현을 실제 실행했습니다. 전체 173,177개 조건을 통과했습니다. DS1307 장치나 I2C 통신을 연결한 결과가 아니라 공식 레지스터 배치와 대조한 소프트웨어 입력 벡터 검증입니다.

먼저 원본과 계약을 한 쌍으로 저장

로그에는 raw=25, field=temperature, encoding=packed-bcd처럼 원본 바이트와 명세의 필드 이름을 함께 남깁니다. “숫자가 자연스럽다”만으로 인코딩을 추정하면 0x12처럼 이진수 18과 BCD 12가 모두 가능한 입력에서 오류가 숨습니다. 데이터시트의 자릿수 순서, 플래그, 허용 범위, 단위가 빠졌다면 변환 코드를 확정하지 않습니다.

같은 바이트 읽기 계약 이번 계산
25 일반 unsigned 8비트 37
25 두 자리 packed BCD 25
12 34 네 자리 BCD, 앞 바이트가 앞 두 자리 문자열 1234
12 34 16비트 이진 big-endian 4660
00 07 네 자리 식별번호 0007, 정수 7과 표시 의미가 다름

바이트 범위와 두 니블을 모두 거절 조건에 넣기

int decodeBcd(int b) {
  if (b < 0 || b > 255) {
    throw RangeError.range(b, 0, 255, 'byte');
  }
  final tens = b >> 4;
  final ones = b & 0x0f;
  if (tens > 9 || ones > 9) {
    throw FormatException('invalid BCD', b);
  }
  return tens * 10 + ones;
}

int encodeBcd(int n) {
  if (n < 0 || n > 99) {
    throw RangeError.range(n, 0, 99, 'decimal');
  }
  return ((n ~/ 10) << 4) | (n % 10);
}

바이트를 먼저 b & 0xff로 잘라 버리면 0x125가 0x25로 바뀌어 잘못된 입력이 정상 값처럼 통과할 수 있습니다. 이번 함수는 -1, 256, 0x125를 RangeError로 거절했습니다. 정상 바이트 안에서도 A~F 니블은 10진 자리가 아니므로 FormatException입니다. 오류 값을 0으로 치환하면 진짜 00과 통신·파싱 오류를 구분할 수 없습니다.

BCD 유효 바이트 100개와 거절 156개 및 DS1307 자정 정오 CH 상태의 Dart 검증 결과
2026년 10월 9일 격리 환경의 실제 실행 결과를 정리한 설명 도식입니다. 기기·스토어의 실제 화면이 아닙니다.

256개 바이트 중 유효한 BCD는 100개, 잘못된 니블을 포함한 입력은 156개였습니다. 별도로 0부터 99까지 encode→decode 왕복 100개를 확인했습니다. 두 방향이 모두 같은 실수를 할 수 있으므로 왕복만으로 끝내지 않고 0x00→0, 0x25→25, 0x99→99 같은 규격상의 기준값과 범위 거절을 같이 둡니다.

RTC에서는 마스크 하나로 12시간 모드를 지울 수 없다

DS1307 데이터시트의 8쪽 레지스터 표를 기준으로 첫 세 바이트를 초·분·시 순서로 읽었습니다. 초의 bit 7은 CH 상태, 시의 bit 6은 12시간 선택이고 그 모드에서 bit 5는 PM입니다. 24시간 모드의 bit 5는 시 값의 일부이므로 무조건 플래그로 제거하면 안 됩니다.

int decodeDs1307Hour(int raw) {
  if (raw < 0 || raw > 255) throw RangeError('byte');
  if ((raw & 0x80) != 0) throw FormatException('reserved');
  if ((raw & 0x40) != 0) {
    final h12 = decodeBcd(raw & 0x1f);
    if (h12 < 1 || h12 > 12) {
      throw FormatException('12-hour range');
    }
    return h12 % 12 + ((raw & 0x20) != 0 ? 12 : 0);
  }
  final h24 = decodeBcd(raw & 0x3f);
  if (h24 > 23) throw FormatException('24-hour range');
  return h24;
}
초·분·시의 raw hex 실제 파서 출력 놓치기 쉬운 판단
48 59 23 23:59:48, 24시간 바이트 순서는 시·분·초가 아님
00 00 52 00:00:00, 12시간 AM 12 AM은 자정
00 00 72 12:00:00, 12시간 PM 12 PM은 정오
48 59 71 23:59:48, 12시간 PM PM 비트를 숫자에 더하지 않음
80 00 00 00:00:00, halted=true 표시 가능한 값이어도 시계 동작 상태는 별도

초를 raw & 0x7f로 변환한 뒤에도 CH가 켜져 있었다는 상태를 반환값에 보존했습니다. 화면의 시간이 유효하다는 사실과 시간이 실제 진행한다는 사실은 다릅니다. 이 해석은 DS1307의 해당 레지스터 계약이며 다른 RTC에 같은 마스크를 붙이지 않습니다.

시간은 BCD 문법 다음에 도메인 범위를 검사

0x60은 BCD 숫자 60으로는 유효하지만 초에는 허용하지 않았습니다. 이번 파서는 입력 길이가 정확히 3인지, 각 값이 바이트인지, 분·시의 예약 비트가 0인지 확인한 뒤 BCD 문법과 시간 범위를 검사합니다. 24시간의 24, 12시간의 0·13, 초의 60·5A, 예약 비트와 길이 오류를 포함한 10개 레지스터 벡터를 거절했습니다.

하루의 각 시간에 대해 초·분·시를 24시간과 AM/PM 표현으로 각각 인코딩하고 같은 시각으로 돌아오는지 확인했습니다. 86,400개 시각 × 두 표현으로 172,800개 비교를 실행했습니다. 이는 시·분·초 변환의 범위를 촘촘히 확인한 것이지 날짜의 윤년 처리, 읽기 중 레지스터 갱신, 배터리·발진 정확도를 측정한 결과가 아닙니다.

네 자리 값이 코드라면 숫자 대신 문자열로 보존

String decodeDigits(List<int> bytes) => bytes
    .map((b) => decodeBcd(b).toString().padLeft(2, '0'))
    .join();

// [0x00, 0x07] -> "0007"
// [0x12, 0x34] -> "1234"

이 함수는 앞 바이트부터 앞 자리를 담는 고정된 계약입니다. 반대 순서, 홀수 자릿수의 패딩 니블, 부호·소수점 규칙은 지원하지 않습니다. 네 자리 문자열이 앱의 ID라면 int로 바꿨다가 길이를 추측하지 말고 원본 폭을 유지합니다. 임의 길이 값을 정수로 누적할 때의 오버플로도 이번 문자열 경로와 별도로 검토해야 합니다.

앱에 붙이기 전 남길 실패 벡터

dart analyze bcd_contract_test.dart
dart run bcd_contract_test.dart
// analyzer: No issues found
// validBytes=100, invalidBytes=156
// hmsValues=86400, formatChecks=172800
// checks=173177, passed=true

재현 프로젝트는 assert 활성화 여부에 기대지 않고 조건이 틀리면 StateError로 종료했습니다. 실제 수신 코드에는 장치 모델·레지스터 주소·raw hex·길이·거절 이유를 남기고 개인정보가 담긴 패킷은 그대로 공유하지 않습니다. 표시 단위와 단위 환산은 파싱 이후 단계로 분리합니다. 엔디안과 signed 이진 정수는 비트 필드 계약 실험에서 이어 보며, 이 글은 10진 니블과 RTC 모드 판정에 집중합니다.

계약의 근거는 2026년 10월 9일 다시 확인한 DS1307 데이터시트와 Dart int 연산 API입니다. 공식 레지스터 정의, 자체 작성 함수, 실제 출력의 역할을 구분했습니다. BLE·USB·I2C 실기기, 다른 RTC, Swift·Kotlin 실행은 이번 확인 범위에 포함하지 않았습니다.