Basically I manually implemented a mini-virtual memory engine, things that are offered for free by the Linux kernel, as you pointed out
Right; sometimes the memory mapping approch just isn't valid. Another example would be a low-powered embedded device, which provides access to some large database, where the time is not as big of a factor as RAM footprint is: then I would use low-level I/O as well.
For database stuff, I use POSIX memory mapping with the Linux-specific MAP_NORESERVE,(...)
Yes, that would be the best approach, but is not fully portable...
Speaking of SQLite, I've used it, but I admit I haven't taken a look at how they do it.
It is actually
quite nice, implementing its own pager (page cache), with memory mapping support on many OSes (even partial maps, not simply "map this entire file" stuff).
For implementing low-level I/O access to a binary database-like file, I do recommend taking a look at the POSIX
pread() and
pwrite() functions. They take a file descriptor, pointer to the buffer, the size of that buffer (noting that it is not guaranteed that
all of that is read or written!),
plus the offset at which the read/write should start. These do not affect the file position, you see.
For portable code that needs to do reads and writes to the same stream, I would consider implementing wrapper functions
size_t file_read(FILE *stream, void *buffer, size_t size, size_t count, off_t offset); size_t file_write(FILE *stream, const void *buffer, size_t size, size_t count, off_t offset);the functions returning zero with errno set to indicate the error, if an error occurs; otherwise the
count of successfully read or written records of
size bytes each.
On Linux and Unix systems that do provide
unlocked stdio, they can be made thread-safe by locking the stream handle (using
flockfile()/
funlockfile()). They'd do an
fseek() to the specified offset by default, and the write a
fflush() afterwards (before releasing the stream handle).
On systems that do have the file descriptor abstraction (basically all; Windows just calls them
handles instead of descriptors), I would consider
size_t fd_read(int desc, void *buffer, size_t size, size_t count, off_t offset); size_t fd_write(int desc, const void *buffer, size_t size, size_t count, off_t offset);with three different implementations: one for Linux, BSDs, and Unix systems having
pread() and
pwrite(), one for those that do not, and one for Windows; based on
pre-defined compiler macros. I would also use a compile or run-time option, or perhaps add a sixth
flags parameter, so that if desired, the operation takes an advisory record lock via
fcntl(); this provides "atomic" accesses (for processes that do take advisory locks; across processes, but not across threads in the same process).
As you can see, the POSIX approach yields much more options – even avoiding the file position mess completely –, and that's why it is more applicable to mixed read and write accesses to binary data. Of course, one could say you just switch the set of pitfalls, because short reads and writes are always possible in practice (i.e., you need more than one call in a loop to get all the data you want), and because some systems, like Linux, limit a single read or write call to just under 2 GiB (because of historical bugs in certain filesystem kernel drivers).
Thinking about this further, I would claim that the solution
here, in this particular case, is not to add a call before and/or after each standard I/O call, but to create wrapper functions that implement the logical read and write operations one needs.
These wrapper functions need to take care of the fflush()/fseek(), obviously, but the "trickiness" is then restricted to those wrapper functions.
My own mind needs this kind of tools to work well on complex applications and problems. It not only lets my mind concentrate on the issues at the correct complexity level (from nitty gritty details, to the highest concept level "okay, so how are the users going to do their thang with this app?"), but it also lets me unit test such wrappers, and after testing,
trust them. That means that whenever there is a bug, I have sort-of automatically limited the scope of that bug, simply by observing which function reports the problem first. (That also means my programs are often full of "unnecessary" error checks, with people offhandedly mentioning that "that call can never fail". Ha! In a perfect world, yes. In my world, everything I touch can fail.
)