☰
基于QT的RTT上位机开发:ARM单片机调试工具的自制实践
2026/10/13 3:25:17 网站建设 项目流程

简介:这是一套面向嵌入式开发初学者与进阶者的RTT(Real-Time-Thread)调试辅助工具,专为ARM架构单片机调试场景设计,适用于电子信息、自动化、物联网等专业学生的课程设计、毕业设计及工程实践。资源基于Qt跨平台框架开发,包含完整上位机源码、编译可执行文件(exe)、UI界面定义(ui)、核心通信模块(cpp/h)、配置文件(ini)及图标资源(ico/png/jpg),共40个文件,总大小仅190KB,结构清晰、轻量易部署。项目已通过导师评审并获95分高分,所有代码均经实测运行稳定,配套详细文档涵盖环境搭建、串口通信协议解析、RTT对象交互逻辑与界面控件绑定说明。学习者可直接用于调试演示,亦可基于现有comboboxex、seggerop等模块快速扩展功能,是理解嵌入式系统上下位机协同开发的优质实践范例。 搞嵌入式调试这么多年,我越来越觉得手里的家伙什得趁手。串口打印调试老项目还好,遇到高频率日志、时序敏感的数据流,串口那点带宽和阻塞问题真是让人头疼。后来用了SEGGER的RTT,确实爽,但官方RTT Viewer用着总觉得有点“不够用”,界面丑是一回事,关键是没法按自己项目需求深度定制,想自动解析些协议数据、嵌套到自己的工具链里,麻烦得很。所以我就干脆自己动手,基于QT写了一个RTT上位机,专供ARM单片机调试用。现在把全部资料、详细文档和源码整理成一个包,分享出来。这篇文章就把这个项目的来龙去脉、核心原理、实现细节和踩坑过程都捋一遍,希望能给同样在折腾RTT和QT上位机的朋友一点参考。

注意:项目本身完全开源可复现,核心目标就一个字——“快”。快速定位数据、快速显示、快速部署。这套上位机已经搭载过STM32、NXP i.MX RT系列,以及国产GD32系列实测,跑起来很稳。

1. 为什么不用官方RTT Viewer,非要自己写一个

刚开始用RTT的时候,我也觉得官方Viewer挺好,至少不用自己写协议。但用了一段时间,痛点越来越明显:

  • 官方RTT Viewer的界面和交互比较固定,多通道日志显示不够直观,尤其是同时调试多个模块时,颜色、过滤、自动保存这些功能用着别扭;
  • 我需要在项目里加入自定义的二进制协议解析,比如把传感器数据、电机速度、PID的实时参数直接解码成曲线。官方工具没有插件机制,做不了这事儿;
  • 调试过程中经常要和自己的测试脚本、自动化流程联动,需要提供命令行级或者接口级的调用方式,官方工具做不到;
  • 还有就是我想把上位机集成到团队内部的工具平台上,给不同学历、不同经验的小伙伴统一使用,界面和操作习惯得按我们自己的文档来。

所以决定自己写一个“够用、灵活、可控”的RTT上位机。技术上选型,第一反应是QT。为什么是QT别的不用?因为QT是C++跨平台,Windows下跑得顺,后面换Linux、Mac都不用重写核心逻辑;而且信号槽机制天然适合做UI刷新的实时数据流;再加上QSerialPort、QTcpSocket这类现成模块,将来扩展蓝牙、网络转发都很顺手。我用的是QT 5.12.9版本,搭配MinGW 64位编译器,工程文件也兼容VS2015/VS2017,具体版本都可以换,核心代码不依赖某个特定QT版本。

这个项目不像网上那些零散的demo,不是简单调一下DLL读个地址就完事,而是完整实现了RTT协议栈的解析、环形缓冲区处理、多通道管理、自动重连、日志保存这些生产级功能。整个包里有源码、详细设计文档、使用手册、测试固件示例,到手就能编译运行。

2. RTT上位机必须懂的核心原理

写这个上位机之前,一定要把RTT的底层机制弄清楚,不然代码写到一半容易懵。

2.1 RTT控制块在RAM中的布局

RTT全称Real Time Transfer,是SEGGER J-Link调试器内置的一种通信方式。它不依赖串口,直接在目标MCU的内存里开辟一段缓冲区,J-Link通过SWD/JTAG接口访问这段RAM,实现PC和MCU之间的双向传输。由于SWD频高、不用占额外引脚,带宽和实时性都远好于串口。

MCU端需要运行SEGGER RTT库(典型文件是SEGGER_RTT.c和SEGGER_RTT.h),会在RAM中初始化一个全局控制块,也就是SEGGER_RTT_CB结构体。这个结构体最关键的是开头的16字节ID字段,固定内容为“SEGGER RTT”字符串,后面会跟上缓冲区的描述信息。结构体在内存中的大致排布是:

  • 前16字节:ID标识,用来让上位机扫描定位;
  • 接着是最大上行缓冲数、最大下行缓冲数;
  • 然后是上行缓冲数组(每个成员包含缓冲名称、缓冲区指针、缓冲区大小、写偏移、读偏移、标志位);
  • 再下来是下行缓冲数组,结构类似。

理解这个布局非常关键,因为上位机要做的第一件事就是在目标MCU的RAM中扫描这段ID字符串,找到控制块首地址,然后才能解析出各个通道的缓冲区信息。如果RAM里扫不到“SEGGER RTT”,大概率是目标固件没有调用RTT初始化函数,或者扫描范围没设置对。

2.2 环形缓冲区的读写机制

RTT的数据缓冲区本质是个环形缓冲。拿上行通道(MCU发送、PC接收)来说,MCU往缓冲区写数据会移动写偏移WrOff,PC上位机读完数据会移动读偏移RdOff。上位机需要根据这两个偏移自己判断有多少新数据可读,还要处理写指针回绕的情况。

举个例子,假设缓冲区大小是1024字节,如果WrOff<RdOff,说明写指针已经绕过了一圈,有效数据分成了两段:一段从RdOff到缓冲区末尾,另一段从缓冲区开头到WrOff。我实现时做了一个专门的处理函数,一次读不完就分两次读,用JLINKARM_ReadMem分别读取两段,再拼接到QByteArray中。这个处理必须严谨,不然一旦出现回绕,日志就会错行、丢帧甚至花屏。

下行通道(PC发送、MCU接收)的写过程类似,也是把数据写到环形的下行缓冲区,然后更新写偏移。由于MCU和PC的内存对齐和大小端可能不一致,读取结构体时我统一使用小端方式解析,确保在ARM Cortex-M全系列上都能工作。

2.3 J-Link调试器与上位机的数据通道

J-Link电脑端提供了JLinkARM.dll(新版叫JLink_x64.dll)这种动态库,封装了与调试器通信的API。上位机通过QLibrary加载这个DLL,调用其中的函数来打开设备、连接目标芯片、读写内存。这就相当于在应用层直接对目标板的内存进行操作,比用命令行灌命令效率高得多。

整体链路是这样的:目标MCU RAM里的RTT控制块 -> SWD/JTAG引脚 -> J-Link调试器 -> USB -> JLinkARM.dll -> 上位机解析显示。这条链路中任何一环断开或者配置错误,都会导致上位机读不到数据。后面会逐个讲排查方法。

3. QT上位机的整体架构设计

架构设计原则是“界面不卡、数据不丢、扩展容易”。我最终采用了单窗口加多定时器轮询的架构,而不是粗暴地用多线程。因为RTT的数据量通常不会特别大,QTimer每10ms读取一次完全够用,而且能避免线程安全问题。

3.1 模块划分

整个工程分成几个独立模块,各司其职:

  • JLinkManager模块:负责加载JLinkARM.dll,初始化调试器连接,封装Open、Connect、ReadMem、WriteMem等底层操作,向上层提供简洁的接口;
  • RttProtocol模块:负责在内存中扫描RTT控制块,解析通道信息,处理环形缓冲区数据的读写,这个模块不依赖QT的界面组件,方便单元测试;
  • DataProcessor模块:负责把读到的原始字节流按通道分发给UI,可扩展解析规则,比如特定通道的数据自动按波形解析;
  • MainWindow模块:负责显示日志、发送命令、连接配置等交互。

这样分层的好处是,后面想换成别的调试器,比如CMSIS-DAP,只要把JLinkManager模块替换掉就行,RttProtocol和UI模块完全不用动。

3.2 线程与刷新策略

QTimer在UI线程里跑,每个周期会依次执行“读取控制块+读取数据+更新界面”三个动作。考虑到ReadMem本身是同步接口,数据量几百字节时耗时很短,UI线程不会有明显卡顿。如果遇到超大日志流量,可以优化成生产者-消费者模式,把读取操作放到工作线程,通过信号槽把数据包发回UI线程。我基本实现里保留了这种优化接口,但默认是单线程,因为简单可靠。

同时,UI上的日志窗口采用QPlainTextEdit,最大显示行数限制在5000行,超过后自动裁剪顶部,避免长时间运行导致内存膨胀。这个细节很多人会忽略,结果跑一晚上内存涨到几个G。

4. 核心模块实现拆解

下面详细讲一下实际写代码时几个核心点,直接给出关键思路和代码片段。完整工程里代码量比较大,这里按知识点讲核心逻辑。

4.1 加载JLinkARM.dll并连接目标芯片

在Windows下用QLibrary加载动态库,注意DLL目录和位数要匹配。如果QT编译为64位,最好使用JLink_x64.dll,否则在64位系统下加载32位DLL容易出问题。

QLibrary jlinklib("JLinkARM.dll"); if (!jlinklib.load()) { qDebug() << "加载JLinkARM.dll失败:" << jlinklib.errorString(); return; }

加载成功后,通过resolve获取函数指针。常用API包括:

  • JLINKARM_Open:打开J-Link设备;
  • JLINKARM_Close:关闭设备;
  • JLINKARM_SelectDevice:选择目标芯片型号(比如STM32F407VE);
  • JLINKARM_Connect:连接目标芯片;
  • JLINKARM_ReadMem:读取目标内存;
  • JLINKARM_WriteMem:写入目标内存。
typedef int (__stdcall *JLink_Open_t)(void*); typedef int (__stdcall *JLink_Connect_t)(void); typedef int (__stdcall *JLink_ReadMem_t)(void*, unsigned int, unsigned int, unsigned char*); JLink_Open_t jlinkOpen = (JLink_Open_t)jlinklib.resolve("JLINKARM_Open"); JLink_Connect_t jlinkConnect = (JLink_Connect_t)jlinklib.resolve("JLINKARM_Connect"); JLink_ReadMem_t jlinkReadMem = (JLink_ReadMem_t)jlinklib.resolve("JLINKARM_ReadMem");

连接流程一般是先Open,然后SelectDevice(参数值由JLinkARM.h里的宏定义给出),再Connect。连接后可以读取CPU型号等信息,但RTT读取内存不需要CPU暂停。

4.2 扫描RTT控制块地址

这是一个大坑,也是初学者最容易卡住的地方。RTT控制块是MCU里的一个全局变量,地址并不固定,因为不同工程、不同编译选项、不同链接脚本,变量存放位置都不一样。上位机只能在目标RAM区域里搜索“SEGGER RTT”这段特征字符串。

实现时,用户在界面上配置RAM起始地址和扫描长度。比如STM32F407的RAM从0x20000000开始,大小为128KB,我默认扫描0x20000000到0x20020000。按4字节对齐逐块读取,每块读512字节,然后搜索字符串“SEGGER RTT”。

注意,有些MCU的RAM不连续,比如有CCM RAM、DTCM、普通SRAM之分,扫描时你需要知道目标芯片具体RAM分布。做成可配置项是最稳妥的。如果扫描不到,可以在串口里加辅助打印,把_SEGGER_RTT符号的地址打印出来,再手动填到上位机的“RTT控制块地址”输入框里。

QByteArray readMemory(uint32_t addr, uint32_t len) { QByteArray buf(len, 0); // 调用 jlinkReadMem int ret = jlinkReadMem(NULL, addr, len, (unsigned char*)buf.data()); if (ret <= 0) { return QByteArray(); } return buf; } int findRttControlBlock(uint32_t startAddr, uint32_t scanLen) { // 按4字节步进扫描,查找 "SEGGER RTT" for (uint32_t addr = startAddr; addr < startAddr + scanLen - 16; addr += 4) { QByteArray data = readMemory(addr, 16); if (data.left(10) == "SEGGER RTT") { return addr; } } return -1; }

这里的readMemory封装会直接用JLinkARM.dll的ReadMem,一次性读取16字节或更多,注意返回长度可能小于请求长度,一定要判断返回值。

4.3 解析控制块并读取环形缓冲数据

找到控制块地址后,把整块结构体读出来。上位机用C语言结构体直接映射内存是最高效的。定义如下:

#pragma pack(push, 1) typedef struct { char acID[16]; // "SEGGER RTT" int MaxNumUpBuffers; int MaxNumDownBuffers; struct { char sName[16]; unsigned int pBuffer; unsigned int SizeOfBuffer; unsigned int WrOff; unsigned int RdOff; unsigned int Flags; } aUp[3]; // 通常最多3个上行通道 struct { char sName[16]; unsigned int pBuffer; unsigned int SizeOfBuffer; unsigned int WrOff; unsigned int RdOff; unsigned int Flags; } aDown[3]; } RTT_CB; #pragma pack(pop)

读取整个RTT_CB后,就可以拿到通道0的缓冲区指针pBuffer、缓冲区大小SizeOfBuffer、读写偏移WrOff和RdOff。然后计算可读长度:

int dataLen = 0; if (wrOff >= rdOff) { dataLen = wrOff - rdOff; } else { dataLen = bufSize - rdOff + wrOff; }

如果rdOff和wrOff相等,说明缓冲区空,没有新数据。如果有数据,分两段读取:

  • 第一段:从rdOff到缓冲区末尾;
  • 第二段:如果wrOff < rdOff,从缓冲区开头到wrOff。

读完后更新rdOff,通过JLINKARM_WriteMem把新的读偏移写回目标MCU,这样MCU端就知道数据已经被取走,才能继续覆盖缓冲区。这个写回操作必须做,否则缓冲区很快就满,MCU会丢数据。

发送命令给MCU时,过程是反过来的:找到下行通道的pBuffer、WrOff、RdOff,根据已有数据量判断是否有剩余空间,然后把命令字符串写入,更新WrOff。

4.4 日志显示与缓存优化

日志显示我用了QPlainTextEdit,往尾部追加文本前先检查行数,超过5000行就执行裁剪。纯字符串拼接在大量日志下很耗性能,我建议先攒一个批次再一次性追加,比如每轮读取后,把本次读到的所有数据先拼成一个QString,然后调用appendPlainText一次更新界面。这样能明显减少UI刷新频率,实测下来很稳。

编码处理也要注意。MCU端RTT输出默认是ASCII,但如果你的代码里有中文,且编译时使用了UTF-8编码,那么读到的字节流需要按UTF-8解码。我在上位机里提供了编码选项,支持ASCII和UTF-8,默认UTF-8。最常见的问题就是中文乱码,切一下编码就好了。

5. 实操演示:从工程下载到看到第一行日志

为了让这份资料开箱即用,我准备了一个基于STM32F407的演示工程,里面已经初始化好了RTT,并周期性打印“Hello RTT Test”。下面按步骤走一遍。

5.1 环境准备

  • 硬件:J-Link V9或V10以上版本,目标板STM32F407;
  • 软件:QT 5.12.9及以上,对应的JLink驱动(建议V6.80以上,版本太旧API不全);
  • 源码工程:双击RTTTool.pro,用QT Creator打开,编译运行即可。

5.2 配置连接参数

启动上位机后,在连接设置面板选择:

  • 设备类型:STM32F407VE,或从下拉列表里找到对应型号;
  • 目标RAM起始地址:0x20000000;
  • 扫描长度:0x20000;
  • 连接速度:默认4000kHz,如果线材不好就降到1000kHz。

点击连接。如果J-Link驱动安装正常,状态栏会显示连接成功,并自动扫描RTT控制块。

扫描成功后会显示出通道列表,比如通道0名称为“Terminal”,通道1名称为“Sys”。勾选需要显示的通道,日志窗口开始刷新。

5.3 观察输出和发送命令

演示固件每100ms打印一次计数,界面应该以毫秒级延时实时显示。在发送区输入框里输入led on、led off之类的文本指令,MCU端收到后可以通过SEGGER_RTT_Read或底层缓冲区读取,然后执行相应动作。

如果一切正常,整个调试流程就盘活了。我平时做PID调参时,直接在发送区输入目标速度、比例系数、微分系数,MCU端解析后立即生效,比反复改代码下载固件方便十倍。这也是RTT上位机最大的价值。

6. 常见问题与排查技巧实录

开发过程中踩过的坑非常多,我把高频问题整理成一张速查表:

问题现象可能原因解决办法
日志窗口一直空白扫描不到RTT控制块增大扫描范围,确认RAM地址;如果还不行,在MCU工程里打印_SEGGER_RTT地址后手动填入
中文显示乱码编码不匹配切换界面编码为UTF-8;MCU端尽量统一使用UTF-8字符串
程序一运行就报错“load dll fail”JLinkARM.dll位数不对或路径没设置确认QT编译位数和JLink库位数一致,把DLL放到exe同级目录
读了一段时间后卡顿缓冲区读取回绕处理错误重新检查环形缓冲区分段读取逻辑,重点看 wrOff < rdOff 的情况
收发数据丢字符MCU端缓冲区太小或读偏移更新不及时调大RTT上行/下行缓冲区;保证上位机每次读完后回写读偏移
多通道日志混在一起通道选择没按名称过滤在RttProtocol模块中根据sName识别通道,并在GUI中按通道着色

除了表格里的问题,再分享几个独家技巧:

  • 扫描RTT控制块时,优先按4字节对齐地址扫描,因为全局变量默认4字节对齐,如果从奇数地址扫描,既慢又容易误匹配;
  • J-Link官方驱动版本对API有影响。示例代码用到JLINKARM_ReadMem的签名,新版SDK可能改成JLINKARM_ReadMemEx,建议以JLinkSDK头文件为准;
  • 长时间挂机调试时,在读取线程里加一个超时判断,如果连续多次读取返回0,自动弹出断开提示,避免一直空转占用CPU;
  • 别在主线程里做内存读取,除非数据量极小。我最初偷懒放在主线程,日志一多UI就卡,后来改成定时器轮询后好了很多;
  • RTT控制块地址最好做成可记忆化,每次扫描成功后保存到配置文件,下次启动直接填上,跳过扫描,能省几秒钟,尤其在频繁插拔设备时体验差异很大。

7. 扩展与后记:把RTT上位机玩出更多花样

这个项目做到后面,我越来越觉得RTT+QT的组合潜力很大。比如我把读取到的温度、电压等数据直接按通道解析成曲线,在QT里用QCustomPlot画出来,配合RTT的高带宽,基本就是一个小小的虚拟示波器。还有就是把读取到的日志通过QT的TCP模块转发到局域网,这样远端同事不用连接J-Link也能看到实时日志,联调时特别好用。

另外,也看到网上有人用AI生成上位机代码的尝试,但RTT上位机这种涉及硬件协议和调试器API的工程,AI很难保证细节完全正确。我自己写这个项目的经验是,想清楚协议、结构体布局、环形缓冲这三大块,剩下的UI拼装反而是最简单的事。

最后分享一个我个人的心得体会:做嵌入式上位机,最忌讳的就是“看着能跑就行”,一定要把底层协议吃透,不然换个芯片、换个调试器,代码很容易废掉。这套基于QT的RTT上位机,我从草稿到稳定版本用了大概两周时间,核心代码也就千把行。把源码、文档、演示固件整包发出去,就是希望后来者能少走点弯路,把时间花在业务逻辑和调参上,而不是重复造轮子。

如果你也在搞ARM单片机调试,建议直接拿这个工程去跑一跑,哪怕是当个学习RTT协议的例子,也绝对值得。后续我会继续加一些实用功能,比如数据录制回放、多通道条件过滤、脚本自动化接口,到时候再更新给大家。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询