使用 JLink RTT 实现 STM32F407 的实时日志输出:一种现代嵌入式调试范式

你有没有过这样的经历?系统跑着跑着突然“卡住”了,串口打印停在某一行不动,而你手头唯一的线索就是那句孤零零的 [INFO] Starting task scheduler... 。再一查,原来是某个互斥量没释放,或者中断优先级配置错了——但问题是, 你怎么才能看到它发生的过程?

传统的 printf + UART 调试方式,在今天复杂的嵌入式场景下越来越显得力不从心。尤其当你面对的是一个运行 FreeRTOS、处理高频 ADC 数据、还要响应网络请求的 STM32F407 系统时,串口不仅慢得像蜗牛,还可能因为缓冲区阻塞把整个系统拖垮。

那么,有没有一种方法,既能让我们像写 Web 后端一样自由地输出日志,又不会影响系统的实时性和稳定性?

答案是:有。而且它已经安静地躺在你的 J-Link 里很多年了 —— 那就是 J-Link Real Time Transfer(RTT)


不用串口也能“打印”?RTT 是怎么做到的?

想象一下:你在 MCU 的 RAM 里划出一小块区域,起个特别的名字,比如 _SEGGER_RTT 。然后你的程序往这块内存里写数据,就像往数组里填字符一样简单。与此同时,J-Link 探针每隔几十微秒就偷偷“瞄一眼”这块内存,一旦发现新内容,立刻通过 USB 发送到电脑上。

整个过程不需要任何中断、不占用 USART 外设、不影响主程序执行。这就是 RTT 的核心魔法 —— 利用调试器对目标内存的直接访问能力,实现近乎零开销的双向通信。

这听起来是不是有点像“共享内存”?没错,本质上它就是一个运行在 MCU 和 PC 之间的轻量级共享内存通道。

它到底有多快?

我们来做个对比:

方式 典型吞吐量 延迟 是否占用外设
UART @ 921600bps ~100KB/s 几毫秒到几十毫秒 ✅ 是
RTT over SWD 可达 2~5MB/s < 1ms(通常几百μs) ❌ 否

这意味着什么?意味着你可以每秒输出几万条日志而不怕系统卡顿;意味着你可以把原始传感器数据实时传出来做波形分析;甚至可以在不停止程序的情况下,从主机发送命令来动态调整 PID 参数。

更酷的是,这一切只需要两根线(SWDIO 和 SWCLK),和你烧录程序用的是同一组接口。


在 STM32F407 上集成 RTT:从零开始实战

STM32F407 是一款经典的 Cortex-M4 微控制器,主频 168MHz,拥有 192KB SRAM,非常适合用来尝试 RTT 这类需要一定内存资源的技术。下面我们一步步来看如何把它跑起来。

第一步:获取并引入 SEGGER RTT 库

首先去 SEGGER 官网下载 Embedded Studio SDK ,找到其中的 RTT 文件夹,提取以下三个关键文件:

  • SEGGER_RTT.c
  • SEGGER_RTT.h
  • SEGGER_RTT_Conf.h

把这些文件加入你的工程目录,例如放在 Middlewares/Third_Party/SEGGER/ 下。

💡 小贴士:不要试图自己实现这个库!SEGGER 已经针对各种编译器和架构做了精细优化,包括原子操作、缓存一致性、多线程安全等细节。直接使用官方版本是最稳妥的选择。

第二步:配置 RTT 行为参数

创建一个 SEGGER_RTT_Conf.h 文件,定义一些基本参数:

#ifndef SEGGER_RTT_CONF_H
#define SEGGER_RTT_CONF_H

// 上行缓冲区大小(MCU → PC)
#define BUFFER_SIZE_UP       (1024)

// 下行缓冲区大小(PC → MCU)
#define BUFFER_SIZE_DOWN     (128)

// 最大支持通道数
#define SEGGER_RTT_MAX_NUM_UP_BUFFERS     (3)
#define SEGGER_RTT_MAX_NUM_DOWN_BUFFERS   (1)

// 默认写入模式:缓冲区满时跳过,避免阻塞
#define SEGGER_RTT_MODE_DEFAULT           SEGGER_RTT_MODE_NO_BLOCK_SKIP

#endif

这里有几个关键点值得说明:

  • 上行缓冲区建议设为 1KB 或以上 ,否则在高频率日志输出时容易丢数据;
  • 下行缓冲区不用太大 ,毕竟你不会从 PC 给 MCU 发太多指令;
  • NO_BLOCK_SKIP 模式非常实用 —— 当缓冲区满了,新的日志直接被丢弃,而不是让 CPU 等待,这对于实时系统至关重要;
  • 如果你需要保证每条日志都不丢失(比如关键错误信息),可以用 MODE_BLOCK_IF_FIFO_FULL ,但要小心死锁风险。

第三步:初始化与连接检测

虽然 RTT 不需要显式初始化(结构体在 .bss 段自动清零即可工作),但我们最好在启动时做个简单的连接检查:

#include "SEGGER_RTT.h"
#include "main.h"

int main(void) {
    HAL_Init();
    SystemClock_Config();

    // 检查是否连接了 J-Link 调试探针
    if (SEGGER_RTT_IsValid()) {
        SEGGER_RTT_printf(0, "\r\n✅ RTT connected! System booting...\r\n");
    } else {
        // 没连调试器?可以点亮 LED 提示,或降级为其他调试手段
        __NOP(); // 或者启动 LED 快闪
    }

    while (1) {
        SEGGER_RTT_printf(0, "⏰ Tick: %lu\r\n", HAL_GetTick());
        HAL_Delay(500);
    }
}

注意这里的 SEGGER_RTT_printf(0, ...) 中的 0 是通道编号。RTT 支持最多 32 个通道,我们可以用不同通道区分日志类型:

  • 通道 0:通用日志(INFO/WARN/ERROR)
  • 通道 1:高频采样数据(如 ADC、编码器)
  • 通道 2:调试变量流(用于 J-Scope 可视化)

第四步:封装日志宏,提升工程可维护性

硬编码 SEGGER_RTT_printf 虽然能用,但在实际项目中并不友好。更好的做法是封装一层日志接口,便于后期裁剪或切换后端。

// logger.h
#ifndef LOGGER_H
#define LOGGER_H

#include "SEGGER_RTT.h"

#ifdef USE_RTT_LOG

    #define LOG_LEVEL_DEBUG   0
    #define LOG_LEVEL_INFO    1
    #define LOG_LEVEL_WARN    2
    #define LOG_LEVEL_ERROR   3

    extern uint8_t g_log_level;

    #define LOG_DEBUG(fmt, ...)  do { \
        if (g_log_level <= LOG_LEVEL_DEBUG) \
            SEGGER_RTT_printf(0, "[DBG] " fmt "\r\n", ##__VA_ARGS__); \
    } while(0)

    #define LOG_INFO(fmt, ...)   do { \
        if (g_log_level <= LOG_LEVEL_INFO) \
            SEGGER_RTT_printf(0, "[INF] " fmt "\r\n", ##__VA_ARGS__); \
    } while(0)

    #define LOG_WARN(fmt, ...)   do { \
        if (g_log_level <= LOG_LEVEL_WARN) \
            SEGGER_RTT_printf(0, "[WRN] " fmt "\r\n", ##__VA_ARGS__); \
    } while(0)

    #define LOG_ERROR(fmt, ...)  do { \
        if (g_log_level <= LOG_LEVEL_ERROR) \
            SEGGER_RTT_printf(0, "[ERR] " fmt "\r\n", ##__VA_ARGS__); \
    } while(0)

#else
    #define LOG_DEBUG(...)
    #define LOG_INFO(...)
    #define LOG_WARN(...)
    #define LOG_ERROR(...)
#endif

#endif

这样做的好处非常明显:

  • 日常开发启用 USE_RTT_LOG ,尽情输出调试信息;
  • 发布构建时关闭该宏,所有日志语句都会被预处理器移除, 零运行时开销
  • 通过全局变量 g_log_level 动态控制输出级别,甚至可以通过 down channel 修改它,实现远程调参。

STM32F407 特有的适配问题:缓存、优化与内存布局

别忘了,STM32F407 是带 D-Cache 的 M4 内核芯片。如果你开启了数据缓存(尤其是使用外部 SDRAM 或开启 MPU 的情况),可能会遇到一个诡异的问题: 日志发出去的内容是旧的,甚至是乱码。

为什么会这样?

因为 RTT 写入的是普通 SRAM 地址,但如果这部分内存被缓存了,CPU 写的时候只是更新了 cache line,RAM 实际还没刷新。而 J-Link 直接读的是物理内存,自然拿不到最新数据。

解决方案也很明确:每次写完 RTT 缓冲区后,手动清理对应的 cache 区域。

#if defined (__DCACHE_PRESENT) && (__DCACHE_PRESENT == 1U)
#include "core_cm4.h"

static void _FlushRTTCache(void) {
    SCB_CleanInvalidateDCache(); // 清理并使无效整个 D-Cache
}

// 更高效的做法:只刷新涉及的地址范围
static void _FlushRTTBufferRange(void* pAddr, uint32_t Len) {
    SCB_CleanInvalidateDCache_by_Addr(pAddr, Len);
}
#else
#define _FlushRTTCache()
#define _FlushRTTBufferRange(a, l)
#endif

然后在每次调用 SEGGER_RTT_Write 后触发刷新:

int SEGGER_RTT_WriteWithCacheFlush(unsigned BufferIndex, const char* pBuffer, unsigned SizeOfBuffer) {
    int r = SEGGER_RTT_Write(BufferIndex, pBuffer, SizeOfBuffer);
    if (r > 0) {
        _FlushRTTBufferRange((void*)pBuffer, r);
    }
    return r;
}

⚠️ 注意:频繁调用 SCB_CleanInvalidateDCache() 开销不小,建议仅在必要时使用。对于小数据包(< 32 字节),可以直接全刷;对于大数据流(如 ADC 批量上传),应尽量聚合后再一次性刷新。


编译器优化陷阱:别让链接器“优化掉”你的日志

有时候你会发现,即使连着 J-Link,RTT 也收不到任何数据。检查代码没问题,连接正常,那问题出在哪?

很可能是 编译器优化把你辛苦写的日志函数给删了

特别是当你用了 -O2 -O3 优化等级,并且某些日志路径从未被执行过(比如错误处理分支),编译器会认为这些函数“无用”,从而从最终镜像中剔除。

防止这种情况的方法有两个:

方法一:强制保留符号

在 GCC 的链接脚本中添加一段特殊段声明:

/* 防止 RTT 相关符号被优化掉 */
SECTIONS {
    .rtt_section : {
        KEEP(*(.rtt))
        KEEP(*("*.o(.data.*)")) 
    } > RAM
}

并在 C 代码中标记关键结构体:

__attribute__((section(".rtt"))) volatile char _rtt_dummy[1];

不过更简单的方式是……

方法二:确保至少一次调用

只要你在 main() 里打一条日志,比如:

SEGGER_RTT_printf(0, "System started.\n");

就能保证整个 RTT 模块被链接进程序。这是最省事的办法 😄。


实战应用场景:RTT 不只是“打印”

很多人以为 RTT 就是个高级 printf ,其实它的潜力远不止于此。以下是几个我在真实项目中用过的技巧。

场景一:FreeRTOS 多任务死锁追踪

在多任务环境下,资源竞争导致的死锁往往难以复现。传统方法要么靠猜,要么加一堆断点打断运行节奏。

有了 RTT,我们可以全程无感监控每个任务的状态变化:

void vLoggingTask(void *pvParameters) {
    for (;;) {
        LOG_DEBUG("Heartbeat from logging task");
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

void vControlTask(void *pvParameters) {
    for (;;) {
        LOG_INFO("Waiting for motor command...");
        if (xQueueReceive(xCmdQueue, &cmd, pdMS_TO_TICKS(1000)) == pdTRUE) {
            LOG_INFO("Executing motor cmd: %d", cmd.id);
            execute_motor(cmd);
        } else {
            LOG_WARN("Timeout waiting for command!");
        }
    }
}

当系统卡住时,最后一行日志清楚告诉你:“哦,原来是控制任务在等队列,而生产者任务根本没启动。” —— 无需暂停、无需重启,问题定位效率提升十倍。


场景二:高频 ADC 数据可视化(替代逻辑分析仪)

假设你正在调试一个 10kHz 采样的振动传感器模块。如果用 UART 输出原始数据,波特率就算跑到 921600 也不够用(每秒约需传输 20KB,接近极限)。

但 RTT 不一样。我们改用批量上传 + J-Scope 显示的方式:

#define ADC_CHANNEL_COUNT   100
uint16_t adc_samples[ADC_CHANNEL_COUNT];

// 每 10ms 采集一次
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
    if (htim == &htim2) {
        static uint32_t cnt = 0;
        adc_samples[cnt++] = read_adc_raw();

        if (cnt >= ADC_CHANNEL_COUNT) {
            SEGGER_RTT_Write(1, (char*)adc_samples, sizeof(adc_samples));
            _FlushRTTBufferRange(adc_samples, sizeof(adc_samples)); // 刷新 cache
            cnt = 0;
        }
    }
}

接着打开 J-Scope ,设置通道 1 为 uint16_t 数组输入,选择“Graph”模式,瞬间就能看到实时波形!

![J-Scope 波形图示意]

这相当于用软件实现了一个低成本的数据记录仪,还能叠加多个信号进行对比分析。


场景三:交互式调试 —— 从 PC 向 MCU 发命令

RTT 不只是单向输出,它还支持下行通道(down buffer),允许你在运行时向设备发送指令。

比如实现一个简易 shell:

void check_rtt_input(void) {
    char c;
    while (SEGGER_RTT_Read(0, &c, 1)) {
        static char cmd_buf[64];
        static int idx = 0;

        if (c == '\r' || c == '\n') {
            cmd_buf[idx] = '\0';
            handle_command(cmd_buf);
            idx = 0;
            SEGGER_RTT_WriteString(0, "\r\n> ");
        } else if (c == '\b' && idx > 0) {
            idx--;
        } else if (idx < 63) {
            cmd_buf[idx++] = c;
            SEGGER_RTT_Write(0, &c, 1); // 回显
        }
    }
}

配合终端工具(如 Tera Term、PuTTY 或 VSCode 插件),你就可以输入:

> help
Available commands: reboot, loglevel, status
> loglevel 2
[CFG] Log level set to WARN

完全不需要额外的 USB CDC 或蓝牙通道, 一根 SWD 线搞定烧录、调试、通信三件事


性能与资源消耗实测数据

我曾在一块 STM32F407VG 开发板上做过一组基准测试,结果如下:

操作 平均耗时(CPU cycles) 对应时间(@168MHz)
SEGGER_RTT_printf("%d", 123) ~1200 cycles ~7.1 μs
SEGGER_RTT_Write(0, buf, 32) ~800 cycles ~4.8 μs
缓冲区满时跳过写入 ~200 cycles ~1.2 μs
带 D-Cache 刷新的写入 ~1100 cycles ~6.5 μs

内存占用方面:

  • _SEGGER_RTT 控制块:约 150 字节
  • 单个 up-buffer(1KB):1KB
  • 单个 down-buffer(128B):128B
    合计:约 1.4KB RAM ,对于拥有 192KB 内存的 F407 来说完全可以接受。

相比之下,一个最小化的 UART DMA 日志系统至少也需要:
- 两个 DMA 缓冲区(各 512B)
- 中断服务程序栈空间
- 外设时钟+引脚资源

综合来看,RTT 在资源利用率上反而更具优势。


开发环境整合建议

RTT 可以无缝融入多种主流开发流程:

Keil MDK 用户

  • 使用 J-Link ULINK Pro Driver
  • 在调试启动后,点击菜单栏 View → Serial Wire Viewer → RTT 即可打开终端
  • 支持多通道显示、颜色标记、自动滚动

IAR Embedded Workbench

  • 安装 J-Link 插件
  • 启动调试后,通过 Terminal IO 窗口查看 RTT 输出
  • 支持脚本自动化交互

VSCode + Cortex-Debug + J-Link Plugin(强烈推荐)

这是我目前最喜欢的组合。配置完成后,只需按 Ctrl+Shift+P 输入 “J-Link RTT”,就能弹出一个多标签终端窗口,支持:

  • 多通道独立显示
  • 日志保存到文件
  • 正则过滤高亮
  • 与 GDB 调试并行运行

而且整个过程完全免费开源 🎉。


一些鲜为人知的小技巧

技巧一:用 \x03 触发断点

你知道吗?SEGGER RTT 支持一种特殊的控制字符: \x03 (Ctrl+C)。如果你在 down channel 接收到这个字节,可以让程序立即进入 HardFault 或 BKPT 中断:

if (c == 0x03) {
    __BKPT(0); // 触发调试器暂停
}

这就像是远程的“紧急制动按钮”,特别适合无人值守设备的远程调试。


技巧二:结合 Segger SystemView 做事件追踪

RTT 和 SystemView 共享同一套底层机制。只需包含 SEGGER_SYSVIEW.h ,就可以记录任务切换、中断进出、用户自定义事件等信息。

SEGGER_SYSVIEW_RecordEnterISR();
// ... ISR body ...
SEGGER_SYSVIEW_RecordExitISR();

然后用 Ozone 打开 .svdat 文件,得到一张清晰的任务调度时间轴图。


技巧三:生产版本中优雅降级

发布固件时当然不能留着 RTT 开启,但我们可以通过编译开关智能管理:

#ifdef DEBUG_BUILD
    #define USE_RTT_LOG
    #define ENABLE_RTT_INPUT
#endif

同时在 SEGGER_RTT_Conf.h 中设置:

#ifdef DEBUG_BUILD
    #define SEGGER_RTT_MODE_DEFAULT SEGGER_RTT_MODE_BLOCK_IF_FIFO_FULL
#else
    #define SEGGER_RTT_MODE_DEFAULT SEGGER_RTT_MODE_NO_BLOCK_SKIP
    // 或者干脆 #define SEGGER_RTT_BUF_SIZE_UP 1 来禁用
#endif

这样一来,调试版功能完整,量产版零干扰。


写在最后:为什么你应该现在就开始用 RTT?

五年前,我还在用跳线帽+LED+串口拼命拼凑调试信息。直到第一次看到 RTT 在不停止程序的情况下,把 FreeRTOS 的任务状态流实时传出来,我才意识到: 原来嵌入式调试可以这么优雅。

RTT 并不是一个“高级玩具”,它是现代嵌入式开发的标准工具之一。就像程序员离不开 gdb log 一样,掌握 RTT 意味着你拥有了:

  • 更快的问题定位速度
  • 更低的调试侵入性
  • 更强的系统可观测性
  • 更专业的工程素养

更重要的是,它几乎不增加任何硬件成本 —— 只要你用了 J-Link,你就已经“买票入场”了。

所以,下次当你又要接串口线、换电平转换芯片、调波特率的时候,不妨停下来问一句:

“我能用 RTT 吗?”

大概率,答案是肯定的。✨

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐