Lesson 20: 预处理器实现 CNB
练习任务
本课包含三个递进练习,逐步构建一个简易 C 预处理器——从字符级别的注释去除,到单词级别的词法切分,再到完整的表驱动状态机实现宏展开:
练习 1(20a_delcomment 去注释):实现一个 7 状态的状态机,逐字符读取 C 源代码,去除所有注释(/* ... */ 块注释和 // ... 行注释),同时正确处理字符常量 '...' 中的转义字符,避免误删。
练习 2(20b_getword 词法切分):实现 getword() 函数,从 stdin 每次读取一个「词」——遇到字母就收集整个单词,遇到非字母就返回该单字符。关键技巧:用 ungetc() 把多读的字符「退回去」。
练习 3(20c_expdefine 宏展开):实现 7 个动作函数(act_print_word、act_save_to_buf、act_print_buf_and_word、act_save_word、act_get_macro_name、act_get_macro_value、act_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:去注释状态机
#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
#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:宏展开的动作函数
#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 字符分类:get_input_type
在去注释问题中,不是所有字符都同等重要——只有少数几个字符会触发状态转换。get_input_type() 把字符「分类」为数字编码,后续状态机只关心编码而非字符本身:
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) 组合:
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 c = '"'; // 双引号在字符常量中
char slash = '\\'; // 反斜杠本身
int div = a / b; // 除法运算符,不是注释字符常量内的 /* 和 // 不应该被当作注释,所以状态机需要用 state 5 和 state 6 来跳过字符常量内的内容。同样,转义字符 \' 在字符常量内代表一个单引号字符而非字符常量结束——state 6 处理的就是这种情况:
// 输入: '\''
// 字符: ' \ ' '
// 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 实现
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 链。它直观但有几个问题:
- 代码冗长:每个
(state, input)组合都需要一个 if 分支 - 难以扩展:新增一个状态需要添加多个 if 分支,容易遗漏
- 逻辑和配置混杂:状态转换规则和控制流代码混在一起
以去注释为例:7 个状态 × 6 种输入 = 42 种可能的组合,但只有约 14 种有实际的状态转换。if-else 链需要手动列出这 14 种,而其余 28 种「不变」的组合被隐式忽略——但如果漏掉了某个组合,就是 bug。
3.2 表驱动:把规则变成数据
表驱动状态机把「状态转换规则」和「动作规则」从代码中抽离出来,变成两个二维数组:
// 状态转换表: 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 },
};有了这两个表,主循环就变得极其简洁:
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 语言允许把函数的地址存储在数组中,通过数组下标调用:
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[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 数组
宏定义存储在一个结构体数组中:
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
表驱动状态机使用三个全局字符数组来暂存数据:
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 七个动作函数
每个动作函数职责单一,通过函数指针表被调用:
// 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 状态的作用:当「正常代码行」中出现 define、if 等关键字时,它们被当作普通标识符处理(进入 s7),而非触发预处理逻辑。这是因为只有以 # 开头的行才是预处理指令——s0 遇到 # 进入 s1,而 s0 遇到其他单词直接进入 s7(普通输出模式)。
5. 条件编译状态机
5.1 #if / #endif 的工作原理
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、任意单词、\n、endif):
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_name 用 atoi(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 (条件输出) |
| 复杂度 | 需要收集宏名和宏值 | 需要计算表达式值和条件判断 |
两个状态机共享相同的架构:getword → get_input_type → 查 act_table → 执行动作 → 查 state_transition → 更新状态。这种架构的统一性是表驱动设计的最大优势。
6. debug 调试宏:可变参数宏
6.1 回顾 Lesson 12 的函数式宏
在 Lesson 12(小端序检测)中我们学习了函数式宏的基础语法:
#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 宏更进一步——支持可变数量的参数:
#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 可变参数宏的语法
// 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 的逗号消除
## 运算符在可变参数宏中的特殊作用:当可变参数为空时,消除前面的逗号。
// 有参数时:
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)相比有以下局限:
// 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) // 本课不支持但本课的架构完全可以扩展——新增一个预处理指令只需:
- 在
get_input_type中添加新的输入类型 - 在状态转换表和动作表中添加对应的行列
- 实现新的动作函数
这正是表驱动设计的威力:扩展功能 = 扩展数据表,而非修改控制流。
参考解答
练习 1: 去注释状态机参考解答
#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 参考解答
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 的三种返回情况:
- EOF →
*word = '\0',调用者用strcmp(word, "") == 0检测结束。 - 非字母(
!isalpha(c))→ 直接返回单字符。这包括空格、#、\n、=、;、数字开头的 token(如3.14)等。对于3.14,它会作为"3"、"."、"14"三个 token 返回——这在宏展开场景下是合理的,因为3.14中的3和14都不会被误认为是宏名。 - 字母开头 → do-while 循环收集连续的字母、数字、下划线(
isalnum(c) || c == '_'),然后用ungetc退回多读的字符。
ungetc(c, stdin) 把多读的非单词字符退回 stdin 缓冲区——这是词法分析中「前瞻一个字符」的标准实现方式。
练习 3: 宏展开动作函数参考解答
#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 → 查表执行 → 更新状态)把复杂的预处理逻辑压缩到了极致。
课堂讨论
去注释状态机中,state 1(遇到
/)进入后如果不立即输出/,而是等下一个字符确认,那如果下一个字符是普通字符(如除法a/b),如何确保/也被输出?当前代码是否处理了这种情况?getword中用isalnum(c) || c == '_'判断单词的延续,为什么不只用isalpha(c)?如果宏名是MAX_SIZE_2,只用isalpha会怎样?表驱动状态机的
state_transition表中,s7 状态的所有转移都指向 s7(除了\n回 s0)。为什么设计这样一个「吸收状态」?如果去掉 s7,代码会出什么问题?act_get_macro_value中同时清空了word_buf、buf和word。为什么需要清空word?如果不清空,下一个getword调用会出问题吗?本课的去注释状态机只处理了单引号字符常量
'...',没有处理双引号字符串"..."。如果代码中有printf("/* not a comment */");,当前状态机会怎样处理?如何扩展?如果要把
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 链末尾添加:
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 状态处理「非预处理行的单词」——当正常代码行中出现 define、if 等单词时,它们应该被当作普通标识符原样输出,而非触发预处理逻辑。
如果没有 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_value 把 word_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 的单引号处理对称:
// 新增输入类型:
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 的状态机
合并后的统一预处理器需要处理:空格、#、define、if、endif、else、任意单词、\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的映射。
参考解答
// 在 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 */ 而不实际读取文件。
实现步骤:
- 在
get_input_type中新增"include" → 5的映射 - 新增状态 s8~s10 处理
#include的解析 - 新增动作函数
act_print_include:输出/* included: word_buf */
知识点提示:
#include的处理与#define非常相似——都是#+ 关键字 + 参数的模式。可以利用已有的buf/word_buf缓冲区暂存文件名。状态转换表需要从 8 状态扩展到 11 状态,动作表也需要相应扩展。
参考解答
// 在 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 A、int x = B; 期望输出 int x = 10;(递归展开)。
修改 act_print_word,使其在输出宏值后继续检查输出是否包含其他宏名。简化实现:最多展开 3 层,防止无限递归。
知识点提示:递归宏展开的关键是「替换后重新扫描」。简单做法是把宏值复制到一个临时缓冲区,对缓冲区中的每个单词再次查宏表。需要注意防止无限递归——例如
#define X Y和#define Y X会导致死循环。可以用一个「展开深度」计数器来限制。
参考解答
#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 A→int x = B;act_print_word_recursive: word="B" → 找到 B → current="A" → 找到 A → current="10" → 输出 "10"- 输出:
int x = 10;
防无限递归:
#define X Y→#define Y X→int 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 更结构化(状态划分清晰),但比表驱动版本更冗长。尝试比较三种实现的行数和可读性。
参考解答
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))。
实现思路:
- 在
struct macro中新增int has_args和char arg_name[64]字段 - 修改
act_print_word:如果宏有参数,读取(...)中的实参,替换形参后输出 - 简化处理:只支持一个参数,不支持嵌套宏调用
知识点提示:这是本课最具挑战性的扩展。需要修改词法切分逻辑(识别
(和)),修改宏表结构(存储参数信息),以及修改输出逻辑(替换形参为实参)。建议先画出扩展后的状态转换图,再修改表数据。
参考解答
// 扩展的宏结构体
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);\n → int a = ((5) * (5));\n
局限性:这个简化版不支持嵌套宏调用(如 SQUARE(MAX) 其中 MAX 也是宏)、不支持多个参数、参数替换是纯字符串级别的(不处理词边界)。完整的参数化宏实现需要更复杂的 token 级别替换。
参考资料
- cppreference: C preprocessor — C 预处理器的官方文档,涵盖
#define、#if、#include、#和##运算符等全部预处理指令 - GNU C Preprocessor Manual — GCC 预处理器的完整手册,包括可变参数宏(Variadic Macros)的 GNU 扩展语法和
##逗号消除 - cppreference: ungetc —
ungetc函数文档,了解字符回退的保证行为和限制 - cppreference: isalpha / isalnum —
<ctype.h>字符分类函数的完整列表,包括isalpha、isalnum、isspace等 - Finite-State Machine (Wikipedia) — 有限状态机的理论基础,包括 Mealy 机和 Moore 机的区别(本课使用的是 Mealy 机:输出依赖于状态和输入)
"The best way to predict the future is to implement it." — David Heinemeier Hansson