이산 논리로 10BASE-T 네트워크 카드 제작
(qdiv.dev)- 상용 CPU나 전용 네트워크 칩 없이 만든 이산 논리 컴퓨터에 10BASE-T Ethernet 송수신 기능을 붙인 하드웨어 제작기임
- 이전에 만든 10BASE-T↔SPI 물리 계층 어댑터 위에 MAC 계층 모듈을 더해 homebrew computer와 연결하고, 송신기와 수신기는 독립적인 풀듀플렉스 구조로 구성됨
- 수신기는 SPI 데이터를 바이트로 변환해 2 kB SRAM에 저장하고, 처음 6바이트를 하드웨어에서 검사해
FE:FA:F6:F2:EE:EA또는 브로드캐스트 MAC만 받음 - 송신기는 회로를 단순화하기 위해 FCS 생성과 프리앰블 준비를 소프트웨어에 맡기고, 1024바이트 고정 길이 프레임만 지원함
- uIP 1.0을 컴파일할 수 있는 C 컴파일러까지 만들어 네트워크 앱을 실행했으며, 결과는 ping 평균 85 ms와 HTTP 정적 파일 다운로드 2.6 kB/s 수준임
이산 논리 컴퓨터에 Ethernet 붙이기
- 완전한 컴퓨터 시스템을 이산 논리 부품으로 만드는 작업의 연장선에서, 네트워크 애플리케이션을 실행할 수 있는 Ethernet 어댑터를 구현함
- 이전에는 10BASE-T Ethernet 신호를 SPI로 변환하고 되돌리는 물리 계층 어댑터를 만들었고, 당시 동작 테스트에는 STM32 마이크로컨트롤러를 사용함
- 이번 작업의 핵심은 그 어댑터를 homebrew computer에 연결하기 위한 MAC 계층 모듈임
- 어댑터는 풀듀플렉스 구조이며, 송신부와 수신부가 서로 독립적으로 동작함
수신기: SPI 데이터를 프레임 버퍼로 저장
- 수신기는 SPI 직렬 데이터를 바이트 단위 병렬 데이터로 바꾸고, 바이트 클록을 추출함
- 목적지 MAC 주소는 처음 6바이트에서 검사하며, 기준에 맞지 않는 프레임은 거부됨
- 받은 바이트는 6116 2 kB SRAM 버퍼에 기록됨
- 프레임이 끝나면 수신기가 비활성화되고, 다시 활성화되기 전까지 추가 프레임을 받지 않음
- 바이트 카운터는 멈춘 뒤에도 값을 유지해 CPU가 수신 길이를 읽을 수 있음
- FCS는 하드웨어에서 검사하지 않음
-
데이터 수집과 버퍼 접근
- SPI 직렬 데이터는 시프트 레지스터
U32로 들어가며,U30과U31이 각각 비트와 바이트를 셈 - D 플립플롭
U29B가 SRAM 쓰기 신호recv_buf_we를 만들고, 이 신호는 입력 데이터 8비트마다 잠깐 낮아짐 - 수신된 바이트는
U20인 6116 SRAM에 기록됨 U13,U16,U18은 주소 멀티플렉서를 구성해 SRAM 주소 입력으로 바이트 카운터 또는 시스템 주소 버스를 선택함U21은 수신 바이트를 RAM으로 전달하는 3상태 버퍼 역할을 함- CPU가 수신 데이터와 길이에 접근할 수 있도록 RAM과 바이트 카운터가 시스템 데이터 버스에 연결됨
U25는 수신 RAM을 시스템 데이터 버스에 연결함- 프레임 완료 뒤 바이트 카운터 값은
recv_byte_cnt버스에 유지됨 U26,U27은 특정 주소 읽기 요청이 들어오면 이 값을 시스템 데이터 버스로 전달함U27의 나머지 절반은 수신기와 송신기 상태를 조회하는 2비트 읽기 전용 상태 레지스터를 만듦
- SPI 직렬 데이터는 시프트 레지스터
하드웨어 MAC 주소 필터링
- Ethernet 트래픽을 분석해 보니 프레임은 보통 짧은 지연으로 구분된 3~4개 단위의 작은 묶음으로 들어왔고, 같은 묶음 안에서도 목적지 MAC 주소가 다른 경우가 많았음
- 컴퓨터가 소프트웨어로 MAC 필터링을 수행하고 수신기를 다시 활성화하기에는 충분히 빠르지 않을 수 있어, 하드웨어 MAC 필터링이 필요했음
- 사용자 지정 MAC 주소를 저장한 뒤 처음 6바이트와 비교하는 방식은 너무 복잡해 제외함
- 단일 바이트 반복 MAC 주소도 가능했지만, 최종적으로 바이트 인덱스의 함수로 MAC 주소를 만듦
- bit 0은 0으로 고정
- bit 1은 1로 고정
- bit 2~4는 바이트 인덱스의 반전값
- bit 5~7은 1로 고정
- 이 규칙으로 만들어진 MAC 주소는
FE:FA:F6:F2:EE:EA임 - ARP 동작을 위해 브로드캐스트 MAC
FF:FF:FF:FF:FF:FF도 받음 U33은 데이터 bit 0과 bit 2~4가 원하는 값과 맞는지 비교하고,U34A출력은 해당 비트들이 일치할 때 높아짐U35A는 브로드캐스트 MAC 검사를 구현하며, bit 0과 bit 2~4가 모두 1이면 출력이 높아짐- 두 신호는
D7과R6을 이용한 다이오드 OR로 결합되고,U35B가 나머지 비트가 모두 1인지 검사함 - 단일 바이트의 유효성 결과는
U10A에 누적됨- 프레임을 받지 않을 때 incoming SPI slave select 신호
ss가 낮고U10A는 1로 설정됨 - 프레임 수신 중에는 각 수신 바이트마다 값이 갱신됨
- 목적지 MAC 주소가 기준에 맞으면
U10A값은 높게 유지됨 - 바이트 주소가 5에 도달하면 최종 값이
U36B에 래치되고, 목적지 주소가 맞지 않으면 프레임 수신이 막힘
- 프레임을 받지 않을 때 incoming SPI slave select 신호
송신기: 고정 길이 프레임으로 회로 단순화
- 송신기도 수신기와 마찬가지로 FCS 생성을 하드웨어에서 구현하지 않고 소프트웨어에서 처리함
- 회로를 줄이기 위해 송신기는 고정 길이 프레임만 지원함
- 프레임 길이는 1024바이트로 선택했으며, 일반적인 MTU 1500바이트에 가까운 값임
- 10BASE-T에 필요한 프리앰블은 여러 개의
0x55와 끝의0xD5로 구성되며, 소프트웨어가 이 1024바이트 안에 함께 로드해야 함 - 고정 프레임 길이는 상위 프로토콜에 영향을 주지 않음
- 상위 프로토콜은 패킷 크기를 헤더에 인코딩함
- 실제 Ethernet 프레임 길이에 의존하지 않음
-
송신 데이터 흐름
- 송신 데이터는 SRAM에 저장됨
- 20 MHz 클록이 4비트 카운터에 입력되고, 오버플로 출력이 바이트 클록으로 사용됨
- 특정 쓰기 전용 메모리 위치에 값을 쓰면 카운터가 활성화되어 프레임 송신이 시작됨
- 병렬 바이트 데이터는 시프트 레지스터를 통해 직렬화됨
- 수신기와 마찬가지로
U12가 비트를 세고U14가 바이트를 셈 - 20 MHz 클록은 통합 오실레이터에서 오며, 직접 사용하지 않고 최소 2분주해서 사용함
- 이 방식은 오실레이터의 듀티 사이클이 출력 신호에 영향을 주지 않게 함
-
RAM, 시프트 레지스터, 타이밍
- RAM
U22의 주소 입력 선택에는 수신기와 같이 세 개의 74HC157 멀티플렉서가 사용됨 U23은 RAM에 데이터를 로드하는 데 사용됨U24는 현재 송신 중인 바이트의 중간 저장소 역할을 함- 바이트 카운터 74HC4040은 리플 카운터라 안정화가 느림
- RAM 출력이 아직 유효하지 않은 동안
U24가 안정적인 출력을 제공함 - 데이터는 시프트 레지스터
U28로 들어가 비트 단위로 이동함 - RAM에서 시프트 레지스터로 들어가는 비트 순서를 잘못 연결한 하드웨어 버그가 있어, 소프트웨어에서 비트를 섞어 우회해야 했음
MOSI와SCK는 좋은 10BASE-T 신호를 만들기 위해 정확히 동기화되어야 함U11A와U8B가 이 동기화를 처리함tx_cnt0은 비트 카운터의 bit 0이며, 20 MHz를 2분주한 신호로 클록에 사용됨U11A는 이 신호에 맞춰 출력을 바꿈U8B는U11A가 만든 지연에 맞추기 위해 클록을 지연함- D 래치는 단순 AND 게이트보다 복잡하고 약 5 ns 더 큰 지연이 있어, 더 빠른 74LV74A를 사용함
- 74LV74A는 이 보드에서 유일하게 빠른 계열의 칩임
- RAM
CPU 인터페이스와 메모리 매핑
- 프로그래머 관점에서 Ethernet 어댑터는 메모리 매핑된 인터페이스로 보임
- 두 프레임 버퍼는
0xF000에 매핑됨 - 읽기 전용 레지스터는 두 개임
0xFB00의 8비트 상태 레지스터는RX_FULL과TX_BUSY플래그를 가짐RX_FULL은 프레임 수신 완료 상태를 나타냄TX_BUSY는 프레임 송신 중 상태를 나타냄0xFB02의 16비트 레지스터는 수신 데이터 길이를 담음
- 쓰기 동작은 제어 명령으로 사용됨
0xFB00에 아무 값이나 쓰면 수신기를 다시 활성화함0xFB01에 아무 값이나 쓰면 송신을 시작함
- CPU가 인터럽트를 지원하지 않아 인터럽트는 없음
- 관련 주소는 모두 상위 4비트가 1인
F로 시작하며, 이 조건은U2A가 검사함 - 버퍼 주소는 bit 11이 0이어야 하며,
U1D,D2,R2,U1E가 이를 검사함 - 레지스터 주소는 두 번째 16진수 자리가
B인1011이어야 하며,U1B와U2B가 확인함 U4A,U4B디코더는 개별 기능 선택에 사용됨- LED 두 개는 버퍼 또는 레지스터 접근을 표시함
프로그래밍과 성능
- 네트워크 지원은 원했지만 TCP/IP 스택을 직접 구현하고 싶지는 않았고, 어셈블리 프로그래밍도 불편했기 때문에 C 컴파일러를 만듦
- 이 컴파일러는 작은 TCP/IP 라이브러리인 uIP 1.0을 컴파일할 수 있을 만큼 성숙함
- CPU의 코드 밀도는 매우 낮지만, uIP는 RAM에 들어가고 실제 애플리케이션을 위한 공간도 남음
- 네트워크 성능은 낮지만, 상용 CPU나 특수 칩 없이 구현한 결과임
- ping 왕복 평균: 85 ms
- HTTP 서버 다운로드 속도: 2.6 kB/s
- HTTP 서버는 SD 카드의 정적 파일을 제공함
- 모델, 회로도 파일, PCB 도면은 GitHub 저장소에 있음
댓글과 토론
Hacker News 의견들
-
공유해줘서 멋진 작업임. 추론 과정의 스택 트레이스가 특히 좋았고, 많은 것을 제1원리에서 풀어냈거나 초심자 관점에서 설명하려 한 점이 교육적으로 훌륭함
실제 네트워킹에는 비실용적이어도 단순한 장난만은 아니라고 봄. 과하게 복잡한 네트워크 칩에서 백도어가 발견되는 시대라, 앞으로는 더 진지한 독자층이나 프로젝트 동기가 생길 수도 있음- 현대 실리콘 내부에 숨은 취약점이 얼마나 많을지 궁금함. 몇천 줄짜리 코드에서도 거의 매일 취약점이 나오는데, 하드코딩된 실리콘 안에는 사실상 수십억 줄 코드에 해당하는 마이크로칩이 들어가 있는 셈임
-
이건 완전 커스텀 컴퓨터용이라 그 자체로도 훨씬 인상적이고, “그래서 C 컴파일러를 만들었다”는 대목은 말할 것도 없음. 그래도 “일반” PC용 이더넷 카드의 최소 구현이 어느 정도일지 궁금해짐
상당 부분은 비슷할 것 같고, 체크섬은 PC CPU에서 처리하게 할 수도 있을 듯함. 연결은 생짜 직렬이거나 더 실용적으로는 USB가 필요하고, 결국 “진짜” 드라이버를 쓰거나 사용자 공간으로 넘겨 처리해야 할 것 같음
비슷한 것들을 보며 장치가 https://en.wikipedia.org/wiki/USB_communications_device_clas...를 구현하면 자체 드라이버 없이 “그냥 동작”하게 할 수 있지 않을까 생각했지만, 체크섬을 모두 호스트 쪽에서 처리하는 것과는 잘 맞지 않을 듯함
검색하다가 https://en.wikipedia.org/wiki/Ethernet_over_USB도 발견했는데, 물리 연결만 USB로 변환하는 어댑터를 만들고 나머지는 컴퓨터가 알아서 처리하게 할 수 있다는 뜻인지도 모르겠음- USB는 오래전에 나온 10base2 Ethernet보다 훨씬 복잡하다고 볼 수 있음. 10base2 이더넷 네트워크에 PCIe나 USB로 연결하려 한다면, 둘 다 이더넷 쪽보다 훨씬 더 많은 작업이 됨
FTDI 스타일 USB 장치가 10base2 Ethernet을 비트뱅잉하게 설득할 수는 있을지도 모름. 선로 트래픽을 깨끗한 비트스트림으로 바꾸고 프레임 시작을 맞추는 “PHY” 쪽만 구현한 뒤, PC가 나머지를 전부 소프트웨어로 처리하게 하는 방식임 - 일반 PC에 30년 전처럼 ISA 버스가 있었다면, 내 네트워크 카드는 약간만 수정해서 거기에 연결할 수 있었음
- FPGA로 NIC를 구현하는 건 보통 PCIe 연결과 함께 아주 흔함
USB 쪽은 CDC-NCM 자체는 어떤 MCU에서도 구현이 어렵지 않지만, USB HS PHY 구현은 사실상 ASIC 하드웨어가 필요함
0.30달러짜리 USB HS ULPI PHY를 쓰면 FPGA에서 USB CDC-NCM을 꽤 쉽게 구현할 수 있을 것임
- USB는 오래전에 나온 10base2 Ethernet보다 훨씬 복잡하다고 볼 수 있음. 10base2 이더넷 네트워크에 PCIe나 USB로 연결하려 한다면, 둘 다 이더넷 쪽보다 훨씬 더 많은 작업이 됨
-
끝부분에 이 프로젝트용으로 만든 C 컴파일러 링크가 있음: https://github.com/imihajlow/ccpu-cc
링커와 libc도 있는 것 같음. 하드웨어 설계가 얼마나 복잡한지는 제대로 모르지만, C 컴파일러를 가볍게 만들어 붙였다는 점이 대단함- Rust로 작성한 C 컴파일러이고, 언어 파싱에는 lang_c 크레이트를 사용함
-
정말 인상적임. 이런 프로젝트를 직접 해보고 싶고, 시스템을 이해한 뒤 만들기까지 들어간 열정과 셀 수 없는 시간을 존경하게 됨
은퇴는 딱히 바라지 않지만, 아마 그때쯤 이런 하드웨어·소프트웨어 프로젝트에 시간을 쓰게 될지도 모르겠음 -
그래서 Etherlink 3c501보다 나은 건가, 아니면 더 나쁜 건가? :-D
https://mirror.math.princeton.edu/pub/oldlinux/Linux.old/net...
기억이 맞다면 CPU가 읽으려던 버퍼를 네트워크에서 들어온 새 패킷이 덮어쓰는 식이었음. 한동안 Linux에서 썼는데 성능이 정말 나빴음- 3c590(https://github.com/torvalds/linux/blob/20cb38a7af88dc40095da...)도 끔찍한 동작이 있었던 걸로 기억함. 치명적 오류를 피하려고 PCI 지연 시간 설정을 32에서 248로 바꿔야 했음
드라이버와 펌웨어 업데이트가 얼마나 많은 것을 시야에서 숨겨주는지 놀라움 - 이 물건에 대한 설명을 찾았음: https://www.os2museum.com/wp/emulating-etherlink/
내 것은 버퍼가 두 개라서 더 낫다 :) 그래도 수신 프레임은 하나만 보관됨
- 3c590(https://github.com/torvalds/linux/blob/20cb38a7af88dc40095da...)도 끔찍한 동작이 있었던 걸로 기억함. 치명적 오류를 피하려고 PCI 지연 시간 설정을 32에서 248로 바꿔야 했음
-
“프레임 길이를 고정해도 상위 프로토콜에는 영향이 없다. 상위 프로토콜은 헤더에 패킷 크기를 인코딩하고 실제 Ethernet 프레임 길이에 의존하지 않기 때문이다”라는 부분이 흥미로움
최근 패킷 디코더를 만들었는데, 각 계층에서 하위 계층 길이가 맞는지 명시적으로 검증했음. IP의 경우 내 디코더에서는 IP 데이터그램 길이가 Ethernet 프레임 길이와 링크 계층 헤더 길이에 정확히 맞아야 함
꼼꼼하려고 한 게 아니라 짧은 프레임을 감지하려던 것이고, 이후 긴 프레임도 오류로 보기로 했음. 작성자는 uIP를 쓰고 있지만 Linux나 다른 현대 OS가 어떻게 처리하는지 궁금함. 상호 운용성 테스트를 했는지도 궁금해짐- 긴 프레임을 네트워크로 내보내고 있는데, 내가 가진 어떤 OS도 문제를 보이지 않았음. 일부 라우터는 패킷 뒤에 메타데이터를 저장하려고 실제로 긴 프레임을 쓴다고 어디선가 읽었음
- 타임스탬프와 다른 형태의 인밴드 네트워크 텔레메트리가 트레일러 형태로 프레임에 삽입되기도 함. 이때 새 FCS가 붙음
애플리케이션이 L2 데이터를 보지 않는다면 Linux IP 스택은 그냥 무시함
-
DEC의 첫 SSI Ethernet 카드 세트보다는 물리적으로 훨씬 작음: https://i.ebayimg.com/images/g/NEYAAOSw-mZlg0lZ/s-l1600.jpg
DEC DEUNA 보드들은 길이가 1피트가 넘지만, 기능도 훨씬 많음. DEUNA는 “진짜” NIC라 송수신 큐를 갖고 그걸 자율적으로 처리하며 DMA도 함. 물론 카드 위에 자체 PDP-11도 있어서 그걸 실행함 -
정말 멋짐. 얼마나 걸렸는지 궁금함
- 네트워크 모듈을 만드는 데는 약 한 달 걸렸지만, 컴파일러 작성에는 훨씬 더 많은 시간이 들었음
-
네트워크 포트에 연결된 칩 안에 백도어를 심는 일이 얼마나 쉬운지 보여줌
-
Communication Systems Engineering 과정에서 Ethernet 신호 처리를 구현했고, 이어 ARP와 스위칭을 포함한 TCP/IP 스택을 Motorola 68k QUIC 어셈블리로 구현했음
인생에서 가장 길었던 18개월이었음