Колупався я недавно в антикваріаті ("MS/PC DOS 1.0", "MS/PC DOS 1.XX в емуляторах", "MS/PC DOS 1.XX "Ось ти який, північний олень!"", "DOS FCB"). Взагалі, цікаве заняття. :-) В процесі натрапив на людину, яка дизасемблювала та прокоментувала вихідні тексти IBMBIO.COM з PC DOS 1.00: "Reverse-Engineering DOS 1.0 – Part 2: IBMBIO.COM" (як і його бутлоадер: "Reverse-Engineering DOS 1.0 – Part 1: The Boot Sector"). Крім того, дизасемблювання бутсекторів виявилося потужним засобом, щоб розібратися, чому в емуляторах не вантажаться різні версії ранніх DOS -- довелося і самому трішки спробувати.
Завершивши той цикл постів, на якийсь час заспокоїв свій археологічний свербіж. Виявилося -- не на довго. Повернення відбулося, коли, чистячи відкриті тематичні вклади, майже випадково натрапив на сайт якогось пана (пані?, але жінки, зазвичай, такими дурницями не страждають), що викладав образи своїх старих дискет: "Data Pack Rat". Дуже багато безцінного історичного матеріалу -- старовинні компілятори, оболонки, утиліти і т.д. Толком ще не розгрібав, натраплю на ще що цікаве -- розповім. Але знайомство почалося із майже чуда! У нього знайшлося кілька дискет, 1987-го року, на яких якийсь псих (в хорошому розумінні цього слова!) розповсюджував дизасембльований DOS 1.10 та DOS 2.10. Підписані вони так:
Don Jindra
Information Modes
P.O. Drawer F.
Denton, Texas 76202
817-387-3339
Знайти якусь додаткову інформацію про цю людину-компанію, крім однієї згадки десь в Usenet не вдалося. Скачати оригінальні образи можна тут. Вони в хитрому форматі, щоб дістати із них файли, довелося витратити пару вечорів, хоча, в результаті, нічого аж такого складного в тому не було. Щоб спростити собі та читачам життя, ось перепаковані версії: PC_DOS_1.10_disasm.ZIP, PC_DOS_2.10_disasm.ZIP. Також, асемблерний файл із IBMBIO, взятий за посиланням вище (в тому пості текст негарний, зіпсутий загадковими, розкиданими по тексту, символами): IBMBIO100.ASM. Файл компілюється NASM, результат ідентичний оригінальному IBMBIO, за винятком зміни кількох байт (сучасні асемблери іноді використовують трішки інші, альтернативні, форми кодування команд).
Детально вміст цих дискет поки не аналізував. Проглянув, порівняв із згаданим вище дизасембльованим IBMBIO. Звірив із тим, що відомо з пізнішої чи малодоступної тоді документації, трішки заліз дизасемблером сам. (В декількох місцях автор чесно зізнавався, що не розуміє певні місця, відомі мені по більш сучасних аналізах.) Виглядає, що робота виконана доволі акуратно та систематично.
Стало цікаво подивитися на еволюцію ранніх DOS, тим більше із двома такими ґрунтовними підказками. Однак, дизасемблювати весь DOS -- довго. Вирішив почати з перших версій різноманітні дрібних утиліт. Для дизасемблювання використовую IDA 5.0 Free. Почав із найменшої програмки -- страшно ж, DATE.COM. Потім усвідомив, що вибір не найкращий -- в наступних версіях її немає. Але ж не викидати. Так що поділюся. :-)
Перш ніж перейти безпосередньо до коду, декілька зауважень:
- Дизасемблювання, декомпіляція, і т.д., не зважаючи на активні спроби компаній заборонити його в своїх ліцензіях, в більшості випадків є явно дозволене законом. (На цей випадок у ліцензіях робиться явний виняток -- "обмежуємо в мірі, дозволеній законом". А куди їм дітися? ;-). Це, звичайно, не стосується подальшого використання отриманого так коду. Див. також "Clean room design".
- На відміну від права дизасемблювати копію, якою ви володієте, DOS 1.XX, формально він все ще захищені копірайтом. Хоча, навряд чи Microsoft судитиметься за їх незаконне розповсюдження. Крім того, більшість утиліт ранніх версій не містять якого-небудь "копірайта".
- IDA -- надзвичайно потужний, але нетривіальний у використанні інструмент. Довелося витратити певний час, освоюючи його засоби. Зокрема, у нього дуже розвинута система засобів підказати дизасемблеру, що тут насправді відбувається. Однак, система ця -- достатньо безбашенна, зіпсувати попередні наробітки може тільки так. Детально про роботу з IDA поки не писатиму. Якщо цікаво -- пишіть, щось придумаємо.
- Де взяти образи ранніх DOS, і як з них виколупати файли (наприклад DATE.COM), описано тут: "MS/PC DOS 1.XX в емуляторах ".
- Коментарі в коді пишу англійською (ну, як знаю, так пишу -- помилок багато), щоб могли прочитати й іноземні колеги. Текст ніби простий, будь-який грамотний комп'ютерщик мав би його розуміти без проблем, навіть якщо толком не знає англійської. Але якщо є труднощі -- пишіть, перекладемо "назад".
Отож, DATE.COM -- звичайна COM програма, яка побайтово вантажиться у пам'ять, починаючи із зміщення 100h від початку сегменту (вище неї, в цих 0FFh байт, знаходиться PSP, ну і не забуваємо про умовність поняття сегменту в реальному режимі x86).
COM файл, коли йому передається керування, отримує (на додачу до інформації в PSP), наступні дані:
Код, сумісний з NASM:
Скачати його, разом із лістингом, згенерованим IDA, скомпільованим файлом та, для порівняння, оригінальним DATE.COM, можна тут.
Проаналізуємо цей код.
"nasm date_my_2.asm -o date_my_2.com"
Результат трішки відрізнятиметься від оригінального (до речі, згенерований MASM співпадатиме із тим, що дає NASM):
Проаналізуємо відмінності.
Перша ж багато що прояснить: в оригінальному файлі 8A C6, у нас 88 F0. Оригінальний опкод відповідає "mov al, dh". Що ж таке 88 F0? Тут підказують: "88 /r" -- "Move byte register to r/m byte", "8A /r" -- "Move r/m byte to byte register". Тобто, команди повністю еквівалентні, просто x86 має окремі коди для "перемістити байтовий регістр або пам'ять у регістр" і "перемістити байтовий регістр у пам'ять або регістр", тотожні по суті, якщо обидва операнди -- регістри. Оригінальний асемблер явно більше любив 8A, сучасні -- 88. Аналогічно, 8B i 89, mov для 16-бітних регістрів, 03 і 01 -- add для 16-бітних, 0A i 08, or для 8-бітних.
Ще одна різниця: за зміщенням 5A, 82 в оригінальному, 80 -- сучасний. Так виглядає, що опкоди повністю еквівалентні (не бачу навіть, в чому ж між ними різниця, невже "r/m8, imm8", проти "imm8, r/m8"?).
Решта коду повністю тотожні.
Приклади (які оригінальні назви процедур були, само собою, невідомо, тому користуюся придуманими мною, тими, що використано в коді вище):
З DATE.COM все -- було б про що говорити, насправді. :-) Далі, вже "готові", але не причесані SYS.COM для 1.00 і 1.10, CHKDSK.COM для 1.0. Можна буде й еволюцію відслідкувати. А поки:
- CS=DS=ES=SS,всі вказують на той же сегмент,
- SP=0FFFEh -- стек у кінці сегмента, росте вниз,
- IP=0100h -- починаємо зразу після PSP.
- AL = 00h, якщо перший FCB в PSP має правильну літеру диску, 0FFh, якщо ні.
- AH -- аналогічно для другого FCB з PSP.
Код, сумісний з NASM:
; This file is generated by The Interactive Disassembler (IDA) ; Copyright (c) 2010 by Hex-Rays SA, <support@hex-rays.com> ; Licensed to: Freeware version ; ; Modified to compile by nasm and commented by Indrekis, indrekis2.blogspot.com ; ; Input MD5 : 432D0670686C7B1FE428063843855D3F ; File Name : <>\DOS1\images\IBM PC-DOS 1.0 (5.25)\files\DATE.COM ; Format : MS-DOS COM-file ; Base Address : 0h Range: 100h-1FCh Loaded length: FCh ; stack_top is set to allow for variables, which are located above programm code. ; This "self-willed" memory management was widely used by early DOS commands ; By the way, there were no memory management means in DOS 1.XX
; DATE.COM do not use any such variables, though.
stack_top EQU 288h ; New Stack top CPU 8086 use16 org 100h start: ; mov sp, stack_top ; Set stack just 8D bytes after the programm code. Rather small. mov dx, aCurrentDateIs ; "Current date is $" mov ah, 9 int 21h ; DOS - PRINT STRING ; DS:DX -> string terminated by "$" mov ah, 2Ah int 21h ; DOS - GET CURRENT DATE ; Return: DL = day, DH = month, CX = year ; AL = day of the week (0=Sunday, 1=Monday, etc.) mov al, dh ; Ignores the day of weak, month to AL call PrnDecFromAL mov al, 2Dh ; Char '-' call PrnASCIIFromAL ; AL -- symbol to print mov al, dl ; Current date to AL call PrnDecFromAL mov al, 2Dh ; '-' call PrnASCIIFromAL ; AL -- symbol to print sub cx, 1900 cmp cx, 100 jb short less_then_2000y sub cx, 100 ; If 2000 or later -- print only last two digits less_then_2000y: mov al, cl call PrnDecFromAL ; Print two last digits of current year enter_date_l: mov dx, aEnterNewDate ; To this point we have printed previous date, ; so asking for new. mov ah, 9 int 21h ; DOS - PRINT STRING ; DS:DX -> string terminated by "$" mov ah, 0Ah mov dx, KbdBuffer int 21h ; Reading from keyboard. ; DOS - BUFFERED KEYBOARD INPUT ; DS:DX -> buffer ; Buffer format (http://www.ctyme.com/intr/rb-2563.htm) ; Size Description (Table 01344) ; 00h BYTE maximum characters buffer can hold ; 01h BYTE (call) number of chars from last input which may be recalled ; (ret) number of characters actually read, excluding CR ; 02h N BYTEs actual characters read, including the final carriage return mov si, KbdBuffer+2 cmp byte [si], 0Dh ; If entered only "Enter" -- exiting jz short exit_prog call TwoCharsToNumber ; SI points to buffer with chars, AH -- result ; Adjusts SI to point to next char in buffer mov dh, ah ; In DH should be the month call CheckSeparator ; SI -- points to buffer, correct separators: '/','-' ; If there is no correct separator, exits program. ; Adjusts SI to point to next char in buffer. call TwoCharsToNumber ; SI points to buffer with chars, AH -- result ; Adjusts SI to point to next char in buffer mov dl, ah ; In DL should be the date call CheckSeparator ; SI -- points to buffer, correct separators: '/','-' ; If there is no correct separator, exits program. ; Adjusts SI to point to next char in buffer. call TwoCharsToNumber ; SI points to buffer with chars, AH -- result ; Adjusts SI to point to next char in buffer mov cx, 1900 cmp byte [si], 0Dh ; Check if year has two digits or more jz short YearHas2Chars mov al, 100 ; If more than 2 -- expecting 4-digits year mul ah ; First two digits multiply by 100d mov cx, ax ; Setup CX correspondingly call TwoCharsToNumber ; SI points to buffer with chars, AH -- result ; Adjusts SI to point to next char in buffer YearHas2Chars: mov al, ah ; Two last digits of year -- from AH to AL mov ah, 0 add cx, ax ; Calculate year mov ah, 2Bh int 21h ; DOS - SET CURRENT DATE ; DL = day, DH = month, CX = year ; Return: AL = 00h if no error /= FFh if bad value sent to routine or al, al ; Fasm uses 0A (r8, r/m8) instead of original 08 (r/m8, r8) jnz short InvalidDateEnterd exit_prog: int 20h ; DOS - PROGRAM TERMINATION ; start endp ; returns to DOS--identical to INT 21/AH=00h ; ============== subroutine =========================================== ; SI -- points to buffer, correct separators: '/','-' ; If there is no correct separator, exits program. ; Adjusts SI to point to next char in buffer. CheckSeparator: ; proc near lodsb cmp al, 2Fh ; Char '/' jz short exit_inputting cmp al, 2Dh ; Char '-' jz short exit_inputting InvalidDateEnterd: mov dx, InvalidDate ; "\r\nInvalid date$" mov ah, 9 int 21h ; DOS - PRINT STRING ; DS:DX -> string terminated by "$" mov sp, 288h ; Clear the stack jmp near enter_date_l ; Try once more ; (jmp is near, not short! -- original assembler pecularity) ;CheckSeparator endp ; ============== subroutine =========================================== ; SI points to buffer with chars, AH -- result ; Adjusts SI to point to next char in buffer TwoCharsToNumber: ;proc near call CharToDigit ; Converts char to corresponding digit ; SI -- pointer to char, AL -- result. ; Adjusts SI to next char ; If conversion is impossible -- sets CF=1 and exits, ; using retn of function above. ; (It is strange, but correct -- return adress is in stack) jb short InvalidDateEnterd mov ah, al call CharToDigit ; Converts char to corresponding digit ; SI -- pointer to char, AL -- result. ; Adjusts SI to next char ; If conversion is impossible -- sets CF=1 and exits, ; using retn of function above. ; (It is strange, but correct -- return adress is in stack) jb short exit_inputting aad ; AL := AH * 10 + AL; ; AH := 0; mov ah, al exit_inputting: retn ;TwoCharsToNumber endp ; ============== subroutine =========================================== ; Converts char to corresponding digit ; SI -- pointer to char, AL -- result. ; Adjusts SI to next char ; If conversion is impossible -- sets CF=1 and exits, ; using retn of function above. ; (It is strange, but correct -- return adress is in stack) CharToDigit: ; proc near mov al, [si] ; SI points somewhere in input buffer sub al, 30h ; Char to number, if '0'<=al<='9' jb short exit_inputting ; Exit, if less than 0, CF=1 cmp al, 0Ah cmc ; reverses the setting of the carry flag. jb short exit_inputting ; Exit if large than 10, CF=1 inc si ; Proceed to next char retn ;CharToDigit endp ; ============== subroutine =========================================== PrnDecFromAL: ; proc near aam ; ASCII adjust AX after multiply ; AH := AL / 10; ; AL := AL MOD 10; ; Result: ; AL -- lower digit ; AH -- higher digit xchg al, ah ; Printing higher digit or ax, 3030h ; '0'=30h, '1'='0'+1 and so on -- ; converting digits to their ASCII codes ; call PrnASCIIFromAL ; AL -- symbol to print mov al, ah ; Now prepare lower digit and fall through ; to char printing subroutine ;PrnDecFromAL endp ; ============== subroutine =========================================== ; AL -- symbol to print PrnASCIIFromAL: ;proc near xchg ax, dx push ax mov ah, 2 int 21h ; DOS - DISPLAY OUTPUT ; DL = character to send to standard output pop ax xchg ax, dx retn ;PrnASCIIFromAL endp ; ============== data =========================================== InvalidDate db 0Dh,0Ah ; db 'Invalid date$' aCurrentDateIs db 'Current date is $' ; aEnterNewDate db 0Dh,0Ah ; db 'Enter new date: $' KbdBuffer db 0Ch ; ; Format: ; 00h BYTE maximum characters buffer can hold ; 01h BYTE ; (call) number of chars from last input which may be recalled ; (ret) number of characters actually read, excluding CR ; 02h N BYTEs actual characters read, including the final carriage return ; (http://www.ctyme.com/intr/rb-2563.htm) db 0 unk_1FB db 0
Скачати його, разом із лістингом, згенерованим IDA, скомпільованим файлом та, для порівняння, оригінальним DATE.COM, можна тут.
Проаналізуємо цей код.
Рекомпіляція
Код, згенерований IDA, довелося трохи підправити напильником, щоб він скомпілювався NASM: останній не знає нічого про директиви сегментів, процедур, не потребує і не знає offset та ptr, ну і ще деякі дрібниці. Асемблюється тривіально:"nasm date_my_2.asm -o date_my_2.com"
Результат трішки відрізнятиметься від оригінального (до речі, згенерований MASM співпадатиме із тим, що дає NASM):
Проаналізуємо відмінності.
Перша ж багато що прояснить: в оригінальному файлі 8A C6, у нас 88 F0. Оригінальний опкод відповідає "mov al, dh". Що ж таке 88 F0? Тут підказують: "88 /r" -- "Move byte register to r/m byte", "8A /r" -- "Move r/m byte to byte register". Тобто, команди повністю еквівалентні, просто x86 має окремі коди для "перемістити байтовий регістр або пам'ять у регістр" і "перемістити байтовий регістр у пам'ять або регістр", тотожні по суті, якщо обидва операнди -- регістри. Оригінальний асемблер явно більше любив 8A, сучасні -- 88. Аналогічно, 8B i 89, mov для 16-бітних регістрів, 03 і 01 -- add для 16-бітних, 0A i 08, or для 8-бітних.
Ще одна різниця: за зміщенням 5A, 82 в оригінальному, 80 -- сучасний. Так виглядає, що опкоди повністю еквівалентні (не бачу навіть, в чому ж між ними різниця, невже "r/m8, imm8", проти "imm8, r/m8"?).
Решта коду повністю тотожні.
Алгоритм роботи
- Спершу програма встановлює стек на 288h -- всього 0FCh байт над місцем, де завершується код. Хоча, цього повинно вистачити. DATE.COM тим не користується, але судячи з аналізу інших, ранні утиліти вважали, що вся пам'ять вище від них -- їхня, принаймні до кінця сегменту. Тому просто переставляли стек нижче, і розташовували вище від нового стеку свої дані. А що було робити -- засобів керування пам'яттю (виділення, звільнення і все таке), ще не було.
- Далі виводиться поточна дата. Програма знає про дати, більші за 2000 рік, але зустрівши такі, все рівно виводить лише дві останніх цифри.
- Наступним кроком просить ввести нову дату: "Enter new date: ", читає з клавіатури в буфер, за допомогою ф-ції 0Ah/int 21h, "BUFFERED INPUT". Максимум 12 символів.
- Введена стрічка аналізується. Якщо введено порожню стрічку -- натиснуто Enter, зразу завершує роботу.
- Якщо ні, перетворює перших два символи в число. Якщо не вдалося -- виводить "Invalid date", і пропонує спробувати ще. Вважається, що першим вводиться місяць.
- Далі перевіряє наявність коректного сепаратора -- '/' або '-', якщо немає -- пропонує спробувати ще.
- Аналогічно, аналізує наступну пару символів (очікуючи цифри), плюс сепаратор. Це буде день місяця.
- Перевіряє: під рік відводиться два символи, чи більше (але не перевіряє, чи не є їх менше двох, хоча це не проблема -- про невірне число повідомить підпрограма перетворення символів у число). Якщо більше, вважає, що введено всі чотири цифри року. Обчислює заданий рік.
- Отримані місяць, дату і рік передає функції 2Bh/int 21h, "SET SYSTEM DATE".
- Якщо дата була успішно встановлена -- виходить, якщо ж функція "SET SYSTEM DATE" повернула помилку -- знову перепитується.
"Документація"
З аналізу коду бачимо, що:- Ніяких опцій команда не приймає, просто їх ігнорує.
- Порядок введення: два символи на місяць, сепаратор, два на день, сепаратор, два або чотири на рік. Приклад: MM-DD-YYYY.
- Сепаратором може бути '/' або '-'.
- Просто "Enter" завершує програму без зміни дати.
- Якщо програма вирішує, що дата невірна -- перепитується ще раз.
Трішки про код
Код, так би мовити, хитрий. Мало того, що жорстокий асемблер, так ще й ніякої поваги до структурного програмування. Підтримувати такий -- це жах. До речі, утиліти наступних версій DOS явно писалися з більшою повагою до структури.Приклади (які оригінальні назви процедур були, само собою, невідомо, тому користуюся придуманими мною, тими, що використано в коді вище):
- CharToDigit, для виходу по помилці, переходить до retn в іншій процедурі, TwoCharsToNumber, але якщо все ОК -- користується "власним". Дрібничка.
- CheckSeparator навпаки, виходить по retn з TwoCharsToNumber, якщо все ОК, або переходить на мітку початку введення дати, (enter_date_l). Перед тим чистить стек від попередніх переходів -- встановлює його вершину у початкове значення. Вже цікавіше.
- PrnDecFromAL друкує перший символ числа, для власне виводу на екран викликаючи PrnASCIIFromAL, а потім готує другий і просто провалюється в PrnASCIIFromAL. Чим не оптимізована хвостова рекурсія? :-) Ну і байт зекономили -- на retn. Правда, оригінальний асемблер якраз один байт "пролюбив", використавши near перехід там, де цілком пасував на байт коротший short-перехід. (Див. коментарі у коді).
З DATE.COM все -- було б про що говорити, насправді. :-) Далі, вже "готові", але не причесані SYS.COM для 1.00 і 1.10, CHKDSK.COM для 1.0. Можна буде й еволюцію відслідкувати. А поки:
Дякую за увагу!
Довідкова література
- "80386 Programmer's Reference Manual" -- список команд, із їх опкодами, описом, і т.д. Не найбільш повний чи найбільш акуратний, але виявився зручним під час роботи над дизасемблюванням.
- "X86 Opcode and Instruction Reference" -- детальний і доволі точний довідник по опкодах x86.
- Переривання ядра DOS, від Ральфа Брауна.
- "BDA - BIOS Data Area - PC Memory Map"
- Та багато інших...
Немає коментарів:
Дописати коментар