实现一个简单的64位操作系统 (0x03)熟悉FAT12文件系统
0x01 概述
之前已经实现了一个简单的boot程序,但是它最大只能占一个Sector,也就是512 Bytes,局限性太大,能够完成的工作不多。接下来就要想办法加载一个更大的程序,用这个更大的程序来加载内核。这个程序就是Loader。
为了能够避免地址硬编码的问题,需要实现一个简单的文件系统来加载Loader与内核。当有了一个文件系统之后,就能灵活地将数据写入软盘以及加载到内存中了。
这里选择的是FAT12文件系统,这个文件系统广泛地用于微软早期的各个系统中。关于FAT12文件系统的介绍可以看FAT12文件系统之引导扇区结构来了解FAT12文件系统的引导扇区结构,以及FAT12文件系统之数据存储方式详解来了解FAT12文件系统中文件的存储方式。
强烈推荐看这篇文章:FAT Filesystem 这篇文章对FAT文件系统讲得非常详细,甚至细到了每一个字段能够取哪些值,以及取值的意义。
这一章计划使用C语言实现一个对FAT12文件系统镜像的解析程序。选择先用C语言实现的原因是能够从镜像外的角度来观察镜像,能够对整个镜像有一个直观的了解。
通过使用C语言对FAT12的镜像文件进行解析,能够熟悉FAT12文件系统,同时,之后的boot实现只需要将C实现人工“翻译”成汇编即可。
0x02 设计
实现一个程序,能够解析FAT12文件系统的镜像,输出其引导扇区结构,遍历根目录,以及对根目录特定文件数据进行读取。
0x03 准备镜像
准备镜像使用到了WinImage。使用WinImage创建了一个新镜像,然后往镜像内写入了几个文件,以及创建了一个目录。如下图。
新镜像文件
将其保存为sample_image.ima(注意不要保存为压缩模式,也不要加密,不然无法正确解析)。
0x04 实现
(1) main函数的实现
main函数负责将镜像文件读到内存中,并调用函数解析内存中的镜像文件。由于镜像文件不大,整个才1.44MB,所以索性将它整个读到内存中了。
先从命令行参数中拿到镜像文件名,并打开镜像。
if(argc != 2)
{
printf("Usage: %s ImageFile\n", argv[0]);
return 1;
}
// open image file
FILE *pImageFile = fopen(argv[1], "rb");
if(pImageFile == NULL)
{
puts("Read image file failed!");
return 1;
}
然后获取文件大小并申请一个Buffer来存储文件数据。
// get file size
fseek(pImageFile,0,SEEK_END);
long lFileSize = ftell(pImageFile);
printf("Image size: %ld\n",lFileSize);
// alloc buffer
unsigned char *pImageBuffer = (unsigned char *)malloc(lFileSize);
if(pImageBuffer == NULL)
{
puts("Memmory alloc failed!");
return 1;
}
接着将文件读到Buffer中,并关闭文件。
// set file pointer to the beginning
fseek(pImageFile,0,SEEK_SET);
// read the whole image file into memmory
long lReadResult = fread(pImageBuffer,1,lFileSize,pImageFile);
printf("Read size: %ld\n",lReadResult);
if(lReadResult != lFileSize)
{
puts("Read file error!");
free(pImageBuffer);
fclose(pImageFile);
return 1;
}
// finish reading, close file
fclose(pImageFile);
最后,调用PrintImage函数打印FAT12的引导扇区结构,调用SeekRootDir来遍历根目录,调用ReadFile来读入指定文件,然后讲读到的内容输出到屏幕上。
// finish reading, close file
fclose(pImageFile);
// print FAT12 structure
PrintImage(pImageBuffer);
// seek files of root directory
SeekRootDir(pImageBuffer);
// file read buffer
unsigned char outBuffer[2048];
// read file 0
DWORD fileSize = ReadFile(pImageBuffer, &FileHeaders[0], outBuffer);
printf("File size: %u, file content: \n%s",fileSize, outBuffer);
至此,main函数就实现完成了。接下来就要开始PrintImage、SeekRootDir、ReadFile这三个函数的实现了。
(2) 准备结构体
由于FAT12的引导扇区结构以及目录项结构的每个字段都是固定长度的,所以可以通过使用结构体方便地解析它们。
在定义它们的结构体之前,需要先给出几个宏,让每个字段的大小有一个直观的了解(原谅我的Windows混搭风格,后续再慢慢改~)。
#define BYTE unsigned char
#define WORD unsigned short
#define DWORD unsigned int
#define BOOT_START_ADDR 0x7c00
使用unsigned的原因是它们在参与计算时不会使用补码(不会带上符号)。
BYTE表示1个字节;WORD表示单字,也就是2个字节;DWORD表示双字,也就是四个字节。
BOOT_START_ADDR表示Boot扇区在内存中的加载起始地址位0x7c00。
接下来就能根据FAT12的规范给出FAT12引导扇区的结构体了:
typedef struct _FAT12_HEADER FAT12_HEADER;
typedef struct _FAT12_HEADER *PFAT12_HEADER;
struct _FAT12_HEADER {
BYTE JmpCode[3];
BYTE BS_OEMName[8];
WORD BPB_BytesPerSec;
BYTE BPB_SecPerClus;
WORD BPB_RsvdSecCnt;
BYTE BPB_NumFATs;
WORD BPB_RootEntCnt;
WORD BPB_TotSec16;
BYTE BPB_Media;
WORD BPB_FATSz16;
WORD BPB_SecPerTrk;
WORD BPB_NumHeads;
DWORD BPB_HiddSec;
DWORD BPB_TotSec32;
BYTE BS_DrvNum;
BYTE BS_Reserved1;
BYTE BS_BootSig;
DWORD BS_VolID;
BYTE BS_VolLab[11];
BYTE BS_FileSysType[8];
}__attribute__((packed)) _FAT12_HEADER;
具体每一个成员的意义可以查阅文章FAT12文件系统之引导扇区结构。
其中,要格外注意的是:
__attribute__((packed)) _FAT12_HEADER
__attribute__((packed)) 告诉编译器,这个结构体是不需要对齐的(GNU GCC有效),如果不指定这个关键字,编译器在编译这个结构体时,会将其对齐,这样解析起Boot扇区就不正确了。对于这个结构体来说,会对齐6 Bytes。
然后是目录项结构的结构体:
typedef struct _FILE_HEADER FILE_HEADER;
typedef struct _FILE_HEADER *PFILE_HEADER;
struct _FILE_HEADER {
BYTE DIR_Name[11];
BYTE DIR_Attr;
BYTE Reserved[10];
WORD DIR_WrtTime;
WORD DIR_WrtDate;
WORD DIR_FstClus;
DWORD DIR_FileSize;
}__attribute__((packed)) _FILE_HEADER;
(3) PrintImage实现
PrintImage函数负责打印被解析的_FAT12_HEADER结构体。其实现如下。
void PrintImage(unsigned char *pImageBuffer)
{
puts("\nStart to print image:\n");
PFAT12_HEADER pFAT12Header = (PFAT12_HEADER)pImageBuffer;
// calculate start address of boot program
WORD wBootStart = BOOT_START_ADDR + pFAT12Header->JmpCode[1] + 2;
printf("Boot start address: 0x%04x\n",wBootStart);
char buffer[20];
memcpy(buffer,pFAT12Header->BS_OEMName,8);
buffer[8] = 0;
printf("BS_OEMName: %s\n",buffer);
printf("BPB_BytesPerSec: %u\n",pFAT12Header->BPB_BytesPerSec);
printf("BPB_SecPerClus: %u\n",pFAT12Header->BPB_SecPerClus);
printf("BPB_RsvdSecCnt: %u\n",pFAT12Header->BPB_RsvdSecCnt);
printf("BPB_NumFATs: %u\n",pFAT12Header->BPB_NumFATs);
printf("BPB_RootEntCnt: %u\n",pFAT12Header->BPB_RootEntCnt);
printf("BPB_TotSec16: %u\n",pFAT12Header->BPB_TotSec16);
printf("BPB_Media: 0x%02x\n",pFAT12Header->BPB_Media);
printf("BPB_FATSz16: %u\n",pFAT12Header->BPB_FATSz16);
printf("BPB_SecPerTrk: %u\n",pFAT12Header->BPB_SecPerTrk);
printf("BPB_NumHeads: %u\n",pFAT12Header->BPB_NumHeads);
printf("BPB_HiddSec: %u\n",pFAT12Header->BPB_HiddSec);
printf("BPB_TotSec32: %u\n",pFAT12Header->BPB_TotSec32);
printf("BS_DrvNum: %u\n",pFAT12Header->BS_DrvNum);
printf("BS_Reserved1: %u\n",pFAT12Header->BS_Reserved1);
printf("BS_BootSig: %u\n",pFAT12Header->BS_BootSig);
printf("BS_VolID: %u\n",pFAT12Header->BS_VolID);
memcpy(buffer,pFAT12Header->BS_VolLab,11);
buffer[11] = 0;
printf("BS_VolLab: %s\n",buffer);
memcpy(buffer,pFAT12Header->BS_FileSysType,8);
buffer[11] = 0;
printf("BS_FileSysType: %s\n",buffer);
}
其中,由于能够相信pImageBuffer的首地址开始就是_FAT12_HEADER结构体,PFAT12_HEADER pFAT12Header = (PFAT12_HEADER)pImageBuffer;
直接将读到内存中的镜像文件首地址传给FAT12_HEADER结构体指针,进行强制转化,就能对各字段进行读取了。
WORD wBootStart = BOOT_START_ADDR + pFAT12Header->JmpCode[1] + 2;
将BOOT_START_ADDR(Boot扇区读到内存中的首地址)加上跳转Offset再加上2就能得到引导程序的收地了。引导扇区一开始是一个JMP Offset和一个NOP。在实模式下,JMP Offset占两个Bytes,NOP占一个Byte。其中,JMP Offset的操作码为0xEB,操作数(Offset)占一个Byte,NOP为0x90,占一个Byte。所以,这整个就是 0xEB Offset 0x90。如果想要得到跳转地址,就需要将当前地址(BOOT_START_ADDR)加上跳转偏移(Offset, 也就是JmpCode[1]),再加上2(JMP Offset的指令长度,因为Offset是针对当前指令的下一条指令地址来的)。也就是BOOT_START_ADDR + pFAT12Header->JmpCode[1] + 2
。
接着,分别对每个字段进行打印。PrintImage的使命就完成了。
(4) SeekRootDir的实现
SeekRootDir用来遍历Root Directory,将文件名、文件属性和文件首簇号打印出来,并将其目录结构作为_FILE_HEADER结构体存储在一个数组中。由于这里很清除具体有多少个文件,为了省事就不动态申请内存存放了,而是使用了一个固定大小的数组。
FILE_HEADER FileHeaders[30];
void SeekRootDir(unsigned char *pImageBuffer)
{
PFAT12_HEADER pFAT12Header = (PFAT12_HEADER)pImageBuffer;
puts("\nStart seek files of root dir:");
// sectors number of start of root directory
DWORD wRootDirStartSec = pFAT12Header->BPB_HiddSec + pFAT12Header->BPB_RsvdSecCnt + pFAT12Header->BPB_NumFATs * pFAT12Header->BPB_FATSz16;
printf("Start sector of root directory: %u\n", wRootDirStartSec);
// bytes num of start of root directory
DWORD dwRootDirStartBytes = wRootDirStartSec * pFAT12Header->BPB_BytesPerSec;
printf("Start bytes of root directory: %u\n",dwRootDirStartBytes);
PFILE_HEADER pFileHeader = (PFILE_HEADER)(pImageBuffer + dwRootDirStartBytes);
int fileNum = 1;
while(*(BYTE *)pFileHeader)
{
// copy file header to the array
FileHeaders[fileNum - 1] = *pFileHeader;
char buffer[20];
memcpy(buffer,pFileHeader->DIR_Name,11);
buffer[11] = 0;
printf("File no. %d\n", fileNum);
printf("File name: %s\n", buffer);
printf("File attributes: 0x%02x\n", pFileHeader->DIR_Attr);
printf("First clus num: %u\n\n", pFileHeader->DIR_FstClus);
++pFileHeader;
++fileNum;
}
}
DWORD wRootDirStartSec = pFAT12Header->BPB_HiddSec + pFAT12Header->BPB_RsvdSecCnt + pFAT12Header->BPB_NumFATs * pFAT12Header->BPB_FATSz16;
计算出了根目录的起始扇区。计算方法为:隐藏扇区数 + 保留扇区数(Boot Sector) + FAT表数量 × FAT表大小(Sectors)。也就是将根目录前面所有的扇区数加起来。
得到起始扇区数后,将其乘上每扇区的字节数就能得到根目录的起始字节偏移了:DWORD dwRootDirStartBytes = wRootDirStartSec * pFAT12Header->BPB_BytesPerSec;
。
接着,讲pImageBuffer地址加上计算出的根目录字节偏移就能得到根目录第一个文件的_FILE_HEADER结构体:PFILE_HEADER pFileHeader = (PFILE_HEADER)(pImageBuffer + dwRootDirStartBytes);
。之后就能够对这个结构体进行操作,然后使用++pFileHeader;
来遍历根目录。
根据pFileHeader的第一个Byte是否为0x00来判断是否到达最后一个文件(这个判断是不对的,中间有文件可能被删除,而且可能隔着0x00后面还有有效文件,所以这里需要后续再改。但是仅仅针对这一个构造的Image是有效的,就暂时用着了)。最终得到的文件都放入FileHeaders
中。
(5) ReadFile的实现
ReadFile函数能够根据传入的_FILE_HEADER结构体从传入的ImageBuffer中读出数据,并写到传入的outBuffer中。实现如下:
DWORD ReadFile(unsigned char *pImageBuffer, PFILE_HEADER pFileHeader, unsigned char *outBuffer)
{
PFAT12_HEADER pFAT12Header = (PFAT12_HEADER)pImageBuffer;
char nameBuffer[20];
memcpy(nameBuffer, pFileHeader->DIR_Name, 11);
nameBuffer[11] = 0;
printf("The FAT chain of file %s:\n", nameBuffer);
// calculate the pointer of FAT Table
BYTE *pbStartOfFATTab = pImageBuffer + (pFAT12Header->BPB_HiddSec + pFAT12Header->BPB_RsvdSecCnt) * pFAT12Header->BPB_BytesPerSec;
WORD next = pFileHeader->DIR_FstClus;
DWORD readBytes = 0;
do
{
printf(", 0x%03x", next);
// get the LSB of clus num
DWORD dwCurLSB = GetLSB(next, pFAT12Header);
// read data
readBytes += ReadData(pImageBuffer, dwCurLSB, outBuffer + readBytes);
// get next clus num according to current clus num
next = GetFATNext(pbStartOfFATTab, next);
}while(next <= 0xfef);
puts("");
return readBytes;
}
首先要得到FAT表的指针:BYTE *pbStartOfFATTab = pImageBuffer + (pFAT12Header->BPB_HiddSec + pFAT12Header->BPB_RsvdSecCnt) * pFAT12Header->BPB_BytesPerSec;
,也就是用指向ImageBuffer的指针加上FAT表的偏移,就能得到这个指针。FAT表的偏移用FAT表之前的所有GetFATNext来计算下一个簇的簇号。GetFatNext的实现会在后面提到。
最后,将读到的字节数返回。
(6) GetLSB的实现
GetLSB用来计算出给出的FAT表项对应在数据区的扇区号。下面是它的实现。
DWORD GetLSB(DWORD ClusOfTable, PFAT12_HEADER pFAT12Header)
{
DWORD dwDataStartClus = pFAT12Header->BPB_HiddSec + pFAT12Header->BPB_RsvdSecCnt + pFAT12Header->BPB_NumFATs * pFAT12Header->BPB_FATSz16 + \
pFAT12Header->BPB_RootEntCnt * 32 / pFAT12Header->BPB_BytesPerSec;
return dwDataStartClus + (ClusOfTable - 2) * pFAT12Header->BPB_SecPerClus;
}
也比较简单。就是将数据区前面所有的扇区号都加起来,得到数据区的起始扇区,然后将给出的FAT项减2,再乘上每簇的扇区数,加上数据区的起始扇区号,最后就得到了当前FAT项的LSB。
(7) GetFATNext的实现
GetFATNext根据当前给出的FAT表项,得到它在FAT表里的下一项。其实现如下。
WORD GetFATNext(BYTE *FATTable, WORD CurOffset)
{
WORD tabOff = CurOffset * 1.5;
WORD nextOff = *(WORD *)(FATTable + tabOff);
nextOff = CurOffset % 2 == 0 ? nextOff & 0x0fff : nextOff >> 4;
return nextOff;
}
由于在FAT12文件系统中,FAT表中的每一项是1.5 Bytes(6 Bits),而又没有任何一种数据类型能够表示1.5 Bytes,所以需要用一个Word,也就是2 Bytes来存储它。但是每一项在FAT表中又是紧紧相连的,所以在读的时候需要用一点小技巧。
先用传来的FAT表项×1.5得到实际需要读取的值在FAT表中的Bytes偏移,然后用一个WORD来存储它。
接着,判断这个偏移是奇数还是偶数。如果是奇数,则将前4位清0(与上0x0fff),如果是偶数,则将其右移4位,最终得到下一项的FAT表偏移。
至于这个具体是怎么来的,可以通过观察一个具体的FAT12的FAT表得出。例如下面的FAT表:
示例FAT表
由于第0项和第1项都是保留的,所以跨过前两项,直接看第2项。
第2项的起始Bytes是2×1.5=3,在第3 Bytes处读出一个Word,根据小端序,读出来是0x4003。然后,由于当前偏移3是奇数,所以将其前4位清零(&0x0FFF),得到0x0003,取后1.5 bytes,得到0x003,也就是它的下一项是0x003。
第3项的起始Bytes是3×1.5=4,在第4 Bytes处读出一个Word,得到0x0040。由于4是偶数,将其右移4位,得到0x0004。取后1.5 Bytes,得到0x004,这就是第3项的下一项。
以此类推。由于这里写文件的可用Cluster都是连续的,所以这里的表项也是连续的。其实FAT表像是一个单项链表,文件存储的地址是可以不连续的。
(8) ReadData的实现
ReadData的实现比较简单,计算出传入的LSB在镜像Buffer中的位置(Bytes),然后写到传入的outBuffer中。
DWORD ReadData(unsigned char *pImageBuffer, DWORD LSB, unsigned char *outBuffer)
{
PFAT12_HEADER pFAT12Header = (PFAT12_HEADER)pImageBuffer;
DWORD dwReadPosBytes = LSB * pFAT12Header->BPB_BytesPerSec;
memcpy(outBuffer, pImageBuffer + dwReadPosBytes, pFAT12Header->BPB_SecPerClus * pFAT12Header->BPB_BytesPerSec);
return pFAT12Header->BPB_SecPerClus * pFAT12Header->BPB_BytesPerSec;
}
首先,使用LSB×每扇区的字节数,得到要读的扇区的字节起始值DWORD dwReadPosBytes = LSB * pFAT12Header->BPB_BytesPerSec;
,然后用memcpy将ImageBuffer的ReadPosBytes偏移处的数据写到outBuffer中,写入长度是每簇的扇区数与每扇区的字节数的积,也就是每簇的字节数。
至此,整个实现就完成了。
0x05 测试
将程序编译后使用上面生成的镜像进行测试。
运行后,首先输出了镜像的引导扇区结构:
引导扇区结构
与镜像实际值对照,发现是正确的。说明程序能够正确解析改镜像的引导扇区结构。
然后,程序开始遍历镜像的根目录,并将根目录输出。如下图。
根目录遍历
能看到,FAT12文件系统中,文件名一共11个字节的长度,后三个字节用来存储扩展名,剩余的字节用来存储文件名,不够的字节用0x20(空格)来填充。
接下来,是对文件README TXT的读取。
读取README TXT
先输出了文件在FAT表中的所有项,然后将其读到了Buffer中。下面的内容是在main函数中对读取的buffer的打印。拉倒末尾,发现打印完整,说明整个文件都读出来了。
0x06 总结
这一章使用C实现了一个从FAT12文件系统的镜像文件中读出引导扇区结构、根目录及特定文件的程序。接下来要做的就是将其在Boot Sector中用汇编实现出来,并加载Loader了。
附件是这章用到的WinImage生成的镜像文件。
附件:
sample_image.ima