
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数据可能还在内存里脚本被 CtrlC 杀掉就丢了。长时间运行的日志脚本一定要记得 flush或者用io.stdout:setvbuf(no)关掉缓冲。对于二进制数据我建议用十六进制导出方便后续用其他工具分析local data rtt.read(1) -- 读通道 1 local hex {} for i 1, #data do hex[#hex1] 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.somacOS 是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 交互模式下的快速调试连上之后直接进 REPLrttsh --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这个循环会一直打印直到你 CtrlC。REPL 里 CtrlC 是中断当前执行不会退出程序所以你可以中断后再敲别的命令。想看看有哪些通道 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 不是 Cstring.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.exitLua 的 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[#buf1] 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-M01KB 的缓冲区可能就是 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 里解放出来变成可编程的接口。只要这个核心在上面能长出来的东西是无限的。