MoreRSS

site iconLuyu Huang修改

vscode-rss开发者
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Luyu Huang的 RSS 预览

新一代排版系统 Typst 介绍

2025-07-27 00:00:00

一直以来文字排版都是一项复杂的工作。计算机出现不久后,人们就尝试用计算机取代铅字处理排版工作。现在计算机上的排版工具有很多。Microsoft Office Word 可能是使用最广泛的排版工具。它容易上手,功能丰富,能够满足绝大多数办公场景。缺点是文件格式私有,价格昂贵;面对一些复杂排版需求(如公式排版)力不从心。随着 web 技术的发展,HTML + CSS 也可以作为排版工具;Markdown 这种可以编译成 HTML 的简易标记语言广泛应用于网络文字排版(本站的文章都是用 Markdown 写的)。但是 web 技术主要服务于网页设计,缺失换行、分页算法,也难以应对复杂排版。由著名计算机科学家、图灵奖获得者 Donald E. Knuth 发明的 及其衍生品 因其强大的功能、美观的排版效果、优秀的换行分页算法、出色的公式排版能力、灵活的可定制性,一直以来是排版系统的黄金标准。但是由于年代久远,上手难度高、历史包袱众多。那么有没有什么功能强大、容易上手、开源免费的排版系统推荐一下呢?有的兄弟,有的。今天我要推荐的 Typst 就是这样一款优秀的排版工具。

Overview

来自柏林工业大学的计算机研究生 Martin Haug 和 Laurenz Mädje 不满足于 的繁琐和臃肿,决定开发一款新的排版工具,这便是 Typst。Typst 功能强大、效果出色,使用却非常简单,容易上手。Typst 是文本文件格式,后缀通常是 .typ

= 这是标题

== 这是二级标题

为了展示Typst上手有多么简单,我们直接展示一段Typst代码。Typst的标记语法类似于Markdown:这是行内代码 `inline code`,这是*加粗*,这是斜体_italic_,这是公式 $e^(pi i) + 1 = 0$。

用空行表示分段。这是无序列表:

- 无序列表
- 用横杠 `-` 标记无序列表
- 多层列表
- 缩进表示多层列表

这是有序列表

+ 有序列表
+ 用加号 `+` 标记有序列表
- 同样支持多层

```c
int main() {
printf("三个反引号表示代码块,支持代码高亮\n");
return 0;
}
```

Typst还是一门强大的编程语言。可以调用函数实现各种样式:#text(size: 15pt, fill: red)[调用`text`函数插入自定义样式的文本。]还有#highlight(fill: yellow)[`highlight`高亮]、#underline(stroke: 1.5pt + red)[`underline`下划线]、#strike[`strike`删除线]等。实际上Typst的标记语法大多数都是函数的简便写法。例如#strong[`strong`加粗]、斜体#emph[italic]

#heading(depth: 3)[这是三级标题]

等价于 `=== 这是三级标题`。通过自定义函数,可以实现复杂的自定义效果。

#let myStyle(content) = { // 定义函数
let styled = text(size: 1.3em, fill: yellow, stroke: 0.3pt + red, font: "KaiTi", content)
return underline(stroke: 1.2pt + blue, styled)
}

这就是#myStyle[调用自定义函数的效果]。

安装

Typst 的安装非常简单。如果你用 vscode,那么最简单的方法就是安装 Tinymist 插件。它是一个非常棒的 Typst 写作环境,能实现实时预览。

也可以在官网下载下载软件本体。Typst 只有一个可执行文件,执行 typst compile 即可将 Typst 源文件编译成 pdf。

$ typst compile test.typ

语法

Typst 有三种模式:标记模式数学模式代码模式。默认为标记模式,使用类似 Markdown 的语法写作文本。数学模式用于编排数学公式;代码模式则用于实现各种可编程功能。这三种模式之间可以互相切换:

  • 使用 # 号切换到代码模式。# 后紧跟代码,直到整个语句结束都是代码模式。如果有歧义,可使用分号 ; 标记语句结束。
  • 使用 [...] 切换到标记模式。例如前面看到的 #strong[`strong`加粗],方括号内便可使用标记语法;这个语句将标记文本传入 strong 函数获得加粗效果。
  • 使用 $...$ 切换到数学模式。

标记模式

如上面看到的,标记模式的语法与 Markdown 相似。基本上每个标记语法有对应的函数,后面我们介绍函数的用法。

语法 含义 元素函数
= 标题 等号的数量表示标题的层级 heading
*加粗* 加粗字体 strong
_强调_ 斜体强调。中文字体一般没有斜体,所以一般不生效。 emph
- 无序列表 无序列表 list
+ 有序列表 有序列表 enum
空行 分段 parbreak
`code` 代码(是否为行内代码取决于是否分行写)。使用三个反引号可支持高亮:```c return 0;``` raw
$y=k x + b$ 数学公式(是否为行内公式取决于是否分行写) 数学模式不是函数

更多语法见官方文档

数学模式

Typst 的数学语法不同于 LaTeX,但比它简单。Typst 中单个字母表示它本身;但多个字母表示特殊值或函数,类似于 LaTeX 省略反斜杠。如果要表示多个字母本身,就需要加双引号。

与 LaTeX 用花括号 {} 不同,Typst 中函数参数放在小括号里面,不同的参数用逗号 , 分隔。上下标的用法与 LaTeX 一致,^ 表示上标,_ 表示下标。此外 Typst 的数学公式有很多简便用法。例如 可以写作 != 可以写作 <=,分数 \frac{a}{b} 可以用斜杠 / 等。

详细用法见 https://typst.app/docs/reference/math/

代码模式

Typst 是完善的编程语言,有很多通用编程语言的特性。

#let factorial(x) = { // let 定义函数
let i = 1; // let 定义变量
let ans = 1;
while i <= x { // while 循环
ans *= i;
i += 1;
}
return ans; // 返回结果
}

#let a = 10; // 定义变量
#a;的阶乘等于#factorial(a) // 调用函数

下面展示了一些常用语法:

语法 含义
let a = 1 定义变量
let f(x, y) = { return x * y; } 定义函数
let f(x, y) = x + y 定义函数。如果没有 return 语句,函数的返回值等于函数体所有表达式的拼接
let f(x: 0, y: 0) = x + y 命名参数 (named argument)的函数定义。命名参数自带默认值,调用时是可选的
(x, y) => x + y 匿名函数表达式
42, 0xff 整数
3.14, 1e10 浮点数
"hello" 字符串
10pt, 1.5em 长度
90deg, 1rad 角度
50% 比例
1fr 分数
(1, 2, 3) 数组
(a: 1, b: "ok") 字典
a = 1 赋值
-a, a + b 一元运算符和二元运算符
f(a, b) 调用函数
enum([a], [b]) 调用 enum 函数,传入两个类型为标记内容 (content) 的参数([...] 切换到标记模式)
enum(start: 2, [a], [b]) 带命名参数的函数调用
enum(start: 2)[a][b] 语法糖,与上面的写法等价。标记内容参数可以放在括号外面
x.y 成员访问
x.flatten() 方法调用

完整的语法见 https://typst.app/docs/reference/scripting/https://typst.app/docs/reference/foundations/

Typst 提供了很多用于实现各种样式的函数,例如文字样式、段落、图表、表格、列表等等,称为元素函数 (element function)。Typst 的标记语法基本上都是元素函数的简便写法。例如用于创建各种样式的文本的 text 函数,它有很多参数。下面列举了它的一小部分参数:

text(
font: str | array,
weight: int | str,
size: length,
fill: color,
stroke: none | length | color,
tracking: length,
spacing: relative,
lang: str,
str,
content,
) -> content
  • font 字体
  • weight 字重。从细到粗分别是 "thin", "extralight", "light", "regular", "medium", "semibold", "bold", "extrabold", "black"
  • size 字体大小。如 10pt, 1.5em
  • fill 填充色。如 red, rgb("#eb27ba")
  • stroke 描边,可以是长度 + 颜色。如 0.3pt + red 表示 0.3 像素红色描边
  • tracking 字母间距
  • spacing 单词间距
  • lang 语言
  • str 字符串文本
  • content 也可传入标记内容

利用 text 函数我们就能生成各种各样的文本了。

完整的元素函数可参考 https://typst.app/docs/reference/model/。下面列出了一些例子:

定制样式

如果每次使用自定义样式时都要显式调用元素函数,未免有些太麻烦了。Typst 提供了两种语法用于定制样式:set 规则和 show 规则。

Set 规则

Set 规则很容易理解:在文档中显示任何元素都实际上是调用元素函数的结果,那么 set 语句就是用于设置元素函数某些参数的默认值。例如 #set text(size: 20pt),那么所有文字的大小都会变成 20 点。

Set 规则作用范围是当前 block。所谓 block 就是内容块 [...] 或代码块 {...}。例如

#set text(fill: red) // 全局生效
#[
#set text(size: 20pt) // 当前 block 生效
红色 20 点文字
]
红色默认大小文字

#let bold(content) = {
set text(weight: "bold") // 当前函数的 block 生效
content
}

#bold[加粗字体]
正常字体

Set 规则非常实用。有些元素函数,例如 par(段落), page(页面), document(文档),我们很少直接调用它们,而是将它们应用 set 规则,设置它们的样式。

Show 规则

Show 规则的一种写法是关键词 show + 选择器 + : + set 语句,表示将所有满足选择器的元素应用指定的 set 规则。最常用的选择器就是元素函数,例如下面的语句表示将所有的标题文字设置为海军蓝:

#show heading: set text(fill: navy)

另一种写法是 show + 选择器 + : + 函数,表示将所有满足选择器的元素传入指定函数。例如下面的语句表示将所有的超链接的样式设置为带下划线的蓝色文字:

#show link: (a) => underline(text(fill: blue, a))

选择器除了是元素函数外,还可以是以下几种:

  • 字符串:show "Text": ... 所有匹配到指定字符串的内容应用指定样式。这在某些场景非常实用。
  • 正则表达式:show regex("\w+"): ... 所有匹配到指定正则表达式的内容应用指定样式。
  • 指定参数的元素函数:show heading.where(level: 1): ... 元素函数支持 .where 方法,返回一个选择器,只选择指定参数的元素。这个例子将所有 1 级标题应用指定样式
  • 所有内容 show: ... 所有内容应用指定样式。如果 : 后面是函数,就会把整篇文档传入函数,这在模板的使用中非常常用。

详细用法见 https://typst.app/docs/reference/styling/

实战:使用 Typst 制作简历

Typst 很适合制作技术简历。在 Typst Universe 中,有很多简历模板。我们从中挑选一个,例如 basic resume。我们执行

typst init @preview/basic-resume:0.2.8

初始化一个 typst 工程。使用 vscode 打开 basic-resume/main.typ 便可以开始编辑了。

首先使用 import 语句引入 basic-resume 模块的内容。这里的 * 表示引入模块中的所有符号。

#import "@preview/basic-resume:0.2.8": *

接着用 let 定义一些可能会复用的变量。接下来是最关键的 show 语句:

#show: resume.with(
author: name,
location: location,
email: email,
...
)

resume 是 basic-resume 模块中定义的一个函数。我们可以看到它的定义:

#let resume(
author: "",
location: "",
email: "",
...
body,
) = {
...
}

它本质上是一个有很多命名参数和一个普通参数 body 的函数,将内容 body 转换成一篇简历并返回。而 with 实际上是函数对象的一个方法,它返回一个预应用了给定参数的新函数。例如函数 let f(a, b) = a + bf.with(1) 就等价于 (b) => f(1, b)。那么这里 resume.with(...) 就得到一个各种命名参数设置好了的新函数。这里的 show 语句会将整个文档作为参数传入这个新函数,我们就能得到一篇简历了。

接下来就是简历正文,也就是被传入 resume 函数的内容。其中的各种语法我们已经基本上已经介绍过了,这里无非是调用模板中定义的函数插入各种内容。例如 #edu(...) 插入教育经历、#work(...) 插入工作经历、#project(...) 插入项目经历等。

编辑完成后,点击 “Export PDF”,或者手动执行 typst compile main.typ 就可以得到 PDF 格式的简历了。

最后

过去我总觉得各种排版系统都不是特别好:Word 排版效果一般,且不便版本控制;Markdown 排版能力弱;LaTeX 古老且使用复杂,编译缓慢。直到发现了 Typst,使用过后立刻就喜欢上了。本文只是简单推荐,而不是详细的教程。如果要深入学习,可参考 Typst 官方文档 https://typst.app/docs,或者直接咨询 AI。

Clang 编译安装指南

2025-03-30 00:00:00

Clang 是一个基于 LLVM 的 C/C++ 编译器,与 GCC 相比有一些优势

  • 编译速度比 GCC 快
  • 内存占用更小
  • 编译报错信息更友好
  • 工具链丰富:ASan, clangd, clang-tidy, clang-doc 等

如果 Linux 发行版比较新,可以直接用包管理器安装;但如果发行版比较老旧,就只能编译安装了。本文介绍一些编译安装 clang 的方法。

直接编译安装

Clang 是 LLVM 项目的一部分。LLVM 是一个编译器基础设施,它定义一种中间代码 (IR),并且能够将这种中间代码编译成各个平台 (x86, ARM, …) 的机器码。这在 LLVM 中称为编译器后端。而 clang 则是 C/C++ 的编译器前端,负责将 C/C++ 代码编译成 LLVM 中间代码。因此要编译安装 clang,我们就要编译 LLVM。

我们进入 LLVM 官网的下载页面 https://releases.llvm.org/ 下载 LLVM 源码。LLVM 10.0.0 之后提供整合包 llvm-projectLLVM 全家桶 下载,包含 clang 在内的各种 LLVM 组件。以最新的 20.1.0 为例,我们直接下载 LLVM 20.1.0 并解压

curl -LO https://github.com/llvm/llvm-project/releases/download/llvmorg-20.1.0/llvm-project-20.1.0.src.tar.xz
unxz llvm-project-20.1.0.src.tar.xz
tar -xf llvm-project-20.1.0.src.tar

LLVM 使用 CMake 构建,要求版本至少为 3.20.0。进入 llvm-project 目录,接着创建 build 目录,然后执行 cmake

cd llvm-project-20.1.0.src
mkdir build && cd build
cmake ../llvm -DCMAKE_BUILD_TYPE=Release \
-DLLVM_ENABLE_PROJECTS='clang' \
-DLLVM_ENABLE_RUNTIMES='compiler-rt;libcxx;libcxxabi;libunwind'

CMake 这一步可以传入各种参数,这里介绍一些常用的参数

  • CMAKE_BUILD_TYPE 构建类型,可以为 Debug, Release, RelWithDebInfo, 或 MinSizeRel。默认为 Debug,一般设置为 Release
  • DLLVM_ENABLE_PROJECTS 启用的组件。llvm-project 中有很多组件,除了 clang,常用的还有
    • clang-tools-extra 额外工具集。包含 clangd, clang-tidy, clang-doc, clang-include-fixer 等。
    • lldb 调试器。Clang 编译的程序用 gdb 调试可能会遇到各种问题,最好用 lldb 调试。
    • lld 链接器,可替代 ld。
    此外还有 bolt, polly, libclc 等组件,具体可参见 LLVM 文档。多个组件之间用分号 ; 分隔。
  • DLLVM_ENABLE_RUNTIMES 启用的运行时组件。常用的组件有
    • compiler-rt 编译器运行时。如果要用 Sanitizer 系列工具(如 ASan),则必选。
    • libcxxlibcxxabi 是 LLVM 的 C++ 标准库实现。Clang 默认将程序链接到系统的 libstdc++,但如果要链接到 LLVM 的标准库 libc++,这两个必选
    • libunwind 实现堆栈展开的库。如果要链接到 libc++,则必选。
    此外还有 libc, llvm-libgcc, offload 等组件,具体可参见 LLVM 文档。多个组件之间用分号 ; 分隔。
  • CMAKE_INSTALL_PREFIX 安装路径前缀,默认为 /usr/local
  • CMAKE_C_COMPILERCMAKE_CXX_COMPILER 分别指定 C 和 C++ 的编译器,默认为 gccg++。后面我们会用到这两个参数。
  • LLVM_ENABLE_LIBCXX 是否链接到 libc++。默认链接到 libstdc++。后面我们会用到这个参数。

如果 LLVM 的各个依赖项都没有问题、这一步成功后,便可执行 make 开始构建

make -j8 # 根据机器情况调整线程数 

如果一切顺利,执行 sudo make install 即可完成安装。安装前可以执行 make check-clang 执行 clang 的测试用例,确认没有问题。

老旧发行版编译

要成功构建 LLVM 20.1.0,GCC 版本至少为 7.4。然而在一些老旧发行版(如 CentOS 7)中,GCC 版本并不能满足要求。为此我们需要先用系统的老版编译器编译一个新版编译器,再用新版编译器编译 LLVM。

# 下载 gcc-9.1.0
curl -LO https://ftp.gnu.org/gnu/gcc/gcc-9.1.0/gcc-9.1.0.tar.xz
unxz gcc-9.1.0.tar.xz
tar -xf gcc-9.1.0.tar

cd gcc-9.1.0

# 安装依赖
./contrib/download_prerequisites
./configure --prefix=${HOME}/toolchains # 安装到 ~/toolchains
make -j8
make install

这里我们编译的 gcc-9.1.0 是用于构建 LLVM 的“临时”编译器,我们不把它安装到系统目录 (/usr/local/),而是安装到 ~/toolchains。GCC 是系统编译器,不可随意升级,否则可能导致系统其它软件出现兼容性问题。

接着我们用刚刚编译的 gcc-9.1.0 构建 LLVM。

cd llvm-project-20.1.0.src
mkdir build && cd build
cmake ../llvm -DCMAKE_C_COMPILER=${HOME}/toolchains/bin/gcc \
-DCMAKE_CXX_COMPILER=${HOME}/toolchains/bin/g++ \
-DCMAKE_BUILD_TYPE=Release \
-DLLVM_ENABLE_PROJECTS='clang' \
-DLLVM_ENABLE_RUNTIMES='compiler-rt;libcxx;libcxxabi;libunwind'

LD_LIBRARY_PATH=${HOME}/toolchains/lib:${HOME}/toolchains/lib64 make -j8

这里我们用 -DCMAKE_C_COMPILER-DCMAKE_CXX_COMPILER 指定 C/C++ 编译器的完整路径,也就是 gcc-9.1.0 的安装路径 ~/toolchains/ 下的 bin/gccbin/g++。LLVM 构建过程中会执行编译出来的工具,这些工具都依赖于 gcc-9.1.0 的 C++ 运行库。因此我们要用环境变量 LD_LIBRARY_PATH 指定动态库路径,确保它们能正常运行。

因为这样构建的 LLVM 工具链都依赖于 gcc-9.1.0 的运行库,我们要设置好 LD_LIBRARY_PATH 才能正常运行它们。

$ bin/clang --version # 直接运行通常会出现 libstdc++ 不兼容的报错
bin/clang: /lib64/libstdc++.so.6: version `GLIBCXX_3.4.26' not found (required by bin/clang)

$ LD_LIBRARY_PATH=${HOME}/toolchains/lib:${HOME}/toolchains/lib64 bin/clang --version # 需要指定 gcc-9.1.0 的动态库路径
clang version 20.1.0
Target: x86_64-unknown-linux-gnu
Thread model: posix
InstalledDir: /home/luyuhuang/llvm-project-20.1.0.src/build/bin

$ LD_LIBRARY_PATH=${HOME}/toolchains/lib:${HOME}/toolchains/lib64 ldd bin/clang
linux-vdso.so.1 => (0x00007fffe95c9000)
libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f470cbbb000)
librt.so.1 => /lib64/librt.so.1 (0x00007f470c9b3000)
libdl.so.2 => /lib64/libdl.so.2 (0x00007f470c7af000)
libz.so.1 => /lib64/libz.so.1 (0x00007f470c599000)
libstdc++.so.6 => /home/luyuhuang/toolchains/lib64/libstdc++.so.6 (0x00007f470c1c0000)
libm.so.6 => /lib64/libm.so.6 (0x00007f470bebe000)
libgcc_s.so.1 => /home/luyuhuang/toolchains/lib64/libgcc_s.so.1 (0x00007f470bca6000)
libc.so.6 => /lib64/libc.so.6 (0x00007f470b8e2000)
/lib64/ld-linux-x86-64.so.2 (0x00007f470cdd7000)

Bootstrap

前面使用 gcc-9.1.0 编译的 LLVM 工具链虽然可以运行,但是却依赖于 gcc-9.1.0 的运行库。由于系统的 C++ 运行库不能随意升级,我们不便将 gcc-9.1.0 的运行库安装到系统中。这里比较合适的做法是让 clang 做一次 bootstrap(自举),用 gcc-9.1.0 编译的 clang 构建 LLVM,并链接到 LLVM 的 C++ 运行库(也就是 libc++)。

cd llvm-project-20.1.0.src
mkdir build1 && cd build1 # 创建一个新的构建目录
cmake ../llvm -DCMAKE_C_COMPILER= $(realpath ../build)/bin/clang \ # 使用 ../build 目录下,用 gcc-9.1.0 编译的 clang 构建
-DCMAKE_CXX_COMPILER= $(realpath ../build)/bin/clang++ \
-DLLVM_ENABLE_LIBCXX=ON \ # 使用 LLVM 的 libc++
-DCMAKE_BUILD_TYPE=Release \
-DLLVM_ENABLE_PROJECTS='clang' \
-DLLVM_ENABLE_RUNTIMES='compiler-rt;libcxx;libcxxabi;libunwind'

LD_LIBRARY_PATH=${HOME}/toolchains/lib:${HOME}/toolchains/lib64:$(realpath ../build)/lib/x86_64-unknown-linux-gnu make -j8

这里我们则是将编译器路径设置成前面用 gcc-9.1.0 编译的 clang 的路径。同时设置 -DLLVM_ENABLE_LIBCXX=ON 链接到 LLVM 的 libc++。最后注意环境变量 LD_LIBRARY_PATH 除了需要指定 gcc-9.1.0 的运行库路径之外,还需要指定前面 gcc-9.1.0 编译的 LLVM 的运行库路径。

构建完成后,执行 sudo make install 安装即可。由于 LLVM 20.1.0 的 C++ 运行库位于 lib/x86_64-unknown-linux-gnu/(更老版本的 LLVM 则直接在 lib/ 里),我们通常需要再配置下动态库搜索路径。

echo /usr/local/lib/x86_64-unknown-linux-gnu >> /etc/ld.so.conf
ldconfig

这样安装的 clang 就能正常运行了。

$ clang --version
clang version 20.1.0
Target: x86_64-unknown-linux-gnu
Thread model: posix
InstalledDir: /usr/local/bin

$ ldd /usr/local/bin/clang
linux-vdso.so.1 => (0x00007ffc6d57c000)
libpthread.so.0 => /lib64/libpthread.so.0 (0x00007fca001ae000)
librt.so.1 => /lib64/librt.so.1 (0x00007fc9fffa6000)
libdl.so.2 => /lib64/libdl.so.2 (0x00007fc9ffda2000)
libm.so.6 => /lib64/libm.so.6 (0x00007fc9ffaa0000)
libz.so.1 => /lib64/libz.so.1 (0x00007fc9ff88a000)
libc++.so.1 => /usr/local/lib/x86_64-unknown-linux-gnu/libc++.so.1 (0x00007fc9ff583000)
libc++abi.so.1 => /usr/local/lib/x86_64-unknown-linux-gnu/libc++abi.so.1 (0x00007fc9ff33b000)
libunwind.so.1 => /usr/local/lib/x86_64-unknown-linux-gnu/libunwind.so.1 (0x00007fc9ff12e000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007fc9fef18000)
libc.so.6 => /lib64/libc.so.6 (0x00007fc9feb54000)
/lib64/ld-linux-x86-64.so.2 (0x00007fca0ad52000)
libatomic.so.1 => /lib64/libatomic.so.1 (0x00007fc9fe94c000)

参考资料:

使用 Address Sanitizer 排查内存越界

2025-02-16 00:00:00

在 C++ 开发中,内存越界是很头痛的问题。这类问题往往非常隐蔽,难以排查,且难以复现。为了排查这类问题,我们可以使用 Address Sanitizer 这个工具。

Address Sanitizer (aka ASan) 是 Google 开发的一款内存错误排查工具,帮助开发这定位各种内存错误,目前已经集成在主流工具链中。LLVM 3.1 和 GCC 4.8 以上的版本均支持 ASan。本文介绍 ASan 的使用方法和其基本原理。

如何使用

在支持 ASan 的编译器加上编译参数 -fsanitize=address 即可开启 ASan。例如我们有代码 test.c

#include <stdlib.h>
#include <stdio.h>

int main() {
int *p = malloc(sizeof(int));
p[1] = 42;
return 0;
}

虽然存在内存越界写入,但是使用常规方法编译后,是能够“正常”运行的:

$ gcc -o test test.c
$ ./test
42

加上编译参数 -fsanitize=address 启用 ASan 后,运行便会触发 ASan 报错:

$ gcc -o test test.c -fsanitize=address -g
$ ./test
=================================================================
==65982==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000014 at pc 0x5600b2a24202 bp 0x7ffda82b5c70 sp 0x7ffda82b5c60
WRITE of size 4 at 0x602000000014 thread T0
#0 0x5600b2a24201 in main (/home/luyuhuang/test+0x1201)
#1 0x7f7eee24c082 in __libc_start_main ../csu/libc-start.c:308
#2 0x5600b2a240ed in _start (/home/luyuhuang/test+0x10ed)

0x602000000014 is located 0 bytes to the right of 4-byte region [0x602000000010,0x602000000014)
allocated by thread T0 here:
#0 0x7f7eee527808 in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cc:144
#1 0x5600b2a241be in main (/home/luyuhuang/test+0x11be)
#2 0x7f7eee24c082 in __libc_start_main ../csu/libc-start.c:308
...

ASan 告诉我们程序触发了一个堆越界 (heap-buffer-overflow) 的报错,尝试在地址 0x602000000014 写入 4 字节数据。随后打印出出错位置的堆栈。第 10 行 ASan 还告诉我们越界的内存是在哪个位置被分配的,随后打印出分配位置的堆栈。

常用参数

ASan 的参数有编译参数运行时参数两种。常用的编译参数有:

  • -fsanitize=address 启用 ASan
  • -fno-omit-frame-pointer 获得更好的堆栈信息
  • ASan 专属的参数,GCC 使用 --param FLAG=VAL 传入,LLVM 使用 -mllvm -FLAG=VAL 传入:
    • asan-stack 是否检测栈内存错误,默认启用。GCC 使用 --param asan-stack=0、LLVM 使用 -mllvm -asan-stack=0 关闭栈内存错误检测。
    • asan-global 是否检测全局变量内存错误,默认启用。同理使用 --param asan-global=0-mllvm -asan-global=0 可关闭。

运行时参数通过环境变量 ASAN_OPTIONS 设置,每个参数的格式为 FLAG=VAL,参数之间用冒号 : 分隔。例如:

$ ASAN_OPTIONS=handle_segv=0:disable_coredump=0 ./test

常用的运行时参数有:

  • log_path: ASan 报错默认输出在 stderr。使用这个参数可以指定报错输出的路径。
  • abort_on_error: 报错时默认使用 _exit 结束进程。指定 abort_on_error=1 则使用 abort 结束进程。
  • disable_coredump: Asan 默认会禁用 coredump。指定 disable_coredump=0 启用 coredump。
  • detect_leaks: 是否启用内存泄漏检测,默认启用。ASan 还包含 LSan (Leak Sanitizer) 内存泄露检测模块。
  • handle_*: 信号控制选项。ASan 默认会注册一些信号处理函数,参数置 0 表示让 ASan 不注册相应的信号信号处理器,置 1 则注册信号信号处理器,置 2 则注册信号处理器并禁止用户修改。
    • handle_segv: SIGSEGV
    • handle_sigbus: SIGBUS
    • handle_abort: SIGABRT
    • handle_sigill: SIGILL
    • handle_sigfpe: SIGFPE

因为 ASan 默认会注册 SIGSEGV 的信号处理器,所以当程序发生段错误时,会触发 ASan 的报错而不是直接 coredump。要想让程序像往常一样产生 coredump,可以指定参数 handle_segv=0 不注册信号处理器,和 disable_coredump=0 启用 coredump。

有些函数可能会做一些比较 hack 操作,又想绕过 Asan 的越界检测。这可以通过声明属性 __attribute__((no_sanitize_address)) 实现。例如

__attribute__((no_sanitize_address))
size_t chunk_size(void *p) {
return *((size_t*)p - 4);
}

这样即使 chunk_size 访问越界,ASan 也不会报错。

更多参数可参考官方文档

原理简介

ASan 需要检测的是应用程序是否访问已向操作系统申请、但未分配给应用程序的内存,也就是下图中红色的部分。至于图中白色的部分,也就是未向操作系统申请的内存,是不需要检测的(一旦访问就会触发段错误)。

ASan 会 hook 标准内存分配函数(malloc、free 等),所有未被分配和已释放的区域都会标记为红区。所有内存的访问都会被检查,如果访问了红区的内存,asan 会立刻报错。例如,原本简单的内存访问

*address = ...;  // or: ... = *address;

在启用 ASan 后,会生成类似如下的代码:

if (IsPoisoned(address)) {
ReportError(address, kAccessSize, kIsWrite);
}
*address = ...; // or: ... = *address;

为了标记内存是否为红区,ASan 将每 8 字节内存映射成 1 字节的 shadow 内存,在 shadow 内存中标记这 8 字节内存的使用情况。64 位系统中,地址 p 对应的 shallow 内存的地址为 (p >> 3) + 0x7fff8000

因为 malloc 分配的内存必然是 8 字节对齐的。这样的话只有 9 种情况:

  • 这 8 字节内存都不是红区。此时 shadow 内存值为 0
  • 这 8 字节内存都是红区。此时 shadow 内存值为负数
  • 前 k 字节不在红区,剩下的 8 - k 字节在红区。此时 shadow 内存值为 k

例如 malloc(11) 分配 11 字节内存的情况,如下图所示。第一个 8 字节都不在红区,对应的 shadow 内存值为 0;第二个 8 字节前 3 字节不在红区,后 5 字节在红区,对应的 shadow 内存值为 3。第三个 8 字节都在红区,对应的 shadow 内存值为负数。

这样,整个地址空间被分为 5 个部分。HighMem 对应 HighShadow,LowMem 对应 LowShadow。如果一个地址对应的 shallow 内存在 ShadowGap 区域,则这个地址不可访问。因为 64 位机器的虚拟内存地址空间很大,这样划分后地址仍然很够用。

地址区间 区域
[0x10007fff8000, 0x7fffffffffff] HighMem
[0x02008fff7000, 0x10007fff7fff] HighShadow
[0x00008fff7000, 0x02008fff6fff] ShadowGap
[0x00007fff8000, 0x00008fff6fff] LowShadow
[0x000000000000, 0x00007fff7fff] LowMem

这样,每当程序要访问内存时,ASan 都会做如下检查:

char *pShadow = ((intptr_t)address >> 3) + 0x7fff8000;  // 计算得到 shadow 内存地址
if (*pShadow) { // 如果 shadow 内存不为 0,做进一步检查
int last = ((intptr_t)address & 7) + kAccessSize - 1; // address % 8 + kAccessSize - 1 计算这次访问的最后一个字节
if (last >= *pShadow) { // 如果 last >= shadow 则报错
ReportError(address, kAccessSize, kIsWrite);
}
}
*address = ...; // or: ... = *address;

假设某 8 字节内存后 3 字节在红区,程序要从第 4 字节开始访问两字节,如下图所示。那么有 address % 8 = 4,last = 4 + 2 - 1 = 5 >= shadow,因此这次访问是越界访问,ASan 就会报错。

了解了 ASan 原理之后就能更好地理解 ASan 的报错信息。ASan 报错时会打印出报错位置的 shadow 内存情况:

SUMMARY: AddressSanitizer: heap-buffer-overflow (/home/luyuhuang/test+0x1201) in main
Shadow bytes around the buggy address:
0x0c047fff7fb0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c047fff7fc0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c047fff7fd0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c047fff7fe0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c047fff7ff0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0c047fff8000: fa fa[04]fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c047fff8010: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c047fff8020: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c047fff8030: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c047fff8040: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c047fff8050: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa

程序试图在 0x602000000014 写入 4 字节数据。 0x602000000014 对应的 shadow 内存地址为 (0x602000000014 >> 3) + 0x7fff8000 = 0x0c047fff8002,也就是上面 [04] 的位置。0x602000000014 % 8 = 4,从这 8 字节的第 4 字节开始访问四字节。而这 8 字节后四字节都在红区,因此访问越界。


参考资料: https://github.com/google/sanitizers/wiki/AddressSanitizer

用 C 语言实现协程

2024-06-14 00:00:00

协程与线程(进程)[1]都是模拟多任务(routine)并发执行的软件实现。操作系统线程在一个 CPU 线程中模拟并发执行多个任务,而协程(coroutine)在一个操作系统线程中模拟并发执行多个任务。 因此协程也被称为“用户态线程”、“轻量级线程”。之所以说“模拟并发执行”,是因为任务实际并没有同时运行,而是通过来回切换实现的。

线程切换由操作系统负责,而协程切换通常由程序员直接控制。程序员通过 resume/yield 操作控制协程切换。resume 操作唤醒一个指定协程;yield 操作挂起当前协程,切换回唤醒它的协程。如果你用过 Lua 的协程,就会很熟悉这套流程。不过 Lua 是基于 Lua 虚拟机(LVM)的脚本语言,它只需要 LVM 中执行“虚拟的”上下文切换。本文介绍如何用 C 语言(和一点点汇编)实现一个 native 协程,执行真正的上下文切换。这个实现非常简单,总共不到 200 行代码。我参考了 libco 的实现。本文的完整代码见 toy-coroutine

上下文切换

所谓的上下文,就是一段程序之前做了什么,接下来要做什么,以及做事情过程的中间产物。例如我们有函数 ff 需要知道下一个指令是什么才能接着往下执行,便是“接下来要做什么”。f 函数还需要知道之前是谁调用了它,以便把结果返回给调用者,便是“之前做了什么”。在 f 函数执行过程中,局部变量要存好(不能被写坏),接下来的指令才能正确执行。这便是“过程的中间产物”。

在 x86-64 下,“之前做了什么” 存储在栈里。函数调用会执行 call 指令,把当前函数的下一个指令的地址压入栈顶,然后再跳转到被调用函数。被调用函数返回时执行 ret 指令,从栈顶取出调用者的返回点地址,然后跳转到返回点。因此栈上存有所有前序调用者的返回点地址。

函数的局部变量通常储存在 16 个通用寄存器中,如果寄存器不够用,就存在栈里(只要在函数返回前将它们弹出,让栈顶是返回点地址即可)。函数调用的参数也是局部变量,存在约定的 6 个通用寄存器里。如果不够用,也存在栈里。

至于“接下来要做什么”,其实也在栈里。上下文切换不过是调用一个函数,调用者在调用它之前已经把下一个指令的地址压栈了。当上下文切换函数返回,ret 指令自然会跳转到接下来要执行的指令。所以上下文就是 16 个通用寄存器 + 栈。

所有的协程共享同一个 CPU,也就共享同样的 16 个通用寄存器。如果我们要把 A 协程切换成 B 协程,就要把当前 16 个通用寄存器的值存在 A 协程的数据结构里;然后再从 B 协程的数据结构里取出 B 协程的寄存器的值,写回通用寄存器中。我们还要处理栈。不过栈与寄存器不同,x86-64 规定 %rsp 寄存器(也是通用寄存器之一)存的值便是栈顶的地址。不同的协程不必共享栈,它们可以分配各自的栈,上下文切换时将 %rsp 指向各自的栈顶即可。

实际上我们不必存储全部的 16 个通用寄存器,它们有些是暂存寄存器(Scratch Registers),是允许被写坏的。这些寄存器的值可能在执行一次函数调用后就变了(被被调用函数写坏的)。编译器也不会在暂存寄存器里存储函数调用后还要用的值。参考 libco 的实现,我们存储 13 个寄存器:

enum {
CO_R15 = 0,
CO_R14,
CO_R13,
CO_R12,
CO_R9,
CO_R8,
CO_RBP,
CO_RDI,
CO_RSI,
CO_RDX,
CO_RCX,
CO_RBX,
CO_RSP,
};

struct co_context {
void *regs[13];
};

有些寄存器有特殊的用途。这里我们只需要知道这三个:

  • %rsp: 栈寄存器,指向栈顶。
  • %rdi, %rsi: 第一参数寄存器和第二参数寄存器,调用函数前将第一个参数存在 %rdi 里,第二个存在 %rsi 里(剩下的四个依次是 %rdx, %rcx, %r8, %r9, 不过这里我们用不上),然后执行 call 指令。

接着我们定义一个函数做上下文切换,把当前通用寄存器的值保存在 curr 中,再把 next 中保存的寄存器的值写回各个通用寄存器。

extern void co_ctx_swap(struct co_context *curr, struct co_context *next);

Emm,这个函数没法用 C 语言实现,我们得用到一点点汇编了。其实非常简单,我们只需要用 movq 指令存取寄存器。代码如下:

.globl co_ctx_swap

co_ctx_swap:
movq %rsp, 96(%rdi)
movq %rbx, 88(%rdi)
movq %rcx, 80(%rdi)
movq %rdx, 72(%rdi)
movq %rsi, 64(%rdi)
movq %rdi, 56(%rdi)
movq %rbp, 48(%rdi)
movq %r8, 40(%rdi)
movq %r9, 32(%rdi)
movq %r12, 24(%rdi)
movq %r13, 16(%rdi)
movq %r14, 8(%rdi)
movq %r15, (%rdi)

movq (%rsi), %r15
movq 8(%rsi), %r14
movq 16(%rsi), %r13
movq 24(%rsi), %r12
movq 32(%rsi), %r9
movq 40(%rsi), %r8
movq 48(%rsi), %rbp
movq 56(%rsi), %rdi
movq 72(%rsi), %rdx
movq 80(%rsi), %rcx
movq 88(%rsi), %rbx
movq 96(%rsi), %rsp
movq 64(%rsi), %rsi

ret

不懂汇编没关系(其实我也不是很懂),只需要知道 movq 指令将第一个操作数的值复制到第二个操作数中。% 开头的标识符为寄存器。%rsp 这样不带括号的,表示存取寄存器的值。(%rdi) 这种带括号的,表示去内存里存取地址为 %rdi 的数据。如果括号前面有数字几,就表示这个地址要加几。movq 存取数据的长度为 8 字节,寄存器的长度也是 8 字节。

还记得前面说过,%rdi 是第一个参数,%rsi 是第二个参数吗?所以 %rdi 就是 struct co_context *curr96(%rdi) 就是 curr->regs[12]88(%rdi) 就是 curr->regs[11],……,(%rdi) 就是 curr->regs[0]。上半部分把 13 个通用寄存器的值全部存到了 curr 里。同理 %rsi 就是 struct co_context *next(%rsi) 就是 next->regs[0]8(%rsi) 就是 next->regs[1],依次类推。于是下半部分把 next 中保存的寄存器的值写回寄存器中。最后执行 ret 指令返回。

注意 29 行写入 %rsp 的值就是上次挂起时第 4 行保存的值,这个值我们原封未动,也没有做任何栈操作。因此最后 ret 返回时,栈顶就是 co_ctx_swap 的调用者设置的返回点地址。一个协程调用 co_ctx_swap 将自己挂起,便陷入沉睡。当 co_ctx_swap 返回之时,便是其它协程调用 co_ctx_swap 将它唤醒之时。此时寄存器被还原、栈被还原、也回到了返回点。它便知道自己之前做了什么、接下来要做什么、中间产物是怎样的。

协程的初始化

struct co_context 仅存储协程的上下文。我们还需要维护给协程分配的栈空间、记录入口函数地址等。我们定义 struct coroutine 表示协程对象。

typedef void (*start_coroutine)();

struct coroutine {
struct co_context ctx;
char *stack;
size_t stack_size;
start_coroutine start;
};

struct coroutine *co_new(start_coroutine start, size_t stack_size) {
struct coroutine *co = malloc(sizeof(struct coroutine));
memset(&co->ctx, 0, sizeof(co->ctx));
if (stack_size) {
co->stack = malloc(stack_size);
} else {
co->stack = NULL;
}
co->stack_size = stack_size;
co->start = start;

return co;
}

void co_free(struct coroutine *co) {
free(co->stack);
free(co);
}

co_new 创建一个新协程,接受两个参数:start 协程入口函数指针,和 stack_size 栈大小;这类似于 pthread_createco_new 分配协程的栈空间并设置好各个字段。

要把主线程切换到我们新创建的协程,这里有两个问题。一是主线程并不是一个协程,新协程跟谁交换上下文呢?二是新创建的协程的上下文是空的(19 行),切换过去肯定跑不起来。

第一个问题很简单:创建一个就行。因为主线程已经跑起来了,要切换到新协程,主线程只需要一个“容器”把它的上下文装进去。直接执行 main = co_new(NULL, 0) 创建主协程,调用 co_ctx_swap(&main->ctx, &new->ctx) 便可切换到新协程。此时主线(协)程的上下文保存在 main 中,当新协程反向调用 co_ctx_swap(&new->ctx, &main->ctx),便又切换回主协程了。

为了解决第二个问题,我们需要对新协程初始化。co_ctx_swap 将新协程的上下文复制到 CPU 后,执行 ret 返回栈顶记录的地址。因此我们要将栈顶置为协程入口函数的地址,这样在 co_ctx_swap 返回后便跳转到协程入口函数了。

void co_ctx_make(struct coroutine *co) {
char *sp = co->stack + co->stack_size - sizeof(void*);
sp = (char*)((intptr_t)sp & -16LL);
*(void**)sp = (void*)co->start;
co->ctx.regs[CO_RSP] = sp;
}

因为 x86 的栈是从高地址向低地址增长的,初始栈为空,所以栈顶应该指向 co->stack 的最末尾。又因为 x86 的栈必须 16 字节对齐,所以执行 (intptr_t)sp & -16LL(-16 低 4 位为 0,其它都为 1)得到栈顶地址。然后将栈顶置为 co->start,也就是入口函数的地址。最后我们把保存的 rsp 寄存器的值设置为栈顶地址,这个值会在 co_ctx_swap 被复制到寄存器 %rsp 中。

现在我们的协程已经可以跑起来了。写一个简单的例子试试:

struct coroutine *main_co, *new_co;

void foo() {
printf("here is the new coroutine\n");
co_ctx_swap(&new_co->ctx, &main_co->ctx);
printf("new coroutine resumed\n");
co_ctx_swap(&new_co->ctx, &main_co->ctx);
}

int main() {
main_co = co_new(NULL, 0);
new_co = co_new(foo, 1024 * 1024);
co_ctx_make(new_co);

co_ctx_swap(&main_co->ctx, &new_co->ctx);
printf("main coroutine here\n");
co_ctx_swap(&main_co->ctx, &new_co->ctx);
return 0;
}

把上面所有的 C 代码复制到文件 co.c,汇编代码存为 co.S,然后执行 gcc -o co co.c co.S 编译,运行试试!

协程的管理

现在的协程虽然可以跑,但是使用起来很不方便,需要手动交换上下文,也容易出错。我们需要实现 resume/yield 操作。resume 操作唤醒指定协程,也就是当前协程与指定协程交换。yield 挂起当前协程,将当前协程与上次唤醒它的协程交换。因此我们需要记录当前运行的协程;而对于每个协程,要保存唤醒它的协程的指针。

协程切换要遵循这几条规则:

  • 主协程不能执行 yield 操作。这是显而易见的,因为它没有唤醒者。
  • 不能 resume 一个正在运行的协程。
  • 如果一个协程通过 resume 操作进入挂起状态,则不能由 resume 操作唤醒。例如,上图所示的协程 B 在 resume 协程 C 后,只能被协程 C 的 yield 操作唤醒。如果允许其它协程通过 resume 操作唤醒它,则协程切换会陷入混乱。
  • 除主协程外的协程结束时需要执行 yield 操作,之后进入死亡状态。死亡状态的协程不能被 resume 操作唤醒。

基于此,我们给协程定义五个状态:

enum {
CO_STATUS_INIT, // 初始状态
CO_STATUS_PENDING, // 执行 yield 操作进入的挂起状态
CO_STATUS_NORMAL, // 执行 resume 操作进入的挂起状态
CO_STATUS_RUNNING, // 运行状态
CO_STATUS_DEAD, // 死亡状态
};

我们使用全局变量 g_curr_co 记录当前协程。每个协程还要记录当前状态和唤醒自己的协程。

struct coroutine {
struct co_context ctx;
char *stack;
size_t stack_size;
int status; // 协程状态
struct coroutine *prev; // 唤醒者
start_coroutine start;
};

struct coroutine *g_curr_co = NULL; // 当前协程

struct coroutine *co_new(start_coroutine start, size_t stack_size) {
struct coroutine *co = malloc(sizeof(struct coroutine));
...
co->status = CO_STATUS_INIT;
co->prev = NULL;
return co;
}

void check_init() {
if (!g_curr_co) { // 初始化主协程
g_curr_co = co_new(NULL, 0);
g_curr_co->status = CO_STATUS_RUNNING; // 主协程状态初始为 RUNNING
}
}

接着实现 resume 操作和 yield 操作。根据上面描述的思路,实现起来很容易。

int co_resume(struct coroutine *next) {
check_init();

switch (next->status) {
case CO_STATUS_INIT: // 初始状态,需要执行 co_ctx_make 初始化
co_ctx_make(next);
case CO_STATUS_PENDING: // 只有处于 INIT 和 PENDING 状态的协程可以被 resume 唤醒
break;
default:
return -1;
}

struct coroutine *curr = g_curr_co;
g_curr_co = next; // g_curr_co 指向新协程
next->prev = curr; // 设置新协程的唤醒者为当前协程
curr->status = CO_STATUS_NORMAL; // 当前协程进入 NORMAL 状态
next->status = CO_STATUS_RUNNING; // 新协程进入 RUNNING 状态
co_ctx_swap(&curr->ctx, &next->ctx);

return 0;
}

int co_yield() {
check_init();

struct coroutine *curr = g_curr_co;
struct coroutine *prev = curr->prev;

if (!prev) { // 没有唤醒者,不能执行 yield 操作
return -1;
}

g_curr_co = prev; // g_curr_co 指向当前协程的唤醒者
if (curr->status != CO_STATUS_DEAD) {
curr->status = CO_STATUS_PENDING; // 当前协程进入 PENDING 状态
}
prev->status = CO_STATUS_RUNNING; // 唤醒者进入 RUNNING 状态
co_ctx_swap(&curr->ctx, &prev->ctx);

return 0;
}

除主协程外的协程结束运行时一定要执行 yield 操作将自己切出,否则它不知道该返回到哪儿。为了不让使用者手动执行这个操作,我们将协程入口函数封装一层。

void co_entrance(struct coroutine *co) {
co->start(); // 执行入口函数
co->status = CO_STATUS_DEAD;
co_yield(); // 已经置为 DEAD 状态了,切出后不会有人唤醒它了。这里 co_yield 永远不会返回
// 不会走到这里来
}

void co_ctx_make(struct coroutine *co) {
char *sp = co->stack + co->stack_size - sizeof(void*);
sp = (char*)((intptr_t)sp & -16LL);
*(void**)sp = (void*)co_entrance; // 设置入口地址为 co_entrance
co->ctx.regs[CO_RSP] = sp;
co->ctx.regs[CO_RDI] = co; // rdi 为第一参数寄存器,将它的值置为 co,这样 co_entrance 就能拿到它的参数了
}

这样我们的协程用起来就更方便了:

void foo() {
printf("here is the new coroutine\n");
co_yield();
printf("new coroutine resumed\n");
}

int main() {
struct coroutine *co = co_new(foo, 1024 * 1024);
co_resume(co);
printf("main coroutine here\n");
co_resume(co);
return 0;
}

传递参数

resume/yield 可以用于传递参数。运行上面的例子,我们发现 co_yield 返回之时便是其它协程调用 co_resume 之时;而 co_resume 返回之时又是其它协程调用 co_yield 之时。因此 resume 操作接受参数,传递给 yield 返回;yield 操作接受参数,传递给 resume 返回。这样方便在协程之间传递数据。

我们在 struct coroutine 中新增一个 data 字段用于传递参数。协程切换时,如果要给目标协程传递参数,就对目标协程的 data 字段赋值。协程切换后,就能从 data 字段中取出上一个协程传递的参数。

struct coroutine {
...
void *data; // 参数
};

int co_resume(struct coroutine *next, void *param, void **result) {
...

next->data = param; // 切换到 next 协程,给 next 协程的参数
co_ctx_swap(&curr->ctx, &next->ctx);
if (result) {
*result = curr->data;
}
return 0;
}

int co_yield(void *result, void **param) {
...

prev->data = result; // 切回 prev 协程,给 prev 协程的结果
co_ctx_swap(&curr->ctx, &prev->ctx);
if (param) {
*param = curr->data; // 其它协程唤醒它时给它的参数
}
return 0;
}

我们重新定义协程入口函数,让它接受参数和返回值。第一次 resume 的参数传给入口函数;入口函数的返回值在最后一次 yield 时传出去。

typedef void *(*start_coroutine)(void *);

static void co_entrance(struct coroutine *co) {
void *result = co->start(co->data);
co->status = CO_STATUS_DEAD;
co_yield(result, NULL); // 协程的最后一次 yield 操作,将入口函数的返回值传出去
}

例子

现在,我们的协程库已经完全实现了。我们可以写一些例子测试一下。比如说我们可以创建一个源源不断生成以 n 开头的自然数的协程:

void *number(void *param) {
intptr_t i = (intptr_t)param;
co_yield(NULL, NULL); // 初始化后立刻 yield
while (1) {
co_yield((void*)i, NULL);
++i;
}
}

int main() {
struct coroutine *num = co_new(number, 1024 * 1024);
co_resume(num, (void*)0, NULL); // 初始化为以 0 开头的自然数流
for (int i = 0; i < 10; ++i) {
intptr_t n;
co_resume(num, NULL, (void**)&n);
printf("%ld ", n);
}
co_free(num);
return 0;
}

运行结果就是

0 1 2 3 4 5 6 7 8 9

这个协程就是一个无限流。我们还可以写一个将两个无限流逐项相加的协程:

void *add(void *param) {
struct coroutine **cov = param, *co0 = cov[0], *co1 = cov[1]; // cov 指向前序协程的栈,这里要立刻将其中的数据取出来
co_yield(NULL, NULL); // 同样,初始化后立刻 yield
while (1) {
intptr_t a, b;
co_resume(co0, NULL, (void**)&a);
co_resume(co1, NULL, (void**)&b);
co_yield((void*)(a + b), NULL);
}
}

然后将 0 开头的自然数流与 1 开头的自然数流逐项相加,得到奇数无限流(0 + 1 = 1, 1 + 2 = 3, 2 + 3 = 5, …)

int main() {
struct coroutine *num0 = co_new(number, 1024 * 1024);
struct coroutine *num1 = co_new(number, 1024 * 1024);
struct coroutine *co_add = co_new(add, 1024 * 1024);

co_resume(num0, (void*)0, NULL); // 以 0 开头的自然数流
co_resume(num1, (void*)1, NULL); // 以 1 开头的自然数流

struct coroutine *cov[] = {num0, num1};
co_resume(co_add, cov, NULL); // 初始化 add 协程

for (int i = 0; i < 10; ++i) {
intptr_t s;
co_resume(co_add, NULL, (void**)&s);
printf("%ld ", s);
}

co_free(num0), co_free(num1), co_free(co_add);
return 0;
}

运行结果就是

1 3 5 7 9 11 13 15 17 19

当然还有更好玩的。我们可以实现一个斐波那契数列生成器。斐波那契数列可以自我定义:令 f(i) 是以第 i 项开头的斐波那契数列,f(a) + f(b) 表示将两个数列逐项相加,那么如下所示,f(2) = f(0) + f(1)。

    0  1  1  2  3  5
+ 1 1 2 3 5 8
-----------------------
1 2 3 5 8 13

所以我们可以这样做

void *fib(void *param) {
co_yield((void*)0, NULL); // 第 0 项
co_yield((void*)1, NULL); // 第 1 项
struct coroutine *f0 = co_new(fib, 1024 * 1024);
struct coroutine *f1 = co_new(fib, 1024 * 1024);
co_resume(f1, NULL, NULL); // f1 先走一步,让它成为以第 1 项开头的斐波那契数列

struct coroutine *co_add = co_new(add, 1024 * 1024);
struct coroutine *cov[] = {f0, f1};
co_resume(co_add, cov, NULL); // 将 f0 与 f1 逐项相加
while (1) {
intptr_t s;
co_resume(co_add, NULL, (void**)&s);
co_yield((void*)s, NULL);
}
}

int main() {
struct coroutine *f = co_new(fib, 1024 * 1024);
for (int i = 0; i < 10; ++i) {
intptr_t s;
co_resume(f, NULL, (void**)&s);
printf("%ld ", s);
}
}

运行结果便是斐波那契数列:

0 1 1 2 3 5 8 13 21 34

不过这种写法会创建大量协程,性能很低。仅供演示(炫技)


  1. 对于 Linux 内核而言,线程和进程是同一个东西 ↩︎

理想国

2024-06-08 00:00:00

我最近读完了《理想国》。这本书为古希腊哲学先贤柏拉图所著,是哲学经典之作、奠基之作,内容晦涩深奥,比较难懂。本人才疏学浅,以我的贫乏的哲学素养,难以完全领会其中深邃的思想。然而读完之后,仍然对我的价值观造成了不小的冲击。这里斗胆分享两点我的体会。

什么是正义

中国有句古话,叫做成王败寇。胜者为正义,败者为邪恶。蒙古铁蹄南下,击败南宋,占据中原,成为正统王朝,自然是正义。元末朱元璋起兵驱逐鞑虏,恢复中华,自然也是正义。但如果朱元璋失败了会怎样?自然是和其它千千万万失败农民起义一样,被列为乱臣贼子,成为邪恶的化身。正义与邪恶似乎是相对的概念,胜利者定义的价值即为正义。“邪不压正”不是规律,而是结果:正是因为甲战胜了乙,甲便是“正”;如果“邪”压住了“正”,“邪”就变成了正。

我曾经一度相信这种观点。然而柏拉图全然否定了这个观点。因为如果把胜利者定义为正义,反对胜利者定义为邪恶,我们可以作出这样的推导:

胜利者是正义的,所以胜利者制定的法律也是正义的。因此遵纪守法是正义的,违法犯罪是不正义的。

那么按照现行法律,谋杀是不正义的、抢劫是不正义的、盗窃是不正义的、贪污是不正义的、诈骗是不正义的。

与这些行为相关的品质也是不正义的。所以伤害他人是不正义的、损人利己是不正义的、贪婪是不正义的、奸诈狡猾是不正义的。

我们说一个贪婪、损人利己、奸诈狡猾的人是坏人,他会做坏事。个人的力量有限,他能做的坏事相对较小。我们试图让一群这样的坏人聚在一起,看看他们能不能做一件更大的坏事,甚至是最大的坏事:把正义——当前的胜利者——推翻?

然而他们会让我们失望的:一群贪婪、损人利己、奸诈狡猾的人,无论如何不能团结起来。越是不正义的个人,越不能团结成不正义的群体。因此不正义的人终究无法完成任何伟业。书中原话是这么说的:

我们看到正义的人的确更聪明能干更好,而不正义的人根本不能合作。当我们说不正义者可以有坚强一致的行动,我们实在说得有点不对头。因为他们要是绝对违反正义,结果非内讧不可。他们残害敌人,而不至于自相残杀,还是因为他们之间多少还有点正义。就凭这么一点儿正义,才使他们做事好歹有点成果;而他们之间的不正义对他们的作恶也有相当的妨碍。因为绝对不正义的真正坏人,也就绝对做不出任何事情来。

因此我们发现,即使是黑帮,也要讲究道义,要求遵守纪律。他们能在一定程度上团结在一起,正是因为他们还有一些正义。蒙古能灭南宋,因为蒙古有一定的正义;而南宋,却冤杀岳飞,昏庸腐败,致使生灵涂炭,怎能说它没有不正义。而元朝末年亦是“人心离叛,天下兵起,使我中国之民,死者肝脑涂地,生者骨肉不相保”,也是它的不正义导致了灭亡。

所以无论怎么改朝换代,无论谁是胜利者,法律永远禁止谋杀、抢劫、盗窃、贪污和诈骗。所以邪不压正,是因为邪本来就不压正,这是规律,不是结果。

社会上流行这样的观点:好人和坏人是相对的,只是立场不同;小孩子才讲对错,大人只看利弊。但这种善恶相对的观点很危险。如果认为正义与邪恶是相对的概念,就不会相信真正的良善是存在的。人就可能会走邪路,成为自私自利、损人利己、为达目的不择手段的人。

为了探寻什么是正义,柏拉图构想了一个理想的城邦,一个“理想国”。在这个城邦里,不同的人分工合作,每个人在国家里执行一种最适合他天性的职务。工匠制作工具,农民种田,皮匠做鞋,以及“爱智慧的人[1]”担任领导者。因为柏拉图认为只有智慧和理性才能让这个城邦在各种情况下做出最正确的决策。在智慧和理性的领导下,这个城邦训练勇敢的护卫者,用音乐和体操教化人民。这样的城邦是智慧的、理性的、勇敢的、节制的。一个这样的城邦便是正义的城邦。

柏拉图将人与城邦类比。一个城邦有形形色色的人,一个人内心也有不同的部分。一个人内心有受理性控制的部分,也有受欲望控制的非理性的部分,也有受激情控制的部分。柏拉图认为在这三部分中,理性的部分应该担任“领导者”,就像理性的人在应当在城邦担任领导者一样。

理智既然是智慧的,是为整个心灵的利益而谋划的,还不应该由它起领导作用吗?激情不应该服从它和协助它吗?

正义的人不许可自己灵魂里的各个部分相互干涉,起别的部分的作用。他应当安排好真正自己的事情,首先达到自己主宰自己,自身内秩序井然,对自己友善。

不正义应该就是三种部分之间的争斗不和、相互间管闲事和相互干涉,灵魂的一个部分起而反对整个灵魂,企图在内部取得领导地位——它天生就不应该领导的而是应该像奴隶一样为统治部分服务的,——不是吗?我觉得我们要说的正是这种东西。

我们常说“做自己的主人”,柏拉图说人怎么才能做自己的主人呢?因为如果说一个人是自己的主人,那他同时也是自己的奴隶。他认为这句话的意思是,一个人内心理性的部分要做非理性部分的主人。理性会让人追求智慧,会在必要时抑制欲望与激情;在他需要战斗时,又会释放激情。这样,这个人便是智慧的、理性的、勇敢的、节制的。这样的人便是正义的人。

什么是快乐

我曾经认为,人的快乐不取决于人拥有多少物质,而取决于拥有的物质的变化。例如,假设你在路上捡到一千块钱,你会快乐,但仅限于得到这些钱的这一刻。你不会因为资产增加了一千元而一直开心。再比如年收入只有 10 万的时候会想,要是我一年能赚 20 万就好了。然而当收入真的变为 20 万时,他会发现快乐只存在于收入变化的这一小段时间,之后便开始追求更高的收入了。痛苦便与之相反:当人失去物质时,会感受到痛苦,但痛苦也仅存在于失去的这一刻。我甚至提出了一个“快乐公式”:函数 表示 时刻人拥有的物质,那么 时刻的快乐便是函数 的导数 。如果 则人是快乐的,反之则是痛苦的。也就是说快乐和痛苦是对比出来的。

然而柏拉图全然否定了这个观点。柏拉图认为世界上有快乐,也有痛苦;还有一种介于快乐和痛苦之间的平静的状态。把快乐、平静、痛苦比喻为上、中、下三级。但人在受到痛苦时会把摆脱痛苦称为快乐,然而摆脱痛苦实际是中间的平静的状态,并不是什么享受。例如生病的人会说,没有什么比健康更快乐的了。然而他们在生病之前并不曾觉得那是最大的快乐。同样,当一个人正在享乐,让他突然停止享乐,进入平静的状态,也是痛苦的。然而平静怎么可以既是快乐又是痛苦呢?这便推导出矛盾了。因此柏拉图认为这种对比出来的快乐不是真的快乐,只是快乐的影像。

和痛苦对比的快乐以及和快乐对比的痛苦都是平静,不是真实的快乐和痛苦,而只是似乎快乐或痛苦。这些快乐的影像和真正的快乐毫无关系,都只是一种欺骗。

柏拉图认为,通过肉体上的享受得到的快乐,大多数属于“快乐的影像”,是某种意义上的脱离痛苦。例如人饥饿时吃食物会觉得无比的美味,这种快乐实际上是脱离痛苦。更进一步地,因欲望的满足而得到的快乐,大多属于“快乐的影像”。人对某事物有了欲望,求而不得,感到痛苦。欲望越强,越求而不得,就越痛苦,得到它时就越“快乐”。然而这种“快乐”某种意义上是痛苦的脱离,只是“快乐的影像”,不是真正的快乐。

柏拉图认为有一种真正的快乐,得到它之前不会感到痛苦,出现的时候能感受到强烈的快乐;停止之后也不留下痛苦。例如当你坚持学习,头脑变得越来越充实的时候;领悟了某个真理时那种”朝闻道,夕死可矣”的满足感。我还记得当时理解了 Y-Combinator,学会了用 CPS 变换实现 call/cc 的时候的那种兴奋感,真的能让人快乐好几天。柏拉图认为,用理性的部分追求智慧、美德得到的是实在的东西,而肉体上的享受是不实在的东西。让实在的东西填充内心才能得到可靠的真实的快乐。

那么,没有经验过真实的人,他们对快乐、痛苦及这两者之中间状态的看法应该是不正确的,正如他们对许多别的事物的看法不正确那样。因此,当他们遭遇到痛苦时,他们就会认为自己处于痛苦之中,认为他们的痛苦是真实的。他们会深信,从痛苦转变到中间状态就能带来满足和快乐。而事实上,由于没有经验过真正的快乐,他们是错误地对比了痛苦和无痛苦。正如一个从未见过白色的人把灰色和黑色相比那样。

因此,那些没有智慧和美德经验的人,只知聚在一起寻欢作乐,终身往返于我们所比喻的中下两级之间,从未再向上攀登看见和到达真正的最高一级境界,或为任何实在所满足,或体验到过任何可靠的纯粹的快乐。他们头向下眼睛看着宴席,就像牲畜俯首牧场只知吃草,雌雄交配一样。须知,他们想用这些不实在的东西满足心灵的那个不实在的无法满足的部分是完全徒劳的。由于不能满足,他们还像牲畜用犄角和蹄爪互相踢打顶撞一样地用铁的武器互相残杀。

柏拉图称肉体上的享受为不实在的东西,认为内心的欲望是不实在的无法满足的部分。现代科学在一定程度上支持柏拉图的观点。肉体上的享受得到的快乐来源于多巴胺。多巴胺能给人带来快乐,然而代价是当多巴胺消退时,人会感觉到空虚和痛苦,需要更大剂量的多巴胺才能弥补这些痛苦。于是人对多巴胺的追求永远不会满足,这个过程最终会变成一种折磨。而人在节制、自律的时候会分泌内啡肽,它给人带来的快乐是缓慢持续的;当它消退时也不会感到痛苦。自律和节制能给人带来更高级的快乐。这也是罗翔所说的,低级的快乐来自放纵,高级的快乐来自克制。

我过去提出的那个“快乐公式”只适用于通过多巴胺获取的快乐。也就是柏拉图所说的,往返于中下两级之间,从平静和痛苦之间感受快乐的影像。

最后柏拉图认为正义的人是真正快乐的,因为他们是理性的、节制的。他们会追求智慧,追求真理,这其中得到的快乐要胜与满足欲望得到的快乐。一个极端不正义的人欲望会无限膨胀,理性成为欲望的奴隶,他永远无法满足,给他再多的物质也得不到快乐。

  1. 哲学家 philosopher 字面意思为“爱智慧的人”。philo- 爱,sophia 智慧。 ↩︎

AppImage: 一次打包,到处运行

2024-04-19 00:00:00

我们知道,不同于 Windows 将软件的所有文件安装在一个目录,一个 Linux 软件的不同部分会被安装在不同路径。例如,可执行文件安装在 /usr/bin 下;库文件安装在 /usr/lib 下;文档、脚本等资源文件通常安装在 /usr/share 下等。这是因为 Linux 认为软件包之间会相互依赖,不同的软件可能依赖于同一个库,那么这个库就只应该存在一份。例如 curl, ssh 和 nginx 都依赖于 libcrypt.so 这个共享库,其为 openssl 的一部分。当我们使用 apt 安装 nginx 时,先会检测 openssl 是否已经安装。如果没有,就先安装 openssl;否则直接安装 nginx。

这样做的好处是可以节省磁盘:所有软件依赖的相同的库只存在一份。因此安装一个 Linux 系统通常只需要几 G 的磁盘空间,而 Windows 通常需要几十 G。同时可以节省内存,因为共享库的加载方式是 mmap,同一个共享库在内存中也只有一份。

但是这么做是有代价的。假设 A, B 软件都依赖于库 L,那么 A 和 B 就只能依赖于同一个版本的库 L。一台机器上有这么多软件,意味着整个依赖网络让他们相互钳制,版本号被限制,不能随意升级。一个发行版会确定各种软件包的版本(确定主次版本号,补丁号通常不做限制),组成软件库,确保它们相互兼容,没有依赖冲突。也就是说 apt 安装的软件版本由当前 Ubuntu 版本决定的。这也是为什么 Linux 发行版通常每年都要发布一个新版本,否则软件库会落后于时代。

如果你在用一个较老的发行版,想安装一些新软件,通常需要自己编译。自己编译的软件通常安装在 /usr/local/* 下,与 /usr/* 区分开。但是我公司用的开发环境的发行版太老了,g++ 版本 4.8,只支持 C++ 11,无法编译要求支持 C++ 17 的新软件。更糟糕的是这个发行版的 glibc 版本也非常老,新软件即使在新系统中编译出来,也无法在这个老系统上运行。而 gcc 工具链(包括 glibc)是操作系统的一部分,不能随意升级。

要是能像 Windows 一样将软件的依赖的各种 DLL 都打包到一起就好了!Linux 有类似的解决方案,AppImage 就是其中一种。它可以将软件打包成一个二进制 AppImage 文件,这是一个标准的 ELF 可执行文件。用户下载 AppImage 文件后,直接 chmod +x 后就可以直接运行,非常方便。对于 AppImage 来说,一个软件就是一个可执行文件。

chmod +x app.AppImage
./app.AppImage

原理

AppImage 的原理是将软件和相关依赖归档成一个磁盘镜像,打包在 AppImage 文件里。这个归档的目录称为 AppDir,它的结构大概是这样的:

AppDir
├── AppRun
├── icon.svg
├── app.desktop
└── usr
├── bin
│   └── app
├── lib
│   └── x86_64-linux-gnu
│   ├── ld-2.31.so
│   ├── libm.so.6
│   ├── libpthread.so.0
│   └── libc.so.6
└── share
└── icons
   └── icon.svg

运行 AppImage 文件时,其中的磁盘镜像会被挂载到 /tmp/.mount_XXX.XXXXX 上,然后执行其中的 AppRun。AppRun 可以是一个脚本,也可以是一个二进制,它负责做一些前序工作,设置各种环境变量(如 LD_LIBRARY_PATH),然后启动目标程序。

Hello, AppImage

接下来我们动手制作一个 AppImage。我们有一个 C 程序 hello.c

#include <stdio.h>

int main() {
printf("Hello Appimage\n");
return 0;
}

然后编译它 gcc -o hello hello.c。接着我们创建一个 AppDir 目录,将 hello 放到 AppDir/usr/bin/ 中。

$ mkdir -p AppDir/usr/bin
$ cp hello AppDir/usr/bin/
$ tree Appdir
AppDir
└── usr
└── bin
└── hello

接着我们要将程序依赖的共享库也打包进去。我们用 ldd 查看 hello 依赖的共享库:

$ ldd hello
linux-vdso.so.1 (0x00007ffe66f8f000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f849544b000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8495650000)

hello 很简单,只依赖 libc。链接器 /lib64/ld-linux-x86-64.so.2 为程序加载各种共享库,是程序的解释器 (interpreter),也需要打包进去。我们把这两个 .so 文件复制到 AppDir 的对应目录:

AppDir
├── lib
│   └── x86_64-linux-gnu
│   └── libc.so.6
├── lib64
│   └── ld-linux-x86-64.so.2
└── usr
└── bin
└── hello

接下来我们创建 AppRun 脚本。这个脚本先设置 LD_LIBRARY_PATH 环境变量,然后用 AppDir 中的链接器加载运行 hello 程序:

#!/usr/bin/sh

export LD_LIBRARY_PATH=${APPDIR}/lib/x86_64-linux-gnu
${APPDIR}/lib64/ld-linux-x86-64.so.2 ${APPDIR}/usr/bin/hello

AppImage 运行时环境变量 APPDIR 便是 AppDir 挂载的路径(/tmp/.mount_XXX.XXXXX),我们可以直接在脚本中引用它。最后我们需要一个 desktop 文件配置一些元数据,还要准备一个图标文件:

[Desktop Entry]
Name=hello
Exec=hello
Icon=hello
Type=Application
Categories=Utility;

最终 AppDir 的目录结构是这样的:

AppDir
├── AppRun
├── hello.desktop
├── hello.svg
├── lib
│   └── x86_64-linux-gnu
│   └── libc.so.6
├── lib64
│   └── ld-linux-x86-64.so.2
└── usr
└── bin
└── hello

要将 AppDir 打包成可执行文件,需要用到的工具是 appimagetool,可以到 Github 下载。appimagetool 本身也是个 AppImage,下载后即可运行。执行 appimagetool AppDir 便可将 AppDir 打包成一个 AppImage。运行它

$ ./hello-x86_64.AppImage
Hello Appimage

因为它打包了程序所需的所有依赖,所以理论上它可以在任意一个同架构(这里是 X86_64)的 Linux 系统上运行,无论这个系统的 libc 版本是多少。你也可以修改这个程序,让它引用一些较新的 libc 里才有的函数(如 gettid, glibc 2.30 被加入),打包成 AppImage 后再发给一个老系统(如 CentOS 7),看看它能不能正常运行。

使用 appimage-builder

上面例子中的程序很简单,只依赖一个 libc。而实际情况下程序通常依赖很多共享库,这些共享库有可能又依赖更多其它的共享库。手动找出来非常麻烦,我们可以使用工具。appimage-builder 就是一个很方便的工具。它的原理是运行目标程序,分析它访问了哪些共享库;然后使用包管理器(如 apt)获取依赖,并制作成 AppDir。此外它还提供了一个功能强大的 AppRun,支持路径映射,通过 hook 程序的文件访问函数,将指定路径映射到 AppDir 中。

appimage-builder 是一个 Python 工具,可以使用 pip 安装:

pip install appimage-builder

要用 appimage-builder 制作 AppImage,我们首先需要准备一个“基础版”的 AppDir,包含软件的可执行文件和一些相关依赖。通常那些 make install 复制到 /usr/local/ 下的文件就是基础 AppDir 应当包含的文件。上面例子的基础 AppDir 结构如下:

AppDir
└── usr
└── bin
└── hello

appimage-builder 基于一个 yaml 配置文件制作 AppImage,称为 recipe。我们不必手动创建 recipe,可以用 appimage-builder --generate 命令生成,然后再根据需要修改。generate 命令是一个向导程序,会询问这个应用的基本信息。

$ appimage-builder --generate
INFO:Generator:Searching AppDir
? ID [Eg: com.example.app]: tech.luyuhuang.hello
? Application Name: hello
? Icon: hello
? Executable path: usr/bin/hello
? Arguments [Default: $@]: $@
? Version [Eg: 1.0.0]: latest
? Update Information [Default: guess]: guess
? Architecture: x86_64
INFO:AppRuntimeAnalyser:/usr/bin/strace -f -E LD_LIBRARY_PATH= -e trace=openat --status=successful AppDir/usr/bin/hello

接着 appimage-builder 会用 strace 运行目标程序,分析它打开了哪些共享库文件;然后用包管理工具分析共享库属于哪个软件包。结束后就会生成 recipe 文件 AppImageBuilder.yml。它的结构如下:

version: 1
AppDir:
path: /path/to/AppDir
app_info: # 应用基础信息
id: tech.luyuhuang.hello
name: hello
icon: hello
version: latest
exec: usr/bin/hello
exec_args: $@
apt:
arch:
- amd64
allow_unauthenticated: true
sources: # 用到的软件源
- sourceline: deb http://archive.ubuntu.com/ubuntu/ focal main restricted
- sourceline: deb http://archive.ubuntu.com/ubuntu/ focal-updates main restricted
- ...
include: # 用到的软件包
- libc6:amd64
files:
include: [] # 额外需要包含到 AppDir 的文件
exclude: # 需要排除的文件
- usr/share/man
- usr/share/doc/*/README.*
- ...
test: # 测试配置
fedora-30:
image: appimagecrafters/tests-env:fedora-30
command: ./AppRun
debian-stable:
...
AppImage:
arch: x86_64
update-information: guess

我们通常需要关注这些配置:

  • AppDir.apt 软件包相关信息,由 generate 命令探测出,通常不用自行修改。include 为要用到的软件包,sources 是这些软件包相关的软件源。
  • AppDir.files 控制要包含哪些文件。支持使用 Glob 表达式(如 *** 通配符)匹配文件路径。
    • include 为需要包含到 AppDir 的文件的绝对路径列表,这些文件会被复制到 AppDir 中的对应位置。例如 /usr/bin/bash 对应 $APPDIR/usr/bin/bash
    • exclude 则为需要在 AppDir 中排除的文件路径列表,路径相对于 AppDir。
  • AppDir.test 为测试环境。appimage-builder 会拉取其中指定的 Docker 镜像,并在其中测试 AppDir。

除这些自动生成的配置外,还有很实用的运行时配置。

AppDir:
runtime:
env:
LD_PRELOAD: '${APPDIR}/usr/lib/libjemalloc.so'
path_mappings:
- /bin/bash:$APPDIR/bin/bash
  • env 指定运行时的环境变量。appimage-builder 自带的 AppRun 程序还支持一些特殊的环境变量
    • APPDIR_EXEC_ARGS 程序的命令行参数,默认为 $@,即原样透传传给 AppRun 的参数。
    • APPDIR_LIBRARY_PATH 共享库搜索路径,效果等同于 LD_LIBRARY_PATH
  • path_mappings 设置路径映射。支持将一个绝对路径映射到 AppDir 中的路径,格式为 源路径:目标路径。例如 /bin/bash:$APPDIR/bin/bash,每当程序访问 /bin/bash 都会实际访问 AppDir 中的 bin/bash

准备好 recipe 文件后执行 appimage-builder --recipe AppImageBuilder.yml 即可生成 AppImage。也可以加上 --skip-tests 跳过测试。

实战:制作 ccls 的 AppImage

ccls 是一个 C++ 的 language server。我想在公司的开发环境用上 ccls,但是 ccls 依赖的工具链和运行时环境都比较新,无法直接在公司的开发环境上编译、运行。因此我准备在 Ubuntu 20.04 下编译 ccls 并制作成 AppImage,让这个老系统也能用上新软件。

执行如下命令构建 ccls:

sudo apt-get install clang libclang-10-dev # 安装依赖
git clone --depth=1 --branch=0.20220729 --recursive https://github.com/MaskRay/ccls # 获取 ccls, 版本 0.20220729
cd ccls
cmake -H. -BRelease -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH=/usr/lib/llvm-10 \
-DLLVM_INCLUDE_DIR=/usr/lib/llvm-10/include \
-DLLVM_BUILD_INCLUDE_DIR=/usr/include/llvm-10/ \
-DCMAKE_INSTALL_PREFIX=/usr # 设置 prefix 为 /usr
cd Release
make -j8
make install DESTDIR=AppDir # 安装到 ./AppDir

这样我们就有了基础 AppDir:

AppDir
└── usr
└── bin
└── ccls

接着我们执行 appimage-builder --generate 生成 recipe:

$ appimage-builder --generate
INFO:Generator:Searching AppDir
? ID [Eg: com.example.app]: com.github.MaskRay.ccls
? Application Name: ccls
? Icon: ccls
? Executable path relative to AppDir [usr/bin/app]: usr/bin/ccls
? Arguments [Default: $@]: $@
? Version [Eg: 1.0.0]: latest
? Update Information [Default: guess]: guess
? Architecture: x86_64

根据 ccls 的文档(和我的测试结果),ccls 运行时要访问 clang 的 lib 目录。我的 clang 是用 apt 安装的,路径在 /usr/lib/llvm-10/lib/clang/10.0.0。我们需要把这个路径打包进 AppDir,并且将其映射到 AppDir 内。我们修改 AppImageBuilder.yml:

AppDir:
files:
include:
- /usr/lib/llvm-10/lib/clang/10.0.0/** # 把这个路径下的全部文件包含进去
runtime:
path_mappings:
- /usr/lib/llvm-10/lib/clang/10.0.0:$APPDIR/usr/lib/llvm-10/lib/clang/10.0.0 # 映射到 AppDir 内

然后我们还要创建个图标。虽然是命令行程序,但是 AppImage 要求每个应用都要有个图标,所以没办法。这里我们就 touch 一个空文件就好:

mkdir -p AppDir/usr/share/icons
touch AppDir/usr/share/icons/ccls.svg

最后执行 appimage-builder --recipe AppImageBuilder.yml 生成 AppImage。大功告成!

$ ./ccls-latest-x86_64.AppImage --version
ccls version 0.20220729-0-g7445891
clang version 10.0.0-4ubuntu1

总结

Linux 的软件管理方式虽然节省了磁盘和内存空间,但是也增加了软件安装的难度。导致 Linux 的软件要么进入发行版使用包管理器安装;要么发布源码,编译安装。前者虽然安装方便,但是版本受限,不能随意升级;后者需要准备开发环境,安装较为麻烦。当编译依赖的工具链不满足要求时,软件安装会变得很棘手。

针对这个问题,Linux 有几种解决方案,例如 snap、容器,以及本文介绍的 AppImage 等。它们的解决思路其实差不多,都是将软件与其依赖一起打成包发布。它们各有优劣,对于 AppImage 来说,优点就是使用方便,用户不需要安装任何环境,下载 AppImage 即可执行;缺点是依赖于 AppRun 的前序处理,兼容性可能不如 snap 和容器。个人感觉 Linux 桌面系统要想推广,软件安装还是要走 Windows 和 macOS 这种形态,即打包软件依赖,降低安装门槛,提高兼容性。

参考资料: