1. 为什么我要自己写一个 RTT 命令行工具
嵌入式调试这件事,干过的都懂。J-Link 自带的 RTT Viewer 是个好东西,实时终端、不占串口、速度也够快,但它有个致命问题——它是个 GUI。GUI 意味着你没法把它塞进脚本里,没法在无人值守的板子上跑一晚上自动抓日志,没法在 CI 里对着输出做断言,更没法让 AI 助手直接调用它去读板子状态。
我日常的工作流里,板子常年挂在测试架上,跑的是长时间稳定性验证。以前的做法是开个 RTT Viewer 手动看,或者用 JLinkRTTClient 那个命令行版本凑合。但 JLinkRTTClient 只能连、只能打印,你想在日志里做点条件判断、想把某段数据自动存成文件、想在特定字符串出现时触发一个动作,它一概做不到。每次都要人肉盯着屏幕,效率低得让人抓狂。
于是就有了rttsh——一个支持脚本化的 J-Link RTT 命令行工具。核心思路很简单:把 RTT 的读写能力封装成一套命令行接口,再嵌一个 Lua 解释器进去,让脚本能直接控制 RTT 通道。这样你既可以在终端里敲一行命令读一段日志,也可以写个 Lua 脚本让它自动轮询、匹配关键字、导出数据、跑完整个验证流程。它解决的就是"RTT 调试自动化"这一个问题,适合所有用 J-Link 调板子、又想把调试流程脚本化的人——不管你是做量产测试、协议验证,还是单纯想让 AI 帮你盯板子。
2. 整体设计思路与方案选型
2.1 为什么是命令行加 Lua,而不是纯 Python 库
市面上其实已经有 Python 的 pylink 之类的库能操作 J-Link,为什么还要单独做个命令行工具?我当时的考量有三点。
第一,命令行是通用接口。任何语言、任何工具、任何 CI 系统都能调用一个可执行文件,但调用一个 Python 库就得先配环境、装依赖。rttsh 编译出来就是一个二进制,扔到测试机上就能跑,没有运行时依赖的烦恼。这一点在产线测试机上尤其重要,那些机器往往环境很干净,装个 Python 都要审批。
第二,Lua 做嵌入式脚本刚刚好。Lua 解释器极小,嵌入成本低,语法简单到非程序员也能看懂,而且它的 C API 非常成熟。相比之下,如果内嵌 Python,整个工具的体积和复杂度会翻好几倍。Lua 还有个好处是启动快,脚本执行几乎没有额外开销,适合那种"读一次、判断一次、退出"的短生命周期调用。
第三,脚本化不等于写程序。很多调试场景需要的不是完整的程序,而是"读一段、匹配一下、存个文件"这种轻量逻辑。用 Lua 写十几行就能搞定,用 Python 反而要写一堆样板代码。而且 Lua 脚本可以直接作为命令行参数传进去,rttsh -e "print(rtt.read())"这种用法,比写个 .py 文件再运行要顺手得多。
2.2 核心架构:三层分离
rttsh 的内部结构我分成了三层,这样各层职责清晰,后续扩展也方便。
最底层是J-Link 通信层,直接调用 SEGGER 的 JLinkARM 动态库(或者 JLinkExe 的命令行接口,取决于平台)。这一层负责连接目标、配置 RTT 控制块地址、读写 RTT 缓冲区。RTT 的机制本质上是在目标内存里维护一个环形缓冲区,控制块里存着读写指针,主机端通过 J-Link 的内存读写能力去轮询这个缓冲区。理解这一点很关键——RTT 不是"推"数据,而是主机"拉"数据,所以轮询频率直接决定了实时性。
中间层是RTT 抽象层,把底层的读写封装成read、write、readline、wait_for这些语义化操作。这一层还处理了缓冲区的边界情况,比如环形缓冲区绕回、数据被覆盖、通道 0 和通道 1 的区分等。很多人用 RTT 只用了通道 0(终端输出),其实通道 1 可以用来传二进制数据或者结构化日志,rttsh 把多通道支持做进了抽象层。
最上层是Lua 绑定层,把中间层的操作暴露成 Lua 函数,同时提供文件 IO、字符串处理、定时器等辅助能力。Lua 标准库本身就有字符串和文件操作,我只需要把 RTT 相关的函数注册进去就行。这样脚本作者可以用rtt.read()读数据,用string.find匹配,用io.open写文件,整个体验非常自然。
2.3 命令行的两种使用模式
rttsh 设计成两种模式,覆盖不同场景。
交互模式:直接运行rttsh,进入一个 REPL,你可以一行一行敲 Lua 代码,实时看结果。这个模式适合探索性调试,比如你想看看板子现在在输出什么,或者想临时试一段匹配逻辑。REPL 里还内置了一些快捷命令,比如:connect、:channels、:dump,不用写完整 Lua 也能操作。
脚本模式:rttsh script.lua或者rttsh -e "...",执行完就退出。这个模式适合自动化和 CI。脚本里可以访问完整的 RTT API,也可以接收命令行参数(通过arg表),返回值可以通过退出码传给调用方。比如脚本里检测到错误就os.exit(1),CI 就能据此判断失败。
这两种模式共用同一套 Lua 环境和 RTT 绑定,所以你在 REPL 里调通的逻辑,直接复制到脚本里就能用,不用改任何东西。
3. 核心细节解析与实操要点
3.1 RTT 控制块地址的确定
RTT 能不能连上,第一关就是控制块地址。SEGGER 的 RTT 实现会在目标内存里放一个_SEGGER_RTT符号,主机端需要知道这个符号的地址才能找到控制块。rttsh 提供了三种确定地址的方式,按优先级从高到低。
第一种是自动搜索。J-Link 的 DLL 提供了一个JLINK_RTTERMINAL_Control接口,可以直接让 J-Link 自己去搜控制块。这个方式最省事,但依赖 J-Link 固件版本,有些老固件不支持。实测下来,J-Link V9 以上的固件基本都没问题,V8 及以下就悬了。
第二种是从 ELF/AXF 文件读符号。如果你有编译产物的符号表,rttsh 可以解析出_SEGGER_RTT的地址。这个方式最可靠,因为地址是编译时确定的。我用的是自己写的一个轻量 ELF 解析器,只读符号表,不依赖 libelf,这样跨平台编译省事。AXF 是 ARM 的格式,本质是 ELF 的变体,解析逻辑基本一样。
第三种是手动指定地址。--rtt-addr 0x20000000这样传进去。这个方式适合那些符号被优化掉、或者你从 map 文件里查到地址的情况。手动指定的时候要注意地址对齐,RTT 控制块要求 4 字节对齐,不对齐会读出错乱的数据。
提示:如果你用自动搜索连不上,先别急着怀疑硬件,八成是固件版本或者控制块被优化掉了。用
--rtt-addr手动指定一个从 map 文件查到的地址,往往能立刻解决。
3.2 环形缓冲区的读取策略
RTT 的上下行缓冲区都是环形结构,主机端读的时候要处理"写指针追上读指针"的情况。rttsh 的读取策略是这样的:每次读之前先读控制块里的写指针,和上次记录的读指针比较,算出可读字节数,然后一次性读出来,最后更新读指针。
这里有个坑:如果主机读得太慢,写指针会绕一圈追上读指针,导致旧数据被覆盖。RTT 的默认缓冲区大小通常是 1KB 到 4KB,如果你的板子输出很猛(比如每秒几十 KB 的日志),而 rttsh 的轮询间隔又比较长,就一定会丢数据。解决办法有两个:一是加大目标端的 RTT 缓冲区(改BUFFER_SIZE_UP宏),二是提高 rttsh 的轮询频率。
rttsh 默认的轮询间隔是 10ms,可以通过--poll-interval调整。实测下来,10ms 对于大多数场景够用,但如果你的日志量特别大,可以降到 1ms。不过要注意,轮询太频繁会占用 J-Link 的带宽,影响其他调试操作(比如你同时还在用 IDE 的单步调试)。我的经验是,纯抓日志的场景用 1ms,边调试边抓日志用 20ms 到 50ms。
还有一个细节是通道选择。RTT 支持最多 16 个上行通道和 16 个下行通道,通道 0 默认是终端。rttsh 的read函数默认读通道 0,但你可以指定rtt.read(1)读通道 1。多通道的好处是可以把不同类型的日志分开,比如通道 0 放普通打印,通道 1 放二进制协议数据,通道 2 放性能计数。这样脚本处理的时候就不用在一堆文本里做正则匹配了。
3.3 Lua 绑定的设计取舍
把 RTT 操作暴露给 Lua,看起来简单,其实有不少设计决策。
阻塞还是非阻塞:rtt.read()我设计成非阻塞的,有多少读多少,没数据就返回空字符串。这样脚本可以自己控制轮询节奏。但有些场景需要"等到某个字符串出现",所以我另外提供了rtt.wait_for(pattern, timeout),这个是阻塞的,内部循环轮询直到匹配或超时。两个函数各司其职,脚本作者按需选用。
返回字符串还是字节数组:Lua 的字符串是字节安全的,可以直接存二进制。所以rtt.read()返回的就是原始字节的字符串,不做任何编码转换。这样处理二进制协议数据也没问题。如果你要按行读,用rtt.readline(),它会一直读到\n为止,返回的字符串包含换行符。
错误处理:RTT 操作可能失败(连接断了、地址错了、缓冲区读失败),这些错误我统一用 Lua 的error机制抛出,脚本可以用pcall捕获。这样既符合 Lua 的习惯,也不会让脚本因为一个偶发错误就整个崩掉。比如:
local ok, data = pcall(rtt.read) if not ok then print("read failed: " .. data) -- 尝试重连 rtt.connect() end定时器:脚本里经常需要"等 100ms 再读",Lua 标准库没有 sleep。我注册了一个rtt.sleep(ms)函数,内部就是简单的忙等或者系统 sleep。忙等在嵌入式场景下其实可以接受,因为脚本通常跑在 PC 上,不是目标板。但如果你的脚本要跑很久,还是用系统 sleep 更省 CPU。
3.4 数据导出与文件操作
调试的最终目的往往是拿到数据。rttsh 提供了几种导出方式。
最简单的是重定向到文件:rttsh --log output.txt会把所有读到的数据追加写入文件。这个模式适合长时间抓日志,脚本都不用写。
更灵活的是在 Lua 脚本里控制。你可以按条件写文件,比如只在匹配到特定关键字时才记录上下文:
local f = io.open("errors.log", "a") while true do local line = rtt.readline() if line and string.find(line, "ERROR") then f:write(os.date("%H:%M:%S ") .. line) f:flush() end end注意f:flush()这行。Lua 的文件写入默认是带缓冲的,如果你不 flush,数据可能还在内存里,脚本被 Ctrl+C 杀掉就丢了。长时间运行的日志脚本一定要记得 flush,或者用io.stdout:setvbuf("no")关掉缓冲。
对于二进制数据,我建议用十六进制导出,方便后续用其他工具分析:
local data = rtt.read(1) -- 读通道 1 local hex = {} for i = 1, #data do hex[#hex+1] = string.format("%02X", string.byte(data, i)) end f:write(table.concat(hex, " ") .. "\n")4. 实操过程与核心环节实现
4.1 环境准备与连接建立
先说环境。rttsh 依赖 SEGGER 的 J-Link 软件包,你需要先装好 J-Link 驱动和 JLinkARM 动态库。Windows 上装完驱动后,JLinkARM.dll通常在C:\Program Files\SEGGER\JLink\下。Linux 上是libjlinkarm.so,macOS 是libjlinkarm.dylib。rttsh 启动时会去默认路径找,找不到就用--jlink-path指定。
连接目标的基本命令是:
rttsh --device STM32F407VG --interface SWD --speed 4000这里--device是目标芯片型号,--interface是 SWD 或 JTAG,--speed是 SWD 时钟频率(单位 kHz)。4000 就是 4MHz,这个速度对大多数 Cortex-M 芯片都够用。如果你的板子走线比较长或者有干扰,可以降到 1000 试试。
连接成功后,rttsh 会打印控制块地址和缓冲区信息,类似:
Connected to STM32F407VG via SWD @ 4000 kHz RTT control block found at 0x20000A1C Up buffer: 1024 bytes, Down buffer: 16 bytes看到这个就说明 RTT 通了。如果卡在 "Searching for RTT control block...",八成是地址问题,参考 3.1 节的三种方式排查。
4.2 交互模式下的快速调试
连上之后,直接进 REPL:
rttsh --device STM32F407VG --interface SWD进去之后,先试试读数据:
> rtt.read() "Hello from target\r\n"如果板子还没开始输出,可以循环读:
> while true do local d = rtt.read(); if #d > 0 then io.write(d) end end这个循环会一直打印,直到你 Ctrl+C。REPL 里 Ctrl+C 是中断当前执行,不会退出程序,所以你可以中断后再敲别的命令。
想看看有哪些通道:
> rtt.channels() {up = {0, 1}, down = {0}}这表示上行通道 0 和 1 有数据,下行通道 0 可用。下行通道可以用来给目标发数据,比如模拟串口输入:
> rtt.write(0, "command\r\n")4.3 写一个完整的自动化验证脚本
假设我要验证一个通信协议:板子会周期性发送数据帧,每帧以0xAA 0x55开头,后面跟长度和载荷。我要抓 100 帧,校验每帧的校验和,把失败的帧存下来。
脚本大概长这样:
-- protocol_check.lua local frame_count = 0 local error_count = 0 local f = io.open("bad_frames.bin", "wb") rtt.connect() -- 确保连接 while frame_count < 100 do local data = rtt.read(1) -- 从通道 1 读二进制 if #data == 0 then rtt.sleep(10) else -- 在数据里找帧头 local pos = 1 while true do local start = string.find(data, "\xAA\x55", pos) if not start then break end -- 需要至少 4 字节才能读出长度 if start + 3 > #data then break end local len = string.byte(data, start + 2) local frame_end = start + 3 + len if frame_end > #data then break end local frame = string.sub(data, start, frame_end) -- 校验和:从长度字节到载荷结束的异或 local checksum = 0 for i = start + 2, frame_end - 1 do checksum = checksum ~ string.byte(data, i) end local expected = string.byte(data, frame_end) if checksum ~= expected then error_count = error_count + 1 f:write(frame) f:flush() end frame_count = frame_count + 1 pos = frame_end + 1 end end end f:close() print(string.format("Checked %d frames, %d errors", frame_count, error_count)) os.exit(error_count == 0 and 0 or 1)这个脚本有几个关键点。第一,它从通道 1 读,因为二进制数据走通道 1 更干净。第二,它处理了"帧跨读取边界"的情况——一次rtt.read可能只读到半帧,所以脚本里用pos游标在缓冲区里找完整帧,不完整的部分留到下次。第三,校验和用的是异或,这是很多简单协议的做法,实际项目里可能是 CRC16,那就得换成对应的算法。第四,退出码根据错误数决定,这样 CI 能直接判断。
跑起来:
rttsh --device STM32F407VG --interface SWD protocol_check.lua4.4 在 CI 里集成
CI 集成其实很简单,就是把 rttsh 当成一个普通命令行程序调用。以 GitLab CI 为例:
rtt_test: stage: test script: - ./rttsh --device STM32F407VG --interface SWD --rtt-addr 0x20000A1C protocol_check.lua artifacts: paths: - bad_frames.bin when: on_failure这里我显式指定了--rtt-addr,因为 CI 机器上可能没有编译产物,自动搜索又不一定靠谱。地址从 map 文件里查,写死在 CI 配置里。如果地址会变(比如固件更新后符号位置变了),可以在构建阶段把地址提取出来,通过环境变量传给测试阶段。
artifacts那段是让失败的帧文件在测试失败时保留下来,方便事后分析。这个很实用,因为 CI 跑完环境就销毁了,不保留产物的话你根本不知道哪里错了。
注意:CI 机器上要确保 J-Link 驱动装好,而且 USB 设备能透传给 CI runner。如果是容器化的 runner,需要把 USB 设备映射进去,这个配置因平台而异,得看你的 CI 环境文档。
5. 常见问题与排查技巧实录
5.1 连接类问题速查
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 找不到 J-Link | 驱动没装或路径不对 | 用--jlink-path指定,或检查设备管理器 |
| 连不上目标 | 接口/速度/芯片型号不对 | 先用 J-Link Commander 确认能连 |
| 找不到 RTT 控制块 | 地址不对或固件不支持 | 用--rtt-addr手动指定,或从 map 文件查 |
| 读到乱码 | 地址不对齐或缓冲区被覆盖 | 检查地址 4 字节对齐,加大缓冲区 |
| 数据断断续续 | 轮询太慢或缓冲区太小 | 降低--poll-interval,加大目标端缓冲区 |
5.2 数据丢失的排查思路
数据丢失是 RTT 最常见的坑。排查顺序是这样的。
先确认是不是缓冲区溢出。rttsh 有个--stats选项,会定期打印读到的字节数和缓冲区剩余空间。如果剩余空间经常接近 0,那就是溢出无疑。解决办法是加大目标端的BUFFER_SIZE_UP,或者提高主机端轮询频率。
再确认是不是轮询间隔太长。默认 10ms,如果你的板子每 1ms 输出一次,那每次轮询之间可能积压了 10 次输出,缓冲区小的话就覆盖了。把--poll-interval降到 1ms 试试。
还要确认是不是J-Link 带宽被占用。如果你同时开着 IDE 的调试会话,J-Link 的带宽是共享的,RTT 读取会变慢。这种情况要么关掉 IDE 调试,要么接受一定的数据丢失。
最后检查目标端代码。有些 RTOS 的 RTT 实现会在任务切换时暂停输出,或者用了非阻塞写导致数据被丢弃。这个得看具体实现,SEGGER 的官方 RTT 代码是阻塞写的,一般不会丢,但有些移植版本改成了非阻塞,就会丢。
5.3 Lua 脚本的常见坑
字符串索引从 1 开始。Lua 不是 C,string.sub(s, 1, 3)取的是前三个字符,不是从 0 开始。这个新手最容易搞错。
nil和空字符串的区别。rtt.read()没数据时返回空字符串"",不是nil。所以判断有没有数据要用#data > 0,不能用data == nil。
文件句柄要关闭。Lua 的 GC 虽然会回收,但文件句柄不显式关闭的话,在长时间运行的脚本里可能耗尽。养成f:close()的习惯,或者用io.open的to-be-closed变量(Lua 5.4 支持)。
os.exit会跳过__gc。如果你在脚本末尾用os.exit,Lua 的 GC 不会运行,未关闭的文件可能丢数据。所以os.exit之前一定要手动 flush 和 close。
5.4 性能调优经验
如果你要抓大量数据,几个调优点。
用二进制通道。文本通道有编码转换开销,二进制通道直接传原始字节,快很多。
批量读。rtt.read()一次读尽可能多的数据,不要一次读一个字节。rttsh 内部会一次读整个可用缓冲区,所以脚本里一次read就能拿到一批数据。
减少 flush 频率。写文件时不要每行都 flush,攒一批再 flush。但也不能攒太多,否则崩溃时丢得多。我的经验是每 100 行或者每 1KB flush 一次。
关掉不必要的日志。rttsh 的--verbose会打印很多调试信息,正式跑的时候关掉,能省不少 IO。
6. 脚本化 RTT 的扩展玩法
6.1 让 AI 助手直接读板子
这是我觉得最有意思的玩法。因为 rttsh 是个命令行工具,任何能执行命令的 AI 助手都能调用它。你可以让 AI 帮你分析板子输出的日志,或者根据日志内容决定下一步操作。
具体做法是给 AI 一个封装好的脚本,比如read_rtt.lua:
-- 读 5 秒数据然后退出 local deadline = os.time() + 5 local buf = {} while os.time() < deadline do local d = rtt.read() if #d > 0 then buf[#buf+1] = d end rtt.sleep(10) end io.write(table.concat(buf))AI 调用rttsh read_rtt.lua,拿到输出后分析。如果发现异常,AI 可以再调用其他脚本去读更多上下文,或者发命令给板子。整个流程不需要人干预。
6.2 批量脚本验证
产线测试经常要跑一批脚本,每个脚本验证一个功能。rttsh 可以配合 shell 脚本批量跑:
#!/bin/bash for test in tests/*.lua; do echo "Running $test" rttsh --device STM32F407VG --interface SWD "$test" if [ $? -ne 0 ]; then echo "FAILED: $test" exit 1 fi done echo "All tests passed"每个测试脚本自己负责连接、执行、判断、退出。rttsh 只提供 RTT 能力,测试逻辑全在 Lua 里。这样测试脚本可以独立开发、独立维护,互不影响。
6.3 数据导出与分析流水线
抓到的数据可以进一步处理。比如把 RTT 数据导出成 CSV,然后用 Python 或 Excel 分析:
-- export_csv.lua local f = io.open("data.csv", "w") f:write("timestamp,value\n") while true do local line = rtt.readline() if line then local ts, val = string.match(line, "(%d+),(%d+)") if ts then f:write(ts .. "," .. val .. "\n") end end rtt.sleep(10) end这个脚本假设板子输出的是timestamp,value格式。实际项目里格式可能更复杂,但思路是一样的:读一行、解析、写 CSV。导出后就可以用任何数据分析工具处理了。
7. 我踩过的几个坑和对应解法
第一个坑是控制块地址在固件更新后变了。我一开始把地址写死在 CI 配置里,结果固件一更新,符号位置变了,CI 全挂。后来改成构建阶段从 map 文件提取地址,通过环境变量传给测试阶段,就再没出过问题。提取地址的命令大概是grep _SEGGER_RTT build/output.map | awk '{print $1}',不同工具链的 map 格式略有差异,得按实际情况调整。
第二个坑是Lua 的整数溢出。Lua 5.3 之前只有双精度浮点数,处理 32 位整数没问题,但处理 64 位就会丢精度。如果你的协议里有 64 位字段,要么用 Lua 5.3+ 的整数类型,要么用字符串处理。我一开始没注意,解析时间戳的时候丢了几位,查了半天才发现是浮点精度问题。
第三个坑是J-Link 连接被其他程序占用。J-Link 是独占设备,同一时间只能有一个程序连。如果你开着 IDE 调试,rttsh 就连不上。解决办法是关掉 IDE,或者用 J-Link 的-SelectEmuBySN参数指定不同的仿真器(如果你有多个 J-Link)。这个坑在 CI 上尤其常见,因为 CI runner 可能同时跑多个测试任务,抢同一个 J-Link。我的做法是给每个测试任务分配独立的 J-Link,或者用锁机制串行化。
第四个坑是RTT 缓冲区大小和目标端内存的权衡。加大缓冲区能减少丢数据,但会占用目标 RAM。在 RAM 紧张的芯片上(比如只有 20KB RAM 的 Cortex-M0),1KB 的缓冲区可能就是 5% 的内存。我的经验是,如果日志量不大,512 字节够用;如果日志量大,先考虑降低输出频率或者用二进制压缩,实在不行再加缓冲区。
第五个坑是脚本的异常处理不完整。早期版本的脚本里,我一个rtt.read()没包pcall,结果 J-Link 偶发断开时整个脚本崩了,CI 报了个莫名其妙的错误。后来所有 RTT 操作都包了pcall,断开时自动重连,重连失败才退出。这样偶发的 USB 抖动不会导致测试失败,稳定性好了很多。
8. 后续可以怎么扩展
rttsh 目前只做了 RTT 的读写和脚本化,其实还有很多可以加的东西。
比如加一个 RTT 数据的实时绘图。Lua 脚本里可以把数值数据推给 gnuplot 或者写个简单的终端绘图,实时看波形。这个对调试传感器数据特别有用。
再比如加一个 RTT 到 MQTT 的桥接。把板子的日志实时推到消息队列,其他系统订阅分析。这个在物联网场景下很实用,板子在测试架上跑,数据直接进云端。
还有加一个 RTT 命令的自动补全。REPL 里敲rtt.然后 Tab,列出所有可用函数。这个能大幅提升交互模式的体验,尤其是记不住函数名的时候。
最后是多 J-Link 并行。现在 rttsh 一次只能连一个 J-Link,如果测试架上有多个板子,得开多个 rttsh 实例。如果能在单个进程里管理多个连接,批量测试会方便很多。这个改动比较大,涉及到底层通信层的重构,但值得做。
这些扩展我还没动手,但思路都在。rttsh 的核心价值是把 RTT 从 GUI 里解放出来,变成可编程的接口。只要这个核心在,上面能长出来的东西是无限的。