ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式场景下用C语言手写HTML解析器:状态机设计与容错实践

嵌入式场景下用C语言手写HTML解析器:状态机设计与容错实践

简介:这是一份面向C/C++初学者与网页解析入门者的轻量级HTML解析实现,核心代码仅30余行,便于快速理解标记语言解析的基本原理。资源包共72个文件,以cpp与h源码文件为主,辅以obj、pdb、ilk等Visual Studio编译中间产物,以及sln、vcxproj工程配置和exe可执行文件,另含少量html、xml示例与日志文件,整体约16.8MB,可直接在VS2010等Windows平台编译运行。解析逻辑覆盖字符串处理、标签令牌化、开始与结束标签匹配、属性提取及简单错误处理等关键环节,适合作为网络爬虫与网页抓取方向的练手素材。目前已有1759人学习下载,读者可借此掌握文本解析思路、树形结构构建方法,并对照源码理解事件驱动式解析的雏形。

1. 一个 C 语言的 HTML 解析器:为什么嵌入式场景下它比你想的更值得做

在资源受限的嵌入式设备上抓取网页数据,很多人第一反应是上 Python 或 Node,但真到了只有几百 KB 内存、没有操作系统、连 libc 都要裁剪的环境里,这些方案全部歇菜。一个 C 语言的 HTML 解析器解决的正是这个问题:用纯 C 写一个能容错、能提取节点、内存占用可控的 HTML 解析库,让设备端自己就能从网页里抠出需要的数据。它适合做物联网网关数据采集、嵌入式 Web 配置页面解析、老旧 C 项目里需要轻量 DOM 操作的场景。我见过太多人在这类项目上翻车,不是因为 C 难写,而是因为 HTML 本身太脏——标签不闭合、属性没引号、嵌套乱七八糟,浏览器能忍,你的解析器不一定能。这篇笔记就把我从零写一个能用的 C 语言 HTML 解析器的路径拆开,包括状态机怎么设计、内存怎么管、哪些坑必须提前绕。

2. 从字节流到节点树:C 语言 HTML 解析器的状态机设计

2.1 为什么不用正则,也不照搬 XML 解析器

很多人第一反应是用正则表达式匹配标签,或者找一个现成的 XML 解析器改改。这两条路我都走过,最后都放弃了。正则的问题在于 HTML 不是正则语言,嵌套结构用正则处理会指数级爆炸,而且遇到<div class="a">里再套<div>的情况,正则根本没法正确配对。XML 解析器的问题更隐蔽:XML 要求标签必须闭合、属性必须加引号、大小写敏感,而真实网页里<br>、<img src=x>、<input disabled>这些写法遍地都是,XML 解析器直接报错退出。

所以正确的做法是自己写一个基于状态机的容错解析器。状态机的核心思路是:逐字符扫描输入流,根据当前状态和遇到的字符决定下一步动作。HTML 解析的状态可以简化为几个核心状态:数据状态、标签开始状态、标签名状态、属性名状态、属性值状态、注释状态、脚本/样式特殊状态。每个状态处理特定字符集,遇到边界就切换状态并触发回调。

这种设计的好处是内存占用可控——你不需要一次性把整个 HTML 读进内存,可以流式处理,每识别出一个节点就交给上层处理。对于嵌入式场景,这意味着你可以用固定大小的缓冲区解析任意长度的网页。

2.2 核心状态机的 C 语言实现骨架

下面是一个最小可用的状态机骨架,我把它拆成枚举和主循环两部分。先看状态定义和节点结构:

/* html_parser.h - 状态机核心定义 */ typedef enum { STATE_DATA, /* 普通文本内容 */ STATE_TAG_OPEN, /* 遇到 '<' 之后 */ STATE_TAG_NAME, /* 正在读标签名 */ STATE_ATTR_NAME, /* 正在读属性名 */ STATE_ATTR_VALUE, /* 正在读属性值(引号内) */ STATE_ATTR_VALUE_UNQ, /* 无引号属性值 */ STATE_COMMENT, /* <!-- 注释内部 */ STATE_SCRIPT, /* <script> 内部,跳过解析 */ STATE_STYLE, /* <style> 内部,跳过解析 */ STATE_END_TAG /* </tag> 结束标签 */ } parser_state_t; /* 节点类型 */ typedef enum { NODE_ELEMENT, NODE_TEXT, NODE_COMMENT } node_type_t; /* 属性键值对 */ typedef struct attr { char *name; char *value; struct attr *next; } attr_t; /* 节点结构 */ typedef struct node { node_type_t type; char *tag; /* 元素标签名,文本节点为 NULL */ char *text; /* 文本内容或注释内容 */ attr_t *attrs; /* 属性链表 */ struct node *parent; struct node *first_child; struct node *last_child; struct node *next_sibling; } node_t; /* 解析器上下文 */ typedef struct { parser_state_t state; char tag_buf[64]; /* 标签名缓冲区 */ int tag_len; char attr_name_buf[64]; int attr_name_len; char attr_val_buf[256]; int attr_val_len; char quote_char; /* 当前属性值的引号字符 */ node_t *current; /* 当前挂载节点 */ node_t *root; /* 根节点 */ int in_script_or_style; /* 是否在 script/style 内 */ } parser_ctx_t;

这个结构体设计的关键点在于:所有缓冲区都是固定大小的栈上数组,不依赖 malloc。标签名限制 64 字节,属性值限制 256 字节,对绝大多数网页够用。如果遇到超长属性值,直接截断而不是崩溃——这是嵌入式场景的生存法则。

接下来是主循环的核心逻辑:

/* html_parser.c - 状态机主循环片段 */ void parser_feed(parser_ctx_t *ctx, const char *data, size_t len) { for (size_t i = 0; i < len; i++) { char c = data[i]; switch (ctx->state) { case STATE_DATA: if (c == '<') { /* 先把之前积累的文本节点挂上去 */ flush_text_node(ctx); ctx->state = STATE_TAG_OPEN; } else { append_text(ctx, c); } break; case STATE_TAG_OPEN: if (c == '/') { ctx->state = STATE_END_TAG; } else if (isalpha(c)) { ctx->tag_len = 0; ctx->tag_buf[ctx->tag_len++] = tolower(c); ctx->state = STATE_TAG_NAME; } else if (c == '!') { /* 可能是注释或 DOCTYPE,简化处理为注释 */ ctx->state = STATE_COMMENT; } else { /* 非法字符,回退到数据状态 */ ctx->state = STATE_DATA; } break; case STATE_TAG_NAME: if (isalnum(c) || c == '-' || c == '_') { if (ctx->tag_len < sizeof(ctx->tag_buf) - 1) ctx->tag_buf[ctx->tag_len++] = tolower(c); } else if (isspace(c)) { ctx->tag_buf[ctx->tag_len] = '\0'; ctx->state = STATE_ATTR_NAME; ctx->attr_name_len = 0; } else if (c == '>') { ctx->tag_buf[ctx->tag_len] = '\0'; open_element(ctx); ctx->state = STATE_DATA; } else if (c == '/') { /* 自闭合标签 <br/> */ ctx->tag_buf[ctx->tag_len] = '\0'; open_element(ctx); close_element(ctx); ctx->state = STATE_DATA; } break; case STATE_ATTR_NAME: if (c == '=') { ctx->attr_name_buf[ctx->attr_name_len] = '\0'; ctx->state = STATE_ATTR_VALUE; ctx->attr_val_len = 0; ctx->quote_char = 0; } else if (c == '>') { /* 无值属性,如 disabled */ if (ctx->attr_name_len > 0) { ctx->attr_name_buf[ctx->attr_name_len] = '\0'; add_attr(ctx, ctx->attr_name_buf, ""); } ctx->tag_buf[ctx->tag_len] = '\0'; open_element(ctx); ctx->state = STATE_DATA; } else if (isspace(c)) { if (ctx->attr_name_len > 0) { ctx->attr_name_buf[ctx->attr_name_len] = '\0'; add_attr(ctx, ctx->attr_name_buf, ""); ctx->attr_name_len = 0; } } else { if (ctx->attr_name_len < sizeof(ctx->attr_name_buf) - 1) ctx->attr_name_buf[ctx->attr_name_len++] = tolower(c); } break; case STATE_ATTR_VALUE: if (ctx->quote_char == 0) { if (c == '"' || c == '\'') { ctx->quote_char = c; } else if (c == '>') { /* 无引号属性值结束 */ ctx->attr_val_buf[ctx->attr_val_len] = '\0'; add_attr(ctx, ctx->attr_name_buf, ctx->attr_val_buf); ctx->tag_buf[ctx->tag_len] = '\0'; open_element(ctx); ctx->state = STATE_DATA; } else if (!isspace(c)) { ctx->attr_val_buf[ctx->attr_val_len++] = c; } } else { if (c == ctx->quote_char) { ctx->attr_val_buf[ctx->attr_val_len] = '\0'; add_attr(ctx, ctx->attr_name_buf, ctx->attr_val_buf); ctx->state = STATE_ATTR_NAME; ctx->attr_name_len = 0; } else { if (ctx->attr_val_len < sizeof(ctx->attr_val_buf) - 1) ctx->attr_val_buf[ctx->attr_val_len++] = c; } } break; case STATE_COMMENT: if (c == '-' && i + 1 < len && data[i+1] == '-') { /* 简化处理:遇到 --> 结束注释 */ i++; /* 跳过下一个 '-' */ if (i + 1 < len && data[i+1] == '>') { i++; ctx->state = STATE_DATA; } } break; default: break; } } }

这段代码的逻辑说明:parser_feed是流式接口,每次喂入一块数据,状态机内部维护跨块的上下文。关键参数包括tag_buf大小 64 字节、attr_val_buf大小 256 字节,这两个值可以根据目标网页的实际情况调整。tolower调用是为了统一标签名和属性名的大小写,HTML 规范里标签名不区分大小写,但属性值区分。open_element和close_element负责在节点树上挂载和弹出节点,add_attr负责把属性追加到当前节点的属性链表。

有一个细节值得注意:在STATE_ATTR_VALUE里,我区分了有引号和无引号两种情况。无引号属性值在真实网页里非常常见,比如<a href=/index.html>,如果只处理引号包裹的情况,这类链接全部会丢。

2.3 节点树的挂载与内存回收策略

状态机只负责识别 token,真正让解析器可用的是节点树的构建。我采用的方式是:维护一个current指针指向当前打开的节点,遇到开始标签就创建新节点并挂到current的first_child或last_child上,然后把current指向新节点;遇到结束标签就把current回退到parent。

/* 打开元素:创建节点并挂载 */ static void open_element(parser_ctx_t *ctx) { node_t *n = calloc(1, sizeof(node_t)); if (!n) return; n->type = NODE_ELEMENT; n->tag = strdup(ctx->tag_buf); n->parent = ctx->current; if (ctx->current) { if (!ctx->current->first_child) { ctx->current->first_child = n; ctx->current->last_child = n; } else { ctx->current->last_child->next_sibling = n; ctx->current->last_child = n; } } else { ctx->root = n; } ctx->current = n; /* 特殊处理:script 和 style 内部不解析标签 */ if (strcmp(ctx->tag_buf, "script") == 0 || strcmp(ctx->tag_buf, "style") == 0) { ctx->in_script_or_style = 1; } } /* 关闭元素:回退 current 指针 */ static void close_element(parser_ctx_t *ctx) { if (ctx->current && ctx->current->parent) { ctx->current = ctx->current->parent; } ctx->in_script_or_style = 0; }

内存回收采用递归释放的方式,从根节点开始深度优先遍历,释放所有子节点和属性链表。这里有一个血泪经验:不要在解析过程中释放节点,因为父节点可能还持有子节点指针。统一在解析完成后调用free_node_tree(root)一次性回收。

对于嵌入式场景,如果连calloc都不想用,可以改成预分配节点池的方式:启动时分配固定数量的节点结构体,解析时从池里取,用完归还。这样内存占用完全确定,不会因为网页大小而波动。

3. 容错处理与边界情况:让解析器在脏 HTML 里活下来

3.1 标签不闭合、嵌套错乱的兜底逻辑

真实网页里<p>不闭合、<li>嵌套<li>、<div>交叉嵌套的情况太常见了。浏览器有一套复杂的容错算法,我们不需要完全复刻,但至少要保证解析器不崩溃、不内存泄漏。

我的策略是:维护一个标签栈,遇到开始标签就压栈,遇到结束标签就弹栈。如果结束标签和栈顶不匹配,就在栈里从顶往下找,找到匹配的就弹到那一层,找不到就忽略这个结束标签。这样即使 HTML 结构错乱,解析器也能继续往下走。

/* 标签栈用于容错匹配 */ #define MAX_TAG_DEPTH 128 static char *tag_stack[MAX_TAG_DEPTH]; static int tag_stack_top = -1; static void handle_end_tag(parser_ctx_t *ctx, const char *tag) { /* 从栈顶往下找匹配的标签 */ for (int i = tag_stack_top; i >= 0; i--) { if (strcmp(tag_stack[i], tag) == 0) { /* 找到匹配,弹栈到这一层 */ while (tag_stack_top >= i) { free(tag_stack[tag_stack_top]); tag_stack_top--; close_element(ctx); } return; } } /* 没找到匹配,忽略这个结束标签 */ }

这段代码的关键参数是MAX_TAG_DEPTH,设为 128 层。超过这个深度的嵌套直接忽略,防止栈溢出。对于绝大多数网页,128 层足够深了。

3.2 属性值里的特殊字符与实体解码

HTML 实体(&amp;、&lt;、&nbsp;等)如果不解码,提取出来的文本会带着一堆&xxx;,后续处理很麻烦。我一般会在文本节点生成时做一次实体解码,把常见的几个实体替换掉。

/* 实体解码:只处理最常见的几个 */ static void decode_entities(char *s) { char *src = s, *dst = s; while (*src) { if (*src == '&') { if (strncmp(src, "&amp;", 5) == 0) { *dst++ = '&'; src += 5; } else if (strncmp(src, "&lt;", 4) == 0) { *dst++ = '<'; src += 4; } else if (strncmp(src, "&gt;", 4) == 0) { *dst++ = '>'; src += 4; } else if (strncmp(src, "&quot;", 6) == 0) { *dst++ = '"'; src += 6; } else if (strncmp(src, "&nbsp;", 6) == 0) { *dst++ = ' '; src += 6; } else { *dst++ = *src++; } } else { *dst++ = *src++; } } *dst = '\0'; }

这个函数是原地解码,不需要额外分配内存。参数说明:只处理&amp;、&lt;、&gt;、&quot;、&nbsp;这五个最常用的实体,其他实体原样保留。如果需要支持更多实体,可以扩展成一个查找表。

3.3 用真实网页片段做回归测试

写完解析器后,一定要用真实网页片段做回归测试。我一般会准备三类测试用例:正常 HTML、脏 HTML(标签不闭合、属性无引号)、极端 HTML(超长属性值、深层嵌套)。测试方法很简单:把网页保存成文件,用解析器跑一遍,然后遍历节点树打印所有标签和文本,人工核对关键数据是否提取正确。

/* 测试用例:遍历节点树并打印 */ static void dump_tree(node_t *n, int depth) { while (n) { for (int i = 0; i < depth; i++) printf(" "); if (n->type == NODE_ELEMENT) { printf("<%s", n->tag); attr_t *a = n->attrs; while (a) { printf(" %s=\"%s\"", a->name, a->value); a = a->next; } printf(">\n"); } else if (n->type == NODE_TEXT) { printf("TEXT: %s\n", n->text); } if (n->first_child) dump_tree(n->first_child, depth + 1); n = n->next_sibling; } }

这个函数递归打印节点树,缩进表示层级。跑完测试用例后,重点检查:标签嵌套关系是否正确、属性值是否完整、文本内容是否被实体解码、script/style 内部是否被正确跳过。

4. 避坑指南:C 语言写 HTML 解析器最容易翻车的五个地方

4.1 缓冲区溢出:标签名或属性值超长导致崩溃

现象:解析某个网页时程序突然段错误,用 gdb 回溯发现是在strcpy或strcat处崩溃。原因:标签名或属性值超过了预分配的缓冲区大小,没有做长度检查。解决:所有写入缓冲区的地方都必须检查剩余空间,超过就截断。我一般会在每个append操作前加一个if (len < sizeof(buf) - 1)判断,宁可丢数据也不能溢出。

4.2 内存泄漏:节点树释放不彻底

现象:长时间运行后内存持续增长,最终 OOM。原因:节点树释放时只释放了节点本身,没有释放tag、text、属性链表里的name和value。解决:写一个递归释放函数,确保每个malloc或strdup出来的指针都被free。我习惯在释放节点前先把所有子节点释放完,再释放属性链表,最后释放节点自身。

4.3 大小写敏感:标签名没统一转小写

现象:用strcmp(node->tag, "div")判断节点类型时,有些<DIV>标签匹配不上。原因:HTML 标签名不区分大小写,但 C 的字符串比较区分。解决:在状态机里读到标签名和属性名时统一转小写,属性值保持原样。这个转换在STATE_TAG_NAME和STATE_ATTR_NAME状态里做,用tolower函数。

4.4 script/style 内部误解析

现象:网页里的 JavaScript 代码包含<或>字符,解析器把它当成标签开始,导致后续解析全部错乱。原因:没有对<script>和<style>标签内部做特殊处理。解决:在open_element里检测到script或style标签时,设置一个标志位,状态机在标志位为真时跳过所有标签解析,直到遇到对应的结束标签。这个逻辑在parser_feed的STATE_DATA分支里加一个判断即可。

4.5 自闭合标签处理不当

现象:<br>、<img>、<input>这些自闭合标签被当成开始标签压栈,导致后续所有节点的父子关系错位。原因:没有识别 HTML 里的 void 元素。解决:维护一个 void 元素列表(br、img、input、hr、meta、link等),遇到这些标签时只创建节点但不压栈,直接回到数据状态。这个列表可以硬编码在代码里,不需要动态配置。

5. 进阶技巧:用回调接口把解析器和业务逻辑解耦

写到这儿,解析器本身已经能跑了。但如果你想让它在多个项目里复用,最好把接口设计成回调模式:解析器不直接构建节点树,而是在识别出元素开始、元素结束、文本、注释时调用用户注册的回调函数。这样上层业务可以按需处理,不需要节点树的项目可以直接在回调里提取数据然后丢弃,内存占用降到最低。

/* 回调接口定义 */ typedef struct { void (*on_element_start)(void *user, const char *tag, attr_t *attrs); void (*on_element_end)(void *user, const char *tag); void (*on_text)(void *user, const char *text, int len); void (*on_comment)(void *user, const char *text, int len); } parser_callbacks_t; /* 使用示例:提取所有链接 */ static void on_start(void *user, const char *tag, attr_t *attrs) { if (strcmp(tag, "a") != 0) return; for (attr_t *a = attrs; a; a = a->next) { if (strcmp(a->name, "href") == 0) { printf("Found link: %s\n", a->value); } } } int main(void) { parser_callbacks_t cbs = { .on_element_start = on_start }; parser_ctx_t ctx = {0}; ctx.callbacks = &cbs; /* 喂入 HTML 数据 */ parser_feed(&ctx, html_data, html_len); return 0; }

这个回调模式的好处是:解析器完全不关心上层怎么用数据,内存分配策略可以完全由上层控制。对于只需要提取特定字段的场景,连节点树都不用建,解析完直接丢弃,内存占用几乎为零。

验证解析器是否正确,我一般用两个指标:一是用valgrind跑一遍,确认没有内存泄漏和非法访问;二是用一批真实网页做回归,对比提取结果和浏览器开发者工具里的 DOM 树是否一致。这两个指标过了,基本就能放心用了。

最后说一个我踩过的坑:不要试图一次性写出完美的解析器。HTML 的容错规则极其复杂,浏览器厂商的实现都有差异。我的做法是先保证核心功能可用,遇到解析不了的网页就加一条测试用例,逐步迭代。这个解析器我维护了三年,到现在还在加新的边界处理。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进