背景
为了能识别出代码中抛出错误码的地址和具体的错误码值,再根据错误码文件获取到错误码的具体值和注释,方便后续的排错,这里使用AST进行语法分析获取到代码中的目标对象。
一、编译过程
在开始解析代码之前先补充了解一下编译过程
编译过程是将高级语言源代码转换为目标机器代码或其他中间表示的过程。它通常包括以下几个主要阶段:
词法分析(Lexical Analysis):
- 输入:源代码
- 输出:词法单元(tokens)
- 任务:将源代码分解为基本的词法单元,如关键字、标识符、运算符等
语法分析(Syntax Analysis):
- 输入:词法单元序列
- 输出:语法树(Abstract Syntax Tree,AST)或其他中间表示
- 任务:根据语法规则检查词法单元序列的结构,构建语法树表示源代码的语法结构。
语义分析(Semantic Analysis):
- 输入:语法树或中间表示
- 输出:语义信息
- 任务:检查源代码的语义正确性,捕捉并处理语法上合法但语义上错误的结构,生成符号表等。
中间代码生成(Intermediate Code Generation):
- 输入:语法树或中间表示
- 输出:中间代码
- 任务:将语法树转换为一种中间表示,以便进行后续优化和目标代码生成。
优化(Optimization):
- 输入:中间代码
- 输出:优化后的中间代码
- 任务:对中间代码进行各种优化,提高程序性能、减小代码体积等。
目标代码生成(Code Generation):
- 输入:优化后的中间代码
- 输出:目标机器代码或汇编代码
- 任务:将中间代码转换为目标机器代码,根据目标平台生成可执行文件。
链接(Linking):
- 输入:目标机器代码或汇编代码,可能包括库文件
- 输出:可执行文件
- 任务:将多个目标文件链接在一起,解决符号引用、地址重定位等问题,生成最终的可执行文件。
二、AST
AST 指的是 Abstract Syntax Tree(抽象语法树),它是编程语言中源代码语法结构的一种抽象表示形式。AST 通常是一个树形结构,用于表示程序的语法层次关系,从而方便进行语法分析和处理。
在编程语言中,源代码是由一系列的词法单元(token)组成的,而 AST 则将这些词法单元按照语法规则组织成一个树形结构。每个节点代表源代码中的一个语法结构,例如表达式、语句、函数声明等。
先看一下demo代码和对应的生成的部分AST树:
package main
import (
"errors"
"log"
)
var DivisionByZero = errors.New("除0错误")
var LessThanZero = errors.New("小于0错误")
func divide(a, b int) (int, error) {
if b == 0 {
log.Default().Panic(DivisionByZero)
return 0, DivisionByZero
}
if a < 0 {
log.Default().Panic(LessThanZero)
return 0, LessThanZero
}
return a / b, nil
}
0 *ast.File {
1 . Package: example.go:1:1
2 . Name: *ast.Ident {
3 . . NamePos: example.go:1:9
4 . . Name: "main"
5 . }
6 . Decls: []ast.Decl (len = 4) {
7 . . 0: *ast.GenDecl {
8 . . . TokPos: example.go:3:1
9 . . . Tok: import
10 . . . Lparen: example.go:3:8
11 . . . Specs: []ast.Spec (len = 2) {
12 . . . . 0: *ast.ImportSpec {
13 . . . . . Path: *ast.BasicLit {
14 . . . . . . ValuePos: example.go:4:2
15 . . . . . . Kind: STRING
16 . . . . . . Value: "\"errors\""
17 . . . . . }
18 . . . . . EndPos: -
19 . . . . }
20 . . . . 1: *ast.ImportSpec {
21 . . . . . Path: *ast.BasicLit {
22 . . . . . . ValuePos: example.go:5:2
23 . . . . . . Kind: STRING
24 . . . . . . Value: "\"log\""
25 . . . . . }
26 . . . . . EndPos: -
27 . . . . }
28 . . . }
29 . . . Rparen: example.go:6:1
30 . . }
31 . . 1: *ast.GenDecl {
32 . . . TokPos: example.go:8:1
33 . . . Tok: var
34 . . . Lparen: -
35 . . . Specs: []ast.Spec (len = 1) {
36 . . . . 0: *ast.ValueSpec {
37 . . . . . Names: []*ast.Ident (len = 1) {
38 . . . . . . 0: *ast.Ident {
39 . . . . . . . NamePos: example.go:8:5
40 . . . . . . . Name: "DivisionByZero"
41 . . . . . . . Obj: *ast.Object {
42 . . . . . . . . Kind: var
43 . . . . . . . . Name: "DivisionByZero"
44 . . . . . . . . Decl: *(obj @ 36)
45 . . . . . . . . Data: 0
46 . . . . . . . }
47 . . . . . . }
48 . . . . . }
49 . . . . . Values: []ast.Expr (len = 1) {
50 . . . . . . 0: *ast.CallExpr {
51 . . . . . . . Fun: *ast.SelectorExpr {
52 . . . . . . . . X: *ast.Ident {
53 . . . . . . . . . NamePos: example.go:8:22
54 . . . . . . . . . Name: "errors"
55 . . . . . . . . }
56 . . . . . . . . Sel: *ast.Ident {
57 . . . . . . . . . NamePos: example.go:8:29
58 . . . . . . . . . Name: "New"
59 . . . . . . . . }
60 . . . . . . . }
61 . . . . . . . Lparen: example.go:8:32
62 . . . . . . . Args: []ast.Expr (len = 1) {
63 . . . . . . . . 0: *ast.BasicLit {
64 . . . . . . . . . ValuePos: example.go:8:33
65 . . . . . . . . . Kind: STRING
66 . . . . . . . . . Value: "\"除0错误\""
67 . . . . . . . . }
68 . . . . . . . }
69 . . . . . . . Ellipsis: -
70 . . . . . . . Rparen: example.go:8:45
71 . . . . . . }
72 . . . . . }
73 . . . . }
74 . . . }
75 . . . Rparen: -
76 . . }
1. Token
在编程语言中,Token(词法单元)是源代码中的最小语法单元,是编译器或解释器在进行词法分析时识别和处理的基本单位。Token 是源代码经过词法分析器处理后得到的标识符,它代表了源代码中的不可分割的词法结构。
不同类型的编程语言有不同的 Token 类型,一般包括以下几类:
关键字(Keywords): 表示编程语言的保留字,具有特殊含义。例如,在 C 语言中,
if
、else
、while
等就是关键字。标识符(Identifiers): 表示变量、函数、类等的名称。标识符需要遵循一定的命名规则。例如,
variableName
是一个标识符。常量(Literals): 表示固定值的词法单元,包括整数、浮点数、字符串等。例如,
42
、3.14
、"Hello, World!"
都是常量。运算符(Operators): 表示执行操作的符号,如加法、减法、乘法等。例如,
+
、-
、*
是运算符。分隔符(Delimiters): 表示源代码结构的分隔符,如括号、分号、逗号等。例如,
(
、)
、;
是分隔符。注释(Comments): 表示注释内容,编译器或解释器通常会忽略它们。例如,
// This is a comment
。
Token 的产生是由词法分析器(Lexer 或 Scanner)负责的,它扫描源代码,将字符序列组合成有意义的 Token。Token 提供给语法分析器(Parser)使用,用于构建 AST(抽象语法树)等进一步的语法分析和语义分析。
下面是一个简单的示例,展示了一个小型程序的源代码和对应的一些 Token:
# 源代码 x = 10 + 5
对应的 Token:
Token(IDENT, "x")
Token(ASSIGN, "=")
Token(INT, "10")
Token(PLUS, "+")
Token(INT, "5")
在这个例子中,Token(IDENT, "x")
表示一个标识符 Token,Token(ASSIGN, "=")
表示一个赋值操作符 Token,Token(INT, "10")
表示一个整数常量 Token,以此类推。
Go 语言中的 Token类型:
2. FileSet
先看代码:使用 token.NewFileSet()
创建一个新的 FileSet
对象,并使用 parser.ParseFile
函数解析 Go 代码文件。
// 解析 Go 代码文件
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
token
包中的FileSet
类型是用于跟踪源代码文件和位置信息的结构。FileSet
用于维护源代码文件的集合,每个文件都关联有唯一的标识符,并记录了源代码中各个位置的行号、列号等信息。
让我们看一下parser.ParseFile
()函数源码:
func ParseFile(fset *token.FileSet, filename string, src any, mode Mode) (f *ast.File, err error) {
if fset == nil {
panic("parser.ParseFile: no token.FileSet provided (fset == nil)")
}
// get source
text, err := readSource(filename, src)
if err != nil {
return nil, err
}
var p parser
defer func() {
if e := recover(); e != nil {
// resume same panic if it's not a bailout
bail, ok := e.(bailout)
if !ok {
panic(e)
} else if bail.msg != "" {
p.errors.Add(p.file.Position(bail.pos), bail.msg)
}
}
// set result values
if f == nil {
// source is not a valid Go source file - satisfy
// ParseFile API and return a valid (but) empty
// *ast.File
f = &ast.File{
Name: new(ast.Ident),
Scope: ast.NewScope(nil),
}
}
p.errors.Sort()
err = p.errors.Err()
}()
// parse source
p.init(fset, filename, text, mode)
f = p.parseFile()
return
}
参数:
fset *token.FileSet
: 用于记录位置信息的文件集合。
filename string
: 源文件的文件名。
src any
: 源代码,可以是字符串、字节数组或io.Reader
。
mode Mode
: 控制解析器的模式,例如是否跳过对象解析阶段。返回值:
f *ast.File
: 解析后的 AST 树,表示整个源文件的语法结构。
err error
: 解析过程中的错误,如果有的话。内部实现:
parser
结构体是用于实际解析的解析器对象。
readSource
函数用于获取源代码文本,它可以从文件或其他来源获取。
p.parseFile()
调用实际执行解析过程,返回解析得到的 AST。错误处理:
如果源代码无法读取,返回的 AST 是空的,但不为 nil。
如果源代码读取成功但存在语法错误,返回的 AST 包含
ast.Bad*
节点来表示错误的源代码片段。多个错误通过
scanner.ErrorList
返回,按源代码位置排序。panic 恢复:
解析器在遇到错误时可能会触发 panic,但会通过
recover
恢复,确保程序不会崩溃。如果 panic 是
bailout
类型且有错误消息,将错误添加到解析器的错误列表中。
让我们再看一下其中的初始化函数p.init():解析器对象的初始化过程,设置了解析器的一些基本属性,初始化了文件对象和词法分析器,并准备开始解析源代码。
func (p *parser) init(fset *token.FileSet, filename string, src []byte, mode Mode) {
p.file = fset.AddFile(filename, -1, len(src))
eh := func(pos token.Position, msg string) { p.errors.Add(pos, msg) }
p.scanner.Init(p.file, src, eh, scanner.ScanComments)
p.top = true
p.mode = mode
p.trace = mode&Trace != 0 // for convenience (p.trace is used frequently)
p.next()
}
继续看一下核心的解析函数:解释看注释
// parseFile 解析整个 Go 源文件,返回相应的 ast.File 节点。
func (p *parser) parseFile() *ast.File {
if p.trace {
defer un(trace(p, "File"))
}
// 如果在扫描第一个标记时存在错误,则不必继续解析。
// 可能根本不是有效的 Go 源文件。
if p.errors.Len() != 0 {
return nil
}
// 包子句
doc := p.leadComment
pos := p.expect(token.PACKAGE)
// Go 规范:包子句不是声明;
// 包名不会出现在任何范围中。
ident := p.parseIdent()
if ident.Name == "_" && p.mode&DeclarationErrors != 0 {
p.error(p.pos, "无效的包名 _")
}
p.expectSemi()
// 如果在解析包子句时存在错误,则不必继续解析。
// 可能根本不是有效的 Go 源文件。
if p.errors.Len() != 0 {
return nil
}
var decls []ast.Decl
if p.mode&PackageClauseOnly == 0 {
// 导入声明
for p.tok == token.IMPORT {
decls = append(decls, p.parseGenDecl(token.IMPORT, p.parseImportSpec))
}
if p.mode&ImportsOnly == 0 {
// 包体的其余部分
prev := token.IMPORT
for p.tok != token.EOF {
// 继续接受导入声明以容忍错误,但是报告警告。
if p.tok == token.IMPORT && prev != token.IMPORT {
p.error(p.pos, "导入必须出现在其他声明之前")
}
prev = p.tok
decls = append(decls, p.parseDecl(declStart))
}
}
}
// 构建 ast.File 对象
f := &ast.File{
Doc: doc,
Package: pos,
Name: ident,
Decls: decls,
FileStart: token.Pos(p.file.Base()),
FileEnd: token.Pos(p.file.Base() + p.file.Size()),
Imports: p.imports,
Comments: p.comments,
GoVersion: p.goVersion,
}
var declErr func(token.Pos, string)
if p.mode&DeclarationErrors != 0 {
declErr = p.error
}
if p.mode&SkipObjectResolution == 0 {
resolveFile(f, p.file, declErr)
}
return f
}
核心的解析函数resolveFile:调用ast.Walk函数获得代码的AST树
func resolveFile(file *ast.File, handle *token.File, declErr func(token.Pos, string)) {
pkgScope := ast.NewScope(nil)
r := &resolver{
handle: handle,
declErr: declErr,
topScope: pkgScope,
pkgScope: pkgScope,
depth: 1,
}
for _, decl := range file.Decls {
ast.Walk(r, decl)
}
r.closeScope()
assert(r.topScope == nil, "unbalanced scopes")
assert(r.labelScope == nil, "unbalanced label scopes")
// resolve global identifiers within the same file
i := 0
for _, ident := range r.unresolved {
// i <= index for current ident
assert(ident.Obj == unresolved, "object already resolved")
ident.Obj = r.pkgScope.Lookup(ident.Name) // also removes unresolved sentinel
if ident.Obj == nil {
r.unresolved[i] = ident
i++
} else if debugResolve {
pos := ident.Obj.Decl.(interface{ Pos() token.Pos }).Pos()
r.trace("resolved %s@%v to package object %v", ident.Name, ident.Pos(), pos)
}
}
file.Scope = r.pkgScope
file.Unresolved = r.unresolved[0:i]
}
最后得到*ast.File ,可以对其进行后续特定的分析处理
3. AST类型
在 Go 语言中,AST(抽象语法树)主要由三个大类别的节点构成,它们分别是:
基本节点(Basic Nodes):
ast.Ident
: 代表标识符(Identifiers),如变量名、函数名等。ast.BasicLit
: 代表基本的字面量,如整数、浮点数、字符串等。ast.CompositeLit
: 代表复合字面量,如数组、切片、字典等。语句节点(Statement Nodes):
ast.ExprStmt
: 代表一个表达式语句,即只包含一个表达式的语句。ast.AssignStmt
: 代表赋值语句,包括简单的赋值和多重赋值。ast.IfStmt
: 代表条件语句。ast.ForStmt
: 代表循环语句。ast.SwitchStmt
: 代表开关语句(switch
)。ast.RangeStmt
: 代表for range
循环语句。声明节点(Declaration Nodes):
ast.GenDecl
: 代表通用声明,用于表示import
、const
、var
等声明。ast.FuncDecl
: 代表函数声明。ast.TypeSpec
: 代表类型声明。
所有节点都实现了ast.Node接口,返回了Node的位置信息
type Node interface {
Pos() token.Pos // position of first character belonging to the node
End() token.Pos // position of first character immediately after the node
}
// All expression nodes implement the Expr interface.
type Expr interface {
Node
exprNode()
}
// All statement nodes implement the Stmt interface.
type Stmt interface {
Node
stmtNode()
}
// All declaration nodes implement the Decl interface.
type Decl interface {
Node
declNode()
}
以下是ast包中包含的节点类型,可以用到的时候查看官方文档:ast package - go/ast - Go Packages
4. 示例
现在我要获取代码中错误码出现的位置以及对应的名称,下面是源代码
package main
import (
"errors"
"log"
)
var DivisionByZero = errors.New("除0错误")
var LessThanZero = errors.New("小于0错误")
func divide(a, b int) (int, error) {
if b == 0 {
log.Panic(DivisionByZero)
return 0, DivisionByZero
}
if a < 0 {
log.Panic(LessThanZero)
return 0, LessThanZero
}
return a / b, nil
}
下面是解析函数: 根据源代码生成的AST节点,遍历一遍获取到需要的声明
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
)
func main() {
// 文件路径
path := "example.go"
// 创建 FileSet
fset := token.NewFileSet()
// 解析 Go 代码文件
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
fmt.Printf("Error parsing file: %v\n", err)
os.Exit(1)
}
// 在这里可以使用 fset 和 f 进行进一步的操作,例如访问 AST 等
// Walk through the AST to find error codes
ast.Inspect(f, func(n ast.Node) bool {
//表示函数调用表达式。
if callExpr, ok := n.(*ast.CallExpr); ok {
if fun, ok := callExpr.Fun.(*ast.SelectorExpr); ok {
if ident, ok := fun.X.(*ast.Ident); ok {
if ident.Name == "log" && fun.Sel.Name == "Panic" {
if len(callExpr.Args) > 0 {
if arg, ok := callExpr.Args[0].(*ast.Ident); ok {
fmt.Printf("错误码名称:%s\n", arg.Name)
fmt.Printf("出现的位置:%s\n", fset.Position(arg.Pos()))
}
}
}
}
}
}
return true
})
}
不是很清楚的多debug几遍就大概知道了:
结果如下: