简介这是一套基于C语言实现的图书管理系统完整项目源码与数据库脚本一应俱全适合需要完成数据库课程设计、期末大作业或毕业设计的高校学生。项目围绕图书管理核心场景展开包含图书信息录入、查询、修改、删除以及借阅归还等常用功能代码层次分明并附有注释新手也能轻松读懂。压缩包共10个文件以C语言源文件.c/.h为主辅以数据库脚本.sql、说明文档.md和Makefile构建文件整体仅4KB结构精简方便快速定位到关键模块。目前已有380人学习浏览该项目为个人手打的高分作品导师认可度高是同类课设中较受欢迎的完整参考模板。下载后即可快速部署使用也能在此基础上二次开发是冲刺高分的实用选择。1. 用 C 语言做图书管理系统课设选型先于写码一个数据库课设题目点名要求 C 语言实现图书管理系统“源码数据库”一起交付。很多人的第一反应是用链表存书、文件存记录最后交一个控制台程序再加个 txt 数据文件。但如果你真把这个方案交上去数据库课的分数大概率不会好看——因为课设考的核心不是你会不会写链表而是你会不会建模、会不会用 SQL 做增删改查、能不能说清数据怎么落到磁盘上的。这篇笔记围绕的是一个更贴合题目本意的做法C 语言做交互和控制层MySQL 做数据层程序跑起来后所有图书、读者、借阅记录都真实存在于数据库表里。新手能照步骤跑通熟手能直接拿表结构和接口设计去改自己的业务。2. 数据库设计与 C 语言接口先想清楚表和查询再碰编译器2.1 图书管理系统最少需要几张表图书管理系统看起来功能多但把需求压到课设级别核心就三件事书要能录入、能修改、能删除读者能借书、能还书管理员能看见谁借了什么、什么时候到期。对应到表设计三类实体就够了。我一般会建三张表book存图书基本信息reader存读者信息borrow存借阅流水。borrow表是典型的多对多关系表把 reader_id 和 book_id 关联起来。注意borrow表里一定要有borrow_time、due_time、return_time三个时间字段后面判断逾期和统计借阅量全靠它们。以下是一个可以直接执行的建表脚本CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4; USE library_db; CREATE TABLE book ( id INT AUTO_INCREMENT PRIMARY KEY, isbn VARCHAR(20) UNIQUE, title VARCHAR(100) NOT NULL, author VARCHAR(50), publisher VARCHAR(50), price DECIMAL(8, 2), stock INT NOT NULL DEFAULT 1, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE reader ( id INT AUTO_INCREMENT PRIMARY KEY, reader_no VARCHAR(20) UNIQUE NOT NULL, name VARCHAR(50) NOT NULL, phone VARCHAR(20) ); CREATE TABLE borrow ( id INT AUTO_INCREMENT PRIMARY KEY, reader_id INT NOT NULL, book_id INT NOT NULL, borrow_time DATETIME NOT NULL, due_time DATETIME NOT NULL, return_time DATETIME DEFAULT NULL, UNIQUE KEY uk_borrow_active (reader_id, book_id, return_time), FOREIGN KEY (reader_id) REFERENCES reader(id), FOREIGN KEY (book_id) REFERENCES book(id) );建表脚本里的uk_borrow_active这个唯一键很多人不理解为啥要加。它的作用是防止同一个读者对同一本书同时存在两条未还记录——因为return_time为 NULL 时UNIQUE约束在 MySQL 里允许同一个读者 ID、同一个书 ID、NULL 多次出现但一旦加上这个复合唯一键同一个人借同一本书而return_time为 NULL 的记录就只会有一条。这个细节在答辩时被问到“怎么保证同一本书不被同一个人重复借”时可以答得很漂亮。2.2 MySQL C API 的使用流程头文件、初始化和连接C 语言连接 MySQL 不是自己写网络协议用的是官方提供的libmysqlclient库。整个使用流程非常固定可以当模板记初始化连接句柄、连接服务器、执行 SQL、处理结果集、释放资源、关闭连接。这个流程在 C 语言的数据库编程里地位等同于“hello world”在语法学习里的地位。#include stdio.h #include mysql/mysql.h int main() { MYSQL *conn mysql_init(NULL); if (conn NULL) { fprintf(stderr, mysql_init failed\n); return -1; } conn mysql_real_connect(conn, 127.0.0.1, /* host */ root, /* user */ 123456, /* password */ library_db, /* database */ 3306, /* port */ NULL, /* unix_socket */ 0); /* client_flag */ if (conn NULL) { fprintf(stderr, mysql_real_connect error: %s\n, mysql_error(conn)); mysql_close(conn); return -1; } printf(database connected\n); mysql_close(conn); return 0; }这段代码里最容易出问题的是mysql_real_connect的参数顺序尤其是第 6 个参数unix_socket。在 Linux 本机连接时传 NULL 表示用默认 socket 文件路径但 Windows 上编译时传 NULL 没问题因为 Windows 走的是 TCP 协议而不是 Unix socket。端口号 3306 是 MySQL 默认端口如果你本机改成过 3307 或者用 Docker 映射过端口这里必须跟着改否则会报Cant connect但你看代码却觉得没写错——血泪经验先确认端口再怀疑代码。编译命令在 Linux 下一般是这样gcc main.c -o library $(mysql_config --cflags --libs)用mysql_config自动带入头文件路径和库文件路径比自己手动写-I/usr/include/mysql -lmysqlclient靠谱。Windows 下用 Dev-C 或 VS 则需要手动加附加依赖项libmysql.lib并且要把libmysql.dll放到 exe 同目录或系统 PATH 下否则编译过了运行时报缺少 DLL属于经典的“编译不报错、运行就翻车”场景。2.3 查询结果怎么读MYSQL_RES 与 MYSQL_ROW 的配合执行查询类 SQL 与执行增删改类 SQL 在处理方式上是分开的。增删改只需要执行完 SQL 后检查返回值查询则需要把结果集一行一行取出来。很多没写过 C 连 MySQL 的人第一次死在mysql_store_result之后不知道数据怎么拿拿到之后又忘了释放内存。看这一段典型代码MYSQL_RES *res NULL; MYSQL_ROW row; int ret mysql_query(conn, SELECT id, title, author, stock FROM book); if (ret ! 0) { fprintf(stderr, query error: %s\n, mysql_error(conn)); return; } res mysql_store_result(conn); if (res NULL) { fprintf(stderr, store result error: %s\n, mysql_error(conn)); return; } while ((row mysql_fetch_row(res)) ! NULL) { int id atoi(row[0]); printf(id%d, title%s, author%s, stock%s\n, id, row[1], row[2], row[3]); } mysql_free_result(res);这里的row的类型是MYSQL_ROW本质上是一个char **每一列都是字符串形式返回的即使数据库字段是INT或者DECIMAL取出来也先当字符串处理需要用时再转。比如stock字段是INT代码里直接用row[3]打印没问题但如果你想做if (row[3] 0)这种判断就要小心了——字符串比较不会按数字大小走必须用atoi(row[3])转换后比较。另一个必须养成习惯的点每次mysql_store_result成功之后都要在分支结束处写mysql_free_result(res)。这个资源不释放不会在功能上报错但程序长时间运行或者 GC 机制不存在的 C 程序里这就是内存泄漏。课设程序虽然跑一会就退不会暴露问题但如果你后面接手真正的服务器程序还这么干运维看过几天的内存曲线就会来找你。3. 在 C 代码里把增删改查写成函数从字符串拼接到参数化执行3.1 SQL 字符串怎么拼格式化还是拼接C 语言没有内嵌 SQL 的语法所有 SQL 都要构建成字符串再交给mysql_query执行。最直接的思路是sprintf格式化出一个完整的 SQL 语句然后执行。比如添加一本图书char sql[1024]; int book_id 0; sprintf(sql, INSERT INTO book (isbn, title, author, publisher, price, stock) VALUES (%s, %s, %s, %s, %.2f, %d), isbn, title, author, publisher, price, stock); if (mysql_query(conn, sql) ! 0) { fprintf(stderr, insert error: %s\n, mysql_error(conn)); } else { book_id (int)mysql_insert_id(conn); printf(new book id%d\n, book_id); }这里有三个值得注意的细节。第一字符串字段在 SQL 里必须用单引号包起来所以sprintf的格式串里%s是带引号的这个引号不能少。第二price是DECIMAL(8,2)sprintf用%.2f保值精度如果你直接把 double 转成%f或%g可能插入 19.90 变成 19.9表结构允许但是显示不好看答辩演示时会显得不专业。第三插入成功后立刻用mysql_insert_id(conn)拿到自增主键这个在后面借书、还书事务里很有用。但sprintf拼接的弊端很明显。用户输入的书名如果包含单引号比如《Toms Story》拼出来的 SQL 就变成VALUES (Toms Story,...)语法错误程序直接返回失败。更危险的是如果输入的不是书名而是一段恶意构造的字符串就可能让 SQL 干更多事。课设答辩时老师不一定会攻击你但这个坑你自己要知道最稳妥的做法是用mysql_real_escape_string把用户输入先转义再拼进 SQL。char esc_title[225]; mysql_real_escape_string(conn, esc_title, title, strlen(title));3.2 把每个功能拆成独立函数以删除图书为例课设代码最容易变成一坨 2000 行的main函数。我建议从一开始就把每个操作拆成函数参数统一为MYSQL *conn和必要的业务参数。这样做的好处不是代码好看而是出了问题你可以在一个函数内部单独测试不需要把整个程序跑起来。下面是一个完整的删除图书函数包含库存检查和删除返回值处理int delete_book(MYSQL *conn, int book_id) { char sql[256]; int ret; MYSQL_RES *res NULL; MYSQL_ROW row; int stock 0; /* 先查这本书的可借库存有未还借阅记录时不能直接删 */ sprintf(sql, SELECT stock FROM book WHERE id%d, book_id); if (mysql_query(conn, sql) ! 0) return -1; res mysql_store_result(conn); if (res NULL) return -1; row mysql_fetch_row(res); if (row NULL) { mysql_free_result(res); printf(book id%d not found\n, book_id); return -1; } stock atoi(row[0]); mysql_free_result(res); if (stock 0) { printf(cannot delete: book still has borrowed copies\n); return -1; } sprintf(sql, DELETE FROM book WHERE id%d, book_id); ret mysql_query(conn, sql); if (ret ! 0) { printf(delete failed: %s\n, mysql_error(conn)); return -1; } printf(book id%d deleted, affected rows%llu\n, book_id, (unsigned long long)mysql_affected_rows(conn)); return 0; }删除前先查stock库存不为 0 就不删——这就是把业务规则落在代码里的最小范例。注意mysql_affected_rows的返回值类型是unsigned long long直接printf(%d)会在-Wall编译选项下报警告用%llu加类型转换才规范。3.3 借书还书状态检查、事务与时间计算借书不是简单 INSERT它必须同时满足两个条件读者存在且该书当前可借库存大于 0。还书也不是简单 UPDATE它要判断是否逾期并把书的库存加回去。这种“先查后写”的逻辑适合放进事务里保证两个操作要么都成功要么都失败。int borrow_book(MYSQL *conn, int reader_id, int book_id, int days) { char sql[512]; int ret, stock 0; MYSQL_RES *res NULL; MYSQL_ROW row; time_t now time(NULL); struct tm *t localtime(now); char borrow_time[32], due_time[32]; /* 检查库存 */ sprintf(sql, SELECT stock FROM book WHERE id%d, book_id); if (mysql_query(conn, sql) ! 0) return -1; res mysql_store_result(conn); row mysql_fetch_row(res); if (row NULL) { mysql_free_result(res); printf(book not found\n); return -1; } stock atoi(row[0]); mysql_free_result(res); if (stock 0) { printf(no stock available\n); return -1; } /* 时间字符串构造借书时间和应还时间 */ strftime(borrow_time, sizeof(borrow_time), %Y-%m-%d %H:%M:%S, t); t-tm_mday days; mktime(t); /* 让 tm 结构自动进位到正确日期 */ strftime(due_time, sizeof(due_time), %Y-%m-%d %H:%M:%S, t); /* 开启事务执行借书 */ mysql_query(conn, START TRANSACTION); sprintf(sql, INSERT INTO borrow (reader_id, book_id, borrow_time, due_time) VALUES (%d, %d, %s, %s), reader_id, book_id, borrow_time, due_time); ret mysql_query(conn, sql); if (ret ! 0) { mysql_query(conn, ROLLBACK); printf(borrow insert failed: %s\n, mysql_error(conn)); return -1; } sprintf(sql, UPDATE book SET stockstock-1 WHERE id%d, book_id); ret mysql_query(conn, sql); if (ret ! 0) { mysql_query(conn, ROLLBACK); printf(stock update failed: %s\n, mysql_error(conn)); return -1; } mysql_query(conn, COMMIT); printf(borrow success, due%s\n, due_time); return 0; }时间处理有一个比较隐蔽的坑tm_mday直接加天数后必须调用mktime让它把“5月32日”这种非法日期自动归一化成“6月1日”不然strftime打印出来的日期是错的。这个做法不是每个人都用因为有些教程教的是用时间戳加 86400 乘天数。两者在绝大多数场景下结果一样但涉及夏令时地区会有 1 小时偏差因为一天并不总是 86400 秒。课设系统里你选哪一种都不会被扣分但能讲清mktime归一化的原理会让答辩老师觉得你懂边界情况。事务的使用是这个函数的关键。INSERT 和 UPDATE 两条 SQL 之间如果第二条失败而第一条已经写入就会产生“借阅记录存在但库存没减”的脏数据。用START TRANSACTION包起来任何一步失败就ROLLBACK表里干干净净。MySQL 的 InnoDB 引擎支持事务如果你的表莫名其妙的用的是 MyISAMSTART TRANSACTION不会报错但也不会生效——建表脚本里你没写 ENGINE 时MySQL 8.0 默认 InnoDB这个坑在旧版本上会更常见。3.4 还书逻辑计算逾期天数还书的本质是把borrow表里对应记录的return_time填上当前时间同时把图书库存加回。逾期判断是在填入return_time之前用当前时间跟due_time比较。int return_book(MYSQL *conn, int reader_id, int book_id) { char sql[512]; int ret; MYSQL_RES *res NULL; MYSQL_ROW row; char return_time[32]; time_t now time(NULL); struct tm *t localtime(now); strftime(return_time, sizeof(return_time), %Y-%m-%d %H:%M:%S, t); /* 查该读者的未还记录 */ sprintf(sql, SELECT id, due_time FROM borrow WHERE reader_id%d AND book_id%d AND return_time IS NULL, reader_id, book_id); if (mysql_query(conn, sql) ! 0) return -1; res mysql_store_result(conn); row mysql_fetch_row(res); if (row NULL) { mysql_free_result(res); printf(no active borrow record found\n); return -1; } /* row[1] 是 due_time 字符串比较时也拼成字符串比 */ if (strcmp(return_time, row[1]) 0) { printf(overdue! due_time%s\n, row[1]); } else { printf(returned on time\n); } mysql_free_result(res); /* 更新还书时间 恢复库存 */ mysql_query(conn, START TRANSACTION); sprintf(sql, UPDATE borrow SET return_time%s WHERE reader_id%d AND book_id%d AND return_time IS NULL, return_time, reader_id, book_id); ret mysql_query(conn, sql); if (ret ! 0) { mysql_query(conn, ROLLBACK); return -1; } sprintf(sql, UPDATE book SET stockstock1 WHERE id%d, book_id); ret mysql_query(conn, sql); if (ret ! 0) { mysql_query(conn, ROLLBACK); return -1; } mysql_query(conn, COMMIT); printf(return success at %s\n, return_time); return 0; }这里的时间比较用了字符串比较而不是把日期拆开算天数。因为 MySQL 的DATETIME格式是YYYY-MM-DD HH:MM:SS这种格式下字符串比较和真实时间先后顺序完全一致所以strcmp(return_time, row[1]) 0就能判断当前时间是否晚于应还时间。这样做的好处是省去了mktime和difftime的换算避免类型转换带来的失误。但前提是return_time的格式必须和数据库里的格式完全一致一个写2025-06-01 10:00:00另一个写2025-06-01 10:00就会比错——这也是代码里统一用strftime给固定格式的原因。4. 图书管理系统课设避坑5 条踩过的真实记录4.1 中文乱码插入正常SELECT 出来全是问号现象程序里录入“数据结构”这本书数据库里查出来是???但 Navicat 里手动插入的中文正常。原因MySQL 连接建立时的字符集没设置。建库时虽然写了DEFAULT CHARACTER SET utf8mb4但 C 程序连接时默认可能走的是latin1字符集。客户端插入数据时按 latin1 编码送过去数据库按 utf8mb4 存自然乱码。解决在mysql_real_connect成功之后立刻执行一次mysql_set_character_set(conn, utf8mb4)或者直接执行SET NAMES utf8mb4这条 SQL。我一般用mysql_set_character_set因为它不经过 SQL 解析少一层编码转换。注意 MySQL 5.5 之前不支持 utf8mb4老环境要改成 utf8但 5.7 和 8.0 都没这个限制课设环境不用纠结。4.2 fgets 和 scanf 混用导致菜单错乱现象程序先问“请输入操作序号”用户输入 1 回车下一个输入书名时 get 到的是空串代码逻辑直接跳走或报错。原因scanf(%d, choice)在读取数字后把回车符留在了输入缓冲区紧接着调fgets(title, sizeof(title), stdin)时直接读到了那个回车符函数返回什么都没读到。解决同一个程序里不要混用scanf和fgets。要么全部用fgets读一行字符串再用sscanf或atoi解析要么在每次scanf后用getchar()把残留回车吃掉。但getchar()并不保险因为如果用户输入的是“1abc”加回车scanf读完数字后残留abc\n一个getchar只消费一个字符。最省心的是全部fgets 字符串解析代码多几行但行为稳定。char line[64]; int choice; printf(please choose: ); fgets(line, sizeof(line), stdin); choice atoi(line);4.3 内存泄漏多查询几次程序开始卡顿或崩溃现象程序开起来没问题执行几次图书查询后内存占用不停涨窗口程序直接无响应。原因mysql_store_result取得的MYSQL_RES没有调用mysql_free_result释放。我见过一个极端例子同学在 while 循环里查询了 5000 次没有释放任何结果集程序内存占用从 5MB 一路涨到 200MB。解决每个mysql_store_result都要配对对应的mysql_free_result。这个习惯一定要在课设阶段养好因为 C 语言没有自动回收垃圾的机制等上生产环境再改就难了。代码里如果分支多可以在函数出口统一释放或者写完 SQL 处理逻辑后立刻释放不要攒着一堆临时结果等函数结束。4.4 mysql_query 返回值是 0 不代表影响行数符合预期现象执行UPDATE book SET stockstock-1 WHERE id999返回 0成功但程序也没有报错管理员以为扣库存成功了。原因mysql_query返回 0 代表 SQL 执行成功不表示有数据行被更新。如果WHERE条件没匹配到任何行它也返回 0 且mysql_affected_rows返回 0。解决对于 UPDATE 和 DELETE执行完务必检查mysql_affected_rows(conn)的返回值。WHERE id999匹配不到书时程序应该提示“该书不存在”而不是闷头继续走流程。4.5 编译通过但连接服务器时报 Authentication Plugin 错误现象代码用mysql_real_connect连本机 MySQL 8.0报Authentication plugin caching_sha2_password cannot be loaded。原因MySQL 8.0 默认认证插件是caching_sha2_password而你链接的libmysqlclient是 5.x 版本自带的旧客户端不认识这个新插件。这是典型的“服务端新、客户端旧”的兼容性问题不是代码写错。解决三个办法里选一个。要么把编译器链接的客户端库升级到与 MySQL 8.0 匹配的版本要么在 MySQL 里执行ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 123456;把用户认证方式改成旧协议要么连接时加CLIENT_SSL需要的证书。课设场景下我推荐第二个改一次认证方式最省事但要在答辩材料里说明一句“当前使用 mysql_native_password 兼容老客户端生产环境建议升级驱动”。5. 把源码和数据库打包成可验收的课设目录结构与功能闭环5.1 目录结构直接影响评审第一印象课设评审老师不会一行行看完整代码他大概率先看目录、再读 README、然后让你现场跑功能。所以交付物的组织方式比代码本身的风格更影响成绩。我建议的目录结构长这样library-manage/ ├── README.md ├── schema.sql # 建库建表脚本评委可以直接执行 ├── src/ │ ├── main.c # 菜单循环 业务分发 │ ├── db.c # 数据库连接与关闭 │ ├── db.h │ ├── book.c # 图书增删改查 │ ├── book.h │ ├── borrow.c # 借书还书与逾期判断 │ ├── borrow.h │ └── Makefile └── data/ └── init.sql # 演示用的初始数据包含书和读者样例这样做有三个实际好处。第一schema.sql独立出来后评审老师第一眼就知道你的“数据库”部分不是摆设库表设计是拿得出手的独立交付物。第二.h头文件把函数接口暴露在顶部老师扫一眼就知道模块划分清晰。第三data/init.sql让演示环境可以一键重建避免现场演示前手工敲数据。5.2 README 怎么写才不显得像流水账README 不用长控制在 60 行内。几个必须写清的点是运行环境的版本操作系统、MySQL 版本、编译工具链、编译方法把 Makefile 里的依赖写清楚、初始账号密码、以及最关键的功能演示步骤。我见过太多课设 README 写了一大堆系统背景、开发意义唯独没写清楚怎么跑起来。评审老师的真实需求是拿到源码后 10 分钟内能把程序跑起来并且把“增加图书→查询图书→读者借书→读者还书→查询借阅记录”这个闭环跑完。README 里直接给一段命令序列是性价比最高的mysql -u root -p schema.sql mysql -u root -p library_db data/init.sql cd src make ./library这段命令不需要额外解释评审流程里照着敲就行。5.3 演示时按“增删改查 借还闭环”的顺序走课设现场演示最怕的是评审问“你这系统能不能支持……”时你现场翻代码、现点菜单、出现意外。稳定的做法是脚本化演示准备一页纸上写清楚操作顺序和预期输出。我的固定顺序是先用 SELECT 展示初始数据 → 新增一本《C 程序设计语言》→ 再 SELECT 看到记录出现 → 修改价格 → 删除一本样例书 → 让一个读者借走刚才新增的书 → 查书库看出库扣减 → 还书 → 查借阅记录看到 return_time 被填充。整个流程覆盖了增删改查、事务、外键关联全部功能走完不超过 3 分钟。预期输出这四字是重点。借书成功后程序应该打印borrow success还书逾期时程序应该打印overdue! due_time...。提前把预期输出写清楚现场演示就不会出现“结果出来了但我不知道对不对”的尴尬。6. 进阶收尾模糊查询中的通配符加固与统计 SQL 下推最后分享一个我自己的习惯把计算尽量交给 SQL而不要在 C 代码里用循环做统计。比如“借阅排行 Top 5 图书”这种需求一段 SQL 就能解决SELECT b.title, COUNT(*) AS borrow_count FROM borrow br JOIN book b ON br.book_id b.id WHERE br.return_time IS NOT NULL GROUP BY b.id, b.title ORDER BY borrow_count DESC LIMIT 5;用 JOIN 关联两张表、用 GROUP BY 聚合、用 ORDER BY 排序全程不需要在 C 里写任何排序算法。这个方法的价值在于它倒逼你理解 SQL 的能力边界而不是把数据库当成一个存储壳子、逻辑全部写在 C 代码里——后者恰恰是课设里最容易被扣分的设计倾向。另一个值得做的小加固是模糊查询的转义。用户如果输入%或_作为书名关键字LIKE 会把它当成通配符导致查出来的结果比预期多。处理办法是在 C 代码层面对输入字符串里的%、_和反斜杠先转义拼 SQL 时再包一层LIKE CONCAT(%, 关键字, %)。这个不是必选项但做上了就能在答辩时多讲一句“考虑到 LIKE 通配符注入风险”属于低成本高印象分的优化点。我自己写课设代码有个经验每个函数入口先检查数据库连接是否存活用mysql_ping(conn)做一次轻量探测网络断了时程序能给出明确提示而不是直接段错误。这个习惯是在一次远程演示翻车后养成的——当时数据库服务被重启程序操作到一半打印了一堆连不上的错误从那以后我都在关键操作前加一句if (mysql_ping(conn) ! 0) reconnect()。系统也许不会因此多出什么炫酷功能但稳定性在评审眼里往往比花哨更重要。希望这篇笔记能让你在这个课设上少走几步弯路把精力花在真正被看重的数据建模和事务处理上。本文还有配套的精品资源点击获取