起因

一块 AT32F403A(雅特力,Cortex-M4F @ 240MHz)的工业控制板,串口通信出问题了:短命令一切正常,长命令后半段全是乱码,偶发丢字节,频率不固定。用逻辑分析仪抓波形——干净得很,一个毛刺都没有。

波形正常,MCU 收到的却是错的数据。那问题出在哪?

加 printf 排查,一轮十几分钟,改了三次代码,异常依旧。这时候我意识到:问题的答案不在代码里,在芯片的寄存器里——但寄存器是看不见的,除非你有办法让程序停下来,把芯片的内部状态翻出来看。

这篇文章讲我怎么用 AI + SWD + 串口 这套组合,几分钟定位到根因,以及总结出的嵌入式 AI 调试范式。

需求

  • 在 GCC + OpenOCD 环境下调试 AT32F403A(不用 Keil/IAR)
  • 用 J-Link 通过 SWD 接口连接目标板,能实时查看寄存器和内存
  • 解决 OpenOCD 认不出 J-Link 的驱动问题(zadig 替换 WinUSB)
  • 用 Python 脚本自动化串口数据收发和验证,异常可复现
  • 用 Codex 辅助生成调试脚本和修复代码
  • 最终总结一套可复用的 AI + 开发板 + 开源工具 嵌入式调试范式

环境准备

硬件连接

1
2
3
4
5
6
J-Link          AT32F403A 目标板
──────          ──────────────
SWDIO    →      PA13 (SWDIO)
SWCLK    →      PA14 (SWCLK)
GND      →      GND
VTref    →      3.3V

注意 VTref 必须接目标板电源参考电压,否则 J-Link 无法识别目标芯片。

软件工具链

工具 用途
arm-none-eabi-gcc 编译(-mcpu=cortex-m4 -mthumb -g -O0
arm-none-eabi-gdb 调试器客户端
OpenOCD GDB 服务器 + SWD 调试器驱动
J-Link SWD 调试探针
Python + pyserial 串口自动化测试脚本
zadig.exe 替换 J-Link 的 USB 驱动

编译固件时用 -g -O0(保留调试符号、关闭优化),否则 GDB 里变量可能被优化掉,断点可能落不在预期位置。

踩坑记录:OpenOCD 认不出 J-Link

问题现象

1
openocd -f interface/jlink.cfg -c "transport select swd" -f target/at32f4xx.cfg

报错:

1
2
Warn : Failed to open device: LIBUSB_ERROR_NOT_SUPPORTED
Error: No J-Link device found

但 J-Link 在设备管理器里显示正常,SEGGER 官方工具也能识别。

原因

OpenOCD 使用 libusb 与 J-Link 通信,需要 WinUSB 驱动。但 SEGGER 官方驱动是私有的,和 libusb 不兼容。J-Link 出厂默认装的是 SEGGER 驱动,所以 OpenOCD 找不到设备。

解决:zadig 替换驱动

  1. 下载 zadig.exe(OpenOCD 官方推荐的驱动替换工具)
  2. 拔掉 J-Link,关闭所有 IDE 和调试软件
  3. 重新插上 J-Link
  4. 打开 zadig,菜单 Options → List All Devices
  5. 下拉列表选择 “J-Link”
  6. 目标驱动选择 WinUSB
  7. 点击 Replace Driver,等待完成
  8. 设备管理器里 J-Link 的驱动将显示为 WINUSB

验证:

1
2
openocd -f interface/jlink.cfg -c "transport select swd" -f target/at32f4xx.cfg
# 输出 "Info : J-Link CPU thread detected" 即成功

注意事项

  • 驱动替换后 SEGGER 官方工具将不可用(J-Link Utility、Ozone、J-Flash 等)
  • 还原驱动:运行 J-Link 安装目录下的 JLinkDLLUpdater.exe(SEGGER 官方驱动修复工具),它会自动重装官方 USB 驱动
  • 某些 J-Link 克隆版(V8/V9 非官方固件)可能 WinUSB 替换后仍有问题,可以尝试选 libusb-win32libusbK 驱动
  • 雅特力官方提供了一个打过补丁的 OpenOCD(AT32 Eclipse 工具包里),对 AT32 系列的 flash 驱动支持更好。如果用 stock OpenOCD 遇到 “flash driver missing”,换雅特力版本

启动调试

OpenOCD 配置文件

at32_swd.cfg

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# J-Link 接口
source [find interface/jlink.cfg]
transport select swd

# 目标芯片(AT32F403A 用 stm32f1x 目标配置兼容)
source [find target/stm32f1x.cfg]

# 提高 SWD 时钟速度
adapter speed 8000

# 复位配置
reset_config srst_only

启动 OpenOCD:

1
2
3
4
5
openocd -f at32_swd.cfg
# 成功输出:
# Info : J-Link CPU thread detected
# Info : AT32F403A ... detected
# Info : starting gdb server on 3333

GDB 连接

另开一个终端:

1
arm-none-eabi-gdb build/firmware.elf
1
2
3
4
5
(gdb) target remote localhost:3333      # 连接 OpenOCD GDB server
(gdb) monitor reset halt                # 复位并停在启动代码
(gdb) load                              # 下载固件到 flash
(gdb) break USART1_IRQHandler           # 在串口中断里打断点
(gdb) continue

串口收发异常排查

异常现象

调试的串口通信协议是:PC 发送命令帧(8 字节头 + N 字节数据 + 2 字节 CRC16),AT32F403A 收到后回复响应帧。实际运行中出现:

  • 短命令(<16 字节)正常,长命令(>32 字节)后半段乱码
  • 偶发丢字节,频率不固定
  • 用逻辑分析仪看波形正常,但 MCU 收到的数据错位

排查思路

第一步:用 Python 脚本做可复现的测试输入

不依赖上位机软件,直接用 Python 精确控制发送时序和内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# test_uart.py — Codex 生成,人工审核后使用
import serial
import struct
import time

def calc_crc16(data: bytes) -> int:
    crc = 0xFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 0x0001:
                crc = (crc >> 1) ^ 0xA001
            else:
                crc >>= 1
    return crc

def send_frame(ser: serial.Serial, cmd: int, payload: bytes):
    header = struct.pack('<BBHH', 0xAA, cmd, len(payload), 0)
    crc = calc_crc16(header + payload)
    frame = header + payload + struct.pack('<H', crc)
    ser.write(frame)
    return frame

ser = serial.Serial('COM3', 115200, timeout=2)

# 测试 1:短命令(正常情况)
print("Test 1: short command (8 bytes payload)")
send_frame(ser, cmd=0x01, payload=b'\x01\x02\x03\x04')
resp = ser.read(64)
print(f"Response: {resp.hex()}")

# 测试 2:长命令(复现异常)
print("Test 2: long command (64 bytes payload)")
send_frame(ser, cmd=0x02, payload=bytes(range(64)))
resp = ser.read(128)
print(f"Response: {resp.hex()}")

# 测试 3:连续快速发送(压力测试)
print("Test 3: rapid fire")
for i in range(100):
    send_frame(ser, cmd=0x03, payload=struct.pack('<I', i))
    time.sleep(0.001)

ser.close()

这个脚本能精确复现异常:Test 1 通过,Test 2 必现乱码。

第二步:GDB 断点定位

在 GDB 里观察串口接收中断的执行情况:

1
2
3
4
5
6
7
8
9
(gdb) break USART1_IRQHandler
(gdb) continue
# 触发断点后
(gdb) info registers                # 看寄存器状态
(gdb) p usart1_rx_buf               # 看接收缓冲区内容
(gdb) p usart1_rx_cnt               # 看接收计数
(gdb) x/32xb usart1_rx_buf          # 十六进制转储缓冲区
(gdb) finish                        # 执行完当前中断
(gdb) continue                      # 继续等下一个中断

在长命令测试里,第一次中断进来时 usart1_rx_cnt 正常,继续运行几次后,发现中断处理函数里读到的数据已经跟发送的不一致了。

第三步:看寄存器,找根因

1
2
3
4
5
(gdb) p/x *(uint32_t*)0x40013800    # USART1_SR 状态寄存器
(gdb) p/x *(uint32_t*)0x40013804    # USART1_DR 数据寄存器
(gdb) p/x *(uint32_t*)0x4001380C    # USART1_CR1 控制寄存器 1
(gdb) p/x *(uint32_t*)0x40013810    # USART1_CR2 控制寄存器 2
(gdb) p/x *(uint32_t*)0x40013814    # USART1_CR3 控制寄存器 3

关键发现:USART1_SRORE(Overrun Error)位被置位了。

根因:接收缓冲区太小 + 中断处理太慢。

原代码用一个 32 字节的环形缓冲区存接收数据,长命令超过 32 字节后,新数据覆盖旧数据(还没被主循环取走)。加上中断处理里有几行 debug printf(又是一个串口发送,阻塞的),导致中断执行时间过长,更容易溢出。

修复

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 原代码:32 字节缓冲区
// uint8_t usart1_rx_buf[32];

// 修复:扩大到 256 字节 + DMA 接收(Codex 生成,人工审核后合入)
#define UART1_RX_BUF_SIZE 256
volatile uint8_t usart1_rx_buf[UART1_RX_BUF_SIZE];
volatile uint16_t usart1_rx_cnt = 0;

// USART1 初始化时开启 DMA 接收
void usart1_dma_init(void) {
    // DMA 配置:从 USART1_DR 搬到 usart1_rx_buf,循环模式
    // ...
    USART1->CR3 |= USART_CR3_DMAR;  // 开启 DMA 接收
}

// 主循环里检查 DMA 计数器,不依赖中断
void uart1_poll(void) {
    static uint16_t last_cnt = 0;
    uint16_t cur_cnt = UART1_RX_BUF_SIZE - DMA1_Channel5->CNDTR;
    if (cur_cnt != last_cnt) {
        // 有新数据,处理 usart1_rx_buf[last_cnt .. cur_cnt]
        last_cnt = cur_cnt;
    }
}

修复后重跑 Python 测试脚本:Test 1/2/3 全部通过,连续发送 1000 帧无丢包。

Codex 在调试中的角色

这篇调试过程中,Codex 做了三件事:

  1. 生成 Python 测试脚本——描述协议格式和异常现象,Codex 生成 test_uart.py,人工审核后使用
  2. 生成 DMA 接收重构代码——描述当前中断接收的问题和目标,Codex 生成修复代码,人工 review 后合入
  3. 辅助查寄存器手册——把 AT32F403A 参考手册里 USART 章节的寄存器描述贴给 Codex,让它生成 GDB p/x 命令和对应的位解释

Codex 生成的是初稿,寄存器地址、DMA 通道号这些关键参数需要人工核对参考手册,但框架和思路可以省去大量查文档的时间。

范式总结:AI + 开源工具 + 开发板的嵌入式调试方法论

这次排查不只是解决了一个 bug,更重要的是验证了一套可复用的范式

核心思想:AI + SWD + 串口

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
┌─────────────────────────────────────────────────┐
│                  AI(Codex)                     │
│   生成测试脚本  生成修复代码  解释寄存器手册       │
└──────────────┬──────────────────────┬───────────┘
               │                      │
       ┌───────▼──────┐       ┌──────▼──────┐
       │  Python 脚本  │       │  GDB + SWD  │
       │  串口数据注入  │       │  寄存器/内存 │
       └───────┬──────┘       └──────┬──────┘
               │                      │
               └───────┬──────────────┘
               ┌───────▼──────┐
               │  AT32F403A   │
               │  目标芯片     │
               └──────────────┘

SWD 是这套范式的灵魂。它让 AI 第一次能"看到"芯片的内部状态——不只是代码跑没跑,而是寄存器的每一位、内存的每一个字节,都是可见的、可验证的。AI 生成的假设,SWD 能立刻验证;AI 生成的修复,SWD 能立刻确认。

为什么不用 printf

方式 原理 问题
printf 调试 在代码里插打印,重新编译下载 改变时序、遮挡中断、一轮十几分钟
逻辑分析仪 看引脚波形 只能看到"发了什么",看不到"芯片收了什么、为什么"
SWD + GDB CPU 停下来,直接读寄存器和内存 看到芯片"此刻的状态",不改代码,不改时序

printf 调试最大的问题是:你在改变被测系统的行为来观测它。加打印会占用串口、改变中断时序、引入额外延迟——对于时序敏感的串口通信问题,printf 本身就是干扰源。SWD 是非侵入式的:CPU 暂停,状态原样呈现,恢复运行后行为不变。

范式步骤

第一步:Python 脚本做可复现输入

不依赖上位机软件,Python + pyserial 精确控制发送的每一个字节、每一次时序。异常从"偶发"变成"必现"。

第二步:AI 生成测试和修复代码的初稿

把协议格式、异常现象、参考手册片段描述给 Codex,它生成 Python 测试脚本和修复代码框架。关键参数(寄存器地址、DMA 通道号)人工核对。

第三步:SWD 断点 + 寄存器检查,定位根因

GDB 断点停在串口中断里,直接读 USART1_SRUSART1_DR——根因写在寄存器的位里(这次就是 ORE 溢出标志),不是猜出来的。

第四步:修复 + 回归验证

AI 生成修复代码(缓冲区扩大 + DMA 接收),SWD 确认修复后寄存器状态正常,Python 脚本回归测试确认不再复现。

这套范式的边界

  • 需要 SWD 调试接口(J-Link/ST-Link/DAP-Link 均可)
  • 需要固件带调试符号(-g -O0 编译)
  • AI 生成的寄存器相关代码必须人工核对参考手册——AI 会编地址、编位定义,这是它的幻觉高发区
  • 时序极敏感的问题(ns 级),SWD 断点本身会引入暂停,需要结合逻辑分析仪

这套范式里,哪些事必须人工做

环节 人工 or AI 说明
开发板接线(SWDIO/SWCLK/GND/VTref) 人工 物理连接,AI 无法代劳
串口接线(TX/RX/GND) 人工 同上
替换 J-Link 驱动(zadig) 人工 Windows 系统级操作
给 Codex 写提示词 人工 描述协议格式、异常现象、修复目标——AI 的输出质量上限取决于提示词的精确度
Python 测试脚本 AI 生成 + 人工审核 AI 写框架,人工核对协议细节
寄存器地址、DMA 通道号 人工核对参考手册 AI 幻觉高发区
GDB 断点设置、寄存器解读 AI 辅助 + 人工判断 AI 给命令,人看结果
修复代码合入 人工 review 最终责任人

核心原则:AI 加速"想"和"写",人负责"接"和"验",而"想"的起点——提示词——必须由人来写。接线是物理世界的操作,验证是工程责任的底线,提示词是调试思路的起点——这三头AI替不了,中间环节AI效率最高。

总结

这次串口异常从"偶发乱码"到"根因定位",耗时不到一小时。如果用传统方式(printf + 逻辑分析仪 + 翻手册),可能要两三天。

核心不是工具多先进,而是思路的转变

传统调试 范式调试
猜 → 改 → 试 看 → 定位 → 改
printf 看输出 SWD 看寄存器
手动发测试数据 Python 脚本自动化
自己翻手册查寄存器 AI 生成查询命令,人工核对

SWD 是嵌入式的"透视眼",AI 是这只眼睛的"大脑"。 一个是让芯片的内部状态可见,一个是让可见的信息变成可执行的调试动作。两者结合,嵌入式调试从"玄学"变成"科学"。

参考链接