Using C++ for cross-platform IPX programming

A description of a C++ encapsulation of the Novell NetWare IPX API for various platforms: MS-DOS, Microsoft Windows, and Apple Macintosh.

Introduction

In this article, we will examine ways of using C++ to implement a uniform interface to IPX programming on various platforms: MS/PC-DOS, Windows, and Macintosh. The emphasis will be on reaching uniformity rather than the maximum possible on each platform; however, you'll probably find that you can easily adapt the result to your own needs.

You may wonder why we should develop yet another programming interface to IPX when Novell already offers several. There are a number of reasons for this. One is precisely the fact that Novell offers several interfaces instead of only one. These interfaces to IPX (and to SPX, for that matter) differ among the platforms we are discussing. Partly this is due to the low-level nature of the services offered, which makes them more susceptible to platform-dependent solutions that show through in the APIs, but other differences are (in my view) signs of not-so-well thought out engineering on Novell's part. That it can be done otherwise is shown for example by the Banyan VINES' IPC and SPP services (roughly equivalent to Novell's IPX and SPX) which have identical interfaces across all platforms.

Another reason for developing a new interface is that using IPX with the standard services is more complex than it needs to be. Forcing the programmer to deal with ECBs, fragment descriptors, task IDs under Windows, etc. diverts a lot of attention to things that are really side issues to the primary purpose, which is getting datagrams across the network.

(Back to the top)

Why C++?

If you are at all familiar with C++ programming, the answer is probably obvious. C++ blends in extremely well with existing C code, offers object-oriented features that help to raise the abstraction level of the programming interface, and still has all the low-level hooks that come in handy when we're, say, dealing with event service routines and CPU registers. And while C++ as a language is still under active development (and not getting any leaner in the process), the core facilities have been relatively stable over several years now and are implemented in most current C++ compilers.

(Back to the top)

The design

If we are to make anything of the new programming interface, we should start by defining a suitable set of abstractions that will serve to give our prospective clients a quick and clear view on what we're offering. It will also help us to focus the implementation. The abstractions will then be turned into C++ classes, thus keeping the semantic gap between the concepts and the implementation small. So what will they be?

Assuming that you are familiar with IPX programming, a number of likely candidates spring to mind. Central to IPX communications is the socket. An IPX socket is the intermediair through which all communications flow. Using a socket, one can send (point to point and broadcast) and receive datagrams. Other than that, there isn't much to a socket.

Another abstraction is the IPX protocol manager. In C or assembly programming this is perhaps less obvious (protocol management is effectively spread out over quite a few routines), but in object-oriented circles some center of command frequently arises and helps to organize the housekeeping that typically accompanies protocols of all kinds. In this case, the primary responsibilities of the protocol manager are the orderly setup and shutdown of the underlying network protocol, the creation of new sockets on request, and some other chores that are of no interest to our clients but help us implementing the whole affair.

Final candidate abstractions are the datagram and a number of implementation-related entities such as ECBs and network addresses. If it seems funny to you to regard addresses as separate abstractions (and future objects), consider that addresses have quite some state of their own (consisting of network, node, and socket numbers, complemented by immediate addresses), and have also useful operations (setting or clearing individual parts of the address, comparing addresses in several ways, etc.).

Turning these abstractions into the beginnings of C++ classes is simple. The following listing shows the initial attempt.

typedef int bool; // For clarity
typedef int result_t; // IPX result codes
typedef WORD socket_t; // IPX socket number
typedef WORD service_t; // IPX service identifier

class IPXAddress; // Forward declaration
class IPXDatagram; // Ditto

class IPXSocket // The socket abstraction
{
    friend class IPXManager; // See text
public:
    // Only destructor; constructor is private
    ~IPXSocket();

    // Interrogating socket address
    socket_t GetSocket() const;
    result_t GetIPXAddress(IPXAddress &) const;

    // Sending datagrams
    result_t SendDatagram(IPXDatagram *, const IPXAddress &);
    result_t SendBroadcast(IPXDatagram *);

    // Receiving datagrams
    result_t PostReceive(IPXDatagram *);
    bool HasDatagram() const;
    IPXDatagram * GetDatagram();

    // Advertising the socket for some service
    result_t StartAdvertising(service_t, const char *);
    result_t StopAdvertising(const char *);

private:
    // Constructor is private -- only for IPXManager
    IPXSocket(...);

    // ... details omitted...
};

class IPXManager // The manager abstraction
{
public:
    // Simple constructor and destructor signatures
    IPXManager();
    ~IPXManager();

    // Functions to start and stop the protocol
    result_t Initialize();
    result_t Terminate();
    bool IsInitialized() const;

    // Functions to create sockets
    IPXSocket * CreateDynamicSocket();
    IPXSocket * CreateSocket(socket_t);

    // Finding a service
    result_t FindService(service_t, const char *, IPXAddress &);

    // Yielding control to the protocol
    void YieldCPU();

    // ... details omitted...
};

class IPXDatagram // The datagram abstraction
{
public:
    // Simple constructor and destructor signatures
    IPXDatagram();
    ~IPXDatagram();

    // Special-purpose allocators -- see text
    static void * operator new(size_t);
    static void * operator new[](size_t);
    static void operator delete(void *);
    static void operator delete[](void *);

    // ... details omitted...
};

// Other abstractions left undefined for now

In a few broad strokes, we have put down how the new IPX interface will look to our clients. What should you read into these partial class definitions? Let's start with the IPXSocket class.

It will be obvious that this class can be used to send datagrams, and that we can obtain its address. There are also provisions for posting receive requests so that the socket is able to respond to incoming datagrams. If and when a network message is actually received, it is stored somewhere inside the socket from where it can be obtained through a call to GetDatagram(). Finally, a socket can advertise itself as offering some kind of named service. Among the less obvious features is the fact that anyone can destroy an IPXSocket (the destructor is public), but apparently creation of sockets is more restricted - the default constructor is private. As it turns out, sockets are created exclusively by the IPX manager who we declare to be a friend of IPXSocket. Furthermore, there is no mentioning of ECBs or event service routines. This doesn't mean that we managed to get rid of them, but they have become something that our clients needn't worry about and that are not part of the public interface.

Turning to the IPXManager we find a simple set of services here as well. Apart from protocol initialization and termination, the manager's primary business is to create sockets. We provide two ways to do so, corresponding directly to the familiar IPXOpenSocket() calls (although in the interest of clarity, there is a separate interface to create a dynamically assigned socket instead of relying on the convention that a socket number of 0 will do that trick as well - no need to bother our clients with those idiosynchrasies). Finally there is a member function to find a particular service.

The last class in listing 1 is IPXDatagram. The reasons for providing this class are for a large part implementation driven and come down to the fact that we need to excercise some control over datagram buffers.

The interfaces shown so far represent the public side of the classes, i.e. the services intended for all normal clients. To complete the class design, we need to define the interfaces for two other categories of clients as well: derived classes (if there are any), and the members of the class itself. These categories correspond to the protected and private parts of the class definition, respectively. Both sets of interfaces are strongly related to the actual implementation of the classes, to which we will turn our attention next.

(Back to the top)

The implementation

One of the objectives for the new IPX interface is to hide the differences among various host platforms. Specifically, we will consider how to fit MS/PC-DOS, Windows, and Macintosh clients into the mold presented above. (OS/2 clients are left out of consideration for the moment; however, most of what applies to DOS and Windows can be carried over to OS/2.) What exactly are the differences among these platforms as far as IPX is concerned?

At a very basic level, Novell has chosen to use different names for similar functions, even if they only differ in lettercase. This is a nuisance, but it could potentially be overcome by use of preprocessor macros (or inline functions, to make it more C++ like). The more important differences have to do with (sometimes subtle) variations in semantics or function parameters. For example, most Windows IPX functions expect a task ID as one of their parameters while the other platforms don't; the MacIPX toolkit uses a completely different ECB layout and has also different SAP semantics. To hide these differences requires an extra layer with its own state information - the classes we have outlined above. We will now highlight some of the more important issues covered by those classes. Due to space constraints, not all source code is listed in this article; it is however available online.

To inherit or not to inherit

One of the first implementation decisions to take is whether to cover up the differences between the platforms by deriving separate IPXSocket classes, one for each platform, or to implement the whole shebang as a single class with preprocessor directives to sort out the platform-specific code. Is there a single best way to do it? I don't think so; at least, I can think of both advantages and drawbacks to both approaches.

Let's start with the class derivation model. In this model, we use the IPXSocket class (and probably the IPXManager class as well) as a base class that defines the outward behavior of IPX sockets without really implementing it. The member functions we have shown so far will be made virtual (most will even be pure virtual, i.e. with a=0 body), and we will then proceed to implement the interface for each platform by, say, defining classes called IPXDOSSocket, IPXWinSocket, IPXMacSocket. These classes will provide their own, platform-specific implementations of the virtual functions they inherit from their common IPXSocket base class. At runtime, our prospective clients will instantiate the appropriate derived class (for example, IPXWinSocket) and then go on to use it as if it were just an ordinary IPXSocket. This solution is fairly clean in terms of coding style; the only point is that clients need to be aware of which derived class they should instantiate. However, we might be able to hide this in the implementation of class IPXManager. The major drawbacks are in code maintenance (and eventually, usability): we need to provide separate implementations for all platforms; should we decide to add features it must be done to three or more classes in parallel; and finally, the temptation arises to add platform-specific features to each class and that would negate most of what we tried to accomplish in the first place.

Using class derivation isn't necessary here. In C++, the use of derived classes with virtual functions is the way to introduce polymorphism in a program, but in this specific case, we don't need that! Polymorphism is useful if we want to treat several different object types in a common way within a single program, but the very nature of our problem precludes that kind of use. The Macintosh version of the IPX socket will, by definition, only compile (let alone run) on a Macintosh platform. The same goes for the other versions. A major incentive to the use of derived classes is therefore gone. In fact, the source code provided with this article uses the 'dirty' preprocessor approach to implement class IPXSocket and its surrounding classes in a way that makes at least the outward appearance (and the behavior) of the class identical across the supported platforms. With the aid of a few C++ features, such as the ability to provide specialized memory allocators for specific classes, even most of the implementation code is generic across the board.

Implementation highlights

Rather than ploughing through every single line of code (which is reasonably commented anyway), I'll guide you past the most important features of the implementation. You may wish to have a printout or other copy of the actual code nearby for closer inspection along the way. [Charles, I'm assuming that none of the code goes in the article; let me know if there is room/desire to include small parts of it so I can change the text accordingly.]

Most differences among the platforms are relatively minor textual issues, where Novell uses IPX prefixes for DOS and Windows, and Ipx for MacIPX. The Windows version of most function requires an additional task ID argument, which has become a data member in the IPXManager class, Windows edition. Conversely, MacIPX needs a SAP handle to refer to Service Advertising functions; that became a data member in the IPXSocket class for Macintosh.

There are two more important areas where IPX differs among the platforms: memory management and event service routines. The two are related in that memory management requirements are in part dictated by the way that ESRs operate on the platforms. On all platforms, ESRs are called as the result of a network interrupt (by the way, under MacIPX the ESRs are also called when events are cancelled!) and are therefore subject to some important constraints. First of all, they may, and often will, be called outside the normal context of their client program. Second, they are fairly restricted in the system services they can use (under Windows for example, only PostMessage(), PostAppMessage(), and a few multi-media functions can be safely called). Third, all their code and all the data they touch as they execute must be available. Again, this is mainly important under Windows, where both code and data segments may be swapped out or paged out if no special measures are taken. It also implies that buffers used by the ESRs (the ECBs and associated datagram buffers) must be carefully controlled. We'll take up that issue first, but before we do so, one word on the Windows implementation. Microsoft has decreed that all interrupt-related handling should be done in DLLs (or one of the various forms of device drivers), but never in normal executables. Windows enforces this decision by making it very difficult for normal applications to obtain fixed and page-locked memory (see for example Matt Pietrek's book "Windows Internals") and the upshot of this is that you'd better use DLLs. Which is what I did. So please remember: the Windows version of the IPX classes must be compiled into a DLL, and the source files even contain checks to that effect.

To ensure that ECBs (which are hidden in the implementation) and datagram buffers will be available at interrupt time, we must take measures that vary among the platforms. Fortunately, C++ offers a nice way to encapsulate these differences: we can overload the memory allocators for individual classes. In this case, we provide overloadings for operator new, operator new[] (the vector allocator), operator delete, and operator delete[] for the datagram class and for the implementation class that represents an ECB. The way to do this is by listing these operators as static member functions in the class definition and providing a suitable implementation. The DOS and Macintosh implementations are nothing out of the ordinary (although you may want to take additional precautions if the code has to run on a Macintosh with virtual memory management enabled); under Windows we use the GlobalAllocPtr() macro with GMEM_FIXED to fix and, under enhanced mode, page lock (remember, this all happens in a DLL) the memory block, and with GMEM_SHARED to make sure that the caller (rather than the task) owns the block and that it can be shared between the DLL and its clients. The operator delete uses GlobalFreePtr() to get rid of the block afterwards. With these precautions taken for both the datagram and the ECB classes, we and our clients can then use the normal C++ allocation and deallocation syntax while still getting the correct results. (In case you frowned at the operator new[] and delete[] functions: these are fairly recent additions to C++ and your favorite compiler may not yet have them implemented. They will surely appear in the next version.)

With the memory management out of the way we can turn to the event service routines. In our quest to encapsulate everything in sight in a C++ class, we even managed to make the ESRs member functions. Having said that, it should be noted that they are static member functions. A static member function differs from other member functions in that it doesn't have a hidden argument referring to the object instance (the C++ 'this' pointer) and is in effect a more or less normal function that happens to be inside the scope of a class. It has all the privileges of other member functions, such as access to the private parts of the class, but again, it doesn't have the extra 'this' pointer argument. For an ESR, that is just as well. Novell's IPX doesn't know about C++ classes and simply expects to be able to call a function, while passing its ECB pointer argument in CPU registers (ES:SI for PC-based platforms, A0 for Macintosh). If you look at the source code of the IPXSocket::SendESR() and IPXSocket::ReceiveESR(), you will see that with a few lines of inline assembly (none in the case of Macintosh) we can retrieve the ECB that caused the event, and then... Well what? Here's the snag: we can't do anything object-oriented wise if we haven't got some kind of reference to an object. The solution is fairly simple: we just extend the Novell-defined ECB with a few of our own fields (refer to the XECB class in the source code) and include a pointer to the IPXSocket instance that originally posted the ECB. Obtaining that pointer from the ECB is a trivial matter for the ESRs, and they proceed to use this pointer to call the IPXSocket::ServiceSend() and IPXSocket::ServiceReceive() member functions (and this time, they are real C++ member functions) to handle the rest of whatever needs to be done. Bearing in mind that we're operating as part of an interrupt handler, the extra work should be minimal, and in fact all the receive handler does is to place the datagram in a queue, while the send handler simply returns the ECB and datagram to their respective pools.

A final word about the XECB class, which doesn't appear in the public header file. This class is introduced as a convenience: it takes care of fragment manipulation (it even includes its own IPX header) and offers easy to use member functions to set up the fields in the ECB. It is also subject to the memory management issues described above. Because it is of no interest to the normal clients of our IPX classes it was hidden from view, but should you have a different need and want more control over the IPX encapsulation presented here, the XECB class is a good candidate to bring out in the daylight. You may want to check class IPXManager to see how it maintains a pool of XECBs for the benefit of the IPXSocket instances; the main reason to want this pool rather than relying on individual allocations (which are also possible, given the operator new and delete overloadings) is to reduce the relative overhead associated with ECB allocations, in particular under Windows.

(Back to the top)

Conclusion

In this article we have very briefly touched upon a number of subjects from C++ programming to Windows memory management, all hovering around an IPX interface. If you are interested to see at least one way of applying C++ techniques for IPX programming, be sure to take a look at the full source code; it contains more detailed comments on implementation specifics than there was room for here. To keep your view clear, the source code omits all error checking, and diagnostic and debugging rigs that would normally be part of a set of library classes such as these.

As to the resulting implementation: the public interfaces of the classes are identical, and so are their semantics. Even most of the private parts are very similar across the platforms. The techniques that accomplished this are more general than the present subject matter, so perhaps you've seen something of use for other purposes as well. But please remember that you need a fairly complete understanding of your C++ implementation to avoid nasty surprises (for example: do you know where your C++ compiler puts the vtable pointer if a class has virtual member functions? And where resides the vtable itself? Does the compiler allow overloading resolution based on the size of pointers?)