掌控程序运行,你需要一台超级变速箱

这篇博客深入探讨了程序在操作系统中的运行方式,特别是通过ptrace系统调用如何实现程序调试。从进程的管理结构到CPU寄存器,再到断点和单步调试,阐述了操作系统如何控制程序执行的全过程,并揭示了gdb调试器的工作机制。文章以实例解释了如何在程序运行时设置断点、单步执行和调试已运行的进程,揭示了操作系统和CPU在程序调试中的关键作用。
摘要由CSDN通过智能技术生成

计算机原理告诉我们,程序要运行,得转变成机器码,并存储到内存中,由CPU一条条提取,解释,并执行。

编译原理告诉我们,程序可以通过编译器,转变为机器码。

操作系统原理告诉我们,程序的加载、调度、退出等,由操作系统统一管理,并在其控制下进行。

操作系统为啥可以做到这些,那是因为操作系统先运行,拥有配置CPU的权利,也就是掌握了先机,从而将自身变为超级核心,后来者则失去了进入核心的机会。

你以为你的程序是完全按照你设定的逻辑执行的吗?也许在计算机的原始社会(没有操作系统时)是这样吧。在现代计算机世界,因为操作系统的出现,计算机已经进入了文明社会,程序的执行路径并不完全由自己决定。你的程序已不再是你看到的那样,它还隐藏的接入了操作系统的代码。也就是你的程序是和操作系统逻辑合并在一起运行的。

举个例子,当你使用系统调用后,它不再是你看到的一个简单的函数调用,而是进入了另一个世界----操作系统的世界。在这里,你可能会被停下来,由别的程序进入CPU运行。当你完成系统调用任务,准备返回时,可能会被要求处理信号,而不是直接返回到系统调用的下一行代码执行。总之,在操作系统接管的世界里,发生什么是不固定的。

但不管怎样,此时,你的程序毕竟还是自由运行的,并不受你的管控。你可能不满足于此,想要掌控你的程序运行。我们常常进行的gdb调试就是干了这件事。可是,你有没有想过,gdb为啥可以掌控程序的运行?

也许很多人会说,gdb是通过ptrace系统调用掌控程序运行的。它就像一台超级变速箱系统,提供给gdb掌控程序运行的能力。可是,有没有进一步想过,为啥通过ptrace就可以掌握程序的运行?这背后到底隐藏了什么秘密?

要搞清楚这个问题,我们需要了解程序的世界里有什么。我们并不一定要知道ptrace的很多细节,记住这些细节当然更好,但是如果不思考背后的真正原理,这些细节是很容易遗忘的,并且在实战中也难以发挥作用。相反,通过自己思考清楚背后的原理后,你也可以设计一套变速箱,完成相同的功能。这时的你,则实现了升维,对原来的问题就可以俯视了。

下面,我们就来看看如何更本质的探究“掌控程序”这件事

还是接着前面的问题,程序的世界里有什么,或者说机器的世界里,一个程序有什么,理清这个问题,再思考如何掌控程序,就会简单很多。

最直观的,我们知道,每个进程在操作系统里,是通过一个进程管理结构来表示的。这就像是一个进程的画像,通过这个管理结构,可以找到进程的堆栈、保存的CPU寄存器环境、内存空间,并进一步的获知代码和数据的位置。当然,详细的位置信息,需要程序自身带调试信息,这是通过在编译时加-g选项完成的。通过调试信息,就可以知道程序的汇编指令跟代码的对应关系,也可以知道各个变量的位置。当然,局部变量一般是存放在栈中的,通过入栈指令可用找出它们的对应关系。

现在,有了进程的一个大概样子后,我们看如何掌控进程的运行。为了便于说明问题,我们通过几个具体例子来说明。

首先,我们看看用gdb调试一个程序会发生什么

当用gdb启用一个程序时,gdb会通过ptrace系统调用,启动真正要执行的程序。此时,gdb告诉ptrace,这个待会要运行的程序,是需要调试的。这样,ptrace就会给待运行的程序打上一个标签,说这个程序是要调试的程序。这个标签一般是保存在前述的进程结构体中的。这样,当被调试进程加载后,准备运行时,会检查标签,发现被打了跟踪标签后,就会发送一个信号出来。在信号处理中,操作系统继续检查标签,发现是追踪进程后,就会将进程状态设置为停止状态,发送信号给父进程,也就是gdb进程。之后调用调度器,重新调度。

经过上述一系列操作后,完成了这样三件事:1修改当前进程状态为停止状态;2发信号通知父进程;3重新调度。

重新调度后,父进程就有机会占有CPU了,此时被追踪进程就放弃CPU,进入等待队列。父进程运行后,就知道子进程已经暂停了,然后就可以控制子进程了。

其次,gdb如何控制子进程,执行调试动作,获取调试信息

gdb作为父进程运行后,它也是一个独立进程,它如何获取进入等待队列的被追踪子进程的信息呢?比如子进程当前(被暂停时)的指令,子进程当前的变量信息。显然,一个进程是无法直接获取另一个进程的信息,此时,gdb还是需要借助ptrace系统调用。只不过这次它告诉ptrace,不用打标签了,而是帮我获取子进程的信息。比如,想要知道子进程当前指令,则ptrace就会去子进程的进程结构体中,获取被保存的最后指令;想要知道局部变量的内容,则ptrace就会去子进程保存的堆栈中获取变量值。当然,也可以修改这些信息。如何做,就不用说了吧。

再次,如何打断点呢?

这是gdb很常见的一个操作。断点的特点是子进程需要执行到断点位置时停下来。为了做到这一点,gdb先让ptrace获取断点位置的机器指令,将其保存在自己手里,并将子进程断点位置的机器指令替换为一条特殊指令。这样,当子进程运行到断点时,不再执行它本来的机器指令,而是要执行gdb通过ptrace给它设定的指令。当这条特殊指令执行时,CPU会执行特殊动作,也就是发信号给作为父进程的gdb。gdb拿到信号后,知道子进程进入了一个断点位置。然后其从自己保存的列表中找出匹配的项,将其中保存的真实指令在写回子进程的代码段中,并告诉用户,断点出现了。

进一步的,单步调试呢?

单步调试也是很常见的一个操作。显然,单步调试也可以跟断点一样来搞,但是这样做,显然存在明显的效率问题。x86提供了一个eflags寄存器位,当这个位被设置后,程序没执行一条指令,CPU就触发一个异常,并暂停程序的执行。借助这个功能,ptrace修改被调试子进程堆栈中保存的CPU寄存器环境中的上述bit位,然后唤醒子进程。子进程运行时,恢复CPU寄存器环境时,就会将上述标志位设置,这样,当子进程运行一条指令后,就会触发异常。子进程在异常中,将自己标记中停止状态,发信号通知父进程,触发调度。三部曲下来,父进程gdb进程执行时,就会获取信号,并知道子进程完成了单步命令的执行。

最后,我们看看如果调试运行中的进程呢?

这也是很常见的场景。如果进程已经运行了,那么该如何调试呢?通过上面的介绍,你也许大概猜出该如何做了吧。显然,也是通过ptrace系统调用。这时,需要告诉ptrace要调试的进程。也就是进程的pid。ptrace根据pid找到要调试的进程,将其打上标签,另外还需要将其父进程设置为gdb进程,也就是调用ptrace的进程,因为进程调试过程中,信号是发给父进程的,这是机制设计问题。这样,当被选定的进程再次执行时,就会因为标签等,而停下来,等待gdb的审视。

最后的最后,我们来总结一下:

之所以可以掌控某一个进程,是操作系统和CPU结合起来完成的。它们开了一个接口,通过这个接口,你可以观察某个进程的世界,修改它的世界。当它的世界发生你所期待的变化后,操作系统会告诉你,然后让你再次掌控这个孩子进程的世界。就是通过这个方法,我们实行了进程的调试。这个接口就是ptrace系统调用,它就像一个超级变速箱,满足你的一切掌控欲望。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

龙赤子

你的小小鼓励助我翻山越岭

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

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

打赏作者

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

抵扣说明:

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

余额充值