The virtio Entropy Device (virtio-rng)

The entropy device is the smallest complete virtio device Firecracker ships, and that is exactly why you should read it first when you want to build one. It exposes a single, device-writable virtqueue: the guest posts empty buffers, the host fills them with random bytes drawn from a host CSPRNG, and the device returns them on the used ring. There is no request header, no status byte, no config space worth speaking of, no second control queue — the whole descriptor chain means nothing more than "give me up to N random bytes." Strip a virtio device down to the irreducible minimum and this is what is left: the VirtioDevice trait, one queue, one eventfd, one optional rate limiter.

This chapter is two things at once. First, it is the reference anatomy of a minimal virtio device: we walk every method of the VirtioDevice trait and show what rng does for it, so you have a template to copy. Second, it explains why a microVM even has an entropy device — the boot-time CRNG problem that makes virtio-rng load-bearing rather than decorative. After this chapter you will be able to read the entropy device's request handler end to end, map each trait method to its rng implementation, and use this device's shape as the skeleton for a new one (that is literally how Level 7's custom-device project starts — copy this device, change the type ID, change what the handler does).

Note: The entropy device is virtio device type ID 4, and it has exactly one virtqueue, which is device-writable: the descriptors the guest enqueues have the VIRTIO_DESC_F_WRITE flag set, because the device writes into them. This is the inverse intuition from virtio-block, where the guest fills the data buffer on a write. Here the guest always hands the device blank space and asks it to be filled. Confirm the type ID and queue count on your branch with the rg below before trusting any of this.


Where the entropy device lives

# The module may be named rng/ or entropy/ — search both.
find . -type d -path '*devices/virtio/rng*' -o -type d -path '*devices/virtio/entropy*'
rg -n "mod (rng|entropy)|virtio/rng|virtio/entropy" --files-with-matches src/vmm/src | head

# The device struct, its type constant, its queue count.
rg -n "struct Entropy\b|TYPE_RNG|TYPE_ENTROPY|NUM_QUEUES|QUEUE_SIZE|avail_features" \
  src/vmm/src/devices/virtio/rng/ src/vmm/src/devices/virtio/entropy/ 2>/dev/null

Confirm three facts before reading on: the type constant resolves to 4, the queue count is 1, and the struct is tiny — a queue, its event, an interrupt handle, an optional rate limiter, the activation flag, and a handle to the host randomness source. That is the entire device. Compare the line count of this module against block/ or net/; the difference is the lesson. Everything that makes block and net big — request parsing, status bytes, multiple queues, io engines, TAP framing — is absent.

 guest kernel (virtio_rng / hwrng driver)
        │  enqueues empty WRITE-only buffers, kicks QueueNotify
        ▼
 ioeventfd ──► VMM thread epoll loop ──► Entropy::process_entropy_queue
        │                                      │
        │                                      ▼
        │                         rate-limiter gate (bandwidth only)
        │                                      │
        │                                      ▼
        │                         host CSPRNG fills the buffer (≤ 64 KiB)
        ▼                                      │
 irqfd ◄────────── add_used(len) ◄─────────────┘
        │
        ▼
 guest kernel entropy pool  →  getrandom() unblocks  →  TLS/sshd/systemd proceed

The single device-writable queue

rg -n "fn process_entropy|fn handle_one|fn process_entropy_queue|add_used|write_slice|\
device-writable|is_write_only|VIRTIO_DESC_F_WRITE|fn queues\b" \
  src/vmm/src/devices/virtio/rng/ src/vmm/src/devices/virtio/entropy/ 2>/dev/null

A request here is a single descriptor chain with no structure imposed by the protocol. The guest driver enqueues one or more descriptors, all marked device-writable, and that entire chain is just "writable space, please fill it." The handler:

  1. Pops the next available chain from the queue.
  2. Checks the bandwidth rate limiter (if configured).
  3. Pulls random bytes from the host source and writes them into the chain's writable buffers, up to the chain's total writable length, capped at the per-request maximum.
  4. Calls add_used with the number of bytes it actually wrote, so the guest knows how much of its buffer is now valid randomness.
  5. Repeats until the available ring is drained, then injects one interrupt via irqfd.
sequenceDiagram
    participant G as Guest virtio_rng driver
    participant E as EventManager (VMM thread)
    participant D as Entropy::process_entropy_queue
    participant R as Host CSPRNG
    G->>E: kick (ioeventfd)
    E->>D: handler fires
    loop each available chain
      D->>D: rate-limiter check (bandwidth)
      D->>R: fill(buf)  (≤ 64 KiB per chain)
      R-->>D: random bytes
      D->>D: add_used(id, len)
    end
    D->>G: one interrupt (irqfd)
    Note over G: kernel folds bytes into the entropy pool

Warning: The writable length comes from the guest. The device must clamp it (the per-request cap below) and must only ever write into descriptors that carry VIRTIO_DESC_F_WRITE. A chain whose descriptors are not writable is malformed — the device should reject it, not write into guest-readable memory. Read the descriptor-direction check as security-critical, exactly as you read the block device's chain validation.


The host randomness source

rg -n "rand|aws_lc_rs|SystemRandom|getrandom|fill\b|RngCore|OsRng|/dev/urandom" \
  src/vmm/src/devices/virtio/rng/ src/vmm/src/devices/virtio/entropy/ 2>/dev/null

The bytes the guest receives originate on the host, from a cryptographically secure pseudo-random number generator — verify the exact crate on your branch (rg above), as Firecracker has tracked ring-style and aws-lc-rs SystemRandom over time, all of which ultimately draw from the host kernel's getrandom//dev/urandom pool (verify on your branch). The important invariant is not which crate: it is that the source is a host CSPRNG, so the guest is getting hardware-RNG-class randomness, not a deterministic stream. The device's job is the plumbing — pull from the source, write into the guest buffer, account the byte count — and the security depends entirely on the source being seeded and non-predictable on the host.

Tip: This is the cleanest place in the codebase to see the host→guest data direction with no protocol noise in between. There is no header to parse, no offset math, no status code. If you want to understand how a virtio device moves bytes into guest memory through bounds-checked accessors, read the fill call here before you read the block device's pread path.


The 64 KiB per-request cap

rg -n "MAX|64|0x10000|65536|clamp|min\(|cap|len\(\)" \
  src/vmm/src/devices/virtio/rng/ src/vmm/src/devices/virtio/entropy/ 2>/dev/null

Each request is bounded: a single descriptor chain is served at most roughly 64 KiB of randomness per kick (verify the exact constant on your branch). This is a deliberate work-bound. The handler runs on the one VMM thread shared by every device on the microVM; an unbounded fill from a guest that enqueues a single enormous chain would let the guest monopolise that thread. The cap turns "fill this chain" into "fill up to N bytes of this chain," so each unit of work is small and the loop yields the thread back to the epoll dispatcher promptly. It is the same philosophy as the block device draining "until the queue is empty" rather than spinning — bound the work you do per wakeup.

KnobWhat it boundsWhy it exists
Per-request cap (~64 KiB)bytes served per descriptor chainkeep each unit of work on the VMM thread small
Bandwidth rate limiterbytes served per second, sustainedstop one microVM draining shared host entropy/CPU
Queue sizein-flight chainsstandard virtqueue backpressure

The single bandwidth rate limiter

rg -n "rate_limiter|RateLimiter|TokenBucket|bandwidth|consume|BANDWIDTH|fn process_entropy" \
  src/vmm/src/devices/virtio/rng/ src/vmm/src/devices/virtio/entropy/ 2>/dev/null

The entropy device carries an optional rate limiter, and here is the detail worth remembering: it has only a bandwidth bucket (bytes/s) — not the ops+bandwidth pair that block and net carry. There is no "operations per second" knob, because counting entropy requests is meaningless; what you want to limit is the volume of randomness a guest can pull, so the host's CSPRNG and the VMM thread are not monopolised by an entropy-hungry guest. Before the handler fills a chain it asks the bandwidth bucket for tokens. If the bucket is empty, process_entropy_queue stops — it does not spin or drop the chain. The limiter's timer eventfd (registered at activation) re-arms; when it fires, the epoll loop calls back and the handler resumes draining from where it paused. The guest experiences backpressure as latency on getrandom(), exactly as if the hardware RNG were slow.


Anatomy of a minimal virtio device: the VirtioDevice trait

rg -n "trait VirtioDevice" src/vmm/src/devices/virtio/
rg -n "fn device_type|fn queues|fn queue_events|fn interrupt_status|fn interrupt_trigger|\
fn avail_features|fn acked_features|fn set_acked_features|fn read_config|fn write_config|\
fn activate|fn is_activated" \
  src/vmm/src/devices/virtio/rng/ src/vmm/src/devices/virtio/entropy/ 2>/dev/null

Every virtio device — block, net, vsock, balloon, rng — implements one trait. The MMIO transport calls into that trait; the device knows nothing about MMIO registers. Because the entropy device has one queue and no real config space, its implementation is the minimal complete one: read it, and you have read the shape of the contract every device must satisfy. This table is the template. When you build a new device, you fill in this same column.

VirtioDevice methodWhat the entropy device does for it
device_type()returns the type ID constant — 4 for entropy. The transport reports this to the guest.
queues() / queues_mut()returns the slice of virtqueues — here a single queue.
queue_events()returns the eventfd(s) the guest kicks (one ioeventfd, one queue).
interrupt_status()returns the shared interrupt-status word the transport reads on an MMIO interrupt-ack.
interrupt_trigger()returns the handle used to raise the guest interrupt (the irqfd path) after add_used.
avail_features()the feature bits the device offers. rng offers essentially the base virtio bits (e.g. VIRTIO_F_VERSION_1) — no device-specific feature flags.
acked_features()the subset the guest accepted during negotiation.
set_acked_features()records the guest's accepted bits; used on snapshot restore to re-establish negotiated state.
read_config() / write_config()rng has no meaningful config space — read_config returns nothing useful and write_config is a no-op (or errors). This is the giveaway that you are looking at the minimal device.
activate()the wiring step (next section): register the queue eventfd and the rate-limiter timer fd with the EventManager, set the activated flag.
is_activated()returns whether DRIVER_OK has been processed — guards the handler so a stray kick before activation is ignored.

Note: Look at how empty read_config/write_config are here. That emptiness is the signal. A device with real config space (block advertises capacity at offset 0x100; net advertises a MAC) has to serialise and bounds-check that space. rng has nothing to advertise, so those methods collapse to near-stubs. When you copy this device as a template, those two methods are where your device's config space will grow — start from rng's empty version, not block's.


Activation: how the device joins the run loop

rg -n "fn activate|DRIVER_OK|ioeventfd|register|EventManager|timer|rate_limiter|is_activated" \
  src/vmm/src/devices/virtio/rng/ src/vmm/src/devices/virtio/entropy/ 2>/dev/null

The device is inert until the guest driver finishes negotiation and writes DRIVER_OK through the MMIO transport. That write triggers activate(). For the entropy device, activation is short — which is why it is the best example of what activation minimally is:

  1. Register the queue eventfd (the ioeventfd the guest kicks) with the EventManager, so a QueueNotify write becomes an epoll wakeup instead of a VM exit into the VMM.
  2. If a rate limiter is configured, register its timer eventfd too, so the device can be re-woken when bandwidth tokens replenish.
  3. Set the activated flag (so is_activated() returns true and the handler will run).

That is the whole activation. No completion eventfd (rng has no async io_uring path), no second queue. After this, processing is entirely event-driven: kick → ioeventfd → epoll → handler drains the queue → one interrupt. The device shares the single VMM thread with every other device, which is exactly why the 64 KiB cap and the bandwidth limiter matter.

flowchart TD
    A["guest writes DRIVER_OK (MMIO transport)"] --> B["Entropy::activate()"]
    B --> C["register queue eventfd with EventManager"]
    B --> D{"rate limiter configured?"}
    D -- yes --> E["register rate-limiter timer fd"]
    D -- no --> F["skip"]
    C --> G["set activated flag"]
    E --> G
    F --> G
    G --> H["handler now runs on every kick / timer fire"]

Configuration and the boot-time reason it exists

rg -n "EntropyDeviceConfig|/entropy|put_entropy|parse_put_entropy|rate_limiter" \
  src/vmm/src/api_server/ src/vmm/src/vmm_config/ 2>/dev/null
rg -n "virtio_rng|hwrng|/dev/hwrng|getrandom|crng init|random:" # search guest kernel logs, not source

The entropy device is configured once, pre-boot, via PUT /entropy, with an optional rate limiter and nothing else (no path, no MAC, no drive id — there is nothing to point it at):

API=/tmp/firecracker.socket
# Add the entropy device, optionally throttled to 1 MiB/s.
curl -X PUT --unix-socket $API \
  --data '{"rate_limiter":{"bandwidth":{"size":1048576,"refill_time":1000}}}' \
  http://localhost/entropy

Now the part that makes this device load-bearing instead of cosmetic. A Linux guest's cryptographic RNG (the CRNG behind getrandom() and /dev/random) must be seeded before it will produce output. On bare metal the kernel gathers entropy from interrupt timing, hardware RNG instructions, and a disk-persisted seed (/var/lib/systemd/random-seed). A Firecracker microVM has none of that: it boots in well under 125 ms, has no spinning disk and few interrupt sources to harvest timing from, and its rootfs may be a fresh read-only image with no saved seed. So early userspace can hit a wall — getrandom() (and therefore TLS handshakes, sshd host-key generation, systemd, anything that needs a secure random number) blocks until the CRNG is seeded, stalling boot, sometimes for seconds. virtio-rng is the fix: the guest's virtio_rng/hwrng driver feeds the host CSPRNG's bytes straight into the kernel entropy pool (surfaced as /dev/hwrng, consumed by the kernel's auto-seeding or rngd), so the CRNG is seeded almost immediately and getrandom() does not block. The device exists so that fast boot and working crypto at boot are not in conflict. See the boot sequence for where this lands in the timeline.

Tip: You can see this. Boot a microVM without an entropy device and grep dmesg for random: crng init done — note how late it appears, and watch userspace crypto stall before it. Add PUT /entropy, reboot, and watch crng init done move dramatically earlier. That delta is the entire justification for the device.


Snapshot

rg -n "Persist|fn save\b|fn restore\b|EntropyState|EntropyConstructorArgs" \
  src/vmm/src/devices/virtio/rng/ src/vmm/src/devices/virtio/entropy/ 2>/dev/null

The device implements the Persist trait so it round-trips through a snapshot. The saved state is tiny — the rate-limiter configuration, the negotiated/acked feature bits, and the virtqueue cursors. There is no data to persist (randomness is not state), so on restore the device is reconstructed and re-activated, and the next kick simply pulls fresh bytes from the host CSPRNG of the restoring host. This is another way the device is minimal: its snapshot state is almost empty.


Reading exercise

# 1. The device struct, queue count, type ID, advertised features.
rg -n "struct Entropy\b|TYPE_RNG|TYPE_ENTROPY|NUM_QUEUES|avail_features|VIRTIO_F_" \
  src/vmm/src/devices/virtio/rng/ src/vmm/src/devices/virtio/entropy/ 2>/dev/null

# 2. The whole VirtioDevice trait implementation — read every method.
rg -n "impl VirtioDevice|fn device_type|fn queues|fn queue_events|fn interrupt_status|\
fn interrupt_trigger|fn avail_features|fn acked_features|fn set_acked_features|\
fn read_config|fn write_config|fn activate|fn is_activated" \
  src/vmm/src/devices/virtio/rng/ src/vmm/src/devices/virtio/entropy/ 2>/dev/null

# 3. The request handler: fill → add_used → interrupt.
rg -n "fn process_entropy|fn handle_one|fill|add_used|trigger" \
  src/vmm/src/devices/virtio/rng/ src/vmm/src/devices/virtio/entropy/ 2>/dev/null

# 4. The host randomness source and the per-request cap.
rg -n "rand|SystemRandom|aws_lc_rs|getrandom|MAX|65536|0x10000|clamp|min\(" \
  src/vmm/src/devices/virtio/rng/ src/vmm/src/devices/virtio/entropy/ 2>/dev/null

# 5. Activation: which fds get registered with the EventManager.
rg -n "fn activate|register|ioeventfd|timer|rate_limiter|is_activated" \
  src/vmm/src/devices/virtio/rng/ src/vmm/src/devices/virtio/entropy/ 2>/dev/null

# 6. On a booted guest, watch the boot-time effect:
#    in guest:  dmesg | grep -E 'crng init|hwrng|virtio_rng'
#               cat /sys/class/misc/hw_random/rng_current   # should name virtio_rng

Answer:

  1. What is the entropy device's virtio type ID and queue count, and in which direction are the queue's descriptors writable — guest-writable or device-writable? Why?
  2. Walk every VirtioDevice method and state what rng does for it. Which two methods are essentially stubs, and what does that tell you about the device?
  3. There is no request header and no status byte. What, then, is a request, and how does the device tell the guest how many valid bytes it produced?
  4. The per-request cap is ~64 KiB. Why does a single-VMM-thread design make that cap necessary?
  5. The entropy device's rate limiter has one bucket, not two. Which one, and why is the missing one meaningless for this device?
  6. Why can a microVM's getrandom() block at boot, and exactly how does virtio-rng prevent it?

Common bugs and symptoms

SymptomRoot causeWhere to look
Guest boot stalls for seconds; crng init done appears very lateno entropy device configured, CRNG waiting on host-less entropy sourcesPUT /entropy present? guest dmesg; the boot sequence
getrandom() blocks, TLS/sshd hangs at first bootguest virtio_rng/hwrng driver not loaded, or device not activatedguest kernel config (CONFIG_HW_RANDOM_VIRTIO); is_activated(); activate fd registration
Device writes into a non-writable buffer / guest data corruptedhandler did not check VIRTIO_DESC_F_WRITE before fillingdescriptor-direction check in the handler
Entropy throughput far below configured cap, burstybandwidth bucket sized too small, or timer fd not registered at activationrate-limiter token math; timer fd in activate
Handler never resumes after throttlingrate-limiter timer eventfd not registered or not re-armedactivate fd registration; the timer re-arm path
Stray kick before boot does nothing (or panics)handler ran before DRIVER_OK / activationis_activated() guard at the top of the handler
add_used reports wrong length; guest reads stale buffer tailbyte count returned ≠ bytes actually written into the chainthe add_used(len) call vs the fill length / cap clamp

Validation: prove you understand this

  1. Explain why the entropy queue's descriptors are device-writable, and contrast this with a block-device write, where the data descriptor is device-readable. Tie both to VIRTIO_DESC_F_WRITE.
  2. Reproduce the full VirtioDevice trait table from memory for rng: name each method and what the entropy device implements for it. Identify the two near-stub methods and explain why their emptiness marks this as the minimal device.
  3. Trace one request from the guest kick to the guest interrupt, naming the ioeventfd, process_entropy_queue, the host CSPRNG fill, add_used, and the irqfd. State where the 64 KiB cap and the bandwidth check sit in that path.
  4. A teammate boots a microVM with no entropy device and reports sshd taking 4 seconds to start on first boot. Explain the mechanism precisely, and explain what PUT /entropy changes.
  5. The entropy device carries a single bandwidth rate limiter. Justify the absence of an ops/s bucket for this device while block and net carry both, in terms of what each device's "operation" costs.
  6. You are starting the custom-device project by copying the entropy device. List, in order, the four or five things you must change to repurpose it (type ID, queue count/direction, the handler body, config space in read_config/write_config, feature bits) and explain why starting from rng rather than block is the right call.

Next: The Serial Console and Legacy Devices — leaving virtio behind for the non-virtio device path: the 8250-style UART behind the legacy I/O bus, how the guest's early-boot console output reaches your terminal, and why these devices wire into the VMM differently from everything you have read so far.