◀ Back to NotesPublished on February 28, 2023

Decoding monitor EDID on macOS

Using Lunar's CLI

If you are already using Lunar, I added a simple command-line action in v6 that can do that on both Apple Silicon and Intel.

# Install CLI if not already installed
/Applications/Lunar.app/Contents/MacOS/Lunar install-cli

# Read and decode EDID from all monitors
lunar edid

# Read and decode EDID from a specific monitor
lunar edid dell

You'll get a nice human readable structure like this:

LG Ultra HD [ID 5]
    edid-decode (hex):

    00 ff ff ff ff ff ff 00 1e 6d 09 5b b9 ca 04 00
    08 1b 01 04 b5 3c 22 78 9e 30 35 a7 55 4e a3 26
    0f 50 54 21 08 00 71 40 81 80 81 c0 a9 c0 d1 c0
    81 00 01 01 01 01 4d d0 00 a0 f0 70 3e 80 30 20
    65 0c 58 54 21 00 00 1a 28 68 00 a0 f0 70 3e 80
    08 90 65 0c 58 54 21 00 00 1a 00 00 00 fd 00 38
    3d 1e 87 38 00 0a 20 20 20 20 20 20 00 00 00 fc
    00 4c 47 20 55 6c 74 72 61 20 48 44 0a 20 01 11

    02 03 11 71 44 90 04 03 01 23 09 07 07 83 01 00
    00 02 3a 80 18 71 38 2d 40 58 2c 45 00 58 54 21
    00 00 1e 56 5e 00 a0 a0 a0 29 50 30 20 35 00 58
    54 21 00 00 1a 00 00 00 00 00 00 00 00 00 00 00
    00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
    00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
    00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
    00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 c8

    ----------------

    Block 0, Base EDID:
     EDID Structure Version & Revision: 1.4
     Vendor & Product Identification:
       Manufacturer: GSM
       Model: 23305
       Serial Number: 314041
       Made in: week 8 of 2017
     Basic Display Parameters & Features:
       Digital display
       Bits per primary color channel: 10
       DisplayPort interface
       Maximum image size: 60 cm x 34 cm
       Gamma: 2.20
       DPMS levels: Standby
       Supported color formats: RGB 4:4:4, YCrCb 4:4:4, YCrCb 4:2:2
       Default (sRGB) color space is primary color space
       First detailed timing includes the native pixel format and preferred refresh rate
     Color Characteristics:
       Red  : 0.6523, 0.3349
       Green: 0.3046, 0.6367
       Blue : 0.1484, 0.0615
       White: 0.3134, 0.3291
     Established Timings I & II:
       DMT 0x04:   640x480    59.940476 Hz   4:3     31.469 kHz     25.175000 MHz
       DMT 0x09:   800x600    60.316541 Hz   4:3     37.879 kHz     40.000000 MHz
       DMT 0x10:  1024x768    60.003840 Hz   4:3     48.363 kHz     65.000000 MHz
     Standard Timings:
       GTF     :  1152x864    60.000000 Hz   4:3     53.700 kHz     81.624000 MHz
       DMT 0x23:  1280x1024   60.019740 Hz   5:4     63.981 kHz    108.000000 MHz
       DMT 0x55:  1280x720    60.000000 Hz  16:9     45.000 kHz     74.250000 MHz
       DMT 0x53:  1600x900    60.000000 Hz  16:9     60.000 kHz    108.000000 MHz (RB)
       DMT 0x52:  1920x1080   60.000000 Hz  16:9     67.500 kHz    148.500000 MHz
       DMT 0x1c:  1280x800    59.810326 Hz  16:10    49.702 kHz     83.500000 MHz
     Detailed Timing Descriptors:
       DTD 1:  3840x2160   59.996625 Hz  16:9    133.312 kHz    533.250000 MHz (600 mm x 340 mm)
                    Hfront   48 Hsync  32 Hback   80 Hpol P
                    Vfront   54 Vsync   5 Vback    3 Vpol N
       DTD 2:  3840x2160   30.000000 Hz  16:9     66.660 kHz    266.640000 MHz (600 mm x 340 mm)
                    Hfront    8 Hsync 144 Hback    8 Hpol P
                    Vfront   54 Vsync   5 Vback    3 Vpol N
       Display Range Limits:
         Monitor ranges (GTF): 56-61 Hz V, 30-135 kHz H, max dotclock 560 MHz
       Display Product Name: 'LG Ultra HD'
     Extension blocks: 1
    Checksum: 0x11

    ----------------

    Block 1, CTA-861 Extension Block:
     Revision: 3
     Basic audio support
     Supports YCbCr 4:4:4
     Supports YCbCr 4:2:2
     Native detailed modes: 1
     Video Data Block:
       VIC  16:  1920x1080   60.000000 Hz  16:9     67.500 kHz    148.500000 MHz (native)
       VIC   4:  1280x720    60.000000 Hz  16:9     45.000 kHz     74.250000 MHz
       VIC   3:   720x480    59.940060 Hz  16:9     31.469 kHz     27.000000 MHz
       VIC   1:   640x480    59.940476 Hz   4:3     31.469 kHz     25.175000 MHz
     Audio Data Block:
       Linear PCM:
         Max channels: 2
         Supported sample rates (kHz): 48 44.1 32
         Supported sample sizes (bits): 24 20 16
     Speaker Allocation Data Block:
       FL/FR - Front Left/Right
     Detailed Timing Descriptors:
       DTD 3:  1920x1080   60.000000 Hz  16:9     67.500 kHz    148.500000 MHz (600 mm x 340 mm)
                    Hfront   88 Hsync  44 Hback  148 Hpol P
                    Vfront    4 Vsync   5 Vback   36 Vpol P
       DTD 4:  2560x1440   59.950550 Hz  16:9     88.787 kHz    241.500000 MHz (600 mm x 340 mm)
                    Hfront   48 Hsync  32 Hback   80 Hpol P
                    Vfront    3 Vsync   5 Vback   33 Vpol N
    Checksum: 0xc8  Unused space in Extension Block: 74 bytes

I only coded the logic for reading the EDID using DDC. The actual decoding part is done using an embedded universal binary of edid-decode.

# Coding it from scratch

Here I'll lay out how EDID data can be read from a monitor using DDC. We'll continue to use edid-decode for decoding it.

If you want to skip compiling edid-decode, you can find it for download here: github.com/alin23/Lunar - bin/edid-decode

# Apple Silicon

Communicating over DDC is different for each Mac architecture.

On Apple Silicon, macOS provides us with a handy private function inside IOKit:

IOReturn IOAVServiceCopyEDID(IOAVServiceRef service, CFDataRef* edidData)

For each connected monitor, there will be a DCPAVServiceProxy present in the hardware tree. From that proxy we can construct an IOAVService instance and call IOAVServiceCopyEDID on.

DCP stands for Display CoProcessor, a separate chip with its own firmware, that handles display communication without loading the CPU.

Read more in Asahi Linux Progress Report: August 2021 - Reverse Engineering DCP

We'll do the following steps:

  • iterate on each DCPAVServiceProxy
  • check its Location property to see if it's External
    • This skips the built-in screen of the MacBook or iMac
  • create an IOAVService from the proxy
  • call IOAVServiceCopyEDID on the IOAVService

edid.m

@import Foundation;
@import IOKit;

typedef CFTypeRef IOAVServiceRef;
extern IOAVServiceRef IOAVServiceCreateWithService(CFAllocatorRef allocator, io_service_t service);
extern IOReturn IOAVServiceCopyEDID(IOAVServiceRef service, CFDataRef* edidData);

int main(int argc, char** argv) {
    io_iterator_t iterator;
    kern_return_t result = IOServiceGetMatchingServices(kIOMasterPortDefault, IOServiceMatching("DCPAVServiceProxy"), &iterator);
    if (result != KERN_SUCCESS) return 1;

    io_service_t service;
    while ((service = IOIteratorNext(iterator)) != IO_OBJECT_NULL) {
        CFStringRef location = IORegistryEntrySearchCFProperty(service, kIOServicePlane, CFSTR("Location"), kCFAllocatorDefault, kIORegistryIterateRecursively);
        if (location == NULL || CFStringCompare(CFSTR("External"), location, 0) != 0) continue;

        IOAVServiceRef avService = IOAVServiceCreateWithService(kCFAllocatorDefault, service);
        if (avService == NULL) continue;

        CFDataRef edidData = NULL;
        IOReturn edidResult = IOAVServiceCopyEDID(avService, &edidData);
        if (edidResult != kIOReturnSuccess || edidData == NULL) continue;

        NSData* nsEdidData = [NSData dataWithBytes:CFDataGetBytePtr(edidData) length:CFDataGetLength(edidData)];
        UInt8 *bytes = nsEdidData.bytes;

        for (int i = 0; i < nsEdidData.length; i++)
            printf("%02X ", bytes[i]);
        printf("\n");
    }
    return 0;
}

We can then compile and run it using the following commands:

clang -fmodules -o edid edid.m

./edid | while read line; do
    echo $line | edid-decode
done

# Intel

On Intel Macs, we don't have the convenience of IOAVServiceCopyEDID so we need to craft the I²C request ourselves and send it to the monitor.

Based on the DDC/CI specifications, the request will need to be sent to address 0x50.

But because IOKit needs the least-significant byte (LSB) for other things, we need to use the 0xA0 address which is 0x50 << 1.

We'll do the following steps:

  • iterate over NSScreen.screens
  • get the internal macOS display ID
  • get the monitor framebuffer using CGSServiceForDisplayNumber
  • send an IOI2CRequest to the first interface that responds to IOI2CInterfaceOpen

edid.m

@import Cocoa;
@import IOKit.i2c;

extern void CGSServiceForDisplayNumber(CGDirectDisplayID display, io_service_t* service);

bool FramebufferI2CRequest(io_service_t framebuffer, IOI2CRequest* request) {
    IOItemCount busCount;
    if (IOFBGetI2CInterfaceCount(framebuffer, &busCount) != KERN_SUCCESS) return false;

    IOOptionBits bus = 0;
    while (bus < busCount) {
        io_service_t interface;
        if (IOFBCopyI2CInterfaceForBus(framebuffer, bus++, &interface) != KERN_SUCCESS) continue;

        IOI2CConnectRef connect;
        if (IOI2CInterfaceOpen(interface, kNilOptions, &connect) != KERN_SUCCESS) continue;

        if (IOI2CSendRequest(connect, kNilOptions, request) == KERN_SUCCESS) {
            IOI2CInterfaceClose(connect, kNilOptions);
            return true;
        }
    }
    return false;
}

int main(int argc, char** argv) {
    for (NSScreen* screen in NSScreen.screens) {
        CGDirectDisplayID displayID = [[screen.deviceDescription objectForKey:@"NSScreenNumber"] unsignedIntValue];
        if (CGDisplayIsBuiltin(displayID)) continue;

        io_service_t framebuffer = 0;
        CGSServiceForDisplayNumber(displayID, &framebuffer);
        if (framebuffer == 0) return 1;

        IOI2CRequest request = {};
        UInt8 data[256] = {};

        request.sendAddress = 0xA0;
        request.sendTransactionType = kIOI2CSimpleTransactionType;
        request.sendBuffer = (vm_address_t)data;
        request.sendBytes = 0x01;

        data[0] = 0x00;
        request.replyAddress = 0xA1;
        request.replyTransactionType = kIOI2CSimpleTransactionType;
        request.replyBuffer = (vm_address_t)data;
        request.replyBytes = sizeof(data);

        if (!FramebufferI2CRequest(framebuffer, &request)) return 1;

        for (int i = 0; i < 256; ++i) {
            printf("%02X ", data[i]);
        }
        printf("\n");
        return 0;
    }
}

Again, we can read and decode the EDID using the following command:

clang -fmodules -o edid edid.m

./edid | while read line; do
    echo $line | edid-decode
done

# Writing EDID to monitors

Not exactly the topic of this note, but wanted to write down my findings for posterity.

Reading the EDID binary from a monitor, editing it and then writing it back to the monitor memory is something that one would rarely need to do.

But there is this annoying practice of monitor vendors that annoys the hell out of a large number of people and it's only fixable by editing EDIDs: they write the same serial number for all their monitors in a batch.

This leads to monitors getting swapped around in a multi monitor setup, and people having to re-arrange them on every reconnection. That's because from the OS side, all monitors look identical and there's no way to know which was left and which was right after they reconnect.

Technically, simply changing one byte of the EDID serial number should fix this. This is how I would do it:

  • read the EDID from the monitor
  • assign a random byte to EDID[12] (serial number is stored as a 32bit number on bytes 12-16)
  • write the EDID back to monitor memory through I²C

And here's the code that should do that:

@import Foundation;
@import IOKit;

typedef CFTypeRef IOAVServiceRef;
extern IOAVServiceRef IOAVServiceCreateWithService(CFAllocatorRef allocator, io_service_t service);
extern IOReturn IOAVServiceCopyEDID(IOAVServiceRef service, CFDataRef* edidData);
extern IOReturn IOAVServiceWriteI2C(IOAVServiceRef service, uint32_t chipAddress, uint32_t dataAddress, void* inputBuffer, uint32_t inputBufferSize);

void edid_decode(NSData *nsEdidData) {
    NSTask* task = [[NSTask alloc] init];
    [task setLaunchPath:@"/usr/local/bin/edid-decode"];
    [task setArguments:@[@"-"]];

    NSPipe* inputPipe = [NSPipe pipe];
    [task setStandardInput:inputPipe];

    [task launch];

    [[inputPipe fileHandleForWriting] writeData:nsEdidData];
    [[inputPipe fileHandleForWriting] closeFile];

    [task waitUntilExit];
}

int main(int argc, char** argv) {
    io_iterator_t iterator;
    kern_return_t result = IOServiceGetMatchingServices(kIOMasterPortDefault, IOServiceMatching("DCPAVServiceProxy"), &iterator);
    if (result != KERN_SUCCESS) return 1;

    io_service_t service;
    while ((service = IOIteratorNext(iterator)) != IO_OBJECT_NULL) {
        CFStringRef location = IORegistryEntrySearchCFProperty(service, kIOServicePlane, CFSTR("Location"), kCFAllocatorDefault, kIORegistryIterateRecursively);
        if (location == NULL || CFStringCompare(CFSTR("External"), location, 0) != 0) continue;

        IOAVServiceRef avService = IOAVServiceCreateWithService(kCFAllocatorDefault, service);
        if (avService == NULL) continue;

        CFDataRef edidData = NULL;
        IOReturn edidResult = IOAVServiceCopyEDID(avService, &edidData);
        if (edidResult != kIOReturnSuccess || edidData == NULL) continue;

        NSMutableData* nsEdidData = [NSMutableData dataWithBytes:CFDataGetBytePtr(edidData) length:CFDataGetLength(edidData)];
        UInt8 *bytes = nsEdidData.mutableBytes;

        printf("Old EDID:\n");
        edid_decode(nsEdidData);

        bytes[12] = arc4random_uniform(256); // Set first serial number byte to a random value
        UInt8 checksum = 0;
        for (int i = 0; i < 127; i++) checksum += bytes[i];
        bytes[127] = 256 - checksum; // Recompute checksum

        printf("\n\nNew EDID:\n");
        edid_decode(nsEdidData);

        char input[10];
        printf("\n\nDo you want to write the new EDID to the monitor? (y/n): ");
        fgets(input, 10, stdin);

        if (strcmp(input, "y\n") != 0) continue;

        printf("\n\nWriting new EDID: ");
        IOReturn err = IOAVServiceWriteI2C(avService, 0x50, 0, bytes, nsEdidData.length);
        if (err) {
            printf("error %s\n", mach_error_string(err));
            return 1;
        }
        printf("OK\n");
    }
    return 0;
}

Provided you have the edid-decode binary in /usr/local/bin/, the code can be run with:

clang -fmodules -o edid-rewrite edid-rewrite.m && ./edid-rewrite

# Why it's not a good solution

The problem is that most monitors have write-protected EEPROMs and the write operation will just fail or discard the data.

I tested this on my LG 27UD88-W 4K monitor, and on an older 55" LG TV and neither accepted the EDID rewrite.

Some monitors might have a service menu which has an option for enabling EDID writes, some might only accept the write on a specific port like HDMI1, but most will just ignore the operation.