Copyright (c) Hyperion Entertainment and contributors.
The Right Tool for the Job (Shared Objects)
For any task, using the right tool for the job is always a crucial matter. This applies to driving a nail into a wall as much as developing software. And while nobody would ever try to use a glass bottle for the nail, the tools of the trade of the software developer are a bit more abstract (and sometimes, more brittle too).
On AmigaOS the word “shared” is used in two major contexts: Shared Library, and Shared Object. Both are tools for sharing code between applications. However, they have very different methods for doing this, and with that comes a very different approach to using them.
Let’s first look at what they are.
Since the early days of AmigaOS, shared libraries have been a means of sharing code and, to a certain degree, data between multiple users. A shared library is, essentially, a structure in memory called the Library Base, and one or more jump tables to functions that are to be shared. Since Version 4.0 of the OS, these jump tables are called Interface, and although their use differs slightly from their setup in AmigaOS 3.9 and earlier, the principles are the same. A program intending to use a library has to do two steps in order to perform any calls into that library:
- It has to open the library by calling Exec’s OpenLibrary call.
- It has to obtain at least one interface from the library by calling GetInterface.
The latter step was not needed on the classic AmigaOS 3.x, but has opened up a host of new possibilities on AmigaOS 4.0 (we’ll talk more about that in a later article).
Interfaces, like the classic AmigaOS 3.x jump tables, are a collection of function pointers in a structure. Calling a function in an interface usually involves knowing the offset of that function. We typically call a function like this:
struct Library *library = IExec->OpenLibrary("foo.library", 0);
The variable IExec contains the interface. The OpenLibrary call is a member of the interface. During compile time, the compiler will calculate the offset of the member and generate appropriate code for that. The code will load the IExec interface pointer into a register, load the CTR register with the address at the specified offset, and branch into the routine using the bctrl mnemonic.
The process relies on a few factors. First of all, it requires to have the library open and have the interface ready. It also relies on the fact that an interface, once written, will at most be extended at the end. It will never be possible to remove functions from the interface (at least it will always have to have a `dummy' entry) nor will it be possible to re-order functions.
Data access is done via the library’s base pointer. The implementer might chose to store user-accessible data within the library base and document (at least part) of it for public access. Since this is a compile-time decision to make, again, the organization of data, just like the organization of functions, must not change once it has been published (unless the library base data is private).
Shared Objects are a relatively recent addition to AmigaOS 4. They work radically different from the traditional shared libraries. A shared object is, as its name implies, somewhat reminiscent of an object file that is used during compilation of a program. In essence, a shared object allows a program to defer complete and final linking of the program until such time as the program is actually executed on the target user’s machine. This means that some symbols in the program remain unresolved until such time as the program is run.
As a matter of fact, even then the symbols might still remain undefined if a feature called Lazy Binding is active. In essence, Lazy Binding delays the linkage of a function until the very moment it is called for the first time. So, suppose you have a function MyGreatFunc that you call. If Lazy Binding is active, the call will jump into a routine called .resolv within elf.library which performs a symbol look-up on the name of the function and, if it finds it, overwrites the call to .resolv with a call to MyGreatFunc which is located somewhere in the shared objects that are bound to this program.
From the programmer’s point of view, though, there is no difference in whether the program was linked completely (statically) during the development cycle, or dynamically during load time or run time. In essence, the programmer can use a shared object like he uses any other object file in his program. This includes referencing data in the shared object file. Even more, the shared object file can reference data and code in any other shared object file bound to the program, and in the program itself.
In fact, this is a common case. If a program uses a shared object implementation of the standard C library, the start-up code in the shared object file will usually reference and call the program’s main function. This works completely transparent for both; there is no need to open the shared object, it just needs to be specified during linking.
Shared Objects can do even more. There is a set of functions to dynamically load shared objects during runtime of the program, and to look up symbols in the program or any of the loaded shared objects, including those that have been loaded at runtime.
Don’t use the Bottle!
Looking at the above, it seems natural to want to use shared objects instead of shared libraries. The shared objects seem to have so many advantages over the traditional AmigaOS shared libraries, so why would you want to use shared libraries in the first place?
The answer is simple: The flexibility and power of shared objects comes at a cost. Depending on what you do, these costs can be quite substantial:
- Shared Objects, in their current implementation as of AmigaOS 4.1 Update 3, are not really shared. Each program using them will load the file again into memory, completely, including all code and data associated with the file. This means that e.g. a shared object version of libc will load all math functions, all system functions, in fact, everything, even if the program only ever uses printf.
- Shared Objects are not versioned. While you can specify a minimum library version when you open a shared library to make sure that you get a guaranteed set of functionality, shared objects are provided “as-is” and do not have this kind of information.
- Shared Objects require a certain amount of infrastructure up and running. Therefore they cannot be used for Kickstart modules, or device drivers.
Especially point 2 is an issue, in theory and in practice. Some libraries that are implemented as shared objects carry their own versioning information, or provide a call to find out what version a program is using, but this is not standardized in any way, and therefore a shared object might or might not implement such a mechanism. A lot of systems using shared object exclusively end up with something commonly referred to as “Dll Hell” – meaning the system has to provide a wide variety of different versions of the same library, mostly encoded in their file name. If you look at the average Linux distribution, you will see what this means. This here is an example from my local network server: