news 2026/10/22 1:12:23

手写编译器前端:从词法分析到三地址码的完整实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写编译器前端:从词法分析到三地址码的完整实践

简介:一份编译原理课程设计报告,围绕简单文法编译器前端展开,面向计算机专业学生、编译器初学者及需要完成课程设计的读者。报告以递归下降子程序法作为语法和语义分析的基础,将词法分析、语法分析、语义分析与四元式中间代码生成整合到同一流程中,同时针对基本文法进行了常量定义、数组结构、条件分支语句、循环语句等扩展,覆盖变量说明、算术运算、逻辑运算和赋值表达式等典型语言现象。设计中还将状态转换关系、关键字表和界符表以文件形式保存,便于修改和扩充;并设计了递归子程序栈,可在分析时观察每一步的子程序调用情况,为理解编译过程提供了直观依据。资源共1个docx文档,容量381KB,内容是完整的课程设计报告,包括设计任务、算法与数据结构、模块流程、程序说明、实验结果和结论,结构清晰,适合作为编译原理课程设计和期末复习的参考资料。目前已有694人学习下载。

1. 一个简单文法编译器前端:为什么说难点不在写代码,而在设计文法

很多人以为编译器前端就是背熟词法分析和递归下降那几章,然后照模板敲代码。真上手写一个能处理嵌套括号、运算符优先级、变量声明的简洁解析器时,才会发现最难受的不是堆代码,而是文法本身怎么写。左递归、公共前缀、悬空else、贪心匹配,这些全是在文法设计阶段埋下的雷,跑到语法分析才炸出来。这篇文章要讲的,就是如何从零设计一个简单但能实际跑起来的编译器前端:从Token划分、上下文无关文法改造,到递归下降解析、AST构建,再到错误恢复和三地址码生成。新手跟着能搭出最小骨架,熟手可以拿到边界参数与踩坑清单。我基于自己给某公司写过的一个配置表达式解析器做了精简,去掉业务细节,只留前端主干。你有没有想过,一个只有加减乘除和赋值语句的“玩具文法”,改上三版仍然能在第三天翻车?这就是为什么值得先把它讲透。

2. 从原始字符流到Token流:先定Token,再写词法骨架

2.1 Token划分的边界:哪些词该归词法,哪些该归语法

动手写词法分析器之前,必须先回答一个问题:什么字符组合算一个Token,什么算语法错误。常见的误区是把所有合法的输入片段都塞给词法,导致正则写得无比复杂;另一头则相反,把本应该由词法消化的空白和注释留给语法,让解析器到处判空格。我的一般做法是:Token是“语法上不可再分的符号”,而空白、换行、注释属于可忽略的字符流,不是Token。关键字、标识符、数字、运算符、括号、分号各自归一类。这里有个实际边界问题:比如负号。-5里的负号和a - b里的减法运算符,是否同一个Token?如果你把减号统一作为一个 MINUS Token,那么-5就需要语法层额外处理一元运算符。更简单的做法是让词法层不做语义判断,只输出 MINUS,由语法层决定是一元还是二元。这样词法逻辑保持简单,代价是语法产生式里多一条 unary 规则。

另一个边界是数字的表示。多数简单前端只支持整数字面量,但即使如此也要明确:123abc是词法错误还是两个Token?按照“最长匹配”原则,词法会先读123,再把abc作为下一个Token。如果后面正好接了一个标识符,就会产生两个相邻Token而没有运算符,语法层才能报错。如果你希望123abc直接报错,需要在词法里识别数字后紧跟字母时的错误状态。这个选择没有标准答案,关键是你要在动手前定清楚,否则后面错误信息会非常奇怪。

2.2 手写一个最小词法分析器:状态机与正则的取舍

对于简单文法,我倾向手写词法分析器,而不是一上来就引 flex 或正则库。原因是简单前端的关键字少、Token模式简单,手写状态机个位数的状态足够覆盖,而且调试时能精确控制出错位置。下面是针对表达式语法的词法分析器,支持标识符、整数、运算符和分号。

import sys class Token: def __init__(self, kind, value, line, col): self.kind = kind # 'ID', 'NUM', 'PLUS', 'ASSIGN', ... self.value = value # 原始文本或语义值 self.line = line self.col = col def __repr__(self): return f"Token({self.kind}, {self.value!r}, {self.line}:{self.col})" def tokenize(src): tokens = [] i = 0 n = len(src) line = 1 col = 1 while i < n: ch = src[i] # 跳过空白与换行,维护行列号 if ch in ' \t\r': i += 1 col += 1 continue if ch == '\n': line += 1 col = 1 i += 1 continue # 标识符与关键字:以字母或下划线开头,后接字母数字下划线 if ch.isalpha() or ch == '_': start_col = col start = i while i < n and (src[i].isalnum() or src[i] == '_'): i += 1 col += 1 text = src[start:i] tokens.append(Token('ID', text, line, start_col)) continue # 数字:只支持十进制整数,后续可扩展浮点 if ch.isdigit(): start_col = col start = i while i < n and src[i].isdigit(): i += 1 col += 1 text = src[start:i] tokens.append(Token('NUM', int(text), line, start_col)) continue # 多字符运算符与单字符运算符 two = src[i:i+2] if two == ':=': # 简单文法里可以用 := 表示赋值,避免与比较混淆 tokens.append(Token('ASSIGN', ':=', line, col)) i += 2 col += 2 continue simple = {'+':'PLUS', '-':'MINUS', '*':'STAR', '/':'SLASH', '(':'LPAREN', ')':'RPAREN', ';':'SEMI'} if ch in simple: tokens.append(Token(simple[ch], ch, line, col)) i += 1 col += 1 continue # 无法识别的字符:记录具体行列,方便报错 raise SyntaxError(f"无法识别的字符 {ch!r} 位于 {line}:{col}") tokens.append(Token('EOF', None, line, col)) return tokens

这段代码的逻辑很直观:每个字符进入一个判断链,空白和换行被跳过;字母开头走标识符分支,直到遇到非字母数字停止;数字走整数分支。所有Token都携带行列号,这是后面错误恢复的基础。有一个值得注意的参数:我把赋值运算写成:=而不是=。如果语言里后续要加比较运算符==,=作为赋值会出现二义性。使用:=能避免词法上的最长匹配冲突。如果你坚持用=,那么要在词法里特殊处理==与=的先后顺序。另一个参数是数字结尾:这里没有处理123abc连写报错,因为我们决定在语法层发现相邻Token问题。这种选择让词法保持单一职责,但如果你在写严格的脚本语言,建议在这里加一个“数字后紧跟字母”的状态判断,把错误直接定位到字符级。

2.3 用正则库还是手写状态机:不看炫技,看错误信息质量

有人会问,Python 里直接re.match一组模式不就行了?确实可以,而且写起来短。但正则模式一旦多了,匹配顺序就成了隐藏的坑。比如标识符模式[a-zA-Z_]\w*放在数字模式\d+前面,不会冲突,但如果你把:=放在=后面,就会匹配成两个=(如果=是有效Token的话)。更关键的是,正则引擎匹配成功时不方便返回匹配文本的精确行列偏移,因为你要用match.span()手动计算。手写状态机虽然啰嗦,但每跳过一个字符都知道当前行列,遇到非法字符可以直接抛错,错误信息能精确到“哪个字符在什么位置出了问题”。对于简单文法的前端,错误信息的质量直接影响调试效率。我见过某个解析器遇到未知字符时报SyntaxError: invalid syntax,连行列号都没有,那就等于黑匣子,根本没法定位。所以我的建议是:Token种类少于20个时,手写词法;Token模式复杂到需要正则嵌套时,才考虑用生成器或正则表,但一定要包装一层带行列号的Token生成函数。

3. 语法分析:递归下降与文法的左递归消除

3.1 递归下降为什么是简单前端的最佳起点

语法分析的主流方法有递归下降、LL(1)、LR(1) 以及各种生成器。对于“简单文法编译器前端”,不要犹豫,直接手写递归下降。原因是它和文法产生式一一对应:看到expr := term ('+' term)*,你就能写出一个parse_expr函数,先调parse_term,再循环匹配+。这种直接性让代码可读性极高,某新手也能在半小时内把文法变成可运行的代码。相比之下,用 yacc/bison 这种 LR 生成器,你要先学习冲突消解规则,遇到 shift/reduce 冲突时还得回文法里做手术,调试成本反而更高。递归下降唯一的硬伤是左递归:如果文法写成expr := expr '+' term,函数调用会无限递归。所以引入递归下降的前提是文法先改写为右递归或迭代形式。

3.2 消除左递归与提取公因子:表达式文法改造实例

考虑一个支持加减乘除和括号的表达式文法,初始写法很自然:

expr -> expr '+' term | expr '-' term | term term -> term '*' factor | term '/' factor | factor factor -> '(' expr ')' | NUM | ID

这个文法符合普通人对优先级的认知,但直接写成递归下降函数,parse_expr第一件事就是调用parse_expr,直接爆栈。改写方式是:将左递归产生式expr -> expr '+' term转换为expr -> term ('+' term)*,右递归部分用循环代替。同样处理 term。提取公因子后得到:

expr -> term ('+' term | '-' term)* term -> factor ('*' factor | '/' factor)* factor -> '(' expr ')' | NUM | ID

转换成 Python 代码时,expr和term都用while循环匹配后续运算符。这里有一个关键点:循环里每吃到一个+,必须立刻解析下一个term,然后将左右操作数构造成 AST 节点。如果文法里有expr -> term ('+' term)*,那么1+2+3会解析成((1+2)+3),还是(1+(2+3)),取决于你在循环里是左结合还是右结合。对加减乘除,标准语义是左结合,所以每次循环生成的新节点会把之前的结果作为左孩子。这就是为什么递归下降里没有显式的结合性声明,而是靠代码结构决定。

class ASTNode: def __init__(self, kind, value=None, left=None, right=None): self.kind = kind # 'BINOP', 'NUM', 'ID', ... self.value = value # 运算符或字面量值 self.left = left self.right = right def parse_factor(tokens, pos): tok = tokens[pos.pos] if tok.kind == 'NUM': pos.pos += 1 return ASTNode('NUM', value=tok.value) if tok.kind == 'ID': pos.pos += 1 return ASTNode('ID', value=tok.value) if tok.kind == 'LPAREN': pos.pos += 1 node = parse_expr(tokens, pos) if tokens[pos.pos].kind != 'RPAREN': raise SyntaxError(f"缺少右括号,位置 {tokens[pos.pos].line}:{tokens[pos.pos].col}") pos.pos += 1 return node raise SyntaxError(f"无法解析的Token: {tok}") def parse_term(tokens, pos): node = parse_factor(tokens, pos) while tokens[pos.pos].kind in ('STAR', 'SLASH'): op = tokens[pos.pos].kind op_value = tokens[pos.pos].value pos.pos += 1 right = parse_factor(tokens, pos) node = ASTNode('BINOP', value=op_value, left=node, right=right) return node def parse_expr(tokens, pos): node = parse_term(tokens, pos) while tokens[pos.pos].kind in ('PLUS', 'MINUS'): op_value = tokens[pos.pos].value pos.pos += 1 right = parse_term(tokens, pos) node = ASTNode('BINOP', value=op_value, left=node, right=right) return node

这里的pos是一个持有pos.pos整数的简单对象,相当于可变游标。为什么不用全局变量或返回位置?因为全局变量在嵌套调用时难回溯,返回位置则要每个函数返回(node, new_pos),可读性差。用一个简单游标对象是递归下降中最常用的方式。注意parse_factor中 LPAREN 分支:进入后先解析表达式,然后必须断言下一个Token是 RPAREN,否则抛出错误。这个错误抛在语法层,有行列号。还有一个细节:ASTNode中kind和value的职责要分清楚。BINOP是语法节点类型,value存放运算符字符,如'+'。这样后续生成三地址码时,直接用node.kind判断节点类型,用node.value获取运算符。

3.3 从产生式到AST:如何设计节点类型才不返工

AST 节点类型就是语法规则的映射。简单前端中,节点类型建议记为:NUM、ID、BINOP、ASSIGN、STATEMENTS和EMPTY。不要为每一种运算符建一个子类,那样模板代码会膨胀到无法维护。用BINOP+ 运算符值就够。我一开始写前端时,为加法建了AddNode,为减法建了SubNode,后面加位运算和逻辑运算时,类数量直接爆炸,重构才意识到应该用一个节点加运算符标记。这个教训让我后来做语法树设计时,先列运算符集合,再定节点形状。对于赋值语句ID := expr;,节点可以设计为ASSIGN类型,左孩子是 ID 节点,右孩子是表达式节点。多个语句用STATEMENTS节点串成链表,左孩子是当前语句,右孩子是剩余语句。这样生成的 AST 天然支持顺序执行遍历。当然,如果你后续要做循环和分支,节点类型需要扩展,但“运算符作为值”的设计仍然适用。

4. 语义动作与错误恢复:让前端从“好看”变成“能用”

4.1 语义动作挂在哪里:语法分析过程中顺便做,还是先建AST再遍历

严格的前端分为语法树构建和语义分析两步。但简单文法中,很多语义动作可以在递归下降解析时同步完成。典型例子是变量名的登记:当你的语言只允许先赋值再使用(或者只允许已声明变量参与运算),在parse_factor遇到ID时,可以立刻查符号表,发现未定义就报错。这种“一边解析一边做语义检查”的做法被称为语法驱动翻译,效率高,代码紧凑。但也有缺点:一旦你想要 AST 再做后续优化,比如常量折叠,最好还是先构建完整 AST 再遍历。我的建议是:如果前端的输出目标是三地址码或解释执行,直接采用语法驱动翻译,省掉一次遍历;如果目标是做优化或转换,则构建 AST,第二阶段再做语义分析。简单前端优先选前者,因为代码更短,错误定位直接对应到语法节点。

4.2 错误恢复策略:Panic模式与同步Token的最小实现

语法分析中一旦遇到输入不符合文法,如果只抛异常退出,一个文件里有多个错误时,用户只能改一个看一个,效率很低。常见的错误恢复是 panic 模式:发现错误后,丢弃一定范围内的Token,直到找到一个能安全继续的同步点,然后重新开始解析。同步Token一般选分号;或者右括号),因为它们是语句边界。下面是个最小实现:在parse_statement里解析失败时,抛出一个特殊异常,外层捕获后跳到下一个分号。

class ParseError(Exception): pass def synchronize(tokens, pos): while tokens[pos.pos].kind not in ('SEMI', 'EOF'): pos.pos += 1 if tokens[pos.pos].kind == 'SEMI': pos.pos += 1 def parse_program(tokens): pos = type('Pos', (), {'pos': 0})() statements = [] while tokens[pos.pos].kind != 'EOF': try: stmt = parse_statement(tokens, pos) statements.append(stmt) except ParseError as e: print(f"ParseError: {e} 于 {tokens[pos.pos].line}:{tokens[pos.pos].col}") synchronize(tokens, pos) return statements

这里synchronize会丢弃从错误位置到分号之间的所有Token。如果错误本身就是缺失分号,这个策略会吃掉下一行语句的开头吗?有可能。所以实践中,同步函数最好先保留当前Token,再判断它是否属于FIRST(statement)集合,如果是就不丢弃。更精细的做法是维护一个同步Token集合:{SEMI, EOF, RPAREN},遇到这些就停。这个最小实现便于理解,但你在实际项目中,至少要加入“错误不落在INFINITE LOOP”的保护:当synchronize后发现pos没有前进,必须强制前进一个Token,否则死循环。这个坑我有血泪教训:某个解析器在错误处理时,如果当前Token是 EOF,会直接进入死循环,卡死整个构建流程。

4.3 符号表与作用域:简单前端的轻量方案

简单文法的前端不一定需要完整符号表,但至少需要一个“已赋值变量”集合。如果你的语言允许变量用前先赋初值,那么parse_factor遇到 ID 时,要检查该名字是否在 env 中。最直接的做法是维护一个 dict:env = {},遇到赋值语句时写入env[var_name] = True,遇到 ID 时查询。注意作用域问题:如果只有全局作用域,这个方案没问题。一旦引入begin ... end或花括号块,你就需要作用域栈。轻量实现是使用 list of dict:

scopes = [{}] def declare(name): scopes[-1][name] = True def lookup(name): for scope in reversed(scopes): if name in scope: return True return False

进入新块时scopes.append({}),退出时scopes.pop()。这个方案没有处理“变量声明与赋值分离”的语言结构,但足够支撑一个教学级别的简单前端。还有一个容易被忽略的点:符号表记录的行列号对后续错误信息非常重要。如果变量在使用前未定义,你报“未定义变量 x”而不指出第一次声明的位置,用户很难排查。所以在declare时,value 可以存储(line, col),报错时打印出来。这个细节能提升实际使用体验。

5. 避坑专区:简单编译器前端最常见的5个翻车现场

5.1 悬空else与运算符优先级混乱

现象:文法写成stmt -> if expr then stmt | if expr then stmt else stmt,解析if a then if b then c else d时,else 被错误地匹配内层 then。原因:递归下降的parse_stmt在匹配完第一个if的子语句后,发现 else 属于当前函数,但文法没有区分配对层级。解决:两个办法。一是改写文法,强制else与最近的未配对if绑定,这需要额外状态。二是在写递归下降时,让parse_stmt的 else 分支在子语句解析后立刻检查是否存在 else,如果存在就归属到最近层。我的做法是:如果语言里只有一种if结构,直接在子语句里把 else 吃掉;如果还要支持elif链条,需要把整个 if 链设计成单一产生式。表达式优先级也一样,必须严格分层:expr -> term -> factor,绝对不能在 token 循环里自行比较运算符优先级,否则会写出“带优先级的调车场算法”,与文法脱节。

5.2 词法贪心匹配导致的标识符与数字粘连

现象:输入123abc,词法分析器先匹配123,再匹配abc,语法层报“期望运算符但遇到ID”。原因:最长匹配只按字符类型划分,没有检查Token边界。解决:在数字扫描循环里,加入“数字后紧跟字母或下划线”的报错。具体做法是:读完数字序列后,检查下一个字符是否是字母或_,如果是,则抛出SyntaxError,提示“数字后不能直接跟标识符”。同样的道理作用于0x与1e10这类特殊情况。简单文法只支持十进制整数,所以不存在1e的误判,但你如果扩展浮点数,需要格外注意1e+2和1e的区别。这个坑的根治办法就是给词法分析器写一组“派生Token”的正则边界规则,并添加单测。

5.3 深嵌套表达式导致的递归爆栈

现象:解析一个包含 1 万层括号的输入,parse_factor无限递归,最终RecursionError。原因:递归下降天然受调用栈深度限制。解决:限制递归深度。最简单的是设置一个解析深度计数器,每进入一个递归函数加1,超过阈值(比如 500)直接报错。另一种思路是把某些右递归产生式改为循环。例如factor -> '(' expr ')'的嵌套本质上无法消除,只能靠深度保护。我见过某真实前端因为没做深度限制,在解析用户输入时崩溃,成了拒绝服务漏洞。所以任何接受外部输入的编译器前端,都必须有限制深度。实现上,给parse_expr传入一个depth参数,进入时加1,离开时减1,超出限制抛ParseError。

5.4 错误信息指向了错误的位置

现象:输入a + * b,报错位置指向b所在的行列,实际错误是*后面缺操作数。原因:错误抛出时游标已经前进到下一个Token,你打印的是当前Token位置,而不是“预期发生”的位置。解决:在跳过一个Token之前,先记录它的行列号。具体到parse_term的循环里,看到STAR后,先记录运算符位置,再解析右侧因子,如果右侧因子的解析抛错,那么错误应该引用运算符位置。更通用的做法是给所有ParseError附加一个position字段,构造时传入你期望的Token位置,而不是当前游标位置。我在调试中最生气的时候,就是错误信息精确到错误的行,不得不手工查上游Token。这个坑解决很简单:不要随手写raise SyntaxError(f"语法错误 {tok.line}:{tok.col}"),而是用专门的error(msg, line, col)辅助函数。

5.5 中间表示选型过度设计,把简单前端搞成庞然大物

现象:只为支持算术表达式,却先搭了CFG、符号表、类型推断、SSA、以及复杂的IR体系,结果两个月没跑通。原因:违背“简单”的初衷。解决:先最小可行前端。用AST作为中间表示,直接解释执行或生成三地址码,不引入额外的IR。如果三地址码够用,就没必要设计图IR。我在某个模拟项目X里,一开始就设计了包含基本块和控制流图的中间表示,后来发现支撑它的需要更难的控制流分析,于是砍掉重来,只用线性三地址码加跳转标签。这个后悔药吃得相当苦。所以建议你:简单文法前端的第一版,AST + 一个递归求值器即可;若要后续做优化,再考虑升级IR结构。代码里提前留好kind字段,新增IR只是多一种节点类型,不会推翻原有设计。

6. 进阶:从AST到三地址码,以及用黄金样例验证前端

6.1 三地址码的生成模板

三地址码(3AC)是指每条指令最多包含一个运算符和三个地址(两个操作数,一个结果)。它能统一描述加减乘除和赋值,也容易进一步转换成汇编或字节码。对AST生成三地址码,只需要一个遍历函数和一个临时变量计数器。

class ThreeAddr: def __init__(self, op, arg1, arg2, result): self.op = op self.arg1 = arg1 self.arg2 = arg2 self.result = result def __repr__(self): return f"{self.result} := {self.arg1} {self.op} {self.arg2}" temp_count = 0 code = [] def new_temp(): global temp_count temp_count += 1 return f"t{temp_count}" def gen_ir(node): if node.kind == 'NUM': return str(node.value) if node.kind == 'ID': return node.value if node.kind == 'BINOP': left = gen_ir(node.left) right = gen_ir(node.right) temp = new_temp() code.append(ThreeAddr(node.value, left, right, temp)) return temp if node.kind == 'ASSIGN': val = gen_ir(node.right) code.append(ThreeAddr('=', val, '', node.left.value)) return none

这里gen_ir递归返回“地址”(变量名或临时变量)。对于BINOP,先递归生成左右两侧的三地址码,再为结果分配临时变量,然后输出一条指令。这个递归过程会和AST结构保持一致,比如1+2*3会先为2*3生成一条指令,再为1+结果生成另一条,天然实现了运算顺序。注意ASSIGN节点里,右孩子调用gen_ir生成指令,左孩子直接作为目标变量。三地址码的优点是每条指令只干一件事,后续做常量传播或死代码删除时,只需要扫描指令列表。缺点是临时变量会很多,但简单前端不在乎。如果追求更少的临时变量,可以在生成指令前先判断左右子树是否为叶子,是叶子就直接放在指令中,不必分配临时变量。这个优化只需在gen_ir里加一个is_leaf判断,代码非常短,建议加上。

6.2 用黄金样例集回归测试前端,而不是靠手工几组输入

前端写完,最怕第二天改了一个小地方,三天前的行为变了。所以要建一组“黄金样例”:覆盖正常输入、优先级、括号嵌套、错误输入、边界Token。黄金样例不追求数量多,但每个样例要能锁定一个具体行为。我一般会准备三组:合法输入组、非法输入组、边界输入组。合法输入里必须有1+2*3(验证优先级)、(1+2)*3(验证括号)、a:=(1+2)*3;(验证赋值)。非法输入组要有缺失右括号、连续运算符、未定义变量、数字粘连。边界输入组要有空程序、只有分号、超长嵌套。每次改动代码后,跑一遍这些样例,比对输出Token流、AST和3AC。自动化可以用 pytest 或简单的 shell 脚本,总之要让回归测试成为一个命令。这样即使哪天我改坏了词法,也不会等到发布后才发现。最后一个技巧:把调试用的repr输出保持稳定,不要输出内存地址,否则对比会永远失败。我习惯为 Token 和 AST 节点写统一的to_string方法,只输出类型和值,这样回归脚本可以精确匹配。这个习惯帮我躲过无数次“看着一样但字符串不一样”的陷阱,希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 1:12:22

STM32F407ZG与PJ85718DM实施HVAC远程测温与I2C校验的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/22 1:12:17

嵌入式远程测温实战:基于PIC18F87J50与I2C温度传感器的HVAC方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/22 1:11:08

HVAC控制器本地与远程温度采集实战:STM32+PJ85718DM数字传感器方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/22 1:11:06

双通道数字温度传感器搭配STM32实现HVAC多点测温与低功耗设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/22 1:10:32

PJ85718DM+STM32实现HVAC远程测温:从I2C数字传感器到NTC替代方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/22 1:10:32

机载SAR成像CS算法实战:从原理、Python复现到工程避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华