让 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):
AT(.volatile_ram_code)voidgo_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 的「板级配置」里改):
#defineTCFG_CFG_TOOL_ENABLE1// FW编辑、在线调音#defineTCFG_COMM_TYPETCFG_USB_COMM// 通信方式:USB打开后,app_config.h会自动打开TCFG_USB_CDC_BACKGROUND_RUN,插上 USB 时固件会在后台枚举 USB 设备。
② 确保后台枚举时带上 CDC 类(apps/common/device/usb/device/task_pc.c):
voidusb_cdc_background_run(constusb_dev usbfd){usb_device_mode(usbfd,MIDI_CLASS|CDC_CLASS);// 至少要有 CDC_CLASScdc_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):
typedefstruct{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
importstruct,timeimportserial,serial.tools.list_ports JL_VID=0x3654defcrc16_xmodem(data:bytes)->int:crc=0forbindata:crc^=b<<8for_inrange(8):crc=((crc<<1)^0x1021)ifcrc&0x8000else(crc<<1)crc&=0xFFFFreturncrcassertcrc16_xmodem(b"123456789")==0x31C3defbuild_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 DATAreturnb"\x5A\xAA\xA5"+struct.pack("<H",crc16_xmodem(body))+bodydeffind_jl_port():forpinserial.tools.list_ports.comports():ifp.vid==JL_VID:returnp.devicereturnNonedefquery_info(port):"""返回设备 SDK 名,用来确认链路正常"""withserial.Serial(port,115200,timeout=1)ass:# CDC 口波特率无意义s.write(build_frame(0x400))rx=s.read(9+102)iflen(rx)<9orrx[:3]!=b"\x5A\xAA\xA5":returnNoneassertstruct.unpack_from("<H",rx,3)[0]==crc16_xmodem(rx[5:])returnrx[9+34:9+66].split(b"\0")[0].decode()defenter_upgrade(port):try:withserial.Serial(port,115200,timeout=0.05,write_timeout=1)ass:s.write(build_frame(0x26))exceptserial.SerialException:pass# 设备收到命令立刻复位,写的时候报错是正常的for_inrange(50):# CDC 口消失说明设备已复位iffind_jl_port()!=port:returnTruetime.sleep(0.1)returnFalse# 见 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 目录下直接执行:
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. 下载
设备进入升级模式后,单独执行一次下载脚本:
importos,subprocessdefdownload(tools_dir):# tools_dir = SDK/cpu/br27/toolsenv={k:vfork,vinos.environ.items()ifk.upper()!="NODEFAULTCURRENTDIRECTORYINEXEPATH"}# 坑,见下文forattemptinrange(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"inout:# ROM 刚复位还没枚举好,等一下重试time.sleep(1.5)continuereturn"Download completed"inout,outreturnFalse,outisd_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:
isd_download.exe -dev br27 -boot 0x120000 -reboot 1000在download.c的下载命令末尾加上-reboot 500:
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 读日志;日志服务没开时,再直接打开串口。核心代码很短:
importre,socket,threading,serial clients,lock=[],threading.Lock()defaccept_loop(srv):whileTrue:c,_=srv.accept()withlock: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""whileTrue:pending+=ser.read(4096)*lines,pending=re.split(rb"\r?\n",pending)forrawinlines:line=raw+b"\n"print(line.decode("utf-8","replace"),end="")# 人看withlock:# 转发给 AI 脚本forcinclients[:]:try:c.sendall(line)exceptOSError:clients.remove(c)想用自己习惯的串口工具看也可以:PuTTY 选 Raw 模式,连
127.0.0.1:5555。
6.2 让 AI 确认"固件真的更新了"
AI 最容易犯的错误是:烧录其实失败了,它却在旧固件的日志里找结论。解决办法是开机打印编译时间,脚本再拿它和本轮编译的开始时间比对。
User/build_info.c:
#ifndefBUILD_GIT_HASH#defineBUILD_GIT_HASHunknown#endif#defineSTR_(x)#x#defineSTR(x)STR_(x)voidBuildInfo_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 里让它每次都重编:
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-dirty6.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:1487AI 改一次参数、跑一轮,读metric_lines就能知道效果,再决定下一步怎么改。
7. 串起来:总控脚本
defrun(build=True,flash=True,log_seconds=8):summary={"steps":{}}logger=LogReader().start()# 优先连日志服务,连不上再开串口ifbuild:t0=datetime.now()summary["steps"]["build"]=make_build()# 返回 ok/errors/secondsifnotsummary["steps"]["build"]["ok"]:returnsummary# 编译失败就不动设备mark=logger.mark()ifflash: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)returnsummary每轮的完整输出(build.log、download.log、device.log、summary.json)都存到results/<时间>_<tag>/,方便事后回看。
8. 教会 AI 用这套工具
在工程根目录的CLAUDE.md(或你用的 AI 工具对应的规则文件)里写清楚:
## 硬件闭环测试 改完固件代码后,必须用实测结果说话,不能只凭"编译通过"下结论: - 只确认能编译: 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 | 单独复位设备 |