字符串长度这问题,说小真小,一个函数调出来就行;说大也真大,我见过太多线上事故,根子就出在“长度”两个字上搞混了。一个短信平台发中文内容,按字符数发结果按字节数计费;一个文件上传接口,前端校验用length放行了,后端Java再用length去截,直接截出一个半个汉字乱码;还有做嵌入式串口通信的同事,协议里长度字段没算对,设备端直接把一帧数据当垃圾扔了。这些都是“字符串长度”惹的祸。
更麻烦的是,不同语言、不同编码、不同工具对这个“长度”的定义压根不统一。有人用strlen拿到的是字节数,有人用len()拿到的是字符数,还有人用size()拿到的既不是字符数也不是字节数,而是UTF-16的“码元”个数。如果你不了解这些底层差异,写出来的代码就只能在“恰好不越界”的情况下跑得对,一旦遇到中文、emoji、多语言混合字符串,立刻翻车。
这篇东西我打算从几个角度把字符串长度彻底聊透:先讲清楚字符数、字节数、存储长度这三者的本质区别,再逐个分析主流语言里length到底返回了什么,然后落到截取、反转、排序、分割这些天天用的操作上,最后把底层串口通信和大模型上下文这两块特殊场景也带上。对写业务代码的、做底层开发的、搞数据处理的,都有参考价值。
1. 字符串长度的本质:字符数、字节数与存储长度
1.1 三者的定义与区别
先把基础概念掰开揉碎。字符串长度这个说法,在实际开发里至少对应着三种完全不同的数字:
字符数:从人类阅读的角度数出来的字符个数。"hello"是5个字符,"你好"是2个字符。这是最符合直觉的长度。
字节数:字符串在内存或存储介质上占据的字节数量。ASCII情况下1个字符刚好1字节,但一旦出现中文、日文、韩文、emoji,字符数和字节数就分道扬镳了。
存储长度:有些语言和数据库文档里说的“长度”既不是字符数也不是字节数,而是底层数组元素的个数。比如Java字符串内部是char[],每个char固定2字节,一个中文字符恰好占用1个char(前提是BMP平面内的字),但一个emoji需要2个char。这种情况下length()返回的是char数组元素个数,也就是UTF-16码元数量。
举一个实际例子:"你好😀"这个字符串,字符数是3(你、好、😀),在UTF-8编码下字节数是8(你3字节+好3字节+emoji 4字节),在Java里length()返回4(你1个char+好1个char+emoji占2个char),在Python里len()返回3,在Go里len("你好😀")返回8而utf8.RuneCountInString("你好😀")返回3。
同样一句话,四种长度数字摆在一起,不知道底层的人早就晕了。其实只要搞清楚一件事:编码决定了字节数,语言的数据模型决定了length的语义。
1.2 为什么UTF-8下“长度”会变来变去
UTF-8是目前互联网和Linux系统里最主流的编码方案,它的设计是变长的。根据Unicode码点范围,UTF-8采用不同长度的字节序列:
| Unicode码点范围 | 字节数 | 典型字符 |
|---|---|---|
| U+0000 ~ U+007F | 1字节 | ASCII英文字母、数字、半角标点 |
| U+0080 ~ U+07FF | 2字节 | 拉丁文扩展、希腊文、西里尔文 |
| U+0800 ~ U+FFFF | 3字节 | 绝大多数常用汉字、日文假名、韩文音节 |
| U+10000 ~ U+10FFFF | 4字节 | emoji、生僻汉字、部分扩展B区汉字 |
所以同样是10个字符,全英文10字节,全中文30字节,混着写可能27字节,再混几个emoji就可能40字节了。这种变长特性让“按字节下标去截取内容”这个操作变得极度危险,因为你完全不知道当前字节是不是一个多字节字符的中间位置。
GBK编码则是另一种思路:ASCII部分1字节,中文部分固定2字节,属于“定长双字节”与“变长”之间的折中,但它在处理emoji时无能为力,所以现在新系统基本都转向UTF-8或UTF-16了。
1.3 判断字符串长度的实际手段
既然三种长度不同,实际开发里就要有意识地明确自己到底需要哪种长度。我自己的习惯是:业务校验用字符数,网络协议和存储容量用字节数,底层数组操作严格按照语言自带的length语义。工具层面,几个常用手段:
xxd -p或者hexdump查看字符串的实际字节序列,这是排查编码问题最直接的武器。- Python里
len(s)是字符数,len(s.encode('utf-8'))是UTF-8字节数。 - Java里
s.length()是UTF-16码元数,s.getBytes(StandardCharsets.UTF_8).length是字节数,s.codePointCount(0, s.length())是Unicode字符数。 - 数据库里
CHAR_LENGTH()看字符数,OCTET_LENGTH()看字节数,别和LENGTH()混用,不同数据库这两个函数的语义天差地别。
理解到这一层,后面所有操作都是上层建筑。
2. 主流语言里length到底返回什么
2.1 C/C++:strlen、size()与宽字符的边界
C语言里字符串本质是char数组,以\0作为结束标志。strlen(s)的实现就是从头数到\0为止的字符个数,注意这里返回的其实是“不含结束符的字节数”。对于纯ASCII没问题,遇到UTF-8编码的中文,strlen返回的依然是字节数,不是字符数。
网上有不少用strlen统计“字符串有几个字”的代码,全英文场景一切正常,一旦有人输入了中文,统计结果直接翻倍。我曾经维护过一个老C模块,日志里用strlen计算固定字段宽度,结果中文日志把表格撑得乱七八糟,最后全部改成按UTF-8解析字符数才解决。
C++的std::string::size()同样返回字节数。C++11以后标准库提供了std::wstring,wcslen返回宽字符个数,但这个“宽字符”在Windows(UTF-16)和Linux(UTF-32)上的定义又不一样,跨平台撸一遍就是一堆坑。C++20开始有std::u8string,但它的size()依然返回的是字节数而不是字符数。所以在C/C++世界里,永远默认length是字节数,需要字符数时自己写UTF-8解码函数去数。
2.2 Python与Java/JS:码点与UTF-16的差异
Python 3的str类型直接以Unicode码点存储,len()返回的就是人类视角的字符数(更准确说是码点数),这是一个巨大的优势。但要注意,"你好😀"里的emoji在Python里算1个字符,而如果你试图通过下标访问s[2],拿到的也是完整的😀,因为Python的字符串索引按码点走。Python 2已经淘汰了,不需要多讲,新项目一律用Python 3。
Java和JavaScript则陷入了一个历史包袱:它们内部的字符串用UTF-16编码存储。所谓UTF-16,就是对大部分常用字符(BMP平面)用1个16位单元表示,超出范围的字符用“代理对”两个16位单元表示。所以:
- Java里
"😀".length()返回2,"😀".codePointCount(0, 2)才返回1。 - JavaScript里
"😀".length也是2,要用[...("😀")].length或者Array.from("😀").length按码点统计。 - Java的
charAt(0)遇到代理对会返回半个字符,肉眼看上去是个问号或乱码。
Go和Rust提供了更现代化的处理方式。Go的len("你好")返回字节数,但for i, r := range "你好"会按rune(Unicode码点)迭代,配合utf8.RuneCountInString获取字符数。Rust的str.len()同样返回字节数,用chars().count()获取字符数。
做个对比表:
| 语言 | 字符串长度API | 返回语义 | 中文常见值 |
|---|---|---|---|
| C | strlen(s) | 字节数 | “你好”=6 |
| C++ | std::string::size() | 字节数 | “你好”=6 |
| Java | s.length() | UTF-16码元数 | “你好”=2,emoji=2 |
| JavaScript | s.length | UTF-16码元数 | “你好”=2,emoji=2 |
| Python 3 | len(s) | Unicode码点数 | “你好”=2,emoji=1 |
| Go | len(s)/utf8.RuneCountInString | 字节数 / 字符数 | 字节=6,字符=2 |
| Rust | s.len()/s.chars().count() | 字节数 / 字符数 | 字节=6,字符=2 |
2.3 SQL与ABAP:数据库里的长度语义
数据库这块的坑更隐蔽。MySQL的VARCHAR(n)中n表示“字符数”而非字节数,这是MySQL 4.1以后的规定,但前提是表的字符集设置。utf8mb4字符集下VARCHAR(255)可以存255个汉字或255个emoji,底层占用的字节最长可达1020字节,所以在设计索引和估算表大小时不能用字符数直接乘3去算。
SQL Server的做法又不一样:LEN()返回字符数,但会忽略尾部空格;DATALENGTH()返回字节数。VARCHAR(n)里n是字节数,NVARCHAR(n)里n是字符数(UTF-16)。如果用错函数,做一个字符串截断比较,结果能让你查半天。
ABAP我接触得不多,但有同事踩过坑。strlen(ls_string)返回的是字符数,拼接到RFC接口按字节传输时,中文长度又得cl_abap_unicode_convert之类的工具去转。判断字符串是否含汉字,比较靠谱的做法是遍历每个字符,检查Unicode码点是否落在4E00~9FFF区间,或者用正则表达式匹配。网上搜“abap判断字符串含有汉字”,出来的方案大多是沿着这个思路写的。
3. 围绕长度展开的字符串操作实战
3.1 截取与半截字问题
截取字符串是重灾区。几乎每种语言都提供按“下标”截取的函数,但下标到底是字节下标、码元下标还是码点下标,直接决定结果是否正确。我先给一个C++的经典反面教材:
#include <iostream> #include <string> int main() { std::string s = "你好,世界"; // 目标:截取前4个字符,期望得到"你好," std::string sub = s.substr(0, 4); std::cout << sub << std::endl; // 输出:你� // 因为"你"占3字节,"好"占3字节,逗号占3字节, // 4字节切进去,刚好把"好"字从中间劈开了 }这种截出半个汉字的代码,如果直接写进文件或发到前端,轻则显示乱码,重则导致下游解析器崩溃。正确做法是先按字节流解析出完整的字符边界,再按字符数截取。C++里可以自己写一个按UTF-8序列判断的函数:根据每个字节的高位比特判断这是不是一个多字节字符的起始字节,凡是以10开头的都是连续字节。
Python就没有这个问题,因为len()和切片都按码点走:
s = "你好,世界" print(s[:4]) # 你好,但我见过有人用Python处理完字符串,又转成JSON发给Java后端,Java后端用substring又劈出半个字符。问题不在Python,而在下游的length语义不一致。
JavaScript的substring按UTF-16码元截取,正常中文不出问题,因为中文在BMP内都是1个码元,但emoji:
const s = "a😀b"; console.log(s.substring(0, 2)); // "a�" 半个emoji // 正确做法:Array.from(s).slice(0, 2).join("") // "a😀"Go和Rust的字符串切片按字节,所以切错的风险同样存在。后面在排查章节我会给一个通用的安全截断思路。
3.2 反转、排序、替换与分割
字符串反转这个面试题年年出现,很多人不知道反转也有“字节反转、码元反转、字符反转”三个层级。
C++里对中文直接std::reverse会得到一团乱码,因为多个字节被颠倒顺序破坏了UTF-8结构。需要先按字符切分成数组再反转。
Python一路顺风:
s = "hello, 世界" rs = s[::-1] # 界世 ,ollehGo反转字符我记得要先转成[]rune:
s := "hello, 世界" r := []rune(s) for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] } fmt.Println(string(r)) // 界世 ,olleh字符串排序则要区分“按字典序”和“按长度”两种需求。按长度排序时,如果length语义算错,结果里中文排序就会乱。很多语言默认的比较函数是按Unicode码点排序,英文大小写会排在一起,中文则是按拼音?不对,是按Unicode编码顺序排,根本不是拼音顺序。业务上要按拼音排序得引入专门的排序库,比如Python的locale配合拼音变换,或者Java的Collator。
替换操作里有一个细节:replace是否支持正则、是否替换所有匹配项,不同语言默认行为不同。C++标准库的replace是替换指定位置的字符,而字符串内容替换要用std::regex_replace或者自己写循环。替换之后字符串长度变化,如果用了固定长度的缓冲区,就面临重新分配内存的问题。
分割字符串时,空字符串和连续分隔符的处理是最容易出bug的。我记得搜热词时看到“c++字符串分割”,C++没有内置的split,用getline或stringstream处理时,连续两个逗号会被忽略掉空字段,跟Python的split行为不一致,要保证数据完整性得自己控制。
3.3 字符串与数字互转
长度和数字互转看着简单,细节不少。SQL Server的“字符串转数字”我遇到过:CAST('12.5' AS INT)在某些默认语言设置下直接报错,而CONVERT(DECIMAL(10,2), '12.5')可以成功。C++里atoi("123abc")能返回123,strtol能返回123并告诉你解析停在了a,stoi则直接抛异常。三个函数行为完全不同,选错了,脏数据就静默通过校验了。
还有反向操作,数字转字符串之后几位对齐的场景。比如生成序号"001",Python里f"{x:03d}"一步到位,Java里String.format("%03d", x),C++20有std::format,老项目只能手写补零。补零前先确认字符串长度是否够,别补出多余位数。
4. 底层串口与协议场景:FPGA发送ASCII字符串
4.1 逐字节发送的本质
热词里出现了“fpga实现串口发送ascii字符串”,这是典型的嵌入式/FPGA应用场景。串口(UART)协议本身是逐字节传输的,每个字符以起始位、数据位、校验位和停止位的封装在物理线路上发送。ASCII字符集每个字符编码为8位,正好一个字节,所以发送ASCII字符串从协议角度就是逐字节发送字符数组,长度等于字符个数。
但FPGA里没有现成的“长度方法”可以调用。字符串一般以只读存储器(ROM)或寄存器数组的形式存在。有两种常见的组织方式:
- 在每个字符串末尾放一个结束标志符(比如
0x00或0xFF),发送状态机逐字节读取直到遇到结束符。 - 用一个独立的长度寄存器记录待发送的字节数,发送逻辑按计数递减直到归零。
从资源占用和时序控制的角度看,我推荐第二种:长度寄存器配合状态机判断“是否发完”,比扫描结束符更可控,少了数据内容与结束符冲突的风险。如果字符串内容里可能包含0x00,第一种方式就会提前停止发送。
4.2 长度字段与帧结构的约定
单个字符串发出去不是大问题,麻烦的是把它封装成完整的通信帧。一般帧结构会包含帧头、长度字段、数据、校验和、帧尾。长度字段的值到底是指数据段的字节长度,还是包含所有字段的总长度,这个约定如果不写明,收发双方就会各自解读。
比如约定长度字段表示“数据段字节数”。发送方在填充长度字段时,必须在发完整帧数据之前就提供这个值。如果字符串数据是动态产生的,就需要先缓冲完整数据或计算长度后再发送。FPGA设计里经常看到“先存完再发”和“边算边发”两种架构,前者多消耗存储资源但时序简单,后者少消耗存储但设计复杂。
ASCII字符串在帧内通常以原始字节承载,但如果是中文UTF-8字节序列,同一个字符串的“字符长度”和“帧内数据长度”又是两个数。协议文档里必须写明“length是字节数”,否则下游按照字符数去解析帧,直接错位。
4.3 缓冲区与越界防护
FPGA开发中的一大痛点就是“长度”与“容量”的换算。一个设计为64字节深度的FIFO,用于缓存64个ASCII字符正好,但如果传输内容改成UTF-8中文,同样字符数对应字节数翻倍,FIFO立刻溢出。
我建议在模块设计阶段就明确三件事:
- 接口定义里的
length信号单位是字节还是字符,并在信号命名上直接体现,比如data_len_bytes和char_count。 - 发送状态机的边界条件用“已发送字节数 >= 总字节数”判断,而不是通过比较当前地址和末地址是否相等来判断,避免地址回绕。
- 预留至少4字节的余量:如果协议设计成定长缓冲,缓冲深度按最大可能字节数加4,为特殊字符和未来扩展留空间。
这类来自底层的经验,写代码的人往往不关心,但一旦通信协议双方有一边是FPGA,字符串长度从“逻辑概念”变成“物理时序问题”,所有的约定都必须精确到字节。
5. 大模型上下文长度:从“字符数”到“token数”
5.1 上下文长度是什么
最近跟“字符串长度”强相关的另一个领域是大模型。大模型的“上下文长度”,比如常见的8K、32K、128K,单位既不是字符数也不是字节数,而是token数。token是模型分词器切分文本后得到的最小语义单元。模型处理文本时,所有输入输出都折算成token序列。
大模型的一个上下文窗口大小是有限的,这个限制直接决定了单次能塞进去多少字符串。如果你在做一个基于大模型的聊天机器人或文档分析工具,用户的输入加上系统提示词加上历史记录,总计token数一旦超过上下文窗口,要么报错,要么被静默截断。
5.2 字符与token怎么换算
token和字符的换算不是固定比例。英文场景下,一个token大约对应4个字符,或者说100个token约等于75个英文单词。中文场景则完全不同,一个汉字在常见分词器里大约是0.6到1个token,某个热门模型的实测体验是大约1个中文汉字约0.6个token,也就是100个汉字约60个token。缩写、特殊符号、编程代码里的关键字,token消耗率更高。
因此“字符串长度”在AI编程工具(比如热词里提到的CodeGeeX这类代码辅助工具)中,不仅要考虑代码字符数,还得考虑token数和模型的上下文记忆长度。上下文记忆长度本质上就是token窗口的一个应用侧面:模型能“记住”的多轮对话范围和窗口长度直接相关。窗口越大,能承载的历史对话和代码上下文越丰富。
我自己做prompt工程时,常用一个粗略公式估算:
- 英文文本:token数约等于字符数除以4。
- 中文文本:token数约等于汉字数乘以0.6,再加上标点和数字的零头。
- 混合代码:按每行代码8~15个token估算,具体取决于代码的复杂度和缩进。
这只是估算,精确值用模型自带的分词器去数才是准的。
5.3 业务上的长度控制策略
既然上下文窗口有限,字符串长度管理在大模型应用里就成了系统设计的一部分。我之前做过一个文档问答小系统,文档内容动不动几万字,直接全塞进去必然超窗。后来采用的策略是分层裁切:
- 先按章节标题切块,每块再按字符数分段,确保每个片段不超过窗口的四分之一。
- 检索阶段只把和用户问题最相关的几个片段拼进上下文,并限制片段总数。
- 如果单片段仍然过长,用摘要模型先压缩,再拼装。
这几招的核心思想是:上下文窗口是硬资源,字符串长度是消耗量,做的事情就是合理规划消耗。这和以前做分布式缓存一样,缓存容量是有限的,你最需要关心的是哪些字符串值得占用这个容量。
还有一个小坑:很多平台的token计算接口有最大输入长度限制,超过限制时会截断。如果你把超长字符串一次性传过去统计token数,接口返回的数字可能是被截断后的结果,不是真实值。所以我一般先按8K字符左右分段统计,再求和,避免一次性压进去。
6. 常见问题排查与避坑手册
6.1 乱码与半截字排查
处理字符串长度问题,最常遇到的症状是“显示乱码”。排查乱码不是去猜,而是按顺序检查编码链路:
- 先确认源头编码。文件还是数据库,用的是UTF-8还是GBK。用
file命令或者数据库的元数据查看。 - 再确认接收端预期编码。网页的
<meta charset>、接口的Content-Type、终端模拟器的编码设置。 - 最后确认每一层有没有做隐式转换。很多编程框架在读取数据库或HTTP请求时会按默认字符集解码,如果和实际编码不匹配,字符串在进入程序的那一刻就已经乱了,后面无论怎么截取、怎么计算长度,都是错上加错。
至于“半个汉字”的问题,排查时看字节序列最直接。UTF-8编码的汉字首字节高位是1110,连续字节高位是10;如果一个字符序列里出现孤立的高位10开头字节,说明它前面一定有字节被切掉了。
6.2 越界与崩溃类问题
老一代C语言程序员在字符串长度上栽过最大的跟头就是缓冲区溢出。strcpy不检查目标缓冲区长度,源字符串一旦超过目标缓冲区,就会覆盖相邻内存。现代语言虽然做了边界检查,但业务逻辑越界依然存在。比如数据库中一个字段定义是VARCHAR(10),你按字符数判断没问题就写入,结果底层存储按字节超限,数据库直接报错或截断。
我处理过一个比较刁钻的案例:线上系统报错“字符串或二进制数据将被截断”,最终定位到原因是某个字段被塞入了中文,字符数不超但字节数超。排查思路是把入库前的所有字段逐一核对长度,最终用查询日志定位到是用户签名里带了一串巨型emoji组合(多个emoji加零宽连接符),字符数不多,字节数爆表。从那以后,凡是有长度校验的字段,我都会同时校验字符数和UTF-8字节数的上限。
6.3 判断中文与编码探测技巧
热词里“abap判断字符串含有汉字”“ida显示中文字符串”都是判断中文的变形。判断一个字符串是否包含汉字,本质上是判断其字符是否落在Unicode汉字区段。最常用的范围是U+4E00 ~ U+9FFF(基本区),扩展区如U+3400 ~ U+4DBF、U+20000 ~ U+2A6DF也属于汉字,但日常业务里判断基本区就够用。
对于IDA这类逆向分析工具,显示中文字符串的关键在于目标程序使用的编码。如果程序内部是UTF-8编码,IDA识别出来是正常的;如果是本地代码页GBK编码,IDA默认按ASCII解析,中文就会变成一串乱码。此时需要在IDA中手动调整字符串的编码解释方式,或者用Python脚本把字节序列重新解码为GBK再显示。可以看到,“字符串长度”在和底层工具结合时从来不是孤立问题,它牵涉编码、区块、工具链的综合处理。
6.4 一张问题定位速查表
| 症状 | 可能原因 | 优先检查项 |
|---|---|---|
| 中文显示为乱码 | 编码链路不一致 | 源端编码、传输编码、接收端解码字符集 |
| 截取后出现半个汉字 | 按字节/码元截取 | 改用按码点截取或安全截取函数 |
| 统计出来的“字数”翻倍 | length返回字节数或码元数 | 确认语言API的返回语义 |
| 数据库报“数据将被截断” | 字符数不超但字节数超 | 同时核对CHAR_LENGTH和OCTET_LENGTH |
| 协议解析混乱 | 长度字段单位约定不一致 | 明确长度字段的字节单位并写入文档 |
| 大模型输出不完整 | 超上下文窗口被截断 | 统计token数并分段压缩 |
| IDA/工具里中文乱码 | 程序使用本地代码页编码 | 手动指定GBK或调整工具编码设置 |
避坑这件事,说到底就是三句话:第一,永远搞清楚你用的length返回的是什么单位;第二,凡是涉及字符串的操作,默认先考虑中文和特殊字符会不会改变长度语义;第三,跨语言、跨系统传递时,长度字段必须带单位约定,不能裸传一个数字。
个人经验是,在团队里定一个简单的规矩:代码里凡是表示“长度”的变量,命名上强制区分byteLen和charLen,不要只用len、size这种泛化名字。这一个习惯帮我们排掉过的雷,比任何一次代码评审都多。字符串长度从来不是“调用一个函数拿个数”这么简单,它背后是编码、语言设计、协议约定、工程管理的一整条链路。把这层窗户纸捅破了,那些奇奇怪怪的乱码、截断、越界问题,就都不再神秘了。