APC 篇—— APC 挂入
写在前面
此系列是本人一个字一个字码出来的,包括示例和实验截图。由于系统内核的复杂性,故可能有错误或者不全面的地方,如有错误,欢迎批评指正,本教程将会长期更新。 如有好的建议,欢迎反馈。码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作。如想转载,请把我的转载信息附在文章后面,并声明我的个人信息和本人博客地址即可,但必须事先通知我。
你如果是从中间插过来看的,请仔细阅读 羽夏看Win系统内核——简述 ,方便学习本教程。
看此教程之前,问几个问题,基础知识储备好了吗?保护模式篇学会了吗?练习做完了吗?没有的话就不要继续了。
华丽的分割线
NtReadVirtualMemory 分析
由于是仅仅分析挂靠时该函数是如何备份和恢复APC
队列的,为了缩短篇幅增加可读性,我会尽可能使用IDA
翻译的伪代码,你的伪代码结果应该和我的不一样,因为我进行了一些重命名操作。我们先定位到NtReadVirtualMemory
这个伪代码:
NTSTATUS __stdcall NtReadVirtualMemory(HANDLE ProcessHandle, PVOID BaseAddress, PVOID Buffer, SIZE_T NumberOfBytesToRead, PSIZE_T NumberOfBytesRead)
{
_KTHREAD *v5; // edi
PSIZE_T v6; // ebx
int v8; // [esp+10h] [ebp-28h] BYREF
PVOID Object; // [esp+14h] [ebp-24h] BYREF
KPROCESSOR_MODE AccessMode[4]; // [esp+18h] [ebp-20h]
NTSTATUS v11; // [esp+1Ch] [ebp-1Ch]
CPPEH_RECORD ms_exc; // [esp+20h] [ebp-18h]
v5 = KeGetCurrentThread();
AccessMode[0] = v5->PreviousMode;
if ( AccessMode[0] )
{
if ( BaseAddress + NumberOfBytesToRead < BaseAddress
|| Buffer + NumberOfBytesToRead < Buffer
|| BaseAddress + NumberOfBytesToRead > MmHighestUserAddress
|| Buffer + NumberOfBytesToRead > MmHighestUserAddress )
{
return 0xC0000005;
}
v6 = NumberOfBytesRead;
if ( NumberOfBytesRead )
{
ms_exc.registration.TryLevel = 0;
if ( NumberOfBytesRead >= MmUserProbeAddress )
*MmUserProbeAddress = 0;
*NumberOfBytesRead = *NumberOfBytesRead;
ms_exc.registration.TryLevel = -1;
}
}
else
{
v6 = NumberOfBytesRead;
}
v8 = 0;
v11 = 0;
if ( NumberOfBytesToRead )
{
v11 = ObReferenceObjectByHandle(ProcessHandle, 0x10u, PsProcessType, AccessMode[0], &Object, 0);
if ( !v11 )
{
v11 = MmCopyVirtualMemory(
Object,
BaseAddress,
v5->ApcState.Process,
Buffer,
NumberOfBytesToRead,
AccessMode[0],
&v8);
ObfDereferenceObject(Object);
}
}
if ( v6 )
{
*v6 = v8;
ms_exc.registration.TryLevel = -1;
}
return v11;
}
我们可以看到,该函数实现内存拷贝是通过MmCopyVirtualMemory
这个函数实现的,我们点击去看看:
NTSTATUS __stdcall MmCopyVirtualMemory(PEX_RUNDOWN_REF RunRef, int a2, PRKPROCESS KPROCESS, volatile void *Address, SIZE_T Length, KPROCESSOR_MODE AccessMode, int a7)
{
struct _KPROCESS *v8; // ebx
PRKPROCESS kprocess; // ecx
NTSTATUS res; // esi
struct _EX_RUNDOWN_REF *RunRefa; // [esp+8h] [ebp+8h]
if ( !Length )
return 0;
v8 = RunRef;
kprocess = RunRef;
if ( RunRef == KeGetCurrentThread()->ApcState.Process )
kprocess = KPROCESS;
RunRefa = &kprocess[1].ProfileListHead.Blink;
if ( !ExAcquireRundownProtection(&kprocess[1].ProfileListHead.Blink) )
return STATUS_PROCESS_IS_TERMINATING;
if ( Length <= 0x1FF )
goto LABEL_10;
res = MiDoMappedCopy(v8, a2, KPROCESS, Address, Length, AccessMode, a7);
if ( res == STATUS_WORKING_SET_QUOTA )
{
*a7 = 0;
LABEL_10:
res = MiDoPoolCopy(v8, a2, KPROCESS, Address, Length, AccessMode, a7);
}
ExReleaseRundownProtection(RunRefa);
return res;
}
你可能看到一个新奇的函数ExAcquireRundownProtection
,这个函数是申请一个锁,从网上查阅翻译过来是停运保护(RundownProtection
)锁,名字怪怪的听起来怪怪的。
这个不涉及我们的核心,我们继续分析,发现它内部又是通过MiDoMappedCopy
实现进程内存读取的:
NTSTATUS __stdcall MiDoMappedCopy(PRKPROCESS PROCESS, int a2, PRKPROCESS a3, volatile void *Address, SIZE_T Length, KPROCESSOR_MODE AccessMode, int a7)
{
// [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]
v13 = 0;
v22 = a2;
v17 = Address;
v7 = 0xE000;
if ( Length <= 0xE000 )
v7 = Length;
v16 = &MemoryDescriptorList;
Length_1 = Length;
v19 = v7;
v20 = 0;
v14 = 0;
v15 = 0;
while ( Length_1 )
{
if ( Length_1 < v19 )
v19 = Length_1;
KeStackAttachProcess(PROCESS, &ApcState);
BaseAddress = 0;
v12 = 0;
v11 = 0;
ms_exc.registration.TryLevel = 0;
if ( v22 == a2 && AccessMode )
{
v20 = 1;
if ( Length && (a2 + Length < a2 || a2 + Length > MmUserProbeAddress) )
ExRaiseAccessViolation();
v20 = 0;
}
MemoryDescriptorList.Next = 0;
MemoryDescriptorList.Size = 4 * (((v22 & 0xFFF) + v19 + 0xFFF) >> 12) + 28;
MemoryDescriptorList.MdlFlags = 0;
MemoryDescriptorList.StartVa = (v22 & 0xFFFFF000);
MemoryDescriptorList.ByteOffset = v22 & 0xFFF;
MemoryDescriptorList.ByteCount = v19;
MmProbeAndLockPages(&MemoryDescriptorList, AccessMode, IoReadAccess);
v12 = 1;
BaseAddress = MmMapLockedPagesSpecifyCache(&MemoryDescriptorList, 0, MmCached, 0, 0, 0x20u);
if ( !BaseAddress )
{
v13 = 1;
ExRaiseStatus(STATUS_INSUFFICIENT_RESOURCES);
}
KeUnstackDetachProcess(&ApcState);
KeStackAttachProcess(a3, &ApcState);
if ( v22 == a2 )
{
if ( AccessMode )
{
v20 = 1;
ProbeForWrite(Address, Length, 1u);
v20 = 0;
}
}
v11 = 1;
qmemcpy(v17, BaseAddress, v19);
ms_exc.registration.TryLevel = -1;
KeUnstackDetachProcess(&ApcState);
MmUnmapLockedPages(BaseAddress, &MemoryDescriptorList);
MmUnlockPages(&MemoryDescriptorList);
Length_1 -= v19;
v22 += v19;
v17 += v19;
}
*a7 = Length;
return STATUS_SUCCESS;
}
经过分析,发现与APC
备份恢复的都是在进程挂靠相关函数上:KeStackAttachProcess
和KeUnstackDetachProcess
。我们先看看KeStackAttachProcess
:
void __stdcall KeStackAttachProcess(PRKPROCESS PROCESS, PRKAPC_STATE ApcState)
{
_KTHREAD *CurrentThread; // esi
char PROCESSa; // [esp+10h] [ebp+8h]
CurrentThread = KeGetCurrentThread();
if ( KeGetPcr()->PrcbData.DpcRoutineActive )
KeBugCheckEx(
5u,
PROCESS,
CurrentThread->ApcState.Process,
CurrentThread->ApcStateIndex,
KeGetPcr()->PrcbData.DpcRoutineActive);
if ( CurrentThread->ApcState.Process == PROCESS )
{
ApcState->Process = 1;
}
else
{
PROCESSa = KeRaiseIrqlToDpcLevel();
if ( CurrentThread->ApcStateIndex )
{
KiAttachProcess(CurrentThread, PROCESS, PROCESSa, ApcState);
}
else
{
KiAttachProcess(CurrentThread, PROCESS, PROCESSa, &CurrentThread->SavedApcState);
ApcState->Process = 0;
}
}
}
重点我们来看看ApcStateIndex
,上一篇我们讲过,当正常状态为0,挂靠状态为1.也就是说,他将会走如下代码:
KiAttachProcess(CurrentThread, PROCESS, PROCESSa, ApcState);
点击去看看里面有啥代码:
void __stdcall KiAttachProcess(_KTHREAD *thread, PRKPROCESS Process, KIRQL irql, PRKAPC_STATE ApcState)
{
// [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]
++Process->StackCount;
KiMoveApcState(&thread->ApcState, ApcState);
InitializeListHead(thread->ApcState.ApcListHead);
InitializeListHead(&thread->ApcState.ApcListHead[1]);
thread->ApcState.Process = Process;
thread->ApcState.KernelApcInProgress = 0;
thread->ApcState.KernelApcPending = 0;
thread->ApcState.UserApcPending = 0;
if ( ApcState == &thread->SavedApcState )
{
thread->ApcStatePointer[0] = &thread->SavedApcState;
thread->ApcStatePointer[1] = &thread->ApcState;
thread->ApcStateIndex = 1;
}
if ( Process->State )
{
thread->State = 1;
thread->ProcessReadyQueue = 1;
v9 = Process->ReadyListHead.Blink;
thread->WaitListEntry.Flink = &Process->ReadyListHead;
thread->WaitListEntry.Blink = v9;
v9->Flink = &thread->WaitListEntry;
Process->ReadyListHead.Blink = &thread->WaitListEntry;
if ( Process->State == 1 )
{
Process->State = 2;
v10 = KiProcessInSwapListHead;
v11 = &Process->SwapListEntry;
Processa = &Process->SwapListEntry;
ApcStatea = KiProcessInSwapListHead;
do
{
v11->Next = v10;
v12 = v10;
v10 = ApcStatea;
_ECX = &KiProcessInSwapListHead;
_EDX = Processa;
__asm { cmpxchg [ecx], edx }
}
while ( ApcStatea != v12 );
KiSetSwapEvent();
}
thread->WaitIrql = irql;
KiSwapThread();
}
else
{
v4 = &Process->ReadyListHead;
while ( 1 )
{
v8 = v4->Flink;
if ( v4->Flink == v4 )
break;
v5 = v8->Flink;
v6 = v8 - 12;
v7 = v8->Blink;
v7->Flink = v5;
v5->Blink = v7;
BYTE1(v6[37].Flink) = 0;
KiReadyThread(v6);
}
KiSwapProcess(Process, ApcState->Process);
KiUnlockDispatcherDatabase(irql);
}
}
我们就可以看到里面与APC
备份相关操作了:
if ( ApcState == &thread->SavedApcState )
{
thread->ApcStatePointer[0] = &thread->SavedApcState;
thread->ApcStatePointer[1] = &thread->ApcState;
thread->ApcStateIndex = 1;
}
我们再来看看KeUnstackDetachProcess
这个函数:
void __stdcall KeUnstackDetachProcess(PRKAPC_STATE ApcState)
{
PRKAPC_STATE v1; // ebx
_KTHREAD *CurrentThread; // esi
_KPROCESS *CurrentProcess; // edi
int v4; // eax
int v7; // ecx
_KAPC_STATE *v8; // [esp-Ch] [ebp-20h]
int v9; // [esp+4h] [ebp-10h]
int v10; // [esp+Ch] [ebp-8h]
signed __int8 v11; // [esp+13h] [ebp-1h]
v1 = ApcState;
if ( ApcState->Process != 1 )
{
CurrentThread = KeGetCurrentThread();
v11 = KeRaiseIrqlToDpcLevel();
if ( !CurrentThread->ApcStateIndex
|| CurrentThread->ApcState.KernelApcInProgress
|| CurrentThread->ApcState.ApcListHead[0].Flink != &CurrentThread->ApcState
|| CurrentThread->ApcState.ApcListHead[1].Flink != &CurrentThread->ApcState.ApcListHead[1] )
{
KeBugCheck(6u);
}
CurrentProcess = CurrentThread->ApcState.Process;
if ( !--CurrentProcess->StackCount && CurrentProcess->ThreadListHead.Flink != &CurrentProcess->ThreadListHead )
{
CurrentProcess->State = 3;
v4 = KiProcessOutSwapListHead;
v10 = KiProcessOutSwapListHead;
do
{
CurrentProcess->SwapListEntry.Next = v4;
v9 = v4;
v4 = v10;
_ECX = &KiProcessOutSwapListHead;
_EDX = &CurrentProcess->SwapListEntry;
__asm { cmpxchg [ecx], edx }
}
while ( v10 != v9 );
KiSetSwapEvent();
v1 = ApcState;
}
v8 = &CurrentThread->ApcState;
if ( v1->Process )
{
KiMoveApcState(v1, v8);
}
else
{
KiMoveApcState(&CurrentThread->SavedApcState, v8);
CurrentThread->SavedApcState.Process = 0;
CurrentThread->ApcStatePointer[0] = &CurrentThread->ApcState;
CurrentThread->ApcStatePointer[1] = &CurrentThread->SavedApcState;
CurrentThread->ApcStateIndex = 0;
}
if ( CurrentThread->ApcState.ApcListHead[0].Flink != &CurrentThread->ApcState )
{
LOBYTE(v7) = 1;
CurrentThread->ApcState.KernelApcPending = 1;
HalRequestSoftwareInterrupt(v7);
}
KiSwapProcess(CurrentThread->ApcState.Process, CurrentProcess);
KiUnlockDispatcherDatabase(v11);
}
}
我们很快找到了与APC
恢复相关的代码:
if ( v1->Process )
{
KiMoveApcState(v1, v8);
}
else
{
KiMoveApcState(&CurrentThread->SavedApcState, v8);
CurrentThread->SavedApcState.Process = 0;
CurrentThread->ApcStatePointer[0] = &CurrentThread->ApcState;
CurrentThread->ApcStatePointer[1] = &CurrentThread->SavedApcState;
CurrentThread->ApcStateIndex = 0;
}
分析至此,本题就结束了。
QueueUserAPC 引发的血案
还记着 APC 篇——备用 APC 队列 提供的第一题的参考代码中的一行注释了吗?
DWORD WINAPI ThreadProc(VOID* Param)
{
for (int i =0 ;i<100;i++)
{
SleepEx(1000,TRUE); //思考为什么?
//Sleep(1000);
printf("Running\n");
}
return 0;
}
为什么我用SleepEx
函数而不是用Sleep
吗?你思考这个问题了吗?我们来看看下面几个图:
我们将SleepEx
函数用Sleep
替换,并注释掉主函数的Sleep
看看效果:
APC
正常被执行,接下来我们去掉注释掉主函数的Sleep
,继续运行看看:
这次竟然发现APC
没有执行,到底是为什么呢?我们改回原答案,就可以正常执行APC
了,也就是我在参考中给的效果图:
原因将会在本篇后部分进行揭晓。
KAPC
无论是正常状态还是挂靠状态,都有两个APC
队列,一个内核队列,一个用户队列。每当要挂入一个APC函数时,不管是内核APC
还是用户APC
,内核都要准备一个KAPC
的数据结构,并且将这个KAPC
结构挂到相应的APC
队列中。现在我们看看KAPC
的结构:
kd> dt _KAPC
ntdll!_KAPC
+0x000 Type : Int2B
+0x002 Size : Int2B
+0x004 Spare0 : Uint4B
+0x008 Thread : Ptr32 _KTHREAD
+0x00c ApcListEntry : _LIST_ENTRY
+0x014 KernelRoutine : Ptr32 void
+0x018 RundownRoutine : Ptr32 void
+0x01c NormalRoutine : Ptr32 void
+0x020 NormalContext : Ptr32 Void
+0x024 SystemArgument1 : Ptr32 Void
+0x028 SystemArgument2 : Ptr32 Void
+0x02c ApcStateIndex : Char
+0x02d ApcMode : Char
+0x02e Inserted : UChar
Type
指明结构体的类型,APC
类型为0x12
。
Size
该结构体的大小,值为0x30
。
Thread
指向目标线程的线程结构体的指针,因为任何一个APC
都是让目标线程进行完成。
ApcListEntry
APC
队列挂的位置。
KernelRoutine
指向一个函数,调用ExFreePoolWithTag
释放APC
。
NormalRoutine
存储着用户APC
总入口或真正的内核APC
函数地址,里面具体的细节将会在后面的文章进行介绍。
NormalContext
当为内核APC
,该成员存储着NULL
;如果为用户APC
,则为真正的APC
函数。
SystemArgument1
APC
函数的参数。
SystemArgument2
APC
函数的参数。
ApcStateIndex
挂哪个队列,有四个值:0、1、2、3,里面的细节将在后面进行介绍。
ApcMode
指示该APC
是内核APC
还是用户APC
。
Inserted
表示本APC
是否已挂入队列。挂入前值为0,挂入后值为1。
挂入流程
为了方便理解,我们先撸一下函数大体调用流程:
QueueUserAPC--用户层调用--> NtQueueApcThread -.->KeInitializeApc -.->KeInsertQueueApc-.->KiInsertQueueApc
其中QueueUserAPC
这个函数位于kernel32.dll
,它会调用内核模块的NtQueueApcThread
进行实现,经历过重重调用,使用KeInitializeApc
为APC
结构体分配内存并进行初始化,调用KeInsertQueueApc
进行插入到指定队列,而插入最终由KiInsertQueueApc
实现。
KeInitializeApc 函数说明
为了做好本篇练习,我们先过一下KeInitializeApc
的相关说明:
VOID KeInitializeApc
(
IN PKAPC Apc, //KAPC 指针
IN PKTHREAD Thread, //目标线程
IN KAPC_ENVIRONMENT TargetEnvironment, //四种状态
IN PKKERNEL_ROUTINE KernelRoutine, //销毁 KAPC 的函数地址
IN PKRUNDOWN_ROUTINE RundownRoutine OPTIONAL,
IN PKNORMAL_ROUTINE NormalRoutine, //用户 APC 总入口或者内核 APC 函数
IN KPROCESSOR_MODE Mode,//要插入用户 APC 队列还是内核 APC 队列
IN PVOID Context//内核APC:NULL,用户APC:真正的APC函数
)
ApcStateIndex 详解
该成员与KTHREAD + 0x165
偏移处的属性同名,但含义不一样。该ApcStateIndex
有四个值,如下面表格所示:
值 | 含义 |
---|---|
0 | 原始环境 |
1 | 挂靠环境 |
2 | 当前环境 |
3 | 插入APC时的当前环境 |
前两个值挺好理解,当值为0时,就是指线程的“亲生父母”;如果值为1时,就是指自己的“养父母”。后面的两个值比较绕,下面将会详细解释一下:
上一篇我们说过,线程在正常情况下ApcStatePointer[0]
指向ApcState
,ApcStatePointer[1]
指向SavedApcState
;而在挂靠情况下ApcStatePointer[0]
指向SavedApcState
,ApcStatePointer[1]
指向ApcState
。当值为2的时候,插入的是当前进程的队列。什么是当前队列,是我不管你环境是挂靠还是不挂靠,我就插入当前进程的APC
队列里面,以初始化APC
的时候为基准。还剩下最玄学的一个值,当值为3时,插入的是当前进程的APC
队列,此时有修复ApcStateIndex
的操作,以插入APC
的时候为基准。
KiInsertQueueApc 调用流程
为了降低本篇思考题难度,我把该函数的调用流程说一下:
- 根据
KAPC
结构中的ApcStateIndex
找到对应的APC
队列 - 再根据
KAPC
结构中的ApcMode
确定是用户队列还是内核队列 - 将
KAPC
挂到对应的队列中(挂到KAPC
的ApcListEntry
处) - 再根据
KAPC
结构中的Inserted
置1,标识当前的KAPC
为已插入状态 - 修改
KAPC_STATE
结构中的KernelApcPending
/UserApcPending
Alertable 详解
Alertable
属性位于KTHREAD
当中,如下所示:
kd> dt _KTHREAD
ntdll!_KTHREAD
...
+0x164 Alertable : UChar
...
我们可以发现很多与线程相关的结尾带Ex
的函数的参数都会有一个bAlertable
,举例如下:
DWORD SleepEx(
DWORD dwMilliseconds, // time-out interval
BOOL bAlertable // early completion option
);
DWORD WaitForSingleObjectEx(
HANDLE hHandle, // handle to object
DWORD dwMilliseconds, // time-out interval
BOOL bAlertable // alertable option
);
该值指示线程是否运行被APC
吵醒,我们开头说QueueUserAPC 引发的血案
解决办法就是由该属性捣的鬼。当该属性为0时,当前插入的用户APC
函数未必有机会执当UserApcPending = 0
时就会无法执行插入的APC
,如果Alertable = 1
,就会使UserApcPending = 1
,从而将目标线程唤醒,从等待链表中被摘出来,并挂到调度链表当中执行。
本节练习
本节的答案将会在下一节进行讲解,务必把本节练习做完后看下一个讲解内容。不要偷懒,实验是学习本教程的捷径。
俗话说得好,光说不练假把式,如下是本节相关的练习。如果练习没做好,就不要看下一节教程了,越到后面,不做练习的话容易夹生了,开始还明白,后来就真的一点都不明白了。本节练习不多,请保质保量的完成,本篇参考将会在正文给出。
1️⃣ 逆向分析QueueUserAPC
完整的调用流程。
2️⃣ 如果在一个无法被唤醒的线程插入一个APC
,然后紧接又插入一个,如果设置线程可被唤醒,那么它会执行几个APC
呢?请用代码论证。
下一篇
APC 篇—— APC 执行
APC 篇—— APC 挂入的更多相关文章
- APC 篇—— APC 执行
写在前面 此系列是本人一个字一个字码出来的,包括示例和实验截图.由于系统内核的复杂性,故可能有错误或者不全面的地方,如有错误,欢迎批评指正,本教程将会长期更新. 如有好的建议,欢迎反馈.码字不易, ...
- APC 篇——备用 APC 队列
写在前面 此系列是本人一个字一个字码出来的,包括示例和实验截图.由于系统内核的复杂性,故可能有错误或者不全面的地方,如有错误,欢迎批评指正,本教程将会长期更新. 如有好的建议,欢迎反馈.码字不易, ...
- 羽夏看Win系统内核—— APC 篇
写在前面 此系列是本人一个字一个字码出来的,包括示例和实验截图.由于系统内核的复杂性,故可能有错误或者不全面的地方,如有错误,欢迎批评指正,本教程将会长期更新. 如有好的建议,欢迎反馈.码字不易, ...
- APC 篇——初识 APC
写在前面 此系列是本人一个字一个字码出来的,包括示例和实验截图.由于系统内核的复杂性,故可能有错误或者不全面的地方,如有错误,欢迎批评指正,本教程将会长期更新. 如有好的建议,欢迎反馈.码字不易, ...
- C++入职学习篇--新员工入职(持续更新)
C++入职学习篇--新员工入职(持续更新) 本人菜鸟一枚,刚刚结束学业生涯,入职C++软件开发岗位,之前对C++一窍不通,刚刚入职,亚历山大,但为祖国和平发展,本人励志为中华崛起而奋斗,学不好C++誓 ...
- 让docker容器使用主机系统时间(挂入/etc/localtime)
-v挂入这个文件就可以了: -v /etc/localtime:/etc/localtime:ro
- APC 篇——总结与提升
写在前面 此系列是本人一个字一个字码出来的,包括示例和实验截图.由于系统内核的复杂性,故可能有错误或者不全面的地方,如有错误,欢迎批评指正,本教程将会长期更新. 如有好的建议,欢迎反馈.码字不易, ...
- iOS开发——实用篇Swift篇&QQ登入界面实现
QQ登入界面实现 我们知道在App Store中几乎所有软件都设计到账户的登入,而我们最常见的就是QQ,微信,在没有踏入程序员这条不归路之前,看到一个个的界面都感觉好高大上的样子. 在学习的过程中,自 ...
- [Erlang13]怎么把一个普通的进程挂入Supervisor监控树?
简单来说:应该是在调用的start_link返回一个{ok,Pid}就可以把这个进程放入监控树Supervisor里面: -module(worker). -author("zhongwen ...
随机推荐
- 【LeetCode】225. Implement Stack using Queues 解题报告(Python)
作者: 负雪明烛 id: fuxuemingzhu 个人博客: http://fuxuemingzhu.cn/ 目录 题目描述 题目大意 解题方法 日期 题目地址:https://leetcode.c ...
- 【LeetCode】870. Advantage Shuffle 解题报告(Python)
[LeetCode]870. Advantage Shuffle 解题报告(Python) 作者: 负雪明烛 id: fuxuemingzhu 个人博客: http://fuxuemingzhu.cn ...
- Hamburger Magi(hdu 3182)
Hamburger Magi Time Limit: 2000/1000 MS (Java/Others) Memory Limit: 32768/32768 K (Java/Others)To ...
- 【机器学*】k*邻算法-01
k临*算法(解决分类问题): 已知数据集,以及该数据对应类型 给出一个数据x,在已知数据集中选择最接*x的k条数据,根据这k条数据的类型判断x的类型 具体实现: from numpy import * ...
- [opencv]findcoutours函数使用
轮廓是定义或限定形状或对象的边或线,是机器视觉中的常用的概念,多用于目标检测.识别等任务. 关于OpenCV轮廓操作,尤其是级别及如何使用轮廓级别进行筛选等问题,相关文章比较少,正好最近用到,因此将其 ...
- SpringBoot 之 Dao层模拟数据库操作
单表操作: # src/main/java/com/wu/dao/DepartmentDao .java @Repository public class DepartmentDao { privat ...
- CSS基础 实战案例 模拟小米官方导航栏
效果图 html结构 <ul> <li><a href="#">Xiaomi手机</a></li> <li> ...
- update sql时,常记错同时更新多个参数用and,正确是用逗号
记录一下,经常记错的一个点,在update多个参数时,多个参数之间用and连接,这个时候,语句就会报错了 其实,正确的是用逗号隔开, 使用SQL中的update更新多个字段值,set后面的条件要用逗号 ...
- Docker_部署本地镜像仓库(6)
在部署本地镜像仓库之前,需要在主机上安装Docker.本地镜像仓库是registry镜像的一个实例,在Docker中运行. 1.创建本地镜像仓库服务 $ docker run -d -p 4000:5 ...
- element ui 动态菜单解决方案集锦
1.<分享一个VUE Element-UI 的多级菜单动态渲染的组件> 2.<饿了么组件库,element-ui开发精美的后台管理系统系列之(一)开发伸缩菜单> 3.<V ...