以下三幅圖片為磁盤結構的簡圖:
磁盤的中間有一個主軸,它可以帶動盤片轉動,讓所有的盤片都圍繞著主軸旋轉。有些磁盤的盤片上會有兩個盤面,即上下兩個盤面。
盤面被劃分成許多個狹窄的同心圓環,每一個同心圓環都被稱為一個磁道,磁道的編號是由外向內的,即最外圈的為0號磁道。 將盤面看成一個圓形,可以在盤面上劃分出多個相同的扇形,分配到每一個磁道上的弧段被稱為扇區??梢钥闯雒恳粋€磁道上的扇區數是相同的。注意上圖右半邊的注解。 每一個磁道上的扇區數目相同,且每個扇區的大小都為512字節,但外圈的扇區占用的物理面積更大,因此外圈的磁道上的扇區存在更多的浪費。
盤片旁邊為機械磁臂,用于移動磁頭。上圖中用藍色標出的圓柱體為一個柱面,例如所有盤面的0磁道可以組成一個柱面。只要往磁盤控制器中寫柱面(cylinder)、磁頭(head)、扇區(sector),就可以使用磁盤了。
扇區號的排列會影響磁盤的訪問速度,Linux0.11使用的磁盤的扇區號排列方式如下:
從圖中可以看出:扇區號是從一個柱面最上面的一個磁道從上往下開始排列,若一個磁道上有7個扇區,最上面磁道的扇區排列為0,1,2,3,4,5,6;7號扇區在下面一個磁道,且位于0號扇區的正下方。下一個柱面的扇區號從上一個柱面結束的扇區號+1開始排列,即扇區號的排列是連續的。這樣數據的讀寫就變為了按柱面進行的,而不是按盤面進行。
磁盤容量 = 盤面數 × 柱面數 × 扇區數 × 512字節; //柱面數也可以理解為一個盤面上的磁道數
磁盤訪問時間 = 寫入控制器時間 + 尋道時間 + 旋轉延遲時間 + 傳輸時間
硬盤讀取數據時,讀寫磁頭沿徑向移動,移到要讀取的扇區所在磁道的上方,這段時間稱為尋道時間(seek time)。因讀寫磁頭的起始位置與目標位置之間的距離不同,尋道時間也不同。磁頭到達指定磁道后,然后通過盤片的旋轉,使得要讀取的扇區轉到讀寫磁頭的下方,這段時間稱為旋轉延遲時間(rotational latencytime)。然后再讀寫數據,讀寫數據也需要時間,這段時間稱為傳輸時間(transfer time)
直接通過三維參數(柱面、磁頭、扇區)使用磁盤是非常麻煩的。從扇區號到盤塊號,是對磁盤使用的第一層抽象,本章節主要介紹如何通過盤塊號來使用磁盤,其中2.1節介紹盤塊號如何轉換為三維參數,2.2節分析Linux0.11中生磁盤的使用過程。
扇區大小固定,但操作系統可以每次讀/寫連續的幾個扇區,這幾個連續的扇區可以組成一個盤塊。為方便理解如何通過盤塊號來訪問磁盤,假定一個盤塊的大小為一個扇區,盤塊號排列方式與扇區號一樣(即盤塊號與圖1.4的扇區號排列一致)。根據這種排列方式,就可以根據盤塊號計算出要訪問的三維參數(C,H,S):
// 根據要訪問的三維參數可以計算出對應的盤塊號
block = C * (Heads * Sectors) + H * Sectors + S;
// 也可以通過盤塊號計算出要訪問的三維參數
S = block % Sectors; // 注意向磁盤控制器內寫入的扇區號是真實磁盤的物理扇區號,不是上圖中的扇區號
C = block / ( Heads * Sectors);
H = (block % ( Heads * Sectors)) / Sectors;
其中 C,H,S 表示訪問磁盤所需要的三維參數;block 表示盤塊號;Heads 為常量,表示磁盤的磁頭數(也就是盤面數);Sectors 為常量,表示一個磁道上的扇區數;若一個盤塊對應n個扇區,計算方式也與之類似。
盤塊大小的分配也會影響到磁盤使用的效率:盤塊越大,讀寫的速度會越快,但碎片也會越大(這些碎片無法使用);盤塊越小讀寫速度會越慢,但碎片會越小。
假設磁頭的初始位置是100號磁道,有多個進程先后陸續的請求訪問55,58,39,18,90,160,150,38,184號磁道
根據進程請求訪問磁盤的先后順序進行調度
按照FCFS的規則,按照請求到達的順序,磁頭需要一次移動到55,58,39,90,160,150,38,184號磁道
磁頭總共移動的磁道個數為45+3+19+21+72+70+10+112+146=498
平均尋道長度為498/9=55.3個磁道
最短尋道時間優先,其要求訪問的磁道與當前磁頭所在的磁道距離最近,以便每次的尋道時間最短,但這種調度算法卻不能保證平均尋道時間最短
假設磁頭的初始位置是100號磁道,有多個進程先后陸續的請求訪問55,58,39,18,90,160,150,38,184號磁道
按照SSTF的規則,請求到達的
磁頭總共移動了(100-18)+(184-18)=248個磁道
平均尋道長度為248/9=27.5個磁道
當磁頭正在由里向外移動時,SCAN算法所選擇的下一個訪問對象應是其欲訪問的磁道,既在當前磁道之外,又是距離最近的。這樣由里向外地訪問,直至再無更外的磁道需要訪問時,才將磁臂換向,由外向里移動。也叫電梯算法。
磁頭總共移動了(184-100)+(184-18)=250個磁道
平均尋道長度為250/9=27.8個磁道
為了減少SCAN算法造成的某些進程的請求被嚴重推遲,CSCAN算法規定磁頭單向移動。
磁頭總共移動了(184-100)+(184-18)+(90-18)=322個磁道
平均尋道長度為322/9=35.8個磁道
本節主要介紹 Linux0.11 中生磁盤的使用過程。本節內容只是對生磁盤的使用過程進行了粗糙的分析,主要是為了了解生磁盤的大致使用過程,如對 請求項隊列的數據結構:struct buffer_head struct request struct blk_dev_struct三個結構體是怎么配合工作的等等, 這些細節部分都沒有進行說明,對于這些細節建議參考《Linux內核完全剖析——基于0.12內核》。
生磁盤的使用過程簡圖如下:
下面根據 Linux0.11 的程序對生磁盤的使用過程進行分析。
(1)進程向緩沖區管理程序提出讀寫磁盤申請。緩沖區管理程序做出一個磁盤請求,在將請求加入請求隊列中,最后讓進程進入睡眠等待狀態
/*創建請求項,并加入到請求隊列中*/
/*major為主設備號,rw為命令(讀/寫),bh為存放數據的緩沖區頭指針*/
static void make_request(int major,int rw, struct buffer_head * bh)
{
struct request * req;
int rw_ahead;
......
/* fill up the request-info, and add it to the queue */
/*前面主要是在尋找空閑請求項,并將請求項插入到請求項隊列的指定位置*/
/*接下來就是做出請求項,從下面這段程序可以看出 bh 中所包含的信息*/
req->dev = bh->b_dev; /*數據源的主設備號*/
req->cmd = rw;
req->errors=0;
/*bh->b_blocknr 應該就是圖1.4中說的扇區號,而req->sector就是盤塊號。
從這里可以看出一個盤塊的大小為2個扇區(2*512Bety)*/
req->sector = bh->b_blocknr<<1;
req->nr_sectors = 2; /*本次請求的扇區數*/
req->buffer = bh->b_data;/*將請求項的緩沖區指針指向需要讀寫的數據緩沖區*/
req->waiting = NULL;
req->bh = bh;
req->next = NULL;
add_request(major+blk_dev,req);/*將請求項加入隊列*/
}
在將請求項插入請求隊列時,為了讓磁盤使用的更加高效,這里采用了電梯算法將請求項插入請求隊列。
/*本函數是將已經做好的請求項(req),加入到請求隊列(dev)中*/
static void add_request(struct blk_dev_struct * dev, struct request * req)
{
struct request * tmp;
req->next = NULL;
cli();/*這段代碼要互斥,因此關中斷*/
if (req->bh)
req->bh->b_dirt = 0;
if (!(tmp = dev->current_request)) {/*將tmp指向請求隊列的隊首*/
/*若當前設備的請求項列表為空則設置 req 為當前請求項,并立即調用設備請求項處理函數*/
dev->current_request = req;
sti();
(dev->request_fn)();/*設備請求項處理函數指針,若當前請求讀寫的為硬盤,則它是 do_hd_request() */
return;
}
/*如果當前設備的請求項列表不為空則將 req 插入請求隊列中*/
/*下面這個for循環將利用 電梯算法 將req插到dev的合適位置*/
for ( ; tmp->next ; tmp=tmp->next)/*從前往后掃描整個請求隊列*/
if ((IN_ORDER(tmp,req) || /*IN_ORDER應該是要比較tem的扇區號是否小于req的扇區號的,不過這里簡化了,比較的是柱面號*/
!IN_ORDER(tmp,tmp->next)) &&
IN_ORDER(req,tmp->next))
break;
req->next=tmp->next;
tmp->next=req;
sti();/*開中斷*/
}
將請求項插入請求隊列后就會讓進程進入睡眠等待狀態,不過我還沒找到相關代碼,找到后在補充這一段。
(2) 根據(1)中算出的盤塊號計算出要訪問的三維參數(扇區號,柱面,磁頭)并寫入磁盤控制器
從(1)中可以看出,對于硬盤的請求項,設備請求項處理函數為 do_hd_request() 。
/*本函數執行磁盤讀寫請求操作*/
/*該函數首先根據請求項中的設備號和盤塊號等信息計算出要訪問的磁盤三維參數。
然后根據請求項中的讀寫命令,向磁盤控制器發出相應的讀寫命令。*/
void do_hd_request(void)
{
int i,r = 0;
unsigned int block,dev;
unsigned int sec,head,cyl;
unsigned int nsect;
INIT_REQUEST;
dev = MINOR(CURRENT->dev);
block = CURRENT->sector;/*CURRENT->sector為盤塊號*/
if (dev >= 5*NR_HD || block+2 > hd[dev].nr_sects) {
end_request(0);
goto repeat;
}
block += hd[dev].start_sect;/*start_sect 是分區在磁盤中的起始物理(絕對)扇區,這個和磁盤分區有關,先不管它*/
dev /= 5;
/*下面這段內嵌匯編將根據盤塊號算出cyl, head, sec(CHS)*/
__asm__("divl %4":"=a" (block),"=d" (sec):"0" (block),"1" (0),
"r" (hd_info[dev].sect));
__asm__("divl %4":"=a" (cyl),"=d" (head):"0" (block),"1" (0),
"r" (hd_info[dev].head));
sec++;
nsect = CURRENT->nr_sectors;/*nr_sectors 是分區中的扇區總數*/
......
if (CURRENT->cmd == WRITE) {
/*發送寫磁盤命令。write_intr 中斷調用函數,當前中斷為寫操作時被設置成中斷過程中調用的 C 函數。
磁盤完成寫盤命令后會向CPU發送中斷請求信號,于是在磁盤控制器完成寫操作后會立刻調用該函數*/
hd_out(dev,nsect,sec,head,cyl,WIN_WRITE,&write_intr);
......
} else if (CURRENT->cmd == READ) {
/*發送讀磁盤命令。read_intr 的用法與 write_intr 類似*/
hd_out(dev,nsect,sec,head,cyl,WIN_READ,&read_intr);
} else
panic("unknown hd-command");
}
static void hd_out(unsigned int drive,unsigned int nsect,unsigned int sect,
unsigned int head,unsigned int cyl,unsigned int cmd,
void (*intr_addr)(void))
{
register int port asm("dx");
......
do_hd = intr_addr; /*do_hd = intr_addr 在磁盤中斷處理函數(hd_interrupt:)中被調用*/
outb_p(hd_info[drive].ctl,HD_CMD); /*向磁盤控制器中輸出控制字節*/
port=HD_DATA;
outb_p(hd_info[drive].wpcom>>2,++port);
outb_p(nsect,++port); //參數,讀寫扇區總數
outb_p(sect,++port); //參數,起始扇區
outb_p(cyl,++port); //參數,柱面號低8位
outb_p(cyl>>8,++port); //參數,柱面號高8位
outb_p(0xA0|(drive<<4)|head,++port); //參數,驅動器號加磁頭號
outb(cmd,++port); //命令,磁盤控制命令
}
outb_p() 會執行一段匯編代碼, 里面很重要的一句 :outb %%al,%%dx,就是向磁盤端口寫數據。
(3)磁盤中斷請求處理
當磁盤處理完成或發生錯誤是就會發出中斷信號,此時CPU響應中斷請求,并調用磁盤中斷處理程序:hd_interrupt:
hd_interrupt:
......
1: jmp 1f
1: xorl %edx,%edx
xchgl do_hd,%edx #do_hd是一個函數指針,被賦值為 write_intr() 或 read_intr(),
#看一下前面提到的 hd_out() 調用過程就會明白了。這里將edx設置為 do_hd
testl %edx,%edx
jne 1f
movl $unexpected_hd_interrupt,%edx
1: outb %al,$0x20
call *%edx # "interesting" way of handling intr.
# 調用 “edx” 函數
......
iret
(4)磁盤處理完成產生中斷,CPU處理中斷并將磁盤返回數據加入緩沖區,最后喚醒進程
read_intr() 函數會將磁盤控制器中的數據復制到請求項指定的緩沖區中。在執行read_intr() 時會調用函數 end_request(1), 該函數會將進程喚醒。write_intr() 的處理過程與 read_intr() 類似。
到此如何通過盤塊使用磁盤就分析完畢了。不過用盤塊號來使用磁盤是相當麻煩的,程序員忍忍也就算了,用戶怎能受得了如此折磨。下一章將介紹如何通過文件來使用磁盤。
圖解
Linux0.11 通過文件來使用磁盤的過程涉及到了許多概念,以及概念之間復雜錯亂的關系,容易讓人困惑,本章主要是對 Linux0.11 通過文件來使用磁盤的過程進行梳理?!禠inux內核完全剖析——基于0.12內核》的第12章:文件系統,對這些概念及其之間的關系進行了相當精彩的分析,推薦大家閱讀
上一章分析了生磁盤的使用方法,即通過盤塊號使用磁盤。但許多人連扇區都不知道是什么? 要求他們根據盤塊號來訪問磁盤顯然是不合適的。我們需要在盤塊上引入更高一層次的抽象概念——文件,人們通過文件來訪問磁盤中的字符序列就會感覺自然多了。用戶在打開一個文件前,先向操作系統提供該文件的文件名,然后操作系統根據文件名找到文件存放的盤塊位置,最后操作系統將文件內容(字符序列)顯示在用戶的眼前。
至此用戶眼中的文件就變成了一串字符序列,用戶可以隨意訪問修改這串字符序列,并且用戶也不用去關心這串字符序列是怎么存放的。而操作系統眼中的文件就變成了字符序列到盤塊集合的映射,即文件建立起了字符序列到盤塊集合的映射關系。
引入文件是對使用磁盤的抽象。為了實現這層抽象,操作系統需要根據用戶提供的文件名找到文件存放的盤塊位置,因此操作系統需要建立一個能夠完成從文件名到盤塊映射的數據結構。暫且把這個數據結構叫做 FCB 吧。
連續結構是一種最自然想到的結構,就像需要存放一堆數據的時候,我們首先會想到用數組是一樣的。順序結構如下:
從圖中可以看出用戶要打開文件名為 test.c 的文件,該文件存放在6、7、8號盤塊,共占用3個盤塊。建立這樣的結構體也不復雜,這里不再多說。
同樣是打開 test.c ,索引結構如下:
索引結構需要在磁盤中留出一個單獨的索引塊,用于存放索引,而這些索引指向的就是文件存放在磁盤中的盤塊位置。從圖中可以看出 test.c 文件按順序存放在磁盤的第9號、17號、1號、10號盤塊中。建立這樣的FCB的結構體同樣也不復雜。
多級索引結構是 Linux0.11 實際使用的一種結構。這里首先需要介紹一個“鼎鼎大名”的數據結構——inode(index node)。inode 是操作系統中定義的一個結構體,inode 中有一個數組成員 short i_zone[9] 里面記錄了文件所占用的盤塊號的信息,操作系統會將 inode 的前32個字節存放磁盤中, short i_zone[9] 就在這32個字節之中:
從圖2.3中可以看出,i_zone[0 - 6] 為直接塊號,它們直接指向數據塊(這里我們暫時把數據塊理解為真正存放著文件數據的盤塊吧,把數據塊與索引塊區分開來)。i_zone[7] 為一次間接塊號,它指向了一個一次間接塊(沒錯就是索引結構中的那個索引塊),一次間接塊又指向了一堆數據塊??梢圆乱幌乱淮伍g接塊可以存放多少個索引呢?因為 i_zone[n] 為 short 類型占2字節,而一個盤塊的大小為 1kB, 因此一次間接塊可以存放 512 個索引。i_zone[8] 為二次間接塊號,原理與 i_zone[7] 差不多,只不過多了一級。當文件的大小小于7個盤塊時,只需要使用 i_zone[0 - 6] 就好,若大于則需要使用 i_zone[7] 甚至 i_zone[8] 了。這樣我們就可以表示很大的文件了,并且對于很小的文件也可以高效訪問,對于中等大小的文件訪問速度也不慢。這里只是粗淺的介紹了一下 inode ,關于 inode 更多的介紹可以參考《Linux內核完全剖析——基于0.12內核》的第12章:文件系統。
的確 i_zone[ ] 就已經形成多級索引結構了,但我們需要通過文件名來獲取文件。我們的 FCB 還沒有建立。多級索引結構已經有了,我們只需要把多級索引結構和文件名關聯起來,我們的 FCB 就建好了:
把上圖做成結構體
struct dir_entry {
unsigned short inode; // i節點號
char name[NAME_LEN]; // 文件名
};
沒錯,這個就是“目錄項”結構體!
再次回顧一下基于多級索引結構通過文件使用磁盤的過程。用戶在打開一個文件前,先向操作系統提供該文件的文件名,然后操作系統根據文件名找文件對應的目錄項。目錄項中存放著 i 節點號,因此操作系統可以從磁盤中將文件的 inode 讀入內存,然后通過 inode 中的 i_zone[] 字段找到文件對應的盤塊號,最后通過盤塊號訪問磁盤,獲取文件內容,并將文件內容顯示在用戶眼前。
本節以對文件進行寫操作為線索,分析 Linux0.11 通過文件使用磁盤部分程序。
(1)調用 open() 打開文件
在讀寫一個文件前,都需要先調用 open() 打開文件,open() 的核心是根據文件名,找到對應的 inode ,并讀入 inode。
int open(const char * filename, int flag, ...)
內核使用文件結構 file ,文件表 file_table,和內存中 i 節點表 inode_table 來管理對文件的訪問操作。open() 會找出 inode_table[NR_INODE] 的一個空閑項,并將讀入的 inode 存放在空閑項中 。然后在找出 file_table[NR_FILE] 的空閑項,填充該空閑項,并讓空閑項的 f_inode 字段指向讀入的 inode 。最后尋找 filp[NR_OPEN] 中的空閑項,并讓該空閑項指向前面剛填充好的file_table[NR_FILE] 中的那個項。open() 的返回值就是剛剛那個 filp[NR_OPEN] 的空閑項的索引。
struct file {
unsigned short f_mode;
unsigned short f_flags;
unsigned short f_count;
struct m_inode * f_inode; // 文件 inode
off_t f_pos; // 文件當前讀寫指針位置(文件光標位置)
};
struct task_struct {
......
struct file * filp[NR_OPEN]; // PCB中的文件指針數組,其中 NR_OPEN = 20
......
};
struct file file_table[NR_FILE]; // 文件表,NR_FILE = 64
struct m_inode inode_table[NR_INODE]; // i 節點表, NR_INODE = 32
進程打開文件時使用的內核數據結構如下:
到此 open() 的工作就結束了。
(2)調用 write()向文件寫
根據系統調用,實際上 write() 的功能時通過 sys_write() 實現的。sys_write() 將利用前面 open() 的返回值,得到文件的 inode。
// 參數 fd 是 open() 的返回值
int sys_write(unsigned int fd,char * buf,int count)
{
struct file * file;
struct m_inode * inode;
if (fd>=NR_OPEN || count <0 || !(file=current->filp[fd])) //獲取 file
return -EINVAL;
if (!count)
return 0;
inode=file->f_inode; // 獲取文件的 inode
......
if (S_ISREG(inode->i_mode)) // 判斷文件類型是否是普通文件
return file_write(inode,file,buf,count); // 該文件為普通文件,調用 file_write() 進行處理
printk("(Write)inode->i_mode=%06o\n\r",inode->i_mode);
return -EINVAL;
}
sys_write() 并沒有直接處理普通文件,而是交給了 file_write() 進行處理。
(3)調用 file_write() 繼續解析
file_write() 共有4個參數,其中 inode 中有最重要的文件所在的盤塊位置信息,filp 中存放著文件光標位置,通過它可以確定文件修改的位置,buf 為寫入的內容,count 為寫入內容的字節數。通過 filp 和 count 可以確認文件修改的范圍。需要注意的是,file_write() 并不是直接將數據寫入盤塊中,而是寫入緩沖區中。
int file_write(struct m_inode * inode, struct file * filp, char * buf, int count)
{
off_t pos;
int block,c;
struct buffer_head * bh;// struct buffer_head 是緩沖開頭結構體
char * p;
int i=0;
if (filp->f_flags & O_APPEND)
pos = inode->i_size; //如果是“追加” 則從文件的末尾開始寫入
else
pos = filp->f_pos; //否則從文件光標位置開始修改,filp->f_pos中記錄了文件光標的位置
while (i<count) {
//BLOCK_SIZE = 1024(一個盤塊的大?。?,pos/BLOCK_SIZE 就是當前修改位置所在的盤塊相對于文件第一個盤塊的盤塊位置
//create_block() 用于獲取盤塊號,create_block() 的返回值是 pos 所在盤塊的盤塊號
if (!(block = create_block(inode,pos/BLOCK_SIZE)))
break;
// bread() 會調用 ll_rw_block(), 而 ll_rw_block()又會調用 make_request() ,然后就是上章講的內容了。
if (!(bh=bread(inode->i_dev,block)))
break;
c = pos % BLOCK_SIZE;//計算出 pos 在盤塊中的相對位置
p = c + bh->b_data; //p指向緩沖塊中開始寫入數據的位置
bh->b_dirt = 1;
c = BLOCK_SIZE-c;
if (c > count-i) c = count-i;
pos += c; //修改 pos
if (pos > inode->i_size) {
inode->i_size = pos;
inode->i_dirt = 1;
}
i += c;
while (c-->0)
*(p++) = get_fs_byte(buf++); //獲取要寫入的內容
brelse(bh);
}
......
}
(4)create_block() 分析
create_block() 中調用了 _bmap() 。_bmap() 的第3個參數為 1 時表示沒有映射時則創建映射,這里的映射是指緩沖區。
int create_block(struct m_inode * inode, int block)
{
return _bmap(inode,block,1);
}
static int _bmap(struct m_inode * inode,int block,int create)
{
struct buffer_head * bh;
int i;
......
if (block<7) { // 若block< 7 直接獲得一個數據塊
if (create && !inode->i_zone[block])
if ((inode->i_zone[block]=new_block(inode->i_dev))) {
inode->i_ctime=CURRENT_TIME;
inode->i_dirt=1;
}
return inode->i_zone[block];
}
// 若減去7個數據塊后剩余的數據塊數小于512,則說明該文件只有一次間接塊號。inode->i_zone[7]是一次間接塊號
block -= 7;
if (block<512) {
if (create && !inode->i_zone[7])
if ((inode->i_zone[7]=new_block(inode->i_dev))) {
inode->i_dirt=1;
inode->i_ctime=CURRENT_TIME;
}
......
}
// 存在二次間接塊號,那么繼續處理
block -= 512;
if (create && !inode->i_zone[8])
......
}
至此,可能還會有些疑問,比如操作系統如何通過 inode 號找到 磁盤中文件的 inode 的呢?目錄項是存在磁盤中還是在系統啟動后操作系統在內存中建立目錄項的呢?如果是在內存中建立的那么 OS 啟動時是怎么知道磁盤中有哪些文件的呢?還有寫文件時寫入的緩沖區是怎么回事?等等一系列的細節問題。其實不必糾結于操作系統對這些細節是如何實現的,正如李治軍老師課程中提到的一種思想:我們能解決這些細節問題嗎?我們可以猜一下操作系統如何實現這些細節。
下一章將分析目錄與文件系統。