news 2026/10/2 11:22:34

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
告别GUI:用Lua脚本打造J-Link RTT命令行自动化调试工具

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 解释器编译出来才两百多 KB,API 干净,和 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({timeout=1000})会等待最多 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({timeout=5000}) if line:find("BOOT OK") then print("启动成功") end

这里有个细节:RTT 的缓冲区是环形的,如果读取速度跟不上写入速度,旧数据会被覆盖。rttsh 在读取的时候会尽量一次性把缓冲区里的数据全部取走,但如果你在脚本里做了耗时的操作(比如写文件、发网络请求),就可能丢数据。我的建议是:读取和落盘分开,用一个单独的线程或者协程专门读,读到之后丢进队列,另一个协程慢慢处理。

3.3 数据写入:命令下发与交互式调试

RTT 不只是单向的日志输出,它也是双向的。你可以通过 RTT 向目标发送数据,这在做交互式调试的时候特别有用。

rtt.write("help\n") local response = rtt.read({timeout=1000}) print(response)

这个能力让 rttsh 可以模拟一个串口终端。很多固件里都有 CLI 接口,通过 RTT 下发命令、读取响应,就能做自动化的功能验证。

写入的时候要注意换行符。Windows 风格的固件可能期望\r\n,Linux 风格的可能只认\n。我一般会在脚本里统一用\n,如果固件不认,再改成\r\n。

还有一个坑是写入速度。RTT 的上行缓冲区通常比下行缓冲区小,如果你连续快速写入大量数据,可能会阻塞。rttsh 在写入的时候会检查缓冲区剩余空间,空间不够就等待。你可以在脚本里用rtt.write(data, {timeout=1000})设置写入超时。

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({timeout=1000}) 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, interface
rtt.read(opts)读取数据timeout
rtt.readline(opts)读取一行timeout
rtt.write(data, opts)写入数据timeout
rtt.wait(pattern, opts)等待匹配timeout, interval
rtt.disconnect()断开连接无
rtt.log(msg)输出日志无

rtt.wait是我用得最多的一个。它的逻辑是:每隔interval毫秒读一次,直到读到的内容匹配pattern(支持 Lua 模式匹配),或者超时。

local ok = rtt.wait("System Ready", {timeout=10000, interval=100}) if not ok then error("系统启动超时") end

这个函数把"轮询 + 匹配 + 超时"三件事封装在一起,脚本里写起来非常干净。

4.2 单板调试脚本示例

先看一个最简单的单板调试脚本。假设固件启动后会打印版本号,然后进入 CLI,我们要验证版本号是否正确,然后执行一条命令。

-- single_board_test.lua rtt.connect({device="STM32F407VG", speed=4000, interface="SWD"}) -- 等启动日志 local version_line = rtt.wait("FW Version: (.-)\n", {timeout=5000}) if not version_line then error("没等到版本号") end print("固件版本: " .. version_line) -- 下发命令 rtt.write("status\n") local status = rtt.wait("STATUS: (.-)\n", {timeout=2000}) 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 = { {serial="12345678", name="board-01"}, {serial="12345679", name="board-02"}, {serial="12345680", name="board-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-artifact@v3 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", {timeout=3000}) 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 自动搜索虽然方便,但搜索范围是整个 RAM,RAM 大的芯片(比如 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({serial=serial, device="STM32F407VG", speed=4000}) end function M.assert_log(pattern, timeout) local ok = rtt.wait(pattern, {timeout=timeout or 5000}) if not ok then error("断言失败: " .. pattern) end end return M

这样测试脚本就变得很薄,只关注测试逻辑本身。

最后分享一个小技巧:rttsh 的--script参数支持从标准输入读取脚本。这意味着你可以用管道把脚本传进去,不用先写文件。

echo 'rtt.connect({device="STM32F407VG"}); print(rtt.read())' | rttsh run --script -

这个在快速验证的时候特别方便,一行命令就能试一个想法。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 11:21:57

PyCharm 中文配置指南:解释器、venv、镜像源与远程开发实战

简介:这是一份系统讲解 PyCharm 使用技巧的中文电子手册,整理自资深云计算博主的实战总结,面向 Python 初学者和希望提升 IDE 效率的中级开发者。内容从版本选择与下载安装起步,依次讲解社区版、专业版、教育版的功能差异&#xf…

作者头像 李华
网站建设 2026/10/2 11:21:17

穿越系统迷雾:揭秘 Cursor 提示词的奥秘与 TaoToken 统一 Key 配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 11:20:57

Promise执行机制全面解析:状态、微任务与并发控制

关于Promise的执行机制,面试考得最多,但实际开发里真正弄明白的人并不多。很多前端拿得出手“三种状态”“宏任务微任务”这些词,真到了排查问题时,却连 uncaught (in promise) 的报错从哪冒出来的都说不清楚。 这篇文章我准备…

作者头像 李华
网站建设 2026/10/2 11:20:56

小鼠单细胞代谢分析源码实战:从表达矩阵到代谢通路打分与可视化

简介:这份源码资源面向从事单细胞转录组与代谢研究的科研人员及生物信息学初学者,围绕scMetabolism包解决小鼠单细胞代谢激活分数分析问题,重点处理小鼠基因名向人类基因名的转换,并适配Seurat v4与v5版本,帮助读者在R…

作者头像 李华
网站建设 2026/10/2 11:19:08

小米MiMo-V2.6开源模型:MoE架构与SGLang推理部署实战

1. 小米 MiMo-V2.6 到底更新了什么 小米这次把 MiMo-V2.6 端出来,最抓眼球的信息其实就两条:一是 Pro 和 Flash 两个版本价格没动,二是它在 AA 指数上把 Kimi K3、GLM-5.3 都压了下去,成了当前排名最高的开源模型。我第一时间去翻…

作者头像 李华