我們在學習 C# 的過程中,總會聽到一個詞叫做 核心態
,比如說用 C# 讀寫檔案,會涉及到程式碼從 使用者態
到 核心態
的切換,用 HttpClient
獲取遠端的資料,也會涉及到 使用者態
到 核心態
的切換,那到底這是個什麼樣的互動流程?畢竟我們的程式是無法操控 核心態
,今天我們就一起探索下。
我們知道人間和地府的交界處在 鬼門關
,同樣的道理 使用者態
和 核心態
的交界處在 ntdll.dll
層,畫個圖就像下面這樣:
作業系統為了保護 核心態
的程式碼,在使用者態直接用指標肯定是不行的,畢竟一個在 ring 3,一個在 ring 0,而且 cpu 還做了硬體保護兜底,那怎麼進入呢? 為了方便研究,先上一個小例子。
我們使用 File.ReadAllLines()
實現檔案讀取,程式碼如下:
internal class Program
{
public static object lockMe = new object();
static void Main(string[] args)
{
var txt= File.ReadAllLines(@"D:\1.txt");
Console.WriteLine(txt);
Console.ReadLine();
}
}
在 Windows 平臺上,所有核心功能對外的入口就是 Win32 Api
,言外之意,這個檔案讀取也需要使用它,可以在 WinDbg 中使用 bp ntdll!NtReadFile
在 鬼門關 處進行攔截。
0:000> bp ntdll!NtReadFile
breakpoint 0 redefined
0:000> g
ModLoad: 00007ffe`fdb20000 00007ffe`fdb50000 C:\Windows\System32\IMM32.DLL
ModLoad: 00007ffe`e2660000 00007ffe`e26bf000 C:\Program Files\dotnet\host\fxr\6.0.5\hostfxr.dll
Breakpoint 0 hit
ntdll!NtReadFile:
00007ffe`fe24c060 4c8bd1 mov r10,rcx
哈哈,很順利的攔截到了,接下來用 uf ntdll!NtReadFile
把這個方法體的組合程式碼給顯示出來。
0:000> uf ntdll!NtReadFile
ntdll!NtReadFile:
00007ffe`fe24c060 mov r10,rcx
00007ffe`fe24c063 mov eax,6
00007ffe`fe24c068 test byte ptr [SharedUserData+0x308 (00000000`7ffe0308)],1
00007ffe`fe24c070 jne ntdll!NtReadFile+0x15 (00007ffe`fe24c075)
00007ffe`fe24c072 syscall
00007ffe`fe24c074 ret
00007ffe`fe24c075 int 2Eh
00007ffe`fe24c077 ret
從組合程式碼看,邏輯非常簡單,就是一個 if 判斷,決定到底是走 syscall
還是 int 2Eh
,很顯然不管走哪條路都可以進入到 核心態
,接下來逐一聊一下。
相信在偵錯界沒有人不知道 int 是幹嘛的,畢竟也看過無數次的 int 3
,本質上來說,在核心層維護著一張 中斷向量表
,每一個數位都對映著一段函數程式碼,當你開啟電腦電源而後被 windows 接管同樣藉助了 中斷向量表
,好了,接下來簡單看看如何尋找 3 對應的函數程式碼。
windbg 中有一個 !idt
命令就是用來尋找數位對應的函數程式碼。
lkd> !idt 3
Dumping IDT: fffff804347e1000
03: fffff80438000f00 nt!KiBreakpointTrap
可以看到,它對應的核心層面的 nt!KiBreakpointTrap
函數,同樣的道理我們看下 2E
。
lkd> !idt 2E
Dumping IDT: fffff804347e1000
2e: fffff804380065c0 nt!KiSystemService
現在終於搞清楚了,進入核心態的第一個方法就是 KiSystemService
,從名字看,它是一個類似的通用方法,接下來就是怎麼進去到核心態相關的 讀取檔案 方法中呢?
要想找到這個答案,可以回頭看下剛才的組合程式碼 mov eax,6
,這裡的 6 就是核心態需要路由到的方法編號,哈哈,那它對應著哪一個方法呢? 由於 windows 的閉源,我們無法知道,幸好在 github 上有人列了一個清單:https://j00ru.vexillium.org/syscalls/nt/64/ ,對應著我的機器上就是。
從圖中可以看到其實就是 nt!NtReadFile
,到這裡我想應該真相大白了,接下來我們聊下 syscall
。
syscall 是 CPU 特別提供的一個功能,叫做 系統快速呼叫
,言外之意,它藉助了一組 MSR暫存器
幫助程式碼快速從 使用者態
切到 核心態
, 效率遠比走 中斷路由表
要快得多,這也就是為什麼程式碼會有 if 判斷,其實就是判斷 cpu 是否支援這個功能。
剛才說到它藉助了 MSR暫存器
,其中一個暫存器 MSR_LSTAR
存放的是核心態入口函數地址,我們可以用 rdmsr c0000082
來看一下。
lkd> rdmsr c0000082
msr[c0000082] = fffff804`38006cc0
lkd> uf fffff804`38006cc0
nt!KiSystemCall64:
fffff804`38006cc0 0f01f8 swapgs
fffff804`38006cc3 654889242510000000 mov qword ptr gs:[10h],rsp
fffff804`38006ccc 65488b2425a8010000 mov rsp,qword ptr gs:[1A8h]
...
從程式碼中可以看到,它進入的是 nt!KiSystemCall64
函數,然後再執行後續的 6
對應的 nt!NtReadFile
完成業務邏輯,最終也由 nt!KiSystemCall64
完成 核心態 到 使用者態 的切換。
知道了這兩種方式,接下來可以把圖稍微修補一下,增加 syscall
和 int xxx
兩種入關途徑。
通過組合程式碼分析,我們終於知道了 使用者態
到 核心態
的切換原理,原來有兩種途徑,一個是 int 2e
,一個是 syscall
,加深了我們對 C# 讀取檔案 的更深層理解。