Jump to content
  • Sky
  • Blueberry
  • Slate
  • Blackcurrant
  • Watermelon
  • Strawberry
  • Orange
  • Banana
  • Apple
  • Emerald
  • Chocolate
  • Charcoal
Ocawesome101

How to Write an OpenComputers Operating System (for Beginners)

Recommended Posts

How to Write an OpenComputers Operating System

This is the kind of guide I wish I had had 9 months ago when I started developing operating systems for OpenComputers. It will walk you through writing a very basic OpenComputers operating system.

EDIT: The guide I followed (the only one I found) is here:

WARNINGS:

-- This post assumes basic knowledge of programming, such as: what a string is, what a table or list is, what a function is, what a variable is, etc. I am NOT attempting to teach you Lua-- see warning #3
-- This operating system is not intended to have many features (nor is it fast!), but more so to teach new programmers. I will try to keep my code well-organized and well-commented, but no guarantees!
-- If you're new to Lua, go read the PIL (https://lua.org/pil/1.html), and keep the Lua reference manual (https://lua.org/manual/5.3/manual.html) open in case you need to reference it. (I also recommend having the OpenComputers wiki [https://ocdoc.cil.li] open.)
--  Note that the component, unicode, and computer APIs, plus checkArg, are unique to OpenComputers, and that io and package (plus require, loadfile, and dofile) must be defined by the user.
-- This OS is structured in a way that is meant to be easy-to-follow, though you can lay your own out however you like.

All source code is available at https://github.com/ocawesome101/basic-oc-os under no license in particular.

1. Init.lua

init.lua is the file that most BIOSes expect to load. Most operating systems, this one included, simply use init.lua to load their kernel.

This is an example of an init.lua file:

-- The path to our kernel. Note that it is advisable to use the 'local' keyword in front of your variables unless you want them to be accessible from everywhere.
local KERNEL_PATH = "/mini/kern.lua"

-- Get the computer's boot address
local address = computer.getBootAddress()

-- Open the kernel file for reading
local handle, err = component.invoke(address, "open", KERNEL_PATH)
if not handle then -- The kernel is probably not present!
  error(err)
end

-- Read all the data from the kernel file
local kernelData = ""
repeat
  local chunk = component.invoke(address, "read", handle, math.huge)
  kernelData = kernelData .. (chunk or "") -- (chunk or "") protects against errors
until not chunk -- End Of File

-- Close the kernel file handle
component.invoke(address, "close", handle)

-- Try to turn the data we read into a function we can call
local ok, err = load(
  kernelData,         -- the data (or "chunk")
  "=" .. KERNEL_PATH, -- what name to use for the loaded chunk, prefixed with an "="
  "bt",               -- the mode with which to load the chunk. "bt" should be generally fine
  _G
)

if not ok then -- There was probably a syntax error or some such thing
  error(err)
end

ok() -- Execute the kernel

-- an idle loop in case the kernel exits. Could be replaced with computer.shutdown()
while true do
  computer.pullSignal()
end

The comments should explain fairly well what is happening.

init.lua should be placed in the root of your drive.

 

2. The Kernel

The kernel is the heart of your operating system. In more advanced operating systems, the kernel usually contains some basic hardware management, a scheduler (relatively complex beasts involving coroutines and signal timeouts), and possibly drivers, leaving the rest of system initialization to the init process. For the sake of this tutorial, we will jump straight from a basic kernel to the shell.

In my case, the kernel is at /mini/kern.lua, though yours can be almost anywhere.

For compatibility's sake, it is usually a good idea to start by defining _OSVERSION like so:

_G._OSVERSION = "mini 0.1.0"

Next, you should find installed GPU and screen components- at least one of each.

local gpu = component.list("gpu")()
local screen = component.list("screen")()

There are two ways to interact with components: component.invoke and through a proxy. For extended usage, a proxy is generally the better choice, but for one or two operations component.invoke is fine.

An example of component.invoke usage:

component.invoke(
  gpu,    -- The component address, a string
  "bind", -- The method you want to invoke. Must be valid, and must be a string.
  screen  -- Any additional arguments are interpreted as parameters
)

It is probably a good idea to create proxies for the GPU and boot filesystem, since we're going to be using these things a lot.

local gpuProxy = component.proxy(gpu)
_G.fs = component.proxy(computer.getBootAddress()) -- computer.getBootAddress() is defined by most BIOSes

Optionally, you can set up basic onscreen boot logging. This is especially useful for debugging, and it can give some insight into what your OS is doing when it boots.

I set up my logging like this. Note that my code is somewhat over-commented for the sake of this tutorial.

local line = 1 -- What line are we on?
local width, height = gpuProxy.maxResolution() -- get the maximum resolution of the GPU and screen. These values are the minimum of the two.
gpuProxy.setResolution(width, height) -- ensure that the screen resolution is properly set
gpuProxy.fill( -- Fill a box on-screen with a single character
  1,      -- The top-left X coordinate
  1,      -- The top-left Y coordinate
  width,  -- How wide the box should be
  height, -- How tall the box should be
  " "     -- The character the box should be made of
)
local function log(message)
  -- checkArg is a very useful function, used for argument checking.
  checkArg(
    1,        -- the argument number
    message,  -- the argument itself
    "string"  -- one or more types that are allowed for the argument, in the form of separate strings.
  )
  
  -- Set the line at
  gpuProxy.set( -- Set a line (or part of a line) onscreen to a string
    1,       -- The X coordinate of the string
    line,    -- The Y coordinate of the string
    message  -- The string
  )
  
  if line == height then -- We can't go down or we'll be off the screen, so scroll down a line
    gpuProxy.copy( -- Copy one screen area to another
      1,        -- The top-left X coordinate
      1,        -- The top-left Y coordinate
      width,    -- The width
      height,   -- The height
      0,        -- The relative X to copy to
      -1        -- The relative Y to copy to
    )
    gpuProxy.fill(1, height, width, 1, " ")
  else
    line = line + 1 -- Move one line down
  end
end

-- Crash the system in a slightly prettier fashion. Not necessary, but nice to have.
local function crash(reason)
  checkArg(1, reason, "string", "nil") -- This is an example of checkArg's ability to check multiple types
  -- Here, reason is already local; there is no need to specify it so
  reason = reason or "No reason given"
  log("==== crash " .. os.date() .. " ====") -- Log the crash header, ex. "==== crash 24/04/20 18:52:34 ===="
  log("crash reason: " .. reason) -- Log the crash reason. ".." is Lua's string concatenation operator.
  local traceback = debug.traceback() -- Tracebacks are usually useful for debugging
  traceback = traceback:gsub("\t", "  ") -- Replace the tab character (not printable) with spaces (printable)
  for line in traceback:gmatch("[^\n]+") --[[ :gmatch("[^\n]+") splits the string on the \n (newline) character using Lua's basic regular expressions ]] do
    log(line)
  end
  log("==== end crash message ====")
  while true do -- Freeze the system
    computer.pullSignal()
  end
end

For sanity's sake (and ease-of-use) you should define loadfile(), dofile(), and require(). If you set up module caching in require, you can do some neat things that otherwise would be difficult, such as library persistence across programs.

loadfile() is probably the most complex of the three:

function _G.loadfile(file, mode, env)
  checkArg(1, file, "string")
  checkArg(2, mode, "string", "nil")
  checkArg(3, env, "table", "nil")
  -- Make sure mode and env are set
  mode = mode or "bt"
  env  = env or _G -- env can be used for sandboxing. Quite useful.
  
  local handle, err = fs.open(file, "r")
  if not handle then
    return nil, err
  end
  
  local data = ""
  repeat
    local fileChunk = fs.read(handle, math.huge)
    data = data .. (fileChunk or "")
  until not fileChunk
  
  fs.close(handle) -- Always close your file handles, kids
  
  return load(data, "=" .. file, mode, env)
end

Then dofile:

function _G.dofile(file)
  checkArg(1, file, "string")
  local ok, err = loadfile(file)
  if not ok then
    return nil, err
  end
  return ok()
end

Basic library caching can be setup very quickly with:

local loaded = { ["gpu"] = gpuProxy } -- Libraries that have already been loaded

Next, you should implement some sort of require() function. Ideally we'd do this with a fully fledged package library, but that's much more complex.

First, you'll need paths of some kind to search. I did mine this way:

local libPaths = { -- The path(s) to search for libraries
  "/mini/lib/?.lua",
  "/ext/lib/?.lua"
}

The standard Lua package.path is "/usr/share/lua/5.3/?.lua;/usr/share/lua/5.3/?/init.lua;/usr/lib/lua/5.3/?.lua;/usr/lib/lua/5.3/?/init.lua;./?.lua;./?/init.lua" but the above is considerably easier to parse.

To disable package caching, useful for development, you can comment out the lines suffixed with "--".

function _G.require(lib)
  checkArg(1, lib, "string")
  if loaded[lib] then   --
    return loaded[lib]  --
  else                  --
    -- It wasn't already loaded, so search all the paths
    for i=1, #libPaths, 1 do
      component.proxy(component.list("sandbox")()).log(libPaths[i]:gsub("%?", lib))
      if fs.exists(
        libPaths[i] -- The current path
          :gsub( -- Replace a character or characters in a string with another string
            "%?", -- The string to replace. "%" is necessary because string.gsub, string.gmatch, and string.match interpret "?", along with a few other patterns, as a form of regex (DuckDuckGo regular expressions if you don't know what regex is).
            lib -- The string with which to replace "?"
          )
        ) then
        local ok, err = dofile(string.gsub(libPaths[i], "%?", lib)) -- string.gsub("stringToGSUB", ...) is the same as ("stringToGSUB"):gsub(...)
        if not ok then
          error(err)
        end
        loaded[lib] = ok  --
        return ok
      end
    end
  end             --
end

Once this is all done, I load my shell with a simple

while true do
  local ok, err = dofile("/mini/shell.lua") -- Run the shell
  if not ok then
    crash(err)
  end
end

Note that mine is encased in a while loop so that if the shell exits it will restart.

We aren't done yet though, as we still need to program the shell.

 

3. The shell

For simplicity's sake, my shell is just a basic Lua interpreter. It doesn't seem to function properly in ocvm, unfortunately (pcall results are strange, I'll have to test in-game) but here it is, in all its glory:

_G.term = require("term")
_G.gpu = require("gpu") -- This is where the loaded = { ["gpu"] = gpu } line comes in
term.clear()

function _G.print(...)
  local args = {...}
  for k, v in pairs(args) do
    term.write(tostring(v) .. " ")
  end
  term.write("\n")
end

local currentDirectory = "/" -- self explanatory

local function drawPrompt()
  gpu.setForeground(0x00FF00) -- Colors are stored as 24-bit hexadecimal values. (Look up "hexadecimal color"). 0x00FF00 is bright green.
  term.write("\n" .. currentDirectory .. " > ")
  gpu.setForeground(0xFFFFFF)
end

local function printError(err)
  gpu.setForeground(0xFF0000)
  term.write(err .. "\n")
  gpu.setForeground(0xFFFFFF)
end

local function execute(command)
  local ok, err = load(command, "=lua")
  if not ok then
    ok, err = load("=" .. command, "=lua")
    if not ok then
      return nil, err
    end
  end
  local result = {pcall(ok)} -- pcall, or protected call, captures errors. Very useful function.
  if not result[1] and result[2] then
    return printError(result[2])
  end
  for i=#result, 1, -1 do
    print(result[i])
  end
end

while true do
  drawPrompt()
  local command = term.read()
  if command ~= "\n" then
    execute(command)
  end
end

What is this shell doing? First, it defines a few utility functions (print(), drawPrompt, printError, and execute), and then it runs the shell's main loop.

But wait! What about the top line? Where is the term API?

It's in /mini/lib/term. I implemented term.getCursorPosition, term.setCursorPosition, term.scroll, term.clear, term.write, and term.read, but you can implement more fairly easily.

Internally, there are two functions (showCursor and hideCursor) that I call after and before every operation, respectively. For example, when you call term.setCursorPosition(2, 2), the term API executes this code:

function term.setCursorPosition(newX, newY)
  checkArg(1, newX, "number")
  checkArg(2, newY, "number")
  hideCursor()
  cursorX, cursorY = newX, newY
  showCursor()
end

cursorX and cursorY, as well as width and height, are used internally for purposes you can probably guess based on their names.

If you need it, here's my term.read function (text input is always particularly tricky to get right):

-- Fairly basic text imput function
function term.read()
  local read = ""
  local enter = 13
  local backspace = 8
  local startX, startY = term.getCursorPosition()
  local function redraw()
    term.setCursorPosition(startX, startY)
    term.write(read) --.. " ") -- the extra space ensures that chars are properly deleted
  end
  while true do
    redraw()
    local signal, _, charID, keycode = computer.pullSignal() -- signal is the signal ID, charID is the ASCII code of the pressed character, and keycode is the physical keyboard code. Note that these are keypress-specific!!
    if signal == "key_down" then -- A key has been pressed
      if charID > 31 and charID < 127 then -- If the character is printable, i.e. 0-9, a-z, A-Z, `~!@#$%^&*()_+-=[]{}\|;':",./<>?
        read = read .. string.char(charID)
      elseif charID == backspace then -- The character is backspace
        read = read:sub(1, -2) -- Remove a character from the end of our read string
      elseif charID == enter then -- my god Kate's syntax detection is crap
        read = read .. "\n"
        redraw()
        return read
      end
    end
  end
end

That should be all! Feel free to comment if you need help (be sure to provide error messages and the relevant bits of your code) and I'll do my best to respond.

My results with this code (I can enter Lua statements and they will be run):

basic-os.png.179fa511dfbd9832cf8bbf6f28cd89c1.png

Next Steps:

-- Expand on this OS with a more advanced shell, io and package libraries, and the like

-- Write your own OS with a task scheduler using coroutines

   -- Be sure you give events to all processes! My first scheduler very much did not do that.

Share this post


Link to post
Share on other sites

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.
Note: Your post will require moderator approval before it will be visible.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

Loading...

×
×
  • Create New...

Important Information

By using this site, you agree to our Terms of Use and Privacy Policy.