如果你写C代码超过三个月,还在靠"strlen是函数、sizeof是运算符"这种口诀区分它们,那我建议你花二十分钟读完这篇文章。网上讲这两个东西区别的帖子能塞满一整个硬盘,但绝大多数你看完就忘,因为那些内容只告诉你"是什么",没告诉你"为什么"。这个知识点不是用来背的,它是理解C语言内存模型的一座桥——为什么数组传进函数就"退化"了?为什么字符串长度和数组大小经常对不上?为什么strlen返回值直接做减法会出鬼畜bug?这些问题的根都在这里。
这篇文章我用实际代码和踩坑经历,把sizeof和strlen从底层原理到实战场景彻底拆一遍。适合刚学C语言的学生,也适合那些"用了好几年但从来没细想过"的工程师。
1. 表面区别只是冰山一角:一个管类型,一个管内容
先说最表层的结论,因为这是所有深入讨论的基础。sizeof是C语言的一个运算符,strlen是标准库<string.h>里的一个函数。单看这半句话就已经能推导出三个关键事实:运算符在编译期处理,函数在运行期执行;运算符不产生函数调用开销,函数有实打实的调用成本;运算符知道的是"类型的大小",函数知道的是"字符串的长度"。
但"编译期"和"运行期"这两个词背后藏着的本质差异,才是真正值得琢磨的东西。sizeof的操作对象是类型,它对"值"不感兴趣,它对"这个变量在内存里占多大块地皮"感兴趣。所以哪怕你写sizeof(100),编译器也只会当成sizeof(int)来处理,它根本不会去看100这个数值,它看的是100这个字面量的类型。strlen则完全反过来,它只对"值"感兴趣——它沿着内存地址一个字节一个字节地往下数,数到\0为止,返回的是"内容"的长度。
这个区别决定了它们的适用场景完全不同。sizeof回答的是"分配了多少内存"这个问题,strlen回答的是"里面存了多少有效数据"这个问题。一个是容器的容量,一个是容器里装了多少东西。你去超市买一箱牛奶,sizeof告诉你这一箱最多放24盒,strlen告诉你里面实际装了18盒,剩下的6个空位子它不管。
网上很多对比表都会列出"作用""适用对象""头文件""返回值类型"这些条目,我不打算再重复一遍那种表格,因为那些信息随时能搜到。我更想说的是这些结论是怎么来的,以及只看那些表格会漏掉什么。
2. sizeof真正干的事:编译期就把内存算明白了
2.1 为什么说sizeof是编译期运算符
sizeof是C语言里极少数在编译阶段就能完全确定的运算符。编译器在解析代码的时候,看到sizeof后面跟着的东西,会直接根据类型系统计算出结果,然后把这个结果当作一个常量嵌进生成的代码里。你的程序运行时,sizeof这行代码根本不执行任何"计算"动作——它已经被替换成一个纯数字了。
这也是为什么C语言允许你用sizeof来定义数组长度:
int arr[sizeof(int) * 8];这在C99之前是合法的,因为sizeof(int) * 8在编译期就能算出32,等价于int arr[32]。你没法写int arr[strlen("hello")],因为strlen是运行期函数调用,C99之前的标准数组长度必须是编译期常量。这个语法差异本身就是sizeof作为运算符的铁证。
举一个更直观的例子。你写一行代码,用两种编译优化级别去编译,sizeof那行产出的汇编指令完全一样,因为结果就是个立即数。strlen在不同优化级别下可能会被优化成不同的指令序列,甚至会因为编译器内置优化(built-in optimization)直接变成循环展开的版本。一个在编译期就尘封定论,一个在运行期每次都要重新跑,这就是本质差别。
2.2 sizeof计算的到底是什么
sizeof的结果单位是字节,但它的计算对象不是"变量里存了什么",而是"变量的类型在内存中占多大空间"。对于基本类型,这很好理解:
printf("%zu\n", sizeof(char)); // 1 printf("%zu\n", sizeof(int)); // 通常是4 printf("%zu\n", sizeof(double)); // 通常是8char固定是1字节,这是C标准唯一下死规定的。int、double这些类型的大小依赖平台,所以不同机器上sizeof(int)可能不同,这也是C语言可移植性问题的经典来源之一。真正有意思的是数组和结构体。
char str1[] = "hello"; char str2[100] = "hello"; char *str3 = "hello"; printf("%zu\n", sizeof(str1)); // 6,字符串字面量自带末尾的\0 printf("%zu\n", sizeof(str2)); // 100,用100初始化了数组,大小就是100 printf("%zu\n", sizeof(str3)); // 8或4,指针本身的大小,跟字符串毫无关系str1是char[6]类型,编译器根据初始化内容推导出数组容量是6字节,5个字符加一个\0。str2是char[100],虽然只用了前6个字节,但容量就是100。str3是一个指针变量,它的类型是char *,在64位系统上占8字节,32位系统上占4字节——它存的是"地址",不是字符串本身。
这个例子应该能解释一个大多数初学者都懵过的现象:为什么sizeof("hello")是6而不是5。因为字符串字面量"hello"的类型是char[6],包含末尾那个看不见的\0。很多人第一次在代码里写出sizeof("hello") / sizeof(char)会得到6,然后一脸困惑,其实就是在和"字符串字面量的类型是数组"这个概念作斗争。
2.3 结构体里的sizeof还有内存对齐这回事
sizeof用在结构体上时,坑更深。C语言的结构体在内存里不是简单地把字段大小加起来,而是需要考虑内存对齐(alignment)。每个字段有各自的字节对齐要求,编译器会在字段之间插入填充字节(padding),让每个字段的起始地址满足对齐要求。
struct A { char c1; int i; char c2; }; struct B { char c1; char c2; int i; }; printf("%zu\n", sizeof(struct A)); // 通常是12 printf("%zu\n", sizeof(struct B)); // 通常是8两个结构体字段一模一样,只是排列顺序不同,大小却差了4字节。struct A里char c1占1字节后,编译器为了把int i放到4字节对齐的地址上,中间填了3个空白字节;c2后面又为了结构体整体对齐补了3个字节。而struct B把两个char放在一起,int正好从偏移2开始,4字节对齐,后面不需要额外填充。这就是为什么写结构体时字段顺序会影响内存占用。
工程上有一个很实在的建议:写代码时把相同类型的成员放在一起,减少padding浪费。你还能用offsetof宏确认每个成员的偏移量到底是多少:
#include <stddef.h> printf("%zu\n", offsetof(struct A, i)); // 通常输出4,不是1这段代码会说服你,"结构体大小等于成员大小之和"这种直觉是完全错误的。理解这一点对后续理解sizeof和strlen的组合使用场景很有帮助,比如你序列化一个结构体往文件里写,一不小心就把padding也写进去了。
3. strlen真正干的事:运行期数到\0为止
3.1 一个朴素的顺序扫描
strlen的实现原理极其朴素,教科书级别的实现长这样:
size_t my_strlen(const char *s) { size_t len = 0; while (*s != '\0') { len++; s++; } return len; }从传入的指针开始,一个一个字节地读,每次判断当前字节是不是\0。不是就计数加一,指针向后移动一字节;是就停下来返回计数值。它不关心你传入的是数组还是指针,它眼里只有一块内存的起始地址。
这个朴素的实现决定了strlen的复杂度是O(n),字符串长度越长,耗时越线性增长。标准库的实现通常会做很多优化,比如每次读一个机器字(4字节或8字节),用位运算一次性判断这一整块里有没有\0,性能比逐字节快好几倍。但不管底层怎么优化,语义是不变的:找到第一个\0,返回它之前的字符个数。
3.2 越界是strlen最危险的影子
因为strlen靠\0来识别终点,所以它有个"盲区":它不知道\0之后是什么,也不知道\0之前的内存边界在哪里。只要没碰到\0,它会一直持续往下读,哪怕已经越过了合法分配的缓冲区。
char buf[5]; buf[0] = 'H'; buf[1] = 'e'; buf[2] = 'l'; buf[3] = 'l'; buf[4] = 'o'; // buf没有\0结尾 printf("%zu\n", strlen(buf)); // 无定义行为,可能输出5,可能输出更大这就是经典的未定义行为(undefined behavior)。strlen会从buf的起始地址一直往后扫描,直到碰巧在某处读到\0。这个\0可能在buf的下一个字节,也可能在很远的地方,取决于栈上或堆上那一段内存里残留了什么数据。运气好的时候输出正常,运气差的时候不仅数字不对,还可能触发段错误。
那么问题来了:为什么char buf[] = "hello";就没问题?因为初始化时编译器自动在末尾加了\0,数组实际大小是6,strlen能正确停在\0上。真正的坑在于手动填充数组或者从外部(网络、文件)拷入数据时,忘记补\0。我见过不少生产环境的代码,从文件里读了固定长度字节到缓冲区,然后直接拿strlen去算长度,结果算出了一个和预期差很远的值。
3.3 sizeof和strlen的时间成本对比
这里有一个很多人忽略的点:strlen在你每次调用时都会重新扫描一遍字符串。如果你在循环里反复调用strlen,复杂度会从O(n)变成O(n*m)。
// 不推荐:循环内反复调用strlen for (int i = 0; i < strlen(s); i++) { // 每轮循环都扫描一次s,总复杂度O(n^2) } // 推荐:先保存长度 size_t len = strlen(s); for (int i = 0; i < len; i++) { // 一次扫描,O(n) }这是字符串处理代码里非常常见的性能隐患。在strlen的实现逐字节扫描的前提下,循环体内每一轮都重新数一遍,字符串一长,性能衰减非常明显。sizeof没有这个问题,因为它在编译期已经固定了,运行时不产生任何遍历动作,它的时间成本是0。
| 维度 | sizeof | strlen |
|---|---|---|
| 本质上 | 运算符 | 库函数 |
| 处理时机 | 编译期 | 运行期 |
| 返回值含义 | 类型/对象占用的字节数 | 字符串中\0前的字符数 |
| 能否用于数组 | 能得到数组总大小 | 不能直接得到,需要遍历 |
| 能否用于指针 | 得到指针本身大小 | 可以,但依赖指针指向的内容 |
| 时间复杂度 | O(1),编译后是常量 | O(n),需要遍历到\0 |
| 依赖头文件 | 不需要 | 需要 <string.h> |
4. 实战验证:五种场景下两者的行为差异
4.1 字符数组用字符串字面量初始化
char s[] = "hello"; printf("%zu\n", sizeof(s)); // 6 printf("%zu\n", strlen(s)); // 5这组结果最直白。sizeof看的是数组类型char[6],它回答"这个变量占多少内存",答案是6字节;strlen看的是内容,它数到\0前有5个有效字符,于是返回5。在需要精确计算"字符串实际长度"的时候用strlen,在需要"给这块缓冲区分配多少空间"的时候用sizeof。
举一个马上能用的场景:你要把s拷贝到新分配的内存里。如果写malloc(sizeof(s)),分配的是6字节,够用;如果写malloc(strlen(s)),分配的是5字节,拷进去的时候\0会写到边界外面,这就是缓冲区溢出的起点。正确写法是malloc(strlen(s) + 1),多出来的1字节专门给\0。这个+1是写C字符串处理代码时最不该忘的细节。
4.2 定长数组只初始化部分内容
char s[64] = "hello"; printf("%zu\n", sizeof(s)); // 64 printf("%zu\n", strlen(s)); // 5这里能非常清楚地看到"容量"和"内容"的区别。s的容量是64字节,这是编译期就定死的事实;s里的有效数据是5个字符加一个\0,这是运行期才能确定的。如果后续代码往strlen(s)的位置写入新的合法字符串,sizeof(s)不会变,strlen(s)会随之变化。
实操中一个常见的写法是:
char buf[128] = {0}; fgets(buf, sizeof(buf), stdin); size_t len = strlen(buf); if (len > 0 && buf[len - 1] == '\n') { buf[len - 1] = '\0'; }fgets的第二个参数传sizeof(buf),能保证最多读入127个字符加一个\0,不会溢出缓冲区。这里的sizeof正是利用了它在编译期确定的特性——即使经过复杂的函数调用,它依然知道buf总大小。而后面判断换行符时用的strlen(buf),是读取实际内容长度。两兄弟各管一摊,配合得明明白白。
4.3 字符指针和数组的致命差异
char arr[] = "hello"; char *ptr = "hello"; printf("%zu\n", sizeof(arr)); // 6,数组总大小 printf("%zu\n", sizeof(ptr)); // 8或4,指针大小 printf("%zu\n", strlen(arr)); // 5 printf("%zu\n", strlen(ptr)); // 5arr和ptr看起来都代表字符串"hello",但一个占6字节(数组本体在栈上),一个占8字节(指针存的是字符串字面量的地址)。strlen对两者返回相同结果,因为它只看内容;sizeof对两者返回完全不同的大小,因为它只看类型。
这个差异很重要。如果你写一个函数:
void print_size(char arr[]) { printf("%zu\n", sizeof(arr)); // 这里不是一个数组大小 }arr[]这种形参写法只是一个语法糖,它实际上是一个指针char *arr。所以函数内部用sizeof(arr)得到的是指针大小,不是外面数组的大小。这种现象叫数组参数退化(array decay)。很多bug的根源就在这里:在函数外部sizeof是6,传进函数再sizeof变成了8,程序员以为自己在算数组长度,其实算的是指针长度。
4.4 二维数组,sizeof和strlen的层次感
二维数组里,sizeof和strlen的更不在一个维度上:
char names[3][16] = { "Alice", "Bob", "Charlie" }; printf("%zu\n", sizeof(names)); // 48 = 3 * 16 printf("%zu\n", sizeof(names[0])); // 16 printf("%zu\n", strlen(names[0])); // 5 printf("%zu\n", sizeof(names[0][0])); // 1sizeof(names)是整个二维数组占用的字节数,sizeof(names[0])是其中一行占用的字节数,sizeof(names[0][0])是单个字符的字节数。而strlen只对names[0]这种"字符串起始地址"有意义,它数的是这一行里实际有多少个字符直到\0为空。层次关系完全由类型决定,strlen则和类型无关,只看内容是"哪个字符串"。
4.5 动态分配的内存,sizeof拿到的是指针
用malloc动态分配的时候,sizeof和strlen的差距尤为扎眼:
char *p = (char *)malloc(100 * sizeof(char)); strcpy(p, "hello"); printf("%zu\n", sizeof(p)); // 8或4,指针本身大小 printf("%zu\n", strlen(p)); // 5,字符串内容长度malloc返回的是void *,存进char *p后,p这个变量本身只是一个8字节的指针,它不含任何"这是100字节缓冲区"的元信息。C语言不记录堆内存块的大小(需要你自己维护),所以sizeof(p)永远不可能告诉你分配了100字节。如果你想追踪动态缓冲区的大小,只能自己用变量记录,或者用一个包含length字段和data字段的结构体。
这不只是语言特性,更是一种思维方式:C语言里,"指针"和"它指向的内存块"是两回事。sizeof只关心指针变量本身有多大,strlen则沿着指针跳到真正的内容里去数。只有想明白这两层分离,写动态内存管理的代码才不会迷路。
5. 工程里最危险的坑:函数传参、unsigned减法、未初始化
5.1 数组"退化"成指针之后,sizeof彻底失灵
这是所有C程序员迟早会遇到的一个坑。我给你一段真实工作里见过的代码:
void print_len(char buf[128]) { printf("%zu\n", sizeof(buf)); // 输出8,不是128 }刚入行的同事会以为形参写成char buf[128],编译器就能在函数体内拿到128这个信息。实际上C语言里,数组作形参时括号里的数字会被忽略,形参类型会调整为char *buf。这个特性叫"调整数组类型为指针类型",很多人叫它"退化"。
C语言这样设计是为了效率。如果数组在函数传参时全部拷贝一份,代价太大,所以C选择传首地址。于是任何数组传入函数后,数组长度信息就丢失了。你必须把长度作为独立参数传进去,或者用strlen从内容里推断(但这要求内容是合法的以\0结尾的字符串)。
一个规规矩矩的工程写法是:
void process(char *buf, size_t buf_size) { // buf_size = sizeof(...)在外面算好 }调用时:
char data[256]; process(data, sizeof(data));把sizeof(data)算出来再传进去,这就绕开了退化问题。这里的关键意识是:不要指望函数内部能通过sizeof拿到外部数组的大小,它拿不到。
5.2 strlen返回的是unsigned类型,减法会变成大正数
strlen的返回类型是size_t,在头文件里通常定义成unsigned long或unsigned long long。这意味着它永远不可能是负数。很多人没意识到,这会在做减法比较时产生极其难排查的bug。
char s1[] = "hello"; char s2[] = "abc"; if (strlen(s1) - strlen(s2) > 0) { printf("s1比s2长\n"); }逻辑上看,5 - 3 = 2,2 > 0,条件成立。但如果把两个数的顺序调换一下:
if (strlen(s2) - strlen(s1) > 0) { printf("s2比s1长\n"); }直觉上3 - 5 = -2,条件应该不成立。但size_t是无符号数,3 - 5会发生无符号整数环绕(underflow),变成SIZE_MAX - 1,这是一个巨大的正数。于是条件判断为真,程序打进了一个完全不存在的分支。
正确的写法是显式做有符号比较:
if (strlen(s1) > strlen(s2)) { // 两个无符号数比较大小,不会出现负数问题 }或者先把它们转成int或long再相减。这一点在实际代码里真的坑人,尤其是你的代码review了多轮,所有人都觉得"就是简单比较两个长度",结果一个边界case把它送上了事故报告。
5.3 未初始化的数组:两个结果都是垃圾
char s[10]; printf("%zu\n", sizeof(s)); // 10,固定 printf("%zu\n", strlen(s)); // 随机,完全不知道是什么sizeof不关心数组里有没有内容,它永远给出10。但strlen需要\0来指示终点,一个未初始化的局部数组里装的是栈上的残留数据,大概率没有\0在合理位置。结果就是strlen(s)返回一个随机数,甚至可能一直扫描到崩溃。
解决方案很土但很有效:用之前先初始化。
char s[10] = {0}; // 全部清零,等效于第一个字符是\0,strlen是0 strcpy(s, "hello"); // 之后再写内容char s[10] = {0}会把数组所有字节都初始化为0,也就是第一个字节是\0。这个时候如果别的代码读strlen(s),得到0,是安全的。等到真正写入字符串后,\0仍然会自动跟在对的末尾,strlen就能安全工作了。
5.4 常量字符串的sizeof和strlen
还有一个加分项,看这段代码:
printf("%zu\n", sizeof("hello")); // 6 printf("%zu\n", strlen("hello")); // 5字符串字面量在C语言里有个很反直觉的性质:它的类型是一个字符数组,大小等于字符个数加1。于是对字符串字面量直接sizeof能拿到包括\0在内的正确大小,这在某些宏定义里有巧妙用途:
#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0]))当a是数组时,ARRAY_SIZE(a)返回数组元素个数。你不能拿这个宏去处理指针参数,因为sizeof(指针)除以sizeof(元素类型)得到的是指针大小除以元素大小,完全不对。可当a是字符串字面量时,因为这个字面量本质是静态数组,这个宏能得到包括\0在内的字符数。这是sizeof非常经典的高阶用法。
6. 判断一个字符串为空:两种情况用不同招
空字符串的判断看起来简单,真放代码里也有讲究。针对不同场景,选择不同工具。
char empty[1] = {0}; // 能放一个\0 char not_init[8]; // 未初始化,危险 if (strlen(empty) == 0) { printf("empty是空字符串\n"); } // if (strlen(not_init) == 0) // 这是不安全的,因为not_init里没有\0如果你只想判断"这个字符串是不是空串",strlen是安全的(只要内容是以\0结尾的合法字符串)。但有些时候你只是想判断"数组是不是还没填过东西",这时候strlen就不合适了,因为它扫描内容而不是状态。一个常见做法是用数组的第一个元素做标记:
char name[32] = {0}; if (name[0] == '\0') { // 数组内容为空 }在C语言里,空字符串和"空内存"是两个概念。判断内容是否为空,用strlen;判断内存是否被使用,用sizeof或者首元素判空。这两个场景对应两种完全不同的语义,很多bug都源自于混用它们。
7. 面试总考这些:几道典型题的标准思路
把常见的面试题和需要真正掌握的思路整理一遍,这部分也是我自己当年被问过的。
7.1 计算数组长度的宏为什么必须在原数组上操作
有一个经典宏:
#define ARRAY_SIZE(arr) (sizeof(arr) / sizeof(arr[0]))这个宏只能用在数组本身上,不能用在函数形参上。还是那个原因:函数形参里的arr已经退化成指针,sizeof(arr)是8或4,除以单一元素大小后,得到的是一个没意义的数字。面试里如果给你一段函数内使用ARRAY_SIZE的代码,答案基本就是"它不正确,因为参数已退化为指针"。
7.2 为什么sizeof不能作为函数的返回值来判断数组长度
因为数组类型信息在传参时丢失。C语言没有内建机制在运行期查询某块内存的"分配大小",malloc分配的内存块大小需要用户自己记住。C语言选择把这块责任交给程序员,sizeof只对编译期可见的静态类型有效。如果你动态分配了内存,sizeof连边都搭不上。
7.3 判断一个字符串是否相等,为什么不能用sizeof比长度
两个字符串长度一样,内容可能完全不同。sizeof只能告诉你类型上占多少字节,不能告诉你内容上有没有\0、有几个字符。strlen能告诉你内容长度,但不能告诉你内容本身。判断字符串相等要用strcmp,strlen和sizeof都只是辅助手段。
7.4 一个字节一个字节填的缓冲区如何保证正确性
有一种填缓冲区的方式是:
char packet[1024]; // ... size_t data_len = build_packet(packet); send(sockfd, packet, data_len);这里没法用sizeof(packet)当发送长度,因为缓冲区可能只填了一部分,sizeof(packet)会直接发1024字节出去,把垃圾数据全发出去。用strlen(packet)也不对,因为二进制数据中间可能含有0,strlen会提前截断。正确思路是:build_packet函数返回实际填充长度,发送时用返回的长度。这是C语言网络编程里非常基础的素养,也再次说明了"容量"和"内容"彻底是两码事。
8. 我自己踩过的一次翻车记录
讲一个我早期写C时真实碰到的bug,帮助你把上面所有知识点连起来。
当时有个模块要从配置文件里读取一行字符串,放进char line[256]里,然后对它做解析。我写了类似这样的代码:
char line[256]; FILE *fp = fopen("config.ini", "r"); while (fgets(line, sizeof(line), fp)) { fputs(line, stdout); printf("len = %zu\n", strlen(line)); }看起来一切正常,直到某天配置文件里有一行特别长的内容。fgets只读入255个字符加\0,剩下的内容留在文件流里,下一次fgets又读走下一截。截断后的line末尾依然是\0,所以strlen和printf都安全。问题出在丢失了"这一行其实被截断了"这个信息。我用strlen判断长度,然后做解析,结果解析出了一个一半的字段,程序里没有任何报错。
后来我改成先判断line[strlen(line) - 1]是不是\n。如果不是,说明这行的读取被截断了,需要特殊处理。这就是strlen的返回值在工程里最常被使用的地方之一——判断是否读取完整行。因此,fgets和strlen配合使用时,千万别只想着"读到了字符串",还要检查"这个字符串是否被截断"。
还有一次,我在一个函数里写了sizeof(buf)想获得缓冲区长度,结果buf是以参数传入的指针。排查了很久,最后才意识到数组退化问题。那次之后我给自己定了一个规矩:函数形参如果接收缓冲区,一律同时接收长度参数,哪怕是全局变量也尽量传,避免内部靠"猜"来使用缓冲区。每一条规矩背后都至少有一个通宵调bug的夜晚。
说实话,sizeof和strlen这两个东西,单独拎出来任何一个我都能背得滚瓜烂熟,但真正让一个人从"会用"到"用对"的,永远不是背结论,而是把这些结论放到真实的场景里反复碰撞。希望这篇内容能帮你在碰撞过程中少走几步弯路。