← 제작기

13바이트가 넘쳐서 설계가 사라졌다

책상용 기기의 화면 구성이 코드에 고정돼 있었습니다. 사진 화면, 알림 목록, 시계 화면이 각각 함수 하나였고, 고치려면 펌웨어를 다시 구워야 했습니다. 이번에 이것을 아이폰 앱에서 꾸미는 구조로 바꿨습니다. 한 페이지는 배경, 자유롭게 배치하는 위젯 여덟 종류, 머무는 시간으로 이루어지고, 그 설계는 JSON 으로 블루투스를 통해 전송돼 기기의 플래시에 저장됩니다.

기능은 하루 만에 돌았습니다. 남은 사흘은 실패가 성공처럼 보이는 자리를 찾는 데 썼고, 모두 여섯 군데가 나왔습니다.

13바이트

앱에서 화면을 꾸미고 저장을 누르면 아무 일도 일어나지 않았습니다. 오류도, 실패 응답도 없었습니다. 기기를 다시 켜면 옛 화면이 그대로 있었습니다.

앱이 설계를 조각내 보내면서 한 조각을 510바이트로 잘랐는데, 그 연결의 한도는 497바이트였습니다. 13바이트가 넘친 쓰기를 iOS 가 조용히 긴 쓰기 방식으로 바꿔 보냈고, 기기는 그 방식을 받지 않았습니다. 거절이 앱까지 올라오지 않았습니다. 앱은 보냈고 기기는 받지 않았는데, 둘 다 아무 말도 하지 않았습니다.

두 곳을 고쳤습니다. 조각 크기를 한도에서 3바이트 뺀 값으로 맞췄고, 기기가 무시한 쓰기를 로그에 남기게 했습니다. 두 번째가 없으면 다음에 같은 일이 생겨도 또 조용히 지나갑니다.

저장되지 않았는데 성공이라고 답했다

설계는 여러 조각으로 나뉘어 옵니다. 마지막 조각이 오면 이어 붙여 저장하고 성공을 돌려주는데, 조각 사이에 짧은 요청 하나가 끼어들면 이어 붙이던 내용이 그 요청 쪽으로 넘어가 버렸습니다. 저장은 되지 않았는데 성공 응답은 돌아갔습니다.

요청마다 "이 요청이 이어 붙이던 내용을 갖고 있는가"를 표시하게 했습니다. 표시가 없는 요청은 설계를 건드리지 않습니다. 검사가 통과했다는 것과 화면이 맞다는 것이 다른 일과 같은 종류의 문제입니다.

말없이 잘린 것 셋

이름과 문구에 한글을 넣으면 마지막 글자가 깨졌습니다. 길이를 바이트 단위로 자르면서 한 글자의 가운데를 갈랐기 때문입니다. 한글 한 글자가 3바이트라는 것을 알면서도 자르는 곳에서는 잊고 있었습니다. 따옴표도 이스케이프 없이 그대로 실려 나가서, 이름에 따옴표를 하나 넣으면 설계 전체를 읽지 못하게 됐습니다. 칸 하나가 아니라 파일 전체가 망가진 것입니다.

기기에는 페이지는 여덟 장, 페이지마다 위젯은 열 개, 이름은 19바이트까지라는 상한이 있습니다. 앱이 그 수를 몰라서 넘겨 보냈고, 기기는 말없이 잘랐습니다. 앱에도 같은 상한을 알려 주고, 넘으면 보내기 전에 앱이 먼저 알리게 했습니다.

사진을 배경으로 쓰는 페이지가 다섯 장을 넘으면 여섯 번째부터 까맣게 나왔습니다. 붙일 수 있는 사진 수에 상한이 있는데 그 상한을 알리는 곳이 없어서, 넘었다는 사실이 화면에 검은색으로만 나타났습니다.

4KB 스택에 4KB 를 복사했다

설계를 바꿀 때 옛 설계를 스택에 복사해 두고 있었습니다. 그 작업의 스택이 4KB 인데 설계도 4KB 였습니다. 스택이 넘치자 보드가 재부팅했습니다.

어제 적은 1.2KB 이야기와 같은 종류입니다. 그때는 알림 버퍼를 블루투스 작업의 스택에 잡았습니다. 두 번 모두 "이만한 크기를 어디에 둘 것인가"를 묻지 않았습니다.

앱 쪽도 조용했다

미리보기를 열 때마다 원본 사진을 메모리에 풀어 들고 있다가 앱이 강제 종료됐습니다. 원본은 파일로 두고 필요할 때만 읽도록 바꿨습니다.

기기 상태가 5초마다 들어오는데, 그때마다 화면을 다시 그리느라 사진 선택 창이 통째로 새로 만들어졌습니다. 자르기 창이 떠 있는 동안에도 5초마다 원본을 다시 풀었습니다. 주기적인 갱신은 화면 어딘가를 늘 다시 만듭니다. 무엇은 다시 만들어져도 괜찮은지 정해 두지 않으면, 사용자가 손대고 있던 것이 사라집니다.

자르기 창은 네모 바깥을 잘라 보여 주지 않아서 결과와 다르게 보였습니다. 창 크기도 한 번만 재는 바람에, 그 값이 0 인 순간에 흰 화면 한 장(12KB)을 만들어 기기로 보냈습니다.

되돌릴 수 없는 쪽은 고르지 않았다

기기 설정 창에 블루투스를 끄는 버튼을 넣으면서, 칩 자체를 끌지 연결만 끊을지 골라야 했습니다. 칩을 끄면 다시 켤 때 서비스 등록을 처음부터 다시 해야 하고, 그러면 아이폰이 페어링할 때 받아 둔 목록과 어긋납니다.

연결을 끊고 광고를 멈추는 쪽으로 정했습니다. 끄고 나면 앱에서는 다시 켤 수 없으니 기기에서만 켭니다. 이 앱의 통신 경로를 처음 넣었을 때 아이폰이 옛 목록을 계속 써서 기기를 지우고 다시 페어링해야 했던 적이 있는데, 그때 배운 것이 이번 선택을 정했습니다.

남은 것

  • 거절이 올라오지 않는 경로가 있습니다. 앱은 보냈고 기기는 받지 않았는데 둘 다 조용했습니다. 보낸 쪽이 성공을 확인할 방법이 없으면 보냈다고 할 수 없습니다.
  • 무시한 것은 로그에 남깁니다. 13바이트를 고치는 것보다, 다음에 무언가를 무시할 때 그 사실이 보이게 만드는 쪽이 오래갑니다.
  • 상한은 양쪽이 함께 알아야 합니다. 한쪽만 아는 상한은 말없이 잘라 내는 장치가 됩니다. 넘는다는 사실을 보내기 전에 알리게 했습니다.
  • 바이트 단위로 자르면 글자가 갈라집니다. 한 글자가 여러 바이트인 언어에서는 길이 제한이 곧 글자 깨짐으로 이어집니다. 따옴표 하나가 설계 전체를 못 읽게 만든 것도 같은 곳에서 나왔습니다.
  • 주기적인 갱신은 손대고 있던 것을 지웁니다. 5초마다 다시 그리는 화면에서는 무엇이 다시 만들어져도 되는지부터 정해야 합니다.

The screens on the desk device were hard-coded. The photo screen, the notification list and the clock were each a function, and changing one meant reflashing. They now get laid out from the iPhone app instead: a page is a background, freely placed widgets of eight kinds, and a dwell time, and that layout crosses over Bluetooth as JSON to live in the device's flash.

The feature worked within a day. The remaining three went into finding the places where failure looked like success. There were six.

Thirteen bytes

Laying out a screen in the app and pressing save did nothing at all. No error, no failure response. Power-cycling the device brought back the old screen.

The app splits the layout into chunks, and it was cutting one at 510 bytes against a limit of 497 for that connection. iOS quietly converted the thirteen-byte overflow into a long write, and the device does not accept that form. The refusal never reached the app. The app had sent, the device had not received, and neither said anything.

Two things changed. Chunks are now cut three bytes below the limit, and a write the device ignores gets written to the log. Without the second, the next occurrence is just as quiet.

Nothing saved, success returned

A layout arrives in several chunks. The last one triggers the assembly and the save, and then a success goes back — but a short request landing between chunks carried the assembled buffer off with it. Nothing was saved, and success went back anyway.

Every request now carries a flag saying whether it is the one holding the assembled buffer. Without that flag a request does not touch the layout. A check passing and a screen being right are different claims, and this is the same seam.

Three things truncated in silence

Korean text in a name or a quote lost its last character, because the length was cut in bytes and the cut landed inside a character. Quotes went through unescaped too, so a single quotation mark in a name made the entire layout unreadable. One field was not broken; the file was.

The device has limits: eight pages, ten widgets per page, nineteen bytes for a name. The app did not know those numbers, sent past them, and the device trimmed without a word. The app now holds the same limits and speaks up before sending rather than after.

Pages using a photo as their background went black from the sixth one on. There is a cap on how many can be attached, and because the cap had no name attached to it, exceeding it showed up on screen only as the colour black.

Copying 4KB onto a 4KB stack

Changing a layout copied the old one onto the stack first. That task's stack is 4KB and a layout is 4KB. It overflowed and the board rebooted.

It is the same kind as yesterday's 1.2KB, where the notification buffer sat on the Bluetooth task's stack. Both times the question "where does something this size live" went unasked.

The app side was quiet too

Every preview decoded the original photo and held it, until the app was killed for memory. Originals now stay as files and are read only when needed.

Device status arrives every five seconds, and redrawing for it rebuilt the photo picker from scratch; while the crop sheet was open, the same five seconds re-decoded the original underneath it. A periodic refresh is always rebuilding some part of the screen. Without deciding in advance what may be rebuilt, whatever the user had in their hands disappears.

The crop sheet also did not clip outside its frame, so the preview disagreed with the result, and measuring the sheet only once meant a moment when that measurement was zero produced a blank white image (12KB) and sent it to the device.

Not choosing the irreversible side

Adding a switch to turn Bluetooth off raised a choice: bring the chip down, or only drop the connection. Bringing the chip down means registering the services again on the way back up, which leaves the iPhone holding a service list from pairing that no longer matches.

Dropping the connection and stopping the advertisement won. With it off, the app cannot turn it back on, so that switch lives on the device. When this app's channel first went in, the iPhone kept using its old list and the device had to be forgotten and paired again — that lesson decided this.

What's left

  • There are paths a refusal never travels back up. The app sent, the device declined, and both stayed quiet. If the sender has no way to confirm success, it has not sent.
  • Log what you ignore. Fixing the thirteen bytes matters less than making the next thing you ignore visible while you ignore it.
  • A limit has to be known on both ends. A limit only one side knows is a blade that cuts silently. Now the app says so before sending.
  • Cutting by bytes splits characters. In a language where one character is several bytes, a length limit is a corruption bug. The quotation mark that made a whole layout unreadable came from the same place.
  • A periodic refresh erases what someone is holding. On a screen that redraws every five seconds, what may be rebuilt has to be decided first.