ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从零实现C语言编译器:词法分析、语法树到汇编代码生成全解析

2026/8/24 4:00:56 拓冰建站 浏览量
从零实现C语言编译器:词法分析、语法树到汇编代码生成全解析 大家好我是专注于技术实战分享的博主。在计算机科学的学习中理解编译原理是深入计算机系统底层的关键一步。然而理论往往抽象直接阅读工业级编译器源码如GCC、LLVM又过于庞大复杂让许多学习者望而却步。本文将带领大家从零开始动手实现一个能编译简单C语言子集的编译器并通过逐行源码解析将词法分析、语法分析、语义检查、中间代码生成到目标代码生成的完整流程具象化。无论你是对编译原理感到好奇的学生还是希望夯实底层知识的开发者这篇万字长文都将为你提供一条清晰的实践路径。1. 背景与核心概念编译器是什么在开始编码之前我们必须清晰地理解我们要构建的是什么。编译器本质上是一个翻译程序它的任务是将一种语言高级编程语言如C、Java编写的源代码转换为另一种语言低级语言通常是特定计算机架构的汇编语言或机器码编写的目标程序。这个过程与我们人类翻译外语文章类似我们需要先识别单词词法分析理解句子结构语法分析把握文章含义语义分析最后用另一种语言重新组织并表达出来代码生成与优化。为什么需要自己实现一个简单的编译器深化理解亲手实现是理解抽象理论如有限自动机、上下文无关文法的最佳方式。掌握系统思维编译器是一个经典的软件系统涉及模块化设计、数据结构符号表、语法树和算法。解决实际问题理解编译过程有助于你更好地调试程序理解错误信息、进行代码优化甚至为特定领域设计自己的小型语言DSL。我们的目标编译器特性语言子集支持一个极简的C语言子集包含整数类型、变量声明、算术运算、赋值、if条件判断和while循环。目标代码生成x86架构的32位汇编代码NASM风格可在模拟器或特定环境下运行。教学目的代码结构清晰每个模块职责单一方便学习和扩展。2. 环境准备与版本说明我们将使用C语言来实现这个编译器本身这既是对C语言的深入应用也使得编译器不依赖复杂的运行时环境。生成的目标代码为x86汇编可以使用NASM汇编器和GCC链接器进行组装和链接。开发环境操作系统Linux (Ubuntu 20.04/22.04) 或 Windows with WSL2。macOS也可行但汇编环节可能需要调整。编译器构建工具链GCC (版本 9.0) 或 Clang。目标代码处理工具NASM汇编器用于将生成的汇编代码转为目标文件。GCC链接器用于将目标文件链接为可执行文件。IDE/编辑器任何你熟悉的即可如 VSCode, CLion, Vim。调试工具GDB (GNU Debugger)用于调试我们编写的编译器本身。版本安装命令Ubuntu为例# 更新包列表并安装必要的工具 sudo apt update sudo apt install build-essential # 安装gcc, make等 sudo apt install nasm # 安装NASM汇编器 sudo apt install gdb # 安装调试器项目结构预览在开始前我们先规划好项目目录这有助于管理代码。simple_c_compiler/ ├── src/ # 编译器源码目录 │ ├── lexer.c # 词法分析器 │ ├── lexer.h │ ├── parser.c # 语法分析器递归下降 │ ├── parser.h │ ├── ast.c # 抽象语法树节点定义与操作 │ ├── ast.h │ ├── semantic.c # 语义分析器类型检查、符号表 │ ├── semantic.h │ ├── codegen.c # 代码生成器x86汇编 │ ├── codegen.h │ ├── main.c # 主程序串联整个流程 │ └── common.h # 公共定义Token类型、错误处理等 ├── include/ # 可选头文件目录 ├── test/ # 测试用例目录 │ └── example.c # 待编译的简单C程序 ├── output/ # 输出目录汇编文件、可执行文件 ├── Makefile # 构建脚本 └── README.md3. 核心模块原理拆解一个典型的编译器前端到后端流程可以简化为以下几个核心模块我们将逐一拆解其原理和实现思路。3.1 词法分析器Lexer词法分析是编译的第一步负责将源代码字符流转换为有意义的词素序列。核心原理有限自动机。我们遍历源代码字符串根据当前字符和状态决定是继续读取如构成一个数字或标识符还是结束当前词素的识别并生成一个Token。Token设计我们需要定义一系列枚举值来代表不同的词法单元。// common.h 或 lexer.h typedef enum { TOKEN_EOF, // 文件结束 TOKEN_INT, // 整数常量如 123 TOKEN_IDENT, // 标识符如 variable, sum // 关键字 TOKEN_INT_KW, // int TOKEN_IF, // if TOKEN_ELSE, // else TOKEN_WHILE, // while TOKEN_RETURN, // return // 运算符和分隔符 TOKEN_PLUS, // TOKEN_MINUS, // - TOKEN_STAR, // * TOKEN_SLASH, // / TOKEN_ASSIGN, // TOKEN_EQ, // TOKEN_NE, // ! TOKEN_LT, // TOKEN_GT, // TOKEN_SEMICOLON, // ; TOKEN_LPAREN, // ( TOKEN_RPAREN, // ) TOKEN_LBRACE, // { TOKEN_RBRACE, // } // ... 其他需要的Token } TokenType; // Token结构体存储类型和对应的文本值 typedef struct { TokenType type; char* value; // 动态分配内存存储词素字符串如 “123”, “main” int line; // 所在行号用于错误报告 } Token;Lexer的工作流程跳过空白字符空格、制表符、换行。识别数字连续读取数字字符生成TOKEN_INT。识别标识符和关键字以字母或下划线开头连续读取字母、数字、下划线。读取完成后与关键字表比较决定是生成关键字Token还是标识符Token。识别运算符和分隔符如需要预读下一个字符判断是否是。返回一个Token流供语法分析器使用。3.2 语法分析器Parser与抽象语法树AST语法分析器根据预定义的语法规则将Token流组织成一棵抽象语法树。这棵树代表了程序的层次结构去掉了像分号、括号这样的细节只保留程序逻辑的核心骨架。核心原理递归下降分析法。我们为每种语法结构如表达式、语句、程序编写一个解析函数。这些函数根据当前Token预测应该匹配哪种结构并递归地调用其他解析函数。我们的简单C语法规则EBNF表示program {statement} statement if_statement | while_statement | assignment | declaration declaration int identifier ; assignment identifier expression ; if_statement if ( expression ) { {statement} } while_statement while ( expression ) { {statement} } expression term { ( | -) term } term factor { (* | /) factor } factor integer | identifier | ( expression )AST节点设计我们需要设计不同的节点类型来对应不同的语法结构。// ast.h typedef enum { AST_PROGRAM, AST_DECLARATION, // int a; AST_ASSIGNMENT, // a 10; AST_IF, AST_WHILE, AST_BIN_OP, // 二元操作如 a b AST_INTEGER, // 整数常量 AST_IDENTIFIER, // 变量名 // ... } ASTNodeType; typedef struct ASTNode { ASTNodeType type; // 联合体根据节点类型存储不同的数据 union { int int_value; // 用于AST_INTEGER char* ident_name; // 用于AST_IDENTIFIER, AST_DECLARATION等 struct { // 用于AST_BIN_OP struct ASTNode* left; struct ASTNode* right; TokenType op; } binop; struct { // 用于AST_ASSIGNMENT struct ASTNode* lhs; // 左值通常是个标识符节点 struct ASTNode* rhs; // 右值表达式节点 } assignment; struct { // 用于AST_IF, AST_WHILE struct ASTNode* condition; struct ASTNode* body; // 语句块可以是一个节点列表 } control; } data; struct ASTNode* next; // 用于连接同一层级的多个语句如程序体、语句块 } ASTNode;Parser的工作流程parse_program()函数作为入口循环调用parse_statement()直到遇到文件结束Token。parse_statement()根据首个Token如int,if, 标识符决定调用哪个具体的解析函数。每个解析函数负责构建对应类型的AST节点并可能递归调用parse_expression()等。3.3 语义分析器Semantic AnalyzerAST只保证了语法结构正确但程序可能仍有逻辑错误如“使用未声明的变量”、“给字符串变量赋整数值”。语义分析器负责发现这些错误。核心任务构建符号表在遍历AST时记录所有声明的变量及其类型在我们的子集中只有int。类型检查检查表达式中操作数的类型是否兼容赋值左右类型是否匹配等。作用域管理虽然我们的简单子集可能只有全局作用域但完善的编译器需要处理块作用域。符号表设计可以使用哈希表或简单的链表来实现。// semantic.h typedef struct Symbol { char* name; // 变量名 int type; // 变量类型如 TYPE_INT int is_defined; // 是否已定义 // 其他属性如内存地址偏移量用于代码生成 int stack_offset; struct Symbol* next; } Symbol; typedef struct SymbolTable { Symbol* head; // 可以扩展为支持多级作用域 } SymbolTable;语义分析器会遍历AST两次或一次结合状态第一次遍历处理所有声明语句将变量加入符号表。第二次遍历检查所有表达式和赋值语句中引用的标识符是否已在符号表中定义。3.4 代码生成器Code Generator这是编译器的后端将经过语义检查的AST转换为目标机器代码这里我们生成x86汇编。核心原理递归遍历AST为每种节点类型生成对应的汇编指令序列。这本质上是一个翻译过程。关键考虑寄存器分配我们采用最简单的策略所有计算都在栈上进行避免复杂的寄存器分配算法。例如使用eax,ebx作为临时计算寄存器。栈帧管理为函数我们可能只支持main或变量分配栈空间。每个局部变量在栈上有一个固定的偏移地址如[ebp-4]。指令选择为每种AST操作选择对应的汇编指令。例如对应add*对应imul。示例为赋值语句a b 5;生成代码假设变量a和b已在栈帧中分配了位置偏移量分别是[ebp-4]和[ebp-8]。生成计算b 5的代码mov eax, [ebp-8] ; 将变量b的值加载到eax add eax, 5 ; eax eax 5生成赋值给a的代码mov [ebp-4], eax ; 将eax的结果存储到变量a的位置4. 完整实战案例编译一个简单程序现在我们将把上述模块串联起来编译一个具体的简单C程序。4.1 创建项目结构与基础文件按照之前规划的项目结构创建目录和文件。首先创建公共头文件common.h定义基本类型和错误处理。// src/common.h #ifndef COMMON_H #define COMMON_H #include stdio.h #include stdlib.h #include string.h #include ctype.h // 简单的错误报告函数 void error(const char* fmt, ...); #endif// src/common.c #include stdarg.h #include common.h void error(const char* fmt, ...) { va_list args; va_start(args, fmt); vfprintf(stderr, fmt, args); fprintf(stderr, \n); va_end(args); exit(EXIT_FAILURE); }4.2 实现词法分析器Lexer我们实现一个简单的Lexer结构体和核心函数next_token。// src/lexer.h #ifndef LEXER_H #define LEXER_H #include common.h // TokenType 定义放在 common.h 或 lexer.h typedef enum { /* 如前文定义的TokenType枚举 */ } TokenType; typedef struct { TokenType type; char* value; int line; } Token; typedef struct { const char* source; // 源代码字符串 int current_pos; // 当前位置 int line; // 当前行号 } Lexer; // 函数声明 Lexer* lexer_create(const char* source); Token* lexer_next_token(Lexer* lexer); void token_free(Token* token); void lexer_destroy(Lexer* lexer); #endif// src/lexer.c #include lexer.h // 辅助函数判断字符是否是标识符起始字符 static int is_ident_start(char c) { return isalpha(c) || c _; } // 辅助函数判断字符是否是标识符组成部分 static int is_ident_char(char c) { return isalnum(c) || c _; } Lexer* lexer_create(const char* source) { Lexer* lexer malloc(sizeof(Lexer)); lexer-source source; lexer-current_pos 0; lexer-line 1; return lexer; } Token* lexer_next_token(Lexer* lexer) { const char* src lexer-source; int len strlen(src); // 跳过空白字符 while (lexer-current_pos len isspace(src[lexer-current_pos])) { if (src[lexer-current_pos] \n) lexer-line; lexer-current_pos; } // 检查是否到达文件末尾 if (lexer-current_pos len) { Token* tok malloc(sizeof(Token)); tok-type TOKEN_EOF; tok-value NULL; tok-line lexer-line; return tok; } char current_char src[lexer-current_pos]; // 识别数字 if (isdigit(current_char)) { int start lexer-current_pos; while (lexer-current_pos len isdigit(src[lexer-current_pos])) { lexer-current_pos; } int length lexer-current_pos - start; char* value malloc(length 1); strncpy(value, src[start], length); value[length] \0; Token* tok malloc(sizeof(Token)); tok-type TOKEN_INT; tok-value value; tok-line lexer-line; return tok; } // 识别标识符和关键字 if (is_ident_start(current_char)) { int start lexer-current_pos; while (lexer-current_pos len is_ident_char(src[lexer-current_pos])) { lexer-current_pos; } int length lexer-current_pos - start; char* ident malloc(length 1); strncpy(ident, src[start], length); ident[length] \0; Token* tok malloc(sizeof(Token)); tok-value ident; tok-line lexer-line; // 关键字判断 if (strcmp(ident, int) 0) tok-type TOKEN_INT_KW; else if (strcmp(ident, if) 0) tok-type TOKEN_IF; else if (strcmp(ident, while) 0) tok-type TOKEN_WHILE; // ... 其他关键字 else tok-type TOKEN_IDENT; // 普通标识符 return tok; } // 识别运算符和分隔符 Token* tok malloc(sizeof(Token)); tok-value NULL; tok-line lexer-line; switch (current_char) { case : tok-type TOKEN_PLUS; break; case -: tok-type TOKEN_MINUS; break; case *: tok-type TOKEN_STAR; break; case /: tok-type TOKEN_SLASH; break; case : // 预读下一个字符判断是 还是 if (lexer-current_pos 1 len src[lexer-current_pos 1] ) { tok-type TOKEN_EQ; lexer-current_pos; // 消耗掉第二个 } else { tok-type TOKEN_ASSIGN; } break; case ;: tok-type TOKEN_SEMICOLON; break; case (: tok-type TOKEN_LPAREN; break; case ): tok-type TOKEN_RPAREN; break; case {: tok-type TOKEN_LBRACE; break; case }: tok-type TOKEN_RBRACE; break; // ... 处理其他符号 default: error(Line %d: Unexpected character %c, lexer-line, current_char); } lexer-current_pos; return tok; } // ... 其他函数token_free, lexer_destroy实现略4.3 实现语法分析器与AST构建由于篇幅限制这里给出语法分析器入口和解析语句的核心函数框架。// src/parser.h #ifndef PARSER_H #define PARSER_H #include lexer.h #include ast.h typedef struct { Lexer* lexer; Token* current_token; } Parser; Parser* parser_create(Lexer* lexer); ASTNode* parse_program(Parser* parser); void parser_destroy(Parser* parser); #endif// src/parser.c #include parser.h // 辅助函数消费当前Token并获取下一个Token static void eat(Parser* parser, TokenType expected_type) { if (parser-current_token-type ! expected_type) { error(Line %d: Syntax error, expected %d but got %d, parser-current_token-line, expected_type, parser-current_token-type); } token_free(parser-current_token); parser-current_token lexer_next_token(parser-lexer); } ASTNode* parse_statement(Parser* parser) { Token* tok parser-current_token; if (tok-type TOKEN_INT_KW) { return parse_declaration(parser); } else if (tok-type TOKEN_IF) { return parse_if_statement(parser); } else if (tok-type TOKEN_WHILE) { return parse_while_statement(parser); } else if (tok-type TOKEN_IDENT) { // 可能是赋值语句 return parse_assignment(parser); } else { error(Line %d: Unexpected token at start of statement, tok-line); return NULL; } } ASTNode* parse_program(Parser* parser) { ASTNode* program_node ast_create_node(AST_PROGRAM); ASTNode* current_stmt_list NULL; while (parser-current_token-type ! TOKEN_EOF) { ASTNode* stmt parse_statement(parser); if (current_stmt_list NULL) { program_node-data.program_body stmt; current_stmt_list stmt; } else { current_stmt_list-next stmt; current_stmt_list stmt; } } return program_node; } // parse_declaration, parse_assignment, parse_expression 等函数需要根据语法规则实现 // 例如 parse_expression 会递归调用 parse_term, parse_factorast.c中需要实现创建各种AST节点的函数如ast_create_node,ast_create_binop,ast_create_integer等。4.4 实现语义分析与代码生成语义分析和代码生成模块的完整实现较长但核心逻辑已在前文阐明。语义分析器会遍历AST使用一个符号表记录变量。代码生成器则递归遍历AST为每个节点打印对应的汇编指令。一个极简的代码生成器入口可能如下// src/codegen.c (片段) void codegen_program(ASTNode* program, FILE* out) { fprintf(out, section .text\n); fprintf(out, global main\n); fprintf(out, main:\n); fprintf(out, push ebp\n); fprintf(out, mov ebp, esp\n); fprintf(out, sub esp, %d ; 为局部变量分配空间\n, total_local_size); // 遍历程序体中的每条语句生成代码 ASTNode* stmt program-data.program_body; while (stmt ! NULL) { codegen_statement(stmt, out); stmt stmt-next; } fprintf(out, mov esp, ebp\n); fprintf(out, pop ebp\n); fprintf(out, ret\n); }4.5 编写测试程序并运行完整流程1. 创建测试C程序// test/example.c int main() { int a; int b; a 10; b a 5; if (b 10) { a 20; } while (a 0) { a a - 1; } return 0; }2. 编写主程序串联流程// src/main.c #include lexer.h #include parser.h #include semantic.h #include codegen.h int main(int argc, char** argv) { if (argc ! 2) { fprintf(stderr, Usage: %s source_file.c\n, argv[0]); return 1; } // 1. 读取源文件 FILE* source_file fopen(argv[1], r); fseek(source_file, 0, SEEK_END); long file_size ftell(source_file); fseek(source_file, 0, SEEK_SET); char* source malloc(file_size 1); fread(source, 1, file_size, source_file); source[file_size] \0; fclose(source_file); // 2. 词法分析 Lexer* lexer lexer_create(source); // 3. 语法分析 Parser* parser parser_create(lexer); ASTNode* ast parse_program(parser); // 4. 语义分析 semantic_analyze(ast); // 5. 代码生成 FILE* asm_output fopen(output/program.asm, w); codegen_program(ast, asm_output); fclose(asm_output); // 6. 清理资源 ast_free(ast); parser_destroy(parser); lexer_destroy(lexer); free(source); printf(Compilation successful. Assembly saved to output/program.asm\n); // 可选调用NASM和GCC进行汇编链接 // system(nasm -f elf32 output/program.asm -o output/program.o); // system(gcc -m32 output/program.o -o output/program); return 0; }3. 编译我们的编译器并测试创建一个简单的Makefile# Makefile CC gcc CFLAGS -g -Wall -stdc11 SRC_DIR src OBJ_DIR obj SOURCES $(wildcard $(SRC_DIR)/*.c) OBJECTS $(patsubst $(SRC_DIR)/%.c, $(OBJ_DIR)/%.o, $(SOURCES)) TARGET scc # simple c compiler all: $(TARGET) $(TARGET): $(OBJECTS) $(CC) $(CFLAGS) -o $ $^ $(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR) $(CC) $(CFLAGS) -c $ -o $ $(OBJ_DIR): mkdir -p $(OBJ_DIR) clean: rm -rf $(OBJ_DIR) $(TARGET) output/* test: $(TARGET) ./$(TARGET) test/example.c在项目根目录执行make # 编译我们的编译器 make test # 使用我们的编译器编译测试程序如果一切顺利你将在output/目录下看到生成的program.asm汇编文件。5. 常见问题与排查思路在实现和运行过程中你可能会遇到以下典型问题问题现象可能原因排查思路与解决方案编译器本身编译失败语法错误、头文件未找到、函数未定义1. 检查CFLAGS是否包含-I./src。2. 确保所有.c文件都正确#include了对应的.h文件。3. 使用gcc -c单独编译每个模块定位具体错误文件。词法分析器将关键字识别为标识符关键字表匹配逻辑错误或顺序问题1. 检查lexer.c中的关键字比较逻辑确保在识别标识符后正确判断是否为关键字。2. 确保字符串比较函数如strcmp使用正确。语法分析器报告“Unexpected token”1. 语法规则与Token流不匹配。2. 词法分析器生成了错误的Token。3.eat()函数期望的Token类型不对。1. 在错误位置打印当前Token的信息类型和值检查是否符合语法规则预期。2. 回溯检查词法分析器对于该处源代码的识别逻辑。3. 使用调试器或打印语句跟踪Parser的解析流程。生成的汇编代码无法通过NASM编译1. 汇编语法错误如寄存器使用不当。2. 标签未定义或重复。3. 栈操作不平衡push/pop不匹配。1. 仔细对照x86汇编语法手册检查生成的指令。2. 检查代码生成器中对变量地址的计算是否正确确保[ebp-4]之类的偏移量是有效的。3. 在函数入口和出口确保栈指针esp和基址指针ebp的保存与恢复是匹配的。程序编译成功但运行结果错误或崩溃1. 语义错误未捕获如使用未初始化变量。2. 代码生成逻辑错误如运算顺序错误。3. 内存访问越界错误的栈偏移。1. 加强语义分析添加对变量“定义前使用”的检查。2. 为AST添加打印功能可视化生成的语法树检查结构是否正确。3. 使用调试器GDB单步执行生成的汇编程序观察寄存器和内存值的变化。处理多字符运算符如,!失败词法分析器未实现“预读”机制。在lexer_next_token函数中识别到时查看下一个字符如果是则生成TOKEN_EQ并前进两个字符的位置。6. 最佳实践与工程建议实现一个教学编译器与开发生产级编译器有天壤之别但以下实践能让你更好地组织代码并理解工业编译器的设计思想。模块化与接口清晰严格分离词法、语法、语义、代码生成模块。通过清晰的头文件定义接口API模块间仅通过AST和符号表等数据结构通信。这便于独立测试和替换某个阶段例如未来可以换用不同的代码生成器。错误处理与恢复目前的error函数直接退出过于粗暴。一个健壮的编译器应能收集多个错误并尝试从错误中恢复以继续分析后续代码。可以实现一个错误列表在分析结束后统一报告。符号表的设计当前使用简单链表查找效率低。可以升级为哈希表以支持更快的查找。此外需要设计作用域栈Scope Stack来支持块作用域如{}内的局部变量。AST的序列化与可视化实现一个将AST以文本如JSON或图形DOT语言格式导出的函数。这对于调试复杂程序的语法分析结果至关重要能直观地看到解析是否正确。中间代码IR的引入直接生成目标汇编代码耦合度高难以优化和移植。引入一个与机器无关的中间表示如三地址码、LLVM IR可以将优化阶段集中在此IR上然后由不同的后端将IR转换为x86、ARM等汇编。这是现代编译器如GCC、LLVM的核心架构。测试驱动开发为每个模块编写单元测试。例如为词法分析器提供一段字符串断言其输出的Token序列为语法分析器提供Token序列断言其生成的AST结构。这能极大提升开发效率和代码可靠性。参考现有简单编译器学习一些著名的小型教学编译器如C4、TCCTiny C Compiler、SubC等。阅读它们的源码能给你带来更多架构和实现细节上的启发。性能考量虽然教学编译器不追求性能但了解性能瓶颈所在是有益的。例如频繁的malloc/free可能影响词法分析速度可以考虑使用内存池Arena Allocator来分配AST节点和Token。7. 总结与学习路线通过这个从零实现简单C语言编译器的项目我们亲身体验了编译器将高级语言转化为机器可执行代码的完整链条从字符流到Token流词法分析从Token流到结构化语法树语法分析进行语义约束检查语义分析最终生成目标平台汇编指令代码生成。本文掌握的关键点编译器架构理解了典型编译器的前后端分离设计。词法分析使用有限自动机思想手写词法扫描器。语法分析使用递归下降法实现解析器构建抽象语法树。语义分析通过符号表进行变量声明与引用的检查。代码生成掌握了如何将AST节点映射为具体的机器指令序列。下一步深入学习方向扩展语言特性尝试支持更多C语言特性如函数定义与调用、数组、指针、结构体。实现优化在AST或IR层面实现常量折叠、公共子表达式消除等经典优化。更换目标平台尝试为ARM或RISC-V架构生成汇编代码理解不同指令集架构的差异。学习LLVMLLVM提供了成熟的编译器基础设施。可以尝试用我们的前端生成LLVM IR然后利用LLVM的后端生成优化后的机器码这是进入工业级编译器领域的捷径。研究标准与规范阅读C语言标准如C11理解未定义行为、实现定义行为等概念让你的编译器更规范。编译器的世界深邃而有趣它连接了软件与硬件是计算机科学的基石之一。希望这个实践项目能成为你探索这个领域的一块坚实垫脚石。动手将文中的代码片段补充完整并运行起来你获得的将远不止一篇文章的知识。如果在实现过程中遇到问题欢迎在评论区交流探讨。