26、__attribute__((format)):printf/scanf 格式检查、自定义格式化函数

说实话,C语言里最让人头疼的bug之一,就是格式化字符串和实际参数不匹配。你想想看,printf 里写了个 %d,结果传了个 long 进去——编译时一声不吭,运行时直接给你打印个莫名其妙的值。我早年在一个通信协议栈项目里就吃过这个亏,排查了整整两天,最后发现是 %x 和 %X 写混了,导致日志里关键字段全是乱码。

嗯,今天要讲的 __attribute__((format)),就是专门治这个毛病的。它能让编译器像检查 printf 一样,检查你自定义的格式化函数。说白了,就是把编译器的「火眼金睛」借给你用。

为什么需要格式检查?

先看个典型场景。你写了个日志函数:

void my_log(const char *fmt, ...) {
    va_list args;
    va_start(args, fmt);
    vprintf(fmt, args);
    va_end(args);
}

调用的时候手一抖:

int value = 42;
my_log("value = %s", value);  // 应该用 %d,但编译器不报错

没有 __attribute__((format)),编译器只会默默编译通过。运行时呢?轻则打印乱码,重则直接段错误。我在嵌入式调试时遇到过这种情况——打印一个浮点数用了 %d,结果后续所有变量都跟着乱掉,整个调试过程直接崩盘。

__attribute__((format)) 的基本用法

它的语法其实很直白:

__attribute__((format(printf, fmt_index, arg_index)))

三个参数分别告诉编译器:

  • 检查规则:用 printf 还是 scanf 的规则
  • 格式字符串位置:从第几个参数开始是格式串(从1开始数)
  • 第一个可变参数位置:格式串后面第一个可变参数是第几个

拿刚才的 my_log 函数举例,加上属性后:

void my_log(const char *fmt, ...) 
    __attribute__((format(printf, 1, 2)));

这里 1 表示 fmt 是第一个参数,2 表示第一个可变参数从第二个参数开始。加上之后,再写 my_log("value = %s", value),编译器就会报 warning 了。

关键点:参数索引从1开始,不是0。很多新手在这里栽跟头,包括我刚开始用的时候也数错过。

自定义格式化函数的实战案例

我在一个物联网网关项目里,需要统一所有日志输出格式,加上时间戳和模块名。当时写了个这样的函数:

#include <stdio.h>
#include <stdarg.h>
#include <time.h>

void log_message(const char *module, const char *fmt, ...)
    __attribute__((format(printf, 2, 3)));

void log_message(const char *module, const char *fmt, ...) {
    time_t now = time(NULL);
    char timebuf[32];
    strftime(timebuf, sizeof(timebuf), "%H:%M:%S", localtime(&now));
    
    printf("[%s][%s] ", timebuf, module);
    
    va_list args;
    va_start(args, fmt);
    vprintf(fmt, args);
    va_end(args);
    
    printf("\n");
}

注意看,format(printf, 2, 3) 表示格式串是第二个参数,可变参数从第三个开始。调用时:

int sensor_val = 25;
log_message("SENSOR", "temperature = %d°C", sensor_val);  // 正确
log_message("SENSOR", "temperature = %s°C", sensor_val);  // 编译器警告!

加了属性后,第二行会触发类似这样的警告:

warning: format '%s' expects argument of type 'char *', 
         but argument 3 has type 'int'

嗯,这就是我们想要的效果。编译阶段就把问题揪出来,省得运行时抓狂。

支持 scanf 格式检查

不光 printf,scanf 也能用。写法类似:

int my_scan(const char *fmt, ...)
    __attribute__((format(scanf, 1, 2)));

我个人在实际项目中很少自定义 scanf 函数,但如果你在写一些安全输入封装,这个属性同样管用。比如你封装了一个带缓冲的输入函数:

int safe_scanf(const char *fmt, ...)
    __attribute__((format(scanf, 1, 2)));

int safe_scanf(const char *fmt, ...) {
    // 做一些输入缓冲、超时处理
    va_list args;
    va_start(args, fmt);
    int ret = vscanf(fmt, args);
    va_end(args);
    return ret;
}

避坑指南:成员函数中的用法

如果你在结构体里放函数指针,并且这个函数指针是格式化函数,那就要小心了。我曾经在一个回调框架里踩过这个坑:

struct logger {
    void (*output)(const char *fmt, ...);
};

// 这样声明是不对的!
struct logger log = {
    .output = my_log  // 编译器不会检查格式
};

正确的做法是在函数指针声明时就加上属性:

struct logger {
    void (*output)(const char *fmt, ...)
        __attribute__((format(printf, 1, 2)));
};

这样赋值时,编译器才会检查 my_log 是否符合格式要求。

注意:__attribute__((format)) 只能检查编译期已知的格式字符串。如果格式串是运行时动态生成的(比如从配置文件读取),编译器无法检查,这个属性就帮不上忙了。

多平台兼容性

这个属性是 GCC 和 Clang 都支持的,但 MSVC 不支持。如果你写跨平台代码,可以用条件宏处理:

#if defined(__GNUC__) || defined(__clang__)
#define PRINTF_FORMAT(fmt, args) __attribute__((format(printf, fmt, args)))
#else
#define PRINTF_FORMAT(fmt, args)
#endif

void my_log(const char *fmt, ...) PRINTF_FORMAT(1, 2);

这样在 GCC/Clang 下能获得编译检查,在 MSVC 下也能正常编译,只是没有检查而已。

知识体系一览

下面这张图总结了 __attribute__((format)) 的核心逻辑:

__attribute__((format)) 知识体系 __attribute__((format)) format(printf/scanf, fmt_index, arg_index) 使用场景:自定义日志函数、安全输入封装、调试输出、回调函数指针 注意事项:参数索引从1开始、仅检查编译期格式串、MSVC不兼容 最佳实践:用条件宏封装、结构体函数指针也加属性 编译时发现问题,比运行时调试高效百倍

总结一下

__attribute__((format)) 是个小工具,但能省大事。它把运行时才暴露的格式化错误,提前到编译阶段暴露出来。我个人的习惯是:只要自定义了带可变参数的格式化函数,第一件事就是加上这个属性。哪怕只是临时调试用的函数,也值得加——因为你永远不知道哪天会复用这段代码。

记住一句话:编译器的警告,是你最好的免费 QA 工程师。别嫌它烦,它是在帮你省钱省时间。

小技巧:在 GCC 下可以用 -Wformat=2 开启更严格的格式检查,连 %d%ld 不匹配这种细节都会报出来。我一般会在项目的 CMakeLists.txt 里加上 add_compile_options(-Wformat=2 -Werror=format),把格式警告直接升级为错误,从源头杜绝这类bug。