编写 .NET 与非托管资源互操作的绑定代码

TL;DR

建议使用 ClangSharpPInvokeGenerator 生成 P/Invoke 绑定代码。在需要人工介入的情况下可以参照本文前半部分介绍绑定技巧的部分进行修改。生成绑定后仍然建议对其二次封装使之更符合 .NET 的使用习惯。

展示

我在 OptimeGBA.io 中手写了 libvpx 的绑定,而用 ClangSharpPInvokeGenerator 生成了 libopenh264 的绑定,供各位对比绑定生成的质量。此外,这两个库的二次封装也可作为案例参考如何将 .NET 与非托管资源的互操作体验更加原生。

运行效果可以在此体验:https://aws.martincl2.me/OptimeGBA.io/。部署的版本使用了 h264 编码。

P/Invoke 是什么

P/Invoke 是 .NET 中与二进制代码库互操作的机制。得益于 .NET 优秀的底层操作能力,P/Invoke 可以在没有额外非托管代码包装的情况下直接与二进制接口互操作。相较 Java 的 JNI 或是 Node.js 的 V8 binding 之下,P/Invoke 可以省去一部分 native code 编译链的耦合,而更重要的是 P/Invoke 的绑定不受 .NET 大版本更新的影响,即一次绑定,所有大版本兼容。

P/Invoke 举例来说, getpid 的 C 接口可以被直接在 C# 中引用:

当然, getpid 是极为简单的例子。常见的互操作中需要使用更为复杂的结构体、指针、生命周期管理,同时还要保证 C# 侧调用的时候能尽可能贴合 C# 的代码风格。此文就以我在 OptimeGBA.io 中的经验为基础,简单分享一下如何迅速而优雅地为一个二进制库实现 P/Invoke 绑定。

生成 P/Invoke 绑定

简单结构体

C# 的结构体声明与内存结构(Layout)几乎是完美兼容 C 的。甚至对于一个简单的结构体而言,只需要简单的复制粘贴就能完成绑定。例如以下的 C 结构体便可以直接翻译为 C# 的结构体:

其中字段的顺序、大小,甚至是内存对齐( byte b 后会留空三字节),C# 都与 C 的行为一致。

翻译 typedef – 预设别名

C 接口中大量使用了 typedef,甚至可以说是无可避免地会使用 typedef,因为在实际操作上 C 依赖各种编译器预设的 typedef 来实现跨平台源代码兼容,因而 C 标准库中的函数签名就大量使用了 typedef。例如在上文 getpid 的例子中,该函数的返回值是 pid_t,而如果在 amd64 的目标平台编译器下顺着标准库的头文件查找并且把 typedef 的声明全都实体化,最终就会发现 pid_t 在本平台的定义实际上是 int

在 C# 中显然并没有诸如 pid_t 的类型别名,因此在写 P/Invoke binding 的时候需要逐个找到这些别名在本平台的实际定义。通常这在 IDE 环境下跳转几次就能找到了。如果对结果不确定可以用 sizeof 验算一下字段长度——毕竟只要字段长度对了,最不济也就一个字段数据不对,而如果长度错了那后面的字段偏移量就全部完蛋了。

此外,虽说如今主流平台的这些 *_t 类型的实际类型基本上是一致的,还是要小心跨平台的时候会有平台差异。

翻译 typedef – 自定义别名

一些情况下 typedef 可以为某个类型设置一个友好而可读的别名。例如说在 C API 中非常常见的一种做法是在结构体或参数里放一个 void* 存放一些内部实现相关的数据。调用方并不需要知道这个指针怎么来的以及存了什么,而只需要保证指针传递到了就行。而此时设计函数签名的时候就可以通过 typedef 给这个 void* 设置别名以便跟别的指针做出区隔:

虽说 C 编译器不会对设置了别名的 void* 做出任何区隔,换言之随便传一个指针进去都能通过编译(然后运行的时候爆炸),但在函数中使用了别名后作为调用方可以更直观地理解应该具体传什么指针进去而不是完全依赖阅读文档。

而当翻译这段 API 到 C# 的时候,固然可以直接使用 void* 作为变量类型,但也可以通过一个但字段的结构体包装这个指针以达到类似 typedef 的效果,甚至可以通过这种方法在编译时就能完成指针类型的检查:

由于这个包装的结构体的数据长度和一个指针完全相同,通过包装结构体定义的函数签名与使用指针在效果上是同样的。

Enum

C 的枚举类型本质上是 int——「正巧」C# 也是。因此如果 C header 中有 enum 的定义,或是函数签名中有用作 flag 的 int,都可以用 C# 替换。例如:

* 与其说是「正巧」应该说是 C# 一步到位直接把 enum 的语法与行为定义为与 C 一致。

如果万一遇到不一致的情况,或是需要手动调整字段的内存布局,则可以使用 StructLayoytFieldOffset 作细致的调整。具体可以参照下文介绍 union 的部分。

Fixed buffer

在 C 标准中,结构体上是可以定义固定长度的数组的。例如:

对于不熟悉 C 的读者请注意:这不同于定义一个指针字段并赋值一个数组指针(如 int* plane),这个数组字段的实际数据是存在于结构体内部的,因而在使用的时候编译器只需简单地将结构体的指针偏移几个字段就能拿到这个定长数组的指针。

而在 C# 中,简单情况下可以用 fixed buffer 声明这种数组:

但是 C# 目前只支持 primitive unmanaged type 用作 fixed buffer,也就是 intulong 等类型。换言之,结构体或指针之类的数据类型就不能这样定义了。这种情况下就必须手动展开了:

Union

Union 是一种 C 中非常常用的结构体布局。具体而言,union 允许将多个不同的数据段存储在同一段内存中。例如:

在 C# 中可以通过手动指定字段偏移量的方式定义一个 union 结构体。结构体上的 StructLayoyt 属性告诉编译器一个结构体需要手动设置字段偏移量,而字段上的 FieldOffset 属性则设置了具体字段偏移量的数值。对于 union 而言,只需要将 FieldOffset 全部设置为 0 即可。

自动生成绑定

虽说以上讲了这么多使用场景、解决方案与技巧,在了解与攻破了这些场景以后写绑定这件事本身其实是个体力活。尤其是 C header 很可能散落各地,编排顺序也并无逻辑,而逐个翻译 header 中的每一个定义既吃力又容易出错。那么有没有方便又可靠的工具可以一键无脑生成绑定代码的呢?有!那便是 ClangSharpPInvokeGenerator

Clang 是 llvm 工具链中处理 C 与 C++ 的前端。编译器里的前端大致上指的是处理分析源代码的部分——将源代码 parse 成抽象语法树(AST)、分析符号、处理宏,等等。而 ClangSharp 是一个 Clang 的 C# 绑定,换言之,ClangSharp 是一个在 .NET 中处理 C/C++ 源代码的工具。为何要花篇幅介绍 Clang?因为 Clang 普遍认为是最模组化、功能齐全,而业界又广泛采用的 C/C++ 前端。也就是说,如果采用 ClangSharp 分析 C/C++ 源码,将会得到与业界主流编译器 clang 完全一致的结果。这也就意味着 ClangSharp 可以为自动生成 P/Invoke 绑定提供非常可靠的信息源。顺带一提,这与 C# 编译器 Roslyn 的设计目的之一相同:编译器一次实现,多处应用,包括编译本身、静态分析、Language Server (IDE 集成)等等,以保证结果的一致性。

* 注:ClangSharp 本身就是通过 ClangSharpPInvokeGenerator 自己生成的绑定代码。

安装 ClangSharpPInvokeGenerator:

生成绑定:

然后将生成的绑定代码复制到项目的源码即可。

* 注意有一些库同时提供了 C 与 C++ 的头文件。这种情况下通常直接为 C++ 的头文件生成绑定会让生成的代码更干净。

通常来说生成的绑定无需修改已经足够可读了。不对生成的代码作任何修改可以保证今后再次生成头文件时可以无缝升级。当然我也遇到了一些不理想的生成,例如有一些库将 enum 通过 #define 定义为了常数,我会手动将其改成 enum。

将绑定封装为 .NET 库

绑定其实只完成了一半的工作。为了让非托管库的调用更符合 .NET 的使用习惯,同时也保证调用安全、减少 unsafe 的使用,我们仍然需要对绑定代码作二次封装。毕竟都用 .NET 了,总不能让调用方仍然手动操作指针吧。

内存管理——IDisposable

.NET 拥有 GC,而非托管代码则并没有。因而使用 P/Invoke 的时候要格外注意来自非托管对象的生命周期。

以 libvpx(已简化)为例,一个典型的 C 库需要这样管理生命周期:

对于手动管理生命周期的运行时而言,分配与销毁必须成对出现,包括出现异常的情况下,不然轻则内存泄漏,重则整个进程原地爆炸。

.NET 标准库中的 IDisposable 则提供了一个统一且设计良好的范式——调用方既可以通过 using 语句实现类似 RAII 的作用域级自动释放,也可以将非托管资源的生命周期与托管资源的生命周期(GC)挂钩自动释放。

举例而言,上述 libvpx 的例子使用 IDisposable 封装:

IDisposable 的模板看着很庞大,但其实已经考虑好了各种边角情况,因此最好还是按照模板实现。

内存操作——Span<T>

.NET 一类的托管语言的一大卖点在于全自动的边界检查。在 .NET 的数组中任何越界的访问都会触发异常,而不是造成越界访问的漏洞。然而在非托管资源上边界检查往往要手动实现。在 .NET 中,我们可以借助 Span<T> 对非托管内存块进行封装。

例如说一个典型的 C 结构会这样暴露一段内存:

使用 Span<T> 封装后:

迭代器——IEnumerable<T>

迭代器是非常常见的一种范式,但在非托管代码中的实现不尽相同。既然要封装成 .NET 库,就要让迭代器的使用体验更接近原生的 .NET 代码。

例如 libvpx 中,要获得编码后的帧数据需要多姿迭代,其 C 接口需要这样使用迭代器(代码已简化):

而 .NET 中的标准迭代器是 IEnumberable<T>,也就是 C# 中的 foreach 语句所使用的接口。因此这个 libvpx 的例子理想情况下在 C# 中的调用应该大致上是这样的:

为了达到这个效果, IEnumerable<VpxPacket> 的实现可以写作:

当然,如果为了追求零分配,也可以手写 struct Enumerable 与 struct Enumerator,只不过会长得多:

异步回调——Task<T>

在一些涉及到 IO 的非托管库中可能会提供基于回调的异步调用的接口,例如:

这种情况下可以用 TaskCompletionSource<T> 来将回调封装成异步调用( async/ await):

此外,如果该非托管库还支持 Cancellation Token,那一定要做好对接。

由于 OptimeGBA.io 项目中并未涉及到非托管回调,我手头并没有足够的案例,也因此暂无法展开更多细节。有机会的话再开坑补充。

va_list——建议手动添加签名

C 标准是支持可变参数的函数签名的。尽管 P/Invoke 也可以通过 __arglist 兼容此类函数签名,但其适用范围非常受限(例如很难多级传递),文档也非常模糊。个人建议不如根据实际调用情况多写几个函数签名。

结语

.NET 通过 P/Invoke 对非托管库的调用由于 ClangSharp 的出现在工程上让本就颇为强大的 P/Invoke 更具可行性与可维护性。但是要让 .NET 与非托管资源之间的互操作更贴近原生体验还是需要话心思作二次封装。在此也再次欢迎读者阅读 OptimeGBA.io 项目中的 libvpx(手写绑定)与 libopenh264(ClangSharp 生成绑定)交互代码作为参考案例。

为 ES2018 移植的 LINQ 方法

为 ES2018 移植的 LINQ 方法

源码:https://github.com/Martin1994/es2018-linq

NPM:https://www.npmjs.com/package/es2018-linq

前言

自我在短暂的金融业生涯中短暂地接触过 C# 之后,对 C# / .NET 的喜爱便一发不可收拾,即便从此之后的工作中再没机会使用 .NET 却依然保持着对其的关注,而这份关注与喜爱这也一直延续到了 Andre 老爷子如今的工作重心——TypeScript。

最近在工作中大量使用了 TypeScript,但却苦于没有合适的函数式编程工具箱。underscore/lodash 对异步方法的支持有限且不支持延迟执行;RxJS 又感觉太过重量级、强制异步,而 API 又自成一派。有 C# 背景的我自然是以 LINQ 对标这些库,所以我想要不干脆自己移植一份 LINQ 好了。

LINQ

有些朋友可能对 C# 或是 LINQ 不太了解,在这里做一下简单的介绍。

LINQ 最初是设计成在 C# 代码中可以用类似 SQL 的方式操作一个可迭代对象(Enumerable),可以是普通的本地数据结构,甚至也可以是封装好的数据库操作。例如这个官方提供的样例

然而据我的理解,真正在代码里用 LINQ 语句的人并不多……大多数情况 LINQ 是直接通过扩展方法调用的:

继续阅读 »

树莓派升值计划——流畅播放 30 帧 1080p 网页视频

TL;DR

在树莓派 4B (实测 4G 内存,目测 2G 够用) 上流畅播放网页视频需要使用 OpenGL 驱动,并将 PulseAudio 调整至使用中断而不是时钟。可选的性能提升有切换至 64 位内核以及超频,但这些只能轻微提升。

可以做到的效果是 YouTube、bilibili、爱奇艺播放 30 帧 1080p 的视频的时候视频没有大卡顿(主要是偶尔有撕裂),音频完全没有卡顿。

背景

树莓派是一款便宜的卡片尺寸的小电脑。但它体型虽小五脏俱全,可以运行完整的 Linux 系统。最新的顶配版本树莓派 4B 4G 内存版仅需 55 美元,实乃便宜好用的码农玩具。考虑到它的价格,只要能实现任意一个「实用」功能就称得上是回本,而这次我想要让它「物超所值」——接在电视上播放 1080p 在线视频。

小道消息:据说树莓派是亏本卖的

树莓派一出现其实就打着可以充当视频播放器的招牌,但实际上它的实现与日常使用差距很大——早先树莓派只支持 OpenGL ES,并且视频硬解必须使用它的闭源驱动,这导致只有少数做了适配的软件可以充分利用树莓派独特的硬件资源,例如 Kodi 和 SteamLink(强烈安利,可香了,当年沉迷在客厅下自走棋)。说它硬件资源独特是因为它有着孱弱的 CPU 和难以利用的 GPU,以及非常局限的内存(1G)。而这一切随着树莓派 4 的到来不再是问题——CPU 增强,OpenGL 驱动成熟(顺带也解决了 64 位内核的驱动问题),以及 4G 内存版本的出现。

这让我重新捡起了曾经用树莓派代替上网本(即升值计划)的尝试。我的目标只有一个——能看 YouTube 视频。

继续阅读 »

在 .NET Core 3.0 中实现 JIT 编译的 JSON 序列化,及一些心得与随想

源码:https://github.com/Martin1994/JsonJitSerializer

NuGet:https://www.nuget.org/packages/MartinCl2.Text.Json.Serialization/

简介:Just-in-time 编译的 JSON 序列化

.NET Core 3.0 即将正式发布,其中一项令人振奋的功能是 corefx 集成了一个 JSON 库用来替代 JSON.NET,目前我按照 namespace 称这套库为 System.Text.Json。

这一套 JSON 库吸取了一部分 JSON.NET 的教训,将 API 的功能尽可能分离。例如它除了提供了 Object 与 String/Stream 之间的序列化与反序列化的高层 API 之外,还提供了逐 token 读写的底层 API。这为第三方开发者实现自己的 JSON 库提供了极大的方便。

了解到这一点后我意识到可以用这套底层 API(具体来说是 Utf8JsonWriter)来实现一个 just-in-time 编译(本质上其实是 IL generation)的 JSON 序列化库。

为何 JSON 序列化可以从 JIT 中受益呢?

System.Text.Json 实现 JSON 序列化的步骤是:

  1. 利用反射读出需要序列化的 class 的结构;
  2. 缓存每个需要序列化的 property,包括其名字(用 UTF-8 存储)、getter method 以及对应的 converter;
  3. 每次需要序列化的时候逐条读取这个结构化的缓存并利用 Utf8JsonWriter 序列化为 JSON stream。

可以注意到步骤 2 到 3 其实有点类似于解释执行的脚本语言。既然是解释执行,那自然可以有其对应的 JIT 优化,将解释的内容直接编译成可执行的代码。这样可以省去一些存取的开销和动态类型检查的开销。具体可以减小多少开销可以参照 benchmark 的结果:

Method Mean StdDev Median Min Max Gen 0/1k Op Gen 1/1k Op Allocated Memory/Op
System.Text.Json_Async 592.6 ns 1.3711 ns 592.6 ns 590.9 ns 594.8 ns 0.0471 304 B
MartinCl2.Text.Json_Async 346.0 ns 1.6620 ns 345.4 ns 344.4 ns 349.2 ns 0.0239 152 B

继续阅读 »

正确配置 vc4-fkms-v3d 驱动,避免使用 llvmpipe

vc4-fkms-v3d 是树莓派的开源 GPU 驱动,支持 OpenGL 2.1。正确配置的情况下 mesa 应该使用 V3D 驱动而不是 llvmpipe,后者使用的是 CPU。树莓派本来就贫弱的 CPU 并不经得起桌面渲染的折腾。此外 Chrome 也应能打开大多数硬件加速。

首先需要切换到开源驱动。前往 raspi-config -> Advanced Options -> GL Driver -> GL (Fake KMS)。树莓派 4 是默认使用这个开源驱动的。

继续阅读 »

将树莓派用作 SD 读卡器

需求

  • 树莓派
  • SD 卡 2 张,其中一张可引导系统
  • USB 存储(可选)

不需要

  • SD 读卡器
  • 显示器
  • 键盘

最近买了第四代树莓派,但等到 SD 卡寄到了我才意识到我的读卡器忘记带在身边了。环顾四周,唯一有 SD 卡槽的居然只有那只老的树莓派 3B+。理论上我可以直接用 U 盘引导系统,但我不知为何一直无法成功。我更无法冒险将唯一可以引导系统的 SD 卡改为引导到 U 盘,因为万一失败了我在搞到读卡器之前都再也进不去系统了。

于是我就想到了一个骚操作:先用一张 SD 卡引导系统,ssh 进去,然后利用 pivot_root 将 root 转移到 SD 卡以外的地方(U 盘或者内存盘),这样我就能把引导系统的 SD 卡拔下来换新的上去了。全程都可以在 ssh 上完成。

继续阅读 »

在 MSYS2 中安装 Git for Windows 并自定义 PATH 中的 toolchain

背景

Git Bash 在 WSL 出现之前一直是 Windows 开发必不可少的工具。哪怕不使用 unix toolchain,git 也是免不了要用的。在 WSL 出现后它依然没有退出历史舞台——至少 VS Code 目前还需要 Git for Windows 来整合 git 功能。此外,Git bash 是基于 MSYS2 开发的,而 MSYS2 在一些从 *nix 移植到 Windows 的项目上不可或缺。

然而,Git for Windows 中的 MSYS2 环境是刻意缩减过的。最重要的是他没有 pacman 包管理系统。如果不想装两份 MSYS2(一份完整的,一份 Git for Windows),那么 Git for Windows 官方提供了两种方案:使用 Git for Windows SDK,或在已有的 MSYS2 中安装 Git for Windows SDK

注:Git for Windows 修改过 MSYS2 的运行环境。未经修改的运行环境不能很好的在 Windows 下互操作,例如 Powershell 中连 git status 都会无法执行。因此在 MSYS2 中安装 Git for Windows 会覆盖原版的 MSYS2 运行环境。

摆脱 SDK

Git for Windows SDK 是一套为了开发 Git for Windows 而存在的环境。把这套 SDK 当 MSYS2 使用会有诸多不便。我只是想把完整的 MSYS 和 Git for Windows 合二为一而已,但在已有的 MSYS2 中安装 Git for Windows SDK 教程中却把 SDK 一同装上了。不装 SDK 其实很简单,不安装 git-extra 包即可。也就是最后一步的命令改为: pacboy sync git:x git-doc-html:x git-doc-man:x curl:x

继续阅读 »

2018 Camry Hybrid 的一些体验

说起来 2018 款的混动凯美瑞我已经开了半年一万多公里了,也算可以发表一些车主感言了。我开过的车有限,这是我的第一辆车,我更称不上(也不想做)车评人,这只是一点车主感想,酌情阅读。

车型:2018 Camry Hybrid SE (加拿大款,相当于国内不存在的风尚版 + 混动)

动力系统:2.5 L 横置前驱直列四缸 + 双电机

变速系统:ECVT


TL;DR

这一代的凯美瑞外观变得可以接受了。混动系统带来的油耗优势只是顺带的(也就抵抵差价),舒适性上的提升才是需要重点关注的地方。总体而言混动款就是一辆整体体验更好的无极变速燃油车,不用在意电机的存在,买菜通勤的王者。然而这仍然是一辆有着特殊技术的燃油车,而不是电动车,不要做不合理的期待。

多媒体中控可以忽略不计,北美款连中文都无法显示,更别提 Apple CarPlay 这一类体验更优秀的投屏系统了(据说新款有了?)。


一些 FAQ – 混动部分

混动系统是什么原理?

对丰田这套混动系统(THS-II)最好的理解其实是将这两颗电机看作是变速箱的一部分,而不是独立的动力源。(自己会动的变速箱你见过吗……)这套系统没有真正意义上的变速箱,因而也没有传统变速箱的传动损耗。它的无极变速是由两颗电机和行星齿轮完成的,所以真正的变速传动损耗来自于这一部分。因为这套系统能做到无极变速,但又不采用钢带式无极变速器,所以一般数据上写的变速系统都是「ECVT」,以区别于更常见的钢带式 CVT。

大多数时候也并不存在所谓的油电切换,因为这套系统的工况并不是非油即电或者两边一起马力全开,而是一个线性的分配:发动机与电机的输出比例(电机的输出可以是负的也就是充电)占多少可以自由地分配。在中控屏上看到的油电切换,要么是从纯电行驶到发动机启动,要么是正好这一瞬间电机的输出从正到负或者从负到正。前者有时会有介入感,后者不会。

继续阅读 »

使用 XLaunch 一键启动 WSL 中的 Gnome 3

升级到 Windows 1803 后可以跑更多的桌面应用了。虽说 WSL 完全不是为此设计的,但我就是乐此不疲地看着热闹,一次次地试探 WSL 的最大潜力(误,只是好玩而已)。虽然还是存在一些问题,Gnome 3 也不意外地可以运行。这篇文章将讲述如何使用 XLaunch 一键启动 WSL 中的 Gnome 3。这篇文章便是在 WSL 上的 Gnome 中完成并发布的。

由于我未能可复现地安装 Gnome 3,暂时就不详细讲怎么安装了。首先需要升级 Ubuntu 至 18.04 LTS。需要安装的包有 ubuntu-desktop 和 mesa-utils,前者包含 Gnome 3 后者包含软件渲染器(因为 WSL 不支持直接硬件访问)。


手动启动

安装完成后可以先尝试手动启动 Gnome。方法是先启动 dbus 服务:(每次启动 Gnome 前都建议重启 dbus 服务,以防 dbus 挂了)

然后使用 XLaunch 打开一个 One large window 的 X Server 并前台启动 Gnome:(或其他 Windows 上的 X Server 实现,并请根据实际 X Server 的端口修改 DISPLAY 的值)

不像之前版本的 WSL,现在使用 dbus 不需要任何的修改,因为 Windows 1803 的 Win32 及 WSL 子系统已经全部支持 Unix Domain Socket 了。对于 Gnome 3,则需要指定其以 x11 的方式启动。效果大概如图:

继续阅读 »

解决 MacBook 双系统无法升级 High Sierra 的问题

我的 MacBook Air 跑的是 Windows 10 + macOS 双系统,但在升级 High Sierra 的时候出了问题. 症状是无论是原系统升级还是抹掉 macOS 全新安装,都会在安装到一半的时候提示 An error occurred while verifying firmware. 我本以为是 APFS 的问题,便尝试过先格式化成 APFS 再安装,以及打上不转换 APFS 的 flag 安装,但依然不行. 更可怕的是,第二次尝试更新的时候,更新失败后无法使用原系统了.

前几天终于有时间去了一趟苹果店,Genius Bar 的小哥分析说可能是因为 EFI 分区出于某种原因不能被 macOS 的安装程序写入,而 mac 的固件是存在 EFI 分区里的,因为 APFS 的原因需要新版的固件才能引导,所以升级 High Sierra 的时候就失败了. 所以最后我让他把整个硬盘抹掉了重装,果然就没问题了.

那现在的问题就在于,我已经全盘备份了整个硬盘,如何把最新的 firmware 导入到原来的系统. 我先备份了整盘抹掉重装 High Sierra 之后的 EFI 分区,然后把之前的全盘备份恢复到了硬盘上,接着把新的 EFI 分区覆盖原来的 EFI 分区,最后把 Windows 在 EFI 分区里的引导文件复制回来.

继续阅读 »