Part 2: Testing with QEMU and Creating Your First Custom Layer
In Part 1, we built a minimal Linux image for the Raspberry Pi 4 using
Yocto and KAS. The system boots, but we can't do much with it yet. Before adding features to our embedded system, we need two things: a way to test quickly without hardware,
and a place to put our custom code.
This part covers testing with QEMU and creating your first custom Yocto layer.
Why QEMU ?
Not everyone has a Raspberry Pi on their desk, and even if you do, the flash-boot-test cycle is slow. QEMU lets you build and test images without hardware, with boot times
measured in seconds rather than minutes.
After publishing Part 1, a reader, Juha Viitanen, built on the tutorial and created their own custom
layer with QEMU support. This demonstrates both that the approach works and that QEMU testing is a natural next step.
Adding QEMU Support
Let's add a QEMU configuration to our project. The project_qemu.yml would share most content with project.yml from Part 1, so first let's refactor to avoid duplication.
Extracting Common Configuration
Create ${PROJECT}/kas/include/common.yml with the shared parts:
header:
version: 18
distro: poky
target:
- core-image-minimal
repos:
poky:
url: "https://git.yoctoproject.org/git/poky"
branch: scarthgap
commit: 9c63e0c9646c61663e8cfc6b4c75865cd0cd3b34
layers:
meta:
meta-poky:
meta-yocto-bsp:
meta-openembedded:
url: "https://git.openembedded.org/meta-openembedded"
branch: scarthgap
commit: e92d0173a80ea7592c866618ef5293203c50544c
layers:
meta-oe:
local_conf_header:
local_dirs: |
DL_DIR = "/home/yocto/Projects/dl"
SSTATE_DIR = "/home/yocto/Projects/sstate-cache"
history: |
INHERIT += "buildhistory"
Now simplify ${PROJECT}/kas/project.yml from Part 1:
header:
version: 18
includes:
- include/common.yml
- include/raspberrypi.yml
machine: raspberrypi4-64
QEMU-Specific Configuration
Create ${PROJECT}/kas/include/qemuarm64.yml for the ARM toolchain:
header:
version: 18
repos:
meta-arm:
url: "https://git.yoctoproject.org/meta-arm"
branch: scarthgap
commit: 8e0f8af90fefb03f08cd2228cde7a89902a6b37c
layers:
meta-arm:
meta-arm-toolchain:
Now create ${PROJECT}/kas/project_qemu.yml:
header:
version: 18
includes:
- include/common.yml
- include/qemuarm64.yml
- include/yoseli-apps.yml
machine: qemuarm64
local_conf_header:
debug: |
EXTRA_IMAGE_FEATURES += "debug-tweaks"
A few things to notice:
-
include/common.yml - Shared repos and configuration from Part 1
-
machine: qemuarm64 - ARM64 QEMU machine instead of
raspberrypi4-64 -
include/qemuarm64.yml - Pulls in
meta-armfor ARM toolchain support -
include/yoseli-apps.yml - Our custom layer (we'll create this next)
-
debug-tweaks - Allows root login without password, useful for testing
The qemuarm64 machine uses the same ARM64 architecture as the Raspberry Pi 4, so recipes that work in QEMU will generally work on the real hardware.
Building the QEMU Image
Using the same project directory from Part 1 (`${PROJECT}` which we set to ${HOME}/Projects/yoseli):
cd ${PROJECT}
kas build kas/project_qemu.yml
The first build takes time as it compiles the kernel and toolchain. Subsequent builds leverage the sstate cache and complete in minutes.
Running QEMU
After the build completes, launch QEMU:
kas shell kas/project_qemu.yml -c "runqemu nographic slirp"
The options:
-
nographic - No GUI window, output goes to terminal (works over SSH)
-
slirp - User-mode networking, no root privileges required
You should see the kernel boot messages followed by a login prompt. Login as root (no password needed thanks to debug-tweaks).
To exit QEMU, press Ctrl+a then x.
Creating Your First Custom Layer
Now we have a fast test environment. Let's create a custom layer to hold our code.
A Yocto layer is a directory containing recipes, configuration, and other metadata. Layers provide separation of concerns: upstream code stays upstream, your customizations
stay in your layer.
Using bitbake-layers to Create the Layer
Yocto provides the bitbake-layers tool to create a properly structured layer:
$> kas shell kas/project_qemu.yml -c "bitbake-layers create-layer ../meta-example"
NOTE: Starting bitbake server...
Add your new layer with 'bitbake-layers add-layer /tmp/meta-example'
$> tree /tmp/meta-example/
/tmp/meta-example/
├── conf
│ └── layer.conf
├── COPYING.MIT
├── README
└── recipes-example
└── example
└── example_0.1.bb
The generated layer.conf contains the boilerplate we need:
BBPATH .= ":${LAYERDIR}"
BBFILES += "${LAYERDIR}/recipes-*/*/*.bb \
${LAYERDIR}/recipes-*/*/*.bbappend"
BBFILE_COLLECTIONS += "meta-example"
BBFILE_PATTERN_meta-example = "^${LAYERDIR}/"
BBFILE_PRIORITY_meta-example = "6"
LAYERDEPENDS_meta-example = "core"
LAYERSERIES_COMPAT_meta-example = "scarthgap"
The example recipe is just a placeholder that prints a banner during build. For our layer, we'll use the same structure but with our own name and recipes.
Creating meta-yoseli-apps
Create the layer:
$> kas shell kas/project_qemu.yml -c "bitbake-layers create-layer ../meta-yoseli-apps"
Then add a directory for our hello recipe:
$> mkdir -p meta-yoseli-apps/recipes-example/hello/files
The layer.conf generated by the tool is almost ready. Just verify that BBFILE_COLLECTIONS and related variables use meta-yoseli-apps as the layer name:
BBFILE_COLLECTIONS += "meta-yoseli-apps"
BBFILE_PATTERN_meta-yoseli-apps = "^${LAYERDIR}/"
BBFILE_PRIORITY_meta-yoseli-apps = "6"
LAYERDEPENDS_meta-yoseli-apps = "core"
LAYERSERIES_COMPAT_meta-yoseli-apps = "scarthgap"
Key elements:
-
BBFILE_COLLECTIONS - Unique name for this layer
-
BBFILE_PRIORITY - Higher numbers take precedence in conflicts
-
LAYERSERIES_COMPAT - Declares compatibility with Scarthgap release
Your First Recipe: Hello World
A recipe tells BitBake how to build a package. Create recipes-example/hello/files/hello.c:
#include <stdio.h>
int main(void)
{
printf("Hello from Yoseli!\n");
printf("This is your first custom Yocto recipe.\n");
return 0;
}
Now create the recipe recipes-example/hello/hello_1.0.bb:
SUMMARY = "Simple Hello World application"
DESCRIPTION = "A minimal C application to demonstrate Yocto recipe creation"
SECTION = "examples"
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
SRC_URI = "file://hello.c"
S = "${WORKDIR}"
do_compile() {
${CC} ${CFLAGS} ${LDFLAGS} ${WORKDIR}/hello.c -o hello
}
do_install() {
install -d ${D}${bindir}
install -m 0755 ${S}/hello ${D}${bindir}/hello
}
Let's break this down:
-
SUMMARY/DESCRIPTION - Human-readable package info
-
LICENSE - Must specify a license; we use MIT
-
LIC_FILES_CHKSUM - Checksum of the license file (prevents silent license changes)
-
SRC_URI - Where to get source files;
file://means local files infiles/subdirectory -
S - Source directory, set to WORKDIR for simple recipes
-
do_compile() - How to build; uses cross-compiler variables
${CC},${CFLAGS},${LDFLAGS} -
do_install() - Where to put built files;
${D}is the destination root,${bindir}is/usr/bin
Alternative: Hello World with devtool
The manual approach above teaches recipe fundamentals, but Yocto provides devtool for a more streamlined workflow. Let's create the same hello recipe using devtool.
What is devtool?
devtool is a command-line tool that simplifies common recipe development tasks:
-
devtool add - Create a new recipe from source code
-
devtool modify - Set up a workspace to modify an existing recipe
-
devtool build - Build a recipe in the workspace
-
devtool finish - Move a recipe from workspace to a layer
The workspace is a temporary area where devtool manages recipe development before you integrate it into your layer.
Creating Hello with devtool
First, prepare a source directory.
Important: Initialize git before running devtool add - this is required for devtool finish to work later:
$> mkdir -p /tmp/hello-devtool
$> cd /tmp/hello-devtool
$> git init
Create the source file:
cat > hello.c << 'EOF'
#include <stdio.h>
int main(void)
{
printf("Hello from devtool!\n");
printf("This recipe was created with devtool add.\n");
return 0;
}
We need a Makefile since devtool expects standard build systems. Include a clean target for devtool reset to work:
cat > Makefile << 'EOF'
CC ?= gcc
CFLAGS ?= -Wall
PREFIX ?= /usr
hello: hello.c
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $<
install: hello
install -d $(DESTDIR)$(PREFIX)/bin
install -m 0755 hello $(DESTDIR)$(PREFIX)/bin/
clean:
rm -f hello
.PHONY: install clean
EOF
Commit the source files:
$> git add .
$> git commit -m "Initial hello-devtool source"
Return to the project directory and use devtool to create the recipe:
$> cd ${PROJECT}
$> kas shell kas/project_qemu.yml -c "devtool add hello-devtool /tmp/hello-devtool"
NOTE: Starting bitbake server...
INFO: Using source tree as build directory since that would be the default for this recipe
INFO: Recipe .../build/workspace/recipes/hello-devtool/hello-devtool.bb has been automatically created; further editing may be required to make it fully functional
devtool creates two files in build/workspace/:
-
The recipe in
recipes/hello-devtool/hello-devtool.bb -
A bbappend in
appends/hello-devtool_%.bbappendthat overrides the source location
Examine the generated recipe:
cat build/workspace/recipes/hello-devtool/hello-devtool.bb
# Recipe created by recipetool
LICENSE = "CLOSED"
LIC_FILES_CHKSUM = ""
SRC_URI = ""
do_configure () {
:
}
do_compile () {
oe_runmake
}
do_install () {
oe_runmake install 'DESTDIR=${D}'
}
Notice SRC_URI = "" - the recipe has no source location. Now look at the bbappend that makes the magic happen:
$> cat build/workspace/appends/hello-devtool_%.bbappend
inherit externalsrc
EXTERNALSRC = "/tmp/hello-devtool"
EXTERNALSRC_BUILD = "/tmp/hello-devtool"
The externalsrc class tells BitBake to:
-
Use
EXTERNALSRCas the source directory (instead of fetching fromSRC_URI) -
Use
EXTERNALSRC_BUILDas the build directory
This means any changes you make to /tmp/hello-devtool are immediately used in the next build.
No need to commit or push to a remote repository during development.
The LICENSE = "CLOSED" is a placeholder ! You'll need to fix this for production.
Building with devtool
Build the recipe in the workspace:
$> kas shell kas/project_qemu.yml -c "devtool build hello-devtool"
NOTE: Executing Tasks
NOTE: hello-devtool: compiling from external source tree /tmp/hello-devtool
NOTE: Tasks Summary: Attempted 762 tasks of which 757 didn't need to be rerun and all succeeded.
Finishing: Moving to Your Layer
Clean build artifacts and move the recipe to your layer:
$> make -C /tmp/hello-devtool clean
$> kas shell kas/project_qemu.yml -c "devtool finish hello-devtool meta-yoseli-apps"
NOTE: Starting bitbake server...
INFO: No patches or files need updating
INFO: Moving recipe file to .../meta-yoseli-apps/recipes-hello-devtool/hello-devtool
INFO: Leaving source tree /tmp/hello-devtool as-is; if you no longer need it then please delete it manually
The recipe is now in meta-yoseli-apps/recipes-hello-devtool/hello-devtool/.
Important: The finished recipe still has SRC_URI = "".
For production, you need to:
-
Host your source in a git repository (GitHub, GitLab, etc.)
-
Update
SRC_URIto point to that repository -
Set proper
LICENSEandLIC_FILES_CHKSUM
Cleaning Up
Note: devtool finish automatically removes the recipe from the workspace, so no reset is needed after a successful finish. Use devtool reset only if you want to
abandon changes without finishing:
# Only needed if you want to discard workspace changes instead of finishing
$> kas shell kas/project_qemu.yml -c "devtool reset -n hello-devtool"
The -n flag skips running make clean (useful if already cleaned or no clean target).
When to Use devtool vs Manual
-
manual: Learning recipe structure, simple local recipes, full control
-
devtool add: Packaging upstream git projects, quick prototyping
-
devtool modify: Patching existing recipes, debugging build issues
For simple local applications like our hello example, the manual approach is often simpler.
devtool shines when:
-
Working with upstream git repositories (SRC_URI is auto-generated)
-
Debugging and patching existing recipes with
devtool modify -
Rapid prototyping before finalizing a recipe
If you want to learn more about devtool, check out Devtool Hands-On Class from Michael Opdenacker.
Customizing Existing Recipes
Understanding DISTRO Overrides in File Search
Before we modify existing recipes, we need to understand how BitBake searches for files. This is crucial for avoiding a common pitfall.
When BitBake looks for a file specified in SRC_URI (like file://motd), it doesn't just search directories in order. It searches for
override-qualified versions first.
Running bitbake -e base-files | grep ^FILESPATH= shows the actual search order (simplified):
FILESPATH=".../meta-yoseli-apps/.../files/poky:
.../meta-poky/.../files/poky:
.../meta/.../files/poky:
.../meta-yoseli-apps/.../files/qemuarm64:
.../meta-poky/.../files/qemuarm64:
.../meta/.../files/qemuarm64:
.../meta-yoseli-apps/.../files/aarch64:
...
.../meta-yoseli-apps/.../files/:
.../meta-poky/.../files/:
.../meta/.../files/"
The pattern is:
-
DISTRO override (`files/poky/`) - searched first across all layers
-
MACHINE override (`files/qemuarm64/`) - searched second
-
ARCH override (`files/aarch64/`) - searched third
-
Plain files (`files/`) - searched last
This means if meta-poky provides files/poky/motd and you create files/motd, the distro-qualified version wins even though your layer has higher priority in
FILESEXTRAPATHS.
Modifying Existing Recipes with bbappend
Sometimes you need to customize an existing recipe without forking it. That's what .bbappend files are for.
Let's customize the login message (motd). The base-files recipe in meta-poky provides a files/poky/motd with a Poky warning message.
To override it, we must match the path structure.
Create the directory structure:
$> mkdir -p meta-yoseli-apps/recipes-core/base-files/files/poky
Create recipes-core/base-files/files/poky/motd:
__ __ _ _
\ \ / /__ ___ ___| (_)
\ V / _ \/ __|/ _ \ | |
| | (_) \__ \ __/ | |
|_|\___/|___/\___|_|_|
Custom Embedded Linux - Built with Yocto & KAS
https://www.yoseli.org
Tip: The ASCII art banner was generated with figlet:
$> figlet Yoseli
Now create recipes-core/base-files/base-files_%.bbappend:
# Customize base system files for Yoseli
# Our files/poky/motd overrides meta-poky's version via FILESEXTRAPATHS
FILESEXTRAPATHS:prepend := "${THISDIR}/files:"
Key concepts:
-
base-files_%.bbappend: The
%wildcard matches any version ofbase-files -
FILESEXTRAPATHS:prepend: Adds our
files/directory to the front of the search path -
files/poky/motd: Matches the DISTRO override, ensuring our file is found before meta-poky's
Integrating the Layer with KAS
Create kas/include/yoseli-apps.yml:
header:
version: 18
repos:
meta-yoseli-apps:
path: meta-yoseli-apps
local_conf_header:
yoseli-apps: |
IMAGE_INSTALL:append = " hello"
This tells KAS where to find our layer and adds the hello package to the image.
Building and Testing
Build the updated image:
$> kas build kas/project_qemu.yml
Launch QEMU and test:
$> kas shell kas/project_qemu.yml -c "runqemu nographic slirp"
Here's a demo of the QEMU boot with our custom layer:
After logging in as root, you should see the custom Yoseli banner:
__ __ _ _
\ \ / /__ ___ ___| (_)
\ V / _ \/ __|/ _ \ | |
| | (_) \__ \ __/ | |
|_|\___/|___/\___|_|_|
Custom Embedded Linux - Built with Yocto & KAS
https://www.yoseli.org
qemuarm64 login: root
root@qemuarm64:~# hello
Hello from Yoseli!
This is your first custom Yocto recipe.
Essential Debugging Commands
When things don't work, these commands help:
# List all layers recognized by BitBake
$> bitbake-layers show-layers
# Show which recipes have bbappend files modifying them
$> bitbake-layers show-appends
# Display the computed value of a variable for a recipe
$> bitbake -e base-files | grep ^FILESPATH=
# Show the file search paths for a recipe
$> bitbake -e base-files | grep ^FILESEXTRAPATHS=
# Clear all build artifacts and force a complete rebuild
$> bitbake -c cleansstate base-files
# Run only the unpack task to inspect source files
$> bitbake -c unpack base-files
# Examine the unpacked files in the work directory
$> cat tmp/work/*/base-files/*/motd
What We Built
In this part, we:
-
Added QEMU support for fast iteration without hardware
-
Created a custom layer (`meta-yoseli-apps`)
-
Wrote a recipe from scratch (`hello`) - both manually and with devtool
-
Learned the devtool workflow: add → build → finish
-
Modified an existing recipe with bbappend (custom motd)
-
Learned about DISTRO overrides in file search paths
-
Integrated everything with KAS
The full code is available in the yocto-blog-posts repository on the part2-qemu-custom-layer branch.
Next Steps
The system now boots with custom software, but it's still missing practical features:
-
SSH access for remote management
-
User accounts with proper authentication
-
Secrets management
This article is part of a series on building embedded Linux systems with Yocto and KAS. Questions or feedback? Open an issue on GitHub.