Bash 셸 스크립팅 완전 가이드

Bash(Bourne-Again SHell)는 리눅스에서 가장 널리 사용되는 명령행 인터프리터이자 스크립팅 언어입니다. 이 문서는 Bash의 내부 구조(렉서/파서/실행기), 변수와 확장, 제어 흐름, 함수, I/O 리디렉션(Redirection), 프로세스 모델과 잡 제어(Job Control), 시그널(Signal) 처리, 프로세스 간 통신(IPC), /proc·/sys 인터페이스 스크립팅, 커널 빌드 자동화, 그리고 POSIX 호환성과 셸 비교까지 커널 개발자 관점에서 상세히 다룹니다.

전제 조건: 개발 도구빌드 시스템 문서를 먼저 읽으면 Bash가 리눅스 커널 개발 워크플로에서 어떤 역할을 하는지 더 잘 이해할 수 있습니다.
일상 비유: Bash는 운영체제의 통역사와 같습니다. 사용자가 자연어에 가까운 명령을 입력하면, Bash가 이를 커널이 이해하는 시스템 호출(System Call)로 번역하여 실행합니다. 마치 외국어 통역사가 문장을 분석(파싱)하고, 문맥을 파악(변수 확장)한 뒤, 적절한 번역(실행)을 수행하는 것과 같습니다.

핵심 요약

  • Bourne-Again SHell — 1989년 Brian Fox가 FSF를 위해 작성한 자유 소프트웨어 셸로, Bourne Shell(sh)의 상위 호환(Superset)입니다.
  • 커널 빌드의 접착제 — 리눅스 커널 소스 트리의 scripts/ 디렉토리에는 수백 개의 셸 스크립트가 있으며, Kconfig, Kbuild, 모듈 설치 등 빌드 과정 전반에 Bash가 관여합니다.
  • 7단계 확장 — Bash는 명령을 실행하기 전에 중괄호 확장, 틸드 확장, 매개변수 확장, 산술 확장, 명령 치환, 단어 분리, 경로명 확장의 7단계를 순서대로 수행합니다.
  • 프로세스 모델 — 외부 명령 실행 시 fork()+exec() 조합을 사용하며, 파이프라인의 각 단계는 별도 프로세스로 실행됩니다.
  • 잡 제어 — 세션(Session), 프로세스 그룹(Process Group), 포그라운드/백그라운드(Foreground/Background) 개념을 통해 다중 작업을 관리합니다.
  • POSIX 호환--posix 플래그나 /bin/sh로 호출 시 POSIX.1-2017 표준 모드로 동작하며, 배열·정규식 매칭 등 Bash 전용 확장은 비활성화됩니다.

핵심 용어 정리

용어설명
셸(Shell)사용자와 커널 사이의 명령행 인터페이스. 명령을 해석하고 실행하는 프로그램
빌트인(Builtin)cd, echo, export 등 셸 내부에 구현된 명령. fork() 없이 현재 프로세스에서 직접 실행
서브셸(Subshell)현재 셸의 자식 프로세스로 생성된 셸 환경. ( ), 파이프라인, 명령 치환 등에서 생성됨
확장(Expansion)Bash가 명령 실행 전에 변수, 와일드카드, 산술식 등을 실제 값으로 치환하는 과정
리디렉션(Redirection)표준 입출력(stdin/stdout/stderr)의 대상을 파일, 파이프, 소켓 등으로 변경하는 기능
파이프라인(Pipeline)cmd1 | cmd2 형태로 한 명령의 출력을 다음 명령의 입력으로 연결하는 구조
잡 제어(Job Control)셸이 프로세스 그룹 단위로 포그라운드/백그라운드 작업을 관리하는 기능
트랩(Trap)시그널 수신 시 실행할 명령을 등록하는 Bash 빌트인. 정리(Cleanup) 작업에 필수적

단계별 이해

Bash 스크립팅을 처음 접하는 커널 개발자를 위한 8단계 가이드입니다:

1단계: 첫 번째 스크립트

#!/bin/bash
# 셔뱅(Shebang) 라인: 이 스크립트를 실행할 인터프리터 지정
echo "Hello, Kernel Developer!"
echo "현재 커널 버전: $(uname -r)"

2단계: 변수와 매개변수

#!/bin/bash
KERNEL_SRC="/usr/src/linux"
ARCH="x86_64"
JOBS="$(nproc)"

echo "커널 소스: ${KERNEL_SRC}"
echo "아키텍처: ${ARCH}"
echo "병렬 빌드 수: ${JOBS}"

# 기본값 설정 (변수가 비어있으면 기본값 사용)
CROSS_COMPILE="${CROSS_COMPILE:-}"
INSTALL_PATH="${INSTALL_PATH:-/boot}"

3단계: 조건문으로 환경 검사

#!/bin/bash
# 커널 소스 디렉토리 존재 확인
if [[ ! -d "/usr/src/linux" ]]; then
    echo "오류: 커널 소스 디렉토리가 없습니다" >&2
    exit 1
fi

# 필수 도구 확인
for tool in gcc make flex bison; do
    if ! command -v "$tool" >/dev/null 2>&1; then
        echo "오류: ${tool}이(가) 설치되지 않았습니다" >&2
        exit 1
    fi
done
echo "모든 필수 도구가 설치되어 있습니다."

4단계: 함수로 구조화

#!/bin/bash
die() {
    echo "오류: $*" >&2
    exit 1
}

warn() {
    echo "경고: $*" >&2
}

check_root() {
    [[ "$(id -u)" -eq 0 ]] || die "root 권한이 필요합니다"
}

build_kernel() {
    local config="$1"
    make mrproper
    cp "${config}" .config
    make olddefconfig
    make -j"$(nproc)" || die "커널 빌드 실패"
}

5단계: 파이프와 텍스트 처리

# 커널 설정에서 활성화된 옵션 수 세기
grep -c "^CONFIG_.*=y" .config

# 로드된 모듈을 메모리 사용량 순으로 정렬
lsmod | sort -k 2 -n -r | head -10

# /proc/cpuinfo에서 CPU 모델명 추출
grep "model name" /proc/cpuinfo | head -1 | cut -d: -f2

6단계: 시그널 트랩으로 정리 작업

#!/bin/bash
TMPDIR=""

cleanup() {
    [[ -n "$TMPDIR" ]] && rm -rf "$TMPDIR"
    echo "임시 파일 정리 완료"
}

trap cleanup EXIT
TMPDIR="$(mktemp -d)"
echo "작업 디렉토리: ${TMPDIR}"
# 스크립트가 정상 종료하든 오류로 종료하든 cleanup() 호출 보장

7단계: /proc과 /sys 활용

# CPU 거버너 확인 및 변경
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
echo "performance" | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor

# 투명 대규모 페이지(THP) 상태 확인
cat /sys/kernel/mm/transparent_hugepage/enabled

# 네트워크 튜닝
sysctl net.core.somaxconn
sudo sysctl -w net.core.somaxconn=4096

8단계: 커널 빌드 자동화 스크립트

#!/bin/bash
set -euo pipefail

KERNEL_VER="$(make -s kernelversion)"
LOG="build-${KERNEL_VER}-$(date +%Y%m%d%H%M).log"

echo "커널 ${KERNEL_VER} 빌드 시작..."
make -j"$(nproc)" 2>&1 | tee "${LOG}"
make modules_install INSTALL_MOD_PATH="./rootfs"
echo "빌드 완료. 로그: ${LOG}"

Bash 개요 및 역사

Bash(Bourne-Again SHell)는 1989년 Brian Fox가 자유 소프트웨어 재단(FSF, Free Software Foundation)을 위해 개발한 유닉스 셸입니다. 이름 자체가 Bourne Shell(sh)의 "재탄생(Born Again)"을 의미하는 말장난으로, Stephen Bourne이 1979년에 작성한 원조 Bourne Shell의 상위 호환을 목표로 설계되었습니다. 1990년부터는 Chet Ramey가 주 관리자를 맡아 현재(5.2 버전, 2022년)까지 지속적으로 발전하고 있습니다.

유닉스 셸의 역사는 1971년 Ken Thompson이 작성한 Thompson Shell에서 시작됩니다. 이후 1978년 Bill Joy의 C Shell(csh), 1979년 Stephen Bourne의 Bourne Shell(sh), 1983년 David Korn의 KornShell(ksh)이 등장하며 셸 생태계가 다양해졌습니다. 1992년 POSIX.2 표준이 셸 명령어 언어를 규격화하면서, 이후 등장한 셸들은 이 표준을 기반으로 구현되었습니다. Bash는 Bourne Shell 호환성을 유지하면서 csh의 히스토리 기능과 ksh의 산술 확장 등 다른 셸의 장점을 흡수한 종합 셸입니다.

리눅스 커널 소스 트리에서 Bash의 역할은 매우 중요합니다. scripts/ 디렉토리에는 scripts/config(Kconfig 조작), scripts/diffconfig(설정 비교), scripts/setlocalversion(버전 문자열 생성), scripts/merge_config.sh(설정 병합) 등 핵심 빌드 도구가 셸 스크립트로 구현되어 있습니다. 또한 scripts/checkpatch.pl은 Perl이지만 셸에서 호출되며, scripts/Makefile.build$(shell ...)을 통해 빈번하게 셸 명령을 실행합니다. 커널 개발자에게 Bash 스크립팅은 필수 역량입니다.

대부분의 리눅스 배포판은 /bin/bash를 기본 셸로 제공합니다. 다만 데비안(Debian)과 우분투(Ubuntu)는 시스템 셸(/bin/sh)로 Dash를 사용하고, Alpine Linux는 BusyBox ash를 사용합니다. 이러한 차이는 스크립트 이식성(Portability)에 직접적인 영향을 미치며, 이 문서 후반부의 POSIX 호환성 섹션에서 자세히 다룹니다.

Thompson sh 1971 csh 1978 (Joy) tcsh Bourne sh 1979 (Bourne) ksh 1983 (Korn) POSIX sh 1992 표준 Bash 1989 (Fox/Ramey) dash/ash 경량 POSIX zsh 1990
그림 1. 유닉스 셸 계보도 — Thompson Shell(1971)에서 시작하여 Bourne Shell, C Shell, KornShell을 거쳐 Bash(1989)와 POSIX sh(1992) 표준으로 수렴하는 과정. 점선은 기능적 영향을 나타냄

Bash 내부 구조

Bash는 단순한 명령 실행기를 넘어, 완전한 프로그래밍 언어 인터프리터입니다. 사용자가 입력한 텍스트는 렉서(Lexer), 파서(Parser), 확장기(Expander), 실행기(Executor)의 네 단계를 거쳐 최종적으로 실행됩니다. 이 내부 구조를 이해하면 셸 스크립트의 미묘한 동작 차이(따옴표 처리, 단어 분리, 글로빙 등)를 정확히 파악할 수 있습니다.

렉서와 파서

렉서(Lexical Analyzer)는 입력 문자열을 토큰(Token) 단위로 분리합니다. Bash의 토큰에는 단어(WORD), 연산자(|, &&, ; 등), 예약어(if, then, do 등)가 있습니다. 렉서는 따옴표(Quoting) 상태를 추적하여 작은따옴표('...') 내부에서는 모든 특수 문자를 리터럴로 처리하고, 큰따옴표("...") 내부에서는 $, `, \만 특수하게 처리합니다.

파서(Parser)는 토큰 스트림을 추상 구문 트리(AST, Abstract Syntax Tree)로 변환합니다. Bash 파서는 LALR(1) 파서가 아닌, 재귀 하향(Recursive Descent) 방식에 가까운 수동 파서입니다. 이는 셸 문법의 문맥 의존적 특성(예: (가 서브셸인지 함수 정의인지는 문맥에 따라 결정) 때문입니다. 파싱 결과로 생성된 AST의 노드 유형에는 단순 명령(Simple Command), 파이프라인(Pipeline), 리스트(List, &&/||/;), 복합 명령(Compound Command, if/while/for/case/{ }/( )) 등이 있습니다.

따옴표 처리(Quoting)는 렉서 단계에서 이루어지는 핵심 메커니즘입니다. 커널 스크립트에서 자주 실수하는 패턴을 살펴보겠습니다:

# 잘못된 예: 단어 분리가 일어남
FILE="my kernel config"
if [ -f $FILE ]; then ...  # 오류! 3개의 인자로 분리됨

# 올바른 예: 큰따옴표로 보호
if [ -f "$FILE" ]; then ...  # 하나의 인자로 전달

# [[ ]]는 단어 분리를 하지 않으므로 안전
if [[ -f $FILE ]]; then ...  # Bash 전용, 안전

# 작은따옴표: 모든 확장 억제
echo '$HOME은 확장되지 않습니다'

# $'...' ANSI-C 인용: 이스케이프 시퀀스 해석
echo $'\t탭\n개행'
입력 문자열/파일 렉서 토큰화 파서 AST 생성 확장기 7단계 확장 실행기 fork/exec 출력 stdout/stderr 따옴표 처리 구문 분석 변수·글로빙 빌트인/외부
그림 2. Bash 처리 파이프라인 — 입력 문자열이 렉서(토큰화)→파서(AST)→확장기(7단계)→실행기(fork/exec)를 거쳐 최종 출력으로 변환되는 과정

실행 모델

Bash의 실행기(Executor)는 AST를 순회하며 각 노드를 실행합니다. 명령의 종류에 따라 실행 방식이 크게 달라집니다:

빌트인 명령(Builtin Command)은 Bash 프로세스 내부에서 직접 실행됩니다. cd, export, read, echo, test 등이 이에 해당합니다. cd가 빌트인이어야 하는 이유는, 자식 프로세스에서 chdir()를 호출해도 부모(셸) 프로세스의 작업 디렉토리에는 영향이 없기 때문입니다. 빌트인 목록은 enable -a 명령으로 확인할 수 있습니다.

외부 명령(External Command)fork()로 자식 프로세스를 생성한 뒤, 자식에서 execve()로 해당 프로그램을 로드하여 실행합니다. Bash는 PATH 환경 변수에 나열된 디렉토리를 순서대로 탐색하되, 성능을 위해 해시 테이블(Hash Table)에 이전 탐색 결과를 캐싱합니다. hash 빌트인으로 이 캐시를 확인하거나 초기화할 수 있습니다.

함수(Function)는 현재 셸 컨텍스트에서 실행됩니다(서브셸이 아닙니다). 따라서 함수 내에서 변경한 변수는 호출자에게 영향을 미칩니다. local 키워드로 지역 변수를 선언하면 동적 스코핑(Dynamic Scoping)이 적용되어, 해당 함수와 그 함수가 호출하는 하위 함수에서만 보입니다.

명령어 입력 빌트인? 함수? 셸 내부 실행 fork() 없음 아니오 fork() 자식 프로세스 생성 execve() 프로그램 로드 부모: waitpid() 대기 $? (종료 상태)
그림 3. 명령 실행 흐름도 — 빌트인/함수는 셸 내부에서 직접 실행하고, 외부 명령은 fork()+execve() 후 waitpid()로 종료 상태를 수집

서브셸과 환경

서브셸(Subshell)은 현재 셸의 복제본(clone)으로 생성되는 자식 프로세스입니다. 다음 상황에서 서브셸이 생성됩니다:

서브셸은 부모 셸의 변수, 함수, 트랩, 옵션 등을 상속받지만, 서브셸 내에서 변경한 내용은 부모에게 전파되지 않습니다. 이것이 파이프라인에서의 흔한 함정입니다:

# 함정: 파이프라인에서 변수 변경이 반영되지 않음
count=0
cat /proc/modules | while read line; do
    ((count++))
done
echo "$count"  # 0 출력! while이 서브셸에서 실행됨

# 해결 1: 프로세스 치환 사용
count=0
while read line; do
    ((count++))
done < <(cat /proc/modules)
echo "$count"  # 정확한 모듈 수 출력

# 해결 2: lastpipe (Bash 4.2+)
shopt -s lastpipe
count=0
cat /proc/modules | while read line; do
    ((count++))
done
echo "$count"  # 정확한 모듈 수 출력

# 해결 3: 리디렉션으로 파이프 자체를 제거
count=0
while read line; do
    ((count++))
done < /proc/modules
echo "$count"  # 파일 리디렉션 → 서브셸 없음

서브셸에서의 환경 상속은 다음 규칙을 따릅니다:

속성서브셸 상속변경 전파(부모→)
환경 변수 (export)아니오
셸 변수 (비export)아니오
함수 정의아니오
트랩 설정리셋됨아니오
현재 디렉토리 ($PWD)아니오
셸 옵션 (set, shopt)아니오
$$ (원래 셸 PID)부모와 동일N/A
$BASHPID (실제 PID)자식 PIDN/A
# $$ vs $BASHPID 차이 확인
echo "부모: $$=$$ BASHPID=$BASHPID"
( echo "서브셸: $$=$$ BASHPID=$BASHPID" )
# 부모: $$=1234 BASHPID=1234
# 서브셸: $$=1234 BASHPID=5678  ← $$ 동일, BASHPID만 다름

# 서브셸 vs 그룹 명령 (중괄호)
x=10
( x=20; echo "서브셸 안: $x" )   # 20
echo "서브셸 밖: $x"            # 10 (변경 없음)

{ x=30; echo "그룹 안: $x"; }   # 30
echo "그룹 밖: $x"             # 30 (현재 셸에서 실행됨!)

변수와 확장

Bash에서 변수는 데이터를 저장하고 조작하는 가장 기본적인 메커니즘입니다. C 언어와 달리 Bash 변수에는 타입(Type)이 없으며 기본적으로 모든 값이 문자열로 저장됩니다. declare 빌트인을 사용하면 정수(-i), 읽기 전용(-r), 배열(-a), 연관 배열(-A), 대소문자 변환(-l/-u) 등의 속성을 부여할 수 있습니다.

변수 종류

Bash 변수는 크게 셸 변수(Shell Variable)와 환경 변수(Environment Variable)로 나뉩니다. 셸 변수는 현재 셸 프로세스에서만 유효하고, 환경 변수는 export를 통해 자식 프로세스에게 상속됩니다. 커널 빌드에서 ARCH, CROSS_COMPILE 등은 환경 변수로 설정해야 make의 하위 프로세스에서도 참조할 수 있습니다.

특수 변수설명커널 개발 활용 예
$?마지막 명령의 종료 상태 (0=성공)make && echo "빌드 성공" || echo "빌드 실패"
$$현재 셸의 PID임시 파일명에 PID 포함: /tmp/build.$$.log
$!마지막 백그라운드 작업의 PIDQEMU 실행 후 PID 추적: qemu ... & QEMU_PID=$!
$#위치 매개변수 개수인자 검증: [[ $# -lt 1 ]] && die "사용법: $0 <config>"
$@모든 위치 매개변수 (개별 단어)인자 전달: make "$@"
$*모든 위치 매개변수 (단일 문자열)로깅: echo "인자: $*"
$0스크립트 이름 또는 셸 이름사용법 출력: echo "사용법: $0 [-v] [-j N]"
$_이전 명령의 마지막 인자반복 참조: mkdir build && cd $_
$LINENO현재 줄 번호디버깅: echo "DEBUG[$LINENO]: 변수=$var"
$FUNCNAME현재 함수명 (배열)에러 메시지: echo "${FUNCNAME[0]}: 실패"
$BASH_SOURCE소스 파일명 (배열)스크립트 경로: DIR="$(dirname "${BASH_SOURCE[0]}")"
$PIPESTATUS파이프라인 각 단계의 종료 상태 (배열)파이프 검증: make 2>&1 | tee log; echo "${PIPESTATUS[0]}"
# declare를 이용한 변수 속성 설정
declare -i counter=0          # 정수 속성: 산술 자동 평가
counter="1+2"                    # counter=3 (문자열이 아닌 산술식으로 평가)

declare -r KERNEL_VERSION="6.1"  # 읽기 전용: 이후 변경 불가
declare -l lower_str="HELLO"    # 소문자 변환: "hello"
declare -u upper_str="hello"    # 대문자 변환: "HELLO"
declare -x EXPORTED_VAR="값"   # export와 동일

# nameref (Bash 4.3+): 다른 변수를 가리키는 참조
declare -n ref=KERNEL_VERSION
echo "$ref"  # "6.1" 출력 — KERNEL_VERSION의 값 참조

확장 순서

Bash는 명령을 실행하기 전에 정해진 순서로 7단계의 확장(Expansion)을 수행합니다. 이 순서를 정확히 이해하는 것이 예측 가능한 스크립트를 작성하는 핵심입니다. 각 단계는 이전 단계의 결과물 위에서 작동하므로, 순서가 중요합니다.

1 중괄호 확장 (Brace) — {a,b,c} 2 틸드 확장 (Tilde) — ~/path 3 매개변수 확장 — ${var}, ${var:-default} 4 산술 확장 — $((expression)) 5 명령 치환 — $(command) 6 단어 분리 7 경로명 확장 ※ 큰따옴표("") 안에서는 단어 분리(6)와 경로명 확장(7)이 억제됩니다
그림 4. Bash 7단계 확장 순서 — 중괄호→틸드→매개변수→산술→명령 치환→단어 분리→경로명 확장. 큰따옴표는 6·7단계를 억제
# 매개변수 확장의 다양한 패턴 (커널 스크립트에서 자주 사용)
KERNEL="linux-6.1.42"

echo "${KERNEL#linux-}"      # "6.1.42"    — 앞에서 최소 매칭 제거
echo "${KERNEL##*-}"         # "6.1.42"    — 앞에서 최대 매칭 제거
echo "${KERNEL%.42}"         # "linux-6.1" — 뒤에서 최소 매칭 제거
echo "${KERNEL%%.*}"         # "linux-6"   — 뒤에서 최대 매칭 제거
echo "${KERNEL/linux/Linux}" # "Linux-6.1.42" — 첫 매칭 치환
echo "${KERNEL//./,}"        # "linux-6,1,42" — 전체 매칭 치환
echo "${KERNEL:6:3}"         # "6.1"       — 부분 문자열 (오프셋:길이)
echo "${#KERNEL}"            # "12"        — 문자열 길이

# 기본값 패턴
echo "${CROSS_COMPILE:-}"           # 미설정이면 빈 문자열
echo "${INSTALL_PATH:=/boot}"       # 미설정이면 /boot 대입 후 반환
echo "${ARCH:?아키텍처를 지정하세요}"  # 미설정이면 에러 메시지 출력 후 종료
echo "${VERBOSE:+--verbose}"        # 설정되어 있으면 --verbose, 아니면 빈 문자열

배열과 연관 배열

Bash는 인덱스 배열(Indexed Array)과 연관 배열(Associative Array, Bash 4.0+)을 지원합니다. 배열은 커널 빌드 스크립트에서 다중 아키텍처, 설정 파일 목록, 테스트 케이스 관리 등에 유용합니다. POSIX sh에는 배열이 없으므로 이식성이 필요한 스크립트에서는 사용에 주의해야 합니다.

# 인덱스 배열
ARCHES=("x86" "arm64" "riscv" "mips")
echo "첫 번째: ${ARCHES[0]}"          # x86
echo "전체: ${ARCHES[@]}"              # x86 arm64 riscv mips
echo "개수: ${#ARCHES[@]}"             # 4
ARCHES+=("s390")                        # 요소 추가
unset 'ARCHES[2]'                      # riscv 제거 (인덱스 유지, 빈 슬롯)

# 배열 순회
for arch in "${ARCHES[@]}"; do
    echo "빌드 중: ARCH=${arch}"
    make ARCH="${arch}" defconfig
done

# 연관 배열 (Bash 4.0+)
declare -A CROSS_COMPILERS
CROSS_COMPILERS[arm64]="aarch64-linux-gnu-"
CROSS_COMPILERS[riscv]="riscv64-linux-gnu-"
CROSS_COMPILERS[mips]="mips-linux-gnu-"

for arch in "${!CROSS_COMPILERS[@]}"; do
    echo "${arch}: ${CROSS_COMPILERS[$arch]}"
done

# mapfile/readarray (Bash 4.0+): 파일을 배열로 읽기
mapfile -t modules < <(lsmod | tail -n+2 | awk '{print $1}')
echo "로드된 모듈 수: ${#modules[@]}"

# 배열 슬라이싱 (인덱스 기반 부분 추출)
echo "처음 2개: ${ARCHES[@]:0:2}"   # x86 arm64
echo "2번부터: ${ARCHES[@]:2}"      # riscv mips

# 배열 검색 패턴
contains_element() {
    local target="$1"; shift
    for item in "$@"; do
        [[ "$item" == "$target" ]] && return 0
    done
    return 1
}
if contains_element "arm64" "${ARCHES[@]}"; then
    echo "arm64 빌드 포함"
fi

배열 성능 특성: Bash 배열은 C의 배열과 달리 희소 배열(Sparse Array)입니다. unset 'arr[5]'로 요소를 삭제해도 인덱스가 재정렬되지 않고 빈 슬롯이 남습니다. 연관 배열은 해시 테이블 기반이므로 키 검색은 O(1)이지만, 수천 개 이상의 요소에서는 외부 도구(awk, sqlite3)가 더 효율적입니다. 대용량 데이터를 배열에 로드해야 한다면 mapfile을 사용하면 while read 루프보다 10배 이상 빠릅니다.

제어 흐름

Bash의 제어 흐름(Control Flow) 구문은 C 언어와 문법적으로 다르지만 기능적으로 대등합니다. 조건문, 반복문, 패턴 매칭 등 프로그래밍의 기본 제어 구조를 모두 제공하며, 특히 case문의 글로빙 패턴 매칭은 셸 스크립팅의 강력한 도구입니다.

조건문

Bash 조건문의 핵심은 명령의 종료 상태입니다. if문은 조건 자리에 오는 명령의 종료 상태가 0(성공)이면 참, 그 외이면 거짓으로 판단합니다. test, [, [[는 조건 평가를 수행하는 명령으로 각각 특성이 다릅니다.

특성test / [[[
종류빌트인 명령 (외부 /usr/bin/[도 존재)Bash 키워드 (셸 문법의 일부)
POSIX 호환아니오 (Bash/zsh/ksh 전용)
단어 분리발생 — 변수 반드시 큰따옴표없음 — [[ $var == ... ]] 안전
글로빙(Glob)발생 가능패턴 매칭으로 사용 가능 (==)
정규식미지원=~ 연산자로 ERE 매칭
논리 연산자-a, -o&&, ||
문자열 비교=, !===, !=, <, > (로캘 순서)
빈 변수 처리[ $var = x ] → 에러 (인용 필수)[[ $var == x ]] → 안전
# test / [ — POSIX 호환, 외부 명령 또는 빌트인
if [ "$ARCH" = "x86" ]; then
    echo "x86 아키텍처"
fi

# [[ — Bash 전용 키워드, 단어 분리 없음, 정규식 지원
if [[ "$KERNEL_VERSION" =~ ^6\.[0-9]+\.[0-9]+$ ]]; then
    echo "커널 6.x 시리즈"
fi

# [[ 의 패턴 매칭 (glob)
if [[ "$CONFIG_FILE" == *.config ]]; then
    echo "설정 파일"
fi

# case 문: 다중 패턴 매칭 (커널 스크립트에서 매우 자주 사용)
case "$ARCH" in
    x86|x86_64)
        SRCARCH="x86"
        ;;
    arm|arm64|aarch64)
        SRCARCH="arm64"
        ;;
    riscv*)
        SRCARCH="riscv"
        ;;
    *)
        die "지원하지 않는 아키텍처: $ARCH"
        ;;
esac

반복문

Bash는 for, while, until, select 네 가지 반복문을 제공합니다. 커널 개발에서는 while read 패턴(파일/명령 출력을 줄 단위로 처리)과 C 스타일 for(()) 루프가 특히 유용합니다.

# for-in: 리스트 순회
for config in arch/*/configs/*_defconfig; do
    echo "설정: ${config}"
done

# C 스타일 for: 산술 반복
for ((i=0; i<$(nproc); i++)); do
    echo "CPU ${i}: $(cat /sys/devices/system/cpu/cpu${i}/cpufreq/scaling_governor 2>/dev/null || echo N/A)"
done

# while read: 줄 단위 처리 (가장 실용적인 패턴)
while IFS= read -r line; do
    module="$(echo "$line" | awk '{print $1}')"
    size="$(echo "$line" | awk '{print $2}')"
    echo "모듈: ${module}, 크기: ${size}"
done < <(lsmod | tail -n+2)

# until: 조건이 참이 될 때까지 반복
until ping -c 1 -W 1 "$BUILD_SERVER" >/dev/null 2>&1; do
    echo "빌드 서버 대기 중..."
    sleep 5
done

# select: 메뉴 선택 (대화형 스크립트)
echo "빌드할 아키텍처를 선택하세요:"
select arch in x86_64 arm64 riscv "전체 빌드" 종료; do
    case "$arch" in
        종료) break ;;
        *) echo "선택: $arch"; break ;;
    esac
done

테스트 연산자 레퍼런스

다음 표는 test/[/[[에서 사용하는 주요 연산자를 정리합니다. 커널 스크립트에서는 파일 테스트와 문자열 비교가 가장 빈번하게 사용됩니다.

분류연산자의미
파일-e file파일 존재 여부
-f file일반 파일인지
-d dir디렉토리인지
-L file심볼릭 링크인지
-r file읽기 가능한지
-w file쓰기 가능한지
-x file실행 가능한지
-s file크기가 0보다 큰지
문자열-z str문자열 길이가 0인지
-n str문자열 길이가 0이 아닌지
str1 = str2문자열이 같은지 (==도 가능)
str1 != str2문자열이 다른지
산술n1 -eq n2같음 (equal)
n1 -lt n2작음 (less than)
n1 -gt n2큼 (greater than)
논리! expr논리 부정
expr1 -a expr2논리 AND ([[에서는 &&)
expr1 -o expr2논리 OR ([[에서는 ||)
산술 추가n1 -ne n2같지 않음 (not equal)
n1 -le n2작거나 같음 (less or equal)
n1 -ge n2크거나 같음 (greater or equal)
파일 비교f1 -nt f2f1이 f2보다 새로운지 (newer than)
f1 -ot f2f1이 f2보다 오래된지 (older than)
f1 -ef f2같은 inode를 가리키는지
# 파일 테스트 실전 예제
KCONFIG=".config"
VMLINUX="vmlinux"
INITRD="/boot/initramfs.img"

# -f: 일반 파일 존재 확인
if [ ! -f "$KCONFIG" ]; then
    echo ".config가 없습니다. 먼저 make defconfig를 실행하세요."
    exit 1
fi

# -d: 디렉토리 존재 확인
if [ -d "tools/testing/selftests" ]; then
    echo "kselftest 디렉토리 발견"
fi

# -x: 실행 가능 확인 (크로스 컴파일러 존재 검증)
CROSS="aarch64-linux-gnu-gcc"
if ! [ -x "$(command -v "$CROSS")" ]; then
    die "크로스 컴파일러를 찾을 수 없음: $CROSS"
fi

# -s: 파일 크기가 0이 아닌지 (빌드 결과물 확인)
if [ -s "$VMLINUX" ]; then
    echo "vmlinux 크기: $(stat -c%s "$VMLINUX") 바이트"
fi

# -nt: 파일 날짜 비교 (재빌드 필요 여부)
if [ "$KCONFIG" -nt "$VMLINUX" ]; then
    echo ".config가 vmlinux보다 새로움 → 재빌드 필요"
    make -j$(nproc)
fi

# 산술 비교 (커널 버전 확인)
KVER_MAJOR="$(uname -r | cut -d. -f1)"
if [ "$KVER_MAJOR" -ge 6 ]; then
    echo "커널 6.x 이상 — EEVDF 스케줄러 사용 가능"
fi

# 문자열 테스트 (환경 변수 검증)
if [ -z "$ARCH" ]; then
    ARCH="$(uname -m)"
    echo "ARCH 미설정 → 호스트 아키텍처 사용: $ARCH"
fi

함수

Bash 함수는 코드를 재사용 가능한 블록으로 구조화하는 핵심 도구입니다. C 함수와 달리 매개변수 타입이나 반환 타입을 선언하지 않으며, 인자는 위치 매개변수($1, $2, ...)로 접근합니다. 함수는 현재 셸 컨텍스트에서 실행되므로, local로 선언하지 않은 변수는 전역에 영향을 미칩니다.

정의와 호출

Bash 함수 정의에는 두 가지 문법이 있습니다. function 키워드를 사용하는 형태와 POSIX 호환 형태입니다. 커널 소스 트리의 스크립트는 대부분 POSIX 호환 형태를 사용합니다.

# POSIX 호환 문법 (권장)
my_function() {
    local arg1="$1"
    local arg2="$2"
    echo "인자: $arg1, $arg2"
    return 0  # 종료 상태 (0-255)
}

# Bash 전용 문법
function my_function2 {
    echo "Bash 전용"
}

# 함수 호출
my_function "hello" "world"
result=$?  # 종료 상태 캡처

# 함수에서 값 반환 (return은 종료 상태만 가능)
# 방법 1: 명령 치환으로 stdout 캡처
get_kernel_version() {
    make -s kernelversion
}
ver="$(get_kernel_version)"

# 방법 2: nameref (Bash 4.3+)
get_arch_info() {
    local -n result_ref="$1"
    result_ref="$(uname -m)"
}
get_arch_info my_arch
echo "$my_arch"  # "x86_64" 등

# 동적 스코핑 (Dynamic Scoping) 이해
outer() {
    local x="outer"
    inner
    echo "outer: $x"  # "outer" — inner의 local이 보호
}
inner() {
    local x="inner"
    echo "inner: $x"  # "inner"
}
outer

커널 스크립트의 함수 패턴

리눅스 커널 소스 트리의 셸 스크립트에서 반복적으로 나타나는 함수 패턴이 있습니다. 이 패턴들은 오랜 기간 검증된 모범 사례(Best Practice)로, 자체 스크립트에서도 활용할 수 있습니다.

#!/bin/bash
# 커널 스크립트 공통 패턴

# 1. die() — 에러 출력 후 즉시 종료
die() {
    echo "ERROR: $*" >&2
    exit 1
}

# 2. warn() — 경고 출력 (계속 진행)
warn() {
    echo "WARNING: $*" >&2
}

# 3. verbose() — 상세 출력 (플래그 제어)
VERBOSE=0
verbose() {
    [[ "$VERBOSE" -ge 1 ]] && echo "$*"
}

# 4. run() — 명령 실행 전 출력 (dry-run 지원)
DRY_RUN=0
run() {
    verbose "실행: $*"
    [[ "$DRY_RUN" -eq 1 ]] && return 0
    "$@" || die "명령 실패: $*"
}

# 5. check_tool() — 필수 도구 존재 확인
check_tool() {
    local tool
    for tool in "$@"; do
        command -v "$tool" >/dev/null 2>&1 || die "필요한 도구 없음: $tool"
    done
}

# 6. cleanup() + trap — 정리 보장
TMPFILES=()
cleanup() {
    local f
    for f in "${TMPFILES[@]}"; do
        rm -f "$f"
    done
}
trap cleanup EXIT

# 7. usage() — 사용법 출력
usage() {
    cat <<EOF
사용법: $0 [옵션] <커널소스경로>

옵션:
  -a ARCH       대상 아키텍처 (기본: x86_64)
  -j JOBS       병렬 빌드 수 (기본: $(nproc))
  -v            상세 출력
  -n            dry-run (실제 실행 안 함)
  -h            이 도움말 출력
EOF
    exit "${1:-0}"
}

I/O 리디렉션

I/O 리디렉션(Redirection)은 Bash에서 프로세스의 표준 입출력 스트림을 파일, 파이프, 네트워크 소켓 등 다양한 대상으로 변경하는 기능입니다. 유닉스의 "모든 것은 파일이다(Everything is a file)" 철학을 직접 활용하는 메커니즘으로, 파일 디스크립터(File Descriptor, FD) 조작을 통해 구현됩니다.

기본 리디렉션

유닉스/리눅스 프로세스는 기본적으로 세 개의 파일 디스크립터를 가집니다: 표준 입력(stdin, FD 0), 표준 출력(stdout, FD 1), 표준 에러(stderr, FD 2). 리디렉션은 이 FD들의 대상을 변경합니다.

# 표준 출력 리디렉션
make defconfig > build.log           # stdout을 파일로 (덮어쓰기)
make defconfig >> build.log          # stdout을 파일로 (추가)

# 표준 에러 리디렉션
make -j$(nproc) 2> errors.log       # stderr을 파일로
make -j$(nproc) 2>> errors.log      # stderr을 파일로 (추가)

# stdout과 stderr 동시 리디렉션
make -j$(nproc) > build.log 2>&1    # 순서 중요! stderr→stdout→파일
make -j$(nproc) &> build.log         # Bash 전용 축약

# 표준 입력 리디렉션
wc -l < .config                       # 파일을 stdin으로

# /dev/null: 출력 무시 (커널 스크립트에서 매우 자주 사용)
command -v gcc >/dev/null 2>&1       # 존재 여부만 확인, 출력 무시

# exec로 FD 영구 변경 (스크립트 전체에 적용)
exec 3> debug.log                    # FD 3을 debug.log에 연결
echo "디버그 메시지" >&3              # FD 3에 쓰기
exec 3>&-                            # FD 3 닫기

# exec로 stdin 영구 변경
exec 4<&0                            # 원래 stdin을 FD 4에 백업
exec < .config                        # stdin을 .config로 변경
while read line; do
    echo "$line"
done
exec 0<&4                            # stdin 복원
exec 4<&-                            # FD 4 닫기
리디렉션 전 (기본) FD 0 (stdin) FD 1 (stdout) FD 2 (stderr) 키보드(터미널) 화면(터미널) 화면(터미널) 리디렉션 후 (cmd >out 2>&1 <in) FD 0 (stdin) FD 1 (stdout) FD 2 (stderr) in (입력 파일) out (출력 파일) out (출력 파일) FD 3+ 사용자 정의 exec N>file 또는 exec N<file
그림 5. 파일 디스크립터 테이블 — 리디렉션 전(좌)에는 FD 0/1/2 모두 터미널을 가리키고, 리디렉션 후(우)에는 각각 입력 파일, 출력 파일로 변경됨. FD 3 이상은 사용자가 자유롭게 할당 가능

Here Document와 Here String

Here Document(히어 문서)는 셸 스크립트 내에서 여러 줄의 텍스트를 명령의 표준 입력으로 전달하는 리디렉션 형태입니다. 커널 빌드 스크립트에서 설정 파일 생성, 패치 적용, init 스크립트 작성 등에 자주 사용됩니다.

# 기본 Here Document
cat <<EOF
커널 버전: $(uname -r)
아키텍처: $(uname -m)
날짜: $(date)
EOF

# 변수 확장 억제 (작은따옴표로 구분자 감싸기)
cat <<'EOF'
$HOME은 확장되지 않습니다.
$(uname -r) 도 실행되지 않습니다.
EOF

# 들여쓰기 탭 제거 (<<- 사용, 탭만 제거)
if true; then
    cat <<-EOF
	이 줄의 선행 탭은 제거됩니다.
	코드 가독성을 위해 들여쓰기할 수 있습니다.
	EOF
fi

# Here String (Bash 전용): 한 줄 입력
read -r major minor patch <<< "$(uname -r | tr '.-' ' ')"
echo "메이저: $major, 마이너: $minor, 패치: $patch"

# 실전: Kconfig 프래그먼트 생성
cat > /tmp/debug.config <<EOF
CONFIG_DEBUG_INFO=y
CONFIG_DEBUG_INFO_DWARF5=y
CONFIG_GDB_SCRIPTS=y
CONFIG_KGDB=y
CONFIG_KGDB_SERIAL_CONSOLE=y
EOF
scripts/kconfig/merge_config.sh .config /tmp/debug.config

Here Document의 구분자(Delimiter) 처리 규칙 정리:

구문변수 확장명령 치환선행 탭 제거사용 상황
<<EOF아니오동적 콘텐츠 생성 (설정 파일, 스크립트)
<<'EOF'아니오아니오아니오리터럴 텍스트 (스크립트 내 스크립트, 정규식)
<<-EOF예 (탭만)들여쓰기된 코드 블록 내에서 가독성
<<-'EOF'아니오아니오예 (탭만)들여쓰기 + 리터럴
# 실전: 원격 서버에서 실행할 스크립트를 heredoc으로 생성
# 구분자를 따옴표로 감싸서 로컬 변수 확장 방지
ssh build-server 'bash -s' <<'REMOTE_SCRIPT'
#!/bin/bash
cd /home/builder/linux
git pull
make -j$(nproc)         # $(nproc)은 원격 서버에서 확장됨
echo "빌드 완료: $(date)"
REMOTE_SCRIPT

# heredoc을 변수에 저장
read -r -d '' USAGE <<-'HELP' || true
	사용법: $0 [옵션] <커널 소스 경로>
	옵션:
	  -a ARCH     타겟 아키텍처 (기본: x86_64)
	  -j JOBS     병렬 빌드 수 (기본: nproc)
	  -c CONFIG   커널 설정 파일
	  -h          이 도움말 표시
HELP

프로세스 치환

프로세스 치환(Process Substitution)은 명령의 출력을 마치 파일인 것처럼 사용할 수 있게 하는 Bash 전용 기능입니다. <(cmd)는 cmd의 출력을 읽을 수 있는 파일 경로로 치환하고, >(cmd)는 cmd의 입력으로 쓸 수 있는 파일 경로로 치환합니다. 내부적으로 /dev/fd/N 파일 디스크립터를 사용합니다.

# 두 커널 설정 비교 (임시 파일 없이)
diff <(grep "^CONFIG_" old.config | sort) \
     <(grep "^CONFIG_" new.config | sort)

# 여러 소스에서 동시에 읽기
paste <(cut -d: -f1 /etc/passwd) <(cut -d: -f7 /etc/passwd)

# tee와 프로세스 치환: 출력을 여러 곳으로 분기
make -j$(nproc) 2>&1 | tee >(grep -i error > errors.log) \
                           >(grep -i warning > warnings.log) \
                           > build.log

# while read에서 서브셸 문제 회피
while IFS= read -r line; do
    # 이 루프는 현재 셸에서 실행됨
    process "$line"
done < <(find /sys/class/net -maxdepth 1 -type l)

프로세스 모델과 잡 제어

Bash의 프로세스 모델을 이해하는 것은 셸 스크립팅의 고급 주제이자 커널 개발자에게는 기본 소양입니다. Bash가 명령을 실행하는 방식은 리눅스 커널의 프로세스 관리 메커니즘과 직접 연결됩니다. fork(), exec(), wait() 시스템 호출의 동작을 이해하면 파이프라인, 백그라운드 실행, 잡 제어의 내부 메커니즘이 명확해집니다.

프로세스 생성

Bash에서 외부 명령을 실행하면 다음 과정이 발생합니다: (1) fork()로 자식 프로세스 생성 — 부모의 메모리를 COW(Copy-On-Write)로 복제, (2) 자식에서 리디렉션 설정 — dup2()로 FD 재배치, (3) 자식에서 execve()로 프로그램 로드 — 기존 메모리 이미지를 새 프로그램으로 교체, (4) 부모(Bash)가 waitpid()로 자식 종료 대기 — 종료 상태 수집. 파이프라인에서는 각 단계가 별도 프로세스로 생성되며, pipe() 시스템 호출로 연결됩니다.

빌트인 명령(예: cd, echo, read)은 fork 없이 현재 셸 프로세스에서 직접 실행되므로 프로세스 생성 비용이 없습니다. 이것이 성능 최적화에서 빌트인을 선호하는 이유입니다. 함수도 마찬가지로 현재 셸에서 실행되며, exec 명령은 fork 없이 현재 프로세스를 새 프로그램으로 교체합니다.

# 단일 명령: fork → exec → wait
ls -la /proc/self

# 파이프라인: 각 단계가 별도 프로세스
cat /proc/cpuinfo | grep "model name" | sort -u | wc -l
# 4개의 프로세스 생성: cat, grep, sort, wc

# 서브셸: fork만 (exec 없음)
(cd /tmp && pwd)  # 서브셸에서 cd → 부모에 영향 없음
pwd                 # 원래 디렉토리 유지

# 명령 치환: 서브셸 생성
result="$(expensive_command)"  # fork → 서브셸에서 실행 → stdout 캡처

# 백그라운드 실행: fork 후 wait하지 않음
make -j$(nproc) &
BUILD_PID=$!
echo "빌드 PID: $BUILD_PID"
wait "$BUILD_PID"
echo "빌드 종료 상태: $?"

# 프로세스 그룹: 파이프라인 전체를 하나의 그룹으로 관리
echo "현재 프로세스 그룹: $(ps -o pgid= -p $$)"

잡 제어

잡 제어(Job Control)는 Bash가 제공하는 프로세스 그룹 기반의 작업 관리 시스템입니다. 대화형(Interactive) 셸에서 기본으로 활성화되며, 세션(Session), 프로세스 그룹(Process Group), 포그라운드(Foreground)/백그라운드(Background) 개념을 사용합니다. 터미널은 하나의 세션과 연결되며, 세션 내에서 하나의 프로세스 그룹만이 포그라운드가 됩니다.

세션 (Session) — SID: 1234 제어 터미널: /dev/pts/0 포그라운드 프로세스 그룹 (PGID: 1234) bash (1234) 세션 리더 grep (1240) sort (1241) wc (1242) 파이프라인 = 하나의 잡 백그라운드 잡 [1] PGID: 1250 make (1250) 백그라운드 잡 [2] PGID: 1260 qemu (1260) Ctrl+Z → SIGTSTP → 잡 일시 정지 fg %N → 포그라운드 전환 | bg %N → 백그라운드 재개
그림 6. 세션·프로세스 그룹 계층 구조 — 하나의 세션 안에 포그라운드 그룹(파이프라인)과 다수의 백그라운드 잡이 공존. Ctrl+Z, fg, bg로 그룹 단위로 제어
# 잡 제어 명령어
make -j$(nproc) &         # 백그라운드 실행, 잡 번호 부여 [1]
jobs                        # 현재 잡 목록
fg %1                      # 잡 1을 포그라운드로
# Ctrl+Z                    # SIGTSTP 전송 → 일시 정지
bg %1                      # 잡 1을 백그라운드에서 재개
wait %1                    # 잡 1 종료 대기
disown %1                  # 잡 1을 잡 테이블에서 제거 (로그아웃 후에도 계속 실행)

# wait를 이용한 병렬 빌드 패턴
for arch in x86_64 arm64 riscv; do
    (
        make ARCH="$arch" defconfig
        make ARCH="$arch" -j$(nproc)
    ) &
done
wait  # 모든 백그라운드 잡 완료 대기
echo "전체 빌드 완료"

종료 상태와 에러 처리

모든 명령은 0(성공)부터 255까지의 종료 상태(Exit Status)를 반환합니다. 이 값은 $?로 참조할 수 있으며, 셸 스크립트의 에러 처리 메커니즘의 기반입니다. 시그널에 의한 종료는 128+시그널번호가 됩니다(예: SIGKILL=137).

#!/bin/bash
# 엄격 모드 (Strict Mode) — 커널 스크립트 권장 패턴
set -e          # 명령 실패 시 스크립트 즉시 종료 (errexit)
set -u          # 미정의 변수 참조 시 에러 (nounset)
set -o pipefail # 파이프라인 중 하나라도 실패하면 전체 실패
# 축약: set -euo pipefail

# set -e의 함정: 이 경우에는 동작하지 않음
if ! make -j$(nproc); then   # if 조건에서는 set -e 비활성
    echo "빌드 실패"
fi

# pipefail 효과
set -o pipefail
make 2>&1 | tee build.log
echo "파이프라인 종료 상태: $?"   # make가 실패하면 0이 아닌 값
echo "개별 상태: ${PIPESTATUS[@]}" # 예: "2 0" (make=2, tee=0)

# trap ERR: 에러 발생 시 핸들러 실행
on_error() {
    echo "오류 발생: 줄 $1, 명령: $2, 종료코드: $3" >&2
}
trap 'on_error $LINENO "$BASH_COMMAND" $?' ERR

set -e에는 여러 함정이 있어 동작 규칙을 정확히 이해해야 합니다. 다음 상황에서는 set -e가 스크립트를 종료시키지 않습니다:

상황이유예시
if/while/until 조건조건부 실행에서는 실패가 정상 흐름if ! grep -q pattern file; then ...
&& / || 체인 내부논리 연산자 좌변 실패는 흐름 제어cmd1 && cmd2 — cmd1 실패 시 종료 안 함
! 부정 명령실패가 성공으로 반전됨! false → 종료 상태 0
서브셸 내부 (부모에 미전파)서브셸은 별도 프로세스(false; echo "실행됨") — 서브셸 내에서만 적용
함수 호출이 조건 위치일 때함수 전체가 조건으로 취급됨if my_func; then ...
# set -e 함정 시연
set -e

# 함정 1: 함수 내부의 에러가 무시됨
my_check() {
    false          # 이 실패로 종료되어야 하지만...
    echo "여기까지 도달"
}
if my_check; then  # if 조건 → set -e 비활성화
    echo "성공"
fi

# 함정 2: || 뒤에서는 set -e 비활성
false || echo "이것은 실행됨"  # 종료되지 않음

# 안전한 패턴: 명시적 에러 처리 조합
set -euo pipefail

# 실패해도 되는 명령은 || true로 명시
rm -f /tmp/maybe-not-exist || true

# 반환 코드를 보존하며 처리
if output="$(make -j$(nproc) 2>&1)"; then
    echo "빌드 성공"
else
    rc=$?
    echo "빌드 실패 (코드: $rc)" >&2
    echo "$output" | tail -20 >&2
    exit "$rc"
fi

시그널 처리

시그널(Signal)은 리눅스 커널이 프로세스에게 비동기적 이벤트를 알리는 메커니즘입니다. Bash는 trap 빌트인을 통해 시그널 핸들러를 등록할 수 있으며, 이는 스크립트의 안정적인 실행을 위해 필수적인 기능입니다. 임시 파일 정리, 락 파일 해제, 하위 프로세스 종료 등의 정리(Cleanup) 작업을 시그널 발생 시에도 보장하려면 적절한 트랩 설정이 필요합니다.

trap 명령

trap은 셸 스크립트에서 시그널 핸들러를 등록하는 빌트인 명령입니다. 임시 파일 정리, 락 해제, 하위 프로세스 종료 등의 정리 작업을 보장하기 위해 사용합니다. Bash는 실제 시그널뿐 아니라 EXIT(스크립트 종료), ERR(명령 실패), DEBUG(매 명령 실행 전), RETURN(함수/소스 반환) 같은 의사 시그널(Pseudo-Signal)도 지원합니다.

시그널번호기본 동작Bash trap 용도
SIGHUP1종료설정 재로드, 데몬 재시작 관례
SIGINT2종료Ctrl+C 처리, 사용자 중단 시 정리
SIGQUIT3코어 덤프디버깅용, 일반적으로 trap하지 않음
SIGTERM15종료kill 기본 시그널, 정상 종료 정리
SIGCHLD17무시자식 프로세스 종료 감지
SIGUSR1/210/12종료스크립트 간 커스텀 통신
SIGPIPE13종료파이프 끊김 처리 (쓰기 측)
EXIT의사N/A스크립트 종료 시 항상 실행 (가장 중요)
ERR의사N/A명령 실패 시 실행 (set -e와 연동)
DEBUG의사N/A매 명령 실행 전 실행 (프로파일링)
RETURN의사N/A함수/source 반환 시 실행
# trap 기본 문법
trap '명령' 시그널 [시그널...]
trap '' 시그널               # 시그널 무시
trap - 시그널                # 기본 동작 복원
trap -p                     # 현재 트랩 목록 출력

# 주요 시그널과 트랩
trap 'echo "인터럽트!"; exit 130' INT      # Ctrl+C (SIGINT=2)
trap 'cleanup; exit 143' TERM               # kill (SIGTERM=15)
trap 'cleanup' EXIT                         # 스크립트 종료 시 항상 실행
trap 'echo "자식 프로세스 종료"' CHLD        # 자식 프로세스 종료 시
trap 'reload_config' HUP                    # 설정 재로드 관례

# 실전 패턴: 안전한 임시 파일 관리
TMPDIR=""
LOCKFILE=""

cleanup() {
    local exit_code=$?
    [[ -n "$TMPDIR" ]] && rm -rf "$TMPDIR"
    [[ -n "$LOCKFILE" ]] && rm -f "$LOCKFILE"
    # 하위 프로세스 정리
    kill 0 2>/dev/null || true
    exit "$exit_code"
}
trap cleanup EXIT INT TERM

TMPDIR="$(mktemp -d)"
LOCKFILE="$(mktemp)"

시그널 처리 내부 동작

Bash는 시그널을 즉시 처리하지 않습니다. 커널이 시그널을 전달하면 Bash의 C 레벨 시그널 핸들러가 내부 플래그(pending_traps[])를 설정하고, Bash 이벤트 루프(Event Loop)의 안전한 지점에서 등록된 트랩 명령을 실행합니다. 이러한 지연 처리(Deferred Execution) 방식 때문에, 빌트인 명령(예: read, wait) 실행 중에 시그널이 도착하면 해당 빌트인이 먼저 완료(또는 중단)된 후 트랩이 실행됩니다.

SIGCHLD와 좀비 프로세스: 자식 프로세스가 종료되면 커널은 부모에게 SIGCHLD를 보냅니다. 부모가 wait()/waitpid()로 자식의 종료 상태를 수집하기 전까지 자식은 좀비(Zombie) 상태(Z)로 남습니다. Bash는 대화형 모드에서 백그라운드 잡의 종료를 자동으로 감지하여 프롬프트에 표시하지만, 비대화형 스크립트에서는 wait를 명시적으로 호출해야 좀비를 방지합니다. trap '' CHLD로 SIGCHLD를 무시하면 자식이 자동으로 수확(Reap)됩니다.

커널 시그널 전달 C 시그널 핸들러 플래그 설정만 보류 큐 pending_signals[] 안전 지점 이벤트 루프 체크 트랩 실행 사용자 명령 시그널 안전 함수만 호출 (비동기 안전 보장) 빌트인 완료 후 또는 외부 명령 wait 중
그림 7. Bash 시그널 처리 흐름 — 커널이 시그널을 전달하면 C 핸들러가 플래그만 설정하고, 이벤트 루프의 안전한 지점에서 사용자가 등록한 trap 명령을 실행

IPC (프로세스 간 통신)

셸 스크립트에서의 프로세스 간 통신(IPC, Inter-Process Communication)은 파이프, FIFO, 소켓, 공유 파일 등 다양한 메커니즘을 활용합니다. 이들은 모두 커널이 제공하는 IPC 기능을 셸 수준에서 추상화한 것입니다.

파이프와 FIFO

익명 파이프(Anonymous Pipe)는 | 연산자로 생성되며, 부모-자식 관계의 프로세스 간에만 사용할 수 있습니다. 내부적으로 커널의 pipe() 시스템 호출이 사용되며, 기본 버퍼 크기는 64KB(16페이지, /proc/sys/fs/pipe-max-size)입니다. 버퍼가 가득 차면 쓰기 측이 블록(Block)되고, 비어있으면 읽기 측이 블록됩니다. 이 특성 때문에 dmesg | grep처럼 대량 출력을 필터링할 때 메모리 사용이 일정합니다. 명명된 파이프(Named Pipe, FIFO)는 mkfifo로 파일시스템에 이름을 가진 특수 파일을 생성하므로 관계없는 프로세스 간 통신에 사용할 수 있습니다. FIFO는 열기(open) 시 읽기/쓰기 양쪽이 모두 연결될 때까지 블록되는 점에 유의해야 합니다.

# 익명 파이프: 파이프 버퍼 크기 확인
echo "파이프 버퍼 크기:"
cat /proc/sys/fs/pipe-max-size

# 명명된 파이프 (FIFO)
FIFO_PATH="/tmp/kernel_build_fifo"
mkfifo "$FIFO_PATH"

# 프로듀서 (백그라운드)
(
    echo "빌드 시작: $(date)"
    make -j$(nproc) 2>&1
    echo "빌드 완료: $(date)"
) > "$FIFO_PATH" &

# 컨슈머
while IFS= read -r line; do
    echo "[모니터] $line"
done < "$FIFO_PATH"

rm -f "$FIFO_PATH"

코프로세스

코프로세스(Coprocess)는 Bash 4.0에서 도입된 기능으로, 백그라운드 프로세스와 양방향 파이프를 자동으로 설정합니다. coproc 키워드를 사용하며, 코프로세스의 stdin/stdout에 연결된 FD가 ${COPROC[0]}(읽기)과 ${COPROC[1]}(쓰기)에 저장됩니다. 일반적으로 양방향 대화가 필요한 외부 프로세스(계산기, 데이터베이스 클라이언트, 커스텀 프로토콜)와 통신할 때 유용합니다.

# 코프로세스 기본 사용 — bc 계산기와 양방향 통신
coproc BC { bc -l; }

# 코프로세스에 계산 요청
echo "scale=4; 22/7" >&"${BC[1]}"
read -r result <&"${BC[0]}"
echo "결과: $result"   # 3.1428

# 여러 번 재사용 (프로세스 재생성 비용 없음)
echo "2^32" >&"${BC[1]}"
read -r result <&"${BC[0]}"
echo "2^32 = $result"  # 4294967296

# 코프로세스 종료 (쓰기 FD를 닫으면 EOF 전달)
exec {BC[1]}>&-
wait "$BC_PID"

# 이름 없는 코프로세스 (COPROC 배열 사용)
coproc { while read -r line; do echo "ECHO: $line"; done; }
echo "hello" >&"${COPROC[1]}"
read -r reply <&"${COPROC[0]}"
echo "$reply"  # ECHO: hello
exec {COPROC[1]}>&-
wait

코프로세스를 사용할 때 주의할 점: (1) 이름이 있는 코프로세스는 하나만 존재할 수 있으며(Bash 4.x 제한), 동일 이름으로 재생성하면 기존 것이 종료됩니다. (2) 읽기 FD에서 read 시 타임아웃(read -t 5)을 설정하지 않으면 교착 상태(Deadlock)에 빠질 수 있습니다. (3) 코프로세스가 예기치 않게 종료되면 쓰기 FD에 쓸 때 SIGPIPE가 발생합니다.

# 타임아웃과 에러 처리가 있는 안전한 코프로세스 패턴
coproc WORKER { while read -r cmd; do eval "$cmd" 2>&1 || echo "ERROR:$?"; done; }

send_to_coproc() {
    local cmd="$1" timeout="${2:-10}"
    echo "$cmd" >&"${WORKER[1]}" || { echo "코프로세스 통신 실패" >&2; return 1; }
    if ! read -r -t "$timeout" reply <&"${WORKER[0]}"; then
        echo "타임아웃: $timeout초 초과" >&2
        return 1
    fi
    echo "$reply"
}

send_to_coproc "uname -r"
send_to_coproc "cat /proc/version"
exec {WORKER[1]}>&-
wait "$WORKER_PID"

셸 스크립트 IPC 패턴

실전 스크립트에서는 다양한 IPC 패턴을 조합하여 사용합니다. 파일 락(flock), TCP/UDP 소켓(/dev/tcp), 공유 파일 등을 활용한 대표적인 패턴을 살펴보겠습니다.

# 1. flock: 파일 락을 이용한 동시성 제어
(
    flock -n 200 || die "이미 실행 중입니다"
    # 임계 영역: 이 코드는 하나의 인스턴스만 실행
    make -j$(nproc)
) 200>/var/lock/kernel-build.lock

# 2. /dev/tcp: 네트워크 통신 (Bash 전용)
exec 3<>/dev/tcp/build-server/8080
echo "GET /status HTTP/1.0" >&3
echo "" >&3
cat <&3
exec 3>&-

# 3. 공유 파일을 이용한 워커 패턴
WORK_DIR="$(mktemp -d)"
NUM_WORKERS=4

for ((i=0; i<NUM_WORKERS; i++)); do
    (
        while read -r task; do
            echo "워커 $i: $task 처리 중"
            eval "$task"
        done < "${WORK_DIR}/tasks"
    ) &
done
익명 파이프 (|) 프로세스 A 커널 버퍼 프로세스 B 단방향, 부모-자식 관계만 명명된 파이프 (FIFO) 프로세스 A /tmp/fifo 프로세스 C 단방향, 무관계 프로세스 가능 소켓 (/dev/tcp) 클라이언트 TCP/UDP 서버 양방향, 네트워크 가능 공유 파일 + flock 프로세스 A 파일 + 락 프로세스 B 양방향, flock으로 동시성 제어
그림 8. 셸 스크립트 IPC 메커니즘 비교 — 파이프(단방향/부모-자식), FIFO(단방향/무관계), 소켓(양방향/네트워크), 공유 파일+flock(양방향/동시성 제어)

/proc과 /sys 인터페이스 활용

리눅스 커널은 /proc(procfs)과 /sys(sysfs) 가상 파일시스템을 통해 커널 내부 상태를 사용자 공간에 노출합니다. 이 인터페이스들은 일반 파일처럼 읽고 쓸 수 있으므로 Bash 스크립트로 커널 상태 모니터링과 튜닝을 쉽게 수행할 수 있습니다. 커널 개발자에게 이 인터페이스를 셸로 다루는 능력은 디버깅과 성능 분석의 기본입니다.

/proc 파일시스템 스크립팅

/proc은 커널과 프로세스 정보를 제공하는 가상 파일시스템입니다. /proc/[pid]/는 개별 프로세스 정보를, /proc/sys/sysctl로도 접근 가능한 커널 튜너블(Tunable) 매개변수를 제공합니다.

#!/bin/bash
# CPU 정보 요약
cpu_model() {
    grep "model name" /proc/cpuinfo | head -1 | cut -d: -f2 | sed 's/^ //'
}
cpu_count() {
    grep -c "^processor" /proc/cpuinfo
}
echo "CPU: $(cpu_model) x$(cpu_count)"

# 메모리 정보 파싱
mem_total_kb="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)"
mem_avail_kb="$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)"
mem_used_pct=$(( (mem_total_kb - mem_avail_kb) * 100 / mem_total_kb ))
echo "메모리: ${mem_used_pct}% 사용 중 ($(( mem_total_kb / 1024 ))MB 중 $(( (mem_total_kb - mem_avail_kb) / 1024 ))MB)"

# 프로세스 정보 조회
show_proc_info() {
    local pid="$1"
    [[ -d "/proc/$pid" ]] || return 1
    echo "PID: $pid"
    echo "  실행파일: $(readlink -f /proc/$pid/exe 2>/dev/null)"
    echo "  명령행: $(tr '\0' ' ' < /proc/$pid/cmdline)"
    echo "  상태: $(awk '/^State:/ {print $2, $3}' /proc/$pid/status)"
    echo "  RSS: $(awk '/^VmRSS:/ {print $2, $3}' /proc/$pid/status)"
    echo "  스레드: $(awk '/^Threads:/ {print $2}' /proc/$pid/status)"
}

# sysctl 매개변수 조회/변경
echo "vm.swappiness = $(cat /proc/sys/vm/swappiness)"
echo "kernel.pid_max = $(cat /proc/sys/kernel/pid_max)"
echo "net.core.somaxconn = $(cat /proc/sys/net/core/somaxconn)"

sysfs 스크립팅

/sys(sysfs)는 디바이스 모델, 드라이버, 버스 정보를 계층적으로 노출하는 가상 파일시스템입니다. /sys/class/는 디바이스를 기능별로 분류하고, /sys/devices/는 물리적 토폴로지를 반영하며, /sys/module/은 로드된 커널 모듈의 매개변수를 노출합니다.

# 네트워크 인터페이스 정보
for iface in /sys/class/net/*; do
    name="$(basename "$iface")"
    [[ "$name" == "lo" ]] && continue
    state="$(cat "$iface/operstate" 2>/dev/null)"
    speed="$(cat "$iface/speed" 2>/dev/null || echo "N/A")"
    mac="$(cat "$iface/address" 2>/dev/null)"
    echo "${name}: 상태=${state}, 속도=${speed}Mbps, MAC=${mac}"
done

# 블록 디바이스 정보
for dev in /sys/block/sd* /sys/block/nvme*; do
    [[ -e "$dev" ]] || continue
    name="$(basename "$dev")"
    size_sectors="$(cat "$dev/size")"
    size_gb=$(( size_sectors * 512 / 1024 / 1024 / 1024 ))
    sched="$(cat "$dev/queue/scheduler" 2>/dev/null)"
    echo "${name}: ${size_gb}GB, 스케줄러=${sched}"
done

# 모듈 매개변수 조회
if [[ -d /sys/module/e1000e/parameters ]]; then
    echo "e1000e 모듈 매개변수:"
    for param in /sys/module/e1000e/parameters/*; do
        echo "  $(basename "$param") = $(cat "$param" 2>/dev/null)"
    done
fi
/proc /proc/[pid]/ /proc/sys/ /proc/meminfo status cmdline maps fd/ exe cgroup kernel/ vm/ net/ fs/ debug/ /sys /sys/class/ /sys/devices/ /sys/module/ net/ block/ thermal/ gpio/ input/ system/ pci0000:00/ platform/ /proc 활용 예 cat /proc/cpuinfo # CPU 정보 cat /proc/meminfo # 메모리 정보 cat /proc/$$/status # 현재 셸 상태 cat /proc/sys/vm/swappiness echo 60 > /proc/sys/vm/swappiness cat /proc/modules # 로드된 모듈 /sys 활용 예 cat /sys/class/net/eth0/speed cat /sys/block/sda/queue/scheduler cat /sys/devices/system/cpu/cpu0/ cpufreq/scaling_governor cat /sys/module/e1000e/parameters/ cat /sys/kernel/mm/transparent_hugepage/
그림 9. /proc과 /sys 주요 경로 구조 — /proc은 프로세스 정보와 커널 매개변수를, /sys는 디바이스·드라이버·모듈 정보를 계층적으로 노출

커널 튜닝 스크립트 예제

다음은 Bash를 사용하여 커널 매개변수를 모니터링하고 튜닝하는 실전 스크립트 패턴입니다. 서버 성능 최적화, 네트워크 튜닝, 메모리 관리 등에 활용할 수 있습니다.

#!/bin/bash
# 커널 튜닝 스크립트 예제
set -euo pipefail

# CPU 거버너 일괄 변경
set_cpu_governor() {
    local governor="$1"
    local cpu
    for cpu in /sys/devices/system/cpu/cpu[0-9]*; do
        local gov_file="${cpu}/cpufreq/scaling_governor"
        [[ -w "$gov_file" ]] || continue
        echo "$governor" > "$gov_file"
    done
    echo "CPU 거버너: $governor 설정 완료"
}

# 네트워크 성능 튜닝
tune_network() {
    sysctl -w net.core.somaxconn=4096
    sysctl -w net.core.netdev_max_backlog=5000
    sysctl -w net.ipv4.tcp_max_syn_backlog=8192
    sysctl -w net.ipv4.tcp_fastopen=3
    sysctl -w net.core.rmem_max=16777216
    sysctl -w net.core.wmem_max=16777216
}

# 투명 대규모 페이지(THP) 설정
configure_thp() {
    local mode="$1"  # always, madvise, never
    echo "$mode" > /sys/kernel/mm/transparent_hugepage/enabled
    echo "$mode" > /sys/kernel/mm/transparent_hugepage/defrag
    echo "THP: $mode 설정 완료"
}

# 시스템 상태 요약 출력
system_summary() {
    echo "=== 시스템 상태 ==="
    echo "커널: $(uname -r)"
    echo "가동시간: $(uptime -p)"
    echo "로드: $(cat /proc/loadavg | cut -d' ' -f1-3)"
    echo "메모리: $(free -h | awk '/^Mem:/ {print $3 "/" $2}')"
    echo "스왑: $(free -h | awk '/^Swap:/ {print $3 "/" $2}')"
    echo "CPU 거버너: $(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null || echo N/A)"
    echo "THP: $(cat /sys/kernel/mm/transparent_hugepage/enabled 2>/dev/null || echo N/A)"
}

커널 빌드 & 개발 스크립팅

리눅스 커널 소스 트리는 수백 개의 셸 스크립트를 포함하고 있으며, 빌드 시스템(Kbuild)의 핵심 구성 요소로 작동합니다. scripts/ 디렉토리의 스크립트들은 설정 관리, 버전 생성, 헤더 설치, 모듈 서명, 패키징 등 빌드의 거의 모든 단계에 관여합니다. 이 절에서는 커널 개발에 직접 활용할 수 있는 Bash 스크립팅 패턴을 살펴봅니다.

Kconfig 관련 스크립트

커널 설정(Configuration)은 .config 파일에 저장되며, scripts/config 스크립트로 프로그래밍 방식으로 조작할 수 있습니다. CI/CD 환경이나 자동화 빌드에서 대화형 menuconfig 대신 이 스크립트를 사용합니다.

# scripts/config — Kconfig 설정 조작 도구
./scripts/config --enable CONFIG_DEBUG_INFO
./scripts/config --disable CONFIG_DEBUG_INFO_REDUCED
./scripts/config --set-val CONFIG_LOG_BUF_SHIFT 18
./scripts/config --set-str CONFIG_LOCALVERSION "-custom"
./scripts/config --module CONFIG_EXT4_FS
./scripts/config --state CONFIG_SMP  # 현재 상태 조회

# scripts/diffconfig — 두 설정 비교
./scripts/diffconfig .config.old .config
# 출력: CONFIG_DEBUG_INFO n -> y
#       CONFIG_KASAN -y         (제거됨)
#       +CONFIG_UBSAN y          (추가됨)

# scripts/kconfig/merge_config.sh — 설정 프래그먼트 병합
# 기본 defconfig + 디버그 + 테스트 설정을 병합
make defconfig
./scripts/kconfig/merge_config.sh .config \
    kernel/configs/debug.config \
    kernel/configs/kvm_guest.config

# 자동화 스크립트: 다중 설정 빌드
for fragment in configs/*.config; do
    name="$(basename "$fragment" .config)"
    echo "=== 빌드: $name ==="
    make defconfig
    ./scripts/kconfig/merge_config.sh .config "$fragment"
    make -j"$(nproc)" 2>&1 | tee "build-${name}.log"
done
최상위 Makefile scripts/config scripts/Makefile.build scripts/setlocalversion scripts/sign-file $(shell ...) 셸 명령 gcc / clang 도구체인 컴파일 (.c → .o) 링크 (.o → vmlinux) 모듈 (.ko 생성)
그림 10. 커널 빌드 스크립트 호출 계층 — 최상위 Makefile이 scripts/ 디렉토리의 셸 스크립트를 호출하고, $(shell ...) 매크로와 도구체인을 통해 최종 바이너리를 생성

커널 테스트 자동화

커널 셀프테스트(kselftest) 프레임워크는 셸 스크립트로 테스트 실행과 결과 수집을 자동화합니다. QEMU를 활용한 가상 환경 테스트, 회귀 테스트, 성능 벤치마크 등을 Bash 스크립트로 완전히 자동화할 수 있습니다.

#!/bin/bash
# QEMU 기반 커널 테스트 자동화
set -euo pipefail

KERNEL="arch/x86/boot/bzImage"
ROOTFS="rootfs.img"
TIMEOUT=300

run_qemu_test() {
    local test_script="$1"
    local log="$2"

    timeout "$TIMEOUT" qemu-system-x86_64 \
        -kernel "$KERNEL" \
        -drive file="$ROOTFS",format=raw \
        -append "root=/dev/sda console=ttyS0 init=$test_script" \
        -nographic \
        -m 512M \
        -smp 2 \
        -no-reboot \
        > "$log" 2>&1

    if grep -q "TEST PASSED" "$log"; then
        echo "PASS: $test_script"
        return 0
    else
        echo "FAIL: $test_script (로그: $log)"
        return 1
    fi
}

# kselftest 실행
make -C tools/testing/selftests TARGETS="size timers" run_tests

테스트 결과 로그를 파싱하여 TAP(Test Anything Protocol) 형식의 결과를 추출하고, 자동으로 성공/실패를 집계하는 패턴은 CI/CD 파이프라인에서 필수적입니다.

# 테스트 결과 로그 파싱 — TAP 형식 (Test Anything Protocol)
parse_tap_results() {
    local log="$1"
    local pass=0 fail=0 skip=0

    while IFS= read -r line; do
        case "$line" in
            ok\ *)      ((pass++)) ;;
            not\ ok\ *) ((fail++)) ;;
            *SKIP*)     ((skip++)) ;;
        esac
    done < "$log"

    echo "결과: PASS=$pass FAIL=$fail SKIP=$skip"
    [[ "$fail" -eq 0 ]]
}

# QEMU 직렬 출력에서 커널 패닉/오류 자동 탐지
check_kernel_log() {
    local log="$1"
    local errors=0

    # 치명적 오류 패턴
    if grep -qE "Kernel panic|BUG:|WARNING:|Call Trace:" "$log"; then
        echo "커널 오류 탐지:" >&2
        grep -nE "Kernel panic|BUG:|WARNING:|Call Trace:" "$log" >&2
        errors=1
    fi

    # KASAN/KFENCE 메모리 오류
    if grep -qE "KASAN|KFENCE|use-after-free|out-of-bounds" "$log"; then
        echo "메모리 오류 탐지:" >&2
        grep -nE "KASAN|KFENCE" "$log" | head -5 >&2
        errors=1
    fi

    return "$errors"
}

# 회귀 테스트 자동화 — 여러 테스트 스위트 순차 실행
TESTS_DIR="tools/testing/selftests"
TARGETS=("size" "timers" "net" "bpf")
RESULTS_DIR="$(mktemp -d)"
total_fail=0

for target in "${TARGETS[@]}"; do
    echo "=== 테스트: $target ==="
    log="${RESULTS_DIR}/${target}.log"

    if make -C "$TESTS_DIR" TARGETS="$target" run_tests > "$log" 2>&1; then
        parse_tap_results "$log"
    else
        echo "FAIL: $target (종료코드: $?)"
        ((total_fail++))
    fi
done

echo "전체 결과: ${#TARGETS[@]}개 스위트 중 ${total_fail}개 실패"
[[ "$total_fail" -eq 0 ]] || exit 1

빌드 자동화

다중 아키텍처 빌드, git bisect run을 통한 회귀 추적, CI 파이프라인 통합 등 커널 빌드 자동화의 실전 패턴을 다룹니다.

#!/bin/bash
# 다중 아키텍처 빌드 스크립트
set -euo pipefail

declare -A CROSS_COMPILERS=(
    [arm64]="aarch64-linux-gnu-"
    [riscv]="riscv64-linux-gnu-"
    [mips]="mips-linux-gnu-"
)

build_arch() {
    local arch="$1"
    local cross="${CROSS_COMPILERS[$arch]:-}"
    local log="build-${arch}.log"

    echo "[$(date +%H:%M:%S)] $arch 빌드 시작..."
    make ARCH="$arch" CROSS_COMPILE="$cross" defconfig >/dev/null 2>&1
    make ARCH="$arch" CROSS_COMPILE="$cross" -j"$(nproc)" 2>&1 | tee "$log"
    echo "[$(date +%H:%M:%S)] $arch 빌드 완료"
}

# 병렬 빌드 (서브셸에서 실행)
for arch in "${!CROSS_COMPILERS[@]}"; do
    build_arch "$arch" &
done
wait
echo "전체 빌드 완료"

# git bisect run: 회귀 커밋 자동 탐색
# git bisect start HEAD v6.1
# git bisect run ./test-regression.sh

# test-regression.sh 예제:
#!/bin/bash
make -j$(nproc) || exit 125  # 빌드 실패: skip
./run-test.sh                   # 테스트 실행 (0=good, 1=bad)

모듈 개발 스크립트

커널 모듈(Out-of-tree Module) 개발 시 빌드, 설치, 테스트, DKMS 등록을 자동화하는 스크립트 패턴을 제공합니다.

#!/bin/bash
# 커널 모듈 개발 헬퍼 스크립트
set -euo pipefail

MODULE_NAME="mymodule"
KVER="$(uname -r)"
KDIR="/lib/modules/${KVER}/build"

build_module() {
    make -C "$KDIR" M="$(pwd)" modules
}

install_module() {
    sudo make -C "$KDIR" M="$(pwd)" modules_install
    sudo depmod -a
}

load_module() {
    sudo rmmod "$MODULE_NAME" 2>/dev/null || true
    sudo insmod "./${MODULE_NAME}.ko" "$@"
    dmesg | tail -5
}

test_params() {
    echo "=== 모듈 매개변수 테스트 ==="
    for val in 1 10 100 1000; do
        load_module debug_level="$val"
        echo "매개변수 값: $(cat /sys/module/${MODULE_NAME}/parameters/debug_level)"
        sleep 1
    done
}

case "${1:-build}" in
    build)   build_module ;;
    install) install_module ;;
    load)    shift; load_module "$@" ;;
    test)    test_params ;;
    clean)   make -C "$KDIR" M="$(pwd)" clean ;;
    *)       echo "사용법: $0 {build|install|load|test|clean}" ;;
esac

고급 주제

이 절에서는 정규 표현식, 디버깅, 성능 최적화, 보안 등 Bash 스크립팅의 고급 주제를 다룹니다. 커널 개발자가 대규모 자동화 스크립트를 작성할 때 직면하는 실전 문제와 해결책을 중심으로 설명합니다.

정규 표현식과 텍스트 처리

Bash 3.0부터 [[ string =~ regex ]] 구문으로 확장 정규 표현식(ERE, Extended Regular Expression)을 직접 사용할 수 있습니다. BASH_REMATCH 배열로 캡처 그룹에 접근할 수 있어, 간단한 패턴 매칭에서는 grep/sed 호출 없이 처리할 수 있습니다.

# Bash 정규식 매칭
VERSION_STRING="Linux version 6.1.42-generic"

if [[ "$VERSION_STRING" =~ ([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
    echo "전체 매칭: ${BASH_REMATCH[0]}"    # 6.1.42
    echo "메이저: ${BASH_REMATCH[1]}"       # 6
    echo "마이너: ${BASH_REMATCH[2]}"       # 1
    echo "패치: ${BASH_REMATCH[3]}"         # 42
fi

# 커널 설정 파싱
parse_kconfig() {
    local line
    while IFS= read -r line; do
        if [[ "$line" =~ ^CONFIG_([A-Z0-9_]+)=(.+)$ ]]; then
            echo "옵션: ${BASH_REMATCH[1]}, 값: ${BASH_REMATCH[2]}"
        fi
    done < .config
}

# grep/sed/awk 통합 패턴
# 커널 로그에서 에러 추출 및 분류
dmesg | grep -E "(error|fault|panic|oops)" -i | \
    sed 's/\[.*\] //' | \
    sort | uniq -c | sort -rn | head -20

# awk로 /proc/meminfo 파싱
awk '/^(MemTotal|MemFree|MemAvailable|Buffers|Cached):/ {
    printf "%-15s %8d MB\n", $1, $2/1024
}' /proc/meminfo

디버깅과 프로파일링

Bash 스크립트 디버깅에는 set -x(xtrace), PS4 커스터마이징, BASH_XTRACEFD, 그리고 외부 도구인 ShellCheck이 핵심적으로 사용됩니다. 프로파일링은 BASH_XTRACEFD와 타임스탬프를 조합하여 수행할 수 있습니다.

# set -x: 실행되는 각 명령을 stderr에 출력
set -x
make defconfig   # + make defconfig (출력됨)
set +x            # xtrace 비활성화

# PS4 커스터마이징: 파일명, 줄번호, 함수명 포함
export PS4='+${BASH_SOURCE[0]}:${LINENO}:${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
set -x
# 출력: +build.sh:42:build_kernel(): make -j8

# BASH_XTRACEFD: xtrace 출력을 별도 파일로
exec 5> debug-trace.log
export BASH_XTRACEFD=5
set -x
# 이제 xtrace는 stderr가 아닌 debug-trace.log에 기록

# 프로파일링: 타임스탬프 포함 트레이스
export PS4='+$(date +%s.%N) ${BASH_SOURCE}:${LINENO}: '
set -x
# 이후 trace 로그에서 시간 차이를 계산하여 병목 식별

# ShellCheck: 정적 분석 도구 (외부 설치 필요)
# shellcheck build-kernel.sh
# SC2086: Double quote to prevent globbing and word splitting.
# SC2155: Declare and assign separately to avoid masking return values.

# 디버그 함수 패턴
DEBUG="${DEBUG:-0}"
debug() {
    [[ "$DEBUG" -ge 1 ]] && echo "[DEBUG] $*" >&2
}
debug2() {
    [[ "$DEBUG" -ge 2 ]] && echo "[TRACE] $*" >&2
}

성능 최적화

Bash 스크립트의 성능 병목은 대부분 불필요한 서브셸/외부 프로세스 생성에서 발생합니다. fork()+exec()는 비용이 높은 연산이므로, 가능하면 빌트인 명령과 매개변수 확장을 활용하여 외부 명령 호출을 최소화해야 합니다.

안티패턴 (느림) result=$(echo "$str" | sed 's/a/b/') → fork 2회 + exec 2회 (echo, sed) len=$(echo -n "$str" | wc -c) → fork 2회 + exec 2회 + pipe cat file | grep pattern → UUOC: 불필요한 cat (fork+exec) for f in $(ls *.txt); do ... → 단어 분리 위험 + 불필요한 ls while read; do echo "$REPLY" >> out 최적 패턴 (빠름) result="${str//a/b}" → 빌트인 매개변수 확장 (fork 0회) len="${#str}" → 빌트인 길이 연산 (fork 0회) grep pattern file → grep이 직접 파일 읽기 (fork 1회) for f in *.txt; do ... → 글로빙으로 안전 + 빠름 { while read; do echo "$REPLY"; done } > out
그림 11. 안티패턴 vs 최적 패턴 — 왼쪽의 느린 패턴은 불필요한 fork/exec를 생성하고, 오른쪽의 최적 패턴은 빌트인과 글로빙으로 프로세스 생성을 최소화
# 대량 데이터 처리: mapfile로 파일을 배열로 읽기 (Bash 4.0+)
# 느린 방법:
while IFS= read -r line; do
    lines+=("$line")
done < bigfile.txt

# 빠른 방법:
mapfile -t lines < bigfile.txt

# 리디렉션 최적화: 루프 외부에서 한 번만
# 느린 방법:
for i in {1..1000}; do
    echo "$i" >> output.txt  # 매 반복 open/close
done

# 빠른 방법:
{
    for i in {1..1000}; do
        echo "$i"
    done
} > output.txt  # open 1회, close 1회

# printf vs echo: printf가 더 이식성 높고 빠른 경우 있음
printf '%s\n' "${lines[@]}" > output.txt

보안 고려사항

셸 스크립트의 보안은 입력 검증, 안전한 임시 파일 관리, PATH 강화, 그리고 위험한 구문(eval) 회피에 집중됩니다. 특히 eval은 코드 인젝션(Code Injection)의 주요 공격 벡터이므로 가능하면 사용하지 않아야 합니다.

# 1. PATH 강화: 절대 경로 또는 신뢰할 수 있는 디렉토리만
export PATH="/usr/sbin:/usr/bin:/sbin:/bin"

# 2. 안전한 임시 파일: mktemp 사용 (예측 불가능한 파일명)
tmpfile="$(mktemp)"          # /tmp/tmp.XXXXXXXXXX
tmpdir="$(mktemp -d)"         # /tmp/tmp.XXXXXXXXXX/
trap 'rm -rf "$tmpfile" "$tmpdir"' EXIT

# 3. eval 회피
# 위험한 코드:
# eval "cmd_$user_input"      # 인젝션 가능!

# 안전한 대안: 연관 배열 + 화이트리스트
declare -A COMMANDS=(
    [build]="do_build"
    [test]="do_test"
    [clean]="do_clean"
)
action="$1"
if [[ -n "${COMMANDS[$action]+exists}" ]]; then
    "${COMMANDS[$action]}"  # 화이트리스트 함수만 실행
else
    die "알 수 없는 명령: $action"
fi

# 4. 입력 검증 패턴
validate_arch() {
    local arch="$1"
    case "$arch" in
        x86|x86_64|arm|arm64|riscv|mips|s390) return 0 ;;
        *) die "유효하지 않은 아키텍처: $arch" ;;
    esac
}

# 5. IFS 보호: 공백 처리 안전하게
local old_ifs="$IFS"
IFS=$'\n'
files=( $(find . -name "*.config") )
IFS="$old_ifs"

POSIX 호환성과 셸 비교

셸 스크립트의 이식성(Portability)은 리눅스 배포판, 임베디드 시스템, CI 환경 등 다양한 실행 환경에서 동일하게 동작하기 위해 중요합니다. POSIX.1-2017(IEEE Std 1003.1-2017)의 셸 명령어 언어(Shell Command Language) 표준은 이식 가능한 스크립트의 기준을 제공합니다.

POSIX sh 표준

POSIX sh 표준은 셸이 반드시 지원해야 하는 기능의 최소 집합을 정의합니다. 이 표준을 따르는 스크립트는 Bash, dash, ash, mksh 등 모든 POSIX 호환 셸에서 동작합니다. 리눅스 커널 소스 트리의 scripts/ 디렉토리에 있는 많은 스크립트가 #!/bin/sh 셔뱅을 사용하며 POSIX 호환을 목표로 작성되어 있습니다.

POSIX sh에서 사용할 수 없는 Bash 전용 기능:

다음 표는 대표적인 Bash 전용 구문과 POSIX 호환 대체 구문을 비교합니다. 커널 소스 트리의 scripts/에서는 대부분 POSIX 호환 스타일을 사용합니다.

기능Bash 전용POSIX 대체
조건 키워드[[ $var == pattern* ]]case "$var" in pattern*) ... ;; esac
배열arr=(a b c); echo ${arr[1]}set -- a b c; eval echo \$$2 또는 반복
산술 명령(( count++ ))count=$((count + 1))
소문자 변환${var,,}echo "$var" | tr '[:upper:]' '[:lower:]'
프로세스 치환diff <(cmd1) <(cmd2)임시 파일: cmd1 > /tmp/a; cmd2 > /tmp/b; diff /tmp/a /tmp/b
정규식 매칭[[ $s =~ regex ]]echo "$s" | grep -qE 'regex'
리디렉션 축약&> file> file 2>&1
중괄호 확장cp file{,.bak}cp file file.bak
소스 명령source file. file
로컬 변수local var="val"변수명에 접두사 사용 또는 서브셸 격리

셸 비교

다음 표는 주요 셸의 특성을 비교합니다. 커널 빌드 환경에서의 적합성, 임베디드 시스템에서의 용량, 대화형 사용 편의성 등을 기준으로 정리했습니다.

POSIX 호환바이너리 크기배열정규식주요 용도
bash예 (확장 포함)~1.1MB인덱스+연관=~범용 대화형/스크립팅
dash예 (엄격)~120KB없음없음시스템 셸 (/bin/sh)
zsh호환 모드~800KB고급 배열=~대화형 사용, 플러그인
ash (BusyBox)대부분~60KB없음없음임베디드, initramfs
mksh~250KB인덱스없음Android 시스템 셸
ksh93예 (확장)~1.5MB인덱스+연관=~상용 유닉스, 레거시
zsh 플러그인, 글로빙 확장, 프롬프트 테마 bash [[ ]], 배열, =~, 프로세스 치환 dash / POSIX sh [ ], 변수, 함수, 파이프, 리디렉션 BusyBox ash 최소 POSIX sh 기능 (임베디드용) ~60KB < ~120KB < ~1.1MB < ~800KB
그림 12. 셸 기능 집합 포함 관계 — POSIX sh(dash) ⊂ bash ⊂ zsh 순으로 기능이 확장되며, BusyBox ash는 최소 POSIX 셸. 바이너리 크기도 기능 범위에 비례

이식성 가이드라인

커널 빌드 스크립트를 작성할 때는 #!/bin/sh를 기본으로 하되, Bash 전용 기능이 반드시 필요한 경우에만 #!/bin/bash를 사용합니다. checkbashisms 도구로 POSIX 비호환 구문을 자동으로 탐지할 수 있습니다.

# checkbashisms 설치 및 사용
sudo apt install devscripts     # Debian/Ubuntu
checkbashisms my-script.sh

# 이식성 높은 대체 패턴
# Bash: [[ $var == pattern* ]]
# POSIX: case "$var" in pattern*) ... ;; esac

# Bash: local var="value"
# POSIX: var="value"  (함수 내 전역, 주의 필요)

# Bash: echo "text" | read var
# POSIX: var=$(echo "text")

# Bash: (( count++ ))
# POSIX: count=$((count + 1))

# Bash: ${var,,}  (소문자 변환)
# POSIX: echo "$var" | tr '[:upper:]' '[:lower:]'

# Bash: source file
# POSIX: . file  (점 명령어)

빠른 레퍼런스

이 절은 Bash 개발 중 빠르게 참조할 수 있는 빌트인 명령, 셸 옵션, 특수 변수, 커널 개발 패턴 치트시트를 제공합니다.

빌트인 명령 레퍼런스

명령설명예시
cd작업 디렉토리 변경cd /usr/src/linux
echo문자열 출력echo "빌드 시작"
printf서식화 출력printf '%-20s %s\n' "$name" "$val"
read표준 입력에서 읽기read -r -p "계속? [y/n] " ans
export환경 변수 설정export ARCH=arm64
declare변수 속성 설정declare -A map
local지역 변수 선언local result="$1"
test / [조건 평가[ -f .config ] && echo "있음"
trap시그널 핸들러 등록trap cleanup EXIT
wait백그라운드 잡 대기wait $PID
execFD 조작 또는 프로세스 교체exec 3>log.txt
source / .파일을 현재 셸에서 실행source ./env.sh
set셸 옵션 설정set -euo pipefail
shopt셸 선택 옵션 설정shopt -s globstar
getopts옵션 파싱while getopts "a:j:vh" opt; do
hash명령 경로 캐시 관리hash -r (캐시 초기화)
type명령 종류 확인type -a echo
command빌트인 우회 또는 존재 확인command -v gcc
enable빌트인 활성/비활성enable -a (목록 출력)
mapfilestdin을 배열로 읽기mapfile -t arr < file

set과 shopt 옵션

옵션명령효과
-e (errexit)set -e명령 실패 시 스크립트 즉시 종료
-u (nounset)set -u미정의 변수 참조 시 에러
-o pipefailset -o pipefail파이프라인 중 하나라도 실패하면 전체 실패
-x (xtrace)set -x실행 명령을 stderr에 출력 (디버깅)
-f (noglob)set -f경로명 확장(글로빙) 비활성화
-n (noexec)set -n명령 실행 없이 구문만 검사
nullglobshopt -s nullglob매칭 없는 글로브 패턴을 빈 문자열로
globstarshopt -s globstar** 패턴으로 재귀 디렉토리 매칭
lastpipeshopt -s lastpipe파이프라인 마지막 명령을 현재 셸에서 실행
extglobshopt -s extglob확장 글로빙: @(), *(), +(), ?(), !()
dotglobshopt -s dotglob글로빙에 숨김 파일(.) 포함
failglobshopt -s failglob매칭 없는 글로브 패턴 시 에러

특수 변수 레퍼런스

변수설명
$?마지막 명령의 종료 상태
$$현재 셸의 PID
$!마지막 백그라운드 프로세스의 PID
$#위치 매개변수 개수
$@모든 위치 매개변수 (개별 단어)
$*모든 위치 매개변수 (IFS로 결합된 단일 문자열)
$0스크립트 이름 또는 셸 이름
$_이전 명령의 마지막 인자
$-현재 셸 옵션 플래그
$BASHPID현재 프로세스 PID (서브셸에서도 정확)
$BASH_VERSIONBash 버전 문자열
$LINENO현재 줄 번호
$FUNCNAME현재 함수명 (배열, 호출 스택)
$BASH_SOURCE소스 파일명 (배열, 호출 스택)
$PIPESTATUS파이프라인 각 단계의 종료 상태 (배열)
$BASH_REMATCH=~ 매칭 결과 (배열)
$RANDOM0~32767 난수
$SECONDS셸 시작 이후 경과 시간 (초)
$EPOCHSECONDS유닉스 에포크 초 (Bash 5.0+)
$IFS내부 필드 구분자 (기본: 공백·탭·개행)

패턴 치트시트

커널 개발에서 자주 사용하는 Bash 원라이너(One-liner)와 패턴을 정리합니다.

# 1. 커널 설정에서 활성 옵션 수 세기
grep -c '^CONFIG_.*=y' .config

# 2. 모듈 설정 옵션만 추출
grep '^CONFIG_.*=m' .config | cut -d= -f1

# 3. 가장 큰 오브젝트 파일 Top 10
find . -name "*.o" -exec ls -la {} + | sort -k5 -rn | head -10

# 4. 커널 로그에서 에러 타임라인 추출
dmesg -T | grep -iE "(error|panic|oops|bug)"

# 5. 로드된 모듈의 총 메모리 사용량
awk '{sum+=$2} END {printf "총 모듈 메모리: %d KB\n", sum}' /proc/modules

# 6. CPU별 인터럽트 카운트
awk 'NR>1 {printf "%-20s %s\n", $NF, $2}' /proc/interrupts | sort -k2 -rn | head -15

# 7. git 로그에서 커밋별 변경 파일 수
git log --oneline --shortstat -20 | paste - -

# 8. 커널 소스에서 특정 함수 호출 빈도
grep -rn "mutex_lock" kernel/ drivers/ | wc -l

# 9. 심볼 테이블에서 가장 큰 함수
nm --size-sort -r vmlinux | head -20

# 10. 빌드 시간 측정
time make -j$(nproc) 2>&1 | tail -1

# 11. .config 디핑: 두 설정 차이점만
diff <(grep '^CONFIG_' old.config | sort) <(grep '^CONFIG_' new.config | sort)

# 12. /proc에서 프로세스별 메모리 사용량 Top 10
ps aux --sort=-%mem | head -11

# 13. 네트워크 인터페이스 상태 한눈에 보기
for i in /sys/class/net/*/operstate; do echo "$(dirname "$i" | xargs basename): $(cat "$i")"; done

# 14. 커널 빌드 경고 카운트
grep -c "warning:" build.log

# 15. 헤더 파일 의존성 그래프 (간이)
grep -rh '^#include' include/linux/mm.h | sort -u
1 #!/bin/bash — 셔뱅(Shebang) 2 set -euo pipefail — 엄격 모드 3 상수·전역 변수 선언 4 함수 정의 (die, warn, usage, ...) 5 trap cleanup EXIT INT TERM 6 getopts 옵션 파싱 + 인자 검증 7 main "$@" — 메인 로직 실행
그림 13. Bash 스크립트 구조 템플릿 — 셔뱅→엄격 모드→변수→함수→트랩→옵션 파싱→메인 실행의 7단계 구조. 커널 스크립트의 표준 패턴

참고 자료

공식 문서

학습 가이드

위키 · FAQ · 베스트 프랙티스

맨 페이지 · 레퍼런스

문서관련 내용
개발 도구GCC, Make, GDB 등 커널 개발 도구 개요
빌드 시스템Kbuild, Kconfig, make 인자와 환경변수
GNU MakeMakefile 문법, 패턴 규칙, 자동 변수 상세
C 언어커널 C 코딩 스타일과 GCC 확장
BusyBoxBusyBox ash 셸, initramfs 환경
프로세스 관리fork, exec, 스케줄링, 프로세스 그룹
커널 디버깅ftrace, perf, QEMU+GDB 디버깅
커널 모듈모듈 빌드, 로드, 매개변수
임베디드 빌드 시스템Buildroot, OpenWrt의 셸 스크립트 활용
개발 환경 설정QEMU, 크로스 컴파일 환경 구축