使用 C-Reduce 進行除錯

SwiftGG翻譯組發表於2019-03-03

作者:Mike Ash,原文連結,原文日期:2018-06-29
譯者:BigNerdCoding;校對:pmstnumbbbbb;定稿:Forelax

除錯複雜問題本身就並不輕鬆,如果還沒有足夠的上下文和大致方向的話那就是一件非常困難的事。所以對程式碼進行精簡縮小除錯範圍也就變成了一種常見的行為。不過與繁雜的手動簡化相比,執行自動化處理程式明顯更容易發揮計算機自身的優勢。C-Reduce 正是為此而生,它能自動對原始程式碼進行簡化操作輸出一個簡化的除錯友好版本。下面我們看看如何使用該自動化程式。

概述

C-Reduce 程式碼基於兩個主要思想。

首先,C-Reduce 通過刪除相關程式碼行或者將 token 重新命名為更短的版本等手段,將某些原始程式碼轉化為一個簡化版本。

其次,對簡化結果進行檢驗測試。上面的程式碼簡化操作是盲目的,因此經常產生不含待跟蹤錯誤甚至是根本無法編譯的簡化版本。所以在使用 C-Reduce 時,除原始程式碼外還需要一個用來測試簡化操作是否符合特定“預期”的指令碼程式。而“預期”的標準則由我們根據實際情況進行設定。例如,如果你想定位到某個 bug 那麼“預期”就意味著簡化版本包含與原始程式碼一致的錯誤。你可以利用指令碼程式寫出任何你想要的“預期”標準,C-Reduce 會依據該指令碼程式確保簡化版本符合預先定義的行為。

安裝

C-Reduce 程式的依賴項非常多,安裝也很複雜。好在有 Homebrew 的加持,我們只需輸入以下命令即可:

brew install creduce
複製程式碼

如果你想手動安裝的話,可以參照該安裝 指南

簡易示例

想出一個小的示例程式碼解釋 C-Reduce 是很困難的,因為它的主要目的是從一個大的程式簡化出一個小型示例。下面是我竭盡全力想出來的一個簡單 C 程式程式碼,它會產生一些難以理解的編譯警告。

$ cat test.c
#include <stdio.h>

struct Stuff {
    char *name;
    int age;
}

main(int argc, char **argv) {
    printf("Hello, world!
");
}

$ clang test.c
test.c:3:1: warning: return type of `main` is not `int` [-Wmain-return-type]
struct Stuff {
^
test.c:3:1: note: change return type to `int`
struct Stuff {
^~~~~~~~~~~~
int
test.c:10:1: warning: control reaches end of non-void function [-Wreturn-type]
}
^
2 warnings generated.
複製程式碼

從警告中我們知道 structmain 程式碼存在某種問題!至於具體問題是什麼,我們可以在簡化版本中仔細分析。

C-Reduce 能輕鬆的將程式精簡到遠超我們想象的程度。所以為了控制 C-Reduce 的精簡行為確保簡化操作符合特定預期,我們將編寫一個小的 shell 指令碼,編譯該段程式碼並檢查警告資訊。在該指令碼中我們需要匹配編譯警告並拒絕任何形式編譯錯誤,同時我們還需要確保輸出檔案包含 struct Stuff,詳細指令碼程式碼如下:

#!/bin/bash

clang test.c &> output.txt
grep error output.txt && exit 1
grep "warning: return type of `main` is not `int`" output.txt &&
grep "struct Stuff" output.txt
複製程式碼

首先,我們對簡化程式碼進行編譯並將輸出重定向到 output.txt。如果輸出檔案包含任何 “error” 字眼則立即退出並返回狀態碼 1。否則指令碼將會繼續檢查輸出文字是否包含特定警告資訊和文字 struct Stuff。當 grep 同時成功匹配上述兩個條件時,會返回狀態碼 0;否則就退出並返回狀態碼 1。狀態碼 0 表示符合預期而狀態碼 1 則表示簡化的程式碼不符合預期需要重新簡化。

接下來我們執行 C-Reduce 看看效果:

$ creduce interestingness.sh test.c 
===< 4907 >===
running 3 interestingness tests in parallel
===< pass_includes :: 0 >===
(14.6 %, 111 bytes)

...lots of output...

===< pass_clex :: rename-toks >===
===< pass_clex :: delete-string >===
===< pass_indent :: final >===
(78.5 %, 28 bytes)
===================== done ====================

pass statistics:
  method pass_balanced :: parens-inside worked 1 times and failed 0 times
  method pass_includes :: 0 worked 1 times and failed 0 times
  method pass_blank :: 0 worked 1 times and failed 0 times
  method pass_indent :: final worked 1 times and failed 0 times
  method pass_indent :: regular worked 2 times and failed 0 times
  method pass_lines :: 3 worked 3 times and failed 30 times
  method pass_lines :: 8 worked 3 times and failed 30 times
  method pass_lines :: 10 worked 3 times and failed 30 times
  method pass_lines :: 6 worked 3 times and failed 30 times
  method pass_lines :: 2 worked 3 times and failed 30 times
  method pass_lines :: 4 worked 3 times and failed 30 times
  method pass_lines :: 0 worked 4 times and failed 20 times
  method pass_balanced :: curly-inside worked 4 times and failed 0 times
  method pass_lines :: 1 worked 6 times and failed 33 times

		 ******** .../test.c ********

struct Stuff {
} main() {
}
複製程式碼

最終我們得到一個符合預期的簡化版本,並且會覆蓋原始程式碼檔案。所以在使用 C-Reduce 時需要注意這一點!一定要在程式碼的副本中執行 C-Reduce 進行簡化操作,否則可能對原始程式碼造成不可逆更改。

該簡化版本使程式碼問題成功暴露了出來:在 struct Stuff 型別宣告末尾忘記加分號,另外 main 函式沒有明確返回型別。這導致編譯器將 struct Stuff 錯誤的當作了返回型別。而 main 函式必須返回 int 型別,所以編譯器發出了警告。

Xcode 工程

對於單個檔案的簡化來說 C-Reduce 非常棒,但是更復雜場景下效果如何呢?我們大多數人都有多個 Xcode 工程,那麼如何簡化某個 Xcode 工程呢?

考慮到 C-Reduce 的工作方式,簡化 Xcode 工程並不簡單。它會將需要簡化的檔案拷貝到一個目錄中,然後執行指令碼。這樣雖然能夠同時執行多個簡化任務,但如果需要其他依賴才能讓它工作,那麼就可能無法簡化。好在可以在指令碼中執行各種命令,所以可以將專案的其餘部分複製到臨時目錄來解決這個問題。

我使用 Xcode 建立了一個標準的 Objective-C 語言的 Cocoa 應用,然後對 AppDelegate.m 進行如下修改:

#import "AppDelegate.h"

@interface AppDelegate () {
    NSWindow *win;
}

@property (weak) IBOutlet NSWindow *window;
@end

@implementation AppDelegate

- (void)applicationDidFinishLaunching: (NSRect)visibleRect {
    NSLog(@"Starting up");
    visibleRect = NSInsetRect(visibleRect, 10, 10);
    visibleRect.size.height *= 2.0/3.0;
    win = [[NSWindow alloc] initWithContentRect: NSMakeRect(0, 0, 100, 100) styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO];
	
    [win makeKeyAndOrderFront: nil];
    NSLog(@"Off we go");
}

@end
複製程式碼

這段程式碼會讓應用在啟動時崩潰:

* thread #1, queue = `com.apple.main-thread`, stop reason = EXC_BAD_ACCESS (code=EXC_I386_GPFLT)
	  * frame #0: 0x00007fff3ab3bf2d CoreFoundation`__CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ + 13
複製程式碼

上面的內容並不是一個非常有用的呼叫棧資訊。雖然我們可以通過除錯追溯問題,但是這裡我們嘗試使用 C-Reduce 來進行問題定位。

這裡的 C-Reduce 預期定義將包含更多的內容。首先我們需要給應用設定執行的超時時間。我們會在執行時進行崩潰捕獲操作,如果沒有發生崩潰則保持應用正常執行直到觸發超時處理而退出。下面是一段網上隨處可見的 perl 指令碼程式碼:

function timeout() { perl -e `alarm shift; exec @ARGV` "$@"; }
複製程式碼

緊接著我們需要拷貝該工程檔案:

cp -a ~/Development/creduce-examples/Crasher .
複製程式碼

然後將修改後的 AppDelegate.m 檔案拷貝到合適的路徑下。(注意:如果檔案發現合適簡化版本,C-Reduce 會將檔案複製回來,所以一定要在這裡使用 cp 而不是 mv。使用 mv 會導致一個奇怪的致命錯誤。)

cp AppDelegate.m Crasher/Crasher
複製程式碼

接下來我們切換到 Crasher 目錄執行編譯命令,並在發生錯誤時退出。

cd Crasher
xcodebuild || exit 1
複製程式碼

如果編譯成功,則執行應用並且設定超時時間。我的系統對編譯項進行了設定,所以 xcodebuild 命令會將編譯結果存放著本地 build 目錄下。因為配置可能存在差異,所以你首先需要自行檢查。如果你將配置設為共享構建目錄的話,那麼需要在命令列中增加 —n 1 來禁用 C-Reduce 的併發構建操作。

timeout 5 ./build/Release/Crasher.app/Contents/MacOS/Crasher
複製程式碼

如果應用發生崩潰的話,那麼會返回特定狀態碼 139 。此時我們需要將其轉化為狀態碼 0 ,其它情形統統返回狀態碼 1。

if [ $? -eq 139 ]; then
    exit 0
else
    exit 1
fi
複製程式碼

緊接著,我們執行 C-Reduce:

$ creduce interestingness.sh Crasher/AppDelegate.m
...
(78.1 %, 151 bytes)
===================== done ====================

pass statistics:
  method pass_ints :: a worked 1 times and failed 2 times
  method pass_balanced :: curly worked 1 times and failed 3 times
  method pass_clex :: rm-toks-7 worked 1 times and failed 74 times
  method pass_clex :: rename-toks worked 1 times and failed 24 times
  method pass_clex :: delete-string worked 1 times and failed 3 times
  method pass_blank :: 0 worked 1 times and failed 1 times
  method pass_comments :: 0 worked 1 times and failed 0 times
  method pass_indent :: final worked 1 times and failed 0 times
  method pass_indent :: regular worked 2 times and failed 0 times
  method pass_lines :: 8 worked 3 times and failed 43 times
  method pass_lines :: 2 worked 3 times and failed 43 times
  method pass_lines :: 6 worked 3 times and failed 43 times
  method pass_lines :: 10 worked 3 times and failed 43 times
  method pass_lines :: 4 worked 3 times and failed 43 times
  method pass_lines :: 3 worked 3 times and failed 43 times
  method pass_lines :: 0 worked 4 times and failed 23 times
  method pass_lines :: 1 worked 6 times and failed 45 times

******** /Users/mikeash/Development/creduce-examples/Crasher/Crasher/AppDelegate.m ********

#import "AppDelegate.h"
@implementation AppDelegate
- (void)applicationDidFinishLaunching:(NSRect)a {
    a = NSInsetRect(a, 0, 10);
    NSLog(@"");
}
@end
複製程式碼

我們得到一個極其精簡的程式碼。雖然 C-Reduce 沒有移除 NSLog 那行程式碼,但是崩潰看起來並不是它引起的。所以此處導致崩潰的程式碼只能是 a = NSInsetRect(a, 0, 10); 這行程式碼。通過檢查該行程式碼的功能和使用到的變數,我們能發現它使用了一個 NSRect 型別的變數而 applicationDidFinishLaunching 函式的入參實際上並不是該型別。

- (void)applicationDidFinishLaunching:(NSNotification *)notification;
複製程式碼

因此該崩潰應該是由於型別不匹配導致的錯誤引起的。

因為編譯工程的耗時遠超過單檔案而且很多測試示例都會觸發超時處理,所以此例中的 C-Reduce 執行時間會比較長。C-Reduce 會在每次執行成功後將精簡的檔案寫回原始檔案,所以你可以使用文字編輯器保持檔案的開啟狀態並檢視更改結果。另外你可以在合適時時機執行 ^C 命令結束 C-Reduce 執行,此時會得到部分精簡過的檔案。如果有必要你後續可以在此基礎上繼續進行精簡工作。

Swift

如果您使用 Swift 並且也有精簡需求時該怎麼辦呢?從名字上來看,我原本以為 C-Reduce 只適用於 C(也許還包括 C++,因為很多工具都是如此)。

不過好在,這次我的直覺錯了。C-Reduce 確實有一些與 C 相關的特定驗證測試,但大部分還是和語言無關的。無論你使用何種語言只要你能寫出相關的驗證測試,C-Reduce 都能派上用場,雖然效率可能不是很理想。

下面我們就來試一試。我在 bugs.swift.org 上面找到了一個很好的測試 用例。不過該崩潰只出現在 Xcode9.3 版本上,而我正好就安裝了該版本。下面是該 bug 示例的簡易修改版:

import Foundation

func crash() {
    let blah = ProblematicEnum.problematicCase.problematicMethod()
    NSLog("(blah)")
}

enum ProblematicEnum {
    case first, second, problematicCase

    func problematicMethod() -> SomeClass {
    	let someVariable: SomeClass

    	switch self {
    	case .first:
    	    someVariable = SomeClass()
    	case .second:
    	    someVariable = SomeClass()
    	case .problematicCase:
    	    someVariable = SomeClass(someParameter: NSObject())
    	    _ = NSObject().description
    	    return someVariable // EXC_BAD_ACCESS (simulator: EXC_I386_GPFLT, device: code=1)
    	}

    	let _ = [someVariable]
    	return SomeClass(someParameter: NSObject())
    }

}

class SomeClass: NSObject {
    override init() {}
    init(someParameter: NSObject) {}
}

crash()
複製程式碼

當我們嘗試在啟用優化的情況下執行程式碼時,會出現如下結果:

$ swift -O test.swift 
<unknown>:0: error: fatal error encountered during compilation; please file a bug report with your project and the crash log
<unknown>:0: note: Program used external function `__T04test15ProblematicEnumON` which could not be resolved!
...
複製程式碼

與之對應的驗證指令碼為:

swift -O test.swift
if [ $? -eq 134 ]; then
    exit 0
else
    exit 1
fi
複製程式碼

執行 C-Reduce 程式我們可以達到如下的簡化版本:

enum a {
    case b, c, d
    func e() -> f {
    	switch self {
    	case .b:
    	    0
    	case .c:
    	    0
    	case .d:
    	    0
    	}
    	return f()
    }
}

class f{}
複製程式碼

深入解析該編譯錯誤超出了本文的範圍,但如果我們需要對其進行修復時,該簡化版本顯然更方便。我們得到了一個相當簡單的測試用例。 我們還可以推斷出 Swift 語句和類的例項化之間存在一些互動,否則 C-Reduce 可能會刪除其中一個。這為編譯器導致該崩潰的原因提供了一些非常好的提示。

總結

測試示例的盲約精簡併不是一種多複雜的除錯技術,但是自動化讓其變的更為有用高效。C-Reduce 可以作為你除錯工具箱的一個很好補充。它並不適用所有場景,但是它在面對有些問題時能夠帶來不小的幫助。雖然在需要與多檔案測試用例一起工作時可能存在一些困難,但檢驗指令碼能夠解決了該問題。另外,對於 Swift 這類其他語言來說 C-Reduce 也是開箱即用的,而不僅僅只能在 C 語言中發揮功效,所以不要因為你使用的語言不是 C 而放棄它。

今天內容到此為止。下次我還會帶來與程式設計和程式碼相關的新內容。當然你也可以將你感興趣的話題 傳送給我

本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問 swift.gg

相關文章