Register forum user name Search FAQ

Gammon Forum

Notice: Any messages purporting to come from this site telling you that your password has expired, or that you need to "verify" your details, making threats, or asking for money, are spam. We do not email users with any such messages. If you have lost your password you can obtain a new one by using the password reset link.
 Entire forum ➜ Electronics ➜ Microprocessors ➜ Putting constant data into program memory (PROGMEM)

Putting constant data into program memory (PROGMEM)

Postings by administrators only.

Refresh page


Posted by Nick Gammon   Australia  (23,057 posts)  Bio   Forum Administrator
Date Mon 29 Sep 2014 04:54 AM (UTC)

Amended on Wed 08 Oct 2014 02:30 AM (UTC) by Nick Gammon

Message
This page can be quickly reached from the link: http://www.gammon.com.au/progmem


RAM is precious on a microcontroller, PROGMEM (program memory) not quite as much so. On the Arduino Uno (using an Atmega328P) you have:


  • 1 kB of EEPROM
  • 2 kB of RAM
  • 32 kB of PROGMEM


Clearly, there is a lot more program memory than RAM.

Variables are copied into RAM at program startup


The problem with constants in general (including string constants like "hello") is that the compiler generates code to copy them into RAM at program startup, because most C functions do not expect to find data in PROGMEM. (It has to generate different instructions to access PROGMEM).

Benchmark memory use


To test memory use, I'll start off with a sketch that simply returns how much memory is free, without doing anything else. Tested on IDE 1.0.6, Arduino Uno, on Ubuntu.


#include "memdebug.h"

void setup ()
  {
  Serial.begin (115200);
  Serial.println ();
  Serial.print (F("Free memory = "));
  Serial.println (getFreeMemory ());
  }  // end of setup

void loop () { } 


Output:


Free memory = 1702


Since we started with 2048 bytes of RAM, already we have used 2048 - 1702 = 346 bytes. These are used as follows:


  • 34 bytes for the HardwareSerial instance (Serial)
  • 64 bytes for the Serial transmit buffer
  • 64 bytes for the Serial receive buffer
  • 4 bytes for the Serial transmit buffer head and tail pointers
  • 4 bytes for the Serial receive buffer head and tail pointers
  • 9 bytes for keeping track of millis / micros
  • 4 bytes for memory allocation (__malloc_heap_start, __malloc_margin)
  • 128 bytes for the heap safety margin
  • 6 bytes for a few nested function calls (main -> setup -> getFreeMemory)
  • 16 bytes for the compiler vtable for HardwareSerial
  • 4 bytes for variables __brkval and __flp (used in memdebug)
  • 2 bytes pushed onto the stack in main (to save registers)
  • 2 bytes pushed onto the stack in setup (to save registers)
  • 4 bytes pushed onto the stack in getFreeMemory (to save registers)
  • 1 byte because the stack pointer starts at 0x8FF rather than 0x900


(That's 346 bytes accounted for)


Tips:

  • The stack starts at the highest possible address and grows downwards. Each function call would add 2 bytes to the stack (the return address) plus any local (auto) variables would be allocated there.

  • The heap safety margin is a buffer between the top of the heap and the bottom of the stack. Without it, if you did a "malloc" and got all available memory, there would be no memory left for function calls and local variables in functions.

  • The vtable is used by the compiler to generate late bindings for virtual functions used by classes.

  • The compiler generates code to push registers onto the stack when you call a function (the registers used inside that function) so that if the same register was used by the calling function, it would not be corrupted.



Printing constants


Let's print a constant by adding this line:


  Serial.println ("Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmo");


Output is now:


Free memory = 1630
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmo


So the free RAM has gone down by 72 bytes, which happens to be be how long that string is (allowing one byte for the 0x00 terminator at the end).

Why? Because the string was copied from PROGMEM, where it must be when the Arduino is turned off, into RAM.

The first thing we can do is use the F() macro, which cunningly expands out to print directly from PROGMEM, thus saving RAM. So we change the line above to read:


  Serial.println (F("Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmo"));


Output:


Free memory = 1702
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmo


Back to 1702 bytes of free memory. So, lesson #1 is to use the F() macro as illustrated above whenever printing constants.


Tip:
You can actually do that to any form of outputting, not just Serial, by deriving a class from Print (if it isn't already so derived). For example:


#include <SPI.h>

class myOutputtingClass : public Print
{
public:
  virtual size_t write (uint8_t c);
};  // end of myOutputtingClass

// output to whatever medium you want here
size_t myOutputtingClass::write (uint8_t c)
  {
  SPI.transfer (c);
  return 1;   // one byte output
  }  // end of myOutputtingClass::write

myOutputtingClass foo;

void setup ()
  {
  digitalWrite(SS, HIGH);  // ensure SS stays high
  SPI.begin ();
  }  // end of setup

void loop () 
  {
  digitalWrite(SS, LOW);    // enable slave select
  foo.print (F("Hello, world"));  // sends to SPI
  digitalWrite(SS, HIGH);    // disable slave select
  delay (1000);
  }  // end of loop




Getting data from an array of constants


Things get tricky if you want a whole lot of words in an array, but not necessarily to be used with Serial.print. For example:


char * messages[] = { 
         "Test0000", 
         "Test0001", 
         "Test0002", 
         "Test0003", 
         "Test0004", 
         "Test0005", 
         "Test0006", 
         "Test0007", 
         "Test0008", 
         "Test0009", 
         "Test0010", 
          };

...

Serial.print (messages [5]);


The above code wastes RAM in two ways:


  • The strings (eg. "Test0000")
  • The pointers to the strings (which is what is stored in the array)


Because of this, a seemingly-simple approach to saving memory won't work very well:


char * const messages[] PROGMEM = { 
         "Test0000", 
         "Test0001", 
         "Test0002", 
         "Test0003", 
         "Test0004", 
         "Test0005", 
         "Test0006", 
         "Test0007", 
         "Test0008", 
         "Test0009", 
         "Test0010", 
          };

...

char * ptr = (char *) pgm_read_word (&messages [5]);
Serial.print (ptr);


Whilst this works, and prints messages [5], the only saving we have made is putting the pointers into PROGMEM and not the strings themselves. I can prove that with a more elaborate example:


#include "memdebug.h"

#define USE_PROGMEM true

const int NUMBER_OF_ELEMENTS = 10;

char * const messages [NUMBER_OF_ELEMENTS] 
#if USE_PROGMEM
PROGMEM
#endif
= { 
 "Twas bryllyg, and ye slythy toves", 
 "Did gyre and gymble", 
 "in ye wabe:", 
 "All mimsy were ye borogoves; And ye mome raths outgrabe.", 
 "\"Beware the Jabberwock, my son! \n The jaws that bite, the claws that catch!",
 "Beware the Jubjub bird, and shun\n The frumious Bandersnatch!\"", 
 "He took his ", 
 "vorpal sword in hand:", 
 "Long time the manxome foe he sought - ", 
 "So rested he by the Tumtum tree, \n And stood awhile in thought.", 
  };

void setup ()
  {
  Serial.begin (115200);
  Serial.println ();
  Serial.print (F("Free memory = "));
  Serial.println (getFreeMemory ());
  
  unsigned int count = 0;

  for (int i = 0; i < NUMBER_OF_ELEMENTS; i++)
    {
#if USE_PROGMEM    
    char * ptr = (char *) pgm_read_word (&messages [i]);
#else
    char * ptr = (char *) messages [i];
#endif
    Serial.println (ptr);
    count += strlen (ptr) + 1;
    }   // end of for loop

  Serial.print (F("String memory used = "));
  Serial.println (count);

  }  // end of setup

void loop () { } 


Output:


Free memory = 1298
Twas bryllyg, and ye slythy toves
Did gyre and gymble
in ye wabe:
All mimsy were ye borogoves; And ye mome raths outgrabe.
"Beware the Jabberwock, my son! 
 The jaws that bite, the claws that catch!
Beware the Jubjub bird, and shun
 The frumious Bandersnatch!"
He took his 
vorpal sword in hand:
Long time the manxome foe he sought - 
So rested he by the Tumtum tree, 
 And stood awhile in thought.
String memory used = 399


Now make it not use PROGMEM for the array:


#define USE_PROGMEM false


Output:


Free memory = 1278
Twas bryllyg, and ye slythy toves
Did gyre and gymble
in ye wabe:
All mimsy were ye borogoves; And ye mome raths outgrabe.
"Beware the Jabberwock, my son! 
 The jaws that bite, the claws that catch!
Beware the Jubjub bird, and shun
 The frumious Bandersnatch!"
He took his 
vorpal sword in hand:
Long time the manxome foe he sought - 
So rested he by the Tumtum tree, 
 And stood awhile in thought.
String memory used = 399


The saving from PROGMEM was 1298 - 1278 = 20. Since there were 10 messages in the table, and as a pointer is 2 bytes, clearly we have only saved the memory for the 10 pointers and not the 399 bytes that the strings themselves used.

Putting the strings themselves into PROGMEM


The challenge now is to put the actual strings, and not just the pointers to them, into PROGMEM.

There is a method suggested here: Arduino reference - PROGMEM

The method is to make a variable per string (in PROGMEM) and put those variables in the array, like this:


#include "memdebug.h"

const int NUMBER_OF_ELEMENTS = 10;

const char Message0000 [] PROGMEM = "Twas bryllyg, and ye slythy toves"; 
const char Message0001 [] PROGMEM = "Did gyre and gymble"; 
const char Message0002 [] PROGMEM = "in ye wabe:"; 
const char Message0003 [] PROGMEM = "All mimsy were ye borogoves; And ye mome raths outgrabe."; 
const char Message0004 [] PROGMEM = "\"Beware the Jabberwock, my son! \n The jaws that bite, the claws that catch!"; 
const char Message0005 [] PROGMEM = "Beware the Jubjub bird, and shun\n The frumious Bandersnatch!\""; 
const char Message0006 [] PROGMEM = "He took his "; 
const char Message0007 [] PROGMEM = "vorpal sword in hand:"; 
const char Message0008 [] PROGMEM = "Long time the manxome foe he sought - "; 
const char Message0009 [] PROGMEM = "So rested he by the Tumtum tree, \n And stood awhile in thought."; 

const char * const messages[NUMBER_OF_ELEMENTS] PROGMEM = 
   { 
   Message0000, 
   Message0001, 
   Message0002, 
   Message0003, 
   Message0004, 
   Message0005, 
   Message0006, 
   Message0007, 
   Message0008, 
   Message0009, 
   };

void setup ()
  {
  Serial.begin (115200);
  Serial.println ();
  Serial.print (F("Free memory = "));
  Serial.println (getFreeMemory ());
  
  unsigned int count = 0;

  for (int i = 0; i < NUMBER_OF_ELEMENTS; i++)
    {
    char * ptr = (char *) pgm_read_word (&messages [i]);
    char buffer [80]; // must be large enough!
    strcpy_P (buffer, ptr);
    Serial.println (buffer);
    count += strlen (buffer) + 1;
    }   // end of for loop

  Serial.print (F("String memory used = "));
  Serial.println (count);

  }  // end of setup

void loop () { } 


Output:


Free memory = 1616
Twas bryllyg, and ye slythy toves
Did gyre and gymble
in ye wabe:
All mimsy were ye borogoves; And ye mome raths outgrabe.
"Beware the Jabberwock, my son! 
 The jaws that bite, the claws that catch!
Beware the Jubjub bird, and shun
 The frumious Bandersnatch!"
He took his 
vorpal sword in hand:
Long time the manxome foe he sought - 
So rested he by the Tumtum tree, 
 And stood awhile in thought.
String memory used = 399


The amount of free RAM is back to 1616 which is only 86 bytes less than the benchmark (1702 bytes). Since there is an extra buffer of 80 bytes which we copy the strings into, we have only really used an extra 6 bytes so clearly the strings are now not being copied into RAM.

A drawback, however, is the need to have an adequate size buffer for copying the string into RAM, prior to printing. If you get that wrong, the program will probably crash. One way is to add another function:


// Print a string from Program Memory directly to save RAM 
void printProgStr (const char * str)
{
  char c;
  if (!str) 
    return;
  while ((c = pgm_read_byte(str++)))
    Serial.print (c);
} // end of printProgStr


Then change the printing part of the loop to use that instead of the temporary buffer:


  for (int i = 0; i < NUMBER_OF_ELEMENTS; i++)
    {
    printProgStr ((const char *) pgm_read_word (&messages [i]));
    Serial.println ();  // finish line off
    }   // end of for loop


Doing that saves the 80-byte buffer (in this case) which we used to hold the string when copied from PROGMEM.

However, the whole thing is tedious. You have to invent names for all those strings, and then put those names into the array. Perhaps there is a simpler way?

Put the strings into the array


If all of the strings are a similar length (which would probably be the case if they are messages to go onto an LCD screen or similar) then you can put them directly into the array like this:


#include "memdebug.h"

const int NUMBER_OF_ELEMENTS = 10;

typedef struct {
   char description [12];
} descriptionType;

const descriptionType descriptions [NUMBER_OF_ELEMENTS] PROGMEM = { 
 { "Furnace on" }, 
 { "Furnace off" }, 
 { "Set clock" }, 
 { "Pump on" }, 
 { "Pump off" }, 
 { "Password:" }, 
 { "Accepted" }, 
 { "Rejected" }, 
 { "Fault" }, 
 { "Service rqd" }, 
 };


void setup ()
  {
  Serial.begin (115200);
  Serial.println ();
  Serial.print (F("Free memory = "));
  Serial.println (getFreeMemory ());

  for (int i = 0; i < NUMBER_OF_ELEMENTS; i++)
    {
    // make a copy of the current one
    descriptionType oneItem;
    memcpy_P (&oneItem, &descriptions [i], sizeof oneItem);
    
    Serial.println (oneItem.description);
    }   // end of for loop

  }  // end of setup

void loop () { } 


This code defines a descriptionType, which in this case can hold up to 12 characters (so that would be 11 plus the null terminator). Assuming all your strings fit (and you can always make the number 12 larger) then this is much simpler.

Output:


Free memory = 1686
Furnace on
Furnace off
Set clock
Pump on
Pump off
Password:
Accepted
Rejected
Fault
Service rqd


This has only used 1702 - 1686 = 16 bytes of RAM compared to the benchmark sketch. So now we could easily hold a much larger array of messages without worrying about RAM usage.

You also have scope for adding more information per element, like this:


typedef struct {
   char description [12];
   char meaning [20];
   int value;
} descriptionType;


So you could store multiple strings (of fixed length) plus numbers as well.


An alternative away of achieving an array of strings, without using a struct, is like this:


#include "memdebug.h"

const int NUMBER_OF_ELEMENTS = 10;
const int MAX_SIZE = 12;

const char descriptions [NUMBER_OF_ELEMENTS] [MAX_SIZE] PROGMEM = { 
 { "Furnace on" }, 
 { "Furnace off" }, 
 { "Set clock" }, 
 { "Pump on" }, 
 { "Pump off" }, 
 { "Password:" }, 
 { "Accepted" }, 
 { "Rejected" }, 
 { "Fault" }, 
 { "Service rqd" }, 
 };

void setup ()
  {
  Serial.begin (115200);
  Serial.println ();
  Serial.print (F("Free memory = "));
  Serial.println (getFreeMemory ());

  for (int i = 0; i < NUMBER_OF_ELEMENTS; i++)
    {
    // make a copy of the current one
    char oneItem [MAX_SIZE];
    memcpy_P (&oneItem, &descriptions [i], sizeof oneItem);
    
    Serial.println (oneItem);
    } // end of for loop

  }  // end of setup

void loop () { } 


PROGMEM functions


These examples and the Arduino support for PROGMEM in general are built using features from the AVR Libc (AVR standard library) that you may also encounter in developing Arduino sketches and looking at ones developed by others.

AVR Libc, and therefore Arduino provides several standard string and memory manipulation functions in versions that address program memory rather than RAM. The AVR Libc convention is to append "_P" to the name of the standard function, thus we have strcpy_P() and memcpy_P() which are functions used in the above examples to copy strings or memory from PROGMEM to RAM. The "source" argument is assumed to be in PROGMEM and the destination argument in RAM. Otherwise they function like the standard library functions strcpy() and memcpy(). Some useful functions of this kind are:


    strcpy_P  (dest, PROGMEM src)
    strcmp_P  (s1,   PROGMEM s2)
    strncmp_P (s1,   PROGMEM s2,  len)
    memcpy_P  (dest, PROGMEM src, len)
    sprintf_P (dest, PROGMEM format, item, ... )


There are many others documented in the AVR Libc manual.

The AVR Libc provides a macro PSTR() that is similar to the F() macro mentioned above. PSTR() works with the AVR Libc "_P" functions, for example:


if (strcmp_P (inputBuffer, PSTR("SUCCESS")) == 0) 
  { 
  }



Here we are using a strcmp() to check that the input buffer contains a certain string. By using strcmp_P() and PSTR() we are arranging that the comparison string doesn't have to occupy RAM.


char buf [20];
sprintf_P (buf, PSTR ("Temperature %i\n"), temperature);


The above formats a string for output without wasting RAM on the format string.

F() and PSTR() although similar in concept, are not interchangeable, and you may have to think carefully about which is appropriate in various situations. In particular, the F() macro generates code which the Print class (and derived classes) can understand for outputting text. The PSTR() macro generates a temporary variable that can be used inside a function call - however that function has to be one of the "_P" functions that expects PROGMEM data.




Below shows how we can avoid having to use the temporary buffer, thus saving more RAM:


#include "memdebug.h"

const int NUMBER_OF_ELEMENTS = 10;
const int MAX_SIZE = 12;

const char descriptions [NUMBER_OF_ELEMENTS] [MAX_SIZE] PROGMEM = { 
 { "Furnace on" }, 
 { "Furnace off" }, 
 { "Set clock" }, 
 { "Pump on" }, 
 { "Pump off" }, 
 { "Password:" }, 
 { "Accepted" }, 
 { "Rejected" }, 
 { "Fault" }, 
 { "Service rqd" }, 
 };

// Print a string from Program Memory directly to save RAM 
void printProgStr (const char * str)
{
  char c;
  if (!str) 
    return;
  while ((c = pgm_read_byte(str++)))
    Serial.print (c);
} // end of printProgStr

void setup ()
  {
  Serial.begin (115200);
  Serial.println ();
  Serial.print (F("Free memory = "));
  Serial.println (getFreeMemory ());

  for (int i = 0; i < NUMBER_OF_ELEMENTS; i++)
    {
    printProgStr ((const char *) &descriptions [i]);
    Serial.println ();  // finish line off
    } // end of for loop

  }  // end of setup

void loop () { } 

- Nick Gammon

www.gammon.com.au, www.mushclient.com
Top

Posted by Nick Gammon   Australia  (23,057 posts)  Bio   Forum Administrator
Date Reply #1 on Tue 30 Sep 2014 05:35 AM (UTC)
Message
Another technique which can be useful for making the use of PROGMEM easier is described here:


Flash - A Library to Ease Accessing Flash-based (PROGMEM) Data

- Nick Gammon

www.gammon.com.au, www.mushclient.com
Top

Posted by Nick Gammon   Australia  (23,057 posts)  Bio   Forum Administrator
Date Reply #2 on Tue 30 Sep 2014 10:49 PM (UTC)

Amended on Sat 04 Oct 2014 05:43 AM (UTC) by Nick Gammon

Message
A simple function that shows how much free RAM is available at startup is:


size_t freeRam ()
  {
  return RAMEND - size_t (__malloc_heap_start);
  } // end of freeRam


You could add that to your code (at least during debugging) to make sure you have a reasonable margin of safety with available RAM, at least when the program starts.

Example of use:


size_t freeRam ()
  {
  return RAMEND - size_t (__malloc_heap_start);
  } // end of freeRam

void setup ()
  {
  Serial.begin (115200);
  Serial.println ();

  Serial.print (F("Free memory = "));
  Serial.println (freeRam ());
  }  // end of setup

void loop () { }


Output on my Uno:


Free memory = 1850


Note that this does not take into account dynamic memory allocation (ie. with malloc or new) however as a guide to how much memory you have at the beginning of setup (before your code actually does anything) this could be very useful.


Other resources


The memdebug functions I used in the first post are at:

Andy Brown - Debugging AVR dynamic memory allocation

Other helpful stuff:





AVR Libc documentation





Other chips may be easier to use


The issues with PROGMEM are somewhat specific to the AVR parts. Arduinos with other chips (eg. the Due) could well be easier to use in this respect. This thread is specifically about the AVR chips such as the Atmega328, used in the Arduino Uno.

- Nick Gammon

www.gammon.com.au, www.mushclient.com
Top

Posted by Nick Gammon   Australia  (23,057 posts)  Bio   Forum Administrator
Date Reply #3 on Thu 02 Oct 2014 08:53 PM (UTC)

Amended on Fri 10 Oct 2014 12:51 AM (UTC) by Nick Gammon

Message
A template function for accessing any type


This simple couple of functions let you read any data type from PROGMEM. To use, put this into the file PROGMEM_readAnything.h:


#include <Arduino.h>  // for type definitions

template <typename T> void PROGMEM_readAnything (const T * sce, T& dest)
  {
  memcpy_P (&dest, sce, sizeof (T));
  }

template <typename T> T PROGMEM_getAnything (const T * sce)
  {
  static T temp;
  memcpy_P (&temp, sce, sizeof (T));
  return temp;
  }


You can put that file into a new tab in your IDE, or make a library by putting it inside a folder called PROGMEM_readAnything and put that folder inside the libraries folder, which is inside your sketchbook folder.

They let you copy from the memory in PROGMEM (using memcpy_P) into RAM. The template is used to work out how many bytes to copy.

Example of using PROGMEM_readAnything:


#include <PROGMEM_readAnything.h>

const int NUMBER_OF_ELEMENTS = 10;

const float table[NUMBER_OF_ELEMENTS] PROGMEM = { 1.0, 34.234, 324.234, 23.1, 52.0, 3.6, 5.6, 42.42, 1908, 23.456 } ;

void setup() 
{
  Serial.begin(115200);

  for (size_t i = 0; i < NUMBER_OF_ELEMENTS; i++) 
  {
    float thisOne;
    PROGMEM_readAnything (&table[i], thisOne);
    Serial.println(thisOne);
  }  // end of for loop
}  // end of setup

void loop() { }


For this to work you need a temporary variable (thisOne) into which the data is copied.

Or if the data can be directly copied, like a float or long, you can use PROGMEM_getAnything like this:


#include <PROGMEM_readAnything.h>

const int NUMBER_OF_ELEMENTS = 10;

const float  table[NUMBER_OF_ELEMENTS] PROGMEM = { 1.0, 34.234, 324.234, 23.1, 52.0, 3.6, 5.6, 42.42, 1908, 23.456 } ;

void setup() 
{
  Serial.begin(115200);

  for (size_t i = 0; i < NUMBER_OF_ELEMENTS; i++) 
    Serial.println(PROGMEM_getAnything (&table[i]));
    
}  // end of setup

void loop() { }


In this case the temporary variable is held inside PROGMEM_getAnything as a static variable, and thus you can assign or print it without having to have your own temporary variable.

- Nick Gammon

www.gammon.com.au, www.mushclient.com
Top

Posted by Nick Gammon   Australia  (23,057 posts)  Bio   Forum Administrator
Date Reply #4 on Sun 05 Oct 2014 03:46 AM (UTC)

Amended on Sun 05 Oct 2014 03:48 AM (UTC) by Nick Gammon

Message
PROGMEM variables have to be const and global


The nature of PROGMEM variables means that they have to satisfy two conditions to work properly (or at all):


  • They must be in global scope (that is, not defined as a function local variable).

  • They should be const. Some versions of the compiler may not insist on that, however clearly, as they are in flash memory, which is read-only, they must not be writable.


For example, under IDE version 1.5.8 this program will fail to compile:


float foo PROGMEM = 42;

void setup() { }
void loop() { }


Error:


sketch_oct05a.ino:1:11: error: variable 'foo' must be const in order to be put into read-only section by means of '__attribute__((progmem))'





Under IDE version 1.0.6 this program gives a warning if you have verbose compiling on:


void setup() 
  { 
  const float foo PROGMEM = 42;
  }
void loop() { }


Warning:


sketch_oct05b.ino: In function ‘void setup()’:
sketch_oct05b.ino:3: warning: ‘__progmem__’ attribute ignored





Thus, the correct usage is:


const float foo PROGMEM = 42;

void setup() { }
void loop() { }


The variable "foo" is both const and global.

- Nick Gammon

www.gammon.com.au, www.mushclient.com
Top

The dates and times for posts above are shown in Universal Co-ordinated Time (UTC).

To show them in your local time you can join the forum, and then set the 'time correction' field in your profile to the number of hours difference between your location and UTC time.


90,368 views.

Postings by administrators only.

Refresh page

Go to topic:           Search the forum


[Go to top] top

Information and images on this site are licensed under the Creative Commons Attribution 3.0 Australia License unless stated otherwise.