1. 主引导扇区的作用以及开机之后的大致流程:
1) 为了学习实模式下的编程而不受操作系统的影响,因为在正常的开机后,经过主引导扇区的对操作系统的加载就会把计算机的控制权交给操作系统从而进入保护模式,因此就只有运行主引导扇区程序时系统处于实模式状态;
2) 内存逻辑地址空间:
i. 实模式下CPU有20根地址线,能访问的地址空间有1MB,但是这1MB并不全部都指向DRAM;
ii. 在体系结构中CPU将这1MB空间映射到了多个存储设备上,其中:
iii. ROM占据顶部64KB空间,F0000~FFFFF;
iv. DRAM占据底部640KB的1MB中最大的空间,00000~9FFFF;
v. 中间空出来320KB用于分配给外围设备的存储空间,其中最重要的就是字符界面的显示器,这是一种古老的显示模式,80(个字符) × 25(行)为一屏,一屏总共能满满显示2000个字符,由于每个字符包括其ASCII码和颜色等属性,因此一个字符占两个字节,因此一屏占4000个字节,而留个其的地址空间时B8000~BFFFF这32768个字节(即刚好一个段64KB),总共可以容纳8屏多一点点儿,而在屏幕上显示字符的过程仅仅就是向显存中存放字符的过程那么简单;
3) 开机加电后系统的运作流程:
i. 卡机加电后cs:ip自动指向0xFFFF0的BIOS固件处;
ii. 该处只有一条指令:jmp far 0xF000:0x0000,然后跳转到了BIOS固件的起始位置,运行BIOS的程序;
iii. 在BIOS程序中会先在0x00000处加载BIOS自己的中断向量表,然后再执行一些硬件初始化和自检程序;
iv. 完成自检和初始化后调用BIOS的int19中断例程将控制权交给操作系统(实质上该例程是读取磁盘0号逻辑扇区的MBR主引导扇区程序,而传统上MBR是属于操作系统的,虽然该程序仍然运行于实模式);
4) MBR:
i. 即主引导扇区程序,位于逻辑0号扇区内,作用是加载操作系统代码,使程序从实模式变换到保护模式,真正将系统的实权交给操作系统,即扮演一个接力手的角色;
ii. int19BIOS例程会从磁盘的0面0道1扇区(也是逻辑0号扇区)的512B内容加载到0x0000:0x7C00处;
iii. BIOS判断该扇区是MBR的标准就是检测512字节的最后两个字节是否是0x55和0xAA,这是规范!如果检测符合规范,则执行跳转指令jmp far 0x0000:0x7C00转到主引导程序处执行操作系统引导工作,否则就会开机失败转去执行错误处理中断例程报告本次开机失败!
5) 在这里我们不介绍如何引导操作系统,只介绍一个简单的MBR程序,让其在屏幕上显示一定的内容,并学会如何使用BOCHS单步调试,这也是调试操作系统内核的基础;
; 此程序用于显示标号target的汇编地址的十进制形式 ; 注意,此时跳到了0x0000:0x7C00处 ; 因此cs=0x0000而ip=0x7C00 jmp start stack times 20 db 0 ; 定义一个栈,程序中需要使用 len_stack equ $ - stack VIDEO_SEG_BEGIN equ 0xB800 ; 显存起始段地址 THIS_SEG equ 0x7C00 ; 整个程序自成一段,起始偏移地址为0x7C00但起始汇编地址是0x0000 str_info db 'Label offset: ' ; 要显示的信息 len_str_info equ $ - str_info ; 上面字符串的长度 target db 0 start: ; es指向显存,ds指向str_info mov ax, VIDEO_SEG_BEGIN mov es, ax mov ax, cs ; 此时cs=0x0000 mov ds, ax ; 显示字符串"Label offet: " mov ah, 0007H ; 设置字的颜色属性,ASCII码存放在al中,颜色属性存放在ah中 mov bx, 0 mov bp, 0 mov cx, len_str_info .lp1: mov al, [THIS_SEG + str_info + bx] mov [es:bp], ax inc bx add bp, 2 loop .lp1 ; 初始化栈段 mov ax, cs mov ss, ax mov sp, THIS_SEG + stack + len_stack ; 将target分解成5位十进制数的ASCII码并倒序入栈 mov ax, target ; ax存放被除数ax mov bx, 10 ; bx存放除数10 mov cx, 5 ; 最多是个五位数,一位位分解 .lp2: xor dx, dx ; 将dx清零,被除数就成了dx:ax div bx add dl, 0x30 ; 余数加0x30得到ASCII码 mov dh, 0004H ; 设置该数字的颜色 push dx ; 由于是倒序的,所以用栈反转一下 loop .lp2 ; 将倒序的5个十进制数顺序弹出到显存从而可以显示正确的顺序 mov cx, 5 .lp3: pop word [es:bp] add bp, 2 loop .lp3 mov dh, 0004H mov dl, 'D' mov [es:bp], dx ; 最后补一个后缀D表示十进制数 mov word [es: bp + 2], 0 ; 补一个空白字符冲马桶,将显存中的内容冲到显示器上 jmp $ ; 死循环卡住程序 times 510-($-$$) db 0 ; 填满剩余空间,总共512KB dw 0xAA55 ; 最后两字节存放MBR结束符
2. 局部标号和全局标号:
1) 局部标号有一个句号"."作为前缀,而全局标号没有前缀;
2) 局部标号的作用是用来解决程序过长时标号的命名冲突问题,局部标号可以使得同一个名字的标号可以多次重复定义并不会产生命名冲突;
3) 局部标号的作用域:
i. 局部标号往往夹在两个离得最近的全局标号之间,而其作用域也就位于这两个全局标号之间了;
ii. 在作用域之外可以重复定义同名的局部标号;
iii. 在逻辑上局部标号”属于“前面的最近的全局标号,这种属于关系类似于C语言中的结构体和结构体成员之间的关系;
iv. 如果在作用域之内访问局部标号可以”直呼其名“,但是如果想在作用域之外访问某个局部标号就要使用和C语言访问结构体成员的一样的方式去访问那个局部标号了:
s1: mov ah, al add cx, bx jmp s2.tag1 ; 作用域之外访问,所属全局标号.局部标号,否则会报错,提示没有定义该局部标号! nop nop sub ax, 1 s2: jmp .tag1 ; 在作用域范围之内访问 mov ax, bx add ax, bx .tag1: nop mov bx, cx!!!注意:gdb拒绝在命令行中对局部标号设置断点(可能是gdb的一个bug吧!),因此只能通过外部工具比如Insight等调试工具进行设置(这些工具可以在任意一行上设置断点);
; 应用程序头 ; 用于提供加载器相关加载信息 ; 是应用程序规范的一部分 section header vstart=0 app_size dd app_end ; [APP_SIZE:0x00] 程序的大小(字节) app_entry dw start ; [APP_ENTRY:0x04] 入口处偏移地址 app_entry_seg dd section.code1.start ; [APP_ENTRY_SEG:0x06] 入口处段地址 ; section.段名.start是NASM提供的伪指令,用于段起始位置在源程序中的绝对汇编地址 ; 绝对汇编地址是指相对于整个源程序头的偏移量,而整个程序头的绝对汇编地址是0 ; 绝对汇编地址是一个32位无符号数,因此使用dd表示 c_realloc_tbl dw (tbl_end - tbl_start) / 4 ; [C_REALLOC_TBL:0x0A] 重定位表表项数目 tbl_start: ; [TBL_START:0x0C] seg_addr_code1 dd section.code1.start seg_addr_code2 dd section.code2.start seg_addr_data1 dd section.data1.start seg_addr_data2 dd section.data2.start seg_addr_stack dd section.stack.start tbl_end: ; section header end ;; ;; section stack align=16 vstart=0 resb 256 stack_end: ; section stack end ;; ;; section data1 align=16 vstart=0 msg0 db ' This is NASM - the famous Netwide Assembler. ' db 'Back at SourceForge and in intensive development! ' db 'Get the current versions from http://www.nasm.us/.' db 0x0d,0x0a,0x0d,0x0a db ' Example code for calculate 1+2+...+1000:',0x0d,0x0a,0x0d,0x0a db ' xor dx,dx',0x0d,0x0a db ' xor ax,ax',0x0d,0x0a db ' xor cx,cx',0x0d,0x0a db ' @@:',0x0d,0x0a db ' inc cx',0x0d,0x0a db ' add ax,cx',0x0d,0x0a db ' adc dx,0',0x0d,0x0a db ' inc cx',0x0d,0x0a db ' cmp cx,1000',0x0d,0x0a db ' jle @@',0x0d,0x0a db ' ... ...(Some other codes)',0x0d,0x0a,0x0d,0x0a db 0 ; section data1 end ;; ;; section data2 align=16 vstart=0 msg1 db ' Welcome and enjoy NASM! ' db '2015-01-05' db 0 ; section data2 end ;; ;; section code1 align=16 vstart=0 start: mov ax, [seg_addr_stack] mov ss, ax mov sp, stack_end mov ax, [seg_addr_data1] mov ds, ax mov bx, msg0 call put_string ; 显示第一段信息 ; 在加载程序中将es指向header了 push word [es:seg_addr_code2] ; 先将code2的偏移地址和段地址入栈 mov ax, _start.begin push ax retf ; 利用retf修改cs:ip使其跳转至code2 .continue: mov ax, [es:seg_addr_data2] mov ds, ax mov bx, msg1 call put_string ; 使ds:bx指向msg1并输出 jmp $ ; end start ; 字符串控制宏以及显卡光标端口宏 CHAR_TRAIL equ 0x00 ; 字符串结束符 CHAR_RET equ 0x0D ; 回车符 CHAR_NL equ 0x0A ; 换行符 DCHAR_NONE equ 0x0720 ; 显存中显示空的字 PORT_CHOOSE equ 0x3D4 ; 索引端口,用于选择子端口(8位) SUBPORT_HIGH equ 0x0E ; 子端口号 SUBPORT_LOW equ 0x0F ; 这两个子端口分别存放光标位置的高位和低位 PORT_DATA equ 0x3D5 ; 数据端口,存放选定的端口中的数据(8位) VIDEO_SEG_BEGIN equ 0xB800 ; 显卡区域起始段地址 ; func put_string ; <- [ds:bx]:msg0 ; colision register: es ; 将msg0打印至屏幕 put_string: push es ; 获取当前光标位置保存在ax中 mov dx, PORT_CHOOSE mov al, SUBPORT_HIGH out dx, al ; 选择一个子端口 mov dx, PORT_DATA in al, dx mov ah, al ; 从子端口中读取光标高位保存在ah中 mov dx, PORT_CHOOSE mov al, SUBPORT_LOW out dx, al mov dx, PORT_DATA in al, dx ; 同理从子端口中读取光标低位保存在al中 ; 最终将整个结果保存在ax中 ; 目前ax存放着光标的位置 .lp: mov cl, [bx] ; 读取一个字符保存在cl中 cmp cl, CHAR_TRAIL ; 判断该字符是否是结束符 je .ret call put_char ; 不是结束符就打印该字符 inc bx ; 继续读取下一个字符 jmp .lp .ret: pop es ret ; func put_char ; <- cl:当前读取的一个字符 ; colision register: ds, bx put_char: push ds push bx ; 备份 ; ds和es都指向显卡 mov bx, VIDEO_SEG_BEGIN mov ds, bx mov es, bx ; 目前ax存放着光标的位置 cmp cl, CHAR_RET ; 判断字符是否是回车 jne .next0 ; 不是回车则继续接下来的步骤 .deal_ret: ; 是回车则处理回车 mov bl, 80 div bl mul bl ; 除去光标位置中80的余数即可 ; ax中得到的是回车后光标的位置 jmp .set_cursor .next0: cmp cl, CHAR_NL ; 判断是否是换行符 jne .next1 ; 如果不是换行符则继续接下来的代码 .deal_nl: ; 处理换行的情形 add ax, 80 ; 换行很简单,只要加80即可 jmp .deal_roll_screen ; 换行可能会造成屏幕滚动,因此需要处理 .next1: ; 结束、回车、换行都不是那就是普通字符了,因此需要打印出来,并且光标后移一位 mov bx, ax ; 先将ax复制到bx中 shl bx, 1 ; 显卡区域每个字符占两个字节(还有一个属性字节) mov [bx], cl inc ax ; 光标后移一位 ; jmp .deal_roll_screen ; 光标后移也可能会造成滚屏 .deal_roll_screen: cmp ax, 2000 jl .set_cursor ; 检查光标是否越界,如果越界则需要滚屏,否则可以直接设置光标 .roll_screen: ; 滚屏处理 mov si, 80 * 2 mov di, 0 mov cx, 2000 - 80 cld rep movsw .clear_bottom_line: ; 滚屏后需要清除最后一行 mov bx, (2000 - 80) * 2 mov cx, 80 .cls: mov word [bx], DCHAR_NONE add bx, 2 loop .cls mov ax, 2000 - 80 ; 滚屏后光标位置设置成最后一行起始 ; jmp .set_cursor ; 滚屏完成后方可显示新的光标的位置了 .set_cursor: mov bx, ax ; 将光标位置备份到bx中,因为访问端口会用到ax mov dx, PORT_CHOOSE mov al, SUBPORT_HIGH out dx, al mov dx, PORT_DATA mov al, bh out dx, al mov dx, PORT_CHOOSE mov al, SUBPORT_LOW out dx, al mov dx, PORT_DATA mov al, bl out dx, al pop bx pop ds ret ; section code1 end ;; ;; section code2 align=16 vstart=0 _start: .begin: push word [es:seg_addr_code1] ; code2没做什么实事就是再跳回code1的continue继续执行 mov ax, start.continue push ax retf ; section code2 end ;; ;; section trail align=16 app_end: ; section trail end
; 主引导扇区程序作为应用程序加载器 ; 虽然就只有一个段但是也需要定义 ; 最主要是为了使用段属性vstart=0x7C00 ; 这样就可以使得段内的所有汇编地址都是相对0x7C00开始的 ; 因为MBR加载在0x0000:0x7C00处,因此IP初始化为0x7C00 ; 而所有偏移地址都是相对0x7C00的 ; 有了这一步程序中的所有标号都能真正代表偏移地址了 section loader align=16 vstart=0x7C00 jmp near start LBA_APP_START equ 100 ; 应用程序所在硬盘的起始逻辑扇区号,这里是人为规定的 ADDR_20_LOAD_START dd 0x10000 ; 内存中加载的起始20位绝对物理地址 ; 应用程序头中信息的偏移地址 APP_SIZE_LOW equ 0x00 APP_SIZE_HIGH equ 0x02 APP_ENTRY equ 0x04 APP_ENTRY_SEG equ 0x06 APP_ENTRY_SEG_LOW equ 0x06 APP_ENTRY_SEG_HIGH equ 0x08 C_REALLOC_TBL equ 0x0A TBL_START equ 0x0C ; 从0x0FFFF往下(即地址减小)的一段区域一般都作为MBR的栈! ; 因此ss:sp指向0x0000:0x0000 ; 这样在push的时候sp能回到0xFFFF start: mov ax, 0 mov ss, ax mov sp, ax ; ds -> 内存中加载的起始位置段地址 mov ax, [cs:ADDR_20_LOAD_START] mov dx, [cs:ADDR_20_LOAD_START+2] mov bx, 16 div bx mov ds, ax mov es, ax ; 先读取一个扇区,即应用程序头所在的扇区 xor di, di mov si, LBA_APP_START ; [di:si]全局保存当前读取的逻辑扇区号 mov cx, 1 ; 读取一个扇区 call read_lba ; 读取完毕,ds:0指向程序的第一扇区中的内容 mov dx, [APP_SIZE_HIGH] mov ax, [APP_SIZE_LOW] mov bx, 512 div bx cmp dx, 0 jne .deal_left ; 有余数,可以将已经读取的那个扇区看做余数的扇区 dec ax ; 无余数则需要减去已经读取的那个扇区 .deal_left: cmp ax, 0 je redirect_entry ; 如果没有剩余扇区要读则直接去重定位程序入口点 push ds ; 备份并改变其指向 mov cx, ax ; 剩余要读的扇区数量 mov ax, ds add ax, 0x20 ; 使其指向下一个512字节起始处(必然是16位对齐的) mov ds, ax inc si ; 指向下一个要读的扇区 call read_lba pop ds ; 恢复ds使其指向加载的程序的开始处 ; 到此为止程序彻底加载完毕 ; 接下来的工作是将程序头中的入口地址,以及重定位表中的地址 ; 修改成实际的物理地址 ; 这里所重定位的地址都是段地址 ; 将程序中段的绝对汇编地址更新成加载在内存中的实际物理段地址 ; 公式是:16位物理段地址 = (整个程序起始位置的20位物理 + 段的32位绝对汇编地址) >> 4 redirect_entry: ; 重定位入口处地址 mov dx, [APP_ENTRY_SEG_HIGH] ; [dx:ax]中保存入口处的绝对汇编地址 mov ax, [APP_ENTRY_SEG_LOW] call calc_seg_phy_addr_16 ; 计算段的16位段地址(即物理段地址),结果保存在ax中 mov [APP_ENTRY_SEG], ax ; 更新 ; 处理重定位表 mov cx, [C_REALLOC_TBL] mov bx, TBL_START .realloc: mov dx, [bx + 2] mov ax, [bx] call calc_seg_phy_addr_16 mov [bx], ax add bx, 4 loop .realloc ;mov ax, ds ;mov es, ax ; 使es初始化成加载的起始位置并交给应用程序处理 jmp far [APP_ENTRY] ; 控制权交给应用程序 ; func read_lba ; <- [di:si]:读取的逻辑扇区号 ; <- cx:读取的扇区数量 ; <- ds:目的区域段地址 ; 将cx个扇区的内容读取到ds:0所指向的内存空间中 read_lba: PORT_DATA equ 0x1F0 ; 数据端口(16位) PORT_ERRNO equ 0x1F1 ; 错误端口(8位)保存最后一次执行命令后的状态(错误原因) PORT_CLBA equ 0x1F2 ; 计数端口(8位)保存读写的扇区数量 PORT_LBA_START equ 0x1F3 ; 逻辑扇区号端口(32位共4个8位口) ; 低28位确定待操作的起始扇区号 ; 最高的4位指定扇区寻址模式以及类型选择符) PORT_CTRL equ 0x1F7 ; 控制端口(8位)下读写命令同时又能反映硬盘工作状态 CTRL_READ equ 0x20 ; 读命令,向控制端口发送 BIT_MASK equ 10001000B ; 位掩码,取控制端口的第7位和第3位 ; 第7位表示硬盘是否忙,1表示忙 ; 第3位表示硬盘是否就绪,1表示就绪 STATUS_READY equ 00001000B ; 彻底就绪时第7位是0,第3位是1,用于检测硬盘是否就绪 ; 指定读取的扇区数量 mov dx, PORT_CLBA mov al, cl out dx, al ; 向LBA地址口写入28位逻辑扇区号 mov dx, PORT_LBA_START ; 0~7位 mov ax, si out dx, al inc dx ; 8~15位 mov al, ah out dx, al inc dx ; 16~23位 mov ax, di out dx, al inc dx ; 24~27位 mov al, 0xE0 ;mov al, 111_1_0000B ; ah保存24~27位,al中保存扇区寻址模式以及类型选择符 or al, ah out dx, al ; 发出读命令 mov dx, PORT_CTRL mov al, CTRL_READ out dx, al .waits: ; 检测硬盘是否就绪,没就绪就一直等待就绪 in al, dx and al, BIT_MASK cmp al, STATUS_READY jne .waits ; 准备就绪就开始读取 shl cx, 2 mov dx, PORT_DATA xor bx, bx .readw: ; 循环读取程序,将其加载至ds:0处 in ax, dx mov [bx], ax add bx, 2 loop .readw ret ; func calc_seg_phy_addr_16 ; <- [dx:ax]:段32位绝对汇编地址 ; -> ax:16位物理段地址 calc_seg_phy_addr_16: ; 这里的20位起始加载地址使用32位保存的 ; 因此可以通过带进位的加法得到段起始位置的实际的20位物理地址 add ax, [cs:ADDR_20_LOAD_START] adc dx, [cs:ADDR_20_LOAD_START+2] ; 现在将绝对的20位物理地址右移4位就能得到16位的物理段地址了 ; 必须dx和ax同时右移 ; 方法是ax右移4位即可 ; 而dx采用循环右移4位,应该移到ax高4位的那4位重新回到dx高4位 ; 然后用位掩码去的dx高4位 ; 再利用or将这4位写入ax的高4位即可 shr ax, 4 ; 低16位右移4位 ror dx, 4 and dx, 0xF000 ; 位掩 or ax, dx ; 写入 ret times 510-($-$$) db 0 dw 0xAA55