Need second opinion on ASM snippet (MULTIKEY issues)

If you have questions about any aspect of QBasic programming, or would like to help fellow programmers solve their problems, check out this board!

Moderators: Pete, Mods

Post Reply
MikeHawk
Coder
Posts: 41
Joined: Sun Jul 08, 2018 11:23 am

Need second opinion on ASM snippet (MULTIKEY issues)

Post by MikeHawk » Wed Sep 01, 2021 2:44 pm

I need the opinion of someone who knows assembly and the obscure intricacies of messing with pointers in an interrupt because I'm an ASM noob.

I used to use Milo Sedlacek's MULTIKEY assembly code to replace the keyboard interrupt service routine because up until recently, it never failed to deliver. I understood the basics of what the code was doing, but never looked into HOW it did it. To get it to work, you have to reserve an array in QuickBASIC, get its pointer (both segment and offset) and then use that to reprogram four bytes into the assembly code, so the code knows where it should store the status of each key (the rest is standard stuff like reading port 0x60, checking if the status is press or release, acknowledge interrupt, etc.)

I recently came across an annoying bug that rendered MULTIKEY unusable. I assumed it was due to the fact I was forcing QuickBASIC to consolidate the memory reserved for variables (and thus moving variables -including the array where key status would be stored- in memory) by reserving memory via a DOS interrupt (QuickBASIC always reserves all the available space for your code and variables; if you want a static memory address, you have to put it away from its control by first telling it to free some memory and then reserving it via DOS.) The obvious side-effect was that the array meant to contain the key status would move and MULTIKEY would write to the old address, where something else is stored. At that point, I decided to also reserve memory for the key status buffer via the DOS interrupt to make sure it wouldn't move. But that didn't exactly fix the issue as it seemed that my own PEEK and POKE calls would not land where I expected them to.

So I read Milo's code going instruction by instruction and realized he was modifying the code segment address to access the key status buffer. Since the code is designed to be an interrupt, it means that it can be executed at any time during the main program's execution - for instance, in the middle of a loop that reads/writes stuff to memory via PEEK and POKE - which means the interrupt could impact DEF SEG silently, and there would be no way of knowing. Here's Milo's interrupt code:

Code: Select all

; pushing registers to stack so they can be restored to their initial value
9C          PUSHF
50          PUSH AX
53          PUSH BX
51          PUSH CX
52          PUSH DX
1E          PUSH DS
56          PUSH SI
06          PUSH ES
57          PUSH DI

; port reading
EA 60       IN   AL, 60       ; Read port 0x60, store in AL
B4 01       MOV  AH, 01       ; Assume the key is pressed

A8 80       TEST AL, 80       ; Test bit-7 in AL, modifies Sign, Zero and Parity flag register.
74 04       JZ   4            ; If Zero flag is SET, skip 4 bytes (2 instructions)
B4 00       MOV  AH, 00       ; The key was in fact released
24 7F       AND  AL, 7F       ; Strip bit-7 from AL (only keep scancode)

; getting offset from begining of the array
D0 E0       SHL  AL, 1        ; Multiply AL by 2 (target is an INTEGER array of 129 elements)
88 C3       MOV  BL, AL       ; Set BX (lower byte) to AL
B7 00       MOV  BH, 00       ; Set BX (high byte) to 0
B0 00       MOV  AL, 00       ; Set AL to 0

; going to the array memory address and write key status
2E          CS:               ; Change code segment, set BX (offset)
03 1E 12 00 ADD  BX, [0012]   ; Add BX to the value stored at 0x12 (array memory offset)
2E          CS:               ; Change code segment, set DS (segment)
8E 1E 10 00 MOV  DS, [0010]   ; Set DS to the value stored at 0x10 (array memory segment)
86 E0       XCHG AH, AL       ; Swap AH and AL (AH contains the key status)
89 07       MOV  [BX], AX     ; Write AX (2 bytes) to [BX] (array memory offset)

; the rest is the standard:
; acknowledge interrupt
; restore registers with POP and POPF
; terminate interrupt execution with IRET
So, as I noob, I assume CS is not restored at the end of the execution of the interrupt and it somehow disrupts the flow of the main QuickBASIC program. I never noticed that bug before so I'm not sure what's up. I rewrote some code so that the buffer (starting at byte 0) and the interrupt code (starting at byte 129) would be stored in the same memory segment, would be out of QuickBASIC's reach, and it would be possible to easily access the key status buffer (129 bytes rather than 129 words) by simply PEEKING memory. It seems to work but I'd like a second opinion:

Code: Select all

; pushing registers like above

EA 60       IN   AL, 60       ; Read port 0x60, store in AL
B4 01       MOV  AH, 01       ; Assume the key is pressed

A8 80       TEST AL, 80       ; Test bit-7 in AL, modifies Sign, Zero and Parity flag register.
74 04       JZ   4            ; If Zero flag is SET, skip 4 bytes (2 instructions)
B4 00       MOV  AH, 00       ; Our bad, key is actually released.
24 7F       AND  AL, 7F       ; Only preserve bits 6-0 in AL, discard bit 7.

88 C3       MOV  BL, AL       ; Set BX to scancode: BL = AL
B7 00       MOV  BH, 0        ; Set BX to scancode: BH = 0
2E 88 27    MOV  CS:[BX], AH  ; Copy key status to specified address

; the rest is the standard:
; acknowledge interrupt
; restore registers with POP and POPF
; terminate interrupt execution with IRET
And here's the full code in QuickBASIC (it will exit on its own after 5 seconds, so all keys can be tested:)

Code: Select all

'$INCLUDE: 'QB.BI'

DECLARE SUB memFree (segAdr AS INTEGER)
DECLARE FUNCTION memAlloc% (numBytes AS LONG)
DECLARE FUNCTION keyInit% ()

DIM keySegm AS INTEGER, tmr AS DOUBLE

CLS
tmr = TIMER + 5
keySegm = keyInit%
DO
	DEF SEG = keySegm
	LOCATE 1, 1
	FOR i% = 0 TO 128
		PRINT PEEK(i%);
	NEXT i%
LOOP UNTIL (tmr < TIMER)
keySegm = keyInit%

FUNCTION keyInit%
	STATIC oldISRSeg AS INTEGER, oldISROfs AS INTEGER, newISRSeg AS INTEGER
	DIM regs AS RegTypeX

	IF (newISRSeg = 0) THEN
		' Reserve memory for buffer & code
		newISRSeg = memAlloc%(182) ' key status buffer (129) + code (53)

		' Clear key strokes (starting at offset 0 of segment [newISRSeg])
		DEF SEG = newISRSeg
		FOR i% = 0 TO 128
			POKE i%, 0
		NEXT i%

		' Write code (starting at offset 129 of segment [newISRSeg])
		FOR i% = 0 TO 52
			POKE i% + 129, VAL("&H" + MID$("FB9C505351521E560657E460B401A8807404B400247F88C3B7002E8827E4610C80E661247FE661B020E6205F075E1F5A595B589DCF", 1 + i% * 2, 2))
		NEXT i%

		' Preserve vector interrupt 9 (BIOS keyboard ISR)
		regs.ax = &H3509
		CALL INTERRUPTX(&H21, regs, regs)
		oldISRSeg = regs.es
		oldISROfs = regs.bx

		' Clear keyboard buffer
		DEF SEG = 0
		POKE (&H41A), PEEK(&H41C)
		DEF SEG

		' Hook custom keyboard handler
		regs.ax = &H2509
		regs.ds = newISRSeg ' interrupt code (and buffer) memory segment
		regs.dx = 129       ' interrupt code offset
		CALL INTERRUPTX(&H21, regs, regs)
	ELSE
		' Restore BIOS keyboard ISR
		regs.ax = &H2509
		regs.ds = oldISRSeg
		regs.dx = oldISROfs
		CALL INTERRUPTX(&H21, regs, regs)

		' Deallocate memory reserved for buffer & code
		memFree newISRSeg
	END IF

	keyInit% = newISRSeg ' offset to key status buffer
END FUNCTION

''
'' QuickBASIC always reserves the largest block of memory available for
'' the far heap. If we need to allocate memory for our purpose, we must
'' first tell QuickBASIC to free part of that memory.
''
FUNCTION memAlloc% (numBytes AS LONG)
	DIM memReq AS INTEGER, junk AS LONG, regs AS RegTypeX

	' Paragraphs are groups of 16 bytes
	memReq = (numBytes \ 16) - ((numBytes AND 15) > 0)

	' Tell QuickBASIC to free some memory (not sure why a margin is needed)
	junk = SETMEM(-CLNG(memReq + 1) * 16)

	' Use DOS Interrupt 0x48 to request <memReq> paragraphs of memory
	regs.ax = &H4800
	regs.bx = memReq
	CALL INTERRUPTX(&H21, regs, regs)

	' If CF is not clear, something went wrong
	IF (regs.flags AND &H1) THEN
		junk = SETMEM(650000)
	ELSE
		memAlloc% = regs.ax
	END IF
END FUNCTION

''
'' Free memory reserved via DOS Interrupt 0x21, function 0x48
''
SUB memFree (segAdr AS INTEGER)
	DIM junk AS LONG, regs AS RegTypeX

	' No segment specified, abort
	IF (segAdr = 0) THEN EXIT SUB

	' Free allocated memory
	regs.ax = &HA900
	regs.es = segAdr
	CALL INTERRUPTX(&H21, regs, regs)

	' Clear segment and offset
	segAdr = 0

	' Give back memory to QuickBASIC
	junk = SETMEM(650000)
END SUB
Does it work as expected? Is it safe (or at the very least safer?) Milo also took the safe path by preserving every register, but I think only FLAG, AX and BX need to be preserved in my code (I don't know what instruction could mess with the other registers.) Is that right? I'm probably going to stick to my own version from now on unless someone notices something really terrible going on.

Post Reply