原文链接

大刀阔斧精简内核

这是“讨论各种精简内核大小方法系列文章”的第四篇,旨在让 Linux 内核能适用于小型的运行环境。精简内核二进制文件是有其极限的,而我们已经尽可能地做到极致。但是,我们的目标是将 Linux 完全运行在一个片上微控制器,这个目标还没有达到。这篇文章会通过使内核和用户态适用于资源受限系统的角度来总结这个系列文章。

微控制器是一个自包含的系统,它拥有外设,内存和CPU。它通常较小,廉价,而且低功耗。微控制器被设计成用于实现单一任务和运行某个特定的程序。因此,微控制器中的动态内存通常比静态内存空间小的多。这也就是为何微控制器上的 ROM 普遍比 RAM 大很多倍的原因。

比如说, ATmega328 (常见的 Arduino 型号)装载 32KB 的闪存(flash),但只有 2KB 的静态内存(SRAM)。至于可以运行 Linux 的 STM32F767BI 装载了 2MB 的闪存和 512KB 的静态内存。所以,我们将锁定使用这些资源来确定如何将最多的内容从 RAM 搬移到 ROM。

Kernel XIP

“就地运行“(eXecute-In-Place XIP)机制允许 CPU 从 ROM 或者闪存中获取指令,这样一来可以避免将运行指令存储和加载到 RAM 中。XIP 引入到微控制器领域是因为它们的 RAM 通常都很小。所以,XIP 在大型系统上很少被使用(因他们拥有充足的内存,所以直接在 RAM 上运行所有东西);在 RAM 上运行指令因为高性能缓存也会变得更高效。这个也就是为何大部分 Linux 架构不支持 XIP。实际上,内核的 XIP 只在 ARM 平台上支持,而且早在 Git 出现之前。

至于内核 XIP, 它需要 ROM 或者闪存能跟内存一样直接通过处理器的内存地址来被访问,且不需要单独的软件驱动。NOR 闪存支持随机访问而经常被用于这个目的,它不像使用块地址索引的 NAND 闪存。然后,内核在构建链接时特殊处理,这样一来代码段和只读数据段将被分配到闪存的地址空间中。我们只需要开启 CONFIG_XIP_KERNEL,构建系统会提示输入预期内核要在闪存上的物理地址。只有可写的内核数据将被拷贝到 RAM 上。

因此,我们期望 XIP 内核更可能多地将代码和数据放置到闪存中。越多空间被放置在闪存中,越少的空间会被拷贝到珍贵的 RAM 上。默认方法和其被 const 标注的数据将会被放置到 flash 上。最近内核开发上为了强化变量用途展开了很多常量化工作,这使得 XIP 内核受益很多。

用户态 XIP 和文件系统

用户空间是个内存消耗大户。但是,正如内核一样,用户空间二进制有可读可写和只读段。如果能将只读段也存放在之前相同的闪存空间,并从闪存中直接运行,这样就避免被加载到内存中。然而,并非完全与内核一样,内核是一个静态的二进制,它只被加载或者映射到 ROM 和 RAM 地址一次。用户空间的程序存在与文件系统,这样使得事情变得复杂起来。

我们能否摈弃文件系统?当然可以。事实上,这也是大多数小型实时操作系统的做法。他们把程序代码直接跟内核链接到一起,完全绕开文件系统层。而且这也不会颠覆 Linux 的运行机制,因为内核线程本身就可以当作是一个用户态的程序: 他们拥有自己的执行上下文,与用户程序一起被调度,可以被发送信号,显示在进程列表中,等等。而且内核线程也不需要文件系统。虽然,将程序运行在内核线程中,可能导致整个内核崩溃,但是微控制器中本来也缺少 MMU 设备,它已经是一个纯粹的用户态程序了。

然而,为用户态程序搭配一个文件系统,会有很多我们不想损失的优势:

  • 兼容全功能的 Linux 系统,这样我们的程序可以在本地工作站中开发测试
  • 方便整合多个不相关的程序
  • 可以单独开发和更新内核与用户态程序
  • 与内核 GPL 协议划清界限

也就是说,我们尽可能想要一个最小,最简单的文件系统。别忘记我们的闪存容量只有 2MB,而我们的内核已经占用 1MB。因为很多可写文件系统有它固有的损耗,我们只能排除这些文件系统,并且我们不希望写闪存区域,因为内核代码存在其中,写操作可能导致系统崩溃。

注意:即使闪存通过开启 CONFIG_MTD_XIP 被用于 XIP,它也是受限可写的。当前只能在 Intel 和 AMD 的闪存设备实现,而且需要特定的架构支持

所以小型且只读文件系统的选择只有这些:

  • Squiashfs:可高度扩展,默认压缩,代码有些复杂,没有 XIP 支持
  • Romfs: 简单,精小的代码,没有压缩,部分支持(没有 MMU 的系统) XIP
  • Cramfs: 简单,精小的代码,有压缩功能,非主干代码可以部分支持 (MMU上) XIP

我选择 Cramfs,因为只它拥有的压缩机制能满足只使用少量的闪存,这些 romfs 所没有的。而且 Cramfs 的代码相对 squashfs 比较简单,可以较容易在没有 MMU 设备的系统上添加 XIP 特性。并且,Cramfs 只需配置一下可以完全被用在块设备上。

然后,添加 XIP 到 cramfs 上的初步尝试是相当粗鲁而缺乏基本原则的。这些尝试功能要么能用,要么完全不能用:比如,每一个文件在 XIP 下,要么完全没压缩,要么完全压缩。实际上,可执行文件由代码和数据组成,既然可写数据得拷贝到 RAM 中,就没有必要让它们以非压缩的方式存在闪存中。所以,我只能靠自己重新设计 cramfs 在 MMU 和非 MMU 的 XIP 支持。我添加了所需的功能以实现混合压缩和不压缩任意区间的块,最终这确实满足上游合并的标准(主干 Linux 版本4.15 以后可用)。

之后,我(再次)发现有10年历史的 AXFS 文件系统(仍不在主干维护)更适用。但,我只能放弃这个想法,毕竟我更愿意与主干代码打交道。

有人会奇怪为何 DAX 没有在这里被适用。极端上,DAX 有点像 XIP;DAX 被制作于大型可写文件系统,并依赖 MMU 来实现页数据按需读入和置出。它的文档也提到另一个缺点:”DAX 的代码不能在虚拟映射缓存的架构中正确运行,如 ARM, MIPS 和 SPARC“。因为XIP 的 cramfs 是只读而且足够小,可以完全被映射到内存中,他的功能完全可以实现所需的结果,而且方式更简单,这样一来 DAX 在这个上下文下就有点太重了。

用户空间 XIP 和可执行二进制格式

现在,我们有了 XIP 支持的文件系统,是时候使用它了。我适用一个静态构建的 BusyBox 版本以保持简单。在适用具有 MMU 的架构,我们可以看到程序是如何被映射到内存中的。

# cat /proc/self/maps
00010000-000a5000 r-xp 08101000 1f:00 1328 /bin/busybox
000b5000-000b7000 rw-p 00095000 1f:00 1328 /bin/busybox
000b7000-000da000 rw-p 00000000 00:00 0 [heap]
bea07000-bea28000 rw-p 00000000 00:00 0 [stack]
bebc1000-bebc2000 r-xp 00000000 00:00 0 [sigpage]
bebc2000-bebc3000 r–p 00000000 00:00 0 [vvar]
bebc3000-bebc4000 r-xp 00000000 00:00 0 [vdso]
ffff0000-ffff1000 r-xp 00000000 00:00 0 [vectors]

第一行粗字体的线索就暴露了 XIP。这行代表文件的偏移与映射的关系。我们可以看到 0x08101000 明显大于文件偏移量;实际上,它代表闪存物理地址的偏移量。Cramfs 也可能用在某些场景调用 vm_insert_mixed() ,这样物理机地址汇报将不可用。在任意场景可靠的汇报映射关系将会很有用。

第二行映射 /bin/busybox (.data 区)被标志成可读可写,并不像第一行的代码区,是只读可执行的。可写段不能映射到闪存中,因此需以正常的方式加载到内存中。

MMU 让程序很容易使用绝对地址而不用在意它的实际内存使用量。在非 MMU 环境下,事情变得复杂起来,用户执行程序必须可以在任意内存地址上运行;因此地址无关代码(PIC)是一个必须选项。这个功能由 bFLT 平铺文件格式支持,该格式被 uClinux 架构支持了很久。然后,这个格式有许多限制,使得 XIP,共享库,或者两者合并,不容易操作。

幸运地是,有一个 ELF 的变体格式,ELF FDPIC,可以解决这些限制。因为 FDPIC 段是地址无关的,不需要先决相对偏移量,因此它可以在多个可执行文件中共享代码段,正如 有 MMU 的 ELF 格式一样。代码段也可以做成 XIP。ELF FDPIC 支持已经被添加到了 ARM 架构上(同样主干 Linux 版本 4.15)

在我的 STM32 设备上,使用 XIP cramfs 和 ELF FDPIC 用户态二进制,BusyBox 的地址映射看起来是这样的:

# cat /proc/self/maps
00028000-0002d000 rw-p 00037000 1f:03 1660       /bin/busybox  
0002d000-0002e000 rw-p 00000000 00:00 0  
0002e000-00030000 rw-p 00000000 00:00 0          [stack]    
081a0760-081d8760 r-xs 00000000 1f:03 1660       /bin/busybox  

因为缺少 MMU,使用 XIP 的段因为没有地址转换,直接可以看到闪存地址。

砍掉静态内存

好了,现在我们准备好做一些大动作。我们看到上面 XIP 的 BusyBox 已经省掉 229,376 字节内存,或者说 56 个内存页。如果我们有 512KB,这是 128 个页的 44%。从现在开始,精确地记录内存被分配到哪,决定这些珍贵的内存如何被高效使用,是很重要的。我们先从内核开始看,使用一个之前文章中精简过的配置,另外加上 XIP 配置,我们得到如下

   text    data     bss     dec     hex filename
1016264   97352  169568 1283184  139470 vmlinux

1,016,264 字节的代码段是被放置在闪存中的,我们先略过这一部分。但是,266,920 字节的数据段和 BSS 段使用了 51% 的内存总量。让我们通过一些脚本来找出哪些东西占用了这部分空间

    #!/bin/sh
    {
        read addr1 type1 sym1
        while read addr2 type2 sym2; do
            size=$((0x$addr2 - 0x$addr1))
            case $type1 in
            b|B|d|D)
                echo -e "$type1 $size\t$sym1"
                ;;
            esac
            type1=$type2
            addr1=$addr2
            sym1=$sym2
        done
    } < System.map | sort -n -r -k 2

前面几行的输出:

    B 133953016     _end
    b 131072        __log_buf
    d 8192  safe_print_seq
    d 8192  nmi_print_seq
    D 8192  init_thread_union
    d 4288  timer_bases
    b 4100  in_lookup_hashtable
    b 4096  ucounts_hashtable
    d 3960  cpuhp_ap_states
    [...]

这里,我们忽略 _end,因为它使用了超大空间明显是因为一个情况,内存分配的末尾是在闪存前面的。

然而,我们有一些明显分配情况用来考量问题。观察一下 __log_buf 的定义:

    /* record buffer */
    #define LOG_ALIGN __alignof__(struct printk_log)
    #define __LOG_BUF_LEN (1 << CONFIG_LOG_BUF_SHIFT)
    static char __log_buf[__LOG_BUF_LEN] __aligned(LOG_ALIGN);

这个变量很简单,因为我们不想将整个 printk() 支持去掉,所以我们将 CONFIG_LOG_BUF_SHIFT 这设置成 12(最小可设置的值)。同时,我们将 CONFIG_PRINTK_SAFE_LOG_BUF_SHIFT 设置成最小值 10. 结果:

   text    data     bss     dec     hex filename
1016220   83016   42624 1141860  116c64 vmlinux

我们通过一些简单的配置调节,将内存使用量从 266,920 降低到 125,640 字节。我们再看看符号使用情况:

B 134092280     _end
D 8192  init_thread_union
d 4288  timer_bases
b 4100  in_lookup_hashtable
b 4096  ucounts_hashtable
b 4096  __log_buf
d 3960  cpuhp_ap_states
[...]

下一个消耗大户是 init_thread_union 。这个比较有趣,因为它来源自 THREAD_SIZE_ORDER,它用来决定内核进程能拥有多少个栈页。第一个进程(init 进程)刚巧在数据字段静态分配了它的栈,这就是为何我们能在这里看到它。把这个值从 2 个修改到 1 个就能很好的适用在我们的小型环境里,而且这也能在动态分配栈时每进程能减少 1 个页。

我们可以调整 LVL_BITS 从 6 到 4,来减少 timer_bases 的大小。调整 IN_LOOKUP_SHIFT 从 10 到 5. 连同一些其他内核常量。

搞定动态内存分配

正如我们所看见的,找出和减少静态内存分配是比较容易的。但是动态分配也需要好好处理,为了这个目的我们得将我们的设备引导起来。当内核的分配器还没有启动运行时,第一个动态分配器来自 memblock 分配器。观察其执行操作的方法是现成的,只要设置 memblock=debug 去启动它。它会显示如下:

memblock_reserve: [0x00008000-0x000229f7] arm_memblock_init+0xf/0x48
memblock_reserve: [0x08004000-0x08007dbe] arm_memblock_init+0x1d/0x48

这里可以看出静态内存被保留了,它与内核代码,只读数据相连,一同存在闪存中(它们被映射在 0x08004000)。如果内核代码是在 RAM,是有必要保留这部分内存。在现在这个场景下,这个保留行为是无用但无害的行为,因为闪存永远不会被用于分配。

现在看下实际的动态分配:

memblock_virt_alloc_try_nid_nopanic: 131072 bytes align=0x0 nid=0
from=0x0 max_addr=0x0 alloc_node_mem_map.constprop.6+0x35/0x5c
  Normal zone: 32 pages used for memmap
  Normal zone: 4096 pages, LIFO batch:0

memmap 数组使用了 131,072 字节 (32 个页)去管理 4096 个页。默认这个设备需要使用 16MB 的主板外置内存地址。所以,如果我们把这个数字降低成实际的页数量,比如说 512KB,那么这个值会明显降低。

下一个较大的分配是:

memblock_virt_alloc_try_nid_nopanic: 32768 bytes align=0x1000 nid=-1
from=0xffffffff max_addr=0x0 setup_per_cpu_areas+0x21/0x64
pcpu-alloc: s0 r0 d32768 u32768 alloc=1*32768

在一个只有小于 1MB 内存的单处理器系统预留给每个 CPU 一个 32KB 的内存池?没必要。需要修改 include/linux/percpu.h 文件来修改成单个页

-#define PCPU_MIN_UNIT_SIZE             PFN_ALIGN(32 << 10)
+#define PCPU_MIN_UNIT_SIZE             PFN_ALIGN(4 << 10)

-#define PERCPU_DYNAMIC_EARLY_SLOTS     128
-#define PERCPU_DYNAMIC_EARLY_SIZE      (12 << 10)
+#define PERCPU_DYNAMIC_EARLY_SLOTS     32
+#define PERCPU_DYNAMIC_EARLY_SIZE      (4 << 10)

+#undef PERCPU_DYNAMIC_RESERVE
+#define PERCPU_DYNAMIC_RESERVE         (4 << 10)

值得注意的是,只有 SLOB 内存分配器 (CONFIG_SLOB)在这些修改后能继续工作。

继续看下一个较大的内存分配:

memblock_virt_alloc_try_nid_nopanic: 8192 bytes align=0x0 nid=-1
from=0x0 max_addr=0x0 alloc_large_system_hash+0x119/0x1a4
Dentry cache hash table entries: 2048 (order: 1, 8192 bytes)
memblock_virt_alloc_try_nid_nopanic: 4096 bytes align=0x0 nid=-1
from=0x0 max_addr=0x0 alloc_large_system_hash+0x119/0x1a4
Inode-cache hash table entries: 1024 (order: 0, 4096 bytes)

谁说这是一个大型系统?是的,目前你应该领悟精简方法了 ———— 接下来值需要一些类似调整,不过为了让这篇文章保持合理的篇幅,这些调整被省略了。

之后,通用的内核内存工作将被分配器如 kmalloc() 接管,所有的分配最终落在 __alloc_pages_nodemask(). 类似的跟踪和调整也适用在这个阶段,直到启动完成。有时就是配置调整的事情,如 sysfs 文件系统,它使用的内存有点超过我们的预算,等等。

回到用户空间

既然我们已经大幅降低内核的内存使用量,我们准备再次引导看看。这次引导成功最低的内存所需,我们设定成800KB (设置内核引导命令行”mem=800K“)。让我们看看这个小小的世界:

BusyBox v1.7.1 (2017-09-16 02:45:01 EDT) hush - the humble shell

# free
             total       used       free     shared    buffers     cached
Mem:           672        540        132          0          0          0
-/+ buffers/cache:        540        132

# cat /proc/maps
00028000-0002d000 rw-p 00037000 1f:03 1660       /bin/busybox
0002d000-0002e000 rw-p 00000000 00:00 0
0002e000-00030000 rw-p 00000000 00:00 0
00030000-00038000 rw-p 00000000 00:00 0
0004d000-0004e000 rw-p 00000000 00:00 0
00061000-00062000 rw-p 00000000 00:00 0
0006c000-0006d000 rw-p 00000000 00:00 0
0006f000-00070000 rw-p 00000000 00:00 0
00070000-00078000 rw-p 00000000 00:00 0
00078000-0007d000 rw-p 00037000 1f:03 1660       /bin/busybox
081a0760-081d8760 r-xs 00000000 1f:03 1660       /bin/busybox

这里我们可以看到从 /bin/busybox 的文件偏移 0x37000 开始有 2 个 4 页的内存。这是两个数据实例,一个是 shell 进程,一个是 cat 进程。他们共同共享 busybox 从 0x081a0760 开始的 XIP 代码段。另外,还有两个匿名的 8 页内存,它们消耗了大量的页预算。他们相当于一个 32KB 的栈空间。这个页可以被调整下来:

--- a/fs/binfmt_elf_fdpic.c
+++ b/fs/binfmt_elf_fdpic.c
@@ -337,6 +337,7 @@ static int load_elf_fdpic_binary(struct linux_binprm *bprm)
        retval = -ENOEXEC;
        if (stack_size == 0)
                stack_size = 131072UL; /* same as exec.c's default commit */

+       stack_size = 8192;

        if (is_constdisp(&interp_params.hdr))
                interp_params.flags |= ELF_FDPIC_FLAG_CONSTDISP;

这个是相当又快又脏的做法;在 ELF 二进制头文件中合理地修改栈大小才是比较恰当的做法。这页需要比较细致的验证,比如说在有 MMU 的系统上设置一个固定大小的栈,任意的栈溢出都能被捕捉到。但是,这反正也不是我们第一次做这种事情了。

在重启之前,我们看看更多的信息:

# ps
  PID USER       VSZ STAT COMMAND
    1 0          300 S    {busybox} sh
    2 0            0 SW   [kthreadd]
    3 0
ps invoked oom-killer: gfp_mask=0x14000c0(GFP_KERNEL),
nodemask=(null), order=0, oom_score_adj=0
[...]
Out of memory: Kill process 19 (ps) score 5 or sacrifice child

内存不足导致的杀进程行为似乎必会发生。好在内存不足时buddy分配器中会提供一些信息:

Normal: 2*4kB (U) 3*8kB (U) 2*16kB (U) 2*32kB (UM)
        0*64kB 0*128kB 0*256kB = 128kB

ps 进程尝试使用0阶大小(单个 4KB 页)的内存分配,尽管有 128KB 空闲,这个操作仍然失败了。为什么?原来是因为页分配器在低于一定水位后会不执行正常的内存分配逻辑,这个水位由 zone_watermark_ok() 来判断返回。这样可以避免死锁,因为无法分配内存后,需要去杀进程,这个杀进程操作又需要内存。尽管这个水位很小,但是在我们的小型环境中,这仍然是一个我们无法接受的数值,所以我们稍微降低这个水位

--- a/mm/page_alloc.c
+++ b/mm/page_alloc.c
@@ -7035,6 +7035,10 @@ static void __setup_per_zone_wmarks(void)
                zone->watermark[WMARK_LOW]  = min_wmark_pages(zone) + tmp;
                zone->watermark[WMARK_HIGH] = min_wmark_pages(zone) + tmp * 2;

+               zone->watermark[WMARK_MIN] = 0;
+               zone->watermark[WMARK_LOW] = 0;
+               zone->watermark[WMARK_HIGH] = 0;
+
                spin_unlock_irqrestore(&zone->lock, flags);
        }

最终,我们能够用 “mem=768k” 来重启内核

Linux version 4.15.0-00008-gf90e37b6fb-dirty (nico@xanadu.home) (gcc version 6.3.1 20170404
 		  (Linaro GCC 6.3-2017.05)) #634 Fri Feb 23 14:03:34 EST 2018
    CPU: ARMv7-M [410fc241] revision 1 (ARMv7M), cr=00000000
    CPU: unknown data cache, unknown instruction cache
    OF: fdt: Machine model: STMicroelectronics STM32F469i-DISCO board
    On node 0 totalpages: 192
      Normal zone: 2 pages used for memmap
      Normal zone: 0 pages reserved
      Normal zone: 192 pages, LIFO batch:0
    random: fast init done
    [...]

    BusyBox v1.27.1 (2017-09-16 02:45:01 EDT) hush - the humble shell

    # free
                 total       used       free     shared    buffers     cached
    Mem:           644        532        112          0          0         24
    -/+ buffers/cache:        508        136

    # ps
      PID USER       VSZ STAT COMMAND
        1 0          276 S    {busybox} sh
        2 0            0 SW   [kthreadd]
        3 0            0 IW   [kworker/0:0]
        4 0            0 IW<  [kworker/0:0H]
        5 0            0 IW   [kworker/u2:0]
        6 0            0 IW<  [mm_percpu_wq]
        7 0            0 SW   [ksoftirqd/0]
        8 0            0 IW<  [writeback]
        9 0            0 IW<  [watchdogd]
       10 0            0 IW   [kworker/0:1]
       11 0            0 SW   [kswapd0]
       12 0            0 SW   [irq/31-40002800]
       13 0            0 SW   [irq/32-40004800]
       16 0            0 IW   [kworker/u2:1]
       21 0            0 IW   [kworker/u2:2]
       23 0          260 R    ps

    # grep -v " 0 kB" /proc/meminfo
    MemTotal:            644 kB
    MemFree:              92 kB
    MemAvailable:         92 kB
    Cached:               24 kB
    MmapCopy:             92 kB
    KernelStack:          64 kB
    CommitLimit:         320 kB

看吧! 尽管没有达到我们 512KB 内存的目标,但是 768KB 已经比较接近了。有些微控制器已经有超过这个数量的片上静态内存了。

不复杂的提升工作仍然存在。我们可以看到在16个进程中,有14个是内核进程,各自使用了 4KB 的栈。其中一些进程肯定可以去除。然后在进行一轮内存页的分析,能透露出更多可以被优化的部分,等等。而且,专用的程序并不需要产生新的子进程,它也只需要更少的内存去运行。毕竟,一些流行的控制器用这里我们剩余的空闲内存就来实现互联网的连接功能。

总结

这里至少有一个重要的点可以从这个项目中学习到。精简内核内存使用量比精简内核代码要容易的多。因为,代码已经被高度优化过,而且代码对系统性能有直接的影响,即使在大型系统上。但是内存使用量却是不一样的。RAM 在大型系统上变得相对偏移,在操作上,即使浪费一些也没有关系。因此,在优化内存使用量的工作中,有很多唾手可得成果。

除了这些小的调整和快速的修改,其他重要的部分如(XIP 内核,XIP 用户态,甚至一些设备内存使用量精简)都已经在主干版本中。但是为了让 Linux 跑在微小的设备上仍需要进一步工作。这个工作的进度,永远依赖于人们对使用上的期待和构建一个社区去推动开发的愿景。

[感谢 Linaro 组织允许我投入时间在这个项目和写作这个文章上]