我之前一直就对C语言里的&操作感到神奇:C程序通过&操作就能直达计算机内存,后面还可以通过*操作来修改内存值。所以,&操作实际上是C语言通向计算机硬件的钥匙。通过这次自制操作系统,算是彻底理解了&操作的过程,因此专门开篇一章来记录实现原理。
一、变量&操作
1.&获取地址
在《计算机自制操作系统(三0):操作系统保护机制》一章中,我们介绍了操作系统跳转到应用程序发生特权级转换时间最重要的一步:为了后面应用程序能正常跳回操作系统,需要在应用程序启动之前把操作系统的堆栈指针ESP写入TSS的ESP0。程序主体结构是:
p = (char *) memman_alloc_4k(memman, finfo->size); /*为应用程序分配代码段内存*/
q = (char *) memman_alloc_4k(memman, 64 * 1024); /*为应用程序分配数据段内存*/
/*将应用程序代码段注册到GDT*/
set_segmdesc(gdt + 103, finfo->size - 1, (int) p, AR_CODE32_ER + 0x60);
/*将应用程序数据段注册到GDT*/
set_segmdesc(gdt + 104, 64 * 1024 - 1, (int) q, AR_DATA32_RW + 0x60);
/*启动应用程序,参数:EIP,CS,ESP,DS,(task->tss.esp0)内存地址/
start_app(0, 103 * 8, 64 * 1024, 104 * 8, &(task->tss.esp0));
memman_free_4k(memman, (int) p, finfo->size); /*回收应用程序分配代码段内存*/
memman_free_4k(memman, (int) q, 64 * 1024); /*回收应用程序分配数据段内存*/
可以看到,把操作系统的堆栈指针ESP写入TSS的ESP0的关键代码是:&(task->tss.esp0)。明显它这是在获取当前任务task指针指向的tss结构体中的esp0成员之地址,这里就用到了&取地址操作,我们来仔细分析一下这个过程是怎么实现的。
要搞清楚这个过程,只能从汇编程序入手,我们首先来看操作系统调用start_app()这个函数编译之后的汇编代码:
PUSH EAX就是函数start_app()的参数:&(task->tss.esp0)。而EAX来源于上面两句指令,那这两条指令是怎么来的呢?下面详细分析:
esp0是结构体TSS32的成员,而TSS32又是结构体TASK的成员,那么我们就必须要把TASK和TSS32翻出来看。
TASK结构体是这样的:
struct TASK { /*单个任务对象数据结构*/
int sel, flags; /*sel为GDT中对应任务的索引编号 */
int level, priority; /*优先级层数和层内优先级*/
struct FIFO32 fifo;
struct TSS32 tss;
};
TSS32结构体是这样的:
struct TSS32 { /* TSS数据结构*/
int backlink, esp0, ss0, esp1, ss1, esp2, ss2, cr3;
int eip, eflags, eax, ecx, edx, ebx, esp, ebp, esi, edi;
int es, cs, ss, ds, fs, gs;
int ldtr, iomap;
};
所以esp0在结构体TSS32中的偏移量是4(字节),记offset1=1*4。
而TSS32在结构体TASK中的偏移量记为offset2,那么:offset2=2*4+2*4+sizeof(FIFO32)=4*4+sizeof(FIFO32)。由于中间插入了一个FIFO32,那我们又把FIFO32结构体打开:
struct FIFO32 {
int *buf;
int p, q, size, free, flags;
struct TASK *task;
};
那这个sizeof(FIFO32)是多少?是4+4*5+sizeof(struct TASK *task)。我晕,FIFO32中居然有套了一个TASK,这样FIFO32和TASK连环套,根本求不出来结果嘛,难道计算机有特异功能?稍微仔细才发现,这个struct TASK *task是一个指针,它不是struct TASK task,因此它的长度是固定的4,所以sizeof(FIFO32)=4+4*5+4=7*4,所以offset2=4*4+74=11*4。
那esp0相对结构体TASK的总偏移:offset=offset1+offset2=1*4+11*4=124=48。这个就是汇编程序中ADD EAX,48的由来。显然在加上48之前的EAX就应该是结构体TASK的内存地址,也即是task指针之值,而EAX来自:MOV EAX, [-52+EBP]。所以结论是:指针变量task就是存放在内存地址:-52+EBP里面的。
下面我们再通过指针变量task的定义进步一验证上上面的结论,因为start_app()调用的是在函数:cmd_app()里面的,在这个函数里面定义task的程序是:
我们在汇编程序cmd_app()中找到它对应之处:
很明显task之值来源于调用task_now()的返回值,因为函数返回值是通过EAX来实现的,因此下一句EAX将要去的地方,就一定是task之值,那么EAX存入了哪里?很明显是:[-52+EBP]。所以,验证我们的结论:-52+EBP就是当前任务(操作系统)的task指针存放地址。那-52+EBP这个地址是什么呢?从上面图中蓝色部分可以看出,它就是操作系统的堆栈内偏移处。这不奇怪,之前我们已知局部变量都是放在堆栈里面的,因此task值就在栈内。
所以,我们回到C语言&(task->tss.esp0),通过上面的分析,它最终在计算机中执行之后的效果是:把操作系统当前任务task指向的tss成员的esp0内存存放位置放在了EAX中。具体这个内存存放位置的数值是:task+48。这个数值代表什么意思呢?它代表的是变量esp0当前相对于DS寄存器段基地址的偏移量,因为访问这个内存地址肯定用 mov xx,[task+48]的格式,[ ]里面的数值就代表该内存地址与DS寄存器段基地址的偏移量。所以重要的结论就是:
C语言变量&取地址操作实际意义就是:获得了变量当前相对于DS寄存器段基地址的偏移量。
具体到本例,因为task的值是来源于调用task_now()的返回值,这个返回值在不同的环境下可能不一样(比如task_now()的返回值定义在栈内),也有可能这个返回值是个永恒不变的值(比如task_now()的返回值定义在静态变量区)。但这些都不是最重要的,我们只需要拿到这个task值在当时相对于DS寄存器段地址的偏移量就够了。因为,我们后面需要操作这个地址的时候,也是通过这个偏移量来实现的。为了后面描述更清楚,我们假定这次的task值是1000,那&(task->tss.esp0)这次取得数值就是1000+48=1048。
2.使用地址
前面我们用&获取了task->tss.esp0变量的内存存放位置,那具体后面是怎么用到它的呢。我们接着看调用start_app()这个函数的使用情况。
_start_app: ; void start_app(int eip, int cs, int esp, int ds, int *tss_esp0);
PUSHAD ; 32位寄存器全部保持在操作系统的堆栈区
MOV EAX,[ESP+36] ;应用程序的EIP
MOV ECX,[ESP+40] ;应用程序的CS
MOV EDX,[ESP+44] ;应用程序的ESP
MOV EBX,[ESP+48] ;应用程序DS/SS
MOV EBP,[ESP+52] ;tss.esp0内存地址
MOV [EBP ],ESP ;保存操作系统ESP
MOV [EBP+4],SS ;保存操作系统SS
MOV ES,BX
MOV DS,BX
MOV FS,BX
MOV GS,BX
OR ECX,3 ;RPL=3,改变请求特权等级
OR EBX,3 ;RPL=3,改变请求特权等级
PUSH EBX ;应用程序改变特权级之后的SS
PUSH EDX ;应用程序的ESP
PUSH ECX ;应用程序改变特权级之后的CS
PUSH EAX ;应用程序的EIP
RETF ;程序回不到这,因为没有用call指令
这个程序是独立的汇编程序naskfunc.nas,前面的&(task->tss.esp0)是在独立的C程序:console.c中,C程序通过调用汇编程序中的函数start_app来实现跳转。汇编函数是把前面获取的task->tss.esp0变量的内存存放位置作为最后一个参数入栈的,它入栈之后就被放入了栈内的52偏移处:ESP+52。然后,程序又把它从内存地址ESP+52挪到了到EBP中,并通过[EBP]寻址,最后才把操作系统的ESP成功放入了task->tss.esp0变量的内存位置。之前我们假定了当时&获取到的数值是1048,所以这次无论中间的过程多复杂,最终其实只相当于执行了这条指令:MOV [1048 ],ESP,也即修改了变量task->tss.esp0的值,这就是前面使用&的最终目标。
从这里就可以看出,使用&获取地址的原因是:当程序需要跨越变量定义范围内的访问时,如这段汇编程序需要访问在C程序中定义的变量,除了通过获取内存偏移地址来衔接外,还能有什么办法呢?
但当我们在一个程序内引用变量的时候,肯定就不再需要使用&地址这么麻烦了。比如
int a;
a=10;
但是,如果你想用&来做事情,也不是不可以:
int a;
*(&a)=10;
甚至更炫的玩法:
int a, *b;
b=&a;
*b=10;
这3种写法其实是等价的,但你可能看到第3种写法会有点晕:明明是修改了指针b的指向值,为什么变成了给a赋值呢?那用我们前面的结论来推导就容易多了:&是获得了变量当前相对于DS寄存器段基地址的偏移量。假设&a=2000,那就是变量a相对于DS寄存器段基地址的偏移量是2000,操作*b=10等于指令mov [2000],10,这不就相当于把变量a赋值成10了嘛。
最后再来看一个我们在本次操作系统制作过程中的C程序&操作例子,这是使用键盘关闭窗口时的程序。这个过程和刚才前面的程序刚好相反,前面利用&是为了获取tss.esp0内存地址进行写入操作(准备从操作系统跳转到应用程序),而这次是利用&获取tss.esp0内存地址是为了后面的读出操作(强行结束应用程序而跳转到操作系统恢复ESP之值)。
/* Shift+F1 关闭窗口*/
if (i == 256 + 0x3b && key_shift != 0 && task_cons->tss.ss0 != 0) {
cons = (struct CONSOLE *) *((int *) 0x0fec);
cons_putstr0(cons, "nBreak(key) :n");
io_cli();
task_cons->tss.eax = (int) &(task_cons->tss.esp0);
task_cons->tss.eip = (int) asm_end_app;
io_sti();
}
这段程序翻译到汇编语言是:
CALL _cons_putstr0
CALL _io_cli
LEA EAX,DWORD [48+EDI]
MOV DWORD [84+EDI],EAX
MOV DWORD [76+EDI],_asm_end_app
CALL _io_sti
可以看到,实现&(task_cons->tss.esp0)操作的汇编指令是:
LEA EAX,DWORD [48+EDI]
这个操作更简单,只用了一个特殊的地址传送指令LEA就实现了:这句指令的意思就是将数值48+EDI传送到EAX,其实就是传送一个偏移地址值。为了表示传送内存偏移地址,格式规定偏移量必须要用 [ ],它和内存访问没有一点关系,就是一个格式要求。千万别误认为它是从内存地址[48+EDI]里取出数据来传送,它就是直接传送48+EDI数值。这里它实际上等价于:
MOV EAX, 48+EDI
&(task_cons->tss.esp0)操作和前面的功能一样:把任务task_cons的tss成员的esp0内存存放位置放在了EAX中,具体这个存放位置数值是48+EDI,esp0较大概率上也是放在栈内的。从+48这里我们可以猜想一下esp0是和前面是一样的偏移值,那么这个EDI大概率就是指针task_cons之值。
既然可以用MOV指令来代替LEA,那LEA存在的意义是什么呢?lea指令是为了简化算术运算而提供的,比如需要复杂的计算偏移地址的时候:
LEA ESI,[ESI*4+EAX]
一条指令就可以轻松将ESI*4+EAX的值直接赋予ESI。而如果要用MOV的话,需要如下过程:
MOV EBX,EAX
MOV EAX,ESI
MUL EAX,4
ADD EAX,EBX
MOV ESI,EAX
如果有专业人士知道LEA指令的作用不仅仅在此,请在评论区告诉我。
二、函数&操作
不光变量有&获取地址操作,C程序中函数也可以用获取&获取地址。如果说变量&取地址操作实际意义是获得了变量当前相对于DS寄存器段基地址的偏移量的话,那么:
C语言函数&取地址操作实际意义就是:获得了函数标号相对于CS寄存器段基地址的偏移量。
set_segmdesc(gdt + 3, 103, (int) &tss_a, AR_TSS32);
set_segmdesc(gdt + 4, 103, (int) &tss_b, AR_TSS32);
load_tr(3 * 8);
task_b_esp = memman_alloc_4k(memman, 64 * 1024) + 64 * 1024;
tss_b.eip = (int) &task_b_main;
tss_b.eflags = 0x00000202; /* IF = 1; */
tss_b.eax = 0;
tss_b.ecx = 0;
tss_b.edx = 0;
tss_b.ebx = 0;
tss_b.esp = task_b_esp;
tss_b.ebp = 0;
我们来看,这个汇编程序就更直接了:MOV DWORD [-1068+EBP],_task_b_main。就是把函数的汇编标号task_b_main给取出来了。在汇编程序里,编号就代表这段代码段的偏移量,当这个程序被编译成目标文件和其它目标文件链接在一起的时候,链接器会自动重新计算task_b_main这个编号相对于程序入口Main()的偏移量,而程序入口一般即是CS段的基地址。
CALL _set_segmdesc
PUSH 24
CALL _load_tr
PUSH 65536
PUSH 2097152
CALL _memman_alloc_4k
ADD EAX,65536
MOV DWORD [-1068+EBP],_task_b_main
MOV DWORD [-1064+EBP],514
MOV DWORD [-1060+EBP],0
MOV DWORD [-1056+EBP],0
MOV DWORD [-1052+EBP],0
MOV DWORD [-1048+EBP],0
MOV DWORD [-1044+EBP],EAX
MOV DWORD [-1040+EBP],0
需要注意的一点是:由于C程序函数名称本身就代表了标号偏移,因此对函数&取地址操作可以不需要符号&,所以上面的程序可以用如下这句代替:
tss_b.eip = (int) task_b_main;