☰
rttsh:支持Lua脚本的J-Link RTT命令行工具,让嵌入式调试自动化
2026/10/3 12:13:36 网站建设 项目流程

1. 为什么我要自己写一个 RTT 命令行工具

嵌入式调试这件事,用过 J-Link 的人应该都有体会:RTT(Real 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 秒的日志存文件”,就是wait+write+read --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 脚本引擎的嵌入方式与可用 API

rttsh 内嵌 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 块板子要做出厂测试,每块板子跑同一套固件,你需要验证每块板子都能正常启动、自检通过、输出特定标志。流程是这样的:

  1. 写一个 Lua 脚本factory_test.lua,逻辑是等BOOT_OK,然后发selftest命令,等SELFTEST_PASS或SELFTEST_FAIL,把结果写文件,退出码反映成败。
  2. 写一个 shell 脚本遍历所有探针序列号,对每个序列号调一次rttsh --script factory_test.lua --serial <SN> --output result_<SN>.log。
  3. 收集所有 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=$?" done

4.4 数据导出与 CI 集成

数据导出这块,rttsh 支持边读边写文件,不需要先把所有数据攒在内存里。--output指定的文件是流式写入的,即使跑几个小时也不会撑爆内存。如果要做日志轮转,可以在脚本里判断文件大小,超过阈值就file.save到新文件。

CI 集成是重点。在 GitLab CI 或 GitHub Actions 里,把 rttsh 的可执行文件放进 runner 能访问的路径,然后加一个 job:

rtt_test: stage: test script: - rttsh --script ci_test.lua --serial $JLINK_SN --output rtt.log artifacts: paths: - rtt.log when: always

when: 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 的管道工”,把数据从芯片里可靠地搬出来,怎么用是用户的事。这个定位我觉得短期内不会变。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询