《操作系统真象还原》内核内存分布与加载
操作系统内核加载流程图
%include "boot6_3.inc" section loader vstart=LOADER_BASE_ADDR ;loader写入的地址 LOADER_STACK_TOP equ LOADER_BASE_ADDR ;------------------------------- 内存安排 -------------------------------- ;创建虚拟机时在build\bochsrc7_3文件中设置的机器内存大小为megs: 32,即32MB ;生成的loader.bin写入硬盘第2个扇区。第0个扇区是MBR,第1个扇区是空的未使用。 ;BIOS调用mbr,mbr的地址是0x7c00,mbr调用loader,loader的地址是0x900。 ;这两个地址是固定的,也就是说,目前的方法是很不灵活的,调用方需要提前和被调用方约定调用地址。 ;------------------------------------------------------------------------------- ; 程序名称 | 磁盘扇区 | 物理内存起始地址 ; mbr | 第0个 | 0x7c00 ; loader | 第2个 | 0x900 ; gdt | 第2个 | 0x900 ; loader_start | 第2个 | 0xc00 ; kernel | 第9个 | 0x70000 ; kernel.segment | 第9个 | 0x1500 ; 页目录表 | 代码生成 | 0x100000 ; 栈顶esp | 代码生成 | LOADER_STACK_TOP = 0x900,启动分页后0xc0000900,最终0xc0000900 ;------------------------------------------------------------------------------- ; 0x7c00 = 7*16*16*16 + 12*16*16 = 7*2^12 + 12*2^8 = 7*4k + 3*4*2^8 = 28k + 3k ;---------------------- 大小:4*8 + 60*8 = 32 + 480 = 512byte --------------------- ;dd是伪指令,意为define double-word,即定义双字变量,一个字是2字节,所以双字就是4字节数据。 ;dd伪指令经编译后会在loader.bin文件中留出4字节的空间存储dd伪指令的操作数。 ;构建gdt及其内部的描述符,将被加载到内存0x900处 GDT_BASE: dd 0x00000000 ; 程序编译后的地址是从上到下越来越高的,所以, dd 0x00000000 ; 下面用dd定义的数据地址要高于上面的dd所定义的数据地址 ;段基址在8字节的段描述符中存在3处,它们在每处都会是0。 CODE_DESC: dd 0x0000FFFF dd DESC_CODE_HIGH4 ;type字段中的e要么是0(向上扩展),要么是1(向下扩展)。 DATA_STACK_DESC: dd 0x0000FFFF dd DESC_DATA_HIGH4 ; 段基址0-15位 段界限0-15位 ;0x80000007 = 10000000_00000000_00000000_00000111 ;0xb8000 = 00001011_10000000_00000000 VIDEO_DESC: dd 0x80000007 ;limit=(0xbffff-0xb8000)/4k=0x7 ??? dd DESC_VIDEO_HIGH4 ;此时dpl为0 GDT_SIZE equ $ - GDT_BASE GDT_LIMIT equ GDT_SIZE - 1 times 60 dq 0 ; 此处预留60个描述符的空位, 60*8=480byte ;total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记 ; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900 ; 故total_mem_bytes内存中的地址是0xb00,将来在内核中咱们会引用此地址 total_mem_bytes dd 0 ;4个字节 ; --------------------- 相当于宏,不计入到磁盘空间吗? ------------------------ ; 相当于(CODE_DESC - GDT_BASE)/8 + TI_GDT + RPL0 ;0x0001 = 00000000_00000001; 0x0001<<3 = 00000000_00001000 SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0 ;全局描述符表GDT中,第二个描述符也就是下标为1的描述符 ;0x0002 = 00000000_00000010; 0x0002<<3 = 00000000_00010000 SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0 ;全局描述符表GDT中,第三个描述符也就是下标为2的描述符 ;0x0003 = 00000000_00000011; 0x0003<<3 = 00000000_00011000 SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0 ; 同上 ;将全局描述符表的基地址和界限写到此处,以下是gdt的指针,此处应该位于内存512 + 4 + 0x900 = 0xb04的位置。 ;前2字节是gdt界限,后4字节是gdt起始地址。 ;此加载器loader将描述符表加载到内存0x900处,那么GDT_BASE的值=0x900处 gdt_ptr dw GDT_LIMIT dd GDT_BASE loadermsg db '2 loader in real.I am yangfan (:' ;32个字节 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;人工对齐:total_mem_bytes 4 + gdt_ptr 6 + loadermsg 32 + ards_buf 212 + ards_nr 2 ,共256字节 ards_buf times 212 db 0 ards_nr dw 0 ;用于记录ARDS结构体数量 ;--------------- 此处加载到的内存位置 768 + 0x900 = 0xc00 --------------- ; 512 + 4 + 6 + 32 + 212 + 2 = 768,上面的描述符表、输出信息、ARDS结构体等占用了768个字节。 ;让loader_start的地址刚好是0xc00,这都是安排好的。加载完后从mbr.s中跳到地址0xc00处执行函数loader_start。 ;此函数功能有四: 1 检测内存大小存入地址ards_buf处;2 加载全局描述符;3 进入保护模式;4 显示一些字符 loader_start: ;----- int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局 xor ebx, ebx ;第一次调用时,ebx值要为0 mov edx, 0x534d4150 ;edx只赋值一次,循环体中不会改变 mov di, ards_buf ;ards结构缓冲区 .e820_mem_get_loop: ;循环获取每个ARDS内存范围描述结构 ;所以每次执行int前都要更新为子功能号 mov eax, 0x0000e820 ;执行int 0x15后,eax值变为0x534d4150, mov ecx, 20 ;ARDS地址范围描述符结构大小是20字节 int 0x15 jc .e820_failed_so_try_e801 ;若cf位为1则有错误发生,尝试0xe801子功能 add di, cx ;使di增加20字节指向缓冲区中新的ARDS结构位置 inc word [ards_nr] ;记录ARDS数量 cmp ebx, 0 ;若ebx为0且cf不为1,这说明ards全部返回 ; 当前已是最后一个 jnz .e820_mem_get_loop ;在所有ards结构中找出(base_add_low + length_low)的最大值,即内存的容量 mov cx, [ards_nr] ;遍历每一个ARDS结构体,循环次数是ARDS的数量 mov ebx, ards_buf xor edx, edx ;edx为最大的内存容量,在此先清0 .find_max_mem_area: ;无需判断type是否为1,最大的内存块一定是可被使用的 mov eax, [ebx] ;base_add_low add eax, [ebx+8] ;length_low add ebx, 20 ;指向缓冲区中下一个ARDS结构 cmp edx, eax ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量 jge .next_ards mov edx, eax ;edx为总内存大小 .next_ards: loop .find_max_mem_area jmp .mem_get_ok ;------ int 15h ax = E801h 获取内存大小,最大支持4G ------ ; 返回后, ax cx 值一样,以KB为单位,bx dx值一样,以64KB为单位 ; 在ax和cx寄存器中为低16MB,在bx和dx寄存器中为16MB到4GB .e820_failed_so_try_e801: mov ax,0xe801 int 0x15 jc .e801_failed_so_try88 ;若当前e801方法失败,就尝试0x88方法 ;1 先算出低15MB的内存 ; ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位 mov cx,0x400 ;cx和ax值一样,cx用作乘数 mul cx ;固定的操作数AX shl edx,16 ;16位操作数乘法,积的高16位在DX寄存器,低16位在AX寄存器 and eax,0x0000FFFF or edx,eax ;将EDX左移16位后再与AX做或运算便得到了完整32位的积 add edx, 0x100000 ;ax只是15MB,故要加1MB mov esi,edx ;先把低16MB的内存容量存入esi寄存器备份 ;2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量 xor eax, eax mov ax, bx mov ecx, 0x10000 ;0x10000十进制为64KB mul ecx ;32位乘法,默认的被乘数是eax,积为64位 ;高32位存入edx,低32位存入eax,由于此方法只能测出4GB以内的内存,故32位eax足够了 add esi, eax mov edx, esi ;edx肯定为0,只加eax便可,edx为总内存大小 jmp .mem_get_ok ;----- int 15h ah = 0x88 获取内存大小,只能获取64MB之内 ----- .e801_failed_so_try88: ;int 15后,ax存入的是以KB为单位的内存容量 mov ah, 0x88 int 0x15 jc .e820_mem_get_failed and eax,0x0000FFFF mov cx, 0x400 ;0x400等于1024,将ax中的内存容量换为以byte为单位 mul cx ;16位乘法,被乘数是ax,积为32位。积的高16位在dx中;积的低16位在ax中 shl edx, 16 ;把dx移到高16位 or edx, eax ;把积的低16位组合到edx,为32位的积 add edx,0x100000 ;0x88子功能只会返回1MB以上的内存,故实际内存大小要加上1MB .e820_mem_get_failed: mov byte [gs:160], 'f' mov byte [gs:162], 'a' mov byte [gs:164], 'i' mov byte [gs:166], 'l' mov byte [gs:168], 'e' mov byte [gs:170], 'd' .mem_get_ok: mov [total_mem_bytes], edx ;将内存换为byte单位后存入 total_mem_bytes 处 ;------------------------------------------------------------ ;INT 0x10 功能号:0x13 功能描述:打印字符串 ;------------------------------------------------------------ ;输入: ;AH 子功能号=13H ;BH = 页码 ;BL = 属性(若AL=00H或01H) ;CX = 字符串长度 ;(DH、DL) = 坐标(行、列) ;ES:BP = 字符串地址 ;AL = 显示输出方式 ; 0—字符串中只含显示字符,其显示属性在BL中 ;显示后,光标位置不变 ; 1—字符串中只含显示字符,其显示属性在BL中 ;显示后,光标位置改变 ; 2—字符串中含显示字符和显示属性。显示后,光标位置不变 ; 3—字符串中含显示字符和显示属性。显示后,光标位置改变 ;无返回值 mov sp, LOADER_BASE_ADDR mov bp, loadermsg ; ES:BP = 字符串地址 mov cx, 32 ; CX = 字符串长度 mov ax, 0x1301 ; AH = 13, AL = 01h mov bx, 0x001f ; 页号为0(BH = 0) 蓝底粉红字(BL = 1fh) mov dx, 0x1800 ; 将中断号存入dx int 0x10 ; 10h 号中断 ;-------------------- 准备进入保护模式 ------------------------------- ;1 打开A20 ;2 加载gdt ;3 将cr0的pe位置1 ;----------------- 打开A20 ---------------- in al,0x92 or al,0000_0010B out 0x92,al ;----------------- 加载GDT ---------------- lgdt [gdt_ptr] ; gdt_ptr本身是个地址0xb04,所以要用中括号[]括起来,表示在地址处取值。 ;----------------- cr0第0位置1 ---------------- mov eax, cr0 or eax, 0x00000001 mov cr0, eax jmp dword SELECTOR_CODE:p_mode_start ; 刷新流水线 ;在loader中初始化全局描述符表进入保护模式后,把段寄存器ds、es、ss都指定为相同的数据段选择子啦 [bits 32] p_mode_start: ; 将加载到内存0xd00处 mov ax, SELECTOR_DATA ;SELECTOR_DATA = 00000000_00010000 = 0x0010 mov ds, ax ;机器码8ED8 mov es, ax mov ss, ax mov esp,LOADER_STACK_TOP ;LOADER_STACK_TOP = 0x900 mov ax, SELECTOR_VIDEO ;SELECTOR_VIDEO = 00000000_00011000 = 0x0018 mov gs, ax ;机器码8EE8 ;------------------------- 加载kernel镜像 ---------------------- mov eax, KERNEL_START_SECTOR ; kernel.bin所在的扇区号 mov ebx, KERNEL_BIN_BASE_ADDR ; kernel.bin从磁盘读出后,写入到ebx指定的物理内存的地址0x70000 = 7*2^16 = 7*64k = 448k mov ecx, 200 ; 读入的扇区数 call rd_disk_m_32 ;加载内核源文件,eax、ebx、ecx是函数rd_disk_m_32的三个参数,为调用下面的函数做准备。 mov byte [gs:160], 'T' ;-------------------------- 启用分页的三步曲 ------------------------- ; 第一步:调用setup_page,创建页目录及页表并初始化页内存位图, 之后才能重新加载lgdt [gdt_ptr] call setup_page ;通过sgdt指令把GDT放在内核的地址空间,将GDT的起始地址和偏移量信息dump(像倒水一样)出来,依然存放到gdt_ptr处 sgdt [gdt_ptr] ;一会儿待条件成熟时,我们再从地址gdt_ptr处重新加载GDT。 mov ebx, [gdt_ptr + 2] ;从gdt_ptr中取出高4字节的描述符表GDT的起始地址 ;视频段是第3个段描述符,每个描述符是8字节,故0x18 = 24。段描述符的高4字节的最高位是段基址的第31~24位 ;这里要将段描述符的基地址修改为3GB以上,所以在原有地址的基础上要加上0xc0000000。 ;段描述符中记录段基址最高位的部分是在段描述符的高4字节的最高1字节,所以ebx不仅要加上0x18,还要加上0x4。 ;为了省事,我们直接将整个4字节做或运算。最后就是第157行的指令“or dword [ebx + 0x18 + 4], 0xc0000000” ; ; 0xc00b8000 = 1100_0000_00|00_1011_1000|0000_0000_0000 ;页部件会根据1100_0000_00去页目录表中下标为768的页目录项中取出页表地址,再根据00_1011_1000, 1011_1000 = 184 ;到该页表中下标为0xB8=184的页表项中取出页物理地址+(加上)0000_0000_0000, ;得到虚拟地址0xc00b8000映射的物理地址 or dword [ebx + 0x18 + 4], 0xc0000000 ;将gdt描述符中视频段描述符中的段基址 + 0xc0000000 = 0xc00b8000 ;修改完了显存段描述符后,现在可以修改GDT基址啦,我们把GDT也移到内核空间中。 ;描述符表gdt在内存地址0x900处,将加上0xc0000000使其成为内核所在的高地址, 0xc0000000 = 3G ;0xc0000000 = 1100_0000_00|00_0000_0000|0000_0000_0000 ;页部件会根据1100_0000_00去页目录表中下标为768的页目录项中 add dword [gdt_ptr + 2], 0xc0000000 ; 此时描述符表GDT的地址是虚拟地址0xc0000900 ; 全局描述符表GDT的起始地址是0x900往上增长的,esp栈帧是往下增长的,所以不会覆盖全局描述符表。 add esp, 0xc0000000 ; 将栈指针同样映射到内核地址, 0x900 + 0xc0000000 ;第二步: ;将页目录表物理地址赋值给cr3寄存器,分页机制打开前要将页表地址加载到控制寄存器cr3中 mov eax, PAGE_DIR_TABLE_POS ; PAGE_DIR_TABLE_POS = 0x100000 = 1*2^20 = 1M mov cr3, eax ;第三步: ;打开cr0的pg位(第31位),将页目录表物理地址赋值给cr3寄存器后,随后启用cr0寄存器的pg位。 mov eax, cr0 or eax, 0x80000000 mov cr0, eax ;在开启分页后,用gdt新的地址重新加载 lgdt [gdt_ptr] ; 重新加载 ;在分页后,GDT的基址会变成3GB之上的虚拟地址,显存段基址也变成了3GB这上的虚拟地址。 ;视频段段基址已经被更新,打印字符virtual Addr mov byte [gs:160], 'V' mov byte [gs:162], 'i' mov byte [gs:164], 'r' mov byte [gs:166], 't' mov byte [gs:168], 'u' mov byte [gs:170], 'a' mov byte [gs:172], 'l' mov byte [gs:174], ' ' mov byte [gs:176], 'A' mov byte [gs:178], 'd' mov byte [gs:180], 'd' ;--------------------- 此时不刷新流水线也没问题 ------------------ ;由于一直处在32位下,原则上不需要强制刷新 ;经过实际测试没有以下这两句也没问题 ;但以防万一,还是加上啦,免得将来出来莫名其妙的问题 jmp SELECTOR_CODE:enter_kernel ;强制刷新流水线,更新gdt enter_kernel: call kernel_init ;初始化内核 ;在进入内核之后,栈也要重新规划,栈起始地址不能再用之前的0xc0000900啦。为了方便编写程序,0x900 = 9 * 16^2 = 9 * 2^8 ;我们在进入内核前将栈指针改成我们期待的值,我们将esp改成了0xc009f000。 ;0xc09f000 = 12*2^24 + 9*2^16 + 15*2^12,9f000 = 636KB = 159 * 4KB = 159页, mov esp, 0xc009f000 mov byte [gs:184], 'r' ; 跳到内核0xc0001500执行,此时已开启了分页。0xc0001500 = 12*2^28 + 1*2^12 + 5*2^8 = 3G + 8K + 1K + 256 jmp KERNEL_ENTRY_POINT jmp $ ; 通过死循环使程序悬停在此 ; 0x10 = 16, 0x100 = 256, 0x1000 = 4k, 0x10000 = 64k, 0x100000 = 1m ;------------- setup_page函数中创建页目录及页表 --------------- ;先把页目录占用的空间逐字节清0,再在0x10000 = 1M 地址的地方建立页目录表 setup_page: mov ecx, 4096 mov esi, 0 .clear_page_dir: ; 清空4k内存空间 mov byte [PAGE_DIR_TABLE_POS + esi], 0 ; PAGE_DIR_TABLE_POS = 0x100000 = 1MB inc esi ; 将寄存器esi加1 loop .clear_page_dir ; 循环4096次 ;开始创建页目录项(PDE) .create_pde: ; 创建Page Directory Entry mov eax, PAGE_DIR_TABLE_POS ; 此时eax为第一个页表的位置及属性, 0x1000 = 1*16^3 = 1*(2^12) = 4096 = 4KB ; eax = eax + 0x1000 = 0x101000,就是说页目录表起始地址是0x100000占用4k,0x101000就是第一个页表的起始地址。 add eax, 0x1000 mov ebx, eax ; 此处为ebx赋值,是为.create_pte做准备,ebx为基址 ;下面将页目录项0和0xc00都存为第一个页表的地址,每个页表表示1024*4KB=4MB内存 ;这样虚拟地址0xc0000000 ~ 0xc03fffff之间共4M的地址空间和虚拟地址0x0 ~ 0x003fffff之间的地址都指向相同的页表 ;这是为将地址映射为内核地址做准备。0xc00 = 12*16^2 = 2^2*3*2^8 = 3*2^10 = 3072, 3072/4 = 768 ;页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问 or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项代表一个页表,也就是说,下标为0和下标为768这两个页目录项都是指向同一个页表。 ; 它们共同所指向的这个页表地址是0x101000,它将来要指向的物理地址范围是0~0xfffff,只是1MB的空间,其余3MB并未分配。 mov [PAGE_DIR_TABLE_POS + 0x0], eax ;将第1个页表(也就是页目录表)的地址放入第一个页目录项中 ;第768(内核空间的第一个)个页目录项与第一个页目录项相同,这样第一个和768个都指向低端4MB空间 ; 0xc00表示第768个页表占用的目录项,0xc00 = 768 * 4 以上的目录项用于内核空间 ;也就是页表的0xc0000000~0xffffffff共计1G属于内核 mov [PAGE_DIR_TABLE_POS + 0xc00], eax ;一个页表项占用4字节 ;0x0~0xbfffffff共计3G属于用户进程 ;在页目录的最后一个页目录项中写入页表自己的物理地址。 ; ;也许您在想,为什么使用属性PG_US_U,而不是PG_US_S?原因是这样的,PG_US_U和PG_US_S是PDE或PTE的属性, ;它用来限制某些特权级的任务对此内存空间的访问(无论该内存空间中存放的是指令,还是普通数据)。PG_US_U表示PDE或PTE的US位为1, ;这说明处理器允许任意4个特权级的任务都可以访问此PDE或PDE指向的内存。 ;PG_US_S表示PDE或PTE的US位为0,这说明处理器允许除特权级3外的其他特权级任务访问此PDE或PDE指向的内存。此时若使用 ;属性PG_US_S也没问题,不过将来会实现init进程,它是用户级程序,而它位于内核地址空间,也就是说将来会在特权级3的情 ;况下执行init,这会访问到内核空间,这就是此处用属性PG_US_U的目的。 sub eax, 0x1000 ; eax = eax - 0x1000 = 0x100007 ; 使最后一个目录项指向页目录表自己的地址 mov [PAGE_DIR_TABLE_POS + 4092], eax ;在第一个页表中创建256个页表项(PTE), 第一个页表的物理地址为0x101000 = 1M零4K mov ecx, 256 ;创建256个页表项, 1M低端内存 / 每页大小4k = 256 mov esi, 0 mov edx, PG_US_U | PG_RW_W | PG_P ; 属性为7,US=1,RW=1,P=1 .create_pte: ; 创建页表中的页表项 Page Table Entry ; edx是能映射到4M物理地址的关键,第一个页表项指向4k物理地址空间, ;第一个页表项(也就是下标为0的页表项)指向的普通物理页起始地址是0x0000,地址范围是0x0000 ~ 0x3fff ;第二个页表项指向的普通物理页起始地址是0x4000,地址范围是0x4000 ~ 0x4fff,以此类推。 ;448k / 4k = 111,111 * 4k = 6f000, 768 * 4M = 0xc0000000 ;内核起始物理地址是0x70000 = 448k,刚好下标为111的页表项指向它。因此,可以通过虚拟地址0x0006fxxx和0xc006fxxx访问到。 mov [ebx + esi * 4], edx ;此时的ebx已经在上面通过eax赋值为0x101000,也就是第一个页表的地址 add edx,4096 ; 4096 = 0x1000 = 1 0000 0000 0000,页表项每增加一个,指向的框物理地址增加4k inc esi loop .create_pte ;创建内核其他页表的PDE,从第769个页目录项开始创建并指向相应的页表地址 mov eax, PAGE_DIR_TABLE_POS add eax, 0x2000 ; 此时eax为第二个页表的位置, 地址为0x102000 = 1M零8K or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性US、RW和P位都为1 mov ebx, PAGE_DIR_TABLE_POS ; 范围为第769~1022的所有目录项数量,最后一个页目录项指向的页表地址是0x100000,也就是指向自己所在的页目录表的起始地址 mov ecx, 254 mov esi, 769 .create_kernel_pde: ; 创建内核页目录项,254 + 769 = 1023 mov [ebx + esi * 4], eax inc esi add eax, 0x1000 ;页目录项每增加一个,指向的页表物理地址增加4k loop .create_kernel_pde ;循环0x944 = 2372次 ret ;--------------------------------- 内核加载位置说明 ------------------------------- ;将来内核肯定是越来越大,为了多预留出生长空间,咱们要将内核文件kernel.bin加载到地址较高的空间,而内核映像要放置到较低的地址。 ;内核文件经过loader解析后就没用啦,这样内核映像将来往高地址处扩展时,也可以覆盖原来的内核文件kernel.bin。 ;所以咱们的结论是在0x7e00~0x9fbff这片区域的高地址中找一亩地给kernel.bin,这里我擅自做主啦,帮大家选的是0x70000。 ;为什么?随意选的,取了个整而已,就是觉得0x70000~0x9fbff有0x2fbff=190KB字节的空间,而我们的内核不超过100KB,够用就行。 ;------------------------------------------------------------------------------- ;功能:读取硬盘n个扇区 ; eax=LBA扇区号 ; bx=将数据写入的内存地址 ; cx=读入的扇区数 ;------------------------------------------------------------------------------- ; kernel.bin位于第9扇区 rd_disk_m_32: ;将加载到0xe50处 mov byte [gs:160], 'V' ;eax、ebx、ecx在p_mode_start函数中rd_disk_m_32调用之前就设置好了。 mov esi, eax ;备份eax mov di, cx ;备份cx ;读写硬盘: ;第1步:设置要读取的扇区数 mov dx, 0x1f2 ; out指令中dx寄存器是用来存储端口号的 mov al, cl out dx, al ;读取的扇区数 mov eax, esi ;恢复ax ;第2步:将LBA地址存入0x1f3 ~ 0x1f6 mov dx, 0x1f3 ;LBA地址7~0位写入端口0x1f3 out dx, al mov cl, 8 shr eax, cl mov dx, 0x1f4 ;LBA地址15~8位写入端口0x1f4 out dx, al shr eax, cl mov dx, 0x1f5 ;LBA地址23~16位写入端口0x1f5 out dx, al shr eax, cl and al, 0x0f ;lba第24~27位 or al, 0xe0 ; 设置7~4位为1110,表示lba模式 mov dx, 0x1f6 out dx, al ;第3步:向0x1f7端口写入读命令,0x20 mov dx,0x1f7 mov al,0x20 out dx,al ;第4步:检测硬盘状态 .not_ready: ;同一端口,写时表示写入命令字,读时表示读入硬盘状态 nop in al, dx and al, 0x88 ;第4位为1表示硬盘控制器已准备好数据传输;第7位为1表示硬盘忙 cmp al, 0x08 jnz .not_ready ;若未准备好,继续等 ;第5步:从0x1f0端口读数据 mov ax, di ; di为要读取的扇区数,一个扇区有512字节,每次读入一个字 mov dx, 256 ; 共需di*512/2次,所以di*256 mul dx mov cx, ax mov dx, 0x1f0 .go_on_read: in ax, dx ; 将端口中的数据读入到ax中 ; 由于在实模式下偏移地址为16位,所以用bx只会访问到0~FFFFh的偏移。 ; loader的栈指针为0x900,bx为指向的数据输出缓冲区,且为16位, ; 超过0xffff后,bx部分会从0开始,所以当要读取的扇区数过大,待写入的地址超过bx的范围时, ; 从硬盘上读出的数据会把0x0000~0xffff的覆盖, ; 造成栈被破坏,所以ret返回时,返回地址被破坏了,已经不是之前正确的地,址 ; 故程序出会错,不知道会跑到哪里去。 ; 所以改为ebx代替bx指向缓冲区,这样生成的机器码前面会有0x66和0x67来反转。 ; 0X66用于反转默认的操作数大小! 0X67用于反转默认的寻址方式. ; cpu处于16位模式时,会理所当然的认为操作数和寻址都是16位,处于32位模式,时 ; 也会认为要执行的指令是32位. ; 当我们在其中任意模式下用了另外模式的寻址方式或操作数大小(姑且认为16位模式用16位字节操作数, ; 32位模式下用32字节的操作数)时,编译器会在指令前帮我们加上0x66或0x67, ; 临时改变当前cpu模式到另外的模式下. ; 假设当前运行在16位模式,遇到0X66时,操作数大小变为32位. 反转前缀 ; 假设当前运行在32位模式,遇到0X66时,操作数大小变为16位. ; 假设当前运行在16位模式,遇到0X67时,寻址方式变为32位寻址 ; 假设当前运行在32位模式,遇到0X67时,寻址方式变为16位寻址. mov [ebx], ax ;写入到内存0x70000处,ebx在p_mode_start函数中设置为了0x70000 add ebx, 2 ; ax寄存器大小为2字节,所以这里+2 loop .go_on_read mov byte [gs:186], 'A' ret
5.3.3 elf格式的二进制文件
程序中最重要的部分就是段(segment)和节(section)。
段和节的信息也是用header来描述的,程序头是program header,节头是section header。
程序中段的大小和数量是不固定的,节的大小和数量也不固定,因此需要为它们专门找个数据结构来描述它们,这个描述结构就是程序头表(program header table)和节头表(section header table)。
既然程序头表和节头表都称为表,这说明里面存储的是多个程序头program header和多个节头section header的信息,故这两个表相当于数组,数组元素分别是程序头program header和节头section header。
程序头表(program header table)中的元素全是程序头(program header),而节头表(section header table)中的元素全是节头(section header)。元素全是单一的,不会在程序头表中存在节头信息。
在表中,每个成员(数组元素)都统称为条目,即entry,一个条目代表一个段或一个节的头描述信息。
对于程序头表,它本质上就是用来描述段(segment)的,所以也可以称它为段头表。
由于程序中段和节的数量不固定,程序头表和节头表的大小自然也就不固定了,而且各表在程序文件中的存储顺序自然也要有个先后,故这两个表在文件中的位置也不会固定。因此,必须要在一个固定的位置,用一个固定大小的数据结构来描述程序头表和节头表的大小及位置信息,这个数据结构便是ELF header,它位于文件最开始的部分,并具有固定大小,一会儿咱们看elf header的数据结构就知道了。
ELF文件格式依然分为文件头和文件体两部分,elf的任何定义,包括变量、常量及取值范围,都可在Linux系统的/usr/include/elf.h中找到。
表 5-8 elf header 中的数据类型
数据类型名称 | 字节大小 | 对 齐 | 意 义 |
---|---|---|---|
Elf32_Half | 2 | 2 | 无符号中等大小的整数 |
Elf32_Word | 4 | 4 | 无符号大整数 |
Elf32_Addr | 4 | 4 | 无符号程序运行地址 |
Elf32_Off | 4 | 4 | 无符号的文件偏移量 |
程序头表告诉系统如何建立一个进程映像.它是从加载执行的角度来看待elf文件.从它的角度看.
elf文件被分成许多段,elf文件中的代码、链接信息和注释都以段的形式存放。每个段都在程序
头表中有一个表项描述,包含以下属性:段的类型,段的驻留位置相对于文件开始处的偏移,
段在内存中的首字节地址,段的物理地址,段在文件映像中的字节数.段在内存映像中的字节数,
段在内存和文件中的对齐标记。可用"readelf -l filename"察看程序头表中的内容。
elf文件头与程序头表的结构如下:
typedef uint32_t Elf32_Word, Elf32_Addr, Elf32_Off typedef uint16_t Elf32_Half;; typedef struct elf32_hdr{ //Magic number和其它信息 7f45 4c46 0101 0100 0000 0000 0000 0000 unsigned char e_ident[EI_NIDENT]; Elf32_Half e_type; //目标文件类型 0200 Elf32_Half e_machine; //硬件平台 0300 Elf32_Word e_version; //elf头部版本 0100 0000 Elf32_Addr e_entry; //程序进入点 0015 00c0 Elf32_Off e_phoff; //程序头表偏移量 3400 0000 Elf32_Off e_shoff; //节头表偏移量 805a 0100 Elf32_Word e_flags; //处理器特定标志 0000 0000 Elf32_Half e_ehsize; //elf头部长度 3400 Elf32_Half e_phentsize; //程序头表中一个条目的长度 2000 Elf32_Half e_phnum; //程序头表条目数目 0300 Elf32_Half e_shentsize; //节头表中一个条目的长度 2800 Elf32_Half e_shnum; //节头表条目个数 0a00 Elf32_Half e_shstrmdx; //节头表字符索引 0900 }Elf32_Ehdr; typedef struct elf32_phdr{ Elf32_Word p_type; //段的类型 Elf32_Off p_offset; //段的位置相对于文件开始处的偏移 Elf32_Addr p_vaddr; //段在内存中的首字节地址 Elf32_Addr p_paddr; //段的物理地址 Elf32_Word p_filesz; //段在文件映像中的字节数 Elf32_Word p_memsz; //段在内存映像中的字节数 Elf32_Word p_flags; //段的标记 Elf32_Word p_align; //段在内存中的对齐标记 )Elf32_Phdr;
kernel.bin中的elf头和程序头表数据
p_vaddr + p_filesz = 0xc0001000 + 0x11ed0 = 0xc0012ed0
程序的起始地址是 0xc0001500,该段又是代码段(从该段的段标志 p_flags 值为 5 看出:可读可执行)这说明该段的实际代码长度是:
0xc0012ed0 - 0xc0001500 = 0x119D0 = 72144
字节。
0x119D0 = 1*2^16 + 1*2^12 + 9*2^8 + 13*16 = 64k + 4k + 2k+256 + 208 = 70k + 464
第二行p_offset - 第一行p_filesz = 0x12000 - 0x119D0 = 0x130
00119a0: 41c5 0c04 0400 0000 2800 0000 181e 0000 A.......(....... 00119b0: 348e ffff ea04 0000 0041 0e08 8502 420d 4........A....B. 00119c0: 0548 8703 8304 03dc 04c3 41c7 41c5 0c04 .H........A.A... 00119d0: 0400 0000 2800 0000 441e 0000 f292 ffff ....(...D....... 00119e0: 1402 0000 0041 0e08 8502 420d 0548 8703 .....A....B..H.. 00119f0: 8304 0306 02c3 41c7 41c5 0c04 0400 0000 ......A.A....... 0011a00: 1c00 0000 701e 0000 da94 ffff 2b00 0000 ....p.......+... 0011a10: 0041 0e08 8502 420d 0567 c50c 0404 0000 .A....B..g...... 0011a20: 1c00 0000 901e 0000 e594 ffff c900 0000 ................ 0011a30: 0041 0e08 8502 420d 0502 c5c5 0c04 0400 .A....B.........
有个猜测:第一个程序段包含elf文件头和程序头表以及,第一个程序段的代码段。也就是说从kernel.bin文件开头0字节到偏移0x119d0处。
起始虚拟地址只是个对应于内存中的地址,程序在内存中才用得着它,现在我们通过虚拟地址来计算它在文件内的位置,也就是需要将其转换成在文件中的偏移量。
- 程序的入口虚拟地址 e_entry 是0xc0101500。
- 第一个段的起始虚拟地址 p_vaddr 为 0xc0001000。
- 并且第一个段在文件内的偏移量为 0,参见上图p_offset字段。
故,起始虚拟地址 e_entry 对应在文件中的偏移量为 0xc0001500 - 0xc0001000 + 0 = 0x500。将其换算成十进制为
1280。还是小心起见,这个偏移量肯定不能超过文件大小。
那么,来看看从bernel.bin文件头偏移0x500的位置代码是什么。
00004d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00004e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00004f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 0000500: 5589 e583 e4f0 83ec 10c7 0424 5cdf 00c0 U..........$\... 0000510: e81b 0f00 00e8 dd02 0000 e8fc 0f00 00c7 ................ 0000520: 0424 69df 00c0 e816 3900 00e8 8006 0000 .$i.....9....... 0000530: ebfe 5589 e583 ec28 e862 4800 0098 8945 ..U....(.bH....E 0000540: f48b 45f4 8944 2404 c704 247e df00 c0e8 ..E..D$...$~.... 0000550: ab4e 0000 837d f400 742b 8d45 ec89 0424 .N...}..t+.E...$
0x500处前3字节是pushl%ebp的机器码55、movl %esp,%ebp的机器码89e5。
内核被加载到内存后,loader还要通过分析其 elf 结构将其展开到新的位置,所以说,内核在内存中有两份拷贝,一份是 elf 格式的原文件 kernel.bin,另一份是 loader 解析 elf 格式的 kernel.bin 后在内存中生成的内核映像(也就是将程序中的各种段 segment 复制到内存后的程序体),这个映像才是真正运行的内核。
5.3.5 将内核载入内存
函数 kernel_init 的作用是将 kernel.bin 中的段( segment)拷贝到各段自己被编译的虚拟地址处。
内核被加载到内存后,loader还要通过分析其elf结构将其展开到新的位置,所以说,内核在内存中有两份拷贝,一份是elf格式的原文件kernel.bin,另一份是loader解析elf格式的kernel.bin后在内存中生成的内核映像(也就是将程序中的各种段segment复制到内存后的程序体),这个映像才是真正运行的内核。
内核文件kernel.bin是elf格式的二进制可执行文件,初始化内核就是根据elf规范将内核文件中的段(segment)展开到(复制到)内存中的相应位置。
预计loader.bin的大小不会超过2000字节。所以可选的起始物理地址是0x900+2000=0x10d0(不要把注意力放在这个奇怪的数上,偶然得出的)。
内存很大,但也尽量往低了选,于是凑了个整数,选了0x1500作为内核映像的入口地址。根据咱们的页表,低端1MB的虚拟内存与物理内存是一一对应的,所以物理地址是0x1500,对应的虚拟地址是0xc0001500。
对于可执行程序,我们只对其中的段(segment)感兴趣,它们才是程序运行的实质指令和数据的所在地,所以我们要找出程序中所有的段。
当调用kernel_init函数时,当时的栈指针是0xc00008fc,KERNEL_BIN_BASE_ADDR = 0x70000
初始化内核:需要在分页后,将加载进来的elf内核文件安置到相应的虚拟内存地址,然后跳过去执行。
为什么不是0xc0070000呢?因为下标为0的页目录项和下标为768的页目录项指向的是同一个页表。
内核在函数rd_disk_m_32中已经被加载到KERNEL_BIN_BASE_ADDR地址处即0x70000处,该处是文件头elf_header。
;---------------------- 将kernel.bin中的segment拷贝到编译的地址 ----------------------- kernel_init: xor eax, eax ;xor如果两个值不相同,则异或结果为1;如果两个值相同,则异或结果为0。 xor ebx, ebx ;ebx记录程序头表地址 xor ecx, ecx ;cx记录程序头表中的program header数量 xor edx, edx ;dx 记录program header尺寸,即e_phentsize ; 偏移文件42字节处的属性是e_phentsize,表示program header大小 mov dx, [KERNEL_BIN_BASE_ADDR + 42] ;表示第1 个program header在文件中的偏移量,其实该值是0x34,不过还是谨慎一点,这里来读取实际值 mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ; 偏移文件开始部分28字节的地方是e_phoff ;由于此时的ebx还只是存储着程序头表在文件内的偏移量,所以要将其加上内核的加载地址,这样才是程序头表的物理地址。 add ebx, KERNEL_BIN_BASE_ADDR ; e_phoff 的值加上KERNEL_BIN_BASE_ADDR, 0x34 + 0x70000 ; 偏移文件开始部分44字节的地方是e_phnum,表示有几个program header也就是段的数量 mov cx, [KERNEL_BIN_BASE_ADDR + 44] .each_segment: ;PT_NULL是在boot/include/boot.inc中定义的宏,其值为0,该意义表示空段类型。 ; (PT_NULL也可以在Linux系统的/usr/include/elf.h中找到其定义:#define PT_NULL 0。) cmp byte [ebx + 0], PT_NULL ; 若p_type等于 PT_NULL,说明此program header未使用 je .PTNULL ;如果发现该段是空段类型的话,就跨过该段不处理,跳到.PTNULL处 ;---------------为函数memcpy压入参数,参数是从右往左依然压入。函数原型类似于 memcpy(dst,src,size)---------------- ;压入函数memcpy的第三个参数:size push dword [ebx + 16] ; program header中偏移16字节的地方是p_filesz。 ;压入函数memcpy的第二个参数:src mov eax, [ebx + 4] ; 取出p_offse的值。一般程序头表中的第一个表项的p_offset=0x0000 0000。 add eax, KERNEL_BIN_BASE_ADDR ; 加上kernel.bin被加载到的物理地址0x70000,eax为该段的物理地址 push eax ;压入函数memcpy的第一个参数:dst,偏移程序头8字节的位置是p_vaddr = 0xc000 1000,这就是目的地址。 push dword [ebx + 8] call mem_cpy ; 调用mem_cpy完成段复制 add esp,12 ; 清理栈中压入的三个参数 .PTNULL: add ebx, edx ;edx为program header大小,即e_phentsize在此ebx指向下一个program header loop .each_segment ret ;---------- 逐字节拷贝 mem_cpy(dst,src,size) ------------ ;输入:栈中三个参数(dst,src,size) ;输出:无 ;--------------------------------------------------------- mem_cpy: cld ;控制重复执行字符串指令时的[e]si 和[e]di的递增方式,为movs[bwd]服务 push ebp ;push操作的原理是先进行sub esp,4,再mov dword [esp],操作数,所以栈底处是空的 mov ebp, esp push ecx ; rep指令用到了ecx。但ecx对于外层段的循环还有用,故先入栈备份 ;栈中地址0xc00008f8处的内容是提供给函数mem_cpy的第三个参数,即size。 ;地址较低的0xc00008f4处是它的第二个参数,即src地址,0xc00008f0处是它的第一个参数,即dst。 mov edi, [ebp + 8] ; dst 栈是向下扩展的 mov esi, [ebp + 12] ; src mov ecx, [ebp + 16] ; size ;rep指令是repeat重复的意思,该指令是按照ecx寄存器中指定的次数重复执行后面的指定的指令,每执行一次,ecx自减1,直到ecx等于0时为止 rep movsb ;将数据从esi指向的内存地址复制到edi指向的地址 ;恢复环境 pop ecx pop ebp ret
kernel_init此函数执行的时候,内核已经加载到内存0x70000的位置,0x70000 + 0x28是e_phoff字段,存储着程序头表在文件中的偏移地址。调用men_cpy函数,将程序头表和段映射复制到0xc000 1000处,然后跳到0xc000 1500处执行。实际,仍是将kernel.bin文件整个复制到内存0xc000 1000处,只不过是为了配合0xc000 1500的程序入口地址罢了。为什么这么说,因为内核在0x70000处时,0xc000 1500处没有代码。唯一能想到这么做的解释是灵活性。
为什么要绕一大圈呢?0xc00 1500 - 0xc000 1000 = 0x500,那为什么不直接跳到0x70000 + 0x500的内存处执行呢?或者直接将内核kernel.bin文件加载到0xc000 1000的内存处呢?这样的话直接跳到0xc00 1500就可以执行了还少一道复制。唯一能想到的解释是灵活性。
上图是在ubuntu中编译32位程序程序头表与二进制可执行程序.out文件对比图,可见程序入口不是0x804800也不是0x1500而是0x1070,与上面编译的内核kernel.bin的解析与加载完全不同。
;-------------------------------------------------------------------------------------------- ; 程序名称 | 磁盘扇区 | 物理内存起始地址 |页目录项(下标)|页表项(下标)|对应虚拟地址 ; mbr | 第0个 | 0x7c00 = 31k | 0, 768 | 6 | 0xC000 6000 ; loader | 第2个 | 0x900 = 2k+256 | 0, 768 | 0 | 0xC000 0000 ; gdt | 第2个 | 0x900 = 2k+256 | 0, 768 | 0 | 0xC000 0000 ; loader_start | 第2个 | 0xc00 = 3k | 0, 768 | 0 | 0xC000 0000 ; 页目录表 | | 0x100000 = 1M | 0, 768 | 255 | 0xC00F F000 ; kernel | 第9个 | 0x70000 = 448k | 0, 768 | 111 | 0xC006 F000 ; kernel.segment | 第9个 | 0x1500 = 5k+256 | 0, 768 | 1 | 0xC000 1000 ;--------------------------------------------------------------------------------------------- ;------------------------------------------------------------------------------- ; 低端1MB中可用内存,可用的部分打√ ;------------------------------------------------------------------------------- ; | 9FC00 | 9FFFF | 1K | EBDA(Extended BIOS Data Area)扩展bios数据区 ; √ | 7E00 | 9FBFF | 622080B约608K | 可用区域 ; √ | 7C00 | 7DFF | 512B | MBR被BIOS加载到此处,共512字节 ; √ | 500 | 7BFF | 30454B约30K | 可用区域 7bff - 500 = 76ff, 76ff - 7000 = 16ff, 29k+791 ; | 400 | 4FF | 256B | BIOS Data Area (BOIS 数据区) ; | 000 | 3FF | 1K | Interrupt Vector Table(中断向量表) ;------------------------------------------------------------------------------- ;------------------------------------------------------------------------------- ; 链接视图 | 运行视图 ;------------------------------------------------------------------------------- ; ELF header(elf头) | ELF header(elf头) ; Programheadertable(程序头表)可选 | Programheadertable(程序头表) ;------------------------------------------------------------------------------- ; Section1(节1) | Segment1(段1) ; .... | ;------------------------------------------------------------------------------- ; Sectionn(节n) | Segment2(段2) ; .... | ;------------------------------------------------------------------------------- ; Sectionheadertable(节头表) | Sectionheadertable(节头表)可选 ; 待重定位文件体 | 可执行文件体 ;------------------------------------------------------------------------------- ; 表 2-1 实模式下的内存布局,可用的部分打√ ;------------------------------------------------------------------------------- ; 起始 | 结 束 | 大小 | 用 途 ;------------------------------------------------------------------------------- ; FFFFO | FFFFF | 16B | BIOS入口地址,此地址也属于BIOS代码,同样属于顶部的640KB字节。只是为了强调其入口地址才单独贴出来。此处16字节的内容是跳转指令impf0o:e05b ; F0000 | FFFEF | 64KB-16B | 系统BIOS范围是F0000~FFFFF共640KB,为说明入口地址,将最上面的16字节从此处去掉了,所以此处终止地址是0XFFFEF ; C8000 | EFFFF | 160KB | 映射硬件适配器的ROM或内存映射式I/O ; C0000 | C7FFF | 32KB | 显示适配器BIOS ; B8000 | BFFFF | 32KB | 用于文本模式显示适配器 ; B0000 | B7FFF | 32KB | 用于黑白显示适配器 ; A0000 | AFFFF | 64KB | 用于彩色显示适配器 ; 9FC00 | 9FFFF | 1KB | EBDA(Extended BIOS Data Area)扩展BIOS数据区 ; 7E00 | 9FBFF |622080B约608KB | 可用区域 ; 7C00 | 7DFF | 512B | MBR被BIOS加载到此处,共512字节 ; 500 | 7BFF | 30464B约30KB | 可用区域 ; 400 | 4FF | 256B | BIOS Data Area(BIOS数据区) ; 000 | 3FF | 1KB | Interrupt Vector Table(中断向量表) ;------------------------------------------------------------------------------- ;表3-17 硬盘控制器主要端口寄存器 ;------------------------------------------------------------------------------- ; IO端口 | 端口用途 ;------------------------------------------------------------------------------- ; Primary通道 | Secondary通道 | 读操作时 | 写操作时 ;------------------------------------------------------------------------------- ; Command Block registers ;------------------------------------------------------------------------------- ; 0x1F0 | 0x170 | Data | Data ; 0x1F1 | 0x171 | Error | Features ; 0x1F2 | 0x172 | Sector count | Sector count ; 0x1F3 | 0x173 | LBA low | LBA low ; 0x1F4 | 0x174 | LBA mid | LBA mid ; 0x1F5 | 0x175 | LBA high | LBA high ; 0x1F6 | 0x176 | Device | device ; 0x1F7 | 0x177 | Status | Command ;------------------------------------------------------------------------------- ; Control Block registers ;------------------------------------------------------------------------------- ; 0x3F6 | 0x376 | Alternate status | Device Control ;-------------------------------------------------------------------------------
Linux 中任务切换不使用 call 和 jmp 指令。
4096 * 8 * 4k = 2^12 * 2^3 * 2^12 = 2^27
作者:yangsail
出处:https://www.cnblogs.com/yangsail/p/18498538
版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通