跳转到内容

Lesson 20: 预处理器实现 CNB

练习任务

本课包含三个递进练习,逐步构建一个简易 C 预处理器——从字符级别的注释去除,到单词级别的词法切分,再到完整的表驱动状态机实现宏展开:

练习 1(20a_delcomment 去注释):实现一个 7 状态的状态机,逐字符读取 C 源代码,去除所有注释(/* ... */ 块注释和 // ... 行注释),同时正确处理字符常量 '...' 中的转义字符,避免误删。

练习 2(20b_getword 词法切分):实现 getword() 函数,从 stdin 每次读取一个「词」——遇到字母就收集整个单词,遇到非字母就返回该单字符。关键技巧:用 ungetc() 把多读的字符「退回去」。

练习 3(20c_expdefine 宏展开):实现 7 个动作函数(act_print_wordact_save_to_bufact_print_buf_and_wordact_save_wordact_get_macro_nameact_get_macro_valueact_null),配合已有的 state_transition[][]act_table[][] 二维数组,构建一个表驱动的状态机,完成 #define 宏定义的存储和替换。

提示:本课的核心是「状态机」思想——把复杂问题分解为「状态 + 输入 → 动作 + 新状态」的规则表。练习 1 用 if-else 链实现状态机(直观但冗长),练习 3 用二维数组表驱动实现(简洁但抽象)。两个版本做的是同一件事,但表驱动版本更容易扩展——新增一个状态只需加一行表数据,不用修改逻辑代码。回顾 Lesson 13(switch-case)中的状态机讨论和 Lesson 18(myprintf)中的函数指针用法——本课将两者结合,构建完整的表驱动架构。

核心知识点

  • 状态机(FSM)设计 — 状态 + 输入类型 → 动作 + 下一状态,用 if-else 链或二维数组表实现
  • 字符分类 — get_input_type() 将字符映射为数字编码(/→1, *→2, \n→3, '→4, \→5, 其他→0)
  • 去注释状态机 — 7 个状态处理块注释 /* */、行注释 //、字符常量 '...' 及转义 \
  • 词法切分 — getword() 每次读取一个 token,用 isalpha()/isalnum() 判断字母数字
  • ungetc() 回退 — 把多读的一个字符「退回」stdin 缓冲区,确保下次读取正确
  • 表驱动状态机 — state_transition[state][input] 二维数组存下一状态,act_table[state][input] 二维函数指针数组存动作
  • 函数指针数组 — typedef void (*PF)(void); PF act_table[8][5] 将函数作为表元素
  • struct macro 宏表 — { char name[64]; char value[64]; } 结构体数组存储已定义的宏
  • 宏查找替换 — act_print_word() 遍历宏表,找到匹配则输出宏值,否则原样输出
  • 条件编译状态机 — #if 1/#if 0 → 根据表达式值决定输出或跳过,#endif 结束条件块
  • debug 调试宏 — #define debug(fmt, args...) fprintf(stderr, fmt, ##args)##args 处理零参数情况
  • 预处理流水线 — 去注释 → 词法切分 → 宏展开 → 条件编译,分阶段处理

代码框架

练习 1:去注释状态机

20a_delcomment.c
c
#include <stdio.h>

int get_input_type(char c)
{
    if (c == '/')
        return 1;
    if (c == '*')
        return 2;
    if (c == '\n')
        return 3;
    if (c == '\'')
        return 4;
    if (c == '\\')
        return 5;
    return 0;
}

int main(void)
{
    int state = 0;

    while (1)
    {
        char c;
        int input;

        c = getchar();
        if (c == EOF)
            break;

        input = get_input_type(c);

        // 在这里实现 7 状态的状态机:
        //   state 0: 正常代码 → 遇 '/' → state 1, 遇 '\'' → state 5, 否则 putchar
        //   state 1: 遇到 '/' → 遇 '*' → state 2 (块注释), 遇 '/' → state 4 (行注释)
        //                      → 否则输出之前暂存的 '/' 和当前字符, 回 state 0
        //   state 2: 块注释中 → 遇 '*' → state 3, 其余留在 state 2 (不输出)
        //   state 3: 块注释遇 '*' → 遇 '/' → state 0 (注释结束), 遇 '*' 留在 state 3
        //                           → 否则回 state 2
        //   state 4: 行注释中 → 遇 '\n' → state 0 (输出换行), 其余不输出
        //   state 5: 字符常量 '...' → 遇 '\'' → state 0 (输出), 遇 '\\' → state 6
        //   state 6: 转义中 → 无论什么字符都输出, 回 state 5
    }

    return 0;
}

练习 2:词法切分 getword

20b_getword.c
c
#include <stdio.h>
#include <string.h>
#include <ctype.h>

// 已有的 get_input_type 和 action 函数(20c 共用)
int get_input_type(char *word) { /* 根据 word 返回 0~4 */ }
void act_print_word(void) { /* 查找宏表,替换或原样输出 */ }
void act_save_to_buf(void) { /* strcat(buf, word) */ }
void act_print_buf_and_word(void) { /* 输出 buf+word,清空 buf */ }
void act_save_word(void) { /* strcat(word_buf, word); strcat(buf, word) */ }
void act_get_macro_name(void) { /* 保存宏名 */ }
void act_get_macro_value(void) { /* 保存宏值,macro_counter++ */ }
void act_null(void) {}

// 状态转换表和动作表(已提供)
enum { s0 = 0, s1, s2, s3, s4, s5, s6, s7 };
int state_transition[8][5] = { /* ... 已提供 ... */ };
typedef void (*PF)(void);
PF act_table[8][5] = { /* ... 已提供 ... */ };

char word[64];
char word_buf[64];
char buf[128];
struct macro { char name[64]; char value[64]; } macros[16];
int macro_counter = 0;

void getword(char *word)
{
    // 在这里实现词法切分:
    //   1. char c = getchar();
    //   2. if (c == EOF) { *word = '\0'; return; }
    //   3. if (!isalpha(c)) { *word++ = c; *word = '\0'; return; }
    //      → 非字母直接返回单字符
    //   4. do { *word++ = c; c = getchar(); }
    //      while (isalnum(c) || c == '_');
    //      → 是字母则连续读取直到遇到非字母数字下划线
    //   5. ungetc(c, stdin);  → 把多读的字符退回
    //   6. *word = '\0';
}

int main(void)
{
    int state = 0;

    while (1)
    {
        int input;
        void (*pf)(void);

        getword(word);
        if (strcmp(word, "") == 0)
            break;

        input = get_input_type(word);
        pf = act_table[state][input];
        pf();
        state = state_transition[state][input];
    }

    return 0;
}

练习 3:宏展开的动作函数

20c_expdefine.c
c
#include <stdio.h>
#include <string.h>
#include <ctype.h>

int get_input_type(char *word)
{
    if (strcmp(word, " ") == 0 || strcmp(word, "\t") == 0)
        return 0;
    if (strcmp(word, "#") == 0)
        return 1;
    if (strcmp(word, "define") == 0)
        return 2;
    if (strcmp(word, "\n") == 0)
        return 4;
    return 3;
}

char word[64];
char word_buf[64];
char buf[128];

struct macro { char name[64]; char value[64]; } macros[16];
int macro_counter = 0;

void act_print_word(void)
{
    // 在这里实现:
    //   遍历 macros[0..macro_counter-1]
    //   如果 strcmp(word, macros[i].name) == 0:
    //       printf("%s", macros[i].value); return;
    //   否则 printf("%s", word);
}

void act_save_to_buf(void)
{
    // 在这里实现: strcat(buf, word);
}

void act_print_buf_and_word(void)
{
    // 在这里实现:
    //   printf("%s", buf); printf("%s", word);
    //   strcpy(buf, "");
}

void act_save_word(void)
{
    // 在这里实现:
    //   strcat(word_buf, word); strcat(buf, word);
}

void act_get_macro_name(void)
{
    // 在这里实现:
    //   strcpy(macros[macro_counter].name, word_buf);
    //   strcpy(word_buf, "");
}

void act_get_macro_value(void)
{
    // 在这里实现:
    //   strcpy(macros[macro_counter].value, word_buf);
    //   strcpy(word_buf, ""); strcpy(buf, ""); strcpy(word, "");
    //   macro_counter++;
}

void act_null(void) {}

// 状态转换表(已提供)
enum { s0 = 0, s1, s2, s3, s4, s5, s6, s7 };
int state_transition[8][5] = {
    { s0, s1, s7, s7, s0 },
    { s1, s0, s2, s0, s0 },
    { s3, s0, s0, s0, s0 },
    { s3, s4, s4, s4, s0 },
    { s5, s5, s4, s0, s0 },
    { s5, s6, s6, s6, s0 },
    { s6, s6, s6, s6, s0 },
    { s7, s7, s7, s7, s0 },
};

// 动作表(已提供)
typedef void (*PF)(void);
PF act_table[8][5] = {
    { act_print_word, act_save_to_buf, act_print_word, act_print_word, act_print_word },
    { act_save_to_buf, act_print_buf_and_word, act_save_to_buf, act_print_buf_and_word, act_print_buf_and_word },
    { act_save_to_buf, act_print_buf_and_word, act_print_buf_and_word, act_print_buf_and_word, act_print_buf_and_word },
    { act_save_to_buf, act_save_word, act_save_word, act_save_word, act_print_buf_and_word },
    { act_get_macro_name, act_get_macro_name, act_save_word, act_print_buf_and_word, act_get_macro_value },
    { act_null, act_save_word, act_save_word, act_save_word, act_get_macro_value },
    { act_save_word, act_save_word, act_save_word, act_save_word, act_get_macro_value },
    { act_print_word, act_print_word, act_print_word, act_print_word, act_print_word },
};

void getword(char *word) { /* 练习 2 已实现 */ }

int main(void)
{
    int state = 0;

    while (1)
    {
        int input;
        void (*pf)(void);

        getword(word);
        if (strcmp(word, "") == 0)
            break;

        input = get_input_type(word);
        pf = act_table[state][input];
        pf();
        state = state_transition[state][input];
    }

    return 0;
}

填充框架的关键思考:练习 1 的状态机中,state 0 遇到 / 后为什么要进入 state 1 而不是立即判断注释?state 5(字符常量中)为什么要单独处理转义字符 \?练习 2 的 getword 中,ungetc() 把多读的字符退回后,下一次 getword 调用能否正确读取它?练习 3 的动作表中,act_print_word 被放在哪些 (state, input) 组合中?为什么有些状态遇到任何输入都执行 act_print_word

TIP

先不要往下翻看参考解答。建议从练习 1 的去注释状态机开始——这是最直观的状态机入门,用 if-else 写出所有状态转换后,你会发现表驱动版本(练习 3)其实做的是同一件事,只是把 if-else 换成了查表。做完后思考:如果要新增一个预处理功能(如 #include 文件包含),表驱动版本需要改动多少代码?


深度讲解

1. 状态机基础:从字符分类到去注释

1.1 什么是状态机

状态机(Finite State Machine, FSM)是计算机科学中最基础的计算模型之一。它由四个要素构成:

状态机 = (状态, 输入, 动作, 状态转移)

   ┌──────────────────────────────────┐
  当前状态 ──→ [查表] ──→ 动作    │


       └──── 下一状态 ◀─────┘
   └──────────────────────────────────┘

每一个处理周期:

  1. 读取一个输入
  2. 根据当前状态 + 输入,查表得到动作下一状态
  3. 执行动作,更新状态,进入下一周期

本课中状态机的输入是一个个字符(练习 1)或一个个单词(练习 2/3),输出是对这些输入的处理(输出、跳过、暂存等)。

1.2 字符分类:get_input_type

在去注释问题中,不是所有字符都同等重要——只有少数几个字符会触发状态转换。get_input_type() 把字符「分类」为数字编码,后续状态机只关心编码而非字符本身:

get_input_type.c
c
int get_input_type(char c)
{
    if (c == '/')   return 1;   // 可能开始注释
    if (c == '*')   return 2;   // 可能结束块注释
    if (c == '\n')  return 3;   // 可能结束行注释
    if (c == '\'')  return 4;   // 字符常量边界
    if (c == '\\')  return 5;   // 转义字符
    return 0;                   // 普通字符
}

为什么要分类?因为状态机处理的是抽象类型而非具体字符。把字符映射为 0~5 的整数后,状态转换表就变成了一个二维整数数组,逻辑大大简化。

字符分类的编码语义:
  input 0: 普通字符 (字母、数字、空格、标点等)
  input 1: '/'  —— 注释开始候选
  input 2: '*'  —— 块注释结束候选
  input 3: '\n' —— 行结束
  input 4: '\'' —— 字符常量定界符
  input 5: '\\' —— 转义前缀

NOTE

字符分类函数是状态机设计的第一步。类似的思路出现在编译器的词法分析阶段——首先把字符流分类(字母、数字、运算符、空白),然后根据分类进入不同的状态处理。回顾 Lesson 21(词法分析器)中更完整的 token 分类体系。

1.3 去注释的 7 状态设计

C 语言有两种注释,加上字符常量和转义字符,一共需要 7 个状态:

state 0: 正常代码 —— 输出字符
state 1: 遇到 '/'  —— 待定:是注释还是除法?
state 2: 块注释中  —— /* ... 等待 */
state 3: 块注释遇* —— /* ... * 等待 / 或继续
state 4: 行注释中  —— // ... 等待 \n
state 5: 字符常量  —— '...' 等待闭合引号
state 6: 转义中    —— '...\ 等待下一个字符
状态转换图:

                    ┌─────────┐
          input=1('/')         input=2('*')
    ┌─→ state0 ──────────→ state1 ──────────→ state2 (块注释)

 input=4(')         │ input=1('/')       │ input=2('*')
    │    ↓                    ↓                    ↓
    │  state5 (字符常量)    state4 (行注释)      state3 (遇*)
    │    │                    │                    │
    │    │ input=5(\)         │ input=3(\n)        │ input=1('/')
    │    ↓                    ↓                    ↓
    │  state6 (转义中)      state0 (输出\n)     state0 (注释结束)
    │    │                                        │
    │    │ 任意字符                                │ input=2('*')
    │    └──→ state5                              └──→ state3 (留在遇*)
    │                                             │
    │    input=4(')                                 input=0(其他)
    └──── state0 (闭合)                           └──→ state2 (回块注释)

关键设计决策:

  • state 1 是「待定」状态:遇到 / 后不立即判断,等下一个字符。如果是 *→块注释,如果是 /→行注释,否则输出之前暂存的 /
  • state 3 处理块注释的「假结束」/* ... * ... */ 中间的 * 不是结束标记,必须紧跟 / 才是真正的 */
  • state 5/6 处理字符常量内的注释标记'/*' 不应该被当作注释开始——字符常量中的 /* 是普通字符。

IMPORTANT

状态机设计的核心技巧:把「需要等待下一个输入才能判断」的情况独立为一个状态。state 1(遇到 /)和 state 3(块注释中遇到 *)都是「待定」状态——当前字符不足以做出决定,必须结合下一个字符。

1.4 if-else 链实现

练习 1 用 if-else 链实现状态机,每个分支判断 (state, input) 组合:

delcomment_fsm.c
c
int state = 0;

while (1) {
    char c = getchar();
    if (c == EOF) break;
    int input = get_input_type(c);

    if (state == 0 && input == 1)           // 正常代码遇 '/'
    {
        state = 1;                          // 进入待定状态
    }
    else if (state == 0 && input == 4)      // 正常代码遇 '\''
    {
        state = 5;
        putchar(c);                         // 字符常量正常输出
    }
    else if (state == 1 && input == 1)      // "//" → 行注释
    {
        state = 4;
    }
    else if (state == 1 && input == 2)      // "/*" → 块注释
    {
        state = 2;
    }
    else if (state == 2 && input == 2)      // 块注释中遇 '*'
    {
        state = 3;                          // 可能结束
    }
    else if (state == 3 && input == 1)      // "*/" → 注释结束
    {
        state = 0;
    }
    else if (state == 3 && input == 0)      // "*?" 不是 "*/"
    {
        state = 2;                          // 回块注释
    }
    else if (state == 4 && input == 3)      // 行注释遇 '\n'
    {
        state = 0;
        putchar(c);                         // 输出换行
    }
    else if (state == 5 && input == 4)      // 字符常量闭合
    {
        state = 0;
        putchar(c);
    }
    else if (state == 5 && input == 5)      // 字符常量内遇 '\'
    {
        state = 6;                          // 进入转义状态
        putchar(c);
    }
    else if (state == 6)                    // 转义中:任意字符都输出并回 state 5
    {
        state = 5;
        putchar(c);
    }
    else if (state == 0 || state == 5)      // 普通字符:正常输出
        putchar(c);
}
执行跟踪: 输入 "int x; /* comment */\nint y;"

字符        input  state_before  action         state_after
'i'         0      0             putchar('i')   0
'n'         0      0             putchar('n')   0
't'         0      0             putchar('t')   0
' '         0      0             putchar(' ')   0
'x'         0      0             putchar('x')   0
';'         0      0             putchar(';')   0
' '         0      0             putchar(' ')   0
'/'         1      0             (不输出)       1
'*'         2      1             (不输出)       2    ← 进入块注释
' '         0      2             (不输出)       2
'c'         0      2             (不输出)       2
...
'*'         2      2             (不输出)       3    ← 遇 *
'/'         1      3             (不输出)       0    ← */ 注释结束
'\n'        3      0             putchar('\n')  0
'i'         0      0             putchar('i')   0
...
输出: "int x; \nint y;"

NOTE

注意 state 1 的设计:当正常代码(state 0)遇到 / 时,先进入 state 1 而不输出任何字符。如果下一个字符确认是注释(*/),则之前暂存的 / 也被吞掉;如果是其他字符(如除法 a/b),则在后续的 else-if 分支中需要把暂存的 / 和当前字符一起输出。这是「前瞻一个字符」(lookahead)的经典实现。

1.5 字符常量与转义的边界处理

如果不去除注释时不处理字符常量,以下代码会被错误处理:

char_constant_comment.c
c
char c = '"';    // 双引号在字符常量中
char slash = '\\';  // 反斜杠本身
int div = a / b;    // 除法运算符,不是注释

字符常量内的 /*// 不应该被当作注释,所以状态机需要用 state 5 和 state 6 来跳过字符常量内的内容。同样,转义字符 \' 在字符常量内代表一个单引号字符而非字符常量结束——state 6 处理的就是这种情况:

escape_in_char_constant.c
c
// 输入: '\''
// 字符: '  \  '  '
// state: 0→5  5→6  6→5  5→0
// output: '  \  '  '    (全部输出)

// 输入: '\\'
// 字符: '  \  \  '
// state: 0→5  5→6  6→5  5→0
// output: '  \  \  '    (全部输出)
转义处理的状态转换:

state 5 (字符常量中):
  input 4 ('\'') → state 0 (字符常量结束)
  input 5 ('\\') → state 6 (进入转义)

state 6 (转义中):
  任意 input     → state 5 (转义完成,回到字符常量)
  (包括 input 4 ('\'') → 因为被转义,不结束字符常量)

WARNING

本课的字符常量处理只覆盖了单引号 '...'。双引号字符串 "..." 中同样可能出现 /*//,完整版本的状态机也需要处理双引号。课堂讨论中会展开这个扩展。


2. 词法切分:从字符流到单词流

2.1 为什么需要词法切分

练习 1 的状态机以字符为单位处理输入,适合「按字符判断」的场景(注释的开始/结束由个别字符决定)。但 #define 宏展开需要以单词为单位——我们需要知道 #define 是一个预处理指令,而 PI 是一个宏名,3.14 是宏值。

词法切分(lexical analysis / tokenization)就是把字符流转换为单词(token)流:

字符流:  # d e f i n e   P I   3 . 1 4  \n
 getword()
单词流:  "#" "define" "PI" "3.14" "\n"

2.2 getword 实现

getword_implementation.c
c
void getword(char *word)
{
    char c;

    c = getchar();

    if (c == EOF)                   // 文件结束
    {
        *word = '\0';               // 返回空串表示结束
        return;
    }

    if (!isalpha(c))                // 非字母: 单字符 token
    {
        *word++ = c;
        *word = '\0';
        return;
    }

    do                              // 字母开头: 收集整个单词
    {
        *word++ = c;
        c = getchar();
    } while (isalnum(c) || c == '_');

    ungetc(c, stdin);               // 退回多读的字符
    *word = '\0';
}

三种情况分析

情况 1: c == EOF
 返回空串,调用者检查 strcmp(word, "") == 0 来终止循环

情况 2: !isalpha(c)  (空格、#、\n、数字开头等)
 只返回这一个字符作为 token
 例如: '#' word = "#", '\n' word = "\n", ' ' word = " "

情况 3: isalpha(c)  (字母开头)
 连续读取字母、数字、下划线,直到遇到不属于标识符的字符
 例如: "define" word = "define", "PI2_MAX" word = "PI2_MAX"

2.3 ungetc:把字符「退回去」

getword 中最关键的技巧是 ungetc(c, stdin)——把多读的一个字符退回 stdin 的输入缓冲区,让下一次 getchar() 能重新读到它。

为什么需要 ungetc?

读取 "PI" 这个单词的过程:
  1. getchar()  'P'  (isalpha ✓, 进入 do-while)
  2. getchar()  'I'  (isalpha ✓, 继续)
  3. getchar()  ' '  (isalpha ✗, 循环结束)
  4. ' ' 已经被读走了!如果不退回去,下一个 token 就从 '3' 开始了

  5. ungetc(' ', stdin)  ' ' 退回缓冲区
  6. 下一次 getword: getchar()  ' ' word = " "

没有 ungetc 的情况:
  3. getchar()  ' ' 被吞掉
  4. 下一次 getword: getchar()  '3' word = "3.14"
 丢失了空格,后续的 get_input_type 可能误判!
stdin 缓冲区示意:

读取 "PI 3.14" 时:

初始:  [P][I][ ][3][.][1][4][\n][EOF]...
 读取位置

第1次 getchar: 'P' [P][I][ ][3][.][1][4][\n]...


第2次 getchar: 'I' [P][I][ ][3][.][1][4][\n]...


第3次 getchar: ' ' [P][I][ ][3][.][1][4][\n]...

  isalpha(' ') == false → 退出循环
  ungetc(' ', stdin)   → [P][I][ ][3][.][1][4][\n]...
 退回!

下一次 getchar: ' ' [P][I][ ][3][.][1][4][\n]...
 重新读取

IMPORTANT

ungetc() 是 C 标准库中最容易被忽视但非常重要的函数。它保证了至少能退回一个字符(大多数实现支持更多,但标准只保证一个)。在编译器前端中,ungetc 用于实现「前瞻一个字符」(lookahead)——读到不属于当前 token 的字符时退回去,让下一轮重新处理。这与 Lesson 21(词法分析器)中的 peek 操作是同一原理。


3. 表驱动状态机:从 if-else 到查表

3.1 if-else 链的局限性

练习 1 的去注释状态机用了约 60 行的 if-else 链。它直观但有几个问题:

  1. 代码冗长:每个 (state, input) 组合都需要一个 if 分支
  2. 难以扩展:新增一个状态需要添加多个 if 分支,容易遗漏
  3. 逻辑和配置混杂:状态转换规则和控制流代码混在一起

以去注释为例:7 个状态 × 6 种输入 = 42 种可能的组合,但只有约 14 种有实际的状态转换。if-else 链需要手动列出这 14 种,而其余 28 种「不变」的组合被隐式忽略——但如果漏掉了某个组合,就是 bug。

3.2 表驱动:把规则变成数据

表驱动状态机把「状态转换规则」和「动作规则」从代码中抽离出来,变成两个二维数组:

table_driven_fsm.c
c
// 状态转换表: state_transition[当前状态][输入类型] = 下一状态
int state_transition[8][5] = {
//   空格  #   define  单词  \n
    { s0, s1, s7,     s7,  s0 },   // s0: 正常
    { s1, s0, s2,     s0,  s0 },   // s1: 遇到 #
    { s3, s0, s0,     s0,  s0 },   // s2: #define
    { s3, s4, s4,     s4,  s0 },   // s3: #define 后空白
    { s5, s5, s4,     s0,  s0 },   // s4: 宏名后
    { s5, s6, s6,     s6,  s0 },   // s5: 宏名后空白
    { s6, s6, s6,     s6,  s0 },   // s6: 宏值中
    { s7, s7, s7,     s7,  s0 },   // s7: 非预处理行的单词
};

// 动作表: act_table[当前状态][输入类型] = 要执行的函数指针
typedef void (*PF)(void);
PF act_table[8][5] = {
//   空格             #                     define              单词                \n
    { act_print_word, act_save_to_buf,     act_print_word,     act_print_word,    act_print_word },
    { act_save_to_buf,act_print_buf_and_word,act_save_to_buf,act_print_buf_and_word,act_print_buf_and_word},
    { act_save_to_buf,act_print_buf_and_word,act_print_buf_and_word,act_print_buf_and_word,act_print_buf_and_word},
    { act_save_to_buf,act_save_word,       act_save_word,      act_save_word,     act_print_buf_and_word},
    { act_get_macro_name,act_get_macro_name,act_save_word,     act_print_buf_and_word,act_get_macro_value},
    { act_null,       act_save_word,       act_save_word,      act_save_word,     act_get_macro_value},
    { act_save_word,  act_save_word,       act_save_word,      act_save_word,     act_get_macro_value},
    { act_print_word, act_print_word,      act_print_word,     act_print_word,    act_print_word },
};

有了这两个表,主循环就变得极其简洁:

main_loop_table_driven.c
c
int state = 0;

while (1) {
    getword(word);
    if (strcmp(word, "") == 0) break;

    int input = get_input_type(word);        // 1. 分类输入
    void (*pf)(void) = act_table[state][input];  // 2. 查动作表
    pf();                                    // 3. 执行动作
    state = state_transition[state][input];  // 4. 更新状态
}

整个主循环只有 8 行代码——状态转换和动作逻辑全部由数据表驱动。对比 if-else 版本,表驱动版本的优势立竿见影。

表驱动的执行流程:

getword "define"

get_input_type("define") → 2 (因为 strcmp("define") == 0)

state = s1, input = 2

act_table[s1][2] act_save_to_buf  (执行: strcat(buf, "define"))
state_transition[s1][2] s2        (状态更新: #define)

3.3 函数指针数组:把函数当作数据

表驱动状态机的关键依赖是函数指针——C 语言允许把函数的地址存储在数组中,通过数组下标调用:

function_pointer_array.c
c
typedef void (*PF)(void);          // PF 是「指向 void fn(void) 的函数指针」类型

PF act_table[8][5] = {             // 8×5 的函数指针数组
    { act_print_word, act_save_to_buf, ... },
    ...
};

// 调用: 先取函数指针,再通过指针调用
void (*pf)(void) = act_table[state][input];
pf();                               // 等价于直接调用 act_print_word()

回顾 Lesson 18(myprintf)中的函数指针用法——printf 的格式字符串解析也用到了类似的思路:根据格式符选择不同的处理函数。本课将函数指针组织为二维数组,实现「状态 + 输入 → 函数」的映射。

函数指针 vs 直接调用:

// if-else 方式 (练习 1):
if (state == 0 && input == 1)
    state = 1;                     // 逻辑分散在 if-else
else if (state == 0 && input == 4) {
    state = 5;
    putchar(c);
}
// ...

// 表驱动方式 (练习 3):
pf = act_table[state][input];      // 通过 state input 索引到函数
pf();                              // 调用 不需要知道是哪个函数
state = state_transition[state][input];  // 同样查表更新状态

NOTE

函数指针数组是 C 语言中实现策略模式命令模式的标准手段。在嵌入式系统、网络协议栈、游戏引擎中广泛使用——当行为需要根据运行时状态动态选择时,查函数指针表比 if-else 或 switch-case 更灵活、更易扩展。

3.4 状态转换表的设计过程

设计一个表驱动状态机,通常从画出状态转换图开始,然后逐行填入表:

步骤 1:确定输入类型和状态

对于 #define 宏展开:

  • 输入类型(5 种):空格/制表符(i0)、#(i1)、define(i2)、任意单词(i3)、换行(i4)
  • 状态(8 个):从 s0 到 s7

步骤 2:画出状态转换图

                     i1(#)           i2(define)
    s0 (正常) ──────────→ s1 (#) ────────────→ s2 (#define)

 i0(空格)              i0/i4 i0(空格)

      s0                     s0                    s3 (#define 后)

                                          i3(单词)     │ i0(空格)
                                          i1(#)        ↓
                                          i2(define)  s4 (宏名)

                                          i0(空格)     │ i3(单词)

                                          i1(#)       s5 (宏名后空白)
                                          i2(define)     │
 i3(单词)

                                                     s6 (宏值中)

 i4(\n)

                                                      s0 (宏定义结束)

    s7: 任何非预处理行中遇到 "define" 等关键字 正常输出(不触发预处理)

步骤 3:逐行填入状态转换表

state_transition_table.c
c
// state_transition[state][input] = next_state

// s0: 正常代码
//     空格 → s0 (继续正常)
//     #    → s1 (可能是预处理指令)
//     define → s7 (独立单词 define,不是 #define)
//     单词 → s7 (正常输出)
//     \n   → s0 (空行)

// s1: 遇到 #
//     空格 → s1 (等待 define)
//     #    → s0 (两个 # 不是预处理)
//     define → s2 (确认 #define)
//     单词 → s0 (不是 define,放弃预处理)
//     \n   → s0 (空预处理行)

// s2: #define
//     空格 → s3 (等待宏名)
//     其他 → s0 (格式错误,放弃)

// s3: #define 后空白
//     空格 → s3 (继续等待)
//     单词 → s4 (这是宏名!)
//     \n   → s0 (空宏定义,放弃)

// s4: 宏名
//     空格 → s5 (等待宏值)
//     \n   → s0 (无值的宏,act_get_macro_value 处理)

// s5: 宏名后空白
//     空格 → s5 (继续等待)
//     单词 → s6 (这是宏值的开始)

// s6: 宏值中
//     任何 → s6 (继续收集宏值)
//     \n   → s0 (宏值结束)

// s7: 非预处理行的单词
//     任何 → s7 (继续输出)
//     \n   → s0 (行结束)

TIP

设计状态转换表时,一个实用的技巧是:先处理「正常情况」,再处理「异常/边界情况」。例如 s4(宏名后)的正常情况是空格→s5 或单词→继续收集宏值;异常情况是 \n→结束宏定义(无值宏)。这样设计出的状态机更健壮。


4. 宏展开的完整流程

4.1 宏表:struct macro 数组

宏定义存储在一个结构体数组中:

macro_table.c
c
struct macro {
    char name[64];    // 宏名,如 "PI"
    char value[64];   // 宏值,如 "3.14"
} macros[16];         // 最多存储 16 个宏

int macro_counter = 0;  // 当前已定义的宏数量

当遇到 #define PI 3.14 时,状态机会调用 act_get_macro_name 保存宏名、act_get_macro_value 保存宏值并递增 macro_counter

4.2 三个缓冲区:buf、word_buf、word

表驱动状态机使用三个全局字符数组来暂存数据:

three_buffers.c
c
char word[64];        // 当前 getword() 读到的单词
char word_buf[64];    // 暂存需要跨多个单词累积的内容(宏名或宏值)
char buf[128];        // 暂存需要延迟输出的内容
三个缓冲区的协作:

输入: "# define PI 3.14 \n int x = PI ; \n"

token    input  state  action               buf        word_buf
"#"       1      s0    act_save_to_buf      "#"
" "       0      s1    act_save_to_buf      "# "
"define"  2      s1    act_save_to_buf      "# define"
" "       0      s2    act_save_to_buf      "# define "
"PI"      3      s3    act_save_word        "# define PI"  "PI"
" "       0      s3    act_save_word        "# define PI " "PI "
"3.14"    3      s4    act_save_word        "# define PI 3.14" "PI 3.14"
"\n"      4      s4    act_get_macro_value  ""             ""
 macros[0] = {"PI", "3.14"}

"int"     3      s0    act_print_word "int"
" "       0      s0    act_print_word " "
"x"       3      s0    act_print_word "x"
" "       0      s0    act_print_word " "
"="       3      s0    act_print_word "="
" "       0      s0    act_print_word " "
"PI"      3      s0    act_print_word "3.14"  (宏替换!)
";"       3      s0    act_print_word ";"
"\n"      4      s0    act_print_word "\n"

最终输出: "int x = 3.14;\n"

4.3 七个动作函数

每个动作函数职责单一,通过函数指针表被调用:

seven_action_functions.c
c
// 1. act_print_word: 输出当前单词(查宏表替换)
void act_print_word(void)
{
    for (int i = 0; i < macro_counter; i++)
    {
        if (strcmp(word, macros[i].name) == 0)  // 是宏名?
        {
            printf("%s", macros[i].value);       // 输出宏值
            return;
        }
    }
    printf("%s", word);                          // 不是宏,原样输出
}

// 2. act_save_to_buf: 把 word 追加到 buf(延迟输出)
void act_save_to_buf(void)
{
    strcat(buf, word);
}

// 3. act_print_buf_and_word: 输出 buf + word,然后清空 buf
void act_print_buf_and_word(void)
{
    printf("%s", buf);
    printf("%s", word);
    strcpy(buf, "");        // 清空缓冲区
}

// 4. act_save_word: 同时追加到 word_buf 和 buf
void act_save_word(void)
{
    strcat(word_buf, word);  // 用于后续保存宏名/宏值
    strcat(buf, word);       // 用于错误恢复时输出
}

// 5. act_get_macro_name: 保存 word_buf 为宏名
void act_get_macro_name(void)
{
    strcpy(macros[macro_counter].name, word_buf);
    strcpy(word_buf, "");    // 清空,准备接收宏值
}

// 6. act_get_macro_value: 保存 word_buf 为宏值,完成宏定义
void act_get_macro_value(void)
{
    strcpy(macros[macro_counter].value, word_buf);
    strcpy(word_buf, "");
    strcpy(buf, "");
    strcpy(word, "");
    macro_counter++;          // 宏计数 +1
}

// 7. act_null: 空操作(某些状态-输入组合不需要动作)
void act_null(void) {}

4.4 完整执行跟踪

以输入 #define MAX 100\nint a = MAX;\n 为例:

token      input  state→next   action               effect
"#"         1     s0→s1        act_save_to_buf      buf="#"
" "         0     s1→s1        act_save_to_buf      buf="# "
"define"    2     s1→s2        act_save_to_buf      buf="# define"
" "         0     s2→s3        act_save_to_buf      buf="# define "
"MAX"       3     s3→s4        act_save_word        buf="# define MAX"
                                                     word_buf="MAX"
" "         0     s4→s5        act_get_macro_name   macros[0].name="MAX"
                                                     word_buf=""
"100"       3     s5→s6        act_save_word        buf=""
                                                     word_buf="100"
"\n"        4     s6→s0        act_get_macro_value  macros[0].value="100"
                                                     macro_counter=1
                                                     (所有 buf 清空)

"int"       3     s0→s7        act_print_word "int"
" "         0     s7→s7        act_print_word " "
"a"         3     s7→s7        act_print_word "a"
" "         0     s7→s7        act_print_word " "
"="         3     s7→s7        act_print_word "="
" "         0     s7→s7        act_print_word " "
"MAX"       3     s7→s7        act_print_word "100" 替换!
";"         3     s7→s7        act_print_word ";"
"\n"        4     s7→s0        act_print_word "\n"

输出: "int a = 100;\n"

NOTE

注意 s7 状态的作用:当「正常代码行」中出现 defineif 等关键字时,它们被当作普通标识符处理(进入 s7),而非触发预处理逻辑。这是因为只有以 # 开头的行才是预处理指令——s0 遇到 # 进入 s1,而 s0 遇到其他单词直接进入 s7(普通输出模式)。


5. 条件编译状态机

5.1 #if / #endif 的工作原理

C 预处理器的条件编译指令允许根据宏的值选择性包含或排除代码:

conditional_compilation.c
c
#define DEBUG 1

#if DEBUG
    printf("Debug mode\n");    // 如果 DEBUG 非零,这段被保留
#else
    printf("Release mode\n");  // 否则这段被保留
#endif

本课的原课代码 expif.c 实现了 #if / #endif 的简化版状态机——根据 #if 后的表达式值决定输出或跳过后续内容。

5.2 expif.c 的 10 状态设计

expif.c 的状态机有 10 个状态(s0~s9),处理 6 种输入(空格、#if、任意单词、\nendif):

expif.c 状态转换表 (简化版):

s0: 正常代码 # → s1, 遇 if → s9
s1: # → 遇 if → s2, 否则回 s0
s2: #if → 遇空格 → s3
s3: #if 后 → 遇单词 → s4
s4: 表达式 \n s5 (act_get_macro_name 计算表达式值)
s5: 条件体内 宏值非零: act_print_word_in_macro (输出)
 宏值为零: act_print_word_in_macro (跳过,不输出)
s6: 条件体内遇 # → 可能是 #endif
s7: 条件体内普通内容 根据 macro_value 决定输出
s8: #if 体内遇 #endif → s0 (条件块结束)
s9: 独立 if (非预处理) → 正常输出

核心逻辑:act_get_macro_nameatoi(word_buf)#if 后的表达式转为整数,非零为 true(输出条件体内的内容),零为 false(跳过条件体内的内容)。act_print_word_in_macro 根据 macro_value 决定是否实际输出。

#if 1  → macro_value = 1 → 输出条件体内代码
#if 0  → macro_value = 0 → 跳过条件体内代码
#if MAX → 如果 MAX 被定义为 100 → macro_value = 1 → 输出

NOTE

原课的 expif.c 是简化版本,只支持 #if 0#if 1(以及 #if MAX 这种宏名),不支持 #if defined(MAX)#if MAX > 100 等复杂表达式。完整 C 预处理器的条件表达式可以包含算术运算、逻辑运算、defined() 运算符等——这需要更复杂的表达式求值器。

5.3 对比:宏展开 vs 条件编译

方面#define 宏展开#if/#endif 条件编译
目的文本替换选择性包含/排除代码
输入类型5 种 (空格, #, define, 单词, \n)6 种 (空格, #, if, 单词, \n, endif)
状态数8 (s0~s7)10 (s0~s9)
核心动作act_print_word (查宏表替换)act_print_word_in_macro (条件输出)
复杂度需要收集宏名和宏值需要计算表达式值和条件判断

两个状态机共享相同的架构:getwordget_input_type → 查 act_table → 执行动作 → 查 state_transition → 更新状态。这种架构的统一性是表驱动设计的最大优势。


6. debug 调试宏:可变参数宏

6.1 回顾 Lesson 12 的函数式宏

在 Lesson 12(小端序检测)中我们学习了函数式宏的基础语法:

lesson12_macro_review.c
c
#define printi(expr) printf(#expr " = %d \n", expr)

// 调用: printi(x + y)
// 展开: printf("x + y" " = %d \n", x + y)
// 结果: printf("x + y = %d \n", x + y)

本课中的 debug 宏更进一步——支持可变数量的参数

debug_macro.c
c
#define debug(fmt, args...)  fprintf(stderr, fmt, ##args)

// 调用: debug("state = %d\n", state)
// 展开: fprintf(stderr, "state = %d\n", state)

// 调用: debug("hello\n")      ← 没有额外参数
// 展开: fprintf(stderr, "hello\n")  ← ##args 消除了多余的逗号

6.2 可变参数宏的语法

variadic_macro_syntax.c
c
// GNU C 扩展语法:
#define debug(fmt, args...)  fprintf(stderr, fmt, ##args)
//           ↑                ↑                     ↑
//      固定参数            可变参数名           ## 消除逗号

// C99 标准语法:
#define debug(fmt, ...)  fprintf(stderr, fmt, ##__VA_ARGS__)
//                       ↑                        ↑
//                  固定参数              标准可变参数名

两种写法等价——args... 是 GNU 扩展,__VA_ARGS__ 是 C99 标准。本课使用 GNU 扩展语法。

6.3 ##args 的逗号消除

## 运算符在可变参数宏中的特殊作用:当可变参数为空时,消除前面的逗号。

token_pasting_variadic.c
c
// 有参数时:
debug("x=%d\n", x)
fprintf(stderr, "x=%d\n", x)   // 正常,有逗号

// 无参数时:
debug("hello\n")
// 如果不用 ##:
fprintf(stderr, "hello\n", )   // 多余的逗号 → 编译错误!
// 使用 ##args:
fprintf(stderr, "hello\n")     // 逗号被消除 ✓

回顾 Lesson 12 中的 ## 记号拼接——#define CONCAT(a, b) a##b 把两个记号粘在一起。在可变参数宏中,## 的语义变成了「消除前导逗号」,这是预处理器的特殊规则。

TIP

debug 宏输出到 stderr 而非 stdout,这是一个好习惯——调试信息应该走标准错误流,与程序的正常输出(标准输出流)分离。这样即使用户把程序输出重定向到文件(./prog > output.txt),调试信息仍然显示在终端上。


7. 预处理流水线与编译原理基础

7.1 C 编译的完整流程

本课实现的三个练习,恰好对应 C 编译器预处理阶段的三个子步骤:

C 源文件 (.c)


┌─────────────────────────────────────┐
 1. 去注释 (delcomment)              │  ← 练习 1
    移除 /* */ // 注释
├─────────────────────────────────────┤
 2. 词法切分 (getword)               │  ← 练习 2
    字符流 token
├─────────────────────────────────────┤
 3. 预处理 (宏展开 + 条件编译)         │  ← 练习 3
    #define 替换, #if/#endif 条件包含  │
├─────────────────────────────────────┤
 4. 编译
    token 语法树 汇编
├─────────────────────────────────────┤
 5. 汇编
    汇编 目标文件 (.o)              │
├─────────────────────────────────────┤
 6. 链接
    目标文件 + 可执行文件
└─────────────────────────────────────┘

7.2 从本课到 Lesson 21 词法分析器

本课的状态机和词法切分是 Lesson 21(词法分析器)的直接基础:

本课 (Lesson 20)Lesson 21 (词法分析器)
getword() 切分标识符和符号完整的 token 分类(关键字、标识符、数字、字符串、运算符)
get_input_type() 5 种输入更丰富的输入类型(10+ 种 token 类型)
8 状态机处理 #define更复杂的状态机处理完整 C 语法
ungetc 单字符回退peek 操作前瞻任意字符

本课的「表驱动状态机」架构——状态转换表 + 动作表 + 主循环——在 Lesson 21 中被扩展为更通用的词法分析框架。

7.3 预处理器的局限性

本课实现的预处理器是教学简化版,与真实的 C 预处理器(如 GCC 的 cpp)相比有以下局限:

preprocessor_limitations.c
c
// 1. 不支持带参数的宏
#define SQUARE(x) ((x) * (x))     // 本课不支持

// 2. 不支持 #include 文件包含
#include <stdio.h>                 // 本课不支持

// 3. 不支持 #if 复杂表达式
#if defined(MAX) && MAX > 100     // 本课不支持

// 4. 不支持 # 和 ## 运算符
#define STR(x) #x                  // 本课不支持
#define CONCAT(a, b) a##b          // 本课不支持

// 5. 不支持 #pragma, #error, #line 等指令
#pragma pack(1)                    // 本课不支持

但本课的架构完全可以扩展——新增一个预处理指令只需:

  1. get_input_type 中添加新的输入类型
  2. 在状态转换表和动作表中添加对应的行列
  3. 实现新的动作函数

这正是表驱动设计的威力:扩展功能 = 扩展数据表,而非修改控制流


参考解答

练习 1: 去注释状态机参考解答
20a_delcomment_solution.c
c
#include <stdio.h>

int get_input_type(char c)
{
    if (c == '/')
        return 1;
    if (c == '*')
        return 2;
    if (c == '\n')
        return 3;
    if (c == '\'')
        return 4;
    if (c == '\\')
        return 5;
    return 0;
}

int main(void)
{
    int state = 0;

    while (1)
    {
        char c;
        int input;

        c = getchar();
        if (c == EOF)
            break;

        input = get_input_type(c);

        if (state == 0 && input == 1)       // 正常遇 '/'
        {
            state = 1;
        }
        else if (state == 0 && input == 4)  // 正常遇 '\''
        {
            state = 5;
            putchar(c);
        }
        else if (state == 1 && input == 1)  // "//" → 行注释
        {
            state = 4;
        }
        else if (state == 1 && input == 2)  // "/*" → 块注释
        {
            state = 2;
        }
        else if (state == 2 && input == 0)  // 块注释内普通字符
        {
            state = 2;
        }
        else if (state == 2 && input == 2)  // 块注释内遇 '*'
        {
            state = 3;
        }
        else if (state == 3 && input == 1)  // "*/" → 注释结束
        {
            state = 0;
        }
        else if (state == 3 && input == 0)  // "*?" → 回块注释
        {
            state = 2;
        }
        else if (state == 4 && input == 3)  // 行注释遇 '\n'
        {
            state = 0;
            putchar(c);
        }
        else if (state == 5 && input == 4)  // 字符常量闭合
        {
            state = 0;
            putchar(c);
        }
        else if (state == 5 && input == 5)  // 字符常量内 '\'
        {
            state = 6;
            putchar(c);
        }
        else if (state == 6 && input == 4)  // 转义 '  → 回 state 5
        {
            state = 5;
            putchar(c);
        }
        else if (state == 6 && input == 5)  // 转义 \  → 回 state 5
        {
            state = 5;
            putchar(c);
        }
        else if (state == 6 && input == 0)  // 转义普通字符 → 回 state 5
        {
            state = 5;
            putchar(c);
        }
        else if (state == 0 || state == 5)  // 普通字符输出
            putchar(c);
    }

    return 0;
}

解析:核心设计是 state 1 的「待定」——遇到 / 后不立即输出,等下一个字符确认是否为注释。state 3 处理块注释中 * 后的「假结束」:/* ... *x */ 中第一个 * 进入 state 3,但下一个字符是 x(input 0),所以回退到 state 2。state 5/6 处理字符常量内的特殊字符,防止 '/*''\'' 被错误解析。state 2 中 input == 0 的条件虽然显式写了 state = 2(不变),但这是为了让逻辑显式化——实际上可以省略,因为不匹配任何 if 分支时 state 自然不变。

练习 2: getword 参考解答
20b_getword_solution.c
c
void getword(char *word)
{
    char c;

    c = getchar();

    if (c == EOF)
    {
        *word = '\0';
        return;
    }

    if (!isalpha(c))
    {
        *word++ = c;
        *word = '\0';
        return;
    }

    do
    {
        *word++ = c;
        c = getchar();
    } while (isalnum(c) || c == '_');

    ungetc(c, stdin);
    *word = '\0';
}

解析getword 的三种返回情况:

  1. EOF*word = '\0',调用者用 strcmp(word, "") == 0 检测结束。
  2. 非字母!isalpha(c))→ 直接返回单字符。这包括空格、#\n=;、数字开头的 token(如 3.14)等。对于 3.14,它会作为 "3"".""14" 三个 token 返回——这在宏展开场景下是合理的,因为 3.14 中的 314 都不会被误认为是宏名。
  3. 字母开头 → do-while 循环收集连续的字母、数字、下划线(isalnum(c) || c == '_'),然后用 ungetc 退回多读的字符。

ungetc(c, stdin) 把多读的非单词字符退回 stdin 缓冲区——这是词法分析中「前瞻一个字符」的标准实现方式。

练习 3: 宏展开动作函数参考解答
20c_expdefine_solution.c
c
#include <stdio.h>
#include <string.h>
#include <ctype.h>

int get_input_type(char *word)
{
    if (strcmp(word, " ") == 0 || strcmp(word, "\t") == 0)
        return 0;
    if (strcmp(word, "#") == 0)
        return 1;
    if (strcmp(word, "define") == 0)
        return 2;
    if (strcmp(word, "\n") == 0)
        return 4;
    return 3;
}

char word[64];
char word_buf[64];
char buf[128];

struct macro
{
    char name[64];
    char value[64];
} macros[16];

int macro_counter = 0;

void act_print_word(void)
{
    int i;
    for (i = 0; i < macro_counter; i++)
    {
        if (strcmp(word, macros[i].name) == 0)
        {
            printf("%s", macros[i].value);
            return;
        }
    }
    printf("%s", word);
}

void act_save_to_buf(void)
{
    strcat(buf, word);
}

void act_print_buf_and_word(void)
{
    printf("%s", buf);
    printf("%s", word);
    strcpy(buf, "");
}

void act_save_word(void)
{
    strcat(word_buf, word);
    strcat(buf, word);
}

void act_get_macro_name(void)
{
    strcpy(macros[macro_counter].name, word_buf);
    strcpy(word_buf, "");
}

void act_get_macro_value(void)
{
    strcpy(macros[macro_counter].value, word_buf);
    strcpy(word_buf, "");
    strcpy(buf, "");
    strcpy(word, "");
    macro_counter++;
}

void act_null(void) {}

enum { s0 = 0, s1, s2, s3, s4, s5, s6, s7 };

int state_transition[8][5] =
{
    { s0, s1, s7, s7, s0 },
    { s1, s0, s2, s0, s0 },
    { s3, s0, s0, s0, s0 },
    { s3, s4, s4, s4, s0 },
    { s5, s5, s4, s0, s0 },
    { s5, s6, s6, s6, s0 },
    { s6, s6, s6, s6, s0 },
    { s7, s7, s7, s7, s0 },
};

typedef void (*PF)(void);
PF act_table[8][5] =
{
    { act_print_word, act_save_to_buf, act_print_word, act_print_word, act_print_word },
    { act_save_to_buf, act_print_buf_and_word, act_save_to_buf, act_print_buf_and_word, act_print_buf_and_word },
    { act_save_to_buf, act_print_buf_and_word, act_print_buf_and_word, act_print_buf_and_word, act_print_buf_and_word },
    { act_save_to_buf, act_save_word, act_save_word, act_save_word, act_print_buf_and_word },
    { act_get_macro_name, act_get_macro_name, act_save_word, act_print_buf_and_word, act_get_macro_value },
    { act_null, act_save_word, act_save_word, act_save_word, act_get_macro_value },
    { act_save_word, act_save_word, act_save_word, act_save_word, act_get_macro_value },
    { act_print_word, act_print_word, act_print_word, act_print_word, act_print_word },
};

void getword(char *word)
{
    char c;

    c = getchar();

    if (c == EOF)
    {
        *word = '\0';
        return;
    }

    if (!isalpha(c))
    {
        *word++ = c;
        *word = '\0';
        return;
    }

    do
    {
        *word++ = c;
        c = getchar();
    } while (isalnum(c) || c == '_');

    ungetc(c, stdin);
    *word = '\0';
}

int main(void)
{
    int state = 0;

    while (1)
    {
        int input;
        void (*pf)(void);

        getword(word);
        if (strcmp(word, "") == 0)
            break;

        input = get_input_type(word);
        pf = act_table[state][input];
        pf();
        state = state_transition[state][input];
    }

    return 0;
}

解析:七个动作函数的设计体现了「关注点分离」——每个函数只做一件事。act_print_word 负责「查宏表 + 输出」,act_save_to_buf 负责「暂存」,act_print_buf_and_word 负责「输出暂存 + 当前」,act_save_word 负责「双份暂存」(word_buf 用于后续保存、buf 用于错误恢复),act_get_macro_name/act_get_macro_value 负责「保存宏定义」。主循环 4 行代码(getword → get_input_type → 查表执行 → 更新状态)把复杂的预处理逻辑压缩到了极致。


课堂讨论

  1. 去注释状态机中,state 1(遇到 /)进入后如果不立即输出 /,而是等下一个字符确认,那如果下一个字符是普通字符(如除法 a/b),如何确保 / 也被输出?当前代码是否处理了这种情况?

  2. getword 中用 isalnum(c) || c == '_' 判断单词的延续,为什么不只用 isalpha(c)?如果宏名是 MAX_SIZE_2,只用 isalpha 会怎样?

  3. 表驱动状态机的 state_transition 表中,s7 状态的所有转移都指向 s7(除了 \n 回 s0)。为什么设计这样一个「吸收状态」?如果去掉 s7,代码会出什么问题?

  4. act_get_macro_value 中同时清空了 word_bufbufword。为什么需要清空 word?如果不清空,下一个 getword 调用会出问题吗?

  5. 本课的去注释状态机只处理了单引号字符常量 '...',没有处理双引号字符串 "..."。如果代码中有 printf("/* not a comment */");,当前状态机会怎样处理?如何扩展?

  6. 如果要把 expif.c 的条件编译状态机和 expdefine.c 的宏展开状态机合并为一个统一的预处理器,你会怎样设计状态转换表和动作表?合并后的状态数大约是多少?

讨论答案

Q1: state 1 中除法运算符 / 的处理

当前代码的 state 1 处理中,如果 / 后不是 */,那么它就是一个普通的除法运算符。但当前 if-else 链中并没有显式处理 state == 1 && input != 1 && input != 2 的情况。这意味着:在 a/b 中,/ 进入 state 1,b(input 0)到来时,没有匹配的 if 分支——/ 被吞掉了!

这就是练习 1 框架中需要学生修复的 bug。正确的处理方式是在 state 1 的 if-else 链末尾添加:

state1_fix.c
c
else if (state == 1)                 // 不是 // 也不是 /*
{
    putchar('/');                    // 输出之前暂存的 '/'
    putchar(c);                      // 输出当前字符
    state = 0;                       // 回到正常状态
}

这也是 if-else 链状态机容易遗漏边界情况的典型例子。表驱动版本没有这个问题——因为每个 (state, input) 组合都在表中显式定义,遗漏会导致编译错误或运行时异常。

Q2: isalnum + '_' 而非纯 isalpha

C 语言的标识符(变量名、宏名、函数名)允许字母、数字和下划线,且数字不能作为首字符。getword 的逻辑与此完全一致:

  • 首字符isalpha(c) — 只允许字母(C 标准不允许 _ 开头的标识符作为宏名,尽管实际上合法)
  • 后续字符isalnum(c) || c == '_' — 允许字母、数字、下划线

如果只用 isalpha 判断后续字符,MAX_SIZE_2 会被切分为 "MAX""_""SIZE""_""2"——一个宏名变成了 5 个 token,宏替换彻底失败。

isalnum(c) 等价于 isalpha(c) || isdigit(c),是 C 标准库中最常用的字符分类函数之一,来自 <ctype.h>

Q3: s7 作为吸收状态的设计原理

s7 状态处理「非预处理行的单词」——当正常代码行中出现 defineif 等单词时,它们应该被当作普通标识符原样输出,而非触发预处理逻辑。

如果没有 s7,s0 遇到 define(input 2)将进入 s2#define 识别流程),导致正常代码中的 int define_x = 5; 被错误解析为宏定义。

 s7:
"int"  (input 3) → s0→s7, act_print_word → "int"
" "    (input 0) → s7→s7, act_print_word → " "
"x"    (input 3) → s7→s7, act_print_word → "x"
" "    (input 0) → s7→s7, act_print_word → " "
"="    (input 3) → s7→s7, act_print_word → "="
...
正确输出: "int x = ..."

没有 s7 (如果 s0 遇到 input 3 进入某个宏处理状态):
"int" s0→?, act_?? 可能开始收集宏定义,错误!

s7 在状态机设计中被称为「吸收状态」(absorbing state)——一旦进入,除非遇到行结束(\n),否则始终留在该状态。吸收状态通常用于处理「与当前关注点无关的输入」,防止它们干扰正常的识别流程。

Q4: act_get_macro_value 为什么清空 word

act_get_macro_value 清空 word 是为了防止宏值的残留污染下一次输出

具体场景:#define MAX 100 的宏值是 "100",它存储在 word_buf 中。当 \n 到来时,act_get_macro_valueword_buf 的内容复制到 macros[macro_counter].value。但此时 word 变量可能还残留着上一个 token 的内容(比如空格 " ")。

如果不把 word 清空,下一个 getword 调用会覆盖 word,所以一般不会出问题——但如果在 act_get_macro_value 和下一次 getword 之间有任何代码引用了 word,就会读到残留值。清空是一种防御性编程。

更关键的是清空 buf:如果不把 buf 清空,下一个宏定义的收集过程会把上一个宏的内容也追加进去,导致宏值错误。

Q5: 双引号字符串的处理

当前状态机只处理单引号 '...'(state 5/6),不处理双引号 "..."。如果遇到 printf("/* not a comment */");

  • "get_input_type 归类为 input 0(普通字符)
  • 进入 state 0 或 state 5(如果在字符常量内)
  • 随后的 /* 会被当作注释开始 → state 1 → state 2
  • 结果:printf(" 被输出,但 /* not a comment */ 被吞掉,输出变成 printf("");

扩展方案:新增 state 7/8 处理双引号字符串,与 state 5/6 的单引号处理对称:

double_quote_extension.c
c
// 新增输入类型:
if (c == '"') return 6;    // 双引号

// 新增状态:
// state 7: 双引号字符串中
// state 8: 双引号内转义中

// 状态转换 (对称于 state 5/6):
if (state == 0 && input == 6)       // 正常遇 '"'
    { state = 7; putchar(c); }
if (state == 7 && input == 6)       // 双引号闭合
    { state = 0; putchar(c); }
if (state == 7 && input == 5)       // 双引号内遇 '\'
    { state = 8; putchar(c); }
if (state == 8)                     // 转义中
    { state = 7; putchar(c); }

扩展后状态数从 7 变为 9,if-else 链需要增加约 6 个分支——而在表驱动版本中,只需要在二维数组后面追加两行数据。

Q6: 合并 expdefine 和 expif 的状态机

合并后的统一预处理器需要处理:空格、#defineifendifelse、任意单词、\n——共 8 种输入类型。

状态设计需要覆盖以下场景:

  • 正常代码输出(类似 s0/s7)
  • #define 宏定义(s1~s6)
  • #if 条件开始(类似 expif 的 s1~s4)
  • #if 条件体内(expif 的 s5~s7)
  • #endif 结束条件(expif 的 s8)
  • #else 分支切换

合并后大约需要 14~16 个状态

  • s0: 正常代码
  • s1~s6: #define 处理(复用 expdefine)
  • s7~s12: #if/#else/#endif 处理(复用 expif)
  • s13: 非预处理行的吸收状态
  • s14~s15: #include 扩展

状态转换表将是 16×8 的二维数组,动作表同样是 16×8 的函数指针数组。在表驱动架构下,这种扩展只需要增加表数据,主循环完全不变——这就是表驱动设计的可扩展性优势。


课后练习

练习 1:扩展去注释状态机支持双引号

在练习 1 的去注释状态机中,新增对双引号字符串 "..." 的支持。要求:

  • 双引号内的 /*// 不被当作注释
  • 双引号内的 \" 转义正确处理(不结束字符串)
  • 测试用例:printf("/* not a comment */"); int x = 1; → 输出 printf("/* not a comment */"); int x = 1;

知识点提示:双引号的处理逻辑与单引号(state 5/6)完全对称。新增 state 7(双引号字符串中)和 state 8(双引号内转义),状态转换规则与 state 5/6 一致。get_input_type 需要新增 '"' → 6 的映射。

参考解答
delcomment_with_double_quote.c
c
// 在 get_input_type 中新增:
if (c == '"') return 6;

// 在状态机中新增以下分支:
else if (state == 0 && input == 6)      // 正常遇 '"'
{
    state = 7;
    putchar(c);
}
else if (state == 7 && input == 6)      // 双引号闭合
{
    state = 0;
    putchar(c);
}
else if (state == 7 && input == 5)      // 双引号内遇 '\'
{
    state = 8;
    putchar(c);
}
else if (state == 8)                    // 转义中(任意字符都输出)
{
    state = 7;
    putchar(c);
}
else if (state == 7)                    // 双引号内普通字符
{
    putchar(c);
}

测试

  • 输入:printf("/* not a comment */"); int x = 1;
  • 经过 state 7 时,/* 中的 /* 都被正常输出,不触发 state 1→state 2
  • 输出:printf("/* not a comment */"); int x = 1;(完全保留)

练习 2:为 expdefine 添加 #include 处理

扩展 expdefine.c 的状态机,添加对 #include 指令的处理。简化实现:遇到 #include "filename" 时,输出 /* included: filename */ 而不实际读取文件。

实现步骤:

  1. get_input_type 中新增 "include" → 5 的映射
  2. 新增状态 s8~s10 处理 #include 的解析
  3. 新增动作函数 act_print_include:输出 /* included: word_buf */

知识点提示#include 的处理与 #define 非常相似——都是 # + 关键字 + 参数的模式。可以利用已有的 buf/word_buf 缓冲区暂存文件名。状态转换表需要从 8 状态扩展到 11 状态,动作表也需要相应扩展。

参考解答
add_include.c
c
// 在 get_input_type 中新增:
if (strcmp(word, "include") == 0)
    return 5;

// 新增动作函数:
void act_print_include(void)
{
    printf("/* included: %s */\n", word_buf);
    strcpy(word_buf, "");
}

// 新增状态:
enum { s0 = 0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10 };

// 扩展的状态转换表(新增列和行):
int state_transition[11][6] = {
    // 空格, #,  define, 单词, \n,  include
    { s0,  s1, s7,    s7,  s0, s7    },  // s0
    { s1,  s0, s2,    s0,  s0, s8    },  // s1: # → define 或 include
    // ... 原有 s2~s7 行不变,增加 include 列 ...
    { s9,  s0, s0,    s0,  s0, s0    },  // s8: #include
    { s9,  s10,s10,   s10, s0,  s10  },  // s9: #include 后空白
    { s10, s10,s10,   s10, s0,  s10  },  // s10: 收集文件名
};

// 扩展的动作表:
PF act_table[11][6] = {
    // ... 原有 8 行不变,增加 include 列和新行 ...
    { act_save_to_buf, ..., act_save_to_buf },  // s8: #include
    { act_save_to_buf, ..., act_print_buf_and_word },  // s9
    { act_save_word,   ..., act_print_include },  // s10: \n 触发输出
};

测试:输入 #include "myheader.h"\nint x = 1;\n → 输出 /* included: "myheader.h" */\nint x = 1;\n

练习 3:实现递归宏展开

当前 act_print_word 只在宏表中查找一次就输出。但如果宏值本身包含另一个宏名呢?

例如:#define A 10#define B Aint x = B; 期望输出 int x = 10;(递归展开)。

修改 act_print_word,使其在输出宏值后继续检查输出是否包含其他宏名。简化实现:最多展开 3 层,防止无限递归。

知识点提示:递归宏展开的关键是「替换后重新扫描」。简单做法是把宏值复制到一个临时缓冲区,对缓冲区中的每个单词再次查宏表。需要注意防止无限递归——例如 #define X Y#define Y X 会导致死循环。可以用一个「展开深度」计数器来限制。

参考解答
recursive_macro_expand.c
c
#define MAX_EXPAND_DEPTH 3

void act_print_word_recursive(void)
{
    char expanded[64];
    int depth = 0;
    const char *current = word;

    while (depth < MAX_EXPAND_DEPTH)
    {
        int found = 0;
        for (int i = 0; i < macro_counter; i++)
        {
            if (strcmp(current, macros[i].name) == 0)
            {
                current = macros[i].value;
                found = 1;
                depth++;
                break;
            }
        }
        if (!found) break;
    }

    printf("%s", current);
}

测试

  • #define A 10#define B Aint x = B;
  • act_print_word_recursive: word="B" → 找到 B → current="A" → 找到 A → current="10" → 输出 "10"
  • 输出:int x = 10;

防无限递归

  • #define X Y#define Y Xint z = X;
  • depth=0: X→Y, depth=1: Y→X, depth=2: X→Y, depth=3: 达到 MAX_EXPAND_DEPTH → 输出 "Y"(截断)

练习 4:用 switch-case 重写去注释状态机

练习 1 用 if-else 链实现状态机。用 switch(state) + 内层 if(input) 重写,体会两种写法的差异。

要求:

  • 外层 switch(state),每个 case 内用 if 判断 input
  • 保留 break 的正确位置——注意 switch 的 fall-through 特性
  • 比较:哪个版本更容易阅读?更容易扩展?

知识点提示:回顾 Lesson 13(switch-case)中的状态机示例——switch 外层 + if 内层是状态机的另一种经典写法。它比纯 if-else 更结构化(状态划分清晰),但比表驱动版本更冗长。尝试比较三种实现的行数和可读性。

参考解答
delcomment_switch.c
c
int main(void)
{
    int state = 0;

    while (1)
    {
        char c = getchar();
        if (c == EOF) break;
        int input = get_input_type(c);

        switch (state)
        {
        case 0:  // 正常代码
            if (input == 1)      state = 1;
            else if (input == 4) { state = 5; putchar(c); }
            else                 putchar(c);
            break;

        case 1:  // 遇到 '/'
            if (input == 1)      state = 4;
            else if (input == 2) state = 2;
            else                 { putchar('/'); putchar(c); state = 0; }
            break;

        case 2:  // 块注释中
            if (input == 2)      state = 3;
            // 否则留在 state 2 (不输出)
            break;

        case 3:  // 块注释遇 '*'
            if (input == 1)      state = 0;
            else if (input == 2) state = 3;
            else                 state = 2;
            break;

        case 4:  // 行注释中
            if (input == 3)      { state = 0; putchar(c); }
            break;

        case 5:  // 字符常量中
            if (input == 4)      { state = 0; putchar(c); }
            else if (input == 5) { state = 6; putchar(c); }
            else                 putchar(c);
            break;

        case 6:  // 转义中
            putchar(c);
            state = 5;
            break;
        }
    }

    return 0;
}

三种实现方式对比

方式行数可读性扩展性
纯 if-else 链~60 行中等(状态-输入对分散)差(新增状态需多处修改)
switch-case~45 行较好(状态分区清晰)中等(新增状态加一个 case)
表驱动~15 行(主循环) + 表数据好(逻辑和数据分离)优(新增状态只加表行)

练习 5:挑战 — 为宏展开添加参数支持

扩展 expdefine.c,支持带参数的宏:#define SQUARE(x) ((x) * (x))。当遇到 SQUARE(5) 时,输出 ((5) * (5))

实现思路:

  1. struct macro 中新增 int has_argschar arg_name[64] 字段
  2. 修改 act_print_word:如果宏有参数,读取 (...) 中的实参,替换形参后输出
  3. 简化处理:只支持一个参数,不支持嵌套宏调用

知识点提示:这是本课最具挑战性的扩展。需要修改词法切分逻辑(识别 ()),修改宏表结构(存储参数信息),以及修改输出逻辑(替换形参为实参)。建议先画出扩展后的状态转换图,再修改表数据。

参考解答
macro_with_args.c
c
// 扩展的宏结构体
struct macro
{
    char name[64];
    char value[64];
    int  has_args;          // 是否有参数
    char arg_name[64];      // 参数名(如 "x")
};

// 带参数的 act_print_word
void act_print_word(void)
{
    for (int i = 0; i < macro_counter; i++)
    {
        if (strcmp(word, macros[i].name) == 0)
        {
            if (macros[i].has_args)
            {
                // 读取实参: 跳过 '(' 后读取到 ')'
                char c;
                while ((c = getchar()) == ' ');  // 跳过空白
                if (c != '(') { ungetc(c, stdin); printf("%s", word); return; }

                char actual_arg[64];
                int j = 0;
                while ((c = getchar()) != ')' && c != EOF && j < 63)
                    actual_arg[j++] = c;
                actual_arg[j] = '\0';

                // 替换形参为实参
                const char *v = macros[i].value;
                while (*v)
                {
                    // 简单字符串替换(忽略词边界)
                    if (strncmp(v, macros[i].arg_name,
                        strlen(macros[i].arg_name)) == 0)
                    {
                        printf("%s", actual_arg);
                        v += strlen(macros[i].arg_name);
                    }
                    else
                    {
                        putchar(*v++);
                    }
                }
                return;
            }
            else
            {
                printf("%s", macros[i].value);
                return;
            }
        }
    }
    printf("%s", word);
}

测试#define SQUARE(x) ((x) * (x))\nint a = SQUARE(5);\nint a = ((5) * (5));\n

局限性:这个简化版不支持嵌套宏调用(如 SQUARE(MAX) 其中 MAX 也是宏)、不支持多个参数、参数替换是纯字符串级别的(不处理词边界)。完整的参数化宏实现需要更复杂的 token 级别替换。


参考资料

"The best way to predict the future is to implement it." — David Heinemeier Hansson

Released under the MIT License.