exec, mmap, musl: dynamic linking

A program that names an interpreter in PT_INTERP gets it loaded too, at
the bottom of the mmap area, and started instead -- with AT_PHDR,
AT_PHENT, AT_PHNUM, AT_BASE and AT_ENTRY to find the program by. ET_DYN
images are placed at a base: the interpreter there, a PIE program where
a fixed one would go. Static musl programs get AT_PHDR now as well,
which is how musl finds their TLS template.

mmap(MAP_FIXED) of a file handed back zeroed pages whatever the
descriptor said, and kept no record of them, so fork() did not copy
them. A dynamic linker maps every library segment after the first that
way. Fixed mappings now read the file and are recorded wherever no
mapping already covers them.

musl is built shared as well as static, and libc.so ships as
/lib/ld-musl-x86_64.so.1 -- libc and loader in one file. Every port
still links -static on purpose.

cmd/dynhello is the test: against libc.so and a library of its own,
checking TLS, the heap, and a fork whose child still has both.
mprotect is accepted and not enforced yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
This commit is contained in:
Nexoniarz 2026-10-24 10:59:52 +02:00
commit 43305768cb
6 changed files with 328 additions and 35 deletions

View file

@ -128,7 +128,7 @@ KERN_NOENTRY = $(filter-out $(BUILD)/kernel/arch/x86_64/entry.o, $(KERNOBJS))
# Third-party software does NOT go here — see ports/.
# -----------------------------------------------------------------------
ULIB = $(addprefix $(BUILD)/lib/, start.o glue.o usys.o)
CMDS = schinit schinitd fbinfo shmtest socktest inputtest nettest reflink schattr ping chvt loadkeys sigtest memfdtest pcitest vgpud schdbg schdbgd resolvconf httpd schmode netecho ldlsd tone hwreport
CMDS = schinit schinitd fbinfo shmtest socktest inputtest nettest reflink schattr ping chvt loadkeys sigtest memfdtest pcitest vgpud schdbg schdbgd resolvconf httpd schmode netecho ldlsd tone hwreport dynhello
CMDBINS = $(addprefix $(BUILD)/cmd/, $(CMDS))
# -----------------------------------------------------------------------
@ -1047,6 +1047,33 @@ $(BUILD)/cmd/ldlsd: cmd/ldlsd/main.c $(ULIB) $(LDLS_GUEST)
$(BUILD)/cmd/ldlsd.o $(LDLS_GUEST) $(ULIB)
$(OBJCOPY) -S $@
# dynhello is a musl program, and the only dynamically linked one: the test
# that the kernel loads an interpreter and the interpreter maps libraries.
# Not -static, against libc.so rather than libc.a, with musl's loader named as
# the interpreter, and with a shared library of its own beside it -- see
# cmd/dynhello/main.c.
DYNCC = $(PORTCC) -O2 -D__SCHOS__ -I$(MUSLPREFIX)/include -fno-stack-protector
DYNLD = -Wl,-dynamic-linker,/lib/ld-musl-x86_64.so.1 -Wl,-rpath,/lib
$(MUSLPREFIX)/lib/libc.so:
$(MAKE) -C ports musl CC=gcc
$(BUILD)/cmd/libdynhello.so: cmd/dynhello/lib.c $(MUSLPREFIX)/lib/libc.so
@mkdir -p $(BUILD)/cmd
$(DYNCC) -fPIC -c $< -o $(BUILD)/cmd/libdynhello.o
$(DYNCC) -shared -nostdlib -Wl,-soname,libdynhello.so -o $@ \
$(BUILD)/cmd/libdynhello.o -L$(MUSLPREFIX)/lib -lc
$(BUILD)/cmd/dynhello: cmd/dynhello/main.c $(BUILD)/cmd/libdynhello.so
$(DYNCC) -fno-pic -c $< -o $(BUILD)/cmd/dynhello.o
$(DYNCC) -no-pie -nostdlib $(DYNLD) -o $@ \
$(MUSLPREFIX)/lib/crt1.o $(MUSLPREFIX)/lib/crti.o $(BUILD)/cmd/dynhello.o \
-L$(BUILD)/cmd -ldynhello -L$(MUSLPREFIX)/lib -lc $(PORTEND)
$(SYSROOT)/lib/libdynhello.so: $(BUILD)/cmd/libdynhello.so
@mkdir -p $(SYSROOT)/lib
cp $< $@
$(BUILD)/cmd/%: cmd/%/main.c $(ULIB)
@mkdir -p $(BUILD)/cmd
$(CC) $(CFLAGS) -c $< -o $(BUILD)/cmd/$*.o
@ -1321,7 +1348,7 @@ SYSROOT_DIRS = dev proc sys run tmp root
# After lwIP is unpacked: `make -C ports` below unpacks it too, and the two
# unzips running at once under -j trip over each other.
sysroot-install: $(addprefix $(SYSROOT)/bin/, $(CMDS)) etc-install | $(LWIPDIR)
sysroot-install: $(addprefix $(SYSROOT)/bin/, $(CMDS)) $(SYSROOT)/lib/libdynhello.so etc-install | $(LWIPDIR)
@mkdir -p $(addprefix $(SYSROOT)/, $(SYSROOT_DIRS))
ifdef WINHOST
@echo "ports: skipped (Windows host) -- musl/mksh/toybox are used as already staged in $(SYSROOT)"

14
cmd/dynhello/lib.c Normal file
View file

@ -0,0 +1,14 @@
// libdynhello.so: a shared library that is not libc, so that dynhello proves
// the dynamic linker can find, map and bind something it was not born with.
#include <stdio.h>
static int calls;
int
dynhello_greet(const char *who)
{
calls++;
return printf("dynhello: hello, %s, from a shared library (call %d)\n",
who, calls);
}

55
cmd/dynhello/main.c Normal file
View file

@ -0,0 +1,55 @@
// dynhello: the smallest honest test of dynamic linking.
//
// Linked against libc.so and libdynhello.so, with /lib/ld-musl-x86_64.so.1 as
// its interpreter, so nothing here works unless the kernel loaded the
// interpreter and the interpreter mapped both libraries. It then checks the
// three things a dynamic program leans on that a static one hides: thread-
// local storage (errno), the heap, and a fork whose child still has its
// libraries.
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
int dynhello_greet(const char *who);
int
main(int argc, char **argv)
{
int bad = 0;
dynhello_greet(argc > 1 ? argv[1] : "world");
errno = 0;
if (close(-1) == 0 || errno != EBADF) {
printf("dynhello: FAIL errno is %d, not EBADF\n", errno);
bad++;
}
char *p = malloc(1 << 20);
if (!p) {
printf("dynhello: FAIL malloc\n");
bad++;
} else {
memset(p, 0x5a, 1 << 20);
free(p);
}
pid_t pid = fork();
if (pid == 0) {
dynhello_greet("child");
_exit(0);
}
int st = 0;
if (pid < 0 || waitpid(pid, &st, 0) != pid || !WIFEXITED(st) ||
WEXITSTATUS(st) != 0) {
printf("dynhello: FAIL fork child status %d\n", st);
bad++;
}
printf("dynhello: %s\n", bad ? "FAILED" : "ok");
return bad != 0;
}

View file

@ -142,6 +142,99 @@ arg_copy(struct argsrc *s, uint64 count, pml4e_t *pgdir, addr_t *at,
static int do_exec(char *path, struct argsrc *av, struct argsrc *ev);
// --- Dynamic linking ---------------------------------------------------------
//
// A dynamically linked program names an interpreter in PT_INTERP -- on musl,
// /lib/ld-musl-x86_64.so.1, which is libc.so under another name -- and the
// kernel's whole part in dynamic linking is to load that as well and start it
// instead of the program. The interpreter finds the program through the
// auxiliary vector (AT_PHDR, AT_ENTRY), maps libc and anything else it needs
// with mmap(), relocates, and jumps to AT_ENTRY itself.
//
// Position-independent images -- the interpreter always, a PIE program
// sometimes -- are ET_DYN, linked at zero and placed at a base of the
// kernel's choosing. A PIE program goes where a fixed one would; the
// interpreter goes at the start of the mmap area, and mmap carries on above
// it, so the two never meet.
#define ET_DYN 3
#define PT_INTERP 3
#define PT_PHDR 6
#define PIE_BASE 0x400000UL // where lld puts a fixed executable
#define INTERP_BASE 0x40000000UL // the bottom of sys_mmap's bump area
// Map and read one image's PT_LOAD segments at `base`. Returns 0 and the end
// of the highest segment, and where the program headers are once loaded (0 if
// no segment carries them), or -1.
//
// Segments are mapped from where the previous one's last page ended: two
// segments may share a page, and asking allocuvm() for it twice would map it
// twice.
static int
load_image(pml4e_t *pgdir, struct inode *ip, struct elfhdr *elf, uint64 base,
uint64 *hi, uint64 *phdr_va)
{
struct proghdr ph;
uint64 mapped = 0;
*hi = 0;
*phdr_va = 0;
for (int i = 0, off = elf->phoff; i < elf->phnum; i++, off += elf->phentsize) {
if (readi(ip, 0, (uint64)&ph, off, sizeof(ph)) != sizeof(ph))
return -1;
if (ph.type == PT_PHDR)
*phdr_va = base + ph.vaddr;
if (ph.type != ELF_PROG_LOAD)
continue;
if (ph.memsz < ph.filesz || ph.vaddr + ph.memsz < ph.vaddr)
return -1;
uint64 start = (base + ph.vaddr) & ~0xFFFULL;
uint64 end = base + ph.vaddr + ph.memsz;
if (start < mapped)
start = mapped;
if (start < end && allocuvm(pgdir, start, end) == 0)
return -1;
if (PGROUNDUP(end) > mapped)
mapped = PGROUNDUP(end);
if (loaduvm(pgdir, (char*)(base + ph.vaddr), ip, ph.off, ph.filesz) < 0)
return -1;
// The headers are in the file at phoff; whichever segment covers that
// offset is where they are in memory. PT_PHDR says so directly when the
// linker wrote one, and this covers the images that do not.
if (!*phdr_va && ph.off <= elf->phoff && elf->phoff < ph.off + ph.filesz)
*phdr_va = base + ph.vaddr + (elf->phoff - ph.off);
if (end > *hi)
*hi = end;
}
return 0;
}
// The interpreter path from PT_INTERP, or "" if there is none. -1 on a
// malformed one.
static int
find_interp(struct inode *ip, struct elfhdr *elf, char *buf, int cap)
{
struct proghdr ph;
buf[0] = 0;
for (int i = 0, off = elf->phoff; i < elf->phnum; i++, off += elf->phentsize) {
if (readi(ip, 0, (uint64)&ph, off, sizeof(ph)) != sizeof(ph))
return -1;
if (ph.type != PT_INTERP)
continue;
if (ph.filesz < 2 || ph.filesz > (uint64)cap)
return -1;
if (readi(ip, 0, (uint64)buf, ph.off, ph.filesz) != (int)ph.filesz)
return -1;
buf[ph.filesz - 1] = 0;
return 0;
}
return 0;
}
// envp may be null, which means "no environment" -- the same thing this
// function used to do unconditionally.
int
@ -165,11 +258,9 @@ do_exec(char *path, struct argsrc *av, struct argsrc *ev)
uint64 _t0 = rdtsc();
fsstat.exec_calls++;
char *s, *last;
int i, off;
uint64 sz, sp;
struct elfhdr elf;
struct inode *ip;
struct proghdr ph;
pml4e_t *pgdir, *oldpgdir;
// The arguments are measured before anything is loaded, so that too many
@ -197,8 +288,11 @@ do_exec(char *path, struct argsrc *av, struct argsrc *ev)
}
uint64 max_sz = 0;
uint64 rand_va = 0;
uint64 base = 0, phdr_va = 0, interp_end = 0, interp_entry = 0;
char interp[128];
begin_op();
int in_op = 1; // the error path closes the transaction only once
int err;
if((ip = nameifollow(0, path, &err)) == 0){
@ -220,34 +314,37 @@ do_exec(char *path, struct argsrc *av, struct argsrc *ev)
if((pgdir = setupkvm()) == 0)
goto bad;
for(i=0, off=elf.phoff; i<elf.phnum; i++, off+=elf.phentsize){
if(readi(ip, 0, (uint64)&ph, off, sizeof(ph)) != sizeof(ph))
goto bad;
if(ph.type != ELF_PROG_LOAD)
continue;
if(ph.memsz < ph.filesz)
goto bad;
if(ph.vaddr + ph.memsz < ph.vaddr)
goto bad;
uint64 start = ph.vaddr & ~0xFFF;
uint64 end = ph.vaddr + ph.memsz;
if(allocuvm(pgdir, start, end) == 0)
goto bad;
if(loaduvm(pgdir, (char*)ph.vaddr, ip, ph.off, ph.filesz) < 0)
goto bad;
if(end > max_sz)
max_sz = end;
}
base = elf.type == ET_DYN ? PIE_BASE : 0;
if (find_interp(ip, &elf, interp, sizeof(interp)) < 0)
goto bad;
if (load_image(pgdir, ip, &elf, base, &max_sz, &phdr_va) < 0)
goto bad;
iunlockput(ip);
end_op();
ip = 0;
// The interpreter, at the bottom of the mmap area. It must itself be
// position independent and must not ask for an interpreter of its own.
if (interp[0]) {
struct elfhdr ielf;
uint64 ihdr;
if ((ip = nameifollow(0, interp, &err)) == 0)
goto bad;
ilock(ip);
if (readi(ip, 0, (uint64)&ielf, 0, sizeof(ielf)) != sizeof(ielf) ||
ielf.magic != ELF_MAGIC || ielf.type != ET_DYN)
goto bad;
if (load_image(pgdir, ip, &ielf, INTERP_BASE, &interp_end, &ihdr) < 0)
goto bad;
interp_entry = INTERP_BASE + ielf.entry;
iunlockput(ip);
ip = 0;
}
end_op();
in_op = 0;
// The stack: 63 pages plus a guard, as always, and on top of that room for
// the arguments, the pointer table and AT_RANDOM.
uint64 nslots = 1 + argc + 1 + envc + 1 + 20;
uint64 nslots = 1 + argc + 1 + envc + 1 + 32;
uint64 argpages = (strbytes + nslots * 8 + 64 + PGSIZE - 1) / PGSIZE;
uint64 stackpages = 64 + argpages;
sz = PGROUNDUP(max_sz);
@ -299,6 +396,18 @@ do_exec(char *path, struct argsrc *av, struct argsrc *ev)
for (int k = 0; k < (int)NELEM(fixed); k++)
if (tbl_put(&t, fixed[k]) < 0)
goto bad;
// Where the program is, for the dynamic linker -- and for a static musl
// program too, which finds its TLS template through AT_PHDR.
uint64 where[] = {
3, phdr_va, // AT_PHDR
4, sizeof(struct proghdr), // AT_PHENT
5, elf.phnum, // AT_PHNUM
7, interp[0] ? INTERP_BASE : 0, // AT_BASE: the interpreter's load address
9, base + elf.entry, // AT_ENTRY: the program's, not the interpreter's
};
for (int k = 0; k < (int)NELEM(where); k++)
if (tbl_put(&t, where[k]) < 0)
goto bad;
if (tbl_put(&t, 25) < 0 || tbl_put(&t, rand_va) < 0 || // AT_RANDOM
tbl_put(&t, 0) < 0 || tbl_put(&t, 0) < 0 || // AT_NULL
tbl_flush(&t) < 0)
@ -321,7 +430,7 @@ do_exec(char *path, struct argsrc *av, struct argsrc *ev)
// Auto-select syscall ABI: musl binaries have entry >= 0x100000;
// SchOS-native binaries (init) are linked at 0x4000.
if (elf.entry >= LINUX_ABI_ENTRY_THRESHOLD) {
if (base + elf.entry >= LINUX_ABI_ENTRY_THRESHOLD) {
myproc()->syscall_map = linux_syscall_map;
myproc()->map_size = linux_syscall_map_size;
} else {
@ -358,6 +467,17 @@ do_exec(char *path, struct argsrc *av, struct argsrc *ev)
myproc()->sigsusp_active = 0;
myproc()->mmap_next = 0;
// The interpreter is ordinary memory to everything after this: fork copies
// it, exit frees it. mmap carries on above it.
if (interp[0]) {
myproc()->vma[0].addr = INTERP_BASE;
myproc()->vma[0].length = PGROUNDUP(interp_end);
myproc()->vma[0].used = 1;
myproc()->vma[0].no_free = 0;
myproc()->vma[0].shmid = -1;
myproc()->mmap_next = INTERP_BASE + PGROUNDUP(interp_end);
}
myproc()->tf->rax = 0;
myproc()->tf->rbx = 0;
myproc()->tf->rdx = 0;
@ -373,8 +493,9 @@ do_exec(char *path, struct argsrc *av, struct argsrc *ev)
myproc()->tf->rdi = argc;
myproc()->tf->rsi = sp + 8;
myproc()->tf->rip = elf.entry;
myproc()->tf->rcx = elf.entry;
uint64 entry = interp[0] ? interp_entry : base + elf.entry;
myproc()->tf->rip = entry;
myproc()->tf->rcx = entry;
myproc()->tf->rsp = sp;
// syscall_entry saves user RSP at kstack+PGSIZE-16 (not in the trapframe).
@ -404,9 +525,9 @@ do_exec(char *path, struct argsrc *av, struct argsrc *ev)
kfree(bounce);
if(pgdir)
freevm(pgdir);
if(ip){
if(ip)
iunlockput(ip);
if(in_op)
end_op();
}
return -1;
}

View file

@ -76,6 +76,41 @@ vma_note_anon(struct proc *p, uint64 va, uint64 len)
return 0;
}
// Record [va, va+len) as anonymous memory wherever no mapping already covers
// it, for a MAP_FIXED that may land anywhere: inside an earlier mapping, across
// the end of one, or in a gap. Below sz is the program image and its heap,
// which fork copies as [0, sz) and needs no record -- a second one there would
// have fork map those pages twice.
static int
vma_cover_anon(struct proc *p, uint64 va, uint64 len)
{
uint64 cur = va, end = va + len;
if (cur < PGROUNDUP(p->sz))
cur = PGROUNDUP(p->sz);
while (cur < end) {
uint64 next = end;
int inside = -1;
for (int i = 0; i < NVMA; i++) {
struct vma *v = &p->vma[i];
if (!v->used)
continue;
if (v->addr <= cur && cur < v->addr + v->length)
inside = i;
else if (v->addr > cur && v->addr < next)
next = v->addr;
}
if (inside >= 0) {
cur = p->vma[inside].addr + p->vma[inside].length;
continue;
}
if (vma_note_anon(p, cur, next - cur) < 0)
return -ENOMEM;
cur = next;
}
return 0;
}
addr_t
sys_sbrk(void)
{
@ -127,11 +162,34 @@ sys_mmap(void)
return va;
for (uint64 a = va; a < va + length; a += PGSIZE) {
char *mem = kalloc();
if (!mem) return -1;
if (!mem) return -ENOMEM;
memset(mem, 0, PGSIZE);
if (mappages(myproc()->pgdir, (void*)a, PGSIZE, V2P((uint64)mem), PTE_W | PTE_U) < 0)
return -1;
if (mappages(myproc()->pgdir, (void*)a, PGSIZE, V2P((uint64)mem), PTE_W | PTE_U) < 0) {
kfree(mem);
return -ENOMEM;
}
}
// The file, when there is one. This path used to ignore it and hand
// back zeroed pages whatever fd said -- and a dynamic linker maps
// every segment of every library after the first exactly this way,
// MAP_FIXED at an offset into the file, so libc came up as a library
// of zeros. A private copy, as in the non-fixed path below.
if (f && f->type == FD_INODE && f->ip->type != T_DEV &&
!(flags & MAP_ANONYMOUS)) {
if ((flags & MAP_SHARED) && (prot & PROT_WRITE))
return -ENODEV;
if (!f->readable)
return -EACCES;
ilock(f->ip);
readi(f->ip, 0, va, offset, length);
iunlock(f->ip);
}
// And remembered. Without a record fork() did not copy these pages,
// so a child of a dynamically linked program had no libc.
if (vma_cover_anon(myproc(), va, length) < 0)
return -ENOMEM;
return va;
}

View file

@ -31,11 +31,22 @@ $(SRCDIR)/.patched: patches/0001-schos-tls.patch | $(SRCDIR)
cd $(SRCDIR) && patch -p1 < $(CURDIR)/patches/0001-schos-tls.patch
touch $@
$(BUILDDIR)/config.mak: $(SRCDIR)/.patched
# Shared as well as static: libc.so is also the dynamic linker, and it is
# what a dynamically linked program names as its interpreter. Every port
# still links -static on purpose; this only makes the other kind possible.
#
# --syslibdir into the prefix, not the default /lib: `make install` puts a
# symlink to the loader there, and the default is the *host's* /lib. The copy
# the OS ships is placed by the sysroot rule below.
#
# Reconfigured when this file changes, so a tree configured before shared
# libraries were turned on does not quietly stay static-only.
$(BUILDDIR)/config.mak: $(SRCDIR)/.patched Makefile
rm -rf $(BUILDDIR)
mkdir -p $(BUILDDIR)
cd $(BUILDDIR) && $(SRCDIR)/configure \
--prefix=$(PREFIX) \
--disable-shared \
--syslibdir=$(PREFIX)/lib \
CC=$(CC) \
CFLAGS="-D__SCHOS__ -fno-stack-protector"
@ -45,7 +56,14 @@ $(BUILDDIR)/lib/libc.a: $(BUILDDIR)/config.mak
$(PREFIX)/lib/libc.a: $(BUILDDIR)/lib/libc.a
$(MAKE) -C $(BUILDDIR) install
install: $(PREFIX)/lib/libc.a
# The dynamic linker, under the name every dynamically linked x86_64 musl
# program asks for. A copy rather than the prefix's symlink: that one points
# at a path on the build host.
$(SYSROOT)/lib/ld-musl-x86_64.so.1: $(PREFIX)/lib/libc.a
mkdir -p $(SYSROOT)/lib
cp $(PREFIX)/lib/libc.so $@
install: $(PREFIX)/lib/libc.a $(SYSROOT)/lib/ld-musl-x86_64.so.1
clean:
rm -rf $(PORTDIR)