무엇을 만들고 있나
우측 편마비 환자용 다리 보조기구다. 한 줄로 요약하면 이렇다. 건강한 쪽 다리의 관절 각도를 읽어, 마비된 쪽 다리가 따라가야 할 목표 각도를 8비트 MCU 위의 신경망이 실시간으로 생성한다.
| 항목 | 내용 |
|---|---|
| 기간 | 2025.07 ~ 진행 중 |
| 제어기 | Arduino Nano (ATmega328P) |
| 구동 | L298N 모터 드라이버 2개 + JGB37-520 DC 기어드모터·엔코더 2개 |
| 기구 | 카본튜브 6×4 링크, 3590S 다회전 포텐셔미터, 12V 파워서플라이, KiCad 회로 설계 |
| 소프트웨어 | Arduino C++ / Unity 6000.0.68f1 (ArticulationBody 시뮬 + 자동 검증) / Python 3 · PyTorch |
| 역할 | 1인 개발 — 기구·회로 설계, 펌웨어, 물리 시뮬레이션, 생체역학 해석, 학습 파이프라인 |
우측 편마비 사용자를 상정하고 설계했다. 지금은 구현 중이고, 이 글에 적은 검증은 전부 계산과 PC 대조 단계다. 사람이 착용한 상태로 시험한 단계가 아니다.

문제 — 이건 보조기구가 아니라 조종기였다
초기 시스템은 전기적으로는 완성돼 있었다. 빵판 위에서 폐루프 위치 제어, PID, 스톨 감지, 소프트 리밋, EEPROM 캘리브레이션까지 전부 동작했다.
그런데 제어 구조를 한 문장으로 요약해 보니 이랬다.
잔존 근력이 없는 사용자에게 "두 관절을 손가락으로 매 순간 조종하세요"라고 요구하는 장치는 쓸모가 없다. 여기서 구조를 갈아엎었다.
| 구분 | 기존 (v5) | 개선 (v6) |
|---|---|---|
| 입력 | 손가락 가변저항 1~2ch | 건측 고관절·무릎 각도 + 보행 위상 |
| 변환 | 선형 map() | 학습된 목표각 생성기 (MLP) |
| 사용자 역할 | 매 순간 두 관절을 손으로 조종 | 평소처럼 걷기 (의도 표현만) |
| 개인화 | 없음 | 본인 데이터로 파인튜닝 |
6개 계층으로 나누기
제어를 여섯 계층으로 분리했다. 원칙은 두 가지다. 각 계층은 위 계층이 실패해도 단독으로 동작한다, 그리고 상위 계층의 어떤 출력도 안전층을 우회할 수 없다.
| 계층 | 역할 | 상태 |
|---|---|---|
| L0 안전층 | 기계 스토퍼 · 비상정지 · 소프트 리밋 · 슬루레이트 · 스톨 감지. 모든 출력이 여기를 통과 | 구현 완료 |
| L1 대칭 미러링 | 건측 각도를 보행 반주기만큼 지연. 학습 없는 규칙 기반 → 대조군 | 구현 완료 |
| L2 학습 목표각 생성기 | 건측 각도·각속도·보행위상 → 환측 목표각. 실제 배포 모델 | 파이프라인 완료 |
| L3 중력·마찰 보상 FF | 관절각별 유지 PWM 테이블을 전향 보상으로 가산 | 설계 완료 |
| L4 온라인 적응 (ILC) | 걸음 단위 반복 오차를 EMA로 누적해 선보상 | 계획 |
| L5 RL 정책 | Unity ML-Agents 균형 정책. 실기 배포 안 함 | 계획 |
우회 경로를 만들지 않는 것이 목표다
안전층은 "막아 준다"고 말하는 것만으로는 부족하다. 코드 구조상 우회가 불가능해야 한다. 모드가 무엇이든 모터에 도달하기 전 같은 경로를 지나가는 것을 목표로 잡았다. 아직 거기까지는 못 갔다. 아래 코드에서 수동 모드는 공통 경로를 타지 않고 바로 빠져나간다 — 구동 펌웨어를 붙이기 전에 정리해야 할 부분이다.
// 모드가 무엇이든 최종 출력은 단 하나의 경로로만 나간다
switch (mode) {
case MODE_MANUAL: targetFromPot(hip); targetFromPot(knee); return;
case MODE_MIRROR: mirrorTargets(&tHip, &tKnee); break; // L1
case MODE_MODEL: modelInfer(features, &tHip, &tKnee); break; // L2
}
// ── 이 아래는 공통. 우회 경로 없음 ──
tHip = clampSoftLimit(tHip, HIP_MIN, HIP_MAX);
tKnee = clampSoftLimit(tKnee, KNEE_MIN, KNEE_MAX);
hip.target = slewLimit(hip.target, tHip, SLEW_DEG_PER_S * DT);
knee.target = slewLimit(knee.target, tKnee, SLEW_DEG_PER_S * DT);
if (!deadmanHeld() || eStopLatched()) { allOff(); return; }
안전층 함수는 전부 순수 정적 함수로 만들었다. 덕분에 펌웨어(C)로 1:1 이식이 가능하고, Unity 플레이 모드 없이 단위 검사를 돌릴 수 있다.
8비트 MCU에서 신경망 돌리기
L2의 목표각 생성기는 7-16-16-2 MLP, 파라미터 434개다. 이걸 int16 고정소수점으로 양자화해 ATmega328P에서 실행한다. 활성값과 가중치는 Q12, 누산기는 int32, tanh 는 65점 룩업 테이블과 선형보간으로 대체했다.
/* 자동 생성된 헤더의 추론 함수 (일부) — AVR PROGMEM 대응 */
static void legassist_infer(const int16_t *raw_in, int16_t *out_d10)
{
int16_t x[LEGASSIST_N_IN];
int16_t h1[LEGASSIST_N_HIDDEN];
/* 입력 정규화: Q16 역표준편차를 곱하고 Q12로 시프트 */
for (i = 0; i < LEGASSIST_N_IN; i++) {
int32_t diff = (int32_t)raw_in[i] - LA_RD16(la_x_mean_raw, i);
int32_t scaled = (diff * LA_RD32(la_x_inv_std, i)) >> (LA_NORM_SHIFT - LA_ACT_SHIFT);
x[i] = la_clamp16(scaled);
}
/* 1층: int32 누산기에 Q24로 쌓고 Q12로 내린 뒤 tanh LUT */
for (i = 0; i < LEGASSIST_N_HIDDEN; i++) {
int32_t acc = LA_RD32(la_b1, i);
for (j = 0; j < LEGASSIST_N_IN; j++) {
acc += (int32_t)LA_RD16(la_w1, i * LEGASSIST_N_IN + j) * (int32_t)x[j];
}
h1[i] = la_tanh(acc >> LA_ACT_SHIFT);
}
/* ... 2층, 출력층, 역정규화 ... */
}
설계 판단 1 — 모델은 각도를 출력한다. 토크나 PWM이 아니다.
건측과 환측의 각도 관계는 운동학적 관계다. 누가 얼마나 힘을 냈는지와 무관하다는 뜻이다.
그래서 지금의 약한 모터로 모은 데이터로 학습해도, 나중에 모터를 바꿨을 때 모델을 다시 학습할 필요가 없다. 바뀌는 것은 하위 추종 제어기(PID 게인, 중력보상)뿐이다. 토크를 출력하도록 설계했다면 구동계를 바꿀 때마다 전부 다시 해야 했다.
설계 판단 2 — tanh 룩업 테이블을 헤더에 함께 내보낸다
Python 참조 구현과 C 구현이 같은 표를 쓰면, 근사 오차가 서로 상쇄되는 게 아니라 아예 동일해진다. 그래서 3,000표본 검증에서 차이가 0으로 나온다. 이건 운이 아니라 설계다.
학습 모델이 새로 만드는 위험
신경망을 넣으면 기존에 없던 고장 방식이 생긴다. 그걸 따로 정의하고 방어책을 붙였다.
| 위험 | 발생 상황 | 방어 |
|---|---|---|
| 분포 밖 입력 (OOD) | 학습하지 않은 자세에서 모델이 엉뚱한 각도 출력 | 입력이 학습 분포 1~99 퍼센타일을 벗어나면 즉시 L1 미러링으로 강등 |
| 센서 단선·고착 | 포텐셔미터 선이 빠져 ADC 값이 고정 | 무변화 2초 지속 시 경고 및 폴트 |
| 위상 추정 실패 | 발 스위치 오접촉으로 좌우 위상이 뒤집힘 | 주기 연속성 검사, 비정상 주기는 채택하지 않고 직전 주기 유지 |
| 모델 갱신 사고 | 잘못된 가중치가 올라감 | 내보내기 단계에서 오차 0.5도 초과 시 헤더 생성 자체를 중단 |
실기에 올릴 수 있는 크기까지는 왔다
아래 표의 검증은 전부 PC 쪽에서 한 것이다. gcc 로 빌드해 파이썬 참조 구현과 출력을 맞춰 보고, avr-gcc 로 빌드해 플래시·RAM 이 들어가는지 확인했다. 보드에 올려 돌린 결과는 아직 없다. 그건 다음 단계다.
| 검증 항목 | 결과 | 검증 방법 |
|---|---|---|
| C 구현 vs Python 참조 | 3,000표본 + 극단 입력 차이 0 | gcc 컴파일 후 실행 결과 직접 대조 |
| PyTorch(float) vs 정수 구현 | 최대 오차 0.27도 (기준 0.5도) | 동일 입력 15,769표본 |
| ATmega328P 빌드 | 플래시 2,412 B (7.4%), SRAM 18 B (0.9%) | avr-gcc -Os 실제 빌드 |
| 자동 검사 스위트 | 30항목 전부 통과 | 안전층·지연선·위상추정·필터·역동역학 |
데이터 수집 펌웨어는 모터를 아예 안 돌린다
50Hz CSV 스트리밍, 활동 라벨 전환, 캘리브레이션 EEPROM 저장을 지원하는 별도 펌웨어를 만들었다. 여기서는 L298N 제어핀을 setup 에서 LOW로 고정하고 그 뒤로 건드리지 않는다.
데이터를 모으는 단계에서 모터가 움직일 이유가 없고, 움직이면 위험만 늘어난다. 구동은 검증이 끝난 뒤 별도 펌웨어로 분리했다.
1편 정리, 그리고 다음
여기까지가 "조종기"를 "스스로 목표를 만드는 장치"로 바꾸는 구조 설계와, 그 모델을 8비트 MCU에 실제로 올린 이야기다.
다음 편에서는 숫자 쪽을 쓴다. 필요 토크가 969 N·m(정상의 20배)로 계산돼 나온 사건과 그 원인, Unity 물리 시뮬레이션이 3D 출력 전에 잡아낸 설계 오류, 그리고 감속비를 아무리 조정해도 보행이 불가능하다는 결론에 도달한 과정이다.
그 결론이 프로젝트의 방향 자체를 바꿨다. 지금은 관절 구축 예방을 위한 느린 가동범위 운동을 1차 목표로 두고 있다.