1주차: 리눅스 개발 환경 구축과 첫 C 프로그램

들어가며

C 프로그래밍을 배우는 여정을 시작하신 것을 환영합니다! 이번 글에서는 리눅스 환경에서 C 프로그래밍을 시작하기 위한 모든 준비 과정을 다룹니다. 많은 분들이 Windows 환경에 익숙하실 텐데요, 왜 굳이 리눅스에서 C를 배워야 할까요?

리눅스는 C 언어로 만들어진 운영체제입니다. 리눅스 커널 자체가 C로 작성되었고, 대부분의 시스템 도구들도 C로 개발되었죠. 따라서 리눅스 환경은 C 프로그래밍을 배우기에 가장 자연스러운 환경입니다. 또한 서버 개발, 임베디드 시스템, 시스템 프로그래밍 등 실무 환경의 대부분이 리눅스 기반이기 때문에, 리눅스에 익숙해지는 것은 개발자로서 필수적인 역량입니다.

이번 주차에서는 Ubuntu 24.04 LTS를 설치하고, 터미널을 사용하는 방법을 배우며, 첫 C 프로그램을 작성하고 실행하는 것까지 모든 과정을 함께 진행하겠습니다.


1. 리눅스 배포판 선택과 설치

Ubuntu를 선택하는 이유

리눅스에는 수많은 배포판(Distribution)이 있습니다. Debian, Fedora, Arch Linux, CentOS 등 각각의 특징이 있지만, 우리는 Ubuntu 24.04 LTS를 선택했습니다. 그 이유는 다음과 같습니다:

  1. 초보자 친화적: Ubuntu는 가장 사용하기 쉬운 리눅스 배포판 중 하나입니다. 설치 과정이 간단하고, 한글 지원도 잘 되어 있습니다.

  2. LTS (Long Term Support): 24.04 LTS는 2029년까지 5년간 지원을 받습니다. 안정적인 환경에서 학습할 수 있습니다.

  3. 풍부한 문서와 커뮤니티: 전 세계적으로 가장 많이 사용되는 배포판이기 때문에, 문제가 생겼을 때 해결 방법을 찾기가 쉽습니다.

  4. 개발 도구의 완벽한 지원: GCC, Make, Git 등 우리가 사용할 모든 도구들이 기본적으로 잘 지원됩니다.

설치 방법 선택하기

Ubuntu를 설치하는 방법은 크게 세 가지가 있습니다:

1) VirtualBox를 이용한 가상 머신 설치 (추천)

가장 안전하고 초보자에게 추천하는 방법입니다. 현재 사용 중인 Windows나 macOS를 그대로 유지하면서, 그 안에서 Ubuntu를 실행할 수 있습니다. 실수로 시스템을 망가뜨려도 가상 머신만 삭제하면 되기 때문에 안전합니다.

장점: – 기존 운영체제를 유지하면서 사용 가능 – 스냅샷 기능으로 언제든지 이전 상태로 복원 가능 – 실험과 학습에 안전한 환경

단점: – 성능이 실제 설치보다 약간 떨어짐 – 최소 8GB RAM 권장 (가상 머신에 4GB 할당)

2) 듀얼 부팅 설치

컴퓨터를 켤 때 Windows와 Ubuntu 중 하나를 선택해서 부팅하는 방식입니다. 네이티브 성능을 100% 활용할 수 있습니다.

장점: – 최고의 성능 – 실제 리눅스 환경 경험

단점: – 설치 과정이 복잡하고 위험할 수 있음 – 파티션 조작 필요 – OS 전환 시 재부팅 필요

3) WSL2 (Windows Subsystem for Linux)

Windows 10/11 사용자라면 WSL2를 사용할 수 있습니다. Windows 내에서 실제 리눅스 커널을 실행합니다.

장점: – 설치가 매우 간단 – Windows와 파일 공유 용이 – 가벼움

단점: – GUI 프로그램 실행이 제한적 – 일부 시스템 프로그래밍 기능 제한

VirtualBox로 Ubuntu 설치하기

이 강의에서는 가장 안전한 VirtualBox 방식을 기준으로 설명하겠습니다.

Step 1: VirtualBox 다운로드 및 설치

VirtualBox 다운로드 페이지

VirtualBox 다운로드 페이지

  1. VirtualBox 공식 웹사이트에 접속합니다.
  2. 본인의 운영체제에 맞는 버전을 다운로드합니다 (Windows hosts / macOS hosts).
  3. 다운로드한 파일을 실행하여 설치합니다.

Step 2: Ubuntu 24.04 LTS ISO 다운로드

Ubuntu 데스크톱 다운로드 페이지

Ubuntu 데스크톱 다운로드 페이지

  1. Ubuntu 공식 웹사이트에 접속합니다.
  2. Ubuntu 24.04 LTS Desktop 버전을 다운로드합니다 (약 6GB).

Step 3: 가상 머신 만들기 — 무인 설치로

VirtualBox 7 부터는 ISO 를 지정하면 우분투 설치를 알아서 끝내 주는 무인 설치(Unattended Installation) 기능이 있습니다. 설치 프로그램에서 언어, 파티션, 계정을 하나씩 클릭하는 대신 VirtualBox 대화상자에서 한 번에 정하고, 나머지는 기다리기만 하면 됩니다. 이 글은 이 방식으로 갑니다. 설치 프로그램을 직접 넘기는 방법은 인터넷에 이미 넘치도록 있습니다.

  1. VirtualBox 를 실행하고 “새로 만들기(New)” 를 클릭합니다.

VirtualBox 메인 화면

VirtualBox 메인 화면

  1. 이름과 운영체제 (Virtual machine name and operating system):
    • VM Name: ubuntu_c_study (원하는 이름)
    • ISO Image: Step 2 에서 받은 ubuntu-24.04.x-desktop-amd64.iso 선택
    • ISO 를 고르면 OS 종류와 버전이 자동으로 채워집니다. 버전이 24.10 처럼 엉뚱하게 잡혀도 신경 쓰지 마세요. 실제로 설치되는 것은 ISO 안의 24.04 이고, 이 칸은 VirtualBox 가 기본값을 고르는 데만 씁니다.
    • Proceed with Unattended Installation 이 체크돼 있는지 확인합니다.

가상 머신 만들기 - 이름과 ISO

가상 머신 만들기 – 이름과 ISO

  1. 무인 설치 계정 (Set up unattended guest OS installation):
    • User Name: 영문 소문자 (예: user)
    • 암호 / Confirm Password: 로그인과 sudo 에 쓸 비밀번호
    • Host Name: ubuntu-c-study (컴퓨터 이름. 터미널 프롬프트에 보입니다)
    • Install Guest Additions: 체크합니다. 설치가 끝나면 화면 크기 조정과 클립보드 공유를 해 주는 드라이버를 같이 넣어 줍니다.

무인 설치 계정 입력

무인 설치 계정 입력

  1. 가상 하드웨어 (Specify virtual hardware):
    • Base Memory: 4096MB 이상. 호스트 메모리의 절반까지는 괜찮습니다.
    • Number of CPUs: 2개 이상. 호스트 코어 수의 절반 정도가 적당합니다.

가상 하드웨어 - 메모리와 CPU

가상 하드웨어 – 메모리와 CPU

  1. 가상 하드 디스크 (Specify virtual hard disk):
    • 30GB 이상. 동적 할당이라 실제로는 쓴 만큼만 차지합니다.
  2. “완료(Finish)” 를 클릭합니다.

Step 4: 설치 기다리기

완료를 누르면 가상 머신이 바로 켜지고 우분투 설치가 저절로 진행됩니다. 설치 프로그램의 슬라이드쇼와 로그가 번갈아 보이는데, 아무것도 누르지 않아도 됩니다. 10분 안팎 걸립니다.

무인 설치 진행 중

무인 설치 진행 중

설치가 끝나면 알아서 재부팅하고 로그인 화면이 나옵니다. Step 3 에서 정한 사용자 이름과 비밀번호로 로그인합니다.

Step 5: 첫 로그인

처음 로그인하면 환영 마법사가 나옵니다. 전부 건너뛰어도 됩니다.

  1. Ubuntu Pro: 건너뛰기
  2. Ubuntu 개선에 도움주기: 선택 사항
  3. “완료” 클릭

첫 로그인 환영 화면

첫 로그인 환영 화면

이 시점에는 우분투 화면이 창 크기에 맞춰지지 않고 작게 보일 수 있습니다. Guest Additions 가 아직 제대로 안 잡힌 것입니다. 다음 절에서 해결합니다.

Guest Additions 설치 (중요!)

Guest Additions 는 VirtualBox 안에서 도는 우분투에 넣는 드라이버 묶음입니다. 이것이 있어야: – 화면 해상도가 창 크기에 맞춰 자동으로 바뀝니다 – 호스트와 클립보드를 공유할 수 있습니다 – 파일을 드래그 앤 드롭할 수 있습니다 – 그래픽 성능이 좋아집니다

Step 3 에서 체크했다면 설치는 돼 있어야 하는데, 로그인했는데도 화면이 작게 고정돼 있다면 커널 모듈이 안 만들어진 것입니다. CD 에서 직접 한 번 돌려 주면 됩니다.

  1. 우분투가 켜진 상태에서 VirtualBox 창 메뉴 장치 > Guest Additions CD 이미지 삽입 을 클릭합니다. 왼쪽 독에 CD 아이콘이 생깁니다.
  2. 터미널을 열고 (Ctrl + Alt + T) 설치 스크립트를 실행합니다:

cd /media/$USER/VBox_GAs_*/
sudo ./VBoxLinuxAdditions.run
  1. 비밀번호를 넣으면 커널 모듈을 빌드하고 설치합니다. 1~2분 걸립니다. VirtualBox Guest Additions: Starting. 이 나오면 끝입니다.

Guest Additions 설치 완료

Guest Additions 설치 완료

커널 모듈 빌드에 실패했다는 메시지가 나오면 컴파일러가 없는 것입니다. sudo apt install build-essential dkms 를 먼저 하고 다시 실행하세요.

  1. 재부팅합니다:
sudo reboot

다시 로그인하면 우분투 화면이 VirtualBox 창을 꽉 채웁니다. 창 크기를 바꾸면 해상도가 따라옵니다.

Guest Additions 설치 후 데스크톱

Guest Additions 설치 후 데스크톱


2. 터미널과 쉘 기초

우분투를 설치하면 윈도우와 비슷한 바탕화면이 나옵니다. 마우스로 폴더를 열고, 아이콘을 더블클릭하면 프로그램이 뜹니다. 그런데 리눅스 개발자들은 왜 굳이 검은 화면에 글자를 쳐서 일을 할까요?

이유는 단순합니다. 글자로 된 명령은 기록하고, 반복하고, 조합할 수 있기 때문입니다. “바탕화면의 문서 폴더를 열고, 새 폴더를 만들고, 이름을 week01로 바꾼다”를 마우스로 하면 매번 같은 동작을 손으로 되풀이해야 합니다. 명령어로 하면 한 줄이고, 그 한 줄을 메모장에 적어 두었다가 내일 다시 쓰거나 친구에게 보낼 수 있습니다. 그리고 이 강좌에서 만들 C 프로그램도 대부분 이 검은 화면 안에서 돌아갑니다. 그러니 터미널은 앞으로 33주 동안 우리의 작업대입니다.

이 절은 깁니다. 명령어를 외우려고 하지 말고, 하나씩 직접 쳐 보면서 화면에 무엇이 나오는지 확인하는 데 집중하세요. 한 번 손으로 해 본 명령은 생각보다 오래 기억에 남습니다.

터미널, 쉘, 명령어 — 세 단어부터 정리하기

처음 배울 때 가장 헷갈리는 것이 이 세 단어입니다. 식당에 비유해 보겠습니다.

단어 하는 일 식당에 비유하면
터미널(Terminal) 글자를 입력받고 결과를 보여 주는 창 주문을 받는 계산대
쉘(Shell) 입력한 글자를 해석해서 운영체제에 일을 시키는 프로그램 주문을 받아 주방에 전달하는 직원
명령어(Command) 실제로 일을 하는 프로그램들 (ls, gcc 등) 요리사

우리가 터미널 창에 ls라고 치면, 쉘이 그 글자를 읽고 “ls라는 프로그램을 찾아서 실행해 달라”고 운영체제에 부탁합니다. ls 프로그램이 파일 목록을 출력하면, 그 글자가 터미널 창에 나타납니다. 즉 터미널은 화면일 뿐이고, 실제로 해석하는 것은 쉘, 일을 하는 것은 각각의 명령어 프로그램입니다.

이 구분이 왜 중요할까요? 나중에 “명령을 찾을 수 없습니다” 같은 오류를 만났을 때, 문제가 어디에 있는지 짐작할 수 있기 때문입니다. 쉘이 프로그램을 못 찾은 것인지, 프로그램이 실행은 됐는데 실패한 것인지는 전혀 다른 문제입니다.

터미널 열기

터미널을 여는 방법은 세 가지입니다.

  1. 단축키: Ctrl + Alt + T. 세 키를 동시에 누르면 바로 열립니다. 제일 빠르니 이것을 손에 익히세요.
  2. 앱 목록: 화면 왼쪽 아래의 점 아홉 개 아이콘(앱 표시)을 누르고 terminal 또는 터미널을 입력합니다.
  3. 파일 관리자에서: 폴더 안의 빈 곳을 오른쪽 클릭하고 “터미널에서 열기”를 누르면, 그 폴더 위치에서 터미널이 열립니다.

터미널을 열면 이런 줄이 보이고 커서가 깜빡입니다.

user@ubuntu-c-study:~$

이 한 줄을 프롬프트(prompt) 라고 합니다. “명령을 입력하세요”라고 쉘이 기다리고 있다는 표시입니다. 모양은 아무렇게나 생긴 게 아니라 정보가 들어 있습니다.

user @ ubuntu-c-study : ~ $
 │   │       │        │ │ │
 │   │       │        │ │ └─ 일반 사용자라는 표시 ($). 관리자(root)면 # 로 바뀝니다
 │   │       │        │ └─── 지금 있는 위치. ~ 는 "내 홈 폴더"를 줄인 기호
 │   │       │        └───── 구분 기호
 │   │       └────────────── 컴퓨터 이름 (1절 Step 3 에서 정한 Host Name)
 │   └────────────────────── 구분 기호 ("~에 있는"의 at)
 └────────────────────────── 로그인한 사용자 이름

그러니 이 프롬프트는 “user 가 ubuntu-c-study 컴퓨터의 홈 폴더(~) 에 있고, 일반 사용자 권한으로 명령을 기다린다”는 뜻입니다. 폴더를 옮겨 다니면 ~ 자리가 ~/c_programming 처럼 바뀌는 것을 곧 보게 됩니다.

터미널 첫 화면

터미널 첫 화면

이 글의 표기법: 앞으로 명령어 예시에서 줄 맨 앞의 $ 는 “여기부터 입력하세요”라는 뜻입니다. $ 는 치지 않습니다. $ 가 없는 줄은 명령을 실행했을 때 화면에 나오는 결과입니다.

명령어의 생김새

모든 명령은 대체로 같은 모양입니다.

명령어  [옵션...]  [대상...]
 ls       -l        /home
  • 명령어: 실행할 프로그램 이름입니다. 항상 맨 앞에 옵니다.
  • 옵션(option): 명령의 동작을 바꾸는 스위치입니다. 보통 - 하나와 글자 하나(-l), 또는 -- 두 개와 단어(--all)로 씁니다. 같은 기능에 짧은 이름과 긴 이름이 둘 다 있는 경우가 많습니다. ls -a 와 ls --all 은 같은 뜻입니다.
  • 대상(인자, argument): 명령이 다룰 파일이나 폴더입니다. 생략하면 대개 “지금 있는 곳”을 대상으로 합니다.

그리고 두 가지 규칙만 기억하세요.

  1. 띄어쓰기가 구분자입니다. ls -l 은 명령어 ls 에 옵션 -l 을 준 것이지만, ls-l 은 ls-l 이라는 (존재하지 않는) 명령어 하나로 읽힙니다.
  2. 대소문자를 구분합니다. ls 와 LS 는 다른 명령이고, Hello.c 와 hello.c 는 다른 파일입니다. 윈도우와 가장 크게 다른 점 중 하나입니다.

짧은 옵션은 합칠 수 있습니다. ls -l -a -h 는 ls -lah 로 줄여 써도 똑같이 동작합니다. 앞으로 -la, -lh 같은 모양이 나오면 “옵션 여러 개를 붙여 쓴 것”이라고 읽으면 됩니다.

Bash — 우리가 쓰는 쉘

쉘 프로그램에도 종류가 여럿 있습니다(sh, bash, zsh, fish 등). 우분투의 기본 쉘은 Bash (Bourne Again SHell)입니다. 1979년에 나온 Bourne Shell(sh)을 다시(again) 만들었다는 말장난이 이름에 들어 있습니다.

지금 쓰는 쉘이 무엇인지 확인해 봅시다.

$ echo $SHELL
/bin/bash

여기서 두 가지가 새로 나왔습니다.

  • echo 는 뒤에 쓴 글자를 그대로 화면에 출력하는 명령입니다. echo 안녕 이라고 치면 안녕 이 나옵니다. 단순하지만 아주 자주 씁니다.
  • $SHELL 은 환경 변수입니다. $ 로 시작하는 이름은 쉘이 “이 이름에 저장된 값으로 바꿔 넣어라”로 해석합니다. 그래서 echo $SHELL 은 실제로는 echo /bin/bash 가 되어 실행됩니다. 환경 변수는 뒤에서 따로 다룹니다.

Bash의 버전도 봅시다.

$ bash --version
GNU bash, 버전 5.2.21(1)-release (x86_64-pc-linux-gnu)
Copyright (C) 2022 Free Software Foundation, Inc.
...

--version 은 거의 모든 리눅스 프로그램이 알아듣는 약속된 옵션입니다. 어떤 프로그램이 설치돼 있는지, 몇 버전인지 궁금하면 일단 프로그램이름 --version 을 쳐 보세요. 설치돼 있지 않으면 “명령을 찾을 수 없습니다”가 나오니, 설치 여부를 확인하는 용도로도 씁니다.

한국어로 나오나요, 영어로 나오나요? 설치할 때 한국어를 골랐다면 많은 메시지가 한국어로 나옵니다. 영어를 골랐다면 GNU bash, version 5.2.21... 처럼 영어로 나옵니다. 내용은 같습니다. 이 글은 한국어 환경의 실제 화면을 기준으로 하되, 인터넷에서 검색할 때 필요한 영어 원문도 함께 적겠습니다.

파일 시스템 — 명령어를 배우기 전에 지도부터

명령어의 절반은 “파일과 폴더”를 다룹니다. 그러니 먼저 리눅스의 폴더 구조가 어떻게 생겼는지 알아야 합니다.

윈도우는 C:\, D:\ 처럼 드라이브마다 따로 시작합니다. 리눅스는 뿌리가 딱 하나입니다. 모든 파일과 폴더가 / (슬래시 하나, 루트 라고 읽습니다) 아래에 나무처럼 매달려 있습니다.

/                     ← 루트. 모든 것의 시작
├── bin               ← 기본 명령어 프로그램들 (ls, cp ...)
├── etc               ← 시스템 설정 파일들
├── home              ← 사용자들의 집(홈 폴더)이 모여 있는 곳
│   └── user          ← 우리의 홈 폴더. ~ 가 가리키는 곳
│       ├── 문서
│       ├── 다운로드
│       └── 바탕화면
├── tmp               ← 임시 파일. 재부팅하면 지워집니다
└── usr
    ├── bin           ← 대부분의 프로그램 (gcc 도 여기 있습니다)
    └── include       ← C 헤더 파일들 (stdio.h 가 여기 있습니다!)

마지막 줄을 눈여겨보세요. 곧 작성할 C 프로그램 첫 줄의 #include <stdio.h> 는 바로 /usr/include/stdio.h 파일을 가리킵니다. 우리가 쓰는 도구들이 실제로 이 나무 어딘가에 파일로 존재한다는 감각을 가지면, 나중에 오류를 만났을 때 훨씬 덜 막막합니다.

폴더 구분자는 윈도우의 \ 가 아니라 / 입니다. 그리고 위치를 가리키는 방법이 두 가지 있습니다.

  • 절대 경로: / 로 시작합니다. 루트부터 끝까지 전부 적은 주소입니다. 예: /home/user/문서. 지금 어디에 있든 항상 같은 곳을 가리킵니다.
  • 상대 경로: / 로 시작하지 않습니다. “지금 있는 곳”을 기준으로 한 주소입니다. 홈 폴더에 있을 때 문서 라고 하면 /home/user/문서 를 뜻합니다.

상대 경로에서 쓰는 특별한 이름이 세 개 있습니다. 앞으로 계속 나오니 꼭 기억하세요.

기호 뜻 예
. 지금 있는 폴더 ./hello = “여기 있는 hello”
.. 한 단계 위(부모) 폴더 cd .. = 위로 올라가기
~ 내 홈 폴더 (/home/user) cd ~ = 집으로 가기

기본 명령어 익히기

이제 명령어를 하나씩 해 보겠습니다. 각 명령마다 이름의 뜻 → 기본 사용법 → 자주 쓰는 옵션 → 실수하면 나오는 화면 순서로 봅니다. 이름의 뜻을 알면 외울 필요가 거의 없습니다. 리눅스 명령어는 대부분 영어 단어를 줄인 것이기 때문입니다.

1) pwd — 지금 어디 있지?

print working directory, “작업 중인 폴더를 출력하라”의 줄임말입니다.

$ pwd
/home/user

터미널을 막 열면 항상 홈 폴더에서 시작하므로 /home/user 가 나옵니다(사용자 이름이 다르면 그 이름이 나옵니다). 프롬프트의 ~ 가 바로 이 경로를 줄인 것입니다.

pwd 는 옵션을 거의 쓰지 않습니다. 대신 길을 잃었을 때 제일 먼저 치는 명령입니다. 명령이 이상하게 동작하면 “내가 지금 엉뚱한 폴더에 있는 건 아닐까?”부터 의심하세요. 초보 시절 오류의 상당수가 여기서 풀립니다.

2) ls — 여기에 뭐가 있지?

list, “목록”의 줄임말입니다. 폴더 안의 파일과 폴더를 보여 줍니다.

$ ls
memo.txt  다운로드  문서  바탕화면

터미널이 색을 지원하면 폴더는 파란색, 일반 파일은 흰색, 실행 파일은 초록색으로 보입니다. 색만 봐도 종류를 구분할 수 있습니다.

대상을 주면 다른 폴더의 내용도 볼 수 있습니다. 내가 이동할 필요는 없습니다.

$ ls /usr/include

화면 가득 .h 파일이 쏟아질 겁니다. C 언어의 헤더 파일들입니다. 그중에 stdio.h 가 보이나요?

옵션 -a: 숨은 파일까지 (all)
$ ls -a
.  ..  .bashrc  .profile  memo.txt  다운로드  문서  바탕화면

아까 없던 것들이 나타났습니다. 리눅스에서는 이름이 . 으로 시작하는 파일은 숨김 파일입니다. 윈도우처럼 “숨김 속성”이 따로 있는 게 아니라, 이름 규칙 하나로 숨깁니다. 주로 설정 파일들이 이렇게 숨어 있습니다. .bashrc 는 Bash 설정 파일로, 뒤에서 직접 고쳐 볼 겁니다.

맨 앞의 . 과 .. 은 앞에서 배운 “지금 폴더”와 “부모 폴더”입니다. 모든 폴더 안에 이 두 이름이 실제로 들어 있습니다.

옵션 -l: 자세히 (long)
$ ls -l
합계 16
-rw-rw-r-- 1 user user   23  9월 20 09:15 memo.txt
drwxrwxr-x 2 user user 4096  9월 24 10:02 다운로드
drwxrwxr-x 2 user user 4096  9월 24 10:02 문서
drwxrwxr-x 2 user user 4096  9월 24 10:02 바탕화면

한 줄에 파일 하나씩, 정보가 빼곡합니다. 처음 보면 암호 같지만, 칸마다 뜻이 정해져 있습니다. memo.txt 줄을 한 칸씩 뜯어봅시다.

-rw-rw-r--   1    user   user    23   9월 20 09:15   memo.txt
│└───┬───┘   │     │      │      │        │             │
│    │       │     │      │      │        │             └ ⑦ 이름
│    │       │     │      │      │        └────────────── ⑥ 마지막으로 고친 시각
│    │       │     │      │      └─────────────────────── ⑤ 크기 (바이트)
│    │       │     │      └────────────────────────────── ④ 그룹
│    │       │     └───────────────────────────────────── ③ 주인
│    │       └─────────────────────────────────────────── ② 링크 수
│    └─────────────────────────────────────────────────── ① 권한 (9글자)
└──────────────────────────────────────────────────────── ⓞ 종류 (- 파일, d 폴더, l 바로가기)
  • ⓞ 종류: 맨 앞 한 글자입니다. - 는 일반 파일, d 는 directory(폴더), l 은 link(윈도우의 바로가기 같은 것)입니다. 폴더 세 개가 d 로 시작하는 게 보이죠.
  • ① 권한: 누가 읽고, 쓰고, 실행할 수 있는지를 나타내는 9글자입니다. 중요해서 이 절 뒤쪽 “파일 권한”에서 자세히 다룹니다.
  • ② 링크 수: 지금은 신경 쓰지 않아도 됩니다. 폴더는 보통 2 이상입니다(폴더 자신의 이름과, 그 안의 . 이 같은 폴더를 가리키므로).
  • ③ 주인, ④ 그룹: 이 파일을 소유한 사용자와 그룹입니다. 우분투는 사용자마다 같은 이름의 그룹을 하나씩 만들어 주기 때문에 user user 처럼 같은 이름이 두 번 나옵니다.
  • ⑤ 크기: 바이트 단위입니다. memo.txt 는 23바이트, 즉 영어 글자 23개 분량입니다. 폴더의 4096은 폴더 안 내용물의 크기가 아니라, “폴더라는 목록 자체”가 차지하는 기본 크기입니다.
  • ⑥ 시각: 마지막으로 내용이 바뀐 시각입니다. 올해 것이면 시:분, 오래된 것이면 연도가 나옵니다.

맨 윗줄의 합계 16(영어 환경에서는 total 16)은 이 목록의 파일들이 디스크에서 차지하는 블록 수입니다. 크기 합계와는 다르니 무시해도 됩니다.

옵션 -h: 사람이 읽기 쉬운 크기 (human-readable)

크기가 바이트로만 나오면 큰 파일은 읽기 힘듭니다. -h 를 -l 과 함께 쓰면 K, M, G 단위로 바꿔 줍니다.

$ ls -lh /usr/bin/bash
-rwxr-xr-x 1 root root 1.4M  3월 31  2024 /usr/bin/bash

Bash 프로그램 자체가 1.4MB라는 것을 알 수 있습니다. 주인이 root(관리자)이고, 연도가 붙어 있으니 오래전에 설치된 파일입니다. -h 는 혼자서는 의미가 없고, 크기를 보여 주는 -l 과 짝으로 씁니다.

자주 쓰는 조합은 ls -la(숨김까지 자세히)와 ls -lh(자세히, 읽기 쉬운 크기)입니다.

직접 해 보기: ls -l /usr/bin/gcc* 를 쳐 보세요. * 는 “아무 글자나”라는 뜻이라, gcc 로 시작하는 파일을 전부 보여 줍니다. 맨 앞이 l 인 줄이 있나요? 그 줄 끝의 -> 는 “이 파일은 사실 저 파일을 가리키는 바로가기”라는 뜻입니다. 우리가 칠 gcc 도 실제로는 버전이 붙은 다른 파일로 연결돼 있습니다.

3) cd — 다른 폴더로 이동

change directory, “폴더 바꾸기”입니다. 윈도우 탐색기에서 폴더를 더블클릭해서 들어가는 것과 같습니다.

$ cd 문서
user@ubuntu-c-study:~/문서$

아무 메시지도 없지만, 프롬프트가 ~ 에서 ~/문서 로 바뀌었습니다. 이동에 성공했다는 뜻입니다. pwd 로 확인해도 됩니다.

$ pwd
/home/user/문서

cd 의 여러 가지 쓰임을 한 번씩 해 보세요.

입력 뜻
cd 문서 지금 폴더 안의 문서 로 (상대 경로)
cd .. 한 단계 위로
cd ../다운로드 위로 올라갔다가 다운로드 로 (옆 폴더로 이동)
cd /usr/include 절대 경로로 한 번에
cd ~ 또는 그냥 cd 어디에 있든 홈 폴더로
cd - 직전에 있던 폴더로 되돌아가기

마지막 cd - 는 두 폴더를 왔다 갔다 할 때 아주 편합니다. 이동한 뒤 도착한 위치를 출력해 줍니다.

$ cd /usr/include
$ cd ~/문서
$ cd -
/usr/include

없는 폴더로 가려고 하면 이렇게 됩니다.

$ cd 문셔
bash: cd: 문셔: 그런 파일이나 디렉터리가 없습니다

영어 환경에서는 No such file or directory 입니다. 이 메시지는 앞으로 수백 번 보게 됩니다. 뜻은 언제나 같습니다. “그 이름의 파일이나 폴더가 여기 없다.” 그러면 철자가 틀렸는지, 아니면 내가 엉뚱한 위치에 있는지(pwd) 확인하면 됩니다.

Tab 키를 누르세요. cd 문 까지만 치고 Tab 키를 누르면 쉘이 cd 문서/ 로 나머지를 채워 줍니다. 후보가 여럿이면 Tab 을 두 번 눌렀을 때 목록을 보여 줍니다. 이것을 자동 완성이라고 합니다. 오타를 막아 주고 타이핑도 줄여 주니, 파일 이름을 칠 때는 무조건 Tab 을 누르는 습관을 들이세요. 위의 문셔 같은 오류는 Tab 만 눌렀어도 생기지 않습니다.

잠깐, 궁금하지 않나요? 앞에서 명령어는 “프로그램”이라고 했습니다. ls 는 /usr/bin/ls 라는 파일입니다. 그럼 cd 도 어딘가에 파일로 있을까요? 확인해 봅시다.

$ type ls
ls은(는) /usr/bin/ls 임
$ type cd
cd은(는) 셸 내장임

type 은 “이 명령이 어디서 오는지” 알려 주는 명령입니다. ls 는 파일로 된 프로그램인데, cd 는 쉘 내장 명령(builtin) 입니다. 쉘 프로그램 안에 들어 있는 기능이라는 뜻입니다.

왜 cd 만 이렇게 특별할까요? 쉘이 외부 프로그램을 실행하면, 그 프로그램은 쉘과 별개인 새 프로그램(프로세스)으로 돌아갑니다. 만약 cd 가 별도 프로그램이라면, 그 프로그램이 자기 위치를 바꾸고 끝나 버릴 뿐 쉘의 위치는 그대로일 것입니다. 남의 집 가구를 옮겨 봐야 우리 집은 그대로인 것과 같습니다. 그래서 “쉘 자신의 위치”를 바꾸는 cd 는 쉘 안에 들어 있어야만 합니다. 이 “프로세스”라는 개념은 18주차 POSIX 시스템 프로그래밍에서 fork 와 exec 로 직접 다루게 됩니다.

4) mkdir — 폴더 만들기

make directory입니다.

$ mkdir c_programming
$ ls
c_programming  memo.txt  다운로드  문서  바탕화면

이번에도 성공하면 아무 말이 없습니다. ls 로 확인해야 보입니다. 이름을 여러 개 주면 한 번에 여러 폴더를 만듭니다.

$ mkdir week01 week02 week03

이제 폴더 안의 폴더 안의 폴더를 한 번에 만들어 봅시다.

$ mkdir projects/week01/examples
mkdir: `projects/week01/examples' 디렉터리를 만들 수 없습니다: 그런 파일이나 디렉터리가 없습니다

실패했습니다. examples 를 만들려면 그것이 들어갈 projects/week01 이 먼저 있어야 하는데, 아직 projects 도 없기 때문입니다. mkdir 은 기본적으로 마지막 한 단계만 만듭니다.

여기서 옵션 -p (parents, 부모들)를 쓰면 중간에 없는 폴더를 전부 만들어 줍니다.

$ mkdir -p projects/week01/examples
$ find projects
projects
projects/week01
projects/week01/examples

find 는 폴더 안을 끝까지 뒤져서 모든 파일과 폴더를 나열하는 명령입니다. 세 단계가 다 만들어진 게 보입니다. -p 에는 보너스가 하나 더 있습니다. 폴더가 이미 있어도 오류를 내지 않습니다. 그래서 스크립트에서 “있으면 그냥 두고, 없으면 만들어라”라는 뜻으로 자주 씁니다.

5) touch — 빈 파일 만들기

이름이 재미있습니다. touch, “건드리다”입니다. 원래 목적은 파일을 살짝 건드려서 수정 시각만 지금으로 바꾸는 것입니다. 그런데 없는 파일을 건드리면 빈 파일을 새로 만들어 주기 때문에, 오늘날에는 빈 파일 만드는 용도로 더 많이 씁니다.

$ touch hello.c
$ ls -l hello.c
-rw-rw-r-- 1 user user 0  9월 24 10:02 hello.c

크기가 0입니다. 이름만 있고 내용은 없는 파일이 생겼습니다.

원래 용도도 확인해 봅시다. 이미 있는 파일에 touch 를 하면 내용은 그대로이고 시각만 바뀝니다.

$ ls -l memo.txt
-rw-rw-r-- 1 user user 23  9월 20 09:15 memo.txt
$ touch memo.txt
$ ls -l memo.txt
-rw-rw-r-- 1 user user 23  9월 24 10:02 memo.txt

크기는 23 그대로, 시각만 지금으로 바뀌었습니다. 이게 무슨 쓸모가 있을까 싶지만, 5주차에 배울 make 가 바로 이 “수정 시각”을 보고 어떤 파일을 다시 컴파일할지 결정합니다. 그때 touch 로 make 를 속여 보는 실험을 하게 됩니다.

6) cp — 복사

copy입니다. 순서가 중요합니다. 항상 “원본 → 사본” 순서입니다.

$ cp memo.txt memo_backup.txt

memo.txt 의 내용을 복사해서 memo_backup.txt 라는 새 파일을 만듭니다. 대상이 폴더면 그 폴더 안에 같은 이름으로 복사합니다.

$ cp memo.txt 문서/

마지막의 / 는 없어도 되지만, 붙여 두면 “이건 폴더다”라는 뜻이 분명해집니다. 만약 문서 폴더가 없으면 / 를 붙인 쪽은 오류를 내고, 안 붙인 쪽은 문서 라는 파일을 만들어 버립니다. 그래서 폴더로 보낼 때는 / 를 붙이는 습관이 안전합니다.

폴더를 복사하려고 하면 거절당합니다.

$ cp projects projects_copy
cp: -r을 지정하지 않음. 'projects' 디렉터리 생략

오류 메시지가 해결책까지 알려 줍니다. -r (recursive, 재귀적으로)을 주라는 것입니다. “재귀적”이란 폴더 안의 폴더 안의 폴더까지, 끝까지 따라 들어가며 전부 처리한다는 뜻입니다.

$ cp -r projects projects_copy

주의: 사본 이름으로 이미 있는 파일을 지정하면, cp 는 묻지도 않고 덮어씁니다. 걱정되면 -i (interactive, 물어보기) 옵션을 붙이세요. 덮어쓰기 전에 덮어쓸까요? 하고 확인합니다.

7) mv — 이동, 그리고 이름 바꾸기

move입니다. 사용법은 cp 와 똑같이 “원본 → 목적지” 순서입니다. 차이는 원본이 남지 않는다는 것뿐입니다.

$ mv memo_backup.txt 문서/      # 문서 폴더로 옮기기
$ mv memo.txt note.txt           # 이름 바꾸기

두 번째 줄을 보세요. 리눅스에는 “이름 바꾸기” 명령이 따로 없습니다. 같은 폴더 안에서 다른 이름으로 옮기는 것이 곧 이름 바꾸기이기 때문입니다. 한번 생각해 보면 자연스럽습니다. 파일의 “위치”는 결국 “경로 이름”이니까요.

mv 는 폴더도 -r 없이 그냥 옮깁니다. 복사와 달리 내용물을 하나하나 옮길 필요 없이 이름표만 바꿔 달면 되기 때문입니다. 그리고 cp 와 마찬가지로 목적지에 같은 이름이 있으면 묻지 않고 덮어씁니다. -i 는 여기서도 통합니다.

8) rm — 삭제 (가장 조심해야 할 명령)

remove입니다.

$ rm note.txt

역시 아무 말 없이 사라집니다. 그리고 여기서 꼭 알아야 할 사실이 있습니다.

rm 은 휴지통을 거치지 않습니다. 지운 파일은 되살릴 방법이 사실상 없습니다. “휴지통 비우기”까지 한 번에 한 것과 같습니다.

폴더를 지우려고 하면 이렇게 됩니다.

$ rm projects
rm: 'projects'을(를) 제거할 수 없습니다: 디렉터리입니다

폴더 전용 삭제 명령 rmdir 도 있지만, 이것은 빈 폴더만 지웁니다.

$ rmdir projects
rmdir: 'projects' 제거 실패: 디렉터리가 비어있지 않음

폴더를 내용물째 지우려면 cp 때처럼 -r 을 줍니다.

$ rm -r projects

인터넷에서 rm -rf 라는 명령을 보게 될 겁니다. -f 는 force, “묻지도 따지지도 말고”입니다. 쓰기 금지된 파일도 묻지 않고, 없는 파일을 지우라고 해도 불평하지 않습니다. 편하지만, 경로를 잘못 치면 그대로 재앙입니다. 예를 들어 rm -rf ~ /tmp/test 처럼 ~ 뒤에 실수로 띄어쓰기가 하나 들어가면, /tmp/test 가 아니라 홈 폴더 전체와 /tmp/test 두 개를 지우라는 명령이 됩니다. 이 강좌에서는 rm -rf 를 쓸 일이 없습니다. 쓰더라도 엔터를 누르기 전에 한 번 더 읽으세요.

초보 시절에는 rm -i 로 하나씩 확인받으며 지우는 것도 좋은 방법입니다.

$ rm -i hello.c
rm: 일반 빈 파일 'hello.c'을(를) 제거할까요? y

y 를 치고 엔터를 누르면 지워지고, 다른 것을 치면 취소됩니다.

9) cat, less — 파일 내용 보기

cat 은 concatenate(이어 붙이다)의 줄임말입니다. 원래는 여러 파일을 이어 붙여 출력하는 명령인데, 파일 하나를 주면 그 내용을 그냥 화면에 쏟아 냅니다.

$ cat memo.txt
first line
second line

-n 옵션(number)은 줄 번호를 붙여 줍니다. 나중에 컴파일러가 “5번째 줄에 오류”라고 할 때 유용합니다.

$ cat -n memo.txt
     1  first line
     2  second line

cat 은 짧은 파일용입니다. 긴 파일을 cat 하면 화면이 순식간에 지나가서 앞부분을 볼 수 없습니다. 긴 파일은 less 로 봅니다. 한 화면씩 끊어서 보여 줍니다.

$ less /usr/include/stdio.h

less 화면 안에서 쓰는 키는 이렇습니다.

키 동작
Space / b 한 화면 앞으로 / 뒤로
↑ ↓ 한 줄씩
g / G 맨 처음 / 맨 끝으로
/printf 아래쪽으로 printf 검색. n 을 누르면 다음 결과
q 나가기

stdio.h 를 연 김에 /printf 로 검색해 보세요. extern int printf (const char *__restrict __format, ...); 같은 줄이 나옵니다. 우리가 곧 쓸 printf 함수가 “이런 모양의 함수가 있다”고 여기 적혀 있는 것입니다. 지금은 읽지 못해도 괜찮습니다. 6주차쯤이면 이 줄을 전부 읽을 수 있게 됩니다.

less 에서 빠져나오는 법을 몰라 당황하는 일이 많습니다. q 입니다. 이 뒤에 나올 man 도 같은 화면을 쓰니 함께 기억하세요.

10) clear — 화면 정리

화면이 지저분해지면 clear 를 치거나 Ctrl + L 을 누릅니다. 내용이 지워지는 것은 아니고, 위로 밀어 올려 새 화면처럼 만들어 줄 뿐입니다. 마우스 휠로 올리면 지난 내용이 그대로 있습니다.

알아 두면 손이 편해지는 단축키

키 동작 언제 쓰나
Tab 자동 완성 파일 이름을 칠 때마다
↑ / ↓ 이전 / 다음에 쳤던 명령 불러오기 방금 한 컴파일을 다시 할 때
Ctrl + C 실행 중인 프로그램 강제 중단 프로그램이 멈추지 않을 때
Ctrl + D 입력 끝 알리기. 빈 줄에서 누르면 터미널 종료 2주차에 입력을 다룰 때 다시 나옵니다
Ctrl + L 화면 지우기
Ctrl + A / Ctrl + E 커서를 줄 맨 앞 / 맨 뒤로 긴 명령 앞부분을 고칠 때
Ctrl + U 커서 앞 글자 전부 지우기 잘못 친 줄을 통째로 지울 때
Ctrl + R 지난 명령 검색 Ctrl + R 후 gcc 를 치면 최근 gcc 명령이 나옵니다

이 중 Ctrl + C 는 꼭 기억하세요. 앞으로 만들 프로그램이 무한 반복에 빠지는 일이 반드시 생깁니다. 그때 터미널을 닫지 말고 Ctrl + C 를 누르면 됩니다.

복사와 붙여넣기는? 터미널에서 Ctrl + C 는 “중단”이라서 복사가 아닙니다. 터미널에서는 Ctrl + Shift + C (복사)와 Ctrl + Shift + V (붙여넣기)를 씁니다. 이 글의 명령을 복사해 붙여 넣을 때 헷갈리기 쉬우니 기억해 두세요.

파일 권한과 소유권

ls -l 에서 미뤄 둔 9글자, rw-rw-r-- 를 이제 읽어 봅시다. 리눅스는 처음부터 여러 사람이 한 컴퓨터를 같이 쓰는 환경을 위해 만들어졌습니다. 그래서 파일마다 “누가 무엇을 할 수 있는가”가 정해져 있습니다.

9글자는 3글자씩 세 묶음입니다.

 - rwx rwx r-x
 │  │   │   │
 │  │   │   └─ 기타(others): 주인도 아니고 그룹도 아닌 나머지 모든 사람
 │  │   └───── 그룹(group): 파일의 그룹에 속한 사람
 │  └───────── 주인(user/owner): 파일의 주인
 └──────────── 종류 (- 파일, d 폴더)

각 묶음 안의 세 글자는 순서가 항상 같습니다.

자리 글자 파일에서의 뜻 폴더에서의 뜻
첫째 r (read) 내용을 읽을 수 있음 안에 무엇이 있는지 목록을 볼 수 있음
둘째 w (write) 내용을 고칠 수 있음 안에 파일을 만들고 지울 수 있음
셋째 x (execute) 프로그램으로 실행할 수 있음 안으로 들어갈(cd) 수 있음

글자 대신 - 가 있으면 그 권한이 없다는 뜻입니다. 그러니 rw-rw-r-- 는 “주인은 읽고 쓰기, 그룹도 읽고 쓰기, 나머지는 읽기만”입니다.

앞에서 본 memo.txt 는 rw-rw-r-- 였는데, 곧 만들 C 프로그램 실행 파일은 rwxrwxr-x 가 됩니다. x 가 붙어야 실행할 수 있기 때문입니다. 윈도우는 파일 이름이 .exe 로 끝나면 실행 파일이지만, 리눅스는 이름과 상관없이 x 권한이 있으면 실행 파일입니다. 그래서 리눅스 실행 파일에는 확장자가 없는 경우가 많습니다.

숫자로 쓰는 권한

권한을 숫자로도 씁니다. r 은 4, w 는 2, x 는 1로 정하고, 한 묶음 안의 숫자를 더합니다.

글자 계산 숫자
rwx 4 + 2 + 1 7
rw- 4 + 2 + 0 6
r-x 4 + 0 + 1 5
r-- 4 + 0 + 0 4
--- 0 0

그래서 rwxrwxr-x 는 775, rw-rw-r-- 는 664, rwxr-xr-x 는 755입니다. 왜 하필 4, 2, 1일까요? 이 셋은 2진수의 자릿값(100, 010, 001)이라, 어떻게 더해도 겹치지 않고 0~7로 모든 조합을 표현할 수 있기 때문입니다. 3주차에 비트 연산을 배우면 이 원리가 다시 나옵니다.

chmod — 권한 바꾸기

change mode입니다. 두 가지 방식이 있습니다.

기호 방식은 “누구에게 + 또는 − 무엇을”입니다.

$ chmod u+x script.sh    # 주인(u)에게 실행(x) 권한 추가(+)
$ chmod g-w memo.txt     # 그룹(g)에서 쓰기(w) 권한 제거(-)
$ chmod o-r memo.txt     # 기타(o)에서 읽기(r) 권한 제거
$ chmod a+x script.sh    # 모두(a, all)에게 실행 권한 추가
$ chmod +x script.sh     # 누구인지 생략하면 a 와 거의 같습니다

숫자 방식은 세 자리를 한 번에 정합니다.

$ chmod 755 script.sh    # rwxr-xr-x
$ chmod 644 memo.txt     # rw-r--r--

chmod 777 은 모든 사람에게 모든 권한을 주는 것입니다. 인터넷에서 “권한 오류가 나면 777로 하세요”라는 조언을 종종 보는데, 아무나 파일을 고칠 수 있게 되므로 좋은 습관이 아닙니다. 필요한 권한만 주세요.

권한은 9절에서 우리가 만든 실행 파일로 직접 실험해 봅니다.

환경 변수와 PATH

앞에서 echo $SHELL 로 환경 변수를 잠깐 봤습니다. 환경 변수는 쉘과 쉘이 실행하는 프로그램들이 함께 보는 설정값입니다. 이름은 관례상 대문자로 씁니다.

$ echo $HOME
/home/user
$ echo $USER
user

env 를 치면 설정된 환경 변수를 전부 볼 수 있습니다. 수십 개가 나오는데, 그중 가장 중요한 하나가 PATH 입니다.

“명령을 찾을 수 없습니다”은 왜 생길까?

ls 를 치면 쉘은 /usr/bin/ls 를 실행합니다. 그런데 우리는 /usr/bin 이라고 쓴 적이 없습니다. 쉘은 어떻게 알았을까요?

정답이 PATH 입니다.

$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin

콜론(:)으로 구분된 폴더 목록입니다. 쉘은 명령어를 받으면 이 목록의 폴더를 왼쪽부터 차례로 뒤져서 같은 이름의 실행 파일을 찾습니다. /usr/local/sbin 에 ls 가 있나? 없다. /usr/local/bin 에는? 없다. … /usr/bin 에는? 있다! 그러면 그것을 실행합니다. 끝까지 뒤져도 없으면 그제야 포기하고 이렇게 말합니다.

$ qzxv
qzxv: 명령을 찾을 수 없습니다

영어로는 command not found 입니다. 이름이 설치 가능한 프로그램과 비슷하면, 우분투가 친절하게 후보까지 알려 줍니다.

$ helo
명령어 'helo' 을(를) 찾을 수 없습니다. 다음 명령어로 시도하시겠습니까:
  deb hellohello의 명령어 ' (2.10-3)'
Try: sudo apt install <deb name>

“hello 라는 프로그램을 설치하면 쓸 수 있다”는 제안입니다(번역이 좀 어색하게 깨져 있지만 뜻은 그렇습니다). 오타였다면 무시하면 됩니다. 이제 이 메시지의 정확한 뜻을 압니다. “PATH에 적힌 폴더 어디에도 그 이름의 실행 파일이 없다.” 철자가 틀렸거나, 프로그램이 설치되지 않았거나, 설치된 위치가 PATH에 없다는 뜻입니다.

어떤 명령이 실제로 어느 파일인지는 which 로 확인합니다.

$ which gcc
/usr/bin/gcc

이 원리가 5절에서 우리를 한 번 당황하게 만들 겁니다. 직접 만든 프로그램을 실행하려고 이름만 치면 “명령을 찾을 수 없습니다”가 나옵니다. 왜 그런지, 이제 여러분은 미리 알고 있는 셈입니다.

환경 변수 만들고 바꾸기

환경 변수는 직접 만들 수도 있습니다.

$ export GREETING="안녕하세요"
$ echo $GREETING
안녕하세요

export 는 “이 변수를 만들고, 앞으로 이 쉘에서 실행하는 프로그램들도 볼 수 있게 내보내라”는 뜻입니다. = 양옆에 띄어쓰기를 하면 안 됩니다. GREETING = "안녕" 이라고 쓰면 쉘은 GREETING 이라는 명령어에 = 와 "안녕" 두 인자를 준 것으로 읽습니다.

이렇게 만든 변수는 터미널을 닫으면 사라집니다. 새 터미널을 열어 echo $GREETING 을 해 보면 빈 줄만 나옵니다. 영구히 설정하려면 쉘이 시작할 때마다 읽는 설정 파일에 적어 둬야 합니다. 그 파일이 앞에서 본 숨김 파일 ~/.bashrc 입니다.

$ nano ~/.bashrc

nano 는 터미널 안에서 쓰는 간단한 편집기입니다(4절에서 자세히 봅니다). 파일 맨 아래로 내려가서 export GREETING="안녕하세요" 를 한 줄 적고, Ctrl + O 와 Enter 로 저장, Ctrl + X 로 나옵니다. 그다음 설정을 다시 읽게 합니다.

$ source ~/.bashrc

source 는 “이 파일에 적힌 명령들을 지금 쉘에서 실행하라”는 뜻입니다. 새 터미널을 열면 자동으로 읽히지만, 지금 열려 있는 터미널에 바로 적용하려면 이렇게 해 줘야 합니다.

man — 모르는 명령은 설명서에게 물어보기

지금까지 명령어 열 개 남짓과 옵션 몇 개를 봤습니다. 하지만 ls 하나만 해도 옵션이 50개가 넘습니다. 전부 외울 수도 없고 외울 필요도 없습니다. 리눅스에는 모든 명령의 설명서가 들어 있기 때문입니다. manual의 man 입니다.

$ man ls

less 와 같은 화면이 뜨고, ls 의 모든 옵션이 설명돼 있습니다. / 로 검색하고 q 로 나오는 것도 less 와 같습니다. 영어지만 겁먹을 필요는 없습니다. 맨 위의 NAME(한 줄 설명)과 SYNOPSIS(사용법 모양)만 읽어도 대부분 해결됩니다.

더 짧은 요약이 필요하면 --help 를 붙입니다.

$ ls --help
사용법: ls [<옵션>]... [<파일>]...
<파일>의 정보를 나타냅니다(기본: 현재 디렉터리).
...

첫 줄의 ls [<옵션>]... [<파일>]... 을 읽어 봅시다. 대괄호 [ ] 는 “생략 가능”, ... 는 “여러 개 가능”이라는 뜻입니다. 그러니 “옵션과 파일은 둘 다 생략할 수 있고, 여러 개 줄 수도 있다”는 말입니다. 설명서에서 이 표기를 계속 만나게 되니 읽는 법을 알아 두세요.

man에는 C 함수 설명서도 있습니다

C를 배우는 우리에게 man 이 특히 중요한 이유가 있습니다. C 표준 함수의 설명서도 들어 있기 때문입니다. 한번 찾아볼까요?

$ man -f printf
printf (1)           - format and print data
printf (3)           - formatted output conversion

-f 는 “이 이름의 설명서가 어떤 게 있는지” 목록만 보여 줍니다. printf 가 두 개 있습니다! 괄호 안의 숫자는 섹션 번호입니다.

섹션 내용
1 쉘에서 치는 명령어
2 운영체제 기능을 부르는 함수 (시스템 콜). 18주차 시스템 프로그래밍부터 매일 봅니다
3 C 라이브러리 함수

printf 는 쉘 명령어(1)이기도 하고 C 함수(3)이기도 한 겁니다. 그냥 man printf 라고 하면 1번이 먼저 나오므로, C 함수 설명을 보려면 섹션을 지정합니다.

$ man 3 printf

만약 printf (3) 이 목록에 없거나 man 3 printf 가 “설명서가 없다”고 하면, C 함수 설명서 패키지가 설치되지 않은 것입니다. 다음 절에서 개발 도구를 설치할 때 함께 설치합니다.


3. 개발 도구 설치

터미널에 익숙해졌으니, 이제 C 프로그램을 만드는 데 필요한 도구를 설치합니다. 여기서 궁금증이 하나 생깁니다. 윈도우에서는 프로그램을 설치하려면 웹사이트에 가서 설치 파일을 받고, 다음 → 다음 → 완료를 눌렀습니다. 리눅스는 어떻게 할까요?

패키지와 APT — 리눅스의 앱 스토어

리눅스에서는 프로그램을 패키지(package) 라는 단위로 설치합니다. 패키지는 프로그램 파일, 설정 파일, 설명서, 그리고 “이 프로그램이 돌아가려면 무엇이 더 필요한지”라는 정보를 한데 묶은 꾸러미입니다.

우분투는 수만 개의 패키지를 인터넷의 저장소(repository) 에 모아 두고 있습니다. 그리고 이 저장소에서 패키지를 찾아 받고, 설치하고, 지우는 관리 프로그램이 APT (Advanced Package Tool)입니다. 스마트폰의 앱 스토어와 거의 같은 개념입니다. 차이는 화면 대신 명령어로 쓴다는 것뿐입니다.

APT가 앱 스토어보다 나은 점이 하나 있습니다. 의존성(dependency) 을 알아서 처리해 줍니다. 예를 들어 gcc 를 설치하려면 그것이 쓰는 여러 부품이 먼저 있어야 합니다. APT는 이 목록을 알고 있어서, gcc 하나만 설치하라고 해도 필요한 부품을 전부 찾아 함께 설치합니다.

sudo — 관리자 권한 빌리기

패키지 설치는 시스템 전체에 영향을 주는 일입니다. 2절에서 봤듯이 /usr/bin 같은 시스템 폴더의 주인은 root(관리자)라서, 일반 사용자는 그곳에 파일을 쓸 수 없습니다. 그래서 설치 명령 앞에는 sudo 를 붙입니다.

sudo 는 superuser do, “관리자로서 실행하라”는 뜻입니다. 명령 앞에 붙이면 그 명령 하나만 관리자 권한으로 실행됩니다.

$ sudo apt update
[sudo] user 암호:

비밀번호를 물어봅니다. 1절 Step 3 에서 정한 비밀번호를 입력하세요. 이때 화면에 아무것도 표시되지 않습니다. * 조차 나오지 않아서 입력이 안 되는 것 같지만, 실제로는 입력되고 있습니다. 비밀번호 길이도 노출하지 않으려는 보안 장치입니다. 그냥 끝까지 치고 Enter 를 누르세요.

한 번 비밀번호를 넣으면 15분 동안은 sudo 를 다시 써도 묻지 않습니다.

sudo 는 꼭 필요할 때만. “권한이 없다”는 오류가 날 때마다 sudo 를 붙이는 습관은 위험합니다. sudo rm 은 시스템 파일도 지워 버립니다. 이 강좌에서 sudo 가 필요한 곳은 패키지 설치처럼 시스템을 바꾸는 일뿐입니다. 우리가 만든 프로그램을 실행하거나 컴파일할 때는 절대 필요 없습니다.

apt update 와 apt upgrade — 이름은 비슷한데 하는 일은 다릅니다

설치 전에 먼저 두 명령을 실행합니다. 초보자가 가장 많이 헷갈리는 짝입니다.

$ sudo apt update

update 는 프로그램을 업데이트하는 게 아닙니다. 저장소에 “지금 어떤 패키지가 어떤 버전으로 있는지” 목록을 새로 받아 오는 것입니다. 앱 스토어를 열었을 때 목록을 새로 고침 하는 것과 같습니다. 화면에는 이런 줄이 지나갑니다.

기존:1 http://kr.archive.ubuntu.com/ubuntu noble InRelease
받기:2 http://kr.archive.ubuntu.com/ubuntu noble-updates InRelease [126 kB]
...
패키지 목록을 읽는 중입니다... 완료
...
14개 패키지를 업그레이드할 수 있습니다. 확인하려면 'apt list --upgradable'를 실행하십시오.

기존(영어로 Hit)은 “바뀐 게 없다”, 받기(Get)는 “새 목록을 받았다”는 뜻입니다. 마지막 줄은 “새 버전이 나온 패키지가 14개 있다”고 알려 줍니다. 숫자는 그날그날 다릅니다.

$ sudo apt upgrade

upgrade 가 실제로 프로그램을 새 버전으로 바꾸는 명령입니다. 방금 받은 목록과 설치된 버전을 비교해서, 새 버전이 있는 패키지를 전부 업그레이드합니다. 진행하기 전에 무엇을 바꿀지 보여 주고 확인을 받습니다.

다음 패키지를 업그레이드할 것입니다:
  ...
14개 업그레이드, 0개 새로 설치, 0개 제거 및 0개 업그레이드 안 함.
...
계속 하시겠습니까? [Y/n]

[Y/n] 에서 대문자 Y 는 “그냥 Enter 를 누르면 Y로 친다”는 뜻입니다. Enter 를 누르거나 y 를 치면 진행합니다.

순서가 중요합니다. update 로 목록을 새로 받은 다음에 upgrade 해야 합니다. 목록이 오래되면 APT는 새 버전이 나온 줄 모릅니다. 그래서 이 두 명령은 거의 항상 붙어 다닙니다.

build-essential — C 개발 도구 한 묶음

이제 핵심 도구를 설치합니다.

$ sudo apt install build-essential

install 뒤에 패키지 이름을 쓰면 그 패키지를 설치합니다. build-essential 은 그 자체로는 거의 빈 껍데기입니다. 이름 그대로 “빌드에 필수적인 것들”을 의존성으로 줄줄이 달고 있는 묶음 패키지입니다. 무엇을 달고 있는지 설치 전에 직접 볼 수 있습니다.

$ apt show build-essential
Package: build-essential
Version: 12.10ubuntu1
Depends: libc6-dev | libc-dev, gcc (>= 4:12.3), g++ (>= 4:12.3), make, dpkg-dev (>= 1.17.11)
...

Depends: 줄이 바로 의존성 목록입니다. 하나씩 뭔지 봅시다.

패키지 정체 우리에게 왜 필요한가
gcc GNU C 컴파일러 C 코드를 실행 파일로 바꿉니다. 이 강좌의 주인공
g++ GNU C++ 컴파일러 C++ 용. 이 강좌에서는 거의 안 씁니다
make 빌드 자동화 도구 5주차부터 여러 파일을 한 번에 컴파일할 때 씁니다
libc6-dev C 표준 라이브러리 개발 파일 stdio.h 같은 헤더 파일이 여기 들어 있습니다
dpkg-dev 패키지 만드는 도구 우리가 직접 쓸 일은 거의 없습니다

libc6-dev | libc-dev 의 | 는 “둘 중 하나만 있으면 된다”는 뜻입니다. gcc (>= 4:12.3) 의 괄호는 “이 버전 이상이어야 한다”는 조건입니다.

설치 명령을 실행하면 APT가 이 목록을 따라가며 필요한 패키지를 전부 모읍니다. 수십 개가 한꺼번에 나열될 텐데, 놀랄 필요 없습니다. gcc 한 개도 여러 부품으로 이루어져 있기 때문입니다.

설치됐는지 확인하기

$ gcc --version
gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0
Copyright (C) 2023 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

첫 줄을 읽어 봅시다. 13.3.0 이 GCC의 버전이고, 괄호 안은 우분투가 이 버전을 어떻게 포장했는지(24.04 용으로 여러 번 고쳐 만든 것)를 나타냅니다. 여러분의 숫자가 조금 달라도 13.x 면 이 글과 똑같이 동작합니다.

$ make --version
GNU Make 4.3
...

헤더 파일이 제대로 들어왔는지도 확인해 봅시다. 2절에서 본 지도 그대로라면 /usr/include 에 있어야 합니다.

$ ls -l /usr/include/stdio.h
-rw-r--r-- 1 root root 34649  9월  3 23:15 /usr/include/stdio.h

34,649바이트짜리 텍스트 파일입니다. 이 파일이 어느 패키지에서 왔는지도 물어볼 수 있습니다.

$ dpkg -S /usr/include/stdio.h
libc6-dev:amd64: /usr/include/stdio.h

dpkg -S 는 “이 파일은 어느 패키지 소속인가”를 알려 줍니다. 표에서 말한 대로 libc6-dev 입니다. 이렇게 명령어로 하나씩 확인하는 습관을 들이면, “설치했는데 왜 안 되지?” 같은 상황에서 어디가 빠졌는지 스스로 찾을 수 있습니다.

C 함수 설명서 설치

2절 끝에서 man 3 printf 로 C 함수 설명서를 본다고 했습니다. 이 설명서는 build-essential 에 들어 있지 않아서 따로 설치해야 합니다.

$ sudo apt install manpages-dev

설치한 뒤 다시 확인해 봅시다.

$ man -f printf
printf (1)           - format and print data
printf (3)           - formatted output conversion

이제 (3) 이 보입니다. man 3 printf 를 열어 SYNOPSIS 부분을 보세요.

SYNOPSIS
       #include <stdio.h>

       int printf(const char *restrict format, ...);

“printf 를 쓰려면 #include <stdio.h> 를 써라”라고 적혀 있습니다. 앞으로 처음 보는 C 함수가 나오면 인터넷을 검색하기 전에 man 3 함수이름 부터 쳐 보세요. 어떤 헤더가 필요한지, 무엇을 돌려주는지, 어떤 경우에 실패하는지가 전부 적혀 있습니다. 18주차 시스템 프로그래밍부터는 매일 쓰게 됩니다.

추가 개발 도구

앞으로 쓸 도구들을 미리 설치해 둡시다. 패키지 이름을 여러 개 이어 쓰면 한 번에 설치됩니다.

$ sudo apt install git gdb valgrind vim
도구 한 줄 설명 본격적으로 쓰는 때
git 코드의 변경 기록을 저장하는 버전 관리 도구 프로젝트를 관리할 때 수시로
gdb 실행 중인 프로그램을 한 줄씩 멈춰 가며 들여다보는 디버거 6주차 포인터부터
valgrind 메모리를 잘못 쓰는 곳을 찾아 주는 검사기 7주차 동적 메모리부터
vim 터미널용 텍스트 편집기 원하는 사람만

설치가 끝나면 버전을 확인합니다.

$ git --version
git version 2.43.0
$ gdb --version
GNU gdb (Ubuntu 15.1-1ubuntu1~24.04.1) 15.1
$ valgrind --version
valgrind-3.22.0

개발 도구 버전 확인

개발 도구 버전 확인

전부 버전이 나왔다면 도구 준비는 끝났습니다. “명령을 찾을 수 없습니다”가 나온 도구가 있으면, 설치 명령에서 그 패키지 이름을 잘못 쳤는지 확인하고 다시 설치하세요.

이 명령들은 무엇을 설치했는지 어떻게 알 수 있을까? apt list --installed | grep gcc 를 쳐 보세요. | (파이프)는 앞 명령의 출력을 뒤 명령의 입력으로 넘기는 기호이고, grep gcc 는 그중 gcc 가 들어간 줄만 골라냅니다. 설치된 패키지 수천 개 중 gcc 관련된 것만 보입니다. 파이프는 리눅스 명령을 레고처럼 조립하는 핵심 도구로, 19주차에 C로 직접 만들어 봅니다.


4. 텍스트 에디터 선택

C 프로그램은 결국 글자로 된 텍스트 파일입니다. 그러니 워드 같은 문서 편집기가 아니라, 글자만 저장하는 텍스트 에디터가 필요합니다. 워드로 저장하면 글꼴, 크기 같은 서식 정보가 같이 들어가서 컴파일러가 읽을 수 없습니다.

리눅스에는 에디터가 많습니다. 네 가지를 보고, 이 강좌에서는 VS Code를 기본으로 쓰겠습니다. 그래도 나머지를 알아야 하는 이유가 있습니다. 서버처럼 화면이 없는 곳에서는 터미널 에디터밖에 쓸 수 없기 때문입니다.

1) nano — 터미널 안의 메모장

가장 쉬운 터미널 에디터입니다. 우분투에 기본으로 설치돼 있습니다.

$ nano test.txt

파일이 없으면 새로 만들고, 있으면 엽니다. 화면 아래쪽에 단축키 목록이 늘 표시되는 게 장점입니다. 여기서 ^ 는 Ctrl 키를 뜻합니다. ^X 는 Ctrl + X 입니다.

키 동작
Ctrl + O 그리고 Enter 저장 (Out, 파일로 내보내기)
Ctrl + X 나가기. 저장 안 한 내용이 있으면 저장할지 묻습니다
Ctrl + K 현재 줄 잘라내기
Ctrl + U 잘라낸 줄 붙여넣기
Ctrl + W 찾기 (Where is)

2절에서 ~/.bashrc 를 고칠 때 쓴 것이 이 nano 입니다. 설정 파일을 잠깐 고칠 때는 VS Code를 여는 것보다 빠릅니다.

2) vim — 강력하지만 첫인상이 험한 에디터

리눅스 개발자들 사이에서 오래 사랑받아 온 에디터입니다. 손가락을 키보드에서 떼지 않고 모든 편집을 할 수 있어 익숙해지면 아주 빠릅니다. 문제는 처음 켰을 때 글자를 칠 수도, 나갈 수도 없다는 것입니다. “vim 에서 나가는 법”은 전 세계 개발자의 오래된 농담거리입니다.

이유는 vim 에 모드가 있기 때문입니다.

  • 일반 모드(Normal): 켜자마자 들어가는 모드입니다. 여기서 키보드는 글자 입력이 아니라 명령입니다. x 는 한 글자 지우기, dd 는 한 줄 지우기입니다.
  • 입력 모드(Insert): 일반 모드에서 i 를 누르면 들어갑니다. 이제야 치는 글자가 입력됩니다. 화면 아래에 -- 끼워넣기 -- 또는 -- INSERT -- 가 표시됩니다.
  • Esc 를 누르면 다시 일반 모드로 돌아옵니다.

저장과 종료는 일반 모드에서 : 로 시작하는 명령으로 합니다.

명령 뜻
:w 저장 (write)
:q 나가기 (quit)
:wq 저장하고 나가기
:q! 저장하지 않고 강제로 나가기

그러니 vim 에 갇혔다면 Esc 를 누르고 :q! 를 치고 Enter. 이것만 기억하면 됩니다. vim 은 지금 배울 필요는 없고, 관심이 생기면 터미널에서 vimtutor 를 쳐 보세요. 30분짜리 한국어 실습이 들어 있습니다.

3) 텍스트 편집기 — 우분투 기본 GUI 에디터

우분투 24.04에는 “텍스트 편집기”(Text Editor)라는 창 방식 에디터가 기본으로 들어 있습니다. 윈도우 메모장과 비슷하게 마우스로 쓸 수 있습니다. 앱 목록에서 “텍스트 편집기”를 찾거나, 파일을 더블클릭하면 열립니다. 간단한 메모에는 충분하지만, 코드를 쓰기에는 기능이 부족합니다.

예전 우분투 글에서 자주 보이는 gedit 는 24.04부터 기본으로 설치돼 있지 않습니다. 필요하면 sudo apt install gedit 로 설치할 수 있습니다.

4) Visual Studio Code — 이 강좌의 기본 에디터

마이크로소프트가 만든 무료 코드 에디터입니다. 코드에 색을 입혀 읽기 쉽게 해 주고(문법 강조), 함수 이름을 일부만 쳐도 나머지를 제안해 주고(자동 완성), 오타가 있으면 컴파일하기 전에 빨간 줄로 표시해 줍니다. 터미널도 안에 들어 있어서 창 하나로 코드를 쓰고, 컴파일하고, 실행할 수 있습니다.

VS Code 설치

방법 1: 공식 사이트에서 .deb 받아 설치 (제일 쉬움)

  1. Firefox 로 code.visualstudio.com 에 접속해 .deb 버튼을 눌러 다운로드합니다.
  2. 파일 관리자에서 다운로드 폴더의 code_*.deb 를 더블클릭하면 앱 센터가 열립니다.
  3. “설치”를 누르고 비밀번호를 입력합니다.

.deb 는 3절에서 말한 “패키지” 파일입니다. APT가 인터넷 저장소에서 받아 오는 것도 결국 이 .deb 파일입니다. 이번에는 우리가 직접 받아서 설치하는 것뿐입니다. 실제로 이 방법으로 설치하면 VS Code 저장소가 APT에 자동으로 등록돼서, 앞으로 sudo apt upgrade 를 할 때 VS Code도 함께 업데이트됩니다.

방법 2: 터미널에서 설치

같은 일을 명령어로 할 수도 있습니다. 명령이 길고 낯설지만, 한 줄씩 무엇을 하는지 보면 “APT에 새 저장소를 추가하는 과정”이라는 게 보입니다.

# ① 마이크로소프트의 서명 키를 받아서 APT가 읽는 형식으로 바꾸기
$ wget -qO- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > packages.microsoft.gpg

# ② 그 키를 시스템의 키 보관 폴더에 설치하기
$ sudo install -D -o root -g root -m 644 packages.microsoft.gpg /etc/apt/keyrings/packages.microsoft.gpg

# ③ APT에 "VS Code 저장소는 여기 있다"고 알려 주는 설정 파일 만들기
$ sudo sh -c 'echo "deb [arch=amd64,arm64,armhf signed-by=/etc/apt/keyrings/packages.microsoft.gpg] https://packages.microsoft.com/repos/code stable main" > /etc/apt/sources.list.d/vscode.list'

# ④ 목록을 새로 받고(update) 설치(install)
$ sudo apt update
$ sudo apt install code
  • ① wget 은 인터넷에서 파일을 받는 명령입니다. -q 는 조용히(quiet), -O- 는 파일로 저장하지 말고 화면(표준 출력)으로 내보내라는 뜻입니다. 그 출력을 | 로 gpg --dearmor 에 넘겨 형식을 바꾸고, > 로 packages.microsoft.gpg 파일에 저장합니다. > 는 “화면에 나올 것을 이 파일에 써라”라는 뜻입니다.
  • 서명 키가 왜 필요할까요? 인터넷에서 받은 패키지가 정말 마이크로소프트가 만든 것인지, 중간에 누가 바꿔치기하지 않았는지 확인하기 위해서입니다. APT는 이 키로 서명을 검사해서 맞지 않으면 설치를 거부합니다.
  • ② install 명령은 파일을 복사하면서 주인(-o root), 그룹(-g root), 권한(-m 644)까지 한 번에 정해 줍니다. 644 는 2절에서 배운 rw-r--r-- 입니다. -D 는 대상 폴더가 없으면 만들라는 뜻입니다.
  • ③ /etc/apt/sources.list.d/ 폴더에 있는 파일들이 APT가 참고하는 저장소 목록입니다. 여기에 한 줄짜리 파일을 넣어서 VS Code 저장소를 추가합니다. sudo sh -c '...' 로 감싼 이유는 > 로 파일을 쓰는 동작까지 관리자 권한으로 해야 하기 때문입니다. sudo echo ... > 파일 로 쓰면 echo 만 관리자로 실행되고 파일 쓰기는 일반 사용자로 시도해서 “허가 거부”가 납니다.
  • ④ 저장소를 추가했으니 목록을 새로 받아야(update) APT가 code 패키지의 존재를 압니다.

두 방법 중 무엇을 써도 결과는 같습니다.

VS Code 실행과 첫 화면

앱 목록에서 Visual Studio Code를 찾아 눌러도 되고, 터미널에서 실행해도 됩니다.

$ code

특정 파일이나 폴더를 바로 열 수도 있습니다.

$ code hello.c        # 파일 하나 열기
$ code .              # 지금 있는 폴더를 통째로 열기

code . 의 . 은 2절에서 배운 “지금 있는 폴더”입니다. 코드는 보통 폴더 단위로 관리하므로 앞으로는 code . 을 가장 많이 쓰게 됩니다.

처음 실행하면 “Welcome to VS Code” 창이 뜨고 GitHub 계정으로 로그인하라고 권합니다. AI 코딩 도우미(Copilot)를 쓰기 위한 것이라, 이 강좌에서는 필요 없습니다. 오른쪽 아래의 Continue without Signing In 을 누르면 됩니다.

VS Code 첫 실행

VS Code 첫 실행

폴더를 열면 화면 위에 “Restricted Mode is intended for safe code browsing” 이라는 띠가 나타날 수 있습니다. 인터넷에서 받은 코드일 수도 있으니, 확인 전까지는 자동 실행 기능을 막아 둔 제한 모드입니다. 내가 직접 만든 폴더라면 띠의 Manage 를 누르고 Trust 를 눌러 신뢰한다고 알려 주세요. 그래야 확장 기능이 제대로 동작합니다.

C/C++ 확장 설치

VS Code 자체는 범용 에디터라 C를 특별히 잘 알지는 못합니다. 확장(Extension) 을 설치해야 C 문법을 이해하고 도와줍니다.

  1. 왼쪽 세로 막대에서 네모 네 개 모양의 확장 아이콘을 누릅니다(단축키 Ctrl + Shift + X).
  2. 위쪽 검색창에 C/C++ 를 입력합니다.
  3. Microsoft 가 만든 C/C++ Extension Pack 을 찾아 Install 을 누릅니다. 게시자 이름 옆에 파란 확인 표시가 있는 것이 공식 확장입니다.

VS Code 확장 - C/C++ 검색

VS Code 확장 – C/C++ 검색

Extension Pack 은 여러 확장을 한 번에 설치하는 묶음입니다. build-essential 과 같은 발상이죠. 이 안에 C/C++(문법 이해와 자동 완성), C/C++ Themes(색 테마), CMake Tools(큰 프로젝트용 빌드 도구) 등이 들어 있습니다. 검색 결과에 비슷한 이름의 확장이 많은데, 이름이 비슷해도 다른 사람이 만든 것이 섞여 있으니 게시자를 꼭 확인하세요.

한 가지 짚어 둘 점이 있습니다. 확장은 컴파일러가 아닙니다. 확장은 코드를 읽고 도와줄 뿐, 실제로 실행 파일을 만드는 것은 3절에서 설치한 gcc 입니다. 그래서 이 강좌에서는 VS Code 의 “실행” 버튼 대신 터미널에서 gcc 를 직접 쳐서 컴파일합니다. 버튼 뒤에서 무슨 일이 일어나는지 알아야, 버튼이 안 될 때 고칠 수 있기 때문입니다.

VS Code 안의 터미널

VS Code에도 터미널이 들어 있습니다. 메뉴 Terminal > New Terminal 을 누르거나 Ctrl + ` (숫자 1 왼쪽의 백틱 키)를 누르면 화면 아래에 터미널이 열립니다. 2절에서 쓴 터미널과 똑같은 Bash 이고, 위치는 VS Code에서 연 폴더로 자동으로 맞춰집니다. 코드를 보면서 바로 아래에서 컴파일할 수 있어 편합니다.

이 강좌에서는 VS Code를 기준으로 설명하지만, 어떤 에디터를 써도 괜찮습니다. 결국 저장된 텍스트 파일을 gcc 에 넘기는 것은 똑같기 때문입니다.


5. 첫 C 프로그램: Hello World!

드디어 첫 C 프로그램을 작성할 시간입니다. 1978년에 나온 C 언어의 교과서 『The C Programming Language』가 첫 예제로 화면에 “hello, world”를 찍은 뒤로, 새 언어를 배울 때 첫 프로그램은 거의 항상 이것입니다. 단 다섯 줄이지만, 이 다섯 줄 안에 C 프로그램의 뼈대가 전부 들어 있습니다. 그래서 이 절에서는 다섯 줄을 한 글자도 대충 넘어가지 않고 봅니다.

작업 폴더 만들기

먼저 이번 강좌의 파일을 모아 둘 폴더를 만듭니다. 2절에서 배운 명령만으로 충분합니다.

$ cd ~
$ mkdir -p c_programming/week01
$ cd c_programming/week01
$ pwd
/home/user/c_programming/week01

한 줄씩 짚어 봅시다.

  1. cd ~ 로 홈 폴더에서 시작합니다. 어디에 있었든 기준점을 맞추는 습관입니다.
  2. mkdir -p 로 c_programming 과 그 안의 week01 을 한 번에 만듭니다. -p 가 없으면 c_programming 이 아직 없어서 실패한다는 것을 2절에서 봤습니다.
  3. cd 로 들어가고, pwd 로 제대로 들어왔는지 확인합니다. 프롬프트도 ~/c_programming/week01$ 로 바뀌었을 겁니다.

이제 이 폴더를 VS Code 로 엽니다.

$ code .

hello.c 작성하기

VS Code 왼쪽의 탐색기(Explorer)에서 새 파일 아이콘을 누르고 이름을 hello.c 로 짓습니다. 또는 Ctrl + N 으로 새 파일을 만든 뒤 Ctrl + S 로 저장하면서 이름을 hello.c 로 지정해도 됩니다.

이름 끝의 .c 는 꼭 붙여야 합니다. 리눅스 자체는 확장자를 신경 쓰지 않지만(2절에서 실행 파일은 x 권한으로 정해진다고 했죠), 컴파일러 gcc 는 확장자를 보고 어떤 언어인지 판단합니다. .c 면 C 코드, .o 면 이미 컴파일된 조각, 모르는 확장자면 “링커용 설명서”로 취급합니다. 만약 hello.txt 로 저장하고 컴파일하면 이런 황당한 오류가 납니다.

$ gcc hello.txt -o hello
/usr/bin/ld:hello.txt: file format not recognized; treating as linker script
/usr/bin/ld:hello.txt:3: syntax error
collect2: error: ld returned 1 exit status

“파일 형식을 모르겠으니 링커 스크립트로 읽어 봤는데 3번째 줄이 문법 오류”라는 뜻입니다. C 코드라고 말해 준 적이 없으니 당연합니다. 그러니 C 파일은 항상 .c 로 끝나게 이름 지으세요.

VS Code 도 확장자를 봅니다. .c 로 저장하는 순간 코드에 색이 입혀지고, 오른쪽 아래 상태 표시줄에 C 가 표시됩니다.

이제 다음 코드를 입력합니다. 복사해서 붙여 넣지 말고 직접 치세요. 괄호와 세미콜론을 손으로 쳐 봐야 어디에 무엇이 들어가는지 몸에 남습니다.

#include <stdio.h>

int main(void) {
    printf("Hello, World!\n");
    return 0;
}

VS Code 에서 hello.c

VS Code 에서 hello.c

화면에서는 파일 이름을 main.c 로 했습니다. 이름은 자유이고 본문은 hello.c 로 갑니다.

스크린샷을 자세히 보면 아래 터미널의 결과가 hello world!user@ubuntu-c-study:...$ 처럼 출력과 프롬프트가 한 줄에 붙어 있습니다. 코드에서 \n 을 빼먹었기 때문입니다. 무슨 뜻인지는 곧 알게 됩니다. 이렇게 작은 차이가 화면에 그대로 드러나는 것을 직접 보는 것이 이 절의 목표입니다.

한 줄씩 뜯어보기

코드를 입력했으면 바로 컴파일하고 싶겠지만, 먼저 이 다섯 줄이 각각 무슨 뜻인지 봅시다. 뜻을 알고 치는 코드와 모르고 베끼는 코드는 오류가 났을 때 차이가 큽니다.

첫째 줄: #include <stdio.h>

#include <stdio.h>

“stdio.h 파일의 내용을 이 자리에 그대로 붙여 넣어라” 는 지시입니다. 정말로 복사해서 붙여 넣습니다. 비유가 아닙니다.

  • # 로 시작하는 줄은 C 코드가 아니라 전처리기(preprocessor) 에게 주는 지시입니다. 전처리기는 컴파일러가 코드를 읽기 전에 먼저 원고를 손질하는 단계입니다. 그래서 이 줄은 끝에 세미콜론(;)을 붙이지 않습니다. C 문장이 아니기 때문입니다.
  • include 는 “포함하라”입니다.
  • <stdio.h> 는 포함할 파일입니다. standard input/output, “표준 입출력”의 줄임말이고, .h 는 header(머리글) 파일이라는 뜻입니다. 꺾쇠 < > 로 감싸면 “시스템이 정해 둔 폴더에서 찾아라”라는 뜻인데, 그 폴더가 바로 3절에서 확인한 /usr/include 입니다.

그럼 stdio.h 안에는 무엇이 있길래 붙여 넣어야 할까요? 3절에서 less 로 열어 봤을 때 이런 줄이 있었습니다.

extern int printf (const char *__restrict __format, ...);

이것을 선언(declaration) 이라고 합니다. “printf 라는 함수가 어딘가에 있다. 이런 모양으로 쓰면 된다”라고 컴파일러에게 알려 주는 안내문입니다. printf 의 실제 몸통(어떻게 글자를 찍는지)은 여기 없습니다. 몸통은 이미 컴파일된 상태로 C 라이브러리 안에 들어 있고, 나중에 합쳐집니다(7절에서 직접 확인합니다).

비유하면 이렇습니다. 컴파일러는 아주 꼼꼼한 교정자라서, 처음 보는 단어가 나오면 “이게 뭐지?” 하고 멈춥니다. #include <stdio.h> 는 원고 맨 앞에 용어 사전을 붙여 두는 것입니다. 사전에 printf 가 있으니 교정자는 “아, 이런 함수구나” 하고 넘어갑니다.

이 줄을 빼면 어떻게 될까요? 10절에서 직접 빼 보고 컴파일러가 무슨 말을 하는지 봅니다.

빈 줄

둘째 줄은 비어 있습니다. 컴파일러는 빈 줄과 띄어쓰기를 거의 신경 쓰지 않습니다. 사람이 읽기 편하라고 넣은 것입니다. 전처리 지시와 실제 코드를 한 줄 띄워 구분하는 것이 관례입니다.

셋째 줄: int main(void) {

int main(void) {

main 이라는 이름의 함수를 만든다는 선언입니다. 앞에서부터 읽어 봅시다.

  • int: 이 함수가 일을 마치고 정수(integer) 하나를 돌려준다는 뜻입니다. 누구에게 돌려줄까요? 곧 나옵니다.
  • main: 함수 이름입니다. 이름은 원래 마음대로 지을 수 있지만, main 은 특별합니다. 프로그램을 실행하면 운영체제가 제일 먼저 부르는 함수가 main 으로 정해져 있습니다. 그래서 모든 C 프로그램에는 main 이 정확히 하나 있어야 합니다. 없으면? 10절에서 main 을 mian 으로 잘못 쳐 보고 확인합니다.
  • ( ): 괄호 안에는 이 함수가 받는 값(매개변수)을 적습니다.
  • void: “비어 있음”이라는 뜻입니다. main 이 아무것도 받지 않는다는 표시입니다. int main() 처럼 괄호를 비워 둔 코드도 많이 보게 되는데, C에서는 void 를 명시하는 쪽이 “받는 게 없다”를 정확하게 말하는 방법입니다. 나중에 명령줄에서 값을 받는 int main(int argc, char *argv[]) 형태도 배웁니다.
  • {: 함수의 몸통이 여기서 시작한다는 표시입니다. 짝이 되는 } 까지가 main 이 하는 일입니다.

“함수”라는 말이 낯설다면 지금은 이름이 붙은 작업 묶음 정도로 생각하세요. 5주차에 함수를 직접 만들면서 제대로 다룹니다.

넷째 줄: printf("Hello, World!\n");

    printf("Hello, World!\n");

드디어 실제로 일을 하는 줄입니다. printf 함수를 불러서 Hello, World! 와 줄바꿈을 화면에 찍어라는 뜻입니다.

  • 앞의 공백 네 칸: 들여쓰기입니다. main 의 { } 안에 있는 코드라는 것을 눈으로 보여 줍니다. 컴파일러는 무시하지만, 사람에게는 구조를 보여 주는 중요한 신호입니다(11절에서 다시 다룹니다).
  • printf: print formatted, “형식을 갖춰 출력하라”는 이름입니다. 형식이 무슨 뜻인지는 2주차에 %d 같은 서식 지정자를 배우면서 알게 됩니다.
  • ( ): 함수를 부를(호출할) 때 괄호 안에 넘길 값을 적습니다. main(void) 의 괄호는 “받을 것”이고, 여기의 괄호는 “줄 것”입니다.
  • "Hello, World!\n": 큰따옴표로 감싼 글자들을 문자열(string) 이라고 합니다. 따옴표 자체는 출력되지 않고, “여기부터 여기까지가 글자 데이터”라는 경계 표시입니다. C에서 문자열은 반드시 큰따옴표입니다. 작은따옴표는 글자 하나('A')에만 씁니다.
  • \n: 백슬래시와 n, 두 글자처럼 보이지만 줄바꿈 문자 하나를 뜻합니다(new line). 키보드의 Enter 를 문자열 안에 넣을 방법이 없으니, \ 로 시작하는 약속된 표기를 씁니다. 이런 표기를 이스케이프 시퀀스 라고 하며 6절에서 여러 개를 봅니다.
  • ;: 문장이 끝났다는 표시입니다. 우리말의 마침표와 같습니다. C 컴파일러는 줄바꿈이 아니라 세미콜론으로 문장의 끝을 판단합니다. 그래서 세미콜론을 빼먹으면 컴파일러는 다음 줄까지 한 문장으로 읽다가 헷갈려 버립니다(10절).

다섯째 줄: return 0;

    return 0;

main 함수를 끝내고, 0이라는 값을 돌려준다는 뜻입니다. 셋째 줄의 int 가 “정수를 돌려준다”고 약속했죠. 그 약속을 지키는 줄입니다.

그럼 이 0은 누가 받을까요? main 을 부른 쪽, 즉 운영체제와 우리 프로그램을 실행한 쉘이 받습니다. 이 값을 종료 코드(exit code) 라고 하고, 관례상 0은 “문제없이 끝났다”, 0이 아닌 값은 “뭔가 잘못됐다” 를 뜻합니다. 실행해 본 뒤에 이 값을 눈으로 확인해 보겠습니다.

여섯째 줄: }

main 함수의 몸통이 여기서 끝납니다. 셋째 줄의 { 와 짝입니다. 괄호는 반드시 짝이 맞아야 합니다. VS Code 에서 { 옆에 커서를 두면 짝이 되는 } 가 강조 표시되니, 괄호가 많아지면 이 기능을 활용하세요.

GCC로 컴파일하기

이제 컴파일할 차례입니다. 그런데 컴파일이 왜 필요할까요?

CPU는 C 코드를 읽을 줄 모릅니다. CPU가 알아듣는 것은 55 48 89 e5 같은 숫자로 된 기계어뿐입니다. 그래서 사람이 쓴 C 코드를 기계어로 번역해 줄 번역가가 필요하고, 그것이 컴파일러입니다. 우리가 쓰는 컴파일러가 GCC(GNU Compiler Collection)입니다. 번역은 한 번만 하면 됩니다. 한 번 만든 기계어 파일은 몇 번이고 바로 실행할 수 있습니다.

VS Code 에서 Ctrl + ` 로 터미널을 열고(위치가 week01 폴더인지 프롬프트로 확인하세요), 다음을 입력합니다.

$ gcc hello.c
$

아무 메시지 없이 프롬프트가 다시 나타났습니다. 실패한 걸까요? 아닙니다. 성공입니다. 리눅스 명령들은 “성공하면 조용히, 문제가 있을 때만 말한다”는 전통을 따릅니다. 흔히 “무소식이 희소식”이라고 부르는 원칙입니다. 2절의 mkdir, cp, rm 도 모두 그랬습니다.

말없이 성공했다면 무언가 생겼어야 합니다. 확인해 봅시다.

$ ls -l
합계 20
-rwxrwxr-x 1 user user 15960  9월 24 10:01 a.out
-rw-rw-r-- 1 user user    84  9월 24 10:01 hello.c

a.out 이라는 새 파일이 생겼습니다. 이것이 실행 파일입니다. 2절에서 배운 대로 읽어 봅시다.

  • 권한이 rwxrwxr-x 입니다. hello.c 에는 없던 x(실행 권한) 가 붙어 있습니다. gcc 가 “이건 실행하는 파일”이라며 알아서 붙여 준 것입니다.
  • 크기가 15,960바이트입니다. 84바이트짜리 소스가 약 190배로 커졌습니다. 우리가 쓴 건 다섯 줄인데 무엇이 이렇게 많이 들어갔을까요? 이 수수께끼는 7절에서 풉니다.

이름이 왜 a.out 일까요? assembler output, “어셈블러의 출력물”의 줄임말로, 1970년대 초기 유닉스 시절부터 이어진 기본 이름입니다. 출력 파일 이름을 따로 말해 주지 않으면 GCC는 50년째 이 이름을 씁니다.

프로그램 실행하기

실행 파일이 생겼으니 실행해 봅시다. 이름을 치면 되겠죠?

$ a.out
a.out: 명령을 찾을 수 없습니다

안 됩니다! 파일이 바로 여기 있는데 왜 못 찾을까요?

2절에서 배운 PATH 를 떠올려 보세요. 쉘은 명령을 받으면 PATH에 적힌 폴더들만 뒤집니다. /usr/bin, /bin 같은 곳이죠. 지금 있는 폴더는 PATH에 없습니다. 그래서 쉘은 a.out 을 끝내 찾지 못한 것입니다.

해결책은 “PATH에서 찾지 말고, 바로 여기 있는 a.out 을 실행하라”고 위치를 직접 알려 주는 것입니다.

$ ./a.out
Hello, World!

축하합니다! 여러분의 첫 C 프로그램이 실행되었습니다.

./a.out 은 2절의 기호 그대로 읽으면 됩니다. . 은 “지금 폴더”, / 는 구분자. 그러니 “지금 폴더에 있는 a.out”입니다. 이름에 / 가 들어 있으면 쉘은 PATH를 뒤지지 않고 그 경로를 바로 실행합니다.

왜 리눅스는 지금 폴더를 PATH에 넣지 않을까요? 불편하지만 이유가 있습니다. 만약 지금 폴더가 PATH에 들어 있다면, 누군가 여러분이 다운로드한 폴더에 ls 라는 이름의 악성 프로그램을 넣어 둘 수 있습니다. 그 폴더에서 무심코 ls 를 치면 진짜 ls 대신 그 프로그램이 실행됩니다. ./ 를 붙이도록 강제하면, “내가 지금 이 폴더의 프로그램을 실행한다”는 것을 항상 의식하게 됩니다.

종료 코드 확인하기 — return 0 은 어디로 갔을까

앞에서 return 0; 의 0은 쉘이 받는다고 했습니다. 정말 받았는지 확인해 봅시다. 쉘은 방금 끝난 프로그램의 종료 코드를 $? 라는 특별한 변수에 넣어 둡니다.

$ ./a.out
Hello, World!
$ echo $?
0

0이 나왔습니다. 우리가 return 0; 으로 돌려준 바로 그 값입니다.

정말 그 값인지 의심스럽다면 바꿔 보면 됩니다. hello.c 의 return 0; 을 return 42; 로 고치고 저장한 뒤, 다시 컴파일하고 실행해 봅시다.

$ gcc hello.c
$ ./a.out
Hello, World!
$ echo $?
42

42가 나옵니다. 우리 코드의 숫자가 쉘까지 전달된 것이 확인됐습니다. 확인했으면 다시 return 0; 으로 되돌려 두세요.

실패한 명령의 종료 코드도 봅시다.

$ ls 없는파일
ls: '없는파일'에 접근할 수 없음: 그런 파일이나 디렉터리가 없습니다
$ echo $?
2

ls 도 C로 만든 프로그램이고, 실패하면 0이 아닌 값을 돌려줍니다. 리눅스의 모든 명령이 이 약속을 지킵니다.

이 약속이 왜 중요할까요? 쉘이 성공과 실패를 보고 다음 행동을 정할 수 있기 때문입니다. 예를 들어 && 는 “앞 명령이 성공(0)했을 때만 뒤 명령을 실행하라”는 뜻입니다.

$ gcc hello.c -o hello && ./hello
Hello, World!

컴파일이 성공하면 곧바로 실행하고, 컴파일이 실패하면 실행하지 않습니다. 앞으로 컴파일하고 실행하기를 수백 번 반복할 텐데, 이 한 줄이 아주 편합니다. ↑ 키로 불러와서 Enter 만 누르면 되니까요.

main 에서 return 0; 을 빼면? 직접 해 보세요. 경고도 없이 컴파일되고, echo $? 도 0이 나옵니다. C99 표준부터 main 함수만은 끝의 } 에 도달하면 자동으로 0을 돌려주도록 정해졌기 때문입니다. 하지만 다른 함수에서는 이 특례가 없고(10절에서 봅니다), “성공하면 0을 돌려준다”는 의도를 코드에 드러내는 것이 좋으니 이 강좌에서는 항상 return 0; 을 씁니다.

\n 을 빼면 어떻게 될까

넷째 줄의 \n 이 정말 줄바꿈인지도 실험해 봅시다. printf("Hello, World!\n"); 에서 \n 을 지우고 저장한 뒤 다시 컴파일하고 실행합니다.

$ gcc hello.c -o hello && ./hello
Hello, World!user@ubuntu-c-study:~/c_programming/week01$

프로그램의 출력 바로 뒤에 프롬프트가 붙어 버렸습니다. 앞의 VS Code 스크린샷과 같은 모습입니다. 프로그램이 줄을 바꾸지 않고 끝났으니, 쉘은 커서가 있던 자리에 그대로 프롬프트를 찍은 것입니다. printf 는 우리가 준 글자만 정확히 출력하고, 알아서 줄을 바꿔 주지 않는다는 것을 알 수 있습니다.

확인했으면 \n 을 다시 넣어 두세요.

의미 있는 이름으로 컴파일하기 — -o 옵션

프로그램마다 a.out 이 생기면, 두 번째 프로그램을 컴파일하는 순간 첫 번째 a.out 이 덮어써집니다. 그래서 실행 파일에 이름을 붙여 줍니다.

$ gcc hello.c -o hello
$ ls -l
합계 36
-rwxrwxr-x 1 user user 15960  9월 24 10:01 a.out
-rwxrwxr-x 1 user user 15960  9월 24 10:01 hello
-rw-rw-r-- 1 user user    84  9월 24 10:01 hello.c
$ ./hello
Hello, World!

-o 는 output, “출력 파일 이름”입니다. -o 바로 뒤에 오는 단어가 출력 파일 이름이 됩니다. 옵션과 파일 이름의 순서는 자유라서, gcc -o hello hello.c 라고 써도 똑같습니다.

리눅스 관례에 따라 실행 파일에는 확장자를 붙이지 않습니다. 윈도우처럼 hello.exe 로 지어도 동작은 하지만, 리눅스에서는 x 권한이 실행 여부를 정하므로 확장자가 필요 없습니다.

a.out 은 이제 필요 없으니 지워도 됩니다.

$ rm a.out

절대 하지 말아야 할 실수: -o 뒤에 소스 파일 이름 쓰기. “-o 바로 뒤가 출력 파일”이라는 규칙을 잊고, 순서를 헷갈려 이렇게 치는 일이 있습니다.

$ gcc -o hello.c hello
/usr/bin/ld: cannot use executable file 'hello' as input to a link
collect2: error: ld returned 1 exit status
$ ls hello.c
ls: 'hello.c'에 접근할 수 없음: 그런 파일이나 디렉터리가 없습니다

컴파일은 실패했는데 hello.c 가 사라졌습니다. GCC는 -o 바로 뒤의 hello.c 를 “만들어 낼 출력 파일”로, hello 를 입력으로 받아들였습니다. 입력이 실행 파일이라 링크가 실패했고, GCC는 실패한 작업의 출력 파일을 치우는 규칙대로 hello.c 를 지웠습니다. 우리 소스 코드를요. 실제로 이 글을 쓰면서 확인한 결과입니다. rm 과 마찬가지로 휴지통도 없습니다.

그래서 이 강좌에서는 항상 gcc 소스.c -o 실행파일 순서로 씁니다. 소스가 먼저, -o 와 출력 이름이 뒤. 이 순서를 지키면 -o 바로 뒤에 .c 파일이 올 일이 없습니다.

컴파일 옵션 이해하기

GCC에는 옵션이 수백 개 있습니다. 그중 이 강좌에서 매번 쓸 네 가지를 하나씩, 옵션이 있을 때와 없을 때의 차이를 직접 보면서 익혀 봅시다.

1) -Wall — 경고를 켜라

먼저 일부러 실수가 들어간 코드를 준비합니다. warn.c 로 저장하세요.

#include <stdio.h>

int main(void) {
    int x;
    printf("x = %d\n", x);
    return 0;
}

2주차 내용을 조금 앞당겼습니다. int x; 는 “정수를 담을 상자 x 를 만든다”는 뜻이고, printf 의 %d 는 “여기에 정수를 끼워 넣어 출력하라”는 뜻입니다. 문제는 x 에 아무 값도 넣지 않고 출력한다는 것입니다. 무엇이 나올지 아무도 모릅니다.

옵션 없이 컴파일해 봅시다.

$ gcc warn.c -o warn
$

아무 말도 없습니다. GCC는 이 코드를 문제없는 코드로 통과시켰습니다. 이제 -Wall 을 붙여 봅니다.

$ gcc -Wall warn.c -o warn
warn.c: In function ‘main’:
warn.c:5:5: warning: ‘x’ is used uninitialized [-Wuninitialized]
    5 |     printf("x = %d\n", x);
      |     ^~~~~~~~~~~~~~~~~~~~~
warn.c:4:9: note: ‘x’ was declared here
    4 |     int x;
      |         ^

“x 가 초기화되지 않은 채 쓰였다. x 는 4번째 줄에서 선언됐다”고 정확히 짚어 줍니다. 같은 컴파일러, 같은 코드인데 옵션 하나로 실수를 잡아낸 것입니다.

-Wall 은 Warning all, “경고를 다 켜라”라는 이름입니다. 그런데 이름과 달리 모든 경고는 아니고, “대부분의 사람이 켜 두는 게 좋다고 동의하는 경고 묶음”입니다. 기본값으로는 이런 경고가 대부분 꺼져 있습니다. 오래된 코드들이 경고를 쏟아 내지 않도록 하려는 역사적 이유 때문입니다.

경고(warning)와 오류(error)의 차이도 여기서 짚고 가겠습니다.

  • 오류(error): 문법이 틀려서 번역을 할 수 없습니다. 실행 파일이 만들어지지 않습니다.
  • 경고(warning): 번역은 할 수 있지만 아마 여러분이 의도한 게 아닐 것이라는 뜻입니다. 실행 파일은 만들어집니다.

위의 warn 도 경고만 났으니 실행 파일이 생겼습니다. 하지만 실행하면 쓰레기 값이 찍힐 수 있습니다. 경고는 “지금은 돌아가지만 언젠가 반드시 사고가 날 곳”을 미리 알려 주는 것입니다. 그래서 경고를 오류처럼 대하는 습관이 중요합니다.

2) -Wextra — 경고를 더 켜라

-Wall 에 포함되지 않은 추가 경고 묶음입니다. 예를 하나 봅시다. extra.c:

#include <stdio.h>

int main(int argc, char *argv[]) {
    printf("hi\n");
    return 0;
}

main 이 값 두 개(argc, argv)를 받도록 적어 놓고 한 번도 쓰지 않았습니다.

$ gcc -Wall extra.c -o extra
$ gcc -Wall -Wextra extra.c -o extra
extra.c: In function ‘main’:
extra.c:3:14: warning: unused parameter ‘argc’ [-Wunused-parameter]
    3 | int main(int argc, char *argv[]) {
      |          ~~~~^~~~
extra.c:3:26: warning: unused parameter ‘argv’ [-Wunused-parameter]
    3 | int main(int argc, char *argv[]) {
      |                    ~~~~~~^~~~~~

-Wall 만으로는 조용했지만, -Wextra 를 더하니 “받아 놓고 안 쓴 매개변수가 있다”고 알려 줍니다. 받은 값을 안 쓴다는 건, 쓰려고 했는데 까먹었을 가능성이 높다는 신호입니다.

경고 메시지 끝의 대괄호 [-Wunused-parameter] 를 보세요. 어떤 옵션 때문에 이 경고가 나왔는지 알려 주는 이름표입니다. 모르는 경고가 나오면 이 이름으로 검색하면 됩니다.

3) -std=c11 — C 언어의 어느 판을 쓸지 정하기

C 언어는 한 번 만들어지고 끝난 게 아니라, 여러 번 개정판이 나왔습니다.

표준 나온 해 주요 변화
C89 / C90 1989 / 1990 첫 공식 표준. “ANSI C” 라고도 부릅니다
C99 1999 // 한 줄 주석, for (int i = 0; ...) 처럼 변수를 쓸 곳에서 선언하기 등
C11 2011 멀티스레드 지원 등. 이 강좌의 기준
C17 2018 C11의 오류 수정판. 새 기능은 거의 없습니다
C23 2024 0b1010 같은 2진수 표기, true/false 키워드 등

-std=c11 은 “C11 규칙으로 번역하라”는 뜻입니다. 옵션을 안 주면 무엇이 기본일까요? GCC에게 직접 물어볼 수 있습니다.

$ echo | gcc -dM -E - | grep __STDC_VERSION__
#define __STDC_VERSION__ 201710L
$ echo | gcc -std=c11 -dM -E - | grep __STDC_VERSION__
#define __STDC_VERSION__ 201112L

명령이 복잡해 보이지만 요지는 이렇습니다. 빈 입력(echo |)을 GCC에 주고 “미리 정의된 값들을 보여 달라”(-dM -E)고 한 뒤, 표준 버전을 뜻하는 줄만 골라낸(grep) 것입니다. 기본값은 201710, 즉 2017년판(C17)이고, -std=c11 을 주면 201112, 2011년 12월판으로 바뀝니다.

정확히는 GCC 13의 기본값은 gnu17 입니다. C17에 GCC만의 확장 기능을 더한 것입니다. 편리한 확장이 많지만, 그것에 익숙해지면 다른 컴파일러로 옮겼을 때 코드가 안 돌아갑니다. 이 강좌는 표준 C를 배우는 것이 목적이므로 -std=c11 로 판을 고정합니다.

표준 밖의 기능을 쓰면 알려 주는 -Wpedantic 도 있습니다. 예를 들어 2진수를 0b1010 으로 쓰는 것은 C23에 와서야 표준이 됐고, 그 전에는 GCC 확장이었습니다.

#include <stdio.h>

int main(void) {
    int n = 0b1010;
    printf("%d\n", n);
    return 0;
}
$ gcc -std=c11 -Wall pedantic.c -o pedantic
$ gcc -std=c11 -Wpedantic pedantic.c -o pedantic
pedantic.c: In function ‘main’:
pedantic.c:4:13: warning: binary constants are a C2X feature or GCC extension
    4 |     int n = 0b1010;
      |             ^~~~~~

-std=c11 만으로는 조용히 넘어가지만, -Wpedantic 을 주면 “이건 C11 표준이 아니다(C23 기능이거나 GCC 확장이다)”라고 알려 줍니다. pedantic 은 “현학적인, 까다롭게 따지는”이라는 뜻입니다. 이름 그대로 표준 문서를 한 글자씩 따집니다.

4) -g — 디버그 정보 넣기

$ gcc hello.c -o hello
$ gcc -g hello.c -o hello_g
$ ls -l hello hello_g
-rwxrwxr-x 1 user user 15960  9월 24 10:01 hello
-rwxrwxr-x 1 user user 17096  9월 24 10:01 hello_g

-g 를 주면 실행 파일이 약 1KB 커집니다. 늘어난 부분에는 “기계어의 이 위치는 hello.c 의 4번째 줄에 해당한다” 같은 소스 코드와 기계어의 대응표가 들어 있습니다. 실행 결과는 똑같지만, 디버거 gdb 가 이 대응표를 보고 “지금 4번째 줄을 실행 중”이라고 알려 줄 수 있게 됩니다. 6주차에 포인터를 디버깅할 때 요긴하게 씁니다.

5) -O — 최적화 (알아만 두기)

-O (대문자 O, Optimize)는 컴파일러에게 “더 빠르게 돌아가도록 머리를 써서 번역하라”고 시키는 옵션입니다. 숫자로 강도를 정합니다.

옵션 뜻
-O0 최적화 안 함. 기본값. 코드를 쓴 그대로 번역해서 디버깅하기 쉽습니다
-O1 가벼운 최적화
-O2 일반적인 배포용 최적화. 대부분의 리눅스 프로그램이 이것으로 만들어집니다
-O3 공격적인 최적화. 항상 더 빠른 것은 아닙니다
-Os 속도보다 크기를 줄이는 쪽으로

Hello World 는 너무 단순해서 최적화해도 달라질 게 거의 없습니다. 23주차 “성능 최적화”에서 같은 프로그램을 -O0 과 -O2 로 만들어 속도를 재 보면서 본격적으로 다룹니다. 지금은 배우는 동안에는 최적화를 켜지 않는다는 것만 기억하세요. 최적화를 켜면 컴파일러가 코드 순서를 바꾸거나 변수를 없애 버려서, 디버거로 볼 때 코드와 실행이 어긋나 보입니다.

-O 는 대문자 알파벳 O 이고, 출력 파일을 정하는 -o 는 소문자입니다. 헷갈리지 마세요.

이 강좌의 기본 컴파일 명령

이제 네 옵션을 모두 알았으니, 앞으로 매번 쓸 명령을 정합니다.

$ gcc -Wall -Wextra -std=c11 -g hello.c -o hello
부분 뜻
gcc GCC 컴파일러를 실행한다
-Wall -Wextra 흔한 실수를 경고로 알려 달라
-std=c11 C11 표준 규칙으로 번역하라
-g 디버거용 정보를 넣어라
hello.c 번역할 소스 파일
-o hello 결과를 hello 라는 이름으로 저장하라

길어 보이지만, 한 번 치고 나면 ↑ 키로 불러와서 파일 이름만 바꾸면 됩니다. 5주차에는 make 로 이 명령을 자동화합니다.

목표: 경고 0개. 이 명령으로 컴파일했을 때 경고가 하나도 나오지 않는 코드를 쓰는 것을 목표로 하세요. 경고가 났다면 실행하기 전에 먼저 읽고 고칩니다. 이 습관 하나로 앞으로 만날 버그의 상당수를 컴파일 단계에서 막을 수 있습니다.

컴파일부터 실행까지

컴파일부터 실행까지

마지막 file hello 는 파일의 정체를 알려 주는 명령입니다. 출력에 나오는 ELF 64-bit ... executable 이 무슨 뜻인지는 7절에서 알아봅니다.


6. 다양한 Hello World 프로그램

Hello World 하나를 완전히 이해했으니, 조금씩 바꿔 가며 printf 와 문자열을 가지고 놀아 봅시다. 예제마다 “이걸 바꾸면 어떻게 될까?” 실험을 하나씩 붙였습니다. 예제를 그대로 따라 친 다음, 실험까지 꼭 해 보세요. 결과를 예상하고 → 실행하고 → 예상과 비교하는 것이 프로그래밍 실력이 느는 가장 빠른 길입니다.

예제 파일은 모두 같은 방법으로 컴파일하고 실행합니다.

$ gcc -Wall -Wextra -std=c11 파일이름.c -o 파일이름 && ./파일이름

예제 1: 여러 줄 출력

hello2.c:

#include <stdio.h>

int main(void) {
    printf("=========================================\n");
    printf("   Welcome to C Programming!\n");
    printf("=========================================\n");
    printf("\n");
    printf("Hello, World!\n");
    printf("This is my first C program.\n");
    printf("I'm learning C on Linux!\n");
    printf("\n");
    printf("Author: Your Name\n");
    printf("Date: October 19, 2024\n");

    return 0;
}
$ gcc -Wall -Wextra -std=c11 hello2.c -o hello2 && ./hello2
=========================================
   Welcome to C Programming!
=========================================

Hello, World!
This is my first C program.
I'm learning C on Linux!

Author: Your Name
Date: October 19, 2024

새로운 문법은 없습니다. printf 를 열 번 불렀을 뿐입니다. 그래도 눈여겨볼 점이 세 가지 있습니다.

  1. 실행 순서: main 안의 문장은 위에서 아래로 한 줄씩 실행됩니다. 출력 순서가 코드 순서와 정확히 같습니다. 당연해 보이지만, 3주차에 이 순서를 바꾸는 방법(조건문, 반복문)을 배우기 전에 기본을 확인해 두는 것입니다.
  2. 빈 줄 출력: printf("\n"); 은 줄바꿈 문자 하나만 출력합니다. 그래서 빈 줄이 생깁니다.
  3. 줄 앞의 공백: " Welcome..." 처럼 문자열 안의 띄어쓰기는 그대로 출력됩니다. 코드의 들여쓰기(printf 앞의 공백)는 무시되지만, 따옴표 안의 공백은 데이터입니다. 이 차이를 구분하세요.

Author 와 Date 줄을 여러분의 이름과 오늘 날짜로 바꿔서 다시 실행해 보세요.

실험: 문자열을 두 줄에 걸쳐 쓸 수 있을까?

문자열이 길어지면 코드에서 줄을 바꾸고 싶어집니다. 이렇게 써 보면 어떨까요?

    printf("Hello,
World!\n");
$ gcc ml.c -o ml
ml.c: In function ‘main’:
ml.c:3:12: error: missing terminating " character
    3 |     printf("Hello,
      |            ^~~~~~~
ml.c:4:1: error: ‘World’ undeclared (first use in this function)
...

오류입니다. “닫는 따옴표가 없다”고 합니다. C에서 문자열 하나는 한 줄 안에서 열고 닫아야 합니다. 코드의 줄바꿈이 문자열 안에 들어갈 수는 없습니다. 그래서 문자열 안의 줄바꿈은 \n 으로 쓰는 것입니다.

그럼 긴 문자열은 어떻게 할까요? C에는 재미있는 규칙이 하나 있습니다. 따옴표로 감싼 문자열 두 개를 나란히 쓰면 컴파일러가 하나로 이어 붙입니다.

    printf("Hello, "
           "World!\n");
$ gcc -Wall cc.c -o cc && ./cc
Hello, World!

"Hello, " 와 "World!\n" 사이에 줄바꿈과 공백이 있어도, 컴파일러는 둘을 "Hello, World!\n" 하나로 합칩니다. 긴 문장을 코드에서 보기 좋게 나눌 때 씁니다.

예제 2: 이스케이프 시퀀스

키보드로 칠 수 없거나, 치면 문법과 충돌하는 글자가 있습니다. 줄바꿈이나 탭은 Enter 와 Tab 을 누르면 코드 자체가 바뀌어 버리고, 큰따옴표는 문자열의 끝으로 읽힙니다. 이런 글자를 문자열에 넣기 위해 \ 로 시작하는 약속된 표기를 씁니다. 이것을 이스케이프 시퀀스(escape sequence) 라고 합니다. escape는 “탈출”이라는 뜻으로, \ 를 만나면 “다음 글자는 평소 의미에서 탈출해서 특별한 뜻으로 읽어라”는 신호입니다.

escape.c:

#include <stdio.h>

int main(void) {
    // 이스케이프 시퀀스 예제

    printf("1. 줄바꿈: 첫 번째 줄\n두 번째 줄\n\n");

    printf("2. 탭: 이름\t나이\t도시\n");
    printf("   홍길동\t25\t서울\n\n");

    printf("3. 백슬래시: C:\\Users\\Documents\\\n\n");

    printf("4. 따옴표: \"안녕하세요\"라고 말했다.\n\n");

    printf("5. 작은따옴표: \'C\' 언어\n\n");

    printf("6. 백스페이스: abc\b\b123\n\n");

    printf("7. 캐리지 리턴: 12345\r99\n\n");

    printf("8. 경고음: \a준비되셨나요?\n");

    return 0;
}

이스케이프 문자

이스케이프 문자

넷째 줄의 // 이스케이프 시퀀스 예제 는 주석입니다. // 부터 그 줄 끝까지는 컴파일러가 무시합니다. 코드를 읽을 사람에게 남기는 메모입니다(11절에서 자세히 다룹니다).

하나씩 봅시다.

표기 이름 하는 일
\n 줄바꿈 (new line) 다음 줄로
\t 탭 (tab) 다음 탭 위치(보통 8칸 단위)로
\\ 백슬래시 \ 글자 하나. \ 자체가 신호라서 두 번 써야 글자가 됩니다
\" 큰따옴표 " 글자. 그냥 쓰면 문자열이 끝나 버립니다
\' 작은따옴표 ' 글자. 문자열 안에서는 \ 없이 써도 됩니다
\b 백스페이스 (backspace) 커서를 한 칸 왼쪽으로
\r 캐리지 리턴 (carriage return) 커서를 줄 맨 앞으로
\a 경고음 (alert) 삑 소리. 터미널 설정에 따라 안 날 수도 있습니다
\0 널 문자 (null) 문자열의 끝 표시. 아래 실험에서 봅니다

3번을 보세요. 윈도우 경로 C:\Users\Documents\ 를 출력하려고 \ 를 전부 두 번씩 썼습니다. 한 번씩만 쓰면 \U, \D 같은 알 수 없는 이스케이프가 돼 버립니다.

6번과 7번이 제일 재미있습니다. 코드만 보고 화면에 무엇이 나올지 먼저 예상해 보세요. 그다음 실행하면 이렇게 나옵니다.

6. 백스페이스: a123
99 캐리지 리턴: 12345

6번은 abc 를 쓴 다음 \b 두 번으로 커서를 두 칸 왼쪽(b 위)으로 옮기고, 거기서 123 을 썼습니다. 1, 2 가 b, c 를 덮어쓰고 3 이 이어져서 a123 이 됩니다.

7번은 더 놀랍습니다. \r 은 커서를 줄 맨 앞으로 보냅니다. 그런데 줄 맨 앞에는 12345 가 아니라 제목 7. 이 있었습니다. 그래서 99 가 7. 두 칸을 덮어써 버렸습니다. 우리 눈에는 “7번 줄”이 사라지고 “99”로 시작하는 줄만 남았습니다.

그럼 프로그램이 bc 와 7. 을 정말 지운 걸까요? 프로그램이 실제로 무엇을 내보냈는지 바이트 단위로 들여다보면 알 수 있습니다. 작은 실험 파일 br.c 를 만들어 봅시다.

#include <stdio.h>

int main(void) {
    printf("abc\b\b123\n");
    printf("12345\r99\n");
    return 0;
}

od -c 는 출력을 글자 하나하나로 쪼개서 보여 주는 명령입니다. | 로 프로그램의 출력을 od 에 넘깁니다.

$ ./br | od -c
0000000   a   b   c  \b  \b   1   2   3  \n   1   2   3   4   5  \r   9
0000020   9  \n

a b c \b \b 1 2 3 이 그대로 다 있습니다. 지워진 글자는 하나도 없습니다. \b 와 \r 은 글자를 지우는 명령이 아니라 “커서를 옮겨라”라는 신호이고, 그 신호를 받아 화면에서 덮어쓰는 것은 터미널입니다. 그래서 같은 출력을 파일로 저장하면 원래 글자가 전부 남아 있습니다. 화면에 보이는 것과 프로그램이 내보낸 것이 다를 수 있다는 것, 이것이 이 실험의 교훈입니다.

\r 은 실무에서 “진행률 50%… 51%… 52%”처럼 같은 줄을 계속 고쳐 쓰는 표시를 만들 때 씁니다. 줄 맨 앞으로 돌아가서 새 숫자로 덮어쓰는 것이죠.

캐리지 리턴이라는 이름: 타자기에서 종이를 끼운 틀(캐리지)을 오른쪽 끝으로 되돌려(리턴) 줄 맨 앞부터 다시 치게 하는 동작에서 왔습니다. 줄바꿈(\n)은 종이를 한 줄 올리는 것이고요. 윈도우가 줄 끝에 \r\n 두 글자를 쓰는 것도 이 타자기 시절의 흔적입니다. 리눅스는 \n 하나만 씁니다.

실험: \t 는 몇 칸일까?

탭은 “공백 몇 칸”이 아니라 “다음 탭 정지 위치까지” 입니다. 보통 8칸마다(0, 8, 16, 24번째 칸…) 정지 위치가 있습니다. tab.c 로 확인해 봅시다.

#include <stdio.h>

int main(void) {
    printf("a\tb\n");
    printf("abcdefghij\tk\n");
    return 0;
}
$ ./tab
a       b
abcdefghij      k

첫 줄의 a 는 1칸을 차지하니, 탭은 다음 정지 위치인 8번째 칸까지 7칸을 띄웁니다. 둘째 줄의 abcdefghij 는 이미 10칸이라 8번 정지 위치를 지나쳤으므로, 그다음 정지 위치인 16번째 칸까지 6칸만 띄웁니다. 같은 \t 인데 띄우는 칸 수가 다릅니다. 그래서 탭으로 표를 맞추면 앞 글자 길이에 따라 줄이 어긋날 수 있습니다. 2주차에 printf 의 폭 지정(%-10s)으로 표를 정확히 맞추는 법을 배웁니다.

실험: \0 뒤에는 무엇이 있을까?

    printf("abc\0def\n");
    printf("[end]\n");
$ gcc -Wall z.c -o z
z.c:3:16: warning: embedded ‘\0’ in format [-Wformat-contains-nul]
$ ./z
abc[end]

def 와 줄바꿈이 통째로 사라졌습니다. C의 문자열은 길이를 따로 기록하지 않습니다. 대신 문자열 끝에 \0 (값이 0인 글자)을 붙여서 “여기서 끝”을 표시합니다. printf 는 글자를 하나씩 출력하다가 \0 을 만나면 멈춥니다. 우리가 중간에 \0 을 넣었으니 거기서 멈춘 것입니다. GCC도 “서식 문자열 안에 \0 이 끼어 있다”고 경고해 줬습니다.

사실 모든 문자열 끝에는 눈에 보이지 않는 \0 이 자동으로 하나 붙어 있습니다. "Hello" 는 5글자처럼 보이지만 실제로는 H e l l o \0, 6바이트입니다. 이 사실은 4주차 문자열 단원의 핵심이고, C에서 가장 유명한 버그들이 이 \0 을 잊어서 생깁니다. 지금은 “C 문자열은 \0 으로 끝난다”는 한 문장만 기억해 두세요.

예제 3: ASCII 아트

ascii_art.c:

#include <stdio.h>

int main(void) {
    printf("\n");
    printf("    /\\_/\\  \n");
    printf("   ( o.o ) \n");
    printf("    > ^ <  \n");
    printf("   /|   |\\ \n");
    printf("  (_|   |_)\n");
    printf("\n");
    printf("  Hello from C!\n");
    printf("\n");

    return 0;
}

ASCII 아트

ASCII 아트

$ gcc -Wall -Wextra -std=c11 ascii_art.c -o ascii_art && ./ascii_art

    /\_/\
   ( o.o )
    > ^ <
   /|   |\
  (_|   |_)

  Hello from C!

코드의 고양이 귀는 /\\_/\\ 인데 화면에는 /\_/\ 로 나옵니다. 예제 2에서 배운 대로 \\ 가 \ 글자 하나가 되기 때문입니다. 그림을 그릴 때 코드와 화면의 모양이 다르다는 점이 헷갈리는 부분입니다.

실험: 백슬래시를 하나만 쓰면?

화면 모양 그대로 printf(" /\_/\ \n"); 라고 써 보면 어떻게 될까요?

$ gcc -Wall bs.c -o bs
bs.c: In function ‘main’:
bs.c:3:27: warning: unknown escape sequence: '\_'
bs.c:3:27: warning: unknown escape sequence: '\040'
$ ./bs
    /_/

“\_ 는 모르는 이스케이프 시퀀스”라는 경고가 나오고, 고양이 귀에서 \ 가 전부 사라졌습니다. 두 번째 경고의 \040 은 \ 뒤에 온 공백을 8진수 번호(040 = 32번 글자 = 공백)로 표시한 것입니다. 컴파일러는 \ 를 신호로 쓰고 버렸고, 뒤의 글자만 남겼습니다. 경고가 나왔는데도 실행 파일은 만들어졌다는 점도 보세요. 이게 5절에서 말한 “지금은 돌아가지만 의도와 다른” 상황입니다. -Wall 을 안 켰다면 이 경고조차 못 봤을 겁니다.

직접 해 보기: 강아지, 집, 로켓 등 자신만의 그림을 그려 보세요. \ 와 " 는 반드시 두 번 쓰거나 \ 를 앞에 붙여야 한다는 것만 기억하면 됩니다.

예제 4: 한글 출력

korean.c:

#include <stdio.h>

int main(void) {
    printf("안녕하세요!\n");
    printf("C 프로그래밍의 세계에 오신 것을 환영합니다.\n");
    printf("\n");
    printf("Hello in different languages:\n");
    printf("한국어: 안녕하세요\n");
    printf("English: Hello\n");
    printf("日本語: こんにちは\n");
    printf("中文: 你好\n");
    printf("Español: Hola\n");
    printf("Français: Bonjour\n");
    printf("Deutsch: Guten Tag\n");
    printf("Русский: Привет\n");

    return 0;
}

한글 출력과 로케일

한글 출력과 로케일

$ gcc -Wall -Wextra -std=c11 korean.c -o korean && ./korean

한글, 일본어, 중국어, 러시아어가 전부 잘 나옵니다. 영어만 있던 1970년대에 태어난 C 언어가 어떻게 한글을 출력할 수 있을까요?

비밀은 UTF-8 이라는 글자 저장 방식에 있습니다. 컴퓨터는 글자를 숫자로 저장합니다. 영어 A 는 숫자 하나(1바이트)면 되지만, 한글은 글자가 1만 개가 넘어서 1바이트(256가지)로는 부족합니다. UTF-8은 영어는 1바이트, 한글은 3바이트로 저장합니다. 직접 확인해 봅시다.

$ printf 'A' | od -An -tx1
 41
$ printf '안' | od -An -tx1
 ec 95 88

od -An -tx1 은 “바이트를 16진수로 보여 달라”는 뜻입니다. A 는 41 한 바이트, 안 은 ec 95 88 세 바이트입니다.

그러니 printf 입장에서 "안녕하세요!" 는 한글 다섯 글자가 아니라 그냥 16개의 바이트입니다(한글 5 × 3 + 느낌표 1). printf 는 그 바이트들을 뜻도 모른 채 그대로 내보내고, 그걸 한글 모양으로 그려 주는 것은 터미널입니다. 에디터가 UTF-8로 저장하고, 터미널이 UTF-8로 읽기로 약속했기 때문에 한글이 제대로 보이는 것입니다.

$ printf '안녕하세요!' | wc -c
16
$ file korean.c
korean.c: C source, Unicode text, UTF-8 text

wc -c 는 바이트 수를 세는 명령입니다. 16바이트가 맞습니다. file 명령은 korean.c 가 UTF-8로 저장된 C 소스라고 알려 줍니다. VS Code 오른쪽 아래 상태 표시줄의 UTF-8 도 같은 뜻입니다.

“한글 한 글자가 3바이트”라는 사실은 4주차에 문자열 길이를 셀 때 다시 문제가 됩니다. "안녕" 의 길이를 C에게 물으면 2가 아니라 6이라고 답하기 때문입니다.

예제 5: 시스템 정보 출력

마지막 예제는 조금 어려워 보이는 문법이 나옵니다. 지금 전부 이해할 필요는 없고, “컴파일러가 컴파일하는 순간의 정보를 프로그램에 새겨 넣을 수 있다” 는 것만 보면 됩니다.

sysinfo.c:

#include <stdio.h>

int main(void) {
    printf("=== System Information ===\n\n");

    printf("Program compiled with:\n");
    printf("  Compiler: GCC\n");

    #ifdef __GNUC__
        printf("  GCC Version: %d.%d.%d\n", __GNUC__, __GNUC_MINOR__, __GNUC_PATCHLEVEL__);
    #endif

    printf("  C Standard: ");
    #if __STDC_VERSION__ >= 201710L
        printf("C17 or later\n");
    #elif __STDC_VERSION__ >= 201112L
        printf("C11\n");
    #elif __STDC_VERSION__ >= 199901L
        printf("C99\n");
    #else
        printf("C89/C90\n");
    #endif

    printf("\nSystem details:\n");
    printf("  Operating System: Linux\n");

    #ifdef __x86_64__
        printf("  Architecture: x86_64 (64-bit)\n");
    #elif __i386__
        printf("  Architecture: x86 (32-bit)\n");
    #elif __aarch64__
        printf("  Architecture: ARM64\n");
    #elif __arm__
        printf("  Architecture: ARM\n");
    #endif

    printf("\nFile information:\n");
    printf("  Source file: %s\n", __FILE__);
    printf("  Compilation date: %s\n", __DATE__);
    printf("  Compilation time: %s\n", __TIME__);

    return 0;
}

시스템 정보

시스템 정보

$ gcc -Wall -Wextra -std=c11 sysinfo.c -o sysinfo && ./sysinfo
=== System Information ===

Program compiled with:
  Compiler: GCC
  GCC Version: 13.3.0
  C Standard: C11

System details:
  Operating System: Linux
  Architecture: x86_64 (64-bit)

File information:
  Source file: sysinfo.c
  Compilation date: Sep 24 2026
  Compilation time: 10:11:35

새로 나온 것을 봅시다.

#ifdef, #if, #elif, #else, #endif 는 #include 처럼 # 으로 시작하니 전처리기 지시입니다. “조건에 맞는 부분만 남기고 나머지는 원고에서 지워라”는 뜻입니다. 이것을 조건부 컴파일이라고 합니다.

  • #ifdef __x86_64__ 는 “__x86_64__ 라는 이름이 정의돼 있으면”입니다. 이 이름은 GCC가 인텔/AMD 64비트 CPU용으로 컴파일할 때 스스로 정의해 두는 이름입니다.
  • #elif 는 else if, “그게 아니고 이것이면”, #endif 는 조건 블록의 끝입니다.

그러니 이 프로그램을 ARM CPU(스마트폰이나 라즈베리파이)에서 컴파일하면, x86_64 줄은 원고에서 지워지고 ARM64 줄만 남습니다. 실행할 때 CPU를 확인하는 게 아니라, 컴파일할 때 이미 결정됩니다.

GCC가 미리 정의해 두는 이름들을 직접 볼 수 있습니다. 5절에서 쓴 명령입니다.

$ echo | gcc -dM -E - | grep -E "__x86_64__|__linux__|__GNUC__ "
#define __GNUC__ 13
#define __x86_64__ 1
#define __linux__ 1

__FILE__, __DATE__, __TIME__ 도 미리 정의된 이름인데, 전처리기가 각각 파일 이름, 컴파일한 날짜와 시각으로 바꿔 넣습니다. 프로그램을 몇 번을 실행해도 시각이 바뀌지 않는 것을 확인해 보세요. 실행 시각이 아니라 컴파일 시각이 프로그램 안에 박혀 있기 때문입니다. 다시 컴파일해야 바뀝니다.

printf 안의 %d, %s 는 “이 자리에 뒤에 준 값을 끼워 넣어라”는 서식 지정자입니다. %d 는 정수(decimal), %s 는 문자열(string)을 끼웁니다. printf(" GCC Version: %d.%d.%d\n", __GNUC__, ...) 는 %d 세 자리에 13, 3, 0을 차례로 끼워 13.3.0 을 만듭니다. printf 의 이름에 formatted 가 붙은 이유가 이것입니다. 2주차의 주제입니다.

실험: -std 를 바꾸면 결과가 바뀔까?

-std=c11 을 빼고 컴파일해 보세요.

$ gcc sysinfo.c -o sysinfo && ./sysinfo | grep Standard
  C Standard: C17 or later

C11 이 C17 로 바뀌었습니다. 5절에서 배운 대로 GCC 13의 기본값은 C17(gnu17)이고, __STDC_VERSION__ 값이 달라지니 #if 가 다른 줄을 골랐습니다. 컴파일 옵션 하나가 프로그램의 내용을 바꿀 수 있다는 것을 눈으로 확인한 셈입니다.

실험: % 를 그냥 쓰면?

printf 가 % 를 “서식 지정자의 시작”으로 쓴다면, % 글자 자체를 출력하고 싶을 땐 어떻게 할까요? 그냥 써 보겠습니다.

    printf("100% sure\n");
$ gcc -Wall pc.c -o pc
pc.c:3:18: warning: ' ' flag used with ‘%s’ gnu_printf format [-Wformat=]
pc.c:3:18: warning: format ‘%s’ expects a matching ‘char *’ argument [-Wformat=]
$ ./pc
100㨭��ure

경고가 나오고, 실행하면 깨진 글자가 출력됩니다. printf 는 % s 를 “여기에 문자열을 끼워라”로 읽었는데, 끼울 값을 주지 않았으니 메모리의 엉뚱한 곳을 문자열로 읽어 버린 것입니다. 실행할 때마다 다른 글자가 나올 수 있고, 운이 나쁘면 프로그램이 죽습니다.

% 글자를 출력하려면 %% 라고 두 번 씁니다. \\ 와 같은 발상입니다.

    printf("100%% sure\n");    // 출력: 100% sure

8절의 계산기 안내 프로그램에서 나머지 (%%) 라고 쓴 곳이 바로 이것입니다.


7. 컴파일 과정 이해하기

5절에서 풀지 못한 수수께끼가 세 개 남아 있습니다.

  1. #include <stdio.h> 가 “파일 내용을 그대로 붙여 넣는다”고 했는데, 정말일까?
  2. 84바이트짜리 소스가 어떻게 15,960바이트짜리 실행 파일이 됐을까?
  3. printf 의 몸통은 stdio.h 에 없다고 했는데, 그럼 어디서 왔을까?

이 절에서는 gcc hello.c -o hello 라는 한 줄 뒤에 숨어 있는 과정을 한 단계씩 멈춰 세우면서 세 수수께끼를 풉니다. 조금 깊이 들어가지만, 이 과정을 한 번 눈으로 보고 나면 앞으로 만날 오류 메시지의 절반은 “아, 그 단계에서 난 오류구나” 하고 읽을 수 있게 됩니다.

gcc 는 사실 감독입니다

먼저 놀라운 사실 하나. gcc 는 혼자서 번역을 다 하지 않습니다. gcc 는 여러 전문가 프로그램을 순서대로 부르는 감독(드라이버) 입니다. -v (verbose, 말 많게) 옵션을 주면 뒤에서 누구를 부르는지 보여 줍니다.

$ gcc -v hello.c -o hello 2>&1 | grep -E "^ " | awk '{print $1}'
/usr/libexec/gcc/x86_64-linux-gnu/13/cc1
as
/usr/libexec/gcc/x86_64-linux-gnu/13/collect2

출력이 아주 길어서 명령을 몇 개 이어 붙여 필요한 줄만 골랐습니다(2>&1 은 오류 출력까지 합쳐서 넘기라는 뜻이고, awk '{print $1}' 은 각 줄의 첫 단어만 출력합니다. 지금은 몰라도 됩니다). 세 프로그램이 차례로 불렸습니다.

순서 프로그램 하는 일
① cc1 전처리 + 컴파일: C 코드를 어셈블리어로 번역. 크기가 30MB나 되는, GCC의 진짜 두뇌입니다
② as 어셈블: 어셈블리어를 기계어 조각(오브젝트 파일)으로
③ collect2 → ld 링크: 조각들과 라이브러리를 이어 붙여 실행 파일로

그림으로 그리면 이렇습니다.

hello.c ──① 전처리──▶ hello.i ──① 컴파일──▶ hello.s ──② 어셈블──▶ hello.o ──③ 링크──▶ hello
 C 코드              C 코드(펼쳐진)        어셈블리어            기계어 조각            실행 파일

평소에는 이 중간 파일들이 임시 폴더에 잠깐 만들어졌다가 사라집니다. gcc 에게 “여기서 멈춰”라고 말하는 옵션이 있어서, 각 단계의 결과물을 직접 볼 수 있습니다.

옵션 멈추는 곳 결과물
-E 전처리까지 .i
-S 컴파일까지 .s
-c 어셈블까지 .o
(없음) 끝까지 실행 파일

실험을 위해 깨끗한 폴더를 하나 만들고 hello.c 를 복사해 둡시다.

$ mkdir -p ~/c_programming/week01/stages
$ cp ~/c_programming/week01/hello.c ~/c_programming/week01/stages/
$ cd ~/c_programming/week01/stages

1단계: 전처리 (Preprocessing) — 원고 손질

$ gcc -E hello.c -o hello.i
$ wc -l hello.c hello.i
   6 hello.c
 819 hello.i

-E 는 전처리만 하고 멈추라는 옵션이고, wc -l 은 줄 수를 세는 명령입니다. 6줄짜리 원고가 819줄이 됐습니다. 첫 번째 수수께끼의 답이 여기 있습니다. #include <stdio.h> 한 줄이 정말로 수백 줄로 바뀌었습니다.

늘어난 내용을 앞에서부터 봅시다.

$ head -12 hello.i
# 0 "hello.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "hello.c"
# 1 "/usr/include/stdio.h" 1 3 4
# 28 "/usr/include/stdio.h" 3 4
# 1 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" 1 3 4
# 33 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" 3 4
# 1 "/usr/include/features.h" 1 3 4
# 394 "/usr/include/features.h" 3 4

head -12 는 파일의 앞 12줄만 보여 줍니다. # 으로 시작하는 이 줄들은 “지금부터 나오는 내용은 어느 파일의 몇 번째 줄에서 왔다” 는 표시(line marker)입니다. # 1 "/usr/include/stdio.h" 는 “여기부터 stdio.h 1번 줄”이라는 뜻이죠. 그런데 stdio.h 를 넣자마자 곧바로 libc-header-start.h, features.h 가 이어집니다. stdio.h 도 첫머리에서 다른 헤더를 #include 하고 있기 때문입니다. 줄줄이 사탕처럼 딸려 온 파일이 몇 개인지 세어 봅시다.

$ grep "^# " hello.i | awk -F'"' '{print $2}' | sort -u | grep "\.h$" | wc -l
26

hello.c 에는 #include 가 딱 한 줄이었지만, 실제로 붙여 넣어진 헤더 파일은 26개입니다.

이 표시 줄들이 왜 필요할까요? 컴파일러가 오류를 알려 줄 때 “819줄짜리 hello.i 의 815번째 줄”이 아니라 “hello.c 의 4번째 줄” 이라고 말하기 위해서입니다. 10절에서 보게 될 오류 메시지의 hello.c:4:5: 같은 위치 정보가 이 표시에서 나옵니다.

이제 less hello.i 로 파일을 열고 /printf 로 검색해 보세요.

extern int printf (const char *__restrict __format, ...);

5절에서 말한 printf 의 선언이 들어 있습니다. 컴파일러는 이 줄을 보고 printf 가 어떤 함수인지 압니다. 그리고 G 를 눌러 파일 맨 끝으로 가면, 드디어 우리가 쓴 코드가 나옵니다.

# 3 "hello.c"
int main(void) {
    printf("Hello, World!\n");
    return 0;
}

#include 줄은 사라졌고, 그 자리에 800줄 넘는 선언들이 들어왔고, 우리 코드는 맨 뒤에 그대로 남았습니다. 전처리기는 C 문법을 이해하는 프로그램이 아닙니다. 파일을 붙여 넣고, 조건에 따라 줄을 지우고(6절 예제 5의 #ifdef), 이름을 바꿔 치기하는(__FILE__) 텍스트 편집 도구입니다. 진짜 번역은 다음 단계부터입니다.

2단계: 컴파일 (Compilation) — C를 어셈블리어로

$ gcc -S hello.c
$ ls
hello.c  hello.i  hello.s

-S 는 어셈블리어까지 번역하고 멈추라는 옵션입니다. -o 를 주지 않으면 알아서 hello.s 라는 이름을 붙입니다. 어셈블리어는 CPU 명령어를 사람이 읽을 수 있는 영어 약어로 적은 언어입니다. 기계어와 거의 일대일로 대응합니다. 중요한 부분만 봅시다.

$ cat hello.s
    .file   "hello.c"
    .text
    .section    .rodata
.LC0:
    .string "Hello, World!"
    .text
    .globl  main
    .type   main, @function
main:
    ...
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    leaq    .LC0(%rip), %rax
    movq    %rax, %rdi
    call    puts@PLT
    movl    $0, %eax
    popq    %rbp
    ret
    ...

어셈블리어를 읽을 줄 몰라도, 우리 코드와 짝을 맞춰 볼 수는 있습니다.

어셈블리 뜻 우리 코드에서
.section .rodata / .string "Hello, World!" 읽기 전용(read-only) 데이터 영역에 문자열을 둔다 "Hello, World!\n"
main: 여기서부터 main 이라는 이름의 코드 int main(void) {
pushq %rbp / movq %rsp, %rbp 함수가 쓸 작업 공간을 준비한다 (눈에 안 보이는 준비 작업)
leaq .LC0(%rip), %rax / movq %rax, %rdi 문자열의 주소를 rdi 에 넣는다 printf( 에 문자열을 넘기는 부분
call puts@PLT puts 함수를 부른다 printf(...)
movl $0, %eax eax 에 0을 넣는다 return 0;
ret 부른 쪽으로 돌아간다 }

두 가지가 눈에 띕니다.

첫째, return 0; 이 movl $0, %eax 가 됐습니다. x86-64 CPU에서는 “함수가 돌려줄 값은 eax 라는 저장 칸에 넣어 둔다”는 약속이 있습니다. 그래서 return 0 은 “eax 에 0을 넣고 돌아가라”로 번역됩니다. 5절에서 echo $? 로 본 0이 바로 이 eax 에서 나온 값입니다. 문자열을 넘길 때 쓴 rdi 도 마찬가지로 “첫 번째로 넘기는 값은 rdi 에”라는 약속입니다. 이런 약속을 호출 규약(calling convention) 이라고 합니다. 7주차에 함수 포인터를 다룰 때 다시 떠올리게 됩니다.

둘째, 이상한 점이 있습니다. 우리는 분명 printf 를 불렀는데 어셈블리에는 call puts 가 있습니다. 그리고 문자열 끝의 \n 도 사라졌습니다. GCC가 몰래 바꿔치기한 것입니다. 출력할 문자열에 %d 같은 서식 지정자가 없고 \n 으로 끝나면, printf("...\n") 는 “문자열을 그대로 출력하고 줄을 바꾼다”는 뜻이 됩니다. 그건 정확히 puts 함수가 하는 일입니다. puts 는 서식을 해석하지 않아서 printf 보다 가볍고, 줄바꿈을 알아서 붙여 줍니다. 그래서 GCC는 결과가 완전히 같은 더 가벼운 함수로 바꿔 부르고, 줄바꿈은 puts 가 붙여 주니 문자열에서 \n 을 뺐습니다. 최적화 옵션을 주지 않아도 이 정도는 해 줍니다. 컴파일러는 우리가 쓴 코드를 글자 그대로가 아니라 뜻대로 번역한다는 좋은 예입니다.

실험: printf("Hello, World!\n"); 를 printf("Hello, World!"); 로(줄바꿈 제거) 바꾸고 gcc -S 를 다시 해 보세요. 이번에는 call printf@PLT 가 나옵니다. puts 는 무조건 줄을 바꾸니, 줄바꿈이 없는 출력은 대신할 수 없기 때문입니다.

3단계: 어셈블 (Assembly) — 기계어 조각 만들기

$ gcc -c hello.c
$ ls -l hello.o
-rw-rw-r-- 1 user user 1504  9월 24 10:01 hello.o
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

-c 는 compile only, 기계어로 만들되 링크는 하지 말라는 옵션입니다. 결과물 .o 는 오브젝트 파일(object file) 이라고 부릅니다. 권한을 보면 x 가 없습니다. 아직 실행할 수 없는 조각이라는 뜻입니다. file 명령도 executable 이 아니라 relocatable(재배치 가능, 즉 아직 위치가 정해지지 않은)이라고 말합니다.

이 파일은 텍스트가 아니라서 cat 으로 열면 깨진 글자만 보입니다. 대신 objdump -d 로 기계어를 어셈블리로 거꾸로 풀어서(disassemble) 볼 수 있습니다.

$ objdump -d hello.o

hello.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <main>:
   0:   f3 0f 1e fa             endbr64
   4:   55                      push   %rbp
   5:   48 89 e5                mov    %rsp,%rbp
   8:   48 8d 05 00 00 00 00    lea    0x0(%rip),%rax        # f <main+0xf>
   f:   48 89 c7                mov    %rax,%rdi
  12:   e8 00 00 00 00          call   17 <main+0x17>
  17:   b8 00 00 00 00          mov    $0x0,%eax
  1c:   5d                      pop    %rbp
  1d:   c3                      ret

가운데 열의 55, 48 89 e5 같은 16진수가 진짜 기계어, CPU가 읽는 바이트입니다. 오른쪽은 objdump 가 사람을 위해 풀어 준 어셈블리입니다. 2단계의 hello.s 와 같은 명령들이죠. main 함수 전체가 겨우 30바이트(0부터 1d 까지)입니다.

그런데 12: 줄을 보세요. call 명령의 기계어가 e8 00 00 00 00 입니다. e8 은 “부르라”는 명령이고 뒤의 네 바이트는 어디로 갈지인데, 전부 0입니다. 8: 줄의 문자열 주소도 00 00 00 00 입니다. 왜 비어 있을까요?

puts 가 어디 있는지 아직 모르기 때문입니다. 오브젝트 파일은 hello.c 하나만 번역한 결과라서, 이 파일 밖에 있는 puts 의 위치를 알 수 없습니다. 그래서 자리만 비워 두고 “여기 나중에 채워 넣을 것”이라고 메모를 남깁니다. 그 메모를 볼 수 있습니다.

$ nm hello.o
0000000000000000 T main
                 U puts

nm 은 오브젝트 파일에 들어 있는 이름표(심볼) 목록을 보여 줍니다.

  • T main: main 은 이 파일 안에 있다(Text, 코드 영역).
  • U puts: puts 는 이 파일에서 쓰지만 정의되지 않았다(Undefined). 누군가 채워 줘야 한다.
$ objdump -r hello.o
...
OFFSET           TYPE              VALUE
000000000000000b R_X86_64_PC32     .rodata-0x0000000000000004
0000000000000013 R_X86_64_PLT32    puts-0x0000000000000004

-r 은 재배치(relocation) 메모를 보여 줍니다. “0x13 위치(바로 e8 다음의 빈칸)에 puts 의 주소를 채워 넣어라”, “0xb 위치에 문자열의 주소를 채워 넣어라”. 이 빈칸을 채우는 것이 마지막 단계의 일입니다.

4단계: 링크 (Linking) — 조각 이어 붙이기

$ gcc hello.o -o hello
$ ./hello
Hello, World!

입력이 .o 파일이면 gcc 는 링크 단계만 합니다. 링커(linker) ld 가 빈칸을 채우는 방법은 이렇습니다.

  1. hello.o 에서 “puts 가 필요하다”는 메모를 읽습니다.
  2. C 표준 라이브러리에서 puts 를 찾습니다. 이 라이브러리 파일이 libc.so.6 입니다.
  3. 프로그램을 시작하는 준비 코드를 붙입니다. 이것이 Scrt1.o 같은 파일인데, 여기 있는 _start 라는 함수가 main 을 부르는 역할을 합니다.
  4. 모든 조각의 위치를 확정하고, 비워 뒀던 주소를 채웁니다.

채워졌는지 확인해 봅시다.

$ objdump -d hello | grep -A9 "<main>:"
0000000000001149 <main>:
    1149:   f3 0f 1e fa             endbr64
    114d:   55                      push   %rbp
    114e:   48 89 e5                mov    %rsp,%rbp
    1151:   48 8d 05 ac 0e 00 00    lea    0xeac(%rip),%rax        # 2004 <_IO_stdin_used+0x4>
    1158:   48 89 c7                mov    %rax,%rdi
    115b:   e8 f0 fe ff ff          call   1050 <puts@plt>
    1160:   b8 00 00 00 00          mov    $0x0,%eax
    1165:   5d                      pop    %rbp
    1166:   c3                      ret

e8 00 00 00 00 이 e8 f0 fe ff ff 로 채워졌고, objdump 도 이제 call 1050 <puts@plt> 라고 목적지를 알려 줍니다. 문자열 주소 자리도 ac 0e 00 00 으로 채워졌습니다. main 자신의 위치도 0번지에서 1149 번지로 정해졌습니다.

nm 으로 실행 파일의 이름표를 보면 조각 하나일 때보다 훨씬 많습니다.

$ nm hello
...
0000000000001060 T _start
0000000000001149 T main
                 U puts@GLIBC_2.2.5
                 U __libc_start_main@GLIBC_2.34
...

_start 가 보이죠. 운영체제가 프로그램을 실행하면 맨 처음 실행되는 것은 main 이 아니라 _start 입니다. _start 가 준비를 마친 뒤 __libc_start_main 을 거쳐 우리 main 을 부르고, main 이 return 0 으로 돌려준 값을 받아 운영체제에 종료 코드로 넘깁니다. 5절에서 “main 은 운영체제가 부른다”고 했던 것의 실제 모습입니다. 10절에서 main 을 mian 으로 잘못 쓰면 바로 이 _start 가 main 을 못 찾아서 오류가 납니다.

그런데 puts 는 여전히 U(정의 안 됨)입니다. 이제 세 번째 수수께끼를 풀 차례입니다.

printf 의 몸통은 어디에? — 동적 링크

$ ldd hello
    linux-vdso.so.1 (0x0000745b1bdf1000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000745b1ba00000)
    /lib64/ld-linux-x86-64.so.2 (0x0000745b1bdf3000)

ldd 는 실행 파일이 실행될 때 필요로 하는 라이브러리 목록을 보여 줍니다. libc.so.6 이 있습니다. puts 와 printf 의 몸통은 이 파일 안에 있습니다.

핵심은 몸통을 실행 파일 안에 복사해 넣지 않았다는 것입니다. 실행 파일에는 “puts 는 libc.so.6 에서 가져다 쓸 것”이라는 메모만 들어 있고, 프로그램을 실행하는 순간 운영체제가 libc.so.6 을 메모리에 올려서 연결해 줍니다. 이것을 동적 링크(dynamic linking) 라고 합니다. .so 는 shared object, “공유 오브젝트”라는 뜻입니다. ls, bash, gcc 등 컴퓨터의 거의 모든 프로그램이 이 파일 하나를 공유합니다.

동적 링크를 쓰지 않으면 어떻게 될까요? -static 옵션으로 필요한 라이브러리 코드를 실행 파일에 전부 복사해 넣을 수 있습니다.

$ gcc -static hello.c -o hello_static
$ ls -l hello hello_static
-rwxrwxr-x 1 user user  15960  9월 24 10:01 hello
-rwxrwxr-x 1 user user 785384  9월 24 10:01 hello_static
$ ldd hello_static
    동적 실행 파일이 아닙니다

785KB, 49배로 커졌습니다. Hello World 하나 찍으려고 printf 가 쓰는 부품들까지 전부 끌려 들어왔기 때문입니다. 만약 모든 프로그램이 이렇게 만들어진다면, 같은 printf 코드가 컴퓨터 안에 수천 벌 복사돼 있을 겁니다. 동적 링크는 이 낭비를 막고, libc 에 보안 패치가 나오면 libc.so.6 파일 하나만 바꿔서 모든 프로그램을 한꺼번에 고칠 수 있게 해 줍니다.

15,960바이트의 정체

이제 두 번째 수수께끼를 풀 수 있습니다. size 명령은 실행 파일 안의 실제 코드와 데이터 크기를 보여 줍니다.

$ size hello.o hello
   text    data     bss     dec     hex filename
    132       0       0     132      84 hello.o
   1376     600       8    1984     7c0 hello
  • hello.o 의 코드(text)는 132바이트입니다. 우리 main 과 문자열 정도입니다.
  • hello 는 1,376바이트로 늘었습니다. _start 같은 시작 준비 코드가 붙었기 때문입니다.
  • 코드와 데이터를 다 합쳐도(dec) 1,984바이트입니다.

그럼 나머지 약 14KB는 무엇일까요? 실행 파일에는 코드 말고도 많은 것이 들어갑니다. 운영체제가 읽을 파일 머리말, 어느 라이브러리가 필요한지 적은 동적 링크 정보, nm 으로 본 이름표 목록, 그리고 메모리에 올릴 때 4KB 단위로 맞추느라 생긴 빈 공간입니다. file 명령이 이 머리말을 읽고 알려 줍니다.

$ file hello
hello: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, ..., for GNU/Linux 3.2.0, not stripped

한 단어씩 읽으면 지금까지 배운 것의 요약입니다.

부분 뜻
ELF 리눅스 실행 파일 형식의 이름. Executable and Linkable Format. 윈도우의 .exe 는 PE 라는 다른 형식이라서 리눅스 실행 파일은 윈도우에서 돌지 않습니다
64-bit 64비트 CPU용
LSB 숫자를 작은 자리부터 저장하는 방식(리틀 엔디언). 위의 e8 f0 fe ff ff 가 거꾸로 적힌 숫자처럼 보이는 이유입니다. 6주차와 8주차에 바이트를 직접 찍어 확인합니다
pie executable 메모리 어디에 올려도 동작하는 실행 파일. 보안을 위해 실행할 때마다 위치를 바꿉니다(24주차)
x86-64 인텔/AMD 64비트 CPU용 기계어
dynamically linked 동적 링크. libc.so.6 을 실행할 때 가져다 씁니다
interpreter /lib64/ld-linux-x86-64.so.2 실행할 때 라이브러리를 연결해 주는 프로그램. 위 ldd 목록 마지막 줄에도 있었습니다
not stripped 이름표 목록이 남아 있음. strip hello 로 지우면 작아지지만 디버깅이 어려워집니다

한 번에 vs 단계별

평소에는 한 줄로 끝냅니다.

$ gcc hello.c -o hello

GCC가 네 단계를 모두 해 주고 중간 파일은 지웁니다. 중간 파일을 남기고 싶다면 -save-temps 옵션을 쓰면 됩니다.

$ gcc -save-temps hello.c -o hello
$ ls
hello  hello.c  hello.i  hello.o  hello.s

그럼 단계를 나누는 것은 언제 쓸모가 있을까요? 파일이 여러 개일 때입니다.

# 각 .c 파일을 따로 .o 로 (3단계까지)
$ gcc -c main.c -o main.o
$ gcc -c calc.c -o calc.o
$ gcc -c print.c -o print.o

# .o 들을 한꺼번에 링크 (4단계)
$ gcc main.o calc.o print.o -o program

calc.c 하나만 고쳤다면 calc.o 만 다시 만들고 링크만 다시 하면 됩니다. 파일이 수천 개인 프로젝트(리눅스 커널은 3만 개가 넘습니다)에서는 이 차이가 몇 시간과 몇 초의 차이가 됩니다. 5주차에 여러 파일로 나눈 프로그램을 직접 만들고, “무엇이 바뀌었는지” 판단해서 필요한 것만 다시 컴파일하는 일을 make 에게 맡기는 법을 배웁니다. 2절의 touch 가 거기서 다시 나옵니다.


8. 첫 실습 프로젝트

지금까지 배운 것은 printf 하나뿐입니다. 하지만 printf 하나로도 제법 그럴듯한 프로그램을 만들 수 있습니다. 세 프로젝트를 만들어 보면서, 이번에는 따라 치는 것에서 한 걸음 나아가 여러분의 내용으로 바꿔 보세요. 남의 코드를 내 것으로 바꿔 보는 순간부터 진짜 프로그래밍이 시작됩니다.

세 프로젝트 모두 ~/c_programming/week01/projects 폴더에서 작업합시다.

$ mkdir -p ~/c_programming/week01/projects
$ cd ~/c_programming/week01/projects
$ code .

프로젝트 1: 자기소개 프로그램

introduce.c:

#include <stdio.h>

int main(void) {
    printf("\n");
    printf("╔════════════════════════════════════════╗\n");
    printf("║         자기소개 프로그램              ║\n");
    printf("╚════════════════════════════════════════╝\n");
    printf("\n");

    printf("이름: 홍길동\n");
    printf("나이: 20세\n");
    printf("전공: 컴퓨터공학과\n");
    printf("학년: 2학년\n");
    printf("\n");

    printf("프로그래밍 경험:\n");
    printf("  - Python: 1년\n");
    printf("  - Java: 6개월\n");
    printf("  - C: 이제 시작!\n");
    printf("\n");

    printf("학습 목표:\n");
    printf("  1. C 언어 문법 완벽 이해\n");
    printf("  2. 시스템 프로그래밍 마스터\n");
    printf("  3. 자료구조 직접 구현\n");
    printf("  4. 리눅스 환경에 능숙해지기\n");
    printf("\n");

    printf("좋아하는 명언:\n");
    printf("  \"프로그래밍은 생각을 코드로 표현하는 예술이다.\"\n");
    printf("\n");

    printf("연락처: hong@example.com\n");
    printf("GitHub: github.com/honggildong\n");
    printf("\n");

    return 0;
}

자기소개 프로그램

자기소개 프로그램

$ gcc -Wall -Wextra -std=c11 introduce.c -o introduce && ./introduce

코드 읽기 포인트

  • 빈 줄로 덩어리 나누기: 코드 안의 빈 줄은 출력과 상관없습니다. “여기까지가 기본 정보, 여기부터 경력”처럼 사람이 읽기 좋게 나눈 것입니다. 출력의 빈 줄은 printf("\n"); 이 만듭니다. 두 종류의 빈 줄을 구분하세요.
  • 명언 줄의 \": 6절에서 배운 이스케이프입니다. 문자열 안에 큰따옴표를 넣으려고 \" 를 썼습니다.
  • 상자 그리기 문자 ╔ ═ ╗ ║ ╚ ╝: 키보드에는 없는 글자지만 UTF-8 문자라서 한글처럼 그대로 쓸 수 있습니다. 한 글자가 3바이트입니다. 직접 입력하기 어려우면 이 코드에서 복사해 쓰세요.

직접 해 보기

  1. 이름, 나이, 전공을 여러분의 정보로 바꾸세요.
  2. “좋아하는 음식”, “취미” 같은 항목을 한두 개 추가하세요.
  3. 상자 안의 제목을 "홍길동의 자기소개" 처럼 바꿔 보세요. 그러면 오른쪽 테두리 ║ 가 들쭉날쭉해질 겁니다. 왜 그럴까요? 다음 프로젝트에서 답을 봅니다.

프로젝트 2: 계산기 안내 프로그램

calculator_intro.c:

#include <stdio.h>

int main(void) {
    printf("\n");
    printf("┌─────────────────────────────────────┐\n");
    printf("│    간단한 계산기 프로그램 v1.0      │\n");
    printf("└─────────────────────────────────────┘\n");
    printf("\n");

    printf("이 프로그램은 다음과 같은 기능을 제공합니다:\n");
    printf("\n");

    printf("  [기본 연산]\n");
    printf("    • 덧셈 (+)\n");
    printf("    • 뺄셈 (-)\n");
    printf("    • 곱셈 (×)\n");
    printf("    • 나눗셈 (÷)\n");
    printf("\n");

    printf("  [고급 연산]\n");
    printf("    • 나머지 (%%)\n");
    printf("    • 거듭제곱\n");
    printf("    • 제곱근\n");
    printf("\n");

    printf("개발 정보:\n");
    printf("  Version: 1.0\n");
    printf("  Language: C\n");
    printf("  Platform: Linux\n");
    printf("  Compiled: %s %s\n", __DATE__, __TIME__);
    printf("\n");

    printf("참고: 실제 계산 기능은 다음 주차에 구현됩니다!\n");
    printf("      지금은 프로그램 구조만 만들어보는 연습입니다.\n");
    printf("\n");

    return 0;
}
$ gcc -Wall -Wextra -std=c11 calculator_intro.c -o calculator_intro && ./calculator_intro

┌─────────────────────────────────────┐
│    간단한 계산기 프로그램 v1.0      │
└─────────────────────────────────────┘

이 프로그램은 다음과 같은 기능을 제공합니다:

  [기본 연산]
    • 덧셈 (+)
    ...
  [고급 연산]
    • 나머지 (%)
    ...
개발 정보:
  Version: 1.0
  Language: C
  Platform: Linux
  Compiled: Sep 24 2026 10:17:37
...

코드 읽기 포인트

  • 나머지 (%%): 6절 마지막 실험에서 본 대로, % 글자를 찍으려면 %% 로 씁니다. 출력에는 % 하나만 나옵니다.
  • printf(" Compiled: %s %s\n", __DATE__, __TIME__);: 이 프로그램에서 유일하게 서식 지정자를 쓰는 줄입니다. %s 두 자리에 컴파일 날짜와 시각이 순서대로 끼워집니다. 6절 예제 5에서 본 미리 정의된 이름들이죠. 이 줄 덕분에 “이 실행 파일이 언제 만들어졌는지”를 프로그램 스스로 말할 수 있습니다.

수수께끼: 상자의 오른쪽 테두리는 왜 어긋날까?

이 예제를 처음 만들었을 때, 가운데 줄은 "│ 간단한 계산기 프로그램 v1.0 │" 이었습니다(v1.0 뒤 공백 5칸). 코드에서 보면 위아래 줄과 길이가 비슷해 보이는데, 실행하면 가운데 줄의 오른쪽 테두리가 한 칸 왼쪽으로 들어가 있었습니다.

┌─────────────────────────────────────┐
│    간단한 계산기 프로그램 v1.0     │
└─────────────────────────────────────┘

원인은 한글이 화면에서 두 칸을 차지한다는 것입니다. 터미널은 영어·숫자·괘선을 한 칸에, 한글·한자를 두 칸에 그립니다. 칸 수를 세어 보면 이렇습니다.

줄 계산 칸 수
위 테두리 ┌ 1 + ─ 37개 + ┐ 1 39
가운데 줄 (고치기 전) │ 1 + 공백 4 + 한글 10자 × 2칸 + 공백 3 + v1.0 4 + 공백 5 + │ 1 38

한 칸이 모자랍니다. 그래서 v1.0 뒤에 공백을 하나 더 넣어 6칸으로 고친 것이 위의 코드입니다.

그런데 VS Code 에디터 화면에서는 이 두 줄이 똑같은 길이처럼 보였을 겁니다. 에디터 글꼴은 한글 폭을 정확히 두 칸으로 그리지 않는 경우가 많기 때문입니다. 코드 화면과 실행 화면이 다를 수 있다는 것을 다시 한번 보여 주는 예입니다. 앞의 자기소개 프로그램에서 제목을 바꿨을 때 테두리가 어긋난 것도 같은 이유입니다. 한글 한 자를 넣으면 공백 두 칸을, 영어 한 자를 넣으면 공백 한 칸을 빼 주면 맞습니다.

이 문제는 이 강좌의 도구를 만들면서도 똑같이 겪었습니다. 한글이 섞인 표를 반듯하게 맞추는 것은 생각보다 어려운 일이고, 30주차 커널 모듈에서 다시 만나게 됩니다.

직접 해 보기

  1. v1.0 을 v2.0 beta 로 바꾸고, 테두리가 맞도록 공백을 조절해 보세요. 영어와 숫자는 한 칸씩이니 늘어난 글자 수만큼 공백을 빼면 됩니다.
  2. 컴파일을 두 번 해 보고, 실행할 때마다 Compiled: 시각이 바뀌는지, 컴파일할 때만 바뀌는지 확인해 보세요.

프로젝트 3: 시작 배너 생성기

banner.c:

#include <stdio.h>

int main(void) {
    // 큰 텍스트로 C 표시
    printf("\n");
    printf("   CCCCCCCCCCCCC\n");
    printf(" CCC::::::::::::C\n");
    printf("CC:::::::::::::::C\n");
    printf("C:::::CCCCCCCC::::C\n");
    printf("C:::::C       CCCCCC\n");
    printf("C:::::C\n");
    printf("C:::::C\n");
    printf("C:::::C\n");
    printf("C:::::C\n");
    printf("C:::::C\n");
    printf("C:::::C       CCCCCC\n");
    printf("C:::::CCCCCCCC::::C\n");
    printf("CC:::::::::::::::C\n");
    printf(" CCC::::::::::::C\n");
    printf("   CCCCCCCCCCCCC\n");
    printf("\n");

    printf("   Programming Language\n");
    printf("\n");

    printf("========================================\n");
    printf("   리눅스 C 프로그래밍 학습 시작!     \n");
    printf("========================================\n");
    printf("\n");

    printf("특징:\n");
    printf("  ✓ 빠른 실행 속도\n");
    printf("  ✓ 하드웨어 직접 제어\n");
    printf("  ✓ 메모리 관리의 완벽한 제어\n");
    printf("  ✓ 운영체제 개발 언어\n");
    printf("  ✓ 임베디드 시스템의 표준\n");
    printf("\n");

    printf("C로 만들어진 것들:\n");
    printf("  • Linux 커널\n");
    printf("  • Windows 커널 (주로 C/C++)\n");
    printf("  • Git\n");
    printf("  • Python 인터프리터\n");
    printf("  • MySQL, PostgreSQL\n");
    printf("  • 대부분의 임베디드 시스템\n");
    printf("\n");

    return 0;
}

배너 생성기

배너 생성기

$ gcc -Wall -Wextra -std=c11 banner.c -o banner && ./banner

새로운 문법은 없지만, 출력 내용 자체가 C 언어 소개입니다. 아래 목록에 있는 것들이 정말 C로 만들어졌는지 확인하는 방법이 있을까요? 있습니다. 7절에서 배운 ldd 를 여러분 컴퓨터의 프로그램에 써 보세요.

$ ldd /usr/bin/git | grep libc
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x...)
$ ldd /usr/bin/python3 | grep libc
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x...)

git 도 python3 도 우리 hello 와 똑같은 libc.so.6 을 쓰고 있습니다. 우리가 이번 주에 쓴 printf 가 들어 있는 바로 그 파일입니다. 여러분이 쓰는 도구들과 여러분의 첫 프로그램이 같은 뿌리에서 나왔다는 뜻입니다.

직접 해 보기: C 모양 대신 여러분 이름의 이니셜로 큰 글자를 만들어 보세요. 모눈종이에 먼저 그려 보고, 한 줄씩 printf 로 옮기면 됩니다. 인터넷에서 “ASCII art generator” 를 검색하면 글자를 넣었을 때 큰 글자로 바꿔 주는 사이트도 있습니다. 거기서 만든 그림을 붙여 넣을 때는 \ 와 " 를 이스케이프해야 한다는 것, 잊지 마세요.


9. 실행 파일 관리와 권한

2절에서 배운 파일 권한을 우리가 만든 실행 파일로 직접 실험해 봅시다.

실행 권한 직접 확인하기

$ cd ~/c_programming/week01
$ ls -l hello hello.c
-rwxrwxr-x 1 user user 15960  9월 24 10:01 hello
-rw-rw-r-- 1 user user    84  9월 24 10:01 hello.c

2절의 표를 떠올리며 읽어 봅시다.

  • hello: rwx rwx r-x. 주인과 그룹은 읽기, 쓰기, 실행 모두 가능하고, 나머지 사람은 읽기와 실행만 가능합니다. 숫자로는 775 입니다.
  • hello.c: rw- rw- r--. 실행 권한이 없는 일반 텍스트 파일입니다. 숫자로는 664 입니다.

gcc 가 실행 파일에 x 를 알아서 붙여 줬다는 것을 5절에서 봤습니다. 그럼 x 를 빼면 어떻게 될까요?

$ chmod -x hello
$ ls -l hello
-rw-rw-r-- 1 user user 15960  9월 24 10:01 hello
$ ./hello
bash: ./hello: 허가 거부
$ echo $?
126

영어로는 Permission denied 입니다. 파일 내용은 1바이트도 바뀌지 않았는데 실행이 거부됐습니다. 리눅스에서 “실행 파일인가”를 정하는 것은 내용이나 이름이 아니라 x 권한 하나라는 것을 확인했습니다. 종료 코드 126은 쉘이 “파일은 찾았는데 실행할 수 없었다”고 알려 주는 약속된 번호입니다. 참고로 “명령을 찾을 수 없습니다”일 때는 127입니다. 쉘도 이렇게 종료 코드로 실패의 종류를 구분합니다.

다시 권한을 줍니다.

$ chmod +x hello
$ ./hello
Hello, World!

반대로 텍스트 파일에 x 를 준다고 실행되는 것도 아닙니다.

$ chmod +x hello.c
$ ./hello.c
./hello.c: 줄 3: 예기치 않은 `(' 토큰 주변에서 문법 오류
./hello.c: 줄 3: `int main(void) {'

쉘은 hello.c 를 실행하려다가, 기계어가 아니니 쉘 명령이 적힌 파일(쉘 스크립트) 로 보고 한 줄씩 읽어 봅니다. 당연히 C 코드는 쉘 명령이 아니라서 오류가 납니다. 그런데 오류가 1번 줄이 아니라 3번 줄에서 났습니다. 왜일까요? 쉘에서는 # 으로 시작하는 줄이 주석이라서, #include <stdio.h> 를 조용히 건너뛰었기 때문입니다. 같은 # 이 C에서는 전처리 지시, 쉘에서는 주석입니다. 실행 권한은 “실행해도 된다”는 허락일 뿐이고, 실행할 수 있는 내용인지는 별개입니다. 확인했으면 chmod -x hello.c 로 되돌려 두세요.

왜 새 파일은 664, 실행 파일은 775 일까?

아무도 권한을 정해 준 적이 없는데, 새 파일은 항상 이 권한으로 만들어집니다. 규칙이 있습니다. 프로그램은 파일을 만들 때 “가능하면 모두에게 다 허락”(일반 파일 666, 실행 파일과 폴더 777)을 요청하고, 운영체제가 사용자마다 정해진 마스크(umask) 만큼 권한을 빼고 만듭니다.

$ umask
0002

우분투의 기본 umask 는 002, 즉 “기타 사용자의 쓰기(w, 2)는 빼라”입니다. 그래서 666 − 002 = 664(rw-rw-r--), 777 − 002 = 775(rwxrwxr-x) 가 됩니다. 오래된 책이나 다른 리눅스에서는 rwxr-xr-x(755)로 나오는 경우가 많은데, 그쪽은 umask 가 022 라서 그룹의 쓰기 권한까지 빼기 때문입니다. 둘 다 정상입니다.

PATH에 프로그램 추가하기

./hello 대신 어디서든 hello 만 쳐서 실행하고 싶다면 어떻게 할까요? 2절과 5절에서 배운 원리대로라면 답은 두 가지입니다. PATH에 이미 있는 폴더에 프로그램을 넣거나, 프로그램이 있는 폴더를 PATH에 넣거나.

방법 1: 개인 bin 폴더 만들기 (권장)

$ mkdir -p ~/bin
$ cp hello ~/bin/

bin 은 binary(실행 파일)의 줄임말로, 실행 파일을 모아 두는 폴더에 관례적으로 붙이는 이름입니다. /usr/bin 처럼요.

이제 ~/bin 을 PATH에 넣어야 하는데, 우분투는 이 일을 이미 준비해 두었습니다. 로그인할 때 읽히는 설정 파일 ~/.profile 을 열어 보세요.

$ grep -A2 'HOME/bin' ~/.profile
if [ -d "$HOME/bin" ] ; then
    PATH="$HOME/bin:$PATH"
fi

grep -A2 는 찾은 줄과 그 뒤 2줄(After)을 보여 줍니다. 쉘 스크립트 문법이지만 거의 영어처럼 읽힙니다. “만약(if) $HOME/bin 이 폴더(-d)라면, PATH 앞에 $HOME/bin 을 붙여라.” 즉 ~/bin 폴더가 있으면 로그인할 때 자동으로 PATH에 들어갑니다. 방금 폴더를 만들었으니, 로그아웃했다가 다시 로그인하면 적용됩니다.

다시 로그인하기 귀찮다면, 지금 터미널에서 같은 일을 직접 해도 됩니다.

$ export PATH="$HOME/bin:$PATH"
$ echo $PATH
/home/user/bin:/usr/local/sbin:/usr/local/bin:...

"$HOME/bin:$PATH" 는 “~/bin, 그 뒤에 콜론, 그 뒤에 원래 PATH 전체”라는 뜻입니다. 기존 PATH를 지우지 않고 앞에 덧붙이는 것이 중요합니다. export PATH="$HOME/bin" 이라고 쓰면 PATH가 ~/bin 하나만 남아서 ls 도 gcc 도 “명령을 찾을 수 없습니다”가 됩니다(터미널을 새로 열면 복구됩니다).

이제 어디서든 됩니다.

$ cd /tmp
$ hello
Hello, World!
$ which hello
/home/user/bin/hello

which 로 보면 쉘이 ~/bin 에서 찾았다는 것을 알 수 있습니다.

순서가 중요한 이유: ~/bin 을 PATH 앞에 붙였으므로, 만약 ~/bin 에 ls 라는 이름의 프로그램을 넣으면 진짜 /usr/bin/ls 대신 그것이 실행됩니다. 쉘은 PATH를 왼쪽부터 찾다가 처음 발견한 것을 실행하기 때문입니다. 그래서 내가 만든 프로그램에는 기존 명령과 겹치지 않는 이름을 붙여야 합니다. 5절에서 “지금 폴더를 PATH에 넣지 않는 이유”로 설명한 위험과 같은 원리입니다.

방법 2: 시스템 폴더에 복사 (연습용으로만)

$ sudo cp hello /usr/local/bin/

/usr/local/bin 은 처음부터 PATH에 들어 있는 폴더라서 바로 됩니다. 다만 시스템 폴더라 sudo 가 필요하고, 컴퓨터의 모든 사용자에게 영향을 줍니다. 3절에서 말했듯 sudo 는 꼭 필요할 때만 씁니다. 연습으로 해 봤다면 지워 두세요.

$ sudo rm /usr/local/bin/hello

10. 일반적인 에러와 해결 방법

프로그래밍의 절반은 오류를 읽고 고치는 일입니다. 처음에는 빨간 글씨만 봐도 겁이 나지만, 컴파일러의 오류 메시지는 사실 아주 친절한 안내문입니다. 읽는 법만 알면 대부분 어디가 왜 틀렸는지 정확히 알려 줍니다.

오류 메시지 읽는 법

모든 GCC 메시지는 같은 모양입니다.

e1.c:4:30: error: expected ‘;’ before ‘return’
 │   │  │    │       │
 │   │  │    │       └─ 무엇이 문제인지 (영어 설명)
 │   │  │    └───────── 심각도: error(오류), warning(경고), note(참고)
 │   │  └────────────── 몇 번째 칸
 │   └───────────────── 몇 번째 줄
 └───────────────────── 어느 파일

그 아래에는 문제가 있는 줄을 보여 주고, ^ 로 정확한 위치를 가리킵니다. 읽는 요령은 세 가지입니다.

  1. 맨 위의 첫 번째 오류부터 고칩니다. 오류 하나가 뒤따르는 오류 여러 개를 연쇄적으로 만들어 내는 경우가 많습니다. 첫 오류를 고치면 나머지가 저절로 사라지기도 합니다.
  2. 줄 번호를 믿되, 그 윗줄도 봅니다. 컴파일러는 “여기서 이상하다는 걸 알아챈 곳”을 가리킵니다. 실제 실수는 그보다 앞에 있는 경우가 많습니다.
  3. 영어 설명을 그대로 검색합니다. 따옴표 안의 코드 부분(‘return’ 같은)만 빼고 검색하면 같은 오류를 겪은 사람들의 글이 나옵니다.

cat -n (2절)이나 VS Code 의 줄 번호를 보면서 따라가세요. 아래 오류들은 모두 실제로 컴파일해서 나온 메시지입니다. 파일을 하나씩 만들어 직접 오류를 내 보세요. 일부러 내 본 오류는, 나중에 실수로 만났을 때 바로 알아볼 수 있습니다.

에러 1: 세미콜론 누락

#include <stdio.h>

int main(void) {
    printf("Hello, World!\n")
    return 0;
}
$ gcc -Wall -Wextra -std=c11 e1.c -o e1
e1.c: In function ‘main’:
e1.c:4:30: error: expected ‘;’ before ‘return’
    4 |     printf("Hello, World!\n")
      |                              ^
      |                              ;
    5 |     return 0;
      |     ~~~~~~

읽기: 첫 줄 In function ‘main’ 은 “main 함수 안에서”라는 위치 안내입니다. 4번째 줄 30번째 칸에서 “return 앞에 ; 가 있어야 한다”고 합니다. 심지어 ^ 아래에 ; 를 그려서 어디에 무엇을 넣으면 되는지 보여 줍니다.

왜 이렇게 말할까: 5절에서 “컴파일러는 줄바꿈이 아니라 세미콜론으로 문장의 끝을 판단한다”고 했습니다. 세미콜론이 없으니 컴파일러는 4번째 줄과 5번째 줄을 printf(...) return 0; 이라는 한 문장으로 읽으려다가, return 을 만나서 “어? 여기서 문장이 끝났어야 하는데”라고 알아챈 것입니다. 그래서 문제를 발견한 곳은 return 이지만 고칠 곳은 윗줄 끝입니다. 요령 2번이 바로 이런 경우입니다.

결과: error 이므로 실행 파일이 만들어지지 않습니다. echo $? 를 해 보면 gcc 도 1(실패)을 돌려줍니다.

에러 2: 헤더 파일 누락


int main(void) {
    printf("Hello, World!\n");
    return 0;
}
$ gcc -Wall -Wextra -std=c11 e2.c -o e2
e2.c: In function ‘main’:
e2.c:3:5: warning: implicit declaration of function ‘printf’ [-Wimplicit-function-declaration]
    3 |     printf("Hello, World!\n");
      |     ^~~~~~
e2.c:1:1: note: include ‘<stdio.h>’ or provide a declaration of ‘printf’
  +++ |+#include <stdio.h>
    1 |
e2.c:3:5: warning: incompatible implicit declaration of built-in function ‘printf’ [-Wbuiltin-declaration-mismatch]
...

읽기: “printf 함수가 선언 없이(implicit) 쓰였다”는 경고입니다. 그 아래 note 가 해결책을 알려 줍니다. “<stdio.h> 를 include 하거나 printf 를 선언하라.” +++ |+#include <stdio.h> 는 1번째 줄에 그 한 줄을 추가하라는 표시입니다.

왜 이렇게 말할까: 5절에서 #include <stdio.h> 는 컴파일러에게 주는 용어 사전이라고 했습니다. 사전이 없으니 컴파일러는 printf 를 처음 보는 단어로 만납니다. 7절에서 봤듯 printf 의 선언은 stdio.h 안에 있으니까요.

주의할 점: 이번에는 error 가 아니라 warning 입니다. 즉 실행 파일이 만들어지고, 실행하면 Hello World 가 잘 나옵니다. GCC가 printf 라는 이름을 원래 알고 있어서 대충 맞춰 준 것입니다. 그렇다고 괜찮은 게 아닙니다. 선언 없이 함수를 쓰는 것은 C99 이후 표준에서 허용하지 않는 코드이고, printf 가 아닌 다른 함수였다면 잘못된 값이 넘어가 프로그램이 이상하게 동작할 수 있습니다. 5절의 “경고 0개” 원칙을 기억하세요.

에러 3: main 함수 오타

#include <stdio.h>

int mian(void) {
    printf("Hello, World!\n");
    return 0;
}
$ gcc -Wall -Wextra -std=c11 e3.c -o e3
/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu/Scrt1.o: in function `_start':
(.text+0x1b): undefined reference to `main'
collect2: error: ld returned 1 exit status

읽기: 이 메시지는 모양이 완전히 다릅니다. e3.c:4:5: 같은 줄 번호가 없고, 대신 /usr/bin/ld 로 시작합니다. 7절에서 본 링커 ld 가 낸 오류라는 뜻입니다. “Scrt1.o 의 _start 함수에서 main 을 참조하는데, 정의된 곳이 없다(undefined reference).”

왜 이렇게 말할까: 7절의 내용을 그대로 떠올리면 됩니다. 프로그램은 _start 에서 시작하고, _start 가 main 을 부릅니다. 컴파일러 입장에서 mian 은 문법적으로 완벽한 함수라서 컴파일 단계는 통과했습니다. 그런데 링크 단계에서 _start 가 부를 main 을 찾아보니 없습니다. nm 으로 보면 명확합니다.

$ gcc -c e3.c && nm e3.o
0000000000000000 T mian
                 U puts

main 이 아니라 mian 이 있습니다.

교훈: 오류 메시지에 ld 나 undefined reference 가 보이면 링크 단계의 문제입니다. 문법은 맞는데 “이름으로 찾을 수 없는 것”이 있다는 뜻이고, 대개 철자가 틀렸거나 필요한 파일·라이브러리를 빼먹은 경우입니다. 5주차에 파일을 여러 개로 나누면 이 오류를 자주 보게 됩니다.

에러 4: 따옴표 불일치

#include <stdio.h>

int main(void) {
    printf("Hello, World!\n');
    return 0;
}
$ gcc -Wall -Wextra -std=c11 e4.c -o e4
e4.c: In function ‘main’:
e4.c:4:12: warning: missing terminating " character
    4 |     printf("Hello, World!\n');
      |            ^
e4.c:4:12: error: missing terminating " character
    4 |     printf("Hello, World!\n');
      |            ^~~~~~~~~~~~~~~~~~~
e4.c:5:5: error: expected expression before ‘return’
    5 |     return 0;
      |     ^~~~~~
e4.c:5:14: error: expected ‘;’ before ‘}’ token
    5 |     return 0;
      |              ^
      |              ;
    6 | }
      | ~

읽기: 오류가 세 개나 나왔습니다. 하지만 요령 1번대로 첫 번째만 봅시다. “4번째 줄 12번째 칸의 " 가 닫히지 않았다(missing terminating " character).” 12번째 칸은 printf( 바로 뒤의 여는 따옴표입니다. 이 따옴표로 문자열을 열었는데 ' 로 닫으려 했으니, 컴파일러에게는 문자열이 끝나지 않은 것입니다.

연쇄 오류: 나머지 두 오류(expected expression before ‘return’, expected ‘;’)는 첫 오류 때문에 컴파일러가 코드 구조를 놓쳐서 생긴 부산물입니다. 5번째 줄의 return 0; 에는 아무 문제가 없습니다. ' 를 " 로 고치면 세 오류가 한꺼번에 사라집니다. 오류가 여러 개 나왔을 때 아래쪽부터 고치려고 하면 멀쩡한 코드를 망가뜨리게 됩니다.

VS Code 를 쓰면 이런 실수는 컴파일하기 전에 보입니다. 따옴표가 안 닫히면 그 뒤의 코드 전체가 문자열 색으로 칠해지기 때문입니다. 문법 강조가 갑자기 이상해지면 따옴표나 괄호부터 의심하세요.

에러 5: 값을 돌려주지 않는 함수 (경고)

#include <stdio.h>

int add(int a, int b) {
    int sum = a + b;
}

int main(void) {
    printf("%d\n", add(1, 2));
}

2주차와 5주차 내용이 섞여 있지만 뜻은 간단합니다. add 는 두 정수를 받아 더한 값을 돌려주겠다고(int) 약속한 함수인데, 더하기만 하고 return 을 잊었습니다.

$ gcc -Wall -Wextra -std=c11 e5.c -o e5
e5.c: In function ‘add’:
e5.c:4:9: warning: unused variable ‘sum’ [-Wunused-variable]
    4 |     int sum = a + b;
      |         ^~~
e5.c:5:1: warning: control reaches end of non-void function [-Wreturn-type]
    5 | }
      | ^

읽기: 경고가 두 개입니다.

  • “sum 변수를 만들어 놓고 쓰지 않았다(unused variable).” 계산한 값을 어디에도 쓰지 않았으니까요.
  • “void 가 아닌 함수의 끝에 도달했다(control reaches end of non-void function).” 값을 돌려주겠다고 해 놓고 return 없이 } 에 닿았다는 뜻입니다.

두 경고가 사실 같은 실수 하나를 서로 다른 각도에서 가리키고 있습니다. return sum; 한 줄을 넣으면 둘 다 사라집니다. 이 상태로 실행하면 add(1, 2) 가 3이 아니라 아무 값이나 돌려줄 수 있습니다. 7절에서 “돌려줄 값은 eax 에 넣는다”고 했는데, 넣지 않았으니 eax 에 우연히 남아 있던 값이 나오는 것입니다.

main 은 왜 괜찮을까? 이 코드의 main 도 return 0; 이 없는데, main 에 대해서는 경고가 없습니다. 5절에서 말한 특례 때문입니다. C99부터 main 만은 끝에 도달하면 0을 돌려주도록 정해져 있습니다. 다른 함수에는 이 특례가 없습니다.

에러 6: 초기화하지 않은 변수 (경고)

5절에서 -Wall 을 설명할 때 본 코드입니다.

#include <stdio.h>

int main(void) {
    int x;
    printf("x = %d\n", x);
    return 0;
}
$ gcc -Wall -Wextra -std=c11 e7.c -o e7
e7.c: In function ‘main’:
e7.c:5:5: warning: ‘x’ is used uninitialized [-Wuninitialized]
    5 |     printf("x = %d\n", x);
      |     ^~~~~~~~~~~~~~~~~~~~~
e7.c:4:9: note: ‘x’ was declared here
    4 |     int x;
      |         ^

읽기: warning 과 note 가 짝을 이룹니다. 경고는 “문제가 드러난 곳”(5번째 줄, 사용), note 는 “관련된 곳”(4번째 줄, 선언)을 알려 줍니다. note 는 혼자 나오지 않고 항상 앞의 오류나 경고를 보충합니다.

-Wall 이 없으면 이 경고는 나오지 않는다는 것을 5절에서 확인했습니다. 옵션을 빼먹으면 컴파일러가 알고 있는 문제도 알려 주지 않습니다.

에러 7: 서식 지정자와 값의 불일치 (경고)

#include <stdio.h>

int main(void) {
    printf("%d\n", "hello");
    return 0;
}
$ gcc -Wall -Wextra -std=c11 e8.c -o e8
e8.c: In function ‘main’:
e8.c:4:14: warning: format ‘%d’ expects argument of type ‘int’, but argument 2 has type ‘char *’ [-Wformat=]
    4 |     printf("%d\n", "hello");
      |             ~^     ~~~~~~~
      |              |     |
      |              int   char *
      |             %s

읽기: 이 경고는 그림까지 그려 줍니다. %d 아래에는 int(정수를 원함), "hello" 아래에는 char *(실제로는 문자열을 줬음)라고 표시하고, 맨 아래에 %s 로 바꾸라고 제안합니다. 6절에서 배운 대로 %d 는 정수, %s 는 문자열 자리입니다. 2주차에 printf 를 본격적으로 쓰면 가장 자주 만날 경고입니다.

에러 8: 파일을 찾을 수 없음

$ gcc helo.c -o hello
cc1: fatal error: helo.c: 그런 파일이나 디렉터리가 없습니다
compilation terminated.

읽기: cc1 이 보냈습니다. 7절에서 본 GCC의 두뇌죠. fatal error 는 “치명적 오류, 더 진행할 수 없음”입니다. 2절의 cd 에서 본 바로 그 메시지 그런 파일이나 디렉터리가 없습니다(No such file or directory)입니다.

점검 순서:

$ pwd          # 내가 맞는 폴더에 있나?
$ ls *.c       # 이 폴더의 .c 파일은 뭐가 있나?

*.c 는 2절에서 본 * 로, “.c 로 끝나는 모든 파일”입니다. 대부분 철자가 틀렸거나(helo.c), 다른 폴더에 있거나, VS Code 에서 저장을 안 해서 파일이 아직 없는 경우입니다. 파일 이름은 Tab 자동 완성으로 치는 습관을 들이면 이 오류는 거의 사라집니다.

오류 유형 한눈에 보기

메시지에 보이는 것 어느 단계 흔한 원인
파일.c:줄:칸: error: 컴파일 문법 오류. 세미콜론, 따옴표, 괄호
파일.c:줄:칸: warning: 컴파일 실행은 되지만 위험한 코드. 반드시 고치기
ld: / undefined reference 링크 이름 철자, 파일이나 라이브러리 누락
cc1: fatal error: ... 없습니다 시작 전 파일 이름, 현재 위치
(컴파일은 성공) 허가 거부 실행 x 권한 없음
(컴파일은 성공) 명령을 찾을 수 없습니다 실행 ./ 를 빼먹음

마지막 두 줄은 컴파일러가 아니라 쉘의 메시지입니다. 2절의 “터미널, 쉘, 명령어” 구분이 여기서 쓸모를 발휘합니다. 누가 보낸 메시지인지 알면 어디를 고쳐야 할지 압니다.


11. 좋은 코딩 습관

컴파일러는 들여쓰기도, 줄바꿈도, 이름도 신경 쓰지 않습니다. 극단적으로 말하면 Hello World 를 한 줄에 다 써도 됩니다.

#include <stdio.h>
int main(void){printf("Hello, World!\n");return 0;}

이것도 완벽하게 컴파일되고 실행됩니다. 그럼 좋은 습관은 누구를 위한 것일까요? 사람, 특히 일주일 뒤의 나를 위한 것입니다. 코드는 한 번 쓰고 수십 번 읽습니다. 처음부터 좋은 습관을 들여 두면 앞으로 33주 동안 편합니다.

1) 일관된 들여쓰기

나쁜 예:

#include <stdio.h>

int main(void) {
printf("Hello, World!\n");
return 0;
}

좋은 예:

#include <stdio.h>

int main(void) {
    printf("Hello, World!\n");
    return 0;
}

들여쓰기는 “이 코드는 저 { } 안에 있다” 를 눈으로 보여 줍니다. 지금은 { } 가 한 겹이라 차이가 작아 보이지만, 3주차에 조건문과 반복문이 겹치기 시작하면 들여쓰기 없는 코드는 읽을 수가 없습니다.

이 강좌는 공백 4칸을 씁니다. 탭을 쓰는 사람도 있고 2칸을 쓰는 사람도 있습니다. 무엇을 고르든 한 파일 안에서는 한 가지만 쓰는 것이 중요합니다. VS Code 는 Tab 키를 누르면 설정된 칸 수만큼 공백을 넣어 주고, 오른쪽 아래 상태 표시줄의 Spaces: 4 에서 확인하고 바꿀 수 있습니다.

2) 의미 있는 공백

나쁜 예:

#include <stdio.h>
int main(void){
printf("Hello, World!\n");
return 0;
}

좋은 예:

#include <stdio.h>

int main(void) {
    printf("Hello, World!\n");
    return 0;
}
  • #include 와 코드 사이에 빈 줄을 둬서 “준비”와 “본문”을 나눕니다.
  • ) 와 { 사이에 공백 한 칸을 둡니다.
  • 관련된 문장끼리 묶고, 덩어리 사이에 빈 줄을 둡니다(8절의 자기소개 프로그램처럼).

글쓰기의 문단 나누기와 같습니다. 빈 줄 하나가 읽는 사람에게 “여기서 생각이 바뀐다”는 신호를 줍니다.

3) 주석 작성

#include <stdio.h>

/*
 * 프로그램: Hello World
 * 작성자: 홍길동
 * 날짜: 2026-09-24
 * 설명: C 언어로 작성한 첫 번째 프로그램
 */

int main(void) {
    // 환영 메시지 출력
    printf("Hello, World!\n");

    return 0;  // 운영체제에 "성공"을 알린다
}

C의 주석은 두 종류입니다.

모양 범위 비고
/* ... */ /* 부터 */ 까지. 여러 줄 가능 C 초창기부터 있던 방식
// ... // 부터 그 줄 끝까지 C99 에서 추가됨. 5절의 표 참고

주석은 전처리 단계(7절)에서 지워집니다. 그래서 실행 파일 크기에도 속도에도 아무 영향이 없습니다. 마음껏 쓰세요.

다만 무엇을 쓸지가 중요합니다. printf("Hello"); // Hello 를 출력한다 같은 주석은 코드를 그대로 다시 읽어 준 것이라 쓸모가 없습니다. 좋은 주석은 코드만 봐서는 알 수 없는 “왜” 를 적습니다. 위 예제의 return 0; 옆 주석처럼, 이 0이 무슨 뜻인지를요.

실험: /* */ 안에 /* */ 를 넣으면? /* 바깥 /* 안쪽 */ 끝 */ 을 코드에 넣고 컴파일해 보세요.

warning: "/*" within comment [-Wcomment]
error: unknown type name ‘끝’

/* 주석은 처음 만나는 */ 에서 끝납니다. 안쪽의 /* 는 그냥 주석 속 글자로 취급되고, 안쪽 */ 에서 주석이 끝나 버리니 끝 */ 은 코드로 읽힙니다. 그래서 컴파일러가 “끝 이라는 자료형은 모른다”고 합니다. 주석은 겹칠 수 없습니다. 코드 덩어리를 잠시 꺼 두고 싶을 때는 // 를 줄마다 붙이세요. VS Code 에서는 여러 줄을 선택하고 Ctrl + / 를 누르면 한 번에 됩니다.

4) 컴파일 경고 무시하지 않기

5절과 10절에서 여러 번 말했습니다. 한 번 더 정리합니다.

$ gcc -Wall -Wextra -std=c11 -g hello.c -o hello
  • 항상 이 옵션으로 컴파일합니다.
  • 경고가 하나라도 나오면, 실행하기 전에 읽고 고칩니다.
  • 10절에서 봤듯 경고의 상당수는 실행하면 틀린 값이 나오는 코드를 가리킵니다. 실행해서 우연히 맞게 나왔더라도 고쳐야 합니다.

더 엄격하게 하고 싶다면 -Werror 옵션이 있습니다. 모든 경고를 오류로 취급해서, 경고가 하나라도 있으면 실행 파일을 만들지 않습니다. 실무 프로젝트에서 자주 쓰는 방법입니다.

5) 코드 포맷팅 도구

들여쓰기와 공백을 사람이 매번 맞추기는 귀찮습니다. 도구에게 맡길 수 있습니다. clang-format 이 대표적입니다.

$ sudo apt install clang-format

엉망으로 쓴 ugly.c 가 있다고 합시다.

#include <stdio.h>
int main(void){
printf("Hello, World!\n");
        return 0;}
$ clang-format ugly.c
#include <stdio.h>
int main(void) {
  printf("Hello, World!\n");
  return 0;
}

clang-format 은 파일을 바꾸지 않고 정리된 결과를 화면에 보여 주기만 합니다. 기본 스타일은 들여쓰기가 2칸입니다. 이 강좌처럼 4칸을 쓰려면 스타일을 지정합니다.

$ clang-format --style="{BasedOnStyle: LLVM, IndentWidth: 4}" ugly.c

결과가 마음에 들면 -i (in-place, 그 자리에서) 옵션으로 파일 자체를 고칩니다.

$ clang-format -i --style="{BasedOnStyle: LLVM, IndentWidth: 4}" ugly.c

매번 스타일을 치기 싫다면, 폴더에 .clang-format 이라는 설정 파일을 두면 됩니다. 앞에 . 이 붙었으니 2절에서 배운 숨김 파일입니다.

$ echo "{BasedOnStyle: LLVM, IndentWidth: 4}" > .clang-format
$ clang-format -i ugly.c

VS Code 의 C/C++ 확장도 clang-format 을 씁니다. 코드에서 Ctrl + Shift + I 를 누르면 같은 정리가 됩니다.


12. 다음 단계

첫 주차를 끝냈습니다. 돌아보면 이번 주에 여러분은 이것들을 할 수 있게 됐습니다.

  • 가상 머신에 우분투를 설치하고 Guest Additions 까지 설정할 수 있습니다.
  • 터미널에서 폴더를 오가고, 파일을 만들고, 복사하고, 지울 수 있습니다. 명령어가 무엇의 줄임말인지 압니다.
  • ls -l 의 권한 9글자를 읽고, chmod 로 바꿀 수 있습니다.
  • “명령을 찾을 수 없습니다”가 PATH 때문이라는 것을 압니다.
  • man 3 printf 로 C 함수의 설명서를 찾아볼 수 있습니다.
  • C 프로그램의 다섯 줄이 각각 무슨 뜻인지 설명할 수 있습니다.
  • gcc 의 옵션을 골라 컴파일하고, echo $? 로 종료 코드를 확인할 수 있습니다.
  • 소스 코드가 전처리, 컴파일, 어셈블, 링크를 거쳐 실행 파일이 되는 과정을 눈으로 확인했습니다.
  • 오류 메시지를 보고 어느 단계에서 난 문제인지, 어디를 고쳐야 하는지 읽을 수 있습니다.

추가 연습 과제

1. 자기소개 프로그램 완성하기

8절의 자기소개 프로그램을 여러분의 내용으로 완성하세요. 조건은 두 가지입니다.

  • 상자 제목을 여러분 이름으로 바꾸고, 오른쪽 테두리를 정확히 맞출 것 (8절 계산기 수수께끼 참고)
  • gcc -Wall -Wextra -std=c11 로 컴파일했을 때 경고 0개

2. 출력 패턴 그리기

printf 만으로 다음을 그려 보세요.

    *
   ***
  *****
 *******
*********

그다음 같은 모양을 탭(\t)으로 들여쓰기 해 보세요. 6절에서 본 탭의 성질 때문에 어떻게 되는지 관찰해 보세요. 3주차에 반복문을 배우면 이 삼각형을 printf 한 줄과 반복문으로 크기와 상관없이 그리게 됩니다. 지금 손으로 그려 두면 그때 반복문의 고마움을 확실히 느낄 겁니다.

3. 오류 수집하기

10절의 오류 중 세 개를 골라, 메시지를 보지 않고 코드만 보며 “어떤 메시지가 나올까” 예상해 적은 다음 컴파일해서 비교해 보세요. 예상이 틀린 부분이 곧 여러분이 새로 배운 부분입니다.

4. 컴파일 스크립트 만들기

매번 긴 컴파일 명령을 치기 귀찮다면, 명령을 파일에 적어 두고 실행할 수 있습니다. 이런 파일을 쉘 스크립트라고 합니다. 2절에서 배운 쉘 명령을 파일에 순서대로 적은 것일 뿐입니다.

compile.sh:

#!/bin/bash

# 사용법: ./compile.sh 파일이름 (확장자 제외)
# 예: ./compile.sh hello

if [ $# -eq 0 ]; then
    echo "사용법: $0 파일이름 (확장자 제외)"
    exit 1
fi

SOURCE="$1.c"
OUTPUT="$1"

if [ ! -f "$SOURCE" ]; then
    echo "에러: $SOURCE 파일을 찾을 수 없습니다."
    exit 1
fi

echo "컴파일 중: $SOURCE"
gcc -Wall -Wextra -std=c11 -g "$SOURCE" -o "$OUTPUT"

if [ $? -eq 0 ]; then
    echo "컴파일 성공! 실행 파일: $OUTPUT"
    echo ""
    echo "프로그램을 실행하려면 다음 명령을 입력하세요:"
    echo "  ./$OUTPUT"
else
    echo "컴파일 실패!"
    exit 1
fi

처음 보는 문법이 많지만, 이번 주에 배운 것들로 대부분 읽을 수 있습니다.

줄 뜻
#!/bin/bash “이 파일은 /bin/bash 로 실행하라”. 셔뱅(shebang) 이라고 부릅니다. 9절에서 hello.c 에 x 를 줬을 때 쉘이 억지로 읽으려 했던 것과 달리, 이 줄이 있으면 어떤 프로그램으로 실행할지 명확해집니다
# 로 시작하는 줄 쉘 스크립트의 주석 (C의 // 와 같은 역할)
$# 스크립트에 넘긴 값의 개수. ./compile.sh hello 면 1
$1 스크립트에 넘긴 첫 번째 값. 위 예에서는 hello
$0 스크립트 자신의 이름
if [ ... ]; then ... fi 조건이 참이면 실행. fi 는 if 를 거꾸로 쓴 것으로 “if 끝”
-eq 같다(equal)
! -f "$SOURCE" $SOURCE 가 일반 파일(file)이 아니면(!)
exit 1 스크립트를 끝내고 종료 코드 1(실패)을 돌려준다. 5절의 return 과 같은 역할
$? 직전 명령(여기서는 gcc)의 종료 코드. 5절에서 쓴 바로 그것

if [ $? -eq 0 ] 은 “직전 gcc 가 0(성공)을 돌려줬으면”입니다. 5절에서 배운 종료 코드 약속이 스크립트에서 이렇게 쓰입니다.

실행 권한을 주고(9절) 사용합니다.

$ chmod +x compile.sh
$ ./compile.sh hello
컴파일 중: hello.c
컴파일 성공! 실행 파일: hello

프로그램을 실행하려면 다음 명령을 입력하세요:
  ./hello
$ ./compile.sh 없는파일
에러: 없는파일.c 파일을 찾을 수 없습니다.

도전: 컴파일에 성공하면 “실행하려면 …” 안내 대신 곧바로 실행까지 하도록 스크립트를 고쳐 보세요. 힌트는 5절의 && 에 있습니다. compile.sh 를 9절의 ~/bin 에 넣으면 어느 폴더에서든 쓸 수 있는 여러분만의 명령이 됩니다.

유용한 리소스

  • man 페이지: 가장 먼저 찾아볼 곳. man 3 printf, man gcc
  • cppreference (C 부분): https://en.cppreference.com/w/c — C 표준 함수와 문법을 표준 기준으로 정리한 사이트
  • GCC 매뉴얼: https://gcc.gnu.org/onlinedocs/ — 옵션의 정확한 뜻
  • GNU C Library 매뉴얼: https://www.gnu.org/software/libc/manual/ — libc.so.6 안의 함수들
  • Linux man pages 온라인: https://man7.org/linux/man-pages/ — man 과 같은 내용을 웹에서

다음 주 예고

이번 주에는 정해진 글자를 출력하기만 했습니다. 다음 주에는 값을 저장하고, 계산하고, 입력받습니다.

  • 변수는 정확히 무엇이고, 메모리 어디에 어떤 모양으로 저장될까?
  • int, double, char 는 각각 몇 바이트이고, 왜 그 크기일까?
  • 이번 주에 잠깐 본 %d, %s 말고 printf 의 서식 지정자는 무엇이 더 있을까?
  • scanf 로 키보드 입력을 받으면, 사용자가 엉뚱한 값을 넣었을 때 무슨 일이 생길까?
  • 이번 주 10절에서 본 “초기화하지 않은 변수”는 왜 쓰레기 값을 가질까?

마치며

첫 주차를 끝낸 것을 축하합니다. 가상 머신 설치부터 터미널 명령, 컴파일러의 내부까지, 한 주 치고는 꽤 먼 길을 왔습니다. 처음에는 터미널이 낯설고 명령어가 암호처럼 보였겠지만, 이제 ls -l 의 한 줄 한 줄이, 오류 메시지의 파일:줄:칸 이 무슨 뜻인지 읽을 수 있습니다.

이번 주에 가장 강조하고 싶은 것은 “궁금하면 직접 확인한다” 는 태도입니다. #include 가 정말 붙여 넣는지 궁금하면 gcc -E 로 보면 되고, return 0 이 어디로 가는지 궁금하면 echo $? 로 보면 되고, 파일이 왜 16KB 인지 궁금하면 size 와 nm 으로 뜯어보면 됩니다. 이 강좌의 모든 설명은 여러분의 터미널에서 직접 확인할 수 있게 썼습니다. 책의 설명보다 여러분의 터미널이 보여 주는 결과가 언제나 더 정확합니다.

그리고 예제는 꼭 직접 치세요. 복사해서 붙여 넣으면 5분이면 끝나지만, 직접 치면 세미콜론을 빼먹고, 따옴표를 잘못 닫고, 오류를 만나게 됩니다. 그 오류를 읽고 고치는 시간이 바로 실력이 느는 시간입니다.

막히는 곳이 있으면 오류 메시지를 처음부터 끝까지 천천히 읽어 보고, 10절의 표를 보며 어느 단계의 문제인지 짚어 보세요. 모든 프로그래머가 여러분과 똑같은 오류를 만나며 시작했습니다.

다음 주에 만나요!


체크리스트

이번 주차를 마쳤다면 스스로 확인해 보세요. 각 항목을 설명할 수 있으면 체크합니다.

  • [ ] Ubuntu 24.04 LTS 를 VirtualBox 무인 설치로 설치하고 Guest Additions 까지 설정했다
  • [ ] 터미널, 쉘, 명령어의 차이를 설명할 수 있다
  • [ ] 절대 경로와 상대 경로, . .. ~ 의 뜻을 안다
  • [ ] pwd, ls, cd, mkdir, touch, cp, mv, rm, cat, less 를 쓸 수 있고 이름의 뜻을 안다
  • [ ] ls -l 출력의 각 칸과 권한 9글자를 읽을 수 있다
  • [ ] “명령을 찾을 수 없습니다”가 왜 나는지 PATH 로 설명할 수 있다
  • [ ] sudo apt update 와 sudo apt upgrade 의 차이를 안다
  • [ ] man 3 printf 로 C 함수 설명서를 열 수 있다
  • [ ] Hello World 다섯 줄을 한 줄씩 설명할 수 있다
  • [ ] ./ 를 붙여야 하는 이유를 안다
  • [ ] echo $? 로 종료 코드를 확인하고 return 0 과 연결할 수 있다
  • [ ] -Wall, -Wextra, -std=c11, -g, -o 가 각각 무엇을 하는지 안다
  • [ ] gcc -o hello.c hello 가 왜 위험한지 안다
  • [ ] 전처리, 컴파일, 어셈블, 링크 각 단계의 결과물(.i, .s, .o, 실행 파일)을 직접 만들어 봤다
  • [ ] 오류 메시지를 보고 컴파일 단계인지, 링크 단계인지, 쉘의 문제인지 구분할 수 있다
  • [ ] 경고와 오류의 차이를 알고, 경고 0개로 컴파일하는 습관을 들였다

모든 항목을 체크했다면 2주차로 넘어갈 준비가 되었습니다!

댓글 남기기

이 사이트는 Akismet을 사용하여 스팸을 줄입니다. 댓글 데이터가 어떻게 처리되는지 알아보세요.