面向 Linux 程序员的 uClinux

作者:David McCullough

uClinux 的普及程度大幅提升,并且比以往任何时候都更多地出现在商品设备中。它在路由器(图 1)、网络摄像头甚至 DVD 播放器中的应用证明了它的多功能性。能够运行 uClinux 的低成本 32 位 CPU 的爆炸式增长,为考虑 uClinux 的制造商提供了更多选择。现在,随着 uClinux 作为 2.6 内核的一部分首次亮相,它必将变得更加流行。

uClinux for Linux Programmers

图 1. SnapGear LITE2 VPN/路由器运行 uClinux。

随着越来越多的嵌入式开发人员面临使用 uClinux 的可能性,一份关于 uClinux 与 Linux 的区别以及其陷阱和缺陷的指南将是非常宝贵的工具。本文中,我们将讨论开发人员在使用 uClinux 时可能遇到的变化,以及环境如何引导开发过程。

无内存管理

uClinux 与其他 Linux 系统之间最明显的和最主要的区别是缺乏内存管理。在 Linux 下,内存管理是通过使用虚拟内存 (VM) 实现的。uClinux 是为不支持 VM 的系统创建的。由于 VM 通常使用称为 MMU(内存管理单元)的处理单元来实现,因此您经常在 uClinux 圈子中听到术语 NOMMU。

使用 VM,所有进程都在相同的地址(尽管是虚拟地址)上运行,VM 系统负责将哪些物理内存映射到这些位置。因此,即使进程看到的虚拟内存是连续的,但它占用的物理内存也可能是分散的。其中一些甚至可能在硬盘上的交换空间中。由于可以任意定位的内存可以映射到进程地址空间中的任何位置,因此可以向已运行的进程添加内存。

在没有 VM 的情况下,每个进程都必须位于内存中可以运行的位置。在最简单的情况下,这片内存区域必须是连续的。通常,它无法扩展,因为其上方和下方可能还有其他进程。这意味着 uClinux 中的进程无法像传统的 Linux 进程那样在运行时增加其可用内存的大小。

虽然所有程序都需要在运行时重新定位才能执行,但这对于开发人员来说是一项相当透明的任务。真正困扰每个 uClinux 开发人员的是没有 VM 的直接影响。最终结果是,不提供任何类型的内存保护——任何应用程序或内核都可能损坏系统的任何部分。某些 CPU 架构允许保护某些 I/O 区域、指令和内存区域免受用户程序的影响,但这并不能保证。比导致系统崩溃的损坏更糟糕的是未被注意到的损坏,并且跟踪随机的进程间损坏可能极其困难。

在没有 VM 的情况下,交换空间实际上是不可能的,尽管这种限制在运行 uClinux 的系统类型上很少成为问题。它们通常没有硬盘驱动器或足够的内存来使交换空间值得使用。

内核差异

对于内核开发人员来说,uClinux 与 Linux 相比几乎没有区别。唯一真正的问题是您无法利用 MMU 提供的分页支持。实际上,这不会影响内核的太多部分。例如,tmpfs 在 uClinux 上不起作用,因为它依赖于 VM 系统。

同样,所有标准可执行格式都不受支持,因为它们使用了 uClinux 下不存在的 VM 功能。相反,需要一种新的格式,即平面格式。平面格式是一种精简的可执行格式,仅存储可执行代码和数据,以及将可执行文件加载到内存中任何位置所需的重定位信息。

当您迁移到 uClinux 时,设备驱动程序通常需要进行一些工作,这并不是因为内核存在差异,而是因为内核需要支持的设备类型不同。例如,SMC 网络驱动程序支持 ISA SMC 卡。它们通常是 16 位的,并且位于 0x3ff 以下的 I/O 地址。可以很容易地使同一个驱动程序支持该芯片的非 ISA 嵌入式版本,但它可能需要在 8 位、16 位或 32 位模式下运行,I/O 地址是一个完整的 32 位地址,并且中断号通常高于 ISA 的最大值 16。因此,尽管驱动程序的大部分内容是相同的,但硬件细节可能需要一些移植工作。通常,较旧的驱动程序以短格式存储 I/O 地址,这在设备出现在内存映射 I/O 地址的嵌入式 uClinux 平台上不起作用。

内核中 mmap 的实现方式也大相径庭。虽然通常对开发人员是透明的,但仍需要了解它,以免在 uClinux 系统上以效率特别低下的方式使用它。除非 uClinux mmap 可以直接指向文件系统中的文件,从而保证它是顺序且连续的,否则它必须分配内存并将数据复制到已分配的内存中。在 uClinux 下高效使用 mmap 的要素非常具体。首先,目前唯一可以保证文件连续存储的文件系统是 romfs。因此,必须使用 romfs 以避免分配。其次,只有只读映射才能共享,这意味着映射必须是只读的才能避免内存分配。出于这个原因,uClinux 下的开发人员无法利用写时复制功能。内核还必须将文件系统视为“在 ROM 中”,这意味着 CPU 地址空间中名义上的只读区域。如果文件系统存在于 RAM 或 ROM 中的某个位置,则这是可能的,这两者都可以由 CPU 直接寻址。如果文件系统在硬盘上,即使它是 romfs 文件系统,也无法进行零分配 mmap,因为 CPU 无法直接寻址其内容。

内存分配(内核和应用程序)

uClinux 提供了两种内核内存分配器供选择。起初,可能不明显为什么需要替代内核内存分配器,但在小型 uClinux 系统中,这种差异是痛苦地显而易见的。Linux 下的默认内核分配器使用 2 的幂分配方法。这有助于它更快地运行并快速找到正确大小的内存区域以满足分配请求。不幸的是,在 uClinux 下,应用程序必须加载到此分配器预留的内存中。为了理解这造成的后果,特别是对于大型分配,请考虑这样一个情况:一个需要 33KB 分配才能加载的应用程序实际上分配到下一个 2 的幂,即 64KB。分配的 31KB 额外空间无法有效利用。这种程度的内存浪费在大多数 uClinux 系统上是不可接受的。为了解决这个问题,为 uClinux 内核创建了一个替代内存分配器。它通常被称为 page_alloc2 或 kmalloc2,具体取决于内核版本。

page_alloc2 通过对大小不超过一页(一页为 4,096 字节或 4KB)的分配使用 2 的幂分配器来解决 2 的幂分配造成的浪费。然后,它分配向上舍入到最接近页面的内存。对于之前的示例,一个 33KB 的应用程序实际上分配了 36KB;对于 33KB 的应用程序,可以节省 28KB。

page_alloc2 还采取措施避免内存碎片。它从内存的开头向上分配所有两页(8KB)或更少的量,并从可用内存的末尾向下分配所有更大的量。这阻止了用于网络缓冲区等的瞬时分配,碎片化内存并阻止大型应用程序运行。有关内存碎片的更详细示例,请参见下面的“应用程序和进程”部分中的示例。page_alloc2 并非完美,但它在实践中效果良好,因为运行 uClinux 的嵌入式环境往往具有相对静态的长期运行应用程序组。

一旦开发人员克服了内核内存分配的差异,真正的变化就会出现在应用程序空间中。这就是 uClinux 缺乏 VM 的全部影响得以实现的地方。最有可能导致应用程序在 uClinux 下失败的第一个主要差异是缺乏动态堆栈。在 VM Linux 上,每当应用程序尝试写入堆栈顶部之外时,都会标记异常,并在堆栈顶部映射更多内存以允许堆栈增长。在 uClinux 下,没有这种奢侈可用,因为堆栈必须在编译时分配。这意味着以前对应用程序中的堆栈使用情况一无所知的开发人员现在必须意识到堆栈需求。当面对新移植的应用程序的奇怪崩溃或行为时,开发人员应考虑的第一件事是分配的堆栈大小。默认情况下,uClinux 工具链为堆栈分配 4KB,这对于现代应用程序来说几乎不算什么。开发人员应尝试使用以下方法之一增加堆栈大小

  1. 添加FLTFLAGS = -s <堆栈大小>export FLTFLAGS到应用程序的 Makefile 中,然后再构建。

  2. 运行flthdr -s <堆栈大小> 可执行文件在应用程序构建完成后。

第二个让 uClinux 开发人员感到震惊的主要差异是缺乏动态堆,即用于使用 C 语言中的 malloc 和相关函数满足内存分配的区域。在具有 VM 的 Linux 上,应用程序可以增加其进程大小,从而使其具有动态堆。这传统上是在低级别使用 sbrk/brk 系统调用实现的,这些调用会增加/更改进程地址空间的大小。然后,堆的管理由库函数(如 malloc)执行,这些函数在调用 sbrk() 代表应用程序获得的额外内存上进行。如果应用程序在任何时候需要更多内存,它只需再次调用 sbrk() 即可获得更多内存;它也可以使用 brk() 减少内存。sbrk() 的工作原理是在进程末尾添加更多内存(增加其大小)。brk() 可以任意设置进程的末尾更靠近进程的开头(减小进程大小)或更远(增加进程大小)。

由于 uClinux 无法实现 brk 和 sbrk 的功能,因此它实现了全局内存池,该内存池基本上是内核的空闲内存池。这种方法存在一些缺陷。例如,失控的进程可能会使用系统的所有可用内存。从系统池分配与 sbrk 和 brk 不兼容,因为它们需要将内存添加到进程地址空间的末尾。因此,正常的 malloc 实现是不好的,需要一种新的实现。

全局池方法有一些优点。首先,只使用实际需要的内存量,这与某些嵌入式系统使用的预分配堆系统不同。这在通常在少量内存下运行的 uClinux 系统上极其重要。另一个优点是,一旦内存使用完毕,就可以将其返回到全局池,并且该实现可以利用现有的内核分配器来管理此内存,从而减小应用程序代码的大小。

新用户遇到的常见问题之一是内存丢失问题。系统显示大量可用内存,但应用程序无法分配大小为 X 的缓冲区。这里的问题是内存碎片,而目前所有可用的 uClinux 解决方案都存在这个问题。由于 uClinux 环境中缺乏 VM,几乎不可能充分利用内存,因为存在碎片。这最好用例子来解释。假设系统有 500KB 的可用内存,并且希望分配 100KB 来加载应用程序。很容易认为这是可能的。但是,重要的是要记住,必须有一个连续的 100KB 内存块才能满足分配。假设内存映射看起来像这样。每个字符代表大约 20KB,X 标记由其他程序或内核分配或使用的区域

  0    100   200   300  400   500   600   700  800   900   1000
 -+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+--
  |XXXXX|XXXXX|---XX|--X--|-X---|XX---|-X---|-XX--|-X---|XXXXX|

在这种情况下,有 500KB 可用,但最大的连续块只有 80KB。有很多方法可以达到这种情况。一个程序分配了一些内存,然后释放了大部分内存,在较大的空闲块中间留下了一个小的分配,这通常是原因。uClinux 下的瞬时程序也会影响内存的分配位置和方式。uClinux page_alloc2 内核分配器有一个配置选项,可以帮助识别此问题。它启用了一个新的 /proc 条目,/proc/mem_map,它显示页面及其分配分组。记录这一点超出了本文的范围,但有关更多信息,请参见内核源代码 page_alloc2.c。

经常有人问,为什么不能对内存进行碎片整理,以便可以加载 100KB 的应用程序?问题是我们没有 VM,并且无法移动程序正在使用的内存。程序通常具有对已分配内存区域内地址的引用,并且如果没有 VM 使内存始终看起来位于正确的地址,则如果我们移动其内存,程序将崩溃。在 uClinux 下,这个问题没有解决方案。开发人员需要意识到这个问题,并在可能的情况下,尝试使用较小的分配块。

应用程序和进程

VM Linux 和 uClinux 之间的另一个区别是缺少 fork() 系统调用。当移植使用 fork() 的应用程序时,这可能需要开发人员进行大量工作。在 uClinux 下,唯一的选择是使用 vfork()。虽然 vfork() 与 fork() 有许多共同的属性,但不同之处才是最重要的。

对于那些不熟悉这些系统调用的人来说,fork() 和 vfork() 允许进程拆分为两个进程,一个父进程和一个子进程。一个进程可以多次拆分以创建多个子进程。当进程调用 fork() 时,子进程在所有方面都是父进程的副本,但它与父进程不共享任何内容,并且可以独立运行,父进程也可以独立运行。对于 vfork(),情况并非如此。首先,父进程被挂起,并且在子进程退出或调用 exec()(用于启动新应用程序的系统调用)之前无法继续执行。子进程在从 vfork() 返回后,直接在父进程的堆栈上运行,并使用父进程的内存和数据。这意味着子进程可能会损坏父进程中的数据结构或堆栈,从而导致失败。通过确保子进程在调用 vfork() 后永远不会从当前堆栈帧返回,并在完成时调用 _exit 来避免这种情况——不能调用 exit,因为它会更改父进程中的数据结构。子进程还必须避免更改全局数据结构或变量中的任何信息,因为此类更改可能会破坏父进程的执行。

使应用程序使用 vfork 而不是 fork 通常属于绝对简单或极其困难的类别。通常,如果应用程序没有 fork,然后几乎立即 exec(),则在将 fork() 替换为 vfork() 之前,需要仔细检查该应用程序。

uClinux 平面可执行格式虽然不直接影响应用程序及其操作,但确实允许 Linux 下通常的 ELF 可执行文件不具备的许多选项。平面格式可执行文件有两种基本类型,完全重定位的和位置无关代码 (PIC) 的变体。完全重定位的版本具有其代码和数据的重定位,而 PIC 版本通常只需要对其数据进行少量重定位。

嵌入式开发人员最有利的功能之一是就地执行 (XIP)。这是指应用程序直接从闪存或 ROM 执行,只需要最少的内存,因为只需要应用程序数据的内存。这允许应用程序的文本或代码部分在应用程序的多个实例之间共享。并非所有 uClinux 平台都能够进行 XIP,因为它需要编译器支持和平面可执行文件的 PIC 形式。因此,除非给定平台的工具链可以执行 PIC,否则它无法执行 XIP。目前,只有 m68k 和 ARM 工具链为平面格式 XIP 提供了所需的级别支持。romfs 是 uClinux 下唯一支持 XIP 的文件系统,因为应用程序必须在文件系统中连续存储才能实现 XIP。

平面格式还在平面标头中将应用程序的堆栈大小定义为一个字段。要增加分配给应用程序的堆栈,只需简单地更改此字段即可。这可以使用 flthdr 命令完成,如下所示

flthdr -s  flat-executable

平面格式还允许两种压缩选项。可以压缩整个可执行文件,从而最大限度地节省 ROM 空间。它还提供了一个通常有用的副作用,即应用程序完全加载到连续的 RAM 块中。您也可以选择仅压缩数据段。如果您想节省 ROM 空间但仍然希望利用 XIP 选项,这很重要。以下

flthdr -z flat-executable

创建一个完全压缩的可执行文件,并且

flthdr -d flat-executable

仅压缩数据段。

共享库

虽然对共享库的完整讨论超出了本文的范围,但它们在 uClinux 下却大相径庭。当前可用的解决方案需要编译器更改和开发人员的谨慎。创建共享库的最佳方法是从示例开始。当前的 uClinux 发行版为 uC-libc 和 uClibc 库都提供了共享库。创建共享库的方法并不困难,并且这两个库都提供了关于如何完成它的良好、清晰的示例。为了适当地设置预期,GCC -shared 选项不是共享库创建过程的一部分,因此不要期望它会很熟悉。uClinux 下的共享库是平面格式可执行文件,就像应用程序一样,并且要真正共享,必须为 XIP 编译。如果没有 XIP,共享库会导致每个使用它的应用程序都复制一份完整的库,这比静态链接应用程序更糟糕。

总结

从 Linux 过渡到 uClinux 通常不仅仅是 uClinux 和 Linux 之间的差异。uClinux 系统往往是更深入的嵌入式系统,具有更小的内存和 ROM footprint 以及一系列不寻常的设备。硬盘驱动器的丢失和严格的资源限制,加上没有内存保护和许多其他细微的差异,可能会使开发人员的第一次 uClinux 冒险比想象的更困难。入门的最佳方法是查看可用的 uClinux 模拟器(图 2)和廉价硬件(图 3)选项。

uClinux for Linux Programmers

图 2. 在 Xcopilot (Palm 模拟器) 下运行的 uClinux

uClinux for Linux Programmers

图 3. 在真正的 Palm IIIx 上运行的 uClinux(使用 Microwindows)

希望突出这些问题将有助于谨慎的开发人员提前做好准备,并避免使用 uClinux 时的一些常见陷阱和误解。

本文资源: /article/7546

David McCullough 是一位资深软件工程师和经验丰富的嵌入式软件开发人员。在 SnapGear 和 Lineo 工作之前,他曾在 Stallion Technologies 担任软件开发和工程管理职位,并参与了基于 SCO 和 BSD UNIX 的产品的开发。David 多年来在 SCO UNIX 上移植和维护了 XFree86,并且最近在 Linux 2.6 的 uClinux 移植的开发中发挥了重要作用。

加载 Disqus 评论