跳转至

02-预处理与词法分析

重要性:⭐⭐⭐⭐⭐ 实用度:⭐⭐⭐⭐ 学习时间:2天 必须掌握:是


为什么学这一章?

预处理和词法分析是编译的最前端阶段。预处理器处理宏替换、文件包含等指令,而词法分析器(Lexer)将源代码字符流转化为有意义的记号(Token)序列。

学完这一章,你将能够: - ✅ 掌握C/C++预处理器的完整工作流程 - ✅ 理解词法分析的原理与正则表达式 - ✅ 手写一个简单的词法分析器 - ✅ 理解有限自动机在词法分析中的应用


📖 核心概念

1. 预处理阶段

C/C++编译器在真正编译之前,会先执行预处理。预处理器是一个文本替换工具。

Text Only
┌─────────────────────────────────────────────────────────────┐
│                    预处理器的工作                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  源文件 (.c/.cpp)                                            │
│    │                                                        │
│    ├── #include 处理 → 展开头文件内容                        │
│    ├── #define 处理  → 宏替换                                │
│    ├── #ifdef 处理   → 条件编译                              │
│    ├── #pragma 处理  → 编译器指令                            │
│    └── 注释删除      → 去除所有注释                          │
│    │                                                        │
│    ▼                                                        │
│  预处理后文件 (.i)                                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

实验:观察预处理输出

C
// preprocess_demo.c
#include <stdio.h>

#define MAX_SIZE 100
#define SQUARE(x) ((x) * (x))

#ifdef DEBUG
    #define LOG(msg) printf("[DEBUG] %s\n", msg)
#else
    #define LOG(msg)
#endif

int main() {
    int arr[MAX_SIZE];
    int result = SQUARE(5 + 1);
    LOG("程序开始");
    printf("result = %d\n", result);
    return 0;
}
Bash
# 只执行预处理,输出到 .i 文件
gcc -E preprocess_demo.c -o preprocess_demo.i

# 观察宏展开结果
# SQUARE(5+1) → ((5+1) * (5+1)) = 36,而非 5+1*5+1 = 11

宏的常见陷阱

C
// 宏陷阱示例
#include <stdio.h>

// 陷阱1:缺少括号的宏
#define BAD_SQUARE(x) x * x
#define GOOD_SQUARE(x) ((x) * (x))

// 陷阱2:带副作用的宏参数
#define MAX(a, b) ((a) > (b) ? (a) : (b))

int main() {
    // 陷阱1演示
    printf("BAD:  %d\n", BAD_SQUARE(3 + 1));   // 3+1*3+1 = 7,不是16
    printf("GOOD: %d\n", GOOD_SQUARE(3 + 1));   // ((3+1)*(3+1)) = 16

    // 陷阱2演示
    int a = 5, b = 3;
    int m = MAX(a++, b++);
    // a++被执行了两次!结果不可预期
    printf("a=%d, b=%d, max=%d\n", a, b, m);

    return 0;
}

2. 词法分析概述

词法分析器(Lexer / Scanner / Tokenizer)的任务是:将字符流转换为记号(Token)流

Text Only
┌─────────────────────────────────────────────────────────────┐
│                    词法分析过程                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  输入字符流:   i n t   m a i n ( ) { \n                    │
│                                                             │
│  输出Token流:                                               │
│  ┌──────────┬──────────┬──────────┬──────────┬──────────┐  │
│  │ KEYWORD  │  IDENT   │  LPAREN  │  RPAREN  │  LBRACE  │  │
│  │  "int"   │  "main"  │   "("    │   ")"    │   "{"    │  │
│  └──────────┴──────────┴──────────┴──────────┴──────────┘  │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Token的分类

Token类型 示例 说明
关键字(Keyword) int, if, return 语言保留字
标识符(Identifier) main, count, x 用户定义的名字
字面量(Literal) 42, 3.14, "hello" 常量值
运算符(Operator) +, -, ==, && 运算符号
分隔符(Delimiter) (, ), {, ; 分隔符号

3. 正则表达式与Token定义

编译器使用正则表达式来定义每种Token的模式:

Text Only
标识符:  [a-zA-Z_][a-zA-Z0-9_]*
整数:    [0-9]+
浮点数:  [0-9]+\.[0-9]+
字符串:  "([^"\\]|\\.)*"
运算符:  \+|\-|\*|\/|==|!=|<=|>=

4. 有限自动机(DFA/NFA)

词法分析器的本质是一个有限状态自动机

Text Only
┌─────────────────────────────────────────────────────────────┐
│            识别标识符的DFA状态转换图                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│         字母/下划线         字母/数字/下划线                  │
│   ┌───┐ ──────────→ ┌───┐ ──────────→ ┌───┐              │
│   │ S │              │ A │    (循环)    │ A │              │
│   └───┘              └───┘ ←────────── └───┘              │
│   起始状态           接受状态                                 │
│                                                             │
│  S:初始状态                                                 │
│  A:已读取至少一个字母/下划线,为接受状态                     │
│  遇到非字母数字下划线字符时停止,输出Token                    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

5. 手写词法分析器

下面用C语言实现一个简单但完整的词法分析器:

C
// simple_lexer.c - 简单词法分析器
#include <stdio.h>  // 引入头文件
#include <stdlib.h>
#include <string.h>
#include <ctype.h>

// Token类型枚举
typedef enum {
    TOKEN_INT,        // int关键字
    TOKEN_RETURN,     // return关键字
    TOKEN_IF,         // if关键字
    TOKEN_ELSE,       // else关键字
    TOKEN_IDENTIFIER, // 标识符
    TOKEN_NUMBER,     // 数字
    TOKEN_PLUS,       // +
    TOKEN_MINUS,      // -
    TOKEN_STAR,       // *
    TOKEN_SLASH,      // /
    TOKEN_ASSIGN,     // =
    TOKEN_EQ,         // ==
    TOKEN_LPAREN,     // (
    TOKEN_RPAREN,     // )
    TOKEN_LBRACE,     // {
    TOKEN_RBRACE,     // }
    TOKEN_SEMICOLON,  // ;
    TOKEN_EOF,        // 文件结束
    TOKEN_UNKNOWN     // 未知
} TokenType;

// Token结构
typedef struct {
    TokenType type;
    char value[256];
    int line;
    int column;
} Token;

// 词法分析器状态
typedef struct {
    const char *source;  // 指针:存储变量的内存地址
    int pos;
    int line;
    int column;
} Lexer;

// 关键字表
static const char *keywords[] = {"int", "return", "if", "else"};
static const TokenType keyword_types[] = {
    TOKEN_INT, TOKEN_RETURN, TOKEN_IF, TOKEN_ELSE
};
#define NUM_KEYWORDS 4

// 初始化词法分析器
void lexer_init(Lexer *lex, const char *source) {
    lex->source = source;
    lex->pos = 0;
    lex->line = 1;
    lex->column = 1;
}

// 获取当前字符
char current(Lexer *lex) {
    return lex->source[lex->pos];
}

// 前进一个字符
void advance(Lexer *lex) {
    if (current(lex) == '\n') {
        lex->line++;
        lex->column = 1;
    } else {
        lex->column++;
    }
    lex->pos++;
}

// 跳过空白字符和注释
void skip_whitespace(Lexer *lex) {
    while (current(lex)) {
        if (isspace(current(lex))) {
            advance(lex);
        } else if (current(lex) == '/' && lex->source[lex->pos + 1] == '/') {
            // 单行注释
            while (current(lex) && current(lex) != '\n') advance(lex);
        } else {
            break;
        }
    }
}

// 读取标识符或关键字
Token read_identifier(Lexer *lex) {
    Token tok;
    tok.line = lex->line;
    tok.column = lex->column;
    int start = lex->pos;

    while (isalnum(current(lex)) || current(lex) == '_') {
        advance(lex);
    }

    int len = lex->pos - start;
    strncpy(tok.value, lex->source + start, len);
    tok.value[len] = '\0';

    // 检查是否为关键字
    tok.type = TOKEN_IDENTIFIER;
    for (int i = 0; i < NUM_KEYWORDS; i++) {
        if (strcmp(tok.value, keywords[i]) == 0) {
            tok.type = keyword_types[i];
            break;
        }
    }
    return tok;
}

// 读取数字
Token read_number(Lexer *lex) {
    Token tok;
    tok.type = TOKEN_NUMBER;
    tok.line = lex->line;
    tok.column = lex->column;
    int start = lex->pos;

    while (isdigit(current(lex))) advance(lex);

    int len = lex->pos - start;
    strncpy(tok.value, lex->source + start, len);
    tok.value[len] = '\0';
    return tok;
}

// 获取下一个Token
Token next_token(Lexer *lex) {
    skip_whitespace(lex);
    Token tok;
    tok.line = lex->line;
    tok.column = lex->column;

    char c = current(lex);
    if (c == '\0')   { tok.type = TOKEN_EOF;  strcpy(tok.value, "EOF"); return tok; }
    if (isalpha(c) || c == '_') return read_identifier(lex);
    if (isdigit(c))             return read_number(lex);

    advance(lex);
    tok.value[0] = c; tok.value[1] = '\0';
    switch (c) {
        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 '(': tok.type = TOKEN_LPAREN;    break;
        case ')': tok.type = TOKEN_RPAREN;    break;
        case '{': tok.type = TOKEN_LBRACE;    break;
        case '}': tok.type = TOKEN_RBRACE;    break;
        case ';': tok.type = TOKEN_SEMICOLON; break;
        case '=':
            if (current(lex) == '=') {
                advance(lex);
                tok.type = TOKEN_EQ;
                strcpy(tok.value, "==");
            } else {
                tok.type = TOKEN_ASSIGN;
            }
            break;
        default: tok.type = TOKEN_UNKNOWN; break;
    }
    return tok;
}

// 测试主函数
int main() {
    const char *code = "int main() {\n"
                       "    int x = 42;\n"
                       "    return x + 1;\n"
                       "}\n";

    printf("源代码:\n%s\n", code);
    printf("Token序列:\n");
    printf("%-4s %-16s %-14s %s\n", "行", "类型", "值", "位置");
    printf("──────────────────────────────────────────\n");

    Lexer lex;
    lexer_init(&lex, code);
    Token tok;
    do {
        tok = next_token(&lex);
        printf("%-4d %-16d %-14s (%d:%d)\n",
               tok.line, tok.type, tok.value, tok.line, tok.column);
    } while (tok.type != TOKEN_EOF);

    return 0;
}
Bash
gcc simple_lexer.c -o simple_lexer && ./simple_lexer  # &&前一个成功才执行后一个;||前一个失败才执行

🔍 深入理解

Flex/Lex工具

工业级编译器通常使用词法分析器生成工具:

Text Only
/* simple.l - Flex词法规则文件 */
%{
#include <stdio.h>
%}

%%
"int"       { printf("KEYWORD: %s\n", yytext); }
"return"    { printf("KEYWORD: %s\n", yytext); }
[a-zA-Z_][a-zA-Z0-9_]*  { printf("IDENT: %s\n", yytext); }
[0-9]+      { printf("NUMBER: %s\n", yytext); }
"+"         { printf("PLUS\n"); }
"="         { printf("ASSIGN\n"); }
"=="        { printf("EQ\n"); }
";"         { printf("SEMICOLON\n"); }
[ \t\n]     { /* 跳过空白 */ }
.           { printf("UNKNOWN: %s\n", yytext); }
%%

int main() { yylex(); return 0; }
int yywrap() { return 1; }
Bash
flex simple.l
gcc lex.yy.c -o lexer -lfl
echo "int x = 42;" | ./lexer  # |管道:将前一命令的输出作为后一命令的输入

💡 面试常见问题

Q1:预处理器中 #define 和 const 的区别?

#define 是文本替换,发生在预处理阶段,无类型检查,不占存储空间;const 是编译器处理的常量,有类型检查,可以调试。现代C++推荐使用 const / constexpr

Q2:词法分析器如何区分关键字和标识符?

:词法分析器首先将所有字母开头的连续字符识别为"标识符",然后查关键字表(哈希表或排序数组)判断是否为关键字。有些设计在DFA中直接区分。

Q3:NFA和DFA有什么区别?

:NFA(非确定有限自动机)允许ε转换和多个转换,更易构造但执行慢;DFA(确定有限自动机)每个状态对每个输入只有一个转换,执行快但状态可能更多。通过子集构造法可将NFA转换为DFA。

Q4:词法分析阶段会报哪些错误?

:词法错误包括非法字符(如中文标点)、未闭合的字符串/注释、非法数字格式(如 0x3G)等。大部分结构错误留给语法分析阶段处理。

Q5:为什么编译器要分词法分析和语法分析两个阶段?

:模块化设计的好处:①词法规则用正则表达式描述更简洁高效;②语法分析器不必处理空白和注释;③词法分析可以独立优化(如用DFA实现O(n)扫描);④便于维护和扩展。


📝 本章小结

Text Only
┌─────────────────────────────────────────────┐
│              本章核心知识点                    │
├─────────────────────────────────────────────┤
│                                             │
│  1. 预处理:宏展开、文件包含、条件编译       │
│  2. 词法分析:字符流 → Token流               │
│  3. Token分类:关键字、标识符、字面量等      │
│  4. 正则表达式定义Token模式                  │
│  5. DFA/NFA是词法分析的理论基础             │
│  6. 手写Lexer / 使用Flex生成                │
│                                             │
└─────────────────────────────────────────────┘

下一章03-语法分析与AST - 将Token流组织成语法树