天天看點

從Rust到遠方:C星系什麼是C語言,為什麼有C?Rust ? C理論分析實戰C ? 可執行程式更多的細節測試

來源:https://mnt.io/2018/09/11/from-rust-to-beyond-the-c-galaxy/

這篇部落格文章是這一系列解釋如何将Rust發射到地球以外的許多星系的文章的一部分:

  • 前奏,
  • WebAssembly 星系
  • ASM.js星系
  • C星系(目前這一集)
  • PHP星系,以及
  • NodeJS 星系

今天将要探索的是C語言星系。這篇文章會解釋什麼是C語言(比較簡要),理論上怎樣編譯Rust供C使用,以及如何在實際使用從Rust和C兩方面來實作我們的Rust解析器。我們還将看到如何測試這樣的綁定。

什麼是C語言,為什麼有C?

C應該是在全球範圍内被應用和被知道的最為廣泛的一種程式設計語言。Wikipedia的引用:

C[...] 是一種通用的指令式計算機程式設計語言,支援結構化程式設計、詞法變量作用域和遞歸,而靜态類型系統可以防止許多意外操作。通過設計,C提供了有效地映射到典型機器指令的構造,是以它在以前用彙編語言編碼的應用程式中得到了持久的使用,包括作業系統,以及從超級計算機到嵌入式系統的各種計算機應用軟體。
從Rust到遠方:C星系什麼是C語言,為什麼有C?Rust ? C理論分析實戰C ? 可執行程式更多的細節測試

Dennis Ritchie, C語言的發明者.

C語言對程式設計語言世界的影響可能是史無前例的。從作業系統開始以及之上的幾乎所有的東西都是用C語言寫的。今天,它是世界上為數不多的通用标準,連結任何機器上的任何系統上的任何程式。換句話說,與C語言相容為所有事情打開了一扇大門。您的程式将能夠直接與任何程式輕松對話。

因為像PHP或Python這樣的語言都是用C語言編寫的,在我們特定的Gutenberg解析器用例中,這意味着解析器可以被PHP或Python直接嵌入和使用,幾乎沒有開銷。非常整潔!

Rust ? C

從Rust到遠方:C星系什麼是C語言,為什麼有C?Rust ? C理論分析實戰C ? 可執行程式更多的細節測試

為了在C裡面使用Rust,隻需要下面兩個東西:

  • 一個靜态庫(.a檔案)
  • 一個頭檔案(.h檔案)

理論分析

要将Rust項目編譯成靜态庫,crate類型屬性必須包含staticlib值。讓我們編輯一下Cargo.toml如下:

[lib]
name = "gutenberg_post_parser"
crate-type = ["staticlib"]
           

複制

運作

cargo build -release

之後, 就會有

libgutenberg_post_parser.a

檔案被生成到

target/release/

。完工!

cargo

rustc

使這一步非常容易。

現在輪到頭檔案了。它可以手動寫成,但這樣會非常枯燥而且容易過時即和源代碼不同步。我們的目标是自動化生成。進入

cbindgen

cbindgen

可以用來生成Rust代碼的C綁定。目前它主要被開發來支援建立WebRender的綁定,但是它還被設計得可以支援任何項目。

要安裝cbindgen,編輯你的Cargo.toml檔案,如下:

[package]
build = "build.rs"

[build-dependencies]
cbindgen = "^0.6.0"
           

複制

事實上,cbindgen有兩種使用方式:獨立指令行可執行程式,或者一個庫。我喜歡使用庫的方式,因為這讓安裝更簡單。

注意我們已經訓示Cargo用build.rs來建構項目。這個檔案是一個很合适的地方來使用cbindgen來生成C頭檔案。我們來寫一下!

extern crate cbindgen;

fn main() {
    let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();

    cbindgen::generate(crate_dir)
        .expect("Unable to generate C bindings.")
        .write_to_file("dist/gutenberg_post_parser.h");
}
           

複制

有了這些資訊,cbindgen會掃描項目的源代碼并且會自動的生成C頭檔案到dist/gutenberg_post_parser.h。稍後會細講掃描部分,現在我們來快速的看看如何控制頭檔案中的内容。基于上面的代碼片段,cbindgen會到CARGO_MANIFEST_DIR目錄去找一個叫做cbindgen.toml的配置檔案,也就是crate的根目錄。我們看起來是這樣的:

header = """
/*
Gutengerg Post Parser, the C bindings.

Warning, this file is autogenerated by `cbindgen`.
Do not modify this manually.

*/"""
tab_width = 4
language = "C"
           

複制

它非常簡潔且自描述。文檔也把配置描述的很詳細。

cbindgen要掃描代碼,碰到有

#[repr(C)]

,

#[repr(size)]

or

#[repr(transparent)]

修飾的

structs

或者

enums

會停下來,還有那些用

extern "C"

标記的公共函數。我們繼續寫:

#[repr(C)]
pub struct Slice {
    pointer: *const c_char,
    length: usize
}

#[repr(C)]
pub enum Option {
    Some(Slice),
    None
}

#[no_mangle]
pub extern "C" parse(pointer: *const c_char) -> c_void { … }
           

複制

然後cbindgen的輸出會是這樣:

… header comment …

typedef struct {
    const char *pointer;
    uintptr_t length;
} Slice;

typedef enum {
    Some,
    None,
} Option_Tag;

typedef struct {
    Slice _0;
} Some_Body;

typedef struct {
    Option_Tag tag;
    union {
        Some_Body some;
    };
} Option;

void parse(const char *pointer);
           

複制

可以工作,非常棒!

注意有

#[no_mangle]

修飾Rust的parse函數。它訓示編譯器不要對這個函數重命名,是以這個函數在C語言的表示裡面會保持和Rust相同的名字。

好了,這就是所有的理論基礎。實戰開始,我們有一個解析器需要綁定到C!

實戰

我們要來綁定parse函數。這個函數的輸出是我們要分析的語言的AST表示。回顧一下,我們原來的AST看起來是這樣的:

pub enum Node<'a> {
    Block {
        name: (Input<'a>, Input<'a>),
        attributes: Option<Input<'a>>,
        children: Vec<Node<'a>>
    },
    Phase(Input<'a>)
}
           

複制

這個AST是定義在Rust解析器裡面的。而Rust的C綁定會轉換這個AST到另外為C準備的

struct

enum

。Rust内部的類型不需要這個轉換,隻有對需要直接暴露到C語言的類型才是必須的。我們開始定義Node:

#[repr(C)]
pub enum Node {
    Block {
        namespace: Slice_c_char,
        name: Slice_c_char,
        attributes: Option_c_char,
        children: *const c_void
    },
    Phrase(Slice_c_char)
}
           

複制

可以立刻想到的:

  • Slice_c_char

    模拟Rust的切片(看下面),
  • enum Option_c_char

    模拟Option (看下面),
  • children

    成員是

    *const c_void

    類型。它應該是

    *const Vector_Node

    (我們定義的

    Vector

    ),但是

    Node

    的定義是基于

    Vector_Node

    的,相反也成立。循環定義的情況目前的cbindgen還不支援。是以它被定義為空指針,将在C裡面做強制轉換。
  • namespace

    name

    成員原來在Rust中是一個元組。因為在元組在cbindgen裡面沒有對應的類型,是以我們這裡用兩個成員來代替。

我們來定義

Slice_c_char

#[repr(C)]
pub struct Slice_c_char {
    pointer: *const c_char,
    length: usize
}
           

複制

這個定義借用了Rust的Slices語意。主要的好處是Rust的slice綁定到這個結構的時候不需要copy。

我們來定義

Option_c_char

#[repr(C)]
pub enum Option_c_char {
    Some(Slice_c_char),
    None
}
           

複制

最後,我們需要定義

Vector_Node

Result

。他們都是非常接近Rust的模拟:

#[repr(C)]
pub struct Vector_Node {
    buffer: *const Node,
    length: usize
}

#[repr(C)]
pub enum Result {
    Ok(Vector_Node),
    Err
}
           

複制

好的,所有的類型都定義了。是時候開始寫

parse

函數了:

#[no_mangle]
pub extern "C" fn parse(pointer: *const c_char) -> Result {
    …
}
           

複制

這個函數在C語言裡面接受一個指針。它由C配置設定代表了我們要分析的資料(也就是Gutenberg的部落格文章):記憶體是在C語言裡面配置設定的,Rust隻負責解析。Rust出色的地方展現在:沒拷貝,沒克隆,沒有混亂的記憶體,隻有指向資料的指針會傳回給C語言當作slices和數組。

工作流如下:

  • C裡面第一件事情:檢查指針不為空,
  • 基于這個指針用CStr重建輸入。這個标準API對于從Rust的角度抽象C字元串非常有用。差別是C字元串以NULL為結束位元組沒有長度,然而Rust字元串有長度而不是NULL位元組作為結束。
  • 運作解析器,轉換AST到“C AST”

我們開始!

pub extern "C" fn parse(pointer: *const c_char) -> Result {
    if pointer.is_null() {
        return Result::Err;
    }

    let input = unsafe { CStr::from_ptr(pointer).to_bytes() };

    if let Ok((_remaining, nodes)) = gutenberg_post_parser::root(input) {
        let output: Vec =
            nodes
                .into_iter()
                .map(|node| into_c(&node))
                .collect();

        let vector_node = Vector_Node {
            buffer: output.as_slice().as_ptr(),
            length: output.len()
        };

        mem::forget(output);

        Result::Ok(vector_node);
    } else {
        Result::Err
    }
}
           

複制

Vector_Node

裡面隻用到了指向

output

的指針,以及

output

的長度。這個轉換是比較輕量的。

現在來看

into_c

函數。有寫部分不會細講;不是因為它太難而是有點重複。所有的代碼都在這裡可以找到。

fn into_c<'a>(node: &ast::Node<'a>) -> Node {
    match *node {
        ast::Node::Block { name, attributes, ref children } => {
            Node::Block {
                namespace: …,
                name: …,
                attributes: …,
                children: …
            }
        },

        ast::Node::Phrase(input) => {
            Node::Phrase(…)
        }
    }
}
           

複制

我想展示

namespace

作為一個熱身(

name

,

attributes

Phrase

都非常類似),還會展示

childen

因為它處理

void

先轉換

ast::Node::Block.name.0

Node::Block.namespace

ast::Node::Block { name, …, … } => {
    Node::Block {
        namespace: Slice_c_char {
            pointer: name.0.as_ptr() as *const c_char,
            length: name.0.len()
        },

        …
           

複制

目前還非常的直覺。

namespace

Slice_c_char

類型。

pointer

name.0

切片的指針。

length

name.0

的長度。處理其它的Rust切片這個過程一樣。

children

有點不一樣,它需要下面的三步:

  • 把所有的

    childen

    作為C AST節點儲存到Rust vector裡面,
  • 轉換這個Rust vector到一個合法的

    Vector_Node

  • 轉換

    Vector_Node

    *const c_void pointer

ast::Node::Block { …, …, ref children } => {
    Node::Block {
        …

        children: {
            // 1. Collect all children as C AST nodes.
            let output: Vec =
                children
                    .into_iter()
                    .map(|node| into_c(&node))
                    .collect();

            // 2. Transform the vector into a Vector_Node.
            let vector_node = if output.is_empty() {
                Box::new(
                    Vector_Node {
                        buffer: ptr::null(),
                        length: 0
                    }
                )
            } else {
                Box::new(
                    Vector_Node {
                        buffer: output.as_slice().as_ptr(),
                        length: output.len()
                    }
                )
            }

            // 3. Transform Vector_Node into a *const c_void pointer.
            let vector_node_pointer = Box::into_raw(vector_node) as *const c_void;

            mem::forget(output);

            vector_node_pointer
        }
           

複制

第一步是直覺的的。

第二步,定義在沒有節點時候的行為。換句話說,定義了什麼是空

Vector_Node

buffer

必須是值為

NULL

位元組的原始指針,

length

也顯然會是0. 不這樣做,即使我在代碼裡面檢查了

buffer

的長度,我依然碰到了嚴重的段錯誤。注意

Vector_Node

是通過

Box::new

在堆上配置設定的,它可以很容易的和C共享。

第三步,用

Box::into_raw

函數消費這個box并且傳回一個封裝了的原始指針,這個指針指向box擁有的資料。這裡Rust不會釋放任何東西,這是我們的職責(或者更嚴謹的說是C語言的職責)。然後·Box::into_raw·傳回的·*mut Vector_Node·可以無成本轉換為·*const c_void·。

最後,我們通過·mem::forget·(你已經看到這個系列了的目前位置了,很大可能性已經知道它的作用了)訓示編譯器當

output

離開作用域的時候不要釋放它

對我自己來講,我花了好幾個小時去了解為什麼我的指針會得到随機位址,或者指向NULL資料。雖然得到的最終代碼看起來比較的簡單易讀,但是在知道如何做到這個之前卻不是那麼顯然的。

這就是Rust部分所有的内容。下一個部分我們有展示用C代碼來調用Rust,以及如何把所有的東西編譯到一起。

C ? 可執行程式

從Rust到遠方:C星系什麼是C語言,為什麼有C?Rust ? C理論分析實戰C ? 可執行程式更多的細節測試

既然Rust部分已經就緒,的要寫C的部分作為調用方。

最小可工作示例

我們快速的寫點代碼看看是否可以連結和編譯:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include "gutenberg_post_parser.h"

int main(int argc, char **argv) {
    FILE* file = fopen(argv[1], "rb");
    fseek(file, 0, SEEK_END);
    long file_size = ftell(file);
    rewind(file);

    char* file_content = (char*) malloc(file_size * sizeof(char));
    fread(file_content, 1, file_size, file);

    // Let's call Rust!
    Result output = parse(file_content);

    if (output.tag == Err) {
        printf("Error while parsing.\n");

        return 1;
    }

    const Vector_Node nodes = output.ok._0;
    // Do something with nodes.

    free(file_content);
    fclose(file);

    return 0;
}
           

複制

為了保持代碼的簡潔,我在示例代碼中沒有做任何的錯誤處理。如果你感興趣,可以到這裡找到是以的代碼。

代碼裡面做了什麼?第一個值得注意的是

#include "gutenberg_post_parser.h"

,這個是由cbindgen自動生成的頭檔案。

然後是從

argv[1]

得到的

filename

用來讀取部落格文章到

parse

函數。來自Rust的parse函數隻喜歡Result 和 Vector_Node類型

Rust的

enum Result { Ok(Vector_Node), Err }

編譯到C語言看起來是這樣的:

typedef enum {
    Ok,
    Err,
} Result_Tag;

typedef struct {
    Vector_Node _0;
} Ok_Body;

typedef struct {
    Result_Tag tag;
    union {
        Ok_Body ok;
    };
} Result;
           

複制

沒有必要說Rust版更容易閱讀,更緊湊,但這不是重點。為了檢查Result是否包含一個

OK

或者

Error

,我們必須檢查

tag

成員變量,就像我們以前檢查

output.tag == Err

。要得到

Ok

的内容,我們用

output.ok._0

(

_0

Ok_Body

的成員變量).

我們用clang來編譯!假設上面的代碼和

gutenberg_post_parser.h

檔案在同一個目錄,也就是在

dist/

目錄。是以:

$ cd dist
$ clang \
      # Enable all warnings. \
      -Wall \

      # Output executable name. \
      -o gutenberg-post-parser \

      # Input source file. \
      gutenberg_post_parser.c \

      # Directory where to find the static library (*.a). \
      -L ../target/release/ \

      # Link with the gutenberg_post_parser.h file. \
      -l gutenberg_post_parser \

      # Other libraries to link with.
      -l System \
      -l pthread \
      -l c \
      -l m
           

複制

就這些!我們最終得到一個

gutenberg-post-parser

可執行檔案,它能運作C和Rust。

更多的細節

在原始源代碼中,可以找到一個在

stdout

上列印整個AST的遞歸函數,即print(夠原始吧,不是嗎?)。下面是Rust文法和C文法之間的一些并列比較。

Rust裡的

Vector_Node

:

pub struct Vector_Node {
    buffer: *const Node,
    length: usize
}
           

複制

C裡的

Vector_Node

typedef struct {
     const Node *buffer;
     uintptr_t length;
 } Vector_Node;
           

複制

是以,要分别讀取節點數(數組的長度)和C中的節點,必須這樣寫:

const uintptr_t number_of_nodes = nodes->length;

for (uintptr_t nth = 0; nth < number_of_nodes; ++nth) {
    const Node node = nodes->buffer[nth];
}
           

複制

這幾乎是慣用的C代碼! 節點在C中定義為:

typedef enum {
    Block,
    Phrase,
} Node_Tag;

typedef struct {
    Slice_c_char namespace;
    Slice_c_char name;
    Option_c_char attributes;
    const void* children;
} Block_Body;

typedef struct {
    Slice_c_char _0;
} Phrase_Body;

typedef struct {
    Node_Tag tag;
    union {
        Block_Body block;
        Phrase_Body phrase;
    };
} Node;
           

複制

是以,一旦擷取了節點,就可以編寫以下代碼來檢測其類型:

if (node.tag == Block) {
    // …
} else if (node.tag == Phrase) {
    // …
}
           

複制

讓我們先關注一下

Block

,然後列印

namespace

name

,他們之間用斜杠(/)分隔:

const Block_Body block = node.block;

const Slice_c_char namespace = block.namespace;
const Slice_c_char name = block.name;

printf(
    "%.*s/%.s\n",
    (int) namespace.length, namespace.pointer,
    (int) name.length, name.pointer
);
           

複制

printf中特殊的%.s形式中的允許根據字元串的長度和指針列印字元串。

我覺得看看如何把

children

節點從

void

轉換到

Vector_Node

比較有趣,隻需要一行:

const Vector_Node* children = (const Vector_Node*) (block.children);
           

複制

我想這就是所有的細節!

測試

我認為,看看如何直接用Rust對C綁定進行單元測試也很有趣。要模拟C綁定,首先,輸入必須是C格式的,是以字元串必須是C字元串。我更喜歡寫一個宏來做這個事情:

macro_rules! str_to_c_char {
    ($input:expr) => (
        {
            ::std::ffi::CString::new($input).unwrap()
        }
    )
}
           

複制

第二,相反的方向:

parse

函數傳回C語言的資料,是以需要将它們轉換回Rust。同樣,我更喜歡為此編寫一個宏:

macro_rules! slice_c_char_to_str {
    ($input:ident) => (
        unsafe {
            ::std::ffi::CStr::from_bytes_with_nul_unchecked(
                ::std::slice::from_raw_parts(
                    $input.pointer as *const u8,
                    $input.length + 1
                ).to_str().unwrap()
            )
        }
    )
}
           

複制

好吧!最後一步是編寫單元測試。寫一個短語作為被測試對象的示例,對于Block的想法是一樣的,但是前者的代碼更簡潔。

#[test]
fn test_root_with_a_phrase() {
    let input = str_to_c_char!("foo");
    let output = parse(input.as_ptr());

    match output {
        Result::Ok(result) => match result {
            Vector_Node { buffer, length } if length == 1 =>
                match unsafe { &*buffer } {
                    Node::Phrase(phrase) => {
                        assert_eq!(slice_c_char_to_str!(phrase), "foo");
                    },

                    _ => assert!(false)
                },

            _ => assert!(false)
        },

        _ => assert!(false)
    }
}
           

複制

這裡發生了什麼?輸入和輸出都準備好了。前者是C字元串“foo”。後者是解析的結果。然後有一個比對來驗證AST。Rust非常有表達力,這個測試就是一個很好的例子。進入

Vector_Node

分支,當且僅當向量長度為1時,表示為

length== 1

時,然後将短語的内容轉換為Rust字元串,并用正常的

assert_eq!

宏進行比較。

注意,在本例中

buffer

類型為

*const Node

,是以它表示向量的第一個元素。如果我們想通路下一個元素,我們需要使用

Vec::from_raw_parts

函數來獲得适當的Rust API來操作這個向量。

#結論

我們已經看到Rust可以很容易地嵌入C中。在本例中,Rust已編譯為一個靜态庫和一個頭檔案;前者是原生的Rust工具,後者是使用cbindgen自動生成的。

用Rust編寫的解析器操作一個由C配置設定和擁有的字元串。Rust隻将這個字元串的指針(作為切片)傳回給C,然後C就可以輕松地讀取這些指針了。惟一棘手的部分是Rust在堆上配置設定了一些C必須釋放的資料(比如節點的數組)。不過,本文省略了

“free”

部分:它并不代表很大的挑戰,而C開發人員可能已經習慣了這種情況。

Rust不使用垃圾收集器這一事實使它成為這些場景的完美候選語言。這些綁定背後的故事實際上都是關于記憶體的:誰配置設定了什麼,記憶體中資料的形式是什麼。Rust有一個

#[repr(C)]

裝飾器來訓示編譯器使用C記憶體布局,這使得C綁定對于開發人員來說非常簡單。

我們還看到,C綁定可以在Rust内部進行單元測試,并與cargo測試一起運作。

cbindgen是這次冒險的一個寶貴的夥伴,通過自動生成頭檔案,它将代碼的更新和維護簡化為build.rs腳本。

在性能方面,C應該比Rust有相似的結果,非常快。我沒有運作基準測試來驗證這個聲明,它純粹是理論上的。它可以作為下一篇文章的主題!

現在我們已經成功地将Rust嵌入到C中,一個全新的世界向我們打開了!下一集将把Rust作為一個本地擴充(用C編寫)推向PHP的世界。我們走吧!