My previous entry about the Nokia 5110/3310 LCD was more a storytelling session than going into the technical parts or the source code. Well, speaking of source code, the Github repository does cover the main usage concepts, but I still wanted to add a few paragraphs here now.
After finishing the initial proof of concept and dumping the example code on Github, I had another look and tried to see what parts could be optimized. After all, the very core idea was to reduce the program memory size to display XBM image based animations, so why stop here yet.
I was curious to see if I could fit the crappy-face-with-wobbling-eyes example animation into an AVR chip with only 2kB of flash memory, like an 8pin ATtiny25. That, however, will require one crucial adjustment regardless of program memory.
Cutting down on RAM usage
The original example was targeted for an ATmega328 with 32kB program memory and 2kB RAM. So if this goal succeeds, the whole example would fit into the RAM alone - which makes no sense in practice, and it doesn't work that way, but just to put in perspective. Especially putting in perspective what a spoiled fat cat these amounts of resources can make you.
Sticking with the ATtiny25 as example target, the available RAM amount drops to a sad 128 bytes. That amount couldn't even store a full-length tweet, let alone the full display memory content. In fact, this is how far 128 byte display memory would get us:
Not to mention that filling up all the RAM like that would mean that there's no space left to have a stack. Kiss your local variables and functions altogether goodbye right here and now.
Long story short, the LCD memory content can't be stored inside the code with that little RAM (and shouldn't be stored there either with just slightly more RAM than data, i.e. 512 bytes RAM, due to stack growth). Luckily that step isn't really necessary, and the LCD memory can to be updated by reading the image data directly from the program memory and sending it straight to the LCD.
So let's take the example's initial implementation that keeps the LCD memory in RAM, copies the given data into that and then sends the whole thing to the LCD via SPI:
/* internal LCD memory buffer */
unsigned char nokia_lcd_memory[LCD_MEMORY_SIZE];
void nokia_lcd_fullscreen(const uint8_t data[])
{
memcpy_P(nokia_lcd_memory, (PGM_P) data, 504);
nokia_lcd_update();
}
static void nokia_lcd_update(void)
{
uint8_t x;
uint8_t y;
for (y = 0; y < LCD_Y_RES / 8; y++) {
spi_send_command(0x80); // set X addr to 0x00
spi_send_command(0x40 | y); // set Y addr to y
for (x = 0; x < LCD_X_RES; x++) {
spi_send_data(nokia_lcd_memory[y * LCD_X_RES + x]); // send data
}
}
}
Now let's get rid of all the nokia_lcd_memory
related parts, which in the end will be simply one function directly sending the given image data to the LCD:
void nokia_lcd_fullscreen(const uint8_t data[])
{
uint8_t x;
uint8_t y;
for (y = 0; y < LCD_Y_RES / 8; y++) {
spi_send_command(0x80); // set X addr to 0x00
spi_send_command(0x40 | y); // set Y addr to y
for (x = 0; x < LCD_X_RES; x++) {
/* read data straight from PROGMEM variable and send it */
spi_send_data(pgm_read_byte(&(data[y * LCD_X_RES + x])));
}
}
}
The nokia_lcd_memory
char array is however also used within the frame diff update functions for animations. I'll come back to that later. For now, we'll be using full screen animations, i.e. call ./xbm2nokia.sh -g
. Yeah, this is completely counterproductive as it will more than double the program memory size, but bear with me.
ELF binary sections and sizes
Assuming full screen animations were created from all 9 frames x1.xbm-x9.xbm, the avr-size
command will reveal the differences:
$ avr-size v?/example.elf
text data bss dec hex filename
5054 0 523 5577 15c9 v1/example.elf
5038 0 19 5057 13c1 v2/example.elf
A few words on what each section means:
text
stores the actual compiled program codedata
stores all initial values for global variables that aren't zerobss
defines the size of all global variables that are uninitialized or initialized with zerodec
is the total size of the executable includingtext
,data
andbss
hex
is the same asdec
, just as hexadecimal value
As we can see, the bss
size decreased drastically, by 504 bytes, the size of the internal LCD memory buffer. If you're wondering why data
and bss
are separated, it's because the bss
section won't actually make it into the final Hex file that is flashed to the microcontroller. The C runtime library will - among other things - take care that the RAM is properly initialized before calling main()
, by copying data
content to it, and zeroing everything in the bss
section. So only the location and size of that zero-initialized memory section needs to be known.
To verify that the bss
won't be flashed, have a look at at the final Hex file created from the ELF file, which will be flashed in the microntrollers program memory. If you know about the Intel Hex file format itself, you might be able to just look at the file, alternatively, avr-size
works here as well:
$ avr-size v?/example.hex
text data bss dec hex filename
0 5054 0 5054 13be v1/example.hex
0 5038 0 5038 13ae v2/example.hex
Well, it's not an ELF file, so everything is shown as data
section content, but it's still good enough to see, that these numbers are both times exactly the same as the ELF file's text
section was. Since there is nothing stored in the data
section, this adds up. If you have doubt, just add some int abcd=123;
as global variable and see how to resulting 2 bytes in the data
section (since int is 16 bit in AVR) add up accordingly.
But regardless of being part of the to-be-flashed Hex file or not, it will be part of the size that is required in them RAM.
Coming back to animations
Well, as we can see from the avr-size
output, eliminating the code that copies the image data into the internal buffer saved us a whooping 16 bytes in program memory. Too bad that's going to change.
As I mentioned before, the functions that handle the frame diffs for animations still use the nokia_lcd_memory
buffer. There are actually two different implementations of this functionality: one that sends the full buffer content via SPI after updating all its content based on the frame diff, and one that sends the new display data to the display while it is processing the diff data and updating the internal buffer.
Getting rid of the internal buffer will leave us with option two as only choice. This however will require additional address calculations that will increase the program size but around 160 bytes.
This way, we also won't need separation between those two choices any longer, and everything related to the NOKIA_GFX_ANIMATION_FULL_UPDATE
preprocessor defines can be removed from both nokia_lcd.h
and nokia_lcd.c
and all that will be left is:
void
nokia_lcd_update_diff(const struct nokia_gfx_frame *frame)
{
uint16_t i;
...
diff.data = pgm_read_byte(&(frame->diffs[i].data));
/* nokia_lcd_memory[diff.addr] = diff.data; */
...
}
Okay, time to recreate the image data for animations and recompile
/path/to/example$ ../xbm2nokia -a x?.xbm
/path/to/example$ cd v1
/path/to/example/v1$ make distclean
/path/to/example/v1$ make
/path/to/example$ cd ../v2
/path/to/example/v2$ make distclean
/path/to/example/v2$ make
/path/to/example/v2$ cd ..
/path/to/example$ avr-size v?/example.elf
text data bss dec hex filename
2210 0 523 2733 aad v1/example.elf
2352 0 19 2371 943 v2/example.elf
/path/to/example$
So, we got rid of 504 bytes RAM and gained 142 bytes program memory. This sucks, but if RAM is scarce, we haven no choice - and in this case, anything under 1kB RAM would be not enough (as there's usually nothing between 512 and 1024 bytes with AVRs)
Adjusting for specific hardware
Before digging into assembly code and analyzing the compiled binaries, some other optimization places can be considered, especially when having a closer look at the hardware. In this case, it's just the LCD connected to the AVR. And that's exactly the point, it's the only thing connected. There is no other SPI slave to communicate with, so whenever we do communicate via SPI, it's always the LCD.
So, there is no reason to control the chip select / chip enable signal, we can simply connect the LCD's CE pin to ground (since it's active low) and remove all code toggling its state.
Getting rid of chip select handling
Handling the signal is done via preprocessor macro that just toggles the pin. The easiest way is to just delete what the macro is doing, i.e. change
#define spi_cs_high() do { PORTB |= (1 << PB2); } while (0)
#define spi_cs_low() do { PORTB &= ~(1 << PB2); } while (0)
to
#define spi_cs_high()
#define spi_cs_low()
Or alternatively fully delete every occurrence of either macro - the result will be the same: 20 bytes less content in the text
section. (I did seem to run into some signal stability here as the animation was some pixels off in some places. I might just blame it on the shitty breadboard.)
An alternative in-between the previous handling and grounding the pin is to simply set the pin output to zero before sending the first command in the display init function. This way there's no need to toggle the pin before every single command or data sending.
A second candidate here is the LCD reset signal. Instead of using the microcontroller pin to drive the signal on startup, the LCD reset pin can be connected to an RC element that will keep the signal long enough in low state during power-up.
Getting rid of LCD reset handling
Connecting an RC element (as in resistor and capacitor) between the supply voltage and LCD reset pin will delay the signal rise long enough to count as actual reset signal. According to the LCD driver's datasheet, a low pulse of at least 3μs is required for this. Googling for "RC delay calculator" or "RC time calculator" should point you to formulas and online calculators to figure out good R and C values - ladyada's old website has one for example.
I had a 22kΩ resistor and 4.7μF capacitor lying next to me - that combination works most of the time. However, sometimes, the display remains blank and I have to try again by with a power cycle. Keep in mind here, while basically anything over the required 3μs is okay, the LCD driver needs to be out of the reset state by the time the first command is sent via SPI.
Removing the pin handling: 4 bytes off
Removing the delay between the signal change: 12 more bytes.
Removing the nokia_lcd_reset()
function altogether: 6 bytes!
Yeah, we're obviously not getting anywhere with that.
But then, if really thinking about a device like the ATtinyX5 with just 8 pins, reducing the number of required pins can be yet another crucial part.
You will not believe what happens next!
The only pin left to look at as the D/#C signal that toggles between command and data transfer with SPI. The actual SPI data and clock signals are fully in control of the AVR's SPI hardware module, so not much to do with these pins.
Since D/#C signal needs to be high for data and low for commands, they need to be controlled inside the microcontroller firmware. But looking at my initial implementation of spi_send_data()
and spi_send_command()
, some parts are identical in both functions. In fact, everything but setting the D/#C signal level itself is identical. This calls for the next logical step: combine the common parts.
This could end up as something like this:
static void
spi_send(uint8_t data)
{
SPDR = data;
while (!(SPSR & (1 << SPIF))) {
/* wait */
}
}
static void
spi_send_command(uint8_t command)
{
spi_dc_low();
spi_send(command);
}
static void
spi_send_data(uint8_t data)
{
spi_dc_high();
spi_send(data);
}
So at this point we had reached 2310 bytes text
section size. Now, with this change in place, we get this:
text data bss dec hex filename
2310 0 19 2329 919 example.elf
Yes. As it turns out, the compiler optimization was doing this re-arrangement all along.
To see the real impact of this change, we could disable the compiler optimization by removing the "-Os
" flag from from the CFLAGS
variable. This will actually result in a compiler warning as it's generally a bad idea to disable compiler optimization.
text data bss dec hex filename
3536 0 19 3555 de3 example--duplicate-parts.elf
3528 0 19 3547 ddb example--shared-common-part.elf
Well, no significant difference, but still, separating the shared common parts into its own function saves a few bytes. And so does very clearly the compiler optimization, so let's put that back into the Makefile.
There probably is a lesson in here. Not every seemingly obvious optimization will actually have any impact? Something like that. And, well, I guess when it comes to assembly code in general, the compiler will probably do a better job than I ever could. At least from code size point of view, but that's the point here - speed optimization would be a different story.
Digging into the binary and assembly files
So those were the obvious hardware related adjustments. As I mentioned in the initial article on this, changing the animation part into a split frame and therefore being able to store the 16bit address data in 8bit would be another improvement on the code size.
But what comes next? What would be our options after all the (more or less) obvious parts have been optimized?
To answer that, you'll have to know where the program-space-hungry parts actually are. Two places to look at that are each source code file's assembly listing, i.e. what the compiler will turn the C code into, and the linker map file, showing more details on the resulting binary's memory spaces.
The example code's Makefile is set up to generate both of those, i.e. .lst
files for each source file's assembly code, and a .map
file from the linker. There are other tools as well, for example avr-objdump
and avr-readelf
will give similar information on the binary file.
Well, any one of those mentioned tools and files are worth their own article, so I won't be going into details on them at this point. Just have a look at the .lst
and .map
files to get some idea. It's all standard gcc related stuff, so documentation will be plentiful available on the internet.
Final words
Code optimization can be a tedious task and at some point it might be wise to just settle on compromises - provided size constraints allow that (or speed constraints, but again, speed optimization is a different story). If you want to use a non-SMD ATmega with small enough footprint (like a DIP-28 package), 32kB program memory will be as good as it gets. If your current code needs more than that, you have to get creative.
And if your code does need less than it, well, you might be able to use a smaller member of the family and save a few cents on the component. But if you're a hobbyist, you probably won't really benefit from those savings and are better off with the bigger devices in the long run.
But regardless of actual size constraints, code optimization is worth to have a look at, because in order to optimize your code, you need to know what you're doing, and you need to know your system. And this works the other way around as well, so trying to optimize your code will make you force to get to know your system, and it's definitely a place to learn new tricks.
All this is especially true in embedded systems and microcontrollers, but can also help you write more efficient code on less constraint system. Careful though, balance is key here; sacrificing all forms of readability and understandable logic to gain 1% is probably a bad idea unless you're at 101% capacity.