C语言灵活数组---你不可不知的使用风险

  1. 引言
  2. 问题
  3. 探索原因
  4. 继续思考

引言

在 C 语言中,数组的长度在编译期固定,例如 int a[5]。

为了支持运行时可变大小的数据块,C99 引入了柔性数组/灵活数组(Flexible Array Member),允许在结构体的最后一个成员声明一个不完整数组类型,比如int a[];,再通过 malloc动态分配足够的连续空间。

但是灵活数组在实际使用中,也有一些需要注意的风险点。

问题

看linux调度器负载均衡代码的时候发现sched_domain结构体中的变量span被注释掉了,这个span就是sched_domain结构体的最后一个成员,是一个灵活数组,用来表示这个调度域中可以负载均衡的cpu的mask。

现在变成了这样,看注释的意思,貌似灵活数组在这里有问题?

struct sched_domain {
.......
        unsigned int span_weight;
        /*
         * See sched_domain_span(), on why flex arrays are broken.
         *
        unsigned long span[];
         */
};

然后我们看一下注释中提到的sched_domain_span函数就恍然大悟了,原来这里使用灵活数组的话会导致over-write。

static inline struct cpumask *sched_domain_span(struct sched_domain *sd)
{
        /*
         * Turns out that C flexible arrays are fundamentally broken since it
         * is allowed for offsetof(*sd, span) < sizeof(*sd), this means that
         * structure initialzation *sd = { ... }; which writes every byte
         * inside sizeof(*type), will over-write the start of the flexible
         * array.
         *
         * Luckily, the way we allocate sched_domain is by:
         *
         *   sizeof(*sd) + cpumask_size()
         *
         * this means that we have sufficient space for the whole flex array
         * *outside* of sizeof(*sd). So use that, and avoid using sd->span.
         */
        unsigned long *bitmap = (void *)sd + sizeof(*sd);
        return to_cpumask(bitmap);
}

探索原因

为什么sched_domain中的灵活数组会导致over-write呢?虽然注释中已经说的很清楚了,但是我们还是来做一些小实验实际验证一下吧。

首先我们写这样一个结构体:

struct A {
        long long a;
        int b;
        int c[];
};

容易知道在64位系统上这个sizeof(struct A)的大小应该是16,因为long long占8个字节,int占4个字节,然后因为结构体对齐的原因,int b后面还要填充4个字节。(可以自己写个简单的程序验证一下)

那么这时候这个灵活数组c的偏移是多大呢?是0xc也就是12个字节处:

struct A *aa = 0x0;
printf("%p\n", &aa->c);

//结果
c offset is 0xc

那么我如果写这么一个程序的话:

#include <stdio.h>
#include <stdlib.h>

struct A {
        long long a;
        int b;
        int c[];
};

int main()
{
        struct A *aa;

        aa = malloc(sizeof(*aa) + 3 * sizeof(int));

        aa->c[0] = 1;
        aa->c[1] = 2;
        aa->c[2] = 3;

        printf("aa->c[0]:%d aa->c[1]:%d aa->c[2]:%d\n",
                aa->c[0], aa->c[1], aa->c[2]);

        *aa = (struct A) {
                .a = 100,
        };

        printf("aa->c[0]:%d aa->c[1]:%d aa->c[2]:%d\n",
                aa->c[0], aa->c[1], aa->c[2]);

        free(aa);
}

编译出bin后执行,将得到以下输出:

aa->c[0]:1 aa->c[1]:2 aa->c[2]:3
aa->c[0]:0 aa->c[1]:2 aa->c[2]:3

进行了一次结构体赋值之后,灵活数组的内容被覆盖掉了!

这是因为刚才说的,结构体赋值会把sizeof这个结构体置0,而灵活数组c的偏移是0xc,与sizeof(struct A)重叠了4个字节。

继续思考

如果我把上面代码中的的:

        *aa = (struct A) {
                .a = 100,
        };

修改为:

        *aa = (struct A) {
                .a = 100,
                .b = 200,
        };

重新编译bin并运行,我们得到的结果又不一样了,变成了:

aa->c[0]:1 aa->c[1]:2 aa->c[2]:3
aa->c[0]:1 aa->c[1]:2 aa->c[2]:3

这个后面解释,我们继续修改,如果我们把上面代码中的struct A的内容修改为:

struct A {
        long long a;
        int b;
        int d;
        int c[];
};

那么在结构体赋值的时候无论是是否把a、b、d变量全部赋值,aa灵活数组的内容都不会被覆写!

接下来我们把代码精简一下:

#include <stdio.h>
#include <stdlib.h>

struct A {
        long long a;
        int b;
        int c[];
};

int main()
{
        struct A *aa;

        aa = malloc(sizeof(struct A) + 3 * sizeof(int));

        *aa = (struct A) {
                .a = 100,
                .b = 200,
        };
}

(会有内存泄漏,不过我们先不关注这方面了)

使用gcc编译出一个bin来,然后我们使用objdump反汇编看一下main函数的内容:

0000000000401195 <main>:
  401195:	55                   	push   %rbp
  401196:	48 89 e5             	mov    %rsp,%rbp
  401199:	48 83 ec 10          	sub    $0x10,%rsp
  40119d:	bf 1c 00 00 00       	mov    $0x1c,%edi
  4011a2:	e8 a9 fe ff ff       	callq  401050 <malloc@plt>
  4011a7:	48 89 45 f8          	mov    %rax,-0x8(%rbp)
  4011ab:	48 8b 45 f8          	mov    -0x8(%rbp),%rax
  4011af:	48 c7 00 64 00 00 00 	movq   $0x64,(%rax)
  4011b6:	48 8b 45 f8          	mov    -0x8(%rbp),%rax
  4011ba:	c7 40 08 c8 00 00 00 	movl   $0xc8,0x8(%rax)
  4011c1:	b8 00 00 00 00       	mov    $0x0,%eax
  4011c6:	c9                   	leaveq
  4011c7:	c3                   	retq
  4011c8:	0f 1f 84 00 00 00 00 	nopl   0x0(%rax,%rax,1)
  4011cf:	00

然后我们把代码中的结构体赋值中的.b = 200去掉然后再次编译并反汇编,得到的main函数的汇编如下:

0000000000401195 <main>:
  401195:	55                   	push   %rbp
  401196:	48 89 e5             	mov    %rsp,%rbp
  401199:	48 83 ec 10          	sub    $0x10,%rsp
  40119d:	bf 1c 00 00 00       	mov    $0x1c,%edi
  4011a2:	e8 a9 fe ff ff       	callq  401050 <malloc@plt>
  4011a7:	48 89 45 f8          	mov    %rax,-0x8(%rbp)
  4011ab:	48 8b 45 f8          	mov    -0x8(%rbp),%rax
  4011af:	48 c7 00 00 00 00 00 	movq   $0x0,(%rax)
  4011b6:	48 c7 40 08 00 00 00 	movq   $0x0,0x8(%rax)
  4011bd:	00
  4011be:	48 8b 45 f8          	mov    -0x8(%rbp),%rax
  4011c2:	48 c7 00 64 00 00 00 	movq   $0x64,(%rax)
  4011c9:	b8 00 00 00 00       	mov    $0x0,%eax
  4011ce:	c9                   	leaveq
  4011cf:	c3                   	retq

我们可以清楚地看到:

在结构体中(除了灵活数组外)只有两个结构体成员a和b的情况下,在使用{}初始化列表形式进行结构体赋值的时候如果把两个变量全部都进行初始化,则在汇编中会直接把相应的值赋值给这两个变量。

而如果使用{}初始化列表进行结构体赋值的时候只把(a、b)其中一个变量进行赋值的话,在汇编中会先把整个sizeof覆盖的区间全部清零,然后再对对应的变量进行初始化。

所以,在使用灵活数组的时候就应该注意以下情况:如果使用{}初始化列表形式赋值结构体;如果灵活数组的赋值在结构体初始化列表赋值之前;如果结构体最后一个结构体成员后面还需要填充字节。

以上条件全部满足时,应该注意是否存在以上的覆写情况。

最后,留一个小问题,上面的反汇编中我们可以看到,结构体区域清零的时候是使用movq $0x0,(%rax)来做的,那如果结构体特别大的话,还会使用mov指令去清零嘛?


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 857879363@qq.com