从零实现C/C++词法分析器:编译原理核心实践指南

📅 2026/7/30 5:33:38 👁️ 阅读次数 📝 编程学习
从零实现C/C++词法分析器:编译原理核心实践指南

1. 项目概述:从“字符串”到“单词”的翻译官

如果你刚开始接触编译原理,可能会觉得这个词法分析器听起来很玄乎。其实,你可以把它想象成一个精通多国语言的翻译官,只不过它翻译的对象是程序员写的源代码。我们写的C/C++代码,在计算机看来,最初就是一大串毫无意义的字符流,比如int a = 10;。词法分析器的核心任务,就是把这串字符流,按照编程语言的语法规则,切割、识别成一个一个有意义的“单词”,在编译原理里我们称之为“词法单元”或“Token”。这些Token是后续语法分析、语义分析等所有编译步骤的基础原料。没有它,编译器就“看不懂”你的代码。

这个项目,就是动手实现这样一个翻译官。它不仅仅是完成一个课程实验,更是理解编译器如何“阅读”代码的绝佳入口。通过亲手设计状态转换、编写识别规则,你会对编程语言底层的运作机制有更深刻的认识。无论你是计算机专业的学生,还是对底层技术感兴趣的开发者,这个项目都能帮你打通从“写代码”到“理解代码如何被理解”的任督二脉。接下来,我将以一个完整的、可运行的C/C++词法分析器为例,拆解其设计思路、核心实现与避坑指南。

2. 核心需求与设计思路拆解

2.1 核心需求解析:我们要识别什么?

一个简单的C语言子集词法分析器,需要能准确识别以下几类Token:

  1. 关键字:如int,if,else,while,for,return等。它们是语言预定义的、具有特殊功能的单词。
  2. 标识符:由字母或下划线开头,后跟字母、数字或下划线的字符串,如myVariable,_count,MAX_SIZE。用于命名变量、函数等。
  3. 常量
    • 整数常量:如123,0xFF(十六进制),077(八进制)。
    • 浮点数常量:如3.14,.5,1e-5
    • 字符常量:用单引号括起来的单个字符或转义序列,如'a','\n'
    • 字符串常量:用双引号括起来的字符序列,如"Hello, World\n"
  4. 运算符:如+,-,*,/,=,==,!=,&&,||,++,--等。注意有些运算符由多个字符组成。
  5. 分隔符:如;,,,(,),{,},[,]

我们的词法分析器需要逐个字符读取源代码文件,根据当前字符和后续字符,判断它属于哪一类Token的起始,然后完整地读取出这个Token,并记录其类型和值(如果是标识符或常量,还需要记录具体的字符串或数值)。

2.2 设计思路:有限状态自动机是灵魂

实现词法分析器,最经典、最直观的理论模型是有限状态自动机。你可以把它看作一个流程图,分析器当前处于某个“状态”,根据读入的下一个字符,决定是留在当前状态、跳转到另一个状态,还是识别完成一个Token。

例如,识别一个标识符的简化状态机:

  • 初始状态:读入一个字母或下划线 -> 进入“识别标识符中”状态。
  • “识别标识符中”状态:继续读入字母、数字或下划线 -> 保持该状态。
  • “识别标识符中”状态:读入非字母/数字/下划线(如空格、运算符) ->识别完成,将之前积累的字符作为一个标识符Token输出,并将刚读入的这个“非字母”字符回退,作为下一个Token的起始。

对于更复杂的Token(如浮点数3.14,需要处理小数点;如运算符==,需要判断第二个字符是不是=),状态机会有更多的分支。

在代码实现上,我们通常不显式地画出所有状态和跳转表(虽然那是最规范的做法),而是采用一种更易于手写的手工编码的词法分析器结构。其核心是一个循环,在循环体内用一个大的switch-caseif-else链,根据当前字符的特征,进入不同的识别子程序。

注意:虽然正则表达式是描述词法规则的有力工具,很多生成器(如Lex/Flex)也基于它,但手工实现能让你更透彻地理解每个细节,这是学习阶段最重要的。

3. 核心数据结构与程序框架

3.1 定义Token类型

首先,我们需要用枚举类型定义所有可能的Token种类。这是分析器的“词汇表”。

// token.h #ifndef TOKEN_H #define TOKEN_H typedef enum { // 关键字 TK_INT, TK_IF, TK_ELSE, TK_WHILE, TK_FOR, TK_RETURN, TK_VOID, // 标识符 TK_IDENT, // 常量 TK_INTEGER, TK_FLOAT, TK_CHAR, TK_STRING, // 运算符 TK_PLUS, TK_MINUS, TK_STAR, TK_SLASH, // + - * / TK_ASSIGN, TK_EQ, TK_NEQ, TK_LT, TK_GT, TK_LE, TK_GE, // = == != < > <= >= TK_AND, TK_OR, TK_NOT, // && || ! TK_INC, TK_DEC, // ++ -- // 分隔符 TK_SEMICOLON, TK_COMMA, // ; , TK_LPAREN, TK_RPAREN, // ( ) TK_LBRACE, TK_RBRACE, // { } TK_LBRACKET, TK_RBRACKET, // [ ] // 特殊 TK_EOF, // 文件结束符 TK_ERROR // 错误Token,用于报告词法错误 } TokenType; #endif

3.2 定义Token结构体

每个识别出的Token,除了类型,还需要知道它的具体值(对于标识符是名字,对于常量是数值或字符串)以及在源代码中的位置(便于错误报告)。

// token.h (续) typedef struct { TokenType type; // Token类型 union { int intVal; // 整型常量的值 double floatVal; // 浮点常量的值 char charVal; // 字符常量的值 char* strVal; // 标识符名或字符串常量的内容(需要动态分配内存) } value; int line; // 所在行号 int column; // 所在列号(或起始位置) } Token;

3.3 词法分析器状态结构体

分析器需要维护一个状态,记录当前读取到文件的哪个位置,以及一些临时缓冲区。

// lexer.h #ifndef LEXER_H #define LEXER_H #include "token.h" typedef struct { FILE* fp; // 源代码文件指针 char* filePath; // 文件路径 int currentChar; // 当前查看的字符(ASCII码) int line; // 当前行号 int column; // 当前列号 char* buffer; // 用于临时存储正在识别的词素(lexeme) int bufSize; int bufPos; } Lexer; // 函数声明 Lexer* createLexer(const char* filePath); void destroyLexer(Lexer* lexer); Token getNextToken(Lexer* lexer); void printToken(const Token* token); #endif

3.4 主程序框架

主程序负责创建分析器,循环获取Token并打印,直到文件结束。

// main.c #include <stdio.h> #include <stdlib.h> #include "lexer.h" int main(int argc, char* argv[]) { if (argc < 2) { fprintf(stderr, "Usage: %s <source_file>\n", argv[0]); return 1; } Lexer* lexer = createLexer(argv[1]); if (!lexer) { fprintf(stderr, "Failed to open file: %s\n", argv[1]); return 1; } Token token; do { token = getNextToken(lexer); printToken(&token); if (token.type == TK_ERROR) { fprintf(stderr, "Lexical error at line %d, column %d\n", token.line, token.column); // 通常这里可以选择继续或终止 } } while (token.type != TK_EOF); destroyLexer(lexer); return 0; }

4. 关键函数实现与核心算法

4.1 辅助函数:字符读取与缓冲区管理

词法分析器需要“预读”字符,即查看下一个字符是什么,但不消耗它(用于判断=后面是不是=来决定是=还是==)。我们实现一个peekChar函数。同时,需要一个动态增长的缓冲区来存储正在识别的词素。

// lexer.c #include <ctype.h> #include <string.h> #include <stdlib.h> #include "lexer.h" // 获取下一个字符,但不移动文件指针(预读) static int peekChar(Lexer* lexer) { if (feof(lexer->fp)) return EOF; int c = fgetc(lexer->fp); ungetc(c, lexer->fp); // 放回流中 return c; } // 消耗当前字符,读取下一个,并更新行列信息 static void nextChar(Lexer* lexer) { if (lexer->currentChar == '\n') { lexer->line++; lexer->column = 1; } else { lexer->column++; } lexer->currentChar = fgetc(lexer->fp); } // 初始化缓冲区 static void initBuffer(Lexer* lexer) { lexer->bufSize = 32; lexer->buffer = (char*)malloc(lexer->bufSize); lexer->bufPos = 0; lexer->buffer[0] = '\0'; } // 向缓冲区添加一个字符,必要时扩容 static void appendToBuffer(Lexer* lexer, char c) { if (lexer->bufPos + 1 >= lexer->bufSize) { // +1 for '\0' lexer->bufSize *= 2; lexer->buffer = (char*)realloc(lexer->buffer, lexer->bufSize); } lexer->buffer[lexer->bufPos++] = c; lexer->buffer[lexer->bufPos] = '\0'; } // 重置缓冲区 static void resetBuffer(Lexer* lexer) { lexer->bufPos = 0; lexer->buffer[0] = '\0'; }

4.2 核心函数:getNextToken 的实现逻辑

这是词法分析器的核心引擎。其主体是一个大的循环或switch,根据currentChar跳转到不同的识别路径。

Token getNextToken(Lexer* lexer) { Token token; token.line = lexer->line; token.column = lexer->column; resetBuffer(lexer); // 开始识别新Token前清空缓冲区 // 跳过空白字符(空格、制表符、换行符) while (isspace(lexer->currentChar)) { nextChar(lexer); // 更新Token起始位置(跳过空白后) token.line = lexer->line; token.column = lexer->column; } // 处理文件结束 if (lexer->currentChar == EOF) { token.type = TK_EOF; return token; } // 根据首字符分类处理 if (isalpha(lexer->currentChar) || lexer->currentChar == '_') { // 识别标识符或关键字 return parseIdentifierOrKeyword(lexer, &token); } else if (isdigit(lexer->currentChar)) { // 识别数字常量(整数或浮点数) return parseNumber(lexer, &token); } else if (lexer->currentChar == '\'') { // 识别字符常量 return parseChar(lexer, &token); } else if (lexer->currentChar == '\"') { // 识别字符串常量 return parseString(lexer, &token); } else { // 识别运算符和分隔符 return parseOperatorOrDelimiter(lexer, &token); } }

4.3 子过程解析:标识符与关键字识别

关键点和关键字共享相同的词法规则(字母/下划线开头),识别出完整的词素后,再去查表判断是否是关键字。

static Token parseIdentifierOrKeyword(Lexer* lexer, Token* token) { // 收集所有符合标识符规则的字符 while (isalnum(lexer->currentChar) || lexer->currentChar == '_') { appendToBuffer(lexer, lexer->currentChar); nextChar(lexer); } // 查关键字表 token->type = lookupKeyword(lexer->buffer); if (token->type == TK_IDENT) { // 如果是标识符,需要复制字符串值 token->value.strVal = strdup(lexer->buffer); } return *token; } // 简单的关键字查找函数 static TokenType lookupKeyword(const char* str) { static struct { const char* word; TokenType type; } keywords[] = { {"int", TK_INT}, {"if", TK_IF}, {"else", TK_ELSE}, {"while", TK_WHILE}, {"for", TK_FOR}, {"return", TK_RETURN}, {"void", TK_VOID} // ... 可以扩展更多 }; for (int i = 0; i < sizeof(keywords)/sizeof(keywords[0]); i++) { if (strcmp(str, keywords[i].word) == 0) { return keywords[i].type; } } return TK_IDENT; // 不是关键字,就是标识符 }

实操心得:关键字表用静态数组实现简单高效。如果关键字很多,可以考虑用哈希表提升查找速度。strdup函数用于复制字符串,记得在销毁Token或分析器时要free这些内存,避免泄漏。

4.4 子过程解析:数字常量识别

数字的识别稍复杂,需要区分整数、十进制浮点数、科学计数法、八进制、十六进制等。这里以实现十进制整数和简单浮点数为例。

static Token parseNumber(Lexer* lexer, Token* token) { int isFloat = 0; // 收集整数部分 while (isdigit(lexer->currentChar)) { appendToBuffer(lexer, lexer->currentChar); nextChar(lexer); } // 检查小数点 if (lexer->currentChar == '.') { isFloat = 1; appendToBuffer(lexer, '.'); nextChar(lexer); // 收集小数部分 while (isdigit(lexer->currentChar)) { appendToBuffer(lexer, lexer->currentChar); nextChar(lexer); } } // 检查科学计数法(如1e-5),这里作为扩展,暂不实现 // if (lexer->currentChar == 'e' || lexer->currentChar == 'E') { ... } if (isFloat) { token->type = TK_FLOAT; token->value.floatVal = atof(lexer->buffer); // 简单转换,生产环境应用strtod并检查错误 } else { token->type = TK_INTEGER; token->value.intVal = atoi(lexer->buffer); // 简单转换,注意溢出 } return *token; }

注意事项atoiatof没有错误检查。在严谨的实现中,应使用strtolstrtod,并检查errno以及转换后剩余的字符,以处理像123abc这样的非法数字。此外,十六进制(0x)和八进制(0开头)的数字需要额外的逻辑分支。

4.5 子过程解析:字符与字符串常量识别

字符和字符串常量需要处理转义序列(如\n,\t,\\,\',\")。

static Token parseChar(Lexer* lexer, Token* token) { nextChar(lexer); // 跳过开头的单引号 if (lexer->currentChar == '\\') { // 处理转义字符 nextChar(lexer); token->value.charVal = escapeChar(lexer->currentChar); // 实现一个转换函数 nextChar(lexer); } else { token->value.charVal = lexer->currentChar; nextChar(lexer); } if (lexer->currentChar != '\'') { token->type = TK_ERROR; // 缺少闭合的单引号 } else { token->type = TK_CHAR; nextChar(lexer); // 跳过闭合的单引号 } return *token; } static Token parseString(Lexer* lexer, Token* token) { nextChar(lexer); // 跳过开头的双引号 resetBuffer(lexer); // 用缓冲区存储字符串内容 while (lexer->currentChar != '\"' && lexer->currentChar != EOF) { if (lexer->currentChar == '\\') { nextChar(lexer); // 跳过反斜杠 appendToBuffer(lexer, escapeChar(lexer->currentChar)); } else { appendToBuffer(lexer, lexer->currentChar); } nextChar(lexer); } if (lexer->currentChar != '\"') { token->type = TK_ERROR; // 缺少闭合的双引号 } else { token->type = TK_STRING; token->value.strVal = strdup(lexer->buffer); // 复制字符串内容 nextChar(lexer); // 跳过闭合的双引号 } return *token; } static char escapeChar(char c) { switch (c) { case 'n': return '\n'; case 't': return '\t'; case '\\': return '\\'; case '\'': return '\''; case '\"': return '\"'; default: return c; // 简单处理,实际应报错或支持更多转义 } }

4.6 子过程解析:运算符与分隔符识别

这部分需要处理多字符运算符(如==,!=,>=,<=,&&,||,++,--)。技巧是“预读”下一个字符。

static Token parseOperatorOrDelimiter(Lexer* lexer, Token* token) { char current = lexer->currentChar; nextChar(lexer); // 消耗当前字符 int next = peekChar(lexer); // 预读下一个字符 switch (current) { case '+': if (next == '+') { nextChar(lexer); token->type = TK_INC; } else token->type = TK_PLUS; break; case '-': if (next == '-') { nextChar(lexer); token->type = TK_DEC; } else token->type = TK_MINUS; break; case '=': if (next == '=') { nextChar(lexer); token->type = TK_EQ; } else token->type = TK_ASSIGN; break; case '!': if (next == '=') { nextChar(lexer); token->type = TK_NEQ; } else token->type = TK_NOT; break; case '<': if (next == '=') { nextChar(lexer); token->type = TK_LE; } else token->type = TK_LT; break; case '>': if (next == '=') { nextChar(lexer); token->type = TK_GE; } else token->type = TK_GT; break; case '&': if (next == '&') { nextChar(lexer); token->type = TK_AND; } else { token->type = TK_ERROR; } // 单个&不是合法Token(在此子集中) break; case '|': if (next == '|') { nextChar(lexer); token->type = TK_OR; } else { token->type = TK_ERROR; } break; case ';': token->type = TK_SEMICOLON; break; case ',': token->type = TK_COMMA; break; case '(': token->type = TK_LPAREN; break; case ')': token->type = TK_RPAREN; break; case '{': token->type = TK_LBRACE; break; case '}': token->type = TK_RBRACE; break; case '[': token->type = TK_LBRACKET; break; case ']': token->type = TK_RBRACKET; break; case '*': token->type = TK_STAR; break; case '/': token->type = TK_SLASH; break; // 可以处理注释:如果是'/'后接'/'或'*',则进入跳过注释的逻辑 default: token->type = TK_ERROR; // 无法识别的字符 break; } return *token; }

5. 编译、测试与调试实战

5.1 项目编译与运行

假设项目文件结构如下:

lexer_project/ ├── lexer.h ├── lexer.c ├── token.h ├── main.c └── test.c (测试用的源代码)

使用GCC编译(Linux/macOS或Windows的MinGW):

gcc -o my_lexer lexer.c main.c -Wall -Wextra -g

-Wall -Wextra开启更多警告,-g加入调试信息。

创建一个简单的测试文件test.c

int main() { int a = 42; float b = 3.14; if (a >= 10) { return a + b; } return 0; }

运行词法分析器:

./my_lexer test.c

期望的输出应该是逐行打印每个Token的类型和值(如果适用)。

5.2 设计测试用例与边界情况处理

全面的测试是保证分析器健壮性的关键。你需要设计覆盖所有Token类型以及边界和错误情况的测试。

  1. 基础功能测试:确保所有关键字、运算符、分隔符都能正确识别。
  2. 标识符测试:包含下划线、数字的标识符(如_var1,MAX_LEN)。
  3. 数字测试
    • 整数:0,123,999999
    • 浮点数:0.5,12.,.34,1.23e-4(如果实现了科学计数法)。
    • 边界:过大的整数(考虑溢出)、1.2.3(非法,应报错或部分识别)。
  4. 字符与字符串测试
    • 正常:'a','\n',"hello"
    • 转义:"He said, \"Hi!\"",'\\'
    • 错误:未闭合的'",非法的转义序列'\x'
  5. 注释处理:增加跳过//行注释和/* */块注释的功能。这需要在getNextToken开头跳过空白字符的部分加入对/字符的特殊判断。
  6. 预处理指令:简单的词法分析器通常忽略以#开头的预处理行(如#include),可以在初始阶段跳过整行。
  7. 错误恢复:当遇到无法识别的字符(如@,$)时,是报错后停止,还是跳过该字符继续分析?一个健壮的编译器前端通常选择后者,并记录错误信息。

踩坑记录:在实现注释跳过时,最容易出的bug是块注释的嵌套和未终止问题。/* /* */在C语言中通常不支持嵌套,你的分析器在遇到/*后,应一直读到*/为止,如果遇到文件结束符EOF还没找到*/,应报告错误。处理//注释时,要一直读到行尾\n,但这个\n不应该被“吃掉”,因为它是更新行号计数器的关键。

5.3 调试技巧与工具使用

  1. 打印调试:在getNextToken和各个子函数的关键位置,打印当前字符、状态和缓冲区内容,这是最直接的方法。
  2. GDB/LLDB调试器:使用-g编译后,可以用调试器单步执行,观察变量状态。特别适合追踪复杂的状态转换。
  3. 测试驱动:为每个独立的识别函数(如parseNumber)编写单元测试,输入特定字符串,验证输出的Token是否正确。这能极大提升开发效率和代码质量。
  4. 可视化:对于复杂的逻辑,可以手工画一下状态转换图,帮助理清思路。例如,识别浮点数的状态图:开始 -> 整数部分 -> [遇到'.'] -> 小数部分 -> [遇到'e/E'] -> 指数部分

6. 性能优化与扩展方向

一个基础的词法分析器完成后,可以考虑以下优化和扩展,使其更专业、更实用。

6.1 从文件到内存的读取优化

一次读取整个文件或大块数据到内存缓冲区,比反复调用fgetc()效率高得多。可以修改Lexer结构,增加char* source;int pos;字段,指向内存中的源代码字符串和当前位置。

6.2 关键字查找优化

当关键字数量增多时,线性查找 (strcmp循环) 效率低。可以使用完美哈希或简单的字典树来加速。例如,根据首字母快速定位到一个小的子集。

6.3 错误处理与恢复机制增强

目前的实现遇到错误可能就返回TK_ERROR。更完善的机制应包括:

  • 错误信息:记录错误类型(未知字符、未闭合字符串等)和精确位置。
  • 错误恢复:跳过出错的词素,尝试从下一个可能的安全点(如分号、右大括号后)继续分析,以便在一次运行中报告多个错误。
  • 恐慌模式恢复:一种简单的恢复策略,当遇到错误时,不断丢弃输入符号,直到找到一个同步词法单元(如分号)为止。

6.4 与语法分析器的接口设计

词法分析器通常不是独立运行的,它的输出(Token流)会喂给语法分析器(Parser)。一个良好的接口是提供一个nextToken()函数(我们已实现),Parser反复调用它。有时Parser需要“预读”一个Token(即查看但不消耗),这称为向前看符号。可以增加一个peekToken()函数,它调用getNextToken但将结果缓存起来,下次调用时直接返回缓存。

6.5 支持更完整的C语言特性

可以逐步添加对以下内容的支持:

  • 所有C语言关键字switch,case,typedef,struct等)。
  • 完整的常量类型:长整型后缀(L,LL)、无符号后缀(U)、浮点后缀(f,F,l,L)、十六进制/八进制常量、宽字符/字符串常量(L'x',L"str")。
  • 三字符组和双字符组:现代编译器默认可能不支持,但了解其历史也有意义。
  • 预处理指令的初步过滤:虽然不展开宏,但可以识别#开头的行并特殊处理(通常是忽略或传递给后续阶段)。

7. 常见问题排查与解决实录

在实际编码和测试中,你几乎一定会遇到下面这些问题。这里记录了我的排查思路和解决方法。

问题1:浮点数1..5识别错误或漏识别。

  • 现象1.被识别为整数1和一个点号分隔符;.5可能被识别为点号和整数5。
  • 原因parseNumber函数逻辑只处理了“数字+小数点+数字”的模式。
  • 解决:修改数字识别逻辑。当遇到小数点时,即使后面没有数字,只要前面有数字(1.),也应识别为浮点数。当首字符就是小数点时,需要进入浮点数识别路径。这需要更精细的状态控制。

问题2:注释///*导致后续Token错乱。

  • 现象:注释后的代码没有被正确识别。
  • 原因:没有在词法分析器的主循环中跳过注释。注释内容被当作了代码字符处理。
  • 解决:在getNextToken函数开头跳过空白字符的循环中,加入对/字符的判断。如果遇到/,预读下一个字符。如果是/,则消耗字符直到行尾;如果是*,则消耗字符直到遇到*/。注意处理未闭合的块注释错误。

问题3:字符串常量中的转义序列\"被错误地当作字符串结束。

  • 现象"He said, \"Hi!\""在第一个\"处就被认为字符串结束了。
  • 原因:在parseString中,判断字符串结束的条件是遇到",但没有检查这个"前面是否有转义符\
  • 解决:在循环中,当当前字符是\时,进行转义处理,将下一个字符(即使是")作为普通字符放入缓冲区,并跳过这两个字符。

问题4:标识符和关键字冲突,但关键字表未覆盖所有。

  • 现象main被识别为标识符,这没错。但如果你希望把main也当作特殊关键字处理呢?
  • 原因:关键字表是固定的。
  • 解决:关键字表的设计取决于你的语言定义。在C语言中,main不是关键字,而是预定义的标识符。你的分析器行为是正确的。如果你想把它当作特殊Token,只需将其加入关键字表即可。这引出了一个重要概念:保留字(语言保留,不能作它用)和预定义标识符(有特殊含义但理论上可重写,如printf)的区别。

问题5:内存泄漏。

  • 现象:程序长时间运行或分析大文件后,内存占用持续增长。
  • 原因strdup为标识符和字符串常量的值分配了内存,但在Token使用后(或分析器销毁时)没有释放。
  • 解决
    1. 为Token增加一个销毁函数freeToken(Token* t),如果Token类型是TK_IDENTTK_STRING,则free(t.value.strVal)
    2. 在语法分析器消费完一个Token后调用此函数。
    3. 或者在词法分析器内部维护一个字符串池,所有重复的字符串只存储一份,在分析器销毁时统一释放池子。这更高效但更复杂。

实现一个词法分析器,就像为编译器搭建起了感知源代码的“眼睛”。这个过程充满了对细节的打磨,从处理一个简单的空格到解析一个复杂的数字字面量,每一步都需要严谨的逻辑。当你看到自己写的程序能将一行行代码准确无误地拆分成一个个有意义的Token时,那种对底层原理豁然开朗的感觉,是单纯学习理论无法比拟的。这个项目代码虽然只有几百行,但它为你打开了一扇通往编译器和语言设计世界的大门。你可以尝试用它去解析一些更复杂的代码片段,或者挑战一下自己,增加对更多C语言特性的支持,比如位运算符、复合赋值运算符(+=)等。每解决一个边界情况,你对这门语言的理解就会加深一分。