ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入LLVM:源码结构、核心原理与Pass编写实战

深入LLVM:源码结构、核心原理与Pass编写实战 如果你在一个周末下午跟着某个教程把llvm-project仓库 clone 下来看到顶层目录的那一瞬间大概率会愣住这个仓库怎么这么大目录怎么这么多我到底该从哪儿看起我第一次面对那份目录树时脑子里只有一个想法——这不是一个人写个编译器能撑起来的规模这是一整套工业级基础设施工程。llvm-project不是一个单独的软件项目它是围绕编译器研发而聚合起来的一整套工具链合集前端、中端优化器、后端代码生成、链接器、运行时库、标准库实现、Sanitizer甚至还有已经自成体系的 MLIR 多级 IR 框架全部打包在同一个仓库里。这篇文章我打算从一个经常拿它干活也偶尔会翻到源码里查问题的人的角度把这个仓库彻底拆开讲一遍。不管你是想做一门新语言、想给现有程序定制优化还是单纯想知道“编译器到底是怎么把代码变成机器码的”这篇内容都能帮你建立一张完整的地图。看完之后你至少能弄明白三件事llvm-project里到底装了些什么为什么这么多公司愿意把自己的编译器押在它身上以及如果你想在上面动手做点什么第一步该怎么迈。1. 打开llvm-project仓库的第一印象它远超一个编译器1.1 顶层目录不是乱堆的每个子目录都有明确分工llvm-project仓库的顶层目录很多人第一次看会发懵因为没有一个叫src的主目录也没有一个统一的README告诉你全部答案。它是一批相对独立的子项目放在同一屋檐下。按正确的方式理解这其实是 LLVM 基金会刻意保持的组织方式核心库与外围工具分开而外围工具之间共享一套基础设施。先说最核心的llvm/。这个目录才是“真正的 LLVM 本体”这里存放着 LLVM 核心库模型、IR 定义、Pass 优化管线、目标无关代码生成框架以及一堆开发者日常用的小工具源码比如opt、llc、llvm-as、llvm-dis、FileCheck。它不直接面向普通用户但面向所有基于 LLVM 做二次开发的人。你想写一个自定义优化 pass真正需要改的就是这棵目录树里lib/Passes、include/llvm/IR这些位置。clang/是 C/C/Objective-C 的编译器前端。很多人把 clang 和 LLVM 混为一谈因为日常命令里clang最常见但严格来说 clang 只是 LLVM 这套基础设施上的第一个“大用户”。它负责把 C 语言解析成语法树、做语义分析最终降级为 LLVM IR然后交给 LLVM 核心做优化和后端代码生成。lld/是一套全新的链接器实现速度非常快在大型项目里通常比 GNU 的 ld 快一个量级现在很多构建系统默认就在用 lld。再往下是compiler-rt/它提供各种运行时支持库比如 ASan 内存检测、UBSan 未定义行为检测、Profile 计数等。这些看起来不像“编译器”的组件恰恰是现代编译器必须具备的外围能力。你经常用的-fsanitizeaddress背后的运行时就是从这里来的。libcxx/、libcxxabi/、libunwind/三个目录合起来是完整的 C 标准库实现加 ABI 层加栈展开库。如果你用 clang 配合这套标准库在 macOS 或 Linux 上跑 C链路就落到它们头上。mlir/是近几年发展得非常快的一块全称 Multi-Level Intermediate Representation它做的是多层 IR 的通用基础设施在 AI 编译器、硬件加速器、自定义 DSL 领域很受关注。剩下的flang/是 Fortran 前端polly/是多面体优化openmp/是 OpenMP 运行时third-party/里则有一些项目依赖的第三方库。这套目录结构直观告诉你一件事LLVM 已经不满足于只是“把高级语言编译到机器码”它在试图成为整个编译生态的操作系统。像操作系统给上层应用提供稳定接口一样LLVM 向外提供 IR、Pass API、指令选择框架这些稳定接口而 clang 这类前端相当于跑在它上面的应用。1.2 核心主干的流水线前端产出 IR中端优化后端生成机器码如果你打开 LLVM 官方文档经常看到一张三阶段的示意图Frontend → Optimizer → Backend。对应到这套代码上就是clang 把 C 代码解析成 AST然后生成 LLVM IRLLVM 优化器跑一系列 pass 处理 IR后端把优化后的 IR 变成汇编再由汇编器和链接器收尾。你可以用一条命令看到这个完整过程cat sum.c clang -S -emit-llvm sum.c -o sum.llsum.ll就是 LLVM IR 的文本表示我截取一段经典例子define i32 add(i32 %a, i32 %b) { entry: %sum add i32 %a, %b ret i32 %sum }这还没做任何优化。你想看优化之后长什么样可以用opt工具opt -O2 -S sum.ll -o sum.opt.ll优化后的 IR 可能会发生常量折叠、内联、死代码消除等变化。再到汇编级用llc生成目标汇编llc sum.opt.ll -o sum.s如果你在 x86-64 机器上执行会看到类似addl %esi, %edi这样的结果。拆成这种流水线式结构而不是传统编译器那样一个整体最大的好处是每个阶段都能单独替换、单独测试。你不想用 clang可以写一个自己的前端产出 IR你不想用默认的 x86 后端可以插一个新的 target你想在优化阶段插入一个自己项目的分析逻辑空间也非常大。2. 编译器的分水岭LLVM IR和Pass机制凭什么能通吃2.1 IR设计的三条铁律SSA、强类型、三地址码如果你只看过汇编再看一眼上面那段 LLVM IR应该能嗅到一种“既接近机器又接近高级语言”的味道。这种中间表示是 LLVM 整个体系里最关键的资产。它坚持三条设计原则SSA 形式、强类型、三地址码。SSA 全称 Static Single Assignment核心含义是每个变量只赋值一次。看上面例子里的%sum它只被add指令写一次之后只被读取。这个约束看起来很小却给优化算法带来巨大的便利。传统编译器在做数据流分析时要处理变量的多次写入追踪定义和使用关系非常痛苦在 SSA 形式下数据依赖关系直接通过变量的使用链就能看出来很多优化算法能简化一个数量级。这也是为什么 LLVM 的优化 pass 能在几百行代码里实现很强的分析能力。所谓强类型就是每条指令的操作数都带着明确类型i32表示 32 位整数i64表示 64 位整数指针是ptr。类型信息让 IR 层面的优化器可以预设很多目标无关的规律比如add i32和add i64是两个不同的指令溢出行为彼此不影响。三地址码的意思是每条指令最多一个运算符、两个源操作数和一个目标操作数。跟四地址、任意地址数的带树形表达式 IR 相比三地址码让各种 pass 处理起来非常规整后端做指令选择时也不用拆树。但也别把 IR 的“目标无关”想象成绝对干净。实际上 LLVM IR 里有大量 target specific 的 intrinsic比如 x86 的 MMX/SSE 指令映射到llvm.x86.sse.*这组内建函数一旦后端需要暴露一些类似平台定制能力它会通过 intrinsic 或 target attribute 缝进去。所以更准确的说法是IR 提供了绝大多数优化所需的目标无关抽象但仍然留了口子给后端处理特殊需求。看 IR 文本文件是走进 LLVM 世界的必经之路。很多初学者害怕那一长串%1 load i32, i32* %ptr这样的行其实把它想象成“一种更规整的汇编”就好。你只要明白三件事所有临时寄存器都以%开头、每条指令带类型信息、基本块之间用分支指令跳转就能读懂绝大多数 IR。2.2 Pass优化管线分析和变换如何协同IR 本身只是一张中间表示的数据结构真正让它增值的是挂在上面的优化算法。在 LLVM 里这些算法以 Pass 为单元组织分成两大类别分析 Pass 和变换 Pass。分析 Pass 只读 IR 并产出某种总结信息比如 DominatorTree 计算出每个基本块的支配者关系LoopInfo 识别出循环结构AAResults 回答“这两个内存访问能不能别名、会不会冲突”。这些分析结果会被变换 Pass 消费。变换 Pass 负责改写 IR比如把死代码删掉、把冗余加载合并、把循环展开或向量化。实际编译器里最常见的 InstCombine 是一组非常激进的模式匹配优化你在-O2下大部分 IR 改写都来自它。Pass 之间的依赖关系是现代 PassManager 要解决的核心问题。比如一个循环变换 pass 很可能要先用 LoopInfo 分析PassManager 得保证在运行它之前先跑 LoopInfo。老的 PassManager 靠注册时的静态依赖声明来调度新的 PassManager 则用更细粒度的分析请求和 PreservedAnalyses 标记机制。你可以把它理解为一份流水线“工作排期表”每个 pass 干完活报告“我改了什么、没改什么”PassManager 据此决定哪些分析结果还能复用哪些必须重新算。这套机制就是 New Pass Manager现在默认启用。如果你打开 clang 的源码会发现-O2其实对应一大串 pass 的注册链。同一个优化点往往可以拆成几十个几十行的微小 pass而不是一个巨型优化器这样既方便阅读也方便测试责任追踪。实际操作中你给opt传-passes...就能在命令行级别组合任意 pass 链这让调试优化问题变得异常清晰先用-print-after-all看到每个 pass 执行前后的 IR 变化哪个 pass 把代码改成不对了立刻就能定位。2.3 后端抽象与TableGen换一个CPU架构的成本为什么低每个想做新语言的人都会问如果我基于 LLVM 做后端是不是就可以免费获得 x86 和 ARM 的全部支持答案是大方向对但得理解后端是怎么抽象出来的。LLVM 后端的设计目标是让新增一个 CPU target 时尽量少写重复逻辑。它靠两套关键机制实现TableGen 描述文件和统一的指令选择框架。TableGen 是一种描述性语言专门用来声明寄存器种类、指令格式、基本块布局、跳转关系这些后端信息。以 x86 后端的X86InstrInfo.td为例里面每条指令都写清楚操作数类型、编码格式、汇编助记符和机器码 pattern。编写一个新的后端时很大一部分工作是填这些.td描述文件然后 TableGen 工具自动生成大量 C 代码包括指令编码表、反汇编表、寄存器分配规则等。这让“加一条新指令”变成改几行描述文件而不是重写一整个后端逻辑。指令选择阶段面临的问题是IR 指令和底层机器指令不是一一对应的。比如一条add i32IR 到 x86 上既可能映射成addl也可能映射成leal来利用地址计算单元。早期后端使用 SelectionDAG 框架先把 IR 构建成一个 DAG然后做模式匹配逐层匹配模式并生成指令。这个框架强大但复杂编译阶段开销高。近几年 LLVM 在推 GlobalISel一种更增量式的指令选择方案它把指令选择过程切成更小的步骤便于引入新架构、减少编译时间。很多新加入的后端如 RISC-V 从一开始就支持 GlobalISel。所以“换一个 CPU 架构的成本低”是对前端而言。真实情况是后端一旦涉及性能调优、调度器改进、向量指令扩展工程量会迅速上升。但相对于从零开始给一门语言写所有后端基于 LLVM 的成本还是低了不止一个量级。这也是后面要说的新语言几乎都押注在 LLVM 上最核心的原因。3. 把llvm-project拉到本地一次完整的构建与自定义Pass实验3.1 构建前要做的三个决定组件范围、版本分支、构建类型如果你决定亲手编译llvm-project先别急着cmake -G Ninja一把梭构建以前有三个决定直接影响最终体验。第一个决定是组件范围。LLVM 在设计上把项目分成“projects”和“runtimes”两组。传统方式是用-DLLVM_ENABLE_PROJECTSclang;lld把要一起构建的前端或外围工具列进去而像libcxx、compiler-rt这些运行时组件在新版本里更推荐放进-DLLVM_ENABLE_RUNTIMES。这块命名在新旧版本里有变化如果你网上搜到一个三年前的教程可能会看到截然不同的推荐配置所以最好以llvm目录下的CMakeLists.txt注释和当前文档为准。第二个决定是版本分支。不要直接跑到 main 分支上追最新代码除非你准备好承受每天都在迁移的 API。所有正式版本都有 tag比如llvmorg-20.1.0。用 release 分支构建稳定很多。你 clone 完之后可以git clone --depth 1 -b llvmorg-20.1.0 https://github.com/llvm/llvm-project.git--depth 1只拉最新提交节省大量网络和时间。如果想试用新技术也可以切到main分支但一定要有“编译一次代码要更新不少旧 API”的心理准备。第三个决定是构建类型和调试信息。CMAKE_BUILD_TYPE 选 Release 还是 Debug 差异巨大。Debug 构建会带大量断言和调试符号编译出的 clang 运行时间也慢很多但如果有问题看堆栈非常方便Release 构建产物稳定快速但断言和内部检查会大量去掉。两者都要占磁盘Debug 构建很容易突破 30GBRelease 至少也要 10GB 上下。我自己的习惯是日常用 Release -DLLVM_ENABLE_ASSERTIONSON保留内部一致性检查同时保证跑起来速度能接受。如果你只关心 x86那把LLVM_TARGETS_TO_BUILD限定成X86能省下大量后端代码和编译时间。3.2 一套实测可用的cmake构建配置以 LLVM 20 和 Ninja 为例一个完整可用的构建命令长这样cd llvm-project cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64 \ -DLLVM_CCACHE_BUILDON \ -DCMAKE_INSTALL_PREFIX$HOME/toolchain/llvm20然后执行ninja -C build这个配置会同时构建 LLVM、clang 和 lld。LLVM_CCACHE_BUILDON建议一定开ccache 能让你每次改一小部分代码重新编译时快很多省下的是真金白银的时间。构建耗时取决于机器核数和内存通常一台 16 核的机器全量构建需要半小时到一小时文件传输结束后磁盘会看到十几个 GB 的 build 目录。如果编译过程中 OOM 崩溃优先调低ninja -j的并发任务数比如ninja -C build -j 8。构建完成后如果想手动测试一下可以直接用build/bin/clang。这个 clang 还没有安装到系统但已经完整可用。它会用刚才编译出的 LLVM 库完成整条编译流水线。把build/bin加进PATH你就相当于拥有了一套自带工具链。3.3 手写一个打印指令的FunctionPass并跑起来只构建不动手优化等于没真正进入 LLVM 世界。这里我带你写一个最简单的自定义 pass。目标就是遍历每个函数里的所有指令把每条指令的 opcode 名字打印到终端。现代 LLVM 默认使用 New Pass Manager新写法如下。把这段代码存成PrintOpcode.cpp#include llvm/IR/Function.h #include llvm/IR/InstIterator.h #include llvm/IR/Instructions.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { class PrintOpcodePass : public PassInfoMixinPrintOpcodePass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { outs() Function: F.getName() \n; for (Instruction I : instructions(F)) { outs() I.getOpcodeName() \n; } return PreservedAnalyses::all(); } static bool isRequired() { return true; } }; } // namespace PassPluginLibraryInfo getPassPluginInfo() { const auto callback [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name print-opcodes) { FPM.addPass(PrintOpcodePass()); return true; } return false; }); }; return {LLVM_PLUGIN_API_VERSION, PrintOpcode, LLVM_VERSION_STRING, callback}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getPassPluginInfo(); }编译这个插件clang -stdc17 -fPIC -shared PrintOpcode.cpp -o libPrintOpcode.so \ $(llvm-config --cxxflags --ldflags --libs --system-libs)前提是你的 clang 是从上面构建目录里来的且llvm-config指向同一个版本。接下来准备一份测试代码cat sum.c EOF int add(int a, int b) { return a b; } EOF clang -S -emit-llvm sum.c -o sum.ll opt -load-pass-plugin./libPrintOpcode.so -passesprint-opcodes sum.ll -disable-output如果一切正常你能在终端里看到每个函数名和函数内所有指令的 opcode。这个东西本身没有实际优化价值但它完整验证了自定义 pass 的注册、加载和运行链路。从这一步开始往真正的优化方向扩展就只是逻辑问题不是框架问题。3.4 调试LLVM时真正救命的两个开关跑过 pass 之后真正调试 LLVM 时你会遇到一堆“为什么 IR 变成这样”的问题。这里有两个开关是救命的。第一个是-print-after-all。把它传给optLLVM 会在每个 pass 执行后把整个 IR 打印出来。一旦发现某个 pass 把代码改到崩坏直接搜它前面的 pass 名就能锁定元凶。更精细一点还可以用-print-before-all看 pass 执行前的状态。CI 场景可以用-filter-print-funcs函数名限制只打印某个函数的 IR避免刷屏。第二个是-debug-only它控制 LLVM 内部基于 debug 日志的输出。比如排查指令选择问题时加llc -debug-onlyisel就能看到指令选择器内部决策过程排查调度问题用-debug-onlyscheduler。这个开关依赖代码里的LLVM_DEBUG(dbgs() ...)分支必须在编译时保留断言和 debug 支持才会生效。所以前面建议 Release LLVM_ENABLE_ASSERTIONSON是有道理的。另外两个值得记住的参数是-debug-passStructure和-opt-bisect-limit。前者让你看 pass 执行顺序和依赖关系后者能让优化管线在指定数量的 pass 之后停下来特别适合定位“某个 pass 一加就崩溃”的场景。踩坑多了你就知道LLVM 的调参设计几乎是围绕可观测性来做的几乎所有中间状态都能以某种方式导出查看。4. 被念叨最多的几个误解以及什么人才真的需要它4.1 把LLVM当成编译器本身是最常见的误解很多博客会写“GCC 是编译器LLVM 也是编译器”严格说这个类比有误导。GCC 是一个整体式编译器前端、优化器、后端打包在一起用户通过gcc一个命令完成全部步骤。LLVM 框架则更接近模块化套装你可以只用它的优化器而不使用 clang可以用它的后端生成纯汇编完全不碰优化甚至可以只把它当非常丰富的 IR 操作库来用。日常交流时大家说“用 LLVM 编译”真实含义通常是“用 clang/LLVM 工具链编译”。但如果你想做二次开发就必须区分这些层次。别人问“LLVM 支持 C 吗”答案不是 LLVM 支持而是 clang 支持LLVM 核心只处理 IR 层面的东西。这种区分决定了你在阅读源码时找的是clang/目录还是llvm/目录。正确理解“工具链”的方式是clang 负责前端LLVM 核心负责中端优化和后端LLD 负责链接compiler-rt 负责运行时支撑libc 负责标准库实现。它们组合起来才是一条完整的编译工具链。单独说“LLVM is a compiler”虽然方便但会让你在深入学习时对目录和 API 边界产生不必要的困惑。4.2 为什么Rust、Swift、Zig这些新语言都选择押在LLVM上看近十年的主流新语言Rust、Swift、Zig、Julia几乎都倒向了 LLVM。原因是成本账太划算了。语言设计者最想精雕细琢的是语法、类型系统和语义而不是重新发明一套寄存器分配算法和指令调度器。基于 LLVM前端只要输出 IR就能立刻获得几十个后端的支持和一套已经打磨多年的优化器。这相当于白捡了一个编译器后端团队几十年的积累。但这不是没有代价。上游 LLVM 每半年就有一次版本更新新的 IR 特性、新的 pass 管理器版本、新的编译选项都可能影响下游。Rust 社区跟踪 LLVM 版本、处理目标后端问题的经验其实占了很大一部分维护负担。Julia 的 JIT 路径对 LLVM 版本也很敏感。所以“押注 LLVM”是收益巨大的决策但也是一笔需要长期维护承诺的决策。除了编译器语言LLVM 在 GPU/AI 编译、Sanitizer、静态分析、代码检查工具上也被大量复用。它已经不只是“编译器的选择”而是“有没有必要自己写编译基础设施”的分界线。你只要能产出合法的 LLVM IR后面的一整套优化和后端输出能力就都站在你这边了。4.3 实际工程里什么情况不要轻易上LLVM尽管 LLVM 光环巨大但实际工程中确实有一些场景不该硬上。编译速度极其敏感的场景是第一条。LLVM 优化管线非常强大但代价是耗时高。如果你的项目希望每次改动后秒级重编或者目标是超大规模源码的持续集成那 LLVM 的-O2开销很可能比预期大。此时应该拉低优化等级或只在最终发布阶段启用重型优化。第二条是二进制体积。LLVM 优化后的代码偏向速度和通用性对于几十 KB 级别的嵌入式固件它不一定是极致体积的正解。极端场景你可能更愿意用 TinyCC 这类极简编译方案。第三条是已经定型的嵌入式工具链。很多芯片厂商会把特定版本的 GCC 工具链作为官方推荐改了之后可能带来 ABI 差异、内建函数行为差异、调试信息差异这些连锁问题会让人筋疲力尽。这些边界不是说“LLVM 不行”而是说它是通用型基础设施适合你愿意投入构建复杂度、需要长期演进和多架构支持的项目。对于临时脚本解释器、超小型固件、纯玩具语言完全没必要背这个概念包袱。选编译器基础设施和选数据库、选框架一样要看场景边界。5. 一份给后来人的学习路线和踩坑清单5.1 从会用clang到会写pass的路径经常有人问“我 C 基础一般能不能入门 LLVM”。我的答案是可以但要按顺序走。第一阶段先把 clang 当成高级实验工具用熟。随便写一点 C 代码用clang -S -emit-llvm转出 IR再用opt -O2对比优化前后差异。读不懂 IR 没关系先认指令模式比如add、load、br、ret。第二阶段研究一个现存 pass 的源码。我推荐先读InstCombine里一个小优化函数看它是怎么匹配 pattern、怎么替换 IR 的。同时配合-print-after-all观察它在真实代码上的行为。第三阶段自己做一个小变换。可以试着把一条add指令改成等价的mul或shift处理好常量边界后验证结果这个过程你才会真正明白 IRBuilder 怎么用、PreservedAnalyses 为什么重要。第四阶段去翻社区已经提交的小型 patch 或 beginner issue强行走一遍从阅读 issue、写代码、跑测试到提交 PR 的完整链路。这条路走得快慢差异很大但对大部分人来说最难的一步是“把仓库编译跑通并成功加载一个自定义 pass”。所以如果你还没动手先把第 3 节那套配置跑一遍后面一切都好说。5.2 我踩过的坑磁盘耗尽、教程失配、版本迁移LLVM 的坑排在最前面的永远是磁盘不够。Debug 构建动不动 30GB 起步很多人刚开始只给虚拟机留 20GB构建到一半直接挂掉。解决办法是构建前用du -sh检查空间、限制 target 和组件范围、尽量用 Release 构建。第二个高频坑是教程版本错配。你搜到一个讲 Legacy PassManager 的老教程照着写RegisterPass结果当前版本可能已经默认切换到 New PassManager运行时报错一头雾水。下次遇到这种情况先看一眼仓库 release tag 和文档版本号再决定学习路径。第三个坑是搞混 Debug 和 Release 的预期。Debug 构建的 clang 编译速度比 Release 慢好几倍如果你只在 Debug 构建下做性能测试可能得出“LLVM 优化很烂”的错误结论。第四个坑是盲目看大而全的源码比如第一次就想读懂整个优化管线目录。正确做法是用调试开关锁定一条小路径比如说只看某个 pass 如何被注册、如何被调度。LLVM 整体代码量有好几百万行没有人需要全部掌握。5.3 参与LLVM社区的入门姿势如果你决定参与项目本身要知道的是社区协作方式已经全面转到 GitHub。以前大家用 Phabricator 做 code review现在大部分 patch 都走 GitHub Pull Request讨论集中在 Discourse 论坛和 GitHub issue。第一次提 patch 不要贪大一个小修文档、一个错误提示改进、一个 test case 补充都很受欢迎。review 过程中可能会被问很多“为什么这里要这么做”的问题这是好事审查意见能逼你把迁移原因想清楚。每年还有 LLVM Developers Meeting国内也常有各种编译技术线下聚会。如果你在线下能遇到真正维护过clang或mlir的人多聊几句收获往往比看一个月文档都大。我个人的体会是不要一开始就抱着“我要贡献大功能”的心态先从一个对你项目真正有用的小 plugin 开始。只要你的项目能跑通opt -load-pass-plugin并产出正确结果你就已经踏进了这个庞大工程最可爱的门缝里。往里走的路总是越走越亮的。
返回列表