Reading CD subcode data

Thanks to byuu for their article about CDs, and Technology Connections for his videos on digital audio/CDs. And even more videos about CDs.

What's a subcode??

You probably realise CDs have more data on them than just audio if you've ever skipped to a specific track or been lucky enough to find a CD that has the artist name, album title, and track titles baked into it. Question is - where's all that data stored?

It's probably simplest to consider a CD to be a list of sectors, with each sector containing 98 frames, with each frame containing 24 bytes of audio data, 8 bytes of ECC and most importantly, one subchannel byte. The presence and purpose of audio data and ECC is likely self-evident, but this one "subchannel" byte's may not be. Spoiler, that's where most of a CD's non-audio data is stored.

Compared to the audio data, the subchannel is an extremely slow stream of data. When you realise we lose two of these subchannel bytes to special synchronisation values, and that the subchannel byte is essentially treated as 8 streams - one for each bit, labelled as P through W from MSB to LSB - it's clear there's not much capacity here. In some cases (like CD-TEXT, which gives you the artist name, album title, track titles, etc.) multiple subchannel bits from the same byte are used in conjunction with each other, but the P and Q channels are always used alone.

These 96 extra bytes per frame are what we'll try and rip here.

Ripping subcode data

There's probably open-source software to do this. Maybe commercial software. Maybe I could have just right-clicked my CD drive and had Windows detect my goal and present me with a "Dump subcode data" menu item. But that's boring, so let's do it ourselves. Time to fire up Visual Studio and get a console app going.

First we'll need a reference to the CD drive we're using. On my PC, my drive is assigned the drive letter E.

HANDLE cdDrive = CreateFile(

Notice that the path we pass to CreateFile, after stripping away the escaping, is \\.\E:. Not E:\, not E:, but \\.\E:.

Since our intention is to operate at a layer lower than the file system (our goal is to read some decently low-level data here), we need to avoid referencing any kind of construct that only makes in the context of a file system. That trailing slash we left off would signify "the root directory of drive E:" - exactly what we wanted to avoid. Leaving off the trailing slash points us to "the drive E itself" instead of "the root directory of the drive E". The \\.\ we've tacked on to the start signifies that the path is a "DOS device path" which further indicates that we're accessing a device, rather than a file. Simple, right?

Now I'm not one for patience, and ripping a whole CD's worth of subcode data at 1x speed would take exactly as long as the CD itself, if not longer. We'll start our DeviceIoControl journey by telling the CD drive to ramp its speed up so we're not waiting around for the next half an hour.

CDROM_SET_SPEED cdromSetSpeed;
cdromSetSpeed.RequestType = CdromSetSpeed;
cdromSetSpeed.ReadSpeed = 0xFFFF; // max speed
cdromSetSpeed.WriteSpeed = 0xFFFF; // max speed
cdromSetSpeed.RotationControl = CdromDefaultRotation;

    // Data in
    // Data out

There's a bit to unpack here.

DeviceIoControl is the Windows API mechanism for communicating directly with a device driver - the equivalent of Linux's ioctl. Since there's no file we can simply write to that tells the CD drive to change its speed, we've got to use this side-channel mechanism.

First, we set up the data we'll send along with the request. Setting both the read and write speed to 0xFFFF, the maximum value for a USHORT, commands the drive to use the maximum speed it's capable of. We also select the default rotation mode, CLV (constant linear velocity), though this isn't too important for our use.

Setting the RequestType member is quite important - IOCTL_CDROM_SET_SPEED is used for both a CDROM_SET_SPEED request (like we're sending) and a CDROM_SET_STREAMING request. Both these structures begin with a RequestType member, which the driver can use to distinguish the two.

We then hand off this data structure to the driver by passing a pointer/length pair to DeviceIoControl. We're not expecting a response, so we can zero the output arguments.

We can actually check this out from the driver's side, by taking a look at some of the Windows driver samples. In the CD-ROM driver sample, we can see both the callback which handles all IRP_MJ_DEVICE_CONTROL IRPs and the specific handler for an IOCTL_CDROM_SET_SPEED. Side note - I don't recommend writing a Windows driver. Don't do it. It's not worth the suffering.

That's it! CD drive speed set.

The easy way

There's a handy IOCTL for reading raw CD data, as you'd expect. Uncreatively named IOCTL_CDROM_RAW_READ, we can ask the CD-ROM driver to read a number of sectors from a specified address in a specified format. Perfect!

ULONGLONG sector = 0;

RAW_READ_INFO rawReadInfo;
rawReadInfo.DiskOffset.QuadPart = sector * 2048ULL;
rawReadInfo.SectorCount = 1;
rawReadInfo.TrackMode = RawWithSubCode;

DWORD bytesReturned;

    // Data in
    // Data out

Eagle-eyed viewers (who've also been reading all the docs I've linked along the way 😋) may have noticed that the 2048ULL constant we use to turn our sector into an offset on the disk doesn't match the value of the CD_RAW_SECTOR_WITH_SUBCODE_SIZE constant (= 2352 + 96 = 2448).

The CD-ROM driver tries to play it smart, and causes us a headache in the process.

On a data CD (remember, we're looking at Red Book Audio CDs here) each sector contains 2,048 bytes of data after the extra error correction is added on. Audio CDs contain 2,352 bytes of audio data, and we're requesting an extra 96 bytes of subcode data along side that. In an effort to make reading data CDs easier, the raw read IOCTL takes a disk offset in bytes rather than a LBA (logical block address) which would count from 0 for the first sector, incrementing by 1 for each subsequent sector. Since the CD drive has no concept of anything other than LBAs, the driver has to undo this multiplication by 2048 before it can pass it to the drive. Seriously. They also don't appear to check that the bits they throw away are zero, fun.

From the CD-ROM driver sample, comments mine:

// In ioctl.c:RequestValidateReadWrite, one of many functions called before the
// IOCTL is handled
if (!DeviceExtension->DiskGeometry.BytesPerSector) 
    DeviceExtension->DiskGeometry.BytesPerSector = 2048;

if (!DeviceExtension->SectorShift) 
    DeviceExtension->SectorShift = 11; // 2 to the power of 11 == 2048

// Later on, inside ioctl.c:DeviceHandleRawRead
// Divide by 2048 with a shift
startingSector = (ULONG)(rawReadInfo.DiskOffset.QuadPart >> DeviceExtension->SectorShift);
// [...]
cdb->READ_CD.StartingLBA[3]  = (UCHAR) (startingSector & 0xFF);
cdb->READ_CD.StartingLBA[2]  = (UCHAR) ((startingSector >>  8));
cdb->READ_CD.StartingLBA[1]  = (UCHAR) ((startingSector >> 16));
cdb->READ_CD.StartingLBA[0]  = (UCHAR) ((startingSector >> 24));

With that aside, we set up the read, ask for the raw sector data including the subcode (there's no track mode for just subcode data 😢) and hand our request off to the driver. The driver does whatever it needs to do to perform that request, and hands the data back to us in our data buffer, helpfully telling us how many bytes it read through the bytesReturned value.

Let's take a peek at the data it returned.

00000000: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000010: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000020: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000030: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000040: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000050: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000060: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000070: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000080: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000090: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000a0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000b0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000c0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000d0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000e0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000f0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000100: 0000 0000 0000 0000 0000 0000 0000 0000  ................
            *** heaps more zeros ***
00000900: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000910: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000920: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000930: 8080 8080 8080 80c0 8080 8080 8080 80c0  ................
00000940: 8080 8080 8080 80c0 8080 8080 8080 8080  ................
00000950: 8080 8080 8080 8080 8080 8080 8080 8080  ................
00000960: 8080 8080 8080 8080 8080 8080 8080 8080  ................
00000970: 8080 8080 8080 c080 8080 8080 8080 8080  ................
00000980: 80c0 80c0 c080 c080 8080 c080 c080 8080  ................

Bumping sector up to 10 000 and we start to see some more data.

00000000: 0df9 21f7 f9f6 9cf8 34f5 8afa a8f3 adfc  ..!.....4.......
00000010: 58f2 effe 75f1 6701 13f1 1804 49f1 fe06  X...u.g.....I...
00000020: f4f1 0a0a ddf2 e60c a6f3 1b0f 3af4 fc0f  ............:...
00000030: 88f4 610f 93f4 6c0d 5ff4 580a 0cf4 b006  ..a...l._.X.....
00000040: a8f3 dc02 29f3 cdfe 87f2 b1fa c5f1 8af6  ....)...........
00000050: cef0 26f2 e8ef afed 4cef 5ae9 35ef 3fe5  ..&.....L.Z.5.?.
00000060: bdef a2e1 44f1 c2de e1f3 1edd a3f7 f9dc  ....D...........
00000070: 4efc 64de 7e01 21e1 8f06 c8e4 de0a 9fe8  N.d.~.!.........
00000080: 630d c0eb ff0d c8ed 320c 68ee dd08 22ee  c.......2.h...".
00000090: 8c04 98ed 1100 53ed defb 96ed 5bf8 7aee  ......S.....[.z.
000000a0: 8af5 8fef b2f3 bcf0 7af2 52f1 fff1 25f1  ........z.R...%.
000000b0: daf1 e3ef 4cf2 f8ed 39f3 d5eb 71f4 d4e9  ....L...9...q...
000000c0: f6f5 8ee8 aff7 64e8 52f9 6be9 a3fa cceb  ......d.R.k.....
000000d0: 5dfb f9ee 56fb 9ef2 6ffa 55f6 b5f8 8bf9  ]...V...o.U.....
000000e0: 30f6 8dfb 34f3 05fc 74f0 fefa 5aee dcf8  0...4...t...Z...
000000f0: 28ed 2cf6 36ed bff3 c2ee 85f2 8cf1 c7f2  (.,.6...........
00000100: 6bf5 8af4 fbf9 b2f7 9afe cdfb 6802 2600  k...........h.&.
            *** heaps more data ***
00000900: 1e0a a019 c70d 3b1d 8d0f 4b1f 150f 1a1f  ......;...K.....
00000910: f30b db1b 7906 6015 2eff 0d0c 05f7 c100  ....y.`.........
00000920: bcee a9f4 12e7 e5e8 a9e0 9cde eddb cad6  ................
00000930: 0000 0000 0000 0040 0000 0000 0000 0040  .......@.......@
00000940: 0000 0000 0000 0040 0000 0000 0000 4000  .......@......@.
00000950: 0000 0040 0000 4040 0000 4000 0040 0040  ...@..@@..@..@.@
00000960: 0000 0000 0000 0000 0000 0000 0000 4000  ..............@.
00000970: 0000 0040 0040 0040 0000 4000 0040 0040  ...@.@.@..@..@.@
00000980: 0040 0000 0040 0040 0000 0000 4040 4040  .@...@.@....@@@@

Since we requested RawWithSubCode, we see the CD digital audio data first, followed by our subcode data in the last 96 bytes (from 0x930 to 0x98F). Success!

The harder way

I originally abandoned the "easy" way because I thought this would give me access to the subcode data within the lead-in and lead-out. Turns out that's a "maybe", and things didn't really work out that way.

We're already communicating directly with the driver, but the driver itself has to communicate with the drive somehow. After a couple layers of encapsulation (ATAPI, SATA) the driver is sending SCSI commands, specifically those from SCSI MMC-3 "Multimedia Commands".

A screenshot from the SCSI MMC-3 standard showing the READ CD command structure

The raw SCSI READ CD command also gives us finer grain control of the data we receive back from the drive. If we don't set the "User Data" bit, we won't get any of the CDDA data - just the subcode data we originally wanted.

More DeviceIoControl magic now - with IOCTL_SCSI_PASS_THROUGH_DIRECT we'll send a raw READ CD command to the drive.

// Earlier in our code...
struct read_cd {
    uint8_t dataBuffer[96];
    uint8_t senseBuffer[18];

// Send the actual command
ULONGLONG sector = 0;

struct read_cd readCd;
readCd.scsiPassThrough.Length = sizeof(readCd.scsiPassThrough);
readCd.scsiPassThrough.ScsiStatus = 0; // Returned after command performed
// PathId, TargetId, Lun filled by port driver
readCd.scsiPassThrough.CdbLength = 11;
readCd.scsiPassThrough.SenseInfoLength = 18;
readCd.scsiPassThrough.DataIn = SCSI_IOCTL_DATA_IN;
readCd.scsiPassThrough.DataTransferLength = 96;
readCd.scsiPassThrough.TimeOutValue = 10;
readCd.scsiPassThrough.DataBuffer = readCd.dataBuffer;
readCd.scsiPassThrough.SenseInfoOffset = ((uint8_t *) &readCd.senseBuffer) - ((uint8_t *) &readCd);

// Operation code (READ CD)
readCd.scsiPassThrough.Cdb[0] = 0xBE;
// Expected sector type (any type)
readCd.scsiPassThrough.Cdb[1] = 0b000 << 2;
// Starting LBA (MSB to LSB)
readCd.scsiPassThrough.Cdb[2] = (sector >> 24) & 0xFF;
readCd.scsiPassThrough.Cdb[3] = (sector >> 16) & 0xFF;
readCd.scsiPassThrough.Cdb[4] = (sector >> 8) & 0xFF;
readCd.scsiPassThrough.Cdb[5] = sector & 0xFF;
// Transfer length in blocks (MSB to LSB)
readCd.scsiPassThrough.Cdb[6] = 0;
readCd.scsiPassThrough.Cdb[7] = 0;
readCd.scsiPassThrough.Cdb[8] = 1;
// Flag bits:
//     No sync field
//     No header codes
//     No user data
//     No EDC/ECC
//     No error data
readCd.scsiPassThrough.Cdb[9] = (0 << 7) | (0b00 << 5) | (0 << 4) | (0 << 3) | (0b00 << 1);
// Sub-channel selection (raw)
readCd.scsiPassThrough.Cdb[10] = 0b001;
readCd.scsiPassThrough.Cdb[11] = 0;

DWORD bytesReturned;

    // Data in
    // Data out

Uhh, what's with struct read_cd? Doesn't the IOCTL just take a SCSI_PASS_THROUGH_DIRECT. Well, yeah! That's why it's the first member of our struct. But you might have noticed the SenseInfoOffset member inside the SCSI pass through structure. That member provides the driver an offset from the start of the SCSI_PASS_THROUGH_DIRECT structure where a buffer for sense information is stored (consider it to be extended SCSI error information). Since it's unsigned, the sense buffer must be after the SCSI pass through data in memory. Easiest way to ensure that is to put them both in the same structure, and pass them as one.

The same logic applies for the data buffer, but only for SCSI_PASS_THROUGH. If we used that IOCTL instead, we'd also have to specify the data buffer as an offset.

Now, for the original reason I wanted to issue a direct SCSI command, rather than going through the driver: this paragraph.

A screenshot from the SCSI MMC-3 standard, which reads "When the Starting Logical Block Address is set to F000 0000h and P-W raw data is selected, the Logical Unit returns P-W raw data from the Lead-in area"

But my hope was quickly dashed.

A command line window showing a SCSI error. The corresponding error is highlighted in the MMC-3 spec - "Logical Block Address Out Of Range"

Apparently this isn't a widely supported feature. Oh well. We're not dragging along ~2 KiB of user data like we were with the raw read IOCTL, so this is probably faster anyway.


There's not much to talk about once we've got the subcode data. Since we couldn't rip the lead-in, we can't access most CD-TEXT data (in raw form). I don't own any CD+Gs which store their graphics data in the R to to W subchannels in the program area, so we're stuck. And honestly? This post is long enough as is.

The Rust code I wrote to read the data we can from the Q subchannel (timecode, ISRC and catalogue number/barcode) can be found on GitHub. And you've basically read all the C code to rip the subchannel data here... just add some error checking and you're golden 😋

You might also be interested in:

See all posts