🧐

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に標準ヘッダ等が置かれているらしいので、確認。
WSLでlsコマンドで調べている画面
うん、ある。

2. unistd.hの中身を見てみる

まずはヘッダファイルから覗いてみましょ。

/usr/include/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の中に定義があります。

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_writewriteとしてエクスポートしているため問題ない。

ちなみに、__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自体がマクロのためコンパイル前に解釈される際に連結が行われるため成り立っている。

/glibc-2.27/sysdeps/unix/sysdep.h
#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;                                                                  \
  })

シングルスレッドかマルチスレッドかによって分岐があるが、そこは割愛。

/glibc-2.27/sysdeps/unix/sysdep.h
#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__)

各マクロの中身を掘っていくと...

  1. INLINE_SYSCALL_CALL (__VA_ARGS__)
  2. __INLINE_SYSCALL_DISP (__INLINE_SYSCALL, __VA_ARGS__)
  3. __SYSCALL_CONCAT (b,__INLINE_SYSCALL_NARGS(__VA_ARGS__))(__VA_ARGS__)
    の順に内部処理が実行されて...
    __SYSCALL_CONCAT(a,b)の中の__SYSCALL_CONCAT_X(a,b)で連結される。
/glibc-2.27/sysdeps/unix/sysdep.h
#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が呼ばれる。
/glibc-2.27/sysdeps/unix/sysdep.h
#define __INLINE_SYSCALL3(name, a1, a2, a3) \
  INLINE_SYSCALL (name, 3, a1, a2, a3)

INLINE_SYSCALLからファイルが変わって以下の用に定義されている。
(今までのマクロと同じファイルにも定義があるが、上位階層の定義が優先されるため)

sysdeps/unix/sysv/linux/x86_64/sysdep.h
# 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に渡る。

sysdeps/unix/sysv/linux/x86_64/sysdep.h
#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言語ファイルでの記述としてはここが終着点で、アセンブリを実行する部分です。

sysdeps/unix/sysv/linux/x86_64/sysdep.h
#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")が、問題なく実行できている。

sysdeps/unix/sysv/linux/x86_64/sysdep.h
    register TYPEFY (arg3, _a3) asm ("rdx") = __arg3;
    register TYPEFY (arg2, _a2) asm ("rsi") = __arg2;
    register TYPEFY (arg1, _a1) asm ("rdi") = __arg1;

GCCのインラインアセンブラは以下のように指定して実行できる。

__asm__ ( アセンブリテンプレート
        : 出力オペランド                    /* オプション */
        : 入力オペランド                    /* オプション */
        : 破壊されるレジスタのリスト        /* オプション */
);      

以下のように書いてみると同じような処理が実装できる。

test.c
// 本来は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