Skip to content
← 返回C列表
知识专题

C 内存模块:所有权、失败原子性与边界诊断 ​

本轮仅全文静态复核,示例未执行;此前工具链记录不构成本轮运行证据。

先完成指针与数组以及基础控制流。本篇的目标是独立设计一个动态内存模块:说明谁拥有指针、失败后还能否重试、如何证明长度计算安全,最后通过故障注入验证。教学标准仍为C11,验证工具链为Zig0.15.2所带Clang;Windows PowerShell和Linux Bash分别构建,不涉及macOS。

对象生存期、表示与别名 ​

变量名、对象、地址和指针值是不同概念。对象有存储期及生存期;把地址复制到另一个指针不会延长对象生存期。释放后所有借用地址都失效,即使数值看起来没变。离开函数后返回局部数组的地址同样无效。

unsigned char可用于查看对象表示,但把字节强转为任意结构体指针不自动满足对齐、有效类型或生存期条件。文件协议应该逐字段解码,不能直接把struct写入文件:填充字节、字节序、整型宽度和ABI都可能变化。二进制长度以size_t计量;字符串长度还需要明确是否包括结尾NUL。这里处理任意字节,不附加NUL,也不能直接用%s打印。

memcpy要求源、目标区间有效且不重叠;memmove允许重叠。后者仍不验证指针或长度。本项目先分配新块,旧块保持存活直到两次复制完成,因此支持从本缓冲自身追加。成功后旧借用指针失效;失败后仍有效。调用者必须保证source至少指向length个可读字节;C函数不能从裸指针推断实际对象大小。

模块契约与失败原子性 ​

Buffer的零初始化状态合法。data独占一个由malloc兼容分配器取得的块,size表示已经初始化的字节数量。结构体不能按值复制后分别销毁,否则重复释放。传Buffer*只借用句柄;buffer_destroy释放所有权并清零,可重复调用。

追加先检查length > SIZE_MAX - size,再执行加法;先加后判断会让无符号回绕掩盖错误。带符号溢出则是未定义行为,不能把它当作检测手段。长度为零时不调用分配器或复制,避免给空指针传入需要有效对象的库操作。

本实现故意不用几何扩容,便于检查事务顺序:分配→复制旧数据→复制新增数据→释放旧块→提交指针和长度。失败返回-1,旧内容和所有权不变。分配回调只用于可控测试,必须返回可由free释放、与现有块不重叠的至少指定大小的块;不得返回栈地址、现有指针或抛出非本地跳转。真实调用使用malloc。

realloc适合需要保留一段前缀的场景,但应先把返回值存临时变量,失败时原块仍在;成功后旧指针失效。零大小重分配规则随标准演变,不依赖它表达释放。若追加源指向旧块,成功的realloc还会使源指针失效,所以不能直接照搬替换本项目的事务步骤。

完整字节缓冲项目 ​

仓库examples/c/buffer/包含三个文件,无第三方依赖。头文件声明公共契约,.c持有实现,测试是可执行入口。下载或新建项目时保持以下文件同目录。static私有函数留在实现文件;头文件保护避免同一翻译单元重复声明。ABI不是源代码组织方式:修改公开结构体布局可能破坏已编译调用方;发布二进制库时通常用不透明句柄并提供创建/销毁函数。

c
#ifndef LEARNING_BUFFER_H
#define LEARNING_BUFFER_H
#include <stddef.h>
/* Invariant: data is owned; size is the number of initialized bytes. */
typedef struct { unsigned char *data; size_t size; } Buffer;
int buffer_append(Buffer *b, const void *source, size_t length);
int buffer_append_with(Buffer *b, const void *source, size_t length,
                       void *(*allocate)(size_t));
void buffer_destroy(Buffer *b);
#endif
c
#include "buffer.h"
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
int buffer_append_with(Buffer *b, const void *source, size_t length,
                       void *(*allocate)(size_t)) {
    if (!b || !allocate || (!source && length) || length > SIZE_MAX - b->size)
        return -1;
    if (!length) return 0;
    unsigned char *next = allocate(b->size + length);
    if (!next) return -1;
    if (b->size) memcpy(next, b->data, b->size);
    memcpy(next + b->size, source, length);
    free(b->data);
    b->data = next;
    b->size += length;
    return 0;
}
int buffer_append(Buffer *b, const void *source, size_t length) {
    return buffer_append_with(b, source, length, malloc);
}
void buffer_destroy(Buffer *b) {
    if (b) { free(b->data); b->data = NULL; b->size = 0; }
}
c
#include "buffer.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
static void *fail(size_t n) { (void)n; return NULL; }
int main(void) {
    Buffer b = {0};
    assert(buffer_append(&b, "abc", 3) == 0);
    assert(b.size == 3 && memcmp(b.data, "abc", 3) == 0);
    unsigned char *old = b.data;
    assert(buffer_append_with(&b, "d", 1, fail) != 0);
    assert(b.data == old && b.size == 3);
    assert(buffer_append(&b, NULL, 1) != 0);
    assert(buffer_append(&b, "x", (size_t)-1) != 0);
    assert(buffer_append(&b, b.data, b.size) == 0);
    assert(b.size == 6 && memcmp(b.data, "abcabc", 6) == 0);
    buffer_destroy(&b); buffer_destroy(&b);
    assert(b.data == NULL && b.size == 0);
    puts("buffer: all contracts passed");
}

在仓库根目录执行;zig代表已安装工具链的可执行文件,PowerShell可写& '完整路径/zig.exe' cc ...。Linux运行./buffer;Windows输出buffer.exe并运行./buffer.exe。

sh
zig cc -std=c11 -Wall -Wextra -Werror -g examples/c/buffer/buffer.c examples/c/buffer/test.c -o buffer
./buffer

预期唯一输出buffer: all contracts passed,退出0。测试覆盖正常追加、内存不足、NULL长度不匹配、容量溢出、自身追加和重复销毁。分配失败通过回调确定复现,不向系统申请天文数字内存赌博。去掉失败前检查或提前释放旧块会破坏测试;断言启用时运行,不加-DNDEBUG。

编译链接、未定义行为与诊断 ​

头文件找不到属于预处理/编译阶段;链接阶段的undefined reference通常是漏传实现文件或符号不匹配;程序启动后崩溃才是运行问题。先启用警告,再最小化输入,画出每个指针的拥有者和最后有效时间。const只限制通过当前访问路径写入,不能证明其他别名不修改。

有可用运行库的Clang/GCC环境可使用以下附加诊断;具体平台需安装匹配的Sanitizer运行库,不把Zig上不支持的选项说成已运行:

sh
clang -std=c11 -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer examples/c/buffer/buffer.c examples/c/buffer/test.c -o buffer-check
./buffer-check

ASan侧重越界/释放后访问,UBSan侧重部分未定义操作;两者都不能证明所有指针永远有效,也不能替代错误路径测试。泄漏检查支持随系统和运行库不同。测量内存分配次数和峰值需要独立采样;本事务实现每次追加都复制旧数据,逐字节追加会形成二次方复制总量。性能敏感时再引入capacity和扩容,并重新证明别名与失败不变式。

线程共享还需要同步:两个线程无锁修改同一个Buffer会发生数据竞争。volatile不提供锁或跨线程发布保证。一个简单边界是把句柄完整交给一个线程,其他线程仅传消息;需要共享时在每次读写及销毁周围使用同一把锁,不能只给追加加锁。

独立练习与解答 ​

  1. 在不运行代码前说明自追加为什么安全;说明调用成功后原old指针能否再读。
  2. 添加一次性buffer_clone,要求分配失败时目标保持原样;不能直接复制结构体。
  3. 设计容量扩展方案,列出新增不变式与一项会使天真realloc实现失败的测试。
参考解答与验证条件
  1. 源仍指向未释放旧块,目标是新块;两次复制完成才释放。成功后old失效,不能解引用;失败时没有释放,仍可读。
  2. 构造零初始化临时Buffer next,调用buffer_append(&next, source->data, source->size);失败返回,成功后销毁目标并赋next。须处理target==source为立即成功,否则会把源销毁;测试空缓冲、自克隆、分配失败和克隆后独立修改。
  3. 新增size<=capacity且data块长度至少capacity。测试源为b.data+1、追加触发扩容。扩容前保存源偏移且证明它来自旧块并不容易泛化到任意指针比较;更清晰的接口是专门提供按索引复制函数,或始终使用临时副本。不能对不相关对象指针作减法来“检测归属”。

资料、适用边界与旧笔记 ​

2026-10-17核对WG14 C11工作草案的对象、库函数及内存接口定义,并查阅Clang诊断手册。国内中文检索核读Microsoft官方realloc文档,它对Windows运行库的零大小行为有实现说明,不能推广为所有标准版本通则。中文cppreference页面本次直接抓取未成功,不把失败页面算作已核读。原类型提纲、入门和进制仍保留,分别服务类型速览、独立基础练习和编码表示;本篇没有重复它们的正文。FFI旧文归Rust工作包审计,C侧仅保留精确引用。

继续阅读