文中讲述的原理是推理和探讨 , 和现实中的实现不一定完全相同 。
编译原理 给人的感觉好像一直都比较晦涩, 其实 编译原理 没那么难啦 。
编译原理 分 3 步 :
1 语法分析 (文法分析)
2 生成 目标代码
3 链接 (Link)
关于 语法分析, 可以参考我之前写的一个项目 《SelectDataTable》 https://www.cnblogs.com/KSongKing/p/9455216.html , 可以解析简单的 Sql 语句, 用 Sql 查询 DataTable 里的资料 。
生成目标代码 的 部分, 还是可以参考 SelectDataTable …… 哈哈哈, SelectDataTable 里并没有生成 目标代码, SelectDataTable 解析 Sql 的结果是 一个 表达式树, 通过 递归执行 表达式树, 来得到 Sql 的 执行结果 。
对于 生成目标代码, 可以 递归执行 表达式树 来 产生 目标代码 。
当然, 要产生 具体 的 目标代码, 需要对 硬件 操作系统 汇编 熟悉 。
但是, 如果我们从 广义的角度 来 定义 编译原理 的 话, 也许不需要了解 硬件 操作系统 汇编 也可以实现一个 编译器 哦 ~ ~ !
比如, 如果 目标代码 是 .Net IL , 那么, 只要 了解 .Net 平台就可以了 。
又如, 如果 目标代码 是 C#, …… 那么, 只要了解 C# 就可以了 。 ^^
事实上, 我提议过 用 C 语言作为 中间语言 来 开发一个 泛语言(跨语言)的 编译器 。 参考 《我发起了一个用 C 语言作为中间语言的编译器项目 VMBC》 https://www.cnblogs.com/KSongKing/p/9628981.html 。 VMBC 是受到 LLVM 的启发而产生的想法 。
用 C 语言作为中间语言 来 开发编译器, 意味着 可以用 C 语言 作为 目标代码 语言 。
对于 第 3 部分, 链接, 传统的教科书是这样说的 “目标代码还需要和外部的一些 库 链接 ……”, 我一直都搞不懂这个 外部的一些库 是什么, 不过就在前不久终于明白了 。 外部的一些 库 包括 3 类 :
1 操作系统原语(或者叫 系统调用)
2 基础库 (就像 .Net 里的 System.XXX, 当然 操作系统 提供的 基础库 可能比较原始)
3 第三方 库, 这个就很容易理解了, 程序引用的 DLL 什么的
链接, 本质上就是 填入调用方法的 入口地址, 或者 按格式 留下空位, 在运行时根据动态加载的 库 (比如 DLL), 填入 调用方法 的 入口地址 。
前者 是 静态链接, 后者是 动态链接 。当然 静态链接 还分为 2 种情况, 一种只是 填入 入口地址, 通常这些 入口地址 是 固定的, 比如 操作系统 的 底层 API, 一种是把 库 的代码也包含进入到 目标代码 里, 作为 可执行程序 的 一部分 。
实际上 通常讲 的 静态链接 是指将 库 的代码 包含到 目标代码 里。
上面说的, 在 编译时就填入 库 的 调用方法 的 入口地址 。
等, 我们先 捋 一下, 不然有点乱 。
实际上我们说了 3 种情况 :
1 在 编译 时 将 调用方法 的 入口地址 填入 目标代码,
2 在 编译 时 将 库 的 代码 包括进入到 目标代码 里
3 在 编译 时 按 规则 生成 调用 库 方法 的 代码, 当然 也 预留 了 可填入 库 方法 入口地址 的 空位空间, 在 运行时 告知 操作系统 要 链接 的 库, 操作系统 返回 库 的 入口地址, 程序 将 入口地址 填入 预留 的 空位 中, 从此 可以 通过 编译时 生成的 调用 库 方法 的 代码 调用 库方法 。
这里说的 库 的 入口地址 是一个 笼统的 概念, 前面我们提的是 调用方法 的 入口地址, 这里又变成了 库 的入口地址 。
调用方法 的 入口地址 = 库的入口地址 + 方法在库里的偏移量
方法在库里的偏移量 是在编译时可以确定的, 根据 库 的 元数据文件 可以知道 方法 在 库里的偏移量 。 库 的 元数据文件 可以是 DLL 本身, 也可以是 DLL 以外的一些文件, 只包含 接口 的 元数据 文件, Win 32 下我记得有好几种 非 DLL 的 文件 可以作为 接口元数据 文件, 扩展名我记不得了 。 这些 接口 元数据 文件 就类似 C / C++ 语言 里的 头文件(Head File), 只包含 接口 的 定义, 不包含具体的实现代码 。
对于 情况 1, 这种情况 实际中可能并不太可能使用 。 这种方法 不像 情况 2 一样 将 库 的 代码 包括 到 目标代码 里, 也不像 情况 3 一样在运行时才由 操作系统 告知 库 的 地址, 实际上, 情况 1 比较像是 早期 的 做法, 透着 原始 朴素 实验室 的 气质 。
要使用 情况 1 的方式, 通常比较适用的是 操作系统原语 和 基础库, 但即便如此, 在 操作系统 的 不同版本 和 世代 之间 要 保持兼容性 也不容易 。
所谓世代, 比如 Win7 , Win8 , Win10 , 这样是 3 个世代, 版本的话, 比如 Win7.1 , Win7.2 , Win7.3 这样是不同的版本 。
要在 不同版本 和 世代 之间都保持 操作系统 内核库 的 库 地址不变, 这个可能比较勉强 。 在 实验室 的 时代 倒是应该可以 。
所以, 现在实际在用的, 应该是 情况 2 和 情况 3, 这 2 种 方式 也就是现在所说的 “静态链接” 和 “动态链接” 。
我们来作一个假设, 系统调用 分为 2 种, 一种是 跨进程的, 另一种是 不跨进程的 。 跨进程的 就是要 切换到 系统进程, 不跨进程的 不需要切换到系统进程, 相当于是调用了一个 函数 。
对于 跨进程 的 情况, 需要设置一个系统中断, 通过 中断 来切换到 系统进程, 不跨进程的, 就是调用一个 库函数 。
但实际上, 对于 跨进程的情况, 也可以通过调用 库函数 的 方式 来完成, 在 库函数 里由 库函数 来 设置 系统中断 。
这样的话, 问题就归结到 调用 库函数 了 。
接下来, 库函数 应如何调用呢 ?
操作系统 应该 制定一个 规则, 让 程序 和 库 遵守, 就是 调用函数 的 规则 。
函数 如何 调用 ?
函数 就是 堆栈(Stack) 。 假设 CPU 有 3 个 寄存器 A B C , 那么, 用 A 来保存 栈顶, B 来保存 本次函数调用 在 栈 里的 开始地址(也可以叫 基址), 那么, 就可以开始 函数调用 了 。
我的理解是, 栈底 是 栈 固定的一端, 栈顶 是 栈 活动的一端, 可以 压入(Push) 和 弹出(Pop) 数据 的 一端 。
假设 栈底 的 地址 是 100,
当 第一个函数调用 开始时, 向 栈 里 压入 参数 和 局部变量, 假设这些 压入 的数据 占用了 10 个 字节, 那么, 此时, 栈顶(地址) 是 110, 本次调用 的 基址 是 100 + 0 = 100 。 直观的来看, 第一个函数调用 占用 的 栈空间 是 100 - 109 这段 地址空间 。
当 第二个函数调用 开始时, 同样向 栈 里 压入 参数 和 局部变量, 假设压入的 数据 占用了 20 个 字节, 那么, 此时, 栈顶 是 130, 本次调用 的 基址 是 上次调用 的 栈顶, 即 第一次调用 的 栈顶 110 。 直观的来看, 第二个函数调用 占用 的 栈空间 是 110 - 129 这段 地址空间 。
以此递推 。
如果 栈顶 超过了 堆栈 的 最大 Size, 就会 抛出 “StackOverflow” 的 异常 。
这就是 函数 的 调用方法, 也是 程序 和 库 要 共同遵守 的 规则 。 程序 和 库 共同遵守 了 这个 规则, 程序 就可以 调用 库, 库 也可以调用 其它 库 。
但 要在 操作系统 和 各种语言 的 编译器 之间 都 遵守这个 规则, 可能 不太现实 。 比如 操作系统 和 各种语言 的 编译器 编译 函数调用 的 时候 都 处理为 寄存器 A 存 栈顶, 寄存器 B 存 调用基址, 这个 太死板, 实际中 很难统一 。
所以, 我们还有 方案二 。 ^^
方案二 其实 是 方案一 的 扩展版 。
就是 在 调用 库 的时候, 把 当前函数 的 栈顶 和 调用基址 传给 库, 实际上 也是 作为 参数 传给 库, 这样可以和 其它参数 一样, 在 堆栈 里 保存起来 。 接下来 的 执行 就交给 库, 库 同样 将 参数 和 局部变量 压入 栈, 同时将 新的 栈顶 和 调用基址 存入 寄存器, 库 可以按照自己的 规则 来将 栈顶 和 调用基址 存入 寄存器, 比如 可以 用 寄存器 C 来 保存 栈顶, 寄存器 D 来 保存 调用基址 。 当 库函数 调用完成, 返回 主程序 时, 将 作为参数 保存在 堆栈 里的 主程序函数 的 栈顶 和 调用基址 返回给 主程序, 主程序 将 栈顶 和 调用基址 按自己的 规则 保存回 寄存器, 比如 寄存器 A 存 栈顶, 寄存器 B 存 调用基址, 然后就可以 继续 执行后面的代码了 。
所以, 现代操作系统 的系统调用, 大概 都是 基于 动态链接库, 而 动态链接库 的 调用过程, 就是 上述 的 过程 。
当然, 也可能使用 静态链接, 上述过程 对 静态链接 也适用 。
同时, 除了 系统库 以外, 上述过程 对 第三方 库 的 调用 也适用 。
所以, 上述过程, 也是 编译器 要处理的 链接 的 过程 。