Repository: xiaoweiChen/Learn-LLVM-17
Branch: main
Commit: c31b917c7617
Files: 104
Total size: 416.1 KB
Directory structure:
gitextract_ejqkru__/
├── .gitignore
├── LICENSE
├── Learn-LLVM-17.tex
├── README.md
└── content/
├── chapter0/
│ ├── 0.tex
│ ├── 1.tex
│ ├── 2.tex
│ └── 3.tex
├── part1/
│ ├── chapter1/
│ │ ├── 0.tex
│ │ ├── 1.tex
│ │ ├── 2.tex
│ │ ├── 3.tex
│ │ ├── 4.tex
│ │ └── 5.tex
│ ├── chapter2/
│ │ ├── 0.tex
│ │ ├── 1.tex
│ │ ├── 2.tex
│ │ ├── 3.tex
│ │ ├── 4.tex
│ │ ├── 5.tex
│ │ ├── 6.tex
│ │ └── 7.tex
│ └── part1.tex
├── part2/
│ ├── chapter3/
│ │ ├── 0.tex
│ │ ├── 1.tex
│ │ ├── 2.tex
│ │ ├── 3.tex
│ │ ├── 4.tex
│ │ ├── 5.tex
│ │ ├── 6.tex
│ │ ├── 7.tex
│ │ └── 8.tex
│ ├── chapter4/
│ │ ├── 0.tex
│ │ ├── 1.tex
│ │ ├── 2.tex
│ │ ├── 3.tex
│ │ └── 4.tex
│ ├── chapter5/
│ │ ├── 0.tex
│ │ ├── 1.tex
│ │ ├── 2.tex
│ │ ├── 3.tex
│ │ ├── 4.tex
│ │ └── 5.tex
│ ├── chapter6/
│ │ ├── 0.tex
│ │ ├── 1.tex
│ │ ├── 2.tex
│ │ ├── 3.tex
│ │ └── 4.tex
│ ├── chapter7/
│ │ ├── 0.tex
│ │ ├── 1.tex
│ │ ├── 2.tex
│ │ ├── 3.tex
│ │ ├── 4.tex
│ │ ├── 5.tex
│ │ └── 6.tex
│ └── part2.tex
├── part3/
│ ├── chapter10/
│ │ ├── 0.tex
│ │ ├── 1.tex
│ │ ├── 2.tex
│ │ ├── 3.tex
│ │ ├── 4.tex
│ │ ├── 5.tex
│ │ ├── 6.tex
│ │ └── 7.tex
│ ├── chapter8/
│ │ ├── 0.tex
│ │ ├── 1.tex
│ │ ├── 2.tex
│ │ ├── 3.tex
│ │ ├── 4.tex
│ │ ├── 5.tex
│ │ └── 6.tex
│ ├── chapter9/
│ │ ├── 0.tex
│ │ ├── 1.tex
│ │ ├── 2.tex
│ │ ├── 3.tex
│ │ ├── 4.tex
│ │ ├── 5.tex
│ │ └── 6.tex
│ └── part3.tex
└── part4/
├── chapter11/
│ ├── 0.tex
│ ├── 1.tex
│ ├── 2.tex
│ ├── 3.tex
│ ├── 4.tex
│ ├── 5.tex
│ ├── 6.tex
│ ├── 7.tex
│ └── 8.tex
├── chapter12/
│ ├── 0.tex
│ ├── 1.tex
│ ├── 2.tex
│ ├── 3.tex
│ ├── 4.tex
│ ├── 5.tex
│ ├── 6.tex
│ ├── 7.tex
│ ├── 8.tex
│ └── 9.tex
├── chapter13/
│ ├── 0.tex
│ ├── 1.tex
│ ├── 2.tex
│ ├── 3.tex
│ └── 4.tex
└── part4.tex
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
*.pdf
*.aux
*.log
*.out
*.gz
*toc
*.listing
*.synctex(busy)
/_minted-Learn-LLVM-17/
================================================
FILE: LICENSE
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: Learn-LLVM-17.tex
================================================
\documentclass[11pt,a4paper,UTF8]{book}
\usepackage{minted}
\usepackage[T1]{fontenc}
\usepackage[utf8]{inputenc}
\usepackage{authblk}
\usepackage{fontspec} %引入字体设置宏包
\setmainfont{Times New Roman} %设置英文正文字体
% Courier New
% Book Antique
\setsansfont{Arial} %英文无衬线字体
\setmonofont{Courier New} %英文等宽字体
\usepackage{ctex} %导入中文包
%\usepackage{ulem}
\usepackage{tocvsec2}
\usepackage{verbatim}
\usepackage{tabularx}
\usepackage{longtable}
\usepackage{booktabs}
\usepackage{multirow}
\usepackage{bbding}
\usepackage{float}
\usepackage{xspace}
\usepackage[none]{hyphenat}
\usepackage{graphicx}
\usepackage{subfigure}
\usepackage{pifont}
\usepackage{hyperref} %制作pdf的目录
\usepackage{subfiles} %使用多文件方式进行
\usepackage{geometry} %设置页边距的包
\geometry{left=2.5cm,right=2cm,top=2.54cm,bottom=2.54cm} %设置书籍的页边距
\usepackage{url}
\hypersetup{hidelinks, %去红框
colorlinks=true,
allcolors=black,
pdfstartview=Fit,
breaklinks=true
}
% 调整itemlist中的行间距
\usepackage{enumitem}
\setenumerate[1]{itemsep=0pt,partopsep=0pt,parsep=\parskip,topsep=5pt}
\setitemize[1]{itemsep=0pt,partopsep=0pt,parsep=\parskip,topsep=5pt}
\setdescription{itemsep=0pt,partopsep=0pt,parsep=\parskip,topsep=5pt}
% 超链接样式设置
\usepackage{hyperref}
\hypersetup{
colorlinks=true,
linkcolor=blue,
filecolor=blue,
urlcolor=blue,
citecolor=cyan,
}
\usepackage{indentfirst}
\usepackage{listings}
\usepackage[usenames,dvipsnames,svgnames, x11names]{xcolor}
\usepackage[most]{tcolorbox}
\tcbuselibrary{breakable} % 引入 breakable 库
\tcbuselibrary{skins} % 引入 skins 库
\usepackage{tikz}
% URL 正确换行
% https://liam.page/2017/05/17/help-the-url-command-from-hyperref-to-break-at-line-wrapping-point/
\makeatletter
\def\UrlAlphabet{%
\do\a\do\b\do\c\do\d\do\e\do\f\do\g\do\h\do\i\do\j%
\do\k\do\l\do\m\do\n\do\o\do\p\do\q\do\r\do\s\do\t%
\do\u\do\v\do\w\do\x\do\y\do\z\do\A\do\B\do\C\do\D%
\do\E\do\F\do\G\do\H\do\I\do\J\do\K\do\L\do\M\do\N%
\do\O\do\P\do\Q\do\R\do\S\do\T\do\U\do\V\do\W\do\X%
\do\Y\do\Z}
\def\UrlDigits{\do\1\do\2\do\3\do\4\do\5\do\6\do\7\do\8\do\9\do\0}
\g@addto@macro{\UrlBreaks}{\UrlOrds}
\g@addto@macro{\UrlBreaks}{\UrlAlphabet}
\g@addto@macro{\UrlBreaks}{\UrlDigits}
\makeatother
% enable subsubsubsection
% from https://tex.stackexchange.com/练习题/274212/correct-hierarchy-levels-of-pdf-bookmarks-for-custom-section-subsubsubsection
\usepackage[depth=3]{bookmark}
\setcounter{secnumdepth}{3}
\setcounter{tocdepth}{4}
\hypersetup{bookmarksdepth=4}
\makeatletter
\newcommand{\toclevel@subsubsubsection}{4}
\newcounter{subsubsubsection}[subsubsection]
\renewcommand{\thesubsubsubsection}{\thesubsubsection.\arabic{subsubsubsection}}
\newcommand{\subsubsubsection}{\@startsection{subsubsubsection}{4}{\z@}%
{-3.25ex\@plus -1ex \@minus -.2ex}%
{1.5ex \@plus .2ex}%
{\normalfont\normalsize\bf\bfseries}}
\newcommand*{\l@subsubsubsection}{\@dottedtocline{4}{11em}{5em}}
\newcommand{\subsubsubsectionmark}[1]{}
\makeatother
\ExplSyntaxOn
% Setup enumerate, itemize and description
\setenumerate { nosep }
\setitemize { nosep }
\setdescription{ nosep }
% Setup minted
\setminted { obeytabs, tabsize=2, breaklines=true, fontsize=\footnotesize, frame=single }
% Def \filename
\NewDocumentCommand { \filename } { m }
{ \noindent \hspace*{\fill} \\ \textit { #1 } \vspace*{ -1ex } \nopagebreak[4] }
% Def \mySamllsection
\NewDocumentCommand { \mySamllsection } { m }
{ \noindent \hspace*{\fill} \\ \textbf { #1 } \vspace*{ -1ex } \nopagebreak[4] \\ }
\NewDocumentCommand { \myGraphic } { mmm }
{
\begin{center}
\includegraphics[width=#1\textwidth]{#2}\\
{#3}
\end{center}
}
% Def \inlcpp
\NewDocumentCommand { \inlcpp } { m }
{ \mintinline { cpp } { #1 } }
% Def cpp environment
\NewDocumentEnvironment { cpp } { }
{ \VerbatimEnvironment
\begin { minted } [ linenos=true ] { cpp } }
{ \end { minted } }
% Def cmake environment
\NewDocumentEnvironment { cmake } { }
{ \VerbatimEnvironment
\begin { minted } [ linenos=true, frame=single ] { cmake } }
{ \end { minted } }
% Def shell environment
\NewDocumentEnvironment { shell } { }
{ \VerbatimEnvironment
\begin { minted } [ linenos=true ] { text } }
{ \end { minted } }
\NewDocumentEnvironment { myNotic } { m }
{ %\hspace*{\fill} \\
\begin { tcolorbox } [ breakable,colback = blue!5!white, colframe=blue!55!black ,title={#1}] }
{ \end { tcolorbox } }
\NewDocumentEnvironment { myTip } { m }
{ %\hspace*{\fill} \\
\begin { tcolorbox } [ breakable,colback = green!5!white, colframe=green!45!black ,title={#1}] }
{ \end { tcolorbox } }
\NewDocumentEnvironment { myWarning } { m }
{ %\hspace*{\fill} \\
\begin { tcolorbox } [ breakable,colback=red!5!white,colframe=red!55!black,title={#1}] }
{ \end { tcolorbox } }
\NewDocumentCommand { \mySubsubsection } { mm }
{
\subsubsection*{\zihao{3} {#1} \hspace{0.2cm}{#2}}
\addcontentsline{toc}{subsubsection}{{#1}\hspace{0.2cm}{#2}}
}
\NewDocumentCommand { \mySubsection } { mmm }
{
\subsection*{\zihao{3}{#1}\hspace{0.2cm}{#2}}
\addcontentsline{toc}{subsection}{{#1}\hspace{0.2cm}{#2}}
\subfile{{#3}}
}
\NewDocumentCommand { \mySection } { mmm }
{
\color{black}
\pagecolor{white}
\section*{\zihao{2}{#1}\hspace{0.5cm}{#2}}
\addcontentsline{toc}{section}{{#1}\hspace{0.5cm}{#2}}
\subfile{{#3}}
}
\NewDocumentCommand { \myPart } { mmm }
{
\color{white}
\section*{\zihao{2}{#1}\hspace{0.5cm}{#2}}
\pagecolor{gray}
\addcontentsline{toc}{section}{{#1}\hspace{0.5cm}{#2}}
\subfile{{#3}}
}
% Latex如何在文本模式批量处理下划线
% https://zhuanlan.zhihu.com/p/615108006
\ExplSyntaxOff
\begin{document}
\begin{sloppypar} %latex中一行文字出现溢出问题的解决方法
%\maketitle
\begin{center}
\thispagestyle{empty}
%\includegraphics[width=\textwidth,height=\textheight,keepaspectratio]{cover.png}
\begin{tikzpicture}[remember picture, overlay, inner sep=0pt]
\node at (current page.center)
{\includegraphics[width=\paperwidth, keepaspectratio=false]{cover.png}};
\end{tikzpicture}
\newpage
\thispagestyle{empty}
\huge
\textbf{Learn LLVM 17}
\\[9pt]
{\Large 学习LLVM编译器工具和核心库的初学者指南(C++版)}
\\[9pt]
\normalsize
作者: Kai Nacke 和 Amy Kwan
\\[8pt]
\normalsize
译者:\href{https://github.com/xiaoweiChen/Learn-LLVM-17}{陈晓伟}
\\[8pt]
\end{center}
\newpage
\pagestyle{empty}
\tableofcontents
\newpage
\setsecnumdepth{section}
\textit{
\mySection{}{作者致谢}{content/chapter0/0.tex}
}
\newpage
\mySection{}{关于作者}{content/chapter0/1.tex}
\newpage
\mySection{}{关于审稿者}{content/chapter0/2.tex}
\newpage
\mySection{}{前言}{content/chapter0/3.tex}
\newpage
\myPart{第一部分}{使用LLVM构建编译器的基础知识}{content/part1/part1.tex}
\newpage
\mySection{第1章}{安装LLVM}{content/part1/chapter1/0.tex}
\mySubsection{1.1.}{编译与直接安装LLVM}{content/part1/chapter1/1.tex}
\mySubsection{1.2.}{配置环境}{content/part1/chapter1/2.tex}
\mySubsection{1.3.}{使用代码库源码进行构建}{content/part1/chapter1/3.tex}
\mySubsection{1.4.}{自定义构建}{content/part1/chapter1/4.tex}
\mySubsection{1.5.}{总结}{content/part1/chapter1/5.tex}
\newpage
\mySection{第2章}{编译器的结构}{content/part1/chapter2/0.tex}
\mySubsection{2.1.}{编译器的构建块}{content/part1/chapter2/1.tex}
\mySubsection{2.2.}{算术表达式语言}{content/part1/chapter2/2.tex}
\mySubsection{2.3.}{词法分析}{content/part1/chapter2/3.tex}
\mySubsection{2.4.}{语法分析}{content/part1/chapter2/4.tex}
\mySubsection{2.5.}{语义分析}{content/part1/chapter2/5.tex}
\mySubsection{2.6.}{使用LLVM后端生成代码}{content/part1/chapter2/6.tex}
\mySubsection{2.7.}{总结}{content/part1/chapter2/7.tex}
\newpage
\myPart{第二部分}{从源码到机器码}{content/part2/part2.tex}
\newpage
\mySection{第3章}{将源码文件转换为抽象语法树}{content/part2/chapter3/0.tex}
\mySubsection{3.1.}{定义一种编程语言}{content/part2/chapter3/1.tex}
\mySubsection{3.2.}{项目的目录结构}{content/part2/chapter3/2.tex}
\mySubsection{3.3.}{管理编译器的输入文件}{content/part2/chapter3/3.tex}
\mySubsection{3.4.}{处理用户信息}{content/part2/chapter3/4.tex}
\mySubsection{3.5.}{构造词法分析器}{content/part2/chapter3/5.tex}
\mySubsection{3.6.}{构建递归下降语法分析器}{content/part2/chapter3/6.tex}
\mySubsection{3.7.}{执行语义分析}{content/part2/chapter3/7.tex}
\mySubsection{3.8.}{总结}{content/part2/chapter3/8.tex}
\newpage
\mySection{第4章}{生成IR代码的基础知识}{content/part2/chapter4/0.tex}
\mySubsection{4.1.}{AST生成IR}{content/part2/chapter4/1.tex}
\mySubsection{4.2.}{使用AST编号生成SSA格式的IR代码}{content/part2/chapter4/2.tex}
\mySubsection{4.3.}{设置模块和驱动程序}{content/part2/chapter4/3.tex}
\mySubsection{4.4.}{总结}{content/part2/chapter4/4.tex}
\newpage
\mySection{第5章}{高级语言结构生成的IR}{content/part2/chapter5/0.tex}
\mySubsection{5.1.}{环境要求}{content/part2/chapter5/1.tex}
\mySubsection{5.2.}{处理数组、结构体和指针}{content/part2/chapter5/2.tex}
\mySubsection{5.3.}{获得应用程序二进制接口}{content/part2/chapter5/3.tex}
\mySubsection{5.4.}{为类和虚函数创建IR}{content/part2/chapter5/4.tex}
\mySubsection{5.5.}{总结}{content/part2/chapter5/5.tex}
\newpage
\mySection{第6章}{生成IR代码的进阶知识}{content/part2/chapter6/0.tex}
\mySubsection{6.1.}{抛出和捕获异常}{content/part2/chapter6/1.tex}
\mySubsection{6.2.}{为基于类型的别名分析生成元数据}{content/part2/chapter6/2.tex}
\mySubsection{6.3.}{生成调试元数据}{content/part2/chapter6/3.tex}
\mySubsection{6.4.}{总结}{content/part2/chapter6/4.tex}
\newpage
\mySection{第7章}{优化IR}{content/part2/chapter7/0.tex}
\mySubsection{7.1.}{环境要求}{content/part2/chapter7/1.tex}
\mySubsection{7.2.}{LLVM通道管理器}{content/part2/chapter7/2.tex}
\mySubsection{7.3.}{实现一个新的通道}{content/part2/chapter7/3.tex}
\mySubsection{7.4.}{使用ppprofiler}{content/part2/chapter7/4.tex}
\mySubsection{7.5.}{向编译器添加优化管道}{content/part2/chapter7/5.tex}
\mySubsection{7.6.}{总结}{content/part2/chapter7/6.tex}
\newpage
\myPart{第三部分}{LLVM的进阶}{content/part3/part3.tex}
\newpage
\mySection{第8章}{TableGen语言}{content/part3/chapter8/0.tex}
\mySubsection{8.1.}{环境要求}{content/part3/chapter8/1.tex}
\mySubsection{8.2.}{了解TableGen语言}{content/part3/chapter8/2.tex}
\mySubsection{8.3.}{实验TableGen语言}{content/part3/chapter8/3.tex}
\mySubsection{8.4.}{使用TableGen文件生成C++代码}{content/part3/chapter8/4.tex}
\mySubsection{8.5.}{TableGen的缺点}{content/part3/chapter8/5.tex}
\mySubsection{8.6.}{总结}{content/part3/chapter8/6.tex}
\newpage
\mySection{第9章}{JIT编译}{content/part3/chapter9/0.tex}
\mySubsection{9.1.}{环境要求}{content/part3/chapter9/1.tex}
\mySubsection{9.2.}{LLVM的整体JIT的实现和用例}{content/part3/chapter9/2.tex}
\mySubsection{9.3.}{使用JIT直接执行}{content/part3/chapter9/3.tex}
\mySubsection{9.4.}{用LLJIT实现JIT编译器}{content/part3/chapter9/4.tex}
\mySubsection{9.5.}{从头开始构建JIT编译器类}{content/part3/chapter9/5.tex}
\mySubsection{9.6.}{总结}{content/part3/chapter9/6.tex}
\newpage
\mySection{第10章}{使用LLVM工具进行调试}{content/part3/chapter10/0.tex}
\mySubsection{10.1.}{环境要求}{content/part3/chapter10/1.tex}
\mySubsection{10.2.}{用消毒器检测应用程序}{content/part3/chapter10/2.tex}
\mySubsection{10.3.}{使用libFuzzer查找bug}{content/part3/chapter10/3.tex}
\mySubsection{10.4.}{使用XRay进行性能分析}{content/part3/chapter10/4.tex}
\mySubsection{10.5.}{使用clang静态分析器检查源代码}{content/part3/chapter10/5.tex}
\mySubsection{10.6.}{创建基于clang的工具}{content/part3/chapter10/6.tex}
\mySubsection{10.7.}{总结}{content/part3/chapter10/7.tex}
\newpage
\myPart{第四部分}{创建自定义后端}{content/part4/part4.tex}
\newpage
\mySection{第11章}{目标描述}{content/part4/chapter11/0.tex}
\mySubsection{11.1.}{为新后端做准备}{content/part4/chapter11/1.tex}
\mySubsection{11.2.}{将新架构添加到Triple类中}{content/part4/chapter11/2.tex}
\mySubsection{11.3.}{扩展LLVM中的ELF文件格式定义}{content/part4/chapter11/3.tex}
\mySubsection{11.4.}{创建目标描述}{content/part4/chapter11/4.tex}
\mySubsection{11.5.}{为LLVM添加M88k后端}{content/part4/chapter11/5.tex}
\mySubsection{11.6.}{实现汇编解析器}{content/part4/chapter11/6.tex}
\mySubsection{11.7.}{创建反汇编器}{content/part4/chapter11/7.tex}
\mySubsection{11.8.}{总结}{content/part4/chapter11/8.tex}
\newpage
\mySection{第12章}{指令选择}{content/part4/chapter12/0.tex}
\mySubsection{12.1.}{定义调用约定规则}{content/part4/chapter12/1.tex}
\mySubsection{12.2.}{通过DAG进行指令选择}{content/part4/chapter12/2.tex}
\mySubsection{12.3.}{添加寄存器和指令信息}{content/part4/chapter12/3.tex}
\mySubsection{12.4.}{向下转译空帧}{content/part4/chapter12/4.tex}
\mySubsection{12.5.}{发出机器指令}{content/part4/chapter12/5.tex}
\mySubsection{12.6.}{创建目标机器和子目标}{content/part4/chapter12/6.tex}
\mySubsection{12.7.}{全局指令的选择}{content/part4/chapter12/7.tex}
\mySubsection{12.8.}{进化后端}{content/part4/chapter12/8.tex}
\mySubsection{12.9.}{总结}{content/part4/chapter12/9.tex}
\newpage
\mySection{第13章}{超越指令选择}{content/part4/chapter13/0.tex}
\mySubsection{13.1.}{为LLVM添加新机器功能通道}{content/part4/chapter13/1.tex}
\mySubsection{13.2.}{将新目标集成到clang前端}{content/part4/chapter13/2.tex}
\mySubsection{13.3.}{针对不同的CPU架构}{content/part4/chapter13/3.tex}
\mySubsection{13.4.}{总结}{content/part4/chapter13/4.tex}
\newpage
\end{sloppypar}
\end{document}
================================================
FILE: README.md
================================================
# Learn LLVM 17
A beginner's guide to learning LLVM compiler tools and core libraries with C++
(*使用C++学习LLVM编译器和核心库的初学者教程*)
* 作者:Kai Nacke 和 Amy Kwan
* 译者:陈晓伟
* 原文发布时间:2024年1月
> [!IMPORTANT]
> 翻译是译者用自己的思想,换一种语言,对原作者想法的重新阐释。鉴于我的学识所限,误解和错译在所难免。如果你能买到本书的原版,且有能力阅读英文,请直接去读原文。因为与之相较,我的译文可能根本不值得一读。
>
>
— 云风,程序员修炼之道第2版译者
## 本书概述
构造编译器是一项复杂而迷人的任务。LLVM项目为编译器提供了可重用的组件,LLVM核心库实现了世界级的优化代码生成器,可以为所有主流CPU架构翻译与源语言无关的机器码中间表示,许多编程语言的编译器已经在使用LLVM。
本书将介绍如何实现自己的编译器,以及如何使用LLVM来实现。您将了解编译器的前端如何将源代码转换为抽象语法树,以及如何从中生成中间表示(IR)。此外,还将探索在编译器中添加一个优化管道,可将IR编译为高性能的机器码。
LLVM框架可以通过多种方式进行扩展,读者将了解如何向LLVM添加通道,甚至是一个全新的后端。高级主题,如编译不同的CPU架构和扩展clang和clang静态分析器与自己的插件和检查器也包括在内。本书遵循一种实用的方法,并附有示例源代码,读者可以在自己的项目中应用相应的代码。
## 作者简介
**Kai Nacke**是一名专业IT架构师,目前居住在加拿大多伦多。毕业于德国多特蒙德技术大学的计算机科学专业。他关于通用哈希函数的毕业论文,被评为最佳论文。
他在IT行业工作超过20年,在业务和企业应用程序的开发和架构方面有丰富的经验。他在研发一个基于LLVM/Clang的编译器。
几年来,他一直是LDC(基于LLVM的D语言编译器)的维护者。在Packt出版过《D Web Development》一书,他也曾在自由和开源软件开发者欧洲会议(FOSDEM)的LLVM开发者室做过演讲。
## 本书相关
* github翻译地址:https://github.com/xiaoweiChen/Learn-LLVM-17
* 译文的LaTeX 环境配置:https://www.cnblogs.com/1625--H/p/11524968.html
* 禁用拼写检查:https://blog.csdn.net/weixin_39278265/article/details/87931348
* 使用xelatex编译时需要添加`-shell-escape`和`-8bit`选项,例如:
`xelatex -synctex=1 -interaction=nonstopmode -shell-escape -8bit "C++-Standard-Library".tex`
* 为了内容中表格和目录索引能正常生成,需要至少两次连续编译
* vscode中配置LaTeX:https://blog.csdn.net/Ruins_LEE/article/details/123555016
================================================
FILE: content/chapter0/0.tex
================================================
\begin{center}
写一本书需要很多时间和精力。没有我的妻子坦尼娅和女儿波琳娜的支持和理解,这本书是不可能完成的。谢谢你们一直鼓励我!
由于一些个人的挑战,这个项目处于危险之中,很感谢艾米作为作者加入这个项目。若没有她,这本书就不会像现在这样好。
再次感谢Packt的团队,不仅对我的写作提供指导,而且对我缓慢的写作表现出理解,并一直激励我坚持下去。我欠他们一个大大的感谢。
\hspace*{\fill} \\
- Kai Nacke
\hspace*{\fill} \\
2023年对我来说是非常具有变革意义的一年,我将自己的LLVM知识贡献给这本书,是今年如此重要的原因之一。我从来没有想过Kai会找到我,开始这段激动人心的旅程,与大家分享LLVM 17 !感谢Kai,感谢他的技术指导和指导,感谢Packt的团队,当然还有我的家人和亲人,感谢他们在我写这本书的过程中给予的支持和动力。
\hspace*{\fill} \\
- Amy Kwan
\end{center}
================================================
FILE: content/chapter0/1.tex
================================================
\textbf{Kai Nacke}是一名专业的IT架构师,目前居住在加拿大多伦多,拥有德国多特蒙德技术大学(Technical University of Dortmund)计算机科学文凭。他关于通用散列函数的毕业论文是当时最好的论文之一。
Kai在IT行业拥有超过20年的经验,在业务和企业应用程序的开发和架构方面拥有丰富的专业知识。在他目前的职位上,开发了一个基于LLVM/clang的编译器。
几年来,Kai一直担任LDC(基于llvm的D编译器)的维护者。他是《D Web Development》和《Learn LLVM 12》的作者,这两本书都由Packt出版。过去,他还是自由和开源软件开发者欧洲会议(FOSDEM)的LLVM开发者工作室的讲师。
\hspace*{\fill} \\
\textbf{Amy Kwan}是一名编译器开发人员,目前居住在加拿大多伦多。Amy来自加拿大大草原,拥有萨斯喀彻温大学计算机科学学士学位。她在职位上,利用LLVM技术作为后端编译器开发人员。此前,Amy曾与Kai Nacke一起在2022年的LLVM开发者大会上发表演讲。
================================================
FILE: content/chapter0/2.tex
================================================
\textbf{Akash Kothari}是伊利诺伊LLVM编译器研究实验室的研究助理。他在伊利诺伊大学厄巴纳-香槟分校获得计算机科学博士学位。Akash专注于性能工程、程序合成、形式语义和验证,他还对探索计算和编程系统的历史非常感兴趣。
\hspace*{\fill} \\
\textbf{Shuo Niu}拥有计算机工程硕士学位,是编译技术领域的一股活跃力量。在英特尔PSG专注于FPGA HLD编译器的五年里,领导了编译器中端优化器的创新。他在开发尖端功能方面的专业知识,使用户能够在FPGA板上实现显着的性能提升。
================================================
FILE: content/chapter0/3.tex
================================================
构造编译器是一项复杂而迷人的任务。LLVM项目为编译器提供了可重用的组件,LLVM核心库实现了世界级的优化代码生成器,可以为所有主流CPU架构翻译与源语言无关的机器码中间表示,许多编程语言的编译器已经在使用LLVM。
本书将介绍如何实现自己的编译器,以及如何使用LLVM来实现。您将了解编译器的前端如何将源代码转换为抽象语法树,以及如何从中生成中间表示(IR)。此外,还将探索在编译器中添加一个优化管道,可将IR编译为高性能的机器码。
LLVM框架可以通过多种方式进行扩展,读者将了解如何向LLVM添加通道,甚至是一个全新的后端。高级主题,如编译不同的CPU架构和扩展clang和clang静态分析器与自己的插件和检查器也包括在内。本书遵循一种实用的方法,并附有示例源代码,读者可以在自己的项目中应用相应的代码。
\mySubsubsection{}{新版本增加的内容}
有一个新的章节,专门介绍在LLVM中使用的TableGen语言的概念和语法,读者可以利用它来定义类,记录和整个LLVM后端。此外,本书还介绍了后端开发的重点,其中讨论了可以为LLVM后端实现的各种新后端概念,例如:实现GlobalISel指令框架和开发机器功能通道。
\mySubsubsection{}{适读人群}
本书是为有兴趣学习LLVM框架的编译器开发人员、爱好者和工程师编写的。对于希望使用基于编译器的工具进行代码分析和改进的C++软件工程师,以及希望获得更多LLVM基本知识的LLVM库的工程师。要理解本书所涵盖的概念,必须具有C++中级编程经验。
\mySubsubsection{}{本书内容}
\textit{第1章,安装LLVM},解释了如何设置和使用你的开发环境,了解如何自行编译LLVM库,以及自定义构建过程。
\textit{第2章,编译器的结构},对编译器组件的概述,最后可以实现第一个生成LLVM IR的编译器。
\textit{第3章,将源码文件转换为抽象语法树},详细介绍了如何实现编译器的前端。为一种小型编程语言创建前端编译器,最后构建抽象语法树。
\textit{第4章,生成IR代码的基础知识},展示了如何从抽象语法树生成LLVM IR。将实现语言的编译器,生成汇编文本或目标代码文件。
\textit{第5章,高级语言结构生成的IR},说明了如何将高级编程语言中常见的源语言特性转换为LLVM IR。将了解聚合数据类型的转换、实现类继承和虚函数的各种选项,以及如何遵循系统的应用程序二进制接口。
\textit{第6章,生成IR代码的进阶知识},展示了如何在源语言中为异常处理语句生成LLVM IR。还将学习如何为基于类型的别名分析添加元数据,以及如何向生成的LLVM IR添加调试信息,并扩展编译器生成的元数据。
\textit{第7章,优化IR},解释了LLVM通道管理器。将实现自己的通道,既作为LLVM的一部分,也作为插件,将了解如何将通道添加到优化通道流水线中。
\textit{第8章,TableGen语言},介绍了LLVM自己的领域特定语言TableGen。该语言用于减少开发人员的编码工作,将了解在TableGen语言中定义数据的不同方法,以及如何在后端使用。
\textit{第9章,JIT编译},讨论了如何使用LLVM实现一个即时(JIT)编译器。将以两种不同的方式为LLVM IR实现自己的JIT编译器。
\textit{第10章,使用LLVM工具进行调试},探讨了LLVM的各种库和组件的细节,可以识别应用程序中的错误,使用消毒器来识别缓冲区溢出和其他错误;使用libFuzzer库,使用随机数据作为输入来测试函数;XRay将帮助程序找到性能瓶颈。可使用clang静态分析器在源代码级别识别错误,并且将了解到可以向分析器添加自己的检查器,还将了解如何使用自己的插件对clang进行扩展。
\textit{第11章,目标描述},解释了如何添加对新的CPU架构的支持。本章讨论了必要的和可选的步骤,如定义寄存器和指令,开发指令选择,以及支持汇编和反汇编程序。
\textit{第12章,指令选择},演示了两种不同的指令选择方法,特别解释了SelectionDAG和GlobalISel是如何工作的,并展示了如何在目标中实现这些功能,基于前一章的例子。此外,将了解如何对指令选择进行调试和测试。
\textit{第13章,超越指令选择},解释了如何通过探索超越指令选择的概念来完成后端实现。这包括添加新的机器通道来实现特定于目标的任务,这些主题对于简单后端来说是不必要的,但对于高度优化的后端来说是有趣的,例如:交叉编译到另一个CPU架构。
\mySubsubsection{}{编译环境}
您需要一台运行Linux、Windows、Mac OS X或FreeBSD的计算机,并为操作系统安装了开发工具链。所需工具请参见表格,所有工具都需要配置到shell的搜索路径中。
% Please add the following required packages to your document preamble:
% \usepackage{longtable}
% Note: It may be necessary to compile the document several times to get a multi-page table to line up properly
\begin{longtable}{|l|l|}
\hline
\textbf{书中涉及的软件/硬件} & \textbf{操作系统} \\ \hline
\endfirsthead
%
\endhead
%
\begin{tabular}[c]{@{}l@{}}C/C++编译器:\\ gcc 7.1.0或更高版本, clang 3.0或更高版本,\\ Apple clang 10.0 或更高版本,\\ Visual Studio 2019 16.7或更高版本\end{tabular} &
\begin{tabular}[c]{@{}l@{}}Linux(any), Windows,\\ Mac OS X或FreeBSD\end{tabular} \\ \hline
CMake 3.20.0或更高版本 & \\ \hline
Ninja 1.11.1 & \\ \hline
Python 3.6或更高版本 & \\ \hline
Git 2.39或更高版本 & \\ \hline
\end{longtable}
要创建第10章“使用LLVM工具调试”中的火焰图,需要从\url{https://github.com/brendangregg/FlameGraph}获取安装脚本。要运行安装脚本,还需要安装最新版本的Perl。要查看图形,还需要能够显示SVG文件的Web浏览器,这是所有现代浏览器都能做到的。要在同一章中查看Chrome Trace Viewer可视化,需要安装Chrome浏览器。
\textbf{如果正在使用本书的数字版本,我们建议您自己输入代码或通过GitHub存储库访问代码(下一节提供链接),将避免复制和粘贴代码。}
\mySubsubsection{}{下载示例}
可以从GitHub网站\url{https://github.com/PacktPublishing/Learn-LLVM-17}下载本书的示例代码。如果有对代码的更新,也会在现有的GitHub存储库中更新。
我们还在\url{https://github.com/PacktPublishing/}上提供了丰富的图书和视频目录中的其他代码包。可以一起拿来看看!
\mySubsubsection{}{联系方式}
我们欢迎读者的反馈。
\textbf{反馈}:如果你对这本书的任何方面有疑问,需要在你的信息的主题中提到书名,并给我们发邮件到\url{customercare@packtpub.com}。
\textbf{勘误}:尽管我们谨慎地确保内容的准确性,但错误还是会发生。如果您在本书中发现了错误,请向我们报告,我们将不胜感激。请访问\url{www.packtpub.com/support/errata},选择相应书籍,点击勘误表提交表单链接,并输入详细信息。
\textbf{盗版}:如果您在互联网上发现任何形式的非法拷贝,非常感谢您提供地址或网站名称。请通过\url{copyright@packt.com}与我们联系,并提供材料链接。
\textbf{如果对成为书籍作者感兴趣}:如果你对某主题有专长,又想写一本书或为之撰稿,请访问\url{authors.packtpub.com}。
\mySubsubsection{}{欢迎评论}
我们很想听听读者们对本书的看法!欢迎请点击这里直接进入这本书的亚马逊评论页面,分享你的反馈。
您的评论对我们和技术社区都很重要,将帮助确保书籍内容的品质。
================================================
FILE: content/part1/chapter1/0.tex
================================================
为了学习如何使用LLVM,最好先从源代码开始编译LLVM。LLVM是一个伞形项目,GitHub库包含属于LLVM的所有项目的源代码。每个LLVM项目都位于存储库的顶级目录中。除了克隆库之外,本地环境还必须安装构建系统所需的所有工具。
本章中,将学习以下主题:
\begin{itemize}
\item
准备环境,将展示如何设置构建系统
\item
克隆库并使用源码构建,了解如何获得LLVM源代码,以及如何编译和安装LLVM核心库和clang与CMake和Ninja
\item
自定义构建过程,将讨论影响构建过程的各种可能性
\end{itemize}
================================================
FILE: content/part1/chapter1/1.tex
================================================
可以使用各种源来安装LLVM二进制文件。若使用的是Linux,则其发行版包含LLVM库。为什么要自己编译LLVM呢?
首先,并非所有安装包都包含使用LLVM进行开发所需的所有文件,自己编译和安装LLVM可以避免这个问题。另一个原因是,LLVM可定制,通过构建LLVM,将了解如何自定义LLVM,这将使读者能够诊断将LLVM应用程序放到另一个平台运行时可能出现的问题。最后,本书的第三部分,将会对LLVM进行扩展,所以需要有能自行构建LLVM的能力。
但在开始使用时,完全可以避免编译LLVM。若想要走这条路,只需要安装下一节中描述的相关工具即可。
\begin{myNotic}{Note}
许多Linux发行版将LLVM分成几个包。请确保安装了开发包。以Ubuntu为例,需要安装llvm-dev包。请确定安装了LLVM 17。对于其他版本,本书中的示例可能需要修改。
\end{myNotic}
================================================
FILE: content/part1/chapter1/2.tex
================================================
要使用LLVM,开发系统应该运行一个通用的操作系统,如Linux、FreeBSD、macOS或Windows。可以在不同的模式下构建LLVM和clang,启用调试符号的构建最多需要30 GB的空间。所需的磁盘空间在很大程度上取决于所选择的构建选项。例如,在发布模式下仅构建LLVM核心库,只针对一个平台,需要最少2 GB的可用磁盘空间。
为了减少编译时间,快速的CPU(例如:时钟速度为2.5 GHz的四核CPU)和快速的SSD也很有帮助。甚至可以在小型设备(如Raspberry Pi)上构建LLVM——需要花费很多时间。本书中的示例是在一台笔记本电脑上开发的,该笔记本电脑采用Intel四核CPU,时钟速度为2.7 GHz,具有40GB RAM和2.5TB SSD磁盘空间。
您的开发系统必须安装一些必备软件,来回顾一下这些软件包的最低要求版本。
要从GitHub查看源代码,需要Git(\url{https://git-scm.com/})。GitHub帮助页面建议至少使用1.17.10版本。由于过去发现的已知安全问题,建议使用最新的可用版本,在撰写本文时为2.39.1。
LLVM项目使用CMake(\url{https://cmake.org/})作为构建文件生成器,至少为3.20.0。CMake可以为各种构建系统生成构建文件。本书中,使用了Ninja(\url{https://ninja-build.org/}),因为它速度快,并且可以在所有平台上使用,建议使用最新版本1.11.1。
显然,还需要一个C/C++编译器。LLVM项目是基于C++17标准,用现代C++编写的。需要一个兼容的编译器和标准库。以下编译器可以与LLVM 17一起工作(已测试):
\begin{itemize}
\item
gcc 7.1.0或更高版本
\item
clang 5.0或更高版本
\item
Apple clang 10.0或更高版本
\item
Visual Studio 2019 16.7或更高版本
\end{itemize}
\begin{myTip}{Tip}
随着LLVM项目的进一步发展,编译器的需求很可能会发生变化。一般来说,应该使用系统可用的最新编译器版本。
\end{myTip}
Python(\url{https://python.org/})用于生成构建文件和运行测试套件,至少为3.8。
虽然本书没有涉及,但可能需要使用Make,而非Ninja,所以需要使用GNU Make(\url{https://www.gnu.org/software/make/})3.79或更高版本。这两种构建工具的用法非常相似。对于下面描述的场景,将每个命令中的ninja替换为make就可以了。
LLVM还依赖于zlib库(\url{https://www.zlib.net/}),至少为1.2.3.4版本。与往常一样,建议使用最新版本1.2.13。
要安装必备软件,最简单的方法是从操作系统中使用包管理器。下面几节中,将为主流操作系统显示安装软件所需的命令。
\mySubsubsection{1.2.1.}{Ubuntu}
Ubuntu 22.04使用apt包管理器。大多数基本的工具都已经安装好了,只缺少开发工具。要一次安装所有软件包,可以输入以下命令:
\begin{shell}
$ sudo apt -y install gcc g++ git cmake ninja-build zlib1g-dev
\end{shell}
\mySubsubsection{1.2.2.}{Fedora和RedHat}
Fedora 37和RedHat Enterprise Linux 9的包管理器名为dnf。和Ubuntu一样,大多数基本的工具都已经安装好了。要一次安装所有软件包,可以输入以下命令:
\begin{shell}
$ sudo dnf –y install gcc gcc-c++ git cmake ninja-build zlib-devel
\end{shell}
\mySubsubsection{1.2.3.}{FreeBSD}
在FreeBSD 13或更高版本上,必须使用pkg包管理器。FreeBSD与基于linux的系统的不同之处在于已经安装了clang编译器。要一次安装所有其他软件包,可以输入以下命令:
\begin{shell}
$ sudo pkg install –y git cmake ninja zlib-ng
\end{shell}
\mySubsubsection{1.2.4.}{OS X}
在OS X上开发,最好从Apple商店安装Xcode。虽然本书中没有使用Xcode IDE,但它附带了所需的C/C++编译器和相关工具。对于其他工具的安装,可以使用包管理器Homebrew(\url{https://brew.sh/})。要一次安装所有软件包,可以输入以下命令:
\begin{shell}
$ brew install git cmake ninja zlib
\end{shell}
\mySubsubsection{1.2.5.}{Windows}
和OS X一样,Windows没有包管理器。对于C/C++编译器,需要下载个人免费使用的Visual Studio Community 2022(\url{https://visualstudio.microsoft.com/vs/community/})。请确保安装了名为Desktop Development with C++的工作负载。可以使用包管理器Scoop(\url{https://scoop.sh/})来安装其他包。按照网站上的描述安装Scoop之后,从Windows菜单中打开VS 2022的x64 Native Tools Command Prompt。要安装所需的软件包,输入以下命令:
\begin{shell}
$ scoop install git cmake ninja python gzip bzip2 coreutils
$ scoop bucket add extras
$ scoop install zlib
\end{shell}
请密切关注Scoop的输出。对于Python和zlib包,建议添加一些注册表项。其他软件需要这些条目才能找到这些软件包。要添加注册表项,最好复制并粘贴来自Scoop的输出,如下所示:
\begin{shell}
$ %HOMEPATH%\scoop\apps\python\current\install-pep-514.reg
$ %HOMEPATH%\scoop\apps\zlib\current\register.reg
\end{shell}
每个命令之后,注册表编辑器将弹出一个消息窗口,询问是否真的要导入这些注册表项,需要单击Yes以完成导入。现在,已经安装了所有所需的工具。
对于本书中的所有示例,必须在VS 2022中使用x64本机工具命令提示符。使用此命令提示符,编译器将自动添加到搜索路径中。
\begin{myTip}{Tip}
LLVM代码库非常大。为了方便地导航源代码,建议使用一个IDE,可以跳转到类的定义,并搜索源代码。我们发现Visual Studio Code(\url{https://code.visualstudio.com/download})是一个可扩展的跨平台IDE,使用起来非常舒服,但这不是运行本书示例的必要条件。
\end{myTip}
================================================
FILE: content/part1/chapter1/3.tex
================================================
准备好构建工具后,可以从GitHub中下载LLVM项目,并构建LLVM。这一过程在所有平台上都是相同的:
\begin{enumerate}
\item
配置Git
\item
克隆库
\item
创建构建目录
\item
生成构建系统文件
\item
最后,编译和安装LLVM
\end{enumerate}
先从配置Git开始吧!
\mySubsubsection{1.3.1.}{配置Git}
LLVM项目使用Git进行版本控制。若以前没有使用过Git,在继续之前应该先做一些Git的基本配置:设置用户名和电子邮件地址。提交修改,将使用这两个信息。
可以使用以下命令检查是否已经在Git中配置了电子邮件和用户名:
\begin{shell}
$ git config user.email
$ git config user.name
\end{shell}
前面的命令将输出在使用Git时已经设置的电子邮件和用户名,但若是第一次设置用户名和电子邮件,可以输入以下命令进行第一次配置。以下命令中,可以简单地将Jane替换为您的姓名,将jane@email.org替换为您的电子邮件:
\begin{shell}
$ git config --global user.email "jane@email.org"
$ git config --global user.name "Jane"
\end{shell}
这些命令更改全局Git配置。在Git存储库中,可以不指定-{}-global选项在本地覆盖这些值。
默认情况下,Git使用vi编辑器提交消息。若喜欢其他编辑器,可以以类似的方式更改配置。例如:要使用nano编辑器,可以输入以下命令:
\begin{shell}
$ git config --global core.editor nano
\end{shell}
有关Git的更多信息,请参阅Git版本控制手册(\url{https:// www.packtpub.com/product/git-version-control-cookbook-secondedition/9781789137545})。
现在可以从GitHub克隆LLVM了。
\mySubsubsection{1.3.2.}{克隆库}
克隆库的命令在所有平台上基本上是相同的。仅在Windows上,建议关闭行结束符的自动翻译。
在所有非windows平台上,输入以下命令克隆库:
\begin{shell}
$ git clone https://github.com/llvm/llvm-project.git
\end{shell}
仅在Windows上,添加禁用行结束符自动翻译的选项,可输入以下内容:
\begin{shell}
$ git clone --config core.autocrlf=false \
https://github.com/llvm/llvm-project.git
\end{shell}
这个Git命令将最新的源代码从GitHub克隆到一个名为llvm-project的本地目录中。现在使用以下命令,将当前目录切换到llvm-project目录:
\begin{shell}
$ cd llvm-project
\end{shell}
目录中是所有LLVM项目,每个项目都在自己的目录中。最值得注意的是,LLVM核心库位于LLVM子目录中。LLVM项目使用分支用于后续版本开发(“release/17.x”)和标签(“llvmg -17.0.1”)来标记某个版本。使用前面的clone命令,可以获得当前的开发状态。本书使用LLVM 17。要将LLVM 17的第一个版本检出到一个名为llvm-17的分支中,可以输入以下命令:
\begin{shell}
$ git checkout -b llvm-17 llvmorg-17.0.1
\end{shell}
通过前面的步骤,就克隆了整个库,并从标记创建了分支。这是最灵活的方法。
Git还允许只克隆一个分支或标记(包括历史记录)。使用git clone -{}-branch release/X https://github.com/llvm/llvm-project,只克隆版本release/17.x分支及其历史。然后,就可以看到LLVM 17发布分支的最新状态。若需要确切的发布版本,只需要像之前一样从发布标签创建一个分支。
使用-{}-depth=1选项,就是Git的浅克隆,可以避免下载太多历史记录。这节省了时间和空间,但显然限制了在本地可以做的事情,包括根据发布标记签出分支。
\mySubsubsection{1.3.3.}{创建构建目录}
LLVM不支持内联构建,并且需要一个单独的构建目录。最简单的方法是在llvm-project目录中创建,这是当前目录。简单起见,将构建目录命名为build。这里,Unix和Windows系统的命令不同。在类Unix系统上,可以使用以下命令:
\begin{shell}
$ mkdir build
\end{shell}
在Windows上,使用以下命令:
\begin{shell}
$ md build
\end{shell}
现在,就已经准备好使用该目录下的CMake工具创建构建系统文件了。
\mySubsubsection{1.3.4.}{生成构建系统文件}
为了使用Ninja生成编译LLVM和clang的构建系统文件,可以运行以下命令:
\begin{shell}
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DLLVM_ENABLE_PROJECTS=clang -B build -S llvm
\end{shell}
-G选项告诉CMake为哪个系统生成构建文件。该选项的常用值如下:
\begin{itemize}
\item
Ninja – for the Ninja build system
\item
Unix Makefiles – for GNU Make
\item
Visual Studio 17 VS2022 – for Visual Studio and MS Build
\item
Xcode – for Xcode projects
\end{itemize}
使用-B选项,告诉CMake构建目录的路径。类似地,可以使用-S选项指定源目录。可以通过使用-D选项设置各种变量来影响生成过程,通常以CMAKE\_(由CMAKE定义)或LLVM\_(由LLVM定义)为前缀。
如前所述,我们也对与LLVM一起编译clang感兴趣。使用LLVM\_ENABLE\_PROJECTS=clang,允许CMake除了为LLVM生成构建文件外,还为clang生成构建文件。此外,CMAKE\_BUILD\_TYPE=Release变量告诉CMAKE它应该为“发布”构建生成构建文件。
-G选项的默认值取决于平台,而构建类型的默认值取决于工具链,但可以使用环境变量定义自己的首选项。CMAKE\_GENERATOR变量控制生成器,CMAKE\_BUILD\_TYPE变量指定构建类型。若使用bash或类似的shell,可以这样设置变量:
\begin{shell}
$ export CMAKE_GENERATOR=Ninja
$ export CMAKE_BUILD_TYPE=Release
\end{shell}
若使用的是Windows命令提示符,可以这样设置变量:
\begin{shell}
$ set CMAKE_GENERATOR=Ninja
$ set CMAKE_BUILD_TYPE=Release
\end{shell}
有了这些设置,创建构建系统文件的命令就变成了下面这样,这样更容易输入:
\begin{shell}
$ cmake -DLLVM_ENABLE_PROJECTS=clang -B build -S llvm
\end{shell}
将在自定义构建过程一节中找到更多关于CMake变量的信息。
\mySubsubsection{1.3.5.}{编译和安装LLVM}
生成构建文件后,可以使用以下命令编译LLVM和clang:
\begin{shell}
$ cmake --build build
\end{shell}
我们告诉CMake在配置步骤中生成Ninja文件,所以这个命令在底层运行Ninja。但若为支持多构建配置的生成器(如Visual Studio)生成构建文件,则需要使用-{}-config选项指定要用于构建的配置。根据硬件资源的不同,该命令的运行时间从15分钟(具有大量CPU内核、内存和快速存储的服务器)到几个小时(具有有限内存的双核Windows笔记本)不等。
默认情况下,Ninja使用所有可用的CPU内核。这有利于提高编译速度,但可能会阻止其他任务的运行;例如,在Windows操作系统的笔记本电脑上,几乎不可能在运行Ninja时上网。幸运的是,可以使用-j选项限制资源使用。
假设有四个可用的CPU内核,而Ninja应该只使用两个(因为你有并行任务要运行)。就使用以下命令进行编译:
\begin{shell}
$ cmake --build build -j2
\end{shell}
编译完成后,运行测试检查是否一切都如预期的那样工作:
\begin{shell}
$ cmake --build build --target check-all
\end{shell}
同样,该命令的运行时随着可用硬件资源的不同而变化很大。checkall Ninja目标运行所有测试用例。为每个包含测试用例的目录生成目标。使用check-llvm而不是check-all会运行LLVM测试,但不会运行clang测试,checkllvm-codegen仅从LLVM(即llvm/test/CodeGen目录)运行CodeGen目录中的测试。
也可以进行快速手动检查。LLVM应用程序之一是LLVM编译器llc。若使用-version选项运行,会显示LLVM的版本,主机CPU和所有支持的架构:
\begin{shell}
$ build/bin/llc --version
\end{shell}
若在编译LLVM时遇到困难,应该查阅入门LLVM系统文档(\url{https://releases.llvm.org/17.0.1/docs/GettingStarted.html#common-problems})中的常见问题部分,了解常见问题的解决方案。
最后一步,安装二进制文件:
\begin{shell}
$ cmake --install build
\end{shell}
类Unix系统上,安装目录是/usr/local。在Windows上,使用C:\verb|\|Program Files\verb|\|LLVM。这可以修改,下一节将解释如何实现。
================================================
FILE: content/part1/chapter1/4.tex
================================================
CMake系统使用CMakeLists.txt文件中的项目描述。顶层文件位于llvm目录“llvm/CMakeLists.txt”。其他目录也有CMakeLists.txt文件,这些文件在生成过程中递归包含。
根据项目描述中提供的信息,CMake检查安装了哪些编译器,检测库和符号,并创建构建系统文件,例如build.ninja或Makefile(取决于所选择的生成器)。也可以定义可重用的模块,例如:检测LLVM是否已安装的函数。这些脚本放在特殊的cmake目录(llvm/cmake)中,在生成过程中会自动搜索该目录。
构建过程可以通过定义CMake变量来定制。命令行选项-D用于将变量设置为一个值,这些变量在CMake脚本中使用。CMake自己定义的变量几乎总是以CMAKE\_为前缀,这些变量可以在所有项目中使用。由LLVM定义的变量以LLVM\_为前缀,但只能在项目定义中包含LLVM时使用。
\mySubsubsection{1.4.1.}{可定义的CMake变量}
有些变量是用环境变量的值初始化的。最值得注意的是CC和CXX,定义了用于构建的C和C++编译器。CMake尝试使用当前shell搜索路径自动定位C和C++编译器,会选择找到的第一个编译器。若本地安装了多个编译器,例如gcc和clang或不同版本的clang,那么这可能不是您构建LLVM所需的编译器。
假设使用clang17作为C编译器,clang++17作为C++编译器。可以在Unix shell中调用CMake,方法如下:
\begin{shell}
$ CC=clang17 CXX=clang++17 cmake –B build –S llvm
\end{shell}
这只会为cmake的调用设置环境变量的值,可以为编译器可执行文件指定一个绝对路径。
CC是CMAKE\_C\_COMPILER CMAKE变量的默认值,CXX是CMAKE\_CXX\_COMPILER CMAKE变量的默认值。可以直接设置CMake变量,而不是使用环境变量:
\begin{shell}
$ cmake –DCMAKE_C_COMPILER=clang17 \
-DCMAKE_CXX_COMPILER=clang++17 –B build –S llvm
\end{shell}
CMake定义的其他相关变量如下所示:
% Please add the following required packages to your document preamble:
% \usepackage{longtable}
% Note: It may be necessary to compile the document several times to get a multi-page table to line up properly
\begin{longtable}{|l|l|}
\hline
\textbf{变量名} &
\textbf{功能} \\ \hline
\endfirsthead
%
\endhead
%
CMAKE\_INSTALL\_PREFIX &
\begin{tabular}[c]{@{}l@{}}
这是安装过程中每个安装路径的前缀。Unix上默认为/usr/local和\\ Windows上默认为C:\textbackslash Program Files\textbackslash{}\textless{}Project\textgreater。要在/opt/llvm目录\\ 下安装LLVM,需要设置-DCMAKE\_INSTALL\_PREFIX=/opt/llvm。\\ 二进制文件将复制到/opt/llvm/bin文件夹下,库文件将复制到\\ /opt/llvm/lib文件夹下,以此类推。
\end{tabular} \\ \hline
CMAKE\_BUILD\_TYPE &
\begin{tabular}[c]{@{}l@{}}
不同类型的构建需要不同的设置。例如,调试构建需要指定生成调\\ 试符号的选项,通常链接到系统库的调试版本。相比之下,发布版\\本使用针对库的生产版本的优化标志和链接。此变量仅用于只能处\\ 理一种构建类型的构建系统,例如:Ninja或Make。对于IDE构建系\\ 统,将生成所有配置,并且需要使用IDE在构建类型之间进行切换。\\ 取值范围如下所示: \\
\\
DEBUG:构建带有调试符号 \\
RELEASE:构建优化运行速度 \\
RELWITHDEBINFO:构建发布版本,但带有调试符号 \\
MINSIZEREL:构建会生成尺寸最小的二进制文件(也是一种优化) \\
\\
默认构建类型由CMAKE\_BUILD\_TYPE变量设置。若未设置此变\\量,则默认值取决于所使用的工具链,并且通常为空。为了生成\\ 发布版本的构建,可以设置-DCMAKE\_BUILD\_TYPE=RELEASE。
\end{tabular} \\ \hline
\begin{tabular}[c]{@{}l@{}}CMAKE\_C\_FLAGS \\ CMAKE\_CXX\_FLAGS\end{tabular} &
\begin{tabular}[c]{@{}l@{}}
这些是编译C和C++源文件时使用的编译选项,初始值取自\\ CFLAGS和CXXFLAGS环境变量。
\end{tabular} \\ \hline
CMAKE\_MODULE\_PATH &
\begin{tabular}[c]{@{}l@{}}
指定CMake模块搜索的其他目录。在默认目录之前搜索指定的目\\ 录,以分号分隔的目录列表。
\end{tabular} \\ \hline
PYTHON\_EXECUTABLE &
\begin{tabular}[c]{@{}l@{}}
若没有找到Python解释器,或者在安装了多个版本的情况下选择了\\ 错误的解释器,则可以将此变量设置为Python二进制文件的路径。\\ 这个变量只有在包含CMake的Python模块时才有效(LLVM就是这\\ 种情况)。
\end{tabular} \\ \hline
\end{longtable}
\begin{center}
表1.1 - CMake定义的其他相关变量
\end{center}
CMake为变量提供了内置帮助。{}-help-variable var选项打印var变量的帮助信息。可以输入以下命令来获取CMAKE\_BUILD\_TYPE的信息:
\begin{shell}
$ cmake --help-variable CMAKE_BUILD_TYPE
\end{shell}
也可以用下面的命令列出所有的变量:
\begin{shell}
$ cmake --help-variable-list
\end{shell}
这个清单很长,可能将输出管道传输到相应的处理程序中会更合适。
\mySubsubsection{1.4.2.}{使用LLVM定义的构建配置变量}
除了没有内置帮助之外,LLVM定义的构建配置变量的工作方式与CMake定义的相同。最有用的变量如下表所示,对首次安装LLVM的用户有用的变量和对更高级的LLVM用户有用的变量。
% Please add the following required packages to your document preamble:
% \usepackage{longtable}
% Note: It may be necessary to compile the document several times to get a multi-page table to line up properly
\begin{longtable}{|l|l|}
\hline
\textbf{变量名} &
\textbf{功能} \\ \hline
\endfirsthead
%
\endhead
%
LLVM\_TARGETS\_TO\_BUILD &
\begin{tabular}[c]{@{}l@{}}
LLVM支持不同CPU架构的代码生成。默认\\ 情况下,所有这些目标都已构建。使用此变\\ 量指定要构建的目标列表,以分号分隔。目\\ 前支持的目标有AArch64、AMDGPU、\\ ARM、AVR、BPF、Hexagon、Lanai、\\ LoongArch、Mips、MSP430、NVPTX、\\ PowerPC、RISCV、Sparc、SystemZ、VE、\\ WebAssembly、X86和XCore。\\
\\
all可以作为所有目标的简写。名称区分大小\\ 写。要只启用PowerPC和SystemZ目标,可\\ 以指定-DLLVM\_TARGETS\_TO\_BUILD=\\ "PowerPC;SystemZ"。
\end{tabular} \\ \hline
LLVM\_EXPERIMENTAL\_TARGETS\_TO\_BUILD &
\begin{tabular}[c]{@{}l@{}}
除了官方支持的架构外,LLVM源代码树还\\ 包含实验目标。这些目标还在开发中,通常\\ 还不支持后端的全部功能。目前的实验得架\\ 构有ARC、CSKY、DirectX、M68k、SPIRV\\ 和Xtensa。要构建M68k目标,可以设置\\ -DLLVM\_EXPERIMENTAL\_TARGETS\_TO\\ \_BUILD=M68k。
\end{tabular} \\ \hline
LLVM\_ENABLE\_PROJECTS &
\begin{tabular}[c]{@{}l@{}}
这是要构建的项目列表,以分号分隔。项目\\ 的源代码必须与llvm目录在同一级(并排布\\ 局)。当前的列表是bolt、clang、\\ clang-tools-extra、compiler-rt、\\ cross-project-tests、libc、libclc、lld、lldb、\\ mlir、openmp、polly和pstl。\\
\\all可以用作此列表中所有项目的简写,可以\\ 在这里指定flang项目。由于一些特殊的构建\\ 需求,它还不是all列表的一部分。\\
\\
要将clang和bolt与LLVM一起构建,可以\\ 设置-DLLVM\_ENABLE\_PROJECT=\\ "clang;bolt"。
\end{tabular} \\ \hline
\end{longtable}
\begin{center}
表1.2 - 对于首次安装LLVM的用户有用的变量
\end{center}
% Please add the following required packages to your document preamble:
% \usepackage[normalem]{ulem}
% \useunder{\uline}{\ul}{}
% \usepackage{longtable}
% Note: It may be necessary to compile the document several times to get a multi-page table to line up properly
\begin{longtable}{|l|l|}
\hline
\textbf{变量名} &
\textbf{功能} \\ \hline
\endfirsthead
%
\endhead
%
LLVM\_ENABLE\_ASSERTIONS &
\begin{tabular}[c]{@{}l@{}}
若设置为ON,则启用断言检查。这些检查有助于发现错\\ 误,在开发过程中非常有用。对于DEBUG版本,默认值\\ 为ON,否则为OFF。要打开断言检查(例如,对于\\ RELEASE版本),可以设置\\ -DLLVM\_ENABLE\_ASSERTIONS=ON。
\end{tabular} \\ \hline
LLVM\_ENABLE\_EXPENSIVE\_CHECKS &
\begin{tabular}[c]{@{}l@{}}
这将启用一些大开销的检查,这些检查可能会减慢编译速\\ 度或消耗大量内存,默认值为OFF。要打开这些检查,可\\ 以指定-DLLVM\_ENABLE\_EXPENSIVE\_CHECKS=ON。
\end{tabular} \\ \hline
LLVM\_APPEND\_VC\_REV &
\begin{tabular}[c]{@{}l@{}}
若使用-version命令行选项,llc等LLVM工具除了显示其\\ 他信息外,还会显示它们所基于的LLVM版本。该版本\\ 信息基于C代码中的LLVM\_REVISION宏。默认情况下,\\ 不仅LLVM版本,而且当前Git哈希值也是版本信息的一\\ 部分。若正在跟踪主分支的开发,就能清楚地表明该工具\\ 基于哪个Git提交。若不需要,则可以使用\\ -DLLVM\_APPEND\_VC\_REV=OFF关闭此功能。
\end{tabular} \\ \hline
LLVM\_ENABLE\_THREADS &
\begin{tabular}[c]{@{}l@{}}
若检测到线程库(通常是pthread库),LLVM会自动包含线\\ 程支持。在这种情况下,LLVM会假定编译器支持TLS\\ (线程本地存储)。若不想要线程支持或者本地编译器不支\\ 持TLS,那么可以设置-DLLVM\_ENABLE\_THREADS=OFF\\ 关闭该功能。
\end{tabular} \\ \hline
LLVM\_ENABLE\_EH &
\begin{tabular}[c]{@{}l@{}}
LLVM项目不使用C++异常处理,因此在默认情况下关闭\\ 异常支持。此设置可能与项目所链接的其他库不兼容。若\\ 需要,可以指定-DLLVM\_ENABLE\_EH=ON来启用异常支\\ 持。
\end{tabular} \\ \hline
LLVM\_ENABLE\_RTTI &
\begin{tabular}[c]{@{}l@{}}
LLVM使用一个轻量级的自构建系统来获取运行时类型信\\ 息,C++ RTTI的生成在默认情况下是关闭的。与异常处理\\ 支持一样,这可能与其他库不兼容。要开启C++ RTTI的生\\ 成,可以设置-DLLVM\_ENABLE\_RTTI=ON。
\end{tabular} \\ \hline
LLVM\_ENABLE\_WARNINGS &
\begin{tabular}[c]{@{}l@{}}
可能的话,编译LLVM应该不会生成任何警告消息,所以\\ 打印警告消息的选项在默认情况下是打开的。要关闭它,\\ 可以设置-DLLVM\_ENABLE\_WARNINGS=OFF。
\end{tabular} \\ \hline
LLVM\_ENABLE\_PEDANTIC &
\begin{tabular}[c]{@{}l@{}}
LLVM源代码应符合C/C++语言标准,默认情况下启用对\\ 源代码进行严格检查。若可能的话,还会禁用特定于编译\\ 器的扩展。要关闭此设置,可以设置\\ -DLLVM\_ENABLE\_PEDANTIC=OFF。
\end{tabular} \\ \hline
LLVM\_ENABLE\_WERROR &
\begin{tabular}[c]{@{}l@{}}
若设置为ON,那么所有警告都被视为错误——发现警告,\\ 编译就会中止。这有助于在源代码中找到所有剩余的警告。\\ 默认情况下,它是关闭的。要打开它,可以设置\\ -DLLVM\_ENABLE\_WERROR=ON。
\end{tabular} \\ \hline
LLVM\_OPTIMIZED\_TABLEGEN &
\begin{tabular}[c]{@{}l@{}}
通常,tablegen工具与LLVM的其他部分使用相同的选项\\ 构建。同时,tablegen是用来生成大部分代码的生成器。\\ 因此,tablegen在调试构建中要慢得多,显著增加了编译\\ 时间。若将此选项设置为ON,则即使在调试构建中,\\ tablegen也会在编译时打开优化,这可能会减少编译时间。\\ 默认为OFF。要打开它,可以指定\\ -DLLVM\_OPTIMIZED\_TABLEGEN=ON。
\end{tabular} \\ \hline
LLVM\_USE\_SPLIT\_DWARF &
\begin{tabular}[c]{@{}l@{}}
若构建编译器是gcc或clang,那么打开该选项将指示编译\\ 器在单独的文件中生成DWARF调试信息。目标文件大小\\ 的减小,减少了调试构建的链接时间,默认为OFF。要打\\ 开它,可以设置-DLLVM\_USE\_SPLIT\_DWARF=ON。
\end{tabular} \\ \hline
\end{longtable}
\begin{center}
表1.3 - LLVM进阶用户的变量
\end{center}
\begin{myNotic}{Note}
LLVM定义了更多的CMake变量,可以在关于CMake的LLVM文档\url{https://releases.llvm.org/17.0.1/docs/CMake.html#llvm-specific-variables}中找到完整的列表。前面的列表只包含最可能需要的部分。
\end{myNotic}
================================================
FILE: content/part1/chapter1/5.tex
================================================
本章中,准备了开发机器来编译LLVM,克隆了GitHub库,并编译了LLVM和clang版本。构建过程可以用CMake变量自定义,了解了有用的变量以及如何更改它们。有了这些知识,就可以根据自己的需要调整LLVM了。
下一节中,我们将进一步了解编译器的结构,将探索编译器内部的不同组件,以及其中发生的不同类型的分析——特别是词法、语法和语义分析。最后,还会简要介绍使用LLVM后端进行代码生成的接口。
================================================
FILE: content/part1/chapter2/0.tex
================================================
编译器技术是计算机科学中一个研究领域,其任务是将源语言翻译成机器码。通常,此任务分为三个部分:前端、中端和后端。前端主要处理源语言,而中端执行转换并改进代码,后端负责生成机器码。由于LLVM核心库提供了中端和后端,我们将在本章中专注于前端的介绍。
本章中,将学习以下主题:
\begin{itemize}
\item
编译器的构建块,将了解编译器中的常用组件
\item
算术表达式语言,将介绍一种示例语言,并展示如何使用语法来定义语言
\item
词法分析,讨论如何实现语言的词法分析器
\item
语法分析,包括从语法构造解析器
\item
语义分析,将了解如何实现语义检查
\item
使用LLVM后端生成代码,讨论如何与LLVM后端进行接口,并将前面的所有阶段粘合在一起,创建完整的编译器
\end{itemize}
================================================
FILE: content/part1/chapter2/1.tex
================================================
自从有了计算机,编程语言就开发了数千种。事实证明,所有编译器都必须解决相同的任务,并且编译器的实现最好根据这些任务进行结构化。总得来说,有三个组成部分。前端将源代码转换为中间表示(IR);中端在IR上执行转换,其目标是提高性能或减少代码的大小;后端将IR生成为机器码。LLVM核心库为所有主流平台提供了一个由复杂转换和后端组成的中端。
此外,LLVM核心库还定义了一个中间表示,用作中间端和后端的输入。这种设计的优点是,只需要关心编程语言的前端即可。
前端的输入是源代码,通常是一个文本文件。为了使它更有意义,前端首先标识语言的单词,例如数字和标识符,通常称为标记。这一步由词法分析器执行。其次,分析了符号构成的句法结构。所谓的解析器执行这个步骤,结果是生成了抽象语法树(AST)。
最后,前端需要由语义分析器检查,代码是否遵守编程语言的规则。若没有检测到错误,则将AST转换为IR,并移交给中端处理。
下面几节中,将为表达式语言构造一个编译器,该编译器将根据其输入生成LLVM IR。LLVM llc静态编译器表示后端,将IR编译成目标代码,这一切都从定义语言开始。切记,本章中所有文件的C++实现,都将包含在src/目录中。
================================================
FILE: content/part1/chapter2/2.tex
================================================
算术表达式是每一种编程语言的一部分。下面是一个算术表达式计算语言calc的例子,calc表达式会编译成一个计算以下表达式的应用程序:
\begin{shell}
with a, b: a * (4 + b)
\end{shell}
表达式中使用的变量必须用关键字with声明。该程序会编译成一个应用程序,该程序向用户询问a和b变量的值,并输出结果。
示例总是简单并容易理解的,但作为编译器作者,需要比这更全面的实现和测试规范。首先,编程语言的载体是语法。
\mySubsubsection{2.2.1.}{描述程序设计语言语法的形式}
语言中的元素,例如关键字、标识符、字符串、数字和操作符,称为标记(token)。从这个意义上说,程序是一个标记序列,语法指定了哪些序列是有效的。
通常,语法以扩展的Backus-Naur形式(EBNF)编写。语法规则有左边和右边,左边只有一个符号,叫做非终结符;右侧由非终结符、记号和用于替代和重复的元符号组成。来看看calc语言的语法:
\begin{shell}
calc : ("with" ident ("," ident)* ":")? expr ;
expr : term (( "+" | "-" ) term)* ;
term : factor (( "*" | "/") factor)* ;
factor : ident | number | "(" expr ")" ;
ident : ([a-zAZ])+ ;
number : ([0-9])+ ;
\end{shell}
第一行中,calc是非终结符。若没有特别说明,则语法的第一个非终结符是开始符号。冒号(:)是规则左右两边之间的分隔符,"with"、","和":"是表示这个字符串的标记,括号用于分组。组可以是可选的,也可以是重复的。右括号后的问号(?)表示一个可选的组。星号*表示零次或多次重复,加号+表示一次或多次重复。ident和expr是非终结符,处理它们使用的是另一种规则。分号(;)表示规则的结束,第二行中的管道|表示另一种选择。最后,最后两行的括号[]表示字符类。有效字符写在括号内。例如,字符类[a-zA-Z]匹配一个大写或小写字母,而([a-zA-Z])+匹配这些字母中的一个或多个。看上去,这就是一个正则表达式。
\mySubsubsection{2.2.2.}{语法如何帮助编译器作者?}
这样的语法看起来像一个玩具,但对编译器作者很有意义。首先,定义所有标记,这是创建词法分析器所需的,语法规则可以转化为解析器。若有解析器是否正确工作的问题,则语法就可以作为标准进行衡量。
然而,语法并不能定义编程语言的所有方面。语法的含义——语义——也必须定义。为此目的的形式也开发出来了,因为是在最初引入该语言时拟定的,所以通常在纯文本中指定。
了解了这些知识,接下来的两节将介绍词法分析如何将输入转换为标记序列,以及语法如何使用C++进行语法分析。
================================================
FILE: content/part1/chapter2/3.tex
================================================
正如在前一节的示例,编程语言由许多元素组成,如关键字、标识符、数字、操作符等。词法分析器的任务是获取文本输入并从中创建一个标记序列。calc语言由,:,+,-,*,/,(,)和正则表达式([a- za -z])+(标识符)和([0-9])+(数字)组成。需要为每个标记分配唯一编号,以使标记更容易处理。
\mySubsubsection{2.3.1.}{手写词法分析器}
词法分析器的实现通常称为Lexer。这里,创建一个名为Lexer.h的头文件,并开始定义Token:
\begin{cpp}
#ifndef LEXER_H
#define LEXER_H
#include "llvm/ADT/StringRef.h"
#include "llvm/Support/MemoryBuffer.h"
\end{cpp}
llvm::MemoryBuffer类提供对内存块的只读访问,内存块由文件的内容填充,在缓冲区的末尾添加一个尾零字符('\verb|\|x00')。我们使用这个特性来读取缓冲区,而不必在每次访问时检查缓冲区的长度。StringRef类封装了一个指向C字符串及其长度的指针。因为对长度进行了保存,所以字符串不需要像普通的C字符串那样以零字符('\verb|\|x00')结束。StringRef实例允许指向由MemoryBuffer管理的内存。
了解了这一点后,我们开始实现Lexer类:
\begin{enumerate}
\item
首先,Token类包含前面提到的唯一标记编号的枚举定义:
\begin{cpp}
class Lexer;
class Token {
friend class Lexer;
public:
enum TokenKind : unsigned short {
eoi, unknown, ident, number, comma, colon, plus,
minus, star, slash, l_paren, r_paren, KW_with
};
\end{cpp}
除了为每个标记定义成员外,还添加了两个值:eoi和unknown。eoi表示输入结束,当处理完输入的所有字符时返回。unknown在词法级别发生错误时使用,例如,\#不是该语言的标记,将映射为unknown。
\item
除了枚举之外,该类还有一个Text成员,该成员指向标记文本的开头,使用了前面提到的StringRef类:
\begin{cpp}
private:
TokenKind Kind;
llvm::StringRef Text;
public:
TokenKind getKind() const { return Kind; }
llvm::StringRef getText() const { return Text; }
\end{cpp}
这对于语义处理很有意义,例如:对于标识符,了解名称很有用。
\item
is()和isOneOf()方法用于测试标识是否属于某种类型。isOneOf()方法使用可变的模板,允许可变数量的参数:
\begin{cpp}
bool is(TokenKind K) const { return Kind == K; }
bool isOneOf(TokenKind K1, TokenKind K2) const {
return is(K1) || is(K2);
}
template
bool isOneOf(TokenKind K1, TokenKind K2, Ts... Ks) const {
return is(K1) || isOneOf(K2, Ks...);
}
};
\end{cpp}
\item
头文件中,Lexer类本身也有类似的接口:
\begin{cpp}
class Lexer {
const char *BufferStart;
const char *BufferPtr;
public:
Lexer(const llvm::StringRef &Buffer) {
BufferStart = Buffer.begin();
BufferPtr = BufferStart;
}
void next(Token &token);
private:
void formToken(Token &Result, const char *TokEnd,
Token::TokenKind Kind);
};
#endif
\end{cpp}
除了构造函数之外,公共接口只有next()方法,该方法返回下一个标识。该方法的作用类似于迭代器,总是前进到下一个可用标识。该类的唯一成员是指向输入开头和下一个未处理字符的指针,假设缓冲区以0结束(就像C字符串一样)。
\item
在Lexer.cpp文件中实现Lexer类,从一些帮助函数开始分类字符:
\begin{cpp}
#include "Lexer.h"
namespace charinfo {
LLVM_READNONE inline bool isWhitespace(char c) {
return c == ' ' || c == '\t' || c == '\f' ||
c == '\v' ||
c == '\r' || c == '\n';
}
LLVM_READNONE inline bool isDigit(char c) {
return c >= '0' && c <= '9';
}
LLVM_READNONE inline bool isLetter(char c) {
return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z');
}
}
\end{cpp}
这些函数使条件更具有可读性。
\begin{myNotic}{Note}
没有使用标准库头文件提供的函数有两个原因。首先,这些函数根据环境中定义的语言环境改变行为。例如,区域设置是德语区域,那么德语的变音符可以分类为字母。这在编译器中通常是不需要的。其次,由于函数的形参类型为int,因此需要从char类型进行转换。这种转换的结果取决于char是视为有符号类型还是无符号类型,这会导致移植性问题。
\end{myNotic}
\item
前一节对语法的了解中,知道了该语言的所有符号,但语法没有定义应该忽略的字符。例如,空格或换行字符只添加空白,通常应该忽略。next()一开始就会忽略这些字符:
\begin{cpp}
void Lexer::next(Token &token) {
while (*BufferPtr &&
charinfo::isWhitespace(*BufferPtr)) {
++BufferPtr;
}
\end{cpp}
\item
接下来,确保仍然有字符需要处理:
\begin{cpp}
if (!*BufferPtr) {
token.Kind = Token::eoi;
return;
}
\end{cpp}
至少有一个字符需要处理。
\item
首先检查字符是小写还是大写。所以,标记要么是标识符,要么是with关键字,因为标识符的正则表达式也匹配关键字。最常见的解决方案是,收集正则表达式匹配的字符,并检查字符串是否恰好是关键字:
\begin{cpp}
if (charinfo::isLetter(*BufferPtr)) {
const char *end = BufferPtr + 1;
while (charinfo::isLetter(*end))
++end;
llvm::StringRef Name(BufferPtr, end - BufferPtr);
Token::TokenKind kind =
Name == "with" ? Token::KW_with : Token::ident;
formToken(token, end, kind);
return;
}
\end{cpp}
formToken()私有方法用于填充标记。
\item
接下来,检查数字。与前面的代码片段非常相似:
\begin{cpp}
else if (charinfo::isDigit(*BufferPtr)) {
const char *end = BufferPtr + 1;
while (charinfo::isDigit(*end))
++end;
formToken(token, end, Token::number);
return;
}
\end{cpp}
现在只剩下由固定字符串定义的标记。
\item
这很容易用switch完成。由于所有这些标记都只有一个字符,因此使用CASE预处理器宏来减少编码量:
\begin{cpp}
else {
switch (*BufferPtr) {
#define CASE(ch, tok) \
case ch: formToken(token, BufferPtr + 1, tok); break
CASE('+', Token::plus);
CASE('-', Token::minus);
CASE('*', Token::star);
CASE('/', Token::slash);
CASE('(', Token::Token::l_paren);
CASE(')', Token::Token::r_paren);
CASE(':', Token::Token::colon);
CASE(',', Token::Token::comma);
#undef CASE
\end{cpp}
\item
最后,需要检查不支持的字符:
\begin{cpp}
default:
formToken(token, BufferPtr + 1, Token::unknown);
}
return;
}
}
\end{cpp}
只有formToken()私有辅助方法仍然缺失。
\item
填充Token实例的成员,并更新指向下一个未处理字符的指针:
\begin{cpp}
void Lexer::formToken(Token &Tok, const char *TokEnd,
Token::TokenKind Kind) {
Tok.Kind = Kind;
Tok.Text = llvm::StringRef(BufferPtr,
TokEnd - BufferPtr);
BufferPtr = TokEnd;
}
\end{cpp}
\end{enumerate}
下一节中,我们将了解如何构造用于语法分析的解析器。
================================================
FILE: content/part1/chapter2/4.tex
================================================
语法分析由解析器完成,接下来我们将实现解析器。它的基础是前面小节中的语法和词法分析器。解析过程的结果是一个动态数据结构,称为抽象语法树(AST)。AST是输入的一个非常紧凑的表示,非常适合语义分析。
首先,将实现解析器,再了解AST的解析过程。
\mySubsubsection{2.4.1.}{手写解析器}
解析器的接口在头文件Parser.h中定义,以一些include声明开始:
\begin{cpp}
#ifndef PARSER_H
#define PARSER_H
#include "AST.h"
#include "Lexer.h"
#include "llvm/Support/raw_ostream.h"
\end{cpp}
AST.h头文件声明AST的接口,将在后面展示。LLVM的编码指南禁止使用库,所以包含了等效LLVM功能的头文件。这里需要发出一个错误消息:
\begin{enumerate}
\item
Parser类首先声明一些私有成员:
\begin{cpp}
class Parser {
Lexer &Lex;
Token Tok;
bool HasError;
\end{cpp}
Lex和Tok是前一节中类的实例。Tok存储下一个标记(预检),Lex用于从输入中检索下一个标记。HasError标志表示是否检测到错误。
\item
有几个方法处理这个标记:
\begin{cpp}
void error() {
llvm::errs() << "Unexpected: " << Tok.getText()
<< "\n";
HasError = true;
}
void advance() { Lex.next(Tok); }
bool expect(Token::TokenKind Kind) {
if (Tok.getKind() != Kind) {
error();
return true;
}
return false;
}
bool consume(Token::TokenKind Kind) {
if (expect(Kind))
return true;
advance();
return false;
}
\end{cpp}
advance()从词法分析器检索下一个标记。Expect()测试预检是否具有预期的类型,否则发出错误消息。最后,若预检具有预期的类型,则consume()将检索下一个标记。若发出错误消息,则将HasError标志设置为true。
\item
对于语法的每个非终结符,声明一个解析规则的方法:
\begin{cpp}
AST *parseCalc();
Expr *parseExpr();
Expr *parseTerm();
Expr *parseFactor();
\end{cpp}
\begin{myNotic}{Note}
没有标识和编号的方法。这些规则只返回标记,并用相应的标记替换。
\end{myNotic}
\item
接下来是公共接口。构造函数初始化所有成员,并从词法分析器获取第一个标记:
\begin{cpp}
public:
Parser(Lexer &Lex) : Lex(Lex), HasError(false) {
advance();
}
\end{cpp}
\item
需要一个函数来获取错误标志的值:
\begin{cpp}
bool hasError() { return HasError; }
\end{cpp}
\item
最后,parse()方法是解析的主要入口:
\begin{cpp}
AST *parse();
};
#endif
\end{cpp}
\end{enumerate}
\mySubsubsection{2.4.1.1}{解析器的实现}
让我们深入了解解析器的实现!
\begin{enumerate}
\item
Parser.cpp文件的实现中,以parse()开始:
\begin{cpp}
#include "Parser.h"
AST *Parser::parse() {
AST *Res = parseCalc();
expect(Token::eoi);
return Res;
}
\end{cpp}
parse()的要点是使用了整个输入。还记得第一节中的解析示例中,添加了一个特殊符号来表示输入的结束吗?需要在这里进行检查。
\item
parseCalc()实现相应的规则。因为其他解析方法也遵循相同的模式,所以有必要仔细研究一下这个方法。回顾一下第一节的规则:
\begin{cpp}
calc : ("with" ident ("," ident)* ":")? expr ;
\end{cpp}
\item
该方法首先声明一些局部变量:
\begin{cpp}
AST *Parser::parseCalc() {
Expr *E;
llvm::SmallVector Vars;
\end{cpp}
\item
第一个决定是是否必须解析可选组。组以with标记开始,将标记与以下值进行比较:
\begin{cpp}
if (Tok.is(Token::KW_with)) {
advance();
\end{cpp}
\item
接下来,需要一个标识符:
\begin{cpp}
if (expect(Token::ident))
goto _error;
Vars.push_back(Tok.getText());
advance();
\end{cpp}
若标识符存在,则将其保存在Vars vector中。否则,就是一个语法错误,单独处理。
\item
接下来是一个重复组,解析了更多的标识符,并用逗号分隔:
\begin{cpp}
while (Tok.is(Token::comma)) {
advance();
if (expect(Token::ident))
goto _error;
Vars.push_back(Tok.getText());
advance();
}
\end{cpp}
重复组以标记(,)开头,标记的测试成为while循环的条件,实现零或更多次的重复。循环内的标识符和以前一样进行处理。
\item
最后,可选组需要在末尾加一个冒号:
\begin{cpp}
if (consume(Token::colon))
goto _error;
}
\end{cpp}
\item
最后,必须解析expr规则:
\begin{cpp}
E = parseExpr();
\end{cpp}
\item
有了这个调用,规则的解析就完成了。收集到的信息可以用于为该规则创建AST节点:
\begin{cpp}
if (Vars.empty()) return E;
else return new WithDecl(Vars, E);
\end{cpp}
\end{enumerate}
现在只缺少错误处理。检测语法错误很容易,但从中恢复却异常复杂。这里使用了一种简单的方法,称为恐慌模式。
恐慌模式下,标记会从标记流中删除,直到找到一个解析器可以使用它继续工作。大多数编程语言都有表示结束的符号,例如:C++中,a;(语句结束)或\}(语句块结束)。这样的标记是很好的候选对象。
另一方面,错误可能是正在寻找的符号丢失了,所以可能在解析器继续之前删除了许多标记。这并不像听起来那么糟糕,现在更重要的是编译器的速度。若出现错误,开发人员查看第一条错误消息,修复它,然后重新启动编译器。这与使用打孔卡完全不同,打孔卡非常重要的是获取尽可能多的错误消息,因为编译器的下一次运行可能要在第二天才可以进行。
\mySubsubsection{2.4.1.2}{错误处理}
这里没有使用一些标记来查找,而是使用了另一组标记。对于每个非终结符,在规则中都有一组标记可以跟随该非终结符:
\begin{enumerate}
\item
在calc中,只有输入的结尾跟在这个非终结符后面。实现很简单:
\begin{cpp}
_error:
while (!Tok.is(Token::eoi))
advance();
return nullptr;
}
\end{cpp}
\item
其他解析方法的构造也类似。parseExpr()是对expr规则的翻译:
\begin{cpp}
Expr *Parser ::parseExpr() {
Expr *Left = parseTerm() ;
while (Tok.isOneOf(Token::plus, Token::minus)) {
BinaryOp::Operator Op =
Tok.is(Token::plus) ? BinaryOp::Plus :
BinaryOp::Minus;
advance();
Expr *Right = parseTerm();
Left = new BinaryOp(Op, Left, Right);
}
return Left;
}
\end{cpp}
规则中的重复组可转换为while循环,使用isOneOf()方法简化了对多个标记的检查。
\item
术语规则的编码看起来都一样:
\begin{cpp}
Expr *Parser::parseTerm() {
Expr *Left = parseFactor();
while (Tok.isOneOf(Token::star, Token::slash)) {
BinaryOp::Operator Op =
Tok.is(Token::star) ? BinaryOp::Mul :
BinaryOp::Div;
advance();
Expr *Right = parseFactor();
Left = new BinaryOp(Op, Left, Right);
}
return Left;
}
\end{cpp}
此方法与parseExpr()非常相似,有的读者可能想将它们合并为一个方法。在语法中,可以使用一个规则处理乘法和加性操作符。使用两个规则的优点是,操作符的优先级与求值的数学顺序非常吻合。若结合了这两个规则,需要在其他地方弄清楚求值顺序。
\item
最后,需要实现解析factor规则:
\begin{cpp}
Expr *Parser::parseFactor() {
Expr *Res = nullptr;
switch (Tok.getKind()) {
case Token::number:
Res = new Factor(Factor::Number, Tok.getText());
advance(); break;
\end{cpp}
这里不使用if和else if语句链,而使用switch语句,因为每个选项都只以一个标记开头。一般来说,应该考虑喜欢使用哪种翻译模式。若以后需要更改解析方法,并不是每个方法都有不同的实现语法规则的方式,这就很方便修改了。
\item
若使用switch语句,错误处理将在默认情况下发生:
\begin{cpp}
case Token::ident:
Res = new Factor(Factor::Ident, Tok.getText());
advance(); break;
case Token::l_paren:
advance();
Res = parseExpr();
if (!consume(Token::r_paren)) break;
default:
if (!Res) error();
\end{cpp}
由于失败,需要在这里避免发出错误消息。
\item
若括号表达式中存在语法错误,则已经发出错误消息。这个守卫可以防止第二个错误消息:
\begin{cpp}
while (!Tok.isOneOf(Token::r_paren, Token::star,
Token::plus, Token::minus,
Token::slash, Token::eoi))
advance();
}
return Res;
}
\end{cpp}
\end{enumerate}
这很简单,不是吗?记住使用的模式之后,根据语法规则编写解析器是一项乏味的工作。这种类型的解析器称为递归下降解析器。
\begin{myNotic}{并非所有语法都能用于构造递归下降解析器}
语法必须满足某些条件才能适合于构造递归下降解析器,这类语法称为LL(1)。事实上,能在网上能找到的大多数语法都不属于这类语法。大多数关于编译器结构理论的书籍都解释了这一点的原因。关于这个话题的经典书籍是所谓的“龙书”——《编译器:原则,技术和工具》,由Aho, Lam, Sethi和Ullman编写。
\end{myNotic}
\mySubsubsection{2.4.2.}{抽象语法树}
解析过程的结果是AST,AST是输入程序的另一种紧凑表示,捕获了基本信息。许多编程语言都有作为分隔符的符号,但这些符号没有进一步的含义。例如:在C++中,分号;表示单个语句的结束,这些信息对解析器很重要。当将语句转换为内存中的表示形式后,分号就不重要,可以删除了。
看一下示例表达式语言的第一条规则,with关键字、逗号(,)和冒号(:)对于程序的含义并不重要。重要的是声明的变量列表,可以在表达式中使用。结果是只需要几个类来记录信息:Factor保存一个数字或标识符,BinaryOp保存算术运算符和表达式的左右两边,WithDecl存储声明的变量和表达式的列表。AST和Expr仅用于创建公共类层次结构。
除了来自解析输入的信息外,还支持使用访问者模式对树进行遍历。这都在AST.h头文件中有所体现:
\begin{enumerate}
\item
开始于访问者接口:
\begin{cpp}
#ifndef AST_H
#define AST_H
#include "llvm/ADT/SmallVector.h"
#include "llvm/ADT/StringRef.h"
class AST;
class Expr;
class Factor;
class BinaryOp;
class WithDecl;
class ASTVisitor {
public:
virtual void visit(AST &){};
virtual void visit(Expr &){};
virtual void visit(Factor &) = 0;
virtual void visit(BinaryOp &) = 0;
virtual void visit(WithDecl &) = 0;
};
\end{cpp}
访问者模式需要知道要访问的每个类。每个类也引用访问者,所以在文件的顶部声明所有类。请注意,AST和Expr的visit()方法有一个默认实现,但不做任何事情。
\item
AST类是层次结构的根:
\begin{cpp}
class AST {
public:
virtual ~AST() {}
virtual void accept(ASTVisitor &V) = 0;
};
\end{cpp}
\item
类似地,Expr是与表达式相关的AST类的根:
\begin{cpp}
class Expr : public AST {
public:
Expr() {}
};
\end{cpp}
\item
Factor类存储一个数字或一个变量的名称:
\begin{cpp}
class Factor : public Expr {
public:
enum ValueKind { Ident, Number };
private:
ValueKind Kind;
llvm::StringRef Val;
public:
Factor(ValueKind Kind, llvm::StringRef Val)
: Kind(Kind), Val(Val) {}
ValueKind getKind() { return Kind; }
llvm::StringRef getVal() { return Val; }
virtual void accept(ASTVisitor &V) override {
V.visit(*this);
}
};
\end{cpp}
本例中,数字和变量的处理方式几乎相同,所以可以只创建一个AST节点类来表示。Kind成员实例代表两种情况中的哪一种。在更复杂的语言中,通常希望使用不同的AST类,例如:用于数字的NumberLiteral类和用于变量引用的VariableAccess类。
\item
BinaryOp类保存表达式求值所需的数据:
\begin{cpp}
class BinaryOp : public Expr {
public:
enum Operator { Plus, Minus, Mul, Div };
private:
Expr *Left;
Expr *Right;
Operator Op;
public:
BinaryOp(Operator Op, Expr *L, Expr *R)
: Op(Op), Left(L), Right(R) {}
Expr *getLeft() { return Left; }
Expr *getRight() { return Right; }
Operator getOperator() { return Op; }
virtual void accept(ASTVisitor &V) override {
V.visit(*this);
}
};
\end{cpp}
与解析器相反,BinaryOp类不区分乘法运算符和加法运算符。操作符的优先级在树结构中隐式可用。
\item
最后,WithDecl类存储了声明的变量和表达式:
\begin{cpp}
class WithDecl : public AST {
using VarVector =
llvm::SmallVector;
VarVector Vars;
Expr *E;
public:
WithDecl(llvm::SmallVector Vars,
Expr *E)
: Vars(Vars), E(E) {}
VarVector::const_iterator begin()
{ return Vars.begin(); }
VarVector::const_iterator end() { return Vars.end(); }
Expr *getExpr() { return E; }
virtual void accept(ASTVisitor &V) override {
V.visit(*this);
}
};
#endif
\end{cpp}
\end{enumerate}
AST是在解析过程中构造的。语义分析检查树是否符合语言的含义(例如,使用已声明的变量),并可能对树进行扩充,树将用于代码生成。
================================================
FILE: content/part1/chapter2/5.tex
================================================
语义分析器遍历AST并检查语言的各种语义规则,例如:变量必须在使用前声明,或者变量的类型必须在表达式中兼容。若语义分析器发现可以改进的情况,还可以输出警告。对于示例表达式语言,语义分析器必须检查是否声明了每个使用的变量(这是该语言所要求的)。一个可能的扩展(这里没有实现)是,在声明的变量未使用时输出警告。
语义分析器在Sema类中实现,由semantic()方法执行。下面是完整的Sema.h头文件内容:
\begin{cpp}
#ifndef SEMA_H
#define SEMA_H
#include "AST.h"
#include "Lexer.h"
class Sema {
public:
bool semantic(AST *Tree);
};
#endif
\end{cpp}
实现在Sema.cpp文件中。有趣的部分是语义分析,使用访问者实现。其基本思想是,每个声明变量的名称存储在一个集合中。创建集合时,可以检查每个名字的唯一性,再检查给定的名字是否在集合中:
\begin{cpp}
#include "Sema.h"
#include "llvm/ADT/StringSet.h"
namespace {
class DeclCheck : public ASTVisitor {
llvm::StringSet<> Scope;
bool HasError;
enum ErrorType { Twice, Not };
void error(ErrorType ET, llvm::StringRef V) {
llvm::errs() << "Variable " << V << " "
<< (ET == Twice ? "already" : "not")
<< " declared\n";
HasError = true;
}
public:
DeclCheck() : HasError(false) {}
bool hasError() { return HasError; }
\end{cpp}
与Parser类中一样,使用一个标志来指示发生了错误。这些名称存储在一个名为Scope的集合中。在包含变量名的Factor节点上,检查变量名是否在集合中:
\begin{cpp}
virtual void visit(Factor &Node) override {
if (Node.getKind() == Factor::Ident) {
if (Scope.find(Node.getVal()) == Scope.end())
error(Not, Node.getVal());
}
};
\end{cpp}
对于BinaryOp节点,除了两边都存在并访问之外,没什么需要检查:
\begin{cpp}
virtual void visit(BinaryOp &Node) override {
if (Node.getLeft())
Node.getLeft()->accept(*this);
else
HasError = true;
if (Node.getRight())
Node.getRight()->accept(*this);
else
HasError = true;
};
\end{cpp}
在WithDecl节点上,填充该集合并开始遍历表达式:
\begin{cpp}
virtual void visit(WithDecl &Node) override {
for (auto I = Node.begin(), E = Node.end(); I != E;
++I) {
if (!Scope.insert(*I).second)
error(Twice, *I);
}
if (Node.getExpr())
Node.getExpr()->accept(*this);
else
HasError = true;
};
};
}
\end{cpp}
semantic()方法只启动树遍历,并返回错误标志:
\begin{cpp}
bool Sema::semantic(AST *Tree) {
if (!Tree)
return false;
DeclCheck Check;
Tree->accept(Check);
return Check.hasError();
}
\end{cpp}
还可以做得更多。若没有使用声明的变量,也可以打印警告。我们把这个留给读者当做练习来实现。若语义分析没有错误,则可以从AST生成LLVM IR。这将在下一节中完成。
================================================
FILE: content/part1/chapter2/6.tex
================================================
后端的任务是从模块的LLVM IR中创建优化的机器码。IR是后端接口,可以使用C++接口或以文本形式创建,IR也是从AST生成的。
\mySubsubsection{2.6.1.}{LLVM IR的文本表示}
\begin{enumerate}
\item
向用户询问每个变量的值。
\item
计算表达式的值。
\item
输出结果。
\end{enumerate}
为了让用户提供一个变量的值并打印结果,使用了两个库函数:calc\_read()和calc\_write()。对于with a: 3*a表达式,生成的IR如下所示:
\begin{enumerate}
\item
必须声明库函数,就像在C中一样,语法也类似于C。函数名之前的类型是返回类型。用括号括起来的类型名是参数类型。声明可以出现在文件的任何地方:
\begin{shell}
declare i32 @calc_read(ptr)
declare void @calc_write(i32)
\end{shell}
\item
calc\_read()函数将变量名作为参数。下面的构造定义了一个常量保存a,以及使用空字节作为C字符串的结束符:
\begin{shell}
@a.str = private constant [2 x i8] c"a\00"
\end{shell}
\item
其遵循main()函数。省略参数名,因为没有使用。和C语言一样,函数体用大括号括起来:
\begin{shell}
define i32 @main(i32, ptr) {
\end{shell}
\item
每个基本块必须有一个标签,这是函数的第一个基本块,将其命名为entry:
\begin{shell}
entry:
\end{shell}
\item
calc\_read()用来读取a变量的值。嵌套的getelemenptr指令执行索引计算,以计算指向字符串常量第一个元素的指针。函数结果赋值给未命名的\%2变量。
\begin{shell}
%2 = call i32 @calc_read(ptr @a.str)
\end{shell}
\item
接下来,将变量乘以3:
\begin{shell}
%3 = mul nsw i32 3, %2
\end{shell}
\item
结果通过调用calc\_write()函数输出在控制台上:
\begin{shell}
call void @calc_write(i32 %3)
\end{shell}
\item
最后,main()函数返回0,表示执行成功:
\begin{shell}
ret i32 0
}
\end{shell}
\end{enumerate}
LLVM IR中的每个值都是有类型的,i32表示32位整数类型,ptr表示指针。
\begin{myNotic}{Note}
以前版本的LLVM使用类型化指针。例如:在LLVM中,指向字节的指针表示为i8*。从LLVM 16开始,不透明指针是默认的。不透明指针只是一个指向内存的指针,不携带关于类型的信息。LLVM IR中的符号是ptr。
\end{myNotic}
既然现在已经清楚了IR的样子,让我们使用AST生成它吧。
\mySubsubsection{2.6.2.}{使用AST生成IR}
CodeGen.h头文件中提供的接口非常少:
\begin{cpp}
#ifndef CODEGEN_H
#define CODEGEN_H
#include "AST.h"
class CodeGen
{
public:
void compile(AST *Tree);
};
#endif
\end{cpp}
AST包含了这些信息,所以使用一个访问器来遍历AST。CodeGen.cpp文件的实现如下所示:
\begin{enumerate}
\item
所需的包含在文件的顶部:
\begin{cpp}
#include "CodeGen.h"
#include "llvm/ADT/StringMap.h"
#include "llvm/IR/IRBuilder.h"
#include "llvm/IR/LLVMContext.h"
#include "llvm/Support/raw_ostream.h"
\end{cpp}
\item
LLVM库的命名空间用于名称查找:
\begin{cpp}
using namespace llvm;
\end{cpp}
\item
首先,在访问者中声明一些私有成员。在LLVM中,每个编译单元都由Module类表示,访问者有一个指向模块M的指针。为了便于生成IR,使用了Builder (IRBuilder<>类型)。LLVM有一个类层次结构来表示IR中的类型,可以从LLVM上下文中查找i32等基本类型的实例。
这些基本类型经常使用。为了避免重复查找,这里缓存了所需的类型实例:VoidTy、Int32Ty、PtrTy和Int32Zero。成员V是当前计算的值,通过树遍历更新。最后,nameMap将变量名映射到calc\_read()函数返回的值:
\begin{cpp}
namespace {
class ToIRVisitor : public ASTVisitor {
Module *M;
IRBuilder<> Builder;
Type *VoidTy;
Type *Int32Ty;
PointerType *PtrTy;
Constant *Int32Zero;
Value *V;
StringMap nameMap;
\end{cpp}
\item
构造函数初始化所有成员:
\begin{cpp}
public:
ToIRVisitor(Module *M) : M(M), Builder(M->getContext())
{
VoidTy = Type::getVoidTy(M->getContext());
Int32Ty = Type::getInt32Ty(M->getContext());
PtrTy = PointerType::getUnqual(M->getContext());
Int32Zero = ConstantInt::get(Int32Ty, 0, true);
}
\end{cpp}
\item
对于每个函数,必须创建一个FunctionType实例。在C++术语中,这是一个函数原型。函数本身是用函数实例定义的。run()方法首先在LLVM IR中定义main()函数:
\begin{cpp}
void run(AST *Tree) {
FunctionType *MainFty = FunctionType::get(
Int32Ty, {Int32Ty, PtrTy}, false);
Function *MainFn = Function::Create(
MainFty, GlobalValue::ExternalLinkage,
"main", M);
\end{cpp}
\item
然后用entry标签创建BB基本块,并将其添加到IR构建器:
\begin{cpp}
BasicBlock *BB = BasicBlock::Create(M->getContext(),
"entry", MainFn);
Builder.SetInsertPoint(BB);
\end{cpp}
\item
准备工作完成后,就可以开始遍历树了:
\begin{cpp}
Tree->accept(*this);
\end{cpp}
\item
遍历树之后,通过调用calc\_write()函数输出计算值,必须创建函数原型(FunctionType的实例)。唯一的参数是当前值V:
\begin{cpp}
FunctionType *CalcWriteFnTy =
FunctionType::get(VoidTy, {Int32Ty}, false);
Function *CalcWriteFn = Function::Create(
CalcWriteFnTy, GlobalValue::ExternalLinkage,
"calc_write", M);
Builder.CreateCall(CalcWriteFnTy, CalcWriteFn, {V});
\end{cpp}
\item
生成main()函数,并返回0结束:
\begin{cpp}
Builder.CreateRet(Int32Zero);
}
\end{cpp}
\item
WithDecl节点保存声明的变量的名称,为calc\_read()函数创建一个函数原型:
\begin{cpp}
virtual void visit(WithDecl &Node) override {
FunctionType *ReadFty =
FunctionType::get(Int32Ty, {PtrTy}, false);
Function *ReadFn = Function::Create(
ReadFty, GlobalValue::ExternalLinkage,
"calc_read", M);
\end{cpp}
\item
该方法循环遍历变量名:
\begin{cpp}
for (auto I = Node.begin(), E = Node.end(); I != E;
++I) {
\end{cpp}
\item
对于每个变量,创建一个带有变量名的字符串:
\begin{cpp}
StringRef Var = *I;
Constant *StrText = ConstantDataArray::getString(
M->getContext(), Var);
GlobalVariable *Str = new GlobalVariable(
*M, StrText->getType(),
/*isConstant=*/true,
GlobalValue::PrivateLinkage,
StrText, Twine(Var).concat(".str"));
\end{cpp}
\item
创建调用calc\_read()函数的IR代码,使用上一步中创建的字符串作为参数传递:
\begin{cpp}
CallInst *Call =
Builder.CreateCall(ReadFty, ReadFn, {Str});
\end{cpp}
\item
返回值存储在mapNames映射中供以后使用:
\begin{cpp}
nameMap[Var] = Call;
}
\end{cpp}
\item
树的遍历将继续使用下面的表达式:
\begin{cpp}
Node.getExpr()->accept(*this);
};
\end{cpp}
\item
Factor节点可以是变量名,也可以是数字。对于变量名,将在mapNames映射中查找该值。对于数字,将值转换为整数并转换为常数值:
\begin{cpp}
virtual void visit(Factor &Node) override {
if (Node.getKind() == Factor::Ident) {
V = nameMap[Node.getVal()];
} else {
int intval;
Node.getVal().getAsInteger(10, intval);
V = ConstantInt::get(Int32Ty, intval, true);
}
};
\end{cpp}
\item
对于BinaryOp节点,必须使用正确的计算操作:
\begin{cpp}
virtual void visit(BinaryOp &Node) override {
Node.getLeft()->accept(*this);
Value *Left = V;
Node.getRight()->accept(*this);
Value *Right = V;
switch (Node.getOperator()) {
case BinaryOp::Plus:
V = Builder.CreateNSWAdd(Left, Right); break;
case BinaryOp::Minus:
V = Builder.CreateNSWSub(Left, Right); break;
case BinaryOp::Mul:
V = Builder.CreateNSWMul(Left, Right); break;
case BinaryOp::Div:
V = Builder.CreateSDiv(Left, Right); break;
}
};
};
}
\end{cpp}
\item
至此,访问者类就完成了。compile()方法创建全局上下文和模块,运行树遍历,并将生成的IR转储到控制台:
\begin{cpp}
void CodeGen::compile(AST *Tree) {
LLVMContext Ctx;
Module *M = new Module("calc.expr", Ctx);
ToIRVisitor ToIR(M);
ToIR.run(Tree);
M->print(outs(), nullptr);
}
\end{cpp}
\end{enumerate}
现在,已经实现了编译器的前端,从读取源代码到生成IR。所有这些组件必须在用户输入时一起工作,这是编译器驱动程序的任务,还需要实现运行时所需的函数。这两个都是下一节的主题。
\mySubsubsection{2.6.3.}{缺失的部分——驱动程序和运行时库}
前几节中的所有阶段都由Calc.cpp驱动程序粘合在一起:声明输入表达式的参数,初始化LLVM,并调用前几节中的所有代码:
\begin{enumerate}
\item
首先,包含所需的头文件:
\begin{cpp}
#include "CodeGen.h"
#include "Parser.h"
#include "Sema.h"
#include "llvm/Support/CommandLine.h"
#include "llvm/Support/InitLLVM.h"
#include "llvm/Support/raw_ostream.h"
\end{cpp}
\item
LLVM自带声明命令行选项的系统,只需要为需要的每个选项声明一个静态变量。在此过程中,该选项会注册到一个全局命令行解析器中。这种方法的优点是,每个组件都可以在需要时添加命令行选项。我们为输入表达式声明一个选项:
\begin{cpp}
static llvm::cl::opt
Input(llvm::cl::Positional,
llvm::cl::desc(" "),
llvm::cl::init(""));
\end{cpp}
\item
main()函数中,首先初始化LLVM库。需要ParseCommandLineOptions()来处理命令行上给出的选项。这还处理帮助信息的输出,若发生错误,使用此方法将结束应用程序的运行:
\begin{cpp}
int main(int argc, const char **argv) {
llvm::InitLLVM X(argc, argv);
llvm::cl::ParseCommandLineOptions(
argc, argv, "calc - the expression compiler\n");
\end{cpp}
\item
接下来,调用词法分析器和解析器。在语法分析之后,检查是否发生了任何错误。若是这种情况,则退出编译器,并返回一个表示失败的错误码:
\begin{cpp}
Lexer Lex(Input);
Parser Parser(Lex);
AST *Tree = Parser.parse();
if (!Tree || Parser.hasError()) {
llvm::errs() << "Syntax errors occured\n";
return 1;
}
\end{cpp}
\item
若有语义错误,也会这样做:
\begin{cpp}
Sema Semantic;
if (Semantic.semantic(Tree)) {
llvm::errs() << "Semantic errors occured\n";
return 1;
}
\end{cpp}
\item
作为驱动程序的最后一步,将调用代码生成器:
\begin{cpp}
CodeGen CodeGenerator;
CodeGenerator.compile(Tree);
return 0;
}
\end{cpp}
\end{enumerate}
现在,已经成功地为用户输入创建了一些IR代码。将目标代码生成委托给LLVM llc静态编译器,这样就完成了编译器的实现。我们将所有组件链接在一起以创建calc应用程序。
运行时库由一个文件rtcalc.c组成,实现了calc\_read()和calc\_write()函数,用C语言实现:
\begin{cpp}
#include
#include
void calc_write(int v)
{
printf("The result is: %d\n", v);
}
\end{cpp}
calc\_write()只将结果值写入终端:
\begin{cpp}
int calc_read(char *s)
{
char buf[64];
int val;
printf("Enter a value for %s: ", s);
fgets(buf, sizeof(buf), stdin);
if (EOF == sscanf(buf, "%d", &val))
{
printf("Value %s is invalid\n", buf);
exit(1);
}
return val;
}
\end{cpp}
calc\_read()从终端读取一个整数。不能阻止用户输入字母或其他字符,所以必须仔细检查输入。若输入不是数字,则退出应用程序。更复杂的方法是让用户意识到问题,并再次要求提供一个数字。
下一步是构建并试用我们的编译器calc,这是一个从表达式创建IR的应用程序。
\mySubsubsection{2.6.3.1}{构建和测试calc应用程序}
为了构建calc,首先需要在原始src目录之外创建一个新的CMakeLists.txt文件,其中包含所有源文件实现:
\begin{enumerate}
\item
首先,将最低所需的CMake版本设置为LLVM所需的值,并将项目名称命名为calc:
\begin{cmake}
cmake_minimum_required (VERSION 3.20.0)
project ("calc")
\end{cmake}
\item
接下来,需要加载LLVM包,将LLVM提供的CMake模块目录添加到搜索路径中:
\begin{cmake}
find_package(LLVM REQUIRED CONFIG)
message("Found LLVM ${LLVM_PACKAGE_VERSION}, build type ${LLVM_BUILD_TYPE}")
list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR})
\end{cmake}
\item
还需要从LLVM中添加定义和包含路径,使用的LLVM组件通过函数调用映射到库名:
\begin{cmake}
separate_arguments(LLVM_DEFINITIONS_LIST NATIVE_COMMAND ${LLVM_DEFINITIONS})
add_definitions(${LLVM_DEFINITIONS_LIST})
include_directories(SYSTEM ${LLVM_INCLUDE_DIRS})
llvm_map_components_to_libnames(llvm_libs Core)
\end{cmake}
\item
最后,还需要在构建中包含src子目录,这是本章中所有C++的实现:
\begin{cmake}
add_subdirectory ("src")
\end{cmake}
\end{enumerate}
还需要在src子目录中添加一个新的CMakeLists.txt文件。src目录中的CMake描述如下所示,只需定义可执行文件的名称calc,然后列出要编译的源文件和要链接的库:
\begin{cmake}
add_executable (calc
Calc.cpp CodeGen.cpp Lexer.cpp Parser.cpp Sema.cpp)
target_link_libraries(calc PRIVATE ${llvm_libs})
\end{cmake}
最后,可以开始构建calc应用程序。在src目录之外,创建一个新的构建目录并对其进行修改,就可以运行CMake配置和构建调用:
\begin{shell}
$ cmake -GNinja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++
-DLLVM_DIR= ../
$ ninja
\end{shell}
我们现在有了一个新构建的、功能性的calc应用程序,可以生成LLVM IR代码。这可以进一步与llc(LLVM静态后端编译器)一起使用,将IR代码编译成目标文件。
然后,可以使用喜欢的C编译器来链接小型运行时库。在Unix-x86上,可以键入以下命令:
\begin{shell}
$ calc "with a: a*3" | llc –filetype=obj -relocation-model=pic –o=expr.o
$ clang –o expr expr.o rtcalc.c
$ expr
Enter a value for a: 4
The result is: 12
\end{shell}
在其他Unix平台(如AArch64或PowerPC)上,需要删除-relocationmodel=pic选项。
在Windows上,需要使用cl编译器:
\begin{shell}
$ calc "with a: a*3" | llc –filetype=obj –o=expr.obj
$ cl expr.obj rtcalc.c
$ expr
Enter a value for a: 4
The result is: 12
\end{shell}
现在,已经创建了第一个基于llvm的编译器!请花点时间练习不同的表达方式。特别要检查乘法运算符是否在加法运算符之前求值,以及使用括号是否改变了求值顺序(例如四则运算)。
================================================
FILE: content/part1/chapter2/7.tex
================================================
在本章中,了解了编译器的典型组件,使用算术表达式语言介绍编程语言的语法。了解了如何为这种语言开发前端的典型组件:词法分析器、解析器、语义分析器和代码生成器。代码生成器只生成LLVM IR,并使用LLVM llc静态编译器从中创建目标文件。现在我们已经开发了第一个基于llvm的编译器!
下一章中,我们将深化这方面的知识,为编程语言构建前端。
================================================
FILE: content/part1/part1.tex
================================================
将了解如何编译LLVM,并根据需要定制构建;了解LLVM项目是如何组织的,将使用LLVM创建一个项目。最后,将探索编译器的总体结构,并创建一个小型编译器。
\begin{itemize}
\item
第1章,安装LLVM
\item
第2章,编译器的结构
\end{itemize}
================================================
FILE: content/part2/chapter3/0.tex
================================================
正如在前一章了解到的,编译器通常分为两部分——前端和后端。本章中,将实现一种编程语言的前端——主要处理源语言的部分。我们将学习现实世界的编译器使用的技术,并将其应用到我们的编程语言中。
旅程将从定义编程语言的语法开始,并以抽象语法树(AST)结束,其将成为代码生成的基础。对于想要实现编译器的每种编程语言,都可以使用这种方法。
本章中,将了解以下内容:
\begin{itemize}
\item
定义一个真正的编程语言,将了解tinylang语言,它是实际的编程语言的一个子集,将为它实现一个编译器前端
\item
组织编译器项目的目录结构
\item
了解如何为编译器处理多个输入文件
\item
处理用户信息并以令人愉快的方式通知他们问题的技巧
\item
使用模块化部件构建词法分析器
\item
根据从语法导出的规则构造递归下降解析器来执行语法分析
\item
执行语义分析,通过创建AST并分析其特点
\end{itemize}
有了本章的技能,读者们将能够为任何编程语言构建一个编译器前端。
================================================
FILE: content/part2/chapter3/1.tex
================================================
与前一章中简单的calc语言相比,真正的编程面临更多挑战。为了了解其中的细节,我们将在本章和后续章节中使用Modula-2的一小部分。Modula-2设计良好,可选支持泛型和面向对象编程(OOP)。但在本书中,我们并不打算创建一个完整的Modula-2编译器,所以我们将这个子集称为tinylang。
让我们从一个例子开始,看看tinylang编写的程序是什么样的。下面的函数使用欧几里得算法计算最大公约数:
\begin{shell}
MODULE Gcd;
PROCEDURE GCD(a, b: INTEGER) : INTEGER;
VAR t: INTEGER;
BEGIN
IF b = 0 THEN
RETURN a;
END;
WHILE b # 0 DO
t := a MOD b;
a := b;
b := t;
END;
RETURN a;
END GCD;
END Gcd.
\end{shell}
现在我们对使用这种语言编写的程序有了大致的了解,下面来快速了解一下tinylang子集的语法。在接下来的几节中,我们将使用这种语法来派生词法分析器和解析器:
\begin{shell}
compilationUnit
: "MODULE" identifier ";" ( import )* block identifier "." ;
import : ( "FROM" identifier )? "IMPORT" identList ";" ;
block
: ( declaration )* ( "BEGIN" statementSequence )? "END" ;
\end{shell}
Modula-2中的编译单元以MODULE关键字开头,后面跟着模块的名称。模块的内容可以包含一个导入模块的列表、声明,以及一个包含在初始化时运行语句的块:
\begin{shell}
declaration
: "CONST" ( constantDeclaration ";" )*
| "VAR" ( variableDeclaration ";" )*
| procedureDeclaration ";" ;
\end{shell}
声明引入了常量、变量和过程。常量的声明以CONST关键字作为前缀。类似地,变量声明以VAR关键字开头。常量的声明非常简单:
\begin{shell}
constantDeclaration : identifier "=" expression ;
\end{shell}
标识符的名称不变。该值是从一个表达式派生的,该表达式必须在编译时可计算。变量的声明会有点复杂:
\begin{shell}
variableDeclaration : identList ":" qualident ;
qualident : identifier ( "." identifier )* ;
identList : identifier ( "," identifier)* ;
\end{shell}
为了能够一次声明多个变量,需要使用标识符列表。类型名可能来自另一个模块,本例中以模块名作为前缀,这称为限定标识符(qualified identifier):
\begin{shell}
procedureDeclaration
: "PROCEDURE" identifier ( formalParameters )? ";"
block identifier ;
formalParameters
: "(" ( formalParameterList )? ")" ( ":" qualident )? ;
formalParameterList
: formalParameter (";" formalParameter )* ;
formalParameter : ( "VAR" )? identList ":" qualident ;
\end{shell}
前面的代码展示了如何常量、变量声明和过程。过程可以有参数和返回类型,普通参数以值的形式传递,VAR参数以引用的形式传递。块规则中缺少的另一部分是statementSequence,是一个单条语句的列表:
\begin{shell}
statementSequence
: statement ( ";" statement )* ;
\end{shell}
若语句后面跟着另一个语句,则用分号分隔,所以只支持Modula-2语句的子集:
\begin{shell}
statement
: qualident ( ":=" expression | ( "(" ( expList )? ")" )? )
| ifStatement | whileStatement | "RETURN" ( expression )? ;
\end{shell}
该规则的第一部分描述了一个赋值或过程调用。限定标识符后面跟:=是赋值操作。若后面跟着(,则是一个过程调用。其他语句是常见的控制语句:
\begin{shell}
ifStatement
: "IF" expression "THEN" statementSequence
( "ELSE" statementSequence )? "END" ;
\end{shell}
IF语句也有简化的语法,只能有一个ELSE块。使用该语句,可以有条件地保护语句:
\begin{shell}
whileStatement
: "WHILE" expression "DO" statementSequence "END" ;
\end{shell}
WHILE语句描述了一个由条件保护的循环。与IF语句一起,能够用tinylang编写简单的算法。最后,还缺少了表达式的定义:
\begin{shell}
expList
: expression ( "," expression )* ;
expression
: simpleExpression ( relation simpleExpression )? ;
relation
: "=" | "#" | "<" | "<=" | ">" | ">=" ;
simpleExpression
: ( "+" | "-" )? term ( addOperator term )* ;
addOperator
: "+" | "-" | "OR" ;
term
: factor ( mulOperator factor )* ;
mulOperator
: "*" | "/" | "DIV" | "MOD" | "AND" ;
factor
: integer_literal | "(" expression ")" | "NOT" factor
| qualident ( "(" ( expList )? ")" )? ;
\end{shell}
表达式语法与前一章中的calc非常相似,只支持INTEGER和BOOLEAN数据类型。
此外,还使用了标识符和integer\_literal标记。标识符是以字母或下划线开头,后面跟着字母、数字和下划线的名称。整数字面值是一个十进制数字序列或后跟字母H的十六进制数字序列。
这些规则已经有很多了,我们只介绍Modula-2的一部分!不过,也可以在这个子集中编写小型应用程序。来实现一个tinylang编译器吧!
================================================
FILE: content/part2/chapter3/2.tex
================================================
tinylang的目录结构遵循我们在第1章安装LLVM中列出的方式。每个组件的源代码位于lib目录的子目录中,头文件位于include/tinylang的子目录中。子目录以组件命名。在第1章安装LLVM中,我们只创建了基本组件。
前一章中,了解了需要实现词法分析器、解析器、AST和语义分析器。每个都是自己的组件,分别称为Lexer、Parser、AST和Sema。本章将要使用的目录布局是这样的:
\myGraphic{0.3}{content/part2/chapter3/images/1.png}{图3.1 - tinylang项目的目录布局}
组件具有明确定义的依赖关系。Lexer只依赖于Basic,Parser依赖于Basic、Lexer、AST和Sema,Sema只依赖于Basic和AST。定义良好的依赖关系有助于我们重用组件。
来仔细看看实现吧!
================================================
FILE: content/part2/chapter3/3.tex
================================================
真正的编译器必须处理许多文件,开发人员用主编译单元的名称来调用编译器。这个编译单元可以引用其他文件——例如,通过C语言中的\#include指令或Python或Modula-2中的import语句。导入的模块可以导入其他模块,以此类推。所有这些文件必须加载到内存中,并通过编译器的分析阶段运行。开发过程中,开发人员可能会犯语法或语义错误。当检测到错误时,应该打印一条错误消息,包括源代码行和一个标记,这个基本组成部分很重要。
幸运的是,LLVM提供了一个解决方案:LLVM::SourceMgr类。通过调用AddNewSourceBuffer()方法,一个新的源文件添加到SourceMgr中。或者,通过调用AddIncludeFile()方法来加载文件。这两种方法都返回一个标识缓冲区的ID。可以使用这个ID来取得一个指向相关文件的内存缓冲区的指针。要在文件中定义位置,可以使用llvm::SMLoc类。该类封装了一个指向缓冲区的指针,各种PrintMessage()方法允许向用户发送错误和其他信息消息。
================================================
FILE: content/part2/chapter3/4.tex
================================================
我们目前还缺少消息的集中定义。在大型软件(如编译器)中,你应该不会希望消息字符串散布各处。若有修改消息或将其翻译成另一种语言的请求,最好将它们放在一个中心位置!一种简单的方法是,每个消息都有一个ID(枚举成员)、一个严重级别(如Error或Warning)和一个包含消息的字符串。在代码中,只需要引用消息ID即可。
严重性级别和消息字符串仅在消息输出时使用,必须一致地管理这三项(ID、安全级别和消息)。LLVM库使用预处理器来解决这个问题,数据存储在一个后缀为.def的文件中,并包装在一个宏名称中。该文件通常被包含多次,并对该宏有不同的定义。该定义位于include/tinylang/Basic/Diagnostic.def文件路径中:
\begin{cpp}
#ifndef DIAG
#define DIAG(ID, Level, Msg)
#endif
DIAG(err_sym_declared, Error, "symbol {0} already declared")
#undef DIAG
\end{cpp}
第一个宏参数ID是枚举标签,第二个参数Level是严重性,第三个参数Msg是消息文本。有了这个定义,就可以定义一个diagnosticengine类来发出错误消息。接口在include/tinylang/ Basic/Diagnostic.h文件中:
\begin{cpp}
#ifndef TINYLANG_BASIC_DIAGNOSTIC_H
#define TINYLANG_BASIC_DIAGNOSTIC_H
#include "tinylang/Basic/LLVM.h"
#include "llvm/ADT/StringRef.h"
#include "llvm/Support/FormatVariadic.h"
#include "llvm/Support/SMLoc.h"
#include "llvm/Support/SourceMgr.h"
#include "llvm/Support/raw_ostream.h"
#include
namespace tinylang {
\end{cpp}
在包含必要的头文件之后,可以使用Diagnostic.def定义枚举。为了不污染全局命名空间,我们使用了名为diag的嵌套命名空间:
\begin{cpp}
namespace diag {
enum {
#define DIAG(ID, Level, Msg) ID,
#include "tinylang/Basic/Diagnostic.def"
};
} // namespace diag
\end{cpp}
DiagnosticsEngine类使用SourceMgr实例通过report()方法发送消息。消息可以有参数。为了实现这个功能,我们使用了LLVM提供的可变格式支持。消息文本和严重性级别在静态方法的帮助下检索,还会统计发出的错误消息的数量:
\begin{cpp}
class DiagnosticsEngine {
static const char *getDiagnosticText(unsigned DiagID);
static SourceMgr::DiagKind
getDiagnosticKind(unsigned DiagID);
\end{cpp}
消息字符串由getDiagnosticText()返回,而级别由getDiagnosticKind()返回。这两个方法稍后会在.cpp文件中进行实现:
\begin{cpp}
SourceMgr &SrcMgr;
unsigned NumErrors;
public:
DiagnosticsEngine(SourceMgr &SrcMgr)
: SrcMgr(SrcMgr), NumErrors(0) {}
unsigned nunErrors() { return NumErrors; }
\end{cpp}
由于消息可以具有可变数量的参数,所以C++中的解决方案是使用可变模板。当然,LLVM提供的formatv()函数也会使用这种方法。要获得格式化的消息,只需要转发模板参数即可:
\begin{cpp}
template
void report(SMLoc Loc, unsigned DiagID,
Args &&... Arguments) {
std::string Msg =
llvm::formatv(getDiagnosticText(DiagID),
std::forward(Arguments)...)
.str();
SourceMgr::DiagKind Kind = getDiagnosticKind(DiagID);
SrcMgr.PrintMessage(Loc, Kind, Msg);
NumErrors += (Kind == SourceMgr::DK_Error);
}
};
} // namespace tinylang
#endif
\end{cpp}
至此,我们已经实现了类的大部分内容,只缺失了getDiagnosticText()和getDiagnosticKind()。它们在lib/Basic/Diagnostic.cpp文件中定义,并使用了Diagnostic.def文件:
\begin{cpp}
#include "tinylang/Basic/Diagnostic.h"
using namespace tinylang;
namespace {
const char *DiagnosticText[] = {
#define DIAG(ID, Level, Msg) Msg,
#include "tinylang/Basic/Diagnostic.def"
};
\end{cpp}
与头文件中一样,定义DIAG宏来检索所需的部件。这里,定义了一个保存文本消息的数组。因此,DIAG宏只返回Msg部分。我们对于level中使用了相同的方法:
\begin{cpp}
SourceMgr::DiagKind DiagnosticKind[] = {
#define DIAG(ID, Level, Msg) SourceMgr::DK_##Level,
include "tinylang/Basic/Diagnostic.def"
};
} // namespace
\end{cpp}
毫不奇怪,这两个函数都只是对数组进行索引,并返回所需的数据:
\begin{cpp}
const char *
DiagnosticsEngine::getDiagnosticText(unsigned DiagID) {
return DiagnosticText[DiagID];
}
SourceMgr::DiagKind
DiagnosticsEngine::getDiagnosticKind(unsigned DiagID) {
return DiagnosticKind[DiagID];
}
\end{cpp}
SourceMgr和DiagnosticsEngine类的组合为其他组件提供了良好的基础。我们将首先在lexer中使用它们!
================================================
FILE: content/part2/chapter3/5.tex
================================================
正如从前一章所知,我们需要一个Token类和一个Lexer类,所以需要TokenKind枚举来为每个Token类提供唯一的编号。拥有一个集所有功能于一体的头文件和实现文件并不能扩展,TokenKind可以普遍使用,并放置在Basic组件中。Token和Lexer类属于Lexer组件,但位于不同的头文件和实现文件中。
有三种不同类型的标记:关键字、标点符号和标记,它们表示许多值的集合。例如CONST关键字,;分隔符和ident标记,它们分别表示源代码中的标识符。每个标记都需要枚举的成员名。关键字和标点符号具有可以用于消息的自然显示名称。
与许多编程语言一样,关键字是标识符的子集。要将标记分类为关键字,我们需要一个关键字过滤器,检查找到的标识符是否确实是关键字。这与C或C++中的行为相同,其中关键字也是标识符的子集。编程语言不断发展,可能会引入新的关键字。例如,最初的K\&R C语言没有用enum关键字定义枚举,所以应该出现一个标志来指示关键字的语言级别。
我们收集了几条信息,都属于TokenKind枚举的一个成员:枚举成员的标签、标点符号的拼写和关键字的标志。对于诊断消息,我们将信息集中存储在一个名为include/tinylang/Basic/tokenkind.def的.def文件中,该文件如下所示。需要注意的一点是,其关键字的前缀是kw\_:
\begin{cpp}
#ifndef TOK
#define TOK(ID)
#endif
#ifndef PUNCTUATOR
#define PUNCTUATOR(ID, SP) TOK(ID)
#endif
#ifndef KEYWORD
#define KEYWORD(ID, FLAG) TOK(kw_ ## ID)
#endif
TOK(unknown)
TOK(eof)
TOK(identifier)
TOK(integer_literal)
PUNCTUATOR(plus, "+")
PUNCTUATOR(minus, "-")
// …
KEYWORD(BEGIN , KEYALL)
KEYWORD(CONST , KEYALL)
// …
#undef KEYWORD
#undef PUNCTUATOR
#undef TOK
\end{cpp}
有了这些集中的定义,就可以很容易地在include/tinylang/Basic/TokenKind.h文件中创建TokenKind枚举,枚举也会有自己的命名空间,例如:
\begin{cpp}
#ifndef TINYLANG_BASIC_TOKENKINDS_H
#define TINYLANG_BASIC_TOKENKINDS_H
namespace tinylang {
namespace tok {
enum TokenKind : unsigned short {
#define TOK(ID) ID,
#include "TokenKinds.def"
NUM_TOKENS
};
\end{cpp}
现在应该熟悉填充数组的模式了,TOK宏定义为只返回ID。另外,还将NUM\_TOKENS定义为枚举的最后一个成员,用于表示已定义的标记的数量:
\begin{cpp}
const char *getTokenName(TokenKind Kind);
const char *getPunctuatorSpelling(TokenKind Kind);
const char *getKeywordSpelling(TokenKind Kind);
}
}
#endif
\end{cpp}
实现文件lib/Basic/tokenkind.cpp也使用.def文件来检索名称:
\begin{cpp}
#include "tinylang/Basic/TokenKinds.h"
#include "llvm/Support/ErrorHandling.h"
using namespace tinylang;
static const char * const TokNames[] = {
#define TOK(ID) #ID,
#define KEYWORD(ID, FLAG) #ID,
#include "tinylang/Basic/TokenKinds.def"
nullptr
};
\end{cpp}
标记的文本名称源自其枚举标签ID。有两个特点:
\begin{itemize}
\item
首先,一些操作符共享相同的前缀,例如<和<=。若当前查看的字符是<,则必须先检查下一个字符,再决定找到哪个标记。请记住,输入需要以空字节结束,若当前字符有效,则可以使用下一个字符:
\begin{cpp}
case '<':
if (*(CurPtr + 1) == '=')
formTokenWithChars(token, CurPtr + 2,
tok::lessequal);
else
formTokenWithChars(token, CurPtr + 1, tok::less);
break;
\end{cpp}
\item
另一个是,现在有更多的关键字。我们该如何处理?一个简单而快速的解决方案是用关键字填充散列表,这些关键字都存储在tokenkind.def文件中,可以在Lexer类的实例化期间完成。使用这种方法,可以使用附加标志过滤关键字,还可以支持不同级别的语言。这里,还不需要这种灵活性。头文件中,关键字过滤器定义如下,使用llvm::StringMap实例作为哈希表:
\begin{cpp}
class KeywordFilter {
llvm::StringMap HashTable;
void addKeyword(StringRef Keyword,
tok::TokenKind TokenCode);
public:
void addKeywords();
\end{cpp}
getKeyword()返回给定字符串的标记类型,若字符串不代表关键字则返回默认值:
\begin{cpp}
tok::TokenKind getKeyword(
StringRef Name,
tok::TokenKind DefaultTokenCode = tok::unknown) {
auto Result = HashTable.find(Name);
if (Result != HashTable.end())
return Result->second;
return DefaultTokenCode;
}
};
\end{cpp}
实现文件中,填充关键字表:
\begin{cpp}
void KeywordFilter::addKeyword(StringRef Keyword,
tok::TokenKind TokenCode) {
HashTable.insert(std::make_pair(Keyword, TokenCode));
}
void KeywordFilter::addKeywords() {
#define KEYWORD(NAME, FLAGS) \
addKeyword(StringRef(#NAME), tok::kw_##NAME);
#include "tinylang/Basic/TokenKinds.def"
}
\end{cpp}
\end{itemize}
使用刚学到的技术,编写一个高效的词法分析器类并不困难。由于编译速度很重要,许多编译器使用手写词法分析器,其中一个例子就是clang。
================================================
FILE: content/part2/chapter3/6.tex
================================================
如前一章所示,解析器是从语法派生出来的。回顾一下所有的构造规则,对于语法的每个规则,创建一个以规则左侧的非终结符命名的方法来解析规则的右侧。根据右边的定义,可以有以下操作:
\begin{itemize}
\item
对于每个非终结符,调用相应的方法
\item
每个标记都要进行处理
\item
对于备选项和可选的或重复的组,将检查超前标记(下一个未使用的令牌),以决定在何处继续
\end{itemize}
让我们将这些结构规则应用于以下语法规则:
\begin{shell}
ifStatement
: "IF" expression "THEN" statementSequence
( "ELSE" statementSequence )? "END" ;
\end{shell}
可以很容易地将其转换为下面的C++代码:
\begin{cpp}
void Parser::parseIfStatement() {
consume(tok::kw_IF);
parseExpression();
consume(tok::kw_THEN);
parseStatementSequence();
if (Tok.is(tok::kw_ELSE)) {
advance();
parseStatementSequence();
}
consume(tok::kw_END);
}
\end{cpp}
tinylang的整个语法可以通过这种方式转换成C++。因为在互联网上找到的大多数语法都不适合这种结构,所以必须小心避免一些陷阱。
\begin{myTip}{语法和解析器}
有两种不同类型的解析器:自顶向下解析器和自底向上解析器。其名称来源于解析过程中处理规则的顺序,解析器的输入是词法分析器生成的标记序列。
自顶向下解析器展开规则中最左边的符号,直到匹配到一个标记。若使用了所有标记并展开了所有符号,则解析成功。这正是tinylang解析器的工作方式。
自底而上的解析器则相反:查看标记序列,并尝试用规则左侧的语法符号替换这些标记。例如,接下来的标记是if、3、+和4,自下而上的解析器将用expression符号替换3 + 4标记,从而产生IF expression序列。当检测到一系列属于完整IF语句的标记时,这个标记和符号序列将使用ifStatement符号替换。
若使用了所有标记,并且只剩下开始符号,则解析成功。虽然自顶向下的解析器可以很容易地手工构造,但自底向上的解析器却不是这样。
描述这两种解析器的另一种方法是首先展开符号。两者都从左到右读取输入,但是自顶向下的解析器首先展开最左边的符号,而自底向上的解析器首先展开最右边的符号。因此,自顶向下的解析器也称为LL解析器,而自底向上的解析器称为LR解析器。
语法必须具有某些属性,才可以从中派生出LL或LR解析器。语法是相应地命名的:需要一个LL语法来构造LL解析器。
可以在大学教科书中找到有关编译器构造的更多细节,例如Wilhelm, Seidl和Hack合著的《Compiler Design. Syntactic and Semantic Analysis》,Springer 2013,以及Grune和Jacobs合著的《Parsing Techniques, A practical guide》,Springer 2008。
\end{myTip}
需要查找的一个问题是左递归规则。若规则的右侧以与左侧相同的终端开始,则称为左递归规则。典型的例子可以在表达式语法中看到:
\begin{shell}
expression : expression "+" term ;
\end{shell}
若从语法上看还不清楚,翻译成的C++会很明显地有无限递归:
\begin{cpp}
void Parser::parseExpression() {
parseExpression();
consume(tok::plus);
parseTerm();
}
\end{cpp}
左递归也可以间接发生,并且涉及更多规则,这更难以发现。这就是为什么会存在一种可以检测和消除左递归的算法。
\begin{myNotic}{Note}
左递归规则只是LL解析器的问题,比如tinylang的递归下降解析器。原因是这些解析器首先展开最左边的符号。相反,若使用解析器生成器生成LR解析器,首先展开最右边的符号,则应该避免右递归规则。
\end{myNotic}
每一步中,解析器仅仅通过提前查看后续的标记来决定如何继续解析。若不能确定地做出这个决定,则代表语法有冲突。为了说明这一点,看一下C\#中的using语句。与C++一样,using语句可用于使符号在命名空间中可见,例如using Math;。也可以用using M = Math;为导入的符号定义别名。在语法中,这可以表示为:
\begin{shell}
usingStmt : "using" (ident "=")? ident ";"
\end{shell}
这里有一个问题:在解析器使用using关键字之后,预检标记是ident类型的,但这些信息不足以让我们决定是否跳过或解析中间的可选组。若可选组开始使用的标记集与可选组后面的标记集重叠,就会出现这种情况。
让我们用另一种不使用可选组的方法来重写这个规则:
\begin{shell}
usingStmt : "using" ( ident "=" ident | ident ) ";" ;
\end{shell}
现在,有一个不同的冲突:两种选择都以相同的标记开始。解析器只查看预检标记,无法确定哪个选项是正确的。
这些冲突很常见,知道如何处理它们就好。一种方法是以消除冲突的方式重写语法。在前面的示例中,两种备选方案都以相同的标记开头,我们可以把此标记提取出来,得到以下规则:
\begin{shell}
usingStmt : "using" ident ("=" ident)? ";" ;
\end{shell}
这种表述没有冲突,但也应该注意到它的表达不够直观。在另外两个公式中,很明显哪个标识是别名,哪个标识是命名空间名。在无冲突规则中,最左边的标识的角色可能会改变。一开始,它是命名空间的名称,但若后面跟着等号,它就变成了别名。
第二种方法是添加一个谓词来区分这两种情况。该谓词通常称为解析器(resolver),可以使用上下文信息进行决策(例如在符号表中查找名称),也可以查看多个标记。假设词法分析器有一个名为Token \&peek(int n)的方法,该方法在当前的超前标记之后返回第n个标记。这里,等号的存在性可以作为判断过程中的额外谓词:
\begin{cpp}
if (Tok.is(tok::ident) && Lex.peek(0).is(tok::equal)) {
advance();
consume(tok::equal);
}
consume(tok::ident);
\end{cpp}
第三种方法是使用回溯。为此,需要保存当前状态,并且尝试解析冲突的组。若这没有成功,需要返回到保存的状态并尝试另一个路径。这里搜索的是要应用的正确规则,效率不如其他方法。因此,只能在万不得已的情况下才会使用这种方法。
现在,让我们加入错误恢复。上一章中,介绍了一种称为恐慌模式(panic mode)的错误恢复技术。基本思想是跳过标记,直到找到一个适合继续解析的标记。例如,在tinylang中,语句后跟分号(:)。
若If语句中存在语法问题,则跳过所有标记,直到找到分号为止,再继续执行下一个语句。与其使用临时定义的标记集,不如使用系统的方法。
对于每个非终结符,计算可以跟在该非终结符后面的标记集合,称为跟随集(FOLLOW Set)。对于语句非终结符,后面可以是;、ELSE和END标记,所以必须在parseStatement()的错误恢复部分使用这个集。这个方法假定语法错误可以就地处理,但大多数情况下这是不可能的。解析器可能会跳过很多标记,直到到达输入的结尾。在这种情况下,无法进行就地恢复。
为了防止无意义的错误消息,需要通知调用方法错误恢复仍未完成。这可以通过bool来实现。若返回true,则表示错误恢复尚未完成,而false则表示解析(包括可能的错误恢复)成功。
有许多方法可以扩展此错误恢复方案。使用主动调用者的FOLLOW集合是一种常见的方法。作为一个简单的例子,假设parseStatement()由parseStatementSequence()调用,而parseBlock()本身又由parseModule()调用。
这里,每个相应的非终结符都有一个FOLLOW集合。若解析器在parseStatement()中检测到语法错误,则跳过标记,直到当前标记出现在至少一个主动调用者的FOLLOW集合中。若标记位于当前语句的FOLLOW集合中,则就地恢复错误,并向调用者返回false;否则,返回true,表示必须继续进行错误恢复。这个扩展的一种可能的实现策略是将std::bitset或std::tuple传递给调用方,以表示当前FOLLOW集合的并集。
最后一个问题仍然没有解决:我们如何调用错误恢复?上一章中,goto用于跳转到错误恢复块。这是可行的,但不够好。根据前面讨论的内容,我们可以在单独的方法中跳过标记。Clang有一个用于此目的的方法skipUntil(),tinylang也可以用。
因为下一步是向解析器添加语义操作,所以若有必要的话,最好有一个中央位置放置清理代码。嵌套函数将是理想的选择。C++没有嵌套函数,而Lambda函数可以达到类似的目的。我们最初看到的parseIfStatement()方法在添加完整的错误恢复代码时,类似如下实现:
\begin{cpp}
bool Parser::parseIfStatement() {
auto _errorhandler = [this] {
return skipUntil(tok::semi, tok::kw_ELSE, tok::kw_END);
};
if (consume(tok::kw_IF))
return _errorhandler();
if (parseExpression(E))
return _errorhandler();
if (consume(tok::kw_THEN))
return _errorhandler();
if (parseStatementSequence(IfStmts))
return _errorhandler();
if (Tok.is(tok::kw_ELSE)) {
advance();
if (parseStatementSequence(ElseStmts))
return _errorhandler();
}
if (expect(tok::kw_END))
return _errorhandler();
return false;
}
\end{cpp}
\begin{myTip}{解析器和词法分析器生成器}
手动构造解析器和词法分析器可能是一项乏味的任务,特别是在尝试发明一种新的编程语言,并经常更改语法的情况下。幸运的是,有些工具可以自动完成这项任务。
经典的Linux工具是flex(\url{https://github.com/westes/flex})和bison (\url{https://www.gnu.org/software/bison/})。flex从一组正则表达式生成词法分析器,而bison从语法描述生成LALR(1)解析器。这两个工具都可以生成C/C++源代码,并且可以一起使用。
另一个流行的工具是AntLR(\url{https://www.antlr.org/})。AntLR可以从语法描述生成词法分析器、解析器和AST。生成的解析器属于LL(*)类,所以它是一个自顶向下的解析器,使用可变数量的提前查找量(lookahead)来解决冲突。该工具是用Java编写的,但可以生成许多流行语言的源代码,包括C/C++。
所有这些工具都需要一些库支持。若正在寻找生成自包含的词法分析器和解析器的工具,那么Coco/R(\url{https://ssw.jku.at/Research/Projects/Coco/})可能是适合您的工具。Coco/R根据LL(1)语法描述生成词法分析器和递归下降解析器,类似于本书中使用的语法描述。生成的文件基于模板文件,可以根据需要更改模板文件。该工具用C\#编写的,可以移植到C++、Java和其他语言。
还有许多其他可用的工具,其在特性和支持的输出语言方面差异很大。在选择工具时,也需要考虑权衡。LALR(1)解析器生成器(如bison)可以接受范围广泛的语法,可以在互联网上找到的免费语法通常是LALR(1)语法。
缺点是,这些生成器生成需要在运行时解释的状态机,这可能比递归下降解析器慢,错误处理也更加复杂。Bison对处理语法错误提供了基本支持,但要正确使用它,需要对解析器的工作原理有深入的了解。与此相比,AntLR消耗的语法类略小,但会自动生成错误处理,并且还可以生成AST,所以重写语法以便与AntLR一起使用可能会加快之后的开发进度。
\end{myTip}
================================================
FILE: content/part2/chapter3/7.tex
================================================
我们在上一节中构建的解析器只检查输入的语法,下一步是添加执行语义分析的能力。上一章的calc示例中,解析器构建了一个AST。在另一个单独的阶段中,语义分析器在这个树上工作。这种方法总是可以使用的。本节中,我们将使用一种稍微不同的方法,并将解析器和语义分析器更多地交织在一起。
语义分析器需要做什么?一起来看看:
\begin{itemize}
\item
对于每个声明,必须检查变量、对象等的名称,以确保它们没有在其他地方声明过。
\item
对于表达式或语句中每次出现的名称,必须检查是否声明了该名称,以及所需的用途是否符合声明。
\item
对于每个表达式,必须计算结果类型。还需要计算表达式是否为常量,若是常量,具为哪个值。
\item
对于赋值和形参传递,必须检查类型是否兼容。此外,必须检查IF和WHILE语句中的条件是否为BOOLEAN类型。
\end{itemize}
对于这样一个编程语言的小子集来说,要检查的东西已经很多了!
\mySubsubsection{3.7.1.}{处理名称作用域}
先来了解一下名称作用域,名称作用域是该名称可见的范围。与C语言一样,tinylang使用了使用前声明(declare-before-use)模型。例如,B和X变量在模块级别可声明为INTEGER类型:
\begin{shell}
VAR B, X: INTEGER;
\end{shell}
声明之前,变量是未知的,不能使用。这只有在声明之后才有可能。在一个过程中,可以声明更多的变量:
\begin{shell}
PROCEDURE Proc;
VAR B: BOOLEAN;
BEGIN
(* Statements *)
END Proc;
\end{shell}
注释所在的位置,使用B指向B局部变量,而使用X指向X全局变量。局部变量B的作用域是Proc。若在当前作用域中找不到名称,则在外层封闭作用域中继续搜索,所以X变量可以在过程中使用。在tinylang中,只有模块和过程打开一个新的作用域。其他语言结构,如结构和类,通常也会打开作用域。预定义实体,如INTEGER类型和TRUE在全局作用域中声明,包含模块的作用域。
在tinylang中,只有名字才是关键。可以将作用域实现为从名称到其声明的映射,只有在新名称不存在的情况下才能插入该名称。对于查找,还必须知道封闭或父作用域,接口(在include/tinylang/Sema/Scope.h文件中)如下所示:
\begin{cpp}
#ifndef TINYLANG_SEMA_SCOPE_H
#define TINYLANG_SEMA_SCOPE_H
#include "tinylang/Basic/LLVM.h"
#include "llvm/ADT/StringMap.h"
#include "llvm/ADT/StringRef.h"
namespace tinylang {
class Decl;
class Scope {
Scope *Parent;
StringMap Symbols;
public:
Scope(Scope *Parent = nullptr) : Parent(Parent) {}
bool insert(Decl *Declaration);
Decl *lookup(StringRef Name);
Scope *getParent() { return Parent; }
};
} // namespace tinylang
#endif
\end{cpp}
lib/Sema/Scope.cpp文件中的实现如下所示:
\begin{cpp}
#include "tinylang/Sema/Scope.h"
#include "tinylang/AST/AST.h"
using namespace tinylang;
bool Scope::insert(Decl *Declaration) {
return Symbols
.insert(std::pair(
Declaration->getName(), Declaration))
.second;
}
\end{cpp}
注意,StringMap::insert()方法不会覆盖现有条目。结果std::pair的第二个成员表示表是否更新,此信息将返回给调用者。
要实现对符号声明的搜索,lookup()方法在当前作用域内搜索,若没有找到,则搜索父成员链接的作用域:
\begin{cpp}
Decl *Scope::lookup(StringRef Name) {
Scope *S = this;
while (S) {
StringMap::const_iterator I =
S->Symbols.find(Name);
if (I != S->Symbols.end())
return I->second;
S = S->getParent();
}
return nullptr;
}
\end{cpp}
然后,对变量声明进行如下处理:
\begin{itemize}
\item
当前的作用域是模块作用域。
\item
查找INTEGER类型声明。若没有找到声明或者不是类型声明,则会出现错误。
\item
实例化一个名为VariableDeclaration的新AST节点,其中重要的属性是名称B和类型。
\item
名称B插入到当前作用域,映射到声明实例。若该名称已经存在于作用域中,则这是一个错误,所以当前作用域的内容不会改变。
\item
对于X变量也是如此。
\end{itemize}
这里执行两个任务。与calc示例一样,构造AST节点。同时,计算节点的属性,如类型。为什么这是可能的呢?
语义分析器可以依赖于两组不同的属性:作用域继承自调用者,类型声明可以通过评估类型声明中的名称来计算(或者说合成)。该语言的设计方式,使得这两组属性足以计算AST节点的所有属性。
这里,需要先声明再使用模型。若一种语言允许在声明之前使用名称,例如C++中类中的成员,不可能一次计算一个AST节点的所有属性,AST节点必须仅使用部分计算的属性或仅使用普通信息(例如在calc示例中)来构造。
然后必须访问AST一次或多次以确定缺失的信息。在tinylang(和Modula-2)的情况下,可以省去AST构造——AST是通过parseXXX()方法的调用层次结构间接表示。使用AST生成代码更为常见,所以我们在这里也构造一个AST。
在把这些部分放在一起之前,我们需要了解LLVM使用运行时类型信息(runtime type information, RTTI)的风格。
\mySubsubsection{3.7.2.}{使用LLVM风格RTTI的AST}
当然,AST节点是类层次结构的一部分。声明总是有一个名称,其他属性取决于所声明的内容。若声明了变量,则需要指定类型。声明常量需要类型、值等。在运行时,需要找出使用的是哪种类型的声明,dynamic\_cast<>的C++操作符可用于此。问题是,只有当C++类附加了虚函数表时,所需的RTTI才可用——使用了虚函数。另一个缺点是C++ RTTI过于臃肿。为了避免这些缺点,LLVM开发人员引入了一种自制的RTTI风格,这种风格在整个LLVM库中使用。
我们的层次结构的(抽象)基类是Decl,要实现llvm风格的RTTI,必须添加一个公共枚举,其中包含每个子类的标签。此外,还需要该类型的私有成员和公共getter。私有成员通常为Kind。具体的实现,如下所示:
\begin{cpp}
class Decl {
public:
enum DeclKind { DK_Module, DK_Const, DK_Type,
DK_Var, DK_Param, DK_Proc };
private:
const DeclKind Kind;
public:
DeclKind getKind() const { return Kind; }
};
\end{cpp}
现在,每个子类都需要一个名为classof的特殊函数成员。该函数的目的是确定给定实例是否属于所请求的类型。对于VariableDeclaration,实现如下:
\begin{cpp}
static bool classof(const Decl *D) {
return D->getKind() == DK_Var;
}
\end{cpp}
现在,可以使用特殊模板llvm::isa<>来检查对象是否为所请求的类型,并使用llvm::dyn\_cast<>来动态转换对象。存在更多模板,但这两个是最常用的模板。有关其他模板,请参阅\url{https://llvm.org/docs/ProgrammersManual.html#the-isa-cast-and-dyn-cast-templates};有关LLVM样式的更多信息,包括更高级的用法,请参阅\url{https://llvm.org/docs/HowToSetUpLLVMStyleRTTI.html}。
\mySubsubsection{3.7.3.}{创建语义分析器}
有了这些,现在可以实现所有的部分。首先,必须为存储在include/llvm/tinylang/AST/AST.h文件中的变量创建AST节点的定义。除了支持LLVM风格的RTTI,基类存储声明的名称、名称的位置和一个指向外层声明的指针,后者在嵌套过程的代码生成过程中是必需的。Decl基类声明如下:
\begin{cpp}
class Decl {
public:
enum DeclKind { DK_Module, DK_Const, DK_Type,
DK_Var, DK_Param, DK_Proc };
private:
const DeclKind Kind;
protected:
Decl *EnclosingDecL;
SMLoc Loc;
StringRef Name;
public:
Decl(DeclKind Kind, Decl *EnclosingDecL, SMLoc Loc,
StringRef Name)
: Kind(Kind), EnclosingDecL(EnclosingDecL), Loc(Loc),
Name(Name) {}
DeclKind getKind() const { return Kind; }
SMLoc getLocation() { return Loc; }
StringRef getName() { return Name; }
Decl *getEnclosingDecl() { return EnclosingDecL; }
};
\end{cpp}
变量的声明只向类型声明添加一个指针:
\begin{cpp}
class TypeDeclaration;
class VariableDeclaration : public Decl {
TypeDeclaration *Ty;
public:
VariableDeclaration(Decl *EnclosingDecL, SMLoc Loc,
StringRef Name, TypeDeclaration *Ty)
: Decl(DK_Var, EnclosingDecL, Loc, Name), Ty(Ty) {}
TypeDeclaration *getType() { return Ty; }
static bool classof(const Decl *D) {
return D->getKind() == DK_Var;
}
};
\end{cpp}
解析器中的方法需要扩展语义动作和收集信息的变量:
\begin{cpp}
bool Parser::parseVariableDeclaration(DeclList &Decls) {
auto _errorhandler = [this] {
while (!Tok.is(tok::semi)) {
advance();
if (Tok.is(tok::eof)) return true;
}
return false;
};
Decl *D = nullptr; IdentList Ids;
if (parseIdentList(Ids)) return _errorhandler();
if (consume(tok::colon)) return _errorhandler();
if (parseQualident(D)) return _errorhandler();
Actions.actOnVariableDeclaration(Decls, Ids, D);
return false;
}
\end{cpp}
DeclList是声明列表,std::vector和IdentList是位置和标识符列表,std::vector{}>。
parseQualident()方法返回一个声明,在本例中应该是一个类型声明。
解析器类知道存储在Actions成员中的语义分析器类Sema的实例。对actOnVariableDeclaration()的调用运行语义分析器和AST构造,实现在lib/Sema/Sema.cpp文件中:
\begin{cpp}
void Sema::actOnVariableDeclaration(DeclList &Decls,
IdentList &Ids,
Decl *D) {
if (TypeDeclaration *Ty = dyn_cast(D)) {
for (auto &[Loc, Name] : Ids) {
auto *Decl = new VariableDeclaration(CurrentDecl, Loc,
Name, Ty);
if (CurrentScope->insert(Decl))
Decls.push_back(Decl);
else
Diags.report(Loc, diag::err_symbold_declared, Name);
}
} else if (!Ids.empty()) {
SMLoc Loc = Ids.front().first;
Diags.report(Loc, diag::err_vardecl_requires_type);
}
}
\end{cpp}
使用llvm::dyn\_cast检查类型声明。若不是类型声明,则打印错误消息;否则,对于Ids列表中的每个名称,将实例化VariableDeclaration并添加到声明列表中。若将变量添加到当前作用域中失败,因为变量名已经声明,也会输出一条错误消息。
大多数其他实体以相同的方式构建——语义分析的复杂性是唯一的区别。模块和过程需要做更多的工作,打开了一个新的作用域。打开一个新的作用域很容易:只需要实例化一个新的作用域对象。在解析模块或过程之后,必须删除作用域。
这一类实现必须可靠,我们不想在出现语法错误时将名称添加到错误的作用域中,这是C++中资源获取即初始化(Resource Acquisition Is Initialization, RAII)习惯用法的经典用法。另一个复杂之处在于过程可以递归地调用自身,所以在使用过程之前,必须将其名称添加到当前作用域。语义分析器有两个方法来进入和离开作用域。作用域与声明相关联:
\begin{cpp}
void Sema::enterScope(Decl *D) {
CurrentScope = new Scope(CurrentScope);
CurrentDecl = D;
}
void Sema::leaveScope() {
Scope *Parent = CurrentScope->getParent();
delete CurrentScope;
CurrentScope = Parent;
CurrentDecl = CurrentDecl->getEnclosingDecl();
}
\end{cpp}
一个简单的辅助类用于实现RAII惯用法:
\begin{cpp}
class EnterDeclScope {
Sema &Semantics;
public:
EnterDeclScope(Sema &Semantics, Decl *D)
: Semantics(Semantics) {
Semantics.enterScope(D);
}
~EnterDeclScope() { Semantics.leaveScope(); }
};
\end{cpp}
解析模块或过程时,语义分析器会进行两次交互。第一个是在名字被解析之后,构造了(几乎是空的)AST节点,并建立了一个新的作用域:
\begin{cpp}
bool Parser::parseProcedureDeclaration(/* … */) {
/* … */
if (consume(tok::kw_PROCEDURE)) return _errorhandler();
if (expect(tok::identifier)) return _errorhandler();
ProcedureDeclaration *D =
Actions.actOnProcedureDeclaration(
Tok.getLocation(), Tok.getIdentifier());
EnterDeclScope S(Actions, D);
/* … */
}
\end{cpp}
语义分析器检查当前范围中的名称,返回AST节点:
\begin{cpp}
ProcedureDeclaration *
Sema::actOnProcedureDeclaration(SMLoc Loc, StringRef Name) {
ProcedureDeclaration *P =
new ProcedureDeclaration(CurrentDecl, Loc, Name);
if (!CurrentScope->insert(P))
Diags.report(Loc, diag::err_symbold_declared, Name);
return P;
}
\end{cpp}
真正的工作是在所有声明和过程解析之后完成的。只需要检查过程声明末尾的名称是否与过程名称相等,以及用于返回类型的声明是否为类型声明:
\begin{cpp}
void Sema::actOnProcedureDeclaration(
ProcedureDeclaration *ProcDecl, SMLoc Loc,
StringRef Name, FormalParamList &Params, Decl *RetType,
DeclList &Decls, StmtList &Stmts) {
if (Name != ProcDecl->getName()) {
Diags.report(Loc, diag::err_proc_identifier_not_equal);
Diags.report(ProcDecl->getLocation(),
diag::note_proc_identifier_declaration);
}
ProcDecl->setDecls(Decls);
ProcDecl->setStmts(Stmts);
auto *RetTypeDecl =
dyn_cast_or_null(RetType);
if (!RetTypeDecl && RetType)
Diags.report(Loc, diag::err_returntype_must_be_type, Name);
else
ProcDecl->setRetType(RetTypeDecl);
}
\end{cpp}
有些声明本身就存在,不能由开发人员定义。这包括BOOLEAN和INTEGER类型以及TRUE和FALSE字面值,这些声明存在于全局作用域中,必须以编程方式添加。Modula-2还预定义了一些程序,如INC或DEC,可以添加到全局作用域。对于我们的类,初始化全局作用域很简单:
\begin{cpp}
void Sema::initialize() {
CurrentScope = new Scope();
CurrentDecl = nullptr;
IntegerType =
new TypeDeclaration(CurrentDecl, SMLoc(), "INTEGER");
BooleanType =
new TypeDeclaration(CurrentDecl, SMLoc(), "BOOLEAN");
TrueLiteral = new BooleanLiteral(true, BooleanType);
FalseLiteral = new BooleanLiteral(false, BooleanType);
TrueConst = new ConstantDeclaration(CurrentDecl, SMLoc(),
"TRUE", TrueLiteral);
FalseConst = new ConstantDeclaration(
CurrentDecl, SMLoc(), "FALSE", FalseLiteral);
CurrentScope->insert(IntegerType);
CurrentScope->insert(BooleanType);
CurrentScope->insert(TrueConst);
CurrentScope->insert(FalseConst);
}
\end{cpp}
使用该方案,tinylang所需的所有计算都可以完成。例如,下面来看看如何计算表达式的结果是否为常量:
\begin{itemize}
\item
必须确保常量声明的字面量或引用是常量
\item
表达式的两边都是常量,运算符也会得到一个常量
\end{itemize}
为表达式创建AST节点时,这些规则被嵌入到语义分析器中,也可以计算类型和常数值。
但并不是所有种类的计算都可以用这种方法进行。例如,要检测未初始化变量的使用,可以使用一种称为符号解释的方法。该方法需要一个特殊的遍历AST的顺序,在构造期间不可能获取。好消息是,所提出的方法创建了一个完全修饰过的AST,可以为代码生成做好准备。这个AST可以用于进一步的分析,所以昂贵的分析可以根据需要打开或关闭。
要使用前端,还需要更新驱动程序。由于缺少代码生成,正确的tinylang程序不会产生任何输出。尽管如此,其仍可以用于探索错误恢复和引发语义错误:
\begin{cpp}
#include "tinylang/Basic/Diagnostic.h"
#include "tinylang/Basic/Version.h"
#include "tinylang/Parser/Parser.h"
#include "llvm/Support/InitLLVM.h"
#include "llvm/Support/raw_ostream.h"
using namespace tinylang;
int main(int argc_, const char **argv_) {
llvm::InitLLVM X(argc_, argv_);
llvm::SmallVector argv(argv_ + 1,
argv_ + argc_);
llvm::outs() << "Tinylang "
<< tinylang::getTinylangVersion() << "\n";
for (const char *F : argv) {
llvm::ErrorOr>
FileOrErr = llvm::MemoryBuffer::getFile(F);
if (std::error_code BufferError =
FileOrErr.getError()) {
llvm::errs() << "Error reading " << F << ": "
<< BufferError.message() << "\n";
continue;
}
llvm::SourceMgr SrcMgr;
DiagnosticsEngine Diags(SrcMgr);
SrcMgr.AddNewSourceBuffer(std::move(*FileOrErr),
llvm::SMLoc());
auto TheLexer = Lexer(SrcMgr, Diags);
auto TheSema = Sema(Diags);
auto TheParser = Parser(TheLexer, TheSema);
TheParser.parse();
}
}
\end{cpp}
恭喜!已经完成了tinylang的前端实现!可以使用示例程序Gcd.mod(在定义真正的编程语言一节中提供),以运行前端:
\begin{shell}
$ tinylang Gcd.mod
\end{shell}
这是一个有效的程序,看起来好像什么也没发生。一定要尝试修改代码文件,使它输出一些错误消息。我们将在下一章中添加代码生成的过程。
================================================
FILE: content/part2/chapter3/8.tex
================================================
本章中,了解了实际编译器在前端使用的技术。从项目布局开始,为词法分析器、解析器和语义分析器创建了单独的库。为了向用户输出消息,扩展了一个现有的LLVM类,允许集中存储消息,词法分析器现在已经分成几个接口。
然后,了解了如何根据语法描述构造递归下降解析器,了解了要避免哪些陷阱,如何使用生成器来完成这项工作。构建的语义分析器执行语言所需的所有语义检查,同时与解析器和AST构造交织在一起。
编码工作的结果是一个完全修饰的AST,你将在下一章中使用它来生成IR代码,最后生成汇编代码。
================================================
FILE: content/part2/chapter4/0.tex
================================================
为编程语言创建了修饰抽象语法树(AST)之后,下一个任务是从中生成LLVM IR代码。LLVM IR代码类似于具有人类可读表示的三地址码,所以需要一种系统的方法来将语言概念(如控制结构)翻译成低层LLVM IR。
本章中,将了解LLVM IR的基础知识,如何从AST为控制流结构生成IR,如何使用现代算法为静态单赋值(SSA)形式的表达式生成LLVM IR,如何生成汇编文本和目标代码。
本章中,将了解以下内容:
\begin{itemize}
\item
使用AST生成IR
\item
使用AST编号生成SSA格式的IR代码
\item
设置模块和驱动程序
\end{itemize}
本章结束时,将了解如何为编程语言创建代码生成器,以及如何将其集成到编译器中。
================================================
FILE: content/part2/chapter4/1.tex
================================================
LLVM代码生成器将LLVM IR中的一个模块作为输入,并将其转换为目标代码或汇编文本,需要将AST表示转换为IR。为了实现一个IR代码生成器,首先看一个简单的例子,然后开发代码生成器所需的类。完整的实现将分为三类:
\begin{itemize}
\item
CodeGenerator
\item
CGModule
\item
CGProcedure
\end{itemize}
CodeGenerator类是编译器驱动程序使用的通用接口,CGModule和CGProcedure类保存为编译单元和单个函数生成IR代码所需的状态。
我们将从clang生成的IR开始。
\mySubsubsection{4.1.1.}{理解IR代码}
生成IR代码之前,最好先了解一下IR语言的主要元素。在第2章中,简要介绍了IR。获得更多IR知识的一个简单方法是研究clang的输出。例如,这个C代码(gcd.c),其实现了计算两个数的最大公约数的欧几里得算法:
\begin{cpp}
unsigned gcd(unsigned a, unsigned b) {
if (b == 0)
return a;
while (b != 0) {
unsigned t = a % b;
a = b;
b = t;
}
return a;
}
\end{cpp}
使用clang创建IR文件(gcd.ll):
\begin{shell}
$ clang --target=aarch64-linux-gnu -O1 -S -emit-llvm gcd.c
\end{shell}
IR代码与目标有关,说明该命令用于编译Linux下ARM 64位CPU的源文件。-S选项指示clang输出一个程序集文件,通过设置-emit-llvm选项,创建一个IR文件。优化级别-O1,用于获得一个易于阅读的IR代码。Clang有更多的选项,所有这些选项都在\url{https://clang.llvm.org/docs/ClangCommandLineReference.html}的命令行参数引用中进行了记录。来看一下生成的文件,并理解C代码是如何映射到LLVM IR的。
一个C文件翻译成一个模块,其中包含函数和数据对象。一个函数至少有一个基本块,一个基本块包含指令,这种分层结构也反映在C++ API中。所有数据元素都有类型,整数类型由字母i和位数表示。例如,64位整数类型写为i64。最基本的浮点类型是float和double,分别表示32位和64位IEEE浮点类型。也可以创建聚合类型,如vector、数组和结构体。
以下是LLVM IR,在文件的顶部,确定了一些基本属性。
\begin{shell}
; ModuleID = 'gcd.c'
source_filename = "gcd.c"
target datalayout = "e-m:e-i8:8:32-i16:16:32-i64:64-i128:128-
n32:64-S128"
target triple = "aarch64-unknown-linux-gnu"
\end{shell}
第一行是一个注释,表面使用了哪个模块标识符。下一行中,将命名源文件的文件名。对于clang,两者一样。
目标数据布局字符串建立一些基本属性。不同的部分用-号隔开。包括以下信息:
\begin{itemize}
\item
小写e表示内存中的字节使用小端模式存储。要指定大端序,必须使用大写E。
\item
M: 指定应用于符号的名称改写。这里,m:e表示使用ELF名称改写。
\item
iN:A:P形式,例如i8:8:32,指定了数据的对齐方式,以位表示。第一个数字是ABI要求的对齐方式,第二个数字是首选对齐方式。对于字节(i8), ABI对齐是1字节(8),首选对齐是4字节(32)。
\item
n指定可用的本机寄存器大小,n32:64表示原生支持32位和64位宽的整数。
\item
S指定了栈的对齐方式,同样以位为单位。S128表示栈保持16字节对齐。
\end{itemize}
\begin{myNotic}{Note}
所提供的目标数据布局必须与后端期望的匹配,将捕获的信息传递给与目标无关的优化过程。例如,优化过程可以查询数据布局以获得指针的大小和对齐方式,但改变数据布局中指针的大小,并不会改变后端代码的生成。
目标数据布局提供了更多的信息,可以在\url{https://llvm.org/docs/LangRef.html#data-layout}的参考手册中找到更多信息。
\end{myNotic}
最后,目标三重字符串指定了我们要编译的架构,在命令行上给出的信息。三元组是一个配置字符串,通常由CPU架构、厂商和操作系统组成,通常还会添加更多关于环境的信息。例如,x86\_64-pc-win32三元组用于运行在64位x86 CPU上的Windows系统。x86\_64是CPU架构,pc是通用的供应商,win32是操作系统。各部分用连字符连接。在ARMv8 CPU上运行的Linux系统使用aarch64-unknown-linux-gnu作为它的三元组。aarch64是CPU架构,而操作系统是运行gnu环境的linux。基于linux的系统没有真正的供应商,所以这部分未知。对于特定目的而言,不知道或不重要的部分通常忽略:所以aarch64-linux-gnu和aarch64-unknown-linux-gnu三元组描述了同一个Linux系统。
接下来,在IR文件中定义gcd函数:
\begin{shell}
define i32 @gcd(i32 %a, i32 %b) {
\end{shell}
这类似于C文件中的函数签名。无符号数据类型被转换成32位整数类型i32。函数名以@为前缀,参数名以\%为前缀。函数体用大括号括起来:
\begin{shell}
entry:
%cmp = icmp eq i32 %b, 0
br i1 %cmp, label %return, label %while.body
\end{shell}
IR代码组织成基本块,格式良好的基本块是一个线性指令序列,以一个可选的标签开始,以一个终止指令结束,所以每个基本块都有一个入口点和一个出口点。LLVM允许在构造时使用畸形基本块,第一个基本块的标签是entry。代码块中的代码很简单:第一条指令将\%b参数与0进行比较。第二条指令在条件为true时跳转到return标签,然后跳转到while语句。若条件为false,则使用body标签。
IR代码的另一个特点是采用静态单一赋值(SSA)形式。代码使用无限量的虚拟寄存器,但每个寄存器只写入一次。比较的结果会赋值给指定的虚拟寄存器\%cmp,使用这个寄存器,但不再写入。诸如常量传播和公共子表达式消除之类的优化,在SSA形式下工作得非常好,所有现代编译器都在使用。
\begin{myTip}{SSA}
SSA是在20世纪80年代后期发展起来的,因其简化了数据流分析和优化,所以广泛应用于编译器中。例如,R是SSA形式,循环内部公共子表达式的识别就会容易得多。SSA的一个基本属性是建立了def-use和use-def链:对于单个定义,可知道所有的用法(def-use),对于每个用法,知道唯一的定义(use-def)。这个信息广泛使用,例如在常量传播中:若一个定义确定为常量,则所有对该值的使用都可以很容易地替换为该常量值。
Cytron等人(1989)提出的构造SSA形式的算法非常流行,也用于LLVM的实现。早期的观察发现,若语言没有goto语句,这些算法会变得更简单。
对SSA的深入研究可以在F. rastello和F. B. Tichadou的《基于SSA的编译器设计》一书中找到,Springer 2022。
\end{myTip}
下一个基本块是while循环的主体:
\begin{shell}
while.body:
%b.loop = phi i32 [ %rem, %while.body ],
[ %b, %entry ]
%a.loop = phi i32 [ %b.loop, %while.body ],
[ %a, %entry ]
%rem = urem i32 %a.loop, %b.loop
%cmp1 = icmp eq i32 %rem, 0
br i1 %cmp1, label %return, label %while.body
\end{shell}
在gcd的循环中,参数a和b会赋新值。若一个寄存器只能写入一次,则是不可能的。解决方案是使用特殊的phi指令,phi指令有一个基本块和值的列表作为参数。基本块表示来自该基本块的传入边,值是来自该基本块的值。运行时,phi指令将之前执行的基本块的标签与参数列表中的标签进行比较。
指令的值就是与标签相关联的值。对于第一条phi指令,若先前执行的基本块是while.body,则该值为\%rem寄存器。若先前执行的基本块,则值为\%b,这些值是位于基本块开头的值。\%b循环寄存器从第一个phi指令获取一个值。第二个phi指令的参数列表中使用了相同的寄存器,但假定该值是通过第一个phi指令改变之前的值。
循环体之后,必须选择返回值:
\begin{shell}
return:
%retval = phi i32 [ %a, %entry ],
[ %b.loop, %while.body ]
ret i32 %retval
}
\end{shell}
同样,phi指令用于选择所需的值。ret指令不仅结束了这个基本块,运行时表示这个函数的结束。它有一个返回值作为参数。
使用phi指令有一些限制,必须是基本块的第一个指令。第一个基本块比较特殊:没有之前执行过的块,所以不能以phi指令开始。
\begin{myTip}{LLVM IR参考}
我们只触及了LLVM IR的皮毛,访问LLVM语言参考手册\url{https://llvm.org/docs/LangRef.html},可了解更多细节。
\end{myTip}
IR代码本身看起来很像C语言和汇编语言的混合体。尽管有这种熟悉的风格,但还是不清楚如何使用AST生成IR代码,尤其是phi指令看起来很难生成。下一节中,将为此实现一个算法!
\mySubsubsection{4.1.2.}{了解加载和存储的方法}
LLVM中的所有局部优化都是基于这里所示的SSA,使用全局变量的内存引用。IR语言有load和store指令,用于获取和存储这些值,也可以将此用于局部变量。这些指令不属于SSA形式,LLVM知道如何将其转换为所需的SSA,可以为每个局部变量分配内存槽,并使用load和store指令来更改它们的值,只需要记住指向存储变量的内存槽的指针。clang编译器使用这种方法。
来看一下load和store的IR代码。再次编译gcd.c,但这次没有启用优化:
\begin{shell}
$ clang --target=aarch64-linux-gnu -S -emit-llvm gcd.c
\end{shell}
gcd函数现在看起来有所不同。这是第一个基本块:
\begin{shell}
define i32 @gcd(i32, i32) {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
%5 = alloca i32, align 4
%6 = alloca i32, align 4
store i32 %0, ptr %4, align 4
store i32 %1, ptr %5, align 4
%7 = load i32, ptr %5, align 4
%8 = icmp eq i32 %7, 0
br i1 %8, label %9, label %11
\end{shell}
IR代码现在依赖于寄存器和标签的自动编号,未指定参数的名称,隐式地为\%0和\%1。基本块没有标签,因此赋值为2,前几条指令为4个32位值分配内存。之后,参数\%0和\%1存储在寄存器\%4和\%5指向的内存槽中。为了比较\%1和0,这个值显式地从内存槽加载。使用这种方法,不需要使用phi指令!相反,可以从内存槽中加载一个值进行计算,然后将新值存储回内存槽中。下次读取内存槽时,就会得到最后一次计算的值。gcd函数的所有其他基本块都遵循这种模式。
以这种方式使用加载和存储指令的优点是,生成IR代码相当容易;缺点是,在将基本块转换为SSA形式后,在第一个优化步骤中,LLVM将使用mem2reg时,会删除大量的IR指令。
因此,我们直接生成SSA形式的IR代码,通过将控制流映射到基本块开始生成IR代码。
\mySubsubsection{4.1.3.}{控制流映射到基本块}
基本块的概念,是按该顺序执行的指令的线性序列。基本块在开始时只有一个条目,以终止指令结束,终止指令是将控制流转移到另一个基本块的指令,例如:分支指令、切换指令或返回指令。请参阅\url{https://llvm.org/docs/LangRef.html#terminator-instructions}获取终止器说明的完整列表。一个基本块可以以phi指令开始,但在一个基本块内,既不允许phi指令,也不允许分支指令。换句话说,只能在第一个指令中输入一个基本块,并且只能在最后一个指令中留下一个基本块,即结束指令。不可能在基本块内分支到指令,也不可能从基本块中间分支到另一个基本块。请注意,带有call指令的简单函数调用可以发生在基本块中。每个基本块只有一个标签,标记基本块的第一条指令,标签是分支指令的目标。可以将分支视为两个基本块之间的有向边,从而生成控制流图(CFG)。一个基本块可以有前身和后继,函数的第一个基本块是特殊的(因为它不允许有前块)。
由于这些限制,源语言中的控制语句,如WHILE和IF,会产生几个基本块。来看一下WHILE语句。WHILE语句的条件控制是执行循环体还是执行下一个语句,该条件语句必须在一个单独的基本块中生成,因为其两个前块:
\begin{itemize}
\item
由WHILE之前的语句产生的基本块
\item
从循环体的末端返回到条件的分支
\end{itemize}
还有两个后块:
\begin{itemize}
\item
循环体的开始部分
\item
由WHILE后面的语句产生的基本块
\end{itemize}
循环体本身至少有一个基本块:
\myGraphic{0.5}{content/part2/chapter4/images/1.png}{图4.1 - WHILE语句的基本块}
IR代码生成遵循这种结构。在CGProcedure类中存储一个指向当前基本块的指针,并使用llvm::IRBuilder<>的实例将指令插入基本块。创建基本块:
\begin{cpp}
void emitStmt(WhileStatement *Stmt) {
llvm::BasicBlock *WhileCondBB = llvm::BasicBlock::Create(
CGM.getLLVMCtx(), "while.cond", Fn);
llvm::BasicBlock *WhileBodyBB = llvm::BasicBlock::Create(
CGM.getLLVMCtx(), "while.body", Fn);
llvm::BasicBlock *AfterWhileBB = llvm::BasicBlock::Create(
CGM.getLLVMCtx(), "after.while", Fn);
\end{cpp}
Fn变量表示当前函数,getLLVMCtx()返回LLVM上下文,两者都会在稍后设定。我们用一个基本块的分支来结束当前的基本块,该分支将保存以下条件:
\begin{cpp}
Builder.CreateBr(WhileCondBB);
\end{cpp}
该条件的基本块成为新的当前基本块。生成条件语句,并以一个条件分支结束代码块:
\begin{cpp}
setCurr(WhileCondBB);
llvm::Value *Cond = emitExpr(Stmt->getCond());
Builder.CreateCondBr(Cond, WhileBodyBB, AfterWhileBB);
\end{cpp}
接下来,生成循环体,在条件语句的基本块中添加一个分支:
\begin{cpp}
setCurr(WhileBodyBB);
emit(Stmt->getWhileStmts());
Builder.CreateBr(WhileCondBB);
\end{cpp}
这样,就生成了WHILE语句。现在已经生成了WhileCondBB和Curr块,可以对其进行密封:
\begin{cpp}
sealBlock(WhileCondBB);
sealBlock(Curr);
\end{cpp}
WHILE语句后面的空基本块变成了当前基本块:
\begin{cpp}
setCurr(AfterWhileBB);
}
\end{cpp}
按照这个模式,可以为语言的每个语句创建emit()方法。
================================================
FILE: content/part2/chapter4/2.tex
================================================
为了使用AST生成SSA形式的IR代码,可以使用一种称为AST编号的方法。基本思想是,对于每个基本块,我们存储写入该基本块中局部变量的当前值。
\begin{myNotic}{Note}
实现基于Braun et al.的论文《Simple and Efficient Construction of Static Single Assignment Form》,International Conference on CompilerConstruction 2013 (CC 2013), Springer(见\url{http://individual.utoronto.ca/dfr/ece467/braun13.pdf})。在其呈现的形式中,仅适用于具有结构化受控流的IR代码。若需要支持任意控制流,本文还描述了必要的扩展——例如:goto。
\end{myNotic}
虽然很简单,但仍然需要几个步骤。首先介绍所需的数据结构,然后了解如何在基本块的局部读写值。然后,将处理几个基本块中使用的值,并通过优化创建的phi指令来结束。
\mySubsubsection{4.2.1.}{定义数据结构保存值}
使用BasicBlockDef struct来保存单个块的信息:
\begin{cpp}
struct BasicBlockDef {
llvm::DenseMap> Defs;
// ...
};
\end{cpp}
llvm::Value类表示SSA格式的值,其作用类似于计算结果的标签。通过IR指令创建,然后使用。优化过程中可能会发生各种变化,若优化器检测到\%1和\%2的值始终相同,可将\%2替换为\%1。这会改变标签,但不会改变计算。
为了了解这些变化,不能直接使用Value类。相反,这时需要一个值句柄。Value的处理有不同的功能。为了跟踪替换,需要使用llvm::TrackingVH<>类,所以Defs成员将AST的声明(变量或正式参数)映射到其当前值,需要为每个基本块存储这些信息:
\begin{cpp}
llvm::DenseMap CurrentDef;
\end{cpp}
有了这个数据结构,就可以处理局部值了。
\mySubsubsection{4.2.2.}{定义保存值的数据结构}
为了在基本块中存储局部变量的当前值,将在map中创建一个条目:
\begin{cpp}
void writeLocalVariable(llvm::BasicBlock *BB, Decl *Decl,
llvm::Value *Val) {
CurrentDef[BB].Defs[Decl] = Val;
}
\end{cpp}
因为值可能不在基本块中,所以变量值的查找有点复杂,所以需要使用可能的递归搜索将搜索扩展到前面的节点:
\begin{cpp}
llvm::Value *
readLocalVariable(llvm::BasicBlock *BB, Decl *Decl) {
auto Val = CurrentDef[BB].Defs.find(Decl);
if (Val != CurrentDef[BB].Defs.end())
return Val->second;
return readLocalVariableRecursive(BB, Decl);
}
\end{cpp}
实际的工作是搜索前面的块,将在下一节中实现。
\mySubsubsection{4.2.3.}{前块中搜索值}
若正在查看的当前基本块只有一个前块,则直接在那里搜索变量值。若基本块有多个前块,需要在所有这些块中搜索值并组合结果。为了说明这种情况,可以看看前一节中使用WHILE条件语句的基本块。
这个基本块有两个前块——一个来自WHILE语句之前的语句,另一个来自WHILE循环主体末尾的分支。在条件中使用的变量应该有一些初始值,并且最有可能在循环体中更改,所以需要收集这些定义并创建phi指令。从WHILE语句创建一个包含循环的基本块。
因为需要递归地搜索前块,所以必须打破这种循环。可以使用一个简单的技巧:插入一个空的phi指令,并将其记录为变量的当前值。若在搜索中再次看到这个基本块,则看到该变量有一个可以使用的值,搜索到这里就停止了。当收集了所有的值,就必须更新phi指令。
然而,我们仍然会面临一个问题。在查找的时候,并不是一个基本块的所有前块都已知。来看看为WHILE语句创建的基本块,首先生成循环条件的IR,但只有在主体的IR生成之后,才能添加从主体末尾返回到包含条件基本块的分支(这是因为这个基本块之前是未知的)。若需要在条件中读取变量的值,那就卡住了,因为不是所有的前块都已知。
为了解决这个问题,必须做点什么:
\begin{enumerate}
\item
首先,必须在基本块上添加一个密封(Sealed)标志。
\item
然后,若知道基本块的所有前块,必须将基本块定义为密封。若基本块没有密封,并且需要查找这个基本块中尚未定义变量的值,必须插入一个空的phi指令,并将其作为值。
\item
也需要记住这个指令。若块稍后密封,则需要用实际值更新指令。为了实现这一点,必须在结构体BasicBlockDef中添加两个成员:IncompletePhis映射,记录了稍后需要更新的phi指令,以及Sealed标志,这表示基本块是否密封:
\begin{cpp}
llvm::DenseMap IncompletePhis;
unsigned Sealed : 1;
\end{cpp}
\item
该方法的实现,如本节开头所讨论:
\begin{cpp}
llvm::Value *CGProcedure::readLocalVariableRecursive(
llvm::BasicBlock *BB, Decl *Decl) {
llvm::Value *Val = nullptr;
if (!CurrentDef[BB].Sealed) {
llvm::PHINode *Phi = addEmptyPhi(BB, Decl);
CurrentDef[BB].IncompletePhis[Phi] = Decl;
Val = Phi;
} else if (auto *PredBB = BB->getSinglePredecessor()) {
Val = readLocalVariable(PredBB, Decl);
} else {
llvm::PHINode *Phi = addEmptyPhi(BB, Decl);
writeLocalVariable(BB, Decl, Phi);
Val = addPhiOperands(BB, Decl, Phi);
}
writeLocalVariable(BB, Decl, Val);
return Val;
}
\end{cpp}
\item
addEmptyPhi()方法在基本块的开头插入一个空的phi指令:
\begin{cpp}
llvm::PHINode *
CGProcedure::addEmptyPhi(llvm::BasicBlock *BB,
Decl *Decl) {
return BB->empty()
? llvm::PHINode::Create(mapType(Decl), 0,
"", BB)
: llvm::PHINode::Create(mapType(Decl), 0,
"", &BB->front());
}
\end{cpp}
\item
要将缺失的操作数添加到phi指令中,必须首先搜索基本块的所有前块,并将操作数值对和基本块添加到phi指令中,需要尝试优化指令:
\begin{cpp}
llvm::Value *
CGProcedure::addPhiOperands(llvm::BasicBlock *BB,
Decl *Decl,
llvm::PHINode *Phi) {
for (auto *PredBB : llvm::predecessors(BB))
Phi->addIncoming(readLocalVariable(PredBB, Decl),
PredBB);
return optimizePhi(Phi);
}
\end{cpp}
\end{enumerate}
该算法可以产生不必要的phi指令,有种方法可以用来进行优化,这将在下一节中实现。
\mySubsubsection{4.2.4.}{优化生成的phi指令}
如何优化指令,为什么要这样做?尽管SSA形式对许多优化都有利,但算法通常不能解析phi指令,从而阻碍了优化,所以生成的phi指令越少越好:
\begin{enumerate}
\item
若指令只有一个操作数,或者所有操作数的值都相同,则用这个值替换指令。若该指令没有操作数,则用特殊的Undef值替换该指令。只有当指令有两个或更多不同的操作数时,才必须保留这条指令:
\begin{cpp}
llvm::Value *
CGProcedure::optimizePhi(llvm::PHINode *Phi) {
llvm::Value *Same = nullptr;
for (llvm::Value *V : Phi->incoming_values()) {
if (V == Same || V == Phi)
continue;
if (Same && V != Same)
return Phi;
Same = V;
}
if (Same == nullptr)
Same = llvm::UndefValue::get(Phi->getType());
\end{cpp}
\item
删除一条phi指令可能会给其他phi指令带来优化机会,LLVM会跟踪用户和值的使用(即SSA定义中提到的use-def链),必须在其他phi指令中搜索该值使用情况,并尝试优化这些指令:
\begin{cpp}
llvm::SmallVector CandidatePhis;
for (llvm::Use &U : Phi->uses()) {
if (auto *P =
llvm::dyn_cast(U.getUser()))
if (P != Phi)
CandidatePhis.push_back(P);
}
Phi->replaceAllUsesWith(Same);
Phi->eraseFromParent();
for (auto *P : CandidatePhis)
optimizePhi(P);
return Same;
}
\end{cpp}
\end{enumerate}
还可以进一步改进这个算法,可以选择并记住两个不同的值,而不是总是迭代每个phi指令的值列表。在optimizePhi函数中,可以检查这两个值是否仍然在phi指令的列表中,则没必要优化。但即使没有这个优化,这个算法也可以运行得也很快,所以现在不进行实现。
现在,唯一没有做的是实现密封基本块的操作。
\mySubsubsection{4.2.5.}{密封块}
了解了一个区块的所有前块,就可以密封这个块了。若语言只包含结构化语句(如tinylang),那么很容易确定块的位置。再看一下为WHILE语句生成的基本块。
包含条件的基本块可以在主体的末端添加分支后进行密封,这是缺少的最后一个前块。要密封一个块,可以简单地将缺失的操作数,添加到不完整的phi指令中并设置标志:
\begin{cpp}
void CGProcedure::sealBlock(llvm::BasicBlock *BB) {
for (auto PhiDecl : CurrentDef[BB].IncompletePhis) {
addPhiOperands(BB, PhiDecl.second, PhiDecl.first);
}
CurrentDef[BB].IncompletePhis.clear();
CurrentDef[BB].Sealed = true;
}
\end{cpp}
有了这些方法,就可以为表达式生成IR了。
\mySubsubsection{4.2.6.}{生成表达式的IR}
要翻译表达式,如第2章所示。唯一有趣的部分是如何访问变量。上一节讨论了局部变量,但还要考虑其他类型的变量。这里,就讨论一下接下来需要些做什么:
\begin{itemize}
\item
对于过程的局部变量,使用上一节中的readLocalVariable()和writeLocalVariable()方法。
\item
对于一个封闭过程中的局部变量,需要一个指向封闭过程框架的指针。这将在本章稍后的章节中进行处理。
\item
对于全局变量,生成加载和存储指令。
\item
对于形式参数,必须区分按值传递和按引用传递(tinylang中的VAR参数)。通过值传递的参数会视为局部变量,通过引用传递的参数会当作全局变量。
\end{itemize}
综上所述,得到如下用于读取变量或形参的代码:
\begin{cpp}
llvm::Value *CGProcedure::readVariable(llvm::BasicBlock *BB,
Decl *D) {
if (auto *V = llvm::dyn_cast(D)) {
if (V->getEnclosingDecl() == Proc)
return readLocalVariable(BB, D);
else if (V->getEnclosingDecl() == CGM.getModuleDeclaration()) {
return Builder.CreateLoad(mapType(D),
CGM.getGlobal(D));
} else
llvm::report_fatal_error("Nested procedures not yet supported");
} else if (auto *FP = llvm::dyn_cast(D)) {
if (FP->isVar()) {
return Builder.CreateLoad(mapType(FP, false),
FormalParams[FP]);
} else
return readLocalVariable(BB, D);
} else
llvm::report_fatal_error("Unsupported declaration");
}
\end{cpp}
对变量或形式参数的写入是对称的——需要将要读的方法与要写的方法交换,并使用存储指令,而非加载指令。
接下来,在为这些函数生成IR代码时,应用这些函数。
\mySubsubsection{4.2.7.}{生成函数的IR}
大多数IR代码都存在于函数中,IR代码中的函数类似于C语言中的函数,通过名称、参数类型、返回值和其他属性指定。要调用不同编译单元中的函数,需要声明该函数,这与C语言中的原型类似。若向函数中添加基本块,就可以定义函数。接下来的几节会介绍这些内容,但首先要讨论的是,符号名称的可见性。
\mySubsubsection{4.2.8.}{通过链接和名称修改控制可见性}
函数(以及全局变量)具有附加的链接样式。使用链接方式,我们定义了符号名称的可见性,若多个符号具有相同的名称会发生什么。最基本的链接样式是私有的和外部的,私有链接的符号仅在当前编译单元中可见,而具有外部链接的符号则全局可见。
对于没有模块概念的语言(如C语言),这是足够的。对于模块,需要做更多的事情。假设有一个模块Square,提供了一个Root()函数,还有一个模块Cube,也提供了一个Root()函数。若函数私有,那么没什么问题,该函数获取名称Root和私有链接。若导出函数,以便可以从其他模块调用它,情况就不同了。因为函数名不唯一,所以仅使用函数名就不够了。
解决方案是调整名称使其全局唯一,这称为名称修改,如何做到取决于语言的要求和特点。在我们的示例中,基本思想是使用模块名和函数名的组合来创建全局唯一的名称。使用Square.Root作为名称看起来是一个显而易见的解决方案,但这可能会导致汇编程序的问题,因为点可能具有特殊的含义。不需要在名称组件之间使用分隔符,可以通过在名称组件前加上长度:6Square4Root来获得类似的效果。单这不是LLVM的合法标识符,我们可以通过在整个名称前加上\_t (t表示tinylang)来修复这个问题:\_t6Square4Root。通过这种方式,可以为导出符号创建唯一名称:
\begin{cpp}
std::string CGModule::mangleName(Decl *D) {
std::string Mangled("_t");
llvm::SmallVector List;
for (; D; D = D->getEnclosingDecl())
List.push_back(D->getName());
while (!List.empty()) {
llvm::StringRef Name = List.pop_back_val();
Mangled.append(llvm::Twine(Name.size()).concat(Name).str());
}
return Mangled;
}
\end{cpp}
若语言支持类型重载,则需要使用类型名扩展此方案。例如,C++中中要区分int root(int)和double root(double)函数,必须将形参类型和返回值添加到函数名中。还需要考虑生成的名称的长度,因为某些链接器对长度进行了限制。在C++中使用嵌套的命名空间和类,修改后的名称可能相当长。
这里,C++定义了一个压缩方案,以避免一遍又一遍地重复名称组件。
接下来,就来看看如何处理函数参数。
\mySubsubsection{4.2.9.}{AST描述类型转换为LLVM类型}
函数的参数也需要考虑。首先,需要将源语言的类型映射到LLVM类型。由于tinylang目前只有两种类型,所以很容易:
\begin{cpp}
llvm::Type *CGModule::convertType(TypeDeclaration *Ty) {
if (Ty->getName() == "INTEGER")
return Int64Ty;
if (Ty->getName() == "BOOLEAN")
return Int1Ty;
llvm::report_fatal_error("Unsupported type");
}
\end{cpp}
Int64Ty、Int1Ty和VoidTy是类成员,包含i64、i1和void LLVM类型的类型表示。
对于通过引用传递的形参,这是不够的。该参数的LLVM类型为指针型,当我们想要使用形参的值时,需要知道底层类型。这由HonorReference标志控制,其默认值为true。我们对函数进行推广,并考虑形参:
\begin{cpp}
llvm::Type *CGProcedure::mapType(Decl *Decl,
bool HonorReference) {
if (auto *FP = llvm::dyn_cast(
Decl)) {
if (FP->isVar() && HonorReference)
return llvm::PointerType::get(CGM.getLLVMCtx(),
/*AddressSpace=*/0);
return CGM.convertType(FP->getType());
}
if (auto *V = llvm::dyn_cast(Decl))
return CGM.convertType(V->getType());
return CGM.convertType(llvm::cast(Decl));
}
\end{cpp}
有了这些辅助程序,我们就可以创建LLVM IR函数了。
\mySubsubsection{4.2.10.}{创建LLVM IR函数}
要在LLVM IR中生成一个函数,需要一个函数类型,这类似于C中的原型。创建函数类型包括映射类型,然后使用工厂方法来创建函数类型:
\begin{cpp}
llvm::FunctionType *CGProcedure::createFunctionType(
ProcedureDeclaration *Proc) {
llvm::Type *ResultTy = CGM.VoidTy;
if (Proc->getRetType()) {
ResultTy = mapType(Proc->getRetType());
}
auto FormalParams = Proc->getFormalParams();
llvm::SmallVector ParamTypes;
for (auto FP : FormalParams) {
llvm::Type *Ty = mapType(FP);
ParamTypes.push_back(Ty);
}
return llvm::FunctionType::get(ResultTy, ParamTypes,
/*IsVarArgs=*/false);
}
\end{cpp}
根据函数类型,我们还创建了LLVM函数。这将函数类型与链接修改后的名称进行了关联:
\begin{cpp}
llvm::Function *
CGProcedure::createFunction(ProcedureDeclaration *Proc,
llvm::FunctionType *FTy) {
llvm::Function *Fn = llvm::Function::Create(
Fty, llvm::GlobalValue::ExternalLinkage,
CGM.mangleName(Proc), CGM.getModule());
\end{cpp}
getModule()方法返回当前的LLVM模块,稍后我们将对其进行设置。
创建了函数后,可以添加更多关于它的信息:
\begin{itemize}
\item
首先,可以给出参数的名称,可使得IR更具可读性。
\item
其次,可以给函数和参数添加属性来指定一些特征。例如,通过引用传递的参数进行此操作。
\end{itemize}
在LLVM级别,这些参数是指针。但是从源语言设计来看,这些是非常受限的指针。与C++中的引用类似,总是需要为VAR参数指定一个变量。根据设计,我们知道这个指针永远不会为空,并且始终可解引用,所以可以读取指向的值,而不会有风险。而且,该指针不能传递——特别是,因为没有比函数调用更长的指针副本,所以不能捕获指针。
llvm::AttributeBuilder类用于为形式参数构建属性集。要获取参数类型的存储大小,可以简单地查询数据布局对象:
\begin{cpp}
for (auto [Idx, Arg] : llvm::enumerate(Fn->args())) {
FormalParameterDeclaration *FP =
Proc->getFormalParams()[Idx];
if (FP->isVar()) {
llvm::AttrBuilder Attr(CGM.getLLVMCtx());
llvm::TypeSize Sz =
CGM.getModule()->getDataLayout().getTypeStoreSize(
CGM.convertType(FP->getType()));
Attr.addDereferenceableAttr(Sz);
Attr.addAttribute(llvm::Attribute::NoCapture);
Arg.addAttrs(Attr);
}
Arg.setName(FP->getName());
}
return Fn;
}
\end{cpp}
这样,就创建了IR函数。下一节中,我们将把函数体的基本块添加到函数中。
\mySubsubsection{4.2.11.}{生成函数体}
我们几乎完成了为函数生成IR代码,只需要把这些片段组合在一起就可以生成函数了,包括它的函数体:
\begin{enumerate}
\item
给定一个来自tinylang的过程声明。首先,将创建函数类型和函数:
\begin{cpp}
void CGProcedure::run(ProcedureDeclaration *Proc) {
this->Proc = Proc;
Fty = createFunctionType(Proc);
Fn = createFunction(Proc, Fty);
\end{cpp}
\item
接下来,将创建函数的第一个基本块,并使其成为当前块:
\begin{cpp}
llvm::BasicBlock *BB = llvm::BasicBlock::Create(
CGM.getLLVMCtx(), "entry", Fn);
setCurr(BB);
\end{cpp}
\item
然后,必须逐一检查所有的形参。为了正确处理VAR参数,需要初始化FormalParams成员(在readVariable()中使用)。与局部变量相比,形参在第一个基本块中有一个值,所以些值必须已知:
\begin{cpp}
for (auto [Idx, Arg] : llvm::enumerate(Fn->args())) {
FormalParameterDeclaration *FP =
Proc->getFormalParams()[Idx];
FormalParams[FP] = &Arg;
writeLocalVariable(Curr, FP, &Arg);
}
\end{cpp}
\item
设置之后,可以调用emit()方法来生成IR代码:
\begin{cpp}
auto Block = Proc->getStmts();
emit(Proc->getStmts());
\end{cpp}
\item
生成IR代码后的最后一个块可能还没有密封,所以现在必须调用sealBlock()。tinylang中的过程可能有隐式返回,因此必须检查最后一个基本块是否有正确的结束符;否则,需要添加一个结束符:
\begin{cpp}
if (!Curr->getTerminator()) {
Builder.CreateRetVoid();
}
sealBlock(Curr);
}
\end{cpp}
\end{enumerate}
至此,我们已经完成了为函数生成IR代码的工作。但仍然需要创建LLVM模块,它将所有IR代码整合在一起。我们将在下一节中进行此操作。
================================================
FILE: content/part2/chapter4/3.tex
================================================
我们在LLVM模块中收集编译单元的所有函数和全局变量。为了简化IR生成过程,可以将前几节中的所有函数包装到代码生成器类中。为了获得一个工作的编译器,还需要定义想要为其生成代码的目标架构,并添加生成代码的通道。我们将在本章和接下来的几章中实现它,先从代码生成器开始。
\mySubsubsection{4.3.1.}{代码生成器}
IR模块包含为编译单元生成大括号内的所有元素。我们在全局级别遍历模块级别的声明,创建全局变量,并调用过程的代码生成。tinylang中的全局变量会映射到llvm::GobalValue类的实例。这种映射保存在全局变量中,可用于过程代码的生成:
\begin{cpp}
void CGModule::run(ModuleDeclaration *Mod) {
for (auto *Decl : Mod->getDecls()) {
if (auto *Var =
llvm::dyn_cast(Decl)) {
// Create global variables
auto *V = new llvm::GlobalVariable(
*M, convertType(Var->getType()),
/*isConstant=*/false,
llvm::GlobalValue::PrivateLinkage, nullptr,
mangleName(Var));
Globals[Var] = V;
} else if (auto *Proc =
llvm::dyn_cast(Decl)) {
CGProcedure CGP(*this);
CGP.run(Proc);
}
}
}
\end{cpp}
该模块还包含LLVMContext类,并缓存最常用的LLVM类型。后者需要初始化,例如:对于64位整数类型的初始化:
\begin{cpp}
Int64Ty = llvm::Type::getInt64Ty(getLLVMCtx());
\end{cpp}
CodeGenerator类初始化LLVM IR模块并调用该模块的代码生成,这个类必须了解要为哪个目标架构生成代码。该信息在驱动程序中的llvm::TargetMachine类中:
\begin{cpp}
std::unique_ptr
CodeGenerator::run(ModuleDeclaration *Mod,
std::string FileName) {
std::unique_ptr M =
std::make_unique(FileName, Ctx);
M->setTargetTriple(TM->getTargetTriple().getTriple());
M->setDataLayout(TM->createDataLayout());
CGModule CGM(M.get());
CGM.run(Mod);
return M;
}
\end{cpp}
为了方便使用,必须为代码生成器引入一个工厂方法:
\begin{cpp}
CodeGenerator *
CodeGenerator::create(llvm::LLVMContext &Ctx,
llvm::TargetMachine *TM) {
return new CodeGenerator(Ctx, TM);
}
\end{cpp}
CodeGenerator类提供了一个用于创建IR代码的接口,适合在编译器驱动程序中使用。在集成它之前,需要实现对机器代码生成的支持。
\mySubsubsection{4.3.2.}{初始化目标机型类}
现在,只有目标机型未知。在目标机型上,需要定义为其生成代码的CPU架构。对于每个CPU,都有一些特性会影响代码的生成过程。例如,CPU架构族中的一个较新的CPU可以支持向量指令。通过这些特性,我们可以切换使用向量指令的标志。支持从命令行设置这些选项,LLVM提供了一些支持代码。在Driver类中,可以包含以下头文件:
\begin{cpp}
#include "llvm/CodeGen/CommandFlags.h"
\end{cpp}
这个include将常见的命令行选项添加到编译器驱动程序中。许多LLVM工具也使用这些命令行选项,其好处是为用户提供了一个通用的接口,不过只缺少指定目标三元组的选项:
\begin{cpp}
static llvm::cl::opt MTriple(
"mtriple",
llvm::cl::desc("Override target triple for module"));
\end{cpp}
让我们定义目标机型:
\begin{enumerate}
\item
要显示错误消息,必须将应用程序的名称传递给该函数:
\begin{cpp}
llvm::TargetMachine *
createTargetMachine(const char *Argv0) {
\end{cpp}
\item
首先,收集命令行提供的所有信息。这些是代码生成器的选项——CPU的名称和应该激活或停用的功能,以及目标的三个值:
\begin{cpp}
llvm::Triple Triple = llvm::Triple(
!MTriple.empty()
? llvm::Triple::normalize(MTriple)
: llvm::sys::getDefaultTargetTriple());
llvm::TargetOptions TargetOptions =
codegen::InitTargetOptionsFromCodeGenFlags(Triple);
std::string CPUStr = codegen::getCPUStr();
std::string FeatureStr = codegen::getFeaturesStr();
\end{cpp}
\item
然后,必须在目标注册表中查找目标。若发生错误,将显示错误消息并退出。一个可能的错误是,用户指定的目标不支持:
\begin{cpp}
std::string Error;
const llvm::Target *Target =
llvm::TargetRegistry::lookupTarget(
codegen::getMArch(), Triple, Error);
if (!Target) {
llvm::WithColor::error(llvm::errs(), Argv0) << Error;
return nullptr;
}
\end{cpp}
\item
在Target类的帮助下,可以使用用户请求的所有已知选项来配置目标机型:
\begin{cpp}
llvm::TargetMachine *TM = Target->createTargetMachine(
Triple.getTriple(), CPUStr, FeatureStr,
TargetOptions, std::optional(
codegen::getRelocModel()));
return TM;
}
\end{cpp}
\end{enumerate}
有了目标机型实例,就可以生成相应CPU架构的IR代码。目前缺少的是向汇编文本的转换或目标代码文件的生成,我们将在下一节添加这种支持。
\mySubsubsection{4.3.3.}{生成汇编程文本和目标代码}
在LLVM中,IR代码通过通道运行。每次遍历都只执行一项任务,比如移除无效代码。我们将在第7章中了解更多关于通道的信息。输出汇编代码或目标文件也作为通道实现。让我们为它添加基本支持吧!
需要包含更多的LLVM头文件。首先,需要llvm::legacy::PassManager类来保存向文件发出代码的通道。还希望能够输出LLVM IR代码,因此还需要一个通道来生成它。最后,使用llvm::ToolOutputFile类进行文件操作:
\begin{cpp}
#include "llvm/IR/IRPrintingPasses.h"
#include "llvm/IR/LegacyPassManager.h"
#include "llvm/MC/TargetRegistry.h"
#include "llvm/Pass.h"
#include "llvm/Support/ToolOutputFile.h"
\end{cpp}
还需要另一个用于输出LLVM IR的命令行选项:
\begin{cpp}
static llvm::cl::opt EmitLLVM(
"emit-llvm",
llvm::cl::desc("Emit IR code instead of assembler"),
llvm::cl::init(false));
\end{cpp}
最后,给输出文件一个名字:
\begin{cpp}
static llvm::cl::opt
OutputFilename("o",
llvm::cl::desc("Output filename"),
llvm::cl::value_desc("filename"));
\end{cpp}
新emit()方法中的第一个任务是,处理用户在命令行中没有给出的输出文件名。若从标准输入中读取输入,使用减号表示,则将结果输出到标准输出。ToolOutputFile类了解如何处理特殊的文件名:
\begin{cpp}
bool emit(StringRef Argv0, llvm::Module *M,
llvm::TargetMachine *TM,
StringRef InputFilename) {
CodeGenFileType FileType = codegen::getFileType();
if (OutputFilename.empty()) {
if (InputFilename == "-") {
OutputFilename = "-";
}
\end{cpp}
否则,删除输入文件名的可能扩展名,并根据用户给出的命令行选项追加.ll、.s或.o作为扩展名。FileType选项在llvm/CodeGen/CommandFlags.inc头文件中定义,在前面包含了它。这个选项不支持发出IR代码,所以添加了new-emit-llvm选项,只有在与汇编文件类型一起使用时才有效:
\begin{cpp}
else {
if (InputFilename.endswith(".mod"))
OutputFilename =
InputFilename.drop_back(4).str();
else
OutputFilename = InputFilename.str();
switch (FileType) {
case CGFT_AssemblyFile:
OutputFilename.append(EmitLLVM ? ".ll" : ".s");
break;
case CGFT_ObjectFile:
OutputFilename.append(".o");
break;
case CGFT_Null:
OutputFilename.append(".null");
break;
}
}
}
\end{cpp}
一些平台区分文本和二进制文件,所以必须在打开输出文件时提供正确的标志:
\begin{cpp}
std::error_code EC;
sys::fs::OpenFlags OpenFlags = sys::fs::OF_None;
if (FileType == CGFT_AssemblyFile)
OpenFlags |= sys::fs::OF_TextWithCRLF;
auto Out = std::make_unique(
OutputFilename, EC, OpenFlags);
if (EC) {
WithColor::error(llvm::errs(), Argv0)
<< EC.message() << '\n';
return false;
}
\end{cpp}
现在,可以将所需的通道添加到PassManager。TargetMachine类有一个工具方法,用于添加所请求的类,所以只需要检查用户是否请求输出LLVM IR代码即可:
\begin{cpp}
legacy::PassManager PM;
if (FileType == CGFT_AssemblyFile && EmitLLVM) {
PM.add(createPrintModulePass(Out->os()));
} else {
if (TM->addPassesToEmitFile(PM, Out->os(), nullptr,
FileType)) {
WithColor::error(llvm::errs(), Argv0)
<< "No support for file type\n";
return false;
}
}
\end{cpp}
所有这些准备工作完成后,生成文件可以归结为一个函数调用:
\begin{cpp}
PM.run(*M);
\end{cpp}
若没有显式地请求保留该文件,ToolOutputFile类会自动删除该文件。这使得错误处理更容易,因为可能有许多地方需要处理错误。我们成功地生成了代码,所以想保留这个文件:
\begin{cpp}
Out->keep();
\end{cpp}
最后,必须向调用者报告成功与否:
\begin{cpp}
return true;
}
\end{cpp}
使用llvm::Module调用emit()方法(通过调用CodeGenerator类创建了该方法),将按要求发出代码。假设将tinylang中的最大公约数算法存储在Gcd.mod文件中:
\begin{shell}
MODULE Gcd;
PROCEDURE GCD(a, b: INTEGER) : INTEGER;
VAR t: INTEGER;
BEGIN
IF b = 0 THEN
RETURN a;
END;
WHILE b # 0 DO
t := a MOD b;
a := b;
b := t;
END;
RETURN a;
END GCD;
END Gcd.
\end{shell}
为了把这个翻译成Gcd.o目标文件,需要输入以下内容:
\begin{shell}
$ tinylang --filetype=obj Gcd.mod
\end{shell}
若想直接在屏幕上检查生成的IR代码,请输入以下命令:
\begin{shell}
$ tinylang --filetype=asm --emit-llvm -o - Gcd.mod
\end{shell}
以tinylang的当前实现状态,无法在tinylang中创建完整的程序,可以使用一个名为callgcd.c的C程序来测试生成的目标文件。注意,调用GCD函数时已经修改了名称:
\begin{cpp}
#include
extern long _t3Gcd3GCD(long, long);
int main(int argc, char *argv[]) {
printf("gcd(25, 20) = %ld\n", _t3Gcd3GCD(25, 20));
printf("gcd(3, 5) = %ld\n", _t3Gcd3GCD(3, 5));
printf("gcd(21, 28) = %ld\n", _t3Gcd3GCD(21, 28));
return 0;
}
\end{cpp}
要使用clang编译并运行整个应用程序,需要输入以下命令:
\begin{shell}
$ tinylang --filetype=obj Gcd.mod
$ clang callgcd.c Gcd.o -o gcd
$ gcd
\end{shell}
让我们好好庆祝一下吧!至此,通过读取语言源码,并生成了汇编代码或目标文件,我们已经创建了一个完整的编译器。
================================================
FILE: content/part2/chapter4/4.tex
================================================
本章中,了解了如何实现LLVM IR代码的代码生成器,基本块是保存所有指令和表示分支的重要数据结构。了解了如何为源语言的控制语句创建基本块,以及如何向基本块中添加指令。使用一种现代算法来处理函数中的局部变量,从而减少了IR代码。编译器的目标是为输入生成汇编文本或目标文件,因此还添加了一个简单的编译管道。有了这些知识,读者们将能够为自己的语言编译器生成LLVM IR代码和汇编程序文本或目标代码。
下一章中,将了解如何处理聚合数据结构,以及如何确保函数调用符合平台的规则。
================================================
FILE: content/part2/chapter5/0.tex
================================================
现在的高级语言通常使用聚合数据类型和面向对象编程(OOP)结构。LLVM IR对聚合数据类型有一定的支持,而OOP结构(如类)必须自己实现。添加聚合类型会引起传递聚合类型参数的问题。不同的平台有不同的规则(这也会体现在IR上),遵循调用约定还能够调用系统函数。
本章中,将了解如何将聚合数据类型和指针转换为LLVM IR,以及如何以符合系统的方式将参数传递给函数。还将了解如何在LLVM IR中实现类和虚函数。
本章中,将了解以下内容:
\begin{itemize}
\item
处理数组、结构体和指针
\item
正确获取应用程序的二进制接口(ABI)
\item
为类和虚函数创建IR代码
\end{itemize}
本章结束时,将了解为聚合数据类型和OOP构造创建LLVM IR的知识。还将了解如何根据平台的规则,传递聚合数据类型。
================================================
FILE: content/part2/chapter5/1.tex
================================================
本章使用的代码在这里,\url{https://github.com/PacktPublishing/ Learn-LLVM-17/tree/main/Chapter05}。
================================================
FILE: content/part2/chapter5/2.tex
================================================
对于应用程序来说,只有INTEGER这样的基本类型是不够的。要表示矩阵或复数等数学对象,必须在已有数据类型的基础上构造新的数据类型。这些新数据类型通常称为聚合(aggregate)或组合(composite)。
数组是由相同类型的元素组成的序列。在LLVM中,数组总是静态的,所以元素的数量恒定。tinylang类型ARRAY [10] OF INTEGER或C类型long[10]在IR中表示如下:
\begin{shell}
[10 x i64]
\end{shell}
结构是不同类型的组合物。在编程语言中,通常用命名成员表示。例如,在tinylang中,结构可写成RECORD x: REAL; color: INTEGER; y: REAL; END; C语言中同样的结构是struct \{ float x; long color; float y; \};。在LLVM IR中,只列出类型名:
\begin{shell}
{ float, i64, float }
\end{shell}
要访问成员,需要使用数字索引。与数组一样,第一个元素的索引号为0。
该结构的成员按照数据布局字符串中的规范在内存中排列。有关LLVM中数据布局字符串的更多信息,详见第4章。
若有必要,还会插入未使用的填充字节。若需要控制内存布局,可以使用打包结构,其中所有元素都具有1字节对齐方式。在C语言中,可以以下方式利用结构体的\_\_packed\_\_属性:
\begin{cpp}
struct __attribute__((__packed__)) { float x; long long color; float y; }
\end{cpp}
LLVM IR中的语法略有不同:
\begin{shell}
<{ float, i64, float }>
\end{shell}
加载到寄存器中,数组和结构体视为一个单元。例如,不可能将数组值寄存器\%x的单个元素引用为\%x[3]。这是由于SSA形式,不能判断\%x[i]和\%x[j]是否指的是同一个元素。相反,需要特殊的指令来提取和插入单元素值到数组中。要读取第二个元素,需要使用以下指令:
\begin{shell}
%el2 = extractvalue [10 x i64] %x, 1
\end{shell}
也可以更新一个元素,比如第一个元素:
\begin{shell}
%xnew = insertvalue [10 x i64] %x, i64 %el2, 0
\end{shell}
这两个指令也适用于结构体。例如,使用寄存器\%pt访问color成员:
\begin{shell}
%color = extractvalue { float, float, i64 } %pt, 2
\end{shell}
这两条指令都有一个重要的限制:索引必须是一个常量。对于结构体来说,索引号只是名称的替代,而C等语言没有动态计算结构成员名称的概念。对于数组来说,只是无法高效地实现。这两条指令在元素数量较少,且已知的特定情况下都有价值。例如,复数可以建模为两个浮点数组成的数组。传递这个数组是合理的,而且很清楚在计算过程中必须访问数组的哪一部分。
对于前端的一般使用,必须借助于指向内存的指针。LLVM中的所有全局值都用指针表示。下面将全局变量@arr声明为包含8个i64元素的数组,这等价于C语言中的long arr[8]声明:
\begin{shell}
define i64 @second() {
%1 = load i64, ptr getelementptr inbounds ([8 x i64], ptr @arr, i64
0, i64 1)
ret i64 %1
}
\end{shell}
getelementptr指令是地址计算的主力,需要更详细的解释。第一个操作数[8 x i64]是指令所操作的基类型,第二个操作数ptr @arr指定基指针。请注意这里的差别:声明了一个包含八个元素的数组,由于所有全局值都视为指针,所以有一个指向数组的指针。在C的语法中,实际上使用long (*arr)[8]!其结果是,在对元素进行索引之前,必须先对指针解引用,如C语言中的arr[0][1]。第三个操作数i64 0对指针进行解引用,第四个操作数i64 1是元素索引,计算的结果是索引元素的地址。还需要注意的是,此指令不涉及内存存取。
除了结构体,索引参数不需要是常量,可以在循环中使用getelementptr指令来检索数组的元素。这里对结构体的处理有所不同:只能使用常量,且类型必须为i32。
有了这些知识,数组可以很容易地集成到第4章的代码生成器中,要创建这个类型,必须扩展convertType()方法。若Arr变量保存数组的类型标识符,并且假设数组中的元素数量为整数字面值,则可以向convertType()方法添加以下代码来处理数组:
\begin{cpp}
if (auto *ArrayTy =
llvm::dyn_cast(Ty)) {
llvm::Type *Component =
convertType(ArrayTy->getType());
Expr *Nums = ArrayTy->getNums();
uint64_t NumElements =
llvm::cast(Nums)
->getValue()
.getZExtValue();
llvm::Type *T =
llvm::ArrayType::get(Component, NumElements);
// TypeCache is a mapping between the original
// TypeDeclaration (Ty) and the current Type (T).
return TypeCache[Ty] = T;
}
\end{cpp}
该类型可用于声明全局变量。对于局部变量,需要为数组分配内存。我们在过程的第一个基本块中可以这样做:
\begin{cpp}
for (auto *D : Proc->getDecls()) {
if (auto *Var =
llvm::dyn_cast(D)) {
llvm::Type *Ty = mapType(Var);
if (Ty->isAggregateType()) {
llvm::Value *Val = Builder.CreateAlloca(Ty);
// The following method requires a BasicBlock (Curr),
// a VariableDeclation (Var), and an llvm::Value (Val)
writeLocalVariable(Curr, Var, Val);
}
}
}
\end{cpp}
为了读写一个元素,必须生成getelementptr指令,这会添加到emitExpr()(读取值)和emitStmt()(写入值)方法中。要读取数组的元素,首先读取变量的值,再处理变量的选择器。对于每个索引,计算表达式并存储其值。基于这个列表,计算引用元素的地址并加载值:
\begin{cpp}
auto &Selectors = Var->getSelectors();
for (auto I = Selectors.begin(), E = Selectors.end();
I != E; ) {
if (auto *IdxSel =
llvm::dyn_cast(*I)) {
llvm::SmallVector IdxList;
while (I != E) {
if (auto *Sel =
llvm::dyn_cast(*I)) {
IdxList.push_back(emitExpr(Sel->getIndex()));
++I;
} else
break;
}
Val = Builder.CreateInBoundsGEP(Val->getType(), Val, IdxList);
Val = Builder.CreateLoad(
Val->getType(), Val);
}
// . . . Check for additional selectors and handle
// appropriately by generating getelementptr and load.
else {
llvm::report_fatal_error("Unsupported selector");
}
}
\end{cpp}
写入数组元素使用相同的代码,但不生成加载指令,可以使用指针作为存储指令中的目标。对于记录,可以使用类似的方法。记录成员的选择器包含名为Idx的常量字段索引,可把这个常量转换成一个LLVM常量值:
\begin{cpp}
llvm::Value *FieldIdx = llvm::ConstantInt::get(Int32Ty, Idx);
\end{cpp}
然后,在Builder.CreateGEP()方法中使用value,就像在数组中一样。
现在,应该知道如何将聚合数据类型转换为LLVM IR。以符合系统的方式传递这些类型值要非常谨慎,下一节将介绍如何正确地实现。
================================================
FILE: content/part2/chapter5/3.tex
================================================
通过向代码生成器添加数组和记录,有时生成的代码不会按预期执行,原因是我们忽略了平台的调用约定。对于同一个程序或库中的函数如何调用另一个函数,每个平台都定义了自己的规则。ABI文档中总结了这些规则,常见信息包括以下内容:
\begin{itemize}
\item
机器寄存器用于参数传递吗?如果是,是哪些?
\item
数组和结构这样的聚合类型如何传递给函数?
\item
如何处理返回值?
\end{itemize}
某些平台上,聚合类型间接传递,所以类型的副本会放在栈上,只有指向副本的指针作为参数传递。其他平台上,在寄存器中传递一个小的聚集(例如128或256位宽),只有超过该阈值时才使用间接参数传递。有些平台还使用浮点寄存器和向量寄存器来传递参数,而另一些平台则要求浮点值使用整数寄存器传递。
当然,这些都是些底层特性,但它会影响LLVM IR,明明已经在LLVM IR中定义了函数的所有参数类型?!事实证明,这还不够。为了理解这一点,来看一下复数。有些语言内置了复数的数据类型。例如,C99有float \_Complex(以及其他),旧版本的C没有复数类型,但可以定义struct complex \{float re, im;\}并在此类型上创建算术运算。这两种类型都可以映射到LLVM的\{float, float \} IR类型。
若ABI现在声明内置复数类型的值在两个浮点寄存器中传递,但用户定义的聚合总是间接传递,该函数给出的信息,不足以让LLVM决定如何传递这个特定的参数。但需要向LLVM提供更多的信息,而这些信息等级特定于ABI。
有两种方法可以将这些信息指定给LLVM:参数属性和类型重写,这取决于目标平台和代码生成器。最常用的参数属性如下所示:
\begin{itemize}
\item
inreg指定参数在寄存器中传递
\item
byval指定参数按值传递,该参数必须是指针类型。将指向的数据生成一个隐藏副本,并将该指针传递给调用函数。
\item
zeroext和signext指定传递的整数值是零或符号扩展。
\item
sret指定此形参保存一个指向内存的指针,该指针用于从函数返回聚合类型。
\end{itemize}
虽然所有代码生成器都支持zeroext、signext和sret属性,但只有一些支持inreg和byval。可以使用addAttr()将属性添加到函数的参数中。例如,要在参数Arg上设置inreg属性,可以使用以下方式:
\begin{cpp}
Arg->addAttr(llvm::Attribute::InReg);
\end{cpp}
若需要设置多个属性,可以使用llvm::AttrBuilder类。
提供信息的另一种方法是使用类型重写。使用这种方法,可以隐藏原始类型:
\begin{enumerate}
\item
拆分参数。例如,可以传递两个浮点参数,而不是传递一个复杂参数。
\item
将参数转换为不同的表示形式,例如:通过整数寄存器传递浮点值。
\end{enumerate}
要在不改变值位的情况下强制转换类型,可以使用bitcast指令。位转换指令可以操作简单的数据类型,如整数和浮点值。当通过整数寄存器传递浮点值时,必须将该浮点值强制转换为整数。在LLVM中,32位的浮点数表示为float,32位的整数表示为i32。可以通过以下方式将浮点值转换为整数:
\begin{shell}
%intconv = bitcast float %fp to i32
\end{shell}
此外,位转换指令要求两种类型具有相同的大小。
向参数添加属性或更改类型并不复杂,但如何知道需要实现什么呢?首先,应该大致了解目标平台上使用的调用约定。例如,Linux上的ELF ABI针对每种支持的CPU平台都有文档,可以查找文档并熟悉相关信息。
还有关于LLVM代码生成器需求的文档。信息的来源是clang实现,可以在\url{https://github.com/llvm/llvm-project/blob/main/clang/lib/CodeGen/TargetInfo.cpp}上找到。这个文件包含针对所有受支持平台的ABI操作,也是收集信息的地方。
本节中,了解了如何为函数调用生成与平台的ABI兼容的IR。下一节将为生成类和虚函数的IR创建不同的方法。
================================================
FILE: content/part2/chapter5/4.tex
================================================
许多现代编程语言使用类支持面向对象,类是高级语言结构。本节中,我们将探讨如何将类结构映射到LLVM IR中。
\mySubsubsection{5.4.1.}{实现单继承}
类是数据和方法的集合。一个类可以从另一个类继承,可能会添加更多的数据字段和方法,或者覆盖现有的虚函数。我们用Oberon-2中的类来说明这一点,Oberon-2也是一个很好的tinylang模型。Shape类定义了一个具有颜色和面积的抽象类型:
\begin{shell}
TYPE Shape = RECORD
color: INTEGER;
PROCEDURE (VAR s: Shape) GetColor(): INTEGER;
PROCEDURE (VAR s: Shape) Area(): REAL;
END;
\end{shell}
GetColor方法只返回颜号:
\begin{shell}
PROCEDURE (VAR s: Shape) GetColor(): INTEGER;
BEGIN RETURN s.color; END GetColor;
\end{shell}
抽象形状的面积无法计算,所以这是一种抽象的方法:
\begin{shell}
PROCEDURE (VAR s: Shape) Area(): REAL;
BEGIN HALT; END;
\end{shell}
Shape类型可以扩展为表示Circle类:
\begin{shell}
TYPE Circle = RECORD (Shape)
radius: REAL;
PROCEDURE (VAR s: Circle) Area(): REAL;
END;
\end{shell}
对于一个圆,面积则可以计算:
\begin{shell}
PROCEDURE (VAR s: Circle) Area(): REAL;
BEGIN RETURN 2 * radius * radius; END;
\end{shell}
该类型也可以在运行时查询。若形状是Shape类型的变量,则可以这样制定类型测试:
\begin{shell}
IF shape IS Circle THEN (* … *) END;
\end{shell}
除了语法不同之外,其工作原理与C++非常相似。但显著区别是Oberon-2语法显式声明了this指针,将其称为方法的接收方。
要解决的基本问题是如何在内存中布局类,如何实现方法的动态调用和运行时类型检查。对于内存布局,Shape类只有一个数据成员,可以将其映射到相应的LLVM结构类型:
\begin{shell}
@Shape = type { i64 }
\end{shell}
Circle类添加了另一个数据成员,解决方案是在末尾附加新的数据成员:
\begin{shell}
@Circle = type { i64, float }
\end{shell}
原因是一个类可以有很多子类。使用这种策略,公共基类的数据成员始终具有相同的内存偏移量,并且使用相同的索引通过getelementptr指令访问相应的字段。
为了实现方法的动态调用,必须进一步扩展LLVM结构。若在Shape对象上调用Area()函数,则调用抽象方法,从而导致应用程序停止。若在Circle对象上调用,则调用相应的方法来计算圆的面积。另一方面,可以为这两个类的对象调用GetColor()函数。
实现这一点的基本思想是,将一个表与每个对象的函数指针关联起来。这里,表有两个信息:一个用于GetColor()方法,另一个用于Area()函数。Shape类和Circle类都有这样一个表。这些表在Area()函数的条目中有所不同,该函数根据对象的类型调用不同的代码。这个表称为虚函数表,通常缩写为vtable。
虚函数表本身没有用,必须把它和一个对象关联起来,总是添加指向虚函数表的指针,作为该结构的第一个数据成员。在LLVM级别,@Shape类型是这样的:
\begin{shell}
@Shape = type { ptr, i64 }
\end{shell}
@Circle类型也进行了类似的扩展。
得到的内存结构如图5.1所示:
\myGraphic{0.7}{content/part2/chapter5/images/1.png}{图5.1 - 类和虚函数表的内存布局}
LLVM IR中,Shape类的虚函数表可以可视化为如下表示,其中两个指针对应于GetColor()和GetArea()方法,如图5.1所示:
\begin{shell}
@ShapeVTable = constant { ptr, ptr } { GetColor(), Area() }
\end{shell}
此外,LLVM没有void指针,而是使用指向字节的指针。随着隐藏虚值表字段的引入,现在还需要有一种方法来初始化它。C++中,这是调用构造函数的一部分。在Oberon-2中,该字段在分配内存时自动初始化。
然后,通过以下步骤执行对方法的动态调用:
\begin{enumerate}
\item
通过getelementptr指令计算虚函数表指针的偏移量。
\item
加载指向虚参表的指针。
\item
计算函数在虚函数表中的偏移量。
\item
加载函数指针。
\item
使用调用指令通过指针间接调用函数。
\end{enumerate}
还可以在LLVM IR中可视化对虚拟方法(如Area())的动态调用。首先,从Shape类的相应指定位置加载一个指针。下面的load表示将指针加载到实际的虚函数表中,以便形成函数:
\begin{shell}
// Load a pointer from the corresponding location.
%ptrToShapeObj = load ptr, ...
// Load the first element of the Shape class.
%vtable = load ptr, ptr %ptrToShapeObj, align 8
\end{shell}
之后,getelementptr获取偏移量以调用Area():
\begin{shell}
%offsetToArea = getelementptr inbounds ptr, ptr %vtable, i64 1
\end{shell}
然后,加载指向Area()的函数指针:
\begin{shell}
%ptrToAreaFunction = load ptr, ptr %offsetToArea, align 8
\end{shell}
最后,通过指针调用Area()函数:
\begin{shell}
%funcCall = call noundef float %ptrToAreaFunction(ptr noundef
nonnull align 8 dereferenceable(12) %ptrToShapeObj)
\end{shell}
即使在单继承的情况下,生成的LLVM IR也可能看起来非常冗长。尽管生成对方法的动态调用过程看起来效率不高,但大多数CPU架构只需两条指令就可以执行该动态调用。
此外,要将函数转换为方法,还需要对对象数据的引用,需要通过将指向数据的指针作为方法的第一个参数来实现的。在Oberon-2中,这是明确的接收者。在类似C++的语言中,是隐式this指针。
使用虚函数表,每个类在内存中都有唯一地址。这对运行时类型测试也有帮助吗?是的,但帮助有限。为了说明这个问题,用一个继承自Circle类的Ellipse类来扩展类层次,这不是数学意义上的is-a关系。
若有一个Shape类型的shape变量,则可以实现shape IS Circle类型的测试,将shape变量中存储的虚表指针与Circle类的虚表指针进行比较。只有当shape具有确切的Circle类型时,这种比较才会为true。但若shape是Ellipse类型,则比较返回false。但在需要Circle类型对象的所有地方,都可以使用Ellipse类型的对象。
解决方案是使用运行时类型信息扩展虚函数表,需要存储多少信息取决于源语言。为了支持运行时类型检查,存储一个指向基类虚函数表的指针就足够了,如图5.2所示:
\myGraphic{0.8}{content/part2/chapter5/images/2.png}{图5.2 - 支持简单类型测试的类和虚函数表布局}
若测试失败,则使用指向基类的虚函数表的指针重复测试。重复此操作,直到测试结果为true;若没有基类,则为false。与调用动态函数相比,类型测试是一个代价高昂的操作,最坏的情况下,继承层次结构可以向上回溯到根类。
若了解整个类层次结构,就有一种有效的方法:按照深度优先的顺序,对类层次结构的每个成员进行编号。类型测试变成了对一个数字或一个间隔进行比较,这可以在恒定的时间内完成。事实上,这就是LLVM自己的运行时类型测试的方法,我们在前一章中已经了解过了。
将运行时类型信息与虚函数表耦合是一种设计决策,要么是源语言强制要求的,要么只是作为实现细节。例如,语言在运行时支持反射,所以需要详细的运行时类型信息,并且数据类型没有vtable,将两者耦合就不是一个好主意。在C++中,耦合会导致具有虚函数(因此没有虚函数表)的类,没有运行时的类型数据。
通常,编程语言支持的接口是虚拟方法的集合。接口很重要,提供了有用的抽象。我们将在下一节中查看接口的可能实现方式。
\mySubsubsection{5.4.2.}{使用接口扩展单继承}
Java等语言支持接口,接口是抽象方法的集合,类似于没有数据成员、只定义了抽象方法的基类。接口带来了一个有趣的问题,实现接口的每个类,都可以在虚函数表中的不同位置拥有相应的方法。原因很简单,虚函数表中函数指针的顺序,是从语言源码中类定义中的函数顺序派生出来的。接口的定义与此无关,不同的顺序是标准。
接口中定义的方法可以有不同的顺序,可以将每个实现的接口的表附加到类中。对于接口的每个方法,这个表可以指定该方法在虚函数表中的索引,也可以指定存储在虚函数表中的函数指针的副本。若在接口上调用方法,则搜索接口对应的虚函数表,获取指向该函数的指针,并调用该方法。在Shape类中添加两个I1和I2接口会产生以下布局:
\myGraphic{0.7}{content/part2/chapter5/images/3.png}{图5.3 - 接口虚函数表的布局}
需要注意的是,必须找到合适的表。可以使用类似于运行时类型测试的方法:在接口虚函数表中执行线性搜索。可以为每个接口分配唯一编号(例如,一个内存地址),并使用这个编号来标识这个虚函数表。这种方案的缺点是显而易见的:通过接口调用方法比在类上调用相同的方法要花费更多的时间,但要解决这个问题并不容易。
一个好的方法是用哈希表代替线性搜索。在编译时,类实现的接口是已知的,以可以构造一个完美的哈希函数,将接口编号映射到接口的虚函数表。在构造过程中可能需要标识接口的唯一编号,还有其他方法可以计算唯一编号。若源码中的符号名称唯一,则可以计算该符号的加密哈希(例如MD5),并使用该哈希作为数字。计算在编译时进行,没有运行时成本。
结果比线性搜索快得多,只需要常数时间。不过,会涉及数的几个算术操作,比类类型方法调用要慢。
通常,接口也参与运行时类型测试,使列表搜索更长。若实现了哈希表方法,也可以用于运行时类型测试。
有些语言允许有多个父类。这对实现有一些有趣的挑战,我们将在下一节中了解这些。
\mySubsubsection{5.4.3.}{增加对多重继承的支持}
多重继承增加了另一个挑战。若一个类继承自两个或多个基类,则需要以一种仍然可以从方法访问的方式组合数据成员。与单继承情况类似,解决方案是附加所有数据成员,包括隐藏的虚函数表指针。
Circle类不仅是一个几何形状,而且是一个图形对象。为了对此建模,让Circle类继承Shape类和GraphicObj类。类布局中,Shape类中的字段首先出现,再追加GraphicObj类的所有字段,包括隐藏的虚函数表指针。之后,添加Circle类的新数据成员,得到如图5.4所示的整体结构:
\myGraphic{0.7}{content/part2/chapter5/images/4.png}{图5.4 - 具有多重继承的类和变量的布局}
这种方法有几个含义,可以有多个指向对象的指针。指向Shape或Circle类的指针指向对象的顶部,而指向GraphicObj类的指针指向该对象内部,即嵌入的GraphicObj对象的开始,在比较指针时必须考虑到这一点。
调用虚拟方法也会受到影响,若在GraphicObj类中定义了一个方法,则这个方法需要GraphicObj类的类布局。若在Circle类中没有重写此方法,则有两种可能。若方法调用通过指向GraphicObj实例的指针完成,可以在GraphicObj类的vtable中查找方法的地址并调用该函数。更复杂的情况是,若使用指向Circle类的指针调用该方法,可以在Circle类的虚函数表中查找方法的地址。调用的方法期望this指针是GraphicObj类的一个实例,所以也必须调整那个指针。因为我们知道GraphicObj类在Circle类中的偏移量,所以可以这样做。
若在Circle类中重写了GrapicObj方法,则通过指向Circle类的指针调用该方法,不需要做什么特殊操作。但若该方法是通过指向GraphicObj实例的指针调用,因为该方法需要一个指向Circle实例的this指针,就需要做一些调整。在编译时,因为不知道这个GraphicObj实例是否是多重继承层次结构的一部分,所以无法了解需要调整的部分。为了解决这个问题,我们将再调用方法之前,对this指针进行调整,并将每个函数指针一起存储在虚函数表中,如图5.5所示:
\myGraphic{0.7}{content/part2/chapter5/images/5.png}{图5.5 - vtable与this指针的调整}
一个方法调用现在的过程:
\begin{enumerate}
\item
在虚函数表中查找函数指针。
\item
调整this指针。
\item
调用该方法。
\end{enumerate}
这种方法也可用于实现接口。由于接口只有方法,所以每个实现的接口都会向对象添加一个新的虚函数表指针。这更容易实现,而且很可能更快,但每个对象实例增加了开销。
最坏的情况下,若类有一个64位的数据字段,但是实现了10个接口,则对象需要96字节的内存:8字节用于类本身的虚表指针,8字节用于数据成员,10 * 8字节用于每个接口的虚表指针。
为了支持对对象进行有意义的比较并执行运行时类型测试,需要首先规范指向对象的指针。若在虚函数表中添加一个字段,包含对象顶部的偏移量,则可以调整指针以指向实际对象。在Circle类的实值表中,该偏移量为0,但在嵌入GraphicObj类的实值表中则不是,这是否需要实现取决于源码语言的语义。
LLVM本身并不支持面向对象特性的特殊实现。如本节所示,可以使用可用的LLVM数据类型实现所有方法。正如具有单继承的LLVM IR示例一样,当涉及多继承时,IR可能会变得更加冗长。若想尝试一种新方法,一个好方法就是先用C语言做一个原型。所需的指针操作很快翻译成LLVM IR,但在高级语言中对功能的推理会更容易。
利用本节中获得的知识,可以在自己的代码生成器中实现将编程语言中常见的所有OOP结构简化为LLVM IR。现在,已经掌握了如何表示单继承、使用接口的单继承或内存中的多重继承,以及如何实现类型测试和如何查找虚拟函数,这些都是OOP语言的核心概念。
================================================
FILE: content/part2/chapter5/5.tex
================================================
在本章中,了解了如何将聚合数据类型和指针转换为LLVM IR代码,应用程序二进制接口的复杂性,将类和虚函数转换为LLVM IR的不同方法。有了本章的知识,您将能够为编程语言创建一个LLVM IR代码生成器。
下一章中,将了解一些有关IR生成的高级技术。异常处理在现代编程语言中相当常见,LLVM对此提供了一些支持。将类型信息添加到指针可以进行某些优化。最后,对许多开发人员来说,调试应用程序的能力必不可少,因此还需要在代码生成器中添加功能,以生成调试元数据。
================================================
FILE: content/part2/chapter6/0.tex
================================================
通过前面章节中介绍的IR生成,已经可以实现编译器中所需的大部分功能。本章中,将研究一些编译器中经常出现的高级主题。例如,许多现代语言都使用异常处理,将研究如何将其转换为LLVM IR。
为了支持LLVM优化器,使其能够在某些情况下生成更好的代码,必须向IR代码添加类型的元数据。添加的调试元数据,可使编译器用户能够使用源代码级调试工具进行调试。
本章中,将了解以下内容:
\begin{itemize}
\item
抛出和捕获异常:如何在编译器中实现异常处理
\item
为基于类型的别名分析生成元数据:将更多的元数据到LLVM IR,有助于LLVM更好地优化代码
\item
添加调试元数据:将实现向生成的IR代码添加调试信息所需的类
\end{itemize}
本章结束时,将了解异常处理,以及用于基于类型的别名分析和调试信息的元数据。
================================================
FILE: content/part2/chapter6/1.tex
================================================
LLVM IR 中的异常处理与平台支持密切相关,将使用libunwind来了解最常见的异常处理类型。C++可以充分发挥libunwind的潜力,先来看一个C++示例,其中bar()函数可以抛出一个int或double值:
\begin{cpp}
int bar(int x) {
if (x == 1) throw 1;
if (x == 2) throw 42.0;
return x;
}
\end{cpp}
foo()函数调用bar(),但只处理抛出的int值,并且还声明只抛出int值:
\begin{cpp}
int foo(int x) {
int y = 0;
try {
y = bar(x);
}
catch (int e) {
y = e;
}
return y;
}
\end{cpp}
抛出异常需要两次调用运行时库,可以在bar()函数中看到。\_\_cxa\_allocate\_exception()为异常分配内存,这个函数接受要分配的字节数作为参数。异常负载(本例中的int或double值)被复制到分配的内存中,再通过\_\_cxa\_throw()引发异常。这个函数接受三个参数:指向已分配异常的指针、有关有效负载的类型信息,以及指向析构函数的指针(若异常有有效负载有的话)。\_\_cxa\_throw()函数启动堆栈展开过程,并且永远不会返回。LLVM IR中,对int值执行:
\begin{shell}
%eh = call ptr @__cxa_allocate_exception(i64 4)
store i32 1, ptr %eh
call void @__cxa_throw(ptr %eh, ptr @_ZTIi, ptr null)
unreachable
\end{shell}
\_ZTIi是描述int类型的类型信息。对于double类型,则为\_ZTId。
目前,还没有做特定于LLVM的工作。这一点在foo()函数中发生了变化,对bar()的调用可能引发异常。若是int类型异常,控制流必须转移到catch的IR代码。所以,必须使用invoke指令,而非call指令:
\begin{shell}
%y = invoke i32 @_Z3bari(i32 %x) to label %next
unwind label %lpad
\end{shell}
这两条指令的区别在于,invoke有两个相关联的标签。在函数正常结束时,第一个标号表示执行继续,通常以ret指令结束。示例代码中,这个标签名为\%next。若发生异常,则在标号为\%lpad的“着陆垫”上继续执行。
著陆台是一个基本的块,必须以landingpad指令开始。landingpad指令向LLVM提供有关处理过的异常类型的信息。例如,一个着陆台可能是这样的:
\begin{shell}
lpad:
%exc = landingpad { ptr, i32 }
cleanup
catch ptr @_ZTIi
filter [1 x ptr] [ptr @_ZTIi]
\end{shell}
这里有三种可能的操作类型:
\begin{itemize}
\item
cleanup:表示存在用于清理当前状态的代码,用于调用局部对象的析构函数。若此标记存在,则在堆栈展开期间调用着陆垫指令。
\item
catch:是类型-值对的列表,表示可以处理的异常类型。若在此列表中找到抛出的异常类型,则调用着陆垫指令。在foo()函数的情况下,该值是指向int类型的C++运行时类型信息的指针,类似于\_\_cxa\_throw()函数的参数。
\item
filter:指定一个异常类型数组。若在数组中找不到当前异常的异常类型,则调用着陆垫指令,用于实现throw()规范。对于foo()函数,数组只有一个成员——int类型的类型信息。
\end{itemize}
着陆平垫指令的结果类型是\{ptr, i32 \}结构。第一个元素是指向抛出异常的指针,第二个元素是类型选择器。从结构体中进行提取:
\begin{shell}
%exc.ptr = extractvalue { ptr, i32 } %exc, 0
%exc.sel = extractvalue { ptr, i32 } %exc, 1
\end{shell}
类型选择器是一个数字,可以帮助确定为什么要调用着陆垫指令。若当前异常类型与landingpad指令的catch部分给出的异常类型匹配,则该值为正值。若当前异常类型不匹配过滤部分给出的值,则值为负。若应该调用清理代码,则为0。
类型选择器是类型信息表的偏移量,由着陆垫指令的catch和filter部分给出的值构造而成。优化过程中,多个着陆垫指令可以合并为一个,所以在IR级别上该表的结构是未知的。要检索给定类型的类型选择器,需要调用内部的@llvm.eh.typeid.for的函数。需要它来检查类型选择器值是否与int的类型信息相对应,以便执行catch (int e) \{\}块中的代码:
\begin{shell}
%tid.int = call i32 @llvm.eh.typeid.for(ptr @_ZTIi)
%tst.int = icmp eq i32 %exc.sel, %tid.int
br i1 %tst.int, label %catchint, label %filterorcleanup
\end{shell}
异常的处理是通过调用\_\_cxa\_begin\_catch()和\_\_cxa\_end\_catch()实现的。\_\_cxa\_begin\_catch()函数需要一个参数——当前异常——是着陆垫指令返回的值之一,返回一个指向异常负载的指针——本例中是int值。
\_\_cxa\_end\_catch()函数标志着异常处理的结束,并释放用\_\_cxa\_allocate\_exception()分配的内存。注意,若在catch块中抛出另一个异常,则运行时行为会复杂得多。异常处理方法如下:
\begin{shell}
catchint:
%payload = call ptr @__cxa_begin_catch(ptr %exc.ptr)
%retval = load i32, ptr %payload
call void @__cxa_end_catch()
br label %return
\end{shell}
若当前异常的类型与throws()声明中的列表不匹配,则调用意外异常处理程序,需要再次检查类型选择器:
\begin{shell}
filterorcleanup:
%tst.blzero = icmp slt i32 %exc.sel, 0
br i1 %tst.blzero, label %filter, label %cleanup
\end{shell}
若类型选择器的值小于0,则调用该处理程序:
\begin{shell}
filter:
call void @__cxa_call_unexpected(ptr %exc.ptr) #4
unreachable
\end{shell}
同样,该处理程序也不会返回。
这种情况下不需要清理,所有清理代码要做的就是恢复堆栈展开的执行:
\begin{shell}
cleanup:
resume { ptr, i32 } %exc
\end{shell}
libunwind驱动堆栈展开过程,但没有绑定到语言,依赖于语言的处理在个性函数中完成。对于Linux上的C++,个性函数(personality function)称为\_\_gxx\_personality\_v0()。根据平台或编译器的不同,这个名称可能有所不同。每个需要参与堆栈展开的函数都有一个个性函数。此个性函数分析函数是否捕获异常、是否具有不匹配的筛选器列表或是否需要清理调用,将这些信息返回给解卷器,解卷器采取相应的行动。在LLVM IR中,个性函数的指针是作为函数定义的一部分给出的:
\begin{shell}
define i32 @_Z3fooi(i32) personality ptr @__gxx_personality_v0
\end{shell}
至此,异常处理工具就完成了。
要在编译器中为编程语言使用异常处理,最简单的策略是利用现有的C++运行时函数。这还有一个好处,就是异常可以与C++互操作。缺点是将一些C++运行时绑定到语言的运行时中,尤其是内存管理。若想避免这种情况,则需要创建自己的\_cxa\_函数。不过,想必还是会想要使用libunwind,因为它提供了堆栈解旋机制:
\begin{enumerate}
\item
来看看如何创建这个IR,在第2章中创建了calc表达式编译器。将扩展表达式编译器的代码生成器,以便在执行除零操作时引发并处理异常。生成的IR将检查除法的除数是否为0。若为true,则会引发异常。还将在函数中添加一个着陆垫指令,用来捕获异常并打印Divide by 0 !到控制台,并结束计算。这个简单的例子中,不需要使用异常处理,但可以让专注于代码生成过程。必须将所有代码添加到CodeGen.cpp文件中,首先添加所需的新字段和一些辅助方法,需要存储\_\_cxa\_allocate\_exception()和\_\_cxa\_throw()函数的LLVM声明,其由函数类型和函数本身组成。需要一个GlobalVariable实例来保存类型信息,还需要引用控制着陆垫指令的基本块和包含不可达指令的基本块:
\begin{cpp}
GlobalVariable *TypeInfo = nullptr;
FunctionType *AllocEHFty = nullptr;
Function *AllocEHFn = nullptr;
FunctionType *ThrowEHFty = nullptr;
Function *ThrowEHFn = nullptr;
BasicBlock *LPadBB = nullptr;
BasicBlock *UnreachableBB = nullptr;
\end{cpp}
\item
还要添加一个新辅助函数,来比较两个值的IR。createICmpEq()函数接受Left和Right值作为参数进行比较,创建一个比较指令来测试值是否相等,并为两个基本块创建一个分支指令,用于相等和不相等的情况,这两个基本块通过TrueDest和FalseDest参数中的引用返回。此外,新基本块的标签可以在TrueLabel和FalseLabel参数中给出:
\begin{cpp}
void createICmpEq(Value *Left, Value *Right,
BasicBlock *&TrueDest,
BasicBlock *&FalseDest,
const Twine &TrueLabel = "",
const Twine &FalseLabel = "") {
Function *Fn =
Builder.GetInsertBlock()->getParent();
TrueDest = BasicBlock::Create(M->getContext(),
TrueLabel, Fn);
FalseDest = BasicBlock::Create(M->getContext(),
FalseLabel, Fn);
Value *Cmp = Builder.CreateCmp(CmpInst::ICMP_EQ,
Left, Right);
Builder.CreateCondBr(Cmp, TrueDest, FalseDest);
}
\end{cpp}
\item
要使用运行时中的函数,需要创建几个函数声明。在LLVM中,函数类型给出了签名,并且必须构造函数本身,使用createFunc()方法创建这两个对象。函数需要引用FunctionType和Function指针、新声明函数的名称和结果类型。参数类型列表是可选的,变量参数列表标志设置为false,表示参数列表中没有变量部分:
\begin{cpp}
void createFunc(FunctionType *&Fty, Function *&Fn,
const Twine &N, Type *Result,
ArrayRef Params = None,
bool IsVarArgs = false) {
Fty = FunctionType::get(Result, Params, IsVarArgs);
Fn = Function::Create(
Fty, GlobalValue::ExternalLinkage, N, M);
}
\end{cpp}
\end{enumerate}
完成这些准备工作后,就可以生成抛出异常的IR了。
\mySubsubsection{6.1.1.}{抛出异常}
为了生成引发异常的IR代码,将添加addThrow()方法。这个新方法需要初始化新字段,然后通过\_\_cxa\_throw()函数生成引发异常的IR。所引发异常的有效负载为int类型,可以设置为任意值。下面是需要编写的代码:
\begin{enumerate}
\item
新的addThrow()方法首先检查TypeInfo字段是否已初始化。若还没有初始化,则会创建一个i8指针类型的全局外部常量\_ZTIi。这代表了描述C++ int类型的元数据:
\begin{cpp}
void addThrow(int PayloadVal) {
if (!TypeInfo) {
TypeInfo = new GlobalVariable(
*M, Int8PtrTy,
/*isConstant=*/true,
GlobalValue::ExternalLinkage,
/*Initializer=*/nullptr, "_ZTIi");
\end{cpp}
\item
初始化继续使用辅助createFunc()方法为\_\_cxa\_allocate\_exception()和\_\_cxa\_throw()函数创建IR声明:
\begin{cpp}
createFunc(AllocEHFty, AllocEHFn,
"__cxa_allocate_exception", Int8PtrTy,
{Int64Ty});
createFunc(ThrowEHFty, ThrowEHFn, "__cxa_throw",
VoidTy,
{Int8PtrTy, Int8PtrTy, Int8PtrTy});
\end{cpp}
\item
使用异常处理的函数需要一个个性函数,有助于堆栈展开。添加了IR代码来声明C++库中的\_\_gxx\_personality\_v0()个性函数,并将其设置为当前函数的个性例程。当前函数没有存储为一个字段,可以使用Builder实例来查询当前的基本块,其将函数存储为Parent字段:
\begin{cpp}
FunctionType *PersFty;
Function *PersFn;
createFunc(PersFty, PersFn,
"__gxx_personality_v0", Int32Ty, std::nulopt,
true);
Function *Fn =
Builder.GetInsertBlock()->getParent();
Fn->setPersonalityFn(PersFn);
\end{cpp}
\item
接下来,必须创建并填充着陆台指令的基本块。首先,需要保存指向当前基本块的指针,再创建一个新的基本块,在构建器中进行设置,以便可以用作插入指令的基本块,并调用addLandingPad()方法。该方法生成用于处理异常的IR代码,将在下一节捕获异常中进行描述。这段代码填充了着陆垫指令的基本块:
\begin{cpp}
BasicBlock *SaveBB = Builder.GetInsertBlock();
LPadBB = BasicBlock::Create(M->getContext(),
"lpad", Fn);
Builder.SetInsertPoint(LPadBB);
addLandingPad();
\end{cpp}
\item
初始化部分通过创建保存不可达指令的基本块来完成,并创建基本块并将其设置为构建器中的插入点。然后,可以将不可达指令添加到其中,再将构建器的插入点设置回已保存的SaveBB实例,以便将以下IR添加到正确的基本块中:
\begin{cpp}
UnreachableBB = BasicBlock::Create(
M->getContext(), "unreachable", Fn);
Builder.SetInsertPoint(UnreachableBB);
Builder.CreateUnreachable();
Builder.SetInsertPoint(SaveBB);
}
\end{cpp}
\item
要抛出异常,通过调用\_\_cxa\_allocate\_exception()函数为异常和负载分配内存。我们的有效负载是C++ int类型,通常大小为4字节,再为size创建一个常量无符号值,并将其作为参数调用函数。函数类型和函数声明已经初始化,所以只需要创建调用指令:
\begin{cpp}
Constant *PayloadSz =
ConstantInt::get(Int64Ty, 4, false);
CallInst *EH = Builder.CreateCall(
AllocEHFty, AllocEHFn, {PayloadSz});
\end{cpp}
\item
接下来,将PayloadVal值存储在分配的内存中,需要通过调用ConstantInt::get()函数创建一个LLVM IR常量。所述指向所分配内存的指针为i8指针类型;为了存储i32类型的值,需要创建一个bitcast指令来强制转换该类型:
\begin{cpp}
Value *PayloadPtr =
Builder.CreateBitCast(EH, Int32PtrTy);
Builder.CreateStore(
ConstantInt::get(Int32Ty, PayloadVal, true),
PayloadPtr);
\end{cpp}
\item
最后,必须通过调用\_\_cxa\_throw()函数来抛出异常。由于这个函数引发了一个异常,该异常也在同一个函数中处理,所以需要使用invoke指令,而非call指令。与调用指令不同,因为它有两个后继基本块,所以调用指令会结束一个基本块,这些是UnreachableBB和LPadBB基本块。若函数没有引发异常,控制流将转移到UnreachableBB基本块。由于\_\_cxa\_throw()函数的设计,因为控制流会转移到LPadBB基本块来处理异常,所以这种情况永远不会发生。这样就完成了addThrow()方法的实现:
\begin{cpp}
Builder.CreateInvoke(
ThrowEHFty, ThrowEHFn, UnreachableBB, LPadBB,
{EH,
ConstantExpr::getBitCast(TypeInfo, Int8PtrTy),
ConstantPointerNull::get(Int8PtrTy)});
}
\end{cpp}
\end{enumerate}
接下来,将添加代码来生成处理异常的IR。
\mySubsubsection{6.1.2.}{捕捉异常}
要生成捕获异常的IR代码,必须添加addLandingPad()方法。生成的IR从异常中提取类型信息。若匹配C++ int类型,则通过打印Divide by 0!到控制台来处理异常并从函数中返回。若类型不匹配,只需执行resume指令,该指令将控制转移回运行时。由于调用层次结构中没有其他函数来处理此异常,因此将终止应用程序的运行。以下步骤描述了生成用于捕获异常的IR:
\begin{enumerate}
\item
生成的IR中,需要从C++运行时库中调用\_\_cxa\_begin\_catch()和\_\_cxa\_end\_catch()函数。为了输出错误消息,将从C运行时库生成对puts()函数的调用。此外,为了从异常中获取类型信息,必须生成对llvm.eh.typeid.for指令的调用。还需要FunctionType和Function实例,我们将利用createFunc()方法来创建它们:
\begin{cpp}
void addLandingPad() {
FunctionType *TypeIdFty; Function *TypeIdFn;
createFunc(TypeIdFty, TypeIdFn,
"llvm.eh.typeid.for", Int32Ty,
{Int8PtrTy});
FunctionType *BeginCatchFty; Function *BeginCatchFn;
createFunc(BeginCatchFty, BeginCatchFn,
"__cxa_begin_catch", Int8PtrTy,
{Int8PtrTy});
FunctionType *EndCatchFty; Function *EndCatchFn;
createFunc(EndCatchFty, EndCatchFn,
"__cxa_end_catch", VoidTy);
FunctionType *PutsFty; Function *PutsFn;
createFunc(PutsFty, PutsFn, "puts", Int32Ty,
{Int8PtrTy});
\end{cpp}
\item
着陆台指令是我们生成的第一条指令,结果类型是一个包含i8指针和i32类型字段的结构体。这个结构是通过调用StructType::get()函数生成的。此外,由于需要处理C++ int类型的异常,还需要将this作为子句添加到landingpad指令中,该指令必须是i8指针类型的常量,所以需要生成一个位转换指令来将TypeInfo值转换为这种类型,再将指令返回的值存储在Exc变量中,供后续使用:
\begin{cpp}
LandingPadInst *Exc = Builder.CreateLandingPad(
StructType::get(Int8PtrTy, Int32Ty), 1, "exc");
Exc->addClause(
ConstantExpr::getBitCast(TypeInfo, Int8PtrTy));
\end{cpp}
\item
接下来,从返回值中提取类型选择器。调用llvm.eh.typeid.for指令,检索TypeInfo字段的类型ID,表示C++ int类型。使用这个IR,已经生成了两个值。我们需要进行比较,以决定是否可以处理异常:
\begin{cpp}
Value *Sel =
Builder.CreateExtractValue(Exc, {1}, "exc.sel");
CallInst *Id =
Builder.CreateCall(TypeIdFty, TypeIdFn,
{ConstantExpr::getBitCast(
TypeInfo, Int8PtrTy)});
\end{cpp}
\item
要生成用于比较的IR,必须调用createICmpEq()函数。这个函数还生成了两个基本块,将它们存储在TrueDest和FalseDest变量中:
\begin{cpp}
BasicBlock *TrueDest, *FalseDest;
createICmpEq(Sel, Id, TrueDest, FalseDest, "match",
"resume");
\end{cpp}
\item
若这两个值不匹配,控制流将在false基本块处继续。这个基本块只包含一个resume指令,将控制权交还给C++运行时:
\begin{cpp}
Builder.SetInsertPoint(FalseDest);
Builder.CreateResume(Exc);
\end{cpp}
\item
若这两个值相等,则控制流在TrueDest基本块处继续。生成IR代码,以便从存储在Exc变量中的着陆平台指令的返回值中,提取指向异常的指针。生成对\_\_cxa\_begin\_catch()函数的调用,将指向异常的指针作为参数传递。这表示在运行时开始处理异常:
\begin{cpp}
Builder.SetInsertPoint(TrueDest);
Value *Ptr =
Builder.CreateExtractValue(Exc, {0}, "exc.ptr");
Builder.CreateCall(BeginCatchFty, BeginCatchFn,
{Ptr});
\end{cpp}
\item
再用puts()函数来处理异常,将消息输出到控制台,这里通过调用CreateGlobalStringPtr()函数生成一个指向该字符串的指针,在生成的调用中将该指针作为参数传递给puts()函数:
\begin{cpp}
Value *MsgPtr = Builder.CreateGlobalStringPtr(
"Divide by zero!", "msg", 0, M);
Builder.CreateCall(PutsFty, PutsFn, {MsgPtr});
\end{cpp}
\item
现在已经处理了异常,必须生成对\_\_cxa\_end\_catch()函数的调用,以通知运行时有关它的信息。最后,从函数返回一个aret指令:
\begin{cpp}
Builder.CreateCall(EndCatchFty, EndCatchFn);
Builder.CreateRet(Int32Zero);
}
\end{cpp}
\end{enumerate}
使用addThrow()和addLandingPad()函数,可以生成引发异常和处理异常的IR,但仍需要添加IR来检查除数是否为0。
我们将在下一节中讨论这一点。
\mySubsubsection{6.1.3.}{将异常处理代码集成到应用程序中}
除法的IR是在visit(BinaryOp \&)方法中生成的,必须生成一个IR来将除数与0进行比较,而不是仅仅生成一个sdiv指令。若除数为0,则控制流继续在基本块中运行,从而引发异常;否则,控制流将在带有sdiv指令的基本块中继续。在createICmpEq()和addThrow()函数的帮助下,可以编写如下代码:
\begin{cpp}
case BinaryOp::Div:
BasicBlock *TrueDest, *FalseDest;
createICmpEq(Right, Int32Zero, TrueDest,
FalseDest, "divbyzero", "notzero");
Builder.SetInsertPoint(TrueDest);
addThrow(42); // Arbitrary payload value.
Builder.SetInsertPoint(FalseDest);
V = Builder.CreateSDiv(Left, Right);
break;
\end{cpp}
代码生成部分现在已经完成。要构建应用程序,必须切换到构建目录并运行ninja工具:
\begin{shell}
$ ninja
\end{shell}
构建完成后,可以用with a: 3/a表达式检查生成的IR:
\begin{shell}
$ src/calc "with a: 3/a"
\end{shell}
将看到抛出和捕获异常所需的IR。
生成的IR现在依赖于C++运行时,链接所需库的最简单方法是使用clang++编译器。使用表达式计算器的运行时函数将rtcalc.c文件重命名为rtcalc.cpp,并在文件中的每个函数前面添加extern "C"。然后,使用llc工具将生成的IR转换为目标文件,并使用clang++编译器创建可执行文件:
\begin{shell}
$ src/calc "with a: 3/a" | llc -filetype obj -o exp.o
$ clang++ -o exp exp.o ../rtcalc.cpp
\end{shell}
现在,可以用不同的值对程序进行测试:
\begin{shell}
$ ./exp
Enter a value for a: 1
The result is: 3
$ ./exp
Enter a value for a: 0
Divide by zero!
\end{shell}
第二次运行时,输入为0,这会引发异常——像预期的那样工作了!
本节中,了解了如何引发和捕获异常。生成IR的代码可以用作其他编译器的蓝图,所使用的类型信息和catch子句的数量取决于编译器的输入,但需要生成的IR仍然遵循本节中提供的模式。
添加元数据是向LLVM提供进一步信息的另一种方式。下一节中,将添加类型元数据,以便在某些情况下支持LLVM优化器。
================================================
FILE: content/part2/chapter6/2.tex
================================================
两个指针可能指向同一个存储单元,在这一点上它们彼此别名。内存在LLVM模型中没有类型,这使得优化器很难确定两个指针是否相互别名。若编译器可以证明两个指针不会相互别名,就可以进行更多的优化。在下一节中,我们将更深入地研究这个问题,并研究在实现这种方法之前添加元数据将有什么好处。
\mySubsubsection{6.2.1.}{理解对元数据的需求}
为了演示这个问题,来看看下面的函数:
\begin{cpp}
void doSomething(int *p, float *q) {
*p = 42;
*q = 3.1425;
}
\end{cpp}
优化器不能决定指针p和q是否指向相同的内存单元。优化过程中,可以执行一个重要的分析,称为别名分析。若p和q指向相同的存储单元,其互为别名。此外,若优化器可以证明两个指针永远不会相互别名,这就提供了优化机会。例如,在doSomething()函数中,存储可以在不改变结果的情况下重新排序。
此外,若一种类型的变量可以是另一种不同类型变量的别名,则取决于源语言的定义。注意,语言也可能包含不基于类型的别名假设的表达式——例如,不相关类型之间的类型强制转换。
LLVM开发人员选择的解决方案是在加载和存储指令中添加元数据。添加的元数据有两个目的:
\begin{itemize}
\item
首先,定义了类型层次结构,在此基础上类型可以别名另一个类型
\item
其次,描述了加载或存储指令中的内存访问
\end{itemize}
来看一下C中的类型层次结构。每种类型的层次结构都从一个根节点开始,可以命名,也可以匿名。LLVM假设具有相同名称的根节点描述相同类型的层次结构,可以在相同的LLVM模块中使用不同的类型层次结构,并且LLVM可以安全地假设这些类型可以别名。在根节点下面是标量类型的节点,聚合类型的节点不添加到根节点,但会引用标量类型和其他聚合类型。Clang定义C语言的层次结构如下:
\begin{itemize}
\item
根节点称为简单的C/C++ TBAA。
\item
根节点下面是用于字符类型的节点。这是C语言中的一种特殊类型,所有指针都可以转换为指向char的指针。
\item
char节点下面是其他标量类型的节点和所有指针的类型,称为any指针。
\end{itemize}
除此之外,聚合类型可定义为成员类型和偏移量的序列。
这些元数据定义会添加到加载和存储指令的访问标记中。访问标记由三部分组成:基本类型、访问类型和偏移量。根据基本类型的不同,访问标签描述内存访问有两种可能的方式:
\begin{enumerate}
\item
若基类型是聚合类型,则访问标记描述具有必要访问类型的struct成员的内存访问,并位于给定的偏移量。
\item
若基类型是标量类型,则访问类型必须与基类型相同,并且偏移量必须为0。
\end{enumerate}
有了这些定义,就可以在访问标记上定义一个关系,用于计算两个指针是否可以相互别名。来仔细看看(基类型,偏移量)元组的直接父对象的选项:
\begin{enumerate}
\item
若基类型是标量类型且偏移量为0,则直接父类型为(父类型,0),父类型为父节点的类型,如类型层次结构中定义的那样。若偏移量不为0,则父级未定义。
\item
若基类型是聚合类型,则(基类型,偏移量)元组的直接父级是(新类型,新偏移量)元组,新类型是偏移量处成员的类型。新偏移量是新类型的偏移量,调整到其新起点。
\end{enumerate}
这个关系的传递闭包就是父关系。两个内存访问(基类型1,访问类型1,偏移量1)和(基类型2,访问类型2,偏移量2),若(基类型1,偏移量1)和(基类型2,偏移量2)在父关系中相关,则相互别名,反之亦然。
用一个例子来说明这一点:
\begin{cpp}
struct Point { float x, y; }
void func(struct Point *p, float *x, int *i, char *c) {
p->x = 0; p->y = 0; *x = 0.0; *i = 0; *c = 0;
}
\end{cpp}
当使用标量类型的内存访问标记定义时,i参数的访问标记是(int, int, 0),而c参数的访问标记是(char, char, 0)。类型层次结构中,int类型的节点的父节点是char节点,所以(int, 0)的直接父指针是(char, 0),并且两个指针都可以别名。对于x和c参数也是如此,但x和i参数是不相关的,所以它们不会相互别名。对结构体Point的y成员的访问是(Point, float, 4),其中4是该结构体中y成员的偏移量。(Point, 4)的直接父变量是(float, 0),所以对p->y和x的访问可以别名;同理,参数c也可以使用别名。
\mySubsubsection{6.2.2.}{LLVM中创建TBAA元数据}
创建元数据,必须使用llvm::MDBuilder类,在llvm/IR/MDBuilder.h头文件中声明。数据本身存储在llvm::MDNode和llvm::MDString类的实例中,使用builder类可以避免我们了解构造的内部细节。
通过createTBAARoot()方法创建根节点,该方法需要将类型层次结构的名称作为参数,并返回根节点。可以使用createAnonymousTBAARoot()方法创建一个匿名的唯一根节点。
使用createTBAAScalarTypeNode()方法将标量类型添加到层次结构中,该方法将类型的名称和父节点作为参数。
另一方面,为聚合类型添加类型节点稍微复杂一些。createTBAAStructTypeNode()方法接受类型的名称和字段列表作为参数。具体来说,字段以std::pair实例的形式给出,其中第一个元素表示成员的类型,第二个元素表示struct中的偏移量。
使用createTBAAStructTagNode()方法创建访问标记,该方法将基类型、访问类型和偏移量作为参数。
最后,元数据必须附加到加载或存储指令。Instruction类包含一个名为setMetadata()的方法,该方法用于添加各种基于类型的别名分析元数据。第一个参数必须是llvm::LLVMContext::MD\_tbaa类型,第二个参数必须是访问标签。
有了这些知识,就可以向tinylang添加用于基于类型的别名分析(TBAA)的元数据了。
\mySubsubsection{6.2.3.}{tinylang中添加TBAA元数据}
为了支持TBAA,必须添加一个新的CGTBAA类,该类负责生成元数据节点。此外,我们使CGTBAA类成为CGModule类的成员,称其为TBAA。
每条加载和存储指令都必须加注解,为此在CGModule类中创建了一个名为decorateInst()的新函数,这个函数尝试创建标记访问信息。若成功,则元数据将添加到相应的加载或存储指令。此外,这种设计还允许在不需要的情况下关闭元数据生成过程,例如在构建中关闭优化:
\begin{cpp}
void CGModule::decorateInst(llvm::Instruction *Inst,
TypeDeclaration *Type) {
if (auto *N = TBAA.getAccessTagInfo(Type))
Inst->setMetadata(llvm::LLVMContext::MD_tbaa, N);
}
\end{cpp}
我们将新CGTBAA类的声明放在include/tinylang/CodeGen/CGTBAA.h头文件中,并将定义放在lib/CodeGen/CGTBAA.cpp文件中。除了AST定义,头文件还需要包含定义元数据节点和构建器的文件:
\begin{cpp}
#include "tinylang/AST/AST.h"
#include "llvm/IR/MDBuilder.h"
#include "llvm/IR/Metadata.h"
\end{cpp}
CGTBAA类需要存储一些数据成员:
\begin{enumerate}
\item
首先,需要缓存类型层次结构的根:
\begin{cpp}
class CGTBAA {
llvm::MDNode *Root;
\end{cpp}
\item
为了构造元数据节点,需要一个MDBuilder类的实例:
\begin{cpp}
llvm::MDBuilder MDHelper;
\end{cpp}
\item
最后,必须存储为一个类型生成的元数据以供重用:
\begin{cpp}
llvm::DenseMap MetadataCache;
// …
};
\end{cpp}
\end{enumerate}
现在已经定义了构造所需的变量,必须添加创建元数据所需的方法:
\begin{enumerate}
\item
构造函数初始化数据成员:
\begin{cpp}
CGTBAA::CGTBAA(CGModule &CGM)
: CGM(CGM),
MDHelper(llvm::MDBuilder(CGM.getLLVMCtx())),
Root(nullptr) {}
\end{cpp}
\item
必须惰性地实例化类型层次结构的根,将其命名为Simple tinylang TBAA:
\begin{cpp}
llvm::MDNode *CGTBAA::getRoot() {
if (!Root)
Root = MDHelper.createTBAARoot("Simple tinylang TBAA");
return Root;
}
\end{cpp}
\item
对于标量类型,必须在MDBuilder类的帮助下根据类型的名称创建元数据节点。新的元数据节点存储在缓存中:
\begin{cpp}
llvm::MDNode *
CGTBAA::createScalarTypeNode(TypeDeclaration *Ty,
StringRef Name,
llvm::MDNode *Parent) {
llvm::MDNode *N =
MDHelper.createTBAAScalarTypeNode(Name, Parent);
return MetadataCache[Ty] = N;
}
\end{cpp}
\item
为记录创建元数据的方法更加复杂,所以必须枚举记录的所有字段。与标量类型类似,新的元数据节点存储在缓存中:
\begin{cpp}
llvm::MDNode *CGTBAA::createStructTypeNode(
TypeDeclaration *Ty, StringRef Name,
llvm::ArrayRef> Fields) {
llvm::MDNode *N =
MDHelper.createTBAAStructTypeNode(Name, Fields);
return MetadataCache[Ty] = N;
}
\end{cpp}
\item
要返回tinylang类型的元数据,需要创建类型层次结构。由于tinylang的类型系统非常有限,可以使用一种简单的方法。每个标量类型都是映射到根节点的唯一类型,并且将所有指针映射到单个类型,再结构化类型引用这些节点。若不能映射类型,则返回nullptr:
\begin{cpp}
llvm::MDNode *CGTBAA::getTypeInfo(TypeDeclaration *Ty) {
if (llvm::MDNode *N = MetadataCache[Ty])
return N;
if (auto *Pervasive =
llvm::dyn_cast(Ty)) {
StringRef Name = Pervasive->getName();
return createScalarTypeNode(Pervasive, Name, getRoot());
}
if (auto *Pointer =
llvm::dyn_cast(Ty)) {
StringRef Name = "any pointer";
return createScalarTypeNode(Pointer, Name, getRoot());
}
if (auto *Array =
llvm::dyn_cast(Ty)) {
StringRef Name = Array->getType()->getName();
return createScalarTypeNode(Array, Name, getRoot());
}
if (auto *Record =
llvm::dyn_cast(Ty)) {
llvm::SmallVector,
4> Fields;
auto *Rec =
llvm::cast(CGM.convertType(Record));
const llvm::StructLayout *Layout =
CGM.getModule()->getDataLayout().getStructLayout(Rec);
unsigned Idx = 0;
for (const auto &F : Record->getFields()) {
uint64_t Offset = Layout->getElementOffset(Idx);
Fields.emplace_back(getTypeInfo(F.getType()), Offset);
++Idx;
}
StringRef Name = CGM.mangleName(Record);
return createStructTypeNode(Record, Name, Fields);
}
return nullptr;
}
\end{cpp}
\item
获取元数据的一般方法是getAccessTagInfo()。要获取TBAA访问标记信息,必须添加对getTypeInfo()函数的调用。这个函数期望TypeDeclaration作为它的参数,可从生成元数据的指令中检索:
\begin{cpp}
llvm::MDNode *CGTBAA::getAccessTagInfo(TypeDeclaration *Ty) {
return getTypeInfo(Ty);
}
\end{cpp}
\end{enumerate}
最后,为了生成TBAA元数据,只需要将元数据添加到tinylang中成的所有加载和存储指令上即可。
例如,在CGProcedure:: writvariable()中,存储一个全局变量使用一个存储指令:
\begin{cpp}
Builder.CreateStore(Val, CGM.getGlobal(D));
\end{cpp}
为了修饰这个特定的指令,需要用以下几行替换这一行,其中decorateInst()将TBAA元数据添加到这个存储指令中:
\begin{cpp}
auto *Inst = Builder.CreateStore(Val, CGM.getGlobal(D));
// NOTE: V is of the VariableDeclaration class, and
// the getType() method in this class retrieves the
// TypeDeclaration that is needed for decorateInst().
CGM.decorateInst(Inst, V->getType());
\end{cpp}
有了这些更改,就完成了TBAA元数据的生成。
现在,可以将示例tinylang文件编译为LLVM中间表示,以查看新实现的TBAA元数据。例如,以下文件Person.mod:
\begin{shell}
MODULE Person;
TYPE
Person = RECORD
Height: INTEGER;
Age: INTEGER
END;
PROCEDURE Set(VAR p: Person);
BEGIN
p.Age := 18;
END Set;
END Person.
\end{shell}
本章的构建目录中构建的tinylang编译器可以用来生成这个文件的中间表示:
\begin{shell}
$ tools/driver/tinylang -emit-llvm ../examples/Person.mod
\end{shell}
新生成的Person.ll文件中,可以看到存储指令是用本章中生成的TBAA元数据,其中元数据反映了最初声明的记录类型的字段:
\begin{shell}
; ModuleID = '../examples/Person.mod'
source_filename = "../examples/Person.mod"
target datalayout = "e-m:o-i64:64-i128:128-n32:64-S128"
target triple = "arm64-apple-darwin22.6.0"
define void @_t6Person3Set(ptr nocapture dereferenceable(16) %p) {
entry:
%0 = getelementptr inbounds ptr, ptr %p, i32 0, i32 1
store i64 18, ptr %0, align 8, !tbaa !0
ret void
}
!0 = !{!"_t6Person6Person", !1, i64 0, !1, i64 8}
!1 = !{!"INTEGER", !2, i64 0}
!2 = !{!"Simple tinylang TBAA"}
\end{shell}
既然了解了如何生成TBAA元数据,我们将在下一节中探索一个非常相似的主题:生成调试元数据。
================================================
FILE: content/part2/chapter6/3.tex
================================================
为了允许源代码级调试,必须添加调试信息。LLVM中对调试信息的支持使用调试元数据来描述源语言的类型和其他静态信息,并使用intrinsic来跟踪变量值。LLVM核心库在Unix系统上以DWARF格式生成调试信息,在Windows系统上以PDB格式生成调试信息。我们将在下一节中查看其总体结构。
\mySubsubsection{6.3.1.}{理解调试元数据的一般结构}
为了描述通用结构,LLVM使用的元数据类似于用于基于类型分析的元数据。静态结构描述了文件、编译单元、函数和词法块,以及使用的数据类型。
使用的主要类是llvm::DIBuilder,使用llvm/IR/DIBuilder目录下的头文件来获取类声明。此构建器类提供了一个易于使用的接口来创建调试元数据。之后,元数据将添加到LLVM对象(如全局变量)中,或者在调试内部函数的调用中使用。下面是一些构建器类可以创建的元数据:
\begin{itemize}
\item
llvm::DIFile:使用文件名和包含该文件的目录的绝对路径描述一个文件。可以使用createFile()方法来创建,文件可以包含主编译单元,包含导入的声明。
\item
llvm::DICompileUnit:用于描述当前编译单元。除其他事项外,还指定源语言、特定于编译器的生产者字符串、是否启用优化,当然还有DIFile(编译单元驻留在其中)。可以通过createCompileUnit()来创建。
\item
llvm:: dissubprogram:描述一个函数。这里最重要的信息是作用域(通常是嵌套函数的DICompileUnit或dissubprogram)、函数名、修改后的函数名和函数类型。可以通过调用createFunction()来创建。
\item
llvm::DILexicalBlock:描述了一个词法块,并对许多高级语言中的块范围进行了建模。可以通过调用createLexicalBlock()来创建。
\end{itemize}
LLVM对编译器翻译的语言不做任何假设,所以没有关于该语言的数据类型的信息。要支持源代码级调试,特别是在调试器中显示变量值,还必须添加类型信息。下面是一些重要的构念:
\begin{itemize}
\item
createBasicType()函数返回一个指向llvm::DIBasicType类的指针,创建元数据来描述基本类型,例如tinylang中的INTEGER或C++中的int。除了类型的名称之外,所需的参数还包括以位为单位的大小和编码——例如,若是有符号类型或无符号类型。
\item
有几种方法可以构造复合数据类型的元数据,如llvm::DIComposite类所示。可以使用createArrayType()、createStructType()、createUnionType()和createVectorType()函数分别实例化数组、结构、联合和矢量数据类型的元数据。这些函数需要期望的参数,例如基类型和数组类型的订阅数量或结构类型的字段成员列表。
\item
还有支持枚举、模板、类等的方法。
\end{itemize}
函数列表显示必须将源语言的每个细节添加到调试信息中,假设llvm::DIBuilder类的实例称为DBuilder,还假设在一个名为file的文件中有一些tinylang源文件。/home/llvmuser目录下的File.mod文件中,其第5行是Func():INTEGER函数,第7行包含一个局部VAR i:INTEGER声明。为此创建元数据,从文件的信息开始。这里,需要指定文件名和文件所在文件夹的绝对路径:
\begin{cpp}
llvm::DIFile *DbgFile = DBuilder.createFile("File.mod",
"/home/llvmuser");
\end{cpp}
该文件在tinylang中是一个模块,这使得它成为LLVM的编译单元。这包含了很多信息:
\begin{cpp}
bool IsOptimized = false;
llvm::StringRef CUFlags;
unsigned ObjCRunTimeVersion = 0;
llvm::StringRef SplitName;
llvm::DICompileUnit::DebugEmissionKind EmissionKind =
llvm::DICompileUnit::DebugEmissionKind::FullDebug;
llvm::DICompileUnit *DbgCU = DBuilder.createCompileUnit(
llvm::dwarf::DW_LANG_Modula2, DbgFile, "tinylang",
IsOptimized, CUFlags, ObjCRunTimeVersion, SplitName,
EmissionKind);
\end{cpp}
此外,调试器需要知道源语言。DWARF标准定义了一个包含所有公共值的枚举,这样做的一个缺点是不能简单地添加新的源语言。要做到这一点,必须在DWARF委员会创建一个请求。请注意,调试器和其他调试工具也需要支持一种新语言——仅仅向枚举中添加一个新成员远远不够。
许多情况下,选择一种接近源语言的语言就足够了。在tinylang的例子中,这是Modula-2,使用DW\_LANG\_Modula2作为语言标识符。编译单元保存在文件中,该文件由我们前面创建的DbgFile变量表示。
此外,调试信息可以携带有关生成器的信息,这些信息可以是编译器的名称和版本信息。这里,只是传递了tinylang字符串。若不想添加这些信息,则可以简单地使用一个空字符串作为参数。
下一组信息包括IsOptimized标志,指示编译器是否开启了优化。通常,这个标志来自于-O命令行开关,可以使用CUFlags参数将其他参数设置传递给调试器。这里没有使用这个,直接传递一个空字符串。我们也不使用Objective-C,所以将0作为Objective-C运行时参数。
通常,调试信息嵌入在我们正在创建的目标文件中。若想要将调试信息写入一个单独的文件,则SplitName参数必须包含这个文件的名称;否则,只需传递一个空字符串就足够了。最后,可以定义应该发出的调试信息的级别。默认值是完整的调试信息,正如FullDebug枚举值的使用所表明的那样,但若只想生成行号,也可以选择LineTablesOnly值,或者根本不生成调试信息的NoDebug值。对于后者,最好一开始就不要创建调试信息。
我们的极简源码只使用INTEGER数据类型,这是一个带符号的32位值。为这种类型创建元数据很简单:
\begin{cpp}
llvm::DIBasicType *DbgIntTy =
DBuilder.createBasicType("INTEGER", 32,
llvm::dwarf::DW_ATE_signed);
\end{cpp}
要为函数创建调试元数据,必须首先为签名创建类型,然后为函数本身创建元数据,这类似于为函数创建IR。函数的签名是一个数组,其中所有类型的参数按源顺序排列,函数的返回类型作为索引0处的第一个元素。通常,这个数组是动态构造的。在本例中,还可以静态地构造元数据。这对于内部函数很有用,比如模块初始化。通常,这些函数的参数是已知的,编译器编写器可以硬编码它们:
\begin{cpp}
llvm::Metadata *DbgSigTy = {DbgIntTy};
llvm::DITypeRefArray DbgParamsTy =
DBuilder.getOrCreateTypeArray(DbgSigTy);
llvm::DISubroutineType *DbgFuncTy =
DBuilder.createSubroutineType(DbgParamsTy);
\end{cpp}
我们的函数有INTEGER返回类型,没有其他参数,因此DbgSigTy数组只包含指向该类型元数据的指针。该静态数组被转换为类型数组,然后用于创建函数的类型。
函数本身需要更多的数据:
\begin{cpp}
unsigned LineNo = 5;
unsigned ScopeLine = 5;
llvm::DISubprogram *DbgFunc = DBuilder.createFunction(
DbgCU, "Func", "_t4File4Func", DbgFile, LineNo,
DbgFuncTy, ScopeLine, llvm::DISubprogram::FlagPrivate,
llvm::DISubprogram::SPFlagLocalToUnit);
\end{cpp}
函数属于编译单元,在示例中,编译单元存储在DbgCU变量中。需要在源文件中指定函数的名称,即Func,并且修改后的名称存储在目标文件中。这些信息有助于调试器定位函数的机器码。根据tinylang规则,修改后的名称为\_t4File4Func,还必须指定包含函数的文件。
乍一看,这可能令人惊讶,但想想C和C++中的包含机制:函数可以存储在不同的文件中,然后在主编译单元中使用\#include包含该文件。这里的情况并非如此,我们使用与编译单元使用的文件相同的文件。接下来,传递函数的行号和函数类型。函数的行号不能是函数的词法作用域开始的行号,可以指定不同的ScopeLine。函数也有保护,在这里用FlagPrivate值指定它来指示private函数。函数保护的其他可能值是FlagPublic和FlagProtected,分别用于public和protected域内的函数。
除了保护级别,还可以指定其他标志。例如,FlagVirtual表示虚函数,FlagNoReturn表示该函数不返回给调用者。可以在LLVM include文件中找到可能值的完整列表,即llvm/include/llvm/IR/DebugInfoFlags.def。
最后,可以指定特定于函数的标志。最常用的标志是SPFlagLocalToUnit值,表明该函数是编译单元的本地函数。MainSubprogram值也经常使用,表明这个函数是应用程序的主要函数。前面提到的LLVM包含文件,还列出了与特定于函数的标志相关的所有可能值。
目前,我们只创建了引用静态数据的元数据。变量是动态的,因此将在下一节中探讨如何将静态元数据添加到IR代码中以访问变量。
\mySubsubsection{6.3.2.}{跟踪变量及其值}
前一节中描述的类型元数据需要与源程序的变量相关联。对于全局变量,这非常简单。llvm::DIBuilder类的createGlobalVariableExpression()函数创建描述全局变量的元数据。这包括源文件中变量的名称、修改过的名称、源文件等。LLVM IR中的全局变量由GlobalVariable类的实例表示。这个类有一个名为addDebugInfo()的方法,可将从createGlobalVariableExpression()返回的元数据节点与全局变量关联起来。
对于局部变量,需要采取另一种方法。因为只知道值,所以LLVM IR不知道表示局部变量的类。LLVM社区开发的解决方案是将对函数的调用插入到函数的IR代码中。内在函数是LLVM知道的函数,所以可以用它做一些神奇的事情。大多数情况下,内在函数不会导致机器级别的子例程调用。这里,函数调用可将元数据与值关联起来,函数内最重要的调试元数据是llvm.dbg.declare和llvm.dbg.value。
llvm.dbg.declare内部变量提供信息,由前端生成,用于声明局部变量。本质上,this描述了局部变量的地址。优化过程中,通道可以用(可能多次)调用llvm.dbg来替换这个内在的调用。值来保存调试信息并跟踪本地源变量。优化之后,llvm.dbg.declare用于描述局部变量在内存中的程序点,所以可能会出现多次调用。
另一方面,只要将局部变量设置为新值,就会调用llvm.dbg.value。这个内部变量描述的是局部变量的值,而不是它的地址。
这一切是如何运作的呢?LLVM IR表示和通过LLVM::DIBuilder类进行的编程创建略有不同,因此将对两者进行研究。
继续上一节的示例,将使用alloca指令为function函数中的I变量分配本地存储:
\begin{shell}
@i = alloca i32
\end{shell}
之后,必须添加对llvm.dbg.declare的调用:
\begin{shell}
call void @llvm.dbg.declare(metadata ptr %i,
metadata !1, metadata !DIExpression())
\end{shell}
第一个参数是局部变量的地址。第二个参数是描述局部变量的元数据,通过调用本地变量createAutoVariable()或调用llvm::DIBuilder类参数createParameterVariable()来创建。最后,第三个参数描述一个地址表达式,稍后将对此进行解释。
来实现IR的创建,可以通过调用llvm::IRBuilder<>类的CreateAlloca()方法来为本地@i变量分配存储空间:
\begin{cpp}
llvm::Type *IntTy = llvm::Type::getInt32Ty(LLVMCtx);
llvm::Value *Val = Builder.CreateAlloca(IntTy, nullptr, "i");
\end{cpp}
LLVMCtx变量是使用的上下文类,而Builder是llvm::IRBuilder<>类的使用实例。
局部变量也需要通过元数据来描述:
\begin{cpp}
llvm::DILocalVariable *DbgLocalVar =
Dbuilder.createAutoVariable(DbgFunc, "i", DbgFile,
7, DbgIntTy);
\end{cpp}
使用上一节中的值,可以指定该变量是DbgFunc函数的一部分,称为i,在DbgFile文件的第7行中定义,并且是DbgIntTy类型。
最后,使用llvm.dbg.declare内部变量将调试元数据与变量的地址关联起来。使用llvm::DIBuilder避免添加调用的所有细节:
\begin{cpp}
llvm::DILocation *DbgLoc =
llvm::DILocation::get(LLVMCtx, 7, 5, DbgFunc);
DBuilder.insertDeclare(Val, DbgLocalVar,
DBuilder.createExpression(), DbgLoc,
Val.getParent());
\end{cpp}
同样,必须为变量指定一个源位置。llvm::DILocation的实例是一个容器,保存与作用域相关联的位置的行和列。此外,insertDeclare()方法将调用添加到LLVM IR的内部函数。就这个函数的参数而言,需要存储在Val中的变量的地址,以及存储在DbgValVar中的变量的调试元数据。还传递了一个空地址表达式和前面创建的调试位置,与普通指令一样,需要指定将调用插入到哪个基本块中。若指定一个基本块,则在末尾插入调用。或者,可以指定一条指令,然后在该指令之前插入调用。还有一个指向alloca指令的指针,这是插入到底层基本块中的最后一条指令。因此,可以使用这个基本块,并且调用会添加在alloca指令之后。
若局部变量的值发生了变化,必须向IR添加对llvm.dbg.value的调用,以设置局部变量的新值。可以使用llvm::DIBuilder类的insertValue()方法来实现。
当实现函数的IR生成时,使用了一种高级算法(主要使用值),避免为局部变量分配存储。需要添加调试信息,所以使用llvm.dbg.value的频率比在clang生成的IR中看到的要高得多。
若变量没有专用存储空间,而是更大的聚合类型的一部分,该怎么办?可能出现这种情况的一种情况是使用嵌套函数。要实现对调用者堆栈帧的访问,必须收集结构中所有使用的变量,并将指向该记录的指针传递给调用函数。调用的函数内部,可以引用调用方的变量,就好像是函数的局部变量一样。不同的是,这些变量现在是总量的一部分。
对llvm.dbg.declare的调用中,若调试元数据描述了第一个参数所指向的整个内存,则使用空表达式。但若只描述内存的一部分,则需要添加一个表达式,指示元数据应用于内存的哪一部分。
嵌套帧的情况下,需要计算帧中的偏移量。需要访问DataLayout实例,可以从创建IR代码的LLVM模块获得该实例。若llvm::Module实例命名为Mod,保存嵌套帧结构的变量命名为frame,并且是llvm::StructType类型,可以通过以下方式访问帧的第三个成员。这个访问会提供成员的偏移量:
\begin{cpp}
const llvm::DataLayout &DL = Mod->getDataLayout();
uint64_t Ofs = DL.getStructLayout(Frame)->getElementOffset(3);
\end{cpp}
此外,表达式是由一系列操作创建的。要访问帧的第三个成员,调试器需要向基指针添加偏移量。作为一个例子,需要创建一个数组和相关信息:
\begin{cpp}
llvm::SmallVector AddrOps;
AddrOps.push_back(llvm::dwarf::DW_OP_plus_uconst);
AddrOps.push_back(Offset);
\end{cpp}
从这个数组中,可以创建表达式,然后传递给llvm.dbg.declare,而不是空表达式:
\begin{cpp}
llvm::DIExpression *Expr = DBuilder.createExpression(AddrOps);
\end{cpp}
用户并不限于使用这种偏移操作。DWARF知道许多不同的运算符,从而可以创建相当复杂的表达式。可以在LLVM包含文件(llvm/include/llvm/BinaryFormat/Dwarf.def)中找到完整的操作符列表。
此时,可以为变量创建调试信息。为了使调试器能够遵循源代码中的控制流,还需要提供行号信息。这是下一节的主题。
\mySubsubsection{6.3.3.}{添加行号}
调试器允许程序员逐行调试应用程序,所以调试器需要知道哪些机器指令属于源代码中的哪一行,LLVM允许为每条指令添加一个源位置。在前一节中,创建了llvm::DILocation类型的位置信息,调试位置提供的信息不仅仅是行、列和范围。若需要,可以指定该行内联到的作用域。还可以指出此调试位置属于隐式代码——即前端生成,但不在源码中的代码。
此信息可以添加到指令之前,必须将调试位置包装在llvm::DebugLoc对象中。为此,必须简单地将从llvm::DILocation类获得的位置信息传递给llvm::DebugLoc构造函数。通过这种包装,LLVM可以跟踪位置信息。虽然源代码中的位置不会改变,但可以在优化期间删除为源码级语句或表达式生成的机器码,这种封装有助于处理这些变化。
添加行号信息主要归结为从AST检索行号信息,并将其添加到生成的指令中。指令类具有setDebugLoc()方法,该方法将位置信息添加到指令上。
在下一节中,将学习如何生成调试信息,并将其添加到tinylang编译器中。
\mySubsubsection{6.3.4.}{使tinylang支持调试}
我们将调试元数据的生成封装在新的CGDebugInfo类中。此外,将声明放在tinylang/CodeGen/CGDebugInfo.h头文件中,并将定义放在tinylang/CodeGen/CGDebugInfo.cpp文件中。
CGDebugInfo类有五个重要的成员,需要一个对模块代码生成器CGM的引用,所以需要将类型从AST表示转换为LLVM类型。当然,需要一个名为Dbuilder的llvm::DIBuilder类的实例,还需要一个指向编译单元实例的指针,将其存储在成员CU中。
为了避免再次为类型创建调试元数据,必须添加一个映射来缓存此信息,这个成员叫做TypeCache。最后,需要一种方法来管理范围信息,为此必须基于llvm::SmallVector<>类ScopeStack创建一个堆栈:
\begin{cpp}
CGModule &CGM;
llvm::DIBuilder DBuilder;
llvm::DICompileUnit *CU;
llvm::DenseMap
TypeCache;
llvm::SmallVector ScopeStack;
\end{cpp}
CGDebugInfo类的以下方法利用了这些成员:
\begin{enumerate}
\item
首先,需要在构造函数中创建编译单元,还在这里创建了包含编译单元的文件。稍后,可以通过CU成员引用该文件。
构造函数的代码如下所示:
\begin{cpp}
CGDebugInfo::CGDebugInfo(CGModule &CGM)
: CGM(CGM), DBuilder(*CGM.getModule()) {
llvm::SmallString<128> Path(
CGM.getASTCtx().getFilename());
llvm::sys::fs::make_absolute(Path);
llvm::DIFile *File = DBuilder.createFile(
llvm::sys::path::filename(Path),
llvm::sys::path::parent_path(Path));
bool IsOptimized = false;
llvm::StringRef CUFlags;
unsigned ObjCRunTimeVersion = 0;
llvm::StringRef SplitName;
llvm::DICompileUnit::DebugEmissionKind EmissionKind =
llvm::DICompileUnit::DebugEmissionKind::FullDebug;
CU = DBuilder.createCompileUnit(
llvm::dwarf::DW_LANG_Modula2, File, "tinylang",
IsOptimized, CUFlags, ObjCRunTimeVersion,
SplitName, EmissionKind);
}
\end{cpp}
\item
通常,我们需要提供行号。行号可以从源管理器位置派生,这在大多数AST节点中都是可用的。源代码管理器可以将其转换为行号:
\begin{cpp}
CGDebugInfo::CGDebugInfo(CGModule &CGM)
unsigned CGDebugInfo::getLineNumber(SMLoc Loc) {
return CGM.getASTCtx().getSourceMgr().FindLineNumber(
Loc);
}
\end{cpp}
\item
关于作用域的信息保存在堆栈中,需要打开和关闭作用域以及检索当前作用域的方法。编译单元是全局作用域,则会自动添加:
\begin{cpp}
llvm::DIScope *CGDebugInfo::getScope() {
if (ScopeStack.empty())
openScope(CU->getFile());
return ScopeStack.back();
}
void CGDebugInfo::openScope(llvm::DIScope *Scope) {
ScopeStack.push_back(Scope);
}
void CGDebugInfo::closeScope() {
ScopeStack.pop_back();
}
\end{cpp}
\item
接下来,必须为需要转换的类型的每个类别创建一个方法。getPervasiveType()方法为基本类型创建调试元数据。注意encoding参数的使用,将INTEGER类型声明为有符号类型,并将BOOLEAN类型编码为布尔值:
\begin{cpp}
llvm::DIType *
CGDebugInfo::getPervasiveType(TypeDeclaration *Ty) {
if (Ty->getName() == "INTEGER") {
return DBuilder.createBasicType(
Ty->getName(), 64, llvm::dwarf::DW_ATE_signed);
}
if (Ty->getName() == "BOOLEAN") {
return DBuilder.createBasicType(
Ty->getName(), 1, llvm::dwarf::DW_ATE_boolean);
}
llvm::report_fatal_error(
"Unsupported pervasive type");
}
\end{cpp}
\item
若只是重命名类型名,必须将其映射到类型定义,需要利用作用域和行号信息:
\begin{cpp}
llvm::DIType *
CGDebugInfo::getAliasType(AliasTypeDeclaration *Ty) {
return DBuilder.createTypedef(
getType(Ty->getType()), Ty->getName(),
CU->getFile(), getLineNumber(Ty->getLocation()),
getScope());
}
\end{cpp}
\item
为数组创建调试信息需要指定数组大小和对齐方式,可以从DataLayout类中检索这些数据,还需要指定数组的索引范围:
\begin{cpp}
llvm::DIType *
CGDebugInfo::getArrayType(ArrayTypeDeclaration *Ty) {
auto *ATy =
llvm::cast(CGM.convertType(Ty));
const llvm::DataLayout &DL =
CGM.getModule()->getDataLayout();
Expr *Nums = Ty->getNums();
uint64_t NumElements =
llvm::cast(Nums)
->getValue()
.getZExtValue();
llvm::SmallVector Subscripts;
Subscripts.push_back(
DBuilder.getOrCreateSubrange(0, NumElements));
return DBuilder.createArrayType(
DL.getTypeSizeInBits(ATy) * 8,
1 << Log2(DL.getABITypeAlign(ATy)),
getType(Ty->getType()),
DBuilder.getOrCreateArray(Subscripts));
}
\end{cpp}
\item
使用所有这些单一方法,创建一个中心方法来为类型创建元数据,这些元数据还负责缓存数据:
\begin{cpp}
llvm::DIType *
CGDebugInfo::getType(TypeDeclaration *Ty) {
if (llvm::DIType *T = TypeCache[Ty])
return T;
if (llvm::isa(Ty))
return TypeCache[Ty] = getPervasiveType(Ty);
else if (auto *AliasTy =
llvm::dyn_cast(Ty))
return TypeCache[Ty] = getAliasType(AliasTy);
else if (auto *ArrayTy =
llvm::dyn_cast(Ty))
return TypeCache[Ty] = getArrayType(ArrayTy);
else if (auto *RecordTy =
llvm ::dyn_cast(Ty))
r eturn TypeCache[Ty] = getRecordType(RecordTy);
llvm::report_fatal_error("Unsupported type");
return nullptr;
}
\end{cpp}
\item
还需要添加一个方法来为全局变量生成元数据:
\begin{cpp}
void CGDebugInfo::emitGlobalVariable(
VariableDeclaration *Decl,
llvm::GlobalVariable *V) {
llvm::DIGlobalVariableExpression *GV =
DBuilder.createGlobalVariableExpression(
getScope(), Decl->getName(), V->getName(),
CU->getFile(),
getLineNumber(Decl->getLocation()),
getType(Decl->getType()), false);
V->addDebugInfo(GV);
}
\end{cpp}
\item
要为过程发送调试信息,需要为过程类型创建元数据。为此,需要一个参数类型的列表,第一个条目的返回类型。若过程没有返回类型,则必须使用未指定类型;这称为void,与C语言类似。若形参是引用,则需要添加引用类型;否则,必须将类型添加到列表中:
\begin{cpp}
llvm::DISubroutineType *
CGDebugInfo::getType(ProcedureDeclaration *P) {
llvm::SmallVector Types;
const llvm::DataLayout &DL =
CGM.getModule()->getDataLayout();
// Return type at index 0
if (P->getRetType())
Types.push_back(getType(P->getRetType()));
else
Types.push_back(
DBuilder.createUnspecifiedType("void"));
for (const auto *FP : P->getFormalParams()) {
llvm::DIType *PT = getType(FP->getType());
if (FP->isVar()) {
llvm::Type *PTy = CGM.convertType(FP->getType());
PT = DBuilder.createReferenceType(
llvm::dwarf::DW_TAG_reference_type, PT,
DL.getTypeSizeInBits(PTy) * 8,
1 << Log2(DL.getABITypeAlign(PTy)));
}
Types.push_back(PT);
}
return DBuilder.createSubroutineType(
DBuilder.getOrCreateTypeArray(Types));
}
\end{cpp}
\item
对于过程本身,现在可以使用在前一步中创建的过程类型创建调试信息。过程也会打开一个新的作用域,所以必须将过程压入作用域堆栈,还要将LLVM函数对象与新的调试信息关联起来:
\begin{cpp}
void CGDebugInfo::emitProcedure(
ProcedureDeclaration *Decl, llvm::Function *Fn) {
llvm::DISubroutineType *SubT = getType(Decl);
llvm::DISubprogram *Sub = DBuilder.createFunction(
getScope(), Decl->getName(), Fn->getName(),
CU->getFile(), getLineNumber(Decl->getLocation()),
SubT, getLineNumber(Decl->getLocation()),
llvm::DINode::FlagPrototyped,
llvm::DISubprogram::SPFlagDefinition);
openScope(Sub);
Fn->setSubprogram(Sub);
}
\end{cpp}
\item
当到达过程的末尾时,必须通知构建器完成该过程的调试信息的构造,还需要从作用域堆栈中删除这个过程:
\begin{cpp}
void CGDebugInfo::emitProcedureEnd(
ProcedureDeclaration *Decl, llvm::Function *Fn) {
if (Fn && Fn->getSubprogram())
DBuilder.finalizeSubprogram(Fn->getSubprogram());
closeScope();
}
\end{cpp}
\item
最后,当添加完调试信息后,需要在构建器上实现finalize()方法,再验证生成的调试信息。这是开发过程中的一个重要步骤,可以帮助找到错生成的元数据:
\begin{cpp}
void CGDebugInfo::finalize() { DBuilder.finalize(); }
\end{cpp}
\end{enumerate}
调试信息应该只在用户请求时生成,所以需要一个新的命令行开关。我们将把它添加到CGModule类的文件中,也将在这个类中使用它:
\begin{cpp}
static llvm::cl::opt
Debug("g", llvm::cl::desc("Generate debug information"),
llvm::cl::init(false));
\end{cpp}
-g选项可以与tinylang编译器一起使用,以生成调试元数据。
此外,CGModule类持有std::unique\_ptr类的实例。指针在用于设置命令行开关的构造函数中初始化:
\begin{cpp}
if (Debug)
DebugInfo.reset(new CGDebugInfo(*this));
\end{cpp}
在CGModule.h中定义的getter方法中,我们只返回指针:
\begin{cpp}
CGDebugInfo *getDbgInfo() {
return DebugInfo.get();
}
\end{cpp}
生成调试元数据的常用模式是检索指针,并检查它是否有效。例如,在创建一个全局变量之后,可以像这样添加调试信息:
\begin{cpp}
VariableDeclaration *Var = …;
llvm::GlobalVariable *V = …;
if (CGDebugInfo *Dbg = getDbgInfo())
Dbg->emitGlobalVariable(Var, V);
\end{cpp}
为了添加行号信息,需要在CGDebugInfo类中使用一个名为getDebugLoc()的转换方法,将AST中的位置信息转换为调试元数据:
\begin{cpp}
llvm::DebugLoc CGDebugInfo::getDebugLoc(SMLoc Loc) {
std::pair LineAndCol =
CGM.getASTCtx().getSourceMgr().getLineAndColumn(Loc);
llvm::DILocation *DILoc = llvm::DILocation::get(
CGM.getLLVMCtx(), LineAndCol.first, LineAndCol.second,
getScope());
return llvm::DebugLoc(DILoc);
}
\end{cpp}
此外,可以调用CGModule类中的实用函数来将行号信息添加到指令中:
\begin{cpp}
void CGModule::applyLocation(llvm::Instruction *Inst,
llvm::SMLoc Loc) {
if (CGDebugInfo *Dbg = getDbgInfo())
Inst->setDebugLoc(Dbg->getDebugLoc(Loc));
}
\end{cpp}
通过这种方式,可以添加编译器的调试信息。
================================================
FILE: content/part2/chapter6/4.tex
================================================
本章中,了解了如何在LLVM中抛出和捕获异常,以及如何生成IR来利用此功能。为了增强IR的范围,了解了如何将各种元数据添加到指令。用于基于类型的别名分析的元数据为LLVM优化器提供信息,并帮助进行某些优化以生成更好的机器代码。用户总是喜欢使用源代码级调试器,并且通过向IR代码中添加调试信息,可以实现编译器的这一重要特性。
优化IR代码是LLVM的核心任务。下一章中,我们将学习通道管理器是如何工作的,以及如何影响通道管理器所管理的优化流水线。
================================================
FILE: content/part2/chapter7/0.tex
================================================
LLVM使用一系列的通道来优化IR,一个通道在IR的一个单元上运行,例如一个函数或一个模块。操作可以是转换,以定义的方式更改IR,也可以是分析,其收集诸如依赖关系之类的信息。这一系列的通道称为通道流水线,通道管理器在编译器生成的IR上执行通道流水线,所以需要了解通道管理器的作用以及如何构造一个通道流水线。编程语言的语义可能需要开发新的通道,我们必须将这些通道添加到流水线中。
本章中,将了解以下内容:
\begin{itemize}
\item
如何利用LLVM通道管理器在LLVM内实现通道
\item
如何在LLVM项目中实现一个instrumentation通道,以及一个单独的插件
\item
使用LLVM工具的ppprofiler通道时,如何使用opt和clang的通道插件
\item
向编译器中添加优化流水时,将使用基于新通道管理器的优化流水扩展tinylang编译器
\end{itemize}
本章结束时,将了解如何开发新的通道,以及如何将其添加到通道流水线中,还可以在编译器中设置通道流水线。
================================================
FILE: content/part2/chapter7/1.tex
================================================
本章使用的代码在这里\url{https://github.com/PacktPublishing/Learn-LLVM-17/tree/main/Chapter07}。
================================================
FILE: content/part2/chapter7/2.tex
================================================
LLVM核心库优化编译器创建的IR并将其转换为目标代码。这项艰巨的任务需要分解成一个个独立的步骤,称为“通道”(passes)。这些通道需要以正确的顺序执行,这是通道管理器的目标。
为什么不硬编码通道的顺序呢?编译器的用户通常希望编译器提供不同级别的优化。开发人员更喜欢快速的编译速度,而不是在开发期间进行优化。最终的应用程序应该运行得尽可能快,并且编译器应该能够执行复杂的优化,并且可以接受较长的编译时间。不同级别的优化需要执行不同数量的优化,所以编译器作者可能希望提供自己的通道,以利用自己对语言的了解。例如,可能希望用内联IR甚至是预先计算的结果,来替换众所周知的库函数。对于C语言,这样的通道是LLVM库的一部分,但对于其他语言,需要自行提供。了解了通道后,可能需要重新定制或添加一些通道。例如,若知道通行证的操作使一些IR代码无法访问,则希望在通道之后运行固定代码删除通道,通道管理器可以协助完成这些需求。
通道通常根据其工作的范围进行分类:
\begin{itemize}
\item
模块传递将整个模块作为输入。这样的传递在给定的模块上执行其工作,并可用于该模块内的过程内操作。
\item
调用图通道对调用图的强连接组件(scc)进行操作,自下而上的顺序遍历组件。
\item
函数通道将单个函数作为输入,并仅在该函数上执行其工作。
\item
循环通道作用于函数内部的循环。
\end{itemize}
除了IR代码之外,通道还可能需要、更新或使某些分析结果无效。执行了许多不同的分析,例如,别名分析或支配树的构造。若一个通道需要这样的分析,则可以向分析管理器请求。若已经计算了信息,将返回缓存的结果;否则,将计算该信息。若一个通道更改了IR代码,则需要宣布保留哪些分析结果,以便可以在必要时使缓存的分析信息无效。
在底层,通道管理器有以下作用:
\begin{itemize}
\item
分析结果在各通道之间共享,这需要跟踪哪个通道,需要哪个分析,以及每个分析的状态。目标是避免不必要的分析预计算,并尽快释放分析结果占用的内存。
\item
这些通道以流水线方式执行。例如,若几个函数通道应该依次执行,通道管理器将在第一个函数上运行这些函数中的每个函数,再将运行第二个函数通道的所有函数,以此类推。这里的基本思想是改进缓存行为,因为编译器只对有限的一组数据(一个IR函数)执行转换,然后切换到下一个有限的数据集。
\end{itemize}
让我们实现一个新的IR转换通道,并探索如何将其添加到优化流水线中。
================================================
FILE: content/part2/chapter7/3.tex
================================================
一个通道可以在LLVM IR上执行任意复杂度的转换。为了说明添加新通道的机制,我们添加一个执行简单检测的通道。
为了研究程序的性能,了解函数被调用的频率和运行的时间是很有趣的。收集这些数据的一种方法是在每个函数中插入计数器,这个过程称为插装。我们将编写一个简单的插装通道,在每个函数的入口和每个退出点插入一个特殊的函数调用,这些函数收集计时信息并将其写入文件。因此,可以创建一个非常基本的分析器,将其命名为“穷人”(poor people)分析器,或者简而言之——ppprofiler。我们将开发新通道,以便其可以作为一个独立的插件使用,或者作为一个插件添加到LLVM源代码树中。最后,我们将了解如何将LLVM自带的通道集成到框架中。
\mySubsubsection{7.3.1.}{将ppprofiler作为插件进行开发}
本节中,将了解如何在LLVM树中创建一个新通道作为插件。新通道的目标是在函数的入口处插入对\_\_ppp\_enter()函数的调用,并在每个返回指令之前插入对\_\_ppp\_exit()函数的调用。只有当前函数的名称作为参数传递,这些函数的实现可以计算调用的次数,并测量所使用的时间。我们将在本章的最后实现这个运行时支持,并将研究如何开发通道。
我们将源代码存储在ppprofile.cpp文件中。遵循以下步骤:
\begin{enumerate}
\item
首先,包含一些文件:
\begin{cpp}
#include "llvm/ADT/Statistic.h"
#include "llvm/IR/Function.h"
#include "llvm/IR/PassManager.h"
#include "llvm/Passes/PassBuilder.h"
#include "llvm/Passes/PassPlugin.h"
#include "llvm/Support/Debug.h"
\end{cpp}
\item
为了缩短源代码,告诉编译器正在使用llvm命名空间:
\begin{cpp}
using namespace llvm;
\end{cpp}
\item
LLVM的内置调试基础结构要求定义一个调试类型,它是一个字符串。这个字符串随后显示在打印的统计信息中:
\begin{cpp}
#define DEBUG_TYPE "ppprofiler"
\end{cpp}
\item
接下来,将使用ALWAYS\_ENABLED\_STATISTIC宏定义一个计数器变量。第一个参数是计数器变量的名称,而第二个参数是将在统计中打印的文本:
\begin{cpp}
ALWAYS_ENABLED_STATISTIC(
NumOfFunc, "Number of instrumented functions.");
\end{cpp}
\begin{myNotic}{Note}
可以使用两个宏来定义计数器变量。若使用STATISTIC宏,则只有在启用断言的情况下,或者在CMake命令行中将LLVM\_FORCE\_ENABLE\_STATS设置为ON时,才会在调试构建中收集统计值。若使用ALWAYS\_ENABLED\_STATISTIC宏,则总是收集统计值。但使用-stats命令行选项输出统计信息只适用于前两种方法。若需要,可以通过调用llvm::PrintStatistics(llvm::raw\_ostream)函数打印收集的统计信息。
\end{myNotic}
\item
接下来,必须在匿名命名空间中声明通道类,这个类继承自PassInfoMixin模板。这个模板只添加了一些样板代码,比如name()方法,不用于确定传递的类型,run()方法在执行传递时由LLVM调用,还需要一个名为instrument()的辅助方法:
\begin{cpp}
namespace {
class PPProfilerIRPass
: public llvm::PassInfoMixin {
public:
llvm::PreservedAnalyses
run(llvm::Module &M, llvm::ModuleAnalysisManager &AM);
private:
void instrument(llvm::Function &F,
llvm::Function *EnterFn,
llvm::Function *ExitFn);
};
}
\end{cpp}
\item
现在,来定义如何插装函数。除了要检测的函数外,还要通道要调用的函数:
\begin{cpp}
void PPProfilerIRPass::instrument(llvm::Function &F,
Function *EnterFn,
Function *ExitFn) {
\end{cpp}
\item
在函数内部,更新统计计数器:
\begin{cpp}
++NumOfFunc;
\end{cpp}
\item
为了方便地插入IR代码,需要IRBuilder类的一个实例。我们把它设置为第一个基本块,这是函数的入口块:
\begin{cpp}
IRBuilder<> Builder(&*F.getEntryBlock().begin());
\end{cpp}
\item
现在有了构建器,可以插入一个全局常量来保存检测函数的名称:
\begin{cpp}
GlobalVariable *FnName =
Builder.CreateGlobalString(F.getName());
\end{cpp}
\item
接下来,将插入对\_\_ppp\_enter()函数的调用,并将名称作为参数传递:
\begin{cpp}
Builder.CreateCall(EnterFn->getFunctionType(), EnterFn,
{FnName});
\end{cpp}
\item
要调用\_\_ppp\_exit()函数,必须定位所有返回指令。SetInsertionPoint()函数设置的插入点在作为参数传递的指令之前,所以可以在该点插入调用:
\begin{cpp}
for (BasicBlock &BB : F) {
for (Instruction &Inst : BB) {
if (Inst.getOpcode() == Instruction::Ret) {
Builder.SetInsertPoint(&Inst);
Builder.CreateCall(ExitFn->getFunctionType(),
ExitFn, {FnName});
}
}
}
}
\end{cpp}
\item
接下来,将实现run()方法。LLVM通道所工作的模块和一个分析管理器,若需要,可以从中请求分析结果:
\begin{cpp}
PreservedAnalyses
PPProfilerIRPass::run(Module &M,
ModuleAnalysisManager &AM) {
\end{cpp}
\item
这里有一个小麻烦:若在实现的运行时模块检测到\_\_ppp\_enter()和\_\_ppp\_exit()函数,则创建了一个无限递归,就会遇到麻烦。为了避免这种情况,若定义了其中一个函数,必须什么都不做:
\begin{cpp}
if (M.getFunction("__ppp_enter") ||
M.getFunction("__ppp_exit")) {
return PreservedAnalyses::all();
}
\end{cpp}
\item
现在,我们准备声明函数。首先,创建函数类型,然后是函数体:
\begin{cpp}
Type *VoidTy = Type::getVoidTy(M.getContext());
PointerType *PtrTy =
PointerType::getUnqual(M.getContext());
FunctionType *EnterExitFty =
FunctionType::get(VoidTy, {PtrTy}, false);
Function *EnterFn = Function::Create(
EnterExitFty, GlobalValue::ExternalLinkage,
"__ppp_enter", M);
Function *ExitFn = Function::Create(
EnterExitFty, GlobalValue::ExternalLinkage,
"__ppp_exit", M);
\end{cpp}
\item
现在需要做的就是循环遍历模块的所有函数,并通过调用instrument()方法检测找到的函数。需要忽略函数声明,它们只是原型。也可能存在没有名称的函数,这与我们的方法不兼容,就会过滤掉这些函数:
\begin{cpp}
for (auto &F : M.functions()) {
if (!F.isDeclaration() && F.hasName())
instrument(F, EnterFn, ExitFn);
}
\end{cpp}
\item
最后,必须声明,我们没有保留任何分析。这很可能过于悲观,但这样做是出于安全考虑:
\begin{cpp}
return PreservedAnalyses::none();
}
\end{cpp}
新通道的功能现在已经实现了。为了能够使用我们的通道,需要用PassBuilder对象注册。这可以通过两种方式实现:静态或动态。若插件静态链接,则需要提供一个名为getPluginInfo()的函数。要使用动态链接,需要提供llvmGetPassPluginInfo()函数。这两种情况下,都会返回一个PassPluginLibraryInfo结构体的实例,提供了关于插件的一些基本信息。最重要的是,结构体包含一个指向注册通道的函数指针。现在,让我们把它添加到源文件中。
\item
RegisterCB()函数中,注册了一个Lambda函数,该函数将在解析通道流水线字符串时调用。若通道的名称是ppprofiler,将通道添加到模块通道管理器中。这些回调将在下一节中展开:
\begin{cpp}
void RegisterCB(PassBuilder &PB) {
PB.registerPipelineParsingCallback(
[](StringRef Name, ModulePassManager &MPM,
ArrayRef) {
if (Name == "ppprofiler") {
MPM.addPass(PPProfilerIRPass());
return true;
}
return false;
});
}
\end{cpp}
\item
getPPProfilerPluginInfo()函数在静态链接插件时调用,会返回一些关于插件的基本信息:
\begin{cpp}
llvm::PassPluginLibraryInfo getPPProfilerPluginInfo() {
return {LLVM_PLUGIN_API_VERSION, "PPProfiler", "v0.1",
RegisterCB};
}
\end{cpp}
\item
若插件动态链接,则在加载插件时调用llvmGetPassPluginInfo()函数。但当将此代码静态地链接到工具中时,可能会出现链接器错误,因为该函数可能在多个源文件中定义。解决方案是使用宏来保护函数:
\begin{cpp}
#ifndef LLVM_PPPROFILER_LINK_INTO_TOOLS
extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo
llvmGetPassPluginInfo() {
return getPPProfilerPluginInfo();
}
#endif
\end{cpp}
\end{enumerate}
这样,我们就实现了通道插件。在了解如何使用新插件之前,让我们检查一下,若想要将通道插件添加到LLVM源代码树中,需要更改哪些内容。
\mySubsubsection{7.3.2.}{将通道添加到LLVM源代码树中}
例如,若计划将新通道与预编译的clang一起使用,则将其实现为插件是有用的。另一方面,若编写自己的编译器,就有很好的理由将新通道直接添加到LLVM源代码树中。有两种不同的方式可以做到这一点——作为一个插件和作为一个集成通道。插件方法的修改会更少一些。
\mySamllsection{利用LLVM源代码树中的插件机制}
LLVM IR上执行转换的传递源位于llvm-project/llvm/lib/Transforms目录中。这个目录中,创建一个名为PPProfiler的新目录,并将源文件PPProfiler.cpp复制到其中,不需要对源代码进行任何更改!
为了将新插件集成到构建系统中,创建一个名为CMakeLists.txt的文件,内容如下:
\begin{cmake}
add_llvm_pass_plugin(PPProfiler PPProfiler.cpp)
\end{cmake}
最后,在父目录下的CMakeLists.txt文件中,需要通过添加以下行来包含新的源目录:
\begin{cmake}
add_subdirectory(PPProfiler)
\end{cmake}
现在,已经准备好构建添加了PPProfiler的LLVM。进入LLVM的构建目录,手动运行Ninja:
\begin{shell}
$ ninja install
\end{shell}
CMake将检测到构建描述中的变化,并重新运行配置步骤。这里,会看到一行输出:
\begin{shell}
-- Registering PPProfiler as a pass plugin (static build: OFF)
\end{shell}
这说明该插件已检测到并已构建为动态库。安装完成后,在/lib目录下,将找到动态库PPProfiler.so。
目前为止,与前一节的通道插件的唯一区别是,动态库是作为LLVM的一部分安装的,也可以静态地将新插件链接到LLVM工具。要做到这一点,需要重新运行CMake配置并在命令行中添加-DLLVM\_PPPROFILER\_LINK\_INTO\_TOOLS=ON选项。从CMake中查找以下信息以确认已更改的构建选项:
\begin{shell}
-- Registering PPProfiler as a pass plugin (static build: ON)
\end{shell}
重新编译安装LLVM后,发生如下变化:
\begin{itemize}
\item
该插件编译到静态库libPPProfiler.a中,并且该库安装在/lib目录下。
\item
LLVM工具(如opt)会链接到该库。
\item
该插件注册为扩展,可以检查/include/llvm/Support/Extension.def文件现在包含以下行:
\begin{shell}
HANDLE_EXTENSION(PPProfiler)
\end{shell}
\end{itemize}
此外,所有支持此扩展机制的工具都将获得新的通道。创建优化流水线一节中,将了解如何在编译器中执行此操作。
因为新的源文件位于单独的目录中,所以这种方法工作得很好,并且只修改一个现有文件。若试图保持修改后的LLVM源树与主存储库同步,这将最小化合并冲突。
某些情况下,将新通道添加为插件并不是最好的方法,LLVM提供的通道使用不同的方式进行注册。若开发了一个新通道,并将其添加到LLVM,并且LLVM社区接受了您的贡献,可以使用注册机制。
\mySamllsection{将通道集成到注册表中}
为了将新通道完全集成到LLVM中,需要对插件的源代码进行稍微不同的结构调整。这样做的主要原因是从通道注册表调用通道类的构造函数,这需要将类接口放入头文件中。
与之前一样,必须将新通道放入LLVM的Transforms组件中。通过创建lvm-project/llvm/include/llvm/Transforms/PPProfiler/PPProfiler.h头文件开始实现。该文件的内容是类定义,将其放入LLVM的命名空间中。无需其他更改:
\begin{cpp}
#ifndef LLVM_TRANSFORMS_PPPROFILER_PPPROFILER_H
#define LLVM_TRANSFORMS_PPPROFILER_PPPROFILER_H
#include "llvm/IR/PassManager.h"
namespace llvm {
class PPProfilerIRPass
: public llvm::PassInfoMixin {
public:
llvm::PreservedAnalyses
run(llvm::Module &M, llvm::ModuleAnalysisManager &AM);
private:
void instrument(llvm::Function &F,
llvm::Function *EnterFn,
llvm::Function *ExitFn);
};
} // namespace llvm
#endif
\end{cpp}
接下来,将通道插件的源文件PPProfiler.cpp复制到新目录llvmproject/llvm/lib/Transforms/PPProfiler中。该文件需要按照如下方式更新:
\begin{enumerate}
\item
由于类定义现在位于头文件中,因此必须从该文件中删除类定义。在顶部,添加\#include:
\begin{cpp}
#include "llvm/Transforms/PPProfiler/PPProfiler.h"
\end{cpp}
\item
必须删除llvmGetPassPluginInfo()函数,因为通道没有内置到动态库中。
\end{enumerate}
和前面一样,还需要为构建提供一个CMakeLists.txt文件,必须将新通道声明为一个新组件:
\begin{cmake}
add_llvm_component_library(LLVMPPProfiler
PPProfiler.cpp
LINK_COMPONENTS
Core
Support
)
\end{cmake}
之后,与前一节一样,需要通过向父目录下的CMakeLists.txt添加以下行来包含新的源目录:
\begin{cmake}
add_subdirectory(PPProfiler)
\end{cmake}
LLVM内部,可用的通道保存在llvm/lib/Passes/PassRegistry.def数据库文件中,需要更新这个文件。新通道是一个模块通道,因此需要在文件中搜索定义模块通道部分,例如,搜索MODULE\_PASS宏。在这个部分中,添加以下行:
\begin{shell}
MODULE_PASS("ppprofiler", PPProfilerIRPass())
\end{shell}
该数据库文件在llvm/lib/Passes/PassBuilder.cpp类中使用。这个文件需要包含新的头文件:
\begin{cpp}
#include "llvm/Transforms/PPProfiler/PPProfiler.h"
\end{cpp}
这些都是基于新通道的插件版本所必需的源码更改。
由于创建了一个新的LLVM组件,还需要在llvm/lib/Passes/CMakeLists.txt文件中添加链接依赖项。在LINK\_COMPONENTS关键字下,需要添加一行新组件的名称:
\begin{shell}
PPProfiler
\end{shell}
现在,可以开始构建和安装LLVM了,所有LLVM工具都可以使用新的通道ppprofiler。它已经编译到libLLVMPPProfiler.a库中,并且可以作为PPProfiler组件在构建系统中使用。
我们已经讨论了如何创建一个新的通道。在下一节中,将研究如何使用ppprofiler通道。
================================================
FILE: content/part2/chapter7/4.tex
================================================
回想一下我们在ppprofiler通道开发为插件一节中,从LLVM源码树中作为插件开发的ppprofiler通道。这里,因为LLVM工具可以加载插件,将学习如何将此通道与LLVM工具(如opt和clang)一起使用。
我们先看一下opt。
\mySamllsection{opt中运行通道插件}
要使用这个新插件,需要一个包含LLVM IR的文件。要做到这一点,最简单的方法是翻译一个C程序,例如一个“Hello World”风格的程序:
\begin{cpp}
#include
int main(int argc, char *argv[]) {
puts("Hello");
return 0;
}
\end{cpp}
使用clang编译hello.c文件:
\begin{shell}
$ clang -S -emit-llvm -O1 hello.c
\end{shell}
会得到一个非常简单的IR文件,hello.ll。其中包含以下代码:
\begin{shell}
$ cat hello.ll
@.str = private unnamed_addr constant [6 x i8] c"Hello\00",
align 1
define dso_local i32 @main(
i32 noundef %0, ptr nocapture noundef readnone %1) {
%3 = tail call i32 @puts(
ptr noundef nonnull dereferenceable(1) @.str)
ret i32 0
}
\end{shell}
这就足够测试通道了。
要运行该通道,必须提供几个参数。首先,需要通过-{}-load-pass-plugin选项告诉opt加载动态库。要运行单个通道,必须指定-{}-passes选项。使用hello.ll文件作为输入,可以运行如下命令:
\begin{shell}
$ opt --load-pass-plugin=./PPProfile.so \
--passes="ppprofiler" --stats hello.ll -o hello_inst.bc
\end{shell}
若启用了统计信息生成功能,将看到如下输出:
\begin{shell}
===--------------------------------------------------------===
... Statistics Collected ...
===--------------------------------------------------------===
1 ppprofiler - Number of instrumented functions.
\end{shell}
否则,将提示未启用统计:
\begin{shell}
Statistics are disabled. Build with asserts or with
-DLLVM_FORCE_ENABLE_STATS
\end{shell}
文件hello\_inst.bc是结果,可以使用llvm-dis工具将该文件转换为可读的IR。正如预期的那样,将会看到对\_\_ppp\_enter()和\_\_ppp\_exit()函数的调用,以及一个新的函数名常量:
\begin{shell}
$ llvm-dis hello_inst.bc -o –
@.str = private unnamed_addr constant [6 x i8] c"Hello\00",
align 1
@0 = private unnamed_addr constant [5 x i8] c"main\00",
align 1
define dso_local i32 @main(i32 noundef %0,
ptr nocapture noundef readnone %1) {
call void @__ppp_enter(ptr @0)
%3 = tail call i32 @puts(
ptr noundef nonnull dereferenceable(1) @.str)
call void @__ppp_exit(ptr @0)
ret i32 0
}
\end{shell}
这看起来已经很不错了!若能将此IR转换为可执行文件并运行,,需要为调用函数提供实现。
通常,运行时对特性的支持比将该特性添加到编译器本身要复杂得多。当调用\_\_ppp\_enter()和\_\_ppp\_exit()时,可以将其视为事件。为了以后分析数据,有必要保存事件。可获得的基本数据是该类型的事件、函数的名称及其地址以及时间戳。没有点技巧,处理起来并不是那么容易。
创建一个名为runtime.c的文件,内容如下:
\begin{enumerate}
\item
需要文件I/O、标准函数和时间支持。这是由以下头文件提供的:
\begin{cpp}
#include
#include
#include
\end{cpp}
\item
对于该文件,需要一个文件描述符。当程序结束时,该文件描述符应该正确关闭:
\begin{cpp}
static FILE *FileFD = NULL;
static void cleanup() {
if (FileFD == NULL) {
fclose(FileFD);
FileFD = NULL;
}
}
\end{cpp}
\item
为了简化运行时,只对输出使用固定的名称。若文件未打开,则打开文件并注册清理功能:
\begin{cpp}
static void init() {
if (FileFD == NULL) {
FileFD = fopen("ppprofile.csv", "w");
atexit(&cleanup);
}
}
\end{cpp}
\item
可以调用clock\_gettime()函数来获取时间戳,CLOCK\_PROCESS\_CPUTIME\_ID参数返回该进程消耗的时间。注意,并非所有系统都支持此参数。若有必要,可以使用其他时钟之一,比如CLOCK\_REALTIME:
\begin{cpp}
typedef unsigned long long Time;
static Time get_time() {
struct timespec ts;
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ts);
return 1000000000L * ts.tv_sec + ts.tv_nsec;
}
\end{cpp}
\item
现在,很容易定义\_\_ppp\_enter()函数。只需确保文件是打开的,获取时间戳,并写入事件:
\begin{cpp}
void __ppp_enter(const char *FnName) {
init();
Time T = get_time();
void *Frame = __builtin_frame_address(1);
fprintf(FileFD,
// "enter|name|clock|frame"
"enter|%s|%llu|%p\n", FnName, T, Frame);
}
\end{cpp}
\item
\_\_ppp\_exit()函数仅在事件类型方面有所不同:
\begin{cpp}
void __ppp_exit(const char *FnName) {
init();
Time T = get_time();
void *Frame = __builtin_frame_address(1);
fprintf(FileFD,
// "exit|name|clock|frame"
"exit|%s|%llu|%p\n", FnName, T, Frame);
}
\end{cpp}
\end{enumerate}
这是一个非常简单的运行时支持实现。在我们尝试之前,应该对实现做一些注释,因为有几个部分明显存在问题。
首先,因为只有一个文件描述符,对它的访问不受保护,所以该实现线程不安全。尝试在多线程程序中使用此运行时实现,很可能导致输出文件中的数据受到干扰。
此外,省略了检查与I/O相关的函数的返回值,这可能导致数据丢失。
最重要的是,事件的时间戳并不精确。调用函数已经增加了开销,但在该函数中执行I/O操作会使情况变得更糟。原则上,可以匹配函数的进入和退出事件,并计算函数的运行时。但这个值本身就有缺陷,因为可能包含I/O所需的时间,所以不要相信这里记录的时间。
尽管有这些缺陷,这个小的运行时文件允许产生一些输出。将检测文件的位码与包含运行时代码的文件一起编译,并运行生成的可执行文件:
\begin{shell}
$ clang hello_inst.bc runtime.c
$ ./a.out
\end{shell}
这将在目录中生成一个名为ppprofile.csv的新文件,其中包含以下内容:
\begin{shell}
$ cat ppprofile.csv
enter|main|3300868|0x1
exit|main|3760638|0x1
\end{shell}
Cool – 新通道和运行时似乎可以工作了!
\begin{myTip}{指定通道流水线}
使用-{}-passes选项,不仅可以命名单个通道,还可以描述整个流水线。例如,优化级别2的默认流水线名为default。可以使用-{}-passes="ppprofile,default"参数在默认管道之前运行ppprofile通道。注意,这种流水线描述中的通道名称,类型必须相同。
\end{myTip}
现在,让我们在clang中使用新通道。
\mySamllsection{将新通道插入clang}
上一节中,了解了如何使用opt运行单个通道。若需要调试通道,对于真正的编译器,步骤不应该那么复杂。
为了获得最佳结果,编译器需要按照一定的顺序运行优化通道。LLVM通道管理器有默认的执行顺序,这也称为默认传递管道。使用opt时,可以使用-passes选项指定不同的通道流水线。这很灵活,但对用户来说也很复杂。大多数情况下,只想在特定的点添加一个新的通道,例如在优化通道运行之前或在循环优化过程结束时,这些点称为扩展点。PassBuilder类允许在扩展点注册通道。可以调用registerPipelineStartEPCallback()方法,在优化通道前添加一个通道,这正是我们ppprofiler通道的位置。优化过程中,函数可能内联,通道将错过这些内联函数。相反,在优化通道之前运行通道,可以保证所有函数都检测到。
要使用这种方法,需要在通道插件中扩展RegisterCB()函数。将以下代码添加到函数中:
\begin{cpp}
PB.registerPipelineStartEPCallback(
[](ModulePassManager &PM, OptimizationLevel Level) {
PM.addPass(PPProfilerIRPass());
});
\end{cpp}
每当通道管理器填充默认通道流水线时,都会调用扩展点的所有回调,只需在这里添加新的通道即可。
要将插件加载到clang中,可以使用-fpass-plugin选项。创建hello.c文件的可执行文件就很简单:
\begin{shell}
$ clang -fpass-plugin=./PPProfiler.so hello.c runtime.c
\end{shell}
请运行可执行文件,并验证运行是否创建了ppprofile .csv文件。
\begin{myNotic}{Note}
因为通过检查特殊函数是否尚未在模块中声明,所以没有检测runtime.c文件。
\end{myNotic}
但能扩展到更大的程序吗?假设为第5章构建tinylang编译器的二进制文件。需要怎么做呢?可以在CMake命令行中传递编译器和链接器标志,这正是我们所需要的。
C++编译器的标志在CMAKE\_CXX\_FLAGS变量中给出,所以在CMake命令行中指定以下命令会将新的通道添加到所有编译器运行中:
\begin{shell}
-DCMAKE_CXX_FLAGS="-fpass-plugin=/PPProfiler.so"
\end{shell}
请将替换为动态库的绝对路径。
类似地,指定下列语句将添加runtime.o文件到每个链接器调用,请将替换为runtime.c编译版本的绝对路径:
\begin{shell}
-DCMAKE_EXE_LINKER_FLAGS="/runtime.o"
\end{shell}
当然,这需要clang作为构建编译器。确保clang用作构建编译器的最快方法是设置CC和CXX环境变量:
\begin{shell}
export CC=clang
export CXX=clang++
\end{shell}
有了这些选项,第5章中的CMake配置应该像往常一样运行。
构建了tinylang可执行文件之后,可以使用示例Gcd.mod文件运行。还需要创建ppprofile.csv文件,这次有超过44,000行!当然,拥有这样一个数据集会提出一个问题,即是否可以从中获得有用的东西。例如,获得10个最常调用的函数的列表,以及调用计数和在函数中花费的时间,将是有用的信息。幸运的是,在Unix系统上,有一些工具可以提供帮助。让我们构建一个简短的管道来匹配进入事件和退出事件,对函数进行计数,并显示前10个函数。awk Unix工具可以帮助完成这些步骤中的大部分。
要将进入事件与退出事件匹配,必须将进入事件存储在记录关联映射中。匹配退出事件时,查找存储的进入事件,并写入新记录。发出的行包含进入事件的时间戳、退出事件的时间戳以及两者之间的差异。必须把这个放进join.awk文件中:
\begin{shell}
BEGIN { FS = "|"; OFS = "|" }
/enter/ { record[$2] = $0 }
/exit/ { split(record[$2],val,"|")
print val[2], val[3], $3, $3-val[3], val[4] }
\end{shell}
为了计算函数调用和执行,使用了两个关联映射,count和sum。在count中,函数调用时计数,而在sum中,增加执行时间。最后,映射转储,可以将其放入avg.awk文件中:
\begin{shell}
BEGIN { FS = "|"; count[""] = 0; sum[""] = 0 }
{ count[$1]++; sum[$1] += $4 }
END { for (i in count) {
if (i != "") {
print count[i], sum[i], sum[i]/count[i], I }
} }
\end{shell}
运行这两个脚本之后,可以按降序对结果进行排序,然后可以从文件中取出前10行。仍然可以改进函数名,\_\_ppp\_enter()和\_\_ppp\_exit(),这时依旧很难阅读。使用llvm-cxxfilt工具,可以修改这些名称。demangle.awk脚本如下所示:
\begin{shell}
{ cmd = "llvm-cxxfilt " $4
(cmd) | getline name
close(cmd); $4 = name; print }
\end{shell}
要获得前10个函数调用,可以运行以下命令:
\begin{shell}
$ cat ppprofile.csv | awk -f join.awk | awk -f avg.awk |\
sort -nr | head -15 | awk -f demangle.awk
\end{shell}
下面是输出中的一些示例行:
\begin{shell}
446 1545581 3465.43 charinfo::isASCII(char)
409 826261 2020.2 llvm::StringRef::StringRef()
382 899471 2354.64
tinylang::Token::is(tinylang::tok::TokenKind) const
171 1561532 9131.77 charinfo::isIdentifierHead(char)
\end{shell}
第一个数字是函数的调用次数,第二个数字是累计执行时间,第三个数字是平均执行时间。如前所述,不要相信时间值,但调用计数应该是准确的。
目前,我们已经实现了一个新的检测通道,或者作为一个插件,或者作为LLVM的补充,并在一些实际场景中使用了它。下一节中,将探讨如何在编译器中设置一个优化流水线。
================================================
FILE: content/part2/chapter7/5.tex
================================================
我们在前面章节中开发的tinylang编译器,但没有对IR代码进行优化。接下来的小节中,我们将在编译器中添加一个优化流水线来实现这一目标。
\mySubsubsection{7.5.1.}{创建优化流水线}
PassBuilder类是设置优化流水线的核心。这个类知道所有已注册的通道,并且可以从文本描述构造一个通道流水线。可以使用这个类根据命令行给出的描述创建通道流水线,或者根据所请求的优化级别使用默认流水线。还支持使用通道插件,例如:在前一节中讨论的ppprofiler通道插件,就可以模拟opt工具的部分功能,并为命令行选项使用类似的名称。
PassBuilder类填充ModulePassManager类的实例,ModulePassManager类是通道管理器,保存构造的通道流水线并运行,代码生成通道仍然使用旧的通道管理器,所以必须保留旧的通道管理器。
为了实现它,我们将扩展tinylang编译器中的tools/driver/Driver.cpp文件:
\begin{enumerate}
\item
这里将使用新的类,所以将从添加新的包含文件开始。llvm/Passes/PassBuilder.h文件定义了PassBuilder类,llvm/Passes/PassPlugin.h文件是插件支持所必需的。最后,llvm/Analysis/targettransformminfo.h文件提供了一个IR级转换,并与目标特定信息连接起来的通道:
\begin{cpp}
#include "llvm/Passes/PassBuilder.h"
#include "llvm/Passes/PassPlugin.h"
#include "llvm/Analysis/TargetTransformInfo.h"
\end{cpp}
\item
要使用新通道管理器的某些特性,必须添加三个命令行选项,使用与opt工具相同的名称。-{}-passes选项允许通过管道的文本规范,而-{}-load-pass-plugin选项允许使用通道插件。若给出了-{}-debug-pass-manager选项,则通道管理器会输出执行通道的信息:
\begin{cpp}
static cl::opt
DebugPM("debug-pass-manager", cl::Hidden,
cl::desc("Print PM debugging information"));
static cl::opt PassPipeline(
"passes",
cl::desc("A description of the pass pipeline"));
static cl::list PassPlugins(
"load-pass-plugin",
cl::desc("Load passes from plugin library"));
\end{cpp}
\item
用户以优化级别影响通道流水线的构造。PassBuilder类支持6个不同的优化级别:无优化、3个优化速度级别和2个减小文件大小级别。我们可以在一个命令行选项中获取所有级别:
\begin{cpp}
static cl::opt OptLevel(
cl::desc("Setting the optimization level:"),
cl::ZeroOrMore,
cl::values(
clEnumValN(3, "O", "Equivalent to -O3"),
clEnumValN(0, "O0", "Optimization level 0"),
clEnumValN(1, "O1", "Optimization level 1"),
clEnumValN(2, "O2", "Optimization level 2"),
clEnumValN(3, "O3", "Optimization level 3"),
clEnumValN(-1, "Os", "Like -O2 with extra optimizations for size"),
clEnumValN(-2, "Oz", "Like -Os but reduces code size further")),
cl::init(0));
\end{cpp}
\item
LLVM的插件机制支持静态链接插件的插件注册表,是在项目配置期间创建的。为了利用这个注册表,必须包含llvm/Support/Extension.def数据库文件,以创建返回插件信息的函数原型:
\begin{cpp}
#define HANDLE_EXTENSION(Ext) \
llvm::PassPluginLibraryInfo get##Ext##PluginInfo();
#include "llvm/Support/Extension.def"
\end{cpp}
\item
现在,必须用一个新版本替换现有的emit()函数,必须在函数的顶部声明所需的PassBuilder实例:
\begin{cpp}
bool emit(StringRef Argv0, llvm::Module *M,
llvm::TargetMachine *TM,
StringRef InputFilename) {
PassBuilder PB(TM);
\end{cpp}
\item
为了实现对命令行中给出的传递插件的支持,必须循环遍历用户给出的插件库列表,并尝试加载插件。若此操作失败,将输出一个错误消息;否则,将注册通道:
\begin{cpp}
for (auto &PluginFN : PassPlugins) {
auto PassPlugin = PassPlugin::Load(PluginFN);
if (!PassPlugin) {
WithColor::error(errs(), Argv0)
<< "Failed to load passes from '" << PluginFN
<< "'. Request ignored.\n";
continue;
}
PassPlugin->registerPassBuilderCallbacks(PB);
}
\end{cpp}
\item
来自静态插件注册表的信息,以类似的方式用于向PassBuilder实例注册这些插件:
\begin{cpp}
#define HANDLE_EXTENSION(Ext) \
get##Ext##PluginInfo().RegisterPassBuilderCallbacks(PB);
#include "llvm/Support/Extension.def"
\end{cpp}
\item
现在,需要为不同的分析管理器声明变量。唯一的参数是调试标志:
\begin{cpp}
LoopAnalysisManager LAM(DebugPM);
FunctionAnalysisManager FAM(DebugPM);
CGSCCAnalysisManager CGAM(DebugPM);
ModuleAnalysisManager MAM(DebugPM);
\end{cpp}
\item
接下来,必须使用对PassBuilder实例上各自的注册方法的调用来填充分析管理器。分析管理器将使用默认分析通道填充,并运行注册回调。还必须确保功能分析管理器使用默认别名分析流水线,并且所有的分析管理器都知道彼此的存在:
\begin{cpp}
FAM.registerPass(
[&] { return PB.buildDefaultAAPipeline(); });
PB.registerModuleAnalyses(MAM);
PB.registerCGSCCAnalyses(CGAM);
PB.registerFunctionAnalyses(FAM);
PB.registerLoopAnalyses(LAM);
PB.crossRegisterProxies(LAM, FAM, CGAM, MAM);
\end{cpp}
\item
MPM模块通道管理器保存构造的通道流水线,实例用debug标志初始化:
\begin{cpp}
ModulePassManager MPM(DebugPM);
\end{cpp}
\item
现在,需要实现两种不同的方法,用通道流水线填充模块通道管理器。若用户在命令行上提供了一个通道流水线——使用了-{}-passes选项——则使用这个作为通道流水线:
\begin{cpp}
if (!PassPipeline.empty()) {
if (auto Err = PB.parsePassPipeline(
MPM, PassPipeline)) {
WithColor::error(errs(), Argv0)
<< toString(std::move(Err)) << "\n";
return false;
}
}
\end{cpp}
\item
否则,使用所选择的优化级别来确定要构建的通道流水线。默认的通道流水线名称为default,将优化级别作为参数:
\begin{cpp}
else {
StringRef DefaultPass;
switch (OptLevel) {
case 0: DefaultPass = "default"; break;
case 1: DefaultPass = "default"; break;
case 2: DefaultPass = "default"; break;
case 3: DefaultPass = "default"; break;
case -1: DefaultPass = "default"; break;
case -2: DefaultPass = "default"; break;
}
if (auto Err = PB.parsePassPipeline(
MPM, DefaultPass)) {
WithColor::error(errs(), Argv0)
<< toString(std::move(Err)) << "\n";
return false;
}
}
\end{cpp}
\item
IR代码上运行转换的通道流水线就已经设置好了。之后,需要一个打开的文件来写入结果。系统汇编器和LLVM IR输出是基于文本的,所以应该为其设置OF\_Text标志:
\begin{cpp}
std::error_code EC;
sys::fs::OpenFlags OpenFlags = sys::fs::OF_None;
CodeGenFileType FileType = codegen::getFileType();
if (FileType == CGFT_AssemblyFile)
OpenFlags |= sys::fs::OF_Text;
auto Out = std::make_unique(
outputFilename(InputFilename), EC, OpenFlags);
if (EC) {
WithColor::error(errs(), Argv0)
<< EC.message() << '\n';
return false;
}
\end{cpp}
\item
对于代码生成过程,必须使用旧的通道管理器。必须简单地声明CodeGenPM实例并添加通道,这使得特定于目标的信息在进行IR级别的转换时可用:
\begin{cpp}
legacy::PassManager CodeGenPM;
CodeGenPM.add(createTargetTransformInfoWrapperPass(
TM->getTargetIRAnalysis()));
\end{cpp}
\item
要输出LLVM IR,必须添加一个将IR输出到流中的通道:
\begin{cpp}
if (FileType == CGFT_AssemblyFile && EmitLLVM) {
CodeGenPM.add(createPrintModulePass(Out->os()));
}
\end{cpp}
\item
否则,必须让TargetMachine实例添加所需的代码生成通道,由作为参数传递的FileType值作为指示:
\begin{cpp}
else {
if (TM->addPassesToEmitFile(CodeGenPM, Out->os(),
nullptr, FileType)) {
WithColor::error()
<< "No support for file type\n";
return false;
}
}
\end{cpp}
\item
所有这些准备工作之后,准备执行通道。必须在IR模块上运行优化流水线,再运行代码生成通道。在所有这些工作之后,保留输出文件:
\begin{cpp}
MPM.run(*M, MAM);
CodeGenPM.run(*M);
Out->keep();
return true;
}
\end{cpp}
\item
虽然代码很多,但过程很简单。我们也必须更新tools/driver/CMakeLists.txt构建文件中的依赖项,除了添加目标组件,还必须添加来自LLVM的所有转换和代码生成组件,这些名称大致类似于源代码所在的目录名称。在配置过程中,组件名会转换为链接库名:
\begin{cmake}
set(LLVM_LINK_COMPONENTS ${LLVM_TARGETS_TO_BUILD}
AggressiveInstCombine Analysis AsmParser
BitWriter CodeGen Core Coroutines IPO IRReader
InstCombine Instrumentation MC ObjCARCOpts Remarks
ScalarOpts Support Target TransformUtils Vectorize
Passes)
\end{cmake}
\item
编译器驱动支持插件,必须声明这个支持:
\begin{cmake}
target_link_libraries(tinylang
PRIVATE tinylangBasic tinylangCodeGen
tinylangLexer tinylangParser tinylangSema)
\end{cmake}
这些都是对源代码和构建系统的必要补充。
\item
要构建扩展编译器,必须切换到构建目录并键入以下命令:
\begin{shell}
$ ninja
\end{shell}
\end{enumerate}
对构建系统文件的更改会自动检测,并且在编译和链接更改的源代码前运行cmake。若需要重新运行配置步骤,请按照第1章,编译tinylang应用程序部分中的说明进行操作。
我们已经使用了opt工具的选项作为蓝图,所以应该尝试运行tinylang,使用这些选项来加载一个通道插件并运行通道。
当前的实现中,既可以运行默认的传递管道,也可以自己构造一个。后者是非常灵活的,但它几乎是多余的。默认管道在类C语言中运行得非常好,但缺少的是一种扩展通道流水线的方法。我们将在下一节中讨论如何实现它。
\mySubsubsection{7.5.2.}{扩展通道流水线}
上一节中,使用PassBuilder类从用户提供的描述或预定义的名称创建了一个通道流水线。现在,让我们看看定制通道流水线的另一种方法:扩展点。
通道流水线的构造过程中,通道构建器允许添加用户提供的通道,这些点称为扩展点。一些扩展点的特特征,如下所示:
\begin{itemize}
\item
流水线起始扩展点,允许在流水线的开头添加通道
\item
窥视孔扩展点,允许在每个指令组合器实例通道之后添加通道
\end{itemize}
还存在其他扩展点。要使用扩展点,必须注册回调。通道流水线的构造过程中,回调在定义的扩展点上运行,并且可以向给定的通道管理器添加通道。
要为流水线的起始扩展点注册回调函数,必须调用PassBuilder类的registerPipelineStartEPCallback()方法。例如,要在流水线的开头添加PPProfiler通道,需要调用createModuleToFunctionPassAdaptor()模板函数,将该通道修改为模块通道,然后将其添加到模块通道管理器:
\begin{cpp}
PB.registerPipelineStartEPCallback(
[](ModulePassManager &MPM) {
MPM.addPass(PPProfilerIRPass());
});
\end{cpp}
可以在流水线创建之前的任何地方——也就是parsepaspipeline()方法调用之前,在通道流水线设置代码中添加这段代码。
对于上一节中所做的工作,一个非常自然的扩展是让用户在命令行上添加管道扩展点的流水线描述。opt工具也可以这样做,让我们对管道开始扩展点执行此操作。在tools/driver/Driver.cpp文件中添加如下代码:
\begin{enumerate}
\item
首先,必须为用户创建一个新的命令行来指定管道描述,从opt工具中获取选项名:
\begin{cpp}
static cl::opt PipelineStartEPPipeline(
"passes-ep-pipeline-start",
cl::desc("Pipeline start extension point));
\end{cpp}
\item
使用Lambda函数作为回调是最方便的方式。为了解析管道描述,必须调用PassBuilder实例的parsepaspipeline()方法。这些通道会添加到PM通道管理器中,并作为Lambda函数的参数。若发生错误,只打印错误消息,而不停止应用程序。在调用crossRegisterProxies()方法后,添加以下代码:
\begin{cpp}
PB.registerPipelineStartEPCallback(
[&PB, Argv0](ModulePassManager &PM) {
if (auto Err = PB.parsePassPipeline(
PM, PipelineStartEPPipeline)) {
WithColor::error(errs(), Argv0)
<< "Could not parse pipeline "
<< PipelineStartEPPipeline.ArgStr << ": "
<< toString(std::move(Err)) << "\n";
}
});
\end{cpp}
\begin{myTip}{Tip}
要允许用户在每个扩展点添加通道,需要为每个扩展点添加上述代码片段。
\end{myTip}
\item
是时候尝试不同的传递管理器选项了。使用-{}-debugpass-manager选项,可以了解哪些通道以何种顺序执行。还可以在每个通道执行前后打印IR,通过-{}-print-before-all和-{}-print-after-all选项完成。若创建了自己的通道流水线,就可以在感兴趣的地方插入打印通道。例如,可以尝试-{}-passes="print,inline,print"选项。此外,为了识别哪一个通道改变了IR代码,可以使用-{}-printchanged选项,只会在IR代码与之前通道的结果相比发生了变化时打印出来,大大减少的输出使得跟踪IR修改变得更加容易。
PassBuilder类有一个嵌套的OptimizationLevel类来表示六个不同的优化级别。而不是使用“default"流水描述作为parsePassPipeline()方法的参数,也可以调用buildPerModuleDefaultPipeline()方法,为请求级别构建默认的优化管道——除了级别0,此优化级别不执行任何优化。
没有通道添加到通道管理器中,若仍然想要运行某个通道,则可以手动将其添加到通道管理器中。这个级别上运行的一个简单通道是AlwaysInliner通道,将一个带有always\_inline属性的函数内联到调用者中,将优化级别的命令行选项值转换为OptimizationLevel类的相应成员之后,可以这样实现:
\begin{cpp}
PassBuilder::OptimizationLevel Olevel = …;
if (OLevel == PassBuilder::OptimizationLevel::O0)
MPM.addPass(AlwaysInlinerPass());
else
MPM = PB.buildPerModuleDefaultPipeline(OLevel, DebugPM);
\end{cpp}
当然,可以以这种方式向通道管理器添加多个通道。构造通道流水线时,PassBuilder也使用addPass()方法。
\end{enumerate}
\begin{myTip}{运行扩展点的回调}
因为没有为优化级别O0填充通道流水线,所以注册的扩展点不会调用。使用扩展点来注册应该在O0级别运行的通道会有问题,可以使用runRegisteredEPCallbacks()来运行注册的扩展点回调,从而产生一个仅由通过扩展点注册通道填充的通道管理器。
\end{myTip}
通过将优化流水线添加到tinylang中,既创建了一个类似于clang的优化编译器。LLVM社区致力于在每个版本中改进优化和优化管道,所以很少不使用默认管道。大多数情况下,添加新通道是为了实现编程语言的某些语义。
================================================
FILE: content/part2/chapter7/6.tex
================================================
本章中,了解了如何为LLVM创建一个新的通道,使用通道流水线描述和扩展点运行通道。通过构造和执行类似于clang的通道流水线扩展了编译器,将tinylang变成了一个优化编译器。通道流水线允许在扩展点添加通道,并且了解了如何在这些点注册通道,可以使用开发的通道或现有的通道扩展优化流水线。
下一章中,将解了TableGen语言的基础知识,该语言在LLVM和clang中广泛使用,以减少手动编程。
================================================
FILE: content/part2/part2.tex
================================================
继续了解如何开发自己的编译器。将从构建前端开始,读取源文件并创建源文件的抽象语法树,如何从源文件生成LLVM IR。使用LLVM的优化功能,创建优化的机器码。此外,还会探索几个高级主题,包括为面向对象语言构造生成LLVM IR和添加调试元数据。
\begin{itemize}
\item
第3章,将源码文件转换为抽象语法树
\item
第4章,生成IR代码的基础知识
\item
第5章,高级语言结构生成的IR
\item
第6章,生成IR代码的进阶知识
\item
第7章,优化IR
\end{itemize}
================================================
FILE: content/part3/chapter10/0.tex
================================================
LLVM有一组工具,可以发现应用程序中的某些错误,所有这些工具都使用了LLVM和clang库。
本章中,料及如何使用消毒工具来检查应用程序,如何使用最常见的消毒工具来识别各种各样的bug,之后为应用程序实现模糊测试,这将检查通常在单元测试中找不到的bug。还将了解如何识别应用程序中的性能瓶颈,运行静态分析器来识别编译器通常无法发现的问题,并创建基于clang的工具,在该工具中可以使用新功能扩展clang。
本章中,将了解以下内容:
\begin{itemize}
\item
用消毒程序检测应用程序
\item
使用libFuzzer查找bug
\item
使用XRay进行性能分析
\item
使用Clang静态分析器检查源代码
\item
创建基于clang的工具
\end{itemize}
本章结束时,将了解如何使用各种LLVM和clang工具来识别应用程序中的大量错误。还将获得使用新功能扩展clang的知识,例如:加强命名约定或添加新的源代码分析。
================================================
FILE: content/part3/chapter10/1.tex
================================================
要在XRay性能分析部分创建火焰图,需要从\url{https://github.com/brendangregg/FlameGraph}安装脚本。有些系统,比如Fedora和FreeBSD,为这些脚本提供了一个包,也可以使用。
若要在同一区域查看Chrome浏览器的可视化效果,则需要安装Chrome浏览器。Chrome浏览器可以从\url{https://www.google.com/chrome/}网站下载,也可以使用系统自带的软件包管理器安装。
此外,要通过扫描-构建脚本运行静态分析器,需要在Fedora和Ubuntu上安装perlcore包。
================================================
FILE: content/part3/chapter10/2.tex
================================================
LLVM具有几个消毒器,这些通道使用中间表示(IR)来检查应用程序的某些错误行为。通常,它们需要库支持,这是compiler-rt项目的一部分。消毒器可以在clang中启用,要构建编译器-rt项目,可以简单地在构建LLVM时将CMake变量-DLLVM\_ENABLE\_RUNTIMES=compiler-rt添加到CMake配置步骤中。
下面几节中,将研究地址、内存和线程消毒器。首先,来看看地址消毒器。
\mySubsubsection{10.2.1.}{使用地址消毒器检测内存访问问题}
可以使用地址杀毒器,来检测应用程序中不同类型的内存访问错误。这包括常见的错误,例如在释放动态分配的内存后使用,或者在已分配内存的边界外写入动态分配的内存。
启用后,地址消毒器会替换对malloc()和free()函数的调用,并使用检查保护来检测所有内存访问。这会给应用程序增加很多开销,而且只会在应用程序的测试阶段使用地址消毒器。若对实现细节感兴趣,可以在llvm/lib/Transforms/Instrumentation/AddressSanitzer.cpp中为通道的实现源码,实现算法的描述见\url{https://github.com/google/sanitizers/wiki/AddressSanitizerAlgorithm}。
让我们运行一个简短的示例来展示地址消毒器的功能!
下面的示例应用程序outfbounds.c分配了12个字节的内存,但初始化了14个字节:
\begin{cpp}
#include
#include
int main(int argc, char *argv[]) {
char *p = malloc(12);
memset(p, 0, 14);
return (int)*p;
}
\end{cpp}
可以编译并运行此应用程序而不会发现任何问题,因为这种行为是此类错误的典型表现。即使在较大的应用程序中,这类错误也可能在很长一段时间内被忽视。但若使用-fsanitize=address选项启用地址消毒器,则应用程序在检测到错误后停止。
使用-g选项启用调试符号也很有用,它有助于识别源代码中错误的位置。以下代码是如何在启用地址消毒器和调试符号的情况下编译源文件的示例:
\begin{shell}
$ clang -fsanitize=address -g outofbounds.c -o outofbounds
\end{shell}
现在,当运行应用程序时,将会得到一个冗长的错误报告:
\begin{shell}
$ ./outofbounds
==============================================================
==1067==ERROR: AddressSanitizer: heap-buffer-overflow on address
0x60200000001c at pc 0x00000023a6ef bp 0x7fffffffeb10 sp
0x7fffffffe2d8
WRITE of size 14 at 0x60200000001c thread T0
#0 0x23a6ee in __asan_memset /usr/src/contrib/llvm-project/
compiler-rt/lib/asan/asan_interceptors_memintrinsics.cpp:26:3
#1 0x2b2a03 in main /home/kai/sanitizers/outofbounds.c:6:3
#2 0x23331f in _start /usr/src/lib/csu/amd64/crt1.c:76:7
\end{shell}
该报告还包含有关内存内容的详细信息。重要的信息是错误的类型(在本例中是堆缓冲区溢出)和出错的源代码行。要找到源代码行,必须查看位置\#1处的堆栈跟踪,这是地址消毒器拦截应用程序执行之前的最后一个位置——outfbounds.c文件中的第6行,这是包含对memset()调用的行,这就是缓冲区溢出发生的确切位置。
若替换包含memset(p, 0, 14);在带有以下代码的outfbounds.c文件中,当释放了内存,就可以引入对内存的访问,需要将源代码存储在useafterfree.c文件中:
\begin{cpp}
memset(p, 0, 12);
free(p);
\end{cpp}
同样,若编译并运行它,消毒器会在释放内存后检测指针的使用:
\begin{shell}
$ clang -fsanitize=address -g useafterfree.c -o useafterfree
$ ./useafterfree
==============================================================
==1118==ERROR: AddressSanitizer: heap-use-after-free on address
0x602000000010 at pc 0x0000002b2a5c bp 0x7fffffffeb00 sp
0x7fffffffeaf8
READ of size 1 at 0x602000000010 thread T0
#0 0x2b2a5b in main /home/kai/sanitizers/useafterfree.c:8:15
#1 0x23331f in _start /usr/src/lib/csu/amd64/crt1.c:76:7
\end{shell}
这次,报告指向第8行,其中包含对p指针的解引用。
x86\_64 Linux和macOS上,还可以启用泄漏检测,若将ASAN\_OPTIONS环境变量设置为在运行应用程序之前detect\_leaks=1,还将获得关于内存泄漏的报告。
命令行中,可以这样做:
\begin{shell}
$ ASAN_OPTIONS=detect_leaks=1 ./useafterfree
\end{shell}
地址消毒器非常有用,它捕获了其他方法很难检测到的一类错误。内存消毒器执行类似的任务,我们将在下一节中研究它的用例。
\mySubsubsection{10.2.2.}{使用内存消毒器查找未初始化的内存访问}
使用未初始化的内存是另一类很难发现的bug。在C和C++中,一般的内存分配例程不会用默认值初始化内存缓冲区,对于堆栈上的自动变量也是如此。
有很多出错的机会,内存消毒器可以帮助找到这些错误。若对实现细节感兴趣,可以在llvm/lib/Transforms/Instrumentation/MemorySanitizer.cpp文件中找到内存消毒通道的源码。文件顶部的注释解释了实现背后的思想。
让我们运行一个小示例,并将以下源代码保存为memory.c文件。注意,变量x没有初始化,而是用作返回值:
\begin{cpp}
int main(int argc, char *argv[]) {
int x;
return x;
}
\end{cpp}
没启用杀菌器时,应用程序将运行良好。若使用-fsanitize=memory选项,将会得到一个错误报告:
\begin{shell}
$ clang -fsanitize=memory -g memory.c -o memory
$ ./memory
==1206==WARNING: MemorySanitizer: use-of-uninitialized-value
#0 0x10a8f49 in main /home/kai/sanitizers/memory.c:3:3
#1 0x1053481 in _start /usr/src/lib/csu/amd64/crt1.c:76:7
SUMMARY: MemorySanitizer: use-of-uninitialized-value /home/kai/
sanitizers/memory.c:3:3 in main
Exiting
\end{shell}
与地址消毒器一样,内存消毒器在发现第一个错误时停止应用程序。如图所示,内存杀毒器提供了一个使用初始化值的警告。
下一节中,我们将研究如何使用线程消毒器来检测多线程应用程序中的数据争用。
\mySubsubsection{10.2.3.}{使用线程消毒器发现数据竞争}
为了利用现代CPU的强大功能,应用程序现在使用多线程。这是一种强大的技术,但也引入了新的错误。多线程应用程序中一个非常常见的问题是,对全局数据的访问没有受到保护,例如:使用互斥锁或信号量。
这就是数据竞争。线程消毒器可以检测基于pthread的应用程序和使用LLVM lib++实现的应用程序中的数据竞争,可以在llvm/lib/Transforms/Instrumentation/ThreadSanitizer.cpp文件中找到实现。为了演示线程消毒器的功能,将创建一个非常简单的生产者-消费者风格的应用程序。生产者线程增加一个全局变量,而消费者线程减少同一个变量。对全局变量的访问不受保护,因此这是一种数据竞争。
需要在thread.c文件中书写以下源代码:
\begin{cpp}
#include
int data = 0;
void *producer(void *x) {
for (int i = 0; i < 10000; ++i) ++data;
return x;
}
void *consumer(void *x) {
for (int i = 0; i < 10000; ++i) --data;
return x;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, producer, NULL);
pthread_create(&t2, NULL, consumer, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return data;
}
\end{cpp}
前面的代码中,data变量在两个线程之间共享。为了使示例简单,它是int类型,通常会使用std::vector类或类似的数据结构,这两个线程运行producer()和consumer()函数。
producer()函数只增加数据变量,而consumer()函数则减少数据变量。没有实现访问保护,因此这构成了数据竞争。main()函数使用pthread\_create()函数启动两个线程,使用pthread\_join()函数等待线程结束,并返回data变量的当前值。
若编译并运行此应用程序,则不会注意到错误——返回值始终为零。若执行的循环次数增加100倍,将出现一个错误——在本例中,返回值不等于0。此时,需要注意出现的其他值。
可以使用线程消毒器来识别程序中的数据争用。要在启用线程消毒器的情况下进行编译,需要将-fsanitize=thread选项传递给clang。使用-g选项添加调试符号可以在报告中显示行号,还需要链接pthread库:
\begin{shell}
$ clang -fsanitize=thread -g thread.c -o thread -lpthread
$ ./thread
==================
WARNING: ThreadSanitizer: data race (pid=1474)
Write of size 4 at 0x000000cdf8f8 by thread T2:
#0 consumer /home/kai/sanitizers/thread.c:11:35 (thread+0x2b0fb2)
Previous write of size 4 at 0x000000cdf8f8 by thread T1:
#0 producer /home/kai/sanitizers/thread.c:6:35 (thread+0x2b0f22)
Location is global 'data' of size 4 at 0x000000cdf8f8
(thread+0x000000cdf8f8)
Thread T2 (tid=100437, running) created by main thread at:
#0 pthread_create /usr/src/contrib/llvm-project/compiler-rt/lib/
tsan/rtl/tsan_interceptors_posix.cpp:962:3 (thread+0x271703)
#1 main /home/kai/sanitizers/thread.c:18:3 (thread+0x2b1040)
Thread T1 (tid=100436, finished) created by main thread at:
#0 pthread_create /usr/src/contrib/llvm-project/compiler-rt/lib/
tsan/rtl/tsan_interceptors_posix.cpp:962:3 (thread+0x271703)
#1 main /home/kai/sanitizers/thread.c:17:3 (thread+0x2b1021)
SUMMARY: ThreadSanitizer: data race /home/kai/sanitizers/
thread.c:11:35 in consumer
==================
ThreadSanitizer: reported 1 warnings
\end{shell}
报告指向源文件的第6行和第11行,在这里访问全局变量。它还显示了名为T1和T2的两个线程,访问了变量以及分别调用pthread\_create()函数的文件和行号。
在此基础上,我们了解了如何使用三种不同类型的消毒器来识别应用程序中的常见问题。地址消毒器可识别常见的内存访问错误,例如:越界访问或在内存被释放后使用内存。使用内存消毒器,可以找到对未初始化内存的访问,线程消毒器可检查数据竞争。
下一节中,我们将尝试通过在随机数据上运行应用程序来触发消毒器,这个过程称为模糊测试。
================================================
FILE: content/part3/chapter10/3.tex
================================================
要测试应用程序,需要编写单元测试,这是确保软件正常运行的好方法。然而,由于可能输入的指数数量,可能会错过某些奇怪的输入,以及一些错误。
模糊测试在这方面可以提供帮助。其思想是为应用程序提供随机生成的数据,或者基于有效输入但随机更改的数据。这是重复进行的,因此应用程序将使用大量输入进行测试,这就是为什么模糊测试是一种强大的测试方法。人们注意到,模糊测试已经帮助发现了网页浏览器和其他软件中的数百个bug。
有趣的是,LLVM自带了自己的模糊测试库。libFuzzer最初是LLVM核心库的一部分,最终移到了compiler-rt中,所以该库设计用于测试小而快速的函数。
运行一个小示例,看看libFuzzer是如何工作的。首先,需要提供LLVMFuzzerTestOneInput()函数。该函数由fuzzer驱动程序调用,并提供一些输入。下面的函数对输入中的连续ASCII数字进行计数。但它完成了,就会给它随机输入。
将示例保存在fuzzer.c文件中:
\begin{cpp}
#include
#include
int count(const uint8_t *Data, size_t Size) {
int cnt = 0;
if (Size)
while (Data[cnt] >= '0' && Data[cnt] <= '9') ++cnt;
return cnt;
}
int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
count(Data, Size);
return 0;
}
\end{cpp}
前面的代码中,count()函数对Data变量所指向的内存中的位数进行计数,检查数据的大小只是为了确定是否有可用的字节。在while循环中,不检查大小。
与普通的C字符串一起使用,不会有错误,因为C字符串总是以0字节结束。LLVMFuzzerTestOneInput()函数是所谓的模糊目标,是libFuzzer调用的函数。会调用要测试的函数并返回0,这是当前唯一允许的值。
要用libFuzzer编译文件,必须添加-fsanitize=fuzzer选项。建议还启用地址消毒器和生成调试符号。我们可以使用下面的命令来编译fuzzer.c文件:
\begin{shell}
$ clang -fsanitize=fuzzer,address -g fuzzer.c -o fuzzer
\end{shell}
当运行测试时,就会发出一个冗长的报告。该报告包含比堆栈跟踪更多的信息,所以需要仔细看一下:
\begin{itemize}
\item
第一行说明用于初始化随机数生成器的种子,可以使用-seed=选项来重复执行:
\begin{shell}
INFO: Seed: 1297394926
\end{shell}
\item
默认情况下,libFuzzer将输入限制为最多4096字节,可以使用-max\_len=选项来改变默认值:
\begin{shell}
INFO: -max_len is not provided; libFuzzer will not generate
inputs larger than 4096 bytes
\end{shell}
\item
现在,可以在不提供样本输入的情况下运行测试。所有样本输入的集合称为corpus,在这次运行时它是空的:
\begin{shell}
INFO: A corpus is not provided, starting from an empty corpus
\end{shell}
\item
下面是关于生成的测试数据的一些信息:尝试了28个输入,找到了6个输入,它们加在一起长度为19字节,覆盖了6个覆盖点或基本块:
\begin{shell}
#28 NEW cov: 6 ft: 9 corp: 6/19b lim: 4 exec/s: 0 rss:
29Mb L: 4/4 MS: 4 CopyPart-PersAutoDict-CopyPart-ChangeByte- DE:
"1\x00"-
\end{shell}
\item
在此之后,检测到缓冲区溢出,并遵循来自地址消毒程序的信息。最后,报告说明导致缓冲区溢出的输入保存在哪里:
\begin{shell}
artifact_prefix='./'; Test unit written to ./crash-17ba0791499db908433b80f37c5fbc89b870084b
\end{shell}
\end{itemize}
有了保存的输入,测试用例可以用同样的崩溃输入再次执行:
\begin{shell}
$ ./fuzzer crash-17ba0791499db908433b80f37c5fbc89b870084b
\end{shell}
这有助于识别问题,可以使用保存的输入作来复现和修复可能出现的问题。然而,仅使用随机数据并不是在所有情况下都很有帮助。若尝试对tinylang词法分析器或解析器进行模糊测试,因为找不到有效的标记,将导致纯随机数据会立即拒绝。
提供一小部分有效的输入(称为语料库)更有用,语料库的文件可随机选作为输入。可以认为输入大部分是有效的,只是翻转了几个比特。这也适用于其他必须具有特定格式的输入。例如,对于一个处理JPEG和PNG文件的库,可以提供一些小的JPEG和PNG文件作为语料库。
提供语料库的示例如下所示。可以将语料库文件保存在一个或多个目录中,可以在printf命令的帮助下为模糊测试创建一个简单的语料库:
\begin{shell}
$ mkdir corpus
$ printf "012345\0" >corpus/12345.txt
$ printf "987\0" >corpus/987.txt
\end{shell}
运行测试时,必须在命令行上提供目录:
\begin{shell}
$ ./fuzzer corpus/
\end{shell}
然后,语料库用作生成随机输入的基础,正如报告所示的那样:
\begin{shell}
INFO: seed corpus: files: 2 min: 4b max: 7b total: 11b rss: 29Mb
\end{shell}
此外,若正在测试一个处理标记或其他神奇值(如编程语言)的函数,则可以通过提供一个带有标记字典来加快这个过程。对于编程语言,字典将包含该语言中使用的所有关键字和特殊符号,字典定义遵循简单的键-值样式。例如,要在字典中定义if关键字,可以添加以下内容:
\begin{shell}
kw1="if"
\end{shell}
这个键可选,可以忽略它。现在,可以使用-dict =选项在命令行上指定字典文件。
既然已经介绍了使用libFuzzer查找错误,来看看libFuzzer的限制和替代方案。
\mySubsubsection{10.3.1.}{限制和替代方案}
libFuzzer实现速度很快,但对测试目标提出了一些限制:
\begin{itemize}
\item
测试函数必须在内存中接受作为数组的输入。有些库函数需要数据的文件路径,无法使用libFuzzer进行测试。
\item
不能调用exit()函数。
\item
全球状态不应改变。
\item
不应使用硬件随机数生成器。
\end{itemize}
前两个限制是libFuzzer作为库实现的暗示。为了避免评估算法中的混淆,需要后两个限制。若不满足这些限制中的一个,会对模糊目标的两个相同调用可能会产生不同的结果。
最著名的模糊测试替代工具是AFL,可以在\url{https://github.com/google/AFL}上找到。AFL需要一个仪表化的二进制文件(提供了用于仪表化的LLVM插件),并要求应用程序将输入作为命令行上的文件路径。AFL和libFuzzer可以共享相同的语料库和相同的字典文件,所以可以使用这两种工具测试应用程序。此外,在libFuzzer不适用的地方,AFL可能是一个很好的选择。
还有很多方法可以影响libFuzzer的工作方式,可以阅读参考页面\url{https://llvm.org/docs/LibFuzzer.html}了解更多细节。
下一节中,我们将研究应用程序可能遇到的另一个问题——使用XRay工具检测性能瓶颈。
================================================
FILE: content/part3/chapter10/4.tex
================================================
若应用程序似乎运行缓慢,可能想知道代码中的时间花在了哪里,可以用XRay检测代码可以帮助完成这项任务。每个函数进入和退出时,都会向运行时库插入一个特殊的调用。允许计算函数调用的频率,以及在函数中花费的时间。可以在llvm/lib/XRay/目录中找到检测通道的实现,其运行时是compiler-rt的一部分。
下面的示例源代码中,使用ussleep()函数模拟实际工作。func1()函数休眠10µs,func2()函数调用func1()或休眠100µs,取决于n参数是奇数还是偶数。在main()函数中,两个函数都在循环中调用。需要在xraydemo.c文件中保存以下源码:
\begin{cpp}
#include
void func1() { usleep(10); }
void func2(int n) {
if (n % 2) func1();
else usleep(100);
}
int main(int argc, char *argv[]) {
for (int i = 0; i < 100; i++) { func1(); func2(i); }
return 0;
}
\end{cpp}
要在编译期间启用XRay检测,需要指定-fxrayinstrument选项,指令少于200条的函数不会检测。这是因为这是开发人员定义的任意阈值。我们的例子中,函数不会检测。该阈值可以通过-fxrayinstruction-threshold=选项指定。
或者,可以添加一个function属性来控制是否应该检测一个函数,添加以下原型将总是检测函数:
\begin{cpp}
void func1() __attribute__((xray_always_instrument));
\end{cpp}
通过使用xray\_never\_instrument属性,可以关闭函数的检测功能。
现在将使用命令行选项并编译xraydemo.c文件:
\begin{shell}
$ clang -fxray-instrument -fxray-instruction-threshold=1 -g xraydemo.c -o xraydemo
\end{shell}
生成的二进制文件中,检测在默认情况下关闭。若运行二进制文件,将注意到与未检测的二进制文件相比没有区别。XRAY\_OPTIONS环境变量用于控制运行时数据的记录,要启用数据收集,可以运行如下应用程序:
\begin{shell}
$ XRAY_OPTIONS="patch_premain=true xray_mode=xray-basic" ./xraydemo
\end{shell}
xray\_mode=xray-basic选项告诉运行时我们想要使用基本模式。这种模式下,将收集所有运行时数据,这可能导致生成较大的日志文件。当给出patch\_premain=true选项时,还会检测在main()函数之前运行的函数。
执行该命令后,采集数据所在目录下将创建一个新文件。需要使用llvm-xray工具从该文件中提取可读信息。
llvm-xray工具支持各种子命令,可以使用account子命令提取一些基本统计信息。例如,要获得调用次数最多的前10个函数,可以添加-top=10选项来限制输出,并添加-sort=count选项来指定函数调用计数作为排序标准,还可以使用-sortorder=选项影响排序顺序。
可以运行以下命令来从程序中获取统计信息:
\begin{shell}
$ llvm-xray account xray-log.xraydemo.xVsWiE --sort=count\
--sortorder=dsc --instr_map ./xraydemo
Functions with latencies: 3
funcid count sum function
1 150 0.166002 demo.c:4:0: func1
2 100 0.543103 demo.c:9:0: func2
3 1 0.655643 demo.c:17:0: main
\end{shell}
func1()函数调用的次数最多,还可以看到在此函数中花费的累积时间。这个例子只有三个函数,所以-top=选项在这里没有明显的效果,但是对于实际的应用程序,是非常有用的。
从收集的数据中,可以重建运行时期间发生的所有堆栈帧,使用stack子命令查看top 10的堆叠信息。为了简洁起见,这里简化了显示的输出:
\begin{shell}
$ llvm-xray stack xray-log.xraydemo.xVsWiE –instr_map ./xraydemo
Unique Stacks: 3
Top 10 Stacks by leaf sum:
Sum: 1325516912
lvl function count sum
#0 main 1 1777862705
#1 func2 50 1325516912
Top 10 Stacks by leaf count:
Count: 100
lvl function count sum
#0 main 1 1777862705
#1 func1 100 303596276
\end{shell}
堆栈帧是函数如何调用的序列。func2()函数由main()函数调用,这是累积时间最长的堆栈帧。深度取决于调用了多少函数,堆栈帧通常很大。
此子命令还可以用于从堆栈帧创建火焰图,可以轻松地确定哪些函数具有较大的累积运行时。输出是带有计数和运行时信息的堆栈帧。使用flamegraph.pl脚本,可以将数据转换为可伸缩的矢量图形(SVG)文件,并可以在浏览器中查看该文件。
使用以下命令,指示llvm-xray使用-all-stacks选项输出所有堆栈帧。使用-stack-format =flame选项,输出将是flamegraph.pl脚本所期望的格式。使用-aggregate-type选项,可以选择是按总时间统计堆栈帧,还是按调用次数统计堆栈帧。llvm-xray的输出通过管道输入到flamegraph.pl脚本中,结果输出保存在flame.svg文件中:
\begin{shell}
$ llvm-xray stack xray-log.xraydemo.xVsWiE --all-stacks\
--stack-format=flame --aggregation-type=time\
--instr_map ./xraydemo | flamegraph.pl >flame.svg
\end{shell}
运行命令并生成新的火焰图后,可以在浏览器中打开生成的flame.svg文件。图表如下:
\myGraphic{1.0}{content/part3/chapter10/images/1.png}{图10.1 - llvm-xray生成的火焰图}
火焰图乍一看可能会令人困惑,因为x轴没有通常的时间流逝含义。相反,函数只是按名称的字母顺序排序。此外,火焰图的y轴显示堆栈深度,底部从零开始计数。颜色的选择要有很好的对比,没有别的意思。从前面的图中,可以很容易地确定调用层次结构和在函数中花费的时间。
只有将鼠标移动到表示堆叠帧的矩形上时,才会显示堆叠帧的相关信息。通过单击该框架,可以放大该堆栈框架。若想确定值得优化的函数,火焰图会有很大的帮助。要了解更多关于火焰图的知识,请访问火焰图的发明者Brendan Gregg的主页:\url{http://www.brendangregg.com/flamegraphs.html}。
此外,可以使用转换子命令将数据转换为.yaml格式,或Chrome跟踪查看器可视化使用的格式。后者是另一种从数据创建图形的好方法。将数据保存在xray.evt文件后,执行如下命令:
\begin{shell}
$ llvm-xray convert --output-format=trace_event\
--output=xray.evt --symbolize --sort\
--instr_map=./xraydemo xray-log.xraydemo.xVsWiE
\end{shell}
若不指定-symbol选项,则结果图中不会显示函数名。
完成后,打开Chrome并输入chrome:///tracing。接下来,单击Load按钮来加载xray.evt文件。将看到以下可视化数据:
\myGraphic{1.0}{content/part3/chapter10/images/2.png}{图10.2 - 由llvm-xray生成的可视化Chrome跟踪查看器}
在此视图中,堆栈帧按函数调用发生的时间排序。要进一步了解可视化,请阅读\url{https://www.chromium.org/developers/how-tos/trace-event-profiling-tool}上的教程。
\begin{myTip}{Tip}
llvm-xray工具具有更多适用于性能分析的功能。可以在LLVM网站\url{https://llvm.org/docs/XRay.html}和\url{https://llvm.org/docs/XRayExample.html}上阅读相关内容。
\end{myTip}
本节中,了解如何使用XRay检测应用程序,如何收集运行时信息,以及如何可视化该数据。可以利用这些知识,找到应用程序中的性能瓶颈。
识别应用程序中错误的另一种方法是分析源代码,可以通过clang静态分析器完成。
================================================
FILE: content/part3/chapter10/5.tex
================================================
clang静态分析器是一个对C、C++和Objective C源代码执行额外检查的工具。静态分析器执行的检查比编译器执行的检查更彻底。在时间和所需资源方面,成本也更高。静态分析器有一组检查器,用于检查某些错误。
该工具执行源代码的符号解释,通过应用程序查看所有代码路径,并从中派生出应用程序中使用的值的约束。符号解释是编译器中常用的一种技术,例如:用于识别常数值。静态分析器的上下文中,检查器应用于派生值。
例如,若除法的除数为零,则静态分析器就会发出警告。可以在div.c文件中存储下面的例子来进行检查:
\begin{cpp}
int divbyzero(int a, int b) { return a / b; }
int bug() { return divbyzero(5, 0); }
\end{cpp}
本例中,静态分析器将对除0发出警告。但编译时,使用clang -Wall -c div.c命令编译该文件时,将不会显示任何警告。
有两种方法可以从命令行调用静态分析器。旧的工具是scan-build,包含在LLVM中,可以用于简单的场景。新的工具是CodeChecker,可在\url{https://github.com/Ericsson/codechecker/}上获得。要检查单个文件,扫描构建工具是最简单的解决方案。只需将编译命令传递给工具,其他的工作都是会自动完成:
\begin{shell}
$ scan-build clang -c div.c
scan-build: Using '/usr/home/kai/LLVM/llvm-17/bin/clang-17' for static
analysis
div.c:2:12: warning: Division by zero [core.DivideZero]
return a / b;
~~^~~
1 warning generated.
scan-build: Analysis run complete.
scan-build: 1 bug found.
scan-build: Run 'scan-view /tmp/scan-build-2021-03-01-023401-8721-1'
to examine bug reports.
\end{shell}
屏幕上的输出已经说明发现了一个问题,触发DivideZero检查器。在/tmp目录的上述子目录中,可以找到HTML格式的完整报告。可以使用scan-view命令查看报告,或者打开在浏览器的子目录中找到的index.html文件。
报告的第一页展示了发现的漏洞摘要:
\myGraphic{0.7}{content/part3/chapter10/images/3.png}{图10.3 - 摘要页面}
对于发现的每个错误,摘要页面显示了错误的类型、源码中的位置,以及分析器发现错误之后的路径长度。还提供了指向该错误的详细报告的链接。
下面的截图显示了错误的详细报告:
\myGraphic{0.7}{content/part3/chapter10/images/4.png}{图10.4 -详细报告}
有了这个详细的报告,就可以通过跟踪编号的气泡来验证错误。我们的简单示例展示了传递0作为参数值,如何导致除零错误。
因此,需要人工验证。若派生的约束对于某个检查器来说不够精确,则可能出现误报。
不仅限于该工具提供的检查程序——还可以添加新的检查程序。
\mySubsubsection{10.5.1.}{向clang静态分析器添加新的检查器}
许多C库提供了必须成对使用的函数。例如,C标准库提供了malloc()和free()函数。由malloc()函数分配的内存必须由free()函数释放一次。不调用free()函数,或者多次调用是一个编程错误。这种编码模式还有很多实例,静态分析器为其中一些实例提供了检查器。
iconv库提供了将文本从一种编码转换为另一种编码的函数——例如,从Latin-1编码转换为UTF-16编码。要执行转换,实现需要分配内存。为了透明地管理内部资源,iconv库提供了iconv\_open()和iconv\_close()函数,必须成对使用,类似于内存管理函数。没有为这些函数实现检查器,让我们实现一个。
要向clang静态分析器添加一个新的检查器,必须创建一个新的checker类的子类。静态分析器通过代码尝试所有可能的路径。分析器引擎在某些点生成事件——例如,在函数调用之前或之后。若需要处理这些事件,对应的类必须提供回调,Checker类和事件的注册在clang/include/clang/StaticAnalyzer/Core/Checker.h头文件中提供。
通常,检查器需要跟踪一些符号。不知道分析器引擎当前尝试的代码路径,所以检查器无法管理状态。因此,跟踪的状态必须向引擎注册,并且只能使用ProgramStateRef实例进行更改。
为了检测错误,检查器需要跟踪从iconv\_open()函数返回的描述符。分析器引擎为iconv\_open()函数的返回值返回一个SymbolRef实例。我们将这个符号与一个状态关联起来,以反映iconv\_close()是否调用。对于状态,我们创建了IconvState类,其封装了一个bool值。
新的IconvChecker类需要处理四种类型的事件:
\begin{itemize}
\item
PostCall,发生在函数调用之后。调用iconv\_open()函数之后,检索返回值的符号,并记住其处于“打开”状态。
\item
PreCall,发生在函数调用之前。调用iconv\_close()函数之前,检查描述符的符号是否处于“打开”状态。若没有,则已经为描述符调用了iconv\_close()函数,并且已经检测到对该函数的两次调用。
\item
DeadSymbols,在清理未使用的符号时发生。我们检查描述符中未使用的符号是否仍处于“打开”状态。是的话,则检测到缺少对iconv\_close()的调用,这是一个资源泄漏。
\item
PointerEscape,当分析器无法再跟踪符号时调用该函数。这种情况下,因为不能再推断描述符是否已关闭,所以需要从状态中删除符号。
\end{itemize}
可以创建一个新目录来实现新的检查器作为clang插件,并在IconvChecker.cpp文件中添加实现:
\begin{enumerate}
\item
对于实现,需要包含几个头文件。发布报告需要包含文件BugType.h,头文件Checker.h提供了Checker类的声明和事件的回调,它们在CallEvent.h中声明。此外,CallDescription.h文件有助于匹配函数和方法。最后,需要使用CheckerContext.h文件来声明CheckerContext类,该类是提供对分析器状态访问的中心类:
\begin{cpp}
#include "clang/StaticAnalyzer/Core/BugReporter/BugType.h"
#include "clang/StaticAnalyzer/Core/Checker.h"
#include "clang/StaticAnalyzer/Core/PathSensitive/CallDescription.h"
#include "clang/StaticAnalyzer/Core/PathSensitive/CallEvent.h"
#include "clang/StaticAnalyzer/Core/PathSensitive/CheckerContext.h"
#include "clang/StaticAnalyzer/Frontend/CheckerRegistry.h"
#include
\end{cpp}
\item
为了避免输入命名空间的名称,可以使用clang和ento命名空间:
\begin{cpp}
using namespace clang;
using namespace ento;
\end{cpp}
\item
我们将一个状态与代表图标描述符的每个符号关联起来。状态可以是打开的,也可以是关闭的,使用bool类型的变量,打开状态的值为true。状态值封装在IconvState结构体中,该结构体与FoldingSet数据结构一起使用,FoldingSet是一个过滤重复条目的哈希集。为了使用此数据结构实现,这里添加了Profile()方法,该方法设置该结构的唯一位。将结构体放入匿名命名空间中,以避免污染全局命名空间。这个类提供了getOpened()和getClosed()工厂方法以及isOpen()查询方法,而非对外暴露bool值:
\begin{cpp}
namespace {
class IconvState {
const bool IsOpen;
IconvState(bool IsOpen) : IsOpen(IsOpen) {}
public:
bool isOpen() const { return IsOpen; }
static IconvState getOpened() {
return IconvState(true);
}
static IconvState getClosed() {
return IconvState(false);
}
bool operator==(const IconvState &O) const {
return IsOpen == O.IsOpen;
}
void Profile(llvm::FoldingSetNodeID &ID) const {
ID.AddInteger(IsOpen);
}
};
} // namespace
\end{cpp}
\item
IconvState结构体表示iconv描述符的状态,其由SymbolRef类的符号表示。这最好使用映射来完成,符号作为键,将状态作为值。检查器不能保持状态,儿必须用全局程序状态注册状态,通过REGISTER\_MAP\_WITH\_PROGRAMSTATE宏完成。这个宏引入了IconvStateMap名称,稍后将使用它来访问映射:
\begin{cpp}
REGISTER_MAP_WITH_PROGRAMSTATE(IconvStateMap, SymbolRef,
IconvState)
\end{cpp}
\item
我们还在匿名命名空间中实现了IconvChecker类。请求的PostCall, PreCall, DeadSymbols和PointerEscape事件是Checker基类的模板参数:
\begin{cpp}
namespace {
class IconvChecker
: public Checker {
\end{cpp}
\item
IconvChecker类具有CallDescription类型的字段,用于识别程序中对iconv\_open(), iconv()和iconv\_close()的函数调用:
\begin{cpp}
CallDescription IconvOpenFn, IconvFn, IconvCloseFn;
\end{cpp}
\item
该类还包含对检测到的错误类型的引用:
\begin{cpp}
std::unique_ptr DoubleCloseBugType;
std::unique_ptr LeakBugType;
\end{cpp}
\item
最后,这个类有几个方法。除了构造函数和调用事件的方法之外,还需要一个方法来生成错误报告:
\begin{cpp}
void report(ArrayRef Syms,
const BugType &Bug, StringRef Desc,
CheckerContext &C, ExplodedNode *ErrNode,
std::optional Range =
std::nullopt) const;
public:
IconvChecker();
void checkPostCall(const CallEvent &Call,
CheckerContext &C) const;
void checkPreCall(const CallEvent &Call,
CheckerContext &C) const;
void checkDeadSymbols(SymbolReaper &SymReaper,
CheckerContext &C) const;
ProgramStateRef
checkPointerEscape(ProgramStateRef State,
const InvalidatedSymbols &Escaped,
const CallEvent *Call,
PointerEscapeKind Kind) const;
};
} // namespace
\end{cpp}
\item
IconvChecker类的构造函数的实现,使用函数的名称初始化CallDescription字段,并创建表示错误类型的对象:
\begin{cpp}
IconvChecker::IconvChecker()
: IconvOpenFn({"iconv_open"}), IconvFn({"iconv"}),
IconvCloseFn({"iconv_close"}, 1) {
DoubleCloseBugType.reset(new BugType(
this, "Double iconv_close", "Iconv API Error"));
LeakBugType.reset(new BugType(
this, "Resource Leak", "Iconv API Error",
/*SuppressOnSink=*/true));
}
\end{cpp}
\item
现在,可以实现第一个调用事件方法checkPostCall(),此方法在分析器执行函数调用后调用。若执行的函数不是一个全局C函数,也没有命名为iconv\_open:
\begin{cpp}
void IconvChecker::checkPostCall(
const CallEvent &Call, CheckerContext &C) const {
if (!Call.isGlobalCFunction())
return;
if (!IconvOpenFn.matches(Call))
return;
\end{cpp}
\item
否则,可以尝试以符号的形式获取函数的返回值。为了在全局程序状态中存储具有打开状态的符号,需要从CheckerContext实例中获取一个ProgramStateRef实例。状态是不可变的,将符号添加到状态会产生一个新状态。最后,分析器引擎通过调用addTransition()方法获知新状态:
\begin{cpp}
if (SymbolRef Handle =
Call.getReturnValue().getAsSymbol()) {
ProgramStateRef State = C.getState();
State = State->set(
Handle, IconvState::getOpened());
C.addTransition(State);
}
}
\end{cpp}
\item
同样,在分析器执行函数之前调用checkPreCall()方法。只对一个名为iconv\_close的全局C函数感兴趣:
\begin{cpp}
void IconvChecker::checkPreCall(
const CallEvent &Call, CheckerContext &C) const {
if (!Call.isGlobalCFunction()) {
return;
}
if (!IconvCloseFn.matches(Call)) {
return;
}
\end{cpp}
\item
若函数的第一个参数的符号(即iconv描述符)已知,则可以从程序状态中检索该符号的状态:
\begin{cpp}
if (SymbolRef Handle =
Call.getArgSVal(0).getAsSymbol()) {
ProgramStateRef State = C.getState();
if (const IconvState *St =
State->get(Handle)) {
\end{cpp}
\item
若状态表示关闭状态,则已经检测到双重关闭错误,并且可以生成错误报告。若已经为这个路径生成了错误报告,则调用generateErrorNode()返回一个nullptr值,所以必须检查这种情况:
\begin{cpp}
if (!St->isOpen()) {
if (ExplodedNode *N = C.generateErrorNode()) {
report(Handle, *DoubleCloseBugType,
"Closing a previous closed iconv "
"descriptor",
C, N, Call.getSourceRange());
}
return;
}
}
\end{cpp}
\item
否则,必须将符号的状态设置为“关闭”状态:
\begin{cpp}
State = State->set(
Handle, IconvState::getClosed());
C.addTransition(State);
}
}
\end{cpp}
\item
调用checkDeadSymbols()方法来清理未使用的符号。我们循环遍历跟踪的所有符号,并询问SymbolReaper实例当前符号是否已无效:
\begin{cpp}
void IconvChecker::checkDeadSymbols(
SymbolReaper &SymReaper, CheckerContext &C) const {
ProgramStateRef State = C.getState();
SmallVector LeakedSyms;
for (auto [Sym, St] : State->get()) {
if (SymReaper.isDead(Sym)) {
\end{cpp}
\item
若符号已无效,则需要检查状态。若状态仍然是打开的,这是潜在的资源泄漏。有一个例外:iconv\_open()在出现错误时返回-1。若分析器位于处理此错误的代码路径中,则由于函数调用失败而假定资源泄漏是错误的。可以尝试从ConstraintManager实例中获取符号的值,若该值为-1,则不认为符号是资源泄漏。可向SmallVector实例添加一个泄漏符号,以便稍后生成错误报告。最后,从程序状态中移除无效符号:
\begin{cpp}
if (St.isOpen()) {
bool IsLeaked = true;
if (const llvm::APSInt *Val =
State->getConstraintManager().getSymVal(
State, Sym))
IsLeaked = Val->getExtValue() != -1;
if (IsLeaked)
LeakedSyms.push_back(Sym);
}
State = State->remove(Sym);
}
}
\end{cpp}
\item
循环之后,调用generateNonFatalErrorNode(),此方法转换到新的程序状态,若此路径还没有错误节点,则返回一个错误节点。LeakedSyms容器保存了泄漏符号的列表(可能为空),调用report()方法来生成错误报告:
\begin{cpp}
if (ExplodedNode *N =
C.generateNonFatalErrorNode(State)) {
report(LeakedSyms, *LeakBugType,
"Opened iconv descriptor not closed", C, N);
}
}
\end{cpp}
\item
当分析器检测到无法跟踪参数的函数调用时,调用checkPointerEscape()函数,必须假设我们不知道iconv描述符是否会在函数内部关闭。异常是对iconv()的调用,执行转换并且已知不调用iconv\_close()函数,以及iconv\_close()函数本身,会在checkPreCall()方法中处理。若调用是在系统头文件中,并且知道在调用的函数中参数不会转义,则不会改变状态。其他情况下,都会从状态中删除符号:
\begin{cpp}
ProgramStateRef IconvChecker::checkPointerEscape(
ProgramStateRef State,
const InvalidatedSymbols &Escaped,
const CallEvent *Call,
PointerEscapeKind Kind) const {
if (Kind == PSK_DirectEscapeOnCall) {
if (IconvFn.matches(*Call) ||
IconvCloseFn.matches(*Call))
return State;
if (Call->isInSystemHeader() ||
!Call->argumentsMayEscape())
return State;
}
for (SymbolRef Sym : Escaped)
State = State->remove(Sym);
return State;
}
\end{cpp}
\item
report()方法生成一个错误报告。该方法的重要参数是符号数组、错误类型和错误描述。该方法内部,为每个符号创建一个错误报告,并将该符号标记为对该错误感兴趣的符号。若源范围作为参数提供,则会添加到报告中,并生成报告:
\begin{cpp}
void IconvChecker::report(
ArrayRef Syms, const BugType &Bug,
StringRef Desc, CheckerContext &C,
ExplodedNode *ErrNode,
std::optional Range) const {
for (SymbolRef Sym : Syms) {
auto R = std::make_unique(
Bug, Desc, ErrNode);
R->markInteresting(Sym);
if (Range)
R->addRange(*Range);
C.emitReport(std::move(R));
}
}
\end{cpp}
\item
现在,需要在CheckerRegistry实例中注册新的检查器。当插件加载时,使用clang\_registerCheckers(),我们在其中执行注册。每个检查器都有一个名称,并且属于一个包。因为iconv库是一个标准的POSIX接口,所以可以将IconvChecker称为检查器并将其放入unix打包程序中。这是addChecker()方法的第一个参数,第二个参数是功能的简要文档,第三个参数可以是一个文档的URI,该文档提供有关检查器的更多信息:
\begin{cpp}
extern "C" void
clang_registerCheckers(CheckerRegistry ®istry) {
registry.addChecker(
"unix.IconvChecker",
"Check handling of iconv functions", "");
}
\end{cpp}
\item
最后,需要声明我们正在使用的静态分析器API的版本,使系统能够确定插件是否兼容:
\begin{cpp}
extern "C" const char clang_analyzerAPIVersionString[] =
CLANG_ANALYZER_API_VERSION_STRING;
\end{cpp}
这就完成了新检查器的实现。为了构建插件,还需要在CMakeLists.txt文件中创建一个构建描述,该文件与IconvChecker.cpp位于同一目录下:
\item
首先定义所需的CMake版本和项目名称:
\begin{cmake}
cmake_minimum_required(VERSION 3.20.0)
project(iconvchecker)
\end{cmake}
\item
接下来,包括LLVM文件。若CMake不能自动找到文件,则必须设置LLVM\_DIR变量,指向包含CMake文件的LLVM目录:
\begin{cmake}
find_package(LLVM REQUIRED CONFIG)
\end{cmake}
\item
将包含CMake文件的LLVM目录附加到搜索路径中,并从LLVM中包含所需的模块:
\begin{cmake}
list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR})
include(AddLLVM)
include(HandleLLVMOptions)
\end{cmake}
\item
为clang加载CMake定义。若CMake不能自动找到文件,必须指定Clang\_DIR变量,使其指向包含CMake文件的Clang目录:
\begin{cmake}
find_package(Clang REQUIRED)
\end{cmake}
\item
接下来,将包含CMake文件的Clang目录附加到搜索路径中,并从Clang中包含所需的模块:
\begin{cmake}
list(APPEND CMAKE_MODULE_PATH ${Clang_DIR})
include(AddClang)
\end{cmake}
\item
定义头文件和库文件的位置,以及使用哪些定义:
\begin{cmake}
include_directories("${LLVM_INCLUDE_DIR}"
"${CLANG_INCLUDE_DIRS}")
add_definitions("${LLVM_DEFINITIONS}")
link_directories("${LLVM_LIBRARY_DIR}")
\end{cmake}
\item
前面的定义设置了构建环境。插入以下命令,定义了插件名称和源文件,并且它是一个clang插件:
\begin{cmake}
add_llvm_library(IconvChecker MODULE IconvChecker.cpp
PLUGIN_TOOL clang)
\end{cmake}
\item
Windows上,插件支持与Unix不同,必需的LLVM和clang库必须链接进来。下面的代码确保了这一点:
\begin{cmake}
if(WIN32 OR CYGWIN)
set(LLVM_LINK_COMPONENTS Support)
clang_target_link_libraries(IconvChecker PRIVATE
clangAnalysis
clangAST
clangStaticAnalyzerCore
clangStaticAnalyzerFrontend)
endif()
\end{cmake}
\end{enumerate}
现在,可以配置和构建插件,假设设置了CMAKE\_GENERATOR和CMAKE\_BUILD\_TYPE环境变量:
\begin{shell}
$ cmake -DLLVM_DIR=~/LLVM/llvm-17/lib/cmake/llvm \
-DClang_DIR=~/LLVM/llvm-17/lib/cmake/clang \
-B build
$ cmake --build build
\end{shell}
可以使用保存在conf.c文件中的以下源代码来测试新的检查器,该文件对iconv\_close()函数有两次调用:
\begin{cpp}
#include
void doconv() {
iconv_t id = iconv_open("Latin1", "UTF-16");
iconv_close(id);
iconv_close(id);
}
\end{cpp}
要将插件与扫描-构建脚本一起使用,需要通过-load-plugin选项指定插件的路径。使用conf.c文件运行如下:
\begin{shell}
$ scan-build -load-plugin build/IconvChecker.so clang-17 -c conv.c
scan-build: Using '/home/kai/LLVM/llvm-17/bin/clang-17' for static
analysis
conv.c:6:3: warning: Closing a previous closed iconv descriptor [unix.
IconvChecker]
6 | iconv_close(id);
| ^~~~~~~~~~~~~~~
1 warning generated.
scan-build: Analysis run complete.
scan-build: 1 bug found.
scan-build: Run 'scan-view /tmp/scan-build-2023-08-08-114154-12451-1' to examine bug reports.
\end{shell}
至此,已经了解了如何使用自己的检查器扩展clang静态分析器,可以使用这些知识来创建新的通用检查器并将其贡献给社区,或者创建需求专用的构建的检查器,以提高产品的质量。
静态分析器是通过利用clang基础设施构建的。下一节将介绍如何构建自己的clang插件扩展。
================================================
FILE: content/part3/chapter10/6.tex
================================================
静态分析器是一个令人印象深刻的例子,说明了可以使用clang基础设施做些什么。也可以使用插件扩展clang,这样就可以将自己的功能添加到clang中。该技术非常类似于在LLVM中添加一个通道插件。
我们用一个简单的插件来探索这个功能,LLVM编码标准要求函数名以小写字母开头。然而,编码标准已经发生了变化,在许多情况下,函数以大写字母开头。一个警告违反命名规则的插件可以帮助解决这个问题,所以来试一试。
因为要在AST上运行用户定义的操作,所以需要定义PluginASTAction类的子类。若使用clang库编写自己的工具,则可以为操作定义ASTFrontendAction类的子类。PluginASTAction类是ASTFrontendAction类的子类,具有解析命令行选项的能力。
需要的另一个类是ASTConsumer类的子类。ASTConsumer是一个类,可以使用它在AST上运行一个动作,而不管AST的来源是什么。可以在NamingPlugin.cpp文件中创建实现:
\begin{enumerate}
\item
首先包括所需的头文件。除了上面提到的ASTConsumer类,还需要一个编译器和插件注册表的实例:
\begin{cpp}
#include "clang/AST/ASTConsumer.h"
#include "clang/Frontend/CompilerInstance.h"
#include "clang/Frontend/FrontendPluginRegistry.h"
\end{cpp}
\item
使用clang命名空间,并将实现放在一个匿名命名空间中,以避免名称冲突:
\begin{cpp}
using namespace clang;
namespace {
\end{cpp}
\item
接下来,定义ASTConsumer类的子类。稍后,将希望在检测到违反命名规则的情况下发出警告,所以需要一个对DiagnosticsEngine实例的引用。
\item
需要在类中存储一个CompilerInstance实例,之后可以请求一个DiagnosticsEngine实例:
\begin{cpp}
class NamingASTConsumer : public ASTConsumer {
CompilerInstance &CI;
public:
NamingASTConsumer(CompilerInstance &CI) : CI(CI) {}
\end{cpp}
\item
ASTConsumer实例有几个入口方法,HandleTopLevelDecl()方法符合我们的目的,顶层为每个声明调用该方法。这不仅包括函数,还包括变量,所以必须使用LLVM RTTI dyn\_cast<>()函数来确定该声明是否为函数声明。HandleTopLevelDecl()方法有一个声明组作为参数,可以包含多个声明。这需要对声明进行循环。下面的代码显示了HandleTopLevelDecl()方法:
\begin{cpp}
bool HandleTopLevelDecl(DeclGroupRef DG) override {
for (DeclGroupRef::iterator I = DG.begin(),
E = DG.end();
I != E; ++I) {
const Decl *D = *I;
if (const FunctionDecl *FD =
dyn_cast(D)) {
\end{cpp}
\item
找到函数声明后,需要检索函数的名称。还需要确保名称不为空:
\begin{cpp}
std::string Name =
FD->getNameInfo().getName().getAsString();
assert(Name.length() > 0 &&
"Unexpected empty identifier");
\end{cpp}
若函数名不是以小写字母开头,就违反了命名规则:
\begin{cpp}
char &First = Name.at(0);
if (!(First >= 'a' && First <= 'z')) {
\end{cpp}
\item
要发出警告,需要一个DiagnosticsEngine实例,所以还需要一个消息ID。在clang内部,消息ID定义为枚举。因为插件不是clang的一部分,所以需要创建一个自定义ID,然后可以用它来发出警告:
\begin{cpp}
DiagnosticsEngine &Diag = CI.getDiagnostics();
unsigned ID = Diag.getCustomDiagID(
DiagnosticsEngine::Warning,
"Function name should start with "
"lowercase letter");
Diag.Report(FD->getLocation(), ID);
\end{cpp}
\item
除了关闭所有的大括号,需要从这个函数返回true来表示处理可以继续:
\begin{cpp}
}
}
}
return true;
}
};
\end{cpp}
\item
接下来,需要创建PluginASTAction子类,它实现clang调用的接口:
\begin{cpp}
class PluginNamingAction : public PluginASTAction {
public:
\end{cpp}
必须实现的第一个方法是CreateASTConsumer()方法,其返回NamingASTConsumer类的一个实例。该方法由clang调用,传递的CompilerInstance实例,可以访问编译器的所有重要类:
\begin{cpp}
std::unique_ptr
CreateASTConsumer(CompilerInstance &CI,
StringRef file) override {
return std::make_unique(CI);
}
\end{cpp}
\item
插件还可以访问命令行选项。目前,插件没有命令行参数,只会返回true来表示成功:
\begin{cpp}
bool ParseArgs(const CompilerInstance &CI,
const std::vector &args)
override {
return true;
}
\end{cpp}
\item
插件的操作类型描述了何时调用该操作。默认值是Cmdline,则必须在要调用的命令行上命名插件。要重写该方法并将其值更改为AddAfterMainAction,它会自动运行该操作:
\begin{cpp}
PluginASTAction::ActionType getActionType() override {
return AddAfterMainAction;
}
\end{cpp}
\item
PluginNamingAction类的实现完成了,只有类和匿名命名空间的右大括号不见了。将它们添加到代码中:
\begin{cpp}
};
}
\end{cpp}
\item
最后,需要注册插件。第一个参数是插件的名称,第二个参数是帮助信息:
\begin{cpp}
static FrontendPluginRegistry::Add X("naming-plugin", "naming plugin");
\end{cpp}
\end{enumerate}
这就完成了插件的实现。要编译这个插件,在CMakeLists.txt文件中创建一个构建描述。该插件位于clang源代码树之外,需要设置一个完整的项目,可以按照以下步骤来做:
\begin{enumerate}
\item
首先定义所需的CMake版本和项目名称:
\begin{cmake}
cmake_minimum_required(VERSION 3.20.0)
project(naminglugin)
\end{cmake}
\item
接下来,包括LLVM文件。若CMake不能自动找到文件,必须设置LLVM\_DIR变量,使它指向包含CMake文件的LLVM目录:
\begin{cmake}
find_package(LLVM REQUIRED CONFIG)
\end{cmake}
\item
将包含CMake文件的LLVM目录附加到搜索路径中,并包含一些必需的模块:
\begin{cmake}
list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR})
include(AddLLVM)
include(HandleLLVMOptions)
\end{cmake}
\item
然后,为clang加载CMake定义。若CMake不能自动找到文件,必须设置Clang\_DIR变量,使其指向包含CMake文件的Clang目录:
\begin{cmake}
find_package(Clang REQUIRED)
\end{cmake}
\item
接下来,定义头文件和库文件的位置,以及使用哪些定义:
\begin{cmake}
include_directories("${LLVM_INCLUDE_DIR}"
"${CLANG_INCLUDE_DIRS}")
add_definitions("${LLVM_DEFINITIONS}")
link_directories("${LLVM_LIBRARY_DIR}")
\end{cmake}
\item
前面的定义设置了构建环境。插入以下命令,定义了插件的名称,插件的源文件,并且是一个clang插件:
\begin{cmake}
add_llvm_library(NamingPlugin MODULE NamingPlugin.cpp
PLUGIN_TOOL clang)
\end{cmake}
Windows上,插件支持与Unix不同,必需的LLVM和clang库必须链接进来:
\begin{cmake}
if(WIN32 OR CYGWIN)
set(LLVM_LINK_COMPONENTS Support)
clang_target_link_libraries(NamingPlugin PRIVATE
clangAST clangBasic clangFrontend clangLex)
endif()
\end{cmake}
\end{enumerate}
现在,可以配置和构建插件,假设设置了CMAKE\_GENERATOR和CMAKE\_BUILD\_TYPE环境变量:
\begin{shell}
$ cmake -DLLVM_DIR=~/LLVM/llvm-17/lib/cmake/llvm \
-DClang_DIR=~/LLVM/llvm-17/lib/cmake/clang \
-B build
$ cmake --build build
\end{shell}
这些步骤创建NamingPlugin,所以构建目录中的动态库。
要测试这个插件,将下面的源代码保存为named.c文件。函数名Func1违反了命名规则,但主函数不违反规则:
\begin{cpp}
int Func1() { return 0; }
int main() { return Func1(); }
\end{cpp}
要调用这个插件,需要指定-fplugin=选项:
\begin{shell}
$ clang -fplugin=build/NamingPlugin.so naming.c
naming.c:1:5: warning: Function name should start with lowercase
letter
int Func1() { return 0; }
^
1 warning generated.
\end{shell}
这种调用需要重写PluginASTAction类的getActionType()方法,并且返回一个不同于Cmdline默认值的值。
若没有这样做——例如,想对插件动作的调用有更多的控制——则可以从编译器命令行运行插件:
\begin{shell}
$ clang -cc1 -load ./NamingPlugin.so -plugin naming-plugin naming.c
\end{shell}
恭喜你——已经构建了你的第一个clang插件!这种方法的缺点是它有一定的局限性。ASTConsumer类有不同的入口方法,都是粗粒度的。这可以通过使用RecursiveASTVisitor类来解决。该类遍历所有AST节点,可以覆盖感兴趣的VisitXXX()方法。使用Visitor,可以按照以下步骤重写这个插件:
\begin{enumerate}
\item
需要一个附加的包含来定义RecursiveASTVisitor类。插入如下代码:
\begin{cpp}
#include "clang/AST/RecursiveASTVisitor.h"
\end{cpp}
\item
然后,将访问者定义为匿名命名空间中的第一个类。将只存储对AST上下文的引用,能够访问用于AST操作的所有重要方法,包括发出警告所需的DiagnosticsEngine实例:
\begin{cpp}
class NamingVisitor
: public RecursiveASTVisitor {
private:
ASTContext &ASTCtx;
public:
explicit NamingVisitor(CompilerInstance &CI)
: ASTCtx(CI.getASTContext()) {}
\end{cpp}
\item
遍历期间,只要发现函数声明,就调用VisitFunctionDecl()方法。将内部循环的主体复制到HandleTopLevelDecl()函数中:
\begin{cpp}
virtual bool VisitFunctionDecl(FunctionDecl *FD) {
std::string Name =
FD->getNameInfo().getName().getAsString();
assert(Name.length() > 0 &&
"Unexpected empty identifier");
char &First = Name.at(0);
if (!(First >= 'a' && First <= 'z')) {
DiagnosticsEngine &Diag = ASTCtx.getDiagnostics();
unsigned ID = Diag.getCustomDiagID(
DiagnosticsEngine::Warning,
"Function name should start with "
"lowercase letter");
Diag.Report(FD->getLocation(), ID);
}
return true;
}
};
\end{cpp}
\item
这就完成了访问者的实现。NamingASTConsumer类中,只存储一个Visitor实例:
\begin{cpp}
std::unique_ptr Visitor;
public:
NamingASTConsumer(CompilerInstance &CI)
: Visitor(std::make_unique(CI)) {}
\end{cpp}
\item
删除HandleTopLevelDecl()方法——该功能现在位于访问者类中,因此需要重写HandleTranslationUnit()方法。这个类对每个翻译单元调用一次,将从这里开始AST遍历:
\begin{cpp}
void
HandleTranslationUnit(ASTContext &ASTCtx) override {
Visitor->TraverseDecl(
ASTCtx.getTranslationUnitDecl());
}
\end{cpp}
\end{enumerate}
这个新实现具有相同的功能,其优点是更容易扩展。例如,若想检查变量声明,必须实现VisitVarDecl()方法。若希望使用语句,则必须实现VisitStmt()方法。使用这种方法,可以为C、C++和Objective C语言的每个实体提供一个visitor方法。
通过访问AST,可以构建执行复杂任务的插件。如本节所述,强制命名约定是对clang的一个有用的补充。可以作为插件实现的另一个有用的附加功能是计算软件度量,例如圈复杂度。
还可以添加或替换AST节点,例如,添加运行时检测。添加插件可以以所需的方式扩展clang。
================================================
FILE: content/part3/chapter10/7.tex
================================================
本章中,了解了如何使用各种消毒器。使用地址消毒器检测指针错误,使用内存消毒器检测未初始化的内存访问,并使用线程消毒器执行数据争用。应用程序错误通常是由格式不正确的输入触发的,所以实现了模糊测试,用随机数据测试应用程序。
还使用XRay对应用程序进行了检测,以确定性能瓶颈,还了解了可视化数据的各种方法。本章还了解了如何利用clang静态分析器,通过解释源代码来识别潜在的错误,以及如何创建自己的clang插件。
这些技能将提高构建的应用程序的质量,可在应用程序用户抱怨运行时错误前发现它们。运用本章的知识,不仅可以发现大量的常见错误,还可以用新的功能扩展clang。
下一章中,将了解如何为LLVM添加一个新后端。
================================================
FILE: content/part3/chapter8/0.tex
================================================
LLVM中的大部分后端都用TableGen语言编写,TableGen是一种特殊的语言,用于生成C++源代码的片段,以避免每个后端都实现类似的代码,并减少源码量,所以了解TableGen很重要。
本章中,将了解以下内容:
\begin{itemize}
\item
了解TableGen语言时,将了解TableGen背后的主要思想
\item
TableGen语言的实验中,将定义自己的TableGen类和记录,并学习TableGen语言的语法
\item
使用TableGen文件生成C++代码中,可以开发自己的TableGen后端
\item
TableGen的缺点
\end{itemize}
本章结束时,能够使用现有的TableGen类来定义自己的记录,了解如何从头创建TableGen类和记录,以及如何开发TableGen后端以生成源码。
================================================
FILE: content/part3/chapter8/1.tex
================================================
本章使用的代码在这里, \url{https://github.com/PacktPublishing/Learn-LLVM-17/tree/main/Chapter08}。
================================================
FILE: content/part3/chapter8/2.tex
================================================
LLVM有自己的领域特定语言(DSL),称为TableGen,用于为很多用例生成C++代码,减少开发人员手写的代码量。TableGen语言不是一种编程语言,只用于定义记录。对于名称和值的集合来说,这是一个花哨的词。为了理解为什么使用这种语言,先来看两个例子。
定义CPU的一条机器指令需要的典型数据为:
\begin{itemize}
\item
指令的助记符
\item
位模式
\item
操作数的数量和类型
\item
可能的限制或副作用
\end{itemize}
这些数据可以表示为一条记录。例如,一个名为asmstring的字段可以保存助记符的值;比如,“添加”。另外,一个名为opcode的字段可以保存指令的二进制表示形式。记录将描述一个指令,每个LLVM后端都以这种方式描述指令集。
记录是一个通用的概念,可用来描述各种各样的数据。另一个例子是命令行选项的定义,命令行选项:
\begin{itemize}
\item
有名字
\item
有可选的参数
\item
有帮助文本
\item
可能属于一组选项
\end{itemize}
同样,可以将这些数据视为记录,Clang将这种方法用于Clang驱动程序的命令行选项。
\begin{myTip}{TableGen语言}
LLVM中,TableGen语言用于各种任务。后端的大部分是用TableGen语言编写的;例如,寄存器文件的定义,所有带有助记符和二进制编码的指令,调用约定,指令选择的模式,指令调度的调度模型。LLVM的其他用途包括定义内部函数、定义属性和定义命令行选项。
可以在\url{https://llvm.org/docs/TableGen/ProgRef.html}找到开发者参考,在\url{https://llvm.org/docs/TableGen/BackGuide.html}找到后端开发者指南。
\end{myTip}
为了实现这种灵活性,TableGen语言的解析和语义在库中实现。为了使用记录中生成的C++代码,需要创建一个工具来获取解析后的记录,并生成C++代码。在LLVM中,这个工具称为llvm-tblgen,在Clang中,称为clang-tblgen。这些工具包含项目所需的代码生成器,也可以用来学习TableGen语言,这是下一节要做的。
================================================
FILE: content/part3/chapter8/3.tex
================================================
初学者对TableGen语言感到不知所措,开始尝试这门语言后,就会发现没那么难。
\mySubsubsection{8.3.1.}{定义记录和类}
让我们为指令定义一条简单的记录:
\begin{shell}
def ADD {
string Mnemonic = "add";
int Opcode = 0xA0;
}
\end{shell}
def关键字表示定义了一条记录,后面跟着记录的名称。记录主体由花括号包围,主体由字段定义组成,类似于C++中的结构体。
可以使用llvm-tblgen工具查看生成的记录,将上述源代码保存在inst.td文件中,然后运行如下命令:
\begin{shell}
$ llvm-tblgen --print-records inst.td
------------- Classes -----------------
------------- Defs -----------------
def ADD {
string Mnemonic = "add";
int Opcode = 160;
}
\end{shell}
这只表明正确的解析了定义的记录。
定义指令使用单一的记录不是很舒服。现代的CPU有数百条指令,有了这么多的记录,很容易在字段名中引入输入错误。若决定重命名一个字段或添加一个新字段,则要更改的记录数量将会非常的大,所以蓝图必不可少。C++中,类也有类似的用途;TableGen中,也被称为类。下面是一个Inst类的定义,以及基于该类的两条记录:
\begin{shell}
class Inst {
string Mnemonic = mnemonic;
int Opcode = opcode;
}
def ADD : Inst<"add", 0xA0>;
def SUB : Inst<"sub", 0xB0>;
\end{shell}
类的语法类似于记录的语法。class关键字表示定义了一个类,后跟类名。类可以有一个参数列表,Inst类有两个参数,助记符和操作码,用于初始化记录的字段。这些字段的值在类实例化时给出。ADD和SUB记录显示了类的两个实例化,先使用llvm-tblgen来查看记录:
\begin{shell}
$ llvm-tblgen --print-records inst.td
------------- Classes -----------------
class Inst {
string Mnemonic = Inst:mnemonic;
int Opcode = Inst:opcode;
}
------------- Defs -----------------
def ADD { // Inst
string Mnemonic = "add";
int Opcode = 160;
}
def SUB { // Inst
string Mnemonic = "sub";
int Opcode = 176;
}
\end{shell}
现在,有了一个类定义和两条记录,用于定义记录的类的名称显示为注释。注意,该类的参数具有默认值“?”,这表示int未初始化。
\begin{myTip}{调试技巧}
要获得更详细的记录转储,可以使用-{}-print-detailed-records选项。输出包括记录和类定义的行号,以及初始化记录字段的位置。可用来跟踪一个记录字段如何分配了某个值。
\end{myTip}
ADD和SUB指令有很多共同之处,但加法是可交换运算,而减法不是。这里的一个小小的挑战是,TableGen只支持有限的数据类型集。已经在示例中使用了string和int,其他可用的数据类型有bit、bits、list和dag。位类型表示单个位;也就是0或1。若需要固定数量的位,则使用bits类型。例如,bits<5>是一个宽为5位的整数类型。基于另一种类型定义列表,可以使用list类型。例如,list是一个整数列表,而list是示例中Inst类的记录列表。dag类型表示有向无环图(dag)节点,对于定义模式和操作很有用,并且在LLVM后端中广泛使用。
使用单个比特表示一个标志就足够了,因此可以使用一个比特将指令标记为可交换的。大多数指令都不可交换,所以可以使用默认值:
\begin{shell}
class Inst {
string Mnemonic = mnemonic;
int Opcode = opcode;
bit Commutable = commutable;
}
def ADD : Inst<"add", 0xA0, 1>;
def SUB : Inst<"sub", 0xB0>;
\end{shell}
应该运行llvm-tblgen来验证记录是否按预期定义。
类不需要有参数,也可以稍后赋值。可以定义所有指令都不可交换:
\begin{shell}
class Inst {
string Mnemonic = mnemonic;
int Opcode = opcode;
bit Commutable = 0;
}
def SUB : Inst<"sub", 0xB0>;
\end{shell}
使用let语句,可以覆盖该值:
\begin{shell}
let Commutable = 1 in
def ADD : Inst<"add", 0xA0>;
\end{shell}
或者,打开记录主体来覆盖该值:
\begin{shell}
def ADD : Inst<"add", 0xA0> {
let Commutable = 1;
}
\end{shell}
同样,请使用llvm-tblgen来验证在这两种情况下,是否将可交换标志设置为1。
类和记录可以从多个类继承,并且可以添加新字段或覆盖现有字段的值。可以使用继承来引入一个新的可交换类:
\begin{shell}
class Inst {
string Mnemonic = mnemonic;
int Opcode = opcode;
bit Commutable = 0;
}
class CommutableInst
: Inst {
let Commutable = 1;
}
def SUB : Inst<"sub", 0xB0>;
def ADD : CommutableInst<"add", 0xA0>;
\end{shell}
生成的记录总相同,但是该语言允许以不同的方式定义记录。注意,在后一个示例中,可交换标志可能是多余的:代码生成器可以查询它所基于的类的记录,若该列表包含可交换类,则可以在内部设置该标志。
\mySubsubsection{8.3.2.}{使用多个类一次创建多个记录}
另一个常用语句是multiclass,multiclass允许一次定义多个记录,让我们扩展这个示例。
add指令的定义非常简单,一个CPU通常有几个add指令。一种常见的变体是一条指令有两个寄存器操作数,而另一条指令有一个寄存器操作数和一个直接操作数,这是一个小数字。假设对于具有直接操作数的指令,指令集的设计者决定用i作为后缀来标记,所以以add和addi指令结束。此外,假设操作码相差1。许多算术和逻辑指令遵循这个方案,所以定义需要尽可能紧凑。
第一个挑战是需要操作值,可用于修改值的操作符数量有限。例如,要生成1和字段操作码的值的和:
\begin{shell}
!add(opcode, 1)
\end{shell}
这样的表达式最好用作类的参数。测试一个字段值,需要不可用的动态语句,所以不可能根据找到的值更改。记住,所有的计算都是在构造记录时完成的!
类似地,字符串也可以连接起来:
\begin{shell}
!strconcat(mnemonic,"i")
\end{shell}
因为所有操作符都以感叹号(!)开头,所以也称为bang操作符。可以在开发者参考:\url{https://llvm.org/docs/TableGen/ProgRef.html#appendix-a-bang-operators}中找到bang操作符的完整列表。
现在,可以定义一个多类。Inst类再次作为基类:
\begin{shell}
class Inst {
string Mnemonic = mnemonic;
int Opcode = opcode;
}
\end{shell}
多类的定义有点复杂,分步骤来做:
\begin{enumerate}
\item
多类的定义使用与类相似的语法。新的多类名为InstWithImm,有两个参数,助记符和操作码:
\begin{shell}
multiclass InstWithImm {
\end{shell}
\item
首先,定义一条带有两个寄存器操作数的指令。与普通的记录定义一样,使用def关键字定义记录,并使用Inst类创建记录内容。还需要定义一个空名称,将在后面解释为什么这么做:
\begin{shell}
def "": Inst;
\end{shell}
\item
接下来,用直接操作数定义一条指令。可以使用bang操作符从多类的参数中派生助记符和操作码的值,记录命名为I:
\begin{shell}
def I: Inst;
\end{shell}
\item
这就是全部了,类主体已经完成了:
\begin{shell}
}
\end{shell}
\end{enumerate}
要实例化记录,必须使用defm关键字:
\begin{shell}
defm ADD : InstWithImm<"add", 0xA0>;
\end{shell}
语句的结果如下所示:
\begin{enumerate}
\item
Inst<"add", 0xA0>记录实例化,记录的名称由multiclass语句中defm关键字后面的名称和def后面的名称拼接而成,因此名称为ADD。
\item
为基于类型的别名分析生成元数据:将向LLVM IR添加的元数据,这将帮助LLVM更好地优化代码
\item
Inst<"addi", 0xA1>记录实例化,并按照相同的模式命名为ADDI。
\end{enumerate}
用llvm-tblgen验证这个声明:
\begin{shell}
$ llvm-tblgen –print-records inst.td
------------- Classes -----------------
class Inst {
string Mnemonic = Inst:mnemonic;
int Opcode = Inst:opcode;
}
------------- Defs -----------------
def ADD { // Inst
string Mnemonic = "add";
int Opcode = 160;
}
def ADDI { // Inst
string Mnemonic = "addi";
int Opcode = 161;
}
\end{shell}
使用multiclass,可以一次生成多个记录。这个功能会经常使用!
记录不需要有名称,匿名记录完全没问题,省略名称是定义匿名记录所需要做的全部工作。由多类生成的记录的名称由两个名称组成,并且必须给出两个名称才能创建命名记录。若省略def后面的名称,则只创建匿名记录。若多类中的def后面没有名称,则会创建一个匿名记录。这就是多类示例中的第一个定义使用空名称""的原因:没有它,记录是匿名的。
\mySubsubsection{8.3.3.}{模拟函数调用}
某些情况下,使用前面例子中的多分类可能会导致重复。假定CPU也支持内存操作数,类似于直接操作数。可以通过在多类中添加一个新的记录来定义:
\begin{shell}
multiclass InstWithOps {
def "": Inst;
def "I": Inst;
def "M": Inst;
}
\end{shell}
这完全没问题,但假设要定义的记录不是3条,而是16条,并且需要多次这样做。出现这种情况的场景是,CPU支持许多向量类型,并且向量指令根据所使用的类型略有不同。
请注意,带有def语句的所有三行都具有相同的结构。变化只存在于名称和助记符的后缀中,增量值会添加到操作码中。C语言中,可以将数据放入一个数组中,并实现一个函数,该函数根据索引值返回数据。然后,可以在数据上创建一个循环,而不是手动重复书写语句。
令人惊讶的是,可以在TableGen语言中做类似的事情!下面是如何转换示例:
\begin{enumerate}
\item
要存储数据,需要定义一个包含所有必需字段的类。这个类称为InstDesc,其描述了指令的一些属性:
\begin{shell}
class InstDesc {
string Name = name;
string Suffix = suffix;
int Delta = delta;
}
\end{shell}
\item
现在,可以为每个操作数类型定义记录。注意,其准确地捕获了数据中观察到的差异:
\begin{shell}
def RegOp : InstDesc<"", "", 0>;
def ImmOp : InstDesc<"I", """, 1>;
def MemOp : InstDesc"""","""", 2>;
\end{shell}
\item
假设有一个枚举数字0、1和2的循环,并且希望根据索引选择先前定义的记录之一,需要怎么做呢?解决方案是创建一个以索引作为参数的getDesc类。它有一个字段ret,可以将其解释为返回值。要给这个字段赋正确的值,使用第二个操作符:
\begin{shell}
class getDesc {
InstDesc ret = !cond(!eq(n, 0) : RegOp,
!eq(n, 1) : ImmOp,
!eq(n, 2) : MemOp);
}
\end{shell}
此操作符的工作方式类似于C中的switch/case语句。
\item
现在,可以定义多类了。TableGen语言有一个循环语句,允许定义变量,但没有动态执行!因此,循环范围是静态定义的,可以为变量赋值,但不能更改该值,但这也足以检索数据。注意getDesc类的使用类似于函数调用,但是没有函数调用!相反,将创建一个匿名记录,并从该记录中获取值。最后,past操作符(\#)执行字符串连接,类似于前面使用的!strconcat操作符:
\begin{shell}
multiclass InstWithOps {
foreach I = 0-2 in {
defvar Name = getDesc.ret.Name;
defvar Suffix = getDesc.ret.Suffix;
defvar Delta = getDesc.ret.Delta;
def Name: Inst;
}
}
\end{shell}
\item
现在,可以像以前一样使用multiclass来定义记录:
\begin{shell}
defm ADD : InstWithOps<"add", 0xA0>;
\end{shell}
\end{enumerate}
请运行llvm-tblgen查看记录。除了各种ADD记录之外,还将看到使用getDesc类生成的一些匿名记录。
几个LLVM后端的指令定义中使用了这种技术。根据现在所掌握的知识,阅读这些文件应该没有问题。
foreach语句使用语法0-2来表示范围的边界,这叫做值域。另一种语法是使用三个点(0…3),这在数字为负数时很有用。最后,不局限于数值范围,还可以遍历元素列表,可以使用字符串或先前定义的记录。例如,可能喜欢使用foreach语句,但觉得使用getDesc类过于复杂。这种情况下,循环InstDesc记录是解决方案:
\begin{shell}
multiclass InstWithOps {
foreach I = [RegOp, ImmOp, MemOp] in {
defvar Name = I.Name;
defvar Suffix = I.Suffix;
defvar Delta = I.Delta;
def Name: Inst;
}
}
\end{shell}
目前,只使用最常用的语句在TableGen语言中定义了记录。下一节中,将了解如何从TableGen语言中定义的记录生成C++源代码。
================================================
FILE: content/part3/chapter8/4.tex
================================================
上一节中,用TableGen语言定义了记录。使用这些记录,需要编写自己的TableGen后端,该后端可以生成C++源代码或使用记录作为输入执行其他操作。
第3章中,Lexer类的实现使用数据库文件来定义标记和关键字,各种查询函数使用该数据库文件。除此之外,数据库文件还用于实现关键字过滤器。关键字过滤器是一个哈希映射,使用llvm::StringMap类实现。当找到一个标识符时,就调用关键字过滤器来确定该标识符是否实际上是一个关键字。若仔细看看ppprofiler通道的实现,则将看到该函数经常调用,所以实现该功能很有用。
然而,这并不像看起来那么容易。例如,可以尝试用二进制搜索替换散列映射中的查找。这需要对数据库文件中的关键字进行排序。目前,在开发过程中,一个新的关键字可能会添加到错误的地方。确保关键字按正确顺序排列的唯一方法是,添加一些在运行时检查顺序的代码。
可以通过更改内存布局来加快标准二进制搜索。可以使用Eytzinger布局,而不是对关键字进行排序,其以宽度优先的顺序枚举搜索树。这种布局增加了数据的缓存局部性,加快了搜索速度。就我个人而言,不可能用手动的方式,在数据库文件中以宽度优先的顺序维护关键字。
另一种流行的搜索方法是生成最小完美哈希函数。若将新键插入动态哈希表(如llvm::StringMap),则该键可能会映射到已占用的槽,这称为键碰撞。键冲突是不可避免的,已经开发了许多策略来缓解这个问题。但若知道所有的键,就可以构建没有键冲突的哈希函数,这样的哈希函数堪称完美。若不需要比键更多的槽,则称为最小键。可以高效地生成完美的哈希函数——例如,使用gperf GNU工具。
总而言之,使用关键字生成查找函数一定有原因,让我们将数据库文件转成到TableGen!
\mySubsubsection{8.4.1.}{用TableGen语言定义数据}
TokenKinds.def数据库文件定义了三个不同的宏。TOK宏用于没有固定拼写的令牌——用于整数字面值。PUNCTUATOR宏用于所有类型的标点符号,并包含首选拼写。KEYWORD宏定义了一个由字面量和一个标志组成的关键字,这个标志用于指示这个字面量在哪个语言级别上是关键字。例如,C++11中就添加了thread\_local关键字。
TableGen语言中用来表达关键字的方法是,创建一个保存所有数据的Token类。然后,可以添加该类的子类,还需要一个Flag类,用于与关键字一起定义的标志,还需要一个类来定义关键字过滤器。这些类定义了基本的数据结构,可以在其他项目中重用,可以创建了一个Keyword.td文件:
\begin{enumerate}
\item
标志为名称和相关联的值建模,使用这些数据生成一个枚举很容易:
\begin{shell}
class Flag {
string Name = name;
int Val = val;
}
\end{shell}
\item
Token类用作基类,只是有一个名字。注意,这个类没有参数:
\begin{shell}
class Token {
string Name;
}
\end{shell}
\item
Tok类与数据库文件中相应的Tok宏具有相同的功能,表示没有固定拼写的标记。它派生自基类Token,只是为名称添加了初始化:
\begin{shell}
class Tok : Token {
let Name = name;
}
\end{shell}
\item
以同样的方式,标点器类类似于PUNCTUATOR宏,为标记的拼写添加了一个字段:
\begin{shell}
class Punctuator : Token {
let Name = name;
string Spelling = spelling;
}
\end{shell}
\item
最后,Keyword类需要一个标志列表:
\begin{shell}
class Keyword flags> : Token {
let Name = name;
list Flags = flags;
}
\end{shell}
\item
有了这些定义,现在可以为关键字过滤器定义一个类,称为TokenFilter。其接受标记列表作为参数:
\begin{shell}
class TokenFilter tokens> {
string FunctionName;
list Tokens = tokens;
}
\end{shell}
\end{enumerate}
使用这些类定义,就能够从TokenKinds.def数据库文件中捕获所有数据。TinyLang语言不使用标志,但实际使用的语言,如C和C++经历了几次修订,通常需要标记。因此,我们使用C和C++中的关键字作为示例,来创建一个KeywordC.td文件:
\begin{enumerate}
\item
首先,包含前面创建的类定义:
\begin{shell}
Include "Keyword.td"
\end{shell}
\item
接下来,定义标志,该值为该标志的二进制值。注意,如何使用!or操作符来为KEYALL标志创建值:
\begin{shell}
def KEYC99 : Flag<"KEYC99", 0x1>;
def KEYCXX : Flag<"KEYCXX", 0x2>;
def KEYCXX11: Flag<"KEYCXX11", 0x4>;
def KEYGNU : Flag<"KEYGNU", 0x8>;
def KEYALL : Flag<"KEYALL",
!or(KEYC99.Val, KEYCXX.Val,
KEYCXX11.Val , KEYGNU.Val)>;
\end{shell}
\item
有些符号没有固定的拼写,例如注释:
\begin{shell}
def : Tok<"comment">;
\end{shell}
\item
操作符是使用Punctuator类定义:
\begin{shell}
def : Punctuator<"plus", "+">;
def : Punctuator<"minus", "-">;
\end{shell}
\item
关键字需要使用不同的标志:
\begin{shell}
def kw_auto: Keyword<"auto", [KEYALL]>;
def kw_inline: Keyword<"inline", [KEYC99,KEYCXX,KEYGNU]>;
def kw_restrict: Keyword<"restrict", [KEYC99]>;
\end{shell}
\item
最后,是关键字过滤器的定义:
\begin{shell}
def : TokenFilter<[kw_auto, kw_inline, kw_restrict]>;
\end{shell}
\end{enumerate}
当然,该文件不包括C和C++中的所有标记,其演示了定义的TableGen类的所有可能用法。
基于这些TableGen文件,我们将在下一节中实现TableGen后端。
\mySubsubsection{8.4.2.}{实现TableGen后端}
由于解析和创建记录是通过LLVM库完成的,只需要关心后端实现,主要由基于记录中的信息生成C++源代码片段组成。需要清楚要生成什么源代码,才能将其放入后端。
\mySamllsection{绘制要生成的源码的草图}
TableGen工具的输出是一个包含C++片段的文件,这些片段由宏保护。目标是替换TokenKinds.def数据库文件。根据TableGen文件中的信息,可以生成以下内容:
\begin{enumerate}
\item
用于定义标志的枚举成员。开发者可以自由命名类型,但应该基于无符号类型。若生成的文件名为TokenKinds.inc,预期的使用方式为:
\begin{cpp}
enum Flags : unsigned {
#define GET_TOKEN_FLAGS
#include "TokenKinds.inc"
}
\end{cpp}
\item
TokenKind枚举,以及getTokenName()、getPunctuatorSpelling()和getKeywordSpelling()函数的原型和定义。这段代码替换了TokenKinds.def数据库文件,大部分TokenKinds.h包含文件和TokenKinds.cpp源文件。
\item
新的lookupKeyword()函数,可以用来代替使用llvm::StringMap类型的当前实现,这就是要优化的函数。
\end{enumerate}
了解了想要生成的内容,就可以开始实现后端了。
\mySamllsection{创建一个新的TableGen工具}
新工具使用一个驱动程序,该驱动计算命令行选项,并在不同的文件中调用生成函数和实际的生成器函数。我们将驱动文件命名为TableGen.cpp,将包含生成器的文件命名为TokenEmitter.cpp,还需要TableGenBackends.h头文件。让我们从生成TokenEmitter.cpp文件中的C++代码开始实现:
\begin{enumerate}
\item
像往常一样,文件以包含所需的头文件开始。最重要的是llvm/TableGen/Record.h,定义了Record类,用于保存解析.td文件生成的记录:
\begin{cpp}
#include "TableGenBackends.h"
#include "llvm/Support/Format.h"
#include "llvm/TableGen/Record.h"
#include "llvm/TableGen/TableGenBackend.h"
#include
\end{cpp}
\item
为了简化编码,导入了llvm命名空间:
\begin{cpp}
using namespace llvm;
\end{cpp}
\item
TokenAndKeywordFilterEmitter类负责生成C++源代码。如前一节所述,emitFlagsFragment()、emitTokenKind()和emitKeywordFilter()方法会生成源代码,其中包含要生成的源代码的草图。唯一的公共方法run()调用了所有编写代码的方法,记录保存在RecordKeeper的一个实例中,并作为参数传递给构造函数。这个类位于匿名命名空间中:
\begin{cpp}
namespace {
class TokenAndKeywordFilterEmitter {
RecordKeeper &Records;
public:
explicit TokenAndKeywordFilterEmitter(RecordKeeper &R)
: Records(R) {}
void run(raw_ostream &OS);
private:
void emitFlagsFragment(raw_ostream &OS);
void emitTokenKind(raw_ostream &OS);
void emitKeywordFilter(raw_ostream &OS);
};
} // End anonymous namespace
\end{cpp}
\item
run()方法调用所有发出方法,也乘以每个相位的长度。可用-{}-time-phases选项设置,再在所有代码生成后显示时间:
\begin{cpp}
void TokenAndKeywordFilterEmitter::run(raw_ostream &OS) {
// Emit Flag fragments.
Records.startTimer("Emit flags");
emitFlagsFragment(OS);
// Emit token kind enum and functions.
Records.startTimer("Emit token kind");
emitTokenKind(OS);
// Emit keyword filter code.
Records.startTimer("Emit keyword filter");
emitKeywordFilter(OS);
Records.stopTimer();
}
\end{cpp}
\item
emitFlagsFragment()方法显示了生成C++源代码的函数的典型结构,生成的代码由GET\_TOKEN\_FLAGS宏保护。要生成C++源片段,需要循环遍历TableGen文件中从Flag类派生的所有记录。有了这样的记录,就很容易查询记录的名称和值。注意,名称Flag、Name和Val必须与TableGen文件中的完全相同。若在TableGen文件中将Val重命名为Value,则还需要更改此函数中的字符串。所有生成的源代码都写入提供的流,OS:
\begin{cpp}
void TokenAndKeywordFilterEmitter::emitFlagsFragment(
raw_ostream &OS) {
OS << "#ifdef GET_TOKEN_FLAGS\n";
OS << "#undef GET_TOKEN_FLAGS\n";
for (Record *CC :
Records.getAllDerivedDefinitions("Flag")) {
StringRef Name = CC->getValueAsString("Name");
int64_t Val = CC->getValueAsInt("Val");
OS << Name << " = " << format_hex(Val, 2) << ",\n";
}
OS << "#endif\n";
}
\end{cpp}
\item
emitTokenKind()方法发出标记分类函数的声明和定义。先看一下如何发出声明,整体结构与前一种方法相同——只是要生成更多的C++源码,生成的源码由GET\_TOKEN\_KIND\_DECLARATION宏保护。注意,此方法尝试生成格式良好的C++代码,使用新行和缩进,就像人类开发者一样。若生成的源码不正确,并且需要检查它以找到错误,这将非常有帮助。也很容易犯这样的错误:毕竟,正在编写一个生成C++源代码的C++函数。
首先,生成TokenKind枚举。关键字的名称应该以kw\_字符串作为前缀。循环遍历Token类的所有记录,若也是Keyword类的子类,则可以查询这些记录,生成的前缀为:
\begin{cpp}
OS << "#ifdef GET_TOKEN_KIND_DECLARATION\n"
<< "#undef GET_TOKEN_KIND_DECLARATION\n"
<< "namespace tok {\n"
<< " enum TokenKind : unsigned short {\n";
for (Record *CC :
Records.getAllDerivedDefinitions("Token")) {
StringRef Name = CC->getValueAsString("Name");
OS << " ";
if (CC->isSubClassOf("Keyword"))
OS << "kw_";
OS << Name << ",\n";
}
OS << " NUM_TOKENS\n"
<< " };\n";
\end{cpp}
\item
接下来,生成函数声明。这只是一个常量字符串,这将完成声明生成:
\begin{cpp}
OS << " const char *getTokenName(TokenKind Kind) "
"LLVM_READNONE;\n"
<< " const char *getPunctuatorSpelling(TokenKind "
"Kind) LLVM_READNONE;\n"
<< " const char *getKeywordSpelling(TokenKind "
"Kind) "
"LLVM_READNONE;\n"
<< "}\n"
<< "#endif\n";
\end{cpp}
\item
现在,转向生成定义。同样,生成的代码由一个名为GET\_TOKEN\_KIND\_DEFINITION的宏保护。首先,将标记名称发送到TokNames数组中,getTokenName()函数使用该数组检索名称。注意,当在字符串中使用引号符号时,必须转义为\verb|\|":
\begin{cpp}
OS << "#ifdef GET_TOKEN_KIND_DEFINITION\n";
OS << "#undef GET_TOKEN_KIND_DEFINITION\n";
OS << "static const char * const TokNames[] = {\n";
for (Record *CC :
Records.getAllDerivedDefinitions("Token")) {
OS << " \"" << CC->getValueAsString("Name")
<< "\",\n";
}
OS << "};\n\n";
OS << "const char *tok::getTokenName(TokenKind Kind) "
"{\n"
<< " if (Kind <= tok::NUM_TOKENS)\n"
<< " return TokNames[Kind];\n"
<< " llvm_unreachable(\"unknown TokenKind\");\n"
<< " return nullptr;\n"
<< "};\n\n";
\end{cpp}
\item
接下来,生成getPunctuatorSpelling()函数。与其他部分的区别是,循环遍历从punctuation类派生的所有记录。还会生成一个switch语句,而不是一个数组:
\begin{cpp}
OS << "const char "
"*tok::getPunctuatorSpelling(TokenKind "
"Kind) {\n"
<< " switch (Kind) {\n";
for (Record *CC :
Records.getAllDerivedDefinitions("Punctuator")) {
OS << " " << CC->getValueAsString("Name")
<< ": return \""
<< CC->getValueAsString("Spelling") << "\";\n";
}
OS << " default: break;\n"
<< " }\n"
<< " return nullptr;\n"
<< "};\n\n";
\end{cpp}
\item
最后,生成getKeywordSpelling()函数。代码类似于发出getPunctuatorSpelling(),这次循环遍历Keyword类的所有记录,并且该名称以kw\_为前缀:
\begin{cpp}
OS << "const char *tok::getKeywordSpelling(TokenKind "
"Kind) {\n"
<< " switch (Kind) {\n";
for (Record *CC :
Records.getAllDerivedDefinitions("Keyword")) {
OS << " kw_" << CC->getValueAsString("Name")
<< ": return \"" << CC->getValueAsString("Name")
<< "\";\n";
}
OS << " default: break;\n"
<< " }\n"
<< " return nullptr;\n"
<< «};\n\n»;
OS << «#endif\n»;
}
\end{cpp}
\item
因为生成过滤器需要从记录中收集一些数据,所以emitKeywordFilter()方法比前面的方法更复杂。生成的源代码使用std::lower\_bound()函数,从而实现二分查找。
TableGen文件中可以定义TokenFilter类的多个记录。出于演示目的,最多只生成一个标记过滤器方法:
\begin{cpp}
std::vector AllTokenFilter =
Records.getAllDerivedDefinitionsIfDefined(
"TokenFilter");
if (AllTokenFilter.empty())
return;
\end{cpp}
\item
用于筛选器的关键字位于名为Tokens的列表中。要访问该列表,首先需要查找记录中的Tokens字段。这将返回一个指向RecordVal类实例的指针,可以通过调用方法getValue()从中检索Initializer实例。Tokens字段可定义为一个列表,可将初始化器实例强制转换为ListInit。若失败,则退出该函数:
\begin{cpp}
ListInit *TokenFilter = dyn_cast_or_null(
AllTokenFilter[0]
->getValue("Tokens")
->getValue());
if (!TokenFilter)
return;
\end{cpp}
\item
现在,可以构造一个过滤器表了。对于TokenFilter列表中存储的每个关键字,需要Flag字段的名称和值。该字段再次定义为列表,因此需要遍历这些元素以计算最终值。结果名称/标志值对存储在Table vector中:
\begin{cpp}
using KeyFlag = std::pair;
std::vector Table;
for (size_t I = 0, E = TokenFilter->size(); I < E;
++I) {
Record *CC = TokenFilter->getElementAsRecord(I);
StringRef Name = CC->getValueAsString("Name");
uint64_t Val = 0;
ListInit *Flags = nullptr;
if (RecordVal *F = CC->getValue("Flags"))
Flags = dyn_cast_or_null(F->getValue());
if (Flags) {
for (size_t I = 0, E = Flags->size(); I < E; ++I) {
Val |=
Flags->getElementAsRecord(I)->getValueAsInt(
"Val");
}
}
Table.emplace_back(Name, Val);
}
\end{cpp}
\item
为了能够执行二分查找,需要对表进行排序。比较函数由Lambda函数提供:
\begin{cpp}
llvm::sort(Table.begin(), Table.end(),
[](const KeyFlag A, const KeyFlag B) {
return A.first < B.first;
});
\end{cpp}
\item
现在,可以生成C++源代码了。首先,生成包含关键字名称和相关标志值的排序表:
\begin{cpp}
OS << "#ifdef GET_KEYWORD_FILTER\n"
<< "#undef GET_KEYWORD_FILTER\n";
OS << "bool lookupKeyword(llvm::StringRef Keyword, "
"unsigned &Value) {\n";
OS << " struct Entry {\n"
<< " unsigned Value;\n"
<< " llvm::StringRef Keyword;\n"
<< " };\n"
<< "static const Entry Table[" << Table.size()
<< "] = {\n";
for (const auto &[Keyword, Value] : Table) {
OS << " { " << Value << ", llvm::StringRef(\""
<< Keyword << "\", " << Keyword.size()
<< ") },\n";
}
OS << " };\n\n";
\end{cpp}
\item
接下来,使用标准C++函数std::lower\_bound()在排序表中查找关键字。若关键字在表中,则Value参数接收与关键字关联的标志的值,函数返回true。否则,函数返回false:
\begin{cpp}
OS << " const Entry *E = "
"std::lower_bound(&Table[0], "
"&Table["
<< Table.size()
<< "], Keyword, [](const Entry &A, const "
"StringRef "
"&B) {\n";
OS << " return A.Keyword < B;\n";
OS << " });\n";
OS << " if (E != &Table[" << Table.size()
<< "]) {\n";
OS << " Value = E->Value;\n";
OS << " return true;\n";
OS << " }\n";
OS << " return false;\n";
OS << "}\n";
OS << "#endif\n";
}
\end{cpp}
\item
现在唯一缺少的部分是调用该实现的方法,为此定义了一个全局函数EmitTokensAndKeywordFilter()。在llvm/TableGen/TableGenBackend.h头文件中声明的emitSourceFileHeader()函数,在生成的文件的顶部生成注释:
\begin{cpp}
void EmitTokensAndKeywordFilter(RecordKeeper &RK,
raw_ostream &OS) {
emitSourceFileHeader("Token Kind and Keyword Filter "
"Implementation Fragment",
OS);
TokenAndKeywordFilterEmitter(RK).run(OS);
}
\end{cpp}
\end{enumerate}
这样,就在TokenEmitter.cpp文件中完成了源生成器的实现,代码不算太复杂。
TableGenBackends.h头文件只包含EmitTokensAndKeywordFilter()函数的声明。为了避免包含其他文件,可以对raw\_ostream和RecordKeeper类使用前向声明:
\begin{cpp}
#ifndef TABLEGENBACKENDS_H
#define TABLEGENBACKENDS_H
namespace llvm {
class raw_ostream;
class RecordKeeper;
} // namespace llvm
void EmitTokensAndKeywordFilter(llvm::RecordKeeper &RK,
llvm::raw_ostream &OS);
#endif
\end{cpp}
缺少的部分是驱动程序的实现,其任务是解析TableGen文件并根据命令行选项发出记录。实现在TableGen.cpp文件中:
\begin{itemize}
\item
与往常一样,实现从包含所需的头文件开始。最重要的一个是llvm/TableGen/Main.h,这个头文件声明了TableGen的前端:
\begin{cpp}
#include "TableGenBackends.h"
#include "llvm/Support/CommandLine.h"
#include "llvm/Support/PrettyStackTrace.h"
#include "llvm/Support/Signals.h"
#include "llvm/TableGen/Main.h"
#include "llvm/TableGen/Record.h"
\end{cpp}
\item
为了简化编码,导入了llvm命名空间:
\begin{cpp}
using namespace llvm;
\end{cpp}
\item
用户可以选择一个操作,ActionType枚举包含所有可能的操作:
\begin{cpp}
enum ActionType {
PrintRecords,
DumpJSON,
GenTokens,
};
\end{cpp}
\item
使用一个名为Action的命令行选项对象。用户需要指定-{}-gen-tokens选项,以生成实现的令牌过滤器。另外两个选项-{}-print-records和-{}-dump-json是转储读记录的标准选项。注意,该对象位于匿名命名空间中:
\begin{cpp}
namespace {
cl::opt Action(
cl::desc("Action to perform:"),
cl::values(
clEnumValN(
PrintRecords, "print-records",
"Print all records to stdout (default)"),
clEnumValN(DumpJSON, "dump-json",
"Dump all records as "
"machine-readable JSON"),
clEnumValN(GenTokens, "gen-tokens",
"Generate token kinds and keyword "
"filter")));
\end{cpp}
\item
Main()函数基于action的值执行请求的操作,若在命令行上指定了-{}-gen-tokens,则调用EmitTokensAndKeywordFilter()函数。函数结束后,匿名命名空间关闭:
\begin{cpp}
bool Main(raw_ostream &OS, RecordKeeper &Records) {
switch (Action) {
case PrintRecords:
OS << Records; // No argument, dump all contents
break;
case DumpJSON:
EmitJSON(Records, OS);
break;
case GenTokens:
EmitTokensAndKeywordFilter(Records, OS);
break;
}
return false;
}
} // namespace
\end{cpp}
\item
最后,定义一个main()函数。设置堆栈跟踪处理程序并解析命令行选项之后,将调用TableGenMain()函数来解析TableGen文件并创建记录。若没有错误,该函数也会调用Main()函数:
\begin{cpp}
int main(int argc, char **argv) {
sys::PrintStackTraceOnErrorSignal(argv[0]);
PrettyStackTraceProgram X(argc, argv);
cl::ParseCommandLineOptions(argc, argv);
llvm_shutdown_obj Y;
return TableGenMain(argv[0], &Main);
}
\end{cpp}
\end{itemize}
现在实现了TableGen工具。编译后,就可以使用KeywordC运行它。示例输入文件如下:
\begin{shell}
$ tinylang-tblgen --gen-tokens –o TokenFilter.inc KeywordC.td
\end{shell}
生成的C++源代码会写入TokenFilter.inc文件中。
\begin{myTip}{标记过滤器的性能}
对关键字过滤器使用纯二分查找,并不比基于llvm::StringMap类型的实现提供更好的性能。为了超越当前实现的性能,需要生成一个完美的哈希函数。
来自Czech、Havas和Majewski的经典算法可以很容易地实现,并且提供了非常好的性能。它描述在生成最小完美哈希函数的最优算法,信息处理通讯,第43卷,第5期,1992年。参见\url{https:// www.sciencedirect.com/science/article/abs/pii/002001909290220P}。
最先进的算法是Pibiri和Trani的《PTHash: Revisiting FCH Minimal Perfect Hashing, SIGIR ’21》中进行了描述。参见\url{https://arxiv.org/pdf/2104.10402.pdf}。
这两种算法都是生成标记过滤器的不错选择,会比llvm::StringMap更快。
\end{myTip}
================================================
FILE: content/part3/chapter8/5.tex
================================================
以下是TableGen的一些缺点:
\begin{itemize}
\item
TableGen语言建立在一个简单的概念之上,所以不具有与其DSL相同的计算能力。显然,一些程序员希望用一种不同的、更强大的语言来取代TableGen,这个话题在LLVM讨论论坛中不时会看见。
\item
由于可以实现自己的后端,TableGen语言非常灵活,所以定义的语义隐藏在后端内部。因此,可以创建其他开发人员基本上无法理解的TableGen文件。
\item
最后,若试图解决一个重要的任务,后端实现可能会非常复杂。我们有理由认为,若TableGen语言更强大,这种苦力活将会减少。
\end{itemize}
即使不是所有开发者都对TableGen的功能感到满意,但该工具在LLVM中广泛使用,对于开发者来说,理解它非常重要。
================================================
FILE: content/part3/chapter8/6.tex
================================================
本章中,首先了解了TableGen背后的主要思想,并用TableGen语言定义了第一个类和记录,了解了TableGen语法的知识。最后,基于所定义的TableGen类,开发了用于生成C++源码的TableGen后端。
下一章中,我们将研究LLVM的另一个独特特性:一步生成和执行代码,也称为即时(JIT)编译。
================================================
FILE: content/part3/chapter9/0.tex
================================================
LLVM核心库拥有ExecutionEngine组件,该组件允许在内存中编译和执行中间表示(IR)代码。使用这个组件,可以构建即时(JIT)编译器,其允许直接执行IR代码。因为不需要将目标代码存储在辅助存储器上,所以JIT编译器的工作方式更像解释器。
本章中,将了解JIT编译器的应用,以及LLVM JIT编译器的工作原理。将探索LLVM动态编译器和解释器,如何自己实现JIT编译器工具,如何将JIT编译器作为静态编译器的一部分使用,以及相关的挑战。
本章中,将了解以下内容:
\begin{itemize}
\item
获得LLVM的JIT实现和用例的概述
\item
使用JIT编译直接执行
\item
根据现有类实现自己的JIT编译器
\item
从头开始实现自己的JIT编译器
\end{itemize}
本章结束时,将了解如何开发JIT编译器,可以使用预配置的类,也可以使用适合自己的版本。
================================================
FILE: content/part3/chapter9/1.tex
================================================
本章使用的代码在这里, \url{https://github.com/PacktPublishing/Learn-LLVM-17/tree/main/Chapter09}。
================================================
FILE: content/part3/chapter9/2.tex
================================================
目前,我们只讨论了提前(AOT)编译器,这些编译器编译整个应用程序。应用程序只能在编译完成后运行,若编译是在应用程序的运行时执行的,编译器就是JIT编译器。JIT编译器有一些有趣的用例:
\begin{itemize}
\item
实现虚拟机:编程语言可以用AOT编译器翻译成字节码。运行时,使用JIT编译器将字节码编译为机器码。这种方法的优点是字节码与硬件无关,并且由于JIT编译器,与AOT编译器相比没有性能损失。Java和C\#现在使用这个模型,但这并不是一个新想法:1977年的USCD Pascal编译器已经使用了类似的方法。
\item
表达式求值:电子表格应用程序可以使用JIT编译器编译经常执行的表达式。例如,这可以加快金融模拟。lldb LLVM调试器使用这种方法在调试时计算表达式。
\item
数据库查询:数据库从数据库查询创建一个执行计划。执行计划描述了对表和列的操作,执行时产生查询答案。可以使用JIT编译器将执行计划转换为机器码,从而加快查询的执行速度。
\end{itemize}
LLVM的静态编译模型与JIT模型的距离并不像人们想象的那么遥远,LLVM静态编译器将LLVM IR编译成机器码,并将结果保存为磁盘上的目标文件。若目标文件不是存储在磁盘上,而是存储在内存中,代码是可执行的吗?不是直接的,对全局函数和全局数据的引用使用重定位而不是绝对地址。从概念上讲,重定位描述了如何计算地址——例如,作为已知地址的偏移量。若像链接器和动态加载器将重定位解析为地址,就可以执行目标代码了。运行静态编译器将IR代码编译成内存中的对象文件,在内存中的对象文件上执行链接步骤,然后运行代码,就得到了一个JIT编译器。
LLVM核心库中的JIT实现就是基于这个思想。LLVM的开发历史中,有几个JIT实现,具有不同的功能集。最新的JIT API是按请求编译(ORC)引擎。若对这个缩略词感到好奇,在可执行和链接格式(ELF)和调试标准(DWARF)出现之后,首席开发人员打算根据托尔金的世界创造另一个缩略词。
ORC引擎基于并扩展了在内存中,对象文件上使用静态编译器和动态链接器的思想。实现使用分层的方法。两个基本层次是编译层和链接层,在此之上有一层提供延迟编译支持。转换层可以堆叠在惰性编译层之上或之下,允许开发人员添加转换或简单地收到某些事件的通知。此外,这种分层的方法还有一个优点,即JIT引擎可以针对不同的需求进行定制。例如,高性能的虚拟机可能选择提前编译所有内容,而不使用延迟编译层。另一方面,其他虚拟机将强调启动时间和对用户的响应,并将在惰性编译层的帮助下实现这一点。
旧的MCJIT引擎仍然可用,其API来自一个更旧的、已经删除的JIT引擎。随着时间的推移,这个API逐渐变得臃肿,缺乏ORC API的灵活性。
我们的目标是删除这个实现,因为ORC引擎现在提供了MCJIT引擎的所有功能,新的开发应该使用ORC API。下一节中,深入实现JIT编译器之前,我们将研究LLVM解释器和动态编译器。
================================================
FILE: content/part3/chapter9/3.tex
================================================
考虑JIT编译器时,首先想到的是直接运行LLVM IR。这就是lli工具、LLVM解释器和动态编译器所做的工作。我们将在下一节中探讨lli工具。
\mySubsubsection{9.3.1.}{探索lli工具}
让我们用一个非常简单的例子来试试lli工具。下面的LLVM IR可以存储为一个名为hello.ll的文件,相当于C语言中的hello world应用程序。该文件声明了来自C库的printf()函数的原型,hellostr常量包含要打印的消息。在main()函数内部,生成对printf()函数的调用,该函数包含将打印的hellostr消息,应用程序总是返回0。
完整的代码如下所示:
\begin{shell}
declare i32 @printf(ptr, ...)
@hellostr = private unnamed_addr constant [13 x i8] c"Hello
world\0A\00"
define dso_local i32 @main(i32 %argc, ptr %argv) {
%res = call i32 (ptr, ...) @printf(ptr @hellostr)
ret i32 0
}
\end{shell}
这个LLVM IR文件是通用的,对所有平台都有效。可以使用cli工具直接执行IR,命令如下:
\begin{shell}
$ lli hello.ll
Hello world
\end{shell}
这里有趣的一点是如何找到printf()函数。IR代码会编译为机器码,并触发printf符号的查找。这个符号在IR中找不到,所以在当前进程中搜索它,lli工具会动态链接到C库,在那里可以找到该符号。
当然,lli工具不会链接到创建的库。为了使用这些函数,lli工具支持加载动态库和对象。下面的C代码只输出消息:
\begin{shell}
#include
void greetings() {
puts("Hi!");
}
\end{shell}
存储在greetings.c中,使用它来探索用lli加载对象。下面的命令将把这个源代码编译成一个动态库,–fPIC选项指示clang生成与位置无关的代码,这是动态库所必需的。此外,编译器还会用-shared创建一个greetings.so动态库:
\begin{shell}
$ clang greetings.c -fPIC -shared -o greetings.so
\end{shell}
我们还将该文件编译为greetings.o对象文件:
\begin{shell}
$ clang greetings.c -c -o greetings.o
\end{shell}
现在有两个文件,greetings.so动态库和greetings.o对象文件,加载到lli工具中。
还需要一个调用greetings()函数的LLVM IR文件,还要创建一个main.ll文件,包含单个函数调用:
\begin{shell}
declare void @greetings(...)
define dso_local i32 @main(i32 %argc, i8** %argv) {
call void (...) @greetings()
ret i32 0
}
\end{shell}
执行时,因为lli无法定位greetings符号,所以前面的IR崩溃了,:
\begin{shell}
$ lli main.ll
JIT session error: Symbols not found: [ _greetings ]
lli: Failed to materialize symbols: { (main, { _main }) }
\end{shell}
greetings()函数是在一个外部文件中定义的,为了修复崩溃,必须告诉lli工具需要加载哪个文件。为了使用动态库,必须使用-load选项,该选项将动态库的路径作为参数:
\begin{shell}
$ lli –load ./greetings.so main.ll
Hi!
\end{shell}
若包含动态库的目录不在动态加载器的搜索路径中,则指定动态库的路径非常重要。若省略,则将找不到库。
或者,用-extra-object命令lli加载目标文件:
\begin{shell}
$ lli –extra-object greetings.o main.ll
Hi!
\end{shell}
其他支持的选项有-extra-archive(加载存档文件)和-extramodule(加载另一个位码文件)。这两个选项都需要文件的路径作为参数。
现在,了解了如何使用lli工具直接执行LLVM IR。下一节中,我们将实现自己的JIT工具。
================================================
FILE: content/part3/chapter9/4.tex
================================================
lli工具只不过是LLVM API的一个包装。第一部分中,我们了解了ORC引擎使用分层方法。ExecutionSession类表示一个正在运行的JIT程序。除了其他项之外,这个类还保存诸如使用过的JITDylib实例之类的信息。JITDylib实例是一个符号表,将符号名映射到地址。例如,这些符号可以是在LLVM IR文件中定义的符号,也可以加载动态库的符号。
为了执行LLVM IR,LLJIT类提供了这个功能,不需要自己创建JIT堆栈。这个类提供了相同的功能,所以从旧的MCJIT实现迁移时,也可以使用这个类。
为了说明LLJIT工具的功能,我们将在合并JIT功能的同时创建一个交互式计算器应用程序。我们的JIT计算器源码,将使用第2章中的calc示例进行扩展。
交互式JIT计算器的主要思想:
\begin{enumerate}
\item
允许用户输入函数定义,例如定义 $f(x) = x*2$.
\item
然后,用户输入的函数由LLJIT实用程序编译成一个函数——本例中为f。
\item
允许用户用一个数值来调用定义的函数:$f(3)$.
\item
使用提供的参数计算函数,并将结果输出到控制台: 6
\end{enumerate}
讨论将JIT功能整合到计算器源代码之前,与原始计算器示例相比,其有几个区别:
\begin{itemize}
\item
首先,之前只输入和解析了以with关键字开头的函数,而不是前面描述的def关键字。本章中,只接受以def开头的函数定义,这在抽象语法树(AST)类中表示为一个特定的节点,称为DefDecl。DefDecl类知道定义它的参数及其名称,当然函数名称也存储在这个类中。
\item
其次,还需要AST能够识别函数调用,以表示LLJIT实用程序使用或JIT处理的函数。无论何时用户输入函数名,后面跟着括号内的参数,AST都会将其识别为FuncCallFromDef节点。这个类本质上了解与DefDecl类相同的信息。
\end{itemize}
由于添加了这两个AST类,语义分析、解析器和代码生成类将相应地进行调整,以处理AST中的更改。另外添加了一个新的数据结构,称为JITtedFunctions。这个数据结构是一个映射,将定义的函数名作为键,函数定义的参数数量作为值存储在映射中,稍后我们将了解如何在JIT计算器中使用此数据结构。
有关我们对calc示例所做更改的更多详细信息,可以在lljit源目录中找到包含calc更改和本节JIT实现的完整源码。
\mySubsubsection{9.4.1.}{将LLJIT引擎集成到计算器中}
首先,讨论如何在交互式计算器中设置JIT引擎。所有与JIT引擎相关的实现都存在于Calc.cpp中,并且该文件有一个用于程序执行的main()循环:
\begin{enumerate}
\item
除了包含代码生成、语义分析器和解析器实现的头文件外,还必须包含几个头文件。LLJIT.h头文件定义了LLJIT类和ORC API的核心类。InitLLVM.h头文件用于工具的基本初始化,TargetSelect.h头文件用于本机目标的初始化,还包含了C++标准头文件,允许用户进行输入:
\begin{cpp}
#include "CodeGen.h"
#include "Parser.h"
#include "Sema.h"
#include "llvm/ExecutionEngine/Orc/LLJIT.h"
#include "llvm/Support/InitLLVM.h"
#include "llvm/Support/TargetSelect.h"
#include
\end{cpp}
\item
接下来,将llvm和llvm::orc命名空间添加到当前作用域:
\begin{cpp}
using namespace llvm;
using namespace llvm::orc;
\end{cpp}
\item
将创建的LLJIT实例中的许多调用,都会返回一个错误类型error。ExitOnError类允许丢弃在日志记录的stderr,并退出应用程序时由LLJIT实例调用返回的错误值。我们声明一个全局的ExitOnError变量如下:
\begin{cpp}
ExitOnError ExitOnErr;
\end{cpp}
\item
然后,添加main()函数,初始化工具和本机目标:
\begin{cpp}
int main(int argc, const char **argv{
InitLLVM X(argc, argv);
InitializeNativeTarget();
InitializeNativeTargetAsmPrinter();
InitializeNativeTargetAsmParser();
\end{cpp}
\item
使用LLJITBuilder类来创建一个LLJIT实例,其封装在前面声明的ExitOnErr变量中,以免发生错误。一个可能的错误是,平台还不支持JIT编译:
\begin{cpp}
auto JIT = ExitOnErr(LLJITBuilder().create());
\end{cpp}
\item
接下来,声明JITtedFunctions映射,该映射跟踪函数定义:
\begin{cpp}
StringMap JITtedFunctions;
\end{cpp}
\item
为了创造一个等待用户输入的环境,添加了一个while()循环,允许用户输入一个表达式,将用户输入的那一行保存在一个名为calcExp的字符串中:
\begin{cpp}
while (true) {
outs() << "JIT calc > ";
std::string calcExp;
std::getline(std::cin, calcExp);
\end{cpp}
\item
初始化LLVM上下文类,以及一个新的LLVM模块。模块的数据布局也相应地设置,还声明了一个代码生成器,将用于为用户在命令行上定义的函数生成IR:
\begin{cpp}
std::unique_ptr Ctx = std::make_unique();
std::unique_ptr M = std::make_unique("JIT calc.expr", *Ctx);
M->setDataLayout(JIT->getDataLayout());
CodeGen CodeGenerator;
\end{cpp}
\item
必须解释用户输入的代码行,以确定用户是在定义一个新函数,还是在调用之前用参数定义的函数。Lexer类在接收用户输入行时定义,可了解词法分析器主要关心的两种情况:
\begin{cpp}
Lexer Lex(calcExp);
Token::TokenKind CalcTok = Lex.peek();
\end{cpp}
\item
词法分析器可以检查用户输入的第一个标记,若用户正在定义一个新函数(由def关键字或Token::KW\_def表示),则解析它并检查其语义。若解析器或语义分析器检测到用户定义函数的问题,将相应地发出错误,计算器程序将停止运行。若解析器或语义分析器都没有检测到错误,就有一个有效的AST数据结构——DefDecl:
\begin{cpp}
if (CalcTok == Token::KW_def) {
Parser Parser(Lex);
AST *Tree = Parser.parse();
if (!Tree || Parser.hasError()) {
llvm::errs() << "Syntax errors occured\n";
return 1;
}
Sema Semantic;
if (Semantic.semantic(Tree, JITtedFunctions)) {
llvm::errs() << "Semantic errors occured\n";
return 1;
}
\end{cpp}
\item
然后,可以将新构造的AST传递到代码生成器中,为用户定义的函数编译IR。IR生成的细节将在后面讨论,但这个编译成IR的函数需要知道模块和JITtedFunctions的映射关系。生成IR之后,可以调用addIRModule()将这些信息添加到我们的LLJIT实例中,并将我们的模块和上下文包装在ThreadSafeModule类中,以防止其他线程并发访问:
\begin{cpp}
CodeGenerator.compileToIR(Tree, M.get(), JITtedFunctions); ExitOnErr( JIT->addIRModule(ThreadSafeModule(std::move(M), std::move(Ctx))));
\end{cpp}
\item
相反,若用户调用一个带参数的函数,由Token::ident表示,还需要在将用户输入转换为有效AST之前解析和语义检查用户输入是否有效。这里,解析和检查与之前略有不同,因为其可以包括检查,例如:确保用户提供给函数调用的参数数量与函数最初定义的参数数量匹配:
\begin{cpp}
} else if (CalcTok == Token::ident) {
outs() << "Attempting to evaluate expression:\n";
Parser Parser(Lex);
AST *Tree = Parser.parse();
if (!Tree || Parser.hasError()) {
llvm::errs() << "Syntax errors occured\n";
return 1;
}
Sema Semantic;
if (Semantic.semantic(Tree, JITtedFunctions)) {
llvm::errs() << "Semantic errors occured\n";
return 1;
}
\end{cpp}
\item
为函数调用构造了一个有效的AST, FuncCallFromDef,可以从AST中获取函数的名称,然后代码生成器准备生成对先前添加到LLJIT实例的函数的调用。背后的过程是,用户定义的函数重新生成为一个单独的函数中的LLVM调用,将创建该函数,用于实际评估原始函数。这一步需要AST、模块、函数调用名和函数定义的映射:
\begin{cpp}
llvm::StringRef FuncCallName = Tree->getFnName();
CodeGenerator.prepareCalculationCallFunc(Tree, M.get(), FuncCallName, JITtedFunctions);
\end{cpp}
\item
代码生成器完成了重新生成原始函数和创建单独的求值函数的工作之后,必须将这些信息添加到LLJIT实例中。创建ResourceTracker实例来跟踪分配给已添加到LLJIT的函数的内存,以及模块和上下文的另一个ThreadSafeModule实例,再将这两个实例作为IR模块添加到JIT中:
\begin{cpp}
auto RT = JIT->getMainJITDylib().createResourceTracker();
auto TSM = ThreadSafeModule(std::move(M), std::move(Ctx));
ExitOnErr(JIT->addIRModule(RT, std::move(TSM)));
\end{cpp}
\item
然后,通过lookup()方法在LLJIT实例中查询单独的求值函数,方法是将求值函数的名称calc\_expr\_func提供给该函数。若查询成功,则将calc\_expr\_func函数的地址强制转换为适当的类型,该函数不接受参数并返回单个整数。获取函数的地址后,调用该函数以使用用户定义函数提供的参数生成结果,在将结果输出到控制台:
\begin{cpp}
auto CalcExprCall = ExitOnErr(JIT->lookup("calc_expr_func"));
int (*UserFnCall)() = CalcExprCall.toPtr();
outs() << "User defined function evaluated to:" << UserFnCall() << "\n";
\end{cpp}
\item
函数调用完成后,之前与函数关联的内存使用ResourceTracker释放:
\begin{cpp}
ExitOnErr(RT->remove());
\end{cpp}
\end{enumerate}
\mySubsubsection{9.4.2.}{修改代码生成——支持通过LLJIT进行JIT编译}
现在,简要地看一下我们在CodeGen.cpp中所做的一些更改,以支持JIT版计算器:
\begin{itemize}
\item
代码生成类有两个重要的方法:一个用于将用户定义函数编译成LLVM IR并将IR输出到控制台,另一个用于准备计算求值函数calc\_expr\_func,该函数包含对原始用户定义函数的调用以进行求值。第二个函数也将IR输出给用户:
\begin{cpp}
void CodeGen::compileToIR(AST *Tree, Module *M,
StringMap &JITtedFunctions) {
ToIRVisitor ToIR(M, JITtedFunctions);
ToIR.run(Tree);
M->print(outs(), nullptr);
}
void CodeGen::prepareCalculationCallFunc(AST *FuncCall,
Module *M, llvm::StringRef FnName,
StringMap &JITtedFunctions) {
ToIRVisitor ToIR(M, JITtedFunctions);
ToIR.genFuncEvaluationCall(FuncCall);
M->print(outs(), nullptr);
}
\end{cpp}
\item
如上面的源代码所述,这些代码生成函数定义了一个ToIRVisitor实例,其接受我们的模块,并在初始化时在其构造函数中使用JITtedFunctions映射:
\begin{cpp}
class ToIRVisitor : public ASTVisitor {
Module *M;
IRBuilder<> Builder;
StringMap &JITtedFunctionsMap;
. . .
public:
ToIRVisitor(Module *M,
StringMap &JITtedFunctions)
: M(M), Builder(M->getContext()),
JITtedFunctionsMap(JITtedFunctions) {
\end{cpp}
\item
最终,该信息用于生成IR或计算先前生成IR的函数。生成IR时,代码生成器希望看到DefDecl节点,其表示定义一个新函数。函数名及其定义的参数数量,存储在函数定义映射中:
\begin{cpp}
virtual void visit(DefDecl &Node) override {
llvm::StringRef FnName = Node.getFnName();
llvm::SmallVector FunctionVars =
Node.getVars();
(JITtedFunctionsMap)[FnName] = FunctionVars.size();
\end{cpp}
\item
之后,实际的函数定义由genUserDefinedFunction()调用创建:
\begin{cpp}
Function *DefFunc = genUserDefinedFunction(FnName);
\end{cpp}
\item
genUserDefinedFunction()中,第一步是检查函数是否存在于模块中。若没有,则确保函数原型存在于map数据结构中,再使用名称和实参数来构造一个函数,该函数具有用户定义的实参数,并使该函数返回单个整数值:
\begin{cpp}
Function *genUserDefinedFunction(llvm::StringRef Name) {
if (Function *F = M->getFunction(Name))
return F;
Function *UserDefinedFunction = nullptr;
auto FnNameToArgCount = JITtedFunctionsMap.find(Name);
if (FnNameToArgCount != JITtedFunctionsMap.end()) {
std::vector IntArgs(FnNameToArgCount->second,
Int32Ty);
FunctionType *FuncType = FunctionType::get(Int32Ty,
IntArgs, false);
UserDefinedFunction =
Function::Create(FuncType,
GlobalValue::ExternalLinkage, Name, M);
}
return UserDefinedFunction;
}
\end{cpp}
\item
生成用户定义的函数之后,创建一个新的基本块,并将函数插入到基本块中。每个函数实参还与用户定义的名称相关联,因此可为所有函数实参设置名称,并生成对函数内实参进行操作的数学运算:
\begin{cpp}
BasicBlock *BB = BasicBlock::Create(M->getContext(),
"entry", DefFunc);
Builder.SetInsertPoint(BB);
unsigned FIdx = 0;
for (auto &FArg : DefFunc->args()) {
nameMap[FunctionVars[FIdx]] = &FArg;
FArg.setName(FunctionVars[FIdx++]);
}
Node.getExpr()->accept(*this);
};
\end{cpp}
\item
计算用户定义函数时,示例中期望的AST称为FuncCallFromDef节点。首先,我们定义求值函数,并将其命名为calc\_expr\_func(接受零参数并返回一个结果):
\begin{cpp}
virtual void visit(FuncCallFromDef &Node) override {
llvm::StringRef CalcExprFunName = "calc_expr_func";
FunctionType *CalcExprFunTy = FunctionType::get(Int32Ty, {},
false);
Function *CalcExprFun = Function::Create(
CalcExprFunTy, GlobalValue::ExternalLinkage,
CalcExprFunName, M);
\end{cpp}
\item
接下来,创建一个新的基本块,将calc\_expr\_func插入其中:
\begin{cpp}
BasicBlock *BB = BasicBlock::Create(M->getContext(),
"entry", CalcExprFun);
Builder.SetInsertPoint(BB);
\end{cpp}
\item
与前面类似,用户定义函数由genUserDefinedFunction()检索,将函数调用的数值参数传递给重新生成的函数:
\begin{cpp}
llvm::StringRef CalleeFnName = Node.getFnName();
Function *CalleeFn = genUserDefinedFunction(CalleeFnName);
\end{cpp}
\item
当有了实际的llvm::Function实例,就可以利用IRBuilder创建一个对定义函数的调用,并返回结果,这样的输出结果,用户可以访问:
\begin{cpp}
auto CalleeFnVars = Node.getArgs();
llvm::SmallVector IntParams;
for (unsigned i = 0, end = CalleeFnVars.size(); i != end;
++i) {
int ArgsToIntType;
CalleeFnVars[i].getAsInteger(10, ArgsToIntType);
Value *IntParam = ConstantInt::get(Int32Ty, ArgsToIntType,
true);
IntParams.push_back(IntParam);
}
Builder.CreateRet(Builder.CreateCall(CalleeFn, IntParams,
"calc_expr_res"));
};
\end{cpp}
\end{itemize}
\mySubsubsection{9.4.3.}{构建基于LLJIT的计算器}
最后,为了编译JIT计算器源代码,还需要创建一个包含构建描述的CMakeLists.txt文件,保存在Calc.cpp和其他源文件附近:
\begin{enumerate}
\item
将最低所需的CMake版本设置为LLVM所需的版本,并给项目命名:
\begin{cmake}
cmake_minimum_required (VERSION 3.20.0)
project ("jit")
\end{cmake}
\item
需要加载LLVM包,可将LLVM提供的CMake模块目录添加到搜索路径中,再包括DetermineGCCCompatible和ChooseMSVCCRT模块,其分别检查编译器是否具有gcc兼容的命令行语法,并确保使用与LLVM相同的C运行时:
\begin{cmake}
find_package(LLVM REQUIRED CONFIG)
list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR})
include(DetermineGCCCompatible)
include(ChooseMSVCCRT)
\end{cmake}
\item
还需要从LLVM添加定义和包含路径,使用的LLVM组件通过函数调用映射到库名:
\begin{cmake}
add_definitions(${LLVM_DEFINITIONS})
include_directories(SYSTEM ${LLVM_INCLUDE_DIRS})
llvm_map_components_to_libnames(llvm_libs Core OrcJIT
Support native)
\end{cmake}
\item
之后,若确定编译器具有gcc兼容的命令行语法,检查是否启用了运行时类型信息和异常处理。若未启用,则在编译中相应地添加用于关闭这些特性的C++标志:
\begin{cmake}
if(LLVM_COMPILER_IS_GCC_COMPATIBLE)
if(NOT LLVM_ENABLE_RTTI)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-rtti")
endif()
if(NOT LLVM_ENABLE_EH)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-exceptions")
endif()
endif()
\end{cmake}
\item
最后,定义可执行文件的名称、要编译的源文件和要链接的库:
\begin{cmake}
add_executable (calc
Calc.cpp CodeGen.cpp Lexer.cpp Parser.cpp Sema.cpp)
target_link_libraries(calc PRIVATE ${llvm_libs})
\end{cmake}
\end{enumerate}
前面的步骤是基于JIT的交互式计算器工具所需要的全部内容。接下来,创建并更改为构建目录,然后运行以下命令来创建和编译应用程序:
\begin{shell}
$ cmake –G Ninja
$ ninja
\end{shell}
这将编译计算工具,然后可以启动计算器,开始定义函数,看看计算器是如何计算我们定义的函数。
下面的示例调用显示了首先定义的函数的IR,然后创建calc\_expr\_func函数来生成对最初定义函数的调用,以便使用传递给的参数求值:
\begin{shell}
$ ./calc
JIT calc > def f(x) = x*2
define i32 @f(i32 %x) {
entry:
%0 = mul nsw i32 %x, 2
ret i32 %0
}
JIT calc > f(20)
Attempting to evaluate expression:
define i32 @calc_expr_func() {
entry:
%calc_expr_res = call i32 @f(i32 20)
ret i32 %calc_expr_res
}
declare i32 @f(i32)
User defined function evaluated to: 40
JIT calc > def g(x,y) = x*y+100
define i32 @g(i32 %x, i32 %y) {
entry:
%0 = mul nsw i32 %x, %y
%1 = add nsw i32 %0, 100
ret i32 %1
}
JIT calc > g(8,9)
Attempting to evaluate expression:
define i32 @calc_expr_func() {
entry:
%calc_expr_res = call i32 @g(i32 8, i32 9)
ret i32 %calc_expr_res
}
declare i32 @g(i32, i32)
User defined function evaluated to: 172
\end{shell}
就是这样!我们刚刚创建了一个基于JIT的计算器应用程序!
由于JIT计算器是一个简单的例子,描述了如何将LLJIT合并到我们的项目中,但它有一些限制:
\begin{itemize}
\item
计算器不支持十进制的负数
\item
不能多次重新定义同名函数
\end{itemize}
第二个限制是设计上的,因此是ORC API本身所期望和强制的:
\begin{shell}
$ ./calc
JIT calc > def f(x) = x*2
define i32 @f(i32 %x) {
entry:
%0 = mul nsw i32 %x, 2
ret i32 %0
}
JIT calc > def f(x,y) = x+y
define i32 @f(i32 %x, i32 %y) {
entry:
%0 = add nsw i32 %x, %y
ret i32 %0
}
Duplicate definition of symbol '_f'
\end{shell}
记住,除了为当前进程或动态库公开符号外,还有许多其他可能公开名称的方法。例如,StaticLibraryDefinitionGenerator类公开在静态存档中找到的符号,并且可以在DynamicLibrarySearchGenerator类中使用。
此外,LLJIT类还有一个addObjectFile()方法来公开对象文件的符号。若现有的实现不能满足需求,还可以提供自己的DefinitionGenerator实现。
使用预定义的LLJIT类很方便,但其会限制我们的灵活性。下一节中,我们将研究如何使用ORC API提供的层来实现JIT编译器。
================================================
FILE: content/part3/chapter9/5.tex
================================================
使用ORC的分层方法,很容易构建针对需求定制的JIT编译器。没有放之四海而皆准的JIT编译器,本章的第一节给出了一些示例。让我们看一下如何从头开始设置JIT编译器。
ORC API使用堆叠在一起的层。最低层是对象链接层,由llvm::orc::RTDyldObjectLinkingLayer类表示,负责链接内存中的对象并将其转换为可执行代码。此任务所需的内存由MemoryManager接口的实例管理,有一个默认的实现,也可以使用自定义版本。
对象链接层之上是编译层,负责创建内存中的对象文件。llvm::orc::IRCompileLayer类接受IR模块作为输入,并将其编译为对象文件。IRCompileLayer类是IRLayer类的子类,IRLayer类是接受LLVM IR的层实现的通用类。
这两层已经构成了JIT编译器的核心:添加一个LLVM IR模块作为输入,该模块在内存中进行编译和链接。为了增加功能,可以在两层之上合并更多的层。
例如,CompileOnDemandLayer类拆分一个模块,以便只编译请求的函数,这可以用来实现延迟编译,CompileOnDemandLayer类也是IRLayer类的一个子类。IRTransformLayer类,也是IRLayer类的一个子类,以一种非常通用的方式,允许对模块应用转换。
另一个重要的类是ExecutionSession类,这个类表示一个正在运行的JIT程序。这个类管理JITDylib符号表,提供符号查找功能,并跟踪使用的资源管理器。
JIT编译器的通用方式:
\begin{enumerate}
\item
初始化ExecutionSession类的实例。
\item
初始化层,至少由RTDyldObjectLinkingLayer类和IRCompileLayer类组成。
\item
创建第一个JITDylib符号表,通常使用main或类似的名称。
\end{enumerate}
JIT编译器的一般用法也非常简单:
\begin{enumerate}
\item
在符号表中添加一个IR模块。
\item
查找一个符号,触发相关函数的编译,也可能触发整个模块的编译。
\item
执行函数。
\end{enumerate}
下一小节中,我们将按照通用方式实现JIT编译器类。
\mySubsubsection{9.5.1.}{创建JIT编译器类}
为了保持JIT编译器类的实现简单,所有内容都放在JIT.h中,可以放在名为JIT的目录中。与使用LLJIT相比,类的初始化要复杂一些。由于要处理可能的错误,调用构造函数之前,需要一个工厂方法来预先创建一些对象。创建类的步骤如下:
\begin{enumerate}
\item
首先使用JIT\_H预处理器定义来保护头文件不可多次包含:
\begin{cpp}
#ifndef JIT_H
#define JIT_H
\end{cpp}
\item
首先,需要一些包含文件,其中的大多数都提供了一个与头文件同名的类。Core.h头文件提供了几个基本类,包括ExecutionSession类。ExecutionUtils.h头文件提供了DynamicLibrarySearchGenerator类来搜索库中的符号,CompileUtils.h头文件提供了ConcurrentIRCompiler类:
\begin{cpp}
#include "llvm/Analysis/AliasAnalysis.h"
#include "llvm/ExecutionEngine/JITSymbol.h"
#include "llvm/ExecutionEngine/Orc/CompileUtils.h"
#include "llvm/ExecutionEngine/Orc/Core.h"
#include "llvm/ExecutionEngine/Orc/ExecutionUtils.h"
#include "llvm/ExecutionEngine/Orc/IRCompileLayer.h"
#include "llvm/ExecutionEngine/Orc/IRTransformLayer.h"
#include "llvm/ExecutionEngine/Orc/JITTargetMachineBuilder.h"
#include "llvm/ExecutionEngine/Orc/Mangling.h"
#include "llvm/ExecutionEngine/Orc/RTDyldObjectLinkingLayer.h"
#include "llvm/ExecutionEngine/Orc/TargetProcessControl.h"
#include "llvm/ExecutionEngine/SectionMemoryManager.h"
#include "llvm/Passes/PassBuilder.h"
#include "llvm/Support/Error.h"
\end{cpp}
\item
声明一个新类。我们的新类称为JIT:
\begin{cpp}
class JIT {
\end{cpp}
\item
私有数据成员反映了ORC层和一些辅助类。ExecutionSession、ObjectLinkingLayer、CompileLayer、OptIRLayer和MainJITDylib实例表示正在运行的JIT程序、层和符号表,TargetProcessControl实例用于与JIT目标进程进行交互。这可以是同一进程,同一机器上的另一个进程,或者不同机器上的远程进程,可能具有不同的架构。DataLayout和MangleAndInterner类需要以正确的方式修改符号的名称。符号名称可以内化,所以所有相等的名称具有相同的地址。所以检查两个符号名是否相等,比较地址就足够了,这个操作非常快:
\begin{cpp}
std::unique_ptr TPC;
std::unique_ptr ES;
llvm::DataLayout DL;
llvm::orc::MangleAndInterner Mangle;
std::unique_ptr
ObjectLinkingLayer;
std::unique_ptr
CompileLayer;
std::unique_ptr
OptIRLayer;
llvm::orc::JITDylib &MainJITDylib;
\end{cpp}
\item
初始化分为三个部分。C++中,构造函数不能返回错误。简单且推荐的解决方案是创建一个静态工厂方法,可以在构造对象之前进行错误处理。层的初始化更为复杂,所以也为它们引入了工厂方法。
create()工厂方法中,首先创建一个SymbolStringPool实例,该实例用于实现字符串内部化,并由多个类共享。为了控制当前流程,创建了一个SelfTargetProcessControl实例。若想要针对不同的流程,则需要更改此实例。
接下来,构造一个JITTargetMachineBuilder实例,为此需要知道JIT进程的目标三元组,再向目标机器生成器查询数据布局。若构建器无法基于提供的三元组实例化目标机器,则此步骤可能会失败,例如:因为对此目标的支持没有编译到LLVM库中:
\begin{cpp}
public:
static llvm::Expected> create() {
auto SSP =
std::make_shared();
auto TPC =
llvm::orc::SelfTargetProcessControl::Create(SSP);
if (!TPC)
return TPC.takeError();
llvm::orc::JITTargetMachineBuilder JTMB(
(*TPC)->getTargetTriple());
auto DL = JTMB.getDefaultDataLayoutForTarget();
if (!DL)
return DL.takeError();
\end{cpp}
\item
此时,已经处理了所有可能失败的调用,现在可以初始化ExecutionSession实例了。最后,使用所有实例化的对象调用JIT类的构造函数,并将结果返回给调用者:
\begin{cpp}
auto ES =
std::make_unique(
std::move(SSP));
return std::make_unique(
std::move(*TPC), std::move(ES), std::move(*DL),
std::move(JTMB));
}
\end{cpp}
\item
JIT类的构造函数将传递的参数移动到私有数据成员,层对象是通过调用带有create前缀的静态工厂名称来构造的。此外,每个层工厂方法都需要对ExecutionSession实例的引用,该实例将层连接到正在运行的JIT会话。除了位于层堆栈底部的对象链接层外,每一层都需要引用前一层,说明了堆叠顺序:
\begin{cpp}
JIT(std::unique_ptr
EPCtrl,
std::unique_ptr
ExeS,
llvm::DataLayout DataL,
llvm::orc::JITTargetMachineBuilder JTMB)
: EPC(std::move(EPCtrl)), ES(std::move(ExeS)),
DL(std::move(DataL)), Mangle(*ES, DL),
ObjectLinkingLayer(std::move(
createObjectLinkingLayer(*ES, JTMB))),
CompileLayer(std::move(createCompileLayer(
*ES, *ObjectLinkingLayer,
std::move(JTMB)))),
OptIRLayer(std::move(
createOptIRLayer(*ES, *CompileLayer))),
MainJITDylib(
ES->createBareJITDylib("")) {
\end{cpp}
\item
构造函数中,添加了一个生成器,用于在当前进程中搜索符号。GetForCurrentProcess()方法很特殊,返回值包装在一个Expected<>模板中,表明也可以返回一个Error对象。由于我们知道不会发生错误,所以当前进程最终将运行!使用cantFail()函数展开结果,若发生错误,该函数将终止应用程序:
\begin{cpp}
MainJITDylib.addGenerator(llvm::cantFail(
llvm::orc::DynamicLibrarySearchGenerator::
GetForCurrentProcess(DL.getGlobalPrefix())));
}
\end{cpp}
\item
要创建一个对象链接层,需要提供一个内存管理器。这里,我们坚持使用默认的SectionMemoryManager类,也可以提供不同的实现:
\begin{cpp}
static std::unique_ptr<
llvm::orc::RTDyldObjectLinkingLayer>
createObjectLinkingLayer(
llvm::orc::ExecutionSession &ES,
llvm::orc::JITTargetMachineBuilder &JTMB) {
auto GetMemoryManager = []() {
return std::make_unique<
llvm::SectionMemoryManager>();
};
auto OLLayer = std::make_unique<
llvm::orc::RTDyldObjectLinkingLayer>(
ES, GetMemoryManager);
\end{cpp}
\item
Windows上使用的通用对象文件格式(COFF)对象文件格式存在轻微的复杂性,此文件格式不允许将函数标记为导出。这随后导致对象链接层内部检查失败:将符号中存储的标志与IR中的标志进行比较,由于缺少导出标记,导致不匹配,解决方案是只覆盖这种文件格式的标志。这就完成了对象层的构造,对象会返回给调用者:
\begin{cpp}
if (JTMB.getTargetTriple().isOSBinFormatCOFF()) {
OLLayer
->setOverrideObjectFlagsWithResponsibilityFlags(
true);
OLLayer
->setAutoClaimResponsibilityForObjectSymbols(
true);
}
return OLLayer;
}
\end{cpp}
\item
要初始化编译器层,需要一个IRCompiler实例,IRCompiler实例负责将一个IR模块编译成一个object文件。若JIT编译器不使用线程,则可以使用SimpleCompiler类,使用给定的目标机器编译IR模块。TargetMachine类线程不安全,因此SimpleCompiler类也不是。为了支持多线程编译,使用ConcurrentIRCompiler类,为每个要编译的模块创建一个新的TargetMachine实例。这种方法解决了多线程的问题:
\begin{cpp}
static std::unique_ptr
createCompileLayer(
llvm::orc::ExecutionSession &ES,
llvm::orc::RTDyldObjectLinkingLayer &OLLayer,
llvm::orc::JITTargetMachineBuilder JTMB) {
auto IRCompiler = std::make_unique<
llvm::orc::ConcurrentIRCompiler>(
std::move(JTMB));
auto IRCLayer =
std::make_unique(
ES, OLLayer, std::move(IRCompiler));
return IRCLayer;
}
\end{cpp}
\item
我们没有将IR模块直接编译为机器码,而是安装了一个层来首先优化IR。这是一个深思熟虑的设计决策:将JIT编译器转换为优化的JIT编译器,生成更快的代码,而生成代码需要更长的时间,所以用户需要等待。我们没有添加延迟编译,所以当只查找一个符号时,整个模块都会编译。用户看到代码执行之前,可能会等待很久。
\begin{myNotic}{Note}
所有情况下,引入惰性编译并不是一个合适的解决方案。惰性编译是通过将每个函数移动到它自己的模块中来实现的,该模块在查找函数名时进行编译。防止了诸如内联之类的过程间优化,因为内联传递需要访问调用函数的体才能内联。用户看到延迟编译的启动速度更快,但生成的代码并不是最优的。这些设计决策取决于预期的用途。我们决定使用快速代码,接受较慢的启动时间,所以优化层本质上是一个转换层。
\end{myNotic}
IRTransformLayer类将转换委托给函数——我们的例子中,委托给了optimizeModule函数:
\begin{cpp}
static std::unique_ptr
createOptIRLayer(
llvm::orc::ExecutionSession &ES,
llvm::orc::IRCompileLayer &CompileLayer) {
auto OptIRLayer =
std::make_unique(
ES, CompileLayer,
optimizeModule);
return OptIRLayer;
}
\end{cpp}
\item
optimizeModule()函数是IR模块转换的一个示例。该函数获取要转换的模块作为参数,并返回转换后的IR模块版本。由于JIT编译器可能运行多个线程,所以IR模块可封装在ThreadSafeModule实例中:
\begin{cpp}
static llvm::Expected
optimizeModule(
llvm::orc::ThreadSafeModule TSM,
const llvm::orc::MaterializationResponsibility
&R) {
\end{cpp}
\item
为了优化IR,回顾以下第7章,需要PassBuilder实例来创建优化流水线。首先,定义两个分析管理器,然后在通道构建器中注册,再用O2级别的默认优化流水线填充ModulePassManager实例。这又是一个设计决策:O2级别生成的机器码已经很快了,但O3级别生成的代码甚至更快。接下来,我们在模块上运行流水线。最后,优化后的模块返回给调用者:
\begin{cpp}
TSM.withModuleDo([](llvm::Module &M) {
bool DebugPM = false;
llvm::PassBuilder PB(DebugPM);
llvm::LoopAnalysisManager LAM(DebugPM);
llvm::FunctionAnalysisManager FAM(DebugPM);
llvm::CGSCCAnalysisManager CGAM(DebugPM);
llvm::ModuleAnalysisManager MAM(DebugPM);
FAM.registerPass(
[&] { return PB.buildDefaultAAPipeline(); });
PB.registerModuleAnalyses(MAM);
PB.registerCGSCCAnalyses(CGAM);
PB.registerFunctionAnalyses(FAM);
PB.registerLoopAnalyses(LAM);
PB.crossRegisterProxies(LAM, FAM, CGAM, MAM);
llvm::ModulePassManager MPM =
PB.buildPerModuleDefaultPipeline(
llvm::PassBuilder::OptimizationLevel::O2,
DebugPM);
MPM.run(M, MAM);
});
return TSM;
}
\end{cpp}
\item
JIT类的客户机需要一种添加IR模块的方法,通过addIRModule()函数提供了这种方法。回想一下我们创建的层堆栈:必须将IR模块添加到顶层;否则,会不绕过一些层。这是一个不容易发现的编程错误:若OptIRLayer成员用CompileLayer成员取代,JIT类仍然工作,但不是作为优化JIT,因为已经绕过了这一层。对于这个小实现来说无关紧要,但是在一个大JIT优化中,我们会引入一个函数来返回顶层:
\begin{cpp}
llvm::Error addIRModule(
llvm::orc::ThreadSafeModule TSM,
llvm::orc::ResourceTrackerSP RT = nullptr) {
if (!RT)
RT = MainJITDylib.getDefaultResourceTracker();
return OptIRLayer->add(RT, std::move(TSM));
}
\end{cpp}
\item
同样,JIT类的客户机需要一种查找符号的方法。我们把它委托给ExecutionSession实例,传递一个对主符号表的引用和请求符号的内部名称:
\begin{cpp}
llvm::Expected
lookup(llvm::StringRef Name) {
return ES->lookup({&MainJITDylib},
Mangle(Name.str()));
}
\end{cpp}
\end{enumerate}
这个JIT类的初始化可能很棘手,它涉及到JIT类的工厂方法和构造函数调用,以及每个层的工厂方法。尽管这个发行版是由C++的限制造成的,但代码本身很简单。
接下来,将使用新的JIT编译器类来实现一个简单的命令行实用程序,该实用程序将LLVM IR文件作为输入。
\mySubsubsection{9.5.2.}{使用新的JIT编译器类}
首先在与JIT.h文件相同的目录下创建一个名为JIT.cpp的文件,并在此源文件中添加以下内容:
\begin{enumerate}
\item
首先,包含几个头文件。必须包含JIT.h来使用我们的新类,还必须包含IRReader.h头文件,定义了一个读取LLVM IR文件的函数,CommandLine.h头文件允许我们以LLVM风格解析命令行选项,还需要InitLLVM.h进行工具的基本初始化。最后,TargetSelect.h需要用于初始化本机目标:
\begin{cpp}
#include "JIT.h"
#include "llvm/IRReader/IRReader.h"
#include "llvm/Support/CommandLine.h"
#include "llvm/Support/InitLLVM.h"
#include "llvm/Support/TargetSelect.h"
\end{cpp}
\item
接下来,我们将llvm命名空间添加到当前作用域:
\begin{cpp}
using namespace llvm;
\end{cpp}
\item
JIT工具在命令行上只需要一个输入文件,用cl::opt<>类声明它:
\begin{cpp}
static cl::opt
InputFile(cl::Positional, cl::Required,
cl::desc(""));
\end{cpp}
\item
为了读取IR文件,调用parseIRFile()函数,该文件可以是文本IR表示或位码文件。该函数返回一个指向所创建模块的指针。因为可以解析文本IR文件,错误处理有点不同,这在语法上不一定正确。最后,SMDiagnostic实例保存语法错误时的错误信息。若发生错误,则输出错误消息,并退出应用程序:
\begin{cpp}
std::unique_ptr
loadModule(StringRef Filename, LLVMContext &Ctx,
const char *ProgName) {
SMDiagnostic Err;
std::unique_ptr Mod =
parseIRFile(Filename, Err, Ctx);
if (!Mod.get()) {
Err.print(ProgName, errs());
exit(-1);
}
return Mod;
}
\end{cpp}
\item
jitmain()函数放在loadModule()方法之后,函数设置我们的JIT引擎并编译一个LLVM IR模块。该函数需要带有IR的LLVM模块来执行。这个模块还需要LLVM上下文类,上下文类包含重要的类型信息。因为我们的目标是调用main()函数,所以也传递了常用的argc和argv参数:
\begin{cpp}
Error jitmain(std::unique_ptr M,
std::unique_ptr Ctx,
int argc, char *argv[]) {
\end{cpp}
\item
接下来,创建前面构造的JIT类的一个实例。若发生错误,则返回相应的错误消息:
\begin{cpp}
auto JIT = JIT::create();
if (!JIT)
return JIT.takeError();
\end{cpp}
\item
将模块添加到主JITDylib实例中,再次将模块和上下文包装在ThreadSafeModule实例中。若发生错误,则返回一条错误消息:
\begin{cpp}
if (auto Err = (*JIT)->addIRModule(
orc::ThreadSafeModule(std::move(M),
std::move(Ctx))))
return Err;
\end{cpp}
\item
查找主符号,此符号必须在命令行上给出的IR模块中。查找触发该IR模块的编译。若在IR模块中引用了其他符号,则使用上一步中添加的生成器进行解析。结果是ExecutorAddr类,表示执行进程的地址:
\begin{cpp}
llvm::orc::ExecutorAddr MainExecutorAddr = MainSym->getAddress();
auto *Main = MainExecutorAddr.toPtr();
\end{cpp}
\item
可以调用IR模块中的main()函数,并传递函数期望的argc和argv参数:
\begin{cpp}
(void)Main(argc, argv);
\end{cpp}
\item
函数执行成功后进行报告:
\begin{cpp}
return Error::success();
}
\end{cpp}
\item
实现了jitmain()函数之后,添加了main()函数,其初始化工具和本机目标并解析命令行:
\begin{cpp}
int main(int argc, char *argv[]) {
InitLLVM X(argc, argv);
InitializeNativeTarget();
InitializeNativeTargetAsmPrinter();
InitializeNativeTargetAsmParser();
cl::ParseCommandLineOptions(argc, argv, "JIT\n");
\end{cpp}
\item
初始化LLVM上下文类,并加载命令行中指定的IR模块:
\begin{cpp}
auto Ctx = std::make_unique();
std::unique_ptr M =
loadModule(InputFile, *Ctx, argv[0]);
\end{cpp}
\item
加载IR模块后,可以调用jitmain()函数。为了处理错误,使用ExitOnError实用程序类来打印错误消息,并在遇到错误时退出应用程序。我们还设置了一个带有应用程序名称,其输出在错误消息之前:
\begin{cpp}
ExitOnError ExitOnErr(std::string(argv[0]) + ": ");
ExitOnErr(jitmain(std::move(M), std::move(Ctx),
argc, argv));
\end{cpp}
\item
若控制流达到这一点,则IR已成功执行。返回0表示成功:
\begin{cpp}
return 0;
}
\end{cpp}
\end{enumerate}
现在,可以通过编译一个简单的示例来测试新实现的JIT编译器,该示例输出Hello World!到控制台。底层新类使用一个固定的优化级别,因此对于足够大的模块,可以注意到启动和运行时的差异。
要构建JIT编译器,可以按照在用LLJIT实现自己的JIT编译器一节末尾所做的相同的CMake步骤,只需要确保JIT.cpp源文件正在用正确的库进行编译。
\begin{cmake}
add_executable(JIT JIT.cpp)
include_directories(${CMAKE_SOURCE_DIR})
target_link_libraries(JIT ${llvm_libs})
\end{cmake}
再切换到build目录并编译应用程序:
\begin{shell}
$ cmake –G Ninja
$ ninja
\end{shell}
我们的JIT工具现在可以使用了。一个简单的Hello World!程序可以用C编写,如下所示:
\begin{shell}
$ cat main.c
#include
int main(int argc, char** argv) {
printf("Hello world!\n");
return 0;
}
\end{shell}
可以用下面的命令将Hello World C源代码编译成LLVM IR:
\begin{shell}
$ clang -S -emit-llvm main.c
\end{shell}
记住——我们将C源代码编译成LLVM IR,因为JIT编译器接受IR文件作为输入。最后,可以用IR示例调用JIT编译器:
\begin{shell}
$ JIT main.ll
Hello world!
\end{shell}
================================================
FILE: content/part3/chapter9/6.tex
================================================
本章中,了解了如何开发JIT编译器。首先了解了JIT编译器的可能应用程序,探索了LLVM动态编译器和解释器。使用预定义的LLJIT类,构建了一个交互式的基于jit的计算器工具,并了解了如何查找符号和向LLJIT添加IR模块。为了能够利用ORC API的分层结构,还实现了一个优化的JIT类。
下一章中,将了解如何利用LLVM工具进行调试。
================================================
FILE: content/part3/part3.tex
================================================
深入研究LLVM的各种底层细节,探索TableGen语言,这是LLVM的特定于领域的语言,并了解如何在后端使用它。LLVM还有一个即时(JIT)编译器,将探索如何使用,并根据需求进行定制。此外,还将尝试用于识别应用程序中的错误的各种工具和库。了解了这些后,就可以去支持LLVM尚未支持的新架构了。
\begin{itemize}
\item
第8章,TableGen语言
\item
第9章,JIT编译
\item
第10章,使用LLVM工具进行调试
\end{itemize}
================================================
FILE: content/part4/chapter11/0.tex
================================================
LLVM具有非常灵活的架构,可以添加一个新的目标后端。后端的核心是目标描述,大部分代码都是从目标描述中生成的。本章中,将学习如何添加对历史CPU的支持。
本章中,将学习以下主题:
\begin{itemize}
\item
为创建一个新的后端做准备,介绍M88k CPU架构,了解在哪里可以找到所需的信息
\item
将新架构添加到Triple类中,了解如何使LLVM意识到新的CPU架构
\item
LLVM中扩展ELF文件格式定义,向处理ELF对象文件的库和工具中添加对特定于M88k的支持
\item
了解创建目标描述将应用TableGen语言的知识,对目标描述中的注册文件和指令进行建模
\item
将M88k后端添加到LLVM,了解LLVM后端所需的最小基础结构
\item
实现汇编器解析器,了解如何开发汇编器
\item
创建反汇编器,了解如何创建反汇编器
\end{itemize}
本章结束时,将了解如何为LLVM添加一个新的后端。将了解在目标描述中开发寄存器文件定义和指令定义的知识,并了解如何根据描述创建汇编程序和反汇编程序。
================================================
FILE: content/part4/chapter11/1.tex
================================================
无论是商业上需要支持一个新的CPU,还是一个爱好项目来增加对一些旧架构的支持,向LLVM添加新的后端都是一项主要任务。本章和接下来的两章概述了为新后端开发所需的内容,我们将为摩托罗拉M88k架构添加一个后端,其为20世纪80年代的RISC架构。
\begin{myTip}{参考}
可以在维基百科(\url{https://en.wikipedia.org/wiki/Motorola_88000})上阅读有关摩托罗拉架构的信息,关于这种架构的信息仍然可以在互联网上获得。可以在\url{http://www.bitsavers.org/components/motorola/88000/}上找到包含指令集和计时信息的CPU手册,在\url{https://archive.org/details/bitsavers_attunixSysa0138776555SystemVRelease488000ABI1990_8011463}上找到包含ELF格式定义和调用约定的System V ABI M88k处理器补充。
OpenBSD(可从\url{https://www.openbsd.org/}获得)仍然支持LUNA-88k系统。OpenBSD系统上,很容易为M88k创建一个GCC交叉编译器。通过GXemul(可在\url{http://gavare.se/gxemul/}上获得),可获得了一个能够运行M88k架构的某些OpenBSD版本的模拟器。
\end{myTip}
M88k架构已经停产很久了,但我们找到了足够的信息和工具,为它添加LLVM后端。我们将从扩展Triple类开始。
================================================
FILE: content/part4/chapter11/2.tex
================================================
Triple类的一个实例表示LLVM为之生成代码的目标平台。为了支持新架构,第一个任务是扩展Triple类。llvm/include/llvm/TargetParser/Triple.h文件中,添加一个成员到ArchType枚举和一个新谓词:
\begin{cpp}
class Triple {
public:
enum ArchType {
// Many more members
m88k, // M88000 (big endian): m88k
};
/// Tests whether the target is M88k.
bool isM88k() const {
return getArch() == Triple::m88k;
}
// Many more methods
};
\end{cpp}
llvm/lib/TargetParser/Triple.cpp文件中,有许多方法使用ArchType枚举。需要扩展它们;例如,getArchTypeName()方法中,需要添加一个新case:
\begin{cpp}
switch (Kind) {
// Many more cases
case m88k: return "m88k";
}
\end{cpp}
若忘记在其中一个函数中处理新的m88k枚举成员,编译器会发出警告。接下来,我们将展开可执行和可链接格式(ELF)。
================================================
FILE: content/part4/chapter11/3.tex
================================================
ELF文件格式是LLVM支持的二进制对象文件格式之一。ELF本身为许多CPU架构定义,M88k架构也有定义。我们所需要做的就是,添加重定位的定义和一些标志。重新定位在《System V ABI M88k处理器补充》的第4章,IR代码生成基础(见本章开头设置新后端部分中的链接)中给出:
\begin{enumerate}
\item
我们需要在llvm/include/llvm/BinaryFormat/ELFRelocs/M88k.def文件中进行如下修改:
\begin{cpp}
#ifndef ELF_RELOC
#error "ELF_RELOC must be defined"
#endif
ELF_RELOC(R_88K_NONE, 0)
ELF_RELOC(R_88K_COPY, 1)
// Many more…
\end{cpp}
\item
llvm/include/llvm/BinaryFormat/ELF.h文件中添加了以下标志,以及重定位的定义:
\begin{cpp}
// M88k Specific e_flags
enum : unsigned {
EF_88K_NABI = 0x80000000, // Not ABI compliant
EF_88K_M88110 = 0x00000004 // File uses 88110-specific
features
};
// M88k relocations.
enum {
#include "ELFRelocs/M88k.def"
};
\end{cpp}
代码可以添加到文件中的任何位置,但最好保持文件的结构,并在MIPS架构代码之前插入。
\item
还需要扩展一些其他的方法。在llvm/include/llvm/Object/ELFObjectFile.h文件中,有一些在枚举成员和字符串之间进行转换的方法,必须在getFileFormatName()方法中添加新的case语句:
\begin{cpp}
switch (EF.getHeader()->e_ident[ELF::EI_CLASS]) {
// Many more cases
case ELF::EM_88K:
return "elf32-m88k";
}
\end{cpp}
\item
类似地,可以扩展getArch()方法:
\begin{cpp}
switch (EF.getHeader().e_machine) {
// Many more cases
case ELF::EM_88K:
return Triple::m88k;
\end{cpp}
\item
最后,在getELFRelocationTypeName()方法中,使用llvm/lib/Object/ELF.cpp文件中的重定位定义:
\begin{cpp}
switch (Machine) {
// Many more cases
case ELF::EM_88K:
switch (Type) {
#include "llvm/BinaryFormat/ELFRelocs/M88k.def"
default:
break;
}
break;
}
\end{cpp}
\item
为了完成支持,还可以扩展llvm/lib/ObjectYAML/ELFYAML.cpp文件。该文件由yaml2obj和obj2yaml工具使用,根据YAML描述创建一个ELF文件。第一个添加需要在ScalarEnumerationTraits::enumeration()方法中完成,该方法列出了所有ELF支持的架构:
\begin{cpp}
ECase(EM_88K);
\end{cpp}
\item
ScalarEnumerationTraits::enumeration()方法中,需要再次包含重定位的定义:
\begin{cpp}
case ELF::EM_88K:
#include "llvm/BinaryFormat/ELFRelocs/M88k.def"
break;
\end{cpp}
\end{enumerate}
至此,已经完成了对ELF文件格式的m88k架构的支持。可以使用llvm-readobj工具检查ELF对象文件,由OpenBSD上的交叉编译器创建的对象文件,可以使用yaml2obj工具为m88k架构创建一个ELF对象文件。
\begin{myNotic}{必须添加对目标文件格式的支持?}
对架构的支持集成到ELF文件格式实现中只需要几行代码,若要为其创建的LLVM后端使用ELF格式,则该采用这种方式。另一方面,添加对全新二进制文件格式的支持是一项复杂的任务。若需要这样做,常用的方法是只输出汇编程序文件,并使用外部汇编程序来创建目标文件。
\end{myNotic}
有了这些补充,ELF文件格式的LLVM实现现在支持M88k架构。下一节中,将创建M88k架构的目标描述,其中描述了该架构的指令、寄存器和更多细节。
================================================
FILE: content/part4/chapter11/4.tex
================================================
目标描述是后端实现的核心,使用TableGen语言编写,并定义了架构的基本属性,例如:寄存器和指令格式以及用于指令选择的模式。若不熟悉TableGen语言,建议先阅读本书第8章。基本定义在llvm/include/llvm/Target/Target文件中,该文件可在\url{https://github.com/llvm/llvm-project/blob/main/llvm/include/llvm/Target/Target.td}上找到。该文件有大量注释,都是关于定义使用的信息来源。
我们将根据目标描述生成整个后端。这个目标还没有实现,稍后需要扩展生成的代码。由于其大小,目标描述分割成几个文件。顶级文件是M88k.td,llvm/lib/Target/M88k目录下,该目录还包括其他文件。我们从寄存器定义开始。
\mySubsubsection{11.4.1.}{添加寄存器定义}
CPU架构通常定义一组寄存器,这些寄存器的特性可以有所不同,一些架构允许访问子寄存器。例如,x86架构有特殊的寄存器名来访问寄存器值的一部分,其他架构没有实现这个。除了通用寄存器、浮点寄存器和向量寄存器外,架构还可以有用于状态码或浮点操作配置的特殊寄存器。我们需要为LLVM定义所有这些信息,寄存器定义存储在M88kRegisterInfo中,在llvm/lib/Target/M88k目录中也可以找到。
M88k架构定义了通用寄存器、用于浮点操作的扩展寄存器和控制寄存器。为了保持示例较小,只定义通用寄存器。首先为寄存器定义一个超类,寄存器有名称和编码。该名称用于指令的文本表示。类似地,编码用作指令二进制表示的一部分。该架构定义了32个寄存器,寄存器的编码使用5位,限制了保存编码的字段。还定义了所有生成的C++代码,应该放在在M88k命名空间中:
\begin{cpp}
class M88kReg Enc, string n> : Register {
let HWEncoding{15-5} = 0;
let HWEncoding{4-0} = Enc;
let Namespace = "M88k";
}
\end{cpp}
接下来,可以定义所有32个通用寄存器。r0寄存器很特殊,在读取时总是返回常量0,所以将该寄存器的isConstant标志设置为true:
\begin{cpp}
foreach I = 0-31 in {
let isConstant = !eq(I, 0) in
def R#I : M88kReg;
}
\end{cpp}
对于寄存器分配器,需要将单个寄存器分组到寄存器类中。寄存器的顺序,定义了分配顺序。寄存器分配器还需要有关寄存器的其他信息,可以存储在寄存器中的值类型、寄存器的溢出大小(以位为单位),以及在内存中所需的对齐方式。没有直接使用RegisterClass基类,而是创建了一个新的M88kRegisterClass类。根据需要更改参数列表,还避免了用于生成代码的C++命名空间名称的重复,这是RegisterClass类的第一个参数:
\begin{cpp}
class M88kRegisterClass types, int size,
int alignment, dag regList,
int copycost = 1>
: RegisterClass<"M88k", types, alignment, regList> {
let Size = size;
let CopyCost = copycost;
}
\end{cpp}
此外,还定义了一个寄存器操作数类。操作数描述指令的输入和输出,指令的组装和分解过程中使用,也在指令选择阶段使用的模式中使用。使用自己的类,我们可以给用于解码寄存器操作数函数,一个符合LLVM编码准则的名称:
\begin{cpp}
class M88kRegisterOperand
: RegisterOperand {
let DecoderMethod = "decode"#RC#"RegisterClass";
}
\end{cpp}
基于这些定义,现在定义通用寄存器。注意,m88k架构的通用寄存器是32位宽的,可以保存整数和浮点值。为了避免写入所有寄存器名,使用序列生成器,基于模板字符串生成字符串列表:
\begin{cpp}
def GPR : M88kRegisterClass<[i32, f32], 32, 32,
(add (sequence "R%u", 0, 31))>;
\end{cpp}
同样,我们定义了寄存器操作数。r0寄存器很特殊,它只包含常数0。全局指令选择框架可以使用这一事实,因此将该信息添加到寄存器操作数上:
\begin{cpp}
def GPROpnd : M88kRegisterOperand {
let GIZeroRegister = R0;
}
\end{cpp}
m88k架构有一个扩展,为浮点值定义了一个扩展的寄存器文件,可与通用寄存器相同的方式定义这些寄存器。
通用寄存器也成对使用,主要用于64位浮点运算,需要对它们进行建模。使用sub\_hi和sub\_lo子寄存器索引来描述高32位和低32位,还需要为生成的代码设置C++命名空间:
\begin{cpp}
let Namespace = "M88k" in {
def sub_hi : SubRegIndex<32, 0>;
def sub_lo : SubRegIndex<32, 32>;
}
\end{cpp}
使用RegisterTuples类定义寄存器对,该类将子寄存器索引列表作为第一个参数,并将寄存器列表作为第二个参数。只需要偶数/奇数对,就可通过sequence的第四个可选参数来实现这一点,这是在生成序列时使用的步幅:
\begin{cpp}
def GRPair : RegisterTuples<[sub_hi, sub_lo],
[(add (sequence "R%u", 0, 30, 2)),
(add (sequence "R%u", 1, 31, 2))]>;
\end{cpp}
要使用寄存器对,需要定义一个寄存器类和一个寄存器操作数:
\begin{cpp}
def GPR64 : M88kRegisterClass<[i64, f64], 64, 32,
(add GRPair), /*copycost=*/ 2>;
def GPR64Opnd : M88kRegisterOperand;
\end{cpp}
注意,我们将copycost参数设置为2,因为需要两个指令而不是一个指令来将一个寄存器对复制到另一个寄存器对。
这就完成了对寄存器的定义。下一节中,我们将定义指令格式。
\mySubsubsection{11.4.2.}{定义指令格式和指令信息}
指令是使用TableGen指令类定义的。定义一条指令是一项复杂的任务,必须考虑许多细节,指令具有供汇编程序和反汇编程序使用的文本表示形式。有一个名称,并且可以有操作数。汇编程序将文本表示转换为二进制格式,必须定义该格式的布局。对于指令选择,需要给指令附加一个模式。为了管理这种复杂性,需要定义一个类层次结构。基类将描述各种指令格式,并存储在M88kIntrFormats.td文件中,指令本身和指令选择所需的其他定义存储在M88kInstrInfo.td文件中。
首先为m88k架构指令定义一个名为M88kInst的类,可从预定义的指令类派生出这个类。新类有几个参数,outs和ins参数使用特殊的dag类型将输出和输入操作数描述为一个列表。指令的文本表示分为asm参数中给出的助记符和操作数,pattern参数可以保存用于指令选择的模式。
还要定义两个新字段:
\begin{itemize}
\item
Inst字段用于保存指令的位模式。因为指令的大小取决于平台,所以这个字段不能预先定义。m88k架构的所有指令都是32位宽的,所以该字段为bits<32>类型。
\item
另一个字段称为SoftFail,与Inst具有相同的类型。保存一个位掩码,用于指令的实际编码可以与Inst字段中的位不同,但仍然有效。唯一需要这个的平台是ARM,所以可以简单地将这个字段设置为0。
\end{itemize}
其他字段在超类中定义,只设置值。TableGen语言中可以进行简单的计算,为AsmString字段创建值时使用,AsmString字段包含完整的汇编程序表示。若操作数的操作数字符串为空,AsmString字段将只包含asm参数的值;否则,将是两个字符串的连接,它们之间有一个空格:
\begin{cpp}
class InstM88k pattern = []>
: Instruction {
bits<32> Inst;
bits<32> SoftFail = 0;
let Namespace = "M88k";
let Size = 4;
dag OutOperandList = outs;
dag InOperandList = ins;
let AsmString = !if(!eq(operands, ""), asm,
!strconcat(asm, " ", operands));
let Pattern = pattern;
let DecoderNamespace = "M88k";
}
\end{cpp}
对于指令编码,制造商通常将指令分组在一起,一组指令具有相似的编码。可以使用这些组系统地创建定义指令格式的类。例如,m88k架构的所有逻辑操作都将目标寄存器编码为21到25位,将第一个源寄存器编码为16到20位。请注意这里的实现模式:为值声明了rd和rs1字段,并将这些值赋给了之前在超类中定义的Inst字段的正确位:
\begin{cpp}
class F_L pattern = []>
: InstM88k {
bits<5> rd;
bits<5> rs1;
let Inst{25-21} = rd;
let Inst{20-16} = rs1;
}
\end{cpp}
有几组基于这种格式的逻辑操作。其中一种是使用三个寄存器的指令组,在手册中称为三元寻址模式:
\begin{cpp}
class F_LR func, bits<1> comp, string asm,
list pattern = []>
: F_L<(outs GPROpnd:$rd), (ins GPROpnd:$rs1, GPROpnd:$rs2),
!if(comp, !strconcat(asm, ".c"), asm),
"$rd, $rs1, $rs2", pattern> {
bits<5> rs2;
let Inst{31-26} = 0b111101;
let Inst{15-11} = func;
let Inst{10} = comp;
let Inst{9-5} = 0b00000;
let Inst{4-0} = rs2;
}
\end{cpp}
让我们更详细地研究一下这个类提供的功能。函数形参指定操作,作为一个特殊的特性,第二个操作数可以在操作之前进行补充,可以通过将标志comp设置为1来表示。助记符在asm参数中给出,并可以传递指令选择模式。
通过初始化超类,可以提供更多信息。and指令的完整汇编文本模板是and \$rd,\$rs1,\$rs2。对于该组的所有指令,操作数字符串是固定的,所以可以在这里定义。助记符由该类的用户提供,可以在这里连接.c后缀,这表示应该首先补充第二个操作数。最后,可以定义输出和输入操作数,这些操作数表示为有向无环图或简称为dag。dag有一个操作和一个参数列表。参数也可以是dag,允许构造复杂的图。例如,输出操作数为(outs GPROpnd:\$rd)。
outs操作将此dag表示为输出操作数列表。唯一的参数GPROpnd:\$rd由一个类型和一个名称组成,连接了之前看到的几个部分。类型是GPROnd,在前一节中定义的寄存器操作数的名称。名称\$rd指的是目标寄存器。前面的操作数字符串中使用了这个名称,并且在F\_L超类中也使用了这个名称,输入操作数的定义类似,类的其余部分初始化Inst字段的其他部分。请花点时间检查所有32位是否已经分配。
将最后的指令定义放在M88kInstrInfo.td文件中。由于每个逻辑指令都有两个变体,因此使用一个多类来同时定义这两个指令。这里也将指令选择的模式定义为有向无环图,设置模式中的操作,第一个参数是目标寄存器。第二个参数是一个嵌套图,这是实际的模式。同样,操作的名称是第一个OpNode元素。
LLVM有许多预定义的操作,这些操作可以在llvm/include/llvm/Target/TargetSelectionDAG.td文件中找到件(\url{https://github.com/llvm/llvm-project/blob/main/llvm/include/llvm/Target/TargetSelectionDAG.td})。例如,有and操作,表示位与操作。参数是两个源寄存器,\$rs1和\$rs2。大致可以这样阅读该模式:若指令选择的输入包含使用两个寄存器的OpNode操作,则该操作的结果赋值给\$rd寄存器,并生成该指令。利用图结构,可以定义更复杂的模式。例如,第二个模式使用非操作数将补码集成到模式中。
需要指出的一个小细节是,逻辑运算是可交换的。这对指令选择很有帮助,所以可以将这些指令的isCommutable标志设置为1:
\begin{cpp}
multiclass Logic Fun, string OpcStr, SDNode OpNode> {
let isCommutable = 1 in
def rr : F_LR;
def rrc : F_LR;
}
\end{cpp}
最后,定义指令的记录:
\begin{cpp}
defm AND : Logic<0b01000, "and", and>;
defm XOR : Logic<0b01010, "xor", xor>;
defm OR : Logic<0b01011, "or", or>;
\end{cpp}
第一个参数是函数的位模式,第二个是助记符,第三个参数是模式中使用的dag操作。
要完全理解类层次结构,重新查看类定义。指导设计原则是避免信息的重复。例如,0b01000函数位模式只使用一次。若没有Logic多类,需要输入此位模式两次,并重复该模式几次,这很容易出错。
注意,最好为说明建立一个命名方案。例如,and指令的记录命名为ANDrr,而带有补充寄存器的变体命名为ANDrrc。这些名称最终会出现在生成的C++源码,使用命名方案有助于理解该名称指的是哪条汇编指令。
目前,我们对m88k架构的寄存器文件进行了建模,并定义了一些指令。下一节中,我们将创建顶层文件。
\mySubsubsection{11.4.3.}{为目标描述创建顶层文件}
我们创建了M88kRegisterInfo.td,M88kInstrFormats.td和M88kInstrInfo.td的文件。目标描述是一个名为M88k.td的文件,该文件首先包含LLVM定义:
\begin{cpp}
include "llvm/Target/Target.td"
include "M88kRegisterInfo.td"
include "M88kInstrFormats.td"
include "M88kInstrInfo.td"
\end{cpp}
我们将在稍后添加更多后端功能时扩展这个include部分。
顶层文件还定义了一些全局实例,第一个名为M88kInstrInfo的记录保存了所有指令的信息:
\begin{cpp}
def M88kInstrInfo : InstrInfo;
\end{cpp}
我们将汇编类称为M88kAsmParser。为了使TableGen能够识别硬编码寄存器,指定寄存器名,以百分号为前缀,需要定义一个汇编解析器来指定:
\begin{cpp}
def M88kAsmParser : AsmParser;
def M88kAsmParserVariant : AsmParserVariant {
let RegisterPrefix = "%";
}
\end{cpp}
最后,需要定义目标:
\begin{cpp}
def M88k : Target {
let InstructionSet = M88kInstrInfo;
let AssemblyParsers = [M88kAsmParser];
let AssemblyParserVariants = [M88kAsmParserVariant];
}
\end{cpp}
现在,已经定义了足够的目标,可以编写第一个实用程序了。下一节中,我们将向LLVM添加M88k后端。
================================================
FILE: content/part4/chapter11/5.tex
================================================
我们还没有讨论在哪里放置目标描述文件,LLVM中的每个后端在llvm/lib/Target中都有一个子目录。这里创建M88k目录,并将目标描述文件复制到其中。
当然,仅添加TableGen文件是不够的。LLVM使用注册表查找目标实现的实例,并期望某些全局函数注册这些实例。由于已经生成了一些部分,所以已经可以提供一个实现。
关于目标的所有信息,如目标机器、汇编器、反汇编器等的目标三元组和工厂函数,都存储在target类的实例中。每个目标都持有该类的一个静态实例,这个实例在中心注册表中注册:
\begin{enumerate}
\item
实现在目标的TargetInfo子目录下的M88kTargetInfo.cpp文件中。Target类的单个实例保存在getTheM88kTarget()函数中:
\begin{cpp}
using namespace llvm;
Target &llvm::getTheM88kTarget() {
static Target TheM88kTarget;
return TheM88kTarget;
}
\end{cpp}
\item
LLVM要求每个目标提供一个LLVMInitialize TargetInfo()函数来注册目标实例。这个函数必须有一个C链接,可以在LLVM的C API中使用:
\begin{cpp}
extern "C" LLVM_EXTERNAL_VISIBILITY void
LLVMInitializeM88kTargetInfo() {
RegisterTarget X(
getTheM88kTarget(), "m88k", "M88k", "M88k");
}
\end{cpp}
\item
还需要在同一目录下创建一个M88kTargetInfo.h头文件,只包含一个声明:
\begin{cpp}
namespace llvm {
class Target;
Target &getTheM88kTarget();
}
\end{cpp}
\item
最后,添加一个CMakeLists.txt文件用于构建:
\begin{cmake}
add_llvm_component_library(LLVMM88kInfo
M88kTargetInfo.cpp
LINK_COMPONENTS Support
ADD_TO_COMPONENT M88k)
\end{cmake}
\end{enumerate}
接下来,用机器代码(MC)级别使用的信息部分填充目标实例:
\begin{enumerate}
\item
实现在MCTargetDesc子目录下的M88kMCTargetDesc.cpp文件中,TableGen将前一节中创建的目标描述转换为C++源码段。这里,我们包括寄存器信息、指令信息和子目标信息的部分:
\begin{cpp}
using namespace llvm;
#define GET_INSTRINFO_MC_DESC
#include "M88kGenInstrInfo.inc"
#define GET_SUBTARGETINFO_MC_DESC
#include "M88kGenSubtargetInfo.inc"
#define GET_REGINFO_MC_DESC
#include "M88kGenRegisterInfo.inc"
\end{cpp}
\item
目标注册中心期望这里的每个类都有一个工厂方法。从指令信息开始,分配一个MCInstrInfo类的实例,并调用InitM88kMCInstrInfo()生成的函数来填充对象:
\begin{cpp}
static MCInstrInfo *createM88kMCInstrInfo() {
MCInstrInfo *X = new MCInstrInfo();
InitM88kMCInstrInfo(X);
return X;
}
\end{cpp}
\item
接下来,分配MCRegisterInfo类的对象,并调用生成的函数来填充。M88k::R1参数值告诉LLVM R1寄存器保存着返回地址:
\begin{cpp}
static MCRegisterInfo *
createM88kMCRegisterInfo(const Triple &TT) {
MCRegisterInfo *X = new MCRegisterInfo();
InitM88kMCRegisterInfo(X, M88k::R1);
return X;
}
\end{cpp}
\item
最后,需要一个用于子目标信息的工厂方法。这个方法接受一个目标三元组、一个CPU名称和一个特征字符串作为参数,并将它们转发给生成函数:
\begin{cpp}
static MCSubtargetInfo *
createM88kMCSubtargetInfo(const Triple &TT,
StringRef CPU, StringRef FS) {
return createM88kMCSubtargetInfoImpl(TT, CPU,
/*TuneCPU*/ CPU,
FS);
}
\end{cpp}
\item
定义了工厂方法之后,现在可以注册它们了。与目标注册类似,LLVM期望一个名为LLVMInitialize TargetMC()的全局函数:
\begin{cpp}
extern "C" LLVM_EXTERNAL_VISIBILITY void
LLVMInitializeM88kTargetMC() {
TargetRegistry::RegisterMCInstrInfo(
getTheM88kTarget(), createM88kMCInstrInfo);
TargetRegistry::RegisterMCRegInfo(
getTheM88kTarget(), createM88kMCRegisterInfo);
TargetRegistry::RegisterMCSubtargetInfo(
getTheM88kTarget(), createM88kMCSubtargetInfo);
}
\end{cpp}
\item
M88kMCTargetDesc.h头文件只是使一些生成的代码可用:
\begin{cpp}
#define GET_REGINFO_ENUM
#include "M88kGenRegisterInfo.inc"
#define GET_INSTRINFO_ENUM
#include "M88kGenInstrInfo.inc"
#define GET_SUBTARGETINFO_ENUM
#include "M88kGenSubtargetInfo.inc"
\end{cpp}
\end{enumerate}
实现差不多完成了。为了避免链接器错误,需要提供另一个函数,该函数为TargetMachine类的对象注册一个工厂方法。这个类是代码生成所必需的,将在第12章中实现。这里,只是在M88kTargetMachine.cpp文件中定义了一个空函数:
\begin{cpp}
#include "TargetInfo/M88kTargetInfo.h"
#include "llvm/MC/TargetRegistry.h"
extern "C" LLVM_EXTERNAL_VISIBILITY void
LLVMInitializeM88kTarget() {
// TODO Register the target machine. See chapter 12.
}
\end{cpp}
这就是我们的第一个实现,但LLVM还不知道添加了新后端。为了集成它,打开llvm/CMakeLists.txt文件,找到定义所有实验目标的部分,将M88k目标添加到列表中:
\begin{cmake}
set(LLVM_ALL_EXPERIMENTAL_TARGETS ARC … M88k …)
\end{cmake}
假设新后端LLVM源代码在目录中,可以通过输入以下命令来配置构建:
\begin{shell}
$ mkdir build
$ cd build
$ cmake -DLLVM_EXPERIMENTAL_TARGETS_TO_BUILD=M88k \
../llvm-m88k/llvm
…
-- Targeting M88k
…
\end{shell}
构建LLVM之后,可以验证工具已经知道我们的新目标:
\begin{shell}
$ bin/llc –version
LLVM (http://llvm.org/):
LLVM version 17.0.2
Registered Targets:
m88k - M88k
\end{shell}
到达这里还是有一些困难的,现在可以庆祝一下了!
\begin{myTip}{修复可能的编译错误}
LLVM 17.0.2中有一个问题,会导致编译错误。在代码中的一个地方,子目标信息的TableGen发射器使用已删除的值llvm::None,而非std::nullopt,从而在编译M88kMCTargetDesc.cpp时导致错误。修复此问题的最简单方法是从LLVM 18开发分支中挑选修复该问题的提交:git cherry-pick -x a587f429。
\end{myTip}
下一节中,将实现汇编解析器,这将是我们第一个实现的LLVM工具。
================================================
FILE: content/part4/chapter11/6.tex
================================================
汇编器解析器很容易实现,LLVM为它提供了一个框架,而且大部分都是从目标描述生成的。
当框架检测到需要解析指令时,就会调用类中的ParseInstruction()方法,通过提供的词法分析器解析输入,并构造一个所谓的操作数向量。操作数可以是标记,如指令助记符、寄存器名或直接对象,也可以是特定于目标的类别。例如,从jmp \%r2输入构造两个操作数:一个用于助记符的标记操作数和一个寄存器操作数。
然后,生成的匹配器尝试将操作数向量与指令进行匹配。若找到匹配项,则创建MCInst类的实例,其中保存解析后的指令;否则,会发出错误消息。这种方法的优点是,可自动地从目标描述中派生出匹配器,不需要处理所有的语法问题。
但我们需要添加更多的支持类,来使汇编解析器工作,这些类都放在MCTargetDesc目录中。
\mySamllsection{实现M88k目标的MCAsmInfo支持类}
本节中,将探讨实现汇编解析器配置所需的第一个类:MCAsmInfo类:
\begin{enumerate}
\item
需要为汇编解析器设置一些自定义参数,MCAsmInfo基类(\url{https://github.com/llvm/llvm-project/blob/main/llvm/include/llvm/MC/MCAsmInfo.h})包含常用参数。此外,为每种支持的对象文件格式创建一个子类:例如,MCAsmInfoELF类(\url{https://github.com/llvm/llvm-project/blob/main/llvm/include/ llvm/MC/MCAsmInfoELF.h})。原因是,因为它们必须支持相似的特性,所以使用相同对象文件格式的系统上的系统汇编程序共享相同的特征。我们的目标操作系统是OpenBSD,使用ELF文件格式,因此从MCAsmInfoELF类派生出我们的m88kmccasminfo类。M88kMCAsmInfo.h文件中的声明如下:
\begin{cpp}
namespace llvm {
class Triple;
class M88kMCAsmInfo : public MCAsmInfoELF {
public:
explicit M88kMCAsmInfo(const Triple &TT);
};
\end{cpp}
\item
M88kMCAsmInfo.cpp文件中的实现只设置两个默认值。目前的两个关键设置是系统使用大端模式和使用|符号进行注释,其他设置用于生成之后的代码:
\begin{cpp}
using namespace llvm;
M88kMCAsmInfo::M88kMCAsmInfo(const Triple &TT) {
IsLittleEndian = false;
UseDotAlignForAlignment = true;
MinInstAlignment = 4;
CommentString = "|"; // # as comment delimiter is only
// allowed at first column
ZeroDirective = "\t.space\t";
Data64bitsDirective = "\t.quad\t";
UsesELFSectionDirectiveForBSS = true;
SupportsDebugInformation = false;
ExceptionsType = ExceptionHandling::SjLj;
}
\end{cpp}
\end{enumerate}
现在已经完成了MCAsmInfo类的实现。我们将学习实现的下一个类,可在LLVM中创建指令的二进制表示。
\mySamllsection{为M88k目标实现MCCodeEmitter支持类}
LLVM内部,一条指令由MCInst类的实例表示,指令可以作为汇编文本或以二进制形式发送到目标文件中。M88kMCCodeEmitter类创建指令的二进制表示,而M88kInstPrinter类可生成其文本表示。
首先,将实现M88kMCCodeEmitter类,存储在M88kMCCodeEmitter.cpp文件中:
\begin{enumerate}
\item
类的大部分由TableGen生成,所以只需要添加一些样板代码。注意,没有相应的头文件;工厂函数的原型将添加到M88kMCTargetDesc.h文件中。首先,为生成的指令数量设置一个统计计数器:
\begin{cpp}
using namespace llvm;
#define DEBUG_TYPE "mccodeemitter"
STATISTIC(MCNumEmitted,
"Number of MC instructions emitted");
\end{cpp}
\item
M88kMCCodeEmitter类位于匿名命名空间中,只需要实现在基类中声明的encodeInstruction()方法和getMachineOpValue()辅助方法。另一个getBinaryCodeForInstr()方法由TableGen根据目标描述生成:
\begin{cpp}
namespace {
class M88kMCCodeEmitter : public MCCodeEmitter {
const MCInstrInfo &MCII;
MCContext &Ctx;
public:
M88kMCCodeEmitter(const MCInstrInfo &MCII,
MCContext &Ctx)
: MCII(MCII), Ctx(Ctx) {}
~M88kMCCodeEmitter() override = default;
void encodeInstruction(
const MCInst &MI, raw_ostream &OS,
SmallVectorImpl &Fixups,
const MCSubtargetInfo &STI) const override;
uint64_t getBinaryCodeForInstr(
const MCInst &MI,
SmallVectorImpl &Fixups,
const MCSubtargetInfo &STI) const;
unsigned
getMachineOpValue(const MCInst &MI,
const MCOperand &MO,
SmallVectorImpl &Fixups,
const MCSubtargetInfo &STI) const;
};
} // end anonymous namespace
\end{cpp}
\item
encodeInstruction()方法只是查找指令的二进制表示,将统计计数器加1,然后以大端格式写出字节。指令有一个固定的4字节大小,所以在端流上使用uint32\_t类型:
\begin{cpp}
void M88kMCCodeEmitter::encodeInstruction(
const MCInst &MI, raw_ostream &OS,
SmallVectorImpl &Fixups,
const MCSubtargetInfo &STI) const {
uint64_t Bits =
getBinaryCodeForInstr(MI, Fixups, STI);
++MCNumEmitted;
support::endian::write