QNX From The Board Up #15 - Physical Memory & Video!

Learn about the relationship with physical memory addresses and how we can leverage that to finally draw something on the screen!

QNX From The Board Up #15 - Physical Memory & Video!

Welcome to the blog series "From The Board Up" by Michael Brown. In this series we create a QNX image from scratch and build upon it stepwise, all the while looking deep under the hood to understand what the system is doing at each step.

With QNX for a decade now, Michael works on the QNX kernel and has in-depth knowledge and experience with embedded systems and system architecture.


Let's Get Physical

In an earlier article when we created a very detatched implementation of malloc(), we saw how to use mmap() to request "plain old RAM" with the MAP_ANON flag. We saw how QNX grabbed us some from somewhere within System RAM. We didn't care about the physical address, and we didn't care about the virtual address given.

We didn't care about the physical address, because, who cares? Just gimme RAM. Yes, QNX calls that System RAM, but, as users of QNX, we Do. NoT. Care. where in System RAM it came from. Just gimme.

It's All Relative

We didn't care about the virtual address either because everything we do with the range in the virtual address space is done relative to the absolute address returned by mmap(). Therefore the absolute value of that pointer is irrelevant.

e.g. we call malloc() to allocate an array of 32 integers:

    int * const array = malloc(32 * sizeof(int));
    assert(NULL != array);

and then set a value in the array, say, entry 5:

    array[5] = 42;

which is basically saying: "relative to the beginning of array, go forward 5 * sizeof(int) bytes, and write the integer 42 there."

Now, there are times when we care about absolute physical addresses, but, we don't have to care about all absolute addresses; we can often worry about just one absolute address, and then treat everything relative to that.

In this article, we're going to see how to use mmap() to get access to a specific range of physical addresses by requesting a mapping to a specific absolute physical address, and then accessing everything relative to the mapping.

mmap() A Range of Physical Addresses

For the sake of optimizing the path to the quickest dopamine hit, I'm going to state the following without much background (For now. Future posts will get into it more):

  • Caches are wonderful until they're not. When we're dealing directly with hardware, or memory used by hardware, we need caches out of the way.
  • When we're accessing hardware directly, or memory used by hardware, we're effectively sharing that with anyone else that has access to it. For now, that number we're sharing with is at its simplest: 0. i.e. we have exclusive access. (Aside: Well, exclusive access from a software perspective.)

With that in mind, for this article I'm going to say that we need R/W access to the physical address range [0x000B'8000, 0x000B'8F9F], i.e. 4000 (0x0FA0) bytes starting at physical address 0x000B'8000.

Let's take another wade through the documentation for mmap() and see how we can do that.

addr

addr is the virtual address where we want the mapping created. Still don't care. NULL to say we don't care.

prot

This is going to be PROT_READ | PROT_WRITE and we need PROT_NOCACHE too. When we read, we want it coming directly from memory / hardware.
When we write, we want it to go directly to memory / hardware.

flags

More about this in upcoming posts, but, we want MAP_SHARED for several very good reasons.

And we need to specify that we want a specific physical address range. This is done by setting MAP_PHYS. According to the documentation, when using MAP_PHYS:

The filedes parameter must be NOFD.
When you use this flag without MAP_ANON, the offset specifies
the exact physical address to map (e.g., for video frame buffers).

Also, while we're there, take a look at the "DANGER" block there. When you set MAP_PHYS when you call mmap(), this is you saying to QNX, "Hey, QNX, I know you usually have all these seatbelts and railings to protect me from myself, but, it's my computer, and I know what I'm doing. You, QNX, are here to help make this happen for me. I accept all responsibility. Just do it."

And this is perfectly fine! QNX is an operating system, not an omniscient system. At some point, it is your computer, and only you know where things are. You told QNX a bit about the target via the System Page, and this is Just Another Place where you're saying "I know where it is."

How do you know where things are? In this case, I'm telling you, and I know because I read the manual for an old IBM PC system, and that info is still true today. With all systems, there must be something somewhere that says what's where. It's typically called something like a "Technical Reference Manual."

For example, the Xilinx Zynq Ultrascale+ Technical Reference Manual says it has a Realtime Clock (RTC) at 0xFFA6'0000. I'm gonna guess if you tell the QNX rtc utility to get the current "wall-clock" time from the xzynq RTC, it knows it needs to mmap() that physical address to get access to the RTC hardware.

Let's mmap() This Thing, Already!

Ok, we have enough to call mmap()!

As a person who is not fond of magic numbers, we'll start nonetheless with:

    const off_t  paddr = UINT64_C(0x000B8000);
    const size_t len   = 0x0FA0;
    const int    prot  = PROT_READ | PROT_WRITE | PROT_NOCACHE;
    const int    flags = MAP_SHARED | MAP_PHYS;

    void * const vaddr = mmap(NULL, len, prot, flags, NOFD, paddr);
    assert(MAP_FAILED != vaddr);

Now let's print the virtual address returned, and confirm the physical address backing the mapping by using the mem_offset() function (which we saw in #12):

    off_t queried_paddr = 0;
    int rv = mem_offset(vaddr, NOFD, len, &queried_paddr, NULL);
    assert(0 == rv);

    printf("vaddr = 0x%16.16" PRIX64 "\n", (uintptr_t)vaddr);
    printf("paddr = 0x%16.16" PRIX64 "\n", queried_paddr);

Run that, and we get:

vaddr = 0x0000004FB0167000
paddr = 0x00000000000B8000

vaddr makes sense. User canonical, and within the range that QNX manages for user canonical, i.e. < 0x0000'0080'0000'0000.
paddr is exactly what we wanted and expected. All good!

And? So What?

Well, this is where I reveal that I purposely chose this range because it is a "video frame buffer". i.e. it's RAM, but, it's frequently referenced by graphics hardware (aka "graphics card"), and the contents are interpreted to create a graphical display. Therefore, when we write to it, we want all writes to the RAM to be immediate so that the effects on the graphical display are immediate too; cache would prevent that from happening.

I do not (at the moment) want to get into the whole schemozzle of graphics, other than to say, for the sake of "Let's see mmap()ing physical address ranges in action ASAP", that, for the default configuration of the graphics hardware:

  • This range of physical memory is interpreted by the graphics card as an array of 16-bit values, of length 2000
    • i.e. 2000 * sizeof(uint16_t) = 4000 = 0xFA0.
  • Why is it length 2000? Because it's actually an array of 25 x 80
    • i.e. 25 rows and 80 columns.
  • Each 16-bit value represents:
    • a character to be displayed (in ASCII), and
    • two attributes of the character:
      • the foreground colour (colour of pixels that are part of the character), and
      • the background colour (colour of pixels that are not part of the character).

This buffer is rendered by the graphics card as an 25 x 80 array of characters. Think old skool DOS, or terminal.

This can all be represented in code using something like this:

typedef uint8_t txtdisp_color_t; // No uint4_t

typedef struct txtdisp_char_attr {
    txtdisp_color_t foreground: 4;
    txtdisp_color_t background: 4;
} txtdisp_char_attr_t;

static_assert(sizeof(uint8_t) == sizeof(txtdisp_char_attr_t));

typedef struct txtdisp_char {
    uint8_t             c;    // actual character
    txtdisp_char_attr_t attr; // character attribute
} txtdisp_char_t;

static_assert(sizeof(uint16_t) == sizeof(txtdisp_char_t));

The colours are not the usual RGB triples you see with web pages, like #003153. They're indexes into an array of pre-defined colours, aka a palette of colours:

// Colour palette
enum {
    TXTDISP_COLOR_BLACK,
    TXTDISP_COLOR_BLUE,
    TXTDISP_COLOR_GREEN,
    TXTDISP_COLOR_CYAN,
    TXTDISP_COLOR_RED,
    TXTDISP_COLOR_PURPLE,
    TXTDISP_COLOR_BROWN,
    TXTDISP_COLOR_LIGHT_GRAY,
    TXTDISP_COLOR_DARK_GRAY,
    TXTDISP_COLOR_LIGHT_BLUE,
    TXTDISP_COLOR_LIGHT_GREEN,
    TXTDISP_COLOR_LIGHT_CYAN,
    TXTDISP_COLOR_LIGHT_RED,
    TXTDISP_COLOR_LIGHT_PURPLE,
    TXTDISP_COLOR_YELLOW,
    TXTDISP_COLOR_WHITE,
};

Declare a few more things:

static txtdisp_color_t  s_foreground = TXTDISP_COLOR_LIGHT_GRAY;
static txtdisp_color_t  s_background = TXTDISP_COLOR_BLUE;

static const uint8_t    NUM_ROWS  = 25;
static const uint8_t    NUM_COLS  = 80;
static const unsigned   NUM_CHARS = NUM_ROWS * NUM_COLS;

static txtdisp_char_t * s_display_buffer;

If we store the value returned by the mmap() in s_display_buffer, we can initialize the video buffer:

    // Fill with blank chars
    const txtdisp_char_t blank = {
        .c    = ' ',
        .attr = { .background = s_background,
                  .foreground = s_foreground },
    };

    for (uint8_t r = 0; r < NUM_ROWS; r++) {
        for (uint8_t c = 0; c < NUM_COLS; c++) {
            s_display_buffer[get_index(r,c)] = blank;
        }
    }

where get_index() is:

// Get index of a txt_disp_char_t into buffer based on row, col position
static
unsigned
get_index(const uint8_t row, const uint8_t col)
{
    assert(row < NUM_ROWS);
    assert(col < NUM_COLS);
    const unsigned index = ((unsigned)row * NUM_COLS) + col;
    assert(index < NUM_CHARS);
    return index;
}

The above initialization code, plus the mmap() above, can be put into a function called, say txtdisp_init(), but, when it comes to the graphics hardware, we're really relying upon the initialization done by the firmware on the target, and piggy-backing off of that.

Configure QEMU To See The Display

Back in the first article, "Prepare A Basic System" we said "We don't need a fancy display, yet, so, we'll say -display none".

Well, now we need a fancy display so we can see how the graphics card interprets this video buffer.

NOTE: QEMU is emulating the graphics card.

This seems to be one of those things that has changed in QEMU across versions, but, from what I can tell the most portable solution is to use the QEMU command-line parameter -display vnc=localhost:1.

This tells QEMU to fire up a Virtual Network Computing (VNC) server on this computer (localhost) on port 5901 (:1, i.e. 5900 + 1). This VNC server will tell any client connected to it what exactly is being output by the graphics card.

Any VNC client will work. I'll use Remmina, but, whatever works for you is fine.

Veni, Vidi, Video.

After we fire up QEMU with our QNX image, we then need to connect to QEMU's VNC server with Remmina. This can be done either

  • via the GUI by selecting "VNC" in the dropdown, then localhost:5901, or
  • from the command line using remmina -c vnc://localhost:5901

You should see something like this:

Maybe a diagram of who's talking to whom will help:

The terminal is where we're running QEMU and using QEMU's stdin and stdout as the connection to our target's UART. Remember this whole song-and-dance?

terminal <--> QEMU stdin/stdout <--> target UART <--> startup callout <--> /dev/text resource manager <--> program stdin/stdout

The VNC client is how we're seeing what's being output by the target's graphic card.

Let's Run Our Program!

After we tell kkissh to run our program (which I called graphics_test)

non UEFI or UEFI+CSM boot
ACPI table not found (0x4746434d)
overriding mask for controller 2, vector_base 0
syspage::hypinfo::flags=0x00000000
kkissh: Built on Aug  2 2025 @ 14:48:38: pid 2
/ # graphics_test
vaddr = 0x000000150D25B000
paddr = 0x00000000000B8000
/ #

we see this:

It worked!

Maybe Something Interesting?

With a bit more work, we can create some infrastructure to easily "send text" to the display:


// The current position within the array where the next
// character to be displayed will be placed
static uint8_t  s_curr_row = 0;
static uint8_t  s_curr_col = 0;

// Display a character, and update current position.
static
void
txtdisp_putc(const char c)
{
    assert(s_curr_col < NUM_COLS);
    assert(s_curr_row < NUM_ROWS);

    if ('\n' == c) {
        s_curr_col = 0;
        s_curr_row++;
    } else {
        const unsigned index = get_index(s_curr_row, s_curr_col);
        s_display_buffer[index].c = c;
        s_curr_col++;
    }

    assert(s_curr_col <= NUM_COLS);
    assert(s_curr_row <= NUM_ROWS);

    // Update current position
    if (NUM_COLS == s_curr_col) {
        s_curr_row++;
        s_curr_col = 0;
    }
    if (NUM_ROWS == s_curr_row) {
        // Put back to last row
        s_curr_row--; // TODO Scroll?
    }

    assert(s_curr_col < NUM_COLS);
    assert(s_curr_row < NUM_ROWS);
}

// Display a '\0'-terminated string
static
void
txtdisp_putstr(char const * s)
{
    char c = *s;
    while (c != '\0') {
        txtdisp_putc(c);
        s++;
        c = *s;
    }
}

// Display '\0'-terminated string, then append newline.
static
void
txtdisp_puts(char const * const s)
{
    txtdisp_putstr(s);
    txtdisp_putc('\n');
}

and then we can easily output some text!

    txtdisp_init();

    txtdisp_puts("See? It doesn't take rocket appliances");
    txtdisp_puts("to display things on the screen.");

That gives us this:

Mission Accomplished?

That's enough to be dangerous with. You can experiment with scrolling, tabs, colours, clearing the screen, looping to receive text and responding to it (e.g. up arrow). Let your curiosity guide you!

I'll also note that, yes, there is a blemish in the image: the blinking cursor. The cursor is a feature supported by the graphics hardware. Fixing that – either turning it off, or updating its position – will require getting more into the configuration of the graphics hardware. More on that later after we cover some more topics.

But, the objective right now is understanding mmap() to get access to specific physical address ranges, and it looks like we've shown we can do that!

Coming Up..

Later we'll get more into MAP_PRIVATE and MAP_SHARED, and how they can be handy, or harmful.

After that, I'll briefly talk about typed memory as a way of saying "I want a specific range of physical memory, but, I'll let you, dear QNX, handle the lion's share of that because I don't really care about absolute addresses, I just know what I want."