ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

memset深度解析:字节填充原理、常见误用与正确实践

memset深度解析:字节填充原理、常见误用与正确实践 memset 这个词在搜索里热度一直很高但作为用了十几年 C/C 的人说实话每次看到有人把 memset 用错我都会觉得挺可惜的——不是这个函数难而是很少有人把它的底层机制讲透。大多数人只记住了“把内存块填成某个值”然后就在各种场景里强行用等到出了诡异 bug又回头怀疑是不是编译器有问题。这篇文章我想把 memset 一次讲明白。它适用于刚入门 C/C 的读者也适用于已经写了几年业务代码、偶尔被“莫名其妙清不掉”或者“清了反而崩”的问题折磨的开发。我会从函数原型讲到字节布局从结构体初始化讲到 C 对象的边界再讲性能和编译器行为最后列几个我实际排查过的典型误用现场。你看完再遇到 memset大概率不会再踩坑。1. memset的声明、参数与最基础用法1.1 函数原型s、c、n三个参数到底怎么理解memset 的标准声明长这样void *memset(void *s, int c, size_t n);三个参数分别是s指向要操作的内存块起始地址。它可以是数组、结构体指针、动态内存指针甚至是某个结构体里的成员指针。c要填充的值虽然类型是int但真正写入内存时会被转成unsigned char后面我会专门讲这个转换有多坑。n要填充的字节数注意是字节数不是元素个数这是最常见的误用点。返回值是传入的s指针一般用不上但偶尔可以看到有人拿它做链式操作。标准里对它的描述非常简短把c转换为unsigned char后写入s指向的内存前n个字节。也就是说memset 做的事情永远是“按字节填同一个值”它不理解 int、float、结构体也不理解大小端它只是把一整块内存看成没有类型的一串字节。1.2 最典型的三个使用场景及代码示范实际项目里 memset 用得最多的场景有三个第一个是栈上结构体清零。C 语言里结构体变量如果直接声明内存里的内容是“不确定”的很多 bug 就是没初始化成员就使用导致的。清零后至少可以保证不会读到垃圾值。struct packet { uint16_t type; uint32_t len; char payload[64]; }; struct packet pkt; memset(pkt, 0, sizeof(pkt));第二个是数组清零。比如申请一块用作缓冲区的数组希望把之前的残留数据全部清掉char buffer[1024]; memset(buffer, 0, sizeof(buffer));第三个是动态分配内存后的初始化。malloc出来的内存不保证清零如果业务逻辑要求初始状态为 0可以配合 memset 使用int *arr (int *)malloc(10 * sizeof(int)); if (arr NULL) { // 处理分配失败 } memset(arr, 0, 10 * sizeof(int));这三个场景里sizeof是关键。sizeof(buffer)是 1024sizeof(pkt)是整个结构体的字节大小10 * sizeof(int)是要清零的字节数。记住一个原则memset 的n永远是基于字节的如果看到有人写memset(arr, 0, 10)却想清零 10 个 int那就要警惕了。1.3 头文件、返回值和与bzero的简单对比C 语言里 memset 在string.h中声明C 里建议包含cstring。它的返回值和 memcpy 类似返回原始指针方便链式调用但现实中很少用到这个返回值。这里我想提一下bzero。老代码里经常看到bzero(buf, len)它是 BSD 时代留下来的接口glibc 也一直保留着。功能上相当于memset(buf, 0, len)而且看起来更简洁。问题在于它不是 C 标准函数在某些平台或嵌入式交叉编译环境里可能没有声明移植性不如 memset。所以新代码里尽量别再用 bzero统一用 memset少一个“为什么这里能编过、那里编不过”的编译问题。还有一个很小的细节memset 的指针参数不能是NULL。这个函数内部会按地址连续写n个字节传 NULL 进去基本就是直接段错误。在使用之前判断指针是否为空是基本功。2. “按字节操作”这句话决定了你能填什么值2.1 int参数会被截断成unsigned char0x12345678不是你以为的那个数memset 的第二个参数类型是int这很容易让人误以为可以传任意整数。但标准里说得很明确这个int会在写入前转换成unsigned char。转换的规则就是取低 8 位。如果你写memset(buf, 0x12345678, sizeof(buf));实际写入每个字节的只是0x78。如果业务代码里有人想通过这种方式设置一个“含魔数”的缓冲区结果会完全出乎意料。更常见的坑是传负数。memset(buf, -1, n)会让每个字节都变成0xFF这个行为倒是有规律但如果不了解转换机制看到效果还是会懵。我见过一个比较经典的排查案例某嵌入式项目里想给帧缓冲区填充0xA5A5A5A5这种模式用来做内存有效性检查结果直接传memset(buf, 0xA5A5A5A5, len)内存里全是0xA5根本不是预期的四字节重复的整数模式。这类问题在代码审查阶段很难看出来只有把内存 dump 出来对比才会发现。2.2 memset一个int数组的等待数为什么填1会变成16843009如果你尝试用 memset 给 int 数组全部赋 1你会得到意想不到的数值int arr[4]; memset(arr, 1, sizeof(arr)); // arr[0] 是 0x01010101 16843009不是 1原因很简单memset 把 4 个字节都写成了0x01而 int 类型在内存里是 4 个字节组合出来的值就是0x01010101。这件事和大小端无关因为每个字节的值都一样读出来的结果固定就是 16843009。同理memset(arr, 0xFF, sizeof(arr))会把每个字节写成0xFF对 signed int 来说结果是 -1对 unsigned int 来说是 4294967295。memset(arr, 0x3F, sizeof(arr))会得到0x3F3F3F3F约等于 10.6 亿。这也是很多竞赛代码里用0x3F3F3F3F表示“无穷大”的由来。如果你真想给 int 数组依次赋 1、2、3 这种递增序列或者都赋成同一个整数值 1memset 做不到应该用循环或std::fill。memset 只适合“所有字节都填同一个值”的场景这个值用于整型时必须能接受“4 个字节重复组合”这件事。2.3 浮点数数组也不能用memset赋1IEEE 754决定了这件事浮点数比整型更容易踩坑。一个float类型的 1.0在 IEEE 754 标准下的内存表示是0x3F800000。注意看这 4 个字节00 00 80 3F小端。它们不是同一个值所以用 memset 写0x01、0xFF或任何单一字节都不可能得到 1.0。如果你真的对 float 数组执行memset(arr, 1, sizeof(arr))每个字节都会变成0x01组合出来的浮点数是一个极小的非正规数而不是 1。对 double 同样如此double 的 1.0 表示是 8 个字节字节相互之间差别很大。但浮点数有一个例外可以安全使用 memset清零。因为 IEEE 754 标准里0.0 的字节表示全为 0所以memset(arr, 0, sizeof(arr))得到的浮点数组确实是 0.0。这也是很多人在初始化大浮点数组时直接用 memset 的原因。如果需要给浮点数组统一赋 1.0、0.5 这样的普通值正确做法是std::fill或者循环。别试图用 memset 找捷径。2.4 常见“为什么没清零”的误判与字节序实际调试中“为什么清了没效果”或者“清了之后数据不对”通常不是 memset 本身坏了而是对n的把握出了偏差。举个例子int arr[100]; memset(arr, 0, 100); // 只清了 100 个字节也就是 25 个 int不是 100 个 int很多时候你只是清了一部分剩下的数据残留导致了后续逻辑混乱。这种问题很难靠肉眼发现因为代码里的100看起来非常合理。另一个相关的问题是“只清某个成员”。比如结构体里有struct timeval你只memset(obj.tv, 0, sizeof(obj.tv))这是没问题的。但如果你想清整个结构体却只写了memset(obj.tv, 0, sizeof(obj.tv))那只能清到 timeval 一块其它成员依然是垃圾值。把“清整块”和“清其中某一块”分清楚很多误判就能避免。字节序在这个问题里反而很少制造麻烦因为 memset 填的是单字节值每个字节都一样读出来的结果不受大小端影响。真正和字节序强相关的是内存 dump 时的查看方式如果你用x/4bx看字节序列再用p/x看 int 值中间才会出现“怎么字节顺序反了”的错觉。3. 结构体、类对象与memset的边界在哪3.1 结构体清零是C语言常见的正确用法在 C 语言里结构体变量直接声明后不初始化成员的值是栈上的随机垃圾。很多服务端 bug 的源头都是某个结构体成员没有初始化就被拿去判断导致逻辑走到了完全意外的分支。所以在项目里看到memset(st, 0, sizeof(st))是一个非常正常且推荐的动作。清零之后再给需要的字段赋值能避免大量“漏初始化”问题。尤其是网络报文、IPC 消息、事件对象这类需要序列化传输的结构体清零几乎是必须的struct msg { int type; int len; char data[256]; }; struct msg m; memset(m, 0, sizeof(m)); m.type 1; m.len 0;这样做的另一个好处是如果后续加字段新字段也会被自动清零不会因为忘记在构造函数里初始化而出现不可复现的偶发 bug。3.2 别忘了paddingsizeof结构体里的“隐藏字节”与安全影响结构体的大小并不总是等于所有成员大小之和因为编译器会对成员做内存对齐。比如下面这个结构体struct record { char flag; int value; };flag占 1 字节int占 4 字节但由于int要 4 字节对齐flag后面会有 3 个 padding 字节。整个结构体通常占 8 字节而不是 5 字节。memset(record, 0, sizeof(record))会把 padding 字节也清成 0这是好事。但如果你只做memset(record.flag, 0, sizeof(record.flag))padding 字节不会被碰。为什么这个细节很关键假设你把这个结构体直接写入 socket 或者文件send(fd, record, sizeof(record), 0);发送的数据里包含 padding 字节。如果这些 padding 字节没有清零它们携带的是栈上的残留数据。从内存安全角度讲这属于未初始化内存泄露可能被外部观察到。有些安全审计工具会把这种问题标记为信息泄露漏洞。所以我的习惯是所有可能被序列化或发往外部的结构体声明后在第一时间整体 memset 一次不给 padding 留任何机会。3.3 C里别对非POD类型用memset虚函数表会被清空到了 C 领域memset 的使用要非常克制。对带有虚函数的类对象使用 memset会直接把虚函数表指针清成 0之后调用任何虚函数都会崩溃或未定义class Handler { public: virtual void Run() {} int id_ 0; }; Handler h; memset(h, 0, sizeof(h)); // vptr 被清掉 h.Run(); // 大概率崩溃更危险的是容器类型。比如std::string、std::vector内部持有指向堆内存的指针。对它执行 memset 等同于抹掉这些指针但原堆内存既不会被释放对象的析构逻辑也完全没走。轻则内存泄漏重则对象析构时对随机地址释放直接触发 abort。C 里初始化对象应该用构造函数、值初始化或者赋值// 推荐 std::vectorint vec(100, 0); std::string str; MyClass obj{};如果确实是在做底层内存操作而且对象本身是 trivially copyable 的 POD 类型用 memset 也不是不行但至少要加一个编译期检查#include type_traits static_assert(std::is_trivially_copyable_vMyStruct, memset only for trivially copyable types); memset(obj, 0, sizeof(obj));这样如果以后有人给结构体加了虚函数或者容器成员编译直接报错而不是等到线上崩了再排查。4. 指针、动态内存、二维数组清零的坑与正确做法4.1 函数参数传数组后sizeof失效memset只清了一个指针大小这是 C/C 里最高频的 memset 错误之一。很多人写一个清数组的函数void clear_array(int arr[]) { memset(arr, 0, sizeof(arr)); // arr 此时是 int*sizeof(arr) 是 864位下 } int data[100]; clear_array(data); // 实际只清了 2 个 int剩下 98 个没动问题根源是数组作为函数参数传递时会自动退化成指针。int arr[]形参本质上就是int *arr所以sizeof(arr)得到的是指针大小而不是数组大小。在 64 位平台上这通常是 8 字节也就是只能清 2 个 int。正确的做法有三种第一种传入字节长度void clear_array(int *arr, size_t count) { memset(arr, 0, count * sizeof(int)); }第二种对于固定大小的局部数组用模板推导数组真实尺寸template size_t N void clear_array(int (arr)[N]) { memset(arr, 0, sizeof(arr)); }第三种如果你不需要保留数组变量本身直接调用处用sizeof(data)int data[100]; memset(data, 0, sizeof(data));我的习惯是任何函数内部要清外部传入的缓冲区都要显式传入字节数或元素个数绝不依赖 sizeof 在函数内还能得到原始数组大小。这个规则在代码审查里能拦下一大批隐患。4.2 mallocmemset与calloc的关系以及大块内存为什么要警惕清零malloc只分配内存不负责清零calloc分配内存的同时会把内容置零。这两者本质区别在于清零的时机和成本的支付方式。int *p1 (int *)malloc(100 * sizeof(int)); memset(p1, 0, 100 * sizeof(int)); int *p2 (int *)calloc(100, sizeof(int));看起来两者最终效果一样但底层并不完全相同。操作系统内核会维护一个“全零页”机制calloc分配大块内存时可以直接映射到只读全零页直到第一次写入才真正分配物理页。这种方法在“只分配但不会立刻全部写入”的场景下速度明显快于malloc后再memset。但这不是说calloc绝对比malloc memset好。如果你分配后马上要对整个缓冲区填充业务数据反正每个字节都要写那么前置清零就是纯浪费。比如从网络读取一整包数据到缓冲区recv会覆盖缓冲区之前的 memset 完全多余。在服务端写数据包时我通常这样处理如果缓冲区后续会被完整覆盖直接malloc不清零。如果缓冲区存在部分字节不覆盖、但又需要这些字节是 0 的情况才考虑calloc或malloc memset。如果分配小块内存性能差异可以忽略选方便维护的方案即可。4.3 二维数组/结构体数组的一举清零方式与“指针数组不可直接memset”真正的二维数组在内存里是连续的比如int grid[3][4]总共 48 字节可以直接一次 memset 清掉int grid[3][4]; memset(grid, 0, sizeof(grid)); // 3 * 4 * sizeof(int) 48 字节结构体数组也一样struct point { int x; int y; }; struct point points[10]; memset(points, 0, sizeof(points));但如果你声明的是指针数组事情就不一样了int *rows[3]; for (int i 0; i 3; i) { rows[i] (int *)malloc(4 * sizeof(int)); } memset(rows, 0, sizeof(rows)); // 危险rows是一个由 3 个指针组成的数组memset(rows, 0, sizeof(rows))只会把这三个指针变量置为 NULL并不会释放它们指向的堆内存。结果是内存泄漏而且后续再对这 3 个指针解引用就会段错误。对于动态分配的二维指针int **matrix更不能直接按整个逻辑大小 memset因为每行的内存不一定在物理上连续。想清一个“规则的矩形”动态二维数组要么逐行 memset要么干脆申请一整块连续内存后用一维索引访问这样仍然可以一次 memset 搞定。4.4 局部清零、偏移清零在环形缓冲区里的使用memset 并不总是从内存块头开始清。环形缓冲区、协议解析器里经常需要从某个偏移位置开始清特定长度这时候地址计算要用字节为单位uint8_t *buffer (uint8_t *)pool; size_t offset 16; size_t count 32; memset(buffer offset, 0, count);这里最容易犯的错是把offset当成“元素个数”但没有乘以元素大小就直接加到地址上。如果 buffer 是uint32_t *而你想跳过 16 个元素那必须buffer 16编译器会自动按sizeof(uint32_t)缩放或者在 typedef 成uint8_t *后自己乘sizeof(uint32_t)。关键是清楚当前指针类型是字节指针还是带类型的指针不要两种混着算。5. 从性能角度重新审视memset与编译器行为5.1 memset比手写for循环强在哪很多初学者会问不就是一个字节一个字节写吗直接写循环不就好了for (int i 0; i n; i) { buf[i] 0; }事实上memset 在主流平台上的性能通常比手写循环好原因有两个。第一编译器认识 memset知道它的语义可以把它转换成最高效的内存写入指令。小内存时直接展开成几条宽位存储指令连函数调用都省了大内存时会调用 libc 里经过专门优化的版本比如 x86 平台上的 AVX2 实现通常还会配合非临时写避免污染 CPU 缓存。这些优化在读代码时可以当作“IDE 自动帮你优化”。第二memset 的底层实现往往包含一些针对缓存线对齐、宽位存储、批量写入的策略手写循环难以达到同等性能。不过这不是让你迷信 memset。如果你的对象本来就要被全部覆盖加一个 memset 反而是额外开销。比如recv一个缓冲区然后再 memset 清零这属于典型的重复劳动。5.2 编译器可能会把你“安全擦除”的memset优化掉这是我在安全类项目里反复强调的一个坑。考虑下面这段代码void clear_secret(char *secret, size_t len) { memset(secret, 0, len); }如果调用方在调用clear_secret之后不再读取这块内存编译器会认为这次 memset 是“死代码”——写入了但没有人读所以从“可观察行为”的角度说不保留它也不影响程序结果。在开启优化的情况下这个 memset 可能被直接优化掉密码或密钥仍然留在内存中。这不是理论问题而是真实出现过的漏洞。正确做法是使用专门的“防优化清零”函数比如 glibc 的explicit_bzeroWindows 上的SecureZeroMemory或者 C11 提供的memset_s。它们会在语义层面阻止编译器把清零动作消除。如果没有这些平台接口也可以手动加一个 volatile 标记来阻止优化void wipe_secret(volatile unsigned char *p, size_t len) { while (len--) { *p 0; } }提醒一句这类“擦除敏感信息”的需求千万不要觉得自己写个普通 memset 就够了。编译器优化是个非常活跃的领域你今天测出来有效不代表换个版本、换个平台仍然有效。5.3 什么时候根本不该清零什么时候必须换其他手段再展开说一下“清零”的时机。有些场景里清零不是必要的。比如申请了一块缓冲区准备接收文件内容接收函数会填充这段内存那你不需要提前 memset。先清零再覆盖等于白跑一遍内存写入对性能敏感的服务可能带来不必要的损耗。但也有一些场景必须换其他手段而不是硬用 memset清零一个 C 容器对象本身不能 memset应该用容器的clear()或重新赋值。初始化一个带有构造逻辑的对象不能 memset应该用构造函数。操作设备寄存器或 MMIO 地址不能直接用 memset因为有些寄存器存在副作用而且 volatile 语义不匹配驱动里应该用平台提供的 readl/writel 或特定寄存器写接口。多线程并发写同一块共享内存memset 不是一个原子操作多线程同时 memset 同一区域会产生数据竞争需要锁或其他同步机制。这些场景的共同点在于你已经不是在处理“一块无类型的内存”而是在处理“有类型、有生命周期、有并发约束的对象”。memset 的理解里应该始终带着“无类型”这个默认前提。6. 实战自查常见误用现场与定位方法6.1 三个经典型误用现场先看第一个也是最常见的在清空指针变量本身而不是清指针指向的内存。char *p (char *)malloc(64); memset(p, 0, sizeof(p)); // 把 p 这个指针变量本身清了这行代码之后p变成 NULL原来申请的内存地址丢失既无法使用也无法释放。真正的意图可能是清 p 指向的 64 字节memset(p, 0, 64);第二个误用是在容器对象上直接 memset。比如清一个std::stringstd::string str hello; memset(str, 0, sizeof(str)); // 内部指针被抹掉 str world; // 行为未定义这段代码的运行结果取决于标准库实现但大概率会崩溃或产生内存访问错误因为str内部的指针已被清空但它并不知道自己要重新申请内存。标准库实现如果依赖默认构造后的堆状态这里就直接炸了。第三个误用是对只读字符串区域写数据char *s hello; memset(s, 0, strlen(s)); // 可能写入只读段字符串字面量在不少平台位于只读区写入会触发段错误即使不崩也属于未定义行为。正确做法是把字符串放在栈数组中char s[] hello; memset(s, 0, sizeof(s));6.2 用gdb与AddressSanitizer定位memset相关崩溃当 memset 导致崩溃或数据损坏时我通常按下面几步定位。先用 gdb 查看崩溃位置和调用栈。如果崩溃在某个虚函数调用处看对象地址的前 8 字节很可能全是 0这就意味着有人动过虚表指针。用 gdb 的 x 命令检查内存(gdb) p obj (gdb) x/8bx obj如果对象头几个字节是 0而正常对象应该是非零地址那基本可以断定发生过 memset。数据内容不对的问题可以直接看关键地址附近的字节(gdb) x/16bx arr同时对照源码里 memset 的n是不是按元素个数传的。这是最容易被忽略的一环。另一种更高效的排查方式是先用 AddressSanitizer 编译gcc -fsanitizeaddress -g test.c -o testASAN 会在越界 memset、释放后使用、内存泄漏这些问题上直接报出错误位置和调用栈比肉眼 review 快得多。对于内部指针被 memset 打断导致析构崩溃的问题ASAN 也能在崩溃前给出更清晰的提示。6.3 替代方案速查表我整理了一张表可以作为日常编码时的快速参考目标推荐做法说明清零 C 结构体/数组memset(p, 0, sizeof(*p))确保是字节数不是元素个数清零浮点数组memset(arr, 0, sizeof(arr))浮点 0.0 的字节全为 0可以安全使用初始化 C 对象构造函数、T{}、默认初始化避免虚函数表和容器内部指针被破坏int 数组统一赋某个整数值std::fill或循环memset 无法做出非重复字节的整数布局float 数组统一赋 1.0std::fill或循环memset 无法写出 IEEE 754 的 1.0分配大块且内容必须为 0calloc优先可以借助操作系统零页优化减少成本擦除敏感信息explicit_bzero/memset_s/SecureZeroMemory防止编译器把“写入后无人读”的 memset 优化掉清空容器元素clear()或重新赋值不要在 vector/string 对象本体上 memset访问设备寄存器特定驱动接口memset 语义不适用且 volatile 不保证这张表不是万能的但它能覆盖日常编码里 90% 的 memset 使用场景。你只要先判断“我要清的到底是一块无类型的内存还是一个有类型、有行为的对象”就知道该不该用 memset。按我这些年审查代码的经验memset 相关的 bug 最后几乎都能归到两类一类是把“元素个数”当成了“字节数”另一类是把它用在了不应该用它的对象类型上。如果你也想在团队里减少这类问题可以在代码审查的 checklist 里加一条问一句“这个 memset 要清的字节数是多少被清的对象是平凡可拷贝的吗”把这两个问题想清楚memset 其实就是一个非常可靠、非常好用的老朋友。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进