The non-Universal binary: a cautionary tale

Predictability, determinism if you want to be posh, is something we come to rely on. When you have two apps which use identical code, you expect it to behave exactly the same in each app. When it doesn’t, you can readily lose much of a day trying to discover why.

My problem should have had a simple solution: I wanted to get the firmware version number in all recent models of Mac. For Intel hardware, this is slightly arcane, but reliable. You can either use the command line equivalent of System Information, system_profiler, or you can inspect an IORegistry entry in the path IODeviceTree:/rom, and there are a couple of other alternatives. The IORegistry technique doesn’t appear to work with Apple Silicon models, though, and system_profiler‘s output also changes subtly.

I have three apps, each of which need now to check with system_profiler so they can get the firmware version. Mints was a bit of a bigger job, as it also needs to obtain other information from that tool, so makes the call
/usr/sbin/system_profiler -xml -detailLevel basic SPHardwareDataType
to get it in XML format, then trudges through that data. That works fine.

LockRattler has simpler needs, and uses
/usr/sbin/system_profiler SPHardwareDataType
to get the information in plain text. That too works fine.

But dropping either function to obtain the firmware version number into SilentKnight failed completely: there was simply no sign of that line in the text output, nor the key boot_rom_version in the XML. I tried entering the commands in Terminal, where both worked fine, and generated the firmware version reproducibly.

So there was something wrong internally in SilentKnight. My first suspicion was that the app wasn’t waiting properly for the result from system_profiler, which takes a little longer than many commands. That wasn’t the case, and I could access other data produced by system_profiler, just not the firmware version number, except on Intel systems, where it worked fine.

It wasn’t until several hours later, by which time I had coded almost every conceivable variant to access this missing firmware version, that it occurred to me that there might be a difference in SilentKnight’s build settings. Before I had gone far in checking the many inscrutable controls, I realised that all these test versions of SilentKnight which had failed were being built for Intel processors alone, and not as Universal Apps, although elsewhere I had changed that setting and debug versions of Mints and LockRattler are Universal.

determinism

I quickly reinstated some sensible code to check for the firmware version – things had got out of hand by this time – changed the build settings so that the debug build is also a Universal App, and the problem vanished. My original code from several increasingly fraught hours ago now worked a treat.

The explanation is as weird as its solution. When an Intel app runs on Apple Silicon and makes calls to command tools like system_profiler, it will keep to a consistent architecture, so calling the Intel version of the tool. The Intel version of system_profiler gave a different result when asked to provide information of SPHardwareDataType, which omitted the Apple Silicon firmware version.

When a Universal App runs on Apple Silicon and makes that same call to a command tool, it’s the ARM version of the executable which is run, and that can and does deliver the firmware version number on an Apple Silicon Mac.

In case you expect code for the supported architectures in a Universal binary to do essentially the same things, let this be a lesson. I don’t know whether system_profiler intentionally omits firmware versions when running in Rosetta 2, but it’s entirely unpredictable. I’ve also learned the lesson to ensure that I never build anything for a single architecture, even a debug version, although Xcode seems to make that a default. Now you’ll be testing on and developing for both architectures, ensure everything is built for both.