大概半年前我又一次开着 J-Link RTT Viewer盯着滚动日志去对应固件行为。项目里多了一步要做 AI 模型在板验证还要把数据导出文件去复现问题随时跑回归。反复在 GUI 里点几下搞得人实在烦躁我就写了 rttsh——一个支持脚本化的 J-Link RTT 命令行工具。这篇文章想把 rttsh 的来龙去脉、设计取舍、常用脚本以及在 AI 在板调试、批量脚本验证、CI 集成里的用法都过一遍给正在做嵌入式日志采集、自动化板测或者 TinyML 调试的朋友一条可落地的路径。rttsh 解决的问题不算复杂但很实际J-Link RTT 本身是个好东西调试日志几乎不占额外串口资源速度又快。可它的官方客户端 J-Link RTT Viewer 是 GUI 程序脚本调不起、数据难接管、跑 CI 更是完全没门。团队里只要遇到“板子交给测试机自动验证”“固件在无人值守时跑一万次压力测试”这类需求第一个卡壳的就是这里。1. 从“又要开着 RTT Viewer 盯半天”说起1.1 RTT 这么好用为什么还缺一个命令行工具RTTReal Time Transfer是 SEGGER 提供的一种调试数据传输方案核心思路是目标芯片的 RAM 里放一块环形缓冲区调试器侧通过 J-Link 设备直接读写这块内存。目标端写日志时不需要等待串口外设也不需要把 JTAG/SWD 接口切来切去CPU 只要往内存里塞几个字节就行然后 J-Link 实时把缓冲区内容拉回主机。相比 UART 日志RTT 的优势很明显延迟低主机拉取数据的动作可以和 CPU 执行并行不影响实时性。不占串口尤其适合串口已经被应用功能占用的板子。下载调试和日志输出可以共用同一个 SWD 接口少焊一根线。但问题在于SEGGER 官方提供的最常用工具 J-Link RTT Viewer 是个图形界面程序。偶尔用一下没问题批量验证场景就尴尬了你要看的东西可能要跑 10 分钟才出现一次你得一直盯着屏幕你要把日志交给另一个程序做分析还得想方设法从窗口里复制出来你想在 Jenkins、GitHub Actions 里跑一轮自动化板测GUI 工具基本无能为力。官方其实也有命令行方案比如 JLinkExe 配合命令脚本可以做一些 RTT 底层的读写但它的定位是交互式调试命令不是给自动化脚本做数据采集和断言。于是我自己写 rttsh核心目标很明确用命令行参数和脚本文件控制 J-Link把 RTT 数据变成程序能处理的 stdout、文件、JSON并且能根据收到的日志内容做成功或失败判定。1.2 工具定位不是另一个 RTT Viewer而是一个可编程数据源rttsh 从一开始就没打算复刻 RTT Viewer 的界面那个没有意义。我更愿意把它理解成一个“可编程的数据探头”连接上 J-Link打开 RTT 通道然后按照脚本规则去等待、过滤、匹配、导出数据。它主要有四种使用模式交互模式像读串口工具一样直接打印 RTT 输出但可以通过命令动态过滤关键词。单次模式一条命令完成“连接、等待、匹配、退出”适合做 CI 断言。脚本模式编写类似批处理的小脚本支持循环、等待、匹配、超时、断言、发命令到目标端。文件模式把 RTT 原始数据完整导出成文本或二进制文件供离线分析和复现。这种定位决定了它的架构会偏向 CLI 和事件驱动而不是界面驱动。只要能拿到 RTT 数据剩下的事情全部交给标准 shell 工具链去处理。2. rttsh 是怎么设计的核心结构与工作原理2.1 主机侧实现调用 J-Link 动态库而不是裸调 JLinkExerttsh 在主机侧有两种实现路径可以考虑。一种是通过 SEGGER 官方提供的 J-Link SDK即 jlinkarm.dll / libjlinkarm.so直接访问 J-Link API另一种是调用 JLinkExe 的命令行接口用解析输出结果的方式间接工作。我最终选择了第一种。原因是 JLinkExe 是交互式程序输出内容面向人而不是面向机器解析起来脆而且每次调用都要重新初始化 USB/目标连接非常慢。直接使用底层 API 可以做到一次连接长时间保持持续读取 RTT 数据。需要重置目标时直接用 API 控制不用重启进程。可以同时打开多个 J-Link应对多板卡并测。精确控制搜索内存、读取缓冲区、目标复位等行为。SDK 版的接入逻辑大致如下加载动态库枚举 J-Link 仿真器连接指定序列号或 USB 位置然后调用目标连接接口例如JLINKARM_Open、JLINKARM_Connect、JLINKARM_WriteMem/ReadMem这类函数去访问 RTT 控制块和缓冲区。需要注意的是SEGGER 的 SDK 还要配合 J-Link Software Pack 一起安装否则动态库和文档都拿不到。从实现角度说真正的核心技术点分成三块控制块定位、通道数据轮询、脚本状态机。2.2 控制块定位与 RTT 数据读取RTT 控制块通常是一个位于 RAM 中的结构体由SEGGER_RTT.c在目标端注册。它包含 ID 字符串通常是SEGGER RTT、上行缓冲区数组、下行缓冲区数组、通道数量和读写索引。J-Link 主机侧要读取目标日志首先就要找到这个控制块。控制块定位有几种方式默认地址如果目标端编译时知道_SEGGER_RTT的地址可以把它写入代码并传给 rttsh。指定地址后不用搜索连接速度快很多。内存搜索主机端在整个可读 RAM 范围内搜索SEGGER RTT这个 ID 特征串命中后按结构体偏移解析通道。结合 J-Link 的 RTT 自动搜索功能SEGGER 软件包内置了控制块搜索机制可以直接调用对应能力。内存搜索虽然慢但最稳妥尤其是在目标代码带 bootloader、实际 RAM 地址会变的情况下。rttsh 里我加了一个参数允许手动指定控制块地址一旦固件更新导致搜不到又不想每次全量扫描直接把这个地址写进项目配置就行。读完控制块之后就是周期性地读取上行缓冲区。目标端往环形缓冲区写主机侧读更新索引然后从读索引到写索引之间的区域取走数据。数据读取用轮询模型默认每 1 到 5 毫秒拉一次也可以根据带宽需求调整。这里有个容易踩的坑如果目标端的写指针已经越过缓冲边界完成回绕主机侧必须在读取时处理“读索引和写索引之间跨越缓冲区尾端”的情况。RTT 官方代码里是分段处理主机端同样要按两段读先从读索引读到缓冲区末再从头读到写索引。这个处理不严谨的话日志会莫名其妙丢一块或者顺序乱掉。2.3 为什么用脚本状态机而不是简单打印循环如果只是“把 RTT 输出打到屏幕上”逻辑非常简单轮询缓冲区把字节送给终端。但 rttsh 还要做验证、断言、等待、发命令这些动作之间有相互依赖必须有一个明确的状态机。我设计的脚本执行模型是一个典型的阻塞式脚本循环每一步命令要么执行成功并进入下一步要么因条件不满足而等待。等待本身有超时时间超时则整体失败并返回非零退出码。比如echo START wait READY timeout3000 send RUN_TEST\r wait TEST_COMPLETE timeout10000 assert RESULT_PASS timeout0 exit 0这个脚本的意思是先在 RTT 通道里找READY找到后向目标端下行通道发送RUN_TEST\r再等TEST_COMPLETE最后确认RESULT_PASS。每一步如果超时rttsh 就输出超时日志并退出非零。这种设计可以直接对接到 CI进程退出码就是测试结果。3. 环境准备Win11、J-Link 驱动和“固件不支持”的排查3.1 从 SEGGER 软件包到系统驱动使用 rttsh 之前环境里必须装好 SEGGER J-Link Software Pack。它包含了主机侧的动态库、JLinkExe、驱动、文档和固件更新工具。在 Windows 上安装完成后建议手动确认一下C:\Program Files\SEGGER\JLink目录下是否存在jlinkarm.dll。如果选择的是 64 位安装路径里一般还有JLink_x64这样的目录。Windows 11 上安装 J-Link v9 驱动时常见的现象是设备管理器里看到 J-Link 但有黄色感叹号或者“SEGGER J-Link”设备无法正常枚举。多数情况是因为旧版驱动和 Win11 的驱动签名策略冲突。解决方法是去 SEGGER 官方下载工作站装最新的 J-Link Software Pack安装时选择“Repair”或者先卸载旧驱动再安装之后以管理员身份重插 J-Link 让系统重新枚举。还有一点Win11 如果开启了“内核隔离”或“内存完整性”某些版本的 J-Link USB 驱动可能受影响导致连接枚举失败。遇到这种场景临时关掉内存完整性再试一次往往就能定位是不是驱动兼容问题。3.2 报错“the firmware of the connected J-Link does not support”怎么处理rttsh 连接 J-Link 时底层会调用 SDK 初始化并启用 RTT 功能。如果你的 J-Link 固件版本比较老而软件包版本很新某些功能会被判定为不支持典型报错类似The firmware of the connected J-Link (s/n: 20090928) does not support the following features: - J-Link RTT Please update the firmware of the J-Link to use these features.这个报错的意思不是 RTT 真的不可用而是 J-Link 设备固件缺少新版软件包的特性声明。处理分几步打开 SEGGER 安装目录下的JLinkConfig或运行 J-Link CommanderJLink.exe在命令行输入exec查看当前固件版本。用软件包自带的JLink.exe执行固件更新命令或打开J-Link Updater工具选择设备并刷新固件。更新完成后再重新枚举设备rttsh 重新连接。如果固件更新失败最常见的原因是 J-Link 被占用要么是 RTT Viewer 还开着要么是调试器进程没退出。确保所有 SEGGER 相关程序关干净断开其他占用 J-Link 的工具再重试。更新过程中不要拔线断电到一半设备就变砖了只能返厂。3.3 Linux 和 CI 环境下的 USB 权限与稳定性CI 环境很多时候跑在 Linux 机器上自托管 runner 连着一块真实板子。Linux 下访问 J-Link 需要正确处理 USB 权限。我通常写一个 udev 规则把 SEGGER 的 USB Vendor ID 设备权限放开sudo tee /etc/udev/rules.d/99-jlink.rules EOF SUBSYSTEMusb, ATTR{idVendor}1366, MODE0666 EOF sudo udevadm control --reload-rules sudo udevadm triggerSEGGER 的 USB Vendor ID 一般是1366但也有老设备用0x1366。如果你的环境里有多个 J-Link建议用lsusb确认一下。另外长期跑无人值守测试时USB 连接偶尔会进入异常状态。我建议在 rttsh 里加入重连策略当读取超时或者底层 API 返回错误时自动关闭设备并重新初始化。CI 场景下这个策略价值特别大因为一旦测试中途崩掉整个流水线都要重来成本很高。4. 写脚本之前rttsh 的核心命令和语法4.1 最小可用的交互和单次模式先看最简单的用法。连接设备打开 RTT 通道 0直接看输出30 秒后退出rttsh --device STM32F407 --if SWD --speed 4000 --channel 0 --timeout 30000--device指定目标芯片--if指定调试接口一般用 SWD--speed是 JTAG/SWD 时钟频率4000 kHz 在大多数板子上都能稳定工作。不想让终端输出太多时可以用--silent屏蔽原始数据只打印匹配结果。单次断言模式更适合直接放到 CI 里rttsh --device STM32F407 --expect BOOT_OK --timeout 5000这条命令会一直读 RTT 直到发现BOOT_OK字符串发现后立即退出并返回 0如果 5 秒内没出现则返回 1。$? 可以直接作为测试结果。4.2 等待、断言、发送命令复杂一点的需求要进入脚本模式。rttsh 脚本语法围绕几个原语展开命令作用示例connect建立 J-Link 和目标连接connect STM32F407 swd 4000channel打开一个 RTT 上行通道channel 0 openwait等待某个字符串出现wait READY timeout5000expect断言字符串出现expect RESULT_PASSmatch按正则匹配match \[ERR\] .* thenfailsend通过下行通道发送数据send CMD_START\rsleep延时sleep 200log写日志log test case 1 doneexit结束脚本exit 0比如验证一个电源管理功能connect STM32F407 swd 4000 channel 0 open log waiting for health check wait HEALTH_CHECK_START timeout10000 send ENTER_SLEEP\r wait SLEEP_CONFIRM timeout3000 expect WAKEUP_REASON0 exit 0关于 wait 和 expect 的区别很多朋友第一次用会搞混。wait 是阻塞等待适合“目标端要先跑一段时间才会输出某个字符串”的场景expect 是立即断言常用于检查日志中是否已经包含某个内容。如果一个字符串可能在连接期间任何时候出现用 wait 更靠谱。4.3 一个数据校验脚本的例子我有一个实际使用过的场景验证传感器标定参数是否正确写入板载 flash。固件启动后会通过 RTT 输出标定数据的校验值格式固定calib: id3, crc0xA1B2C3D4, statusok脚本可以这样写connect STM32F407 swd 4000 channel 0 open match calib: id[0-9] timeout10000 thencontinue match crc0x[0-9A-Fa-f]{8} thencontinue expect statusok log calibration verified exit 0如果 CRC 格式不对或者状态不是 ok脚本会在对应断言处失败并退出非零。这样固件发布前我可以在流水线里直接跑几十块板子把每块板的标定结果都验证一遍不再需要人守着串口工具。5. AI 在板调试从打印一堆张量到结构化结果5.1 为什么 AI 模型调试尤其需要结构化 RTT 输出做 AI 模型在板调试时一个很头疼的问题是模型输出的中间张量量大、维度多如果用 printf 无脑打印整个日志会变成一片数字海洋根本分不清哪个是哪个层的结果。比如给一个图像分类模型做量化验证需要在板子上输出每层激活值范围、量化参数、推理耗时以及推理结果。如果全部混着打你只有打开 GUI 手工搜索如果丢到 rttsh 里配合正则表达式和过滤按通道筛选就可以把“信息流”拆成“数据流”。我推荐的做法是目标端 RTT 打印时用统一的标签格式而不是随意起名字。例如AI:FILTER:extract:conv1:min-0.234,max1.023 AI:FILTER:act:relu1:min0.000,max0.902 AI:TIME:inference:12.34ms AI:RESULT:label3,score0.9721这种格式有一个很大的好处rttsh 可以按关键字过滤也可以按字段提取甚至在脚本里直接断言数值范围。比如想检查某个层的激活值没有出现 NaN或者最大值不超过某个阈值都可以自动化完成。5.2 用 rttsh 做 AI 推理结果断言在一块跑着量化图像分类模型的板子上我想验证三张测试图是否全部被正确分类。固件端会逐个打印AI:TEST:img1:label3,score0.9721,time12.1ms AI:TEST:img2:label5,score0.8812,time11.9ms AI:TEST:img3:label3,score0.5631,time12.4msrttsh 脚本里我可以写connect STM32F407 swd 4000 channel 0 open wait AI:TEST:img1 timeout15000 expect AI:TEST:img1:label3 wait AI:TEST:img2 timeout15000 expect AI:TEST:img2:label5 wait AI:TEST:img3 timeout15000 expect AI:TEST:img3:label3 log all inference labels verified exit 0如果某一轮的分数低于预期我也可以在匹配正则之后继续执行离线分析。这里有个小技巧得分比较最好不要在脚本里处理因为浮点比较放在嵌入式日志里容易受格式影响。我一般在 rttsh 里只做“是否输出”“标签是否正确”这种强断言数值偏差异常丢给后续 Python 分析脚本去处理。5.3 实时诊断只挑关键通道和关键事件AI 模型调试过程中还有一个场景是“跑着跑着突然推理结果不对”但日志量巨大。RTT Viewer 一屏刷过去根本来不及看。rttsh 可以通过正则过滤减少噪声rttsh --device STM32F407 --regex AI:RESULT|AI:ERROR --timeout 60000只匹配出结果和错误其他日志全部忽略。中断现场会清晰很多。更进一步rttsh 可以给匹配到的事件加高亮标记比如匹配到AI:ERROR时立刻在终端输出红色提示并触发整体失败。调试 AI 模型时相比肉眼盯日志这种“在数据流里找关键事件”的模式效率高得多。6. 批量脚本验证与 CI让板子自己跑测试6.1 从单项到批量退出码与循环处理单项验证跑通后批量验证的核心就是两件事循环和退出码。rttsh 脚本支持简单的循环和变量替换。例如要对 10 个测试用例逐一执行connect STM32F407 swd 4000 channel 0 open for i in 1 2 3 4 5 6 7 8 9 10 do log run test case $i send RUN_CASE$i\r wait CASE_DONE timeout5000 expect CASE_RESULTPASS done exit 0任何一个用例失败脚本都会提前退出并返回非零。在 CI 层面我还能拿到哪一步失败的信息rttsh 默认会把当前步骤和期望值输出到 stderr方便定位问题。批量验证还有一个常见需求是“在同一块板子上反复刷不同的固件”。我先用 JLinkExe 完成固件下载再用 rttsh 做启动日志校验。两个工具搭配起来流程非常顺。6.2 GitHub Actions 接入示例GitHub Actions 里如果要用真实硬件必须使用自托管 runner因为普通 runner 无法访问 USB 设备。在自托管机器上装好 J-Link 软件包和 rttsh 后CI 配置可以这样写name: board-test on: [push] jobs: test: runs-on: self-hosted steps: - name: Checkout uses: actions/checkoutv4 - name: Flash firmware run: | JLinkExe -device STM32F407 -if SWD -speed 4000 -CommanderScript flash.jlink - name: Run RTT verification run: | rttsh --device STM32F407 --expect ALL_TEST_PASSED --timeout 120000 - name: Save RTT log if: always() run: | rttsh --device STM32F407 --channel 0 --outfile rtt_log.txt --timeout 30000这里第 4 步用if: always()即使测试失败也要抓日志方便事后分析。自托管 runner 的每台机器只能接有限数量的 J-Link这正好符合 rttsh 同时支持多设备连接的特性可以让一台大机器接多块板子跑并行测试大幅压缩总执行时间。6.3 GitLab CI 接入示例GitLab CI 同理。runner 带jlink标签用脚本来区分专用硬件board_test: stage: test tags: - jlink script: - JLinkExe -device STM32F407 -if SWD -speed 4000 -CommanderScript flash.jlink - rttsh --device STM32F407 --expect ALL_TEST_PASSED --timeout 120000 - rttsh --device STM32F407 --channel 0 --outfile rtt_log.txt --timeout 30000 || true artifacts: paths: - rtt_log.txt when: always variables: FF_USE_FASTZIP: true|| true的目的是即使收集日志的命令返回非零也保留测试结果避免把主流程的结果覆盖掉。GitLab artifacts 可以把 RTT 日志随流水线保存之后直接定位问题。7. 数据导出与离线分析7.1 文本与二进制导出的取舍rttsh 的数据导出功能对调试和复现问题非常关键。文本导出是最基础的每一行时间戳加内容[12:00:01.123] calib: id3, crc0xA1B2C3D4, statusok [12:00:01.456] AI:TEST:img1:label3,score0.9721,time12.1ms文本适合人看但不适合大数据量场景。因为文本解析和格式化会消耗主机 CPU大量数据时容易出现处理不过来的情况。二进制导出的优势在于容量和保真度。目标端完整输出的原始字节直接写入.bin不经过任何转义、编码转换也不做换行处理。这样导出后可以用于波形复现、通信协议分析等场景。缺点也很明显不带时间戳也不方便人直接阅读。我建议的实践是-o指定输出文件同时用--format text或--format binary控制格式两种格式都可以配合--timestamp加时间戳rttsh --device STM32F407 --channel 0 --format text --timestamp --outfile run.log rttsh --device STM32F407 --channel 1 --format binary --outfile raw.bin对多通道设备上行通道 0 通常做日志通道 1 以上可以用独立数据通道比如把原始传感器采样值直接塞给主机。这样日志通道和数据通道互不干扰rttsh 也能分别导出。7.2 离线分析示例TinyML 模型验证拿到导出的日志后离线分析用 Python 最方便。比如我要统计某次测试中模型推理耗时import re time_ms [] pattern re.compile(rAI:TIME:inference:([0-9.])ms) with open(run.log, r) as f: for line in f: m pattern.search(line) if m: time_ms.append(float(m.group(1))) print(count:, len(time_ms)) print(max:, max(time_ms)) print(avg:, sum(time_ms) / len(time_ms))另一个常见用途是错误聚类。固件在压力测试中偶尔报AI:ERROR导出的日志可能有几万行。用 rttsh 导出的结构化格式我可以快速统计各类错误码出现的次数而不需要重新跑测试。二进制导出也同样有应用场景分析 SPI 外设收到的原始帧、ADC 采样波形、或者调试无头文件的神经网络权重分布。导出之后用 numpy 读入内存直接绘图整个调试链路非常顺。8. 实盘经验踩过的坑与解决思路8.1 缓冲区大小与读取速度不匹配大部分 RTT 概率问题的本质是“目标端写入速度”和“主机端读取速度”不匹配。RTT 虽然快但缓冲区不是无限大的。如果目标端突然打印大量日志比如 AI 模型推理时把每个中间层都输出默认 1 KB 的通道缓冲很快就能填满。一旦缓冲区满了目标端的SEGGER_RTT_Write会丢弃无法写入的数据。遇到这种情况我通常做三个调整目标端把SEGGER_RTT_CONFIG_BUFFER_SIZE_UP调大尤其是日志量大时改成 4 KB 或 8 KB。rttsh 缩短轮询间隔从默认 5 毫秒改成 1 毫秒。减少日志本身用标签过滤的方式在主机侧保留需要的信息。调试 AI 模型时我甚至不惜把日志拆到两个通道通道 0 给状态日志通道 1 给大数据块。通道 0 的日志量控制在小几百字节通道 1 的缓冲区开成 16 KB让主机慢慢拉。这样既保证了关键日志不丢又能采集到完整的数据。8.2 控制块搜索失败与 RAM 初始化问题RTT 控制块搜索失败的报错通常是RTT control block not found。造成这个问题的原因不是 rttsh 有 bug而是目标端在连接时还没有把 RTT 控制块初始化好。常见情况有三种目标程序还在 bootloader 阶段没跑到包含SEGGER_RTT_Init()的应用代码。RAM 内容在上电时是随机的搜索脚本找不到成熟的控制块结构。目标已经被调试器暂停但 RTT 控制块被优化器删除了配置中关闭了SEGGER_RTT的相关编译选项。处理办法先让目标复位并全速运行给 rttsh 一个--reset参数连接时先复位再搜索或者指定控制块地址跳过搜索。在 CI 过程中我通常加一句wait RTT_READY确保目标端已经跑到初始化完成之后再开始断言。8.3 数据导出文件不要太小大量数据导出时写入磁盘的节奏也可能成为瓶颈。如果 rttsh 把数据写到普通机械硬盘上高频日志持续输出时磁盘 IO 跟不上就会拖累轮询线程间接导致缓冲区溢出。SSD 环境基本没这个问题但在老式测试机上我建议导出文件尽量放在内存盘或者 SSD 上。还有一个实用技巧导出文件按固定大小分片比如 64 MB 一个文件避免单个文件过大后后续处理工具打不开。8.4 多个 J-Link 同时工作的注意事项多板并行测试时每个 J-Link 要有独立的设备定位方式。用序列号区分最可靠不要依赖 USB 端口顺序因为 Linux 下的/dev/ttyUSB*编号可能随插入顺序变化。rttsh 可以用--jlink-serial指定序列号rttsh --jlink-serial 123456789 --device STM32F407 --expect PASS --timeout 10000在 CI 脚本里我会让每台测试机使用固定的 J-Link 序列号并把序列号做成环境变量避免脚本硬编码。8.5 我的几个习惯用 rttsh 跑了小半年测试养成了几个还算实用的习惯。第一所有断言尽量做得“脆”失败就立刻终止不带侥幸心理因为 CI 的目的不是跑过而是发现问题。第二日志输出永远带时间戳哪怕当时觉得不重要事后排查问题时才发现时间信息是最难补的。第三每个测试脚本开头都打印一个唯一标记比如TEST_SUITE_NAMExxx这样查看 rtt 日志时能快速定位是哪一轮测试。做这样一个工具最大的收获不只是省掉了打开 GUI 等日志的重复劳动而是让“板子上的行为”真正进入了自动化体系。固件可以像普通软件一样有测试命令和断言输出AI 模型在板验证可以脱离人工盯屏变成流水线里的一环。以后项目里再遇到需要验证固件行为、采集实时数据、跑回归测试的场景我都会先问一句能不能让 rttsh 帮我盯这块板子