C语言变量及其生命周期

时间:2022-10-04 14:45:39

变量类型以及作用域和生命周期

变量的作用域

变量的作用域就该变量可以被访问的区间,变量的作用域可以分为以下四种:

  • 进程作用域(全局):在当前进程的任何一个位置都可以访问
  • 函数作用域:当流程转移到函数后,在其开始和结束的花括号内可访问
  • 块作用域:最常见的就是if(...){...},while(..){...},类似这种,
    块内部可以访问
  • 文件作用域:在当前源码文件内可以被访问

    变量的生命周期

    变量的生命周期就是从创建该变量开始到该变量被销毁的这一段时间,
    各种变量的生命周期:
  • 全局变量:进程开始时创建,进程结束时销毁,在代码编译链接后,直接将
    其初始值写入到可执行文件中,创建时按照定义时的初始值进
    行赋值
  • 局部变量和参数变量:进入函数时创建,退出函数时销毁
  • 全局静态变量:定义一个全局变量并使用static关键字修饰时,这个变量
    就成了全局静态变量,它的生命周期和全局变量一样,但是
    作用域被限制在定义文件内,无法使用extern来让其他源
    文件中使用它
  • 静态局部变量:在函数内使用static关键字修饰一个变量时,这个变量就
    是静态局部变量,它的生命周期同全局变量一样,作用域被
    限制在函数内
  • 寄存器变量:在VC++的Debug版本中,寄存器变量和普通变量没区别,在
    Release版本中VC++编译器会自动优化,即使一个变量不是
    寄存器变量也有可能放到寄存器中,所以register关键字对
    于VC++编译器来说只是个建议

    各种变量和常量的小实验

  • 全局常量
    C语言变量及其生命周期
    编写对全局常量赋值的代码会导致编译时报错,现在我用指针指向它的地址,
    然后在向它赋值,看看这种猥琐的方式是否能成功:
    C语言变量及其生命周期
    可以看出编译时能混过去,但是运行时报错,这是因为全局常量保存在数据区
    的常量区中,常量区的内存属性为只读,如果向只读内存写入数据则会引发错误
  • 局部常量和参数常量
    C语言变量及其生命周期
    可以看出局部常量和参数常量都在栈上,只是在编译时检查是否被赋值,运行时
    还是可以猥琐修改
  • 全局常量,局部常量,参数常量,全局变量,全局静态变量,静态局部变量的生命周期:

    int g_Test1 = 3;
    const int g_Test2 = 4;
    static int g_Test3 = 5;
    void TestConstVar(const int nTest1)
    {
      static int nTest4 = 8;
      const int nTest = 1;
      int* pTest = (int*)&nTest;
      *pTest = 2;
      pTest = (int*)&nTest1;
      *pTest = 9;
    }
    
    int main()
    {
      TestConstVar(3);
      return 0;
    }
    在程序入口点mainCRTStartup下函数点,程序停在这里,此时程序刚刚
    建立,main函数还没有被执行:
    C语言变量及其生命周期
    可以看出g_Test1,g_Test2,g_Test3都可以在"监视"窗口中查看
    C语言变量及其生命周期
    main函数退出后g_Test1,g_Test2,g_Test3依旧存在
    局部常量和参数常量在保存在栈上,但静态局部变量因为只做一次初始化的
    原因所以它也被保存在数据区,在实验的过程中发现了之前的VS2013以及之前
    的版本的编译器在初始化静态局部变量是线程不安全的,对比如下:
    • VS2013:
      C语言变量及其生命周期
      从源码对应的汇编语言可以看出,VC++编译器为了做到静态局部变量
      只被初始化一次,所以使用了标记变量,只要发现标记变量没有被置位,
      那么会先进行置位,然后在进行初始化,但是这在多线程环境中是不安全的,
      当两个线程同时调用静态局部变量所在的函数时,会出现两个线程在没有同
      步机制的情况下操作同一个变量,在我这个简单代码中,静态局部变量的类型
      是整型,所以看起来没啥太大危害,但是如果静态局部变量的类型是一个类,
      那么构造函数极有可能发生一个线程,刚刚置标记位还没构造完成,接着另一个
      线程也调用了该函数,这个线程发现标记位被置位了,然而此时对象的构造还未
      完成,如果该线程就执行剩下的代码,那么极有可能发生错误,而且极难排查
    • VS2015:
      我在测试程序中创建另一个线程,以便观察:

      int g_Test1 = 3;
      const int g_Test2 = 4;
      static int g_Test3 = 5;
      void TestConstVar(const int nTest1)
      {
        static int nTest4 = nTest1;
        nTest4 += 1;
        const int nTest = 1;
        int* pTest = (int*)&nTest;
        *pTest = 2;
        pTest = (int*)&nTest1;
        *pTest = 9;
      }
      unsigned __stdcall startaddress(void *)
      {
        TestConstVar(3);
        printf("333");
        return 0;
      }
      
      
      
      int main()
      {
        TestConstVar(3);
        uintptr_t ret = _beginthreadex(NULL, 0, startaddress, NULL, 0, NULL);
        system("pause");
        return 0;
      }
      

      TestConstVar函数完整的反汇编代码:

      void TestConstVar(const int nTest1)
      {
      011F1760  push        ebp  
      011F1761  mov         ebp,esp  
      011F1763  sub         esp,0DCh  
      011F1769  push        ebx  
      011F176A  push        esi  
      011F176B  push        edi  
      011F176C  lea         edi,[ebp-0DCh]  
      011F1772  mov         ecx,37h  
      011F1777  mov         eax,0CCCCCCCCh  
      011F177C  rep stos    dword ptr es:[edi]  
      011F177E  mov         eax,dword ptr [__security_cookie (011FA014h)]  
      011F1783  xor         eax,ebp  
      011F1785  mov         dword ptr [ebp-4],eax  
        static int nTest4 = nTest1;
      011F1788  mov         eax,dword ptr [_tls_index (011FA194h)]  
      011F178D  mov         ecx,dword ptr fs:[2Ch]  
      011F1794  mov         edx,dword ptr [ecx+eax*4]  
      011F1797  mov         eax,dword ptr ds:[011FA154h]  
      011F179C  cmp         eax,dword ptr [edx+104h]  
      011F17A2  jle         TestConstVar+6Fh (011F17CFh)  
      011F17A4  push        11FA154h  
      011F17A9  call        __Init_thread_header (011F104Bh)  
      011F17AE  add         esp,4  
      011F17B1  cmp         dword ptr ds:[11FA154h],0FFFFFFFFh  
      011F17B8  jne         TestConstVar+6Fh (011F17CFh)  
      011F17BA  mov         eax,dword ptr [nTest1]  
      011F17BD  mov         dword ptr [nTest4 (011FA150h)],eax  
      011F17C2  push        11FA154h  
      011F17C7  call        __Init_thread_footer (011F10E1h)  
      011F17CC  add         esp,4  
        nTest4 += 1;
      011F17CF  mov         eax,dword ptr [nTest4 (011FA150h)]  
      011F17D4  add         eax,1  
      011F17D7  mov         dword ptr [nTest4 (011FA150h)],eax  
        const int nTest = 1;
      011F17DC  mov         dword ptr [nTest],1  
        int* pTest = (int*)&nTest;
      011F17E3  lea         eax,[nTest]  
      011F17E6  mov         dword ptr [pTest],eax  
        *pTest = 2;
      011F17E9  mov         eax,dword ptr [pTest]  
      011F17EC  mov         dword ptr [eax],2  
        pTest = (int*)&nTest1;
      011F17F2  lea         eax,[nTest1]  
      011F17F5  mov         dword ptr [pTest],eax  
        *pTest = 9;
      011F17F8  mov         eax,dword ptr [pTest]  
      011F17FB  mov         dword ptr [eax],9  
      }
      011F1801  push        edx  
      011F1802  mov         ecx,ebp  
      011F1804  push        eax  
      011F1805  lea         edx,ds:[11F1830h]  
      011F180B  call        @_RTC_CheckStackVars@8 (011F128Fh)  
      011F1810  pop         eax  
      011F1811  pop         edx  
      011F1812  pop         edi  
      }

      从上述反汇编代码中可以看出VS2015对静态变量的初始化与VS2013完全不一样,
      编译器插入了这两个函数:__Init_thread_header,__Init_thread_footer,
      从VS2015的安装目录下:VS2015\VC\crt\src\vcruntime的thread_safe_statics.cpp,
      源文件中找到了这两个函数的源码和这两个函数中引用到的变量:

      
      int const Uninitialized    = 0;
      int const BeingInitialized = -1;
      int const EpochStart = INT_MIN;
      
      extern "C"
      {
          int _Init_global_epoch = EpochStart;
          __declspec(thread) int _Init_thread_epoch = EpochStart;
      }
      
      
      extern "C" void __cdecl _Init_thread_header(int* const pOnce)
      {
          _Init_thread_lock();
      
          if (*pOnce == Uninitialized)
          {
              *pOnce = BeingInitialized;
          }
          else
          {
              while (*pOnce == BeingInitialized)
              {
                  // Timeout can be replaced with an infinite wait when XP support is
                  // removed or the XP-based condition variable is sophisticated enough
                  // to guarantee all waiting threads will be woken when the variable is
                  // signalled.
                  _Init_thread_wait(XpTimeout);
      
                  if (*pOnce == Uninitialized)
                  {
                      *pOnce = BeingInitialized;
                      _Init_thread_unlock();
                      return;
                  } 
              }
              _Init_thread_epoch = _Init_global_epoch;
          }
      
          _Init_thread_unlock();
      }
      
      // Called by the thread that completes initialization of a variable.
      // Increment the global and per thread counters, mark the variable as
      // initialized, and release waiting threads.
      extern "C" void __cdecl _Init_thread_footer(int* const pOnce)
      {
          _Init_thread_lock();
          ++_Init_global_epoch;
          *pOnce = _Init_global_epoch;
          _Init_thread_epoch = _Init_global_epoch;
          _Init_thread_unlock();
          _Init_thread_notify();
      }
      
      extern "C" void __cdecl _Init_thread_lock()
      {
          EnterCriticalSection(&_Tss_mutex);
      }
      

      从反汇编代码中可以看出调用_Init_thread_footer,和_Init_thread_header时,前面都会有
      011F17C2 push 11FA154h,这行代码是将与静态变量关联的标记变量的地址作为参数
      传递,在_Init_thread_footer中先调用_Init_thread_lock函数进入临界区,确保在当前线程
      独占此标记变量,进入临界区后判断此标记变量的值是否为Uninitialized(值为0,表示静态局部
      变量未被初始化),如果标记变量为0,那么则将标记变量置为BeingInitialized(值为-1,表示该
      变量正在被初始化),然后当前线程调用_Init_thread_unlock函数释放临界区,退出_Init_thread_footer
      函数,流程转移到TestConstVar函数中进行静态局部变量的初始化,如果在此时紧接着又有好几个线程同
      时调用TestConstVar函数,假设此时静态局部变量还咩有初始化完成,那么后来的线程就会进入
      _Init_thread_header中,然后发现与该静态变量关联的标记变量已经被置为BeingInitialized
      那么这些线程则会进入到_Init_thread_header的else分支中,然后在else分支的while循环中
      等待当前正在初始化静态局部变量的线程完成初始化,那么现在来看看这些线程是如何等待的:

        static decltype(SleepConditionVariableCS)* encoded_sleep_condition_variable_cs;
        extern "C" bool __cdecl _Init_thread_wait(DWORD const timeout)
        {
            if (_Tss_event == nullptr)
            {
                return __crt_fast_decode_pointer(encoded_sleep_condition_variable_cs)(&_Tss_cv, &_Tss_mutex, timeout) != FALSE;
            }
            else
            {
                _ASSERT(timeout != INFINITE);
                _Init_thread_unlock();
                HRESULT res = WaitForSingleObjectEx(_Tss_event, timeout, FALSE);
                _Init_thread_lock();
                return (res == WAIT_OBJECT_0);
            }
        }

      _Tss_event只有在XP系统下才不为空,因为XP系统不支持条件变量,所以只能用WaitForSingleObjectEx
      来模拟条件变量,这里的encoded_sleep_condition_variable_cs是函数指针,这行代码:
      __crt_fast_decode_pointer(encoded_sleep_condition_variable_cs)(&_Tss_cv, &_Tss_mutex, timeout),就是在调用SleepConditionVariableCS,然后睡眠timeout(100ms),在睡眠的期间会释放
      _Tss_mutex,超时或者醒来时在重新进入临界区_Tss_mutex。

      当前线程初始化完成后会调用_Init_thread_footer:

          _Init_thread_lock();
          ++_Init_global_epoch;
          *pOnce = _Init_global_epoch;
          _Init_thread_epoch = _Init_global_epoch;
          _Init_thread_unlock();
          _Init_thread_notify();

      正是因为那些后来等待的线程调用SleepConditionVariableCS时会释放临界区,所以_Init_thread_footer
      中调用_Init_thread_lock()不会卡在这里,当前线程进入临界区后,那些在_Init_thread_wait
      中调用SleepConditionVariableCS函的线程将会卡在这个函数中,因为_Tss_mutex临界区被当前线程
      所占有;++_Init_global_epoch则是累加全局计数器,然后将全局计数器的值赋值给标记变量,而每个线程
      都有一个计数器(_Init_thread_epoch),全局计数器的值也被赋值给当前线程的计数器,至此标记变量和
      计数器都已赋值完成,此时在调用_Init_thread_unlock释放临界区,然后在调用_Init_thread_notify:

      static decltype(WakeAllConditionVariable)* encoded_wake_all_condition_variable;
      extern "C" void __cdecl _Init_thread_notify()
      {
          if (_Tss_event == nullptr)
          {
              __crt_fast_decode_pointer(encoded_wake_all_condition_variable)(&_Tss_cv);
          }
          else
          {
              SetEvent(_Tss_event);
              ResetEvent(_Tss_event);
          }
      }

      从上面代码可以看出在非XP系统下,调用WakeAllConditionVariable唤醒所有陷入睡眠的线程,
      在XP系统下使用SetEvent和ResetEvent唤醒等待线程,醒来的线程发现while循环中的条件
      *pOnce == BeingInitialized不成立,则退出_Init_thread_header函数,返回到TestConstVar
      函数,然后进行如下判断:

      011F17B1  cmp         dword ptr ds:[11FA154h],0FFFFFFFFh  
      011F17B8  jne         TestConstVar+6Fh (011F17CFh) 

      发现与静态局部变量关联的标记变量已经不是BeingInitialized,则说明该静态局部变量已经被
      其他线程初始化了,则跳过静态局部变量的初始化代码。

      现在回过头解释下反汇编中的第一个判断语句:

      011F1788  mov         eax,dword ptr [_tls_index (011FA194h)]  
      011F178D  mov         ecx,dword ptr fs:[2Ch]  
      011F1794  mov         edx,dword ptr [ecx+eax*4]  
      011F1797  mov         eax,dword ptr ds:[011FA154h]  
      011F179C  cmp         eax,dword ptr [edx+104h]  
      011F17A2  jle         TestConstVar+6Fh (011F17CFh)  

      这里前三行代码从局部线程存储中取出的一个值与静态局部变量对应的标记变量进行比较,
      根据_Init_thread_epoch变量的声明可以判断出取出的值就是_Init_thread_epoch,
      _Init_thread_epoch初值被置为EpochStart(一个负数),而标记变量未完成初始化时的
      值是0,比_Init_thread_epoch大,所以jle指令不满足跳转条件,后续的静态变量初始化
      代码得以执行;静态变量初始化完成后标记变量被置为_Init_thread_epoch(++_Init_global_epoch),
      所以标记变量时小于或者等于_Init_thread_epoch,jle指令跳转条件成立,静态局部的
      初始化代码全部跳过.

      At Last: 这个静态局部变量初始化bug经历了将近20年才被修复,我也是偶然间观察VS2013和
      VS2015生成的二进制代码的反汇编代码才发现这事,同时也顺带学会了条件变量的使用。