What You Will Learn
A kernel module often needs to know something about the hardware it is running on — the CPU architecture, endianness, and word size. This lecture shows you how to detect these details from inside a kernel module using compile-time macros and how to write portable kernel code that works correctly across multiple architectures.
- How to use architecture macros like
CONFIG_ARM64,CONFIG_X86, andBITS_PER_LONG - How to use
pr_info()and related functions safely in Linux 6.x - What
EXPORT_SYMBOLdoes and when you need it - Why unsafe string functions like
sprintfandstrlenshould be avoided in kernel code - How to write portable kernel module code that compiles for both ARM and x86
- Security-aware coding practices for kernel modules in Linux 6.x
This is part of our completely free Linux device drivers course and free Linux kernel development course at EmbeddedPathashala.
You should already know:
Why a Kernel Module Needs to Know the System Architecture
When you write a kernel module for a product that ships on multiple hardware platforms — say, the same driver running on an x86 server, an ARM64 embedded board, and a 32-bit ARM IoT device — your code needs to behave differently depending on the underlying hardware.
For example, the size of a long type differs between a 32-bit and a 64-bit system. DMA buffer alignment requirements differ between architectures. Some hardware features exist only on certain CPU families. Detecting these at compile time using macros is the standard kernel approach.
The Linux kernel exposes a rich set of compile-time configuration macros that you can use inside your module source to write conditional code. These macros are set by the kernel’s Kconfig build system when the kernel is configured for a specific architecture.
| Macro | Defined When | Example Platform |
|---|---|---|
| CONFIG_X86 | Building for x86 or x86_64 | Intel / AMD desktop, server |
| CONFIG_ARM | Building for 32-bit ARM | Raspberry Pi (32-bit OS), STM32MP |
| CONFIG_ARM64 | Building for 64-bit ARM (AArch64) | RPi 4/5, Jetson, RK3588 boards |
| CONFIG_MIPS | Building for MIPS architecture | MediaTek routers, OpenWRT targets |
| CONFIG_PPC | Building for PowerPC | Some industrial / telecom hardware |
| BITS_PER_LONG == 32 | 32-bit address space (long = 4 bytes) | 32-bit ARM, x86 |
| BITS_PER_LONG == 64 | 64-bit address space (long = 8 bytes) | ARM64, x86_64 |
| __BIG_ENDIAN | Big-endian byte order | Some PowerPC / MIPS configs |
Writing a Portable System Info Function in a Kernel Module
Here is a complete example of a portable system information function you can include in your kernel module. This is original code written for Linux 6.x — it detects the CPU architecture, endianness, and word size at compile time and logs them via pr_info():
// sysinfo_lkm.c — Portable system info for Linux 6.x kernel modules
// License: GPL-2.0
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("EmbeddedPathashala");
MODULE_DESCRIPTION("Portable system info demo — Linux 6.x");
MODULE_VERSION("1.0");
static void show_platform_info(void)
{
/* Use a local buffer to build the info string */
char arch_str[32] = "Unknown";
char bits_str[16] = "?-bit";
char endian_str[16] = "?-endian";
/* Detect CPU architecture at compile time */
#ifdef CONFIG_X86
#if (BITS_PER_LONG == 32)
strscpy(arch_str, "x86-32", sizeof(arch_str));
#else
strscpy(arch_str, "x86_64", sizeof(arch_str));
#endif
#elif defined(CONFIG_ARM)
strscpy(arch_str, "ARM-32", sizeof(arch_str));
#elif defined(CONFIG_ARM64)
strscpy(arch_str, "ARM64 (AArch64)", sizeof(arch_str));
#elif defined(CONFIG_MIPS)
strscpy(arch_str, "MIPS", sizeof(arch_str));
#elif defined(CONFIG_PPC)
strscpy(arch_str, "PowerPC", sizeof(arch_str));
#elif defined(CONFIG_S390)
strscpy(arch_str, "IBM S390", sizeof(arch_str));
#endif
/* Detect word size */
#if (BITS_PER_LONG == 32)
strscpy(bits_str, "32-bit", sizeof(bits_str));
#elif (BITS_PER_LONG == 64)
strscpy(bits_str, "64-bit", sizeof(bits_str));
#endif
/* Detect byte order */
#ifdef __BIG_ENDIAN
strscpy(endian_str, "big-endian", sizeof(endian_str));
#else
strscpy(endian_str, "little-endian", sizeof(endian_str));
#endif
pr_info("Platform info: CPU=%s | %s | %s OS\n",
arch_str, endian_str, bits_str);
}
static int __init sysinfo_init(void)
{
pr_info("sysinfo_lkm: loaded\n");
show_platform_info();
return 0;
}
static void __exit sysinfo_exit(void)
{
pr_info("sysinfo_lkm: unloaded\n");
}
module_init(sysinfo_init);
module_exit(sysinfo_exit);
In Linux 6.x, the kernel strongly discourages strcpy, sprintf, strlen, and strncat in kernel code. These functions can cause buffer overflows and are targets of kernel exploits. The preferred replacements are strscpy() (safe string copy with size limit), snprintf() (safe formatted string), and strlcat(). Use these always. Static analysis tools like sparse and the kernel’s own checkpatch.pl will warn you about unsafe function usage.
What is EXPORT_SYMBOL and When Do You Use It in Linux 6.x
When you look at kernel module source code, you will often see EXPORT_SYMBOL() or EXPORT_SYMBOL_GPL() macros after function definitions. These are used when one kernel module wants to make a function available for use by other kernel modules.
| Macro | What It Does |
|---|---|
EXPORT_SYMBOL(func) |
Makes func available to all kernel modules, including non-GPL ones |
EXPORT_SYMBOL_GPL(func) |
Makes func available only to modules with MODULE_LICENSE(“GPL”) |
| No export macro | Function is private to the module — other modules cannot call it |
For a simple standalone module, you do not need EXPORT_SYMBOL. You need it only when you are building a multi-module system where one module provides shared services to others — for example, a base driver module that other protocol modules build on top of.
/* Only needed if another .ko module needs to call this function */
EXPORT_SYMBOL_GPL(show_platform_info);
In Linux 6.x, most new kernel subsystem APIs are exported with EXPORT_SYMBOL_GPL, which means your module must declare MODULE_LICENSE("GPL") to use them. This is intentional — it encourages open-source device driver development.
Security-Aware Kernel Module Coding Practices for Linux 6.x
Writing kernel modules is fundamentally different from writing user-space programs. A bug in a kernel module can crash the entire system, corrupt memory, or create a security vulnerability that an attacker can exploit. Linux 6.x has introduced many improvements to catch these bugs at compile time, but you need to follow safe coding practices from the start.
Avoid sprintf(), strcpy(), strlen() on unvalidated input, and strcat(). Use their safe alternatives: snprintf(), strscpy(), strnlen(), strlcat(). The kernel provides these safe versions specifically to prevent buffer overflow exploits.
If your module receives data from user space (through sysfs, procfs, ioctl, or character device read/write), always validate size and content before using it. Use copy_from_user() and copy_to_user() — never dereference user-space pointers directly in kernel context.
The kernel ships with scripts/checkpatch.pl for style and basic safety checks. Use sparse (install with apt install sparse) by building with make C=1 or make C=2. These tools catch type mismatches, implicit function declarations, and unsafe operations before they reach the device.
# Run sparse on your module during build
make C=2 ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- M=$(pwd) -C /path/to/kernel modules
Always declare MODULE_LICENSE("GPL") for open-source modules. Modules without a recognized license are marked as “tainted” when loaded, which means the kernel logs this and the kernel community will not help debug issues on a tainted kernel. More importantly, without GPL, your module cannot use EXPORT_SYMBOL_GPL()-exported functions — which covers most modern kernel APIs.
Long chains of #ifdef CONFIG_xxx in source code make the code hard to read and maintain. A better approach is to isolate architecture-specific code in separate source files selected by Kconfig, or use the kernel’s IS_ENABLED() macro which evaluates cleaner in many contexts. For simple detection like the example above, a short #ifdef chain is acceptable — but keep it minimal.
Running Your Module and Reading the Output
After compiling and loading the sysinfo module, use dmesg to see the output. On an x86_64 machine:
sudo insmod ./sysinfo_lkm.ko
dmesg | tail -5
Expected output on x86_64 Linux 6.x:
[12345.678901] sysinfo_lkm: loaded
[12345.678910] Platform info: CPU=x86_64 | little-endian | 64-bit OS
If you cross-compile and run the same module on an ARM64 board running Linux 6.x:
[ 80.123456] sysinfo_lkm: loaded
[ 80.123460] Platform info: CPU=ARM64 (AArch64) | little-endian | 64-bit OS
And on a 32-bit ARM device (like a board running a 32-bit ARM Linux kernel):
[ 80.234567] sysinfo_lkm: loaded
[ 80.234570] Platform info: CPU=ARM-32 | little-endian | 32-bit OS
This shows the power of portable kernel module code: the same source compiles correctly for all three architectures and reports accurate information at runtime.
Understanding BITS_PER_LONG and Portable Data Types in the Linux Kernel
One of the most common sources of bugs in kernel code — especially when moving between 32-bit and 64-bit ARM — is incorrect assumptions about data type sizes. The size of long, pointer, and int changes between architectures.
| Type | ARM32 (bytes) | ARM64 (bytes) | x86_64 (bytes) | Notes |
|---|---|---|---|---|
| char | 1 | 1 | 1 | Always 1 byte |
| int | 4 | 4 | 4 | Always 4 bytes on Linux |
| long | 4 | 8 | 8 | Changes with arch! Use BITS_PER_LONG |
| pointer (void *) | 4 | 8 | 8 | Same as long on Linux |
| u32 (kernel type) | 4 | 4 | 4 | Guaranteed 32-bit, use for fixed-width |
| u64 (kernel type) | 8 | 8 | 8 | Guaranteed 64-bit, use for fixed-width |
The key takeaway: never use long or pointers for values that must be a fixed width. Use the kernel’s fixed-width types: u8, u16, u32, u64, s8, s16, s32, s64. These are defined in <linux/types.h> and are always exactly the right size regardless of architecture.
🌟 Key Takeaways
Frequently Asked Questions
pr_info() is a macro wrapper around printk() that automatically adds the KERN_INFO log level prefix. In Linux 6.x, pr_info(), pr_warn(), pr_err(), and similar macros are the preferred way to log from kernel modules. They are cleaner than using printk(KERN_INFO "...") directly and produce better-formatted output. Always use pr_info() and its family instead of raw printk().
No. printf() is a user-space C library function. It does not exist in the kernel. The kernel equivalent is printk(), and the preferred wrapper macros are pr_info(), pr_err(), pr_warn(), and pr_debug(). Similarly, standard C library functions like malloc(), free(), fopen() do not exist in kernel space. Use their kernel equivalents like kmalloc(), kfree(), and the VFS layer for file operations.
No, they are separate macros for separate architectures. CONFIG_ARM is defined when building for 32-bit ARM (ARMv7 and earlier). CONFIG_ARM64 is defined when building for 64-bit ARM (ARMv8, AArch64). On a Raspberry Pi 5 running a 64-bit OS, CONFIG_ARM64 is defined and CONFIG_ARM is not. On an older board running a 32-bit ARM OS, only CONFIG_ARM is defined. Check /proc/version or uname -m on the device to confirm the running architecture.
Use EXPORT_SYMBOL_GPL() when your function is part of a subsystem that should only be accessible to GPL-licensed modules. This is the practice followed by the Linux kernel’s own subsystems for most new exports. Use EXPORT_SYMBOL() for functions that need to be accessible to all modules, including proprietary ones. As a general rule for new code: prefer EXPORT_SYMBOL_GPL() unless you have a specific reason to allow non-GPL access.
When a kernel module without a recognized open-source license is loaded, the kernel marks itself as “tainted.” You can see this in dmesg as “Tainted: P” (P = proprietary module loaded). A tainted kernel means kernel developers will generally not help debug issues because the proprietary module could be the cause. Additionally, some kernel features behave differently on tainted kernels. To avoid tainting, always declare MODULE_LICENSE("GPL") or another recognized license in your module.
This depends on which OS image you are running, not the hardware itself. For example, a Raspberry Pi 4 has a 64-bit ARM Cortex-A72 CPU, but it can run both a 32-bit OS and a 64-bit OS. If you run a 32-bit Raspberry Pi OS, the kernel is compiled with CONFIG_ARM and BITS_PER_LONG is 32. If you run a 64-bit OS (Ubuntu 22.04 or 64-bit Raspberry Pi OS), the kernel has CONFIG_ARM64 and BITS_PER_LONG is 64. The CPU hardware capability and the OS bitness are two separate things.
Run these commands on the target device to gather everything you need before setting up your cross-compile environment: uname -r gives the exact kernel version, uname -m gives the machine architecture (aarch64 for ARM64, armv7l for 32-bit ARM), and cat /proc/version gives the full version string including the compiler used to build the kernel. Match your cross-compiler and kernel source tree to these values.
Conclusion
Writing portable kernel modules that correctly detect and adapt to the underlying architecture is a skill that separates professional embedded Linux developers from beginners. The Linux kernel’s compile-time macros — CONFIG_ARM64, CONFIG_X86, BITS_PER_LONG, __BIG_ENDIAN — give you everything you need to write a single source file that compiles correctly for multiple architectures.
Combined with safe string handling using strscpy() and snprintf(), the correct use of EXPORT_SYMBOL_GPL(), and running static analysis with sparse and checkpatch.pl, you are building kernel modules the right way — safe, portable, and ready for the Linux 6.x ecosystem.
In the next lecture, we will go deeper into the Linux kernel’s LKM framework and explore module parameters, the sysfs interface, and how to expose module configuration to user space.
Keep Learning — Free Linux Device Drivers Course
All courses at EmbeddedPathashala are completely free. No registration. No paywalls. Learn Linux kernel programming, device drivers, and embedded systems at your own pace.
🏠 Visit EmbeddedPathashala