ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C语言登录系统实战:从结构体、文件存储到密码哈希的完整教程

C语言登录系统实战:从结构体、文件存储到密码哈希的完整教程 1. 项目概述与核心思路拆解1.1 为什么选“登录系统”作为C语言入门实验我在带新手学C语言的时候最常被问的一个问题是“学了指针、结构体、文件操作但不知道这些东西到底能拼出什么来”。说实话课本上的例题都是孤立的知识点——今天讲循环就写个九九乘法表明天讲数组就来个冒泡排序后天讲指针就搞字符串逆序。学的时候都会合在一起就懵。登录系统这个选题恰好把这些零散的知识点串成了一条线而且需求极其明确用户注册、数据持久化、密码校验、登录状态控制。这个需求不管放在哪个编程语言里都成立用C语言实现一遍等于把“输入输出、流程控制、结构体、文件IO、字符串处理”这些基础功全部过了一遍。更重要的是它不是一个玩具题——真实世界的软件几乎都有账号体系哪怕你之后去学Java、Python、Go这个业务模型的理解都是通用的。1.2 基础能力储备与VSCode环境准备动手之前先说环境。我一直推荐新手用VSCode配C/C插件来做这类小项目而不是一上来就上Visual Studio或者CLion——VSCode轻量、启动快、配置透明写几十行的课程设计完全够用。配置步骤很简单但有几个坑必须提前排掉。第一编译器必须要用MinGW-w64别用老掉牙的MinGW32第二安装完MinGW之后必须把...\mingw64\bin这个路径加到系统环境变量Path里不然终端里敲gcc会提示“不是内部或外部命令”第三VSCode里需要同时安装“C/C”扩展和“Code Runner”扩展前者负责语法高亮和智能提示后者负责一键编译运行。配置好之后随手编译一个hello.c验证环境。这一步过了后面的实验就顺畅了。1.3 登录系统的功能范围界定既然是入门实验就不能一上来就追求企业级的东西。我认为这个项目合理的边界是这样的用户注册输入用户名和密码保存到本地文件用户登录核对用户名和密码给出成功/失败提示重复注册检测同一个用户名不能注册两次密码隐藏输入输入密码时不回显明文在Windows下用getch模拟登录尝试限制连续输错3次就锁定防止暴力猜密码数据持久化程序退出后用户数据不丢失有朋友可能会问要不要做密码加密要不要做多用户并发要不要做网络登录我的建议是统统不做——那是进阶版本的事。第一步能把文件读写和结构体用利索就已经达到入门目标了。从技术栈上看这个版本只用到C标准库里的stdio.h、string.h、stdlib.h外加Windows下控制台相关的conio.h。也就是说无论你在Windows还是Linux上核心代码都能编译只是密码隐藏那个函数需要做平台适配。先把这个范围记在心里我们往下拆每一块的设计思路。2. 登录系统的四个核心技术点解析2.1 结构体与文件存储用户数据怎么组织最合理要存用户信息第一步是定数据结构。最直觉的方案是开个全局数组存用户名和密码但程序一退出数据就没了。所以必须落到文件里。我的做法是定义一个User结构体#define MAX_USERS 100 #define NAME_LEN 20 #define PASS_LEN 20 typedef struct { char username[NAME_LEN]; char password[PASS_LEN]; int is_locked; // 0-正常 1-锁定 } User;这个结构体看起来简单但每个字段都有讲究。用户名和密码用定长数组而不是char*指针原因是定长数组在文件读写时不需要额外处理指针指向的内存fwrite一个结构体就能全部落盘。is_locked字段用来标记用户是否因为输错多次被锁定——这个字段一开始可能觉得没用但当你真正把登录系统拿给别人测试的时候就会发现没有锁定机制的系统密码可以被无限穷举体验和安全性都很糟糕。文件存储我选用二进制方式而不是文本方式。原因有三第一fwrite/fread配合结构体写入代码量最少第二二进制格式没有换行符转换问题读回来就是原样第三读写的效率和空间利用率都优于文本。具体操作是维护一个User users[MAX_USERS]数组程序启动时用fread从一个名为users.dat的文件里加载全部用户到内存程序退出时再用fwrite把整个数组写回文件。内存和磁盘之间通过文件这座桥同步这就是持久化的基本模型。2.2 密码存储方案明文书写是新手最容易犯的错误等写好了基本功能你会发现问题来了users.dat文件里用户的密码全是明文。自己打着玩没问题但如果哪天这代码被同学拷走了你的密码就裸奔了。入门项目虽然不必上MD5和SHA256但至少要知道一个朴素的道理用户的密码永远不应该以明文形式存储在数据库或文件里。企业级的做法是加盐哈希我在这里给C语言新手一个过渡方案——简单的哈希变换。我用的是一种被称为BKDRHash的字符串哈希算法代码非常短unsigned int hash_password(const char* str) { unsigned int seed 131; // 31, 131, 1313, 13131... unsigned int hash 0; while (*str) { hash hash * seed (*str); } return hash; }这个算法的思想是把字符串当作一个31进制或131进制的大整数逐字符累乘累加最终得到一个无符号整数。因为不同字符串计算出来的hash值大概率不同所以存hash值可以避免直接暴露原始密码。当然这种简单哈希不能跟真正的加密算法相提并论——碰撞、彩虹表等问题它都有。但作为入门项目它能让你体会到“密码校验的本质是比较密文而非比较明文”这件事值回票价了。2.3 输入处理与缓冲区陷阱scanf和fgets怎么选C语言的输入函数是个大坑几乎所有新手都在scanf上栽过跟头。特别是第一次写登录系统时用户输完用户名后按回车紧接着输密码结果发现密码输入直接被跳过了——这就是缓冲区里残留的换行符在作祟。我的建议是用户名和密码的读取一律用fgets不要用scanf(%s)。原因有两个第一scanf(%s)不能控制最大读取长度一旦输入超过数组长度就会缓冲区溢出这在有安全意识的场景里是不能容忍的。第二scanf不会把换行符从缓冲区里清掉连续读取时很容易出问题。fgets的正确用法是这样的fgets(username, sizeof(username), stdin); // 去掉末尾的换行符 username[strcspn(username, \n)] \0;strcspn这个函数返回字符串中第一次出现\n的位置把它替换成\0就把换行符干掉了。这个习惯一旦养成之后写任何C语言项目都会少踩很多坑。2.4 登录控制逻辑尝试次数限制与会话状态登录控制的流程看着简单但把完整的状态机画出来涉及的判断分支一点不少接收用户名输入在用户表中查找该用户名查不到提示“用户不存在”重新输入查到了检查is_locked字段已锁定提示“账户已锁定请联系管理员”比较当前密码hash值和存储的hash值不匹配错误次数13次不匹配锁定账户匹配登录成功进入主界面这个流程里最核心的思想是把“登录动作”和“业务主循环”拆开。也就是说login()函数只负责验证身份返回成功或失败主函数根据返回值决定是否进入后续的业务逻辑比如打印“欢迎回来”或进入菜单。很多新手喜欢把所有逻辑堆在main里一个if套一个if最后代码根本没法看。把这些技术点拆清楚之后下面进入实际编码环节。我会把整个项目的完整代码分段讲一遍每一段顺便解释为什么这么写。3. 完整代码实现与逐步讲解3.1 项目结构划分这个项目我建议拆成三个文件不是为了装模作样地搞工程化而是为了让新手直观感受一下“头文件声明、源文件定义”是怎么协作的login_system/ ├── user.h # 结构体定义、函数声明 ├── user.c # 用户管理的实现 ├── main.c # 主循环和交互界面 └── users.dat # 运行时生成的数据文件user.h里声明这些接口void user_init(); // 启动时加载文件数据 void user_save(); // 退出前保存数据 int user_register(const char* name, const char* pass); int user_login(const char* name, const char* pass); int user_exists(const char* name); void user_lock(const char* name);把头文件和实现分开编译命令也很简单gcc main.c user.c -o login.exe生成一个login.exe你把代码给别人人家一条命令就能跑起来体验比在IDE里点按钮好得多。3.2 文件加载与保存的实现先看user_init和user_save这是数据持久化的核心#define DATA_FILE users.dat static User users[MAX_USERS]; static int user_count 0; void user_init() { FILE* fp fopen(DATA_FILE, rb); if (fp NULL) { // 文件不存在说明是第一次运行 user_count 0; return; } user_count fread(users, sizeof(User), MAX_USERS, fp); fclose(fp); } void user_save() { FILE* fp fopen(DATA_FILE, wb); if (fp NULL) { printf(错误无法写入数据文件\n); return; } fwrite(users, sizeof(User), user_count, fp); fclose(fp); }这段代码有两个细节值得讲。第一fread的返回值直接赋给user_count意味着文件里有多少个完整结构体我们就认为有多少个用户——这是一种约定写的时候要保持一致。第二fopen返回NULL的情况必须处理。新手写文件操作经常不检查返回值一旦磁盘满了或者路径无权限程序就悄悄崩溃了。检查返回值是职业习惯得从一开始就养成。fwrite(users, sizeof(User), user_count, fp)这句的含义是把users数组起始地址开始的内存按sizeof(User)为单位连续写出user_count个块。结构体在内存里是连续排布的所以写文件就是“内存搬运工”的活。3.3 注册功能的实现注册逻辑的漏洞比较容易出现在“重复名检查”这一步int user_register(const char* name, const char* pass) { if (user_count MAX_USERS) { return -1; // 用户数已满 } if (user_exists(name)) { return 0; // 用户名已存在 } strcpy(users[user_count].username, name); users[user_count].password_hash hash_password(pass); users[user_count].is_locked 0; user_count; return 1; }这里我用了三个返回值表示三种结果1成功0用户名已存在-1用户满。这种用整数返回码做错误处理的习惯在C语言里很常见比靠全局变量传递状态清晰多了。有个细节需要注意strcpy是不安全的因为目标缓冲区可能不够大。但在这个项目里name是从fgets读入的而fgets已经限制了最大长度NAME_LEN所以这里用strcpy是安全的。3.4 登录验证与锁定机制登录函数的实现带着一个计数器这个计数器我用静态局部变量而不是全局变量int user_login(const char* name, const char* pass) { for (int i 0; i user_count; i) { if (strcmp(users[i].username, name) 0) { if (users[i].is_locked) { return -2; // 账户锁定 } if (users[i].password_hash hash_password(pass)) { return 1; // 登录成功 } else { return 0; // 密码错误 } } } return -1; // 用户不存在 }锁定逻辑放在主循环int fail_count 0; while (fail_count 3) { printf(请输入密码); get_password(input_pwd, PASS_LEN); int result user_login(input_name, input_pwd); if (result 1) { printf(登录成功\n); break; } else if (result -2) { printf(账户已锁定请联系管理员。\n); break; } else { fail_count; printf(用户名或密码错误剩余尝试次数%d\n, 3 - fail_count); } } if (fail_count 3) { user_lock(input_name); printf(连续错误3次账户已锁定。\n); }这个循环结构值得反复品味。fail_count只在这一个循环里有效初始化是0每次失败自增达到3次就锁定账户并退出循环。主意这里锁定的依据是“连续输错3次”一旦中途登录成功计数器就重置了。更精细的方案是记录错误时间并在N分钟内限制尝试次数那是企业级的行为入门阶段先做计数锁定就可以了。3.5 密码隐藏输入的实现密码不回显是登录系统的一个经典体验要求。Windows下用conio.h里的getch()函数每读一个字符就打印一个*void get_password(char* pwd, int max_len) { int i 0; char ch; while (i max_len - 1) { ch getch(); if (ch \r) { // 回车结束 break; } else if (ch \b) { // 退格删除 if (i 0) { printf(\b \b); i--; } } else { pwd[i] ch; printf(*); } } pwd[i] \0; printf(\n); }这里的getch()是不会回显输入的也就是说用户输入什么字符屏幕不会自动显示需要我们自己在合法字符后面补一个*。退格键\b的处理也很关键——如果不处理用户按退格时前一个字符删不掉体验极差。Linux下对应的实现就要换成termios相关的系统调用了但核心思路是一样的。3.6 主循环与菜单设计main函数负责把这些模块串起来。我设计了三个选项注册、登录、退出。int main() { user_init(); int choice; char name[NAME_LEN], pwd[PASS_LEN]; while (1) { printf(\n 登录系统 \n); printf(1. 注册\n); printf(2. 登录\n); printf(3. 退出\n); printf(请选择); scanf(%d, choice); while (getchar() ! \n); // 清除缓冲区残留 if (choice 1) { printf(请输入用户名); fgets(name, NAME_LEN, stdin); name[strcspn(name, \n)] \0; printf(请输入密码); get_password(pwd, PASS_LEN); int res user_register(name, pwd); if (res 1) { printf(注册成功\n); } else if (res 0) { printf(用户名已存在\n); } else { printf(用户数已满\n); } } else if (choice 2) { // 登录流程代码 } else if (choice 3) { break; } } user_save(); return 0; }注意scanf(%d, choice)后面那行while (getchar() ! \n);——这是用来把缓冲区的回车符清掉的。如果不做这一步下一次fgets会直接读到残留的换行符从而产生“还没输入用户名就跳过”的问题。这是我反复强调的一个坎几乎所有初学者第一次跑这个程序都会遇到。到这一步一个能用的登录系统已经落地了。但“能用”和“好用”之间还有一段路要走。下面我把自己在打磨过程中总结的几个优化方向和对应的坑写出来。4. 从能用到好用三个值得做的优化方向4.1 优化一日志记录让系统有迹可循运行过一阵子之后你会遇到一件尴尬的事——程序里没有任何记录你根本不知道昨天谁登录过、失败了多少次。这时就该加日志了。在user_login的各个返回点后追加一行日志写入#include time.h void write_log(const char* username, int result) { FILE* log_fp fopen(login.log, a); if (log_fp NULL) return; time_t now time(NULL); char* time_str ctime(now); time_str[strcspn(time_str, \n)] \0; fprintf(log_fp, [%s] user%s result%s\n, time_str, username, result 1 ? success : result -2 ? locked : result 0 ? wrong_pwd : no_user); fclose(log_fp); }日志这种功能入门阶段最容易忽略一旦加上就会发现它带来的价值远超预期——排查问题、回溯操作、验证功能全都靠它。用追加模式a打开文件每次写一行不用担心覆盖前一天的记录。4.2 优化二把配置参数从代码里抽出来MAX_USERS、MAX_LOGIN_ATTEMPTS这些宏写死在代码里在入门阶段没问题。但如果你想让别人不改代码就调整规则就得引入配置文件了。写法也不复杂用空格分隔键值对的文件即可MAX_USERS100 MAX_ATTEMPTS3 LOCK_TIME300然后在user_init里加一个load_config函数用fscanf逐行读取。这个优化看起来不起眼但它会让你理解“为什么真实项目里要有配置文件”——因为运维人员不想为了改一个数字重新编译整个程序。4.3 优化三密码策略的简单化处理注册时的一个常见问题是用户设了特别短的密码或者密码和用户名一样。入门阶段做个最简单的长度检查再加个拒绝常见弱密码就能挡掉一大半问题int is_valid_password(const char* pwd) { if (strlen(pwd) 6) return 0; // 至少6位 if (strspn(pwd, abcdefghijklmnopqrstuvwxyz) strlen(pwd)) { return 0; // 过于简单只有小写字母 } return 1; }strspn函数返回第一个不在集合内的字符的位置如果整个字符串都在小写字母集内说明密码太弱。这只是个玩具级策略但能让你体会到“规则”的威力。4.4 优化四从命令行参数读取用户名写完了这个项目你可能会想怎么把它变得更“真实”。一个很容易做的改进是支持命令行参数直接指定用户名login.exe -u zhangsan -p 123456实现方式从main(int argc, char* argv[])里依次解析参数比对字符串。这个功能让程序可以在批处理脚本里被调用也为以后对接其他系统留了口子。5. 常见问题与排查技巧实录5.1 scanf与fgets缓冲区冲突症状输入完数字选项后紧接着的fgets读取用户名直接跳过甚至读到空字符串。原因scanf(%d, choice)会把回车留在输入流里。下一个fgets读到这个残留的回车认为一行已经结束直接返回空字符串。解决在scanf之后加一行while (getchar() ! \n);清空缓冲区。或者干脆选项也统一用fgets加atoi解析彻底避开这个问题。5.2 密码输入不回显的错误输出症状程序运行时有用户反馈输入密码时屏幕显示明文。原因在Windows下判断条件可能是_WIN32宏没生效或者引入了conio.h但链接失败。更常见的是在IDE的“输出面板”里运行时终端类型不对getch()的表现异常。解决先在普通命令提示符下测试确认问题和环境无关后再考虑换输入方案。5.3 数据文件读取后用户数据异常症状重新运行程序后之前的注册记录不见了或者打开users.dat发现乱码。原因一个可能是保存和加载时user_count不一致——注册了5个用户但程序异常退出时没调user_save。另一个可能是结构体里包含了指针类型的字段指针值直接写进文件下次加载时指向的内存早已失效。解决坚持“退出前统一保存”的模式不要在每次注册后单独写文件结构体里只放定长数组不放指针程序准备退出时用atexit(user_save)注册回调保证无论走哪个分支退出都能保存。5.4 VSCode中文乱码问题症状程序里的中文提示在VSCode终端里显示乱码。原因Windows的cmd默认编码是GBKVSCode的终端编码配置没跟上或者源文件保存成了UTF-8而终端按GBK解析。解决VSCode里打开设置搜索terminal.integrated.profiles.windows把终端编码调整一下或者写代码时干脆用英文提示省事也避免这种历史遗留问题。5.5 编译通过但运行崩溃症状程序编译没报错但一运行就提示“段错误”或者直接闪退。原因最常见的失手是数组越界。比如用户注册了第101个用户时users[100]越界对数组元素的写入可能损坏了相邻内存区域。解决在注册函数里先检查user_count MAX_USERS再执行写操作每次fgets都要限制最大长度strcpy之前确认源字符串长度确实够短。6. 写在最后的个人体会这个项目前前后后我陪好几届新手做过每次都能看到大家踩同样的坑。最大的感触是很多报错并不是因为你笨而是因为C语言把内存管理、缓冲区这些底层逻辑暴露给了你。这些机制在Python、Java里被隐藏了但恰恰是理解计算机工作原理的关键钥匙。两点提醒各位。第一编码时给自己留一个“随时能跑”的版本。每次改完一小块功能就编译运行一次不要等到写了一整天再编译否则一堆错误凑在一起查都查不过来。第二动手改代码比看代码重要得多。我见过很多人把这篇文章里的代码复制到本地编译通过就以为自己学会了——只要让他加一个“密码找回”功能立刻露馅。自己亲手推倒重写一遍哪怕写得慢一点收获也是看代码的十倍。如果你想继续扩展这个项目我建议按这个顺序来先加日志再加密码策略然后试着把数据文件改成文本模式并手写解析最后再思考“如何把核心逻辑改造成多用户同时操作的安全模型”。每一步都是在加固你C语言的地基。走到第四步的时候你已经可以出去找工作面试了。
返回列表