Notice: This website is an unofficial Microsoft Knowledge Base (hereinafter KB) archive and is intended to provide a reliable access to deleted content from Microsoft KB. All KB articles are owned by Microsoft Corporation. Read full disclaimer for more details.

How to send IOCTLs to a filter driver


View products that this article applies to.

Summary

This article explains how to send IOCTL requests to a plug-and-play (PNP) filter driver by creating a separate control deviceobject instead of opening a proprietary device interface registered (IoRegisterDeviceInterface) by the filter driver.

↑ Back to the top


More information

Developers who are writing filter drivers may want to send custom device I/O control requests from an application or another driver to their filter driver in order to control its behavior. On a Windows 2000-based computer, Microsoft recommends that drivers register the device interface in the AddDevice routine for other applications or drivers to interact with them.

Filter driver writers, following this recommendation, register an interface by using their own GUI on the PhysicalDeviceObject (PDO) that they receive in the AddDevice routine and write a custom application to enumerate this interface GUID and send IOCTLs to the driver.
status = IoRegisterDeviceInterface (
                PhysicalDeviceObject,
                (LPGUID) &MY_SPECIAL_INTERFACE_GUID,
                NULL, 
                &devExt->InterfaceName);
				
This approach works as long as the filter is the upper-most filter (class filter) in the stack, because that is the one that handles the request first. If the filter driver is anywhere else in the stack, the IOCTL request may be rejected by either other filters or the function driver above it. It is inappropriate for a filter driver to reject requests that it doesn't know about; however, a function driver may do so. Therefore, if the filter driver is a lower filter, all the IOCTL requests unknown to the function driver are guaranteed to be rejected.

The only way to avoid this problem is to create another standalone named controlobject and a symbolic link (as is normally done on Microsoft Windows NT 4.0 drivers), instead of registering an interface on the PDO. The application can open the symbolic link and send IOCTLs to the filter. These I/O requests are sent directly to the control deviceobject instead of going through the entire stack, irrespective of where the filter driver is located in the stack.

The following code shows how to provide custom IOCTLs support to the generic toaster filter sample (Filter.c) present in the Windows 2000 and Windows XP DDKs under <DDK root>\Src\General\Toaster\Filter\. All the code required to provide IOCTL support is within conditional-compilation directives. You must define IOCTL_INTERFACE in your sources file (-DIOCTL_INTERFACE-=1) to include the IOCTL interface support.

In addition to the code added to show the IOCTL interface, the code also has some enhancements and bug fixes compared to the sample available in the Windows 2000 DDK.
#include <ntddk.h>
#include "filter.h"

#ifdef ALLOC_PRAGMA
#pragma alloc_text (INIT, DriverEntry)
#pragma alloc_text (PAGE, FilterAddDevice)

#pragma alloc_text (PAGE, FilterDispatchPnp)
#pragma alloc_text (PAGE, FilterUnload)
#endif

// 
// Define IOCTL_INTERFACE in your sources file if  you want your
// app to have private interaction with the filter driver. Read KB Q262305
// for more information.
// 

#ifdef IOCTL_INTERFACE

#ifdef ALLOC_PRAGMA
#pragma alloc_text (PAGE, FilterCreateControlObject)
#pragma alloc_text (PAGE, FilterDeleteControlObject)
#pragma alloc_text (PAGE, FilterDispatchIo)
#endif
FAST_MUTEX ControlMutex;
ULONG InstanceCount = 0;
PDEVICE_OBJECT ControlDeviceObject;

#endif

NTSTATUS
DriverEntry(
    IN PDRIVER_OBJECT  DriverObject,
    IN PUNICODE_STRING RegistryPath
    )
/*++

Routine Description:

    Installable driver initialization entry point.
    This entry point is called directly by the I/O system.

Arguments:

    DriverObject - pointer to the driver object

    RegistryPath - pointer to a unicode string representing the path,
                   to driver-specific key in the registry.

Return Value:

    STATUS_SUCCESS if successful,
    STATUS_UNSUCCESSFUL otherwise.

--*/ 
{
    NTSTATUS            status = STATUS_SUCCESS;
    ULONG               ulIndex;
    PDRIVER_DISPATCH  * dispatch;

    UNREFERENCED_PARAMETER (RegistryPath);

    DebugPrint (("Entered the Driver Entry\n"));


    // 
    // Create dispatch points
    // 
    for (ulIndex = 0, dispatch = DriverObject->MajorFunction;
         ulIndex <= IRP_MJ_MAXIMUM_FUNCTION;
         ulIndex++, dispatch++) {

        *dispatch = FilterPass;
    }

    DriverObject->MajorFunction[IRP_MJ_PNP]            = FilterDispatchPnp;
    DriverObject->MajorFunction[IRP_MJ_POWER]          = FilterDispatchPower;
    DriverObject->DriverExtension->AddDevice           = FilterAddDevice;
    DriverObject->DriverUnload                         = FilterUnload;

#ifdef IOCTL_INTERFACE
    // 
    // Set the following dispatch points as we will be doing
    // something useful to these requests instead of just
    // passing them down. 
    // 
    
    DriverObject->MajorFunction[IRP_MJ_CREATE]     = 
    DriverObject->MajorFunction[IRP_MJ_CLOSE]      = 
    DriverObject->MajorFunction[IRP_MJ_CLEANUP]    = 
    DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = 
                                                        FilterDispatchIo;
    // 
    // Mutex is to synchronize multiple threads creating & deleting 
    // control deviceobjects.
    // 
    ExInitializeFastMutex (&ControlMutex);
    
#endif

    return status;
}


NTSTATUS
FilterAddDevice(
    IN PDRIVER_OBJECT DriverObject,
    IN PDEVICE_OBJECT PhysicalDeviceObject
    )
/*++

Routine Description:

    The Plug & Play subsystem is handing us a brand new PDO, for which we
    (by means of INF registration) have been asked to provide a driver.

    We need to determine if we need to be in the driver stack for the device.
    Create a function device object to attach to the stack
    Initialize that device object
    Return status success.

    Remember: We can NOT actually send ANY non pnp IRPS to the given driver
    stack, UNTIL we have received an IRP_MN_START_DEVICE.

Arguments:

    DeviceObject - pointer to a device object.

    PhysicalDeviceObject -  pointer to a device object created by the
                            underlying bus driver.

Return Value:

    NT status code.

--*/ 
{
    NTSTATUS                status = STATUS_SUCCESS;
    PDEVICE_OBJECT          deviceObject = NULL;

    PDEVICE_EXTENSION       deviceExtension;
    ULONG                   deviceType = FILE_DEVICE_UNKNOWN;

    PAGED_CODE ();


    // 
    // IoIsWdmVersionAvailable(1, 0x20) returns TRUE on os after Windows 2000.
    // 
    if (!IoIsWdmVersionAvailable(1, 0x20)) {
        // 
        // Win2K system bugchecks if the filter attached to a storage device
        // doesn't specify the same DeviceType as the device it's attaching
        // to. This bugcheck happens in the filesystem when you disable
        // the devicestack whose top level deviceobject doesn't have a VPB.
        // To workaround we will get the toplevel object's DeviceType and
        // specify that in IoCreateDevice.
        // 
        deviceObject = IoGetAttachedDeviceReference(PhysicalDeviceObject);
        deviceType = deviceObject->DeviceType;
        ObDereferenceObject(deviceObject);
    }

    // 
    // Create a filter device object.
    // 

    status = IoCreateDevice (DriverObject,
                             sizeof (DEVICE_EXTENSION),
                             NULL,  // No Name
                             deviceType,
                             FILE_DEVICE_SECURE_OPEN,
                             FALSE,
                             &deviceObject);


    if (!NT_SUCCESS (status)) {
        // 
        // Returning failure here prevents the entire stack from functioning,
        // but most likely the rest of the stack will not be able to create
        // device objects either, so it is still OK.
        // 
        return status;
    }

    DebugPrint (("AddDevice PDO (0x%x) FDO (0x%x)\n",
                    PhysicalDeviceObject, deviceObject));

    deviceExtension = (PDEVICE_EXTENSION) deviceObject->DeviceExtension;


    deviceExtension->NextLowerDriver = IoAttachDeviceToDeviceStack (
                                       deviceObject,
                                       PhysicalDeviceObject);
    // 
    // Failure for attachment is an indication of a broken plug & play system.
    // 

    if(NULL == deviceExtension->NextLowerDriver) {

        IoDeleteDevice(deviceObject);
        return STATUS_UNSUCCESSFUL;
    }

    deviceObject->Flags |= deviceExtension->NextLowerDriver->Flags &
                            (DO_BUFFERED_IO | DO_DIRECT_IO |
                            DO_POWER_PAGABLE );


    deviceObject->DeviceType = deviceExtension->NextLowerDriver->DeviceType;

    deviceObject->Characteristics =
                          deviceExtension->NextLowerDriver->Characteristics;

    deviceExtension->Self = deviceObject;

    // 
    // Set the initial state of the Filter DO
    // 

    INITIALIZE_PNP_STATE(deviceExtension);

    DebugPrint(("AddDevice: %x to %x->%x \n", deviceObject,
                       deviceExtension->NextLowerDriver,
                       PhysicalDeviceObject));

    deviceObject->Flags &= ~DO_DEVICE_INITIALIZING;

    return STATUS_SUCCESS;

}

NTSTATUS
FilterPass (
    IN PDEVICE_OBJECT DeviceObject,
    IN PIRP Irp
    )
/*++

Routine Description:

    The default dispatch routine.  If this driver does not recognize the
    IRP, then it should send it down, unmodified.
    If the device holds iris, this IRP must be queued in the device extension
    No completion routine is required.

    For demonstrative purposes only, we will pass all the (non-PnP) Irps down
    on the stack (as we are a filter driver). A real driver might choose to
    service some of these Irps.

    As we have NO idea which function we are happily passing on, we can make
    NO assumptions about whether or not it will be called at raised IRQL.
    For this reason, this function must be in put into non-paged pool
    (aka the default location).

Arguments:

   DeviceObject - pointer to a device object.

   Irp - pointer to an I/O Request Packet.

Return Value:

      NT status code

--*/ 
{
    PDEVICE_EXTENSION           deviceExtension;

    deviceExtension = (PDEVICE_EXTENSION) DeviceObject->DeviceExtension;

    IoSkipCurrentIrpStackLocation (Irp);
    return IoCallDriver (deviceExtension->NextLowerDriver, Irp);
}

NTSTATUS
FilterDispatchPnp (
    IN PDEVICE_OBJECT DeviceObject,
    IN PIRP Irp
    )
/*++

Routine Description:

    The plug and play dispatch routines.

    Most of these the driver will completely ignore.
    In all cases it must pass on the IRP to the lower driver.

Arguments:

   DeviceObject - pointer to a device object.

   Irp - pointer to an I/O Request Packet.

Return Value:

      NT status code

--*/ 
{
    PDEVICE_EXTENSION           deviceExtension;
    PIO_STACK_LOCATION          irpStack;
    NTSTATUS                    status;

    PAGED_CODE();

    deviceExtension = (PDEVICE_EXTENSION) DeviceObject->DeviceExtension;
    irpStack = IoGetCurrentIrpStackLocation(Irp);

    DebugPrint(("FilterDO %s IRP:0x%x \n",
                PnPMinorFunctionString(irpStack->MinorFunction), Irp));

    switch (irpStack->MinorFunction) {
    case IRP_MN_START_DEVICE:

        // 
        // The device is starting.
        // 
        // We cannot touch the device (send it any non pnp irps) until a
        // start device has been passed down to the lower drivers.
        // 
        IoCopyCurrentIrpStackLocationToNext(Irp);
        IoSetCompletionRoutine(Irp,
                               (PIO_COMPLETION_ROUTINE) FilterStartCompletionRoutine,
                               NULL,
                               TRUE,
                               TRUE,
                               TRUE);

        return IoCallDriver(deviceExtension->NextLowerDriver, Irp);

    case IRP_MN_REMOVE_DEVICE:

        IoSkipCurrentIrpStackLocation(Irp);

        status = IoCallDriver(deviceExtension->NextLowerDriver, Irp);

        SET_NEW_PNP_STATE(deviceExtension, Deleted);
        
#ifdef IOCTL_INTERFACE
        FilterDeleteControlObject();
#endif 
        IoDetachDevice(deviceExtension->NextLowerDriver);
        IoDeleteDevice(DeviceObject);
        return status;


    case IRP_MN_QUERY_STOP_DEVICE:
        SET_NEW_PNP_STATE(deviceExtension, StopPending);
        status = STATUS_SUCCESS;
        break;

    case IRP_MN_CANCEL_STOP_DEVICE:

        // 
        // Check to see whether you have received cancel-stop
        // without first receiving a query-stop. This could happen if someone
        // above us fails a query-stop and passes down the subsequent
        // cancel-stop.
        // 

        if(StopPending == deviceExtension->DevicePnPState)
        {
            // 
            // We did receive a query-stop, so restore.
            // 
            RESTORE_PREVIOUS_PNP_STATE(deviceExtension);
        }
        status = STATUS_SUCCESS; // We must not fail this IRP.
        break;

    case IRP_MN_STOP_DEVICE:
        SET_NEW_PNP_STATE(deviceExtension, Stopped);
        status = STATUS_SUCCESS;
        break;

    case IRP_MN_QUERY_REMOVE_DEVICE:

        SET_NEW_PNP_STATE(deviceExtension, RemovePending);
        status = STATUS_SUCCESS;
        break;

    case IRP_MN_SURPRISE_REMOVAL:

        SET_NEW_PNP_STATE(deviceExtension, SurpriseRemovePending);
        status = STATUS_SUCCESS;
        break;

    case IRP_MN_CANCEL_REMOVE_DEVICE:

        // 
        // Check to see whether you have received cancel-remove
        // without first receiving a query-remove. This could happen if
        // someone above us fails a query-remove and passes down the
        // subsequent cancel-remove.
        // 

        if(RemovePending == deviceExtension->DevicePnPState)
        {
            // 
            // We did receive a query-remove, so restore.
            // 
            RESTORE_PREVIOUS_PNP_STATE(deviceExtension);
        }

        status = STATUS_SUCCESS; // We must not fail this IRP.
        break;

    case IRP_MN_DEVICE_USAGE_NOTIFICATION:

        // 
        // On the way down, pagable might become set. Mimic the driver
        // above us. If no one is above us, just set pagable.
        // 
        if ((DeviceObject->AttachedDevice == NULL) ||
            (DeviceObject->AttachedDevice->Flags & DO_POWER_PAGABLE)) {

            DeviceObject->Flags |= DO_POWER_PAGABLE;
        }

        IoCopyCurrentIrpStackLocationToNext(Irp);

        IoSetCompletionRoutine(
            Irp,
            FilterDeviceUsageNotificationCompletionRoutine,
            NULL,
            TRUE,
            TRUE,
            TRUE
            );

        return IoCallDriver(deviceExtension->NextLowerDriver, Irp);

    default:
        // 
        // If you don't handle any IRP you must leave the
        // status as is.
        // 
        status = Irp->IoStatus.Status;

        break;
    }

    // 
    // Pass the IRP down and forget it.
    // 
    Irp->IoStatus.Status = status;
    return FilterPass(DeviceObject, Irp);
}

NTSTATUS
FilterStartCompletionRoutine(
    IN PDEVICE_OBJECT   DeviceObject,
    IN PIRP             Irp,
    IN PVOID            Context
    )
/*++
Routine Description:
    A completion routine for use when calling the lower device objects to
    which our filter deviceobject is attached.

Arguments:

    DeviceObject - Pointer to deviceobject
    Irp          - Pointer to a PnP Irp.
    Context      - NULL
Return Value:

    NT Status is returned.

--*/ 

{
    PDEVICE_EXTENSION       deviceExtension;

    UNREFERENCED_PARAMETER(Context);

    deviceExtension = (PDEVICE_EXTENSION) DeviceObject->DeviceExtension;

    if (Irp->PendingReturned) {

        IoMarkIrpPending(Irp);
    }

    if (NT_SUCCESS (Irp->IoStatus.Status)) {
 
        // 
        // As we are successfully now back, we will
        // first set our state to Started.

        // 

        SET_NEW_PNP_STATE(deviceExtension, Started);

        // 
        // On the way up inherit FILE_REMOVABLE_MEDIA during Start.
        // This characteristic is available only after the driver stack is started!.
        // 
        if (deviceExtension->NextLowerDriver->Characteristics & FILE_REMOVABLE_MEDIA) {

            DeviceObject->Characteristics |= FILE_REMOVABLE_MEDIA;
        }

#ifdef IOCTL_INTERFACE

        // 
        // If the PreviousPnPState is stopped then we are being stopped temporarily
        // and restarted for resource rebalance. 
        // 
        if(Stopped != deviceExtension->PreviousPnPState) {
            // 
            // Device is started for the first time.
            // 
            FilterCreateControlObject(DeviceObject);
        }
#endif   

    }

    return STATUS_SUCCESS;

}

NTSTATUS
FilterDeviceUsageNotificationCompletionRoutine(
    IN PDEVICE_OBJECT   DeviceObject,
    IN PIRP             Irp,
    IN PVOID            Context
    )
/*++
Routine Description:
    A completion routine for use when calling the lower device objects to
    which our filter deviceobject is attached.

Arguments:

    DeviceObject - Pointer to deviceobject
    Irp          - Pointer to a PnP Irp.
    Context      - NULL
Return Value:

    NT Status is returned.

--*/ 

{
    PDEVICE_EXTENSION       deviceExtension;

    UNREFERENCED_PARAMETER(Context);

    deviceExtension = (PDEVICE_EXTENSION) DeviceObject->DeviceExtension;


    if (Irp->PendingReturned) {

        IoMarkIrpPending(Irp);
    }

    // 
    // On the way up, pagable might become clear. Mimic the driver below us.
    // 
    if (!(deviceExtension->NextLowerDriver->Flags & DO_POWER_PAGABLE)) {

        DeviceObject->Flags &= ~DO_POWER_PAGABLE;
    }

    return STATUS_SUCCESS;

}

NTSTATUS
FilterDispatchPower(
    IN PDEVICE_OBJECT    DeviceObject,
    IN PIRP              Irp
    )
/*++

Routine Description:

    This routine is the dispatch routine for power irps.

Arguments:

    DeviceObject - Pointer to the device object.

    Irp - Pointer to the request packet.

Return Value:

    NT Status code
--*/ 
{
    PDEVICE_EXTENSION   deviceExtension;

    deviceExtension = (PDEVICE_EXTENSION) DeviceObject->DeviceExtension;
    PoStartNextPowerIrp(Irp);
    IoSkipCurrentIrpStackLocation(Irp);
    return PoCallDriver(deviceExtension->NextLowerDriver, Irp);
}



VOID
FilterUnload(
    IN PDRIVER_OBJECT DriverObject
    )
/*++

Routine Description:

    Free all the allocated resources in DriverEntry, etc.

Arguments:

    DriverObject - pointer to a driver object.

Return Value:

    VOID.

--*/ 
{
    PAGED_CODE ();

    // 
    // The device object(s) should be NULL now
    // (since we unload, all the devices objects associated with this
    // driver must be deleted.
    // 
    ASSERT(DriverObject->DeviceObject == NULL);

    // 
    // We should not be unloaded until all the devices we control
    // have been removed from our queue.
    // 
    DebugPrint (("unload\n"));

    return;
}

#ifdef IOCTL_INTERFACE
NTSTATUS
FilterCreateControlObject(
    IN PDEVICE_OBJECT    DeviceObject
)
{
    UNICODE_STRING      ntDeviceName;
    UNICODE_STRING      symbolicLinkName;
    PCONTROL_DEVICE_EXTENSION   deviceExtension;
    NTSTATUS status;
    
    ExAcquireFastMutexUnsafe(&ControlMutex);

    // 
    // If this is a first instance of the device, then create a controlobject
    // and register dispatch points to handle ioctls.
    // 
    if(1 == ++InstanceCount)
    {

        // 
        // Initialize the unicode strings
        // 
        RtlInitUnicodeString(&ntDeviceName, NTDEVICE_NAME_STRING);
        RtlInitUnicodeString(&symbolicLinkName, SYMBOLIC_NAME_STRING);

        // 
        // Create a named deviceobject so that applications or drivers
        // can directly talk to us without going throuhg the entire stack.
        // This call could fail if there are not enough resources or
        // another deviceobject of same name exists (name collision).
        // 
        
        status = IoCreateDevice(DeviceObject->DriverObject,
                                sizeof(CONTROL_DEVICE_EXTENSION),
                                &ntDeviceName,
                                FILE_DEVICE_UNKNOWN,
                                FILE_DEVICE_SECURE_OPEN,
                                FALSE, 
                                &ControlDeviceObject);

        if (NT_SUCCESS( status )) {

            ControlDeviceObject->Flags |= DO_BUFFERED_IO;

            status = IoCreateSymbolicLink( &symbolicLinkName, &ntDeviceName );

            if ( !NT_SUCCESS( status )) {
                IoDeleteDevice(ControlDeviceObject);
                DebugPrint(("IoCreateSymbolicLink failed %x\n", status));
                goto End;
            }

            deviceExtension = ControlDeviceObject->DeviceExtension;
            deviceExtension->ControlData = NULL;
            
            ControlDeviceObject->Flags &= ~DO_DEVICE_INITIALIZING;
            
        }else {
            DebugPrint(("IoCreateDevice failed %x\n", status));
        }
    }

End:
    
    ExReleaseFastMutexUnsafe(&ControlMutex); 
    return status;
    
}

VOID
FilterDeleteControlObject(
)
{
    UNICODE_STRING      symbolicLinkName;

    ExAcquireFastMutexUnsafe(&ControlMutex);

    // 
    // If this is the last instance of the device then delete the controlobject
    // and symbolic link to enable the pnp manager to unload the driver.
    // 
    
    if(!(--InstanceCount) && ControlDeviceObject)
    {
        RtlInitUnicodeString(&symbolicLinkName, SYMBOLIC_NAME_STRING);

        IoDeleteSymbolicLink(&symbolicLinkName);
        IoDeleteDevice(ControlDeviceObject);
        ControlDeviceObject= NULL;
    }

    ExReleaseFastMutexUnsafe(&ControlMutex); 

}


NTSTATUS
FilterDispatchIo(
    IN PDEVICE_OBJECT    DeviceObject,
    IN PIRP              Irp
    )
/*++

Routine Description:

    This routine is the dispatch routine for non passthru irps.
    We will check the input device object to see if the request
    is meant for the control device object. If it is, we will
    handle and complete the IRP, if not, we will pass it down to 
    the lower driver.
    
Arguments:

    DeviceObject - Pointer to the device object.

    Irp - Pointer to the request packet.

Return Value:

    NT Status code
--*/ 
{
    PIO_STACK_LOCATION  irpStack;
    NTSTATUS            status;

    PAGED_CODE();

    // 
    // Please note that this is a common dispatch point for controlobject and
    // filter deviceobject attached to the pnp stack. 
    // 
    if(DeviceObject != ControlDeviceObject) {
        // 
        // We will just  the request down as we are not interested in handling
        // requests that come on the PnP stack.
        // 
        return FilterPass(DeviceObject, Irp);    
    }
    
    // 
    // Else this is targeted at our control deviceobject so let's handle it.
    // Here we will handle the IOCTl requests that come from the app.
    //    
    status = STATUS_SUCCESS;
    Irp->IoStatus.Information = 0;
    irpStack = IoGetCurrentIrpStackLocation (Irp);

    switch (irpStack->MajorFunction) {
        case IRP_MJ_CREATE:
            DebugPrint(("Create \n"));
            break;
            
        case IRP_MJ_CLOSE:
            DebugPrint(("Close \n"));
            break;
            
        case IRP_MJ_CLEANUP:
            DebugPrint(("Cleanup \n"));
            break;
            
         case  IRP_MJ_DEVICE_CONTROL:
            DebugPrint(("DeviceIoControl\n"));
            switch (irpStack->Parameters.DeviceIoControl.IoControlCode) {
                // 
                //case IOCTL_CUSTOM_CODE: 
                // 
                default:
                    status = STATUS_INVALID_PARAMETER;
                    break;
            }
        default:
            break;
    }
 
    Irp->IoStatus.Status = status;
    IoCompleteRequest (Irp, IO_NO_INCREMENT);
    return status;
}

#endif
The control device object is common for all the instances of the devices that the filter attaches to. As a result, it is created (FilterCreateControlObject) when the first instance of the device gets started and deleted (FilterDeleteControlObject) when the last instance of the device gets deleted. If the control object is not deleted before the last instance of the device gets removed, it will prevent the filter from unloading dynamically. This sample uses a mutex, and a global counter is used to track the instances. The reason for using a mutex for synchronization instead of a spinlock is that all the routines that access the global counter run at IRQL PASSIVE_LEVEL.

↑ Back to the top


Keywords: KB262305, kbplugplay, kbkmode, kbhowto

↑ Back to the top

Article Info
Article ID : 262305
Revision : 4
Created on : 8/4/2005
Published on : 8/4/2005
Exists online : False
Views : 211