2026/10/10 0:20:24

C/C++字符串与数值转换全解析:从atoi到strtod的选型与避坑指南

C/C++字符串与数值转换全解析:从atoi到strtod的选型与避坑指南 1. 字符串与数值互转的全景认知1.1 为什么这组函数值得单独拎出来讲C/C 里做字符串和数值之间的转换几乎每个项目都会碰到。从配置文件解析、命令行参数处理到日志分析、协议报文拆解这组函数无处不在。但恰恰因为它们太常见很多人只记住了atoi和sprintf遇到边界情况就翻车——溢出没检测、错误没处理、缓冲区被写爆线上出问题时排查半天才发现是转换环节埋的雷。我自己在做一个嵌入式数据采集模块时就吃过atoi的亏。传感器返回的字符串里混入了非数字字符atoi不报错直接返回 0结果采集到的温度值全变成了 0 度排查了一整天才定位到问题。从那以后我把这组函数彻底梳理了一遍也养成了根据场景选函数的习惯。这篇文章面向所有写 C/C 的开发者不管你是刚接触指针和字符数组的新手还是写了多年代码的老手都能从中找到之前忽略的细节。我会把这一组函数分成三大类来讲字符串转数值atoi、atol、atoll、atof、strtol、strtoll、strtoul、strtoull、strtof、strtod、stoi、数值转字符串sprintf、snprintf、字符串解析与分割sscanf、strtok。每一类都会讲清楚核心原理、参数含义、返回值陷阱、错误处理方式以及实际项目里怎么选、怎么用、怎么避坑。1.2 这组函数的分类逻辑先建立一个整体框架后面逐个拆解时就不会乱。这组函数按功能可以分成三条线分类函数核心用途典型场景字符串转数值简单版atoi、atol、atoll、atof快速转换不报错确定输入合法的场景字符串转数值安全版strtol、strtoll、strtoul、strtoull、strtof、strtod带错误检测和进制控制需要校验输入的场景字符串转数值C版stoi、stol、stod 等抛异常类型安全C 项目异常处理体系数值转字符串sprintf、snprintf格式化输出到缓冲区日志、报文拼装字符串解析sscanf从字符串按格式提取结构化文本解析字符串分割strtok按分隔符切分CSV、配置项解析这个分类不是绝对的比如sscanf也能做数值转换strtol也能做部分解析。但按这个框架去理解选型时思路会清晰很多。提示简单版函数atoi 系列在输入非法时行为未定义或返回 0生产代码里除非你能百分百保证输入合法否则优先用安全版。2. 字符串转数值从 atoi 到 strtod 的选型与陷阱2.1 atoi、atol、atoll快但危险的“裸奔”转换atoi的函数原型是int atoi(const char *str)它扫描字符串跳过前导空白读取可选的正负号然后一直读到非数字字符为止把中间的数字部分转成int返回。atol返回longatoll返回long long逻辑完全一样只是目标类型不同。这三个函数最大的问题在于没有任何错误反馈机制。如果字符串是abc它返回 0如果字符串是0它也返回 0如果数字超出int范围行为是未定义的——在大多数实现上会截断或回绕但标准并不保证。这意味着你无法区分“转换成功得到 0”和“转换失败返回 0”。我见过不少代码这样写int port atoi(get_config(port)); if (port 0) { // 认为配置有问题 }这段代码有个致命漏洞如果配置里写的就是0或者配置项根本不存在返回了空字符串都会被当成“配置有问题”。更糟的是如果配置是999999999999atoi可能返回一个完全意想不到的值而程序毫无察觉。那atoi什么时候能用我的经验是只在输入来源完全可控、且已经做过前置校验的场景用。比如你从自己刚用snprintf写进去的缓冲区里读回来那用atoi没问题。但凡输入来自外部配置文件、网络、用户输入就别用。2.2 strtol 系列带错误检测的正规军strtol的原型是long strtol(const char *str, char **endptr, int base)。三个参数各有讲究str待转换的字符串。endptr指向char*的指针。函数会把“第一个未被转换的字符”的地址写进*endptr。如果你传NULL就不获取这个信息。base进制2 到 36 之间或者传 0 表示自动判断0x开头按十六进制0开头按八进制否则十进制。返回值方面成功时返回转换后的long值如果没做任何转换比如字符串开头就是非数字返回 0 并把endptr设为str如果溢出返回LONG_MAX或LONG_MIN并把errno设为ERANGE。这里的关键技巧是用 endptr 判断是否真的转换了用errno 判断是否溢出。一个健壮的转换函数应该这样写#include stdlib.h #include errno.h #include limits.h int parse_long(const char *str, long *out) { char *endptr; errno 0; long val strtol(str, endptr, 10); if (endptr str) { return -1; // 没有转换任何字符 } if (*endptr ! \0) { return -2; // 有尾部垃圾字符 } if (errno ERANGE) { return -3; // 溢出 } *out val; return 0; }注意几个细节调用前必须把errno清零因为strtol只在出错时设置errno不清零的话可能读到之前遗留的值。endptr str说明一个字符都没转换这是最严格的失败判断。*endptr ! \0说明字符串后面还有非数字内容是否算失败取决于你的业务需求——有时候你只想解析前缀那就允许尾部有内容。strtoll返回long longstrtoul返回unsigned longstrtoull返回unsigned long long用法完全一致。特别说一下strtoul它会把负号也接受然后按无符号回绕处理比如strtoul(-1, NULL, 10)返回ULONG_MAX。这个行为很多人不知道如果你明确不想要负数得自己检查字符串里有没有负号。2.3 strtod、strtof浮点数的安全转换浮点版本的strtod原型是double strtod(const char *str, char **endptr)strtof返回float。它们比整数版多支持科学计数法如1.5e10、inf、nan等特殊值。错误检测逻辑和strtol一样检查endptr和errno。浮点转换有个额外的坑精度丢失和舍入。比如strtod(0.1, NULL)得到的 double 并不是精确的 0.1而是最接近的可表示值。这在做金额计算时是致命的。我的建议是金额、计数这类需要精确的场景用整数分单位存储别用浮点。如果非要用浮点比较时用误差范围而不是。另一个坑是strtod对1e999这种超大指数的处理返回HUGE_VAL并设置errno为ERANGE。如果你不检查errno就会拿到一个无穷大值继续参与运算后面全是inf。2.4 stoi 系列C 的异常风格C 标准库提供了std::stoi、std::stol、std::stoll、std::stoul、std::stoull、std::stof、std::stod、std::stold。它们的签名是int stoi(const std::string str, size_t *pos 0, int base 10)。和 C 版本的区别在于转换失败时抛异常。如果没转换任何字符抛std::invalid_argument如果溢出抛std::out_of_range。pos参数用来返回第一个未处理字符的位置。#include string #include stdexcept bool parse_int(const std::string s, int out) { try { size_t pos; out std::stoi(s, pos); return pos s.size(); // 确保整个字符串都被消费 } catch (const std::invalid_argument) { return false; } catch (const std::out_of_range) { return false; } }用stoi的代价是异常开销。在高频调用的热路径上异常的性能不如strtol。我实测过一个解析百万行日志的场景用strtol比stoi快大约 30% 到 40%因为异常抛出和栈展开有成本。所以选型原则是业务逻辑层用 stoi 图省事性能敏感层用 strtol 图效率。注意stoi的pos参数默认是 0空指针如果你不关心位置可以不传。但如果你传了记得检查它是否等于字符串长度否则123abc会被成功解析成 123 而不报错。3. 数值转字符串sprintf 与 snprintf 的安全边界3.1 sprintf方便但容易写爆缓冲区sprintf(char *buf, const char *format, ...)把格式化结果写入buf。它的问题和atoi类似不检查目标缓冲区大小。如果格式化后的内容超过缓冲区容量就会发生缓冲区溢出这是经典的安全漏洞来源。char buf[10]; sprintf(buf, value%d, 123456); // 需要 13 字节溢出这种代码在代码审查里应该直接打回。sprintf唯一能用的场景是你已经精确计算过最大长度并且确保缓冲区足够大。但即便如此维护时格式串一改长度假设就可能失效。我的建议是新代码一律用 snprintfsprintf 只出现在维护老代码时。3.2 snprintf带长度限制的安全版本snprintf(char *buf, size_t size, const char *format, ...)最多写入size - 1个字符然后自动补\0。返回值是“假如缓冲区无限大本应写入的字符数”不含结尾的\0。这个返回值设计很巧妙你可以用它来判断是否被截断。char buf[16]; int n snprintf(buf, sizeof(buf), value%d, 123456); if (n (int)sizeof(buf)) { // 被截断了需要更大的缓冲区 }注意返回值的类型是int而size是size_t无符号。比较时要把n转成size_t或者把sizeof转成int否则无符号比较会出问题。我一般写成if (n 0 || (size_t)n sizeof(buf))把负数返回值编码错误也一并处理。snprintf有个容易忽略的行为当size为 0 时它不写任何东西但仍然返回本应写入的长度。这个特性可以用来先探测所需长度再分配缓冲区int needed snprintf(NULL, 0, name%s, age%d, name, age); char *buf malloc(needed 1); snprintf(buf, needed 1, name%s, age%d, name, age);这个技巧在处理不确定长度的拼接时非常实用避免了拍脑袋定缓冲区大小。3.3 格式化占位符的常见错误用sprintf/snprintf时格式串和参数类型不匹配是高频 bug。几个典型错误写法问题正确写法%d配long在 64 位平台上 long 是 8 字节会读错栈%ld%d配size_tsize_t 是无符号且可能是 8 字节%zu%f配double这个其实没问题float 会自动提升为 double%f正确%s配NULL解引用空指针崩溃先判空或用(null)%n写入已输出字符数到指针有安全风险避免使用%n这个占位符特别危险它会把当前已输出的字符数写到一个int*参数指向的位置。如果格式串来自外部输入攻击者可以用它改写内存。所以除非有特殊需求永远不要用%n。4. sscanf 与 strtok解析与分割的实战技巧4.1 sscanf从字符串里按格式“抠”数据sscanf(const char *str, const char *format, ...)和scanf类似只是从字符串而不是标准输入读取。它的返回值是成功匹配并赋值的项数这个返回值是判断解析是否成功的关键。int year, month, day; int n sscanf(2024-01-15, %d-%d-%d, year, month, day); if (n ! 3) { // 解析失败 }sscanf的格式串支持宽度限制比如%3d表示最多读 3 位数字。这个特性在解析固定宽度字段时很有用比如解析20240115这种紧凑日期int y, m, d; sscanf(20240115, %4d%2d%2d, y, m, d);但sscanf有几个坑。第一%s不限制长度遇到长字符串会溢出必须写成%31s这种带宽度限制的形式。第二它对空白字符的处理比较隐晦格式串里的空格会匹配任意数量的空白包括零个。第三解析失败时已经赋值的变量可能被部分修改所以最好用临时变量接收全部成功后再提交。我在解析一个自定义协议报文时用sscanf提取字段格式串是CMD:%d,LEN:%d,DATA:%s。结果 DATA 字段里如果包含逗号%s会一直读到空白为止把后面的内容全吞了。后来改成%[^,]这种字符集匹配才解决。%[...]是sscanf里很强大的特性%[^,]表示“读取直到遇到逗号”适合解析 CSV 这类分隔格式。4.2 strtok有状态的分割函数strtok(char *str, const char *delim)按分隔符切分字符串。第一次调用传待分割的字符串后续调用传NULL表示继续分割同一个字符串。它内部用一个静态指针记录位置所以不是线程安全的也不能嵌套使用。char line[] apple,banana,cherry; char *token strtok(line, ,); while (token ! NULL) { printf(%s\n, token); token strtok(NULL, ,); }strtok会修改原字符串把分隔符替换成\0。所以你不能传字符串字面量char *s a,b,c是只读的必须传可修改的字符数组。这个特性经常被忽略导致段错误。线程安全版本是strtok_r多一个char **saveptr参数用来保存状态char *saveptr; char *token strtok_r(line, ,, saveptr); while (token) { // 处理 token token strtok_r(NULL, ,, saveptr); }strtok_r在 POSIX 系统上可用Windows 上对应的是strtok_s。跨平台代码需要做条件编译。还有一个坑strtok会把连续的分隔符当成一个。比如a,,b按逗号分割得到的是a和b中间的空字段被跳过了。如果你需要保留空字段比如解析 CSV 时strtok就不合适得自己写循环或者用strsepBSD 系或手动扫描。4.3 手写分割函数的场景当strtok的行为不满足需求时手写一个分割函数往往更可控。比如要保留空字段、要支持多字符分隔符、要线程安全且可重入// 按单个分隔符分割保留空字段 char *next_token(char **p, char delim) { if (*p NULL) return NULL; char *start *p; char *end strchr(start, delim); if (end) { *end \0; *p end 1; } else { *p NULL; } return start; }这个函数用strchr找分隔符找到就替换成\0并更新指针找不到就返回剩余部分并把指针置空。逻辑简单行为可预测没有静态状态线程安全。我在处理配置文件时更倾向于用这种手写版本因为配置项里经常有连续分隔符表示空值的情况。5. 常见问题与排查技巧实录5.1 转换失败但没报错的排查思路最常见的现象是程序跑起来结果不对但没有任何报错。这时候按以下顺序排查检查输入字符串本身。打印出来看看有没有前导空格、不可见字符如\r、BOM 头。Windows 换行是\r\n如果按\n分割每行末尾会残留\r导致strtol解析失败。检查 errno。在调用strtol前清零errno调用后立即检查。如果忘了清零可能读到之前函数留下的错误码。检查 endptr。如果endptr str说明一个字符都没转换如果*endptr ! \0说明有尾部垃圾。检查溢出。大数转换时strtol返回LONG_MAX并设errno为ERANGE。如果你没检查就会拿到一个边界值继续算。我遇到过一个案例从文件读配置strtol总是返回 0。排查发现文件是 UTF-8 带 BOM 的开头有\xEF\xBB\xBF三个字节strtol看到第一个字节不是数字就返回 0 了。解决办法是读文件后跳过 BOM或者用支持 BOM 的解析库。5.2 缓冲区溢出的定位与预防snprintf用错也会出问题主要是长度参数传错。比如char buf[10]; snprintf(buf, 10, %s, long_string); // 正确 snprintf(buf, sizeof(buf), %s, long_string); // 更好如果传了比实际缓冲区大的值snprintf就会写越界。所以永远用sizeof(buf)而不是硬编码数字。如果buf是指针而不是数组sizeof会得到指针大小8 字节这时候必须显式传正确的长度。另一个预防手段是编译期检查。GCC 和 Clang 的-Wformat系列警告能发现格式串和参数类型不匹配的问题-Wformat-truncation能发现可能的截断。把警告级别开高很多问题在编译阶段就能暴露。5.3 性能对比与选型速查我做过一组简单的基准测试在同一个环境下解析 100 万个整数结果大致如下函数相对耗时适用场景atoi1.0x输入确定合法追求极致速度strtol1.3x需要错误检测stoi1.8xC 项目异常可接受sscanf3.5x复杂格式解析非热路径stringstream8.0x不推荐用于性能敏感场景这个数据不是绝对的编译器优化、平台、具体输入都会影响。但趋势是明确的atoi最快但最不安全strtol是安全与性能的平衡点sscanf和流式解析适合可读性优先的场景。选型时我的原则是先保证正确性再考虑性能。除非 profiling 明确显示转换是瓶颈否则用strtol系列就够了。真到了性能瓶颈再考虑手写解析或者用更快的第三方库。5.4 常见问题速查表现象可能原因排查方法解决方式转换结果总是 0输入有 BOM 或前导非数字打印输入字节跳过 BOM校验输入大数转换结果异常溢出未检测检查 errno ERANGE用 strtoll 或检查范围程序随机崩溃sprintf 缓冲区溢出用 Valgrind 或 ASan改用 snprintf分割结果少了字段strtok 跳过连续分隔符打印每个 token手写分割或保留空字段多线程下分割错乱strtok 非线程安全检查是否多线程调用改用 strtok_r浮点比较不相等精度丢失打印完整精度用误差范围比较提示调试转换问题时把输入字符串的每个字节用十六进制打印出来往往一眼就能看出问题。不可见字符是这类 bug 的头号元凶。6. 实战组合一个配置解析模块的完整实现6.1 需求与设计假设我们要解析一个简单的配置文件每行格式是keyvaluevalue 可能是整数、浮点数或字符串。要求支持注释行#开头、忽略空行、对数值做合法性校验、错误时给出具体行号和原因。设计思路逐行读取用strtok或手写分割拿到 key 和 value根据 key 的类型用strtol或strtod转换转换失败时记录错误。这里我选择手写分割而不是strtok因为要保留 value 里的等号比如urlhttp://x?ab。6.2 核心代码实现#include stdio.h #include stdlib.h #include string.h #include errno.h #include limits.h typedef struct { char key[64]; char value[256]; int line_no; } ConfigItem; // 去除首尾空白 static char *trim(char *s) { while (*s || *s \t) s; if (*s \0) return s; char *end s strlen(s) - 1; while (end s (*end || *end \t || *end \r || *end \n)) { *end-- \0; } return s; } // 安全解析整数 static int parse_int(const char *s, int *out) { char *endptr; errno 0; long v strtol(s, endptr, 10); if (endptr s || *endptr ! \0) return -1; if (errno ERANGE || v INT_MIN || v INT_MAX) return -2; *out (int)v; return 0; } // 安全解析浮点 static int parse_double(const char *s, double *out) { char *endptr; errno 0; double v strtod(s, endptr); if (endptr s || *endptr ! \0) return -1; if (errno ERANGE) return -2; *out v; return 0; } int load_config(const char *path) { FILE *fp fopen(path, r); if (!fp) return -1; char line[512]; int line_no 0; while (fgets(line, sizeof(line), fp)) { line_no; char *p trim(line); if (*p \0 || *p #) continue; char *eq strchr(p, ); if (!eq) { fprintf(stderr, line %d: missing \n, line_no); continue; } *eq \0; char *key trim(p); char *value trim(eq 1); // 根据 key 决定类型这里举例 if (strcmp(key, port) 0) { int port; if (parse_int(value, port) ! 0) { fprintf(stderr, line %d: invalid port %s\n, line_no, value); continue; } // 使用 port... } else if (strcmp(key, timeout) 0) { double timeout; if (parse_double(value, timeout) ! 0) { fprintf(stderr, line %d: invalid timeout %s\n, line_no, value); continue; } // 使用 timeout... } // 其他 key 按字符串处理 } fclose(fp); return 0; }这段代码里有几个值得说的点。trim函数处理了\r因为 Windows 换行会残留回车符这是跨平台解析的常见坑。parse_int里同时检查了endptr、errno和范围三重保险。用strchr找第一个等号而不是strtok这样 value 里可以包含等号。6.3 测试用例与边界验证写完解析代码后我习惯用一组边界用例验证# 正常情况 port8080 timeout3.14 # 边界情况 port0 port2147483647 port2147483648 # 溢出应报错 port-1 # 负数取决于业务是否允许 portabc # 非数字应报错 port8080extra # 尾部垃圾应报错 port 8080 # 前导空格trim 后正常 timeout1e10 # 科学计数法 timeoutinf # 特殊值看是否接受每个用例都要实际跑一遍确认错误信息正确、行号准确、程序不崩溃。特别是溢出用例很多实现会在这里出问题。7. 跨平台与编译器差异的注意事项7.1 Windows 与 Linux 的函数差异strtok_r在 Windows 上叫strtok_s参数顺序略有不同。snprintf在老的 MSVC 上叫_snprintf而且行为有差异_snprintf在截断时不保证补\0。VS2015 之后才提供了符合 C99 标准的snprintf。跨平台代码要么用宏做兼容要么用第三方库如safe_str_lib。strtoll和strtoull在 C99 才标准化老的 MSVC 用_strtoi64和_strtoui64。如果项目需要兼容老编译器得做条件编译。7.2 编译器优化对转换的影响GCC 和 Clang 在-O2以上会对atoi、strtol这类函数做内联优化特别是当格式串是编译期常量时。但优化也可能带来意外比如编译器可能把atoi的溢出行为优化成未定义行为导致结果和预期不符。所以依赖未定义行为的代码在优化后可能“变脸”。我的建议是不要依赖任何未定义行为。溢出就老老实实检查errno不要指望某个特定平台的回绕行为。7.3 本地化与数字格式strtod和sprintf的浮点格式化受 locale 影响。在某些 locale 下小数点可能是逗号而不是点。如果你的程序要处理国际化数据要么显式设置 locale 为C要么用不受 locale 影响的转换方式。strtod在解析3,14时如果 locale 是德语会解析成 3.14如果是 C locale会解析成 3 然后停在逗号处。这个差异在跨地区部署时经常引发 bug。提示在程序启动时调用setlocale(LC_NUMERIC, C)可以确保数字格式一致除非你确实需要本地化显示。8. 我个人的几条实战心得第一条永远不要相信外部输入。不管是配置文件、网络报文还是命令行参数在转换前都要做校验。strtol系列的三重检查endptr、errno、范围是标配少一个都可能埋雷。第二条缓冲区大小用 sizeof 而不是硬编码。snprintf(buf, sizeof(buf), ...)是肌肉记忆写多了就不会错。如果 buf 是指针那就得另外维护长度变量这时候封装一个结构体把指针和长度绑在一起会更安全。第三条性能优化放到最后。我见过太多项目一上来就用atoi图快结果线上出问题排查成本远高于那点性能收益。先用strtol保证正确profiling 确认是瓶颈后再针对性优化。第四条写单元测试覆盖边界。空字符串、纯符号、最大最小值、溢出值、带空白、带尾部垃圾这些用例写一遍以后改代码心里有底。我现在的习惯是每个解析函数至少配 10 个测试用例跑起来也就几毫秒但能挡住 90% 的低级错误。第五条注意字符串的生命周期。strtok返回的指针指向原字符串内部如果原字符串被释放或修改这些指针就悬空了。用strtok分割后如果需要长期保存 token必须拷贝一份。这个坑我在一个多线程日志处理模块里踩过token 指针指向的缓冲区被另一个线程复用了日志内容错乱查了好久。最后分享一个小技巧调试转换问题时把errno、endptr指向的字符、返回值的十六进制表示一起打印出来信息量比只看返回值大得多。我一般会写一个调试宏在开发阶段打开发布时关掉既不污染生产代码又能快速定位问题。