在Driver中呼叫I/O API的時候你考慮到了嗎
本文主題在於指出在驅動中呼叫I/O函式時存在的問題,輕者卡死,重者BSOD
1. 前言
驅動中我們經常使用一些I/O函式來查詢檔案、裝置的資訊,比如IoQueryFileDosDeviceName獲取程式的DOS路徑,IoVolumeDeviceToDosName獲取卷的DOS名稱等等,一般使用這些函式的場景無外乎在LoadImage回撥,CreateProcess回撥,微檔案過濾器/傳統檔案過濾器註冊的callback,甚至會在一些核心Hook點中呼叫。可能不做線上產品使用者不多的時候很多問題不會被察覺,自己嘗試的時候基本上也無法出現問題。但是如果使用者一多可能會遇到各種奇奇怪怪的反饋,那麼到底哪裡容易出問題呢?
2. I/O API的特殊性
Windows中的I/O管理器提供的API大多數都是非同步完成的,而其內部氾濫地使用APC,導致很多I/O函式對使用場景有很高的要求,例如:
IoVolumeDeviceToDosName
Starting with Windows Vista, you must ensure that APCs are not disabled before calling this routine. The KeAreAllApcsDisabled routine can be used to verify that APCs are not disabled.
意思很明確,就是說這個API內部需要用到KernelApc,在呼叫時得確保當前執行緒的APCs可以執行,可以用 KeAreAllApcsDisabled這個API做判斷。
你無法預知當前的程式碼執行時的環境是怎麼樣的,比如說在LoadImage回撥,你無法確保當前的IRQL一定是PASSIVE_LEVEL,或者沒有在一些核心的臨界區範圍內,說到核心的臨界區,現在常用的兩種: KeEnterCriticalRegion與KeEnterGuardedRegion。
//
// Enters a Guarded Region
//
#define KeEnterGuardedRegionThread(_Thread) \
{ \
/* Sanity checks */ \
ASSERT(KeGetCurrentIrql() <= APC_LEVEL); \
ASSERT(_Thread == KeGetCurrentThread()); \
ASSERT((_Thread->SpecialApcDisable <= 0) && \
(_Thread->SpecialApcDisable != -32768)); \
\
/* Disable Special APCs */ \
_Thread->SpecialApcDisable--; \
}
#define KeEnterGuardedRegion() \
{ \
PKTHREAD _Thread = KeGetCurrentThread(); \
KeEnterGuardedRegionThread(_Thread); \
}
//
// Leaves a Guarded Region
//
#define KeLeaveGuardedRegionThread(_Thread) \
{ \
/* Sanity checks */ \
ASSERT(KeGetCurrentIrql() <= APC_LEVEL); \
ASSERT(_Thread == KeGetCurrentThread()); \
ASSERT(_Thread->SpecialApcDisable < 0); \
\
/* Leave region and check if APCs are OK now */ \
if (!(++_Thread->SpecialApcDisable)) \
{ \
/* Check for Kernel APCs on the list */ \
if (!IsListEmpty(&_Thread->ApcState. \
ApcListHead[KernelMode])) \
{ \
/* Check for APC Delivery */ \
KiCheckForKernelApcDelivery(); \
} \
} \
}
#define KeLeaveGuardedRegion() \
{ \
PKTHREAD _Thread = KeGetCurrentThread(); \
KeLeaveGuardedRegionThread(_Thread); \
}
//
// Enters a Critical Region
//
#define KeEnterCriticalRegionThread(_Thread) \
{ \
/* Sanity checks */ \
ASSERT(_Thread == KeGetCurrentThread()); \
ASSERT((_Thread->KernelApcDisable <= 0) && \
(_Thread->KernelApcDisable != -32768)); \
\
/* Disable Kernel APCs */ \
_Thread->KernelApcDisable--; \
}
#define KeEnterCriticalRegion() \
{ \
PKTHREAD _Thread = KeGetCurrentThread(); \
KeEnterCriticalRegionThread(_Thread); \
}
//
// Leaves a Critical Region
//
#define KeLeaveCriticalRegionThread(_Thread) \
{ \
/* Sanity checks */ \
ASSERT(_Thread == KeGetCurrentThread()); \
ASSERT(_Thread->KernelApcDisable < 0); \
\
/* Enable Kernel APCs */ \
_Thread->KernelApcDisable++; \
\
/* Check if Kernel APCs are now enabled */ \
if (!(_Thread->KernelApcDisable)) \
{ \
/* Check if we need to request an APC Delivery */ \
if (!(IsListEmpty(&_Thread->ApcState.ApcListHead[KernelMode])) && \
!(_Thread->SpecialApcDisable)) \
{ \
/* Check for the right environment */ \
KiCheckForKernelApcDelivery(); \
} \
} \
}
#define KeLeaveCriticalRegion() \
{ \
PKTHREAD _Thread = KeGetCurrentThread(); \
KeLeaveCriticalRegionThread(_Thread); \
}
我們看到在呼叫KeEnterCriticalRegion後當前執行緒的KernelApcDisable是非零值,而
呼叫
KeEnterGuardedRegion之後
當前執行緒的SpecialApcDisable是非零值
這兩個是什麼玩意呢??
我們接著來看Apc分發的時候如何使用這兩個值的
VOID
NTAPI
KiDeliverApc(IN KPROCESSOR_MODE DeliveryMode,
IN PKEXCEPTION_FRAME ExceptionFrame,
IN PKTRAP_FRAME TrapFrame)
{
PKTHREAD Thread = KeGetCurrentThread();
PKPROCESS Process = Thread->ApcState.Process;
PKTRAP_FRAME OldTrapFrame;
PLIST_ENTRY ApcListEntry;
PKAPC Apc;
KLOCK_QUEUE_HANDLE ApcLock;
PKKERNEL_ROUTINE KernelRoutine;
PVOID NormalContext;
PKNORMAL_ROUTINE NormalRoutine;
PVOID SystemArgument1;
PVOID SystemArgument2;
ASSERT_IRQL_EQUAL(APC_LEVEL);
/* Save the old trap frame and set current one */
OldTrapFrame = Thread->TrapFrame;
Thread->TrapFrame = TrapFrame;
/* Clear Kernel APC Pending */
Thread->ApcState.KernelApcPending = FALSE;
/* Check if Special APCs are disabled */
if (Thread->SpecialApcDisable) goto Quickie; // 總開關,如果SpecialApcDisable那麼整個執行緒的APC都不會被執行
/* Do the Kernel APCs first */
while (!IsListEmpty(&Thread->ApcState.ApcListHead[KernelMode]))
{
/* Lock the APC Queue */
KiAcquireApcLockAtApcLevel(Thread, &ApcLock);
/* Check if the list became empty now */
if (IsListEmpty(&Thread->ApcState.ApcListHead[KernelMode]))
{
/* It is, release the lock and break out */
KiReleaseApcLock(&ApcLock);
break;
}
/* Kernel APC is not pending anymore */
Thread->ApcState.KernelApcPending = FALSE;
/* Get the next Entry */
ApcListEntry = Thread->ApcState.ApcListHead[KernelMode].Flink;
Apc = CONTAINING_RECORD(ApcListEntry, KAPC, ApcListEntry);
/* Save Parameters so that it's safe to free the Object in the Kernel Routine*/
NormalRoutine = Apc->NormalRoutine;
KernelRoutine = Apc->KernelRoutine;
NormalContext = Apc->NormalContext;
SystemArgument1 = Apc->SystemArgument1;
SystemArgument2 = Apc->SystemArgument2;
/* Special APC */
if (!NormalRoutine)
{
/* Remove the APC from the list */
RemoveEntryList(ApcListEntry);
Apc->Inserted = FALSE;
/* Release the APC lock */
KiReleaseApcLock(&ApcLock);
/* Call the Special APC */
KernelRoutine(Apc,
&NormalRoutine,
&NormalContext,
&SystemArgument1,
&SystemArgument2);
/* Make sure it returned correctly */
if (KeGetCurrentIrql() != ApcLock.OldIrql)
{
KeBugCheckEx(IRQL_UNEXPECTED_VALUE,
(KeGetCurrentIrql() << 16) |
(ApcLock.OldIrql << 8),
(ULONG_PTR)KernelRoutine,
(ULONG_PTR)Apc,
(ULONG_PTR)NormalRoutine);
}
}
else
{
/* Normal Kernel APC, make sure it's safe to deliver */
if ((Thread->ApcState.KernelApcInProgress) ||
(Thread->KernelApcDisable)) // 子開關,控制著Normal KernelApc的執行與否
{
/* Release lock and return */
KiReleaseApcLock(&ApcLock);
goto Quickie;
}
/* Dequeue the APC */
RemoveEntryList(ApcListEntry);
Apc->Inserted = FALSE;
/* Go back to APC_LEVEL */
KiReleaseApcLock(&ApcLock);
/* Call the Kernel APC */
KernelRoutine(Apc,
&NormalRoutine,
&NormalContext,
&SystemArgument1,
&SystemArgument2);
/* Make sure it returned correctly */
if (KeGetCurrentIrql() != ApcLock.OldIrql)
{
KeBugCheckEx(IRQL_UNEXPECTED_VALUE,
(KeGetCurrentIrql() << 16) |
(ApcLock.OldIrql << 8),
(ULONG_PTR)KernelRoutine,
(ULONG_PTR)Apc,
(ULONG_PTR)NormalRoutine);
}
/* Check if there still is a Normal Routine */
if (NormalRoutine)
{
/* At Passive Level, an APC can be prempted by a Special APC */
Thread->ApcState.KernelApcInProgress = TRUE;
KeLowerIrql(PASSIVE_LEVEL);
/* Call and Raise IRQL back to APC_LEVEL */
NormalRoutine(NormalContext, SystemArgument1, SystemArgument2);
KeRaiseIrql(APC_LEVEL, &ApcLock.OldIrql);
}
/* Set Kernel APC in progress to false and loop again */
Thread->ApcState.KernelApcInProgress = FALSE;
}
}
/* Now we do the User APCs */
if ((DeliveryMode == UserMode) &&
!(IsListEmpty(&Thread->ApcState.ApcListHead[UserMode])) &&
(Thread->ApcState.UserApcPending))
{
/* Lock the APC Queue */
KiAcquireApcLockAtApcLevel(Thread, &ApcLock);
/* It's not pending anymore */
Thread->ApcState.UserApcPending = FALSE;
/* Check if the list became empty now */
if (IsListEmpty(&Thread->ApcState.ApcListHead[UserMode]))
{
/* It is, release the lock and break out */
KiReleaseApcLock(&ApcLock);
goto Quickie;
}
/* Get the actual APC object */
ApcListEntry = Thread->ApcState.ApcListHead[UserMode].Flink;
Apc = CONTAINING_RECORD(ApcListEntry, KAPC, ApcListEntry);
/* Save Parameters so that it's safe to free the Object in the Kernel Routine*/
NormalRoutine = Apc->NormalRoutine;
KernelRoutine = Apc->KernelRoutine;
NormalContext = Apc->NormalContext;
SystemArgument1 = Apc->SystemArgument1;
SystemArgument2 = Apc->SystemArgument2;
/* Remove the APC from Queue, and release the lock */
RemoveEntryList(ApcListEntry);
Apc->Inserted = FALSE;
KiReleaseApcLock(&ApcLock);
/* Call the kernel routine */
KernelRoutine(Apc,
&NormalRoutine,
&NormalContext,
&SystemArgument1,
&SystemArgument2);
/* Check if there's no normal routine */
if (!NormalRoutine)
{
/* Check if more User APCs are Pending */
KeTestAlertThread(UserMode);
}
else
{
/* Set up the Trap Frame and prepare for Execution in NTDLL.DLL */
KiInitializeUserApc(ExceptionFrame,
TrapFrame,
NormalRoutine,
NormalContext,
SystemArgument1,
SystemArgument2);
}
}
Quickie:
/* Make sure we're still in the same process */
if (Process != Thread->ApcState.Process)
{
/* Erm, we got attached or something! BAD! */
KeBugCheckEx(INVALID_PROCESS_ATTACH_ATTEMPT,
(ULONG_PTR)Process,
(ULONG_PTR)Thread->ApcState.Process,
Thread->ApcStateIndex,
KeGetCurrentPrcb()->DpcRoutineActive);
}
/* Restore the trap frame */
Thread->TrapFrame = OldTrapFrame;
}
An asynchronous procedure call (APC) is a function that executes asynchronously. APCs are similar to deferred procedure calls (DPCs), but unlike DPCs, APCs execute within the context of a particular thread. Drivers (other than file systems and file-system filter drivers) do not use APCs directly, but other parts of the operating system do, so you need to be aware of how APCs work.
The Windows operating system uses three kinds of APCs:
1. User APCs run strictly in user mode and only when the current thread is in an alertable wait state. The operating system uses user APCs to implement mechanisms such as overlapped I/O and the QueueUserApc Win32 routine.
2. Normal kernel APCs run in kernel mode at IRQL = PASSIVE_LEVEL. A normal kernel APC preempts all user-mode code, including user APCs. Normal kernel APCs are generally used by file systems and file-system filter drivers.
3. Special kernel APCs run in kernel mode at IRQL = APC_LEVEL. A special kernel APC preempts user-mode code and kernel-mode code that executes at IRQL = PASSIVE_LEVEL, including both user APCs and normal kernel APCs. The operating system uses special kernel APCs to handle operations such as I/O request completion.
其實總結一下很簡單,一個使用者模式的Apc,一個核心模式的Apc(分為NormalRoutine為NULL的SpecialKernelApc和不為NULL的NormalKernelApc)
區別在於, SpecialKernelApc只執行KernelRoutine, IRQL為APC_LEVEL,而 NormalKernelApc不僅僅執行 KernelRoutine還執行 NormalRoutine,在PASSIVE_LEVEL下執行NormalRoutine。
但是I/O API也沒有說明他內部用的是哪個型別的Kernel APC,要穩一點就判斷總開關 SpecialApcDisable。不過一般MSDN都會說明。
3. KeAreApcsDisabled/KeAreAllApcsDisabled
BOOLEAN
NTAPI
KeAreApcsDisabled(VOID)
{
/* Return the Kernel APC State */
return KeGetCurrentThread()->CombinedApcDisable ? TRUE : FALSE;
}
BOOLEAN
NTAPI
KeAreAllApcsDisabled(VOID)
{
/* Return the Special APC State */
return ((KeGetCurrentThread()->SpecialApcDisable) ||
(KeGetCurrentIrql() >= APC_LEVEL)) ? TRUE : FALSE;
}
typedef struct _KTHREAD
{
......
union
{
struct
{
SHORT KernelApcDisable;
SHORT SpecialApcDisable;
};
ULONG CombinedApcDisable;
};
......
};
我也理解了很久, KeAreApcsDisabled是說只要當前在核心臨界區內就是Disable狀態,這個可以是子開關KernelApcDisable或者是總開關SpecialApcDisable至少一個有值,要是 SpecialApcDisable則就是所有Apc都是無效狀態(與KeAreAllApcsDisabled判斷相同),要是 KernelApcDisable就是Normal KernelApc失效;要是用的 KeEnterCriticalRegion就只能用這個函式檢查,一般用這個就可以了,當然最好if ( KeAreApcsDisabled() || __readcr8() == APC_LEVEL )
而 KeAreAllApcsDisabled則是真正意義上的所以APC都無效,但是對於 KeEnterCriticalRegion的臨界區這個API是無法判斷的。
4. 解決辦法
當出現無法呼叫I/O API的時候,建議使用勞務執行緒,這個執行緒的執行環境還是比較穩定的,而且就算是做同步響應也不會耗時很久。例如:
typedef struct tag_FyWorkQueueItem
{
WORK_QUEUE_ITEM WorkQueueItem;
PVOID lpParameter1;
PVOID lpParameter2;
PVOID lpParameter3;
KEVENT CompleteEvent;
BOOL bStatus;
} FyWorkQueueItem, *PFyWorkQueueItem;
PUNICODE_STRING QueryProcessObjectName(IN HANDLE ProcessId)
{
NTSTATUS Status = STATUS_SUCCESS;
PEPROCESS EProcess = NULL;
HANDLE hProcess = NULL;
ULONG ulRealSize = 0;
PUNICODE_STRING lpuniImageFileName = NULL;
BOOL bSuccess = FALSE;
if (KeGetCurrentIrql() <= APC_LEVEL)
{
Status = PsLookupProcessByProcessId(ProcessId, &EProcess);
if (NT_SUCCESS(Status) && EProcess)
{
Status = ObOpenObjectByPointer((PVOID)EProcess, OBJ_KERNEL_HANDLE, NULL,
PROCESS_ALL_ACCESS, NULL, KernelMode, &hProcess);
if (NT_SUCCESS(Status))
{
Status = ZwQueryInformationProcess(hProcess, ProcessImageFileName, NULL, 0, &ulRealSize);
if (Status == STATUS_INFO_LENGTH_MISMATCH)
{
lpuniImageFileName = (PUNICODE_STRING)ExAllocatePoolWithTag(NonPagedPool,
ulRealSize + sizeof(UNICODE_STRING), 'hiti');
if (lpuniImageFileName)
{
memset(lpuniImageFileName, 0, ulRealSize + sizeof(UNICODE_STRING));
Status = ZwQueryInformationProcess(hProcess, ProcessImageFileName,
lpuniImageFileName, ulRealSize + sizeof(UNICODE_STRING), &ulRealSize);
if (NT_SUCCESS(Status))
{
bSuccess = TRUE;
}
}
}
ZwClose(hProcess);
}
ObDereferenceObject(EProcess);
}
}
if (!bSuccess)
{
ExFreePool(lpuniImageFileName);
lpuniImageFileName = NULL;
}
return lpuniImageFileName;
}
BOOL GetProcessImageFileName(
IN HANDLE ProcessId,
OUT WCHAR* lpwzImageFileName,
IN ULONG uMaxSize)
{
NTSTATUS Status = STATUS_SUCCESS;
HANDLE FileHandle = NULL;
IO_STATUS_BLOCK IoStatusBlock = { 0 };
PUNICODE_STRING lpuniProcessObjectName = NULL;
OBJECT_ATTRIBUTES oa = { 0 };
PFILE_OBJECT FileObject = NULL;
POBJECT_NAME_INFORMATION ObjectNameInformation = NULL;
BOOL bStatus = FALSE;
if (KeGetCurrentIrql() > PASSIVE_LEVEL) {
return FALSE;
}
lpuniProcessObjectName = QueryProcessObjectName(ProcessId);
if (!lpuniProcessObjectName) {
return FALSE;
}
InitializeObjectAttributes(&oa, lpuniProcessObjectName, OBJ_KERNEL_HANDLE | OBJ_CASE_INSENSITIVE, NULL, NULL);
Status = IoCreateFile(
&FileHandle,
FILE_READ_ATTRIBUTES,
&oa,
&IoStatusBlock,
NULL,
FILE_ATTRIBUTE_NORMAL,
FILE_SHARE_READ | FILE_SHARE_WRITE,
FILE_OPEN,
FILE_NON_DIRECTORY_FILE,
NULL,
0,
CreateFileTypeNone,
NULL,
IO_NO_PARAMETER_CHECKING);
if (!NT_SUCCESS(Status))
{
ExFreePool(lpuniProcessObjectName);
return FALSE;
}
Status = ObReferenceObjectByHandle(FileHandle, FILE_ANY_ACCESS, *IoFileObjectType,
KernelMode, (PVOID*)&FileObject, NULL);
if (NT_SUCCESS(Status) && FileObject)
{
Status = IoQueryFileDosDeviceName(FileObject, &ObjectNameInformation);
if (NT_SUCCESS(Status))
{
if (ObjectNameInformation)
{
if (ObjectNameInformation->Name.Length <= sizeof(WCHAR) * uMaxSize)
{
memset(lpwzImageFileName, 0, 2 * uMaxSize);
memcpy(lpwzImageFileName, ObjectNameInformation->Name.Buffer,
ObjectNameInformation->Name.Length);
bStatus = TRUE;
}
ExFreePool(ObjectNameInformation);
ObjectNameInformation = NULL;
}
}
ObDereferenceObject(FileObject);
}
ObCloseHandle(FileHandle, KernelMode);
FileHandle = NULL;
ExFreePool(lpuniProcessObjectName);
return bStatus;
}
VOID QueryProcessFileNameWorkItem(IN PFyWorkQueueItem lpFyWorkQueueItem)
{
lpFyWorkQueueItem->bStatus = GetProcessImageFileName(
(HANDLE)lpFyWorkQueueItem->lpParameter1,
(WCHAR*)lpFyWorkQueueItem->lpParameter2,
(ULONG)lpFyWorkQueueItem->lpParameter3);
KeSetEvent(&lpFyWorkQueueItem->CompleteEvent, IO_NO_INCREMENT, FALSE);
}
BOOL GetProcessImageFileNameSafeIrql(
IN HANDLE ProcessId,
OUT WCHAR* lpwzImageFileName,
IN ULONG uMaxSize)
{
BOOL bStatus;
FyWorkQueueItem WorkItem;
if (KeGetCurrentIrql() <= APC_LEVEL)
{
if (KeAreApcsDisabled() || KeGetCurrentIrql() == APC_LEVEL)
{
memset(&WorkItem, 0, sizeof(WorkItem));
KeInitializeEvent(&WorkItem.CompleteEvent, NotificationEvent, FALSE);
WorkItem.bStatus = FALSE;
WorkItem.WorkQueueItem.List.Flink = NULL;
WorkItem.WorkQueueItem.WorkerRoutine = (PWORKER_THREAD_ROUTINE)QueryProcessFileNameWorkItem;
WorkItem.lpParameter1 = (PVOID)ProcessId;
WorkItem.lpParameter2 = (PVOID)lpwzImageFileName;
WorkItem.lpParameter3 = (PVOID)uMaxSize;
WorkItem.WorkQueueItem.Parameter = &WorkItem;
ExQueueWorkItem(&WorkItem.WorkQueueItem, DelayedWorkQueue);
KeWaitForSingleObject(&WorkItem.CompleteEvent, Executive, KernelMode, FALSE, NULL);
bStatus = WorkItem.bStatus;
}
else
{
bStatus = GetProcessImageFileName(ProcessId, lpwzImageFileName, uMaxSize);
}
}
else
{
bStatus = FALSE;
}
return bStatus;
}
本文由看雪論壇 FaEry 原創
轉載請註明來自看雪社群
相關文章
- 在平時開發的時候,你有考慮過邊界問題嗎?說說你對邊界的理解!2024-11-29
- 考慮時區了嗎?2021-11-26
- 你的專案剛剛啟動?是時候考慮Globalization了!2018-10-23
- 什麼是RockyLinux,你應該考慮嗎?2021-11-01Linux
- 什麼時候考慮使用無程式碼開發平臺2020-03-26
- 當遇到css佈局,你在考慮什麼?2019-04-02CSS
- 這幾個高階前端常用的API,你用到了嗎?2021-11-12前端API
- 我考慮的是來看考慮考慮勞福德2022-03-10
- 向Docker告別的時候到了2020-12-28Docker
- 企業在選擇MES軟體時,需要考慮二次開發嗎?2018-11-06
- C++中的i++和++i你真的理解嗎?2020-12-08C++
- 香檳中的冠軍,你猜到了嗎?2022-12-30
- 在選擇框架時應該考慮哪些因素?2024-11-26框架
- MySQL在刪除表時I/O錯誤原因分析2018-07-30MySql
- Fastjson到了說再見的時候了2020-07-20ASTJSON
- IT人進行DFMEA分析時需考慮的潛在失效模式有哪些?2024-01-18模式
- 在考慮繼續堅持現有崗位,還是考慮轉崗2024-11-13
- 選用住宅代理時要考慮的因素2022-05-10
- 我在設計資料庫的時候會考慮到哪些資料欄位將來可能會發生變更。2022-03-09資料庫
- 你真的懂 i++ 和 ++i 嗎?2021-03-15
- jvm是如何執行i = i++ + ++i的,你知道嗎?2021-09-09JVM
- 計算機I/O與I/O模型2019-05-10計算機模型
- I/O流中的BufferedXXXStream與DataXXXStream、ObjectXXStream2020-10-06Object
- 你以為面試官在問深拷貝的時候,僅僅是在問深拷貝嗎?2020-07-29面試
- Hadoop的I/O操作2021-09-09Hadoop
- I/O流2018-07-26
- Java I/O2024-07-07Java
- 你真的瞭解 i++, ++i 和 i+++++i 以及 i+++i++ 嗎?2018-11-30
- 作為技術面試官,我在面試時考慮什麼?2019-10-21面試
- <span>得瑟的時候就是你要倒黴的時候。</span>2020-09-04
- Spring I/O 2018影片釋出中2018-09-08Spring
- 設計模式中巧記I/O2020-04-19設計模式
- 在EntityFramework6中管理DbContext的正確方式(1)【考慮的關鍵點】2018-05-28FrameworkContext
- 程式設計師,你焦慮嗎?2018-11-15程式設計師
- Firebase 在 Google I/O 2018上有什麼更新?2019-03-03Go
- ARCore 在 Google I/O 2018 中有什麼更新2018-05-09Go
- 現在到了招聘自閉症人才的時候了 - Matt2022-02-23
- [自動化執行]沒用過Ansible,你的自動化任務會考慮用它嗎?2021-09-09