C言語のwrite関数を読む。
C言語のwrite関数とは?
学生時代、write触ることがなかったのですが最近ubunts環境下でコード書くことが増えて存在を知りました。
write関数が含まれているunistd.hはUNIX/POSIX向けのシステムコールや基本的な関数を宣言したものだそう。(unistdはUNIX STANDARDの略っぽいな)
Windowsで学び始め、呪文のようにstdio.hを多用してprintfにお世話になっていたので、初対面のwriteのことを深く知っていきたいと思います...
読んでみる
ブログ執筆はWindowsを使っているため、WSL環境下でのwriteを読んでいく。
1. ファイルの場所を探す
/usr/includeに標準ヘッダ等が置かれているらしいので、確認。

うん、ある。
2. unistd.hの中身を見てみる
まずはヘッダファイルから覗いてみましょ。
369 extern ssize_t write (int __fd, const void *__buf, size_t __n) __wur;
370
371 #if defined __USE_UNIX98 || defined __USE_XOPEN2K8
372 # ifndef __USE_FILE_OFFSET64
373 /* Read NBYTES into BUF from FD at the given position OFFSET without
374 changing the file pointer. Return the number read, -1 for errors
375 or 0 for EOF.
376
377 This function is a cancellation point and therefore not marked with
378 __THROW. */
369行目に定義がありました。
引数として求めているのは以下の3つ。
| 引数名 | 型 | 役割 |
|---|---|---|
| fd | int | ファイルディスクリプタが入ります。 通常、標準出力の場合は 1を標準エラー出力の場合は2を入れる、他に特定のファイルなどに書き込む場合はファイルの識別子を渡してあげる引数です。ファイルディスクリプタに関してはこの記事がわかりやすいので参考に。 |
| buf | void * | 書き込む値が格納されているバッファのポインタが入ります。void *自体、見慣れないかもですが、型は定義せずに任意の型のデータのアドレスを入れれることを指しています。 |
| n | size_t | bufに格納されているデータのbyte数ですね。sizeof()でサイズが取得できるので、使うときは合わせて使っています。 |
尚、公式ドキュメントでは以下のように説明されています。
write ()関数は、 bufが指すバッファからn byteを、開いているファイル記述子fildesに関連付けられたファイルに書き込もうとします。(Google翻訳)
https://pubs.opengroup.org/onlinepubs/9699919799/functions/write.html
また、関数定義部分に書かれているコメントも和訳すると以下のように書かれていました。
ファイルポインタを変更せずに、FDから指定された位置OFFSETからNBYTESバイトをBUFに読み込みます。
読み込んだバイト数を返します。
エラーの場合は-1、EOFの場合は0を返します。
この関数はキャンセルポイントであるため、__THROWは設定されません。
ちなみに、ターミナルでman 2 writeを実行すると仕様を確認できる。恥ずかしいことに最近知った。
WRITE(2) Linux Programmer's Manual WRITE(2)
NAME
write - write to a file descriptor
SYNOPSIS
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
DESCRIPTION
write() writes up to count bytes from the buffer starting at buf to the file referred to by the file descriptor fd.
The number of bytes written may be less than count if, for example, there is insufficient space on the underlying physical medium, or the RLIMIT_FSIZE
resource limit is encountered (see setrlimit(2)), or the call was interrupted by a signal handler after having written less than count bytes. (See also
pipe(7).)
For a seekable file (i.e., one to which lseek(2) may be applied, for example, a regular file) writing takes place at the file offset, and the file offset is
incremented by the number of bytes actually written. If the file was open(2)ed with O_APPEND, the file offset is first set to the end of the file before
writing. The adjustment of the file offset and the write operation are performed as an atomic step.
POSIX requires that a read(2) that can be proved to occur after a write() has returned will return the new data. Note that not all filesystems are POSIX
conforming.
According to POSIX.1, if count is greater than SSIZE_MAX, the result is implementation-defined; see NOTES for the upper limit on Linux.
RETURN VALUE
On success, the number of bytes written is returned (zero indicates nothing was written). It is not an error if this number is smaller than the number of
bytes requested; this may happen for example because the disk device was filled. See also NOTES.
On error, -1 is returned, and errno is set appropriately.
If count is zero and fd refers to a regular file, then write() may return a failure status if one of the errors below is detected. If no errors are
detected, or error detection is not performed, 0 will be returned without causing any other effect. If count is zero and fd refers to a file other than a
regular file, the results are not specified.
ERRORS
EAGAIN The file descriptor fd refers to a file other than a socket and has been marked nonblocking (O_NONBLOCK), and the write would block. See open(2) for
further details on the O_NONBLOCK flag.
EAGAIN or EWOULDBLOCK
The file descriptor fd refers to a socket and has been marked nonblocking (O_NONBLOCK), and the write would block. POSIX.1-2001 allows either error
to be returned for this case, and does not require these constants to have the same value, so a portable application should check for both possibili‐
ties.
3. 関数の中身を確認
実際の処理内容が含まれたソースコードはデフォルトでOSに含まれていないため、インストールをする。
sudo apt update
sudo apt source libc6
これでできなかったらなんか調べてみてください(ごちゃごちゃやったのでちゃんと覚えてない)
sysdeps/unix/sysv/linux/write.cの中に定義があります。
#include <unistd.h>
#include <sysdep-cancel.h>
#include <not-cancel.h>
/* Write NBYTES of BUF to FD. Return the number written, or -1. */
ssize_t
__libc_write (int fd, const void *buf, size_t nbytes)
{
return SYSCALL_CANCEL (write, fd, buf, nbytes);
}
libc_hidden_def (__libc_write)
weak_alias (__libc_write, __write)
libc_hidden_weak (__write)
weak_alias (__libc_write, write)
libc_hidden_weak (write)
#if !IS_IN (rtld)
ssize_t
__write_nocancel (int fd, const void *buf, size_t nbytes)
{
return INLINE_SYSCALL_CALL (write, fd, buf, nbytes);
}
#else
strong_alias (__libc_write, __write_nocancel)
#endif
libc_hidden_def (__write_nocancel)
ファイルの頭に長いコメントがあったがライブラリ全体に関しての言及なので割愛。
パッと見、__libc_write()って別の関数の定義に見えるが、weak_alias (__libc_write, write)の部分で__libc_writeをwriteとしてエクスポートしているため問題ない。
ちなみに、__libc_write()はエクスポートされてないため普通に呼び出そうとするとコンパイルエラーになる。
warning: implicit declaration of function ‘__libc_write’;
4. マクロを追ってみる。
更に内部処理を見ようとすると、SYSCALL_CANCEL (write, fd, buf, nbytes);の部分を深掘っていく。
ややこしいのだが、第一引数で渡しているwriteは今回のテーマのwrite()の関数ではなく、/usr/include/x86_64-linux-gnu/asm/unistd_64.hなどで定義されているマクロ#define __NR_write 1のことだそう。内部的に変換(連結)されてwriteが__NR_write(つまり1)として解釈される。
これは、以下のようにSYSCALL_CANCEL自体がマクロのためコンパイル前に解釈される際に連結が行われるため成り立っている。
#define SYSCALL_CANCEL(...) \
({ \
long int sc_ret; \
if (SINGLE_THREAD_P) \
sc_ret = INLINE_SYSCALL_CALL (__VA_ARGS__); \
else \
{ \
int sc_cancel_oldtype = LIBC_CANCEL_ASYNC (); \
sc_ret = INLINE_SYSCALL_CALL (__VA_ARGS__); \
LIBC_CANCEL_RESET (sc_cancel_oldtype); \
} \
sc_ret; \
})
シングルスレッドかマルチスレッドかによって分岐があるが、そこは割愛。
#define __SYSCALL_CONCAT_X(a,b) a##b
#define __SYSCALL_CONCAT(a,b) __SYSCALL_CONCAT_X (a, b)
// 省略
#define __INLINE_SYSCALL_DISP(b,...) \
__SYSCALL_CONCAT (b,__INLINE_SYSCALL_NARGS(__VA_ARGS__))(__VA_ARGS__)
// 省略
#define INLINE_SYSCALL_CALL(...) \
__INLINE_SYSCALL_DISP (__INLINE_SYSCALL, __VA_ARGS__)
各マクロの中身を掘っていくと...
INLINE_SYSCALL_CALL (__VA_ARGS__)__INLINE_SYSCALL_DISP (__INLINE_SYSCALL, __VA_ARGS__)-
__SYSCALL_CONCAT (b,__INLINE_SYSCALL_NARGS(__VA_ARGS__))(__VA_ARGS__)
の順に内部処理が実行されて...
__SYSCALL_CONCAT(a,b)の中の__SYSCALL_CONCAT_X(a,b)で連結される。
#define __INLINE_SYSCALL_NARGS_X(a,b,c,d,e,f,g,h,n,...) n
#define __INLINE_SYSCALL_NARGS(...) \
__INLINE_SYSCALL_NARGS_X (__VA_ARGS__,7,6,5,4,3,2,1,0,)
ここで連結されるのは以下。
-
a:__INLINE_SYSCALL -
b:__INLINE_SYSCALL_NARGS(__VA_ARGS__)で引数の数を取得している。今回はINLINE_SYSCALL_CALL (write, fd, buf, nbytes);のため4で3を返す。
ここから、__INLINE_SYSCALL3が呼ばれる。
#define __INLINE_SYSCALL3(name, a1, a2, a3) \
INLINE_SYSCALL (name, 3, a1, a2, a3)
INLINE_SYSCALLからファイルが変わって以下の用に定義されている。
(今までのマクロと同じファイルにも定義があるが、上位階層の定義が優先されるため)
# undef INLINE_SYSCALL
# define INLINE_SYSCALL(name, nr, args...) \
({ \
unsigned long int resultvar = INTERNAL_SYSCALL (name, , nr, args); \
if (__glibc_unlikely (INTERNAL_SYSCALL_ERROR_P (resultvar, ))) \
{ \
__set_errno (INTERNAL_SYSCALL_ERRNO (resultvar, )); \
resultvar = (unsigned long int) -1; \
} \
(long int) resultvar; })
そこからINTERNAL_SYSCALLに渡る。
#define INTERNAL_SYSCALL(name, err, nr, args...) \
internal_syscall##nr (SYS_ify (name), err, args)
nrにはnameを抜いた引数の数3をINLINE_SYSCALL (name, 3, a1, a2, a3)で入れているためinternal_syscall3が実行される。
序盤でも触れたようにSYS_ify(name)で__NR_と連結されていて、ここで__NR_writeとして渡る。
4. 最後の砦、アセンブリ
やっとC言語ファイルでの記述としてはここが終着点で、アセンブリを実行する部分です。
#define internal_syscall3(number, err, arg1, arg2, arg3) \
({ \
unsigned long int resultvar; \
TYPEFY (arg3, __arg3) = ARGIFY (arg3); \
TYPEFY (arg2, __arg2) = ARGIFY (arg2); \
TYPEFY (arg1, __arg1) = ARGIFY (arg1); \
register TYPEFY (arg3, _a3) asm ("rdx") = __arg3; \
register TYPEFY (arg2, _a2) asm ("rsi") = __arg2; \
register TYPEFY (arg1, _a1) asm ("rdi") = __arg1; \
asm volatile ( \
"syscall\n\t" \
: "=a" (resultvar) \
: "0" (number), "r" (_a1), "r" (_a2), "r" (_a3) \
: "memory", REGISTERS_CLOBBERED_BY_SYSCALL); \
(long int) resultvar; \
})
以下の部分で各変数のレジスタを固定している。そのため、後のインラインアセンブラ内でレジスタが固定されていない("r")が、問題なく実行できている。
register TYPEFY (arg3, _a3) asm ("rdx") = __arg3;
register TYPEFY (arg2, _a2) asm ("rsi") = __arg2;
register TYPEFY (arg1, _a1) asm ("rdi") = __arg1;
GCCのインラインアセンブラは以下のように指定して実行できる。
__asm__ ( アセンブリテンプレート
: 出力オペランド /* オプション */
: 入力オペランド /* オプション */
: 破壊されるレジスタのリスト /* オプション */
);
以下のように書いてみると同じような処理が実装できる。
// 本来はsysdep.hにあるがincludeできないため定義。
#define REGISTERS_CLOBBERED_BY_SYSCALL "rcx","r11","memory"
int main(void) {
const char *txt = "hello";
long ret;
asm volatile (
"syscall"
: "=a"(ret) // 出力: RAX → ret
: "0"(1), // 入力: RAX = __NR_write (1)
"D"(1), // RDI = fd
"S"(txt), // RSI = buf
"d"((long)sizeof(txt)) // RDX = count
: REGISTERS_CLOBBERED_BY_SYSCALL
);
return (ret < 0) ? 1 : 0;
}

各レジスタの説明を軽く。
| レジスタ名 | 役割 | writeでの値 |
|---|---|---|
| RAX | 演算の結果を格納 | 処理の結果を格納 |
| RDI | 一部のデータ転送命令において、データの転送先を格納 | ファイルディスクリプタ 1の場合標準出力 |
| RSI | 一部のデータ転送命令において、データの転送元を格納 | 書き込むデータのアドレス |
| RDX | 演算に使用するデータを格納 | 書き込むデータのバイト数 |
以上が、write関数の内部でやっていることでした。
最後に
処理内容はシンプルなので、ただアセンブリまでの経路を辿るだけの冒険譚になってしまいました。
しかもほぼマクロなので、C言語らしい学びは薄いですね。
ただ、個人的にはC言語のリハビリとして結構いい体験になりました。
次回はもう少し、ロジックの盛り込まれている関数に触れたいと思いました。
Discussion