一、缘起
左耳朵耗子写过这样一篇文章:《C语言结构体里的成员数组和指针》。(本文只会展开讨论这篇文章中的一小部分,所以建议你有时间的话去看看完整的原文,写得很好。)
里面有一段C程序,如下(我删除了main方法中的参数,因为没有用到):
#include <stdio.h>
struct str {
int len;
char s[0];
};
struct foo {
struct str *a;
};
int main() {
struct foo f = {0};
if (f.a->s) {
printf(f.a->s);
}
return 0;
}
里面提到一个问题:为什么第13行和第14行中都用到了f.a->s
,但是第13行不报错,而第14行会报错?
二、从汇编代码找原因
C代码中第12~14行的代码对应的汇编代码如下:
// C代码12行:struct foo f = {0};
mov QWORD PTR [rbp-0x8],0x0 // 把数字0存入[rbp-0x8]内存地址处,占8个字节
// C代码13行:if (f.a->s) {
mov rax,QWORD PTR [rbp-0x8] // [rbp-0x8]内存地址处的值(即0)存入rax寄存器
add rax,0x4 // rax寄存器中的值加上4,结果仍然保存在rax中
test rax,rax // 测试rax寄存器中的值相与后是否为0(这里不为0)
je 0x401582 <main+50> // 如果值为0,则跳到其他地址(即不执行后面的printf)
// C代码14行: printf(f.a->s);
mov rax,QWORD PTR [rbp-0x8] // [rbp-0x8]内存地址处的值(即0)存入rax寄存器
add rax,0x4 // rax寄存器中的值加上4,结果仍然保存在rax中
mov rcx,rax // 把rax寄存器中的值存入rcx寄存器
call 0x402a90 <printf> // 调用printf方法
2.1 第6行的0x4
是怎么来的
C代码中f.a
得到了struct str
,汇编代码中第5行的[rbp-0x8]
得到的就是str
的地址。
当要访问str->s
时,需要在[rbp-0x8]
的基础上增加int len;
的长度,即4个字节,因此得到了0x4
。
你可以验证一下,把int len;
改成double len;
,反汇编查看一下,0x4
就变成了0x8
。
2.2 到底是哪一行汇编在报错
对比C代码13行和14行对应的汇编代码可以发现:
C代码f.a->s
对应的汇编是:
mov rax,QWORD PTR [rbp-0x8] // [rbp-0x8]内存地址处的值(即0)存入rax寄存器
add rax,0x4 // rax寄存器中的值加上4,结果仍然保存在rax中
这两行汇编并没有做任何非法的事情。
C代码13行不报错的原因也是这样的,因为C代码13行的汇编代码并没有做任何非法的事情。
C代码14行报错的原因不在f.a->s
上面,而在printf
方法里面,即第14行汇编代码报错了。
2.3 报错的具体原因是什么
左耳朵耗子
原文中对报错原因的解释:
访问0x4的内存地址,不crash才怪。
首先,他的这句话是对的。程序确实访问了0x4
内存地址,而这个地址是受操作系统保护的地址,不允许程序访问,因此报错。
其次,这句话太简洁了,我没有真正理解,因为我从上面的汇编代码里面看不出来哪里访问了0x4
内存地址,明明没有访问啊。
三、测试printf(1)
和printf(0)
3.1 测试printf(1)
按照上面的思路,如果printf(4)
就是访问0x4
内存地址,那printf(1)
应该就是访问0x1
内存地址吧,它也是受保护的地址,不允许访问,应该会报错吧。
执行以下程序:
#include <stdio.h>
int main() {
printf(1);
return 0;
}
结果真的报错了(exit code
不为0
代表出错了),很开心,说明上面猜测是对的:
Process finished with exit code -1073741819 (0xC0000005)
3.2 测试printf(0)
满怀信心地把1
改成了0
,再执行了一下以下代码:
#include <stdio.h>
int main() {
printf(0);
return 0;
}
发现结果居然没有报错!!!:
Process finished with exit code 0
于是我傻眼了,难道访问0x0
这样的系统起始地址反而是允许的???
3.3 0x0
内存地址允许访问吗?
于是测试了一下,如下图,0x0
内存地址确实是不允许访问的。
3.4 得出结论
既然0x0
内存地址不允许访问,那么结论很简单:printf(0)
根本就没有访问0x0
内存地址。
可是问题也就又来了:结论是推理出来的,证据在哪里? 上面明明说printf(1)
和printf(4)
访问了内存地址,现在printf(0)
又没有访问内存地址,证据何在?
四、找证据
4.1 弯路
很自然地想看看printf
源码里面到底是怎么写的。
于是查了一些文章,去下载了C语言本身的源代码,过程见《怎样找到C语言本身的源码(比如stdio),对学习C语言有帮助吗?》。
看了printf
的源码,发现里面层层调用,不止有C代码,还有汇编代码,完全不知道走的是哪条分支,看着看着就看不懂了,遂放弃。
4.2 gdbgui调试
于是打开了两个gdbgui工具,一个测试printf(0)
,另一个测试printf(1)
,一步步调试printf
里面的汇编代码,对比查看到底哪一步不一样,还真被我给找到了。
详细过程请看视频(11分06秒处开始),这里只贴一下重要的汇编代码:
test r13,r13 // r13寄存器的值自身做与运算
je 0x7ffff7e406ee // 如果结果是0,则跳转到0x7ffff7e406ee地址处去执行指令
mov ebx,r15d // 如果结果不是0,则执行这里
关键就在这里,其中的r13
寄存器中的值,就是我们从printf
函数中传来的值。
当调用printf(0)
时,test r13,r13
得到的是0,于是跳转到0x7ffff7e406ee地址处去执行指令,此后再也没有遇到访问0x0
内存单元的指令。
当调用printf(1)
时,test r13,r13
得到的不是0,于是继续往下走,此后在别的更深层的方法中遇到了访问0x1
内存单元的指令vmovdqu ymm8,YMMWORD PTR [rdi]
(这里的rdi
寄存器中的值就是0x1
),于是报错。
说得再直白一点就是:在printf
及其后的汇编代码里,有一个if-else
分支,当参数为0
时,走了if
分支,这条分支没有访问0x0
内存单元;参数不为0
时,走了else
分支,这条分支里面访问了0x1
(由参数决定)内存单元,如果访问受保护的内存单元,就会报错。