从裸机启动开始运行一个C++程序(九)

前序文章请看:
从裸机启动开始运行一个C++程序(八)
从裸机启动开始运行一个C++程序(七)
从裸机启动开始运行一个C++程序(六)
从裸机启动开始运行一个C++程序(五)
从裸机启动开始运行一个C++程序(四)
从裸机启动开始运行一个C++程序(三)
从裸机启动开始运行一个C++程序(二)
从裸机启动开始运行一个C++程序(一)

调用栈

参数传递

前面我们介绍了通过callret指令进行调用和回跳,但这样单纯的调用和回跳适用范围是很局限的,多数情况下我们还是需要携带参数。

例如,我们想把「输出到显存」这个需求封装成一个调用过程,而需要输出的数据则通过参数传递的方式来确定。为了简化问题,我们先来实现传递单个字符的情况。

要想传递参数其实很简单,我们只需要在call之前,将参数入栈即可,下面给出一个简单的示例(注意,下面的实例是有问题的!!!):

[bits 32]

begin:

mov ax, 00011_00_0b ; 选择3号段,数据段
mov ss, ax
mov eax, 0x1000
mov esp, eax        ; 设置初始栈顶

mov ax, 00010_00_0b ; 选择2号段,显存段
mov ds, ax

; 32位环境下,入栈只能32位,所以先组一下数据
mov al, byte 'A'  ; 字符'A'
mov ah, byte 0x0f ; 黑底白色
push eax          ; 整个32位都入栈(用的时候只解析低16位)
call print

hlt

print:

pop edx ; 出栈,写到edx暂存
mov [0x0000], dx ;16位才是有用的数据

ret ; 回跳

times 1024-($-begin) db 0 ; 补满2个扇区

如果读者尝试执行一下上面的实例,就会发现两个问题。第一,显存中的数据是不正确的,屏幕无法正常显示;第二,在执行到ret指令的时候会异常中断,CPU会复位。

出问题的点就在于对栈的不当使用。我们在希望传参的时候,把参数进行了压栈,而后执行call指令的时候,又隐式用到了栈,也就是cs:eip(实际上这里因为是近跳,只保存了相对位置)压栈。那么在print里面,直接弹栈的应当是回跳地址,而不是参数。

又执行到ret的时候,则会再次弹栈作为回跳地址,而此时弹栈的是参数,而不是回跳地址,于是此处触发了异常中断。因此,在print内部这样直接弹栈肯定是不正确的做法。

从另一个角度来解释,'A'0x0f以及补0的16位,这一组32位数据0x00000f41是在begin中压栈的,我们可以理解为「begin这个方法中分配的栈空间」,那么它应当归begin去管理和释放。而print过程中,只可读取,而不可随意释放(也就是不能弹栈),它只能去管理自己内部分配的栈空间。

那既然不能pop,我们又如何在print内部找到参数的位置呢?只能通过ss:esp来计算了,进入print时,栈顶指向的是回跳地址,再向上32位才是参数(注意栈指针是向低地址方向前进的,所以我们找栈内元素需要加上偏移地址走回去)。所以我们应当写作:

print:

mov edx, [ss:esp+4] ; esp+4才是参数位置
mov [0x0000], dx    ; 取低16位写入显存

ret ; 回跳,由于没有擅自pop,此时就会将正确的回跳地址弹栈

这时再重新运行则可以正常显示:
运行结果1

全局数据

前面的例程中,我们确实可以通过参数来动态变化需要打印的文字了,但问题是,我们写入显存的位置是固定的,也就是说,不管你调用多少次,显示的文字都只会在第一个位置。

然而我们自然是希望,每当调用一次print之后,「光标」可以向后移动,下次再调用的时候就可以在后面的位置继续打印了。

要想实现这种功能,咱们就需要一个专门的内存空间,来保存当前的光标位置,每当我们进行一次print以后,就把光标向后移动一次。那这个内存空间应当是全局的,不能随着某次调用就释放或清零,所以放在栈区就不合适了。刚才我们把初始栈顶设为了0x1000,那么0x200000~0x201000的位置就留给栈了,咱们再在数据段重新找一个地方存光标数据,比如就放在0x202000的位置。修改后的例程如下:

[bits 32]

begin:

mov ax, 00011_00_0b ; 选择3号段,数据段
mov ss, ax
mov es, ax      ; es也置为3号段
mov eax, 0x1000
mov esp, eax    ; 设置初始栈顶

mov ax, 00010_00_0b ; 选择2号段,显存段
mov ds, ax

; 初始化光标信息
mov [es:0x2000], dword 0 ; 初始化为0

; 32位环境下,入栈只能32位,所以先组一下数据
mov al, byte 'H'  ; 字符'H'
mov ah, byte 0x0f ; 黑底白色
push eax          ; 整个32位都入栈(用的时候只解析低16位)
call print

; 再打印一个字符
mov al, byte 'i'  ; 字符'i'
mov ah, byte 0x0f ; 黑底白色
push eax          
call print

; 再打印一个字符
mov al, byte '!'  ; 字符'!'
mov ah, byte 0x0f ; 黑底白色
push eax          
call print

hlt

print:
mov edx, [ss:esp+4] ; esp+4才是参数位置
; 获取光标信息作为偏移地址
mov ebx, [es:0x2000]
; 注意,此时ebx中的是字符数,而不是内存偏移量,因为一个字符要占2字节的显存(数据+颜色)
sal ebx, 1       ; 左移一位,相当于乘以2,算出实际的内存偏移量
mov [ebx], dx    ; 取低16位写入显存
; 改变光标信息
inc dword [es:0x2000]  ; 自增

ret ; 回跳

times 1024-($-begin) db 0 ; 补满2个扇区

一些需要注意的细节已经在代码注释中标注,希望读者仔细阅读,此处不再赘述。运行结果如下:
运行结果2

现场记录与还原

不知道大家有没有发现一个问题,通用寄存器我们在每一个调用过程中都可能用到,但是,当发生调用以后,寄存器就可能被调用的过程改变。换句话说,每次call一个过程以后,此时的寄存器值是不确定的,因为可能会被这个过程改乱。

所以这是一个很严重的问题,虽然callret可以让指令地址回跳,但是却无法让寄存器数据还原。解决的办法也是类似的,就是对于一个调用的过程来说,如果要使用某个寄存器,那么就事先「记录」以下这个寄存器原本的值,等到用过以后,再把这个寄存器进行「还原」。我们将这个动作称为「现场记录」和「现场还原」。

首先我先给一个例子来演示一下,如果不做现场记录和还原会发生什么:

[bits 32]

begin:

mov ax, 00011_00_0b ; 选择3号段,数据段
mov ss, ax
mov es, ax      ; es也置为3号段
mov eax, 0x1000
mov esp, eax    ; 设置初始栈顶

mov ax, 00010_00_0b ; 选择2号段,显存段
mov ds, ax

; 初始化光标信息
mov [es:0x2000], dword 0 ; 初始化为0

; 打印黑底白字'H'
mov al, byte 'H'  ; 字符'H'
mov ah, byte 0x0f ; 黑底白色
push eax          
call print

; 再打印个黑底白字'i'
mov al, byte 'i' ; 字符'i'
; 照理说ah跟上面一样,所以不用变
push eax
call print

hlt

print: ; 逻辑不变,这里把edx平替为eax
mov eax, [ss:esp+4] ; esp+4才是参数位置
; 获取光标信息作为偏移地址
mov ebx, [es:0x2000]
; 注意,此时ebx中的是字符数,而不是内存偏移量,因为一个字符要占2字节的显存(数据+颜色)
sal ebx, 1       ; 左移一位,相当于乘以2,算出实际的内存偏移量
mov [ebx], ax    ; 取低16位写入显存
; 改变光标信息
inc dword [es:0x2000]  ; 自增

ret ; 回跳

times 1024-($-begin) db 0 ; 补满2个扇区

运行后会发现,i字符没有正常打印:
运行结果3

我们观察此时的寄存器可以看到,eax的值是不正确的:
寄存器值被修改

这就是因为,print过程中修改了eax的值,导致回跳以后,寄存器的值没有还原。

所以,对于需要进行回跳的过程而言,做现场记录和还原是必要的,原则就是只要这个过程用到了的寄存器,就需要先进行现场记录。而在回跳之前,要把所有记录的进行还原。

修改后的print代码如下:

print: 
; 现场记录,由于过程用到了eax和edx,所以讲这两个寄存器的值入栈
push eax
push edx
; 下面是实际逻辑
mov edx, [ss:esp+12] ; 注意此时,因为现场记录又占用了2个栈空间,因此esp上移332位才是参数
; 获取光标信息作为偏移地址
mov eax, [es:0x2000]
sal eax, 1       ; 左移一位,相当于乘以2,算出实际的内存偏移量
mov [eax], dx    ; 取低16位写入显存
; 改变光标信息
inc dword [es:0x2000]  ; 自增
; 现场还原
pop edx
pop eax
ret ; 回跳

上面的例程又暴露出另一个问题,就是说因为现场记录这件事需要用到栈空间,因此会使esp进行偏移,后续非常不利于我们计算出原本的栈顶位置以及寻找参数。因此,推荐的做法是,在过程一开始的时候,就先记录一下此时的栈顶(也就是esp的值),然后再去操作栈。这样如果需要找到参数,也就不用再去计算当前的esp偏移量,而是直接用记录好的栈顶去计算。

原本我们是靠入栈来记录各种数据的,但现在咱们就是要记录栈顶呀,单纯入栈的话,esp还是会跑掉,所以它得是个固定位置才行。有的读者可能会想到,那就跟光标信息一样,通过固定的全局数据来存储。没错,这确实是一种可行的方案,但毕竟要访问内存,对于这种高频操作来说效率比较低,不过不用担心,Intel给我们提供了一个专门的寄存器来做这件事,这就是ebp寄存器。

ebp寄存器也是一种通用寄存器,你当然可以把它用到任何的用途上,但对于这种含有调用栈的过程来说,我们通常是用它来记录当前栈顶的。因此,在每个调用栈开始时,我们都需要记录这个栈顶,并且把它写到ebp中,等调用结束时,再把上一个栈顶还原给ebp,然后再进行回跳,以保证ebp永远都指向当前栈的栈顶。

完善后的代码如下:

print: 
; 现场记录
push ebp ; 栈顶记录(上一个调用栈的栈顶)
mov ebp, esp ; 用ebp记录现在的栈顶
; 通用寄存器的记录
push eax
push edx
; 下面是实际逻辑
mov edx, [ss:ebp+8] ; 现在再寻找参数时,就用ebp来计算了,ebp前有一个记录的上一个栈顶,以及一个回跳地址,所以固定偏移232位就是参数位置,不会随着入栈而跑偏
; 获取光标信息作为偏移地址
mov eax, [es:0x2000]
sal eax, 1       ; 左移一位,相当于乘以2,算出实际的内存偏移量
mov [eax], dx    ; 取低16位写入显存
; 改变光标信息
inc dword [es:0x2000]  ; 自增
; 现场还原
pop edx
pop eax
pop ebp ; 还原到之前调用栈的栈顶
ret ; 回跳

既然ebp用来记录栈顶了,因此我们在begin中配置好栈空间以后,也应当记录一下初始情况的栈顶,将它的功能利用起来:

begin:

mov ax, 00011_00_0b ; 选择3号段,数据段
mov ss, ax

mov eax, 0x1000
mov esp, eax    ; 设置初始栈顶
mov ebp, eax    ; ebp也记录初始栈顶

有的读者可能会疑惑,从begin跳转到print的时候,我们做了一系列现场记录的事情,但从MBR跳转到begin的时候为什么没有做?这个请大家一定要理解做现场记录的目的,只有需要回跳的过程,才有必要记录一下上一个调用时的现场。而begin咱们是通过MBR最后的jmp跳转过来的,MBR只是做单纯的引导,等执行到Kernel以后,就再也不会跳转回MBR了,因此begin可以视为这个操作系统的「主函数」,不会再回跳,自然也就没有必要记录上一级的现场了。

栈帧

另一个有意思的事情是,如果整个调用栈都做了这样的栈顶的记录的话,我们是可以通过当前栈空间的情况,来依次还原出调用栈的。下面我们给一个用例,不做逻辑功能,仅仅做栈调用的演示:

begin:

mov ax, 00011_00_0b ; 选择3号段,数据段
mov ss, ax

mov eax, 0x1000
mov esp, eax    ; 设置初始栈顶
mov ebp, eax

call f1
hlt

f1:
push ebp
mov ebp, esp

call f2 ; f1中调用f2

pop ebp
ret

f2:
push ebp
mov ebp, esp

pop ebp
ret

我们执行到f2的时候,看一下此时的栈空间情况:
栈帧还原

此时的ebp0x0ff0,指向当前(f2)的栈顶,我们找到ss:0x0ff0,也就是0x200ff0的位置,可以看到这里存放的就是上一个调用栈(f1)的栈顶0x0ff8。再查看ss:0x0ff8的位置,则可以看到再上一个调用栈(begin)的栈顶0x1000,正好对应我们指定的初始化栈顶位置。

在调用栈中的每次伴随记录栈顶的call过程,我们称其为一个「栈帧(stack frame)」,在执行过程中,我们随时都可以根据ebp和栈内存的数据情况,还原出整个调用栈来,通过每次栈顶的记录情况,来判断栈空间的归属情况(判断哪片空间是哪个栈帧使用的)。

小结

我们这一篇主要介绍的是调用栈的原理,当大家掌握了调用栈的方法以及栈帧还原的方法以后,我们就已经做好了所有准备来迎接C语言了。

本篇工程代码会上传至附件。

下一篇将会正式介绍如何跟C语言联动。
从裸机启动开始运行一个C++程序(十)

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

borehole打洞哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值