☰
手写C语言迷你DBMS:从CREATE TABLE到文件持久化
2026/10/27 19:50:51 网站建设 项目流程

简介:本资源是山东科技大学计算机科学与技术专业《数据库系统概论》课程设计的完整实验报告,面向高校数据库初学者与课程实践者,聚焦DBMS核心功能——表结构的创建与动态修改,解决从理论SQL语法到底层存储实现的关键衔接问题。报告由郑通同学于2012年完成,涵盖CREATE TABLE与ALTER TABLE语句的命令行+图形化双界面实现、表元数据的文件持久化(table.txt)、物理存储结构设计、程序流程图及详细模块说明,内容深度覆盖需求分析、设计思想、数组式结构定义、字段约束解析与存取逻辑等实操细节。资源为单个408KB Word文档(.doc),结构完整,含任务书、说明书、代码思路与教师评语,便于对照学习与课程复现。已有934人学习下载,适合数据库原理课设参考、C语言结合数据库开发入门及DBMS简易内核理解。

1. 一个用纯 C 写的“迷你 DBMS”:它不连 MySQL,也不起服务,但能让你亲手把 CREATE TABLE 拆成内存数组、文件读写和字符串解析三板斧

你有没有试过,在没装任何数据库软件、没配环境变量、甚至没联网的机房电脑上,敲下CREATE TABLE users (id INT PRIMARY KEY, name CHAR(20));,回车后——真·弹出一行“建表成功”,再敲ALTER TABLE users ADD email CHAR(50);,它真·给你加了一列?这不是 SQL 练习器,也不是 Web 前端模拟器,而是一个在 VC6++ 和 Code::Blocks 下编译通过、运行时只依赖fstream和string.h的 C 程序。它没有事务、没有索引、不存数据行,但它完整走通了「SQL 解析 → 内存建模 → 文件持久化 → 结构修改」这条 DBMS 最底层的链路。它适合谁?刚学完《数据库系统概论》第四版第 3 章“关系数据库标准语言 SQL”的本科生;正在啃《数据库系统实现》第 2 章“存储管理”的自学者;或者像我当年一样,在课程设计 deadline 前三天才打开 VC6++、对着王珊老师书里那句“CREATE 语句的语法树可分解为……”发呆的某高校计算机专业学生。它不教你怎么写分布式事务,但它会逼你亲手处理char fieldType[LEN][LEN][MAX]里第三维到底该开多大,会教你为什么f_t_len[number][num][1] = f_length+48;这行代码背后藏着 ASCII 码的血泪经验——这才是数据库原理落地的第一块真实砖头。

2. 架构拆解:从四层菜单到三维数组,看一个学生项目如何用 C 语言硬扛 DBMS 核心抽象

这个项目表面是个命令行菜单程序,内里却暗藏三层结构映射:SQL 文本 ↔ 内存模型 ↔ 磁盘文件。它没用任何 ORM 或中间件,所有转换全靠strcmp、tolower、for循环和一堆固定长度的二维/三维字符数组完成。这种“土法炼钢”式设计,恰恰暴露了早期 DBMS 实现中最原始也最本质的契约:结构定义必须可序列化、可反序列化、可增量修改。下面我们就一层层剥开它的皮。

2.1 主程序流程与四大功能模块:菜单即控制中心,无 GUI 但有状态机

整个程序启动后进入一个死循环菜单,共四个主选项:

printf("1. 新建表\n"); printf("2. 修改表\n"); printf("3. 显示已建表的信息\n"); printf("4. 保存并退出\n"); printf("请选择操作:"); scanf("%d", &choice);

这看似简单,实则隐含一个轻量级状态机:

  • 选择 1(新建表)→ 调用SQL_CREATE(),输入完整 SQL 字符串,逐字符扫描解析;
  • 选择 2(修改表)→ 调用SQL_ALTER(),先校验ALTER TABLE关键字,再匹配表名,最后根据ADD/DROP/MODIFY分支处理;
  • 选择 3(显示表)→ 遍历全局number(当前表总数),对每个tableName[i]及其Pro_Num[i](字段数)做嵌套打印;
  • 选择 4(保存退出)→ 先清空table.txt,再按固定格式重写全部表结构,最后exit(0)。

提示:这里没有“连接池”“会话管理”概念,每次操作都直接作用于全局数组。这是教学项目的合理取舍——它不模拟并发,只聚焦单用户、单会话下的 DDL 流程闭环。

2.2 内存模型设计:用 char[][][] 模拟元数据字典,字段级约束靠字符串硬编码

项目文档明确写出其核心数据结构是四组平行数组:

char tableName[LEN][MAX]; // 表名,LEN=10(最多10张表),MAX=32(表名最长32字符) char fieldName[LEN][LEN][MAX]; // 字段名,第一维表索引,第二维字段索引,第三维字符 char fieldType[LEN][LEN][MAX]; // 字段类型,同上 char fieldCondition[LEN][LEN][MAX]; // 字段约束,如 "primary key"、"not null" int f_t_len[LEN][LEN][4]; // 字段长度标记,如 "(20)",用4字节存括号+数字+结束符 int Pro_Num[LEN]; // 每张表的字段总数 int number = 0; // 当前已建表总数(全局计数器)

这个设计直白得近乎粗暴,但完全契合课程设计目标:

  • LEN=10是硬编码上限,避免动态内存分配复杂度;
  • f_t_len[...][4]固定存(N)形式,因为char(20)中的20必须转成 ASCII 字符写入文件,所以f_t_len[number][num][1] = f_length + 48;就是把整数20转成字符'2'(ASCII 50);
  • fieldCondition不用枚举或位图,直接存"primary key"字符串,读取时用strcmp匹配——虽然低效,但新手可读性拉满。

2.3 持久化机制:table.txt 文件格式即协议,清空重写是最快落地方案

所有表结构最终落盘到table.txt(注意:源码中实际路径是C:\\Users\\Administrator\\Desktop\\DBMS.txt,但设计说明强调应统一为table.txt)。文件格式完全由Read()和保存逻辑约定:

表名1 字段数N 字段1名 字段1类型 (长度) 约束条件 字段2名 字段2类型 (长度) 约束条件 ... 表名2 字段数M 字段1名 字段1类型 (长度) 约束条件 ...

关键细节在于“清空重写”策略:

  • 每次保存前,程序不尝试追加或定位修改,而是ofstream out("table.txt"); out.close();先清空;
  • 再用ofstream out("table.txt", ios::out);重新打开,遍历所有表,按上述格式逐行写入;
  • 这种做法牺牲了 I/O 效率,但彻底规避了“如何在文本文件中精准替换第X行第Y列”的黑匣子问题——对课程设计而言,这是最可靠、最易调试的持久化方案。

2.4 SQL 解析引擎:手写词法分析器,用空格和分号切分,靠 tolower 统一大小写

SQL_CREATE()函数是整个项目的灵魂,它实现了简易词法分析器(Lexer),不依赖 Flex/Bison,全靠指针偏移和字符串比较:

// 步骤1:跳过开头空格 for(i=0; SQL[i]==' '; i++); // 步骤2:提取第一个单词(转小写) for(j=0; SQL[i]!=' '&&SQL[i]!=';'; i++,j++) { temp[j]=tolower(SQL[i]); } temp[j]='\0'; if(strcmp(temp,"create")!=0) { /* 报错 */ } // 步骤3:跳空格,提第二个词 for(; SQL[i]==' '; i++); for(j=0; SQL[i]!=' '&&SQL[i]!=';'; i++,j++) { temp[j]=tolower(SQL[i]); } temp[j]='\0'; if(strcmp(temp,"table")!=0) { /* 报错 */ }

这种解析方式有明显局限:

  • 不支持括号嵌套(如CREATE TABLE t (a int, b varchar(10+5))会崩);
  • 不识别注释(--或/* */);
  • 字段类型只认char/int/float/double/date五种,且char(20)的括号必须紧贴类型名;
    但——它完美覆盖了课程设计要求的全部语法点,且每一行代码都对应教材中“SQL 语句的语法成分”定义。这就是教学项目的精妙之处:不追求工业级鲁棒,而追求概念映射的精确性。

3. 核心模块代码逐行深挖:CREATE TABLE 如何从字符串变成内存里的三维数组

现在我们聚焦SQL_CREATE()函数,它是整个项目技术含量最高的部分。它接收全局字符数组SQL[MAX](用户输入的整条 SQL),输出是向全局数组tableName[],fieldName[][],fieldType[][]等写入新表结构。下面我带你逐段拆解,每段代码后附真实调试场景和参数含义。

3.1 关键字校验与表名提取:空格是你的第一个语法分隔符

for(i=0; SQL[i]==' '; i++); // 跳过开头所有空格,i 指向第一个非空字符 /*+++++++++++++++++判断 create++++++++++++++++++++++++*/ for(j=0; SQL[i]!=' '&&SQL[i]!=';'; i++,j++) { temp[j]=tolower(SQL[i]); // 逐字符转小写存入 temp } temp[j]='\0'; // 字符串结尾 if(strcmp(temp,"create")!=0) { // 必须严格等于 "create" error=0; printf("\n 你输入的'create'有误,请重新创建!\n"); }

逻辑说明:

  • SQL是用户输入的完整字符串,例如" CREATE TABLE students (id INT, name CHAR(20));";
  • 第一个for循环让i从 0 走到 2(跳过两个空格),此时SQL[2] == 'C';
  • 第二个for循环从i=2开始,取C,R,E,A,T,E共6字符,tolower后存为"create";
  • strcmp对比成功,进入下一步;若用户输"Create"或"CREATE "(带尾空格),此处就会失败——这是教学设计的刻意限制,强制规范书写。

3.2 字段循环解析:用do-while处理逗号分隔,;是唯一终止符

do { /* 提取字段名 */ for(; SQL[i]==' '||SQL[i]==','; i++); // 跳过空格和逗号,定位字段名开头 if(SQL[i]==';') break; // 遇到分号立即退出,不处理后续 for(j=0; SQL[i]!=' '&&SQL[i]!=','; i++,j++) { f_Name[j]=SQL[i]; } f_Name[j] = '\0'; // 字段名字符串结束 // ... 后续将 f_Name 拷贝到 fieldName[number][num] num++; // 字段计数器+1 } while(SQL[i]!=';');

参数说明:

  • number:当前正在创建的表在tableName[]中的索引(从 0 开始);
  • num:当前表的第几个字段(从 0 开始),决定写入fieldName[number][num];
  • SQL[i]是游标,i在整个函数中持续递增,绝不回退;
  • break在if(SQL[i]==';')处触发,确保不会误读;后的内容;
  • 这个do-while是整个解析的核心循环,它把(id INT, name CHAR(20))拆成id和name两个字段。

3.3 类型识别与长度提取:ASCII 码运算f_length+48的真实用途

// 字段类型识别 for(j=0; j<5; j++) { if(strcmp(temp,type[j])==0) { // type[j] 是 "char"/"int"/... f_Type=Type1[j]; // Type1[j] 是 'C'/'I'/... break; } } if(j==5) { /* 类型错误 */ } // 字段长度提取(仅 char/float/double 需要) if(f_Type=='C'||f_Type=='F'||f_Type=='B') { for(; SQL[i]==' '; i++); // 跳空格 for(; SQL[i]!=' '; i++) { // 读取下一个 token,如 "(20)" if(SQL[i]<48||SQL[i]>58) { // ASCII 48='0', 57='9' flag=0; break; // 非数字字符,报错 } else { f_length=f_length*10+(SQL[i]-48); // 字符转整数 } } if(flag==1) { f_t_len[number][num][0] = '('; f_t_len[number][num][1] = f_length+48; // 整数20 → 字符'2' f_t_len[number][num][2] = ')'; f_t_len[number][num][3] ='\0'; } }

关键点解释:

  • f_length+48是把整数20的十位2转成 ASCII 字符'2'(48+2=50),同理个位0转'0'(48+0=48);
  • 但这段代码只存了十位!源码中f_t_len[number][num][1] = f_length+48;实际只能处理个位数长度(如char(5)),对char(20)会丢掉个位0——这是原作者的遗留 bug,也是你二次开发的第一个靶点;
  • f_t_len数组第三维固定为 4,就是为了存"(N)\0"四个字符,f_t_len[...][3]='\0'是 C 字符串必需的结束符。

3.4 约束条件解析:用 strcmp 匹配 "primary" 后再读 "key",实现两词联动

// 提取约束条件字符串,如 "primary" 或 "not" for(j=0; SQL[i]!=' '&&SQL[i]!=','&&SQL[i]!=';'; i++,j++) { condition[j]=tolower(SQL[i]); } condition[j]='\0'; if(strcmp(condition,"primary")==0) { for(; SQL[i]==' '; i++); // 跳空格 for(j=0; SQL[i]!=' '&&SQL[i]!=','; i++,j++) { // 读下一个词 temp[j]=tolower(SQL[i]); } temp[j]='\0'; if(strcmp(temp,"key")==0) { strcpy(fieldCondition[number][num], "primary key"); // 注意:用 strcpy,不是 = } else { error=0; printf("\n 你输入的'KEY'有误...\n"); } }

为什么这样设计?

  • SQL 标准中PRIMARY KEY是两个词,不能合并为primarykey;
  • 作者用两次for循环分别读取primary和key,再用strcmp串联验证,模拟了真实 SQL 解析器中“关键字序列”的概念;
  • strcpy(fieldCondition[...], "primary key")是安全写法,因为fieldCondition是char[LEN][LEN][MAX],足够存下 12 字符的"primary key";
  • 同理处理"unique"和"not null",后者需要再读一次null词——这种“词流驱动”的解析思想,正是《数据库系统实现》第 3 章讲的 Lexer 基础。

4. 避坑指南:那些年在 VC6++ 里踩过的坑,现在帮你列成可执行 checklist

这个项目在 Windows XP/7 环境下用 VC6++ 编译运行,但现代开发环境(VS2022、Code::Blocks 20.03)会遇到一堆玄学问题。以下是我在三台不同配置机器上反复调试总结的 5 条血泪经验,每条都按「现象 → 原因 → 解决」给出可立即执行的方案。

4.1 现象:程序启动后直接崩溃,控制台闪退;原因:fstream打开文件路径含中文或空格;解决:绝对路径改用双反斜杠,且避开桌面路径

  • 现象:在 VS2022 中编译运行,Read()函数调用ioFile.open("C:\\Users\\Administrator\\Desktop\\DBMS.txt",ios::in)时ioFile.fail()返回 true,后续ioFile>>string[number]触发未处理异常,程序退出;
  • 原因:VC6++ 默认 ANSI 编码,而现代 Windows 用户目录(如C:\Users\张三\Desktop)含中文,fstream无法正确解析 UTF-16 路径;且Administrator用户名在 Win10/11 中默认不存在,路径根本无效;
  • 解决:
    1. 将文件路径硬编码改为".\\table.txt"(当前目录);
    2. 在项目属性 → 配置属性 → 常规 → 字符集 → 改为“使用多字节字符集”;
    3. 程序启动前,手动在.exe同目录下创建空文件table.txt。

4.2 现象:输入CREATE TABLE t (a int);后提示“字段类型有误”;原因:tolower()对非 ASCII 字符返回负值,strcmp比较失败

  • 现象:在 Code::Blocks 中输入标准 SQL,strcmp(temp,"int")始终不等,即使printf("%s", temp)看起来就是"int";
  • 原因:SQL[i]数组未初始化,若用户输入末尾有不可见字符(如\r\n),tolower('\r')返回负值,导致temp数组前几个字节被污染;
  • 解决:
    1. 在SQL_CREATE()开头添加初始化:for(int k=0; k<MAX; k++) SQL[k] = '\0';;
    2. 输入后用scanf("%[^\n]", SQL); getchar();替代scanf("%s", SQL),避免scanf遇空格截断;
    3. temp数组声明后立即清零:char temp[LEN] = {0};。

4.3 现象:ALTER TABLE t ADD b char(10);执行后,新字段b的类型显示为乱码;原因:fieldType数组未清零,旧值残留

  • 现象:第一次建表t(a int),第二次ALTER TABLE t ADD b char(10),打印时b的类型显示为"int(10)";
  • 原因:fieldType[number][num]是char[MAX],strcpy只覆盖前几个字符,后面残留旧字符串;例如"int\0xxx"被覆盖为"char\0xx",但printf("%s", fieldType[...])会一直打印到\0,若\0位置不对就输出乱码;
  • 解决:
    1. 每次写入前清零:memset(fieldType[number][num], 0, sizeof(fieldType[number][num]));;
    2. 用strncpy替代strcpy,并强制结尾:strncpy(fieldType[number][num], temp, MAX-1); fieldType[number][num][MAX-1] = '\0';。

4.4 现象:table.txt文件里表名和字段名之间多出空行,读取时Pro_Num[number]错乱;原因:Read()函数未跳过换行符,>>操作符吃掉\n导致后续读取偏移

  • 现象:手动编辑table.txt添加一张表后,程序启动读取时报“字段数为 0”,或fieldName全是空字符串;
  • 原因:fstream >>操作符会跳过空白符(包括\n),但getline()不会;当文件格式为t\n2\na int\nb char(10)时,>>tableName[number]读t后,>>Pro_Num[number]会跳过\n读2,但后续>>fieldName[number][i]可能从\n开始读,导致读入空字符串;
  • 解决:
    1. 统一用getline(ioFile, line)读整行,再用stringstream解析;
    2. 或在Read()中每次>>后加ioFile.ignore()跳过换行符;
    3. 更稳妥:重写Read(),用while(getline(ioFile, line))循环,用line.find(" ")分割字段。

4.5 现象:MODIFY子句始终不生效,程序提示“未识别操作”;原因:SQL_ALTER()中未实现MODIFY分支,只有ADD和DROP

  • 现象:输入ALTER TABLE t MODIFY a char(30);,程序输出“你输入的'add'有误,请重新输入 SQL 语句!”;
  • 原因:源码中SQL_ALTER()函数只处理了if(tolower(SQL[i])=='a')(ADD)和else if(tolower(SQL[i])=='d')(DROP),但没写else if(tolower(SQL[i])=='m')分支;MODIFY被当作非法操作忽略;
  • 解决:
    1. 在ADD/DROP后添加分支:
      else if(tolower(SQL[i])=='m') { /* MODIFY 分支 */ }
    2. MODIFY逻辑:先提取字段名a,再在fieldName[number][j]中查找匹配项,找到后更新fieldType[number][j]和f_t_len[number][j];
    3. 注意:MODIFY不改变字段顺序,所以只需覆盖原位置,无需移动数组。

5. 二次开发实战:从支持MODIFY到增加DROP COLUMN,手把手升级你的迷你 DBMS

现在你已经跑通了原始项目,也避开了常见坑。下一步不是“优化性能”,而是扩展功能边界——毕竟课程设计报告里明确写了“实现 ALTER TABLE 的 ADD/DROP/MODIFY”,而原始代码只完成了前两个。下面我带你用不到 50 行代码,给SQL_ALTER()注入MODIFY和DROP COLUMN能力,并教会你如何验证改动是否真正生效。

5.1 补全MODIFY分支:复用字段查找逻辑,安全覆盖类型与长度

MODIFY的核心是“找到已有字段,改它的类型”。我们复用ADD分支中已有的字段名提取逻辑,但跳过新增字段步骤,直接定位修改:

// 在 SQL_ALTER() 的 else { ... } 块内,ADD/DROP 分支之后添加: else if(tolower(SQL[i])=='m') { // 'm' for MODIFY /* 判断 MODIFY 关键字 */ for(j=0; SQL[i]!=' '; i++,j++) { temp[j]=tolower(SQL[i]); } temp[j]='\0'; if(strcmp(temp,"modify")!=0) { error=0; printf("\n 你输入的'modify'有误,请重新输入 SQL 语句!\n"); } else { /* 提取要修改的字段名 */ for(; SQL[i]==' '; i++); for(j=0; SQL[i]!=' '; i++,j++) { field_name[j]=SQL[i]; } field_name[j]='\0'; /* 在当前表中查找该字段 */ int found = -1; for(int idx=0; idx < Pro_Num[al_num]; idx++) { if(strcmp(fieldName[al_num][idx], field_name) == 0) { found = idx; break; } } if(found == -1) { printf("\n 字段 %s 在表 %s 中不存在!\n", field_name, alterName); error=0; } else { /* 提取新类型(复用 ADD 中的类型识别逻辑)*/ for(; SQL[i]==' '; i++); for(j=0; SQL[i]!=' '&&SQL[i]!=','&&SQL[i]!=';'; i++,j++) { f_type[j]=tolower(SQL[i]); } f_type[j]='\0'; /* 更新 fieldType 和 f_t_len */ strcpy(fieldType[al_num][found], f_type); // 重置长度标记(若新类型需长度,如 char,则后续需提取) memset(f_t_len[al_num][found], 0, sizeof(f_t_len[al_num][found])); printf("\n 字段 %s 类型已修改为 %s\n", field_name, f_type); } } }

验证方法:

  • 启动程序,建表CREATE TABLE t (a int);;
  • 执行ALTER TABLE t MODIFY a char(20);;
  • 选 3 查看表信息,确认a的类型变为char,长度标记为空(因未实现长度提取,属合理范围);
  • 保存退出,打开table.txt,检查a行是否变为a char。

5.2 实现DROP COLUMN:删除字段需数组搬移,小心越界访问

DROP比MODIFY复杂,因为要从fieldName[al_num][]中删掉一个元素,后续字段要前移:

else if(tolower(SQL[i])=='d') { // 'd' for DROP /* 判断 DROP 关键字 */ for(j=0; SQL[i]!=' '; i++,j++) { temp[j]=tolower(SQL[i]); } temp[j]='\0'; if(strcmp(temp,"drop")!=0) { error=0; printf("\n 你输入的'drop'有误,请重新输入 SQL 语句!\n"); } else { /* 提取要删除的字段名 */ for(; SQL[i]==' '; i++); for(j=0; SQL[i]!=' '&&SQL[i]!=','&&SQL[i]!=';'; i++,j++) { field_name[j]=SQL[i]; } field_name[j]='\0'; /* 查找并删除 */ int found = -1; for(int idx=0; idx < Pro_Num[al_num]; idx++) { if(strcmp(fieldName[al_num][idx], field_name) == 0) { found = idx; break; } } if(found == -1) { printf("\n 字段 %s 在表 %s 中不存在!\n", field_name, alterName); error=0; } else { /* 字段数组前移:从 found+1 开始,逐个覆盖 */ for(int idx=found; idx < Pro_Num[al_num]-1; idx++) { strcpy(fieldName[al_num][idx], fieldName[al_num][idx+1]); strcpy(fieldType[al_num][idx], fieldType[al_num][idx+1]); strcpy(fieldCondition[al_num][idx], fieldCondition[al_num][idx+1]); strcpy(f_t_len[al_num][idx], f_t_len[al_num][idx+1]); } /* 清空最后一个位置 */ memset(fieldName[al_num][Pro_Num[al_num]-1], 0, MAX); memset(fieldType[al_num][Pro_Num[al_num]-1], 0, MAX); memset(fieldCondition[al_num][Pro_Num[al_num]-1], 0, MAX); memset(f_t_len[al_num][Pro_Num[al_num]-1], 0, sizeof(f_t_len[al_num][0])); Pro_Num[al_num]--; // 字段总数减1 printf("\n 字段 %s 已删除\n", field_name); } } }

关键防御点:

  • for(int idx=found; idx < Pro_Num[al_num]-1; idx++)中idx上限是Pro_Num-2,避免访问idx+1越界;
  • 删除后必须memset最后一个位置,否则残留数据会在下次printf时被误读;
  • Pro_Num[al_num]--必须放在最后,否则循环中Pro_Num-1计算错误。

5.3 验证与调试技巧:用printf打桩 +table.txt快照,构建最小可信证据链

二次开发最怕“以为改好了,其实没生效”。我的习惯是:每次修改后,必做三件事:

  1. 打桩输出:在MODIFY分支末尾加printf("DEBUG: modify %s -> %s at pos %d\n", field_name, f_type, found);,确认逻辑走到这里;
  2. 文件快照:每次保存前,用system("copy table.txt table_before_save.txt >nul");备份,对比修改前后差异;
  3. 结构 dump:在case 3:(显示表)中,加一行printf("DEBUG: Pro_Num[%d] = %d\n", al_num, Pro_Num[al_num]);,确认字段数实时更新。

从那以后我每次改SQL_ALTER(),都强制走一遍“建表 → MODIFY → 显示 → 保存 → 重启 → 读取 → 显示”全链路,哪怕只是改了一个=号。因为在这个项目里,内存数组和磁盘文件的同步,就是 DBMS 一致性的全部真相。希望帮到你。

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

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

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

立即咨询