[Intel汇编-NASM]程序的加载以及硬盘访问

本文详细介绍了使用NASM汇编语言进行程序设计的方法,包括用户程序结构、段定义与属性、重定位表功能,以及如何实现加载器和应用程序头部来模拟操作系统的工作。通过具体实例,展示了程序加载过程、段的对齐方式、重定位表的作用,以及如何在程序中处理字符串输出和屏幕刷新。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

 

转载地址:https://blog.youkuaiyun.com/lirx_tech/article/details/42680405

1. 用户程序的结构:

   1) 一般源程序都以段的形式进行组织,这样可以使逻辑更加清晰,在NASM中使用section关键字定义一个段,形式是:section 段名

   2) 程序可以用段名来引用段,但是NASM编译器并不关心段的具体用途,或者说是根本不知道段的用途(代码段还是数据段等),同时NASM对段的数量也没有任何限制,如果代码中没有定一段则整个程序自成一段;

   3) 定义段的同时可以定义段的一些属性,比如可以使用关键字align来定义段的对其方式,比如:section code align=16,这样就表示该段的其实地址是以16字节对齐的,即段的起始位置必须是16的整数倍;

!注意:该属性只影响段的起始位置的对其但不影响段的末尾对齐方式,事实上NASM也无法判断一个段的末尾,只有当遇到一个新的段的定义的时候才能知道前一个段结束了;

   4) 段的起始位置:就是该段中第一行指令的地址(指令可以是普通指令也当然可以是数据定义指令db、dw等等;

   5) 和加载程序之间的约定——应用程序头部Header:

       i. 在有操作系统的环境下编译完一个程序之后编译器会隐式地、默认地添加一个应用程序头部(位置处于程序的起始位置处);

       ii. 头部包含着加载器该如何加载该程序的一些信息,或者说是加载器和程序之间的某些约定或规范,而加载器通过这些信息将程序正确地加载进内存中;

       iii. 在有操作系统的环境下,应用程序头部和加载器都是操作系统负责的,但是在这里我们模拟一下这个操作系统的工作,即手写完成加载器和程序头部来模拟操作系统的这两个功能;

 

2. 用户程序和加载器的简单实现:

用户程序:默认程序已经正确加载到了内存的空闲位置,并且从定义的标号start处该是执行程序,作用是将两个数据段中的字符串打印到屏幕上,并且处理回车和换行两个控制符

!注意用户程序头部的定义,里面包含了程序大小、程序开始执行的入口、程序中各个段的起始位置等信息;

!其中重定位表的作用就是:编译后各项保存的是各段在源程序中的绝对汇编地址,经加载程序加载后就将各项修改成在物理内存中实际的地址,因此称为重定位表;

app.nas,编译后生成app.bin

; 应用程序头
; 用于提供加载器相关加载信息
; 是应用程序规范的一部分
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
!resb指令就是reserve byte的缩写,即保留一定数量的字节的意思,因此必然还有resw、resd,表示保留一定数量的字和双字的意思,既然是保留就不对定义的数据进行初始化,因此该指令就是定义一段连续的未初始化的数据;
!关于汇编地址的介绍以及硬盘访问的端口都在源代码中详细介绍,所以这里就不累述了;

 

加载器的实现:作为主引导扇区程序

loader.nas,编译后生成loader.mbr

; 主引导扇区程序作为应用程序加载器

; 虽然就只有一个段但是也需要定义
; 最主要是为了使用段属性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                        ; 留给用户程序时使ds和es都指向加载位置首部

            ; 先读取一个扇区,即应用程序头所在的扇区
            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

            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, 111_0_0000B            ; ah保存24~27位,al中保存扇区寻址模式以及类型选择符
                                            ; 其中最高位的111表示采用28位逻辑扇区号模式
                                            ; 后面一位的0表示是主盘,1表示从盘,即盘片类型选择符
            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, 8                    ; 一个扇区512B,即256个字
                                            ; cx记录剩余多少字未读完,而cx原本存放剩余扇区数
                                            ; 因此cx要乘以256,即左移8位
            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
!注意:程序开始处的一大堆宏定义就是用于和应用程序头部进行通讯的,即那些表项等在头部中的偏移位置,这样就可以轻松访问这些表项了;


3. 查看程序运行结果:将loader.mbr写进虚拟硬盘的0号扇区(总共一个扇区),将app.bin写进100号扇区(总共两个扇区),然后将虚拟盘作为虚拟机的启动盘放在VirtualBox中运行中即可;


————————————————
版权声明:本文为优快云博主「Lirx_Tech」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.youkuaiyun.com/lirx_tech/article/details/42680405

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值