42Schoolの課題「libasm」をやるにあたり、学習の過程で得た学びや疑問・思考過程などをメモする。

課題の概要

libasmとは、アセンブラで記述された自作Cライブラリを作る課題である。 ライブラリに含めるのは、strlenやwriteなどの標準的な関数である。なお、名前の最初にはft_を付してft_strlenなどとする。命名以外は標準関数と同じ働きをする。

また、以下の要件がある。

  • Makefileを使って、.sファイルをnasmでアセンブルし、静的ライブラリlibasm.aを作成する。
  • また、bit数は64bitとし、Intel Syntaxを用いる。

取り掛かり

家にあった本に沿って進めてみる。以下、何も記述せずにページ数を出す場合はこの本のページを指す。

参考書籍: 独習アセンブラ 新版 (2021) 翔泳社

CファイルからSファイルの生成

まずは、ft_strlen.sを作成することを目指す。

ft_strlenは、const char *型の引数を取り、その文字列の長さをsize_tで返すものである。

C言語でft_strlenを実装してみると、以下のようになる。

typedef unsigned long size_t;
size_t my_strlen(const char *s) {
  size_t l;
 
  for (l = 0; s[l] != '\0'; l++);
  return l;
}

これを、gcc -S -O2 -fno-pie -fomit-frame-pointer my_strlen.cで.sファイルにすると、以下のようになった。

オプションについて

-S:アセンブラを出力 -O2:最適化 -fno-pie:位置非依存のコード(PIC)を生成しない -fomit-frame-pointer:フレームポインターを管理するコードを生成しない -fno-builtin:ビルトイン関数を使用しない

-fno-pieおよび-fomit-frame-pointerを入れなくても、生成物にほとんど変化はなかったため、ここでは意味がないかもしれない。変化があったのは、call strlen@PLT@PLTがついたかどうかのみ。

	.file	"my_strlen.c"
	.text
	.p2align 4
	.globl	my_strlen
	.type	my_strlen, @function
my_strlen:
.LFB0:
	.cfi_startproc
	endbr64
	xorl	%eax, %eax
	cmpb	$0, (%rdi)
	je	.L4
	.p2align 4,,10
	.p2align 3
.L3:
	addq	$1, %rax
	cmpb	$0, (%rdi,%rax)
	jne	.L3
	ret
	.p2align 4,,10
	.p2align 3
.L4:
	ret
	.cfi_endproc
.LFE0:
	.size	my_strlen, .-my_strlen
	.ident	"GCC: (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0"
	.section	.note.GNU-stack,"",@progbits
	.section	.note.gnu.property,"a"
	.align 8
	.long	1f - 0f
	.long	4f - 1f
	.long	5
0:
	.string	"GNU"
1:
	.align 8
	.long	0xc0000002
	.long	3f - 2f
2:
	.long	0x3
3:
	.align 8
4:

ちなみに、Docker上(docker run --platform=linux/amd64 -it -v "$(pwd)":/workspace ubuntu /bin/bash)で実行している。

gdbでのトレース

C言語ソースコードからアセンブリを生成し、gdbでトレースする。

.gdbinitは以下のように記述しておく。

break *main
display /x $eax
display /x $ebx
display /x $esi
display /4i $pc

ちなみに、セキュリティ上の関係で、以下を指定されたファイル(ホームディレクトリの.gdbinit?)に書き込まなければいけない場合がある。

set auto-load safe-path /

ところが、Apple Siliconではgdbによるデバッグができなかった。参考 解決策として、以下のようなものもあるが、.gdbinitの内容を毎回ロードさせなければいけないため、gdbは一旦諦めることとした。

ROSETTA_DEBUGSERVER_PORT=1234 ./my_binary & gdb
(gdb) set architecture i386:x86-64
(gdb) file my_binary
(gdb) target remote localhost:1234

追記:Apple SiliconでもPaiza Cloudでx86_64環境が使えるので、ソースコードをアップロードしてデバッグできた。

GNU アセンブラ

前提知識を飛ばして、p.182からのGAS(GNUアセンブラ)からの説明を読み進める。

以下はGNU binutilsに含まれる主なコマンド(p.183 表7.1 から抜粋)。

コマンド機能
arアーカイブの作成・変更・操作
nmオブジェクトファイル中のシンボル一覧
objcpyオブジェクトファイルの複製・変換
objdumpオブジェクトファイルの情報表示
ranlibアーカイブの索引生成
sizeオブジェクトファイル・アーカイブのセクションの大きさを表示
strings文字列の抽出
stripオブジェクトファイル内のシンボルを削除する
c++filtC++のシンボル名のdemangle
addr2lineアドレスをファイル名と行番号に変換
readelfELFファイルの中身を表示
elfeditELFファイルのヘッダの更新
面白いのは、objdump -M intel -d <.oファイル>でオブジェクトファイルを逆アセンブルできる。

アセンブラの文法

p.204で説明されているGASの文法をまとめると、

  • スペースとタブ、インデント
  • コメント
  • シンボル
    • 局所シンボル(L.Lで始まるシンボル。オプジェクトファイルに記録されない。)
  • ラベル(main:など:で終わる)
    • 局所ラベル(数字:の形式で記述される)
  • 命令
  • 定数
    • 整数
    • bignum
    • flonum
    • 文字
    • 文字列
  • セクション

となる。

しかし、上記のGASはAT&T構文であり、要件のIntel構文とは以下の点で異なる。

  • オペランドの順番
    • AT&Tでは、命令 出所, 宛先のように記述する。
    • Intelでは、命令 宛先, 出所のように記述する。
  • 命令の表記法
    • AT&Tでは、ニーモニックのsuffixでデータサイズを指定する。
    • Intelでは、オペランドのprefixでデータサイズを指定する。
  • 即値及びレジスタの表記法
  • 間接メモリ参照の記述法

間接メモリ参照

間接メモリ参照は、以下のようにアドレスを決定する(p.224)。

アドレス = ベースレジスタの値 + インデックスレジスタの値 * 倍率 + 変位

AT&T構文とIntel構文は間接アドレス参照のオペランドを

変位(ベースレジスタ, インデックスレジスタ, 倍率)      # AT&T
[ベースレジスタ + インデックスレジスタ * 倍率 + 変位] # Intel

のように書く。以下のように記述される(Intel構文)。

	mov     dword ptr [ebp - 4], 123
	mov     dword ptr [eax * 4 + i], 123
	mov     word ptr [ebp + eax * 2 + 8], 123

それぞれ、指定されたアドレスに123を格納する。

擬似命令

p.228以降の説明をまとめる。これらの説明はAT&T構文のようなので、Intel構文に読み替える必要がある。

擬似命令説明
.file論理ファイルの開始
.text.textセクションの開始
.global <シンボル>シンボルを他のオブジェクトファイルから参照可能にする
.type <シンボル>, <type>シンボルの種類をGASに伝える
.cfi_...Call Frame Information
.cfa_...Canonical Frame Address

Call Frame Informationは、関数呼び出し時にスタックフレームをデバッガに知らせるための情報。 Canonical Frame Addressは、関数呼び出し元のスタックフレームのアドレス。

命令ランキング

pp.273-275で、/usr/bin内の実行ファイルにおける機械語の命令の出現回数を数える方法が紹介されていたので、自分の環境(Docker ubuntu:24.04)でも試してみた。

全体の結果はニーモニックランキングに示し、以下には上位15位までを示す。 この15個の命令だけで全体の約90%を占めていることがわかる。

RankPctCumPctMnemonic
135.13135.131mov
27.40842.539call
36.05048.588cmp
45.26853.856je
55.19459.050test
65.00964.060jmp
74.11068.169lea
83.96372.132jne
93.35575.487pop
103.26578.752xor
113.17481.926push
122.06183.987add
131.97685.963nop
141.64287.605movzx
151.53789.142sub

他の文献へ移動

AT&T記法で本を読み進めるのも限界を感じてきたので、Intel記法の情報源を探すことに。

すると、ブログアセンブリを読むための基礎知識 | 晴耕雨読を見つけた。また、そのブログが参考文献にしていたIntel® 64 and IA-32 Architectures Software Developer Manualsも見つけた(Intel® 64 and IA-32 Architectures Software Developer’s Manual Combined Volumes: 1, 2A, 2B, 2C, 2D, 3A, 3B, 3C, 3D, and 4をダウンロードした)。

BNF記法などで説明されたIntel記法の文法を求めていたが、マニュアルにはその記載はなさそうだった。 → nasmの公式サイトにあった。

ここで知ったのだが、以下二つは別物だった。nasmを使う用件から、下の情報源を探した方が良さそうだ。

  • MASM(Microsoft Macro Assembler)
  • NASM(Netwide Assembler)

ここで見つけた文献を整理する。

文献1. Intel Architectures

Intel® 64 and IA-32 Architectures Software Developer Manuals

命令セットやハードウェア、命令とEFlagsの関係についての説明がある。

文献2. ABI

System V Application Binary Interface AMD64 Architecture Processor Supplement

Calling Conventionやデータ型の配置についての説明がある。

文献3. NASM

NASM documentation

nasmアセンブラの説明。nasmのオプションやマクロについての説明がある。

レジスタ

64bit環境では、以下のレジスタがある(文献1)。

  • 64bitのレジスタ: RAX, RBX, RCX, RDX, RSI, RDI, RSP, RBP, R8-R15
  • 32bitのレジスタ: EAX, EBX, ECX, EDX, ESI, EDI, ESP, EBP, R8D-R15D
  • 16bitのレジスタ: AX, BX, CX, DX, SI, DI, SP, BP, R8W-R15W
  • 8-bitのレジスタ
    • REXプレフィックスあり: AL, BL, CL, DL, SIL, DIL, SPL, BPL, R8B-R15B
    • REXプレフィックスなし: AL, BL, CL, DL, AH, BH, CH, DH
  • Segmentレジスタ: CS, DS, SS, ES, FS, GS
  • RFLAGSレジスタ
  • X87 FPUレジスタ
  • MMXレジスタ (MM0-MM7)
  • XMMレジスタ (XMM0-XMM15), MXCSRレジスタ
  • Controlレジスタ: CR0, CR2, CR3, CR4, CR8
  • System table pointerレジスタ: GDTR, LDTR, IDTR, taskレジスタ
  • Debugレジスタ: DR0, DR1, DR2, DR3, DR6, DR7
  • MSRレジスタ
  • 128bitオペランドのレジスタ (RDX:RAX)

ちなみに、charが8bit、intが32bit、longが64bit(文献2 17p)。

Calling Convention

文献2 p.20、3.2 Function Calling Sequenceに詳述される。

  1. 引数の型がINTEGER, SSE, MEMORYなどのクラスに分類される。
  2. 1の分類に従い、引数がレジスタおよびスタックに配置される。

Aggregate Type(struct, array, union)の分類:

  1. サイズが64Byteよりも大きいか、unalignedなフィールドがある場合→MEMORY
  2. C++のnon-trivialな型→invisible referenceとしてポインタ(INTEGER)
  3. 1, 2以外の場合は、8バイトごとのチャンクに分割しNO_CLASSにする。
  4. 3で各チャンクに複数のフィールドがまたがる場合は、
    1. 両方同じクラス→そのクラス
    2. 一方がNO_CLASS→もう一つのクラス
    3. 一方がMEMORY→MEMORY
    4. 一方がINTEGER
  5. 4で一部にMemoryを含む場合は全体をMemoryにするなど。
クラス型の例配置先
INTEGERchar, int, long long, void *%rdi, %rsi, %rdx, %rcx, %r8, %r9
SSEfloat, double%xmm0から%xmm7
SSEUP構造体内の連続したfloat直前のxxmレジスタの上位部分に追加
NO_CLASS(パディング)
MEMORY大きな構造体など、上記以外アラインメントされてスタックに
補足:
  • レジスタに収まりきらない場合は、スタックに積まれる
    • Aggregate Typeの一部がレジスタに収まらない場合は全てスタックに積まれる(rollback)
  • スタックは右から左に積まれる(右端が低アドレス、%rspは最後に積まれた左端を指す)
    • スタックはアドレスの低い方(%rsp)に向かって積まれる
  • %alは可変長引数の個数を表す
  • %r10はfunction’s static chain pointer(?)を表す
  • INTEGERで余ったbitの内容は未定義
    • 例えば、int型(32bit)で余った上位32bitに何が入っているかは未定義

スタックの様子:

↑ 低アドレス側(pushで伸びる方向)
│
│   [rsp - 128]    ← red zone(割り込みで破壊されない128バイト)
│   ...
│   [rsp]          ← ローカル変数領域の終了
│   ...  
│   [rbp - 8]      ← ローカル変数領域の開始
│   [rbp]          ← previous `%rbp` value
│   [rbp + 8]      ← return address・ここより上が現在のstack frame
│   [rbp + 16]     ← memory argument eightbyte 0
│   [rbp + 24]     ← memory argument eightbyte 1
│   ...
│   [rbp + 8n + 16]← memory argument eightbyte n
│
↓ 高アドレス側(引数、データセグメント等に向かう)

7つ目以降のINTEGER引数はMEMORY引数として渡されることに注意。

calleeが%rbpに呼ばれた時点での%rspを保存しておくことで、%rbp経由でローカル変数などにアクセスできる。もちろん、sub rsp Nなどで%rspを動かし、関数の最後で戻す必要がある。

返り値は以下の順で返される:

  1. 引数と同様に、型を分類する。
  2. クラスがMEMORYの場合:
    1. callerは事前に領域を確保しておき、最初の引数として%rdiに渡している
    2. calleeはその領域に返り値を書き込む
    3. %raiにそのアドレスを再設定する
  3. クラスがINTEGERの場合、%rax(収まり切らない場合は%rdxにも)に書き込む
  4. クラスがSSE/SSEUPの場合、%xmm0(収まらなければ%xmm1にも)に書き込む

レジスタの使用用途

callee-savedなレジスタは、calleeが終了する前に呼び出された時の値を元に戻さなければいけないレジスタ。

レジスタsaved用途
%raxNo%alで可変引数のvector registers使用数、返り値の1番目
%rbxYes
%rcxNo整数引数の4番目
%rdxNo整数引数の3番目、返り値の2番目
%rspYesスタックポインタ
%rbpYesフレームポインタ
%rsiNo整数引数の2番目
%rdiNo整数引数の1番目
%r8No整数引数の5番目
%r9No整数引数の6番目
%r10Nofunction’s static chain pointer
%r11No
%r12-%r15Yes
%r16-%r31No
%xmm0-%xmm1No浮動点少数の引数・返り値
%xmm2-%xmm7No浮動点少数の引数
%xmm8-%xmm15No

Syscall

文献2にSyscallについての記述がある。

文献2 Appendix A. Linux Conventions(要約)

Linux kernelとuser-level applicationは以下の点でのみ異なる。

  1. INTEGER引数を渡す順番:
  • user: %rdi, %rsi, %rdx, %rcx, %r8, %r9
  • kernel: %rdi, %rsi, %rdx, %r10, %r8, %r9
  1. %raxにsyscall番号を設定し、syscall命令でシステムコールを呼び出す。
  2. 引数は最大6個でスタックでは渡さない。カテゴリINTEGERとMEMORYのみが渡される。
  3. %rcx, %r11は破壊され、それと%rax以外のすべてのレジスタは保持される。
  4. syscallの後、%raxには-errno(-4905〜-1)が格納される。

Syscall番号はtorvalds/linux - GitHubで確認できる?

また、errno_locationなどの外部関数を呼び出す場合の注意について、文献3で以下のように説明されている。

文献3 10.2.5 Calling Procedures Outside the Library(抜粋)

To call an external routine, you must use another special PIC relocation type, WRT ..plt. This is much easier than the GOT-based ones: you simply replace calls such as CALL printf with the PLT-relative version CALL printf WRT ..plt.

つまり、以下のように呼び出す必要がある。

call __errno_location wrt ..plt

命令たち

上で示した上位14位までの命令を整理する(全体はニーモニックランキング)。 nopは省略(バインディングやタイミング調整に使われる)。

文献1 5.1に概要が説明される。詳細な説明は文献1のVol.2 にある。

データ転送

命令説明
movレジスタ・メモリ間でデータをコピーする。
例:mov eax, ebx : ebx の値を eax にコピー。
leaメモリアドレスを計算してレジスタに格納する(ポインタのような使い方)。
例:lea eax, [ebx+4]:eax = ebx + 4に相当
movzx小さいサイズの値を大きいレジスタにゼロ拡張してコピー。
例:movzx eax, byte ptr [ebx]
pushスタックにデータを格納する。
popスタックからデータを取り出してレジスタに格納。

演算

命令説明
add加算。例:add eax, 1 は eax に 1 を加える。
sub減算。例:sub eax, 1 は eax から 1 を引く。
xor排他的論理和(XOR)。例:xor eax, eax は eax を 0 に初期化する用途が多い。

0初期化のためのmovとxorの違い

  • 即値の分、xorの方がmovよりも命令サイズが小さい
  • movはフラグを変更しないが、xorはZeroフラグをセットする。

論理・比較

命令説明
cmp2つの値を比較(内部的には減算だけして結果は保存しない)。
例:cmp eax, ebx
test論理ANDを行ってフラグだけを設定。
例:test eax, eax はゼロかどうかのチェックに使われる。

制御

命令説明
call関数呼び出し。呼び出し元アドレスをスタックに保存し、指定先へジャンプ。
jmp無条件ジャンプ。例:jmp label
je比較後、等しい(Zero Flag=1)ならジャンプ。
jne比較後、等しくない(Zero Flag=0)ならジャンプ。

Status Flags

文献1 Vol.1 3-17, 3.4.3.1に計算結果のフラグについて説明されている。

Flagbit説明
Carry0符号なし演算のオーバーフロー(繰り上がり・繰り下がり)
Parity2計算結果の最下位Byteにおけるbitの1の数が偶数かどうか
Auxiliary Carry44ビット単位でのキャリーが発生したらセット
Zero6計算結果がゼロならセット
Sign7計算結果の最上位bitが1かどうか
Overflow11符号あり演算のオーバーフロー(型の範囲を超えた)

Assembler Directives

文献3 Chapter 7にassembler directiveの一覧が載っている。

  1. Section 7.1: BITS: Target Processor Mode
    1. Section 7.1.1: USE16 & USE32: Aliases for BITS
  2. Section 7.2: DEFAULT: Change the assembler defaults
    1. Section 7.2.1: REL & ABS: RIP-relative addressing
    2. Section 7.2.2: BND & NOBNDBND prefix
  3. Section 7.3: SECTION or SEGMENT: Changing and Defining Sections
    1. Section 7.3.1: The __?SECT?__ Macro
  4. Section 7.4: ABSOLUTE: Defining Absolute Labels
  5. Section 7.5: EXTERN: Importing Symbols from Other Modules
  6. Section 7.6: REQUIRED: Unconditionally Importing Symbols from Other Modules
  7. Section 7.7: GLOBAL: Exporting Symbols to Other Modules
  8. Section 7.8: COMMON: Defining Common Data Areas
  9. Section 7.9: STATIC: Local Symbols within Modules
  10. Section 7.10: (G|L)PREFIX(G|L)POSTFIX: Mangling Symbols
  11. Section 7.11: CPU: Defining CPU Dependencies
  12. Section 7.12: FLOAT: Handling of floating-point constants
  13. Section 7.13: [WARNING]: Enable or disable warnings

Unwinding

文献2 6.3 Unwinding through assembler codeに、

For successful unwinding on AMD64 every function must provide a valid debug information in the DWARF Debugging Information Format. (中略) for hand-written assembly routines the debug info must be provided by the author of the code.

(再掲) Call Frame Informationは、関数呼び出し時にスタックフレームをデバッガに知らせるための情報。 Canonical Frame Addressは、関数呼び出し元のスタックフレームのアドレス。

原則として、%rspを動かしたら(関数呼び出しも含め)、CFIをつけておく。しかしnasmでは必要はなさそう?

GNU stack

リンク時に以下のエラーが発生する。

/usr/bin/ld: warning: ft_strlen.o: missing .note.GNU-stack section implies executable stack
/usr/bin/ld: NOTE: This behaviour is deprecated and will be removed in a future version of the linker

.note.GNU-stackセクションを書かないと、stackが実行可能になってしまう(セキュリティ上の懸念)らしい。

Hardened/GNU stack quickstartによると、単に以下をペーストすれば良いとあるが、これにはプリプロセスを必要とする。

%ifidn __OUTPUT_FORMAT__,elf
section .note.GNU-stack noalloc noexec nowrite progbits
%endif
%ifidn __OUTPUT_FORMAT__,elf32
section .note.GNU-stack noalloc noexec nowrite progbits
%endif
%ifidn __OUTPUT_FORMAT__,elf64
section .note.GNU-stack noalloc noexec nowrite progbits
%endif

要件で.sファイルとして提出する必要があるため、これは使えない。単に、section .note.GNU-stack noalloc noexec nowrite progbitsと書くことにする。

課題を進める

テスト駆動開発?で進める。 最初にmainでテストケースを作り、それに合うことを確認しながら進める。

  • strlen
  • strcpy
  • strcmp
  • write
  • read
  • strdup

続いて、自作関数の開発。

typedef struct s_list {
    void *content;
    struct s_list *next;
} t_list;
  • atoi_base(C-05)
    • baseで示される文字列で表現されたと仮定する文字列strint型に直す。
      • base = "0123456789"なら普通の10進数。
      • base = "0123456789abcdef"なら16進数。
      • base = "FT", str = "TTFFT"なら2進数の0b11001、つまり25
    • strは任意の数のwhitespace、任意の数の符号が、この順で数より前に来ても良い。
      • 符号は-が奇数回なら負の値に、偶数回なら正の値にする。
    • strがこのルールに従わなくなる直前までで数を解釈する。
      • baseが普通の10進数でstr = " ---+--+1234ab567"-1234となる。
    • オーバーフローとアンダーフローは無視する(未定義)。
    • 以下の場合はエラーとして0を返す。
      • strもしくはbaseNULLの場合。
      • baseが空文字もしくは1文字しか含まれていない場合。
      • baseが同じ文字を2度含む場合。
      • baseが符号やwhitespaceを含む場合。
    • 文献3 3.4.2 Character Stringsにリテラルの表記法が載っている。
int ft_atoi_base(char *str, char *base);
  • ft_list_push_front(C12-01)
    • dataNULLでも良い)からt_list型のnodeをリストの先頭につける。*begin_listはそのnodeのnextとなる(NULLでも良い)。
    • begin_listNULLの場合、エラーとして何も行わない。
void ft_list_push_front(t_list **begin_list, void *data);
  • ft_list_size(C12-02)
    • begin_listから始まるリストのノード数を返す。
    • 返り値はint型なんですね。
int ft_list_size(t_list *begin_list);
  • ft_list_sort(C12-14)
    • cmp関数を用いて、リストを昇順に並び替える。
    • cmpあるいはbegin_listNULLの場合は、エラーとして何もしない。
    • 先頭が入れ替わることで、*begin_listの値が変化する場合がある。
void ft_list_sort(t_list **begin_list, int (*cmp)());
 
(*cmp)(list->data, list_other_ptr->data);

ft_list_sortは複雑なので、方針をここに示しておく。

void ft_list_sort(
	t_list **begin_list,    // [rsp]
	int (*cmp)(void*,void*) // [rsp + 8]
)
{
    if (!begin_list || !cmp)
        return;
 
    size_t size = list_size(*begin_list);
    if (size < 2)
        return;
    size_t n_left = size - 1;                // rbx
    do
    {
        size_t n_idx = 0;                    // r12
        t_list **prev_next_ptr = begin_list; // r13
		t_list *current = *begin_list;       // r14
        t_list *next = current->next;        // r15
        do
        {
            int ret = cmp(current->content, next->content);
            if (ret > 0)
            {
	            // 両者の->nextを入れ替える
	            t_list *next_next = next->next; // rdx
                current->next = next_next;
                next->next = current;
 
				// ...->next->current->...として扱う。
				*prev_next_ptr = next;
                prev_next_ptr = &(next->next);
                next = next_next;
            }
            else
            {
                prev = &(current->next);
                current = next;
                next = next->next;
            }
            n_idx++;
        } while (n_idx < n_left);
    } while (--n_left > 0);
}
  • ft_list_remove_if(C12-12)
    • cmp関数にdata_refと掛けた返り値が0であるノード全てを削除する。
    • t_listの中のメンバ変数dataは、free_fctNULLでない場合、その関数にかけて削除する。
    • t_list自体はfreeで削除する。
    • cmpあるいはbegin_listNULLの場合は、エラーとして何もしない。
    • 先頭が削除されることで、*begin_listの値が変化する場合がある。
void ft_list_remove_if(t_list **begin_list, void *data_ref, int (*cmp)(), void (*free_fct)(void *));
 
(*cmp)(list_ptr->data, data_ref);
(*free_fct)(list_ptr->data);

これも、方針をここに示す。

void ft_list_remove_if(
    t_list **begin_list,
    void *data_ref,             // [rsp]
    int (*cmp)(void *, void *), // [rsp + 8]
    void (*free_fct)(void *)    // r15
)
{
	if (!begin_list || !cmp) return;
	t_list **prev_ptr = begin_list; // r12
	t_list *current = *begin_list;  // r13
	while (!current)
	{
		int ret = cmp(current->content, data_ref);
		if (ret)
		{
			t_list *del_node = current;             // rdi
			void   *del_content = current->content; // r14
			current = current->next;
			*prev_ptr = current;
			free(del_node);
			if (free_fct)
				free_fct(del_content);
		}
		else
		{
			prev_ptr = &current->next;
			current = current->next;
		}
	}
}