
用sprintf写崩一个进程往往只需要一行代码。我印象很深的一次是接手一个日志模块char buf[64]里拼了一条带主机名、时间戳和请求路径的日志平时在内网跑得好好的,上线第二天就出现了段错误——路径里多了一段很长的查询串,直接越界覆盖了栈上的返回地址。这行代码本身没有任何语法错误,编译器也不会给警告,但sprintf根本不知道buf有多大,它只管往那里写,写多少全凭格式串和参数的实际情况。这就是sprintf最需要先理解的一点:它是个无边界检查的格式化写入函数,把它当成字符串版 printf来用,迟早要还债。sprintf属于 C 标准库stdio.h里的变参函数,作用是把格式化结果写进一块由调用者提供的字符缓冲区,而不是输出到终端。它的价值在于:数字转字符串、多类型拼接、定宽排版、十六进制转换、构造协议报文和日志行。这篇内容会从返回值语义、格式串语法、缓冲区容量计算、实战踩坑记录、snprintf迁移、真实应用场景到性能取舍一路拆下去。适合刚学完指针和数组、准备在项目里用sprintf的朋友,也适合用了几年但从没认真算过缓冲区大小的同行。1. 把 sprintf 的返回值当作内容长度使用陷阱很快出现1.1 原型里那个 int 返回值到底代表什么标准声明是这样的:int sprintf(char *str, const char *format, ...);返回值是写入的字符数,不包含结尾的\0。注意这里有两个容易混淆的点。第一,它返回的是实际写入的长度,不是缓冲区容量,也不是是否成功。很多新手看到返回值不是-1就以为万事大吉,其实返回值告诉你的是内容有多长,而缓冲区早就可能被写爆了。第二,写入的内容总是以\0结尾,所以真正占用的字节数是返回值 1。如果你申请的缓冲区大小是n,那么返回值最大只能是n - 1,再多一个字节就溢出。我见过一段代码,作者用返回值和sizeof比较来判断是否越界:char buf[32]; int n sprintf(buf, %s, some_string); if (n (int)sizeof(buf)) { /* 处理溢出 */ }这个判断逻辑看着很严谨,实际上已经晚了——越界写入在sprintf返回之前就已经发生,等到判断的时候栈可能已经坏了。sprintf没有任何事后补救的机会,边界控制必须在调用之前做,这也是后面要重点讲snprintf的原因。返回值在另一种场景里又特别有用:累加偏移量。拼接多段内容时,很多人写strcat之后再strlen,平白多扫描一遍字符串;用sprintf的返回值就能省掉这次扫描:char line[128]; int off 0; off sprintf(line off, id%d;, id); off sprintf(line off, code%d;, code); off sprintf(line off, ok\n);逻辑上很顺滑,但风险在于off没有任何上限校验。一旦某段内容比预期长,line off这个指针就跑到缓冲区外面去了,后面几步的写入是彻底的未定义行为。这种写法在嵌入式项目里特别常见,因为寄存器或状态字段的长度通常固定,大家默认不可能超长;可一旦某个字段从uint8_t改成uint32_t,或者某天加了一个变长的错误描述字符串,就是一条潜伏的崩溃路径。1.2 返回负数的情况:编码转换失败sprintf也可能返回负值。当格式串里出现%ls、%lc这类宽字符转换,而当前 locale 下无法把宽字符映射成多字节序列时,转换失败,函数返回负数。这在纯 ASCII 环境里几乎遇不到,但代码一旦跑在多语言环境或者被配置成某个奇怪的 locale,就会出现返回值是 -1,而缓冲区里是一堆不完整内容的状态。如果你用返回值做偏移累加,负值会让off往回退,后续写入直接落到缓冲区前面去,后果比越界还难查。所以凡是拿返回值参与指针运算的地方,至少要加一道if (n 0) break;的防线。这一条我在代码评审里几乎每次都会提,因为它属于平时不出事,出事查三天的类型。2. 格式串的每个字符都在生效:占位符、宽度与修饰符2.1 四类常用占位符的实际输出形态整型、浮点、字符、字符串、指针,是最常打交道的几类。先看一张对照表,把写什么和出什么对上:占位符期望参数类型示例输出备注%d/%iint-42有符号十进制%uunsigned int4294967295无符号,传负数会变成大正数%ounsigned int52八进制%x/%Xunsigned int2a/2A十六进制大小写%cintA实际取低 8 位%schar *hello遇\0停止%fdouble3.140000默认 6 位小数%e/%Edouble3.140000e00科学计数法%g/%Gdouble3.14自动选择并去掉尾零%pvoid *0x7ffd...格式平台相关%%无%输出一个百分号这张表最需要留意的是%u和%d那一行。把一个值为-1的int用%d打印是-1,用%u打印是4294967295,位模式没变,只是解释方式变了。反过来,把一个unsigned int变量用%d打印,当它的值超过INT_MAX时,你会得到一个负数。这种看起来算错了的 bug,根因往往就是占位符和参数类型不匹配,和运算逻辑无关。%s那一行也有个隐藏条件:参数必须指向一块以\0结尾的合法内存。如果传的是NULL,标准里是未定义行为。glibc 的实现比较宽容,会打印(null),于是很多人在 Linux 上测通了就以为没问题;换到别的标准库实现或者开了编译优化,进程直接崩。不能依赖(null)这个输出,该判空的地方还是要判。2.2 宽度、精度、标志三者的组合规则格式串的完整语法可以粗略拆成%[标志][宽度][.精度][长度修饰符]类型。宽度控制最少占几列,精度在不同类型上含义不同:对浮点是小数位数,对字符串是最大截取长度,对整数是最少数字位数(不足左边补零)。sprintf(b, [%5d], 42); /* [ 42] 右对齐,补空格 */ sprintf(b, [%-5d], 42); /* [42 ] 左对齐 */ sprintf(b, [%05d], 42); /* [00042] 补零 */ sprintf(b, [%05d], -42); /* [-0042] 零在符号之后 */ sprintf(b, [%d], 42); /* [42] 强制显示符号 */ sprintf(b, [%.3f], 3.14159); /* [3.142] 四舍五入到 3 位 */ sprintf(b, [%.5s], abcdefg); /* [abcde] 截断到 5 字符 */ sprintf(b, [%8.3s], abcdefg); /* [ abc] 先截断再补宽 */ sprintf(b, [%#x], 255); /* [0xff] 带前缀 */这里有两个反直觉的细节值得单独说。第一个是%05d配负数,零填充发生在符号之后,得到-0042而不是00-42,这是标准要求的,但手写补零逻辑的人常常写反。第二个是%8.3s,执行顺序是先按精度截断,再按宽度补齐,而不是先补齐再截断,如果理解反了,做表格对齐时列宽就会乱。标志位里0和-同时出现时,-优先,0被忽略,因为左对齐之后右边补零没有意义。和空格标志互斥,同时出现时生效。#标志对%o是加前导0,对%x是加0x,注意%#o在值为 0 时不会加前导零,这是个很多人踩过的小坑。2.3 动态宽度与长度修饰符:变参函数的类型提升规则宽度和精度都可以用*从参数列表里取,这在写通用表格输出函数时非常实用:void print_row(int w, int p, double v) { char b[64]; sprintf(b, %*.*f, w, p, v); /* 宽度 w,小数位 p,由参数动态指定 */ puts(b); }注意*对应的参数类型必须是int,不能用size_t或者别的整型,因为变参函数不会做隐式转换。这一点和后面的长度修饰符是同一个道理。长度修饰符决定了变参里参数的真实宽度。%d期望int,%ld期望long,%lld期望long long,%zu期望size_t,%jd期望intmax_t,%td期望ptrdiff_t,%Lf期望long double。在 LP64 平台(64 位 Linux、macOS)上long是 8 字节,在 LLP64 平台(64 位 Windows)上long是 4 字节,所以跨平台代码里用%ld打印long long会出问题。有一个细节被大量教程忽略:printf家族是变参函数,调用时会做默认实参提升。所有比int窄的整型(char、short、位域)都会提升成int,所有float都会提升成double。所以%f传一个float变量其实完全正常,因为它在进函数之前就已经变成double了;而%Lf就必须传真正的long double。反过来,%d传double是彻头彻尾的未定义行为——在 x86-64 上,浮点参数走的是 XMM 寄存器,整数参数走通用寄存器,sprintf去通用寄存器里取一个int,取到的可能是上一次调用的残留值,于是打印出一个毫无意义的随机数字。这种 bug 最折磨人,因为它不崩,只是数值不对。2.4 末位孤立的百分号:一个非常隐蔽的崩溃sprintf(buf, 进度 100%);这行代码是未定义行为。格式串末尾的%后面没有类型字符,sprintf会继续往后读,把栈上的任意字节当作格式说明符来解析,轻则输出乱码,重则因为读到%n之类的字符而写坏内存。正确写法是进度 100%%。这个坑之所以常见,是因为它在某些编译器和某些字符串内容下恰好没事,等到某天栈上那个位置的字节变成了一个能触发转换的值,程序才崩。开启-Wformat之后,GCC 和 Clang 对字面量格式串里的孤立%会给出警告,所以格式串是字面量时一定要把警告打开,这是成本最低的一道防线。如果格式串来自变量,编译器就无能为力了,那属于后面要讲的安全问题。3. 缓冲区开多大:从凭感觉估算到精确计算3.1 按占位符推最坏长度sprintf不检查边界,那么容量就必须由调用者算准。思路是先算出每个占位符在最坏情况下会输出多少个字符,再加上字面量长度和结尾的\0。整数类占位符的最坏长度可以直接从类型的取值范围推出来:占位符最坏示例字符数(含符号)建议预留%d-21474836481112%u42949672951011%xffffffff89%o377777777771112%ld(LP64)-92233720368547758082021%lld-92233720368547758082021%zu184467440737095516152021%e-1.797693e3081524(按%.17e估)%g-1.79769e3081524%p0xffffffffffffffff182 2*sizeof(void*) 1%s取决于内容strlen(s)strlen(s) 1浮点数用%f是最容易估算失误的。double的最大值约1.7976931348623157e308,用%f展开时整数部分有 309 位,加上小数点、默认 6 位小数、可能的负号,合计约 317 个字符。也就是说一个%f最坏能吃掉 320 字节左右,而在日常测试里你只会看到3.140000这种 8 字符的输出,于是很容易开出char buf[32]。这个差距就是潜在的事故点。实际工程里更稳妥的做法是:凡是浮点参与格式化,缓冲区直接给 64 或 128 字节起步,不为了省几十字节去赌取值范围。如果确实要严谨,就按1 309 1 6 1 318来算,取 320 以上。%s是最不可控的一项,因为长度由运行时内容决定。这也是为什么拼接用户输入的字符串这类场景必须用snprintf,或者先strlen算总长再malloc。3.2 把容量计算写成可复用的常量与其每次心算,不如把常用占位符的最坏长度定义成宏,拼格式串的时候直接相加:#include limits.h #include stdio.h #define LEN_INT 12 /* %d */ #define LEN_UINT 11 /* %u */ #define LEN_HEX32 9 /* %x */ #define LEN_I64 21 /* %lld / %zu */ #define LEN_F64 320 /* %f */ #define LEN_PTR (2 2 * sizeof(void *) 1) /* id..., v...\n 的最坏占用 */ #define LINE_MAX_LEN (4 LEN_I64 4 LEN_F64 1 1) int main(void) { char line[LINE_MAX_LEN]; long long id -1; double v 3.5; int n sprintf(line, id%lld, v%f\n, id, v); if (n 0 || (size_t)n sizeof line) { /* 理论上不会到这里,但保留防御 */ return 1; } fputs(line, stdout); return 0; }这种写法看起来啰嗦,但好处是容量和格式串的对应关系被显式写出来了,后面有人往格式串里加一个%s,就会顺手去调整宏。比起在代码里散落一堆128、256这样的魔法数字,可维护性高得多。提示:即便是算准了的sprintf,也建议保留n sizeof buf的检查并配合snprintf使用。计算失误是人的问题,越界写入是语言的问题,两者叠加起来排查成本极高。4. 实战踩坑记录:几个只有遇到过才懂的故障4.1 一次栈溢出崩溃的完整排查过程先说一个比较典型的排查链路。现象是某个服务进程在跑了几个小时之后偶发退出,日志里最后一行是一条拼好的状态信息,看不出异常,coredump 显示的栈被破坏。第一步,先看崩溃点。bt出来发现栈已经乱了,函数调用链不完整,这基本可以判定为缓冲区越界写坏了返回地址,而不是空指针之类的干净崩溃。第二步,在可疑模块里搜所有sprintf、strcpy、strcat调用,重点看参数里有变长内容的那些。第三步,找到一条形如:char msg[256]; sprintf(msg, [%s] %s, tag, payload);payload来自网络对端,长度不可控。这就是根因。第四步,验证:把sprintf换成snprintf(msg, sizeof msg, ...),再用一个超长payload复现,确认不再崩溃,同时观察输出是否被截断、截断后下游解析是否还有问题——这一步很多人只做前半段,换完函数就以为完事,结果下游因为消息被截断而解析失败,变成另一个 bug。第五步,回头扫同类问题。这个模块里还有六七处同样的写法,只是触发条件没凑齐。把它们全部替换,并且加了一条编译选项-Wformat -Wformat-truncation(GCC 7 以上),让编译器帮忙盯住字面量格式串的截断风险。整个链路里最关键的一步其实是第五步。单个 bug 修起来很快,真正浪费时间的是同一模式的批量复制。看到一处sprintf拼变长内容,就默认整个工程里还有若干处,这个经验能省很多次紧急排查。4.2 参数类型对不上时的静默错误比崩溃更难查的是不崩但结果不对的那类问题。整理了几种高频情况:现象错误的写法根因正确写法打印出巨大的正数%u传负的int位模式按无符号解释检查类型是否本该用%d打印出随机数%d传double变参类型不匹配,取错寄存器用%f,float会提升为double大整数被截断%d传long long只读了低 32 位用%lld64 位值高位丢失%ld传long long(Windows)LLP64 下long是 4 字节用%lld或PRId64打印size_t变负数%d传size_t无符号被当有符号读用%zu字符串只输出前几个字符%c传了char *类型完全错了用%s跨平台代码里我一般直接用inttypes.h提供的宏,避免自己去记long到底是 4 字节还是 8 字节:#include inttypes.h #include stdio.h int64_t big INT64_MIN; uint64_t ubig UINT64_MAX; size_t sz sizeof(int); char b[128]; sprintf(b, % PRId64 / % PRIu64 / %zu\n, big, ubig, sz);PRId64会展开成lld或者ld,取决于平台。这套宏看着丑,但它把平台差异封装掉了,比手写%lld安全。代价是可读性差一些,我在项目里通常配合一句注释说明它对应的类型。4.3 平台之间的格式串差异一个老生常谈但至今仍会遇到的问题:某些老版本编译器不支持%lld,要用%I64d。如果代码需要在那类环境下编译,就得用条件编译隔离:#if defined(_MSC_VER) _MSC_VER 1400 # define FMT_I64 I64d #else # define FMT_I64 lld #endif另一个差异是%p的输出格式。标准只说实现定义,glibc 一般输出0x加小写十六进制,某些实现输出纯十六进制不带前缀,还有的输出大写。如果要把指针值输出给下游程序解析,别依赖固定格式,自己用%zx配合强制转换更可控:sprintf(b, 0x%zx, (size_t)(uintptr_t)ptr);还有一个更隐蔽的差异:long double在 x86 上是 80 位扩展精度(存储占 16 字节),在 ARM 上通常就是 64 位double,在部分平台是 128 位。用%Lf的输出位数因此不同,如果下游按固定宽度解析,可能对不上。跨平台传输数据时,浮点一律先转成定点整数或者用明确的十进制字符串,不要指望%Lf的形态一致。5. 迁移到 snprintf:不只是换个函数名5.1 返回值语义的变化与截断判定snprintf的原型多了一个容量参数:int snprintf(char *str, size_t size, const char *format, ...);它最多写入size - 1个字符,然后补\0。返回值是假如缓冲区足够大,本应写入的字符数,不包含\0。这个语义非常关键,也是从sprintf迁移时最容易理解错的地方。因为它返回的是应有的长度,所以判断截断就变得很直接:char b[32]; int n snprintf(b, sizeof b, %s/%s, dir, file); if (n 0) { /* 编码错误 */ } else if ((size_t)n sizeof b) { /* 被截断了,实际只写入了 sizeof b - 1 个字符 */ }注意比较用的是而不是。当n sizeof b时,说明需要sizeof b 1个字节才能放下,当前缓冲区刚好差一个\0,属于截断。这个边界条件写错的人不少,结果就是偶尔输出一个不完整但没有终止符的字符串,后面strlen直接跑飞。还有一点:snprintf在size 0时是安全的,什么都不写,但可以正常返回所需长度。这个特性被广泛用于探测长度。5.2 两段式调用与工程里常用的封装知道了snprintf返回应有长度,就可以先探长度再分配,彻底摆脱固定大小的缓冲区:#include stdio.h #include stdlib.h char *make_line(const char *tag, const char *payload) { int need snprintf(NULL, 0, [%s] %s, tag, payload); if (need 0) return NULL; char *p malloc((size_t)need 1); if (!p) return NULL; int n snprintf(p, (size_t)need 1, [%s] %s, tag, payload); if (n ! need) { free(p); return NULL; } /* 理论上不会发生 */ return p; }代价是格式化要做两遍。对于性能敏感的热点路径,这个开销不能忽略;对于日志、配置生成这类低频场景,完全值得,因为它把容量算错这个类别的问题彻底消掉了。工程里更常见的做法是封装一个带追加语义的函数,内部统一用vsnprintf,失败时返回错误码而不是崩溃:#include stdarg.h #include stdio.h /* 返回 0 成功,-1 失败(容量不足或编码错误) */ int appendf(char *buf, size_t cap, size_t *off, const char *fmt, ...) { if (*off cap) return -1; va_list ap; va_start(ap, fmt); int n vsnprintf(buf *off, cap - *off, fmt, ap); va_end(ap); if (n 0) return -1; if ((size_t)n cap - *off) { *off cap - 1; return -1; } *off (size_t)n; return 0; }把变参统一收敛到一个函数里,还有个额外好处:可以给它加__attribute__((format(printf, 4, 5)))(GCC/Clang)或者对应的 MSVC 标注,让编译器用和printf相同的规则检查格式串和参数,类型不匹配、数量不够都会直接报警。5.3 迁移时容易漏掉的三个点第一个是截断后的下游兼容。把sprintf换成snprintf之后,程序不崩了,但输出可能变成半截。如果这个输出是给某个解析器用的,半截 JSON 或者半截 SQL 反而更危险。所以迁移时必须同时确认:截断了应该报错、还是应该换用动态分配、还是应该加大缓冲区。这属于业务判断,不是替换函数能解决的。第二个是sprintf到snprintf的可变参数求值次数。两段式调用会让参数表达式执行两次,如果参数里有i或者函数调用,行为就变了。稳妥的写法是先把值取到局部变量,再参与格式化。第三个是旧代码里的有意越界。极少数情况下,有人故意用sprintf写到某个固定偏移,靠的还是相邻变量的内存布局。这种代码迁移到snprintf后行为会变,必须整段重写,不能机械替换。遇到这种代码,我的做法是先读懂它想干什么,再用显式数组和下标重写一遍,顺便把那段布局依赖也去掉。6. 真正用得上的场景:把 sprintf 当工具而不是万能胶6.1 十六进制转储与协议报文拼装把一块二进制数据转成可读的十六进制字符串,是sprintf最合适的场景之一,因为每个字节的输出宽度是确定的,容量可以精确算出来:每个字节两位十六进制加一个分隔符,再加结尾。#include stdio.h #include stddef.h /* 输出形如 DE AD BE EF ,dst 至少 3*n1 字节 */ void hex_dump(char *dst, size_t cap, const unsigned char *src, size_t n) { size_t off 0; dst[0] \0; for (size_t i 0; i n; i) { int w snprintf(dst off, cap - off, %02X , src[i]); if (w 0 || (size_t)w cap - off) { /* 放不下了,停止 */ dst[cap - 1] \0; return; } off (size_t)w; } }%02X里的2是宽度,0是补零标志。不要写成%2X,那样空格字节0x0A会变成 A,解析端按固定两位切分就错位了。这个细节我在协议调试里踩过一次,现象是某些报文解析乱套,查了很久才发现是空格和0的区别。拼装协议报文时有个额外注意点:%s拼进去的字符串如果来自外部,可能包含%、换行、控制字符。格式化本身不会解析参数里的%(只有格式串里的%才有特殊含义),但换行和\0会破坏报文结构。所以对不可信内容,先做一次转义或者白名单过滤,再交给sprintf。6.2 定宽表格与日志对齐做命令行工具输出表格,sprintf的宽度控制比手写补空格清爽得多:#include stdio.h typedef struct { const char *name; int count; double ratio; } Row; void print_table(const Row *rows, int n) { char line[128]; sprintf(line, %-12s %8s %10s\n, NAME, COUNT, RATIO); fputs(line, stdout); for (int i 0; i n; i) { sprintf(line, %-12s %8d %10.2f\n, rows[i].name, rows[i].count, rows[i].ratio); fputs(line, stdout); } }这里缓冲区开 128,是因为名字字段理论上可能超过 12 字符。%-12s只保证至少 12 列,不会截断,名字太长会把后面的列推过去。如果需要严格截断,用%-12.12s。日志场景同理,常见格式是时间戳 级别 模块 内容,%-5s对齐级别可以让日志在终端里好看很多。日志用sprintf有个额外建议:不要在格式串里拼用户可控的字符串作为格式部分,只作为%s的参数。这一点下一节展开。6.3 用户输入参与格式串时的处理最危险的一种写法:char out[256]; sprintf(out, user_input); /* 错误示范 */如果user_input里含有%s,sprintf就会去读一个不存在的char *,大概率崩;如果含有%n,它会往一个由参数指定的地址写数据。这类问题属于典型的格式化字符串缺陷,不需要用户能控制参数,只要控制格式串就够了。正确做法永远是格式串固定,内容走参数:char out[256]; snprintf(out, sizeof out, %s, user_input);只要格式串是字面量,这个类别的风险就基本消掉了。代码评审时我会专门搜所有sprintf的第二个参数,凡是变量形式的一律打回,除了极少数确实要做格式串动态拼接的地方——那种地方应该被设计掉,而不是保留。7. 性能与替代方案:什么时候不该用 sprintf7.1 高频路径下的开销来源sprintf内部要做的事情比想象中多:解析格式串、处理宽度和精度、查 locale 决定小数点字符、处理浮点的舍入和进位。其中浮点格式化尤其贵,因为它要把二进制浮点转成十进制,涉及大整数运算。在一秒调用几十万次的循环里,这部分开销会明显到能被perf抓出来。我做过一个粗略对比,在同一台机器上把一百万个整数转成字符串:方式相对耗时说明sprintf(buf, %d, v)1.0(基准)通用、安全,但要解析格式串手写十进制转换约 0.2 ~ 0.3只处理int,不支持宽度和符号标志snprintf约 1.1多了容量检查,略慢一点十六进制手写查表约 0.15固定两位,只适合uint8_t结论不是不要用sprintf,而是热点路径上的纯数字转换可以用手写版本替换。手写版本的核心思路是反复取模取余,把余数倒序填进缓冲区,再处理符号:/* 仅处理 int,返回写入长度,不写 \0 */ static int fast_itoa(int v, char *dst) { char tmp[12]; int i 0, neg 0; unsigned u (unsigned)v; if (v 0) { neg 1; u (unsigned)(-(v 1)) 1u; } /* 避开 INT_MIN 取负溢出 */ do { tmp[i] (char)(0 u % 10u); u / 10u; } while (u); int n 0; if (neg) dst[n] -; while (i 0) dst[n] tmp[--i]; return n; }注意INT_MIN那一行。直接写-v在v INT_MIN时会溢出,属于未定义行为,这是手写转换最容易漏的地方。要么像上面那样先加一再取负,要么整体转成无符号再处理。如果编译器支持 C17,std::to_chars是更好的选择,它不分配内存、不做 locale 处理,速度比sprintf快一个数量级。纯 C 项目里没有这个,就只能自己在通用性和性能之间做取舍。7.2 几种替代方案的适用边界方案适用场景不适合的场景sprintf已确定容量绝对够、格式简单内容长度不可控snprintf绝大多数场景,默认选择极热点的纯数字转换asprintf(GNU)需要动态分配、不想算长度非 GNU 环境、错误处理要求严格strcatitoa单纯拼接、无格式需求需要宽度/精度控制手写转换热点循环、固定类型需要浮点、需要宽度标志std::format(C20)C 项目、需要类型安全纯 C、老旧编译器asprintf用起来很省事,失败返回 -1,成功返回长度并把指针写进参数,但它不是标准函数,在 Windows 上通常没有。跨平台项目里我用两段式snprintf代替它,行为一致,只是代码多几行。还有一个常被忽略的点:snprintf的容量参数是size_t,如果你传的是int变量并且可能是负数,转换到size_t会变成一个巨大的值,边界检查直接失效。这种 bug 在把int len用作容量的老代码里很常见。开启-Wsign-conversion能抓出一部分,但最好的办法还是从源头就用sizeof buf,让编译器算出正确类型。8. 几个能立刻用上的习惯我自己写格式化的代码,现在固定遵循几条规矩,踩过的坑基本都能覆盖到。第一,新代码一律用snprintf,容量参数写sizeof buf,不写数字,这样后续改数组大小时不会漏改。第二,格式串永远是字面量,内容一律走参数,绝对不允许变量出现在第二个参数的位置。第三,所有sprintf的返回值都要用起来或者显式忽略,拿它做偏移累加时必须配上界检查,否则不如不写。第四,编译选项里常开-Wall -Wformat -Wformat-truncation,这三项组合能拦下大部分格式串与类型不匹配的问题,成本几乎为零。再补一个小技巧:调试阶段可以临时把缓冲区全部填成0xCC,格式化之后检查结尾的\0之后是否还有0xCC,如果没有了,说明写穿过了边界。这个办法比等崩溃来得直接,特别适合在没有内存检测工具的嵌入式环境里用。当然,能上AddressSanitizer的环境还是优先用它,一句-fsanitizeaddress就能把越界写当场拦下来,定位速度比手工填填充值快得多。至于sprintf本身,它并没有不能用的原罪,问题一直出在谁来保证缓冲区够大这件事上。只要容量是可控的、格式串是固定的、类型是对得上的,它依然是最省事的那把螺丝刀。真正需要警惕的,是那种先跑通再说的心态——sprintf恰好是对这种心态惩罚最重的函数之一。