Close
0%
0%

AVR-Laptop

Pushing an 8-bit MCU far beyond what it was designed for

Similar projects worth following

AVR-Laptop

💻 This is a laptop built on an Arduino-compatible board based on the ATmega2560.

⚙️ AVR is usually associated with simple things: relays, sensors, LEDs. Multitasking, UI, video playback — not what you'd expect from it. AVR-Laptop is an attempt to do exactly that on it, to show what such a device would look like.

📐 The device measures 12×11×3.4 cm, with a 3D-printed case. Inside there's a homemade QWERTY keyboard, a monochrome 128×64 LCD, a VS1053 with speakers, a microSD, an RTC, and an 18650 battery. The firmware is based on FreeRTOS.

What it can do:

🧵 Multitasking. Real multitasking, with Alt-Tab switching. Start a video, minimize it — the sound keeps playing while you play Sokoban.

🪟 UI engine. Forms, buttons, checkboxes, lists, input fields, dialogs. Handling key presses, tabbing, redrawing, events dispatched to the owner task.

🧰 A set of tasks. File manager, video player, music player, clock, calculator, Sokoban, Snake, Game of Life.

🎬 Video player. Comfortably handles 24 fps video (and more) with sound. A custom format: XBM stream + MP3 in a single file.

🧠 All of this runs on 8 KB of RAM, half of which is already taken up by global variables (the video buffer, the SD buffer, FreeRTOS itself, and other service variables). Free memory is very scarce, and this defines the architecture entirely — from how data is laid out to how the tasks are structured.

🧩 But at the same time, the architecture is open for extension. A new task is a class with setup and loop, like in a regular Arduino sketch, plus a couple of methods for the menu and an icon. Everything else — registration, stack, switching, events — is handled by the shell. Tasks don't know about each other: they receive events and draw through the UI engine. So a new task can be added without touching the others.

📝 This is only the top layer — there are a lot of interesting details accumulated, and I'll describe some of them in the logs. If there's something you're particularly interested in — ask in the comments, I'll try to answer. And below is a short demonstration of it working.

🚧 I'm currently making the final edits before publishing on GitHub — polishing the text editor, finalizing the schematics and documentation.

  • Just thinking out loud

    Vladimir Tsibrov • a day ago • 0 comments

    I have created 126 square meters of soil. I have created a device based on the ATmega2560 with a multitasking shell and a set of tasks. But every task needs memory to run. The ATmega2560 has only 8 KB of SRAM, and almost all of it is taken. So I need a lot more memory. Good thing I know the recipe: take external SRAM, a latch register, and hook it up to the XMEM interface.

    Originally, I planned to add external memory in the next version of the device. And I already have a bunch of ideas for it: external memory, a bigger display, a separate controller for the display, maybe even a bus for external devices. But right now it feels like the current memory limitations don't let me call this version logically complete: the file manager is limited in path length and nesting depth; in some cases I even use the video buffer as a data buffer (when copying files, I read data from SD into the video buffer without drawing it to the display, then write it to a new file); the text editor with a tiny working buffer is just painful. So I'm considering freezing the current state as v1 and adding external memory as v1.1. All other major improvements will stay for the second version.

    Also, adding external memory would let me move the video buffer (which takes 1 KB) into it. Access will be slower than with internal memory. But on the other hand, most screen changes are triggered by user actions (key presses) and aren't critical to redraw speed. Meanwhile, a whole kilobyte would be freed up in internal memory, which could be used for FreeRTOS task contexts (moving the contexts themselves to external memory is definitely not worth it).

    The problem is that there's no room in the case for another board (memory and latch), so it has to grow. I could replace my 128×64 display with a 192×64 one — that goes well with enlarging the case and justifies it (a small display in a big case would look out of proportion). I already have such a display, but I haven't worked with it yet. Judging by the CS1, CS2, CS3 pins on its board, I suspect it's based on the KS0108: three of these controllers handle a 64×64 area of the display.

    An important point — as far as I know, this controller draws pixels not horizontally (like my current ST7920), but vertically: the first byte draws a vertical column of 8 pixels, the second byte draws the column to its right, and so on to the end of the page. This differs from the memory layout of my video buffer — it's horizontal. So the question is: change the video buffer layout so it mirrors the display memory, but that would require changing all graphics functions, all the way up to XBM rendering and my video converter. Or keep the current layout and logic, but transpose the data when sending (or rather, before sending, so the ISR takes already-prepared bytes). I prefer the second option — if I can implement it with minimal overhead.

    Another point — my dirty-line mechanism. In its current form it doesn't fit the way KS0108 works, since sending will be by pages, not by lines. Also, right now I have 64 lines, whereas this controller has 8 pages — that's too coarse for a dirty-region mechanism. Although I have 3 controllers, which gives a logical split into 3 physical zones of the display, i.e. that's already 24 dirty regions. Still coarse...

    Regarding the three controllers and three CS lines — it would be interesting to test the idea of sending data alternately to each of them. That is, after sending a byte to the first controller, don't wait for it to become free, but check the readiness of the second and send a byte to it. Then the same with the third controller. Maybe this will minimize waiting time and speed up frame sending.

    So adding XMEM turns into a series of serious improvements. But it also opens up new possibilities for the firmware and tasks. I already have the memory itself — HM628512BLFP-7, 512 kilobytes, which is far more than the ATmega2560 can address (64 KB). So I plan to do bank...

    Read more »

  • Switching to FreeRTOS and Building a UI Engine

    Vladimir Tsibrov • 10/03/2026 at 14:40 • 0 comments

    I've written about the video player before. But I was working on other things in parallel too — in particular, building out the file manager: added a context menu, copy, delete, create directory, and so on. A lot of these operations meant talking to the user: entering some text, confirming things like "delete this file? yes/no." And in the code that turned into a separate chunk of UI for every single operation.

    I didn't have a ready-made set of widgets. Every task rolled its own buttons and fields: drew them, parsed key presses, assembled dialogs out of them. Which, of course, scaled terribly — the more tasks, the more copy-pasted code written from scratch.

    The fix was obvious: I needed an engine that hands tasks a ready-made tool for building interfaces. The task describes what it wants to show, and everything else — drawing, key handling, focus switching, dispatching events — is handled by one shared mechanism.

    But there was a second thing, and it turned out to be the bigger one.

    The device was originally meant to be single-tasking, with each task owning its own screen. As long as that was its territory, everything was predictable: it decides when and what to draw.

    Then the dirty-rows mechanism and the lcd.service() method came along, and part of the job moved to the shell. Now the shell has to be called regularly, no matter what the current task is doing. And a single-tasking model gives you no such guarantee: if a task doesn't return control, the shell can't service the display, and the rows just stay undrawn.

    Easiest to show with an example. File manager operations — copy, delete — needed user confirmation. In code that was a blocking loop: the task sat there until the user hit "yes" or "no." And the whole time, the shell got no control and couldn't call lcd.service().

    I could technically have stuck a service() call into every long loop, but that's a hack: by design the shell should get control on time, and the shell is what calls service() — not the task.

    So I went looking for other options. I looked at coroutines, but quickly realized that wasn't it. Their model makes the task track where it yields and mark those spots with macros. What I wanted was for task code to read like plain sequential code and not care what other tasks are doing. So I dropped coroutines pretty much immediately.

    In the end I landed on FreeRTOS.

    Switching to FreeRTOS and building the UI engine wasn't a patch job — it was an architecture change. So the project had to be rewritten from scratch, and I started with the UI engine.

    First came the basic widgets: button, checkbox, input field. I used them to debug all the mechanics: how a widget is created, how it takes focus, how it redraws, how it reports events — its whole lifecycle. The structure came together along the way: the form as a container, owner of the widgets and the access point to them for the UI engine, plus a stack of forms on top, so a task can open new screens over the current ones.

    Now a task could throw an interface together from ready-made widgets. But it also needed a set of standard dialogs: message box, value input, and the "open" and "save" file dialogs.

    Alongside the UI engine I was reworking the rest of the code. FreeRTOS dragged edits through the entire project — it touched literally everything where the code assumed only one scenario was ever running.

    There were a lot of those places. At times it felt like I was writing code and making progress, when really I was just getting further from the finish line, because new wrinkles kept popping up.

    Memory deserves its own mention. In the single-tasking system there was plenty, and I never thought about SRAM. Now I was pinching bytes everywhere: shrinking task stacks, reworking data structures, hunting for whatever else could go. Every task had to get by on the bare minimum.

    As for multitasking — FreeRTOS fixed the blocking loops and the lcd.service() call, but the user still couldn't see any of it....

    Read more »

  • Video Playback

    Vladimir Tsibrov • 09/29/2026 at 20:27 • 0 comments

    Initially, I didn't set out to play video. The interest came when it became clear that the display could be redrawn fast enough. At first, outputting a 128×64 XBM image from flash took about 40 ms; after optimizing the data transfer and reducing delays, it came down to around 32 ms per frame. That was already enough to try video.

    I wrote a converter in Python: it extracted frames from a video file, converted them to monochrome 128×64 using simple brightness thresholding, and saved the result to a file. On the device, all that remained was to open this file from the microSD, read it in 1024-byte blocks into the video buffer, and send them to the display. A separate challenge was hitting the source video's fps exactly. The difficulty wasn't that the AVR couldn't keep up, but that frames had to be swapped at the right moment.

    The first playback attempts weren't perfect (brightness thresholding is just terrible), but they showed that the microcontroller and display handle 24 fps easily — a typical frame rate for most video. Then came improvements to the converter: besides threshold binarization, I added other methods — Otsu, Sauvola — which gave better results. The next step was an attempt to convey halftones by blinking pixels between frames: a pixel isn't lit in every frame, and over several frames the brightness averages out to the desired shade. I later learned that this method has existed for a long time and is called temporal dithering. With it, I managed to squeeze the most out of the monochrome 128×64: the picture got much better, especially in close-ups.

    Once the video pipeline was done, I started looking into audio playback. The VS1053 audio decoder became part of the device at this stage. I placed the speakers in the display module's enclosure without changing the device's dimensions. I was already familiar with the VS1053, but this decoder has a quirk: it needs to be constantly fed audio data, or the sound simply cuts out. Meanwhile, my repaint function that output a frame to the display was a loop over the video buffer and blocked the CPU for about 32 ms. Most of that time, the processor was just waiting for the display to become ready before sending the next byte.

    According to the ST7920 datasheet, almost all controller instructions take 72 µs to execute — though in reality it's noticeably less, otherwise sending a single frame would take 1024 × 72 = over 73 ms, not counting address setup commands.

    I ran a small test: I added code to the display send function that measured how long the display stays busy, and got values from 8 to 40 µs. Setting the control signals and outputting a data byte to the port takes just a few hundred nanoseconds, meaning the waiting eats up more than 99% of CPU time.

    The VS1053 has a data request pin that could be used to feed the decoder from an interrupt. But reading the SD card is an operation orders of magnitude longer than servicing audio, and inside an ISR it would break the timing and hit fps.

    The obvious move was to shift display data transfer into a timer interrupt. Display readiness time fluctuates from 8 to 40 µs, so I set the timer interval to the lower bound, and moved the readiness check inside the handler, before sending the next byte.

    Along the way, I dropped the explicit repaint call in favor of a dirty-line mechanism: graphics functions mark changed lines, the send routine clears those marks, and service() picks the next dirty line and starts its transfer.

    The solution has two parts:

    • ISR on Timer 5: minimal code to check display readiness and send the next byte. It's called every 8 µs, that is 125,000 times per second, so it has to run fast.
    • Function service(): called regularly, but far less often than the ISR. If it finds a dirty line, it sends address setup commands to the display, sets up pointers, and starts Timer 5.

    After the line is sent, the ISR stops the timer itself. So the ISR is a pump that...

    Read more »

  • Building a Keyboard

    Vladimir Tsibrov • 09/28/2026 at 08:00 • 0 comments

    Building the keyboard was one of the trickiest parts of the project: tracing the PCB, fabricating it, soldering, and applying symbols to the keys. But one thing at a time.

    Since I was crazy enough to aim for a laptop, I needed a full QWERTY keyboard. I settled on a character set — 64 keys in total, which fits neatly into an 8×8 matrix, requiring two 8-bit ports from the microcontroller (fortunately, free ports are not in short supply on the ATmega2560). But in reality the keys were arranged in an 11×6 rectangle, which made routing less than convenient. I did the schematic and layout in the online editor EasyEDA. Then came milling the board on a CNC3018 and soldering the switches (this was my first attempt at making a double-sided board with UV-curable solder mask, so not everything went perfectly).

    Originally I had the idea of making the keyboard with keycaps as a single solid module, but I later abandoned it. As for the key labeling — I printed the symbols on sticker paper, cut them out, and glued them on.

    Scanning the keyboard is the classic matrix approach. The microcontroller activates each column of the matrix in turn, pulling it low, and while that column is active it reads the state of the rows. If a key in the current column is pressed, the corresponding row gets pulled low — that's how the press coordinate is determined. Scanning runs continuously, column by column, at a rate of hundreds of hertz — standard practice for matrix keyboards. I added debouncing in software: a key's state is only confirmed after several consecutive scans with an unchanged value, which filters out false triggers.

    Later I decided to rework the keyboard: add a diode to each key for more reliable detection of key combinations, and also switch column selection to a 4-to-16 decoder. Now four pins are enough to select a column, and there's headroom to expand the keyboard to 16 columns; there's room on the rows too — currently there are 6, but reading a whole microcontroller port makes it easy to bump that up to 8 — might come in handy in future versions of the project. This time the keyboard came out single-sided and very tidy. The decoder went on a separate board. And for future versions I'm planning to use SMD switches and put the decoder and a dedicated microcontroller for key scanning on the back side.

    Over time the stickers started peeling off on the most-used keys. So I started looking for a new way to apply the labeling. I experimented with different methods: printing symbols with a 0.25 mm nozzle, burning away paint with a laser, exposing UV-curable solder mask through a negative mask, and toner transfer with solvent and pressure. In the end I settled on the method described here: https://hackaday.com/2025/01/02/dress-up-your-3d-prints-with-toner-transfer-labels

    The labeling came out crisp and durable. I'm planning to add a protective coating (probably clear lacquer — need to experiment), to protect the symbols from wearing off.

  • How It Started

    Vladimir Tsibrov • 09/27/2026 at 07:42 • 0 comments

    First, a few words about how the idea for this device came about. This will also answer questions like "why not STM32?" and "why not Raspberry Pi with a color display?"

    I've always been interested in the history of computing, retro computers, old laptops. I even have a few of them, the oldest being an i386. Looking at their specs, I couldn't help comparing them to the AVR, which runs at 16 MHz — more than many machines of that era. And the thought crossed my mind: what if I built a computer on an AVR/Arduino — what would it be like, what tasks could it handle? Obviously, it's not meant for that, but still? That idea is what started my project, and at first I didn't even imagine what would come of it.

    The first thing I tackled was the display. I chose a 128×64 monochrome display based on the ST7920 and started figuring out how to connect it, what it could do, its instruction set. In parallel, I was thinking about the enclosure. At first I wasn't sure about the controller: use a bare AVR, which would give me control over power and the ability to go into sleep mode (at the cost of designing and manufacturing my own board), or take a ready-made Arduino board (which would mean giving up on power-down). In the end I went with the second option and chose the Mega 2560 Pro Mini for its compact size. And most importantly — it makes the project easier to replicate: a person just buys the same board instead of soldering a controller with its supporting circuitry themselves.

    The ST7920 supports parallel (4-bit and 8-bit) and serial (SPI) modes. I chose parallel because it provides the maximum transfer speed. For the same reason, I abandoned general-purpose libraries and started writing my own display driver. I allocated a 1 KB video buffer in SRAM (exactly the size of a 128×64 monochrome frame), wrote methods to send its contents to the display, and added several graphics functions: from setting individual pixels to drawing primitives, rendering text, and outputting XBM images.

    The hardest part turned out to be print: supporting fonts of different sizes (up to 16×16) and rendering text from an arbitrary horizontal position — you can't just take a byte from the font and write it to the video buffer. You have to account for the font size and how many free bits are left in the current byte of the video buffer, shift the font data if it doesn't fit, and write the remaining bits into the next byte. Each character is a series of shifts, masks, and carries between bytes.

    Once the graphics functions were in place, I moved on to programming the shell: a menu with a list of available tasks, a base Task class describing the required methods — setup and loop — and other things that came up as I went along.

View all 5 project logs

Enjoy this project?

Share

Discussions

Similar Projects

Does this project spark your interest?

Become a member to follow this project and never miss any updates