一個 va_list 錯誤案例

最近幫別人除錯,看到一個有趣的案例。下面的程式會有 Segmentation Fault(記憶體區段錯誤),大家看得出問題嗎?

#include <stdarg.h>

struct Context {
  char *error_;
};

void report_error(struct Context *ctx,
                  const char *format,
                  va_list args) {
  int len = vsnprintf(NULL, 0, format, args);  // XX
  free(ctx->error_);
  ctx->error_ = (char *)malloc(len + 1);
  if (ctx->error_) {
    vsnprintf(ctx->error_, len + 1, format, args);  // XX
  }
}

va_list 簡介

C 語言的可變參數函式(Variadic Function)是以 <stdarg.h> 定義的 va_listva_startva_argva_end 實現。以下是一個簡單的範例:

#include <stdarg.h>

void vmyprintf(const char *format, va_list args) {
  while (*format) {
    char ch = *format++;
    switch (ch) {
      case '%':
        switch (*format++) {
          case 'd': {
            int i = va_arg(args, int);
            // ...
            break;
          }
          case 'f': {
            double d = va_arg(args, double);
            // ...
            break;
          }
          // ...
        }
        break;
      default:
        putchar(ch);
        break;
    }
  }
}

void myprintf(const char *format, ...) {
  va_list args;
  va_start(args, format);
  vmyprintf(format, args);
  va_end(args);
}

其中,va_list 是一個「實作定義(Implementation-defined)」的資料結構。它的功能相當於一個函式參數的迭代器。我們必須先以 va_start 初始化迭代器的起始位置,再透過 va_arg 依次讀取各個參數,最後再以 va_end 釋放所有 va_list 所需的資源。

va_list 本身也能作為參數傳遞給其他函式。常見於以 v 開頭的 printf 函式,例如:vprintfvfprintfvsnprintf 等等。

問題

回到本文開頭的程式碼。該程式碼想要先呼叫 vsnprintf 計算其所需的記憶體大小,接著配置足夠的記憶體,最後再次呼叫 vsnprintfformatargs 轉化為字串。

void report_error(struct Context *ctx,
                  const char *format,
                  va_list args) {
  int len = vsnprintf(NULL, 0, format, args);  // XX
  free(ctx->error_);
  ctx->error_ = (char *)malloc(len + 1);
  if (ctx->error_) {
    vsnprintf(ctx->error_, len + 1, format, args);  // XX
  }
}

問題根源在於 args 迭代器在第一次呼叫 vsnprintf 函式的時候就已經走到結尾了。C 語言標準對 vsnprintf 函式的行為有以下註解:

288) As the functions vfprintf, vfscanf, vprintf, vscanf, vsnprintf, vsprintf, and vsscanf invoke the va_arg macro, the value of arg after the return is indeterminate. -- C 11 (N1570), p. 327

換句話說,在 vsnprintf 回傳之後,如果再以 va_arg 從同一個 va_list 讀取函式參數,va_arg 的回傳值將無法被確定。在我的測試環境下,va_arg(args, const char *) 會回傳錯誤的位址,進而導致 Segmenetation Fault(記憶體區段錯誤)。

修正方法

因為我們要走訪參數兩次,我們在第一次走訪參數前,應該先以 va_copy 複製一份 va_list。例如:

void report_error(struct Context *ctx,
                  const char *format,
                  va_list args) {
  va_list args_copy;                                // ADDED
  va_copy(args_copy, args);                         // ADDED
  int len = vsnprintf(NULL, 0, format, args_copy);  // MODIFIED
  va_end(args_copy);                                // ADDED

  free(ctx->error_);
  ctx->error_ = (char *)malloc(len + 1);
  if (ctx->error_) {
    vsnprintf(ctx->error_, len + 1, format, args);
  }
}

另外,如果可以使用 GNU 擴充函式(編譯時有定義 _GNU_SOURCE),我們可以直接呼叫 vasprintf 函式。vasprintf 會直接計算好所需要的記憶體空間、配置記憶體、並輸出字串:

#define _GNU_SOURCE
#include <stdio.h>

void report_error(struct Context *ctx,
                  const char *format,
                  va_list args) {
  free(ctx->error_);
  vasprintf(&ctx->error_, format, args);
}

不過我實際遇到的程式碼並不是以 malloc 配置記憶體,因此我就沒有使用這個修改方式。

參考資料

  • N1570 - Programming languages - C