用 EDK2 编译你的第一个 UEFI 程序:HelloWorld
🔥 UEFI/BSP 开发系列第 010 篇 | 难度:⭐ 入门
作者:BSP 开发工程师
系列目标:300 篇由浅入深,构建完整的 UEFI 固件知识体系
写在前面
前两篇我们在 Windows 和 Linux 上搭好了 EDK2 开发环境,编译出了 OVMF 固件。
但那是编译别人写好的代码。
今天,我们要写自己的代码——一个在 UEFI 环境中运行的 HelloWorld 程序。
是的,这个程序不在 Windows 里运行,不在 Linux 里运行,它运行在操作系统启动之前,直接和固件对话。
如果你以前只写过跑在 OS 上面的程序,今天你将第一次触碰到"OS 之下"的世界。
很酷,对吧?
一、UEFI 程序和普通程序有什么不同?
先看一段普通 C 语言 HelloWorld:
#include<stdio.h>intmain(){printf("Hello, World!\n");return0;}这段代码依赖stdio.h,依赖 C 标准库,依赖操作系统的系统调用。没有 OS,它跑不起来。
再看 UEFI 版本的 HelloWorld:
#include<Uefi.h>#include<Library/UefiApplicationEntryPoint.h>#include<Library/UefiLib.h>EFI_STATUS EFIAPIUefiMain(IN EFI_HANDLE ImageHandle,IN EFI_SYSTEM_TABLE*SystemTable){Print(L"Hello, UEFI World!\n");returnEFI_SUCCESS;}几个关键区别:
| 维度 | 普通 C 程序 | UEFI 程序 |
|---|---|---|
| 入口函数 | main() | UefiMain() |
| 运行环境 | 操作系统上 | 固件环境(无 OS) |
| 依赖库 | C 标准库(libc) | UEFI 库(MdePkg) |
| 字符串类型 | char*(ASCII) | CHAR16*(Unicode,L"…") |
| 返回值 | int | EFI_STATUS |
| 参数 | argc/argv | ImageHandle + SystemTable |
💡 UEFI 程序的入口函数接收两个参数:
ImageHandle:当前程序自己的"身份证"SystemTable:系统服务表,通过它可以访问所有 UEFI 提供的功能(屏幕输出、内存分配、文件操作等)
Print(L"...")本质上是通过SystemTable->ConOut->OutputString()在屏幕上输出文字。UEFI 用的是 Unicode(UTF-16),所以字符串前面有个L。
二、创建 HelloWorld 程序
我们需要创建两个文件:
- 源代码文件
.c— 程序逻辑 - 模块描述文件
.inf— 告诉 EDK2 构建系统怎么编译这个模块
2.1 创建目录
在 EDK2 的某个 Package 下创建我们的程序目录。初学者直接放在MdeModulePkg下最简单:
mkdir-p~/UEFI/edk2/MdeModulePkg/Application/HelloWorldWindows 下对应路径:
C:\UEFI\edk2\MdeModulePkg\Application\HelloWorld
2.2 创建源代码:HelloWorld.c
/** @file My first UEFI application - HelloWorld Copyright (c) 2024, My Name. All rights reserved. SPDX-License-Identifier: BSD-2-Clause-Patent **/#include<Uefi.h>#include<Library/UefiApplicationEntryPoint.h>#include<Library/UefiLib.h>#include<Library/UefiBootServicesTableLib.h>EFI_STATUS EFIAPIUefiMain(IN EFI_HANDLE ImageHandle,IN EFI_SYSTEM_TABLE*SystemTable){// 在屏幕上打印 Hello WorldPrint(L"=================================\n");Print(L" Hello, UEFI World!\n");Print(L" This is my first UEFI app.\n");Print(L"=================================\n");Print(L"\nPress any key to exit...\n");// 等待用户按下任意键EFI_INPUT_KEY Key;gBS->WaitForEvent(1,&gST->ConIn->WaitForKey,NULL);gST->ConIn->ReadKeyStroke(gST->ConIn,&Key);returnEFI_SUCCESS;}💡代码解读:
gST= Global System Table,就是入口函数的SystemTable参数的全局版本gBS= Global Boot Services,启动阶段的服务集合WaitForEvent让程序暂停,等待键盘输入事件- 这就像普通 C 程序里的
getchar()或system("pause")
2.3 创建模块描述文件:HelloWorld.inf
## @file # My first UEFI Application - HelloWorld # # Copyright (c) 2024, My Name. All rights reserved. # SPDX-License-Identifier: BSD-2-Clause-Patent ## [Defines] INF_VERSION = 0x00010005 BASE_NAME = HelloWorld FILE_GUID = a912f198-7f0e-4803-b908-b757b806ec83 MODULE_TYPE = UEFI_APPLICATION VERSION_STRING = 1.0 ENTRY_POINT = UefiMain [Sources] HelloWorld.c [Packages] MdePkg/MdePkg.dec MdeModulePkg/MdeModulePkg.dec [LibraryClasses] UefiApplicationEntryPoint UefiLib UefiBootServicesTableLibINF 文件各段含义
INF 文件是 EDK2 中每个模块的"身份证"——每个 .c 文件想被编译,必须有一个对应的 .inf 文件。
你可以把 INF 文件理解为 EDK2 版本的Makefile或CMakeLists.txt,但它更偏向"声明式"——你只需要告诉构建系统"我叫什么、我依赖什么",具体怎么编译由 EDK2 的 build 工具自动处理。
| 段名 | 作用 | 类比 |
|---|---|---|
[Defines] | 模块的基本信息:名称、GUID、类型、入口函数 | CMake 的project()+add_executable() |
[Sources] | 源代码文件列表 | CMake 的源文件列表 |
[Packages] | 依赖哪些包(.dec 文件) | CMake 的find_package() |
[LibraryClasses] | 依赖哪些库 | CMake 的target_link_libraries() |
那DSC 文件又是什么?DSC(Description file)是平台级别的总管文件——它定义了"整个固件由哪些模块组成"。一个.dsc对应一个平台(比如 OvmfPkgX64.dsc 对应 QEMU 虚拟机平台)。
简单说:INF 描述一个模块,DSC 把一堆模块组装成一个固件。后面第 062~064 篇会详细讲解这些文件。
⚠️
FILE_GUID必须是唯一的。你可以用在线 GUID 生成器生成一个,也可以用 Python:importuuid;print(str(uuid.uuid4()))
⚠️
MODULE_TYPE = UEFI_APPLICATION表示这是一个 UEFI 应用程序(类似 EXE),不是驱动。
三、把程序加入编译
光创建了文件还不够,你需要告诉 EDK2 的构建系统:“嘿,我有一个新模块,请编译它。”
方法:修改 .dsc 文件
编辑 OVMF 平台的 DSC 文件,把我们的模块添加进去:
vim~/UEFI/edk2/OvmfPkg/OvmfPkgX64.dsc找到[Components]段(文件末尾附近),添加一行:
[Components] # ... 已有的模块列表 ... MdeModulePkg/Application/HelloWorld/HelloWorld.inf # <-- 添加这一行保存退出。
四、编译
# Linuxcd~/UEFI/edk2source.venv/bin/activatesourceedksetup.sh build# Windows(VS 开发者命令行) cd C:\UEFI\edk2 edksetup.bat build编译成功后,你的 HelloWorld 程序在这里:
# Linux Build/OvmfX64/DEBUG_GCC5/X64/HelloWorld.efi # Windows Build\OvmfX64\DEBUG_VS2022\X64\HelloWorld.efi这个.efi文件就是你的 UEFI 可执行程序!它的格式是 PE32+(和 Windows 的 EXE 格式很像,但是跑在固件环境里)。
五、在 QEMU 中运行
5.1 创建一个虚拟磁盘
我们需要把 HelloWorld.efi 放到一个虚拟磁盘里,这样 UEFI Shell 才能找到它:
# 创建一个 FAT 格式的虚拟磁盘镜像ddif=/dev/zeroof=disk.imgbs=1Mcount=64mkfs.fat disk.img# 创建挂载点并挂载mkdir-p/tmp/uefi_disksudomountdisk.img /tmp/uefi_disk# 把 HelloWorld.efi 复制进去sudocpBuild/OvmfX64/DEBUG_GCC5/X64/HelloWorld.efi /tmp/uefi_disk/# 卸载sudoumount/tmp/uefi_disk5.2 用 QEMU 启动
qemu-system-x86_64\-biosBuild/OvmfX64/DEBUG_GCC5/FV/OVMF.fd\-drivefile=disk.img,format=raw\-netnone\-serialstdio5.3 在 UEFI Shell 中运行
QEMU 启动后进入 UEFI Shell,执行:
Shell> fs0: FS0:\> HelloWorld.efi如果一切正常,你会看到:
================================= Hello, UEFI World! This is my first UEFI app. ================================= Press any key to exit...🎉 恭喜!你成功运行了人生中第一个 UEFI 程序!
这段代码在操作系统启动之前就运行了。此刻没有 Windows,没有 Linux,只有你的代码和 UEFI 固件。
六、深入理解:这个程序到底经历了什么?
从你输入HelloWorld.efi到看到输出,UEFI 做了这些事:
你输入 HelloWorld.efi 并回车 ↓ UEFI Shell 在 FAT 文件系统中找到 HelloWorld.efi ↓ UEFI Image Loader 把 PE32+ 格式的 EFI 文件加载到内存 ↓ 检查文件头、分配内存、解析依赖 ↓ 跳转到入口函数 UefiMain() ↓ UefiMain 调用 Print() → 实际调用 SystemTable->ConOut->OutputString() ↓ GOP 驱动把字符渲染到屏幕上 ↓ WaitForEvent 等待键盘中断 ↓ 你按下任意键 → 函数返回 EFI_SUCCESS ↓ UEFI Shell 回收内存,回到命令行你以为只是打印了一行字,其实背后有 Protocol 调用、内存管理、事件机制、驱动配合……这就是 UEFI 的面向对象架构在工作。
七、总结
| 维度 | 内容 |
|---|---|
| 程序入口 | UefiMain(ImageHandle, SystemTable) |
| 输出函数 | Print(L"...")或gST->ConOut->OutputString() |
| 文件格式 | .efi(PE32+ 格式) |
| 模块描述 | .inf文件定义模块信息和依赖 |
| 编译方式 | 修改 .dsc 添加模块 →build |
| 运行方式 | QEMU + OVMF + UEFI Shell |
下一篇:#011 UEFI Shell 是什么?它能干什么?——UEFI 固件里的"命令行工具",比你想的强大得多。
💬 如果这篇文章对你有帮助,欢迎关注本系列。300 篇 UEFI/BSP 系列文章持续更新中。