
1. 为什么我要自己写一个 RTT 命令行工具嵌入式调试这件事用过 J-Link 的人应该都有体会RTTReal Time Transfer是个好东西它能在不占用串口、不打断 CPU 运行的前提下把日志从芯片里实时吐出来速度比 UART 快得多也不像 SWO 那样挑引脚和时钟配置。但 SEGGER 官方给的 RTT Viewer 和 RTT Client 都是 GUI 工具人盯着看没问题一旦你想把它塞进自动化流程里立刻就卡住了。我最初的需求很朴素手头一块 STM32 板子跑的是带 RTOS 的固件日志量不小我想在每次编译完之后自动烧录、自动抓一段 RTT 日志、自动做关键字断言最后把日志落盘归档。这套流程如果靠 GUI 工具就得人手点一遍完全谈不上 CI。于是我开始找现成的命令行方案试过 JLinkExe 配合 RTT 命令也试过一些开源封装但要么是交互式的、要么是阻塞式的、要么就是没法在脚本里做条件判断和数据处理。这就是 rttsh 的由来。它是一个支持脚本化的 J-Link RTT 命令行工具核心思路是把 RTT 的读写能力暴露成一套命令行接口再内嵌一个 Lua 脚本引擎让你可以用脚本去控制“什么时候读、读多少、怎么解析、满足什么条件就退出”。它解决的不是“能不能看日志”的问题而是“能不能让机器替我看日志”的问题。适合谁用做嵌入式固件开发、需要批量验证、需要把板子调试接进 CI 流水线、或者单纯嫌 GUI 工具太重的工程师。哪怕你只是想在终端里tail -f一样看 RTT它也能干。2. 整体设计思路与方案选型2.1 为什么是命令行加脚本而不是纯 GUI 或纯库先说我为什么不做成纯库。纯库比如一个 C 或 Python 的 RTT 库灵活是灵活但每次用都得写代码、编译、配环境对“我就想快速抓一段日志”这种场景太重了。命令行工具的好处是零门槛rttsh read --channel 0 --output log.txt这种命令谁都能敲不需要懂 API。那为什么还要加脚本因为纯命令行只能做固定动作而真实调试场景里充满了“如果……就……”如果日志里出现ASSERT就立刻停止并保存现场如果连续 10 秒没有新日志就判定死机如果收到特定命令就回写一段数据触发固件行为。这些逻辑用命令行参数堆是堆不出来的必须有个脚本层。选 Lua 而不是 Python 或 JavaScript理由很实际Lua 解释器极小几百 KB 就能跑交叉编译到各种平台都容易而且语法简单嵌入式工程师看两眼就能上手不需要为了写个调试脚本再去学一套生态。所以 rttsh 的架构是两层底层是 J-Link SDK 提供的 RTT 读写接口负责和 J-Link 探针通信上层是命令行解析加 Lua 引擎负责把底层能力编排成脚本可调用的函数。命令行是入口脚本是大脑。2.2 核心能力拆解读、写、等、判、存把 RTT 调试的需求拆开其实就五件事。第一是读从上行缓冲区把目标机打印的数据取出来第二是写往下行缓冲区塞数据让目标机收到第三是等等待特定条件出现而不是傻等固定时间第四是判对读到的数据做匹配、解析、统计第五是存把原始数据或处理结果落盘。rttsh 的命令行接口就是围绕这五件事设计的。read负责读write负责写wait负责等脚本里的match、parse负责判--output和脚本里的save负责存。这样拆的好处是每个命令职责单一组合起来却能覆盖绝大多数场景。比如“等出现 READY 之后写一条命令再把接下来 5 秒的日志存文件”就是waitwriteread --duration 5 --output的组合。2.3 和官方工具、其他开源方案的取舍官方 RTT Viewer 的优势是图形化、开箱即用缺点是没法脚本化、没法进 CI、日志多了界面会卡。JLinkExe 的 RTT 命令能脚本化但它是“批处理”式的你得把命令写成一个脚本文件喂给它交互性差而且拿到的数据格式不好处理。一些开源封装比如某些 Python 库能编程但依赖 Python 环境和一堆包部署到 CI 容器里体积大。rttsh 的取舍是单文件可执行不依赖运行时环境Lua 引擎静态链接进去命令行和脚本双模式输出格式对机器友好支持纯文本、带时间戳、JSON 三种。代价是它不提供图形界面也不做日志高亮——这些交给终端和下游工具去做。我认为这个取舍是对的因为调试工具的定位应该是“管道里的一环”而不是“终点”。3. 核心细节解析与实操要点3.1 RTT 缓冲区的工作原理与参数选择要理解 rttsh 怎么用得先搞懂 RTT 的缓冲区机制。RTT 在目标机的 RAM 里划出一块控制块Control Block里面记录了上行和下行缓冲区的位置、大小、读写指针。J-Link 通过调试接口直接读写这块内存所以不需要目标机参与通信协议速度极快。控制块的位置有两种确定方式一种是固定地址在链接脚本里指定一种是让 J-Link 自动搜索 RAM 里的特征字符串。rttsh 默认走自动搜索因为大多数工程用的是 SEGGER 的 RTT 库默认配置控制块会在 RAM 里留下可识别的标识。但如果你的工程把控制块放到了特殊位置或者 RAM 里有多个疑似控制块就得手动指定地址。命令行参数是--rtt-address 0x20000000这种形式。我踩过的坑是某些低功耗芯片在休眠时 RAM 会掉电控制块内容丢失自动搜索就会失败这时候要么唤醒芯片再连要么改用固定地址加重新初始化。缓冲区大小也值得说。上行缓冲区默认常见是 1KB 到 4KB如果目标机打印速度极快而 J-Link 读取速度跟不上缓冲区会满满了之后目标机的打印函数要么阻塞要么丢数据。rttsh 的读取策略是“尽快排空”它内部有个读取线程以尽可能高的频率轮询但受限于 J-Link 的接口速度SWD 通常几 MHz实际吞吐在几百 KB/s 量级。如果你的日志量超过这个就得考虑加大缓冲区或者降低打印频率。3.2 Lua 脚本引擎的嵌入方式与可用 APIrttsh 内嵌 Lua 5.4脚本通过--script参数加载或者用-e直接执行一行。脚本里能调用的 API 我设计得尽量少而精避免学习成本。核心的就几个rtt.read(timeout_ms)读一次返回字符串或 nil超时。rtt.write(data)写数据返回实际写入字节数。rtt.wait(pattern, timeout_ms)等匹配返回匹配到的整行或 nil。log.info(msg)/log.error(msg)输出到 rttsh 自己的日志不是目标机。file.save(path, data)存文件。os.time()、string.*、table.*这些 Lua 标准库都能用。为什么 API 这么少因为每多一个 API就多一份维护成本和文档成本而且用户记不住。我试过加一些“高级”API比如直接解析结构体后来发现不如让用户用 Lua 的string.unpack自己解更灵活也更透明。脚本的执行模型是单线程顺序执行rtt.read会阻塞直到有数据或超时这样写起来最直观不需要处理回调。一个典型的脚本长这样-- wait.lua: 等 READY 后写命令抓 3 秒日志 local line rtt.wait(READY, 10000) if not line then log.error(等 READY 超时) os.exit(1) end rtt.write(start_measure\n) local buf {} local deadline os.time() 3 while os.time() deadline do local data rtt.read(200) if data then table.insert(buf, data) end end file.save(measure.log, table.concat(buf))这段脚本干的事用纯命令行参数是表达不出来的。注意os.exit(1)的用法——在 CI 里脚本退出码非零就意味着这一步失败流水线会中断这正是我们想要的。3.3 输出格式与时间戳的处理RTT 本身不带时间戳目标机打印什么就是什么。但调试时时间信息很重要所以 rttsh 在读取时会给每段数据打上主机侧的时间戳。有三种输出模式--format raw只输出原始数据适合重定向到文件--format ts每行前面加[秒.毫秒]--format json输出{t: 123.456, data: ...}这种结构化格式方便下游用 jq 处理。这里有个细节RTT 的数据是流式的不保证按行到达可能一次读到半行。rttsh 内部做了行缓冲--format ts和--format json会等到凑齐一行才输出raw则原样透传。如果你要精确到字节级分析用 raw如果要人看用 ts如果要机器处理用 json。我个人的习惯是 CI 里用 json本地调试用 ts。注意时间戳是主机收到数据的时间不是目标机产生数据的时间。两者之间隔着目标机缓冲、J-Link 传输、主机轮询三段延迟通常在毫秒级但对实时性要求极高的分析不够用。真要精确时间得让固件自己打时间戳。4. 实操过程与核心环节实现4.1 环境准备驱动、探针与目标机配置先说环境。J-Link 的驱动在 Windows 上装 SEGGER 官方包就行注意版本匹配——我遇到过 J-Link V9 在 Win11 上装老驱动识别不稳定的情况换成较新的驱动包就正常了。Linux 下用官方的 udev 规则把探针的 USB 权限配好否则普通用户访问不了。目标机这边固件里要集成 SEGGER RTT 的源码SEGGER_RTT.c和.h初始化之后就能用SEGGER_RTT_printf打印了。一个容易忽略的点RTT 控制块默认放在.bss段如果固件启动时清零了整个.bss而 RTT 初始化在清零之前控制块就会被抹掉。正确做法是确保SEGGER_RTT_Init()在内存初始化之后调用或者把控制块放到不被清零的段。这个坑我在两块不同的板子上都踩过现象是 J-Link 连上了但搜不到 RTT 控制块。4.2 从零跑通第一条 RTT 日志环境就绪后第一步是确认能连上。用rttsh probe命令列出可用的 J-Link 探针确认序列号和固件版本。然后rttsh info会尝试连接目标机并搜索 RTT 控制块输出控制块地址、上下行缓冲区大小。如果这一步失败先别急着写脚本用官方 RTT Viewer 验证一下硬件连接是否正常排除是 rttsh 的问题还是环境的问题。确认能搜到控制块后跑rttsh read --channel 0应该能看到目标机的打印。如果目标机还没开始打印可以先用rttsh write --channel 0 hello\n测试下行通道——前提是固件里有读取下行缓冲区的逻辑。很多固件的 RTT 只配了上行下行没处理这时候 write 会成功但目标机没反应别以为是工具坏了。4.3 批量脚本验证的完整流程这是 rttsh 最有价值的场景。假设你有 20 块板子要做出厂测试每块板子跑同一套固件你需要验证每块板子都能正常启动、自检通过、输出特定标志。流程是这样的写一个 Lua 脚本factory_test.lua逻辑是等BOOT_OK然后发selftest命令等SELFTEST_PASS或SELFTEST_FAIL把结果写文件退出码反映成败。写一个 shell 脚本遍历所有探针序列号对每个序列号调一次rttsh --script factory_test.lua --serial SN --output result_SN.log。收集所有 result 文件统计通过率。关键点是脚本里的超时设置要合理。等BOOT_OK给 10 秒等自检结果给 30 秒自检可能涉及外设初始化比较慢。超时太短会误判太长会拖慢批量测试。我的经验是先手动跑一遍记录实际耗时然后设成实际值的 2 到 3 倍。#!/bin/bash # batch_test.sh for sn in $(rttsh probe --list-serial); do rttsh --script factory_test.lua --serial $sn \ --output result_${sn}.log --format json echo $sn exit$? done4.4 数据导出与 CI 集成数据导出这块rttsh 支持边读边写文件不需要先把所有数据攒在内存里。--output指定的文件是流式写入的即使跑几个小时也不会撑爆内存。如果要做日志轮转可以在脚本里判断文件大小超过阈值就file.save到新文件。CI 集成是重点。在 GitLab CI 或 GitHub Actions 里把 rttsh 的可执行文件放进 runner 能访问的路径然后加一个 jobrtt_test: stage: test script: - rttsh --script ci_test.lua --serial $JLINK_SN --output rtt.log artifacts: paths: - rtt.log when: alwayswhen: always很重要因为即使测试失败日志也要保留下来供分析。脚本的退出码会决定 job 成败所以脚本里该os.exit(1)的地方不能含糊。我见过有人脚本里捕获了错误但没退出非零结果 CI 显示绿灯实际上测试早就失败了这种“假通过”比直接失败更危险。提示CI 环境里 J-Link 探针通常接在固定的 runner 上多个 job 并发时会争抢探针。要么给每个 runner 配独立探针要么在 CI 配置里加互斥锁确保同一时间只有一个 job 用探针。5. 常见问题与排查技巧实录5.1 连接类问题速查现象可能原因排查方法搜不到探针USB 权限/驱动问题Linux 检查 udev 规则Windows 重装驱动搜不到 RTT 控制块控制块被清零/地址不对用 RTT Viewer 验证检查初始化顺序连接后立刻断开目标机复位/休眠检查固件是否在低功耗模式加--keep-alive提示探针异常固件版本过旧/线缆接触不良升级 J-Link 固件换根短一点的 SWD 线“the connected J-Link is defective”这个报错我遇到过几次多数不是探针真坏了而是目标机供电不稳或者 SWD 线太长导致信号完整性差。换根 10cm 以内的线或者给目标机单独供电基本能解决。5.2 数据丢失与乱码的处理数据丢失最常见的原因是缓冲区溢出。RTT 上行缓冲区满了之后SEGGER 的默认行为是丢弃新数据非阻塞模式或阻塞等待阻塞模式。如果你发现日志中间缺了一段先看固件用的是哪种模式。非阻塞模式丢数据是设计使然要么加大缓冲区要么降低打印频率。乱码通常是波特率无关的——RTT 不走波特率——所以乱码一般意味着读到了错误的内存区域或者控制块结构被破坏。检查一下是不是有多个 RTT 控制块或者目标机在运行中重新初始化了 RTT。还有一种可能是 J-Link 的接口速度设太高SWD 在长线下误码把--speed从默认的 4000kHz 降到 1000kHz 试试。5.3 脚本执行中的坑Lua 脚本里最容易犯的错是忘了处理nil。rtt.read超时会返回nil如果你直接string.match(nil, ...)就会报错退出。养成习惯每次 read 之后先判空。另一个坑是os.time()的精度只有秒级做毫秒级超时要自己用循环计数或者用rtt.read的 timeout 参数来间接控制。还有一点脚本里的os.exit会立刻终止进程如果此时还有未 flush 的文件写入数据可能丢。稳妥做法是显式调用file.flush()或者用file.save这种一次性写入的 API而不是自己维护文件句柄。注意rttsh 的 Lua 引擎默认不加载os.execute和io.popen这是出于安全考虑防止脚本执行任意系统命令。如果你确实需要得用--allow-exec显式开启。在 CI 里跑第三方脚本时千万别开这个开关。5.4 性能调优的几个实测结论我做过一组对比测试同一块 STM32H7 板子固件以 100KB/s 的速度打印rttsh 在不同参数下的表现参数组合平均吞吐CPU 占用丢数据情况默认轮询约 80KB/s15%偶发丢--read-interval 0约 95KB/s35%极少丢--read-interval 1约 60KB/s8%较多丢加大缓冲区到 8KB约 95KB/s15%无丢结论是如果日志量大优先加大目标机缓冲区其次调小--read-interval。CPU 占用在 CI 环境里通常不是瓶颈但如果你在笔记本上跑--read-interval 1能省电。6. 我对这个工具后续扩展的一些想法rttsh 目前够用但还有几个方向我觉得值得做。一个是多通道并行读取现在一次只能读一个通道如果固件用了多个 RTT 通道分别打不同级别的日志得开多个进程有点浪费。另一个是和 GDB 的联动比如在 GDB 里设断点断点命中时自动让 rttsh 抓一段日志这样能把代码执行和日志对上。还有就是日志的实时过滤和告警比如匹配到ERROR就发通知这个在长时间无人值守的测试里很有用。不过工具这东西功能加多了就容易变重。我个人的原则是核心的读写等判存做扎实剩下的交给脚本和下游工具。rttsh 的定位就是“RTT 的管道工”把数据从芯片里可靠地搬出来怎么用是用户的事。这个定位我觉得短期内不会变。