メモリ割り当て関数mallocの原理と実装を徹底解説

C を使用または学習したことがある人なら誰でも、malloc に精通しているでしょう。malloc が連続したメモリ空間を割り当て、使用されなくなったら free を通じて解放できることは誰もが知っています。しかし、多くのプログラマは malloc の背後にあるものに詳しくなく、malloc をオペレーティング システムによって提供されるシステム コールまたは C キーワードとさえ考えています。

実際、malloc は C 標準ライブラリで提供されている一般的な関数にすぎず、 malloc を実装する基本的な考え方は複雑ではなく、C とオペレーティング システムの知識があるプログラマであれば簡単に理解できます。

この記事では、単純な malloc を実装することによって、malloc の背後にあるメカニズムについて説明します。もちろん、既存の C 標準ライブラリの実装 (glibc など) と比較すると、私たちの malloc の実装は特に効率的ではありませんが、この実装は現在の実際の malloc の実装よりもはるかに単純なので、理解しやすいです。重要なのは、この実装は基本原則において実際の実装と一致しているということです。

この記事では、まずオペレーティング システムのプロセスのメモリ管理や関連するシステム コールなど、必要な基本的な知識を紹介し、次に簡単な malloc を徐々に実装します。わかりやすくするために、この記事では x86_64 アーキテクチャのみを考慮し、オペレーティング システムは Linux です。

1 mallocとは

malloc を実装する前に、まず malloc を比較的形式的に定義する必要があります。

標準 C ライブラリ関数の定義によれば、malloc には次のプロトタイプがあります。

void* malloc(size_t size);

この関数によって実装される機能は、システム内で使用可能なメモリを継続的に割り当てることであり、具体的な要件は次のとおりです。

  • malloc によって割り当てられるメモリ サイズは、少なくともsize パラメータで指定されたバイト数です。
  • malloc の戻り値は、使用可能なメモリのセグメントの開始アドレスを指すポインタです。
  • malloc で割り当てたアドレスを解放しない限り、malloc で複数回割り当てたアドレスは重複できません。
  • malloc はメモリ割り当てを完了し、できるだけ早く戻る必要があります ( NP-hard[1]のメモリ割り当てアルゴリズムは使用できません)
  • malloc を実装する場合、メモリ サイズ調整とメモリ解放機能 (つまり、realloc と free) を同時に実装する必要があります。

malloc の詳細な手順については、コマンド ラインに次のコマンドを入力して表示できます。

man malloc

2 予備知識

malloc を実装する前に、Linux システム メモリに関する知識をいくつか説明する必要があります。

2.1 Linux のメモリ管理

2.1.1 仮想メモリアドレスと物理メモリアドレス

簡単にするために、最新のオペレーティング システムは通常、メモリ アドレスを処理するときに仮想メモリ アドレス テクノロジを使用します。つまり、アセンブラ (または機械語) レベルでは、メモリ アドレスが関係する場合、仮想メモリ アドレスが使用されます。このテクノロジを使用すると、各プロセスは独自の 2N バイトのメモリを持っているように見えます (N はマシンのビット数です)。たとえば、64 ビット CPU と 64 ビット オペレーティング システムでは、各プロセスの仮想アドレス空間は 264 バイトです。

この仮想アドレス空間の主な機能は、プログラムの作成を簡素化し、オペレーティング システムによるプロセス間メモリの分離管理を容易にすることです。実際のプロセスがこのような大きなメモリ空間を持つことはほとんどありません (また、使用することもできません)。使用できる量は物理メモリサイズに依存します。

仮想アドレスは機械語レベルで使用されるため、実際の機械語コードのプログラムにメモリ操作が含まれる場合、動作を実現するには、現在実行中のプロセスの実際のコンテキストに従って仮想アドレスを物理メモリアドレスに変換する必要があります。実メモリデータ。この変換は通常、 MMU[2] (メモリ管理ユニット)と呼ばれるハードウェアによって完了します。

2.1.2 ページとアドレスの構成

最新のオペレーティング システムでは、仮想メモリも物理メ​​モリもバイト単位ではなく、ページ単位で管理されます。メモリ ページとは、固定サイズの連続メモリ アドレスの総称で、特に Linux では、一般的なメモリ ページ サイズは 4096Byte (4K) です。

したがって、メモリアドレスはページ番号とページ内のオフセットに分割できます。64 ビット マシン、4G 物理メモリ、4K ページ サイズを例にとると、仮想メモリ アドレスと物理メモリ アドレスの構成は次のとおりです。

上が仮想メモリ アドレス、下が物理メモリ アドレスです。ページ サイズはすべて 4K であるため、ページ内のオフセットは下位 12 ビットで表され、残りの上位アドレスがページ番号を表します。

MMU のマッピング単位はバイトではなくページであり、このマッピングはメモリ常駐のデータ構造ページ テーブルを参照することによって実装されます[3] 。現在、コンピュータの特定のメモリ アドレス マッピングは比較的複雑になっており、プロセスを高速化するために、 TLB [4]やその他のメカニズムなどの一連のキャッシュと最適化が導入されています。

以下にメモリ アドレス変換の簡略化した概略図を示しますが、簡略化されていますが、基本原理は現代のコンピュータの実際の状況と一致しています。

2.1.3 メモリページとディスクページ

一般にメモリはディスク キャッシュとしてみなされることが知られていますが、MMU が動作しているときに、特定のメモリ ページが物理メモリにないことをページ テーブルが示していることが時々あります。このとき、ページ フォールト例外 (ページ フォールト) が発生します。このとき、システムはディスク上の対応する場所でディスク ページをメモリにロードし、ページ フォールトにより失敗した機械語命令を再実行します。この部分については、malloc 実装に対して透過的であるとみなせるため、詳細は説明しません。

最後に、Wikipedia で実際のアドレス変換に準拠したプロセスを添付します。この図は、TLB およびページ欠落例外のプロセスを追加しています。

2.2 Linux プロセスレベルのメモリ管理

2.2.1 メモリの配置

仮想メモリと物理メモリの関係、および関連するマッピング メカニズムを理解したところで、プロセス内でメモリがどのように配置されるかを見てみましょう。

Linux 64 ビット システムを例に挙げます。理論的には、64 ビット メモリ アドレスに利用可能なスペースは 0x0000000000000000 ~ 0xFFFFFFFFFFFFFFFF です。これは非常に大きなスペースであり、Linux が実際に使用するのはそのほんの一部 (256T) だけです。

Linux カーネル関連のドキュメント [6]によると、Linux 64 ビット オペレーティング システムは拡張に下位 47 ビットと上位 17 ビットのみを使用します (すべて 0 またはすべて 1 のみ可能)。したがって、実際に使用されるアドレスは、スペース 0x0000000000000000 ~ 0x00007FFFFFFFFFFFF および 0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF であり、前者はユーザー スペース、後者はカーネル スペースです。回路図は以下の通りです:

ユーザーにとって、主に関心のあるスペースはユーザー スペースです。ユーザースペースを拡大すると、主に次のセクションに分かれていることがわかります。

  • コード: これはユーザー空間全体の最下位アドレス部分であり、命令 (つまり、プログラムによってコンパイルされた実行可能なマシン コード) が格納されます。
  • データ: 初期化されたグローバル変数がここに保存されます。
  • BSS: 初期化されていないグローバル変数はここに保存されます。
  • ヒープ: ヒープ、これがこの記事の焦点です。ヒープは、低いアドレスから高いアドレスに成長します。後で説明する brk 関連のシステム コールは、ここからメモリを割り当てます。
  • マッピング領域: mmap システムコールに関連する領域です。実際の malloc 実装のほとんどは、mmap を介してより大きなメモリ領域を割り当てることを考慮しますが、この記事ではこの状況については説明しません。この領域は上位アドレスから下位アドレスに拡張されます。
  • スタック: これはスタック領域であり、上位アドレスから下位アドレスまで拡張されます。

以下では主にヒープ領域の操作に焦点を当てます。Linux のメモリ構成全体に興味がある学生は、他の資料を参照してください。

2.2.2 ヒープメモリモデル

一般に、malloc によって要求されるメモリは主にヒープ領域から割り当てられます (この記事では、mmap による大きなメモリ ブロックの適用については考慮していません)。

上記からわかるように、プロセスが直面する仮想メモリ アドレス空間は、ページ ベースで物理メモリ アドレスにマッピングされている場合にのみ実際に使用できます。物理ストレージ容量の制限により、ヒープ仮想メモリ空​​間全体を実際の物理メモリにマップすることは不可能です。Linux のヒープ管理は次のとおりです。

Linux は、ヒープ領域内のアドレスを指すブレーク ポインターを維持します。ヒープ開始アドレスからブレークまではマップされておりプロセスからアクセスできますが、ブレーク以降はアンマップのアドレス空間であり、この空間にアクセスするとプログラムはエラーを報告します。

2.2.3 brk と sbrk

上記のことから分かるように、プロセスで実際に利用可能なヒープ サイズを増やすには、ブレーク ポインタをより高いアドレスに移動する必要があります。Linux は、brk および sbrk システム コールを通じてブレーク ポインタを操作します。2 つのシステム コールのプロトタイプは次のとおりです。

int brk(void *addr);
void *sbrk(intptr_t increment);

brk はブレーク ポインタをアドレスに直接設定しますが、sbrk はブレークを現在の位置から increment で指定された増分だけ移動します。brk は、正常に実行された場合は 0 を返し、それ以外の場合は -1 を返し、errno を ENOMEM に設定します。sbrk が正常に実行された場合は、ブレークが移動する前に指されていたアドレスを返し、それ以外の場合は (void *)-1 を返します。

ちょっとしたトリックは、increment を 0 に設定すると、現在のブレークのアドレスを取得できることです。

もう 1 つ注意すべき点は、Linux はページごとにメモリをマップするため、ブレークがページ サイズによって整列されないように設定されている場合、システムは実際には最後に完全なページをマップするため、実際にマップされたメモリ空間はブレークが指す領域よりも大きくなるということです。の方が大きいです。ただし、ブレーク後のアドレスを使用するのは危険です(ブレーク後に空きメモリ アドレスの小さな領域がある可能性がありますが)。

2.2.4 リソース制限と rlimit

システムによって各プロセスに割り当てられるリソースは、マップ可能なメモリ空間も含めて無制限ではないため、各プロセスには、現在のプロセスで使用できるリソースの上限を表す rlimit があります。

この制限は getrlimit システム コールを通じて取得できます。次のコードは、現在のプロセスの仮想メモリ空​​間の rlimit を取得します。

int main() {
struct rlimit *limit = (struct rlimit *)malloc(sizeof(struct rlimit));
getrlimit(RLIMIT_AS, limit);
printf("soft limit: %ld, hard limit: %ld\n", limit->rlim_cur, limit->rlim_max);
}

ここで、rlimit は構造体です。

struct rlimit {
rlim_t rlim_cur; /* Soft limit */
rlim_t rlim_max; /* Hard limit (ceiling for rlim_cur) */
};

各リソースにはソフト制限とハード制限があり、rlimit は setrlimit を通じて条件付きで設定できます。ハード リミットはソフト リミットの上限として機能します。非特権プロセスはソフト リミットのみを設定でき、ハード リミットを超えることはできません。

 Information Direct: Linux カーネル ソース コード テクノロジ学習ルート + ビデオ チュートリアル カーネル ソース コード

Learning Express: Linux カーネル ソース コード メモリ チューニング ファイル システム プロセス管理 デバイス ドライバー/ネットワーク プロトコル スタック

3 mallocを実装する

3.1 おもちゃの実装

malloc の実装について正式に議論し始める前に、上記の知識を使用して、単純ではあるが実際に使用するのはほぼ不可能なおもちゃの malloc を実装できます。これは、上記の知識の復習として使用する必要があります。

/* 一个玩具malloc */
#include <sys/types.h>
#include <unistd.h>
void *malloc(size_t size)
{
void *p;
p = sbrk(0);
if (sbrk(size) == (void *)-1)
return NULL;
return p;
}

この malloc は、現在のブレークに基づいて size で指定されたバイト数を毎回増加させ、前のブレークのアドレスを返します。この malloc には割り当てられたメモリの記録がなく、メモリの解放に不便なため、実際のシナリオでは使用できません。

3.2 正式な実装

malloc の実装について真剣に議論しましょう。

3.2.1 データ構造

まず、使用されるデータ構造を決定する必要があります。シンプルで実現可能な解決策は、ヒープメモリ空間をブロックの形で編成することです. 各ブロックはメタ領域とデータ領域で構成されます. メタ領域にはデータブロックのメタ情報 (データ領域のサイズ、空きフラグ) が記録されますビット、ポインタなど))、データ領域は実際に割り当てられたメモリ領域であり、データ領域の最初のバイト アドレスは malloc によって返されるアドレスです。

次の構造でブロックを定義できます。

typedef struct s_block *t_block;
struct s_block {
  size_t size; /* 数据区大小 */
  t_block next; /* 指向下个块的指针 */
  int free; /* 是否是空闲块 */
  int padding; /* 填充4字节,保证meta块长度为8的倍数 */
  char data[1] /* 这是一个虚拟字段,表示数据块的第一个字节,长度不应计入meta */
};

ここでは 64 ビット マシンのみを考慮しているため、便宜上、構造体の最後に int を埋めて、構造体自体の長さがメモリ アライメントのために 8 の倍数になるようにします。概略図は次のとおりです。

3.2.2 適切なブロックを見つける

次に、ブロック チェーン内で適切なブロックを見つける方法を考えてみましょう。一般に、次の 2 つの検索アルゴリズムがあります。

  • First Fit : 最初から開始し、データ領域サイズが必要なサイズよりも大きい最初のブロック、いわゆる今回割り当てられたブロックを使用します。
  • Best Fit : 先頭から全ブロックを巡回して、データ領域サイズが size より大きく、差分が最も小さいブロックを今回割り当てたブロックとして使用します。

どちらの方法にもそれぞれメリットがあり、Best Fit はメモリ使用量が多く (ペイロードが高く)、First Fit は動作効率が高くなります。ここでは、最初の適合アルゴリズムを使用します。

/* First fit */
t_block find_block(t_block *last, size_t size) {
  t_block b = first_block;
  while(b && !(b->free && b->size >= size)) {
     *last = b;
     b = b->next;
    }
  return b;
}

find_block は、frist_block から開始し、要件を満たす最初のブロックを検索し、ブロックの開始アドレスを返します。見つからない場合は、NULL を返します。

ここで、last と呼ばれるポインタはトラバーサル中に更新され、このポインタは常に現在トラバースされているブロックを指します。これは、適切なブロックが見つからない場合に新しいブロックを開くために使用されます。これは次のセクションで使用されます。

3.2.3 新しいブロックを開く

既存のブロックがサイズ要件を満たせない場合は、リンク リストの最後で新しいブロックを開く必要があります。ここで重要なのは、sbrk のみを使用して構造体を作成する方法です。

#define BLOCK_SIZE 24 /* 由于存在虚拟的data字段,sizeof不能正确计算meta长度,这里手工设置 */
 
t_block extend_heap(t_block last, size_t s) {
t_block b;
b = sbrk(0);
if(sbrk(BLOCK_SIZE + s) == (void *)-1)
return NULL;
b->size = s;
b->next = NULL;
if(last)
last->next = b;
b->free = 0;
return b;
}

3.2.4 ブロックの分割

First Fit には小さなサイズが大きなブロックを占有する可能性があるという致命的な欠点がありますが、このときペイロードを増やすために、残りのデータ領域が十分に大きい場合に新しいブロックに分割する必要があります。以下のとおりであります:

実装コード:

void split_block(t_block b, size_t s) {
t_block new;
new = b->data + s;
new->size = b->size - s - BLOCK_SIZE ;
new->next = b->next;
new->free = 1;
b->size = s;
b->next = new;
}

3.2.5 malloc の実装

上記のコードを使用すると、それらを単純ですが最初は使用可能な malloc に統合できます。まず、ブロック リストの先頭の first_block を定義し、それを NULL に初期化する必要があることに注意してください。さらに、分割操作を実行する前に、残りのスペースが少なくとも BLOCK_SIZE + 8 である必要があります。

malloc によって割り当てられたデータ領域を 8 バイトで揃えたいため、size が 8 の倍数でない場合は、size を size より大きい最小の 8 の倍数に調整する必要があります。

size_t align8(size_t s) {
if(s & 0x7 == 0)
return s;
return ((s >> 3) + 1) << 3;
}

#define BLOCK_SIZE 24
void *first_block=NULL;
 
/* other functions... */
 
void *malloc(size_t size) {
t_block b, last;
size_t s;
/* 对齐地址 */
s = align8(size);
if(first_block) {
/* 查找合适的block */
last = first_block;
b = find_block(&last, s);
if(b) {
/* 如果可以,则分裂 */
if ((b->size - s) >= ( BLOCK_SIZE + 8))
split_block(b, s);
b->free = 0;
} else {
/* 没有合适的block,开辟一个新的 */
b = extend_heap(last, s);
if(!b)
return NULL;
}
} else {
b = extend_heap(NULL, s);
if(!b)
return NULL;
first_block = b;
}
return b->data;
}

3.2.6 callocの実装

malloc を使用すると、calloc を実装する手順は 2 つだけです。

  1. メモリの一部を割り当てます
  2. データ領域の内容を0に設定します。

データ領域は 8 バイトでアライメントされているため、効率を向上させるために、1 つずつ設定するのではなく、8 バイトごとに 0 を設定できます。これは、新しい size_t ポインタを作成し、メモリ領域を size_t 型にすることで実現できます。

void *calloc(size_t number, size_t size) {
size_t *new;
size_t s8, i;
new = malloc(number * size);
if(new) {
s8 = align8(number * size) >> 3;
for(i = 0; i < s8; i++)
new[i] = 0;
}
return new;
}

3.2.7 無料の実装

free の実装は見た目ほど単純ではありません。ここでは 2 つの重要な問題を解決する必要があります。

  1. 受信アドレスが有効なアドレスであること、つまり、実際に malloc によって割り当てられたデータ領域の最初のアドレスであることを確認する方法。
  2. 断片化の問題を解決する方法

まず最初に、受信したフリー アドレスが有効であることを確認する必要があります。この有効性には次の 2 つの側面が含まれます。

  • アドレスは、以前に malloc によって割り当てられた領域内、つまり first_block と現在のブレーク ポインタの範囲内にある必要があります。
  • このアドレスは実際に、独自の malloc を通じて以前に割り当てられていました。

最初の問題はアドレスを比較するだけなので簡単に解決できますが、鍵となるのは 2 番目の問題です。

解決策は 2 つあり、1 つは構造体にマジック ナンバー フィールドを埋め込む方法、解放する前に特定の位置の値が相対オフセットを使用して設定したマジック ナンバーであるかどうかを確認する方法、もう 1 つはマジック ポインタを追加する方法です。このポインタは、データ領域の最初のバイト (つまり、解放が正当であるときに渡されるアドレス) を指します。解放する前に、マジック ポインタがパラメータによって示されるアドレスを指しているかどうかを確認します。ここでは 2 番目のオプションを使用します。

まず、構造体にマジック ポインタを追加します (同時に BLOCK_SIZE を変更します)。

typedef struct s_block *t_block;
struct s_block {
size_t size; /* 数据区大小 */
t_block next; /* 指向下个块的指针 */
int free; /* 是否是空闲块 */
int padding; /* 填充4字节,保证meta块长度为8的倍数 */
void *ptr; /* Magic pointer,指向data */
char data[1] /* 这是一个虚拟字段,表示数据块的第一个字节,长度不应计入meta */
};

次に、アドレスの有効性をチェックする関数を定義します。

t_block get_block(void *p) {
char *tmp;
tmp = p;
return (p = tmp -= BLOCK_SIZE);
}
 
int valid_addr(void *p) {
if(first_block) {
if(p > first_block && p < sbrk(0)) {
return p == (get_block(p))->ptr;
}
}
return 0;
}

複数の malloc と解放の後、メモリ プール全体で多くの断片化されたブロックが生成される場合があります。これらのブロックは非常に小さいため、多くの場合使用できません。多くの断片が接続されている場合もあります。malloc の全体的な要件は満たされますが、ブロックは次のように分割されます。複数の小さなブロック。ブロックされて収まらない場合、これは断片化の問題です。

簡単な解決策は、ブロックを解放するときに、その隣接ブロックも解放されていることが判明した場合、そのブロックを隣接ブロックとマージすることです。この実装に対応するには、s_block を二重リンクリストに変更する必要があります。

変更されたブロック構造は次のとおりです。

typedef struct s_block *t_block;
struct s_block {
size_t size; /* 数据区大小 */
t_block prev; /* 指向上个块的指针 */
t_block next; /* 指向下个块的指针 */
int free; /* 是否是空闲块 */
int padding; /* 填充4字节,保证meta块长度为8的倍数 */
void *ptr; /* Magic pointer,指向data */
char data[1] /* 这是一个虚拟字段,表示数据块的第一个字节,长度不应计入meta */
};

マージ方法は次のとおりです。

t_block fusion(t_block b) {
  if (b->next && b->next->free) {
  b->size += BLOCK_SIZE + b->next->size;
  b->next = b->next->next;
  if(b->next)
  b->next->prev = b;
  }
  return b;
}

上記の方法では、free の実装アイデアは比較的明確です: まずパラメータ アドレスの正当性をチェックし、不正な場合は何もしません。そうでない場合は、このブロックの free を 1 としてマークし、それと一致させます。可能であれば、次のブロックがマージされます。

現在のブロックが最後のブロックの場合は、ブレーク ポインタをロールバックしてプロセス メモリを解放します。現在のブロックが最後のブロックの場合は、ブレーク ポインタをロールバックして、first_block を NULL に設定します。実装は次のとおりです。

void free(void *p) {
  t_block b;
  if(valid_addr(p)) {
   b = get_block(p);
  b->free = 1;
  if(b->prev && b->prev->free)
  b = fusion(b->prev);
  if(b->next)
    fusion(b);
  else {
   if(b->prev)
     b->prev->prev = NULL;
   else
    first_block = NULL;
   brk(b);
  }
 }
}

3.2.8 reallocの実装

realloc を実装するには、まずメモリ コピー メソッドを実装する必要があります。calloc と同様に、効率を高めるために 8 バイト単位でコピーします。

void copy_block(t_block src, t_block dst) {
size_t *sdata, *ddata;
size_t i;
sdata = src->ptr;
ddata = dst->ptr;
for(i = 0; (i * 8) < src->size && (i * 8) < dst->size; i++)
ddata[i] = sdata[i];
}

次に、realloc の実装を開始します。単純な (ただし非効率的な) 方法は、メモリのセクションを割り当てて、そこにデータをコピーすることです。しかし、それをより効率的に行うことは可能であり、具体的には次の点を考慮できます。

  • 現在のブロックのデータ領域が realloc で必要なサイズ以上の場合、操作は実行されません。
  • 新しいサイズが小さくなった場合は、分割を検討してください
  • 現在のブロックのデータ領域がサイズを満たせないが、後続のブロックが空き、マージ後にサイズを満たせる場合は、マージを検討してください。

realloc の実装は次のとおりです。

void *realloc(void *p, size_t size) {
size_t s;
t_block b, new;
void *newp;
if (!p)
/* 根据标准库文档,当p传入NULL时,相当于调用malloc */
return malloc(size);
if(valid_addr(p)) {
s = align8(size);
b = get_block(p);
if(b->size >= s) {
if(b->size - s >= (BLOCK_SIZE + 8))
split_block(b,s);
} else {
/* 看是否可进行合并 */
if(b->next && b->next->free
&& (b->size + BLOCK_SIZE + b->next->size) >= s) {
fusion(b);
if(b->size - s >= (BLOCK_SIZE + 8))
split_block(b, s);
} else {
/* 新malloc */
newp = malloc (s);
if (!newp)
return NULL;
new = get_block(newp);
copy_block(b, new);
free(p);
return(newp);
}
}
return (p);
}
return NULL;
}

3.3 従来の問題と最適化

上記は比較的単純ですが、最初は使用可能な malloc 実装です。次のような、考えられる最適化ポイントがまだたくさん残っています。

  • 32 ビットと 64 ビットの両方のシステムと互換性があります
  • より大きなメモリ ブロックを割り当てる場合は、sbrk の代わりに mmap を使用することを検討してください。多くの場合、より効率的です。
  • 単一のリンク リストではなく、複数のリンク リストを維持することを検討できます。各リンク リストのブロック サイズは、8 バイトのリンク リスト、16 バイトのリンク リスト、24 ~ 32 バイトのリンク リストなどの範囲内になります。このとき、サイズに応じて対応するリンクリストに割り当てを行うことができるため、断片化を効果的に削減し、ブロックのクエリ速度を向上させることができます。
  • 割り当てられたブロックではなく、空きブロックのみをリンク リストに保存することを検討できます。これにより、ブロック検索の数が減り、効率が向上します。

原著者:一緒に埋め込まれた学習

おすすめ

転載: blog.csdn.net/youzhangjing_/article/details/132762132