Writing
GBA Assembly — 5. Timing, VBlank and animation
Make a square blink in sync with the display and understand VCOUNT, VBlank, polling and the difference between a frame and a CPU instruction.
The previous chapter's rectangle was drawn once. Animation requires repeated changes. Repeating as fast as possible, however, does not define a useful speed: the pace would depend on how much work each pass performs.
We will make the square alternate between green and red every 32 updates, following display timing. Use pisca.s with the same gba.ld and rom.py. The complete package includes them all.
Your route through this chapter
Before starting: the static rectangle from chapter 4 should work. This chapter adds time; it does not add controls.
- Save the complete
pisca.swith the same supporting files. Findmain_loop,wait_vblank, andtickas you read their explanations below. - Run the four commands under “Running and experimenting,” then open
pisca.gbaat normal emulation speed. - Watch the square for several color changes without pressing anything. Each color lasts 32 updates, about half a second.
- Find
tst r1, #32inmain_loop. Change only that mask to 64, save, and rebuild. Do not change the rectangle's width or height, which also use 32.
Checkpoint: the square stays in the same place and blinks more slowly after the edit. If its size changes, restore the file and edit the tst line instead. Restore mask 32 before continuing.
Video has its own clock
The video circuitry traverses the image's rows in a regular sequence. The GBA has 160 visible lines. These are followed by a vertical return interval called VBlank, before the next image begins. The screen does not turn black during that interval: it is a phase of the video cycle.
The VCOUNT hardware register at 0x04000006 reports the current line. Visible lines run from 0 to 159. The vertical blank interval runs from 160 to 227. The count then starts over.
0 ... 159 160 ... 227 0 ... 159
visible image | VBlank | next visible image
A complete cycle repeats approximately 59.73 times per second. That does not mean each CPU instruction takes one frame. The CPU executes many instructions while the video circuitry advances. We must synchronize their work.
Waiting for a transition, not just a value
For the wait commands, see load and store widths, CMP and TST, and B, BHS, and BLO.
The following code waits by checking repeatedly, also known as polling:
<!-- source: en/pisca.s#clock -->wait_vblank:
ldr r0, =VCOUNT
leave_vblank:
ldrh r1, [r0]
cmp r1, #160
bhs leave_vblank
enter_vblank:
ldrh r1, [r0]
cmp r1, #160
blo enter_vblank
bx lr
tick:
ldr r0, =frame_tick
ldr r1, [r0]
add r1, r1, #1
str r1, [r0]
bx lr
wait_vblank has two stages. It first waits for any ongoing VBlank to end, then waits for the next one to begin. Why leave before entering?
Imagine a function that only checks VCOUNT >= 160 and returns. Calling it again while the count is still in that interval would return immediately. The program could update many times within one VBlank. Waiting for the next transition avoids that.
ldrh reads the 16 bits of VCOUNT. bhs and blo perform the unsigned comparisons we studied earlier. The CPU stays in these loops while waiting: it is not sleeping or ceasing to consume cycles. Interrupts and BIOS calls provide other strategies later.
A counter in RAM
Reserving and initializing the counter uses assembler directives and loads and stores.
The tick routine increments frame_tick. The file reserves four bytes for it in the BSS, aligned to four bytes. During startup, the program walks from __bss_start to __bss_end and writes zero to each word.
This initialization is necessary because we cannot treat whatever RAM contains at power-on as our intended state. .space 4 reserves a location; it does not make the CPU execute an assignment.
The counter is not a hardware register either. VCOUNT belongs to video; frame_tick is a variable we created. Its value increases because our routine changes it once per loop iteration.
Making the square blink
This fragment uses TST and conditional execution with EQ. The flags section explains why the test result remains available after ldr.
The main loop in pisca.s is:
main_loop:
bl wait_vblank
bl tick
ldr r0, =frame_tick
ldr r1, [r0]
tst r1, #32
ldr r4, =0x001f
ldreq r4, =0x03e0
mov r0, #104
mov r1, #64
mov r2, #32
mov r3, #32
bl fill_rect
b main_loop
After waiting and incrementing, we inspect bit 5 of frame_tick using mask 32. This bit remains zero for 32 counts, then one for another 32. tst does not change the counter; it only updates flags.
We load red into r4. ldreq replaces it with green when the test result was zero. The previous ldr, without an s suffix, does not change flags, so the condition still comes from tst. The remaining registers receive the square's position and size, and the drawing routine writes the selected color.
The color changes every 32 frames, approximately 0.54 seconds. A complete green-and-red cycle takes 64 frames. We use the video cycle as our reference rather than counting how many unrestricted loop iterations fit in a second.
Running and experimenting
arm-none-eabi-as -mcpu=arm7tdmi -g -o pisca.o pisca.s
arm-none-eabi-ld -T gba.ld -o pisca.elf pisca.o
arm-none-eabi-objcopy -O binary pisca.elf pisca.gba
python3 rom.py pisca.gba
Open pisca.gba in mGBA at normal emulation speed. The square should alternate colors without changing position.
Change mask 32 to 64. Each color should last twice as long. Then try 16: it should last half as long. Use powers of two for this exercise; a mask such as 30 selects several bits and does not simply produce a 30-frame period.
For exactly 30 updates per color, you need a different rule: increment a counter, compare it with 30, reset it, and toggle a color variable. That is already an application of persistent state in RAM.
What VBlank solves, and what still needs measuring
Starting an update during VBlank does not guarantee that all work finishes within it. The interval is finite. A program that takes too long may write while the next image is already being produced or miss the next update.
Mode 3 does not automatically swap between two image pages. We are changing the same image the circuitry reads. This example performs little work per frame: just a 32-by-32 square. Repainting the whole screen repeatedly has a different cost that must be evaluated.
In a game, this separation helps: read inputs, update rules, and write the graphics data that needs changing. Game logic should not depend on how many instructions a drawing routine contains.
Tonc's video introduction explains line timing, VBlank, and polling-based synchronization. Our observable result is simple: a software variable follows a regular hardware event and determines a visible change.
Previous: pixels and rectangles. Next: using button A as the source of a change. Series index.