記事のきっかけ
久しぶりのQiitaへの投稿がAdvent Calendarとなった@akachochinです。皆様、順調に忘年会をしているでしょうか。
つい最近「Linuxカーネル解読室」を読んで調べ物をする機会がありました。このとき、以下の文を見つけて、「システムコールをもう一度行う処理は別のOSのは見たことあったけれど、Linuxではどうなっているのか?」とふと疑問に感じました。
また、シグナルを受信した際、プロセスは、エラー終了するかシステムコールをもう一度行うように指定できます。
これは、処理がユーザ空間に戻る際に、実行ポインタを「カーネルへ処理を移す命令」に戻すことによって実現されています。
ちょうど書くネタに困っていたこともあり(!)、今のLinux実装を調べてまとめました。
なお、調査対象およびコードを引用したLinuxのバージョンは4.18.11です。また、アーキテクチャはx86です。
システムコールから戻る箇所
システムコールを実行するメインとなる処理は以下の関数です。
#ifdef CONFIG_X86_64
__visible void do_syscall_64(unsigned long nr, struct pt_regs *regs)
{
struct thread_info *ti;
enter_from_user_mode();
local_irq_enable();
ti = current_thread_info();
if (READ_ONCE(ti->flags) & _TIF_WORK_SYSCALL_ENTRY)
nr = syscall_trace_enter(regs);
/*
* NB: Native and x32 syscalls are dispatched from the same
* table. The only functional difference is the x32 bit in
* regs->orig_ax, which changes the behavior of some syscalls.
*/
nr &= __SYSCALL_MASK;
if (likely(nr < NR_syscalls)) {
nr = array_index_nospec(nr, NR_syscalls);
regs->ax = sys_call_table[nr](regs);
}
syscall_return_slowpath(regs);
}
#endif
「処理がユーザ空間に戻る際に」とありますので、syscall_return_slowpath()以降の処理を見れば、調べたいことがわかりそうです。
ちなみに、do_syscall_64()の関数についている__visibleについては、この記事の最後にある「参考」の「__visibleについて」を参照してください。
また、sys_call_tableについては、この記事の最後にある「参考」の「システムコールテーブル(sys_call_table)について」を参照してください。
今回の記事にとってはいずれもオマケですが、この辺のコードを読まれる方にとっては必要なことかな、と思います。
syscall_return_slowpath以降の処理
ということで、処理をみていきます。実は焦点となる処理は、以下コード抜粋の呼出経路を経て、do_signal()で実施しています。
/*
* Called with IRQs on and fully valid regs. Returns with IRQs off in a
* state such that we can immediately switch to user mode.
*/
__visible inline void syscall_return_slowpath(struct pt_regs *regs)
{
struct thread_info *ti = current_thread_info();
u32 cached_flags = READ_ONCE(ti->flags);
/* 略 */
prepare_exit_to_usermode(regs);
}
/* Called with IRQs disabled. */
__visible inline void prepare_exit_to_usermode(struct pt_regs *regs)
{
struct thread_info *ti = current_thread_info();
u32 cached_flags;
addr_limit_user_check();
lockdep_assert_irqs_disabled();
lockdep_sys_exit();
cached_flags = READ_ONCE(ti->flags);
if (unlikely(cached_flags & EXIT_TO_USERMODE_LOOP_FLAGS))
exit_to_usermode_loop(regs, cached_flags);
#define EXIT_TO_USERMODE_LOOP_FLAGS \
(_TIF_SIGPENDING | _TIF_NOTIFY_RESUME | _TIF_UPROBE | \
_TIF_NEED_RESCHED | _TIF_USER_RETURN_NOTIFY | _TIF_PATCH_PENDING)
static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)
{
/* 略 */
while (true) {
/* 略 */
/* deal with pending signal delivery */
if (cached_flags & _TIF_SIGPENDING)
do_signal(regs);
/* 略 */
cached_flags = READ_ONCE(current_thread_info()->flags);
if (!(cached_flags & EXIT_TO_USERMODE_LOOP_FLAGS))
break;
}
}
これがdo_signal()です。
/*
* Note that 'init' is a special process: it doesn't get signals it doesn't
* want to handle. Thus you cannot kill init even with a SIGKILL even by
* mistake.
*/
void do_signal(struct pt_regs *regs)
{
struct ksignal ksig;
if (get_signal(&ksig)) {
/* Whee! Actually deliver the signal. */
handle_signal(&ksig, regs);
return;
}
/* Did we come from a system call? */
if (syscall_get_nr(current, regs) >= 0) {
/* Restart the system call - no handlers present */
switch (syscall_get_error(current, regs)) {
case -ERESTARTNOHAND:
case -ERESTARTSYS:
case -ERESTARTNOINTR:
regs->ax = regs->orig_ax;
regs->ip -= 2;
break;
case -ERESTART_RESTARTBLOCK:
regs->ax = get_nr_restart_syscall(regs);
regs->ip -= 2;
break;
}
}
/*
* If there's no signal to deliver, we just put the saved sigmask
* back.
*/
restore_saved_sigmask();
}
syscall_get_error()は以下の通りです。基本的には、axレジスタの値を返します。
static inline long syscall_get_error(struct task_struct *task,
struct pt_regs *regs)
{
unsigned long error = regs->ax;
#ifdef CONFIG_IA32_EMULATION
/*
* TS_COMPAT is set for 32-bit syscall entries and then
* remains set until we return to user mode.
*/
if (task->thread_info.status & (TS_COMPAT|TS_I386_REGS_POKED))
/*
* Sign-extend the value so (int)-EFOO becomes (long)-EFOO
* and will match correctly in comparisons.
*/
error = (long) (int) error;
#endif
return IS_ERR_VALUE(error) ? error : 0;
}
IS_ERROR_VALUE()は、Linuxのソースを読むとたまに出てきます。念のため、定義を載せておきます。
#define MAX_ERRNO 4095
#ifndef __ASSEMBLY__
#define IS_ERR_VALUE(x) unlikely((unsigned long)(void *)(x) >= (unsigned long)-MAX_ERRNO)
axレジスタの値
実は、この時点でのaxの中身は、ユーザプロセスに返すシステムコールのエラー値です。(libcの関数を呼び、エラーで戻ってきたときのerrnoと捉えるとイメージしやすいと思います。)
また、regs->axと別にregs->orig_axがあります。この中身はシステムコール番号です。システムコールを呼び出す際、システムコール番号をaxレジスタに格納して引き渡します。
x86でシステムコール呼出をする際のaxレジスタに関するトピックは、ググると出てくる情報ですので詳細は省略します。
ただ、参考までにシステムコール呼び出し時のレジスタを扱った記事として、ptraceシステムコール入門 ― プロセスの出力を覗き見してみよう!
という記事を紹介します。このブログ記事は良記事ですので、是非読まれると良いでしょう。
再びdo_signal()へ
以上述べたことを踏まえて、再度do_signal()のコードを見ます。)
switch (syscall_get_error(current, regs)) {
case -ERESTARTNOHAND:
case -ERESTARTSYS:
case -ERESTARTNOINTR:
/* このケースでは、呼び出し時のシステムコール番号をaxにセットします */
regs->ax = regs->orig_ax;
regs->ip -= 2;
break;
case -ERESTART_RESTARTBLOCK:
regs->ax = get_nr_restart_syscall(regs);
regs->ip -= 2;
break;
まず、regs->ip -= 2の意味を説明します。
普通にシステムコールを呼び出す際、おおよそ以下の図のとおり進みます。
このとき、戻り先アドレスは図のとおり、システムコール呼出命令の一つ後を指しています。これによって、システムコール呼出後の処理が実施されます。
ここで、regs->ip -= 2を行いますと、以下図のとおり、戻り先はシステムコール呼出命令の一つ前を指します。
これを踏まえて、以下のコードを読みますと、「直近に呼び出したシステムコールをやり直す」という意味であることがわかるでしょう。
case -ERESTARTNOHAND:
case -ERESTARTSYS:
case -ERESTARTNOINTR:
/* このケースでは、呼び出し時のシステムコール番号をaxにセットします */
regs->ax = regs->orig_ax;
/* システムコール呼出命令の一つ前に戻ることで、同じシステムコールをやり直します */
regs->ip -= 2;
break;
さて、残りは「-ERESTART_RESTARTBLOCKの場合にaxに代入しているシステムコール番号」は何かという点です。これも調べましょう。
restart_syscall
get_nr_restart_syscall(regs)は、__NR_restart_syscallを返しています。__NR_restart_syscallとは何でしょうか。x86の場合、以下の値です。
#define __NR_restart_syscall 128
__SYSCALL(__NR_restart_syscall, sys_restart_syscall)
__NR_restart_syscallに対応するシステムコール本体は、以下のとおりです。
/**
* sys_restart_syscall - restart a system call
*/
SYSCALL_DEFINE0(restart_syscall)
{
struct restart_block *restart = ¤t->restart_block;
return restart->fn(restart);
}
一見してわかるとおり、単に関数ポインタ経由で処理を呼び出すだけです。では、restart->fn()には何が設定されるのでしょうか。
実は、「残り待ち時間を加味した、待ち系のシステムコール再実行処理」が設定されます。
restart->fn()への設定を行っている処理には、poll, futex, nanosleep, clock_nanosleepなどがあります。それぞれの処理でrestart->fn()に設定する関数は異なりますが、いずれもやっていることは、「待ち状態からシグナルで割り込まれた際に待ち直す。ただ、前述したような単純なシステムコール再実行では、残り待ち時間を加味するケースをうまく捌けない。よって、残り待ち時間分だけ待ち直すような処理を行う」ことです。
例えば、1sのタイムアウト時間で待ちを行い、待ち開始から300ms経過したときにシグナルによって起こされたとします。単純なやり直し処理では引数をそのまま渡し直しますので、単純に再度1s経過を待ちます。ここでは、残り700msだけ待ちたいので、それを加味したやり直しをする必要があります。
わかりやすそうな例として、poll処理を挙げます。
例:poll処理でのrestart->fn()
static long do_restart_poll(struct restart_block *restart_block)
{
struct pollfd __user *ufds = restart_block->poll.ufds;
int nfds = restart_block->poll.nfds;
struct timespec64 *to = NULL, end_time;
int ret;
/*
* タイムアウト時間が指定されたケースでのやり直しは、単純に渡された引数を使わず、最初に覚えておいた
* 待ち時間を使う
*/
if (restart_block->poll.has_timeout) {
end_time.tv_sec = restart_block->poll.tv_sec;
end_time.tv_nsec = restart_block->poll.tv_nsec;
to = &end_time;
}
ret = do_sys_poll(ufds, nfds, to);
if (ret == -EINTR) {
restart_block->fn = do_restart_poll;
ret = -ERESTART_RESTARTBLOCK;
}
return ret;
}
SYSCALL_DEFINE3(poll, struct pollfd __user *, ufds, unsigned int, nfds,
int, timeout_msecs)
{
/* 略 */
ret = do_sys_poll(ufds, nfds, to);
/* 待っている最中にシグナルに割り込まれた */
if (ret == -EINTR) {
struct restart_block *restart_block;
restart_block = ¤t->restart_block;
/* やり直し時に呼ぶ関数と覚えておくべきパラメータを覚えてから、一度システムコールを抜ける */
restart_block->fn = do_restart_poll;
restart_block->poll.ufds = ufds;
restart_block->poll.nfds = nfds;
/* タイムアウト時間が指定されている */
if (timeout_msecs >= 0) {
/* すでに求めてある「待ち終了時刻」をセットする。*/
restart_block->poll.tv_sec = end_time.tv_sec;
restart_block->poll.tv_nsec = end_time.tv_nsec;
restart_block->poll.has_timeout = 1;
} else
restart_block->poll.has_timeout = 0;
ret = -ERESTART_RESTARTBLOCK;
}
return ret;
}
poll()のI/F仕様はここです。もし、poll()の知識があやふやなら一読ください。
do_sys_poll()の戻り値が-EINTRの場合にrestart_block->fnをdo_restart_pollに設定していることがわかります。
つまり、do_sys_poll()がシグナルに割り込まれたとき、一度ユーザプロセスに戻りますが、システムコールの呼び直しにより、残り待ち時間を加味した待ち処理を行います。
おおよそ以下図のようなイメージとなります。以下の図は、「1秒のタイムアウトでpoll()を呼出し、300ms後にシグナルに割り込まれ、再度poll()を呼ぶ。その後700ms待ち、タイムアウトを迎える」ケースを表したものです。先に引用したコードと併せて読むと、よりイメージしやすいと思います。
最後に
システムコールの呼び直しのために、戻り先の命令ポインタに細工をする手法は知っていましたが、タイムアウトを加味する再実行処理は想定していませんでした。
また、ここには書いていませんが、この調査の過程でclock_nanosleep()というのを初めて知りました。思わぬことを知ることができるのも、コードリーディングの醍醐味のひとつです。
今回の記事はシステムコール呼出に関するトピックの一つですが、システムコール呼出は単純なようでいて実は色々と低レイヤなトピックが飛び出す面白い分野でもあります。ご興味のある方は色々と調べられてはいかがでしょうか。
ここのところ色々あり、Qiitaへの投稿をサボりがちでしたが、来年はもうちょいいろいろ投稿できたらな、と思います。
今年も残り少ないですが、皆様、Happy, Hacking、良いお年を。
参考
__visibleって何だっけ?
__visibleは、以下ヘッダの通り、GCCの特定バージョン以上の場合、GCCのattribute指定に展開されます。
#if GCC_VERSION >= 40600
/*
* When used with Link Time Optimization, gcc can optimize away C functions or
* variables which are referenced only from assembly code. __visible tells the
* optimizer that something else uses this function or variable, thus preventing
* this.
*/
#define __visible __attribute__((externally_visible))
#endif /* GCC_VERSION >= 40600 */
GNUのマニュアルを読みますと、以下の通り書かれています。
externally_visible
This attribute, attached to a global variable or function, nullifies the effect of the -fwhole-program command-line option, so the object remains visible
outside the current compilation unit.
If -fwhole-program is used together with -flto and gold is used as the linker plugin, externally_visible attributes are automatically added to functions (not
variable yet due to a current gold issue) that are accessed outside of LTO objects according to resolution file produced by gold.
For other linkers that cannot generate resolution file, explicit externally_visible attributes are still necessary.
ちなみに「gold」は金ではなくて、ここを参照しましょう。ググるときはgold linkerでね。
external_visible
この属性は、グローバル変数又はグローバル関数に関連付けられるもので、-fwhole-programコマンドラインオプションの効果を無効にします。よって、該当オブジェクトは現在
のコンパイル単位の外側から見えたままになります。-fwhole-programが-fltoとともに使用され、goldがリンカプラグインとして使用されている場合、作成された解決ファイル
に従ってLTOオブジェクトの外部でアクセスされる関数にexternal_visible属性が自動的に追加されます。金で。解決ファイルを生成できない他のリンカの場合、明示的に
external_visible属性が必要です。
ちなみに、GCCが特定バージョン未満の場合、以下のように空定義となります。
#ifndef __visible
#define __visible
#endif
システムコールテーブル(sys_call_table)について
do_syscall_64()では、以下のコードのように、渡されたシステムコール番号を用いて関数ポインタで処理を振り分けています。
if (likely(nr < NR_syscalls)) {
nr = array_index_nospec(nr, NR_syscalls);
regs->ax = sys_call_table[nr](regs);
}
ところが、コードを見ると以下のように全エントリがsys_ni_syscallで初期化されています。
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
/*
* Smells like a compiler bug -- it doesn't work
* when the & below is removed.
*/
[0 ... __NR_syscall_max] = &sys_ni_syscall,
#include <asm/syscalls_64.h>
};
sys_ni_syscallは以下の通り、「そんなシステムコールはない」を意味するエラー値を返す関数です。これではシステムコールは呼べません。
/* we can't #include <linux/syscalls.h> here,
but tell gcc to not warn with -Wmissing-prototypes */
asmlinkage long sys_ni_syscall(void);
/*
* Non-implemented system calls get redirected here.
*/
asmlinkage long sys_ni_syscall(void)
{
return -ENOSYS;
}
ソースコードを検索しても、sys_call_tableを初期化するような処理を見つけられません。実は、sys_call_tableのエントリはビルド時に生成されます。
fyoshida@ubuntu:~/source/linux-4.19$ make
SYSTBL arch/x86/include/generated/asm/syscalls_32.h
SYSHDR arch/x86/include/generated/asm/unistd_32_ia32.h
SYSHDR arch/x86/include/generated/asm/unistd_64_x32.h
SYSTBL arch/x86/include/generated/asm/syscalls_64.h
HYPERCALLS arch/x86/include/generated/asm/xen-hypercalls.h
SYSHDR arch/x86/include/generated/uapi/asm/unistd_32.h
SYSHDR arch/x86/include/generated/uapi/asm/unistd_64.h
SYSHDR arch/x86/include/generated/uapi/asm/unistd_x32.h
// 以下略
以下が、動的に生成されたエントリです。
生成された#ifdef CONFIG_X86
__SYSCALL_64(0, __x64_sys_read, )
#else /* CONFIG_UML */
__SYSCALL_64(0, sys_read, )
#endif
#ifdef CONFIG_X86
__SYSCALL_64(1, __x64_sys_write, )
#else /* CONFIG_UML */
__SYSCALL_64(1, sys_write, )
#endif