• 操作系统内核Hack:(三)引导程序制作


    操作系统内核Hack:(三)引导程序制作

    关于本文涉及到的完整源码请参考MiniOS的v1_bootloader分支


    1.制作方法

    现在我们已经了解了关于BootLoader的一切知识,让我们开始动手做一个BootLoader吧!但真正开始之前,我们还要做出一个选择,在之前的讨论中我们曾说过,有两种学习和制作引导程序和操作系统内核的路线:1)《Orange’s:一个操作系统的实现》书中的路线;2)Linux 0.11的路线。

    1.1 两种实现思路

    具体来说,第一种路线就是将BootLoader和Kernel都放到预先格式化成FAT的软盘上,BootLoader通过FAT和ELF的知识定位并加载Kernel,借助FreeDOS启动或调试我们的操作系统。并非对FAT和DOS心存偏见,毕竟现在的确很多汇编语言书籍仍然以DOS作为学习平台,但我个人更喜欢第二种路线。在引导过程,我们不依赖任何文件系统,在裸扇区中加载运行第二阶段BootLoader和Kernel。进入Kernel之后,我们再挂载一个根文件系统。为何要这样做而不跟着《Orange’s》作者的思路走呢?因为我们毕竟主要学习和模仿的是*nix族的操作系统,既然如此,为何不纯粹一些?直接循着当年Linus在Linux 0.11中的思路,这才算是一次酣畅淋漓的操作系统内核的Hack之旅!

    经过了本系列前两回对实验环境和底层编程基础知识的准备,加上刚才对实现思路的分析,现在我们就参考《Linux内核完全注释》中Linux 0.11的思路,借鉴《Orange’s:一个操作系统的实现》中NASM的汇编代码,“双剑合璧,威力无穷”。让我们站在前人的肩膀上,一起动手来实现一个属于我们自己的操作系统BootLoader!

    1.2 Linux 0.11参考

    既然要借鉴思路,我们就先看一下Linux 0.11这个成品是什么样子。因为《Linux内核完全注释》中提供的源代码链接要做很多手动修改和准备工作才能使用,而网上有很多热心的网友分享了开箱即用的0.11源码安装包。

    1.2.1 编译内核

    cdai@cdai ~/Source $ git clone https://github.com/yuanxinyu/Linux-0.11
    cdai@cdai ~/Source $ cd Linux-0.11
    
    cdai@cdai-Vostro-1450 ~/Source/Linux-0.11 $ make help
    <<<<This is the basic help info of linux-0.11>>>
    
    Usage:
         make --generate a kernel floppy Image with a fs on hda1
         make start -- start the kernel in qemu
         make debug -- debug the kernel in qemu & gdb at port 1234
         make disk  -- generate a kernel Image & copy it to floppy
         make cscope -- genereate the cscope index databases
         make tags -- generate the tag file
         make cg -- generate callgraph of the system architecture
         make clean -- clean the object files
         make distclean -- only keep the source code files
    
    Note!:
         * You need to install the following basic tools:
              ubuntu|debian, qemu|bochs, ctags, cscope, calltree, graphviz 
              vim-full, build-essential, hex, dd, gcc 4.3.2...
         * Becarefull to change the compiling options, which will heavily
         influence the compiling procedure and running result.
         ...
    
    <<<Be Happy To Play With It :-)>>>
    
    cdai@cdai ~/Source/Linux-0.11 $ sudo apt-get install -y ctags cscope graphviz qemu
    cdai@cdai ~/Source/Linux-0.11 $ make

    1.2.2 下载硬盘IMG

    从OldLinux下载硬盘镜像hdc-0.11.img,大小为127MB。将其拷贝到Linux0.11目录下后,就可以执行make start用qemu模拟Linux 0.11的运行环境了。

    cdai@cdai ~/Source/Linux-0.11 $ tree
    
    ├── boot
    ├── fs
    ├── hdc-0.11.img
    ├── Image
    ├── include
    ├── init
    ├── kernel
    ├── lib
    ├── Makefile
    ├── Makefile.header
    ├── mm
    ├── README.md
    ├── readme.old
    ├── System.map
    └── tools
    
    8 directories, 10 files
    cdai@cdai ~/Source/Linux-0.11 $ make start

    1.2.3 安装运行

    启动时,系统会从软盘引导,并挂载根文件系统:

    SeaBIOS (version 1.7.4-20140219_122725-roseapple)
    
    iPXE (http://ipxe.org) 00:0.3.0 C900 PCI2.10 PnP PMM+00FC1110+00F21110 C900
    
    Booting from Floppy...
    
    Loading system...
    
    Partition table ok.
    43012/62000 free blocks
    19719/20666 free inodes
    3446 buffers = 3528704 bytes buffer space
    Free mem: 12574720 bytes
     Ok.
    [/usr/root]# ls
    README      hello       mtools.howto        shoelace.tar.Z
    gcclib140   hello.c     shoe

    2.文件目录组织

    文件目录组织是按照打包后的模块划分,一级目录分为boot1、boot2、system三个文件夹,tools中放的则是打包工具类。对应《Orange’s》代码,boot1/bootsect.s相当于boot.asm,boot2/setup.s相当于loader.asm。

    cdai@cdai ~/Workspace/syspace/1-assembly $ tree -d minios/
    minios/
    ├── boot1
    │   └── bootsect.s
    ├── boot2
    │   └── setup.s
    ├── Makefile
    ├── system
    │   ├── fs
    │   ├── include
    │   ├── init
    │   ├── kernel
    │   ├── lib
    │   └── mm
    └── tools
        └── build.c
    
    10 directories, 4 files

    3.实用工具

    3.1 构建工具

    3.1.1 Makefile

    首先,Makefile份内的工作就是编译出bootsect、setup、system、build。之后,调用build处理前三者,最终产生拼接好的可引导Image镜像文件。在此过程中,Makefile还有一些小任务要完成。例如利用临时汇编文件tmp.s将system模块的大小拼接到bootsect.asm的头部,加载system的代码中会用到它。

    
    #################
    # Macro & Rule
    #################
    
    AS      = nasm
    ASFLAGS = -f elf
    CC      = gcc
    CFLAGS  = -Wall -O
    LD      = ld
    # -Ttext org -e entry -s(omit all symbol info)
    # -x(discard all local symbols) -M(print memory map)
    LDFLAGS = -Ttext 0 -e main --oformat binary -s -x -M
    
    %.o:    %.c
        $(CC) $(CFLAGS) -c -o $@ $<
    
    #%.o:   %.asm
    #   $(AS) $(ASFLAGS) -o $@ $<
    
    
    #################
    #   Default
    #################
    
    all:    Image
    
    
    Image:  boot1/bootsect boot2/setup system/system tools/build    
        tools/build boot1/bootsect boot2/setup system/system > a.bin
        dd if=a.bin of=Image bs=8192 conv=notrunc
        rm -f a.bin
    
    tools/build:    tools/build.c
        $(CC) $(CFLAGS) -o $@ $<
    
    # SYSSIZE= number of clicks (16 bytes) to be loaded
    boot1/bootsect: boot1/bootsect.asm system/system
        (echo -n "SYSSIZE equ (";ls -l system/system | grep system 
            | cut -d " " -f 5 | tr '12' ' '; echo "+ 15 ) / 16") > tmp.asm
        cat $< >> tmp.asm
        $(AS) -o $@ tmp.asm
        rm -f tmp.asm
    
    boot2/setup:    boot2/setup.asm
        $(AS) -o $@ $<
    
    system/system:  system/init/main.o system/init/myprint.o
        $(LD) $(LDFLAGS) 
        system/init/main.o 
        system/init/myprint.o 
        -o $@ > System.map
    
    system/init/main.o: system/init/main.c
    
    system/init/myprint.o: system/init/myprint.asm
        $(AS) $(ASFLAGS) -o $@ $<
    
    
    #################
    # Create floppy
    #################
    
    disk:
        bximage -q -fd -size=1.44 Image
    
    .PHONY: disk
    
    
    #################
    #   Start Vm
    #################
    
    start:  Image
        bochs -q -f bochsrc
    
    qemu:   Image
        qemu-system-x86_64 -m 16M -boot a -fda Image
    
    .PHONY: start
    
    
    #################
    #   GitHub
    #################
    
    commit:
        git add .
        git commit -m "$(MSG)"
    
    
    #################
    #   Clean
    #################
    
    clean:
        rm -f boot1/bootsect boot2/setup system/system tools/build 
        rm -f system/**/*.o
        rm -f a.bin tmp.asm System.map
    
    .PHONY: clean

    3.1.2 build.c

    build.c主要完成一些不便于或无法在Makefile中实现的操作:

    1. 文件有效性的检查:例如bootsect末尾的0x55AA签名
    2. 文件填充:例如对不足四个扇区大小的setup进行填充
    3. 文件内容解析:例如解析出a.out或ELF格式的system,将其中二进制代码部分提取出来
    4. 拼接:将bootsect、setup、system三者拼接成Image可引导镜像
    #include <stdio.h>      /* fprintf */
    #include <string.h>
    #include <stdlib.h>     /* exit */
    #include <sys/types.h>  /* unistd.h needs this */
    #include <unistd.h>     /* read/write */
    #include <fcntl.h>
    
    
    #define BUFFER_SIZE 1024
    
    #define SETUP_SECTS 4   /* max number of sectors of setup */
    #define STRINGIFY(x) #x /* cat string */
    
    #define GCC_HEADER 1024 /* GCC header length */
    #define SYS_SIZE 0x2000 /* max system length (SYS_SIZE*16=128KB) */
    
    
    void die(const char *str)
    {
        fprintf(stderr, "%s
    ", str);
        exit(1);
    }
    
    void usage()
    {
        die("Usage: build bootsect setup system [> image]");
    }
    
    void copy_bootsect(char const *filename, char *buf)
    {
        int c, fd;
    
        if ((fd = open(filename, O_RDONLY, 0)) < 0)
            die("Unable to open 'bootsect'");
    
        for (c = 0; c < BUFFER_SIZE; c++)
            buf[c] = 0;
    
        c = read(fd, buf, BUFFER_SIZE);
    
        close(fd);
    
        fprintf(stderr, "'bootsect' is %d bytes.
    ", c);
        if (c != 512)
            die("'bootsect' must be exactly 512 bytes");
    
        if ((*(unsigned short *)(buf + 510)) != 0xAA55)
            die("'bootsect' hasn't got boot flag (0xAA55)");
    
        c = write(1, buf, 512);
        if (c != 512)
            die("Write call failed");
    
    }
    
    void copy_setup(char const *filename, char *buf)
    {
        int c, i, fd;
    
        if ((fd = open(filename, O_RDONLY, 0)) < 0)
            die("Unable to open 'setup'");
    
        for (i = 0; (c = read(fd, buf, BUFFER_SIZE)) > 0; i += c)
            if (write(1, buf, c) != c)
                die("Write call failed");
        close(fd);
    
        fprintf(stderr, "'setup' is %d bytes
    ", i);
        if (i > SETUP_SECTS * 512)
            die("'setup' exceeds " STRINGIFY(SETUP_SECTS) " sectors");
    
        // Fill '' if smaller than max bytes
        for (c = 0; c < BUFFER_SIZE; c++)
            buf[c] = 0;
    
        while(i < SETUP_SECTS * 512) {
            c = SETUP_SECTS * 512 - i;
            if (c > BUFFER_SIZE)
                c = BUFFER_SIZE;
            if (write(1, buf, c) != c)
                die("Write call failed");
            i += c;
        }
    }
    
    void copy_system(char const *filename, char *buf)
    {
        int c, i, fd;
    
        if ((fd = open(filename, O_RDONLY, 0)) < 0)
            die("Unable to open 'system'");
    
        /*if (read(fd, buf, GCC_HEADER) != GCC_HEADER)
            die("Unable to read header of 'system'");
        if (((long *) buf)[5] != 0)
            die("Non-GCC header of 'system'");*/
    
        for (i = 0; (c = read(fd, buf, BUFFER_SIZE)) > 0; i += c)
            if (write(1, buf, c) != c)
                die("Write call failed");
        close(fd);
    
        fprintf(stderr, "'system' is %d bytes
    ", i);
        if (i > SYS_SIZE * 16)
            die("'system' is too big");
    }
    
    int main(int argc, char const *argv[])
    {
        char buf[BUFFER_SIZE];
    
        if (argc != 4)
            usage();    
    
        copy_bootsect(argv[1], buf);
        copy_setup(argv[2], buf);
        copy_system(argv[3], buf);
    
        return 0;
    }

    编写build.c中可能会碰到一些C语言的小问题:例如如何字符串拼接,sizeof(array)和sizeof(pointer)陷阱等,这里就不细说了,回头总结C语言开发问题时才一并总结吧。

    3.2 调试工具

    3.2.1 BIN反汇编

    由于BIN文件是Raw Binary,没有文件头及其他附加信息,所以直接运行objdump是会报错的。用objdump -D -b binary -m i386,其中-D表示对整个文件反汇编,-b表示二进制,-m表示指令集架构。objdump的确可以反汇编出一些指令,但不够准确,尤其是16位操作数的处理。最好的办法就是用NASM自带的反汇编工具ndisasm,-o指定起始地址,-e指定开头跳过的字节数,-k指定不反汇编的位置,例如不用-o参数时-k 0x1FE,2会跳过0x55AA签名。与objdump对比一下,看!是不是比objdump好多了!

    cdai@cdai ~/Workspace/syspace/1-assembly/minios $ objdump -D -b binary -m i386 Image 
    
    Image:     file format binary
    
    Disassembly of section .data:
    
    00000000 <.data>:
       0:   b8 c0 07 8e d8          mov    $0xd88e07c0,%eax
       5:   b8 00 90 8e c0          mov    $0xc08e9000,%eax
       a:   b9 00 01 31 f6          mov    $0xf6310100,%ecx
       f:   31 ff                   xor    %edi,%edi
      11:   f3 a5                   rep movsl %ds:(%esi),%es:(%edi)
      13:   ea 18 00 00 90 8c c8    ljmp   $0xc88c,$0x90000018
      1a:   8e d8                   mov    %eax,%ds
        ...
    
    cdai@cdai ~/Workspace/syspace/1-assembly/minios $ ndisasm -o 0x7c00 Image
    00007C00  B8C007            mov ax,0x7c0
    00007C03  8ED8              mov ds,ax
    00007C05  B80090            mov ax,0x9000
    00007C08  8EC0              mov es,ax
    00007C0A  B90001            mov cx,0x100
    00007C0D  31F6              xor si,si
    00007C0F  31FF              xor di,di
    00007C11  F3A5              rep movsw
    00007C13  EA18000090        jmp word 0x9000:0x18
    00007C18  8CC8              mov ax,cs
    00007C1A  8ED8              mov ds,ax

    3.2.2 Bochs调试

    因为之前在《C实战:强大的程序调试工具GDB》中已经学习并总结过GDB调试器的使用,所以这里就简单说一下Bochs对应的命令:

    • 执行:b加断点,c继续执行,s和p分别是step in/step next单步调试
    • 查看寄存器:r查看通用寄存器,sreg查看段寄存器
    • 查看内存:x查看线性地址,xp查看物理地址。例如xp /40bx 0x7c00表示以十六进制(x)字节(b)的形式,查看0x7c00位置长度为40字节的数据
    • 反汇编:u反汇编一段内存中的代码。例如x 0x7c00 0x7c10
    <bochs:5> xp /40bx 0x7c00
    [bochs]:
    0x00007c00 <bogus+       0>:    0xb8    0xc0    0x07    0x8e    0xd8    0xb8    0x00    0x90
    0x00007c08 <bogus+       8>:    0x8e    0xc0    0xb9    0x00    0x01    0x31    0xf6    0x31
    0x00007c10 <bogus+      16>:    0xff    0xf3    0xa5    0xea    0x18    0x00    0x00    0x90
    0x00007c18 <bogus+      24>:    0x8c    0xc8    0x8e    0xd8    0x8e    0xc0    0x8e    0xd0
    0x00007c20 <bogus+      32>:    0xbc    0x00    0xff    0xba    0x00    0x00    0xb9    0x02
    <bochs:6> u 0x7c00 0x7c10
    00007c00: (                    ): mov ax, 0x07c0            ; b8c007
    00007c03: (                    ): mov ds, ax                ; 8ed8
    00007c05: (                    ): mov ax, 0x9000            ; b80090
    00007c08: (                    ): mov es, ax                ; 8ec0
    00007c0a: (                    ): mov cx, 0x0100            ; b90001
    00007c0d: (                    ): xor si, si                ; 31f6
    00007c0f: (                    ): xor di, di                ; 31ff

    4.源码分析

    4.1 第一阶段引导:bootsect.asm

    因为受限于引导扇区的512字节,所以bootsect的工作很简单,首先就是将自己挪动到90000h高地址处,“明哲保身”避免被后面要加载的模块覆盖掉。然后打印提示信息”Loading system…”并加载setup和system后,就跳转到setup继续执行。为了简化,我们暂时没有根据make时拼接到bootsect.asm头部的SYSSIZE去加载system,而只是加载了第6个扇区

    ; ############################
    ;           Constants
    ; ############################
    
    SETUPLEN    equ 4
    BOOTSEG     equ 0x07c0
    INITSEG     equ 0x9000
    SETUPSEG    equ 0x9020
    SYSSEG      equ 0x1000
    
    
    ; ############################
    ;       Booting Process
    ; ############################
    
        org     07c00h
    
    ; 1) Move bootsect to 0x90000
        mov     ax, BOOTSEG
        mov     ds, ax
        mov     ax, INITSEG
        mov     es, ax
        mov     cx, 256         ; cx    = counter
        xor     si, si          ; ds:si = source
        xor     di, di          ; es:di = target
        rep     movsw           ; move word by word
        jmp     INITSEG:go-$$   ; far jump!
    go: 
        mov     ax, cs          ; re-init registers
        mov     ds, ax
        mov     es, ax
        mov     ss, ax
        mov     sp, 0xff00      ; arbitrary value >> 512
    
    ; 2) Load setup module at 0x90200
    load_setup:
        mov     dx, 0000h       ; dx    = driver(dh)/head(dl)
        mov     cx, 0002h       ; cx    = track(ch)/sector(cl)
        mov     bx, 0200h       ; es:bx = target(es=9000h,bx=90200-90000)
        mov     ah, 02h         ; ah    = service id(ah=02 means read)
        mov     al, SETUPLEN    ; al    = number of sectors to read(al)
        int     13h
        jnc     ok_load_setup
    
        xor     dl, dl          ; reset floppy and retry if failed
        xor     ah, ah
        int     13h
        jmp     load_setup      
    
    ; 3) Display loading message
    ok_load_setup:
        mov     ah, 03h         ; read cursur position
        xor     bh, bh
        int     10h
    
        mov     bp, msg1-$$     ; es:bp = message start address
        mov     cx, 21          ; cx    = message length 
        mov     ax, 1301h
        mov     bx, 0007h       ; bx    = page no.(bh=0 page-0)
                                ;         attribute(bl=7 white fg and black bg)
        mov     dl, 0h
        int     10h
    
    ; 4) Load system module at 0x10000
    load_system:
        mov     ax, SYSSEG
        mov     es, ax
        mov     dx, 0000h       ; dx    = driver(dh)/head(dl)
        mov     cx, 0006h       ; cx    = track(ch)/sector(cl)
        mov     bx, 00h         ; es:bx = target(es=1000h,bx=0)
        mov     ah, 02h         ; ah    = service id(ah=02 means read)
        mov     al, 01h         ; al    = number of sectors to read(al)
        int     13h
        jnc     ok_load_system
    
        xor     dl, dl          ; reset floppy and retry if failed
        xor     ah, ah
        int     13h
        jmp     load_system
    
    ; 5) Jump to setup
    ok_load_system:
        jmp     SETUPSEG:0h
    
    ; ############################
    ;           Message
    ; ############################
    
    msg1:       
        db  13,10               ; CRLF
        db  "Loading system..."
        db  13,10               ; CRLF
    
    times   510-($-$$)      db  0
        dw      0xaa55

    为什么在bootsect默认保留一些字节?因为只有bootsect的长度是确定的512字节,在末尾保留一些“全局变量”的话,其他模块很容易就能访问到。

    4.2 第二阶段引导:setup.asm

    setup.asm看似很长,其实都是“见怪不怪”的代码了。开头部分的段描述符的宏定义和属性的常量定义,后面就是进入保护模式的常规代码了,就是这么简单。为了简化,跳转system前直接将ss置为00000h地址处。尽管如此,这段代码却耗费了我大量时间去调错,那究竟了哪里容易出问题呢?

    ; ############################
    ;   Constants & Macro
    ; ############################
    
    ; 描述符类型
    DA_32       equ 4000h   ; 32 位段
    DA_LIMIT_4K equ 8000h   ; 段界限粒度为 4K 字节
    
    ; 存储段描述符类型
    DA_DR       equ 90h ; 存在的只读数据段类型值
    DA_DRW      equ 92h ; 存在的可读写数据段属性值
    DA_C        equ 98h ; 存在的只执行代码段属性值
    DA_CR       equ 9Ah ; 存在的可执行可读代码段属性值
    
    ; Descriptor macro
    %macro Descriptor 3
        dw  %2 & 0FFFFh                         ; Limit 1
        dw  %1 & 0FFFFh                         ; Base addr 1
        db  (%1 >> 16) & 0FFh                   ; Base addr 2
        dw  ((%2 >> 8) & 0F00h) | (%3 & 0F0FFh) ; Attr 1 + Limit 2 + Attr 2
        db  (%1 >> 24) & 0FFh                   ; Base addr 3
    %endmacro
    
    
    ; ############################
    ;   Booting Process
    ; ############################
    
    [SECTION .s16]
    [BITS   16]
    LABEL_BEGIN:
    
    ; 1) Enter protection mode
        mov     ax, cs
        mov     ds, ax
        mov     es, ax
        mov     ss, ax
        mov     sp, 0100h
    
        ; 1.1) Init descriptor
        xor     eax, eax
        mov     ax, cs
        shl     eax, 4
        add     eax, LABEL_SEG_CODE32
        mov     word [LABEL_DESC_CODE32 + 2], ax
        shr     eax, 16
        mov     byte [LABEL_DESC_CODE32 + 4], al
        mov     byte [LABEL_DESC_CODE32 + 7], ah
    
        ; 1.2) Load gdt to gdtr
        xor     eax, eax
        mov     ax, ds
        shl     eax, 4
        add     eax, LABEL_GDT          ; eax <- gdt base addr
        mov     dword [GdtPtr + 2], eax ; [GdtPtr + 2] <- gdt base addr
        lgdt    [GdtPtr]
    
        ; 1.3) Disable interrupt
        cli
    
        ; 1.4) Enable A20 addr line
        in      al, 92h
        or      al, 00000010b
        out     92h, al
    
        ; 1.5) Set PE in cr0
        mov     eax, cr0
        or      eax, 1
        mov     cr0, eax
    
        ; 1.6) Jump to protective mode!
        jmp     dword SelectorCode32:0  ; SelectorCode32 (LABEL_SEG_CODE32:0)
    
    [SECTION .s32]
    ALIGN   32
    [BITS   32]
    LABEL_SEG_CODE32:
    
    ; 2) Jump to system
        mov     ax, SelectorData
        mov     ds, ax
        mov     es, ax
        mov     ss, ax
        mov     esp, 1000h
    
        jmp     SelectorSystem:0
    
    SegCode32Len equ $ - LABEL_SEG_CODE32
    
    [SECTION .gdt]
    ;                            Base Addr,        Limit,   Attribute
    LABEL_GDT:          Descriptor       0,            0, 0
    LABEL_DESC_CODE32:  Descriptor       0, SegCode32Len, DA_CR  | DA_32 | DA_LIMIT_4K
    LABEL_DESC_SYSTEM:  Descriptor  10000h,       0ffffh, DA_CR  | DA_32 | DA_LIMIT_4K
    LABEL_DESC_DATA:    Descriptor   0,       0ffffh, DA_DRW | DA_32 | DA_LIMIT_4K
    LABEL_DESC_VIDEO:   Descriptor 0B8000h,       0ffffh, DA_DRW
    
    GdtLen      equ $ - LABEL_GDT
    GdtPtr      dw  GdtLen - 1                  ; GDT limit
                dd  0                           ; GDT base addr
    
    SelectorCode32      equ LABEL_DESC_CODE32 - LABEL_GDT
    SelectorSystem      equ LABEL_DESC_SYSTEM - LABEL_GDT
    SelectorData        equ LABEL_DESC_DATA - LABEL_GDT
    SelectorVideo       equ LABEL_DESC_VIDEO - LABEL_GDT

    此外,SegCode32Len equ $ - LABEL_SEG_CODE32一定要放到32位代码段的末尾,因为这个长度会设置到32位代码段的描述符中的Limit字段上。如果执行长度超过Limit的话,Bochs同样会没有任何提示的崩溃。

    4.3 内核空壳:main.c

    一路磕磕绊绊,我们终于进入了32位保护模式下的内核代码。现阶段我们在内核中还做不了什么,但这一路辛苦走下来,到这里还是应该尝点甜头儿的。那我们就在内核代码中用C程序调用汇编代码,输出一段话吧。我们先看源代码,后面会详细解释一下为什么要混合使用C和汇编,以及源代码中的细节问题。

    4.3.1 源代码

    从C程序调用汇编代码其实非常简单,我们并不用关注myprint()到底是什么语言实现的,只要这个函数原型使我们产生的代码能够与myprint()的实现一起工作就可以了。此外,编译myprint.asm的时候要注意不能编译成默认的BIN文件,而要编译为ELF格式产生可重定位的相关信息,这样链接器才能将其与main.o正确地链接到一起。注意参数msg的类型要声明为const char *msg,避免编译时的警告。

    // main.c
    void myprint(const char *msg, int len);
    
    int main(int argc, char const *argv[])
    {
        myprint("Hello, MiniOS!
    ", 15);
        return 0;
    }

    我们在.loop循环外初始化循环中要用到的变量:

    • gs:显存映射空间的选择符,值32对应我们在setup.asm中建立的GDT中的SelectorVideo
    • ah:字符串的属性,我们一会儿在Bochs中打印内存时能看到它
    • ebx:循环中要递增的列值
    • ecx:循环中要递减的循环次数计数器,对应C程序传入的参数len
    • edx:循环中要递增的字符串首地址,对应C程序传入的参数msg。esp相当于一个二级指针,[esp+4]得到的只是msg,[msg]得到才是第一个字符’H’。所以说,函数原型(char *msg)是与汇编代码([esp+4])一一对应的,从此处也能一瞥C语言的灵活和强大。
    ; myprint.asm
    [section .data]
    
    [section .text]
    
    global myprint
    
    myprint:
        mov     ax, 32          ; SelectorVideo
        mov     gs, ax
        mov     ah, 0Ch         ; attr
        mov     ebx, 0          ; col
        mov     ecx, [esp+8]    ; msg len
        mov     edx, [esp+4]    ; msg addr
    
    .loop:
        ;(80 * row + col) * 2
        mov     edi, ebx
        add     edi, (80 * 20)  
        imul    edi, 2
        mov     al, byte [edx]
        mov     [gs:edi], ax
    
        inc     ebx
        dec     ecx
        inc     edx
        cmp     ecx, 0h
        jne     .loop
    
        jmp     $

    4.3.2 反汇编

    Makefile中已经有了三个反汇编的target,分别利用ndisasm和objdump工具反汇编bootsect、setup和system。这里我们反汇编一下system,看一下我们的C和汇编混合代码是如何链接的,链接后又是什么样子?不要被细节干扰,抓住几个关键点就可以了,如果相关知识忘记了的话可以参考《六星经典CSAPP-笔记(3)程序的机器级表示》

    • a~e:被调用函数的惯例,保存ebp挪动esp分配栈空间
    • 11~19:保存两个函数参数msg(0x00010076指向的就是字符串的首地址)和len(0xf=15)到栈上
    • 20:调用汇编代码中的函数myprint(),call指令自动将25行的地址压入栈上作为返回地址后跳转,call的操作数0x1b加上IP指向的0x25等于0x40,恰好是myprint()在汇编代码中的起始地址。因为call压栈挪动了esp,所以两个函数参数的位置也就从(%esp)和0x4(%esp)变成了0x4(%esp)和0x8(%esp)了
    [root@localhost minios]# make disasm-sys
    nasm -f elf -o system/init/myprint.o system/init/myprint.asm
    ld -Ttext 10000 -e main --oformat binary -s -x -M 
        system/init/main.o 
        system/init/myprint.o 
        -o system/system > System.map
    objdump -b binary -m i386 -D system/system
    
    system/system:     file format binary
    
    Disassembly of section .data:
    
    0000000000000000 <.data>:
       0:   8d 4c 24 04             lea    0x4(%esp),%ecx
       4:   83 e4 f0                and    $0xfffffff0,%esp
       7:   ff 71 fc                pushl  0xfffffffc(%ecx)
       a:   55                      push   %ebp
       b:   89 e5                   mov    %esp,%ebp
       d:   51                      push   %ecx
       e:   83 ec 14                sub    $0x14,%esp
      11:   c7 44 24 04 0f 00 00    movl   $0xf,0x4(%esp)
      18:   00 
      19:   c7 04 24 76 00 01 00    movl   $0x10076,(%esp)
      20:   e8 1b 00 00 00          call   0x40
      25:   b8 00 00 00 00          mov    $0x0,%eax  
      ...  
    
      40:   66 b8 20 00             mov    $0x20,%ax
      44:   8e e8                   movl   %eax,%gs
      46:   b4 0c                   mov    $0xc,%ah
      48:   bb 00 00 00 00          mov    $0x0,%ebx
      4d:   8b 4c 24 08             mov    0x8(%esp),%ecx
      51:   8b 54 24 04             mov    0x4(%esp),%edx
      55:   89 df                   mov    %ebx,%edi
      57:   81 c7 40 06 00 00       add    $0x640,%edi
      5d:   69 ff 02 00 00 00       imul   $0x2,%edi,%edi
      63:   8a 02                   mov    (%edx),%al
      65:   65 66 89 07             mov    %ax,%gs:(%edi)
      69:   43                      inc    %ebx
      6a:   49                      dec    %ecx
      6b:   42                      inc    %edx
      6c:   81 f9 00 00 00 00       cmp    $0x0,%ecx
      72:   75 e1                   jne    0x55
      74:   eb fe                   jmp    0x74
    
      76:   48                      
      77:   65                      
      78:   6c                      
      79:   6c                      
      7a:   6f                      
      7b:   2c 20                   
      7d:   4d                      
      7e:   69 6e 69 4f 53 21 0a    
        ...

    4.3.3 源码解释

    关于内核雏形部分的代码看起来不那么直接,直接写个C程序的main方法不就可以了吗?但这都是有原因的,解释:

    1. 为何要输出字符串?因为我们想直观地看到内核确实可以正常工作,体现出我们的劳动成果
    2. 为何要混合编程?既然要输出点什么,而这又是我们自己的操作系统内核,像printf这种系统调用我们还没有实现,所以只能靠底层的汇编代码去输出字符串。这里为了简化,我们在汇编中没有遵守正常的规则,例如保存ebp、挪动esp分配栈等
    3. **为何不直接在C中嵌入NASM代码?**C语言支持直接嵌入汇编代码,但不支持NASM语法的汇编代码,如果我们不想再学习另一种汇编语法的话,就只能单独拆分出一个asm文件
    4. 为何不直接用int 0x10中断?因为BIOS中断在保护模式下是不能用的,而我们进入保护模式后只是设置了栈的段寄存器ss,还有很多工作没做,这里为了避免出错所以用直接写显存在内存映射地址空间的方式
    5. 如何写显存映射空间?显存在内存中映射空间的起始地址是0B8000h,每行80个字符共35行。每个字符占用2个字节,第一个字节是字符的ASCII码,第二个字节是属性。例如第12行的最后一个字符在内存中的位置就是(80 * 11 + 79) * 2。为了简化我们直接将字符串输出到第21行,而没有读当前光标位置

    4.3.4 测试

    下面就测试一下我们的代码是否正确,说起来这还是我们写的第一个比较复杂的汇编代码,学习了如何在汇编语言中实现高级语言中的循环。一直执行到循环后的jmp $一行,通过Bochs就能看到输出。如果环境没有图形界面而安装Bochs时没有安装GUI也没有关系,打印一下0xB8B40内存位置就能跳过Bochs前面的输出,看到我们刚刚输出的内容了。每个字符后面跟着的7和x0C就是该字符的属性了

    (0) [0x0000000000010072] 0010:00000072 (unk. ctxt): jnz .-31 (0x00010055)     ; 75e1
    <bochs:161> 
    Next at t=13185655
    (0) [0x0000000000010074] 0010:00000074 (unk. ctxt): jmp .-2 (0x00010074)      ; ebfe
    <bochs:162> xp /500bc 0x0B8B40
    [bochs]:
    0x000b8b40 <bogus+       0>:  L   7    o   7    a   7    d   7  
    0x000b8b48 <bogus+       8>:  i   7    n   7    g   7        7  
    0x000b8b50 <bogus+      16>:  s   7    y   7    s   7    t   7  
    0x000b8b58 <bogus+      24>:  e   7    m   7    .   7    .   7  
    0x000b8b60 <bogus+      32>:  .   7        7        7        7  
    0x000b8b68 <bogus+      40>:      7        7        7        7  
        ...
    0x000b8c80 <bogus+     320>:  H   x0C  e   x0C  l   x0C  l   x0C
    0x000b8c88 <bogus+     328>:  o   x0C  ,   x0C      x0C  M   x0C
    0x000b8c90 <bogus+     336>:  i   x0C  n   x0C  i   x0C  O   x0C
    0x000b8c98 <bogus+     344>:  S   x0C  !   x0C x0A x0C      7  

    5.总结

    现在回头想想,似乎找到了当时阅读《Orange’s》一书时,进行到第五章就没有继续下去的原因:一是第三章“保护模式”过早地交代了保护模式的方方面面。尽管搭配了很多汇编代码丰富了示例,但也有些扰乱了学习进程,其实这一章是可以分成两部分,像中断、特权级、分页等一半的知识是可以在学会如何加载Kernel后再了解的;二是FAT和FreeDOS的引入又进一步干扰了进度。如果你只是把它当作未来Kernel的管理工具那还好,要是像我容易刨根问底的话可能就卡在那里了。其实像《30天自制操作系统》一书,不出几章很早就学完了Kernel的引导。在这一点上,《Orange’s》的作者比较细致,但也略显罗嗦。

    5.1 引导过程小结

    这里整理一下Orange’s与Linux 0.11对内存使用上的差异,以及各自比较麻烦的地方。

    5.1.1 Orange’s引导过程

    Orange’s的引导工作都是在两阶段引导程序boot和loader中完成的。整个引导过程还是比较清晰的,但有几个地方很麻烦:一是解析FAT文件系统格式将内核读取到内存;二是解析内核ELF文件头将内核的代码部分加载到指定位置。关于问题一之前也提到过,Orange’s作者使用FAT软盘和FreeDOS来方便调试。

    1. boot:从FAT格式软盘加载loader,打印一些信息后跳转
    2. loader:从FAT格式软盘加载内核,进入保护模式后准备GDT/IDT和PDT、栈段寄存器ss等后,跳转到内核

    所以Orange’s的内存使用情况如下图所示:boot始终在7c00h,kernel和loader的位置也固定在30000h和90000h,PDT被放置在了高地址100000h上。下面就看一下Linux 0.11的引导过程是什么样?它是如何使用内存空间的?

        ;              ┃                                    ┃
        ;              ┃                 .                  ┃
        ;              ┃                 .                  ┃
        ;              ┃                 .                  ┃
        ;              ┣━━━━━━━━━━━━━━━━━━┫
        ;              ┃■■■■■■■■■■■■■■■■■■┃
        ;              ┃■■■■■■Page  Tables■■■■■■┃
        ;              ┃■■■■■(大小由LOADER决定)■■■■┃
        ;    00101000h ┃■■■■■■■■■■■■■■■■■■┃ PageTblBase
        ;              ┣━━━━━━━━━━━━━━━━━━┫
        ;              ┃■■■■■■■■■■■■■■■■■■┃
        ;    00100000h ┃■■■■Page Directory Table■■■■┃ PageDirBase  <- 1M
        ;              ┣━━━━━━━━━━━━━━━━━━┫
        ;              ┃□□□□□□□□□□□□□□□□□□┃
        ;       F0000h ┃□□□□□□□System ROM□□□□□□┃
        ;              ┣━━━━━━━━━━━━━━━━━━┫
        ;              ┃□□□□□□□□□□□□□□□□□□┃
        ;       E0000h ┃□□□□Expansion of system ROM □□┃
        ;              ┣━━━━━━━━━━━━━━━━━━┫
        ;              ┃□□□□□□□□□□□□□□□□□□┃
        ;       C0000h ┃□□□Reserved for ROM expansion□□┃
        ;              ┣━━━━━━━━━━━━━━━━━━┫
        ;              ┃□□□□□□□□□□□□□□□□□□┃ B8000h ← gs
        ;       A0000h ┃□□□Display adapter reserved□□□┃
        ;              ┣━━━━━━━━━━━━━━━━━━┫
        ;              ┃□□□□□□□□□□□□□□□□□□┃
        ;       9FC00h ┃□□extended BIOS data area (EBDA)□┃
        ;              ┣━━━━━━━━━━━━━━━━━━┫
        ;              ┃■■■■■■■■■■■■■■■■■■┃
        ;       90000h ┃■■■■■■■LOADER.BIN■■■■■■┃ somewhere in LOADER ← esp
        ;              ┣━━━━━━━━━━━━━━━━━━┫
        ;              ┃■■■■■■■■■■■■■■■■■■┃
        ;       80000h ┃■■■■■■■KERNEL.BIN■■■■■■┃
        ;              ┣━━━━━━━━━━━━━━━━━━┫
        ;              ┃■■■■■■■■■■■■■■■■■■┃
        ;       30000h ┃■■■■■■■■KERNEL■■■■■■■┃ 30400h ← KERNEL 入口 (KernelEntryPointPhyAddr)
        ;              ┣━━━━━━━━━━━━━━━━━━┫
        ;              ┃                                    ┃
        ;        7E00h ┃              F  R  E  E            ┃
        ;              ┣━━━━━━━━━━━━━━━━━━┫
        ;              ┃■■■■■■■■■■■■■■■■■■┃
        ;        7C00h ┃■■■■■■BOOT  SECTOR■■■■■■┃
        ;              ┣━━━━━━━━━━━━━━━━━━┫
        ;              ┃                                    ┃
        ;         500h ┃              F  R  E  E            ┃
        ;              ┣━━━━━━━━━━━━━━━━━━┫
        ;              ┃□□□□□□□□□□□□□□□□□□┃
        ;         400h ┃□□□□ROM BIOS parameter area □□┃
        ;              ┣━━━━━━━━━━━━━━━━━━┫
        ;              ┃◇◇◇◇◇◇◇◇◇◇◇◇◇◇◇◇◇◇┃
        ;           0h ┃◇◇◇◇◇◇Int  Vectors◇◇◇◇◇◇┃
        ;              ┗━━━━━━━━━━━━━━━━━━┛ ← cs, ds, es, fs, ss
        ;
        ;
        ;       ┏━━━┓       ┏━━━┓
        ;       ┃■■■┃ 我们使用  ┃□□□┃ 不能使用的内存
        ;       ┗━━━┛       ┗━━━┛
        ;       ┏━━━┓       ┏━━━┓
        ;       ┃      ┃ 未使用空间  ┃◇◇◇┃ 可以覆盖的内存
        ;       ┗━━━┛       ┗━━━┛

    5.1.2 Linux 0.11引导过程

    Linux引导程序的第一阶段与Orange’s基本一致,但第二阶段要复杂一些,主要区别在于:Linux将Orange’s的loader进入保护模式后的内核准备工作拆分到一个单独的程序head中。具体来说,Linux的system的头部其实一个汇编程序head.s编译成的,system中head后面的部分才是真正的kernel。head中负责初始化PDT、重新放置GDT/IDT等工作。为了节约内存,PDT、GDT等数据会覆盖掉head代码,所以这部分代码的技巧性很高。

    5.1.3 我的MiniOS

    从上面的分析能够看出,我们目前的引导过程类似Orange’s的经典两阶段引导,但本质(比如命名、放置位置、软盘上程序的加载方式等)上都是借鉴Linux的。所以前面说到的Orange’s的麻烦之处我们也巧妙地避开了:

    • 关于Orange’s的第一点因为我们的bootsect和setup是连续放置的所以省了;
    • 关于第二点可以看一下我们的Makefile,因为链接时丢掉了ELF各种符号表等信息,直接得到了纯二进制文件,并且在软盘上system也紧接着setup,所以第二点也可以省了。

    接下来对引导和内核程序的完善过程中,我们还是更倾向于Linux,从setup中拆分出head,这样做的好处就是:内核程序以00000h为入口地址,最终进入内核时GDT、IDT、PDT等系统数据都位于00000h后的低地址,整体结构非常清晰。相比之下,Orange’s尽管引导过程能简单一些,但内核代码和系统数据比较零散,不够清晰。

    1. bootsect
      1.1 移动自己:将自己从7c00h拷贝到90000h。因为在2.1中bootsect区域会被用来保存BIOS信息,而2.2中system会被拷贝到00000h,所以为了避免bootsect中的信息被system覆盖掉,最好现在就挪到90000h高地址
      1.2 加载setup:加载setup到90200h
      1.3 显示信息:输出”Loading system…”
      1.4 加载system:根据SYSSIZE加载system到10000h
      1.5 跳转:跳转到setup继续执行
    2. setup
      2.1 读取内存大小:从BIOS读取内存等信息覆盖到90000h~901ffh,即bootsect的位置。因为bootsect中包含SYSSIZE,所以必须在bootsect中加载system,不能等到setup中再做
      2.2 移动system:BIOS中断已经使用完毕,可以将system拷贝到00000h覆盖掉BIOS中断表了
      2.3 进入保护模式:设置GDTR、A20、CR0进入保护模式
      2.4 跳转:跳转到system继续执行
    3. system(head)
      3.1 重新放置GDT:包括GDT/IDT,setup中的GDT是用于进入保护模式的临时GDT
      3.2 初始化PDT:根据内存大小确定PDT大小
      3.3 跳转:进入到main()函数,进入真正的内核

    从上面的引导过程,也让我们清晰地了解了为什么Linux 0.11会在内存中频繁的挪动代码——bootsect拷贝自己到90000h,setup挪动system到00000h——这两次挪动的原因以及为什么system不能等到setup读取完BIOS后直接加载到00000h的原因都在上面给出了解答。

  • 相关阅读:
    如何列出陣列中大於n的所有元素? (C/C++) (STL)
    為什麼int *ptr = 345;這樣的寫法有問題?
    如何使用STL寫XML轉檔程式? (C/C++) (STL) (Web) (XML)
    如何判斷回文(palindrome) ? (C/C++) (C) (STL)
    如何將int轉string? (C/C++) (C)
    如何將輸入的字串存到記憶體後,再一起印出來? (C/C++) (C)
    如何為程式碼加上行號? (C/C++) (STL)
    如何将字符串前后的空白去除(C/C++) (STL)
    簡單的Linked List實現
    如何將struct塞進vector? (C/C++) (STL)
  • 原文地址:https://www.cnblogs.com/xiaomaohai/p/6157591.html
Copyright © 2020-2023  润新知