「手探りでCUI OS作成に挑む」連載
この記事は「手探りでCUI OS作成に挑む」連載の一部です。
全体の目次は「手探りでCUI OS作成に挑む」連載目次を御覧下さい。
初めに
この記事では以下のようにFAT12のファイル一覧を表示する機能を実装します。
TEST.TXT 12
TEST2.TXT 45
段階的に実装するため、今回はルートディレクトリ上のファイル及びディレクトリの表示のみに止め、ディレクトリ内部の構造は表示できません。
開発中のOSコードにいきなり追加すると可読性が下がるため、一旦個別に開発し、その後結合するつもりです。
予備知識
フロッピーはシリンダ、ヘッダ、セクタを指定して読み書きを行いますが、ここに関しては割愛させて下さい。
詳細については、インターネット上に多くの解説図や資料がありますので、そちらも参考にしてみてください。
FAT12とは
特徴と用途
FAT12は主にフロッピーに使われていた形式です。
現在でもUSB等で拡張形のFAT32が広く使われています。
なぜ必要なのか
簡単にいうとフロッピー上に保存されたファイルの管理の仕組みです。
元々境目のない土地を便宜上地番で区切って、登記簿に登録することで個人や法人が土地を所有できる仕組みに似ています。
全てのデータは0と1の羅列です。
モールス信号のようなもので読み出す時に法則を決めて都合よく解釈しているだけです。
フロッピー、HDDなどの記憶装置は0と1の羅列しか記憶できず境目もありません。
写真や文章などのファイルを保存する場合、当然どこかに保存される訳ですが、それを見つけられないと再生はできません。
しかも一つのデータは連続した場所にずらっと保存される訳ではなく幾つもの欠片に分けて空いている場所へ保存されます。
具体的に言うと0011010100101010100101010101......0010101010001010101111010101001
のように連続した0と1の羅列の中から欲しいデータを探しださなければなりません。
そうすると、管理台帳のようなものにファイルの一覧、そして各ファイルの欠片の分布表が記載された番地分布表のようなものが必要です。
便宜上、固定の長さ(今回は512バイト、即ち512×8個の0または1)毎に番地を振ります。
そして管理台帳には写真1、音楽1、写真2のデータが登録されているとします。
番地分布表には写真1の欠片は○番地、○番地、......、音楽1は、○番地、○番地、......、等のようにどこに分散しているかが記載されています。
この台帳及び番地分布表を参照すると各地に分散していても目的のファイルを復元することができます。
幾つもの欠片に分けて保存するのは複雑さが増すだけかとも思われるかもしれませんが、こうしないとまずいことが起きます。
例えば1GB分の細かいデータを保存して、500MB分消したとします。
しかし空いた領域は連続しているとは限らず小さな隙間が大量にある状態となり、せっかく空いた領域に大きなデータを保存できなくなってしまいます。
またある程度まとまった区域毎に番地を振らないと番地分布表に登録ができません。
そしてどんなメーカーの製品でもフロッピーを読み込めるように、管理台帳、番地分布表の置かれる場所、形式は全て決まっています。その1つがFAT12です。
各社が独自の規格で開発してしまったら互換性が全くなくなってしまいます。
仕組み
FAT12形式でフロッピーを初期化するとFAT表、目録が作られます。
上で説明した番地分布表がFAT表、目録が管理台帳に当たります。
今回はファイル一覧しか実装しないため、目録の説明のみに止めます。
ファイルの中身を再生する時に初めてFAT表が必要となります。
FAT12で初期化(USBメモリをフォーマットするのと同じです)すると目録はフロッピー上の19セクタ目から32セクタ目までの14セクタに生成されます。
目録の構造
FAT表は今回使用しないため割愛します。
1項目=32ビット、全224項目
オフセット | サイズ (バイト) | 内容 | 説明 |
---|---|---|---|
0x00 | 8 | ファイル名(8文字) | 満たない場合は空白埋め。大文字英数字。 |
0x08 | 3 | 拡張子(3文字) | 満たない場合は空白埋め。 |
0x0B | 1 | 属性 | ファイル属性ビット |
0x0C | 1 | 予約 | 常に0、使用不可 |
0x0D | 1 | 作成時間(10ミリ秒単位) | 通常は無視される |
0x0E | 2 | 作成時間 | 16bit DOS形式時間 |
0x10 | 2 | 作成日 | 16bit DOS形式日付 |
0x12 | 2 | 最終アクセス日 | ディレクトリでは重要(更新用) |
0x14 | 2 | 高位クラスタ番号(FAT32用) | FAT12では無視してよい |
0x16 | 2 | 最終更新時刻 | 通常の更新時刻 |
0x18 | 2 | 最終更新日 | 通常の更新日付 |
0x1A | 2 | ファイルの開始クラスタ番号 | FATのチェーン開始位置 |
0x1C | 4 | ファイルサイズ(バイト) | ディレクトリなら0、ファイルサイズ |
実装
完全なコード
boot.asm
org 0x7C00
bits 16
jmp short start
nop
; FAT12 BIOSパラメータブロック (BPB)
OEMLabel db "MYFAT12 " ; 8バイト、スペースで埋める必要あり
BytesPerSector dw 512 ; 1セクタあたりのバイト数
SectorsPerCluster db 1 ; クラスタあたりのセクタ数
ReservedSectors dw 1 ; 予約セクタ数
NumberOfFATs db 2 ; FATテーブルの数
RootDirEntries dw 224 ; ルートディレクトリエントリ数(224エントリ)
TotalSectors dw 2880 ; 総セクタ数(2880セクタ=1.44MB)
MediaDescriptor db 0xF0 ; メディア記述子(0xF0=3.5インチフロッピー)
SectorsPerFAT dw 9 ; FATテーブルあたりのセクタ数(9セクタ)
SectorsPerTrack dw 18 ; 1トラックあたりのセクタ数
HeadsPerCylinder dw 2 ; 1シリンダあたりのヘッド数
HiddenSectors dd 0 ; 隠しセクタ数
LargeSectors dd 0 ; LBA用総セクタ数(未使用)
DriveNumber db 0 ; ドライブ番号(0=フロッピー)
Reserved db 0 ; 予約領域
ExtendedBootSig db 0x29 ; 拡張ブートシグネチャ
VolumeSerial dd 0x12345678 ; ボリュームシリアル(任意の値)
VolumeLabel db "MYVOLUME " ; 11バイトのボリュームラベル
FileSystem db "FAT12 " ; 8バイトのファイルシステムタイプ
start:
xor ax, ax ; AXレジスタをゼロ初期化
mov ds, ax ; データセグメントを0に設定
mov es, ax ; エクストラセグメントを0に設定
mov ss, ax ; スタックセグメントを0に設定
mov sp, 0x7C00 ; スタックポインタをブートローダ位置に設定
; CHSパラメータを使用してLBA 100を直接読み込み
mov ah, 0x02 ; BIOS ディスク読み込み機能
mov al, 1 ; 読み込むセクタ数(1セクタ)
mov ch, 2 ; シリンダ番号=2(LBA 100に対応する計算値)
mov cl, 11 ; セクタ番号=11(LBA 100に対応する計算値)
mov dh, 1 ; ヘッド番号=1(LBA 100に対応する計算値)
mov dl, 0x00 ; ドライブ番号(0x00=フロッピードライブA)
mov bx, 0x7E00 ; ES:BX = 読み込み先アドレス 0x0000:0x7E00
int 0x13 ; ディスクサービス割り込み
jc disk_error ; エラー発生時はdisk_errorへジャンプ
jmp 0x0000:0x7E00 ; 読み込んだプログラムへジャンプ
disk_error:
jmp $
times 510-($-$$) db 0
dw 0xAA55
load.asm
org 0x7E00
;mov si, debug_msg
;call print_str
call load_root_directory
call list_root_directory
mov si, debug_msg_hlt
call print_str
cli
hlt
; --- 目録をES:BXへ読み込む ---
load_root_directory:
; ES:BXの値を1000:0000に初期化する
mov ax, 0x1000 ; ES = 0x1000
mov es, ax
xor bx, bx ; ES:BX = 1000:0000
mov si, 0 ; sector index (0 ~ 13)
.load_sector_loop:
mov ax, 19 ; 19セクタ目から読み込む
add ax, si
push si
call read_sect_lba ; 1セクタ分ES:BXへ読み込む
pop si
add bx, 512 ; 512バイト足すと次のES:BXがセクタを指す(1セクタ=512バイト)
inc si ; 読み込みセクタ数+1
cmp si, 14 ; 14セクタ分読み込んだか調べる
jl .load_sector_loop ; 14セクタに達していない場合は次のセクタを読み込む
ret
; --- 目録の内容を画面に表示する ---
; 入力: 目録は既に0x1000:0000へ読み込まれている
; 出力: 画面上にファイル及びディレクトリを表示
list_root_directory:
mov si, 0 ; 現在読み込み中の項目(目録の項目は0~223)
mov di, 0 ; 目録の相対アドレス(オフセット)(0 ~ 32*224)
.next_entry:
; ES:DI 現在の目録項目
; 項目1バイト目が0x00であればその下には他のファイル無し,0xE5は削除済み
mov al, byte [es:di]
cmp al, 0x00
je .done ; 最後のファイル、読み込み終了
cmp al, 0xE5
je .skip_entry ; 削除済み、跳ばす
; 各項目11バイト目の属性を確認
mov al, byte [es:di+11]
test al, 0x08
jnz .skip_entry ; ドライブ自体の情報は表示しない為、跳ばす
; ファイル名表示(ファイル名8+拡張子3) 例:FILE.TXT
push si
push di
mov ax, es ; ES保存
push ax
mov si, di
add si, 0x00 ; ファイル名
call print_filename ; ファイル名表示
pop ax
mov es, ax
; 属性を見てディレクトリかどうかを確認
mov al, byte [es:di+11]
test al, 0x10
jnz .print_dir
; ファイルの大きさ(0x1C-0x1F)
mov ax, word [es:di+0x1C]
call print_num
jmp .done_print
.print_dir:
mov si, msg_dir
call print_str
.done_print:
;call print_hex
call print_newline
pop di
pop si
.skip_entry:
add di, 32
inc si
cmp si, 224
jl .next_entry
.done:
;mov si, debug_msg_done
;call print_str
ret
; --- 8+3ファイル名表示 ---
; 入力: ES:SIが指している項目のファイル名を表示
; 出力: FILE.TXTのような形式で画面表示
print_filename:
; DS:SI ファイル名を指している
push cx
push ax
push es
push ds
push es
pop ds ; DS = ES
mov cx, 8
.next_char1:
mov al, byte [es:si]
cmp al, ' ' ;8バイト未満は空白で埋められるので、空白は表示しない
je .skip_char1
call print_char ;空白でなければ画面に表示する
.skip_char1:
inc si
loop .next_char1
mov al, '.' ;例:FILE.TXTの'.'
call print_char
;拡張子表示(処理はファイル名と同じ)
mov cx, 3
.next_char2:
mov al, byte [es:si]
cmp al, ' '
je .skip_char2
call print_char
.skip_char2:
inc si
loop .next_char2
mov al, ' '
call print_char
pop ds
pop es
pop ax
pop cx
ret
; --- 指定されたセクタをES:BXへ読み込む ---
read_sect_lba:
; 入力: AX = 読み込むセクタのアドレス
; 出力: 1セクタ分データを読み込み`ES:BX`へ読み込む
pusha
;mov si, debug_msg_reading_sector
;call print_str
; LBA→CHS変換 フロッピー一周18セクタ、円盤1枚2面
mov cx, 36 ; 1シリンダ36セクタ
xor dx, dx
div cx ; AX = シリンダ番号,DX = 余剰セクタ (0-35)
mov ch, al ; CH = シリンダ番号
mov ax, dx ; AX = 余剰セクタ (0-35)
mov cl, 18
div cl ; AL = ヘッダ番号(0或は1),AH = 余剰セクタ (0-17)
mov dh, al ; DH = ヘッダ番号
mov cl, ah
inc cl ; セクタは0でなく1番から始まる(BIOS仕様)
mov dl, 0 ; フロッピーは0を指定
mov ax, 0x0201 ; AH=0x02(セクタ読み込み),AL=1(1セクタ分読み込み)
int 0x13
jc .error
;mov si, debug_msg_sector_ok
;call print_str
popa
ret
.error:
mov si, disk_error_msg
call print_str
cli
.halt_loop:
hlt
jmp .halt_loop
debug_msg db 'Stage2: Booting from floppy...', 0x0D, 0x0A, 0
debug_msg_done db 'Root directory scan completed.', 0x0D, 0x0A, 0
debug_msg_hlt db 'System halted. Remove disk.', 0x0D, 0x0A, 0
debug_msg_entering_load db 'Entering load_root_directory...', 0x0D, 0x0A, 0
debug_msg_loaded db 'Root directory loaded!', 0x0D, 0x0A, 0
disk_error_msg db 'Disk error! Status=', 0
debug_msg_reading_sector db 'Reading LBA=', 0
debug_msg_sector_ok db ' - OK', 0x0D, 0x0A, 0
; --- 1文字表示(AL = ASCII)---
; 入力: AL = 表示する文字のASCIIコード
print_char:
pusha
mov ah, 0x0E ; BIOS teletype
mov bx, 0x0007 ; ページ 0,色 7
int 0x10
popa
ret
; --- 文字列表示 ---
; 入力: DS:SI = 終端文字0で終わる文字列
print_str:
pusha
.next_char:
lodsb ; [DS:SI] を ALへ読み込む
test al, al
jz .done
call print_char
jmp .next_char
.done:
popa
ret
; --- 改行 ---
print_newline:
push si
mov si, newline_str
call print_str
pop si
ret
newline_str db 0x0D, 0x0A, 0
; --- 10進数数字表示 ---
print_num:
pusha
mov cx, 0
.divide_loop:
xor dx, dx ; DXを0に
mov bx, 10 ; 除数10をBXに設定
div bx ; AX = DX:AX / 10, DX = 余り
push dx ; 余り(桁)をスタックに保存
inc cx ; 桁数カウンタを増加
test ax, ax ; AXが0かチェック
jnz .divide_loop ; 0でなければループ継続
.print_loop:
pop ax ; スタックから桁を取り出し
add al, '0' ; 数値→ASCII文字に変換
call print_char ; 文字を表示
loop .print_loop ; CXが0になるまでループ
popa
ret
msg_dir: db ' <DIR>', 0
load.asmの要となる処理
load_root_directory関数
FAT12で初期化すると自動的に19セクタ目 ~ 32セクタ目、計14セクタに目録が書き込まれ、
ファイルやディレクトリを追加するとこの目録に情報が追加される。
この目録をES:BX
へ読み込むのが以下のプログラム。
実際にfloppyからデータを読み込む処理はread_sect_lba
の中で実装している。
load_root_directory:
; ES:BXの値を1000:0000に初期化する
mov ax, 0x1000 ; ES = 0x1000
mov es, ax
xor bx, bx ; ES:BX = 1000:0000
mov si, 0 ; sector index (0 ~ 13)
.load_sector_loop:
mov ax, 19 ; 19セクタ目から読み込む
add ax, si
push si
call read_sect_lba ; 1セクタ分ES:BXへ読み込む
pop si
add bx, 512 ; 512バイト足すと次のES:BXがセクタを指す(1セクタ=512バイト)
inc si ; 読み込みセクタ数+1
cmp si, 14 ; 14セクタ分読み込んだか調べる
jl .load_sector_loop ; 14セクタに達していない場合は次のセクタを読み込む
ret
read_sect_lba
FAT12では、データの読み書きにCHS(Cylinder-Head-Sector)方式が使われています。
LBA(Logical Block Addressing)はより高機能な方法で、セクタ番号だけでアクセスできる形式ですが、フロッピーでは対応していません。
将来的な拡張性を考慮し、本関数ではLBA形式でセクタ番号を受け取り、内部でCHS形式へ変換して処理を行っています。
※団地の一部屋を特定する場合の例
CHS:〇棟、〇階、〇番目の部屋
LBA:〇号室
この関数内ではセクタ番号から、シリンダ、ヘッダ、セクタを計算し、ES:BX
へデータを読み込みます。
読み込むセクタ番号 LBA
一周のセクタ数 (SectorsPerTrack, SPT)
ヘッド数 (Heads, H)
$$
\text{シリンダ} = \left\lfloor \frac{\text{LBA}}{\text{SPT} \times H} \right\rfloor
$$
$$
\text{ヘッド} = \left\lfloor \frac{\text{LBA}}{\text{SPT}} \right\rfloor \bmod H
$$
$$
\text{セクタ} = (\text{LBA} \bmod \text{SPT}) + 1
$$
CHS変換の具体例:LBA = 19(フロッピーの19番目のセクタ)
- シリンダ (C) の計算
$$
C = \left\lfloor \frac{19}{18 \times 2} \right\rfloor = \left\lfloor \frac{19}{36} \right\rfloor = 0
$$
- ヘッド (H) の計算
$$
H = \left\lfloor \frac{19}{18} \right\rfloor \bmod 2 = 1 \bmod 2 = 1
$$
- セクタ (S) の計算
$$
S = (19 \bmod 18) + 1 = 1 + 1 = 2
$$
read_sect_lba:
; 入力: AX = 読み込むセクタのアドレス
; 出力: 1セクタ分データを読み込み`ES:BX`へ読み込む
pusha
;mov si, debug_msg_reading_sector
;call print_str
; LBA→CHS変換 フロッピー一周18セクタ、円盤1枚2面
mov cx, 36 ; 1シリンダ36セクタ
xor dx, dx
div cx ; AX = シリンダ番号,DX = 余剰セクタ (0-35)
mov ch, al ; CH = シリンダ番号
mov ax, dx ; AX = 余剰セクタ (0-35)
mov cl, 18
div cl ; AL = ヘッダ番号(0或は1),AH = 余剰セクタ (0-17)
mov dh, al ; DH = ヘッダ番号
mov cl, ah
inc cl ; セクタは0でなく1番から始まる(BIOS仕様)
mov dl, 0 ; フロッピーは0を指定
mov ax, 0x0201 ; AH=0x02(セクタ読み込み),AL=1(1セクタ分読み込み)
int 0x13
jc .error
;mov si, debug_msg_sector_ok
;call print_str
popa
ret
.error:
mov si, disk_error_msg
call print_str
cli
.halt_loop:
hlt
jmp .halt_loop
list_root_directory
目録を走査し一覧を表示する
; 入力: 目録は既に0x1000:0000へ読み込まれている
; 出力: 画面上にファイル及びディレクトリを表示
list_root_directory:
mov si, 0 ; 現在読み込み中の項目(目録の項目は0~223)
mov di, 0 ; 目録の相対アドレス(オフセット)(0 ~ 32*224)
.next_entry:
; ES:DI 現在の目録項目
; 項目1バイト目が0x00であればその下には他のファイル無し,0xE5は削除済み
mov al, byte [es:di]
cmp al, 0x00
je .done ; 最後のファイル、読み込み終了
cmp al, 0xE5
je .skip_entry ; 削除済み、跳ばす
; 各項目11バイト目の属性を確認
mov al, byte [es:di+11]
test al, 0x08
jnz .skip_entry ; ドライブ自体の情報は表示しない為、跳ばす
; ファイル名表示(ファイル名8+拡張子3) 例:FILE.TXT
push si
push di
mov ax, es ; 保存ES
push ax
mov si, di
add si, 0x00 ; ファイル名
call print_filename ; ファイル名表示
pop ax
mov es, ax
; 属性を見てディレクトリかどうかを確認
mov al, byte [es:di+11]
test al, 0x10
jnz .print_dir
; ファイルの大きさ(0x1C-0x1F)
mov ax, word [es:di+0x1C]
call print_num
jmp .done_print
print_filename
; 入力: ES:SIが指している項目のファイル名を表示
; 出力: FILE.TXTのような形式で画面表示
print_filename:
; DS:SI ファイル名を指している
push cx
push ax
push es
push ds
push es
pop ds ; DS = ES
mov cx, 8
.next_char1:
mov al, byte [es:si]
cmp al, ' ' ;8バイト未満は空白で埋められるので、空白は表示しない
je .skip_char1
call print_char ;空白でなければ画面に表示する
.skip_char1:
inc si
loop .next_char1
mov al, '.' ;例:FILE.TXTの'.'
call print_char
;拡張子表示(処理はファイル名と同じ)
mov cx, 3
.next_char2:
mov al, byte [es:si]
cmp al, ' '
je .skip_char2
call print_char
.skip_char2:
inc si
loop .next_char2
mov al, ' '
call print_char
pop ds
pop es
pop ax
pop cx
ret
動作確認
フロッピーの内部構造
1セクタは512バイト。1.44MBフロッピーは、1,474,560バイト ÷ 512バイト/セクタ = 2880セクタ。
用途 | 物理位置(LBAセクタ番号) |
---|---|
ブートセクタ(boot.bin) | LBA 0 |
FAT1 表 | LBA 1–9 |
FAT2 表(予備) | LBA 10–18 |
ルートディレクトリ | LBA 19–32(全14セクタ) |
ファイル領域 | LBA 33〜 |
load.bin | LBA 100(FAT・ファイル領域を避けた任意配置) |
FAT12で初期化しプログラム及びファイルを書き込む作業
Linux端末上で、FAT12形式の仮想フロッピーに、ブートローダとプログラムを配置し、QEMUで起動確認するまでの手順を記します。
nasm -f bin boot.asm -o boot.bin # コンパイル
nasm -f bin load.asm -o load.bin
# フロッピーイメージを作成(1.44MB = 512バイト × 2880セクタ)
dd if=/dev/zero of=testdisk.img bs=512 count=2880
# FAT12ファイルシステムで初期化
# -F 12: FAT12形式を指定
# -v: 詳細情報を表示(verbose)
mkdosfs -F 12 -v testdisk.img
# ブートセクタを書き込み(最初の1セクタ)
dd if=boot.bin of=testdisk.img bs=512 count=1 conv=notrunc
# load.binは100セクタ目に配置
dd if=load.bin of=testdisk.img bs=512 seek=100 conv=notrunc
# 不要になった一時ファイルを削除
rm boot.bin
rm load.bin
# マウントポイントを作成
#sudo mkdir /mnt/floppy
# 作成したFAT12イメージをマウントして中身を編集
sudo mount -o loop,fat=12 testdisk.img /mnt/floppy
# 動作確認用ファイルを作成(ルートディレクトリにファイルとディレクトリを追加)
echo "Hello FAT12!" | sudo tee /mnt/floppy/test.txt
echo 'Hello FAT12!aaaaa' | sudo tee /mnt/floppy/test2.txt #2komeha singuru
sudo mkdir /mnt/floppy/TEST_DIR
# 書き込んだ結果を確認
ls -l /mnt/floppy
# マウント解除(編集完了後)
sudo umount /mnt/floppy
# QEMUでフロッピーイメージを起動して動作確認
qemu-system-i386 -fda testdisk.img
動作画面
詰まったところ
FAT12で初期化するとブートローダーが上書きされる→ブートローダー内でFAT12のヘッダー情報を定義。
フロッピーをLBA方式で読み込もうとして失敗。
CPUを停止させるためにjmp $
と書き。ファンが激しく回る。→cli hlt
と書き換え解決。
関数内でレジスタの値が書き換えられ、原因が分からず詰まった。
最後に
説明が難しくうまく説明できていないところもあると思います。
ネット上にもっと分かり易い説明もあるので、興味のある方は調べてみて下さい。