文章目录
在家太无聊了,不能总想用睡觉迎接下一次面试吧,于是精神小伙(?)下午研究了一下之前面试里回答不好的问题,其实就是操作系统,顺便拓宽下知识面,初探汇编(?
后续还会写写操作系统里进程切换啊等等发生的事情,背面经太无聊了,一问点别的就GG了,自己会了还能和面试官聊聊,要不然只有3s的尴尬沉默。顺便看看大一没看懂的CSAPP,大概想着四月份多学点趁着在家。
本来想一下午都写完呢这个系列,后面的写了一部分了(就是没排版乱乱的),结果万恶的学弟居然以实习之名大晚上给我发鸡爪,耽误了我的创作。
之前也看了好多博客,感觉自问自答带点情景风的风格还挺好玩,至少我自己能看下去,因为感觉面试很考验画图的能力,自己写博客的时候也有动手画画, 会尽力写写遇到的面试题什么的,一方面自己多思考,一方面总结经验。个人能力和知识有限,如有错误,还望指正哈哈哈。
一、说说系统调用
当一个进程正在运行的时候,遇到读写文件这样的操作,由于I/O操作,基于我们学过的OS知识,可以条件反射的知道此事进程会从运行态转变为阻塞态。更深入一些,此时会发生一个中断,这个中断会导致系统把当前用户进程的一些寄存器信息保存到内核堆栈中(将来恢复的时候需要从中断位置继续执行),接着执行中断服务程序,这里说的中断服务程序指的是系统调用。
问题来了,内核实现了很多的系统调用,那么进程怎么知道要执行哪一个?这个时候需要传递一个系统调用号来帮忙,这个调用号放在了%eax
寄存器中,在Linux中是通过执行int $0x80
进行系统调用的中断。
举个例子来说,我们想打印当前时间,那么在C++中可以通过库函数来实现。
#include<stdio.h>
#include<time.h>
int main(){
time_t tt;
struct tm *t;
tt=time(NULL);
t=localtime(&tt);
printf("time:%d:%d:%d:%d:%d:%d\n",t->tm_year, t->tm_mon+1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec);
return 0;
}
//结构体如下:
struct tm {
int tm_sec; /* 秒,范围从 0 到 59 */
int tm_min; /* 分,范围从 0 到 59 */
int tm_hour; /* 小时,范围从 0 到 23 */
int tm_mday; /* 一月中的第几天,范围从 1 到 31 */
int tm_mon; /* 月份,范围从 0 到 11 */
int tm_year; /* 自 1900 起的年数 */
int tm_wday; /* 一周中的第几天,范围从 0 到 6 */
int tm_yday; /* 一年中的第几天,范围从 0 到 365 */
int tm_isdst; /* 夏令时 */
};
利用汇编语言来实现系统调用会发现,实际上是通过mov $0xd %%eax来将系统调用号放入%eax寄存器中(time()的系统调用号是13),然后通过执行int $0x80,系统就会去执行time()这个系统调用了。
/*有省略...*/
time_t tt;
struct tm *t;
asm volatile(
"mov $0,%%ebx\n\t"
"mov $0xd,%%eax\n\t"
"int $0x80\n\t"
"mov %%eax,%0\n\t"
: "=m" (tt)
);
二、程序是怎么运行的
这个问题在腾讯总监面的时候被问过,记录一下。
面试官:同学,说一下一个项目run之前,都会做哪些事情?
我:编译吧,嗯,还有检查一下错误。
面试官:编译之前还有吗?
我:???
之前没注重这部分,只说出了个编译,后来问了问同学应该是预编译、编译、汇编、链接。知道大概流程了,总要刨根问底,看看执行程序时,内部到底发生了啥吧,秉承着打破砂锅问到底(? 哈哈哈哈,是为了避免面试尴尬的沉默。
说这么少,感觉会让面试官觉得像是背下来的,那多了解点好了,下面给出一个经典的程序运行的步骤:
-
操作系统在创建进程后,把控制权交到程序的入口,这个入口往往是运行库中的某个入口函数
-
入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量的构造
-
入口函数在完成初始化后,调用main函数,正式开始执行程序主体部分
-
main函数执行完毕后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后系统调用结束进程
这似乎很容易理解,但是对应预编译、编译、汇编、链接,要怎么理解呢,留到后面的文章在写啦。
下面结合汇编指令和内存变化来详细介绍一下。又到了举例子的时候了。我需要完成一个简单的加法操作,代码人人会写,那么它到底有什么奥妙呢?
int add(int a,int b){
return a+b;
}
int main(){
int a=1,b=2;
int c=add(a,b);
}
汇编代码如下所示(只选取了部分):
这里又要从腾讯的一个面试题说起。
面试官:进程的内存分布了解吗?那画个图来看看吧。
我:面试官,我画好了(wps上哗啦哗啦,画好了)。
面试官:不错,是我想要的答案。那你说说低地址和高地址,以及堆栈的增长的方向。
我:哎,是啥来着???(勉强回答上来。
复习进程和线程的区别的时候,我们应该有印象,每个进程都会有自己的内存空间,具体这部分都有什么信息呢,见下图。
程序中的出现的变量存放在栈中,自顶向下分别是高地址-低地址,假设有这样一个初始化栈,其中%esp和%ebp都是寄存器,为了说明它们的用处,已在图中标明。
首先,执行main中的代码,栈中的变化和汇编指令对应如下。
接下来,subl $24,%esp
指令会将栈顶指针(%esp)的地址值减去24,这个时候,它应该指向地址72。接下来,能看到有两行差不多的指令:
movl $1,-12(%ebp)
movl $2,-8(%ebp)
这表示把数1和数2分别放置到当前指针-12和-8的位置上,也就是数1存到了地址值为84的地方,数2存到了地址值为88的地方上。
不难注意到,接下来四条指令,非常相似,在这里都列出来,然后解释下。
movl -8(%ebp),%eax
movl %eax,4(%esp)
movl -12(%ebp),%eax
movl %eax,(%esp)
首先,%ebp的基础上减去8的地址上存放的数是2,通过movl指令,我们将数字2赋值给了%eax寄存器,然后又是通过一个movl指令,我们把它放到了%esp+4的位置上。
盲猜,读者可能会忘记%eax是干啥的寄存器了,在这里,我们重新提下。在文章的最开头我们说它是放调用号的,更确切的说它是个累加器,当你写一个函数,最后返回一个值x(return x),那么这个x就要被存到%eax。当然了,我说的是32位情况下,如果64位数,那么%eax只存储低32位。
继续,那么剩下两条就不难理解了,不过还是要叨叨下。在%ebp的基础上减去12存放的数是1,通过movl指令,我们将数字1赋值给了%eax寄存器,然后又是通过一个movl指令,我们把它放到了%esp的位置上。**这4条指令的一个作用就是再给函数add()传递参数。**下面画个图,方便理解。
说了这么多赋值和传参的事情,总该调用add()函数了吧,call add
指令就是干这个事情的。这里对call指令简单介绍下:
pushl %eip
movel add %eip上面两条指令的作用就相当于call了,其中%eip寄存器存放的是当前执行的指令的地址。这里调用了call以后,相当于把当前指令的地址压入栈中,此时%esp指向68,然后把add函数要执行的指令的地址赋给%eip。
接下来,我们看到add()函数当中的一些指令,其实和main当中的大同小异。细心的你可能在想这个leal、popl还有ret是干啥用的呢?以及是不是每个方法第一行都要pushl一下?
pushl %ebp
把当前函数的存取指针入栈,当add()函数执行完了,通过pop弹出操作,将%ebp恢复到main函数的样子,仿佛没发生过这一顿操作一样(当然只是表面上看起来,实际上我们是进行了简单加法的)。还有个ret操作,这个指令可以理解为popl %eip
,也就是将%eip恢复到原来的值,这样就可以继续执行main中执行add()方法的下一条指令了。
说了这么多,还剩下两条指令…
main函数执行movl %eax,-4(%ebp)
后,将%eax寄存器保存的值放到%ebp的值减去4之后的地址,然后开始执行leave,leave指令相当于如下两条指令:
mov %ebp,%esp
pop %ebp
这就相当于恢复初始状态的栈情况了。
到这里,一个程序的执行就结束了。