システムプログラム(第1週)

電子・情報工学系
追川 修一
<shui @ cs.tsukuba.ac.jp>

このページは,次の URL にあります.
http://www.coins.tsukuba.ac.jp/~syspro/2005/No1.html
システムプログラムのホームページ(2005年度)
http://www.coins.tsukuba.ac.jp/~syspro/2005/
からもリンクが張ってあります.

今日の内容

理解を深めるために:

講義システムプログラムの目的

システムプログラムでは,ユーザの立場からオペレーティングシステムを理解し,活用できるようになるためのプログラミングスキルの習得を目的とする.

オペレーティングシステム(OS: Operating System)とは

OSはハードウェアの違いを隠蔽し,ユーザがプログラムを実行するための使いやすい環境を提供する. 例えば,PC,ラップトップ,サーバのハードウェア構成(プロセッサの種類や数,メモリの量,入出力デバイスなど)の違いによって,プログラムの変更が必要だとすると,プログラミングが大変である. OSは,ハードウェアを抽象化した概念(アブストラクション)を提供することで,いろいろなコンピュータで共通に使用できる実行環境を提供する. OSの提供する主な抽象概念としては以下のものがある.

抽象概念ハードウェア機能
プロセスプロセッサ,メモリ
ファイル,ディレクトリストレージ
プロセス間通信コンピュータ間の通信
シグナル割り込み
アクセス制御コンピュータ共有時の保護

基本的な抽象概念の考え方はめったに変わることはない.上記の抽象概念は30年以上変わっていない.

OSの「内部の仕組み」については2学期の「オペレーティング・システム」という講義で扱われる. また,3学期の「オペレーティング・システム II 」や「分散システム」では,より高度な話題が扱われる. 内部の仕組みの前に,外から見た場合のOSの考え方,使い方を理解することがシステムプログラムの目的である.

API(Application Program Interface)を用いたプログラミング

OSの提供する抽象概念の集合が,ユーザから見た場合のコンピュータになる. その抽象的なコンピュータを操作するためのインタフェースとしてAPI(Application Program Interface)が提供されている. APIを用いることにより,抽象化されたコンピュータを操作することができるようになる. APIを通して,プログラムという実体のないものが,OS,OSの管理するデバイスを通して,外の世界とつながり,プログラムと人間のインタラクションが可能になる.

APIは一般に

により提供される. APIを使いこなすことにより,プログラムの生産性,信頼性,安全性,移植性を上げることができる. プログラミング言語を自然言語と対比してみると,プログラミング言語自体は文法,APIは語彙に相当すると考えられる. APIを覚えることにより,わかりやすく簡潔な表現が可能になる.

この講義ではLinuxを用いて,POSIX (Portable Operating System Interface for UNIX) APIを利用してプログラムを作成する.

Linuxが使われているところ

Linuxはデスクトップシステムだけでなく,大型サーバから,カーナビや携帯電話などの組み込み機器まで幅広い製品で使われるようになってきている.以下はその例である.
SGI Altix 3700N901iC, P901i
SGI Altix 3700 Linux Mobile Phones
Sony XYZカーナビゲーションシステム(中身Sony Cocoon CSV-EX11
Sony XYZ Sony Cocoon CSV-EX11

ソフトウェアの構成とシステムプログラム

プログラムの種類

プログラムの種類

システムプログラムでは,ライブラリやシステムコールを通してLinuxカーネルを直接的に利用する.

UNIX OSの構成要素

OSという言葉は様々な意味で使われる. 様々なプログラムから構成されるOS環境を意味する場合もあるし,OSカーネルを意味する場合もある. 以下の図はUNIX環境を構成するプログラムを示している.

UNIX OSの構成要素

以下,ユーザの目に触れる部分から構成要素について概説する.

・シェル

UNIXユーザのユーザインタフェースとして最もよく使われるプログラムがシェル(shell)である. シェルは,ユーザの視点からOSを見た場合,OSを操作するためにOSを取り囲んでいる殻という意味である. 一般的なシェルには,BSH(Bourne Shell),BASH(Bourne Again Shell),CSH(C Shell),TCSH(Tenex-like C Shell)などがある.

シェルはCLI(Command Line Interface)を通して,ユーザからのコマンドを受け付け,解釈,実行し,その結果を出力する機能を提供する. シェルは,コマンドの入出力を,ファイルや別のコマンドにするリダイレクションやパイプといった機能を提供する. この機能により,複数の比較的単純な機能を持つコマンドを組み合わせて使用することが可能になる. 実行するコマンドを記述した簡易プログラムとしてシェルスクリプトを作ることもでき,この機能と合わせて,プログラミングの素養を持つユーザには非常に強力なインタフェースを提供している.

計算機システム実験 I,IIのシステムプログラムでは,シェルを実際に作成してみる. シェルはUNIXの基本となるプログラムであり,UNIXの基本的な抽象概念の操作が必要であるため,システムプログラムの基本を学習するための題材として最適である.

・XウィンドウシステムとGUI(Graphical User Interface)

現在のユーザが直接使用するコンピュータのほとんどはGUIを提供している. WindowsなどではGUIは完全にOSの一部となってしまっているが,UNIXではXウィンドウシステムとウィンドウマネージャという独立したプログラムとして提供されている. Xウィンドウシステムは,ビットマップディスプレイ上にウィンドウを表示するための基本的な機能を提供するだけである. ユーザがウィンドウを操作するためのGUIは,GNOMEやKDEといったプログラム群(デスクトップ環境とも呼ばれる)により提供される.

・コマンド,アプリケーション

コマンドとは,ユーザが(シェルを通して)コンピュータに与える命令である. UNIXは,シェルから使用することのできる非常に多くのコマンドを提供している. コマンドはシェルとは別のプログラムである場合(外部コマンド)もあるし,シェルに組み込まれている場合(組み込みコマンド)もある. cd, set, umask, exitといったシェル自身の状態を変更するコマンドや,historyなどのシェルが持つ情報を表示するコマンドは組み込みコマンドである. そうでないコマンドは,通常外部コマンドである.

コマンドと呼ぶ場合とアプリケーションと呼ぶ場合に明確な違いがあるわけではない. Officeやビデオ再生プログラムのような,それだけで必要な機能を提供する自己完結的なプログラムをアプリケーションと呼ぶ場合が多い.

・サーバ,デーモン

UNIXでは,バックグラウンドで動作し様々なサービスを提供する裏方で働くプログラムのことをデーモン(daemon)と呼んでいたが,最近ではサーバと呼ぶことも多い. デーモンには,メイルの配信をするプログラム,プリンタへの出力要求を仲介するプログラム,リモートログインやリモートファイルコピーなどのネットワーク機能を提供するプログラムなどがある.

・システムコール,ライブラリ,ミドルウェア

UNIXでプログラミングをする場合,システムコール,ライブラリ,ミドルウェアを使用してプログラムを作成する. システムコールは,OSカーネルの機能を直接呼び出すためのインタフェースである. UNIXのシステムコールは,できるだけシンプルになるように設計されている. ライブラリとミドルウェアはプログラムの部品となる関数の集合である. ライブラリとミドルウェアの違いは,ライブラリは様々な目的のプログラムで共通の機能を提供するものであるのに対し,ミドルウェアはライブラリより特定のプログラム(例えばGUI)の共通部品となるものである. これらをうまく使うことで,開発効率,信頼性,安全性,移植性が上がり,また出来上がったプログラムも読みやすくなり,実行効率(又は見栄え)も良くなる.

システムコールもライブラリも,Cプログラムから呼び出す場合はどちらも関数呼び出しの形態で使用できるため,同じに見える. UNIXのマニュアルでは,システムコールは2章,ライブラリは3章に分類されており,ヘッダ部分に「READ(2)」のように「(2)」と付いていれば2章の意味でシステムコールであり,「FREAD(3)」のように「(3)」と付いていれば3章の意味でライブラリである.

プログラムとの関係については,プログラム,ライブラリ,システムコールの関係でより詳しく述べる.

・UNIXカーネル

UNIXカーネルは,プロセッサの特権モードというハードウェアの全てを制御することのできる動作モードで動作し,直接ハードウェアを制御するプログラムである. UNIXカーネルは,プロセッサの機能を使うことで,複数プログラムの同時実行を可能にし,それぞれのプログラム(又はユーザ)がコンピュータを占有しているかのような幻想を与える. UNIX環境で特権モードで動作するプログラムはカーネルだけである. その他のプログラムは,ユーザモードというハードウェアへのアクセスは制御された環境で動作する.

UNIXカーネルは,大まかに言ってプロセス管理,ファイルシステム,メモリ管理,ネットワーク,プロセッサ依存部,デバイスドライバからなる. ファイルシステム,ネットワークは比較的部品化されているが,全体的にお互いが関係しあって動作する大きなプログラムである.

プログラム,ライブラリ関数,システムコールの関係

ライブラリ関数とシステムコールは,入出力機能やその他にもいろいろ便利な機能を提供してくれるという点で,プログラマから見ると似ているところがある. しかし,ライブラリ関数とシステムコールには,いくつか大きな違いがある.

プログラム,ライブラリ,システムコールの関係

上の例では,read はシステムコールなので直接カーネルを呼び出している. strcmp はライブラリだけで機能が実現されているライブラリ関数である. printf はライブラリ関数であるが,標準出力に文字出力を行うために,write システムコールを使用している.

プログラムの実行環境

プログラムとプロセスの関係

一言で言えば,プログラムを実行中のものがプロセスである. 別の言い方をすると,プログラムはプロセスにより実行される. プログラムとそれを実行中のプロセスは別のものである. 従って,1つのプログラムを複数実行する(同じプロセスを実行しているプロセスを複数作る)ことができる.

プログラムは,CPUが実行できる機械語命令とそれにより処理されるデータの集合(実行形式,ロードモジュール)がファイルに格納されたものである. つまり,プログラムには何かをするためにCPUで処理を開始するための情報が入っている.
一方,プロセスには実行中の情報が入っている. 実行が進めばデータは書き換えられたり,追加されたりする(機械語命令は通常変わらない). プログラムには含まれていない,実行中の履歴を格納するためのデータ(スタック)も必要である.

プログラムの実行は通常シェルから行う. シェルのプロンプトにプログラムのファイル名を打ち込むと,そのプログラムを実行するためのプロセスが作られ,実行される. プロセスは誰か(そのプロセス自信でもよい)が終了させないと,いつまでも動いている. 終了させるためには,そのための手順を踏む必要がある(システムコールを呼ぶか強制的に終了させる).

プログラムとプロセスの関係

上の例は,HDD (Hard Disk Drive) に格納されている2つのプログラムemacsとmozillaを異なる二人のユーザが実行している例である. emacs,mozillaのプログラムはそれぞれ1つであるが,同じプログラムを実行して複数のプロセスを作ることができるため,ユーザ毎に別々のプロセスになっている. もちろん同じユーザが複数emacs,mozillaを実行することもできる(mozillaはブックマークやキャッシュの一貫性の関係で複数実行すると警告が出るが,複数実行できる).

プロセスの観察

UNIXにはプロセスを観察するためのコマンドがいくつか用意されている. psコマンドはプロセスの状態を得るための最も標準的なコマンドであり,全てのUNIXで使用できる. pstree,topコマンドは,用意されていないUNIXシステムもあるかもしれない.

psコマンドはオプションにより様々な情報を表示することができる.

% ps [←]
  PID TTY          TIME CMD
32173 pts/1    00:00:00 tcsh
32190 pts/1    00:00:00 ps
% ps xu [←]
USER       PID %CPU %MEM   VSZ  RSS TTY      STAT START   TIME COMMAND
shui     32173  0.0  0.7  4100 1804 pts/1    S    11:51   0:00 -tcsh
shui     32209  0.1  0.7  4104 1800 pts/2    S    11:52   0:00 -tcsh
shui     32242  7.4  1.7  8800 4524 pts/2    S    11:53   0:00 emacs -nw
shui     32253  0.0  0.3  2836  892 pts/1    R    11:53   0:00 ps xu

pstreeはプロセスの親子関係をツリー形式で表示する.

% pstree [←]
init-+-X
     |-apmd
     |-atd
     |-automount
     |-bdflush
     |-cannaserver
     |-crond
     |-dpkeyserv
     |-gdm---gdmlogin
     |-gdm
     |-gmond---gmond---6*[gmond]
     |-gpm
     |-jserver---jserver
     |-kapmd
     |-keventd
     |-khubd
     |-klogd
     |-kscand/DMA
     |-kscand/HighMem
     |-kscand/Normal
     |-ksoftirqd_CPU0
     |-kswapd
     |-kupdated
     |-lockd
     |-lpd
     |-mdrecoveryd
     |-6*[mingetty]
     |-ntpd
     |-pg_ctl---postmaster
     |-portmap
     |-rpc.statd
     |-rpciod
     |-sendmail
     |-sshd-+-sshd---tcsh
     |      |-sshd---tcsh---pstree
     |      `-sshd---tcsh---emacs
     |-syslogd
     |-xfs
     |-xinetd
     |-ypbind---ypbind---2*[ypbind]
     `-ypserv

topはたくさんCPU時間を使用しているプロセスの状態を一定時間おきに表示する. 表示間隔は -d オプションの引数として秒単位で指定できる.

% top -d 1 [←]
 11:55am  up 12 days,  2:53,  3 users,  load average: 0.00, 0.00, 0.00
66 processes: 64 sleeping, 2 running, 0 zombie, 0 stopped
CPU states:  0.0% user,  0.0% system,  0.0% nice, 100.0% idle
Mem:   254860K av,  250020K used,    4840K free,       0K shrd,   25900K buff
Swap:  530104K av,   36744K used,  493360K free                  169928K cached

  PID USER     PRI  NI  SIZE  RSS SHARE STAT %CPU %MEM   TIME COMMAND
    1 root      10   0   516  484   448 S     0.0  0.1   0:04 init
    2 root       9   0     0    0     0 SW    0.0  0.0   0:00 keventd
    3 root       9   0     0    0     0 SW    0.0  0.0   0:00 kapmd
    4 root      19  19     0    0     0 SWN   0.0  0.0   0:00 ksoftirqd_CPU0
    5 root       9   0     0    0     0 SW    0.0  0.0   0:02 kswapd
    6 root       9   0     0    0     0 SW    0.0  0.0   0:00 kscand/DMA
    7 root       9   0     0    0     0 SW    0.0  0.0   0:01 kscand/Normal
    8 root       9   0     0    0     0 SW    0.0  0.0   0:00 kscand/HighMem
    9 root       9   0     0    0     0 SW    0.0  0.0   0:00 bdflush
   10 root       9   0     0    0     0 SW    0.0  0.0   0:01 kupdated
   11 root      -1 -20     0    0     0 SW<   0.0  0.0   0:00 mdrecoveryd
   74 root       9   0     0    0     0 SW    0.0  0.0   0:00 khubd
  493 root       9   0   588  540   480 S     0.0  0.2   0:00 syslogd
  498 root       9   0  1144  428   424 S     0.0  0.1   0:00 klogd
  512 rpc        9   0   592  548   492 S     0.0  0.2   0:40 portmap
  530 rpcuser    9   0   688  564   560 S     0.0  0.2   0:00 rpc.statd
  618 root       9   0     0    0     0 SW    0.0  0.0   0:12 rpciod

プログラムの開発環境

マニュアルの読み方

% man システムコール名 [←]
% man ライブラリ関数名 [←]
% man コマンド名 [←]
% man -k キーワード [←]

マニュアルを表示するには,他に xman コマンドや,emacs の中では ESC x man やメニューのHelp→Manuals→Read Man Pageと選び,モードラインに Manual Entry と表示されたところに表示したいシステムコール,ライブラリ関数,又はコマンド名を打つことで表示することができる.

マニュアルの章立ては以下のようになっている.

1章コマンド
2章システムコール
3章ライブラリ関数
4章デバイスファイル
5章ファイル形式
6章ゲーム
7章その他
8章管理用コマンド

man コマンドの引数に指定された名前は,1章から順番に検索される. 従って,man printf とすると1章の printf コマンドのマニュアルが表示されてしまう. ライブラリ関数である printf(3) について知りたいときには,3章であることを指定するために次のように章を指定する.

% man 3 printf [←]

各章の説明用に intro というエントリが用意されている. 2章(システムコール)について知りたいときには,次のようにする.

% man 2 intro [←]

コンパイルとリンク

プログラム作成から実行までの流れ

プログラムの作成には,emacs などのエディタでプログラムを作成し,それをCコンパイラ(cc)でコンパイルし,実行を繰り返すことになる. コンパイルでエラーになれば,エディタに戻りプログラムを変更の後,またコンパイル,実行となる. 実行時に間違いが見つかれば,エディタに戻りプログラムを変更の後,またコンパイル,実行となる. プログラムは,いきなり全部を作ろうとするのではなく,部品となる部分を少しずつ動作を確かめながら作るのが良い.

プログラム作成から実行まで

以下は,sum.c という1から10までの総和を求める簡単なプログラムをコンパイル実行した例である.

% nl -ba sum.c [←]
     1  #define MAX     10
     2
     3  main()
     4  {
     5          int     i, total;
     6
     7          total = 0;
     8          for (i = 1; i <= MAX; i++)
     9                  total += i;
    10
    11          printf("total = %d\n", total);
    12  }
% cc sum.c [←]
% ./a.out [←]
total = 55
%

ccにより起動されるプログラム

cc コマンド自体は実はコンパイルといった処理をしない. コンパイルに必要な処理をしてくれるコマンドを呼び出すだけである.

ccにより起動されるプログラム

cc に -v オプションを追加すると,cc から起動されるプログラムの様子がわかる. -v オプションは,cc から起動されるプログラムに指定される引数も全て表示される. そのため,例えばプリプロセッサの引数で指定されるマクロや,リンカに指定されるライブラリなどを知ることもできる.

% cc -v sum.c [←]
Reading specs from /usr/lib/gcc-lib/i386-redhat-linux/2.96/specs
gcc version 2.96 20000731 (Red Hat Linux 7.2 2.96-112.7.1)
 /usr/lib/gcc-lib/i386-redhat-linux/2.96/cpp0 -lang-c -v -D__GNUC__=2 -D__GNUC_MINOR__=96 -D__GNUC_PATCHLEVEL__=0 -D__ELF__ -Dunix -Dlinux -D__ELF__ -D__unix__ -D__linux__ -D__unix -D__linux -Asystem(posix) -D__NO_INLINE__ -Acpu(i386) -Amachine(i386) -Di386 -D__i386 -D__i386__ -D__tune_i386__ sum.c /tmp/cc7enFUM.i
GNU CPP version 2.96 20000731 (Red Hat Linux 7.2 2.96-112.7.1) (cpplib) (i386 Linux/ELF)
ignoring nonexistent directory "/usr/i386-redhat-linux/include"
#include "..." search starts here:
#include <...> search starts here:
 /usr/local/include
 /usr/lib/gcc-lib/i386-redhat-linux/2.96/include
 /usr/include
End of search list.
 /usr/lib/gcc-lib/i386-redhat-linux/2.96/cc1 /tmp/cc7enFUM.i -quiet -dumpbase sum.c -version -o /tmp/ccAeJU5p.s
GNU C version 2.96 20000731 (Red Hat Linux 7.2 2.96-112.7.1) (i386-redhat-linux) compiled by GNU C version 2.96 20000731 (Red Hat Linux 7.2 2.96-112.7.1).
 as -V -Qy -o /tmp/ccd7Y7D1.o /tmp/ccAeJU5p.s
GNU assembler version 2.10.91 (i386-redhat-linux) using BFD version 2.10.91.0.2
 /usr/lib/gcc-lib/i386-redhat-linux/2.96/collect2 -m elf_i386 -dynamic-linker /lib/ld-linux.so.2 /usr/lib/gcc-lib/i386-redhat-linux/2.96/../../../crt1.o /usr/lib/gcc-lib/i386-redhat-linux/2.96/../../../crti.o /usr/lib/gcc-lib/i386-redhat-linux/2.96/crtbegin.o -L/usr/lib/gcc-lib/i386-redhat-linux/2.96 -L/usr/lib/gcc-lib/i386-redhat-linux/2.96/../../.. /tmp/ccd7Y7D1.o -lgcc -lc -lgcc /usr/lib/gcc-lib/i386-redhat-linux/2.96/crtend.o /usr/lib/gcc-lib/i386-redhat-linux/2.96/../../../crtn.o
%

cc -E

cc に -E オプションを追加すると,プリプロセッサ(cpp)だけが起動され,結果は標準出力(stdout)に出される. マクロがうまく展開されない時,typedefがうまく処理されない時など,ヘッダファイルに関係するエラーが起きたときに,プリプロセッサを通した結果を見ると原因がわかることがある.

% cc -E sum.c | nl -ba [←]
     1  # 3 "sum.c"
     2  main()
     3  {
     4          int i, total;
     5
     6          total = 0;
     7          for (i = 1; i <= 10; i++)
     8                  total += i;
     9
    10          printf("total = %d\n", total);
    11  }
%

リンク

cc に -c オプションを追加すると,アセンブラまでが実行され,オブジェクトプログラムが作られる. オブジェクトプログラムのファイル名は,元のCプログラムのファイル名のサフィックス(接尾辞)を o に変えたものになる. 即ち,Cプログラムが sum.c というファイル名の場合は sum.o というファイル名になる.

オブジェクトプログラムを cc の引数に指定すると,cc はサフィックスから引数のファイルがオブジェクトプロプログラムであることを判定し,リンカを起動する.

% cc -c sum.c [←]
% ls sum.o [←]
sum.o
% cc -v sum.o [←]
Reading specs from /usr/lib/gcc-lib/i386-redhat-linux/2.96/specs
gcc version 2.96 20000731 (Red Hat Linux 7.2 2.96-112.7.1)
 /usr/lib/gcc-lib/i386-redhat-linux/2.96/collect2 -m elf_i386 -dynamic-linker /lib/ld-linux.so.2 /usr/lib/gcc-lib/i386-redhat-linux/2.96/../../../crt1.o /usr/lib/gcc-lib/i386-redhat-linux/2.96/../../../crti.o /usr/lib/gcc-lib/i386-redhat-linux/2.96/crtbegin.o -L/usr/lib/gcc-lib/i386-redhat-linux/2.96 -L/usr/lib/gcc-lib/i386-redhat-linux/2.96/../../.. sum.o -lgcc -lc -lgcc /usr/lib/gcc-lib/i386-redhat-linux/2.96/crtend.o /usr/lib/gcc-lib/i386-redhat-linux/2.96/../../../crtn.o
%

上の例では,collect2 というプログラムが起動されている. 下のように,collect2 に明示的に -v オプションを渡してみると,ld が起動されているのがわかる.

% /usr/lib/gcc-lib/i386-redhat-linux/2.96/collect2 -v -m elf_i386 -dynamic-linker /lib/ld-linux.so.2 /usr/lib/gcc-lib/i386-redhat-linux/2.96/../../../crt1.o /usr/lib/gcc-lib/i386-redhat-linux/2.96/../../../crti.o /usr/lib/gcc-lib/i386-redhat-linux/2.96/crtbegin.o -L/usr/lib/gcc-lib/i386-redhat-linux/2.96 -L/usr/lib/gcc-lib/i386-redhat-linux/2.96/../../.. sum.o -lgcc -lc -lgcc /usr/lib/gcc-lib/i386-redhat-linux/2.96/crtend.o /usr/lib/gcc-lib/i386-redhat-linux/2.96/../../../crtn.o [←]
collect2 version 2.96 20000731 (Red Hat Linux 7.2 2.96-112.7.1) (i386 Linux/ELF)/usr/bin/ld -v -m elf_i386 -dynamic-linker /lib/ld-linux.so.2 /usr/lib/gcc-lib/i386-redhat-linux/2.96/../../../crt1.o /usr/lib/gcc-lib/i386-redhat-linux/2.96/../../../crti.o /usr/lib/gcc-lib/i386-redhat-linux/2.96/crtbegin.o -L/usr/lib/gcc-lib/i386-redhat-linux/2.96 -L/usr/lib/gcc-lib/i386-redhat-linux/2.96/../../.. sum.o -lgcc -lc -lgcc /usr/lib/gcc-lib/i386-redhat-linux/2.96/crtend.o /usr/lib/gcc-lib/i386-redhat-linux/2.96/../../../crtn.o
GNU ld version 2.10.91 (with BFD 2.10.91.0.2)
%

動的リンクと静的リンク

リンクの方法には,動的(ダイナミック)リンクと静的(スタティック)リンクがある. 動的リンクとはライブラリを実行時にリンクする方法である. 一方,静的リンクでは,リンク時に全てのライブラリをリンクした実行形式を作成する. 動的リンクをした実行形式は,(ダイナミックリンク可能な)ライブラリを含まないため,静的リンクされた実行形式よりも小さくなる. また,動的リンクされるライブラリで使用されるメモリ領域は,実行形式間で共有することができるため,メモリ使用量が少ない. しかし,実行時にリンク(未解決シンボルの解消)を行うので,わずかに実行が遅くなる.

動的リンクされているかどうかは file コマンドで知ることができる. また,動的リンクされた実行形式が使用するライブラリは ldd コマンドを用いて知ることができる. 動的リンクされる共有ライブラリは libc.so.6 のように,共有ライブラリであることを表す .so (Shared Objects)の後に .X (Xは整数)というバージョン番号がくる.

% file a.out [←]
a.out: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), for GNU/Linux 2.2.5, dynamically linked (uses shared libs), not stripped
% ldd a.out [←]
        libc.so.6 => /lib/libc.so.6 (0x4002d000)
        /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)
%

現在,一部のコマンドを除きほとんど全ての実行形式で動的リンクが用いられている. そのため,デフォルトでは動的リンクされる. 静的リンクをするためには cc -static とする. 静的リンクされるライブラリは libc.a のように末尾が .a (Archive)で終る.

% cc -static sum.c [←]
% ldd a.out [←]
        not a dynamic executable
% file a.out [←]
a.out: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), for GNU/Linux 2.2.5, statically linked, not stripped

その他の(比較的)良く使われる cc のオプション

cc に -S オプションを付けると,サフィックスを s に変えたファイルに,アセンブリ言語のプログラムを書き出す. OSカーネルを作成するためにC言語から使用することのできないプロセッサの機能を使用する場合,アセンブリ言語のプログラムをCプログラムに,インラインアセンブラとして埋め込むことができる. そのような場合は,意図したようにCプログラムと結合されているかどうかの確認を cc -S で行う. また,コンパイラを作成,改良している人は,この結果を見てコンパイル結果を確認することがあるかもしれない.

デバッガ

デバッガとして gdb を利用する. 日本語マニュアルは以下に用意されている.

http://www.coins.tsukuba.ac.jp/~syspro/gdb-5.0-doc/

ソースコードを参照しながらデバッガを用いたい場合は cc に -g オプションを付けてコンパイルする必要がある. -g オプションを付けていない場合でも,デバッガを用いることはでき,バックトレース(関数の呼び出し履歴)やアセンブリ言語でどの命令で問題が発生したかはわかる. (stripコマンドでシンボル情報が削除されてしまうと,関数名などグローバルシンボルも表示されなくなる.) -g オプションを付けてコンパイルした場合は,ソースコードの何行目に問題が発生したのかがわかり,また変数名を指定してその値を調べることもできる.

良くあるバグにセグメンテーションフォルト(segmentation fault)がある. セグメンテーションフォルトは,アクセスが許可されていない番地にアクセスすると発生する. 非常に典型的な例としてNULLポインタアクセスがある. 0番地は通常アクセスが許可されていないため,NULLポインタにアクセスするとセグメンテーションフォルトが発生する. 以下はNULLポインタアクセスによりセグメンテーションフォルトを起こすプログラムを,デバッガから実行した例である.

% cc segfault.c [←]
% a.out [←]
Segmentation fault
% cc -g segfault.c [←]
% gdb a.out [←]
GNU gdb Red Hat Linux (5.1-0.71)
Copyright 2001 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB.  Type "show warranty" for details.
This GDB was configured as "i386-redhat-linux"...
(gdb) run [←]
Starting program: /home/lab/Denjo/shui/syspro/a.out

Program received signal SIGSEGV, Segmentation fault.
0x08048473 in access_null_pointer () at segfault.c:7
7               printf("%c", *a);
(gdb) backtrace [←]
#0  0x08048473 in access_null_pointer () at segfault.c:7
#1  0x08048493 in test () at segfault.c:12
#2  0x080484a3 in main () at segfault.c:17
#3  0x40049336 in __libc_start_main (main=0x8048498 
, argc=1, ubp_av=0xbfffee84, init=0x80482e4 <_init>, fini=0x80484f0 <_fini>, rtld_fini=0x4000d25c <_dl_fini>, stack_end=0xbfffee7c) at ../sysdeps/generic/libc-start.c:129 (gdb) list [←] 2 3 access_null_pointer() 4 { 5 char *a = NULL; 6 7 printf("%c", *a); 8 } 9 10 test() 11 { (gdb) print a [←] $1 = 0x0 (gdb) quit [←] The program is running. Exit anyway? (y or n) y [←] %

xxgdb

gdbをウィンドウ中から動かしてくれる(GUIフロントエンド)として xxgdb がある. 以下のようなウィンドウを表示し,ボタンをクリックすることで操作することができる.

xxgdb

Emacs の中から gdb

Emacs (Mule) の中から gdb を呼ぶこともできる. エラーが起こったソースコードの行を自動的に表示してくれる. 実行の仕方は

  1. emacs を実行する。
  2. ESC x gdb [←]
  3. gdb program_name [←]
とする.

xxgdb

strace コマンド

gdb の他のデバッグツールとして,プログラムの呼び出したシステムコールを表示してくれる strace コマンドがある. 以下は2つのウィンドウを使い,上のウィンドウでstrace コマンドを実行し,その出力を下のウィンドウに表示させた例を示している. strace コマンドは -o オプションを指定することで,結果をファイルに書き出すことができる. 書き出し先を,別ウィンドウのターミナルデバイスにすることで,別のウィンドウに結果を見ることができる.

% nl -ba getputchar.c [←]
     1  #include <stdio.h>
     2
     3  main()
     4  {
     5          int     c;
     6
     7          while ((c = getchar()) != EOF)
     8                  putchar(c);
     9  }
% cc getputchar.c [←]
% strace -o /dev/pts/1 ./a.out [←]
1[←]
1
1234567890[←]
1234567890
[C-D]
%
% tty [←]
/dev/pts/1
% execve("./a.out", ["./a.out"], [/* 46 vars */]) = 0
uname({sys="Linux", node="adonis1.coins.tsukuba.ac.jp", ...}) = 0
brk(0)                                  = 0x804962c
old_mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x40016000
open("/etc/ld.so.preload", O_RDONLY)    = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY)      = 3
fstat64(3, {st_mode=S_IFREG|0644, st_size=87112, ...}) = 0
old_mmap(NULL, 87112, PROT_READ, MAP_PRIVATE, 3, 0) = 0x40017000
close(3)                                = 0
open("/lib/libc.so.6", O_RDONLY)        = 3
read(3, "\177ELF\1\1\1\0\0\0\0\0\0\0\0\0\3\0\3\0\1\0\0\0 \304\1"..., 1024) = 1024
fstat64(3, {st_mode=S_IFREG|0755, st_size=5737218, ...}) = 0
old_mmap(NULL, 1267240, PROT_READ|PROT_EXEC, MAP_PRIVATE, 3, 0) = 0x4002d000
mprotect(0x40159000, 38440, PROT_NONE)  = 0
old_mmap(0x40159000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED, 3, 0x12b000) = 0x40159000
old_mmap(0x4015f000, 13864, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x4015f000
close(3)                                = 0
munmap(0x40017000, 87112)               = 0
fstat64(0, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0
old_mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x40017000
read(0, "1\n", 1024)                    = 2
fstat64(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0
old_mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x40018000
write(1, "1\n", 2)                      = 2
read(0, "1234567890\n", 1024)           = 11
write(1, "1234567890\n", 11)            = 11
read(0, "", 1024)                       = 0
munmap(0x40018000, 4096)                = 0
_exit(-1)                               = ?

上記のプログラム例では特に問題は起きていないが,どのシステムコールが呼び出されているかを知ることができる.

以下は,gdb のところで取り上げたセグメンテーションフォルトを起こすプログラムをstrace コマンドを用いて実行してみた例である.

% strace ./a.out [←]
execve("./a.out", ["./a.out"], [/* 46 vars */]) = 0
uname({sys="Linux", node="adonis1.coins.tsukuba.ac.jp", ...}) = 0
brk(0)                                  = 0x804961c
old_mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x40016000
open("/etc/ld.so.preload", O_RDONLY)    = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY)      = 3
fstat64(3, {st_mode=S_IFREG|0644, st_size=87112, ...}) = 0
old_mmap(NULL, 87112, PROT_READ, MAP_PRIVATE, 3, 0) = 0x40017000
close(3)                                = 0
open("/lib/libc.so.6", O_RDONLY)        = 3
read(3, "\177ELF\1\1\1\0\0\0\0\0\0\0\0\0\3\0\3\0\1\0\0\0 \304\1"..., 1024) = 1024
fstat64(3, {st_mode=S_IFREG|0755, st_size=5737218, ...}) = 0
old_mmap(NULL, 1267240, PROT_READ|PROT_EXEC, MAP_PRIVATE, 3, 0) = 0x4002d000
mprotect(0x40159000, 38440, PROT_NONE)  = 0
old_mmap(0x40159000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED, 3, 0x12b000) = 0x40159000
old_mmap(0x4015f000, 13864, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x4015f000
close(3)                                = 0
munmap(0x40017000, 87112)               = 0
--- SIGSEGV (Segmentation fault) ---
+++ killed by SIGSEGV +++
%

残念ながら,このような場合には strace コマンドはあまり有効な情報を示してはくれない. デバッグツールには向き不向きがあるため,どのような場合にはどのツールを使うと便利かは,いろいろ試して学ぶ必要がある.

make コマンド

Cプログラムはファイル単位でのシンボルのスコープを持つため,また大きなソースプログラムファイルはコンパイルに時間がかかるため,プログラムは適度に複数ファイルに分割して作成することが望ましい. Cプログラムを複数ファイルに分割した場合,まずそれぞれを cc -c によりオブジェクトプログラムにコンパイルする. そうすると,Cプログラムファイルごとに(サフィックスが o の)オブジェクトプログラムのファイルができる. これらのオブジェクトプログラムのファイルをリンクして実行形式を作る.

makeの例

このようなコンパイル手順を自動化してくれるのが make コマンドである. make コマンドは,カレントディレクトリにある makefile 又は Makefile を読み込み,そこに書かれているルールに従ってコンパイルを行う(ルールに従って処理をするだけで,処理がコンパイルである必要性は全くない). カレントディレクトリに makefile と Makefile の両方があった場合は,makefile が優先される. 以下は,上の図のコンパイル手順を Makefile として記述したものである.

a.out: file-1.o file-2.o
        cc file-1.o file-2.o

file-1.o: file-1.c
        cc -c file-1.c

file-2.o: file-2.c
        cc -c file-2.c

ルールの書き方の基本的なところは単純で,作成したいターゲットの作成方法を以下のように記述する. 依存ファイルがまた別の依存ファイルから作成される場合(例えば file1.o が file1.cから作成される場合)は,そのためのルールを書く. 依存ファイルがない場合は,必ず処理が実行される.

ターゲット: 依存するファイル
<TAB>ターゲット作成のための処理1
<TAB>ターゲット作成のための処理2
...
<TAB>ターゲット作成のための処理n

実際に make コマンドを実行してみると,以下のようになる.

% make [←]
cc -c file-1.c
cc -c file-2.c
cc file-1.o file-2.o
%
file-2.cを変更
% make [←]
cc -c file-2.c
cc file-1.o file-2.o
%

Makefile が複雑になると,Makefile を見ただけでは実際にどのように処理が進むのかすぐにはわからないこともある.そのような場合,make をする前に make -n とすると,実際にコマンドは起動されないが,起動されるコマンドを処理の流れとともに表示してくれる.

ちょっと復習

ポインタ

入出力のためのライブラリ関数やシステムコールにはポインタが必須である. 関数を呼び出し,まとまったデータをやり取りするためにはポインタを使う必要があるからである.

例えば下図のように,あるプログラムがライブラリ又はカーネルを呼び出し,データを読み込もうとしているとする. プログラムは,データを読み込むデータ領域を予め確保し,そこにデータを読み込みたい. そのデータを読み込ませたいデータ領域の先頭を,ライブラリ又はカーネルに知らせる方法がポインタである. ポインタの実態はアドレス(番地)である. ライブラリ又はカーネルはアドレスを受け取り,そこからデータを書き込みはじめる.

データコピー先のポインタ

ポインタ型の変数を宣言しても,初期化されていない変数の値は不定であり,ポインタとしてはどこを指しているかわからない. 次のプログラムの4行目の read システムコールでは,標準入力から1文字 buf に読み込もうとしているが,buf は初期化されていないため,どこに書き込まれるかわからない.

     1  main()
     2  {
     3          void *buf;
     4          read(0, buf, 1);
     5  }

次のプログラムように,buf を初期化すれば,一応は正しいプログラムになる. 5行目で,buf は a を指すポインタとして初期化されている. &a のように,変数の前に & を付けるとその変数のアドレスが値になる. そのため &a は a のアドレスということになる.

     1  main()
     2  {
     3          char a;
     4          void *buf;
     5          buf = &a;
     6          read(0, buf, 1);
     7  }

正確には,変数の前に & を付けて得られる値の型は,その変数の型へのポインタ型ということになる. char型の変数 a に対し,&a の型は char* となる. int型の変数 i に対し,&i の型は int* となる.

間違ったアドレスに書いてしまう又は書こうとするといろいろ問題が発生する. 書き込めないアドレスに書こうとすると,セグメンテーションフォルトが発生し,プログラムは異常終了する. この場合は,異常終了により間違ったことが起きていることがすぐにわかるので,まだましな場合である.

書き込めるデータ領域だが,意図とは違う間違ったアドレスに書き込まれてしまうと,書き込み自体は成功してしまい,プログラムの実行が進んでから問題が起こる. すると,どうしてここに変なデータが書き込まれているのかと悩むことになる.

これをうまく使ったのがバッファオーバフローである. 外部からスタック上に確保されているバッファよりも多くのデータ(プログラム)を読み込ませることにより,プログラムの実行を乗っ取ってしまう. バッファオーバフローについては第2回目の講義でもう少し詳しく説明する.

ポインタは非常に便利でありシステムプログラミングには欠かせないが,細心の注意をもって使用しないといけない.

練習問題

練習問題(1)

cc コマンドを実行するウィンドウとは別のウィンドウで top コマンド(top -d 1)を実行し,コンパイル時に実行されるプロセスを観察してみなさい.

練習問題(2)

pstree コマンドで,プロセスの親子関係を観察してみなさい. 特に,下の練習問題(3)のようなパイプを使ったコマンド実行時に,プロセスの親子関係がどうなるかを観察してみなさい.

練習問題(3)

man,Emacs 内での ESC x man の使い方を練習しなさい.

man で表示されるマニュアルのファイルは /usr/share/man ディレクトリ以下にある. マニュアルの実際のファイルを確認し,表示させてみよ. 例えば,ls コマンドのマニュアルを表示させるには以下のようにする.

% zcat /usr/share/man/ja/man1/ls.1.gz | nroff -man | less

練習問題(4)

デバッガ gdb, xxgdb, Emacs 内での ESC x gdb の使い方を練習し,自分にあった方法を見つけなさい.

練習問題(5)

Segmentation fault を起こすプログラムをかき,それをデバッガで追跡し,どこで起きたかを調べなさい. プログラムは cc -g でコンパイルすること.

練習問題(6)

make コマンドのところで示した Makefile と,適当な C プログラムで,make コマンドを試してみなさい.

file-1.o, file-2.o や a.out ファイルを消してみたり,touch file-1.c などとして,make によりどのようにコンパイラが起動されるかを観察しなさい.

練習問題(7)

Makefile はコンパイルだけに使用する必要はない. ルールにのっとって,処理をするためのコマンドを実行するだけである. そこで,Makefile を用いてよく行われるのが,コンパイルによって作られたファイルの消去である.

Makefile にルールを追加し,以下のようにオブジェクトファイルが消去されるようにしなさい.

% make clean [←]
/bin/rm -f a.out file-1.o file-2.o
% 

練習問題(8)

make は便利に使えるように,予め様々な暗黙のルールを持っており,処理の全てを事細かに Makefile に記述しなくても処理を行えるようになっている. a.out が file-1.o と file-2.o から作られる依存関係が定義されており,カレントディレクトリに file-1.c,file-2.c がある場合,make は .c というサフィックスから cc -c コマンドを用いて .o ファイルを作成するというルールを持っている. つまり,file-1.o は file-1.c に依存し,cc -c コマンドで作成するというルールを,いちいち Makefile に書く必要はなくなる. また変数を定義する機能もあり,複数のファイル名を値として持つ変数を定義することで,いちいちファイル名を列挙する必要が無くなる. 例えば,

OBJS = file-1.o file-2.o

と書くと,シェルで変数値を参照するときのように,$(OBJS) は file-1.o file-2.o を意味するようになる.

変数と暗黙のルールを用いて,練習問題(7)で作成した Makefile をできるだけ短く書き換えなさい.

参考: 暗黙のルールは make -p を使うと表示できる. 実際に処理コマンドの実行が必要でない場合が多いので,make -n -p とするとよい.

練習問題(9)

同じプログラムについて,動的リンク,静的リンク,それぞれの方法で実行形式を作成し,strace コマンドを用いてトレースした時の違いについて観察しなさい.

練習問題(10)

以下のようにすると strace により sh シェルが起動され,トレース結果は sh.log に書き出される. strace に -f オプションをつけることにより,sh から起動された子プロセスについてもトレースすることができる. トレースされたシェルの中でコマンドを実行することにより,シェルがどのようにシステムコールを呼び出し,様々な機能を実現しているか推測することができる. sh.log を解析し,どのような順序でシステムコールを呼び出すことで,パイプが実現されているか調べなさい.

% strace -f -o sh.log sh [←]
sh-2.05b$ ls | sort | wc [←]
     18      18     255
sh-2.05b$ exit [←]
exit
% egrep 'fork|pipe|dup|exec|exit|wait' sh.log [←]
...