Windows PC 蓝牙 HCI(btsnoop)抓包工具
Windows 蓝牙 HCI 抓包工具:一个 Python 脚本生成 btsnoop 日志
背景
蓝牙开发调试中,抓取 HCI 日志是定位问题的常用手段。Android 上打开开发者选项就能拿到 btsnoop 日志,但 Windows 上却没有这么方便的途径。
bt_capture.py 是一个轻量的 Python 脚本,利用 Windows 内置命令实现蓝牙 HCI 抓包,自动转换为标准 btsnoop 格式,可直接用 Wireshark 打开。零依赖,无需安装任何第三方库。
使用方法
管理员终端下运行,按 Enter 停止,自动生成 btsnoop 文件:
python bt_capture.py
也支持离线转换已有的 ETL/XML 文件:
python bt_capture.py bt_hci.etl
实现方式
整个流程分三步,串联了两个 Windows 内置工具和一段 Python 解析逻辑:
netsh trace (.etl) → tracerpt (.xml) → Python 解析 (.btsnoop)
- netsh trace:启用
Microsoft-Windows-BTH-BTHPORT和Microsoft-Windows-BTH-BTHUSB两个 ETW Provider,采集蓝牙 HCI 原始数据,输出 ETL 文件。 - tracerpt:将二进制 ETL 转为 XML,使事件数据可被解析。
- Python 解析:从 XML 中提取
HCIRAW通道的事件,读取BIP_Type(区分 Command/Event/ACL 及方向)和BIP_Data(HCI 负载),按 btsnoop 文件格式规范(魔数 + 版本 + datalink type 1002 + 逐条记录)写入输出文件。
生成的 btsnoop 文件与 Android 的 btsnoop_hci.log 格式一致,Wireshark 会自动解析出 HCI、L2CAP、ATT/GATT 等协议层。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
蓝牙 HCI 抓包工具
用法:
管理员运行,无参数: 启动抓包 -> 等待 Enter -> 停止 -> 转换
管理员运行,带参数: python bt_capture.py <etl或xml文件> [输出btsnoop]
"""
import subprocess
import sys
import os
import struct
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
def make_session_dir():
d = os.path.join(_BASE_DIR, datetime.now().strftime("%Y%m%d_%H%M%S"))
os.makedirs(d, exist_ok=True)
return d
# BIP_Type -> H4 indicator byte
# 1=CMD(sent), 2=EVT(recv), 3=ACL(recv), 4=ACL(sent)
BIP_TO_H4 = {1: 0x01, 2: 0x04, 3: 0x02, 4: 0x02}
# BIP_Type -> btsnoop flags (0=sent host->ctrl, 1=recv ctrl->host)
# 1=CMD(sent), 2=EVT(recv), 3=ACL(recv), 4=ACL(sent)
BIP_TO_FLAGS = {1: 0, 2: 1, 3: 1, 4: 0}
def run(cmd, check=True):
print(f" > {cmd}")
r = subprocess.run(f"chcp 437 >nul && {cmd}", shell=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout = r.stdout.decode("ascii", errors="replace").strip()
stderr = r.stderr.decode("ascii", errors="replace").strip()
if stdout:
print(stdout)
if stderr:
print(stderr)
if check and r.returncode not in (0, 1):
print(f"[WARN] 返回码: {r.returncode}")
return r
def start_capture(etl):
print("[1/4] 启动 netsh trace 抓包...")
run('netsh trace stop', check=False)
run(
f'netsh trace start '
f'provider=Microsoft-Windows-BTH-BTHPORT '
f'provider=Microsoft-Windows-BTH-BTHUSB '
f'capture=no report=disabled overwrite=yes '
f'tracefile="{etl}"'
)
def stop_capture():
print("[2/4] 停止抓包...")
run('netsh trace stop')
def etl_to_xml(etl, xml):
print("[3/4] 转换 ETL -> XML...")
if os.path.exists(xml):
os.remove(xml)
run(f'tracerpt "{etl}" -o "{xml}" -of XML -y')
def xml_to_btsnoop(xml, btsnoop):
print("[4/4] 转换 XML -> btsnoop...")
tree = ET.parse(xml)
root = tree.getroot()
ns = {"e": "http://schemas.microsoft.com/win/2004/08/events/event"}
events = []
for event in root.findall("e:Event", ns):
ch = event.find("e:System/e:Channel", ns)
if ch is None or ch.text != "Microsoft-Windows-BTH-HCI/HCIRAW":
continue
data_map = {}
for d in event.findall("e:EventData/e:Data", ns):
data_map[d.get("Name")] = (d.text or "").strip()
if "BIP_Data" not in data_map:
continue
bip_type = int(data_map.get("BIP_Type", "0"))
hex_str = data_map["BIP_Data"].replace("0x", "").replace(" ", "")
try:
payload = bytes.fromhex(hex_str)
except ValueError:
continue
time_str = event.find("e:System/e:TimeCreated", ns).get("SystemTime", "")
try:
ts = datetime.fromisoformat(time_str).astimezone(timezone.utc)
except Exception:
ts = datetime.now(timezone.utc)
events.append((ts, bip_type, payload))
print(f" 找到 {len(events)} 条 HCI 事件")
epoch2000 = datetime(2000, 1, 1, tzinfo=timezone.utc)
with open(btsnoop, "wb") as f:
# btsnoop 文件头: magic(8) + version(4 BE) + datalink(4 BE)
# 1002 = HCI H4
f.write(b"btsnoop\x00")
f.write(struct.pack(">II", 1, 1002))
for ts, bip_type, payload in events:
indicator = BIP_TO_H4.get(bip_type, 0x01)
record = bytes([indicator]) + payload
flags = BIP_TO_FLAGS.get(bip_type, 0)
ts_us = int((ts - epoch2000).total_seconds() * 1_000_000)
f.write(struct.pack(">IIIIq", len(record), len(record), flags, 0, ts_us))
f.write(record)
print(f" 输出: {btsnoop} ({os.path.getsize(btsnoop)} bytes)")
if __name__ == "__main__":
import ctypes
if len(sys.argv) >= 2:
# 直接解析模式,不需要管理员
src = sys.argv[1]
out = sys.argv[2] if len(sys.argv) >= 3 else src.rsplit(".", 1)[0] + ".btsnoop"
if src.lower().endswith(".xml"):
xml_to_btsnoop(xml=src, btsnoop=out)
elif src.lower().endswith(".etl"):
xml = src.rsplit(".", 1)[0] + ".xml"
etl_to_xml(etl=src, xml=xml)
xml_to_btsnoop(xml=xml, btsnoop=out)
else:
print(f"[ERROR] 不支持的文件类型: {src}")
sys.exit(1)
print(f"\n完成!用 Wireshark 打开: {out}")
else:
if not ctypes.windll.shell32.IsUserAnAdmin():
print("[ERROR] 请以管理员身份运行")
sys.exit(1)
d = make_session_dir()
etl = os.path.join(d, "bt_hci.etl")
xml = os.path.join(d, "bt_hci.xml")
btsnoop = os.path.join(d, "bt_hci.btsnoop")
print(f" 输出目录: {d}")
start_capture(etl)
print()
print("=" * 50)
print(" 请现在操作蓝牙(连接设备、复现问题等)")
print(" 完成后按 Enter 停止抓包...")
print("=" * 50)
input()
stop_capture()
etl_to_xml(etl, xml)
xml_to_btsnoop(xml, btsnoop)
print(f"\n完成!用 Wireshark 打开: {btsnoop}")
浙公网安备 33010602011771号