资讯动态

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

发布时间:2026/10/15 18:39:06 来源:尧图企业网站定制
简介这是一份面向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 classa里再套div的情况正则根本没法正确配对。XML 解析器的问题更隐蔽XML 要求标签必须闭合、属性必须加引号、大小写敏感而真实网页里br、img srcx、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[i1] -) { /* 简化处理遇到 -- 结束注释 */ i; /* 跳过下一个 - */ if (i 1 len data[i1] ) { 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 的容错规则极其复杂浏览器厂商的实现都有差异。我的做法是先保证核心功能可用遇到解析不了的网页就加一条测试用例逐步迭代。这个解析器我维护了三年到现在还在加新的边界处理。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑