C语言实现外来人员进出监控系统:结构体、文件读写与状态机设计
2026/9/15 17:36:12 网站建设 项目流程

简介:面向C语言课程设计与实训场景,这套外来人员进出监控与管理系统以控制台为交互界面,按访客和管理员两类角色拆解业务流程。访客侧支持提出申请、入校登记、离校登记,并可区分个人与团体访客;管理员侧提供审批申请、增加管理员、查询申请记录等功能,覆盖校园或园区外来人员管理的核心环节。压缩包共25个文件,大小约1.58MB,以C语言源码与头文件为主,并配有PPT演示文稿、实验报告Word文档、16张运行截图及Markdown说明文档,从代码、文档、演示多个层面辅助学习。适合正在选题或需要快速搭建类似管理系统的C语言初学者与高校学生,已有128人学习下载。通过学习这份资源,可掌握系统需求拆分、模块化编码、控制台界面设计与答辩汇报素材整理,为课程设计评分或毕业设计预研提供可直接参考的完整样例。

1. 外来人员进出监控与管理系统,为什么用C语言做

外来人员进出管理看起来是个业务问题,但落到技术上就是一笔数据流:人来了要登记,人走了要销账,中间还要查得到“谁在厂里、什么时候进的、进的是哪个区域”。这类场景在中小企业门卫、园区访客、工地人员管控中经常遇到,除了商业访客系统,更常见的是让嵌入式终端或本地服务器跑一个轻量程序,而 C 语言恰恰是这类需求里最不容易出错的选择。它没有运行时依赖,编译完一个小文件就能放在老旧的 x86 工控机或者 ARM 开发板上跑;文件读写、数据结构、时间处理都直接对应操作系统接口,不存在解释型语言在嵌入式环境里被裁剪掉库的问题。对一个要长期运行、无人值守的进出监控程序来说,C 语言的确定性、低内存占用、可预测的启动速度,就是它在这类系统中的核心价值。

本文不会带你去做一个带界面的完整系统,而是把“外来人员进出监控与管理”拆成几条主线:数据模型怎么设计、文件读写怎么做、进出状态怎么联动、运行期怎么排错。代码用纯 C 写,编译环境用 GCC 即可,开发工具用 VS Code 加 C/C++ 插件或 C-Free 都行。你会看到一套不依赖数据库、不依赖框架的最小落地实现,适合 C 语言初学者理解项目结构,也适合有经验的工程师把它改造成服务端模块或嵌入式端程序。文中代码只保留主流程,删除和修改功能给出可扩展的接口设计。

2. 数据模型先行:用结构体与文件读写管好“谁进过、几点进、几点出”

2.1 结构体设计:人员档案与进出记录分开两张“表”

没有数据库的前提下,C 语言里最自然的建模方式就是结构体。外来人员管理系统至少需要两类数据:一类是人员档案,即姓名、身份证号/证件号、手机号、被访部门、事由;另一类是每次进出的流水,即谁、什么时候进、什么时候出、当前是否在厂内。常见的设计错误是把这两类数据合在一个结构体里,导致一个人多次来访时数据互相覆盖。正确做法是拆成两个结构体,用 ID 关联。

#define MAX_NAME_LEN 32 #define MAX_ID_LEN 20 #define MAX_DEPT_LEN 32 #define MAX_REASON_LEN 64 #define MAX_PHONE_LEN 16 typedef struct { int person_id; // 人员唯一ID,也是文件里的主键 char name[MAX_NAME_LEN]; char id_card[MAX_ID_LEN]; // 证件号,作为第二检索键 char phone[MAX_PHONE_LEN]; char dept[MAX_DEPT_LEN]; // 被访部门 char reason[MAX_REASON_LEN]; // 来访事由 int visit_count; // 累计来访次数 int in_company_flag; // 1=在厂内,0=已离开 } PersonInfo; typedef struct { int record_id; // 单据流水号 int person_id; // 关联 PersonInfo.person_id time_t enter_time; // 进门时间戳 time_t leave_time; // 出门时间戳,0 表示未出 char area[16]; // 允许进入的区域编码 } AccessRecord;

time_t是 C 标准库<time.h>里定义的整数类型,存的是自 1970-01-01 00:00:00 UTC 起的秒数。这里用整数时间戳而不是字符串,是为了后续做“在场时长计算”“超时未出提醒”时不需要每次解析字符串。in_company_flag字段看似冗余,但它在内存里能避免每次判断来访者状态时都要把整个文件扫一遍,属于典型的“用空间换时间”设计。

2.2 C语言文件读写操作代码:fwrite/fread 还是 fprintf/fscanf

结构体定好后,下一步是落盘。C 语言文件读写操作代码有两条路线:fwrite/fread直接读写二进制块,fprintf/fscanf按格式化文本读写。对于这套系统,我更推荐二进制方式,原因在于time_t在文本模式下会被转成可读字符串,读回时需要strptime解析,而二进制方式一次fread就能还原完整结构体。性能上批量写入时差距不大,但二进制方式不会出现字符串里含空格导致解析错位的问题。

int save_person(const char* filename, PersonInfo* p) { FILE* fp = fopen(filename, "ab"); if (fp == NULL) return -1; // 写入完整结构体,包含填充字节,因此读回必须用同编译器的同结构体 size_t written = fwrite(p, sizeof(PersonInfo), 1, fp); fclose(fp); return (written == 1) ? 0 : -1; } int load_all_persons(const char* filename, PersonInfo** out_arr, int* out_count) { FILE* fp = fopen(filename, "rb"); if (fp == NULL) return -1; fseek(fp, 0, SEEK_END); long filesize = ftell(fp); rewind(fp); if (filesize % sizeof(PersonInfo) != 0) { fclose(fp); return -2; // 文件损坏,长度不是结构体整数倍 } int count = filesize / sizeof(PersonInfo); PersonInfo* arr = (PersonInfo*)malloc(count * sizeof(PersonInfo)); if (arr == NULL) { fclose(fp); return -3; } size_t rd = fread(arr, sizeof(PersonInfo), count, fp); fclose(fp); if (rd != count) { free(arr); return -4; } *out_arr = arr; *out_count = count; return 0; }

参数说明:save_person以追加模式打开文件,适合登记新人员时直接尾插;load_all_persons先通过fseekftell算出文件字节数,再除以sizeof(PersonInfo)得到记录条数,接着一次性分配内存并读入。需要特别注意的是fwrite写的是结构体的内存镜像,包含编译器为字节对齐补的 padding,所以这个文件不能跨编译器混用,在真实项目中要在文档里注明“数据文件与编译环境绑定”。返回码-2-3是我自定义的错误码,调用方可以通过它区分是文件损坏还是内存不足。

2.3 链表+文件:让记录在内存和磁盘之间保持一致

文件读写解决了持久化问题,但运行时如果用数组来管理,删除中间一条记录需要大量memmove,而且数组扩容要realloc,遇到内存碎片时容易失败。常见做法是加载文件后构建单向链表,增删改查都在链表上做,需要落盘时再整体写回临时文件然后重命名替换。

typedef struct PersonNode { PersonInfo data; struct PersonNode* next; } PersonNode; PersonNode* create_person_list(PersonInfo* arr, int count) { PersonNode* head = NULL; PersonNode* tail = NULL; for (int i = 0; i < count; i++) { PersonNode* node = (PersonNode*)malloc(sizeof(PersonNode)); if (node == NULL) { // 内存不足,释放已建节点 PersonNode* cur = head; while (cur) { PersonNode* tmp = cur; cur = cur->next; free(tmp); } return NULL; } node->data = arr[i]; node->next = NULL; if (tail == NULL) { head = node; tail = node; } else { tail->next = node; tail = node; } } return head; } void sync_list_to_file(const char* filename, PersonNode* head) { // 先写到同目录下的临时文件,成功后再 rename,避免中途断电损坏主文件 char tmpname[256]; snprintf(tmpname, sizeof(tmpname), "%s.tmp", filename); FILE* fp = fopen(tmpname, "wb"); if (fp == NULL) return; for (PersonNode* cur = head; cur != NULL; cur = cur->next) { fwrite(&cur->data, sizeof(PersonInfo), 1, fp); } fclose(fp); rename(tmpname, filename); }

sync_list_to_file里用临时文件加rename的做法是文件操作里的经典技巧。直接写原文件一旦写入中途程序崩溃,文件会变成半截,所有人员档案全部报废;先写.tmp再重命名,能保证磁盘上要么是旧文件要么是新文件,不存在中间状态。rename在 Linux 和 Windows 上行为略有差异,Windows 下如果目标文件已存在且被其他进程打开,rename会失败,所以实际项目里要先remove(filename)rename,或者检测返回值并重试。

3. 核心功能实现:登记、签入、签出、查询与数据删除

3.1 登记外来人员的代码骨架与参数说明

登记操作要处理一个关键问题:同一个证件号第二次来访时,不应该新增一条人员档案,而应该更新来访次数和来访事由。常见的入门代码只做fwrite追加,结果同一人产生多条重复记录,查询和统计都会乱。正确的登记流程是先按证件号在链表中查找,存在则更新visit_countreason,不存在才新建节点。

int register_visitor(PersonNode** head, PersonInfo* new_person) { // 先查证件号,避免重复建档 PersonNode* cur = *head; while (cur) { if (strcmp(cur->data.id_card, new_person->id_card) == 0) { // 老访客:累计次数加1,更新事由、部门、电话 cur->data.visit_count++; snprintf(cur->data.reason, MAX_REASON_LEN, "%s", new_person->reason); snprintf(cur->data.dept, MAX_DEPT_LEN, "%s", new_person->dept); snprintf(cur->data.phone, MAX_PHONE_LEN, "%s", new_person->phone); return 1; // 1 表示老访客更新 } cur = cur->next; } // 新访客:分配ID,插到链表头部 PersonNode* node = (PersonNode*)malloc(sizeof(PersonNode)); if (node == NULL) return -1; memset(&node->data, 0, sizeof(PersonInfo)); int max_id = 0; for (cur = *head; cur; cur = cur->next) { if (cur->data.person_id > max_id) max_id = cur->data.person_id; } node->data.person_id = max_id + 1; node->data.visit_count = 1; node->data.in_company_flag = 0; snprintf(node->data.name, MAX_NAME_LEN, "%s", new_person->name); snprintf(node->data.id_card, MAX_ID_LEN, "%s", new_person->id_card); snprintf(node->data.phone, MAX_PHONE_LEN, "%s", new_person->phone); snprintf(node->data.dept, MAX_DEPT_LEN, "%s", new_person->dept); snprintf(node->data.reason, MAX_REASON_LEN, "%s", new_person->reason); node->next = *head; *head = node; return 0; // 0 表示新建 }

strcmp比较的是证件号,这是判断“是否同一人”的硬标准。使用snprintf而不是strcpy是为了防止输入超长导致缓冲区溢出,snprintf会自动截断并在末尾补\0person_id的计算方式是扫描链表找最大值再加一,这个逻辑在单机程序里没问题,但如果将来多个门卫终端并发登记,就要改用分布式 ID 或者把 ID 生成交给服务端。老访客更新时不改person_id,因为所有进出流水记录都关联这个 ID,改了就会断链。

3.2 签入签出怎么更新状态:先 find 再 update

签入签出的本质是两件事:找到对应的人员记录,然后更新状态和时间戳。查找键建议用证件号,因为门卫很难记住内部整型 ID。签入时除了设置in_company_flag = 1,还要判断这个人是不是已经在厂内,防止重复签入;签出同理,不在厂内的人不能签出。

int check_in(PersonNode* head, const char* id_card, const char* area, AccessRecord* records, int* rec_count) { for (PersonNode* cur = head; cur; cur = cur->next) { if (strcmp(cur->data.id_card, id_card) != 0) continue; if (cur->data.in_company_flag == 1) { return -3; // 已在厂内,重复签入 } cur->data.in_company_flag = 1; // 生成一条进出记录 records[*rec_count].record_id = *rec_count + 1; records[*rec_count].person_id = cur->data.person_id; records[*rec_count].enter_time = time(NULL); records[*rec_count].leave_time = 0; snprintf(records[*rec_count].area, sizeof(records[*rec_count].area), "%s", area); (*rec_count)++; return 1; // 签入成功 } return -1; // 未找到该访客 } int check_out(PersonNode* head, const char* id_card, AccessRecord* records, int rec_count) { for (PersonNode* cur = head; cur; cur = cur->next) { if (strcmp(cur->data.id_card, id_card) != 0) continue; if (cur->data.in_company_flag == 0) { return -4; // 尚未签入,不能签出 } cur->data.in_company_flag = 0; // 找最近的未签出记录,回填 leave_time for (int i = rec_count - 1; i >= 0; i--) { if (records[i].person_id == cur->data.person_id && records[i].leave_time == 0) { records[i].leave_time = time(NULL); break; } } return 1; } return -1; }

签出时倒序遍历记录数组,是因为同一个访客可能连续多次来访,最后一条未签出的记录一定是离现在最近的。time(NULL)取的是系统当前时间的秒数,注意在嵌入式环境里要确认 RTC 已经校时,否则记录的时间会是 1970 年或者上一次断电时的旧值。签入签出函数返回码设计成1表示成功,负数表示不同原因的失败,调用方在终端的switch语句里把返回码映射成中文提示即可。

3.3 查询与删除功能的实现细节

查询操作按姓名模糊匹配、按证件号精确匹配、按部门匹配是三种最常见的检索方式。模糊匹配用strstr,精确匹配用strcmp。这里给出一个按部门列出所有在厂人员的函数,它的输出直接对应门卫最关心的“此刻有哪些访客还没走”:

void list_in_company_by_dept(PersonNode* head, const char* dept) { printf("当前在厂外来人员(部门:%s):\n", dept); printf("%-6s %-12s %-10s %-16s\n", "ID", "姓名", "证件号", "来访事由"); int found = 0; for (PersonNode* cur = head; cur; cur = cur->next) { if (cur->data.in_company_flag != 1) continue; if (dept != NULL && strlen(dept) > 0 && strcmp(cur->data.dept, dept) != 0) continue; printf("%-6d %-12s %-10s %-16s\n", cur->data.person_id, cur->data.name, cur->data.id_card, cur->data.reason); found = 1; } if (!found) { printf("没有符合条件的在厂访客。\n"); } }

删除功能不建议物理删除文件里的记录,而是打删除标记。物理删除需要把后面所有记录前移并重写文件,如果程序在写一半时崩溃,整个文件就坏了。更稳妥方案是在PersonInfo里增加一个is_deleted字段,查询时跳过标记,管理员确认清理时再重建文件。删除接口的设计要点是:先定位,再标记,最后同步盘;不要在main函数里裸写fwrite

4. 进出联动与合法性校验:状态机、时间戳与防重入

4.1 状态机定义:在厂、离厂、禁止进入

进出管理系统的核心逻辑不是增删改查,而是状态流转。一个访客有且仅有两种合法状态:在厂和离厂。从“离厂”可以迁移到“在厂”对应签入动作;从“在厂”迁移到“离厂”对应签出动作;“在厂”状态下重复签入属于非法事件,“离厂”状态下签出也属于非法事件。把这个流转关系显式建模,代码会清晰很多。

enum VisitorState { STATE_OUTSIDE = 0, // 离厂 STATE_INSIDE = 1 // 在厂 }; // 状态迁移表:合法迁移返回1,非法返回0 static int is_valid_transition(int current_state, int action) { // action: 0=签入, 1=签出 if (current_state == STATE_OUTSIDE && action == 0) return 1; if (current_state == STATE_INSIDE && action == 1) return 1; return 0; } int process_action(PersonNode* p, int action, AccessRecord* records, int* rec_count) { int cur_flag = p->data.in_company_flag; if (!is_valid_transition(cur_flag, action)) { return -1; // 非法状态迁移,调用方输出"操作不允许" } if (action == 0) { p->data.in_company_flag = STATE_INSIDE; // 生成新记录,设置 enter_time,leave_time 置 0 } else { p->data.in_company_flag = STATE_OUTSIDE; // 回填最近一条记录的 leave_time } return 0; }

把状态迁移单独写成函数,而不是在业务代码里到处if (flag == 1 && action == 0),后续要加“黑名单禁止进入”“临时通行证已过期”等状态时,只需要在is_valid_transition里增加规则,不必改动签入签出的主体逻辑。这是状态机设计带来的最大收益:规则集中、可测试、可扩展。

4.2 时间戳校验与访问时段限制

外来人员管理不只是记时间,还要判断时间是否合法。比如某访客登记的访问时段是 09:00–12:00,他在 08:50 到达,门卫可以选择让进或者不让进,由系统参数控制。实现上需要在签入时做一次时间段校验,签出时做一次超时检查。

typedef struct { int allow_before_start; // 是否允许在开始时间之前签入(0/1) int max_stay_minutes; // 最大停留分钟数,0 表示不限制 } AccessPolicy; int is_within_allow_window(time_t now, time_t access_start, time_t access_end, AccessPolicy policy, int* error_code) { if (now < access_start && !policy.allow_before_start) { *error_code = 1001; // 早到不允许进入 return 0; } if (now > access_end) { *error_code = 1002; // 访问时段已过期 return 0; } return 1; } int check_stay_timeout(time_t enter_time, time_t leave_time, int max_minutes) { if (max_minutes <= 0) return 0; // 不限时 double minutes = difftime(leave_time, enter_time) / 60.0; if (minutes > max_minutes) return 1; // 超时 return 0; }

difftime返回的是double类型的秒数差,除以 60 转成分钟。时间段校验必须在签入前做,避免给访客签了进厂又马上驱离的尴尬操作。访问时段的来源可以是登记时门卫手动输入,也可以是外部系统下发的访客预约数据,这里只关心它是一对time_t值即可。当家,白天开机校准一次,记录的时间差不超过 1 秒。

4.3 防重入与序列号冲突的处理

防重入指同一证件号在未签出的情况下再次签入必须被拦截。这个逻辑在上文check_in里通过in_company_flag已实现,但有一种边界情况:程序在签入写文件之后、更新内存状态之前断电,重启后内存里in_company_flag是旧值,文件里却多了一条未签出记录。解决办法是启动时扫描所有进出记录,把leave_time为 0 的记录对应的person_id在内存里标记为在厂。

void rebuild_state_from_records(PersonNode* head, AccessRecord* records, int rec_count) { // 先把所有人强制置为离厂 for (PersonNode* cur = head; cur; cur = cur->next) { cur->data.in_company_flag = 0; } // 根据记录回置状态 for (int i = 0; i < rec_count; i++) { if (records[i].leave_time != 0) continue; for (PersonNode* cur = head; cur; cur = cur->next) { if (cur->data.person_id == records[i].person_id) { cur->data.in_company_flag = 1; break; } } } }

这个函数的本质是“以流水记录为准,重建当前状态”,它比每次签入时都更新文件里的人员状态字段要可靠得多。因为流水是只追加的,不会覆盖,天然具备审计能力;而人员状态字段是可变数据,断电时可能不一致。序列号冲突则出现在record_id的自增逻辑上,如果程序崩溃后手动改了数据文件,record_id可能倒退回旧值。用于保存数据的文件要定期自动备份,备份文件名带日期后缀。真实项目里我是把record_id改用这次程序启动时读文件最大record_id + 1的全局变量,并在每次新增后更新,而不依赖数组下标。

5. 运行期排错与验证技巧:C语言内存管理和状态迁移测试

5.1 文件读写失败的定位方法

这一章专门讲调试和验证,因为这套系统的隐蔽 bug 大多数不在业务逻辑,而在内存和文件操作的边界处。文件读写失败时,第一步不是改代码,而是确认失败原因。常见的做法是封装一层debug_log,把fopenfreadfwriterename的返回值和errno同时打印出来。

#include <errno.h> #include <string.h> void debug_file_error(const char* operation, const char* filename) { // errno 是上次系统调用失败的错误码,strerror 转成可读信息 fprintf(stderr, "[%s] %s 失败: %s (errno=%d)\n", operation, filename, strerror(errno), errno); }

调用方式:在每次文件操作后检查返回值,失败就调用debug_file_error并把errno清零。常见问题集中在:路径不存在、权限不足、磁盘写满、Windows 下目标文件被占用。定位“写入的条数对不上”时,先检查fread的返回值是否等于请求的条数,再检查文件大小是否是结构体大小的整数倍。如果文件大小不满足整数倍,直接用fseek到最后一个完整记录处截断,宁可丢尾巴也不让程序崩溃。

5.2 非法地址与段错误排查

C 语言里最常见的运行期崩溃是“非法地址”,也就是操作了野指针或越界数组。监控系统长期无人值守运行,这类崩溃一旦发生,门卫终端直接死掉,所以必须在开发阶段找出所有隐患。推荐工具是 GCC 的地址消毒器 AddressSanitizer,它不用改业务代码,只要在编译时加参数:

gcc -g -fsanitize=address -fno-omit-frame-pointer visitor_system.c -o visitor_system_asan ./visitor_system_asan

逻辑说明:-fsanitize=address在编译时插入内存检查代码,程序访问到堆越界、栈越界、释放后使用等非法地址时,会立即打印出错的源码行号和调用栈并终止运行;-fno-omit-frame-pointer是让回溯栈保留帧指针,这样报错信息里能看到完整调用链。别忘了参数是加在编译命令里而不是代码里,所以用 VS Code 配置 C 语言环境时,tasks.json里要单独加一个带-fsanitize=address的构建任务。

排查段错误时另一个高效手段是gdblldb。程序崩溃后直接用gdb ./visitor_system core加载核心转储文件,输入bt命令看回溯栈,通常一眼就能定位到是哪个函数里的哪个指针出了问题。如果开发环境没有图形界面,gdb配合print查看指针值比在代码里乱加printf高效得多。

5.3 用测试数据验证进出状态迁移

逻辑正确性验证不依赖 UI,写一个纯数据驱动的测试脚本比较实用。核心思路是把“初始状态 + 动作序列 + 期望结果”组织成一张表,程序按表执行并比对结果。这里只保留最少的验证要点。

用例编号初始状态动作期望结果
T01离厂签入返回成功,状态变为在厂,生成一条记录
T02在厂签入返回错误,状态不变,不生成记录
T03在厂签出返回成功,状态变为离厂,回填 leave_time
T04离厂签出返回错误,状态不变
T05离厂签入(超时)返回错误,不生成记录
T06在厂签出(在厂时长超限)返回成功,但报警提示超时

手动造数据时,time_t可以手动传入而不是依赖time(NULL)。测试代码里写死当前时间的话测试就没有复现性:把签入签出时间通过函数参数传入,主流程里的调用才用time(NULL),这样测试用例可以任意构造过去、现在、未来的时间戳,验证超时判断逻辑。这类状态迁移测试值得在写完process_action后立刻做一遍,把所有非法路径全跑完,再进入功能联调。推荐把测试驱动开发作为一种日常习惯,哪怕系统不大,边界用例表也会成为以后重构的安全网。

C 语言环境里没有现成的测试框架也可以这样做一个独立测试文件:test_transition.cinclude 业务头文件,main函数里写十几个断言,用assert宏判断结果。编译两个目标文件,一个给业务程序,一个给测试程序。这套做法比在业务代码里加#ifdef TEST分支干净得多,测试代码也不会被打进最终部署的二进制里。

最后提一个线上应急技巧:程序在被多人同时操作时,文件写入前要加锁或用原子替换。如果这个系统要跑在服务器上被多台门卫机访问,则要引入进程间锁或者改用 SQLite;但如果你只是做单机版本,临时文件加rename这套方案已经足够。部署给客户前,至少用valgrind --leak-check=full跑一遍全流程登记、签入、签出,确认没有内存泄漏,再放行。无人值守的程序最怕的就是内存泄漏积累到半夜把系统拖垮。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询