💬

[課題振り返り] minitalk編

2025/01/23に公開

はじめに

今回は42の課題の1つであるminitalkを振り返っていきます。

課題概要

C言語で実装します。
標準入力から入力を受け付けそれをメッセージとして送るclientプログラムと、そのメッセージを受け取り標準出力に出力するserverプログラムを作成します。
条件として、SIGUSR1とSIGUSR2の2種類のシグナルを使用する必要があります。(その他のシグナルは禁止)

学んだこと

PID

OSは各プロセスを一意に識別するためにPID(Process Identifier)を割り当てます。
kill関数を用いてsignalを送る際にはPIDを指定することで送り先のプロセスを指定できます。
Linux環境ではps auxコマンドでPID一覧を確認できます。

$ ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.2 168892  9096 ?        Ss   Jan21   0:01 /lib/systemd/systemd --
root           2  0.0  0.0      0     0 ?        S    Jan21   0:00 [kthreadd]
root           3  0.0  0.0      0     0 ?        I<   Jan21   0:00 [rcu_gp]
root           4  0.0  0.0      0     0 ?        I<   Jan21   0:00 [rcu_par_gp]
root           5  0.0  0.0      0     0 ?        I<   Jan21   0:00 [slub_flushwq]
...
(抜粋)

(余談)プロセスには親子関係がある

プロセスを生成するのはプロセスなのでプロセスには親子関係があります。pstree -pでその親子関係を木構造で表示できます。
先ほどのreceive.cプログラムの実行中に実際に確認してみます。

$ pstree -p
...
           |-gnome-terminal-(2896)-+-bash(2922)---pstree(8131)
           |                       |-bash(3557)---receive(8104) //該当するプロセス
           |                       |-bash(6361)
           |                       |-bash(6440)
           |                       |-{gnome-terminal-}(2897)
           |                       |-{gnome-terminal-}(2899)
           |                       `-{gnome-terminal-}(2900)
...

receiveプロセスはbashから派生していることがわかります。

では、親の親の...と辿っていくと何のプロセスが大元の親プロセスなのでしょうか?pstree -pで確認してみましょう。

$ pstree -p
systemd(1)-+-ModemManager(5544)-+-{ModemManager}(5563)
           |                    `-{ModemManager}(5567)
           |-NetworkManager(509)-+-{NetworkManager}(544)
...
(抜粋)

どうやらPID1のプロセスが大元になっているようです。systemdという名前がついていますがこれは何をするプロセスでしょうか?
これはinitプロセスと呼ばれるものでLinuxシステムで最初に起動されるプロセスです。具体的には以下の役割を持ちます。
・システムの各種サービスとデーモンを依存関係を解析して正しい順序で起動
・システムの状態監視
・シャットダウン(こちらも正しい順序で)

また、Linuxは以下の順序でシステムが起動されます。
1.コンピュータの電源を入れる。
2.BIOS(Basic Input/Output System)やUEFI(Unified Extensible Firmware Interface)などのファームウェア(PCのハードウェアとソフトウェアを繋ぐ基盤的なソフトウェア)を起動してハードウェアを初期化する。
3.ファームウェアがGRUB(Grand Unified Bootloader)などのブートローダ(PC起動時にOSをメモリにロードするためのプログラム)を起動する。
4.ブートローダがOS(Linux)カーネルを起動する。
5.Linuxカーネルがinitプロセス(systemd)を起動する。
6.initプロセスが子プロセスを起動する。

signal

プロセスは基本的に一つの流れにそって実行されます。C言語のif文などの条件分岐は広い意味で一つの流れだといえます。
これに対してシグナルはあるプロセスの流れを強制的に変えるものです。
例えばプログラマが普段よく使用するCtrl+Cは"SIGINT"を送っています。SIGINTを受け取るとデフォルトではそのプロセスは終了しますが、C言語では"signal"関数を使用することで特定のシグナルを受け取った時の挙動を決めることができます。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void handler(int signum)
{
	printf("Received signal %d\n", signum);
}

int main(void)
{
	signal(SIGINT, handler); //SIGINTを受け取ったらhandler関数へ
	while (1)
		sleep(3);
	return (0);
}
$ ./a.out 
^CReceived signal 2
^CReceived signal 2
^CReceived signal 2

Ctrl+Cでプログラムが終了せず、handler関数が実行されていることがわかります。

signalを使用したプログラム間の通信

プログラム間で通信するのは初めてでした。
以下に簡単なsignalを使用したプログラム間の通信のコード例を示します。

(send.c)
#include <signal.h>
#include <stdlib.h>

int main(int argc, char **argv)
{
	pid_t receiver_pid = atoi(argv[1]);
	kill(receiver_pid, SIGUSR1);
	return (0);
}

kill関数を使用して指定したPIDにSIGUSR1を送っています。

(receive.c)
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>

void handler(int signum)
{
	printf("Signal received: %d\n", signum);
}

int main(void)
{
	printf("PID: %d\n", getpid());
	signal(SIGUSR1, handler);
	while (1)
	{
		pause();
	}
	return (0);
}

getpid関数でそのプロセスのpidを得ることができます。
pause()はシグナルを受け取るまで待機する関数なのでwhileで回しています。

(実行結果:terminal2)
$ ./send 8104 //receiveのpidを引数に

(実行結果:terminal1)
$ ./receive 
PID: 8104
Signal received: 30 //sendを実行するとこの出力が得られる。

SIGUSR1とSIGUSR2の情報から文字に変換する

この課題では2種類のシグナルしか使用できない関係上、0と1の情報から文字に変換する必要があります。
これを解決する方法の前提知識として
・文字型charは8bitで構成されている
・charの最上位bitは符号bit
具体的な解決方法として、8bitで一文字としてbitごとに処理していき、8bit分受け取ったら出力します。
説明するよりもコードを見た方が早いと思うので以下に簡単な例を示します。
まずはman asciiで出力したい文字を決めます。今回は'A'を出力するとします。'A'は10進数で65であり、2進数に直すと"01000001"です。これをコード内で1bitずつ入れていくようにします。

#include <stdio.h>

int main(void)
{
	unsigned char c = 0;
	c = 0 << 7;
	c |= 1 << 6;
	c |= 0 << 5;
	c |= 0 << 4;
	c |= 0 << 3;
	c |= 0 << 2;
	c |= 0 << 1;
	c |= 1 << 0;
	printf("%c\n", c);
}

1bitずつbit演算によって格納し、それを文字として出力しています。

(実行結果)
$ ./a.out 
A

'A'がちゃんと出力されましたね。このようにbitごとに格納していくことで文字に変換します。

ところで、コード内でなぜchar型ではなくunsigned char型が使用されているのでしょうか?
実際にこの型をchar型にしてみても正しく'A'と出力されました。
では、問題が起こるケースを以下に示します。

#include <stdio.h>
#include <unistd.h>

int main(void)
{
	printf("Output:\n");

	unsigned char c = 0;
        /* 1バイト目 */
	c = 1 << 7;  // 最上位ビットを0にセット
	c |= 1 << 6; // 2番目のビットを0にセット
	c |= 1 << 5; // 3番目のビットを1にセット
	c |= 0 << 4; // 4番目のビットを0にセット
	c |= 0 << 3; // 5番目のビットを0にセット
	c |= 1 << 2; // 6番目のビットを1にセット
	c |= 1 << 1; // 7番目のビットを0にセット
	c |= 0 << 0; // 最下位ビットを1にセット
	if (c == 230) // bitごとに入れた結果cに期待する数値が入っているかどうか
		write(1, &c, 1);

        /* 2バイト目 */
	c = 1 << 7;
	c |= 0 << 6;
	c |= 0 << 5;
	c |= 1 << 4;
	c |= 0 << 3;
	c |= 1 << 2;
	c |= 1 << 1;
	c |= 1 << 0;
	if (c == 151)
	write(1, &c, 1);

        /* 3バイト目 */
	c = 1 << 7;
	c |= 0 << 6;
	c |= 1 << 5;
	c |= 0 << 4;
	c |= 0 << 3;
	c |= 1 << 2;
	c |= 0 << 1;
	c |= 1 << 0;
	if (c == 165)
    	write(1, &c, 1);
	
	return 0;
}

これはUTF-8というアルファベットの以外の世界中の文字のエンコーディング方式の一つで、漢字の「日」を表す3バイト文字です。
コード内では1バイトずつ出力することでOSが3バイト文字だと認識して1文字として出力してくれます。

$ ./a.out 
Output:
日

これをchar型にして実行してみましょう。

$ ./a.out
Output:

何も出力されませんでした。

いくつかポイントがあります。
・前提でも書いたchar型は最上位bitが符号bitであること
・asciiにはない、最上位bitが1でかつ表示可能文字であるUTF-8の「日」を使用しているところ
・bit列をセットしたcを数字と比較しているところ
まずchar型は最上位bitが1だと符号のが負であることを示すので、その状態で数値と比較してしまうと偽になってしまいます。「数値と比較してしまうと」という部分が重要で、数値と比較したりしない分にはbit列としては正しいので問題なく表示されます。
ですが、例え数値として比較しないコードであってもそのコードの可読性や大きなプロジェクトでは複数人がそのコードを触るなどのことを考慮すると、unsigned char型にするのが良いでしょう。

おわりに

かなり脱線した部分もありますが、こうやって課題に取り組む中で興味の出たことを調べるのは良いことに思います。
最後まで読んでいただきありがとうございました。

Discussion