Tutorial · start here
Board bring-up
Take a bare KrIO and a K26 from power-on to running your own logic, covering boot images, console, network, a Linux image built with PetaLinux or Yocto, and the rules this board imposes on anything you build for it.
Updated 2026-08-28
This is the path from an unpopulated carrier to a board that boots Linux, joins the network, and runs programmable logic you built yourself. Work through it in order, because each step assumes the previous one.
Tool versions are deliberately left open. Anything from Vivado/Vitis 2022.1 onward
works. The commands below use $XILINX for whichever you have installed, and the two
places where a version genuinely matters are noted explicitly.
export XILINX=$HOME/Xilinx/2025.2 # whichever release you installed
export PATH=$XILINX/Vitis/bin:$XILINX/Vivado/bin:$PATH
What you need
| Carrier | KrIO |
| Module | Kria K26 SOM (xck26-sfvc784-2LV-c) in J1 |
| Power | 12 V into the 6-pin PCIe connector |
| Console | USB serial adapter on the debug header, 115200 8N1 |
| Optional | Digilent JTAG adapter for the first flash |
| Host | Linux with Vivado/Vitis, and a wired network the board can reach |
1. Power and console
Bring power up and watch the console before anything else. The board tells you how far it got.
ls /dev/serial/by-id/ # find the adapter; the number moves, the id does not
picocom -b 115200 /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0
Use the by-id path. A JTAG adapter on the same host will take ttyUSB0 and shift the
console to ttyUSB1 without warning.
Power-on line noise can abort U-Boot’s autoboot and leave a half-typed command in its
buffer. Before typing anything, send a lone " and a carriage return to close any
unterminated quote, then Ctrl-C. Type slowly, because fast writes drop characters on
this adapter.
Rule Never reboot. Warm reset does not work on
this board: the boot-time QSPI drivers leave the flash in a non-power-on state and the
BootROM hangs silently before any output. Cut power instead. It is 100% reproducible and
cold boot is 100% reliable. See the errata.
2. What is in a boot image
BOOT.BIN is a container. Built with bootgen from a .bif, it holds, in order:
| Component | Job |
|---|---|
| FSBL | configures the PS from psu_init (clocks, DDR, I/O, AXI ports) |
| PMU firmware | power management |
| BL31 | ARM trusted firmware |
| U-Boot | loads the kernel |
| (optionally) a bitstream | PL configured at boot rather than from Linux |
Omit the bitstream. A boot image without one is about 1.7 MB instead of 9.5 MB, which matters for flashing (below), and loading the PL from Linux is more flexible anyway.
the_ROM_image:
{
[bootloader, destination_cpu=a53-0] fsbl.elf
[pmufw_image] pmufw.elf
[destination_cpu=a53-0, exception_level=el-3, trustzone] bl31.elf
[destination_cpu=a53-0, exception_level=el-2] u-boot.elf
}
bootgen -image boot.bif -arch zynqmp -o BOOT.BIN -w
Do not pass
--fpga <file>for a file that does not exist.bootgendrops the FSBL silently and produces an image that boots to nothing at all.
3. Getting the boot image onto QSPI
Two routes. Use JTAG the first time, then the board can flash itself.
JTAG, from Vivado’s hardware manager. This works reliably only for images under
about 2 MB, and larger ones hang partway, which is why the bitstream stays out of
BOOT.BIN.
open_hw_manager
connect_hw_server -url localhost:3121
open_hw_target
create_hw_cfgmem -hw_device [lindex [get_hw_devices] 0] \
[lindex [get_cfgmem_parts {mt25qu512-qspi-x4-single}] 0]
program_hw_cfgmem -hw_cfgmem [current_hw_cfgmem]
Self-flash from U-Boot is faster, and the only option for large images. Serve the file over TFTP from the host, then write it:
ZynqMP> setenv ipaddr 192.168.0.60; setenv serverip <your host's address>
ZynqMP> tftpboot 0x10000000 BOOT.BIN
ZynqMP> sf probe 0 0 0
ZynqMP> sf update 0x10000000 0 $filesize
Rule Never run saveenv. U-Boot’s environment
offset on this board falls inside the region BOOT.BIN occupies, so saving the
environment corrupts the boot image.
Rule Never cut power during a flash write.
4. Building a Linux image
There are two ways to produce the kernel, device tree and root filesystem. They end in
the same place, an Image, a .dtb and a rootfs the board boots, and every section
after this one is identical whichever you pick.
PetaLinux is the vendor flow. It wraps Yocto, hides most of it, and is the shortest route to a booting board. Take it if you want the board up today, or if you already have a PetaLinux project for another Xilinx design.
Yocto is the same machinery without the wrapper: upstream layers, a machine you define, recipes you can read. Take it if you are going to maintain this for years, need to pin exactly what goes into the image, or want a build a colleague can reproduce from a git hash.
Which path do you want to follow?
The rest of this page follows your choice. You can switch at any time.
Both build paths are shown below, each under its own label.
PetaLinux path
Put the kernel, device tree and root filesystem on eMMC, with a FAT boot partition holding
Image, system.dtb and boot.scr, and an ext4 root.
Compile boot.cmd into the script U-Boot reads:
env set -f ethaddr 00:0a:35:24:07:fd
env set -f eth1addr 00:0a:35:24:07:fe
load mmc 0:1 0x18000000 Image
load mmc 0:1 0x14000000 system.dtb
setenv bootargs console=ttyPS1,115200 earlycon clk_ignore_unused cma=900M \
root=/dev/mmcblk0p2 rw rootwait
booti 0x18000000 - 0x14000000
mkimage -c none -A arm64 -T script -d boot.cmd boot.scr
env set -f is not optional. U-Boot pre-populates a random, write-protected
ethaddr; a plain setenv fails silently and the device-tree fixup then stamps that
random MAC into the kernel. Your board gets a new address on every boot and DHCP
reservations never stick.
Yocto path
Budget about 100 GB of disk and several hours for the first build. Put the tree on a volume with room, not a home partition that is nearly full.
Run the build in a container
On Ubuntu 24.04 BitBake refuses to start:
ERROR: User namespaces are not usable by BitBake, possibly due to AppArmor.
kernel.apparmor_restrict_unprivileged_userns=1 lets an unprivileged process create a
user namespace but denies the write to /proc/self/uid_map that follows, which is what
BitBake’s sanity check probes. Confirm it in one line:
unshare -r --user --net id -u
# unshare: write failed /proc/self/uid_map: Operation not permitted
The documented fix is a sysctl that weakens the whole machine. You do not need it. Running the build in Docker with seccomp off and AppArmor left on works with no root:
| Setting | Result |
|---|---|
| Docker defaults | unshare blocked by the seccomp profile |
seccomp=unconfined | works, because the docker-default AppArmor profile grants userns |
seccomp=unconfined + apparmor=unconfined | fails again, back under the host restriction |
Turning AppArmor off as well is the intuitive move and the wrong one: the container’s AppArmor profile is what grants the permission.
docker run --security-opt seccomp=unconfined \
-v /mnt/build/yocto-krio:/mnt/build/yocto-krio \
-w /mnt/build/yocto-krio -u "$(id -u):$(id -g)" krio-yocto:scarthgap bash
Mount the tree at its real host path, not at /work. BitBake bakes absolute paths into
sstate signatures, so an identical path keeps the tree usable both inside the container and outside it.
Layers
Four repositories, all on scarthgap (Yocto 5.0 LTS):
for r in "git://git.yoctoproject.org/poky" \
"https://github.com/Xilinx/meta-xilinx" \
"https://github.com/openembedded/meta-openembedded" \
"https://git.yoctoproject.org/meta-arm"; do
git clone -b scarthgap "$r"
done
source poky/oe-init-build-env build
bitbake-layers add-layer ../meta-arm/meta-arm-toolchain
bitbake-layers add-layer ../meta-arm/meta-arm
bitbake-layers add-layer ../meta-xilinx/meta-xilinx-core
bitbake-layers add-layer ../meta-xilinx/meta-xilinx-bsp
bitbake-layers add-layer ../meta-openembedded/meta-oe
bitbake-layers add-layer ../meta-openembedded/meta-python
bitbake-layers add-layer ../meta-openembedded/meta-networking
meta-arm is not optional. From scarthgap onward meta-xilinx depends on it, and the
error you get without it does not say so.
Define the machine
meta-xilinx ships no Kria machine. Its own conf/machine/README describes the intended
chain (k26_kv → k26 → zynqmp-generic), but the middle is not in the layer, and
zynqmp-ev-generic is marked obsolete. Define your own on top of zynqmp-generic,
using the preamble and postamble that README specifies:
# meta-kria-custom/conf/machine/krio-k26.conf
MACHINEOVERRIDES =. "${@['', 'krio-k26:']['krio-k26' != '${MACHINE}']}"
require conf/machine/zynqmp-generic.conf
MACHINE_FEATURES += "mali400 vcu"
KERNEL_DEVICETREE = "xilinx/zynqmp-krio-k26.dtb"
SPL_BINARY = ""
PACKAGE_EXTRA_ARCHS:append = "${@['', ' krio_k26']['krio-k26' != "${MACHINE}"]}"
The preamble and postamble are what let sstate from a zynqmp-generic build carry over;
only the kernel, bootloader and device-tree recipes rebuild.
Device tree from the kernel tree
linux-xlnx already ships the SOM. zynqmp-sm-k26-revA.dts describes the module (DDR,
eMMC, QSPI, PMICs) and deliberately defines no GEM nodes, because those belong to
the carrier.
Use sm-k26, not smk-k26: the starter-kit variant differs in exactly one respect, it
disables sdhci0, and this SOM has that eMMC populated.
#include "zynqmp-sm-k26-revA.dts"
/ {
model = "Kria K26 on KrIO carrier";
compatible = "krio,k26-carrier", "xlnx,zynqmp-sm-k26-revA", "xlnx,zynqmp";
};
Add the Ethernet from the next section, then register the file with kbuild, because arm64 only builds device trees its directory Makefile lists:
# linux-xlnx_%.bbappend
do_configure:prepend() {
dtsdir="${S}/arch/arm64/boot/dts/xilinx"
install -m 0644 ${WORKDIR}/zynqmp-krio-k26.dts "$dtsdir/"
if ! grep -q "zynqmp-krio-k26.dtb" "$dtsdir/Makefile"; then
echo 'dtb-$(CONFIG_ARCH_ZYNQMP) += zynqmp-krio-k26.dtb' >> "$dtsdir/Makefile"
fi
}
Do not enable uart0. The console is uart1/ttyPS1, which the SOM file already
enables. The PMU firmware in QSPI refuses uart0’s power domain, so enabling it buys
two errors per boot and nothing else:
genpd_provider domain0: domain0 request failed for node 33: -13
xuartps ff000000.serial: probe with driver xuartps failed with error -13
Image configuration
Two settings in local.conf matter. The board boots from the BOOT.BIN already in QSPI,
so Yocto must not try to build a boot image, which would need an FSBL and PMU firmware
from the Xilinx tools:
EXTRA_IMAGEDEPENDS:remove = "virtual/boot-bin virtual/fsbl virtual/pmu-firmware"
And zynqmp-generic.conf appends wic.qemu-sd to IMAGE_FSTYPES with +=, parsing
after local.conf. A plain assignment will not remove it:
IMAGE_FSTYPES = "tar.gz ext4 cpio.gz"
IMAGE_FSTYPES:remove = "wic.qemu-sd"
Check both before starting a long build. This takes minutes and saves hours:
bitbake -e kria-custom-image > env.txt
grep -E '^(EXTRA_IMAGEDEPENDS|IMAGE_FSTYPES|KERNEL_DEVICETREE|MACHINE)=' env.txt
bitbake kria-custom-image
Set BB_NUMBER_THREADS and PARALLEL_MAKE from your memory, not your core count. On
a 24-core machine with 30 GB, six is right; matching the cores gets the build OOM-killed.
Booting an image without writing to the board
Whichever path you took, you can test an image entirely from RAM before committing it to
eMMC or QSPI. Nothing on the board is modified, and recovery from a bad image is a power
cycle. Build a cpio.gz rootfs and put the three files where TFTP can serve them, in a
subdirectory so you do not overwrite images already being served. At the U-Boot
prompt:
setenv serverip <your host>
setenv ipaddr 192.168.0.45
setenv bootargs 'console=ttyPS1,115200 root=/dev/ram0 rw earlycon'
tftpboot 0x00200000 yocto/Image
tftpboot 0x04000000 yocto/krio.dtb
tftpboot 0x06000000 yocto/rootfs.cpio.gz
booti 0x00200000 0x06000000:0x4202a2e 0x04000000
booti needs the ramdisk as address:size because a raw cpio.gz carries no uImage
header. Use the size U-Boot prints as Bytes transferred, and check it against the file
you staged. A truncated initramfs panics in a way that looks like a kernel bug.
Derive
serverip, do not remember it. Your host’s wired address is a DHCP lease and it moves. U-Boot’s only symptom for a wrongserveripisTFTP server died; starting again, which reads like a server fault.ip route get <board-ip>gives you the address that actually routes.
5. Ethernet
Both ports work, but the device tree has to describe them correctly, and two independent mistakes each produce a silent failure.
&gem1 {
phy-handle = <&phy0>;
phy-mode = "rgmii-id";
mdio {
#address-cells = <1>;
#size-cells = <0>;
phy0: phy@0 {
/* The ethernet-phy-id string MUST come first. Linux matches the
first compatible entry, and a generic driver on a DP83867 leaves
the RGMII delays unconfigured. */
compatible = "ethernet-phy-id2000.a231", "ti,dp83867";
reg = <0x0>;
device_type = "ethernet-phy";
ti,rx-internal-delay = <0x8>;
ti,tx-internal-delay = <0x8>;
ti,fifo-depth = <0x1>;
};
phy1: phy@5 { /* ... same, reg = <0x5> ... */ };
};
};
The second PHY is at address 5, not 1. The schematic annotates it wrongly; the silicon
straps to 0x5. The DP83867 uses four-level straps, so one divider does not encode one
address bit.
PetaLinux path
This goes inproject-spec/meta-user/recipes-bsp/device-tree/files/system-user.dtsi,
which PetaLinux merges over the device tree generated from your XSA.Yocto path
This goes in the carrier.dts from the previous section, alongside the
#include "zynqmp-sm-k26-revA.dts". The SOM file defines no GEM nodes at all, so
these are additions rather than overrides.6. Building logic the board will accept
Your PL design’s PS block never configures anything, because the FSBL already did that before Linux started. What it must do is agree with the FSBL that ran. Disagree, and the fabric gets no clock, or decodes at an address with nothing routed to it.
| Setting | Value |
|---|---|
PSU__CRL_APB__PL0_REF_CTRL__FREQMHZ | 100 |
PSU__FPGA_PL0_ENABLE | 1 |
PSU__USE__M_AXI_GP0 | 1 |
PSU__MAXIGP0__DATA_WIDTH | 128 |
Assert them in the build rather than trusting them:
dict for {k want} $PS_CONTRACT {
if {[get_property CONFIG.$k [get_bd_cells ps]] ne $want} {
error "PS setting $k disagrees with the fielded FSBL"
}
}
PL peripherals live in the 0xA000_0000 aperture reached through M_AXI_HPM0_FPD.
Convert the bitstream for run-time loading:
printf 'all:\n{\n\t[destination_device = pl] design.bit\n}\n' > design.bif
bootgen -image design.bif -arch zynqmp -process_bitstream bin -w
7. Loading and switching firmware from Linux
sudo fpgautil -b /lib/firmware/krio/mydesign/design.bit.bin # ~135 ms
The K26 holds one PL design at a time. If the board has more than one job, use krio-fw,
which sequences the switch: stop the outgoing design’s services, swap the bitstream, start
the incoming design’s. That ordering is the point, because a process holding a UIO node or a
/dev/mem window on the outgoing design is pointing into fabric that is about to stop
existing.
krio-fw list
sudo krio-fw load mydesign
sudo krio-fw default tdc # what loads at next power-on
A slot is a directory under /lib/firmware/krio/ with four lines:
DESC="What this design is"
BIN=/lib/firmware/krio/mydesign/design.bit.bin
DTBO=
SERVICES=myservice.service
Anything written to the root filesystem needs an explicit sync before you cut power.
There is no orderly shutdown on this board.
Rule Never touch a PL address when the fabric holds
no matching design. It wedges the AXI bus and only a power cycle recovers. krio-fw status says what is loaded.
8. Talking to your logic
AXI4-Lite slaves need no driver and no device-tree node, just /dev/mem and a pointer:
int fd = open("/dev/mem", O_RDWR | O_SYNC);
volatile uint32_t *reg = mmap(NULL, 0x10000, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0xA0040000);
reg[0] = 1; /* write a control register */
UIO is the alternative when you want interrupts or a named device. It needs a
device-tree node and a kernel built with CONFIG_UIO_PDRV_GENIRQ, plus
uio_pdrv_genirq.of_id=generic-uio,ui_pdrv on the kernel command line. Find devices by
name rather than by number, because the PS performance monitors claim uio0 through uio3:
for d in /sys/class/uio/uio*; do
printf '%s %s %s\n' "$(basename $d)" "$(cat $d/name)" "$(cat $d/maps/map0/addr)"
done
Measured cost of AXI4-Lite from userspace on this board, worth knowing before you design an interface: a posted write is about 7.6 ns, a blocking read about 200 ns. Writes are nearly free; reads, including polling a done flag, are not.
Troubleshooting
| Symptom | Cause |
|---|---|
| Silent board, no console output at all | Warm reset was attempted, or bootgen dropped the FSBL |
| Board boots but gets a new MAC every time | setenv ethaddr used instead of env set -f |
| Ethernet link comes up, no traffic | PHY compatible string order, or PHY address 1 instead of 5 |
| JTAG flash hangs partway | Image over ~2 MB; drop the bitstream or self-flash over TFTP |
| Everything hangs on first PL access | Fabric holds no matching design; power cycle |
sf reads garbage JEDEC IDs | QSPI left in a non-power-on state; power cycle |
| Settings lost after power cut | No sync before power was removed |
Next
With the board booting and loading your logic, put a neural network on it.