caimouse头像

C# 编译器前端深入(一):概念与端到端数据流

C# 编译器前端深入(一):概念与端到端数据流

本篇是三篇系列之一:第一篇

章节编号沿用原单篇文档(如 §3.6 在第二篇、§7.3 在第三篇);源码基准:roslyn-src(Roslyn 2.10 / dev15.9)。

定位:这是《C# 编译器开发学习》系列的深入篇,专门把 01(总体架构与编译管线)和 02(词法与语法分析)里"一句话带过"的内容,展开成"概念 → 源码在哪 → 亲眼看 → 动手改 → 自检清单"。

读者假设:会写 C#,但没读过编译器源码。文中每个新概念都会先给一句大白话,再落到 roslyn-src 的具体文件与行号。

源码基准:本工作区 roslyn-src/(Roslyn 2.10 / dev15.9,HEAD 27afbc5)。行号会随版本漂移,找不到时按方法名检索。

配套:地图见 csharp-compiler-learning/01-Roslyn总体架构与编译管线.md,导读见 csharp-compiler-learning/02-词法与语法分析.md,改完怎么集成回 IDE 见 csharp-compiler-learning/06-动手实践与源码修改指南.md。


0. 这份文档怎么用

0.1 三条阅读路线

  • 只看懂(2 小时):第 1 章 → 第 2 章 → 第 3、4 章各读一半(跳过源码细节,只看图和"输入→输出"小表)。
  • 能改代码(1~2 天):第 1~6 章顺序读完,边读边跑第 8 章练习 1~5。
  • 接手维护(按需查阅):第 7 章(改动 SOP)+ 附录 A(函数列表)+ 第 9 章(排错手册)。

0 使用说明

1 七个概念(够用最小集)

2 端到端:csc → 语法树

3 Lexer 深入

4 Parser 深入

5 红绿树与增量解析

6 错误恢复与诊断

7 修改 SOP(五类典型改动)

8 练习册

9 排错手册

附录 A 函数列表 / B 文件职责 / C 索引与术语

图 0-1 学习路线图:蓝色是概念地基,绿色是前端两大阶段,橙色是"能改代码"的落脚点。

0.2 15 分钟环境自检

在动手前先确认四件事(命令均可直接复制;输出为本机实测):

# ① 源码在
ls /media/deepseek2008/data/monodev/roslyn-src/src/Compilers/CSharp/Portable/Parser/Lexer.cs
# ② 编译器产物在
ls /media/deepseek2008/data/monodev/roslyn-src/Binaries/Debug/Exes/csc/net46/csc.exe
# ③ 能跑(打印版本)
mono /media/deepseek2008/data/monodev/roslyn-src/Binaries/Debug/Exes/csc/net46/csc.exe -version
# 实测输出:42.42.42.42 (<developer build>)
# ④ 能改(单项目构建 1 分钟左右)
cd /media/deepseek2008/data/monodev/roslyn-src
export EnableSourceControlManagerQueries=false EnableSourceLink=false EmbedUntrackedSources=false
/media/deepseek2008/data/monodev/dotnet21/dotnet msbuild \
  src/Compilers/CSharp/Portable/CSharpCodeAnalysis.csproj \
  /p:Configuration=Debug /v:minimal /m:1 /nodeReuse:false
# 实测输出:
#   CodeAnalysis -> .../Binaries/Debug/Dlls/CodeAnalysis/Microsoft.CodeAnalysis.dll
#   CSharpCodeAnalysis -> .../Binaries/Debug/Dlls/CSharpCodeAnalysis/Microsoft.CodeAnalysis.CSharp.dll

四条全过,后面所有实验都能复现;任一条失败,先修环境再看文档。


1. 够用最小集:七个概念

这一章只讲"不掌握就看不懂后面代码"的概念。每个概念三段:大白话 → 一个最小例子 → 它在 Roslyn 里的落点。

1.1 字符流、Token、Trivia

大白话:编译器不是直接理解"程序"的,它先把一串字符切成一个个词(Token);词与词之间的空格、换行、注释不参与语法,但也不能丢(否则无法原样还原代码),所以单独存成 Trivia(“琐事”)。

最小例子(本机实测,完整输出见 §3.6 实验 A):

#02 SemicolonToken  text=[;]  span=[21..22) full=[21..23)
      trailing (1):
        - EndOfLineTrivia  [\n]
#03 ClassKeyword  text=[class]  span=[24..29) full=[23..30)
      leading (1):
        - EndOfLineTrivia  [\n]

读法:; 的 Span 是 [21,22)(只含自己),FullSpan 是 [21,23)(含挂在自己身上的 trivia)。换行同时出现在 ; 的 trailing 和 class 的 leading —— 见 §1.6 的归属规则。

源码落点:Token 类型 SyntaxToken(src/Compilers/CSharp/Portable/Syntax/InternalSyntax/SyntaxToken.cs),Trivia 类型 SyntaxTrivia(同目录 SyntaxTrivia.cs)。

1.2 上下文无关语法与递归下降

大白话:语言的语法可以用"一个结构由哪些更小的结构组成"来描述,例如 if 语句 = "if" "(" 表达式 ")" 语句。递归下降就是照着这些规则,为每条规则写一个函数,函数之间互相调用。

最小例子:C# 的语句分派就是一个大 switch,每个 case 调用一个专门的解析函数:

// src/Compilers/CSharp/Portable/Parser/LanguageParser.cs:6471
private StatementSyntax ParseStatementCore()
{
    switch (this.CurrentToken.Kind)
    {
        case SyntaxKind.OpenBraceToken:   return this.ParseBlock();
        case SyntaxKind.IfKeyword:        return this.ParseIfStatement();
        case SyntaxKind.ForKeyword:       return this.ParseForStatement();
        // ... 几十个 case
    }
}

源码落点:Parser/LanguageParser.cs(1.1 万行,绝大多数是"一个语法结构一个方法")。

1.3 优先级与结合性

大白话:a + b * c 要先算乘。教科书做法是给每个运算符一个数字(优先级),解析时"优先级高的先结合"。

最小例子:Roslyn 用优先级爬升实现,枚举定义在:

// Parser/LanguageParser.cs:8724
enum Precedence : uint
{
    Expression = 0,   // 最宽松
    Assignment,
    Lambda = Assignment,
    Ternary,
    Coalescing,
    // ... 中间省略 ...
    Additive,
    Mutiplicative,    // 注意:源码这里就拼错成 Mutiplicative
    Unary, Cast, PointerIndirection, AddressOf,
    Primary_UNUSED,
}

源码落点:Precedence(L8724)、GetPrecedence(SyntaxKind)(L8748)、主循环 ParseSubExpressionCore(L8901),逐步走查见 §4.3。

1.4 不可变数据结构

大白话:树一旦建好就不再修改。"改代码"实际是造一棵新树,尽可能共享旧树里没变的子树。

最小例子:

var m2 = m.WithIdentifier(SyntaxFactory.ParseToken("N")); // 旧树 m 不变,m2 是新树

源码落点:所有 WithXxx 方法都由生成器产出(Generated/Syntax.xml.Main.Generated.cs),底层构造走 SyntaxFactory。

1.5 缓存与复用

大白话:编译器里最常见的性能手段是"算过一次就记住"。Roslyn 前端有两条缓存链:词法缓存(相同文本的 Token/Trivia/关键字)和节点缓存(相同 Kind + 相同子节点的小节点直接返回旧实例)。

源码落点:

  • 词法:Parser/LexerCache.cs(TryGetKeywordKind L50,MaxKeywordLength=10)
  • 节点:Syntax/InternalSyntax/CSharpSyntaxNodeCache.cs(TryGetNode L9/14/19)

1.6 Span / FullSpan 与 Trivia 归属规则

大白话:每个 Token 有两套位置:只算自己的 Span,和连 trivia 一起算的 FullSpan。Trivia 归属有一条硬规则:

一个 Token 最多带 1 个 trailing trivia(本行内其后的空白/注释/换行);从它之后到下一个 Token 之前剩下的 trivia,全部算下一个 Token 的 leading trivia。

本机实测(实验 B),同一段代码只改排版:

== 同一行注释: int x = 1; // 注释A
  token ';' trailing (3): Whitespace [ ] / SingleLineComment [// 注释A] / EndOfLine [\n]
  token 'int'(下一个) leading (1): Whitespace [ ]

== 独立成行注释: int x = 1;\n    // 注释A
  token ';' trailing (1): EndOfLine [\n]
  token 'int'(下一个) leading (4): Whitespace [    ] / SingleLineComment [// 注释A] / EndOfLine [\n] / Whitespace [ ]

为什么这样设计:编辑一行代码时,行尾注释跟着"这一行"走(trailing),不会跑到下一行去;而整行注释属于"下一段代码",放 leading 更自然。

源码落点:Parser/Lexer.cs:2204 LexSyntaxTrivia(bool afterFirstToken, bool isTrailing, ...);FullSpan 定义在 Core/Portable/Syntax/SyntaxNode.cs:81。

1.6.1 位置模型:Span 与 FullSpan 的拼接关系

图 1-2 源文本位置模型(以 §3.6 的实测数据为例)

索引:  18 19 20 21 22 23 24 25 26 27 28 29 30 ...
文本:   \n  .  .  ;  \n  .  c  l  a  s  s  .  .
               └─┬─┘  └┬─┘
                 │     │
   SemicolonToken ──────┴── Span    = [21,22)   ← 只含 ';'
                         └── FullSpan = [21,23)  ← ';' + 尾随 '\n'

   ClassKeyword  ─────────── Span    = [24,29)   ← 只含 'class'
                         └── FullSpan = [23,30)  ← 前导 '\n' + 'class' + 尾随 ' '

拼接关系(贯穿整棵树):
  token[0].FullSpan.Start = 0
  token[i].FullSpan.End   = token[i+1].FullSpan.Start   ← 无缝、不重叠
  root.FullSpan           = [0, 源文本长度)

图 1-2 源文本位置模型:Span 用于"语义定位"(跳转/报告),FullSpan 用于"文本还原"(ToFullString)与增量编辑。可对照 §3.6 的真实 Token 转储读这张图。

1.7 诊断与错误恢复

大白话:编译器遇到错不能"罢工",否则 IDE 里打一半的代码就全无提示。Roslyn 的策略是:报告问题 + 造一个占位节点(missing token)继续解析,最终仍返回一棵结构完整的树。

本机实测(实验 D):

== 缺分号 : class C { void M() { int x = 1 int y = 2; } }
  missing tokens: SemicolonToken
  diag CS1002 @0,31 : 应输入 ;
  roundtrip ok = True

== 缺右花括号 : class C { void M() { if (true) { }
  missing tokens: CloseBraceToken, CloseBraceToken
  diag CS1513 @0,34 : 应输入 }
  diag CS1513 @0,34 : 应输入 }
  roundtrip ok = True

roundtrip ok = True 的意思是:把树重新拼回文本(root.ToFullString())与输入逐字相同——错代码也不会丢字符。

源码落点:Parser/SyntaxParser.cs:463/518/537(EatToken 家族)、Parser/SyntaxParser.cs:550/563(EatTokenWithPrejudice);详细流程图见 §6。

1.8 概念关系总图

诊断

诊断

缓存命中

源文本 string/SourceText

SlidingTextWindow
字符窗口

Lexer
扫描

SyntaxToken + Trivia
(绿节点)

LanguageParser
递归下降 + 优先级爬升

绿树 GreenNode
无父指针/可共享

红树 SyntaxNode
父指针/绝对位置

Syntax API
DescendantNodes/WithXxx

SyntaxDiagnosticInfo

CSharpSyntaxNodeCache

图 1-1 概念关系图:数据从左到右变形,诊断是旁路输出,缓存是加速器。


2. 端到端:从 csc 到语法树

本章把 01 册的"一次编译数据流"落到前端:命令行的第一个字节,是怎么变成一棵语法树的。每一步都给出断点位置、观察变量、预期值。

2.1 时序图

渲染错误: Mermaid 渲染失败: Parse error on line 17: ...options) TREE->>PAR: new LanguagePar ----------------------^ Expecting '+', '-', '()', 'ACTOR', got 'par'

图 2-1 前端时序图:注意"先建 Lexer、再建 Parser、最后才真正解析"——这就是 01 册说的"惰性"的起点(构造不等于解析)。

2.2 数据流图

SourceText.From

new Lexer

LexSyntaxToken

LanguageParser 消费

CreateRed()

tree.GetRoot()

string / SourceText

SourceText
+ 编码/校验和

Lexer
SlidingTextWindow

绿 Token 流
含 leading/trailing trivia

绿树 GreenNode
CompilationUnit

红树 CompilationUnitSyntax

使用者 API

图 2-2 数据流图(核心图之一,ASCII 版见下)。每一跳的源码位置:SourceText.From → CSharpSyntaxTree.cs:370;new Lexer → CSharpSyntaxTree.cs:389;LexSyntaxToken → Lexer.cs:297;CreateRed → CSharpSyntaxTree.cs:393。

ASCII 版(图 2-2)
┌──────────┐  SourceText.From  ┌────────────┐  new Lexer  ┌──────────┐
│  string  │──────────────────▶│ SourceText │────────────▶│  Lexer   │
└──────────┘                   └────────────┘             └────┬─────┘
                                                                │ LexSyntaxToken(含 trivia)
                                                                ▼
      ┌──────────────────────────┐   CreateRed()   ┌────────────────────┐
      │ 红树 CompilationUnitSyntax│◀────────────────│ 绿树 GreenNode 根  │
      └────────────┬─────────────┘                 └────────▲───────────┘
                   │ GetRoot()                             │ LanguageParser 消费 Token
                   ▼                                       │
              ┌─────────┐                          ┌───────┴────────┐
              │ 使用者  │                          │  绿 Token 流   │
              └─────────┘                          └────────────────┘

2.3 逐步对照表(可直接打断点)

步代码位置(roslyn-src/src/)断点建议观察什么预期
1Compilers/CSharp/csc/Program.cs:11 Main入口args命令行参数数组
2同文件 :26 MainCore → :33 DesktopBuildClient.Run(...)调用行RequestLanguage.CSharpCompile语言枚举
3Compilers/Shared/DesktopBuildClient.cs:29 Run方法首行是否连上服务器本地模式会回调 compileFunc
4Compilers/Shared/Csc.cs:17 Run方法首行arguments命令行参数
5Compilers/Core/Portable/CommandLine/CommonCompiler.cs:526 RunCore方法首行解析出的 ArgumentsCSharpCommandLineArguments
6同文件 :633 CompileAndEmit:650 阶段检查compilation.GetDiagnostics(Parse)空(正常代码)
7Compilers/CSharp/Portable/CommandLine/CSharpCompiler.cs:154 ParseFile方法首行file.Path、parseOptions当前源文件
8Compilers/CSharp/Portable/Syntax/CSharpSyntaxTree.cs:376 ParseText:389text.Length、options源文本与解析选项
9Compilers/CSharp/Portable/Parser/Lexer.cs:235 Lex方法首行modeLexerMode.Syntax
10Compilers/CSharp/Portable/Parser/LanguageParser.cs:298 ParseCompilationUnit方法首行_token(当前 Token)第一个 Token
11同文件 :310 ParseCompilationUnitCore方法内成员列表构建CompilationUnitSyntax
12CSharpSyntaxTree.cs:393CreateRed()绿树根转为红树

提示:如果想跳过服务器路径,直接观察本地编译,可给 csc 加 /shared:false(见 §5 的 VBCSCompiler 说明)。

2.4 为什么"构造 ≠ 解析"

new Lexer(...) 与 new LanguageParser(...) 只是准备状态;真正扫描发生在 Parser 第一次取 Token 时(SyntaxParser.CurrentToken → FetchCurrentToken → AddNewToken)。这个设计让增量解析成为可能:解析器可以在中途直接复用旧树的节点/Token,而不必先完整扫描新文本(见 §5.3)。


转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/caimouse/article/details/170053553

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--