VC++经典项目解析:从Windows底层到现代C++的工程实践

VC++经典项目解析:从Windows底层到现代C++的工程实践 1. 项目概述与学习价值如果你是一名C开发者尤其是深耕于Windows桌面应用、企业级软件或遗留系统维护的工程师那么“VC”这三个字对你而言绝不仅仅是一个编译器或IDE的名字它代表着一个时代的技术栈、一套成熟的开发范式以及无数经典软件背后的基石。今天我想和你深入聊聊“深入学习VC经典项目案例解析与实践”这个话题。这并非老调重弹而是希望我们能一起从那些历经时间考验的经典项目中挖掘出超越特定工具本身的、关于软件架构、性能优化和工程实践的永恒智慧。VC即Visual C是微软Visual Studio集成开发环境中用于C开发的工具集。它不仅仅包括编译器MSVC更是一整套包含MFCMicrosoft Foundation Classes、ATLActive Template Library、COMComponent Object Model等技术的生态体系。在.NET和现代C跨平台浪潮之前VC是Windows平台上高性能、高复杂度桌面应用和系统级软件开发的不二之选。学习它的经典项目其核心价值在于理解Windows生态的底层逻辑许多现代Windows API和框架的设计思想都能在VC时代的项目中找到源头。理解MFC的消息映射、ATL的模板元编程技巧、COM的二进制接口标准能让你在面对任何Windows平台问题时都更加游刃有余。掌握大型软件工程的构建方法VC经典项目往往结构庞大、模块复杂。通过解析它们你能学习到如何在缺乏现代包管理工具如vcpkg、conan的时代组织头文件、管理库依赖、进行跨项目配置这些是超越语言的工程能力。汲取高性能与资源管理的经验在内存和CPU都相对珍贵的年代VC项目对性能的压榨和资源GDI对象、内存、句柄的管理达到了极致。其中的优化技巧、防止内存泄漏和资源耗尽的设计至今仍有极高的参考价值。从“黑盒”到“白盒”的思维转变很多我们日常使用的系统工具或商业软件其早期版本可能就是基于VC构建的。学习这些案例能帮助你理解一个成熟软件从原型到产品的完整演进路径而不仅仅是调用现成的API。因此本次学习之旅的目标不是让你回去写MFC程序而是通过解剖经典提炼出可迁移的编程思想、设计模式和调试方法论让你在面对任何复杂C项目时都能有一套清晰的拆解和攻关思路。1.1 核心需求解析我们到底要学什么面对“VC经典项目”这个庞大的主题我们需要聚焦。盲目地下载源码然后F5运行是低效的。我们的学习应该围绕以下几个核心需求展开架构与设计模式这个项目是如何组织代码的采用了哪些经典的设计模式如观察者模式处理消息、工厂模式创建对象、单例模式管理全局资源模块之间是如何解耦的核心算法与数据结构项目中的关键功能如渲染引擎、文件解析器、网络通信模块采用了什么算法使用了哪些自定义或标准库的数据结构其时间/空间复杂度如何Windows平台特定技术如何与Windows API交互如何使用GDI/GDI进行绘图如何进行进程间通信IPC如何注册和使用COM组件如何处理Windows特有的消息循环和事件驱动性能优化技巧在哪里使用了内联汇编缓存是如何优化的有无使用特定的内存池图形渲染或数据处理的关键路径是如何加速的调试与问题排查项目中有哪些独特的断言Assert或日志机制对于内存损坏、GDI泄漏等典型Windows C问题提供了怎样的调试线索或防护代码工程化实践项目是如何构建的古老的.dsp/.dsw文件还是后来的.vcproj/.sln第三方库是如何集成的版本管理和代码规范有何特点基于这些需求我将选取几个具有代表性的领域和项目进行深度解析并在每个案例后附上可操作的实践建议让你不仅能看懂更能动手复现关键模块加深理解。2. 经典项目领域与案例选取VC的经典项目遍布图形界面、办公软件、多媒体、网络工具等多个领域。为了系统性地学习我们可以将其分为几个大类并选取每个类别中的标杆项目进行剖析。2.1 图形界面与应用程序框架MFC与CWinApp的典范代表项目早期版本的Microsoft Visual Studio IDE部分组件、一些经典的企业内部管理系统。虽然MFC如今已不是新技术但它是理解Windows GUI程序运行机制的绝佳教材。一个典型的MFC应用程序其核心是CWinApp派生类和一系列CDialog、CView派生类。案例解析一个简单的MFC文档-视图结构应用我们以创建一个支持多文档、带有工具栏、状态栏的文本编辑器为例类似早期Windows记事本的增强版。其核心架构如下应用对象CWinApp这是程序的入口和总控中心。在InitInstance()函数中完成了注册窗口类、创建主框架窗口、处理命令行参数、初始化文档模板等所有启动工作。这里的关键是理解**文档模板CDocTemplate**如何将文档类、框架窗口类和视图类绑定在一起。// 在CMyApp::InitInstance()中 CSingleDocTemplate* pDocTemplate; pDocTemplate new CSingleDocTemplate( IDR_MAINFRAME, RUNTIME_CLASS(CMyDoc), // 文档类 RUNTIME_CLASS(CMainFrame), // 主框架窗口类 RUNTIME_CLASS(CMyView)); // 视图类 AddDocTemplate(pDocTemplate);实操心得MFC大量使用RUNTIME_CLASS和DECLARE_DYNCREATE/IMPLEMENT_DYNCREATE宏来实现动态创建。这是早期C缺乏反射机制时的一种巧妙解决方案理解它有助于理解很多框架的运行时类型信息RTTI设计。消息映射机制这是MFC的核心它将Windows消息如WM_PAINT,WM_COMMAND映射到类的成员函数。通过BEGIN_MESSAGE_MAP和END_MESSAGE_MAP宏实现。BEGIN_MESSAGE_MAP(CMyView, CView) ON_WM_PAINT() ON_COMMAND(ID_EDIT_COPY, CMyView::OnEditCopy) ON_UPDATE_COMMAND_UI(ID_EDIT_COPY, CMyView::OnUpdateEditCopy) END_MESSAGE_MAP()注意事项ON_UPDATE_COMMAND_UI是MFC的精华之一用于更新用户界面状态如灰化按钮、添加勾选标记。它体现了“命令”与“UI状态”分离的思想在现代UI框架如Qt、WPF中仍有体现。文档-视图分离文档类CDocument派生负责数据管理和序列化保存/加载。视图类CView派生负责显示数据和与用户交互。这种分离保证了数据的一致性并支持同一份数据用多种视图如文本视图、图表视图显示。实践建议不要满足于向导生成的代码。尝试手动创建一个不带向导的、最小化的MFC对话框程序从注册窗口类、实现消息循环开始逐步添加控件和消息处理。这会让你彻底明白MFC在背后为你做了什么。2.2 系统工具与实用程序深入Windows API代表项目Process Explorer早期版本、WinRAR部分模块、一些磁盘清理或文件同步工具。这类项目通常对系统底层有较深的操作大量直接调用Windows API是学习系统编程的宝库。案例解析一个简单的进程信息枚举器我们将模仿Process Explorer的核心功能之一列举系统所有进程及其加载的模块DLL。使用CreateToolhelp32Snapshot这是遍历进程和模块的标准方法。它创建一个系统快照。HANDLE hProcessSnap CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (hProcessSnap INVALID_HANDLE_VALUE) { // 错误处理 return; } PROCESSENTRY32 pe32; pe32.dwSize sizeof(PROCESSENTRY32); // **关键必须初始化dwSize** if (!Process32First(hProcessSnap, pe32)) { CloseHandle(hProcessSnap); return; } do { // 输出进程信息pe32.szExeFile, pe32.th32ProcessID // 对于每个进程再遍历其模块 EnumerateModules(pe32.th32ProcessID); } while (Process32Next(hProcessSnap, pe32)); CloseHandle(hProcessSnap); // **关键务必关闭句柄防止泄漏**遍历进程模块在EnumerateModules函数中使用TH32CS_SNAPMODULE参数创建另一个快照。HANDLE hModuleSnap CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, dwPID); MODULEENTRY32 me32; me32.dwSize sizeof(MODULEENTRY32); Module32First(hModuleSnap, me32); do { // 输出模块信息me32.szModule, me32.szExePath } while (Module32Next(hModuleSnap, me32)); CloseHandle(hModuleSnap);常见问题与排查权限不足获取某些系统进程如csrss.exe的模块信息需要SE_DEBUG_NAME特权。可以使用AdjustTokenPrivileges函数提升当前进程权限。进程已退出在获取进程ID和遍历其模块的间隙目标进程可能已经退出。需要处理ERROR_INVALID_PARAMETER等错误。路径显示为设备路径某些系统模块的路径可能显示为\Device\HarddiskVolume...这是正常的NT路径格式。实践建议将此功能封装成一个类提供按进程名查找、按模块名查找进程等接口。进一步可以尝试获取每个进程的CPU、内存占用率使用GetProcessMemoryInfo和NtQuerySystemInformation等更底层的API向一个简易的任务管理器迈进。2.3 多媒体与图形处理GDI/GDI与DirectX的启蒙代表项目早期图像查看器如IrfanView的早期C版本、简单的屏幕录制工具、基于DirectDraw/Direct3D 9的游戏。案例解析一个使用GDI实现图像缩放和旋转的查看器GDI是GDI的增强版提供了更丰富的2D图形功能。我们将实现一个视图能够平滑缩放和旋转一张位图。初始化GDI在使用任何GDI功能前必须初始化。通常在CWinApp::InitInstance()中创建在ExitInstance()中销毁。#include gdiplus.h using namespace Gdiplus; ULONG_PTR m_gdiplusToken; // 初始化 GdiplusStartupInput gdiplusStartupInput; GdiplusStartup(m_gdiplusToken, gdiplusStartupInput, NULL); // 销毁 GdiplusShutdown(m_gdiplusToken);在OnPaint中绘制在视图的OnPaint函数中使用Graphics对象进行绘制。void CMyView::OnPaint() { CPaintDC dc(this); Graphics graphics(dc.m_hDC); // 包装DC为Graphics对象 graphics.SetSmoothingMode(SmoothingModeAntiAlias); // 设置抗锯齿 graphics.SetInterpolationMode(InterpolationModeHighQualityBicubic); // 高质量插值 // 加载或获取位图对象 Image* m_pImage if (m_pImage) { // 计算缩放和旋转后的绘制位置 RectF destRect(/* 计算得到的坐标和大小 */); // 设置旋转中心为图像中心 graphics.TranslateTransform(destRect.X destRect.Width / 2, destRect.Y destRect.Height / 2); graphics.RotateTransform(m_fRotationAngle); // 旋转角度 graphics.TranslateTransform(-(destRect.X destRect.Width / 2), -(destRect.Y destRect.Height / 2)); // 绘制图像 graphics.DrawImage(m_pImage, destRect); } }性能优化技巧频繁的缩放旋转计算和绘制会带来性能压力。一个常见的优化是双缓冲先在内存位图Bitmap上完成所有绘制操作最后一次性BitBlt到屏幕DC上可以极大减少闪烁。void CMyView::OnDraw(CDC* pDC) { // 1. 创建兼容内存DC和位图 CDC memDC; CBitmap memBitmap; CRect clientRect; GetClientRect(clientRect); memDC.CreateCompatibleDC(pDC); memBitmap.CreateCompatibleBitmap(pDC, clientRect.Width(), clientRect.Height()); memDC.SelectObject(memBitmap); // 2. 在memDC上绘制使用GDI Graphics包装memDC Graphics graphics(memDC.m_hDC); // ... 所有绘制操作 // 3. 一次性拷贝到屏幕 pDC-BitBlt(0, 0, clientRect.Width(), clientRect.Height(), memDC, 0, 0, SRCCOPY); }实践建议实现一个简单的图片浏览器支持拖放打开、鼠标滚轮缩放、按住右键拖动旋转。尝试加入对PNG透明通道的支持GDI原生支持并处理大图像时的内存和性能问题。2.4 网络通信与服务器Winsock的实战代表项目早期的FTP客户端/服务器、局域网聊天工具、简单的HTTP服务器。案例解析一个基于Winsock的异步TCP回显服务器我们将使用Winsock的异步事件选择模型WSAAsyncSelect或完成端口IOCP模型来构建一个高性能的服务器。这里以相对易懂的WSAAsyncSelect为例它利用Windows消息机制处理网络事件。初始化Winsock与创建监听套接字WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), wsaData); // 初始化 SOCKET listenSocket socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in service; service.sin_family AF_INET; service.sin_addr.s_addr INADDR_ANY; service.sin_port htons(DEFAULT_PORT); bind(listenSocket, (SOCKADDR*)service, sizeof(service)); listen(listenSocket, SOMAXCONN);将套接字与窗口消息关联这是WSAAsyncSelect的核心。它将网络事件如FD_ACCEPT,FD_READ,FD_CLOSE转化为发送到指定窗口的WM_SOCKET消息自定义消息。#define WM_SOCKET (WM_USER 1) // hWnd 是你的主窗口句柄 WSAAsyncSelect(listenSocket, hWnd, WM_SOCKET, FD_ACCEPT | FD_READ | FD_CLOSE);在窗口过程中处理网络消息LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) { switch (message) { case WM_SOCKET: { SOCKET s (SOCKET)wParam; int event WSAGETSELECTEVENT(lParam); int error WSAGETSELECTERROR(lParam); if (error) { /* 处理错误 */ break; } switch (event) { case FD_ACCEPT: { // 有新连接 SOCKET clientSocket accept(s, NULL, NULL); WSAAsyncSelect(clientSocket, hWnd, WM_SOCKET, FD_READ | FD_CLOSE); break; } case FD_READ: { // 可读数据 char buffer[1024]; int recvLen recv(s, buffer, sizeof(buffer), 0); if (recvLen 0) { send(s, buffer, recvLen, 0); // 回显 } break; } case FD_CLOSE: { // 连接关闭 closesocket(s); break; } } break; } // ... 其他消息处理 } return DefWindowProc(hWnd, message, wParam, lParam); }注意事项与进阶WSAAsyncSelect模型在连接数非常多成千上万时由于所有事件都通过同一个窗口消息队列处理可能成为瓶颈。对于高性能服务器完成端口IOCP是更专业的选择它利用线程池和重叠I/O能最大程度发挥多核CPU优势。务必检查recv和send的返回值。返回值0表示连接正常关闭SOCKET_ERROR表示出错需要用WSAGetLastError()获取具体错误码。对于FD_READ事件不要假设一次recv就能读完所有数据。TCP是流式协议可能需要多次读取。更好的做法是将接收到的数据追加到每个连接对应的缓冲区中直到遇到约定的结束符或达到预定长度。实践建议实现这个回显服务器并用Telnet或自己写一个简单的客户端进行测试。然后将其扩展为一个支持简单命令如GETTIME,ECHO text的微型应用层协议服务器。最后可以研究一下IOCP模型尝试重写服务器核心对比性能差异。3. 从解析到实践构建你自己的“经典”模块理论学习之后动手实践是巩固知识的唯一途径。我建议不要一开始就试图复刻整个大型项目而是选择其中一个你感兴趣的、相对独立的模块进行深度实现。3.1 实践选题一实现一个轻量级INI配置文件解析器许多经典VC项目使用INI文件进行配置。我们可以实现一个自己的解析器学习文件I/O、字符串处理和简单数据结构的应用。核心设计类设计设计一个CIniFile类主要接口包括Load(LPCTSTR filename),Save(),GetString(),GetInt(),SetString()等。数据结构使用std::map或CMap来存储数据。键可以是节名键名的组合。文件解析逐行读取文件忽略空行和注释行以;或#开头。识别[Section]行和keyvalue行。关键实现片段BOOL CIniFile::Load(LPCTSTR lpszFileName) { CStdioFile file; if (!file.Open(lpszFileName, CFile::modeRead | CFile::typeText)) { return FALSE; } CString strLine; CString strCurrentSection; m_mapData.RemoveAll(); // 清空现有数据 while (file.ReadString(strLine)) { strLine.Trim(); if (strLine.IsEmpty() || strLine[0] _T(;) || strLine[0] _T(#)) { continue; } if (strLine[0] _T([) strLine[strLine.GetLength() - 1] _T(])) { // 解析节 strCurrentSection strLine.Mid(1, strLine.GetLength() - 2); } else { // 解析键值对 int nEqualPos strLine.Find(_T()); if (nEqualPos 0) { CString strKey strLine.Left(nEqualPos); strKey.Trim(); CString strValue strLine.Mid(nEqualPos 1); strValue.Trim(); // 存储键为“节.键” CString strFullKey; if (strCurrentSection.IsEmpty()) { strFullKey strKey; } else { strFullKey.Format(_T(%s.%s), strCurrentSection, strKey); } m_mapData.SetAt(strFullKey, strValue); } } } file.Close(); m_strFilePath lpszFileName; return TRUE; }避坑技巧编码问题INI文件通常是ANSI编码。如果程序是Unicode_TCHAR需要使用CStdioFile并指定CFile::typeText它会进行基本的ANSI到Unicode转换。对于复杂情况可能需要使用MultiByteToWideChar。值中的等号上述简单解析无法处理值中包含等号的情况。一个健壮的解析器需要找到第一个等号进行分割或者支持转义字符。空格处理Trim()函数去除了首尾空格但有些配置项可能需要保留值内部的空格。需要根据规范仔细处理。3.2 实践选题二封装一个日志输出类调试是开发中的重要环节。一个良好的日志系统能极大提升效率。我们可以实现一个线程安全的、支持分级Debug, Info, Warning, Error和输出到文件/控制台的日志类。核心设计单例模式日志类通常全局唯一适合用单例模式实现。线程安全使用临界区CRITICAL_SECTION或互斥量CMutex保护共享的日志队列和文件写操作。格式化输出支持类似printf的格式化字符串使用vsprintf_s或CString::FormatV。异步写入可以创建一个单独的日志写入线程主线程将日志条目放入队列写入线程负责从队列取出并写入文件避免阻塞主线程。简化版同步日志类示例class CLogger { private: static CLogger* m_pInstance; CCriticalSection m_cs; // 用于线程同步 CStdioFile m_logFile; int m_nLogLevel; // 日志级别 CLogger() { /* 私有构造函数 */ } public: enum LogLevel { Debug, Info, Warning, Error }; static CLogger* GetInstance() { if (m_pInstance nullptr) { m_pInstance new CLogger(); } return m_pInstance; } BOOL Init(LPCTSTR lpszFilePath, LogLevel level Info) { m_nLogLevel level; return m_logFile.Open(lpszFilePath, CFile::modeCreate | CFile::modeWrite | CFile::modeNoTruncate | CFile::typeText); } void Log(LogLevel level, LPCTSTR lpszFormat, ...) { if (level m_nLogLevel) return; // 级别过滤 CSingleLock lock(m_cs, TRUE); // 进入临界区 CString strLevel; switch (level) { case Debug: strLevel _T([DBG]); break; case Info: strLevel _T([INF]); break; case Warning: strLevel _T([WRN]); break; case Error: strLevel _T([ERR]); break; } CString strLogMsg; va_list args; va_start(args, lpszFormat); strLogMsg.FormatV(lpszFormat, args); va_end(args); CString strTime; CTime tm CTime::GetCurrentTime(); strTime tm.Format(_T(%Y-%m-%d %H:%M:%S)); CString strFinalMsg; strFinalMsg.Format(_T(%s %s %s\n), strTime, strLevel, strLogMsg); // 输出到文件 m_logFile.SeekToEnd(); m_logFile.WriteString(strFinalMsg); m_logFile.Flush(); // 确保写入磁盘 // 同时可以输出到调试窗口OutputDebugString OutputDebugString(strFinalMsg); } }; // 使用宏简化调用 #define LOG_DEBUG(format, ...) CLogger::GetInstance()-Log(CLogger::Debug, format, ##__VA_ARGS__) #define LOG_ERROR(format, ...) CLogger::GetInstance()-Log(CLogger::Error, format, ##__VA_ARGS__)性能与资源考量频繁的磁盘I/O每行日志都Flush()会严重影响性能。可以设置一个缓冲区积累一定量的日志或定时写入。日志文件滚动长时间运行的程序日志文件会无限增大。需要实现按大小或日期分割日志文件的功能。死锁风险如果在日志输出函数内部又调用了其他可能获取同一把锁的函数会导致死锁。设计时需要非常小心锁的粒度。4. 现代VCVisual Studio下的兼容性与迁移思考如今我们主要在Visual Studio 2019/2022等现代IDE中使用VC工具链。学习经典项目时必然会遇到兼容性问题。4.1 常见编译问题与解决安全函数警告C4996经典代码中大量使用sprintf,strcpy,fopen等“不安全”函数。微软推荐使用带_s后缀的安全版本如sprintf_s。解决方案在文件开头定义_CRT_SECURE_NO_WARNINGS宏来禁用这些警告。但更好的做法是逐步替换为安全版本并添加缓冲区长度参数。#define _CRT_SECURE_NO_WARNINGS // 临时禁用 // 或者使用安全版本 char buffer[100]; sprintf_s(buffer, _countof(buffer), Value: %d, nValue);字符集问题早期项目多使用多字节字符集MBCS而现代项目默认使用Unicode字符集_UNICODE和UNICODE宏被定义。这会导致LPSTR/LPCSTR与LPWSTR/LPCWSTR、CHAR与WCHAR的类型不匹配。解决方案使用通用文本映射。始终使用TCHAR,_T(),LPCTSTR等通用类型和宏。确保字符串字面量用_T()包裹。或者将项目字符集设置为“使用多字节字符集”但这不符合现代开发趋势。过时的库和头文件如iostream.h应改为iostreamMFC的一些类或方法可能已被标记为废弃。解决方案查阅微软文档找到替代的类或方法。对于MFC确保包含了正确的头文件如afxwin.h,afxext.h并链接了对应的库如mfc140u.lib。4.2 将经典思想融入现代C项目我们学习的目的是古为今用。如何将VC经典项目中的精华应用到现代C开发中架构清晰化将经典项目中混杂在庞大类中的功能按照单一职责原则拆分成更小、更专注的现代C类或命名空间。资源管理现代化将大量使用new/delete和原始句柄HANDLE,HDC等的代码用std::unique_ptr,std::shared_ptr配合自定义删除器或RAII包装类如自己写一个GdiplusObjectGuard进行管理彻底避免泄漏。用现代库替代部分功能文件操作用fstream替代CFile。字符串处理用std::string/std::wstring和string算法替代CString虽然CString在现代MFC中依然强大。容器和算法用std::vector,std::map,algorithm替代CArray,CMap等MFC容器获得更好的泛型支持和性能。跨平台考量如果项目有跨平台需求需要将直接调用Windows API的部分抽象成接口然后用条件编译为不同平台提供实现。例如网络模块可以抽象成INetworkService在Windows下用Winsock实现在Linux下用BSD Socket实现。5. 学习资源与进阶路径源码获取GitHub搜索“vc”、“mfc”、“win32”等关键词可以找到很多经典项目的开源版本或仿写实现。前文提到的Awesome_c-cpp_Projects列表里也有大量相关资源。CodeProject、CodeGuru这两个网站是VC时代的宝藏存有大量高质量的文章和示例代码虽然网站设计古老但内容价值极高。微软官方示例Visual Studio安装目录下的Samples文件夹或者MSDN网站上的古老示例都是最正统的学习材料。调试与分析工具Visual Studio Debugger熟练使用条件断点、数据断点、内存查看、反汇编窗口、调用堆栈窗口。Process Monitor监视程序的文件、注册表、网络活动。Dependency Walker分析程序的DLL依赖关系。DebugView捕获OutputDebugString的输出是日志调试的利器。进阶路径深入COM/ATL如果你想理解Windows系统组件如DirectX、Shell扩展是如何构建的COM是必经之路。从理解接口、引用计数、跨公寓调用开始。研究WTLWindows Template Library是ATL的一个扩展提供了轻量级的GUI框架。它比MFC更简洁高效是学习Windows GUI编程底层机制的优秀材料。对接现代C学习如何在VC项目中逐步引入C11/14/17/20的新特性如智能指针、lambda表达式、范围for循环、协程等让老项目焕发新生。学习VC经典项目就像考古学家研究一件精美的青铜器。我们不是在追求重新铸造它而是通过它理解那个时代的工艺水平、审美标准和解决特定问题的方法论。这些沉淀下来的智慧足以让你在面对任何复杂、底层的软件开发挑战时多一份从容与底气。从读懂一行古老的WndProc开始到你能够设计出清晰、健壮、高效的现代C模块这条路每一步都算数。