C# 编译器前端深入(一):概念与端到端数据流
本篇是三篇系列之一:第一篇
- 第一篇:概念与端到端数据流(第 0–2 章)
- 第二篇:词法、语法、红绿树与错误恢复(第 3–6 章)
- 第三篇:修改实战、练习与附录(第 7–9 章 + 附录 A/B/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,HEAD27afbc5)。行号会随版本漂移,找不到时按方法名检索。配套:地图见 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 学习路线图:蓝色是概念地基,绿色是前端两大阶段,橙色是"能改代码"的落脚点。
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(TryGetKeywordKindL50,MaxKeywordLength=10) - 节点:
Syntax/InternalSyntax/CSharpSyntaxNodeCache.cs(TryGetNodeL9/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 概念关系总图
图 1-1 概念关系图:数据从左到右变形,诊断是旁路输出,缓存是加速器。
2. 端到端:从 csc 到语法树
本章把 01 册的"一次编译数据流"落到前端:命令行的第一个字节,是怎么变成一棵语法树的。每一步都给出断点位置、观察变量、预期值。
2.1 时序图
图 2-1 前端时序图:注意"先建 Lexer、再建 Parser、最后才真正解析"——这就是 01 册说的"惰性"的起点(构造不等于解析)。
2.2 数据流图
图 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/) | 断点建议 | 观察什么 | 预期 |
|---|---|---|---|---|
| 1 | Compilers/CSharp/csc/Program.cs:11 Main | 入口 | args | 命令行参数数组 |
| 2 | 同文件 :26 MainCore → :33 DesktopBuildClient.Run(...) | 调用行 | RequestLanguage.CSharpCompile | 语言枚举 |
| 3 | Compilers/Shared/DesktopBuildClient.cs:29 Run | 方法首行 | 是否连上服务器 | 本地模式会回调 compileFunc |
| 4 | Compilers/Shared/Csc.cs:17 Run | 方法首行 | arguments | 命令行参数 |
| 5 | Compilers/Core/Portable/CommandLine/CommonCompiler.cs:526 RunCore | 方法首行 | 解析出的 Arguments | CSharpCommandLineArguments |
| 6 | 同文件 :633 CompileAndEmit | :650 阶段检查 | compilation.GetDiagnostics(Parse) | 空(正常代码) |
| 7 | Compilers/CSharp/Portable/CommandLine/CSharpCompiler.cs:154 ParseFile | 方法首行 | file.Path、parseOptions | 当前源文件 |
| 8 | Compilers/CSharp/Portable/Syntax/CSharpSyntaxTree.cs:376 ParseText | :389 | text.Length、options | 源文本与解析选项 |
| 9 | Compilers/CSharp/Portable/Parser/Lexer.cs:235 Lex | 方法首行 | mode | LexerMode.Syntax |
| 10 | Compilers/CSharp/Portable/Parser/LanguageParser.cs:298 ParseCompilationUnit | 方法首行 | _token(当前 Token) | 第一个 Token |
| 11 | 同文件 :310 ParseCompilationUnitCore | 方法内 | 成员列表构建 | CompilationUnitSyntax |
| 12 | CSharpSyntaxTree.cs:393 | CreateRed() | 绿树根 | 转为红树 |
提示:如果想跳过服务器路径,直接观察本地编译,可给 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



