自幹作業系統 - 初探 Timer 原理


自幹作業系統 - Simple OS 的過程中,我的學習方法是快速的先取得成就感,透過與 AI 協作過程,快速刻出能動的、完整的,只用了三週就完成實作 (03/12 - 03/27),但是非常古早味的作業系統,說白話:先能動再說,好聽的就是 麻雀雖小,五臟俱全。但往下探索,很容易暴露一個現實:知其然,不知所以然,有些東西就是沒有透測的理解。但也是這份好奇心停留在心裡,我開始往下了解與深究背後的原理以及知識,同時也連結到以前的經驗與知識 (或者資訊)。

Day12 - Timer/PIT (Programmable Interval Timer) 是我開始深究的題目,底下是我好奇的幾個點:

  • timer.c 中出現的狀聲詞 tick 以及 Magic Number 1,193,180 覺得很好奇
  • timer.c 裡的 timer_handler()Game Loop 有點像

整理一下探索過程學習到的資料,以及自問自答相關資料。

註:內容僅是自學的一些筆記,如果有發現資訊不正確,後者描述錯誤,請不令給予指教,感謝。


Timer in SimpleOS

計算機構成 Timer (計時器) 整個工作的角色有以下:

  1. CPU: 中央處理單元,負責計算資料
  2. PIT: Programmable Interval Timer (可程式化間隔計時器),負責產生固定的頻率為 1,193,180 Hz,通常是 INTEL 8253/8254 晶片,背後的物理原理是透過 石英振盪器 (Crystal Oscillator) 輸出固定的頻率
  3. PIC: Programmable Interrupt Control (可程式化中斷控制器),當外部硬體 (PIT、鍵盤、滑鼠、網卡) 發出中斷訊號時,負責執行仲裁角色,通常是 INTEL 8259 晶片
  4. OS: 作業系統,介於硬體與應用層之間的軟體。

註:PIT, PIC 這兩顆晶片在現代計算機裡,會被整合在主機板上的 南橋晶片 組內,但邏輯與職責上還是分開的。

用一個例子來說明,這三個角色的運作:

  • 主管 (CPU) 在負責對外溝通與協調,正在忙著開會中;而團隊成員 (其他硬體: 滑鼠、鍵盤) 有事要找他;秘書 (PIC) 則負責管理誰可以去敲老闆的門;行事曆 (PIT) 則是一個時間軸。
  • 當整點時間到了 (PIT),秘書 (PIC) 會根據 來敲門的團隊成員 (滑鼠與鍵盤) 優先序,安排跟老闆 (CPU) 碰面。
  • PIT 就是個固定計數器,一直在數數 (tick),一個單位時間的數值到了,就會跟 PIC 講,由 PIC 根據次序安排硬體跟 CPU 溝通。

SimpleOS 的 timer.c 有以下介面:

1
2
void init_timer(uint32_t frequency);
void timer_handler(void);

其中 init_timer() 負責出根據 PIT 的工作頻率與目標中斷次數 (frequency),計算出 tick 的最小單位,然後把 tick 最小單位的值寫到 PIT。而 timer_handler() 則是負責記錄每次中斷的 tick 累加。

整個 Timer 概念在 Simple OS 裡大概如下:

PIT - INTEL 8254

計數器晶片 INTEL 8254 負責產生的規律電子訊號,他的基礎頻率是 1.19318 MHz (1,193,180 Hz),頻率則透過 石英振蕩器生成 (Crystal Oscillator)1,193,180 這個數字緣由是彩色電視 NTSC 的副載波頻率(14.31818 MHz)經過 12 分頻計算得來。在電子工程中,「分頻 (Frequency Division)」指的是將一個高頻率的訊號,轉換成較低頻率訊號的過程。簡單說「12 分頻」就是把原始頻率除以 12。

14.31818 MHz / 12 = 1,193,181 Hz

作業系統約定成俗的會使用 1,193,180 或 1,193,182 當常數

80 年代 (IBM PC 誕生初期) 工程師為了省錢,不想在主機板上插滿各種不同頻率的石英鐘,於是採用了「一魚多吃」策略:

  • 主頻源:只買一個最便宜、大量生產的 14.31818 MHz 石英震盪器 (當時因為彩色電視普及,這種零件極度便宜)。
  • 給顯卡用:這個頻率直接給彩色顯示卡使用,因為它剛好是 NTSC 電視訊號的 4 倍。
  • 給 CPU 用:經過 3 分頻 (14.318 MHz / 3 = 4.77 MHz),這就是初代 IBM PC 處理器的時脈。
  • 給計時器用:經過 12 分頻 (14.318 MHz / 12 = 1.19 MHz),這就是 8254 PIT 晶片拿到的工作頻率。

PIC - INTEL 8259

中斷控制器 (Programmable Interrupt Controller, PIC) 通常是 Intel 8259 這顆晶片,是介於所有硬體設備(如 PIT、鍵盤、滑鼠)與 CPU 之間的「通訊官」。PIC 的角色如同警衛,在 PIC 向 CPU 發出中斷訊號後,會自動擋住後續的所有硬體中斷訊號。如果 OS 不回覆 EOI (End of Interrupt) 指令給 PIC,PIC 會判定 CPU 仍在處理舊的中斷,導致 PIC 停止發送任何新的中斷訊號給 CPU,最終造成 OS 無法接收新的 Tick 或硬體事件,系統表現如同「當機」。

OS 透過發送 EOI (End of Interrupt) 指令告訴 PIC:

OS 已經處理完當前的中斷請求,PIC 可以發送下一個中斷訊號給 CPU 了。

完成告知之後,OS 就可以做所謂的 Context Switch,把 CPU 的使用權讓出去給其他的 Task,這個過程則是透過 Scheduler 執行。在 SimpleOS 的實作,是在 timer_handler() 裡面最後調用 schedule(),實踐 搶佔式多工 (Preemptive Multitasking),強行切換任務,達成「每個程式都在同時執行」的假象。


timer.c#init_timer() - 初始節拍器最小單位:Tick

tick (狀聲詞, 滴答聲) 用來記錄 作業系統 的 節拍數虛擬時間,定義作業系統的節奏感,從開機就開始計算,每次的累加,都代表著作業系統時間的流逝。

實際的運作原理是利用 INTEL 8254 的基頻 - 1,193,180 Hz,我們想在每秒產生 100 次中斷 / 100 個 tick,在 timer.c#init_timer() 計算方式如下:

1
2
3
void init_timer(uint32_t frequency) {
// divisor = 1,193,180 / 100 = 1,193.180
uint32_t divisor = PIT_CLOCK_RATE / frequency;

PIT 每經過 1193.18 個震盪週期,就是一個 tick (一拍),每秒則有 100 個 tick。

這是一個 原子操作 或受 中斷保護 的操作,確保所有依賴時間的 SysCall (如 sleep) 都有統一的參考。所以 tick 在 c 語言裡需要宣告 volatile,保證 tick 可見性 (Visibility)。如果沒有它,在多工或編譯器優化下,一個 while(tick < target) 的無窮迴圈可能會因為編譯器認為 tick 在迴圈內沒被修改,而將其優化為死循環,永遠不重新讀取記憶體中的新值。

題外話:編曲軟體 (Digial Audio Workstation, DAW) 中常用的 Piano Roll 畫面,為了要可以紀錄更自然、更真實的演奏時間,每個拍子的單位不是只有固定在節奏數,而是有個單位稱為 PPQ (Pulses Per Quarter Note),例如 PPQ=360,代表一拍可以再細分 360 個等分,透過這樣的方式可以紀錄演奏更細緻的表情,像是琶音這種技巧。現代的 DAW 像是 Logic Pro、FL Studio 預設為 960 PPQ。下圖由 Google 產生


timer.c#timer_handler() - 作業系統的 Game Loop

timer.c#timer_handler() 每一個週期會被 IRQ32 中斷 interrupts.S 呼叫一次,整個內容最重要的幾件事:

  1. 累加 Tick,也就是現在是第幾個 Clock
  2. 告訴 中斷控制器,這個 Tick 結束,請重新分配 Task
  3. 讓 OS 透過 Task Scheduler 執行 Context Switch,重新分配 CPU

整個 Timer 運作流程如下:

timer_handler() 是透過 PIC 固定週期就被調用一次,概念類似於 Game Loop 裡的 FPS。但不同的是:

Game Loop 會因為 Update 處理的效能 (通常是 2D/3D 圖形渲染),因而影響 FPS 的大小。

底下是我在 純手工遊戲開發 - Java 2D RPG GameGame Loop 精簡過後的片段邏輯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public void run() {
double drawInterval = 1000000000 / FPS; // 0.016666 seconds per frame
double delta = 0;
long lastTime = System.nanoTime();
long currentTime;
long timer = 0;
int drawCount = 0;

while(gameThread != null) {
currentTime = System.nanoTime();
delta += (currentTime - lastTime) / drawInterval;
timer += currentTime - lastTime;
lastTime = currentTime;

if(delta >= 1) {
update();
delta--;
drawCount++;
}

if (timer >= 1000000000) {
drawCount = 0;
timer = 0;
}
}
}

public void update() { // 類似 timer_handler
player.update();
npc.update();
monster.update();
// ... ooxx.update();
}

OS 不能因為 task 慢了,tick 計數就停下來,這在電腦科學中稱為 不可屏蔽性強制性 (Preemption)。遊戲中的 Update() 是協作式的 (前一個跑完下一個才跑);OS 的 timer_handler 是侵入式的(管你跑完沒,硬體訊號強行插隊)。

底下我整理了我自己的理解的差異:

特性 Game Loop (遊戲) OS Tick (Simple OS)
迴圈本體 while(running) { ... } isr32: -> call timer_handler
頻率控制 軟體自行計算 dt 並 Sleep 硬體晶片固定發送電氣訊號
執行保證 若卡住,下一影格就順延 若卡住,硬體訊號會強制插隊 (或遺失)
主要任務 更新遊戲邏輯、渲染畫面 計時、統計 CPU 佔用、強制換人 (schedule)

Game Loop:主動式。跑者自己看手錶,跑太快就 Sleep。
OS Tick:被動式。硬體監工每隔一段時間「啪」地拍桌子,強制 CPU 停下工作,執行 OS 的家務事。


小結

在 Linux 面前,我的知識是匱乏的、無知的,我在看到 Linux Kernel 是尊敬的,他是人類智慧與文明的精華。開啟 Simple OS 自幹的初衷,就是覺得我自己知識匱乏的緊,即使我已經工作數十餘載,但常常還是覺得自己懂的太少。

洋蔥是學習是我的方法,一圈一圈的扒開,逐漸明朗,好玩又有趣!再配合很多既有的課程,像是 Linux 核心設計: Timer 及其管理機制、以前在研究 分散式系統 讀過的論文 Time, clocks, and the ordering of events in a distributed system – Leslie Lamport (1978),利用幫我 AI 探索,這個過程是有趣且豐富的!

但如果只是把這一切都交給 AI Agent,那最後我將得到一個能用的東西,但我依舊停在原點,呈現 淺薄思考知識匱乏 的狀態。

未完

本來還有還有很多想寫,不過再寫下去篇幅太長了,先做個紀錄,之後慢慢來補。

  • timer.c 概念,那麼 User Space 的 sleep() 是怎麼被實作出來的?
    • 已經完成實作,不過內容篇福有點多,加上要講到 Task Scheduler 會有點複雜,需要放到獨立的一篇來講。
  • 實務上曾經遇過跟時間有關的想法,包含 sleep(), ntpk8s cpu=100m 的意思
  • 摘要現代 Linux Kernel 跟 timer / tick 相關的資訊
  • 探索程式語言 Java 21 sleep() 的實作
  • 以前在研究 分散式系統 讀過的論文 Time, clocks, and the ordering of events in a distributed system – Leslie Lamport (1978) 的一些想法

延伸閱讀

站內文章

參考資料



Comments

  • 全站索引
  • 關於這裏
  • 關於作者
  • 學習法則
  • 思考本質
  • 一些領悟
  • 分類哲學
  • ▲ TOP ▲