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)
  1. netsh trace:启用 Microsoft-Windows-BTH-BTHPORTMicrosoft-Windows-BTH-BTHUSB 两个 ETW Provider,采集蓝牙 HCI 原始数据,输出 ETL 文件。
  2. tracerpt:将二进制 ETL 转为 XML,使事件数据可被解析。
  3. 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}")
posted @ 2026-05-27 09:56  www378660084  阅读(4)  评论(0)    收藏  举报