硬中斷和軟中斷
中斷有很多種,但都是程式執行過程中的強制性轉移,轉移到作業系統核心相應的處理程式
除了主動讓出CPU外,程序的排程都需要在程序外(核心)進行,這就需要從程序的指令流裡切換出來
中斷處理程式是與程序無關的核心指令流,起到切出程序指令流的作用
執行完核心程式碼後,CPU會檢測是否需要程序排程
若需要則切換程序(本質上是切換核心堆疊),否則根據函式呼叫堆疊返回iret
到原程序繼續執行
ntel定義的中斷型別
硬中斷
CPU的兩根引腳(可遮蔽和不可遮蔽)
CPU在執行每條指令後會檢測這兩根引腳的電平,高電平則有中斷請求
一般外設以這種方式中斷CPU的,如時鐘、鍵盤、硬碟等
軟中斷/異常
包括除零錯誤、系統呼叫、除錯斷點等在CPU執行指令過程中發生的各種特殊情況統稱為異常
異常會導致程式無法繼續執行,而跳轉到CPU預設的處理函式
異常分為三類
- 故障fault
出問題,但可以恢復到當前指令
如除零錯誤,缺頁中斷等 - 退出abort
不可恢復的嚴重故障,導致程式無法繼續執行
如連續發生故障double fault - 陷進trap
程式主動產生的異常,在執行當前指令後發生
程式藉助中斷機制進行轉移,從CPU的處理機制上,與其他中斷沒有區別
如系統呼叫int 0x80
及除錯斷點指令int 3
程序排程時機
schedule函式
實現程序排程,在執行佇列中找到一個程序,分配CPU
排程一次即執行一次schedule函式,位於linux-3.18.6/kernel/sched/core.c#2865
呼叫schedule函式的兩種方法
- 程序主動呼叫schedule()
如程序呼叫阻塞的系統呼叫等待外設或主動睡眠等,最終都會在核心中呼叫schedule函式 - 鬆散呼叫
核心程式碼可以隨時呼叫schedule()
使當前核心路徑(中斷處理程式或核心執行緒)讓出CPU
上下文
一般,CPU在任意時刻都處於三種情況之一
執行於使用者空間,執行使用者程序上下文
執行於核心空間,處於程序(一般為核心執行緒)上下文
執行於核心空間,處於中斷(中斷處理程式ISR,包括系統呼叫處理過程)上下文
應用程式透過系統呼叫陷入核心,或外設產生中斷,抬高CPU中斷引腳電平,此時CPU處於中斷上下文
中斷上下文的get_current獲取一個指向當前程序的指標,指向被中斷程序或即將執行的就緒程序
為了系統的執行效率,會限制在中斷上下文中呼叫其他核心程式碼
核心執行緒以程序上下文的形式執行在核心空間,本質上還是程序,但有呼叫核心程式碼的許可權,如呼叫schedule函式讓出CPU
程序排程時機
當核心返回使用者空間時,核心會檢查need_resched
標誌,從而決定是否呼叫schedule函式,即排程程序
核心執行緒或中斷處理程式中在需要暫時中止當前執行路徑的位置時,都可以直接呼叫schedule(),如等待某個資源就緒
程序排程時機如下:
使用者程序透過特定的系統呼叫主動讓出CPU
中斷處理程式在核心返回使用者態時進行排程
核心執行緒主動呼叫schedule函式讓出CPU
中斷處理程式主動呼叫schedule函式讓出CPU
Linux中沒有執行緒概念,從核心角度看,不管是程序還是核心執行緒,都對應一個task_struct結構體,本質上都是程序
Linux系統在使用者態實現的執行緒庫pthread是透過在核心中多個程序共享一個地址空間實現的
排程策略與演算法
排程演算法就是從就緒佇列中選一個程序的策略
程序分類1
- I/O消耗型程序
需要大量檔案讀寫操作或網路讀寫操作,如檔案伺服器的服務程序
特點是CPU負載不高,大量時間都在等待讀寫資料 - CPU消耗型程序
如影片編碼轉換、加密解密演算法等
特點是CPU佔用率高,沒有太多硬體讀寫操作
程序分類2
- 互動式程序
有大量的人機互動,程序不斷處於睡眠狀態,等待使用者輸入,如VIM編輯器
這就要求系統響應時間短 - 批處理程序
後臺執行,佔用大量系統資源
對系統響應性要求不高,如編譯器 - 實時程序
對排程延遲要求高
Linux系統解決方案
對於實時程序,採用FIFO或Round Robin時間片輪轉的排程策略
對於其他程序,採用CFS(Completely Fair Scheduler)排程
排程策略
/*
* scheduling policies
*/
#define SCHED_NORMAL 0 // 普通程序
#define SCHED_FIFO 1 // 實時程序
#define SCHED_RR 2 // 實時程序
#define SCHED_BATCH 3 // 保留,未實現
#define SCHED_IDLE 5 // idle程序
Linux中根據程序優先順序來區分普通程序和實時程序
核心程序優先順序為0~139,0為最高優先順序
實時程序優先順序取值為0~99
普通程序只具有nice值,對映到優先順序為100~139
子程序會繼承父程序的優先順序,對於實時程序,Linux系統會盡量將其排程延時在一個時間期限內,但不能保證總是如此
SCHED_FIFO採用先進先出的排程策略,對於所有相同優先順序的程序,最先進入就緒佇列的程序總能優先獲得排程,直到其主動放棄CPU
SCHED_RR採用更公平的輪轉Round Robin策略,使得相同優先順序的實時程序輪流獲得排程,每次執行一個時間片
SCHED_NORMAL使用在Linux-2.6.23核心版本中引入的CFS排程策略,且每個程序能夠分配到的CPU時間佔比跟系統當前負載有關
因為互動式程序的執行時間很少,所以CFS演算法對互動式程序的響應較好
CFS完全公平排程演算法,是基於權重的動態優先順序排程演算法,位於kernel/sched/fair.c
每個程序每次佔用CPU後能執行的時間ideal_runtime
由程序權重決定,且保證在某個時間週期__sched_period
內執行佇列裡的所有程序都能被排程至少一次
排程週期__sched_period
__sched_period = nr_running * sysctl_sched_min_granularity
nr_running
為程序數
sysctl_sched_min_granularity
預設值為0.75ms
程序越多,排程週期越長,上限預設值為8ms
理論執行時間ideal_runtime
ideal_runtime = __sched_period * 程序權重 / 執行佇列總權重
每次程序獲取CPU後最長可佔用CPU的時間為ideal_runtime
虛擬執行時間vruntime
每個程序都有一個vruntime
,排程時選擇vruntime
值最小的程序
vruntime
維護在時鐘中斷裡,每次時鐘中斷及程序就緒、阻塞等狀態變化時更新
計算方法:
if se->load.weight != NICE_0_LOAD
vruntime+= delta_exec;
else
vruntime+= delta_exec* NICE_0_LOAD/se.load->weight
說明如下
se即schedule entity,儲存程序排程相關屬性的結構體
se->load.weight表示當前程序權重
NICE_0_LOAD表示nice值為0的程序權重
delta_exec表示當前程序本次執行時間
為避免長時間佔用CPU,新程序的vruntime會設定為一定的初始值,而非0
若程序是0優先順序,其虛擬時間等於實際執行的物理時間,權重越大,虛擬時間增長的越慢
每次更新完vruntime後,會進行一次檢查,決定是否需要設定排程標誌need_schedule
系統中斷返回時會檢查該標誌,並按需進行程序排程
時鐘中斷週期
Linux傳統預設時鐘週期為10ms,由param.h的HZ定義
Linux-3.9核心版本中為4ms,在boot目錄下的config-x.x.x檔案中的CONFIG_HZ配置,時鐘中斷為1/CONFIG_HZ
秒
Linux傳統優先順序和權重的轉換關係是經驗值
static const int prio_to_weight[40]={
/* -20 / 88761, 71755, 56483, 46273, 36291, 1411,
/ -15 / 29154, 23254, 18705, 14949, 11916, 1412,
/ -10 / 9548, 7620, 6100, 4904, 3906, 1413,
/ -5 */ 3121, 2501, 1991, 1586, 1277,
...
}
就緒程序排序和儲存
Linux採用紅黑樹rb tree儲存就緒程序指標
當程序進入就緒佇列時,根據vruntime
排序,排程時選擇最左邊的葉子節點即可
程序上下文切換
恢復執行一個程序之前,核心必須確保每個暫存器裝入了掛起程序時的值
程序上下文包括:
- 使用者地址空間
程式程式碼、資料、使用者堆疊等 - 控制資訊
程序描述符、核心堆疊等 - 硬體上下文
暫存器相關值
CR3暫存器,代表程序頁目錄表,即地址空間、資料等
ESP暫存器,代表程序核心堆疊
struct thread、程序控制塊、核心堆疊儲存於連續8KB區域中,透過ESP獲取地址
EIP暫存器即其他暫存器,代表程序硬體上下文,即要執行的下條指令
安裝8086體系結構的設計,程序切換使用一個專用的段型別(任務狀態段task state segment,TSS)存放硬體上下文
Linux並不使用TSS進行硬體上下文切換,但依然為系統中每個不同的CPU建立一個TSS,主要儲存不同執行級別(ring0~3)的堆疊資訊
每個程序描述符包含一個型別為thread_struct的thread欄位,只要程序被切換出去,核心就將硬體上下文儲存在這個結構中
thread欄位包含大部分CPU暫存器,但不包含如eax、ebx等通用暫存器,其值儲存在核心堆疊中
在實際程式碼中,每個程序切換基本由兩個步驟組成
切換頁全域性目錄CR3以載入一個新的地址空間,這樣不同程序的虛擬地址就會經過不同的頁錶轉換為不同的實體地址
切換核心態堆疊和硬體上下文
核心程式碼分析
schedule函式選擇一個新程序執行,並呼叫context_switch進行上下文切換,位於linux-3.18.6/kernel/sched/core.c#2336
呼叫switch_mm切換CR3
呼叫宏switch_to進行硬體上下文切換
地址空間切換
關鍵程式碼load_cr3,將下一程序的頁表地址裝入CR3,此時,所有虛擬地址轉換都使用next程序的頁表項
因為所有程序對核心地址空間是相同的,所以在核心態時,使用任意程序的頁錶轉換的核心地址都是相同的
static inline void context_switch(struct rq* rq, struct task_struct* prev, struct task_struct* next){
...
if(unlikely(!mm)){ // 若切換進來的程序的mm為空切換,核心執行緒mm為空
next->active_mm= oldmm; // 將共享切換出去程序的active_mm
atomic_inc(&oldmm->mm_count); // 有一個程序共享,所有引用計數加一
// 將per cpu保留cpu_tlbstate狀態設為LAZY
enter_lazy_tlb(oldmm, next);
} else { // 普通mm不為空,則呼叫switch_mm切換地址空間
switch_mm(oldmm, mm, next);
}
...
// 切換暫存器狀態和棧
switch_to(prev, next, prev);
...
}
static inline void switch_mm(struct mm_struct* prev, struct mm_struct* next, struct task_struct* tsk){
...
if(!cpumask_test_and_set_cpu(cpu, mm_cpumask(next))){
load_cr3(next->pgd); // 地址空間切換
load_LDT_nolock(&next->context);
}
#endif
}
堆疊及硬體上下文
該部分是內聯彙編程式碼
宏switch_to,位於linux-3.18.6/arch/x86/include/asm/switch_to.h#31
#define switch_to(prev, next, last)
do{
/*
* context-switching clobbers all registers, so we clobber
* them explicitly, via unused output variables.
* (EAX and EBP is not listed because EBP is saved/restored
* explicitly for wchan access and EAX is the return value of
* __switch_to())
*/
unsigned long ebx, ecx, edx, esi, edi;
asm volatile(
"pushfl\n\t" // 儲存當前程序flags
"pushl %%ebp\n\t" // 將當前程序堆疊基址壓棧
"movl %%esp, %[prev_sp]\n\t" // 儲存ESP,將當前堆疊棧頂儲存
"movl %[next_sp], %%esp\n\t" // 更新ESP,將下一棧頂儲存到ESP
// 完成核心堆疊的切換
"movl $1f, %[prev_ip]\n\t" // 儲存當前程序EIP
"pushl %[next_ip]\n\t" // 將next程序起點壓棧,即next程序的棧頂
__switch_canary
// next_ip一般是$1f,對於新建立的子程序是ret_from_fork
"jmp __switch_to\n" // prev程序中,設定next程序堆疊
// jmp不同於call,是透過暫存器傳遞引數,而不是透過堆疊傳遞
// 所以ret時,彈出的是之前壓入棧頂的next程序起點
// 完成EIP的切換
"1:\t" // next程序開始執行
"popl %%ebp\n\t"
"popfl\n"
// 輸出量定義
: [prev_sp] "=m" (prev->thread.sp), // 儲存prev程序的esp
[prev_ip] "=m" (prev->thread.ip), // 儲存prev程序的eip
"=a" (last),
// 要破壞的暫存器
"=b" (ebx), "=c" (ecx), "=d" (edx),
"=S" (esi), "=D" (edi)
__switch_canary_oparam
// 輸入變數
: [next_sp] "m" (next->thread.sp), // next程序核心堆疊棧頂地址,即esp
[next_ip] "m" (next->thread.ip), // next程序的原eip
// [next_ip]下一個程序執行起點,一般是$1f,對於新建立的子程序是ret_from_fork
// regparm parameters for __switch_to():
[prev] "a" (prev),
[next] "d" (next)
__switch_canary_iparam
: // 重新載入段暫存器
"memory"
);
}
上述程式碼的虛擬碼如下
pushfl
pushl %ebp // s0
prev->thread.sp= %esp // s1
%esp= next->thread.sp // s2
prev->thread.ip= $1f // s3
push next->thread.ip // s4
jmp _switch_to // s5
1f:
popl %%ebp // s6,與s0對齊
popfl
虛擬碼中可以看出,s0兩句在prev的堆疊中將eflag和ebp暫存器壓棧
s1將當前esp暫存器儲存到程序PCB的prev->thread.ip中
s2載入next->thread.sp到ESP暫存器,執行該指令後,程序從prev變為next,說明如下
每個程序的程序控制塊與核心堆疊在核心中佔連續8KB記憶體
核心中get_current用來獲取當前程序,利用ESP暫存器低14位置0來實現8KB對齊
所以ESP暫存器切換後,再呼叫get_current得到的程序指標就是next程序
s3儲存$1f位置的記憶體地址到prev->thread.ip
s4將$1f壓棧,此時是next程序的堆疊
s5跳轉到c函式,通常call與return搭配,call會自動壓棧返回地址,return會自動彈出返回地址
jmp不會壓棧,所以此時return彈出的是$1f位置,即s4+s5模擬了一個call,且自由控制__switch_to的返回地址,如$1f地址
s6此時下一程序執行,對稱地將s0壓棧的資料彈出
接下來的程式碼是函式呼叫堆疊
堆疊儲存了程序所有的函式呼叫歷史
先返回到next程序上次切換讓出CPU時的schedule()
中,然後返回到呼叫schedule()
的系統呼叫處理過程中
而系統呼叫又是int 0x80
觸發的,所以透過中斷上下文返回到系統呼叫被觸發的地方,接著執行使用者空間程式碼
此時返回路徑是根據next程序堆疊中儲存的返回地址來返回的,所以是返回到next程序中
程序上下文切換時需要儲存程序切換相關資訊,如thread.sp和thread.ip,這與中斷上下文切換是不同的
中斷是在一個程序中從程序的使用者態到程序的核心態,或從程序的核心態返回到程序的使用者態
一般程序上下文切換是巢狀在中斷上下文切換中的
比如系統呼叫作為一種中斷,先陷入核心,即發生中斷儲存現場
在系統呼叫處理過程中,呼叫了schedule()
發生程序上下文切換
當系統呼叫返回到使用者態時會恢復現場
至此完成了儲存現場和恢復現場,即完成了中斷上下文切換