Skip to content

realloc 的两副面孔 ​

上一章的增长轨迹里埋了一根刺:容量 2→4 那行打印说"搬到",地址却和起步一模一样;4→8 那行也说"搬到",地址真换了。同一个函数、同一种调用,一会儿原地不动一会儿举家迁移,咱们这一章就把这件事查个水落石出。它值得专门一章,因为答案牵出的那条合同,您写任何用到动态内存的 C 代码都用得上。

标准怎么说 ​

标准的话咱们原样搬来。ISO C 的 realloc(C11 到 C23 条款号都是 §7.22.3.5)规定它二选一:要么原地扩大或收缩那块内存,内容保留到新旧大小的较小值;要么新开一块、把旧内容按较小值拷过去、释放旧块。失败时返回空指针,旧块原封不动还活着。还有一句特别容易被跳过的:只要成功了,旧指针就作废,即便扩容是在原地完成的也一样——旧指针作废是合同条款,不是分配器心情。

这份合同里还埋着一个边角,咱们写代码时最好绕开:新大小传 0。realloc(p, 0) 在老标准里是实现定义,各家想怎么处理就怎么处理;C23 起干脆判成未定义行为。所以别拿它当 free 用,内存不要了就明明白白 free。

探针:plain 下的大部分与必搬 ​

口说无凭,咱们拿探针量。小程序一路 realloc 翻倍,每次对比新旧地址:

C
static void probe(const char *label, size_t sizes[], int n) {
    char *p = malloc(sizes[0]);
    if (!p) { printf("%s: 初始 malloc 失败\n", label); return; }
    printf("%s 首块 %zuB -> %p\n", label, sizes[0], (void *)p);
    for (int i = 1; i < n; i++) {
        char *q = realloc(p, sizes[i]);
        if (!q) { printf("  扩到 %zuB 失败,停\n", sizes[i]); free(p); return; }
        printf("  %10zuB -> %p  %s\n", sizes[i], (void *)q,
               q == p ? "原地" : "搬移");
        p = q;
    }
    free(p);
}

sanitizer 关掉时,gcc 与 clang 输出一致,咱们取 gcc 的:

终端
[小块] 首块 16B -> 0x596566d27010
          32B -> 0x596566d28040  搬移
          64B -> 0x596566d28040  原地
         128B -> 0x596566d28040  原地
         256B -> 0x596566d28040  原地
         512B -> 0x596566d28040  原地
        1024B -> 0x596566d28040  原地
[中块] 首块 4096B -> 0x596566d28750
        8192B -> 0x596566d28750  原地
       16384B -> 0x596566d28750  原地
       65536B -> 0x596566d28750  原地
      262144B -> 0x7ef7c33bf010  搬移

咱们把两副面孔各自的样子看清。小尺寸经常原地:glibc 多半是把堆顶空余直接划给了它,又因为探针是唯一住户,堆顶正好挨着。跨过约 128 KiB 一档的阈值(这个值 glibc 会动态调,别背数字)后,分配改走 mmap,旧地址和新区域接不上,必搬。再往上,1 MiB 到 64 MiB 每一次都搬。深究要读 glibc malloc 的实现,咱们点到为止;对写应用的人来说,知道"两副都存在"就够改变写法了。

探针:ASan 下每一次都搬 ​

同样的事咱们在 ASan 底下再跑一遍:

终端
[小块] 首块 16B -> 0x7a90529e0010
          32B -> 0x7aa0529e0040  搬移
          64B -> 0x7ad0529e0080  搬移
         128B -> 0x7b30529e0040  搬移
         256B -> 0x7b80529e0040  搬移
         512B -> 0x7bc0529e0080  搬移
        1024B -> 0x7c00529e0080  搬移

每一次都搬。sanitizer 的分配器故意不原地,为的就是把"地址变了"这件事放大给咱们看。于是同一个程序,plain 下三次扩容两次原地,ASan 下四次全搬,哪一副才是 realloc 的真面目——两副都是。结论只有一条,咱们把它写进使用契约:任何代码都不能赌 realloc 搬不搬。

这条"不能赌"还有一层意思,对咱们是礼物。咱们的代码如果偷偷依赖了"原地",平时跑得欢,一切到 sanitizer 立刻现形。分配器故意把最坏情况常态化,等于替咱们把侥幸心理上了刑,这也是讲 sanitizer 门禁那一章说过的"门禁"二字的本意。

合同落到契约:get 到的指针会过期 ​

由此推出咱们容器最重要的使用边界,您写业务代码时一定会撞上:jyp_get_vector_at 返回的元素指针,在下一次可能触发扩容的操作(push、insert、reserve)之后就作废了,哪怕那次扩容恰好原地。想长期持有某个元素,要么把值拷出来,要么事后再 get 一次。头文件注释里"直接读、直接改都行"说的是当下,不是永远。

⚠️ 咱们拿到手的元素指针别攒着过夜:下一次扩容后,它指向的可能是已经归还给分配器的旧内存,读它就是 ASan 一抓一个准的 use-after-free。

交棒 ​

到这一章为止,咱们的动态数组在内存里怎么长、长的时候发生了什么,都清楚了。还剩最后一摊事:这块内存从哪儿来、归谁管、装的东西里如果还有指针,拆房子的时候谁替它们办后事,以及内存不够的那一天会发生什么。这些问题的答案都在 common 层那套可以换装的分配器里,下一章收拢整个手搓系列的第一战。咱们下一章见。

87bb5f2 · 87bb5f2 · 2026-09-21