C
고친 사람 github-actions[bot]
C 는 짜 둔 코드를 기계가 바로 알아듣는 명령으로 바꿔 주는 프로그래밍 언어입니다. 메모리를 언제 얻고 언제 돌려줄지는 언어가 아니라 짜는 사람이 정합니다. 운영체제나 장치를 움직이는 프로그램처럼 기계에 가까이 붙어야 하는 곳에서 씁니다.
쉽고 빠른 이해
C 는 코드를 미리 한 번 번역해 실행 파일 하나로 만드는 언어입니다. 리눅스 커널이나 데이터베이스 엔진처럼 밑바닥에서 도는 프로그램이 C 로 짜여 있습니다.
기계에 가까이 붙는 프로그램은 어느 메모리 칸을 언제 쓰는지까지 정해야 합니다. 언어가 그 결정을 대신 내려 주면 정할 길이 없어집니다. C 는 그 결정을 짜는 사람에게 전부 넘겼습니다.
어떻게 도는가:
- 소스 파일을 미리 번역해 기계 명령이 담긴 실행 파일 하나로 만듭니다
- 변수와 함수를 메모리의 어느 칸에 놓을지는 번역할 때 미리 정해집니다
- 실행 중에 더 필요한 메모리는 짜는 사람이 달라고 하고 다 쓰면 돌려줍니다
대가는 실수가 그대로 통과한다는 점입니다. 돌려주기를 잊으면 쓰는 메모리가 계속 늘어나고, 배열 밖을 건드려도 막아 주는 검사가 없습니다. 그래서 웹 서비스처럼 기계에 붙을 일이 적은 곳에서는 다른 언어를 고르는 편입니다.
상세
C 는 프로그래밍 언어입니다. 이 절은 C 코드가 실행 파일이 되기까지 거치는 길을 먼저 봅니다. 그다음 짧은 코드 조각으로 타입과 포인터, 메모리, 문자열을 하나씩 봅니다. 마지막에 언제 C 를 고르고 언제 안 고르는지를 적습니다.
소스에서 실행 파일까지
C 코드는 .c 로 끝나는 소스 파일에 적습니다. 이 파일은 그대로 실행되지 않습니다. 도구 셋을
차례로 거쳐야 기계가 읽는 실행 파일이 됩니다.
첫 도구는 전처리기입니다. # 로 시작하는 줄을 처리해 소스를 손봅니다. #include <stdio.h> 는
다른 파일의 내용을 그 줄에 끌어다 붙이라는 뜻입니다. 이렇게 끌어다 붙이는 파일을
헤더 파일이라고 부릅니다.
다음은 컴파일러입니다. 손본 소스를 읽어 기계어 명령으로 바꿉니다. 기계어는 칩이 바로 알아듣는 명령입니다. 이 단계의 결과물은 소스 파일 하나당 하나씩 나오는 오브젝트 파일입니다.
마지막은 링커입니다. 오브젝트 파일 여럿과 이미 만들어져 있는 표준 라이브러리를 합쳐 실행 파일 하나로 묶습니다. 내가 짜지 않은 함수의 실제 코드가 이때 붙습니다.
flowchart TD
A["소스 파일 · hello.c"] --> B["전처리기"]
B --> S["손본 소스"]
S --> D["컴파일러"]
D --> O["오브젝트 파일 · hello.o"]
O --> L["링커"]
LIB["표준 라이브러리"] --> L
L --> X["실행 파일"]
기계어는 칩마다 다릅니다. 그래서 한 번 컴파일한 실행 파일은 만들 때와 같은 종류의 칩과 운영체제에서만 돕니다. 다른 기계에서 돌리려면 그 기계에 맞춰 다시 컴파일합니다.
타입을 적고 함수로 짠다
C 프로그램은 함수를 모아 놓은 것입니다. 변수를 만들 때는 그 변수에 담을 값의 종류를 먼저 적습니다.
이 종류를 타입이라고 부릅니다. 정수는 int, 문자 하나는 char, 소수점이 있는 수는 double
입니다.
프로그램이 시작하는 곳도 정해져 있습니다. main 이라는 이름의 함수가 그곳입니다. 아래 코드를
컴파일해 실행하면 화면에 hi 가 찍힙니다.
#include <stdio.h>
int main(void) {
printf("hi\n"); // hi
return 0; // 정상 종료
}
printf 는 화면에 글자를 찍는 표준 라이브러리 함수입니다. 첫 줄의 #include 는 그 함수의 이름과
인자 모양을 컴파일러에게 알려 주는 헤더 파일을 끌어옵니다. 이 줄이 없으면 컴파일러는 printf 가
무엇인지 모릅니다.
main 앞에 붙은 int 는 이 함수가 정수를 돌려준다는 뜻입니다. 돌려준 값은 운영체제가 받아 갑니다.
0 은 정상으로 끝났다는 약속이고, 0 이 아닌 값은 실패를 알립니다.
메모리 주소를 값으로 다루는 포인터
변수는 메모리 어딘가에 놓입니다. 놓인 칸마다 번호가 매겨져 있는데, 이 번호를 주소라고 부릅니다. 포인터는 그 주소를 담는 변수입니다.
&x 는 변수 x 의 주소를 꺼냅니다. *p 는 반대로 p 가 가진 주소로 찾아가 거기 있는 값을
봅니다.
int x = 10;
int *p = &x; // p 는 x 의 주소
*p = 20;
printf("%d", x); // 20
x 에 직접 손대지 않았는데 값이 바뀌었습니다. p 를 통해 x 가 놓인 칸을 고쳤기 때문입니다.
flowchart TD
subgraph 변수
P["p · 주소를 담는다"]
X["x · 값 20"]
end
P --> X
포인터가 있어서 함수에 큰 데이터를 넘길 때 값을 복사하지 않고 주소만 넘길 수 있습니다. 함수 안에서 부른 쪽의 변수를 고치는 일도 포인터로 합니다.
손으로 얻고 손으로 돌려주는 메모리
지금까지 만든 변수는 함수가 시작할 때 생기고 함수가 끝나면 사라집니다. 이런 변수가 놓이는 구역을 스택이라고 부릅니다. 크기를 컴파일할 때 알아야 하고, 함수가 끝나면 그 칸은 없어집니다.
실행 중에야 크기가 정해지는 데이터는 스택에 둘 수 없습니다. 그래서 힙이라는 다른 구역이
있습니다. 힙에서 공간을 얻으려면 malloc 에게 몇 바이트가 필요한지 말하고, 다 쓰고 나면 free
로 돌려줍니다.
int *buf = malloc(4 * sizeof(int));
buf[0] = 7;
printf("%d", buf[0]); // 7
free(buf); // 돌려준다
sizeof(int) 는 정수 하나가 몇 바이트를 쓰는지 알려 줍니다. 그 넷만큼 담을 공간을 달라고 한 것이
첫 줄입니다.
돌려주는 일은 아무도 대신 해 주지 않습니다. free 를 부르지 않으면 그 공간은 프로그램이 끝날
때까지 남습니다. 오래 도는 서버에서 이 일이 되풀이되면 쓰는 메모리가 계속 늘어납니다. 이것을
메모리 누수라고 부릅니다.
반대로 너무 일찍 돌려주는 실수도 있습니다. 이미 free 한 공간을 가리키는 포인터를 다시 쓰면 그
칸에는 다른 데이터가 들어와 있을 수 있습니다. Java 나 Python 처럼 가비지 컬렉션이 도는
언어는 이 두 실수를 언어가 막아 줍니다. C 에는 그런 장치가 없습니다.
배열과 문자열은 메모리 조각이다
배열은 같은 타입의 값을 연달아 붙여 놓은 메모리 조각입니다. a[2] 는 첫 칸에서 두 칸 건너뛴
곳을 보라는 뜻입니다.
C 는 이 건너뛰기가 배열 밖으로 나가는지 검사하지 않습니다. 칸이 셋인 배열에서 여섯째 칸을 읽어도 컴파일러는 막지 않습니다.
int a[3] = {1, 2, 3};
printf("%d", a[5]); // 값을 알 수 없다
배열 밖을 읽으면 엉뚱한 값이 나오고, 배열 밖에 쓰면 옆에 있던 다른 데이터가 깨집니다. 이것을 버퍼 오버플로라고 부릅니다. 밖으로 멀리 나가 프로그램에 허락되지 않은 구역을 건드리면 운영체제가 그 프로그램을 끄는데, 그때 나는 것이 세그멘테이션 폴트입니다.
문자열도 따로 있는 타입이 아니라 문자를 담은 배열입니다. 어디서 끝나는지는 길이를 적어 두는 대신 마지막에 값이 0 인 문자를 하나 붙여 표시합니다. 이 문자를 널 문자라고 부릅니다.
char s[] = "hi";
printf("%zu", sizeof(s)); // 3
글자는 둘인데 셋이 나옵니다. 널 문자가 한 칸을 더 쓰기 때문입니다. 문자열을 다루는 함수들은 이 널 문자를 보고 멈춥니다. 그래서 널 문자가 빠지면 함수가 배열 밖까지 계속 읽어 나갑니다.
결과를 정해 두지 않은 코드
앞의 배열 예처럼 C 는 규칙을 어긴 코드를 막지 않습니다. 그때 무슨 일이 일어날지도 언어가 아예 정해 두지 않았습니다. 이렇게 결과를 정해 두지 않은 것을 정의되지 않은 동작이라고 부릅니다.
정해 두지 않은 까닭은 검사를 넣지 않으려는 것입니다. 배열을 읽을 때마다 범위를 확인하면 그만큼 명령이 늘어납니다. C 는 그 검사를 빼고, 대신 어긋난 코드의 결과를 책임지지 않기로 했습니다.
그래서 같은 코드가 컴파일러에 따라, 최적화 설정에 따라 다르게 돕니다. 어제 돌던 코드가 오늘 깨지기도 합니다. C 로 짠 프로그램에서 원인을 찾기 힘든 버그가 나오는 뿌리가 이 성질입니다.
C 가 맞는 곳과 안 맞는 곳
C 는 기계에 가까이 붙어야 하는 프로그램과 맞습니다. 운영체제의 커널, 디바이스 드라이버, 메모리가 아주 적은 임베디드 기기의 프로그램이 그런 것들입니다. Linux 커널이 C 로 짜여 있습니다.
다른 언어를 떠받치는 밑바닥에도 C 가 있습니다. Python 을 돌리는 실행 프로그램이 C 로 짜여 있고, 압축이나 암호처럼 여러 언어가 함께 쓰는 부품도 C 로 만들어 각 언어에서 불러 씁니다. 남의 코드를 부르는 통로가 대개 C 의 함수 호출 규칙에 맞춰져 있기 때문입니다.
손이 많이 가는 일에는 안 맞습니다. 문자열을 자르고 붙이거나 목록을 다루는 일은 다른 언어에서 한 줄인 것이 C 에서는 메모리를 얻고 돌려주는 코드까지 합쳐 여러 줄이 됩니다. 그 코드의 실수 하나가 보안 취약점이 되기도 합니다. 그래서 웹 서비스를 통째로 C 로 짜는 대신, 오래 걸리는 일부만 C 로 짜 붙이는 쪽을 택하는 일이 많습니다.
관련 항목
C 코드가 실행 파일이 되기까지 거치는 도구
전처리기 · 컴파일러 · 어셈블러 · 링커 · 오브젝트 파일 · 헤더 파일 · GCC · Clang · Make · 빌드 시스템
C 가 메모리를 얻고 돌려주는 수단
포인터 · 스택 · 힙 · malloc · free · 주소 공간 · 수동 메모리 관리
C 언어를 이루는 문법 요소
구조체 · 공용체 · 열거형 · 함수 포인터 · 매크로 · 배열 · 문자열 · 타입 캐스팅
C 프로그램에서 자주 나는 오류
세그멘테이션 폴트 · 버퍼 오버플로 · 메모리 누수 · 허상 포인터 · 이중 해제 · 널 포인터 역참조 · 정의되지 않은 동작
C 로 짜여 있는 밑바닥 소프트웨어
Linux · 커널 · 디바이스 드라이버 · SQLite · Redis · nginx · memcached
C 를 대신하려고 나온 시스템 프로그래밍 언어
C++ · Rust · Go · Zig · 어셈블리 · D
C 로 짠 코드를 다른 언어에서 부르는 통로
ABI · 공유 라이브러리 · 외부 함수 인터페이스 · 시스템 콜 · 표준 라이브러리
C 가 속하는 상위 분류
다른 이름: C 언어 · C언어