M14b: DMA memory (dma_alloc / dma_free)
An HCD programs a bus-master engine: it needs a descriptor ring that is physically contiguous, at a physical address it knows, uncacheable, and pinned. mmap gives none of those. Add dma_alloc(len, flags) -> vaddr (rax), paddr (rdx) and dma_free(vaddr, len): grant contiguous, zeroed, pinned, strong-uncacheable memory in a per-process DMA arena (PML4[228]) and hand back both addresses. Pieces: pmm.allocContiguous(count, max_phys) finds a run of contiguous free frames below a cap (dma_below_4g for 32-bit engines); mapUserDmaInto maps them uncacheable (PCD|PWT) but WITHOUT device_grant, so unlike an MMIO grant these frames are real RAM and freeSubtree returns them on teardown — a driver that dies leaks nothing. dma_free is bounded to the DMA arena so it can never unmap the caller's stack/heap/MMIO. dma_write_combining is accepted but falls back to coherent (WC needs PAT programming). Runtime: runtime.dma.alloc/free (a two-return-value stub, like replyWait). New `dma` kernel test drives the mechanism directly — contiguity, the below-4G cap, coherent mapping, and reclaim-on-teardown (no leak). The thin syscall wrappers follow the tested mmap/mmio_map shape and land their first real use with the first DMA driver. Suite 38/38 plus host tests.
This commit is contained in:
parent
e7c7e7b94c
commit
125a3b4993
|
|
@ -138,6 +138,13 @@ If a class driver needs `mmio`, it has become an HCD and should be one.
|
|||
table fails `-ENOSPC` and does not half-deliver. This is the "open" primitive — a bus
|
||||
driver mints a per-device endpoint and hands it to a class driver. The runtime exposes
|
||||
`callCap` and `replyWait(..., send_cap)`; no class driver consumes it yet.
|
||||
- **M14** — DMA memory + the memory-ordering layer. `/lib/mmio` gives drivers typed
|
||||
volatile access and `mb`/`rmb`/`wmb` (per-arch); `dma_alloc`/`dma_free` grant
|
||||
physically-contiguous, pinned, uncacheable, reclaim-on-teardown buffers with the
|
||||
physical address exposed (`pmm.allocContiguous`, a DMA arena, `mapUserDmaInto`).
|
||||
`dma_below_4g` caps the address for legacy engines; `dma_write_combining` is accepted
|
||||
but falls back to coherent until PAT is programmed. hpet is refactored onto `/lib/mmio`;
|
||||
no DMA driver consumes `dma_alloc` yet.
|
||||
- **`system_spawn`** — a user-space supervisor starts a driver: `system_spawn(name)`
|
||||
loads a binary bundled in the initial-ramdisk as a fresh ring-3 process. This is what
|
||||
turned the device manager from "log the match" into "run the driver": the kernel now
|
||||
|
|
@ -194,7 +201,12 @@ const dev_ep = ipc.callCap(h, // ... mint a per-device endpoint,
|
|||
// now dev_ep is a private channel to that one device
|
||||
```
|
||||
|
||||
## M14 — DMA memory and the memory-ordering contract, for HCDs
|
||||
## M14 — DMA memory and the memory-ordering contract, for HCDs ✅ done
|
||||
|
||||
*Implemented: `/lib/mmio` (typed volatile access + `mb`/`rmb`/`wmb`, per-arch) and
|
||||
`dma_alloc`/`dma_free` (contiguous, pinned, uncacheable, reclaim-on-teardown, physical
|
||||
address exposed). `dma_write_combining` still falls back to coherent — real WC needs
|
||||
PAT, a small follow-up. The rest of this section is the original design note.*
|
||||
|
||||
**The blocker.** An HCD is a DMA-engine programmer. It needs a descriptor ring the
|
||||
device can read, which means memory that is (a) physically contiguous, (b) at a
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
//! User-space DMA memory: `dma_alloc` / `dma_free`. A driver that programs a
|
||||
//! bus-mastering engine needs a descriptor ring the device can read — memory that is
|
||||
//! physically contiguous, at a physical address the driver knows, uncacheable, and
|
||||
//! pinned. `mmap` gives none of those; this does. Pair it with the barriers in
|
||||
//! `/lib/mmio` (fill the ring, `wmb()`, ring the doorbell). See docs/driver-model.md.
|
||||
|
||||
const abi = @import("abi");
|
||||
const sc = @import("system-call.zig");
|
||||
|
||||
/// Allocation flags. `coherent` (uncacheable) is the portable default; the rest are
|
||||
/// opt-in for specific hardware — see `abi`.
|
||||
pub const coherent: usize = abi.dma_coherent;
|
||||
pub const write_combining: usize = abi.dma_write_combining;
|
||||
pub const below_4g: usize = abi.dma_below_4g;
|
||||
|
||||
/// A DMA allocation: the `virtual` address the CPU touches, and the `physical` address
|
||||
/// to program into the device's descriptor-ring / base registers.
|
||||
pub const Region = struct {
|
||||
virtual: usize,
|
||||
physical: usize,
|
||||
};
|
||||
|
||||
inline fn failed(r: usize) bool {
|
||||
return r > ~@as(usize, 0) - 4095;
|
||||
}
|
||||
|
||||
/// Allocate `len` bytes of DMA-capable memory with `flags` (e.g. `coherent`, or
|
||||
/// `coherent | below_4g`). Returns the virtual/physical pair, or null on failure. Two
|
||||
/// return values — the virtual address in rax, the physical address in rdx — so it
|
||||
/// needs a hand-written stub.
|
||||
pub fn alloc(len: usize, flags: usize) ?Region {
|
||||
var rax: usize = undefined;
|
||||
var rdx: usize = undefined; // out: physical address
|
||||
asm volatile ("syscall"
|
||||
: [rax] "={rax}" (rax),
|
||||
[rdx] "={rdx}" (rdx),
|
||||
: [n] "{rax}" (@intFromEnum(abi.SystemCall.dma_alloc)),
|
||||
[a0] "{rdi}" (len),
|
||||
[a1] "{rsi}" (flags),
|
||||
: .{ .rcx = true, .r11 = true, .memory = true });
|
||||
if (failed(rax)) return null;
|
||||
return .{ .virtual = rax, .physical = rdx };
|
||||
}
|
||||
|
||||
/// Release a region from a prior `alloc` (`virtual` and the same `len`).
|
||||
pub fn free(virtual: usize, len: usize) void {
|
||||
_ = sc.systemCall2(.dma_free, virtual, len);
|
||||
}
|
||||
|
|
@ -20,6 +20,8 @@ pub const vfs_protocol = @import("vfs-protocol");
|
|||
/// C stdio: fopen/fread/fwrite/fseek/ftell/fclose over unistd.
|
||||
/// Device access for drivers: enumerate/claim/mmioMap.
|
||||
pub const device = @import("device.zig");
|
||||
/// DMA-capable memory for drivers: contiguous, pinned, uncacheable buffers.
|
||||
pub const dma = @import("dma.zig");
|
||||
|
||||
/// Re-exported so a user binary can `pub const panic = runtime.panic;`.
|
||||
pub const panic = start.panic;
|
||||
|
|
|
|||
|
|
@ -37,9 +37,18 @@ pub const SystemCall = enum(u64) {
|
|||
irq_ack = 15, // irq_ack(id, resource_index): re-arm a bound IRQ after servicing it
|
||||
device_register = 16, // device_register(parent_id, descriptor) -> id: publish a child of a device you claimed
|
||||
system_spawn = 17, // system_spawn(name_ptr, name_len) -> 0: start a named initial-ramdisk binary as a new ring-3 process
|
||||
dma_alloc = 18, // dma_alloc(len, flags) -> vaddr (rax), paddr (rdx): contiguous, pinned, uncacheable DMA memory
|
||||
dma_free = 19, // dma_free(vaddr, len) -> 0: release a prior dma_alloc
|
||||
_,
|
||||
};
|
||||
|
||||
/// `dma_alloc` flags. `coherent` (uncacheable) is the portable default; the others are
|
||||
/// opt-in for specific hardware. `write_combining` needs PAT programming (not yet — it
|
||||
/// currently falls back to coherent); see docs/driver-model.md (M14).
|
||||
pub const dma_coherent: u64 = 1; // strong-uncacheable — the default, the only portable one
|
||||
pub const dma_write_combining: u64 = 2; // write-combining (framebuffers); needs PAT
|
||||
pub const dma_below_4g: u64 = 4; // physical address must fit 32 bits (legacy DMA engines)
|
||||
|
||||
/// Set in the badge returned by `ipc_reply_wait` when what arrived is an
|
||||
/// **asynchronous notification** (today: a device interrupt bound with `irq_bind`)
|
||||
/// rather than a message from a client. There is no payload and no reply owed; the
|
||||
|
|
|
|||
|
|
@ -168,6 +168,12 @@ pub fn mapUserDeviceInto(root: u64, virtual: u64, physical: u64, len: u64) void
|
|||
paging.mapUserDeviceInto(root, virtual, physical, len);
|
||||
}
|
||||
|
||||
/// Map coherent DMA RAM into address space `root`: strong-uncacheable, RW+NX, but
|
||||
/// reclaimed on teardown (real RAM, not MMIO). For dma_alloc.
|
||||
pub fn mapUserDmaInto(root: u64, virtual: u64, physical: u64, len: u64) void {
|
||||
paging.mapUserDmaInto(root, virtual, physical, len);
|
||||
}
|
||||
|
||||
/// Map a page into the kernel address space (non-executable). For the heap, etc.
|
||||
pub fn mapPage(virtual: u64, physical: u64, writable: bool) void {
|
||||
paging.map(virtual, physical, writable);
|
||||
|
|
|
|||
|
|
@ -273,6 +273,30 @@ pub fn mapUserDeviceInto(pml4: u64, virtual: u64, physical: u64, len: u64) void
|
|||
}
|
||||
}
|
||||
|
||||
/// Map `[physical, physical+len)` into the user half rooted at `pml4` as **coherent
|
||||
/// DMA memory**: strong-uncacheable (PCD|PWT — a device reads/writes this RAM without
|
||||
/// snooping the CPU caches) but, unlike `mapUserDeviceInto`, **without** `device_grant`
|
||||
/// — because these frames are real RAM from `pmm.allocContiguous`, so teardown
|
||||
/// (`freeSubtree`) must return them to the allocator like any other user page. RW + NX.
|
||||
/// The caller aligns `virtual`/`physical` and places `virtual` in the DMA arena.
|
||||
pub fn mapUserDmaInto(pml4: u64, virtual: u64, physical: u64, len: u64) void {
|
||||
const flags: u64 = present | user | writable | no_execute | pcd | pwt;
|
||||
const first = physical & ~@as(u64, page_size - 1);
|
||||
const last = (physical + (if (len == 0) 1 else len) - 1) & ~@as(u64, page_size - 1);
|
||||
var off: u64 = 0;
|
||||
while (first + off <= last) : (off += page_size) {
|
||||
const v = virtual + off;
|
||||
const pml4e = &tableAt(pml4)[(v >> 39) & 0x1FF];
|
||||
const pdpt = descendUser(pml4e);
|
||||
const pdpte = &tableAt(pdpt)[(v >> 30) & 0x1FF];
|
||||
const pd = descendUser(pdpte);
|
||||
const pde = &tableAt(pd)[(v >> 21) & 0x1FF];
|
||||
const pt = descendUser(pde);
|
||||
tableAt(pt)[(v >> 12) & 0x1FF] = ((first + off) & address_mask) | flags;
|
||||
invalidate(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new address space: a fresh PML4 with an empty user half and the
|
||||
/// kernel's higher half shared in (copying PML4[256..512), whose entries point
|
||||
/// at the kernel's PDPTs — pre-created at init and never restaled, so growth in
|
||||
|
|
|
|||
|
|
@ -173,3 +173,31 @@ pub fn free(address: u64) void {
|
|||
used_frames -= 1;
|
||||
if (f < next_hint) next_hint = f;
|
||||
}
|
||||
|
||||
/// Allocate `count` physically **contiguous** frames whose highest byte is below
|
||||
/// `max_phys` (pass `~0` for no limit; use a real limit for DMA engines with 32-bit
|
||||
/// addressing). Returns the physical base, or null if no free run of that size fits.
|
||||
/// A DMA descriptor ring needs contiguity, a known physical address, and pinning —
|
||||
/// none of which one-frame `alloc` gives. Linear scan for a run of clear bits: fine
|
||||
/// for the small rings DMA needs; a buddy allocator is a later optimisation. The
|
||||
/// frames are freed individually with `free`, so there is no bespoke free path.
|
||||
pub fn allocContiguous(count: usize, max_phys: u64) ?u64 {
|
||||
if (count == 0) return null;
|
||||
const limit: usize = @intCast(@min(@as(u64, total_frames), max_phys / page_size));
|
||||
var start: usize = 1; // frame 0 stays reserved as the "none" address
|
||||
while (start + count <= limit) {
|
||||
if (isUsed(start)) {
|
||||
start += 1;
|
||||
continue;
|
||||
}
|
||||
var run: usize = 0;
|
||||
while (run < count and !isUsed(start + run)) : (run += 1) {}
|
||||
if (run == count) {
|
||||
for (0..count) |i| setUsed(start + i);
|
||||
used_frames += count;
|
||||
return @as(u64, start) * page_size;
|
||||
}
|
||||
start += run + 1; // the frame at start+run is used; skip past it
|
||||
}
|
||||
return null; // no contiguous run of `count` frames below max_phys
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,13 @@ pub const user_half_end: u64 = 0x0000_8000_0000_0000;
|
|||
pub const device_arena_base: u64 = 0x0000_7100_0000_0000;
|
||||
pub const device_arena_end: u64 = device_arena_base + (4 << 30);
|
||||
|
||||
/// The DMA arena: where `dma_alloc` places coherent DMA buffers, in PML4[228] — a
|
||||
/// user-exclusive region distinct from the MMIO arena. Unlike MMIO grants these back
|
||||
/// real RAM (contiguous frames), so they are reclaimed on teardown. Per-process cursor
|
||||
/// in `Task.dma_map_next`.
|
||||
pub const dma_arena_base: u64 = 0x0000_7200_0000_0000;
|
||||
pub const dma_arena_end: u64 = dma_arena_base + (256 << 20); // 256 MiB per process
|
||||
|
||||
/// Largest single `mmap` grant, in pages (1 MiB). The user heap grows in small
|
||||
/// chunks, so this bound is generous; it also caps the frame scratch array below.
|
||||
const maximum_mmap_pages = 256;
|
||||
|
|
@ -150,6 +157,8 @@ fn system_call(state: *architecture.CpuState) void {
|
|||
.irq_ack => systemIrqAck(state),
|
||||
.device_register => systemDeviceRegister(state),
|
||||
.system_spawn => systemSpawn(state),
|
||||
.dma_alloc => systemDmaAlloc(state),
|
||||
.dma_free => systemDmaFree(state),
|
||||
_ => fail(state),
|
||||
}
|
||||
}
|
||||
|
|
@ -261,6 +270,64 @@ fn systemMmioMap(state: *architecture.CpuState) void {
|
|||
architecture.setSystemCallResult(state, base_v + (r.start & (page_size - 1))); // register base
|
||||
}
|
||||
|
||||
/// dma_alloc(len, flags) -> vaddr (rax), paddr (rdx): grant `len` bytes (rounded up to
|
||||
/// whole pages) of DMA-capable memory — physically contiguous, zeroed, pinned, and
|
||||
/// strong-uncacheable (coherent) — mapping it into the caller's DMA arena and handing
|
||||
/// back both the virtual address to touch and the physical address to program into the
|
||||
/// device. This is what `sysMmap` can't do: mmap frames are scattered, cacheable, and
|
||||
/// their physical address is never disclosed. `dma_below_4g` caps the physical address
|
||||
/// for legacy engines; `dma_write_combining` is accepted but falls back to coherent
|
||||
/// until PAT is programmed. See docs/driver-model.md (M14).
|
||||
fn systemDmaAlloc(state: *architecture.CpuState) void {
|
||||
const len = architecture.systemCallArg(state, 0);
|
||||
const flags = architecture.systemCallArg(state, 1);
|
||||
const t = scheduler.current();
|
||||
if (t.aspace == 0 or len == 0) return fail(state);
|
||||
|
||||
const pages: usize = @intCast((len + page_size - 1) / page_size);
|
||||
const max_phys: u64 = if (flags & abi.dma_below_4g != 0) (@as(u64, 4) << 30) else ~@as(u64, 0);
|
||||
const phys = pmm.allocContiguous(pages, max_phys) orelse return fail(state);
|
||||
|
||||
if (t.dma_map_next == 0) t.dma_map_next = dma_arena_base;
|
||||
const base_v = t.dma_map_next;
|
||||
if (base_v + pages * page_size > dma_arena_end) {
|
||||
for (0..pages) |i| pmm.free(phys + i * page_size); // arena exhausted; give the frames back
|
||||
return fail(state);
|
||||
}
|
||||
|
||||
// Zero through the physmap (the frames aren't mapped in the caller yet), then map.
|
||||
const kernel_view: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(phys));
|
||||
@memset(kernel_view[0 .. pages * page_size], 0);
|
||||
architecture.mapUserDmaInto(t.aspace, base_v, phys, pages * page_size);
|
||||
|
||||
t.dma_map_next = base_v + pages * page_size;
|
||||
architecture.setSystemCallResult(state, base_v); // virtual address for the CPU
|
||||
architecture.setSystemCallResult2(state, phys); // physical address for the device
|
||||
}
|
||||
|
||||
/// dma_free(vaddr, len) -> 0: release a prior `dma_alloc`. Bounded to the DMA arena so
|
||||
/// it can never unmap-and-free the caller's stack, heap, or an MMIO grant; only pages
|
||||
/// actually mapped are freed (an unmapped hole is skipped). Teardown also reclaims any
|
||||
/// DMA pages left mapped at exit (they carry no `device_grant`, so `freeSubtree` frees
|
||||
/// them as ordinary RAM), so a driver that just dies leaks nothing.
|
||||
fn systemDmaFree(state: *architecture.CpuState) void {
|
||||
const base_v = architecture.systemCallArg(state, 0);
|
||||
const len = architecture.systemCallArg(state, 1);
|
||||
const t = scheduler.current();
|
||||
if (t.aspace == 0) return fail(state);
|
||||
const pages: usize = @intCast((len + page_size - 1) / page_size);
|
||||
if (base_v < dma_arena_base or base_v + pages * page_size > dma_arena_end) return fail(state);
|
||||
|
||||
for (0..pages) |i| {
|
||||
const va = base_v + i * page_size;
|
||||
if (architecture.translate(t.aspace, va)) |phys| {
|
||||
architecture.unmapUserPageInto(t.aspace, va);
|
||||
pmm.free(phys);
|
||||
}
|
||||
}
|
||||
architecture.setSystemCallResult(state, 0);
|
||||
}
|
||||
|
||||
/// device_register(parent_id, descriptor_ptr) -> id: publish a child device below a device
|
||||
/// this process has claimed. The bus-driver primitive: a process that owns a bus
|
||||
/// enumerates it and hands each device it finds to the table, where a class driver
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ pub const Task = struct {
|
|||
ipc_reply_ptr: u64 = 0, // client: reply buffer (vaddr)
|
||||
ipc_reply_cap: u64 = 0,
|
||||
ipc_status: i64 = 0, // client: reply length / -errno, written by the replier
|
||||
dma_map_next: u64 = 0, // bump pointer into this task's DMA arena (0 = unseeded)
|
||||
ipc_send_cap: u64 = ~@as(u64, 0), // handle to transfer with this message (abi.no_cap = none)
|
||||
ipc_received_cap: u64 = ~@as(u64, 0), // client: handle the reply's transferred cap landed at (abi.no_cap = none)
|
||||
next: ?*Task = null, // ready-queue link (also the endpoint sender-FIFO link)
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
|
|||
ipcCallTest();
|
||||
} else if (eql(case, "ipc-cap")) {
|
||||
capabilityTest();
|
||||
} else if (eql(case, "dma")) {
|
||||
dmaTest();
|
||||
} else if (eql(case, "smp")) {
|
||||
smpTest();
|
||||
} else if (eql(case, "affinity")) {
|
||||
|
|
@ -935,6 +937,53 @@ fn capabilityTest() void {
|
|||
result();
|
||||
}
|
||||
|
||||
/// DMA memory (M14): the properties a bus-mastering driver needs — physically
|
||||
/// contiguous, a known physical address, correct cacheability, pinned, and reclaimed
|
||||
/// on teardown. Exercises the kernel mechanism directly (`pmm.allocContiguous` +
|
||||
/// `mapUserDmaInto`); the `dma_alloc`/`dma_free` syscalls are thin wrappers over it,
|
||||
/// following the tested `mmap`/`mmio_map` shape, and land their first real use with the
|
||||
/// first DMA driver.
|
||||
fn dmaTest() void {
|
||||
log("DANOS-TEST-BEGIN: dma\n", .{});
|
||||
const base_free = pmm.stats().free_frames;
|
||||
|
||||
// A contiguous run: aligned, and it consumed exactly that many frames.
|
||||
const frames = 4;
|
||||
const phys = pmm.allocContiguous(frames, ~@as(u64, 0)) orelse {
|
||||
check("allocContiguous(4) succeeded", false);
|
||||
result();
|
||||
return;
|
||||
};
|
||||
check("contiguous run is page-aligned", phys % abi.page_size == 0);
|
||||
check("contiguous run consumed 4 frames", pmm.stats().free_frames == base_free - frames);
|
||||
|
||||
// The below-4G cap is honoured (legacy 32-bit DMA engines).
|
||||
const low = pmm.allocContiguous(2, @as(u64, 4) << 30) orelse 0;
|
||||
check("below-4G run stays under 4 GiB", low != 0 and low + 2 * abi.page_size <= (@as(u64, 4) << 30));
|
||||
|
||||
// Map the run into a fresh address space as coherent DMA and translate each page
|
||||
// back: the same physical run, in order — proving contiguity and the mapping.
|
||||
const aspace = architecture.createAddressSpace().?;
|
||||
architecture.mapUserDmaInto(aspace, process.dma_arena_base, phys, frames * abi.page_size);
|
||||
var mapped_ok = true;
|
||||
for (0..frames) |i| {
|
||||
const va = process.dma_arena_base + i * abi.page_size;
|
||||
const got = architecture.translate(aspace, va) orelse {
|
||||
mapped_ok = false;
|
||||
break;
|
||||
};
|
||||
if (got != phys + i * abi.page_size) mapped_ok = false;
|
||||
}
|
||||
check("DMA pages translate to the contiguous physical run", mapped_ok);
|
||||
|
||||
// Teardown must reclaim the DMA RAM (the leaves carry no device_grant, so
|
||||
// freeSubtree frees them as ordinary frames) — a driver that just dies leaks none.
|
||||
architecture.destroyAddressSpace(aspace);
|
||||
for (0..2) |i| pmm.free(low + i * abi.page_size);
|
||||
check("no frames leaked after DMA teardown", pmm.stats().free_frames == base_free);
|
||||
result();
|
||||
}
|
||||
|
||||
var proc_worker_run: bool = true;
|
||||
var proc_worker_ran: bool = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,11 @@ CASES = [
|
|||
{"name": "ipc-cap",
|
||||
"expect": r"DANOS-TEST-RESULT: PASS",
|
||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
||||
# DMA memory (M14): contiguous frame allocation, below-4G cap, coherent mapping,
|
||||
# and reclaim on teardown.
|
||||
{"name": "dma",
|
||||
"expect": r"DANOS-TEST-RESULT: PASS",
|
||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
||||
# Parallelism: needs more than one core, so this case boots with -smp 4.
|
||||
{"name": "smp",
|
||||
"smp": 4,
|
||||
|
|
|
|||
Loading…
Reference in New Issue