In this blog post I will start to describe the data structures and the use of pointers in communicating cross-platform via DLLs.
The purpose of this blog series is to understand how to transfer raw data at high speed with direct access using pointers to structures that can be supported by most programming platforms.
When we transmit data between an application and a DLL, we operate in the same memory space. This means that we can read memory directly via pointers. This allows high speed transmission of data by directly reading memory as common structures shared between the application and DLLs.
Many tutorials on pointers exist already and this is not a replacement for those, just a basic primer and refresher on high level concepts.
Think of memory as a grid of boxes where each box has a sequential identifier (or address). If we have a memory position in this grid as well as knowledge about the number of boxes to read beyond that position, and their interpretation, then two systems with this common understanding can create an efficient system of directly sharing information.
In the model described above a pointer is simply the address of a box as described above. I like to think of pointers as “native unsigned integers”. Thinking about pointers this way helps keep our sanity when passing pointers by value or by reference. The value held by the pointer behaves in the same way as a numeric value passed by value or by reference.
Let us look at a sample of a pointer type defined in System.SysUtils.pas
PThreadInfo is a pointer type to
TThreadInfo. The type
PThreadInfo has exacly the same size as
Pointer, which is the same size as
NativeUInt (32-bit or 64-bit based on the platform). So, if the
PThreadInfo essentially just holds a numeric value just like any other pointer, then why declare it in this way? Well, as I mentioned earlier, if we know how much memory we need to read and how to interpret its values then we know what the data represents. When we de-reference a typed pointer variable like
LMyThreadPtr: PThreadInfo, by calling in this form:
LMyThreadPtr^, then we interpret the memory at the position value held by
LMyThreadPtr as memory with a stucture defined by
Besides an understanding of the data at the memory location of the pointer, the strongly typed pointer also has benefits in an area called “pointer math”. In short, this means that if we increment our pointer it will not always increment by 1, but by the size of the associated type. Pointer math also allows for indexing memory positions based on a pointer type. I will explain in more detail in a future post.
Now we have both parts to make our communication between application and DLL possible: a memory address and an interpretation of that memory. Let us look at an example of what our communication might look like
Abstract Structured Types in Window Messaging
Windows messaging deals with lots of varying information and types in a generic way. Let us look at one scenario of windows messaging how this works with abstract treatment of pointers.
The windows message WM_DEVICECHANGE can inform us of device changes on our system. Once we receive this method and the
wmparam has a value of DBT_DEVICEARRIVAL we know that a device arrived. Then we can read the
lparam for information for device. In the windows message strucutre
lparam is simply a
NativeInt (i.e. a numeric value), and for this windows message we can treat it as a pointer to DEV_BROADCAST_HDR
The structure and its strongly typed pointer looks like this in Delphi:
The information we can glean form this is limited to essentially just the device type, so how can we get more detail? If you read the documentation for DEV_BROADCAST_HDR. You will see that for each of the values of the device type it tells you that the structure is not really
DEV_BROADCAST_HDR, but some other structure like DEV_BROADCAST_DEVICEINTERFACE or DEV_BROADCAST_VOLUME. You may be confused at this point, but let us look at those two types as translated to Delphi code:
Look at the two records above and compare them to
DEV_BROADCAST_HDR. You will notice that the first part of their data is the same.
DEV_BROADCAST_HDR is an abstract structure. Once we cast our pointer from the header type
PDEV_BROADCAST_HDR to the specific type (say
PDEV_BROADCAST_DEVICEINTERFACE) we can now read more information. All of this works of course if both parties have a clear and unambiguous understanding of the size of data that needs to be read.
Without going into detail. Here is an example of how we would use this
Shared Structures (Naive Attempt)
As long as our application and DLL have a common understanding of the structure they need to pass the call could be a simple one
The DLL receiving the information can cast to the correct pointer type and read the memory at that address, but this would not be very extensible and reduce readability of the source code. Look at the windows message handler routine we defined earlier. In that case we use the less abstract
PDEV_BROADCAST_HDR type and cast to the appropriate
PDEV_BROADCAST_* pointer type based on
dbch_devicetype. We can do the same in our shared structure (record) definitions.
Let use start by creating our header or “base” record type. As seen above we need a type so we can cast to correct pointer type. An enumeration would work fine
Next we can define our Line and Arc records, but we have to keep in mind that the first data needs to match that of TxRec.
The type alone will work for single records transmitted by our API. We will need the size if we read a complex stream of data (for instance from a file). It would be prudent to also add the size to our base type. If we ever encounter a stream that has a record that we don’t understand we can skip that section of memory.
Shared Structures (Slightly Improved)
Records don’t always occupy the memory size equivalent to the size of their component values, so adding the size allow receivers to read the correct amount of data when reading streams. There is another issue to consider: data members and records themselves have the data aligned to certain size boundaries. Since all systems reading the memory need a clear understanding of the data, we need to understand how this data can be represented in an unambiguous way. The
Size value can be read by both parties as a first line of defense to know that records are represented in a similar way, but for complete safety we need to ensure that our data alignment matches.
Record Alignment and Padding
Records are usually padded and aligned, this means they have their data members and the record as a whole are padded to certain size multiples. This speeds up memory access and indexing of data. We could declare our records as
Packed to prevent alignment or we could use explicit compiler defines (
$ALIGN) to set the alignment. As long as all parties involved in the transfer of the data have the same understanding of the data structure the data can be read correctly.
Records are by aligned by the smaller of:
- The record alignment setting. By default, 8-byte (Quad Word)
- The size of the largest element
Record fields are aligned by the smaller of:
- The alignment of fields of its own size
- The alignment of the record (see above)
By placing larger members before smaller ones, we could create a more compact structure because we would eliminate data member alignment requirements. This is true because data types are usually multiples of smaller types (1,2,4,8 bytes). 8 Byte alignment is the same standard used by the Windows SDK and the reason why we could do a verbatim translation of the C++ struct to a Delphi record.
If we now look at our header record the
RecType: TxRectTypeEnum gains no space benefit defined as a type with a size of 1 byte. By default, enumerated types are byte, but since our
DWord the record will be aligned by at least 4 bytes. This means that the size of the record will be 8 instead of 5. In our Line and Arc records it means that a pad of 3 bytes will be added before
CenterX and before
TxRecLine respectively. Similarly, since our record will be aligned to a boundary of 8 bytes (since it contains
Double), a pad of 7 bytes is added to the end.
In memory our Record really looks like this (I added comments where padding is done)
Since enumerated types are typically 32-bit in other programming languages we might as well set our
TxRectTypeEnum to be 4 bytes using (the
$Z compiler directive). Doing so this
Pad1 will disappear and we will be more compatible with C++. Also, since we have padding after CCW we could use
LongBool datatypes without growing our record footprint
If we use “standard” alignment rules (8 Byte) we remain compatible with Windows SDK standards. Most applications can easily be configured to use our records and we won’t have surprises about alignment of fields or records.
I highly recommend staying within the 8-Byte (Quad Word) record alignment scheme. It is well known and widely used. If parties use this alignment the fields will be aligned as expected.
Pointers are numeric values. Strongly typed pointers allow us to understand the structure and values at the address held by a pointer. Casting a pointer allows for a different or extended interpretation of the data at a memory address. As long as two parties have common interpretation of the structure of memory data can be easily read or written by either.
In the next section I will expand on pointer math and the transmission of arrays of data.