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返回long,atoll返回long long,逻辑完全一样,只是目标类型不同。
这三个函数最大的问题在于没有任何错误反馈机制。如果字符串是"abc",它返回 0;如果字符串是"0",它也返回 0;如果数字超出int范围,行为是未定义的——在大多数实现上会截断或回绕,但标准并不保证。这意味着你无法区分“转换成功得到 0”和“转换失败返回 0”。
我见过不少代码这样写:
int port = atoi(get_config("port")); if (port == 0) { // 认为配置有问题 }这段代码有个致命漏洞:如果配置里写的就是0,或者配置项根本不存在返回了空字符串,都会被当成“配置有问题”。更糟的是,如果配置是"999999999999",atoi可能返回一个完全意想不到的值,而程序毫无察觉。
那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 long,strtoul返回unsigned long,strtoull返回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唯一能用的场景是你已经精确计算过最大长度,并且确保缓冲区足够大。但即便如此,维护时格式串一改,长度假设就可能失效。我的建议是:新代码一律用 snprintf,sprintf 只出现在维护老代码时。
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_t | size_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就不合适,得自己写循环或者用strsep(BSD 系)或手动扫描。
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 万个整数,结果大致如下:
| 函数 | 相对耗时 | 适用场景 |
|---|---|---|
| atoi | 1.0x | 输入确定合法,追求极致速度 |
| strtol | 1.3x | 需要错误检测 |
| stoi | 1.8x | C++ 项目,异常可接受 |
| sscanf | 3.5x | 复杂格式解析,非热路径 |
| stringstream | 8.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 需求与设计
假设我们要解析一个简单的配置文件,每行格式是key=value,value 可能是整数、浮点数或字符串。要求:支持注释行(#开头)、忽略空行、对数值做合法性校验、错误时给出具体行号和原因。
设计思路:逐行读取,用strtok或手写分割拿到 key 和 value,根据 key 的类型用strtol或strtod转换,转换失败时记录错误。这里我选择手写分割而不是strtok,因为要保留 value 里的等号(比如url=http://x?a=b)。
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 测试用例与边界验证
写完解析代码后,我习惯用一组边界用例验证:
# 正常情况 port=8080 timeout=3.14 # 边界情况 port=0 port=2147483647 port=2147483648 # 溢出,应报错 port=-1 # 负数,取决于业务是否允许 port=abc # 非数字,应报错 port=8080extra # 尾部垃圾,应报错 port= 8080 # 前导空格,trim 后正常 timeout=1e10 # 科学计数法 timeout=inf # 特殊值,看是否接受每个用例都要实际跑一遍,确认错误信息正确、行号准确、程序不崩溃。特别是溢出用例,很多实现会在这里出问题。
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指向的字符、返回值的十六进制表示一起打印出来,信息量比只看返回值大得多。我一般会写一个调试宏,在开发阶段打开,发布时关掉,既不污染生产代码,又能快速定位问题。