鸿蒙内核源码分析(内存映射篇) | 虚拟地址<->物理地址是如何映射的 | 百篇博客分析HarmonyOS源码 | v15.02
2021/4/25 7:28:47
本文主要是介绍鸿蒙内核源码分析(内存映射篇) | 虚拟地址<->物理地址是如何映射的 | 百篇博客分析HarmonyOS源码 | v15.02,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
百万汉字注解 >> 精读内核源码,中文注解分析, 深挖地基工程,大脑永久记忆,四大码仓每日同步更新< gitee | github | csdn | coding >
百篇博客分析 >> 故事说内核,问答式导读,生活式比喻,表格化说明,图形化展示,主流站点定期更新中< oschina | csdn | 掘金 | harmony >
MMU的本质
虚拟地址(VA): 就是线性地址, 鸿蒙内存部分全是VA的身影, 是由编译器和链接器在定位程序时分配的,每个应用程序都使用相同的虚拟内存地址空间,而这些虚拟内存地址空间实际上分别映射到不同的实际物理内存空间上。CPU只知道虚拟地址,向虚拟地址要数据,但在其保护模式下很悲催地址信号在路上被MMU拦截了,MMU把虚拟地址换成了物理地址,从而拿到了真正的数据。
物理地址(PA):程序的指令和常量数据,全局变量数据以及运行时动态申请内存所分配的实际物理内存存放位置。
MMU采用页表(page table)来实现虚实地址转换,页表项除了描述虚拟页到物理页直接的转换外,还提供了页的访问权限(读,写,可执行)和存储属性。 MMU的本质是拿虚拟地址的高位(20位)做文章,低12位是页内偏移地址不会变。也就是说虚拟地址和物理地址的低12位是一样的,本篇详细讲述MMU是如何变戏法的。
MMU是通过两级页表结构:L1和L2来实现映射功能的,鸿蒙内核当然也实现了这两级页表转换的实现。本篇是系列篇关于内存部分最满意的一篇,也是最不好理解的一篇, 强烈建议结合源码看.鸿蒙内核源码注释 < G| G| C| C >
一级页表L1
L1页表将全部的4G地址空间划分为4096个1M的节,页表中每一项(页表项)32位,其内容是L2页表基地址或某个1M物理内存的基地址。虚拟地址的高12位用于对页表项定位,也就是4096个页面项的索引,L1页表的基地址,也叫转换表基地址,存放在CP15的C2(TTB)寄存器中,鸿蒙内核源码分析(内存汇编篇)中有详细的描述,自行翻看。
L1页表项有三种描述格式,鸿蒙源码如下。
/* L1 descriptor type */ #define MMU_DESCRIPTOR_L1_TYPE_INVALID (0x0 << 0) #define MMU_DESCRIPTOR_L1_TYPE_PAGE_TABLE (0x1 << 0) #define MMU_DESCRIPTOR_L1_TYPE_SECTION (0x2 << 0) #define MMU_DESCRIPTOR_L1_TYPE_MASK (0x3 << 0)
第一种:Fault(INVALID)页表项,表示对应虚拟地址未被映射,访问将产生一个数据中止异常。
第二种:PAGE_TABLE页表项,指向L2页表的页表项,意思就是把1M分成更多的页(256*4K)
第三种:SECTION页表项 ,指向1M节的页表项
页表项的最低二位[1:0],用于定义页表项的类型,section页表项对应1M的节,直接使用页表项的最高12位替代虚拟地址的高12位即可得到物理地址。还是直接看鸿蒙源码来的清晰,每一行都加了详细的注释。
LOS_ArchMmuQuery
//通过虚拟地址查询物理地址 STATUS_T LOS_ArchMmuQuery(const LosArchMmu *archMmu, VADDR_T vaddr, PADDR_T *paddr, UINT32 *flags) {//archMmu->virtTtb:转换表基地址 PTE_T l1Entry = OsGetPte1(archMmu->virtTtb, vaddr);//获取PTE vaddr右移20位 得到L1描述子地址 PTE_T l2Entry; PTE_T* l2Base = NULL; if (OsIsPte1Invalid(l1Entry)) {//判断L1描述子地址是否有效 return LOS_ERRNO_VM_NOT_FOUND;//无效返回虚拟地址未查询到 } else if (OsIsPte1Section(l1Entry)) {// section页表项: l1Entry低二位是否为 10 if (paddr != NULL) {//物理地址 = 节基地址(section页表项的高12位) + 虚拟地址低20位 *paddr = MMU_DESCRIPTOR_L1_SECTION_ADDR(l1Entry) + (vaddr & (MMU_DESCRIPTOR_L1_SMALL_SIZE - 1)); } if (flags != NULL) { OsCvtSecAttsToFlags(l1Entry, flags);//获取虚拟内存的flag信息 } } else if (OsIsPte1PageTable(l1Entry)) {//PAGE_TABLE页表项: l1Entry低二位是否为 01 l2Base = OsGetPte2BasePtr(l1Entry);//获取L2转换表基地址 if (l2Base == NULL) { return LOS_ERRNO_VM_NOT_FOUND; } l2Entry = OsGetPte2(l2Base, vaddr);//获取L2描述子地址 if (OsIsPte2SmallPage(l2Entry) || OsIsPte2SmallPageXN(l2Entry)) { if (paddr != NULL) {//物理地址 = 小页基地址(L2页表项的高20位) + 虚拟地址低12位 *paddr = MMU_DESCRIPTOR_L2_SMALL_PAGE_ADDR(l2Entry) + (vaddr & (MMU_DESCRIPTOR_L2_SMALL_SIZE - 1)); } if (flags != NULL) { OsCvtPte2AttsToFlags(l1Entry, l2Entry, flags);//获取虚拟内存的flag信息 } } else if (OsIsPte2LargePage(l2Entry)) {//鸿蒙目前暂不支持64K大页,未来手机版应该会支持。 LOS_Panic("%s %d, large page unimplemented\n", __FUNCTION__, __LINE__); } else { return LOS_ERRNO_VM_NOT_FOUND; } } return LOS_OK; }
这是鸿蒙内核对地址使用最频繁的功能,通过虚拟地址得到物理地址和flag信息,看下哪些地方会调用到它。
二级页表L2
L1页表项表示1M的地址范围,L2把1M分成更多的小页,鸿蒙内核 一页按4K算,所以被分成 256个小页。
L2页表中包含256个页表项,每个32位(4个字节),L2页表需要 256*4 = 1K的空间,必须按1K对齐,每个L2页表项将4K的虚拟内存地址转换为物理地址,每个L2页面项都给出了一个4K的页基地址。
L2页表项有三种格式:
/* L2 descriptor type */ #define MMU_DESCRIPTOR_L2_TYPE_INVALID (0x0 << 0) #define MMU_DESCRIPTOR_L2_TYPE_LARGE_PAGE (0x1 << 0) #define MMU_DESCRIPTOR_L2_TYPE_SMALL_PAGE (0x2 << 0) #define MMU_DESCRIPTOR_L2_TYPE_SMALL_PAGE_XN (0x3 << 0) #define MMU_DESCRIPTOR_L2_TYPE_MASK (0x3 << 0)
第一种:Fault(INVALID)页表项,表示对应虚拟地址未被映射,访问将产生一个数据中止异常。
第二种:大页表项,包含一个指向64K页的指针,但鸿蒙内核并没有实现大页表的支持,给出了未实现的提示
if (OsIsPte2LargePage(l2Entry)) { LOS_Panic("%s %d, large page unimplemented\n", __FUNCTION__, __LINE__); }
第三种:小页表项,包含一个指向4K页的指针。
映射初始化的过程
先看调用和被调用的关系
//启动映射初始化 VOID OsInitMappingStartUp(VOID) { OsArmInvalidateTlbBarrier();//使TLB失效 OsSwitchTmpTTB();//切换到临时TTB OsSetKSectionAttr();//设置内核段(text,rodata,bss)映射 OsArchMmuInitPerCPU();//初始化CPU与mmu相关信息 }
干脆利落,调用了四个函数,其中三个在鸿蒙内核源码分析(内存汇编篇)有涉及,不展开讲,这里说OsSetKSectionAttr
它实现了内核空间各个区的映射,内核本身也是程序,鸿蒙把内核空间在物理内存上就独立开来了,也就是说在物理内存上有一段区域是只给内核空间享用的,从根上就把内核和APP 空间隔离了,里面放的是内核的重要数据(包括代码,常量和全局变量),具体看代码,代码很长,整个函数全贴出来了,都加上了注释。
OsSetKSectionAttr 内核空间的设置和映射
typedef struct ArchMmuInitMapping { PADDR_T phys;//物理地址 VADDR_T virt;//虚拟地址 size_t size;//大小 unsigned int flags;//标识 读/写/.. VM_MAP_REGION_FLAG_PERM_* const char *name;//名称 } LosArchMmuInitMapping; VADDR_T *OsGFirstTableGet() { return (VADDR_T *)g_firstPageTable;//UINT8 g_firstPageTable[MMU_DESCRIPTOR_L1_SMALL_ENTRY_NUMBERS] } //设置内核空间段属性,可看出内核空间是固定映射到物理地址 STATIC VOID OsSetKSectionAttr(VOID) { /* every section should be page aligned */ UINTPTR textStart = (UINTPTR)&__text_start;//代码段开始位置 UINTPTR textEnd = (UINTPTR)&__text_end;//代码段结束位置 UINTPTR rodataStart = (UINTPTR)&__rodata_start;//常量只读段开始位置 UINTPTR rodataEnd = (UINTPTR)&__rodata_end;//常量只读段结束位置 UINTPTR ramDataStart = (UINTPTR)&__ram_data_start;//全局变量段开始位置 UINTPTR bssEnd = (UINTPTR)&__bss_end;//bss结束位置 UINT32 bssEndBoundary = ROUNDUP(bssEnd, MB); LosArchMmuInitMapping mmuKernelMappings[] = { { .phys = SYS_MEM_BASE + textStart - KERNEL_VMM_BASE,//映射物理内存位置 .virt = textStart,//内核代码区 .size = ROUNDUP(textEnd - textStart, MMU_DESCRIPTOR_L2_SMALL_SIZE),//代码区大小 .flags = VM_MAP_REGION_FLAG_PERM_READ | VM_MAP_REGION_FLAG_PERM_EXECUTE,//代码段可读,可执行 .name = "kernel_text" }, { .phys = SYS_MEM_BASE + rodataStart - KERNEL_VMM_BASE,//映射物理内存位置 .virt = rodataStart,//内核常量区 .size = ROUNDUP(rodataEnd - rodataStart, MMU_DESCRIPTOR_L2_SMALL_SIZE),//4K对齐 .flags = VM_MAP_REGION_FLAG_PERM_READ,//常量段只读 .name = "kernel_rodata" }, { .phys = SYS_MEM_BASE + ramDataStart - KERNEL_VMM_BASE,//映射物理内存位置 .virt = ramDataStart, .size = ROUNDUP(bssEndBoundary - ramDataStart, MMU_DESCRIPTOR_L2_SMALL_SIZE), .flags = VM_MAP_REGION_FLAG_PERM_READ | VM_MAP_REGION_FLAG_PERM_WRITE,//全局变量区可读可写 .name = "kernel_data_bss" } }; LosVmSpace *kSpace = LOS_GetKVmSpace();//获取内核空间 status_t status; UINT32 length; paddr_t oldTtPhyBase; int i; LosArchMmuInitMapping *kernelMap = NULL;//内核映射 UINT32 kmallocLength; /* use second-level mapping of default READ and WRITE */ kSpace->archMmu.virtTtb = (PTE_T *)g_firstPageTable;//__attribute__((section(".bss.prebss.translation_table"))) UINT8 g_firstPageTable[MMU_DESCRIPTOR_L1_SMALL_ENTRY_NUMBERS]; kSpace->archMmu.physTtb = LOS_PaddrQuery(kSpace->archMmu.virtTtb);//通过TTB虚拟地址查询TTB物理地址 status = LOS_ArchMmuUnmap(&kSpace->archMmu, KERNEL_VMM_BASE, (bssEndBoundary - KERNEL_VMM_BASE) >> MMU_DESCRIPTOR_L2_SMALL_SHIFT);//解绑 bssEndBoundary - KERNEL_VMM_BASE 映射 if (status != ((bssEndBoundary - KERNEL_VMM_BASE) >> MMU_DESCRIPTOR_L2_SMALL_SHIFT)) {//解绑失败 VM_ERR("unmap failed, status: %d", status); return; } //映射 textStart - KERNEL_VMM_BASE 区 status = LOS_ArchMmuMap(&kSpace->archMmu, KERNEL_VMM_BASE, SYS_MEM_BASE, (textStart - KERNEL_VMM_BASE) >> MMU_DESCRIPTOR_L2_SMALL_SHIFT, VM_MAP_REGION_FLAG_PERM_READ | VM_MAP_REGION_FLAG_PERM_WRITE | VM_MAP_REGION_FLAG_PERM_EXECUTE); if (status != ((textStart - KERNEL_VMM_BASE) >> MMU_DESCRIPTOR_L2_SMALL_SHIFT)) { VM_ERR("mmap failed, status: %d", status); return; } length = sizeof(mmuKernelMappings) / sizeof(LosArchMmuInitMapping); for (i = 0; i < length; i++) {//对mmuKernelMappings一一映射好 kernelMap = &mmuKernelMappings[i]; status = LOS_ArchMmuMap(&kSpace->archMmu, kernelMap->virt, kernelMap->phys, kernelMap->size >> MMU_DESCRIPTOR_L2_SMALL_SHIFT, kernelMap->flags); if (status != (kernelMap->size >> MMU_DESCRIPTOR_L2_SMALL_SHIFT)) { VM_ERR("mmap failed, status: %d", status); return; } LOS_VmSpaceReserve(kSpace, kernelMap->size, kernelMap->virt);//保留区 } //将剩余空间映射好 kmallocLength = KERNEL_VMM_BASE + SYS_MEM_SIZE_DEFAULT - bssEndBoundary; status = LOS_ArchMmuMap(&kSpace->archMmu, bssEndBoundary, SYS_MEM_BASE + bssEndBoundary - KERNEL_VMM_BASE, kmallocLength >> MMU_DESCRIPTOR_L2_SMALL_SHIFT, VM_MAP_REGION_FLAG_PERM_READ | VM_MAP_REGION_FLAG_PERM_WRITE); if (status != (kmallocLength >> MMU_DESCRIPTOR_L2_SMALL_SHIFT)) { VM_ERR("unmap failed, status: %d", status); return; } LOS_VmSpaceReserve(kSpace, kmallocLength, bssEndBoundary); /* we need free tmp ttbase */ oldTtPhyBase = OsArmReadTtbr0();//读取TTB值 oldTtPhyBase = oldTtPhyBase & MMU_DESCRIPTOR_L2_SMALL_FRAME; OsArmWriteTtbr0(kSpace->archMmu.physTtb | MMU_TTBRx_FLAGS);//内核页表基地址写入CP15 c2(TTB寄存器) ISB; /* we changed page table entry, so we need to clean TLB here */ OsCleanTLB();//清空TLB缓冲区 (VOID)LOS_MemFree(m_aucSysMem0, (VOID *)(UINTPTR)(oldTtPhyBase - SYS_MEM_BASE + KERNEL_VMM_BASE));//释放内存池 }
LOS_ArchMmuMap
mmu的map 就是生成L1,L2页表项的过程,以供虚实地址的转换使用,还是直接看代码吧,代码说明一切!
//所谓的 map 就是 生成L1,L2页表项的过程 status_t LOS_ArchMmuMap(LosArchMmu *archMmu, VADDR_T vaddr, PADDR_T paddr, size_t count, UINT32 flags) { PTE_T l1Entry; UINT32 saveCounts = 0; INT32 mapped = 0; INT32 checkRst; checkRst = OsMapParamCheck(flags, vaddr, paddr);//检查参数 if (checkRst < 0) { return checkRst; } /* see what kind of mapping we can use */ while (count > 0) { if (MMU_DESCRIPTOR_IS_L1_SIZE_ALIGNED(vaddr) && //虚拟地址和物理地址对齐 0x100000(1M)时采用 MMU_DESCRIPTOR_IS_L1_SIZE_ALIGNED(paddr) && //section页表项格式 count >= MMU_DESCRIPTOR_L2_NUMBERS_PER_L1) { //MMU_DESCRIPTOR_L2_NUMBERS_PER_L1 = 0x100 /* compute the arch flags for L1 sections cache, r ,w ,x, domain and type */ saveCounts = OsMapSection(archMmu, flags, &vaddr, &paddr, &count);//生成L1 section类型页表项并保存 } else { /* have to use a L2 mapping, we only allocate 4KB for L1, support 0 ~ 1GB */ l1Entry = OsGetPte1(archMmu->virtTtb, vaddr);//获取L1页面项 if (OsIsPte1Invalid(l1Entry)) {//L1 fault页面项类型 OsMapL1PTE(archMmu, &l1Entry, vaddr, flags);//生成L1 page table类型页表项并保存 saveCounts = OsMapL2PageContinous(l1Entry, flags, &vaddr, &paddr, &count);//生成L2 页表项目并保存 } else if (OsIsPte1PageTable(l1Entry)) {//L1 page table页面项类型 saveCounts = OsMapL2PageContinous(l1Entry, flags, &vaddr, &paddr, &count);//生成L2 页表项目并保存 } else { LOS_Panic("%s %d, unimplemented tt_entry %x\n", __FUNCTION__, __LINE__, l1Entry); } } mapped += saveCounts; } return mapped; } STATIC UINT32 OsMapL2PageContinous(PTE_T pte1, UINT32 flags, VADDR_T *vaddr, PADDR_T *paddr, UINT32 *count) { PTE_T *pte2BasePtr = NULL; UINT32 archFlags; UINT32 saveCounts; pte2BasePtr = OsGetPte2BasePtr(pte1); if (pte2BasePtr == NULL) { LOS_Panic("%s %d, pte1 %#x error\n", __FUNCTION__, __LINE__, pte1); } /* compute the arch flags for L2 4K pages */ archFlags = OsCvtPte2FlagsToAttrs(flags); saveCounts = OsSavePte2Continuous(pte2BasePtr, OsGetPte2Index(*vaddr), *paddr | archFlags, *count); *paddr += (saveCounts << MMU_DESCRIPTOR_L2_SMALL_SHIFT); *vaddr += (saveCounts << MMU_DESCRIPTOR_L2_SMALL_SHIFT); *count -= saveCounts; return saveCounts; }
OsMapL2PageContinous 没有加注释,希望你别太懒,赶紧动起来,到这里应该都能看懂了!最好能结合 鸿蒙内核源码分析(内存汇编篇)一起看理解会更深透。
鸿蒙源码百篇博客 往期回顾
v44.03 (中断管理篇) | 硬中断的实现<>观察者模式 < csdn | harmony | 掘金 >
v43.03 (中断概念篇) | 外人眼中权势滔天的当红海公公 < csdn | harmony | 掘金 >
v42.03 (中断切换篇) | 中断切换到底在切换什么? < csdn | harmony | 掘金 >
v41.03 (任务切换篇) | 汇编逐行注解分析任务上下文 < csdn | harmony | 掘金 >
v40.03 (汇编汇总篇) | 所有的汇编代码都在这里 < csdn | harmony | 掘金 >
v39.03 (异常接管篇) | 社会很单纯,复杂的是人 < csdn | harmony | 掘金 >
v38.03 (寄存器篇) | ARM所有寄存器一网打尽,不再神秘 < csdn | harmony | 掘金 >
v37.03 (系统调用篇) | 全盘解剖系统调用实现过程 < csdn | harmony | 掘金 >
v36.03 (工作模式篇) | CPU是韦小宝,有哪七个老婆? < csdn | harmony | 掘金 >
v35.03 (时间管理篇) | Tick是操作系统的基本时间单位 < csdn | harmony | 掘金 >
v34.03 (原子操作篇) | 是谁在为原子操作保驾护航? < csdn | harmony | 掘金 >
v33.03 (消息队列篇) | 进程间如何异步解耦传递大数据 ? < csdn | harmony | 掘金 >
v32.03 (CPU篇) | 内核是如何描述CPU的? < csdn | harmony | 掘金 >
v31.03 (定时器篇) | 内核最高优先级任务是谁? < csdn | harmony | 掘金 >
v30.03 (事件控制篇) | 任务间多对多的同步方案 < csdn | harmony | 掘金 >
v29.03 (信号量篇) | 信号量解决任务同步问题 < csdn | harmony | 掘金 >
v28.03 (进程通讯篇) | 进程间通讯有哪九大方式? < csdn | harmony | 掘金 >
v27.03 (互斥锁篇) | 互斥锁比自旋锁可丰满许多 < csdn | harmony | 掘金 >
v26.03 (自旋锁篇) | 想为自旋锁立贞节牌坊! < csdn | harmony | 掘金 >
v25.03 (并发并行篇) | 怎么记住并发并行的区别? < csdn | harmony | 掘金 >
v24.03 (进程概念篇) | 进程在管理哪些资源? < csdn | harmony | 掘金 >
v23.02 (汇编传参篇) | 汇编如何传递复杂的参数? < csdn | harmony | 掘金 >
v22.02 (汇编基础篇) | CPU在哪里打卡上班? < csdn | harmony | 掘金 >
v21.02 (线程概念篇) | 是谁在不断的折腾CPU? < csdn | harmony | 掘金 >
v20.02 (用栈方式篇) | 栈是构建底层运行的基础 < csdn | harmony | 掘金 >
v19.02 (位图管理篇) | 为何进程和线程优先级都是32个? < csdn | harmony | 掘金 >
v18.02 (源码结构篇) | 内核500问你能答对多少? < csdn | harmony | 掘金 >
v17.02 (物理内存篇) | 这样记伙伴算法永远不会忘 < csdn | harmony | 掘金 >
v16.02 (内存规则篇) | 内存管理到底在管什么? < csdn | harmony | 掘金 >
v15.02 (内存映射篇) | 什么是内存最重要的实现基础 ? < csdn | harmony | 掘金 >
v14.02 (内存汇编篇) | 什么是虚拟内存的实现基础? < csdn | harmony | 掘金 >
v13.02 (源码注释篇) | 热爱是所有的理由和答案 < csdn | harmony | 掘金 >
v12.02 (内存管理篇) | 虚拟内存全景图是怎样的? < csdn | harmony | 掘金 >
v11.02 (内存分配篇) | 内存有哪些分配方式? < csdn | harmony | 掘金 >
v10.02 (内存主奴篇) | 紫禁城的主子和奴才如何相处? < csdn | harmony | 掘金 >
v09.02 (调度故事篇) | 用故事说内核调度 < csdn | harmony | 掘金 >
v08.02 (总目录) | 百万汉字注解 百篇博客分析 < csdn | harmony | 掘金 >
v07.02 (调度机制篇) | 任务是如何被调度执行的? < csdn | harmony | 掘金 >
v06.02 (调度队列篇) | 就绪队列对调度的作用 < csdn | harmony | 掘金 >
v05.02 (任务管理篇) | 谁在让CPU忙忙碌碌? < csdn | harmony | 掘金 >
v04.02 (任务调度篇) | 任务是内核调度的单元 < csdn | harmony | 掘金 >
v03.02 (时钟任务篇) | 触发调度最大的动力来自哪里? < csdn | harmony | 掘金 >
v02.02 (进程管理篇) | 进程是内核资源管理单元 < csdn | harmony | 掘金 >
v01.09 (双向链表篇) | 谁是内核最重要结构体? < csdn | harmony | 掘金 >
参与贡献
访问注解仓库地址
Fork 本仓库 >> 新建 Feat_xxx 分支 >> 提交代码注解 >> 新建 Pull Request
新建 Issue
喜欢请大方 点赞+关注+收藏 吧
关注「鸿蒙内核源码分析」公众号,百万汉字注解 + 百篇博客分析 => 深挖鸿蒙内核源码
各大站点搜 "鸿蒙内核源码分析" .欢迎转载,请注明出处.
这篇关于鸿蒙内核源码分析(内存映射篇) | 虚拟地址<->物理地址是如何映射的 | 百篇博客分析HarmonyOS源码 | v15.02的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-23Springboot应用的多环境打包入门
- 2024-11-23Springboot应用的生产发布入门教程
- 2024-11-23Python编程入门指南
- 2024-11-23Java创业入门:从零开始的编程之旅
- 2024-11-23Java创业入门:新手必读的Java编程与创业指南
- 2024-11-23Java对接阿里云智能语音服务入门详解
- 2024-11-23Java对接阿里云智能语音服务入门教程
- 2024-11-23JAVA对接阿里云智能语音服务入门教程
- 2024-11-23Java副业入门:初学者的简单教程
- 2024-11-23JAVA副业入门:初学者的实战指南