
1. 字符串与数字互转的完整技术地图C/C 里字符串和数字之间的转换看起来是最基础的东西但真正在项目里踩过坑的人都知道这里面藏着大量细节溢出怎么处理、错误怎么检测、进制怎么指定、线程安不安全、性能差多少。标题里列出的这一串函数——atoi、strtol、stoi、atol、strtoll、strtoul、strtoull、atof、strtof、strtod、sscanf、sprintf、snprintf、strtok——基本覆盖了日常开发中字符串与数字互转、以及字符串切分的全部主力工具。这篇文章面向的是所有写 C/C 的人不管你是刚学完指针的新手还是写了多年服务端、嵌入式、算法题的老手只要你的代码里出现过atoi(argv[1])或者sscanf(buf, %d, n)这篇内容就值得你花时间过一遍。我会把这一串函数按“字符串转数字”“数字转字符串”“字符串切分”三条线拆开讲清楚每个函数的适用场景、内部行为、错误处理方式以及那些文档里不会明说、但线上事故里反复出现的坑。核心关键词字符串转整数、字符串转浮点、安全转换、溢出检测、格式化输出、字符串切分。先说结论性的判断方便你带着预期往下读能用strtol系列就不要用atoi系列能用snprintf就不要用sprintf能用strtok_r就不要用strtokC 里stoi系列方便但异常开销和错误语义要心里有数。这些结论背后的原因就是下面要展开的全部内容。2. 字符串转整数从 atoi 到 strtoull 的选型逻辑2.1 atoi 系列为什么“能用但危险”atoi、atol、atoll这一族函数签名极其简单int atoi(const char *nptr); long atol(const char *nptr); long long atoll(const char *nptr);调用方只需要传一个字符串返回一个整数看起来人畜无害。但它的设计缺陷恰恰藏在这份“简单”里。第一个问题是无法区分“转换失败”和“转换结果是 0”。atoi(0)返回 0atoi(abc)也返回 0atoi()还是返回 0。如果你的业务逻辑里 0 是合法值那你就永远不知道这个 0 到底是用户输入的还是解析失败兜底出来的。这在配置解析、协议解析场景里是致命的。第二个问题是溢出行为未定义。C 标准对atoi溢出时的行为没有强制规定glibc 的实现里它内部其实调用了strtol然后截断成int也就是说atoi(999999999999)在 64 位平台上会先被strtol解析成long再强转int结果是被截断的垃圾值而不是饱和到INT_MAX。不同 libc 实现可能给出不同结果跨平台代码里这就是个定时炸弹。第三个问题是没有 endptr无法知道解析停在哪。atoi(123abc)返回 123但你没法知道后面还有abc没被消费。做严格校验的时候这就很被动。所以我的经验是atoi只适合两种场景——一是你已经百分百确定输入合法比如自己刚sprintf出来的字符串二是写一次性脚本、算法题里图省事。生产代码里尤其是处理外部输入的地方一律换成strtol系列。2.2 strtol 系列错误检测的完整方案strtol家族是字符串转整数的“正规军”签名如下long int strtol(const char *nptr, char **endptr, int base); long long int strtoll(const char *nptr, char **endptr, int base); unsigned long int strtoul(const char *nptr, char **endptr, int base); unsigned long long int strtoull(const char *nptr, char **endptr, int base);三个参数各有讲究我逐个拆。base 参数是最容易被低估的。传 0 表示自动推断0x开头按十六进制0开头按八进制其余按十进制。传 10 就是强制十进制传 16 就是强制十六进制合法范围是 2 到 36。这里有个经典坑解析用户输入的08时如果你传 base0它会被当成八进制而8不是合法八进制数字解析会在0处停止endptr 指向8返回值为 0。很多解析日期、编号的代码就栽在这。处理用户输入时除非你明确要支持多进制否则一律传 10。endptr 参数是错误检测的关键。它的用法是char *end; errno 0; long val strtol(input, end, 10); if (end input) { /* 没有任何数字被解析输入非法 */ } if (*end ! \0) { /* 数字后面还有残留字符输入不干净 */ } if (errno ERANGE) { /* 溢出val 被置为 LONG_MAX 或 LONG_MIN */ }注意这里必须在调用前把 errno 清零因为strtol只在出错时设置 errno成功时不会清除它。如果你不清零上一次系统调用留下的 ERANGE 会误判成这次溢出。这个细节我在 code review 里见过太多次被漏掉。溢出处理是strtol相比atoi最大的优势。溢出时它返回LONG_MAX或LONG_MIN对应strtoul是ULONG_MAX同时设置errno ERANGE。这意味着你可以安全地做范围检查而不是拿到一个被截断的垃圾值。2.3 无符号版本的隐藏陷阱strtoul和strtoull有个反直觉的行为它们接受负号。strtoul(-1, NULL, 10)不会报错而是返回ULONG_MAX因为标准规定无符号转换是按模运算进行的。这在解析端口号、长度字段时是个大坑——用户输入-1你以为会失败结果拿到一个巨大的正数后续分配内存直接崩。正确的做法是解析前先检查字符串首字符或者用strtol解析后再判断范围char *end; errno 0; long tmp strtol(input, end, 10); if (errno ERANGE || tmp 0 || tmp UINT16_MAX || *end ! \0) { /* 非法端口号 */ } uint16_t port (uint16_t)tmp;多写几行但换来的是可控的行为。2.4 C 的 stoi 系列方便与代价C11 引入了std::stoi、std::stol、std::stoll、std::stoul、std::stoull签名是int stoi(const std::string str, size_t* pos nullptr, int base 10);它比strtol好用的地方在于直接吃std::string不用.c_str()而且通过pos参数返回已处理字符数。但它的错误处理是抛异常非法输入抛std::invalid_argument溢出抛std::out_of_range。这里有两个实战要点。第一异常在热路径上是有成本的如果你的解析函数每秒被调用几十万次异常抛出/捕获的开销即使不抛异常表的维护也有成本可能成为瓶颈这种场景还是strtol更稳。第二stoi的溢出判断是可靠的它内部会检查范围并抛out_of_range比atoi安全得多所以在非热路径的 C 代码里stoi是完全可以放心用的。还有一个细节stoi会跳过前导空白这点和strtol一致但它不会像strtol那样处理0x前缀除非 base0 或 16。用的时候心里要有数。3. 字符串转浮点atof、strtof、strtod 的精度与错误处理3.1 atof 的定位与局限atof等价于strtod(nptr, NULL)返回double。它同样有atoi的所有毛病无法检测错误、溢出行为不明确。atof(abc)返回 0.0atof(1e999)返回HUGE_VAL即无穷大但你无从得知这是溢出还是用户真的输入了inf。浮点解析比整数解析更微妙的地方在于精度。atof返回double如果你需要float得自己强转而强转可能引入额外的舍入误差。如果原始字符串的精度超过float能表示的范围直接(float)atof(s)和strtof(s, NULL)在极端情况下结果可能不同因为strtof是直接按float精度做正确舍入的。3.2 strtod 与 strtof正确舍入与错误检测double strtod(const char *nptr, char **endptr); float strtof(const char *nptr, char **endptr); long double strtold(const char *nptr, char **endptr);这三个函数的核心价值有两个。第一是正确舍入标准要求它们返回最接近输入十进制值的可表示浮点数也就是所谓的 correctly rounded。这在金融、科学计算场景里很重要atof虽然实现上通常也调strtod但语义上没有这个保证。第二是完整的错误检测和strtol一样通过endptr和errnochar *end; errno 0; double val strtod(input, end); if (end input) { /* 无有效数字 */ } if (errno ERANGE) { /* 上溢返回 HUGE_VAL下溢返回接近 0 的值 */ }浮点的 ERANGE 有个特殊之处下溢也会设置 ERANGE。当输入是一个极小的数比如1e-400结果会变成 0 或次正规数同时 errno 被置为 ERANGE。很多代码只检查上溢忽略了这一点导致精度丢失被静默吞掉。3.3 浮点解析的实战注意事项第一locale 影响小数点。strtod的行为受当前 locale 影响在某些 locale 下小数点可能是逗号而不是点。如果你的程序会setlocale解析网络协议里的浮点数时要么先切回Clocale要么自己写解析逻辑。这个坑在做国际化软件时特别常见。第二十六进制浮点。C99 起strtod支持0x1.8p3这种十六进制浮点表示等于 1.5 × 2³ 12。如果你不希望用户输入被这样解析需要自己校验格式。第三性能。strtod是出了名的慢因为它要做任意精度的中间计算来保证正确舍入。如果你的场景是解析大量已知格式简单的浮点数比如日志里的固定小数位自己写一个快速解析器可能快几倍。但除非 profiling 证明它真的是瓶颈否则不要过早优化。4. 数字转字符串sprintf 与 snprintf 的安全边界4.1 sprintf 的缓冲区溢出风险sprintf把格式化结果写进调用者提供的缓冲区但它不检查缓冲区大小。这是 C 语言历史上最著名的安全漏洞来源之一char buf[16]; sprintf(buf, %s, user_input); /* user_input 超过 15 字节就溢出 */栈上的缓冲区溢出可以被利用来执行任意代码这是很多经典漏洞的成因。现代编译器GCC/Clang会对sprintf发出-Wformat-overflow警告但警告不等于强制很多人直接忽略。我的原则很简单新代码里sprintf一次都不该出现。它的所有功能snprintf都能覆盖没有任何理由再用它。4.2 snprintf 的正确用法与返回值陷阱int snprintf(char *str, size_t size, const char *format, ...);snprintf最多写size字节包括结尾的\0保证不溢出。但它的返回值语义是新手最容易搞错的返回值是“假如缓冲区足够大本应写入的字符数”不含\0而不是实际写入的字符数。如果返回值 size说明发生了截断输出不完整。这意味着正确的截断检测是char buf[32]; int n snprintf(buf, sizeof(buf), %s-%d, name, id); if (n 0) { /* 编码错误 */ } else if ((size_t)n sizeof(buf)) { /* 被截断了buf 里是不完整内容需要处理 */ }很多代码只判断n 0把截断当成成功结果拿到半截字符串继续用引发后续解析错误。这个 bug 在日志拼接、SQL 构造场景里特别隐蔽。还有一个细节size传 0 时snprintf不写任何东西但仍然返回本应写入的长度。这个特性可以用来先计算所需缓冲区大小int needed snprintf(NULL, 0, %s-%d, name, id); char *buf malloc(needed 1); snprintf(buf, needed 1, %s-%d, name, id);这是动态拼接字符串的标准套路比拍脑袋定一个char buf[1024]靠谱得多。4.3 格式化输出的性能与替代方案snprintf功能强大但相对慢因为它要解析格式串、处理各种类型。在性能敏感的场景比如高频日志、序列化有几个替代思路。一是整数转字符串自己写。把一个int转成十进制字符串用除法和取模手写循环比snprintf快好几倍。itoa 不是标准函数但自己实现十几行就够了。二是 C 里用std::to_string可读性好但性能一般而且它内部也是走sprintf那套浮点格式化尤其慢。三是 C17 的std::to_chars这是目前最快且无 locale 依赖的方案不分配内存、不抛异常直接写进你给的缓冲区char buf[32]; auto [ptr, ec] std::to_chars(buf, buf sizeof(buf), 12345); if (ec std::errc()) { std::string_view sv(buf, ptr - buf); }如果你的编译器支持 C17处理数字转字符串的首选就是它。5. sscanf 与 strtok解析与切分的实战技巧5.1 sscanf 的便利与陷阱sscanf从字符串里按格式提取数据写起来很直观int a, b; sscanf(10 20, %d %d, a, b);但它有几个必须知道的坑。第一返回值是成功匹配的项数不是字符数。sscanf(abc, %d, a)返回 0表示没有成功匹配任何项。判断解析是否完整要检查返回值是否等于期望的项数。第二%s不检查目标缓冲区大小和sprintf一样危险。要限制宽度%31s表示最多读 31 个字符留一个给\0。这个宽度必须写死不能用变量所以缓冲区大小和格式串要手动保持一致容易出错。第三%d溢出行为未定义。sscanf(999999999999, %d, a)的结果不可靠。要安全解析还是得用strtol。第四%n可以获取已读取字符数用来判断是否解析完整int a, consumed; if (sscanf(input, %d%n, a, consumed) 1 input[consumed] \0) { /* 完整解析 */ }但%n在某些安全编码规范里被禁用因为它可能被格式化字符串漏洞利用用之前确认团队规范。5.2 strtok 的全局状态问题strtok用来按分隔符切分字符串用法是反复调用直到返回 NULLchar *tok strtok(str, ,); while (tok) { /* 处理 tok */ tok strtok(NULL, ,); }它最大的问题是使用静态内部状态这意味着两件事一是不可重入二是非线程安全。如果你在一个函数里切分字符串调用的子函数里又用了strtok外层的切分状态就被破坏了这是非常隐蔽的 bug。解决方案是strtok_rPOSIX或strtok_sC11 Annex K但支持度参差char *saveptr; char *tok strtok_r(str, ,, saveptr); while (tok) { /* 处理 */ tok strtok_r(NULL, ,, saveptr); }saveptr由调用者提供状态不再共享可重入且线程安全。任何多线程代码里strtok都不该出现。5.3 strtok 会修改原字符串strtok在切分时会把分隔符原地替换成\0所以它要求第一个参数是可写的。传字符串字面量strtok(a,b,c, ,)是未定义行为可能直接段错误。如果你需要保留原字符串先strdup一份。另外strtok会跳过连续的分隔符a,,b按,切分得到a和b中间的空字段被丢弃。如果你的格式里空字段有意义比如 CSVstrtok就不合适得自己写解析或者用strsep它会返回空字段但同样修改原串。6. 常见问题与排查技巧实录6.1 转换函数选型速查表需求推荐函数避免使用关键理由字符串转 int需错误检测strtol 范围检查atoi可检测溢出和非法输入字符串转 intC 非热路径std::stoiatoi异常语义清晰字符串转无符号strtoul 负号检查strtoul直接用负号会被转成大正数字符串转 doublestrtodatof可检测 ERANGE数字转字符串snprintf/to_charssprintf防缓冲区溢出字符串切分单线程strtok_rstrtok可重入字符串切分需保留空字段strsep或手写strtokstrtok 跳过空字段6.2 高频踩坑与排查思路坑一errno 没清零导致误判溢出。现象是偶尔解析合法输入却报 ERANGE。排查检查调用strtol/strtod前是否errno 0。这是最高频的错误没有之一。坑二snprintf 截断被当成成功。现象是日志或 SQL 偶尔缺尾巴。排查检查是否判断了返回值 size。修复要么加大缓冲区要么用两段式snprintf动态分配。坑三strtok 在多线程里串数据。现象是切分结果偶尔错乱且难以复现。排查全局搜索strtok(全部换成strtok_r。坑四strtoul 解析负数。现象是端口号、长度字段变成天文数字。排查解析后加范围检查或解析前检查首字符是否为-。坑五locale 影响浮点解析。现象是某些环境下strtod(3.14)只解析出 3。排查确认程序是否调用了setlocale解析前临时切到Clocale。6.3 我个人的几条硬规矩写这类代码十几年我给自己定了几条不破的规矩分享出来供参考。第一所有来自外部的字符串转数字一律走strtol/strtod系列并且完整检查 endptr、errno、范围三件套。封装成一个工具函数全项目复用避免每处都手写检查还写漏。第二所有数字转字符串一律用snprintf并且检查返回值。缓冲区大小用sizeof而不是魔数动态拼接用两段式。第三strtok从我的代码库里彻底消失需要切分就用strtok_r需要保留空字段就手写或用strsep。第四C 项目里优先std::stringstoi/to_chars但热路径和需要精细错误控制的场景退回 C 风格函数。第五写单元测试覆盖边界空串、纯符号、前导空白、溢出值、负数转无符号、超长输入、locale 切换。这些边界才是 bug 的藏身之处正常输入反而不会出问题。这套规矩看起来啰嗦但它帮我挡掉了无数次线上事故。字符串和数字的转换是那种“写对了没人夸写错了要背锅”的代码值得多花那几分钟把错误处理写全。