用C语言实现HTML解析器:容错模型与状态机实战
简介这是一份面向C/C初学者的HTML解析器示例工程。核心代码仅三十多行演示如何在纯C环境下完成HTML文本的标签识别、属性提取与结构解析涉及字符串查找、令牌化、标签配对、属性解析及错误处理等关键知识点可作为理解词法分析和树形数据结构的入门范例。压缩包共72个文件、约16.8MB除cmarkup.cpp、Markup.h等源码与头文件外还包含Visual Studio 2010工程配置、编译生成的exe与pdb调试文件、HTML测试页、ReadMe说明以及42个tlog编译日志完整还原项目从编写、编译到调试的过程可直接打开运行查看效果。目前已有1759人学习下载。借助这份精简工程可掌握HTML解析的基本流程和常见容错思路深入理解HTML文档结构也可作为学习网络爬虫、网页抓取等更高级话题的前置练习。代码量少、结构清晰适合边读边改非常适合C语言初学者完整体验一个解析器小项目的实现过程。1. 一个 C 语言写的 HTML 解析器最难的不是读标签而是容忍烂代码HTML 大概是所有编程语言里最不挑剔输入的一类——标签可以不闭合、属性值可以不加引号、标签还能交叉嵌套而主流浏览器的渲染引擎从来不会因此拒绝渲染。一个用 C 语言实现的 HTML 解析器恰恰是这套容错机制的绝佳解剖样本。C 语言要求你对每一块内存负责HTML 却要求解析器在烂输入面前表现得像什么都没发生这种反差决定了“用 C 写 HTML 解析”从来不是选型问题而是对底层机制的一次硬核检验。搞懂这个解析器你得到的不是“怎么遍历标签”这种 API 层认知而是完整看到编码探测、词法状态机、树构建三件套是怎么在容错目标下协同的。以后再看 JSON、YAML 这类格式的解析器设计你一眼就能认出哪些是通用容错骨架哪些是格式本身的特殊规则。2. HTML 不严谨到什么程度把容错模型当作解析器的地基HTML 看似可以用正则表达式去匹配标签但对一个真正的解析器来说“正则能抓标签”和“解析器能建树”之间有巨大鸿沟。正则只能匹配良构模式而 HTML 的绝大多数真实输入都处于“半良构”状态。HTML5 规范没有把它当作缺陷去修补而是把容错动作一条条写进了规范强制解析器执行。这套容错模型才是 C 语言解析器的真正地基。2.1 良构性与容错模型的分岔XML 的解析器遇到任何一个良构性错误都必须停下来因为 XML 的数据交换语义要求文档必须精确出错后继续解析只会产生垃圾数据。HTML 则相反它的语义是“文档渲染”渲染引擎对残缺内容的容忍是刻进规范里的。HTML5 规范定义了一个专门的 parse error 概念并明确要求解析器遇到 parse error 时“记录错误并继续解析”。这个 continue 的设计是 HTML 解析器和 XML 解析器的根本分岔点。在具体实现上常见的思路是解析器内部维护一个错误收集器。tokenizer 阶段把非法字符、未闭合注释、非法标签名都写进一个链表里但绝不中断流程。真正的严格校验放到树构建完成之后由上层应用根据错误列表决定要不要拒绝这次解析。用 C 语言做这件事很顺手一个错误记录链表一个贯穿始终的解析上下文结构体错误处理不需要复杂的异常机制。我一般会这么理解这套设计解析器里的错误不是异常而是另一种输入。错误一旦被当作输入解析器就无须“处理异常”只需要“转移状态”。这也是 HTML 规范与 C 语言的天然契合点——C 没有内建异常机制你反而不会被异常处理的思路带偏纯状态机就是最直接的表达。2.2 编码探测的顺序问题BOM、meta 声明与启发式兜底解析器拿到手的是无符号字节流而 HTML 没有强制指定编码。编码探测的顺序在实现里是有讲究的优先级探测手段触发条件结果1BOM 识别流前几个字节匹配 BOM 序列直接确定编码跳过后续探测2字节流预扫描前 1024 字节内找到meta charset或 XML 声明采用声明指定的编码3传输层默认值HTTP 头或文档来源附带编码参数采用该默认值4启发式兜底按 UTF-8 做合法性校验失败则退回系统默认不确定但至少能出结果这个顺序不是拍脑袋定的。BOM 是最高优先级的硬信号因为它是字节序标记不可能被误判meta 声明是作者明确给出的意图但可能写错位置传输层默认值只在 HTTP 场景存在启发式是最后无奈的猜测。用 C 语言实现这个逻辑时位运算天然适合 BOM 判断——读取两到三个字节与固定字节序列比较即可连函数调用都不需要。这里有个容易翻车的细节meta 标签的 charset 声明必须出现在前 1024 字节内才有效而且实现要兼容两种写法——meta http-equivContent-Type contenttext/html; charsetgbk和meta charsetgbk。只认其中一种就会让大量历史页面乱码。2.3 实体解码 不是什么时候都算实体引用HTML 文本里的一定代表实体引用吗不是。在数据状态下只有当后面紧跟#、或者后面是一串字母且最终以分号结尾时才需要解码单独的按原样输出。这个“有条件解码”的规则是新手最容易踩的地方。常见的实现思路是不单独做一个解码器而是把解码逻辑挂在 tokenizer 的状态转移里。遇到先进入一个临时状态向后读若干字符判断能否构成合法引用。能构成就输出解码后的字符不能构成就把当作普通字符吐回去并切回之前的状态继续解析。解码时机也要分清。数据态里的实体解码与属性值态里的实体解码行为不完全相同。比如nbsp;在 HTML5 里属于“历史遗留实体”即使不加分号在某些上下文也会被解码而数字字符引用如#x41;对分号的处理在不同上下文有细微差异。写 C 实现时我会把实体解码和状态机拆成两个模块状态机只负责在合适的时机调用解码函数解码函数只负责查表输出。这样两个模块可以独立测试避免规则相互纠缠。3. Tokenizer如何用 C 语言的状态机把字符流削成标签流从字节流到标签流这是 HTML 解析的第一道工序。C 语言写 tokenizer 的最大优势是你可以把一个字符一个字符地喂进状态机不做任何缓冲拷贝。整个解析循环只有两层结构外层驱动读取下一个字符内层状态机决定这个字符怎么改变当前状态。3.1 字符驱动状态转移一个 switch 就是一台词法机核心状态机的形状非常简单。用枚举定义状态用 switch 按当前状态分发字符每个分支决定下一个状态typedef enum { STATE_DATA, /* 普通文本 */ STATE_TAG_OPEN, /* 刚遇到 */ STATE_TAG_NAME, /* 正在读标签名 */ STATE_ATTR_NAME, /* 正在读属性名 */ STATE_ATTR_VALUE, /* 正在读属性值 */ STATE_COMMENT /* 正在读注释 */ } html_state; html_state st STATE_DATA; const unsigned char *p buf, *end buf len; while (p end) { switch (st) { case STATE_DATA: if (*p ) st STATE_TAG_OPEN; break; case STATE_TAG_OPEN: if (*p /) { /* 进入结束标签处理 */ } else if (*p !) st STATE_COMMENT; else { /* 这就是起始标签开始收标签名 */ st STATE_TAG_NAME; } break; case STATE_TAG_NAME: if (*p || *p ) { /* 标签名收尾切换状态 */ } break; /* 其余状态同理 */ } p; }这段代码虽然省略了细节但骨架是完整的。state是唯一的核心变量输入是单个字符输出是状态转移。比函数指针表更直观也比递归下降更省栈。常见实现里还有一种二维查表法——用“当前状态 × 字符类别”索引转移表速度更快但调试不友好switch 版本分支清晰大多数场景性能足够。注意这里的字符类别判断有个 C 语言特有的坑ctype.h里的isalpha受当前 locale 影响在某些环境下会把高位字节当字母。做 HTML 解析时更稳的做法是自己维护一张 256 字节的属性表用位掩码表示“是否字母”“是否空白”“是否标签名合法字符”查表比调用函数快一个数量级。3.2 缓冲与字符串驻留malloc 次数降一个量级的设计tokenizer 输入的原始字节流通常来自网络或文件读取以 4KB 或 8KB 为一块。C 实现里常见做法是维护一个分块缓冲链表每次读取追加一块但不复制字符。解析位置是一个指针记住当前块和块内偏移行列号根据偏移计算。这样整个 tokenizer 不需要对输入做一次完整拷贝。真正的内存大头在标签名、属性名和属性值的存储上。如果每读到一个标签就 malloc 一次解析一个 1MB 的页面可能产生几十万次小分配。常见的优化是字符串驻留池所有字符串从一块连续大缓冲里分配分配方式是把指针往前拨一段并把字节复制过去。驻留池内部维护一个哈希表字符串内容相同就返回已有地址不再重复分配。这个设计带来的额外好处是字符串比较变快。要判断当前标签名是不是div不需要逐个字节比内容只要比较驻留池返回的指针是否相等——因为内容相同的字符串已经在池里归并成同一份。属性名字段也可以用同样的机制连哈希都不用算第二遍。驻留池的生命周期通常是“一次解析一次池”。解析开始时创建解析结束时整块释放。这样标签名、属性名、文本内容全部一次性回收C 语言的“手动内存管理”变成了“两行代码管理”创建时calloc销毁时free。中间不需要任何逐节点释放。3.3 把 parse error 当成普通事件传递tokenizer 阶段会遇到大量非预期输入标签名里有非法字符、注释没闭合就遇到文件结尾、属性值缺右引号。这些情况绝不能把整个解析中止否则浏览器就没法渲染任何带瑕疵的网页。常见实现是给 token 结构加一个错误标记位。tokenizer 照常产出 token但把has_error置位同时把具体错误码写进上下文里的错误列表。树构建阶段看到带错误标记的 token会走对应的容错规则外部调用者解析结束后查错误列表可以得到完整的错误清单。HTML5 规范对这类情况有明确表述记录错误、继续解析、在树构建阶段补偿。tokenizer 要做的只是“不撒谎”——该标记的标记该放行的放行。4. 树构建标签流到 DOM 的插入规则比想象中更讲规矩tokenizer 输出的是一串扁平的事件树构建阶段的任务是把它们组织成有结构的 DOM 树。这个阶段比 tokenizer 更复杂因为规则不是“遇到什么输出什么”而是“遇到什么决定它插到哪、谁先闭合、谁被忽略”。C 语言在这个阶段主要解决两件事节点怎么组织以及容错规则怎么用代码表达。4.1 用结构体和指针组织 DOM 节点先看节点本身长什么样。一个简化但完整的 C 结构体typedef struct html_node { int type; /* 元素 / 文本 / 注释 / 文档 */ unsigned char *name; /* 指向驻留池中的标签名 */ struct html_node *parent; /* 父节点 */ struct html_node *first_child; /* 第一个孩子 */ struct html_node *last_child; /* 最后一个孩子 */ struct html_node *next; /* 兄弟链表 */ struct html_attr *attrs; /* 属性链表 */ } html_node;所有节点从同一个内存池分配所以parent、first_child、next这些指针都指向池内地址。树构建完成时整个内存池一次释放不需要递归释放每个节点。这里的技巧是孩子节点的生命周期一定不会超过父节点所以父子关系不需要引用计数。我一般会这么理解这个结构它把“树”和“内存”两件事解耦了。树结构用链表组织遍历顺序是first_child往下、next走兄弟内存用池管理释放顺序是“整池一起倒”。如果混用 malloc 和池就会陷入“先释放谁后释放谁”的泥潭。4.2 开放元素栈与隐式自动闭合树构建的核心数据结构不是树本身而是一个开放元素栈。栈顶是当前正在处理的元素所有新节点默认作为栈顶的孩子插入。遇到结束标签时把对应的开放元素从栈里弹出。这个模型本身很简单真正的复杂度在“自动闭合”规则上。比如 HTML 里p标签不允许嵌套。当栈顶已经是p再遇到一个p起始标签时正确行为不是插入一个p的孩子p而是先把栈顶的p弹出再插入新的p。更常见的场景是块级标签的强制闭合当栈里有一个没写完的p紧接着来了一个div解析器必须自动把p闭合让div成为p的兄弟而不是子节点。触发条件行为栈顶是p又来一个p自动弹出旧p再插入新p栈内有未闭合p遇到块级标签先强制闭合p再按新标签正常插入栈顶是li再来一个li自动闭合旧li插入新li遇到结束标签/p但栈里没有p忽略该结束标签不报错这些规则不适合在 tokenizer 里判断因为 tokenizer 看不见树的上下文。树构建阶段用一个按标签名分派的分发表每个标签名对应一个处理函数处理函数只维护栈和插入位置。C 语言实现时这个分发表可以是一张静态表标签名通过驻留池指针匹配哈希一次就能找到处理函数不需要逐字符串比较。4.3 表格容错Foster parenting 如何给乱写的结构兜底表格是 HTML 里容错规则最复杂的地方因为表格的合法结构非常严格table里只能有caption、colgroup、thead、tbody、tfoot、tr这些特定子元素。但真实网页里经常出现tabledivhello/divtr.../tr/table这种写法直接把div塞进table。规范给出的方案是“寄养式父节点”机制。当解析器在table内部遇到非表格内容时不会把这个节点插入到table下面而是把它“寄养”到table的父节点下同时后续内容继续按表格规则处理。看一下实际效果。输入table沉默divhello/divtrtdx/td/tr/table如果完全不处理“沉默”文本和div会成为table的子节点这在 DOM 里既不符合规范渲染时也会导致样式继承混乱。应用寄养规则后文本“沉默”和div被提升为table的兄弟节点位于table之前tr和td仍然正常进入表格结构。最终树形状是根节点下依次有文本“沉默”、table元素、div元素。而table内部只有tr td 文本“x”。实现这个机制的关键是一个额外的“插入位置”指针。正常插入位置是栈顶元素但一旦进入表格容错状态插入位置被临时重定向到table的父节点。用代码表达就是每次插入前检查当前插入位置是否在表格上下文里是则替换插入目标并标记为“寄养节点”。规则看似绕但它保证了渲染时文本和块级元素不会被困在表格的严格结构里。5. 常见问题与避坑C 语言写 HTML 解析器的高频翻车点这部分直接记录写解析器过程中的真实翻车案例。每条都是先描述现象再分析原因最后给出解决方式。5.1 块级标签未隐式闭合树结构静默错乱现象输入divphello/div预期是div下挂一个pp下挂文本。实际解析结果里p一直没闭合/div也没能正确闭合外层div整棵树比预期多套了一层后续遍历全部错位。原因只实现了“遇到结束标签弹栈”没实现“遇到块级标签自动闭合当前p”的规则。栈里p一直挂着/div弹栈时发现栈顶是p不是div处理函数没写“向上查找”的逻辑。解决在插入新节点之前先对栈顶做一次“是否会被新标签隐式闭合”的检查。实现时按标签分发表里加一步check_implied_end_tags遇到块级标签主动闭合p、li、dt、dd等可隐式闭合的标签。5.2 属性值实体解码导致数据被改写现象一个属性值写成href?a1amp;b2解析出来变成了?a1b2这个没有问题。但另一个属性值写成href?a1b2裸解析结果也变成了?a1b2看起来没问题。真正翻车的是文本里的把用户输入的ATT解成了AT。原因实体解码逻辑没有区分“数据态”和“属性值态”。在属性值里裸不是合法引用的起点必须原样保留但在数据态里裸同样不能解码——只有满足条件时才是引用。实现里直接用“读到就解码”的粗暴逻辑把不该解的部分也解了。解决解码入口只在进入字符引用状态时调用。进入前先看下一个字符是#或字母才尝试匹配实体表否则直接把作为普通字符输出并留在当前状态。这段逻辑放到一个独立的try_consume_char_ref函数里数据态和属性值态共用但传入一个参数区分上下文。5.3 字符串驻留池生命周期没管好出现悬垂指针现象解析完一个页面后再解析第二个页面第一个页面的标签名指针内容乱掉或者释放了解析上下文后外部调用者拿着节点指针去读name读出一串垃圾。原因驻留池被整体释放了但节点结构里存的是直接指向池内name的指针不是拷贝。外部调用者以为节点数据在解析结束后仍然有效实际已经悬垂。解决把“解析上下文”和“文档对象”的生命周期绑定清楚。池在解析开始时创建在文档对象销毁时释放节点指针只允许在文档存活期间使用。另一个常见做法是在文档结构体里保存池的根指针所有节点分配时带上文档指针释放时只释放池不在节点层做任何free。5.4 把 XML 的自闭合规则套到 HTML 上现象输入script/alert(1)/script。预期是空脚本加一个标签。实际解析器把script/当成自闭合标签后面所有内容都被当成普通文本甚至吞掉了后续的闭合标签导致大片内容成为脚本内容。原因HTML 里只有少数元素如br、img、hr在遇到/时按自闭合处理script不在其中。对script标签字符在它的内容里是合法的解析器必须进入脚本数据状态把script之后所有内容当作脚本文本直到遇到/script。用 XML 规则处理就是在错误的状态里读错了输入。解决tokenizer 维护一个“当前元素类型”的上下文。遇到script时进入脚本数据状态在该状态里不识别标签语法只查找/script这个结束序列。script/中的/会被当作脚本内容的一部分而不是自闭合标记。5.5 排查解析器 bug 的顺序先最小化输入再查编码与缓冲现象一个大型 HTML 页面解析结果和浏览器不一致但错误位置随机有时是这个标签错乱有时是另一个。原因这是排查思路的问题不是单一 bug。解析器里 tokenizer 缓冲、树构建状态、实体解码三个模块相互影响直接在大页面里定位会把时间浪费在无关路径上。解决固定一个排查顺序。第一步把出错片段裁剪成最小可复现输入手动推演预期树第二步给 tokenizer 加一个“事件转储”开关把 token 流打印出来确认 tag 名、属性名、文本片段都正确第三步再转储树构建阶段的插入操作对比每一步的栈顶和插入位置。绝大多数问题在看到 token 流时就能定位——要么是 tokenizer 状态错要么是树构建的自动闭合规则缺项。6. 设计取舍事件回调与整树构建之间没有银弹一个 HTML 解析器对外暴露什么接口决定了它的内存特征和适用场景。常见实现里有两派事件回调风格和整树构建风格。事件回调风格在解析过程中把标签事件逐个抛给上层优点是内存占用低、支持流式处理但上层如果要做随机访问就得自己维护一棵树等于把树构建的逻辑又做了一遍。整树构建风格直接把 DOM 树建好再返回上层用起来方便但代价是整棵树必须全部驻留内存。维度事件回调风格整树构建风格内存占用只保留上下文栈全树常驻首个结果产出边读边出必须等 tokenizer 跑完全程上层易用性需要自己维护树结构查询、修改、遍历都直接错误恢复恢复逻辑要自己写规范里的容错机制已内建对 C 语言来说这个取舍还有一层现实考量树构建规则非常复杂如果每个上层使用者都要自己维护一份自动闭合和表格容错逻辑几乎必然出错。因此大多数解析器最终都提供整树构建接口事件接口留给流式场景。比较好的折中是把两者做成同一套 tokenizer 上的两个可插拔输出端。我最早写某个跨平台系统的配置解释器时就吃过这个亏先图省事只做了事件回调结果发现上层拿到的只是一串散落的事件根本没法做嵌套配置的校验。后来重构成 tokenizer 与树构建分层事件输出和树构建共用同一套词法状态机才算把问题解决掉。从那以后我每次写解析器都强制先定义好错误处理契约再动手写状态机。这个习惯帮我避开了许多看不见的坑。希望帮到你。本文还有配套的精品资源点击获取