Writing custom GRUB2 modules
A few months ago, I updated a kernel module I wrote (qnap8528) to expose an interface to the LEDs on my QNAP TS-473A NAS. I use the LEDs on the device to indicate the system state. For example, when shutting down, I set the status LED to blink in an alternating green-red pattern, visually indicating that a shutdown sequence has been initiated.
However, when the NAS boots up, the status LED is solid green, and there is no visual indicator to show that the system is still in the booting process. Since I cannot load a Linux kernel module when there is no Linux kernel running, I decided to create a simple GRUB module to set the status LED from the bootloader. Once the system has finished booting, I can set the LED back to solid green from inside Linux using a service.
The GRUB module I wrote is very simple. It is named qnap8528led
, and when inserted using GRUB’s insmod
, it sets the status LED to blink via the EC accordingly.
Since there is little documentation on creating and compiling a module for GRUB, I decided to document the process for anyone who needs to do the same and for myself, If I ever need to do this again in the future. However, this module is very simple—it does not even register a new command in the GRUB command subsystem. So, I will try and provide additional information along the way as a guide.
Before I begin, let’s talk about GRUB modules. These module files are not particularly complex; they are standard ELF files. However, similar to kernel modules, they must follow a specific structure so that GRUB knows how to initialize and remove them (as there is no standard main
function).
Additionally, GRUB modules require the use of GRUB-specific functions and types, since standard library functions are not available. They must also be compiled with specific options, such as disabling stack protection, SIMD, hardware floating-point support, and more—after all, GRUB is a bootloader.
For these reasons, it is much simpler to create and add the module within the GRUB source tree and compile GRUB from source, allowing its build system to handle the module compilation. This approach avoids the complexity of compiling the module out-of-tree and manually managing include paths and compiler options.
The first step in the process is to set up a development environment. Since my target machine (the NAS) runs Debian 12 (Bookworm), I created a container with the same OS. I use Incus, a fork of LXD, for my containers, but this environment can also be a VM, Docker, LXC, or bare metal.
From this point on, all commands are executed inside the container until the installation procedure on the target machine.
# Create a container
incus launch images:debian/12 grub-module-env
# Launch a bash shell inside the container
incus exec grub-module-env bash
After the container is created, the next step is to prepare the environment. This involves installing the required packages and downloading the necessary sources. Since both my target system and the container are running Debian 12, I will use the APT repositories to retrieve the source code for GRUB2 (in my case, GRUB version 2.06), as this is the same version of GRUB installed on the target machine.
However, if needed, the exact version of GRUB can be obtained from the GNU website at https://ftp.gnu.org/gnu/grub/.
# Add the source repositories to sources.list
echo deb-src http://deb.debian.org/debian bullseye main contrib >> /etc/apt/sources.list
# Make sure the environment is up to date
apt update && apt -y upgrade
# Install required tools for compiling GRUB
apt install -y python3 gettext autopoint pkg-config build-essential autoconf automake bison flex
# Download GRUB2 sources, if in root account ignore permission warnings
apt source grub2
Now that the environment is set up, let’s take a deeper look at a typical GRUB module structure. Each GRUB module contains an init
and exit
function, which are created using the GRUB_MOD_INIT
and GRUB_MOD_FINI
macros (their return type is void
). These functions, similar to kernel modules, allow the module to initialize, allocate, and register components with the GRUB subsystems, as well as free resources when the module is unloaded.
Back to the development environment, it’s time to create our module. In the GRUB source directory, I created a new file for my module under grub-core
named qnap8528led.c
. I then added some basic headers:
grub/dl.h
defines various macros needed for loadable modules,grub/i18n.h
is used for localization macros,grub/time.h
andgrub/cpu/io.h
are included because I plan on using sleep functionality and IO ports to access the NAS EC hardware and set the LED state.
For more complex modules, there are many other headers that provide GRUB’s implementation of standard library functions and types. Example headers for standard library functions include:
grub/misc.h
for a subset ofmem*
,str*
, andis*
functions,grub/env.h
for getting and setting environment variables,grub/mm.h
for memory management, and more.
Additionally, there are header files for the GRUB subsystem APIs, such as:
grub/command.h
orgrub/extcmd.h
for adding new commands to GRUB,grub/term.h
for retrieving user input,grub/file.h
for file operations, and more.
In addition to the headers and the init and exit macros, a license directive also needs to be added. This is important because if the license does not match GRUB’s license, it will fail to compile, preventing the inclusion of incompatible licensed code into the project. So, I added the headers I need, implemented the init and exit functions using the macros mentioned above, and included the license directive.
#include <grub/dl.h>
#include <grub/i18n.h>
#include <grub/time.h>
#include <grub/cpu/io.h>
GRUB_MOD_LICENSE ("GPLv3+");
GRUB_MOD_INIT(qnap8528led) {
}
GRUB_MOD_FINI(qnap8528led) {
}
In the init
function, a typical module would usually have only a few calls to various GRUB subsystem register functions, such as:
grub_register_command
to register a new GRUB command,grub_fs_register
to register a new filesystem type,grub_partition_map_register
orgrub_disk_dev_register
to register a new disk interface or partition format.
There are more subsystem registration functions, but unfortunately, GRUB lacks documentation for most of them. To use a different subsystem, it’s best to read parts of the source code in the grub-core
directory to get a sense of how they are used.
In the case of my module, I’m not going to interact with any GRUB subsystems. Instead, I will try to set the LED using the IO ports and silently fail if needed, to avoid causing any issues. If you’re interested in seeing an example of registering commands, you can look at the sources in the grub-core/commands
directory, as they all register commands in different ways to the command subsystem.
In my init
function, I only added a call to a function I will show later that sets the LED as required and returns a success code of 0
, regardless of the outcome.
GRUB_MOD_INIT(qnap8528led) {
qnap8528led_set_status();
}
My next step is to add the code to actually communicate with the hardware and set the LED to the desired state. I will not explain the EC communication logic here (I might write a small post about my qnap8528
kernel module later), but this is an excellent place to explain a few other things.
My functions are as follows:
/*
* qnap8528led_wait_ibf_clear
* Wait for the EC input buffer to be clear for writing
*/
static grub_err_t qnap8528led_ec_wait_ibf(void) {
grub_uint16_t retries = 0;
do {
if (!(grub_inb(0x6c) & 2))
return GRUB_ERR_NONE;
grub_millisleep(1);
} while (retries++ < 1000);
return grub_error(GRUB_ERR_TIMEOUT, N_("Timeout waiting for EC IBF"));
}
/*
* qnap8528led_set_status
*
* Set the status LED to "booting indicator mode" (flashing green/red),
* this is done by wirint the value 5 to register 0x155, since this is a
* write command, the register value must be ORed with 0x8000.
*/
static void qnap8528led_set_status(void) {
/* Prepare the EC for a command */
if (qnap8528led_ec_wait_ibf())
return;
grub_outb(0x88, 0x6c);
/* Write first part of 0x155 | 0x8000 */
if (qnap8528led_ec_wait_ibf())
return;
grub_outb(0x81, 0x68);
/* Write second part of 0x155 | 0x8000 */
if (qnap8528led_ec_wait_ibf())
return;
grub_outb(0x55, 0x68);
/* Write value of 5 to the register */
if (qnap8528led_ec_wait_ibf())
return;
grub_outb(0x05, 0x68);
}
Let’s note a few things about the code:
Prefixing with
grub_
First, note the prefixgrub_
for types (e.g.,grub_uint16_t
foruint16_t
) and what would be standard library calls such asinb
andoutb
(which aregrub_inb
andgrub_outb
in GRUB). Almost everything in GRUB is prefixed withgrub_
. This is also true for functions likemalloc
,free
,strcmp
,memset
, and others.Return Type of
qnap8528led_wait_ibf_clear
Second, note the return type ofqnap8528led_wait_ibf_clear
. I could have used a simpleint
orgrub_int32_t
for the return value, but this is an excellent place to demonstrate how to return error codes in GRUB. The return type of the function isgrub_err_t
, which is an enum defined ingrub/err.h
. When returning an error,grub_error
is used so that both an error message and an error code (e.g.,GRUB_ERR_TIMEOUT
) can be set. In this example, there isn’t much point to this (the error is never passed on, and I will remove it), but when returning from a subsystem callback (for example, a command handler), this allows GRUB to show a descriptive error to the user instead of just a cryptic return code. TheN_
macro for the error string is used for localization, which is standard practice when dealing with strings in GRUB.Function Arguments
Third, let’s talk about function arguments. Notice that I have avoid
in the function declaration as a parameter even though it takes no arguments. Omitting this will cause the build to error witherror: function declaration isn’t a prototype
, since GRUB is compiled with-Wstrict-prototypes
. Also, if there are any unused arguments in the function (especially in GRUB subsystem callback functions), the modifier__attribute__ ((unused))
must be added after the argument name to avoid errors from the-Wunused-parameter
flag passed to GCC. For example, if I have a functionvoid do_stuff(grub_int32_t arg1)
andarg1
is not used, it must be declared asvoid do_stuff(grub_int32_t arg1 __attribute__ ((unused)))
.
Now, it’s time to compile GRUB and get the module compiled with it. The first step is to add the module to the list of modules compiled with the core. This is done by adding an entry to grub-core/Makefile.core.def
. The entry should be in the format shown in the code block below. The name
and common
entries are required, with the common
entry being the path to the source file for the module relative to the grub-core
directory (multiple common
entries are allowed). Other entries, like enable
, specify for which target architectures the module should be compiled (multiple enable
entries are allowed), but I will not use any, as I am only using the GRUB build system to build for my architecture and do not plan on this module being part of GRUB’s public sources.
module = {
name = <module name>;
common = <source location>;
};
After that, it’s as simple as compiling GRUB for the target architecture (in my case, x86_64
with EFI support).
# Add the module to grub-core/Makefile.core.def
echo -e "\nmodule = {\n name = qnap8528led;\n common = qnap8528led.c;\n};" >> grub-core/Makefile.core.def
# Prepare build environment
./autogen.sh
./configure --prefix=/usr --target=x86_64 --with-platform=efi
# Build
make -j$(nproc)
Once the build process is completed (successfully—make
output can be confusing, so I like to run echo $?
after make
to check that the exit code is 0
), the new module should be located under the grub-core
directory with a .mod
extension. Now the module can be installed on the target machine (or a test VM). I exited the container environment and pulled the file from the container (again, I use Incus; use whatever method matches your development environment).
# Exit container shell back to host
exit
# Download file from container
incus file pull grub-module-env/root/grub2-2.06/grub-core/qnap8528led.mod /root/qnap8528led.mod
All that is left is to install the module and use it in GRUB. The most basic installation approach is to copy the module manually to the /boot/grub/x86_64-efi
directory. This is enough for commands like GRUB’s insmod
to find and load the module when needed. However, to ensure the module is copied whenever grub-install
is executed, it should be copied to /usr/lib/grub/x86_64-efi/
, and then grub-install
should be run again (with whatever parameters are required for the specific system).
Now it’s time to use the module. In the case of my module, all that is needed is for the module to be inserted. So, I edited /etc/default/grub
and added GRUB_PRELOAD_MODULES="qnap8528led"
. This will cause GRUB to autoload the module (essentially, run an insmod
command) at the beginning of the GRUB config file (/boot/grub/grub.cfg
). Once the module was copied and the file change was made, I ran grub-install
to copy the modules over, update-grub
to regenerate the config, and everything should work from here.
Since I was working on this post while away from my device, to demonstrate and test the basic module loading on GRUBs starts in a VM, I changed all the grub_inb
and grub_outb
commands to grub_printf
with the values I was writing so it could load on the VM that does not have the QNAP EC hardware. And here is the unimpressive result (you can see the prints just before the GRUB menu appears):