C++程序崩溃调试:自动生成Dump文件工具的实现与应用

📅 2026/7/11 22:01:10
C++程序崩溃调试:自动生成Dump文件工具的实现与应用
1. 项目概述与核心价值在C开发中程序在客户现场崩溃而你手头只有一份日志文件上面写着“程序异常退出”这种场景是不是让你头皮发麻我经历过太多次了尤其是在处理复杂的多线程或内存问题时本地复现不了远程又没日志排查起来简直像大海捞针。这就是为什么我们需要一个能自动生成Dump文件的小工具。Dump文件简单说就是程序崩溃瞬间的“现场快照”它完整记录了当时的内存状态、线程堆栈、寄存器值等关键信息。有了它你就能像法医一样回到“案发现场”精准定位崩溃原因。这个“C实现自动生成Dump文件的小型调试工具”项目核心目标就是打造一个轻量级、可嵌入的“黑匣子”。它不依赖于庞大的IDE或复杂的第三方库只需几行代码集成到你的项目中就能在程序发生未处理异常时自动捕获并生成一个.dmp文件。这个文件配合你编译时生成的PDB程序数据库文件就能在Visual Studio或WinDbg中精确还原崩溃时的调用堆栈甚至看到变量的值。对于需要长期稳定运行的服务端程序、客户端软件或者难以调试的发布版本问题这个小工具的价值是巨大的。无论你是刚接触C的新手还是负责维护大型项目的老手掌握这套机制都能让你的调试和问题定位能力提升一个维度。2. 核心原理与方案设计2.1 Dump文件是什么为什么它能定位崩溃很多人听说过Dump文件但未必清楚其底层原理。你可以把它理解为一个进程在某个时间点的完整内存镜像。当程序崩溃比如访问了非法内存地址、除零错误时操作系统会抛出一个异常。如果我们没有捕获这个异常操作系统默认的未处理异常过滤器Unhandled Exception Filter就会接管通常会弹出一个错误对话框然后结束进程所有现场信息都丢失了。我们工具的核心就是用自己的异常处理函数替换掉系统默认的。当崩溃发生时我们的处理函数被调用此时进程虽然即将终止但其内存空间还未被完全销毁。这时我们调用Windows提供的MiniDumpWriteDump函数它能够遍历当前进程的虚拟内存空间、线程环境块TEB、进程环境块PEB等关键数据结构并将它们序列化写入一个文件。这个文件里包含了异常记录异常代码、发生异常的地址。线程列表与堆栈每个线程的ID、寄存器上下文EIP/RIP, ESP/RSP等、堆栈内存数据。这是定位崩溃代码行的关键。模块列表当时加载的所有DLL/EXE的基地址、路径、时间戳和GUID用于匹配PDB。部分或全部内存内容取决于生成的Dump类型可能包含堆内存、全局变量等用于分析崩溃时的数据状态。有了这个“快照”再配合编译时生成的PDB文件里面记录了函数名、变量名、类型信息与源代码行的映射关系调试器就能将堆栈中的内存地址“翻译”回具体的源代码文件和行号从而实现精准定位。2.2 工具整体架构设计思路一个健壮、易用的自动生成Dump工具不能只是简单调用一下API。我们需要考虑几个关键的设计点轻量与无侵入工具应该是一个独立的类或模块通过简单的头文件包含和初始化调用即可集成不影响项目的主体架构。异常捕获的可靠性必须确保在绝大多数崩溃场景下包括栈溢出、堆损坏等严重情况我们的异常处理函数仍有机会被执行。这涉及到异常过滤器的设置时机和范围。Dump文件的生成策略生成时机除了未处理异常有时我们也需要在程序逻辑中主动触发Dump生成例如检测到某个致命错误但还未崩溃时。工具应支持这两种模式。Dump类型MiniDumpWriteDump支持多种类型如MiniDumpNormal最小信息、MiniDumpWithFullMemory包含全部可读内存等。我们需要根据磁盘空间和问题排查深度进行权衡。文件命名与存储文件名最好包含时间戳、进程名、异常类型等信息方便归档和查找。存储路径也需要可配置避免因权限问题写入失败。多线程与资源安全崩溃可能发生在任何线程。异常处理函数必须考虑线程安全并且其自身的实现要极其简单、稳定避免在生成Dump时再次引发异常。与现有日志系统集成理想情况下在生成Dump的同时最好能记录一些简单的上下文信息如最后的错误码、关键业务状态到日志或Dump文件本身为后续分析提供更多线索。基于这些考虑我设计的工具核心类MiniDumpGenerator将包含以下主要功能静态方法Init()在程序启动早期调用设置全局未处理异常过滤器。静态方法GenerateDump(EXCEPTION_POINTERS*)核心的Dump生成函数可被异常过滤器或外部主动调用。配置接口允许设置Dump文件保存路径、文件名前缀、Dump类型等。一个简单的单例或静态类模式确保全局唯一且易于访问。3. 关键技术实现与代码解析3.1 搭建基础工程与引入必要依赖首先我们创建一个空的C控制台项目。工具的核心依赖于Windows SDK中的DbgHelp.dll及其头文件。在Visual Studio中你通常不需要额外下载但需要正确链接库。创建一个头文件比如mini_dump_generator.h开始我们的实现// mini_dump_generator.h #pragma once #include windows.h #include dbghelp.h #include string #include ctime #pragma comment(lib, dbghelp.lib) // 静态链接DbgHelp库 class MiniDumpGenerator { public: // 初始化工具设置异常处理器 static bool Initialize(const std::wstring dumpDir L, const std::wstring appName L); // 主动触发生成Dump用于程序逻辑中检测到严重错误时 static bool GenerateManually(const std::string reason ); // 获取单例实例如果需要存储状态 static MiniDumpGenerator GetInstance(); // 配置接口 void SetDumpType(MINIDUMP_TYPE type) { m_dumpType type; } void SetFullMemoryDump(bool full) { m_dumpType full ? (MINIDUMP_TYPE)(MiniDumpWithFullMemory | MiniDumpWithHandleData | MiniDumpWithUnloadedModules | MiniDumpWithProcessThreadData) : MiniDumpNormal; } private: MiniDumpGenerator(); // 私有构造函数单例模式 ~MiniDumpGenerator() default; // 实际的Dump生成函数 static bool WriteMiniDump(EXCEPTION_POINTERS* pExceptionInfo); // 全局未处理异常过滤器 static LONG WINAPI UnhandledExceptionFilter(EXCEPTION_POINTERS* pExceptionInfo); // 生成带时间戳的唯一文件名 static std::wstring GenerateDumpFileName(const std::string reason ); // 成员变量 static std::wstring m_dumpDirectory; static std::wstring m_applicationName; static MINIDUMP_TYPE m_dumpType; static bool m_initialized; };这里有几个关键点#pragma comment(lib, dbghelp.lib)是让编译器自动链接静态库的便捷方式。确保你的项目设置中dbghelp.lib的路径在库目录中。对于跨平台或更精细的控制也可以在项目属性-链接器-输入中添加。我们使用了单例模式但大部分方法设计为静态因为异常处理函数必须是静态的。m_dumpDirectory等静态成员变量用于存储配置。MINIDUMP_TYPE是一个枚举定义了Dump的详细程度。MiniDumpNormal是默认的包含最基本的信息异常、线程、堆栈、模块列表。对于复杂的内存破坏问题可能需要MiniDumpWithFullMemory但文件会非常大可能几个GB。3.2 实现异常捕获与Dump生成核心逻辑接下来是核心的.cpp文件实现// mini_dump_generator.cpp #include mini_dump_generator.h #include sstream #include iomanip #include shlwapi.h // 用于PathIsDirectory #pragma comment(lib, shlwapi.lib) // 静态成员初始化 std::wstring MiniDumpGenerator::m_dumpDirectory L.\\dumps\\; std::wstring MiniDumpGenerator::m_applicationName LApplication; MINIDUMP_TYPE MiniDumpGenerator::m_dumpType MiniDumpNormal; bool MiniDumpGenerator::m_initialized false; MiniDumpGenerator::MiniDumpGenerator() { // 确保DbgHelp.dll的函数可用建议在Initialize中调用MiniDumpWriteDump之前先调用SymInitialize } bool MiniDumpGenerator::Initialize(const std::wstring dumpDir, const std::wstring appName) { if (m_initialized) { return true; } if (!dumpDir.empty()) { m_dumpDirectory dumpDir; // 确保路径以反斜杠结尾 if (m_dumpDirectory.back() ! L\\ m_dumpDirectory.back() ! L/) { m_dumpDirectory L\\; } // 尝试创建目录如果不存在 CreateDirectoryW(m_dumpDirectory.c_str(), NULL); } if (!appName.empty()) { m_applicationName appName; } else { // 默认使用可执行文件名 wchar_t moduleName[MAX_PATH]; GetModuleFileNameW(NULL, moduleName, MAX_PATH); m_applicationName PathFindFileNameW(moduleName); PathRemoveExtensionW(const_castwchar_t*(m_applicationName.c_str())); // 去除扩展名 } // 设置全局未处理异常过滤器 SetUnhandledExceptionFilter(UnhandledExceptionFilter); // 初始化符号处理器可选但推荐能为Dump提供更丰富的符号信息 // SymInitialize(GetCurrentProcess(), NULL, TRUE); m_initialized true; return true; } LONG WINAPI MiniDumpGenerator::UnhandledExceptionFilter(EXCEPTION_POINTERS* pExceptionInfo) { // 这是崩溃发生时的入口点 if (pExceptionInfo nullptr) { // 某些严重崩溃可能没有异常指针尝试生成一个基础Dump WriteMiniDump(nullptr); } else { WriteMiniDump(pExceptionInfo); } // 返回EXCEPTION_EXECUTE_HANDLER会导致进程在我们这里结束不会弹出系统错误框。 // 返回EXCEPTION_CONTINUE_SEARCH会让系统默认处理弹框。 // 通常我们生成完Dump后选择让进程安静退出。 return EXCEPTION_EXECUTE_HANDLER; } bool MiniDumpGenerator::WriteMiniDump(EXCEPTION_POINTERS* pExceptionInfo) { std::wstring dumpFilePath GenerateDumpFileName(); HANDLE hFile CreateFileW(dumpFilePath.c_str(), GENERIC_WRITE, 0, // 不共享 NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); if (hFile INVALID_HANDLE_VALUE) { // 如果创建文件失败可以尝试在临时目录或当前目录创建 dumpFilePath m_applicationName L_crash.dmp; hFile CreateFileW(dumpFilePath.c_str(), GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); if (hFile INVALID_HANDLE_VALUE) { // 彻底失败无法生成Dump return false; } } MINIDUMP_EXCEPTION_INFORMATION exceptionInfo; exceptionInfo.ThreadId GetCurrentThreadId(); exceptionInfo.ExceptionPointers pExceptionInfo; exceptionInfo.ClientPointers FALSE; // 指针位于崩溃进程的地址空间内 // 添加额外的信息可选 MINIDUMP_USER_STREAM_INFORMATION userStreamInfo {0}; MINIDUMP_USER_STREAM userStream {0}; // 可以在这里添加自定义数据到Dump比如一段错误描述字符串 // ... MINIDUMP_CALLBACK_INFORMATION callbackInfo {0}; // 可以设置回调来过滤或添加Dump内容 // ... BOOL success MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), hFile, m_dumpType, pExceptionInfo ? exceptionInfo : NULL, userStreamInfo, callbackInfo); CloseHandle(hFile); // 可以在这里记录一条日志通知Dump文件已生成及其路径 // OutputDebugStringW(dumpFilePath.c_str()); return success TRUE; } std::wstring MiniDumpGenerator::GenerateDumpFileName(const std::string reason) { SYSTEMTIME st; GetLocalTime(st); wchar_t fileName[MAX_PATH]; swprintf_s(fileName, MAX_PATH, L%s%s_%04d%02d%02d_%02d%02d%02d, m_dumpDirectory.c_str(), m_applicationName.c_str(), st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); if (!reason.empty()) { // 将reasonASCII转换为宽字符并追加 std::wstring wReason(reason.begin(), reason.end()); wcscat_s(fileName, MAX_PATH, L_); wcscat_s(fileName, MAX_PATH, wReason.c_str()); } wcscat_s(fileName, MAX_PATH, L.dmp); return std::wstring(fileName); } bool MiniDumpGenerator::GenerateManually(const std::string reason) { // 主动生成Dump不传递异常信息 return WriteMiniDump(nullptr); } MiniDumpGenerator MiniDumpGenerator::GetInstance() { static MiniDumpGenerator instance; return instance; }注意SetUnhandledExceptionFilter设置的是进程级别的异常过滤器。但它无法捕获一些“硬”错误比如调用abort()、terminate()或者遇到栈溢出Stack Overflow时可能来不及执行我们的代码。对于更全面的捕获有时还需要结合_set_abort_behavior、signal处理对于SIGABRT等以及使用Vectored Exception Handling (AddVectoredExceptionHandler)。但对于大多数访问违例、除零等异常这个过滤器是有效的。3.3 在项目中集成与使用集成这个工具非常简单。在你的主程序入口如main或WinMain的最开始处进行初始化#include mini_dump_generator.h int main() { // 初始化Dump生成器设置Dump文件保存目录和应用程序名 MiniDumpGenerator::Initialize(LC:\\MyApp\\CrashDumps\\, LMyAwesomeApp); // ... 你的其他初始化代码和主逻辑 ... // 示例在某个你认为可能发生严重错误的地方主动生成Dump if (someCriticalErrorOccurred) { MiniDumpGenerator::GenerateManually(CriticalStateBeforeCrash); // 然后可能选择退出或抛出异常 } // 示例触发一个崩溃仅用于测试 // int* p nullptr; *p 42; // 访问违例 return 0; }编译你的项目记得在项目属性中确保生成调试信息PDB文件。对于Debug和Release配置进入“项目属性” - “C/C” - “常规”将“调试信息格式”设置为“程序数据库 (/Zi)”或“用于编辑并继续的程序数据库 (/ZI)”。进入“链接器” - “调试”将“生成调试信息”设置为“是 (/DEBUG)”。可选但推荐在“链接器” - “调试” - “生成程序数据库文件”中指定一个固定的PDB文件名如$(TargetName).pdb避免每次编译后PDB的GUID因路径变化而改变方便符号服务器管理。现在当你的程序因为未处理异常而崩溃时会在指定的目录如C:\MyApp\CrashDumps\下生成一个类似MyAwesomeApp_20231027_143022.dmp的文件。如果没有设置目录默认会在程序所在目录的dumps子文件夹下生成。4. Dump文件的分析与调试实战生成Dump文件只是第一步更重要的是如何从这一堆二进制数据中挖出崩溃的根源。4.1 使用Visual Studio分析Dump文件最便捷对于使用Visual Studio开发的团队这是最直接的方法。确保你用于分析的机器上有与生成Dump文件完全匹配的可执行文件EXE和PDB文件并且源代码路径可用或者源代码与编译时的路径一致。直接双击Dump文件如果系统关联了VS会直接用VS打开。也可以在VS中通过“文件”-“打开”-“文件”选择.dmp文件。选择调试引擎VS会提示你选择“使用本机进行调试”还是其他。通常选“本机”。设置符号和源代码路径如果PDB和EXE就在Dump文件同目录或者位于编译时的原始输出路径VS通常能自动加载符号。如果找不到VS会在“模块”窗口显示符号未加载。你需要手动指定符号路径在“工具”-“选项”-“调试”-“符号”中添加包含PDB文件的目录或微软符号服务器https://msdl.microsoft.com/download/symbols。如果源代码找不到VS会提示你查找源文件。你可以导航到当前的源代码目录。开始调试点击“使用仅限本机进行调试”或类似的按钮。VS会加载Dump并自动停在发生异常的那条指令上。调用堆栈窗口会显示崩溃时的完整函数调用链局部变量窗口如果Dump包含足够内存信息可能还能看到一些变量的值。实操心得为了确保VS总能找到符号一个最佳实践是在构建服务器上编译发布版本时将生成的PDB文件连同EXE一起归档并建立版本关联。当拿到一个Dump文件时根据其时间戳或版本号取出对应版本的PDB和源代码进行调试。4.2 使用WinDbg分析Dump文件更强大、更底层WinDbg是微软官方的强大调试器尤其擅长分析内核转储和复杂的用户态Dump。对于VS无法解析的复杂崩溃如堆损坏后期现象WinDbg往往能提供更多线索。安装与启动可以从Windows SDK中获取WinDbg或下载独立的“Debugging Tools for Windows”。打开WinDbg。设置符号路径这是关键一步。通过命令或菜单设置。命令方式按CtrlS打开符号路径对话框或直接输入命令.sympath SRV*C:\Symbols*https://msdl.microsoft.com/download/symbols这会将微软公共符号服务器缓存到C:\Symbols目录。然后添加你自己的PDB路径.sympath C:\MyApp\Release\菜单方式File - Symbol File Path...设置源代码路径可选如果你需要查看源代码。命令.srcpath C:\YourSourceCode菜单File - Source File Path...打开Dump文件File - Open Crash Dump...或者直接将.dmp文件拖入WinDbg窗口。执行自动分析在命令窗口输入!analyze -v这个命令会让WinDbg自动分析异常上下文、堆栈并给出一个初步的诊断报告通常会直接指出可疑的异常代码和可能出错的线程、指令。查看堆栈输入k或kb显示前三个参数来查看当前线程的调用堆栈。~*kb可以查看所有线程的堆栈。分析关键信息!teb查看当前线程的环境块。!peb查看进程环境块。lm查看已加载的模块。!heap查看堆状态对于内存损坏问题非常有用。.exr 地址查看异常记录。!address 地址查看指定内存地址的属性是否可读、可写、属于哪个模块等。WinDbg的命令非常丰富学习曲线较陡但对于解决疑难杂症是不可或缺的工具。4.3 确保符号匹配时间戳与GUID无论是VS还是WinDbg成功调试的核心在于符号匹配。EXE、PDB和Dump文件必须来自同一次构建。编译器会在EXE和PDB中嵌入一个唯一的GUID或时间戳大小。调试器会校验这个标识符不匹配则拒绝加载符号。你可以使用dumpbin命令来查看EXE的GUID和时间戳dumpbin /headers YourApp.exe | findstr time date或者更详细地查看调试目录信息dumpbin /pdbpath:verbose YourApp.pdb注意事项在自动化构建和发布流程中一定要将每次构建产生的PDB文件妥善保存并与版本号或构建ID关联。严禁用新编译的PDB去调试旧版本程序产生的Dump这会导致堆栈错乱得出完全错误的结论。5. 高级话题与生产环境实践5.1 处理特殊崩溃场景我们之前实现的基于SetUnhandledExceptionFilter的方法并不能捕获所有类型的“崩溃”。纯虚函数调用、无效参数错误如printf(NULL)等在MSVC中通常会先调用_invalid_parameter或_purecall然后才可能触发异常。我们可以在初始化时设置这些回调#include cstdlib #include crtdbg.h // 在Initialize函数中添加 _set_purecall_handler([]() { MiniDumpGenerator::GenerateManually(PureVirtualCall); std::abort(); }); _set_invalid_parameter_handler([](const wchar_t*, const wchar_t*, const wchar_t*, unsigned int, uintptr_t) { MiniDumpGenerator::GenerateManually(InvalidParameter); std::abort(); });终止调用如调用std::terminate()通常由未捕获的C异常引起或abort()。可以设置终止处理器std::set_terminate([]() { MiniDumpGenerator::GenerateManually(UncaughtCppException); std::abort(); // 或者调用原始终止处理器 });栈溢出栈溢出异常EXCEPTION_STACK_OVERFLOW发生时线程的栈空间已耗尽我们的异常处理函数如果使用过多栈内存可能无法运行。解决方案是在编译链接时使用/STACK链接器选项增大栈预留大小。更可靠的是使用向量化异常处理VEH。VEH在结构化异常处理SEH链的最前面被调用有机会在栈损坏不严重时运行。可以使用AddVectoredExceptionHandler来注册。// 在Initialize中注册VEH优先级设为1表示最先调用 AddVectoredExceptionHandler(1, [](EXCEPTION_POINTERS* pExceptionInfo) - LONG { if (pExceptionInfo-ExceptionRecord-ExceptionCode EXCEPTION_STACK_OVERFLOW) { // 为处理栈溢出最好切换到备用栈或使用极简逻辑 // 可以尝试生成一个最小化的Dump // ... } // 返回EXCEPTION_CONTINUE_SEARCH让其他处理器包括我们的未处理异常过滤器继续处理 return EXCEPTION_CONTINUE_SEARCH; });5.2 生成更丰富的Dump信息默认的MiniDumpNormal可能信息不足。根据问题类型可以选择更详细的类型MiniDumpWithDataSegs包含所有可写数据段有助于查看全局变量。MiniDumpWithFullMemory包含进程整个用户态地址空间的完整内存内容。文件巨大但能提供最全面的信息用于分析复杂的内存破坏、查找内存中的特定数据模式。MiniDumpWithHandleData包含句柄信息有助于诊断资源泄漏。MiniDumpWithProcessThreadData包含完整的进程和线程信息。你可以通过位或操作组合这些标志SetDumpType((MINIDUMP_TYPE)(MiniDumpWithFullMemory | MiniDumpWithHandleData | MiniDumpWithThreadInfo));但务必权衡一个全内存Dump可能比你的程序本身还大很多倍。5.3 集成到服务与日志系统对于Windows服务崩溃时可能没有交互式桌面需要特殊处理服务在SERVICE_CONTROL_STOP等控制请求中崩溃我们的异常过滤器可能仍然有效。建议在服务启动的ServiceMain函数最开始就调用MiniDumpGenerator::Initialize。将Dump文件路径设置为服务有写入权限的目录如程序数据目录%ProgramData%。在生成Dump后可以考虑通过ReportEvent函数将事件记录到Windows事件查看器或者调用一个外部脚本将Dump文件上传到服务器。与日志系统集成也很重要。可以在WriteMiniDump函数中在生成Dump文件后尝试将一些简单的上下文信息如__FILE__,__LINE__或一个全局的错误上下文字符串写入一个同名的.txt文件或者利用MiniDumpCallback机制将自定义信息流写入Dump文件本身。5.4 常见问题排查与技巧实录生成的Dump文件大小为0或很小这通常意味着MiniDumpWriteDump调用失败了。检查GetLastError()。常见原因路径权限不足程序运行账户如Network Service对目标目录没有写权限。尝试写入当前目录或临时目录GetTempPath。磁盘空间不足。在异常过滤器中进行了不安全的操作避免在异常过滤器中分配内存、调用复杂函数。尽量只做文件IO和MiniDumpWriteDump调用。VS/WinDbg提示“无法找到或打开PDB文件”检查符号路径确保设置了正确的路径。验证GUID匹配使用!sym noisy命令打开WinDbg的符号加载详细信息查看它正在寻找哪个GUID的PDB然后与你手头的PDB对比。使用chkmatch工具微软调试工具包中有一个chkmatch.exe可以检查EXE和PDB是否匹配。堆栈显示为乱码或只有地址没有函数名这绝对是符号不匹配。请务必使用与构建崩溃程序完全一致的源代码和PDB。如果程序使用了动态链接库DLL也需要这些DLL的PDB文件。确保所有相关模块的符号都能找到。Dump分析时局部变量显示“优化掉了”在Release版本中编译器会进行大量优化包括内联函数、重用寄存器、消除未使用的变量等。这会导致局部变量在Dump中不可见或值不正确。解决方案对于难以调试的Release版问题可以考虑在关键函数上使用#pragma optimize(, off)临时关闭优化或者使用/Od编译一个特殊的调试版本进行复现。但这会影响性能仅用于问题调查。多线程崩溃的Dump分析使用~*kb查看所有线程堆栈。找到异常线程通常是!analyze -v报告的那个。关注持有锁的线程。如果崩溃发生在锁内部检查其他线程是否在等待这个锁这可能是死锁导致的超时或资源耗尽。使用!locks命令需要合适的Dump类型查看当前持有的临界区锁。将这个小型调试工具集成到你的C项目里就像是给程序装上了“飞行记录仪”。它不会阻止崩溃但能在最糟糕的事情发生时给你留下最宝贵的现场证据。从实现到集成再到最后的分析每一步都有需要注意的细节尤其是符号文件的管理是能否成功诊断的关键。我建议在项目开发的早期就引入这套机制并把它作为CI/CD和发布流程的一部分确保每个正式版本都有对应的符号文件归档。当线上问题发生时你就能从容不迫地拿出Dump快速定位根因而不是对着模糊的日志和用户的描述一筹莫展。