尧图精选

告别GUI:用Lua脚本打造J-Link RTT命令行自动化调试工具

🕒 发布时间:2026/10/2 11:22:39 📁 来源:尧图网络
1. 为什么我要自己写一个 RTT 命令行工具嵌入式调试这件事做过几年的人都有一个共同感受IDE 里的调试器很好用但一旦离开 IDE事情就变得别扭起来。J-Link RTT 就是典型例子。SEGGER 官方的 RTT Viewer 是个 GUI 工具点点鼠标看日志没问题可一旦你想在 CI 里跑、想批量验证、想把日志自动落盘分析GUI 就成了拦路虎。我日常的工作流里板子经常是远程挂着的本地只有一台跑脚本的机器。每次调试都要开远程桌面、点开 RTT Viewer、手动选芯片型号、连上、看日志、复制粘贴一套下来十分钟没了。更别提做回归测试的时候几十块板子要轮流跑同一套固件靠人工点根本不现实。所以我就动手写了rttsh一个支持脚本化的 J-Link RTT 命令行工具。核心思路很简单把 RTT 的连接、读取、写入、日志落盘这些动作全部命令化再套一层 Lua 脚本引擎让整个调试流程可以像写 shell 脚本一样被编排。这样 AI 在板调试、批量脚本验证、数据导出、CI 集成这些场景就都能覆盖了。这篇文章我会把 rttsh 的设计思路、核心实现、实操步骤、踩过的坑全部摊开讲。如果你也在做嵌入式自动化测试或者单纯想摆脱 GUI 调试器的束缚这篇内容应该能帮你省下不少时间。2. rttsh 的整体设计与技术选型2.1 为什么是命令行 Lua 脚本这个组合先说选型。市面上做 RTT 自动化的方案其实有几类一是直接用 SEGGER 的 JLinkExe/JLink Commander通过脚本文件驱动二是用 pyOCD、OpenOCD 这类开源方案三是自己调 J-Link SDK 的 DLL/SO。我最终选了命令行工具 Lua 脚本引擎的组合理由有三条。第一命令行是自动化的最小公分母。不管你是写 Makefile、写 Python 脚本、还是配 CI 的 pipeline调用一个可执行文件永远是最稳的方式。GUI 做不到这一点而纯 SDK 调用又要求你写 C 代码门槛太高。第二Lua 的嵌入成本极低。Lua 解释器编译出来才两百多 KBAPI 干净和 C/C 互操作非常顺。相比之下如果我用 Python 做脚本层就得处理 Python 环境、依赖包、版本兼容这一堆破事。嵌入式工程师的机器上不一定有干净的 Python 环境但一个静态编译的二进制文件放哪都能跑。第三Lua 的语法对嵌入式工程师友好。它足够简单没有复杂的类型系统写起来像伪代码。而且 Lua 的协程机制特别适合处理等待某个日志出现这类异步场景比回调地狱舒服太多。提示如果你团队里没人写过 Lua别慌。Lua 的核心语法半小时就能上手比学一门新语言轻松得多。rttsh 的脚本 API 我刻意设计得很薄常用的就十来个函数。2.2 底层通信J-Link SDK 还是 JLinkExe这是第二个关键决策。J-Link 的 RTT 功能底层是通过 J-Link 的 RTT 控制块RTT Control Block在目标内存里读写环形缓冲区实现的。要访问它有两条路。一条路是调用 SEGGER 提供的 J-Link SDK里面有JLINK_RTTERMINAL_Read、JLINK_RTTERMINAL_Write这些 API直接操作 RTT 缓冲区。这条路性能最好延迟最低但需要链接 SEGGER 的动态库而且 SDK 有授权限制分发的时候要注意合规。另一条路是调用 JLinkExe 的命令行接口用RTT相关的命令间接操作。这条路的好处是只依赖 J-Link 的软件包不需要额外授权坏处是每次读写都要走一次进程通信延迟高而且 JLinkExe 的脚本模式对交互式读取支持得不好。我最终选了J-Link SDK 直连的方案因为 rttsh 的核心价值就在于低延迟的实时读写。如果延迟太高等待某个日志出现再触发下一步这种脚本就失去了意义。SDK 的授权问题我通过让用户自行安装 J-Link 软件包来解决rttsh 本身不打包任何 SEGGER 的二进制。2.3 整体架构分层rttsh 的架构我分了三层从上到下依次是脚本层Lua 解释器负责执行用户写的.lua脚本提供rtt.read()、rtt.write()、rtt.wait()这些 API。命令层命令行解析器把rttsh connect --device STM32F407 --speed 4000这样的命令翻译成内部调用。通信层封装 J-Link SDK 的 RTT 操作管理连接生命周期、缓冲区读写、错误重试。这三层之间通过一个统一的上下文对象传递状态。脚本层和命令层共享同一个上下文所以你可以先用命令行连上板子再进入交互模式敲 Lua 命令状态是连续的。分层的好处是每一层都能单独测试。通信层我用一个模拟的 RTT 缓冲区做单元测试命令层用参数解析的测试用例覆盖脚本层则直接跑真实的 Lua 脚本。这样出问题的时候定位很快不用一层层猜。3. 核心功能拆解与实操要点3.1 连接管理设备、速度、接口怎么选连接是第一步也是最容易出问题的一步。rttsh 的连接命令长这样rttsh connect --device STM32F407VG --speed 4000 --interface SWD这里三个参数都有讲究。--device是芯片型号必须和 J-Link 软件包里支持的型号完全匹配。我踩过的坑是STM32F407 有好几个后缀STM32F407VG和STM32F407VE在 J-Link 里是两个不同的条目选错了连不上。你可以用rttsh list-devices列出所有支持的型号或者用rttsh list-devices --filter STM32F4过滤。--speed是 SWD/JTAG 的时钟频率单位 kHz。4000 就是 4MHz。这个值不是越高越好。线太长、板子供电不稳的时候高频会导致连接失败或者 RTT 数据丢包。我的经验是短线10cm 以内可以上 4000 甚至 8000长线20cm 以上降到 1000 到 2000 更稳。--interface是调试接口SWD 或 JTAG。现在绝大多数 Cortex-M 芯片都用 SWD引脚少速度快。JTAG 一般只在老芯片或者需要多核调试的时候用。连接成功后rttsh 会打印 RTT 控制块找到的地址和缓冲区大小。如果没找到控制块通常是两个原因一是固件里没初始化 RTT二是控制块地址需要手动指定。后者可以用--rtt-address 0x20000000强制指定。注意RTT 控制块默认是让 J-Link 自动搜索的搜索范围是整个 RAM。如果 RAM 很大搜索会慢。你可以在固件里把控制块放到一个固定地址然后用--rtt-address指定连接速度能快好几倍。3.2 数据读取阻塞、非阻塞与超时控制读取 RTT 数据是 rttsh 用得最多的功能。我设计了三种读取模式。非阻塞读取rtt.read()立即返回当前缓冲区里的所有数据没有就返回空字符串。适合在主循环里轮询。阻塞读取rtt.read({timeout1000})会等待最多 1000 毫秒直到有数据或者超时。适合等一条日志出现的场景。行读取rtt.readline()会一直读到换行符为止。适合解析结构化的日志输出。这三种模式在 Lua 脚本里的用法-- 非阻塞轮询 while true do local data rtt.read() if data ~ then print(data) end os.sleep(10) end -- 阻塞等特定日志 local line rtt.readline({timeout5000}) if line:find(BOOT OK) then print(启动成功) end这里有个细节RTT 的缓冲区是环形的如果读取速度跟不上写入速度旧数据会被覆盖。rttsh 在读取的时候会尽量一次性把缓冲区里的数据全部取走但如果你在脚本里做了耗时的操作比如写文件、发网络请求就可能丢数据。我的建议是读取和落盘分开用一个单独的线程或者协程专门读读到之后丢进队列另一个协程慢慢处理。3.3 数据写入命令下发与交互式调试RTT 不只是单向的日志输出它也是双向的。你可以通过 RTT 向目标发送数据这在做交互式调试的时候特别有用。rtt.write(help\n) local response rtt.read({timeout1000}) print(response)这个能力让 rttsh 可以模拟一个串口终端。很多固件里都有 CLI 接口通过 RTT 下发命令、读取响应就能做自动化的功能验证。写入的时候要注意换行符。Windows 风格的固件可能期望\r\nLinux 风格的可能只认\n。我一般会在脚本里统一用\n如果固件不认再改成\r\n。还有一个坑是写入速度。RTT 的上行缓冲区通常比下行缓冲区小如果你连续快速写入大量数据可能会阻塞。rttsh 在写入的时候会检查缓冲区剩余空间空间不够就等待。你可以在脚本里用rtt.write(data, {timeout1000})设置写入超时。3.4 日志落盘格式、轮转与实时导出把 RTT 日志导出到文件是刚需。rttsh 提供了两种方式。一种是命令行直接落盘rttsh connect --device STM32F407VG --speed 4000 rttsh log --output debug.log --format text--format支持text、csv、json三种。text就是原始日志csv会加上时间戳和方向读/写json每条日志一个对象方便后续用脚本分析。另一种是在 Lua 脚本里手动控制local f io.open(debug.log, w) while true do local line rtt.readline({timeout1000}) if line then f:write(os.date(%Y-%m-%d %H:%M:%S ) .. line .. \n) f:flush() end end手动控制的好处是你可以加时间戳、加过滤、加格式化。f:flush()很重要不 flush 的话数据可能还在缓冲区里程序崩溃就丢了。日志轮转我用的是按大小切分。rttsh log --output debug.log --max-size 10M --rotate 5会在文件超过 10MB 的时候切分最多保留 5 个历史文件。这个逻辑在长时间跑测试的时候很有用不然一个日志文件能涨到几个 GB。4. 脚本化实战从单板调试到批量验证4.1 Lua 脚本 API 设计rttsh 的 Lua API 我刻意保持精简核心就这些API说明常用参数rtt.connect(opts)连接目标device, speed, interfacertt.read(opts)读取数据timeoutrtt.readline(opts)读取一行timeoutrtt.write(data, opts)写入数据timeoutrtt.wait(pattern, opts)等待匹配timeout, intervalrtt.disconnect()断开连接无rtt.log(msg)输出日志无rtt.wait是我用得最多的一个。它的逻辑是每隔interval毫秒读一次直到读到的内容匹配pattern支持 Lua 模式匹配或者超时。local ok rtt.wait(System Ready, {timeout10000, interval100}) if not ok then error(系统启动超时) end这个函数把轮询 匹配 超时三件事封装在一起脚本里写起来非常干净。4.2 单板调试脚本示例先看一个最简单的单板调试脚本。假设固件启动后会打印版本号然后进入 CLI我们要验证版本号是否正确然后执行一条命令。-- single_board_test.lua rtt.connect({deviceSTM32F407VG, speed4000, interfaceSWD}) -- 等启动日志 local version_line rtt.wait(FW Version: (.-)\n, {timeout5000}) if not version_line then error(没等到版本号) end print(固件版本: .. version_line) -- 下发命令 rtt.write(status\n) local status rtt.wait(STATUS: (.-)\n, {timeout2000}) print(状态: .. status) rtt.disconnect()这个脚本跑下来大概三秒钟比手动开 RTT Viewer 快多了。而且它可以被 CI 直接调用每次提交代码都跑一遍版本号错了立刻发现。4.3 批量验证多板并行与结果汇总批量验证是 rttsh 真正发挥价值的地方。假设你有 8 块板子通过 USB Hub 连到同一台机器上每块板子的 J-Link 序列号不同。rttsh 支持用--serial指定序列号。rttsh connect --serial 12345678 --device STM32F407VG --speed 4000批量脚本的思路是为每块板子起一个独立的 rttsh 进程各自跑测试脚本最后汇总结果。-- batch_test.lua local boards { {serial12345678, nameboard-01}, {serial12345679, nameboard-02}, {serial12345680, nameboard-03}, } local results {} for _, board in ipairs(boards) do local cmd string.format( rttsh run --serial %s --device STM32F407VG --script test.lua --output %s.log, board.serial, board.name ) local ok os.execute(cmd) results[board.name] ok and PASS or FAIL end for name, result in pairs(results) do print(string.format(%s: %s, name, result)) end这里用os.execute起子进程每个进程独立连接一块板子。好处是隔离性好一块板子挂了不影响其他板子。坏处是进程启动有开销如果板子很多比如 50 块可以考虑用协程在同一个进程里管理多个连接。提示批量测试的时候J-Link 的 USB 带宽是瓶颈。8 块板子同时跑每块板子的 RTT 读取速度都会下降。如果测试对实时性要求高建议分批跑每批 4 块。4.4 CI 集成退出码、超时与产物归档CI 集成是 rttsh 设计的核心目标之一。CI 环境有几个特点无人值守、需要明确的成功/失败信号、需要归档产物。rttsh 的退出码约定0脚本执行成功1脚本执行失败Lua 报错或断言失败2连接失败3超时CI 的 pipeline 里可以这样写- name: Run RTT test run: | rttsh run --device STM32F407VG --speed 4000 \ --script ci_test.lua \ --output rtt.log \ --timeout 60 timeout-minutes: 2 - name: Upload log if: always() uses: actions/upload-artifactv3 with: name: rtt-log path: rtt.log--timeout 60是脚本级别的超时60 秒没跑完就强制退出退出码 3。这个参数很重要不然脚本卡死会一直占着 CI 的 runner。if: always()保证即使测试失败日志也会被归档。排查问题的时候日志比什么都重要。5. 常见问题与排查技巧实录5.1 连接类问题速查连接问题占了 RTT 调试问题的一大半。我整理了一个速查表现象可能原因排查方法找不到 J-LinkUSB 驱动没装设备管理器看有没有 J-Link 设备连接超时芯片型号选错用list-devices确认型号连接超时线太长或速度太高降速到 1000 试试找不到 RTT 控制块固件没初始化 RTT检查固件里SEGGER_RTT_Init()有没有调用找不到 RTT 控制块控制块地址不在搜索范围用--rtt-address手动指定读到乱码缓冲区大小不匹配检查固件里的BUFFER_SIZE_UP配置Windows 11 上 J-Link 驱动的问题比较常见。J-Link V9 在 Win11 上需要装最新的驱动包老版本驱动会有兼容性问题。装完之后如果设备管理器里显示黄色感叹号试试卸载设备再重新插拔。5.2 数据丢包与乱码排查数据丢包通常有三个原因。第一读取速度跟不上。RTT 的缓冲区是环形的写满了就覆盖旧数据。解决办法是提高读取频率或者在固件里加大缓冲区。SEGGER_RTT_Conf.h里的BUFFER_SIZE_UP默认是 1024 字节可以改成 4096 甚至 8192。第二多线程竞争。如果固件里有多个线程同时往 RTT 写数据可能会交错。解决办法是在固件里加锁或者用不同的 RTT 通道channel分开输出。第三J-Link 速度太高导致数据错误。这个比较隐蔽表现是偶尔出现乱码。降速到 2000 以下通常能解决。乱码还有一个可能字符编码。如果固件输出的是 GBK 编码的中文而 rttsh 按 UTF-8 解析就会乱码。rttsh 默认按原始字节读取不做编码转换你可以在脚本里用iconv转换。5.3 脚本执行超时与死锁Lua 脚本死锁最常见的原因是rtt.wait的超时设置不合理。比如你等一条永远不会出现的日志超时设了 60 秒脚本就卡 60 秒。我的经验是每个rtt.wait都要设超时而且超时时间要合理。等启动日志可以设 10 秒等命令响应设 2 秒等周期性日志设 5 秒。宁可超时失败也不要无限等待。另一个死锁场景是rtt.write阻塞。如果目标的下行缓冲区满了写入会一直等。rttsh 的写入默认超时是 5 秒超过就报错。你可以在脚本里捕获这个错误做重试或者跳过。local ok, err pcall(function() rtt.write(long command\n, {timeout3000}) end) if not ok then print(写入失败: .. err) -- 做清理或者重试 end用pcall包裹可能失败的操作是 Lua 脚本健壮性的关键。5.4 多板并发时的资源竞争多板并发的时候最容易出问题的是 J-Link 的 USB 资源。每块板子的 J-Link 都是一个 USB 设备如果同时打开太多USB 控制器可能扛不住。我的建议是并发数不要超过 8。如果板子更多用队列分批处理。另外每块板子的连接和断开都要成对出现不要连上就不管了。rttsh 在进程退出的时候会自动断开但如果是长时间运行的脚本最好显式调用rtt.disconnect()。还有一个坑是 J-Link 的序列号冲突。有些便宜的 J-Link 克隆版序列号是重复的多板并发的时候会连错板子。用正版 J-Link 或者确保每块板子的序列号唯一。6. 我在实际使用中总结的几条经验rttsh 我从第一版写到现在迭代了大概半年踩的坑不算少。有几条经验我觉得值得单独拿出来说。第一条RTT 控制块的地址最好固定。让 J-Link 自动搜索虽然方便但搜索范围是整个 RAMRAM 大的芯片比如 STM32H7 有 1MB RAM搜索要好几秒。在固件里把控制块放到一个固定地址连接速度能快一个数量级。具体做法是在链接脚本里预留一段空间然后在SEGGER_RTT_Init()之前把控制块指针指过去。第二条日志落盘一定要 flush。我早期版本的脚本忘了 flush跑了一小时的测试程序崩溃日志全丢。后来改成每写一行就 flush虽然有点性能损失但数据安全多了。如果性能敏感可以每 100 行 flush 一次或者用setvbuf设置行缓冲。第三条CI 里的超时要设两层。一层是 rttsh 自己的--timeout一层是 CI 平台的timeout-minutes。rttsh 的超时是软超时会走正常的退出流程日志能落盘。CI 平台的超时是硬超时直接杀进程日志可能丢。所以 rttsh 的超时要设得比 CI 平台的短让 rttsh 有机会优雅退出。第四条Lua 脚本要模块化。我一开始把所有逻辑写在一个文件里后来测试用例多了改一个地方要翻几百行。现在我把连接、日志解析、断言这些公共逻辑抽成模块用require引入。Lua 的模块机制很简单一个.lua文件就是一个模块返回一个 table 就行。-- common.lua local M {} function M.connect_board(serial) rtt.connect({serialserial, deviceSTM32F407VG, speed4000}) end function M.assert_log(pattern, timeout) local ok rtt.wait(pattern, {timeouttimeout or 5000}) if not ok then error(断言失败: .. pattern) end end return M这样测试脚本就变得很薄只关注测试逻辑本身。最后分享一个小技巧rttsh 的--script参数支持从标准输入读取脚本。这意味着你可以用管道把脚本传进去不用先写文件。echo rtt.connect({deviceSTM32F407VG}); print(rtt.read()) | rttsh run --script -这个在快速验证的时候特别方便一行命令就能试一个想法。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →