cgccavr-gcc

Passing a non-volatile variable pointer to a function expecting a volatile variable pointer


I'm rewriting an interrupt based I2C driver to avoid internal fixed buffer sizes. To achieve this, I'm passing a buffer pointer into the driver for it to use. This is simple for the transmit case as the data is fixed, but for the receive case the buffer needs to be volatile.

As I understand it this requires the original declaration of the buffer to be volatile, but if it is not declared as volatile GCC does not complain. This has led to some questions about passing a non-volatile variable pointer to a function expecting a volatile variable pointer

The basic structure of the receive code is as follows. (Greatly simplified)

driver.c

volatile uint8_t* buff_ptr;
volatile size_t buff_index;
size_t num_bytes; 
volatile bool complete;

void receive_data(volatile uint8_t* buff, size_t length)
{
    buff_ptr = buff;
    buff_index = 0;
    num_bytes = length;
    complete = false;
    // Code to start transfer
    while (!complete) {}
}

ISR()
{
    switch (state)
    {
        case DATA_RECEIVED:
        {
            buff_ptr[buff_index++] = DATA_REGISTER;
            if (buff_index >= num_bytes)
            {
                complete = true;
                // Code to end transfer
            }
            break;
        }
        // Other states
    }
}

main.c

int main()
{
    // Correctly typed usage
    volatile uint8_t volatile_buff[10];
    receive_data(volatile_buff, sizeof(volatile_buff));
    printf("V: %s", volatile_buff);
    
    // GCC doesn't complain that "normal_buff" is not volatile.
    // This might cause problems
    uint8_t normal_buff[10];
    receive_data(normal_buff, sizeof(normal_buff));
    printf("N: %s", normal_buff);
}

Questions

  1. What happens to a non-volatile variable which is passed as a pointer into a function expecting a volatile variable pointer?
    1. Is it treated as volatile inside the scope of that function? (I'm assuming yes.)
    2. Does the compiler see that the function expects a volatile and assume that its contents might be changed unexpectedly inside the function, therefore avoiding optimisation around that function call even though the variable itself is not declared as volatile?
  2. AVR GCC 8.3.0 (with -Wall -Wextra -pedantic) doesn't complain that a non-volatile variable is passed as a pointer to a function expecting a volatile variable pointer. Why is this?
  3. It would be very easy for a user of receive_data() to accidentally pass it a non-volatile variable pointer. Is there a way to enforce that the variable must be volatile?

Since the driver code always waits for the transfer to complete before returning, I'm planning to shift the state machine out of the interrupt and into the while loop. This should remove the need for volatile qualifiers entirely. However, I'm still interested in the above questions for academic purposes.

Update 1

Based on the answers provided so far, (particularly Eric Postpischil and Lundin), I've done some testing with various options and looking at the (dis)assembly. Given the following new version of main():

int main(void)
{
    uint8_t main_buff[2] = { 1, 2 };
    PORTA = main_buff[1];    // PORTA is a hardware register
    receive_data(main_buff, sizeof(main_buff));
    PORTA = main_buff[1];    // PORTA is a hardware register
    return 0;
}

If receive_data() is in a different translation unit (source file) and link time optimisation is disabled, upon returning from receive_data(), main_buff is always read from memory like this regardless of if main_buff is volatile or not in main():

    PORTA = main_buff[1];
 ldd    r24, Y+2    ; 0x02
 out    0x02, r24   ; 2

However, if link time optimisation is enabled the compiler starts to assume that the function didn't change anything despite buff and buff_ptr both being volatile inside receive_data(). If main_buff is not volatile in main() it assumes main_buff still has its initial value and we get the following upon returning from receive_data():

    PORTA = main_buff[1];
 ldi    r24, 0x02   ; 2
 out    0x02, r24   ; 2

So it seems that if link time optimisation is disabled, having code in separate source files is sufficient to prevent unwanted optimisation and declaring main_buff as volatile in main() is not required. However, if link time optimisation is enabled main_buff must be volatile in main() to prevent potential optimisation.


Solution

  • What happens to a non-volatile variable which is passed as a pointer into a function expecting a volatile variable pointer?

    That is fine and valid C. You can always add a type qualifier like volatile or const, but you can't get rid of one (or risk poorly-defined behavior). It is indeed treated as volatile from there on, inside the function.


    Does the compiler see that the function expects a volatile and assume that its contents might be changed unexpectedly inside the function, therefore avoiding optimisation around that function call even though the variable itself is not declared as volatile?

    Normally, compilers cannot optimize function contents if that function is part of another translation unit. In this code main.c + all included headers form one translation unit and driver.c + all its included headers form another one.

    So it probably won't be able to do any optimizations regardless. Inlining local functions with internal linkage would be another story. And then there's link-time optimization but that's mostly about removing functions from the binary which are never called anywhere.

    Also note that in case of volatile type* name, the pointer itself isn't volatile and may be stored in for example an index register. So that shouldn't affect calling convention. Unlike volatile type* volatile name which may force the compiler to stack allocate the pointer and then it changes the necessary calling convention.


    AVR GCC 8.3.0 (with -Wall -Wextra -pedantic) doesn't complain that a non-volatile variable is passed as a pointer to a function expecting a volatile variable pointer. Why is this?

    Because that's valid standard C. Specifically, this is covered by the rules of the assignment operator (parameters are passed to functions "as per assignment"), see C23 6.5.17.1:

    One of the following shall hold:
    ...

    • the left operand has atomic, qualified, or unqualified pointer type, and (considering the type the left operand would have after lvalue conversion) both operands are pointers to qualified or unqualified versions of compatible types, and the type pointed to by the left operand has all the qualifiers of the type pointed to by the right operand;

    What this means in plain English is that during assignment left = right;, then left must have at least all qualifiers (const, volatile) that right got. But right does not need to have all qualifiers that left got.

    Exactly the same rule applies when calling a function: void func (volatile* type left) ... func(right);.


    It would be very easy for a user of receive_data() to accidentally pass it a non-volatile variable pointer. Is there a way to enforce that the variable must be volatile?

    That is not an issue. If you want strict type safety, then yeah it is possible in modern C, but involves writing some awkward wrapper macros, for example (C23):

    void func (volatile int* x);
    
    #define IS_VOLATILE(x) _Generic((x), volatile typeof_unqual(*x)*: 1, default: 0)
    #define STATIC_ASSERT(expr, msg) (void)((struct{int dummy; static_assert(expr,msg);}){}.dummy) // dummy compound literal
    #define func(x) (STATIC_ASSERT(IS_VOLATILE(x),"parameter was wrong type or not a pointer to volatile"), func(x))
    
    int main (void)
    {
      int* x;
      func(x);
    
      volatile int* y;
      func(y);
    }
    

    This should remove the need for volatile qualifiers entirely

    Please note that the only time you need to use volatile here is when a variable is shared between the ISR and the background program. And when that sharing happens in "run-time", ie it's not a problem to have the init function share a non-volatile variable with the ISR because the ISR won't even be active when the init function is called (unless the program design is wrong).

    As for the function, it only needs volatile qualified parameters in case you intend to pass pointers to hardware registers to it, but then it's volatile for another reason.