背景

研究 redis zmalloc 变更历史时中的一段

基于 11965 里面的 示例代码

这段代码展示了一个包含柔性数组的结构体,以及内存分配和缓冲区溢出的相关问题

#include <stdio.h>
#include <malloc.h>
#include <string.h>

struct a {
    char c;
    char buf[];
};

int main() {
    const int BUFSIZE = 5;

    struct a *a = malloc(sizeof(*a) + BUFSIZE);
    printf("sizeof(struct a)=%zu \n", sizeof(struct a));
    printf("sizeof(*a)=%zu \n", sizeof(*a));
    size_t n = malloc_usable_size(a);
    printf("a usable size=%zu \n", n);

    // Dummy condition to prevent compiler throw warning on compile time
    // n will always be n >= BUFSIZE
    size_t copy = n >= BUFSIZE ? BUFSIZE + 3 : BUFSIZE;
    memcpy(a->buf, "12345678", copy);
    printf("%.*s \n", (int) copy, (char*) a->buf);

    return 0;
}
gcc main.c
./a.out

能 “正常” 运行

sizeof(struct a)=1
sizeof(*a)=1
a usable size=24
12345678

gcc -O2及以上

gcc -O2 main.c
sizeof(struct a)=1
sizeof(*a)=1
a usable size=24
*** buffer overflow detected ***: terminated
Aborted (core dumped)

ida反编译

gcc -O0

gcc -O2

变成 __memcpy_chk

代码解析

结构体定义

/*
 * 柔性数组(Flexible Array Member)示例
 * 柔性数组是C99标准引入的特性,允许结构体的最后一个成员是未知大小的数组
 */
struct a {
    char c;        // 固定大小的成员
    char buf[];    // 柔性数组成员
};
  • buf 是一个柔性数组成员,它不占用结构体的静态大小,而是在运行时通过动态内存分配确定大小

内存分配

malloc

struct a *a = malloc(sizeof(*a) + BUFSIZE);
  • 分配的内存大小为 sizeof(struct a) + BUFSIZE(即 1 + 5 = 6 字节)
  • sizeof(struct a) 是 1(因为只有 char c 占用 1 字节,柔性数组 buf 不占用静态大小

malloc_usable_size

  • 这个函数返回实际可用的内存大小(可能比请求的大小更大,因为内存对齐和分配策略)
  • 这里返回 24,说明实际分配的内存是 24 字节(这是 glibc 的内存分配策略,通常会有最小分配大小和对齐)

memcpy 和缓冲区溢出

size_t copy = n >= BUFSIZE ? BUFSIZE + 3 : BUFSIZE;
memcpy(a->buf, "12345678", copy);
  • copy 的值是 BUFSIZE + 3 = 8(因为 n = 24 >= 5
  • memcpy 尝试拷贝 8 字节(“12345678”)到 a->buf,但 a->buf 的实际可用大小是 BUFSIZE = 5,因此会发生缓冲区溢出

glibc 内存分配策略

glibc 的内存分配策略中,最小分配大小(minimum allocation size)内存对齐(alignment) 是两个相关但不同的概念,它们共同影响 malloc 返回的内存块的实际大小

1. 最小分配大小(Minimum Allocation Size)

定义

glibc 的 malloc 不会精确分配请求的字节数,而是会分配一个至少满足请求大小的内存块,并且通常会分配更多(由于内存池管理和效率优化)

原因

  • 减少内存碎片(避免频繁分配和释放小内存块)
  • 提高内存管理效率(glibc 使用 ptmalloc,基于 chunk 的内存管理机制)

典型值

  • 在 64 位系统上,glibc 的 malloc 最小分配通常是 16 或 24 字节(即使你只请求 1 字节)

2. 内存对齐(Alignment)

定义

malloc 返回的内存地址必须满足特定的对齐要求(通常是 2 * sizeof(void*)16 字节对齐,取决于平台)

原因

  • 某些 CPU 架构要求特定类型的数据(如 doublelong longSSE 指令等)必须对齐访问,否则会引发性能下降或崩溃
  • 例如,在 x86-64 上,malloc 通常返回 16 字节对齐 的地址

对齐规则

  • 32 位系统:通常 8 字节对齐
  • 64 位系统:通常 16 字节对齐(但可能更高,如 32 字节对齐用于 AVX 指令)

两者的关系

特性 最小分配大小 内存对齐
作用 确保分配的内存块足够大,避免频繁分配小内存 确保返回的地址满足 CPU/指令集的对齐要求
影响 决定 malloc_usable_size 返回的实际可用大小 决定返回地址的低位是否为零(如 address % 16 == 0
典型值 16/24 字节(64 位) 16 字节(64 位)
是否可调整 由 glibc 内部策略决定,不可直接调整 可通过 aligned_allocposix_memalign 自定义对齐

相同点

  • 两者都会导致 malloc 分配的内存比请求的更大
  • 都是出于性能和正确性的考虑

不同点

  • 最小分配大小 关注的是内存块的最小大小(防止碎片化)
  • 内存对齐 关注的是返回地址的数值是否满足 CPU 要求

malloc 分配的内存比请求的大,超出部分能用吗?

malloc 分配的内存通常会比请求的更大(由于 最小分配大小 和 内存对齐),但 超出请求大小的部分是否能用?

这涉及 C 语言标准、未定义行为(UB)和 实际实现 的问题

1. 标准规定:超出请求部分不可用

C 语言标准(C11/C17)明确规定:

malloc(size) 返回的内存块只能安全访问 [0, size) 字节,超出部分属于 未定义行为(Undefined Behavior, UB)

  • 即使 malloc_usable_size() 返回更大的值,也不能保证超出部分可用
  • 使用超出部分可能导致:
    • 缓冲区溢出(Buffer Overflow)
    • 内存损坏(Memory Corruption)
    • 程序崩溃(Crash)
    • 安全漏洞(如被攻击者利用)

2. malloc_usable_size 的作用

malloc_usable_size(glibc 扩展)返回 实际可用的内存大小,但:

  • 它仅用于调试和优化,不能依赖它扩展内存访问范围
  • 它的返回值可能比 malloc 请求的更大(由于内存池管理、对齐等)
  • 但标准并未保证超出部分可安全使用

示例

char *p = malloc(10);           // 请求 10 字节
size_t usable = malloc_usable_size(p);  // 可能返回 24
  • 你可以安全使用 p[0..9](请求的 10 字节)
  • p[10..23] 虽然可能“能用”,但属于未定义行为(UB)

3. 为什么“能用”但“不应用”?

不同环境行为不同

  • Linux glibc:可能允许访问超出部分(但未来可能改变)
  • Windows CRT:可能立即崩溃(如启用 _FORTIFY_SOURCE
  • ASAN(AddressSanitizer):会检测并报错

内存池管理可能破坏数据

  • malloc 返回的内存块可能被 glibc 用于内部管理(如 chunk 结构)
  • 写入超出部分可能破坏堆元数据,导致 free() 崩溃或安全漏洞

未来可能失效

  • 新版本 glibc 可能优化内存分配策略,导致超出部分不再可用
  • 编译器优化可能假设你遵守标准,并优化掉“越界”访问

最佳实践

  1. 永远不要依赖 malloc_usable_size 扩展访问范围

  2. 如果需要更大内存,应直接 malloc 更大的空间

  3. 使用 realloc 调整大小(如需动态扩展)

  4. 启用ASAN安全选项检测越界访问

gcc知识

Function Attributes alloc_size

#include <stdio.h>
#include <malloc.h>

int main() {
	char *p = malloc(5);
	memcpy(p, "1234567890", 10); // 写入 10 字节(超出请求 5)
	printf("%s\n", p);  // 可能正常打印,但属于 UB!
	return 0;
}

仿 redis 之 attribute alloc_size

#include <stdio.h>
#include <malloc.h>
#include <string.h>

struct a {
    char c;
    char buf[];
};
#define UNUSED(x) ((void)(x))

__attribute__((alloc_size(2), noinline)) void *extend_to_usable(void *ptr, size_t size) {
    UNUSED(size);
    return ptr;
}

int main() {
    const int BUFSIZE = 5;

    struct a *a = malloc(sizeof(*a) + BUFSIZE);
    printf("sizeof(struct a):%zu \n", sizeof(struct a));
    printf("sizeof(*a):%zu \n", sizeof(*a));
    size_t n = malloc_usable_size(a);
    printf("a usable size: %zu \n", n);

    // Dummy condition to prevent compiler throw warning on compile time
    // n will always be n >= BUFSIZE
    size_t copy = n >= BUFSIZE ? BUFSIZE + 3 : BUFSIZE;
    a=extend_to_usable(a, n);
    memcpy(a->buf, "12345678", copy);
    printf("%.*s \n", (int) copy, (char*) a->buf);

    return 0;
}

UNUSED 宏

#define UNUSED(x) ((void)(x))

此宏可以强制的产生一个对该变量的使用操作, 从而消除编译器产生的”unused variable”警告

extend_to_usable

extend_to_usable是一个纯声明性质的函数, 通过alloc_size(2)属性标记, 向编译器表明这个函数的第二个参数是 ptr 执行的内存空间的实际大小

此函数通常与redis里的zmalloc_size函数配合使用, 通过zmalloc_size函数获取实际分配的内存空间大小后, 使用extend_to_usable函数向编译器声明后续代码会按照实际分配的空间大小使用

ASan

gcc -fsanitize=address

-fsanitize=address: 这个选项启用的是 AddressSanitizer (ASan),它是一个动态内存错误检测工具,能够检测到内存越界、使用后释放、堆栈溢出等错误。ASan 通过拦截程序的内存分配和释放操作,插入一些检查代码来实现这些功能

-fsanitize=address 依赖于拦截 mallocfree 等函数

_FORTIFY_SOURCE

_FORTIFY_SOURCE 是 GCC 和 glibc 提供的一个 编译时+运行时的安全增强机制,主要用于 检测常见的缓冲区溢出和其他内存相关的安全漏洞

基本概念

_FORTIFY_SOURCE 是一个宏定义,当被启用时,它会:

  1. 替换某些容易出错的函数调用(如 strcpy, memcpy 等)为更安全的版本
  2. 在编译时检查一些明显的缓冲区溢出
  3. 在运行时检查缓冲区边界

gcc -O2 -Wall -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 main.c

FORTIFY_SOURCE

  • 3 在 2 的基础上,可以对 malloc 出来的内存进行检测 (gcc> 12)

  • 2 在 1 的基础上,可以对栈变量进行检测

  • 1 在编译时进行检测

  • 优化等级必须要大于 O2 这个选项才会生效