Proven C Book←↑→

부록 I — 기계는 어떻게 이야기하는가: 선, 버스, 그리고 끼어들기

「OS 없이 도는 C」 부록에서 우리는 장치를 주소로 다뤘다. *(volatile uint8_t *)0x10000000 = 'A' 라고 쓰면 글자가 나갔다. 그런데 그 쓰기 한 번 뒤에 무슨 일이 벌어지는가? 어떤 선에 어떤 전압이 어떤 순서로 실리고, 상대는 그것을 어떻게 글자로 되돌리는가?

이 부록은 그 이야기다. 목표는 하드웨어 기술자가 되는 것이 아니라, 데이터시트의 첫 장을 읽을 수 있게 되는 것이다.

플랫폼 노트. 이 부록의 근거와 한계

이 기계에는 I2C 장치도 PCI 카드도 붙어 있지 않다. 그래서 여기서 「재었다」고 말할 수 있는 것은 딱 하나 — 운영체제의 신호 처리 비용뿐이다. 나머지는 세 가지 방식으로 다룬다. 선 위의 규약은 비트와 바이트를 규격대로 지어 보이고 되읽어 확인하고, 속도와 시간은 계산으로 낸다. 드라이버는 한 벌을 두 번 돌린다 — 이 기계에서는 하드웨어 대신 받은 명령을 찍고, QEMU 9.2 의 RISC-V 기계 모형에서는 진짜 레지스터 주소에 읽고 쓴다. 모형은 레지스터의 약속을 흉내 낼 뿐 선의 전압과 시간은 흉내 내지 않는다. 표와 본문에서 「계산」과 「실측」을 구분해 적는다.

먼저 알아 둘 낱말#

낱말뜻헷갈리기 쉬운 점
선(line·signal)전압이 실리는 한 가닥「데이터 선」과 「제어 선」은 하는 일이 다르다
버스(bus)여러 장치가 함께 쓰는 선 묶음점대점 연결(PCIe·USB)은 이름만 버스인 경우가 많다
프로토콜언제 무엇을 실을지에 대한 약속선이 같아도 약속이 다르면 못 알아듣는다
프레임(frame)한 번에 보내는 한 덩어리「바이트 하나」가 아니다 — 앞뒤에 표시가 붙는다
보율(baud)초당 신호 변화 횟수초당 비트 수와 같지 않을 수 있다(변조에 따라)
처리량(throughput)초당 실제로 옮겨진 바이트프레임의 군더더기 때문에 늘 이론값보다 작다
지연(latency)부탁하고 나서 응답이 오기까지의 시간처리량이 커도 지연은 클 수 있다
마스터 / 타깃말을 거는 쪽 / 대답하는 쪽요즘 규격은 controller/target 같은 말을 쓴다
전이중 / 반이중양쪽이 동시에 / 번갈아 말한다선의 개수가 대개 이것을 정한다

표 115.1 — 이 부록에서 쓰는 낱말

★ 마지막에서 셋째 줄이 실무에서 가장 자주 오해되는 자리다. 처리량과 지연은 다른 축이다. 트럭에 하드디스크를 가득 싣고 달리면 처리량은 어마어마하지만 지연은 몇 시간이다. 반대로 I2C 는 느리지만(100 kbit/s) 한 바이트를 주고받는 지연은 밀리초 아래다.

연결을 가르는 네 축#

세상의 연결 방식은 수십 가지지만, 물어볼 것은 넷뿐이다. 이 넷만 정하면 나머지는 세부다.

축한쪽다른 쪽무엇이 갈리나
선의 수직렬 — 한 가닥에 줄지어병렬 — 여러 가닥에 한꺼번에핀 수와 최고 속도(그림 115.1)
시간 맞추기비동기 — 클록 선이 없다동기 — 클록 선이 따로 있다양쪽이 속도를 미리 약속해야 하는가
주소없다 — 상대가 하나뿐있다 — 여럿 중 하나를 부른다선 하나에 장치 여럿을 달 수 있는가
누가 시작하나한쪽만(마스터-타깃)아무나(피어·중재)충돌을 어떻게 막는가

표 115.2 — 연결을 가르는 네 축

serial-parallel

그림 115.1 — 같은 한 바이트를 직렬로 보낼 때와 병렬로 보낼 때.

이 네 축으로 흔한 방식들을 늘어놓으면 이렇게 된다. 뒤의 절들은 이 표의 각 줄을 하나씩 푸는 것이다.

이름선의 수클록주소한마디로
UART(universal asynchronous receiver-transmitter) / RS-232직렬 2가닥(+흐름 제어)없다(비동기)없다가장 오래되고 가장 단순하다
병렬 포트병렬 8가닥 + 제어있다(스트로브)없다빨랐다가 속도의 벽에 걸렸다
I2C직렬 2가닥있다(SCL)있다(7비트)선 둘로 장치 여럿
SPI직렬 4가닥있다(SCK)선으로 고른다(CS)빠르고 단순하나 선을 먹는다
CAN직렬 2가닥(차동)없다메시지에 붙는다아무나 말하고 중재로 정리한다
PCIe직렬 레인 ×N(차동)내장(부호에 실림)있다(설정 공간)기계 안의 고속도로
USB직렬 2~4가닥내장있다(호스트가 준다)호스트가 전부 시킨다

표 115.3 — 이 부록에서 다루는 연결들

흔한 오해. 직렬은 느리고 병렬은 빠르다

옛날에는 그랬다. 지금은 반대다. 빨라질수록 여러 줄의 도착 시각을 맞추기가 어려워지기 때문이다(그림 115.1). 그래서 오늘의 빠른 연결 — PCIe, USB, SATA, 이더넷 — 은 전부 직렬이고, 대신 그 직렬 선을 여러 벌(레인) 묶는다. 「병렬 대신 직렬 여러 벌」이 오늘의 답이다.

가장 오래된 선 — UART 와 RS-232#

두 기계를 잇는 가장 단순한 방법은 이것이다. 선 하나에 비트를 차례로 흘려보낸다. 클록 선도 없다. 대신 양쪽이 「초당 몇 비트」를 미리 약속해 두고, 각자 제 시계로 센다.

이 방식을 다루는 장치가 UART(universal asynchronous receiver-transmitter)다.

프레임 — 놀고 있는 선에서 글자를 오려 내는 법#

클록이 없으면 「지금부터 한 글자가 시작된다」를 어떻게 아는가? 답은 약속된 모양이다.

차례이름값무엇없으면
—쉬는 상태1(높음)아무것도 안 보낼 때의 선시작을 알아볼 기준이 없다
1시작 비트0(낮음)「지금부터다」 — 받는 쪽이 여기서 시계를 맞춘다글자 경계를 못 찾는다
2~9자료 8비트값낮은 자리부터 나간다—
(선택)패리티계산값1의 개수를 짝수(또는 홀수)로 맞추는 한 비트한 비트 오류를 못 알아챈다
10정지 비트1(높음)「여기서 끝」 — 다음 시작 비트와 구별된다연달아 보낼 때 경계가 사라진다

표 115.4 — 8N1 프레임의 열 비트

8N1 이라는 표기가 이 표를 줄인 것이다.

자리뜻흔한 값메모
첫째 숫자자료 비트 수8, 드물게 77비트는 ASCII 만 쓰던 시절의 흔적
가운데 글자패리티N(없음)·E(짝수)·O(홀수)N 이 오늘의 기본
끝 숫자정지 비트 수1, 드물게 22 는 느린 상대에게 숨 돌릴 틈을 준다

표 115.5 — 8N1 같은 표기 읽는 법

examples/apx-links/uart_frame.c (발췌)

static size_t make_frame(unsigned char *w, uint8_t byte, unsigned parity_bits)
{
    size_t n = 0;
    for (unsigned i = 0; i < OS * 2; i++) w[n++] = 1;      /* 놀고 있을 때는 높다 */
    for (unsigned i = 0; i < OS; i++)     w[n++] = 0;      /* 시작 비트: 떨어뜨린다 */
    unsigned ones = 0;
    for (int b = 0; b < 8; b++) {
        unsigned bit = byte >> b & 1;                       /* LSB first */
        ones += bit;
        for (unsigned i = 0; i < OS; i++) w[n++] = (unsigned char)bit;
    }
    if (parity_bits) {                                      /* 짝수 패리티라면 */
        unsigned p = ones & 1;                              /* 1의 개수를 짝수로 맞춘다 */
        for (unsigned i = 0; i < OS; i++) w[n++] = (unsigned char)p;
    }
    for (unsigned i = 0; i < OS; i++)     w[n++] = 1;      /* 정지 비트: 다시 높다 */
    for (unsigned i = 0; i < OS * 2; i++) w[n++] = 1;
    return n;
}
/* … */
static int receive(const unsigned char *w, size_t n, double rx_bit,
                   uint8_t *out, int *stop_ok)
{
    size_t edge = 0;
    while (edge < n && w[edge] != 0) edge++;                /* 내려가는 자리 */
    if (edge >= n) return -1;

    double t0 = (double)edge;
    uint8_t v = 0;
    for (int b = 0; b < 8; b++) {
        double at = t0 + rx_bit * (b + 1) + rx_bit / 2.0;   /* (b+1)번째 비트의 한가운데 */
        size_t idx = (size_t)(at + 0.5);
        if (idx >= n) return -1;
        v |= (uint8_t)(w[idx] << b);
    }
    double sat = t0 + rx_bit * 9 + rx_bit / 2.0;            /* 정지 비트 자리 */
    *stop_ok = (sat < n) && w[(size_t)(sat + 0.5)] == 1;
    *out = v;
    return 0;
}

실행 결과

== the byte to send ==
  'K' = 0x4B = binary 01001011 (most significant first)

== the shape on the wire (8N1) --- low=_ high=- ==
  ----__----__--____--__------
      Std0d1d2d3d4d5d6d7Sp    
  * data goes least significant bit first, so it looks reversed to the eye.

== timing ==
  baud       one bit        one frame (10 bits) bytes per second
  300        3333.333       33333.3        30
  9600       104.167        1041.7         960
  19200      52.083         520.8          1920
  115200     8.681          86.8           11520
  921600     1.085          10.9           92160
  (microseconds. 8N1 spends 10 bits on one byte, so 20% is overhead)

== when the receiver's baud rate is off ==
  the receiver samples the middle using its own bit length; the error shifts it.

  rx baud      error      value read stop bit result
  9600            +0.0%  0x4B     ok       'K' --- correct
  9700            +1.0%  0x4B     ok       'K' --- correct
  9900            +3.1%  0x4B     ok       'K' --- correct
  10100           +5.2%  0x4B     ok       'K' --- correct
  10600          +10.4%  0x8B     broken   character corrupted
  19200         +100.0%  0x9E     ok       character corrupted

  range of error where the character survives here: -5.2% to +5.9%
  * the arithmetic agrees. The stop bit is sampled at 9.5 bit times,
    and that instant must fall within that bit (one bit wide), so
    0.5 / 9.5 = 5.3% is the margin on one side.
  * yet practice uses 2 to 3% as the rule. This 5.3% is shared by both ends
    (the sender drifts too), oscillators move with temperature and supply,
    and a real receiver samples three points and takes a majority.
    The theoretical limit and the design budget are different numbers.

== what parity catches ==
  'K' has 4 ones -> even parity bit = 0
  one flipped bit changes the parity, so it is caught.
  two flipped bits leave the parity unchanged, so it is not --- parity
  is a device for noticing an error, not for correcting one.

시연이 네 가지를 보인다.

첫째, 자료는 낮은 자리부터 나간다. 선 위의 모양을 눈으로 읽으면 이진수가 뒤집혀 보이는 이유다.

둘째, 8N1 은 바이트 하나에 열 비트를 쓴다. 그래서 9600 보율은 초당 960바이트다 — 20%가 틀을 유지하는 데 쓰이는 군더더기다.

셋째, 보율이 어긋나면 어긋남이 쌓인다. 시작 비트에서 시계를 맞추지만, 열 번째 비트에 이를 때까지 오차가 아홉 배 반으로 불어난다. 시연이 글자가 깨지는 경계를 직접 찾아 약 ±5% 라고 답했고, 셈으로도 0.5 / 9.5 = 5.3% 가 나온다.

넷째, 그런데 보율 오차의 실무 규칙은 「2~3%」다. 이 두 수가 다른 것이 중요하다 — 이론 한계는 양쪽이 나눠 갖고, 온도·전원에 따라 발진기가 흔들리며, 실제 수신기는 여러 점을 찍어 다수결한다. 55장의 말로 하면, 계약은 한계가 아니라 여유를 남긴 예산이다.

문. 왜 하필 9600, 19200, 115200 같은 어중간한 수인가?

답. 옛 UART 칩이 쓰던 수정 발진기가 1.8432 MHz 였고, 그것을 정수로 나누면 이 수들이 나온다(1.8432 MHz ÷ 16 ÷ 12 = 9600). 「깔끔한 십진수」가 아니라 정수 분주로 만들 수 있는 수가 표준이 된 것이다. 그래서 어떤 클록에서는 115200 을 정확히 만들 수 없고, 그때 생기는 오차가 위의 예산을 갉아먹는다.

RS-232 — 같은 프레임, 다른 전압#

UART 는 논리이고, RS-232 는 그것을 선에 싣는 전기 규격이다. 둘을 섞어 쓰다 태워 먹는 일이 흔하므로 표로 갈라 둔다.

TTL/CMOS UARTRS-232
1(mark)3.3 V 또는 5 V−3 V ~ −15 V
0(space)0 V+3 V ~ +15 V
논리높음이 1뒤집혀 있다 — 음이 1
닿는 거리보드 위 몇 cm십수 m
직접 이으면—전압이 달라 칩이 상한다 — 사이에 변환기가 필요

표 115.6 — TTL 수준 UART 와 RS-232

★ 표의 마지막 줄이 실무에서 물건을 태우는 자리다. 마이크로컨트롤러의 UART 핀에 컴퓨터의 시리얼 포트를 바로 물리면 안 된다 — MAX232 같은 변환기가 사이에 있어야 한다.

이름방향무엇요즘
TxD나감보내는 자료늘 쓴다
RxD들어옴받는 자료늘 쓴다
GND—기준 전압 — 없으면 아무것도 안 된다늘 쓴다
RTS / CTS나감 / 들어옴「보내도 되나」 — 하드웨어 흐름 제어빠른 연결에서 쓴다
DTR / DSR나감 / 들어옴「나 켜져 있다」 — 장치 준비 상태모뎀 시절의 흔적
DCD들어옴모뎀이 상대와 연결됨거의 안 쓴다
RI들어옴전화가 왔다거의 안 쓴다

표 115.7 — RS-232 의 신호선(9핀 기준)

흐름 제어 — 「잠깐만」이라고 말하는 법#

받는 쪽이 처리하지 못하면 자료가 그냥 사라진다. UART 에는 재전송이 없다.

방식어떻게장점단점
하드웨어 (RTS/CTS)따로 있는 선을 내려 「멈춰」라고 말한다자료를 건드리지 않는다. 즉시 멈춘다선이 두 가닥 더 필요하다
소프트웨어 (XON/XOFF)자료 흐름 안에 0x13·0x11 을 끼워 보낸다선 세 가닥이면 된다이진 자료를 못 보낸다 — 그 바이트가 나오면 오해한다

표 115.8 — 흐름 제어의 두 방식

반례. 이진 자료를 XON/XOFF 회선으로 보낸다

자료 한가운데 0x13 바이트가 나오면 상대는 그것을 「멈춰」로 읽는다. 그 순간 전송이 얼어붙고, 다시는 안 풀릴 수도 있다. 이진 자료를 보낼 회선에서는 하드웨어 흐름 제어를 쓰거나, 자료를 텍스트로 인코딩해야 한다.

UART 가 알려 주는 오류 넷#

이름언제대개 무슨 뜻인가
프레이밍 오류정지 비트 자리가 낮았다보율이 다르거나 선이 흔들렸다
패리티 오류1의 개수가 약속과 다르다잡음 — 한 비트가 뒤집혔다
오버런읽기 전에 다음 바이트가 왔다내 코드가 늦었다 — 인터럽트나 DMA 가 필요하다
브레이크한 프레임 넘게 계속 낮았다상대가 일부러 보낸 신호, 또는 선이 끊겨 접지에 붙었다

표 115.9 — UART 상태 레지스터에서 만나는 오류

★ 셋째 줄이 이 부록의 뒷부분과 이어진다. 오버런은 하드웨어가 아니라 소프트웨어의 문제다 — 바이트마다 끼어들기(인터럽트)를 받아 처리하기에 벅차지면, 그때 필요한 것이 DMA(direct memory access)다.

드라이버 — 16550 의 레지스터 여덟 칸#

PC 의 COM1 에 앉아 있던 UART 칩 16550 은 지금도 같은 레지스터 배치로 쓰이고, QEMU 의 RISC-V 기계 모형 virt 도 이 칩을 흉내 낸다. 아래 드라이버는 리눅스의 8250 드라이버와 펌웨어의 초기 콘솔이 하는 일을 줄인 것이다. 보율에 맞는 분주값을 넣고 프레임을 8N1 로 정한 뒤, 보낼 칸이 비기를 기다려 바이트를 넣고, 칩 안의 루프백(송신을 수신에 바로 잇는 시험 모드)으로 칩이 대답하는지 본다.

이 부록과 「디스크」 부록의 드라이버는 하드웨어에 닿는 자리를 함수 넷(hw_read8 · hw_read32 · hw_write8 · hw_write32)으로 모았다. 드라이버 코드는 한 벌이고, 이 넷의 몸만 두 가지다. 이 기계(x86-64 리눅스)에서 짓는 판은 받은 명령을 한 줄씩 찍는다 — W 주소 <- 값 은 쓰기, R 주소 -> 값 은 읽기다. 읽기에는 대답이 있어야 하므로 예제마다 작은 대답 표를 두었고, 그 표가 장치 흉내의 전부다. 교차 컴파일러로 짓는 판은 그 주소에 정말 읽고 쓰며 QEMU 의 기계 모형에서 돈다. # 로 시작하는 줄은 명령이 아니라 예제가 제 결과를 알리는 줄이다.

examples/apx-links/uart16550/uart16550.c (발췌)

/* 레지스터는 바이트 하나씩 여덟 칸이다.
   같은 번호가 읽을 때와 쓸 때 다른 레지스터다. */
enum {
    RBR = 0, THR = 0, DLL = 0,   /* 받은 바이트 / 보낼 바이트 / (DLAB=1) 분주값 아래 */
    IER = 1, DLM = 1,            /* 인터럽트 허용 / (DLAB=1) 분주값 위 */
    FCR = 2,                     /* FIFO 제어(쓰기 전용) */
    LCR = 3,                     /* 줄 제어: 자료 비트 · 패리티 · 정지 비트 · DLAB */
    MCR = 4,                     /* 모뎀 제어: DTR · RTS · 루프백 */
    LSR = 5,                     /* 줄 상태: 받은 것 있음 · 보낼 자리 있음 · 오류 */
};
enum { LCR_8N1 = 0x03, LCR_DLAB = 0x80 };
enum { MCR_DTR = 0x01, MCR_RTS = 0x02, MCR_LOOP = 0x10 };
enum { LSR_DR = 0x01, LSR_OE = 0x02, LSR_PE = 0x04, LSR_FE = 0x08, LSR_BI = 0x10,
       LSR_THRE = 0x20 };
/* … */
int uart_init(const struct uart *u, uint32_t baud)
{
    /* 칩은 한 비트를 16번 찍어 읽는다. 그래서 분주값 = 클록 / (16 × 보율). */
    uint32_t div = u->clock / (16u * baud);
    if (div == 0 || div > 0xFFFF) return -1;
    if (u->clock % (16u * baud) * 50 > 16u * baud) return -1;   /* 오차 2% 넘음 */

    wr(u, IER, 0x00);                    /* 인터럽트는 끄고 폴링으로 간다 */
    wr(u, LCR, LCR_DLAB);                /* 0번 · 1번 칸을 분주값 자리로 바꾼다 */
    wr(u, DLL, (uint8_t)div);
    wr(u, DLM, (uint8_t)(div >> 8));
    wr(u, LCR, LCR_8N1);                 /* DLAB 을 내리며 8N1 로 */
    wr(u, FCR, 0x07);                    /* FIFO 를 켜고 양쪽을 비운다 */
    wr(u, MCR, MCR_DTR | MCR_RTS);       /* 「여기 있다」를 상대에게 알린다 */
    return 0;
}
/* … */
void uart_putc(const struct uart *u, char c)
{
    while (!(rd(u, LSR) & LSR_THRE)) { }  /* 보낼 칸이 빌 때까지 기다린다 */
    wr(u, THR, (uint8_t)c);
}

int uart_getc(const struct uart *u)
{
    if (!(rd(u, LSR) & LSR_DR)) return -1;
    return rd(u, RBR);
}

unsigned uart_errors(const struct uart *u)
{
    /* 오류 비트는 LSR 을 읽는 순간 지워진다. 한 번 읽어 간직해야 한다. */
    return rd(u, LSR) & (LSR_OE | LSR_PE | LSR_FE | LSR_BI);
}
/* … */
/* 칩 안에서 송신을 수신에 잇고 한 바이트를 돌려 본다. 선을 꽂기 전에
   「칩이 거기 있고 레지스터가 말을 듣는가」를 가린다. */
int uart_loopback_test(const struct uart *u)
{
    uint8_t mcr = rd(u, MCR);
    wr(u, MCR, mcr | MCR_LOOP);
    for (int i = 0; i < 16 && uart_getc(u) >= 0; i++) { }  /* FIFO 를 비운다 */
    uart_putc(u, 'K');
    int got = -1;
    for (int tries = 0; tries < 1000 && got < 0; tries++)
        got = uart_getc(u);
    wr(u, MCR, mcr);
    return got == 'K' ? 0 : -1;
}

실행 결과

== this machine: the commands the driver sends (trace backend) ==
W  0x10000001 <- 0x00
W  0x10000003 <- 0x80
W  0x10000000 <- 0x02
W  0x10000001 <- 0x00
W  0x10000003 <- 0x03
W  0x10000002 <- 0x07
W  0x10000004 <- 0x03
R  0x10000004 -> 0x03
W  0x10000004 <- 0x13
R  0x10000005 -> 0x60
R  0x10000005 -> 0x60
W  0x10000000 <- 0x4b 'K'
R  0x10000005 -> 0x61
R  0x10000000 -> 0x4b 'K'
W  0x10000004 <- 0x03
R  0x10000005 -> 0x60
W  0x10000000 <- 0x6f 'o'
R  0x10000005 -> 0x60
W  0x10000000 <- 0x6b 'k'
R  0x10000005 -> 0x60
W  0x10000000 <- 0x0a
W  0x00100000 <- 0x00005555

== QEMU virt (emulated RISC-V): the same driver on the 16550 model ==
ok

출력에서 처음 다섯 번의 쓰기가 앞의 물음 「왜 하필 9600, 19200, 115200 인가」와 이어진다. QEMU 가 장치 트리에 적어 내주는 이 칩의 클록은 3,686,400 Hz(1.8432 MHz 의 두 배)이고, 115200 보율의 분주값은 3686400 / (16 × 115200) = 2 로 나누어떨어진다. 드라이버는 LCR 의 DLAB 비트를 세워 0번 · 1번 칸을 분주값 자리로 바꾸고, 2 를 넣은 뒤 DLAB 을 내린다. 같은 주소가 때에 따라 다른 레지스터가 되는 이 방식은 주소선이 귀하던 시절의 절약이다.

줄 상태 레지스터(LSR)의 오류 비트 넷이 위 표 「UART 상태 레지스터에서 만나는 오류」의 네 줄이다. 이 비트들은 LSR 을 읽는 순간 지워지므로, 드라이버는 한 번 읽은 값을 간직해야 한다.

QEMU 쪽의 ok 는 칩 모형이 루프백으로 'K' 를 돌려준 결과다. 루프백 비트를 빼고 지으면 'K' 가 콘솔로 나가 버리고 no 가 찍혔다. 다만 QEMU 의 16550 은 보율을 흉내 내지 않아서, 분주값이 틀려도 글자는 그대로 나온다. 보율이 어긋날 때 생기는 일은 앞의 프레임 시연이 보여 주고, 이 시연은 레지스터에 무엇을 어떤 차례로 쓰는지를 보여 준다.

병렬은 왜 사라졌나#

한 번에 여덟 비트를 보내면 여덟 배 빠를 것 같다. 실제로 한동안 그랬다. 프린터를 잇던 센트로닉스 포트가 그 방식이다.

무리선무엇방향
자료D0 ~ D7여덟 비트를 한꺼번에나감
제어STROBE「지금 자료가 유효하다」 — 사실상의 클록나감
상태ACK「받았다」들어옴
상태BUSY「아직 처리 중이니 기다려라」들어옴
상태PAPER OUT · SELECT · ERROR프린터의 사정들어옴
기준GND ×8자료선마다 접지를 짝지어 잡음을 줄인다—

표 115.10 — 병렬(센트로닉스) 포트의 신호들

빠른데도 사라진 이유가 그림 115.1에 있다. 여덟 줄이 똑같은 순간에 도착해야 한다는 조건이 속도의 벽이 된다.

문제무엇인가빨라지면
스큐(skew)줄마다 길이·부하가 달라 도착 시각이 어긋난다한 비트의 시간이 어긋남보다 짧아지는 순간 못 맞춘다
크로스토크옆 줄의 변화가 이웃 줄에 새어 든다변화가 빨라질수록 심해진다
핀과 비용줄이 여덟이면 커넥터·기판·차폐가 여덟 벌비싸고 두껍고 구부러지지 않는다
끝맺음줄마다 임피던스를 맞춰야 반사가 안 생긴다수백 MHz 를 넘기면 사실상 불가능

표 115.11 — 빨라질수록 병렬이 지는 이유

★ 그래서 오늘의 답은 「병렬을 버린 것」이 아니라 「직렬 한 벌을 아주 빠르게 만들고, 그것을 여러 벌 묶는 것」이다. PCIe 의 레인, USB 3 의 여러 쌍, SATA 가 전부 그 모양이다. 줄 사이의 시각을 맞추는 일은 하드웨어가 각 줄을 따로 복원한 뒤 다시 모으는 방식으로 푼다 — 줄마다 제 클록을 부호 안에 실어 보내기에 그럴 수 있다.

두 선으로 여럿 — I2C#

선을 최대한 아끼고 싶을 때 쓰는 방식이다. 선 둘(자료 SDA, 클록 SCL)에 장치 여럿을 매달고, 주소로 골라 부른다.

이름무엇어떻게 만드나왜 그렇게
SCL클록주인(마스터)이 흔든다동기 방식 — 속도를 미리 약속할 필요가 없다
SDA자료양쪽이 번갈아 쓴다반이중 — 한 번에 한 방향
START「지금부터 거래」SCL 이 높은 동안 SDA 를 내린다자료는 SCL 이 낮을 때만 바뀐다는 규칙을 일부러 어겨 만든 표시
STOP「끝」SCL 이 높은 동안 SDA 를 올린다〃
반복 START「끝내지 않고 방향만 바꿈」STOP 없이 START 를 또그 사이 다른 주인이 끼어들지 못한다
ACK / NACK「받았다」/「없거나 그만」아홉 번째 클록에서 받는 쪽이 SDA 를 내린다/안 내린다선을 「내리기만」 할 수 있으니 가능한 방식

표 115.12 — I2C 의 신호와 표시

examples/apx-links/i2c_frame.c (발췌)

static void put_byte(uint8_t b, const char *what, int acked)
{
    for (int i = 7; i >= 0; i--) put(EV_BIT, b >> i & 1, i == 7 ? what : NULL);
    put(acked ? EV_ACK : EV_NACK, acked ? 0 : 1, NULL);
}
/* … */
put(EV_START, 0, "the controller takes the bus");
put_byte((uint8_t)(dev << 1 | 0), "device address + write", 1);
put_byte(reg, "register number", 1);
put_byte(val, "value to write", 1);
put(EV_STOP, 0, "release the bus");

/* ── 읽기 거래: 반복 START 로 방향만 바꾼다 ── */
put(EV_START, 0, "take it again");
put_byte((uint8_t)(dev << 1 | 0), "device address + write", 1);
put_byte(reg, "say which register to read first", 1);
put(EV_RSTART, 0, "* START again without STOP --- nobody can cut in meanwhile");
put_byte((uint8_t)(dev << 1 | 1), "device address + read", 1);
put_byte(val, "the value the device returned", 0);   /* 마지막 바이트는 주인이 NACK 로 끝을 알린다 */
put(EV_STOP, 0, "end");

실행 결과

== why the address is confusing ==
  the 7-bit address 0x3C is shifted one place on the wire (last bit is read/write).
    write: 0x3C << 1 | 0 = 0x78
    read : 0x3C << 1 | 1 = 0x79
  so datasheets call the same device 0x3C in one place and 0x78 in another.

== the order on the wire ==
  START        the controller takes the bus
  byte         0x78  device address + write
  ACK          the device pulled SDA down = received
  byte         0x00  register number
  ACK          the device pulled SDA down = received
  byte         0xAF  value to write
  ACK          the device pulled SDA down = received
  STOP         release the bus
  START        take it again
  byte         0x78  device address + write
  ACK          the device pulled SDA down = received
  byte         0x00  say which register to read first
  ACK          the device pulled SDA down = received
  repeated START * START again without STOP --- nobody can cut in meanwhile
  byte         0x79  device address + read
  ACK          the device pulled SDA down = received
  byte         0xAF  the value the device returned
  NACK         nobody pulled it down = absent, or done
  STOP         end

== what means what ==
  START : SDA falls while SCL is high   (deliberately breaking the rule that
  STOP  : SDA rises while SCL is high    data changes only while SCL is low)
  ACK   : on the ninth clock the receiver pulls SDA down
  NACK  : nobody pulls it down and the line stays high --- absent and enough look alike

== timing ==
  clocks in these two transactions: 63 (data and ACK bits), 5 markers
  speed mode     clock          time for these two transactions
  standard       100.0          630.0 microseconds
  fast           400.0          157.5 microseconds
  fast plus      1000.0         63.0 microseconds
  high speed     3400.0         18.5 microseconds
  (clock in kHz; markers and wait states are not counted)

== why the lines are only ever pulled down ==
  if two devices speak at once, one pushing 5V and one pushing 0V, current
  flows straight through and damages the chips. I2C lets nobody push (open drain)
  and leaves the raising to a resistor. So speaking at once is safe,
  and the property that zero wins gives arbitration and ACK for free.

세 가지가 실무에서 바로 쓰인다.

첫째, 주소가 두 가지로 적히는 까닭. 7비트 주소는 선 위에서 한 칸 왼쪽으로 밀려 실리고 맨 끝에 읽기/쓰기 비트가 붙는다. 그래서 같은 장치가 데이터시트에 따라 0x3C 로도 0x78 로도 적힌다. 둘 다 맞다 — 하나는 주소, 하나는 「선 위의 첫 바이트」다.

둘째, NACK 는 두 가지 뜻이다. 「그런 장치 없다」와 「이제 그만 보내라」가 선 위에서 같은 모양이다. 읽기의 마지막 바이트에 주인이 NACK 를 보내 끝을 알리는 것이 규약이다.

셋째, 선을 「내리기만」 하는 설계가 많은 것을 공짜로 만든다. 아무도 전압을 밀어 올리지 않으므로 둘이 동시에 말해도 칩이 상하지 않고, 「0 이 이긴다」는 성질에서 ACK 와 중재가 그냥 나온다. 대신 올리는 일은 저항이 하므로 올라가는 속도가 느리고, 그것이 I2C 속도의 상한을 만든다.

이름클록초당 바이트(어림)메모
표준100 kHz약 11,000센서 대부분
빠름400 kHz약 44,000요즘 기본
빠름+1 MHz약 110,000배선이 짧아야 한다
고속3.4 MHz약 380,000전용 회로가 필요 — 흔치 않다

표 115.13 — I2C 의 속도 모드

문. I2C 장치를 여럿 달았더니 하나도 안 잡힌다. 무엇부터 보나?

답. 넷을 순서대로 본다. ① 풀업 저항이 있는가(없으면 선이 절대 올라가지 않는다). ② 주소가 겹치지 않는가(같은 종류의 센서 둘은 대개 겹친다 — 주소 핀으로 바꾼다). ③ 접지를 공유하는가. ④ 배선이 너무 길거나 장치가 많아 올라가는 시간이 늦지 않은가. 넷 중 셋이 「전기」의 문제이고 하나만 「소프트웨어」의 문제다.

드라이버 — 제어기에게 신호를 하나씩 시킨다#

소프트웨어가 SDA · SCL 을 직접 흔드는 일(비트 뱅잉)은 드물다. 보통은 I2C 제어기가 선을 흔들고, 드라이버는 제어기에게 「시작을 내고 이 바이트를 보내라」 같은 명령을 준다. 아래는 SiFive FU540 같은 칩에 들어 있는 OpenCores I2C 제어기의 드라이버로, 온도 감지기 LM75 (주소 0x48)에서 온도를 읽는다. 제어기의 명령 비트 STA · WR · RD · NACK · STO 가 위 표의 신호(START · 바이트 · ACK/NACK · STOP)와 하나씩 맞는다.

examples/apx-links/i2c_ocores/i2c.c (발췌)

/* 명령 하나를 내리고 끝나기를 기다린다.
   바이트 하나 + ACK 한 비트가 선 위에서 오간다. */
static int step(const struct i2c *b, uint8_t cmd)
{
    wr(b, CR, cmd);
    uint8_t sr;
    while ((sr = rd(b, SR)) & SR_TIP) { }
    if (sr & SR_AL) return -1;                     /* 다른 주인에게 버스를 잃었다 */
    return (cmd & CR_WR) && (sr & SR_RXNACK) ? -1 : 0;   /* 아무도 ACK 하지 않았다 */
}
/* … */
int i2c_write_read(const struct i2c *b, uint8_t addr, const uint8_t *w, unsigned nw,
                   uint8_t *r, unsigned nr)
{
    wr(b, TXR, (uint8_t)(addr << 1));                    /* 주소 + 쓰기(0) */
    if (step(b, CR_STA | CR_WR)) goto fail;
    for (unsigned i = 0; i < nw; i++) {
        wr(b, TXR, w[i]);
        if (step(b, CR_WR)) goto fail;
    }
    if (nr) {
        wr(b, TXR, (uint8_t)(addr << 1 | 1));            /* 다시 시작, 주소 + 읽기(1) */
        if (step(b, CR_STA | CR_WR)) goto fail;
        for (unsigned i = 0; i < nr; i++) {
            /* 마지막 바이트에는 NACK 으로 「그만」이라 답하고 정지를 붙인다 */
            int last = i + 1 == nr;
            step(b, (uint8_t)(CR_RD | (last ? CR_NACK | CR_STO : 0)));
            r[i] = rd(b, RXR);
        }
        return 0;
    }
    return step(b, CR_STO);
fail:
    wr(b, CR, CR_STO);                                   /* 버스를 놓아 준다 */
    return -1;
}

실행 결과

== this machine: the commands the driver sends (trace backend) ==
W  0x10030008 <- 0x00
W  0x10030000 <- 0xe7
W  0x10030004 <- 0x03
W  0x10030008 <- 0x80
W  0x1003000c <- 0x90
W  0x10030010 <- 0x90
R  0x10030010 -> 0x00
W  0x1003000c <- 0x00
W  0x10030010 <- 0x10
R  0x10030010 -> 0x00
W  0x1003000c <- 0x91
W  0x10030010 <- 0x90
R  0x10030010 -> 0x00
W  0x10030010 <- 0x20
R  0x10030010 -> 0x00
R  0x1003000c -> 0x19
W  0x10030010 <- 0x68
R  0x10030010 -> 0x00
R  0x1003000c -> 0x80
#  LM75 at 0x48: 25.5 C

(QEMU's RISC-V machines model no I2C controller: there is no second half here)

출력에 보내는 바이트로 나오는 0x90 과 0x91 이 앞에서 말한 「주소가 두 가지로 적히는 까닭」이다 — 7비트 주소 0x48 이 한 칸 밀리고 끝에 쓰기(0)나 읽기(1) 비트가 붙었다. 둘째 바이트를 받는 명령 0x68 은 RD · NACK · STO 를 한꺼번에 세운 값으로, 「그만」과 정지를 함께 낸다. 첫 바이트를 받는 명령 0x20 에는 NACK 이 없으니 ACK 가 나간다.

이 시연에는 QEMU 쪽 절반이 없다. QEMU 의 RISC-V 기계 모형에는 I2C 제어기가 없어서, 위 출력의 대답은 대답 표가 꾸민 것이다(상태 레지스터는 언제나 「끝, ACK 받음」, 온도 바이트는 25.5 도). 이 드라이버는 진짜 제어기에서 돌려 보지 않은 코드다.

네 선으로 빠르게 — SPI#

I2C 가 선을 아끼는 대신 속도를 포기했다면, SPI 는 반대다. 선을 넷 쓰고 대신 빠르다.

이름다른 이름무엇방향
SCKSCLK, CLK클록 — 주인이 흔든다주인 → 장치
MOSISDO, COPI주인이 보내는 자료주인 → 장치
MISOSDI, CIPO장치가 보내는 자료장치 → 주인
CSSS, NSS, CE이 장치에게 말한다는 표시(대개 낮을 때 선택)주인 → 장치

표 115.14 — SPI 의 네 선

★ 주소가 없다. 대신 장치마다 CS 선을 한 가닥씩 뽑는다. 장치가 늘면 선이 는다 — I2C 와 정확히 반대의 맞바꿈이다.

examples/apx-links/spi_shift.c (발췌)

for (int c = 1; c <= 8; c++) {
    unsigned mosi = m >> 7 & 1;          /* 주인이 내놓는 비트 (높은 자리부터) */
    unsigned miso = s >> 7 & 1;          /* 장치가 내놓는 비트 */
    m = (uint8_t)(m << 1 | miso);        /* 서로의 비트를 낮은 자리로 받아 넣는다 */
    s = (uint8_t)(s << 1 | mosi);
    bits8(m, mb); bits8(s, sb);
    printf("  %-6d %-6u %-6u %-12s %-12s\n", c, mosi, miso, mb, sb);
}

실행 결과

== at the start ==
  controller register : 0xA5 (10100101)
  device register     : 0x3C (00111100)

== one bit per clock ==
  clock  MOSI   MISO   controller   device      
  1      1      0      01001010     01111001    
  2      0      0      10010100     11110010    
  3      1      1      00101001     11100101    
  4      0      1      01010011     11001010    
  5      0      1      10100111     10010100    
  6      1      1      01001111     00101001    
  7      0      0      10011110     01010010    
  8      1      0      00111100     10100101    

== after eight clocks ==
  controller register : 0x3C  <- what the device sent
  device register     : 0xA5  <- what the controller sent
  * sending and receiving finished in the same eight clocks. Full duplex is
    free because the two registers form one ring. Even with nothing to receive
    something must be sent (usually 0x00 or 0xFF): a dummy byte.

== the four modes --- which edge presents, which samples ==
  mode     CPOL   CPHA   SCK at rest      sampling edge
  0        0      0      low              rising (first edge)
  1        0      1      low              falling (second edge)
  2        1      0      high             falling (first edge)
  3        1      1      high             rising (second edge)

== when the mode is mismatched ==
  sent 0xA5 (10100101)
  read with one edge of error: 0x52 (01010010)  <- everything shifted one place
  * so when SPI gives half-right values, suspect the mode first.

== speed ==
  SPI has no start, stop, address or ACK. Exactly eight clocks per byte.
  SCK            bytes per second compared
  1 MHz          125000           11 times I2C standard (100 kHz)
  10 MHz         1250000          112 times I2C standard (100 kHz)
  50 MHz         6250000          562 times I2C standard (100 kHz)
  (UART spends 10 bits per byte, I2C 9, SPI 8 --- no overhead)

시연이 SPI 의 본질을 보인다. 두 시프트 레지스터가 하나의 고리를 이룬다. 클록마다 한 비트씩 밀어 넣으면 여덟 클록 뒤에 두 값이 서로 자리를 바꾼다. 그래서 「보내면서 동시에 받는」 것이 공짜다 — 그리고 받기만 하고 싶어도 무언가는 보내야 한다(더미 바이트).

모드CPOLCPHA쉴 때 클록읽는 모서리
000낮다올라가는 쪽(첫 모서리)
101낮다내려가는 쪽(둘째 모서리)
210높다내려가는 쪽(첫 모서리)
311높다올라가는 쪽(둘째 모서리)

표 115.15 — SPI 의 네 모드

흔한 오해. SPI 는 규격이 있으니 아무 장치나 물리면 된다

SPI 에는 공식 규격이 없다. 모드도 넷이고, 자료를 높은 자리부터 보낼지 낮은 자리부터 보낼지도 장치마다 다르며, CS 를 바이트마다 올릴지 거래 전체에서 내려 둘지도 다르다. 시연에서 모드를 어긋나게 읽은 부분처럼 모드가 한 모서리만 어긋나도 값이 전부 한 칸 밀려 나온다 — 「절반쯤 맞는 값」이 나오면 먼저 모드를 의심하는 이유다.

드라이버 — 플래시에게 이름을 묻는다#

SPI 에 가장 흔히 달리는 것은 NOR 플래시다. 많은 보드가 첫 단계 부트로더를 이 플래시에서 읽는다. 아래 드라이버는 QEMU 의 기계 모형 sifive_u(SiFive FU540 보드의 모형)의 SPI 제어기로 플래시에게 신분(JEDEC ID: 제조사 · 종류 · 용량 세 바이트)을 묻고, 맨 앞 여덟 바이트를 읽는다.

examples/apx-links/spi_flash/spi.c (발췌)

/* SPI 는 보내는 만큼 받는다. 한 바이트를 밀어 넣으면 한 바이트가 밀려 나온다. */
void spi_xfer(const struct spi *s, const uint8_t *tx, uint8_t *rx, unsigned n)
{
    wr(s, CSMODE, CSMODE_HOLD);   /* 바이트 사이에도 칩 선택을 붙들고 있는다 */
    for (unsigned i = 0; i < n; i++) {
        while (rd(s, TXDATA) & FIFO_FLAG) { }
        wr(s, TXDATA, tx ? tx[i] : 0x00);
        uint32_t v;
        while ((v = rd(s, RXDATA)) & FIFO_FLAG) { }
        if (rx) rx[i] = (uint8_t)v;
    }
}

void spi_release(const struct spi *s) { wr(s, CSMODE, CSMODE_AUTO); }
/* … */
/* SPI NOR 플래시의 명령은 첫 바이트 하나다.
   칩 선택이 내려가 있는 동안이 명령 하나다. */
void flash_jedec_id(const struct spi *s, uint8_t id[3])
{
    uint8_t cmd = 0x9F;                       /* READ ID: 제조사, 종류, 용량 */
    spi_xfer(s, &cmd, 0, 1);
    spi_xfer(s, 0, id, 3);
    spi_release(s);
}

void flash_read(const struct spi *s, uint32_t addr, uint8_t *dst, unsigned n)
{
    uint8_t cmd[4] = { 0x03, (uint8_t)(addr >> 16), (uint8_t)(addr >> 8),
                       (uint8_t)addr };
    spi_xfer(s, cmd, 0, 4);                   /* READ: 명령 하나 + 3바이트 주소 */
    spi_xfer(s, 0, dst, n);                   /* 이어서 n 바이트를 밀어 내온다 */
    spi_release(s);
}

실행 결과

== this machine: the commands the driver sends (trace backend) ==
W  0x10040060 <- 0x00000000
W  0x10040000 <- 0x00000003
W  0x10040004 <- 0x00000000
W  0x10040010 <- 0x00000000
W  0x10040040 <- 0x00080000
W  0x10040018 <- 0x00000002
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x0000009f
R  0x1004004c -> 0x00000000
W  0x10040018 <- 0x00000002
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x00000000
R  0x1004004c -> 0x0000009d
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x00000000
R  0x1004004c -> 0x00000070
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x00000000
R  0x1004004c -> 0x00000019
W  0x10040018 <- 0x00000000
W  0x10040018 <- 0x00000002
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x00000003
R  0x1004004c -> 0x00000000
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x00000000
R  0x1004004c -> 0x00000000
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x00000000
R  0x1004004c -> 0x00000000
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x00000000
R  0x1004004c -> 0x00000000
W  0x10040018 <- 0x00000002
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x00000000
R  0x1004004c -> 0x00000042
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x00000000
R  0x1004004c -> 0x0000004f
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x00000000
R  0x1004004c -> 0x0000004f
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x00000000
R  0x1004004c -> 0x00000054
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x00000000
R  0x1004004c -> 0x00000020
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x00000000
R  0x1004004c -> 0x0000004f
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x00000000
R  0x1004004c -> 0x0000004b
R  0x10040048 -> 0x00000000
W  0x10040048 <- 0x00000000
R  0x1004004c -> 0x0000000a
W  0x10040018 <- 0x00000000
#  JEDEC id 9d 70 19 (manufacturer 0x9d = ISSI)
#  first 8 bytes of the flash: BOOT OK

== QEMU sifive_u (emulated RISC-V): the same driver on the SPI controller and flash models ==
JEDEC id 9d 70 19 (manufacturer 0x9d = ISSI)
first 8 bytes of the flash: BOOT OK

출력에서 W 0x10040048 <- 0x00000000 이 되풀이되는 자리가 앞에서 말한 더미 바이트다. 플래시가 신분 세 바이트를 내놓게 하려면 주인이 세 바이트를 밀어 넣어야 한다. 반대로 명령 바이트 0x9f 를 보낼 때 밀려 나온 바이트는 버린다. 칩 선택 선은 명령 하나 동안 내려가 있어야 하므로, 드라이버는 CSMODE 를 HOLD(2)로 두었다가 명령이 끝나면 AUTO(0)로 되돌린다. QEMU 쪽의 첫 바이트 9d 는 ISSI 의 제조사 번호로, 이 기계 모형에 달린 플래시(IS25WP256)와 맞는다. 그다음 줄은 QEMU 에 플래시 내용으로 넘긴 파일의 첫 여덟 바이트다.

I2CSPI그래서
선2가닥(장치가 늘어도 그대로)3 + 장치마다 1가닥장치가 많으면 I2C
속도100 kHz ~ 1 MHz수십 MHz빨라야 하면 SPI
바이트당 비트9 (ACK 포함)8SPI 가 군더더기도 없다
주고받기반이중전이중동시에 주고받아야 하면 SPI
받았는지 확인ACK 가 있다없다SPI 는 상대가 죽어도 모른다
규격문서로 정해져 있다관습SPI 는 데이터시트를 꼭 읽어야 한다

표 115.16 — I2C 와 SPI 를 견주면

한 선으로도, 그리고 아무나 말해도 — 1-Wire 와 CAN#

이름선발상어디서
1-Wire1가닥(+접지)자료선의 펄스 길이로 0과 1을 나누고, 선에서 전원까지 훔쳐 쓴다온도 센서, 식별 칩
CAN2가닥(차동)아무나 말하고, 충돌하면 메시지 번호가 작은 쪽이 이긴다(중재)자동차, 산업 기계

표 115.17 — 특이한 두 연결 방식

★ CAN 의 중재가 I2C 의 「0 이 이긴다」와 같은 발상이다. 둘이 동시에 말하면 0 을 보낸 쪽의 신호가 선에 남고, 1 을 보낸 쪽은 자기가 보낸 것과 선의 상태가 다른 것을 보고 스스로 물러난다. 충돌을 감지해 다시 보내는 것이 아니라 충돌이 곧 우선순위 판정이다 — 그래서 급한 메시지가 늦어지지 않는다.

기계 안의 큰길 — PCI 와 PCI Express#

여기까지는 「칩과 칩을 잇는 선」이었다. 이제 규모가 달라진다. 그래픽 카드, 네트워크 카드, NVMe 저장 장치가 붙는 자리 — PCI 계열이다.

이름은 「버스」지만 오늘의 PCIe 는 사실 점대점 연결이다. 옛 PCI 는 정말로 여러 장치가 같은 선을 나눠 썼고(그래서 하나가 느리면 다 느려졌다), PCIe 는 장치마다 제 선을 갖고 스위치가 가운데서 이어 준다.

옛 PCIPCIe그래서
연결여러 장치가 한 버스를 공유장치마다 점대점 + 스위치한 장치가 남을 붙잡지 못한다
선병렬 32/64비트 + 클록직렬 차동 레인 ×1~×16그림 115.1의 결론 그대로
속도 올리기클록을 올린다(33 → 66 MHz)레인을 늘리거나 세대를 올린다늘리는 방향이 다르다
끼어들기INTA~D 선을 공유MSI/MSI-X — 기억 쓰기로 보낸다선이 아니라 자료가 된다

표 115.18 — PCI 와 PCI Express

세대선 위 속도부호 방식실효 대역폭
1.02.5 GT/s8b/10b (20% 손실)약 250 MB/s
2.05 GT/s8b/10b약 500 MB/s
3.08 GT/s128b/130b (1.5% 손실)약 985 MB/s
4.016 GT/s128b/130b약 1.97 GB/s
5.032 GT/s128b/130b약 3.94 GB/s

표 115.19 — PCIe 세대와 실효 속도(레인 하나 기준)

★ 「부호 방식」 열이 중요한 이유가 있다. 클록 선이 따로 없으므로 클록을 자료 속에 심어야 하고(그래야 오래 0만 이어져도 받는 쪽이 시간을 잃지 않는다), 그 심는 비용이 곧 손실이다. 3.0 에서 8b/10b 를 버리고 128b/130b 로 바꾼 것이 「속도는 1.6배인데 대역폭은 2배」가 된 까닭이다.

설정 공간 — 장치가 자기를 소개하는 256바이트#

PCI 의 가장 좋은 발상은 이것이다. 모든 장치가 같은 모양의 명함을 내민다. 그래서 운영체제는 무슨 장치가 꽂혔는지 몰라도 명함, 곧 설정 공간을 읽어 알아낼 수 있다.

오프셋크기이름무엇전형적인 값
0x002제조사 IDPCI-SIG 가 나눠 준 번호0x8086(인텔)
0x022장치 ID제조사가 정한 모델 번호—
0x042명령이 장치를 켜는 스위치 — IO·기억·버스 마스터0x0007
0x062상태오류와 「능력 목록이 있다」 표시0x0010
0x081개정같은 모델의 판—
0x09~0x0B3분류 코드무슨 종류의 장치인가 — 드라이버 없이도 안다02 00 00= 이더넷
0x0C1캐시 줄 크기옛 PCI 의 흔적0x10
0x0E1머리말 종류0=장치, 1=브리지, 0x80 이면 기능 여럿0x00
0x10~0x2424BAR(base address register) 0~5이 장치가 요구하는 주소 창 (표 115.21)—
0x2C~0x2E4하위 시스템 ID보드를 만든 회사·모델—
0x304확장 ROM카드에 든 펌웨어의 자리—
0x341능력 목록 시작연결 목록의 첫 오프셋0x50
0x3C1인터럽트 선옛 방식에서 어느 IRQ 에 연결되었나11
0x3D1인터럽트 핀INTA~D 중 어느 것1(A)

표 115.20 — PCI 설정 공간 머리말(종류 0)의 주요 필드

비트값뜻메모
00기억 공간보통 이쪽 — 포인터로 닿는다
01입출력 공간x86 의 좁은 64 KiB 세계
2~10032비트 주소
2~11064비트 주소다음 BAR 가 상위 32비트를 맡는다 — 두 칸을 먹는다
31미리 가져와도 됨읽어도 부작용이 없다는 뜻 — 캐시·묶음 전송이 가능
나머지주소기준 주소낮은 비트는 크기만큼 0 으로 굳어 있다

표 115.21 — BAR 의 낮은 비트가 종류를 말한다

아래 드라이버는 펌웨어가 부팅 때 하는 열거(enumeration, 꽂힌 장치를 모두 찾아 주소를 나눠 주는 일)를 줄인 것이다. QEMU virt 기계 모형의 PCIe 를 ECAM(설정 공간을 메모리 주소에 펼쳐 둔 창)으로 훑고, 찾은 장치마다 BAR 의 크기를 재어 주소를 나눠 주고, 능력 목록을 따라간다. QEMU 에는 장치 둘을 더 꽂았다 — QEMU 의 교육용 장치 edu 와 virtio 난수 발생기다.

examples/apx-links/pci_enum/pci.c (발췌)

/* ECAM: 버스 · 장치 · 기능 · 레지스터 번호를 비트 자리에 놓으면 그대로 주소가 된다.
   기능 하나에 4 KiB, 장치 하나에 기능 여덟, 버스 하나에 장치 서른둘. */
static uintptr_t cfg_addr(const struct pci_host *h, unsigned bus, unsigned dev,
                          unsigned fn, unsigned off)
{
    return h->ecam + ((uintptr_t)bus << 20 | (uintptr_t)dev << 15
                      | (uintptr_t)fn << 12 | off);
}
/* … */
/* BAR 의 크기는 장치에게 물어볼 수 없다. 전부 1 을 써 보고, 장치가 0 으로 굳혀 둔
   아래 자리를 되읽어 알아낸다. 64비트 BAR 는 다음 칸이 위 32비트다. */
static unsigned size_bar(struct pci_host *h, struct pci_fn *f, unsigned i)
{
    unsigned off = BAR0 + 4 * i;
    uint32_t orig = pci_read32(h, f->bus, f->dev, f->fn, off);
    if (orig & 1) return 1;                         /* 입출력 공간 BAR 는 건너뛴다 */
    int is64 = (orig >> 1 & 3) == 2;

    pci_write32(h, f->bus, f->dev, f->fn, off, 0xFFFFFFFFu);
    uint64_t mask = pci_read32(h, f->bus, f->dev, f->fn, off) & ~0xFull;
    if (is64) {
        pci_write32(h, f->bus, f->dev, f->fn, off + 4, 0xFFFFFFFFu);
        mask |= (uint64_t)pci_read32(h, f->bus, f->dev, f->fn, off + 4) << 32;
    } else {
        mask |= 0xFFFFFFFF00000000ull;
    }
    if ((uint32_t)mask == 0) {                      /* 굳힌 비트가 없다: BAR 가 없다 */
        pci_write32(h, f->bus, f->dev, f->fn, off, orig);
        return is64 ? 2 : 1;
    }
    uint64_t size = ~mask + 1;                      /* 굳힌 자리의 가장 낮은 1 */

    /* 넷째 걸음: BAR 에 주소를 되돌려 쓴다. 펌웨어인 여기서는 크기로 줄을 맞춰 창에서
       떼어 준 새 주소를 쓴다(운영체제라면 펌웨어가 준 원래 값을 되돌린다).
       이 주소가 곧 장치 레지스터의 주소가 된다. */
    uint64_t at = (h->mem_next + size - 1) & ~(size - 1);
    if (at + size > h->mem_end) at = 0;             /* 창이 모자라면 주지 않는다 */
    else h->mem_next = at + size;
    pci_write32(h, f->bus, f->dev, f->fn, off, (uint32_t)at);
    if (is64) pci_write32(h, f->bus, f->dev, f->fn, off + 4, (uint32_t)(at >> 32));
    f->bar[i] = at;
    f->bar_size[i] = size;
    return is64 ? 2 : 1;
}
/* … */
int pci_scan(struct pci_host *h,
             void (*visit)(const struct pci_host *, const struct pci_fn *))
{
    int found = 0;
    for (unsigned dev = 0; dev < 32; dev++) {
        for (unsigned fn = 0; fn < 8; fn++) {
            uint32_t id = pci_read32(h, 0, dev, fn, ID);
            if ((id & 0xFFFF) == 0xFFFF) {          /* 아무도 대답하지 않으면 전부 1 */
                if (fn == 0) break;
                continue;
            }
            struct pci_fn f = { .bus = 0, .dev = dev, .fn = fn,
                                .vendor = (uint16_t)id, .device = (uint16_t)(id >> 16),
                                .class_code = pci_read32(h, 0, dev, fn, CLASS) >> 8 };
            uint32_t hdr = pci_read32(h, 0, dev, fn, HDR) >> 16 & 0xFF;
            uint32_t cmd = pci_read32(h, 0, dev, fn, CMD) & 0xFFFF;
            /* 크기를 재는 동안 BAR 에는 엉뚱한 값이 들어간다. 그동안 장치가 그 주소에
               대답하지 않도록 기억 · 입출력 해독을 꺼 둔다. */
            pci_write32(h, 0, dev, fn, CMD, cmd & ~(uint32_t)(CMD_MEM | CMD_IO));
            if ((hdr & 0x7F) == 0)                  /* 보통 장치(다리가 아니다) */
                for (unsigned i = 0; i < 6; )
                    i += size_bar(h, &f, i);
            pci_write32(h, 0, dev, fn, CMD, cmd | CMD_MEM | CMD_MASTER);
            visit(h, &f);
            found++;
            if (fn == 0 && !(hdr & 0x80)) break;    /* 기능이 하나뿐인 장치 */
        }
    }
    return found;
}
/* … */
/* 능력 목록: 상태 레지스터의 비트 4 가 서 있으면 0x34 에 첫 항목의 자리가 있다.
   항목마다 [종류 1바이트][다음 자리 1바이트] 로 시작하는 연결 리스트다. */
unsigned pci_caps(const struct pci_host *h, const struct pci_fn *f, uint8_t *ids,
                  unsigned max)
{
    if (!(pci_read32(h, f->bus, f->dev, f->fn, CMD) >> 16 & 0x10)) return 0;
    unsigned n = 0;
    unsigned at = pci_read32(h, f->bus, f->dev, f->fn, 0x34) & 0xFC;
    while (at && n < max) {                         /* max 가 고리를 끊는다 */
        uint32_t w = pci_read32(h, f->bus, f->dev, f->fn, at);
        ids[n++] = (uint8_t)w;
        at = w >> 8 & 0xFC;
    }
    return n;
}

실행 결과

== this machine: the commands the driver sends (trace backend) ==
R  0x30000000 -> 0x11e81234
R  0x30000008 -> 0x00ff0010
R  0x3000000c -> 0x00000000
R  0x30000004 -> 0x00100000
W  0x30000004 <- 0x00000000
R  0x30000010 -> 0x00000000
W  0x30000010 <- 0xffffffff
R  0x30000010 -> 0xfff00000
W  0x30000010 <- 0x40000000
R  0x30000014 -> 0x00000000
W  0x30000014 <- 0xffffffff
R  0x30000014 -> 0x00000000
W  0x30000014 <- 0x00000000
R  0x30000018 -> 0x00000000
W  0x30000018 <- 0xffffffff
R  0x30000018 -> 0x00000000
W  0x30000018 <- 0x00000000
R  0x3000001c -> 0x00000000
W  0x3000001c <- 0xffffffff
R  0x3000001c -> 0x00000000
W  0x3000001c <- 0x00000000
R  0x30000020 -> 0x00000000
W  0x30000020 <- 0xffffffff
R  0x30000020 -> 0x00000000
W  0x30000020 <- 0x00000000
R  0x30000024 -> 0x00000000
W  0x30000024 <- 0xffffffff
R  0x30000024 -> 0x00000000
W  0x30000024 <- 0x00000000
W  0x30000004 <- 0x00000006
R  0x30000004 -> 0x00100000
R  0x30000034 -> 0x00000040
R  0x30000040 -> 0x00000005
#  00:00.0  1234:11e8  class 00ff00  caps MSI
#     BAR0 at 0x40000000, 1024 KiB
R  0x40000000 -> 0x010000ed
W  0x40000008 <- 0x00000005
R  0x40000020 -> 0x00000001
R  0x40000020 -> 0x00000000
R  0x40000008 -> 0x00000078
#     edu id register 0x010000ed, 5! = 120
R  0x30008000 -> 0xffffffff
R  0x30010000 -> 0xffffffff
   ... 29 more like this, every 0x8000, up to 0x300f8000
#  functions found: 1
W  0x00100000 <- 0x00005555

== QEMU virt (emulated RISC-V): the same driver on the PCIe host bridge model ==
00:00.0  1b36:0008  class 060000
00:01.0  1234:11e8  class 00ff00  caps MSI
   BAR0 at 0x40000000, 1024 KiB
   edu id register 0x010000ed, 5! = 120
00:02.0  1af4:1005  class 00ff00  caps MSI-X vendor vendor vendor vendor vendor
   BAR1 at 0x40100000, 4 KiB
   BAR4 at 0x40104000, 16 KiB
functions found: 3

시연의 가운데 대목은 BAR 의 크기를 알아내는 과정이고, 이 규격에서 가장 영리한 부분이다. 장치에게 크기를 묻는 자리가 따로 없다. 대신 BAR 에 1을 가득 써 보고 되읽으면, 장치가 0 으로 굳혀 둔 낮은 비트들이 크기를 알려 준다. 네 걸음 — 쓴다, 되읽는다, 뒤집어 1을 더한다, 주소를 되돌려 쓴다 — 이 장치 열거의 핵심이다. 넷째 걸음에서 운영체제는 펌웨어가 준 원래 값을 되돌리고, 펌웨어인 이 드라이버는 크기로 줄을 맞춰 떼어 준 새 주소를 쓴다. 이 걸음을 빠뜨리면 장치가 전부 1 인 엉뚱한 주소에 앉는다. 재는 동안에는 명령 레지스터에서 기억 · 입출력 해독을 꺼 둔다(출력의 W 0x30000004 <- 0x00000000).

QEMU 쪽 출력이 그 주소가 진짜임을 보인다. edu 의 BAR0 에 나눠 준 0x40000000 에 5 를 쓰자 장치가 5! = 120 을 돌려주었다. virtio 장치의 BAR4 는 64비트 BAR 라 두 칸을 먹었고, 16 KiB 짜리라 16 KiB 경계인 0x40104000 에 놓였다. 그 장치의 능력 목록에 찍힌 vendor 다섯은 제조사 고유 능력으로, virtio 가 제 설정 창들의 자리를 알리는 방법이다. 이 기계 쪽 절반에서 장치 0 뒤의 읽기 서른한 번이 모두 0xffffffff 인 것은 「그 자리에 아무도 없다」는 PCI 의 대답이다(대답 표는 장치 하나만 꾸며 두었다).

문. lspci 가 보여 주는 것이 결국 무엇인가?

답. 이 명함(설정 공간)을 읽어서 사람이 읽을 말로 바꾼 것이다. 제조사·장치 ID 는 숫자일 뿐이고, 이름은 pci.ids 라는 목록 파일에서 찾아 붙인다. 그래서 새 장치를 옛 시스템에 꽂으면 「Unknown device 8086:10fa」처럼 나온다 — 장치가 고장난 것이 아니라 이름표 목록이 낡은 것이다.

밖으로 나가는 큰길 — USB#

USB 의 설계 원칙은 한 문장이다. 호스트가 전부 시킨다. 장치는 스스로 말을 걸 수 없고, 호스트가 물어볼 때만 대답한다.

이름세대선 위 속도쓰는 곳
Low Speed1.x1.5 Mbit/s마우스·키보드
Full Speed1.x12 Mbit/s오디오·간단한 장치
High Speed2.0480 Mbit/s저장 장치·카메라
SuperSpeed3.x5 ~ 20 Gbit/s외장 SSD·화면

표 115.22 — USB 속도 등급

이름보장하는 것쓰는 곳메모
제어(control)반드시 전달장치를 알아보고 설정할 때모든 장치가 갖춰야 한다
벌크(bulk)반드시 전달, 시간은 보장 안 함저장 장치·프린터남는 대역을 쓴다
인터럽트(interrupt)정해진 주기 안에 전달마우스·키보드이름과 달리 끼어들기가 아니다 — 호스트가 주기적으로 물어본다
등시성(isochronous)시간은 보장, 전달은 보장 안 함오디오·영상늦은 자료는 쓸모가 없으니 재전송하지 않는다

표 115.23 — USB 의 전송 네 가지

★ 셋째 줄의 이름이 이 부록 전체에서 가장 헷갈리는 낱말이다. USB 의 「인터럽트 전송」은 장치가 끼어드는 것이 아니다. 호스트가 「할 말 있니?」를 장치가 정한 주기(이를테면 1 ms)마다 물어보는 것이고, 장치는 그때만 대답한다. 진짜 끼어들기는 다음 절의 IRQ 다.

흔한 오해. USB 장치를 꽂으면 장치가 컴퓨터에게 말을 건다

장치는 전기적으로 「나 붙었다」만 알린다(선의 전압이 바뀐다). 그 뒤로는 전부 호스트가 시킨다 — 주소를 정해 주고, 명함(디스크립터)을 달라 하고, 설정을 고른다. 그래서 USB 장치는 혼자서는 아무것도 못 한다. 이 단순한 규칙 덕에 값싼 장치를 만들 수 있었고, 그 대가로 호스트 쪽이 복잡해졌다.

끼어들기 — IRQ#

지금까지는 「어떻게 옮기는가」였다. 이제 「누가 언제 말을 거는가」다. 장치가 CPU 에게 「나 일 생겼다」고 알리는 선이 IRQ(interrupt request)다.

91장과 「OS 없이 도는 C」 부록이 처리기 안에서 무엇을 할 수 있는가를 다뤘다면, 여기서는 그 앞단 — 선과 그 값 — 을 본다.

에지(edge)레벨(level)그래서
무엇이 신호인가선이 바뀌는 순간선이 낮게(또는 높게) 있는 동안
놓칠 수 있나있다 — 순간을 못 보면 끝없다 — 아직 그대로 있다레벨이 안전하다
지우는 법저절로 지워진다장치의 레지스터를 읽어 지워야 한다안 지우면 무한히 다시 걸린다
여럿이 함께 쓰기어렵다 — 동시에 오면 하나로 보인다쉽다 — 누가 아직 잡고 있는지 물어본다공유 IRQ 는 레벨이 정석

표 115.24 — 에지 방식과 레벨 방식

★ 셋째 줄이 「OS 없이 도는 C」 부록에서 본 그 함정이다. 레벨 방식에서 처리기가 장치의 상태 레지스터를 읽지 않으면 요청이 계속 걸려 있고, 프로그램은 처리기 안에서 영원히 맴돈다. 그리고 그 읽기를 컴파일러가 지워 버리지 못하게 하는 낱말이 volatile 이다.

방식어떻게장치 수그래서
전용 선장치마다 IRQ 선 한 가닥선의 개수만큼핀이 모자란다
공유 선여러 장치가 한 선을 함께 쓴다여럿누가 걸었는지 다 물어봐야 한다
MSI끼어들기를 약속된 주소에 값을 쓰는 것으로 대신사실상 무제한선이 없으니 공유 문제도 없다
MSI-XMSI 를 늘려 큐마다 다른 번호수천큐마다 다른 코어가 받게 나눌 수 있다

표 115.25 — 끼어들기를 전달하는 방식의 변천

문. 끼어들기를 「기억에 값을 쓰는 것」으로 어떻게 대신하는가?

답. PCIe 에는 어차피 자료를 나르는 길이 있다. 그러니 장치가 「특별한 주소」에 정해진 값을 한 번 쓰면, 그 쓰기를 인터럽트 제어기가 받아 CPU 를 깨우는 것으로 약속하면 된다. 선 한 가닥을 아끼는 정도가 아니라, 끼어들기마다 번호를 붙일 수 있게 되는 것이 진짜 이득이다 — 그래서 「어느 큐가 찼는가」를 묻지 않고도 안다.

examples/apx-links/signal_cost.c (발췌)

static volatile sig_atomic_t hits;
static void handler(int sig) { (void)sig; hits++; }
/* … */
double t0 = now_ns();
for (long i = 0; i < N; i++) raise(SIGUSR1);
double t1 = now_ns();
double per_signal = (t1 - t0) / (double)N;

t0 = now_ns();
for (long i = 0; i < N; i++) plain_p();
t1 = now_ns();
double per_call = (t1 - t0) / (double)N;

실행 결과

== measured on this machine (mean of 200000) ==
  one ordinary function call     :      2.3 ns
  raising and handling one signal:   3116.2 ns
  ratio                          :   1337.4 times

  the handler ran 201000 times (a check that none were missed)

== what this number tells you ==
  that one interruption is not free, and that it costs far more than an
  ordinary function call --- because each interruption must put the state of
  the work somewhere, go to the handler, and take it back out again.

  from it, how many per second can be borne:
  events/s         CPU used         note
  1000             0.31%            comfortable
  10000            3.12%            comfortable
  100000           31.16%           workable, but the margin is thin
  1000000          311.62%          impossible --- it would do nothing but handle them

  * again, this is the cost of an operating system signal. A hardware interrupt
    is usually far cheaper (a few hundred nanoseconds). But the *shape* of the
    arithmetic is the same --- learning that shape is the point of this demonstration.

★ 위 시연은 하드웨어 인터럽트가 아니라 운영체제 신호를 잰 것이다(그 사실을 시연이 스스로 밝힌다). 하드웨어 IRQ 는 대개 이보다 훨씬 싸다. 그런데도 이 수가 쓸모 있는 이유는 계산의 모양이 같기 때문이다 — 「사건 하나의 비용 × 초당 사건 수 = CPU 점유」.

대신 옮기기 — DMA#

바이트마다 끼어들 수 없다면 어떻게 하는가. 답은 「CPU 를 빼는 것」이다.

dma-path

그림 115.2 — CPU 가 직접 나르는 길과 DMA 가 나르는 길.

방식어떻게맞바꿈
단일 전송한 번에 한 워드씩, 사이사이 CPU 에게 버스를 돌려준다CPU 가 덜 막히지만 느리다
버스트정해진 개수를 몰아서빠르지만 그동안 CPU 가 기다린다
스캐터-개더「여기 4 KiB, 저기 8 KiB」를 목록으로 주고 알아서 나르게흩어진 기억을 한 번에 — 목록 자체가 자료 구조가 된다

표 115.26 — DMA 가 버스를 쓰는 방식

문제무엇인가어떻게 푸나
캐시 일관성DMA 는 기억에 쓰는데 CPU 는 캐시를 본다읽기 전에 무효화, 보내기 전에 비우기. 또는 캐시를 안 쓰는 구역
주소의 종류장치가 보는 주소와 CPU 가 보는 주소가 다를 수 있다드라이버가 「장치용 주소」로 변환해서 넘긴다
보안·격리장치가 아무 기억이나 읽고 쓸 수 있다IOMMU 가 장치별로 볼 수 있는 자리를 제한한다

표 115.27 — DMA 를 쓸 때 걸리는 세 가지

★ 첫 줄이 「OS 없이 도는 C」 부록의 volatile 이야기와 정확히 이어진다. volatile 은 컴파일러를 붙잡을 뿐 캐시를 어쩌지 못한다. 층이 다르면 막는 수단도 달라야 한다.

반례. DMA 버퍼를 스택에 잡는다

스택의 지역 배열을 DMA 대상으로 넘기면 두 가지가 겹쳐 터진다. 함수가 먼저 돌아가면 그 자리는 다른 것이 쓰는 자리가 되고(45장), 캐시에 걸린 자리라 장치가 쓴 값과 CPU 가 보는 값이 갈릴 수 있다. DMA 버퍼는 오래 사는 자리에, 정렬을 맞춰, 드라이버가 주는 방법으로 잡는다.

셋을 견주면 — 폴링, 인터럽트, DMA#

아래 시연은 폴링·인터럽트·DMA 가 CPU 를 얼마나 쓰는지 견주는 계산이고, 이 계산이 이 부록의 결론에 해당한다. 폴링(polling)은 CPU 가 장치에 일이 있는지 되풀이해 묻는 방식이다.

examples/apx-links/link_budget.c (발췌)

static void budget(struct link L, double dma_block)
{
    double irq_rate  = L.bytes_per_sec;                  /* 바이트마다 끼어들기 */
    double irq_busy  = irq_rate * IRQ_COST_US / 1e6 * 100.0;
    double dma_rate  = L.bytes_per_sec / dma_block;      /* 덩어리마다 한 번 */
    double dma_busy  = dma_rate * IRQ_COST_US / 1e6 * 100.0;

    char a[24], b[24];
    snprintf(a, sizeof a, "%.1f%%", irq_busy);
    snprintf(b, sizeof b, "%.3f%%", dma_busy);
    printf("  %-22s %12.0f %10s %12.0f %10s\n", L.name, irq_rate, a, dma_rate, b);
}

실행 결과

== assumptions (the values used) ==
  handling one interrupt    : 1.0 microseconds
  one poll (read and judge) : 0.2 microseconds
  DMA batch size            : 256 bytes
  * all three differ by machine. Change them and the conclusion changes --- that is the point.

== an interrupt per byte vs DMA in 256-byte batches ==
  link                          IRQ/s        CPU    DMA IRQ/s        CPU
  UART 9600 8N1                   960       0.1%            4     0.000%
  UART 115200 8N1               11520       1.2%           45     0.005%
  I2C 400 kHz                   44000       4.4%          172     0.017%
  SPI 10 MHz                  1250000     125.0%         4883     0.488%
  USB 2.0 (effective)        30000000    3000.0%       117188    11.719%
  1 Gbit Ethernet           118000000   11800.0%       460938    46.094%

  * reading the table downwards shows when DMA becomes necessary.
    on a slow link an interrupt per byte is no trouble; past some point
    that scheme leaves the machine doing nothing else.

== where the crossover is (assuming an interrupt per byte) ==
  CPU used       events/s         the data rate that means
  5%             50000            48.8 KiB/s
  10%            100000           97.7 KiB/s
  20%            200000           195.3 KiB/s
  40%            400000           390.6 KiB/s
  80%            800000           781.2 KiB/s

== the three schemes compared ==
  polling   : the CPU keeps asking.      latency = about half the period.
              suits very frequent events, or very simple places.
  interrupt : it wakes only when there is work. latency = the cost of waking.
              suits events that come now and then.
  DMA       : it wakes only when a batch ends. latency = until the batch fills.
              suits data that is large and steady.

== there are places where polling is better ==
  poll rate        CPU used       mean latency
  1000        /s 0.02%          500.0 microseconds
  10000       /s 0.20%          50.0 microseconds
  100000      /s 2.00%          5.0 microseconds
  1000000     /s 20.00%         0.5 microseconds
  * when events are very frequent polling wins --- there is always work, so
    there is no reason to pay the cost of waking each time. That is why fast
    network drivers switch to polling under load (Linux NAPI).

시연의 계산에서 세 방식을 고르는 기준 셋을 읽는다.

첫째, 「좋은 방식」은 없고 「이 속도에 맞는 방식」이 있다. 9600 보율 UART 는 초당 960 바이트뿐이라 바이트마다 끼어들어도 CPU 의 0.1%면 된다. 같은 방식을 1 Gbit 이더넷에 쓰면 CPU 가 118배 모자란다.

둘째, DMA 는 「옮기는 일」을 없애는 것이 아니라 「끼어드는 횟수」를 줄이는 것이다. 256바이트씩 묶으면 사건이 256분의 1 이 된다. 대신 덩어리가 찰 때까지 기다려야 하므로 지연은 늘어난다 — 그래서 마감이 짧은 자료에는 덩어리를 작게 잡는다.

셋째, 사건이 아주 잦으면 폴링이 이긴다. 어차피 늘 일이 있는데 매번 「깨는 비용」을 치를 이유가 없기 때문이다. 리눅스의 네트워크 드라이버가 부하가 높을 때 인터럽트를 끄고 폴링으로 바꾸는 것(NAPI)이 그 예다. 세 방식은 사다리가 아니라 부하에 따라 오가는 자리다.

폴링인터럽트DMA
CPU 가 하는 일계속 물어본다일이 있을 때만 깬다시작과 끝만
지연묻는 주기의 절반쯤깨는 비용만큼덩어리가 찰 때까지
사건이 드물면낭비다맞다지나치다
사건이 잦으면맞다기계가 그 일만 한다맞다
자료가 크면바이트마다 CPU 를 거친다〃맞다 — CPU 를 안 거친다

표 115.28 — 폴링·인터럽트·DMA 를 한눈에

여기서 남기는 것#

복습 정리

  • 연결은 네 가지만 물으면 정리된다 — 선의 수, 클록, 주소, 누가 시작하는가.
  • 빨라질수록 병렬이 진다. 오늘의 답은 아주 빠른 직렬 선을 여러 벌 묶는 것이다.
  • UART 는 클록 없이 약속된 모양으로 글자를 오려 낸다. 그 대가가 보율 오차 예산이고, 이론 한계(약 5%)와 설계 규칙(2~3%)은 다른 수다.
  • I2C 는 선 둘로 장치 여럿을 잇는다 — 「끌어내리기만 하는」 설계가 ACK 와 중재를 공짜로 만든다.
  • SPI 는 시프트 레지스터 두 개의 고리다. 그래서 전이중이 공짜이고, 모드가 어긋나면 값이 전부 한 칸 밀린다.
  • PCI 의 발상은 모든 장치가 같은 모양의 명함을 내미는 것이다. 크기조차 「1을 써 보고 되읽어」 알아낸다.
  • USB 는 호스트가 전부 시킨다. 그 「인터럽트 전송」은 끼어들기가 아니라 주기적 질문이다.
  • 폴링·인터럽트·DMA 는 사다리가 아니라 부하에 따라 고르는 세 자리다. 고르는 방법은 취향이 아니라 계산이다 — 사건 하나의 비용 × 초당 사건 수.