An interesting comment on my previous post suggests using udev rules to give connected USB-to-serial adaptors their own, unique names. It works by setting udev rules to apply a symbolic link to specific devices when they are connected.

The approach, outlined in this blog post, works at the command line. This got me thinking: can I do the same in code?
Central to doing so is libudev, a library of routines used to enumerate and access devices managed by udev. A little digging found API docs and examples, and I was able to write a basic C routine to get the USB serial number of a connected USB-to-serial adaptor.
Note I should, of course, be using the more modern sd-device library, part of systemd, but hey, I’ve got to start somewhere. That can be phase 2…
Here’s the code ported to Swift, which is what dlist is written in, and after adding libudev as a dependency by way of a shim header and a custom module map which allow Swift to import the library’s functions and link to it:
func getSerialNumber(_ device: String) -> String? {
// Get the `/sys` path to the specified device
let devicePath = SYS_PATH_LINUX + device
// udev access must begin with `udev_new()` (see `man udev`)
// and, if we get a pointer to the struct, we have to free it
// before the function exits
guard let udev = udev_new() else { return nil }
defer { udev_unref(udev) }
// Get the udev representation of the specified device.
// Again, make sure we free it before the function exits
guard var dev = udev_device_new_from_syspath(udev, devicePath) else { return nil }
defer { udev_device_unref(dev) }
// Get the device's parent node
dev = udev_device_get_parent_with_subsystem_devtype(dev, "usb", "usb_device")
let serial = udev_device_get_sysattr_value(dev, "serial")!
return String(cString: serial)
}
With the device’s USB serial number in hand, and the required alias, just write it all out as a rules file:
func apply(alias: String, to serial: String, path: String = "") -> Bool {
// TODO Update deviceLine for ttyACMx devices too
let deviceLine = "KERNEL==\"ttyUSB?\", ATTRS{serial}==\"\(serial)\", SYMLINK+=\"\(alias)\", MODE=\"0666\"\n"
let fm = FileManager.default
if fm.fileExists(atPath: UDEV_RULES_PATH_LINUX) {
// Read in the file and add the new rule or update an existing one
do {
var rulesFileText = try String(contentsOfFile: UDEV_RULES_PATH_LINUX)
// Make sure the alias is not in use
if rulesFileText.contains("SYMLINK+=\"\(alias)\"") {
reportErrorAndExit("Alias \(alias) already in use", 3)
}
// Check the serial number: If it already exists, update its alias
if let matchSerial = rulesFileText.firstMatch(of: #/ATTRS{serial}=="(.*?)"/#) {
if let matchAlias = rulesFileText.firstMatch(of: #/SYMLINK\+="(.*?)"/#) {
let oldAlias = String(matchAlias.1)
if oldAlias != alias {
rulesFileText = rulesFileText.replacingOccurrences(of: "+=\"\(oldAlias)", with: "+=\"\(alias)")
reportInfo("Alias changed from \(oldAlias) to \(alias) -- reconnect your device to make use of it")
}
}
} else {
rulesFileText += deviceLine
}
return writeRules(rulesFileText)
} catch {
// Fallthrough
}
} else {
return writeRules(deviceLine)
}
//Error!
return false
}
func writeRules(_ fileContents: String) -> Bool {
do {
try fileContents.write(toFile: UDEV_RULES_PATH_LINUX, atomically: false, encoding: .utf8)
return true
} catch {
// Fallthrough
}
return false
}
When the device is next connected, udev causes it to appear in /dev/ttyUSBx and as, say, /dev/PicoBoard01. The latter is a symlink but operates. By setting the permissions correctly, as the blog post notes, it doesn’t require code to be run as sudo.
Of course, the point of my utility, dlist, is to provide the path to you, whatever it is — you don’t need to know whether a connected device is /dev/ttyUSB0, /dev/ttyUSB1 or whatever. If there are more than one devices attached, dlist identifies them by an index number.
That said, the symlink approach guarantees that a given device, when connected, will always be referenced by the symlink you’ve granted it. In other words, the method provides a way for you always to know a connected device is called. dlist, on the other hand, ensures you don’t need to know. Both approaches are good — take your pick which you prefer.
OK, so that’s Linux, but what about macOS, which dlist also supports?
Connected devices are named by the driver, not udev, which macOS doesn’t use. Getting a device’s USB serial number is possible. For example, in a termnal run
system_profiler SPUSBDataType | grep "Serial Number" | cut -w -f 4
to view a connected adaptor’s serial number.
Programmatically, it’s rather more complicated, requiring C-based IOKit and therefore some fiddly work to interoperate with Swift. The hardest part was actually working out how to get enumerated serial ports’ USB serial numbers, but despite some opaque documentation (where documentation exists), I managed to find the solution via a mix of advice on solving other macOS USB questions and reading between the lines.
This function generates a dictionary of devices, keyed by their Unix device paths with their USB serial numbers as values:
func findConnectedSerialDevices() -> [String: String] {
var portIterator: io_iterator_t = 0
if let matchesCFDict = IOServiceMatching(kIOSerialBSDServiceValue) {
// Convert received CFDictionary to a Swift equivalent so
// we can easily punch in the values we want...
let matchesNSDict = matchesCFDict as NSDictionary
var matches = matchesNSDict.swiftDictionary
matches[kIOSerialBSDTypeKey] = kIOSerialBSDAllTypes
// ...and convert it back again for use
let matchesCFDictRef = (matches as NSDictionary) as CFDictionary
if IOServiceGetMatchingServices(kIOMasterPortDefault, matchesCFDictRef, &portIterator) == KERN_SUCCESS {
// We got a port iterator back - ie. one or more matching devcies - so use it
defer { IOObjectRelease(portIterator) }
return getSerialDevices(portIterator)
}
}
// No devices found, or error
return [:]
}
To get the serial numbers themselves, it calls:
func getSerialDevices(_ portIterator: io_iterator_t) -> [String: String] {
var serialDevices: [String: String] = [:]
var serialDevice: io_service_t
let serialKey = "USB Serial Number"
let bsdPathKey = kIOCalloutDeviceKey
repeat {
serialDevice = IOIteratorNext(portIterator)
if serialDevice == 0 {
break
}
var serialNumber = "UNKNOWN"
let searchOptions : IOOptionBits = IOOptionBits(kIORegistryIterateParents) |
IOOptionBits(kIORegistryIterateRecursively)
if let serialRef : CFTypeRef = IORegistryEntrySearchCFProperty(serialDevice, kIOServicePlane, serialKey as CFString, nil, searchOptions) {
// Got a serial number - convert to text
serialNumber = String(describing: serialRef)
}
// Get the Unix device path
let bsdPathAsCFString: CFTypeRef? = IORegistryEntryCreateCFProperty(serialDevice, bsdPathKey as CFString, kCFAllocatorDefault, 0).takeUnretainedValue()
if let bsdPath = bsdPathAsCFString as? String {
if doKeepDevice(bsdPath) {
serialDevices[bsdPath] = serialNumber
}
}
// Release the current device object
IOObjectRelease(serialDevice)
} while true
// Return the list of devices and serial numbers
return serialDevices
}
Thee subsidiary function dokeepDevice() just makes sure we don’t include macOS’ own serial devices (eg. for Bluetooth) in the list.
To show it works, you can run a basic output loop:
let devices = findConnectedSerialDevices()
if !devices.isEmpty {
for (path, serialNumber) in devices {
print("Connected device at \(path) has USB serial number \(serialNumber)")
}
}
Yielding:
Connected device at /dev/cu.usbmodem101 has USB serial number E6605838833D5F2E
Connected device at /dev/cu.usbmodem1101 has USB serial number e6614c311b368723
To work with aliases, because there’s no udev under macOS, I have to do some jiggery-pokery. In short, dlist needs to maintain a list of aliases and the unique USB serial numbers of the devices they reference. Pass in an alias as your chosen device (assuming you have more than one connected; it’s unnecessary otherwise), then dlist can look up the alias, get the serial number it maps to, check that the referenced device is connected, and issues the relevant device file path via stdout.
Personally, it’s enough for me to get a list of connected devices and then use dlist with the device’s listed index, but the alias system provides an alternative that may be quicker to use when you know the device you want.
I haven’t yet fully added this functionality to dlist — it’s a work in progress. But it has been a very interesting rabbit hole down which to explore, taking in udev, IOKit, C interoperability with Swift in both Xcode (macOS) and Swift Package Manager (Linux) contexts, and much more. It has certainly kept me entertained! The moral: never ignore suggestions in comments because they can take you on a fascinating journey. Oh the places you’ll go…
