尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

嵌入式调试自动化:基于J-Link SDK与Lua的RTT命令行工具rttsh设计与实现 1. 为什么我要自己写一个 RTT 命令行工具嵌入式调试这件事做过 STM32 或者 Nordic 系列开发的人应该都有体会J-Link 自带的 RTT Viewer 是个好东西实时性好、不占用串口、速度也够快但它是纯 GUI 的。GUI 在手动调试的时候没问题一旦你想把它塞进自动化流程里就立刻卡住了——你没法让一个窗口程序在 CI 里跑也没法让它在无人值守的板子老化测试里连续抓几个小时的数据然后自动落盘。我最初的痛点很具体手上有几块板子要做长时间运行验证需要每隔一段时间把 RTT 输出的日志导出来同时还要能往板子里发一些命令做交互。用 RTT Viewer 的话我得开着窗口、手动点保存、手动敲命令一天下来人直接废掉。后来也试过 JLinkRTTLogger 这个官方命令行工具它能做基础的日志落盘但交互能力几乎为零没法根据板子输出动态决定下一步发什么更别提批量跑测试用例了。所以就有了rttsh这个东西——一个支持脚本化的 J-Link RTT 命令行工具。核心思路是把 RTT 的读写能力封装成命令行接口再挂一个 Lua 脚本引擎上去让你可以用脚本描述读什么、判断什么、写什么、存哪里这一整套逻辑。它解决的就是把 RTT 调试从手动操作变成可编程流程这个问题。适合谁用做嵌入式固件测试的、需要批量验证板子的、想把 RTT 接进 CI 流水线的以及单纯嫌 GUI 麻烦想用终端搞定一切的开发者。下面我会把整个工具的设计思路、核心实现细节、实操流程和踩过的坑完整拆一遍。如果你也在做类似的板级自动化这篇应该能帮你省不少时间。2. 整体设计与方案选型拆解2.1 为什么是命令行 Lua 这个组合先说选型。市面上做 RTT 自动化的路子大概有这么几条一是直接用 SEGGER 官方的 SDKJLinkARM.dll / libjlinkarm.so自己封装二是调 JLinkRTTLogger 或 JLinkExe 的命令行做进程包装三是用 Python 的 pylink 库。我最终选了官方 SDK Lua 脚本层的组合理由有三条。第一SDK 是唯一能拿到完整 RTT 控制权的路径。JLinkRTTLogger 虽然简单但它是个黑盒你没法在读取的同时做条件判断和动态写入而 SDK 里的JLINK_RTTERMINAL_Read和JLINK_RTTERMINAL_Write是双向可控的这才是脚本化的基础。第二脚本层为什么用 Lua 而不是 Python。Python 当然更流行但嵌入式场景里 Lua 有个天然优势体积极小、嵌入成本低、启动快。rttsh 本身是个 C/C 写的命令行程序把 Lua 解释器嵌进去只需要链接一个几百 KB 的库整个二进制可以做到很小扔到任何一台测试机上都能直接跑不用装 Python 环境、不用管依赖冲突。而且 Lua 的 C API 极其干净和底层 SDK 对接非常顺。第三命令行接口的设计让它可以被任何东西调用——shell 脚本、Makefile、CI 的 job、甚至另一个程序。它不绑定任何特定的构建系统或测试框架这是通用性的关键。2.2 整体架构分层整个工具从下到上分四层我画不出图这里也不方便贴图用文字描述清楚底层J-Link SDK 封装层。负责连接 J-Link、配置目标器件、启动 RTT 控制块、读写缓冲区。这一层把 SDK 的 C 接口包成一组更安全的函数处理错误码、重连、缓冲区边界。中层RTT 会话管理层。管理 RTT 的上行target 到 host和下行host 到 target通道处理非阻塞读取、超时、数据缓冲。这一层是性能的关键读的时机和缓冲区大小直接决定会不会丢数据。上层Lua 脚本引擎层。暴露一组 Lua 函数给脚本调用比如rtt.read()、rtt.write()、rtt.sleep()、file.append()等脚本用这些原语组合出任意逻辑。顶层命令行入口。解析参数决定是进入交互模式、执行脚本文件、还是做一次性读写。这个分层的核心考量是关注点分离底层只管和硬件打交道中层只管数据流上层只管业务逻辑。这样换硬件比如从 J-Link 换到别的调试器只需要改底层脚本完全不用动。2.3 和官方工具的能力对比为了让你清楚 rttsh 的定位我列个表对比一下几个常见方案能力RTT ViewerJLinkRTTLoggerrttsh实时查看支持支持支持日志落盘手动支持支持可脚本控制下行写入手动输入不支持支持可脚本控制条件逻辑无无Lua 脚本任意逻辑CI 集成困难一般原生支持批量多板手动切换脚本包装原生多会话依赖GUI 环境官方包单个二进制从表里能看出来rttsh 不是要替代 RTT Viewer 做日常手动调试而是补上自动化这块空白。日常看波形、临时打印我还是用 Viewer但只要涉及跑一晚上跑一百块板接进流水线就上 rttsh。3. 核心细节解析与实操要点3.1 RTT 控制块与缓冲区机制要理解 rttsh 怎么工作得先搞清楚 RTT 本身的机制。RTT 的原理其实很朴素在目标芯片的 RAM 里放一块控制块Control Block这块内存里记录了上行和下行缓冲区的位置、大小、读写指针。host 端也就是 J-Link通过调试接口直接读写这块 RAM从而实现和目标程序的数据交换。控制块的结构大致是这样开头是一个 ID 字符串SEGGER RTT然后是若干个上行缓冲区描述符和下行缓冲区描述符每个描述符包含缓冲区指针、大小、当前写指针、当前读指针。目标程序用SEGGER_RTT_Write往缓冲区写数据host 端读走之后更新读指针反过来 host 端写下行缓冲区目标程序用SEGGER_RTT_Read读走。这里有个关键点RTT 默认是非阻塞的。如果 host 端读得不够快上行缓冲区写满了目标程序的写操作会直接丢弃数据或者按配置阻塞。所以 rttsh 里读取线程的优先级和缓冲区大小设置非常关键。我的做法是把读取做成一个独立的循环尽量高频地轮询把数据先搬到 host 端的内存队列里再由脚本层消费。这样即使脚本处理慢也不会因为缓冲区满而丢数据。提示RTT 缓冲区大小是在目标固件里通过SEGGER_RTT_ConfigUpBuffer配置的默认通常只有 1KB 左右。如果你要抓大量日志务必在固件里把上行缓冲区调大比如 4KB 或 8KB否则再快的 host 端也救不了。3.2 连接与器件配置的坑连接 J-Link 这一步看似简单实际上坑最多。rttsh 启动时需要知道几件事用哪个 J-Link如果同时插了多个、目标器件是什么、接口是 SWD 还是 JTAG、速度多少。器件型号这个参数特别重要。J-Link SDK 需要知道目标芯片的 RAM 布局才能正确访问 RTT 控制块。如果你不指定器件SDK 会用默认配置有时候能连上但读不到 RTT 数据因为控制块所在的 RAM 地址它不认识。我的做法是强制要求脚本或命令行传入器件型号比如STM32F407VG或nRF52840_xxAA然后 SDK 会自动加载对应的内存映射。速度设置也有讲究。SWD 速度设太高比如 4000kHz在某些板子上会不稳定尤其是走线长或者有干扰的时候设太低又影响 RTT 的吞吐。我一般默认用 1000kHz实测大部分场景够用遇到不稳定的板子降到 500kHz。# 典型启动命令 rttsh --device STM32F407VG --interface SWD --speed 1000 --script test.lua还有一个容易被忽略的点RTT 控制块的搜索。SDK 默认会在目标 RAM 里搜索SEGGER RTT这个 ID 字符串来定位控制块。如果目标程序还没初始化 RTT比如刚上电还没跑到初始化代码搜索就会失败。rttsh 里我加了重试逻辑连接后如果找不到控制块会等一段时间再试直到超时。这个在板子刚复位或者跑 bootloader 的场景下特别有用。3.3 Lua 脚本接口的设计脚本接口是整个工具的灵魂设计得好不好直接决定用起来顺不顺。我暴露给 Lua 的接口遵循一个原则原语要少而正交组合交给脚本。核心接口大概这么几个rtt.read(timeout_ms)读一行或一块数据带超时。返回字符串超时返回 nil。rtt.write(str)往目标写数据。rtt.flush()清空当前缓冲区。rtt.sleep(ms)等待让出 CPU。log.info(str)/log.error(str)输出到 host 端日志。file.open(path, mode)/file.write(f, str)/file.close(f)文件操作。有了这几个原语你就能写出各种逻辑。比如等板子打印 READY然后发一条命令把接下来 10 秒的输出存文件-- wait_ready.lua local f file.open(output.log, w) local deadline os.time() 30 -- 等 READY while os.time() deadline do local line rtt.read(1000) if line and string.find(line, READY) then log.info(board ready, sending command) rtt.write(start_test\n) break end end -- 抓 10 秒数据 local end_time os.time() 10 while os.time() end_time do local line rtt.read(500) if line then file.write(f, line) end end file.close(f)这段脚本虽然简单但已经覆盖了等待条件、触发动作、持续采集、落盘这个完整闭环。实际项目里你会写得更复杂比如解析输出判断测试通过与否、失败时重试、多块板并行等等。注意Lua 的os.time()精度只到秒做毫秒级定时不准。rttsh 里我额外暴露了一个rtt.now_ms()返回毫秒时间戳需要精确计时的时候用它。3.4 数据落盘与格式处理数据导出这块看起来只是写文件实际上要考虑的东西不少。首先是换行符处理RTT 传过来的数据是原始字节流目标程序用\n还是\r\n取决于固件怎么写。rttsh 默认按\n分行但提供了选项让你指定分隔符避免日志里混进一堆\r。其次是时间戳。做长时间测试的时候光有日志内容不够你还得知道每条是什么时候来的。rttsh 支持在每行前面加 host 端时间戳格式可以配置。这个时间戳是 host 收到数据的时间不是目标产生数据的时间两者有微小偏差但对大多数测试场景够用。如果你需要精确的目标时间那得在固件里自己打时间戳。第三是文件轮转。跑一晚上的测试日志可能几百 MB单个文件太大不好处理。rttsh 支持按大小或按时间轮转比如每 10MB 换一个文件或者每小时换一个。这个在脚本里通过配置项控制。-- 配置日志轮转 log.set_rotate({ max_size 10 * 1024 * 1024, -- 10MB max_files 20, -- 最多保留 20 个 prefix rtt_log_ })3.5 多会话与批量板子管理批量验证是 rttsh 的一个重要场景。如果你有 8 块板子同时测总不能开 8 个终端手动跑。rttsh 支持在一个进程里管理多个 RTT 会话每个会话绑定一个 J-Link通过序列号区分。J-Link 的序列号可以用JLinkExe或者官方工具查每块 J-Link 的序列号是唯一的。rttsh 启动时传入序列号列表就能把多个会话区分开。脚本里通过会话 ID 来操作对应的板子。-- 多板并行 local sessions rtt.open_multi({ { serial 12345678, device STM32F407VG }, { serial 87654321, device STM32F407VG }, }) for i, s in ipairs(sessions) do s:write(run_test\n) end -- 收集结果 for i, s in ipairs(sessions) do local result s:read_until(DONE, 60000) log.info(board .. i .. : .. result) end多会话的难点在于资源竞争。多个 J-Link 同时通过 USB 通信如果读取线程设计不好会出现某个会话饿死的情况。我的做法是每个会话一个独立的读取线程用互斥锁保护共享的 SDK 调用因为 SDK 本身不是线程安全的实测 8 块板并行没有明显问题。4. 实操过程与核心环节实现4.1 环境准备与编译先说环境。rttsh 依赖 J-Link SDK你需要从 SEGGER 官网下载对应平台的 SDK 包注意是 SDK不是普通的 J-Link 软件包两者不一样。SDK 里包含头文件和动态库编译时需要链接。Linux 下的编译流程大致是这样# 假设 SDK 解压在 /opt/jlink_sdk gcc -o rttsh main.c rtt_core.c lua_bind.c \ -I/opt/jlink_sdk/Include \ -L/opt/jlink_sdk/Lib \ -lJLinkARM \ -llua5.4 \ -lpthread -lmWindows 下用 MSVC 或者 MinGW 都行链接JLinkARM.lib。注意 Windows 上 SDK 的库是 32 位还是 64 位要和你的编译器匹配混用会链接失败。编译完之后运行前要确保 J-Link 的驱动库能被找到。Linux 下把 SDK 的 Lib 目录加到LD_LIBRARY_PATHWindows 下把 dll 放到可执行文件同目录或者系统 PATH 里。提示如果你用的是较新的 J-Link 硬件比如 J-Link V9 之后的型号务必用配套版本的新 SDK。老 SDK 可能识别不了新硬件报 the connected J-Link is defective 之类的错误其实不是硬件坏了是 SDK 版本太老。4.2 目标固件侧的 RTT 集成host 端工具再好目标固件里也得先把 RTT 跑起来。SEGGER 提供了 RTT 的源码SEGGER_RTT.c和SEGGER_RTT.h直接加到你的工程里就行。初始化的典型代码#include SEGGER_RTT.h void rtt_init(void) { SEGGER_RTT_Init(); // 配置上行缓冲区0 号通道4KB SEGGER_RTT_ConfigUpBuffer(0, RTTUP, NULL, 0, SEGGER_RTT_MODE_NO_BLOCK_SKIP); // 配置下行缓冲区 SEGGER_RTT_ConfigDownBuffer(0, RTTDOWN, NULL, 0, SEGGER_RTT_MODE_NO_BLOCK_SKIP); } // 打印 SEGGER_RTT_printf(0, READY\n);这里有个关键配置SEGGER_RTT_MODE_NO_BLOCK_SKIP。这个模式下如果缓冲区满了写操作直接跳过丢数据不会阻塞目标程序。对于实时性要求高的固件这个模式是必须的否则 RTT 写满会拖慢整个系统。但代价就是可能丢数据所以 host 端要读得够快。如果你能接受轻微阻塞用SEGGER_RTT_MODE_BLOCK_IF_FIFO_FULL这样不会丢数据但目标程序在缓冲区满时会卡住。这个要看你固件的实时性要求来权衡。4.3 一个完整的自动化测试脚本光说接口没意思我拿一个实际用过的测试脚本给你看。场景是板子上电后跑自检自检结果通过 RTT 打印脚本要抓取结果、判断通过与否、把日志存档、失败时重试。-- self_test.lua -- 板级自检自动化脚本 local MAX_RETRY 3 local TIMEOUT_MS 60000 local function run_once(attempt) log.info( attempt .. attempt .. ) local logfile file.open(selftest_ .. attempt .. .log, w) local start rtt.now_ms() local result nil -- 复位板子通过 J-Link 控制复位引脚 rtt.reset() rtt.sleep(500) while rtt.now_ms() - start TIMEOUT_MS do local line rtt.read(1000) if line then file.write(logfile, line) -- 判断结果 if string.find(line, SELFTEST PASS) then result pass break elseif string.find(line, SELFTEST FAIL) then result fail break end end end file.close(logfile) return result end -- 主流程 local final nil for i 1, MAX_RETRY do local r run_once(i) if r pass then final pass break elseif r fail then final fail break -- 明确失败就不重试了 end -- r nil 表示超时重试 log.error(attempt .. i .. timeout, retrying) end if final pass then log.info(SELFTEST PASSED) os.exit(0) else log.error(SELFTEST FAILED: .. tostring(final)) os.exit(1) end这个脚本有几个设计点值得说。第一超时和重试分开处理超时没收到任何结果会重试明确失败收到 FAIL不重试因为重试也没用。第二每次尝试单独存日志方便事后分析是哪次出的问题。第三退出码通过返回 0失败返回非 0这样 CI 能直接根据退出码判断。4.4 接入 CI 流水线把 rttsh 接进 CI 其实很简单因为它就是个命令行程序退出码就是结果。以常见的 CI 配置为例# 伪代码具体语法看你的 CI 平台 test_job: script: - rttsh --device STM32F407VG --serial $JLINK_SERIAL --script self_test.lua artifacts: paths: - *.log关键点在于J-Link 的序列号要作为环境变量传入因为 CI 机器上可能插了多个 J-Link不指定序列号会连错板子。另外日志文件要作为 artifact 保存失败的时候能下载下来分析。CI 环境还有个坑USB 权限。Linux 的 CI runner 如果是容器化的容器里默认访问不了 USB 设备需要把 J-Link 的 USB 设备映射进容器或者用 privileged 模式。这个因平台而异配置的时候注意一下。4.5 性能调优的几个参数rttsh 有几个参数直接影响性能我实测下来值得调的主要是这几个参数默认值建议范围影响读取缓冲区1KB4KB-16KB太小会频繁系统调用太大占内存轮询间隔1ms0.5ms-5ms太小费 CPU太大可能丢数据SWD 速度1000kHz500-4000kHz影响吞吐和稳定性目标缓冲区固件决定4KB决定能扛多大的突发流量轮询间隔这个参数特别微妙。RTT 的读取本质是 host 主动去读目标 RAM没有中断通知机制除非用 RTT 的 blocking 模式配合调试器的某些特性。所以 host 必须定期轮询。间隔太小CPU 占用高间隔太大目标缓冲区可能写满丢数据。1ms 是个比较平衡的值实测在 1KB 目标缓冲区、100KB/s 数据率下不会丢。5. 常见问题与排查技巧实录5.1 连不上或找不到 RTT 控制块这是最常见的问题表现是 rttsh 启动后报RTT control block not found或者一直读不到数据。排查顺序我总结成一张表现象可能原因排查方法完全连不上 J-LinkUSB 驱动/权限问题用官方工具测试连接连上但找不到控制块固件没初始化 RTT检查固件是否调用了 RTT 初始化找到控制块但读不到数据器件型号不对指定正确的 device 参数读到乱码波特率/编码问题检查固件输出编码偶尔丢数据缓冲区太小/读太慢调大缓冲区、减小轮询间隔器件型号不对这个坑我踩过好几次。有一次用 STM32F103 的板子我随手填了 STM32F407 的型号结果 J-Link 能连上、能找到控制块因为 RAM 地址碰巧对但读出来的数据全是乱的。后来改成正确型号就好了。所以器件型号一定要填对别偷懒。5.2 数据丢失与缓冲区溢出数据丢失是 RTT 自动化的头号敌人。原因无非两个目标缓冲区太小或者 host 读得太慢。判断是不是缓冲区问题有个简单方法在固件里加一个计数器每次SEGGER_RTT_Write返回的值如果小于要写的长度说明发生了丢弃把丢弃次数打印出来。如果这个数字在涨那就是缓冲区不够。解决办法按优先级排第一调大目标缓冲区这是最直接的第二提高 host 读取频率减小轮询间隔第三降低目标输出速率比如合并日志、减少打印。三个办法可以组合用。提示RTT 的上行缓冲区是在 RAM 里静态分配的调大意味着占用更多 RAM。如果你的芯片 RAM 紧张可以用多个小缓冲区轮转或者只在关键路径上开 RTT。5.3 脚本执行卡死或超时Lua 脚本卡死通常是因为rtt.read()没有超时或者循环里没有让出 CPU。rttsh 的rtt.read()是带超时参数的一定要用别写成死等。另外长时间运行的脚本要定期rtt.sleep()否则会占满 CPU 影响读取线程。还有一种卡死是目标程序挂了。板子跑飞了、HardFault 了RTT 自然就没输出了。这时候脚本的超时机制就派上用场了超时后应该能优雅退出而不是无限等。我在脚本模板里都会加一个全局超时防止单个用例卡死拖垮整个测试。5.4 多板场景下的资源冲突多块板子并行的时候最容易出问题的是J-Link 序列号搞混。如果脚本里没指定序列号SDK 会连第一个找到的 J-Link结果所有会话都连到同一块板子上数据全乱。解决办法是强制指定序列号并且在脚本启动时校验每个序列号对应的板子是否在线。rttsh 提供了rtt.list_devices()接口返回当前连接的所有 J-Link 序列号脚本可以先检查再连接。另一个冲突是USB 带宽。多个 J-Link 同时高速通信USB 总线可能成为瓶颈。实测 4 块板并行问题不大8 块以上就要注意了可能需要错开读取时机或者降低单板速率。5.5 跨平台兼容性问题rttsh 在 Linux、Windows、macOS 上都能跑但每个平台都有各自的坑。Linux 下主要是 USB 权限和库路径Windows 下是 dll 版本和路径分隔符macOS 下是签名和权限。Windows 上有个特别烦的问题路径分隔符。Lua 脚本里写文件路径如果用/在 Windows 上大部分情况能用但某些 API 会出问题。我的做法是在脚本里统一用/rttsh 内部做转换。另外 Windows 的文件锁机制和 Linux 不同如果日志文件被别的程序打开着写入会失败这个要注意。macOS 上从某个版本开始对 USB 设备访问有额外限制可能需要给终端或者可执行文件授权。这个具体看系统版本遇到问题查一下系统日志基本能定位。6. 一些实操心得和扩展思路6.1 脚本模板化能省大量时间用久了你会发现大部分测试脚本的骨架是一样的连接、等待、采集、判断、落盘、退出。我后来把这些抽成一个公共库每个具体测试只写差异部分。比如一个common.lua提供wait_for(pattern, timeout)、collect(seconds)、assert_pass()这些函数具体测试脚本就变得很短。-- common.lua local M {} function M.wait_for(pattern, timeout_ms) local start rtt.now_ms() while rtt.now_ms() - start timeout_ms do local line rtt.read(500) if line and string.find(line, pattern) then return line end end return nil end function M.collect(seconds, filepath) local f file.open(filepath, w) local end_time rtt.now_ms() seconds * 1000 while rtt.now_ms() end_time do local line rtt.read(500) if line then file.write(f, line) end end file.close(f) end return M这样具体测试脚本可能就十几行维护起来轻松很多。6.2 把 RTT 数据接到其他系统rttsh 的输出不一定非要落文件。因为它是命令行的你可以把它的 stdout 管道给别的程序。比如实时把 RTT 数据喂给一个 Python 脚本做可视化或者喂给一个数据库做存储。rttsh --device STM32F407VG --script stream.lua | python3 visualize.py这种组合的灵活性很高rttsh 只负责把数据从板子里弄出来怎么处理交给下游。这也是我坚持做成命令行工具而不是 GUI 的原因——Unix 哲学里的做好一件事管道组合出无限可能。6.3 后续可以扩展的方向这个工具目前够我用但还有几个方向可以继续做。一是支持更多调试器现在只支持 J-Link理论上 RTT 是 SEGGER 的专利但有些第三方调试器也兼容 RTT 协议可以抽象一层接口出来。二是加一个简单的 Web 界面方便不熟悉命令行的同事用。三是和测试框架深度集成比如直接输出 JUnit XML 格式的测试报告这样 CI 平台能直接解析。不过这些都是锦上添花核心的命令行 脚本 RTT这套组合已经解决了 90% 的问题。工具这东西够用就好别过度设计。我在实际使用中最大的体会是自动化的价值不在于省那点手动操作的时间而在于让测试变得可重复、可追溯。手动测的时候你今天怎么点的、明天可能就忘了脚本化的测试每次跑的逻辑完全一致出了问题有日志可查这才是真正的价值。rttsh 只是实现这个目标的一个手段你也可以用别的工具达到类似效果关键是思路要对。
返回列表