Driver Development Part 1 : 요약정리
디바이스 드라이버를 만들기 위해서 기본적으로 구현해 주어야 하는 함수들이 있는데.
1. 디바이스를 만든다.(DriverEntry)
2. 디바이스를 삭제한다.(Example_Unload)
3. 디바이스로 데이터를 보내고 받는다.
DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath);
DRIVER_OBJECT : 드라이버를 나타내는 데이터 구조체
이거는 DEVICE_OBJECT를 포함하고 있고(특정디바이스를 나타내는 데이터 구조체)
pRegistryPath : 드라이버의 정보가 저장된 레지스트리 위치를 가리키는 문자열
드라이버의 특정 정보를 저장하는데 사용될수 있다.
역할 : 디바이스를 만든다. 모든 드라이버가 하드웨어와 동작하는 것은 아니기 때문에 드라이버 타입은 UNKNOWN이 될 수 있다.
DO_DEVICE_INITIALIZING은 I/O 매니저에게 디바이스가 초기화되고 있고 드라이버로 I/O request를 보내지 말것을 명시한다. DriverEntry 컨텍스트에서 만들어지는 디바이스의 경우에 DriverEntry가 수행되어지면 I/O 매니저가 이 플래그를 clear해주기 때문에 필요하지는 않다. 하지만 이 함수 이외에서 생성되는 디바이스의 경우에는 수동으로 clear 해줄 필요가 있다.
Dos device Name : \\.\
NT device Name : \Device\
위의 심볼릭 링크를 통해서 얻어진 핸들을 갖고서 WriteFile, ReadFile, CloseHandle같은 것을 사용해서 통신을 할 수가 있게 된다.
(blog.naver.com/obelisk9/40035468599)
사용자 응용 프로그램들이 읽기나 쓰기 같은 요청을 했을 경우 사용자 쪽의 데이터 버퍼를 처리하는 방법에 따라서 위와 같이 분류를 하는데
Buffered I/O : I/O관리자는 먼저 호출한 응용 프로그램의 사용자 버퍼와 같은 크기의 Non-paged 풀 버퍼를 할당한다. 응용 프로그램에서
쓰기시 I/O 관리자는 IRP를 만들때 할당한 버퍼로 사용자 버퍼 데이터를 복사한다.
읽기시에 I/O 관리자는 IRP가 끝났을때 I/O 관리자가 할당한 버퍼에서 사용자 버퍼로 복사한다. 그리고 I/O 관리자는 할당한 버퍼를 해제한다.
Direct I/O : I/O 관리자는 먼저 사용자 버퍼에 해당하는 물리 메모리 페이지를 잠근다. 그리고 잠근 페이지에 대한 내용을 설명하기 위해 MDL을 만든다. MDL에는 버퍼에 의해 할당한 물리적인 메모리를 지정한다. 그리고 만약 드라이버가 버퍼의 내용을 접근하려고 하면, 시스템 주소 공간으로 버퍼를 맵해 사용할 수 있다.
Neither I/O : I/O 관리자는 어떤 버퍼관리도 하지 않는다. 대신 버퍼관리는 장치 드라이버의 discreation 으로 남겨지고, 장치 드라이버는 I/O 관리자가 다른 버퍼관리 타입에서 처리하는 과정을 손수 처리할 수 있도록 선택할 수 있다.
꼭 그런것은 아니지만 일반적으로 드라이버는 호출한 부분의 데이터 양이 한 페이지보다(4kb)보다 작으면 Buffer I/O 방식을 사용하고, 그것보다 클 경우엔 Direct I/O 방식을 사용한다. 그리고 파일 시스템 드라이버는 Neither I/O를 주로 사용한다. 데이터를 파일 시스템 캐시에서 사용자의 버퍼로 복사할때 버퍼 처리에 오버헤드가 없기 때문이다.
디스패치 루틴
윈도우 200에서 I/O는 패킷 드리븐 방식으로 이루어진다. I/O 요청이 있을때 먼저 I/O 관리자가 그 요청에 해당하는 내용을 가지고 IRP를 만든다. I/O 관리자는 응용 프로그램의 요청(읽기,쓰기 등)을 받았을때, 그 요청에 맞는 함수 코드로 바꾼다. 그리고 처리할 요청에 대한 드라이버를 선택하고 적절한 드라이버 내의 디스패치 루틴을 호출한다.
디스패치 루틴은 요청 내용을 보고 알맞은 처리를 한후 결과를 I/O 관리자에 반환한다. <표1>은 응용 프로그램에서 호출하는 함수와 대응되는 드라이버 내의 주요 함수코드들을 보여준다.
CreateFile -> IRP_MJ_CREATE
CloseHande -> IRP_MJ_CLOSE, IRP_MJ_CLEANUP
ReadFile -> IRP_MJ_READ
WriteFile -> IRP_MJ_WRITE
DeviceIoControl -> IRP_MJ_DEVICE_CONTROL
디스패치 루틴에서 기본적으로 처리해야 할 몇가지 내용은 다음과 같다.
1. 드라이버와 관련 있는 IRP 스택 위치에 포인터를 얻기 위해 IoGetCurrentIrpStatckLocation 함수를 호출한다.
2. I/O 요청에 대한 매개변수들을 가져온다.
3. IoStatus에 반환할 값들을 IRP에 채운다. Status에는 에러 코드를 채우고, Information에는 적절한 값을 채운다. 일반적으로 읽기시에는 응용 프로그램으로 복사할 데이터 크기를 알려준다.
4. I/O 요청에 대한 모든 처리를 끝내고, IRP를 더이상 사용하지 않기 위해 IoCompleteRequest를 호출한다.
1. 디바이스를 만든다.(DriverEntry)
2. 디바이스를 삭제한다.(Example_Unload)
3. 디바이스로 데이터를 보내고 받는다.
DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath);
DRIVER_OBJECT : 드라이버를 나타내는 데이터 구조체
이거는 DEVICE_OBJECT를 포함하고 있고(특정디바이스를 나타내는 데이터 구조체)
pRegistryPath : 드라이버의 정보가 저장된 레지스트리 위치를 가리키는 문자열
드라이버의 특정 정보를 저장하는데 사용될수 있다.
역할 : 디바이스를 만든다. 모든 드라이버가 하드웨어와 동작하는 것은 아니기 때문에 드라이버 타입은 UNKNOWN이 될 수 있다.
UNICODE_STRING은 자체적으로 사이즈 파라미터를 갖고 있기 때문에 널로 끝날필요는 없다. 따라서 드라이버로 건네지는 유니코드 스트링은 널로 끝나지 않기 때문에 항상 염두에 두고 있어야 한다. pDeviceObject는 새롭게 만들어지는 디바이스 오브젝트를 받는다. 두번째 파라미터는 0을 건네주고 있는데, device extension이 필요로 하는 바이트 수를 의미한다. 사용하지 않기 때문에 0을 건네고 있다. 특정 요청들이 왔을때 드라이버가 호출될수 있도록 뭔가를 세팅을 해주어야 하는데 (이러한 요청이 IRP Major request이고 여기 아래에는 sub-request인 Minor request가 있다. IRP 스택 위치에서 발견될 수 있다) for(uiIndex = 0; uiIndex < IRP_MJ_MAXIMUM_FUNCTION; uiIndex++) pDriverObject->MajorFunction[uiIndex] = Example_UnSupportedFunction; pDriverObject->MajorFunction[IRP_MJ_CLOSE] = Example_Close; pDriverObject->MajorFunction[IRP_MJ_CREATE] = Example_Create; pDriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = Example_IoControl; pDriverObject->MajorFunction[IRP_MJ_READ] = Example_Read; pDriverObject->MajorFunction[IRP_MJ_WRITE] = USE_WRITE_FUNCTION;드라이버 언로드 하는 코드는 아래와 같으며, 시스템이 언로드 하는것을 허용하고 싶지 않다면 생략할 수 있다.
pDriverObject->DriverUnload = Example_Unload;DRIVER_OBJECT와 DEVICE_OBJECT 2개의 구조체가 사용되는데, 이름이 거의 같기 때문에 헷갈릴 수 있는데 조심위 2개에 대한 부연설명을 해 보면 직렬 드라이버를 예로 들어 DriverObject와 DeviceObject의 연관 관계를 잠시 설명해 보겠다. 우리가 직렬 드라이버를 만든다고 가정해 보자. 다들 알다시피 직렬 드라이버는 COM1, COM2, COM3 등의 이름을 가지고 응용 프로그램에서 접근한다. 그러나 실제로 윈도우 2000에는 COM 개수에 따라 직렬 드라이버가 각각 다르게 존재하는 것이 아니다. 하나의 드라이버가 여러 개의 COM 포트를 관리하고 처리한다. 즉, 드라이버에서는 DriverObject가 하나 존재하고, 대신 COM 포트 개수만큼 DeviceObject를 생성하는 것이다. DriverObject가 같다는 것은 IRP 처리루틴을 같이 공유한다는 뜻이다. 예를 들어 응용 프로그램에서 COM1을 읽는 명령이 내려왔다고 가정해 보자. Win32 API를 통해서 커널 모드로 진입할 것이다. 그리고 명령은 I/O 관리자에 전달될 것이며, I/O 관리자는 해당 명령을 실행할 수 있는 IRP를 생성해 해당 드라이버, 즉 직렬 드라이버에 전달한다. 그리고 DriverObject의 MajorFunction에 해당하는 디스패치 루틴 함수로 분기 처리하고, 처리 결과를 I/O 관리자에 돌려준다. 여기서 읽기 처리 루틴이 다음과 같다고 하자. Read(...) { ... data = InPort( 0x378 ); ... }이와 같이 코딩되어 있다면 읽기 디스패치 루틴 실행시 0x378(COM1 포트의 주소)값을 읽어올 것이고 응용 프로그램은 원하던 결과를 얻을 것이다. 그렇다면 응용 프로그램이 COM2 또는 COM3의 값을 읽게 하는 명령이 직렬 드라이버에 내려온다면 어떻게 될까?그럼 COM 포트들은 같은 DriverObject를 사용하기 때문에 IRP에 관한 루틴을 공유한다. 그럼 앞과 같은 루틴이라면 COM2, COM3 포트의 값을 읽었을 때도 COM1의 값이 얻어질 것이다. 이런 문제를 해결하기 위해 DeviceExtension을 사용한다. DeviceExtenstion은 DeviceObject만을 위한 메모리 공간이다. 문제가 되던 data = InPort( DeviceObject->DeviceExtention->Port );부분이 해결이 되는 것이다. DeviceExtention에는 Port라는 변수가 있을 것이고, 그 변수에는 각 포트 주소가 기억되어 있다. 이와 같이 처리하면, 읽기 디스패치 루틴을 공유해도 각각의 포트값을 읽어올 수 있다. |
DO_DEVICE_INITIALIZING은 I/O 매니저에게 디바이스가 초기화되고 있고 드라이버로 I/O request를 보내지 말것을 명시한다. DriverEntry 컨텍스트에서 만들어지는 디바이스의 경우에 DriverEntry가 수행되어지면 I/O 매니저가 이 플래그를 clear해주기 때문에 필요하지는 않다. 하지만 이 함수 이외에서 생성되는 디바이스의 경우에는 수동으로 clear 해줄 필요가 있다.
pDeviceObject->Flags |= IO_TYPE;
pDeviceObject->Flags &= (~DO_DEVICE_INITIALIZING);오브젝트 매니저에 심볼릭 링크를 만든다. 오브젝트 매니저에서 해당 링크를 보려면 sysinternal사의 WINOBJ툴을 통해서 확인할 수 있다. 심볼릭 링크는 도스 이름을 NT 디바이스 이름으로 매핑한다.IoCreateSymbolicLink(&usDosDeviceName, &usDriverName);유저레벨 프로그램에서 커널드라이버로 심볼릭 링크를 통해서 접근할 수 있는데Dos device Name : \\.\
NT device Name : \Device\
위의 심볼릭 링크를 통해서 얻어진 핸들을 갖고서 WriteFile, ReadFile, CloseHandle같은 것을 사용해서 통신을 할 수가 있게 된다.
VOID Example_Unload(PDRIVER_OBJECT DriverObject) DrvierObject : 우리가 만들었던 디바이스를 담고 있는 구조체 VOID Example_Unload(PDRIVER_OBJECT DriverObject) { UNICODE_STRING usDosDeviceName; DbgPrint("Example_Unload Called \r\n"); RtlInitUnicodeString(&usDosDeviceName, L"\\DosDevices\\Example"); IoDeleteSymbolicLink(&usDosDeviceName); IoDeleteDevice(DriverObject->DeviceObject); }
아래의 3개의 함수중 한개를 사용한다. 드라이버로 IPR를 건네주기 전에 마샬이 사용되어진다. 마샬되는 방법에 따라 드라이버에서 데이터를 해석하는 방법이 결정된다. Direct I/O, Buffered I/O, Neither Direct I/O NTSTATUS Example_WriteDirectIO(PDEVICE_OBJECT DeviceObject, PIRP Irp) { NTSTATUS NtStatus = STATUS_SUCCESS; PIO_STACK_LOCATION pIoStackIrp = NULL; PCHAR pWriteDataBuffer; DbgPrint("Example_WriteDirectIO Called \r\n"); /* * Each time the IRP is passed down * the driver stack a new stack location is added * specifying certain parameters for the IRP to the driver. */ pIoStackIrp = IoGetCurrentIrpStackLocation(Irp); if(pIoStackIrp) { pWriteDataBuffer = MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority); if(pWriteDataBuffer) { /* * We need to verify that the string * is NULL terminated. Bad things can happen * if we access memory not valid while in the Kernel. */ if(Example_IsStringTerminated(pWriteDataBuffer, pIoStackIrp->Parameters.Write.Length)) { DbgPrint(pWriteDataBuffer); } } } return NtStatus; } IoGetCurrentIrpStackLocation은 IO_STACK_LOCATION을 우리에게 제공해 준다. 여기서 우리가 필요로 하는 것은 Parameters.Write.Length이다. 유저모드 주소와 물리적 주소로 매핑되어 있는 정보가 Mdl이다(Memory Descriptor List) 아래의 함수가 시스템 가상주소를 우리에게 건네준다.(이 주소를 갖고서 메모리를 읽을수 있다) MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority); 이것은 단순히 유저모드 프로세스에서 사용된 물리적 페이지를 시스템 메모리로 매핑한다. 리턴된 주소는 유저모드로 부터 건네진 버퍼에 접근하기 위해서 사용된다. Direct I/O는 카피될 메모리를 필요로 하지 않기 때문에 큰 버퍼에 대해서 사용되어진다. 단점으로는 IRP가 끝날때까지 메모리가 LOCK된다는 것이다. Buffered I/O NTSTATUS Example_WriteBufferedIO(PDEVICE_OBJECT DeviceObject, PIRP Irp) { NTSTATUS NtStatus = STATUS_SUCCESS; PIO_STACK_LOCATION pIoStackIrp = NULL; PCHAR pWriteDataBuffer; DbgPrint("Example_WriteBufferedIO Called \r\n"); /* * Each time the IRP is passed down * the driver stack a new stack location is added * specifying certain parameters for the IRP to the driver. */ pIoStackIrp = IoGetCurrentIrpStackLocation(Irp); if(pIoStackIrp) { pWriteDataBuffer = (PCHAR)Irp->AssociatedIrp.SystemBuffer; if(pWriteDataBuffer) { /* * We need to verify that the string * is NULL terminated. Bad things can happen * if we access memory not valid while in the Kernel. */ if(Example_IsStringTerminated(pWriteDataBuffer, pIoStackIrp->Parameters.Write.Length)) { DbgPrint(pWriteDataBuffer); } } } return NtStatus; } 다른 프로세스의 스레드에서 어떠한 컨텍스트에서도 접근될 수 있는것이 장점이다. 이 방식은 상승된 IRQL 레벨에서도 읽을 수 있도록 메모리를 non-paged로 매핑된다. 현재 프로세스 컨텍스트 밖에서 메모리를 읽어야 하는 이유는 몇몇 드라이버가 SYSTEM 프로세스에 스레드를 생성하기 때문이다. 비동기 혹은 동기로 작업을 처리하기 ㅤㄸㅒㅤ문에. 내가 만든 드라이버보다 더 높은 레벨의 드라이버가 이렇게 할수도 있고, 내가 할수도 있고 단점은 non-paged 메모리를 할당하고 복사를 수행한다는 것이다. 드라이버로의 read/write 모두에 대해서 이렇게 처리가 되기 때문에 오버헤드가 크다. 커다란 버퍼에 대해서 이 방식을사용하면 non-paged 메모리를 할당하기 때문에 일련의 이어진 커다란 버퍼를 필요로 하게 되는 단점이 있다. 따라서 작은 버퍼에 대해서만 적용하는게 좋고 대신에 Direct I/O처럼 메모리가 락되지는 않는다. 메모리 버퍼가 다른 위치에서 만들어지고 유저모드 메모리가 위 위치로 복사되어진다. 드라이버로부터 데이터를 읽고자 하는 경우는 I/O 매니저가 위의 임시 버퍼에서 실제 유저모드 메모리 위치로 복사되어질 양을 알필요가 있다. Neither Buffered nor Direct NTSTATUS Example_WriteNeither(PDEVICE_OBJECT DeviceObject, PIRP Irp) { NTSTATUS NtStatus = STATUS_SUCCESS; PIO_STACK_LOCATION pIoStackIrp = NULL; PCHAR pWriteDataBuffer; DbgPrint("Example_WriteNeither Called \r\n"); /* * Each time the IRP is passed down * the driver stack a new stack location is added * specifying certain parameters for the IRP to the driver. */ pIoStackIrp = IoGetCurrentIrpStackLocation(Irp); if(pIoStackIrp) { /* * We need this in an exception handler or else we could trap. */ __try { ProbeForRead(Irp->UserBuffer, pIoStackIrp->Parameters.Write.Length, TYPE_ALIGNMENT(char)); pWriteDataBuffer = Irp->UserBuffer; if(pWriteDataBuffer) { /* * We need to verify that the string * is NULL terminated. Bad things can happen * if we access memory not valid while in the Kernel. */ if(Example_IsStringTerminated(pWriteDataBuffer, pIoStackIrp->Parameters.Write.Length)) { DbgPrint(pWriteDataBuffer); } } } __except( EXCEPTION_EXECUTE_HANDLER ) { NtStatus = GetExceptionCode(); } } return NtStatus; }이 방식의 장점으로는 데이터를 복사하지도 유저모드 페이지를 락하지도 않는다는 것이다. 단점으로는 호출스레드의 컨텍스트안에서 이러한 요청을 반드시 처리해야만 한다는 것이다. 또 다른 단점으로는 프로세스 그 자체가 페이지 접근을 변경하거나 메모리를 해제할수 있다는 것이다. 따라서 예외 핸들러 안에다가 ProbeForRead/ProbeForWrite 함수를 써주는게 안전하다.IRP버퍼관리에 대한 설명중 정리가 되어 있는 사이트에서 가져온 내용임
(blog.naver.com/obelisk9/40035468599)
사용자 응용 프로그램들이 읽기나 쓰기 같은 요청을 했을 경우 사용자 쪽의 데이터 버퍼를 처리하는 방법에 따라서 위와 같이 분류를 하는데
Buffered I/O : I/O관리자는 먼저 호출한 응용 프로그램의 사용자 버퍼와 같은 크기의 Non-paged 풀 버퍼를 할당한다. 응용 프로그램에서
쓰기시 I/O 관리자는 IRP를 만들때 할당한 버퍼로 사용자 버퍼 데이터를 복사한다.
읽기시에 I/O 관리자는 IRP가 끝났을때 I/O 관리자가 할당한 버퍼에서 사용자 버퍼로 복사한다. 그리고 I/O 관리자는 할당한 버퍼를 해제한다.
Direct I/O : I/O 관리자는 먼저 사용자 버퍼에 해당하는 물리 메모리 페이지를 잠근다. 그리고 잠근 페이지에 대한 내용을 설명하기 위해 MDL을 만든다. MDL에는 버퍼에 의해 할당한 물리적인 메모리를 지정한다. 그리고 만약 드라이버가 버퍼의 내용을 접근하려고 하면, 시스템 주소 공간으로 버퍼를 맵해 사용할 수 있다.
Neither I/O : I/O 관리자는 어떤 버퍼관리도 하지 않는다. 대신 버퍼관리는 장치 드라이버의 discreation 으로 남겨지고, 장치 드라이버는 I/O 관리자가 다른 버퍼관리 타입에서 처리하는 과정을 손수 처리할 수 있도록 선택할 수 있다.
꼭 그런것은 아니지만 일반적으로 드라이버는 호출한 부분의 데이터 양이 한 페이지보다(4kb)보다 작으면 Buffer I/O 방식을 사용하고, 그것보다 클 경우엔 Direct I/O 방식을 사용한다. 그리고 파일 시스템 드라이버는 Neither I/O를 주로 사용한다. 데이터를 파일 시스템 캐시에서 사용자의 버퍼로 복사할때 버퍼 처리에 오버헤드가 없기 때문이다.
디스패치 루틴
윈도우 200에서 I/O는 패킷 드리븐 방식으로 이루어진다. I/O 요청이 있을때 먼저 I/O 관리자가 그 요청에 해당하는 내용을 가지고 IRP를 만든다. I/O 관리자는 응용 프로그램의 요청(읽기,쓰기 등)을 받았을때, 그 요청에 맞는 함수 코드로 바꾼다. 그리고 처리할 요청에 대한 드라이버를 선택하고 적절한 드라이버 내의 디스패치 루틴을 호출한다.
디스패치 루틴은 요청 내용을 보고 알맞은 처리를 한후 결과를 I/O 관리자에 반환한다. <표1>은 응용 프로그램에서 호출하는 함수와 대응되는 드라이버 내의 주요 함수코드들을 보여준다.
CreateFile -> IRP_MJ_CREATE
CloseHande -> IRP_MJ_CLOSE, IRP_MJ_CLEANUP
ReadFile -> IRP_MJ_READ
WriteFile -> IRP_MJ_WRITE
DeviceIoControl -> IRP_MJ_DEVICE_CONTROL
디스패치 루틴에서 기본적으로 처리해야 할 몇가지 내용은 다음과 같다.
1. 드라이버와 관련 있는 IRP 스택 위치에 포인터를 얻기 위해 IoGetCurrentIrpStatckLocation 함수를 호출한다.
2. I/O 요청에 대한 매개변수들을 가져온다.
3. IoStatus에 반환할 값들을 IRP에 채운다. Status에는 에러 코드를 채우고, Information에는 적절한 값을 채운다. 일반적으로 읽기시에는 응용 프로그램으로 복사할 데이터 크기를 알려준다.
4. I/O 요청에 대한 모든 처리를 끝내고, IRP를 더이상 사용하지 않기 위해 IoCompleteRequest를 호출한다.
이곳에서 사용된 생소한 #pragma 구문 아래 지시자는 어떤 세그먼트에 코드를 놓을지 페이지에 어떤 옵션을 SET할지를 링커에게 알려준다. INIT는 discardable page를 의미한다. DriverEntry 함수가 초기화 동안에만 필요하다는 것을 링커에게 알려주는 역할을 하고 있다. #pragma alloc_text(INIT, DriverEntry) #pragma alloc_text(PAGE, Example_Unload)
첨부파일
DriverSampleSource.zip : 아래 코드프로젝트의 소스
DriverEx1.zip : 위 커널드라이브에 WriteFile()을 사용해서 통신하는 소스(데브피아에서 가져옴)
주의할점으로 DriverEx1.zip에서 WriteFile()을 보면 널을 포함한 사이즈를 건네주어야만 성공할 수 있다는 것을 알아야 함
출처 : http://www.codeproject.com/KB/system/driverdev.aspxNTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath) { NTSTATUS NtStatus = STATUS_SUCCESS; UINT uiIndex = 0; PDEVICE_OBJECT pDeviceObject = NULL; UNICODE_STRING usDriverName, usDosDeviceName; DbgPrint("DriverEntry Called \r\n"); RtlInitUnicodeString(&usDriverName, L"\\Device\\Example"); RtlInitUnicodeString(&usDosDeviceName, L"\\DosDevices\\Example"); NtStatus = IoCreateDevice(pDriverObject, 0, &usDriverName, FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, FALSE, &pDeviceObject); |
댓글
댓글 쓰기