内核文件kernel.bin是elf格式的二进制可执行文件,初始化内核就是根据elf规范将内核文件中的段(segment)展开到(复制到)内存中的相应位置。在分页模式下,程序是靠虚拟地址来运行的,无论是内核还是用户程序,它们对cpu来说都是指令或数据、没什么区别,交给cpu的指令或数据的地址一律被认为是虚拟地址。坦白说,内核文件中的地址是在编译阶段确定的,里面都是虚拟地址,程序也是靠这些虚拟地址来运行。但这些虚拟地址实际上是我们在初始化内核阶段规划好的,即想安排内核在哪片虚拟内存中,就将内核地址编译成对应的虚拟地址。而目前我们初始化的是内核,它在物理低端1MB内存中,初始化工作取决于这1MB物理内存中哪块空间可用,所以,现在还要看前面的内存分布图从中找块合适的内存空间来容纳内核映像。
其实大家早已经知道内核的入口虚拟地址是0xc0001500啦。但现在大家要假装不知道^_^,配合一下啊,咱们说一下0xc0001500是怎么来的。
物理内存中0x900处是loader.bin加载的地址,在loader.bin的开始部分是GDT,它可是必须要保留下来的,可不能覆盖,我们不打算在内核中重新定义它,以后都要指望它了。正如伟大领袖虽然仙逝了,但威望犹在,虽然loader的工作结束啦,但loader所完成的工作成果咱们还得继续发扬继续用。预计loader.bin的大小不会超过2000字节。所以咱们可选的起始物理地址是0x900+2000=0x10d0(不要把注意力放在这个奇怪的数上,偶然得出的)。内存很大,但也尽量往低了选,于是凑了个整数,选了0x1500做为内核映像的入口地址。
根据咱们的页表,低端1MB的虚拟内存与物理内存是一一对应的,所以物理地址是0x1500对应的虚拟地址是0xc0001500。这就解释了在5.3.1节中,链接命令ld中用-Ttext指定了代码段的起始虚拟地址,再把命令搬过来给大家看下:
ld kernel/main.o -Ttext 0xc0001500 -e main -o kernel/kernel.bin
好,现在咱们得说一下初始化内核的代码,见代码:
193 ;---------- 将kernel.bin中的segment拷贝到编译的地址 -----------
194 kernel_init:
195 xor eax, eax
196 xor ebx, ebx ;ebx记录程序头表地址
197 xor ecx, ecx ;cx记录程序头表中的program header数量
198 xor edx, edx ;dx 记录program header尺寸,即e_phentsize
199
200 mov dx, [KERNEL_BIN_BASE_ADDR + 42]
; 偏移文件42字节处的属性是e_phentsize,表示program header大小
201 mov ebx, [KERNEL_BIN_BASE_ADDR + 28]
; 偏移文件开始部分28字节的地方是e_phoff,
;表示第1 个program header在文件中的偏移量
202 ; 其实该值是0x34,不过还是谨慎一点,这里来读取实际值
203 add ebx, KERNEL_BIN_BASE_ADDR
204 mov cx, [KERNEL_BIN_BASE_ADDR + 44]
; 偏移文件开始部分44字节的地方是e_phnum,表示有几个program header
205 .each_segment:
206 cmp byte [ebx + 0], PT_NULL ; 若p_type等于 PT_NULL,说明此program header未使用。
207 je .PTNULL
208
209 ;为函数memcpy压入参数,参数是从右往左依然压入.;函数原型类似于 memcpy(dst,src,size)
210 push dword [ebx + 16] ; program header中偏移16字节的地方是p_filesz,
;压入函数memcpy的第三个参数:size
211 mov eax, [ebx + 4] ; 距程序头偏移量为4字节的位置是p_offset
212 add eax, KERNEL_BIN_BASE_ADDR
; 加上kernel.bin被加载到的物理地址,eax为该段的物理地址
213 push eax ; 压入函数memcpy的第二个参数:源地址
214 push dword [ebx + 8] ; 压入函数memcpy的第一个参数:目的地址;偏移程序头8字节的位置是p_vaddr,这就是目的地址
215 call mem_cpy ; 调用mem_cpy完成段复制
216 add esp,12 ; 清理栈中压入的三个参数
217 .PTNULL:
218 add ebx, edx ; edx为program header大小,即e_phentsize,;在此ebx指向下一个program header
219 loop .each_segment
220 ret
221
222 ;---------- 逐字节拷贝 mem_cpy(dst,src,size) ------------
223 ;输入:栈中三个参数(dst,src,size)
224 ;输出:无
225 ;---------------------------------------------------------
226 mem_cpy:
227 cld
228 push ebp
229 mov ebp, esp
230 push ecx ; rep指令用到了ecx,; 但ecx对于外层段的循环还有用,故先入栈备份
231 mov edi, [ebp + 8] ; dst
232 mov esi, [ebp + 12] ; src
233 mov ecx, [ebp + 16] ; size
234 rep movsb ; 逐字节拷贝
235
236 ;恢复环境
237 pop ecx
238 pop ebp
239 ret
对于可执行程序,我们只对其中的段(segment)感兴趣,它们才是程序运行的实质指令和数据的所在地,所以我们要找出程序中所有的段。
函数kernel_init的作用是将kernel.bin中的段(segment)拷贝到各段自己被编译的虚拟地址处,将这些段单独提取到内存中,这就是平时所说的内存中的程序映像。kernel_init的原理是分析程序中的每个段(segment),如果段类型不是PT_NULL(空程序类型),就将该段拷贝到编译的地址中。
现在内核已经被加载到KERNEL_BIN_BASE_ADDR地址处,该处是文件头elf_header。在我们的程序中,遍历段的方式是指向第一个程序头后,每次增加一个段头的大小,即e_phentsize。该属性位于偏移程序开头42字节处。为了以后遍历段时方便,避免了频繁的访问内存,在第200行,我们用寄存器dx来存储段头大小,这样,每遍历一个段头时,就直接从dx中获取段头大小,这将在第218行体现。
为了找到程序中所有的段,必须要获取程序头表。在文件开头偏移28字节处是属性e_phoff,该属性表示程序头表在文件中的偏移量,程序头表是程序头program header的数组,所以e_phoff也就是第1 个program header在文件中的偏移量。第201行,在内存e_phoff处取值,将得到的程序头表偏移量存入寄存器ebx。
我们需要的是程序头表的物理地址,由于此时的ebx还是程序头表文件内的偏移量,所以要将其加上内核的加载地址,这样才是程序头表的物理地址。所以在第203行为ebx加上了内核文件的加载地址KERNEL_BIN_BASE_ADDR。最终ebx寄存器做为程序头表的基址,用它来遍历每一个段,此时ebx指向程序中的第1 个program header。
我们已经知道,段是由程序头(program header)来描述的,一个程序头代表一个段。在知道了第一个程序头的地址后,为了遍历所有的程序头,还需要知道程序中程序头的数量,也就是段的数量,这是由elf_header中的属性e_phnum决定,它在elf_header中偏移为44。我们通常用cx寄存器来做循环计数器,所以在第204行,汇编语句“mov cx, [KERNEL_BIN_BASE_ADDR + 44]”将段的数量赋值给寄存器cx。
现在程序头表地址在寄存器ebx中,而且又知道了程序头表中段的数量,所以现在可以遍历每一个段的信息啦,其工作在代码第205~220行中完成。
在第206行,程序先判断下段的类型是不是PT_NULL,PT_NULL是在boot/include/boot.inc中定义的宏,其值为0,该意义表示空段类型。(PT_NULL也可以在linux系统的/usr/include/elf.h中找到其定义:#define PT_NULL 0)
在207行,如果发现该段是空段类型的话,就跨过该段不处理,跳到.PTNULL处,也就是第217行。
指定下一个段是通过在程序头表地址处加上一个段的大小e_phentsize来实现的,e_phentsize的值咱们已经将其存储在dx寄存器啦,所以在第218行,直接将ebx,也就是当前program header地址,加上edx,ebx便指向了下一个段的program header。edx的高16位为0,所以这里用add ebx, edx没有问题。
第209~216行,程序中的段通过mem_cpy函数复制到段自身的虚拟地址处。在这里,我们涉及到了函数调用约定的知识,不过为了叙述的更清楚,在这里我不想简单地说,在下一章中我们专门拿出一节来说这事儿。在此我还是本着够用的原则,把用到的部分给您说明白。
我们在此实现的函数是mem_cpy,不是c标准库中的memcpy函数,将来我们会在内核中实现memcpy。memcpy原型是void *memcpy(void *dest, const void *src, size_t n),功能是将src指向的地址空间处的连续n个字节拷贝到dest指向的地址空间。我们的学习它的用法,在汇编语言中用mem_cpy函数实现了它,此函数的原型相当于mem_cpy(void* dst, void* src, int size)。所以我们也要提供三个参数才能使用它。这三个参数都在程序头program header中,所以它们都可以基于ebx再增加适当的偏移量来得到。program header结构,很容易理解210~214行的代码。
第215行是调用 mem_cpy,这涉及到为该函数传入参数的问题。在汇编语言中传递参数的方法太多了,原因是汇编语言太灵活了,不怎么受约束,咱们可以访问到的资源太多了。所以,主调函数可以把参数放在寄存器中,也可以放在栈中,而栈就是内存,所以只要大家高兴,也可以把参数直接放到某块内存中,类似共享内存的方式来传递参数。主调函数以上面任意一种方式传递参数,被调函数都可以轻松地拿到参数。