アセンブリ言語 - シンプルなマスター ブート レコード (MBR) ブート ユーザー プログラムを実装します。

この記事は、Li Zhong 氏の「X86 アセンブリ言語: リアル モードからプロテクト モードへ」を参考にしています。

序文

ユーザー プログラムをガイドする単純なマスター ブート レコードを手動で実装します。これは理解に役立ちます。

  • メイン ブートローダーのワークフロー
  • アセンブリ コード レベルで関数を呼び出す方法 (関数呼び出しの原則)
  • アセンブリコードレベルでハードディスクを読み書きする方法(CPUと周辺機器の相互作用)

内容は多くはありませんが、様々な知識を総合的に応用できます。

マスター ブート レコード

以下内容はwikiより引用

マスター ブート レコード (マスター ブート レコード、略称: MBR) は、マスター ブート セクターとも呼ばれ、ハード ディスクにアクセスするためにコンピューターの電源を入れたときに読み取る必要がある最初のセクターです。 is (シリンダー、ヘッド、セクター) = (0, 0, 1)

システム起動のプロセス:

  1. システムハードウェア (メモリを含む) をチェックする BIOS パワーオンセルフテスト
  2. マスター ブート レコードを読み取ります。BIOS は、ディスクの最初のセクター (つまり、MBR セクター) をメモリ アドレス 0000:7C00H に読み取ります。
  3. MBR のブート コードに従ってブートローダーを開始します。
  4. ブートローダーはオペレーティング システムのカーネルをロードします

マスター ブート レコードの変更

BIOS がセルフテスト後に指定したプログラムを実行するように、独自のマシン命令をメイン ブート セクタに書き込むことができます。このような:

サンプル アセンブリ コード demo.asm:

    mov ax, 0x1234
    jmp $

;--------------------------------------------------------------
    ; $  表示当前行的汇编地址
    ; $$ 表示第一行的汇编地址
    ; $-$$得到前面代码占用的字节数
    times (512-2-($-$$)) db 0 ;主引导扇区总长度必须512字节,用0占位
    db 0x55, 0xaa           ;主引导扇区最后两个字节必须是0x55aa

nasm でコンパイルして、demo.bin ファイルを取得します。

Li Zhong 先生の仮想ハード ディスク書き込みツール Vhd ライターを使用して、demo.bin ファイルの内容を Vhd 仮想ハード ディスクのメイン ブート セクターに書き込みます。

デバッグのために bochsdbg 仮想マシンを起動します。これは、マスター ブート レコードが物理アドレス 0x7c00 に読み込まれるためb 0x7c00ですc

ここに画像の説明を挿入

コマンドを使用してsシングル ステップ デバッグを行い、rコマンドを使用してレジスタの値を観察する

ここに画像の説明を挿入

マスター ブート レコード ブート ユーザー プログラム

メインのブート プログラムを自分で作成すると、ユーザー プログラムなどの他のコンテンツをロードする (制御を放棄する) ことができます。ユーザー プログラムをロードするには、メイン ブートローダに必要な情報がいくつかあります。ユーザー プログラムの全長、プログラムの実行エントリなどです。したがって、コンピューターの世界のさまざまなプロトコル ヘッダーと同様に、ユーザー プログラムの先頭にヘッダー セクションを追加する必要があります。

ヘッダー セクションには、次のものが含まれます。

  1. プログラム全体の長さは 4B のダブルワードを占めます。
  2. エントリ ポイント セグメント内のオフセット アドレス。1 ワード、つまり 2B を占めます。
  3. エントリ ポイント セグメントのアセンブリ アドレスは、4B であるダブル ワードを占有します。
  4. 1 ワード、つまり 2B を占める再配置エントリの数
  5. 対応する n エントリ。各エントリはダブル ワード、つまり 4B を占有します。

ここに画像の説明を挿入

ユーザー プログラム コードのサンプル コード:

;------------------------------------------------------
SECTION head_section vstart=0
                                                            ;用户程序头部段
    app_length: dd app_end
    entry_offset: dw start                                  ;用户程序入口点段内偏移地址
    entry_sec_addr: dd section.code_section.start           ;用户程序入口点段汇编地址
    realloc_tbl_num: dw (head_sec_end-code_sec_base_addr)/4 ;每项4字节
    code_sec_base_addr: dd section.code_section.start       ;代码段汇编地址
    data_sec_base_addr: dd section.data_section.start       ;数据段汇编地址
    stack_sec_base_addr: dd section.stack_section.start     ;栈段汇编地址
    head_sec_end:
;------------------------------------------------------
SECTION code_section align=16 vstart=0                      ;代码段

put_string:
                                                            ;input: ds:si 源字符串起始地址
                                                            ;        es:di 目标位置起始地址
    push ax                                                 ;保存寄存器
@1:
    mov al,[ds:si]
    cmp al,0                                                ;字符串结尾
    je end_loop
    mov ah,0x0f                                             ;字体格式
    mov [es:di],ax
    add di,2
    inc si
    jmp @1
end_loop:
    pop ax                                                  ;恢复寄存器
    ret

start:
                                                            ;设置用户程序的栈空间
    xor ax,ax
    mov sp,ax
    xor ax,[stack_sec_base_addr]
    mov ss,ax
                                                            ;程序主体部分,显示数据段文本
    mov ax,[data_sec_base_addr]
    mov ds,ax
    xor si,si
    mov ax,0xb800
    mov es,ax
    xor di,di
    call put_string

;------------------------------------------------------
SECTION data_section align=16 vstart=0                      ;数据段
    db 'Hello World! ___Written by cy.',0

;------------------------------------------------------
SECTION stack_section align=16 vstart=0                     ;栈段
    resb 256

;------------------------------------------------------
SECTION end_section
    app_end:

ヘッダー セグメントを使用して、ユーザー プログラムをメモリに読み込み、制御を与え (セグメント アドレス レジスタを変更)、ロード作業を完了します。簡単にするために、次の規則を作成します。

  1. ユーザー プログラムは、ディスクの 100 番目のセクターに順番に格納されます。
  2. メイン ブートローダは、常にユーザー プログラムを物理メモリの 0x10000 にロードします。

ハードディスクの読み書き

CPU と周辺 I/O デバイスは、I/O インターフェースを使用してデータを送信する必要があります. I/O インターフェースには、ポートと呼ばれるいくつかのレジスタが統合されています. 異なるポートには、コマンドの送信やデータの送信などの異なる機能があります. CPU は、ポートからデータを読み書きし、周辺機器と対話します。inおよびコマンドを使用して、outポートからデータを読み書きします

in命令:

    ;一般使用ax和dx寄存器
    ;选择ax还是al取决于端口的位宽
    mov dx,0xc30    ;从0x3c0端口读取数据
    in ax,dx
    ;in al,dx

out命令:

    mov dx,0xc30    
    out dx,ax       ;往0x3c0端口写入数据
    ;out dx,al

私たちのコンピュータでは、ハードディスク インターフェイスには 0x1f0 から 0x1f7 まで順番に 8 つのポートがあり、読み書きするポート番号は dx に格納され、データは ax/al に格納されます。

ハードディスクの読み書きの最小単位はセクタなので、ハードディスクは典型的なブロックデバイスです。ハードディスクの読み取りと書き込みには、次の 2 つのモードがあります。

  1. CHS モード (シリンダー ヘッド セクター) シリンダー ヘッド セクター トリプレット
  2. LBA モード (Logic Block Address) 論理セクタ番号

ハードディスクを読み取るための基本的な手順:

  1. 読み取るセクタ数を 0x1f2 ポートに書き込みます
    mov dx,0x1f2
    mov al,0x1
    out dx,al
  1. 選択したハードディスク (マスター/スレーブ) の 32 ビット データ、読み取り/書き込みモード (CHS/LBA)、およびセクター番号をポート 0x1f3、0x1f4、0x1f5、および 0x1f6 に書き込みます。
1010 0000 00000000 00000000 00000000
共写入32位,低28位表示扇区号
第29和31位固定为1
第28位为0表示读写主硬盘,为1表示读写从硬盘
第30位为0表示CHS读写模式,为1表示LBA读写模式在·
    mov dx,0x1f2
    mov al,0x2
    out dx,al;0000 0002

    inc dx 
    xor al,al
    out dx,al;0000 0000

    inc dx 
    out dx,al;0000 0000
    inc dx 

    inc dx 
    mov al,0xe0
    out dx,al;1110 0000
    inc dx 
    ;11100000 00000000 00000000 00000002
    ;选择LBA读写模式,读写主硬盘的2号逻辑扇区
  1. ポート 0x1f7 に 0x20 コマンドを書き込み、ハードディスクの読み取りを要求します
    mov dx,0x1f7
    mov al,0x20
    out dx,al
  1. ハードディスクの読み取り準備が整っているかどうかを確認する

ポート 0x1f7 から 1B データを読み取る

    mov dx,0x1f7
    in al,dx
00000000
共读取8位
第7位为1表示硬盘正忙
第3位为1表示硬盘已经可以交换数据

ポーリングは、データが読み取れるまで待機します

    mov dx,0x1f7
.waits:
    in dx,al
    and al,0x88     ;取出第3位和第7位
    cmp al,0x08
    jnz .waits
  1. ハードディスクの読み取り準備が整ったら、データ ポート 0x1f0 からデータを読み取ります。
    ;假定DS:BX已经指向数据要存放的逻辑地址
    mov dx,0x1f0
    mov cx,256      ;512B=256word
.readlo:
    in dx,ax
    mov [bx],ax  
    add bx,2
    loop .readlo

ユーザープログラムをロード

ハードディスクの読み取り方法を理解したら、次の目標はユーザー プログラム全体をロードすることです。最初に、ユーザー プログラムのヘッダー セクションをメモリにロードし、ヘッダー セクション内の情報を解析する必要があります。

以前に次の契約を締結しました。

  1. ユーザー プログラムは、ディスクの 100 番目のセクターに順番に格納されます。
  2. メイン ブートローダは、常にユーザー プログラムを物理メモリの 0x10000 にロードします。

したがって、論理セクター番号を定数として定義し、後でアクセスする必要があるため、物理アドレスは 4 バイトのメモリに格納されます。

    disk_lba_address equ 100        ;定义常数,约定的逻辑扇区号
    phy_base_address : dd 0x10000   ;约定的用户程序要存放的物理地址

次に、最初にセクター 100 (ヘッダー セグメントを含む) をメモリに読み取り、ヘッダー セグメントの最初のダブル ワード (プログラムの全長) に従って残りのすべてのセクターを読み取ります。

具体的な手順は次のとおりです。

  1. セクター 100 (ヘッダーを含む) を読み取り、プログラムの全長を解析します。1セクタ512Bは全長/512がセクタ数であり、割り切れない場合はさらに1セクタ必要となる。
    mov ax,[cs:phy_base_address]
    mov dx,[cs:phy_base_address+2]
    mov bx,16
    div bx                          ;左移四位得到逻辑段地址
    mov ds,ax                       ;令ds指向将要载入的逻辑段地址
    xor bx,bx                       ;对应read_hard_disk的输入

    mov si,disk_lba_address         ;对应read_hard_disk的输入
    xor di,di

    call read_hard_disk             ;读出头部段

    mov ax,[0x00]                   ;dx:ax = 程序总长度
    mov dx,[0x02]
    mov bx,512
    div bx                          ;得到用户程序占用的扇区数
    cmp dx,0
    jz @1                           ;占用整数个扇区
    inc ax                          ;不是刚好占用整数个扇区,需要继续读取一个扇区才能读完
@1:
    dec ax                          ;已经读了一个扇区,减去
  1. ループを使用して残りのセクターを読み取る
    mov cx,ax                       ;需要读取的扇区数量
@2:
    mov ax,ds;
    add ax,0x20                     ;0x20<<4=0x200=512B,得到下一个512B的逻辑段地址
    mov ds,ax
    xor bx,bx
    inc si                          ;读下一个逻辑扇区
    call read_hard_disk
    loop @2                         ;循环读取剩余扇区

ユーザープログラムの移動

ユーザー プログラム全体をメモリに正常にロードしたので、次のタスクはユーザー プログラムに制御を渡すことです.明らかに、cs:ip レジスタをユーザー プログラム エントリ ポイントの論理アドレスに変更する必要があります。

計算方法は?ヘッダー セクションには、次の 2 つの情報があります。

  1. ユーザー プログラムのエントリ ポイント セグメントのアセンブリ アドレス。cs、つまりこの値からセグメントのベースアドレスの値を計算するのは実際には非常に簡単です.ロードされたユーザープログラムの開始物理アドレスはphy_base_addressであり、2つを加算するだけで実際の物理アドレスになります.ユーザプログラムのエントリポイントのセグメント セグメントベースアドレス cs の値を取得するために 4 ビットをシフトします

  2. ユーザープログラムのエントリポイントのセグメント内のオフセットアドレス。ip の値に直接対応します

結果を計算した後、後で使用できるように、ヘッダー セグメント内のユーザー プログラムのエントリ ポイント セグメントのアセンブリ アドレスに結果を直接バックフィルします。ip の値がヘッダー セグメントのオフセット アドレス [0x04] に格納され、cs の値がヘッダー セグメントのオフセット アドレス [0x06] に格納されているのは "偶然" です. コマンドを直接使用することができますjmp farJump実行のためのユーザープログラムのエントリポイントに。

    jmp far [0x04]

ユーザープログラムには複数の SECTION があり、それらのセグメントのベースアドレスが分からないと使いづらいです。このとき、再配置エントリが役に立ちます. 上記の計算プロセスに従って、再配置エントリ内のすべての SECTION のセグメント ベース アドレスを計算し、対応するメモリ空間に 1 つずつ埋め戻します. SECTION では、セグメント ベース アドレスを対応する値に設定するだけで済みます。

                                    ;计算用户程序入口点逻辑段地址
    mov ax,[0x06]
    mov dx,[0x08]
    call cal_segment_base
    mov [0x06],ax                   ;将计算得到的用户程序入口点段基地址回填到头部段[0x06]处

    mov cx,[0x0a]                   ;重定位表项数
    mov bx,[0x0c]                   ;第一项
@3:
    mov ax,[bx]
    mov dx,[bx+0x02]
    call cal_segment_base
    mov [bx],ax                     ;将计算得到的段基地址回填到重定位表项处
    add bx,4
    loop @3

メイン ブートローダが制御を取り戻す [拡張]

ユーザープログラム実行後は、制御権を上位に戻す必要があり、そうしないと1つのプログラムしか実行できず、原理は非常に単純で、各レジスタの値を保存して復元するだけですが、cs/ipの場合は/ss/sp register 保存と復元には細心の注意が必要.たとえば、cs は変更されていますが、ip は変更されていません.このとき、他のコマンドを直接指します (cs:ip が変更されています)。

さらに、ip レジスタの値を直接読み書きすることはできません。call 命令を覚えていますか? その原則は、ip レジスタの値をスタックにプッシュすることです。そのため、次のように call 命令を介して間接的に ip レジスタの値を取得できます。

    push cs                         
    call get_ip                     ;等价于push ip
get_ip:
    pop ax                          ;得到ip的值
    push ax

シームレスに見えますが、この時点での IP は命令の開始アドレスを指しているため、この方法で保存された IP は間違っています。pop ax明らかに、必要なのはpush ax次の命令の開始アドレスです。このような問題はまだまだ多く、実践の過程で遭遇するでしょう。

メイン ブートローダは制御コードを取得します (メイン ブートローダ部分):

    push ax                         ;保存环境
    push bx
    push cx
    push dx
    push ds
    push es
    push di
    push si

    push cs                         ;【拓展内容】用户程序执行完之后将控制权还给主引导程序
    call get_ip                     ;等价于push ip
get_ip:
    pop ax                          ;得到ip的值
    add ax,delta_2-get_ip
    push ax

    jmp far [0x04]                  ;==执行用户程序==
delta_2:                            ;【拓展内容】主引导程序拿回控制权之后应该从此处开始执行
    pop si
    pop di
    pop es
    pop ds
    pop dx
    pop cx
    pop bx
    pop ax                         ;恢复环境

メイン ブート プログラムは、制御コード (ユーザー プログラム部分) を取得します。

mov si,ss                                               ;【拓展内容】记录主引导程序的栈空间
    mov di,sp

                                                            ;设置用户程序的栈空间
    xor ax,ax
    mov sp,ax
    xor ax,[stack_sec_base_addr]
    mov ss,ax

    push di
    push si                                                 ;【拓展内容】记录主引导程序的栈空间

    ;用户程序代码部分
    ;..............
    ;..............
    ;用户程序代码部分


    pop si ;【拓展内容】用户程序执行完之后将控制权还给主引导程序
    pop di
    mov ss,si
    mov sp,di
    retf ;= pop ip & pop cs

しかし、私は常々、制御権が奇妙なものを引き渡すためにユーザー プログラムの積極的な協力を必要としていると感じています...

確認

メイン ブートローダー my_mbr.asm の完全なアセンブリ コード:

    disk_lba_address equ 100        ;定义常数,约定的逻辑扇区号
SECTION mbr vstart=0x7c00
                                    ;初始化栈寄存器
    xor ax,ax
    mov ss,ax
    mov sp,ax

    mov ax,[cs:phy_base_address]
    mov dx,[cs:phy_base_address+2]
    mov bx,16
    div bx                          ;左移四位得到逻辑段地址
    mov ds,ax                       ;令ds指向将要载入的逻辑段地址

    xor di,di
    mov si,disk_lba_address         ;对应read_hard_disk的输入
    xor bx,bx                       ;对应read_hard_disk的输入
    call read_hard_disk             ;读出头部段

    mov ax,[0x00]                   ;dx:ax = 程序总长度
    mov dx,[0x02]
    mov bx,512
    div bx                          ;得到用户程序占用的扇区数
    cmp dx,0
    jz @1                           ;占用整数个扇区
    inc ax                          ;不是刚好占用整数个扇区,需要继续读取一个扇区才能读完
@1:
    dec ax                          ;已经读了一个扇区,减去
    cmp ax,0
    jz direct                       ;无扇区需要读了

    push ds                         ;保存用户程序的逻辑段地址

    mov cx,ax;
@2:
    mov ax,ds
    add ax,0x20                     ;0x20<<4=0x200=512B,得到下一个512B的逻辑段地址
    mov ds,ax
    xor bx,bx
    inc si                          ;读下一个逻辑扇区
    call read_hard_disk
    loop @2                         ;循环读取剩余扇区

    pop ds                          ;恢复用户程序的逻辑段地址

direct:
                                    ;计算用户程序入口点逻辑段地址
    mov ax,[0x06]
    mov dx,[0x08]
    call cal_segment_base
    mov [0x06],ax                   ;将计算得到的用户程序入口点段基地址回填到头部段[0x06]处

    mov cx,[0x0a]                   ;重定位表项数
    mov bx,0x0c                     ;第一项的地址
@3:
    mov ax,[bx]
    mov dx,[bx+0x02]
    call cal_segment_base
    mov [bx],ax                     ;将计算得到的段基地址回填到重定位表项处
    add bx,4
    loop @3

    push ax                         ;保存环境
    push bx
    push cx
    push dx
    push ds
    push es
    push di
    push si

    push cs                         ;【拓展内容】用户程序执行完之后将控制权还给主引导程序
    call get_ip                     ;等价于push ip
get_ip:
    pop ax                          ;得到ip的值
    add ax,delta_2-get_ip
    push ax

    jmp far [0x04]
delta_2:                            ;【拓展内容】主引导程序拿回控制权之后应该从此处开始执行
    pop si
    pop di
    pop es
    pop ds
    pop dx
    pop cx
    pop bx
    pop ax                         ;恢复环境

    jmp $
;------------------------------------------------------------------------
cal_segment_base:
                                    ;input: dx:ax = 段汇编地址
                                    ;output:ax = 段基地址
    push dx

    add ax,[cs:phy_base_address]
    adc dx,[cs:phy_base_address+2]  ;带进位加法
                                    ;ds:ax=段逻辑地址,要取出第4-19位
    shr ax,4
    ror dx,4
    or ax,dx

    pop dx

    ret
;------------------------------------------------------------------------
read_hard_disk:
                                    ;从硬盘读入一个扇区
                                    ;input: DI:SI = 扇区号
                                    ;       DS:BX = 物理内存
    push di                         ;保存相关寄存器
    push si
    push ds
    push bx
    push ax
    push dx
    push cx

    mov dx,0x1f2
    mov al,1
    out dx,al

    inc dx
    mov ax,si                       ;写入扇区号、读写模式、硬盘
    out dx,al

    inc dx
    shr ax,8
    out dx,al

    inc dx
    mov ax,di
    out dx,al

    inc dx
    mov al,0xe0
    out dx,al

    inc dx                          ;发送请求读命令
    mov al,0x20
    out dx,al

.waits:                             ;轮询硬盘是否可读
    in al,dx
    and al,0x88
    cmp al,0x08
    jnz .waits

    mov dx,0x1f0                    ;开始读硬盘
    mov cx,256
.readlo:
    in ax,dx
    mov [bx],ax
    add bx,2
    loop .readlo

    pop cx
    pop dx
    pop ax
    pop bx
    pop ds
    pop si
    pop di                          ;恢复相关寄存器

    ret
;------------------------------------------------------------------------
    phy_base_address : dd 0x10000   ;约定的用户程序要存放的物理地址
    times (512-2-($-$$)) db 0       ;主引导扇区总长度必须512字节,用0占位
    db 0x55, 0xaa                   ;主引导扇区最后两个字节必须是0x55aa

ユーザー プログラムの完全なアセンブリ コード my_app.asm:

;------------------------------------------------------
SECTION head_section vstart=0
                                                            ;用户程序头部段
    app_length: dd app_end
    entry_offset: dw start                                  ;用户程序入口点段内偏移地址
    entry_sec_addr: dd section.code_section.start           ;用户程序入口点段汇编地址
    realloc_tbl_num: dw (head_sec_end-code_sec_base_addr)/4 ;每项4字节
    code_sec_base_addr: dd section.code_section.start       ;代码段汇编地址
    data_sec_base_addr: dd section.data_section.start       ;数据段汇编地址
    stack_sec_base_addr: dd section.stack_section.start     ;栈段汇编地址
    head_sec_end:
;------------------------------------------------------
SECTION code_section align=16 vstart=0                      ;代码段
put_string:
                                                            ;input: ds:si 源字符串起始地址
                                                            ;        es:di 目标位置起始地址
    push ax                                                 ;保存寄存器
@1:
    mov al,[ds:si]
    cmp al,0                                                ;字符串结尾
    je end_loop
    mov ah,0x0f                                             ;字体格式
    mov [es:di],ax
    add di,2
    inc si
    jmp @1
end_loop:
    pop ax                                                  ;恢复寄存器
    ret

start:
    mov si,ss                                               ;【拓展内容】记录主引导程序的栈空间
    mov di,sp
                                                            ;设置用户程序的栈空间
    xor ax,ax
    mov sp,ax
    xor ax,[stack_sec_base_addr]
    mov ss,ax

    push di
    push si                                                 ;【拓展内容】记录主引导程序的栈空间

    mov ax,[data_sec_base_addr]                             ;程序主体部分,显示数据段文本
    mov ds,ax
    xor si,si
    mov ax,0xb800
    mov es,ax
    xor di,di
    call put_string

    pop si                                                  ;【拓展内容】用户程序执行完之后将控制权还给主引导程序
    pop di
    mov ss,si
    mov sp,di
    retf                                                    ;= pop ip & pop cs

;------------------------------------------------------
SECTION data_section align=16 vstart=0                      ;数据段
    db 'Hello World! ___Written by cy.',0

;------------------------------------------------------
SECTION stack_section align=16 vstart=0                     ;用户栈段
    resb 256                                                ;为栈空间预留256B

;------------------------------------------------------
SECTION end_section
    app_end:

それらを個別にコンパイルして my_mbr.asm と my_app.asm を生成し、Li Zhong 氏の仮想ハードディスク書き込みツールを使用して仮想ハードディスクに書き込みます。

ここに画像の説明を挿入

仮想ボックス仮想マシンを実行すると、予期したテキスト コンテンツが正常に表示されることがわかります。これは、メインのブート プログラムがユーザー プログラムを正しく誘導したことを証明しています。

ここに画像の説明を挿入

また、Bochsdbg を使用して、プログラムの実行プロセス、特に制御権が前後に切り替えられたときの各レジスタおよびスタック空間の状態変化をデバッグおよび観察することもできます。

まとめ

オペレーティング システムを起動する実際のメイン ブート プログラムはそれほど単純ではありませんが、この実験を書き留めておけば、関連するプロセスの概要を理解することもできます。紙で話すよりも、実際には細かい問題が多く、1日で終わると思っていた実験が、3、4日かかってしまい、今までの知識を十分に理解できていないことが問題になっています。使用中で。テクノロジーを学び、さらに練習することによってのみ、ギャップをチェックし、ギャップを埋め、記憶を深める効果を得ることができます.

おすすめ

転載: blog.csdn.net/hesorchen/article/details/128847119