6.1 源码阅读:SQLite 架构概述
在通用数据管理系统与系统软件的版图中,SQLite 占据着独树一帜的地位。它不是传统意义上驻留在后台守护进程中、通过网络套接字与远程客户端通信的客户机/服务器(C/S)数据库,而是一个直接静态或动态链接进宿主应用进程内的自包含、零配置、支持完整 ACID 事务的嵌入式 C 语言引擎。在现代软件生态中,从几乎所有智能手机的底层系统组件、桌面浏览器的主力存储,到车载系统乃至边缘计算设备,其全球物理部署量以数十亿计,是人类工业界部署密度最高的基础软件之一。
对于 C 语言学习者与系统软件开发者而言,SQLite 是一座取之不尽的工程代码富矿。它的合并发行版本(amalgamation)将约 15 万行 C 代码浓缩在单个 sqlite3.c 编译单元中,不依赖除标准 C 运行时与宿主操作系统原生接口之外的任何第三方组件;同时,它以教科书级的严谨性在一个项目中融汇了手写表驱动词法分析、可重入的 LALR(1) 语法解析、针对复杂表达式的代码生成器、基于寄存器的执行虚拟机(VDBE)、B+Tree 索引引擎、预写式日志事务(WAL),以及基于函数指针的多态虚拟文件系统(VFS)。
源码:https://sqlite.org/src,属于公有领域(Public Domain)。本篇基于 SQLite 3.46 版本的合并源码(amalgamation)展开结构剖析。
1. 为什么读 SQLite
将 SQLite 作为系统级 C 语言工程的学习蓝本,具有几项不可替代的优势:
- 单文件自包含交付:官方提供的合并源码
sqlite3.c将庞大的工程组织折叠为单一翻译单元,极大简化了全局符号检索、交叉引用跟踪以及代码构建实验的门槛。 - 纯粹的零依赖设计:除了 ISO C 运行时库与操作系统的基础系统调用,整套引擎没有引入任何外部框架,所有基础设施(内存分块、哈希表、排序器、位图)均由手写 C 代码亲力亲为。
- 经典的软件分层闭环:自顶向下的清晰分层,将编译器前端、虚拟机执行引擎、树形存储管理与底层磁盘 I/O 紧密咬合在一起,展现出系统软件如何不依赖面向对象语法而实现高度模块化。
- 工业级的工程防御性:SQLite 拥有达到 100% 分支覆盖率的测试套件,其防御性编程、断言矩阵以及多阶段资源清理模式是 C 语言资源管理的范本。
2. 架构总览
SQLite 的分层架构
SQLite 的内部实现自顶向下划分为八个清晰的抽象层。最上层是面向宿主应用程序暴露的公开 C API,最下层是与操作系统内核文件子系统直接打交道的虚拟文件系统(VFS)。当宿主程序传入一条 SQL 文本后,该语句沿着分层管道逐级下沉,完成词法切分、语法规约、字节码编译并驱动底层存储引擎读写磁盘页面:
| 层 | 职责 | 核心源文件 |
|---|---|---|
| 接口层 (Interface) | 公开 API,管理数据库连接与句柄生命周期 | main.c, prepare.c, vdbeapi.c |
| 词法分析 (Tokenizer) | 将 SQL 文本切分为 token 词法单元流 | tokenize.c |
| 语法分析 (Parser) | 将 token 流规约为语法树并构建查询上下文 | parse.y (经 Lemon 生成 parse.c) |
| 代码生成 (Code Generator) | 遍历语法树并翻译为 VDBE 字节码程序 | build.c, select.c, where.c, expr.c |
| 虚拟机 (VDBE) | 基于寄存器模型解释执行字节码指令集 | vdbe.c, vdbeaux.c |
| B-tree | 维护物理数据页构成的树形索引与游标导航 | btree.c |
| Pager | 负责磁盘页缓存、事务一致性与崩溃恢复(WAL) | pager.c, wal.c |
| OS 接口 (VFS) | 抽象底层操作系统的文件读写、锁机制与时间接口 | os_unix.c, os_win.c |
2.1 接口层
接口层是宿主应用程序与 SQLite 交互的门面。所有公开 API 均统一以 sqlite3_ 作为标识符前缀,其原型在公共头文件 sqlite3.h 中完整导出,具体逻辑主要分布在 main.c、prepare.c 与 vdbeapi.c 等源文件中。
在日常开发中,以下四个核心函数构成了驱动一条 SQL 语句运转的骨架:
int sqlite3_open(const char *filename, sqlite3 **ppDb);
int sqlite3_prepare_v2(sqlite3 *db, const char *zSql, int nByte,
sqlite3_stmt **ppStmt, const char **pzTail);
int sqlite3_step(sqlite3_stmt *pStmt);
int sqlite3_finalize(sqlite3_stmt *pStmt);2
3
4
5
函数 sqlite3_open 负责打开或创建指定的数据库文件,并在堆上构造出核心连接对象 sqlite3 *。其底层实现 openDatabase 生动展示了 C 语言中处理复杂多阶段资源分配的典型范式:
static int openDatabase(
const char *zFilename,
sqlite3 **ppDb,
unsigned int flags,
const char *zVfs
){
sqlite3 *db;
int rc;
*ppDb = 0;
rc = sqlite3_initialize();
if( rc ) return rc;
/* 分配并零初始化 sqlite3 结构体 */
db = sqlite3MallocZero( sizeof(sqlite3) );
if( db==0 ) goto opendb_out;
/* ... 设置互斥锁、编码、默认页大小 ... */
/* ... 打开 B-tree 存储、加载 schema ... */
opendb_out:
/* ... 释放互斥锁、整理错误码 ... */
*ppDb = db;
return rc;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
这里使用的 goto opendb_out 标签是 C 语言实现单一出口(Single Exit Point)资源收尾的工业级实践:当初始化流程中任一步骤(如内存分配、互斥锁创建、B 树根页读取)失败时,控制流直接跳跃至末尾的统一清理标签处,集中处理局部锁释放与句柄反注册,彻底规避了在各个分散的错误分支中重复编写析构逻辑可能引发的内存泄露。
2.2 词法分析器
词法分析器集中在 tokenize.c 中实现,其核心例程为 sqlite3GetToken,职责是按字符序列逐一提取下一个有意义的词法单元(token),并返回其字节跨度与分类标记。
不同于许多编译器工具链依赖 lex 或 flex 等自动化工具生成状态机,SQLite 采用了手写的高性能词法分析器。为了消除密集的字符串比较与分支预测开销,它引入了一张 256 字节的静态字符分类查找表 aiClass[],将输入的每一个单字节映射到 31 种预定义的字符类别编号上:
#define CC_SPACE 7 /* 空白字符 */
#define CC_MINUS 11 /* '-',可能是减号或 SQL 注释 */
#define CC_SLASH 16 /* '/',可能是除号或 C 风格注释 */
#define CC_LT 12 /* '<',可能是 < 或 <= 或 <> */
#define CC_DIGIT 3 /* 数字 */
#define CC_KYWD0 1 /* 关键字首字母 */
/* ... 共 31 种分类 ... */2
3
4
5
6
7
随后外层主分发逻辑仅需针对类别编号展开 switch 判定:
int sqlite3GetToken(const unsigned char *z, int *tokenType){
int i;
switch( aiClass[*z] ){
case CC_SPACE: {
for(i=1; sqlite3Isspace(z[i]); i++){}
*tokenType = TK_SPACE;
return i;
}
case CC_MINUS: {
if( z[1]=='-' ){
/* SQL 注释:-- 直到行尾 */
for(i=2; z[i]!=0 && z[i]!='\n'; i++){}
*tokenType = TK_SPACE; /* 注释按空白处理 */
return i;
}
*tokenType = TK_MINUS;
return 1;
}
case CC_LT: {
if( z[1]=='=' ){
*tokenType = TK_LE; /* <= */
return 2;
}else if( z[1]=='>' ){
*tokenType = TK_NE; /* <> */
return 2;
}else{
*tokenType = TK_LT; /* < */
return 1;
}
}
/* ... 其余分类 ... */
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
先通过查表映射类别再执行分支跳转,体现了极高的软硬件协同智慧。若直接针对 ASCII 字符编码(0–255)编写 switch,分支标签在数值空间上极度稀疏,编译器往往被迫退化生成多重二分查找指令序列;而通过 aiClass 预先将字符折叠为 0–30 的连续小整数后,现代编译器(如 GCC 或 Clang)能够直接生成单条基于基址寻址的紧凑跳转表指令(如 jmp [rax*8 + jump_table]),消除了多段比较与分支预测惩罚。
2.3 语法分析器
SQLite 的语法分析器由其作者 D. Richard Hipp 自主研发的 Lemon LALR(1) 解析器生成器编译而成。其输入规范文件为 parse.y,经 Lemon 处理后生成结构严密的 C 源码 parse.c。
相比经典的 yacc 与 bison 工具,Lemon 专门针对系统级软件的嵌入诉求进行了重构:
- 原生可重入与线程安全:Lemon 解析器完全摈弃了全局状态,所有运行态上下文均封装在堆分配的独立对象中,通过显式入参传递;
- 外部驱动的推入式架构:传统 yacc 解析器通常由内部主动向外拉取词法(调用
yylex);而 Lemon 采用推入式模型(Push Parser),由外部词法循环主动向解析器灌入 token(sqlite3Parser(pEngine, tokenType, token))。这一设计赋予了解析阶段极大的灵活性,使得 SQLite 能够根据当前语法流的上下文,平滑判定某些同名标识符究竟是作为列名、表名还是上下文关键字(如WINDOW、FILTER、OVER); - 专用数据结构就地构造:Lemon 的语法归约动作并不产出形式化的通用抽象语法树(AST),而是在语法规约的同时,直接分配并装配内部核心结构(如
Select、Expr、SrcList等专用结构体),大幅压缩了中间对象的内存周转。
在 tokenize.c 中,sqlite3RunParser 例程将词法分析与语法规约串联为一个紧凑的主动推入循环:
int sqlite3RunParser(Parse *pParse, const char *zSql){
void *pEngine; /* Lemon 生成的解析器实例 */
int tokenType;
int n;
pEngine = sqlite3ParserAlloc(sqlite3Malloc, pParse);
while( 1 ){
n = sqlite3GetToken((u8*)zSql, &tokenType);
if( tokenType==TK_SPACE ){
zSql += n;
continue; /* 跳过空白和注释 */
}
/* ... 处理窗口函数关键字等特殊情况 ... */
sqlite3Parser(pEngine, tokenType, ...); /* 送入解析器 */
zSql += n;
if( /*解析完成或出错*/ ) break;
}
sqlite3ParserFree(pEngine, sqlite3_free);
return pParse->nErr;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
这是一个词法驱动循环:反复调用 sqlite3GetToken 取出下一个 token,跳过空白和注释后送入 Lemon 解析器。
2.4 代码生成器
代码生成器是 SQLite 中逻辑最为庞大且复杂的模块,占据了合并源码的大半篇幅。它根据不同的 SQL 语义划分为若干关键实现源文件:
| 文件 | 核心职责 |
|---|---|
select.c | SELECT 查询语句的语义处理与代码生成 |
where.c | WHERE 子句的代价优化(CBO)、索引路径选择与扫描循环构建 |
expr.c | 标量表达式求值、函数调用与类型提升的指令生成 |
build.c | DDL 定义语句(CREATE TABLE、CREATE INDEX、VIEW)的元数据装配 |
insert.c | INSERT 语句的行记录打包与主从键约束检查 |
update.c | UPDATE 语句的旧记录撤销与新字段原地覆写 |
delete.c | DELETE 语句的游标定位与行清理 |
代码生成器遍历语法分析器就地产出的语义结构,驱动查询优化器挑选代价最优的索引,并将其平铺展开为一段线性的 VDBE 字节码指令序列。
2.5 虚拟机 (VDBE)
VDBE(Virtual Database Engine)是 SQLite 的指令执行引擎。与 JVM 或 Python 字节码解释器相似,它也是一台运行在进程内的虚拟机,但它采用了基于寄存器(Register-based)的架构,并且其指令集天然围绕关系数据库的“行、列、索引、表游标”这一概念体系展开。
一条编译完成的 SQL 语句在内部具象化为一个 Vdbe 结构体(在对外 API 中被抽象为不透明指针 sqlite3_stmt *),其内部完整持有运行所需的全套软硬件环境:
struct Vdbe {
sqlite3 *db; /* 所属的数据库连接 */
Vdbe **ppVPrev,*pVNext; /* 连接上所有 VDBE 的双向链表 */
Parse *pParse; /* 创建此 VDBE 的解析上下文 */
int nMem; /* 已分配的寄存器数量 */
int nCursor; /* 游标槽位数量 */
int pc; /* 程序计数器 */
int rc; /* 返回值 */
Op *aOp; /* 字节码指令数组 */
int nOp; /* 指令数量 */
Mem *aMem; /* 寄存器数组 */
VdbeCursor **apCsr; /* 游标数组 */
Mem *pResultRow; /* 当前输出行 */
/* ... */
};2
3
4
5
6
7
8
9
10
11
12
13
14
15
当外部调用 sqlite3_step() 时,虚拟机从当前的程序计数器 pc 处启动字节码循环。vdbe.c 的核心是一个庞大的 switch 循环,每一种操作码(Opcode,系统内共包含 180 余种)对应一个处理分支。执行流或通过游标读取底层数据、或在寄存器之间进行算术运算,直到产生一行可供消费的结果集(向宿主返回 SQLITE_ROW 并暂停现场),或全部执行完毕收尾(返回 SQLITE_DONE)。
2.6 B-tree 层
B-tree 层(btree.c)承担了物理数据库文件与抽象数据行之间的组织桥梁。在 SQLite 的存储模型中,数据库内的每一个数据表和索引都各自映射为一棵独立的 B-tree:
- 表数据采用 B+ 树 组织(代码中称为 Table B-tree):内部节点仅存储用于路由寻址的 64 位整型 ROWID,所有的真实行载荷(Payload)完整聚簇在底层叶节点中;
- 辅助索引采用 B 树 组织(代码中称为 Index B-tree):所有的索引键在内部节点与叶节点上均有存放。
B-tree 层向上为 VDBE 提供面向记录的游标(Cursor)接口,VDBE 指令正是通过驱动游标完成高效的二分定位、顺序扫描与行插入删除(如 sqlite3BtreeTableMoveto、sqlite3BtreeNext、sqlite3BtreeInsert 等)。
2.7 Pager 层
Pager 层(pager.c)横亘在 B-tree 索引与操作系统磁盘 I/O 之间,是保证数据完整性与事务安全的核心壁垒。其职责清晰地聚焦于三项关键任务:
- 页面内存池缓存:将底层磁盘上固定尺寸(通常为 4096 字节)的物理数据页缓存在堆内存中,避免高频系统调用;
- ACID 事务提交控制:通过原子状态流转确保多页修改要么整体持久化生效,要么在异常时完整还原;
- 系统崩溃安全恢复:通过日志机制防止主机掉电、系统崩溃或进程异常中断造成的数据文件半写损坏。
SQLite 支持两种日志持久化机制:经典的回滚日志模式(Rollback Journal)以及高吞吐的预写式日志模式(Write-Ahead Logging,WAL)。在 WAL 模式下,写操作追加记录至独立日志,读写互不阻塞,大幅提升了并发读取性能。
2.8 OS 接口 (VFS)
虚拟文件系统(VFS,sqlite3_vfs)是 SQLite 的最底层抽象。它将平台相关的系统调用(文件创建与打开、读写操作、强制刷盘、多进程文件锁控制、真随机数与系统时钟获取)全部封装在一组函数指针构成的虚表结构之后:
struct sqlite3_vfs {
int iVersion;
int szOsFile;
int mxPathname;
sqlite3_vfs *pNext;
const char *zName;
void *pAppData;
int (*xOpen)(sqlite3_vfs*, const char*, sqlite3_file*, int, int*);
int (*xDelete)(sqlite3_vfs*, const char*, int);
int (*xAccess)(sqlite3_vfs*, const char*, int, int*);
int (*xFullPathname)(sqlite3_vfs*, const char*, int, char*);
/* ... */
};2
3
4
5
6
7
8
9
10
11
12
13
SQLite 源码中内置了针对 POSIX 平台的 os_unix.c 与针对 Windows 的 os_win.c 实现。基于这套纯 C 语言的函数指针接口,应用程序无需修改上层逻辑,即可通过注册自定义 VFS 实现内存虚拟文件、全盘加密文件系统、或是向特殊嵌入式实时操作系统(RTOS)的无缝移植。依靠结构体与函数指针定义多态接口,是现代系统级 C 语言开发中取代复杂对象继承体系的经典实践。
3. 核心数据结构
阅读 SQLite 源码时,有三个核心结构体会反复出现在视野里:代表连接的 sqlite3、代表已编译语句的 Vdbe,以及代表编译上下文的 Parse。把这三个结构体的角色理清楚,整个系统的协作脉络就会变得格外顺畅。
sqlite3 连接句柄
在所有结构体中,sqlite3 是当之无愧的中枢。每一个成功的 sqlite3_open 调用,都会在堆上分配这样一个结构体,用来维系该数据库连接生命周期内的全部状态。
它汇集了连接所需的一切资源:底层文件系统接口(pVfs)、由该连接派生出的活跃虚拟机链表(pVdbe)、挂载的数据库后端数组(aDb,用以支持通过 ATTACH DATABASE 打开的多个库文件)、近期的错误状态与错误码、事务控制标志,以及各种资源配额上限。
struct sqlite3 {
sqlite3_vfs *pVfs; /* 文件系统接口 */
struct Vdbe *pVdbe; /* 活跃虚拟机链表 */
Db *aDb; /* 所有数据库后端 */
int nDb; /* 当前使用的后端数量 */
u64 flags; /* 行为控制标志 */
i64 lastRowid; /* 最近一次 INSERT 的 ROWID */
int errCode; /* 最近的错误码 */
u8 autoCommit; /* 自动提交标志 */
u8 enc; /* 文本编码 (UTF-8/16) */
int nVdbeActive; /* 正在运行的 VDBE 数量 */
int aLimit[SQLITE_N_LIMIT]; /* 各种上限 */
/* ... 共约 90 个成员 ... */
};2
3
4
5
6
7
8
9
10
11
12
13
14
这种“用一个主干结构体容纳所有连接状态”的做法,在优秀的 C 语言系统里屡见不鲜。对比 kilo 编辑器里存放全局配置的 editorConfig,SQLite 的 sqlite3 并未固守单一全局空间,而是完全依赖指针在各个子系统与 API 之间流转。一个进程可以从容地创建多个 sqlite3 指针,分别连接不同的数据库文件,彼此之间互不干扰。
除了维系全局连接的 sqlite3 之外,另外两个频繁打交道的关键结构体各司其职:
Vdbe(对外部使用者暴露为sqlite3_stmt *):代表一条已经编译完成的 SQL 语句。它内部封装了一整套字节码指令序列、虚拟机的寄存器阵列、操作数据的 B 树游标以及推进执行的程序计数器。每次调用sqlite3_prepare_v2,引擎便会生成一个新的Vdbe实例;直到调用sqlite3_finalize时,它所占据的资源才会被彻底释放。Parse:代表 SQL 解析过程的上下文。它的生命周期格外短暂,仅在sqlite3_prepare_v2执行期间存在。词法分析器、语法分析器和代码生成器之间需要频繁交换符号表、语法树节点以及生成的字节码指针,Parse正是承载这部分共享状态的容器。
struct Parse {
sqlite3 *db; /* 所属的数据库连接 */
Vdbe *pVdbe; /* 正在构建的虚拟机 */
int rc; /* 返回码 */
u8 nested; /* 嵌套调用深度 */
u8 isMultiWrite; /* 是否可能修改多行 */
/* ... */
};2
3
4
5
6
7
8
Parse 体现了系统编程中经典的“上下文对象”模式。从词法分析一路走到代码生成,跨越了多个不同的处理阶段,如果将解析所需的状态逐一写进函数形参,接口签名很快就会膨胀失控;如果图省事放到全局对象中,又会瞬间摧毁多线程多连接的并发能力。把临时状态整合成一个轻量结构体并在调用链路中逐层传递,既理清了数据归属,也便于在出错时统筹清理。
4. 一条查询的生命周期
prepare / step / finalize 三阶段生命周期
SQLite 将一条 SQL 语句的处理流程划分为三个显式阶段:
- Prepare(编译):调用
sqlite3_prepare_v2。引擎将传入的 SQL 文本送入编译器前端,经过词法拆解、语法推导与代码生成,编译为 VDBE 字节码程序,并交付一个语句句柄sqlite3_stmt *。 - Step(执行):调用
sqlite3_step。虚拟机开始驱动字节码运转。若是一条查询语句(SELECT),单次调用在遇到结果行时会停顿下来,返回SQLITE_ROW,供调用方拉取列数据;若是一条写入或更新语句(INSERT/UPDATE/DELETE),单次调用就会将指令执行完毕,返回SQLITE_DONE。 - Finalize(释放):调用
sqlite3_finalize。当不再需要执行该语句时,通知引擎销毁编译生成的字节码、回收寄存器与游标占用的内存。
明确划分这三个阶段带来了显著的工程优势。最直观的收益在于预编译复用:同一条结构相同但参数不同的 SQL,只需编译一次,后续便能在循环中反复执行(配合 sqlite3_reset 重置指令游标即可),省去了重复词法与语法分析的庞大开销。
一段典型的 API 协作调用流程如下:
sqlite3 *db;
sqlite3_stmt *stmt;
sqlite3_open("test.db", &db);
/* Prepare:编译 SQL → 字节码 */
sqlite3_prepare_v2(db,
"SELECT name FROM users WHERE id = ?;",
-1, &stmt, NULL);
sqlite3_bind_int(stmt, 1, 42); /* 绑定参数 */
/* Step:逐行获取结果 */
while( sqlite3_step(stmt) == SQLITE_ROW ){
const char *name = (const char *)sqlite3_column_text(stmt, 0);
printf("name = %s\n", name);
}
/* Finalize:释放资源 */
sqlite3_finalize(stmt);
sqlite3_close(db);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
代码中的 sqlite3_bind_int 负责把实际数值注入占位符 ?。这种参数绑定机制让参数值脱离了 SQL 文本拼接环节,从根源上杜绝了 SQL 注入攻击;同时,编译好的同一条语句对象可以在下一次循环中重新绑定新的入参继续运转。
5. 词法分析器详解
在整个 SQLite 庞大的代码库中,词法分析器(主要集中在 tokenize.c,仅约 900 行)算得上是结构最纯粹、依赖最少的一块,非常适合作为探究编译器前端源码的第一步。
5.1 字符分类系统
前面提到,sqlite3GetToken 并未直接针对每个字符的大量离散 ASCII 码写一大串条件分支,而是借助预先计算好的查找表 aiClass,将 256 个可能的单字节映射到 0–30 的紧凑类别编号上,之后再基于类别进行 switch 分发。
aiClass 是一个大小为 256 字节的静态常量数组,由 SQLite 源码中的预构建工具直接生成并内嵌在编译单元中。比如空格、制表符与换行符都被归入 CC_SPACE(7),所有十进制数字都被划分为 CC_DIGIT(3)。由于分类编号从 0 到 30 紧凑排列,底层的 C 编译器面对这 31 个连续 case 时,能极其轻松地生成一张基于偏移的跳转表(jump table),在单个间接跳转指令下直达目标处理代码,省去了逐项比较或二分查找的指令开销。
5.2 多字符运算符的处理
对于括号、逗号、分号等单字符标记(token),词法分析只需返回长度 1 与对应的标记类型即可。但 SQL 语言中充斥着各类双字符甚至多字符运算符,例如 <=、<>、!=、||,以及行注释 -- 与块注释 /* ... */。词法分析器通过向前窥探一个字符(lookahead)来确定最终形态:
case CC_LT: {
if( (c=z[1])=='=' ){
*tokenType = TK_LE; /* <= */
return 2;
}else if( c=='>' ){
*tokenType = TK_NE; /* <> */
return 2;
}else if( c=='<' ){
*tokenType = TK_LSHIFT; /* << */
return 2;
}else{
*tokenType = TK_LT; /* < */
return 1;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
遇到 CC_MINUS 时也是同样的思路:当扫描到减号 - 时,先查看下一字节;如果紧跟着另一个减号,就表明这里开启了一段行注释 --,解析器直接扫到行末:
case CC_MINUS: {
if( z[1]=='-' ){
for(i=2; (c=z[i])!=0 && c!='\n'; i++){}
*tokenType = TK_SPACE; /* 注释按空白处理 */
return i; /* 注释延续到行尾 */
}
*tokenType = TK_MINUS;
return 1;
}2
3
4
5
6
7
8
9
5.3 词法分析器与解析器的交互
在很多由传统 yacc/bison 生成的解析器里,都是由语法解析器占据主导权,当它需要下一个符号时主动调用 yylex() 索取(“拉取”模式)。而 SQLite 选择了相反的“推送”模式。
负责将词法与语法分析串联起来的 sqlite3RunParser 函数,其核心是一个简洁明了的驱动循环:
- 调用
sqlite3GetToken,从输入缓冲区中解析出下一个 token 的类型与字节长度。 - 过滤掉无意义的连续空白和注释标记。
- 把准备就绪的 token 及其属性直接喂入 Lemon 生成的推导函数
sqlite3Parser。 - 重复上述步骤,直到 SQL 字符串解析完毕,或者解析器抛出语法错误/宣布解析完成。
这种由外层循环主动把 token 推送给解析器的设计,赋予了编译器前端更高的灵活性。例如在某些特定的语法树位置,SQLite 可以在将标记推送给语法分析器之前,结合上下文环境动态裁决某个标识符(如 WINDOW、OVER、FILTER)究竟是作为普通字段名看待,还是提升为保留关键字处理,规避了传统解析器在上下文相关关键字识别上的诸多死锁难题。
6. 后续文章
顺着一条 SQL 语句从前向后的处理脉络,后续文章将逐层深入 SQLite 的执行与存储核心:
- 虚拟机(VDBE):深入拆解 VDBE 虚拟机的寄存器模型与操作码体系,结合
EXPLAIN输出追踪sqlite3_step的核心执行循环。 - B 树与存储引擎:剖析 B 树与 B+ 树在磁盘页上的精妙排布、变长记录的编码格式、Pager 的事务日志与崩溃恢复流程,以及 WAL 模式下的高并发读写机制。
7. 习题
7.1 巩固
aiClass 把 256 个字节值映射到 31 个分类。写一个程序(把合并源码 sqlite3.c 直接 #include 进来,或者从源码里抄出 aiClass[] 与 CC_* 的定义)打印三样东西:
- 全部 256 项里一共出现了多少种不同的分类号,最大的是多少。
'0'、'9'、' '、'\t'、'\n'、'-'、'<'、'/'、'a'、'A'、'_'的分类号,并说出每个对应哪个CC_*常量。- 用
gcc -O2 -S -masm=intel编译两个函数:一个直接对字符c做switch,case用真实字符编码('\t'、' '、'!'、'A'、'a'、127、128、255这类稀疏分布);另一个先aiClass[c]再对 0–30 做switch。两个函数的返回值都写成互不相关的常数,免得编译器把它化简成算术。比较两份汇编,看哪个生成了.long标签表加jmp rax,哪个是一串cmp与条件跳转。
验收:第 1 问为 31 种、最大 30;第 3 问指出汇编里出现 CSWTCH 标签表(或 jmp rax 一类的间接跳转)的是分类切换的那一份,另一份是逐值比较。优化策略随编译器版本变化,照实记录你这一份的结果即可;如果两份都被优化成比较,换 -O1 或换一组更密集的 case 再试。
先写出答案,再用 sqlite3GetToken 验证。对下面五个输入,逐个给出 token 的字节长度序列与个数:
x>=1 AND y!=2
1--2
1- -2
a<<2
/*c*/x2
3
4
5
1--2与1- -2的 token 个数为什么不同?分别落在CC_MINUS分支的哪条路径上?a<<2走CC_LT的哪个分支?如果把case CC_LT的前瞻只保留'='和'>',这个输入会解析成什么?/*c*/x的第一个 token 长度是多少?注释的tokenType是什么?为什么注释按空白处理?
验收:长度序列与实测一致(提示:1--2 是 2 个 token,1- -2 是 5 个);三问都能指到 tokenize.c 的 sqlite3GetToken 的具体分支。
7.2 编程
用 §2.1 的四个函数写一个程序 bulk.c,全程不用 sqlite3_exec:
sqlite3_open打开bulk.db,用sqlite3_prepare_v2加sqlite3_step执行CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT)。- 只 prepare 一次
INSERT INTO t(v) VALUES(?),在循环里绑定、step、reset,插入 5000 行,内容从row-0000到row-4999。 - 再 prepare 一次
SELECT id, v FROM t ORDER BY id,逐行step打印前 3 行与后 3 行,统计总行数。 - 用
clock()(<time.h>)分别计时两种写法:prepare 一次复用,和每次循环重新 prepare。各插 5000 行,报告两个数字。
验收:两种写法都插入 5000 行、查询返回 5000 行;计时显示复用更快,并说明省掉的是词法分析、语法分析和代码生成中的哪几步。程序在 -Wall -Wextra 下无警告。
写一个多语句执行器 runscript.c:读入整个 .sql 文件,反复调用 sqlite3_prepare_v2(db, tail, -1, &stmt, &tail) 直到语句耗尽。要求:
stmt为NULL时(文件尾的空白或纯注释)直接结束,不当作错误。- 每条语句打印「消耗的字节数 + 剩余文本的前 20 个字符」,再
step:有结果列就逐行打印,没有就打印done。 - 先用下面这个脚本,写出预期的调用次数与每次的剩余文本,再运行:
-- 建表
CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT);
INSERT INTO users VALUES(1,'Alice;2'),(2,'Bob');
SELECT name FROM users WHERE id = 1;2
3
4
- 再换一个只有一行
-- nothing的文件,记录stmt的值与返回码。
验收:第一次调用返回的语句文本包含开头的注释行,消耗字节数与你的预测一致;字符串字面量里的分号不会切断语句;纯注释文件得到 stmt == NULL 且返回 SQLITE_OK。说明 pzTail 指向什么、调用者为什么必须自己推进它。
合并源码用 Begin file / End of 注释保留了每个源文件的边界。用 grep、awk 或一个小脚本完成:
- 为下面每个符号找到它所在的
Begin file段,填一张「符号、源文件、层」的表:sqlite3GetToken、sqlite3Parser、sqlite3VdbeExec、sqlite3BtreeInsert、sqlite3PagerGet、sqlite3WalCheckpoint、sqlite3OsRead、sqlite3_create_function_v2、openDatabase。 - 统计 §2 表格中每一层的核心源文件各自的行数,加总后从大到小排序。
pager.c与tokenize.c里各有一处「在中间包含头文件」的注释:Include wal.h in the middle of pager.c、Include keywordhash.h in the middle of tokenize.c。统计时这些嵌套段算在谁头上?给出你的处理方式并说明影响。
验收:九个符号全部落到正确文件;行数排序中代码生成一组(select.c、where.c、expr.c、build.c 等)排在最前,与正文「代码生成器是代码量最大的部分」一致;第 3 问说明为什么只按相邻两个 Begin 相减会把 pager.c 算成几十行。
7.3 综合与挑战
把 §2 的前四层缩成一个能跑的小程序 minisql.c,只支持一种查询:
SELECT <列名> FROM <表名> WHERE <列名> <比较符> <整数>;其中比较符是 >、>=、<、<=、= 之一。内存表有三行:Alice 25、Bob 35、Charlie 40,列名为 name 与 age。程序分四段,接口自己定:
- 词法:把输入切成标识符、整数、比较符、分号四类 token,给出每类的长度。
- 语法:检查 token 顺序,构建
struct Select { char col[32]; char table[32]; int op; long val; };出错时打印出错处的字节偏移。 - 代码生成:把结构体翻译成一条指令数组,至少包含
OPEN、REWIND、COLUMN、COMPARE_JUMP、RESULT、NEXT、HALT,跳转目标写成数组下标。 - 执行:用内存表执行指令数组,遇到
RESULT打印一行并暂停,下次调用从下一条继续;COLUMN按列名从name与age中取值。
验收:SELECT name FROM users WHERE age > 30 输出 Bob、Charlie;把 > 改成 <= 输出 Alice;SELECT name FROM users WHERE age > 打印出错位置,位置由你的实现定义但要在报告里说明;把执行器改成一次跑到底后,再调用一次能从暂停处继续。代码在 -Wall -Wextra 下无警告。