最近幫別人除錯,看到一個有趣的案例。下面的程式會有 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_list
、va_start
、va_arg
與 va_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
函式,例如:vprintf
、vfprintf
、vsnprintf
等等。
問題
回到本文開頭的程式碼。該程式碼想要先呼叫 vsnprintf
計算其所需的記憶體大小,接著配置足夠的記憶體,最後再次呼叫 vsnprintf
將 format
與 args
轉化為字串。
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 functionsvfprintf
,vfscanf
,vprintf
,vscanf
,vsnprintf
,vsprintf
, andvsscanf
invoke theva_arg
macro, the value ofarg
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