C (프로그래밍 언어)
C는 1970년대 초 Bell Labs의 데니스 리치가 개발한 범용 프로그래밍 언어이다. BCPL과 B (프로그래밍 언어)에서 이어진 간결한 문법과 표현식을 바탕으로 하며, 기계어에 가까운 메모리와 데이터 표현을 다룰 수 있으면서도 특정 하드웨어의 명령어에 직접 종속되지 않는 고급 언어...
| 분류 | 범용 프로그래밍 언어, 시스템 프로그래밍 언어 |
|---|---|
| 패러다임 | 명령형 프로그래밍, 절차형 프로그래밍, 구조적 프로그래밍 |
| 설계자 | 데니스 리치 |
| 개발 기관 | Bell Labs |
| 최초 공개 | 1972년 |
| 선행 언어 | BCPL, B (프로그래밍 언어) |
| 타입 체계 | 정적 타입, 약한 타입 검사, 명시적 타입 변환 |
| 메모리 관리 | 수동 메모리 관리 |
| 주요 표준 | ISO/IEC 9899:2024 (C23) |
| 파일 확장자 | .c, .h |
C는 1970년대 초 Bell Labs의 데니스 리치가 개발한 범용 프로그래밍 언어이다. BCPL과 B (프로그래밍 언어)에서 이어진 간결한 문법과 표현식을 바탕으로 하며, 기계어에 가까운 메모리와 데이터 표현을 다룰 수 있으면서도 특정 하드웨어의 명령어에 직접 종속되지 않는 고급 언어로 설계되었다. C는 정적 타입과 함수, 구조체, 포인터, 배열, 명시적인 제어 흐름을 제공하며, 작은 언어 핵심과 표준 라이브러리를 조합하여 시스템 소프트웨어부터 일반 응용 프로그램까지 구현할 수 있다. 데니스 리치의 기록에 따르면 B에서 C로의 발전은 1971년부터 1973년 사이에 이루어졌으며, Computer History Museum은 1972년을 C가 공개된 시점으로 기록한다.
C는 UNIX를 개발하고 이식하기 위한 과정에서 형성되었다. 초기 UNIX는 어셈블리어로 작성되었지만 이후 상당 부분이 C로 다시 구현되었고, 운영체제를 새로운 컴퓨터로 옮길 때 하드웨어별 어셈블리 코드를 모두 다시 작성해야 하는 부담을 줄였다. C와 UNIX가 함께 여러 대학과 연구기관으로 확산되면서 C는 운영체제와 컴파일러, 장치 드라이버, 네트워크 소프트웨어를 작성하는 대표적인 시스템 프로그래밍 언어로 자리 잡았다.
C 프로그램은 일반적으로 컴파일러를 통해 대상 프로세서의 네이티브 기계어로 변환되지만, C 언어 자체가 하나의 특정 컴파일 방식이나 운영체제, 실행 파일 형식을 요구하는 것은 아니다. 국제 표준은 C 프로그램의 표현과 구문, 제약 조건, 의미 규칙, 입출력 데이터와 표준 라이브러리의 동작을 정의하고, 서로 다른 데이터 처리 시스템에서 프로그램을 이식할 수 있는 공통 기준을 제공한다. 현재 국제 표준은 2024년에 채택된 ISO/IEC 9899:2024, 통칭 C23이며, C 표준화는 ISO/IEC JTC 1/SC 22의 WG14가 담당한다.
C는 하드웨어 주소를 나타내는 포인터와 비트 연산, 사용자 정의 메모리 배치, 운영체제 및 외부 라이브러리와 연결하기 쉬운 호출 인터페이스를 제공한다. 언어가 자동 가비지 컬렉션이나 객체 수명 관리를 기본적으로 제공하지 않으므로, 동적으로 할당한 메모리와 파일, 장치 등의 자원은 프로그램이 직접 관리하거나 별도의 라이브러리와 설계 규칙을 통해 관리해야 한다. 이러한 구조는 실행 비용과 메모리 사용을 세밀하게 제어할 수 있게 하지만, 배열 범위를 벗어난 접근과 해제된 메모리의 사용, 잘못된 포인터 연산처럼 프로그램의 동작을 보장할 수 없게 만드는 오류도 발생할 수 있다.
C의 추상화는 대체로 실제 컴퓨터의 자료 표현과 효율적으로 대응할 수 있도록 구성되어 있다. 정수와 부동소수점 타입의 정확한 크기, 정렬과 일부 연산 특성은 대상 구현체에 따라 달라질 수 있으며, 언어 표준은 모든 하드웨어를 하나의 고정된 기계 구조로 가정하지 않는다. 이로 인해 동일한 C 소스 코드를 다양한 프로세서와 운영체제로 이식할 수 있지만, 구현체가 정하도록 허용된 동작이나 정의되지 않은 동작에 의존한 프로그램은 환경에 따라 서로 다르게 실행될 수 있다.
C는 운영체제 커널과 시스템 라이브러리, 컴파일러, 데이터베이스 엔진, 임베디드 펌웨어, 네트워크 장비, 게임 엔진과 과학 계산 라이브러리 등 성능과 메모리 제어가 중요한 분야에서 사용된다. 또한 C++, Objective-C, Java, C#, JavaScript, Go (프로그래밍 언어), Rust를 비롯한 여러 언어의 구문과 타입, 실행 모델 및 외부 함수 인터페이스에 영향을 주었다. 수십 년 동안 축적된 C 코드와 ABI, 운영체제 API 및 하드웨어용 도구 체계는 오늘날에도 C가 새로운 시스템과 기존 소프트웨어를 연결하는 공통 언어로 사용되는 기반을 이룬다.
역사
C의 역사는 하나의 완성된 언어를 처음부터 설계한 과정이 아니라, 1960년대 말 Bell Labs에서 진행되던 운영체제 연구와 제한된 하드웨어 환경 속에서 BCPL, B (프로그래밍 언어), NB를 차례로 개조하며 형성한 진화의 역사이다. C를 설계한 데니스 리치는 C가 초기 UNIX와 함께 1969년부터 1973년 사이에 만들어졌으며, 가장 창조적인 변화가 1972년에 집중되었다고 기록했다.[1]
오늘날 C의 핵심으로 인식되는 타입과 포인터, 배열, 구조체, 선언 문법과 전처리기는 하나의 설계서에 따라 한꺼번에 완성된 것이 아니다. C는 실제 운영체제와 컴파일러, 파일 처리 도구를 구현하는 과정에서 발견된 문제를 해결하며 단계적으로 형성되었다. 이후 UNIX를 다른 컴퓨터로 이식하면서 타입 체계와 이식성이 강화되었고, 《The C Programming Language》의 출간과 ANSI·ISO 표준화를 거쳐 특정 연구소의 시스템 구현 언어에서 국제 표준 프로그래밍 언어로 발전했다.
BCPL과 언어적 계보
C의 직접적인 언어적 계보는 BCPL에서 시작된다. BCPL은 마틴 리처즈가 컴파일러와 시스템 소프트웨어를 작성하기 위해 설계한 언어로, 1967년에 초기 참조 설명서가 작성되었다. Bell Labs에서도 1969년 무렵 BCPL 구현과 관련 연구가 이루어졌다.[2]
BCPL은 사실상 하나의 기계어 워드만을 기본 데이터 단위로 사용하는 비타입 언어였다. 메모리는 동일한 크기의 셀이 연속된 공간으로 간주되었으며, 하나의 값은 연산에 따라 정수나 주소 등으로 해석될 수 있었다. 배열 역시 독립적인 고수준 객체라기보다 첫 번째 메모리 셀의 위치와 인덱스 계산의 조합으로 표현되었다.
BCPL과 이후의 B에서 배열 접근은 개념적으로 다음과 같은 관계를 가졌다.
V[i]
*(V + i)
이 관계는 C에도 이어졌지만, C에서는 포인터가 가리키는 자료형의 크기에 따라 포인터 연산의 이동 단위가 결정되도록 발전했다.
BCPL은 함수와 전역 선언, 조건문과 반복문, 포인터에 해당하는 간접 참조 및 별도 컴파일을 지원했다. C의 함수 중심 구조와 여러 연산자, 제어문은 BCPL과 B에서 이어졌지만, C 전체가 BCPL의 문법을 그대로 복제한 것은 아니다. C의 자료형과 구조체, 선언 체계와 전처리기 등은 이후 별도로 발전했다.
Multics 철수와 UNIX의 시작
1960년대 말 Bell Labs는 MIT와 General Electric이 함께 개발하던 Multics 프로젝트에 참여하고 있었다. Multics는 시분할과 다중 사용자, 계층형 파일 시스템, 동적 연결과 강력한 보호 체계를 목표로 한 대규모 운영체제였다.
그러나 개발의 복잡성과 지연이 커지면서 Bell Labs는 1969년 무렵 Multics 프로젝트에서 철수했다. 켄 톰프슨과 데니스 리치 등은 Multics에서 얻은 경험을 바탕으로 더 작고 직접 사용할 수 있는 새로운 컴퓨팅 환경을 만들기 시작했다.[3]
초기 UNIX는 DEC PDP-7에서 개발되었다. 당시에는 해당 컴퓨터에서 사용할 수 있는 적합한 고급 언어와 개발 환경이 없었으므로 운영체제의 핵심은 어셈블리어로 작성되었다. 연구자들은 작은 컴퓨터에서도 구현할 수 있고 어셈블리어보다 사용하기 쉬운 시스템 프로그래밍 언어를 필요로 했다.
초기 UNIX의 주요 개념에는 다음이 포함되었다.
- 프로세스
- 계층형 파일 시스템
- 사용자 공간에서 실행되는 명령 해석기
- 단순한 텍스트 파일
- 파일과 장치에 대한 공통 인터페이스
- 작고 조합 가능한 명령행 도구
C는 이러한 UNIX 환경에서 프로그램과 운영체제를 직접 구현하기 위한 필요에서 발전했다.
B의 개발
켄 톰프슨은 1969년부터 1970년 사이 BCPL을 단순화한 B (프로그래밍 언어)를 개발했다. B는 BCPL의 전반적인 계산 모델과 함수 중심 구조를 유지하면서, 매우 작은 PDP-7 환경에서도 컴파일러와 프로그램을 실행할 수 있도록 축소되었다.
B 역시 하나의 기계어 워드를 중심으로 동작하는 비타입 언어였다. 정수와 주소를 언어 차원에서 엄격하게 구분하지 않았고, 배열 변수에는 연속된 메모리의 시작 위치를 나타내는 값이 저장되었다.
B의 함수는 다음과 비슷한 형태로 작성되었다.
max(a, b) {
if (a > b)
return a;
return b;
}
이 코드는 B의 문법적 형태를 보여주기 위해 단순화한 예시이다. B는 중괄호 블록과 함수 호출, 조건문과 return 등 이후 C에 이어지는 여러 문법을 이미 갖고 있었다.
B에는 이후 C의 대표적인 연산자가 된 ++와 --가 도입되었다. 이 연산자들이 PDP-11의 자동 증가 주소 지정 때문에 만들어졌다는 설명이 알려져 있지만, B가 개발되던 시점에는 PDP-11이 아직 사용되지 않았다. 리치는 작은 문법으로 증가와 감소를 표현하고 효율적인 코드를 생성하는 것이 직접적인 동기였다고 설명했다.[4]
초기 B와 C의 복합 대입 연산자는 현재와 순서가 반대였다.
value =+ 1;
value =- 1;
value =* 2;
현재의 C에서는 다음과 같이 작성한다.
value += 1;
value -= 1;
value *= 2;
PDP-7의 B 컴파일러는 네이티브 기계어를 직접 생성하는 대신, 작은 코드 조각의 주소를 나열하는 threaded code를 만들고 이를 인터프리터에서 실행했다. 컴파일러의 크기를 줄일 수 있었지만 실행 속도가 느렸고, PDP-7의 제한된 메모리에서 UNIX 전체를 B로 다시 작성하기에는 부족했다.
PDP-11과 B의 한계
UNIX 프로젝트가 가능성을 보이면서 Bell Labs 연구진은 1970년에 DEC PDP-11을 확보했다. 초기 PDP-11용 UNIX도 어셈블리어로 구현되었지만, 새로운 하드웨어는 B의 단일 워드 자료형이 가진 한계를 명확하게 드러냈다.
PDP-11은 16비트 워드와 바이트 단위 주소 체계를 사용했다. 기존 BCPL과 B의 워드 중심 구조에서는 정수와 주소, 문자 데이터를 하나의 워드로 취급했지만, PDP-11에서는 다음 요소를 구분할 필요가 있었다.
- 8비트 문자
- 16비트 정수
- 바이트 단위 메모리 주소
- 포인터와 배열
- 부동소수점 값
B에서는 문자를 다루기 위해 워드 안에서 특정 바이트를 추출하고 다시 조합해야 했다. 포인터 역시 워드 인덱스에서 실제 바이트 주소로 바꾸는 추가 계산을 필요로 했다. 하나의 워드로 모든 값을 표현하는 방식으로는 PDP-11의 하드웨어를 자연스럽고 효율적으로 사용할 수 없었다.
데니스 리치는 1971년부터 B에 char와 int 등의 자료형을 추가하고, threaded code 대신 PDP-11 기계어를 직접 생성하는 새로운 컴파일러를 작성하기 시작했다.
NB에서 C로
B와 C 사이의 과도기 언어는 NB 또는 New B라고 불렸다. NB의 소스 코드는 현재 발견되지 않았으며, 데니스 리치의 회고와 이후 남은 컴파일러 자료를 통해 그 성격을 추정할 수 있다.[5]
NB는 char와 int, 각 타입의 배열과 포인터를 제공했다. 포인터 연산은 가리키는 타입의 크기에 따라 조정되었다.
char *characters;
int *numbers;
characters += 1;
numbers += 1;
두 연산 모두 포인터에 1을 더하지만, 실제 주소 이동량은 각각 char와 int의 크기에 따라 달라진다.
NB의 배열은 여전히 B와 비슷한 방식으로 구현되었다. 배열 변수 내부에 별도로 할당된 저장 공간의 주소를 보관하는 구조였다. 단순한 배열에서는 사용할 수 있었지만 구조체 안에 배열을 포함하려 할 때 문제가 발생했다.
구조체가 파일이나 장치, 메모리상의 실제 데이터 배치를 표현하려면 배열 요소가 구조체 안에 직접 연속 배치되어야 했다. 배열마다 별도의 숨겨진 포인터를 가진다면 구조체의 실제 메모리 배치가 의도한 데이터와 일치하지 않았다.
리치는 이 문제를 해결하면서 C의 배열과 포인터 관계를 형성한 핵심 규칙을 만들었다. 배열은 요소가 연속된 실제 객체가 되고, 배열 이름은 대부분의 표현식에서 첫 번째 요소를 가리키는 포인터 값으로 변환되었다.
int values[4];
int *first = values;
int *same = &values[0];
이 규칙은 구조체 내부에 배열을 직접 포함할 수 있게 했고, 기존 B 코드의 배열 접근 방식도 상당 부분 유지했다.
초기 C의 형성
타입 체계와 새 컴파일러가 일정한 형태를 갖추자 리치는 NB라는 이름을 버리고 언어를 C라고 부르기 시작했다. C라는 이름이 단순히 B 다음 글자이기 때문인지, BCPL의 다음 단계라는 뜻인지에 대해서 리치는 하나의 설명으로 확정하지 않았다.
1972년과 1973년은 비타입 B에서 타입을 가진 C로 전환된 핵심 시기였다. 데니스 리치가 보존한 last1120c와 prestruct-c는 이 시기에 작성된 초기 C 컴파일러 자료이다. 정확한 작성 날짜는 당시 테이프의 시간 정보와 복사 과정 때문에 확정하기 어렵지만, 두 컴파일러가 1972년에서 1973년 사이의 자료라는 점은 분명하다.[6]
1972년 저작권 표시가 남아 있는 last1120c에는 다음과 같은 함수 정의가 나타난다.
main(argc, argv)
int argv[];
{
/* ... */
}
이 코드는 당시의 실제 형식을 짧게 발췌한 것이다. 현대 C와 달리 main의 반환 타입이 없으며, 매개변수 이름을 먼저 나열하고 타입은 다음 줄에서 별도로 선언한다. argv 역시 현대의 char **argv가 아니라 int argv[]로 선언되어 있다.[7]
같은 컴파일러에는 당시의 복합 대입 연산자가 실제로 사용되었다.
i =+ *sp++;
i =% hshsiz;
i =* pssiz;
초기 컴파일러는 오늘날의 일반적인 복합 선언 문법을 지원하지 않았다. 포인터는 다음과 같은 배열 표기로 선언되었다.
int pointer[];
이후에는 포인터와 배열, 함수 타입을 조합할 수 있는 선언 체계가 발전했다.
int *pointer;
int **pointer_to_pointer;
int (*callback)(int);
last1120c에는 구조체가 존재하지 않았으며 소스에도 struct라는 문자열이 나타나지 않는다. 이후의 prestruct-c는 구조체를 지원하기 시작했고, .과 -> 연산자를 사용했다. 다만 초기 구조체 선언은 현재와 달리 중괄호가 아닌 괄호를 사용하는 형태였다.[8]
당시에는 C 전처리기도 존재하지 않았다. 토큰 종류와 연산자 속성 등이 이름 있는 매크로가 아니라 숫자 상수로 컴파일러 소스 곳곳에 직접 기록되었다.
초기 컴파일러는 여러 파일로 구성된 두 개의 패스를 사용했다. 첫 번째 패스는 소스를 구문 분석하여 구문 트리를 만들고, 두 번째 패스는 트리를 읽어 PDP-11 어셈블리 코드를 생성했다. 오늘날의 컴파일러와 비교하면 매우 작고 원시적인 구조였지만, C로 작성된 컴파일러가 다시 C 프로그램을 번역하는 자기 호스팅 체계의 초기 형태를 보여준다.
연산자와 선언 체계의 발전
초기 B와 C에서는 &와 |가 비트 연산뿐 아니라 조건식의 논리 연산에도 사용되었다. 논리 연산과 비트 연산을 구분하기 위해 &&와 ||가 추가되었다.
if (pointer != 0 && *pointer != 0) {
process(*pointer);
}
&&는 왼쪽 조건이 거짓이면 오른쪽 조건을 평가하지 않고, ||는 왼쪽 조건이 참이면 오른쪽 조건을 평가하지 않는 단락 평가를 제공했다.
C의 선언 문법은 선언된 이름이 표현식에서 사용되는 모습을 반영하도록 발전했다.
int value;
int *pointer;
int values[10];
int function(int value);
int (*callback)(int value);
*pointer가 int 값을 나타내고, values[index]가 int 값을 나타내며, function(value)의 결과가 int가 되는 형태로 선언을 읽을 수 있게 한 것이다.
배열과 포인터, 함수를 여러 단계로 결합할 수 있는 선언 체계는 초기 컴파일러에는 없었지만 1975년 무렵에는 현재와 가까운 형태로 발전했다.
C 전처리기의 등장
1972년부터 1973년 사이에는 C 전처리기가 도입되기 시작했다. 초기 전처리기는 파일 포함과 매개변수가 없는 단순한 매크로 치환을 제공했다.
#include "header.h"
#define BUFFER_SIZE 512
이후에는 함수형 매크로와 조건부 컴파일이 추가되었다.
#define MAX(left, right) \
((left) > (right) ? (left) : (right))
#ifdef PDP11
/* PDP-11 전용 코드 */
#endif
전처리기는 C의 일반적인 구문 분석 전에 토큰을 변환하는 별도의 번역 단계로 구현되었다. 이 역사 때문에 전처리 지시문은 일반 C 문장과 다른 문법과 처리 규칙을 가진다.
초기의 hello, world
브라이언 커니핸이 Bell Labs에서 작성한 초기 C 튜토리얼 《Programming in C — A Tutorial》에는 다음과 같은 프로그램이 실렸다.
main() {
printf("hello, world");
}
이 코드는 오늘날 널리 사용되는 Hello, world! 예제의 초기 형태이다. main의 반환 타입과 printf의 선언, 헤더 파일과 명시적인 반환문이 없다.[9]
현대 C에서 같은 프로그램을 K&R 스타일로 작성하면 다음과 같다.
#include <stdio.h>
int main(void) {
printf("hello, world\n");
return 0;
}
초기 C에서는 선언되지 않은 함수가 정수를 반환한다고 암묵적으로 가정할 수 있었다. 따라서 printf를 호출하기 전에 함수 선언을 포함하지 않는 코드가 허용되었다. 이러한 암묵적 함수 선언은 타입 불일치와 이식성 문제를 일으켰고, 이후 C99에서 제거되었다.
UNIX의 C 재작성
1973년 초가 되자 현대 C의 핵심 요소 대부분이 갖춰졌다. 컴파일러가 생성하는 코드의 크기와 성능도 어셈블리어로 작성한 시스템 프로그램을 대체할 수 있는 수준에 도달했다.
켄 톰프슨은 구조체가 완성되기 전인 1972년에 초기 C로 UNIX 커널을 옮기려 했지만, 당시 언어가 충분히 성숙하지 않아 작업을 중단했다. 구조체와 타입 체계가 발전한 뒤 1973년 여름 UNIX 커널이 C로 다시 작성되었다.[10]
UNIX 제4판 무렵의 커널 소스에는 다음과 같은 형태가 나타난다.
proc[0].p_stat = SRUN;
proc[0].p_flag =| SLOAD|SSYS;
u.u_procp = &proc[0];
구조체 구성원 접근과 배열, 주소 연산은 현재와 유사하지만 복합 대입은 아직 |=가 아니라 =|로 작성되었다.
당시의 커널 함수는 다음과 같은 구식 함수 정의를 사용했다.
main()
{
register i1, *p;
/* ... */
}
이 코드는 역사 자료이므로 당시 형식을 유지한 것이다. 반환 타입과 i1의 타입이 생략되어 있으며, 생략된 타입은 int로 간주될 수 있었다.
당시 장치 드라이버는 장치의 메모리 주소와 제어 비트를 정수 상수로 표현했다.
#define LPADDR 0177514
#define IENABLE 0100
C는 배열과 포인터, 구조체와 비트 연산을 통해 운영체제의 프로세스 테이블, 파일 시스템과 장치 레지스터를 표현할 수 있었다. 동시에 어셈블리어보다 하드웨어에 덜 종속적이었기 때문에 UNIX를 다른 컴퓨터로 옮길 수 있는 기반이 되었다.
운영체제를 고급 언어로 구현한 최초의 사례가 C와 UNIX였던 것은 아니지만, C로 작성된 UNIX는 작은 언어와 네이티브 성능, 하드웨어 제어와 이식성을 함께 제공할 수 있음을 보여주었다.
초기 C 참조 설명서
1974년과 1975년 무렵에는 C 구현을 설명하는 참조 설명서와 튜토리얼이 정리되었다. 데니스 리치의 《C Reference Manual》은 PDP-11용 C의 어휘와 타입, 표현식, 선언과 프로그램 구조를 설명했다.
당시 C에는 다음과 같은 기본 타입이 있었다.
char character;
int integer;
float single_precision;
double double_precision;
기본 타입에서 배열과 포인터, 함수와 구조체를 만들 수 있었다.
int values[10];
int *pointer;
int function();
struct point {
int x;
int y;
};
함수 정의에는 매개변수 이름과 타입을 분리하는 형식이 사용되었다.
int add(a, b)
int a, b;
{
return a + b;
}
이 형식은 이후 구식 함수 정의, K&R 함수 정의 문법 등으로 불렸다. 이것은 코드 스타일로서의 K&R 중괄호 배치와는 별개의 개념이다.
현대적인 함수 선언 문법과 K&R 코드 스타일을 사용하면 다음과 같다.
int add(int a, int b) {
return a + b;
}
배열과 포인터의 관계도 당시 설명서에서 중요한 언어 규칙으로 자리 잡았다.
array[index]
*(array + index)
함수의 인수는 값으로 전달되었으며, 호출자의 객체를 변경하려면 포인터를 전달했다.
void swap(int *left, int *right) {
int temporary = *left;
*left = *right;
*right = temporary;
}
이식성과 Portable C Compiler
C와 UNIX의 이식성은 언어가 처음 만들어질 때부터 완성된 최우선 목표는 아니었다. 초기에는 연구진이 실제로 사용할 수 있는 작은 시스템을 만드는 데 초점이 있었고, 다른 기계로 옮길 수 있다는 장점은 이후의 실제 이식 작업을 통해 중요해졌다.
스티븐 C. 존슨은 새로운 프로세서에 비교적 쉽게 맞출 수 있도록 Portable C Compiler를 개발했다. 존슨과 톰프슨, 리치는 UNIX를 Interdata 8/32로 이식했고, 이후 VAX를 비롯한 여러 아키텍처로 확장했다.[11]
Interdata 이식은 C 코드와 UNIX가 PDP-11에 완전히 종속되어 있지 않음을 보여주었다. 그러나 이 과정에서 초기 C에 남아 있던 비타입 언어의 흔적도 문제가 되었다.
초기 프로그램에서는 정수와 포인터 사이의 느슨한 변환이 사용될 수 있었다.
int address;
address = pointer;
pointer = address;
정수와 포인터의 크기가 다른 시스템에서는 이러한 코드가 올바르게 동작하지 않았다. 이후 C는 포인터 타입을 더 명확하게 구분하고, 필요한 변환을 명시적으로 작성하는 방향으로 발전했다.
현대 C에서는 플랫폼이 제공하는 적절한 정수 타입을 사용할 수 있다.
#include <stdint.h>
uintptr_t address = (uintptr_t)pointer;
pointer = (void *)address;
unsigned 타입과 명시적인 타입 캐스트가 추가되고, 구조체 포인터와 함수 호출의 타입 규칙도 강화되었다.
lint의 개발
초기 C 컴파일러는 여러 번역 단위 사이의 함수 호출과 타입 불일치를 충분히 검사하지 못했다. 이를 보완하기 위해 스티븐 C. 존슨은 lint를 개발했다.
초기 함수 선언에는 매개변수 타입이 포함되지 않았다.
int calculate();
이 상태에서는 다음 호출이 실제 함수 정의와 일치하는지 호출 지점만 보고 완전히 검사하기 어려웠다.
double result = calculate(1.5);
lint는 여러 소스 파일을 분석해 함수 인수와 반환 타입, 의심스러운 정수·포인터 변환, 사용되지 않는 값 등을 진단했다. 이러한 문제는 이후 ANSI C에서 함수 원형을 도입하는 중요한 배경이 되었다.
표준 입출력 라이브러리
마이크 레스크가 1973년 무렵 작성한 이식 가능한 입출력 패키지는 이후 C 표준 입출력 라이브러리의 기초가 되었다. 초기 UNIX 프로그램은 운영체제의 저수준 파일 디스크립터와 시스템 호출을 직접 사용했지만, 이식 가능한 입출력 패키지는 서로 다른 시스템에서도 유사한 방식으로 문자와 파일을 처리할 수 있는 상위 인터페이스를 제공했다.
현대 C의 표준 입출력은 다음과 같은 형태로 사용된다.
#include <stdio.h>
int copy_stream(FILE *input, FILE *output) {
int character;
while ((character = fgetc(input)) != EOF) {
if (fputc(character, output) == EOF) {
return -1;
}
}
return ferror(input) ? -1 : 0;
}
언어 자체와 라이브러리를 분리한 구조는 C의 작은 언어 핵심을 유지하면서도 입출력과 문자열, 메모리 할당 등 공통 기능을 이식 가능하게 제공하는 기반이 되었다.
K&R C
1978년 브라이언 커니핸과 데니스 리치는 《The C Programming Language》를 출간했다. 이 책은 저자들의 성을 따라 K&R이라고 불렸으며, 흰색 표지 때문에 white book이라고도 불렸다.
K&R은 최초의 C 문서는 아니었지만 언어와 라이브러리의 핵심을 간결하고 일관된 형태로 널리 소개했다. 공식 표준이 만들어질 때까지 약 10년 동안 사실상의 C 언어 정의로 사용되었다.[12]
K&R 초판 시기의 전형적인 프로그램은 다음과 같은 형태였다.
#include <stdio.h>
main()
{
printf("hello, world\n");
}
이 코드는 역사적 문법을 보여주는 것이므로 원래 형태를 유지했다. main의 반환 타입이 생략되어 있고, 빈 매개변수 목록은 인수가 없다는 명확한 선언이 아니라 매개변수 정보가 지정되지 않았다는 의미였다.
매개변수를 가진 함수는 다음과 같은 구식 정의 문법을 사용했다.
int maximum(a, b)
int a, b;
{
return a > b ? a : b;
}
함수 선언은 다음과 같이 작성할 수 있었지만 매개변수 타입을 포함하지 않았다.
int maximum();
K&R 초판 이후에도 C는 계속 변했다. 1970년대 후반부터 1980년대 초 사이에 다음 기능이 언어와 구현체에 확산되었다.
unsignedlongunionenumvoid- 구조체 대입
- 구조체의 인수 및 반환
- 더 엄격한 포인터 타입 검사
- 현재 형태의 복합 대입 연산자
현재 형태의 복합 대입 연산자는 다음과 같다.
value += offset;
flags |= ENABLED;
count *= 2;
기존의 =+, =|, =*는 사라졌다.
개인용 컴퓨터와 산업계로의 확산
1980년대에 들어 C 컴파일러는 주요 컴퓨터 아키텍처와 운영체제에 제공되기 시작했다. 초기에는 많은 컴파일러가 Portable C Compiler의 설계와 코드를 활용했지만, 점차 독립적인 상용 컴파일러와 개발 환경이 등장했다.
C는 UNIX 워크스테이션뿐 아니라 다음 환경으로 확산되었다.
- 개인용 컴퓨터
- MS-DOS 응용 프로그램
- 상용 운영체제
- 임베디드 제어기
- 네트워크 장비
- 데이터베이스
- 그래픽 소프트웨어
- 게임과 시스템 도구
C 컴파일러를 새로운 컴퓨터에 구현하면 운영체제와 개발 도구, 라이브러리 및 응용 프로그램을 함께 이식할 수 있었다. C는 다른 언어의 컴파일 결과를 표현하는 이식 가능한 저수준 대상 언어로도 사용되었다.
이 시기에는 C++와 Objective-C가 C를 직접 확장하는 방식으로 등장했다. 이후 Java와 C#, JavaScript 등도 C의 중괄호 블록과 표현식, 연산자와 제어문 문법에 큰 영향을 받았다.
표준화의 필요성
C가 여러 회사와 하드웨어, 운영체제로 확산되면서 서로 호환되지 않는 방언도 증가했다. K&R 초판은 널리 사용되었지만 언어의 모든 세부 사항을 완전하게 규정한 공식 표준은 아니었다.
컴파일러 개발사는 다음 요소를 서로 다르게 처리할 수 있었다.
- 정수 타입의 크기와 변환
- 구조체 전달과 반환
- 함수 선언과 호출 검사
- 전처리기 동작
- 표준 라이브러리
- 포인터 변환
- 확장 키워드
- 플랫폼별 호출 규약
같은 C 소스 코드가 컴파일러마다 다르게 해석되거나, 한 환경에서 작성한 프로그램이 다른 환경에서 컴파일되지 않는 문제가 증가했다. 상용 계약과 정부 조달에서도 C 구현체가 어떤 기준에 적합한지 판단할 공식 규격이 필요했다.
ANSI는 1983년 X3J11 기술위원회를 구성하여 C의 미국 국가 표준을 개발하기 시작했다. 위원회의 목적은 새로운 언어를 처음부터 만드는 것이 아니라, 이미 널리 사용되던 C의 공통 관행을 명확하고 일관되게 규정하는 것이었다.[13]
표준화 과정에서는 다음 원칙이 중요하게 다뤄졌다.
- 기존 C 프로그램의 가치를 보존한다.
- 프로그램의 이식성을 높인다.
- 특정 컴파일러 하나를 표준으로 삼지 않는다.
- 하드웨어별 시스템 코드를 작성하는 능력을 유지한다.
- 효율적인 구현을 방해하지 않는다.
- 실제 구현 경험이 있는 기능을 우선한다.
ANSI C와 C89
X3J11은 1989년 ANSI X3.159-1989를 발표했다. 이 판은 C89 또는 ANSI C라고 부른다. ISO는 이를 1990년 ISO/IEC 9899:1990으로 채택했으며, 국제 표준의 발행 연도에 따라 C90이라고도 부른다.[14]
C89의 중요한 변화 가운데 하나는 함수 원형이었다. K&R 방식의 함수 선언은 매개변수의 개수와 타입을 나타내지 않았다.
int calculate();
ANSI C에서는 매개변수 타입을 선언에 포함할 수 있게 되었다.
int calculate(int left, int right);
함수 정의 역시 현대적인 문법과 K&R 스타일로 작성할 수 있었다.
int calculate(int left, int right) {
return left + right;
}
컴파일러는 호출 시 전달되는 인수의 개수와 타입을 검사할 수 있게 되었다.
int result = calculate(10, 20);
C89는 void와 void *, const, volatile 등을 표준화했다.
#include <stddef.h>
static int sum(const int *values, size_t count);
int main(void) {
int values[] = { 1, 2, 3 };
return sum(values, 3) == 6 ? 0 : 1;
}
static int sum(const int *values, size_t count) {
int result = 0;
while (count != 0) {
result += *values++;
--count;
}
return result;
}
main(void)는 함수가 매개변수를 받지 않는다는 사실을 명확하게 나타낸다. const int *는 포인터를 통해 요소를 변경하지 않겠다는 타입 제약을 표현한다.
범용 객체 포인터인 void *도 표준화되었다.
void *memory;
int *numbers;
memory = numbers;
numbers = memory;
C에서는 void *와 객체 포인터 사이의 변환을 명시적인 캐스트 없이 수행할 수 있다. 다만 함수 포인터에는 같은 규칙이 적용되지 않는다.
C89는 전처리기의 동작도 상세하게 규정하고 문자열화 연산자 #와 토큰 결합 연산자 ##를 표준화했다.
#define STRINGIFY(value) #value
#define CONCAT(left, right) left ## right
int CONCAT(user_, id);
표준 라이브러리에는 입출력과 문자열, 메모리 할당, 수학, 시간과 신호 처리 등의 공통 인터페이스가 정리되었다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <time.h>
기존 코드와의 호환성을 위해 구식 함수 정의 문법도 계속 허용되었다. 이 문법은 C23에서 제거되기까지 장기간 남았다.
K&R 제2판
1988년에 출간된 《The C Programming Language》 제2판은 ANSI 표준화 과정에서 형성된 새로운 C를 반영했다. 함수 원형과 void, const 등 현대적인 선언 방식을 설명했으며, 이후 C 학습과 문서 작성에서 널리 사용되었다.
제2판의 문법을 따르는 함수는 다음과 같은 형태를 가진다.
int maximum(int left, int right) {
return left > right ? left : right;
}
함수의 여는 중괄호를 선언과 같은 줄에 두는 이 형식이 K&R 스타일이다.
C95
1990년 국제 표준은 1995년 ISO/IEC 9899:1990/Amd 1:1995로 개정되었다. 이 판은 비공식적으로 C95라고 부른다. 완전히 새로운 표준 판이라기보다 C90에 적용된 개정안이었다.[15]
C95는 제한된 문자 집합에서도 C의 구두점 토큰을 표현할 수 있도록 digraph를 추가했다.
int values<:3:> = <% 10, 20, 30 %>;
위 코드는 다음과 같은 의미를 가진다.
int values[3] = { 10, 20, 30 };
<iso646.h>는 일부 연산자를 단어 형태로 표현하는 매크로를 제공했다.
#include <iso646.h>
int check(int enabled, int failed) {
if (enabled and not failed) {
return 1;
}
return 0;
}
다중 바이트 문자와 와이드 문자 처리도 확장되었으며 <wchar.h>와 <wctype.h>가 추가되었다.
#include <wchar.h>
#include <wctype.h>
int main(void) {
wchar_t character = L'A';
return iswalpha(character) ? 0 : 1;
}
__STDC_VERSION__ 매크로를 사용해 구현체가 지원하는 표준 판을 확인할 수 있게 되었다.
#if defined(__STDC_VERSION__) && \
__STDC_VERSION__ >= 199409L
/* C95 이상의 구현 */
#endif
C99
두 번째 완전한 국제 표준 판인 ISO/IEC 9899:1999는 1999년 12월 발행되었으며 C99라고 부른다. C99는 C89 이후 컴파일러 확장과 수치 계산, 국제화와 현대적인 프로그래밍 관행을 대규모로 반영했다.[16]
C99에서는 블록 중간에 변수를 선언하고 for 문의 초기식에서도 변수를 선언할 수 있게 되었다.
int sum_values(const int *values, int count) {
int total = 0;
for (int index = 0; index < count; ++index) {
total += values[index];
}
return total;
}
C89에서는 일반적으로 블록의 실행문 앞에 선언을 모아야 했다.
int sum_values(const int *values, int count) {
int total;
int index;
total = 0;
for (index = 0; index < count; ++index) {
total += values[index];
}
return total;
}
// 한 줄 주석도 표준에 포함되었다.
int count = 10; // 처리할 요소 수
지정 초기화를 사용하면 구조체 구성원과 배열 인덱스를 이름으로 지정할 수 있다.
struct point {
int x;
int y;
};
struct point position = {
.y = 20,
.x = 10
};
int values[8] = {
[0] = 10,
[7] = 80
};
compound literal은 별도의 이름 없는 객체를 표현할 수 있게 했다.
struct point *create_origin(void) {
static struct point origin = {
.x = 0,
.y = 0
};
return &origin;
}
함수 호출 인수에서 compound literal을 직접 사용할 수도 있다.
draw_point((struct point) {
.x = 10,
.y = 20
});
C99는 long long int와 <stdint.h>의 정수 타입을 도입했다.
#include <stdint.h>
uint32_t flags = UINT32_C(0x80000000);
int64_t distance = INT64_C(9000000000);
실행 시점에 길이가 결정되는 가변 길이 배열도 추가되었다.
void clear_values(int count) {
int values[count];
for (int index = 0; index < count; ++index) {
values[index] = 0;
}
}
restrict 포인터 한정자는 함수가 사용하는 포인터 사이의 별칭 관계에 대한 정보를 구현체에 제공했다.
#include <stddef.h>
void add_arrays(
size_t count,
float *restrict result,
const float *restrict left,
const float *restrict right
) {
for (size_t index = 0; index < count; ++index) {
result[index] = left[index] + right[index];
}
}
inline 함수 지정자도 추가되었다.
inline int square(int value) {
return value * value;
}
inline은 실제 인라인화를 강제하지 않으며, 구체적인 최적화 여부는 구현체가 결정한다.
가변 인수 매크로도 추가되었다.
#define LOG(format, ...) \
fprintf(stderr, format, __VA_ARGS__)
C99에는 _Bool과 <stdbool.h>도 도입되었다.
#include <stdbool.h>
static bool running = true;
void stop(void) {
running = false;
}
복소수 타입과 <complex.h>도 표준화되었다.
#include <complex.h>
double complex multiply(
double complex left,
double complex right
) {
return left * right;
}
구조체 끝에 가변 크기 데이터를 배치할 수 있는 flexible array member도 추가되었다.
#include <stddef.h>
struct packet {
size_t size;
unsigned char data[];
};
C99에서는 암묵적 int와 암묵적 함수 선언이 제거되었다. 다음과 같은 코드는 더 이상 올바른 현대 C 프로그램이 아니다.
main() {
undeclared_function();
}
반환 타입과 함수 선언을 명시해야 한다.
int declared_function(void);
int main(void) {
return declared_function();
}
C99에는 이 밖에도 다음 기능이 포함되었다.
__func__- 16진수 부동소수점 상수
<inttypes.h><tgmath.h><fenv.h>- 유니버설 문자 이름
- 정수 나눗셈 규칙의 명확화
- 구조체 및
union초기화 확장
C99에는 2001년과 2004년, 2007년에 세 차례의 Technical Corrigendum이 발행되었다. WG14 N1256은 이 수정 사항을 합친 공개 작업 문서이다.[17]
C11
세 번째 국제 표준 판인 ISO/IEC 9899:2011은 2011년에 발행되었으며 C11이라고 부른다. C11은 다중 코어와 동시성, 메모리 정렬, 유니코드와 정적 검증 요구를 언어와 표준 라이브러리에 반영했다.[18]
가장 큰 변화는 멀티스레드 실행과 메모리 모델의 표준화였다. _Atomic과 <stdatomic.h>를 이용해 원자적 객체를 표현할 수 있게 되었다.
#include <stdatomic.h>
static _Atomic unsigned int job_count = 0;
void add_job(void) {
atomic_fetch_add(&job_count, 1);
}
표준은 원자적 연산의 순서와 스레드 사이의 메모리 가시성, 데이터 경쟁의 의미를 규정했다.
<threads.h>에는 스레드와 뮤텍스, 조건 변수 및 스레드별 저장소를 위한 인터페이스가 포함되었다.
#include <threads.h>
int worker(void *context) {
return context != 0;
}
int main(void) {
thrd_t thread;
int result;
if (thrd_create(&thread, worker, 0) != thrd_success) {
return 1;
}
thrd_join(thread, &result);
return result;
}
_Static_assert는 번역 시점에 조건을 확인할 수 있게 했다.
#include <stdint.h>
_Static_assert(
sizeof(uint32_t) == 4,
"uint32_t must be four bytes"
);
_Generic은 표현식의 타입에 따라 다른 결과를 선택할 수 있게 했다.
#define TYPE_NAME(value) \
_Generic((value), \
int: "int", \
double: "double", \
default: "other")
const char *get_type_name(void) {
return TYPE_NAME(10);
}
메모리 정렬을 표현하기 위한 _Alignas와 _Alignof도 도입되었다.
_Alignas(64) static unsigned char cache_line[64];
_Static_assert(
_Alignof(cache_line) >= 64,
"insufficient alignment"
);
익명 구조체와 익명 union도 지원되었다.
struct color {
union {
struct {
unsigned char red;
unsigned char green;
unsigned char blue;
unsigned char alpha;
};
unsigned int value;
};
};
반환하지 않는 함수를 나타내는 _Noreturn도 추가되었다.
#include <stdlib.h>
_Noreturn void fatal_error(void) {
quick_exit(EXIT_FAILURE);
}
C11에는 다음 기능도 추가되었다.
<uchar.h>- UTF-16과 UTF-32 문자 타입
aligned_allocquick_exit- 배타적 파일 생성 모드
- 조건부 bounds-checking interface
- 스레드별 저장 기간
일부 기능은 구현체가 선택적으로 제공할 수 있게 되었다. 가변 길이 배열과 스레드, 원자적 연산 및 복소수 기능의 일부는 구현 환경에 따라 지원 여부가 달라질 수 있었다.
C17
C11 이후에는 대규모 언어 기능을 추가하기보다 표준의 결함과 모호성을 수정하는 작업이 진행되었다. 그 결과 ISO/IEC 9899:2018이 발행되었다.
이 판은 작업 연도를 기준으로 C17이라고 부르기도 하고 발행 연도에 따라 C18이라고도 부른다. WG14에서는 일반적으로 C17이라는 명칭을 사용한다.[19]
C17은 새로운 주요 언어 문법을 추가하지 않았다. C11에서 도입한 원자적 연산과 정렬, 스레드와 라이브러리의 규칙을 수정하고 명확하게 하는 판이었다.
따라서 C11 코드는 C17에서도 같은 형태로 사용된다.
#include <stdatomic.h>
static _Atomic int ready = 0;
int main(void) {
atomic_store(&ready, 1);
return atomic_load(&ready) == 1 ? 0 : 1;
}
프로그램은 __STDC_VERSION__을 이용해 C17 이상의 구현인지 확인할 수 있다.
#if !defined(__STDC_VERSION__) || \
__STDC_VERSION__ < 201710L
#error "C17 or later is required"
#endif
C17은 새 문법을 보여주는 표준이라기보다 C11을 정리하고 안정화하여 다음 대규모 개정의 기준을 마련한 판이었다.
C23
WG14는 C11 이후의 다음 대규모 개정을 C2x라는 작업명으로 개발했다. 최종 표준은 C23으로 완성되었으며, ISO와 IEC가 2024년 ISO/IEC 9899:2024로 발행했다. C23이라는 통칭과 공식 발행 연도 2024가 다른 것은 위원회의 개발 주기와 ISO 발행 절차의 차이 때문이다.[20]
C23은 C11과 C17보다 넓은 언어 변화를 포함했다. 기존에 밑줄로 시작하던 여러 이름과 매크로에 일반 키워드 형태가 추가되었다.
bool enabled = true;
static_assert(sizeof(int) >= 2);
thread_local unsigned int worker_id;
이전 표준에서는 다음과 같은 형태가 사용되었다.
_Bool enabled = 1;
_Static_assert(
sizeof(int) >= 2,
"int is too small"
);
_Thread_local unsigned int worker_id;
C23은 typeof와 typeof_unqual을 도입했다.
int value = 10;
typeof(value) another_value = 20;
typeof_unqual(value) copy = value;
constexpr 객체도 도입되었다.
constexpr unsigned int buffer_size = 4096;
static_assert(buffer_size > 0);
이진 정수 리터럴과 작은따옴표 숫자 구분자를 사용할 수 있게 되었다.
unsigned int mask = 0b1111'0000;
long population = 50'000'000;
빈 초기화 목록으로 객체를 0에 해당하는 값으로 초기화할 수 있게 되었다.
struct point {
int x;
int y;
};
struct point position = {};
int values[8] = {};
표준 속성 문법도 추가되었다.
[[nodiscard]]
int parse_document(const char *source) {
return source != nullptr;
}
[[deprecated]]
void legacy_function(void) {
}
C23의 기능을 조합하면 다음과 같은 코드를 작성할 수 있다.
[[nodiscard]]
int calculate_mask(void) {
constexpr unsigned int mask = 0b1111'0000;
bool enabled = true;
typeof_unqual(mask) copy = mask;
static_assert(mask == 240);
return enabled ? (int)copy : 0;
}
전처리기에는 #elifdef, #elifndef, #warning과 #embed가 추가되었다.
#if defined(_WIN32)
#define PLATFORM_NAME "Windows"
#elifdef __linux__
#define PLATFORM_NAME "Linux"
#else
#warning "Unknown platform"
#endif
#embed는 외부 바이너리 데이터를 번역 과정에서 포함할 수 있게 한다.
static const unsigned char icon_data[] = {
#embed "icon.bin"
};
C23은 nullptr과 nullptr_t도 도입했다.
#include <stddef.h>
int *pointer = nullptr;
nullptr_t null_value = nullptr;
구식 식별자 목록 방식의 함수 정의는 제거되었다.
/* C23에서 제거된 구식 함수 정의 */
int add(a, b)
int a, b;
{
return a + b;
}
현대적인 선언과 K&R 스타일을 사용해야 한다.
int add(int a, int b) {
return a + b;
}
빈 괄호를 사용한 함수 선언의 의미도 바뀌었다.
int function();
과거에는 매개변수에 대한 정보가 지정되지 않은 선언이었지만 C23에서는 매개변수가 없는 함수 선언으로 해석된다.
C23에는 _BitInt 기반 비트 정밀 정수 타입도 추가되었다.
_BitInt(24) signed_value = 0;
unsigned _BitInt(7) small_value = 100;
auto를 이용한 객체 타입 추론도 도입되었다.
auto integer = 42;
auto floating = 3.14;
C23에는 다음 변화도 포함되었다.
- 2의 보수 부호 있는 정수 표현
- 고정 기반 타입을 가진 열거형
- UTF-8·UTF-16·UTF-32 처리 개선
u8문자 및 문자열 규칙strdup과strndupmemccpygmtime_r과localtime_r- 부동소수점 확장 통합
- 라벨 앞의 선언과 라벨 끝의 복합문 허용
- 기존의 여러 구식 문법 제거
WG14의 공개 작업 문서 N3220은 ISO/IEC 9899:2024의 문법과 의미, 라이브러리 및 이전 판에서의 주요 변경 사항을 정리한다.[21]
C23 이후
C23 개정 주기가 끝난 뒤 WG14는 다음 표준 판을 위한 C2y 작업을 시작했다. C2y는 최종 표준 명칭이 아니라 다음 C 표준 판의 작업명이다. N3220은 C23 주기가 끝난 뒤 차기 판 작업이 2024년 1월 시작되었다고 기록한다.[22]
차기 개정에서는 다음 영역이 논의되고 있다.
- 포인터 provenance
- 메모리 객체 모델
- 정의되지 않은 동작의 축소와 진단
- 타입 안전성
- 배열 크기 표현
- 함수 호출과 가변 인수
constexpr의 확장- 문자열과 메모리 API
- 기존 표준 판과의 호환성
- C++ 및 다른 언어와의 상호운용
C2y는 아직 작업 중이므로 제안된 모든 기능이 최종 표준에 포함된다고 단정할 수 없다. WG14의 토론과 투표, 국가 표준 기관의 검토를 거쳐 최종 내용이 결정된다.
코드 형태의 변화
C의 역사적 변화는 동일한 계산을 작성하는 코드 형태에서도 확인할 수 있다.
1970년대 초기 C에서는 다음과 같은 구식 함수 정의와 복합 대입 연산자가 사용되었다.
main(argc, argv)
int argv[];
{
value =+ 1;
}
K&R C 시기의 함수는 다음과 같은 형태였다.
int add(a, b)
int a, b;
{
return a + b;
}
ANSI C에서는 매개변수 타입을 함수 선언 안에 넣을 수 있게 되었다.
int add(int a, int b) {
return a + b;
}
C99에서는 반복문 안의 선언과 한 줄 주석 등을 사용할 수 있게 되었다.
int sum(int count) {
int result = 0;
for (int index = 0; index < count; ++index) {
result += index;
}
return result;
}
C11에서는 원자적 연산과 정적 검증을 표현할 수 있게 되었다.
#include <stdatomic.h>
static _Atomic int ready = 0;
int initialize(void) {
_Static_assert(
sizeof(int) >= 2,
"invalid int"
);
atomic_store(&ready, 1);
return 0;
}
C23에서는 새로운 키워드와 리터럴, 속성을 사용할 수 있다.
[[nodiscard]]
int create_mask(void) {
constexpr unsigned int mask = 0b1111'0000;
bool ready = true;
static_assert(mask == 240);
return ready ? (int)mask : 0;
}
이 변화는 서로 완전히 다른 언어가 차례로 교체된 것이 아니다. 초기 C의 함수와 표현식, 포인터와 배열, 구조체와 직접적인 메모리 모델을 유지하면서 타입 검사와 이식성, 수치 계산, 동시성, 국제화와 현대적인 문법을 점진적으로 추가한 결과이다.
역사적 의미
C는 초기 UNIX를 구현하기 위한 작은 시스템 언어에서 출발했지만, 표준화를 거쳐 특정 운영체제와 프로세서에 속하지 않는 국제 표준 언어가 되었다.
초기의 BCPL과 B에서 이어진 포인터와 배열 모델, PDP-11의 제약에서 만들어진 타입 체계, UNIX 이식 과정에서 강화된 타입 검사와 Portable C Compiler, K&R을 통한 보급, ANSI와 ISO의 보수적인 표준화 원칙은 오늘날의 C에도 직접 남아 있다.
C의 역사적 연속성은 오래된 문법과 일부 불규칙성이 남게 된 원인이기도 하다. 동시에 수십 년 전에 작성된 코드와 현대의 컴파일러, 운영체제와 하드웨어를 연결할 수 있게 한 기반이기도 하다.
데니스 리치는 C를 결함과 특이점이 있지만 어셈블리어를 대체할 정도로 효율적이고, 다양한 환경의 알고리즘과 시스템 상호 작용을 표현할 만큼 충분히 추상적인 언어로 평가했다.[23]
C의 역사는 자동화된 안전성과 직접적인 통제, 추상화와 기계 의존성, 새로운 기능과 기존 코드의 호환성 사이에서 균형을 조정해 온 과정이라고 할 수 있다.
언어의 특징
C는 범용 고급 프로그래밍 언어이면서 하드웨어와 운영체제에 가까운 작업을 수행할 수 있는 시스템 프로그래밍 언어이다. 일반적인 계산과 데이터 처리, 응용 프로그램을 표현할 수 있는 제어 구조와 함수, 복합 자료형을 제공하는 한편, 메모리 주소와 비트 단위 데이터, 하드웨어 레지스터와 외부 함수를 직접 다룰 수 있는 기능도 제공한다. WG14는 C를 저수준 프로그래밍에 적합한 범용 고급 언어로 설명하며, 이식성·상호운용성·효율성·안정성을 언어의 두드러진 특성으로 제시한다.[24]
C의 특징은 단순히 “기계어와 가까운 언어”라는 표현만으로 설명하기 어렵다. C 프로그램은 표준이 정의한 추상 기계를 대상으로 작성되며, 실제 명령어와 레지스터 배치, 호출 규약과 실행 파일 형식은 구현체와 플랫폼이 결정한다. 그러나 C의 정수와 배열, 구조체, 포인터와 명시적인 메모리 관리 방식은 실제 컴퓨터의 자료 표현과 비교적 직접적으로 대응할 수 있도록 설계되었다. 이 때문에 동일한 언어가 운영체제 커널과 장치 드라이버, 임베디드 펌웨어뿐 아니라 데이터베이스와 그래픽 라이브러리, 일반 응용 프로그램에도 사용될 수 있다.
범용 언어와 시스템 프로그래밍 언어
C는 특정한 한 종류의 프로그램만을 대상으로 하지 않는다. 함수와 조건문, 반복문, 구조체와 배열, 산술 연산과 표준 라이브러리를 사용하여 일반적인 응용 프로그램을 작성할 수 있으므로 범용 프로그래밍 언어로 분류된다.
동시에 C는 다음과 같은 시스템 수준의 작업을 표현할 수 있다.
- 메모리 주소를 포인터로 다룬다.
- 메모리 영역의 배치와 정렬을 제어한다.
- 정수의 개별 비트를 검사하고 변경한다.
- 하드웨어 레지스터에 대응하는 메모리에 접근한다.
- 운영체제의 시스템 호출과 네이티브 라이브러리를 호출한다.
- 별도의 런타임 환경 없이 제한된 장치에서 실행할 수 있다.
- 특정 프로세서와 운영체제에 맞는 확장 기능을 사용할 수 있다.
시스템 프로그래밍 언어라는 분류가 C 프로그램이 반드시 운영체제나 드라이버여야 한다는 뜻은 아니다. C는 일반 목적의 언어이지만, 추상화 아래의 자료 표현과 실행 비용을 프로그래머가 비교적 직접 통제할 수 있기 때문에 시스템 구현에 적합하다.
C가 제공하는 저수준 기능도 실제 기계 명령어와 완전히 동일하지는 않다. 예를 들어 포인터는 구현체가 정의한 방식으로 객체와 함수를 가리키는 언어 수준의 값이며, 모든 플랫폼에서 단순한 정수형 물리 주소와 같다고 보장되지 않는다. C 표준은 프로그램의 문법과 의미를 정의하지만 프로그램을 실제 시스템에서 변환하고 호출하는 구체적인 방식은 규정하지 않는다.[25]
명령형·절차형 언어
C는 프로그램이 수행해야 할 명령과 상태 변화를 순서대로 기술하는 명령형 프로그래밍 언어이다. 변수의 값을 변경하고, 조건에 따라 실행 경로를 선택하며, 반복문으로 명령을 되풀이하고, 함수를 호출하여 작업을 분리한다.
int total = 0;
for (int i = 0; i < count; ++i) {
total += values[i];
}
이 코드는 배열의 각 요소를 순서대로 읽어 total의 상태를 변경한다. 같은 계산을 수학적 관계로 선언하는 대신, 값을 어떻게 읽고 변경할지를 명시적으로 표현한다.
C는 절차형 프로그래밍을 중심으로 프로그램을 함수 단위로 나눈다. 함수는 입력을 매개변수로 받고 값을 반환하거나 외부 상태를 변경할 수 있다. 관련된 함수와 자료형을 여러 소스 파일과 헤더로 분리하여 프로그램을 모듈화할 수 있다.
함수 호출의 인수는 기본적으로 값으로 전달된다. 호출된 함수가 호출자의 객체를 직접 수정해야 한다면 해당 객체를 가리키는 포인터를 전달한다.
void increment(int *value)
{
++*value;
}
C에는 C++의 참조 매개변수처럼 별도의 참조 전달 문법이 없다. 배열 매개변수는 함수 선언에서 포인터 형태로 조정되므로 겉으로 배열을 전달하는 것처럼 보여도 실제로는 배열의 요소를 가리키는 포인터가 전달된다.
구조적 프로그래밍
C는 문장과 블록, 함수, 조건문과 반복문을 사용하여 제어 흐름을 계층적으로 구성할 수 있는 구조적 프로그래밍 언어이다.
주요 제어 구조에는 다음이 포함된다.
if와elseswitchwhiledofor- 함수 호출과 반환
- 중괄호로 둘러싼 복합문
이러한 구조를 사용하면 프로그램을 단순한 명령어 주소와 무조건 분기의 나열이 아니라, 조건과 반복 및 함수의 계층으로 표현할 수 있다.
C는 goto도 제공한다. 따라서 언어 차원에서 구조적 프로그래밍만을 강제하지는 않는다. goto는 일반적인 흐름을 불필요하게 복잡하게 만들 수 있지만, 여러 단계의 자원 정리와 오류 처리처럼 제한된 상황에서는 중복을 줄이는 데 사용되기도 한다.
FILE *file = fopen(path, "rb");
if (file == NULL) {
return ERROR_OPEN;
}
void *buffer = malloc(size);
if (buffer == NULL) {
goto cleanup_file;
}
/* 작업 수행 */
free(buffer);
fclose(file);
return SUCCESS;
cleanup_file:
fclose(file);
return ERROR_MEMORY;
C는 프로그램 구조를 표현할 도구를 제공하지만 특정한 설계 방법론과 객체 모델을 강제하지 않는다. 함수와 구조체, 포인터와 별도 번역 단위를 조합하여 절차적 인터페이스와 객체 지향에 가까운 구조, 데이터 지향 구조 등을 직접 설계할 수 있다.
작은 언어 핵심
C는 비교적 작은 언어 핵심과 별도의 표준 라이브러리로 구성된다. 파일 입출력과 문자열 처리, 동적 메모리 할당, 수학 함수와 시간 처리는 대부분 언어 문법이 아니라 표준 라이브러리의 함수와 매크로로 제공된다.
C 언어
├── 자료형과 선언
├── 표현식과 연산자
├── 문장과 제어 흐름
├── 함수
├── 객체와 저장 기간
├── 포인터와 배열
├── 구조체·union·열거형
└── 전처리 지시문
C 표준 라이브러리
├── 입출력
├── 문자열과 메모리
├── 동적 메모리 할당
├── 수학
├── 시간
├── 문자 분류와 변환
├── 원자적 연산
└── 스레드와 동기화
언어가 작은 편이라는 설명이 C 표준 전체가 단순하다는 뜻은 아니다. 배열과 포인터의 변환, 정수 승격, 객체 수명, 유효 타입과 별칭, 평가 순서 및 전처리기에는 정밀하고 복잡한 규칙이 존재한다. 문법에 사용되는 키워드와 기본 추상화가 비교적 적고, 고수준 기능을 언어 자체에 대량으로 내장하지 않는다는 의미에 가깝다.
C에는 다음 기능이 기본 언어 요소로 포함되지 않는다.
- 클래스와 상속
- 예외 처리
- 자동 가비지 컬렉션
- 범위 검사를 수행하는 기본 배열
- 문자열 전용 내장 자료형
- 범용 컨테이너
- 패키지와 모듈 배포 체계
- 리플렉션
- 코루틴과 비동기 문법
- 언어 차원의 네트워크와 그래픽 API
필요한 기능은 라이브러리와 코드 생성 도구, 플랫폼 API 또는 프로그램 내부의 설계로 구현한다. 이러한 구조는 언어와 실행 환경을 작게 유지할 수 있지만, 프로젝트마다 서로 다른 구현과 규칙을 사용하게 만들기도 한다.
정적 타입 체계
C는 변수와 함수, 표현식의 타입이 주로 번역 시점에 결정되는 정적 타입 프로그래밍 언어이다. 선언은 객체의 자료형과 함수의 매개변수 및 반환 타입을 지정하며, 컴파일러는 타입 규칙에 맞지 않는 표현을 진단할 수 있다.
int count;
double average;
char *text;
int calculate(int lhs, int rhs);
C의 타입은 크게 다음 범주로 나눌 수 있다.
- 정수 타입
- 부동소수점 타입
- 열거형
- 포인터 타입
- 배열 타입
- 함수 타입
- 구조체
union- 원자적 타입
void
타입은 값의 표현과 허용되는 연산, 객체의 크기와 정렬, 포인터 연산의 단위와 함수 호출 규칙을 결정한다. 구조체와 union, 배열과 포인터를 조합하여 하드웨어와 파일 형식, 통신 프로토콜에 대응하는 자료 구조를 표현할 수 있다.
그러나 C의 타입 검사는 현대의 강한 정적 타입 언어와 비교하면 비교적 유연하다. 정수 타입 사이에는 자동 변환과 승격이 일어나고, 명시적 캐스트를 사용하면 여러 포인터 타입과 정수 타입 사이의 변환도 표현할 수 있다.
double value = 3.75;
int integer = (int)value;
이러한 변환은 하드웨어와 외부 인터페이스를 다루는 데 필요하지만, 정보 손실과 잘못된 해석을 일으킬 수 있다. 캐스트는 타입 오류를 해결했다는 보장이 아니라 컴파일러에 특정 변환을 명시적으로 요청하는 문법이다.
값의 표현과 구현 의존성
C는 모든 컴퓨터가 동일한 정수 크기와 바이트 순서, 부동소수점 표현을 사용한다고 가정하지 않는다. char, short, int, long 등의 상대적인 최소 범위와 순서는 정하지만 정확한 비트 수와 표현의 일부는 구현체가 정한다.
다음 사항은 환경에 따라 달라질 수 있다.
- 기본 정수 타입의 크기
char가 기본적으로 부호 있는 타입인지- 포인터의 크기와 표현
- 구조체 구성원의 정렬과 패딩
- 바이트 순서
- 일부 정수 변환의 결과
- 부동소수점의 범위와 정밀도
- 함수 호출 규약
- 객체 파일과 실행 파일 형식
이를 통해 C는 8비트 마이크로컨트롤러부터 64비트 서버와 특수 목적 프로세서까지 서로 다른 시스템에 구현될 수 있다. 반면 특정 환경의 크기와 표현을 암묵적으로 가정한 코드는 다른 플랫폼으로 이식할 때 오류를 일으킬 수 있다.
정확한 폭이 필요한 프로그램은 <stdint.h>의 int32_t와 uint64_t 같은 타입을 사용할 수 있다. 다만 해당 폭을 정확하게 제공할 수 없는 구현체에서는 일부 정확한 폭 타입이 정의되지 않을 수 있다.
C 표준의 목적에는 여러 데이터 처리 시스템 사이에서 프로그램의 이식성과 신뢰성, 유지보수성 및 효율적인 실행을 촉진하는 것이 포함된다.[26] 그러나 이식성은 모든 구현체에서 기계 수준의 동작이 동일하다는 뜻이 아니라, 표준이 보장하는 부분과 구현체가 문서화해야 하는 부분을 구분하여 프로그램을 작성할 수 있다는 뜻이다.
포인터와 직접적인 메모리 접근
포인터는 객체나 함수의 위치를 가리키는 값을 표현한다. C 프로그램은 포인터를 통해 메모리에 간접적으로 접근하고 배열을 순회하며, 동적으로 할당한 객체와 연결 자료 구조를 관리하고, 함수와 외부 시스템 인터페이스를 연결한다.
int number = 10;
int *pointer = &number;
*pointer = 20;
&number는 객체 number를 가리키는 포인터를 만들고, *pointer는 해당 포인터가 가리키는 객체에 접근한다.
포인터 산술은 가리키는 타입의 크기를 기준으로 동작한다.
int values[4] = { 10, 20, 30, 40 };
int *pointer = values;
pointer += 2;
pointer += 2는 단순히 주소 숫자에 2바이트를 더하는 것이 아니라 두 개의 int 요소를 건너뛴 위치를 나타낸다.
배열과 포인터는 밀접한 관계가 있지만 동일한 타입과 객체는 아니다. 배열은 일정한 수의 요소를 포함하는 객체이고, 배열 이름은 대부분의 표현식에서 첫 번째 요소를 가리키는 포인터로 변환된다. sizeof와 주소 연산자 등 일부 문맥에서는 이 변환이 일어나지 않는다.
포인터는 C의 표현력과 효율성을 구성하는 핵심 요소이지만 다음과 같은 오류의 원인이 될 수 있다.
- null 포인터 역참조
- 배열 범위를 벗어난 접근
- 객체 수명이 끝난 뒤의 포인터 사용
- 초기화되지 않은 포인터 사용
- 호환되지 않는 타입을 통한 객체 접근
- 잘못된 정렬
- 같은 메모리의 중복 해제
- 정수와 포인터 사이의 잘못된 변환
C는 일반적인 포인터 접근마다 객체의 유효성과 배열 범위를 자동으로 검사하도록 요구하지 않는다. 검사가 필요한 프로그램은 명시적인 길이 정보와 검증, 안전한 추상화와 분석 도구를 사용해야 한다.
명시적인 자원 관리
C는 동적 메모리를 자동으로 회수하는 가비지 컬렉터를 요구하지 않는다. 프로그램은 malloc, calloc, realloc으로 저장 공간을 할당하고 사용이 끝나면 free로 반환한다.
int *values = malloc(count * sizeof(*values));
if (values == NULL) {
return ERROR_MEMORY;
}
/* values 사용 */
free(values);
파일과 소켓, 뮤텍스, 운영체제 핸들과 그래픽 자원도 일반적으로 명시적으로 획득하고 반환한다.
자원 획득
↓
오류 확인
↓
자원 사용
↓
모든 실행 경로에서 반환
명시적 자원 관리는 자원의 생성 시점과 수명, 해제 비용을 정밀하게 제어할 수 있게 한다. 실시간 시스템과 커널, 임베디드 장치에서는 예측하기 어려운 자동 회수 작업을 피하는 데 유리할 수 있다.
반면 프로그램이 자원 수명을 정확히 관리하지 못하면 다음 문제가 발생한다.
- 메모리 누수
- 이중 해제
- 해제 후 사용
- 자원 핸들 누수
- 오류 경로에서의 정리 누락
- 소유권 불분명
- 부분 초기화된 객체의 잘못된 해제
C에는 C++의 소멸자나 Rust의 소유권 체계처럼 범위를 벗어날 때 일반 자원을 자동으로 반환하는 통합 언어 기능이 없다. 프로그램은 함수와 규칙, 단일 정리 경로와 별도 라이브러리를 사용하여 수명을 관리한다.
실행 비용에 대한 직접적인 통제
C의 기본 연산과 자료 구조는 실제 프로세서 명령과 메모리 표현으로 비교적 직접 변환될 수 있다. 정수 연산과 포인터 접근, 배열과 구조체에는 언어가 요구하는 복잡한 객체 런타임이 없으며, 사용하지 않은 고수준 기능의 비용을 자동으로 부담하도록 요구하지 않는다.
이러한 특성 때문에 프로그래머는 다음 요소를 비교적 명확하게 통제할 수 있다.
- 객체의 메모리 크기
- 구조체의 구성
- 메모리 할당 시점
- 함수 호출과 인라인화 가능성
- 메모리 복사
- 데이터의 연속 배치
- 비트 단위 표현
- 시스템 호출과 라이브러리 호출 시점
그러나 C 문장 하나가 항상 특정한 기계 명령 하나와 대응하는 것은 아니다. 컴파일러는 언어가 보장하는 관찰 가능한 동작을 유지하는 범위에서 코드를 재배치하거나 제거하고, 여러 연산을 하나로 합치며, 레지스터와 벡터 명령을 사용할 수 있다.
inline 역시 함수 호출을 반드시 제거하라는 명령이 아니다. 표준에서 인라인 함수 지정자가 실제 호출 비용을 얼마나 줄이는지는 구현체가 정한다.[27]
C의 성능은 언어 이름만으로 자동 보장되지 않는다. 알고리즘과 자료 구조, 메모리 접근 패턴, 컴파일러와 최적화 옵션, 대상 하드웨어에 따라 결과가 달라진다. C는 성능을 제어할 수 있는 수단을 제공하지만 그 수단을 올바르게 사용하는 책임도 프로그래머에게 둔다.
추상 기계
C 표준은 특정 CPU를 직접 정의하는 대신 C 프로그램이 실행되는 개념적인 추상 기계의 동작을 규정한다. 구현체는 실제 하드웨어에서 표준이 요구하는 관찰 가능한 결과가 나타나도록 프로그램을 변환해야 한다.
추상 기계는 다음과 같은 요소를 정의하는 기준이 된다.
- 표현식의 의미
- 객체의 값과 수명
- 저장 기간
- 함수 호출과 반환
- 부작용과 평가
- 입출력의 관찰 가능한 동작
- 스레드와 원자적 연산
- 프로그램 시작과 종료
컴파일러는 소스 코드의 모든 중간 계산을 문자 그대로 실행할 필요가 없다. 외부에서 관찰되는 동작이 같다면 계산을 제거하거나 상수로 바꾸고, 함수 호출을 펼치거나 반복문을 변형할 수 있다.
int square(int value)
{
return value * value;
}
int result = square(4);
컴파일러는 실행 시 실제 함수 호출과 곱셈을 수행하지 않고 result에 16을 직접 저장할 수 있다. 이는 프로그램의 관찰 가능한 의미가 유지되기 때문이다.
이 구조는 여러 하드웨어에서 C를 구현할 수 있게 하는 동시에 최적화의 이론적 기반을 제공한다. 반대로 프로그램이 표준에서 의미를 보장하지 않는 동작에 의존하면 컴파일러가 프로그래머의 예상과 다른 결과를 만들 수 있다.
정의되지 않은 동작
정의되지 않은 동작은 잘못되었거나 이식할 수 없는 프로그램 구성 또는 데이터가 사용되었을 때, C 표준이 결과에 아무런 요구 사항도 부과하지 않는 경우이다. null 포인터를 역참조하는 동작이 대표적인 예다.[28]
정의되지 않은 동작의 예에는 다음이 포함된다.
- 배열 범위를 벗어난 객체 접근
- null 포인터 역참조
- 0으로 정수 나눗셈
- 수명이 끝난 객체 접근
- 부호 있는 정수 오버플로
- 변경할 수 없는 문자열 리터럴 수정
- 호환되지 않는 함수 포인터 타입으로 호출
- 동기화되지 않은 데이터 경쟁
정의되지 않은 동작이 발생했을 때 구현체는 오류 메시지를 출력하거나 프로그램을 종료해야 할 의무가 없다. 우연히 예상한 결과를 내거나, 다른 데이터를 손상시키거나, 최적화에 의해 관련 코드가 제거될 수도 있다.
이는 컴파일러가 고의로 프로그램을 파괴한다는 의미가 아니다. 컴파일러는 정의된 프로그램이 언어 규칙을 지킨다고 가정하여 최적화할 수 있다. 예를 들어 부호 있는 정수 오버플로가 발생하지 않는다는 조건에서 비교와 반복문을 단순화할 수 있다.
정의되지 않은 동작은 하드웨어별로 검사 비용이 큰 상황을 언어가 항상 처리하도록 강제하지 않고, 다양한 구현과 최적화를 가능하게 한다. 그러나 메모리 안전성과 보안 측면에서는 큰 위험이 되므로 C 프로그램은 입력 검증과 정적 분석, 동적 검사 도구와 안전한 API를 활용해야 한다.
구현 정의·미지정 동작
C는 정의되지 않은 동작 외에도 구현체의 차이를 표현하기 위한 여러 분류를 사용한다.
| 분류 | 의미 |
|---|---|
| 정의된 동작 | 표준이 결과를 규정함 |
| 구현 정의 동작 | 구현체가 선택하고 그 선택을 문서화해야 함 |
| 미지정 동작 | 허용된 여러 결과 중 하나지만 어떤 결과를 선택하는지 문서화할 의무가 없음 |
| 정의되지 않은 동작 | 표준이 아무 요구 사항도 부과하지 않음 |
기본 char의 부호 여부처럼 구현 정의인 사항은 컴파일러와 플랫폼 문서에서 확인할 수 있다. 함수 인수의 평가 순서처럼 여러 순서가 허용되는 경우에는 한 특정 순서를 가정하지 않는 코드를 작성해야 한다.
이러한 구분은 C가 다양한 기계를 지원하는 방식의 일부이다. 모든 세부 사항을 하나의 가상 하드웨어로 고정하지 않고, 구현체가 효율적인 방식을 선택할 수 있게 하면서 필요한 부분은 문서화하도록 한다.
이식성과 기계 의존성의 균형
C 표준화의 주요 목표 중 하나는 여러 실행 환경에서 사용자 프로그램의 이식성을 높이는 것이다. 그러나 C는 모든 기계 의존적 특성을 제거하지 않는다. WG14는 이식성을 개선하면서도 일부 특성을 기계 의존적으로 남기고, 기존 프로그램을 무효화하지 않으며 언어의 기본 구조와 단순성을 유지하는 작업을 상충하는 요구 사이의 균형으로 설명한다.[29]
C 프로그램의 이식성은 여러 수준으로 나눌 수 있다.
엄격히 이식 가능한 코드
↓
표준이 보장하는 기능만 사용
구현 정의 코드
↓
구현체 문서를 전제로 함
플랫폼 종속 코드
↓
운영체제 API와 ABI 사용
하드웨어 종속 코드
↓
레지스터·명령어·메모리 맵 사용
범용 라이브러리는 가능한 한 표준이 보장하는 타입과 동작만 사용할 수 있다. 반면 운영체제 커널과 장치 드라이버는 본질적으로 특정 CPU와 장치, 컴파일러 확장에 의존한다. C는 두 종류의 코드를 같은 언어로 작성할 수 있도록 표준 영역과 구현체 확장 영역을 함께 허용한다.
C89와 이후 표준 위원회는 완전히 새로운 언어를 설계하기보다 이미 널리 사용되던 관행을 명확하게 규정하는 원칙을 중요하게 여겼다. 표준화 근거 문서는 명확한 선례가 있을 때 기존 관행을 유지하고, 여러 방언에서 검증된 개선 사항을 표준화하려 했다고 설명한다.[30]
호스트 구현과 독립 실행 구현
C 표준은 적합한 구현을 호스트 구현과 독립 실행 구현으로 구분한다.
호스트 구현은 일반적으로 운영체제 위에서 실행되는 완전한 프로그램 환경이다. 표준에서 정의한 프로그램 시작 방식과 폭넓은 표준 라이브러리를 제공한다. 데스크톱과 서버의 일반적인 C 컴파일 환경이 여기에 해당한다.
독립 실행 구현은 운영체제가 없거나 일반적인 프로그램 시작 방식과 전체 표준 라이브러리를 제공할 수 없는 환경을 대상으로 한다. 부트로더와 운영체제 커널, 마이크로컨트롤러 펌웨어 등이 이에 해당할 수 있다.
호스트 구현
├── 운영체제 환경
├── main을 통한 일반적인 시작
└── 전체 표준 라이브러리 사용 가능
독립 실행 구현
├── 운영체제가 없을 수 있음
├── 시작 지점이 구현체에 따라 다름
└── 요구되는 표준 라이브러리 범위가 제한됨
C23 표준은 적합한 구현의 두 형태를 호스트와 독립 실행으로 구분하며, 독립 실행 구현에는 제한된 표준 헤더와 기능만을 필수로 요구한다.[31]
이 구분은 C가 운영체제의 응용 프로그램뿐 아니라 운영체제 자체와 운영체제보다 먼저 실행되는 소프트웨어에도 사용될 수 있는 기반이다. 언어가 하나의 고정된 런타임과 실행 환경을 반드시 요구하지 않기 때문이다.
별도 컴파일
C 프로그램은 여러 번역 단위를 독립적으로 번역한 뒤 링크하여 하나의 프로그램으로 만들 수 있다. 일반적으로 하나의 소스 파일과 전처리 과정에서 포함된 헤더들이 하나의 전처리 번역 단위를 이룬다.
main.c ──────→ main.o ───┐
│
network.c ───→ network.o ─┼─→ 실행 프로그램
│
storage.c ───→ storage.o ─┘
각 소스 파일을 독립적으로 컴파일할 수 있으므로 프로그램의 일부만 수정했을 때 전체 코드를 다시 번역하지 않아도 된다. 이미 번역된 코드는 정적 라이브러리와 동적 라이브러리 형태로 보관할 수 있다.
번역 단위 사이는 외부 연결을 가진 함수와 객체, 데이터 파일 등의 방식으로 상호 작용한다. C 표준은 번역 단위가 독립적으로 번역된 뒤 나중에 연결되어 실행 프로그램을 구성할 수 있다고 규정한다.[32]
헤더 파일은 여러 번역 단위에 공통 선언과 타입, 매크로를 제공한다. 다만 헤더의 내용은 전처리 과정에서 각 소스 파일에 텍스트로 포함되므로, 선언과 정의의 일관성을 프로그램과 빌드 시스템이 유지해야 한다.
전처리기
C는 본격적인 언어 번역 전에 소스 텍스트를 처리하는 C 전처리기를 제공한다. 전처리기는 파일 포함과 매크로 치환, 조건부 컴파일 등을 수행한다.
#include <stdint.h>
#define BUFFER_SIZE 4096
#if defined(_WIN32)
#include <windows.h>
#else
#include <unistd.h>
#endif
전처리기는 다음 기능을 제공한다.
- 헤더 파일 포함
- 객체형 매크로
- 함수형 매크로
- 조건부 컴파일
- 토큰 문자열화
- 토큰 결합
- 구현체별 pragma
- C23의 바이너리 데이터 포함
전처리기는 타입과 변수의 의미를 이해하는 일반 함수 체계가 아니라 토큰을 변환하는 별도의 번역 단계이다. 따라서 매크로의 인수가 여러 번 평가되거나 연산자 우선순위가 예상과 달라지는 문제가 발생할 수 있다.
#define SQUARE(x) ((x) * (x))
이 매크로는 SQUARE(value++)처럼 부작용이 있는 표현식과 함께 사용하면 value가 두 번 증가할 수 있다. 전처리 매크로는 함수처럼 보이더라도 함수와 같은 평가 규칙과 타입 검사를 자동으로 제공하지 않는다.
전처리기는 플랫폼 차이와 컴파일 설정을 한 소스 트리에서 관리하는 데 유용하지만, 지나치게 사용하면 실제로 컴파일되는 코드를 이해하기 어려워질 수 있다.
라이브러리와의 결합
C는 많은 기능을 언어 자체가 아니라 라이브러리에 맡긴다. 표준 라이브러리는 모든 적합한 호스트 구현이 제공해야 할 공통 API를 정의하며, 운영체제와 플랫폼은 그 위에 추가 API를 제공한다.
C 프로그램
├── C 언어 기능
├── C 표준 라이브러리
├── 운영체제 API
├── 외부 C 라이브러리
└── 다른 언어의 네이티브 인터페이스
문자열도 언어의 독립된 고수준 객체가 아니라 char 요소의 배열과 종료 문자를 사용하는 관례로 표현된다. 문자열 길이와 메모리 크기, 버퍼 용량은 프로그램과 라이브러리 호출자가 관리한다.
이러한 구성은 작은 실행 환경에서도 필요한 기능만 선택할 수 있게 한다. 반면 표준 라이브러리가 제공하지 않는 네트워크와 그래픽, 스레드 기능의 일부는 운영체제별 API와 별도 라이브러리에 의존해야 한다.
상호운용성과 ABI
C는 운영체제와 라이브러리의 공용 인터페이스를 표현하는 언어로 널리 사용된다. 많은 플랫폼에서 C 함수 호출 방식과 기본 자료형의 배치가 응용 프로그램 이진 인터페이스의 기준이 되며, 다른 프로그래밍 언어도 C 함수를 호출하거나 C에서 호출될 수 있는 인터페이스를 제공한다.
다음 언어와 런타임은 C 인터페이스를 사용할 수 있다.
이러한 상호운용은 C 표준만으로 완전히 정의되지 않는다. 구조체의 정렬과 함수 호출 규약, 이름 장식과 동적 라이브러리 형식은 플랫폼 ABI와 컴파일러가 결정한다.
서로 다른 언어 사이에서 C 인터페이스를 사용할 때는 다음을 합의해야 한다.
- 자료형의 크기와 표현
- 함수 호출 규약
- 구조체의 배치와 정렬
- 메모리 소유권
- 문자열의 인코딩과 종료 방식
- 오류 전달 방식
- 콜백 함수의 수명
- 스레드 안전성
- 예외와 패닉의 경계
C의 인터페이스가 널리 사용되는 이유는 언어가 단순하고 오래 유지된 자료형과 함수 모델을 제공하며, 대부분의 네이티브 플랫폼에 C 컴파일러와 ABI가 존재하기 때문이다. WG14도 C가 시스템과 언어를 연결하는 공통 언어로 사용되며 컴파일러의 대상 언어, 인터프리터 구현 언어와 운영체제 및 임베디드 프로그래밍 언어로 활용된다고 설명한다.[33]
보수적인 표준화와 호환성
C는 수십 년 동안 사용된 소스 코드와 라이브러리, 운영체제 API를 보유하고 있다. 따라서 새로운 기능을 추가할 때 언어의 일관성만이 아니라 기존 프로그램과 구현체에 미치는 영향도 중요하게 고려된다.
WG14의 표준화 원칙에는 다음과 같은 균형이 포함된다.
- 이식성을 개선한다.
- 일부 기계 의존적 특성은 유지한다.
- 새로운 기능으로 언어의 기본 구조를 훼손하지 않는다.
- 기존 프로그램을 불필요하게 무효화하지 않는다.
- 명확성과 일관성을 개선한다.
- C의 단순성을 유지한다.
- 구현 경험과 실제 관행을 고려한다.
이 정책은 오래된 C 코드와 새 컴파일러의 호환성을 높이지만, 역사적으로 남은 불규칙성과 위험한 기능을 빠르게 제거하기 어렵게 만들기도 한다. C23은 일부 오래된 함수 선언 방식과 구식 기능을 제거했지만, 전반적인 언어 모델은 이전 C와 연속성을 유지한다.
언어와 구현체의 구분
C 언어와 특정 컴파일러의 기능은 구분해야 한다. GCC와 Clang, MSVC 등의 구현체는 표준 C 외에도 다음과 같은 확장을 제공할 수 있다.
- 특정 CPU의 벡터 타입
- 인라인 어셈블리
- 함수와 변수 속성
- 운영체제별 호출 규약
- 비표준 내장 함수
- 확장된 정수 타입
- 진단과 정적 분석 기능
- 표준에 없는 문장 표현식
- 커널 개발용 확장
확장 기능은 표준 C만으로 표현하기 어려운 플랫폼 기능을 사용할 수 있게 하지만 다른 구현체에서는 동작하지 않을 수 있다. 이식 가능한 코드와 플랫폼 종속 코드를 분리하면 공통 로직은 여러 환경에서 재사용하면서 필요한 부분만 구현체별로 교체할 수 있다.
공통 표준 C 코드
+
운영체제별 코드
+
프로세서별 코드
+
컴파일러별 확장
컴파일러가 특정 문법을 허용한다고 해서 해당 기능이 C 표준의 일부라는 뜻은 아니다. 프로그램이 어떤 C 표준 판과 구현체 확장에 의존하는지 빌드 설정과 문서에 명확하게 기록해야 한다.
프로그래머의 책임
C는 프로그램의 자료 배치와 자원 수명, 포인터와 버퍼 크기, 오류 처리를 프로그래머가 직접 통제할 수 있게 한다. 동시에 언어와 구현체가 자동으로 확인하지 않는 사항도 많다.
C 프로그래머가 직접 관리해야 할 수 있는 항목은 다음과 같다.
- 배열과 버퍼의 길이
- 포인터의 유효성
- 객체의 수명
- 메모리와 자원의 소유권
- 정수 연산의 범위
- 함수 반환값과 오류 코드
- 동시 접근의 동기화
- 문자열 종료와 인코딩
- 구현 정의 동작
- 플랫폼별 ABI 차이
이러한 설계는 흔히 C가 프로그래머를 신뢰한다고 표현된다. 그러나 이는 잘못된 프로그램도 올바른 것으로 간주한다는 뜻이 아니라, 여러 검사와 자원 관리 정책을 언어 런타임이 항상 강제하기보다 구현체와 도구, 프로그램 설계에 맡긴다는 의미다.
안전하고 유지보수 가능한 C 프로그램을 작성하려면 언어 문법을 아는 것만으로 충분하지 않다. 컴파일러 경고와 정적 분석, 주소 검사기와 정의되지 않은 동작 검사기, 퍼징과 코드 검토, 명확한 소유권 규칙과 제한된 API가 함께 사용되어야 한다.
특징의 종합
C는 일반적인 제어 흐름과 함수, 정적 타입을 제공하는 고급 언어인 동시에 포인터와 비트 연산, 명시적인 자원 관리를 통해 실제 시스템의 자료와 실행 비용을 직접 다룰 수 있는 언어이다. 작은 언어 핵심과 라이브러리 중심 구조, 호스트와 독립 실행 환경의 구분을 통해 운영체제가 있는 시스템부터 작은 임베디드 장치까지 폭넓게 구현될 수 있다.
C의 이식성은 하드웨어 차이를 모두 숨기는 방식이 아니라 표준이 보장하는 동작과 구현체가 정할 수 있는 동작을 구분하는 방식으로 제공된다. 별도 컴파일과 안정적인 함수 중심 인터페이스는 대규모 시스템과 라이브러리 개발, 다른 프로그래밍 언어와의 상호운용을 가능하게 한다.
이러한 특성은 높은 효율성과 넓은 적용 범위라는 장점을 제공하는 동시에 메모리 안전성과 자원 수명, 정의되지 않은 동작을 프로그래머가 엄격하게 관리해야 한다는 한계를 만든다. C의 설계는 자동화된 안전성과 직접적인 통제, 추상화와 기계 의존성, 새로운 기능과 기존 코드의 호환성 사이에서 지속적으로 균형을 유지해 온 결과이다.
문법과 구성 요소
C 프로그램은 문자로 작성된 소스 파일을 전처리하고 번역하여 실행 가능한 프로그램이나 라이브러리로 만드는 구조를 가진다. 소스 코드는 키워드와 식별자, 상수와 문자열 리터럴, 연산자와 구두점 등의 토큰으로 나뉘며, 토큰은 선언과 표현식, 문장, 함수 정의 및 전처리 지시문을 구성한다.
C의 문법은 비교적 적은 수의 기본 요소를 조합하는 방식으로 설계되어 있다. 변수와 함수, 타입을 선언하고 표현식으로 값을 계산하며, 문장으로 실행 흐름을 구성한다. 배열과 포인터, 함수와 구조체 같은 타입 구성자를 함께 사용하면 복잡한 자료형을 표현할 수 있다.
#include <stdio.h>
struct point {
int x;
int y;
};
static int add_coordinates(const struct point *point) {
return point->x + point->y;
}
int main(void) {
struct point position = {
.x = 10,
.y = 20
};
printf("%d\n", add_coordinates(&position));
return 0;
}
이 프로그램에는 다음과 같은 C의 주요 구성 요소가 포함되어 있다.
#include전처리 지시문struct를 사용한 구조체 선언int기본 자료형- 객체와 함수 선언
- 포인터와
const한정자 - 함수 호출
- 구성원 접근 연산자
- 지정 초기화
return문- 복합문
C 표준은 이러한 요소의 문법과 의미를 정의하지만, 소스 파일의 배치와 빌드 명령, 객체 파일 형식과 실행 파일 형식은 구현체와 운영체제가 결정한다.[34]
번역 단위
C 프로그램은 하나 이상의 번역 단위로 구성된다. 일반적으로 하나의 .c 소스 파일과 해당 파일이 전처리 과정에서 포함한 헤더의 내용이 전처리 번역 단위를 이룬다.
main.c
+ 포함된 헤더
↓
전처리 번역 단위
↓
컴파일
↓
객체 파일
여러 번역 단위는 각각 독립적으로 번역된 뒤 하나의 프로그램으로 연결될 수 있다.
main.c → main.o ───┐
│
math.c → math.o ───┼→ 실행 프로그램
│
storage.c → storage.o ┘
번역 단위 사이에서는 외부 연결을 가진 함수와 객체를 공유할 수 있다. 헤더 파일은 여러 번역 단위에서 공통으로 사용할 타입과 함수 선언, 매크로를 제공한다.
/* calculator.h */
#ifndef CALCULATOR_H
#define CALCULATOR_H
int add(int left, int right);
int subtract(int left, int right);
#endif
/* calculator.c */
#include "calculator.h"
int add(int left, int right) {
return left + right;
}
int subtract(int left, int right) {
return left - right;
}
/* main.c */
#include <stdio.h>
#include "calculator.h"
int main(void) {
printf("%d\n", add(10, 20));
return 0;
}
각 소스 파일은 독립적으로 컴파일되지만 함수 선언과 정의의 타입은 서로 호환되어야 한다. 한 번역 단위에서 int add(int, int)로 선언하고 다른 번역 단위에서 다른 타입으로 정의하면 올바른 프로그램이 되지 않는다.
번역 단계
C 표준은 소스 파일이 처리되는 과정을 개념적인 번역 단계로 나눈다. 실제 컴파일러가 반드시 각 단계를 별도 프로그램으로 실행해야 하는 것은 아니지만, 최종 결과는 표준이 정한 순서와 같은 의미를 가져야 한다.
주요 과정은 다음과 같이 정리할 수 있다.
소스 파일 문자
↓
문자와 줄바꿈 처리
↓
주석 제거
↓
전처리 토큰 인식
↓
전처리 지시문과 매크로 확장
↓
문자열 리터럴 결합
↓
번역 단위 분석
↓
외부 참조 연결
주석은 전처리 과정에서 공백으로 대체된다.
int value = 10; /* 이 주석은 공백으로 대체된다. */
인접한 문자열 리터럴은 하나의 문자열로 결합된다.
const char *message =
"TechPedia "
"Wiki Engine";
이 선언은 다음 문자열을 나타낸다.
const char *message = "TechPedia Wiki Engine";
문자열의 결합은 소스 코드에서 긴 문장을 여러 줄로 나누거나 매크로와 문자열을 조합할 때 사용할 수 있다.
문자 집합
C 소스는 문자와 숫자, 구두점 및 공백 문자를 사용해 작성된다. 표준은 기본 소스 문자 집합과 기본 실행 문자 집합을 정의하며, 구현체는 소스 파일의 실제 문자 인코딩을 번역 과정에서 C의 문자 집합으로 대응시킨다.
C 소스에서 기본적으로 사용되는 문자에는 다음이 포함된다.
- 라틴 대문자
A부터Z - 라틴 소문자
a부터z - 숫자
0부터9 - 공백과 수평 탭, 줄바꿈
- 연산자와 구두점에 사용되는 특수 문자
식별자에는 기본 라틴 문자 외에도 구현체와 표준이 허용하는 확장 문자를 사용할 수 있다. 다만 소스 코드와 도구, 운영체제 및 다른 언어와의 호환성을 고려하면 공개 API의 식별자에는 제한된 문자 집합을 사용하는 경우가 많다.
주석
C는 블록 주석과 한 줄 주석을 지원한다.
블록 주석은 /*로 시작하고 */로 끝난다.
/*
* 여러 줄에 걸쳐
* 설명을 작성할 수 있다.
*/
int value = 10;
한 줄 주석은 // 뒤에서 해당 줄의 끝까지 이어진다.
int count = 10; // 처리할 요소의 개수
블록 주석은 중첩할 수 없다.
/*
바깥쪽 주석
/* 안쪽 주석 */
이 지점에서 이미 바깥쪽 주석도 끝난 것으로 처리될 수 있다.
*/
코드를 임시로 제거할 목적으로 블록 주석을 중첩하면 예상하지 못한 구문 오류가 발생할 수 있다. 조건부 컴파일이 필요하면 #if 0을 사용할 수 있다.
#if 0
int disabled_function(void) {
return 10;
}
#endif
토큰
전처리와 번역 과정에서 소스 코드는 토큰 단위로 구분된다. 주요 토큰 분류는 다음과 같다.
| 분류 | 예시 |
|---|---|
| 키워드 | int, return, struct |
| 식별자 | value, calculate, point |
| 상수 | 10, 3.14, 'A' |
| 문자열 리터럴 | "hello" |
| 연산자 | +, ==, && |
| 구두점 | ;, ,, {, } |
| 헤더 이름 | <stdio.h>, "project.h" |
공백은 토큰을 구분하지만 대부분의 문맥에서 공백의 정확한 개수는 의미에 영향을 주지 않는다.
int value = 10;
다음 코드도 토큰 구조는 같다.
int
value
=
10
;
다만 문자열과 문자 리터럴, 전처리 지시문 및 여러 문자로 이루어진 연산자 안에서는 임의로 공백을 넣을 수 없다.
value += 1;
다음은 += 연산자가 아니라 +와 =라는 별도 토큰이므로 다른 문법이 된다.
value + = 1;
키워드
키워드는 C 문법에서 미리 정해진 의미를 가지며 일반 식별자로 사용할 수 없다.
C의 키워드는 자료형과 타입 한정자, 저장 클래스, 제어문과 연산자 등의 역할을 한다.
대표적인 키워드는 다음과 같다.
| 분류 | 키워드 예시 |
|---|---|
| 기본 타입 | char, int, float, double, void |
| 부호와 크기 | signed, unsigned, short, long |
| 복합 타입 | struct, union, enum |
| 타입 한정자 | const, restrict, volatile, _Atomic |
| 저장 클래스 | auto, extern, register, static, typedef |
| 제어문 | if, else, switch, case, for, while, do |
| 이동문 | goto, continue, break, return |
| 연산·검사 | sizeof, typeof, typeof_unqual |
| 함수 지정자 | inline, _Noreturn |
| 정렬 | _Alignas, _Alignof |
| 일반 선택 | _Generic |
| 정적 검증 | static_assert, _Static_assert |
| C23 키워드 | bool, true, false, nullptr, constexpr, thread_local |
키워드 집합은 C 표준 판에 따라 다르다. 예를 들어 inline과 restrict는 C99, _Atomic과 _Generic은 C11, typeof와 constexpr, nullptr은 C23에서 표준에 포함되었다.
constexpr unsigned int buffer_size = 4096;
bool initialized = false;
int *pointer = nullptr;
이 코드는 C23에서 사용할 수 있지만 이전 표준 판에서는 같은 식별자가 표준 키워드가 아니거나 기능 자체가 제공되지 않는다.
식별자
식별자는 변수와 함수, 타입 별칭, 구조체 태그, 열거 상수, 레이블과 매크로 등에 붙이는 이름이다.
int item_count;
double calculate_average(void);
struct network_packet;
typedef unsigned long object_id;
식별자는 문자 또는 허용되는 확장 문자로 시작하고, 이후에는 숫자를 포함할 수 있다.
int value;
int value2;
int network_packet_count;
다음 이름은 숫자로 시작하므로 식별자가 될 수 없다.
int 2nd_value;
C는 대소문자를 구분한다.
int value;
int Value;
int VALUE;
세 이름은 서로 다른 식별자다.
예약 식별자
일부 식별자는 구현체와 표준 라이브러리를 위해 예약된다. 프로그램이 예약된 이름을 임의로 정의하면 표준 라이브러리나 컴파일러 구현과 충돌할 수 있다.
대표적으로 다음 이름은 피해야 한다.
- 밑줄 두 개로 시작하는 이름
- 밑줄과 대문자로 시작하는 이름
- 파일 범위에서 밑줄로 시작하는 이름
- 표준 라이브러리가 특정 헤더에서 예약한 이름
- 표준 함수와 매크로의 이름
int __internal_value;
int _SystemValue;
이러한 이름은 구현체를 위해 예약되어 있다.
프로그램 내부 식별자는 다음과 같은 형태가 안전하다.
int internal_value;
int system_value;
식별자의 최대 유효 길이는 구현체가 충분한 범위를 지원해야 하지만, 외부 연결을 가진 이름은 링커와 객체 파일 형식의 제약을 받을 수도 있다.
이름 공간
C는 모든 식별자를 하나의 이름 공간에서 관리하지 않는다. 문법적인 위치에 따라 여러 종류의 이름이 서로 별도로 관리된다.
주요 이름 공간은 다음과 같다.
- 레이블 이름
- 구조체·
union·열거형 태그 - 구조체와
union의 구성원 - 그 밖의 일반 식별자
- 전처리 매크로 이름
따라서 다음 선언은 서로 충돌하지 않는다.
struct item {
int item;
};
int item;
struct item의 item은 태그 이름 공간에 있고, 전역 변수 item은 일반 식별자 이름 공간에 있다. 구조체 구성원 item은 해당 구조체의 구성원 이름 공간에 속한다.
레이블도 별도의 이름 공간을 사용한다.
int process(void) {
int cleanup = 0;
cleanup:
return cleanup;
}
변수 cleanup과 레이블 cleanup은 서로 다른 이름 공간에 속하므로 문법적으로 함께 존재할 수 있다. 다만 가독성을 위해 같은 이름을 중복 사용하는 것은 피하는 편이 좋다.
상수
상수는 소스 코드에 직접 작성된 고정된 값을 나타낸다. C에는 정수 상수와 부동소수점 상수, 열거 상수와 문자 상수 등이 있다.
정수 상수
정수 상수는 진법에 따라 다른 접두사를 사용할 수 있다.
int decimal = 42;
int octal = 052;
int hexadecimal = 0x2A;
int binary = 0b101010;
- 접두사가 없으면 10진수
0으로 시작하면 8진수0x또는0X로 시작하면 16진수0b또는0B로 시작하면 C23의 2진수
C23에서는 숫자 사이에 작은따옴표를 구분자로 넣을 수 있다.
unsigned int mask = 0b1111'0000;
long population = 50'000'000;
숫자 구분자는 값에 영향을 주지 않는다.
정수 상수의 타입은 값과 진법, 접미사에 따라 결정된다.
10
10U
10L
10UL
10LL
10ULL
U또는u는 부호 없는 타입L또는l은longLL또는ll은long long
소문자 l은 숫자 1과 혼동될 수 있으므로 대문자 L을 사용하는 편이 읽기 쉽다.
long distance = 1000L;
unsigned long flags = 0x8000UL;
부동소수점 상수
부동소수점 상수는 소수점이나 지수 표기를 사용한다.
double first = 3.14;
double second = 1.0e-6;
float third = 2.5F;
long double fourth = 1.0L;
접미사가 없으면 double, F가 붙으면 float, L이 붙으면 long double 타입이다.
16진수 부동소수점 상수도 사용할 수 있다.
double value = 0x1.8p+1;
p 뒤의 지수는 2의 거듭제곱을 나타낸다. 위 값은 16진수 1.8에 (2^1)을 곱한 값이므로 3.0을 나타낸다.
문자 상수
문자 상수는 작은따옴표로 작성한다.
int letter = 'A';
int newline = '\n';
int tab = '\t';
일반 문자 상수의 타입은 int다. 문자 값을 저장하는 객체에는 보통 char를 사용한다.
char letter = 'A';
여러 문자로 구성된 문자 상수도 문법적으로 허용될 수 있지만 값은 구현 정의이며 이식성이 낮다.
int value = 'AB';
이러한 다중 문자 상수는 일반 프로그램에서 피하는 편이 좋다.
이스케이프 시퀀스
문자와 문자열 리터럴에서 직접 쓰기 어려운 문자는 역슬래시로 시작하는 이스케이프 시퀀스로 표현한다.
| 표기 | 의미 |
|---|---|
\n | 줄바꿈 |
\t | 수평 탭 |
\r | 캐리지 리턴 |
\b | 백스페이스 |
\f | 폼 피드 |
\v | 수직 탭 |
\\ | 역슬래시 |
\' | 작은따옴표 |
\" | 큰따옴표 |
\0 | 널 문자 |
const char *path = "C:\\Program Files\\Example";
const char *message = "first line\nsecond line\n";
문자는 8진수와 16진수 값으로도 표현할 수 있다.
char letter_a = '\101';
char another_a = '\x41';
유니버설 문자 이름은 유니코드 코드 포인트를 소스에서 표현하는 데 사용할 수 있다.
const char *copyright = "\u00A9";
실제 실행 문자열의 인코딩은 문자열 접두사와 구현체의 문자 인코딩 규칙에 따라 달라진다.
문자열 리터럴
문자열 리터럴은 큰따옴표로 둘러싼 문자 배열을 나타낸다.
"hello"
일반 문자열 리터럴은 마지막에 널 문자가 자동으로 추가된 char 배열을 생성한다.
'h' 'e' 'l' 'l' 'o' '\0'
따라서 다음 배열의 크기는 6이다.
char message[] = "hello";
문자열 리터럴을 포인터로 가리킬 수도 있다.
const char *message = "hello";
C에서 일반 문자열 리터럴의 요소 타입은 역사적으로 char지만, 내용을 수정하려는 동작은 정의되지 않는다.
char *message = "hello";
message[0] = 'H';
문자열 리터럴을 변경해서는 안 되므로 포인터를 사용할 때는 const char *로 선언하는 편이 안전하다.
const char *message = "hello";
수정 가능한 문자열이 필요하면 배열로 복사한다.
char message[] = "hello";
message[0] = 'H';
문자열 리터럴에는 문자 인코딩을 나타내는 접두사를 붙일 수 있다.
"ordinary string"
u8"UTF-8 string"
u"UTF-16 string"
U"UTF-32 string"
L"wide string"
각 문자열의 요소 타입과 인코딩 규칙은 접두사에 따라 달라진다.
구두점
구두점은 선언과 표현식, 문장 및 전처리 문법의 구조를 구분한다.
대표적인 구두점은 다음과 같다.
| 기호 | 주요 용도 |
|---|---|
; | 선언과 표현식 문장 종료 |
, | 선언자·인수·초기화 요소 구분 |
() | 함수 호출, 매개변수, 표현식 그룹 |
[] | 배열 선언과 첨자 |
{} | 복합문과 초기화 목록 |
. | 구조체 또는 union 구성원 접근 |
-> | 포인터를 통한 구성원 접근 |
: | 레이블, case, 비트 필드 |
... | 가변 인수 매개변수 |
# | 전처리 지시문과 문자열화 |
## | 전처리 토큰 결합 |
세미콜론은 빈 문장도 만들 수 있다.
while (is_device_busy()) {
}
다음 코드의 세미콜론은 while의 본문인 빈 문장이다.
while (is_device_busy())
;
의도하지 않은 세미콜론은 제어문을 빈 문장으로 만들 수 있으므로 주의해야 한다.
if (ready); {
process();
}
이 코드에서 블록은 if와 관계없이 항상 실행된다.
선언
선언은 식별자의 의미와 타입, 저장 방식 및 다른 속성을 지정한다.
int count;
이 선언은 count라는 이름을 int 타입의 객체로 선언한다.
함수도 선언할 수 있다.
int calculate(int left, int right);
선언은 일반적으로 다음 요소로 구성된다.
선언 지정자 + 선언자 + 초기화
static const unsigned long count = 10UL;
이 선언에서 각 요소의 역할은 다음과 같다.
static: 저장 클래스 지정자const: 타입 한정자unsigned long: 타입 지정자count: 선언자= 10UL: 초기화
하나의 선언에서 여러 식별자를 선언할 수도 있다.
int left, right, result;
하지만 포인터가 포함되면 *가 각 선언자에 속한다는 점에 주의해야 한다.
int *left, right;
이 선언에서 left만 int *이고 right는 int다.
두 포인터를 선언하려면 각각 *를 붙인다.
int *left;
int *right;
또는 다음처럼 작성할 수 있다.
int *left, *right;
가독성과 수정의 용이성을 위해 한 선언에 하나의 객체만 두는 규칙을 사용하는 프로젝트도 많다.
선언자
선언자는 식별자에 포인터와 배열, 함수 등의 타입 구조를 결합한다.
int value;
int *pointer;
int values[10];
int function(int value);
int (*callback)(int value);
선언은 식별자가 표현식에서 사용되는 형태와 유사하게 읽을 수 있다.
int *pointer;
*pointer가 int 타입이라는 의미다.
int values[10];
values[index]가 int 타입이라는 의미다.
int function(int value);
function(value)가 int 값을 반환한다는 의미다.
int (*callback)(int value);
(*callback)(value)가 int 값을 반환하므로 callback은 함수 포인터다.
괄호는 선언자의 결합 순서를 바꾼다.
int *functions[4];
functions는 int *를 요소로 가진 배열이다.
int (*function)(void);
function은 int를 반환하는 함수를 가리키는 포인터다.
int *function(void);
function은 int *를 반환하는 함수다.
복잡한 선언은 typedef로 단계적으로 분리할 수 있다.
typedef int (*comparison_function)(
const void *left,
const void *right
);
comparison_function comparator;
정의
선언은 이름과 타입을 알리는 행위이며, 정의는 객체의 저장 공간을 만들거나 함수의 본문을 제공한다.
다음은 객체의 정의다.
int global_count;
다음은 객체를 선언하지만 이 번역 단위에서 정의하지 않는다.
extern int global_count;
함수 원형은 함수 선언이다.
int calculate(int left, int right);
함수 본문을 포함하면 정의가 된다.
int calculate(int left, int right) {
return left + right;
}
하나의 프로그램 안에서 외부 연결을 가진 객체와 함수의 정의가 중복되면 링크 오류나 정의 규칙 위반이 발생할 수 있다.
타입
C의 타입은 객체와 함수가 표현하는 값의 종류와 사용할 수 있는 연산을 정한다.
주요 타입 범주는 다음과 같다.
타입
├── 객체 타입
│ ├── 기본 타입
│ │ ├── 정수 타입
│ │ ├── 부동소수점 타입
│ │ └── 열거형
│ ├── 포인터 타입
│ ├── 배열 타입
│ ├── 구조체
│ ├── union
│ └── 원자적 타입
├── 함수 타입
└── void 타입
타입은 객체의 크기와 정렬, 값의 범위, 표현 방식과 허용되는 연산에 영향을 준다.
정수 타입
표준 정수 타입에는 다음이 포함된다.
_Bool
char
signed char
unsigned char
short
unsigned short
int
unsigned int
long
unsigned long
long long
unsigned long long
C23에서는 _Bool과 함께 bool을 사용할 수 있다.
bool enabled = true;
정수 타입의 정확한 비트 수는 모든 구현에서 같지 않다. 표준은 최소 표현 범위와 크기의 순서 관계를 규정한다.
sizeof(char) ≤ sizeof(short) ≤ sizeof(int)
≤ sizeof(long) ≤ sizeof(long long)
sizeof(char)는 항상 1이지만 C의 1바이트가 반드시 8비트라는 뜻은 아니다. 한 바이트의 비트 수는 CHAR_BIT로 확인할 수 있다.
#include <limits.h>
static_assert(CHAR_BIT >= 8);
char, signed char, unsigned char는 서로 구별되는 타입이다. 기본 char가 부호 있는지 없는지는 구현체가 정한다.
정확한 폭이 필요한 경우 <stdint.h>의 타입을 사용할 수 있다.
#include <stdint.h>
uint8_t byte;
int32_t coordinate;
uint64_t identifier;
정확한 폭의 타입은 구현체가 해당 비트 폭을 정확히 제공할 수 있을 때 정의된다.
부동소수점 타입
실수 부동소수점 타입은 다음과 같다.
float
double
long double
각 타입의 범위와 정밀도는 구현체와 부동소수점 형식에 따라 달라질 수 있다.
float single_precision = 1.0F;
double double_precision = 1.0;
long double extended_precision = 1.0L;
C는 복소수 타입도 지원할 수 있다.
#include <complex.h>
double complex value = 1.0 + 2.0 * I;
부동소수점 계산은 일반적인 실수 수학과 완전히 같지 않다. 반올림과 무한대, NaN 및 부동소수점 환경의 영향을 받을 수 있다.
void
void는 값이 없거나 완전한 객체 타입이 지정되지 않았음을 나타낸다.
값을 반환하지 않는 함수는 void 반환 타입을 사용한다.
void print_message(void) {
puts("hello");
}
void *는 객체를 가리키는 범용 포인터로 사용된다.
void *memory = malloc(1024);
void 자체는 객체 타입이 아니므로 다음과 같은 객체를 만들 수 없다.
void value;
void *가 가리키는 객체에 접근하려면 구체적인 객체 포인터 타입으로 변환해야 한다.
int value = 10;
void *memory = &value;
int *integer = memory;
printf("%d\n", *integer);
열거형
열거형은 이름이 붙은 정수 상수의 집합을 정의한다.
enum connection_state {
CONNECTION_DISCONNECTED,
CONNECTION_CONNECTING,
CONNECTION_CONNECTED
};
열거형 객체는 정의된 상태 가운데 하나를 나타내는 데 사용할 수 있다.
enum connection_state state = CONNECTION_CONNECTING;
열거 상수는 정수 상수 표현식으로 사용할 수 있다.
int counters[CONNECTION_CONNECTED + 1];
C23에서는 열거형에 고정된 기반 타입을 지정할 수 있다.
enum packet_type : unsigned char {
PACKET_CONNECT = 1,
PACKET_DATA = 2,
PACKET_DISCONNECT = 3
};
이 문법은 C23 이전 표준에서는 사용할 수 없다.
구조체
구조체는 서로 다른 타입의 구성원을 하나의 객체 안에 순서대로 배치한다.
struct point {
int x;
int y;
};
구조체 객체는 다음과 같이 정의한다.
struct point position;
초기화할 수도 있다.
struct point position = {
.x = 10,
.y = 20
};
구조체 객체의 구성원은 .으로 접근한다.
position.x = 30;
구조체를 가리키는 포인터에서는 ->를 사용한다.
struct point *pointer = &position;
pointer->y = 40;
다음 두 표현은 같은 의미다.
pointer->x
(*pointer).x
구조체 구성원 사이에는 정렬을 위한 패딩이 삽입될 수 있다. 따라서 구조체의 크기가 각 구성원 크기의 단순한 합과 같다고 가정해서는 안 된다.
struct example {
char tag;
int value;
};
char와 int 사이에 패딩이 들어갈 수 있다.
union
union은 여러 구성원이 같은 저장 공간을 공유한다.
union number {
int integer;
float floating;
};
union의 크기는 일반적으로 가장 큰 구성원을 저장할 수 있는 크기와 정렬을 만족한다.
union number value;
value.integer = 10;
한 구성원에 값을 저장한 뒤 다른 구성원을 읽는 동작에는 세부적인 표준 규칙과 구현 특성이 관련된다. 임의의 타입 변환 수단으로 무조건 안전하다고 가정해서는 안 된다.
태그를 별도로 저장해 현재 활성 구성원을 구분할 수 있다.
enum value_type {
VALUE_INTEGER,
VALUE_FLOATING
};
struct value {
enum value_type type;
union {
int integer;
float floating;
} data;
};
배열
배열은 같은 타입의 요소를 연속해서 저장한다.
int values[4];
요소는 0부터 시작하는 첨자로 접근한다.
values[0] = 10;
values[1] = 20;
배열의 요소 수는 다음과 같이 계산할 수 있다.
size_t count = sizeof(values) / sizeof(values[0]);
이 방식은 values가 실제 배열인 범위에서만 유효하다. 함수 매개변수로 전달된 배열 선언은 포인터 타입으로 조정된다.
void clear_values(int values[10]) {
/* values의 타입은 함수 안에서 int *로 조정된다. */
}
따라서 함수 내부에서 sizeof(values)는 원래 배열 전체의 크기가 아니라 포인터 크기를 반환한다.
배열의 크기를 함께 전달해야 한다.
void clear_values(int *values, size_t count) {
for (size_t index = 0; index < count; ++index) {
values[index] = 0;
}
}
C99의 가변 길이 배열은 실행 시점의 크기를 사용할 수 있다.
void process(size_t count) {
int values[count];
/* ... */
}
가변 길이 배열의 지원 여부와 사용 가능한 범위는 표준 판과 구현체에 따라 다를 수 있다.
포인터
포인터는 객체나 함수를 가리키는 값을 나타낸다.
int value = 10;
int *pointer = &value;
&는 객체의 주소를 얻고 *는 포인터가 가리키는 객체에 접근한다.
*pointer = 20;
널 포인터는 유효한 객체나 함수를 가리키지 않는다.
int *pointer = NULL;
C23에서는 nullptr을 사용할 수 있다.
int *pointer = nullptr;
널 포인터를 역참조하면 정의되지 않은 동작이 발생한다.
*pointer = 10;
포인터 산술은 가리키는 타입의 요소 단위로 동작한다.
int values[] = { 10, 20, 30 };
int *pointer = values;
pointer += 2;
pointer는 세 번째 int 요소를 가리킨다.
포인터 산술은 같은 배열 객체와 그 끝 바로 다음 위치의 범위 안에서만 정의된다. 서로 관계없는 객체의 포인터를 임의로 더하거나 빼는 것은 허용되지 않는다.
함수 타입
함수 타입은 반환 타입과 매개변수 타입을 포함한다.
int add(int left, int right);
add는 두 개의 int를 받아 int를 반환하는 함수다.
함수를 가리키는 포인터를 만들 수 있다.
int (*operation)(int left, int right) = add;
함수 포인터를 호출할 수 있다.
int result = operation(10, 20);
다음 형태도 같은 호출을 나타낸다.
int result = (*operation)(10, 20);
함수 포인터는 콜백과 전략 선택, 이벤트 처리 및 동적 라이브러리 인터페이스에 사용된다.
타입 한정자
타입 한정자는 객체에 접근하는 방식과 최적화, 동시성에 관한 추가 제약을 표현한다.
const
const는 해당 식별자를 통해 객체를 수정할 수 없음을 나타낸다.
const int value = 10;
다음 대입은 허용되지 않는다.
value = 20;
포인터 선언에서는 const의 위치에 따라 의미가 달라진다.
const int *pointer;
pointer가 가리키는 int를 이 포인터를 통해 수정할 수 없다.
int *const pointer = &value;
포인터 자체를 다른 주소로 변경할 수 없다.
const int *const pointer = &value;
포인터 자체와 가리키는 값을 모두 해당 식별자를 통해 변경할 수 없다.
const가 객체가 물리적으로 절대 변하지 않음을 항상 의미하는 것은 아니다. 다른 비한정 포인터나 하드웨어, 다른 스레드에 의해 값이 변경될 가능성은 객체와 프로그램 구조에 따라 달라진다.
volatile
volatile은 객체에 대한 접근이 구현체가 관찰해야 하는 부작용을 가질 수 있음을 나타낸다.
volatile unsigned int *status_register;
메모리 매핑 하드웨어 레지스터나 일부 신호 처리 객체에 사용될 수 있다.
while ((*status_register & READY_BIT) == 0) {
}
컴파일러는 각 접근이 실제로 수행되어야 한다고 취급한다.
volatile은 스레드 동기화 수단이 아니다. 여러 스레드가 같은 객체에 접근한다면 원자적 타입과 뮤텍스 등 적절한 동기화가 필요하다.
restrict
restrict는 특정 포인터가 객체에 접근하는 주된 경로라는 약속을 표현한다.
void copy_values(
int *restrict destination,
const int *restrict source,
size_t count
) {
for (size_t index = 0; index < count; ++index) {
destination[index] = source[index];
}
}
호출자가 restrict의 별칭 규칙을 위반하면 정의되지 않은 동작이 발생할 수 있다. restrict는 단순한 최적화 요청이 아니라 프로그램이 지켜야 하는 의미 규칙이다.
_Atomic
원자적 타입은 여러 스레드 사이에서 분리되지 않는 원자적 접근을 제공한다.
#include <stdatomic.h>
_Atomic int counter = 0;
또는 원자적 타입 지정자를 사용할 수 있다.
atomic_int counter = 0;
원자적 객체에는 표준 원자 연산을 적용할 수 있다.
atomic_fetch_add(&counter, 1);
원자성만으로 전체 알고리즘의 동기화가 자동으로 해결되는 것은 아니다. 메모리 순서와 복합 상태의 일관성도 함께 설계해야 한다.
저장 클래스
저장 클래스 지정자는 객체와 함수의 저장 기간, 연결 및 선언의 성격에 영향을 준다.
auto
블록 범위의 일반 지역 변수는 기본적으로 자동 저장 기간을 가진다.
void function(void) {
auto int value = 10;
}
auto는 전통적으로 지역 객체의 저장 클래스를 나타냈으나 거의 생략해 왔다.
void function(void) {
int value = 10;
}
C23에서는 auto가 초기화식으로부터 객체 타입을 추론하는 데도 사용될 수 있다.
auto value = 42;
auto ratio = 3.14;
register
register는 객체에 빠른 접근이 가능하도록 구현체에 제안하는 저장 클래스였다.
register int index;
현대 컴파일러는 자체 분석으로 레지스터 배치를 결정하므로 이 지정자는 성능을 보장하지 않는다. 전통적으로 register 객체에는 주소 연산자를 적용할 수 없다는 제약도 있었다.
static
파일 범위에서 static은 내부 연결을 부여한다.
static int internal_count;
이 객체는 같은 번역 단위 안에서만 이름으로 접근할 수 있다.
함수에도 내부 연결을 부여할 수 있다.
static int calculate_internal_value(void) {
return 10;
}
블록 범위의 지역 객체에 사용하면 정적 저장 기간을 가진다.
int next_identifier(void) {
static int identifier = 0;
return ++identifier;
}
identifier는 함수 호출이 끝나도 수명을 유지한다.
extern
extern은 객체나 함수가 다른 위치에 정의될 수 있음을 나타낸다.
extern int global_count;
다른 번역 단위에 다음 정의가 있을 수 있다.
int global_count = 0;
함수 선언은 기본적으로 외부 연결을 가진다.
extern int calculate(void);
일반적으로 extern은 생략한다.
int calculate(void);
typedef
typedef는 기존 타입에 새로운 이름을 붙인다.
typedef unsigned long object_id;
object_id identifier = 10;
구조체 타입을 간결하게 표현할 수 있다.
typedef struct point {
int x;
int y;
} point;
point position = {
.x = 10,
.y = 20
};
typedef는 새로운 호환되지 않는 타입을 만드는 것이 아니라 기존 타입의 별칭을 만든다.
typedef int meter;
typedef int second;
meter와 second는 모두 int와 같은 타입이며 서로 자동으로 구분되지 않는다.
범위
범위는 하나의 선언으로 도입된 식별자를 소스 코드의 어느 영역에서 사용할 수 있는지를 나타낸다.
주요 범위는 다음과 같다.
- 파일 범위
- 블록 범위
- 함수 원형 범위
- 함수 범위
파일 범위
함수 바깥에서 선언된 식별자는 파일 범위를 가진다.
static int global_count;
int calculate(void) {
return global_count;
}
선언 지점부터 번역 단위의 끝까지 이름을 사용할 수 있다.
블록 범위
블록 안에서 선언된 식별자는 해당 블록과 내부 블록에서 사용할 수 있다.
int calculate(void) {
int result = 10;
if (result > 0) {
int doubled = result * 2;
return doubled;
}
return result;
}
doubled는 if 블록 바깥에서 사용할 수 없다.
내부 선언은 바깥쪽 이름을 가릴 수 있다.
int value = 10;
void function(void) {
int value = 20;
printf("%d\n", value);
}
함수 안의 value는 전역 value를 가린다.
함수 범위
레이블 이름은 함수 전체에 걸친 함수 범위를 가진다.
int process(int failed) {
if (failed) {
goto cleanup;
}
return 0;
cleanup:
return -1;
}
레이블이 goto보다 뒤에 있어도 같은 함수 안에서 참조할 수 있다.
연결
연결은 서로 다른 범위나 번역 단위의 같은 이름이 동일한 객체나 함수를 나타내는지를 결정한다.
연결은 다음과 같이 나뉜다.
- 외부 연결
- 내부 연결
- 연결 없음
외부 연결을 가진 함수는 여러 번역 단위에서 같은 함수를 나타낼 수 있다.
int calculate(void);
파일 범위에서 static으로 선언하면 내부 연결을 가진다.
static int calculate_internal(void);
지역 변수는 연결이 없다.
void function(void) {
int local_value;
}
연결과 범위는 다른 개념이다. 범위는 이름을 사용할 수 있는 소스 영역이고, 연결은 여러 선언이 같은 대상과 연결되는지를 나타낸다.
저장 기간
저장 기간은 객체의 저장 공간이 존재하는 기간을 나타낸다.
주요 저장 기간은 다음과 같다.
- 정적 저장 기간
- 스레드 저장 기간
- 자동 저장 기간
- 할당 저장 기간
정적 저장 기간
파일 범위 객체와 static 지역 객체는 프로그램 전체 실행 동안 존재한다.
static int global_count;
int next_value(void) {
static int local_count;
return ++local_count;
}
스레드 저장 기간
thread_local 또는 _Thread_local 객체는 각 스레드마다 별도 인스턴스를 가진다.
thread_local int current_worker_id;
객체의 수명은 해당 스레드의 실행 기간과 연결된다.
자동 저장 기간
일반 지역 변수와 함수 매개변수는 블록 실행과 연관된 자동 저장 기간을 가진다.
int calculate(int input) {
int result = input * 2;
return result;
}
함수가 반환되면 input과 result의 수명이 끝난다. 해당 객체를 가리키는 포인터를 반환하면 무효 포인터가 된다.
int *invalid_pointer(void) {
int value = 10;
return &value;
}
할당 저장 기간
동적 메모리 할당 함수로 얻은 저장 공간은 명시적으로 해제할 때까지 유지된다.
int *value = malloc(sizeof(*value));
if (value != NULL) {
*value = 10;
free(value);
}
할당한 메모리를 해제하지 않으면 메모리 누수가 발생한다. 해제한 저장 공간에 다시 접근하면 정의되지 않은 동작이 발생한다.
초기화
초기화는 객체가 만들어질 때 초기값을 설정한다.
int value = 10;
배열은 초기화 목록을 사용할 수 있다.
int values[4] = { 10, 20, 30, 40 };
일부 요소만 제공하면 나머지는 0에 해당하는 값으로 초기화된다.
int values[4] = { 10 };
이 배열의 값은 다음과 같다.
10, 0, 0, 0
구조체도 순서대로 초기화할 수 있다.
struct point {
int x;
int y;
};
struct point position = { 10, 20 };
지정 초기화를 사용하면 구성원 이름을 명시할 수 있다.
struct point position = {
.y = 20,
.x = 10
};
배열 인덱스도 지정할 수 있다.
int values[10] = {
[0] = 10,
[9] = 100
};
C23에서는 빈 초기화 목록을 사용할 수 있다.
struct point position = {};
int values[10] = {};
객체는 각 타입의 0에 해당하는 값으로 초기화된다.
정적 저장 기간을 가진 객체는 명시적 초기화가 없어도 0 초기화된다.
static int global_count;
static int *global_pointer;
자동 저장 기간의 객체는 명시적으로 초기화하지 않으면 불확정한 값을 가질 수 있다.
void function(void) {
int value;
printf("%d\n", value);
}
초기화되지 않은 자동 객체의 값을 읽으면 객체와 타입, 상황에 따라 정의되지 않은 동작이나 불확정한 결과가 발생할 수 있으므로 사용 전에 반드시 값을 설정해야 한다.
타입 변환
C는 여러 상황에서 값을 다른 타입으로 변환한다. 변환은 암시적으로 일어나거나 명시적 캐스트로 요청할 수 있다.
정수 승격
char와 short, _Bool 및 일부 열거형은 산술 연산에서 int 또는 unsigned int로 승격된다.
unsigned char left = 200;
unsigned char right = 100;
int result = left + right;
덧셈은 원래 unsigned char 타입에서 직접 수행되지 않고 정수 승격 후 수행된다.
일반 산술 변환
서로 다른 산술 타입을 함께 연산하면 공통 타입을 결정하기 위한 변환이 일어난다.
int integer = 10;
double floating = 2.5;
double result = integer + floating;
integer가 double로 변환된 뒤 덧셈이 수행된다.
부호 있는 정수와 부호 없는 정수를 혼합하면 예상하지 못한 결과가 발생할 수 있다.
int signed_value = -1;
unsigned int unsigned_value = 1;
if (signed_value < unsigned_value) {
/* 환경과 변환 규칙을 이해해야 한다. */
}
비교 전에 signed_value가 부호 없는 타입으로 변환될 수 있으므로 -1이 매우 큰 양수처럼 해석될 수 있다.
명시적 캐스트
캐스트는 변환할 타입을 괄호 안에 작성한다.
double value = 3.75;
int integer = (int)value;
소수 부분은 버려지고 integer는 3이 된다.
포인터 타입도 변환할 수 있다.
void *memory = malloc(sizeof(int));
int *integer = (int *)memory;
C에서는 void *에서 객체 포인터로의 변환에 캐스트가 필요하지 않다.
int *integer = memory;
불필요한 캐스트는 타입 오류를 숨길 수 있다. 특히 malloc의 반환값 캐스트는 <stdlib.h> 누락으로 인한 함수 선언 오류를 가릴 수 있으므로 C에서는 일반적으로 생략한다.
캐스트는 잘못된 데이터나 정렬, 객체 수명 문제를 해결하지 않는다. 단지 표준이 허용하는 타입 변환을 명시할 뿐이다.
표현식
표현식은 값을 계산하거나 객체를 읽고 변경하며 함수를 호출하는 문법 요소다.
left + right
표현식은 값과 타입을 가지며, 일부 표현식은 부작용을 발생시킨다.
count += 1;
이 표현식은 값을 계산하는 동시에 count를 변경한다.
표현식은 더 큰 표현식의 일부로 결합할 수 있다.
result = left + right * scale;
연산자 우선순위에 따라 곱셈이 먼저 수행된다.
result = left + (right * scale);
평가 순서는 우선순위와 다른 개념이다. 문법적으로 어떤 연산자가 어떤 피연산자와 결합하는지는 우선순위가 정하지만, 피연산자가 실제로 평가되는 순서는 별도 규칙에 따른다.
값 범주
C의 표현식은 객체를 지정하는 표현식과 값만을 계산하는 표현식 등 서로 다른 성질을 가질 수 있다.
객체를 지정하는 표현식은 대입의 왼쪽에 올 수 있는 경우가 있다.
int value;
value = 10;
수정 가능한 객체를 지정하지 않는 표현식에는 대입할 수 없다.
10 = value;
배열과 함수 표현식은 대부분의 문맥에서 각각 포인터로 변환된다.
int values[4];
int *pointer = values;
함수 이름도 함수 포인터로 변환될 수 있다.
int calculate(int value);
int (*callback)(int) = calculate;
산술 연산자
산술 연산자는 수치 계산에 사용된다.
| 연산자 | 의미 |
|---|---|
+ | 덧셈 |
- | 뺄셈 |
* | 곱셈 |
/ | 나눗셈 |
% | 나머지 |
+value | 단항 양수 |
-value | 단항 음수 |
int sum = left + right;
int difference = left - right;
int product = left * right;
int quotient = left / right;
int remainder = left % right;
정수 나눗셈은 0 방향으로 절삭된다.
int value = 7 / 2;
결과는 3이다.
int value = -7 / 2;
결과는 -3이다.
0으로 정수 나눗셈을 수행하면 정의되지 않은 동작이 발생한다.
int value = 10 / 0;
부호 있는 정수의 표현 범위를 넘는 연산도 정의되지 않은 동작이다.
int value = INT_MAX;
value += 1;
부호 없는 정수는 타입의 범위를 기준으로 모듈러 연산을 수행한다.
unsigned int value = UINT_MAX;
value += 1;
결과는 0이다.
증가와 감소
++와 --는 객체의 값을 1만큼 증가하거나 감소시킨다.
++count;
--count;
전위 연산은 변경된 값을 결과로 사용한다.
int result = ++count;
후위 연산은 변경 전 값을 결과로 사용한 뒤 객체를 변경한다.
int result = count++;
부작용이 있는 표현식을 한 문장 안에서 복잡하게 조합하면 평가 순서 문제를 일으킬 수 있다.
values[index++] = index;
같은 객체를 순서 규칙 없이 읽고 변경하는 표현식은 정의되지 않은 동작이 될 수 있다. 값을 변경하는 작업은 별도 문장으로 분리하는 편이 명확하다.
values[index] = index;
++index;
비교 연산자
비교 연산자는 두 값을 비교하여 참이면 1, 거짓이면 0인 int 값을 만든다.
| 연산자 | 의미 |
|---|---|
== | 같음 |
!= | 다름 |
< | 작음 |
> | 큼 |
<= | 작거나 같음 |
>= | 크거나 같음 |
if (left == right) {
/* ... */
}
대입 연산자 =와 동등 비교 연산자 ==를 혼동하면 오류가 발생할 수 있다.
if (value = 10) {
/* value에 10을 대입한 뒤 참으로 평가된다. */
}
비교하려면 ==를 사용한다.
if (value == 10) {
/* ... */
}
컴파일러 경고를 활성화하면 조건문 안의 의심스러운 대입을 진단할 수 있다.
논리 연산자
논리 연산자는 조건을 조합한다.
| 연산자 | 의미 | | | | ---- | ------ | - | ----- | | ! | 논리 부정 | | | | && | 논리 AND | | | | | | | 논리 OR |
if (pointer != NULL && *pointer > 0) {
process(*pointer);
}
&&와 ||는 단락 평가를 수행한다.
&&는 왼쪽이 거짓이면 오른쪽을 평가하지 않는다.||는 왼쪽이 참이면 오른쪽을 평가하지 않는다.
따라서 위 코드에서는 pointer가 널 포인터일 때 *pointer가 평가되지 않는다.
논리 연산의 결과는 0 또는 1이다.
int result = left < right && ready;
비트 연산자
비트 연산자는 정수의 개별 비트를 처리한다.
| 연산자 | 의미 | |
|---|---|---|
& | 비트 AND | |
| | 비트 OR | |
^ | 비트 XOR | |
~ | 비트 반전 | |
<< | 왼쪽 시프트 | |
>> | 오른쪽 시프트 |
unsigned int flags = 0;
flags |= FLAG_VISIBLE;
flags &= ~FLAG_DISABLED;
flags ^= FLAG_SELECTED;
특정 비트가 설정되어 있는지 확인할 수 있다.
if ((flags & FLAG_VISIBLE) != 0) {
/* ... */
}
시프트 연산은 비트 위치를 이동한다.
unsigned int mask = 1U << 5;
시프트할 비트 수가 타입의 폭보다 크거나 같거나 음수이면 정의되지 않은 동작이 발생한다. 부호 있는 음수의 시프트와 범위를 넘는 왼쪽 시프트에도 주의해야 한다. 비트 조작에는 부호 없는 정수 타입을 사용하는 편이 규칙을 이해하기 쉽다.
대입 연산자
단순 대입은 오른쪽 값을 왼쪽 객체에 저장한다.
value = 10;
복합 대입 연산자는 기존 값과 연산한 결과를 다시 저장한다.
value += 10;
value -= 10;
value *= 2;
value /= 2;
value %= 3;
flags &= mask;
flags |= mask;
flags ^= mask;
value <<= 1;
value >>= 1;
복합 대입은 왼쪽 피연산자를 한 번만 평가한다는 점에서 단순한 텍스트 치환과 다르다.
values[index++] += 10;
index++는 한 번만 평가된다.
대입 표현식 자체도 값을 가진다.
left = right = 0;
먼저 right에 0을 대입하고, 그 결과를 left에 대입한다.
조건문에서 대입 결과를 사용할 수도 있지만, 의도가 불분명해질 수 있으므로 괄호와 명확한 비교를 사용한다.
while ((character = getchar()) != EOF) {
putchar(character);
}
조건 연산자
조건 연산자 ?:는 조건에 따라 두 표현식 중 하나를 평가한다.
int maximum = left > right ? left : right;
다음 if 문과 비슷하다.
int maximum;
if (left > right) {
maximum = left;
} else {
maximum = right;
}
조건 연산자는 값을 선택하는 짧은 표현에 적합하다. 여러 부작용과 중첩 조건을 포함하면 가독성이 떨어질 수 있다.
쉼표 연산자
쉼표 연산자는 왼쪽 표현식을 평가한 뒤 오른쪽 표현식을 평가하고, 오른쪽 결과를 전체 값으로 사용한다.
int result = (prepare(), calculate());
함수 인수와 선언자 목록에서 사용하는 쉼표는 쉼표 연산자와 문법적 역할이 다르다.
function(left, right);
두 함수 인수의 평가 순서는 쉼표로 적혀 있다는 이유만으로 왼쪽에서 오른쪽으로 보장되지 않는다.
for 문에서는 여러 표현을 함께 갱신할 때 쉼표 연산자를 자주 사용한다.
for (
size_t left = 0, right = count - 1;
left < right;
++left, --right
) {
/* ... */
}
sizeof
sizeof는 타입 또는 객체 표현의 크기를 바이트 단위로 반환한다.
size_t integer_size = sizeof(int);
size_t value_size = sizeof value;
타입 이름에는 괄호가 필요하다.
sizeof(int)
표현식에는 괄호를 생략할 수 있다.
sizeof value
배열 전체 크기를 구할 수 있다.
int values[10];
size_t size = sizeof(values);
size_t count = sizeof(values) / sizeof(values[0]);
대부분의 표현식은 sizeof 안에서 실제로 평가되지 않는다.
size_t size = sizeof(value++);
가변 길이 배열과 관련된 일부 상황을 제외하면 value는 증가하지 않는다.
동적 메모리를 할당할 때 대상 객체를 기준으로 크기를 계산하면 타입 변경에 안전하다.
int *values = malloc(count * sizeof(*values));
정렬 연산자
_Alignof 또는 C23의 alignof는 타입의 정렬 요구 사항을 반환한다.
size_t alignment = alignof(double);
_Alignas 또는 alignas는 객체에 정렬 요구를 지정한다.
alignas(64) unsigned char cache_line[64];
정렬은 구조체 배치와 SIMD, 원자적 접근, 하드웨어 인터페이스에서 중요할 수 있다.
구성원 접근
구조체와 union 객체의 구성원은 .으로 접근한다.
position.x
포인터를 통한 구성원 접근에는 ->를 사용한다.
pointer->x
->는 역참조와 .을 결합한 형태다.
pointer->x
(*pointer).x
역참조 연산자의 우선순위 때문에 괄호가 필요하다.
*pointer.x
이 표현은 *(pointer.x)로 해석되므로 구조체 포인터 접근과 다른 의미다.
배열 첨자
배열 첨자 연산은 포인터 산술과 역참조를 결합한 형태다.
values[index]
다음 표현과 같다.
*(values + index)
문법적으로는 다음 표현도 같은 결과를 가질 수 있다.
index[values]
하지만 관례와 가독성에 어긋나므로 사용하지 않는다.
C는 기본 배열 첨자에서 범위를 자동 검사하지 않는다.
int values[4];
values[4] = 10;
유효한 첨자는 0부터 3까지이므로 범위를 벗어난 접근은 정의되지 않은 동작이다.
함수 호출
함수는 함수 이름이나 함수 포인터 뒤에 괄호와 인수 목록을 작성하여 호출한다.
int result = add(10, 20);
인수 표현식은 함수 매개변수 타입에 맞게 변환된다.
double square(double value);
double result = square(10);
정수 10은 double로 변환된다.
함수 인수의 평가 순서는 일반적으로 왼쪽에서 오른쪽으로 보장되지 않는다.
function(first(), second());
first와 second 중 어느 함수가 먼저 호출되는지 가정해서는 안 된다. 실행 순서가 중요하면 별도 문장으로 나눈다.
int first_result = first();
int second_result = second();
function(first_result, second_result);
복합 리터럴
C99의 복합 리터럴은 타입 이름과 초기화 목록으로 이름 없는 객체를 만든다.
struct point {
int x;
int y;
};
draw_point((struct point) {
.x = 10,
.y = 20
});
배열 복합 리터럴도 만들 수 있다.
process_values(
(int[]) { 10, 20, 30 },
3
);
복합 리터럴의 저장 기간은 사용 위치와 표준 판의 규칙에 따라 결정된다.
일반 선택
C11의 _Generic은 제어 표현식의 타입에 따라 하나의 표현식을 선택한다.
#define TYPE_NAME(value) \
_Generic((value), \
int: "int", \
float: "float", \
double: "double", \
default: "other")
printf("%s\n", TYPE_NAME(10));
결과는 "int"다.
타입별 함수를 선택하는 매크로를 만들 수도 있다.
#define ABSOLUTE(value) \
_Generic((value), \
int: abs, \
long: labs, \
float: fabsf, \
double: fabs \
)(value)
_Generic은 런타임 분기가 아니라 번역 시점의 타입 선택이다.
상수 표현식
상수 표현식은 번역 시점에 평가할 수 있도록 제한된 표현식이다. 배열 크기와 열거 상수, case 레이블과 정적 검증 등에 사용된다.
enum {
BUFFER_SIZE = 1024
};
int buffer[BUFFER_SIZE];
switch (value) {
case BUFFER_SIZE:
break;
}
C23의 constexpr 객체는 이름이 있는 상수를 정의할 수 있다.
constexpr size_t buffer_size = 4096;
static_assert(buffer_size > 0);
const 객체가 항상 정수 상수 표현식으로 인정되는 것은 아니다.
const int size = 10;
표준 판과 문맥에 따라 배열 크기 등의 상수 표현식으로 사용할 수 없는 경우가 있다. enum, 매크로 또는 C23의 constexpr을 사용할 수 있다.
문장
문장은 프로그램의 실행 흐름을 구성한다.
주요 문장 종류는 다음과 같다.
- 레이블 문장
- 복합문
- 표현식 문장
- 선택문
- 반복문
- 이동문
표현식 문장
표현식 뒤에 세미콜론을 붙이면 표현식 문장이 된다.
value = 10;
function();
++count;
표현식이 없는 세미콜론은 빈 문장이다.
;
복합문
중괄호로 둘러싼 선언과 문장의 묶음을 복합문 또는 블록이라고 한다.
{
int value = 10;
process(value);
}
함수 본문과 제어문의 본문에 사용된다.
if 문
if는 조건에 따라 문장을 실행한다.
if (value > 0) {
process_positive(value);
}
else를 사용해 조건이 거짓일 때 실행할 문장을 지정할 수 있다.
if (value > 0) {
process_positive(value);
} else {
process_non_positive(value);
}
여러 조건을 연결할 수 있다.
if (value > 0) {
result = 1;
} else if (value < 0) {
result = -1;
} else {
result = 0;
}
C에서 0은 거짓이고 0이 아닌 값은 참으로 처리된다.
if (pointer) {
/* pointer가 널 포인터가 아님 */
}
명확하게 비교할 수도 있다.
if (pointer != NULL) {
/* ... */
}
중괄호를 생략하면 다음 하나의 문장만 조건에 포함된다.
if (ready)
start();
유지보수 과정에서 문장이 추가될 수 있으므로 TechPedia의 설명용 코드에서는 한 문장이어도 중괄호를 사용하는 형태를 기본으로 한다.
if (ready) {
start();
}
switch 문
switch는 정수 또는 열거형 표현식의 값에 따라 여러 경로 중 하나를 선택한다.
switch (command) {
case COMMAND_START:
start();
break;
case COMMAND_STOP:
stop();
break;
default:
report_unknown_command();
break;
}
case 레이블에는 정수 상수 표현식이 필요하다.
break가 없으면 다음 case로 실행이 이어진다.
switch (level) {
case LEVEL_ERROR:
log_error();
case LEVEL_WARNING:
increase_warning_count();
break;
}
이러한 fallthrough가 의도된 경우 C23 속성 또는 명확한 주석을 사용할 수 있다.
switch (level) {
case LEVEL_ERROR:
log_error();
[[fallthrough]];
case LEVEL_WARNING:
increase_warning_count();
break;
}
같은 switch 안에서 case 값은 중복될 수 없다.
while 문
while은 조건이 참인 동안 본문을 반복한다.
while (count > 0) {
process();
--count;
}
조건은 반복 전에 평가되므로 처음부터 거짓이면 본문이 한 번도 실행되지 않는다.
무한 반복은 다음과 같이 작성할 수 있다.
while (true) {
process_events();
}
C23 이전에는 <stdbool.h>의 true나 정수 1을 사용할 수 있다.
while (1) {
process_events();
}
do 문
do 문은 본문을 먼저 실행하고 이후 조건을 검사한다.
do {
read_input();
} while (!input_is_valid());
본문은 최소 한 번 실행된다. 끝의 while 뒤에는 세미콜론이 필요하다.
for 문
for는 초기화와 조건, 반복 후 표현식을 한곳에 작성한다.
for (size_t index = 0; index < count; ++index) {
process(values[index]);
}
구조는 다음과 같다.
for (초기화; 조건; 반복 후 표현식)
초기화는 반복 시작 전에 한 번 실행된다. 조건은 각 반복 전에 평가되고, 반복 후 표현식은 본문이 끝난 뒤 실행된다.
각 부분은 생략할 수 있다.
for (;;) {
process_events();
}
이는 무한 반복이다.
여러 값을 갱신할 수도 있다.
for (
size_t left = 0, right = count - 1;
left < right;
++left, --right
) {
swap(&values[left], &values[right]);
}
break
break는 가장 가까운 반복문이나 switch 문을 종료한다.
while (true) {
if (should_stop()) {
break;
}
process();
}
중첩된 반복문에서는 가장 안쪽 반복문만 종료한다.
continue
continue는 현재 반복의 나머지 부분을 건너뛰고 다음 반복으로 이동한다.
for (size_t index = 0; index < count; ++index) {
if (!is_valid(values[index])) {
continue;
}
process(values[index]);
}
for 문에서는 반복 후 표현식으로 이동하고, while과 do에서는 조건 평가로 이동한다.
return
return은 현재 함수를 종료하고 호출한 위치로 돌아간다.
값을 반환하는 함수는 반환 표현식을 사용한다.
int add(int left, int right) {
return left + right;
}
void 함수는 값 없이 반환할 수 있다.
void process(int value) {
if (value < 0) {
return;
}
/* ... */
}
반환 표현식은 함수의 반환 타입으로 변환된다.
double calculate(void) {
return 10;
}
정수 10은 double로 변환된다.
값을 반환해야 하는 함수가 정상적인 실행 경로에서 반환값 없이 끝나면 문제가 발생한다. main 함수는 예외적으로 끝까지 도달하면 return 0;과 같은 의미를 가진다.
goto와 레이블
goto는 같은 함수 안의 레이블로 실행을 이동한다.
int process(const char *path) {
FILE *file = fopen(path, "rb");
if (file == NULL) {
return -1;
}
void *buffer = malloc(4096);
if (buffer == NULL) {
goto cleanup_file;
}
free(buffer);
fclose(file);
return 0;
cleanup_file:
fclose(file);
return -1;
}
C에는 범위가 끝날 때 일반 자원을 자동으로 해제하는 통합 기능이 없으므로, 여러 자원을 역순으로 정리하는 오류 처리에서 goto를 제한적으로 사용하기도 한다.
int process(const char *path) {
int result = -1;
FILE *input = NULL;
FILE *output = NULL;
void *buffer = NULL;
input = fopen(path, "rb");
if (input == NULL) {
goto cleanup;
}
output = fopen("output.bin", "wb");
if (output == NULL) {
goto cleanup;
}
buffer = malloc(4096);
if (buffer == NULL) {
goto cleanup;
}
result = 0;
cleanup:
free(buffer);
if (output != NULL) {
fclose(output);
}
if (input != NULL) {
fclose(input);
}
return result;
}
goto는 함수 밖이나 다른 함수의 레이블로 이동할 수 없다.
함수
함수는 입력을 매개변수로 받고 작업을 수행한 뒤 선택적으로 값을 반환하는 프로그램 단위다.
함수 선언은 반환 타입과 이름, 매개변수 타입을 지정한다.
int add(int left, int right);
함수 정의는 본문을 포함한다.
int add(int left, int right) {
return left + right;
}
함수는 호출 전에 적절한 선언이 보여야 한다.
int calculate(int value);
int main(void) {
return calculate(10);
}
int calculate(int value) {
return value * 2;
}
C99 이후에는 선언되지 않은 함수를 암묵적으로 int 반환 함수로 간주하지 않는다.
매개변수
함수 매개변수는 함수가 호출될 때 인수 값으로 초기화되는 지역 객체다.
int multiply(int left, int right) {
return left * right;
}
C는 기본적으로 인수를 값으로 전달한다.
void set_value(int value) {
value = 10;
}
호출자의 객체는 변경되지 않는다.
int value = 0;
set_value(value);
호출자의 객체를 변경하려면 포인터를 전달한다.
void set_value(int *value) {
*value = 10;
}
int value = 0;
set_value(&value);
구조체도 값으로 전달할 수 있다.
struct point move_point(struct point point, int x, int y) {
point.x += x;
point.y += y;
return point;
}
원본 구조체는 변경되지 않고 복사된 매개변수가 수정된다.
큰 구조체는 복사 비용과 수정 의도를 고려해 포인터로 전달할 수 있다.
void move_point(struct point *point, int x, int y) {
point->x += x;
point->y += y;
}
배열 매개변수
함수 매개변수에서 배열 문법은 포인터 타입으로 조정된다.
void process(int values[10]);
함수 타입에서는 다음 선언과 호환된다.
void process(int *values);
배열 길이는 자동으로 전달되지 않는다.
void process(const int *values, size_t count) {
for (size_t index = 0; index < count; ++index) {
printf("%d\n", values[index]);
}
}
C99의 배열 매개변수 문법은 최소 요소 수나 한정자를 표현할 수 있다.
void process(
size_t count,
int values[static count]
) {
/* 호출자는 최소 count개의 요소를 제공해야 한다. */
}
이 문법은 호출자와 구현체에 계약 정보를 제공하지만 런타임 범위 검사를 자동으로 수행한다는 뜻은 아니다.
가변 인수 함수
마지막 매개변수에 ...를 사용하면 가변 개수의 인수를 받는 함수를 선언할 수 있다.
int printf(const char *restrict format, ...);
사용자 정의 가변 인수 함수는 <stdarg.h>를 사용한다.
#include <stdarg.h>
int sum_values(size_t count, ...) {
va_list arguments;
int result = 0;
va_start(arguments, count);
for (size_t index = 0; index < count; ++index) {
result += va_arg(arguments, int);
}
va_end(arguments);
return result;
}
호출자는 함수가 기대하는 인수의 개수와 타입을 정확히 제공해야 한다.
int result = sum_values(3, 10, 20, 30);
가변 인수 부분에는 함수 원형을 통한 개별 타입 검사가 적용되지 않는다. 잘못된 타입을 읽으면 정의되지 않은 동작이 발생할 수 있다.
재귀
C 함수는 자기 자신을 직접 또는 간접적으로 호출할 수 있다.
unsigned long long factorial(unsigned int value) {
if (value <= 1) {
return 1;
}
return value * factorial(value - 1);
}
재귀 호출마다 자동 객체와 호출 정보가 새로 생성될 수 있다. 지나치게 깊은 재귀는 실행 환경의 호출 스택을 소진할 수 있다.
inline
inline은 함수 호출을 인라인 형태로 구현할 수 있음을 나타내며, C의 연결 및 정의 규칙에도 영향을 준다.
static inline int square(int value) {
return value * value;
}
실제 인라인화 여부는 컴파일러가 결정한다. inline만으로 호출 비용 제거가 보장되지 않는다.
헤더에 짧은 함수를 정의할 때는 내부 연결을 가진 static inline 형태가 자주 사용된다.
#ifndef VECTOR_H
#define VECTOR_H
struct vector2 {
float x;
float y;
};
static inline struct vector2 vector2_add(
struct vector2 left,
struct vector2 right
) {
return (struct vector2) {
.x = left.x + right.x,
.y = left.y + right.y
};
}
#endif
함수 포인터와 콜백
함수 포인터는 함수를 값처럼 선택하고 전달할 수 있게 한다.
typedef int (*operation_function)(int left, int right);
int add(int left, int right) {
return left + right;
}
int subtract(int left, int right) {
return left - right;
}
int calculate(
operation_function operation,
int left,
int right
) {
return operation(left, right);
}
int result = calculate(add, 10, 20);
함수 포인터 배열을 사용해 명령에 따라 함수를 선택할 수도 있다.
static operation_function operations[] = {
add,
subtract
};
함수 포인터 타입이 실제 함수와 호환되지 않으면 호출 시 정의되지 않은 동작이 발생할 수 있다.
전처리기
C 전처리기는 C의 일반적인 구문 분석 전에 소스의 전처리 토큰을 처리한다.
주요 기능은 다음과 같다.
- 헤더와 파일 포함
- 매크로 정의와 확장
- 조건부 컴파일
- 번역 진단
- 구현체 지시
- 소스 줄 정보 제어
- C23의 바이너리 데이터 포함
전처리 지시문은 일반적으로 줄의 첫 번째 전처리 토큰인 #으로 시작한다.
#include <stdio.h>
#define BUFFER_SIZE 4096
#include
#include는 지정한 헤더나 파일의 내용을 현재 전처리 번역 단위에 포함한다.
#include <stdio.h>
꺾쇠괄호 형태는 일반적으로 구현체와 시스템의 헤더 검색 경로를 사용한다.
#include "project.h"
큰따옴표 형태는 일반적으로 현재 소스와 프로젝트 경로를 먼저 검색하고, 찾지 못하면 시스템 헤더 검색 방식으로 이어질 수 있다. 정확한 검색 규칙은 구현체가 정한다.
헤더의 텍스트가 직접 포함되므로 같은 선언이 여러 번 처리되지 않도록 include guard를 사용한다.
#ifndef PROJECT_H
#define PROJECT_H
int project_initialize(void);
#endif
구현체가 지원하면 #pragma once도 사용할 수 있지만 ISO C 표준의 전처리 지시문은 아니다.
객체형 매크로
객체형 매크로는 이름을 다른 전처리 토큰으로 치환한다.
#define BUFFER_SIZE 4096
#define APPLICATION_NAME "TechPedia"
사용 위치에서 토큰이 확장된다.
char buffer[BUFFER_SIZE];
매크로는 타입이 있는 객체가 아니다. 정수 상수에는 열거형이나 C23의 constexpr 객체를 사용할 수도 있다.
enum {
BUFFER_SIZE = 4096
};
constexpr size_t buffer_size = 4096;
함수형 매크로
함수형 매크로는 인수를 받아 토큰을 치환한다.
#define MAXIMUM(left, right) \
((left) > (right) ? (left) : (right))
매크로 본문과 인수를 괄호로 감싸지 않으면 연산자 우선순위 문제를 일으킬 수 있다.
#define SQUARE(value) value * value
int result = SQUARE(1 + 2);
확장 결과는 다음과 같다.
int result = 1 + 2 * 1 + 2;
안전한 형태는 다음과 같다.
#define SQUARE(value) ((value) * (value))
그러나 인수가 여러 번 평가되는 문제는 남는다.
int result = SQUARE(index++);
index++가 두 번 확장되므로 정의되지 않은 동작이나 예상하지 못한 결과를 일으킬 수 있다. 타입과 평가 규칙이 필요한 경우 inline 함수를 사용하는 편이 안전하다.
static inline int square(int value) {
return value * value;
}
문자열화
함수형 매크로의 매개변수 앞에 #을 붙이면 전달된 토큰을 문자열로 만든다.
#define STRINGIFY(value) #value
const char *text = STRINGIFY(hello);
결과는 "hello"다.
매크로가 먼저 확장된 뒤 문자열화되어야 한다면 보조 매크로가 필요하다.
#define STRINGIFY_DIRECT(value) #value
#define STRINGIFY(value) STRINGIFY_DIRECT(value)
#define VERSION 23
const char *version = STRINGIFY(VERSION);
토큰 결합
##는 두 전처리 토큰을 하나로 결합한다.
#define DECLARE_VARIABLE(name) int variable_##name
DECLARE_VARIABLE(count);
확장 결과는 다음과 같다.
int variable_count;
토큰 결합은 반복적인 선언과 코드 생성에 사용될 수 있지만, 지나치게 사용하면 실제 생성되는 식별자를 추적하기 어려워진다.
가변 인수 매크로
가변 인수 매크로는 ...와 __VA_ARGS__를 사용한다.
#define LOG(format, ...) \
fprintf(stderr, format, __VA_ARGS__)
LOG("value: %d\n", value);
C23은 가변 인수가 비어 있는 경우를 다루기 위한 __VA_OPT__를 제공한다.
#define LOG(format, ...) \
fprintf( \
stderr, \
format __VA_OPT__(,) __VA_ARGS__ \
)
LOG("started\n");
LOG("value: %d\n", value);
조건부 컴파일
조건부 컴파일은 전처리 조건에 따라 소스 일부를 포함하거나 제외한다.
#if defined(_WIN32)
#define PLATFORM_NAME "Windows"
#elif defined(__linux__)
#define PLATFORM_NAME "Linux"
#elif defined(__APPLE__)
#define PLATFORM_NAME "Apple"
#else
#define PLATFORM_NAME "Unknown"
#endif
#ifdef와 #ifndef는 매크로 정의 여부를 검사한다.
#ifdef DEBUG
log_debug_information();
#endif
#ifndef BUFFER_SIZE
#define BUFFER_SIZE 4096
#endif
C23에서는 #elifdef와 #elifndef를 사용할 수 있다.
#if defined(_WIN32)
#define PLATFORM_WINDOWS
#elifdef __linux__
#define PLATFORM_LINUX
#endif
전처리 조건식은 일반 C 표현식과 같은 모든 기능을 제공하지 않는다. 전처리 단계에서 사용할 수 있는 정수 상수 표현식과 defined 연산자를 사용한다.
#error와 #warning
#error는 번역을 실패시키는 진단을 발생시킨다.
#ifndef REQUIRED_FEATURE
#error "REQUIRED_FEATURE must be defined"
#endif
C23의 #warning은 경고 진단을 요청한다.
#ifndef OPTIMIZED_BUILD
#warning "Building without optimization"
#endif
#line
#line은 이후 소스의 줄 번호와 파일 이름 정보를 변경한다.
#line 100 "generated.c"
코드 생성기와 전처리 도구가 진단 위치를 원본 소스에 대응시키는 데 사용할 수 있다.
#pragma
#pragma는 구현체별 동작을 요청한다.
#pragma STDC FENV_ACCESS ON
표준은 일부 STDC pragma를 정의하지만, 많은 pragma는 컴파일러별 확장이다.
#pragma once
지원하지 않는 pragma에 대한 처리는 구현체 규칙에 따른다. 이식 가능한 코드에서는 pragma의 지원 여부와 대체 경로를 고려해야 한다.
_Pragma
_Pragma는 문자열 리터럴을 사용하여 pragma를 표현하는 단항 연산자 형태다.
_Pragma("STDC FENV_ACCESS ON")
매크로 안에서 pragma를 생성할 수 있다는 장점이 있다.
#define ENABLE_FLOATING_ENVIRONMENT \
_Pragma("STDC FENV_ACCESS ON")
#embed
C23의 #embed는 외부 리소스의 바이트를 전처리 과정에서 포함한다.
static const unsigned char icon_data[] = {
#embed "icon.bin"
};
파일의 데이터가 배열 초기화 요소로 삽입된다. 임베디드 펌웨어와 게임, 그래픽 및 테스트 데이터처럼 바이너리 리소스를 실행 파일에 포함할 때 사용할 수 있다.
#embed가 파일을 찾는 방법과 지원하는 리소스 경로는 구현체의 환경과 옵션에도 영향을 받는다.
미리 정의된 매크로
구현체는 표준이 정한 여러 매크로를 미리 정의한다.
대표적인 매크로는 다음과 같다.
| 매크로 | 의미 |
|---|---|
__FILE__ | 현재 소스 파일 이름 |
__LINE__ | 현재 줄 번호 |
__DATE__ | 번역 날짜 |
__TIME__ | 번역 시간 |
__STDC__ | 표준 C 구현 여부 |
__STDC_VERSION__ | 지원하는 C 표준 판 |
__STDC_HOSTED__ | 호스트 구현 여부 |
printf(
"%s:%d\n",
__FILE__,
__LINE__
);
표준 판을 확인할 수 있다.
#if defined(__STDC_VERSION__) && \
__STDC_VERSION__ >= 202311L
#define HAS_C23 1
#else
#define HAS_C23 0
#endif
__DATE__와 __TIME__은 빌드마다 결과가 달라질 수 있으므로 재현 가능한 빌드에서는 사용을 피하거나 빌드 시스템에서 통제할 수 있다.
속성
C23의 속성은 선언과 문장 등에 추가 정보를 붙이는 표준 문법이다.
[[nodiscard]]
int create_resource(void);
반환값을 사용하지 않을 때 구현체가 진단할 수 있다.
[[deprecated]]
void legacy_function(void);
사용이 권장되지 않는 기능임을 나타낸다.
switch (value) {
case 1:
process_first();
[[fallthrough]];
case 2:
process_second();
break;
}
C23에서 정의된 주요 속성에는 다음이 포함된다.
deprecatedfallthroughmaybe_unusednodiscardnoreturnreproducibleunsequenced
구현체는 별도 이름 공간의 확장 속성을 제공할 수도 있다. 프로그램은 알 수 없는 속성을 처리하는 표준 규칙을 고려하여 이식성을 유지할 수 있다.
정적 검증
static_assert는 번역 시점에 조건을 검사한다.
static_assert(sizeof(unsigned int) >= 4);
조건이 거짓이면 번역에 필요한 진단이 발생한다.
구조체와 바이너리 인터페이스의 가정을 검사할 수 있다.
struct packet_header {
uint32_t type;
uint32_t size;
};
static_assert(
sizeof(struct packet_header) == 8,
"unexpected packet header size"
);
다만 구조체 패딩과 바이트 순서를 단순한 크기 검사만으로 모두 보장할 수 있는 것은 아니다.
C11의 기존 문법은 _Static_assert다.
_Static_assert(
sizeof(int) >= 2,
"int is too small"
);
C23에서는 static_assert를 직접 사용할 수 있다.
완전한 예시
다음 코드는 C의 여러 문법과 구성 요소를 하나의 작은 프로그램으로 결합한 예다.
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#define INITIAL_CAPACITY 8
struct integer_list {
int *values;
size_t count;
size_t capacity;
};
static bool integer_list_reserve(
struct integer_list *list,
size_t capacity
) {
if (capacity <= list->capacity) {
return true;
}
int *new_values = realloc(
list->values,
capacity * sizeof(*new_values)
);
if (new_values == NULL) {
return false;
}
list->values = new_values;
list->capacity = capacity;
return true;
}
static bool integer_list_push(
struct integer_list *list,
int value
) {
if (list->count == list->capacity) {
size_t new_capacity = list->capacity == 0
? INITIAL_CAPACITY
: list->capacity * 2;
if (!integer_list_reserve(list, new_capacity)) {
return false;
}
}
list->values[list->count++] = value;
return true;
}
static void integer_list_destroy(
struct integer_list *list
) {
free(list->values);
*list = (struct integer_list) {
.values = NULL,
.count = 0,
.capacity = 0
};
}
int main(void) {
struct integer_list list = { 0 };
for (int value = 1; value <= 10; ++value) {
if (!integer_list_push(&list, value)) {
fprintf(stderr, "memory allocation failed\n");
integer_list_destroy(&list);
return EXIT_FAILURE;
}
}
for (size_t index = 0; index < list.count; ++index) {
printf("%d\n", list.values[index]);
}
integer_list_destroy(&list);
return EXIT_SUCCESS;
}
이 프로그램에는 다음 요소가 사용되었다.
- 표준 헤더와 매크로
- 구조체
- 포인터
size_t- 내부 연결 함수
- 함수 원형과 매개변수
- 동적 메모리 할당
sizeof- 조건문과 반복문
- 조건 연산자
- 구조체 지정 초기화
- 복합 리터럴
- 오류 반환값
- 명시적인 자원 해제
C의 각 문법 요소는 개별적으로는 비교적 단순하지만, 타입과 객체 수명, 변환과 평가 순서 및 포인터 규칙이 결합되면 프로그램의 의미가 복잡해질 수 있다. 올바른 C 프로그램을 작성하려면 문법적인 형태뿐 아니라 각 표현식이 어떤 타입과 값을 가지며, 객체가 언제 존재하고 어떤 접근이 허용되는지도 함께 이해해야 한다.[35]
메모리 모델
C의 메모리 모델은 프로그램의 객체가 언제 생성되고 사라지는지, 값을 어떤 바이트 표현으로 저장하는지, 포인터가 무엇을 가리킬 수 있는지, 서로 다른 타입을 통한 메모리 접근이 언제 허용되는지, 여러 스레드의 읽기와 쓰기가 어떤 순서로 관찰되는지를 규정한다.
여기서 메모리 모델은 특정 운영체제의 가상 메모리 구조나 프로세서의 물리 메모리 배치를 그대로 설명하는 개념이 아니다. C 표준은 프로그램이 실행되는 추상 기계를 정의하고, 구현체가 실제 하드웨어에서 그 추상 기계의 관찰 가능한 동작을 재현하도록 요구한다.
일반적으로 C 프로그램의 메모리를 다음과 같이 설명하기도 한다.
코드 영역
읽기 전용 데이터
전역·정적 데이터
동적 할당 영역
호출 스택
이 구조는 많은 운영체제와 실행 파일 형식에서 실제로 사용되지만, ISO C 표준이 이러한 구체적인 영역이나 방향을 요구하는 것은 아니다. 표준에서 핵심이 되는 개념은 객체, 타입, 저장 기간, 수명, 정렬, 표현, 접근 규칙과 실행 순서다.
객체
C에서 객체는 값을 나타낼 수 있도록 내용이 해석되는 저장 영역이다. 변수로 선언한 값뿐 아니라 배열 요소와 구조체 구성원, 문자열 리터럴이 만드는 배열, 동적 할당 저장 공간에 만들어진 값도 객체가 될 수 있다.
int count = 10;
count는 int 타입의 객체다. 이 객체에는 int 값을 표현할 수 있는 저장 공간이 존재한다.
배열은 하나의 배열 객체이면서 각각의 요소 객체를 포함한다.
int values[4] = {
10,
20,
30,
40
};
이 선언에는 배열 객체 values와 네 개의 int 요소 객체가 존재한다.
구조체도 하나의 구조체 객체 안에 여러 구성원 객체를 포함한다.
struct point {
int x;
int y;
};
struct point position = {
.x = 10,
.y = 20
};
position은 구조체 객체이고, position.x와 position.y는 그 안에 포함된 구성원 객체다.
객체는 반드시 식별자를 가져야 하는 것은 아니다. 동적 할당 저장 공간과 복합 리터럴로 만들어진 객체에는 일반 변수 이름이 없을 수 있다.
struct point *origin = &(struct point) {
.x = 0,
.y = 0
};
복합 리터럴은 이름 없는 struct point 객체를 만들고, origin은 그 객체를 가리킨다.
객체와 값
객체와 그 객체가 저장하는 값은 같은 개념이 아니다.
int value = 10;
value는 객체를 나타내는 식별자다.- 객체의 타입은
int다. - 객체에 현재 저장된 값은 10이다.
- 객체는 값을 저장하기 위한 일정한 바이트 표현을 가진다.
다음 대입은 객체 자체를 다른 객체로 바꾸는 것이 아니라, 객체에 저장된 값을 변경한다.
value = 20;
하나의 객체는 수명 동안 여러 값을 차례로 저장할 수 있다.
부분 객체
배열 요소와 구조체 또는 union의 구성원은 더 큰 객체 안에 포함된 부분 객체다.
struct rectangle {
struct point minimum;
struct point maximum;
};
struct rectangle area;
area 안에는 다음과 같은 객체 관계가 존재한다.
area
├── minimum
│ ├── x
│ └── y
└── maximum
├── x
└── y
부분 객체는 자신만의 타입과 값을 갖지만, 저장 공간과 수명은 상위 객체와 밀접하게 연결된다. 상위 구조체 객체의 수명이 끝나면 그 안의 구성원 객체 수명도 함께 끝난다.
저장 기간과 수명
객체의 저장 기간은 저장 공간을 확보하고 유지하는 방식의 분류다. 수명은 실제 프로그램 실행 중 해당 객체의 저장 공간이 보장되고 객체를 유효하게 참조할 수 있는 기간이다.
C는 다음 네 가지 저장 기간을 정의한다.
- 정적 저장 기간
- 스레드 저장 기간
- 자동 저장 기간
- 할당 저장 기간
객체의 수명 안에서는 저장 공간이 유지되고, 일반적으로 객체의 주소가 일정하며, 마지막으로 저장된 값이 유지된다. 객체의 수명이 끝난 뒤 객체를 참조하거나 객체를 가리키던 포인터 값을 잘못 사용하면 정의되지 않은 동작이 발생한다.[36]
저장 기간은 식별자의 범위와 다르다.
- 범위는 소스 코드에서 이름을 사용할 수 있는 영역이다.
- 저장 기간은 객체의 저장 공간이 존재하는 방식이다.
- 수명은 실행 중 객체가 실제로 존재하는 기간이다.
- 연결은 여러 선언이 같은 객체나 함수를 나타내는지를 정한다.
정적 저장 기간
파일 범위에서 정의된 객체와 static으로 선언한 지역 객체는 정적 저장 기간을 가진다.
static int global_count;
int next_identifier(void) {
static int identifier = 0;
return ++identifier;
}
global_count와 identifier는 프로그램 실행 전체에 걸쳐 존재한다. next_identifier가 반환되어도 지역 정적 객체 identifier의 수명은 끝나지 않는다.
정적 저장 기간의 객체는 프로그램 시작 전에 한 번 초기화된다. 명시적인 초기화가 없으면 타입에 맞는 0 값으로 초기화된다.
static int count;
static double ratio;
static int *pointer;
개념적으로 다음과 같은 초기값을 가진다.
count → 0
ratio → 0.0
pointer → 널 포인터
정적 저장 기간이 곧 외부 연결을 의미하는 것은 아니다.
int public_value;
static int private_value;
두 객체 모두 정적 저장 기간을 가지지만 public_value는 외부 연결을 가질 수 있고 private_value는 내부 연결을 가진다.
스레드 저장 기간
thread_local 또는 _Thread_local로 선언된 객체는 스레드 저장 기간을 가진다.
thread_local unsigned int worker_identifier;
각 스레드는 worker_identifier의 독립된 인스턴스를 가진다.
스레드 A → worker_identifier A
스레드 B → worker_identifier B
스레드 C → worker_identifier C
한 스레드가 자신의 인스턴스에 저장한 값은 다른 스레드의 인스턴스를 직접 변경하지 않는다.
thread_local int error_code;
void set_error(int code) {
error_code = code;
}
객체의 수명은 해당 스레드의 실행 기간과 연결된다. 다른 스레드의 스레드 저장 객체를 간접적으로 참조하는 동작에는 구현 규칙과 동기화 문제가 관련될 수 있다.
자동 저장 기간
일반적인 지역 변수와 함수 매개변수는 자동 저장 기간을 가진다.
int calculate(int input) {
int result = input * 2;
return result;
}
함수가 호출될 때 input과 result 객체가 존재하고, 함수 실행이 끝나면 수명이 종료된다.
블록 안의 객체는 해당 블록 실행과 연결된다.
void process(bool enabled) {
if (enabled) {
int temporary = 10;
use_value(temporary);
}
}
temporary의 수명은 해당 블록에 진입해 선언에 도달했을 때 시작되고 블록을 떠날 때 끝난다.
자동 객체의 주소를 객체 수명이 끝난 뒤 사용해서는 안 된다.
int *create_invalid_pointer(void) {
int value = 10;
return &value;
}
value는 함수가 반환할 때 수명이 끝난다. 반환된 포인터는 더 이상 살아 있는 int 객체를 가리키지 않는다.
int *pointer = create_invalid_pointer();
printf("%d\n", *pointer);
이 역참조는 정의되지 않은 동작이다.
자동 저장 기간이 반드시 실제 CPU 호출 스택에 구현된다는 보장은 없다. 컴파일러는 객체를 레지스터에 두거나 최적화로 완전히 제거할 수 있다. 재귀 호출이나 주소가 외부로 전달되는 경우에 실제 스택 저장 공간을 사용할 가능성이 높지만, 자동 저장 기간은 표준 개념이고 스택은 대표적인 구현 방식이다.
할당 저장 기간
malloc, calloc, realloc과 관련 할당 함수가 제공하는 저장 공간은 할당 저장 기간을 가진다.
int *value = malloc(sizeof(*value));
if (value == NULL) {
return -1;
}
*value = 10;
free(value);
성공한 할당으로 얻은 저장 공간의 수명은 할당 시점부터 해제 시점까지 이어진다. 할당 함수가 반환한 포인터는 요청된 크기 안에서 기본 정렬을 요구하는 객체를 만들 수 있도록 적절하게 정렬된다.[37]
malloc은 지정한 크기의 초기화되지 않은 저장 공간을 제공한다.
int *values = malloc(count * sizeof(*values));
할당된 바이트는 자동으로 0이 되지 않는다.
calloc은 요소 수와 요소 크기를 받아 저장 공간을 할당하고 모든 비트를 0으로 초기화한다.
int *values = calloc(count, sizeof(*values));
모든 비트가 0인 표현이 모든 타입에서 항상 수학적인 0이나 널 포인터를 뜻한다고 일반화해서는 안 된다. 다만 calloc은 바이트를 0으로 만드는 함수이며, 현대의 일반적인 구현에서는 정수 0과 널 포인터 초기화에 흔히 사용된다. 타입별 초기값이 반드시 필요한 경우 객체 단위로 명시적으로 초기화하는 편이 의미가 명확하다.
malloc과 객체 타입
malloc이 반환하는 저장 공간에는 선언으로 지정된 객체 타입이 없다.
void *memory = malloc(sizeof(int));
저장 공간을 int *로 사용하여 값을 저장하면 해당 접근에 필요한 int 객체의 의미가 형성된다.
int *integer = memory;
*integer = 10;
C23 표준은 선언된 타입이 없는 객체에 값을 저장하거나 memcpy로 복사하는 경우의 타입을 유효 타입 규칙으로 설명한다.[38]
일반적으로 C에서는 malloc의 반환값을 명시적으로 캐스트할 필요가 없다.
int *values = malloc(count * sizeof(*values));
다음과 같이 작성할 수도 있지만 C에서는 불필요하다.
int *values = (int *)malloc(count * sizeof(*values));
캐스트를 사용하면 <stdlib.h>를 포함하지 않아 함수 선언이 누락된 오류를 과거 컴파일 환경에서 숨길 수 있었다. C 코드에서는 대상 포인터 타입을 이용한 sizeof와 캐스트 없는 할당이 일반적으로 더 안전하다.
할당 크기 계산
동적 배열을 할당할 때는 요소 수와 요소 크기를 곱한다.
int *values = malloc(count * sizeof(*values));
그러나 곱셈이 size_t의 범위를 넘으면 실제 필요한 크기보다 작은 저장 공간이 할당될 수 있다.
if (count > SIZE_MAX / sizeof(*values)) {
return NULL;
}
int *values = malloc(count * sizeof(*values));
C23 이전부터 일부 구현과 라이브러리는 오버플로를 검사하는 별도 할당 함수를 제공하지만, 이식 가능한 기본 C 코드에서는 직접 검사해야 할 수 있다.
0바이트 할당의 결과는 구현체와 함수 규칙에 따라 특별한 포인터 값 또는 널 포인터가 될 수 있다. 반환된 포인터가 널이 아니더라도 해당 공간을 객체처럼 역참조해서는 안 된다.
realloc
realloc은 기존 할당의 크기를 변경한다.
int *new_values = realloc(
values,
new_count * sizeof(*new_values)
);
if (new_values == NULL) {
/* 기존 values는 여전히 유효하다. */
return false;
}
values = new_values;
성공한 realloc은 기존 위치를 확장할 수도 있고, 새로운 위치에 저장 공간을 만들고 내용을 복사할 수도 있다.
따라서 성공 여부를 확인하기 전에 원래 포인터를 직접 덮어쓰면 안 된다.
values = realloc(values, new_size);
실패하면 values에 널 포인터가 저장되어 기존 할당에 접근할 방법을 잃을 수 있다.
realloc이 성공하면 이전 할당의 수명은 끝난다. 같은 주소 값이 반환된 것처럼 보여도 이전 객체를 가리키던 별도 포인터를 계속 사용하면 안 된다.
int *alias = values;
int *new_values = realloc(values, new_size);
if (new_values != NULL) {
values = new_values;
/* alias는 더 이상 사용하지 않는다. */
}
해제와 해제 후 사용
동적으로 할당한 저장 공간은 free로 반환한다.
free(values);
free가 호출되면 해당 할당 객체의 수명이 끝난다.
values[0] = 10;
해제 후 포인터를 역참조하면 정의되지 않은 동작이다.
포인터 변수에 널 포인터를 대입하면 같은 변수의 우발적인 재사용을 줄일 수 있다.
free(values);
values = NULL;
그러나 다른 별칭 포인터까지 자동으로 널 포인터가 되는 것은 아니다.
int *first = values;
int *second = values;
free(first);
first = NULL;
/* second는 여전히 해제된 저장 공간의 주소를 보유한다. */
이 때문에 동적 메모리 관리에서는 어떤 코드가 할당을 소유하며 누가 해제할 책임이 있는지 명확하게 정해야 한다.
소유권
C 표준에는 소유권을 자동으로 추적하는 언어 기능이 없다. 그러나 실제 프로그램에서는 자원 수명을 관리하기 위해 소유권 규칙을 설계해야 한다.
대표적인 규칙은 다음과 같다.
소유 포인터
→ 객체를 해제할 책임이 있음
비소유 포인터
→ 객체를 참조하지만 해제하지 않음
이동
→ 소유권을 다른 객체나 함수로 넘김
복사
→ 별도 객체를 만들거나 참조 수를 증가시킴
예를 들어 다음 함수는 새 문자열을 할당하고 호출자에게 소유권을 반환한다.
char *duplicate_text(const char *source) {
size_t length = strlen(source) + 1;
char *copy = malloc(length);
if (copy == NULL) {
return NULL;
}
memcpy(copy, source, length);
return copy;
}
호출자는 반환된 포인터를 사용한 뒤 해제해야 한다.
char *text = duplicate_text("TechPedia");
if (text == NULL) {
return -1;
}
puts(text);
free(text);
반면 다음 함수의 반환 포인터는 정적 저장 객체를 가리키므로 호출자가 해제하면 안 된다.
const char *application_name(void) {
return "TechPedia";
}
API는 포인터의 소유권과 수명 규칙을 문서로 명시해야 한다.
범위와 수명의 차이
식별자의 범위가 끝났다고 해서 객체의 수명이 반드시 끝나는 것은 아니다.
int *pointer;
{
static int value = 10;
pointer = &value;
}
printf("%d\n", *pointer);
value라는 이름의 블록 범위는 끝났지만, 객체는 정적 저장 기간을 가지므로 여전히 존재한다.
반대로 이름이 아직 문법적으로 보이는 것처럼 작성되어도 객체 수명이 끝난 포인터를 사용할 수는 없다.
int *pointer;
{
int value = 10;
pointer = &value;
}
/* value의 수명은 끝났다. */
pointer 변수 자체는 살아 있지만 가리키던 객체는 존재하지 않는다.
객체의 주소
객체의 주소는 & 연산자로 얻을 수 있다.
int value = 10;
int *pointer = &value;
객체의 수명 동안 주소는 일정하게 유지되는 것으로 추상화된다. 다만 구현체는 주소를 실제 물리 주소나 단순 정수와 같은 방식으로 표현할 필요가 없다.
컴파일러가 객체의 주소가 프로그램에서 관찰되지 않는다고 판단하면 객체를 레지스터에 저장하거나 제거할 수 있다.
int square(int value) {
int result = value * value;
return result;
}
최적화된 코드에는 result를 위한 메모리 주소가 전혀 존재하지 않을 수 있다. 이는 추상 기계에서 프로그램의 관찰 가능한 결과가 같기 때문이다.
sizeof와 바이트
sizeof는 객체나 타입의 크기를 C 바이트 단위로 반환한다.
size_t size = sizeof(int);
C에서 sizeof(char)는 항상 1이다.
static_assert(sizeof(char) == 1);
그러나 C의 1바이트가 반드시 8비트인 것은 아니다. 한 바이트에 포함된 비트 수는 <limits.h>의 CHAR_BIT로 확인한다.
#include <limits.h>
static_assert(CHAR_BIT >= 8);
객체 전체의 비트 수는 개념적으로 다음과 같이 계산할 수 있다.
size_t bits = sizeof(object) * CHAR_BIT;
정확한 폭의 정수 타입이 필요하면 <stdint.h>의 타입을 사용할 수 있다.
uint32_t value;
uint32_t는 정확히 32비트인 부호 없는 정수 타입을 제공할 수 있는 구현에서만 정의된다.
객체 표현과 값 표현
객체는 하나 이상의 바이트로 표현된다. 객체의 저장 바이트 전체를 객체 표현이라고 한다.
uint32_t value = UINT32_C(0x12345678);
일반적인 8비트 바이트 환경에서 value는 4바이트 객체 표현을 가질 수 있다.
12 34 56 78
또는 다음처럼 저장될 수 있다.
78 56 34 12
어떤 순서가 사용되는지는 구현체와 대상 시스템의 바이트 순서에 따라 달라진다.
객체 표현의 모든 비트가 반드시 값 자체에 사용되는 것은 아니다. 일부 타입에는 다음이 포함될 수 있다.
- 값 비트
- 부호 비트
- 패딩 비트
- 여러 값 표현
- 값을 나타내지 않는 표현
같은 값이 둘 이상의 객체 표현을 가질 수도 있다. 따라서 모든 타입에서 바이트 단위 비교가 값의 동등성을 정확하게 나타낸다고 가정해서는 안 된다.
문자 타입을 통한 객체 표현 접근
C는 객체의 표현을 문자 타입의 배열처럼 관찰할 수 있도록 허용한다.
uint32_t value = UINT32_C(0x12345678);
const unsigned char *bytes =
(const unsigned char *)&value;
for (size_t index = 0; index < sizeof(value); ++index) {
printf("%02X\n", bytes[index]);
}
unsigned char 포인터를 사용하면 객체의 바이트 표현을 검사하거나 복사할 수 있다.
이 기능은 다음 영역에서 사용된다.
- 직렬화 도구
- 디버거
- 해시 계산
- 메모리 복사
- 바이너리 형식 분석
- 네트워크 변환
- 저수준 런타임
그러나 객체 표현을 읽을 수 있다는 사실이 해당 바이트 배열을 다른 타입으로 자유롭게 재해석해도 된다는 뜻은 아니다.
바이트 순서
다중 바이트 정수가 메모리에 배치되는 순서를 엔디언이라고 한다.
값 0x12345678을 예로 들면 대표적인 형태는 다음과 같다.
빅 엔디언
낮은 주소 → 12 34 56 78 → 높은 주소
리틀 엔디언
낮은 주소 → 78 56 34 12 → 높은 주소
C 표준은 하나의 바이트 순서를 강제하지 않는다. 따라서 정수 객체의 메모리 바이트를 그대로 파일이나 네트워크에 기록하면 플랫폼에 따라 형식이 달라질 수 있다.
fwrite(&value, sizeof(value), 1, file);
이 코드는 같은 구현 환경에서 임시 데이터를 저장할 때는 사용할 수 있지만, 이식 가능한 파일 형식을 보장하지 않는다.
고정된 바이너리 형식에서는 바이트를 명시적으로 인코딩한다.
void encode_u32_be(
unsigned char output[4],
uint32_t value
) {
output[0] = (unsigned char)(value >> 24);
output[1] = (unsigned char)(value >> 16);
output[2] = (unsigned char)(value >> 8);
output[3] = (unsigned char)value;
}
읽을 때도 명시적으로 복원한다.
uint32_t decode_u32_be(
const unsigned char input[4]
) {
return
((uint32_t)input[0] << 24) |
((uint32_t)input[1] << 16) |
((uint32_t)input[2] << 8) |
(uint32_t)input[3];
}
구조체 배치와 패딩
구조체 구성원은 선언된 순서대로 배치되지만, 각 구성원의 정렬 요구를 맞추기 위해 사이에 패딩 바이트가 들어갈 수 있다.
struct example {
char tag;
int value;
};
일반적인 구현에서는 다음과 비슷한 배치가 가능하다.
offset 0 : tag
offset 1 : padding
offset 2 : padding
offset 3 : padding
offset 4 : value
offset 8 : 구조체 끝
따라서 다음 관계가 항상 성립한다고 가정할 수 없다.
sizeof(struct example)
==
sizeof(char) + sizeof(int)
구성원의 실제 오프셋은 <stddef.h>의 offsetof로 확인할 수 있다.
#include <stddef.h>
size_t value_offset =
offsetof(struct example, value);
구조체 끝에도 배열 요소의 정렬을 유지하기 위한 꼬리 패딩이 들어갈 수 있다.
struct example values[2];
두 번째 요소가 올바르게 정렬되려면 sizeof(struct example) 자체가 필요한 정렬의 배수여야 한다.
구조체나 union에 값을 저장하면 패딩 바이트는 미지정 값을 가질 수 있다. 구조체 대입이 패딩 바이트까지 항상 동일하게 복사한다고 가정해서는 안 된다.[39]
구조체의 바이트 비교
두 구조체의 논리적 구성원이 모두 같더라도 memcmp 결과가 0이라고 보장되지 않는다.
struct point {
char tag;
int x;
};
struct point first = {
.tag = 'A',
.x = 10
};
struct point second = {
.tag = 'A',
.x = 10
};
다음 비교는 패딩 바이트 때문에 잘못된 결과를 낼 수 있다.
bool equal =
memcmp(&first, &second, sizeof(first)) == 0;
구성원별로 비교해야 한다.
bool point_equal(
const struct point *left,
const struct point *right
) {
return
left->tag == right->tag &&
left->x == right->x;
}
반대로 객체 표현 자체가 규격의 일부인 고정 바이트 배열이라면 memcmp가 적절할 수 있다.
struct identifier {
unsigned char bytes[16];
};
이 구조체가 오직 바이트 배열만 포함하고 형식이 그렇게 정의되었다면 배열을 직접 비교할 수 있다.
정렬
각 객체 타입에는 주소가 만족해야 하는 메모리 정렬 요구가 있다.
alignof(int)
alignof(double)
alignof(struct point)
정렬 요구가 4인 타입은 일반적으로 주소가 4의 배수인 위치에 놓여야 한다.
정렬 4
허용 주소 예시: 0, 4, 8, 12, 16
객체에 더 강한 정렬을 지정할 수 있다.
alignas(64)
unsigned char cache_line[64];
정렬된 동적 메모리가 필요하면 aligned_alloc을 사용할 수 있다.
size_t alignment = 64;
size_t size = 256;
void *memory = aligned_alloc(alignment, size);
aligned_alloc의 크기는 정렬 값의 정수배여야 한다.
if (size % alignment != 0) {
return NULL;
}
정렬되지 않은 주소를 특정 타입의 포인터로 변환한 뒤 역참조하면 정의되지 않은 동작이 발생할 수 있다.
unsigned char buffer[sizeof(int) + 1];
int *pointer = (int *)(buffer + 1);
*pointer = 10;
buffer + 1이 int에 필요한 정렬을 만족한다는 보장이 없다.
외부 바이트에서 값을 읽으려면 memcpy를 사용할 수 있다.
int value;
memcpy(&value, buffer + 1, sizeof(value));
다만 바이트가 해당 타입의 유효한 객체 표현을 구성하는지도 별도로 보장해야 한다.
union의 저장 공간
union의 모든 구성원은 같은 저장 공간을 공유한다.
union number {
uint32_t integer;
float floating;
};
union의 크기는 모든 구성원을 저장할 수 있을 만큼 커야 하며, 가장 엄격한 구성원의 정렬도 만족해야 한다.
union number value;
value.integer = UINT32_C(0x3F800000);
이후 다른 구성원인 floating을 읽는 방식은 C에서 일정한 타입 재해석 관행과 표준 규칙이 관련되지만, 결과의 이식성과 표현은 구현에 영향을 받는다.
float result = value.floating;
IEEE 754와 특정 바이트 순서를 가정하는 코드는 다른 환경에서 같은 결과를 보장하지 못한다.
바이트 복사를 사용해 비트 패턴을 옮기는 방식이 의도를 더 명확하게 표현할 수 있다.
float bits_to_float(uint32_t bits) {
float result;
static_assert(
sizeof(result) == sizeof(bits)
);
memcpy(&result, &bits, sizeof(result));
return result;
}
그러나 복사된 비트 패턴이 대상 부동소수점 타입에서 유효한 표현인지와 값이 무엇인지는 여전히 해당 구현의 부동소수점 형식에 의존한다.
유효 타입
선언으로 만들어진 객체는 선언된 타입을 가진다.
int value;
value의 유효 타입은 int다.
동적으로 할당한 저장 공간처럼 선언된 타입이 없는 객체는 저장과 복사 방식에 따라 유효 타입이 결정될 수 있다.
void *memory = malloc(sizeof(int));
int *integer = memory;
*integer = 10;
int 타입의 lvalue를 통해 값을 저장했으므로 이후 해당 저장값을 int로 접근할 수 있다.
다른 타입을 통해 같은 바이트를 임의로 읽는 것은 허용되지 않을 수 있다.
float *floating = memory;
float result = *floating;
앞서 int로 저장한 객체를 호환되지 않는 float lvalue로 접근하면 유효 타입과 별칭 규칙을 위반할 수 있다.
엄격한 별칭 규칙
C 컴파일러는 일반적으로 서로 호환되지 않는 타입의 포인터가 같은 객체를 가리키지 않는다고 추론할 수 있다. 이를 흔히 엄격한 별칭 규칙이라고 부른다.
int update(int *integer, float *floating) {
*integer = 10;
*floating = 1.0F;
return *integer;
}
컴파일러는 integer와 floating이 같은 객체를 가리키지 않는다고 가정할 수 있다. 하나의 저장 공간을 두 포인터 타입으로 억지로 가리키게 하면 프로그램이 타입 접근 규칙을 위반할 수 있다.
일반적으로 객체의 저장값은 다음과 같은 타입의 lvalue를 통해 접근할 수 있다.
- 객체의 유효 타입과 호환되는 타입
- 대응하는 부호 있는 또는 부호 없는 타입
- 호환 타입의 한정된 버전
- 위 타입을 구성원으로 포함하는 일부 구조체나
union - 문자 타입
문자 타입 접근은 객체 표현을 읽기 위한 특별한 예외다.
const unsigned char *bytes =
(const unsigned char *)&value;
이 예외가 unsigned char 배열을 임의의 다른 타입 객체로 곧바로 역참조할 수 있다는 의미는 아니다.
memcpy와 타입 보존
memcpy는 객체 표현을 바이트 단위로 복사한다.
struct point source = {
.x = 10,
.y = 20
};
struct point destination;
memcpy(
&destination,
&source,
sizeof(destination)
);
같은 구조체 타입의 객체 사이에서 복사하면 destination은 source와 같은 값을 표현할 수 있다.
동적으로 할당한 선언되지 않은 저장 공간으로 객체 표현을 복사하면 원본 객체의 유효 타입이 이후 접근에 영향을 줄 수 있다.
struct point source = {
.x = 10,
.y = 20
};
void *memory = malloc(sizeof(source));
if (memory == NULL) {
return NULL;
}
memcpy(memory, &source, sizeof(source));
struct point *copy = memory;
C 표준은 선언된 타입이 없는 객체에 memcpy나 memmove로 값을 복사한 경우 원본 객체의 유효 타입이 이후 접근에 사용된다고 규정한다.[40]
memcpy와 겹치는 영역
memcpy의 원본과 대상 영역이 겹치면 정의되지 않은 동작이 발생한다.
char text[] = "abcdef";
memcpy(text + 1, text, 5);
겹칠 가능성이 있다면 memmove를 사용한다.
memmove(text + 1, text, 5);
memmove는 개념적으로 원본을 임시 배열에 복사한 뒤 대상으로 옮긴 것처럼 동작한다.
포인터
포인터는 객체나 함수를 참조하는 값을 표현한다.
int value = 10;
int *pointer = &value;
포인터 값은 단순한 정수 주소와 동일하다고 보장되지 않는다. 구현체는 포인터에 다음과 같은 추가 정보를 포함할 수 있다.
- 주소 공간 정보
- 권한
- 범위
- 세그먼트
- 객체 기원
- 하드웨어 태그
- 함수와 객체 포인터 구분
따라서 포인터를 충분히 큰 정수에 넣었다가 다시 변환하면 항상 원래 포인터의 모든 의미가 보존된다고 단정할 수 없다. <stdint.h>의 uintptr_t와 intptr_t도 구현체가 적합한 정수 타입을 제공할 수 있을 때만 정의된다.
uintptr_t representation =
(uintptr_t)pointer;
int *restored =
(int *)representation;
이러한 변환의 세부 의미는 구현체와 표준 규칙을 확인해야 한다.
널 포인터
널 포인터는 어떤 유효한 객체나 함수도 가리키지 않는 특별한 포인터 값이다.
int *pointer = NULL;
C23에서는 nullptr을 사용할 수 있다.
int *pointer = nullptr;
널 포인터의 모든 비트가 0이라고 표준이 일반적으로 요구하는 것은 아니다.
int *pointer;
memset(&pointer, 0, sizeof(pointer));
이 코드가 모든 C 구현에서 올바른 널 포인터를 만든다고 일반화해서는 안 된다. 언어 수준의 널 포인터 상수를 사용해야 한다.
int *pointer = NULL;
정적 저장 기간 객체의 포인터는 언어 규칙에 따라 널 포인터로 초기화된다.
static int *pointer;
널 포인터 역참조는 정의되지 않은 동작이다.
*pointer = 10;
포인터 산술
포인터 산술은 같은 배열 객체를 기준으로 정의된다.
int values[4] = {
10,
20,
30,
40
};
int *pointer = values;
다음 연산은 두 번째 요소를 가리킨다.
pointer += 1;
포인터에 정수 n을 더하면 n바이트가 아니라 n개의 요소만큼 이동한다.
int * + 1
→ sizeof(int)만큼 주소 이동
double * + 1
→ sizeof(double)만큼 주소 이동
배열 끝 바로 다음 위치를 가리키는 포인터는 계산과 비교에 사용할 수 있다.
int *end = values + 4;
하지만 역참조할 수는 없다.
int value = *end;
이는 배열 범위를 벗어난 접근이다.
배열의 시작보다 앞이나 끝 바로 다음보다 뒤를 가리키는 포인터 계산은 허용되지 않는다.
int *invalid = values + 5;
서로 다른 배열 객체의 포인터를 빼거나 순서 비교하는 동작도 일반적으로 정의되지 않는다.
int first[4];
int second[4];
ptrdiff_t distance = &first[0] - &second[0];
같은 배열 안의 포인터 차이는 요소 수로 계산된다.
ptrdiff_t distance =
&values[3] - &values[0];
결과는 3이다.
배열 객체와 한 개의 객체
포인터 산술 규칙에서는 배열이 아닌 단일 객체도 길이 1인 배열처럼 취급되는 경우가 있다.
int value = 10;
int *pointer = &value;
int *end = pointer + 1;
end는 value 바로 다음 위치를 나타내는 계산용 포인터가 될 수 있지만 역참조할 수 없다.
포인터 비교
같은 객체를 가리키는 포인터는 ==로 같음을 비교할 수 있다.
if (left == right) {
/* 같은 주소를 나타냄 */
}
널 포인터와도 비교할 수 있다.
if (pointer == NULL) {
return -1;
}
관계 연산자 <, >, <=, >=를 사용한 포인터 순서 비교는 같은 배열 객체의 요소 사이에서 의미가 정의된다.
if (current < end) {
/* 같은 배열 범위에서 순서 비교 */
}
서로 무관한 객체의 포인터 순서를 이식 가능한 주소 정렬 기준으로 사용할 수는 없다.
포인터 provenance
현대 컴파일러는 포인터를 단순한 숫자 주소뿐 아니라 어떤 객체에서 유래했는지에 관한 정보와 함께 추론한다. 이러한 개념을 포인터 provenance라고 부른다.
int first;
int second;
int *pointer = &first;
pointer는 단순히 특정 숫자 주소를 가진 값이 아니라 first 객체에 대한 접근 경로에서 만들어진 포인터로 해석될 수 있다.
정수 계산으로 우연히 second와 같은 숫자 주소를 만든다고 해서 그 값이 자동으로 second에 접근할 수 있는 유효한 포인터가 된다고 단정할 수 없다.
포인터 provenance와 정수 변환, 할당 해제 후 같은 주소의 재사용, 장치 메모리 접근은 오랫동안 WG14에서 해석과 개선이 논의된 분야다. C의 객체 모델이 실제 바이트 표현을 노출하면서도 컴파일러 최적화는 객체의 기원과 수명 정보를 사용하기 때문에 단순한 “주소 숫자” 모델만으로 모든 프로그램 동작을 설명하기 어렵다.[41]
이식 가능한 코드에서는 다음 원칙을 지키는 편이 안전하다.
- 유효한 객체나 할당에서 얻은 포인터를 사용한다.
- 포인터를 임의의 정수 연산으로 조작하지 않는다.
- 객체 수명이 끝난 포인터를 사용하지 않는다.
- 같은 배열 범위를 벗어나는 산술을 하지 않는다.
- 구현체가 문서화한 장치 메모리 인터페이스를 따른다.
- 포인터와 정수 변환이 필요하면 플랫폼 ABI를 확인한다.
함수 포인터와 객체 포인터
함수 포인터와 객체 포인터는 서로 다른 종류의 포인터다.
int *object_pointer;
int (*function_pointer)(void);
void *는 범용 객체 포인터로 사용할 수 있지만, 함수 포인터와 void * 사이의 변환을 ISO C가 일반적으로 보장하지 않는다.
void *memory;
int (*function)(void);
POSIX와 특정 운영체제 API는 동적 심벌 조회를 위해 별도 규칙을 제공할 수 있지만, 이는 ISO C 표준만의 보장이 아니라 플랫폼 규약의 일부다.
포인터 수명과 불확정 표현
객체의 수명이 끝나면 그 객체를 가리키던 포인터를 역참조할 수 없는 것뿐 아니라, C23의 규정에서는 해당 포인터 값의 평가 자체가 문제가 될 수 있다.
int *pointer;
{
int value = 10;
pointer = &value;
}
/* value의 수명이 끝남 */
이후 다음과 같은 코드도 안전한 포인터 사용으로 보장되지 않는다.
if (pointer != NULL) {
/* ... */
}
포인터가 널이 아닌지 비교할 뿐이라고 생각할 수 있지만, 가리키던 객체의 수명이 끝난 뒤 포인터 표현이 불확정해질 수 있기 때문이다. 객체 수명 종료 전에 포인터를 더 이상 사용하지 않도록 구조를 설계해야 한다.
동적 할당에서도 마찬가지다.
free(pointer);
pointer = NULL;
free 뒤에 바로 같은 포인터 객체에 널 포인터를 저장하는 관행은 일반적으로 사용된다. 그러나 해제된 객체를 가리키는 값의 별칭을 읽거나 비교하는 방식으로 프로그램 논리를 구성해서는 안 된다.
불확정 값
명시적으로 초기화되지 않은 자동 객체는 불확정 값을 가질 수 있다.
void function(void) {
int value;
printf("%d\n", value);
}
초기화되지 않은 값을 읽는 것은 타입과 상황에 따라 정의되지 않은 동작을 일으킬 수 있다.
포인터는 반드시 초기화한 뒤 사용한다.
int *pointer = NULL;
구조체 전체를 초기화하면 구성원 누락을 줄일 수 있다.
struct state {
int count;
bool ready;
void *context;
};
struct state state = { 0 };
C23에서는 빈 초기화 목록도 사용할 수 있다.
struct state state = {};
값이 아닌 표현
일부 타입은 모든 가능한 비트 조합이 유효한 값을 나타내지 않을 수 있다. 값을 나타내지 않는 객체 표현을 non-value representation이라고 한다.
이러한 표현을 문자 타입이 아닌 해당 타입의 lvalue로 읽으면 정의되지 않은 동작이 발생할 수 있다.
현대의 일반적인 2의 보수 정수에서는 모든 비트 조합이 정수 값을 나타내는 경우가 많지만, 다른 타입과 특수 하드웨어에서는 값이 아닌 표현이 존재할 수 있다.
따라서 임의의 바이트를 특정 타입 객체로 복사한 뒤 곧바로 읽는 방식은 이식 가능하지 않을 수 있다.
unsigned char bytes[sizeof(double)] = {
/* 외부 데이터 */
};
double value;
memcpy(&value, bytes, sizeof(value));
bytes가 구현체의 유효한 double 객체 표현을 구성할 때만 value를 안전하게 사용할 수 있다.
메모리 초기화와 memset
memset은 바이트 단위로 같은 값을 저장한다.
memset(buffer, 0, size);
문자 배열과 원시 버퍼를 0으로 채우는 데 적합하다.
unsigned char buffer[1024];
memset(buffer, 0, sizeof(buffer));
구조체 전체를 0바이트로 채우는 관행도 흔하다.
struct state state;
memset(&state, 0, sizeof(state));
그러나 모든 타입에서 모든 비트 0이 언어 수준의 0 값이나 널 포인터와 같은 표현이라고 일반화해서는 안 된다. 일반 객체는 초기화 문법을 사용하는 편이 명확하다.
struct state state = { 0 };
동적으로 할당한 구조체도 임시 초기값을 대입할 수 있다.
struct state *state = malloc(sizeof(*state));
if (state == NULL) {
return NULL;
}
*state = (struct state) {
.count = 0,
.ready = false,
.context = NULL
};
유연한 배열 구성원
구조체의 마지막 구성원에는 크기가 생략된 배열을 둘 수 있다.
struct packet {
size_t size;
unsigned char data[];
};
이 배열을 유연한 배열 구성원이라고 한다.
구조체와 데이터 영역을 한 번에 할당할 수 있다.
struct packet *packet_create(size_t size) {
if (
size >
SIZE_MAX - sizeof(struct packet)
) {
return NULL;
}
struct packet *packet = malloc(
sizeof(*packet) + size
);
if (packet == NULL) {
return NULL;
}
packet->size = size;
return packet;
}
데이터는 구조체 바로 뒤의 할당 영역을 사용한다.
packet->data[0] = 0x01;
할당한 추가 영역보다 큰 첨자로 접근하면 범위를 벗어난다.
유연한 배열 구성원은 반드시 구조체의 마지막 구성원이어야 하며, 구조체에는 그 외의 이름 있는 구성원이 있어야 한다.
가변 길이 배열
C99의 가변 길이 배열은 실행 시점에 크기가 결정되는 자동 배열이다.
void process(size_t count) {
int values[count];
/* ... */
}
가변 길이 배열의 저장 공간은 해당 블록 실행과 연결된다. 매우 큰 크기를 사용하면 구현 환경의 자동 저장 공간을 소진할 수 있다.
void unsafe(size_t count) {
unsigned char buffer[count];
}
외부 입력을 검증하지 않고 가변 길이 배열 크기로 사용하면 스택 고갈이나 서비스 거부 문제가 발생할 수 있다.
큰 배열이나 실패를 처리해야 하는 저장 공간에는 동적 할당이 더 적절할 수 있다.
unsigned char *buffer = malloc(count);
if (buffer == NULL) {
return -1;
}
가변 길이 배열은 표준 판과 구현체에 따라 선택적으로 지원될 수 있으므로 이식성이 필요한 프로젝트에서는 지원 여부를 확인해야 한다.
스택과 힙
C 표준은 스택과 힙이라는 메모리 영역을 직접 정의하지 않는다. 하지만 일반적인 구현에서는 다음 대응이 자주 사용된다.
자동 저장 기간 객체
→ 호출 스택 또는 레지스터
정적 저장 기간 객체
→ 실행 파일의 데이터·BSS 영역
할당 저장 기간 객체
→ 힙 또는 사용자 지정 할당자 영역
문자열 리터럴
→ 읽기 전용 데이터 영역일 수 있음
함수 코드
→ 실행 가능한 코드 영역
자동 객체가 반드시 스택에 있고 동적 객체가 반드시 하나의 전역 힙에 있어야 하는 것은 아니다.
임베디드 구현에서는 정적 메모리 풀을 사용할 수 있고, 게임 엔진은 프레임 할당자나 아레나를 사용할 수 있으며, 운영체제 커널은 자체 페이지·슬랩 할당자를 사용할 수 있다.
struct arena {
unsigned char *memory;
size_t capacity;
size_t offset;
};
void *arena_allocate(
struct arena *arena,
size_t size,
size_t alignment
) {
uintptr_t current =
(uintptr_t)(arena->memory + arena->offset);
uintptr_t aligned =
(current + alignment - 1) &
~(uintptr_t)(alignment - 1);
size_t next =
(size_t)(aligned - (uintptr_t)arena->memory);
if (
next > arena->capacity ||
size > arena->capacity - next
) {
return NULL;
}
arena->offset = next + size;
return arena->memory + next;
}
이러한 사용자 지정 할당자는 플랫폼의 포인터 표현과 정렬 요구, 오버플로를 정확히 처리해야 한다. 위 예시는 정렬이 2의 거듭제곱이고 포인터 정수 변환이 지원되는 환경을 가정하므로 완전한 범용 ISO C 구현은 아니다.
메모리 매핑 입출력
임베디드 시스템과 장치 드라이버에서는 특정 주소가 하드웨어 레지스터에 대응할 수 있다.
#define STATUS_REGISTER_ADDRESS \
UINTPTR_C(0x40000000)
volatile uint32_t *status_register =
(volatile uint32_t *)
STATUS_REGISTER_ADDRESS;
uint32_t status = *status_register;
이 코드는 ISO C만으로 의미가 완성되지 않는다. 다음 조건을 플랫폼이 보장해야 한다.
- 정수 주소에서 포인터로의 변환
- 해당 주소의 유효성
- 레지스터의 폭과 정렬
- 접근 횟수와 순서
- 바이트 순서
- 캐시 정책
- 버스와 장치의 부작용
volatile은 접근이 제거되거나 임의로 합쳐지는 것을 제한하는 데 사용되지만, CPU 메모리 장벽이나 스레드 간 원자성을 자동으로 제공하지 않는다.
volatile과 메모리 모델
volatile 객체는 구현체가 알 수 없는 방식으로 값이 변경되거나 접근 자체가 외부 부작용을 일으킬 수 있음을 나타낸다.
volatile sig_atomic_t signal_received;
void signal_handler(int signal) {
signal_received = signal;
}
또는 메모리 매핑 레지스터를 나타낼 수 있다.
volatile uint32_t *device_status;
그러나 다음과 같은 공유 카운터를 volatile로 선언하는 것만으로 스레드 안전해지지 않는다.
volatile int counter = 0;
두 스레드가 동시에 다음 연산을 수행하면 데이터 경쟁이 발생할 수 있다.
++counter;
증가 연산은 개념적으로 읽기, 계산, 쓰기의 여러 단계로 이루어질 수 있고 원자성을 보장하지 않는다.
스레드 동기화에는 원자적 타입이나 뮤텍스를 사용해야 한다.
C의 병렬 실행 모델
C11부터 표준은 여러 실행 스레드의 메모리 접근과 원자적 연산을 정의한다.
중요한 개념은 다음과 같다.
- 평가
- 값 계산
- 부작용
- sequenced before
- synchronizes with
- happens before
- 원자적 연산
- modification order
- 데이터 경쟁
한 스레드 안의 평가 사이에는 sequenced before 관계가 형성될 수 있다. 스레드 사이의 동기화 연산은 synchronizes with 관계를 만들 수 있고, 이를 통해 happens before 관계가 구성된다.
스레드 A의 쓰기
↓ sequenced before
release 저장
↓ synchronizes with
acquire 읽기
↓ sequenced before
스레드 B의 읽기
이 관계가 형성되면 스레드 A에서 동기화 전에 수행한 쓰기를 스레드 B가 올바르게 관찰할 수 있다.
데이터 경쟁
두 스레드가 같은 메모리 위치에 충돌하는 접근을 수행하고, 그중 하나 이상이 쓰기이며, 적절한 happens-before 관계가 없고, 관련 접근이 원자적이지 않으면 데이터 경쟁이 발생한다.
static int counter = 0;
int worker(void *context) {
++counter;
return 0;
}
여러 스레드가 동시에 worker를 실행하면 counter 접근에 데이터 경쟁이 발생할 수 있다.
C에서는 데이터 경쟁이 정의되지 않은 동작이다.[42]
결과가 단순히 증가 횟수를 일부 잃는 정도로 제한된다고 생각해서는 안 된다. 컴파일러는 데이터 경쟁이 없는 올바른 프로그램을 전제로 최적화할 수 있다.
원자적 타입
원자적 객체는 분리되지 않는 읽기와 쓰기를 제공한다.
#include <stdatomic.h>
static atomic_int counter = 0;
여러 스레드가 원자적으로 값을 증가시킬 수 있다.
void increment(void) {
atomic_fetch_add(&counter, 1);
}
_Atomic 지정자를 직접 사용할 수도 있다.
static _Atomic int counter = 0;
일반적인 대입과 읽기도 원자적 객체에서는 기본적으로 순차 일관성 의미를 가진다.
counter = 10;
int value = counter;
다만 명시적인 원자 함수는 의도와 메모리 순서를 더 분명하게 표현한다.
atomic_store(&counter, 10);
int value = atomic_load(&counter);
원자적 타입의 크기와 표현, 정렬은 대응하는 비원자 타입과 다를 수 있다.
static_assert(
sizeof(_Atomic int) == sizeof(int)
);
이 관계는 모든 구현에서 보장되지 않으므로 이러한 검증에 의존해서는 안 된다.
원자적 연산
대표적인 원자적 연산은 다음과 같다.
atomic_load(&object);
atomic_store(&object, value);
atomic_exchange(&object, value);
atomic_fetch_add(&object, value);
atomic_fetch_sub(&object, value);
atomic_fetch_or(&object, value);
atomic_fetch_and(&object, value);
atomic_compare_exchange_strong(
&object,
&expected,
desired
);
원자적인 compare-and-exchange는 현재 값이 예상값과 같을 때 새 값을 저장한다.
bool set_if_zero(atomic_int *value) {
int expected = 0;
return atomic_compare_exchange_strong(
value,
&expected,
1
);
}
비교가 실패하면 expected에 실제 값이 기록될 수 있다.
원자적 연산만 사용한다고 복잡한 자료 구조 전체가 자동으로 안전해지는 것은 아니다. 여러 원자 객체 사이의 불변 조건과 객체 수명, 메모리 회수 문제도 함께 해결해야 한다.
메모리 순서
원자적 연산에는 다음과 같은 메모리 순서를 지정할 수 있다.
memory_order_relaxedmemory_order_consumememory_order_acquirememory_order_releasememory_order_acq_relmemory_order_seq_cst
가장 단순한 기본값은 순차 일관성이다.
atomic_store(&ready, true);
명시적으로 작성하면 다음과 같다.
atomic_store_explicit(
&ready,
true,
memory_order_seq_cst
);
memory_order_relaxed는 해당 객체의 원자성은 보장하지만 다른 메모리 접근에 대한 동기화 순서를 만들지 않는다.
atomic_fetch_add_explicit(
&counter,
1,
memory_order_relaxed
);
독립적인 통계 카운터처럼 값의 원자성만 필요할 때 사용할 수 있다.
release와 acquire는 다른 객체의 쓰기와 읽기를 전달하는 데 사용된다.
struct message {
int value;
};
static struct message shared_message;
static atomic_bool ready = false;
작성 스레드:
void publish_message(void) {
shared_message.value = 42;
atomic_store_explicit(
&ready,
true,
memory_order_release
);
}
읽기 스레드:
bool consume_message(int *result) {
if (!atomic_load_explicit(
&ready,
memory_order_acquire
)) {
return false;
}
*result = shared_message.value;
return true;
}
acquire 읽기가 release 저장의 값을 관찰하면 shared_message.value에 대한 앞선 쓰기가 읽기 스레드에서 올바르게 보이도록 happens-before 관계가 형성된다.
메모리 순서를 잘못 선택하면 코드가 특정 프로세서에서는 동작하지만 다른 아키텍처나 최적화 수준에서 실패할 수 있다. 필요한 이유를 증명할 수 없다면 기본 순차 일관성이나 뮤텍스를 사용하는 편이 안전하다.
원자성 여부
모든 원자적 타입이 하드웨어 명령 하나로 lock-free하게 구현되는 것은 아니다.
atomic_int value;
bool lock_free = atomic_is_lock_free(&value);
구현체는 내부 잠금이나 런타임 함수를 사용해 원자성을 제공할 수 있다.
일부 기본 원자 타입에는 lock-free 여부를 나타내는 매크로가 있다.
ATOMIC_INT_LOCK_FREE
ATOMIC_POINTER_LOCK_FREE
값의 의미는 항상 lock-free, 때에 따라 lock-free, lock-free가 아님 등의 구현 상태를 나타낸다.
신호 처리기나 매우 제한된 실시간 문맥에서는 단순히 타입이 원자적이라는 이유만으로 안전하다고 가정할 수 없으며, lock-free 보장과 함수의 신호 안전성을 별도로 확인해야 한다.
뮤텍스와 조건 변수
복합 상태를 보호할 때는 <threads.h>의 뮤텍스를 사용할 수 있다.
#include <threads.h>
struct counter {
mtx_t mutex;
int value;
};
bool counter_increment(struct counter *counter) {
if (mtx_lock(&counter->mutex) != thrd_success) {
return false;
}
++counter->value;
mtx_unlock(&counter->mutex);
return true;
}
같은 뮤텍스의 잠금과 해제는 스레드 사이의 동기화 관계를 형성한다. 보호되는 비원자 객체에는 뮤텍스를 보유한 상태에서만 접근해야 한다.
조건 변수는 특정 상태가 될 때까지 대기하는 데 사용한다.
struct queue {
mtx_t mutex;
cnd_t changed;
size_t count;
};
조건 변수 대기는 깨어난 뒤 조건을 다시 검사하는 반복문 형태로 사용한다.
mtx_lock(&queue->mutex);
while (queue->count == 0) {
cnd_wait(
&queue->changed,
&queue->mutex
);
}
/* 큐 요소 사용 */
mtx_unlock(&queue->mutex);
가짜 깨움이나 다른 스레드가 먼저 상태를 변경하는 상황을 처리하기 위해 if가 아니라 while로 검사한다.
평가 순서와 메모리
한 스레드 안에서도 표현식의 평가 순서를 잘못 가정하면 문제가 생긴다.
int result = function(
first(),
second()
);
first와 second 중 어느 함수가 먼저 실행되는지는 일반적으로 보장되지 않는다.
같은 객체를 순서 관계 없이 여러 번 변경하면 정의되지 않은 동작이 발생할 수 있다.
int value = 0;
value = value++;
또는 다음과 같은 코드도 문제가 된다.
values[index++] = index;
의도한 순서를 별도 문장으로 표현해야 한다.
values[index] = index;
++index;
컴파일러가 문장을 위에서 아래로 무조건 기계 명령으로 변환한다고 생각해서는 안 된다. 표준이 보장하는 관찰 가능한 결과와 순서 관계를 지키는 범위에서 명령을 재배치할 수 있다.
컴파일러 최적화와 메모리 접근
다음 반복문을 생각할 수 있다.
while (!ready) {
}
ready가 일반 비원자 객체이고 다른 스레드가 변경한다고 기대하면 프로그램은 데이터 경쟁을 포함한다.
컴파일러는 현재 스레드에서 ready를 변경하는 코드가 없다고 판단하고 값을 한 번만 읽거나 반복문을 무한 반복으로 최적화할 수 있다.
volatile을 붙이는 것만으로 스레드 동기화가 되지는 않는다.
volatile bool ready;
스레드 간 플래그에는 원자적 타입을 사용한다.
atomic_bool ready = false;
while (!atomic_load(&ready)) {
}
실제 프로그램에서는 바쁜 대기 대신 조건 변수나 플랫폼 대기 기능을 사용하는 편이 자원 효율적일 수 있다.
정의되지 않은 동작과 메모리
C의 메모리 관련 정의되지 않은 동작에는 다음이 포함된다.
- 널 포인터 역참조
- 배열 범위를 벗어난 접근
- 객체 수명이 끝난 뒤의 접근
- 해제 후 사용
- 이중 해제
- 잘못된 정렬의 포인터 역참조
- 호환되지 않는 타입을 통한 접근
- 초기화되지 않은 값 읽기
- 겹치는 영역에
memcpy사용 - 유효하지 않은 포인터 산술
- 부호 있는 정수 오버플로로 크기 계산
- 데이터 경쟁
restrict계약 위반- 잘못된 가변 인수 타입
- 읽기 전용 문자열 리터럴 수정
정의되지 않은 동작이 발생하면 C 표준은 결과를 제한하지 않는다.
int values[4];
values[4] = 10;
프로그램이 즉시 종료될 수도 있지만, 다른 객체를 덮어쓰거나 겉으로 정상 동작하는 것처럼 보일 수도 있다. 최적화된 빌드에서 관련 조건과 코드가 제거될 수도 있다.
버퍼 오버플로
C 배열에는 기본 범위 검사가 없다.
void copy_text(
char *destination,
const char *source
) {
while ((*destination++ = *source++) != '\0') {
}
}
대상 버퍼가 충분히 크지 않으면 범위를 벗어나 쓰게 된다.
크기를 명시적으로 전달해야 한다.
bool copy_text(
char *destination,
size_t capacity,
const char *source
) {
size_t length = strlen(source);
if (length >= capacity) {
return false;
}
memcpy(
destination,
source,
length + 1
);
return true;
}
길이와 종료 문자를 모두 고려해야 한다.
필요 크기
= 문자열 문자 수 + 널 종료 문자 1개
크기 매개변수의 단위도 문서화해야 한다.
바이트 수인지
요소 수인지
널 문자를 포함하는지
출력 가능한 최대 길이인지
정수 오버플로와 메모리 안전
메모리 크기를 계산할 때 정수 오버플로가 발생하면 작은 버퍼를 할당한 뒤 큰 데이터를 쓰게 될 수 있다.
size_t size = count * element_size;
void *memory = malloc(size);
곱셈 전에 검사해야 한다.
if (
element_size != 0 &&
count > SIZE_MAX / element_size
) {
return NULL;
}
size_t size = count * element_size;
덧셈도 검사한다.
if (
payload_size >
SIZE_MAX - sizeof(struct packet)
) {
return NULL;
}
외부 파일이나 네트워크 패킷에서 받은 길이는 신뢰하지 않고 검증해야 한다.
이중 해제
같은 할당을 두 번 해제하면 정의되지 않은 동작이다.
free(pointer);
free(pointer);
여러 코드 경로에서 해제 책임이 겹치지 않도록 소유권을 하나로 정한다.
void destroy_resource(
struct resource **resource_pointer
) {
if (
resource_pointer == NULL ||
*resource_pointer == NULL
) {
return;
}
struct resource *resource =
*resource_pointer;
free(resource->data);
free(resource);
*resource_pointer = NULL;
}
이 방식도 동일한 객체를 가리키는 다른 별칭까지 처리하지는 못한다. 핵심 해결책은 소유권과 수명을 구조적으로 관리하는 것이다.
메모리 누수
할당한 저장 공간에 접근할 포인터를 잃고 해제하지 않으면 메모리 누수가 발생한다.
void leak(void) {
void *memory = malloc(1024);
if (memory == NULL) {
return;
}
/* free 없이 함수 종료 */
}
반복적으로 호출되면 프로세스의 메모리 사용량이 증가한다.
오류 경로에서도 모든 자원을 정리해야 한다.
int load_resource(const char *path) {
int result = -1;
FILE *file = NULL;
void *buffer = NULL;
file = fopen(path, "rb");
if (file == NULL) {
goto cleanup;
}
buffer = malloc(4096);
if (buffer == NULL) {
goto cleanup;
}
result = 0;
cleanup:
free(buffer);
if (file != NULL) {
fclose(file);
}
return result;
}
메모리 단편화
동적 할당과 해제를 반복하면 사용할 수 있는 총 공간이 충분해도 큰 연속 블록을 얻기 어려워질 수 있다. 이를 메모리 단편화라고 한다.
- 외부 단편화는 빈 공간이 여러 작은 영역으로 나뉘는 현상이다.
- 내부 단편화는 정렬과 할당 단위 때문에 요청보다 많은 공간을 사용하는 현상이다.
ISO C는 할당자의 내부 전략을 규정하지 않는다. 장시간 실행되는 서버와 게임 엔진, 임베디드 시스템은 사용 패턴에 따라 다음 기법을 사용할 수 있다.
- 메모리 풀
- 아레나 할당자
- 슬랩 할당자
- 고정 크기 블록
- 프레임 단위 일괄 해제
- 세대별 저장 공간
- 객체별 전용 할당자
아레나 할당
아레나는 큰 메모리 블록에서 여러 객체를 순차적으로 할당하고 전체를 한 번에 해제하는 방식이다.
struct arena {
unsigned char *data;
size_t capacity;
size_t used;
};
단순한 바이트 할당은 다음과 같이 구현할 수 있다.
void *arena_allocate_bytes(
struct arena *arena,
size_t size
) {
if (size > arena->capacity - arena->used) {
return NULL;
}
void *result =
arena->data + arena->used;
arena->used += size;
return result;
}
실제 객체를 위한 할당자는 정렬을 반드시 처리해야 한다.
아레나의 장점은 다음과 같다.
- 할당 비용이 작다.
- 여러 객체를 한꺼번에 해제할 수 있다.
- 단편화를 줄일 수 있다.
- 수명 관계가 명확한 데이터에 적합하다.
한계도 있다.
- 개별 객체만 해제하기 어렵다.
- 아레나보다 오래 살아야 하는 포인터를 만들면 안 된다.
- 정렬과 크기 오버플로를 직접 처리해야 한다.
- 전체 최대 용량을 계획해야 한다.
메모리 풀
고정 크기 객체가 반복적으로 생성되고 제거된다면 메모리 풀을 사용할 수 있다.
struct node {
struct node *next;
int value;
};
struct node_pool {
struct node *free_list;
};
해제된 노드를 자유 목록에 넣고 다시 사용할 수 있다.
void node_release(
struct node_pool *pool,
struct node *node
) {
node->next = pool->free_list;
pool->free_list = node;
}
이 방식은 할당자를 직접 호출하는 횟수를 줄이고 고정 크기 객체의 단편화를 제한할 수 있다.
그러나 해제된 노드를 외부 포인터가 계속 가리키면 새로운 객체로 재사용된 뒤 심각한 수명 오류가 발생한다.
객체 수명과 재사용
동일한 주소에 새로운 객체가 만들어져도 이전 객체와 같은 객체라고 볼 수는 없다.
int *first = malloc(sizeof(*first));
if (first == NULL) {
return;
}
*first = 10;
free(first);
int *second = malloc(sizeof(*second));
우연히 second가 같은 숫자 주소를 받더라도 이전 first 객체의 수명은 이미 끝났다. first를 통해 새 객체에 접근해서는 안 된다.
if (first == second) {
/* 숫자 표현이 같아 보여도 first를 사용하지 않는다. */
}
주소 재사용과 객체 동일성은 다른 개념이다.
문자열과 메모리
C 문자열은 널 문자로 끝나는 문자 배열이다.
char text[] = "hello";
메모리에는 다음처럼 저장된다.
'h' 'e' 'l' 'l' 'o' '\0'
문자열 함수는 널 문자를 찾을 때까지 메모리를 읽는다.
size_t length = strlen(text);
널 문자가 없는 배열을 문자열 함수에 전달하면 배열 범위를 넘어 읽을 수 있다.
char bytes[4] = {
't',
'e',
's',
't'
};
strlen(bytes);
bytes는 C 문자열이 아니다.
버퍼 크기와 문자열 길이를 구분해야 한다.
char text[16] = "hello";
size_t capacity = sizeof(text);
size_t length = strlen(text);
capacity → 16바이트
length → 5문자
문자열 리터럴의 수명
문자열 리터럴은 정적 저장 기간의 배열 객체를 만든다.
const char *message = "hello";
message 포인터가 지역 변수여도 문자열 리터럴 배열은 프로그램 실행 동안 존재한다.
const char *get_message(void) {
return "hello";
}
이 반환 포인터는 자동 지역 배열의 주소를 반환하는 것과 다르다.
문자열 리터럴의 내용을 수정하려 하면 정의되지 않은 동작이 발생한다.
char *message = "hello";
message[0] = 'H';
수정 가능한 배열이 필요하면 별도 배열로 초기화한다.
char message[] = "hello";
message[0] = 'H';
메모리 안전 도구
C 언어 자체가 모든 메모리 오류를 자동으로 검사하지 않으므로 개발 과정에서 도구를 함께 사용한다.
대표적인 방법은 다음과 같다.
- 컴파일러 경고
- 정적 분석
- AddressSanitizer
- UndefinedBehaviorSanitizer
- MemorySanitizer
- ThreadSanitizer
- Valgrind 계열 도구
- 퍼징
- 단위 테스트
- 경계값 검사
- 코드 검토
예를 들어 Clang과 GCC 계열에서는 개발 빌드에 검사기를 활성화할 수 있다.
-fsanitize=address,undefined
-fno-omit-frame-pointer
스레드 데이터 경쟁 검사는 별도 빌드로 수행할 수 있다.
-fsanitize=thread
AddressSanitizer와 ThreadSanitizer는 일반적으로 같은 실행 파일에서 함께 사용할 수 없으므로 별도 검사 구성을 둔다.
도구는 모든 오류가 없음을 증명하지는 않지만, 해제 후 사용과 범위 초과, 일부 정의되지 않은 동작과 데이터 경쟁을 발견하는 데 큰 도움이 된다.
안전한 메모리 관리 원칙
C 프로그램의 메모리 안전성을 높이기 위한 기본 원칙은 다음과 같다.
- 모든 객체를 사용 전에 초기화한다.
- 배열과 포인터에는 길이 정보를 함께 전달한다.
- 크기 덧셈과 곱셈의 오버플로를 검사한다.
- 객체의 수명보다 오래 유지되는 포인터를 만들지 않는다.
- 할당의 소유자와 해제 책임을 명확히 한다.
- 해제 후 모든 별칭을 사용하지 않는다.
- 구조체 패딩과 바이트 순서에 의존하지 않는다.
- 이식 가능한 데이터 형식은 명시적으로 직렬화한다.
- 임의의 포인터 타입 재해석을 피한다.
memcpy와memmove의 차이를 지킨다.- 멀티스레드 공유 상태에는 원자 연산이나 잠금을 사용한다.
volatile을 동기화 수단으로 사용하지 않는다.- 메모리 순서 최적화는 정확성을 증명한 뒤 적용한다.
- 경고와 정적·동적 분석 도구를 사용한다.
메모리 모델의 종합
C의 메모리 모델은 하드웨어 메모리를 단순히 주소가 붙은 바이트 배열로만 취급하지 않는다. 프로그램은 타입과 수명을 가진 객체를 만들고, 포인터를 통해 해당 객체와 부분 객체에 접근한다. 객체는 정렬과 저장 기간, 객체 표현과 값 표현을 가지며, 접근에 사용되는 타입과 포인터의 기원도 프로그램 의미에 영향을 준다.
저장 공간
↓
객체 생성과 수명
↓
타입과 정렬
↓
값 및 객체 표현
↓
포인터와 접근 경로
↓
평가 순서와 동기화
C가 객체 표현을 문자 단위로 관찰하고 포인터 산술과 직접적인 동적 할당을 허용한다는 점은 시스템 프로그래밍에 필요한 강력한 통제력을 제공한다. 동시에 배열 범위와 객체 수명, 유효 타입과 정렬, 메모리 해제를 프로그래머가 정확하게 관리해야 한다.
C11 이후의 병렬 메모리 모델은 원자적 연산과 happens-before 관계를 통해 여러 스레드의 메모리 접근 의미를 규정한다. 적절한 동기화가 없는 데이터 경쟁은 단순히 값이 부정확해지는 현상이 아니라 프로그램 전체의 정의되지 않은 동작이 된다.
C의 메모리 모델을 이해하려면 스택과 힙이라는 구현상의 구분만 아는 것으로 충분하지 않다. 객체가 언제 존재하는지, 어떤 타입으로 접근할 수 있는지, 포인터가 어느 배열과 객체에서 유래했는지, 저장된 바이트가 유효한 값 표현인지, 여러 실행 사이에 어떤 순서 관계가 존재하는지를 함께 판단해야 한다.
실행 과정
C 프로그램의 실행 과정은 소스 코드를 작성한 뒤 하나의 명령으로 실행 파일을 만드는 단순한 작업처럼 보이지만, 내부적으로는 여러 단계가 연결되어 있다. C 소스 파일은 전처리되고 문법과 의미가 분석되며, 대상 시스템의 기계 명령이나 중간 표현으로 변환된다. 이후 객체 파일이 만들어지고 필요한 라이브러리와 결합되어 프로그램 이미지가 구성된다. 실행 시에는 운영체제나 독립 실행 환경이 프로그램을 메모리에 배치하고 지정된 시작 함수를 호출한다.
일반적인 호스트 환경에서 전체 흐름은 다음과 같이 나타낼 수 있다.
C 소스 파일
↓
전처리
↓
문법·의미 분석
↓
중간 표현 생성과 최적화
↓
대상 기계 코드 생성
↓
어셈블
↓
객체 파일
↓
링크
↓
실행 파일 또는 공유 라이브러리
↓
운영체제 로더
↓
동적 링커와 런타임 초기화
↓
main 호출
↓
프로그램 실행
↓
종료 처리
이 흐름의 모든 단계가 ISO C 표준에 동일한 이름과 파일 형식으로 규정되어 있는 것은 아니다. ISO C는 소스 문자가 프로그램 이미지로 변환되는 개념적인 번역 단계와 프로그램의 시작·실행·종료 의미를 규정한다. 전처리 실행 파일과 어셈블리 파일, ELF·PE·Mach-O 객체 파일, 정적·동적 링커와 운영체제 로더는 구체적인 구현체와 플랫폼이 제공하는 체계다.
GCC는 실제 컴파일 작업을 전처리, 본격적인 컴파일, 어셈블과 링크의 네 단계로 설명하며, Clang도 전처리와 파싱, 최적화, 코드 생성, 어셈블 및 링크를 하나의 드라이버에서 조정한다.[43][44]
번역 환경과 실행 환경
C 표준은 구현 환경을 크게 번역 환경과 실행 환경으로 구분한다.
번역 환경
→ C 소스 파일을 프로그램 이미지로 변환
실행 환경
→ 변환된 프로그램을 시작하고 실행
번역 환경은 다음 작업을 담당할 수 있다.
- 소스 문자 처리
- 전처리 지시문 실행
- 매크로 확장
- 문법과 의미 분석
- 진단 생성
- 번역 단위 변환
- 외부 참조 해결
- 라이브러리 결합
- 실행에 필요한 프로그램 이미지 생성
실행 환경은 다음 작업을 담당할 수 있다.
- 프로그램 이미지 적재
- 정적 저장 객체 초기화
- 실행 시작 함수 호출
- 프로그램 입출력 환경 제공
- 프로그램 종료 상태 처리
- 실행 환경으로 제어 반환
번역 환경과 실행 환경은 같은 컴퓨터에 존재할 필요가 없다. 개발용 컴퓨터에서 다른 프로세서나 운영체제를 위한 프로그램을 번역한 뒤 대상 장치에서 실행할 수 있다.
개발 호스트
x86-64 Linux
↓ 교차 컴파일
ARM 임베디드 실행 파일
↓ 전송
대상 장치
ARM 마이크로컨트롤러
이 방식을 교차 컴파일이라고 한다.
프로그램과 번역 단위
C 프로그램 전체를 한 번에 번역할 필요는 없다. 프로그램은 여러 소스 파일로 나뉠 수 있고, 각 소스 파일은 포함된 헤더와 함께 독립적인 번역 단위가 된다.
main.c
renderer.c
network.c
storage.c
각 파일은 별도로 번역될 수 있다.
main.c → main.o
renderer.c → renderer.o
network.c → network.o
storage.c → storage.o
이후 링커가 객체 파일을 하나의 프로그램으로 결합한다.
main.o
renderer.o
network.o
storage.o
libc
시스템 라이브러리
↓
실행 프로그램
ISO C는 #include 처리가 끝난 소스 파일을 전처리 번역 단위라고 하고, 전처리 이후의 결과를 번역 단위라고 구분한다. 여러 번역 단위는 외부 연결을 가진 함수와 객체, 파일과 라이브러리 인터페이스를 통해 서로 통신할 수 있다.[45]
ISO C의 번역 단계
ISO C는 번역 과정을 여덟 단계로 나눈다. 실제 컴파일러가 내부적으로 반드시 여덟 개의 독립 프로그램을 실행해야 하는 것은 아니다. 여러 단계를 하나의 처리 과정으로 합칠 수 있지만, 최종 결과는 표준이 정한 단계가 순서대로 일어난 것과 같은 의미를 가져야 한다.[46]
1. 소스 문자 변환
2. 줄 연결
3. 전처리 토큰 분해와 주석 제거
4. 전처리 지시문과 매크로 처리
5. 문자·문자열의 실행 문자 집합 변환
6. 인접 문자열 리터럴 결합
7. 문법·의미 분석과 번역
8. 외부 참조 해결과 프로그램 이미지 생성
1단계: 물리적 소스 문자 처리
첫 번째 단계에서는 물리적 소스 파일의 문자를 구현체가 사용하는 C 소스 문자 집합으로 대응시킨다. 파일에 사용된 문자 인코딩과 컴파일러 내부의 문자 표현이 다를 수 있기 때문이다.
UTF-8 소스 파일
↓
컴파일러의 소스 문자 해석
↓
C 소스 문자 집합
소스 파일의 줄 끝 표현도 논리적인 줄바꿈 문자로 처리된다.
운영체제에 따라 텍스트 파일의 줄 끝은 다음처럼 다를 수 있다.
Unix 계열 → LF
Windows → CRLF
과거 Mac → CR
컴파일러는 이를 C 소스의 줄 구분으로 해석한다.
소스 파일 인코딩은 C 표준 하나만으로 고정되지 않는다. 컴파일러 옵션과 빌드 환경, 소스 관리 정책이 실제 인코딩을 결정할 수 있다.
2단계: 역슬래시 줄 연결
역슬래시 바로 뒤에 줄바꿈이 있으면 두 물리적 줄이 하나의 논리적 줄로 연결된다.
#define LOG_MESSAGE(message) \
fprintf(stderr, "%s\n", message)
개념적으로 다음 한 줄과 같은 전처리 토큰 흐름을 만든다.
#define LOG_MESSAGE(message) fprintf(stderr, "%s\n", message)
문자열 리터럴 안에서도 줄 연결은 문자열 토큰을 인식하기 전에 처리된다.
const char *message = "hello \
world";
이는 줄바꿈 자체가 문자열에 포함되는 것이 아니라 물리적 줄이 연결된 결과다.
실제 문자열을 여러 줄로 나누려면 인접 문자열 리터럴을 사용하는 편이 명확하다.
const char *message =
"hello "
"world";
역슬래시 뒤에 공백이 들어가면 줄 연결이 되지 않을 수 있으므로 매크로 작성 시 주의해야 한다.
#define VALUE 10 \
+ 20
역슬래시와 줄바꿈 사이의 공백 때문에 의도한 매크로가 만들어지지 않을 수 있다.
3단계: 전처리 토큰 분해와 주석 제거
소스 파일은 전처리 토큰과 공백 문자로 분해된다. 주석은 하나의 공백 문자로 대체된다.
int/* comment */value = 10;
주석 제거 후에는 개념적으로 다음과 같다.
int value = 10;
주석이 단순히 아무 문자도 남기지 않고 삭제되는 것이 아니라 공백으로 대체되므로 서로 다른 토큰이 우연히 결합하지 않는다.
int/**/value;
이는 intvalue가 아니라 int value로 처리된다.
문자열 리터럴 안의 주석 기호는 주석으로 해석되지 않는다.
const char *text = "/* not a comment */";
이 단계에서 소스는 다음과 같은 전처리 토큰으로 분해된다.
- 헤더 이름
- 식별자
- 전처리 숫자
- 문자 상수
- 문자열 리터럴
- 구두점
- 그 밖의 비공백 문자
4단계: 전처리 지시문 실행
전처리기는 #include, #define, 조건부 컴파일과 기타 지시문을 처리한다.
#include <stdio.h>
#define BUFFER_SIZE 4096
#if defined(DEBUG)
#define LOG_ENABLED 1
#else
#define LOG_ENABLED 0
#endif
이 단계에서 다음 작업이 수행된다.
- 포함 파일 탐색
- 헤더 내용 삽입
- 객체형 매크로 확장
- 함수형 매크로 확장
- 조건부 코드 선택
#embed처리_Pragma처리- 전처리 진단 생성
- 처리된 전처리 지시문 제거
예를 들어 다음 소스가 있다고 하자.
#define SQUARE(value) ((value) * (value))
int result = SQUARE(4);
매크로 확장 후의 핵심 코드는 다음과 같다.
int result = ((4) * (4));
#include는 지정된 헤더를 다시 초기 번역 단계부터 처리하도록 한다.
#include "project.h"
헤더 파일 안의 문자 처리와 줄 연결, 토큰 분해, 전처리 지시문도 동일하게 처리된다.
GCC와 Clang에서는 -E 옵션으로 전처리까지만 수행한 결과를 확인할 수 있다.
clang -E main.c
gcc -E main.c
GCC 공식 문서는 -E를 사용하면 전처리만 수행하고 이후 컴파일 단계로 진행하지 않는다고 설명한다.[47]
전처리 결과에는 포함된 표준 헤더 전체와 매크로 확장 결과가 들어갈 수 있어 원본보다 매우 길어질 수 있다.
전처리기의 한계
전처리기는 C의 타입과 객체 의미를 이해하는 일반적인 함수 체계가 아니다. 토큰을 치환하는 처리 단계다.
#define SQUARE(value) \
((value) * (value))
다음 호출은 인수를 두 번 평가한다.
int result = SQUARE(index++);
확장하면 다음과 비슷해진다.
int result =
((index++) * (index++));
이는 같은 객체를 순서 없이 여러 번 변경하여 정의되지 않은 동작을 일으킬 수 있다.
타입 검사와 한 번의 평가가 필요하면 inline 함수를 사용할 수 있다.
static inline int square(int value) {
return value * value;
}
5단계: 실행 문자 집합 변환
문자 상수와 문자열 리터럴 안의 소스 문자 및 이스케이프 시퀀스는 실행 환경에서 사용하는 문자 집합의 대응 문자로 변환된다.
const char *message = "hello";
char newline = '\n';
소스 파일이 UTF-8이라고 해서 모든 일반 문자열 리터럴이 반드시 실행 환경에서도 UTF-8 바이트 배열이 된다고 ISO C만으로 보장되지는 않는다.
문자열 접두사에 따라 실행 문자 인코딩의 종류가 달라질 수 있다.
"ordinary"
u8"UTF-8"
u"UTF-16"
U"UTF-32"
L"wide"
실제 인코딩과 문자 타입의 표현은 표준 판과 구현체 설정의 영향을 받는다.
컴파일러는 소스 인코딩과 실행 인코딩을 별도 옵션으로 다룰 수 있다.
6단계: 인접 문자열 리터럴 결합
서로 인접한 문자열 리터럴 토큰은 하나의 문자열로 결합된다.
const char *message =
"TechPedia "
"Wiki Engine";
이는 다음과 같다.
const char *message =
"TechPedia Wiki Engine";
매크로 확장 결과로 문자열 리터럴이 인접해도 결합될 수 있다.
#define PRODUCT_NAME "TechPedia"
const char *message =
PRODUCT_NAME " Wiki Engine";
전처리 후 다음과 같이 인접한다.
const char *message =
"TechPedia" " Wiki Engine";
최종적으로 하나의 문자열 리터럴이 된다.
7단계: 문법과 의미 분석
전처리 토큰은 실제 C 토큰으로 변환되고, 컴파일러가 문법과 의미를 분석하여 번역 단위를 처리한다.
이 단계에서는 다음과 같은 작업이 이루어진다.
- 키워드와 식별자 인식
- 선언 분석
- 타입 구성
- 표현식 분석
- 연산자 결합
- 범위와 연결 확인
- 함수 호출 검사
- 제약 위반 진단
- 초기화 검사
- 제어 흐름 분석
- 번역 단위 생성
예를 들어 다음 함수가 있다고 하자.
int add(int left, int right) {
return left + right;
}
컴파일러는 다음 정보를 구성한다.
함수 이름 → add
연결 → 외부 연결
반환 타입 → int
매개변수 → int, int
본문 → 두 매개변수 덧셈 후 반환
잘못된 타입의 인수를 전달하면 진단할 수 있다.
int add(int left, int right);
int main(void) {
return add("text", 10);
}
문자열을 int 매개변수에 전달하려 하므로 제약을 위반한다.
ISO C에서 제약 위반이 포함된 번역 단위에는 적합한 구현체가 최소 하나의 진단 메시지를 출력해야 한다. 다만 진단을 출력한 뒤에도 구현체가 확장 기능으로 번역을 계속할 수 있으며, 경고와 오류의 구체적인 분류는 구현체가 정한다.[48]
구문 분석
컴파일러의 프런트엔드는 토큰을 C 문법에 따라 구문 구조로 만든다.
result = left + right * scale;
연산자 우선순위를 적용하면 다음 구조가 된다.
대입
├── 왼쪽: result
└── 오른쪽: 덧셈
├── left
└── 곱셈
├── right
└── scale
곱셈이 덧셈보다 먼저 결합된다.
많은 컴파일러는 이를 추상 구문 트리 형태로 표현한다.
BinaryOperator '='
├── DeclRefExpr result
└── BinaryOperator '+'
├── DeclRefExpr left
└── BinaryOperator '*'
├── DeclRefExpr right
└── DeclRefExpr scale
Clang에서는 다음과 같은 명령으로 추상 구문 트리를 확인할 수 있다.
clang -Xclang -ast-dump \
-fsyntax-only main.c
이 명령은 Clang 구현체의 기능이며 ISO C 표준 기능은 아니다.
의미 분석
구문이 올바르더라도 타입과 선언 규칙을 위반할 수 있다.
int *pointer;
double value;
pointer = value;
토큰과 문장 구조는 분석할 수 있지만, double을 객체 포인터에 직접 대입할 수 없으므로 의미상 잘못되었다.
의미 분석에서는 다음과 같은 사항을 검사한다.
- 선언된 이름인지
- 타입이 완전한지
- 연산자가 해당 타입에 적용 가능한지
- 함수 인수가 원형과 호환되는지
- 반환 표현식이 반환 타입과 호환되는지
- 대입 대상이 수정 가능한 객체인지
- 레이블과
goto가 유효한지 case값이 중복되지 않는지- 구조체 구성원이 존재하는지
- 상수 표현식이 필요한 위치인지
struct point {
int x;
int y;
};
int main(void) {
struct point position;
position.z = 10;
return 0;
}
z라는 구성원이 없으므로 컴파일러가 진단한다.
중간 표현
현대 컴파일러는 C 소스를 바로 기계어로 변환하기보다 하나 이상의 중간 표현으로 변환하는 경우가 많다.
C 소스
↓
추상 구문 트리
↓
고수준 중간 표현
↓
최적화 중간 표현
↓
대상 기계 중간 표현
↓
기계 명령
Clang은 C 프런트엔드에서 분석한 프로그램을 LLVM IR로 변환할 수 있다.
다음 코드가 있다고 하자.
int square(int value) {
return value * value;
}
LLVM IR을 출력할 수 있다.
clang -S -emit-llvm square.c
개념적으로 다음과 비슷한 중간 표현이 만들어진다.
define i32 @square(i32 %value) {
entry:
%result = mul nsw i32 %value, %value
ret i32 %result
}
중간 표현은 C 언어 자체가 아니며, 특정 컴파일러 구조의 내부 또는 공개된 도구 인터페이스다.
최적화
컴파일러는 표준이 보장하는 관찰 가능한 동작을 유지하는 범위에서 프로그램을 변형할 수 있다.
대표적인 최적화에는 다음이 포함된다.
- 상수 접기
- 죽은 코드 제거
- 공통 부분식 제거
- 함수 인라인화
- 반복문 변환
- 벡터화
- 레지스터 할당
- 분기 단순화
- 불필요한 메모리 접근 제거
- 프로시저 간 최적화
- 링크 시간 최적화
다음 코드를 생각할 수 있다.
int calculate(void) {
int result = 10 * 20;
return result;
}
컴파일러는 실행 시간의 곱셈을 제거하고 상수 200을 반환할 수 있다.
return 200
사용되지 않는 계산도 제거할 수 있다.
int calculate(void) {
int unused = expensive_calculation();
return 10;
}
expensive_calculation이 외부에서 관찰되는 부작용이 없다면 호출이 제거될 수 있다. 그러나 함수 호출이 입출력이나 volatile 접근 같은 관찰 가능한 동작을 수행한다면 함부로 제거할 수 없다.
추상 기계와 최적화
C 소스의 각 문장이 반드시 순서대로 동일한 기계 명령을 생성하는 것은 아니다.
int first = 10;
int second = 20;
int result = first + second;
최적화된 프로그램에서는 세 객체가 실제 메모리에 존재하지 않고 결과 30만 직접 사용될 수 있다.
컴파일러는 올바른 C 프로그램이 다음 규칙을 지킨다고 가정할 수 있다.
- 정의되지 않은 동작을 수행하지 않음
- 유효하지 않은 포인터를 역참조하지 않음
- 부호 있는 정수 오버플로가 발생하지 않음
- 엄격한 별칭 규칙을 지킴
- 데이터 경쟁이 없음
- 객체 수명을 지킴
프로그램이 이러한 규칙을 위반하면 최적화 결과가 소스 순서에 대한 단순한 직관과 크게 달라질 수 있다.
최적화 수준
GCC와 Clang 계열 컴파일러는 최적화 수준을 옵션으로 제공한다.
-O0
-O1
-O2
-O3
-Os
-Oz
일반적인 의미는 다음과 같다.
| 옵션 | 일반적 목적 |
|---|---|
-O0 | 최적화를 최소화하고 디버깅을 쉽게 함 |
-O1 | 기본적인 최적화 |
-O2 | 성능과 코드 크기의 일반적인 균형 |
-O3 | 더 공격적인 최적화 |
-Os | 코드 크기 감소 |
-Oz | 코드 크기를 더 강하게 최소화 |
정확히 어떤 최적화가 활성화되는지는 컴파일러와 버전에 따라 달라질 수 있다.
최적화 수준이 높다고 항상 프로그램이 더 빠른 것은 아니다. 코드 크기와 캐시, 벡터화, 분기와 대상 CPU에 따라 실제 성능이 달라진다.
코드 생성
컴파일러 백엔드는 중간 표현을 대상 프로세서의 기계 명령으로 변환한다.
중간 표현
↓
명령 선택
↓
레지스터 할당
↓
명령 스케줄링
↓
대상 어셈블리 또는 기계 코드
같은 C 코드라도 대상 아키텍처에 따라 결과가 달라진다.
int add(int left, int right) {
return left + right;
}
x86-64에서는 개념적으로 다음과 같은 명령이 생성될 수 있다.
lea eax, [rdi + rsi]
ret
AArch64에서는 다음과 비슷할 수 있다.
add w0, w0, w1
ret
이는 예시이며 실제 결과는 ABI와 컴파일러, 최적화 옵션에 따라 달라진다.
어셈블리 출력을 확인하려면 -S를 사용할 수 있다.
clang -S -O2 main.c
gcc -S -O2 main.c
일반적으로 .s 확장자의 어셈블리 파일이 생성된다.
대상 시스템
코드 생성에는 대상 시스템에 관한 정보가 필요하다.
- 프로세서 아키텍처
- 명령어 집합
- 운영체제
- ABI
- 데이터 모델
- 호출 규약
- 바이트 순서
- 객체 파일 형식
- 사용 가능한 CPU 확장
- 부동소수점 규칙
Clang에서는 target triple을 사용할 수 있다.
x86_64-unknown-linux-gnu
aarch64-apple-darwin
x86_64-pc-windows-msvc
arm-none-eabi
각 부분은 대체로 다음 정보를 나타낸다.
아키텍처-제조사-운영체제-환경
구체적인 구성은 도구체인마다 다를 수 있다.
ABI
응용 프로그램 이진 인터페이스는 서로 별도로 번역된 코드가 이진 수준에서 함께 동작하기 위한 규칙이다.
ABI는 다음을 규정할 수 있다.
- 함수 인수를 전달하는 레지스터
- 반환값 위치
- 호출자가 보존할 레지스터
- 피호출자가 보존할 레지스터
- 스택 정렬
- 구조체 배치
- 기본 타입의 크기
- 이름과 심벌 표현
- 시스템 호출 방식
- 객체 파일 형식
- 예외와 언와인딩 정보
다음 함수의 C 원형은 플랫폼마다 같을 수 있다.
int add(int left, int right);
그러나 실제 인수 전달 방식은 달라질 수 있다.
x86-64 System V
left → EDI
right → ESI
결과 → EAX
Windows x64
left → ECX
right → EDX
결과 → EAX
ISO C는 이러한 레지스터를 규정하지 않는다. 플랫폼 ABI가 정한다.
어셈블
어셈블러는 어셈블리 언어를 재배치 가능한 기계 코드와 메타데이터를 포함하는 객체 파일로 변환한다.
main.s
↓ assembler
main.o
객체 파일에는 일반적으로 다음 정보가 들어간다.
- 기계 명령
- 초기화된 데이터
- 초기화되지 않은 데이터 크기
- 심벌
- 재배치 정보
- 디버깅 정보
- 예외 처리 정보
- 섹션과 정렬 정보
컴파일과 어셈블까지만 수행하려면 -c를 사용한다.
clang -c main.c -o main.o
gcc -c main.c -o main.o
GCC 문서에서 -c는 링커를 실행하지 않고 어셈블러가 만든 객체 파일을 결과로 남기는 옵션이다.[49]
객체 파일
객체 파일은 일반적으로 아직 완전한 실행 프로그램이 아니다. 다른 번역 단위나 라이브러리에 정의된 심벌을 참조할 수 있다.
/* main.c */
int calculate(int value);
int main(void) {
return calculate(10);
}
main.o에는 main의 기계 코드가 들어 있지만 calculate의 실제 주소는 아직 결정되지 않을 수 있다.
개념적으로 다음 심벌 정보를 가진다.
정의된 심벌
main
정의되지 않은 심벌
calculate
다른 객체 파일이 함수를 정의할 수 있다.
/* calculate.c */
int calculate(int value) {
return value * 2;
}
calculate.o
정의된 심벌
calculate
링커가 두 객체를 결합하면 main의 calculate 참조를 실제 정의에 연결한다.
객체 파일 형식
객체 파일 형식은 플랫폼에 따라 다르다.
| 플랫폼 계열 | 대표 형식 |
|---|---|
| Linux와 여러 UNIX 계열 | ELF |
| Windows | PE/COFF |
| macOS와 Apple 플랫폼 | Mach-O |
| 일부 임베디드 환경 | ELF 또는 전용 형식 |
| WebAssembly | WebAssembly 모듈 형식 |
ELF는 재배치 가능 객체 파일과 실행 파일, 공유 객체와 코어 파일 등을 표현할 수 있다. ELF 파일은 헤더와 프로그램 헤더 테이블, 섹션 헤더 테이블 등을 포함할 수 있다.[50]
객체 파일의 내부를 확인하는 도구에는 다음이 있다.
readelf
objdump
nm
otool
dumpbin
llvm-readobj
llvm-objdump
예를 들어 ELF 심벌을 확인할 수 있다.
readelf --symbols main.o
섹션
객체 파일은 용도에 따라 내용을 여러 섹션으로 구분할 수 있다.
대표적인 ELF 섹션 이름은 다음과 같다.
.text
.rodata
.data
.bss
.symtab
.strtab
.rela.text
.debug_info
일반적인 역할은 다음과 같다.
| 섹션 | 일반적 내용 |
|---|---|
.text | 기계 명령 |
.rodata | 읽기 전용 상수 데이터 |
.data | 초기값이 있는 전역·정적 객체 |
.bss | 0으로 초기화되는 전역·정적 객체 |
.symtab | 심벌 테이블 |
.strtab | 심벌 문자열 |
| 재배치 섹션 | 링크 시 수정해야 할 위치 |
| 디버그 섹션 | 소스와 기계 코드 대응 정보 |
ISO C가 이러한 섹션 이름과 배치를 요구하는 것은 아니다.
다음 C 선언이 반드시 특정 섹션에 들어간다고 표준만으로 보장할 수 없다.
static const int first = 10;
static int second = 20;
static int third;
일반적인 구현에서는 각각 읽기 전용 데이터, 초기화 데이터와 BSS에 들어갈 수 있지만 컴파일러와 링커가 병합하거나 제거할 수 있다.
심벌
심벌은 객체와 함수의 이름을 링크 가능한 형태로 표현한다.
int global_count;
int calculate(int value) {
return value * 2;
}
객체 파일에는 다음과 같은 심벌이 존재할 수 있다.
global_count
calculate
파일 범위의 static 객체나 함수는 내부 연결을 가진다.
static int internal_count;
static int calculate_internal(void) {
return internal_count;
}
이 이름은 다른 번역 단위의 같은 이름과 같은 대상을 나타내지 않는다.
최적화 과정에서 함수가 인라인화되거나 객체가 제거되면 최종 객체 파일에 해당 심벌이 남지 않을 수도 있다.
재배치
객체 파일을 만들 때 함수와 객체의 최종 메모리 주소가 정해지지 않은 경우가 많다.
extern int global_value;
int read_value(void) {
return global_value;
}
컴파일러는 global_value의 주소를 알지 못한 채 참조 위치와 재배치 정보를 기록한다.
read_value 기계 코드
+
global_value 참조 재배치 항목
링커는 최종 주소를 결정하고 해당 위치를 수정한다.
재배치 전
load [미정 주소]
재배치 후
load [global_value 주소]
위치 독립 코드에서는 실행 시점에 동적 링커가 일부 재배치를 처리할 수도 있다.
링크
링커는 여러 객체 파일과 라이브러리를 결합하여 실행 파일이나 공유 라이브러리를 만든다.
main.o
calculator.o
libc
시작 코드
↓ linker
application
주요 작업은 다음과 같다.
- 외부 심벌 참조 해결
- 섹션 결합
- 메모리 배치 결정
- 재배치 적용
- 라이브러리 코드 선택
- 진입점 설정
- 실행 파일 헤더 생성
- 동적 링크 정보 생성
- 사용하지 않는 섹션 제거
- 링크 스크립트 적용
ISO C의 마지막 번역 단계는 모든 외부 객체와 함수 참조를 해결하고 라이브러리 구성 요소를 결합하여 실행 환경에 필요한 정보를 포함한 프로그램 이미지를 만드는 것으로 설명한다.[51]
심벌 해결
링커는 선언만 있는 참조를 실제 정의와 연결한다.
/* main.c */
int add(int left, int right);
int main(void) {
return add(10, 20);
}
/* add.c */
int add(int left, int right) {
return left + right;
}
링커는 main.o의 미정의 심벌 add를 add.o의 정의와 연결한다.
정의를 찾지 못하면 링크 오류가 발생한다.
undefined reference to `add`
같은 외부 심벌이 허용되지 않는 방식으로 여러 번 정의되면 중복 정의 오류가 발생할 수 있다.
multiple definition of `global_count`
선언과 정의의 불일치
링커는 보통 C의 전체 타입 정보를 알지 못하거나 제한적으로만 안다.
/* first.c */
int process(int value);
/* second.c */
double process(double value) {
return value;
}
두 번역 단위가 호환되지 않는 선언과 정의를 사용하면 C 프로그램의 규칙을 위반한다. 심벌 이름만 같으면 일부 링커는 연결을 완료할 수도 있지만, 호출 규약과 반환값 해석이 달라 실행 중 잘못된 동작이 발생할 수 있다.
공통 헤더를 사용하여 선언과 정의의 일관성을 유지해야 한다.
/* process.h */
#ifndef PROCESS_H
#define PROCESS_H
int process(int value);
#endif
/* process.c */
#include "process.h"
int process(int value) {
return value;
}
정적 라이브러리
정적 라이브러리는 여러 객체 파일을 하나의 아카이브로 묶은 형태다.
math.o
vector.o
matrix.o
↓
libmath.a
링커는 프로그램이 필요로 하는 객체 파일이나 섹션을 정적 라이브러리에서 선택해 실행 파일에 포함할 수 있다.
clang main.o -L. -lmath \
-o application
정적 링크의 일반적인 특징은 다음과 같다.
- 필요한 라이브러리 코드가 실행 파일에 포함됨
- 실행 시 해당 공유 라이브러리가 없어도 될 수 있음
- 실행 파일 크기가 커질 수 있음
- 여러 프로그램이 같은 코드의 별도 복사본을 가질 수 있음
- 라이브러리 갱신 시 프로그램을 다시 링크해야 할 수 있음
- 완전한 정적 링크 가능 여부는 플랫폼에 따라 다름
정적 라이브러리는 일반적으로 새로운 번역 단위를 만드는 것이 아니라 이미 번역된 객체 파일의 묶음이다.
공유 라이브러리
공유 라이브러리는 프로그램 실행 시 여러 프로세스에서 매핑하여 사용할 수 있는 라이브러리 형식이다.
대표적인 확장자는 다음과 같다.
Linux·UNIX → .so
Windows → .dll
macOS → .dylib
실행 파일에는 라이브러리 코드 전체가 아니라 필요한 공유 라이브러리와 심벌에 관한 정보가 들어갈 수 있다.
application
├── 자체 코드
├── libc.so 필요
└── libgraphics.so 필요
실행 시 동적 링커가 공유 객체를 찾아 메모리에 적재하고 심벌과 재배치를 처리한다.
공유 라이브러리의 장점은 다음과 같다.
- 여러 프로그램이 코드 페이지를 공유할 수 있음
- 실행 파일 크기를 줄일 수 있음
- 일부 라이브러리를 별도로 갱신할 수 있음
- 플러그인과 런타임 로딩을 구현할 수 있음
다음 문제도 발생할 수 있다.
- 라이브러리를 찾지 못함
- ABI 불일치
- 심벌 버전 충돌
- 로딩 경로 문제
- 배포 환경 차이
- 실행 시 재배치 비용
- 라이브러리 교체로 인한 동작 변화
링크 순서
일부 정적 링커에서는 라이브러리 지정 순서가 심벌 해결에 영향을 줄 수 있다.
clang main.o -lmath
링커가 왼쪽에서 오른쪽으로 처리하며 현재 필요한 심벌을 기준으로 정적 아카이브 구성원을 선택하는 경우, 의존하는 객체나 라이브러리를 앞에 두고 제공하는 라이브러리를 뒤에 두어야 할 수 있다.
application.o
↓ libgraphics 필요
libgraphics
↓ libmath 필요
libmath
clang application.o \
-lgraphics \
-lmath
정확한 동작은 사용하는 링커와 옵션에 따라 다르다.
링크 시간 최적화
링크 시간 최적화는 여러 번역 단위의 중간 표현을 링크 단계까지 보존하여 프로그램 전체를 대상으로 최적화하는 방식이다.
main.c → 중간 표현 객체
math.c → 중간 표현 객체
util.c → 중간 표현 객체
↓
LTO 링크
↓
전체 프로그램 최적화
다음과 같은 최적화가 가능해질 수 있다.
- 번역 단위를 넘는 함수 인라인화
- 사용되지 않는 함수 제거
- 전역 상수 전파
- 호출 그래프 최적화
- 타입 기반 최적화
GCC와 Clang 계열에서는 보통 다음처럼 활성화한다.
-flto
컴파일과 링크 양쪽에서 호환되는 LTO 설정과 도구체인이 필요하다.
실행 파일
링크 결과는 실행 가능한 프로그램 이미지가 될 수 있다.
실행 파일은 일반적으로 다음 정보를 포함한다.
- 파일 형식 식별자
- 대상 아키텍처
- 진입점
- 메모리에 적재할 영역
- 기계 코드
- 초기 데이터
- 동적 라이브러리 목록
- 재배치 정보
- 심벌과 디버깅 정보
- 권한과 보안 관련 메타데이터
실행 파일이 생성되었다고 반드시 현재 컴퓨터에서 실행할 수 있는 것은 아니다.
다음 조건이 맞아야 할 수 있다.
- CPU 아키텍처
- 운영체제 ABI
- 실행 파일 형식
- 필요한 동적 로더
- 공유 라이브러리
- 시스템 호출 인터페이스
- 파일 권한
- 보안 정책
로딩
사용자가 프로그램을 실행하면 운영체제의 프로그램 로더가 실행 파일을 읽고 새로운 프로세스 이미지를 구성한다.
대표적인 UNIX 계열 흐름은 다음과 같다.
셸
↓ execve
커널
↓ 실행 파일 확인
프로그램 세그먼트 매핑
↓
스택과 초기 실행 상태 구성
↓
동적 링커 또는 프로그램 진입점
Linux에서 execve로 동적 링크된 ELF 실행 파일을 시작하면, ELF의 PT_INTERP 세그먼트에 지정된 인터프리터가 필요한 공유 객체를 적재하는 데 사용된다.[52]
프로그램 로더는 다음과 같은 작업을 수행할 수 있다.
- 기존 프로세스 이미지 교체
- 실행 파일 헤더 검증
- 코드와 데이터 영역 매핑
- 초기 스택 구성
- 인수와 환경 정보 전달
- 페이지 권한 설정
- 동적 링커 지정
- 초기 명령 포인터 설정
이는 특정 운영체제 구현의 동작이며 ISO C가 시스템 호출이나 가상 메모리 배치를 직접 규정하지 않는다.
섹션과 세그먼트
객체 파일의 섹션과 실행 시의 세그먼트는 목적이 다르다.
섹션
→ 링크와 파일 구성 중심
세그먼트
→ 실행 시 메모리 적재 중심
여러 섹션이 하나의 메모리 세그먼트로 합쳐질 수 있다.
.text
.rodata 일부
↓
읽기·실행 세그먼트
.data
.bss
↓
읽기·쓰기 세그먼트
ELF의 프로그램 헤더는 실행 시 어떤 파일 영역을 어느 권한으로 메모리에 매핑할지 설명한다.
가상 메모리 매핑
일반적인 운영체제는 실행 파일 전체를 한 번에 물리 메모리에 복사하지 않고 가상 메모리 영역으로 매핑할 수 있다.
프로세스 가상 주소 공간
├── 실행 코드
├── 읽기 전용 데이터
├── 쓰기 가능한 전역 데이터
├── 동적 라이브러리
├── 힙
├── 스택
└── 운영체제별 영역
필요한 페이지는 처음 접근할 때 실제 메모리에 연결될 수 있다.
이러한 구체적 메모리 배치는 ISO C의 보장이 아니다. 임베디드 환경에서는 코드가 플래시 메모리에 있고 데이터가 SRAM에 있을 수 있으며, 운영체제 커널이나 부트로더는 별도의 링크 스크립트와 메모리 맵을 사용할 수 있다.
동적 링커
동적 링크된 프로그램에서는 동적 링커가 실행 전에 또는 실행 도중 공유 라이브러리를 처리한다.
Linux ELF 환경에서는 ld-linux.so 계열 프로그램이 동적 링크 작업을 수행할 수 있다. 동적 링커는 필요한 공유 객체를 찾고 적재하며 심벌과 재배치를 해결한다.[53]
일반적인 흐름은 다음과 같다.
실행 파일 적재
↓
동적 링커 적재
↓
필요한 공유 라이브러리 탐색
↓
공유 객체 메모리 매핑
↓
심벌 해결
↓
재배치 적용
↓
초기화 함수 실행
↓
프로그램 시작 코드로 이동
공유 라이브러리 검색 경로는 플랫폼과 설정에 따라 달라진다.
Linux 계열에서는 다음 요소가 관여할 수 있다.
- 실행 파일의 동적 섹션
RPATHRUNPATH- 환경 변수
- 동적 링커 캐시
- 기본 라이브러리 디렉터리
- 실행 파일 기준 상대 경로
정확한 검색 우선순위는 동적 링커 문서를 확인해야 한다.
즉시 바인딩과 지연 바인딩
공유 라이브러리의 함수 심벌은 프로그램 시작 시 모두 해결할 수도 있고, 함수가 처음 호출될 때 해결할 수도 있다.
즉시 바인딩
→ 시작 시 심벌 해결
지연 바인딩
→ 첫 호출 시 심벌 해결
지연 바인딩은 시작 시간을 줄일 수 있지만 첫 호출에 추가 비용이 발생할 수 있다.
일부 환경에서는 보안이나 예측 가능한 실패 시점을 위해 모든 심벌을 시작 시 해결하도록 설정할 수 있다.
위치 독립 코드
공유 라이브러리는 여러 주소에 매핑될 수 있으므로 위치 독립 코드를 사용하는 경우가 많다.
고정 절대 주소 대신
현재 위치나 테이블을 기준으로 접근
GCC와 Clang 계열에서는 일반적으로 다음 옵션을 사용할 수 있다.
-fPIC
공유 라이브러리 생성 예시는 다음과 같다.
clang -fPIC \
-c library.c \
-o library.o
clang -shared \
library.o \
-o libexample.so
옵션과 확장자는 플랫폼에 따라 다르다.
실행 시작점
운영체제가 실행 파일을 적재한 뒤 곧바로 C의 main 함수로 직접 점프한다고 단순화해서 설명하는 경우가 많지만, 일반적인 호스트 구현에서는 실제 진입점과 main 사이에 런타임 시작 코드가 존재할 수 있다.
운영체제 진입점
↓
런타임 시작 코드
↓
프로세스 초기 정보 처리
↓
C 라이브러리 초기화
↓
전역 런타임 상태 준비
↓
main 호출
ELF 환경에서 실행 파일 헤더의 진입점은 _start와 같은 런타임 시작 코드일 수 있다.
_start
↓
C 런타임 초기화
↓
main(argc, argv)
↓
exit 반환 처리
_start라는 이름과 구체적인 호출 구조는 ISO C의 일부가 아니다.
C 런타임
C 런타임은 프로그램 시작과 종료, 표준 라이브러리 연결과 실행 환경 적응을 위한 코드와 데이터의 집합을 가리키는 일반적인 용어다.
다음 요소가 포함될 수 있다.
- 시작 코드
- 스택과 인수 해석
- 표준 입출력 준비
- 정적 데이터 초기화 지원
- 스레드 로컬 저장소 준비
- 동적 링커 인터페이스
main호출- 종료 함수 처리
- 프로세스 종료 시스템 호출
- 컴파일러 지원 함수
정수 나눗셈이나 넓은 정수 연산처럼 대상 CPU가 직접 지원하지 않는 연산은 컴파일러 런타임 함수로 구현될 수도 있다.
64비트 나눗셈
↓
대상 CPU에 직접 명령 없음
↓
컴파일러 지원 함수 호출
따라서 표준 라이브러리를 사용하지 않는 프로그램도 컴파일러 런타임에 의존할 수 있다.
정적 객체 초기화
C 표준은 프로그램 시작 전에 정적 저장 기간을 가진 모든 객체가 초기값으로 설정되어 있어야 한다고 규정한다.[54]
static int count;
static int value = 10;
static int *pointer;
프로그램 시작 시 개념적으로 다음 상태를 가진다.
count → 0
value → 10
pointer → 널 포인터
C의 정적 초기화식은 번역 시점에 처리할 수 있는 형태로 제한된다. C++처럼 일반적인 사용자 함수 호출을 이용한 전역 객체 생성자가 언어 차원에서 자동 실행되는 구조는 없다.
static int value = calculate();
일반적인 C에서는 calculate()가 정수 상수 표현식이 아니므로 정적 저장 객체의 초기화식으로 사용할 수 없다.
런타임 초기화가 필요하면 main이나 별도의 초기화 함수에서 수행한다.
static int value;
static void initialize(void) {
value = calculate();
}
int main(void) {
initialize();
return 0;
}
컴파일러 확장과 플랫폼별 초기화 섹션은 별도로 존재할 수 있다.
호스트 환경
호스트 구현은 일반적으로 운영체제와 표준 라이브러리를 사용할 수 있는 완전한 프로그램 환경이다.
호스트 환경에서는 프로그램 시작 시 호출되는 함수의 이름이 main이다.
대표적인 형태는 다음과 같다.
int main(void) {
return 0;
}
명령행 인수를 받을 수 있다.
int main(
int argc,
char *argv[]
) {
return 0;
}
동등한 포인터 표기를 사용할 수도 있다.
int main(
int argc,
char **argv
) {
return 0;
}
C 표준은 구현체가 다른 구현 정의 형태의 main을 제공하는 것도 허용한다.[55]
argc와 argv
두 매개변수를 사용하는 main에서 argc는 프로그램에 전달된 문자열의 개수를 나타내고, argv는 문자열 포인터 배열을 가리킨다.
argc = 3
argv[0] → "application"
argv[1] → "--output"
argv[2] → "file.txt"
argv[3] → NULL
C 표준은 다음을 규정한다.
argc는 음수가 아님argv[argc]는 널 포인터argc가 0보다 크면argv[0]은 프로그램 이름을 나타내거나 빈 문자열- 이후 문자열은 실행 환경에서 제공한 프로그램 매개변수
argc,argv와 각 인수 문자열은 프로그램에서 수정 가능- 해당 값은 프로그램 시작부터 종료까지 저장된 값을 유지
간단한 인수 출력 프로그램은 다음과 같다.
#include <stdio.h>
int main(
int argc,
char *argv[]
) {
for (int index = 0; index < argc; ++index) {
printf(
"argv[%d] = %s\n",
index,
argv[index]
);
}
return 0;
}
명령행을 문자열 배열로 나누는 방식은 운영체제와 실행 환경에 따라 다를 수 있다. 셸이 따옴표와 와일드카드, 환경 변수 등을 먼저 처리할 수도 있다.
환경 변수
ISO C 표준 라이브러리는 getenv를 통해 실행 환경의 문자열 값을 조회할 수 있게 한다.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
const char *home = getenv("HOME");
if (home != NULL) {
printf("%s\n", home);
}
return 0;
}
환경 변수의 이름과 의미, 대소문자 규칙은 플랫폼이 정한다.
일부 구현은 세 번째 main 매개변수로 환경 포인터를 제공하지만 ISO C의 표준 main 형태는 아니다.
int main(
int argc,
char **argv,
char **environment
)
이식 가능한 C 문서에서는 구현체 확장으로 구분해야 한다.
표준 스트림
호스트 환경에서는 프로그램 시작 시 세 개의 텍스트 스트림이 미리 열려 있다.
- 표준 입력
stdin - 표준 출력
stdout - 표준 오류
stderr
#include <stdio.h>
int main(void) {
fprintf(stdout, "normal output\n");
fprintf(stderr, "error output\n");
return 0;
}
스트림이 실제 터미널과 파일, 파이프나 다른 장치 중 무엇에 연결되는지는 실행 환경이 정할 수 있다.
application
↓ stdout
터미널
application
↓ stdout redirection
output.txt
application
↓ pipe
another application
독립 실행 환경
독립 실행 구현은 운영체제가 없거나 완전한 호스트 환경을 제공하지 않는 실행 환경이다.
대표적인 예는 다음과 같다.
- 부트로더
- 운영체제 커널
- 마이크로컨트롤러 펌웨어
- 초기 런타임
- 특수 목적 프로세서
- 일부 실시간 시스템
독립 실행 환경에서는 프로그램 시작 시 호출되는 함수의 이름과 타입이 구현 정의다.
void reset_handler(void) {
/* 하드웨어 초기화 */
}
다른 시스템에서는 다음과 같은 시작점을 사용할 수 있다.
void kernel_main(void) {
/* 커널 초기화 */
}
ISO C는 독립 실행 환경에서 반드시 main을 요구하지 않는다. 제공되는 표준 라이브러리 기능도 호스트 환경보다 제한될 수 있고, 종료의 효과도 구현 정의다.[56]
독립 실행 시작 과정
마이크로컨트롤러의 대표적인 시작 과정은 다음과 같을 수 있다.
전원 인가 또는 리셋
↓
리셋 벡터
↓
초기 스택 포인터 설정
↓
.data 초기값을 플래시에서 RAM으로 복사
↓
.bss 영역을 0으로 초기화
↓
클록과 하드웨어 초기화
↓
사용자 시작 함수 호출
이 과정은 C 표준이 직접 구현해 주는 것이 아니다. 시작 어셈블리와 링커 스크립트, 컴파일러 런타임과 하드웨어 규격이 함께 구성한다.
startup.s
linker.ld
main.c
운영체제 커널 역시 기존 호스트 프로그램과 다른 진입 과정과 메모리 환경을 가진다.
링크 스크립트
링커 스크립트는 코드와 데이터를 대상 메모리의 특정 위치에 배치하도록 지시할 수 있다.
임베디드 장치에서는 다음과 같은 메모리 영역이 있을 수 있다.
FLASH
시작 주소: 0x08000000
크기: 512 KiB
RAM
시작 주소: 0x20000000
크기: 128 KiB
개념적인 배치는 다음과 같다.
.text → FLASH
.rodata → FLASH
.data → FLASH에 초기값, RAM에서 실행
.bss → RAM
stack → RAM
heap → RAM
ISO C는 링커 스크립트 문법이나 메모리 주소를 규정하지 않는다. 사용하는 링커와 플랫폼 문서를 따라야 한다.
main 호출
호스트 환경에서 프로그램 런타임은 필요한 초기화를 마친 뒤 main을 호출한다.
실행 환경
↓
main(argc, argv)
main도 문법상 C 함수이므로 다른 함수를 호출하고 객체를 만들며 표준 라이브러리를 사용할 수 있다.
#include <stdio.h>
#include <stdlib.h>
static int application_run(void) {
puts("running");
return EXIT_SUCCESS;
}
int main(void) {
return application_run();
}
일반적인 프로그램에서는 main을 프로그램의 구성과 종료 상태를 조정하는 얇은 진입점으로 두고 실제 로직을 별도 함수와 모듈로 나눌 수 있다.
프로그램 실행
프로그램이 시작되면 C 추상 기계의 규칙에 따라 표현식과 문장이 평가된다.
int main(void) {
int left = 10;
int right = 20;
int result = left + right;
return result == 30 ? 0 : 1;
}
실제 프로세서는 컴파일된 기계 명령을 실행하지만, C 프로그램의 의미는 특정 명령어 순서가 아니라 표준의 추상 의미를 기준으로 판단한다.
실행 과정에는 다음이 포함될 수 있다.
- 함수 호출
- 자동 객체 생성과 소멸
- 정적 객체 접근
- 동적 메모리 할당
- 입출력
- 시스템 라이브러리 호출
- 스레드 생성
- 원자적 연산
- 신호 처리
- 운영체제 API 호출
ISO C 외부 기능을 호출하면 프로그램 의미의 일부가 해당 플랫폼 API에 의존한다.
함수 호출 과정
일반적인 기계 수준의 함수 호출은 다음 과정을 포함할 수 있다.
인수 계산
↓
ABI에 따라 레지스터나 스택에 배치
↓
반환 위치 저장
↓
호출 대상 이동
↓
필요한 레지스터 보존
↓
지역 객체 공간 준비
↓
함수 본문 실행
↓
반환값 배치
↓
호출자 복귀
다음 함수가 있다고 하자.
int add(int left, int right) {
return left + right;
}
호출은 다음과 같다.
int result = add(10, 20);
최적화가 비활성화된 경우 실제 호출 명령이 만들어질 수 있다. 최적화된 경우 함수가 인라인화되어 호출 자체가 사라질 수 있다.
result = 30
함수 호출 스택은 흔한 구현이지만 ISO C가 특정한 스택 프레임 구조를 규정하지 않는다.
시스템 호출과 라이브러리 호출
C 표준 라이브러리 함수가 항상 직접 운영체제 시스템 호출 하나에 대응하는 것은 아니다.
printf("hello\n");
일반적인 구현에서는 다음과 같은 계층을 거칠 수 있다.
printf
↓
형식 문자열 분석
↓
표준 입출력 버퍼
↓
C 라이브러리 내부 함수
↓
운영체제 쓰기 API
↓
커널
↓
파일·터미널·파이프
버퍼링 때문에 printf 호출 직후 실제 장치에 데이터가 기록되지 않을 수 있다.
printf("loading...");
줄바꿈이나 fflush, 스트림 종료 시점에 출력될 수 있다.
printf("loading...");
fflush(stdout);
정상 종료
호스트 환경에서 최초의 main 호출이 값을 반환하면 해당 값을 인수로 exit를 호출한 것과 같은 효과를 가진다.
int main(void) {
return 0;
}
다음과 같은 의미다.
int main(void) {
exit(0);
}
main 함수의 끝에 도달하면 0을 반환한 것처럼 처리된다.
int main(void) {
}
이는 다음과 같다.
int main(void) {
return 0;
}
이 특별 규칙은 일반적인 int 반환 함수 전체에 적용되지 않는다.
int calculate(void) {
}
값을 반환해야 하는 일반 함수가 유효한 반환 없이 끝나고 호출자가 그 값을 사용하면 정의되지 않은 동작이 발생할 수 있다.
종료 상태
프로그램은 종료 상태를 실행 환경에 반환할 수 있다.
#include <stdlib.h>
int main(void) {
return EXIT_SUCCESS;
}
실패 상태는 다음처럼 반환할 수 있다.
#include <stdlib.h>
int main(void) {
return EXIT_FAILURE;
}
0 또는 EXIT_SUCCESS는 성공 종료에 대응하고, EXIT_FAILURE는 실패 종료에 대응한다. 운영체제가 실제로 사용하는 숫자와 외부 표시 방법은 구현 정의다.
임의의 다른 정수 값을 반환했을 때의 외부 의미도 실행 환경에 따라 달라질 수 있다.
exit
exit는 정상적인 프로그램 종료를 수행한다.
#include <stdlib.h>
void fatal_error(void) {
exit(EXIT_FAILURE);
}
C23 표준에서 정상적인 exit 과정은 개념적으로 다음 순서를 따른다.
atexit 등록 함수 역순 호출
↓
출력 버퍼가 남은 스트림 flush
↓
열린 스트림 닫기
↓
tmpfile 임시 파일 제거
↓
실행 환경으로 제어 반환
exit는 호출자에게 돌아오지 않는다.[57]
atexit
atexit는 정상 종료 시 호출할 함수를 등록한다.
#include <stdio.h>
#include <stdlib.h>
static void cleanup(void) {
puts("cleanup");
}
int main(void) {
if (atexit(cleanup) != 0) {
return EXIT_FAILURE;
}
puts("running");
return EXIT_SUCCESS;
}
출력 흐름은 다음과 같을 수 있다.
running
cleanup
여러 함수를 등록하면 일반적으로 등록의 역순으로 호출된다.
atexit(cleanup_first);
atexit(cleanup_second);
호출 순서는 다음과 같다.
cleanup_second
cleanup_first
atexit는 현재 함수 범위를 벗어날 때 자원을 자동으로 정리하는 기능이 아니다. 프로그램의 정상 종료 시점에만 호출된다.
quick_exit
quick_exit는 일반적인 exit보다 제한된 종료 절차를 수행한다.
#include <stdlib.h>
quick_exit(EXIT_SUCCESS);
at_quick_exit으로 등록된 함수는 호출되지만, atexit 등록 함수는 호출되지 않는다.
#include <stdlib.h>
static void quick_cleanup(void) {
/* 제한된 정리 */
}
int main(void) {
at_quick_exit(quick_cleanup);
quick_exit(EXIT_SUCCESS);
}
스트림 처리와 다른 종료 효과는 exit와 다르므로 일반적인 정상 종료와 동일하다고 보면 안 된다.
_Exit
_Exit는 atexit이나 at_quick_exit 함수 호출 없이 실행 환경으로 제어를 반환한다.
#include <stdlib.h>
_Exit(EXIT_FAILURE);
표준 입출력 버퍼가 일반적인 정상 종료처럼 처리된다고 기대해서는 안 된다.
긴급 종료나 하위 수준 런타임 처리에서 사용할 수 있지만, 보통의 응용 프로그램 종료에는 return이나 exit가 적합하다.
abort
abort는 비정상 종료를 발생시킨다.
#include <stdlib.h>
void unreachable_state(void) {
abort();
}
일반적인 정상 종료 절차와 다르며, 열려 있는 스트림의 버퍼가 기록되거나 파일이 닫힌다고 보장되지 않는다.
abort
↓
SIGABRT와 관련된 처리
↓
비정상 종료
실행 환경은 코어 덤프나 오류 보고서를 생성할 수 있지만 이는 플랫폼 정책이다.
프로세스 종료와 자원
운영체제는 프로세스가 종료되면 일반적으로 해당 프로세스의 주소 공간과 커널 자원 일부를 회수한다.
그러나 ISO C의 exit 의미와 운영체제의 프로세스 자원 회수는 구분해야 한다.
다음과 같은 자원은 프로그램이 직접 정리해야 할 수 있다.
- 파일에 기록되지 않은 응용 데이터
- 데이터베이스 트랜잭션
- 다른 프로세스와 공유한 상태
- 임시 파일
- 장치 상태
- 네트워크 프로토콜 종료
- 잠금 파일
- 외부 서비스 세션
- 공유 메모리 내부 구조
운영체제가 메모리를 회수한다고 해서 프로그램이 논리적 정리를 생략해도 된다는 뜻은 아니다.
신호와 비정상 실행 흐름
호스트 환경에서는 신호가 프로그램의 일반적인 제어 흐름을 중단할 수 있다.
#include <signal.h>
static volatile sig_atomic_t interrupted;
static void handle_signal(int signal) {
interrupted = signal;
}
int main(void) {
signal(SIGINT, handle_signal);
while (!interrupted) {
/* 작업 */
}
return 0;
}
신호 처리기에서 호출할 수 있는 함수와 접근 가능한 객체에는 엄격한 제한이 있다. 일반적인 함수가 재진입 가능하거나 신호 안전하다고 가정해서는 안 된다.
POSIX는 ISO C보다 더 구체적인 신호와 비동기 신호 안전 함수 규칙을 제공하지만 별도 표준이다.
디버그 정보
컴파일러는 소스 코드와 기계 명령의 대응을 나타내는 디버그 정보를 객체 파일과 실행 파일에 넣을 수 있다.
소스 파일 이름
줄 번호
함수 이름
지역 변수
타입 정보
호출 프레임 정보
GCC와 Clang 계열에서는 -g를 사용한다.
clang -g main.c -o application
대표적인 디버그 정보 형식에는 DWARF와 CodeView가 있다.
최적화된 코드에서는 다음 현상이 발생할 수 있다.
- 변수가 제거됨
- 문장 순서와 명령 순서가 달라짐
- 함수가 인라인화됨
- 한 소스 줄이 여러 명령 범위에 대응함
- 여러 소스 문장이 하나의 명령으로 합쳐짐
따라서 높은 최적화 수준에서는 소스 줄 단위 디버깅이 직관적이지 않을 수 있다.
디버그 빌드와 릴리스 빌드
일반적인 개발 구성은 다음처럼 나뉠 수 있다.
디버그 빌드
-O0 또는 낮은 최적화
-g
추가 검사
assert 활성화
릴리스 빌드
-O2 또는 -O3
필요한 보안 옵션
불필요한 심벌 제거
배포 설정
하지만 디버그와 릴리스는 C 표준 개념이 아니라 프로젝트와 빌드 시스템의 구성이다.
최적화에서만 나타나는 오류는 정의되지 않은 동작이나 데이터 경쟁, 초기화되지 않은 값과 별칭 위반의 징후일 수 있다. 단순히 최적화를 끄는 것으로 해결해서는 안 된다.
별도 컴파일
여러 소스 파일을 별도 컴파일하면 변경되지 않은 파일을 다시 번역하지 않아도 된다.
main.c 수정
↓
main.o만 다시 생성
↓
기존 renderer.o 재사용
기존 network.o 재사용
↓
다시 링크
이 방식은 대규모 프로젝트의 빌드 시간을 줄인다.
그러나 헤더가 변경되면 해당 헤더를 포함한 모든 번역 단위를 다시 컴파일해야 할 수 있다.
common.h 변경
↓
main.c 다시 컴파일
renderer.c 다시 컴파일
network.c 다시 컴파일
빌드 시스템은 헤더 의존성을 추적해야 한다.
증분 빌드
증분 빌드는 변경된 소스와 그 영향을 받는 대상만 다시 생성한다.
소스 수정 시간
헤더 의존 관계
컴파일 옵션
도구 버전
생성 파일
이 정보를 바탕으로 재빌드 대상을 판단한다.
대표적인 빌드 시스템에는 다음이 있다.
- Make
- CMake
- Ninja
- Meson
- Bazel
- 자체 빌드 시스템
빌드 시스템은 컴파일러 자체가 아니며, 필요한 명령과 의존 관계를 구성하고 실행한다.
컴파일러 드라이버
사용자가 clang이나 gcc를 실행하면 하나의 프로그램이 모든 변환을 직접 수행하는 것처럼 보인다.
clang main.c -o application
실제로 드라이버는 다음 도구나 내부 단계를 조정한다.
컴파일러 드라이버
├── 전처리기
├── C 프런트엔드
├── 최적화기
├── 코드 생성기
├── 어셈블러
└── 링커
Clang은 통합 어셈블러를 내부적으로 사용할 수 있고, 외부 시스템 링커를 실행할 수도 있다. GCC 역시 대상과 구성에 따라 여러 내부 프로그램과 외부 도구를 호출한다.
Clang의 -### 옵션으로 실제 실행할 명령을 출력하고 실행은 하지 않을 수 있다.
clang -### main.c -o application
각 단계에서 멈추기
GCC와 Clang 호환 드라이버에서는 대표적으로 다음 옵션을 사용할 수 있다.
| 명령 | 결과 |
|---|---|
-E | 전처리 결과 |
-S | 어셈블리 출력 |
-c | 객체 파일 |
| 옵션 없음 | 링크까지 수행 |
clang -E main.c -o main.i
clang -S main.c -o main.s
clang -c main.c -o main.o
clang main.o -o application
단계를 분리하면 오류가 어느 과정에서 발생했는지 분석하기 쉽다.
오류 발생 단계
오류는 실행 과정의 서로 다른 위치에서 발생할 수 있다.
전처리 오류
→ 헤더를 찾지 못함
→ 매크로 오류
→ 조건부 컴파일 오류
컴파일 오류
→ 문법 오류
→ 타입 불일치
→ 선언 위반
어셈블 오류
→ 잘못된 인라인 어셈블리
→ 대상 명령어 문제
링크 오류
→ 심벌 정의 없음
→ 중복 정의
→ 라이브러리 누락
로드 오류
→ 공유 라이브러리 없음
→ 실행 형식 불일치
→ 권한 문제
실행 오류
→ 잘못된 포인터
→ 정의되지 않은 동작
→ 논리 오류
→ 외부 자원 오류
예를 들어 다음 오류는 컴파일 단계가 아니라 링크 단계에서 나타난다.
int calculate(int value);
int main(void) {
return calculate(10);
}
calculate 선언은 있으므로 컴파일은 가능하지만 정의를 링크하지 않으면 다음과 같은 오류가 발생한다.
undefined reference to `calculate`
헤더를 찾지 못하는 오류
다음 오류는 전처리 단계에서 발생한다.
#include "missing.h"
fatal error: 'missing.h' file not found
헤더 탐색 경로를 추가할 수 있다.
clang -Iinclude \
main.c \
-o application
-I의 구체적인 동작과 우선순위는 컴파일러 문서를 따라야 한다.
라이브러리를 찾지 못하는 오류
링커가 라이브러리를 찾지 못하면 링크 단계에서 실패한다.
clang main.o \
-lexample \
-o application
cannot find -lexample
라이브러리 검색 경로를 지정할 수 있다.
clang main.o \
-L/path/to/library \
-lexample \
-o application
실행 파일 생성에는 성공했지만 실행 시 공유 라이브러리를 찾지 못할 수도 있다.
error while loading shared libraries
링크 시간 검색 경로와 실행 시간 동적 라이브러리 검색 경로는 서로 다를 수 있다.
크로스 컴파일
교차 컴파일에서는 현재 실행 중인 컴퓨터가 아닌 다른 대상의 코드와 실행 파일을 만든다.
호스트
x86-64 Linux
대상
AArch64 Linux
Clang에서는 다음과 같은 형태를 사용할 수 있다.
clang \
--target=aarch64-linux-gnu \
--sysroot=/path/to/sysroot \
main.c \
-o application
완전한 교차 도구체인에는 다음이 필요할 수 있다.
- 대상 헤더
- 대상 표준 라이브러리
- 대상 컴파일러 런타임
- 대상 링커
- 대상 시작 코드
- 대상 sysroot
- 대상 ABI 설정
Clang 공식 문서도 대상 triple과 sysroot, CPU 및 ABI 설정을 교차 컴파일의 주요 요소로 설명한다.[58]
컴파일러와 링커의 분리
C 컴파일러와 링커는 서로 다른 도구일 수 있다.
clang
→ C 소스를 객체 파일로 변환
ld 또는 lld
→ 객체 파일을 실행 파일로 결합
그러나 사용자가 직접 ld를 호출하면 컴파일러 드라이버가 자동으로 추가하는 다음 요소를 빠뜨릴 수 있다.
- 시작 객체 파일
- C 라이브러리
- 컴파일러 런타임
- 동적 링커 경로
- 대상별 라이브러리 경로
- 기본 링크 옵션
일반적인 C 프로그램은 컴파일러 드라이버를 통해 링크하는 편이 안전하다.
clang main.o -o application
직접 링커를 사용하는 것은 커널과 부트로더, 특수 런타임처럼 전체 구성을 직접 통제할 때 필요할 수 있다.
재현 가능한 빌드
같은 소스와 도구 설정으로 동일한 결과물을 반복해서 생성할 수 있는 특성을 재현 가능한 빌드라고 한다.
결과를 달라지게 할 수 있는 요소에는 다음이 있다.
- 빌드 날짜와 시간
- 소스 절대 경로
- 파일 순서
- 환경 변수
- 임시 파일 이름
- 병렬 링크 순서
- 컴파일러 버전
- 라이브러리 버전
- 무작위 빌드 식별자
- 지역과 문자 인코딩
다음 매크로는 빌드마다 값이 달라질 수 있다.
const char *build_date = __DATE__;
const char *build_time = __TIME__;
재현 가능한 빌드가 필요하면 이러한 값을 피하거나 빌드 시스템에서 고정된 메타데이터를 제공해야 한다.
실행 과정의 보안
번역과 링크, 로딩 과정은 보안에도 영향을 준다.
대표적인 방어 기법에는 다음이 있다.
- 스택 보호
- 위치 독립 실행 파일
- 주소 공간 배치 난수화
- 읽기 전용 재배치
- 비실행 데이터 영역
- 제어 흐름 보호
- 심벌 가시성 제한
- 포트폴리오 기반 강화 옵션
- 정의되지 않은 동작 검사
- 소스 무결성 검증
- 신뢰 가능한 라이브러리 경로
컴파일러와 링커 옵션 예시는 다음과 같다.
-fstack-protector-strong
-fPIE
-pie
-Wl,-z,relro
-Wl,-z,now
지원 여부와 의미는 운영체제와 도구체인에 따라 달라진다.
보안 옵션은 메모리 안전하지 않은 코드를 자동으로 안전하게 만드는 수단이 아니다. 오류의 악용 가능성을 줄이는 방어 계층이다.
실행 과정의 종합
C 프로그램의 실행은 소스 파일을 기계어로 단순 치환하는 과정이 아니다. 소스 문자는 여러 개념적 번역 단계를 거쳐 토큰과 번역 단위가 되고, 컴파일러는 문법과 의미를 분석하여 대상 시스템의 코드와 데이터로 변환한다.
소스 문자
↓
전처리 토큰
↓
C 토큰과 번역 단위
↓
중간 표현
↓
대상 기계 코드
↓
객체 파일
↓
프로그램 이미지
객체 파일은 심벌과 재배치 정보를 가진 중간 결과이며, 링커가 여러 번역 단위와 라이브러리의 외부 참조를 해결해 실행 파일이나 공유 라이브러리를 만든다.
호스트 환경에서는 운영체제 로더와 동적 링커, C 런타임이 실행 파일과 라이브러리를 준비하고 main을 호출한다. 독립 실행 환경에서는 시작 함수와 메모리 초기화, 종료 효과가 구현체와 하드웨어에 따라 달라질 수 있다.
호스트 환경
운영체제 → 로더 → 런타임 → main
독립 실행 환경
리셋·부팅 → 시작 코드 → 구현 정의 시작 함수
main의 반환이나 exit는 정상 종료 절차를 수행하고 실행 환경으로 상태를 돌려준다. 반면 _Exit, quick_exit, abort는 서로 다른 정리와 종료 의미를 가진다.
C의 실행 과정을 정확하게 이해하려면 ISO C가 규정하는 번역·실행 의미와 컴파일러·링커·운영체제가 구현하는 구체적인 도구 단계를 구분해야 한다. 전자는 이식 가능한 프로그램의 의미를 정하고, 후자는 그 의미를 특정 프로세서와 운영체제에서 실제로 실현한다.
C 표준과 표준 라이브러리
C는 하나의 특정 컴파일러나 운영체제 구현으로 정의되는 언어가 아니다. C의 문법과 의미, 번역 및 실행 환경, 구현체가 제공해야 하는 최소 기능과 표준 라이브러리는 국제 표준인 ISO/IEC 9899에서 규정한다.
C 표준은 프로그램의 소스 형태만 설명하는 문법 설명서가 아니다. 표준은 다음 사항을 함께 규정한다.
- C 프로그램을 구성하는 문자와 토큰
- 선언과 타입, 표현식과 문장의 문법
- 프로그램을 해석하는 의미 규칙
- 번역과 실행의 개념적 과정
- 객체와 메모리, 평가 순서와 동시성
- 적합한 구현체가 제공해야 하는 기능
- 구현체가 지원해야 하는 최소 한계
- 프로그램의 입력과 출력 표현
- 표준 헤더와 라이브러리 인터페이스
- 정의된 동작과 구현 정의·미지정·정의되지 않은 동작
- 호스트 구현과 독립 실행 구현의 요구 사항
ISO는 C 표준의 목적을 다양한 데이터 처리 시스템 사이에서 C 프로그램의 이식성을 높이는 것으로 설명한다. 표준은 구현자와 프로그래머 모두를 대상으로 하며, 프로그램 표현과 문법적 제약, 의미 규칙, 입출력 데이터 표현과 적합한 구현체의 제한을 규정한다.[59]
ISO/IEC 9899
├── 일반 용어와 적합성
├── 번역 및 실행 환경
├── C 언어
├── 표준 라이브러리
├── 언어 문법 요약
├── 라이브러리 요약
├── 이식성과 구현 한계
└── 부속서
C 표준에서 언어와 라이브러리는 서로 분리된 부분으로 규정되지만 하나의 표준을 구성한다. 언어 문법만 구현하고 표준 라이브러리를 전혀 제공하지 않는 환경은 완전한 호스트 구현이 될 수 없다. 반대로 표준 라이브러리 함수도 C 언어의 타입과 객체, 포인터와 실행 규칙 위에서 동작한다.
ISO/IEC 9899
C 국제 표준의 식별자는 ISO/IEC 9899다. ISO와 IEC의 공동 기술위원회인 ISO/IEC JTC 1 산하 SC 22가 프로그래밍 언어 표준을 담당하며, C 표준의 실질적인 개발과 유지 관리는 WG14가 수행한다.
ISO / IEC
↓
ISO/IEC JTC 1
↓
SC 22
↓
WG14
↓
ISO/IEC 9899
WG14는 기존 표준의 결함 보고를 검토하고, 다음 표준 판에 들어갈 제안을 논의하며, 위원회 초안과 공개 작업 문서를 관리한다. WG14는 자신을 ISO/IEC 9899와 관련 기술 명세를 관리하는 C 언어 위원회로 설명한다.[60]
표준은 일정한 개정 주기를 거쳐 새로운 판으로 발행된다. 새로운 판이 발행되면 이전 판은 국제 표준으로서 대체되지만, 컴파일러와 프로젝트는 호환성을 위해 과거 표준 모드를 계속 제공할 수 있다.
표준 판
C의 주요 표준 판은 다음과 같다.
| 통칭 | 공식 문서 | 주요 성격 |
|---|---|---|
| C89 | ANSI X3.159-1989 | 최초의 공식 ANSI C 표준 |
| C90 | ISO/IEC 9899:1990 | C89를 국제 표준으로 채택 |
| C95 | ISO/IEC 9899:1990/Amd 1:1995 | C90 개정안 |
| C99 | ISO/IEC 9899:1999 | 대규모 언어·라이브러리 확장 |
| C11 | ISO/IEC 9899:2011 | 메모리 모델, 원자 연산과 스레드 |
| C17 | ISO/IEC 9899:2018 | C11 결함 수정과 안정화 |
| C23 | ISO/IEC 9899:2024 | 현대 문법·라이브러리 확장 |
C89와 C90은 본질적으로 같은 언어 판을 가리킨다. C89는 미국 ANSI 표준의 발행 연도이고, C90은 이를 ISO가 국제 표준으로 채택한 연도를 기준으로 한 이름이다.
C95는 독립된 전면 개정판이 아니라 C90에 적용된 Amendment 1과 기술 수정 사항을 묶어 부르는 비공식 명칭이다.
C17은 위원회 작업 시기의 이름이며 공식 문서는 2018년에 발행되었기 때문에 C18이라고 부르기도 한다. WG14의 프로젝트 기록에서는 주로 C17이라는 이름을 사용한다.[61]
현재 발행된 최신 표준은 ISO/IEC 9899:2024이며, 개발 과정과 언어 판의 통칭은 C23이다.[62]
표준과 언어 모드
컴파일러는 여러 C 표준 판을 선택할 수 있는 옵션을 제공할 수 있다.
GCC와 Clang 계열에서는 일반적으로 -std 옵션을 사용한다.
-std=c89
-std=c90
-std=c99
-std=c11
-std=c17
-std=c23
컴파일러별 확장을 함께 허용하는 GNU 방언도 존재한다.
-std=gnu89
-std=gnu99
-std=gnu11
-std=gnu17
-std=gnu23
c17과 gnu17은 같은 것이 아니다.
c17
→ ISO C17 중심
→ 비표준 확장을 제한하거나 진단
gnu17
→ C17 기반
→ GNU 확장 기능을 함께 허용
표준 모드를 선택한다고 해당 컴파일러와 시스템 라이브러리가 표준의 모든 선택 기능을 완전하게 제공한다고 자동으로 보장되지는 않는다. 구현체가 지원하는 기능과 결함, 라이브러리 버전 및 대상 환경을 확인해야 한다.
표준 판 확인
C95 이후의 구현체는 __STDC_VERSION__ 매크로로 지원하는 표준 판을 나타낼 수 있다.
#include <stdio.h>
int main(void) {
#ifdef __STDC_VERSION__
printf("%ld\n", __STDC_VERSION__);
#else
puts("pre-C95 or nonconforming implementation");
#endif
return 0;
}
대표적인 값은 다음과 같다.
| 표준 판 | __STDC_VERSION__ |
|---|---|
| C95 | 199409L |
| C99 | 199901L |
| C11 | 201112L |
| C17 | 201710L |
| C23 | 202311L |
특정 표준 판 이상을 요구할 수 있다.
#if !defined(__STDC_VERSION__) || \
__STDC_VERSION__ < 201710L
#error "C17 or later is required"
#endif
C23 기능을 조건부로 사용할 수 있다.
#if defined(__STDC_VERSION__) && \
__STDC_VERSION__ >= 202311L
constexpr unsigned int buffer_size = 4096;
#else
enum {
buffer_size = 4096
};
#endif
__STDC_VERSION__은 컴파일 모드를 나타내는 중요한 기준이지만, 선택적 기능과 구현체 확장의 존재까지 모두 나타내지는 않는다.
적합성
C 표준은 프로그램과 구현체의 적합성을 구분한다.
주요 개념은 다음과 같다.
- 엄격히 적합한 프로그램
- 적합한 프로그램
- 적합한 호스트 구현
- 적합한 독립 실행 구현
엄격히 적합한 프로그램은 표준에 규정된 언어와 라이브러리 기능만 사용하고, 미지정·정의되지 않은 동작이나 구현 정의 결과에 출력이 의존하지 않으며, 표준이 요구하는 최소 구현 한계를 넘지 않는 프로그램이다.
엄격히 적합한 프로그램
├── ISO C 기능만 사용
├── 비표준 확장에 의존하지 않음
├── 정의되지 않은 동작에 의존하지 않음
├── 미지정 결과에 출력이 의존하지 않음
└── 최소 구현 한계를 초과하지 않음
적합한 프로그램은 적합한 구현체가 받아들이는 프로그램이다. 구현체별 확장과 플랫폼 API를 사용할 수도 있으므로 반드시 이식 가능하지는 않다.
#ifdef __linux__
#include <unistd.h>
#endif
이 프로그램은 Linux 구현에서 유효하게 사용할 수 있지만 unistd.h는 ISO C 표준 헤더가 아니다.
C23 표준은 호스트 구현과 독립 실행 구현이라는 두 가지 적합한 구현 형태를 정의한다.[63]
호스트 구현
호스트 구현은 일반적으로 운영체제 위에서 실행되는 완전한 C 프로그램 환경이다.
호스트 구현은 다음 요소를 제공한다.
main을 통한 프로그램 시작- 전체 표준 라이브러리
- 표준 입력과 출력, 오류 스트림
- 파일과 실행 환경
- 정상 종료 처리
- 표준이 요구하는 프로그램 기능
#include <stdio.h>
#include <stdlib.h>
int main(void) {
puts("hosted C program");
return EXIT_SUCCESS;
}
적합한 호스트 구현은 엄격히 적합한 모든 프로그램을 받아들여야 한다.
다만 표준 라이브러리가 반드시 하나의 동적 라이브러리 파일이나 특정 libc 구현으로 제공되어야 하는 것은 아니다. 컴파일러 내장 함수와 정적 라이브러리, 운영체제 API와 런타임 코드가 함께 표준 인터페이스를 구현할 수 있다.
독립 실행 구현
독립 실행 구현은 완전한 운영체제와 일반적인 프로그램 실행 환경이 없을 수 있는 환경이다.
대표적인 예는 다음과 같다.
- 운영체제 커널
- 부트로더
- 마이크로컨트롤러 펌웨어
- 실시간 제어 프로그램
- 제한된 임베디드 시스템
- 특수 목적 프로세서 런타임
void reset_handler(void) {
initialize_memory();
initialize_hardware();
application_run();
for (;;) {
}
}
독립 실행 구현은 표준 라이브러리 전체를 필수로 제공할 필요가 없다. C23은 독립 실행 구현이 반드시 지원해야 하는 헤더와 기능 범위를 별도로 규정한다.
C23에서 기본적으로 요구되는 독립 실행 헤더에는 다음이 포함된다.
<float.h>
<iso646.h>
<limits.h>
<stdalign.h>
<stdarg.h>
<stdbit.h>
<stdbool.h>
<stddef.h>
<stdint.h>
<stdnoreturn.h>
또한 C23은 <string.h>의 대부분과 <stdlib.h>의 선택된 메모리 정렬 기능도 독립 실행 구현의 적합성 범위에 포함한다. 부동소수점 관련 조건 매크로를 제공하는 구현에서는 제한된 <fenv.h>, <math.h>와 변환 함수도 사용할 수 있다.[64]
독립 실행 구현이 해당 기능만 제공해야 한다는 뜻은 아니다. 구현체는 전체 표준 라이브러리나 자체 확장을 추가로 제공할 수 있다.
구현 정의와 문서화
C는 모든 하드웨어와 운영체제의 세부 사항을 하나로 고정하지 않는다. 일부 특성은 구현체가 선택하되 반드시 문서화하도록 한다.
대표적인 구현 정의 사항은 다음과 같다.
- 기본
char의 부호 - 기본 정수 타입의 크기
- 오른쪽 시프트의 일부 결과
- 소스 및 실행 문자 집합
- 파일과 스트림의 세부 표현
- 일부 정수 변환 결과
- 구조체 정렬과 패딩
main의 추가 형태- 신호와 실행 환경의 효과
- 표준 라이브러리의 일부 환경 동작
적합한 구현체는 구현 정의와 지역화 특성, 제공하는 확장을 설명하는 문서를 함께 제공해야 한다.[65]
ISO C 표준
→ 구현체가 선택 가능한 범위를 정의
컴파일러·플랫폼 문서
→ 실제 선택 결과를 설명
이식 가능한 프로그램을 작성하려면 표준 문서와 함께 대상 컴파일러, ABI와 표준 라이브러리 문서를 확인해야 한다.
진단
적합한 구현체는 번역 단위가 C 문법 규칙이나 제약을 위반하면 최소 하나의 진단 메시지를 출력해야 한다.
int main(void) {
int *pointer = 10;
return pointer;
}
타입 제약을 위반하므로 구현체는 진단해야 한다.
그러나 표준이 진단을 요구한다고 해서 반드시 번역을 중단해야 하는 것은 아니다. 컴파일러는 경고를 출력한 뒤 확장 기능으로 실행 파일을 만들 수도 있다.
진단 필요
≠
반드시 컴파일 중단
#error 지시문은 조건부로 제외되지 않은 경우 성공적인 번역을 허용하지 않는다.
#ifndef REQUIRED_PLATFORM
#error "REQUIRED_PLATFORM is not defined"
#endif
정의되지 않은 동작은 일반적으로 컴파일러가 반드시 진단해야 하는 대상이 아니다.
int values[4];
values[4] = 10;
컴파일러가 경고할 수도 있지만 표준이 모든 실행 경로의 범위 오류를 검출하도록 요구하지 않는다.
최소 구현 한계
C 표준은 적합한 구현체가 최소한 처리할 수 있어야 하는 프로그램 규모와 정밀도 등을 규정한다.
이러한 한계에는 다음과 같은 종류가 포함된다.
- 중첩 블록 깊이
- 조건부 전처리 중첩
- 함수 매개변수 개수
- 매크로 매개변수 개수
- 외부 식별자 수
- 하나의 선언에 포함되는 선언자
- 문자열 리터럴 길이
- 소스 줄의 문자 수
- 지원해야 하는 정수 범위
- 부동소수점의 최소 범위와 정밀도
표준의 최소치는 구현체가 그 이상을 지원하지 못하게 제한하는 값이 아니다.
표준 최소 한계
→ 모든 적합한 구현체가 적어도 지원
실제 구현 한계
→ 표준 최소치보다 클 수 있음
엄격히 적합한 프로그램은 표준이 요구하는 최소 한계를 초과하는 프로그램 구조에 의존하지 않아야 한다.
표준 라이브러리
C 표준 라이브러리는 C 프로그램이 여러 적합한 구현 환경에서 공통으로 사용할 수 있도록 정의된 타입과 매크로, 함수 및 헤더의 집합이다.
표준 라이브러리는 다음 영역을 제공한다.
표준 라이브러리
├── 진단
├── 문자와 문자열
├── 메모리 처리
├── 파일과 입출력
├── 수치 계산
├── 동적 메모리 관리
├── 시간과 날짜
├── 지역화
├── 오류 처리
├── 정수 타입과 한계
├── 가변 인수
├── 원자적 연산
├── 스레드와 동기화
└── 유니코드 문자 처리
표준 라이브러리가 언어에 내장된 특별 문법이라는 뜻은 아니다. 대부분은 헤더에서 선언되고 구현체의 라이브러리 또는 컴파일러 내장 기능으로 제공된다.
#include <stdio.h>
int main(void) {
printf("hello\n");
return 0;
}
printf는 C 문법 요소가 아니라 <stdio.h>에서 선언되는 표준 라이브러리 함수다.
반면 sizeof와 return, struct는 언어 문법이다.
size_t size = sizeof(int);
헤더
표준 라이브러리 선언은 표준 헤더를 통해 프로그램에 제공된다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
표준에서 헤더는 반드시 실제 파일 시스템의 텍스트 파일로 구현되어야 하는 것은 아니다. 구현체는 컴파일러 내부 데이터베이스나 다른 메커니즘으로 헤더 내용을 제공할 수 있다.
헤더는 다음 요소를 선언하거나 정의할 수 있다.
- 함수
- 객체
- 매크로
- 타입
- 열거 상수
- 구조체와
union - 구현 한계
#include <stddef.h>
size_t size;
ptrdiff_t difference;
nullptr_t null_value;
헤더는 일반적으로 외부 선언이나 정의가 가능한 위치에서 포함해야 한다.
표준 헤더 분류
C23의 표준 라이브러리 헤더를 기능별로 나누면 다음과 같다.
공통 타입과 언어 지원
| 헤더 | 주요 기능 |
|---|---|
<stddef.h> | size_t, ptrdiff_t, nullptr_t, offsetof |
<stdint.h> | 고정 폭 및 최소 폭 정수 타입 |
<inttypes.h> | 정수 형식 지정자와 변환 함수 |
<limits.h> | 정수 타입의 범위 |
<float.h> | 부동소수점 타입의 특성 |
<stdbool.h> | 불리언 호환 이름 |
<stdalign.h> | 정렬 관련 호환 이름 |
<stdnoreturn.h> | 반환하지 않는 함수 호환 이름 |
<stdarg.h> | 가변 인수 처리 |
<iso646.h> | 연산자의 대체 표기 |
진단과 오류
| 헤더 | 주요 기능 |
|---|---|
<assert.h> | 실행 시 진단 매크로 |
<errno.h> | 라이브러리 오류 상태 |
<setjmp.h> | 비지역 실행 이동 |
<signal.h> | 신호 처리 |
문자와 문자열
| 헤더 | 주요 기능 |
|---|---|
<ctype.h> | 좁은 문자 분류와 변환 |
<string.h> | 문자열과 메모리 블록 처리 |
<wchar.h> | 와이드 문자열과 다중 바이트 문자 |
<wctype.h> | 와이드 문자 분류와 변환 |
<uchar.h> | UTF 계열 문자 변환 |
입출력과 실행 환경
| 헤더 | 주요 기능 |
|---|---|
<stdio.h> | 파일과 스트림 입출력 |
<stdlib.h> | 메모리 할당, 변환, 정렬, 종료와 검색 |
<time.h> | 날짜와 시간 |
<locale.h> | 지역화 |
<threads.h> | 스레드와 동기화 |
수치 계산
| 헤더 | 주요 기능 |
|---|---|
<math.h> | 실수 수학 함수 |
<complex.h> | 복소수 |
<tgmath.h> | 타입 일반 수학 |
<fenv.h> | 부동소수점 환경 |
<stdbit.h> | 정수 비트 연산 |
동시성
| 헤더 | 주요 기능 |
|---|---|
<stdatomic.h> | 원자적 타입과 연산 |
<threads.h> | 스레드, 뮤텍스와 조건 변수 |
표준 판에 따라 존재하는 헤더와 제공 기능은 다르다. 예를 들어 <stdint.h>는 C99, <stdatomic.h>와 <threads.h>는 C11, <stdbit.h>는 C23에서 표준에 추가되었다.[66]
헤더의 반복 포함
표준 헤더는 일반적으로 하나의 번역 단위에서 여러 번 포함할 수 있다.
#include <stddef.h>
#include <stddef.h>
구현체는 중복 선언이 충돌하지 않도록 제공해야 한다.
프로젝트 헤더에는 include guard를 사용하는 것이 일반적이다.
#ifndef TECHPEDIA_BUFFER_H
#define TECHPEDIA_BUFFER_H
#include <stddef.h>
struct buffer {
void *data;
size_t size;
};
#endif
#pragma once도 널리 지원되지만 ISO C 표준 기능은 아니다.
예약 식별자
표준 라이브러리는 구현체와 향후 확장을 위해 일부 식별자를 예약한다.
대표적인 예약 규칙은 다음과 같다.
- 밑줄 두 개로 시작하는 식별자
- 밑줄 다음에 대문자가 오는 식별자
- 파일 범위에서 밑줄로 시작하는 식별자
- 표준 라이브러리의 외부 함수 이름
- 특정 헤더가 예약하는 매크로와 이름
- 향후 라이브러리 확장을 위해 예약된 이름
int __internal_value;
int _SystemValue;
이러한 이름은 프로그램이 정의해서는 안 된다.
int internal_value;
int system_value;
표준 함수 이름을 다른 외부 함수로 정의해서도 안 된다.
int malloc(int size) {
return size;
}
malloc은 표준 라이브러리의 외부 식별자로 예약된다.
헤더가 포함되지 않았더라도 표준 라이브러리 외부 함수의 이름은 외부 연결 식별자로 예약된다. C23 표준은 헤더가 포함될 때 추가로 예약되는 매크로와 파일 범위 식별자 규칙도 정의한다.[67]
라이브러리 함수와 매크로
표준 라이브러리 이름은 함수처럼 보이더라도 매크로로 함께 정의될 수 있다.
#include <ctype.h>
int result = isalpha(character);
구현체는 isalpha를 효율적인 매크로로 제공할 수 있다.
함수형 매크로의 실제 함수에 접근해야 하는 특별한 경우에는 괄호를 이용해 매크로 확장을 억제할 수 있는 이름도 있다.
int result = (isalpha)(character);
그러나 모든 표준 인터페이스에 같은 방식이 적용된다고 가정해서는 안 된다. 각 헤더와 함수의 규정을 확인해야 한다.
표준 라이브러리의 함수형 매크로 이름을 임의로 #undef하거나 재정의하면 프로그램 의미와 이식성이 달라질 수 있다.
#undef isalpha
표준이 허용하는 범위를 벗어난 조작은 피해야 한다.
<assert.h>
<assert.h>는 프로그램의 전제 조건을 실행 중에 검사하는 assert 매크로를 제공한다.
#include <assert.h>
int divide(int left, int right) {
assert(right != 0);
return left / right;
}
조건이 거짓이면 진단 정보를 출력하고 abort에 해당하는 비정상 종료를 수행한다.
NDEBUG가 정의되어 있으면 assert 검사가 비활성화된다.
#define NDEBUG
#include <assert.h>
비활성화된 빌드에서도 반드시 실행되어야 하는 부작용을 assert 안에 넣으면 안 된다.
assert(initialize_system());
NDEBUG가 정의되면 초기화 함수 호출 자체가 사라질 수 있다.
bool initialized = initialize_system();
assert(initialized);
assert는 외부 입력 오류를 처리하는 일반적인 수단이 아니다. 프로그램 내부의 불변 조건과 개발 중 결함 검출에 적합하다.
<ctype.h>
<ctype.h>는 좁은 문자의 분류와 대소문자 변환 기능을 제공한다.
#include <ctype.h>
int count_digits(const char *text) {
int count = 0;
while (*text != '\0') {
if (isdigit((unsigned char)*text)) {
++count;
}
++text;
}
return count;
}
대표적인 함수는 다음과 같다.
isalnum
isalpha
isblank
iscntrl
isdigit
isgraph
islower
isprint
ispunct
isspace
isupper
isxdigit
tolower
toupper
문자 분류 함수의 인수는 EOF 또는 unsigned char로 표현 가능한 값이어야 한다. 기본 char가 부호 있는 구현에서 음수 char를 직접 전달하면 정의되지 않은 동작이 발생할 수 있다.
isalpha((unsigned char)character);
문자 분류 결과는 현재 지역화 설정의 영향을 받을 수 있다.
<errno.h>
<errno.h>는 일부 라이브러리 함수가 실패 원인을 보고하는 데 사용하는 errno와 오류 매크로를 제공한다.
#include <errno.h>
#include <stdlib.h>
bool parse_number(
const char *text,
long *result
) {
char *end;
errno = 0;
long value = strtol(text, &end, 10);
if (end == text) {
return false;
}
if (errno == ERANGE) {
return false;
}
*result = value;
return true;
}
errno는 함수 호출이 성공하면 자동으로 0이 된다고 보장되지 않는다. 오류 여부를 확인해야 하는 함수에서는 호출 전에 0으로 설정하고 함수의 반환값과 함께 검사한다.
반환값 검사
+
errno 검사
errno의 값은 오류가 실제로 보고된 경우에만 의미가 있다.
<float.h>
<float.h>는 부동소수점 타입의 범위와 정밀도, 반올림 특성 등을 나타내는 매크로를 제공한다.
#include <float.h>
#include <stdio.h>
int main(void) {
printf("%d\n", FLT_DIG);
printf("%d\n", DBL_DIG);
printf("%d\n", LDBL_DIG);
return 0;
}
대표적인 매크로는 다음과 같다.
FLT_MIN
FLT_MAX
FLT_EPSILON
FLT_DIG
DBL_MIN
DBL_MAX
DBL_EPSILON
DBL_DIG
LDBL_MIN
LDBL_MAX
LDBL_EPSILON
LDBL_DIG
모든 C 구현이 IEEE 754의 동일한 형식을 사용한다고 가정해서는 안 된다. C23은 IEC 60559 결합 기능을 조건부 매크로로 나타낼 수 있다.
<limits.h>
<limits.h>는 기본 정수 타입의 범위를 나타낸다.
#include <limits.h>
static_assert(CHAR_BIT >= 8);
대표적인 매크로는 다음과 같다.
CHAR_BIT
CHAR_MIN
CHAR_MAX
SCHAR_MIN
SCHAR_MAX
UCHAR_MAX
SHRT_MIN
SHRT_MAX
USHRT_MAX
INT_MIN
INT_MAX
UINT_MAX
LONG_MIN
LONG_MAX
ULONG_MAX
LLONG_MIN
LLONG_MAX
ULLONG_MAX
정수 타입 크기를 고정된 값으로 가정하는 대신 이러한 매크로와 <stdint.h>를 사용한다.
<stddef.h>
<stddef.h>는 여러 라이브러리와 언어 기능에서 공통으로 사용하는 타입과 매크로를 제공한다.
대표적인 요소는 다음과 같다.
size_t
ptrdiff_t
max_align_t
nullptr_t
NULL
offsetof
객체 크기와 배열 요소 수에는 size_t를 사용한다.
#include <stddef.h>
size_t count = 10;
같은 배열 안의 포인터 차이는 ptrdiff_t로 표현한다.
ptrdiff_t distance = end - begin;
구조체 구성원의 오프셋은 offsetof로 얻는다.
#include <stddef.h>
struct packet {
unsigned int type;
unsigned int size;
};
static_assert(
offsetof(struct packet, size) >=
sizeof(unsigned int)
);
C23에서는 nullptr_t도 <stddef.h>에서 제공된다.
nullptr_t null_value = nullptr;
<stdint.h>
<stdint.h>는 폭과 범위 특성이 명시된 정수 타입을 제공한다.
#include <stdint.h>
uint8_t byte;
int32_t coordinate;
uint64_t identifier;
타입은 다음 범주로 나뉜다.
정확한 폭
int8_t
uint8_t
int16_t
uint16_t
int32_t
uint32_t
int64_t
uint64_t
최소 폭
int_least32_t
uint_least32_t
가장 빠른 타입
int_fast32_t
uint_fast32_t
포인터 변환 가능 타입
intptr_t
uintptr_t
최대 폭
intmax_t
uintmax_t
정확한 폭 타입은 구현체가 해당 폭을 패딩 없이 제공할 수 있을 때만 정의된다.
정수 상수 매크로도 제공된다.
uint64_t value = UINT64_C(10000000000);
intmax_t minimum = INTMAX_C(-1000);
<inttypes.h>
<inttypes.h>는 <stdint.h>의 타입을 포함하며 정수 형식 입출력 매크로와 변환 함수를 제공한다.
#include <inttypes.h>
#include <stdio.h>
int main(void) {
uint64_t identifier =
UINT64_C(123456789);
printf(
"%" PRIu64 "\n",
identifier
);
return 0;
}
uint64_t가 항상 unsigned long long과 같다고 가정해 %llu를 직접 사용하는 것은 이식 가능하지 않을 수 있다.
대표적인 형식 매크로는 다음과 같다.
PRId32
PRIu32
PRIx32
PRId64
PRIu64
SCNd32
SCNu64
최대 폭 정수의 절댓값과 나눗셈, 문자열 변환 함수도 제공한다.
<stdarg.h>
<stdarg.h>는 가변 인수 함수의 인수 목록을 처리한다.
#include <stdarg.h>
int sum_values(size_t count, ...) {
va_list arguments;
int sum = 0;
va_start(arguments, count);
for (size_t index = 0;
index < count;
++index) {
sum += va_arg(arguments, int);
}
va_end(arguments);
return sum;
}
가변 인수 부분은 개별 매개변수 타입을 함수 원형으로 검사할 수 없다. 호출자와 함수가 타입과 개수를 정확히 합의해야 한다.
int result = sum_values(
3,
10,
20,
30
);
기본 인수 승격도 적용된다.
float
→ double
char, short
→ int 또는 unsigned int
잘못된 타입으로 va_arg를 사용하면 정의되지 않은 동작이 발생할 수 있다.
<stdio.h>
<stdio.h>는 파일과 스트림 기반 입출력을 제공한다.
대표적인 타입과 객체는 다음과 같다.
FILE
fpos_t
stdin
stdout
stderr
EOF
주요 함수 범주는 다음과 같다.
파일 열기와 닫기
fopen
freopen
fclose
문자 입출력
fgetc
fputc
getchar
putchar
문자열 입출력
fgets
fputs
형식 입출력
printf
fprintf
snprintf
scanf
fscanf
sscanf
바이너리 입출력
fread
fwrite
파일 위치
fseek
ftell
rewind
fgetpos
fsetpos
상태와 버퍼
fflush
feof
ferror
clearerr
setbuf
setvbuf
파일 관리
remove
rename
tmpfile
tmpnam
간단한 파일 복사는 다음과 같이 작성할 수 있다.
#include <stdio.h>
int copy_file(
const char *source_path,
const char *destination_path
) {
int result = -1;
FILE *source = NULL;
FILE *destination = NULL;
unsigned char buffer[4096];
source = fopen(source_path, "rb");
if (source == NULL) {
goto cleanup;
}
destination = fopen(
destination_path,
"wb"
);
if (destination == NULL) {
goto cleanup;
}
while (!feof(source)) {
size_t count = fread(
buffer,
1,
sizeof(buffer),
source
);
if (
count > 0 &&
fwrite(
buffer,
1,
count,
destination
) != count
) {
goto cleanup;
}
if (ferror(source)) {
goto cleanup;
}
}
result = 0;
cleanup:
if (destination != NULL) {
fclose(destination);
}
if (source != NULL) {
fclose(source);
}
return result;
}
feof는 다음 읽기가 끝에 도달할지 미리 알려주는 함수가 아니다. 읽기 함수가 파일 끝을 만나 실패한 뒤 상태를 검사한다.
텍스트 스트림과 바이너리 스트림
C는 텍스트 스트림과 바이너리 스트림을 구분한다.
FILE *text_file =
fopen("document.txt", "r");
FILE *binary_file =
fopen("image.bin", "rb");
일부 UNIX 계열에서는 두 모드가 사실상 동일하게 동작하지만, C 표준은 텍스트 모드에서 줄바꿈이나 특정 문자가 실행 환경의 외부 표현으로 변환될 수 있도록 허용한다.
Windows 계열 구현에서는 텍스트 모드에서 줄바꿈 변환이 일어날 수 있다.
이식 가능한 바이너리 파일 처리에는 b 모드를 사용한다.
FILE *file = fopen(path, "wb");
그러나 fwrite로 구조체를 그대로 저장한다고 플랫폼 독립적인 파일 형식이 되는 것은 아니다.
fwrite(
&header,
sizeof(header),
1,
file
);
구조체 패딩과 엔디언, 타입 크기의 영향을 받으므로 고정 형식에서는 명시적으로 직렬화해야 한다.
형식 출력
printf 계열 함수는 형식 문자열에 따라 값을 문자로 변환한다.
#include <stdio.h>
int main(void) {
int count = 10;
double ratio = 0.75;
printf(
"count=%d ratio=%.2f\n",
count,
ratio
);
return 0;
}
형식 지정자와 실제 인수 타입이 일치해야 한다.
size_t count = 10;
printf("%zu\n", count);
다음 코드는 size_t가 unsigned int와 같다는 보장이 없으므로 잘못될 수 있다.
printf("%u\n", count);
포인터는 %p와 void *를 사용한다.
printf("%p\n", (void *)pointer);
형식 문자열이 외부 입력이라면 형식 문자열 취약점이 발생할 수 있다.
printf(user_input);
데이터로 출력해야 한다.
printf("%s", user_input);
snprintf
snprintf는 출력 버퍼의 크기를 제한한다.
char buffer[128];
int length = snprintf(
buffer,
sizeof(buffer),
"value=%d",
value
);
반환값은 널 문자를 제외하고 충분한 공간이 있었다면 기록되었을 문자 수다.
if (length < 0) {
return false;
}
if ((size_t)length >= sizeof(buffer)) {
return false;
}
버퍼가 작으면 출력이 잘릴 수 있지만 가능한 경우 널 종료를 제공한다.
<string.h>
<string.h>는 널 종료 문자열과 일반 메모리 블록을 처리한다.
문자열 함수에는 다음이 포함된다.
strlen
strcmp
strncmp
strcpy
strncpy
strcat
strncat
strchr
strrchr
strstr
strspn
strcspn
strpbrk
strtok
strcoll
strxfrm
strdup
strndup
메모리 함수에는 다음이 포함된다.
memcpy
memmove
memset
memcmp
memchr
memccpy
문자열 길이는 strlen으로 구한다.
size_t length = strlen(text);
널 종료 문자가 없는 바이트 배열에는 사용할 수 없다.
char bytes[4] = {
't',
'e',
's',
't'
};
strlen(bytes);
배열 범위를 넘어 널 문자를 찾으려 하므로 잘못된 접근이 발생할 수 있다.
memcpy와 memmove
memcpy는 겹치지 않는 메모리 영역을 복사한다.
memcpy(
destination,
source,
size
);
영역이 겹치면 정의되지 않은 동작이다.
memcpy(
buffer + 1,
buffer,
count
);
겹칠 가능성이 있으면 memmove를 사용한다.
memmove(
buffer + 1,
buffer,
count
);
memcpy와 memmove의 크기는 요소 수가 아니라 바이트 수다.
memcpy(
destination,
source,
count * sizeof(*destination)
);
곱셈 오버플로도 검사해야 한다.
<stdlib.h>
<stdlib.h>는 여러 범용 기능을 제공한다.
주요 범주는 다음과 같다.
숫자 변환
atoi
strtol
strtoul
strtod
동적 메모리
malloc
calloc
realloc
free
aligned_alloc
프로그램 종료
abort
exit
quick_exit
_Exit
atexit
at_quick_exit
환경
getenv
system
검색과 정렬
bsearch
qsort
정수 연산
abs
labs
llabs
div
ldiv
lldiv
다중 바이트 문자
mblen
mbtowc
wctomb
숫자 변환
atoi 계열은 오류를 구체적으로 보고할 수 없으므로 검증이 필요한 입력에는 strtol 계열이 적합하다.
#include <errno.h>
#include <limits.h>
#include <stdlib.h>
bool parse_int(
const char *text,
int *result
) {
char *end;
errno = 0;
long value = strtol(
text,
&end,
10
);
if (end == text) {
return false;
}
if (*end != '\0') {
return false;
}
if (errno == ERANGE) {
return false;
}
if (
value < INT_MIN ||
value > INT_MAX
) {
return false;
}
*result = (int)value;
return true;
}
숫자 입력에는 다음 사항을 구분해야 한다.
- 숫자가 하나도 없었는지
- 뒤에 불필요한 문자가 있는지
- 표현 범위를 넘었는지
- 부호가 허용되는지
- 진법이 무엇인지
동적 메모리 관리
동적 배열은 다음처럼 할당할 수 있다.
#include <stdlib.h>
int *values = malloc(
count * sizeof(*values)
);
if (values == NULL) {
return false;
}
크기 곱셈은 오버플로할 수 있다.
if (
count >
SIZE_MAX / sizeof(*values)
) {
return false;
}
사용 후에는 해제한다.
free(values);
values = NULL;
calloc은 요소 수와 요소 크기를 받아 모든 바이트가 0인 저장 공간을 제공한다.
int *values = calloc(
count,
sizeof(*values)
);
realloc은 임시 포인터로 결과를 확인한다.
int *new_values = realloc(
values,
new_count *
sizeof(*new_values)
);
if (new_values == NULL) {
return false;
}
values = new_values;
정렬된 할당
aligned_alloc은 지정한 정렬을 만족하는 메모리를 할당한다.
#include <stdlib.h>
size_t alignment = 64;
size_t size = 256;
void *memory = aligned_alloc(
alignment,
size
);
크기는 정렬의 정수배여야 한다.
if (size % alignment != 0) {
return NULL;
}
C23은 메모리 정렬 관련 인터페이스를 확장했으며, 독립 실행 구현에도 선택된 정렬 기능을 요구한다.
qsort
qsort는 범용 배열 정렬 함수다.
#include <stdlib.h>
static int compare_int(
const void *left_pointer,
const void *right_pointer
) {
const int left =
*(const int *)left_pointer;
const int right =
*(const int *)right_pointer;
return (left > right) -
(left < right);
}
void sort_values(
int *values,
size_t count
) {
qsort(
values,
count,
sizeof(*values),
compare_int
);
}
다음과 같이 단순 뺄셈으로 비교하면 오버플로할 수 있다.
return left - right;
비교 함수는 음수, 0, 양수라는 관계만 반환하면 된다.
qsort는 안정 정렬을 보장하지 않는다. 같은 키를 가진 요소의 상대 순서를 보존해야 한다면 별도 정렬 알고리즘이 필요하다.
bsearch
bsearch는 정렬된 배열에서 이진 검색을 수행한다.
int key = 20;
int *found = bsearch(
&key,
values,
count,
sizeof(*values),
compare_int
);
if (found != NULL) {
printf("%d\n", *found);
}
배열은 비교 함수의 순서에 따라 미리 정렬되어 있어야 한다.
프로그램 종료
정상 종료에는 return 또는 exit를 사용할 수 있다.
#include <stdlib.h>
int main(void) {
return EXIT_SUCCESS;
}
void fatal_error(void) {
exit(EXIT_FAILURE);
}
atexit은 정상 종료 시 실행할 함수를 등록한다.
#include <stdlib.h>
static void cleanup(void) {
release_global_resources();
}
int main(void) {
if (atexit(cleanup) != 0) {
return EXIT_FAILURE;
}
return application_run();
}
abort, quick_exit과 _Exit는 서로 다른 종료 절차를 가진다. 일반적인 정상 종료와 같은 자원 정리가 이루어진다고 가정해서는 안 된다.
<math.h>
<math.h>는 실수 수학 함수를 제공한다.
대표적인 함수에는 다음이 있다.
삼각 함수
sin
cos
tan
asin
acos
atan
atan2
지수와 로그
exp
exp2
exp10
log
log2
log10
거듭제곱과 제곱근
pow
sqrt
cbrt
hypot
반올림
ceil
floor
trunc
round
nearbyint
절댓값과 나머지
fabs
fmod
remainder
분류
isfinite
isinf
isnan
isnormal
signbit
각 기본 함수에는 float, double, long double 변형이 있을 수 있다.
float first = sqrtf(4.0F);
double second = sqrt(4.0);
long double third = sqrtl(4.0L);
수학 함수는 정의역과 범위 오류를 보고할 수 있다. 반환값과 errno, 부동소수점 예외를 함수 규정에 따라 확인해야 한다.
#include <errno.h>
#include <math.h>
errno = 0;
double value = sqrt(-1.0);
if (errno == EDOM) {
/* 정의역 오류 */
}
IEC 60559를 지원하는 환경에서는 NaN과 부동소수점 예외 규칙이 추가로 적용될 수 있다.
<complex.h>
<complex.h>는 복소수 연산을 제공한다.
#include <complex.h>
#include <stdio.h>
int main(void) {
double complex value =
1.0 + 2.0 * I;
printf(
"%f %f\n",
creal(value),
cimag(value)
);
return 0;
}
대표적인 함수에는 다음이 있다.
cabs
carg
creal
cimag
conj
cproj
cexp
clog
cpow
csqrt
csin
ccos
ctan
복소수 지원은 구현체와 표준 판에서 선택 기능으로 다뤄질 수 있다.
<tgmath.h>
<tgmath.h>는 인수 타입에 맞는 수학 함수를 선택하는 타입 일반 매크로를 제공한다.
#include <tgmath.h>
float first = sqrt(4.0F);
double second = sqrt(4.0);
long double third = sqrt(4.0L);
같은 이름을 사용하지만 인수 타입에 따라 sqrtf, sqrt, sqrtl 등에 대응할 수 있다.
매크로 확장과 복소수 타입이 함께 관련되므로 공개 API와 디버깅에서는 명시적인 함수를 선택하는 경우도 있다.
<fenv.h>
<fenv.h>는 부동소수점 예외와 반올림 모드를 제어한다.
#include <fenv.h>
int calculate_upward(void) {
int previous = fegetround();
if (fesetround(FE_UPWARD) != 0) {
return -1;
}
double value = perform_calculation();
fesetround(previous);
consume(value);
return 0;
}
컴파일러가 부동소수점 환경 접근을 무시하지 않도록 pragma가 필요할 수 있다.
#pragma STDC FENV_ACCESS ON
부동소수점 환경의 완전한 지원과 의미는 구현체가 IEC 60559 결합 기능을 제공하는지에 따라 달라질 수 있다.
<time.h>
<time.h>는 달력 시간과 처리 시간, 시각 변환 기능을 제공한다.
대표적인 타입과 함수는 다음과 같다.
time_t
clock_t
struct tm
timespec
time
clock
difftime
mktime
strftime
gmtime
localtime
timespec_get
현재 달력 시간을 얻을 수 있다.
#include <stdio.h>
#include <time.h>
int main(void) {
time_t now = time(NULL);
if (now == (time_t)-1) {
return 1;
}
struct tm *local =
localtime(&now);
if (local == NULL) {
return 1;
}
char buffer[64];
if (
strftime(
buffer,
sizeof(buffer),
"%Y-%m-%d %H:%M:%S",
local
) == 0
) {
return 1;
}
puts(buffer);
return 0;
}
localtime과 gmtime은 같은 정적 객체를 재사용할 수 있으므로 연속 호출과 스레드 사용에 주의해야 한다. C23에는 재진입 가능한 변형이 표준화되었다.
struct tm result;
if (localtime_r(&now, &result) == NULL) {
return false;
}
time_t의 단위와 표현은 표준이 단순 정수 초로 고정하지 않는다.
<locale.h>
<locale.h>는 문자 분류와 숫자, 날짜, 정렬 등의 지역화 설정을 제어한다.
#include <locale.h>
int main(void) {
if (setlocale(LC_ALL, "") == NULL) {
return 1;
}
return 0;
}
주요 범주는 다음과 같다.
LC_ALL
LC_COLLATE
LC_CTYPE
LC_MONETARY
LC_NUMERIC
LC_TIME
프로그램 시작 시 기본 지역은 "C" 로캘이다.
setlocale(LC_ALL, "C");
빈 문자열은 실행 환경의 지역 설정을 요청한다.
setlocale(LC_ALL, "");
지역 설정은 전역 상태일 수 있으므로 여러 라이브러리와 스레드가 함께 사용하는 프로그램에서는 주의해야 한다.
<wchar.h>와 <wctype.h>
<wchar.h>는 와이드 문자와 다중 바이트 문자열 변환, 와이드 입출력과 문자열 기능을 제공한다.
#include <wchar.h>
wchar_t text[] = L"TechPedia";
size_t length = wcslen(text);
대표적인 함수에는 다음이 있다.
wcslen
wcscpy
wcscmp
wmemcpy
wprintf
fwprintf
fgetwc
fputwc
mbrtowc
wcrtomb
mbsrtowcs
wcsrtombs
wchar_t의 크기와 인코딩은 플랫폼마다 다를 수 있다.
일부 Windows 환경
wchar_t → 16비트 계열
여러 UNIX 계열
wchar_t → 32비트 계열
따라서 wchar_t가 항상 UTF-16이나 UTF-32라고 일반화해서는 안 된다.
<wctype.h>는 와이드 문자의 분류와 변환을 제공한다.
#include <wctype.h>
if (iswalpha(character)) {
character = towlower(character);
}
<uchar.h>
<uchar.h>는 UTF 계열 문자 타입과 다중 바이트 문자 사이의 변환 기능을 제공한다.
#include <uchar.h>
char16_t character16;
char32_t character32;
대표적인 변환 함수에는 다음이 있다.
mbrtoc8
c8rtomb
mbrtoc16
c16rtomb
mbrtoc32
c32rtomb
문자열 인코딩과 코드 단위, 유니코드 코드 포인트를 서로 같은 개념으로 다루면 안 된다.
UTF-8
→ 하나의 문자가 여러 8비트 코드 단위
UTF-16
→ 일부 문자가 서로게이트 쌍 사용
UTF-32
→ 하나의 코드 단위가 코드 포인트를 표현
<stdbit.h>
C23의 <stdbit.h>는 부호 없는 정수의 비트 관련 연산을 제공한다.
주요 기능에는 다음이 포함된다.
선행 0비트 수
후행 0비트 수
1비트 수
첫 번째 선행 1비트
첫 번째 후행 1비트
비트 폭
단일 비트 여부
다음·이전 2의 거듭제곱
#include <stdbit.h>
unsigned int value = 0b0011'0000;
unsigned int ones =
stdc_count_ones(value);
unsigned int width =
stdc_bit_width(value);
직접 반복문과 비트 연산으로 구현하던 기능에 표준화된 인터페이스를 제공한다. 구현체는 대상 CPU의 비트 명령을 활용할 수 있다.
<stdatomic.h>
<stdatomic.h>는 원자적 타입과 연산, 메모리 순서를 제공한다.
#include <stdatomic.h>
static atomic_uint counter = 0;
void increment(void) {
atomic_fetch_add(
&counter,
1
);
}
대표적인 기능은 다음과 같다.
atomic_init
atomic_load
atomic_store
atomic_exchange
atomic_compare_exchange_strong
atomic_compare_exchange_weak
atomic_fetch_add
atomic_fetch_sub
atomic_fetch_or
atomic_fetch_xor
atomic_fetch_and
atomic_flag
atomic_thread_fence
atomic_signal_fence
명시적인 메모리 순서를 지정할 수 있다.
atomic_store_explicit(
&ready,
true,
memory_order_release
);
bool value = atomic_load_explicit(
&ready,
memory_order_acquire
);
원자적 연산만 사용한다고 여러 객체로 구성된 전체 자료 구조가 자동으로 안전해지지는 않는다. 객체 수명과 메모리 회수, 불변 조건도 함께 설계해야 한다.
<threads.h>
<threads.h>는 C 표준 스레드와 동기화 기능을 제공한다.
주요 요소는 다음과 같다.
thrd_t
thrd_create
thrd_join
thrd_detach
thrd_current
thrd_sleep
thrd_yield
mtx_t
mtx_init
mtx_lock
mtx_trylock
mtx_unlock
mtx_destroy
cnd_t
cnd_init
cnd_wait
cnd_signal
cnd_broadcast
cnd_destroy
once_flag
call_once
tss_t
tss_create
tss_get
tss_set
tss_delete
간단한 스레드를 만들 수 있다.
#include <stdio.h>
#include <threads.h>
static int worker(void *context) {
int value = *(int *)context;
printf("%d\n", value);
return value;
}
int main(void) {
int value = 10;
int result;
thrd_t thread;
if (
thrd_create(
&thread,
worker,
&value
) != thrd_success
) {
return 1;
}
if (
thrd_join(
thread,
&result
) != thrd_success
) {
return 1;
}
return result == 10 ? 0 : 1;
}
모든 운영체제가 <threads.h>를 네이티브 스레드 API로 직접 제공하는 것은 아니다. 구현체가 다른 런타임이나 래퍼로 구현할 수 있으며, 일부 환경에서는 지원이 제한될 수 있다.
뮤텍스
공유 상태를 보호할 수 있다.
#include <threads.h>
struct counter {
mtx_t mutex;
int value;
};
bool counter_increment(
struct counter *counter
) {
if (
mtx_lock(
&counter->mutex
) != thrd_success
) {
return false;
}
++counter->value;
mtx_unlock(&counter->mutex);
return true;
}
잠금 후 모든 실행 경로에서 반드시 해제해야 한다.
조건 변수
조건 변수는 상태가 바뀔 때까지 스레드를 대기시킨다.
mtx_lock(&queue->mutex);
while (queue->count == 0) {
cnd_wait(
&queue->changed,
&queue->mutex
);
}
consume_queue_item(queue);
mtx_unlock(&queue->mutex);
조건 변수에서 깨어났다는 사실만으로 조건이 참이라고 가정할 수 없다. 다른 스레드가 먼저 상태를 변경하거나 가짜 깨움이 발생할 수 있으므로 반복문으로 다시 검사한다.
<setjmp.h>
<setjmp.h>는 일반적인 함수 반환을 거치지 않고 이전 실행 지점으로 이동하는 비지역 점프를 제공한다.
#include <setjmp.h>
static jmp_buf environment;
void fail(void) {
longjmp(environment, 1);
}
int main(void) {
if (setjmp(environment) == 0) {
fail();
return 0;
}
return 1;
}
setjmp는 최초 호출 시 0을 반환하고, longjmp로 돌아오면 0이 아닌 값을 반환한 것처럼 동작한다.
비지역 점프는 자동 객체의 값과 자원 정리, 제어 흐름을 복잡하게 만든다. C++ 소멸자나 다른 언어 런타임 경계를 넘어 사용해서는 안 되며, 일반적인 오류 처리에는 명시적인 반환값이 더 적합한 경우가 많다.
<signal.h>
<signal.h>는 신호와 비동기 이벤트 처리를 위한 최소 인터페이스를 제공한다.
#include <signal.h>
static volatile sig_atomic_t
interrupted;
static void handle_signal(int signal) {
interrupted = signal;
}
int main(void) {
signal(SIGINT, handle_signal);
while (!interrupted) {
perform_work();
}
return 0;
}
대표적인 신호 매크로는 다음과 같다.
SIGABRT
SIGFPE
SIGILL
SIGINT
SIGSEGV
SIGTERM
ISO C 신호 모델은 운영체제별 신호 시스템 전체를 규정하지 않는다. POSIX는 더 많은 신호와 함수, 비동기 신호 안전 규칙을 별도로 제공한다.
신호 처리기 안에서는 호출 가능한 함수와 접근 가능한 객체가 엄격히 제한된다.
<iso646.h>
<iso646.h>는 일부 연산자를 대체 단어로 표현하는 매크로를 제공한다.
#include <iso646.h>
if (ready and not failed) {
flags = flags or ENABLED;
}
다음 연산자에 대응한다.
| 대체 표기 | 연산자 | | | | -------- | ---- | -- | - | | and | && | | | | and_eq | &= | | | | bitand | & | | | | bitor | | | | | compl | ~ | | | | not | ! | | | | not_eq | != | | | | or | | | | | or_eq | | = | | | xor | ^ | | | | xor_eq | ^= | | |
제한된 문자 집합을 사용하던 환경과의 호환성을 위한 기능이며 현대 코드에서는 기호 연산자를 사용하는 경우가 많다.
라이브러리 함수의 계약
표준 라이브러리 함수는 전달받는 인수와 객체에 일정한 전제 조건을 요구한다.
memcpy(
destination,
source,
size
);
호출자는 다음을 보장해야 한다.
- 대상 포인터가 유효함
- 원본 포인터가 유효함
- 지정 크기만큼 접근 가능함
- 원본과 대상이 허용되지 않는 방식으로 겹치지 않음
- 객체 수명이 유효함
전제 조건을 위반하면 함수가 오류 코드를 반환하는 것이 아니라 정의되지 않은 동작이 발생할 수 있다.
memcpy(
NULL,
source,
10
);
C 표준 라이브러리는 일반적으로 모든 포인터와 배열 범위를 런타임에 검사하지 않는다.
restrict와 라이브러리
여러 표준 함수 원형에는 restrict가 사용된다.
void *memcpy(
void *restrict destination,
const void *restrict source,
size_t count
);
이는 호출자가 함수 실행 동안 별칭 규칙을 지켜야 한다는 계약을 표현한다.
겹치는 영역에는 memmove를 사용한다.
memmove(
destination,
source,
count
);
스레드 안전성
C11 이후 표준 라이브러리는 여러 스레드에서의 사용과 데이터 경쟁에 관한 규칙을 포함한다.
일반적으로 라이브러리 함수는 내부적으로 접근하는 서로 다른 객체에 대해 불필요한 데이터 경쟁을 일으켜서는 안 된다. 그러나 호출자가 같은 사용자 객체에 동기화 없이 동시에 쓰면 프로그램에 데이터 경쟁이 발생할 수 있다.
static char buffer[1024];
int first_worker(void *context) {
strcpy(buffer, "first");
return 0;
}
int second_worker(void *context) {
strcpy(buffer, "second");
return 0;
}
두 스레드가 동시에 실행하면 buffer에 대한 데이터 경쟁이 발생할 수 있다.
라이브러리 함수가 존재한다고 호출자의 공유 데이터가 자동으로 동기화되는 것은 아니다.
지역화와 전역 상태
다음 표준 라이브러리 기능은 전역 또는 공유 상태와 관련될 수 있다.
setlocalelocaleconverrno- 난수 상태
- 표준 입출력 스트림
- 시간 변환의 내부 객체
- 신호 처리
- 환경 변수
errno는 현대 구현에서 스레드별 상태로 제공될 수 있지만 프로그램에서는 헤더가 제공하는 errno 매크로를 사용해야 한다.
errno = 0;
직접 외부 전역 변수로 선언해서는 안 된다.
extern int errno;
구현체가 스레드별 접근 함수와 매크로로 제공할 수 있기 때문이다.
난수
<stdlib.h>의 rand와 srand는 의사 난수 인터페이스를 제공한다.
#include <stdlib.h>
srand(1234);
int value = rand();
rand는 다음 용도에 적합하지 않다.
- 암호 키
- 세션 토큰
- 보안 nonce
- 비밀번호 재설정 코드
- 공격자가 예측하면 안 되는 값
C 표준은 암호학적으로 안전한 난수 생성기를 제공하지 않는다. 보안 목적에는 운영체제나 검증된 암호 라이브러리의 난수 기능을 사용해야 한다.
rand의 알고리즘과 품질, RAND_MAX는 구현체마다 다를 수 있다.
표준에 없는 기능
C 표준 라이브러리는 운영체제 전체를 추상화하는 라이브러리가 아니다.
다음 기능은 ISO C 표준 라이브러리에 포함되지 않는다.
- 소켓 네트워크
- 디렉터리 순회
- 동적 라이브러리 로딩
- 프로세스 생성과 제어
- 가상 메모리 매핑
- 그래픽스
- 오디오
- 입력 장치
- GUI
- 파일 권한
- 비동기 입출력
- 운영체제 이벤트 루프
- 암호화
- 압축
- 데이터베이스
- HTTP
이러한 기능은 운영체제 API와 다른 국제 표준, 외부 라이브러리에서 제공한다.
ISO C
→ 언어와 기본 라이브러리
POSIX
→ UNIX 계열 운영체제 인터페이스
Win32
→ Windows 플랫폼 인터페이스
외부 라이브러리
→ 그래픽스·네트워크·암호화 등
POSIX와의 차이
POSIX는 C 언어를 기반으로 UNIX 계열 운영체제 인터페이스를 규정하지만 ISO C와 별개의 표준이다.
POSIX가 제공하는 대표적인 기능은 다음과 같다.
unistd.h
pthread.h
dirent.h
sys/socket.h
sys/mman.h
dlfcn.h
다음 코드는 POSIX 프로그램이다.
#include <unistd.h>
int main(void) {
const char message[] = "hello\n";
write(
STDOUT_FILENO,
message,
sizeof(message) - 1
);
return 0;
}
write와 <unistd.h>는 ISO C 표준 라이브러리 기능이 아니다.
이식성이 필요한 프로젝트는 ISO C 기반 공통 코드와 플랫폼별 코드를 분리할 수 있다.
공통 계층
→ ISO C
플랫폼 계층
├── POSIX
├── Win32
└── 콘솔·임베디드 API
구현체의 표준 라이브러리
표준 라이브러리는 하나의 특정 코드베이스를 의미하지 않는다. 여러 구현체가 같은 표준 인터페이스를 제공한다.
대표적인 구현에는 다음이 있다.
glibc
musl
Microsoft Universal CRT
Apple libc
FreeBSD libc
Newlib
picolibc
WASI libc
컴파일러·플랫폼 전용 런타임
각 구현은 같은 ISO C 인터페이스를 제공하면서도 다음 부분이 다를 수 있다.
- 지원하는 C 표준 판
- 선택 기능
- 확장 함수
- 성능
- 스레드 구현
- 파일과 지역화
- ABI
- 동적 링크 방식
- 라이선스
- 독립 실행 지원
- 임베디드 환경 적합성
컴파일러와 표준 라이브러리는 서로 독립된 구성 요소일 수 있다.
Clang
+
glibc
Clang
+
musl
Clang
+
Microsoft CRT
GCC
+
Newlib
같은 컴파일러라도 대상 플랫폼과 sysroot에 따라 다른 헤더와 라이브러리를 사용할 수 있다.
컴파일러 내장 함수
표준 라이브러리 함수가 항상 실제 외부 함수 호출로 구현되는 것은 아니다.
memcpy(
destination,
source,
16
);
컴파일러는 이를 직접 기계 명령으로 바꾸거나 벡터 명령, 인라인 복사로 최적화할 수 있다.
memcpy 호출
↓ 최적화
직접 load/store 명령
표준이 요구하는 관찰 가능한 동작이 같다면 구현 방식은 자유롭다.
컴파일러는 sqrt, strlen, memset 등도 내장 함수로 인식할 수 있다. 함수 주소를 사용하거나 특정 컴파일 옵션을 적용하면 실제 라이브러리 호출이 남을 수 있다.
선택적 기능
C의 일부 기능은 구현체가 조건부로 제공할 수 있다.
대표적인 조건부 기능에는 다음이 관련된다.
- IEC 60559 부동소수점
- 복소수
- 원자적 연산의 lock-free 여부
- 표준 스레드
- 특정 문자 인코딩
- 정확한 폭의 정수 타입
- 가변 길이 배열
- bounds-checking interface
구현체는 미리 정의된 매크로로 일부 지원 여부를 나타낸다.
#ifdef __STDC_NO_THREADS__
/* 표준 스레드가 제공되지 않음 */
#endif
#ifdef __STDC_NO_ATOMICS__
/* 표준 원자 연산이 제공되지 않음 */
#endif
#ifdef __STDC_NO_VLA__
/* 가변 길이 배열 지원 제한 */
#endif
표준 판을 선택한 것만으로 모든 선택 기능이 존재한다고 가정해서는 안 된다.
Annex K
C11 계열 표준에는 흔히 Annex K라고 불리는 bounds-checking interface가 조건부 부속서로 포함되어 왔다.
대표적인 함수 이름은 다음과 같다.
memcpy_s
strcpy_s
strcat_s
sprintf_s
이 기능은 __STDC_LIB_EXT1__ 매크로와 요청 매크로를 통해 조건부로 제공될 수 있다.
#define __STDC_WANT_LIB_EXT1__ 1
#include <string.h>
그러나 Annex K는 모든 주요 C 라이브러리에서 일관되게 구현되지 않았고, 같은 이름의 Microsoft 함수와 ISO 규격 사이에도 차이가 있을 수 있다.
이식 가능한 코드에서 해당 기능이 있다고 가정해서는 안 되며, 지원 여부와 실제 계약을 구현체 문서에서 확인해야 한다.
기술 명세와 확장
WG14와 ISO는 ISO/IEC 9899 외에도 특정 분야의 확장을 별도 기술 보고서와 기술 명세로 관리해 왔다.
예시는 다음과 같다.
- 임베디드 프로세서 지원
- IEC 60559 부동소수점 확장
- 보안 코딩 규칙
- bounds-checking interface
- 새로운 문자 타입
- 수학 및 병렬 계산 확장
이러한 문서는 모두 기본 C 표준의 필수 기능과 같은 지위를 갖는 것은 아니다. 일부는 이후 표준 판에 통합되거나 대체되고, 일부는 별도 문서로 유지되거나 폐기된다.[68]
결함 보고
발행된 표준의 문구에서 모호성이나 오류가 발견되면 WG14에 결함 보고가 제출될 수 있다.
표준 발행
↓
구현과 사용 중 문제 발견
↓
결함 보고
↓
WG14 검토
↓
답변·해석·다음 판 수정
결함 보고는 다음 표준 판이나 기술 정오표에 반영될 수 있다. C90과 C99, C11·C17, C23에는 각각 별도의 이슈 목록이 관리된다.[69]
위원회 초안과 결함 보고는 표준의 해석을 이해하는 중요한 자료지만, 발행된 ISO 표준 자체와 완전히 같은 법적·규범적 지위를 가진다고 단순화해서는 안 된다.
규범 문서와 공개 초안
정식 ISO 표준 문서는 ISO에서 배포된다. WG14는 표준 개발 과정의 공개 작업 초안을 제공하기도 한다.
C23의 마지막 공개 작업 문서인 N3220은 ISO/IEC 9899:2024와 매우 가까운 내용을 담고 있어 문법과 라이브러리 규정을 확인하는 데 널리 사용된다. 그러나 N3220 표지에는 작업 초안임이 명시되어 있으므로 정식 표준 문서와 구분해야 한다.[70]
정식 규범 문서
→ ISO/IEC 9899:2024
공개 작업 문서
→ WG14 N3220
표준의 정확한 적합성 판단과 계약에서는 정식 발행 문서를 기준으로 하고, 학습과 구현 비교에는 공개 초안과 근거 문서, 결함 보고를 함께 활용할 수 있다.
표준과 구현체 확장
적합한 구현체는 엄격히 적합한 프로그램의 동작을 바꾸지 않는 범위에서 확장 기능을 제공할 수 있다.
대표적인 확장에는 다음이 있다.
- 인라인 어셈블리
- 벡터 타입
- 함수와 변수 속성
- 문장 표현식
- 128비트 정수
- 플랫폼별 호출 규약
- 섹션 배치 지정
- 컴파일러 내장 함수
- 확장 pragma
- 가변 크기 구조체
- 운영체제 헤더
#if defined(__GNUC__)
__attribute__((always_inline))
#endif
static inline int square(int value) {
return value * value;
}
확장은 해당 컴파일러에서 유용할 수 있지만 다른 구현체에서는 사용할 수 없다.
표준 코드와 확장 코드를 조건부로 분리할 수 있다.
#if defined(_MSC_VER)
#define FORCE_INLINE __forceinline
#elif defined(__GNUC__) || \
defined(__clang__)
#define FORCE_INLINE \
__attribute__((always_inline)) inline
#else
#define FORCE_INLINE inline
#endif
조건부 컴파일만으로 확장의 의미 차이와 ABI 호환성이 자동으로 해결되는 것은 아니다.
표준 라이브러리와 ABI
C 표준은 함수의 이름과 타입, 의미를 규정하지만 실제 이진 호출 규약과 라이브러리 파일 이름은 규정하지 않는다.
void *malloc(size_t size);
실제 구현에서는 다음 요소가 ABI에 따라 결정된다.
- 인수 전달 레지스터
- 반환값 위치
- 심벌 이름
- 동적 라이브러리 형식
size_t의 실제 기본 타입- 오류 상태 저장 방식
- 심벌 버전
- 호출 규약
같은 C 원형이라도 서로 다른 ABI로 빌드한 라이브러리를 직접 연결할 수 없을 수 있다.
ISO C 함수 선언
+
플랫폼 ABI
+
라이브러리 구현
=
실제 호출 가능한 인터페이스
버전 호환성
새로운 C 표준은 이전 표준의 모든 프로그램을 완전히 변경 없이 받아들이도록 보장하지 않는다.
예를 들어 다음 기능은 시간이 지나며 제거되거나 의미가 바뀌었다.
- 암묵적
int - 암묵적 함수 선언
- 구식 식별자 목록 함수 정의
- 빈 함수 매개변수 목록의 의미
gets- 일부 폐기 예정 기능
- 오래된 전처리 및 선언 관행
/* 오래된 C */
main() {
undeclared_function();
}
현대 C에서는 반환 타입과 함수 선언이 필요하다.
int declared_function(void);
int main(void) {
return declared_function();
}
새 표준을 채택할 때는 컴파일러 경고와 마이그레이션 문서를 확인해야 한다.
표준 라이브러리의 안정성
C 표준 라이브러리는 오랜 기간 유지된 비교적 작은 함수 중심 인터페이스를 제공한다. 이 때문에 다른 언어와 운영체제, 런타임이 C 라이브러리와 ABI를 공통 경계로 사용하는 경우가 많다.
그러나 오래된 인터페이스에는 현대적인 안전 요구에 부족한 부분도 있다.
대표적인 문제는 다음과 같다.
- 배열 길이가 별도로 전달되지 않는 함수
- 널 종료 문자열 의존
- 오류 반환 방식의 불일치
- 전역 상태
- 제한적인 문자 인코딩 모델
- 동적 할당 소유권을 타입으로 표현하지 않음
- 형식 문자열과 가변 인수
- 런타임 범위 검사 부재
strcpy(destination, source);
strcpy에는 대상 버퍼의 크기 정보가 없다.
길이를 사전에 검사하거나 크기 정보를 포함한 별도 인터페이스를 설계해야 한다.
bool copy_text(
char *destination,
size_t capacity,
const char *source
) {
size_t length = strlen(source);
if (length >= capacity) {
return false;
}
memcpy(
destination,
source,
length + 1
);
return true;
}
라이브러리 사용 원칙
표준 라이브러리를 안전하고 이식 가능하게 사용하려면 다음 원칙이 필요하다.
- 필요한 표준 헤더를 정확히 포함한다.
- 함수 선언을 직접 다시 작성하지 않는다.
- 반환값과 오류 상태를 검사한다.
- 배열과 메모리 크기를 명확히 관리한다.
- 형식 문자열과 인수 타입을 일치시킨다.
- 객체 수명과 소유권을 문서화한다.
- 문자열의 널 종료를 보장한다.
- 정수 크기 계산의 오버플로를 검사한다.
memcpy와memmove의 계약을 구분한다.- 문자 분류 함수에는 유효한 인수를 전달한다.
- 지역화와 전역 상태의 영향을 고려한다.
- 스레드 공유 객체에는 동기화를 적용한다.
- 선택 기능과 구현체 확장을 조건부로 확인한다.
- ISO C와 플랫폼 API를 구분한다.
표준과 표준 라이브러리의 종합
C 국제 표준은 언어 문법과 표준 라이브러리를 분리된 제품으로 취급하지 않는다. ISO/IEC 9899 안에서 언어와 라이브러리, 번역 환경과 실행 환경, 구현체의 적합성과 프로그램의 이식성을 함께 규정한다.
ISO/IEC 9899
├── C 언어
│ ├── 타입
│ ├── 표현식
│ ├── 선언
│ ├── 문장
│ ├── 함수
│ └── 전처리
│
├── 실행 모델
│ ├── 번역
│ ├── 객체와 메모리
│ ├── 프로그램 시작
│ └── 프로그램 종료
│
├── 적합성
│ ├── 호스트 구현
│ ├── 독립 실행 구현
│ ├── 엄격히 적합한 프로그램
│ └── 구현 정의 특성
│
└── 표준 라이브러리
├── 공통 타입과 한계
├── 입출력
├── 문자열과 메모리
├── 수치 계산
├── 시간과 지역화
├── 동적 메모리
├── 원자 연산
└── 스레드와 동기화
표준은 모든 컴퓨터가 동일한 하드웨어와 운영체제를 사용하는 것처럼 고정하지 않는다. 대신 구현체가 반드시 보장해야 하는 동작과 구현체가 선택하여 문서화할 수 있는 부분을 구분한다. 이러한 방식은 작은 마이크로컨트롤러부터 데스크톱과 서버, 운영체제 커널과 고성능 계산 환경까지 C를 구현할 수 있게 한다.
표준 라이브러리도 운영체제 전체를 추상화하지 않는다. 파일과 문자열, 메모리, 수학과 시간 등 이식 가능한 공통 기반을 제공하고, 네트워크와 그래픽스, 프로세스와 장치 제어 같은 기능은 플랫폼 API와 외부 라이브러리에 맡긴다.
C 프로그램의 이식성은 단순히 표준 헤더를 포함하는 것만으로 얻어지지 않는다. 프로그램은 각 함수의 전제 조건과 객체 수명, 타입 범위와 오류 처리, 선택 기능과 구현 정의 특성을 지켜야 한다. ISO C와 플랫폼 확장, 컴파일러 방언 및 ABI의 경계를 명확하게 구분해야 한다.
C 표준과 표준 라이브러리는 수십 년 동안 축적된 기존 프로그램을 보존하면서도 새로운 하드웨어와 실행 환경, 수치 계산과 동시성, 문자 인코딩과 안전성 요구를 반영해 발전해 왔다. 이러한 보수적인 연속성과 폭넓은 구현 가능성이 C가 시스템 인터페이스와 네이티브 소프트웨어의 공통 기반으로 유지되는 중요한 이유다.
구현과 컴파일러
C 표준은 프로그램의 문법과 의미, 번역 및 실행 환경과 표준 라이브러리의 인터페이스를 규정하지만, 이를 실제 기계에서 동작하게 만드는 구체적인 방법은 하나로 고정하지 않는다. C 구현체는 표준에 규정된 프로그램을 받아들이고 대상 환경에서 요구되는 동작을 제공해야 하며, 컴파일러와 전처리기, 어셈블러, 링커, 표준 라이브러리 및 런타임이 하나의 구현체를 구성할 수 있다.
일상적으로는 이 전체 체계를 하나의 C 컴파일러라고 부르지만, 엄밀히 말하면 컴파일러 실행 파일 하나와 완전한 C 구현체는 같은 개념이 아니다.
C 구현체
├── 컴파일러 드라이버
├── C 전처리기
├── C 프런트엔드
├── 중간 표현과 최적화기
├── 대상 코드 생성기
├── 어셈블러
├── 링커
├── 시작 코드
├── 컴파일러 런타임
└── C 표준 라이브러리
예를 들어 Clang을 이용해 Linux 프로그램을 빌드하더라도 다음 구성 요소가 조합될 수 있다.
Clang
→ C 문법·타입 분석
LLVM
→ 중간 표현과 최적화·코드 생성
GNU as 또는 LLVM 통합 어셈블러
→ 객체 파일 생성
GNU ld·gold·LLD
→ 링크
glibc 또는 musl
→ C 표준 라이브러리와 운영체제 인터페이스
compiler-rt 또는 libgcc
→ 컴파일러 지원 런타임
따라서 “Clang으로 빌드했다”는 표현이 Clang이 표준 라이브러리와 링커까지 독자적으로 모두 제공한다는 뜻은 아니다. 대상 플랫폼과 도구체인 구성에 따라 서로 다른 구성 요소가 결합될 수 있다.
구현체와 컴파일러
C 표준에서 구현체는 C 프로그램을 번역하고 실행하는 데 필요한 전체 환경을 의미한다. 구현체는 호스트 구현 또는 독립 실행 구현이 될 수 있다.
호스트 구현은 일반적으로 다음 요소를 제공한다.
main을 통한 프로그램 시작- 전체 C 표준 라이브러리
- 파일과 스트림
- 정상적인 프로그램 종료
- 실행 환경과 운영체제 연결
독립 실행 구현은 다음과 같은 환경에서 사용된다.
- 운영체제 커널
- 부트로더
- 마이크로컨트롤러
- 실시간 제어기
- 제한된 임베디드 장치
- 자체 런타임을 가진 프로그램
독립 실행 환경에서는 컴파일러만 제공하고 표준 라이브러리의 일부만 제공하거나, 사용자가 시작 코드와 메모리 할당, 입출력을 직접 구현할 수 있다.
컴파일러의 구성
현대적인 C 컴파일러는 대체로 다음 단계로 구성된다.
C 소스
↓
프런트엔드
↓
중간 표현
↓
최적화기
↓
백엔드
↓
어셈블리·기계 코드
프런트엔드
프런트엔드는 C 언어를 직접 이해하는 부분이다.
주요 역할은 다음과 같다.
- 전처리 토큰 처리
- 문법 분석
- 타입 검사
- 선언과 범위 분석
- 상수 표현식 평가
- 제약 위반 진단
- 추상 구문 트리 생성
- 언어 의미를 중간 표현으로 변환
int add(int left, int right) {
return left + right;
}
프런트엔드는 이 코드를 분석하여 다음 정보를 구성한다.
함수 이름: add
반환 타입: int
매개변수: int, int
연결: 외부 연결
본문: 두 정수의 덧셈 결과 반환
중간 표현
중간 표현은 소스 언어와 특정 CPU 명령어 사이의 내부 표현이다.
C
↓
GENERIC
↓
GIMPLE
↓
RTL
↓
기계 코드
GCC는 내부적으로 GENERIC과 GIMPLE, RTL 등의 표현을 사용한다. GCC 내부 문서는 GIMPLE을 주요 중간 표현 가운데 하나로 설명한다.[71]
LLVM 기반 컴파일러는 LLVM IR을 공통 중간 표현으로 사용한다.
define i32 @add(i32 %left, i32 %right) {
entry:
%result = add i32 %left, %right
ret i32 %result
}
중간 표현을 사용하면 언어 분석과 대상 CPU 코드 생성을 분리할 수 있다.
C 프런트엔드 ─┐
C++ 프런트엔드 ├→ 공통 중간 표현 → 여러 CPU 백엔드
Fortran 프런트엔드 ┘
최적화기
최적화기는 프로그램의 관찰 가능한 의미를 유지하면서 코드를 변형한다.
대표적인 최적화는 다음과 같다.
- 상수 접기
- 죽은 코드 제거
- 함수 인라인화
- 공통 부분식 제거
- 루프 전개
- 루프 벡터화
- 자동 벡터화
- 분기 단순화
- 전역 상수 전파
- 별칭 분석
- 프로시저 간 최적화
- 링크 시간 최적화
int calculate(void) {
int value = 10 * 20;
return value;
}
컴파일러는 실행 시간 곱셈을 제거하고 다음과 같은 결과를 만들 수 있다.
int calculate(void) {
return 200;
}
백엔드
백엔드는 중간 표현을 대상 프로세서의 명령으로 변환한다.
LLVM IR 또는 GIMPLE
↓
명령 선택
↓
레지스터 할당
↓
명령 스케줄링
↓
x86-64·AArch64·RISC-V 등의 기계 코드
하나의 C 프런트엔드가 여러 CPU를 지원할 수 있는 이유는 백엔드가 분리되어 있기 때문이다.
컴파일러 드라이버
gcc, clang, cl 같은 명령은 일반적으로 컴파일러 드라이버다. 드라이버는 전처리와 컴파일, 어셈블 및 링커 실행을 조정한다.
clang main.c -o application
드라이버는 내부적으로 다음 작업을 수행할 수 있다.
clang
├── C 프런트엔드 실행
├── LLVM 최적화와 코드 생성
├── 어셈블러 실행
├── 시작 객체 파일 추가
├── 기본 라이브러리 추가
├── 링커 실행
└── 실행 파일 생성
GCC 문서는 전체 빌드 과정을 전처리, 컴파일, 어셈블과 링크의 단계로 설명한다.[72]
네이티브 컴파일러와 교차 컴파일러
현재 실행 중인 환경과 같은 대상을 생성하면 네이티브 컴파일이라고 한다.
x86-64 Linux
↓ 컴파일
x86-64 Linux 실행 파일
다른 환경을 대상으로 하면 교차 컴파일이라고 한다.
x86-64 Linux
↓ 컴파일
ARM Cortex-M 펌웨어
교차 컴파일러에는 대상 시스템의 다음 정보가 필요하다.
- 명령어 집합
- ABI
- 헤더
- 표준 라이브러리
- 시작 코드
- 링커 스크립트
- 객체 파일 형식
- sysroot
arm-none-eabi-gcc
aarch64-linux-gnu-gcc
x86_64-w64-mingw32-gcc
자체 호스팅
C로 작성된 C 컴파일러가 자신을 다시 컴파일할 수 있으면 자체 호스팅 컴파일러라고 한다.
기존 컴파일러
↓
새 C 컴파일러 소스 번역
↓
새 컴파일러 실행 파일
↓
자신의 소스를 다시 번역
새로운 플랫폼으로 컴파일러를 옮길 때는 부트스트랩 과정이 필요할 수 있다.
1단계 컴파일러
→ 최소 기능
2단계 컴파일러
→ 1단계 결과로 전체 컴파일러 빌드
3단계 컴파일러
→ 결과 비교와 검증
GCC는 일반적인 빌드 과정에서 여러 단계의 부트스트랩을 사용해 컴파일러 자체를 다시 빌드하고 결과를 비교할 수 있다.
최초의 C 컴파일러
최초의 C 컴파일러는 데니스 리치가 PDP-11용으로 작성했다. C는 B를 기반으로 자료형과 포인터 연산, 구조체와 더 일반적인 선언 체계를 추가하며 발전했고, 컴파일러 역시 초기 언어 변화와 함께 수정되었다.
데니스 리치가 보존한 last1120c와 prestruct-c는 1972년에서 1973년 사이의 초기 C 컴파일러 모습을 보여준다.
last1120c
→ 구조체 도입 이전의 초기 C
prestruct-c
→ 구조체가 추가되기 시작한 C
초기 컴파일러는 두 개의 주요 패스로 구성되었다.
첫 번째 패스
→ 소스 분석과 트리 생성
두 번째 패스
→ 트리를 PDP-11 어셈블리로 변환
초기 C 컴파일러는 현대적인 다중 아키텍처 컴파일러가 아니라 PDP-11과 초기 UNIX 개발을 위한 도구였다. 그러나 컴파일러 자체가 C로 작성되면서 C가 컴파일러를 구현할 수 있을 정도로 성숙했음을 보여주었다.[73]
Portable C Compiler
Portable C Compiler은 스티븐 C. 존슨이 Bell Labs에서 개발한 이식 가능한 C 컴파일러다. 일반적으로 PCC 또는 pcc라고 부른다.
초기 C 컴파일러는 PDP-11에 밀접하게 연결되어 있었다. PCC는 기계 독립적인 분석 부분과 대상별 코드 생성 부분을 분리하여 새로운 아키텍처로 옮기기 쉽게 설계되었다.
C 프런트엔드
↓
기계 독립적인 중간 구조
↓
대상별 코드 생성기
PCC는 UNIX를 여러 컴퓨터로 확산하는 데 중요한 역할을 했다. 데니스 리치는 스티븐 존슨의 portable compiler가 널리 사용되었다고 기록했다.[74]
PCC는 UNIX 제7판을 비롯한 여러 UNIX 계열에 포함됐고, 초기 상용 UNIX와 BSD 환경에서 중요한 C 컴파일러로 사용되었다. 이후 많은 시스템에서 GCC로 대체되었지만, 컴파일러의 기계 독립 부분과 대상 의존 부분을 분리하는 설계에 큰 영향을 남겼다.
현대의 Portable C Compiler 프로젝트는 원래 PCC를 기반으로 코드를 크게 수정했으며, C99와 이후 표준의 일부 기능 및 여러 대상을 지원한다.[75]
Amsterdam Compiler Kit
Amsterdam Compiler Kit은 여러 언어와 프로세서를 지원하도록 개발된 컴파일러 도구 모음이다. 일반적으로 ACK라고 부른다.
ACK의 핵심 개념은 소스 언어를 공통 중간 언어로 변환하고, 이를 여러 기계의 코드로 바꾸는 구조였다.
C·Pascal·Modula-2
↓
공통 중간 코드
↓
여러 프로세서
ACK는 교육과 연구뿐 아니라 MINIX 초기 도구체인에서도 사용되었다. 이후 MINIX는 GCC와 Clang 계열 도구체인을 사용할 수 있게 되었지만, ACK는 작고 이식 가능한 다중 언어 컴파일러 구조의 역사적 사례로 남아 있다.
UNIX 벤더 컴파일러
UNIX가 여러 하드웨어 회사로 확산되면서 각 벤더는 자체 C 컴파일러를 제공했다.
대표적인 계열에는 다음이 있었다.
- AT&T C Compiler
- Sun C와 SunPro C
- DEC C
- HP C
- IBM XL C
- SGI MIPSpro C
- Cray C
- SCO C
- IRIX와 AIX 전용 컴파일러
이들 컴파일러는 단순히 ISO C를 번역하는 것뿐 아니라 각 회사의 프로세서와 운영체제에 맞춘 최적화, 벡터 명령과 병렬 처리, 디버거 및 성능 분석 도구를 제공했다.
벤더 C 컴파일러
├── 자체 CPU 최적화
├── 운영체제 ABI
├── 시스템 라이브러리
├── 디버거
├── 프로파일러
└── 병렬·벡터 확장
워크스테이션과 서버 시장에서는 GCC가 널리 보급되기 전까지 이러한 벤더 컴파일러가 중요한 위치를 차지했다.
GCC
GCC는 GNU Compiler Collection의 컴파일러 제품군이다. 처음에는 GNU C Compiler라는 이름의 C 컴파일러로 시작했으며, 이후 C++와 Objective-C, Fortran, Ada 등 여러 언어 프런트엔드를 포함하면서 GNU Compiler Collection이라는 이름으로 확장되었다.
GCC 프로젝트의 목적은 자유 소프트웨어 운영체제에 사용할 수 있는 자유로운 최적화 컴파일러를 제공하는 것이었다. GCC는 GNU 시스템과 Linux 생태계의 핵심 도구체인으로 성장했고, 여러 UNIX 계열과 임베디드 플랫폼에서도 널리 사용된다.
GCC는 다음과 같은 구조를 가진다.
언어 프런트엔드
├── C
├── C++
├── Objective-C
├── Fortran
├── Ada
└── 기타 언어
↓
GENERIC
↓
GIMPLE
↓
최적화
↓
RTL
↓
대상 백엔드
GCC 내부 문서는 언어 프런트엔드와 GENERIC, GIMPLE, RTL, 대상 기술 구조를 별도 계층으로 설명한다.[76]
GCC의 특징
GCC의 주요 특징은 다음과 같다.
- 매우 다양한 CPU 아키텍처 지원
- 여러 운영체제와 ABI 지원
- 높은 수준의 최적화
- GNU C 확장
- OpenMP 지원
- 링크 시간 최적화
- 자동 벡터화
- 경고와 정적 분석
- 프로파일 유도 최적화
- 광범위한 임베디드 도구체인
- 자유 소프트웨어 라이선스
대표적인 대상에는 다음이 포함된다.
x86
x86-64
AArch64
Arm
RISC-V
PowerPC
IBM Z
MIPS
AVR
MSP430
LoongArch
여러 임베디드 프로세서
지원 대상은 GCC 버전과 배포판 구성에 따라 달라진다.
GCC의 C 방언
GCC는 ISO C 표준 모드와 GNU 확장 모드를 제공한다.
-std=c90
-std=c99
-std=c11
-std=c17
-std=c23
GNU 확장을 함께 사용하는 모드는 다음과 같다.
-std=gnu90
-std=gnu99
-std=gnu11
-std=gnu17
-std=gnu23
GNU C 확장에는 다음과 같은 기능이 포함될 수 있다.
- 문장 표현식
typeof- 중첩 함수
- 계산된
goto - 함수·변수 속성
- 인라인 어셈블리
- 0길이 배열
- 확장 벡터 타입
__int128- 내장 함수
- 섹션과 가시성 지정
일부 GNU 확장은 이후 ISO C에 비슷한 형태로 표준화되기도 했다. 예를 들어 typeof는 오래전부터 GNU C에서 제공됐고 C23에서 표준 기능으로 도입됐다. 그러나 GNU 확장과 표준 기능의 세부 문법과 의미가 항상 완전히 같다고 가정해서는 안 된다.
GCC의 최적화
GCC는 GIMPLE 기반의 기계 독립 최적화와 RTL 기반의 대상별 최적화를 수행한다.
대표적인 기능은 다음과 같다.
-O1
-O2
-O3
-Os
-Ofast
-flto
-fprofile-generate
-fprofile-use
GCC는 자동 벡터화와 루프 최적화, 함수 인라인화, 전역 최적화와 링크 시간 최적화를 지원한다.
GCC의 현재 개발
GCC는 정기적으로 주요 버전을 발표하며 여러 안정화 브랜치를 유지한다. GCC 공식 발표에 따르면 GCC 16.1은 2026년 4월 30일 공개되었다.[77]
GCC의 버전 번호는 C 표준 판과 직접 대응하지 않는다. 특정 GCC 버전이 어떤 C23 기능을 지원하는지는 해당 버전의 변경 기록과 C 구현 상태를 확인해야 한다.
LLVM과 Clang
LLVM은 재사용 가능한 컴파일러와 도구체인 기술의 집합이며, Clang은 LLVM 프로젝트의 C·C++·Objective-C 계열 프런트엔드다.
Clang
→ C 계열 프런트엔드
LLVM IR
→ 공통 중간 표현
LLVM 최적화기
→ 기계 독립 최적화
LLVM 백엔드
→ 대상 코드 생성
LLVM 공식 소개는 Clang을 빠른 컴파일과 유용한 진단, 소스 수준 도구 구축을 목표로 하는 LLVM 네이티브 C·C++·Objective-C 컴파일러로 설명한다.[78]
Clang의 등장 배경
GCC는 강력한 컴파일러였지만, 역사적으로 내부 구조를 독립적인 라이브러리로 재사용하거나 IDE와 분석 도구에 통합하기가 쉽지 않은 시기가 있었다. Clang은 다음 목표를 중심으로 개발됐다.
- 빠른 프런트엔드
- 명확한 오류 진단
- 라이브러리로 재사용 가능한 구조
- IDE와 코드 분석 도구 지원
- GCC 명령행과 확장에 대한 높은 호환성
- LLVM 최적화·코드 생성 기반 활용
Clang의 추상 구문 트리는 다음과 같은 도구에서 활용된다.
- 코드 완성
- 리팩터링
- 정적 분석
- 소스 인덱싱
- 문서 생성
- 형식 검사
- 언어 서버
- 자동 코드 변환
Clang의 C 표준 지원
Clang은 C89·C90, C99, C11, C17과 C23 기능을 단계적으로 지원한다. Clang 공식 C 상태 문서는 C99 전체를 구현한다고 밝히며, 이후 표준 기능별 구현 상태도 관리한다.[79]
clang -std=c99
clang -std=c11
clang -std=c17
clang -std=c23
GNU 확장을 포함할 수도 있다.
clang -std=gnu17
clang -std=gnu23
Clang은 많은 GCC 확장과 명령행 옵션을 지원하지만 완전한 GCC 복제는 아니다. 확장의 의미와 진단, 대상 지원 및 최적화 결과가 다를 수 있다.
LLVM IR
Clang은 C 소스를 LLVM IR로 변환한다.
clang -S -emit-llvm main.c
LLVM IR은 여러 LLVM 기반 언어와 도구가 공유하는 중간 표현이다.
Clang
Rust 프런트엔드
Swift
다른 언어와 도구
↓
LLVM IR
↓
LLVM 백엔드
각 언어가 LLVM을 사용한다고 해서 언어의 의미와 ABI가 같아지는 것은 아니다.
Clang 계열 도구
Clang 생태계에는 다음 도구가 포함된다.
clang
clang-cl
clangd
clang-format
clang-tidy
Clang Static Analyzer
scan-build
clang-scan-deps
clang-cl은 Microsoft cl.exe와 유사한 명령행 인터페이스를 제공하며 Windows의 MSVC ABI와 라이브러리를 사용할 수 있다.
clang
→ GCC 스타일 명령행
clang-cl
→ MSVC 스타일 명령행
Apple Clang
Apple은 macOS와 iOS, iPadOS, watchOS 등의 개발 도구에서 LLVM과 Clang을 기반으로 한 Apple Clang을 제공한다.
Apple Clang은 업스트림 Clang을 기반으로 하지만 다음 요소가 결합된다.
- Apple 플랫폼 SDK
- Mach-O 객체 형식
- Apple ABI
- Xcode 도구체인
- Apple 링커
- Objective-C와 Objective-C++
- Apple 플랫폼별 확장
- 배포 대상 버전 처리
Apple Clang
+
macOS SDK
+
Apple libc
+
ld64 또는 Apple 링커
=
macOS 네이티브 도구체인
Apple Clang의 버전 번호는 업스트림 LLVM·Clang 버전 번호와 직접 같지 않을 수 있다. 지원 기능은 Xcode와 Command Line Tools 버전의 문서를 확인해야 한다.
MSVC
Microsoft Visual C++ 컴파일러는 일반적으로 MSVC 또는 cl.exe라고 부른다. 이름에는 C++가 포함되지만 C와 C++ 소스를 모두 처리하며, Windows 개발을 위한 Microsoft의 주요 네이티브 컴파일러다.
cl.exe
→ C와 C++ 프런트엔드·드라이버
link.exe
→ PE/COFF 링커
Microsoft CRT
→ C 런타임과 표준 라이브러리
Windows SDK
→ Win32 API와 시스템 라이브러리
MSVC는 다음 환경과 밀접하게 통합된다.
- Visual Studio
- MSBuild
- Windows SDK
- Universal C Runtime
- Windows 디버거
- PDB 디버그 정보
- PE/COFF
- Windows x64·Arm64 ABI
MSVC의 C 모드
MSVC는 소스 파일 확장자와 옵션에 따라 C 또는 C++로 번역한다.
file.c
→ C 소스
file.cpp
→ C++ 소스
명시적으로 지정할 수도 있다.
/TC
→ C로 처리
/TP
→ C++로 처리
MSVC의 표준 지원
MSVC는 오랫동안 C90 기반 언어와 Microsoft 확장을 중심으로 제공됐으며, 이후 C11과 C17 기능을 추가했다. Microsoft는 C 표준 기능별 적합성 상태를 별도 문서로 관리한다.[80]
C 표준 모드는 다음과 같은 옵션으로 선택할 수 있다.
/std:c11
/std:c17
Microsoft 문서에 따르면 /std 옵션은 지원하는 C 또는 C++ 표준 모드를 선택한다.[81]
MSVC의 C11·C17 지원은 모든 선택 기능과 라이브러리 요소를 완전하게 제공한다는 뜻은 아니다. 특히 다음 영역은 버전별 확인이 필요하다.
- 가변 길이 배열
- 복소수
- C 표준 스레드
- 원자적 연산
- 전처리기 적합성
- C99 수학 및 라이브러리 기능
- C23 기능
MSVC는 토큰 기반의 더 적합한 전처리기를 /Zc:preprocessor 옵션으로 제공한다.[82]
Microsoft 확장
MSVC는 Windows와 프로세서 기능을 위해 다양한 확장을 제공한다.
__declspec
__stdcall
__cdecl
__vectorcall
__forceinline
__int64
SEH
컴파일러 내장 함수
Microsoft pragma
예를 들어 동적 라이브러리 심벌을 내보낼 수 있다.
__declspec(dllexport)
int add(int left, int right) {
return left + right;
}
이 문법은 ISO C가 아니라 Microsoft 확장이다.
GCC와 Clang, MSVC의 관계
세 컴파일러는 현대 C 개발에서 가장 널리 접하는 범용 컴파일러지만 목적과 환경이 다르다.
| 항목 | GCC | Clang | MSVC |
|---|---|---|---|
| 주 생태계 | GNU·Linux·UNIX·임베디드 | LLVM·여러 운영체제·도구 | Windows·Visual Studio |
| 명령 | gcc | clang | cl |
| 주요 중간 표현 | GIMPLE·RTL | LLVM IR | Microsoft 내부 표현 |
| 주요 객체 형식 | ELF 등 다수 | ELF·Mach-O·COFF 등 | PE/COFF |
| 확장 | GNU C | GNU·Clang 확장 | Microsoft 확장 |
| 라이선스 계열 | GPL | Apache 2.0 with LLVM exceptions | 독점 소프트웨어 |
| 도구 통합 | GNU 도구체인 | 라이브러리·IDE 도구 중심 | Visual Studio·Windows SDK |
| 대상 범위 | 매우 넓음 | 매우 넓음 | Windows 중심 |
어느 하나가 모든 상황에서 절대적으로 우수한 것은 아니다.
Linux 배포판과 GNU 생태계
→ GCC 또는 Clang
macOS·Apple 플랫폼
→ Apple Clang
Windows MSVC ABI
→ MSVC 또는 clang-cl
광범위한 교차 컴파일
→ GCC 또는 Clang
IDE·정적 분석·도구 개발
→ Clang 생태계가 자주 사용
특정 벤더 CPU 최적화
→ 벤더 컴파일러 고려
Intel C Compiler
Intel은 오랫동안 Intel C Compiler와 Intel C++ Compiler를 제공했다. 전통적인 컴파일러 명령은 icc와 icl이었다.
icc
→ Linux·UNIX 계열 Intel C Compiler
icl
→ Windows용 Intel C/C++ Compiler
Intel 컴파일러는 다음 영역에 초점을 맞췄다.
- Intel 프로세서 최적화
- 자동 벡터화
- SIMD 명령
- 프로파일 유도 최적화
- 수치 계산
- OpenMP
- 고성능 컴퓨팅
- Intel 성능 라이브러리 통합
Intel은 이후 LLVM 기술을 기반으로 한 Intel oneAPI DPC++/C++ Compiler를 주력으로 전환했다. 명령은 일반적으로 icx와 icpx를 사용한다.
icx
→ C와 C++용 LLVM 기반 드라이버
icpx
→ 주로 C++와 SYCL 용도
Intel oneAPI 도구 모음은 CPU와 GPU 등 다양한 아키텍처를 대상으로 하는 컴파일러와 라이브러리, 분석 도구를 제공한다.[83]
Intel 컴파일러는 Clang·LLVM을 기반으로 하더라도 Intel 대상 최적화와 벡터화, 수학 라이브러리 및 분석 기능을 추가로 제공할 수 있다.
IBM XL C와 Open XL C/C++
IBM은 AIX와 Linux on Power, z/OS를 위해 IBM XL C와 XL C/C++ 컴파일러를 제공해 왔다.
전통적인 XL 컴파일러는 IBM Power와 IBM Z 아키텍처, AIX 및 z/OS 환경에 맞춘 최적화와 벡터화, 엔터프라이즈 ABI 지원을 제공했다.
XL C
├── POWER 최적화
├── IBM Z 최적화
├── AIX ABI
├── z/OS 환경
├── 벡터화
└── 고성능 수치 계산
IBM의 차세대 제품인 Open XL C/C++는 Clang과 LLVM 기술을 채택했다. IBM은 z/OS용 Open XL C/C++가 Clang·LLVM 기술 프레임워크를 기반으로 하며 C17·C18과 C++ 표준을 지원한다고 설명한다.[84]
AIX와 Linux on Power용 Open XL도 LLVM·Clang 인프라를 이용하면서 IBM Power 하드웨어 최적화를 제공한다.[85]
Arm Compiler
Arm은 Arm 프로세서를 위한 상용 임베디드 C·C++ 컴파일러를 제공한다. 전통적으로 Arm Compiler 5는 armcc를 사용했고, Arm Compiler 6은 LLVM과 Clang 기술을 기반으로 armclang을 사용한다.
Arm Compiler 5
→ armcc 기반
Arm Compiler 6
→ Clang·LLVM 기반 armclang
Arm Compiler는 다음 분야에 사용된다.
- Cortex-M 펌웨어
- Cortex-R 실시간 시스템
- Cortex-A 임베디드 시스템
- 고신뢰성 장치
- 자동차와 산업 제어
- 코드 크기 최적화
- Arm ABI
- Arm 특화 명령과 내장 함수
Arm은 Arm Compiler for Embedded를 성숙한 Arm용 임베디드 도구체인으로 제공한다.[86]
Arm의 구현 정의 문서는 ISO C가 구현체에 맡긴 타입 크기와 변환, 번역 한계와 라이브러리 특성을 설명한다.[87]
기능 안전 분야에는 인증과 평가를 고려한 Arm Compiler for Embedded FuSa 제품도 존재한다.[88]
GNU Arm Toolchain
Arm은 GCC를 기반으로 한 GNU Arm Toolchain도 배포한다.
arm-none-eabi-gcc
→ 운영체제 없는 Arm 임베디드 대상
aarch64-none-linux-gnu-gcc
→ AArch64 Linux 대상
GNU Arm Embedded Toolchain은 GCC와 Binutils, C 라이브러리 및 디버깅 도구를 조합한 도구체인이다. Arm은 이를 Arm Cortex 계열을 위한 오픈 소스 C·C++ 도구 모음으로 제공해 왔다.[89]
Arm Compiler와 GNU Arm Toolchain은 서로 다른 제품이다.
Arm Compiler
→ Arm의 상용·전용 최적화 도구체인
GNU Arm Toolchain
→ GCC 기반 자유 소프트웨어 도구체인
IAR C/C++
IAR Embedded Workbench의 C·C++ 컴파일러는 마이크로컨트롤러와 임베디드 개발에서 사용되는 상용 도구체인이다.
주요 특징은 다음과 같다.
- 다양한 마이크로컨트롤러 지원
- 코드 크기 최적화
- 디버거와 IDE 통합
- 링커와 라이브러리 통합
- 기능 안전 인증 제품
- 장치별 메모리 모델
- 임베디드 전용 확장
IAR 컴파일러는 다음과 같은 대상 계열에 사용된다.
Arm
RISC-V
AVR
MSP430
8051
Renesas 계열
STM8
기타 MCU
정확한 지원 대상과 C 표준 판은 제품과 버전에 따라 다르다.
Keil C 컴파일러
Keil은 여러 마이크로컨트롤러 계열을 위한 C 컴파일러로 잘 알려져 있다. Keil 제품군은 현재 Arm 생태계에 포함되어 있다.
대표적인 계열은 다음과 같다.
C51
→ 8051 계열
C251
→ 251 계열
C166
→ Infineon C16x 계열
MDK·Arm Compiler
→ Arm Cortex 계열
Keil C51과 같은 컴파일러는 일반적인 데스크톱 C와 다른 메모리 모델 확장을 제공한다.
unsigned char xdata *pointer;
xdata, code, idata 같은 키워드는 8051의 분리된 주소 공간을 표현하는 구현체 확장이다.
TI C/C++ Compiler
Texas Instruments는 자사 DSP와 마이크로컨트롤러를 위한 C·C++ 컴파일러를 제공한다.
대표 대상은 다음과 같다.
- C2000
- MSP430
- Arm 기반 TI MCU
- C6000 DSP
- 기타 TI 프로세서
TI 컴파일러는 다음 요소를 제공할 수 있다.
- DSP 명령 최적화
- 루프 소프트웨어 파이프라이닝
- 내장 함수
- 장치 레지스터 지원
- 링커 명령 파일
- 실시간 런타임
- Code Composer Studio 통합
일부 최신 TI 도구체인은 LLVM 기반으로 전환되고 있으며, 기존 독자 컴파일러와 LLVM 기반 컴파일러가 제품군에 따라 공존할 수 있다.
Green Hills Compiler
Green Hills Software의 C·C++ 컴파일러는 임베디드와 자동차, 항공우주 및 기능 안전 분야에서 사용되는 상용 컴파일러다.
주요 특징은 다음과 같다.
- 안전 중요 시스템
- 여러 임베디드 CPU 지원
- 최적화
- 정적 분석
- 인증 자료
- MULTI 개발 환경 통합
- INTEGRITY 운영체제 통합
일반적인 범용 데스크톱 개발보다 인증과 장기 지원, 임베디드 하드웨어 및 실시간 시스템에 초점을 맞춘다.
CompCert
CompCert는 형식 검증된 최적화 C 컴파일러다. 일반 컴파일러가 광범위한 테스트와 검증을 통해 신뢰성을 확보하는 것과 달리, CompCert는 컴파일 과정이 프로그램의 의미를 보존한다는 성질을 수학적으로 증명하는 접근을 사용한다.
C 소스의 의미
↓ 검증된 변환
중간 표현
↓ 검증된 변환
기계 코드 의미
CompCert는 주로 안전 중요 시스템과 컴파일러 검증 연구에서 중요하다.
일반적인 GCC나 Clang과 비교하면 다음 차이가 있다.
- 지원하는 C 범위와 확장이 제한될 수 있음
- 대상 아키텍처가 제한적일 수 있음
- 최신 공격적 최적화 전체를 목표로 하지 않음
- 의미 보존 증명이 핵심 목표
- 상용 및 연구 용도로 제공
CompCert가 모든 C 프로그램의 안전성을 증명하는 것은 아니다. 정의되지 않은 동작이 없는 소스 프로그램을 올바르게 작성하는 책임은 여전히 남는다.
Watcom C/C++
Watcom C/C++는 1980년대 후반부터 1990년대 PC 소프트웨어와 게임 개발에서 중요한 상용 컴파일러였다.
Watcom은 다음 환경을 지원했다.
- DOS
- 16비트 Windows
- 32비트 Windows
- OS/2
- Novell NLM
- DOS 확장 환경
Watcom C/C++는 x86 코드 생성 품질과 DOS/4GW 확장 환경으로 널리 알려졌다. 여러 1990년대 게임과 상용 소프트웨어 개발에 사용됐다.
Watcom 컴파일러는 1987년에 C 컴파일러 제품을 도입했다고 Open Watcom 문서에 기록되어 있다.[90]
상용 제품이 중단된 뒤 소스가 공개되어 Open Watcom 프로젝트로 이어졌다.
Turbo C와 Borland C
Borland의 Turbo C는 1980년대 PC 환경에서 널리 사용된 C 개발 환경이다.
Turbo C는 다음 요소를 하나의 제품으로 제공했다.
- C 컴파일러
- 통합 편집기
- 빌드 기능
- 디버깅
- 라이브러리
- DOS 개발 환경
당시 많은 컴파일러가 별도 명령행 도구 중심이었던 것과 달리, Turbo C는 빠른 컴파일과 통합 개발 환경을 통해 개인용 컴퓨터 사용자에게 C 개발을 쉽게 제공했다.
이후 제품은 Borland C와 Borland C++ 계열로 발전했다.
Turbo C
↓
Turbo C 2.x
↓
Borland C++
현대 ISO C 개발 도구로 사용하는 경우는 드물지만, DOS 소프트웨어와 교육용 자료, 고전 PC 프로그램의 역사에서 중요한 위치를 차지한다.
Microsoft C와 QuickC
Microsoft는 MS-DOS와 초기 Windows 시대부터 Microsoft C Compiler를 제공했다.
제품 계보에는 다음이 포함된다.
Microsoft C
QuickC
Microsoft Visual C++
MSVC
QuickC는 PC 개발자를 위한 비교적 간편한 통합 환경을 제공했고, 이후 Visual C++는 Windows 네이티브 개발의 중심 도구로 발전했다.
초기 Microsoft C와 현대 MSVC는 같은 제품 계보에 속하지만, 내부 구조와 표준 지원, 대상 ABI와 개발 환경은 크게 달라졌다.
LCC
LCC는 크리스 프레이저와 데이비드 핸슨이 개발한 작고 재대상화 가능한 C 컴파일러다.
LCC는 교육과 컴파일러 구조 설명, 새로운 대상 코드 생성 연구에 적합하도록 비교적 작은 구조를 유지했다.
C 프런트엔드
↓
기계 독립 인터페이스
↓
대상 코드 생성기
LCC의 설계와 구현은 《A Retargetable C Compiler: Design and Implementation》에 자세히 설명되어 컴파일러 교육 자료로 널리 사용됐다.
LCC는 GCC처럼 매우 많은 최적화와 언어 프런트엔드를 제공하는 종합 도구체인이 아니라, C 컴파일러의 구조를 이해하고 새로운 대상에 이식하기 쉬운 설계를 강조했다.
Open64
Open64는 고성능 컴퓨팅과 연구용 최적화 컴파일러 인프라로 사용된 오픈 소스 컴파일러다. SGI의 MIPSpro 컴파일러 기술과 관련된 계보에서 발전했다.
주요 관심 영역은 다음과 같았다.
- 고급 루프 최적화
- 프로시저 간 최적화
- 병렬화
- 과학 계산
- 연구용 컴파일러 인프라
현대 범용 C 개발에서는 GCC와 LLVM에 비해 사용 범위가 작지만, 컴파일러 최적화 연구와 여러 파생 컴파일러에 영향을 남겼다.
Oracle Developer Studio C
Sun Microsystems의 Sun C와 SunPro C는 SPARC와 Solaris 환경에서 중요한 벤더 컴파일러였다. 이후 Oracle Developer Studio C/C++ 제품으로 이어졌다.
주요 특징은 다음과 같다.
- SPARC 최적화
- Solaris ABI
- 자동 병렬화
- 프로파일링
- 수치 계산
- OpenMP
- 성능 분석기
Solaris와 SPARC 생태계에서는 GCC와 함께 주요 컴파일러 선택지였다.
컴파일러와 표준 라이브러리
같은 컴파일러라도 서로 다른 표준 라이브러리를 사용할 수 있다.
GCC
├── glibc
├── musl
├── Newlib
├── picolibc
└── 벤더 libc
Clang
├── glibc
├── musl
├── Apple libc
├── Microsoft UCRT
├── FreeBSD libc
└── WASI libc
C++에서는 컴파일러와 C++ 표준 라이브러리의 구분이 더 눈에 띄지만, C에서도 컴파일러와 libc는 별개의 구성 요소일 수 있다.
예를 들어 다음 조합이 가능하다.
Clang + glibc
Clang + musl
GCC + glibc
GCC + Newlib
Clang + UCRT
컴파일러가 C23 문법을 지원하더라도 사용하는 C 라이브러리가 C23의 새로운 함수와 헤더를 아직 제공하지 않을 수 있다.
언어 기능 지원
≠
라이브러리 기능 지원
컴파일러 런타임
컴파일러는 표준 라이브러리 외에도 내부 지원 함수를 필요로 할 수 있다.
대표적인 런타임은 다음과 같다.
libgcc
compiler-rt
MSVC compiler runtime
Arm runtime libraries
벤더별 지원 라이브러리
CPU가 직접 제공하지 않는 연산은 런타임 함수로 변환될 수 있다.
long long divide(
long long left,
long long right
) {
return left / right;
}
32비트 CPU에서는 64비트 나눗셈을 수행하는 런타임 함수를 호출할 수 있다.
검사기 기능도 런타임을 필요로 한다.
AddressSanitizer
UndefinedBehaviorSanitizer
ThreadSanitizer
프로파일 계측
코드 커버리지
링커
컴파일러와 링커도 별개의 구성 요소일 수 있다.
대표적인 링커는 다음과 같다.
- GNU ld
- GNU gold
- LLVM LLD
- Microsoft LINK
- Apple ld
- mold
- 벤더 전용 링커
Clang + LLD
Clang + GNU ld
GCC + GNU ld
MSVC + LINK
Apple Clang + Apple ld
같은 컴파일러라도 다른 링커를 사용하면 링크 속도와 지원 객체 형식, 링크 스크립트 및 최적화 기능이 달라질 수 있다.
컴파일러 확장
실제 C 구현체는 ISO C 외에 확장을 제공한다.
확장의 목적은 다음과 같다.
- 하드웨어 명령 접근
- 운영체제 ABI
- 함수 속성
- 벡터 타입
- 주소 공간
- 메모리 섹션 지정
- 인터럽트 함수
- 인라인 어셈블리
- 컴파일 시간 검사
- 성능 최적화
#if defined(__GNUC__)
__attribute__((aligned(64)))
#endif
static unsigned char buffer[64];
#if defined(_MSC_VER)
__declspec(align(64))
#endif
static unsigned char buffer[64];
C23 속성을 지원하면 일부 목적을 표준 문법으로 표현할 수 있다.
alignas(64)
static unsigned char buffer[64];
하지만 모든 구현체 확장이 표준 기능으로 대체되는 것은 아니다.
구현 정의 문서
C 표준은 여러 사항을 구현체가 선택하도록 허용한다. 적합한 구현체는 선택 결과를 문서화해야 한다.
문서화 대상에는 다음이 포함된다.
- 기본
char의 부호 - 정수 타입 크기
- 포인터 표현
- 문자 집합
- 구조체 정렬
- 시프트 동작
- 파일과 스트림
- 신호
- 라이브러리 함수의 환경별 효과
- 번역 한계
따라서 C 구현체를 평가할 때는 “C17 지원” 같은 표시만 볼 것이 아니라 구현 정의 문서와 ABI, 라이브러리 문서도 확인해야 한다.
컴파일러 적합성 검사
컴파일러의 C 표준 적합성은 하나의 예제만 성공한다고 증명되지 않는다.
검증에는 다음이 사용될 수 있다.
- 표준 기능별 테스트
- 컴파일러 회귀 테스트
- 여러 컴파일러 교차 비교
- Csmith와 같은 무작위 프로그램 생성
- ABI 테스트
- 표준 라이브러리 테스트
- 결함 보고 재현
- 실행 결과 비교
- 자체 호스팅
- 부트스트랩 결과 비교
같은 C 프로그램
├── GCC
├── Clang
├── MSVC
├── ICC·ICX
└── 기타 컴파일러
결과가 다르다고 반드시 컴파일러 버그인 것은 아니다. 프로그램이 다음에 의존할 수 있다.
- 정의되지 않은 동작
- 구현 정의 동작
- 미지정 순서
- 타입 크기
- ABI
- 확장 문법
- 라이브러리 차이
컴파일러 버그
C 표준을 따르는 프로그램인데 구현체가 잘못 번역하면 컴파일러 버그다.
대표적인 유형은 다음과 같다.
- 잘못된 최적화
- 타입 분석 오류
- 코드 생성 오류
- ABI 위반
- 전처리기 오류
- 디버그 정보 오류
- 진단 누락
- 표준 라이브러리 구현 오류
최적화 수준에 따라서만 잘못된 결과가 나타나면 다음 가능성을 함께 조사해야 한다.
프로그램의 정의되지 않은 동작
또는
컴파일러 최적화 버그
최소 재현 코드를 만들고
활용 분야
C는 운영체제와 장치 제어부터 데이터베이스, 네트워크 서버, 언어 런타임, 멀티미디어 처리와 과학 계산에 이르기까지 컴퓨터 소프트웨어의 기반을 구성하는 여러 분야에 사용된다. 특히 메모리 배치와 실행 비용, 운영체제 및 하드웨어 인터페이스를 직접 제어해야 하는 분야에서 널리 사용된다.
C가 모든 종류의 소프트웨어에서 동일한 비중으로 사용되는 것은 아니다. 운영체제 커널과 펌웨어처럼 C가 프로그램의 중심 언어가 되는 분야가 있는 반면, 웹 서비스와 데스크톱 애플리케이션처럼 상위 계층은 다른 언어로 작성하고 성능이나 호환성이 필요한 일부 모듈만 C로 구현하는 분야도 있다.
C의 활용 형태는 크게 다음과 같이 나눌 수 있다.
C가 주력으로 사용되는 분야
├── 운영체제 커널
├── 장치 드라이버
├── 임베디드 시스템과 펌웨어
├── 실시간 운영체제
├── 언어 런타임과 가상 머신
├── 시스템 라이브러리
├── 데이터베이스 엔진
├── 네트워크 기반 소프트웨어
├── 멀티미디어 코덱과 처리 라이브러리
└── 과학·고성능 데이터 라이브러리
C가 부분적으로 사용되는 분야
├── 게임과 게임 엔진
├── 그래픽스와 렌더링
├── 데스크톱 애플리케이션
├── 모바일 애플리케이션
├── 웹 서비스
├── 인공지능과 머신러닝
├── 보안 소프트웨어
└── 다른 언어의 확장 모듈
C가 널리 사용되는 이유는 언어 자체의 기능뿐 아니라 오랜 기간 유지된 컴파일러와 ABI, 운영체제 API, 라이브러리 생태계에 있다. 여러 프로그래밍 언어와 운영체제는 C 함수 인터페이스를 공통 경계로 사용하며, 기존 C 라이브러리를 직접 호출할 수 있는 외부 함수 인터페이스를 제공한다.
운영체제 커널
운영체제 커널은 C가 가장 대표적으로 사용되는 분야다. 커널은 프로세서와 메모리, 장치와 프로세스, 파일 시스템과 네트워크를 관리해야 하므로 하드웨어에 가까운 접근과 예측 가능한 실행 비용이 필요하다.
커널에서 C는 다음과 같은 작업에 사용된다.
- 프로세스와 스레드 관리
- 가상 메모리와 페이지 관리
- 파일 시스템
- 네트워크 스택
- 시스템 호출
- 인터럽트와 예외 처리
- 장치 드라이버
- 보안과 권한 검사
- CPU 아키텍처별 코드
- 동기화와 스케줄링
Linux 커널은 공식 문서에서 C로 작성된다고 명시하며, 일반적으로 GNU C11 방언으로 컴파일된다. Linux는 GCC뿐 아니라 Clang을 이용한 빌드도 지원한다.[91]
커널 코드에서는 표준 C만 사용하는 것이 아니라 컴파일러 확장과 인라인 어셈블리, 아키텍처별 내장 함수와 특수한 링크 방식을 함께 사용한다.
struct task {
unsigned long state;
void *stack;
int priority;
};
static void schedule_task(struct task *task) {
if (task == NULL) {
return;
}
task->state = TASK_RUNNING;
}
이 코드는 커널 자료 구조의 일반적인 형태를 단순화한 예시다. 실제 커널에서는 원자적 연산과 메모리 장벽, intrusive list, CPU별 데이터와 컴파일러 확장이 함께 사용될 수 있다.
C는 UNIX 계열 운영체제뿐 아니라 여러 상용 운영체제와 실시간 운영체제, 연구용 운영체제에서도 사용된다. 일부 최신 운영체제 구성 요소는 C++와 Rust 등으로 작성되기도 하지만, 기존 커널과 ABI, 장치 인터페이스의 상당 부분은 여전히 C를 중심으로 구성되어 있다.
장치 드라이버
장치 드라이버는 운영체제와 하드웨어 장치 사이의 인터페이스를 구현한다.
대표적인 대상은 다음과 같다.
- 그래픽 처리 장치
- 저장 장치
- 네트워크 카드
- USB 장치
- 오디오 장치
- 카메라
- 센서
- 입력 장치
- 버스 컨트롤러
- 전원 관리 장치
드라이버는 메모리 매핑 레지스터와 DMA 버퍼, 인터럽트, 장치별 명령과 운영체제 커널 API를 다룬다.
struct device_registers {
volatile uint32_t control;
volatile uint32_t status;
volatile uint32_t data;
};
static bool device_ready(
const struct device_registers *device
) {
return
(device->status & STATUS_READY) != 0;
}
volatile은 장치 레지스터 접근이 불필요하게 제거되는 것을 제한하는 데 사용할 수 있다. 하지만 장치 접근 순서와 CPU 메모리 장벽은 플랫폼별 API가 별도로 필요할 수 있다.
드라이버는 일반적인 ISO C 응용 프로그램이 아니다. 커널의 내부 API와 대상 프로세서, 컴파일러와 하드웨어 규격에 강하게 의존한다.
임베디드 시스템
C는 마이크로컨트롤러와 제한된 메모리, 낮은 소비 전력을 사용하는 임베디드 시스템에서 널리 사용된다.
대표적인 장치는 다음과 같다.
- 가전제품
- 자동차 전자 제어 장치
- 산업 제어기
- 의료 기기
- 네트워크 장비
- IoT 센서
- 웨어러블 기기
- 로봇
- 드론
- 키보드와 마우스
- 저장 장치 컨트롤러
- 스마트 카드
임베디드 프로그램은 운영체제 없이 직접 실행되거나 작은 실시간 운영체제 위에서 실행될 수 있다.
#include <stdint.h>
#define GPIO_OUTPUT_ADDRESS \
UINTPTR_C(0x40020014)
static volatile uint32_t *const gpio_output =
(volatile uint32_t *)GPIO_OUTPUT_ADDRESS;
void led_set(bool enabled) {
if (enabled) {
*gpio_output |= 1U;
} else {
*gpio_output &= ~1U;
}
}
이 코드는 메모리 매핑 입출력을 단순화한 예시다. 실제 주소와 레지스터 폭, 접근 순서는 마이크로컨트롤러의 데이터시트와 SDK가 규정한다.
임베디드 분야에서 C가 사용되는 이유는 다음과 같다.
- 작은 실행 파일을 만들 수 있음
- 런타임 의존성을 최소화할 수 있음
- 고정된 메모리 배치를 설계할 수 있음
- 인터럽트와 레지스터를 직접 다룰 수 있음
- 여러 프로세서용 교차 컴파일러가 존재함
- 기존 제조사 SDK와 드라이버가 C API를 제공함
- 실시간 실행 비용을 분석하기 쉬움
- 인증된 도구체인과 기존 코드가 많음
C가 자동으로 실시간성과 안전성을 제공하는 것은 아니다. 동적 메모리 사용 제한과 실행 시간 분석, 정적 분석, 코딩 규칙과 하드웨어 감시 장치 등이 함께 사용된다.
실시간 운영체제
작은 임베디드 장치에서는 실시간 운영체제의 커널과 응용 프로그램을 C로 작성하는 경우가 많다.
FreeRTOS는 마이크로컨트롤러와 작은 마이크로프로세서를 대상으로 하는 커널과 라이브러리를 제공하며, 공식 배포에는 여러 프로세서 포트의 C 소스와 커널 소스가 포함된다.[92]
Zephyr는 자원이 제한된 장치를 대상으로 여러 하드웨어 아키텍처를 지원하는 실시간 운영체제다. 커널과 드라이버, 네트워크 스택 및 장치 지원 코드는 C 중심의 소스 구조로 개발된다.[93]
실시간 운영체제 위의 C 응용 프로그램은 태스크와 큐, 뮤텍스와 타이머를 사용한다.
static void sensor_task(void *context) {
struct sensor *sensor = context;
for (;;) {
struct sample sample;
if (sensor_read(sensor, &sample)) {
queue_send(&sample);
}
task_delay(SENSOR_INTERVAL);
}
}
실제 함수와 타입 이름은 RTOS마다 다르다.
부트로더와 펌웨어
C는 운영체제가 시작되기 전에 실행되는 부트로더와 시스템 펌웨어에도 사용된다.
주요 작업은 다음과 같다.
- CPU 초기화
- 메모리 컨트롤러 설정
- 장치 검색
- 저장 장치 읽기
- 보안 부팅 검증
- 운영체제 이미지 적재
- 하드웨어 정보 전달
- 펌웨어 업데이트
초기 시작 코드는 어셈블리어로 작성하고, 기본 실행 환경이 준비된 뒤 대부분의 로직을 C로 구현하는 구성이 흔하다.
리셋 벡터
↓
초기 어셈블리 코드
↓
스택과 메모리 초기화
↓
C 시작 함수
↓
장치 초기화
↓
운영체제 적재
펌웨어에서는 일반적인 호스트 표준 라이브러리를 제공하지 않을 수 있으므로 문자열과 메모리, 할당과 입출력 기능을 직접 구현하거나 독립 실행용 라이브러리를 사용한다.
시스템 라이브러리
운영체제와 응용 프로그램 사이의 기본 인터페이스를 제공하는 시스템 라이브러리에도 C가 사용된다.
대표적인 구성 요소는 다음과 같다.
- C 표준 라이브러리
- POSIX 라이브러리
- 시스템 호출 래퍼
- 동적 링커
- 스레드 라이브러리
- 암호화 라이브러리
- 압축 라이브러리
- 파일 형식 라이브러리
- 장치 접근 라이브러리
시스템 라이브러리는 여러 언어가 함께 사용할 수 있도록 C ABI를 공개하는 경우가 많다.
struct image;
struct image *image_load(
const char *path
);
void image_destroy(
struct image *image
);
이와 같은 불투명 포인터 기반 API는 내부 구조를 공개하지 않으면서 C와 C++, Rust, Python, C# 등에서 호출할 수 있다.
프로그래밍 언어 구현
C는 다른 프로그래밍 언어의 인터프리터와 컴파일러 런타임, 가상 머신을 구현하는 데 널리 사용된다.
대표적인 구성 요소는 다음과 같다.
- 어휘 분석기
- 파서
- 바이트코드 컴파일러
- 인터프리터
- 객체와 타입 시스템
- 가비지 컬렉터
- 모듈 로더
- 네이티브 함수 인터페이스
- 표준 라이브러리
- JIT 런타임의 일부
CPython은 Python의 대표 구현으로, 내부 런타임과 객체 체계가 C 소스 중심으로 개발된다. Python은 공식적으로 Python/C API를 제공하여 C 확장 모듈과 임베딩을 지원한다.[94][95]
#include <Python.h>
static PyObject *add_values(
PyObject *self,
PyObject *arguments
) {
int left;
int right;
if (!PyArg_ParseTuple(
arguments,
"ii",
&left,
&right
)) {
return NULL;
}
return PyLong_FromLong(left + right);
}
Lua는 응용 프로그램에 삽입하기 쉽도록 설계된 언어이며, 공식 사이트는 Lua를 애플리케이션에 임베딩하기 쉬운 언어로 설명한다. Lua 구현과 C API는 게임과 도구, 서버 및 임베디드 프로그램에서 사용된다.[96]
C는 다음과 같은 언어 구현에서도 핵심 또는 일부 구성 요소에 사용되어 왔다.
- Python 구현
- Lua 구현
- Ruby 구현
- PHP 구현
- Perl 구현
- R 구현
- Tcl 구현
- JavaScript 엔진의 일부
- Lisp와 Scheme 구현
- 셸과 명령 해석기
- 도메인 특화 언어 런타임
모든 구현이 현재도 순수 C만 사용하는 것은 아니다. C++와 Rust, 어셈블리어 및 코드 생성 언어를 함께 사용하는 경우도 많다.
확장 모듈과 언어 바인딩
상위 수준 언어에서 성능이나 운영체제 접근이 필요한 부분을 C 확장 모듈로 작성할 수 있다.
대표적인 사용 목적은 다음과 같다.
- 기존 C 라이브러리 호출
- 계산 집약적 루프 가속
- 하드웨어 장치 접근
- 운영체제 API 사용
- 네이티브 파일 형식 처리
- 이미지와 오디오 처리
- 암호화
- 압축
- 네트워크 프로토콜
Python·Lua·Ruby·Java·C#
↓ 외부 함수 인터페이스
C API
↓
운영체제 또는 네이티브 라이브러리
Python의 C API는 C와 C++ 프로그램이 인터프리터의 객체와 런타임 기능에 접근할 수 있게 하며, 확장 모듈과 임베딩을 주요 사용 방식으로 설명한다.[97]
확장 모듈은 언어 간 변환 비용과 객체 수명, 오류 처리와 스레드 규칙을 정확히 관리해야 한다. 잘못된 C 확장 하나가 상위 언어 런타임 전체를 종료시키거나 메모리를 손상시킬 수 있다.
컴파일러와 개발 도구
C는 컴파일러와 어셈블러, 링커 및 정적 분석기를 구현하는 데 사용되어 왔다.
대표적인 구성 요소는 다음과 같다.
- 언어 프런트엔드
- 최적화기
- 코드 생성기
- 어셈블러
- 링커
- 디버거
- 프로파일러
- 정적 분석기
- 바이너리 분석 도구
- 빌드 도구
- 버전 관리 시스템
초기 C 컴파일러와 Portable C Compiler는 C로 작성되어 언어의 자기 호스팅과 이식성을 보여주었다. 현대의 대형 컴파일러는 C++를 많이 사용하지만, 시스템 도구와 작은 컴파일러, 런타임 라이브러리 및 대상 지원 코드에는 C도 계속 사용된다.
Git은 대규모 프로젝트를 처리하도록 설계된 분산 버전 관리 시스템이며, 핵심 소스가 C 중심으로 구성되어 있다. Git 공식 사용자 설명서에는 Git 소스 코드의 구조를 소개하는 별도 장이 존재한다.[98]
데이터베이스 엔진
데이터베이스 엔진은 C가 강하게 사용되는 또 다른 분야다.
데이터베이스는 다음 요소를 정밀하게 제어해야 한다.
- 디스크 페이지 배치
- 버퍼 캐시
- 트랜잭션 로그
- 인덱스
- 질의 실행기
- 동시성 제어
- 잠금
- 네트워크 프로토콜
- 파일 형식
- 메모리 할당
- 확장 모듈
SQLite는 작은 크기와 내장형 실행을 목표로 한 SQL 데이터베이스 엔진이며, 공식 사이트는 SQLite가 2000년부터 일반적인 C로 구현되어 왔다고 설명한다.[99]
SQLite는 하나의 C 언어 라이브러리로 제공되며 모바일 장치와 데스크톱 애플리케이션, 운영체제와 브라우저 등 여러 프로그램에 포함될 수 있다.[100]
#include <sqlite3.h>
int open_database(
const char *path,
sqlite3 **database
) {
int result = sqlite3_open(
path,
database
);
if (result != SQLITE_OK) {
sqlite3_close(*database);
*database = NULL;
}
return result;
}
PostgreSQL의 핵심 서버와 도구도 C 개발 환경을 중심으로 구축되며, 공식 문서는 PostgreSQL 핵심 개발에 C 개발 환경이 필요하다고 설명한다. 서버 확장 함수도 C로 작성해 동적 라이브러리로 적재할 수 있다.[101][102]
다른 데이터베이스와 저장 엔진에서도 C가 사용되지만, 현대의 분산 데이터베이스와 상위 관리 계층은 C++, Java, Go와 Rust 등을 함께 사용하는 경우가 많다.
파일 시스템과 저장 장치
파일 시스템과 스토리지 엔진은 디스크 블록과 메타데이터, 캐시와 저널을 직접 다뤄야 하므로 C가 널리 사용된다.
주요 대상은 다음과 같다.
- 운영체제 파일 시스템
- 사용자 공간 파일 시스템
- 압축 파일 형식
- 아카이브 도구
- 키-값 저장 엔진
- 플래시 변환 계층
- 저장 장치 펌웨어
- 데이터 복구 도구
struct block_header {
uint32_t type;
uint32_t size;
uint64_t sequence;
};
bool block_header_read(
FILE *file,
struct block_header *header
) {
unsigned char bytes[16];
if (
fread(
bytes,
1,
sizeof(bytes),
file
) != sizeof(bytes)
) {
return false;
}
header->type = decode_u32_le(bytes);
header->size = decode_u32_le(bytes + 4);
header->sequence =
decode_u64_le(bytes + 8);
return true;
}
이식 가능한 저장 형식에서는 구조체를 그대로 기록하기보다 바이트 순서와 필드 크기를 명시적으로 인코딩한다.
네트워크 스택
C는 운영체제 커널과 임베디드 장치의 네트워크 스택에 널리 사용된다.
다루는 계층에는 다음이 포함된다.
- 이더넷
- Wi-Fi 드라이버
- ARP
- IPv4와 IPv6
- ICMP
- TCP와 UDP
- DHCP
- DNS
- TLS 구현의 일부
- 라우팅과 방화벽
- 패킷 필터링
struct ipv4_header {
uint8_t version_and_length;
uint8_t service_type;
uint16_t total_length;
uint16_t identifier;
uint16_t fragment;
uint8_t time_to_live;
uint8_t protocol;
uint16_t checksum;
uint32_t source;
uint32_t destination;
};
실제 네트워크 패킷 구조를 C 구조체와 직접 대응시키는 코드는 패딩과 정렬, 엔디언 문제를 고려해야 한다. 일반적으로 바이트 단위 파싱과 명시적 변환을 사용한다.
네트워크 서버와 프록시
고성능 네트워크 서버와 프록시, 로드 밸런서에도 C가 사용된다.
nginx는 HTTP 웹 서버와 리버스 프록시, 콘텐츠 캐시, 로드 밸런서 및 TCP·UDP 프록시 기능을 제공한다.[103]
C는 이러한 서버에서 다음 요소를 구현하는 데 적합하다.
- 이벤트 루프
- 비동기 소켓
- 연결 풀
- 버퍼 관리
- 프로토콜 파싱
- 타이머
- 메모리 풀
- 운영체제별 이벤트 API
- 모듈 인터페이스
struct connection {
int socket;
unsigned char *input;
size_t input_size;
size_t input_capacity;
};
static bool connection_reserve(
struct connection *connection,
size_t required
) {
if (
required <=
connection->input_capacity
) {
return true;
}
unsigned char *new_input =
realloc(
connection->input,
required
);
if (new_input == NULL) {
return false;
}
connection->input = new_input;
connection->input_capacity =
required;
return true;
}
현대의 웹 서비스 전체를 C로 작성하는 경우는 상대적으로 적다. HTTP 라우팅과 업무 로직, 데이터 처리 같은 상위 계층은 Java, C#, JavaScript, Python, Go와 Rust 등을 사용하는 경우가 많다. 그러나 서버 런타임과 네트워크 라이브러리, 암호화와 압축 모듈은 C로 구현될 수 있다.
네트워크 클라이언트 라이브러리
C는 다양한 프로토콜을 지원하는 네트워크 전송 라이브러리에도 사용된다.
libcurl은 HTTP와 HTTPS, FTP, SMTP, IMAP, MQTT, WebSocket 등 여러 프로토콜을 지원하는 클라이언트 전송 라이브러리이며 C API를 제공한다.[104]
#include <curl/curl.h>
int download_url(const char *url) {
CURL *handle = curl_easy_init();
if (handle == NULL) {
return -1;
}
curl_easy_setopt(
handle,
CURLOPT_URL,
url
);
CURLcode result =
curl_easy_perform(handle);
curl_easy_cleanup(handle);
return result == CURLE_OK
? 0
: -1;
}
libcurl 공식 문서는 C 프로그램에서 사용하는 API와 여러 언어 바인딩을 제공하며, 기본 인터페이스는 C를 중심으로 한다.[105]
암호화와 보안 라이브러리
암호화와 보안 라이브러리는 C로 구현되는 경우가 많다.
주요 기능은 다음과 같다.
- 대칭키 암호
- 공개키 암호
- 해시 함수
- 디지털 서명
- 인증서 처리
- TLS
- 난수 생성
- 키 파생
- 보안 프로토콜
- 하드웨어 가속
C는 여러 운영체제와 언어에서 호출하기 쉬운 ABI를 제공하고, CPU별 명령어와 어셈블리 최적화를 결합할 수 있다.
다만 암호화 C 코드는 특히 다음 문제에 주의해야 한다.
- 비밀 데이터의 수명
- 일정 시간 실행
- 정수 오버플로
- 버퍼 범위
- 컴파일러가 제거할 수 있는 메모리 지우기
- CPU별 최적화
- 부채널 공격
- 난수 품질
- 오류 처리
void secure_buffer_clear(
volatile unsigned char *buffer,
size_t size
) {
while (size != 0) {
*buffer++ = 0;
--size;
}
}
단순한 volatile 기반 지우기만으로 모든 구현체와 최적화 환경에서 완전한 보안을 보장할 수는 없다. 플랫폼이나 검증된 라이브러리가 제공하는 보안 메모리 삭제 함수를 사용하는 편이 적절하다.
압축과 아카이브
압축 라이브러리와 아카이브 처리기에도 C가 널리 사용된다.
대표적인 작업은 다음과 같다.
- 무손실 압축
- 스트림 압축
- 이미지 압축
- 파일 아카이브
- 체크섬
- 해시
- 데이터 블록 변환
이러한 라이브러리는 운영체제와 브라우저, 게임 엔진, 데이터베이스와 네트워크 프로토콜에 포함된다.
C API로 작성하면 여러 언어에서 같은 압축 구현을 공유할 수 있다.
struct compressor;
struct compressor *compressor_create(
int level
);
bool compressor_update(
struct compressor *compressor,
const void *input,
size_t input_size,
void *output,
size_t output_capacity,
size_t *output_size
);
void compressor_destroy(
struct compressor *compressor
);
멀티미디어 처리
오디오와 비디오 코덱, 컨테이너와 변환 도구는 대량의 데이터를 낮은 수준에서 처리하고 CPU별 SIMD 최적화를 적용해야 하므로 C가 강하게 사용되는 분야다.
FFmpeg은 입력 장치와 파일, 스트림에서 미디어를 읽고 필터링하거나 여러 출력 형식으로 변환하는 멀티미디어 도구 및 라이브러리 집합이다.[106]
FFmpeg에는 코덱을 제공하는 libavcodec, 컨테이너 형식을 처리하는 libavformat 등의 라이브러리가 있다.[107][108]
멀티미디어 분야에서 C는 다음 작업에 사용된다.
- 비디오 디코딩과 인코딩
- 오디오 코덱
- 색 공간 변환
- 샘플 형식 변환
- 컨테이너 파싱
- 스트리밍
- 필터 처리
- 하드웨어 디코더 인터페이스
- CPU별 벡터 최적화
void mix_audio(
float *restrict output,
const float *restrict left,
const float *restrict right,
size_t count
) {
for (
size_t index = 0;
index < count;
++index
) {
output[index] =
left[index] +
right[index];
}
}
실제 미디어 구현에서는 포화 연산과 샘플 형식, 정렬과 SIMD, 멀티스레딩을 함께 고려한다.
이미지 처리
이미지 디코더와 인코더, 색상 변환과 리사이징 라이브러리에도 C가 사용된다.
대표적인 대상은 다음과 같다.
- PNG
- JPEG
- TIFF
- GIF
- WebP 관련 라이브러리
- 색 관리
- 이미지 리사이징
- 필터
- 픽셀 형식 변환
- 카메라 RAW 처리
struct image {
unsigned char *pixels;
size_t width;
size_t height;
size_t stride;
unsigned int channels;
};
픽셀 메모리는 일반적으로 연속된 바이트나 정수 배열로 관리하며, 행 간 패딩과 픽셀 형식을 명시적으로 처리한다.
그래픽스 API와 렌더링 기반
그래픽스 분야에서는 응용 프로그램 전체를 C로 작성하기보다 그래픽스 API와 로더, 드라이버 인터페이스 및 저수준 라이브러리에서 C를 사용하는 경우가 많다.
대표적인 영역은 다음과 같다.
- OpenGL API
- Vulkan API
- 그래픽 드라이버
- 셰이더 컴파일러 런타임
- 이미지 라이브러리
- 창과 입력 라이브러리
- 렌더링 유틸리티
- 디버그 레이어
C API는 언어 중립적인 네이티브 인터페이스로 사용하기 쉽다.
struct graphics_buffer;
struct graphics_buffer *
graphics_buffer_create(
size_t size,
unsigned int usage
);
void graphics_buffer_destroy(
struct graphics_buffer *buffer
);
실제 렌더러와 게임 엔진의 상위 구조는 C++나 Rust 등으로 작성하고, 그래픽스 API 경계와 일부 시스템 모듈에 C를 사용하는 경우가 많다.
게임 개발
게임 개발에서 C는 과거에는 게임 전체의 주력 언어로 많이 사용되었고, 현재는 다음 영역에서 주로 사용된다.
- 콘솔과 임베디드 게임
- 작은 2D 게임
- 게임 런타임
- 플랫폼 계층
- 오디오와 비디오 라이브러리
- 입력 처리
- 네트워크 라이브러리
- 스크립트 언어 런타임
- 플러그인 ABI
- 에뮬레이터
- 레트로 게임 도구
대규모 상용 게임과 엔진에서는 C++가 더 널리 사용되지만, C 기반 라이브러리와 운영체제 API를 함께 사용한다.
struct game_state {
bool running;
double time;
struct player player;
};
void game_update(
struct game_state *game,
double delta_time
) {
player_update(
&game->player,
delta_time
);
game->time += delta_time;
}
C는 객체 지향 언어 기능이 없으므로 게임 객체와 시스템 구조를 함수와 구조체, 데이터 중심 설계와 ECS 등의 방식으로 직접 구성한다.
에뮬레이터
게임기와 컴퓨터, CPU 및 하드웨어 에뮬레이터에도 C가 사용된다.
주요 구성 요소는 다음과 같다.
- CPU 명령어 해석
- 메모리 맵
- 장치 모델
- 타이머
- 그래픽 처리
- 오디오 생성
- 입력 장치
- 상태 저장
- 동적 재컴파일의 일부
struct cpu {
uint16_t program_counter;
uint8_t accumulator;
uint8_t status;
uint8_t memory[65536];
};
void cpu_step(struct cpu *cpu) {
uint8_t opcode =
cpu->memory[
cpu->program_counter++
];
execute_opcode(cpu, opcode);
}
에뮬레이터는 비트 연산과 명시적인 정수 폭, 메모리 배열을 많이 사용하므로 C의 자료 표현과 잘 맞는다.
과학 계산
C는 수치 계산과 과학 시뮬레이션에서 계산 커널과 공통 라이브러리를 구현하는 데 사용된다.
대표적인 분야는 다음과 같다.
- 선형대수
- 수치 적분
- 신호 처리
- 물리 시뮬레이션
- 통계 계산
- 천문학
- 기상과 유체 계산
- 생물정보학
- 지리 공간 처리
- 데이터 변환
상위 연구 코드는 Python과 R, MATLAB, Julia와 Fortran 등을 사용하고, 반복 계산과 메모리 처리를 C로 구현하는 구조가 흔하다.
Python·R·Julia
↓
C 확장 또는 C 라이브러리
↓
수치 계산 커널
↓
SIMD·스레드·GPU 인터페이스
고성능 컴퓨팅
고성능 컴퓨팅에서는 C와 C++, Fortran이 계산 노드와 병렬 라이브러리 구현에 사용된다.
C가 사용되는 영역은 다음과 같다.
- MPI 기반 분산 계산
- OpenMP 기반 공유 메모리 병렬화
- 수치 라이브러리
- 데이터 입출력
- 클러스터 런타임
- GPU API 호출
- 네트워크 패브릭
- 파일 형식
- 성능 분석 도구
대규모 과학 데이터 저장에 사용되는 HDF5 라이브러리는 이식성을 위해 portable C로 구현되어 있으며, 고성능 데이터의 저장과 처리 인터페이스를 제공한다.[109]
#include <hdf5.h>
hid_t file = H5Fcreate(
"data.h5",
H5F_ACC_TRUNC,
H5P_DEFAULT,
H5P_DEFAULT
);
if (file >= 0) {
H5Fclose(file);
}
HDF5는 C뿐 아니라 C++, Fortran과 Java 등의 인터페이스도 제공하지만 핵심 라이브러리는 portable C를 기반으로 한다.
수치 라이브러리
C는 다른 언어에서 호출되는 수치 라이브러리의 공용 인터페이스로 사용된다.
void matrix_multiply(
size_t rows,
size_t columns,
size_t inner,
const double *left,
const double *right,
double *result
);
C ABI는 Fortran과 C++, Python, Julia와 Rust 등에서 비교적 쉽게 연결할 수 있다.
실제 고성능 구현은 다음 기술을 함께 사용할 수 있다.
- CPU별 어셈블리
- SIMD 내장 함수
- OpenMP
- GPU 커널
- 자동 벡터화
- 캐시 블로킹
- 다중 스레드
- 분산 처리
인공지능과 머신러닝
인공지능과 머신러닝에서는 모델 정의와 연구 코드는 Python 중심인 경우가 많지만, 실행 엔진과 수치 커널, 장치 런타임과 임베디드 추론에서 C가 사용된다.
대표적인 활용은 다음과 같다.
- 텐서 연산 커널
- 모델 추론 런타임
- CPU 연산자
- 마이크로컨트롤러용 추론
- 이미지와 신호 전처리
- GPU와 가속기 드라이버 인터페이스
- Python 확장 모듈
- 모델 파일 파서
void relu(
float *values,
size_t count
) {
for (
size_t index = 0;
index < count;
++index
) {
if (values[index] < 0.0F) {
values[index] = 0.0F;
}
}
}
대규모 머신러닝 프레임워크의 전체 구현은 C++와 Python, CUDA 및 다른 언어를 함께 사용하는 경우가 많다. C는 외부 API와 저수준 커널, 임베디드 환경에서 더 자주 나타난다.
로봇과 제어 시스템
로봇과 산업 제어 시스템에서는 C가 센서와 모터, 실시간 제어와 임베디드 통신에 사용된다.
주요 작업은 다음과 같다.
- 센서 샘플링
- 모터 제어
- PID 제어
- 실시간 태스크
- 필드버스 통신
- 안전 상태 관리
- 펌웨어 업데이트
- 하드웨어 진단
struct pid_controller {
float proportional;
float integral;
float derivative;
float previous_error;
float accumulated_error;
};
float pid_update(
struct pid_controller *controller,
float target,
float measured,
float delta_time
) {
float error = target - measured;
controller->accumulated_error +=
error * delta_time;
float derivative =
(error -
controller->previous_error) /
delta_time;
controller->previous_error =
error;
return
controller->proportional *
error +
controller->integral *
controller->
accumulated_error +
controller->derivative *
derivative;
}
실제 제어 시스템은 부동소수점 오차와 샘플링 주기, 포화와 센서 오류, 안전 제한을 함께 처리해야 한다.
자동차 소프트웨어
자동차 전자 시스템에서도 C가 널리 사용되어 왔다.
대표적인 영역은 다음과 같다.
- 엔진 제어
- 변속기 제어
- 제동 시스템
- 에어백
- 배터리 관리
- 인포테인먼트 장치의 펌웨어
- 차량 네트워크
- 센서 처리
- 전원 관리
자동차 소프트웨어는 기능 안전과 실시간성, 제한된 자원을 요구한다. 코딩 규칙과 정적 분석, 추적 가능한 요구 사항과 검증 절차가 함께 사용된다.
C가 메모리 안전성을 자동으로 보장하지 않기 때문에 자동차와 항공, 의료 분야에서는 제한된 언어 부분집합과 코딩 표준을 적용하는 경우가 많다.
항공우주와 방위 시스템
항공우주와 방위 시스템의 임베디드 제어, 통신과 신호 처리에도 C가 사용된다.
사용 분야는 다음과 같다.
- 비행 제어
- 항법
- 위성 탑재 소프트웨어
- 통신 장치
- 레이더와 신호 처리
- 센서 융합
- 실시간 운영체제
- 지상 제어 장비의 하위 모듈
이러한 시스템은 장기간 유지와 검증 가능한 도구체인, 제한된 동적 메모리, 결정적인 실행 시간을 중요하게 여긴다.
의료 기기
의료 기기의 펌웨어와 실시간 제어에도 C가 사용된다.
대표적인 대상은 다음과 같다.
- 환자 모니터
- 영상 장치
- 주입 펌프
- 진단 장비
- 휴대형 측정기
- 센서와 데이터 수집 장치
- 의료용 로봇
의료 기기에서는 코드 정확성뿐 아니라 위험 분석과 검증, 변경 관리와 규제 문서가 중요하다. C는 기존 도구와 인증 경험이 많지만, 메모리 안전 오류를 방지하기 위한 엄격한 개발 절차가 필요하다.
통신 장비
라우터와 스위치, 모뎀과 기지국, 네트워크 보안 장비의 펌웨어와 데이터 경로에도 C가 사용된다.
주요 작업은 다음과 같다.
- 패킷 파싱
- 라우팅 테이블
- 암호화
- 트래픽 분류
- QoS
- 네트워크 관리
- 장치 드라이버
- 고속 버퍼 처리
bool packet_process(
const unsigned char *data,
size_t size
) {
if (size < MINIMUM_HEADER_SIZE) {
return false;
}
uint16_t packet_type =
decode_u16_be(data);
return dispatch_packet(
packet_type,
data + 2,
size - 2
);
}
외부 네트워크 입력은 신뢰할 수 없으므로 모든 길이와 오프셋을 접근 전에 검증해야 한다.
명령행 도구
C는 작은 명령행 유틸리티와 시스템 관리 도구에도 사용된다.
대표적인 유형은 다음과 같다.
- 파일 변환
- 텍스트 처리
- 프로세스 관리
- 네트워크 진단
- 시스템 정보
- 컴파일 도구
- 바이너리 분석
- 복구 도구
#include <stdio.h>
#include <stdlib.h>
int main(
int argc,
char *argv[]
) {
if (argc != 2) {
fprintf(
stderr,
"usage: %s FILE\n",
argv[0]
);
return EXIT_FAILURE;
}
return process_file(argv[1])
? EXIT_SUCCESS
: EXIT_FAILURE;
}
C로 작성한 명령행 도구는 실행 파일과 런타임 의존성을 작게 유지할 수 있다. 반면 복잡한 문자열과 데이터 처리, 빠른 개발이 중요한 도구에서는 Python과 Rust, Go 등이 선택되기도 한다.
데스크톱 애플리케이션
과거에는 C로 전체 데스크톱 애플리케이션을 작성하는 경우가 많았으며, 현재도 일부 GUI 툴킷과 시스템 애플리케이션에서 사용된다.
C가 사용되는 영역은 다음과 같다.
- GUI 툴킷
- 이미지 편집기
- 파일 관리자
- 시스템 설정 도구
- 터미널 에뮬레이터
- 미디어 플레이어
- 플러그인 시스템
- 크로스플랫폼 런타임
하지만 복잡한 대규모 애플리케이션에서는 C++와 C#, Java, Swift, Objective-C와 Rust 등 더 높은 수준의 추상화를 제공하는 언어가 많이 사용된다. C는 네이티브 라이브러리와 성능 모듈, 플랫폼 계층에 남는 경우가 많다.
모바일 애플리케이션
일반적인 모바일 UI와 애플리케이션 로직을 C만으로 작성하는 경우는 드물다. Android에서는 Kotlin과 Java, iOS에서는 Swift와 Objective-C가 주로 사용된다.
C는 다음 용도로 간간이 사용된다.
- 게임 엔진
- 오디오 처리
- 영상 코덱
- 이미지 라이브러리
- 암호화
- 데이터베이스
- 네트워크 라이브러리
- 크로스플랫폼 런타임
- 네이티브 성능 모듈
- 기존 코드 이식
모바일 UI와 앱 로직
→ Kotlin·Java·Swift
네이티브 코어
→ C·C++
운영체제
→ C·C++·Objective-C 등
SQLite와 같은 C 라이브러리는 모바일 애플리케이션 내부에 포함되어 사용될 수 있다.
웹 서비스
웹 서비스 전체를 C로 작성하는 것은 가능하지만 일반적인 업무 애플리케이션에서는 흔하지 않다.
C는 웹 분야에서 주로 다음 계층에 사용된다.
- 웹 서버
- 리버스 프록시
- TLS 라이브러리
- 압축
- HTTP 파서
- 네트워크 런타임
- 데이터베이스 엔진
- 언어 인터프리터
- 캐시 서버
- 성능이 중요한 확장 모듈
상위 웹 애플리케이션에서는 다음 요구 때문에 다른 언어가 많이 사용된다.
- 빠른 기능 개발
- 문자열과 JSON 처리
- 자동 메모리 관리
- 프레임워크
- 데이터베이스 ORM
- 비동기 프로그래밍
- 배포 자동화
- 팀 단위 유지보수
따라서 C는 웹 서비스의 기반 계층에는 많이 사용되지만 업무 로직에는 간간이 사용되는 언어라고 볼 수 있다.
브라우저와 웹 런타임
웹 브라우저와 JavaScript 런타임은 매우 큰 소프트웨어로, C와 C++, Rust 및 여러 언어를 함께 사용한다.
C가 사용될 수 있는 영역은 다음과 같다.
- 네트워크 라이브러리
- 이미지 디코더
- 오디오와 비디오 코덱
- 압축
- 암호화
- 운영체제 인터페이스
- SQLite
- 폰트와 텍스트 라이브러리
- 확장 모듈
브라우저 전체의 DOM과 렌더링 엔진을 순수 C로 구현하는 것이 현대의 일반적인 형태는 아니다. C는 여러 기반 라이브러리와 외부 종속성으로 포함된다.
클라우드와 컨테이너
클라우드 제어 시스템과 오케스트레이션 도구는 Go와 Java, Python, Rust 등으로 많이 작성되지만, 하위 시스템에는 C가 사용된다.
대표적인 영역은 다음과 같다.
- Linux 커널
- 네트워크 스택
- 컨테이너 격리 기능
- C 라이브러리
- 암호화
- 압축
- 스토리지 엔진
- 하이퍼바이저 구성 요소
- eBPF 런타임과 도구 일부
- 고성능 데이터 경로
컨테이너 자체는 C 언어 기능이 아니라 운영체제의 프로세스 격리와 자원 제어 기능을 사용한다. 그 기반이 되는 커널과 시스템 라이브러리에 C가 널리 사용된다.
가상화
하이퍼바이저와 가상 머신 모니터, 장치 에뮬레이션에도 C가 사용된다.
주요 작업은 다음과 같다.
- 가상 CPU 관리
- 메모리 매핑
- 가상 장치
- 인터럽트
- 디스크 이미지
- 네트워크 가상화
- 하드웨어 가속 인터페이스
- 게스트 펌웨어
가상화 시스템 전체는 C와 C++, Rust 등 여러 언어로 구성될 수 있다. C는 운영체제와 하드웨어 API, 에뮬레이션 장치 및 저수준 런타임에서 중요한 역할을 한다.
보안 도구
보안 분야에서는 C가 공격과 방어 양쪽에서 다뤄진다.
합법적인 보안 소프트웨어의 예는 다음과 같다.
- 네트워크 패킷 분석기
- 바이너리 분석 도구
- 디버거
- 샌드박스
- 안티바이러스 엔진
- 암호화 라이브러리
- 퍼저
- 취약점 검사기
- 시스템 감사 도구
- 침입 탐지 시스템
C의 메모리 모델과 ABI, 어셈블리 출력과 운영체제 인터페이스를 이해하는 것은 네이티브 프로그램의 취약점을 분석하고 방어하는 데 중요하다.
동시에 버퍼 오버플로와 해제 후 사용, 정수 오버플로 같은 메모리 안전 오류가 발생하기 쉬우므로 보안에 민감한 신규 구성 요소에서는 Rust 등 메모리 안전 언어를 함께 채택하는 흐름도 존재한다.
전자 설계와 하드웨어 도구
C는 하드웨어 검증과 전자 설계 자동화의 일부 도구, 시뮬레이터와 펌웨어 모델에서도 사용된다.
주요 활용은 다음과 같다.
- 명령어 집합 시뮬레이터
- 하드웨어 테스트 프로그램
- FPGA 제어 소프트웨어
- 펌웨어 검증
- 회로 시뮬레이터
- 장치 모델
- JTAG 도구
- 제조 테스트
하드웨어 설명 언어에서 생성한 시뮬레이션 모델과 C 프로그램을 연결하는 인터페이스가 제공되기도 한다.
교육
C는 컴퓨터 구조와 운영체제, 메모리와 컴파일 과정을 교육하는 데 사용된다.
주요 학습 주제는 다음과 같다.
- 변수와 함수
- 포인터
- 배열
- 구조체
- 동적 메모리
- 파일 입출력
- 자료구조
- 컴파일과 링크
- 운영체제 API
- 프로세스와 스레드
- 네트워크
- 임베디드 시스템
C는 추상화가 적어 컴퓨터의 메모리와 실행 구조를 관찰하기 쉽다. 그러나 초보자가 객체 수명과 포인터, 정의되지 않은 동작을 충분히 이해하지 못하면 잘못된 습관을 익힐 수 있으므로 도구와 안전한 코딩 규칙을 함께 가르쳐야 한다.
경쟁 프로그래밍과 알고리즘
C는 경쟁 프로그래밍과 알고리즘 학습에도 사용할 수 있지만, 현재는 C++가 표준 컨테이너와 알고리즘 라이브러리 때문에 더 많이 선택되는 경우가 많다.
C를 사용할 때는 동적 배열과 해시 테이블, 문자열 처리 등을 직접 구현하거나 별도 라이브러리를 사용해야 한다.
int binary_search(
const int *values,
size_t count,
int target
) {
size_t left = 0;
size_t right = count;
while (left < right) {
size_t middle =
left + (right - left) / 2;
if (values[middle] < target) {
left = middle + 1;
} else {
right = middle;
}
}
if (
left < count &&
values[left] == target
) {
return (int)left;
}
return -1;
}
C는 알고리즘의 메모리 배치와 시간 복잡도를 직접 확인하기 좋지만, 구현량이 많아질 수 있다.
범용 응용 프로그램
C로 문서 편집기와 미디어 플레이어, 웹 브라우저, 게임, 서버와 그래픽 프로그램을 포함한 거의 모든 종류의 응용 프로그램을 만들 수 있다.
그러나 가능하다는 사실과 실제 개발에서 적합하다는 것은 다르다.
다음 요구가 중요한 프로젝트에서는 다른 언어가 더 적합할 수 있다.
- 복잡한 객체 모델
- 자동 메모리 관리
- 높은 수준의 비동기 처리
- 안전한 동시성
- 빠른 UI 개발
- 대규모 패키지 생태계
- 런타임 리플렉션
- 강한 모듈 시스템
- 메모리 안전성
- 빠른 프로토타이핑
C는 전체 애플리케이션을 구성하는 언어라기보다 성능과 이식성, 네이티브 인터페이스가 필요한 기반 모듈로 선택되기도 한다.
C가 많이 쓰이는 분야
C가 현재도 주력 언어로 많이 사용되는 분야를 정리하면 다음과 같다.
| 분야 | C가 사용되는 이유 |
|---|---|
| 운영체제 커널 | 하드웨어와 메모리 직접 제어 |
| 장치 드라이버 | 레지스터와 인터럽트 접근 |
| 임베디드 시스템 | 작은 런타임과 예측 가능한 실행 |
| 실시간 운영체제 | 낮은 오버헤드와 다양한 프로세서 지원 |
| 시스템 라이브러리 | 안정적인 ABI와 언어 간 호환성 |
| 언어 런타임 | 객체와 메모리, 네이티브 확장 구현 |
| 데이터베이스 엔진 | 파일 배치와 캐시, 동시성 제어 |
| 네트워크 서버 | 이벤트와 버퍼, 프로토콜 제어 |
| 코덱과 미디어 | SIMD와 낮은 수준의 데이터 처리 |
| 과학 데이터 라이브러리 | 이식성과 다른 언어와의 연동 |
| 보안·암호화 라이브러리 | CPU별 최적화와 공통 API |
| 부트로더와 펌웨어 | 운영체제 이전의 독립 실행 환경 |
C가 간간이 쓰이는 분야
C가 전체 프로그램의 주력 언어는 아니지만 일부 계층에서 사용되는 분야는 다음과 같다.
| 분야 | C의 일반적인 역할 |
|---|---|
| 웹 애플리케이션 | 웹 서버, 데이터베이스와 네이티브 모듈 |
| 모바일 애플리케이션 | 코덱, 게임 엔진과 공통 네이티브 코어 |
| 데스크톱 UI | 시스템 라이브러리와 성능 모듈 |
| 게임 | 플랫폼·오디오·영상·스크립트 런타임 |
| 머신러닝 | 수치 커널과 임베디드 추론 |
| 브라우저 | 코덱, 압축, 데이터베이스와 시스템 계층 |
| 클라우드 | 커널, 스토리지와 네트워크 기반 |
| 가상화 | 장치 에뮬레이션과 하드웨어 인터페이스 |
| 과학 응용 | Python·R·Fortran 아래의 계산 라이브러리 |
| 다른 언어 프로젝트 | C API와 외부 함수 인터페이스 |
이러한 분야에서 C 코드는 사용자에게 직접 보이지 않는 경우가 많다. 사용자는 Python과 JavaScript, Kotlin이나 C#으로 프로그램을 작성하지만, 내부에서는 C로 작성된 데이터베이스와 이미지 디코더, 암호화와 압축 및 운영체제 라이브러리를 사용할 수 있다.
새 프로젝트에서의 선택
새로운 프로젝트에서 C를 선택할지는 단순히 실행 속도만으로 결정할 수 없다.
C가 적합할 가능성이 높은 조건은 다음과 같다.
- 운영체제나 하드웨어에 직접 접근해야 함
- 작은 런타임과 실행 파일이 필요함
- 독립 실행 환경을 지원해야 함
- 명확한 메모리 배치가 필요함
- 기존 C API와 긴밀히 통합해야 함
- 여러 언어에서 호출할 공용 ABI가 필요함
- 오래된 플랫폼과 컴파일러를 지원해야 함
- 실행 비용을 세밀하게 통제해야 함
- 인증된 C 도구체인을 사용해야 함
다른 언어를 우선 고려할 수 있는 조건은 다음과 같다.
- 메모리 안전성이 최우선임
- 복잡한 웹 업무 로직을 빠르게 개발해야 함
- 대규모 GUI 애플리케이션을 만들어야 함
- 자동 메모리 관리가 필요함
- 안전한 동시성 추상화가 중요함
- 문자열과 동적 데이터 처리가 중심임
- 개발 속도가 실행 속도보다 중요함
- 이미 성숙한 다른 언어 생태계가 존재함
C와 다른 언어를 함께 사용하는 방식도 가능하다.
고수준 애플리케이션
Python·C#·Java·Lua
↓
C API 경계
↓
성능·시스템 모듈
C
↓
운영체제와 하드웨어
이 구조에서는 C 모듈의 인터페이스를 작고 안정적으로 유지하고, 메모리 소유권과 오류 전달 및 스레드 규칙을 명확히 정의해야 한다.
활용 분야의 종합
C는 컴퓨터를 사용하는 거의 모든 분야에 등장하지만, 가장 강한 위치는 소프트웨어의 기반을 직접 구현하는 계층이다.
응용 프로그램과 서비스
↓
언어 런타임과 프레임워크
↓
데이터베이스·코덱·암호화·네트워크
↓
시스템 라이브러리
↓
운영체제 커널과 드라이버
↓
하드웨어
이 계층 구조에서 아래로 내려갈수록 메모리 배치와 실행 비용, ABI와 하드웨어 제어가 중요해지고 C의 사용 비중이 높아지는 경향이 있다. 위쪽으로 올라갈수록 개발 생산성과 안전성, 프레임워크와 동적 기능이 중요해져 다른 언어가 주력으로 사용되는 경우가 많다.
운영체제와 임베디드 시스템, 언어 런타임과 데이터베이스, 네트워크와 멀티미디어 라이브러리에서는 C가 프로그램의 중심 언어로 많이 쓰인다. 게임과 그래픽스, 모바일과 데스크톱, 웹과 머신러닝에서는 전체 프로그램보다 성능이 중요한 코어와 네이티브 라이브러리, 다른 언어가 호출하는 기반 계층에 간간이 사용된다.
C의 넓은 활용 범위는 언어가 모든 분야에 가장 편리해서 생긴 것이 아니다. 여러 하드웨어에서 구현할 수 있고, 작은 런타임과 안정적인 ABI를 제공하며, 메모리와 운영체제 인터페이스를 직접 다룰 수 있기 때문이다. 이러한 장점은 프로그래머가 객체 수명과 배열 범위, 오류와 동시성을 직접 관리해야 한다는 책임과 함께 존재한다.
다른 프로그래밍 언어와의 관계
C는 BCPL과 B (프로그래밍 언어)에서 직접 발전했으며, 이후 등장한 여러 프로그래밍 언어의 문법과 실행 모델, 시스템 인터페이스에 영향을 주었다. C를 직접 확장한 언어가 있는 반면, C의 중괄호와 연산자 문법만 받아들이고 메모리 모델과 실행 환경은 전혀 다르게 설계한 언어도 있다.
C와 다른 언어의 관계는 하나의 계보로만 설명하기 어렵다.
BCPL
↓
B
↓
C
├── 직접 확장
│ ├── C++
│ └── Objective-C
│
├── 문법적 영향
│ ├── Java
│ ├── C#
│ ├── JavaScript
│ ├── Go
│ ├── Rust
│ ├── Zig
│ └── Wave
│
├── 시스템 인터페이스
│ ├── 운영체제 API
│ ├── C ABI
│ ├── 표준 라이브러리
│ └── 외부 함수 인터페이스
│
└── 구현 기반
├── 언어 런타임
├── 인터프리터
├── 가상 머신
└── 확장 모듈
C의 영향을 받은 언어라고 해서 모두 C의 파생 언어인 것은 아니다. 문법이 비슷해도 타입 체계와 메모리 관리, 객체 모델과 실행 방식이 다르면 별개의 언어로 보아야 한다.
BCPL과 B
C의 직접적인 전신은 BCPL과 B (프로그래밍 언어)다.
BCPL은 컴파일러와 시스템 소프트웨어를 구현하기 위해 설계된 간결한 언어였으며, 하나의 기계어 워드를 중심으로 값을 표현했다. B는 켄 톰프슨이 BCPL을 더 작은 컴퓨터에서 사용할 수 있도록 단순화한 언어였다.
C는 B의 다음 요소를 이어받았다.
- 함수 중심 프로그램 구조
- 중괄호 블록
- 포인터와 배열의 밀접한 관계
- 여러 산술·비트 연산자
if,while과return- 간결한 표현식 문법
- 시스템 프로그램을 직접 구현하려는 목적
B는 타입이 거의 없는 언어였지만, C는 PDP-11의 바이트 단위 메모리와 서로 다른 크기의 데이터를 다루기 위해 char와 int, 포인터 타입과 구조체를 도입했다.
int values[4];
int *pointer = values;
배열 이름이 대부분의 표현식에서 첫 번째 요소를 가리키는 포인터로 변환되는 C의 규칙은 BCPL과 B의 메모리 모델에서 발전한 것이다.
C 계열 언어
C의 중괄호와 세미콜론, 표현식과 제어문 문법을 공유하는 언어들을 넓은 의미에서 C 계열 언어라고 부르기도 한다.
대표적인 공통 문법은 다음과 같다.
if (condition) {
process();
}
for (int index = 0; index < count; ++index) {
process(values[index]);
}
다음 요소가 여러 언어에 반복해서 나타난다.
{}를 사용한 블록- 세미콜론으로 끝나는 문장
if,else,switchfor,while,dobreak,continue,return+,-,*,/==,!=,<,>&&,||,!++와--- 배열 첨자
[] - 함수 호출
()
그러나 이러한 표면 문법이 같다고 해서 프로그램의 의미까지 같지는 않다.
C
→ 직접적인 메모리 접근
→ 수동 메모리 관리
→ 정의되지 않은 동작 존재
→ 구현체와 ABI의 영향이 큼
Java
→ 가상 머신
→ 가비지 컬렉션
→ 배열 범위 검사
→ 포인터 산술 없음
Rust
→ 소유권과 대여 검사
→ 안전 코드와 unsafe 코드 구분
→ 기본적으로 메모리 안전성 검사
Go
→ 가비지 컬렉션
→ 고루틴과 채널
→ 제한된 포인터 연산
C 계열이라는 표현은 문법적 친족 관계를 설명하는 데 유용하지만, 언어의 안전성과 실행 비용, 메모리 모델을 판단하는 기준으로 사용해서는 안 된다.
C++
C++는 C와 가장 밀접한 관계를 가진 언어 가운데 하나다. 비야네 스트롭스트룹은 1979년 C에 클래스와 객체 지향 기능을 추가하는 작업을 시작했으며, 초기 언어의 이름은 C with Classes였다. 1983년부터 C++라는 이름이 사용되었고 1985년에 첫 상용 구현과 언어 서적이 공개되었다.[110]
초기 C++는 C의 다음 요소를 그대로 기반으로 삼았다.
- 기본 자료형
- 포인터와 배열
- 구조체
- 함수
- 전처리기
- 별도 컴파일
- 운영체제 및 하드웨어 접근
- C 라이브러리와의 호환
- 네이티브 코드 생성
여기에 다음 기능이 추가되었다.
- 클래스
- 접근 제어
- 생성자와 소멸자
- 상속
- 가상 함수
- 함수와 연산자 오버로딩
- 참조
- 템플릿
- 예외
- 네임스페이스
- 일반화 프로그래밍
- 표준 템플릿 라이브러리
- 람다
- 자동 자원 관리
C의 구조체와 함수로 객체 비슷한 구조를 만들 수 있다.
struct counter {
int value;
};
void counter_increment(struct counter *counter) {
++counter->value;
}
C++에서는 데이터와 동작을 클래스 내부에 결합할 수 있다.
class Counter {
public:
void increment() {
++value_;
}
private:
int value_ = 0;
};
C++는 역사적으로 C와 높은 호환성을 유지해 왔지만, 현대의 C와 C++는 서로 다른 국제 표준과 위원회에서 관리되는 별개의 언어다.
C
→ WG14
→ ISO/IEC 9899
C++
→ WG21
→ ISO/IEC 14882
모든 유효한 C 프로그램이 C++ 프로그램으로 그대로 컴파일되는 것은 아니다.
예를 들어 C에서는 void *를 다른 객체 포인터로 암시적으로 변환할 수 있다.
void *memory = malloc(sizeof(int));
int *value = memory;
C++에서는 명시적인 변환이나 다른 할당 방식을 요구한다.
void *memory = std::malloc(sizeof(int));
int *value = static_cast<int *>(memory);
C와 C++는 키워드 집합과 타입 규칙, 초기화, 문자열 리터럴, 열거형과 함수 선언 등 여러 부분에서 차이가 난다.
C와 C++의 혼합
C++ 프로그램은 C로 작성된 라이브러리를 호출하는 경우가 많다. C++ 컴파일러에서 C 연결 규약을 지정하려면 extern "C"를 사용할 수 있다.
extern "C" {
int library_initialize(void);
void library_shutdown(void);
}
C와 C++에서 함께 포함하는 헤더는 다음과 같이 작성할 수 있다.
#ifndef LIBRARY_H
#define LIBRARY_H
#ifdef __cplusplus
extern "C" {
#endif
struct library_context;
struct library_context *
library_create(void);
void library_destroy(
struct library_context *context
);
#ifdef __cplusplus
}
#endif
#endif
extern "C"는 C++ 이름 맹글링과 연결 규약을 조정하기 위한 기능이다. C와 C++ 객체 모델 전체를 동일하게 만드는 기능은 아니다.
C++ 클래스와 예외, 템플릿 타입을 C ABI 경계에 직접 노출하는 것은 이식성이 낮다. 공통 인터페이스에는 기본 타입과 포인터, 명시적인 구조체와 불투명 핸들을 사용하는 편이 일반적이다.
Objective-C
Objective-C는 C에 객체 지향 메시지 전달과 동적 런타임을 추가한 언어다. Apple의 공식 문서는 Objective-C를 C의 상위 집합으로 설명하며, C의 문법과 기본 타입, 구조체와 함수, 포인터와 제어문을 그대로 사용할 수 있다고 설명한다.[111]
일반적인 C 함수는 Objective-C 소스에서도 그대로 사용할 수 있다.
int add(int left, int right) {
return left + right;
}
Objective-C는 클래스와 메서드, 객체에 대한 메시지 전송 문법을 추가한다.
@interface Counter : NSObject
@property int value;
- (void)increment;
@end
@implementation Counter
- (void)increment {
self.value += 1;
}
@end
메서드 호출은 대괄호를 사용한 메시지 전송으로 표현된다.
[counter increment];
Objective-C는 C의 다음 요소를 유지한다.
- 포인터
- 구조체
- 함수
- 전처리기
- C 표준 라이브러리
- C ABI와의 높은 호환성
- C 소스와의 혼합
여기에 다음 요소를 추가한다.
- 클래스와 객체
- 메시지 전달
- 프로토콜
- 카테고리
- 동적 메서드 탐색
- 런타임 리플렉션
- 자동 참조 계산
Objective-C++를 사용하면 하나의 소스 파일에서 C와 C++, Objective-C를 함께 사용할 수도 있다.
Java
Java는 C와 C++의 문법에서 큰 영향을 받았지만 C의 직접적인 상위 집합은 아니다. Java Virtual Machine 명세는 Java 문법이 C와 C++와 비슷하지만, 복잡하거나 혼란스럽고 안전하지 않은 여러 기능을 제거했다고 설명한다.[112]
Java는 C에서 다음과 같은 표면 문법을 이어받았다.
if (value > 0) {
process(value);
}
for (int index = 0; index < count; ++index) {
process(values[index]);
}
하지만 실행 모델은 크게 다르다.
| 항목 | C | Java |
|---|---|---|
| 일반 실행 | 네이티브 코드 | JVM 바이트코드와 JIT |
| 메모리 관리 | 수동 관리 | 가비지 컬렉션 |
| 포인터 산술 | 지원 | 지원하지 않음 |
| 배열 범위 | 일반적으로 검사 없음 | 런타임 검사 |
| 객체 | 구조체와 직접 구현 | 클래스 기반 |
| 상속 | 언어 기능 없음 | 클래스 상속 |
| 예외 | 언어 기능 없음 | 예외 처리 |
| 이식 단위 | 소스와 ABI | 바이트코드와 JVM |
Java의 참조는 C 포인터와 같은 문법적·의미적 객체가 아니다. 참조의 숫자 주소를 얻거나 임의의 포인터 산술을 수행할 수 없다.
C의 직접적인 통제력을 줄이는 대신, Java는 실행 환경이 객체 수명과 배열 경계를 관리한다.
C#
C#은 C와 C++ 및 Java의 문법적 전통을 이어받은 언어다. 이름 자체도 C 계열 언어와의 연속성을 나타내지만 C의 상위 집합은 아니다.
public static int Add(
int left,
int right
) {
return left + right;
}
C#은 일반적으로 .NET 런타임 위에서 실행되며 다음 기능을 제공한다.
- 가비지 컬렉션
- 클래스와 구조체
- 속성
- 대리자
- 이벤트
- 제네릭
- 예외
- 리플렉션
- 비동기 함수
- 관리되는 배열과 문자열
- 런타임 타입 정보
대부분의 C# 코드에서는 C 포인터를 직접 사용하지 않는다. 필요한 경우 unsafe 문맥에서 포인터를 사용할 수 있다.
unsafe static int Read(int *pointer) {
return *pointer;
}
C#은 P/Invoke와 네이티브 상호 운용 기능을 통해 C ABI 라이브러리를 호출할 수 있다.
[DllImport("example")]
private static extern int add(
int left,
int right
);
C 라이브러리의 구조체 배치와 문자열 인코딩, 호출 규약과 소유권이 C# 선언과 정확히 일치해야 한다.
JavaScript
JavaScript는 이름과 초기 문법 설계에서 Java의 영향을 받았고, 제어문과 연산자 구조는 넓은 의미에서 C 계열 문법에 속한다.
if (value > 0) {
process(value);
}
for (let index = 0; index < count; ++index) {
process(values[index]);
}
그러나 JavaScript의 타입과 실행 방식은 C와 크게 다르다.
C
→ 정적 타입
→ 직접적인 객체 표현
→ 포인터
→ 수동 메모리 관리
→ 네이티브 실행
JavaScript
→ 동적 타입
→ 객체 기반 실행 모델
→ 가비지 컬렉션
→ 직접 포인터 없음
→ JavaScript 엔진에서 실행
JavaScript 엔진과 브라우저 런타임은 C와 C++로 작성된 라이브러리와 시스템 구성 요소를 사용할 수 있다. WebAssembly를 통해 C 코드를 브라우저나 다른 샌드박스 환경에서 실행하는 것도 가능하다.
Go
Go (프로그래밍 언어)는 C와 유사한 간결한 문법과 네이티브 컴파일 방식을 일부 공유하지만, 메모리 관리와 동시성은 다르게 설계되었다.
func add(left int, right int) int {
return left + right
}
Go는 다음과 같은 특징을 가진다.
- 가비지 컬렉션
- 고루틴
- 채널
- 인터페이스
- 구조적 타이핑의 일부
- 슬라이스
- 맵
- 자동으로 확장되는 스택
- 제한적인 포인터
- 포인터 산술 미지원
Go의 포인터는 객체를 간접 참조할 수 있지만 일반적인 C 포인터 산술은 허용하지 않는다.
value := 10
pointer := &value
*pointer = 20
Go는 cgo를 통해 C 코드를 호출할 수 있다.
/*
int add(int left, int right) {
return left + right;
}
*/
import "C"
func main() {
result := C.add(10, 20)
_ = result
}
Go 공식 FAQ는 C와 Go를 같은 주소 공간에서 함께 사용할 수 있지만, 인터페이스 코드가 필요하고 순수 Go가 제공하는 메모리 안전성과 스택 관리 특성 일부를 잃게 된다고 설명한다.[113]
C 경계에서는 다음 문제를 관리해야 한다.
- Go 포인터를 C에서 보관할 수 있는 기간
- 가비지 컬렉터가 관리하는 객체
- 스레드와 고루틴
- 콜백
- 문자열과 배열 변환
- C 메모리의 해제
- 호출 비용
Rust
Rust는 C와 C++가 담당해 온 시스템 프로그래밍 영역을 목표로 하면서, 소유권과 대여 검사로 메모리 안전 문제를 줄이도록 설계된 언어다.
fn add(left: i32, right: i32) -> i32 {
left + right
}
Rust는 다음 기능을 언어와 타입 체계에 포함한다.
- 소유권
- 대여
- 수명
- 패턴 일치
- 열거형과 대수적 데이터 타입
Result와Option- 특성
- 제네릭
- 기본적인 널 참조 방지
- 데이터 경쟁 방지
- 안전 코드와
unsafe코드의 구분
C에서는 동적으로 할당한 객체의 소유권이 타입에 나타나지 않는다.
struct resource *resource_create(void);
void resource_destroy(
struct resource *resource
);
호출자는 문서를 읽고 정확히 한 번 해제해야 한다.
Rust에서는 소유 객체가 범위를 벗어날 때 정리되는 방식을 타입으로 표현할 수 있다.
struct Resource {
data: Vec<u8>,
}
Rust가 모든 코드에서 정의되지 않은 동작을 불가능하게 만드는 것은 아니다. 원시 포인터 역참조와 외부 함수 호출, 일부 저수준 연산은 unsafe 문맥에서 수행된다.
unsafe fn read(pointer: *const i32) -> i32 {
*pointer
}
Rust 공식 참조 문서는 unsafe 코드에서도 정의되지 않은 동작을 일으켜서는 안 되며, unsafe는 그러한 규칙을 지킬 책임이 프로그래머에게 이동한다는 뜻이라고 설명한다.[114]
Rust는 C ABI 함수를 선언하고 호출할 수 있다.
unsafe extern "C" {
fn add(left: i32, right: i32) -> i32;
}
Rust 라이브러리도 C에서 호출할 수 있는 인터페이스를 공개할 수 있다.
#[unsafe(no_mangle)]
pub extern "C" fn add(
left: i32,
right: i32,
) -> i32 {
left + right
}
두 언어 사이에서는 구조체 배치와 패닉, 문자열, 메모리 소유권과 콜백 수명을 명확히 제한해야 한다.
Zig
Zig는 C와 비슷한 시스템 프로그래밍 영역을 대상으로 하며, 명시적인 메모리 할당과 컴파일 시간 실행, 오류 집합과 선택 타입 등을 제공한다.
Zig는 C와의 상호 운용을 중요한 기능으로 두고 C 헤더를 가져오거나 C ABI 라이브러리를 만들 수 있다.
const c = @cImport({
@cInclude("example.h");
});
Zig는 C의 다음 특성을 유지하거나 활용한다.
- 네이티브 코드
- 직접 메모리 제어
- C ABI
- 운영체제 및 하드웨어 접근
- 별도 런타임을 최소화할 수 있는 실행 방식
반면 다음 요소를 더 명시적으로 다룬다.
- 오류 처리
- 선택 값
- 배열과 슬라이스
- 컴파일 시간 계산
- 할당자 전달
- 정수 오버플로 연산
- 포인터 종류
- 정의되지 않은 동작을 줄이기 위한 안전 검사
Zig는 자체 언어일 뿐 아니라 C와 C++ 프로젝트의 빌드 및 교차 컴파일 도구로도 사용될 수 있다.
Wave
Wave (프로그래밍 언어)는 C가 오랫동안 담당해 온 시스템 프로그래밍과 저수준 제어 영역을 대상으로 개발된 프로그래밍 언어다. 2024년에 개발이 시작되었고 2025년에 초기 버전이 공개되었다.
Wave는 C의 포인터가 제공하는 직접적인 메모리 제어 능력을 유지하면서, 포인터 오용과 객체 수명 위반, 정의되지 않은 동작으로 이어지는 여러 문제를 줄이는 것을 목표로 한다.
C에서는 포인터가 가리키는 객체의 수명과 별칭, 널 여부와 유효 범위를 프로그래머가 직접 관리해야 한다.
int *create_value(void) {
int value = 10;
return &value;
}
함수가 끝나면 value의 수명이 종료되므로 반환된 포인터는 유효한 객체를 가리키지 않는다. 이를 역참조하면 정의되지 않은 동작이 발생한다.
int *pointer = create_value();
int result = *pointer;
C에서는 이와 같은 코드가 문법적으로 표현 가능하며, 컴파일러 진단도 항상 보장되지 않는다.
Wave는 이러한 C의 포인터와 정의되지 않은 동작 문제를 보완하기 위해 포인터 타입과 가변성, 컴파일러 검사를 언어 설계에 더 명시적으로 포함하려는 방향으로 개발되었다.
Wave의 포인터는 ptr<T> 형태로 타입을 명시한다.
fun main() {
var value: i32 = 10;
var pointer: ptr<i32> = &value;
println("{}", deref pointer);
}
Wave는 2025년 공개된 초기 프리베타 버전에서 ptr<T> 포인터 타입과 주소 연산자, 명시적인 역참조 문법을 도입했다.[115]
또한 변수 선언에서 변경 가능성을 구분하는 문법을 제공한다.
let immutable_value: i32 = 10;
let mut controlled_value: i32 = 20;
var mutable_value: i32 = 30;
이는 모든 객체가 기본적으로 자유롭게 변경되는 C의 변수 모델보다 가변성과 의도를 명시적으로 나타내려는 설계다.[116]
Wave는 저수준 제어와 높은 성능을 목표로 하는 시스템 프로그래밍 언어이며, 컴파일러에 내장 함수를 많이 두기보다 기능을 표준 라이브러리에서 제공하는 구조를 지향한다.[117]
2025년 공개된 초기 버전은 아직 프리베타 단계였으므로 C를 즉시 대체하는 완성된 표준 언어로 볼 수는 없다. 다만 C가 제공하는 직접적인 하드웨어 접근과 네이티브 실행 능력을 유지하면서, 포인터 안전성과 명시적인 가변성, 더 강한 컴파일러 검사를 통해 C의 반복적인 오류를 줄이려는 현대 시스템 언어의 한 사례다.
C
→ 직접적인 포인터와 메모리 제어
→ 적은 런타임 의존성
→ 넓은 정의되지 않은 동작 영역
→ 객체 수명과 별칭을 프로그래머가 관리
Wave
→ 시스템 프로그래밍과 저수준 제어 지향
→ 명시적 포인터 타입
→ 명시적 역참조
→ 가변성 구분
→ 컴파일러 검사를 통한 오류 감소 지향
Wave가 C와 맺는 관계는 C의 단순한 문법 확장이라기보다, C가 개척한 시스템 프로그래밍 영역을 유지하면서 포인터와 정의되지 않은 동작의 문제를 새로운 언어 설계로 보완하려는 관계에 가깝다.
D
D (프로그래밍 언어)는 C와 C++의 시스템 프로그래밍 능력과 유사한 문법을 유지하면서 현대적인 언어 기능을 추가한 언어다.
D는 다음 기능을 제공한다.
- 클래스와 구조체
- 가비지 컬렉션
- 템플릿
- 범위
- 계약 프로그래밍
- 모듈
- 컴파일 시간 함수 실행
- 선택적인 수동 메모리 관리
- C ABI 상호 운용
extern(C)
int add(int left, int right) {
return left + right;
}
D는 C의 직접적인 상위 집합은 아니며, C 헤더를 그대로 문법적으로 포함하는 방식보다 C ABI 선언을 별도로 작성해 연동한다.
Swift
Swift는 Apple 플랫폼에서 Objective-C와 C 기반 프레임워크를 대체하거나 함께 사용하기 위해 설계되었다.
Swift는 다음 기능을 제공한다.
- 자동 참조 계산
- 선택 타입
- 구조체와 클래스
- 프로토콜
- 제네릭
- 패턴 일치
- 오류 처리
- 값 타입
- 범위 검사
Swift 프로젝트에서는 C와 Objective-C 헤더를 모듈로 가져와 사용할 수 있다.
int library_add(int left, int right);
Swift에서는 가져온 함수를 일반 함수처럼 호출할 수 있다.
let result = library_add(10, 20)
C 인터페이스의 널 가능성이나 배열 길이, 소유권 정보가 부족하면 Swift가 완전한 안전성을 자동으로 추론할 수 없다. Apple 생태계의 C API에서는 nullability와 속성, 모듈 메타데이터를 추가하여 다른 언어에서 더 정확하게 사용할 수 있도록 한다.
Python
Python은 C와 문법적으로는 큰 차이가 있지만 구현과 확장 생태계에서 매우 깊은 관계를 가진다.
대표 구현인 CPython은 C로 구현되며, Python/C API를 통해 확장 모듈을 작성하거나 Python 인터프리터를 C 프로그램에 삽입할 수 있다.
#include <Python.h>
static PyObject *add_values(
PyObject *self,
PyObject *arguments
) {
int left;
int right;
if (!PyArg_ParseTuple(
arguments,
"ii",
&left,
&right
)) {
return NULL;
}
return PyLong_FromLong(
left + right
);
}
Python에서 보이는 많은 라이브러리는 내부에서 C와 C++, Fortran으로 작성된 네이티브 모듈을 사용한다.
Python 코드
↓
Python C API
↓
C 확장 모듈
↓
수치·이미지·데이터베이스 라이브러리
C 확장 모듈은 Python의 높은 수준 인터페이스와 C의 실행 성능 및 기존 라이브러리를 결합한다.
반면 C 확장에서 메모리 오류가 발생하면 Python의 예외로 안전하게 제한되지 않고 전체 인터프리터가 종료되거나 손상될 수 있다.
Lua
Lua는 C 프로그램에 삽입하기 쉽도록 설계된 스크립트 언어다. Lua 구현과 공개 API는 C를 중심으로 구성되어 있다.
C 프로그램은 Lua 상태를 만들고 스크립트를 실행할 수 있다.
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
int run_script(const char *path) {
lua_State *state =
luaL_newstate();
if (state == NULL) {
return -1;
}
luaL_openlibs(state);
int result =
luaL_dofile(state, path);
lua_close(state);
return result;
}
Lua는 게임 엔진과 응용 프로그램, 서버와 도구에서 C 또는 C++ 코어 위의 스크립트 계층으로 사용된다.
C 또는 C++ 엔진
↓
Lua C API
↓
Lua 게임·도구 스크립트
C 함수도 Lua에 등록할 수 있다.
static int add_values(
lua_State *state
) {
lua_Integer left =
luaL_checkinteger(state, 1);
lua_Integer right =
luaL_checkinteger(state, 2);
lua_pushinteger(
state,
left + right
);
return 1;
}
다른 언어의 구현 언어
C는 여러 프로그래밍 언어의 구현 언어로 사용되어 왔다.
대표적인 예는 다음과 같다.
- CPython
- Lua
- Ruby의 CRuby
- PHP 구현
- Perl
- R
- Tcl
- 여러 Scheme과 Lisp 구현
- 셸과 명령 해석기
- 가상 머신
- 바이트코드 런타임
C가 구현 언어로 선택되는 이유는 다음과 같다.
- 여러 운영체제와 프로세서에 컴파일러가 존재함
- 운영체제 API에 직접 접근할 수 있음
- 작고 안정적인 ABI를 제공함
- 다른 언어에서 호출하기 쉬움
- 런타임과 메모리 관리기를 직접 설계할 수 있음
- 오랜 기간 유지된 라이브러리 생태계가 있음
C로 구현되었다고 해당 언어의 사용자 코드가 C의 메모리 모델을 그대로 갖는 것은 아니다.
Python 사용자 코드
→ 가비지 컬렉션과 Python 객체 규칙
CPython 내부
→ C 객체와 포인터, 참조 횟수 관리
구현 언어와 사용자에게 노출되는 언어 의미는 구분해야 한다.
C ABI
C가 다른 언어와 깊은 관계를 유지하는 가장 중요한 이유 가운데 하나는 C ABI다.
C ABI는 운영체제와 컴파일러가 다음 요소를 이진 수준에서 약속하는 규칙이다.
- 함수 인수 전달
- 반환값 전달
- 구조체 배치
- 기본 타입 크기
- 포인터 표현
- 스택 정렬
- 레지스터 보존
- 심벌 이름
- 동적 라이브러리 형식
- 호출 규약
C 표준 자체가 하나의 범용 ABI를 규정하는 것은 아니다. 실제 ABI는 대상 플랫폼이 정한다.
C 언어
+
컴파일러
+
운영체제
+
프로세서 ABI
=
실제 C 이진 인터페이스
C API는 대체로 다음과 같이 단순한 형태로 설계된다.
struct image;
struct image *image_create(
size_t width,
size_t height
);
bool image_load(
struct image *image,
const char *path
);
void image_destroy(
struct image *image
);
이 인터페이스는 다음 언어에서 바인딩하기 쉽다.
- C++
- Rust
- Zig
- Wave
- Python
- C#
- Java
- Swift
- Go
- Lua
- Ruby
외부 함수 인터페이스
다른 언어가 C 함수를 호출하는 기능을 외부 함수 인터페이스 또는 FFI라고 한다.
일반적인 구조는 다음과 같다.
고수준 언어
↓
FFI 선언
↓
C ABI
↓
공유 라이브러리
↓
C 구현
FFI에서는 문법보다 데이터 표현과 수명이 중요하다.
다음 C 구조체가 있다고 하자.
struct point {
int32_t x;
int32_t y;
};
다른 언어에서도 정확히 같은 배치와 정렬을 사용해야 한다.
Rust에서는 repr(C)를 지정한다.
#[repr(C)]
struct Point {
x: i32,
y: i32,
}
C#에서는 구조체 배치 속성을 사용할 수 있다.
[StructLayout(LayoutKind.Sequential)]
struct Point {
public int X;
public int Y;
}
배치가 일치하지 않으면 값이 잘못 해석되거나 메모리 손상이 발생할 수 있다.
불투명 포인터
다른 언어와 공유하는 C API에서는 내부 구조를 숨기기 위해 불투명 포인터를 자주 사용한다.
헤더에는 구조체의 이름만 선언한다.
struct database;
생성과 파괴 함수를 제공한다.
struct database *database_open(
const char *path
);
void database_close(
struct database *database
);
구조체 정의는 C 구현 파일에만 둔다.
struct database {
FILE *file;
void *cache;
size_t page_count;
};
이 방식의 장점은 다음과 같다.
- ABI 노출을 줄임
- 내부 구현 변경 가능
- 다른 언어가 구조체 구성원에 직접 접근하지 못함
- 소유권 경계를 명확하게 만들 수 있음
- 컴파일러와 언어 차이를 줄임
콜백
C 함수 포인터는 다른 언어가 C 라이브러리에 콜백을 전달하는 기반이 된다.
typedef void (*event_callback)(
int event,
void *context
);
void event_set_callback(
event_callback callback,
void *context
);
void *context는 콜백과 함께 사용자 상태를 전달한다.
struct application {
int event_count;
};
static void handle_event(
int event,
void *context
) {
struct application *application =
context;
++application->event_count;
}
다른 언어에서 콜백을 전달할 때는 다음 문제가 발생할 수 있다.
- 콜백 객체의 수명
- 가비지 컬렉터 이동
- 호출 스레드
- 예외나 패닉 전파
- 호출 규약
- 종료 뒤 호출되는 콜백
- 사용자 데이터의 소유권
C 함수 경계를 넘어 C++ 예외나 Rust 패닉, 다른 언어의 런타임 예외가 직접 전파되게 해서는 안 된다.
문자열 상호 운용
C 문자열은 널 문자로 끝나는 바이트 배열이다.
const char *name =
"TechPedia";
다른 언어의 문자열은 다음과 같이 다를 수 있다.
C
→ 널 종료 바이트 문자열
→ 길이 정보가 별도로 없을 수 있음
Rust
→ UTF-8 바이트와 길이
→ 내부 널 허용
Java
→ JVM 문자열 객체
→ UTF-16 기반 표현
C#
→ .NET 문자열 객체
→ UTF-16 기반 표현
Swift
→ 유니코드 문자 컬렉션
→ 내부 표현 추상화
언어 경계에서는 인코딩과 길이, 널 문자와 소유권을 명확히 해야 한다.
길이를 함께 전달하는 C API가 더 일반적일 수 있다.
bool document_parse(
const char *text,
size_t length
);
널 종료 문자열을 요구하는 API는 다음과 같이 문서화해야 한다.
bool document_open(
const char *null_terminated_path
);
메모리 소유권
C ABI는 메모리 소유권을 타입으로 자동 표현하지 않는다.
다음 함수가 반환한 포인터를 누가 해제하는지 선언만으로는 알기 어렵다.
char *document_get_title(
struct document *document
);
가능한 의미는 여러 가지다.
1. 호출자가 free로 해제
2. 전용 함수로 해제
3. document가 소유
4. 다음 호출까지 유효
5. 프로그램 종료까지 유효
6. 수정할 수 없는 내부 문자열
API는 소유권을 함수 이름과 문서, 타입으로 최대한 분명히 해야 한다.
char *document_title_copy(
const struct document *document
);
void document_string_free(
char *string
);
또는 비소유 포인터를 반환할 수 있다.
const char *document_title_view(
const struct document *document
);
다른 언어 바인딩은 이 규칙을 해당 언어의 자원 관리 모델로 변환해야 한다.
오류 전달
C 함수는 오류를 여러 방식으로 전달한다.
- 정수 오류 코드
- 불리언 결과
- 널 포인터
errno- 출력 매개변수
- 별도 오류 객체
- 콜백
enum result_code {
RESULT_SUCCESS,
RESULT_INVALID_ARGUMENT,
RESULT_OUT_OF_MEMORY,
RESULT_IO_ERROR
};
enum result_code document_open(
const char *path,
struct document **result
);
이 구조는 다른 언어에서 예외나 Result, 오류 객체로 변환하기 쉽다.
C 오류 코드
↓
Rust Result
C# Exception
Python Exception
Go error
C 경계를 넘는 함수는 언어별 예외를 직접 전파하지 않고 C에서 표현 가능한 결과로 변환하는 편이 안전하다.
포인터와 정의되지 않은 동작
C와 현대 시스템 언어의 관계에서 가장 중요한 차이 가운데 하나는 포인터와 정의되지 않은 동작을 다루는 방식이다.
C에서 다음 동작은 정의되지 않은 동작을 일으킬 수 있다.
- 널 포인터 역참조
- 해제 후 사용
- 배열 범위를 벗어난 접근
- 수명이 끝난 객체 접근
- 정렬되지 않은 포인터 역참조
- 호환되지 않는 타입을 통한 접근
- 데이터 경쟁
- 부호 있는 정수 오버플로
- 잘못된 포인터 산술
restrict계약 위반
int *pointer = NULL;
*pointer = 10;
C 표준이 이러한 동작을 정의하지 않는 이유는 모든 하드웨어에서 동일한 검사 비용을 강제하지 않고, 구현체가 강한 최적화를 수행할 수 있게 하기 위해서다.
그러나 정의되지 않은 동작은 단순히 프로그램이 예상하지 못한 값을 반환한다는 뜻이 아니다. 컴파일러는 올바른 프로그램에서 해당 동작이 발생하지 않는다고 가정할 수 있다.
포인터 provenance에 관한 WG14 문서들은 포인터를 단순한 정수 주소가 아니라 어떤 객체와 할당에서 유래했는지를 포함하는 값으로 해석할 필요성을 논의한다.[118]
이 문제는 Rust와 Zig, Wave를 비롯한 현대 시스템 언어가 다음 기능을 설계하게 된 배경 가운데 하나다.
- 포인터 연산 제한
- 배열과 슬라이스 구분
- 널 가능성의 타입 표현
- 소유권
- 수명 검사
- 명시적인 unsafe 영역
- 정수 오버플로 검사
- 가변성 구분
- 컴파일러 진단 강화
C를 대체하는 언어와 보완하는 언어
새로운 시스템 언어는 흔히 C를 완전히 대체한다고 소개되지만, 실제 관계는 더 복잡하다.
대체
→ 새로운 모듈을 다른 언어로 작성
→ 기존 C 코드를 점진적으로 제거
보완
→ 안전한 상위 계층을 새 언어로 작성
→ 기존 C 라이브러리를 FFI로 호출
공존
→ 플랫폼과 ABI 경계는 C
→ 핵심 모듈은 Rust·Zig·Wave
→ 기존 라이브러리는 C 유지
C는 매우 큰 기존 코드와 운영체제 API, 하드웨어 SDK와 라이브러리를 가지고 있다. 따라서 새로운 시스템 언어도 대체로 C ABI와 상호 운용 기능을 제공한다.
C를 대체하려는 언어조차 C와의 연결을 포기하기 어렵다는 점은 C가 단순한 소스 언어를 넘어 네이티브 소프트웨어의 공통 인터페이스 역할을 한다는 사실을 보여준다.
C를 중간 언어로 사용
일부 언어 구현은 소스 코드를 C로 변환한 뒤 기존 C 컴파일러를 사용해 네이티브 코드를 생성한다.
새로운 언어 소스
↓
C 코드 생성
↓
C 컴파일러
↓
객체 파일
↓
실행 프로그램
이 방식의 장점은 다음과 같다.
- 여러 플랫폼의 C 컴파일러 활용
- 기존 최적화 백엔드 사용
- ABI와 링커 도구 재사용
- 플랫폼별 코드 생성 부담 감소
- C 디버거와 분석 도구 활용
단점도 있다.
- 원본 언어와 C 사이의 의미 차이
- 디버깅 정보 매핑
- C의 정의되지 않은 동작에 노출될 가능성
- 최적화 제어의 한계
- 컴파일 시간 증가
- C 코드 크기 증가
C를 중간 표현으로 사용하는 방식은 LLVM IR과 WebAssembly 같은 별도 중간 표현이 널리 사용되기 전부터 여러 언어 구현에서 활용되었다.
WebAssembly
C는 WebAssembly 대상으로 컴파일할 수 있다.
C 소스
↓
Clang·LLVM 또는 Emscripten
↓
WebAssembly 모듈
↓
브라우저 또는 WASI 런타임
WebAssembly 환경에서는 C 포인터가 일반적으로 선형 메모리의 오프셋으로 구현된다.
C 포인터
→ WebAssembly 선형 메모리 주소
브라우저 JavaScript와 C 모듈 사이에는 별도의 바인딩 계층이 필요하다.
const result = module.exports.add(
10,
20
);
WebAssembly가 C의 정의되지 않은 동작을 자동으로 제거하는 것은 아니다. C 소스 단계의 잘못된 포인터 산술과 수명 위반은 컴파일 최적화에 영향을 줄 수 있다.
운영체제 API
많은 운영체제 API는 C 함수와 구조체 형태로 공개된다.
int open(
const char *path,
int flags,
...
);
void *mmap(
void *address,
size_t length,
int protection,
int flags,
int file,
off_t offset
);
Windows API 역시 많은 부분이 C 호출 인터페이스를 기반으로 한다.
HANDLE CreateFileW(
LPCWSTR file_name,
DWORD access,
DWORD sharing,
LPSECURITY_ATTRIBUTES security,
DWORD creation,
DWORD attributes,
HANDLE template_file
);
다른 언어의 운영체제 라이브러리는 이러한 C API를 감싸는 경우가 많다.
Rust 표준 라이브러리
Go runtime
Python os 모듈
Java native runtime
.NET runtime
↓
운영체제 C ABI 또는 시스템 호출
모든 운영체제 기능이 실제로 C로 구현된다는 뜻은 아니다. 외부에 공개되는 ABI와 헤더가 C 형태로 제공되는 경우가 많다는 의미다.
라이브러리의 공통 경계
C 라이브러리는 여러 언어가 공유하는 공통 기반이 된다.
대표적인 예는 다음과 같다.
- SQLite
- zlib
- libpng
- libjpeg
- OpenSSL
- libcurl
- FFmpeg
- Lua
- Git 라이브러리
- 운영체제 표준 라이브러리
- 그래픽스 API
- 장치 SDK
하나의 C 라이브러리에 여러 언어 바인딩을 만들 수 있다.
Python 바인딩
│
Rust 바인딩 ─ C 라이브러리 ─ C# 바인딩
│
Go 바인딩
이 구조는 구현을 한 번 유지하면서 여러 언어에서 사용할 수 있다는 장점이 있다.
반면 C ABI 경계는 다음 정보를 자동으로 전달하지 못한다.
- 소유권
- 배열 길이
- 널 가능성
- 문자열 인코딩
- 스레드 안전성
- 오류 의미
- 콜백 수명
- 예외 처리
- 비동기 실행 규칙
따라서 바인딩은 단순히 함수 이름만 연결하는 작업이 아니다.
언어 독립 API 설계
여러 언어에서 사용할 C API는 C 내부 전용 API보다 더 제한적으로 설계하는 편이 좋다.
적합한 요소는 다음과 같다.
- 고정 폭 정수
- 명시적인 크기
- 불투명 핸들
- 단순 구조체
- 오류 코드
- 명시적인 생성과 파괴
- 콜백과 사용자 데이터
- 버전 필드
- 함수 테이블
struct library_api {
uint32_t structure_size;
uint32_t version;
struct library_context *
(*create)(void);
void (*destroy)(
struct library_context *context
);
};
피해야 할 요소는 다음과 같다.
- 컴파일러별 비트 필드
- 가변 길이 구조체를 무분별하게 노출
long처럼 플랫폼마다 폭이 다른 타입- C++ 클래스
- 예외 전파
- 내부 포인터 직접 공개
- 소유권이 불명확한 반환값
- 호출 중에만 유효한 임시 포인터
- 문서화되지 않은 스레드 요구
문법적 영향과 의미적 차이
C의 문법은 이후 언어의 학습 장벽을 낮추는 공통 표현 체계를 만들었다.
if (...)
for (...)
while (...)
return ...
function(...)
array[index]
한 C 계열 언어를 배운 개발자는 다른 언어의 기본적인 제어 흐름을 쉽게 읽을 수 있다.
그러나 비슷한 문법은 잘못된 추론을 만들기도 한다.
다음 코드는 C와 Java에서 비슷해 보인다.
values[index] = 10;
C에서는 범위 검사가 없을 수 있다.
index 범위 초과
→ 정의되지 않은 동작
Java에서는 배열 범위를 검사한다.
index 범위 초과
→ 예외
다음 코드도 C와 C#에서 비슷해 보인다.
object = NULL;
C에서는 일반적으로 포인터 변수 하나의 값만 변경한다. 동적 할당 객체가 자동 해제되는 것은 아니다.
가비지 컬렉션 언어에서는 더 이상 참조되지 않는 객체를 런타임이 나중에 회수할 수 있다.
문법이 같아도 객체 수명과 오류 처리 규칙을 해당 언어 기준으로 이해해야 한다.
영향과 한계
C는 다음과 같은 프로그래밍 관행을 널리 확산시켰다.
- 중괄호 기반 블록
- 표현식 중심 문법
- 세미콜론 문장
- 포인터와 배열
- 구조체
- 함수와 별도 컴파일
- 헤더와 라이브러리
- 전처리
- 네이티브 ABI
- 0을 거짓으로 보는 조건식
- 연산자 우선순위 체계
동시에 C의 한계도 후속 언어 설계에 영향을 주었다.
C의 수동 메모리 관리
→ 가비지 컬렉션
→ 소유권과 RAII
널 포인터
→ 선택 타입
→ null safety
배열 범위 검사 부재
→ 슬라이스
→ 런타임 경계 검사
오류 코드
→ 예외
→ Result 타입
전처리 매크로
→ 제네릭
→ 템플릿
→ 위생적 매크로
정의되지 않은 동작
→ 안전한 언어 부분집합
→ 명시적인 unsafe 경계
헤더 기반 인터페이스
→ 모듈 시스템
→ 패키지 시스템
C 이후의 언어들은 C를 단순히 복제하기보다, C가 제공한 성능과 이식성을 유지하면서 더 높은 안전성과 추상화를 제공하려고 시도해 왔다.
관계의 종합
C와 다른 프로그래밍 언어의 관계는 크게 네 가지로 정리할 수 있다.
직접적인 계보
BCPL → B → C
C의 직접 확장
C → C++
C → Objective-C
문법과 설계의 영향
C → Java
C → C#
C → JavaScript
C → Go
C → Rust
C → Zig
C → Wave
구현과 상호 운용
C ABI
C 라이브러리
C 기반 런타임
FFI와 확장 모듈
C++와 Objective-C는 C를 직접 확장하면서 등장했지만 현재는 각각 독립된 언어와 표준, 객체 모델을 가진다. Java와 C#, JavaScript와 Go는 C의 문법 일부를 이어받았지만 실행 환경과 메모리 관리 방식은 크게 다르다.
Rust와 Zig, Wave와 같은 현대 시스템 언어는 C가 개척한 저수준 프로그래밍 영역을 계승하면서, 포인터와 객체 수명, 오류 처리와 정의되지 않은 동작에서 발생하는 문제를 줄이려 한다.
C는 새 언어가 등장하면서 사라진 공통 기반이 아니라, 오히려 대부분의 새 시스템 언어가 반드시 상호 운용해야 하는 언어가 되었다. 운영체제 API와 하드웨어 SDK, 데이터베이스와 멀티미디어, 암호화와 네트워크 라이브러리의 상당수가 C ABI를 제공하기 때문이다.
따라서 C의 영향은 소스 문법에만 남아 있지 않다. 네이티브 코드가 서로 호출되는 방식과 라이브러리의 공개 인터페이스, 운영체제와 언어 런타임의 경계에도 남아 있다. 이러한 점에서 C는 하나의 프로그래밍 언어인 동시에 현대 소프트웨어 생태계의 공통 이진 인터페이스 역할을 수행한다.
영향
C는 하나의 운영체제를 구현하기 위해 만들어진 언어에서 출발했지만, 이후 프로그래밍 언어의 문법과 시스템 소프트웨어의 구조, 운영체제 인터페이스, 라이브러리와 컴파일러 설계 및 소프트웨어 교육에 광범위한 영향을 미쳤다.
C의 영향은 단순히 많은 프로그램이 C로 작성되었다는 사실에만 있지 않다. C는 운영체제를 특정 컴퓨터의 어셈블리어에서 분리하고, 서로 다른 기계에서 공통 소스 코드를 사용할 수 있다는 가능성을 보여주었다. 또한 함수와 구조체, 포인터를 중심으로 한 작은 언어 핵심과 별도의 라이브러리를 결합하는 방식을 널리 확산시켰다.
C의 영향
├── UNIX와 운영체제의 이식성
├── 시스템 프로그래밍 언어의 기준
├── C 계열 문법의 확산
├── 네이티브 ABI의 공통 기반
├── 라이브러리 중심 소프트웨어 구조
├── 컴파일러와 도구체인의 발전
├── 임베디드 및 하드웨어 소프트웨어
├── 성능과 데이터 배치 중심 사고
├── 컴퓨터 과학 교육
├── 오픈 소스 개발 문화
└── 메모리 안전성과 정의되지 않은 동작 논의
C는 높은 수준의 추상화를 제공하면서도 생성되는 기계 코드와 메모리 배치를 비교적 직접적으로 예측할 수 있는 언어로 받아들여졌다. 이러한 위치는 이후 여러 시스템 언어가 자신을 설명할 때 비교 기준으로 C를 사용하게 만든 원인이 되었다.
UNIX의 이식성
C가 남긴 가장 중요한 초기 영향은 UNIX를 특정 컴퓨터의 어셈블리어에서 분리한 것이다.
초기 UNIX는 PDP-7과 PDP-11의 어셈블리어로 작성되었다. 운영체제를 다른 프로세서로 옮기려면 대부분의 코드를 다시 작성해야 했다. C가 충분히 발전한 뒤 UNIX 커널의 상당 부분이 C로 재작성되면서, 운영체제의 공통 로직과 하드웨어별 코드를 분리할 수 있게 되었다.
어셈블리어 기반 운영체제
→ 특정 프로세서와 강하게 결합
C 기반 운영체제
→ 공통 커널 로직을 C로 작성
→ 일부 시작 코드와 하드웨어 접근만 어셈블리어 사용
→ 새로운 컴퓨터로 이식 가능
데니스 리치는 C가 UNIX의 이식성을 가능하게 한 기반이었다고 설명했다. C와 UNIX가 함께 발전하면서 언어는 실제 운영체제 구현에 필요한 기능을 얻었고, UNIX는 C 컴파일러가 존재하는 새로운 하드웨어로 옮겨질 수 있었다.[119][120]
UNIX의 이식 과정은 운영체제 전체를 고급 언어로 작성할 수 있다는 사실을 산업계와 학계에 보여주었다. 이후 운영체제는 다음과 같은 구조를 채택할 수 있었다.
운영체제
├── 공통 C 코드
│ ├── 파일 시스템
│ ├── 프로세스 관리
│ ├── 네트워크
│ └── 메모리 관리
│
└── 아키텍처별 코드
├── 부팅
├── 인터럽트 진입
├── 문맥 교환
└── 특수 명령
현대 운영체제는 C만으로 구성되지 않으며 C++, Rust와 어셈블리어 등 여러 언어를 함께 사용할 수 있다. 그러나 공통 커널 코드와 하드웨어별 계층을 분리하는 방식은 C와 UNIX가 널리 확산시킨 중요한 구조다.
운영체제 개발 방식
C는 운영체제 커널을 개발할 때 사용하는 기본 표현 방식에도 영향을 미쳤다.
운영체제의 주요 개념은 C 구조체와 포인터로 표현되는 경우가 많다.
struct process {
unsigned long identifier;
unsigned int state;
void *address_space;
struct process *parent;
};
프로세스와 파일, 장치와 네트워크 패킷 같은 운영체제 객체를 구조체로 나타내고, 함수가 해당 구조체의 포인터를 전달받아 상태를 변경하는 방식이 일반화되었다.
void process_set_state(
struct process *process,
unsigned int state
) {
process->state = state;
}
Linux 커널은 현재도 C를 중심으로 개발되며, 공식 문서는 Linux 커널이 일반적으로 GNU C11 방언으로 컴파일된다고 설명한다.[121]
Linux는 표준 C만 사용하는 것이 아니라 GNU 확장과 아키텍처별 어셈블리어, 컴파일러 내장 기능과 Rust 코드를 함께 사용한다. 그럼에도 커널의 핵심 자료 구조와 내부 API, 드라이버 인터페이스는 C의 타입과 함수 모델을 중심으로 형성되어 있다.
시스템 프로그래밍 언어의 기준
C는 이후 등장한 시스템 프로그래밍 언어를 평가하는 기준이 되었다.
새로운 시스템 언어는 흔히 다음 항목을 C와 비교하여 설명한다.
- C와 비슷한 실행 성능을 제공하는가
- C와 같은 수준으로 메모리를 제어할 수 있는가
- 별도의 가비지 컬렉터가 필요한가
- 운영체제나 펌웨어를 구현할 수 있는가
- C ABI와 상호 운용할 수 있는가
- C보다 메모리 안전한가
- C보다 정의되지 않은 동작이 적은가
- 실행 파일과 런타임 크기가 작은가
새로운 시스템 언어
↓ 비교 기준
C의 성능
C의 이식성
C의 메모리 제어
C의 ABI
C의 런타임 의존성
이는 C가 모든 시스템 분야에서 항상 가장 좋은 선택이라는 뜻은 아니다. C가 시스템 소프트웨어가 제공해야 하는 최소 능력을 오랫동안 대표해 왔다는 의미다.
C++, Rust, Zig, D와 Wave (프로그래밍 언어) 등은 서로 다른 방식으로 C가 담당해 온 시스템 프로그래밍 영역을 확장하거나 보완하려 한다.
기계 독립적인 저수준 언어
C는 흔히 고급 어셈블리어라고 표현되지만, C는 특정 프로세서의 명령어를 직접 표현하는 어셈블리어가 아니다.
C의 중요한 영향은 기계 독립적인 저수준 표현을 제공했다는 점에 있다.
struct register_block {
volatile uint32_t control;
volatile uint32_t status;
};
void device_enable(
struct register_block *device
) {
device->control |= 1U;
}
이 코드는 구조체와 포인터, 비트 연산을 사용해 장치 상태를 나타내지만 실제 레지스터와 명령 선택은 대상 컴파일러와 플랫폼이 결정한다.
C는 다음 두 요구 사이의 절충점을 제시했다.
고수준 언어
→ 이식성
→ 함수와 자료 구조
→ 추상적인 표현
저수준 언어
→ 주소와 메모리
→ 비트 연산
→ 하드웨어 제어
→ 예측 가능한 비용
이 특성은 운영체제와 임베디드 시스템, 컴파일러 런타임과 데이터베이스 엔진에서 C가 오랫동안 사용되는 이유가 되었다.
C 계열 문법
C는 프로그래밍 언어의 표면 문법에도 매우 큰 영향을 미쳤다.
다음과 같은 형태는 C 이후 여러 언어에서 반복적으로 사용되었다.
if (condition) {
process();
}
for (int index = 0; index < count; ++index) {
process(values[index]);
}
return result;
C가 확산시킨 대표적인 문법 요소는 다음과 같다.
- 중괄호로 표현하는 블록
- 세미콜론으로 끝나는 문장
- 괄호 안의 조건식
if와elseswitch와casefor,while,dobreak,continue,return- 함수 호출의
() - 배열 첨자의
[] - 구조체 구성원의
. - 포인터 구성원 접근의
-> ++와--&&,||,!==,!=,<=,>=- 복합 대입 연산자
C++와 Objective-C는 C를 직접 확장하며 이러한 문법을 이어받았다. Java와 C#, JavaScript, Go, Rust와 여러 다른 언어도 문법 일부를 채택했다.
C 문법
├── C++
├── Objective-C
├── Java
├── C#
├── JavaScript
├── Go
├── Rust
├── D
├── Zig
└── 여러 스크립트 및 시스템 언어
이로 인해 서로 다른 언어 사이에 공통적인 코드 형태가 만들어졌다. 하나의 C 계열 언어를 배운 개발자는 다른 언어의 기본적인 제어 흐름을 비교적 쉽게 읽을 수 있다.
그러나 문법적 영향이 곧 실행 의미의 동일성을 뜻하지는 않는다. C의 배열 접근은 범위 검사를 보장하지 않지만 Java와 C#의 배열은 런타임 범위 검사를 수행한다. C의 포인터와 Rust의 참조도 겉보기 목적은 비슷하지만 수명과 안전성 규칙이 다르다.
연산자 중심 표현식
C는 작은 수의 연산자를 조합해 복잡한 계산과 상태 변경을 표현하는 방식을 확산시켰다.
flags |= FLAG_VISIBLE;
value <<= 1;
pointer += offset;
result = condition ? first : second;
C의 연산자 체계는 산술 계산뿐 아니라 포인터와 비트, 조건과 대입을 하나의 표현식 문법 안에 배치했다.
이 방식은 간결한 코드를 만들 수 있지만, 부작용이 많은 표현식과 복잡한 우선순위 문제도 만들었다.
values[index++] =
source[offset++] +
calculate();
이러한 문제는 후속 언어가 다음과 같은 선택을 하게 한 배경이 되었다.
- 대입을 표현식에서 제외
- 증가·감소 연산자 제거
- 암시적 변환 축소
- 평가 순서 명확화
- 연산자 오버로딩 제한
- 부작용이 있는 표현식 억제
C의 표현식 문법은 강력함과 간결함의 기준이 되는 동시에, 가독성과 안전성 논쟁의 출발점도 되었다.
포인터와 배열 모델
C의 배열과 포인터 관계는 시스템 프로그래밍과 데이터 처리 방식에 큰 영향을 주었다.
int values[4] = {
10,
20,
30,
40
};
int *pointer = values;
배열 이름은 대부분의 표현식에서 첫 번째 요소를 가리키는 포인터로 변환된다.
values[index]
*(values + index)
이 모델은 다음과 같은 장점을 제공했다.
- 연속 메모리 처리
- 배열 부분 구간 전달
- 동적 배열 구현
- 문자열 처리
- 버퍼와 파일 데이터 접근
- 하드웨어 메모리 접근
- 간단한 함수 인터페이스
반면 배열 길이가 포인터 타입에 포함되지 않는다는 문제도 만들었다.
void process(
int *values,
size_t count
);
호출자는 포인터와 길이를 정확히 함께 전달해야 한다.
이 문제는 이후 언어가 슬라이스와 범위가 포함된 배열 참조를 도입하는 배경이 되었다.
C 포인터
→ 주소만 전달
→ 길이는 별도 값
현대 슬라이스
→ 주소와 길이를 함께 표현
→ 범위 검사 가능
Rust의 슬라이스와 Go의 슬라이스, 여러 라이브러리의 span과 view 구조는 C 배열 인터페이스가 가진 장점과 한계를 보완한 형태로 볼 수 있다.
구조체 기반 데이터 모델
C의 struct는 서로 다른 타입의 값을 하나의 연속된 데이터 객체로 구성하는 방식을 널리 확산시켰다.
struct image {
unsigned char *pixels;
size_t width;
size_t height;
size_t stride;
};
구조체는 다음 분야에서 기본적인 데이터 표현이 되었다.
- 운영체제 커널 객체
- 파일 형식
- 네트워크 패킷
- 장치 레지스터
- 데이터베이스 레코드
- 그래픽스 리소스
- 게임 객체
- 라이브러리 컨텍스트
- ABI 인터페이스
C에는 클래스와 캡슐화가 없지만 구조체와 함수를 조합해 객체와 유사한 API를 설계할 수 있다.
struct image;
struct image *image_create(
size_t width,
size_t height
);
void image_destroy(
struct image *image
);
불투명 구조체와 생성·파괴 함수 패턴은 다른 언어와 공유하는 네이티브 라이브러리의 일반적인 설계가 되었다.
함수 중심 모듈 설계
C는 클래스보다 함수와 데이터 구조를 중심으로 프로그램을 모듈화한다.
header
→ 공개 타입
→ 함수 선언
→ 상수와 매크로
source
→ 내부 구조
→ 함수 정의
→ 정적 객체와 함수
대표적인 C 모듈은 다음과 같이 구성된다.
/* buffer.h */
struct buffer;
struct buffer *buffer_create(
size_t capacity
);
bool buffer_append(
struct buffer *buffer,
const void *data,
size_t size
);
void buffer_destroy(
struct buffer *buffer
);
이 패턴은 다음과 같은 영향을 남겼다.
- 명시적인 공개 API
- 구현 세부 사항 은닉
- 헤더 기반 계약
- 라이브러리 단위 배포
- 안정적인 ABI
- 함수 포인터를 이용한 확장
- 콜백 기반 이벤트 처리
- 사용자 데이터 포인터
현대 언어의 모듈과 인터페이스, 패키지 기능은 C의 헤더보다 높은 수준의 지원을 제공하지만, 네이티브 라이브러리 경계에서는 여전히 C식 함수 인터페이스가 많이 사용된다.
별도 컴파일
C는 여러 소스 파일을 독립적으로 번역한 뒤 링커가 결합하는 별도 컴파일 모델을 널리 정착시켰다.
main.c → main.o
renderer.c → renderer.o
network.c → network.o
↓
linker
↓
application
별도 컴파일은 다음과 같은 장점을 제공했다.
- 변경된 파일만 다시 컴파일
- 여러 개발자가 모듈을 나누어 작업
- 정적·공유 라이브러리 배포
- 컴파일러와 링커의 역할 분리
- 다른 언어로 작성된 객체 파일과 결합
- 플랫폼별 구현 교체
이 모델은 C++와 Objective-C뿐 아니라 Rust, Swift와 여러 네이티브 언어의 빌드 과정에도 이어졌다.
반면 헤더와 구현의 불일치, 반복적인 전처리와 긴 빌드 시간, 복잡한 링크 오류라는 문제도 낳았다. 후속 언어의 모듈 시스템과 패키지 관리자는 이러한 문제를 줄이려 했다.
컴파일러 구조
C는 컴파일러를 새로운 플랫폼으로 이식하는 방식에도 영향을 주었다.
전통적인 C 컴파일러는 다음 구조를 가질 수 있다.
C 프런트엔드
↓
중간 표현
↓
공통 최적화
↓
대상 코드 생성기
새로운 프로세서를 지원할 때 언어 전체를 다시 구현하기보다 대상 코드 생성기와 ABI 지원을 추가하는 방식이 가능해졌다.
Portable C Compiler는 이러한 접근을 대표하는 초기 컴파일러였다. C 컴파일러와 UNIX의 이식 경험은 언어 프런트엔드와 대상 백엔드를 분리하는 컴파일러 설계가 확산되는 데 기여했다.
현대의 GCC와 LLVM은 여러 언어 프런트엔드와 프로세서 백엔드를 공유하는 구조를 사용한다.
C
C++
Objective-C
Rust 계열 프런트엔드
기타 언어
↓
공통 중간 표현과 최적화
↓
x86-64
AArch64
RISC-V
WebAssembly
기타 대상
모든 현대 컴파일러 구조가 C에서 직접 유래했다고 단정할 수는 없지만, C의 다양한 아키텍처 지원과 이식 요구는 재사용 가능한 코드 생성기와 중간 표현 발전의 중요한 실용적 동기가 되었다.
자기 호스팅
초기 C 컴파일러가 C 자체로 작성되면서 자기 호스팅 컴파일러의 대표적인 사례가 만들어졌다.
초기 컴파일러
↓
C 프로그램 번역 가능
↓
컴파일러 자체를 C로 작성
↓
기존 컴파일러로 새 컴파일러 빌드
새로운 컴퓨터에 C를 이식하는 과정은 다음처럼 진행될 수 있었다.
기존 시스템
→ 대상용 C 컴파일러 생성
대상 시스템
→ 컴파일러 실행
대상 시스템에서
→ C 프로그램과 운영체제 빌드
자기 호스팅은 언어가 실제 대규모 소프트웨어와 자체 개발 도구를 구현할 수 있을 만큼 충분히 표현력이 있다는 상징이 되었다.
이후 C++와 Rust, Go, Swift 등 여러 언어도 자신의 컴파일러나 주요 도구를 해당 언어로 작성하는 방향으로 발전했다.
C를 이식성 계층으로 사용
C는 특정 운영체제 API의 차이를 숨기는 이식성 계층으로도 사용된다.
#if defined(_WIN32)
static bool platform_file_open(
struct file *file,
const char *path
) {
/* Win32 구현 */
}
#elif defined(__unix__)
static bool platform_file_open(
struct file *file,
const char *path
) {
/* POSIX 구현 */
}
#endif
상위 코드는 공통 인터페이스만 사용한다.
struct file file;
if (!platform_file_open(
&file,
"document.dat"
)) {
return false;
}
이 방식은 크로스플랫폼 엔진과 라이브러리, 언어 런타임과 데이터베이스에서 널리 사용된다.
C 표준은 다양한 데이터 처리 시스템 사이에서 C 프로그램의 이식성을 높이는 것을 목적으로 한다.[122]
표준 C만으로 모든 플랫폼 기능을 사용할 수는 없지만, 공통 로직은 ISO C로 작성하고 제한된 플랫폼 계층만 운영체제별 API로 구현할 수 있다.
국제 표준화
C는 프로그래밍 언어 표준화에도 큰 영향을 미쳤다.
C가 여러 컴파일러와 산업 환경으로 확산되면서 구현체마다 다른 방언이 생겼고, 이를 통합하기 위해 ANSI와 ISO 표준화가 진행되었다.
K&R C
↓
여러 컴파일러와 방언
↓
ANSI X3J11
↓
ANSI C89
↓
ISO C90
↓
C99·C11·C17·C23
C 표준은 다음 항목을 정교하게 구분한다.
- 문법
- 제약
- 의미 규칙
- 정의된 동작
- 구현 정의 동작
- 미지정 동작
- 정의되지 않은 동작
- 적합한 구현
- 엄격히 적합한 프로그램
- 최소 구현 한계
이러한 용어와 규범적 구조는 다른 언어 표준과 컴파일러 문서에서도 중요한 참고 모델이 되었다.
C의 표준화는 언어를 특정 회사나 구현체에 종속시키지 않고, 여러 공급자가 호환되는 컴파일러와 라이브러리를 만들 수 있는 기반을 제공했다.
표준 라이브러리 모델
C는 작은 언어 핵심과 별도의 표준 라이브러리를 결합하는 구조를 널리 확산시켰다.
C 언어
→ 타입
→ 선언
→ 표현식
→ 문장
→ 함수
→ 전처리기
C 표준 라이브러리
→ 입출력
→ 문자열
→ 메모리
→ 수학
→ 시간
→ 동적 할당
입출력과 문자열, 메모리 할당을 언어 문법에 직접 포함하지 않고 라이브러리 함수로 제공한다.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char *memory = malloc(128);
if (memory == NULL) {
return 1;
}
puts("hello");
free(memory);
return 0;
}
이 구조는 다음과 같은 장점을 가진다.
- 언어 핵심을 작게 유지
- 구현 환경에 따라 라이브러리 교체 가능
- 독립 실행 환경에서 일부 기능 생략 가능
- 외부 라이브러리로 기능 확장 가능
- 다른 언어에서 C 라이브러리 호출 가능
현대 언어는 더 큰 표준 라이브러리와 패키지 생태계를 제공하지만, 언어 핵심과 라이브러리를 구분하는 방식은 계속 사용된다.
네이티브 ABI의 공통 기반
C의 가장 지속적인 영향 가운데 하나는 네이티브 소프트웨어의 공통 ABI 역할이다.
운영체제와 라이브러리는 C 함수 형태로 인터페이스를 제공하는 경우가 많다.
struct database;
struct database *database_open(
const char *path
);
void database_close(
struct database *database
);
이러한 API는 여러 언어에서 호출할 수 있다.
C++
Rust
Python
C#
Java
Go
Swift
Lua
Wave
↓
C ABI
↓
네이티브 라이브러리
C 표준 자체가 하나의 범용 ABI를 규정하는 것은 아니다. 각 운영체제와 프로세서 ABI가 인수 전달과 구조체 배치, 심벌과 호출 규약을 결정한다.
그러나 C 함수와 기본 타입, 구조체와 포인터를 기반으로 ABI를 설명하는 방식이 널리 사용된다.
이 때문에 새로운 프로그래밍 언어도 일반적으로 C 상호 운용 기능을 제공한다.
- C 헤더 가져오기
extern "C"- C 호출 규약
repr(C)- P/Invoke
- JNI
- cgo
- 외부 함수 인터페이스
C는 하나의 소스 언어를 넘어 서로 다른 언어와 런타임을 연결하는 공통 경계가 되었다.
라이브러리 생태계
C는 여러 분야의 기반 라이브러리가 공유되는 생태계를 만들었다.
대표적인 C 라이브러리는 다음 영역에 존재한다.
- 데이터베이스
- 암호화
- 압축
- 이미지
- 오디오
- 비디오
- 네트워크
- 파일 형식
- 그래픽스
- 수치 계산
- 문자와 폰트
- 운영체제 인터페이스
SQLite는 C 언어 라이브러리로 구현된 내장형 데이터베이스이며, 공식 사이트는 SQLite가 휴대전화와 컴퓨터 및 수많은 응용 프로그램에 포함되어 있다고 설명한다.[123]
SQLite 개발진은 C를 사용하는 이유로 다음 특성을 제시한다.
- 성능
- 호환성
- 낮은 수준의 제어
- 안정적인 언어와 도구
- 작은 크기
- 여러 언어에서 사용할 수 있는 인터페이스
SQLite는 2000년부터 일반적인 C로 구현되었고 다른 언어로 재작성할 계획이 없다고 공식적으로 설명한다.[124]
이러한 라이브러리는 상위 언어 프로그램에서도 사용된다.
Python 응용 프로그램
→ SQLite C 라이브러리
모바일 응용 프로그램
→ 이미지·압축 C 라이브러리
웹 서버
→ TLS·압축·정규식 C 라이브러리
게임 엔진
→ 오디오·코덱·그래픽스 C API
사용자는 C를 직접 작성하지 않더라도 C로 구현된 기반 소프트웨어를 사용할 수 있다.
운영체제 API의 언어 중립화
C 함수 중심의 API는 운영체제 기능을 여러 프로그래밍 언어에서 호출할 수 있게 했다.
int file_open(
const char *path,
unsigned int flags
);
복잡한 언어 객체나 런타임 메타데이터를 요구하지 않는 C API는 바인딩을 만들기 상대적으로 쉽다.
운영체제 내부 구현
↓
C ABI 함수
↓
언어별 래퍼
↓
고수준 API
Java와 Python, .NET, Go와 Rust의 파일 및 네트워크 기능도 내부적으로 운영체제의 네이티브 인터페이스와 연결된다.
모든 운영체제 API가 C로 구현되거나 ISO C에 속한다는 뜻은 아니다. 공개되는 이진 인터페이스와 헤더가 C의 타입 체계를 기준으로 표현되는 경우가 많다는 의미다.
임베디드 산업
C는 마이크로컨트롤러와 임베디드 시스템 산업의 공통 언어로 자리 잡았다.
반도체 제조사는 일반적으로 다음 자료를 C 기반으로 제공한다.
- 장치 헤더
- 레지스터 정의
- 주변 장치 드라이버
- 시작 코드
- 실시간 운영체제 포트
- 예제 펌웨어
- 디버거 지원
- 프로토콜 스택
#define TIMER_CONTROL \
(*(volatile uint32_t *) \
UINTPTR_C(0x40001000))
void timer_enable(void) {
TIMER_CONTROL |= 1U;
}
이 구조는 제조사 SDK와 컴파일러, 디버거와 정적 분석 도구가 공통으로 지원할 수 있다.
C의 폭넓은 임베디드 지원은 새로운 프로세서가 등장했을 때 C 컴파일러와 헤더, 런타임을 우선 제공하는 관행을 만들었다.
데이터 배치 중심 설계
C는 프로그램 성능을 알고리즘뿐 아니라 메모리 배치와 접근 패턴으로 분석하는 문화를 강화했다.
struct particle {
float x;
float y;
float z;
float velocity_x;
float velocity_y;
float velocity_z;
};
자료 구조의 구성원 순서와 정렬, 배열 배치가 캐시 효율과 실행 속도에 영향을 줄 수 있다.
알고리즘
+
데이터 구조
+
메모리 배치
+
캐시 접근
+
분기와 벡터화
=
실제 성능
이러한 사고는 게임 엔진과 과학 계산, 데이터베이스와 네트워크 처리에서 중요하다.
C의 구조체 배열과 배열 구조체 선택은 데이터 지향 설계의 대표적인 예가 된다.
구조체 배열은 각 객체의 모든 값을 함께 저장한다.
struct particle particles[1024];
구성원별 배열은 같은 종류의 값을 연속해서 저장한다.
struct particles {
float x[1024];
float y[1024];
float z[1024];
};
어느 방식이 유리한지는 접근 패턴과 연산에 따라 달라진다.
C는 메모리 표현을 직접 노출하기 때문에 이러한 최적화를 쉽게 시도할 수 있지만, 정렬과 별칭, 객체 수명 규칙도 정확히 지켜야 한다.
제로 비용 추상화 논의
C는 높은 수준의 구조를 사용하더라도 필요하지 않은 런타임 비용을 피해야 한다는 시스템 프로그래밍 문화에 영향을 주었다.
C 함수와 구조체는 컴파일 후 비교적 단순한 기계 코드로 변환될 수 있다.
static inline int maximum(
int left,
int right
) {
return left > right
? left
: right;
}
컴파일러는 호출을 인라인화하고 조건식만 남길 수 있다.
이러한 기대는 C++의 제로 오버헤드 원칙과 Rust의 제로 비용 추상화, 다른 시스템 언어의 컴파일 시간 추상화 설계로 이어졌다.
고수준 표현
→ 사용하지 않은 기능 비용 없음
→ 추상화 제거 가능
→ 직접 작성한 저수준 코드와 유사한 결과
C 자체에는 제네릭과 클래스 같은 높은 수준의 추상화가 제한적이지만, C의 실행 비용 모델이 후속 시스템 언어가 추상화 비용을 평가하는 기준이 되었다.
소프트웨어 성능 문화
C는 성능 분석과 최적화를 소스 수준과 기계 수준에서 연결하는 문화를 만들었다.
C 개발자는 다음 항목을 함께 살펴볼 수 있다.
- C 소스
- 컴파일러 중간 표현
- 생성된 어셈블리어
- 함수 호출 규약
- 캐시 미스
- 분기 예측
- 메모리 할당
- 시스템 호출
- 프로파일링 결과
C 소스
↓
최적화
↓
기계 명령
↓
프로세서 실행
↓
프로파일링
↓
소스와 자료 구조 개선
Compiler Explorer와 프로파일러, 디버거 및 성능 카운터를 이용해 소스 변경이 생성 코드에 미치는 영향을 확인하는 방식은 C와 C++ 개발에서 널리 사용된다.
이 문화는 Rust와 Zig, Swift의 네이티브 성능 분석에도 이어졌다.
메모리 안전성 논의
C의 영향에는 긍정적인 면만 있는 것은 아니다. C의 수동 메모리 관리와 포인터 모델은 여러 종류의 오류와 보안 취약점의 원인이 되었다.
대표적인 문제는 다음과 같다.
- 버퍼 오버플로
- 해제 후 사용
- 이중 해제
- 널 포인터 역참조
- 초기화되지 않은 값
- 정수 오버플로
- 배열 범위 초과
- 잘못된 타입 재해석
- 데이터 경쟁
- 수명이 끝난 객체 접근
void copy_text(
char *destination,
const char *source
) {
while (
(*destination++ =
*source++) != '\0'
) {
}
}
이 함수는 대상 버퍼의 크기를 알지 못한다. 원본 문자열이 버퍼보다 크면 범위를 벗어난 쓰기가 발생한다.
이러한 문제는 소프트웨어 산업에서 다음과 같은 대응을 발전시켰다.
- 정적 분석기
- 런타임 검사기
- 컴파일러 경고
- 스택 보호
- 주소 공간 배치 난수화
- 비실행 메모리
- 퍼징
- 안전한 코딩 규칙
- 제한된 C 부분집합
- 메모리 안전 언어
- 샌드박스
- 하드웨어 메모리 태깅
C의 안전성 문제는 단순히 C만의 역사적 결함으로 남지 않았다. 새로운 프로그래밍 언어가 메모리와 오류를 어떻게 다뤄야 하는지에 대한 연구와 설계의 주요 동기가 되었다.
정의되지 않은 동작
C의 정의되지 않은 동작은 컴파일러 최적화와 프로그램 안전성에 큰 영향을 미쳤다.
다음 코드는 부호 있는 정수 범위를 넘을 수 있다.
int increment(int value) {
return value + 1;
}
value가 INT_MAX라면 부호 있는 정수 오버플로가 발생하며 동작이 정의되지 않는다.
컴파일러는 올바른 C 프로그램에서 이러한 상황이 발생하지 않는다고 가정할 수 있다.
bool greater_after_increment(
int value
) {
return value + 1 > value;
}
컴파일러는 정의된 범위의 실행에서는 결과가 항상 참이라고 최적화할 수 있다.
정의되지 않은 동작은 다음 목적을 가진다.
- 모든 하드웨어에 동일한 검사 비용을 강제하지 않음
- 특수한 프로세서 표현 허용
- 컴파일러 최적화 가능성 확대
- 잘못된 프로그램의 결과를 표준이 규정하지 않음
그러나 프로그램의 오류가 단순히 예측할 수 없는 값으로 제한되지 않고 전체 최적화 결과를 바꿀 수 있다는 어려움을 만든다.
이 문제는 후속 언어에서 다음 기능이 등장하는 데 영향을 주었다.
- 검사되는 정수 연산
- 명시적인 wrapping 연산
- 배열 범위 검사
- 널 가능성 타입
- 안전 영역과 unsafe 영역 분리
- 수명 검사
- 소유권
- 초기화 추적
- 데이터 경쟁 방지
안전한 시스템 언어
C의 성능과 제어 능력을 유지하면서 메모리 안전 문제를 줄이려는 언어들이 등장했다.
C의 장점
├── 네이티브 성능
├── 작은 런타임
├── 직접 메모리 제어
├── 운영체제 접근
└── 안정적인 ABI
보완 대상
├── 포인터 안전성
├── 객체 수명
├── 널 참조
├── 배열 범위
├── 데이터 경쟁
├── 오류 처리
└── 정의되지 않은 동작
Rust는 소유권과 대여 검사를 통해 안전 코드에서 여러 수명 및 데이터 경쟁 문제를 방지하려 한다. Zig는 포인터 종류와 선택 타입, 검사 가능한 산술과 명시적인 할당자를 제공한다. Wave는 C가 가진 포인터와 정의되지 않은 동작 문제를 줄이기 위해 명시적인 포인터와 가변성, 컴파일러 검사를 강화하는 방향으로 개발되었다.
이러한 언어들이 C의 존재와 무관하게 설계된 것은 아니다. 대부분 C의 장점을 유지하면서 C에서 반복된 문제를 해결하는 것을 주요 목표로 삼는다.
C 코딩 표준
C의 자유로운 표현과 구현 정의 영역은 조직별 코딩 규칙과 제한된 부분집합을 발전시켰다.
대표적인 규칙은 다음을 제한할 수 있다.
- 동적 메모리 할당
- 재귀
- 함수 포인터
goto- 암시적 변환
- 복잡한 매크로
- 포인터 산술
union기반 타입 재해석- 전역 상태
- 부작용이 많은 표현식
if (
count >
SIZE_MAX / sizeof(*values)
) {
return false;
}
안전 규칙은 단순히 특정 문법을 금지하는 데 그치지 않고, 정수 계산과 배열 길이, 객체 수명을 명시적으로 검증하도록 요구할 수 있다.
자동차와 항공, 의료와 산업 제어 분야에서는 C 자체와 별도로 MISRA C, CERT C와 조직별 안전 규칙을 적용한다.
C는 언어 표준만으로 안전한 개발 절차를 완성하기 어렵다는 사실을 보여주었고, 언어·도구·규칙·검증을 결합하는 개발 방법론에 영향을 주었다.
컴파일러 경고와 정적 분석
C 컴파일러는 문법과 타입 검사뿐 아니라 잠재적인 오류를 찾는 분석 도구로 발전했다.
대표적인 진단은 다음과 같다.
- 초기화되지 않은 변수
- 잘못된 형식 문자열
- 부호 변환
- 암시적 축소 변환
- 사용되지 않는 값
- 배열 범위 초과 가능성
- 널 포인터
- 수명 오류
- 잘못된 함수 원형
- 도달할 수 없는 코드
-Wall
-Wextra
-Wconversion
-Wshadow
-Wformat=2
Clang Static Analyzer와 GCC의 분석 기능, 상용 정적 분석 도구는 제어 흐름과 객체 수명을 추적한다.
C의 낮은 수준 접근과 정의되지 않은 동작은 분석을 어렵게 만들었지만, 동시에 정적 분석 연구와 도구 시장의 주요 동기가 되었다.
동적 분석
C 프로그램의 메모리 오류를 실행 중에 검출하기 위한 도구도 발전했다.
대표적인 도구와 기능은 다음과 같다.
AddressSanitizer
→ 범위 초과
→ 해제 후 사용
UndefinedBehaviorSanitizer
→ 여러 정의되지 않은 동작
MemorySanitizer
→ 초기화되지 않은 메모리 사용
ThreadSanitizer
→ 데이터 경쟁
Valgrind 계열
→ 메모리 오류와 누수
퍼저
→ 비정상 입력 탐색
-fsanitize=address,undefined
이러한 도구는 C의 메모리 모델과 실제 실행을 연결해 오류를 발견한다. 모든 오류가 없음을 증명하지는 않지만 C와 C++ 개발 과정의 중요한 검증 계층이 되었다.
보안 취약점 분류
C의 메모리 오류는 현대 보안 취약점 분류와 방어 체계에도 영향을 미쳤다.
다음과 같은 취약점 유형이 반복적으로 연구되었다.
- 스택 버퍼 오버플로
- 힙 버퍼 오버플로
- use-after-free
- double free
- 정수 언더플로와 오버플로
- 형식 문자열 취약점
- 널 포인터 접근
- 경쟁 상태
- 잘못된 크기 계산
- 타입 혼동
이러한 문제를 완화하기 위해 운영체제와 컴파일러는 다음 방어를 제공한다.
스택 카나리
주소 공간 배치 난수화
비실행 데이터
위치 독립 실행 파일
제어 흐름 보호
읽기 전용 재배치
메모리 태깅
강화된 할당자
C의 영향은 보안 문제의 원인이라는 부정적인 측면뿐 아니라, 메모리 안전과 시스템 보안에 관한 연구를 발전시킨 계기라는 측면도 가진다.
오픈 소스 소프트웨어
C는 오픈 소스 소프트웨어 생태계의 초기 기반 언어 가운데 하나였다.
대표적인 C 중심 프로젝트에는 다음과 같은 종류가 있다.
- 운영체제 커널
- 시스템 라이브러리
- 셸과 명령행 도구
- 컴파일러와 링커
- 버전 관리 시스템
- 데이터베이스
- 웹 서버
- 네트워크 라이브러리
- 멀티미디어 도구
- 임베디드 운영체제
C는 비교적 작은 표준 언어와 다양한 무료 컴파일러를 통해 여러 운영체제에서 같은 소스 코드를 공유할 수 있었다.
공개 C 소스
↓
다른 운영체제에서 컴파일
↓
패치와 이식
↓
다시 공개
이 방식은 인터넷을 통한 협업과 소스 코드 배포, 포팅과 패치 문화에 적합했다.
Linux 커널과 GNU 도구, BSD 계열 시스템과 여러 데이터베이스 및 네트워크 프로젝트는 C를 중심으로 대규모 분산 개발이 가능하다는 사실을 보여주었다.
API 안정성과 장기 유지보수
C API는 비교적 단순한 함수와 구조체, 정수와 포인터를 사용하기 때문에 장기간 유지할 수 있다.
struct library_context;
int library_create(
struct library_context **result
);
void library_destroy(
struct library_context *context
);
내부 구현은 변경하더라도 불투명 포인터와 함수 원형을 유지하면 외부 사용자의 소스와 바이너리 호환성을 보존할 수 있다.
이 특성은 다음 분야에서 중요하다.
- 운영체제 시스템 라이브러리
- 플러그인 API
- 그래픽스 드라이버
- 데이터베이스 확장
- 언어 런타임
- 장치 SDK
- 상용 미들웨어
C API는 높은 수준의 타입 정보를 제공하지 못하지만, 단순함과 안정성 때문에 장기적인 호환성 경계로 사용된다.
플러그인 구조
C 함수 포인터와 불투명 핸들은 플러그인 시스템의 기본 구조에도 영향을 주었다.
struct plugin_api {
uint32_t version;
bool (*initialize)(void);
void (*update)(double delta_time);
void (*shutdown)(void);
};
호스트 프로그램은 공유 라이브러리에서 API 함수를 찾고 함수 테이블을 전달받을 수 있다.
호스트 프로그램
↓ 동적 로딩
플러그인 라이브러리
↓
C 함수 테이블
이 구조는 게임 엔진과 그래픽스 도구, 오디오 플러그인, 데이터베이스 확장과 언어 런타임에서 사용된다.
C의 단순한 ABI는 플러그인을 서로 다른 언어로 구현할 수 있게 하지만, 버전과 구조체 크기, 수명과 스레드 규칙을 별도로 설계해야 한다.
함수 포인터와 콜백 문화
C의 함수 포인터는 이벤트와 전략, 비동기 작업과 확장 기능을 표현하는 대표적인 방식이 되었다.
typedef void (*event_callback)(
int event,
void *context
);
void event_register(
event_callback callback,
void *context
);
콜백과 사용자 데이터 포인터의 조합은 다음 분야에서 반복적으로 사용된다.
- GUI 이벤트
- 네트워크 완료 처리
- 정렬 비교 함수
- 스레드 시작 함수
- 플러그인 API
- 파일 시스템 순회
- 파서 이벤트
- 게임 엔진
- 운영체제 비동기 API
현대 언어의 람다와 클로저, 대리자와 함수 객체는 C의 함수 포인터보다 더 많은 상태와 타입 안전성을 제공한다. 그러나 C ABI 경계에서는 여전히 함수 포인터와 void * 컨텍스트가 공통 표현으로 사용된다.
오류 코드 문화
C에는 일반적인 예외 처리 문법이 없으므로 함수 반환값과 오류 코드, 출력 매개변수를 이용한 오류 전달 방식이 널리 발달했다.
enum result {
RESULT_SUCCESS,
RESULT_INVALID_ARGUMENT,
RESULT_OUT_OF_MEMORY,
RESULT_IO_ERROR
};
enum result document_open(
const char *path,
struct document **result
);
이 방식은 운영체제와 네이티브 라이브러리 API에 큰 영향을 주었다.
함수 호출
↓
성공 또는 오류 코드
↓
호출자가 명시적으로 처리
장점은 다음과 같다.
- ABI로 표현하기 쉬움
- 예외 런타임 불필요
- 오류 경로가 명시적
- 언어 경계를 넘기 쉬움
단점도 있다.
- 반환값 검사를 빠뜨릴 수 있음
- 오류 코드와 실제 결과를 함께 전달하기 복잡함
- 정리 코드가 길어짐
- 오류 전파가 반복적임
- 원인 정보를 잃기 쉬움
Rust의 Result, Go의 다중 반환과 error, 여러 언어의 예외 및 결과 타입은 이러한 C식 오류 처리 경험을 바탕으로 서로 다른 해결책을 제공한다.
자원 관리 패턴
C의 수동 자원 관리는 명시적인 생성과 파괴 패턴을 확산시켰다.
struct resource *resource_create(void);
void resource_destroy(
struct resource *resource
);
자원이 여러 개일 때는 역순으로 정리한다.
int process(void) {
int result = -1;
struct first *first = NULL;
struct second *second = NULL;
first = first_create();
if (first == NULL) {
goto cleanup;
}
second = second_create();
if (second == NULL) {
goto cleanup;
}
result = 0;
cleanup:
second_destroy(second);
first_destroy(first);
return result;
}
이 패턴은 C++의 RAII와 Rust의 소유권 및 Drop, 다른 언어의 defer, using과 context manager 같은 자동 자원 관리 기능이 왜 필요한지를 보여주는 배경이 되었다.
C
→ 명시적인 생성과 해제
C++
→ 생성자와 소멸자
Rust
→ 소유권과 Drop
C#
→ using
Python
→ with
Go
→ defer
후속 언어는 C의 명시적인 자원 수명 개념을 유지하면서 정리 누락을 언어와 타입 시스템으로 줄이려 했다.
전처리기의 영향
C 전처리기는 파일 포함과 매크로, 조건부 컴파일을 하나의 공통 도구로 만들었다.
#if defined(_WIN32)
#include "platform_windows.h"
#elif defined(__linux__)
#include "platform_linux.h"
#endif
전처리기는 다음 분야에서 큰 영향을 미쳤다.
- 플랫폼별 코드 선택
- 헤더 포함
- 디버그 기능
- 기능 플래그
- 반복 코드 생성
- 컴파일 시간 설정
- API 내보내기 제어
동시에 전처리기는 타입과 범위를 이해하지 못하는 토큰 치환 도구이기 때문에 많은 문제를 만들었다.
#define MAXIMUM(left, right) \
((left) > (right) \
? (left) \
: (right))
인수가 여러 번 평가될 수 있다.
int result = MAXIMUM(
index++,
limit
);
이러한 한계는 후속 언어의 다음 기능에 영향을 주었다.
- 타입 안전한 제네릭
- 템플릿
- 위생적 매크로
- 컴파일 시간 함수
- 모듈
- 속성
- 조건부 패키지 기능
C 전처리기는 매우 성공적인 도구였지만, 언어 외부의 텍스트 치환이 가진 위험성도 명확하게 보여주었다.
빌드 시스템
C의 별도 컴파일과 헤더 의존성은 현대 빌드 시스템 발전의 주요 동기가 되었다.
소스 파일
헤더 파일
컴파일 옵션
라이브러리
생성 코드
대상 플랫폼
↓
의존 관계 계산
↓
증분 빌드
Make는 파일의 수정 시간과 의존 관계를 바탕으로 필요한 명령만 실행하는 방식을 널리 보급했다.
이후 CMake와 Ninja, Meson과 Bazel 등은 다음 문제를 해결하려 했다.
- 여러 플랫폼의 컴파일러 옵션
- 헤더 의존성
- 정적·공유 라이브러리
- 생성 코드
- 설치와 패키징
- 교차 컴파일
- 테스트
- 증분 빌드
C 프로젝트의 복잡한 빌드 요구는 독립적인 빌드 도구와 패키지 관리 체계가 발전하는 데 큰 영향을 미쳤다.
디버거와 바이너리 도구
C는 소스 코드와 기계 코드 사이의 관계를 직접 분석해야 하는 경우가 많았기 때문에 디버거와 바이너리 분석 도구 발전에도 영향을 주었다.
대표적인 기능은 다음과 같다.
- 중단점
- 단계별 실행
- 호출 스택
- 레지스터 확인
- 메모리 덤프
- 구조체와 포인터 확인
- 심벌 테이블
- 코어 덤프
- 어셈블리어 혼합 보기
C 소스
↓
디버그 정보
↓
기계 명령과 주소
↓
디버거
C 프로그램은 객체 수명과 포인터, 메모리 손상을 직접 분석해야 하므로 메모리 검사와 코어 덤프 분석이 개발 문화의 일부가 되었다.
GDB와 LLDB, objdump와 readelf, nm 및 각 플랫폼의 디버거는 C와 C++ 개발에서 핵심적인 도구로 자리 잡았다.
교육
C는 컴퓨터 과학과 소프트웨어 공학 교육에서 중요한 역할을 해 왔다.
다음 개념을 설명하는 데 자주 사용된다.
- 변수와 자료형
- 함수
- 배열과 문자열
- 포인터
- 구조체
- 동적 메모리
- 자료구조
- 파일 입출력
- 컴파일과 링크
- 프로세스와 스레드
- 운영체제
- 네트워크
- 컴퓨터 구조
C를 통해 소스 코드가 메모리와 기계 명령으로 변환되는 과정을 비교적 직접적으로 볼 수 있다.
C 변수
→ 객체와 저장 공간
C 포인터
→ 주소와 간접 참조
C 함수
→ 호출 규약과 스택 프레임
C 배열
→ 연속된 메모리
C 컴파일
→ 객체 파일과 링커
C는 추상 자료구조를 직접 구현하게 하므로 연결 리스트와 트리, 해시 테이블과 동적 배열의 내부 구조를 학습하는 데 사용된다.
반면 초보자가 포인터와 정의되지 않은 동작에 집중하느라 알고리즘과 문제 해결 학습이 늦어질 수 있다는 비판도 있다. 이에 따라 일부 교육 과정은 Python이나 Java로 시작하고 운영체제와 시스템 수업에서 C를 가르친다.
프로그래밍 사고방식
C는 프로그래머가 프로그램을 다음 요소로 분해해 생각하도록 만들었다.
데이터
+
데이터를 처리하는 함수
+
메모리 수명
+
오류 코드
+
외부 시스템 인터페이스
C에서는 언어 런타임이 자동으로 처리하는 항목이 적기 때문에 다음 질문을 직접 고려해야 한다.
- 객체는 어디에 저장되는가
- 누가 메모리를 소유하는가
- 언제 해제되는가
- 배열 길이는 얼마인가
- 함수 호출 비용은 얼마인가
- 데이터가 복사되는가
- 포인터가 유효한가
- 오류가 어떻게 전달되는가
- 스레드가 동시에 접근하는가
- 이 코드는 다른 플랫폼에서 동작하는가
이러한 사고방식은 C가 아닌 언어에서도 성능과 시스템 인터페이스를 설계할 때 유용하다.
최소주의 언어 설계
C는 비교적 작은 언어 핵심으로 광범위한 프로그램을 구현할 수 있다는 사례를 보여주었다.
C에는 기본적으로 다음과 같은 고수준 기능이 없다.
- 클래스
- 가비지 컬렉션
- 예외
- 제네릭 컬렉션
- 패키지 관리자
- 리플렉션
- 비동기 함수
- 문자열 객체
- 자동 배열 범위 검사
그럼에도 함수와 구조체, 포인터와 라이브러리를 조합해 운영체제와 컴파일러, 데이터베이스와 네트워크 서버를 구현할 수 있다.
이러한 성공은 언어 설계에서 다음과 같은 최소주의 철학에 영향을 주었다.
작은 언어 핵심
+
강력한 조합 가능성
+
라이브러리
+
도구
=
넓은 활용 범위
Go와 Lua, Zig 등은 C와 동일한 언어는 아니지만 문법과 핵심 기능을 지나치게 크게 만들지 않으려는 설계 철학에서 C와 비교되곤 한다.
장기적인 소스 호환성
C는 오래된 소스 코드가 새로운 컴파일러에서도 비교적 쉽게 유지될 수 있는 생태계를 만들었다.
과거 표준과 구현 확장을 지원하는 컴파일러가 많기 때문에 수십 년 전에 작성된 프로그램도 일부 수정으로 빌드할 수 있다.
1970년대 C
↓ 수정
1980년대 ANSI C
↓
현대 컴파일러
그러나 완전한 호환성이 자동으로 보장되는 것은 아니다.
다음 요소는 시간이 지나며 바뀌었다.
- 암묵적
int - 암묵적 함수 선언
- 구식 함수 정의
- 라이브러리 함수 제거
- 정수와 포인터 크기
- 운영체제 API
- 문자 인코딩
- ABI
- 컴파일러 최적화
C의 표준화는 기존 코드 보존을 매우 중요하게 다뤘지만, 이러한 보수성은 오래된 문법과 안전하지 않은 관행이 장기간 남는 원인이 되기도 했다.
소프트웨어 보존
C의 장기적인 소스 가용성과 단순한 빌드 모델은 소프트웨어 보존에도 영향을 주었다.
오래된 C 프로그램은 다음 자료만으로 복원할 가능성이 있다.
- 소스 코드
- 헤더
- 빌드 명령
- 대상 플랫폼 설명
- 외부 라이브러리
동적 런타임과 중앙 패키지 저장소, 네트워크 서비스에 강하게 의존하는 프로그램보다 독립적으로 보존하기 쉬운 경우가 있다.
그러나 실제 복원에는 오래된 컴파일러 동작과 정수 크기, 엔디언과 파일 형식 및 운영체제 API를 이해해야 한다.
C는 역사적인 운영체제와 게임, 컴파일러 및 연구용 소프트웨어를 현대 환경으로 이식하는 데 계속 사용된다.
하드웨어 설계에 대한 영향
C는 하드웨어가 소프트웨어 개발자에게 제공해야 하는 기능을 판단하는 간접적인 기준이 되었다.
프로세서와 ABI는 C의 다음 연산을 효율적으로 지원하도록 설계되는 경우가 많다.
- 정수 산술
- 포인터 주소 계산
- 배열 인덱싱
- 함수 호출
- 구조체 접근
- 비트 연산
- 조건 분기
- 스택 프레임
- 원자적 연산
value = array[index];
이 표현은 주소 계산과 메모리 읽기로 변환된다.
base address
+
index × element size
→ load
현대 프로세서가 C만을 위해 설계되는 것은 아니지만, C와 C++로 작성된 대규모 소프트웨어를 효율적으로 실행하는 것은 범용 프로세서와 컴파일러 최적화의 중요한 요구 사항이다.
CPU와 컴파일러의 공동 발전
C 컴파일러는 프로세서의 새로운 기능을 이용하도록 발전해 왔다.
대표적인 기능은 다음과 같다.
- SIMD
- 원자적 명령
- 조건부 이동
- 비트 연산 명령
- 하드웨어 부동소수점
- 메모리 장벽
- 분기 예측 정보
- 벡터 확장
- 하드웨어 가속
for (
size_t index = 0;
index < count;
++index
) {
output[index] =
left[index] +
right[index];
}
컴파일러는 조건이 맞으면 반복문을 벡터 명령으로 변환할 수 있다.
C 코드는 하드웨어 기능을 직접적인 인라인 어셈블리 없이 이용할 수 있으며, 컴파일러가 대상 프로세서에 맞는 명령을 선택한다.
이 관계는 하드웨어 설계자와 컴파일러 개발자 사이의 공동 최적화 문화를 강화했다.
이식성과 최적화의 긴장
C의 영향은 이식성과 저수준 제어 사이의 지속적인 긴장에도 나타난다.
다음 코드는 고정된 크기의 정수 타입을 사용한다.
uint32_t value;
그러나 메모리에 저장되는 바이트 순서는 플랫폼마다 다를 수 있다.
같은 C 타입
→ 다른 바이트 순서
→ 다른 ABI
→ 다른 정렬
다음과 같은 구현 의존 요소가 존재한다.
- 정수 타입 크기
char의 부호- 구조체 패딩
- 바이트 순서
- 함수 호출 규약
- 원자 연산 지원
- 파일의 텍스트 표현
C는 이러한 차이를 완전히 제거하지 않고, 표준이 허용하는 범위와 구현체 문서화를 통해 관리한다.
이는 C가 다양한 하드웨어에서 효율적으로 구현될 수 있게 한 장점인 동시에 완전한 이식성을 어렵게 만든 요인이다.
성능 이식성
C 소스가 여러 플랫폼에서 컴파일된다고 해서 모든 플랫폼에서 같은 성능을 보장하는 것은 아니다.
for (
size_t index = 0;
index < count;
++index
) {
result += values[index];
}
결과 성능은 다음 요소에 따라 달라진다.
- CPU 캐시
- SIMD 폭
- 메모리 대역폭
- 컴파일러 최적화
- 정렬
- 분기 예측
- 데이터 크기
- 스레드 수
- ABI
C는 소스 이식성을 제공할 수 있지만 성능 이식성은 별도의 프로파일링과 최적화가 필요하다.
이 문제는 추상적인 성능 모델과 자동 벡터화, 성능 이식성 라이브러리 및 다중 백엔드 프레임워크 발전에 영향을 주었다.
언어 간 경계의 기준
C의 단순한 함수와 데이터 모델은 언어 간 인터페이스에서 공통 최소 분모가 되었다.
다른 언어의 복잡한 기능을 C 경계에 직접 노출하기는 어렵다.
C++ 템플릿
Rust 제네릭
Java 객체
C# 대리자
Swift 프로토콜
↓ 직접 전달 어려움
C 함수와 구조체로 변환
따라서 언어 간 API는 다음과 같이 단순화된다.
struct library;
enum library_result
library_open(
const char *path,
struct library **result
);
void library_close(
struct library *library
);
이러한 구조는 언어의 고급 기능을 잃는 대신 광범위한 호환성을 제공한다.
C는 프로그래밍 언어 생태계에서 서로 다른 런타임이 만나는 경계 언어라는 역할을 확립했다.
문화적 영향
C는 프로그래머 문화와 표현에도 영향을 남겼다.
대표적인 예는 다음과 같다.
Hello, world!예제- 0부터 시작하는 배열 인덱스
- 중괄호 스타일 논쟁
- K&R 코드 스타일
- 포인터와 메모리에 대한 통과 의례
- 컴파일러 경고를 오류로 취급하는 문화
- 소스와 어셈블리 비교
- 작은 명령행 도구
- 헤더와 소스 파일 분리
- Makefile 기반 빌드
- UNIX 철학과의 결합
#include <stdio.h>
int main(void) {
puts("hello, world");
return 0;
}
hello, world는 브라이언 커니핸의 초기 C 튜토리얼과 《The C Programming Language》를 통해 프로그래밍 언어의 첫 예제로 널리 확산되었다.
이후 수많은 언어 문서와 교육 자료가 같은 문장을 첫 프로그램으로 사용했다.
기술 문서 작성
C와 UNIX의 매뉴얼 문화는 간결한 기술 문서와 함수 단위 참조 형식에도 영향을 주었다.
표준 라이브러리 함수 문서는 일반적으로 다음 항목을 설명한다.
함수 원형
매개변수
반환값
오류
전제 조건
부작용
스레드 안전성
표준 판
void *memcpy(
void *restrict destination,
const void *restrict source,
size_t count
);
함수 원형 자체가 API 계약의 핵심 요약으로 사용된다.
이 방식은 POSIX 매뉴얼과 운영체제 API, 네이티브 라이브러리 문서 및 다른 언어의 API 참조에도 이어졌다.
영향에 대한 비판
C의 광범위한 영향은 비판의 대상이 되기도 한다.
주요 비판은 다음과 같다.
- 안전하지 않은 포인터 모델의 확산
- 널 종료 문자열의 장기적 사용
- 배열 길이가 타입에 포함되지 않음
- 전처리기 매크로 의존
- 정의되지 않은 동작의 범위
- 헤더와 별도 컴파일의 복잡성
- 오류 코드 누락 가능성
- 수동 메모리 관리
- 플랫폼별 ABI 차이
- 안전하지 않은 기본 라이브러리 함수
strcpy(destination, source);
이 함수는 대상 버퍼의 크기를 알지 못한다.
다음처럼 크기를 명시적으로 전달하는 API가 더 안전할 수 있다.
bool text_copy(
char *destination,
size_t capacity,
const char *source
);
C의 초기 설계가 당시의 제한된 하드웨어와 프로그램 규모에서는 합리적이었지만, 인터넷과 대규모 네트워크 소프트웨어, 공격적인 최적화와 멀티스레드 환경에서는 위험이 커졌다는 평가가 존재한다.
지속되는 영향
C가 만들어진 지 수십 년이 지났지만 그 영향은 현재의 소프트웨어 계층 전체에 남아 있다.
응용 프로그램
↓
언어 런타임
↓
데이터베이스·코덱·암호화
↓
C ABI와 시스템 라이브러리
↓
운영체제 커널
↓
장치 드라이버와 펌웨어
↓
하드웨어
상위 애플리케이션이 Python과 Java, C#이나 JavaScript로 작성되어도 내부에서는 C로 구현된 런타임과 라이브러리, 운영체제 기능을 사용할 수 있다.
C를 직접 작성하지 않는 개발자도 다음 요소를 통해 C의 영향을 받는다.
- 0부터 시작하는 배열
- 중괄호 기반 문법
- 운영체제의 C API
- 네이티브 라이브러리
- C ABI
- 널 포인터 개념
- 정수와 비트 연산
- 파일과 스트림 인터페이스
- 컴파일러 및 링커 구조
- 메모리 안전성 정책
역사적 평가
C의 성공은 언어 하나의 기능만으로 이루어진 것이 아니다. C는 UNIX와 함께 발전했고, Portable C Compiler와 K&R, ANSI·ISO 표준화, 여러 컴파일러와 운영체제 및 하드웨어 제조사의 지원을 통해 확산되었다.
C 언어
+
UNIX
+
컴파일러
+
표준 라이브러리
+
K&R
+
국제 표준
+
하드웨어 이식
=
광범위한 영향
데니스 리치는 C가 결함과 특이점을 가졌지만 어셈블리어를 대체할 만큼 효율적이고, 여러 환경의 알고리즘과 시스템 상호 작용을 표현할 만큼 충분히 추상적이었다고 평가했다.[125]
C의 가장 중요한 성취는 특정 하드웨어를 위한 저수준 프로그램과 이식 가능한 고급 언어 사이에 실용적인 중간 지점을 만든 것이다.
영향의 종합
C의 영향은 크게 다음과 같이 정리할 수 있다.
운영체제
→ UNIX 이식성과 커널 구조
프로그래밍 언어
→ C 계열 문법과 시스템 언어의 기준
컴파일러
→ 자기 호스팅과 다중 대상 코드 생성
소프트웨어 구조
→ 함수·구조체·라이브러리·별도 컴파일
산업
→ 임베디드·데이터베이스·네트워크·멀티미디어
언어 상호 운용
→ C ABI와 FFI
교육
→ 메모리·컴파일·운영체제 학습
성능
→ 데이터 배치와 생성 코드 중심 사고
안전성
→ 정적 분석·검사기·메모리 안전 언어 발전
C는 운영체제를 특정 기계에서 분리하고 여러 하드웨어에 이식할 수 있는 기반을 제공했다. 함수와 구조체, 포인터와 라이브러리를 중심으로 한 프로그램 구조는 시스템 소프트웨어의 공통 형태가 되었다.
C의 문법은 수많은 언어에 이어졌고, C ABI는 서로 다른 언어와 운영체제를 연결하는 네이티브 소프트웨어의 공통 경계가 되었다. 데이터베이스와 코덱, 암호화와 네트워크, 언어 런타임과 임베디드 소프트웨어에는 C로 작성된 코드가 계속 사용되고 있다.
동시에 C의 포인터와 배열, 수동 메모리 관리와 정의되지 않은 동작은 현대 소프트웨어 보안의 주요 문제를 드러냈다. 이러한 한계는 안전한 코딩 규칙과 정적·동적 분석 도구, Rust와 Zig, Wave 같은 새로운 시스템 언어가 발전하는 동기가 되었다.
따라서 C의 영향은 단순히 오래 사용된 언어라는 사실에 있지 않다. C는 현대 소프트웨어가 하드웨어와 만나는 방식, 프로그램이 컴파일되고 연결되는 방식, 여러 언어가 라이브러리를 공유하는 방식과 시스템 프로그래밍에서 안전성과 성능을 논의하는 방식 자체를 형성했다.
장점과 한계
C는 비교적 작은 언어 핵심과 직접적인 메모리 접근, 여러 하드웨어에서 구현 가능한 추상 기계와 안정적인 네이티브 인터페이스를 제공한다. 이러한 특성으로 운영체제와 임베디드 시스템, 데이터베이스와 언어 런타임처럼 실행 환경의 기반을 구성하는 분야에서 오랫동안 사용되어 왔다.
동시에 C는 배열 범위와 객체 수명, 동적 메모리와 오류 처리를 프로그래머가 직접 관리하도록 한다. 언어가 제공하는 자유도와 구현체의 최적화 가능성은 높은 성능과 이식성을 제공하지만, 잘못된 프로그램이 메모리 손상이나 정의되지 않은 동작으로 이어질 위험도 만든다.
C의 장점과 한계는 서로 분리된 특성이 아니라 같은 설계 선택에서 함께 발생하는 경우가 많다.
직접적인 메모리 접근
├── 장점: 정확한 데이터 배치와 하드웨어 제어
└── 한계: 범위·수명·별칭 오류 가능
작은 언어와 런타임
├── 장점: 제한된 환경에서도 구현 가능
└── 한계: 고수준 기능을 직접 구성해야 함
구현체의 자유
├── 장점: 다양한 하드웨어에서 효율적 구현
└── 한계: 구현 정의와 플랫폼 차이 발생
정의되지 않은 동작
├── 장점: 검사 비용을 강제하지 않고 최적화 가능
└── 한계: 오류 결과를 예측하거나 제한하기 어려움
C ABI
├── 장점: 여러 언어와 운영체제를 연결
└── 한계: 소유권·길이·널 가능성을 표현하기 어려움
이식성
C는 다양한 데이터 처리 시스템 사이에서 프로그램을 이식할 수 있도록 표준화되었다. ISO/IEC 9899는 C 프로그램의 표현과 문법, 의미 규칙과 구현체의 제한을 규정하며, 서로 다른 시스템 사이에서 C 프로그램의 이식성을 높이는 것을 목적으로 한다.[126]
동일한 C 소스는 여러 컴파일러와 운영체제, 프로세서에서 번역할 수 있다.
공통 C 소스
├── x86-64 Linux
├── AArch64 Linux
├── Windows
├── macOS
├── RISC-V
├── 마이크로컨트롤러
└── 독립 실행 환경
플랫폼별 기능을 분리하면 프로그램의 대부분을 공통 코드로 유지할 수 있다.
bool file_open(
struct file *file,
const char *path
) {
#if defined(_WIN32)
return windows_file_open(
file,
path
);
#elif defined(__unix__)
return posix_file_open(
file,
path
);
#else
return false;
#endif
}
그러나 C의 이식성이 모든 구현에서 완전히 동일한 결과를 보장한다는 뜻은 아니다. 다음 요소는 구현체와 플랫폼에 따라 달라질 수 있다.
- 정수 타입의 크기
- 기본
char의 부호 - 바이트 순서
- 구조체 패딩과 정렬
- 포인터 표현
- 함수 호출 규약
- 소스와 실행 문자 집합
- 파일과 텍스트 스트림의 표현
- 표준 라이브러리의 선택 기능
- 컴파일러 확장
long value;
long이 모든 플랫폼에서 같은 비트 폭을 가진다고 가정할 수 없다.
고정 폭이 필요한 인터페이스에는 <stdint.h>의 타입을 사용할 수 있다.
#include <stdint.h>
uint32_t identifier;
int64_t timestamp;
C는 소스 수준의 이식 가능성을 제공할 수 있지만, 이식 가능한 프로그램을 작성하려면 구현 정의 특성과 외부 API 의존성을 의식적으로 관리해야 한다.
다양한 실행 환경
C는 완전한 운영체제를 갖춘 호스트 환경뿐 아니라 표준 라이브러리와 운영체제가 제한된 독립 실행 환경에서도 구현할 수 있다.
호스트 환경
→ 데스크톱
→ 서버
→ 일반 운영체제 응용 프로그램
독립 실행 환경
→ 펌웨어
→ 부트로더
→ 운영체제 커널
→ 마이크로컨트롤러
호스트 환경에서는 main과 표준 입출력, 파일과 동적 메모리 같은 전체 표준 라이브러리를 사용할 수 있다.
#include <stdio.h>
int main(void) {
puts("hosted program");
return 0;
}
독립 실행 환경에서는 구현체가 정한 시작 함수와 제한된 라이브러리를 사용할 수 있다.
void reset_handler(void) {
initialize_memory();
initialize_hardware();
application_run();
for (;;) {
}
}
이러한 유연성은 C가 작은 마이크로컨트롤러부터 대형 서버까지 사용될 수 있게 한다.
반면 실행 환경의 차이를 프로그램이 직접 처리해야 하며, 운영체제와 표준 라이브러리가 없는 환경에서는 메모리 관리와 입출력, 시작과 종료 코드를 직접 제공해야 할 수 있다.
실행 성능
C는 네이티브 기계 코드로 번역되며, 언어 차원에서 필수적인 가비지 컬렉션이나 객체 런타임을 요구하지 않는다.
void add_arrays(
float *restrict output,
const float *restrict left,
const float *restrict right,
size_t count
) {
for (
size_t index = 0;
index < count;
++index
) {
output[index] =
left[index] +
right[index];
}
}
컴파일러는 이 반복문을 다음과 같이 최적화할 수 있다.
- 함수 인라인화
- 불필요한 메모리 접근 제거
- 반복문 전개
- SIMD 벡터화
- 레지스터 할당
- 명령어 재배치
- 상수 전파
C는 데이터 구조와 메모리 접근을 직접 표현하기 때문에 성능에 중요한 데이터 배치를 조정하기 쉽다.
struct particles {
float x[1024];
float y[1024];
float z[1024];
};
같은 종류의 데이터를 연속 배치하면 계산 방식에 따라 캐시와 SIMD 효율을 높일 수 있다.
그러나 C로 작성되었다는 이유만으로 프로그램이 자동으로 빠른 것은 아니다.
성능은 다음 요소의 영향을 받는다.
- 알고리즘
- 자료 구조
- 캐시 접근
- 메모리 할당
- 입출력
- 분기
- 동기화
- 컴파일러와 최적화 옵션
- 대상 프로세서
- 코드 품질
잘못 설계된 C 프로그램은 높은 수준 언어의 잘 최적화된 구현보다 느릴 수 있다.
예측 가능한 비용
C의 기본 연산은 대체로 비교적 명확한 실행 비용을 가진다.
value = values[index];
일반적인 구현에서는 주소 계산과 메모리 읽기로 변환된다.
배열 시작 주소
+
index × 요소 크기
→ 메모리 읽기
C에는 다음 기능이 자동으로 개입하지 않는다.
- 일반적인 객체 가비지 컬렉션
- 동적 메서드 탐색
- 필수적인 런타임 리플렉션
- 자동 배열 확장
- 암시적인 문자열 객체 생성
- 일반적인 예외 언와인딩
이 때문에 실시간 시스템과 임베디드 프로그램에서 실행 시간과 메모리 사용을 분석하기 쉽다.
하지만 컴파일러 최적화와 프로세서 캐시, 운영체제 스케줄링과 페이지 폴트까지 완전히 예측할 수 있다는 뜻은 아니다. 동적 메모리 할당과 파일 입출력, 시스템 호출은 환경에 따라 실행 시간이 크게 달라질 수 있다.
직접적인 메모리 제어
C는 객체의 메모리 배치와 주소, 정렬과 수명을 직접 다룰 수 있다.
struct packet {
uint32_t type;
uint32_t size;
unsigned char data[];
};
하나의 연속된 할당에 헤더와 데이터를 배치할 수 있다.
struct packet *packet_create(
size_t data_size
) {
if (
data_size >
SIZE_MAX - sizeof(struct packet)
) {
return NULL;
}
struct packet *packet = malloc(
sizeof(*packet) + data_size
);
if (packet == NULL) {
return NULL;
}
packet->size =
(uint32_t)data_size;
return packet;
}
이 능력은 다음 분야에서 중요하다.
- 운영체제
- 네트워크
- 파일 형식
- 데이터베이스
- 그래픽스
- 게임 엔진
- 코덱
- 임베디드 시스템
- 하드웨어 장치
- 수치 계산
반면 저장 공간의 크기와 정렬, 객체 수명과 해제를 모두 프로그래머가 정확히 관리해야 한다.
free(packet);
packet->size = 0;
해제한 객체에 접근하면 정의되지 않은 동작이 발생한다.
하드웨어 접근
C는 포인터와 비트 연산을 통해 장치 레지스터와 메모리 매핑 입출력을 표현할 수 있다.
#define DEVICE_STATUS_ADDRESS \
UINTPTR_C(0x40000000)
static volatile uint32_t *const
device_status =
(volatile uint32_t *)
DEVICE_STATUS_ADDRESS;
bool device_ready(void) {
return
(*device_status &
STATUS_READY) != 0;
}
이러한 코드가 실제로 올바르게 동작하려면 플랫폼이 다음을 규정해야 한다.
- 유효한 장치 주소
- 정렬
- 접근 폭
- 메모리 순서
- 레지스터 부작용
- 캐시 정책
- 필요한 메모리 장벽
ISO C만으로 하드웨어 장치의 의미가 완성되는 것은 아니다. C는 플랫폼 규격을 표현할 수 있는 수단을 제공한다.
작은 런타임
C 프로그램은 기능과 대상 환경에 따라 매우 작은 런타임으로 구성할 수 있다.
시작 코드
+
필요한 C 함수
+
프로그램 코드
=
작은 펌웨어 이미지
표준 라이브러리 전체를 사용하지 않고 필요한 기능만 제공하는 독립 실행 구현도 가능하다.
void memory_copy(
unsigned char *destination,
const unsigned char *source,
size_t count
) {
while (count != 0) {
*destination++ =
*source++;
--count;
}
}
작은 실행 파일과 런타임은 다음 환경에 유리하다.
- 마이크로컨트롤러
- 부트로더
- 펌웨어
- 운영체제 커널
- 복구 환경
- 제한된 메모리의 장치
반면 표준 라이브러리와 운영체제 기능을 직접 제공해야 하므로 개발 부담이 증가할 수 있다.
안정적인 ABI
C 함수와 구조체를 기반으로 한 ABI는 여러 언어와 라이브러리를 연결하는 공통 경계로 사용된다.
struct decoder;
struct decoder *decoder_create(void);
bool decoder_process(
struct decoder *decoder,
const void *input,
size_t input_size
);
void decoder_destroy(
struct decoder *decoder
);
이러한 인터페이스는 C++와 Rust, C#과 Python, Go와 Swift 등에서 호출할 수 있다.
고수준 언어
↓
외부 함수 인터페이스
↓
C ABI
↓
네이티브 라이브러리
C ABI의 장점은 다음과 같다.
- 함수 호출 구조가 단순함
- 여러 컴파일러와 언어가 지원함
- 불투명 포인터로 구현을 숨길 수 있음
- 동적 라이브러리와 플러그인에 적합함
- 장기적인 API 유지가 가능함
한계도 있다.
- 배열 길이가 포인터 타입에 포함되지 않음
- 문자열 인코딩을 자동 표현하지 못함
- 소유권이 타입에 나타나지 않음
- 널 가능성을 명시하기 어려움
- 예외와 고수준 객체를 직접 전달하기 어려움
- 구조체 배치와 호출 규약이 플랫폼에 의존함
C ABI는 단순하고 넓게 지원되지만, API 계약을 문서와 함수 설계로 보완해야 한다.
성숙한 생태계
C는 오랜 기간 사용되며 여러 플랫폼과 산업 분야의 도구와 라이브러리가 축적되었다.
주요 구성 요소는 다음과 같다.
- GCC
- Clang
- MSVC
- 여러 임베디드 컴파일러
- GDB와 LLDB
- 정적 분석기
- Sanitizer
- Valgrind 계열 도구
- C 표준 라이브러리 구현
- 운영체제 SDK
- 하드웨어 제조사 SDK
- 수치·암호화·네트워크 라이브러리
Linux 커널은 현재도 C를 중심으로 작성되며, 공식 문서는 일반적으로 GNU C11 방언과 GCC를 사용하고 Clang도 지원한다고 설명한다.[127]
성숙한 생태계는 오래된 플랫폼과 새 아키텍처를 모두 지원하는 데 유리하다.
반면 생태계가 오래된 만큼 빌드 시스템과 패키지 관리, 컴파일러 확장과 플랫폼별 관행이 통일되어 있지 않다.
기존 코드와 호환성
C에는 수십 년 동안 개발된 운영체제와 라이브러리, 펌웨어와 산업 코드가 존재한다.
새로운 프로그램은 기존 C 라이브러리를 재사용할 수 있다.
#include <sqlite3.h>
sqlite3 *database;
if (
sqlite3_open(
"application.db",
&database
) != SQLITE_OK
) {
return false;
}
SQLite는 공식적으로 2000년부터 일반적인 C로 구현되어 왔으며, 이식성과 성능, 안정성과 폭넓은 바인딩을 C 사용의 이유로 설명한다.[128]
기존 C 코드와의 호환성은 새 언어와 플랫폼도 C 상호 운용 기능을 제공하게 만든다.
그러나 오래된 코드에는 다음 문제가 남아 있을 수 있다.
- 구식 함수 선언
- 안전하지 않은 문자열 처리
- 32비트 환경 가정
- 오래된 컴파일러 확장
- 정의되지 않은 동작 의존
- 현대적인 스레드 모델과의 충돌
- 유지되지 않는 외부 라이브러리
기존 코드가 많다는 사실은 강점이면서 기술 부채가 될 수도 있다.
단순한 언어 핵심
C의 핵심 문법은 비교적 작다.
주요 요소는 다음과 같다.
- 기본 타입
- 포인터
- 배열
- 구조체와
union - 함수
- 표현식
- 제어문
- 전처리기
struct point {
int x;
int y;
};
int point_sum(
const struct point *point
) {
return point->x + point->y;
}
작은 언어 핵심은 컴파일러와 분석 도구를 구현하고 새로운 플랫폼으로 이식하는 데 유리하다.
그러나 작은 언어가 곧 배우기 쉽거나 안전하다는 뜻은 아니다. C의 문법은 비교적 작지만 다음 규칙은 복잡하다.
- 선언자
- 정수 승격과 변환
- 객체 수명
- 유효 타입
- 별칭
- 포인터 산술
- 평가 순서
- 원자적 메모리 순서
- 정의되지 않은 동작
- 전처리기 확장
표면 문법보다 프로그램의 정확한 의미를 이해하는 것이 더 어렵다.
조합 가능한 기본 요소
C는 작은 기본 요소를 조합해 복잡한 자료 구조와 시스템을 만들 수 있다.
struct node {
struct node *next;
void *value;
};
struct list {
struct node *first;
struct node *last;
size_t count;
};
클래스나 제네릭 컨테이너가 없어도 구조체와 함수 포인터, void *를 이용해 범용 라이브러리를 구현할 수 있다.
typedef int (*comparison_function)(
const void *left,
const void *right
);
그러나 타입 안전성과 자동 자원 관리가 약해진다.
void *value;
value가 실제로 어떤 타입의 객체를 가리키는지는 API 계약과 프로그램 논리에 의존한다.
명시적인 자원 관리
C에서는 메모리와 파일, 잠금과 장치 같은 자원을 명시적으로 생성하고 해제한다.
FILE *file = fopen(
path,
"rb"
);
if (file == NULL) {
return false;
}
/* 파일 사용 */
fclose(file);
명시적인 자원 관리는 실행 흐름과 비용을 확인하기 쉽다는 장점이 있다.
반면 모든 오류 경로에서 자원을 해제해야 한다.
int load_document(
const char *path
) {
int result = -1;
FILE *file = NULL;
void *buffer = NULL;
file = fopen(path, "rb");
if (file == NULL) {
goto cleanup;
}
buffer = malloc(4096);
if (buffer == NULL) {
goto cleanup;
}
result = 0;
cleanup:
free(buffer);
if (file != NULL) {
fclose(file);
}
return result;
}
C에는 범위를 벗어날 때 임의의 자원을 자동으로 해제하는 일반적인 언어 기능이 없다.
이 때문에 다음 오류가 발생할 수 있다.
- 메모리 누수
- 파일 핸들 누수
- 잠금 해제 누락
- 이중 해제
- 해제 후 사용
- 일부 오류 경로의 정리 누락
수동 메모리 관리
C의 가장 큰 한계 가운데 하나는 동적 메모리의 소유권과 수명을 언어가 자동으로 추적하지 않는다는 점이다.
char *text = malloc(128);
if (text == NULL) {
return false;
}
프로그램은 다음을 직접 결정해야 한다.
- 누가 메모리를 소유하는가
- 누가 해제하는가
- 언제까지 포인터가 유효한가
- 다른 별칭 포인터가 존재하는가
- 재할당 뒤 기존 포인터가 유효한가
- 여러 스레드가 공유하는가
char *alias = text;
free(text);
puts(alias);
alias는 해제된 저장 공간을 가리키므로 사용하면 정의되지 않은 동작이 발생한다.
메모리 안전 언어는 가비지 컬렉션이나 소유권과 수명 검사, 참조 계산과 자동 자원 관리로 이러한 문제를 줄이려 한다.
CISA는 C와 C++처럼 메모리 안전을 기본적으로 보장하지 않는 언어에서 작성된 소프트웨어가 메모리 안전 취약점에 노출될 수 있다고 지적하고, 가능한 경우 메모리 안전 언어의 사용을 권고한다.[129]
배열 범위 검사 부재
일반적인 C 배열 접근은 런타임 범위 검사를 수행하지 않는다.
int values[4];
values[4] = 10;
유효한 첨자는 0부터 3까지다. 범위를 벗어난 접근은 정의되지 않은 동작이다.
포인터를 함수에 전달하면 배열 길이 정보도 자동으로 전달되지 않는다.
void clear_values(
int *values,
size_t count
) {
for (
size_t index = 0;
index < count;
++index
) {
values[index] = 0;
}
}
호출자가 실제 배열보다 큰 count를 전달하면 범위를 벗어난다.
int values[4];
clear_values(values, 100);
포인터와 길이를 항상 함께 관리하고, 외부 입력으로부터 받은 크기를 접근 전에 검증해야 한다.
널 종료 문자열
C 문자열은 널 문자로 끝나는 문자 배열이다.
char text[] = "hello";
메모리에는 다음처럼 저장된다.
'h' 'e' 'l' 'l' 'o' '\0'
이 구조는 단순하고 기존 바이트 API와 결합하기 쉽다.
그러나 문자열의 길이가 타입에 포함되지 않으며, 널 종료 문자가 없으면 문자열 함수가 배열 범위를 넘어 읽을 수 있다.
char text[4] = {
't',
'e',
's',
't'
};
size_t length = strlen(text);
text에는 널 종료 문자가 없으므로 올바른 C 문자열이 아니다.
다음과 같은 문제도 발생할 수 있다.
- 버퍼 크기 부족
- 널 종료 누락
- 문자열 길이 계산 비용
- 내부 널 문자 처리 어려움
- 문자 수와 바이트 수의 혼동
- UTF-8 코드 포인트와 바이트 구분
문자열과 길이를 함께 전달하는 API가 더 명확할 수 있다.
bool text_process(
const char *text,
size_t length
);
안전하지 않은 라이브러리 인터페이스
일부 전통적인 C 표준 라이브러리 함수는 대상 버퍼의 크기를 받지 않는다.
strcpy(
destination,
source
);
호출자는 destination이 전체 문자열과 널 종료 문자를 저장할 만큼 크다는 사실을 보장해야 한다.
bool text_copy(
char *destination,
size_t capacity,
const char *source
) {
size_t length =
strlen(source);
if (length >= capacity) {
return false;
}
memcpy(
destination,
source,
length + 1
);
return true;
}
크기를 받는 함수도 사용법이 직관적이지 않을 수 있다.
strncpy(
destination,
source,
capacity
);
strncpy는 원본 길이가 제한 이상이면 널 종료를 보장하지 않으며, 남은 공간 전체를 0으로 채우는 동작도 가진다. 단순히 안전한 strcpy라고 생각해서는 안 된다.
C의 라이브러리 함수는 각 함수의 정확한 계약을 이해하고 사용해야 한다.
정의되지 않은 동작
C에는 표준이 결과를 전혀 요구하지 않는 정의되지 않은 동작이 존재한다.
대표적인 예는 다음과 같다.
- 널 포인터 역참조
- 배열 범위 초과
- 해제 후 사용
- 수명이 끝난 객체 접근
- 부호 있는 정수 오버플로
- 잘못된 포인터 산술
- 정렬되지 않은 접근
- 유효 타입과 별칭 규칙 위반
- 데이터 경쟁
- 잘못된 함수 포인터 호출
- 수정 불가능한 문자열 리터럴 변경
int increment(int value) {
return value + 1;
}
value가 INT_MAX라면 부호 있는 정수 오버플로가 발생한다.
정의되지 않은 동작은 다음과 같은 결과를 낼 수 있다.
겉으로 정상 동작
프로그램 종료
잘못된 값
메모리 손상
보안 취약점
관련 코드 제거
전체 제어 흐름 변화
정의되지 않은 동작은 특정한 오류 결과를 의미하지 않는다. 표준은 구현체에 아무 요구도 하지 않는다.
WG14의 C2Y 작업에서도 C23 핵심 언어에 남아 있는 정의되지 않은 동작을 분석하고 일부를 제약 위반이나 정의된 동작으로 바꾸는 작업이 계속 논의되고 있다.[130]
정의되지 않은 동작은 다양한 하드웨어 지원과 컴파일러 최적화를 가능하게 하지만, 프로그램 오류를 분석하기 어렵게 만든다.
진단의 한계
C 컴파일러는 모든 잘못된 프로그램을 거부하도록 요구되지 않는다.
문법 오류와 제약 위반에는 진단이 요구될 수 있지만, 정의되지 않은 동작은 일반적으로 반드시 진단할 필요가 없다.
int values[4];
void write_value(
size_t index
) {
values[index] = 10;
}
컴파일 시점에는 index의 실제 값을 알 수 없으므로 범위 초과 여부를 판단하지 못할 수 있다.
다음 코드는 컴파일러 경고가 가능하다.
int values[4];
values[10] = 20;
하지만 경고가 출력되지 않았다고 프로그램이 안전하다는 뜻은 아니다.
다음 도구를 함께 사용할 수 있다.
- 높은 수준의 컴파일러 경고
- 정적 분석
- AddressSanitizer
- UndefinedBehaviorSanitizer
- MemorySanitizer
- ThreadSanitizer
- Valgrind 계열 도구
- 퍼징
- 코드 검토
- 단위 및 통합 테스트
정수 변환의 복잡성
C는 여러 정수 타입과 암시적 변환 규칙을 제공한다.
int signed_value = -1;
unsigned int unsigned_value = 1;
if (
signed_value <
unsigned_value
) {
/* ... */
}
비교 과정에서 signed_value가 부호 없는 타입으로 변환되면 예상과 다른 결과가 나올 수 있다.
작은 정수 타입도 산술 연산 전에 정수 승격을 거친다.
unsigned char left = 200;
unsigned char right = 100;
int result = left + right;
이러한 규칙은 여러 프로세서에서 효율적인 산술을 지원하기 위해 만들어졌지만, 다음 오류를 일으킬 수 있다.
- 부호 비교 오류
- 축소 변환
- 크기 계산 오버플로
- 비트 연산 결과 오해
- 형식 문자열 불일치
- 배열 크기 오류
명시적인 타입과 범위 검사를 사용해야 한다.
if (
count >
SIZE_MAX / element_size
) {
return false;
}
복잡한 선언 문법
C 선언자는 식별자가 표현식에서 사용되는 형태를 반영한다.
int *pointer;
int values[10];
int function(int value);
int (*callback)(int value);
간단한 선언은 읽기 쉽지만, 배열과 포인터와 함수가 중첩되면 복잡해진다.
int (*handlers[8])(
const void *data,
size_t size
);
이는 두 매개변수를 받아 int를 반환하는 함수 포인터 8개의 배열이다.
typedef로 분리할 수 있다.
typedef int (*handler_function)(
const void *data,
size_t size
);
handler_function handlers[8];
선언 문법의 복잡성은 함수 포인터와 배열을 많이 사용하는 API의 가독성을 낮출 수 있다.
전처리기
C 전처리기는 헤더 포함과 조건부 컴파일, 매크로를 제공한다.
#if defined(_WIN32)
#include "windows_platform.h"
#else
#include "unix_platform.h"
#endif
전처리기는 플랫폼별 코드를 구성하고 반복 코드를 줄이는 데 유용하다.
하지만 타입과 범위, 함수 의미를 이해하지 않는 토큰 치환 도구다.
#define SQUARE(value) \
((value) * (value))
다음 호출은 인수를 두 번 평가한다.
int result =
SQUARE(index++);
매크로는 다음 문제를 일으킬 수 있다.
- 인수의 중복 평가
- 디버깅 어려움
- 오류 위치 혼란
- 타입 검사 부족
- 이름 충돌
- 긴 전처리 결과
- 조건별로 달라지는 소스 구조
- 빌드 의존성 증가
함수와 inline, 열거형과 constexpr, _Generic 등으로 대체할 수 있는 경우에는 전처리기 사용을 줄일 수 있다.
모듈과 패키지 관리 부족
ISO C는 언어 차원의 일반적인 모듈 시스템이나 패키지 관리자를 제공하지 않는다.
프로그램은 주로 헤더와 소스 파일로 인터페이스를 나눈다.
include/
library.h
src/
library.c
외부 라이브러리의 검색과 다운로드, 버전 결정과 빌드는 별도 도구가 담당한다.
대표적인 문제는 다음과 같다.
- 운영체제별 라이브러리 설치 방식
- 컴파일러별 옵션
- 헤더 검색 경로
- 정적·공유 라이브러리 차이
- ABI 호환성
- 전이 의존성
- 디버그와 릴리스 구성
- 교차 컴파일
- 패키지 버전 충돌
CMake와 Meson, pkg-config와 운영체제 패키지 관리자 등이 이를 보완하지만 하나의 표준 방식으로 통일되어 있지 않다.
헤더 기반 인터페이스
헤더는 여러 번역 단위에서 선언을 공유하는 전통적인 방식이다.
#ifndef CALCULATOR_H
#define CALCULATOR_H
int add(
int left,
int right
);
#endif
헤더는 간단하고 대부분의 C 컴파일러에서 지원된다.
하지만 다음 문제가 있다.
- 같은 내용을 여러 번 전처리
- include guard 필요
- 선언과 정의 분리
- 헤더 순서 의존
- 매크로 오염
- 내부 구현 노출 가능성
- 큰 프로젝트의 컴파일 시간 증가
불투명 구조체와 작은 공개 헤더로 노출 범위를 줄일 수 있다.
오류 처리
C에는 일반적인 예외 처리 문법이 없다.
함수는 반환값과 출력 매개변수, errno 등을 사용해 오류를 보고한다.
enum result {
RESULT_SUCCESS,
RESULT_INVALID_ARGUMENT,
RESULT_OUT_OF_MEMORY,
RESULT_IO_ERROR
};
enum result document_open(
const char *path,
struct document **document
);
명시적인 오류 코드는 다음 장점이 있다.
- 런타임 예외 시스템 불필요
- ABI로 전달하기 쉬움
- 오류 경로가 소스에 드러남
- 독립 실행 환경에서도 사용 가능
그러나 호출자가 반환값을 무시할 수 있다.
document_open(
path,
&document
);
/* 오류 확인 없음 */
오류 전파 코드가 반복되고, 자원 정리와 결합하면 함수가 길어질 수 있다.
if (!first_operation()) {
return false;
}
if (!second_operation()) {
cleanup_first();
return false;
}
프로젝트는 오류 코드와 로그, 정리 규칙을 일관되게 설계해야 한다.
자동 자원 관리 부족
C에는 C++의 소멸자와 Rust의 Drop, C#의 using과 같은 일반적인 자동 자원 관리가 없다.
struct resource *resource =
resource_create();
if (resource == NULL) {
return false;
}
/* ... */
resource_destroy(resource);
함수가 중간에 반환되면 해제가 누락될 수 있다.
if (!process(resource)) {
return false;
}
정리 레이블을 사용해 자원 해제를 한곳에 모을 수 있다.
bool run(void) {
bool result = false;
struct resource *resource = NULL;
resource =
resource_create();
if (resource == NULL) {
goto cleanup;
}
if (!process(resource)) {
goto cleanup;
}
result = true;
cleanup:
resource_destroy(resource);
return result;
}
이 방식은 명시적이지만 언어가 정리 실행을 보장하지는 않는다.
동시성의 어려움
C11 이후 원자적 타입과 표준 스레드, 메모리 순서가 추가되었지만 올바른 병렬 프로그램을 작성하는 것은 어렵다.
#include <stdatomic.h>
static atomic_bool ready = false;
static int shared_value;
작성 스레드는 release 저장을 사용할 수 있다.
shared_value = 42;
atomic_store_explicit(
&ready,
true,
memory_order_release
);
읽기 스레드는 acquire 읽기를 사용할 수 있다.
if (
atomic_load_explicit(
&ready,
memory_order_acquire
)
) {
use_value(shared_value);
}
메모리 순서를 잘못 선택하면 특정 아키텍처에서만 나타나는 오류가 발생할 수 있다.
일반 객체에 동기화 없이 접근하면 데이터 경쟁이 발생하며 C에서 정의되지 않은 동작이다.
static int counter;
void increment(void) {
++counter;
}
volatile은 스레드 동기화 수단이 아니다. Linux 커널 공식 문서도 volatile을 원자 변수처럼 사용하는 것은 잘못이며, 공유 데이터에는 잠금과 적절한 동기화 수단을 사용해야 한다고 설명한다.[131]
보안 위험
C의 메모리 오류는 프로그램 충돌에 그치지 않고 보안 취약점이 될 수 있다.
대표적인 취약점은 다음과 같다.
- 스택 버퍼 오버플로
- 힙 버퍼 오버플로
- use-after-free
- double free
- 형식 문자열 취약점
- 정수 오버플로
- 타입 혼동
- 데이터 경쟁
- 초기화되지 않은 메모리 노출
void print_message(
const char *message
) {
printf(message);
}
외부 입력이 형식 문자열로 사용되면 메모리 읽기와 쓰기 취약점이 발생할 수 있다.
printf("%s", message);
C 프로그램의 보안을 높이기 위해 다음 방어를 함께 사용한다.
- 메모리 안전한 설계
- 경계 검사
- 정수 오버플로 검사
- 정적 분석
- Sanitizer
- 퍼징
- 스택 보호
- ASLR
- 비실행 메모리
- 제어 흐름 보호
- 강화된 할당자
- 코드 검토
- 제한된 코딩 규칙
방어 옵션은 오류의 악용 가능성을 낮출 수 있지만 잘못된 C 코드를 자동으로 안전하게 만들지는 않는다.
유지보수 비용
C는 비교적 적은 런타임과 직접적인 제어를 제공하지만, 대규모 프로그램에서는 유지보수 부담이 커질 수 있다.
주요 원인은 다음과 같다.
- 소유권이 타입에 나타나지 않음
- 공개 헤더와 내부 구현 분리
- 수동 오류 처리
- 매크로와 조건부 컴파일
- 플랫폼별 코드
- ABI 호환성 관리
- 전역 상태
- 수동 문자열 처리
- 메모리 안전성 검증
- 낮은 수준의 동시성
struct object *object_get_child(
struct object *object
);
선언만으로는 다음을 알기 어렵다.
- 반환 포인터가 널일 수 있는가
- 호출자가 해제해야 하는가
- 부모보다 오래 유지되는가
- 다른 스레드에서 사용할 수 있는가
- 수정 가능한가
- 다음 호출 뒤에도 유효한가
문서와 명명 규칙, 정적 분석용 주석과 명확한 API 설계로 보완해야 한다.
생산성
C는 직접 제어가 필요한 작업에는 효율적이지만, 일반적인 업무 로직과 복잡한 애플리케이션을 빠르게 개발하는 데 필요한 기능은 제한적이다.
표준 언어와 라이브러리에는 다음 기능이 없다.
- 동적 배열 컨테이너
- 해시 맵
- 범용 문자열 객체
- 정규 표현식
- JSON 처리
- 네트워크 소켓
- GUI
- 패키지 관리
- 리플렉션
- 일반적인 비동기 함수
- 자동 직렬화
이러한 기능은 직접 구현하거나 외부 라이브러리를 선택해야 한다.
struct dynamic_array {
void *data;
size_t count;
size_t capacity;
size_t element_size;
};
다른 언어에서는 표준 라이브러리나 패키지 생태계가 제공하는 기능을 C에서는 직접 설계해야 할 수 있다.
추상화 기능의 제한
C는 구조체와 함수 포인터, 전처리기와 _Generic을 이용해 추상화를 만들 수 있다.
struct stream {
void *context;
size_t (*read)(
void *context,
void *buffer,
size_t size
);
void (*close)(
void *context
);
};
이 방식은 런타임 비용과 데이터 배치를 직접 관리할 수 있다.
그러나 언어 차원의 다음 기능은 제한적이거나 존재하지 않는다.
- 네임스페이스
- 메서드
- 인터페이스
- 타입 안전한 제네릭
- 함수 오버로딩
- 연산자 오버로딩
- 클래스
- 패턴 일치
- 대수적 데이터 타입
- 일반적인 모듈
대규모 라이브러리에서는 이름 접두사를 사용해야 한다.
image_create();
image_destroy();
image_load();
image_save();
C23은 여러 기능을 현대화했지만, C의 기본적인 함수·구조체 중심 모델은 유지된다.
컴파일러와 방언 차이
C 표준은 공통 기반을 제공하지만 실제 프로젝트는 컴파일러 확장을 사용하는 경우가 많다.
Linux 커널은 ISO C11 자체가 아니라 GNU C11 방언을 사용하며 여러 GNU 확장을 활용한다.[132]
#if defined(__GNUC__)
__attribute__((noreturn))
#endif
void panic(
const char *message
);
컴파일러 확장은 다음 기능을 제공할 수 있다.
- 속성
- 벡터 타입
- 인라인 어셈블리
- 128비트 정수
- 섹션 배치
- 호출 규약
- 문장 표현식
- 내장 함수
확장 기능을 사용하면 특정 플랫폼에서 더 효율적인 코드를 작성할 수 있지만 다른 컴파일러로의 이식성이 낮아진다.
ABI 의존성
C는 소스 수준에서는 이식 가능하지만 컴파일된 객체 파일과 라이브러리는 ABI에 의존한다.
같은 C 소스
↓
Linux System V ABI
Windows x64 ABI
Apple ABI
임베디드 ABI
ABI는 다음을 결정한다.
- 기본 타입 크기
- 구조체 배치
- 인수 전달
- 반환값
- 레지스터 보존
- 스택 정렬
- 심벌 이름
- 공유 라이브러리 형식
서로 다른 ABI로 컴파일된 라이브러리를 직접 연결할 수 없을 수 있다.
구조체를 공개 API에 노출하면 구성원 추가가 ABI를 깨뜨릴 수 있다.
struct public_context {
uint32_t version;
uint32_t flags;
};
장기적인 ABI에는 불투명 포인터나 구조체 크기와 버전 필드를 사용할 수 있다.
제한적인 표준 라이브러리
C 표준 라이브러리는 작은 공통 기반을 제공하지만 현대 응용 프로그램에 필요한 모든 기능을 포함하지 않는다.
표준에 없는 대표적인 기능은 다음과 같다.
- 네트워크 소켓
- 프로세스 관리
- 디렉터리 순회
- 동적 라이브러리 로딩
- 파일 시스템 감시
- 암호화
- 압축
- 그래픽스
- 오디오
- GUI
- HTTP
- 데이터베이스
- XML과 JSON
이러한 기능은 POSIX와 Win32, 플랫폼 SDK와 외부 라이브러리를 사용한다.
ISO C
→ 공통 언어와 기본 라이브러리
플랫폼 API
→ 운영체제 기능
외부 라이브러리
→ 응용 분야 기능
외부 의존성을 선택하고 빌드·배포하는 작업은 프로젝트가 직접 관리해야 한다.
유니코드 처리
C의 전통적인 문자 모델은 char와 널 종료 문자열, 현재 로캘과 다중 바이트 변환을 중심으로 발전했다.
현대의 유니코드 텍스트 처리에는 다음 문제가 있다.
char가 문자 하나를 의미하지 않음- UTF-8 문자가 여러 바이트일 수 있음
strlen은 문자 수가 아니라 바이트 수를 반환wchar_t의 크기와 인코딩이 플랫폼마다 다름- 대소문자 변환이 단순 일대일이 아닐 수 있음
- 정규화와 문자소 단위 처리가 표준 라이브러리에 없음
const char *text = "한글";
size_t bytes = strlen(text);
UTF-8 환경에서 bytes는 한글 음절 수가 아니라 바이트 수다.
완전한 유니코드 처리를 위해 외부 라이브러리가 필요할 수 있다.
표준 발전의 보수성
C 표준은 기존 코드와 구현체의 호환성을 중요하게 다룬다.
이러한 보수성은 다음 장점이 있다.
- 오래된 소스 코드 유지
- 기존 컴파일러와 도구 생태계 보호
- 산업 시스템의 장기 지원
- 점진적인 표준 채택
- ABI와 라이브러리 안정성
반면 오래된 설계와 안전하지 않은 관행을 빠르게 제거하기 어렵다.
호환성 유지
↔
언어 현대화
C23은 nullptr, constexpr, typeof, 비트 함수와 새로운 전처리 기능 등을 추가했지만, 기존 C의 포인터와 배열, 수동 메모리 관리 모델을 대체하지는 않았다.
학습 난이도
C의 기본 문법만 보면 비교적 작고 단순해 보인다.
int add(
int left,
int right
) {
return left + right;
}
그러나 올바른 C 프로그램을 작성하려면 다음을 이해해야 한다.
- 타입과 정수 변환
- 포인터
- 배열 조정
- 객체 수명
- 저장 기간
- 범위와 연결
- 정렬
- 구조체 패딩
- 유효 타입
- 엄격한 별칭
- 정의되지 않은 동작
- 컴파일과 링크
- ABI
- 동시성 메모리 모델
컴파일에 성공하고 간단한 테스트에서 동작하는 프로그램도 표준 규칙을 위반할 수 있다.
C는 문법 입문은 빠를 수 있지만 언어를 정확하게 이해하고 안전한 시스템 프로그램을 작성하는 데는 많은 지식이 필요하다.
C가 적합한 경우
다음과 같은 조건에서는 C가 강한 선택이 될 수 있다.
- 운영체제와 하드웨어에 직접 접근해야 함
- 마이크로컨트롤러와 독립 실행 환경을 지원해야 함
- 작은 런타임이 필요함
- 데이터 배치와 메모리 사용을 직접 제어해야 함
- 기존 C 라이브러리와 긴밀히 통합해야 함
- 여러 언어에서 호출할 안정적인 ABI가 필요함
- 오래된 플랫폼과 컴파일러를 지원해야 함
- 인증된 C 도구체인과 기존 산업 코드가 존재함
- 실행 비용을 세밀하게 분석해야 함
- 운영체제 커널이나 드라이버를 개발함
하드웨어 제어
+
작은 런타임
+
기존 C 생태계
+
안정적인 ABI
→ C가 적합할 가능성이 높음
C가 적합하지 않을 수 있는 경우
다음 요구가 중요하면 다른 언어를 우선 고려할 수 있다.
- 메모리 안전성이 가장 중요함
- 복잡한 웹 업무 로직을 빠르게 개발해야 함
- 대규모 GUI를 구현해야 함
- 자동 메모리 관리가 필요함
- 문자열과 동적 자료 처리가 중심임
- 안전한 동시성 추상화가 필요함
- 강한 모듈과 패키지 생태계가 필요함
- 개발 속도가 미세한 실행 비용보다 중요함
- 신규 보안 민감 코드를 장기적으로 유지해야 함
- 팀이 저수준 메모리 규칙을 관리하기 어려움
C를 완전히 배제하지 않고 일부 성능 모듈과 플랫폼 계층에만 사용할 수도 있다.
고수준 응용 프로그램
↓
작은 C ABI
↓
성능·하드웨어 모듈
이 구조에서는 C 경계를 작게 유지하고 철저하게 검증하는 것이 중요하다.
다른 언어와의 혼합
C는 전체 프로그램의 주력 언어가 아니어도 기반 모듈로 사용할 수 있다.
Python·C#·Java·Lua
↓
FFI
↓
C 라이브러리
↓
운영체제·하드웨어
혼합 구조는 다음 장점을 가진다.
- 고수준 언어의 개발 생산성
- C의 기존 라이브러리 사용
- 성능이 중요한 부분만 최적화
- 운영체제 API와 연결
- 여러 플랫폼의 공통 네이티브 코어
하지만 언어 경계에서는 다음을 명확히 해야 한다.
- 메모리 소유권
- 문자열 인코딩
- 배열 길이
- 널 가능성
- 구조체 배치
- 호출 규약
- 오류 전달
- 스레드
- 콜백 수명
- 예외와 패닉 처리
장점과 한계의 종합
C의 주요 장점은 다음과 같다.
장점
├── 다양한 하드웨어와 실행 환경
├── 네이티브 실행 성능
├── 예측 가능한 기본 비용
├── 직접적인 메모리와 하드웨어 제어
├── 작은 런타임
├── 안정적인 C ABI
├── 성숙한 컴파일러와 라이브러리
├── 기존 코드와의 높은 호환성
├── 별도 컴파일과 라이브러리 배포
└── 여러 언어와의 상호 운용
주요 한계는 다음과 같다.
한계
├── 수동 메모리 관리
├── 배열 범위 검사 부재
├── 객체 수명과 소유권 추적 부재
├── 널 종료 문자열의 제약
├── 정의되지 않은 동작
├── 복잡한 정수 변환과 선언
├── 전처리기의 위험성
├── 모듈·패키지 관리 부족
├── 제한적인 표준 라이브러리
├── 동시성의 복잡성
├── 보안 취약점 위험
└── 대규모 프로그램의 유지보수 부담
C의 강점과 약점은 같은 설계에서 나오는 경우가 많다. 배열 범위 검사를 강제하지 않기 때문에 추가 검사 비용 없이 메모리를 다룰 수 있지만, 범위를 잘못 계산하면 메모리 손상이 발생한다. 객체 수명을 자동으로 관리하지 않기 때문에 런타임을 작게 유지하고 사용자 지정 할당자를 만들 수 있지만, 해제 후 사용과 메모리 누수가 발생할 수 있다.
구현체가 다양한 하드웨어 특성을 활용할 수 있도록 넓은 자유를 제공하기 때문에 C는 여러 시스템에서 효율적으로 동작한다. 반면 구현 정의 특성과 정의되지 않은 동작을 이해하지 않으면 프로그램 결과가 플랫폼과 최적화 수준에 따라 달라질 수 있다.
따라서 C를 선택할 때는 단순히 빠르거나 오래된 언어라는 이유만으로 판단해서는 안 된다. 대상 환경과 기존 생태계, 성능과 메모리 요구, 보안과 유지보수 비용 및 개발 팀의 경험을 함께 고려해야 한다.
C는 운영체제와 하드웨어의 경계, 작은 임베디드 장치와 안정적인 네이티브 ABI가 필요한 분야에서 여전히 강력하다. 반면 일반적인 응용 프로그램과 보안에 민감한 신규 구성 요소에서는 메모리 안전성과 높은 수준의 추상화를 제공하는 다른 언어가 더 적합할 수 있다.
관련 문서
- 프로그래밍 언어
- 프로그래밍 언어의 역사
- 프로그래밍 패러다임
- 명령형 프로그래밍
- 절차적 프로그래밍
- 구조적 프로그래밍
- 시스템 프로그래밍
- 저수준 프로그래밍 언어
- 컴파일 언어
- 정적 타입
- 타입 체계
- C 표준
- ISO/IEC 9899
- WG14
- C 표준 라이브러리
- C 전처리기
- C 컴파일러
- 컴파일러
- 어셈블러
- 링커
- 로더
- 응용 프로그램 이진 인터페이스
- C ABI
- 외부 함수 인터페이스
- 객체 파일
- 실행 파일
- 추상 기계
- 번역 단위
- 별도 컴파일
- 교차 컴파일
- 메모리 모델
- 객체
- 객체 수명
- 저장 기간
- 메모리 정렬
- 동적 메모리 할당
- 포인터
- 포인터 산술
- 포인터 provenance
- 배열
- 문자열
- 널 포인터
- 구조체
- 공용체
- 유연한 배열 구성원
- 유효 타입
- 엄격한 별칭 규칙
- 정의되지 않은 동작
- 구현 정의 동작
- 미지정 동작
- 데이터 경쟁
- 원자적 연산
- 메모리 순서
- 스레드
- 뮤텍스
- 임베디드 시스템
- 실시간 운영체제
- 운영체제
- UNIX
- Linux
- 장치 드라이버
- 펌웨어
- 부트로더
- 데이터베이스
- 네트워크
- 컴퓨터 그래픽스
- 게임 엔진
- 멀티미디어
- 고성능 컴퓨팅
- 메모리 안전성
- 버퍼 오버플로
- 해제 후 사용
- 정적 분석
- 동적 분석
- 퍼징
- AddressSanitizer
- UndefinedBehaviorSanitizer
- K&R C
- ANSI C
- C89
- C90
- C95
- C99
- C11
- C17
- C23
- BCPL
- B (프로그래밍 언어)
- C++
- Objective-C
- Java
- C#
- JavaScript
- Go (프로그래밍 언어)
- Rust
- Zig
- D (프로그래밍 언어)
- Swift
- Python
- Lua
- Wave (프로그래밍 언어)
- GCC
- Clang
- Microsoft Visual C++
- Portable C Compiler
- TinyCC
- SQLite
- PostgreSQL
- FreeRTOS
- Zephyr
- FFmpeg
- libcurl
- Dennis M. Ritchie, The Development of the C Language ↩
- Martin Richards, BCPL Reference Manual ↩
- Dennis M. Ritchie, The Evolution of the UNIX Time-sharing System ↩
- Dennis M. Ritchie, The Development of the C Language — B ↩
- Dennis M. Ritchie, Very Early C Compilers and Language ↩
- Dennis M. Ritchie, Very Early C Compilers and Language ↩
- Bell Labs, last1120c/c00.c ↩
- Dennis M. Ritchie, Very Early C Compilers and Language — Structure Syntax ↩
- Brian W. Kernighan, Programming in C — A Tutorial ↩
- Dennis M. Ritchie, The Development of the C Language — Embryonic C ↩
- S. C. Johnson and D. M. Ritchie, Portability of C Programs and the UNIX System ↩
- Dennis M. Ritchie, The Development of the C Language — K&R C ↩
- WG14, Rationale for International Standard — Programming Languages — C ↩
- WG14, ISO/IEC C Project Status and Milestones ↩
- WG14, Approved C Standards ↩
- WG14, C99 Rationale ↩
- WG14 N1256, C99 with Technical Corrigenda ↩
- WG14 N1570, C11 Committee Draft ↩
- WG14, ISO/IEC C Project Status and Milestones ↩
- ISO/IEC 9899:2024 ↩
- WG14 N3220, ISO/IEC 9899:2024 Working Draft ↩
- WG14 N3220, Introduction ↩
- Dennis M. Ritchie, The Development of the C Language — Conclusion ↩
- WG14 N3255, The C Standard Charter ↩
- ISO/IEC 9899:2024, Scope ↩
- ISO/IEC 9899:2024, Abstract and Scope ↩
- ISO/IEC 9899:2024, Function specifiers ↩
- ISO/IEC 9899:2024, 3.5.3 Undefined behavior ↩
- WG14 N3255, The C Standard Charter ↩
- WG14, C99 Rationale ↩
- ISO/IEC 9899:2024, Conformance and Freestanding Implementations ↩
- ISO/IEC 9899:2024, Program Structure and Translation Units ↩
- WG14 N3255, The C Standard Charter ↩
- WG14 N3220, ISO/IEC 9899:2024, Language ↩
- WG14 N3220, ISO/IEC 9899:2024, Lexical Elements, Types, Expressions, Declarations, Statements and Functions ↩
- ISO/IEC 9899:2024, 6.2.4 Storage durations of objects ↩
- ISO/IEC 9899:2024, 7.24.3 Memory management functions ↩
- ISO/IEC 9899:2024, 6.5 Expressions — effective type ↩
- ISO/IEC 9899:2024, 6.2.6 Representations of types ↩
- ISO/IEC 9899:2024, 6.5 effective type rules ↩
- WG14 N2223, Clarifying the C Memory Object Model ↩
- ISO/IEC 9899:2024, 5.1.2.5 Multi-threaded executions and data races ↩
- GCC, Options Controlling the Kind of Output ↩
- LLVM, Clang Command Guide ↩
- ISO/IEC 9899:2024, 5.1.1.1 Program structure ↩
- ISO/IEC 9899:2024, 5.1.1.2 Translation phases ↩
- GCC, Preprocessor Options ↩
- ISO/IEC 9899:2024, 5.1.1.3 Diagnostics ↩
- GCC, Overall Options ↩
- Linux manual, elf(5) ↩
- ISO/IEC 9899:2024, 5.1.1.2 Translation phases ↩
- Linux manual, execve(2) ↩
- Linux manual, ld.so(8) ↩
- ISO/IEC 9899:2024, 5.1.2.1 Execution environments ↩
- ISO/IEC 9899:2024, 5.1.2.3.2 Program startup ↩
- ISO/IEC 9899:2024, 5.1.2.2 Freestanding environment ↩
- ISO/IEC 9899:2024, 7.24.4.4 The exit function ↩
- LLVM, Cross-compilation using Clang ↩
- ISO, ISO/IEC 9899:2024 — Programming languages — C ↩
- ISO/IEC JTC1/SC22/WG14 — C ↩
- WG14, ISO/IEC 9899 Project Status and Milestones ↩
- ISO, ISO/IEC 9899:2024 ↩
- WG14 N3220, 4 Conformance ↩
- WG14 N3220, 4 Conformance — hosted and freestanding implementations ↩
- WG14 N3220, 4 Conformance ↩
- WG14 N3220, Clause 7 Library ↩
- WG14 N3220, 7.1.3 Reserved identifiers ↩
- WG14, C Projects and Technical Specifications ↩
- WG14, C Standard Issues Lists ↩
- WG14 N3220, ISO/IEC 9899:2024 Working Draft ↩
- GCC Internals, GENERIC and GIMPLE ↩
- GCC, Options Controlling the Kind of Output ↩
- Dennis M. Ritchie, Very Early C Compilers and Language ↩
- Dennis M. Ritchie, The Development of the C Language ↩
- Portable C Compiler Project ↩
- GCC Internals ↩
- GCC 16.1 Released ↩
- The LLVM Compiler Infrastructure Project ↩
- Clang C Language Status ↩
- Microsoft C/C++ Language Conformance ↩
- Microsoft, /std Specify Language Standard Version ↩
- Microsoft, /Zc:preprocessor ↩
- Intel oneAPI Toolkit ↩
- IBM Open XL C/C++ for z/OS ↩
- IBM Open XL C/C++ for AIX ↩
- Arm Compiler for Embedded ↩
- Arm, Standard C Implementation Definition ↩
- Arm Compiler for Embedded FuSa ↩
- GNU Arm Embedded Toolchain ↩
- Open Watcom C/C++ User’s Guide ↩
- Linux Kernel Documentation, Programming Language ↩
- FreeRTOS, Source Code Organization ↩
- Zephyr Project, Primary Source Repository ↩
- Python Developer’s Guide, CPython Internals ↩
- Python Documentation, Extending Python with C or C++ ↩
- Lua, Getting Started ↩
- Python/C API Reference, Introduction ↩
- Git User Manual, A bird’s-eye view of Git’s source code ↩
- SQLite, Why Is SQLite Coded In C ↩
- SQLite Home Page ↩
- PostgreSQL Wiki, So, you want to be a developer? ↩
- PostgreSQL Documentation, C-Language Functions ↩
- nginx Official Website ↩
- libcurl, Network Transfer Library ↩
- libcurl, C API Overview ↩
- FFmpeg Documentation ↩
- FFmpeg Codecs Documentation ↩
- FFmpeg Formats Documentation ↩
- The HDF Group, HDF5 Library and Programming Model ↩
- Standard C++ Foundation, How did C++ get its name and history ↩
- Apple, About Objective-C ↩
- Oracle, Java Virtual Machine Specification — Introduction ↩
- Go Documentation, Frequently Asked Questions — C and Go ↩
- The Rust Reference, Behavior considered undefined ↩
- Wave Programming Language Blog, Pointer Support Arrives ↩
- Wave Programming Language Blog, Explicit Mutability and Safer Pointers ↩
- Wave Programming Language, Official Repository ↩
- WG14 N2363, C provenance semantics: examples ↩
- Dennis M. Ritchie, The Development of the C Language ↩
- Dennis M. Ritchie, Biography ↩
- Linux Kernel Documentation, Programming Language ↩
- ISO, ISO/IEC 9899:2024 ↩
- SQLite, Home Page ↩
- SQLite, Why Is SQLite Coded In C ↩
- Dennis M. Ritchie, The Development of the C Language — Conclusion ↩
- ISO, ISO/IEC 9899:2024 — Programming languages — C ↩
- Linux Kernel Documentation, Programming Language ↩
- SQLite, Why Is SQLite Coded In C ↩
- CISA, The Urgent Need for Memory Safety in Software Products ↩
- WG14 N3529, Ghosts and Demons: Undefined Behavior in the C2Y Core Language ↩
- Linux Kernel Documentation, Why the volatile type class should not be used ↩
- Linux Kernel Documentation, Programming Language ↩