روش های دسترسی به بافر داده در درایورهای ویندوزی
در آموزش های قبلی به صورت خیلی خلاصه در مورد روش هایی که یک درایور می تواند با برنامه سطح کاربر ارتباط برقرار کند صحبت کردیم و برای یکی از این روش ها که IOCTL بود مثالی آوردیم. در این آموزش سعی می کنم این روش ها را با جزئیات بیشتری به همراه مثال شرح بدهم. در طول آموزش موضوعات دیگری هم مطرح می شود و برای این که ساختار آموزش بهم نریزد توضیحات مربوطه را بصورت جداگانه در انتهای این آموزش آورده ام.
سرفصل مطالب این بخش
لینک مثال ها
راه های دسترسی به بافر داده
در روش هایی که جلوتر توضیح خواهم داد I/O Manager نقش مهمی را ایفا میکند به این دلیل که بر اساس روش دسترسی به حافظه داده، I/O Manager یکسری شرایط را قبل از اینکه درخواست کاربر را به درایور مقصد بفرستد برقرار می کند. حالا سوال مهم این است که I/O Manager چطور تشخیص می دهد که کدام روش مورد استفاده قرار گرفته است؟ در واقع این کار به دو طریق زیر انجام می شود.
- برای درخواست های IRP_MJ_READ و IRP_MJ_WRITE درایور نویس می تواند عضو داده Flags از ساختار DEVICE_OBJECT را به یکی از موارد زیر مقدار دهی کند.
- DO_BUFFERED_IO
- DO_DIRECT_IO
- درخواست هایی که نه DO_BUFFERED_IO و نه DO_DIRECT_IO در عضو داده Flags آنها مقدار دهی شده باشد
- برای درخواست های IRP_MJ_DEVICE_CONTROL و IRP_MJ_INTERNAL_DEVICE_CONTROL ما به همراه درخوست یک کد IOCTL می فرستادیم. در مورد ساختار این کد من در آموزش دوم ساختار IOCTL را با جزئیات توضیح داده بودم ولی برای تکرار مجدد من مقادیر مربوط به پارامتر TransferType کد IOCTL را اینجا هم می آورم.
- METHOD_BUFFERED
- METHID_IN_DIRECT و METHOD_OUT_DIRECT
- METHOD_NEITHER
نکته: لازم به ذکر است که سورس مثال های این آموزش بر اساس تغییر Flags هست ولی با کمی تغییر می توان به روش IOCTL تبدیلش کرد. و برای اطلاعات بیشتر به آموزش دوم رجوع کنید که در مورد روش IOCTL مثال زده بودیم.
روش Buffered I/O
این روش بیشتر در درایورهای عمومی مثل کیبورد، ماوس، گرافیک، دستگاه های بر پایه رابط Serial و Parallel مورد استفاده قرار می گیرد. و همچنین به دلیل اینکه دستگاه های مرتبط به این درایورها از نظر عملکرد کند هستند و اندازه اطلاعاتی که بین سیستم عامل و این دستگاه ها منتقل می شود حجم کمی دارند از این روش استفاده می شود.
I/O Manager در صورتی که درخواست به صورت Buffered I/O (با توجه به یکی از راه هایی که بالاتر گفتم. مقدار دهی Flags یا از طریق IOCTL) فرستاده شده باشد، قبل از فرستادن این درخواست به درایور ما یک "حافظه ی سیستمی" (یک حافظه Nonpaged) به اندازه حافظه داده ای که از سمت کاربر آمده تخصیص می کند. در صورتی که درخواست "نوشتن"(Write) باشد I/O Manager داده سطح کاربر را در این حافظه ی سیستمی کپی میکند و این حافظه در دسترس درایور قرار می گیرد، و در صورتی که درخواست "خواندن"(Read) باشد بعد از آنکه درایور و دیگر درایور ها کارشان را انجام دادند، داده بازگشت داده شده از سمت درایور در حافظه سطح کاربر کپی می شود در نتیجه داده برگشتی در دسترس برنامه سطح کاربر قرار خواهد گرفت.
مثال - سطح کرنل
حالا برای اینکه مطلب بهتر جا بیافتد یک مثال میزنیم. لینک به سورس کامل این مثال را در ابتدای آموزش قرار داده ام.
اول) با IoCraeteDevice یک Device Object می سازیم بعد Flags را به DO_BUFFERED_IO مقدار می دهیم. (در یک بخش جدا از همین آموزش این موضوع را شرح داده ام). مقدار دادن Flags هم به این شکل (با حفظ مقادیر قبلی Flags)
objectDevice.Flags |= DO_BUFFERED_IO;
نکته: اگر بخواهید از روش IOCTL استفاده کنید ما نیاز به تغییر متغییری خاصی نداریم. در واقع چون برای فرستادن درخواست به روش IOCTL ما ار تابع DeviceIoControl از سمت کاربر استفاده میکنیم IO Manager به صورت اتوماتیک پیش نیاز های لازم را برقرار می کند.
دوم) درایور ما بسته به نیاز باید بتواند درخواست های IRP_MJ_READ و IRP_MJ_WRITE را پردازش کند. این دو را در کنار دیگر IRP هایی که پردازش می کنیم قرار می دهیم. در نتیجه در DriverEntry داریم
NTSTATUS DriverEntry( __in PDRIVER_OBJECT DriverObject, __in PUNICODE_STRING RegistryPath ) { ... ...
/* تعریف دیگر درخواست ها */ DriverObject->MajorFunction[IRP_MJ_CREATE] = IrpCreateClose; DriverObject->MajorFunction[IRP_MJ_CLOSE] = IrpCreateClose; DriverObject->MajorFunction[IRP_MJ_READ] = IrpRead; DriverObject->MajorFunction[IRP_MJ_WRITE] = IrpWrite; ... ... }
نکته: در روش IOCTL درایور ما بجای درخواست های IRP_MJ_READ و IRP_MJ_WRITE باید درخواست های IRP_MJ_DEVICE_CONTROL و IRP_MJ_INTERNAL_DEVICE_CONTROL را نیز پردازش کند.
سوم) و در نهایت پیاده سازی توابعی که درخواست های IRP_MJ_READ و IRP_MJ_WRITE را دریافت می کند
NTSTATUS IrpRead( PDEVICE_OBJECT DeviceObject, PIRP Irp ) {
...
...
irpSp = IoGetCurrentIrpStackLocation( Irp ); outLength = irpSp->Parameters.Read.Length;
...
...
/* بافر هم برای خواندن و نوشتن */ outBuf = Irp->AssociatedIrp.SystemBuffer;
/* اینجا داده لازم را داخل بافر حافظه می ریزیم*/ RtlCopyBytes(outBuf, msg, sizeof(msg)); Irp->IoStatus.Information = sizeof(msg);
...
... } NTSTATUS IrpWrite( PDEVICE_OBJECT DeviceObject, PIRP Irp ) { ... }
چهارم) اینجا چون پردازش این دو درخواست شبیه به هم است من یکی را توضیح می دهم (فقط درخواست خواندن). در این مرحله ما اطلاعات مربوط به درخواست را می توانیم از ساختار IO_STACK_LOCATION که داخل ساختار IRP است بگیریم. فعلا به جزئیات این ساختار کار نداریم ولی همینقدر لازم است بدانیم که داخل این ساختار یک ساختار داده union است که داخل این union به ازای هر درخواست یک ساختار مجزا وجود دارد.
مثلا تابعی که سطح کاربر صدا زده می شود و ساختار معادل که داخل IO_STACK_LOCATION مقدار دهی می شود در جدول زیر قابل مشاهده است.
UserMode Function | IO_STACK_LOCATION strcut ______________________________________________ CreateFile | Create ReadFile | Read WriteFile | Write DeviceIOControl | DeviceIoControl ... | ...
در نتیجه در مورد مثال ما اگر کاربر از تابع ReadFile استفاده کند که درخواستی به درایور ما بفرستد. IO Manager ساختار مربوط به Read (که پایین محتویات آن را می بینید) را با مقادیر آمده از سطح کاربر مقدار دهی می کند. و در نهایت ما Irp که از طرف IO Manager آماده شده را داخل تابع مربوط به IRP_MJ_READ دریافت می کنیم.
محتویات این ساختارها را من از اینجا گرفته ام.
// // Parameters for IRP_MJ_READ // struct { ULONG Length; ULONG POINTER_ALIGNMENT Key; LARGE_INTEGER ByteOffset; } Read; // // Parameters for IRP_MJ_WRITE // struct { ULONG Length; ULONG POINTER_ALIGNMENT Key; LARGE_INTEGER ByteOffset; } Write;
برای دسترسی به مقادیر IO_STACK_LOCATION داخل IRP از IoGetCurrentIrpStackLocation استفاده می کنیم بعد مقدار Length را میخوانیم.
irpSp = IoGetCurrentIrpStackLocation( Irp ); outLength = irpSp->Parameters.Read.Length;
نکته: در روش IOCTL ما در آموزش دوم دیده بودیم که این قبیل پارامترها مربوط به IO_STACK_LOCATION را از ساختار DeviceIoControl (توجه به irpSp->Parameters.DeviceIoControl
) می گرفتیم. برای اینکه این مطلب مروری شود کد مربوطه را اینجا هم کپی میکنم.
irpSp = IoGetCurrentIrpStackLocation( Irp ); inBufLength = irpSp->Parameters.DeviceIoControl.InputBufferLength; outBufLength = irpSp->Parameters.DeviceIoControl.OutputBufferLength;
برای درخواست "خواندن" ما باید اطلاعاتی را برای کاربر بفرستیم. چطور اینکار را انجام می دهیم؟ قبلا گفته بودیم که IO Manager یک بافر سیستمی تخصیص می کند و اگر درخواست "خواندن" بود داخل این حافظه داده لازم را می ریزیم و اگر درخواست "نوشتن" از آن حافظه سیستمی باید داده آمده از سمت کاربر را بخوانیم. به حافظه سیستمی هم به صورت زیر دسترسی داریم.
outBuf = Irp->AssociatedIrp.SystemBuffer;
من کد سمت کاربر را اینجا نیاوردم ولی لینک سورس کامل این مثال را بالاتر قرار داده ام که می توانید بگیرید و یادتان نرورد که خروجی سمت درایور را با ابزار DbgView می توانید مشاهده کنید.
روش Direct I/O
این روش معمولا برای درایورهای مربوط به دستگاه های نگه داری اطلاعات است (مثل هارد دیسک، و...) یا اگر بخواهیم از DMA استفاده کنیم. در این موارد اندازه داده هایی که بین سیستم عامل و دستگاه منتقل می شود حجم زیادی دارند.
I/O Manager در صورتی که درخواست به صورت Direct I/O فرستاده شده باشد، قبل از فرستادن درخواست به درایور ما آدرس سطح کاربر را قفل می کند و از آن یک Mdl می سازد. Mdl همراه درخواست به درایور ما فر ستاده می شود و درایور با استفاده از یکسری تابع که در مثال خواهیم دید به حافظه سطح کاربر می تواند دسترسی پیدا کند. توجه شود که در این روش ما داده سطح کاربر را مانند روش قبلی کپی نمی کنیم در نتیجه سرعت کارایی و عملیات بیشتر می شود و همچنین ما بطور مستقیم هم به آدرس سمت کاربر دسترسی نداریم در واقع Mdl اینجا نقش یک واسط را بازی می کند که ارتباط درایور با آدرس سطح کاربر را ممکن می سازد.
مثال - سطح کرنل
توضیحات مربوط به این مثال تا حد زیادی مشابه با روش Buffered I/O است در نتیحه من فقط قسمت هایی که متفاوت هستند را اینجا ذکر می کنم.
اول) مقدار دادن Flags به این صورت می شود
objectDevice.Flags |= DO_DIRECT_IO;
دوم) رجوع به مثال Buffered I/O
سوم) پیاده سازی توابع مربوط به درخواست ها
NTSTATUS IrpRead( PDEVICE_OBJECT DeviceObject, PIRP Irp ) { ... ... irpSp = IoGetCurrentIrpStackLocation( Irp ); outLength = irpSp->Parameters.Read.Length; ... ... outBuf = MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority); /* اینجا داده لازم را داخل بافر حافظه می ریزیم*/ RtlCopyBytes(outBuf, msg, sizeof(msg)); Irp->IoStatus.Information = sizeof(msg); ... ... } NTSTATUS IrpWrite( PDEVICE_OBJECT DeviceObject, PIRP Irp ) { ... }
چهارم) همانطور که می بینید خیلی مشابه روش Buffered I/O است با این تفاوت که اینجا IO Manager از حافظه سطح کاربر یک Mdl ساخته و آن را داخل ساختار IRP قرار داده است. ما برای استفاده از Mdl از تابع MmGetSystemAddressForMdlSafe استفاده کرده ایم. این تابع چکار می کند، در واقع یک Mdl می گیرد به شما خروجی آدرس حافظه ای را می دهد که با آن کار کنیم. به همین سادگی
روش نه Buffered I/O و نه Direct I/O
در این روش ما مستقیم به آدرس سطح کاربر دسترسی داریم. استفاده از این روش می تواند مشکلاتی ایجاد کند به این خاطر که اگر درایور یکسری نکات امنیتی را رعایت نکند می تواند باعث بهم ریختن امنیت سیستم شود (درایور های زیادی متاسفانه دارای این نقطه ضعف هستند).
در روش های پیشین توضیح دادیم که I/O Manager یکسری کارها قبل از آمدن در خواست به ما انجام می دهد. ولی در این روش همه چی را خودمان باید انجام دهیم. در واقع ما باید مانند IO Manager عمل کینم.
توضیح این بخش به نظرم فعلا بی مورد است و حجم این آموزش را زیاد میکند. در آینده شاید بخشی جدا برایش ایجاد کنم چون نکته زیاد دارد.
مقدار دادن DEVICE_OBJECT.Flags
ما قبلا در مورد روش IOCTL صحبت کرده ایم (رجوع به آموزش دوم). حالا در مورد مقدار دادن Flags باید صحبت کنیم. عضو داده Flags مقادیر مختلفی می گیرد (سایت ماکروسایت مقادیر کامل تر را ببینید) و می توانیم این مقادیر را با هم OR (یکی از عملگرهای بولی) کنیم. کد DriverEntry داشتیم به این صورت
NTSTATUS DriverEntry(IN PDRIVER_OBJECT pDriverObject,
IN PUNICODE_STRING regPath) {
PDEVICE_OBJECT deviceObject = NULL; ... ...
ntStatus = IoCreateDevice(...,
...,
&deviceObject)
deviceObject.Flags = مقدار های معتبر; /* DO_BUFFERED_IO
DO_DIRECT_IO */
...
... return STATUS_SUCCESS; }
ما داخل این قسمت با IoCreateDevice یک Device Object می سازیم و حالا می توانیم مقدار Flags مربوط به ساختار این Object را تغییر دهیم.
اگر بخواهیم هم DO_BUFFERED_IO داشته باشیم و هم DO_DIRECT_IO به این صورت می شود (که با هم OR شده اند).
deviceObject.Flags = DO_BUFFERED_IO | DO_DIRECT_IO;
یا اگر بخواهیم مقدار قبلی Flags را هم نگه داریم
deviceObject.Flags |= (DO_BUFFERED_IO | DO_DIRECT_IO)
اگرحالت سوم که نه Buffered I/O و نه Direct I/O بود را بخواهیم. دیگر با هیچکدام از این دو مقدار Flags را مقدار دهی نمی کنیم.
موضوعات جداگانه
درخواست ها
ما در طول آموزش در مورد درخواست هایی مثل IRP_MJ_READ, IRP_MJ_WRITE و ... صحبت کردیم. وقتی صحبت از "درخواست" میکنیم منظور ما ساختاری است به نام IRP. داخل این ساختار یک ساختار دیگر وجود دارد به نام IO_STACK_LOCATION که یک جایی داخل این ساختار عضو داده ای وجود دارد به نام MajorFunction. مقادیری که این عضو داده می تواند داشته باشد به شرح زیر است.
IRP_MJ_CREATE IRP_MJ_PNP IRP_MJ_POWER IRP_MJ_READ IRP_MJ_WRITE IRP_MJ_FLUSH_BUFFERS IRP_MJ_QUERY_INFORMATION IRP_MJ_SET_INFORMATION IRP_MJ_DEVICE_CONTROL IRP_MJ_INTERNAL_DEVICE_CONTROL IRP_MJ_SYSTEM_CONTROL IRP_MJ_CLEANUP IRP_MJ_CLOSE IRP_MJ_SHUTDOWN
در نتیجه وقتی می گوییم درخواست IRP_MJ_READ منظور ما این است که یک ساختار IRP.IO_STACK_LOCATION داریم که عضو داده MajorFunction آن IRP_MJ_READ است.
یک درخواست یا همان IRP وقتی به یک درایور می رسد. تابعی با توجه به آرایه ی MajorFunction داخل DriverObject مربوط به آن درایور صدا زده می شود. (در مورد آرایه ای از MajorFunction رجوع به آموزش دوم کنید).
حافظه از نوع Nonpaged
در سیستم عامل یک مفهومی داریم به نام Swapping که قابلیتی است که سیستم عامل می تواند از یک فایل یا پارتیشن به عنوان حافظه اضافی روی دیسک در کنار حافظه اصلی (RAM) استفاده کند (در سیستم عامل ویندوز این فایل pagefile.sys نام دارد). در نتیجه فضایی که در کل خواهید داشت بیشتر از فضای حافظه اصلی سیستم می شود. و همچنین یک مفهومی داریم به نام Paging (این امکان از طرف CPU باید پشتیبانی شود) که در این حالت حافظه اصلی تقسیم شده به واحد های کوچکتر و یک اندازه به نام Page. اندازه Page ها در حالت پیش فرض 4 KiB
می باشد. سیستم عامل با یکسری از روش ها تصمیم می گیرد که یکسری از Page ها که مورد نیاز نیستن به دیسک منتقل شوند(به این عمل اصطلاحا میگن swap-out).
اگر حافظه را به صورت Nonpaged تخصیص کنیم در واقع به سیستم عامل می گوییم که حافظه ای که می گیریم همیشه در حافظه اصلی RAM باقی بماند و به دیسک منتقل نشود. جدا از موضوع آموزش این پست این کار یکسری مزایا و معایب دارد. مزیت این کار این است که چون آن ناحیه همیشه در حافظه است سرعت دسترسی هم بیشتره، در واقع زمانی برای منتقل کردن Page های مربوطه به دیسک صرف نمی شود. عیب این کار این است که وقتی یک فضایی از حافظه اصلی می گیریم در نتیجه حافظه فیزیکی کمتری خواهیم داشت، در نتیجه سیستم عامل در صورت مواجه شدن با کمبود حافظه مجبور است از حافظه اضافی که روی دیسک است استفاده کند که این کار تاثیر روی کارایی سیستم می گذارد.
در ویندوز ما یک جاهایی مجبوریم حافظه را از نوع Nonpaged بگیریم و راستش من نمی خواهم وارد جزئیات این مساله بشوم چون مارو خیلی از موضوع آموزش این پست دور خواهم شد. ولی بعد سعی می کنم پستی در این رابطه بنویسم.
نکته: زمانی که یک حافظه از نوع Nonpaged گرفتین و دیگر کارتان با این حافظه تمام شد حتما آنرا آزاد کنید که تاثیر روی کاراریی سیستم نگذارد.
ایول خیلی خوشحال شدم آپ کردی دمت گرم بازم بکن