Basic Environment Setup

This is my hardware environment and operating system.

Hardware Environment

Download edk2 Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Install required packages
sudo apt update
sudo apt install git
mkdir -p ~/UEFI
cd UEFI
git clone "https://github.com/tianocore/edk2.git"
cd edk2
# Switch to this branch
git checkout origin/stable/202408
git submodule update --init --recursive
git branch
# Check if all submodules are initialized correctly. If not, compilation may have issues.
git submodule status
cd -
# Download edk2-libc code, mainly for using the C standard library in UEFI development.
git clone https://github.com/tianocore/edk2-libc.git
# Create a code folder to store our own code
mkdir -p code

Install Compilation Tools

1
2
3
4
5
6
7
8
# Install basic software packages
sudo apt-get install python3 python3-distutils uuid-dev build-essential bison flex nasm acpica-tools gcc
# Install ARM compiler, mainly for compiling aarch64
mkdir -p ~/UEFI/toolchain
cd ~/UEFI/toolchain
wget https://developer.arm.com/-/media/Files/downloads/gnu-a/8.2-2019.01/gcc-arm-8.2-2019.01-x86_64-aarch64-elf.tar.xz
tar -xf gcc-arm-8.2-2019.01-x86_64-aarch64-elf.tar.xz
cd -

HelloWorld Example

Let’s implement a UEFI code compilation for the target platform x64 or aarch64, which supports running on Emulator and qemu, and debugging with gdb.

Code

1
2
3
4
5
touch HelloWorld.dsc
touch HelloWorld.inf
touch HelloWorld.c
# Use this command to generate UUID, used in dsc and inf files
uuidgen

HelloWorld.dsc

The DSC file is the package description file, where all fields in Defines are mandatory.
To find paths for LibraryClasses, use the following command:

1
2
3
4
5
cd edk2
# Take UefiApplicationEntryPoint as an example
grep UefiApplicationEntryPoint -r ./ --include=*.inf | grep LIBRARY_CLASS
# Find by GUID
grep -i 752F3136 -r ./ --exclude-dir=Build

The format for LibraryClasses is:

1
LibraryClassName|Path/To/LibInstanceName.inf

For a full explanation of DSC files, refer to the following link:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
[Defines]
DSC_SPECIFICATION = 0x0001001A
PLATFORM_GUID = c08977d4-6e87-42f6-bf5c-4d41cfe7ba53
PLATFORM_VERSION = 0.01
PLATFORM_NAME = HelloWorld
SKUID_IDENTIFIER = DEFAULT
SUPPORTED_ARCHITECTURES = AARCH64|X64
BUILD_TARGETS = DEBUG|RELEASE|NOOPT
OUTPUT_DIRECTORY = $(PKG_OUTPUT_DIR)

[LibraryClasses]
BaseLib|MdePkg/Library/BaseLib/BaseLib.inf
BaseMemoryLib|MdePkg/Library/BaseMemoryLib/BaseMemoryLib.inf
DevicePathLib|MdePkg/Library/UefiDevicePathLib/UefiDevicePathLib.inf
MemoryAllocationLib|MdePkg/Library/UefiMemoryAllocationLib/UefiMemoryAllocationLib.inf
PrintLib|MdePkg/Library/BasePrintLib/BasePrintLib.inf
UefiLib|MdePkg/Library/UefiLib/UefiLib.inf
UefiHiiServicesLib|MdeModulePkg/Library/UefiHiiServicesLib/UefiHiiServicesLib.inf
ShellCEntryLib|ShellPkg/Library/UefiShellCEntryLib/UefiShellCEntryLib.inf
HiiLib|MdeModulePkg/Library/UefiHiiLib/UefiHiiLib.inf


UefiApplicationEntryPoint|MdePkg/Library/UefiApplicationEntryPoint/UefiApplicationEntryPoint.inf
UefiBootServicesTableLib|MdePkg/Library/UefiBootServicesTableLib/UefiBootServicesTableLib.inf
UefiRuntimeServicesTableLib|MdePkg/Library/UefiRuntimeServicesTableLib/UefiRuntimeServicesTableLib.inf

DebugLib|MdePkg/Library/BaseDebugLibNull/BaseDebugLibNull.inf
PcdLib|MdePkg/Library/BasePcdLibNull/BasePcdLibNull.inf

[LibraryClasses.ARM,LibraryClasses.AARCH64]
NULL|ArmPkg/Library/CompilerIntrinsicsLib/CompilerIntrinsicsLib.inf
NULL|MdePkg/Library/BaseStackCheckLib/BaseStackCheckLib.inf

[LibraryClasses.X64]
RegisterFilterLib|MdePkg/Library/RegisterFilterLibNull/RegisterFilterLibNull.inf

[Components]
HelloWorld.inf

HelloWorld.inf
The INF file is a configuration file for an EDK2 application. It contains several sections:

  • [Defines]: This section defines basic information for the module.
    • BASE_NAME: The name of the application.
    • FILE_GUID: This can be generated using the uuidgen command. UEFI uses the GUID to distinguish between different modules.
    • MODULE_TYPE: This should be set to UEFI_APPLICATION.
    • ENTRY_POINT: The name of the main function in the C code.
  • [Sources]: The source code for the module, typically .c and .h files.
  • [Packages]: The packages required by the module.
  • [LibraryClasses]: The libraries that the module depends on.

For a complete explanation of the INF file, refer to the following link:

Below is an example of a simple module definition:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Variables defined to be used during the build process
[Defines]
INF_VERSION = 1.25
BASE_NAME = HelloWorld
FILE_GUID = 5455334b-dbd9-4f95-b6ed-5ae261a6a0c1
MODULE_TYPE = UEFI_APPLICATION
VERSION_STRING = 1.0
ENTRY_POINT = UefiMain

# Source code
[Sources]
HelloWorld.c

# Required packages
[Packages]
MdePkg/MdePkg.dec # Contains Uefi and UefiLib

# Required Libraries
[LibraryClasses]
UefiApplicationEntryPoint # Uefi application entry point
UefiLib # UefiLib
UefiBootServicesTableLib

HelloWorld.c

1
2
3
4
5
6
7
8
9
10
#include <Library/UefiLib.h>
#include <Uefi.h>

EFI_STATUS
EFIAPI
UefiMain(IN EFI_HANDLE ImageHandle, IN EFI_SYSTEM_TABLE *SystemTable) {
Print(L"Hello World!!!\n");
SystemTable->BootServices->Stall(10000000);
return EFI_SUCCESS;
}

Build Script

First, we need to create a script to set up the environment variables:

1
2
touch env.sh
chmod a+x env.sh

env.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/bin/bash
# Project name, also the directory of the source files
export PROJ_NAME="HelloWorld"
# DSC file name
export DSC_NAME="HelloWorld"
# INF file name
export INF_NAME="HelloWorld"
# Also the name of the generated *.efi, defined in INF's BASE_NAME
export INF_BASE_NAME="HelloWorld"
# UEFI workspace directory
export UEFI_WORKSPACE="$HOME/UEFI"
# EDK II path
export EDK_PATH="$UEFI_WORKSPACE/edk2"
# EDK II libc path
export EDK_LIBC_PATH="$UEFI_WORKSPACE/edk2-libc"
# Application code path
export APP_PATH="$UEFI_WORKSPACE/code/$PROJ_NAME"
# Build output directory
export PKG_OUTPUT_DIR="$APP_PATH/Build"
# Emulator path
export EMULATOR_PATH="$EDK_PATH/Build/EmulatorX64/DEBUG_GCC5/X64"
# Package path settings, supports multiple paths separated by colon
export PACKAGES_PATH="$EDK_PATH:$EDK_LIBC_PATH:$APP_PATH"
# Specify Python interpreter
export PYTHON_COMMAND="/usr/bin/python3"
# Confirm that environment variables have been set
echo "Environment variables for $PROJ_NAME project are configured."

Next, we will write a script to compile the code for the x64 target platform.

1
2
touch build-x64.sh
chmod a+x build-x64.sh

build-x64.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/bash
set -e
trap "Exiting" INT

# environment variables
source env.sh

export GCC5=/usr/bin/gcc
cd $EDK_PATH
source edksetup.sh
cd -

# Building BaseTools
make -C $EDK_PATH/BaseTools
# In this script, the -b flag is set to DEBUG. When deploying, you should use RELEASE instead.
# -p --platform=
# -m --module=
# -a --arch=
# -b --buildtarget=
# -t --taggname=
build -p $APP_PATH/$DSC_NAME.dsc -m $APP_PATH/$INF_NAME.inf -a X64 -t GCC5 -b DEBUG -D PKG_OUTPUT_DIR=$PKG_OUTPUT_DIR

The process for compiling to the AArch64 platform is similar.

1
2
touch build-aarch64.sh
chmod a+x build-aarch64.sh

build-aarch64.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/bin/bash
set -e
trap "Exiting" INT

# environment variables
source env.sh

export GCC5_AARCH64_PREFIX=$UEFI_WORKSPACE/toolchain/gcc-arm-8.2-2019.01-x86_64-aarch64-elf/bin/aarch64-elf-

cd $EDK_PATH
source edksetup.sh
cd -

# Building BaseTools
make -C $EDK_PATH/BaseTools

build -p $APP_PATH/$DSC_NAME.dsc -m $APP_PATH/$INF_NAME.inf -a AARCH64 -t GCC5 -b DEBUG -D PKG_OUTPUT_DIR=$PKG_OUTPUT_DIR

Running

Run on Emulator

Finally, we write a script to run on the emulator provided by edk2. Note that you need a GUI environment here. If you’re only using the command line, skip this step and refer to the next section to run with QEMU.

1
2
touch run.sh
chmod a+x run.sh

run.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash
set -e
trap "Exiting" INT

source env.sh

export GCC5=/usr/bin/gcc
# Emulator compilation, once compiled, no need to compile again
cd $EDK_PATH
source edksetup.sh
build -p $EDK_PATH/EmulatorPkg/EmulatorPkg.dsc -t GCC5 -a X64

sudo mkdir -p $EMULATOR_PATH/UEFI_Disk

sudo cp $APP_PATH/Build/DEBUG_GCC5/X64/$INF_BASE_NAME.efi $EMULATOR_PATH/UEFI_Disk/

cd $EMULATOR_PATH
./Host

Run with QEMU

First, compile and install QEMU. Here, I choose version 8.1.5. If your version is not working as expected, consider using QEMU version 8.1.5.

1
2
3
4
5
6
7
8
9
10
11
12
git clone https://gitlab.com/qemu-project/qemu.git
cd qemu
git checkout stable-8.1
sudo apt install python3-venv python3-pip python3-setuptools python3-sphinx ninja-build pkg-config libglib2.0-dev libpixman-1-dev
# x86_64
./configure --target-list=x86_64-softmmu
make -j$(nproc)
sudo make install
# aarch64
./configure --target-list=aarch64-softmmu
make -j$(nproc)
sudo make install

Next, write a script to run with QEMU. Some parameters here are for debugging the program with GDB in the next section, but if you just want to run with QEMU, it won’t affect the operation.

1
2
touch debug.sh
chmod a+x debug.sh

debug-x64.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#!/bin/bash
set -e
trap "Exiting" INT

# environment variables
source env.sh

export GCC5=/usr/bin/gcc
# Compiled once, no need to compile again
cd $EDK_PATH
source edksetup.sh
build -a X64 -p OvmfPkg/OvmfPkgX64.dsc -t GCC5 -b DEBUG #-D SOURCE_DEBUG_ENABLE

cd $APP_PATH
mkdir -p _ovmf_dbg
cd _ovmf_dbg
rm -f debug.log
# The default QEMU from Ubuntu 22.04 repo is not compatible; we need to upgrade to QEMU v8.1.5
cp $EDK_PATH/Build/OvmfX64/DEBUG_GCC5/FV/OVMF.fd ./

mkdir -p UEFI_Disk
cp $APP_PATH/Build/DEBUG_GCC5/X64/$INF_BASE_NAME.efi ./UEFI_Disk/
cp $APP_PATH/Build/DEBUG_GCC5/X64/$INF_BASE_NAME.debug ./UEFI_Disk/

# -s enables GDB debugging, default listens at 127.0.0.1:1234
# -bios OVMF.fd, specify OVMF firmware file, this is a QEMU firmware supporting UEFI.
# -debugcon file:debug.log redirects debug output to debug.log file.
# -global isa-debugcon.iobase=0x402 configures the debug console I/O base address.


qemu-system-x86_64 \
-s \
-bios OVMF.fd \
-drive format=raw,file=fat:rw:UEFI_Disk/ \
-net none \
-debugcon file:debug.log \
-global isa-debugcon.iobase=0x402 \
-nographic

This script first compiles the OVMF (Open Virtual Machine Firmware), which is a firmware based on EDKII that runs on QEMU x86-64 virtual machines. This makes debugging and experimenting with UEFI firmware easier, whether for testing OS booting or using the (built-in) EFI shell.

The OVMF firmware (for QEMU’s UEFI implementation) is divided into two files:

  • OVMF_CODE.fd: contains the actual UEFI firmware.
  • OVMF_VARS.fd: acts as a “template” for simulating persistent NVRAM storage.
    All virtual machine instances can share the read-only OVMF_CODE.fd file from the OVMF package, but each instance needs a private, writable copy of OVMF_VARS.fd.
    In QEMU, you can specify OVMF_CODE.fd and OVMF_VARS.fd separately, or use a simplified approach:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    # Specify separately
    qemu-system-x86_64 -drive if=pflash,format=raw,readonly,file=Build/OvmfX64/RELEASE_GCC5/FV/OVMF_CODE.fd \
    -drive if=pflash,format=raw,file=Build/OvmfX64/RELEASE_GCC5/FV/OVMF_VARS.fd \
    -nographic \
    -net none
    # Simplified approach
    qemu-system-x86_64 -drive if=pflash,format=raw,file=Build/OvmfX64/RELEASE_GCC5/FV/OVMF.fd \
    -nographic \
    -net none

Run debug-x64.sh, and you should see the following interface, which is the UEFI Shell:

1
2
3
4
5
6
7
8
9
10
11
12
UEFI Interactive Shell v2.2
EDK II
UEFI v2.70 (EDK II, 0x00010000)
Mapping table
FS0: Alias(s):HD0a1:;BLK1:
PciRoot(0x0)/Pci(0x1,0x1)/Ata(0x0)/HD(1,MBR,0xBE1AFDFA,0x3F,0xFBFC1)
BLK0: Alias(s):
PciRoot(0x0)/Pci(0x1,0x1)/Ata(0x0)
BLK2: Alias(s):
PciRoot(0x0)/Pci(0x1,0x1)/Ata(0x0)
Press ESC in 2 seconds to skip startup.nsh or any other key to continue.
Shell>

In this shell, type fs0: (note the English colon), then type HelloWorld.efi to run your program, and the expected output should be “Hello World!!!”

If the Backspace key doesn’t respond in the shell, try using Ctrl+H instead

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
UEFI Interactive Shell v2.2
EDK II
UEFI v2.70 (EDK II, 0x00010000)
Mapping table
FS0: Alias(s):HD0a1:;BLK1:
PciRoot(0x0)/Pci(0x1,0x1)/Ata(0x0)/HD(1,MBR,0xBE1AFDFA,0x3F,0xFBFC1)
BLK0: Alias(s):
PciRoot(0x0)/Pci(0x1,0x1)/Ata(0x0)
BLK2: Alias(s):
PciRoot(0x0)/Pci(0x1,0x1)/Ata(0x0)
Press ESC in 2 seconds to skip startup.nsh or any other key to continue.
Shell> fs0:
FS0:\> ls
Directory of: FS0:\
01/08/2025 22:23 82 gdb_commands.txt
01/10/2025 20:22 184,544 HelloWorld.debug
01/10/2025 20:22 5,760 HelloWorld.efi
01/10/2025 12:22 1,391 NvVars
4 File(s) 191,777 bytes
0 Dir(s)
FS0:\> HelloWorld.efi
Hello World!!!

Exit QEMU by pressing CTRL+A - X.

Now, here is the AArch64 version:
debug-aarch64.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#!/bin/bash
set -e
trap "Exiting" INT

# Environment variables
source env.sh

export GCC5_AARCH64_PREFIX=$UEFI_WORKSPACE/toolchain/gcc-arm-8.2-2019.01-x86_64-aarch64-elf/bin/aarch64-elf-

# No need to compile again after one successful compilation
cd $EDK_PATH
source edksetup.sh
build -a AARCH64 -p ArmVirtPkg/ArmVirtQemu.dsc -t GCC5 -b RELEASE

cd $APP_PATH/$INF_NAME
mkdir -p _armvirt_dbg
cd _armvirt_dbg
rm -f debug.log
# Ubuntu 22.04 default QEMU is incompatible, need to upgrade QEMU to version 8.1.5

cp $EDK_PATH/Build/ArmVirtQemu-AARCH64/RELEASE_GCC5/FV/QEMU_EFI.fd ./

mkdir -p UEFI_Disk
cp $APP_PATH/$INF_NAME/Build/DEBUG_GCC5/AARCH64/$INF_BASE_NAME.efi ./UEFI_Disk/
cp $APP_PATH/$INF_NAME/Build/DEBUG_GCC5/AARCH64/$INF_BASE_NAME.debug ./UEFI_Disk/

# QEMU command
qemu-system-aarch64 \
-machine virt,kernel_irqchip=on,gic-version=3 \
-cpu cortex-a57 -m 1G \
-drive format=raw,file=fat:rw:UEFI_Disk/ \
-bios QEMU_EFI.fd \
-net none \
-nographic

Debugging

Debugging UEFI programs using gdb can be a bit tricky, but it can be automated with scripts. The general process is as follows:

  1. Run debug.sh, then enter UEFI Shell and run the following code (same as using QEMU in the previous section; this is mainly to capture the driver startup address in _ovmf_dbg/debug.log).
  2. Open another terminal and run the script addr.sh below.
  3. Run gdb -x gdb_commands.txt in the _ovmf_dbg/UEFI_Disk directory.
  4. Set a breakpoint in GDB, e.g., break UefiMain.
  5. Add the GDB debug target target remote localhost:1234.
  6. Run and type c to continue to the first breakpoint.
  7. Run your code in UEFI Shell.

addr-x64.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#!/bin/bash
source env.sh

cd _ovmf_dbg

logfile="debug.log"

line=$(grep -oP "Loading driver at 0x[0-9a-fA-F]+ EntryPoint=0x[0-9a-fA-F]+ $INF_BASE_NAME\.efi" "$logfile" | tail -n 1)

# Use regular expression to extract the two addresses
if [[ $line =~ Loading\ driver\ at\ (0x[0-9a-fA-F]+)\ EntryPoint=(0x[0-9a-fA-F]+)\ $INF_BASE_NAME\.efi ]]; then
address0="${BASH_REMATCH[1]}"
address1="${BASH_REMATCH[2]}"
echo "Loading driver at $address0"
echo "EntryPoint=$address1"
else
echo "Error: No matching line found, maybe you need to run $INF_BASE_NAME in qemu first"
exit 0
fi

cd UEFI_Disk

# Use objdump to get file header info and extract .text and .data File offsets
text_offset=$(objdump -h "$INF_BASE_NAME.efi" | awk '
/\.text/ {print $6} # Extract .text File offset
')

data_offset=$(objdump -h "$INF_BASE_NAME.efi" | awk '
/\.data/ {print $6} # Extract .data File offset
')

# Output extracted results
echo ".text file off: $text_offset"
echo ".data file off: $data_offset"

# Calculate the addresses
text_addr=$((0x${address0#0x} + 0x${text_offset}))
data_addr=$((0x${address0#0x} + 0x${data_offset}))

# Output the results in hexadecimal format
printf "text_addr: 0x%X data_addr: 0x%X\n" $text_addr $data_addr

rm -rf gdb_commands.txt

# Create gdb_commands.txt and write the contents
cat <<EOL > gdb_commands.txt
file ${INF_BASE_NAME}.efi
add-symbol-file ${INF_BASE_NAME}.debug 0x$(printf "%X" $text_addr) -s .data 0x$(printf "%X" $data_addr)
EOL

# Output the file content for verification
echo "gdb_commands.txt has been created with the following content:"
printf "\n"
cat gdb_commands.txt
printf "\n"
echo "Run the following command to debug:"
echo "cd _ovmf_dbg/UEFI_Disk"
echo "gdb -x gdb_commands.txt"
echo "break UefiMain"
echo "target remote localhost:1234"
echo "c"

HelloStd

Another example, using edk-libc to implement a program that calls the standard C library in UEFI.

You can copy HelloWorld.dsc, modify the GUID, and make sure to change the [Components] section to HelloWorld.inf. Finally, add the following line to the [LibraryClasses] section at the end:
HelloStd.dsc

1
!include StdLib/StdLib.inc

Next, for HelloStd.inf, modify the ENTRY_POINT in the [Defines] section to ShellCEntryLib, and in the [Packages] section, add the StdLib/StdLib.dec and ShellPkg/ShellPkg.dec packages. In the [LibraryClasses], remove UefiApplicationEntryPoint, and add the LibC and LibStdio libraries. Below is the declaration of HelloStd.inf:

HelloStd.inf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Variables defined to be used during the build process
[Defines]
INF_VERSION = 1.25
BASE_NAME = HelloStd
FILE_GUID = d0956d2b-c033-45af-8ef2-76c9d30518ec
MODULE_TYPE = UEFI_APPLICATION
VERSION_STRING = 1.0
ENTRY_POINT = ShellCEntryLib

# Source code
[Sources]
HelloStd.c

# Required packages
[Packages]
MdePkg/MdePkg.dec # Contains Uefi and UefiLib
StdLib/StdLib.dec
ShellPkg/ShellPkg.dec

# Required Libraries
[LibraryClasses]
# UefiApplicationEntryPoint # Uefi application entry point
UefiLib # UefiLib
LibC
LibStdio

Now, we can call standard library programs in UEFI.

HelloStd.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

#include <Library/ShellCEntryLib.h>
#include <Library/UefiBootServicesTableLib.h>
#include <Library/UefiLib.h>
#include <Library/UefiRuntimeServicesTableLib.h>
#include <Uefi.h>
#include <stdio.h>
#include <stdlib.h>

int main(IN int Argc, IN char **Argv) {
EFI_TIME curTime;
printf("HelloStd!!!\n");
gBS->Stall(2000);
gRT->GetTime(&curTime, NULL);
printf("Current Time: %d-%d-%d %02d:%02d:%02d\n", curTime.Year, curTime.Month,
curTime.Day, curTime.Hour, curTime.Minute, curTime.Second);
return 0;
}

Finally, modify PROJ_NAME, DSC_NAME, INF_NAME, and INF_BASE_NAME in env.sh, and you can compile and debug as described in the HelloWorld section.

References