2026/10/2 21:15:32

嵌入式调试自动化:基于J-Link RTT与Lua的命令行工具rttsh

嵌入式调试自动化:基于J-Link RTT与Lua的命令行工具rttsh 1. 为什么我要自己写一个 RTT 命令行工具嵌入式调试这件事干过的都懂。J-Link 自带的 RTT Viewer 是个 GUI 工具日常看看日志还行但一旦碰上需要批量跑测试、自动化验证、CI 流水线里抓板子输出这些场景GUI 就成了最大的绊脚石。你没法在脚本里点按钮也没法在服务器上开图形界面。我手上的项目经常需要同时管好几块板子每块板子跑不同的固件测试用例动辄几十上百条。最开始的做法是用 J-Link 的 RTT Viewer 手动看后来换成 JLinkRTTClient但它本质上还是个交互式终端想批量处理数据得靠重定向加文本解析脆弱得很。再后来试过用 J-Link SDK 自己写 C 程序调 RTT 接口功能是能实现但每换一个测试场景就得重新编译维护成本太高。于是就有了rttsh这个东西——一个支持脚本化的 J-Link RTT 命令行工具。核心思路很简单把 RTT 的读写能力封装成命令行接口再嵌一个 Lua 脚本引擎让用户用脚本描述“什么时候读、读什么、怎么处理、写到哪”。这样既保留了命令行的灵活性又有了脚本的可编程性。它适合什么人用如果你在做嵌入式开发需要自动化调试、批量验证固件行为、把 RTT 数据导出成结构化文件、或者想把板子测试塞进 CI 流程那这个工具就是给你准备的。哪怕你只是嫌 RTT Viewer 太笨重想找个轻量替代品它也能胜任。下面我从设计思路开始把整个工具的来龙去脉、核心实现、实操细节和踩过的坑都摊开讲一遍。2. 整体设计与方案选型2.1 为什么是命令行加 Lua而不是纯命令行或纯 GUI先说说为什么不做成纯命令行。纯命令行的好处是简单rttsh read --channel 0 --size 1024这种形式谁都会用。但实际调试场景里需求往往不是“读一次”这么简单。比如先发一个命令到目标板等 500ms再读通道 0 的输出匹配到某个关键字后把后续 2KB 数据存文件同时监控两个通道一个收日志一个收二进制数据分别做不同处理循环执行某个测试用例 100 次每次记录时间戳和返回码最后生成报告。这些逻辑用纯命令行参数表达会变得极其臃肿甚至根本表达不了。而 Lua 恰好补上了这块短板它轻量、嵌入成本低、语法简单嵌入式工程师就算没写过 Lua看半小时也能上手。更重要的是Lua 和 C 的互操作非常成熟把 RTT 的读写函数注册成 Lua 的全局函数脚本里直接调用就行。那为什么不做 GUI因为 GUI 在自动化和远程场景下是死路。CI 服务器没有显示器批量测试需要无人值守GUI 的交互模型天然不适合这些场景。命令行加脚本的组合才是自动化领域的通用语言。2.2 底层通信层直接调 J-Link SDK 还是走 JLinkRTTClientJ-Link 的 RTT 功能有两种接入方式。一种是启动 JLinkRTTClient 进程通过标准输入输出跟它交互另一种是直接链接 J-Link SDK 的库调用JLINK_RTTERMINAL_Read和JLINK_RTTERMINAL_Write这些 API。我选的是后者直接调 SDK。原因有三点。第一进程间通信有延迟和缓冲问题JLinkRTTClient 的输出经过管道后可能被缓冲导致实时性下降而直接调 API 是内存级操作延迟低得多。第二JLinkRTTClient 的输出格式是给人看的带各种控制字符和提示信息解析起来很麻烦直接调 API 拿到的是原始字节流干净。第三SDK 方式可以精确控制连接和断开时机不会出现客户端进程残留导致下次连接失败的问题。当然直接调 SDK 也有代价需要处理 J-Link 库的加载、设备连接、RTT 控制块的地址查找这些底层细节。但这些都是一次性工作封装好之后上层就清爽了。2.3 脚本引擎选型为什么是 Lua 而不是 Python 或 JavaScript嵌入式领域里Python 当然更流行但嵌入 Python 解释器到 C 程序里体积和依赖都是问题。一个完整的 CPython 运行时动辄几十 MB交叉编译到嵌入式环境更是噩梦。JavaScript 引擎比如 QuickJS虽然轻量但嵌入式工程师对 JS 的熟悉程度普遍不如 Lua。Lua 的优势在于核心库编译出来只有几百 KB纯 C 实现没有外部依赖嵌入到 C 程序里就是加几个源文件的事。而且 Lua 的 C API 设计得非常干净注册一个 C 函数给 Lua 调用只需要几行代码。对于“给调试工具加脚本能力”这个需求来说Lua 是性价比最高的选择。2.4 命令结构设计子命令加脚本文件rttsh 的命令行接口设计成子命令模式rttsh connect --device STM32F407VG --interface SWD rttsh read --channel 0 --size 4096 --output log.bin rttsh write --channel 1 --data ATTEST\r\n rttsh script --file test_flow.lua rttsh export --format csv --output result.csv每个子命令做一件事组合起来完成复杂流程。而script子命令则是把整个 Lua 脚本作为输入脚本里可以调用所有底层能力。这种设计的好处是简单场景用子命令复杂场景用脚本两者共享同一套底层实现不会出现功能不一致。3. 核心细节解析与实操要点3.1 RTT 控制块地址的自动查找RTT 工作的前提是目标板内存里有一块 RTT 控制块Control BlockJ-Link 通过扫描目标内存找到这个块然后读写其中的缓冲区。手动指定控制块地址很麻烦因为每次固件重新编译地址都可能变。rttsh 的做法是自动搜索。J-Link SDK 提供了JLINK_RTTERMINAL_Control相关的接口但更稳妥的方式是让 J-Link 自己去找。在连接设备后调用 SDK 的 RTT 启动函数它会自动扫描已知内存区域寻找 RTT 控制块的签名。如果找不到再回退到手动指定地址的模式。这里有个实操要点自动搜索需要目标板已经初始化了 RTT 控制块也就是说固件里得先调用SEGGER_RTT_Init()。如果固件还没跑到那一步搜索会失败。所以脚本里通常要先等一段时间或者通过读某个变量判断固件是否就绪。3.2 通道读写与缓冲区管理RTT 支持多个上行通道目标到主机和下行通道主机到目标。每个通道有独立的缓冲区。rttsh 在读取时需要处理缓冲区满、数据被覆盖的情况。J-Link SDK 的JLINK_RTTERMINAL_Read函数返回实际读到的字节数。如果返回 0说明缓冲区里没数据如果返回的值等于请求的大小说明可能还有更多数据没读完需要继续读。这里有个坑如果读取速度跟不上目标板的写入速度缓冲区会溢出旧数据被覆盖导致日志丢失。我的处理方式是在脚本里用循环加短延时的方式持续读取而不是读一次就完事。比如while running do local data rtt.read(0, 4096) if #data 0 then handle(data) end rtt.sleep(10) -- 毫秒 end这个 10ms 的延时是经验值。太小了会占满 CPU太大了可能丢数据。具体值要根据目标板的写入频率调整。如果目标板每秒写几十 KB10ms 读一次 4KB 基本不会丢。3.3 Lua 脚本引擎的嵌入与 API 设计把 Lua 嵌入到 C 程序里核心工作是注册 C 函数。rttsh 暴露给 Lua 的 API 大致分几类连接管理rtt.connect(device, interface)、rtt.disconnect()通道读写rtt.read(channel, size)、rtt.write(channel, data)时间控制rtt.sleep(ms)、rtt.timestamp()文件操作rtt.file.open(path, mode)、file:write(data)、file:close()字符串处理直接复用 Lua 内置的 string 库这些 API 的设计原则是“薄封装”。C 层只做最基础的操作复杂的逻辑留给 Lua 脚本。比如rtt.read就是调一次 SDK 的读函数返回一个 Lua 字符串至于怎么解析这个字符串、要不要循环读、读到什么条件停全由脚本决定。这样做的好处是灵活性最大化。不同项目的调试需求千差万别工具不可能预判所有场景把控制权交给脚本是最务实的做法。3.4 数据导出格式的设计调试数据导出是刚需。rttsh 支持几种格式格式适用场景特点原始二进制二进制协议分析无损但需要额外工具解析文本日志查看直接可读但二进制数据会乱码CSV结构化数据分析方便导入 Excel 或 pandasJSON程序化处理结构化好但体积较大导出时的一个关键问题是时间戳。RTT 数据本身不带时间信息时间戳需要主机侧在读取时打上。rttsh 在每次读到数据时记录当前时间导出时把时间戳和数据一起写入。这样后续分析时就能知道每条数据是什么时候产生的。注意主机时间戳和目标板时间戳可能不一致。如果目标板自己也在数据里带了时间信息以目标板的为准主机时间戳只作为参考。4. 实操过程与核心环节实现4.1 环境准备与编译rttsh 依赖 J-Link SDK。在 Windows 上SDK 通常安装在C:\Program Files\SEGGER\JLink目录下需要把JLinkARM.dll所在路径加入系统 PATH或者在编译时指定库路径。Linux 下对应的是libjlinkarm.so。编译过程用 CMake 管理mkdir build cd build cmake .. -DJLINK_SDK_PATH/path/to/jlink/sdk make -j4编译产物是一个可执行文件rttsh以及一个可选的 Lua 脚本库目录。如果想把 Lua 脚本打包进可执行文件可以在 CMake 里加一个选项把脚本目录编译成资源嵌入。实操心得J-Link SDK 的版本要和 J-Link 驱动版本匹配。我遇到过 SDK 是 V7 但驱动是 V6 的情况编译能过但运行时连接失败。建议 SDK 和驱动用同一个大版本。4.2 连接目标板与 RTT 初始化连接目标板是第一步。rttsh 的连接命令需要指定设备型号和接口类型rttsh connect --device STM32F407VG --interface SWD --speed 4000--speed是 SWD 时钟频率单位 kHz。4000 就是 4MHz。这个值不是越大越好要看目标板的支持能力。太快了可能导致连接不稳定太慢了读取速度跟不上。一般 4MHz 是个比较稳妥的起点如果目标板支持高速模式可以往上调。连接成功后rttsh 会尝试启动 RTT。如果目标板的固件已经初始化了 RTT 控制块这一步会成功否则会报错。这时候可以加--wait-rtt参数让工具轮询等待直到 RTT 控制块出现。rttsh connect --device STM32F407VG --interface SWD --wait-rtt --timeout 5000--timeout 5000表示最多等 5 秒。这个超时时间要根据固件启动时间来定。如果固件启动很慢比如要等外部晶振稳定可能需要设到 10 秒以上。4.3 用 Lua 脚本描述一个完整的测试流程假设我们要测试一个无线模块的 AT 命令响应。流程是通过 RTT 下行通道发送 AT 命令然后读上行通道检查是否返回 OK记录响应时间。对应的 Lua 脚本大概长这样-- test_at.lua local rtt require(rtt) rtt.connect(STM32F407VG, SWD, 4000) rtt.wait_rtt(5000) local log rtt.file.open(at_test.log, w) local commands {AT, ATVER?, ATMAC?, ATRESET} for _, cmd in ipairs(commands) do local start rtt.timestamp() rtt.write(1, cmd .. \r\n) local response local deadline start 2000 -- 2秒超时 while rtt.timestamp() deadline do local data rtt.read(0, 1024) if #data 0 then response response .. data if response:find(OK) or response:find(ERROR) then break end end rtt.sleep(10) end local elapsed rtt.timestamp() - start log:write(string.format([%d] %s - %s (%dms)\n, start, cmd, response:gsub(\r\n, |), elapsed)) end log:close() rtt.disconnect()这个脚本展示了几个关键点。第一rtt.timestamp()返回毫秒级时间戳用来计算响应时间。第二读循环里用response:find检查是否收到终止标志避免死等。第三日志里把换行符替换成|方便单行查看。运行脚本rttsh script --file test_at.lua4.4 批量验证与 CI 集成CI 集成的核心需求是无人值守、结果可判定、失败有明确输出。rttsh 在脚本模式下如果 Lua 脚本执行出错会返回非零退出码。CI 系统根据退出码判断成功失败。一个典型的 CI 步骤- name: Run RTT test run: | rttsh script --file ci_test.lua timeout-minutes: 5脚本里可以用assert来做断言local resp send_command(ATVER?) assert(resp:find(V1%.2%.3), 版本号不匹配: .. resp)如果断言失败Lua 会抛出错误rttsh 捕获后返回非零退出码CI 标记为失败。实操心得CI 环境里 J-Link 的 USB 设备可能被其他进程占用。建议在 CI 脚本开头加一个清理步骤确保没有残留的 JLink 进程。另外CI 机器上最好固定 J-Link 的序列号避免多设备时连错板子。4.5 数据导出与后处理调试完成后往往需要把数据导出做进一步分析。rttsh 的export子命令支持从已保存的原始数据文件转换格式rttsh export --input raw.bin --format csv --output result.csv --timestamp--timestamp选项会在 CSV 里加一列时间戳。如果原始数据里已经包含了目标板的时间信息可以用--parse-timestamp让工具尝试从数据里提取。导出的 CSV 可以直接用 pandas 分析import pandas as pd df pd.read_csv(result.csv) df[delta] df[timestamp].diff() print(df[delta].describe())这样就能快速看出响应时间的分布找出异常值。5. 常见问题与排查技巧实录5.1 连接失败与设备识别问题最常见的问题是连不上目标板。表现是rttsh connect报错提示找不到设备或连接超时。排查顺序如下确认 J-Link 驱动已安装设备管理器里能看到 J-Link 设备。确认目标板供电正常SWD 线连接牢固。确认设备型号选对了。STM32F407VG 和 STM32F407ZE 的 Flash 大小不同但 RTT 连接通常不受影响不过保险起见还是选对。如果用的是 SWD 接口确认目标板的 SWD 引脚没有被复用为其他功能。注意有些目标板在低功耗模式下会关闭 SWD 接口。如果固件里进了 STOP 或 STANDBY 模式J-Link 就连不上。这时候需要先让目标板复位在固件进入低功耗之前完成连接。5.2 RTT 控制块找不到连接成功但 RTT 启动失败提示找不到控制块。原因通常是固件里没有初始化 RTT或者初始化了但控制块地址不在 J-Link 的搜索范围内。解决方法在固件里确认调用了SEGGER_RTT_Init()并且_SEGGER_RTT这个符号没有被链接器优化掉。如果用的是自定义链接脚本确认 RTT 控制块所在的段没有被放到 J-Link 搜索不到的区域。如果自动搜索实在找不到可以手动指定地址rttsh connect --device STM32F407VG --rtt-address 0x20000000地址可以从固件的 map 文件里查_SEGGER_RTT符号的地址。5.3 数据丢失与缓冲区溢出读到的数据不完整日志里有明显的断档。这是缓冲区溢出的典型表现。RTT 的上行缓冲区大小在固件里定义默认可能是 1KB 或 2KB。如果目标板写入速度超过主机读取速度缓冲区满了之后新数据会覆盖旧数据。解决办法有两个方向。一是提高读取频率把脚本里的rtt.sleep调小比如从 10ms 降到 1ms。二是增大缓冲区在固件里把BUFFER_SIZE_UP改大比如改成 8KB 或 16KB。两者结合效果最好。实操心得如果目标板写入是突发性的比如一次写几 KB 然后停一会儿那把缓冲区设大比提高读取频率更有效。因为突发写入时即使 1ms 读一次也可能来不及而大缓冲区可以吸收突发。5.4 Lua 脚本报错与调试Lua 脚本出错时rttsh 会打印错误信息和堆栈。但有时候错误信息不够直观比如“attempt to index a nil value”不知道是哪个变量为 nil。调试技巧在脚本里多用print输出中间状态。rttsh 会把 Lua 的print输出到标准错误不会和 RTT 数据混在一起。另外可以用pcall包裹可能出错的代码local ok, err pcall(function() -- 可能出错的代码 end) if not ok then print(Error: .. err) end这样即使出错也不会中断整个脚本可以继续执行后续清理逻辑。5.5 常见问题速查表现象可能原因解决方法连接超时驱动未装、线缆松动、目标板未供电检查驱动和设备管理器重新插拔线缆RTT 启动失败固件未初始化 RTT确认固件调用 SEGGER_RTT_Init或手动指定地址数据断档缓冲区溢出增大缓冲区提高读取频率脚本报 nil 错误变量未初始化或 API 返回空加 print 调试用 pcall 包裹CI 中连接失败USB 设备被占用清理残留进程固定设备序列号导出 CSV 乱码二进制数据混入文本用原始二进制格式导出或过滤非打印字符6. 一些扩展思路和实际使用体会rttsh 目前的功能覆盖了大部分日常调试需求但还有一些方向可以继续挖。比如加一个--record模式把整个调试会话的 RTT 数据和时间戳连续记录到文件事后可以像看录像一样回放。再比如支持多个 J-Link 同时连接做多板并行测试。Lua 脚本这边也可以加一些常用的辅助库比如协议解析、数据校验、报告生成减少重复代码。我在实际使用中最大的体会是工具的价值不在于功能多全而在于是否贴合真实工作流。rttsh 的 Lua 脚本能力看起来简单但正是这种简单让它能适应各种奇怪的调试场景。我遇到过需要根据 RTT 输出动态调整测试参数的场景用 GUI 工具根本没法做用 rttsh 写几行 Lua 就搞定了。另外一个小技巧把常用的 Lua 脚本片段存成模板比如“发命令等响应”“循环读直到匹配”“导出带时间戳的日志”下次直接复制粘贴改改就行。这样积累下来调试效率会有明显提升。