怎么在没有操作系统的情况下跑C程序?UEFI裸机编程第一步

大多数开发者一辈子不会写一行「没有操作系统」的代码,但有些场景恰恰需要它:要在任意断电后活下来、几秒内完成重启的嵌入式应用;要对输入做微秒级确定响应的控制程序;或者纯粹想搞明白按下电源键之后、操作系统起来之前,那两秒钟里发生了什么。开发者 Kevin Boone 最近发布了一套五篇的 UEFI 裸机编程教程,假设读者只会 Linux 和 C,从零讲到「让一台普通 PC 在没有任何操作系统的情况下运行你写的程序」。本文浓缩第一篇的核心:编译、链接、格式转换这条工具链,以及每一步为什么必须这么做。

裸机程序能获得什么、放弃什么

开机到程序运行只要一两秒。没有进程切换,就没有调度抖动,微秒级的输入响应变得容易——1980 年代家用电脑「开机即 BASIC」的体验,用这套方法可以复刻。但 EFI 固件给你的只有三样东西:从 VFAT 文件系统读文件、向控制台输出文字和图像、管理内存。除此之外——网络、多任务、标准库——全都要自己实现。还有一点值得警惕:UEFI 加载的程序跑在监督特权级,对全部硬件和文件有完整访问权,没有内核帮你兜底。

UEFI 怎么加载你的程序

现代 PC 的固件只认 VFAT 这一类文件系统:按固定规则在特定位置找一个特定名字、特定格式的可执行文件,载入内存,从约定入口开始执行,然后期望这个程序一直运行到断电——返回即停机。

格式的麻烦在于:UEFI 的最低公共格式是 PE32+,Intel 和微软定义的 Windows 世界格式;而 Linux 工具链的产出是 ELF。同一个 CPU 上机器指令本身没有区别,真正的麻烦只有两处:程序怎么把参数传给固件(调用约定不同,靠 gnu-efi 库的包装宏解决);可执行文件在内存里长什么样(靠专门的链接脚本加一步格式转换解决)。

Hello World 里藏着四个坑

代码不长,但每行都有讲究:

#include <efi/efi.h>
#include <efi/efilib.h>

EFI_STATUS EFIAPI efi_main(EFI_HANDLE image_handle,
                           EFI_SYSTEM_TABLE *system_table) {
    InitializeLib(image_handle, system_table);
    uefi_call_wrapper(ST->ConOut->ClearScreen, 1, ST->ConOut);
    Print(L"Hello, World!\n");
    return EFI_SUCCESS;
}

坑一:字符串是 16 位 UTF-16,不是 C 默认的 8 位字符数组。L 前缀的字符串字面量在 GCC 里默认生成 32 位宽字符,必须加编译选项改成 16 位;程序里计算出来的字符串同样要保持 16 位,或者调用前转换。

坑二:不能直接调用固件函数指针。写 ST->ConOut->ClearScreen(ST->ConOut) 编译器不报错——类型都对——但 GCC 生成的函数调用压栈顺序与 EFI 固件的约定不兼容,运行时行为不保证。uefi_call_wrapper 宏的作用就是在调用前把参数按固件期望的顺序重新排列。代价是它接受可变参数,编译器再也帮不了你查类型,所以真实项目会把这层包装封进自己的库函数。

坑三:efi_main 不是被你调用的,是被 gnu-efi 的启动代码调用的。InitializeLib 负责把固件递进来的系统表指针拆包成 ST(系统表)、BS(启动服务)这些全局变量,后面所有固件调用都从这两个指针出发。

坑四:C 标准库完全不存在。乱包含标准头文件不会让你能用 printf,只会让编译器失去「发现你调用了运行时根本不存在的函数」的能力。

三步工具链:编译、链接、变形

编译还是 GCC,选项有讲究:

gcc -I /usr/include -DEFI_FUNCTION_WRAPPER \
    -fno-stack-protector -mno-red-zone \
    -fpic -fshort-wchar -c test.c -o test.o

-fno-stack-protector 和 -mno-red-zone 是为 EFI 配置栈行为;-fpic 生成位置无关代码;-fshort-wchar 把宽字符改成 16 位。产物是普通 ELF 目标文件。

链接不是在造 Linux 程序,而是在造一个之后会被变形的共享库:

ld -shared -nostdlib -znocombreloc -Bsymbolic \
   -T /usr/lib/elf_x86_64_efi.lds \
   /usr/lib/crt0-efi-x86_64.o test.o \
   -o test.so -lefi -lgnuefi

-T 指定 gnu-efi 自带的链接脚本,告诉链接器段怎么摆;crt0-efi-x86_64.o 是启动代码,负责初始化运行环境然后调用你的 efi_main;-nostdlib 显式拒绝标准库。

最后一步把共享库变形为 PE32+ 可执行文件。objcopy 原生认识 EFI 目标格式,一条命令搞定:

objcopy -j .text -j .sdata -j .data -j .dynamic \
        -j .dynsym -j .rel -j .rela -j .reloc \
        --target=efi-app-x86_64 test.so test.efi

产出的 test.efi 已经是固件能加载的 PE32+ 文件。下一关是做一块带 EFI 系统分区的启动盘让它跑起来——那是系列第二篇的内容。

最阴险的坑:链接器不会告诉你缺了什么

作者反复强调的一点值得单独一节:对 Linux 来说,你产出的 test.efi 只是「一个共享库」,不是程序。链接器发现你调用了不存在的函数时不会报错——它只标记为外部引用,指望「别的代码」在运行时提供。而那个提供者永远不存在,程序跑到那一行直接崩,没有错误日志可看,只有固件黑屏。习惯写应用代码的人几乎都会在这里摔跤。对策只有三条:只包含 EFI 头文件和你自己的头文件;整数宽度老实用 gnu-efi 定义的 UINTN 一族类型;绝不碰任何 Linux 通用库——哪怕它能转成 PE32+,它依赖的标准库函数在运行时也不存在。

参考来源:Kevin Boone《First steps in bare-metal programming using UEFI》

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐