从内存与汇编的角度理解C语言指针第05篇:为什么printf(1)报错、而printf(0)不报错?【视频解析】

本文通过深入剖析左耳朵耗子的文章,揭示了C语言中结构体成员数组访问的汇编细节,探讨了为何`printf(f.a->s)`在不同情况下的行为差异,以及为何`printf(0)`意外未报错。作者通过实验和调试,揭示了printf函数内部的分支逻辑对内存访问的影响。
摘要由CSDN通过智能技术生成

!!!想直接看视频的朋友请点击这里!!!

一、缘起

左耳朵耗子写过这样一篇文章:《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(由参数决定)内存单元,如果访问受保护的内存单元,就会报错。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值