less_retarded_wiki/portability.md
2023-12-26 14:46:41 +01:00

14 KiB

Portability

Portable software is software that is easy to port to (make run on) other platforms. Platforms here mean anything that serves as an environment enabling software to run, i.e. hardware platforms (CPUs, ISAs, game consoles, ...), different operating systems vs bare metal, fantasy consoles etc. Portability is an extremely important attribute of good software as it allows us to write the program once and then run it on many different computers with little effort -- without portability we'd be constantly busy rewriting old programs to run on new computers, portability allows us to free our programs from being tied to specific computers and exist abstractly and independently and so become future proof. Examples of highly portable programs include Anarch, Simon Tatham's Portable Puzzle Collection, sbase (suckless) implementation of Unix tools such as cat and cmp etc.

Portability is different from mere multiplatformness: multiplatform software simply runs on more than one platform without necessarily being designed with high portability in mind; portable software on the other hand possesses the inherent attribute of being designed so that very little effort is required to make it run on wide range of general platforms. Multiplatformness can be achieved cheaply by using a bloated framework such as the Godot engine or QT framework, however that will not achieve portability; on the contrary it will hurt portability. Portability is achieved through good and careful design, efficient code and avoiding dependencies and bloat.

In connection to software the word portable also has one other meaning used mainly in context of Windows programs: it is sometimes used for a binary executable program that can be run without installing (i.e. it can be carried around and ran from a USB drive etc.). However we'll stick to the previously defined meaning.

How To Make Portable Programs

In short: use abstraction to not get tied to any specific platform (separate frontend and backend), keep it simple, minimize dependencies (minimize use of libraries and requiring hardware such as floating point unit or a GPU, have fallbacks), write efficient, simple code (lower hardware demands will support more platforms), avoid platform-specific features (don't write in assembly as that's specific to each CPU, don't directly use Linux syscalls as these are specific to Linux etc.).

Remember, portability is about making it easy for a programmer to take your program and make it run elsewhere, so portability is kind of a mindset, it is about constantly putting oneself in the shoes of someone else with a very different computer and asking questions such as "how hard will it be to make this work if this library isn't available?". Even things that are supposed or commonly expected to be present on all platforms, such as a file system or a raster screen, may not be present on some computers -- always remember this.

Do NOT use big frameworks/engines -- it is one of the greatest misconceptions among many inexperienced programmers to think portable software is created with big frameworks, such as the Godot engine or the QT framework, which can "single click" export/deploy software to different platforms. This will merely achieve creating a badly bloated multiplatform program that's completely dependent on the framework itself which drags along hundreds of dependencies and wastes computing resources (RAM, CPU, storage, ...) which are all factors directly contradicting portability. If you for example create a snake game in Godot, you won't be able to port it to embedded devices or devices without an operating system even though the snake game itself is simple enough to run on such devices -- the game drags along the whole Godot engine which is so huge, complex and hardware demanding that it prevents the simple game from running on simple hardware.

The same goes for languages and libraries: do NOT use big/bloated languages such as Python, Java or JavaScript -- your program would immediately become dependent on a hugely complex ecosystem of such language. For portability you should basically only write in C (the best established, time tested, relatively simple language supported basically by every platform) or in C++ at worst, and even with these languages do NOT use the newer standards as these hugely limit the number of compliant compilers that will be able to compile your program. The best is to write in C89 or C99 standard of C. Minimize the number of libraries you use, even if it is the standard library of your language -- not all compilers fully adhere to standards and some don't have the standard library even if the should. For shell scripts only use posix shell, i.e. only use constructs, utilities and flags/features defined by the posix standard, even if you have more "powerful" shell and utilities like Bash and GNU utils.

{ A great example of how avoiding C features can help your programs be more portable can be seen with Dusk OS, a very small operating system that will likely be THE system we use if (or rather when) the collapse strikes. The system is implementing what they call "Almost C" (https://git.sr.ht/~vdupras/duskos/tree/master/fs/doc/cc/index.txt) -- a language trying to be close to C but avoiding standard compliance to keep simplicity. They want to port C programs but HAVE TO keep it simple so they just can't implement full C and when the judgement day comes, the programs that don't rely on much will simply be the ones that survive. If you just hide behind the excuse "the feature is in the standard so IT HAS TO BE IMEPLEMENTED", your program will end up more unlikely to be ported, an old piece of paper saying your program should run simply won't matter. In Dusk OS you can actually see this porting effort happening right now. ~drummyfish }

In your compiled programs always make your own thin I/O abstraction, decouple your I/O libraries, separate frontend and backend. This is one of the most basic and most important things to do. Why? Well unless you're writing a library, you will need to use I/O (write out messages, draw to screen, create GUI, read keyboard commands, read from files, read from network, ...) so you will NEED to use some library for this (C stdlib, SDL, OS syscalls, Xlib, ...) but you absolutely DON'T WANT this library to become a hard dependency of your program because if your program depends let's say on SDL, you won't be able to make your program run on platforms that don't have SDL. So the situation is that you HAVE TO use some I/O library but you don't want to become dependent on it.

The way to solve this is to create your own small I/O abstraction in your project, i.e. your own functions (such as drawPixel, writeMessage, keyPressed, playSound, readFrile etc.) for performing I/O, which you will use inside your main program. These functions will be defined in a small file which will basically be your own small I/O library just for your program. The functions you define there will then internally use functions of whatever underlying I/O system you choose to use at the time as your frontend (SDL, Xlib, SFML, ...); the important thing is that your main program code won't itself depend on the underlying system, it will only depend on your I/O abstraction, your own functions. Your custom I/O functions will depend on the underlying I/O system but in a way that's very easy to change -- let's say that your keyPressed function internally uses SDL's SDL_GetKeyboardState to read keyboard state. If you want to switch from using SDL to using a different frontend, you will only have to change the code in one place: in your I/O abstraction code, i.e. inside your keyPressed function. E.g. if you switch from SDL to SFML, you will just delete the code inside your keyPressed function and put in another code that uses SFML functions to read keyboard (e.g. the isKeyPressed attribute), and your whole code will instantly just work on SFML. In fact you can have multiple implementations of your functions and allow switching of different backends freely -- just as it is possible to compile a C program with any C compiler, you can make it possible to compile your program with any I/O frontend. If you used SDL's specific functions in your main code, you would have to completely rewrite your whole codebase if you wanted to switch away from SDL -- for this reason your main code must never directly touch the underlying I/O system, it must only do so through your I/O abstraction. Of course these principles may apply to any other thing that requires use of external libraries, not just I/O.

This is all demonstrated by LRS programs such as Anarch or SAF, you can take a look at their code to see how it all works.

Anyway the following is a simple C code to demonstrate the abstraction from an I/O system -- it draws a dithered rectangle to the screen and waits until the user pressed the q key, then ends. The main code is written independently of any I/O system and can use either C stdlib (stdio, draws the rectangle to terminal with ASCII characters) or SDL2 (draws the rectangle to actual window) as its frontend -- of course more frontends (e.g. one using Xlib or SFML) can be added easily, this is left as an exercise :)

#define SCREEN_W 80
#define SCREEN_H 30

// our I/O abstraction:
void ioInit(void);      // init our I/O
void ioEnd(void);       // destroy our I/O
void drawPixel(int x, int y, int white);
void showImage(void);
int  isKeyPressed(char key);

// our main program code:
int main(void)
{
  ioInit();

  for (int y = 3; y < 20; ++y) // draw dithered rectangle
    for (int x = 30; x < 60; ++x)
      drawPixel(x,y,x % 2 == y % 2);

  showImage();

  while (!isKeyPressed('q')); // wait for pressing 'q'

  ioEnd();

  return 0;
}

/*---------------------------------------------------
  implementation of our I/O abstraction for different
  frontends: */

#ifdef FRONTEND_STDLIB // C stdio terminal frontend
  #include <stdio.h>
  char screen[SCREEN_W * SCREEN_H];

  void ioInit(void)
  {
    // clear screen:
    for (int i = 0; i < SCREEN_W * SCREEN_H; ++i)
      screen[i] = 0; 
  }

  void ioEnd(void) { } // nothing needed here

  void drawPixel(int x, int y, int white)
  {
    screen[y * SCREEN_W + x] = white != 0;
  }

  void showImage(void)
  {
    for (int i = 0; i < SCREEN_W * SCREEN_H; ++i)
    {
      if (i % SCREEN_W == 0)
        putchar('\n');

      putchar(screen[i] ? '#' : '.');
    }

    putchar('\n');
  }

  int isKeyPressed(char key)
  {
    return getchar() == key;
  }
#elif defined(FRONTEND_SDL) // SDL2 frontend
  #include <SDL2/SDL.h>
  unsigned char screen[SCREEN_W * SCREEN_H];
  SDL_Window *window;
  SDL_Renderer *renderer;
  SDL_Texture *texture;

  void ioInit(void)
  {
    for (int i = 0; i < SCREEN_W * SCREEN_H; ++i)
      screen[i] = 0; 
    
    SDL_Init(0);
  
    window = SDL_CreateWindow("sdl",SDL_WINDOWPOS_UNDEFINED,
      SDL_WINDOWPOS_UNDEFINED,SCREEN_W,SCREEN_H,SDL_WINDOW_SHOWN); 

    renderer = SDL_CreateRenderer(window,-1,0);

    texture = SDL_CreateTexture(renderer,SDL_PIXELFORMAT_RGB332,
      SDL_TEXTUREACCESS_STATIC,SCREEN_W,SCREEN_H);
  }

  void ioEnd(void)
  {
    SDL_DestroyTexture(texture);
    SDL_DestroyRenderer(renderer); 
    SDL_DestroyWindow(window); 
  }

  void drawPixel(int x, int y, int white)
  {
    screen[y * SCREEN_W + x] = (white != 0) * 255;
  }

  void showImage(void)
  {
    SDL_UpdateTexture(texture,NULL,screen,SCREEN_W);

    SDL_RenderClear(renderer);
    SDL_RenderCopy(renderer,texture,NULL,NULL);
    SDL_RenderPresent(renderer);
  }

  int isKeyPressed(char key)
  {
    SDL_PumpEvents();
    const unsigned char *keyboard = SDL_GetKeyboardState(NULL);

    return keyboard[SDL_SCANCODE_A + (key - 'a')];
  }
#endif

If you compile this code as

gcc -DFRONTEND_STDLIB main.c

You'll get the stdlib version. If you compile it as

gcc -DFRONTEND_SDL -lSDL2 main.c

You'll get the SDL version.

A great example of this kind of portable design can be seen e.g. in well written compilers that separate their architecture into an frontend and backend -- imagine we are writing for example a C compiler. The parser of C syntax can be easily written in a portable way, we simply write functions that work with text, however we find difficulty in asking what instruction set we will compile to. If we choose one, such as x86, then we will not only write an x86 specific code generator, but also e.g. an x86 specific optimizer; the part of the compiler that may get so complex that it ends up being bigger than the rest of the code. What if then we also want to support another ISA such as Arm or RISC-V, will we have to rewrite our painstakingly written optimizer for those architectures from scratch? The solution is the same as explained above in regards to I/O: we make an abstraction above the instruction set, here called an intermediate representation, usually some bytecode, i.e. the compiler first translates C to the abstract bytecode, then we may perform all the complex optimizations on this bytecode, and only then, in the last moment, we relatively simply translate this bytecode to whatever specific instruction set.