2603,系统调用
系统调用不在DLL里,但你的代码一行也离不开它
窗口的系统调用是不是都封装在DLL里?它很关键,因为它触及了应用和系统交互的本质,一个很多人都会搞混的地方.
答案很明确:不是.kernel32.dll这类文件提供的,是用户态的API接口,而不是系统调用的实现自身.可把它们理解为一套稳定,公开的函数合约,应用调用这些函数来请求系统服务.
而在受保护的内核模式中保存真正的系统调用代码.当调用kernel32.dll里的函数时,它会执行一系列准备工作,最终一个特殊的指令(syscall)触发模式切换,将执行权交给内核.
至于printf这类标准库函数,它的首要目标是实现跨平台.因此在窗口环境下,它必须依赖kernel32.dll提供的这套标准接口来完成输出等底层任务.
接着,就来完整地走一遍该流程:从你的代码出发,穿过用户态的DLL,最终抵达内核的执行体.
一,系统调用的真相
先得纠正一个流传甚广的误解:窗口的系统调用``API,并不直接封装在那些.dll文件里.
你看到的kernel32.dll,user32.dll,甚至gdi32.dll,它们只是微软提供给你的用户态接口.
真正的权力中心,在系统的内核,一个叫做Ring0的特殊领域.而你的应用,及你加载的DLL,都活在一个权限受限的Ring3用户态.
从Ring3到Ring0,与是穿越一道不可逾越的鸿沟,这道鸿沟的唯一合法通道,就是系统调用.
来走一遍流程.当你的代码想写点什么时,比如写个文件,调用kernel32.dll里的WriteFile函数.kernel32.dll帮你搞定了一切,但它其实只是个二传手.
它内部会整理一下参数,然后转身去调用ntdll.dll.ntdll.dll,这才是真正接近真相的地方.该DLL里的函数,比如NtWriteFile,是窗口本地API(Native,API)的一部分.
它的命名风格高度统一,清一色的Nt或Zw开头.它的主要工作,就是准备好执行系统调用要求的一切,然后执行一条特殊的CPU指令,syscall.
一旦执行这条指令,CPU会立即挂起你的程序,把控制权交给一个预置好的内核地址.该过程叫Trap.CPU的状态从用户态瞬间切换到内核态,接着,内核的系统服务分发器会根据你传进来的系统调用号,去一张叫SSDT的表里,找到真正干活的那个最终在ntoskrnl.exe里实现的内核函数.
所以你看,整个调用链如下:你的代码->kernel32.dll(Win32API)->ntdll.dll(NativeAPI)->syscall指令->内核模式->ntoskrnl.exe(内核实现)
DLL在此扮演的是提供一个稳定,有文档,且不会轻易变动的ABI.内核怎么升级,SSDT怎么变,都和你没关系,只要kernel32.dll和ntdll.dll的接口不变,你二十年前编译的程序,今天照样能跑.这就是窗口能完成后向兼容的核心秘密.
二,标准库的宿命
现在再看看printf.printf是C标准库而不是系统API的一部分.标准库的意义,是为了抹平差异.让你写的同一份C代码,在窗口上能跑,在林操上能跑,在马操上也能跑.
为了实现该宏伟目标,标准库必须在不同系统上,使用各自提供的底层API来完成同样的功能.在窗口上,printf的最终归宿,就是上面说的那条调用链.
当你调用printf时,事情是这样发展的:1,格式化处理:CRT的内部代码首先会解析你的格式化串,比如%d,%s,然后把所有参数拼成一个完整的串.
2,写入流:printf默认是向标准输出流(stdout)写入.在CRT内部,该流最终会关联到一个文件句柄.3,调用Win32API**:为了把该串写到对应的设备上,CRT会调用kernel32.dll里的WriteFile函数.4,进入内核:接着的事你就熟悉了.WriteFile调用ntdll.dll里的NtWriteFile,然后syscall,进入内核,最终由内核的控制台驱动在屏幕上显示符.
所以,语言的标准库,百分之百依赖系统提供的这些DLL.没有它们,你的程序就无法与外界通信,连一个字符都打印不出来.
来看一段最简单的代码,直观感受一下该过程.
#include<stdio.h>intmain(){printf("Hello, syscall!\n");return0;}这段代码在编译链接后,会把printf的符号解析到你链接的C运行时库里.在运行时,该库里的代码会去加载kernel32.dll,然后调用WriteFile.
可用调试器挂上该程序,在WriteFile下个断点,printf最终一定会走到这里来.
三,绕过编译器,直接与系统对话
理解了这套机制,一个很自然的想法就冒出来了:我能不能绕过标准库,甚至绕过编译器的静态链接,直接跟系统对话?
当然可以.这正是高级程序员和黑客们最喜欢干的事情.有两条路可走,一条文明,一条野蛮.
1,文明之路:动态加载,运行时握手
可在编译时告诉编译器不要用任何库.在程序运行起来后,再手动在内存中加载DLL,然后找到想要的函数地址,直接跳过去执行.
这就是LoadLibrary和GetProcAddress的威力.
好处是极度灵活.你的程序可在运行时决定加载哪个模块,实现插件化架构,或根据不同系统环境执行不同代码.
下面这段C++代码,就彻底放弃了printf和静态链接,用最纯粹的Win32,API来打印串.
#include<windows.h>#include<string.h>//`Forstrlen`定义一个`函数指针`类型,//它的签名必须和要调用的函数`完全一致`//`WriteFile`的官方定义是:BOOLWriteFile(HANDLE hFile,LPCVOID lpBuffer,DWORD nNumberOfBytesToWrite,LPDWORD lpNumberOfBytesWritten,LPOVERLAPPED lpOverlapped);typedefBOOL(WINAPI*MyWriteFileFunc)(HANDLE,LPCVOID,DWORD,LPDWORD,LPOVERLAPPED);voidDirectConsoleOutput(){//`1`.取`kernel32.dll`的模块句柄HMODULE hKernel32=GetModuleHandleA("kernel32.dll");if(hKernel32==NULL){//如果真没找到,那就完蛋了return;}//`2`.从`kernel32.dll`中查找`WriteFile`函数的地址MyWriteFileFunc pWriteFile=(MyWriteFileFunc)GetProcAddress(hKernel32,"WriteFile");if(pWriteFile==NULL){return;}//`3`.取`标准输出句柄`HANDLE hStdout=GetStdHandle(STD_OUTPUT_HANDLE);constchar*message="Hello from raw Win32 API!\n";DWORD bytesWritten=0;//`4`.`直接`用函数指针调用`API`pWriteFile(hStdout,message,(DWORD)strlen(message),&bytesWritten,NULL);}intmain(){DirectConsoleOutput();return0;}这段代码的技术含量比printf高了不止一个数量级.它暴露了窗口编程的本质:一切皆句柄,一切皆API.GetProcAddress取得的,是一个赤裸裸的内存地址,然后转换类型成一个函数指针,最后像调用本地函数一样调用它.这就是动态链接的精髓.
2,野蛮之路:内联汇编,直达天听
如果动态加载还不够刺激,还想把ntdll.dll也甩掉,那就只剩下一条路了:直接在代码里写汇编,手动触发syscall.
警告:前方高能,极度危险!
该玩法,是在与魔鬼共舞.因为系统调用号在不同版本的窗口间,甚至在同一个版本的小补丁间,都可能会变.因此微软要提供ntdll.dll为稳定接口的原因.
绕过它,就等于放弃了所有的兼容保证.你的代码可能在该版本的Win10上跑得好好的,到了下个版本就直接崩溃.
但在技术探索的世界里,没有禁区.下面,我给你展示一段在Winx64上,如何用内联汇编``直接调用``NtWriteFile的思路.
在现代C++``编译器中,直接内联汇编``syscall受到诸多限制,一般会把汇编部分单独写成一个.asm文件,然后链接进来.
首先,需要知道NtWriteFile的系统调用号.该号没有官方文档,只能逆向ntdll.dll来找.比如,在版本的Win10x64上,NtWriteFile的调用号可能是0x08.
然后,需要按x64的syscall``调用约定来设置寄存器:rax:保存系统调用号.rcx:保存第一个参数(FileHandle).rdx:保存第二个参数(事件).r8:保存第三个参数(ApcRoutine).r9:保存第四个参数(ApcContext).rsp栈上传递第五个及之后的参数.NtWriteFile的函数原型比WriteFile``更复杂,它有9个参数.
;syscall_stub.asm;extern"C"NTSTATUSSyscallNtWriteFile(...);.code SyscallNtWriteFile proc mov r10,rcx;//`x64系统调用传统使用r10,而不是rcx`mov eax,8;//假设`NtWriteFile`的调用号是8syscall;//执行`系统调用`ret SyscallNtWriteFile endp.end然后,在C++代码里,可这样调用该汇编函数:
#include<windows.h>#include<winternl.h>//对`IO_STATUS_BLOCK`等结构#include<string.h>//`对strlen`//声明在汇编里实现的函数extern"C"NTSTATUSSyscallNtWriteFile(HANDLE FileHandle,HANDLE Event,PIO_APC_ROUTINE ApcRoutine,PVOID ApcContext,PIO_STATUS_BLOCK IoStatusBlock,PVOID Buffer,ULONG Length,PLARGE_INTEGER ByteOffset,PULONG Key);voidHardcoreOutput(){HANDLE hStdout=GetStdHandle(STD_OUTPUT_HANDLE);constchar*message="Hello from raw syscall!\n";ULONG length=(ULONG)strlen(message);IO_STATUS_BLOCK ioStatusBlock;//`直接调用`用汇编实现的`syscall`包装器SyscallNtWriteFile(hStdout,//`文件句柄`NULL,//`处理事件器`NULL,//`APC`例程NULL,//`APC`环境&ioStatusBlock,//`IO`状态块(PVOID)message,//缓冲length,//长度NULL,//`字节偏移`NULL//键);}intmain(){//`直接调用``syscall`//注意:需要你将上面的`汇编代码`编译成`.obj`文件,并与此`C++`代码链接HardcoreOutput();return0;}这段代码,已完全退出了ntdll.dll的掌控.该技术在杀毒软件,Rootkit,高级调试器等需要深度控制系统的软件中非常常见.
但对日常的应用开发,这绝对是杀鸡用牛刀,而且后患无穷.
四,一个老程序员的忠告
从printf到WriteFile,再到NtWriteFile,最后到赤裸裸的syscall,一层层剥开了系统精心构建的谎言.但你要明白,这些谎言也就是抽象层,是软件工程得以发展的基石.
窗口之所以设计成这样,核心目的就是为了解耦.1,标准库``vsWin32,API:解耦了代码与特定系统.让你的业务逻辑可复用.2,Win32API vs Native,API:解耦了应用与窗口内核的变动.WriteFile该接口三十年没变,但它底层的实现可能已翻天覆地了.3,NativeAPI vs Syscall:解耦了用户态代码与硬件和内核的具体实现.微软可随时修改系统调用号,甚至在未来换掉syscall指令,只要ntdll.dll该"翻译官"还在,你的程序就能继续运行.
对绝大多数你来说,你的活动范围应该就在标准库和Win32,API这一层.仅当你需要解决极其麻烦的性能问题,或开发某些特殊的系统级软件时,才有必要去触碰ntdll.dll甚至syscall.
则问题来了,可见了从用户态到内核态的这条路.CPU需要保存你当前的所有状态,切换到内核栈,执行完再恢复回来.
你每天写的那些应用,在你看不到的后台,每秒钟要在这条路上来回奔波多少次?你为了这些方便,又付出了多少性能的代价呢?
