1. 项目认知这到底是个什么任务1.1 操作系统课程的经典项目形态“操作系统——MFC实现进程创建和通信”光看标题老读者应该能猜到八九分了——这是典型的操作系统课程大作业形态同时也是很多Windows桌面开发者的入门练手项目。它要求你借助MFC微软基础类库搭建一个带界面的小程序然后在这个小程序里完成两件事一是创建一个或多个子进程二是让父子进程之间能够交换数据。这类项目听起来不难但实际上手之后你会发现坑全藏在细节里。CreateProcess、匿名管道、标准输入输出重定向、句柄继承……这些概念单拎出来每一个都有文档可真要把它们串在一起让一个带对话框的MFC程序里安安稳稳跑出一个控制台子进程、再读回它的输出对初学者来说绝对是一道硬菜。我当年做这个项目时周围同学最普遍的状态是函数签名查了十遍代码抄了三版一运行就崩或者对话框直接卡死。原因很简单——知识是零散的没有一个完整的、正确的实现路径作为参照。这篇文章就是来填这个空白的。我会按一个可落地的完整示例来拆解从界面搭建、进程创建、管道通信到问题排查把每一步背后的原理和取舍讲透。无论你是正在做课程设计的学生还是想理解Windows进程机制的自学者跟着走一遍至少能少踩八成以上的坑。1.2 MFC在这里扮演什么角色先说MFC本身。有些人一听到MFC就觉得老、旧、过时这其实是个误读。MFC走到今天确实不如.NET或跨平台方案光鲜但它对Win32 API的封装非常薄意味着你能在这个“半裸奔”的框架里真切看到操作系统API是如何被调用的——这是它至今仍被许多高校选作系统编程课程载体的核心理由。具体到本项目中MFC负责的是三层事情界面层窗口、按钮、编辑框、文本框这些用MFC的对话框模板几分钟就能搭好。事件层按钮点击、菜单触发通过消息映射机制Message Map绑定处理函数。资源管理文档/视图架构、CString等封装类让字符串和句柄管理比纯SDK写起来省心不少。但你要有一个清醒的认知MFC只是壳真正干活的还是它内部包裹的Win32 API。进程创建的核心是CreateProcess通信的核心是CreatePipe、ReadFile、WriteFile、WaitForSingleObject这些清一色是系统级API。看懂这些APIMFC那层皮自然就豁然开朗了。1.3 进程通信的方式怎么选标题里明确写了“进程创建和通信”但通信方式并没有限定。我实际见过的作业方案集中在三种形态通信方式实现难度工程形象适用场景匿名管道中等子进程输出实时读回父子进程之间的单向/双向文本流命名管道较高网络风格透明、支持跨进程连接两个独立进程的持续交互Windows消息较低基于HWND发消息父子窗口间的轻量通知共享内存中等偏难文件映射内核对象大块数据的高效交换一个关键认知是匿名管道是实践中最常用、也是最能体现“进程边界”的方案因为它的核心机制是让子进程“继承”父进程创建的管道句柄再重定向标准输入输出——这就强迫你去理解句柄继承表、STARTUPINFO、内核对象引用计数这些真正有分量的概念。所以如果你没有特殊要求我强烈建议“通信1”这第一篇文章先把匿名管道做扎实后面再扩展其他方式会非常顺。2. 环境准备与界面骨架搭建2.1 开发环境配置的两种选择在开始写代码之前环境先要靠谱。我用过两种组合给出实际结论Visual Studio MFC组件年代无所谓VS2019、VS2022都行。安装时记得勾选“适用于最新生成工具的C MFC组件x86和x64”。非VS的替代方案如果你只有VSCode或其他编辑器理论上也可以配置MSVC编译器和Windows SDK但调试体验会打折。MFC项目涉及大量向导生成代码老实说还是VS全套最省心。如果用的是VS2019以上版本装好后记得确认一点项目属性 → 高级 → 字符集我建议直接选“使用Unicode字符集”。为什么要强调这个因为MFC默认新项目其实已经是Unicode但如果你是从网上抄了老代码很可能是ANSI年代的写法TCHAR那一套。两种字符集混在一起编译报错会让你一头雾水。干脆全程Unicode逻辑简单后面处理宽字符也不容易乱。2.2 对话框程序还是单文档程序这一步很多人上来就犹豫。我的建议非常明确做进程创建与通信这种偏工具的Demo选“基于对话框”的MFC应用别选文档视图架构。原因很实在对话框程序结构简单核心就是一个CDialogEx派生类事件响应都集中在Dlg类里代码量少出错好查。文档视图架构面向的是文档编辑类软件自带菜单、工具栏、状态栏对于这个项目来说完全是噪音。对话框里放几个按钮、几个编辑框布局直观演示效果好。创建过程VS里新建项目 → 搜索MFC → “MFC应用” → 应用程序类型选“基于对话框” → 其他选项里“使用Unicode库”勾上然后一路默认到底。向导会生成类名以你的项目名命名的CApp和CDlg后者就是主战场。2.3 界面布局的思路界面不用花哨但布局要有逻辑。我习惯做三个区域左侧操作区。一个“创建子进程”按钮一个“发送数据”按钮一个“终止子进程”按钮。按钮下面放一个编辑框用来输入你想发给子进程的文本如果子进程是一个能读标准输入的小程序这个编辑框才有意义如果只是运行cmd则可以实现交互式命令发送。右侧显示区。用一个多行编辑框属性里勾选Read-only、Multiline、Vertical Scroll或者更佳方案——CRichEditCtrl或ListBox用来显示子进程的输出、状态日志。注意建议用ListBox或RichEdit就不用在文本长度上操心。底部进程状态栏。用一个小小的编辑框或静态文本显示当前子进程的ID、状态运行中/已退出。进程ID很好拿CreateProcess返回的PROCESS_INFORMATION结构体里就有。我自己的经验是调试阶段一定要在界面上开辟一块“日志区”把每一步API调用的成功失败都打印出来。比如“CreatePipe成功”“CreateProcess成功子进程PIDxxx”。别嫌丑这东西在排查问题时比F5单步调试都快尤其是遇到API返回失败但错误码在64位/32位下打印混乱的情况。3. 进程创建的完整实现3.1 CreateProcess核心参数拆解创建进程这件事MFC没有提供专门的封装类所以直接用Win32的CreateProcess函数签名如下BOOL CreateProcess( LPCWSTR pszApplicationName, LPWSTR pszCommandLine, LPSECURITY_ATTRIBUTES psaProcess, LPSECURITY_ATTRIBUTES psaThread, BOOL fInheritHandles, DWORD fdwCreate, LPVOID pvEnvironment, LPCWSTR pszCurrentDirectory, LPSTARTUPINFOW psiStartInfo, LPPROCESS_INFORMATION ppiProcInfo );这十个参数每一个都有说法我把最容易出错的五个挑出来讲第一pszApplicationName和pszCommandLine的分工。这是最容易懵的地方。MSDN的规定是如果第一个参数传NULL系统就会从第二个参数里解析可执行文件路径如果两个都传第一个是完整的可执行文件路径第二个是命令行字符串。我个人的经验是干脆让第一个参数传入子进程exe的绝对路径第二个参数传除了exe之外的全部参数。这样逻辑最清晰。WCHAR cmdLine[] Lchild.exe -a param; // 注意可能需要可变缓冲区这里还有一个隐蔽的坑pszCommandLine参数的类型是LPWSTR不是LPCWSTR也就是说它要求一个可写的缓冲区因为CreateProcess可能会修改它。如果用字符串字面量直接传进去在特定配置下会导致访问违例。我的习惯是定义一个小数组拷贝一下或者干脆用std::wstring的data()方式绕开。第二fInheritHandles参数。这是个布尔值但它直接决定子进程能不能继承父进程的句柄——后面管道通信全靠它。如果传FALSE子进程不会继承任何句柄那管道就只能是摆设。这里一定要传TRUE。但要注意传TRUE意味着父进程中所有“可继承”的句柄都会被子进程“看到”。这意味着什么如果你为了线程安全创建了一个事件对象并且忘了把它的安全描述符设为不可继承某些诡异的句柄泄漏和协作问题就会发生。所以涉及继承时创建每个句柄的安全属性都要谨慎设置尽量做到“只想让子进程继承什么就只让它继承什么”。第三psiStartInfo和ppiProcInfo。STARTUPINFO结构体需要先清零初始化并且在设置STARTF_USESTDHANDLES标志后把进程的三个标准句柄hStdInput、hStdOutput、hStdError指向你创建的管道。这一块是通信的关键我放在下一节细讲。第四fdwCreate。可以填CREATE_NEW_CONSOLE表示给子进程单独开一个控制台窗口或者填0表示不创建。做演示项目时经常纠结要不要让子进程的窗口显示出来如果你只想要输出结果不想让黑框闪一下可以用CREATE_NO_WINDOW标志。但初学阶段我建议什么也不填即0或填CREATE_NEW_CONSOLE至少能肉眼看到子进程的状态——等调通了再去隐藏也不迟。第五返回值和错误码。失败时返回值是FALSE具体原因需要用GetLastError()去查。错误码不是玄学它是排障第一线索。常见的有ERROR_FILE_NOT_FOUND2exe路径不对或工作目录不对。ERROR_ACCESS_DENIED5权限不足或者你试图创建一个需要提权的程序。ERROR_INVALID_PARAMETER87结构体大小不对cb字段没初始化、字符串指针不对。ERROR_BAD_EXE_FORMAT19364位进程想启动32位程序而配置不当或者exe损坏。建议在代码里统一封装一个“失败即打印错误码”的宏或函数极大节约生命。3.2 在MFC中封装进程创建逻辑很多人习惯直接把CreateProcess代码写在按钮响应函数里其实不太利于复用和调试。我的习惯是写一个单独的CChildProcess类这个类负责封装CreateProcess、保存句柄和PID、提供Wait和Terminate方法。MFC程序里你完全可以混用标准C类和Win32 API没有任何问题。class CChildProcess { public: CChildProcess(); ~CChildProcess(); BOOL Start(const CString exePath, const CString cmdLine, HANDLE hStdInput, HANDLE hStdOutput, HANDLE hStdError); BOOL Wait(DWORD timeoutMs); BOOL Terminate(); DWORD GetExitCode() const; DWORD GetProcessId() const; private: PROCESS_INFORMATION m_pi; BOOL m_started; };构造函数里把m_pi整个用ZeroMemory清零析构函数里记得CloseHandle进程句柄和线程句柄。很多人只CloseHandle了进程句柄忘了线程句柄结果进程其实已经退出了但资源一直没释放干净任务管理器里看进程没了但系统资源还在算。Start的核心代码是这个样子BOOL CChildProcess::Start(const CString exePath, const CString cmdLine, HANDLE hStdInput, HANDLE hStdOutput, HANDLE hStdError) { if (m_started) return FALSE; STARTUPINFO si; ZeroMemory(si, sizeof(si)); si.cb sizeof(si); si.dwFlags STARTF_USESTDHANDLES; si.hStdInput hStdInput ? hStdInput : GetStdHandle(STD_INPUT_HANDLE); si.hStdOutput hStdOutput ? hStdOutput : GetStdHandle(STD_OUTPUT_HANDLE); si.hStdError hStdError ? hStdError : GetStdHandle(STD_ERROR_HANDLE); CString fullCmd exePath; if (!cmdLine.IsEmpty()) { fullCmd L ; fullCmd cmdLine; } // 注意CreateProcess 会修改命令行缓冲区必须复制到可写内存 std::vectorwchar_t mutableBuf(fullCmd.GetLength() 1); wcscpy_s(mutableBuf.data(), mutableBuf.size(), fullCmd.GetString()); BOOL ok CreateProcess( exePath.IsEmpty() ? NULL : exePath.GetString(), mutableBuf.data(), NULL, // 进程安全属性默认 NULL, // 线程安全属性默认 TRUE, // 句柄继承关键 0, // 创建标志 NULL, // 环境块默认继承 NULL, // 当前目录默认继承 si, m_pi); if (!ok) { // 记录 GetLastError() } m_started ok; return ok; }一个小细节exePath和fullCmd都往CreateProcess里传了系统会以第一个参数为准去加载可执行程序第二个参数当作命令行传给子进程。第一个参数传可读字符串没问题因为系统不会改它。第二个参数必须可改所以用vector拷贝一份。3.3 进程句柄与标识的获取CreateProcess成功后PROCESS_INFORMATION结构体里有四个成员hProcess子进程的内核句柄。用它可以做等待、终止、获取退出码。hThread子进程主线程的句柄。一般也用得上比如等待线程结束。dwProcessIdPID纯展示用或者作为唯一标识。dwThreadId主线程ID少用。强调一个超容易踩的坑hProcess句柄不是PID不代表进程本身。它只是一个“指向内核进程对象的引用”受句柄表管理。你可以在拿到后用GetProcessId(hProcess)反查PID也可能通过PID打开句柄但绝不要以为两者相等。很多同学项目里把这两个概念混为一谈后续调试心态会崩。拿到句柄之后对话框里如果想随时感知“子进程退出”可以用WaitForSingleObject(m_pi.hProcess, INFINITE)阻塞等待或者用MsgWaitForMultipleObjects进行非阻塞等待。简单演示里直接在按钮里等就行但如果界面不想卡最好开一个工作线程去等这个我放在第5节说。4. 进程通信的实现方案4.1 匿名管道通信原理匿名管道Anonymous Pipe说白了就是“内核里的一块缓冲区外加两个文件句柄”。一个句柄用来写数据一个句柄用来读数据。它最常用的工作模式是父进程创建管道得到读写两个句柄再把“写句柄”传给子进程作为它的标准输出“读句柄”则留在父进程里不断去读取子进程打印出来的内容。画个示意图手写风的伪图不用工具父进程 子进程 [读句柄] -----管道缓冲区------[写句柄作为stdout]核心API是CreatePipeBOOL CreatePipe( PHANDLE hReadPipe, PHANDLE hWritePipe, LPSECURITY_ATTRIBUTES lpPipeAttributes, DWORD nSize );四个参数里第三个参数最容易出错——lpPipeAttributes。它是一个SECURITY_ATTRIBUTES指针关键字段bInheritHandle必须设为TRUE否则你即便在CreateProcess里传了TRUE也没用因为管道句柄本身不可继承子进程就接不到那个写句柄。用完了记得关闭写句柄。有多少次程序卡死的根源就是父进程忘了关闭自己持有的写端句柄——父进程还握着写句柄不放读端ReadFile就一直等不到EOF文件结束标志可子进程早就退出了。这个现象极其经典我第5节还要重点讲。另外一个值得一提的细节传统匿名管道是半双工的即数据只能单向流动。如果你想让父子进程双向互发必须创建两条管道一条子进程的stdin、一条子进程的stdout。别嫌麻烦这就是Windows管道的工作方式。4.2 管道代码实现重定向子进程的标准输出先给出最实用的一套代码骨架父进程创建子进程并读取它的标准输出。第一步创建管道设置可继承属性SECURITY_ATTRIBUTES sa; sa.nLength sizeof(SECURITY_ATTRIBUTES); sa.bInheritHandle TRUE; sa.lpSecurityDescriptor NULL; HANDLE hReadPipe, hWritePipe; BOOL ok CreatePipe(hReadPipe, hWritePipe, sa, 0); // 实际项目中最好把读句柄设为不可继承只让写句柄可继承这里有个讲究读句柄留在父进程没有理由让子进程也拿到它。如果它也变成可继承子进程等于又拿到一个读端句柄结果就是管道多了一个引用EOF判断会出问题。正规做法是用SetHandleInformation把读句柄的HANDLE_FLAG_INHERIT去掉SetHandleInformation(hReadPipe, HANDLE_FLAG_INHERIT, 0);这样只有写句柄被继承读句柄只有父进程有。第二步把写句柄塞进STARTUPINFOSTARTUPINFO si; ZeroMemory(si, sizeof(si)); si.cb sizeof(si); si.dwFlags STARTF_USESTDHANDLES; si.hStdOutput hWritePipe; si.hStdError hWritePipe; // 顺便把错误输出也接到同一管道 si.hStdInput GetStdHandle(STD_INPUT_HANDLE); // 子进程的标准输入暂不处理如果你同时还想支持“往子进程写入内容”那就需要第二根管道把第二根的读端塞进si.hStdInput。但初期版本建议先只做“子进程输出→父进程读回”这一条通顺了再扩展。第三步调用CChildProcess::Start传入刚才的读写两个句柄。第四步父进程循环读取管道内容CString output; CHAR buffer[4096]; DWORD bytesRead 0; // 关键先关闭父进程自己持有的写端否则读端会永远等不到EOF CloseHandle(hWritePipe); while (ReadFile(hReadPipe, buffer, sizeof(buffer), bytesRead, NULL) bytesRead 0) { buffer[bytesRead] \0; output CString(buffer); }这个读取循环在MFC里怎么执行是有讲究的。如果直接在按钮响应里跑子进程一直输出你的窗口就卡死了。正确姿势是开一个工作线程可以用AfxBeginThread也可以直接用标准库std::thread去读管道读完把结果PostMessage回主窗口。这是一条铁律新手十有八九栽在这。4.3 用Windows消息实现轻量通信除了管道作为第一篇文章的补充我建议顺手掌握“消息通信”这个简单方案。它不一定满足老师对“进程间通信”的验收要求因为有些老师会明令禁止用窗口消息糊弄但作为阶梯学习非常有用。原理极简单所有窗口都有一个HWNDPostMessage可以把一条自定义消息发送到该窗口的消息队列发送方不需要关心接收方窗口具体处于什么状态接收方只要在消息映射里处理WM_APP n这类自定义消息即可。MFC这边的操作// 定义消息号 #define WM_MY_PROCESS_MSG (WM_APP 100) // 发送方 ::PostMessage(hTargetWnd, WM_MY_PROCESS_MSG, wParam, lParam); // 接收方消息映射 BEGIN_MESSAGE_MAP(CMyDlg, CDialogEx) ON_MESSAGE(WM_MY_PROCESS_MSG, CMyDlg::OnMyProcessMsg) END_MESSAGE_MAP() // 处理函数 LRESULT CMyDlg::OnMyProcessMsg(WPARAM wParam, LPARAM lParam) { // 处理逻辑 return 0; }注意wParam和lParam只能传整数或指针传指针时要小心生命周期——PostMessage是异步的指针指向的对象必须等到接收方处理完毕之后才能释放否则就是悬空指针。初学阶段传整数最安全。消息通信在“父子进程之间互相通知状态”这种场景下很顺手比如子进程写完一批数据后给父进程发个“我写完了、你可以读了”的通知。但它传不了大批量数据也不能做到跨会话级别的通信更不可能跨机器——理解它的定位就好。4.4 双向通信的扩展思路如果你的作业要求“父子进程互发消息”只做单项管道肯定验收不过。双向扩展的思路并不难建两根管道。管道A父进程持有“读端”子进程继承“写端”用于读取子进程stdout和stderr。管道B父进程持有“写端”子进程继承“读端”用于向子进程stdin写入数据。父进程往管道B写WriteFile(hWriteInputPipe, data, dataLen, bytesWritten, NULL);子进程那边用普通的scanf或cin就能读到内容。前提是子进程是你自己写的比如一个简单的控制台程序。如果子进程是cmd.exe那写进去的命令后面记得加“\r\n”——Windows命令行控制台对换行要求极严只写“\n”会导致命令不执行。这块实操里最大的坑是主进程写管道时如果管道缓冲区满了默认大小是4096字节可以通过CreatePipe的nSize参数调整WriteFile会阻塞。低手遇到就是界面假死高手会用一个独立线程去写或者用异步IO。我建议初学阶段只写小数据量文本不要超过几百字节以免踩了缓冲区的雷。5. 常见问题与排查实录5.1 进程创建失败的典型排查我见过最多的失败几乎都可以归为这四类路径问题。用相对路径启动子进程结果工作目录不是你项目所在目录。MFC调试时工作目录默认是项目目录$(ProjectDir)但发布后的exe工作目录就不一定了。排查方法是启动前用GetCurrentDirectory打印当前目录子进程路径永远用绝对路径省掉一堆烦恼。字符集问题。ANSI工程里传了宽字符串或Unicode工程里用了char*。我记得自己在某个版本里因为一个CString转char*的疏忽错误码是87参数错误查了半小时才发现。现在统一用Unicode调试器里看宽字符也比较直观。权限问题。如果子进程需要管理员权限而父进程是普通权限CreateProcess会失败或弹UAC提示。演示项目里尽量别玩这种花样大家都选管理员运行即可。映像位数不一致。64位父进程启动32位子进程本身是可以的但前提是你指定的路径确实指向32位exe而不是你猜的路径。如果遇到错误码193先检查你打包的exe路径和架构。5.2 管道堵塞那个“读不到EOF”的千古之谜这是管道通信里最让人抓狂的问题。症状是子进程明明已经输出完成了甚至退出了父进程的ReadFile却一直阻塞在原地。原因99%是“父进程自己那边还有一个写句柄打开着”。管道EOF的判定规则是所有写句柄都关闭后读端读到最后一批数据后就会返回FALSE。只要还有任何一个写句柄哪怕它根本没人用没关读端就会永远认为“可能还有数据要来”于是卡住。典型场景你创建管道时拿到了hWritePipe然后把它传给了CreateProcess但你没有在自己手里CloseHandle(hWritePipe)。于是子进程那边有一个写句柄父进程这边还有一个写句柄子进程退出了父进程那个写句柄还开着——EOF永远不会到来。解决方案就一句话在开始读管道之前立刻把你手里的写句柄关掉。代码我已经在4.2里写了请务必记住并且理解它的原理而不是死记步骤。5.3 界面卡死与消息循环阻塞直接在按钮里调用WaitForSingleObject等待子进程结束或者直接在按钮里ReadFile读管道浏览器看起来都是正常的但窗口一点就卡死。原因很简单你的按钮响应函数霸占了UI线程消息循环消息泵无法处理鼠标键盘消息于是窗口表现为无响应。Windows系统检测到窗口长时间不处理消息还会弹“这个程序已停止响应”。解决之道把等待、读取这类阻塞操作放进工作线程。MFC里最直接的方式AfxBeginThread(ReadingThreadProc, this);在线程函数里读取数据完成后用PostMessage给主窗口发一条自定义消息把结果字符串传给UI线程更新界面。注意工作线程里千万不要直接操作控件对象比如直接调用SetDlgItemText——那是UI线程的活跨线程调控件十有八九有竞态轻则显示异常重则崩溃。标准做法就是PostMessage。5.4 句柄泄漏检测方法你可以在任务管理器里看句柄数或者写一个小工具枚举进程句柄。但最直接的办法是让父进程循环创建子进程一百次看Release模式下内存和句柄数是否平稳。如果只增不减说明一定在某个路径上忘了CloseHandle。时刻问自己CreatePipe得到的句柄关了没CreateProcess得到的进程句柄和线程句柄关了没那些临时创建的继承句柄用完关掉没有句柄泄漏的破坏力对长期运行的服务是致命的但对这种演示项目隐藏较深容易养成坏习惯。5.5 日志先行排障最高效的心法每次API调用后把成功失败、错误码、关键参数打出来。真到了问题焦头烂额的时候翻日志远比重新猜快。我自己的习惯是做一个简单的Trace函数把日志输出到VS的“输出”窗口void TraceLog(const CString msg) { OutputDebugString(msg L\n); }再加上格式化错误码CString GetLastErrorMessage() { LPVOID lpMsgBuf NULL; FormatMessage(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, NULL, GetLastError(), 0, (LPTSTR)lpMsgBuf, 0, NULL); CString msg (LPTSTR)lpMsgBuf; LocalFree(lpMsgBuf); return msg; }这套组合在开发调试期就是神器。在CreateProcess失败处、ReadFile返回FALSE处、Wait超时处各打一行问题基本原形毕露。6. 写在实操之后的一些体会做到最后我想说一点个人感触这个项目真正的价值不在“把作业交上去”而在于它强迫你以肉眼可见的方式感受“进程是有边界的”。在同一个程序里调用一个函数数据直接进函数栈干净利落跨进程传数据你得造管道、借句柄、等同步——正是这套繁琐让人第一次真切体会到操作系统在背后给你挡了多少事情。如果你做完这一版手也顺了强烈建议再往前走两步把子进程换成自己写的一个控制台计数器程序让它周期性输出进度父进程用一个进度条实时展示或者把匿名管道换成命名管道体验一下两个完全独立启动的进程如何通信。每往前走一步你对Windows进程模型的理解都会稳一分。记得我当时做这个项目时反反复复撞了无数次墙最难受的并不是某个API不会用而是根本不知道“该查什么”。希望这篇把从界面到管道、从创建到排障的完整路径一次讲透能让你少走点弯路。项目源码不复杂但每一步都值得亲手敲一遍——尤其是那个读不到EOF的坑踩一次印象最深踩完你就真正明白句柄继承是怎么回事了。