사례로 배우는 디버깅: HCI 로그에서 원인까지
현장의 블루투스 문제는 대개 “가끔 끊겨요” 한 줄로 온다. 그때 손에 쥔 것은 호스트와 컨트롤러 사이를 지나간 HCI 패킷 몇 줄이고, 그 안의 오류 코드 한 바이트가 가장 큰 단서다. 이 장은 흔한 여섯 사례를 로그 바이트부터 읽어, 오류 코드가 무엇을 뜻하고 사양의 어느 규칙에서 나왔는지, 그래서 무엇을 고쳐야 하는지까지 따라간다. 앞 장들에서 배운 규칙이 실제로 어떻게 드러나는지 모아 보는 장이다.
- H4 형식의 HCI 명령·이벤트·ACL 패킷을 바이트 단위로 읽을 수 있다.
- 오류 코드(Part F)의 뜻과, 양쪽 기기의 로그가 서로 다른 이유를 말하는 까닭을 설명할 수 있다.
- 감독 타임아웃, instant, MIC 실패, 키 없음, 명령 거부, ATT_MTU 사례의 원인 규칙과 해결책을 짚을 수 있다.
로그 읽는 법
UART 같은 전송에서 HCI 패킷은 앞에 1바이트 종류 표시가 붙는다(H4). 0x01 명령(호스트 → 컨트롤러), 0x02 ACL 데이터(양방향), 0x04 이벤트(컨트롤러 → 호스트). 숫자 필드는 모두 리틀 엔디언이다.
| 종류 | 모양 | 예 |
|---|---|---|
| 명령 0x01 | 옵코드 2(OGF 6비트 · OCF 10비트) · 길이 1 · 파라미터 | 01 0a 20 01 01 = LE Set Advertising Enable(켬) |
| 이벤트 0x04 | 이벤트 코드 1 · 길이 1 · 파라미터. LE 이벤트는 0x3E 안의 하위 코드 | 04 05 04 00 40 00 08 = 연결 0x0040 끊김, 이유 0x08 |
| ACL 0x02 | 핸들 12비트 + PB·BC 4비트 · 길이 2 · L2CAP(길이 2 · CID 2 · 내용) | CID 0x0004 = ATT |
명령에는 두 가지 답이 있다. 바로 끝나는 명령은 Command Complete(0x0E, 반환 값 첫 바이트가 Status), 오래 걸리는 명령은 Command Status(0x0F)로 “받았다”만 알리고 결과는 나중 이벤트로 온다. 그래서 Command Status의 0x00은 “성공”이 아니라 “시작했다”일 뿐이다.
HCI 로그는 비행기의 블랙박스와 같다. 조종석(호스트)이 내린 지시와 기체(컨트롤러)가 보낸 보고가 시간순으로 남는다. 다만 기체 바깥, 즉 공중에서 상대 기기와 주고받은 링크 계층 PDU는 적혀 있지 않다. 그래서 한쪽 기록만 보면 “상대가 갑자기 사라졌다”(0x08)처럼 보이는 일이 상대 기록에서는 “내가 끊었다, MIC가 틀려서”(0x3D)로 적혀 있곤 하다. 가능하면 두 기기의 로그를, 필요하면 공중 캡처까지 함께 본다.
H4 패킷 표시: [Vol 4] Part A §2 · HCI 패킷 형식: [Vol 4] Part E §5.4 · Command Complete·Status: §7.7.14, §7.7.15 · 오류 코드 표: [Vol 1] Part F §1.3
연결 한 번의 로그 지도
사례를 보기 전에, 문제없는 연결 하나가 HCI 로그에 어떤 순서로 남는지부터 보자. 아래는 본딩한 두 기기가 다시 연결해 암호화하고, 데이터를 주고받다 끊기까지다. 왼쪽은 Central, 오른쪽은 Peripheral의 HCI 로그이고, 가운데는 로그에 없는 공중의 링크 계층 PDU다. 동그라미 숫자는 아래 해석기의 사례가 나타나는 자리다.
지도를 손에 쥐면 로그 읽기가 “어디서 멈췄나” 찾기가 된다. 연결 완료 이벤트가 오지 않았으면 광고·연결 단계, Encryption Change가 오지 않았거나 Status가 0이 아니면 키 단계, 데이터가 오가다 끊겼으면 Disconnection Complete의 Reason을 본다. 그리고 그 이유가 한쪽 로그만의 이야기인지 상대 로그로 확인한다.
LE Extended Create Connection(v1 0x0043, v2 0x0085): [Vol 4] Part E §7.8.66 · LE Enhanced Connection Complete(v1 0x0A, v2 0x29): §7.7.65.10 · LE Read Remote Features Page 0: §7.8.21·§7.7.65.4 · 데이터 길이 변경 0x07: §7.7.65.7 · PHY 갱신 완료 0x0C: §7.7.65.12 · LE Enable Encryption: §7.8.24 · LTK 요청 0x05와 응답(0x001A·0x001B): §7.7.65.5·§7.8.25·§7.8.26 · Encryption Change(v1 0x08, v2 0x59): §7.7.8 · Number Of Completed Packets: §7.7.19 · Disconnection Complete: §7.7.5
여섯 가지 사례
HCI 로그 해석기
② 사례에서 Central 로그는 0x3D(MIC 실패)로 끊겼다고 한다. 같은 순간 Peripheral 로그에는 무엇이 적혀 있을 가능성이 가장 클까?
MIC 실패를 알아챈 쪽은 곧바로 연결 상태에서 빠져나간다. 상대는 그걸 어떻게 알까?
사례 뒤의 규칙들
- 감독 타임아웃: connSupervisionTimeout은 10 ms 배수, 100 ms~32 s이고 (1 + connPeripheralLatency) × connSubrateFactor × connInterval × 2보다 커야 한다. 이 하한은 “합법”의 기준일 뿐이다. 하한에 가까우면 몇 번만 연달아 놓쳐도 끊긴다.
- instant: Peripheral은 (Instant − connEventCount) mod 65536 ≥ 32767이면 instant가 지난 것으로 보고, 연결 갱신 PDU였다면 연결을 잃은 것으로 보고 0x28을 알린다. instant를 고르는 쪽은 상대가 실제로 듣는 이벤트(latency·subrating을 감안해) 최소 6개 뒤로 잡는 것이 권장된다.
- MIC 실패: 인증에 실패한 PDU를 받으면 연결을 잃은 것으로 보고 0x3D. 상대는 감독 타임아웃으로 안다.
- 키 없음: Peripheral의 호스트가 LTK를 주지 못하면 링크 계층은 LL_REJECT_IND(0x06)로 거절하거나(암호화 재개 중이면) 연결을 끊는다.
- 명령 거부: 광고가 켜져 있는 동안 LE Set Advertising Parameters는 Command Disallowed(0x0C). 상태에 따라 금지되는 명령은 각 명령 절의 “Errors” 표에 있다.
- ATT_MTU: 기본 23바이트라 알림 값은 최대 20바이트다. 더 보내려면 ATT_EXCHANGE_MTU로 늘린다(고정 채널에서는 클라이언트가 연결당 한 번만).
오류 코드 표 ([Vol 1] Part F Table 1.1)
감독 타임아웃: [Vol 6] Part B §4.5.2 · instant와 6이벤트 권장: §5.5, §5.5.1 · 연결 갱신: §5.1.1 · 암호화 시작과 키 없음: §5.1.3.1 · MIC 실패: [Vol 6] Part E §1 · 절차 충돌(0x23·0x2A): [Vol 6] Part B §5.3 · Set Advertising Parameters의 오류: [Vol 4] Part E §7.8.5 · ATT_MTU 교환: [Vol 3] Part F §3.2.8, §3.4.2.1, §3.4.7.1 · LE 기본 ATT_MTU 23: [Vol 3] Part G §5.2.1 · 오류 코드: [Vol 1] Part F §1.3, §2
로그 얻기와 열기
해석은 로그가 있어야 시작된다. HCI 로그는 운영체제가 호스트와 컨트롤러 사이에서 받아 적는다. 아래 방법은 Core 사양이 아니라 각 운영체제와 도구의 기능이라 판마다 메뉴 이름이 조금씩 다르다(2026년 기준).
| 환경 | 켜는 법 | 파일 |
|---|---|---|
| Android | 개발자 옵션 → 블루투스 HCI 스누프 로그를 “사용”으로 바꾸고 블루투스를 껐다 켠 뒤 재현. “필터링됨”은 데이터 내용을 잘라 낸다 | adb bugreport의 압축 파일 안 btsnoop_hci.log |
| iOS·iPadOS | Apple 개발자 사이트의 Bluetooth 로깅 프로필을 설치하고 재현한 뒤 sysdiagnose를 받는다 | .pklg (macOS의 PacketLogger로 연다) |
| Linux (BlueZ) | btmon -w trace.log를 켜 둔 채 재현 | btsnoop 형식 |
| 임베디드 칩 | HCI UART 선을 로직 분석기나 칩 제조사 도구로 받는다 | H4 바이트열(이 장의 해석기에 그대로 넣을 수 있다) |
btsnoop 파일은 Wireshark로 열린다. 필터 몇 개만 알면 수천 줄에서 필요한 곳으로 바로 간다. bthci_evt.code == 0x05는 끊김(그림 18-1의 마지막 줄), bthci_evt.code == 0x3e는 LE 이벤트 전체, btatt는 ATT, btsmp는 페어링이다. 끊김을 찾았으면 그 직전 몇 초를 거꾸로 읽으며 지도의 어느 단계에서 멈췄는지 본다.
핵심 정리
- H4: 0x01 명령, 0x02 ACL, 0x04 이벤트. 숫자는 리틀 엔디언, LE 이벤트는 0x3E 안의 하위 코드.
- Command Status 0x00은 “시작”일 뿐, 결과는 뒤의 이벤트에서 본다.
- 끊김 이유는 양쪽이 다를 수 있다. 알아챈 쪽은 0x3D·0x28, 상대는 0x08로 적힌다.
- 감독 타임아웃 하한 = (1 + 지연) × subrate × 간격 × 2. 하한 근처는 합법이지만 위험하다.
- 0x06은 키 없음(재페어링), 0x0C는 지금 상태에서 금지된 명령, 알림 20바이트는 ATT_MTU 23의 한계.
- 로그는 연결 생성 → 연결 완료 → 기능 교환 → 데이터 길이·PHY → 암호화 → 데이터 → 끊김 순서로 남는다. 어디서 멈췄는지 찾고, 상대 로그로 확인한다.
- HCI 로그는 Android 개발자 옵션, iOS 로깅 프로필, Linux btmon으로 얻고 Wireshark로 연다. 공중에서만 일어난 일은 공중 캡처가 필요하다.
확인 퀴즈
04 05 04 00 40 00 13의 뜻은?
간격 50 ms, 지연 4, subrate 1일 때 감독 타임아웃으로 쓸 수 있는 가장 작은 값은?
connEventCount가 100인데 받은 LL_CONNECTION_UPDATE_IND의 Instant가 95다. Peripheral은?
ATT_MTU를 247로 교환한 뒤 알림 하나에 실을 수 있는 값의 최대 길이는?
Central 로그에서 LE Enable Encryption(0x2019) 뒤로 Encryption Change가 오지 않는다. Peripheral 로그에는 LE Long Term Key Request(0x3E/0x05) 바로 뒤에 0x201B가 있다. 무엇을 먼저 의심할까?