C 不再是一種編程語言(cctv5體育節目表)
近日,Rust和Swift資深專家Aria Beingessner發布的一篇文章《C 不再是一種編程語言》在Hacker News上引起了熱烈討論。

Hacker News評論區:https://news.ycombinator.com/item?id=30704642
Aria和朋友Phantomderp在“對C ABI接口感到非常失望并試圖修復上”達成了高度一致。但在失望的原因上,Aria與朋友各自持不同意見。那具體產生了哪些分歧呢?為什么會提出C不再是一種編程語言的觀點呢?筆者對原文進行了編譯:
整理 | 于軒
出品 | 程序人生?(ID:coder _life)
Phantomderp試圖從原生上改善使用C本身作為編程語言的條件,而Aria則希望改善使用C以外的任何語言條件。
這時候大家就會產生疑問了,這個問題和C有什么關系?
Aria表示:如果C真的是一種編程語言,那就和它無關。不幸的是,它并不是。這不是說數十億種實現方式和失敗的層次結構,導致它的定義方式非常糟糕的事實,而是C被提升到一個具有威望和權力的角色,它的統治是絕對和永恒的。C是編程的通用語言,我們都必須學C,因此C不再只是一種編程語言,它成了每一種通用編程語言都需要遵守的協議。
這實際有點像是關于整個“C是一個不可捉摸的實現定義混亂” 。但僅因為它讓我們不得不使用這個協議,這就變成了一個更大的噩夢。
外部功能接口
下面一起來談談技術問題。假如你已經完成了你的新語言Bappyscript的設計,對Bappy Paws/Hooves/Fins有一流的支持。這是一種神奇的語言,將徹底改變cats、sheep、和sharks的編程方式。
但現在需要讓它真正做一些有用的事情。比如接受用戶的輸入,或者輸出,或者字面上的任何可觀察之類的東西。如果你想讓該語言編寫的程序與主流操作系統兼容,那就需要與操作系統的界面進行交互。聽說Linux上的一切都“只是一個文件”,所以一起在Linux上打開一個文件吧!
OPEN(2)
NAME
open, openat, creat - open and possibly create a file
SYNOPSIS
#include?
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
int creat(const char *pathname, mode_t mode);
int openat(int dirfd, const char *pathname, int flags);
int openat(int dirfd, const char *pathname, int flags, mode_t mode);
/* Documented separately, in openat2(2): */
int openat2(int dirfd, const char *pathname,
const struct open_how *how, size_t size);
Feature Test Macro Requirements for glibc (see
feature_test_macros(7)):
openat():
Since glibc 2.10:
_POSIX_C_SOURCE >= 200809L
Before glibc 2.10:
_ATFILE_SOURCE
這是Bappyscript,不是C,那Linux的Bappyscript接口在哪里?
你說Linux中沒有Bappyscript接口是什么意思?好吧,當然是因為這是一種全新的語言,但你會添加一個,對嗎?那這時你就會發現,你好像必須使用他們給的東西。
你將需要某種接口,讓語言能夠調用外部的函數,就像外部函數接口FFI。然后你發現Rust也有C FFI,Swift也有,甚至Python也有。
你會發現,每個人都必須學會C才能與主流的操作系統對話,然后當需要相互對話時,大家突然都用起了C。所以…為什么不直接用C來相互對話呢?
現在C就變成了一種編程通用語言,不僅是一種編程語言,它還是一種協議了。
與C對話包括哪些內容?
很明顯,基本上每種語言都必須學會與C進行對話,而且這種語言絕對是非常明確的。
"對話 "C是什么意思?它意味著以C頭文件的形式獲得接口類型和功能的描述,并以某種方式:
匹配這些類型的布局
用鏈接器做一些事情,將函數的符號解析為指針
用適當的ABI來調用這些函數(比如把args放在正確的寄存器中)
那么,這里就有幾個問題:
你實際上不能寫一個C解析器
C實際上沒有ABI,甚至沒有定義的類型布局
實際上無法解析一個C頭文件
Aria曾斷言解析C基本上是不可能的,但有人說其實有很多工具可以讀取C頭文件,比如rust-bindgen。事實果真如此嗎?其實不然。
bindgen使用libclang來解析C和C++頭文件。要修改bindgen搜索libclang的方式,請參閱clang-sys文檔。關于bindgen如何使用libclang的更多細節,請參閱bindgen用戶指南。
任何花費大量時間試圖快速解析C(++)頭文件的人都會很快放棄,然后讓一個C(++)編譯器來做這件事。請記住,有意義地解析C頭文件不僅僅是解析:你還需要解決#includes、typedefs和macros的問題!所以現在不僅要實現所有相關功能,還要實現所有平臺的頭文件解析邏輯,并且還需要想方設法找到DEFINED!
就拿Swift來說,它在C互操作和資源方面擁有絕對優勢,它是由蘋果開發的一門編程語言,有效取代了Objective-C,成為在其平臺上定義和使用系統API的主要語言。在這樣做的過程中,它比其他任何人都更進一步實現了ABI穩定性和設計概念。
它也是Aria見過的最支持FFI的語言之一。它可以本地導入(Objective-)C(++)頭文件,并產生一個漂亮的本地Swift接口,其類型在邊界自動 "橋接 "到它們的Swift對等項(由于類型具有相同的ABI,所以通常是透明的)。
Swift也是由蘋果公司中許多構建和維護Clang和LLVM的人開發。這些人都是C及其衍生品方面的世界頂級專家。Doug Gregor就是其中之一,他曾表達了對C FFI的看法:
所有這些都是Swift內部使用Clang來處理 C(++) ABI的原因。這樣一來,我們就不會去追著Clang增加的每一個影響ABI的新屬性。
可以看出,即使是Swift也不想花時間解析C(++)頭文件。那么,如果你絕對不想讓C編譯器在編譯時解析和解決頭文件,你該怎么做呢?
你需要手工翻譯!int64_t??還是寫i64. long…?什么是long?
C實際上沒有ABI
好吧,這沒有什么好驚訝的:C語言中的整數類型,為了 “可移植性”而被設計成搖擺不定的大小,實際上大小也是不穩定的。我們可以認為CHAR_BIT很奇怪,但這也不能幫助我們了解long的大小和對齊方式。
有人說每個平臺都有標準化的調用約定和ABI,確實有,而且它們通常定義了C中關鍵原語的布局(并且有些不只是用C類型來定義調用約定,這里側眼于AMD64 SysV)。
還有一個棘手的問題:架構并沒有定義ABI,操作系統也是。我們必須在一個特定的目標三元組上全力以赴,比如 “x86_64-pc-windows-gnu”(不要和 "x86_64-pc-windows-msvc "混淆)。經過測試,一共有176個三元組。
>?rustc?--print?target-list
aarch64-apple-darwin
aarch64-apple-ios
aarch64-apple-ios-macabi
aarch64-apple-ios-sim
aarch64-apple-tvos
...
armv7-unknown-linux-musleabi
armv7-unknown-linux-musleabihf
armv7-unknown-linux-uclibceabihf
...
x86_64-uwp-windows-gnu
x86_64-uwp-windows-msvc
x86_64-wrs-vxworks
>_
這實在是有太多ABI了,因為測試中甚至沒有用到所有不同的調用約定,如stdcall vs fastcall或aapcs vs aapcs-vfp。
但至少所有這些ABI和調用約定之類的東西,都可以一種方便使用的機器可讀格式獲得。至少主流的C編譯器在特定目標三元組的ABI上達成了一致! 當然有一些奇怪的jank C編譯器,但Clang和GCC不是:
> abi-checker --tests ui128 --pairs clang_calls_gcc gcc_calls_clang
...
Test ui128::c::clang_calls_gcc::i128_val_in_0_perturbed_small passed
Test ui128::c::clang_calls_gcc::i128_val_in_1_perturbed_small passed
Test ui128::c::clang_calls_gcc::i128_val_in_2_perturbed_small passed
Test ui128::c::clang_calls_gcc::i128_val_in_3_perturbed_small passed
Test ui128::c::clang_calls_gcc::i128_val_in_0_perturbed_big failed!
test 57 arg3 field 0 mismatch
caller: [30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 3A, 3B, 3C, 3D, 3E, 3F]
callee: [38, 39, 3A, 3B, 3C, 3D, 3E, 3F, 40, 41, 42, 43, 44, 45, 46, 47]
Test ui128::c::clang_calls_gcc::i128_val_in_1_perturbed_big failed!
test 58 arg3 field 0 mismatch
caller: [30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 3A, 3B, 3C, 3D, 3E, 3F]
callee: [38, 39, 3A, 3B, 3C, 3D, 3E, 3F, 40, 41, 42, 43, 44, 45, 46, 47]
...
392 passed, 60 failed, 0 completely failed, 8 skipped
上面是Aria在Ubuntu 20.04 x64上運行的FFI abi-checker,她在這個相當重要的、表現良好的平臺上測試了一些非常無聊的情況。結果發現,一些整數參數在兩個由Clang和GCC編譯的靜態庫之間按值傳遞失敗了!
Aria發現,Clang和GCC甚至不能就Linux x64上_int128的ABI達成一致。
Aria本來是為了檢查rustc中的錯誤,沒想到會在一個重要的、常用的ABI上發現兩大主流C編譯器的不一致。
試圖馴服C
Aria認為,可怕的是對C頭文件進行語義解析,只能由該平臺的C編譯器來完成。即使C編譯器告訴了你類型和如何理解注釋,但實際上你仍然不知道所有內容的大小/對齊/慣例。那如何與這些亂七八糟的東西進行互操作呢?Aria提供了兩種選擇。
第一個選擇是完全投降,將你的語言與C進行靈魂綁定,這可以是以下任何一種:
用C(++)編寫你的編譯器/運行時,這樣它就可以用C了
讓你的 "codegen "直接發出C(++),這樣用戶無論如何都需要一個C編譯器
將你的編譯器建立在一個成熟的主要C編譯器(Clang或GCC)之上
但上面這些也只能讓你走這么遠,因為除非你的語言真的暴露了unsigned long long,否則你將繼承C的巨大可移植性混亂。
這就讓我們想到了第二個選擇:撒謊、欺騙和偷竊。
如果這一切是無論如何都無法避免的災難,你還不如開始手工翻譯類型和接口定義到你的語言中,基本上就是我們每天在Rust中所做的事情。比如,人們使用rust-bindgen和friends自動化處理一些事,但很多時候,定義會被檢查或手工調整。因為人們不想浪費時間,去嘗試Phantomderp的定制C構建系統可移植地工作。
在Rust中,Linux x64上的intmax_t是什么?
pub type intmax_t = i64;
在Nim中,Linux x64上的long long是什么?
clonglong {.importc: "long long", nodecl.} = int64
很多代碼已經完全放棄將C保持在循環中,開始對核心類型的定義進行硬編碼。畢竟,它們顯然只是平臺ABI的一部分!他們要改變intmax_t的大小嗎?這顯然是一個破壞ABI的變化!
那phantomderp正在研究的又是什么?
我們討論過為何intmax_t不能被改變,因為如果我們從long long(64位整數)改為_int128_t(128位整數),某個地方的二進制會失控使用錯誤的調用約定/返回約定。但有沒有一種方法,如果代碼選擇了它或其他東西,我們可以為較新的應用程序升級函數調用,而讓舊應用程序保持不變?讓我們編寫一些代碼,測試一下透明別名可以幫助ABI的想法。
Aria提出了她的疑問:編程語言如何處理這種變化?如何指定與哪個版本的 intmax_t互操作?如果你有一些C頭文件提到intmax_t,它使用的是哪個定義?
在此討論具有不同ABI的平臺的主要機制是目標三元組。你知道什么是目標三元組嗎?你知道基本上涵蓋了過去20年里所有主流桌面/服務器Linux發行版的 x86_64-unknown-linux-gnu包括什么嗎?現在,雖然表面上可以針對這個目標進行編譯,并得到一個在所有這些平臺上都能“正常工作”的二進制文件,但Aria不相信有些程序會被編譯成intmax_t大于int64_t。
任何試圖做出這種改變的平臺都會成為一個新的x86_64-unknown-linux-gnu2 目標三元組嗎?如果任何針對x86_64-unknown-linux-gnu編譯的東西都被允許在上面運行,這難道還不夠嗎?
在不破壞ABI的情況下更改簽名
"那又怎樣,C永遠不會再有進步嗎?"不!但也是!因為他們提供了糟糕的設計。
老實說,進行ABI兼容的修改是一種藝術形式。這種藝術的一部分就是準備工作。具體來說,如果你準備好了,做出不破壞ABI的修改就會容易得多。
正如phantomderp的文章所指出的,像glibc( g 是 x86_64-unknown-linux-gnu 中的 gnu )早就明白了這一點,并使用符號版本化這樣的機制來更新簽名和API,同時為任何針對舊版本編譯的人保留舊版本。
因此,如果你有 int32_t my_rad_symbol(int32_t)?,你告訴編譯器將其導出為 my_rad_symbol_v1 ,那么任何根據這個頭文件進行編譯的人,都會在他們的代碼中寫上 my_rad_symbol ,但針對 my_rad_symbol_v1 鏈接。
然后當你決定實際上應該使用int64_t時,你可以把int64_t my_rad_symbol(int64_t)?作為my_rad_symbol_v2 ,但保留舊的定義作為 ?my_rad_symbol_v1。任何針對較新版本頭文件進行編譯的人都會高興地使用v2符號,而針對舊版本進行編譯的人則繼續使用v1!
但是你仍然有一個兼容性的問題:任何用新頭文件編譯的人都不能與庫的舊版本進行鏈接,庫的V1版本根本沒有V2符號!因此,如果你想獲得熱門的新功能,你就要接受與舊系統的不兼容。
不過這并不是什么大問題,它只是讓平臺供應商感到難過,因為沒有人能夠立即使用他們花了這么多時間做的東西。你不得不推出一個閃亮的新功能,然后讓大家等待它變得足夠普遍和成熟。但為了人們愿意依賴它并中斷對舊平臺的支持(或者愿意為它實施動態檢查和回退)時,你必須坐等幾年。
如果你真的想讓人們立即升級,那就要談論向前兼容的問題。這讓舊版本的東西以某種方式與他們沒有概念的新功能一起工作。
在不破壞ABI的情況下更改類型
那除了可以改變一個函數的簽名,還可以改變類型布局嗎?Aria表示,這取決于你是如何暴露類型的。
C真正奇妙的一個特點是,它可以讓你區分一個已知布局的類型和一個未知布局的類型。如果你只在C頭文件中前向聲明一個類型,那么任何與之交互的用戶代碼都不被“允許”知道該類型的布局,并且必須一直在指針后面不透明地處理它。
所以你可以做一個像MyRadType* make_val()和use_val(MyRadType*)的API,然后使用同樣的符號版本技巧來暴露make_val_v1和 use_val_v1符號,任何時候你想改變這個布局,你就在所有與該類型交互的東西上增加版本。類似地,你在MyRadTypeV1、MyRadTypeV2和一些類型定義中保留了一些,以確保人們使用“正確”的類型。這樣就可以在不同的版本之間改變類型的布局。
如果多個東西建立在你的庫之上,然后開始用不透明類型相互交談,壞事就會發生:
lib1: 制作一個API,接受MyRadType*并調用?use_val
lib2:調用 make_val并將結果傳遞給lib1
如果lib1和lib2針對庫的不同版本進行了編譯,那么make_val_v1就會被輸入到use_val_v2中!你有兩個選擇來處理這個問題:
1.說這是被禁止的,責備那些無論如何都要這么做的人,然后傷心
2.以一種向前兼容的方式設計MyRadType,這樣混合就可以了
常見的前向兼容技巧包括:
保留未使用的字段供未來版本使用
MyRadType的所有版本都有一個共同的前綴,可以讓你“檢查”你所使用的版本
擁有自定大小的字段,以便舊版本可以“跳過”新的部分
案例研究:MINIDUMP_HANDLE_DATA
微軟是這種向前兼容的大師,甚至可以實現在架構之間保持布局兼容。Aria最近正在處理的一個例子是Minidumpapiset.h中的MINIDUMP_HANDLE_DATA_STREAM。
這個API描述了一個有版本的值列表。該列表以這種類型開始:
typedef struct _MINIDUMP_HANDLE_DATA_STREAM {
ULONG32 SizeOfHeader;
ULONG32 SizeOfDescriptor;
ULONG32 NumberOfDescriptors;
ULONG32 Reserved;
} MINIDUMP_HANDLE_DATA_STREAM, *PMINIDUMP_HANDLE_DATA_STREAM;
其中:
SizeOfHeader 是MINIDUMP_HANDLE_DATA_STREAM本身的大小。如果他們需要在最后增加更多的字段,那也沒關系,因為舊版本可以使用這個值來檢測頭的“版本”,也可以跳過任何他們不知道的字段。
SizeOfDescriptor是數組中每個元素的大小。這讓你知道你有什么 "版本 "的元素,并跳過任何你不知道的字段。
NumberOfDescriptors?是數組長度
Reserved是一些額外的內存,無論如何他們決定保留在頭文件中(Minidumpapiset.h非常謹慎,從不在任何地方進行填充,因為填充字節有未指定的值,而且它是一種序列化的二進制文件格式。我希望他們添加這個字段是為了使結構的大小是8的倍數,這樣就不會有任何關于數組元素在標題之后是否需要填充的問題。這是在認真對待兼容性!)
而事實上,微軟實際上有理由使用這種版本方案,并定義了兩個版本的數組元素:
typedef struct _MINIDUMP_HANDLE_DESCRIPTOR {
ULONG64 Handle;
RVA TypeNameRva;
RVA ObjectNameRva;
ULONG32 Attributes;
ULONG32 GrantedAccess;
ULONG32 HandleCount;
ULONG32 PointerCount;
} MINIDUMP_HANDLE_DESCRIPTOR, *PMINIDUMP_HANDLE_DESCRIPTOR;
typedef struct _MINIDUMP_HANDLE_DESCRIPTOR_2 {
ULONG64 Handle;
RVA TypeNameRva;
RVA ObjectNameRva;
ULONG32 Attributes;
ULONG32 GrantedAccess;
ULONG32 HandleCount;
ULONG32 PointerCount;
RVA ObjectInfoRva;
ULONG32 Reserved0;
} MINIDUMP_HANDLE_DESCRIPTOR_2, *PMINIDUMP_HANDLE_DESCRIPTOR_2;
// The latest MINIDUMP_HANDLE_DESCRIPTOR definition.
typedef MINIDUMP_HANDLE_DESCRIPTOR_2 MINIDUMP_HANDLE_DESCRIPTOR_N;
typedef MINIDUMP_HANDLE_DESCRIPTOR_N *PMINIDUMP_HANDLE_DESCRIPTOR_N;
這些結構的實際細節不是很有趣,除了:
他們只是通過在末尾添加字段來改變它
有一個“最新版本”的類型定義
保留了一些也許再次Padding(填充)(RVA是一個ULONG32)
這是一個堅不可摧的向前兼容的龐然大物。它們對填充非常小心,它甚至在32位和64位之間有相同的布局 (這實際上是非常重要的,因為你希望一個架構上的minidump處理器能夠處理來自每個架構的minidump)。
案例研究:jmp_buf
Aria對這種情況不是很熟悉,但在研究歷史上的glibc中斷時,她在LWN上看到了一篇很棒的文章:《glibc s390 ABI中斷》,她假設它是準確的。
事實證明,glibc曾經破解過類型的ABI,至少在s390上。根據這篇文章的描述,它是混亂的。
特別是他們改變了setjmp/longjmp使用的保存狀態類型的布局,即jmp_buf 。現在,他們知道這是一個破壞ABI的變化,所以他們做了負責任的符號版本化的事情。
但jmp_buf并不是一個不透明的類型,其他東西都在內聯地存儲這個類型的實例,比如Perl的運行時間。不用說,這個相對晦澀的類型已經滲透到許多二進制文件中去了,最終的結論是,Debian的所有東西都需要重新編譯!
這篇文章甚至討論了將libc版本升級以應對這種情況的可能性:
在像debian這樣的混合ABI環境中,SO名稱碰撞導致兩個libc被加載并爭奪相同的符號命名空間,而解析(以及因此選擇ABI)則由ELF插值和范圍規則決定。這真是一場噩夢。這可能是一個比告訴大家重建并繼續生活更糟糕的解決方案。
真的能改變intmax_t嗎?
在Aria看來,不完全是。就像jmp_buf一樣,它不是一個不透明的類型,這意味著它被內聯到大量的隨機結構中,被認為具有大量其他語言和編譯器的特定表示,并且可能是大量公共接口的一部分。而這些接口并不在libc、Linux,甚至不在發行版維護者的控制之下。
當然,libc可以適當地使用符號版本技巧來使其API與新的定義兼容,但改變像 intmax_t這樣的基本數據類型的大小,是在一個平臺的大生態系統中尋求混亂。
Aria希望被證明自己是錯誤的,但據她所知,做出這樣的改變需要一個新的目標三元組,并且不允許任何為舊ABI構建的二進制/庫在這個新三元組上運行。當然有人可以做這些工作,但Aria并不羨慕任何這樣做的發行版。
即使如此,面臨的還有x64的int問題:這是一個非常基本的類型,而且長期以來一直是這種大小,無數的應用程序可能對它有奇怪的無法察覺的假設。這就是為什么int在x64上是32位的,盡管它應該是64位的:int是32位的時間太長了,以至于完全無望將軟件更新到新的大小,盡管它是一個全新的架構和目標三元組。
Aria再次希望自己是錯的,但是人們有時犯的錯誤如此嚴重,以至于根本無法挽回。如果C語言是一種獨立的編程語言?當然可以去做。但它不是,它是一個協議,還是我們必須使用的糟糕的協議。
就算C征服了世界,但也許它再也得不到好東西了。
Linux Swift
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。