Something went wrong. Try again.
Serial ports for Zig: rate, framing, flow control and modem lines, on termios2 for Linux and the DCB for Windows.
Something went wrong. Try again.
18 kB · 469 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The Windows backend, on the `DCB`.//!//! There is no `termios` and no line discipline here. A port's framing is a//! `DCB`, read and written whole with `GetCommState` and `SetCommState`, so//! raw mode amounts to switching off the few substitutions a `DCB` can make.//! Three things differ from Linux in ways a caller can see://!//! * **Any rate and every parity are ordinary values**, and one and a half//! stop bits can be asked for.//! * **What a read does when the line is quiet is a property of the port**,//! set with `COMMTIMEOUTS`, not of the call. `nonblocking` picks between//! "return what has arrived, possibly nothing" and "wait for at least one//! byte".//! * **DTR and RTS cannot be read back.** `GetCommModemStatus` reports the//! four inputs only, so `modem` reports the outputs as this port last set//! them.//!//! The kernel32 declarations are written out below rather than taken from a//! binding package: there are fifteen of them.
const std = @import("std");const windows = std.os.windows;
const types = @import("types.zig");const Settings = types.Settings;
pub const Handle = windows.HANDLE;
const BOOL = windows.BOOL;const DWORD = u32;const WORD = u16;const BYTE = u8;
const Dcb = extern struct { DCBlength: DWORD = @sizeOf(Dcb), BaudRate: DWORD = 0, flags: Flags = .{}, wReserved: WORD = 0, XonLim: WORD = 0, XoffLim: WORD = 0, ByteSize: BYTE = 0, Parity: BYTE = 0, StopBits: BYTE = 0, XonChar: u8 = 0, XoffChar: u8 = 0, ErrorChar: u8 = 0, EofChar: u8 = 0, EvtChar: u8 = 0, wReserved1: WORD = 0,};
comptime { std.debug.assert(@sizeOf(Dcb) == 28);}
/// The `DCB`'s bitfield, in the order the Windows SDK declares it.const Flags = packed struct(DWORD) { fBinary: bool = false, fParity: bool = false, fOutxCtsFlow: bool = false, fOutxDsrFlow: bool = false, fDtrControl: u2 = 0, fDsrSensitivity: bool = false, fTXContinueOnXoff: bool = false, fOutX: bool = false, fInX: bool = false, fErrorChar: bool = false, fNull: bool = false, fRtsControl: u2 = 0, fAbortOnError: bool = false, fDummy2: u17 = 0,};
const DTR_CONTROL_DISABLE = 0;const DTR_CONTROL_ENABLE = 1;const RTS_CONTROL_ENABLE = 1;const RTS_CONTROL_HANDSHAKE = 2;
const NOPARITY = 0;const ODDPARITY = 1;const EVENPARITY = 2;const MARKPARITY = 3;const SPACEPARITY = 4;
const ONESTOPBIT = 0;const ONE5STOPBITS = 1;const TWOSTOPBITS = 2;
const CommTimeouts = extern struct { ReadIntervalTimeout: DWORD, ReadTotalTimeoutMultiplier: DWORD, ReadTotalTimeoutConstant: DWORD, WriteTotalTimeoutMultiplier: DWORD, WriteTotalTimeoutConstant: DWORD,};
const MAXDWORD = std.math.maxInt(DWORD);
const PURGE_TXCLEAR = 0x4;const PURGE_RXCLEAR = 0x8;
const SETRTS = 3;const CLRRTS = 4;const SETDTR = 5;const CLRDTR = 6;
const MS_CTS_ON = 0x10;const MS_DSR_ON = 0x20;const MS_RING_ON = 0x40;const MS_RLSD_ON = 0x80;
const GENERIC_READ = 0x80000000;const GENERIC_WRITE = 0x40000000;const OPEN_EXISTING = 3;const INVALID_HANDLE_VALUE: Handle = @ptrFromInt(std.math.maxInt(usize));
const ERROR_FILE_NOT_FOUND = 2;const ERROR_PATH_NOT_FOUND = 3;const ERROR_ACCESS_DENIED = 5;const ERROR_SHARING_VIOLATION = 32;const ERROR_INVALID_PARAMETER = 87;const ERROR_INVALID_NAME = 123;
extern "kernel32" fn CreateFileW( lpFileName: [*:0]const u16, dwDesiredAccess: DWORD, dwShareMode: DWORD, lpSecurityAttributes: ?*anyopaque, dwCreationDisposition: DWORD, dwFlagsAndAttributes: DWORD, hTemplateFile: ?Handle,) callconv(.winapi) Handle;extern "kernel32" fn CloseHandle(hObject: Handle) callconv(.winapi) BOOL;extern "kernel32" fn ReadFile(hFile: Handle, lpBuffer: [*]u8, nNumberOfBytesToRead: DWORD, lpNumberOfBytesRead: ?*DWORD, lpOverlapped: ?*anyopaque) callconv(.winapi) BOOL;extern "kernel32" fn WriteFile(hFile: Handle, lpBuffer: [*]const u8, nNumberOfBytesToWrite: DWORD, lpNumberOfBytesWritten: ?*DWORD, lpOverlapped: ?*anyopaque) callconv(.winapi) BOOL;extern "kernel32" fn FlushFileBuffers(hFile: Handle) callconv(.winapi) BOOL;extern "kernel32" fn GetCommState(hFile: Handle, lpDCB: *Dcb) callconv(.winapi) BOOL;extern "kernel32" fn SetCommState(hFile: Handle, lpDCB: *const Dcb) callconv(.winapi) BOOL;extern "kernel32" fn GetCommTimeouts(hFile: Handle, lpCommTimeouts: *CommTimeouts) callconv(.winapi) BOOL;extern "kernel32" fn SetCommTimeouts(hFile: Handle, lpCommTimeouts: *const CommTimeouts) callconv(.winapi) BOOL;extern "kernel32" fn PurgeComm(hFile: Handle, dwFlags: DWORD) callconv(.winapi) BOOL;extern "kernel32" fn SetCommBreak(hFile: Handle) callconv(.winapi) BOOL;extern "kernel32" fn ClearCommBreak(hFile: Handle) callconv(.winapi) BOOL;extern "kernel32" fn EscapeCommFunction(hFile: Handle, dwFunc: DWORD) callconv(.winapi) BOOL;extern "kernel32" fn GetCommModemStatus(hFile: Handle, lpModemStat: *DWORD) callconv(.winapi) BOOL;extern "kernel32" fn GetLastError() callconv(.winapi) DWORD;extern "kernel32" fn Sleep(dwMilliseconds: DWORD) callconv(.winapi) void;
fn ok(b: BOOL) bool { return @backingInt(b) != 0;}
/// Writes `settings` into `dcb`, leaving alone what they do not touch. Kept/// apart from `SetCommState` so that it can be tested without a port. `dtr`/// and `rts` are the levels this port last set, which a `DCB` would/// otherwise put back to whatever it was opened with.fn encode(dcb: *Dcb, settings: Settings, dtr: bool, rts: bool) types.ConfigureError!void { dcb.BaudRate = settings.baud; dcb.ByteSize = @backingInt(settings.data_bits); dcb.Parity = switch (settings.parity) { .none => NOPARITY, .even => EVENPARITY, .odd => ODDPARITY, .mark => MARKPARITY, .space => SPACEPARITY, }; dcb.StopBits = switch (settings.stop_bits) { .one => ONESTOPBIT, .one_and_half => ONE5STOPBITS, .two => TWOSTOPBITS, };
var f = dcb.flags; // Win32 accepts no other value. f.fBinary = true; f.fParity = settings.parity != .none; // No substitutions, no dropped NULs, and no stopping dead on a // framing error until somebody calls ClearCommError. f.fErrorChar = false; f.fNull = false; f.fAbortOnError = false; f.fDsrSensitivity = false; f.fOutxDsrFlow = false; f.fTXContinueOnXoff = false; f.fDtrControl = if (dtr) DTR_CONTROL_ENABLE else DTR_CONTROL_DISABLE; switch (settings.flow) { .none => { f.fOutxCtsFlow = false; f.fOutX = false; f.fInX = false; f.fRtsControl = if (rts) RTS_CONTROL_ENABLE else 0; }, .rts_cts => { f.fOutxCtsFlow = true; f.fOutX = false; f.fInX = false; f.fRtsControl = RTS_CONTROL_HANDSHAKE; }, .xon_xoff => { f.fOutxCtsFlow = false; f.fOutX = true; f.fInX = true; f.fRtsControl = if (rts) RTS_CONTROL_ENABLE else 0; dcb.XonChar = 0x11; dcb.XoffChar = 0x13; }, .other => return error.UnsupportedSetting, } dcb.flags = f; dcb.ErrorChar = 0; dcb.EofChar = 0; dcb.EvtChar = 0;}
fn decode(dcb: Dcb) Settings { const f = dcb.flags; const rtscts = f.fOutxCtsFlow and f.fRtsControl == RTS_CONTROL_HANDSHAKE; const xonxoff = f.fOutX and f.fInX; return .{ .baud = dcb.BaudRate, .data_bits = switch (dcb.ByteSize) { 5 => .five, 6 => .six, 7 => .seven, else => .eight, }, .parity = switch (dcb.Parity) { ODDPARITY => .odd, EVENPARITY => .even, MARKPARITY => .mark, SPACEPARITY => .space, else => .none, }, .stop_bits = switch (dcb.StopBits) { ONE5STOPBITS => .one_and_half, TWOSTOPBITS => .two, else => .one, }, .flow = if (f.fOutxDsrFlow) .other else if (rtscts and !f.fOutX and !f.fInX) .rts_cts else if (xonxoff and !f.fOutxCtsFlow) .xon_xoff else if (!f.fOutxCtsFlow and !f.fOutX and !f.fInX) .none else .other, // Windows lowers DTR on close whatever is asked, and a port is // never waiting on carrier. .hang_up_on_close = true, .local = true, };}
pub const Port = struct { handle: Handle, original_dcb: Dcb, original_timeouts: CommTimeouts, /// See the module documentation: the outputs cannot be read back. dtr: bool = true, rts: bool = true,
/// `path` is a device name, `COM3`, or a full `\\.\COM10`; the `\\.\` /// prefix is added when it is missing, since without it `COM10` and above /// do not open. pub fn open(path: []const u8, settings: Settings, options: types.OpenOptions) types.OpenError!Port { const prefix = "\\\\.\\"; var name_buf: [260]u16 = undefined; var len: usize = 0; if (!std.mem.startsWith(u8, path, prefix)) { for (prefix) |c| { name_buf[len] = c; len += 1; } } if (len + path.len >= name_buf.len) return error.NameTooLong; len += std.unicode.utf8ToUtf16Le(name_buf[len .. name_buf.len - 1], path) catch return error.FileNotFound; name_buf[len] = 0;
// A serial port cannot be shared, whatever is asked, so `exclusive` // has nothing to add. const handle = CreateFileW(@ptrCast(&name_buf), GENERIC_READ | GENERIC_WRITE, 0, null, OPEN_EXISTING, 0, null); if (handle == INVALID_HANDLE_VALUE) return switch (GetLastError()) { ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND => error.FileNotFound, ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION => error.Busy, ERROR_INVALID_NAME => error.FileNotFound, else => error.Unexpected, }; errdefer _ = CloseHandle(handle);
var port: Port = .{ .handle = handle, .original_dcb = .{}, .original_timeouts = undefined }; if (!ok(GetCommState(handle, &port.original_dcb))) return error.NotASerialPort; if (!ok(GetCommTimeouts(handle, &port.original_timeouts))) return error.NotASerialPort;
// Return what has arrived, at once, possibly nothing — or wait for the // first byte and then return what has arrived. These are the two // documented COMMTIMEOUTS recipes; all zeros, which looks like the // second, instead waits until the whole buffer is full. const timeouts: CommTimeouts = if (options.nonblocking) .{ .ReadIntervalTimeout = MAXDWORD, .ReadTotalTimeoutMultiplier = 0, .ReadTotalTimeoutConstant = 0, .WriteTotalTimeoutMultiplier = 0, .WriteTotalTimeoutConstant = 0 } else .{ .ReadIntervalTimeout = MAXDWORD, .ReadTotalTimeoutMultiplier = MAXDWORD, .ReadTotalTimeoutConstant = MAXDWORD - 1, .WriteTotalTimeoutMultiplier = 0, .WriteTotalTimeoutConstant = 0 }; if (!ok(SetCommTimeouts(handle, &timeouts))) return error.Unexpected;
try port.configure(settings); return port; }
pub fn close(port: *Port) void { _ = SetCommState(port.handle, &port.original_dcb); _ = SetCommTimeouts(port.handle, &port.original_timeouts); _ = CloseHandle(port.handle); port.* = undefined; }
pub fn configure(port: *Port, settings: Settings) types.ConfigureError!void { var dcb: Dcb = .{}; if (!ok(GetCommState(port.handle, &dcb))) return error.InputOutput;
try encode(&dcb, settings, port.dtr, port.rts);
if (!ok(SetCommState(port.handle, &dcb))) return switch (GetLastError()) { ERROR_INVALID_PARAMETER => error.UnsupportedBaud, else => error.Unexpected, }; }
pub fn current(port: *Port) types.ConfigureError!Settings { var dcb: Dcb = .{}; if (!ok(GetCommState(port.handle, &dcb))) return error.InputOutput; return decode(dcb); }
pub fn read(port: *Port, buf: []u8) types.ReadError!usize { var n: DWORD = 0; const len: DWORD = @intCast(@min(buf.len, MAXDWORD)); if (!ok(ReadFile(port.handle, buf.ptr, len, &n, null))) return error.InputOutput; // A timeout reads as success with nothing read; there is no end of // file on a serial port to confuse it with. if (n == 0 and buf.len != 0) return error.WouldBlock; return n; }
pub fn write(port: *Port, bytes: []const u8) types.WriteError!usize { var n: DWORD = 0; const len: DWORD = @intCast(@min(bytes.len, MAXDWORD)); if (!ok(WriteFile(port.handle, bytes.ptr, len, &n, null))) return error.InputOutput; return n; }
pub fn drain(port: *Port) types.WriteError!void { if (!ok(FlushFileBuffers(port.handle))) return error.InputOutput; }
pub fn flush(port: *Port, queue: types.Queue) types.ConfigureError!void { const flags: DWORD = switch (queue) { .input => PURGE_RXCLEAR, .output => PURGE_TXCLEAR, .both => PURGE_RXCLEAR | PURGE_TXCLEAR, }; if (!ok(PurgeComm(port.handle, flags))) return error.InputOutput; }
/// Win32 has no timed break, only a start and a stop; a quarter of a /// second between them matches what Linux sends. pub fn sendBreak(port: *Port) types.WriteError!void { if (!ok(SetCommBreak(port.handle))) return error.InputOutput; Sleep(250); if (!ok(ClearCommBreak(port.handle))) return error.InputOutput; }
pub fn modem(port: *Port) types.LineError!types.Modem { var status: DWORD = 0; if (!ok(GetCommModemStatus(port.handle, &status))) return error.Unsupported; return .{ .dtr = port.dtr, .rts = port.rts, .cts = status & MS_CTS_ON != 0, .dsr = status & MS_DSR_ON != 0, .dcd = status & MS_RLSD_ON != 0, .ri = status & MS_RING_ON != 0, }; }
pub fn setLine(port: *Port, line: types.Line, on: bool) types.LineError!void { const func: DWORD = switch (line) { .dtr => if (on) SETDTR else CLRDTR, .rts => if (on) SETRTS else CLRRTS, }; if (!ok(EscapeCommFunction(port.handle, func))) return error.Unsupported; switch (line) { .dtr => port.dtr = on, .rts => port.rts = on, } }};
// ---------------------------------------------------------------------------// Tests. A Windows machine without a serial port -- a CI runner, say -- can// still check the conversion to and from the `DCB`, and that what is not a// port is refused.// ---------------------------------------------------------------------------
const testing = std.testing;
test "the DCB bitfield is where the SDK puts it" { try testing.expectEqual(0, @bitOffsetOf(Flags, "fBinary")); try testing.expectEqual(2, @bitOffsetOf(Flags, "fOutxCtsFlow")); try testing.expectEqual(4, @bitOffsetOf(Flags, "fDtrControl")); try testing.expectEqual(8, @bitOffsetOf(Flags, "fOutX")); try testing.expectEqual(11, @bitOffsetOf(Flags, "fNull")); try testing.expectEqual(12, @bitOffsetOf(Flags, "fRtsControl")); try testing.expectEqual(14, @bitOffsetOf(Flags, "fAbortOnError"));}
test "every setting survives encoding and decoding" { // Windows reports `hang_up_on_close` and `local` as always on, so they // keep their defaults here. const cases = [_]Settings{ .{}, .{ .baud = 9600, .data_bits = .seven, .parity = .even, .stop_bits = .two }, .{ .baud = 300, .data_bits = .five, .parity = .odd, .stop_bits = .one_and_half, .flow = .rts_cts }, .{ .baud = 31250, .data_bits = .six, .parity = .mark, .flow = .xon_xoff }, .{ .baud = 4_000_000, .parity = .space }, }; for (cases) |want| { var dcb: Dcb = .{}; try encode(&dcb, want, true, true); try testing.expectEqual(want, decode(dcb)); // Raw: no substitutions, no dropped NULs, no stopping on an error. try testing.expect(dcb.flags.fBinary); try testing.expect(!dcb.flags.fNull); try testing.expect(!dcb.flags.fErrorChar); try testing.expect(!dcb.flags.fAbortOnError); try testing.expectEqual(want.parity != .none, dcb.flags.fParity); }}
test "the modem lines keep the levels this port last set" { var dcb: Dcb = .{}; try encode(&dcb, .{}, false, false); try testing.expectEqual(DTR_CONTROL_DISABLE, dcb.flags.fDtrControl); try testing.expectEqual(0, dcb.flags.fRtsControl);
// Except that hardware flow control takes RTS over. try encode(&dcb, .{ .flow = .rts_cts }, false, false); try testing.expectEqual(RTS_CONTROL_HANDSHAKE, dcb.flags.fRtsControl);}
test "what cannot be expressed is refused" { var dcb: Dcb = .{}; try testing.expectError(error.UnsupportedSetting, encode(&dcb, .{ .flow = .other }, true, true));}
test "DSR flow control left by another program reads as other" { var dcb: Dcb = .{}; try encode(&dcb, .{}, true, true); dcb.flags.fOutxDsrFlow = true; try testing.expectEqual(types.Flow.other, decode(dcb).flow);}
test "what is not a serial port is refused" { try testing.expectError(error.NotASerialPort, Port.open("NUL", .{}, .{})); try testing.expectError(error.FileNotFound, Port.open("COM255", .{}, .{}));}