问题背景
最近在写项目CMoe-Counter,在涉及到内存分配时报标题中错误。该错误有以下两点神奇的特征:
MacOS
下用clang
编译后运行完全正常Ubuntu
下用gcc
编译后运行出上述断言错,但是在出错位置附近加puts("任意内容")
后,运行完全正常
错误分析
因为出错位置附近加puts("任意内容")
后,运行完全正常,且MacOS
下clang
编译后一切正常,初步推测该错误是由编译器不同引发。又由于断言在malloc
,该错误必定与内存分配有关。由于问题代码段在添加puts
等输出语句后问题消失,因此很难通过插入puts
的方法检测问题发生位置。不过在仔细检查代码后,终于将错误定位到了simple-protobuf的get_pb
函数上。仔细阅读该函数以及相关结构体定义,错误原因终于水落石出:
simple_protobuf.h
...
struct SIMPLE_PB {
uint32_t struct_len, real_len;
char target[];
};
typedef struct SIMPLE_PB SIMPLE_PB;
...
protobuf.c
...
SIMPLE_PB* spb = malloc(struct_len + sizeof(uint32_t));
...
原来,错误出在内存分配少了。可是为何mac下程序一切正常,在加入puts
后ubuntu下也正常了呢?
问题解释
1. 为何mac下程序一切正常
首先介绍以下两条规则:
- 64位系统下,内存单元一定以
8字节
对齐 - 当
malloc
分配空间不满8字节
的倍数时,自动补齐。
内存的对齐是由空间换效率的方法。
那么,上面的代码中假如struct_len
是8的倍数,加上sizeof(uint32_t)==4
,就必然不是8的倍数了。此时malloc
自动再加4字节补齐,恰好等于了struct SIMPLE_PB
中的两个成员uint32_t struct_len, real_len
的空间。
注
- 上面的这两条是经过简化的
- 由于内存的设计考量,实际上64位系统分配内存块是以16字节(128位)对齐
malloc
分配的内存还有overhead
信息(32字节),如果损坏该信息将报其它断言错- 错误代码的情况中,
struct_len=64+4=68
字节,加上4后实际传给malloc
是72字节,不满16的倍数,补为80字节(不含overhead
)
因此,在没有任何检查的情况下,由于空间足够,程序应当运行正常。很显然gcc
在这里采用了断言进行了检查,因而报错,帮助我们发现了这个隐藏的错误。
2. 为何在加入puts
后ubuntu下也正常
由于与编译器优化有关,我们只能从汇编代码上寻找原因了。
将问题代码单独提取出来,形成如下代码
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
struct SIMPLE_PB {
uint32_t struct_len, real_len;
char target[];
};
typedef struct SIMPLE_PB SIMPLE_PB;
struct DAT {
char n[64];
uint32_t c;
};
typedef struct DAT DAT;
static uint32_t read_num(FILE* fp) {
uint8_t c;
uint32_t n = 0;
uint8_t i = 0;
do {
c = fgetc(fp);
if(feof(fp)) return n;
else n |= (c & 0x7f) << (7 * i++);
} while((c & 0x80));
return n;
}
SIMPLE_PB* get_pb(FILE* fp) {
uint32_t init_pos = ftell(fp);
uint32_t struct_len = read_num(fp);
if(struct_len > 1) {
SIMPLE_PB* spb = malloc(struct_len + sizeof(uint32_t));
if(spb) {
spb->struct_len = struct_len;
spb->real_len = 0;
char* p = spb->target;
char* end = p + struct_len;
memset(p, 0, struct_len);
while(p < end) {
uint32_t offset = read_num(fp);
uint32_t data_len = read_num(fp);
if(data_len > 0) fread(p, data_len, 1, fp);
p += offset;
}
spb->real_len = ftell(fp) - init_pos;
return spb;
}
}
return NULL;
}
int main(){
SIMPLE_PB* spb = get_pb(fopen("dat.sp", "rb"));
DAT* d = (DAT*)spb->target;
printf("%d %d %s %u\n", spb->struct_len, spb->real_len, d->n, d->c);
return 0;
}
1. 在Mac下编译
clang test.c -O3 -o test
./test
68 13 fumiama 9
结果一切正常。
2. 在Ubuntu下编译
gcc -O3 test.c -o test
./test
test: malloc.c:2401: sysmalloc: Assertion `(old_top == initial_top (av) && old_size == 0) || ((unsigned long) (old_size) >= MINSIZE && prev_inuse (old_top) && ((unsigned long) old_end & (pagesize - 1)) == 0)' failed.
Aborted (core dumped)
错误出现。
查看汇编代码如下:
gcc -O3 -S test.c -o test.s
下面仅列出关键代码
.L2:
cmpl $1, %r12d ; 比较struct_len
jbe .L6 ; if(struct_len <= 1) return NULL
movl %r12d, %ebp ; ebp = struct_len
leaq 4(%rbp), %rdi ; 明确加了4
call malloc@PLT ; 调用实际按8字节对齐
testq %rax, %rax ; 比较spb
movq %rax, 16(%rsp) ; 将分配的指针放入内存
je .L6 ; if(!spb) return NULL
leaq 8(%rax), %r13 ; r13 = rax + 8指向了spb->target(char* p = spb->target)
movl %r12d, (%rax) ; spb->struct_len = struct_len
movl $0, 4(%rax) ; spb->real_len = 0(实际无法访问区域)
xorl %esi, %esi ; esi = 0
movq %rbp, %rdx ; rdx = struct_len
leaq 0(%r13,%rbp), %rax ; char* end = p + struct_len
movq %r13, %rdi ; rdi = p
movq %rax, %r15 ; r15 = end
movq %rax, 8(%rsp) ; 保存end备用
call memset@PLT
cmpq %r15, %r13 ; 比较p与end
jnb .L7 ; p>=end退出循环
.p2align 4,,10 ; 进入while循环...
.p2align 3
分析后发现,这段代码完全没有问题,也没有执行任何边界检查,因此问题并非出自这里。
使用gdb
调试后,发现问题出在printf
调用处:
也就是说,这个问题平时并不会出现,只有在调用printf
函数时,其内置的边界检测才会报错!
那么,让我们再来分析一下调用printf
时的汇编代码:
.LC0:
.string "rb"
.LC1:
.string "dat.sp"
.LC2:
.string "%d %d %s %u\n"
....
main:
.LFB54:
.cfi_startproc
leaq .LC0(%rip), %rsi
leaq .LC1(%rip), %rdi
subq $8, %rsp
.cfi_def_cfa_offset 16
call fopen@PLT ; 调用fopen
movq %rax, %rdi
call get_pb ; 调用get_pb
movl 4(%rax), %ecx ; ecx = real_len
movl 72(%rax), %r9d ; r9d = d->c
leaq 8(%rax), %r8 ; r8 = n
movl (%rax), %edx ; edx = struct_len
leaq .LC2(%rip), %rsi ; rsi = &"%d %d %s %u\n"
movl $1, %edi ; edi = 1
xorl %eax, %eax ; eax = 0
call __printf_chk@PLT ; 有检查的printf
xorl %eax, %eax ; return 0
addq $8, %rsp
.cfi_def_cfa_offset 8
ret
.cfi_endproc
可见入参一切正常,在gdb
中进一步做断点,发现在执行printf
前,求值也没有任何问题,下面截图中的代码甚至将每个变量都分离表示然后传入printf
,但是仍然触发了断言。
![]() |
![]() |
基于此,判断并不是printf
本身的问题,再结合断言由malloc
发出,因此尝试在printf
前加一条malloc
语句:
malloc(4);
printf("%d %d %s %u\n", spb->struct_len, spb->real_len, d->n, d->c);
果然,程序执行到malloc
就已经报相同错误。
到这里已经可以确定问题是memory corruption
,下面让就我们来复现添加puts
运行成功的场景:
puts("Magic!");
SIMPLE_PB* spb = malloc(struct_len + sizeof(uint32_t));
于是,错误奇迹般地消失了:
那么让我们来看看加了puts
之后的汇编代码:
.LC0:
.string "Magic!"
.text
...
.L2:
cmpl $1, %r12d
jbe .L6
leaq .LC0(%rip), %rdi
movl %r12d, %ebp
call puts@PLT
leaq 4(%rbp), %rdi
call malloc@PLT
...
可以推测,puts
申请并释放了一片内存,使后续的printf
可以直接使用puts
释放的内存块而无需经过内存完整性检测,因而调用成功。
具体来说,该内存块需要满足以下条件:
- 刚刚被释放,未挪作他用
- 大小恰与
printf
需要分配的块相同
但是,我们并不知道这个大小具体是多少,也不知道其申请的内存块是否唯一。实际上,使用malloc(BUFSIZ)
申请一片内存并释放后,断言仍然会出现。那么,为了验证我们的构想,只有遍历所有可能的情况了。
于是,编写fake_puts
函数如下:
void fake_puts() {
for(int i = 0; i < BUFSIZ; i++) {
void* p = malloc(i);
free(p);
}
}
使用该函数替换puts
fake_puts();
SIMPLE_PB* spb = malloc(struct_len + sizeof(uint32_t));
禁用gcc
优化后编译运行,果然消除错误。
解决方案
当然,要解决这个错误,只需要分配够空间即可。
SIMPLE_PB* spb = malloc(struct_len + 2 * sizeof(uint32_t));
附录
这里提供程序用到的文件dat.sp
的base16384编码,有兴趣的读者可以自己解码验证上述程序。
弐乶柕筩晛搐帄圀㴆