Keil5中使用调试宏打印函数进入退出

AI助手已提取文章相关产品:

在 Keil5 中用调试宏自动追踪函数进出:不只是“打印”那么简单

你有没有过这样的经历?半夜三点,盯着示波器发呆,只因为某个状态机莫名其妙卡住了。或者,你在看一个别人写的、层层嵌套的驱动代码时,完全搞不清执行流到底是怎么跳来跳去的。

这时候, printf 似乎是唯一的救命稻草——但问题是,在没有显示器的单片机上,你怎么“打”?

更糟的是,等你终于把 printf("entering function X") 手动加到几十个函数里头,项目也快黄了。而且一旦发布版本要上线,你还得一个个删回去,生怕影响性能。

这不叫调试,这叫受罪。

其实,有一种方法,可以让你 像魔法一样 ,让每个函数自己“开口说话”:“我进来了!”、“我要走了!”。不需要你手动敲一行日志代码,也不需要在发布时提心吊胆地删除它们。

这就是我们今天要聊的—— 基于 Keil5 的调试宏自动化函数轨迹追踪


函数名从哪来?别再手写了!

在开始写宏之前,先解决一个问题:我们怎么知道当前函数的名字?

答案是: 编译器早就知道,而且它愿意告诉你。

C语言有两个“特殊变量”——不是宏,也不是函数,而是由编译器在进入每个函数时自动声明的字符串常量:

  • __FUNCTION__
  • __func__

比如你写:

void motor_control_task(void) {
    printf("现在正在运行: %s\n", __FUNCTION__);
}

输出就是:

现在正在运行: motor_control_task

听起来很神奇?其实一点都不。这些标识符是编译器内置的“元信息”,属于 C99 标准的一部分( __func__ 是标准定义),而 __FUNCTION__ 则是 GCC、Keil、MSVC 等编译器广泛支持的扩展形式。

🤓 小知识: __func__ 实际上是一个静态局部数组,相当于你在函数开头悄悄写了这么一句:
c static const char __func__[] = "motor_control_task";

这意味着:
- ✅ 它在 编译期就确定了 ,运行时不花 CPU 时间去查表。
- ❌ 你不能在函数外面用它,否则会报错。
- 🔒 它是只读的,改不了。
- ⚖️ 跨平台项目建议优先使用 __func__ ;但在 Keil 环境下,两者都行, __FUNCTION__ 更常见。

所以,别再手写函数名了。你的编译器比你还清楚你现在在哪。


让函数“自报家门”:调试宏的设计哲学

我们现在有了名字,接下来的问题是: 如何优雅地插入“进入”和“退出”的日志?

最笨的办法当然是每进一个函数就写一遍 printf("[+] %s\n", __FUNCTION__); ,但这不仅重复劳动,还容易漏掉、拼错、忘记删。

聪明的做法是: 封装成宏

但这里有个陷阱很多人踩过——你以为宏只是文本替换,于是写出这种东西:

#define FUNC_ENTER() printf("enter %s\n", __FUNCTION__)

然后在一个 if 语句里用了它:

if (error)
    FUNC_ENTER();
else
    do_something();

结果一切正常?不对!如果哪天你给这个宏加了多行代码,比如:

#define FUNC_ENTER() \
    log_enable_check(); \
    printf("enter %s\n", __FUNCTION__)

那上面那段 if 就会出问题: else 不再和前面的 if 配对,因为宏展开后变成了:

if (error)
    log_enable_check();
    printf("enter %s\n", __FUNCTION__)
else
    do_something();  // ← 编译错误!else 没有匹配的 if

怎么办?经典解法来了—— do { ... } while(0)

这个结构看起来奇怪,但它解决了所有问题:
- 它是一条完整的语句,结尾加分号没问题;
- 它强制大括号内的代码作为一个块执行;
- 它不会引入额外作用域或性能损耗(编译器会优化掉);
- 它能安全容纳多个表达式。

所以,真正靠谱的宏长这样:

#define FUNC_ENTER()    do { \
                            printf("[TRACE] --> Entering: %s\r\n", __FUNCTION__); \
                        } while(0)

是不是有点啰嗦?是的。但它值得。


实战:构建一套可开关的调试系统

光能打出来还不够。真正的工程级方案必须满足几个条件:

  1. 开发时打开,发布时关闭 —— 日志不能拖慢产品性能。
  2. 不影响原有逻辑 —— 特别是对返回值的处理。
  3. 兼容现有输出机制 —— 不管你是用串口还是半主机。

所以我们来设计一组完整的调试宏。

头文件出场: debug_trace.h

#ifndef DEBUG_TRACE_H
#define DEBUG_TRACE_H

#include <stdio.h>

// 👇 控制总开关:设为 0 可全局禁用所有调试输出
#define ENABLE_FUNC_TRACE  1

#if ENABLE_FUNC_TRACE

    #define FUNC_ENTER()    do { \
                                printf("[TRACE] --> Entering: %s\r\n", __FUNCTION__); \
                            } while(0)

    #define FUNC_EXIT()     do { \
                                printf("[TRACE] <-- Exiting: %s\r\n", __FUNCTION__); \
                            } while(0)

    // 带返回值的退出宏 —— 关键!避免多次计算表达式
    #define FUNC_EXIT_RET(ret)  do { \
                                    auto _tmp_ret = (ret); \
                                    printf("[TRACE] <-- Exiting: %s, return=0x%x\r\n", \
                                           __FUNCTION__, (unsigned int)_tmp_ret); \
                                    return _tmp_ret; \
                                } while(0)

#else   // DISABLED MODE

    // 🔇 调试关闭时,所有宏变成空操作
    #define FUNC_ENTER()    do {} while(0)
    #define FUNC_EXIT()     do {} while(0)
    #define FUNC_EXIT_RET(ret)  return (ret)

#endif

#endif /* DEBUG_TRACE_H */

注意几个细节:

  • auto _tmp_ret = (ret); 这个技巧非常重要!如果你直接写 (ret) printf return 里各一次,万一 ret 是个带副作用的表达式(比如 get_next_value() ),就会被调用两次!这会导致严重 bug。
  • 使用 \r\n 是为了适配大多数串口终端(尤其是 Keil 自带的 Debug Viewer)。
  • 前缀 [TRACE] 方便后期用脚本过滤日志。

你可以通过编译选项 -DENABLE_FUNC_TRACE=1 动态控制是否启用,无需修改任何源码。


实际效果:看看程序是怎么“走”的

假设我们有下面这段代码:

#include "debug_trace.h"

void low_level_init(void) {
    FUNC_ENTER();

    for (volatile int i = 0; i < 500; i++);

    FUNC_EXIT();
}

int calculate_crc(uint8_t *data, int len) {
    FUNC_ENTER();

    if (!data || len <= 0) {
        FUNC_EXIT_RET(-1);
    }

    int crc = 0;
    for (int i = 0; i < len; i++) {
        crc ^= data[i];
    }

    FUNC_EXIT_RET(crc);
}

int main(void) {
    FUNC_ENTER();

    low_level_init();

    uint8_t buf[] = {1, 2, 3, 4};
    int crc = calculate_crc(buf, 4);

    printf("CRC = %d\r\n", crc);

    FUNC_EXIT();
    while (1);
}

当你运行程序,并连接调试器后,Keil 的 “ Debug (printf) Viewer ” 窗口中会出现类似以下内容:

[TRACE] --> Entering: main
[TRACE] --> Entering: low_level_init
[TRACE] <-- Exiting: low_level_init
[TRACE] --> Entering: calculate_crc
[TRACE] <-- Exiting: calculate_crc, return=0x4
CRC = 4
[TRACE] <-- Exiting: main

看到了吗?整个调用流程一目了然。就像有人拿着摄像机跟拍程序的每一步动作。

再也不用靠猜了。


输出去哪儿了?半主机 vs UART 重定向

现在问题来了:这些 printf 到底是怎么出现在电脑屏幕上的?

毕竟,STM32 又没有接显示器。

答案是: 半主机(Semihosting)

这是 ARM 提供的一种“作弊模式”——当你的 MCU 调用标准库 I/O 函数(如 printf )时,它会触发一个软中断(SWI),通知调试器:“嘿,帮我把这段文字输出到我的电脑上。”

这个过程依赖于 J-Link、ULINK 或 ST-Link 这类调试探针,以及 Keil IDE 内部的拦截机制。

半主机的工作原理简析

  1. 你调用 printf(...)
  2. 底层调用 fputc()
  3. 默认链接的是半主机版的 fputc ,它会执行一条 BKPT 0xAB 指令(ARMv6-M/v7-M 上的半主机入口);
  4. 调试器捕获该断点,解析请求类型(这里是写字符);
  5. 数据被转发到 Keil 的 “ Debug (printf) Viewer ” 窗口。

✅ 优点很明显:
- 不需要配置 UART 引脚;
- 不占用任何外设资源;
- 开箱即用,适合快速原型验证。

⚠️ 但缺点也很致命:
- 每次输出都会暂停程序,造成巨大延迟;
- 如果没连调试器,程序可能直接 HardFault;
- 绝对不能用于正式产品。

所以, 半主机只适合早期调试阶段


真正可用的日志系统:把输出重定向到 UART

要想让日志既高效又可靠,就得让它跑在真实的硬件上——通常是 USART。

做法很简单: 重写 fputc 函数

Keil 的 printf 最终会调用 fputc(int ch, FILE *f) 来发送每一个字符。只要你提供自己的实现,就可以把它导向任意物理接口。

例如,使用 STM32F103 的 USART1:

// fputc 重定向到 USART1
int fputc(int ch, FILE *f) {
    // 等待发送寄存器空
    while ((USART1->SR & USART_SR_TXE) == 0) {
        // 可加入超时机制避免死循环
    }
    USART1->DR = (uint8_t)ch;
    return ch;
}

只要加上这个函数,所有 printf 包括我们的 FUNC_ENTER/EXIT 宏,都会自动通过串口输出!

💡 小贴士:记得开启对应 GPIO 的复用功能和时钟,还要初始化 USART 波特率等参数。

此时,你甚至可以在没有调试器的情况下,用 USB-TTL 模块接到串口,用串口助手查看日志。

这才是生产环境该有的样子。


如何避免把系统拖垮?关于性能与策略的思考

你说,“每次进函数都打一条日志,会不会太吵?”

当然会。

想象一下,一个高频中断服务程序(ISR),每毫秒触发一次,里面放个 FUNC_ENTER() ……那你串口输出的速度可能都赶不上它产生的数据量。

所以, 智能使用比盲目启用更重要

策略一:按模块启用 / 禁用

不要全局一刀切。你可以为不同模块设置不同的日志级别。

比如:

// module_a.c
#define ENABLE_FUNC_TRACE  1
#include "debug_trace.h"

// module_b.c
#define ENABLE_FUNC_TRACE  0
#include "debug_trace.h"

这样,你可以只追踪关键路径,静默其他部分。

策略二:添加缩进,看清调用深度

目前的日志是平铺直叙的。如果我们能根据调用层级增加缩进,就能更直观看出嵌套关系。

虽然全局变量在多任务环境下危险,但在裸机系统中,我们可以尝试这样做:

#if ENABLE_FUNC_TRACE
    static int trace_indent = 0;

    #define FUNC_ENTER()    do { \
                                printf("%*s[+] %s\r\n", trace_indent*2, "", __FUNCTION__); \
                                trace_indent++; \
                            } while(0)

    #define FUNC_EXIT()     do { \
                                trace_indent--; \
                                printf("%*s[-] %s\r\n", trace_indent*2, "", __FUNCTION__); \
                            } while(0)
#endif

输出变成:

[+] main
  [+] low_level_init
  [-] low_level_init
  [+] calculate_crc
  [-] calculate_crc
[-] main

一眼就能看出谁是谁的孩子。

⚠️ 注意:这种方式不适合 RTOS 环境,因为多个任务共享同一个全局变量会造成混乱。但在简单的前后台系统中,非常实用。


更进一步:不只是进出,还能计时!

既然我们已经站在门口了,为什么不顺便量一下这个函数干了多久?

利用 Cortex-M 内核自带的 DWT Cycle Counter (数据观察点与跟踪单元),我们可以精确到 CPU 周期地测量函数耗时。

#if ENABLE_FUNC_TRACE && ENABLE_TIMING
    #define FUNC_ENTER()    do { \
                                uint32_t start = DWT->CYCCNT; \
                                printf("[+] %s (start @ %u)\r\n", __FUNCTION__, start); \
                                // 存储到线程局部存储?或静态变量?
                            } while(0)
#endif

当然,完整实现需要解决“如何保存起始时间”的问题(比如用静态变量+互斥锁,或 TLS)。但对于一次性分析,可以直接打印差值:

void timed_function(void) {
    uint32_t start = DWT->CYCCNT;
    FUNC_ENTER();

    // 干活...
    for (volatile int i = 0; i < 1000; i++);

    FUNC_EXIT();
    printf("[TIME] %s took %lu cycles\r\n", __FUNCTION__, DWT->CYCCNT - start);
}

结合主频换算成微秒,你就得到了精准的性能画像。


工程实践中的最佳习惯

说了这么多技术细节,最后回归到“怎么用才好”。

以下是我在多个工业级项目中总结出来的经验法则:

场景 推荐做法
新模块开发初期 全面启用 FUNC_ENTER/EXIT ,快速理清调用流
定位死循环或卡顿 结合 DWT 计数器,找出耗时大户
RTOS 多任务环境 避免共享 indent 变量,可在日志中加入 osThreadGetId()
发布前准备 确保 ENABLE_FUNC_TRACE=0 ,并通过编译检查确认无残留开销
团队协作 将宏统一放在公共头文件,制定命名规范(如 TRACE_ENTER

另外,强烈建议将这类调试工具纳入项目的 .gitignore 之外 ,但通过编译选项控制行为。也就是说,代码可以提交,但默认关闭。

这样既能保留调试能力,又不会污染生产构建。


写在最后:调试的本质是“看见”

嵌入式开发最难的地方,从来不是语法或算法,而是 你看不见程序的运行过程

它不像 Web 开发那样刷新一下浏览器就能看到变化,也不像桌面软件那样弹个对话框就知道哪里错了。

我们的程序运行在一个封闭的黑盒里,靠猜测和推理前行。

而调试宏,就是在这个黑盒上凿出的一扇小窗。

它不华丽,不炫酷,甚至有点土。但它实实在在地告诉你:“我现在在这里。”

当你不再需要靠注释和脑补来理解代码流时,你就离“掌控全局”近了一步。

下次你面对一堆迷宫般的函数调用时,不妨试试这个方法。

也许,只需要两行宏,就能让整个系统“活”起来。

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

您可能感兴趣的与本文相关内容

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值