简介:面向VC++初学者及需要实现安全随机密码功能的开发者,这份工程示例完整演示了在Visual C++环境下生成随机密码的实现思路,可有效解决如何组合大小写字母、数字与特殊字符的常见需求。压缩包共含16个文件,以C++源码(.cpp/.h)、对话框资源文件(.rc/.rc2)、图标文件与Visual Studio旧版工程文件(.dsw/.dsp)为主,整包仅15KB,结构紧凑便于阅读。已有214人学习浏览。源码中通过RdPass类及对话框实现,清晰展示了随机数生成、字符集索引、用户界面交互等核心逻辑,并配有预编译头文件与工程配置,既适合初学者对照调试、理解MFC程序框架,也可作为起点进一步扩展为支持密码强度检查、自定义密码策略的实用安全工具。
1. 一个 VC++ 6.0 时代的随机密码生成器仍在被反复打开
文件名RdPass.dsw对今天的开发者来说已经相当陌生,但它确实是在 Visual C++ 6.0 环境下可直接打开的 MFC 对话框工程。整个vc生成随机密码.zip压缩包内源码文件不到十个,核心逻辑集中在RdPassDlg.cpp中:用户输入密码长度、勾选字符集,点击生成按钮后由rand()从字符池抽取字符,拼成随机密码回显到编辑框,并提供一键复制。这个工具本身不复杂,却把 MFC 控件绑定、预编译头机制、.dsp/.dsw老工程结构和伪随机数精度四个知识点串在了一起,既适合 MFC 初学者当练手项目,也适合需要做老代码迁移的工程师作为参照样本。
2. rand() 伪随机序列与 RdPass 随机种子初始化细节
2.1 RAND_MAX 只有 32767:取模偏差从哪来
在 Visual C++ 6.0 的 CRT 实现中,rand()底层是线性同余生成器,返回 0 到RAND_MAX的整数,而这里的RAND_MAX是 32767,也就是 15 位随机数。RdPass 生成密码时普遍采用rand() % nPoolSize的方式取字符索引,这有两个层面的隐患。
第一个隐患是取模的 Modulo Bias。假定字符池有 67 个字符,32768 除以 67 等于 489 余 5,那么余数 0 到 4 这五个值出现的概率是 490/32768,其余 62 个余数的概率是 489/32768。虽然单个字符的偏差绝对值很小,但密码生成会连续调用几十次,偏差会被累积放大,安全审计时这是明确的扣分项。
| 字符池大小 N | 32768 / N 的商 | 余数 r | 受影响的余数个数 |
|---|---|---|---|
| 62 | 528 | 32 | 32 个余数多 1 次机会 |
| 67 | 489 | 5 | 5 个余数多 1 次机会 |
| 81 | 404 | 44 | 44 个余数多 1 次机会 |
| 94 | 348 | 56 | 56 个余数多 1 次机会 |
第二个隐患是低位比特周期问题。线性同余算法的最低几位随机性很弱,rand() % 2的结果会以固定的短周期反复出现。直接取模等于把随机性最差的那几个比特暴露给调用者,这在密码场景下不可接受。正确做法是丢弃低比特,用高位参与映射:
// 生成 0 到 nPoolSize-1 的近似均匀索引 int nRand = rand() >> 4; // 丢弃低 4 位,保留高 11 位 int nIndex = (int)((double)nRand / 2048.0 * nPoolSize); // 浮点缩放替代取模 if (nIndex >= nPoolSize) nIndex = nPoolSize - 1; // 防御浮点边界溢出rand() >> 4把随机范围变成 0 到 2047,double除法得到 0 到 1 之间的小数,再乘以池大小映射到目标区间。相比直接%,这种映射在不同池大小下都保持近似均匀。需要强调的是,这只能修复分布偏差,不能改变rand()本身周期有限的事实。
2.2 种子初始化位置决定了密码是否重复
RdPass 工程里种子的调用点通常在窗口初始化阶段:
BOOL CRdPassDlg::OnInitDialog() { CDialog::OnInitDialog(); srand((unsigned)time(NULL)); // 以系统秒级时间作为种子 return TRUE; }time(NULL)返回自 1970 年以来的秒数,种子空间只有约 2^31 量级。更严重的问题是,如果srand()被放到生成按钮的点击事件里,用户在 1 秒内连续点击两次,两次生成的随机密码序列会完全一样。老代码里最常见的错误就是“每次生成前重新播种”,这会让rand()退化成一张按时间查表。
提示:
srand()整个进程只需要调用一次。凡是把srand()写进按钮响应函数里的实现,都需要把调用点移到OnInitDialog()或全局初始化处。
如果希望扩大种子空间,可以组合多个输入源:
// 组合时间、进程 ID 和栈地址随机性,扩大种子空间 unsigned nSeed = (unsigned)time(NULL); nSeed ^= (GetCurrentProcessId() << 16); nSeed ^= (unsigned)(UINT_PTR)&nSeed; // 栈地址本身带随机性 srand(nSeed);2.3 字符池构建:从静态字符串到动态拼接
RdPass 工程中通常定义了静态字符串作为字符集,再根据复选框状态决定把哪些字符串拼入临时池:
char GetRandomChar(BOOL bUpper, BOOL bLower, BOOL bDigit, BOOL bSpecial) { char pool[256] = {0}; if (bLower) strcat(pool, "abcdefghijklmnopqrstuvwxyz"); if (bUpper) strcat(pool, "ABCDEFGHIJKLMNOPQRSTUVWXYZ"); if (bDigit) strcat(pool, "0123456789"); if (bSpecial) strcat(pool, "!@#$%^&*()-_=+[]{}:;?/"); int nSize = (int)strlen(pool); if (nSize == 0) return '\0'; int nRand = rand() >> 4; int nIndex = (int)((double)nRand / 2048.0 * nSize); if (nIndex >= nSize) nIndex = nSize - 1; return pool[nIndex]; }四个字符集全部勾选时,池大小为 26+26+10+22=84,char pool[256]有足够冗余。真正要注意的是strcat的缓冲区长度,如果在 VC++ 6.0 下没有给数组固定大小,编译器不会主动报错,但运行时可能覆盖相邻栈数据。更稳妥的做法是用strncat并显式传入剩余容量。字符池顺序也会影响生成结果:如果你希望所有字符等概率出现,字符池中不能有重复字符,拼接时要避免把同一字符集加入两次。
3. RdPassDlg 对话框工程:控件 ID 与 MFC 消息流
3.1 resource.h 中控件 ID 的分配约定
MFC 对话框工程的界面布局写在RdPass.rc文本资源文件里,所有控件 ID 常量集中在resource.h,编译时RdPassDlg.cpp通过#include "resource.h"引用这些 ID。RdPass 典型的 ID 分配如下:
| 控件 | ID 宏 | 典型值 | 说明 |
|---|---|---|---|
| 密码长度输入框 | IDC_EDIT_LENGTH | 1000 | 接收 4-64 的整数 |
| 大写字母复选框 | IDC_CHECK_UPPER | 1001 | 勾选后启用大写字符集 |
| 小写字母复选框 | IDC_CHECK_LOWER | 1002 | 勾选后启用小写字符集 |
| 数字复选框 | IDC_CHECK_DIGIT | 1003 | 勾选后启用数字字符集 |
| 特殊字符复选框 | IDC_CHECK_SPECIAL | 1004 | 勾选后启用特殊字符集 |
| 生成按钮 | IDC_BTN_GENERATE | 1005 | 触发密码生成 |
| 结果显示编辑框 | IDC_EDIT_RESULT | 1006 | 显示生成的密码,通常设只读 |
resource.h约定俗成用IDC_前缀表示控件,IDD_表示对话框模板,IDR_表示菜单或图标资源。如果你在类向导里删除了一个控件却没有同步清理.rc文件中对应的DEFPUSHBUTTON或PUSHBUTTON行,编译可能不报错,但对话框运行时布局会出现错位,排查成本很高。
3.2 DoDataExchange 中的 DDX/DDV 绑定
RdPassDlg.cpp的DoDataExchange是 MFC 对话框的数据交换中心:
void CRdPassDlg::DoDataExchange(CDataExchange* pDX) { CDialog::DoDataExchange(pDX); // {{AFX_DATA_MAP(CRdPassDlg) DDX_Text(pDX, IDC_EDIT_LENGTH, m_nLength); DDV_MinMaxInt(pDX, m_nLength, 4, 64); DDX_Check(pDX, IDC_CHECK_UPPER, m_bUpper); DDX_Check(pDX, IDC_CHECK_LOWER, m_bLower); DDX_Check(pDX, IDC_CHECK_DIGIT, m_bDigit); DDX_Check(pDX, IDC_CHECK_SPECIAL, m_bSpecial); DDX_Text(pDX, IDC_EDIT_RESULT, m_strPassword); // }}AFX_DATA_MAP }DDX_Text绑定到int时输入非数字会触发类型转换提示,绑定到CString时接收任意文本。DDV_MinMaxInt是数据校验宏,把长度限制在 4 到 64 之间,超出时焦点不会转移到其他控件,而是停在出错框上并弹出提示。注意// {{AFX_DATA_MAP注释标记是类向导的领地,手工新增DDX_语句时如果破坏了上下两行标记,下次运行类向导会把这些绑定全部清掉。
3.3 生成按钮点击后的完整消息流
生成按钮的响应函数维持着基本流程:
void CRdPassDlg::OnBnClickedBtnGenerate() { if (!UpdateData(TRUE)) // 控件同步到成员变量 return; if (m_nLength < 4 || m_nLength > 64) { AfxMessageBox(_T("长度应在 4 到 64 之间")); return; } m_strPassword.Empty(); for (int i = 0; i < m_nLength; i++) { m_strPassword += GetRandomChar(m_bUpper, m_bLower, m_bDigit, m_bSpecial); } UpdateData(FALSE); // 成员变量写回控件 }UpdateData(TRUE)调用DoDataExchange把控件内容读入成员变量,UpdateData(FALSE)反向把成员变量刷新到控件。如果忘记先调用UpdateData(TRUE),m_bUpper等复选框状态会停留在上一次的值,看起来像是按钮没有反应。消息映射由RdPassDlg.h中的afx_msg void OnBnClickedBtnGenerate();声明和RdPassDlg.cpp里BEGIN_MESSAGE_MAP中的ON_BN_CLICKED(IDC_BTN_GENERATE, &CRdPassDlg::OnBnClickedBtnGenerate)共同组成,VC++ 6.0 下这些映射由类向导自动生成,手工修改可能导致RdPass.clw与代码不一致,类向导的控件事件列表会变成空的。
3.4 剪贴板复制与 HGLOBAL 所有权
密码生成后复制到剪贴板是高频操作,这里有一个资源所有权陷阱:
void CRdPassDlg::OnBnClickedBtnCopy() { if (m_strPassword.IsEmpty()) return; if (!OpenClipboard()) return; EmptyClipboard(); int nBytes = (m_strPassword.GetLength() + 1) * sizeof(TCHAR); HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, nBytes); if (hMem != NULL) { LPTSTR pBuf = (LPTSTR)GlobalLock(hMem); _tcscpy(pBuf, (LPCTSTR)m_strPassword); GlobalUnlock(hMem); SetClipboardData(CF_UNICODETEXT, hMem); } CloseClipboard(); }GlobalAlloc之后不能再手动GlobalFree,剪贴板接管了HGLOBAL的释放责任。字符集方面,如果工程在 VC++ 6.0 下使用多字节字符集,而这里设置CF_UNICODETEXT,粘贴时会按 UTF-16 数据被目标程序按 ANSI 读取,出现乱码。稳妥的做法是改用CF_TEXT,或者干脆把整个工程切换为 Unicode 字符集,这也是 VC++ 2010 之后 MFC 新工程的默认状态。
4. 从 .dsp 老工程到新版 Visual Studio:迁移与运行库
4.1 StdAfx 预编译头的加速原理
StdAfx.h和StdAfx.cpp是 Visual C++ 6.0 自动生成的预编译头文件,前者集中包含 MFC 核心头文件:
#include <afxwin.h> #include <afxext.h> #include <afxdisp.h> #include <afxdtctl.h>预编译机制把这组头文件翻译成编译器内部状态并缓存为.pch,后续每个.cpp文件都复用这份缓存。其余源文件的第一行必须是#include "stdafx.h",否则编译会报:
Fatal Error C1010: unexpected end of file while looking for precompiled header directive排查 C1010 时不需要改工程设置,只需要确认两件事:一是工程选项中/Yu指定的头文件名与#include的写法完全一致;二是StdAfx.cpp单独使用/Yc负责生成预编译头。如果StdAfx.cpp被误删,会出现大量链接错误,例如LNK2001: unresolved external symbol __beginthreadex,这类问题很容易被误判为运行库缺失。
4.2 RdPass.dsw 转 .sln 的兼容性处理
用 Visual C++ 2010 学习版直接打开RdPass.dsw,转换向导会生成新的.sln和.vcxproj。实际迁移时,有四个地方需要手工干预。
第一是字符集。VC++ 6.0 工程默认多字节字符集,VC++ 2010 的 MFC 模板默认 Unicode。如果代码里写死了char*到CString的隐式转换,在 Unicode 下会大量触发 C2664 编译错误:
CString strTitle = _T("RdPass"); // 用 _T() 包裹字符串字面量 SetWindowText(strTitle);第二是for循环作用域。VC++ 6.0 的循环变量在循环结束后仍然可见,VC++ 2010 默认开启/Zc:forScope,在循环外引用循环变量会直接报错。修复方式是编辑代码,把变量定义移到循环外。
第三是派生文件清理。RdPass.plg是编译器日志,RdPass.clw是类向导数据库,迁移前直接删除,新版 VS 不需要它们,保留反而会把项目目录弄乱。
第四是 MFC 库的链接方式。在工程属性中检查“使用 MFC”是否设置为“使用共享 DLL 中的 MFC”,老工程从另一台机器拷贝过来后,这里的设置经常会丢。
4.3 编译产物依赖的 Visual C++ 运行库
vc生成随机密码.zip自带Release目录,说明压缩包里包含预编译好的可执行文件。VC++ 6.0 下如果以/MD方式链接,生成的RdPass.exe依赖MFC42.dll和MSVCRT.dll;改成/MT静态链接后可以在干净 Windows 上运行,但文件体积会变大。用dumpbin可以查看具体依赖:
dumpbin /dependents Release\RdPass.exedumpbin是 Visual Studio 自带的命令行工具,需要在“Visual Studio 命令提示符”中执行。输出里的MFC42.dll和MSVCRT.dll表明目标机器需要安装对应运行库。不同版本的 Visual C++ 运行库独立安装、互不包含:2005 对应 8.0 版本,2008 对应 9.0,2010 对应 10.0。装错版本时,程序会弹出side-by-side configuration is incorrect的错误对话框。迁移老工程时,最好的策略是静态链接 MFC 和 CRT,彻底免除目标机器的运行库依赖。
注意:网上常见的 Visual C++ 运行库整合包把所有版本捆在一起安装,可以解决大部分依赖问题,但静态链接仍然是发布工具类程序更可控的方案,尤其适用于需要分发给多台机器的场景。
4.4 .dsp 工程中常见的三个链接错误
老工程迁移时,下面的链接错误出现频率最高,对应的处理方式也相对固定:
| 错误信息 | 原因 | 处理方法 |
|---|---|---|
| LNK2005 xxx already defined | 全局变量在多个 .cpp 中重复定义 | 把定义改为extern声明,并用唯一 .cpp 提供定义 |
| LNK2001 unresolved external symbol | 链接器没有找到 MFC 库 | 检查工程属性中 MFC 使用方式是否为共享 DLL |
| LNK2019 unresolved external symbol "class CRdPassApp theApp" | RdPass.cpp 中的 CWinApp 派生类定义被排除出编译 | 在工程文件列表中确认 RdPass.cpp 存在 |
有些老工程会在源码里写#pragma comment(lib, "winmm.lib")这样的指令,迁移后依然有效。但如果新版 VS 报找不到对应.lib,一般是 Windows SDK 版本升级后旧库文件位置发生了变更,直接从旧 SDK 目录里拷贝一份到工程文件夹即可。
5. 用 CryptGenRandom 替换 rand():随机密码的熵与验证
5.1 用信息熵量化密码强度
RdPass 生成的密码是否够强,不能凭肉眼判断,要用信息熵量化。单字符熵值公式是H = log2(N),其中N是字符池大小。按 RdPass 的四种勾选组合计算:
| 字符池组合 | 池大小 N | 单字符熵 | 12 位总熵 |
|---|---|---|---|
| 仅小写字母 | 26 | 4.70 bit | 56.4 bit |
| 小写 + 数字 | 36 | 5.17 bit | 62.0 bit |
| 小写 + 大写 + 数字 | 62 | 5.95 bit | 71.4 bit |
| 大小写 + 数字 + 22 个特殊字符 | 84 | 6.39 bit | 76.7 bit |
可以看出,即使四个字符集全部勾选,12 位密码的熵也只有 76.7 bit,距离常见的 80 bit 安全基线还有差距。如果企业口令策略要求熵值 80 bit 以上,密码长度至少要到 13 位。
5.2 Crypto API 获取高熵随机数
生产环境不应依赖 CRT 的rand(),推荐改用 Windows Crypto API:
#include <wincrypt.h> BOOL GenerateRandomBytes(BYTE* pOut, DWORD dwLen) { HCRYPTPROV hProv = 0; BOOL bResult = CryptAcquireContext(&hProv, NULL, NULL, PROV_RSA_FULL, CRYPT_VERIFYCONTEXT); if (!bResult) return FALSE; bResult = CryptGenRandom(hProv, dwLen, pOut); CryptReleaseContext(hProv, 0); return bResult; }CryptAcquireContext第五个参数传CRYPT_VERIFYCONTEXT时,函数只获取临时默认上下文,不创建持久密钥文件,这是只取随机数的推荐用法。CryptGenRandom的第二个参数是字节数,第三个参数是输出缓冲区指针。改造 RdPass 时,把原来的rand() % nPoolSize替换为:
int nRand = 0; if (GenerateRandomBytes((BYTE*)&nRand, sizeof(nRand))) { nIndex = nRand % nSize; } else { nRand = rand(); // 降级分支,保证功能可用 nIndex = (int)((double)(nRand >> 4) / 2048.0 * nSize); }降级分支存在的意义是防止系统 API 调用失败时程序直接崩溃,但日志中要标记降级发生,最典型的失败原因是当前用户账户没有获取 CSP 上下文的权限。如果后续希望兼容新版 Windows,可以换用BCryptGenRandom(0, pOut, dwLen, BCRYPT_USE_SYSTEM_PREFERRED_RNG),接口更简洁,且不需要手动释放上下文。
5.3 连续生成结果的验证方法
改造完成后,可以用一个简单的手段验证随机质量:连续调用 10 次生成长度为 64 的密码,检查两组相邻密码的相同位置上出现相同字符的占比。理想情况下,这个比例应该接近 1/84 的量级,也就是约 1.2%。如果相同位置的重复比例明显高于该值,说明随机数的低熵问题没有真正解决,需要检查代码是否在CryptAcquireContext失败后静默走了rand()降级分支——这是这类工具最容易出现的哑故障。
验证脚本用 Python 可以快速完成:
import math pool_size = 84 repeat_rate = 0.05 # 从实际输出统计得到 entropy = 64 * math.log2(pool_size) print(f"理论熵值 {entropy:.1f} bit,同位置重复率阈值约 {1/pool_size:.3f}") print(f"实测重复率 {repeat_rate:.3f}" + (",通过" if repeat_rate < 0.02 else ",需检查降级分支"))如果实测重复率高于 2%,优先检查CryptAcquireContext调用前后的GetLastError()返回值,并把降级路径里的rand()分支替换为死循环重试或直接返回失败,避免在安全工具里让弱的随机数被业务逻辑意外接受。
本文还有配套的精品资源,点击获取