23-Aug-1998

Sector level disk drive interfacing for IBM PCs and Compatibles in C

Part I

 
Contents 
 
0.0 Introduction 
1.0 Basic Data types 
2.0 Drive Dimensions 
    2.1 Physical Drive Values 
    2.2 Logical Drive Values 
3.0 DOS Data Areas 
4.0 Getting Drive Dimensions 
5.0 Setting the DOS Settings 
6.0 The Read Function 
    6.1 DOS INT 25h 
    6.2 BIOS INT 13h 
    6.3 IOCTL INT 21h, 440Dh/61h 
7.0 If Get Device Parameters Fails 
8.0 Putting It All Together 
    8.1 DOSREAD 
    8.2 BIOSREAD 
    8.3 IOCTLREAD 
9.0 Summary 
    9.1 Drive Parameter Functions 
    9.2 Sector Read Functions by Drive Type 
    9.3 Sector Read Functions by OS 
References 
Links 

PART II 

DISKED - The DISK EDitor 
DISKBUG - The Disk Debugger 
DISKIO code archive 
DISK I/O Library - all this and more 
D_DISK - Posix-like DISKIO Library 
DOS C function summary 
Reading the MBR under Windows NT 
Text version: DISKIO.TXT 
 

 
 
 
 
 
 


0.0    Introduction

This document outlines how to read disk drives at the sector level using C. The online code is compatible with the 16-bit versions of Microsoft and Watcom compilers. The code archive is also compatible with DJGPP and WIN32. A more complete library is DISKLIB which works with Windows.

For an alternative version of sector level I/O get my D_DISK Library, a MUCH improved version of DISK I/O.

(I've created a summary of DOS specific C library functions listing the differences between three major C Compilers -- Borland, Microsoft and Watcom; DJGPP will be added later.)

This document currently only shows how to implement the very basics. Over the next few chapters I will be presenting a comprehensive code base for actually doing useful things. I will also explain DOS drive structures and data formats and other technical details.
 

DISCLAIMER
I HAVE TRIED VERY HARD TO KEEP THESE DOCUMENTS CORRECT BUT 
I CANNOT GIVE ANY GARAUNTEES THAT THERE ARE NOT ANY ERRORS
 
All the code presented here is working, bug free code (well, if any bugs exist they will be due to typos and other stupid mistakes). All the code presented here is also archived for downloading. The code examples are archived if you want to jump right in. But here there are annotations as to why I chose to do things a particular way. DJGPP and WIN32 users will have to get the examples archive.
 
Before a drive sector can be read the sector size must be known. Sector size is usually 512 bytes but not guaranteed. To find the sector size it's easiest to just ask DOS.

After the sector size is known which interrupt to use to read the drive must be determined. Not all drives are supported by both BIOS and DOS calls. Only standard floppies and hard drives are supported by the BIOS.

But once the basic dimensions are known, and the interrupt chosen to read the drive, just being able to arbitrarily read the drive is not much use unless you can match sectors to data. To access data the dimensions must be determined and the incremental algorithm must be performed when reads extend across the boundaries.
 

1.0    Basic Data types

The code presented here was developed with 16-bit DOS compilers. The following typedefs are used to help moving the code base to 32-bit compilers and for other operating systems:

typedef unsigned char BYTE;       
typedef unsigned short UINT16;
typedef unsigned long UINT32;
 

Coding Note Data sizes are often very important. Data structure packing is always extremely important and must be packed on byte boundaries for DOS.
 

2.0    Drive Dimensions

All drives have physical dimensions, made up of a number of two sided platters, each platter is divided into tracks, and each track divided into sectors. Each platter is referenced by head, track and sector.

DOS treats a drive as only having a continuous number of sectors; a small percentage of the total number of sectors for it's system data and the rest of the sectors for file data. The file data sectors are sub-grouped into clusters.

The drive dimensions are used to know where to read (or write) and to perform incremental movement. System or file data can run over boundaries; any data block can start on the last sector of a track and extend to the first sector of the next track.

2.1    Physical Drive Values

There are only a few basic physical values that define a drive: number of heads, number of tracks per head, number of sectors per track, and number of bytes per sector.

To access a sector you just supply the head, track and sector and a buffer of the appropriate size:

UINT16 cur_head;        /* current head */
UINT16 cur_track;       /* current track */
UINT16 cur_sector;      /* current sector */
BYTE *sec_buf;          /* sector buffer */

For the drive physical dimensions you need:

UINT16 max_head;        /* number of heads */
UINT16 max_track;       /* number of tracks per head */
UINT16 max_sector;      /* number of sectors per track */
UINT16 sec_size;        /* bytes per sector */

This physical access to a drive is done through the BIOS INT 13h.

2.2    Logical Drive Values

DOS removes all of the physical characteristics from a drive by pretending that the drive is just a sequence of logical sectors. There are even less values needed.

UINT32 log_sector;      /* current logical sector */
BYTE *sec_buf;          /* sector buffer */

For the drive logical dimensions all you need are:

UINT32 num_sectors;     /* total number of sectors */
UINT16 sec_size;        /* bytes per sector */

This logical access to a drive is done through the DOS INTs 25h and 26h.

But because DOS originally chose UINT16 for the data type of the number of sectors, which is too small for the total number of sectors for drives greater than 32MB, you also need to know the drive size to handle the two different ways DOS reads drive sectors.

UINT32 drive_size;      /* drive size in bytes */

This limits the drive size to 4GB.
 

3.0    DOS Data Areas

At the lowest level DOS divvies up the drive into system and file data areas. The values needed consist of the following:

UINT32 hidden_secs;     /* number of hidden sectors on */
                        /* harddrives - Partition info, first track */
UINT16 reserved_secs;   /* number of reserved sectors */
                        /*  Boot sector(s) */
UINT16 secs_fat;        /* sectors per FAT */
UINT16 num_fats;        /* number of FATs */
UINT16 dir_sector;      /* start of root directory */
UINT16 data_sector;     /* first data sector */
UINT16 secs_cluster;    /* sectors per cluster */
UINT16 dir_sectors;     /* number of sectors of root */
UINT16 dir_entries;     /* number of directory entries */
UINT16 cluster_size;    /* cluster size in bytes */
UINT16 num_clusters;    /* maximum cluster no. */

These values must always be calculated. Floppies don't have hidden_secs; reserved_secs is usually one for the boot sector but can be more.

To put this visually:
 

hidden reserved (boot) FAT FAT copy root directory data
 

4.0    Getting Drive Dimensions

Not all drives are the same. Not all DOS versions are the same.

Before a drive sector can be read the sector size needs to be known. Assuming a sector size of 512 bytes will cover most drives, but not all. The type of interrupt used to read a drive sectors depends on the drive type; only standard floppies and hard drives can be read by the BIOS. The sector size and the drive type is determined at the same time.

The DOS function 440dh, code 60h, (get device parameters) gives almost everything needed to start reading a drive. The same information is contained in a drive's boot sector. But the sector size is needed to read the boot sector.

(Note: The get device parameters function is not always going to be supported depending on the drive type and DOS version. The DOS function 36h (get disk free space) can be used if get device parameters is not supported, and then the boot sector is read.)

 The DOS function 440dh, code 60h uses a DEVICEPARAMS structure:

int getdevparams(int drive, struct DEVICEPARAMS *dp)
{
   union REGS regs;
   struct SREGS sregs;
   regs.x.bx = drive;            /* 0 = default, 1 = A:, 2 = B:, etc. */
   regs.x.ax = 0x440D;           /* function */
   regs.x.cx = 0x0860;           /* sub-function */
   regs.x.dx = FP_OFF(dp);
   sregs.ds = FP_SEG(dp);
   intdosx(&regs,&regs,&sregs);
   if (regs.x.cflag)
      return regs.x.ax;
   return 0;
}

[example program]
 

Coding Note For 16-bit compilers the Large Memory model should be used; if not, __far pointers should be used or the FP_OFF/FP_SEG macros should be removed and the data offset set as: 

   regs.x.dx = (unsigned)dp; 
 
and a call to segread() should be used to set sregs.ds.

 

5.0    Setting the DOS Settings

The DEVICEPARAMS does not give everything, only the basics. But once the basic dimensions are known everything else that is needed can be easily calculated:

   sec_size = dp.sec_size;
   if (dp.num_sectors != 0)
   {
      drive_size = (UINT32)dp.num_sectors * dp.sec_size;
      num_sectors = dp.num_sectors;
   }
   else
   {
      drive_size = dp.huge_sectors * dp.sec_size;
      num_sectors = dp.huge_sectors;
   }
   hidden_secs = dp.hidden_sectors;
   secs_cluster = dp.secs_cluster;
   reserved_secs = dp.reserved_secs;
   num_fats = dp.num_fats;
   dir_entries = dp.dir_entries;
   secs_fat = dp.secs_fat;
   max_sector = dp.secs_track;
   max_head = dp.num_heads;
   dir_sectors = dir_entries / (sec_size / 32);
   data_sector = (secs_fat * num_fats) + dir_sectors + reserved_secs-1;
   dir_sector = (secs_fat * num_fats) + reserved_secs;
   cluster_size = sec_size * secs_cluster;
   num_clusters = (UINT16)
      ((num_sectors - (secs_fat * num_fats) - dir_sectors-1) / secs_cluster)+1;
   max_track = (UINT16)
      (((num_sectors + hidden_secs) / max_head) / max_sector)-1;
 
 [example program]
 
 

DOS Note If dp.num_sectors (a UINT16) is zero dp.huge_sectors (a UINT32) is used to hold the actual number of sectors. This is how DOS handled larger disk drives.
 

6.0    The Read Function

6.1    DOS INT 25h

This interrupt is easy to use, and even though stated as obsolete it is still supported. And it fits right in because DOS treats a drive a just a number of sectors. There are two ways of implementing it. For drives less than or equal to 32MB:

(Note: More than one sector can be read at a time, depending on what is to be done, as long as the buffer size is the appropriate size.)

int dosread(int drive, UINT32 sector, BYTE *buffer)
{
union REGS regs;
struct SREGS sregs;

   regs.x.ax = drive;
   regs.x.dx = sector;
   regs.x.cx = 1;
   regs.x.bx = FP_OFF(buffer);
   sregs.ds = FP_SEG(buffer);
   int86x(0x25,&regs,&regs,&sregs);
   if (regs.x.cflag)
      return regs.h.al;
   return 0;
}

For drives greater than 32MB, a Disk Control Block structure must be used:

int dosread(int drive, UINT32 sector, BYTE *buffer)
{
union REGS regs;
struct SREGS sregs;
struct DCB Dcb;
struct DCB *dcb = &Dcb;

   regs.x.ax = drive;
   dcb->sector = sector;
   dcb->number = 1;
   dcb->buffer = buffer;
   regs.x.cx = 0xffff;
   regs.x.bx = FP_OFF(dcb);
   sregs.ds = FP_SEG(dcb);
   int86x(0x25,&regs,&regs,&sregs);
   if (regs.x.cflag)
      return regs.h.al;
   return 0;
}

To write a sector just use 0x26 instead of 0x25. However, the write interrupt is not supported by Windows.

(Notes: The int86x() function does handle the popping of the flags register which DOS leaves on the stack with this function.)
 

Coding Note  The macros FP_OFF and FP_SEG differ between compilers. Microsoft's only work with pointers and will not work with & (address of operator). This is the reason for using both a structure and a pointer to a structure in this and the following examples.
 

6.2    BIOS INT 13h

This also is fairly easy. DOS C run time libraries usually supply the function and a structure.

int biosread(int drive, UINT16 track, UINT16 sec, UINT16 head, BYTE *buffer)
{
unsigned i;
struct diskinfo_t blk;

   if (drive > 2)                /* floppys: 0 - 7Fh, hards: 80h - FFh */
      drive += (0x80 - 3);
   else
      drive--;

   blk.drive = drive;
   blk.track = track;
   blk.sector = sector;
   blk.head = head;
   blk.nsectors = 1;
   blk.buffer = buffer;
   _bios_disk(_DISK_READ,&blk);
   i = _bios_disk(_DISK_STATUS,&blk);
   i >>= CHAR_BIT;                        /* AH contains result; AL sectors read */
   return i;
}

To write a sector use _DISK_WRITE instead of _DISK_READ.

6.3    IOCTL

The DOS IOCTL function (INT 21h Function 440Dh, Sub-function 61h) is similar to the BIOS function and uses a RWBLOCK structure:

int ioctlread(int drive, UINT16 track, UINT16 sec, UINT16 head, BYTE *buffer)
{
union REGS regs;
struct SREGS sregs;
struct RWBLOCK Blk;
struct RWBLOCK *blk = &Blk;

   blk->special = 0;
   blk->head = head;
   blk->track = track;
   blk->sector = sec - 1;
   blk->nsecs = 1;
   blk->buffer = buffer;

   regs.x.bx = drive;            /* 0 = default, 1 = A:, 2 = B:, etc. */
   regs.x.ax = 0x440D;           /* function */
   regs.x.cx = 0x0861;           /* sub-function */
   regs.x.dx = FP_OFF(blk);
   sregs.ds = FP_SEG(blk);
   intdosx(&regs,&regs,&sregs);
   if (regs.x.cflag)
      return regs.x.ax;
   return 0;
}

To write a sector use Sub-function 0x0841 instead of 0x0861.

[example program]
 

7.0    If Get Device Parameters Fails

Some drives do not support the Get Device Parameters function (ramdrives for example). An equivalent of the Device Parameters is stored in the drive boot sector which can be used in it's place. But again the sector and drive sizes are needed. The DOS function 36h, Get Freespace, provide these.

The DOS function 36h uses a FREESPACE structure:

int getfreespace(int drive, struct FREESPACE *fs)
{
union REGS regs;

   regs.x.dx = drive;
   regs.x.ax = 0x3600;
   intdos(&regs,&regs);
   if (regs.h.al == 0xFF)
      return 1;
   fs->secs_cluster = regs.x.ax;
   fs->avail_clusters = regs.x.bx;
   fs->sec_size = regs.x.cx;
   fs->num_clusters = regs.x.dx;
   return 0;
}

[example program]

The sizes we need are:

     sec_size = fs.sec_size;
  drive_size = (UINT32)fs.num_clusters * fs.secs_cluster * fs.sec_size;

The next section provide the function to read the boot sector.
 

8.0    Putting It All Together

These examples read the boot sector of a drive, first using dosread() and then using biosread(). getdevparams() is used to get the sector size and a buffer is allocated for the boot sector. For the dosread() version the drive size is checked for which version of dosread() is used.

The biosread() version needs a special check for the location of the boot sector: for floppies it resides at track 0, sector 1, and head 0; for hard drives it is at track 0, sector 1, and head 1.

8.1    DOSREAD

main(int argc, char **argv)
{
int i,drive;
char *buffer;
UINT32 drive_size;
struct DEVICEPARAMS dp;

   drive = 0;
   if (argc == 2)
      drive = atoi(argv[1]);

   if (drive == 0) {
      printf("usage: dosread <drivenum>");
      return 1;
   }

   if ((i = getdevparams(drive,&dp)) != 0) {
      printf("DEVICEPARAMS not supported: error == %02x",i);
      return 1;
   }

   if ((buffer = malloc(dp.sec_size)) == NULL)
      abort();

   if (dp.num_sectors != 0)
      drive_size = (UINT32)dp.num_sectors * dp.sec_size;
   else
      drive_size = (UINT32)dp.huge_sectors * dp.sec_size;

   if (drive_size <= (UINT32)32L * 1024L * 1024L)
      i = dosread(drive,0,buffer);
   else
      i = dosread32(drive,0,buffer);

   if (i != 0)
      printf("DOS Read error: %02x",i);
   else
      display(drive,(struct BOOTSECTOR *)buffer);
   return 0;
}

[example program]

8.2    BIOSREAD

main(int argc, char **argv)
{
int i,drive;
char *buffer;
struct DEVICEPARAMS dp;

   drive = 0;
   if (argc == 2)
      drive = atoi(argv[1]);

   if (drive == 0) {
      printf("usage: biosread <drivenum>");
      return 1;
   }

   if ((i = getdevparams(drive,&dp)) != 0) {
      printf("DEVICEPARAMS not supported: error == %02x",i);
      return 1;
   }

   if ((buffer = malloc(dp.sec_size)) == NULL)
      abort();

   if (drive <= 2)
      i = biosread(drive,0,1,0,buffer);    /* floppy disk boot sector */
   else
      i = biosread(drive,0,1,1,buffer);    /* hard disk boot sector */

   if (i != 0)
      printf("BIOS Read error: %02x",i);
   else
      display(drive,(struct BOOTSECTOR *)buffer);
   return 0;
}

[example program]

8.3    IOCTL

The IOCTL version is almost identical to the BIOS version; just substitute ioctlread() for biosread().

[example program]
 

9.0 Summary

As mentioned before: Not all drives are the same. Not all DOS versions are the same. What this means is that some of the functions shown here will fail or not work depending on the drive type and the OS version. Here are the results of my tests.
 

9.1    Drive Parameter Functions

 
DOS Drive Parameter Functions by Drive Type
  floppy hard drive ramdrive1
Get Disk Free Space (INT 21/36) X X X
Get Device Parameters (INT 21/440D) X X  
 

9.2    Sector Read Functions by Drive Type

 
DOS Sector Read Functions by Drive Type
  floppy hard drive ramdrive1
dosread (INT 25/26) X X X
biosread (INT 13) X X  
ioctlread (INT 21/440D) X X2  
 

9.3    Sector Read Functions by OS

 
Sector Read Functions by OS (F = floppy, H = hard, R = ramdrive 1)
  DOS  Windows 3.1/95 Windows NT
dosread (INT 25/26) FHR FH3 FH
biosread (INT 13) FH4 FH4 F
ioctlread (INT 21/440D) FH2 FH2 FH2
 
 

References

"MS-DOS(r) Programmer's Reference", Microsoft Press
"IBM ROM BIOS Quick Reference", Ray Duncan, Microsoft Press
"DOS and BIOS Functions Quick Reference", QUE Corp.
"The Official Spinrite II Companion", John M. Goodman, Ph.D., IDG Books
"The x86/MSDOS Interrupt List", Ralf Brown, http://www.pobox.com/~ralf



 
Notes
1 may include other drives created by a loadable device driver
2 translations will make the IOCTL read invalid for hard drives
3 read only for hard drives
4 physical drives only

Presented here are just the basics: you figure out the basic drive parameters, determine which read/write functions to use, and calculate where on the drive DOS places it's data. Simple? Well, sort of. Wait until you see the next part.
 
PART II