让 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 │
└──────────────────────────────────────────────────┘
硬件只需要两根线:
- 设备 USB → PC:固件运行时枚举成 USB 复合设备,其中的 CDC 虚拟串口用来发命令;进入升级模式后,同一根线用来下载。
- 调试串口 → 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 秒。几个要点:
- Makefile 的 post-build 会自动调
cpu\br27\tools\download.bat,也就是编译完就尝试下载。设备不在下载模式时,isd_download会在 1 秒内报Device Offline退出,不会卡住。所以先编译、编译成功后再让设备进升级模式:编译失败时设备还在正常运行,不会卡在 ROM 模式里。 - 判断编译是否成功:看
make的返回码,并在输出里搜error:。 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 ......
踩过的两个坑:
'isd_download.exe' 不是内部或外部命令:AI 助手的 shell 环境可能设置了NoDefaultCurrentDirectoryInExePath,导致 cmd 不在当前目录查找 exe。启动子进程时把这个环境变量删掉。注意 Windows 上os.environ的键是大写,要不区分大小写地匹配。- 不要用
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. 注意事项
- 配置工具协议没有任何鉴权 :插上 USB 的任何人都能让设备进升级模式,还能在线读写 flash。开发阶段很方便,量产固件是否保留
TCFG_CFG_TOOL_ENABLE要评估。 - 不要在设备写 flash 时发升级命令:设备会立即复位,可能打断正在进行的擦写。
- 进入升级模式后如果不想烧录:重新上电,或者运行一次
reboot.bat。 - 日志服务开着时,其他串口工具不能再打开同一个 COM 口。
- 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 |
单独复位设备 |