裸机上的 printf:没有操作系统的 C 标准库

发布时间:2025年4月26日 | 下午12:00

Follow @popovicu94

今天,我们将探讨如何利用 Newlib 为裸机系统创建一个精简的 C 标准库。在一个小型示例中,我们将实现一些 UART 原语,并将它们传递给 Newlib,Newlib 将使用它们作为构建完整 printf 功能的基础。目标平台将是 RISC-V,但通常情况下,这些概念也适用于其他平台。

目录

展开目录

软件抽象和 C 标准库

在典型的、完全运行的最终用户系统(例如,Mac 或 Linux 笔记本电脑)上运行 printf 时,我们会调用一个非常复杂的机制。应用程序进程调用 printf 函数,该函数通常是动态链接的,并且在经过几层不同的 C 函数之后,通常会调用操作系统内核的系统调用。内核会将输出通过不同的子系统进行路由:将调用不同的终端和伪终端原语,并且在某些时候,您还希望在屏幕上直观地看到 printf 输出。这很可能也会调用一个相当厚的抽象层,以便在屏幕上渲染字符。我们甚至不讨论 printf 如何基于提供的模板格式化输出字符串。

然而,在裸机系统上,大多数这些抽象都根本不可用,并且调用栈要薄得多。

裸机上的 C 标准库

如果我们在裸机上工作,那么在我们的 C 函数之下没有任何东西支持我们。在上面的完整示例中,进程将通过系统调用将输出传递给内核,系统调用是通过软件中断实现的。但是,现在我们没有任何东西可以传递,但我们希望有像 printf 这样的东西可以工作,理想情况下是输出到像 UART 这样的简单 I/O 设备。

这就是 Newlib 发挥作用的地方。您可能熟悉不同风格的 C 标准库,例如 GNU (glibc)、musl 等等,但是如果您想在裸机上启用 C 标准库,那么 Newlib 绝对应该在您的考虑范围内。

更准确地说,我认为 Newlib 不是一个 C 标准库,而是一个用于构建自定义、紧凑的 C 标准库的工具包

Newlib 概念

Newlib 不是要求您从头开始实现整个 C 标准库,而是将实现简化为几个非常基本的原语,这些原语具有干净的接口,可以实现为单独的函数,然后像 printfmalloc 这样更复杂的函数将调用这些原语。仅仅为了直观起见,我们将实现像 _write 这样的原语,它本质上是将单个字符写入输出流,而 Newlib 在此基础上构建 printf 以便写入更复杂的输出。

除了提供这一组简单的原语来实现之外,Newlib 还提供了合理的预制实现。在其中一个配置中,您甚至仍然可以将 Linux 作为底层平台,而不是裸机,并且提供的实现将执行像 glibc 那样的系统调用。此外,如果您要进行绝对最小的配置,Newlib 将以最小的形式提供所有原语,其中它们仅返回零或引发错误(相当于在 Python 或 Java 中引发未实现的异常)。

无论哪种方式,您都将实现您实际关心的应用程序中的任何构建块,其余的将依赖于默认实现。

交叉编译工具链

让我们在这里转换一下话题,谈谈交叉编译工具链。当您从一个平台编译到另一个平台时,就会发生交叉编译。直观地,您可以想到从 x86_64/Linux 平台交叉编译到 ARM64/Mac 之类的东西。

但是,在像 Linux 这样的平台上,事情可能会变得更加微妙,因为 Linux 平台并不一定意味着一种 C 标准库风格,因此我更准确地将这些平台称为 x86_64/Linux/glibc。当您从该角度看待平台时,即使是从具有一个标准库的平台编译到同一 x86_64/Linux 设置上的另一个标准库,但具有不同的 C 库,实际上仍然是在进行交叉编译。一个具体的例子是从 x86_64/Linux/glibc 交叉编译到 x86_64/Linux/musl。

此外,如果您想非常准确和严谨(如果您想构建不会崩溃的软件,就应该这样做!),即使是从一个版本的 glibc 构建到另一个版本,实际上也是交叉编译。同样,作为一个例子,从 x86_64/Linux/glibc_v1.0 为 x86_64/Linux/glibc_v1.1 构建就是交叉编译。

这可能会很快变得困难,至少使用传统的构建和使用编译器的方式(例如,GCC);但是,这些“古老”的方式在可预见的未来仍然与我们同在。我很快会更详细地写关于这一点,现在,我们将使用下面描述的快捷方式。

工具链细节

我们想要一个满足两个要求的工具链:

  1. 它从我们的宿主平台构建到 RISC-V,即它生成 RISC-V 指令
  2. 当调用 C 标准库功能时,它使用 Newlib 库

如果您使用的是典型的 Linux 发行版,那么您很可能安装了 GCC(甚至 clang)之类的东西。当您在 C 文件上简单地运行 GCC 而没有任何花哨的标志时,发生的事情是编译器将简单地为它运行的同一平台构建。更准确地说,宿主和目标是相同的,我相信这的正式术语是原生编译。我之所以提出这一点,是为了问问自己,当我们包含像 stdio.h 这样的东西并调用像 printf 这样的东西时会发生什么?这个 .h 文件实际上是从哪里提取的,最终在哪里找到 printf 实现,以便可以链接到它?

这实际上取决于您的编译器构建的方式。当您从源代码构建 GCC 时,并且运行 ./configure,您可以指定大量的标志来驱动此行为。正如承诺的那样,我将来会写更多关于它的内容。现在,让我们记住,我们在日常生活中使用的大多数 Linux 发行版都遵循旧的 UNIX 哲学。例如,我的 Debian 安装在 /usr/include 的标准目录中有一个 stdio.h。此外,我的可以动态链接的标准 C 库 (glibc) 位于 /lib/x86_64-linux-gnu/libc.so(实际上指向 /lib/x86_64-linux-gnu/libc.so.6)。类似地,那里有一个 .a 文件,但我假设您知道 .so.a 文件是做什么用的。所以,长话短说,跳过很多细节,您的原生编译器设置为在一些标准位置查找 C 库,并且当它为同一平台构建时,它只是从那里获取库。

因此,我们需要:

  1. 获取可以为所需平台(机器代码)生成指令的编译器
  2. 为该特定平台设置 C 标准库在某个地方
  3. 确保目标平台的编译器知道如何使用上面的库

从我所看到的,当涉及到交叉编译时,这是一项相当繁琐的工作,需要完成。上面的设置需要大量的构建时间,并且需要分阶段正确完成。未来的文章将详细介绍,但现在,正如我们之前提到的,我们将选择一个快捷方式。

现在请记住,我们想要在某个路径中包含 include、.so/.a 文件,并且我们希望交叉编译器在那里查找 C 标准库,而不是在宿主的 includelib 目录中。在这种情况下,由于我们想从类似 x86_64 的东西构建到 RISC-V,因此很容易发现错误,因为如果我们使用宿主的库,错误的体系结构不可能工作,但是当为同一体系结构和不同的软件平台编译时,宿主污染可能是一个真实的事情,并可能导致非常微妙和烦人的问题!例如,我们希望在 /usr/local/risc_v_stuff/lib 而不是 /usr/lib 中搜索库代码。

自动化的 RISC-V 工具链构建

对于此练习,让我们简单地使用 RISC-V 工具链。此项目仍将在我们的宿主机上从源代码构建所有内容,但上面提到的所有烦人的编排,包括暂存编译器,都将为我们编写脚本并自动化。只需几个命令,我们将启动该过程,该过程将有效地设置像 /usr/local/risc_v_stuff/lib/usr/local/risc_v_stuff/include/usr/local/risc_v_stuff/compiler 这样的东西,并且您将能够调用 /usr/local/risc_v_stuff/compiler/gcc,它将知道窥视正确的目录以查找不同的文件,并将构建正确的机器代码。当然,路径最终会有所不同,但这应该足以作为一个概念。

我们可以从克隆上面链接的 Git 存储库开始。说明表明在克隆期间不需要 --recursive 标志,并且稍后将动态拉取内容,但出于某种原因,这在我的系统上不起作用。我最终使用 --recursive 标志运行克隆以避免问题。不过,它花费了大量的时间和空间,拉入了数 GB 的源代码。

一旦无休止的克隆完成,您就可以配置构建。这是我配置它的方式:

./configure --prefix=/opt/riscv-newlib --enable-multilib --disable-gdb --with-cmodel=medany

我强烈建议您运行 ./configure --help 以查看所有可用选项并自定义构建。现在,我将解释我的参数:

  1. prefix 只是我们将安装新构建的工件的位置,例如交叉编译器、C 标准库(在我们的例子中是 Newlib)等等。
  2. enable-multilib 将启用针对不同 RISC-V 设置的构建。提醒一下,RISC-V 有很多风格,例如 RV32IRV32IMA 等等。请注意,启用此标志将使您的构建超级慢。如果您不想使用 multilib 运行构建,请检查帮助菜单以了解如何精确地为所需的细粒度平台构建。
  3. disable-gdb:出于某种原因,为我构建 GDB 总是失败,所以我只是将其从工具链中排除。真正的工程师反正都使用 printf 进行调试!
  4. with-cmodel:请抓住这个,我将在“坑”的时刻揭示这一点;现在,让我们记住我需要这个才能使 64 位的 RISC-V 构建工作。

现在您的构建已配置,您可以启动构建过程并让它运行相当长的时间。这里让我感到惊讶的一件事是他们没有使用单独的 makemake install 步骤。一切都通过 make 完成,包括编译和安装工件。

注意:我想使用 -j16 并行化构建,就像我通常做的那样,但这也有点破坏了我的构建,所以我建议在没有它的情况下运行,是的,我知道这需要很长时间。

我只是运行了

sudo make

以便将最终工件放置在 /opt 目录中。整个过程非常缓慢,因此请确保您在此工作时还有其他事情要做。

另外,您可能想知道 Newlib 在这里从何而来,当我提到整个过程将自动化我们如何获得 Newlib 可用时。答案是 Newlib 只是我们在此构建工具链的默认选项。您可以查看 GitHub 文档,了解如何设置交叉编译器以针对 RISC-V glibcmusl,但我上面列出的足以获得一个以 Newlib 为目标的交叉编译器。

GitHub 链接

我已经准备好了此存储库来运行我们的示例。代码说明如下(希望它们与存储库本身不同步)。

实现内存和 UART 构建块

现在您有了一个可以工作的、以 RISC-V + Newlib 为目标的交叉工具链,大部分繁重的工作已经完成,我们可以开始将 Newlib 构建块放在一起。让我们从 UART 开始,第一个文件是 uart.h

#ifndefUART_H
#defineUART_H
voiduart_putc(charc);
charuart_getc(void);
#endif

到目前为止,这是不言自明的。让我们看看这些函数是如何实现的:

#include"uart.h"
// QEMU UART registers - these addresses are for QEMU's 16550A UART
#defineUART_BASE0x10000000
#defineUART_THR (*(volatilechar*)(UART_BASE +0x00)) // Transmit Holding Register
#defineUART_RBR (*(volatilechar*)(UART_BASE +0x00)) // Receive Buffer Register
#defineUART_LSR (*(volatilechar*)(UART_BASE +0x05)) // Line Status Register
#defineUART_LSR_TX_IDLE (1<<5) // Transmitter idle
#defineUART_LSR_RX_READY (1<<0) // Receiver ready
voiduart_putc(char c) {
// Wait until transmitter is idle
while ((UART_LSR & UART_LSR_TX_IDLE) ==0);
  UART_THR = c;
// Special handling for newline (send CR+LF)
if (c =='\n') {
while ((UART_LSR & UART_LSR_TX_IDLE) ==0);
    UART_THR ='\r';
  }
}
charuart_getc(void) {
// Wait for data
while ((UART_LSR & UART_LSR_RX_READY) ==0);
return UART_RBR;
}

上面的代码是 AI 生成的,但它是准确的。这就是我们 UART 驱动程序所关心的。这现在如何与 Newlib 一起工作?

我们切换到名为 syscalls.c 的文件。在这里,我们实现 printf 将依赖的函数。我们还将处理输入,只是为了好玩。首先,这里发生的事情是我们实现了用于写入文件句柄的原语。我们真正支持的唯一文件句柄是 stdoutstderr。为了完全准确起见,这里没有文件;我们只是拦截了否则将使用这些概念的 C 标准库调用。

更进一步,我们为更多构建块提供了超小的实现。它们非常基本,比如 _close 函数,它本质上不允许关闭任何文件句柄。

这里非常有趣的一个构建块是 _sbrk。当动态内存分配的例程(如 malloc)(printf 系列函数需要)需要请求操作系统(如果有)向进程提供更多原始内存时,就会调用它,然后 malloc 可以将这些内存分成更小的逻辑单元。这里发生的事情是我们找到链接器定义的符号 _end,它标记了静态部分的 _end(我们将在下面看到如何),并且我们开始使用该地址之后的内存进行堆分配,一直到我们命中堆栈为止。一旦我们命中堆栈,我们就声明一个错误,因为我们的内存已用完。

void*_sbrk(intincr) {
externchar _end;     // Defined by the linker - start of heap
externchar _stack_bottom; // Defined in our linker script - bottom of stack area
staticchar*heap_end =&_end;
char*prev_heap_end = heap_end;
// Calculate safe stack limit - stack grows down from _stack_top towards _stack_bottom
char*stack_limit =&_stack_bottom;
// Check if heap would grow too close to stack
if (heap_end + incr > stack_limit) {
    errno = ENOMEM;
return (void*) -1; // Return error
  }
  heap_end += incr;
return (void*) prev_heap_end;
}

请注意,这里的堆栈顶部和底部是指为堆栈分配的内存块的开始和结束,而不是从应用程序角度来看的堆栈本身的逻辑顶部或底部。

应用示例:输入和输出

我们现在准备将实际的裸机应用程序放在一起。如果您需要复习 RISC-V 上的裸机编程,请再次查看。该文章涵盖了关键地址以及将裸机链接器脚本放在一起的基础知识。

应用程序代码本身非常不言自明:

#include<stdio.h>
intmain(void) {
printf("Hello from RISC-V UART!\n");
charbuffer[100];
printf("Type something: ");
scanf("%s", buffer);
printf("You typed: %s\n", buffer);
while (1) {}
return0;
}

请注意,当我们向此应用程序输入某些内容时,我们不会看到我们的按键回显。这是因为我们没有在某种 shell 环境中运行。当前实现将简单地接受按键并将它们存储在内部内存结构中。当我们点击最终的 printf 时,我们将看到输入的内容。

我们现在还需要组装一个小的 C 运行时。当我们为日常操作系统开发二进制文件时,我们通常不必考虑这一点,并且编译器将注入标准的启动运行时,该运行时负责设置进程以进行正确执行并将控制权传递给 main 函数。

我们的极简运行时将设置堆栈指针寄存器,按照 C 标准将 BSS 节清零,然后调用 main 代码。为了安全起见,我们还在末尾留下一个无限循环,以防 main 返回。同样,在我们的代码下有一个适当的操作系统的情况下,将调用一个系统调用以正确关闭进程,并且它不会只是无限循环。

.section .text.init
.global _start
_start:
  la sp, _stack_top
 # Clear BSS section - using symbols defined in our linker script
  la t0, _bss_start
  la t1, _bss_end
clear_bss:
  bgeu t0, t1, bss_done
  sb zero, 0(t0)
  addi t0, t0, 1
  j clear_bss
bss_done:
 # Jump to C code
call main
 # In case main returns
1: j 1b

现在我们应用程序最重要的部分之一是链接器脚本:

OUTPUT_FORMAT("elf64-littleriscv")
OUTPUT_ARCH("riscv")
ENTRY(_start)
MEMORY
{
 RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 64M
}
SECTIONS
{
 /* Code section */
 .text : {
  *(.text.init)
  *(.text)
 } > RAM
 /* Read-only data */
 .rodata : {
  *(.rodata)
 } > RAM
 /* Initialized data */
 .data : {
  *(.data)
 } > RAM
 /* Small initialized data */
 .sdata : {
  *(.sdata)
 } > RAM
 /* BSS section with explicit symbols */
 .bss : {
  _bss_start = .; /* Define BSS start symbol */
  *(.bss)
  *(COMMON)
  . = ALIGN(8);
  _bss_end = .;  /* Define BSS end symbol */
 } > RAM
 /* Small BSS section */
 .sbss : {
  _sbss_start = .;
  *(.sbss)
  *(.sbss.*)
  . = ALIGN(8);
  _sbss_end = .;
 } > RAM
 /* End marker for heap start */
 . = ALIGN(8);
 _end = .; /* Heap starts here and grows upwards */
 /* Stack grows downward from the end of RAM */
 _stack_size = 64K;
 _stack_top = ORIGIN(RAM) + LENGTH(RAM);
 _stack_bottom = _stack_top - _stack_size;
 /* Ensure we don't overlap with heap */
 ASSERT(_end <= _stack_bottom, "Error: Heap collides with stack")
}

根据我们之前对 QEMU VM 的裸机编程的调查,我们知道用户提供的代码将从 0x80000000 开始执行。因此,我们所做的是将我们先前编写的 C 运行时代码放在那里。换句话说,我们的汇编代码将植入到该内存地址。在我们的 C 运行时之后是其余的代码,即 text 节。在这种情况下,这是我们编写的 C 代码以及我们链接到二进制文件中的 C 标准库。

之后,我们放置其他节,如 rodatadatabss 等等。链接器脚本将捕获 BSS 开始和结束的符号,以便 C 运行时可以将其清零,如上所示。还有一个小的 BSS 节,但 C 运行时代码不会对其执行任何操作,以保持紧凑,因为它没有被应用程序使用。它可能也应该被清零。

然后,我们捕获小 BSS 节的结束位置,因为这也标志着我们的静态节的结束。在此之后,我们让增长的堆消耗所有内容,直到堆栈为止。堆栈占用最后 64K 的内存(并且我们在链接器脚本的顶部将 RAM 描述为 64M)。执行一些加法和减法运算以确定这到底在哪里,并且我们进行断言检查以确保堆和堆栈之间没有冲突。

这个概念很简单:我们识别静态节和堆栈之间的“空隙”,并且我们让通过 Newlib 组装的 C 标准库在其中维护增长的堆。像 Linux 这样的真实内核会在这里进行其内存管理魔术,处理虚拟地址等等,但是这里我们真的有一个“进程”,并且一个简单的内存扩展操作 sbrk 对于我们想要实现的目标来说已经足够了。

“坑”的时刻

现在让我们回顾一下我们将工具链配置为使用 --with-cmodel=medany 标志构建的事实。此标志实际控制什么,以及我们为什么需要它?

如果您仔细阅读链接器脚本的顶部,我们会为 64 位 RISC-V 机器构建。根据 QEMU,我们的指令将从 0x80000000 开始,我们决定简单地将其余代码放置在其后。为了处理这些高值,我们需要使用正确的机器指令来处理这些高地址。因此,我们的应用程序代码可能需要使用可以处理任何地址的内存地址模型,因此我们使用 -mcmodel=medany 构建我们的逻辑。为了兼容,我们的 C 标准库也需要它。

如果我们没有上述标志,则 Newlib 库将使用无法有效使用如此高地址的 RISC-V 指令构建。请记住,C 标准库是在我们的应用程序之前预先构建的。构建系统将简单地从相关库目录中拾取机器代码,并将其链接到您的应用程序代码。如果地址不适合指令支持的值范围,则链接器将无法使其工作。

据我了解,存在一个链接器松弛的概念,其中链接器本身可以进行代码修改,但我不认为它在这种情况下会有所帮助。

我不想在这上面花费太多时间,我希望上面的解释足以满足,如果您想了解更多关于此问题的信息,请查看此链接,其中报告者有链接器错误并提供了解决方案。

运行应用

我在 GitHub 存储库中包含了一个 Makefile。查看它以了解那里到底发生了什么,特别是如何调用交叉编译器的(应该是文件的第一行),以及用于仿真的 QEMU。我将在这里突出显示一些事情。

其中一个 CFLAGS-specs=nosys.specs。这将驱动工具链使用 Newlib 的“nosys”风格。这是最简约的风格,其中所有构建块默认只是返回零或错误的存根。

链接器标志包括 -nostartfiles,这意味着我们将提供我们自己的最小 C 运行时,我们已经在上面描述过。

Makefile 的其余部分应该相当容易理解。我强烈建议使用 debug 目标。我们将继续运行:

makedebug

QEMU 进程启动,我输入 foo 并按 Enter,并在取回应用程序的输出后,我停止 QEMU:

$makedebug
/opt/riscv-newlib/bin/riscv64-unknown-elf-gcc-march=rv64imac_zicsr-mabi=lp64-mcmodel=medany-specs=nosys.specs-O2-g-Wall-cmain.c-omain.o
/opt/riscv-newlib/bin/riscv64-unknown-elf-gcc-march=rv64imac_zicsr-mabi=lp64-mcmodel=medany-specs=nosys.specs-O2-g-Wall-cuart.c-ouart.o
/opt/riscv-newlib/bin/riscv64-unknown-elf-gcc-march=rv64imac_zicsr-mabi=lp64-mcmodel=medany-specs=nosys.specs-O2-g-Wall-csyscalls.c-osyscalls.o
/opt/riscv-newlib/bin/riscv64-unknown-elf-gcc-march=rv64imac_zicsr-mabi=lp64-mcmodel=medany-specs=nosys.specs-O2-g-Wall-cstartup.S-ostartup.o
/opt/riscv-newlib/bin/riscv64-unknown-elf-gcc-march=rv64imac_zicsr-mabi=lp64-mcmodel=medany-specs=nosys.specs-O2-g-Wall-Tlink.ld-nostartfiles-ofirmware.elfmain.ouart.osyscalls.ostartup.o
/opt/riscv-newlib/lib/gcc/riscv64-unknown-elf/14.2.0/../../../../riscv64-unknown-elf/bin/ld:warning:firmware.elfhasaLOADsegmentwithRWXpermissions
qemu-system-riscv64-machinevirt-m256-nographic-biosfirmware.elf-din_asm,cpu_reset-Dqemu_debug.log
HellofromRISC-VUART!
Typesomething:Youtyped:foo

我建议使用 debug 目标的原因是它会删除一个名为 qemu_debug.log 的文件。该文件非常酷,因为它向您显示了 VM 经历的完整轨迹。当然,如果您想弄清楚 printf 到底是如何工作的,您可以检查所有 Newlib 代码,但我仍然认为它非常清晰地显示了 RISC-V 内核实际看到的内容。由于我们正在构建一个 ELF 文件并将其传递给 QEMU,因此它甚至能够告诉我们我们到底在哪个函数中。对于前几个指令,它没有这样做,因为我们正在执行初始硬编码的引导加载程序,然后再执行我们的初始 C 运行时,然后跳转到 main 函数。如果 0x80000000 之前的几个指令使您感到困惑,请查看 RISC-V 使用 SBI 的引导过程 以了解发生了什么。我的调试日志的摘录如下:

----------------
IN:
Priv: 3; Virt: 0
0x0000000000001000: 00000297     auipc          t0,0          # 0x1000
0x0000000000001004: 02828613     addi          a2,t0,40
0x0000000000001008: f1402573     csrrs          a0,mhartid,zero
----------------
IN:
Priv: 3; Virt: 0
0x000000000000100c: 0202b583     ld           a1,32(t0)
0x0000000000001010: 0182b283     ld           t0,24(t0)
0x0000000000001014: 00028067     jr           t0
----------------
IN:
Priv: 3; Virt: 0
0x0000000080000000: 04000117     auipc          sp,67108864       # 0x84000000
0x0000000080000004: 00010113     mv           sp,sp
0x0000000080000008: 00015297     auipc          t0,86016        # 0x80015008
0x000000008000000c: d5828293     addi          t0,t0,-680
0x0000000080000010: 00015317     auipc          t1,86016        # 0x80015010
0x0000000080000014: d5030313     addi          t1,t1,-688
0x0000000080000018: 0062f663     bleu          t1,t0,12        # 0x80000024
----------------
IN:
Priv: 3; Virt: 0
0x0000000080000024: 0be020ef     jal           ra,8382         # 0x800020e2
----------------
IN: main
Priv: 3; Virt: 0
0x00000000800020e2: 7119       addi          sp,sp,-128
0x00000000800020e4: 00011517     auipc          a0,69632        # 0x800130e4
0x00000000800020e8: db450513     addi          a0,a0,-588
0x00000000800020ec: fc86       sd           ra,120(sp)
0x00000000800020ee: 10e000ef     jal           ra,270         # 0x800021fc
----------------
IN: puts
Priv: 3; Virt: 0
0x00000000800021fc: 85aa       mv           a1,a0
0x00000000800021fe: 00012517     auipc          a0,73728        # 0x800141fe
0x0000000080002202: ffa53503     ld           a0,-6(a0)
0x0000000080002206: b7bd       j            -146          # 0x80002174

结论

通过这个例子,我们已经将一些非常强大的功能移植到了我们的裸机平台,并且我们在某种程度上保留了在适当内核之上进行编码的感觉。我们可以继续进行并启用诸如“文件”访问、内存管理等。

事实上,真正有趣的是,现在打开了一扇门,可以在我们的裸机代码中使用一些强大的库,这些库在其他情况下不一定期望裸机环境。某些库可能希望打开一个文件,如果它执行此操作的唯一方法是通过使用 C 标准库,我们可以基本上拦截该 API 调用,并且无需将请求传递给内核,我们就可以在我们的裸机代码中为其提供服务。

而这样做的概念非常简单:依赖于 Newlib 定义的构建块,提供您自己的优先于 Newlib 默认值的实现,并将默认值用于您不关心的任何内容。

当然,在绝对最小的环境中,最终软件镜像的大小以及我们注入的指令量可能是一个问题,但是查看我们在项目中构建的 ELF 文件,它的大小为 220K,这听起来还不错。然而,最终,由您来决定将在您的项目中使用哪些抽象。这应该是您工具箱中的一种工具,有望在您的开发中节省您一些时间。

祝您黑客攻击顺利!

请考虑在 Twitter/XLinkedIn 上关注,以保持更新。