Swift Tuples Drop Their Tail Padding
Jake MarshIn this post
Multimeter can show your Mac’s fan speed. There’s no public API for that, so it asks the System Management Controller through IOKit: open the AppleSMC service, then call IOConnectCallStructMethod with a struct going in and the same kind of struct coming back. The struct is SMCKeyData_t, and in C it’s 80 bytes.
My first Swift version of that struct was 76 bytes. It compiled, nothing warned, and every call came back 0xe00002c2, which is kIOReturnBadArgument. The cause is a rule about Swift layout that’s easy to miss if most of your C interop goes through imported headers: a Swift tuple’s size doesn’t include its trailing padding, and the next field moves in to fill it.
The C Struct#
Here’s the C declaration the SMC expects:
#include <stdint.h>
typedef struct { char major; char minor; char build; char reserved[1]; uint16_t release; } SMCKeyData_vers_t;
typedef struct { uint16_t version; uint16_t length; uint32_t cpuPLimit; uint32_t gpuPLimit; uint32_t memPLimit; } SMCKeyData_pLimitData_t;
typedef struct { uint32_t dataSize; uint32_t dataType; char dataAttributes; } SMCKeyData_keyInfo_t;
typedef char SMCBytes_t[32];
typedef struct {
uint32_t key;
SMCKeyData_vers_t vers;
SMCKeyData_pLimitData_t pLimitData;
SMCKeyData_keyInfo_t keyInfo;
char result;
char status;
char data8;
uint32_t data32;
SMCBytes_t bytes;
} SMCKeyData_t;Three of the fields are small structs of their own. A tuple is the obvious way to write those in Swift without declaring three extra types, so that’s what I did (Bytes is a typealias for a tuple of 32 UInt8s):
var key: UInt32 = 0
var vers = (UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt16(0))
var pLimitData = (UInt16(0), UInt16(0), UInt32(0), UInt32(0), UInt32(0))
var keyInfo = (UInt32(0), UInt32(0), UInt8(0))
var result: UInt8 = 0
var status: UInt8 = 0
var data8: UInt8 = 0
var data32: UInt32 = 0
var bytes: Bytes = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)vers and pLimitData happen to come out right. keyInfo doesn’t.
Size, Stride And Alignment#
MemoryLayout gives you three numbers for any type, and they answer different questions:
sizeis how many bytes the value’s fields occupy, from the start to the end of the last field.alignmentis the boundary the value has to start on.strideissizerounded up to a multiple ofalignment, which is the distance from one element to the next in an array.
For a (UInt32, UInt32, UInt8) tuple, that’s:
typealias KeyInfo = (UInt32, UInt32, UInt8)
print(MemoryLayout<KeyInfo>.size) // 9
print(MemoryLayout<KeyInfo>.stride) // 12
print(MemoryLayout<KeyInfo>.alignment) // 4C has one number for this, sizeof, and it always includes the tail padding. sizeof(SMCKeyData_keyInfo_t) is 12, and a field that comes after a keyInfo starts 12 bytes after it.
Swift lays out the fields of a struct differently. Each field goes at the next offset that suits its own alignment after the previous field’s size. So result, a UInt8 with an alignment of 1, goes at offset 37, right after the tuple’s 9 bytes, in the spot C keeps as padding.
The same thing happens if you declare keyInfo as a nested Swift struct with three properties. Its size is 9 and the next field lands at 9. Only a struct imported from C keeps C’s answer: if Swift sees SMCKeyData_keyInfo_t through a header, its size is 12.
Where The Bytes Went#
Here are the field offsets from offsetof in C next to MemoryLayout.offset(of:) for the tuple version:
| Field | C | Swift with a tuple |
|---|---|---|
keyInfo |
28 | 28 |
result |
40 | 37 |
data32 |
44 | 40 |
bytes |
48 | 44 |
| Total | 80 | 76 |
Everything after keyInfo is in the wrong place. result, status and data8 move up three bytes; data32 realigns to 40, four bytes early; the 32-byte payload starts at 44 and ends at 76.
The size alone is enough to get the call rejected. SMCReader passes MemoryLayout<KeyData>.stride as the input size, and for this version that’s 76. On my MacBook Pro running macOS 27, sending a correctly laid out struct with an input size of 76 returns kIOReturnBadArgument, and the same struct with 80 succeeds.
The Fix#
In Multimeter’s SMCReader, keyInfo is now three separate properties with the padding written out:
// Mirrors the C SMCKeyData_t (80 bytes). keyInfo is spelled out field by
// field: as a Swift tuple it loses its tail padding and shifts
// everything after it by 3 or 4 bytes, and the SMC rejects every call.
var key: UInt32 = 0
var vers = (UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt16(0))
var pLimitData = (UInt16(0), UInt16(0), UInt32(0), UInt32(0), UInt32(0))
var keyInfoDataSize: UInt32 = 0
var keyInfoDataType: UInt32 = 0
var keyInfoAttributes: UInt8 = 0
var keyInfoPadding = (UInt8(0), UInt8(0), UInt8(0))
var result: UInt8 = 0
var status: UInt8 = 0
var data8: UInt8 = 0
var data32: UInt32 = 0
var bytes: Bytes = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)keyInfoPadding is the part that matters. Flattening the tuple without it would leave the same 76 bytes, since result would still sit right after keyInfoAttributes. With it, result is at 40, bytes is at 48, and MemoryLayout<KeyData>.size and .stride are both 80. The two-step read (key info with data8 = 9, then the value with data8 = 5) works, and the fan count key FNum comes back as a one-byte ui8 .
vers and pLimitData can stay tuples because neither has tail padding: vers is 6 bytes with 2-byte alignment and pLimitData is 16 with 4-byte alignment. That’s luck of the layout, so I’d still check them the same way.
Checking Against C#
You don’t have to work out offsets by hand. Put the header in a tiny C file and ask clang:
#include <stdio.h>
#include <stddef.h>
#include "smc.h"
int main(void) {
printf("SMCKeyData_t %zu\n", sizeof(SMCKeyData_t));
printf("SMCKeyData_keyInfo_t %zu\n", sizeof(SMCKeyData_keyInfo_t));
printf("offsetof keyInfo %zu\n", offsetof(SMCKeyData_t, keyInfo));
printf("offsetof result %zu\n", offsetof(SMCKeyData_t, result));
printf("offsetof data32 %zu\n", offsetof(SMCKeyData_t, data32));
printf("offsetof bytes %zu\n", offsetof(SMCKeyData_t, bytes));
return 0;
}clang size.c -o size && ./sizeThat prints 80, 12, 28, 40, 44 and 48. On the Swift side, MemoryLayout<KeyData>.offset(of: \.bytes) gives you the same kind of number for any stored property, so you can line the two lists up field by field.
Mirroring Any C Struct In Swift#
The best option is not to mirror it at all. If you can put the C declaration in a header, Swift imports the struct with C’s layout. In an app target that’s the bridging header (SWIFT_OBJC_BRIDGING_HEADER); in a package or a plain swiftc build, a module map works:
module SMCShim {
header "smc.h"
export *
}After import SMCShim, MemoryLayout<SMCKeyData_t>.size is 80 and you get named fields like data.keyInfo.dataSize for free. The trade is that char fields import as Int8 and the 32-byte array imports as a 32-element tuple of Int8.
If you do write the struct in Swift, avoid tuples and nested Swift structs for any C member that has tail padding, spell those fields out, and add the padding bytes yourself. Then make the layout prove itself. A few preconditions at startup or in a unit test turn a silent kIOReturnBadArgument into a crash that names the problem:
precondition(MemoryLayout<KeyData>.stride == 80, "KeyData is \(MemoryLayout<KeyData>.stride) bytes, SMCKeyData_t is 80")
precondition(MemoryLayout<KeyData>.offset(of: \.result) == 40)
precondition(MemoryLayout<KeyData>.offset(of: \.bytes) == 48)Compare stride with sizeof, since stride is the Swift number that includes tail padding. For KeyData the two Swift numbers are both 80, but they often differ: a Swift struct with a UInt32 followed by a UInt8 has a size of 5 and a stride of 8, and C’s sizeof for the same two fields is 8. The offset checks catch the other case, where fields shift around but the total happens to stay the same.