资讯动态

C语言手写HTML解析器:从零实现轻量级DOM构建与Arena内存管理

发布时间:2026/10/15 18:39:27 来源:尧图企业网站定制
简介这是一份面向C语言初学者与网页解析入门者的轻量级HTML解析实现基于C/C编写核心代码仅三十余行便于快速理解标记语言解析的基本原理。资源包共72个文件以tlog日志、h头文件、cpp源文件、obj与pdb编译产物为主另含sln与vcxproj工程文件、exe可执行程序及少量html、xml示例压缩包约16.8MB可直接在Visual Studio 2010等Windows平台IDE中编译运行。解析器围绕字符串处理、令牌化、标签匹配、属性提取与树形结构构建等关键环节展开并涉及错误处理与事件驱动思路适合作为网络爬虫、网页抓取等进阶话题的练手实例。目前已有1759人学习下载读者可借此掌握文本解析流程与C语言数据结构组织方式为后续深入解析复杂HTML文档打下基础。1. 用 C 语言手撕 HTML 解析器从零实现一个能跑通真实网页的轻量级解析库如果你写过爬虫或者做过网页数据提取大概率用过 BeautifulSoup、lxml、Cheerio 这类现成的解析工具。但有没有想过这些库底层到底怎么把一坨 HTML 字符串变成一棵可遍历的 DOM 树我最近拿到一份用纯 C 语言实现的 HTML 解析器源码整个项目不依赖任何第三方库编译出来就是一个几百 KB 的静态库却能正确处理嵌套标签、自闭合标签、注释、属性解析和实体转义。这份资源适合两类人一是想深入理解 HTML 解析底层机制的 C/C 开发者二是需要在嵌入式或资源受限环境下做网页内容提取的工程师。它解决的核心问题是——在没有 Python 运行时、没有 Node.js 环境的地方你依然能用一个轻量级 C 库把 HTML 结构解析出来。2. 解析器架构拆解词法分析、语法树与内存布局2.1 为什么不用正则而是手写状态机很多人第一反应是「HTML 解析用正则不就行了」。我一开始也这么想直到遇到嵌套div里套span再套a的情况正则的回溯直接爆炸。这份 C 语言实现采用的是经典的词法分析 语法分析两阶段方案和浏览器内核的思路一致只是做了大幅简化。词法分析器Tokenizer负责把原始字符串切成一个个 Token核心状态包括TAG_OPEN遇到进入标签开始状态TAG_NAME读取标签名如div、spanATTR_NAME/ATTR_VALUE解析属性键值对TEXT标签之外的文本内容COMMENT!-- --注释块语法分析器Parser则维护一个栈结构遇到开标签就压栈遇到闭标签就弹栈最终构建出一棵HTMLNode树。每个节点包含标签名、属性链表、子节点链表和父节点指针。typedef struct HTMLNode { char *tag; // 标签名如 div char *text; // 文本内容仅文本节点 Attr *attrs; // 属性链表头 struct HTMLNode *parent; // 父节点 struct HTMLNode *children; // 子节点链表 struct HTMLNode *next; // 兄弟节点 } HTMLNode;这个结构看起来简单但指针关系必须维护正确。我见过不少人自己写的时候忘了设置parent指针结果遍历时只能从上往下走想反向查找父节点就抓瞎了。2.2 内存分配策略为什么用 Arena 而不是逐个 malloc这是这份源码里我觉得最值得学的一个设计。如果每个节点、每个属性、每个字符串都单独malloc解析一个中等复杂度的页面可能产生上千次内存分配释放时还要递归遍历整棵树逐个free既慢又容易漏。源码采用的是Arena 分配器也叫区域分配器一次性申请一大块内存比如 64KB所有节点和字符串都从这块内存里「切」出来最后只需要free一次。typedef struct Arena { char *buffer; // 大块内存起始地址 size_t capacity; // 总容量 size_t offset; // 当前分配偏移 } Arena; void *arena_alloc(Arena *a, size_t size) { // 对齐到 8 字节边界 size (size 7) ~7; if (a-offset size a-capacity) { return NULL; // 容量不足实际项目中会扩容或报错 } void *ptr a-buffer a-offset; a-offset size; return ptr; }参数说明size会自动向上对齐到 8 字节避免某些架构上的非对齐访问问题。capacity一般设为 64KB 或 128KB根据目标页面大小调整。如果解析超大页面可以在arena_alloc返回 NULL 时触发扩容逻辑——新申请一块两倍大小的内存把旧数据拷过去。这种策略的代价是你不能单独释放某个节点但对于「解析-使用-整体释放」这个典型场景来说完全够用而且性能提升非常明显。我实测下来Arena 方案比逐个 malloc 快了大约 3 到 5 倍。2.3 标签栈的压弹逻辑与自闭合处理解析器维护一个HTMLNode *stack[256]数组作为标签栈top指针指向栈顶。核心循环逻辑如下// 遇到开标签 if (token.type TOKEN_OPEN_TAG) { HTMLNode *node create_node(arena, token.name); parse_attrs(arena, node, token.raw); // 挂到当前栈顶节点的 children 链表 append_child(stack[top], node); // 自闭合标签不入栈 if (!token.self_closing) { stack[top] node; } } // 遇到闭标签 else if (token.type TOKEN_CLOSE_TAG) { // 从栈顶往下找匹配的标签名 while (top 0 strcmp(stack[top]-tag, token.name) ! 0) { top--; // 容错自动闭合未关闭的标签 } if (top 0) top--; }关键点闭标签匹配时采用「向下查找」而不是严格匹配栈顶这是为了容错。真实网页里经常出现p没闭合就写div的情况浏览器也是这么处理的。stack大小设为 256 是经验值一般页面嵌套不会超过这个深度超了可以动态扩容。自闭合标签的判断依据是一张硬编码表br、hr、img、input、meta、link等。遇到这些标签名时即使没有/也当作自闭合处理。3. 从源码到可执行文件编译、集成与最小验证用例3.1 编译静态库与目录结构拿到源码后典型的目录结构是这样的htmlparser/ ├── src/ │ ├── arena.c │ ├── tokenizer.c │ ├── parser.c │ └── node.c ├── include/ │ └── htmlparser.h ├── examples/ │ └── demo.c └── Makefile编译成静态库的命令# 编译所有源文件为目标文件 gcc -c -O2 -Iinclude src/*.c # 打包成静态库 ar rcs libhtmlparser.a *.o # 编译示例程序并链接 gcc -O2 -Iinclude examples/demo.c -L. -lhtmlparser -o demo参数说明-O2开启常规优化解析器这种循环密集的代码提升明显。-Iinclude指定头文件路径。如果你的目标平台是嵌入式 ARM把gcc换成对应的交叉编译器即可源码本身没有平台相关代码。3.2 最小验证用例解析一段真实 HTML下面是一个完整的调用示例解析一段包含嵌套标签、属性和注释的 HTML#include stdio.h #include string.h #include htmlparser.h int main() { const char *html htmlbody div class\container\ id\main\ h1Hello/h1 pThis is bbold/b text./p !-- comment -- img src\test.png\ / /div/body/html; Arena arena; arena_init(arena, 64 * 1024); // 64KB 内存池 HTMLNode *root html_parse(arena, html, strlen(html)); if (!root) { fprintf(stderr, Parse failed\n); return 1; } // 查找 idmain 的节点 HTMLNode *main_div html_find_by_id(root, main); if (main_div) { printf(Found div#%s, tag%s\n, html_get_attr(main_div, id), main_div-tag); // 遍历子节点 for (HTMLNode *c main_div-children; c; c c-next) { printf( child: %s\n, c-tag ? c-tag : #text); } } arena_free(arena); // 一次性释放全部内存 return 0; }逻辑说明arena_init初始化内存池html_parse返回根节点指针。html_find_by_id是深度优先遍历找到匹配属性后返回节点指针。最后arena_free一次性回收不需要递归释放每个节点。参数说明html_parse的第三个参数是字符串长度传strlen(html)即可。如果你的 HTML 来自网络响应长度已知直接传缓冲区大小避免多一次strlen扫描。3.3 属性提取与文本内容获取属性在内部以链表形式存储对外提供html_get_attr接口const char *html_get_attr(HTMLNode *node, const char *key) { for (Attr *a node-attrs; a; a a-next) { if (strcmp(a-key, key) 0) { return a-value; } } return NULL; // 属性不存在 }文本内容的获取稍微复杂一些因为文本可能分散在多个子节点中。常见做法是写一个递归函数把所有文本节点的内容拼接起来void collect_text(HTMLNode *node, char *buf, size_t *len, size_t max) { if (!node) return; if (node-text) { size_t tlen strlen(node-text); if (*len tlen max) { memcpy(buf *len, node-text, tlen); *len tlen; } } for (HTMLNode *c node-children; c; c c-next) { collect_text(c, buf, len, max); } }这个函数在提取p标签内的纯文本时特别有用。注意max参数防止缓冲区溢出这是 C 语言里必须养成的习惯。4. 避坑与排查那些让我调试到凌晨的坑4.1 属性值里的引号嵌套导致解析截断现象解析div>typedef void (*TagCallback)(HTMLNode *node, void *userdata); void html_parse_with_callback(Arena *arena, const char *html, size_t len, TagCallback cb, void *userdata) { // 在 parser 循环里每遇到一个开标签就调用 // cb(node, userdata); // 用户可以在回调里判断标签名决定是否处理 }用法示例——只提取所有a标签的hrefvoid on_tag(HTMLNode *node, void *userdata) { if (strcmp(node-tag, a) 0) { const char *href html_get_attr(node, href); if (href) { printf(Link: %s\n, href); } } } // 调用 html_parse_with_callback(arena, html, strlen(html), on_tag, NULL);这种方式内存占用更低因为不需要为不关心的标签保留节点。但代价是你没法做「先找到父节点再找子节点」这种依赖树结构的查询。5.2 性能验证用真实页面测一把我拿一个约 200KB 的 HTML 页面做了测试对比三种方案方案解析耗时内存峰值代码量本 C 解析器Arena约 3ms约 256KB约 800 行本 C 解析器逐个 malloc约 12ms约 1.2MB约 850 行某脚本语言解析库约 45ms约 8MB不适用测试环境是普通 x86 笔记本编译优化-O2。数据仅供参考不同页面结构差异很大。但 Arena 方案的优势很明显——内存峰值只有逐个 malloc 的五分之一左右。验证方法也很简单在main函数前后加clock()计时用/usr/bin/time -v看内存峰值/usr/bin/time -v ./demo test.html 21 | grep -E Maximum resident|Elapsed5.3 一个我常用的调试习惯解析器这种代码出问题往往在边界条件上。我现在的习惯是每次改完 tokenizer 或 parser先跑三个固定用例——一个正常嵌套的、一个全是自闭合标签的、一个故意不闭合的。三个都过了再跑真实页面。这个习惯帮我省了很多「改好一个 bug 引入两个新 bug」的后悔药时间。另外如果你要把它集成到自己的项目里建议先用-fsanitizeaddress编译一遍跑测试用例把内存越界和泄漏都揪出来再上生产。C 语言写解析器指针操作密集AddressSanitizer 基本是必备工具。希望这份拆解能帮到你拿到源码后先跑通 demo再按自己的需求改回调逻辑基本就能覆盖大部分轻量级 HTML 提取场景了。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑