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 and grub/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 of mem*, str*, and is* 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 or grub/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 or grub_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:

  1. Prefixing with grub_
    First, note the prefix grub_ for types (e.g., grub_uint16_t for uint16_t) and what would be standard library calls such as inb and outb (which are grub_inb and grub_outb in GRUB). Almost everything in GRUB is prefixed with grub_. This is also true for functions like malloc, free, strcmp, memset, and others.

  2. Return Type of qnap8528led_wait_ibf_clear
    Second, note the return type of qnap8528led_wait_ibf_clear. I could have used a simple int or grub_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 is grub_err_t, which is an enum defined in grub/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. The N_ macro for the error string is used for localization, which is standard practice when dealing with strings in GRUB.

  3. Function Arguments
    Third, let’s talk about function arguments. Notice that I have a void in the function declaration as a parameter even though it takes no arguments. Omitting this will cause the build to error with error: 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 function void do_stuff(grub_int32_t arg1) and arg1 is not used, it must be declared as void 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):