杰理 JL703N SDK 实现 AI 自动化调试:自动进入下载模式、下载、重启与日志抓取

让 AI 自己改代码、烧录、看日志:杰理 JL703N SDK 自动化调试实践

平台:杰理 JL703N 系列(BR27,SDK le_audio / AC703N),其他 BR2x/BR5x 芯片思路相同

环境:Windows 10/11、Python 3、JLStudio 工具链(C:\JL\pi32)

适合:想让 AI 编程助手(Claude Code、Cursor 等)自己跑「改代码 → 编译 → 烧录 → 看日志 → 判断结果」闭环的嵌入式开发者


0. 先看效果

搭好之后,AI 改完代码只需要跑一条命令:

复制代码
python run_test.py --build --flash --log 8 --tag fix_xxx

不到 1 分钟,它会自动完成下面这些步骤:

复制代码
>> 编译 ...                 OK (4.1s)
>> 进升级模式 + 下载 ...     OK      ← 不用按键、不用拔线
>> 抓日志 8s ...
{
  "ok": true,
  "steps": {
    "build": {"ok": true, "seconds": 4.1, "errors": []},
    "flash": {"ok": true, "steps": {"enter_upgrade": {...}, "download": {"rebooted": true}, "boot": {...}}},
    "log": {
      "boot_seen": true,
      "build_info": {"compile_time": "Oct 14 2026 15:16:04", "is_current_build": true},
      "unexpected_resets": 0,
      "new_error_lines": [],
      "metric_lines": ["[METRIC] dac_lvl pre min:8 max:11 ..."]
    }
  }
}

AI 读这份 summary.json 就能判断:编译过没过、固件是不是真的更新了、开机后有没有死机复位、有没有新出现的错误日志,以及自己加的调试指标是多少。

整个闭环拆成 4 个问题:

问题 方案
怎么不按键让设备进下载模式? 通过 USB CDC 虚拟串口发配置工具协议命令,固件调用go_mask_usb_updata() 复位进 ROM 升级模式
怎么在命令行编译、下载? make 编译,然后调 SDK 自带的 download.bat(内部是 isd_download.exe)
下载完怎么自动重启? isd_download.exe 加 -reboot 500
怎么让 AI看日志、判断对错? 脚本抓调试串口日志,固件按约定格式打印版本和指标,脚本汇总成 JSON

下面逐个讲,每一步都给出可以直接用的代码。


1. 整体架构

复制代码
          ┌──────────────── PC ─────────────────────────────┐
          │  AI 助手                                         │
          │    │ 调用                                        │
          │  run_test.py ──► make(编译)                    │
          │    │         ──► CDC 串口:发「进入升级模式」命令 │
          │    │         ──► download.bat(isd_download 下载)│
          │    │         ◄── 日志服务(TCP 127.0.0.1:5555)  │
          │    ▼                         ▲                   │
          │  summary.json          log_gui.py(独占 COM 口)  │
          └───────┬───────────────────────┬──────────────────┘
             USB  │                       │ USB 转串口(CH340)
          ┌───────┴───────────────────────┴──────────────────┐
          │  JL703N:USB(MIDI + CDC 复合设备)  调试 UART TX │
          └──────────────────────────────────────────────────┘

硬件只需要两根线:

  1. 设备 USB → PC:固件运行时枚举成 USB 复合设备,其中的 CDC 虚拟串口用来发命令;进入升级模式后,同一根线用来下载。
  2. 调试串口 → USB 转串口模块 → PC:看日志。

2. 自动进入下载模式

2.1 原理:go_mask_usb_updata()

SDK 里有一个现成的函数(apps/common/device/usb/device/msd_upgrade.c):

c 复制代码
AT(.volatile_ram_code)
void go_mask_usb_updata()
{
    usb_g_hold(0);                                // 保持 USB 连接
    local_irq_disable();
    ram_protect_close();
    nvram_set_boot_state(UPGRADE_USB_SOFT_KEY);   // 写启动标志:USB 软件触发升级
    cpu_reset();                                  // 复位后 ROM 读到标志,进入 USB 升级模式
    while (1);
}

调用它,设备就会复位进入芯片 ROM(mask)里的 USB 升级模式,等待 PC 下载。问题在于:PC 怎么触发固件调用它?

SDK 里有 3 个调用点:

触发方式 条件 Windows 下是否需要装驱动
U 盘(MSD)私有 SCSI 命令0xFB/0xFD/0xFC 打开 PC 模式(TCFG_PC_ENABLE) 不需要,但要开 U 盘功能
EP0 控制请求GET_STATUS(recipient=Other,wValue=0xFC,wIndex=0xFE,wLength=0),见 user_setup.c 任何 USB 设备模式下都有 需要:设备绑的是系统类驱动,libusb 拿不到,要用 Zadig 装 WinUSB 或 UsbDk
配置工具协议命令 ENTER_UPGRADE_MODE (0x26) ,见 new_cfg_tool.c 打开配置工具,走 USB CDC 不需要 ,用系统自带的 usbser 驱动

本文用第 3 种:免驱,普通串口就能发。

2.2 固件配置

只需要改两处:

① 打开配置工具,并让它走 USB (apps/soundbox/board/br27/sdk_config.h,可以在 JLStudio 的「板级配置」里改):

c 复制代码
#define TCFG_CFG_TOOL_ENABLE      1               // FW编辑、在线调音
#define TCFG_COMM_TYPE            TCFG_USB_COMM   // 通信方式:USB

打开后,app_config.h 会自动打开 TCFG_USB_CDC_BACKGROUND_RUN,插上 USB 时固件会在后台枚举 USB 设备。

② 确保后台枚举时带上 CDC 类 (apps/common/device/usb/device/task_pc.c):

c 复制代码
void usb_cdc_background_run(const usb_dev usbfd)
{
    usb_device_mode(usbfd, MIDI_CLASS | CDC_CLASS);   // 至少要有 CDC_CLASS
    cdc_set_wakeup_handler(usb_cdc_wakeup);
}

我的工程原来只开了 MIDI_CLASS,所以要补上 CDC_CLASS。CDC 自带 IAD 描述符,SDK 会把设备类设为 0xEF/0x02/0x01,复合设备在 Windows 和 macOS 上都能正常识别。

编译烧录后插上 USB,设备管理器的「端口」下会出现 「USB 串行设备 (COMxx)」:

  • VID = 0x3654(杰理)
  • PID 会随 USB 类组合变化 :SDK 在 user_setup.c 里把 PID 高字节按类位图做了偏移,MIDI+CDC 时是 0x4D55,只有 MIDI 时是 0x4855。所以用 VID 找设备,不要写死 PID。

2.3 协议格式

配置工具协议的帧格式(从 new_cfg_tool.c 的 online_cfg_tool_data_deal 和 all_assemble_package_send_to_pc 反推):

复制代码
| 5A AA A5 | CRC_L CRC_H | LEN_L LEN_H | ID | SQ | DATA ... |
   帧头       CRC16         长度          通道  序号  命令号(4字节小端) + 参数
字段 字节数 说明
帧头 3 固定5A AA A5
CRC16 2 小端,计算范围从 LEN 开始到帧尾
LEN 2 小端,= 2 + DATA 长度
ID 1 PC → 设备填0x21(可视化配置工具通道);设备回复是 0x00
SQ 1 包序号,随便填,回复里原样带回
DATA N 前 4 字节是小端命令号

CRC16 是 CRC-16/XMODEM (多项式 0x1021,初值 0,不反转,不异或)。固件里的 CRC16() 在库里看不到源码,我是用查询命令逐个试出来的:只有 XMODEM 能收到设备回复。自测方法:"123456789" 的结果应该是 0x31C3。

常用命令:

命令 命令号 完整帧(SQ=0x10,可直接用串口助手按 HEX 发送)
查询设备信息 0x00000400 5A AA A5 F9 28 06 00 21 10 00 04 00 00
进入升级模式 0x00000026 5A AA A5 EE E4 06 00 21 10 26 00 00 00
设备重启(普通复位) 0x00000505 5A AA A5 8C A3 06 00 21 10 05 05 00 00

查询命令会回复 102 字节的 S_QUERY_BASIC_INFO(定义在 cfg_tool.h):

c 复制代码
typedef struct {
    u16 protocolVer;
    u8  progCrc[32];
    u8  sdkName[32];        // 如 "ac703n_V106-@20250427-$1ef092b8"
    u8  pid[16];            // 如 "JL703N"
    u8  vid[16];
    u16 max_buffer_size;
    u8  support_node_merge;
    u8  comm_type;          // 3 = USB
} S_QUERY_BASIC_INFO;

能收到这个回复,就说明链路和 CRC 都没问题。

2.4 最小可用的 Python 实现

依赖:pip install pyserial

python 复制代码
import struct, time
import serial, serial.tools.list_ports

JL_VID = 0x3654

def crc16_xmodem(data: bytes) -> int:
    crc = 0
    for b in data:
        crc ^= b << 8
        for _ in range(8):
            crc = ((crc << 1) ^ 0x1021) if crc & 0x8000 else (crc << 1)
            crc &= 0xFFFF
    return crc

assert crc16_xmodem(b"123456789") == 0x31C3

def build_frame(cmd: int, sq: int = 0x10, payload: bytes = b"") -> bytes:
    data = struct.pack("<I", cmd) + payload
    body = struct.pack("<H", 2 + len(data)) + bytes([0x21, sq]) + data   # LEN ID SQ DATA
    return b"\x5A\xAA\xA5" + struct.pack("<H", crc16_xmodem(body)) + body

def find_jl_port():
    for p in serial.tools.list_ports.comports():
        if p.vid == JL_VID:
            return p.device
    return None

def query_info(port):
    """返回设备 SDK 名,用来确认链路正常"""
    with serial.Serial(port, 115200, timeout=1) as s:     # CDC 口波特率无意义
        s.write(build_frame(0x400))
        rx = s.read(9 + 102)
    if len(rx) < 9 or rx[:3] != b"\x5A\xAA\xA5":
        return None
    assert struct.unpack_from("<H", rx, 3)[0] == crc16_xmodem(rx[5:])
    return rx[9 + 34: 9 + 66].split(b"\0")[0].decode()

def enter_upgrade(port):
    try:
        with serial.Serial(port, 115200, timeout=0.05, write_timeout=1) as s:
            s.write(build_frame(0x26))
    except serial.SerialException:
        pass                       # 设备收到命令立刻复位,写的时候报错是正常的
    for _ in range(50):            # CDC 口消失说明设备已复位
        if find_jl_port() != port:
            return True
        time.sleep(0.1)
    return False                   # 见 6.4:不一定是失败

if __name__ == "__main__":
    port = find_jl_port()
    print("port:", port, "sdk:", query_info(port))
    print("enter upgrade:", enter_upgrade(port))

如果写串口时报 Write timeout,说明 CDC 口存在但固件不读数据,几乎都是板子上烧的还是没开 TCFG_CFG_TOOL_ENABLE 的旧固件。


3. 命令行编译

JLStudio 底层就是 make。在 SDK 目录下直接执行:

bat 复制代码
set PATH=%CD%\tools\utils;C:\JL\utils;%PATH%
make -j8

增量编译一般 4~10 秒。几个要点:

  1. Makefile 的 post-build 会自动调 cpu\br27\tools\download.bat ,也就是编译完就尝试下载。设备不在下载模式时,isd_download 会在 1 秒内报 Device Offline 退出,不会卡住。所以先编译、编译成功后再让设备进升级模式:编译失败时设备还在正常运行,不会卡在 ROM 模式里。
  2. 判断编译是否成功:看 make 的返回码,并在输出里搜 error:。
  3. download.bat 是由 download.c 用预处理器生成的(见 Makefile 里的 -E -P cpu/br27/tools/download.c -o $(POST_SCRIPT)),要改下载命令就改 download.c ,直接改 bat 会在下次编译时被覆盖。isd_config.ini 同理,由 isd_config_rule.c 生成。

4. 下载

设备进入升级模式后,单独执行一次下载脚本:

python 复制代码
import os, subprocess

def download(tools_dir):           # tools_dir = SDK/cpu/br27/tools
    env = {k: v for k, v in os.environ.items()
           if k.upper() != "NODEFAULTCURRENTDIRECTORYINEXEPATH"}     # 坑,见下文
    for attempt in range(3):
        p = subprocess.run(["cmd", "/c", os.path.join(tools_dir, "download.bat")],
                           cwd=tools_dir, env=env, capture_output=True, timeout=300)
        out = p.stdout.decode("gbk", "replace")
        if "Device Offline" in out:            # ROM 刚复位还没枚举好,等一下重试
            time.sleep(1.5)
            continue
        return "Download completed" in out, out
    return False, out

isd_download 成功时的输出(节选):

复制代码
Device online
Flash ID      : CD7015
Start downloading data to flash ......
Write block: 7 6 5 4 3 2 1. 0.
Download completed.
Erase app2 core data ....[ SUCCESS ]
rebooting device ......

踩过的两个坑:

  1. 'isd_download.exe' 不是内部或外部命令 :AI 助手的 shell 环境可能设置了 NoDefaultCurrentDirectoryInExePath,导致 cmd 不在当前目录查找 exe。启动子进程时把这个环境变量删掉。注意 Windows 上 os.environ 的键是大写,要不区分大小写地匹配。
  2. 不要用 fail 关键字判断失败 :下载输出里有一张 OTA 空间检查表,其中会出现 FAIL: testbox BLE upgrade ... require minial space size,这只是提示信息。要用正向标志 Download completed 判断成功。

5. 下载后自动重启

进入 ROM 下载模式后,运行的是芯片 ROM 和下载工具下发的 loader,固件代码完全不参与 。所以下载完要不要重启,固件里没有开关可配,只由 isd_download.exe 的 -reboot <毫秒> 参数决定。

SDK 里本来就带了一个单独复位的 cpu/br27/tools/reboot.bat:

bat 复制代码
isd_download.exe -dev br27 -boot 0x120000 -reboot 1000

在 download.c 的下载命令末尾加上 -reboot 500:

bat 复制代码
isd_download.exe -tonorflash -dev br27 -boot 0x120000 -div8 -wait 300 -uboot uboot.boot -app app.bin ... %KEY_FILE% %FORMAT% -reboot 500

实测在 BR27 上 -reboot 可以和 -tonorflash 写在同一条命令里,输出末尾会多出 rebooting device ......,烧完设备自动运行新固件。

判断设备是否已经重启:等 VID=0x3654 的 CDC 口重新出现。


6. 抓日志

6.1 串口只能被一个程序打开

调试串口(我这里是 CH340,2 Mbps ,对应 TCFG_DEBUG_UART_BAUDRATE)同一时间只能被一个程序打开。可是人要看日志,AI 也要读日志。

我的做法是写一个日志服务:它独占串口,一边在 GUI 窗口里实时显示(错误行标红、复位行标蓝),一边通过本机 TCP 把每一行转发出去。AI 的脚本连 TCP 读日志;日志服务没开时,再直接打开串口。核心代码很短:

python 复制代码
import re, socket, threading, serial

clients, lock = [], threading.Lock()

def accept_loop(srv):
    while True:
        c, _ = srv.accept()
        with lock:
            clients.append(c)

srv = socket.socket(); srv.bind(("127.0.0.1", 5555)); srv.listen(8)
threading.Thread(target=accept_loop, args=(srv,), daemon=True).start()

ser = serial.Serial("COM3", 2000000, timeout=0.1)
pending = b""
while True:
    pending += ser.read(4096)
    *lines, pending = re.split(rb"\r?\n", pending)
    for raw in lines:
        line = raw + b"\n"
        print(line.decode("utf-8", "replace"), end="")      # 人看
        with lock:                                          # 转发给 AI 脚本
            for c in clients[:]:
                try:
                    c.sendall(line)
                except OSError:
                    clients.remove(c)

想用自己习惯的串口工具看也可以:PuTTY 选 Raw 模式,连 127.0.0.1:5555。

6.2 让 AI 确认"固件真的更新了"

AI 最容易犯的错误是:烧录其实失败了,它却在旧固件的日志里找结论。解决办法是开机打印编译时间,脚本再拿它和本轮编译的开始时间比对。

User/build_info.c:

c 复制代码
#ifndef BUILD_GIT_HASH
#define BUILD_GIT_HASH  unknown
#endif
#define STR_(x) #x
#define STR(x)  STR_(x)

void BuildInfo_Print(void)
{
    printf("[BUILD] compile time: %s %s, git: %s%s\n", __DATE__, __TIME__,
           STR(BUILD_GIT_HASH), BUILD_GIT_DIRTY ? "-dirty" : "");
}

只放 __DATE__ __TIME__ 还不够:增量编译时这个文件不会被重编,时间就停在旧值。在 Makefile 里让它每次都重编:

make 复制代码
BUILD_GIT_HASH  := $(or $(word 1,$(shell git rev-parse --short HEAD)),unknown)
BUILD_GIT_DIRTY := $(if $(strip $(shell git status --porcelain --untracked-files=no -- *.c *.h)),1,0)
$(BUILD_DIR)/User/build_info.c.o: FORCE
$(BUILD_DIR)/User/build_info.c.o: DEFINES += -DBUILD_GIT_HASH=$(BUILD_GIT_HASH) -DBUILD_GIT_DIRTY=$(BUILD_GIT_DIRTY)
.PHONY: FORCE
FORCE:

判断 dirty 时只看 .c/.h:SDK 仓库里 sdk.map、jl_isd.fw 这类编译产物也被跟踪,每次编译都会变,如果算进去 dirty 永远是 1。

开机早期调用 BuildInfo_Print()(比如在 app_main.c 调用的用户初始化函数里),日志里就会出现:

复制代码
[00:00:00.126][BUILD] compile time: Oct 14 2026 15:16:04, git: 4403254-dirty

6.3 让日志变成"AI 能判断的结论"

原始日志有几百行,AI 直接读既费 token 又容易误判。我在脚本里做了这几件事,最后汇总成 summary.json:

检查项 做法
定位"烧录后的开机" 设备日志带运行时间戳[hh:mm:ss.mmm]。以时间戳倒退的那一行作为重启点。不能只看"时间戳很小":连续烧录时,旧固件可能才运行了几秒
意外复位(死机、看门狗、断言) 重启点之后时间戳又倒退一次,就算一次意外复位,必须为 0
固件版本 找[BUILD] 行,编译时间早于本轮编译开始时间就判失败
新错误 匹配 `error
自定义指标 约定:固件里给 AI 看的指标行都带[METRIC] 前缀,脚本原样收集
驱动的单字符标记 比如 DAC 欠载时驱动会putchar('U'),这类标记单独成行,按 ^U+$ 统计

[METRIC] 这个约定是整套流程里最有用的部分。比如调音频延时时,我让固件每秒打印一次 DAC 缓冲水位:

复制代码
[METRIC] dac_lvl pre min:8 max:11 avg:10 ... isr_gap_max_us:1487

AI 改一次参数、跑一轮,读 metric_lines 就能知道效果,再决定下一步怎么改。


7. 串起来:总控脚本

python 复制代码
def run(build=True, flash=True, log_seconds=8):
    summary = {"steps": {}}
    logger = LogReader().start()                       # 优先连日志服务,连不上再开串口
    if build:
        t0 = datetime.now()
        summary["steps"]["build"] = make_build()       # 返回 ok/errors/seconds
        if not summary["steps"]["build"]["ok"]:
            return summary                             # 编译失败就不动设备
    mark = logger.mark()
    if flash:
        port = find_jl_port()
        enter_upgrade(port)
        ok, out = download(TOOLS_DIR)
        booted = wait_for_port(JL_VID, timeout=30)     # 新固件起来后 CDC 口会重新出现
        summary["steps"]["flash"] = {"download_ok": ok, "booted": booted}
    time.sleep(log_seconds)
    summary["steps"]["log"] = analyze_log(logger.since(mark), build_start=t0)
    summary["ok"] = all_steps_ok(summary)
    json.dump(summary, open(out_dir / "summary.json", "w"), ensure_ascii=False, indent=2)
    return summary

每轮的完整输出(build.log、download.log、device.log、summary.json)都存到 results/<时间>_<tag>/,方便事后回看。


8. 教会 AI 用这套工具

在工程根目录的 CLAUDE.md(或你用的 AI 工具对应的规则文件)里写清楚:

markdown 复制代码
## 硬件闭环测试
改完固件代码后,必须用实测结果说话,不能只凭"编译通过"下结论:
- 只确认能编译:     python tools/ai_test/run_test.py --build
- 改了初始化/外设:  python tools/ai_test/run_test.py --build --flash --log 15
- 改了音频通路:     python tools/ai_test/run_test.py --build --flash --log 8 --latency
读 results/<最新目录>/summary.json:
- ok 为 false 时先看是哪一步失败
- build_info.is_current_build 必须为 true,否则说明烧录没生效,日志不可信
- unexpected_resets 必须为 0
- 只关注 new_error_lines;已知开机日志在 log_baseline.txt
- 需要观察的量,在固件里用 [METRIC] 前缀打印

我实际用下来,AI 能自己完成这样的循环:加 [METRIC] 打印 → 跑测试 → 读指标 → 定位问题 → 改代码 → 再跑测试。我只需要在它碰到工具配置(比如 JLStudio 里的节点参数)时动手改一下。


9. 可选:声卡环回测音频延时

如果调的是音频产品,可以再加一个客观指标。用一块多通道声卡:

复制代码
声卡 OUT L ──► 设备输入;设备输出 ──► 声卡 IN L      (被测路径)
声卡 OUT R ───────────────────────► 声卡 IN R      (直连参考)

两个声道同时播放同一段 1 kHz 短脉冲,同时录音。IN L 相对 IN R 的时间差就是设备延时,声卡自身的延时和播放、录音不同步的影响都会被抵消。

计算时有一个容易错的地方:1 kHz 正弦每 1 ms 重复一次,直接对波形做互相关,很容易对错一个周期。正确做法是先对包络做互相关得到粗略延时,再在 ±0.6 ms 范围内对波形做互相关、用抛物线插值得到精确值,精度可以到 µs 级。


10. 注意事项

  1. 配置工具协议没有任何鉴权 :插上 USB 的任何人都能让设备进升级模式,还能在线读写 flash。开发阶段很方便,量产固件是否保留 TCFG_CFG_TOOL_ENABLE 要评估。
  2. 不要在设备写 flash 时发升级命令:设备会立即复位,可能打断正在进行的擦写。
  3. 进入升级模式后如果不想烧录:重新上电,或者运行一次 reboot.bat。
  4. 日志服务开着时,其他串口工具不能再打开同一个 COM 口。
  5. Windows 上用 Python 改 SDK 文件时要注意换行符:文本模式写入会把 LF 变成 CRLF,导致 git 显示整个文件都改了。改文件时用二进制模式读写。

附:用到的 SDK 文件一览

文件 作用
apps/common/device/usb/device/msd_upgrade.c go_mask_usb_updata() 进入 ROM 升级模式
apps/common/config/new_cfg_tool.c 配置工具协议解析,ONLINE_SUB_OP_ENTER_UPGRADE_MODE
apps/common/config/include/cfg_tool.h 命令号、回复结构体定义
apps/common/device/usb/device/user_setup.c EP0 私有升级请求、PID 偏移规则
apps/common/device/usb/device/task_pc.c usb_cdc_background_run(),USB 后台枚举的类组合
apps/soundbox/board/br27/sdk_config.h TCFG_CFG_TOOL_ENABLE、TCFG_COMM_TYPE、调试串口波特率
cpu/br27/tools/download.c 生成download.bat,在这里加 -reboot
cpu/br27/tools/reboot.bat 单独复位设备
相关推荐
海海不掉头发1 小时前
软件开发方法第七周笔记_CSDN博客
人工智能·笔记·学习·软件工程
虎头金猫1 小时前
从上传PPT到观众远程打开互动页面:Claper在CentOS 7上的Docker部署、互动组件设置与固定访问地址实战
linux·运维·网络·人工智能·docker·centos·powerpoint
龙亘川1 小时前
水质决策报表业务建模:从自定义报表到六时间尺度自动报表闭环
大数据·人工智能·智慧城市·开源软件
Zwawa1 小时前
PID 的三个参数之外:位置式、增量式和反馈控制
c语言·单片机·嵌入式
科研小牛马1 小时前
北航何静:Codex调用SketchUp实操,常见智能体均适用
人工智能
机器人梦想家1 小时前
【AI工具】Task Kit:把 Spec Kit 的契约化思想搬来管日常工作
人工智能
创界工坊工作室1 小时前
29-基于单片机的万年历设计
stm32·单片机·嵌入式硬件·proteus
ck0135791 小时前
图片抠图怎么做?从背景去除到透明 PNG 的实用教程
人工智能·测试工具·音视频·媒体·视频
美狐美颜sdk1 小时前
直播APP接入第三方视频美颜SDK安全吗?数据处理与隐私问题分析
人工智能·音视频·美颜sdk·第三方美颜sdk·视频美颜sdk·美颜api