Lay out device/stack interfaces - Netdev/Netlink (#92)

* good code today

* add netdev.Runner

* round off APIs more

* better interface method documentation

* improve DHCP netstack API

* add pico w netdev example

* remove temp file

* keep thinking about this. this is hard :/

* fix ci

* small fixer

* working dhcp

* fix rebase API mismatches

* begin adding espradio example

* keep working on espradio, icmp not working

* fix icmp by fixing dhcp
This commit is contained in:
Pat Whittingslow
2026-06-17 14:18:08 -03:00
committed by GitHub
parent 5f8ca45859
commit 813b7b5e57
16 changed files with 925 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
//go:build tinygo
package netdev
import _ "unsafe" // needed for go:linkname usage.
// UseNetdev is the dynamic linker function
// for inserting a networking stack into the
// standard library implementation in the TinyGo compiler.
//
//go:linkname UseNetdev net.useNetdev
func UseNetdev(dev GoNet)
+30
View File
@@ -0,0 +1,30 @@
package netdev
import (
"net/netip"
"time"
)
// GoNet is the networking interface expected by TinyGo compiler/standard library.
// The methods below define a networking stack as expected by the Go standard library
// when using the TinyGo compiler+stdlib.
type GoNet interface {
// GetHostByName returns the IP address of either a hostname or IPv4
// address in standard dot notation
GetHostByName(name string) (netip.Addr, error)
// Addr returns IP address assigned to the interface, either by
// DHCP or statically
Addr() (netip.Addr, error)
// Berkely Sockets-like interface, Go-ified. See man page for socket(2), etc.
Socket(domain int, stype int, protocol int) (int, error)
Bind(sockfd int, ip netip.AddrPort) error
Connect(sockfd int, host string, ip netip.AddrPort) error
Listen(sockfd int, backlog int) error
Accept(sockfd int) (int, netip.AddrPort, error)
Send(sockfd int, buf []byte, flags int, deadline time.Time) (int, error)
Recv(sockfd int, buf []byte, flags int, deadline time.Time) (int, error)
Close(sockfd int) error
SetSockOpt(sockfd int, level int, opt int, value any) error
}
+172
View File
@@ -0,0 +1,172 @@
package netdev
import (
"context"
"errors"
"net/netip"
"github.com/soypat/lneto"
"github.com/soypat/lneto/ethernet"
"github.com/soypat/lneto/internal"
)
type Interface[C any] struct {
dev DevEthernet
netlink Netlink[C]
ip netip.Prefix
// Below are values calculated from [DevEthernet] return values to avoid recalculation.
frameSize int
frameOff int
mtu int
mac [6]byte
}
// DevEthernet is an L2 capable device HAL. It is an abstraction
// for devices that send actively and are not polled by an external host.
//
// DevEthernet-specific initialization (WiFi join, PHY auto-negotiation,
// firmware loading) must complete BEFORE the device is used as a stack endpoint.
type DevEthernet interface {
// HardwareAddr6 returns the device's 6-byte MAC address.
// For PHY-only devices, returns the MAC provided at configuration.
HardwareAddr6() ([6]byte, error)
// SendEthFrameOffset transmits a complete Ethernet frame at offset given by [DevEthernet.MaxFrameSizeAndOffset].
// The frame includes the Ethernet header but NOT the FCS/CRC
// trailer (device or stack handles CRC as appropriate).
// SendEthFrameOffset blocks until the transmission is queued succesfully
// or finished sending. Should not be called concurrently
// unless user is sure the driver supports it.
SendOffsetEthFrame(offsetTxEthFrame []byte) error
// SetRecvHandler registers the function called when an Ethernet
// frame is received. Buffers needed by the device to operate efficiently
// should be allocated on its side. This function is mutually exclusive with EthPoll:
// use on or the other to receive data.
SetEthRecvHandler(handler func(rxEthframe []byte))
// EthPoll services the device. For poll-based devices (e.g. CYW43439
// over SPI), reads from the bus and invokes the handler for each
// received frame. This method is mutually exclusive with SetEthRecvHandler:
// use one or the other to receive data but not return data via both channels.
EthPoll(buf []byte) (ethFrameOff, ethernetBytes int, err error)
// MaxFrameSizeAndOffset returns the max complete device frame size
// (including headers and any overhead) for buffer allocation.
// The second value returned is the offset at which the ethernet frame
// should be stored when being passed to [DevEthernet.SendOffsetEthFrame].
// Buffers allocated should be maxEthernetFrameSize+frameOff where maxEthernetFrameSize
// is usually 1500 but less or equal to maxFrameSize-frameOff.
// MTU can be calculated doing:
// // mfu-(14+4+4) for:
// // ethernet header+ethernet CRC if present+ethernet VLAN overhead for VLAN support.
// mtu := dev.MaxFrameSizeAndOffset() - ethernet.MaxOverheadSize
MaxFrameSizeAndOffset() (maxFrameSize int, frameOff int)
}
// Stack is an abstraction for a networking stack.
type Stack interface {
// Configure configures this Stack with the argument mac, ip and gateway addresses.
// The Stack must resolve the gateway hardware address if set.
// Configure(mac net.HardwareAddr, ip netip.Prefix, gw netip.Addr) error
// EnableICMP enables responding/sending ICMP echo frames.
EnableICMP(enabled bool) error
// EnableDHCP enables DHCP on the device if enabled=true and performs a DHCP request.
EnableDHCP(ctx context.Context, enabled bool, reqIP netip.Addr) (assigned netip.Addr, routerGW netip.Addr, subnetBits int, _ error)
// Socket is a berkeley socket abstraction. Returns an [net.Listener] or [net.Conn] depending on laddr/raddr combination.
Socket(ctx context.Context, network string, family, sotype int, laddr, raddr netip.AddrPort) (c any, err error)
// EgressPackets instructs Stack to write outgoing packets into bufs and writing the sizes into sizes.
// offset can be used to tell the stack to start writing after an offset for each buffer.
// The size written into sizes includes only Ethernet frame size and so is independent of offset value.
EgressPackets(bufs [][]byte, sizes []int, offset int) error
// IngressPackets is called on incoming packets so the Stack can direct packets to
// their respective node/connection and update internal state.
// offset instructs the Stack to start reading packets at an offset.
IngressPackets(bufs [][]byte, offset int) error
}
// Netlink represents the physical part of a network device which can connect/disconnect.
// One netlink may correspond to many network devices for interconnected systems.
type Netlink[C any] interface {
// LinkConnect attempts to connect the Netlink if it was not already connected.
// It will block until it succeeds/fails and not retry after returning.
LinkConnect(connectParams C) error
// LinkDisconnect disconnects the Netlink immediately.
LinkDisconnect()
// Link notify sets the callback to be executed after connection state
// changes for the Netlink. The callback can signal an immediate reconnect is desired
// by setting reconnectNowRetries to a positive integer. The netlink should then retry connection
// immediately with the given reconnectParams. reconnectParams should not be nil if reconnectNowRetries is positive.
LinkNotify(cb NotifyCallback[C])
}
// NotifyCallback is a convenience type alias that serves mostly as semantic code documentation.
// NotifyCallback is called when a [Netlink] connects or disconnects from a network. The callback returns:
// - reconnectNowRetries: Amount of times to attempt reconnection before giving up.
// - reconnectParams: parameters to use in reconnection.
type NotifyCallback[C any] = func(connected bool) (reconnectNowRetries int, reconnectParams C)
// InterfaceConfig mostly optional configuration.
type InterfaceConfig struct {
// NetworkIP sets the network's IP range and this interface's IP address. See [netip.Prefix].
// This field is optional if not using a networking stack.
NetworkIP netip.Prefix
// HardwareAddr6 overrides the device hardware address during Init.
// This field is optional if [DevEthernet.HardwareAddr6] returns valid MAC.
HardwareAddr6 [6]byte
// MTU is the maximum ethernet payload size. Does not include ethernet header(14b) and FCS(4b).
// If MTU is zero the default ipv4.MTU value of 1500 is used.
MTU uint16
}
// Init initializes the interface from scratch with a netlink and device. If Init fails all methods on Interface are unsafe to call (panic).
func (iface *Interface[C]) Init(netlink Netlink[C], dev DevEthernet, cfg InterfaceConfig) (err error) {
if netlink == nil || dev == nil {
return lneto.ErrInvalidConfig
}
maxFrameSize, frameOff := dev.MaxFrameSizeAndOffset()
maxEthFrame := maxFrameSize - frameOff
maxEthPayload := maxEthFrame - 14
mtu := int(cfg.MTU)
if mtu == 0 {
mtu = min(1500, maxEthPayload)
}
if mtu > maxEthPayload {
return errors.New("MTU exceeds max frame size")
} else if mtu < ethernet.MinimumMTU || mtu > ethernet.MaxMTU {
return errors.New("bad DevEthernet max frame size and/or frame offset. typical is 1500,0")
}
var mac [6]byte
if internal.IsZeroed(cfg.HardwareAddr6) {
mac, err = dev.HardwareAddr6()
if err != nil {
return err
} else if internal.IsZeroed(mac) {
return lneto.ErrInvalidAddr
}
} else {
mac = cfg.HardwareAddr6
}
*iface = Interface[C]{
dev: dev,
netlink: netlink,
ip: cfg.NetworkIP,
frameSize: maxFrameSize,
frameOff: frameOff,
mtu: mtu,
mac: mac,
}
return nil
}
// HardwareAddr6 returns the hardware address the [Interface] was configured with.
func (iface *Interface[C]) HardwareAddr6() [6]byte {
return iface.mac
}
// NetworkAddr returns the IP and subnet of the network behind the interface. See [netip.Prefix].
// May be unset/invalid.
func (iface *Interface[C]) NetworkAddr() netip.Prefix {
return iface.ip
}
func (iface *Interface[C]) bufsize() int {
return iface.frameOff + 14 + iface.mtu
}
+141
View File
@@ -0,0 +1,141 @@
package netdev
import (
"context"
"errors"
"sync/atomic"
"github.com/soypat/lneto"
)
// Runner orchestrates an Interface and a Stack asynchronously.
type Runner[C any] struct {
running atomic.Uint32
// buflen stores the length of data inside buf. It is used as a buffer acquisition synchronizing primitive.
buflen atomic.Uint32
// pktlost is incremented each time an incoming packet is lost due to insufficient buffer size.
pktlost atomic.Uint64
tx, rx atomic.Uint64
// buf stores actual data.
buf []byte
// bufsaux is used as ana argument to stack processing so that no allocations are performed
bufsaux [1][]byte
sizesaux [1]int
handlerTriggered bool
deviceIsPollOnly bool
}
func (r *Runner[C]) Run(ctx context.Context, iface Interface[C], stack Stack, backoff lneto.BackoffStrategy) error {
if stack == nil || backoff == nil {
return errors.New("nil arguments to Run")
}
if !r.acquire() {
return errors.New("runner currently running.")
}
defer func() {
iface.dev.SetEthRecvHandler(nil)
r.release()
}()
r.rx.Store(0)
r.tx.Store(0)
r.buflen.Store(0)
r.pktlost.Store(0)
r.handlerTriggered = false
r.deviceIsPollOnly = false
bufsize := iface.bufsize()
if cap(r.buf) < bufsize {
r.buf = make([]byte, bufsize)
}
r.buf = r.buf[:bufsize]
iface.dev.SetEthRecvHandler(r.recvEthHandler)
// backoffs stores number of consecutive times no data was sent/received.
var backoffs uint
for ctx.Err() == nil {
n1, _ := r.processRx(stack, 0)
eoff, efrm, err := iface.dev.EthPoll(r.buf)
n2, _ := r.processRx(stack, 0)
if efrm > 0 && n2 == 0 {
r.deviceIsPollOnly = true
r.buflen.Store(uint32(eoff + efrm))
r.processRx(stack, eoff)
} else if efrm > 0 && n2 > 0 {
return errors.New("device both returns nonzero poll read and calls, choose one")
} else if err != nil {
println("err EthPoll:", err.Error())
}
// Now do Tx, but first acquire buffer.
if !r.buflen.CompareAndSwap(0, 1) {
continue // Oh no, async data received, go back to Rx processing.
}
r.bufsaux = [1][]byte{r.buf}
err = stack.EgressPackets(r.bufsaux[:], r.sizesaux[:], iface.frameOff)
n := r.sizesaux[0]
if err != nil {
println("err EgressPackets:", err.Error())
} else if n > 0 {
if n+iface.frameOff > len(r.buf) {
return errors.New("EgressPackets returned invalid written data given frameOffset and argument buffer size")
}
err = iface.dev.SendOffsetEthFrame(r.bufsaux[0][:n+iface.frameOff])
r.tx.Add(uint64(n + iface.frameOff))
if err != nil {
println("err SendOffsetEthFrame:", err.Error())
}
}
r.buflen.Store(0) // Release buffer.
if n1 > 0 || n2 > 0 || efrm > 0 || n > 0 {
backoffs = 0
} else {
backoff.Do(backoffs)
backoffs++
}
}
return ctx.Err()
}
// PrintDebug
//
// Deprecated: Might be given other shape in future, but this is not how we do debugging. use freely meanwhile.
func (r *Runner[C]) PrintDebug() {
print("RUNNER: tx|rx:", r.tx.Load(), "|", r.rx.Load(),
" devpollonly:", r.deviceIsPollOnly, " pktlost:", r.pktlost.Load(),
" handles:", r.handlerTriggered, " bufsize:", len(r.buf),
"\n")
}
func (r *Runner[C]) acquire() bool {
return r.running.CompareAndSwap(0, 1)
}
func (r *Runner[C]) release() {
if r.running.Load()&1 == 0 {
panic("release of unacquired resource")
}
r.running.Store(0)
}
// recvEthHandler is called asynchronously. Should be as fast as possible. Do not block inside.
func (r *Runner[C]) recvEthHandler(incomingEthernet []byte) {
if !r.buflen.CompareAndSwap(0, uint32(len(incomingEthernet))) {
// Failed to acquire buffer, packet dropped.
r.pktlost.Add(1)
return
}
copy(r.buf, incomingEthernet)
}
// processRx is called after a packet is received asynchronously and compied to buffer via recvEthHandler
func (r *Runner[C]) processRx(stack Stack, ethFrameOff int) (int, error) {
r.handlerTriggered = true
n := r.buflen.Load()
if n == 0 {
return 0, nil
}
r.rx.Add(uint64(n))
defer r.buflen.Store(0)
r.bufsaux = [1][]byte{r.buf[:n]}
return int(n), stack.IngressPackets(r.bufsaux[:], ethFrameOff)
}