在命令行工具、代码阅读器或者教学演示程序中,经常需要把C语言源文件以不同颜色展示关键字、注释和字符串。实现这一功能并不依赖第三方库,用标准C语言自己写一个简易词法扫描器就能完成。下面先说明基本思路,再给出可编译运行的示例。

一、语法高亮的基本原理
语法高亮本质是一个词法分析过程。普通文本文件是连续的字符流,我们需要按照C语言语法规则,把字符划分成不同的“记号”类别,例如关键字(int、for、if)、标识符(变量名)、数字、字符串、注释、预处理指令等。每类记号对应一种显示样式。
如果只使用字符串查找,很容易出错。比如注释块 /* int x; */ 里面出现了关键字 int,但它是注释的一部分,不应该高亮成关键字。因此必须引入状态机:程序在扫描时维护一个当前状态,如“正常代码态”“行注释态”“块注释态”“字符串态”,根据读到的字符和当前状态决定下一步动作与输出类别。
1.1 状态机设计
一个最小可用的状态集合可以包括:STATE_NORMAL(普通)、STATE_LINE_COMMENT(//注释)、STATE_BLOCK_COMMENT(/* */注释)、STATE_STRING(双引号字符串)、STATE_CHAR(单引号字符常量)。当处于注释或字符串状态时,内部字符一律按对应类别上色,不再识别关键字。
状态切换逻辑也很直接:在 NORMAL 态读到 / 且下一个是 / 则进 LINE_COMMENT,读到 / 且下一个是 * 则进 BLOCK_COMMENT;在 BLOCK_COMMENT 读到 * 且下一个是 / 则回 NORMAL;在 NORMAL 读到双引号进 STRING,读到单引号进 CHAR,在 STRING 或 CHAR 中遇到非转义的结束引号则返回 NORMAL。这样就能正确处理绝大多数C文件。
二、从文件读取并逐字符扫描
我们用标准库的 fopen、fgetc 来打开并读取目标C文件。每读一个字符就交给状态机处理,同时把需要高亮的片段包进HTML的 span 标签。下面示例把输出直接写成HTML,用 class 区分样式,浏览器加载后即可看到颜色。
注意代码中所有 <、> 都做了转义,避免生成的HTML结构被破坏。比如输出 <span> 而非直接写标签文本,但在我们程序里是printf打印字符串,所以源文件里写的是字面量 "<span>"。
#include <stdio.h>
#include <string.h>
/* 关键字列表,实际可扩充 */
const char *keywords[] = {"int", "char", "if", "else", "for", "while", "return", "void", NULL};
int is_keyword(const char *w) {
for (int i = 0; keywords[i]; i++) {
if (strcmp(keywords[i], w) == 0) return 1;
}
return 0;
}
int main(int argc, char *argv[]) {
if (argc < 2) {
printf("usage: %s file.cn", argv[0]);
return 1;
}
FILE *fp = fopen(argv[1], "r");
if (!fp) { perror("fopen"); return 1; }
int c, state = 0; /* 0 normal,1 line comment,2 block,3 string,4 char */
char buf[256]; int bi = 0;
printf("<pre>n");
while ((c = fgetc(fp)) != EOF) {
if (state == 0) {
if (c == '/' && (buf[0] = fgetc(fp)) == '/') {
state = 1; printf("<span class='comment'>//"); continue;
} else if (c == '/' && buf[0] == '*') {
state = 2; printf("<span class='comment'>/*"); continue;
} else if (c == '"') {
state = 3; printf("<span class='string'>""); continue;
} else if (c == ''') {
state = 4; printf("<span class='char'>'"); continue;
} else if (isalnum(c) || c == '_') {
buf[bi++] = c;
} else {
if (bi > 0) {
buf[bi] = 0;
if (is_keyword(buf))
printf("<span class='kw'>%s</span>", buf);
else
printf("%s", buf);
bi = 0;
}
putchar(c);
}
} else if (state == 1) {
putchar(c);
if (c == 'n') { printf("</span>"); state = 0; }
} else if (state == 2) {
putchar(c);
if (c == '*' && (buf[0] = fgetc(fp)) == '/') {
printf("/</span>"); state = 0;
} else if (c == '*') {
ungetc(buf[0], fp);
}
} else if (state == 3) {
putchar(c);
if (c == '"') { printf("</span>"); state = 0; }
} else if (state == 4) {
putchar(c);
if (c == ''') { printf("</span>"); state = 0; }
}
}
printf("</pre>n");
fclose(fp);
return 0;
}
2.1 代码说明与局限
上面程序在 NORMAL 态累积字母数字到下词缓冲,遇到分隔符时判断是不是关键字并包装 span。它能正确处理 //、/* */、字符串与字符常量。但为了简化,没有处理转义引号(如 "a"b")和预处理宏换行,真实场景需要在 STRING 态判断反斜杠跳过下一个字符,在 LINE_COMMENT 遇到行尾才退出。
另外,示例只比较了少量关键字。工程上可以用哈希表或更长的数组,也可以先整文件读入内存再扫描,减少频繁IO。生成的HTML还需要配合CSS定义 .kw、.comment、.string 的颜色,例如给关键字设蓝色、注释设绿色、字符串设红色。
三、不同解析策略的对比
除了手写状态机,还有两种常见思路:一是用 flex 等词法生成器自动产生C词法文件,再嵌入高亮逻辑,优点是规范、易维护,缺点是需要额外工具链;二是用正则逐行替换,优点是代码极短,缺点是遇到跨行块注释或字符串就失效。
下表列出三种方式在典型C文件上的表现:
| 方式 | 多行注释 | 字符串内关键字 | 维护成本 |
|---|---|---|---|
| 手写状态机 | 支持 | 不误判 | 中 |
| flex生成 | 支持 | 不误判 | 低 |
| 正则替换 | 易错 | 误判 | 高 |
3.1 为什么正则不够用
正则通常按行匹配,而C的块注释可以跨多行。若某行结尾是 /*,下一行全是代码却仍应视为注释,单纯行正则无法记录跨行状态。同样,字符串若包含 */ 字样,正则可能错误结束注释态。状态机用单个变量记住上下文,从原理上避免了这类问题。
因此在教学或轻量工具里,手写一个约两百行的C扫描器,比引入庞大依赖更划算,也让人真正理解编译器前端如何切分记号。你只需把输出从HTML换成终端转义码,就能在控制台实现彩色显示。
四、小结与扩展
用C语言对文件做语法高亮,关键在建立正确的词法状态机,逐字符读取并根据状态输出带样式的片段。本文示例展示了从文件打开、状态切换、关键字判断到生成HTML的完整路径。进一步可加入数字高亮、函数名识别、宏指令着色,或把状态机改写为表驱动形式提升可读性。
如果你希望嵌入到网页编辑器,可以把生成的 span 结构直接塞进 div 容器,用JavaScript做行号叠加;若做本地工具,结合 curses 库即可在终端画出彩色代码。理解了底层逻辑,无论换什么语言或界面,思路都是相通的。