作者:Tim Shen
链接:https://www.zhihu.com/question/26623283/answer/33553948
来源:知乎
著作权归作者所有,转载请联系作者获得授权。
链接:https://www.zhihu.com/question/26623283/answer/33553948
来源:知乎
著作权归作者所有,转载请联系作者获得授权。
我应该是从这篇blog学来的。原文简单干脆,比我这篇罗嗦的文字要好多了。
----------------------
你问题提得蛮好的,只是可惜没人解释得通透。我看不少人提到了汇编,就是没进一步解释下去。
我年纪太小,不知道历史上这一切如何上演,所以或有出入,但并不影响解答此问。
0)史前
早期的CPU(也许并没有真正的实现)并不如今天的强大,内存读写的指令可能只有“从*常数*0x1234地址处读入1字节到寄存器a”,或者“把寄存器b的值写入*常数*地址0x5678这个地方”。
那个时候没有变量这一说,所有的内存读写都得指定好常数,也就是得把具体的数字(也称为字面量,literal)写死在程序里。
换言之,你如果想清空100字节的内存,而每条指令只能对内存中某一个字节进行写入,那就得写100条指令。随着要处理的数据的膨胀,程序也得跟着膨胀,而这是不可接受的。
1)流程跳转
为了解决这个问题,人们发明了控制流程的指令,比如“如果寄存器c的值为0,则略过下一条指令”和“无条件跳转到首地址为0x9012”的地方,从那个地方继续运行”。注意,指令本身也是编码成字节存在内存里的,这就是冯诺伊曼机器最优雅的地方。这时候我们可以写出循环了(这里不展开,感觉没必要)。
2)自修改程序
问题并没有解决,因为就算有了循环,整个程序还是只能写常量,所以如果想要对不同的内存块清零,只能写不同的指令。
但是人是很聪明的。
利用“指令本身也是编码成字节存在内存里”这个特征,我们可以通过修改内存来修改指令,继而修改行为。举例来说,在0x3454处存了一条指令,这条指令叫做“把0x5678开始的一字节清零”。比方说这条指令占用了三个字节,第一个字节告诉CPU这是一条清零指令,后两个字节(0x3455和0x3456)存了一个表示地址的整数,告诉CPU到底要把哪个字节清零。显然,这个整数会和0x5678有关,而且大多情况就是0x5678。
现在,要是CPU在某处执行了一句“把起始地址为0x3455的那个2字节数自增1”,那实际上0x3454处的指令就变成了“把0x5679开始的一字节清零”!
看到希望了么,我们可以写一个指令,然后不断修改这个指令,再加上流程控制的指令产生一个循环,就能用一段固定长度(注意,代码内容随着执行并不固定)的代码清零任意指定的一段内存块!
这叫做“自修改程序”。
3)间接寻址
后来的事情就很简单了。编写这种自修改程序极容易出错,因为稍微改错一个地方指令就全改乱了(比如在上例中如果把0x3454中的数字给改了,就完全变成了另一条指令),所以人们再次发明新的工具,叫间接寻址。所以有了这样的指令“把寄存器a存的数字当成地址,取出该地址处的字节放到寄存器b里”和“把寄存器a存的数字当成地址,把寄存器b的字节写入到该地址处”。
回到自修改程序的那个例子里,我们可以把“把0x5678开始的一字节清零”换成:
“把寄存器b设为0”
“把0x5678这个数字写到0x1234处”
“读入0x1234的数字到寄存器a”
“把寄存器a存的数字(此处即0x5678)当成地址,把寄存器b(此处为0)的字节写入到该地址处”
这样实现虽然看起来费事,但是总算得到了等价的功能。另外,我们以后只需要读写0x1234这个内存就能达到改变行为的目的,而不要冒险去修改指令本身了。换言之,指令本身被固化,其行为更加稳定。今天,代码虽然也在内存里,也编码成了一个个字节,但是一般不和数据放在一起,而且一般执行的时候是只读的。
假设程序员无限聪明(当然这种好事从来就没有发生过:),写的代码从来不出错,那么间接寻址是没有必要的,因为直接写自修改程序就行了。间接寻址没有增加任何新的功能,这点不像跳转指令。
4)指针
哦,现在解释指针就很简单了,指针就是间接寻址例子里面那个0x1234的内存块。它存了一片地址,而指针解引用(比如*p)就对应的是间接寻址读写的指令了。
所以说到最后,“为什么要有指针的”就可以化成“为什么要有间接寻址”,问题基本等价于“为什么不直接使用自修改程序”了。
希望我说得比较明白。
----------------------
你问题提得蛮好的,只是可惜没人解释得通透。我看不少人提到了汇编,就是没进一步解释下去。
我年纪太小,不知道历史上这一切如何上演,所以或有出入,但并不影响解答此问。
0)史前
早期的CPU(也许并没有真正的实现)并不如今天的强大,内存读写的指令可能只有“从*常数*0x1234地址处读入1字节到寄存器a”,或者“把寄存器b的值写入*常数*地址0x5678这个地方”。
那个时候没有变量这一说,所有的内存读写都得指定好常数,也就是得把具体的数字(也称为字面量,literal)写死在程序里。
换言之,你如果想清空100字节的内存,而每条指令只能对内存中某一个字节进行写入,那就得写100条指令。随着要处理的数据的膨胀,程序也得跟着膨胀,而这是不可接受的。
1)流程跳转
为了解决这个问题,人们发明了控制流程的指令,比如“如果寄存器c的值为0,则略过下一条指令”和“无条件跳转到首地址为0x9012”的地方,从那个地方继续运行”。注意,指令本身也是编码成字节存在内存里的,这就是冯诺伊曼机器最优雅的地方。这时候我们可以写出循环了(这里不展开,感觉没必要)。
2)自修改程序
问题并没有解决,因为就算有了循环,整个程序还是只能写常量,所以如果想要对不同的内存块清零,只能写不同的指令。
但是人是很聪明的。
利用“指令本身也是编码成字节存在内存里”这个特征,我们可以通过修改内存来修改指令,继而修改行为。举例来说,在0x3454处存了一条指令,这条指令叫做“把0x5678开始的一字节清零”。比方说这条指令占用了三个字节,第一个字节告诉CPU这是一条清零指令,后两个字节(0x3455和0x3456)存了一个表示地址的整数,告诉CPU到底要把哪个字节清零。显然,这个整数会和0x5678有关,而且大多情况就是0x5678。
现在,要是CPU在某处执行了一句“把起始地址为0x3455的那个2字节数自增1”,那实际上0x3454处的指令就变成了“把0x5679开始的一字节清零”!
看到希望了么,我们可以写一个指令,然后不断修改这个指令,再加上流程控制的指令产生一个循环,就能用一段固定长度(注意,代码内容随着执行并不固定)的代码清零任意指定的一段内存块!
这叫做“自修改程序”。
3)间接寻址
后来的事情就很简单了。编写这种自修改程序极容易出错,因为稍微改错一个地方指令就全改乱了(比如在上例中如果把0x3454中的数字给改了,就完全变成了另一条指令),所以人们再次发明新的工具,叫间接寻址。所以有了这样的指令“把寄存器a存的数字当成地址,取出该地址处的字节放到寄存器b里”和“把寄存器a存的数字当成地址,把寄存器b的字节写入到该地址处”。
回到自修改程序的那个例子里,我们可以把“把0x5678开始的一字节清零”换成:
“把寄存器b设为0”
“把0x5678这个数字写到0x1234处”
“读入0x1234的数字到寄存器a”
“把寄存器a存的数字(此处即0x5678)当成地址,把寄存器b(此处为0)的字节写入到该地址处”
这样实现虽然看起来费事,但是总算得到了等价的功能。另外,我们以后只需要读写0x1234这个内存就能达到改变行为的目的,而不要冒险去修改指令本身了。换言之,指令本身被固化,其行为更加稳定。今天,代码虽然也在内存里,也编码成了一个个字节,但是一般不和数据放在一起,而且一般执行的时候是只读的。
假设程序员无限聪明(当然这种好事从来就没有发生过:),写的代码从来不出错,那么间接寻址是没有必要的,因为直接写自修改程序就行了。间接寻址没有增加任何新的功能,这点不像跳转指令。
4)指针
哦,现在解释指针就很简单了,指针就是间接寻址例子里面那个0x1234的内存块。它存了一片地址,而指针解引用(比如*p)就对应的是间接寻址读写的指令了。
所以说到最后,“为什么要有指针的”就可以化成“为什么要有间接寻址”,问题基本等价于“为什么不直接使用自修改程序”了。
希望我说得比较明白。