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'sExternal
- This skips the built-in screen of the MacBook or iMac
- create an
IOAVService
from the proxy - call
IOAVServiceCopyEDID
on theIOAVService
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 toIOI2CInterfaceOpen
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.