But all of my interrupts are set to configLIBRARY_KERNEL_INTERRUPT_PRIORITY (15, lowest priority), and the hang doesn’t occur in an interrupt at all.
You have to be very careful here on the STM32, as, unlike most (all?) other Cortex-M3 parts you normally have to manually set the number of pre-emption priority bits too. That need only be done once, during start up, before the kernel is started. Try placing a call to NVIC_PriorityGroupConfig( NVIC_PriorityGroup_4 ); at the start of main(). Getting the interrupt configuration wrong can have subtle side effects that you might not think are anything to do with the interrupts, but turn out to be.
Oddly enough, rearranging the declarations of my semaphores made the lockup go away. Very scary.
Yes that is scary. Presumably the semaphores are being created correctly before being used, otherwise they wouldn’t work at all. If you are saying that just changing their positioning in the memory map by changing where they are declared effects how they operate – then that could point to a problem in the linker script, overflowing heap, etc. Do you have stack checking turned on? It could just be a bug elsewhere in your application code that is corrupting RAM that was being used to hold the semaphore, but when you move it another variable is being corrupted instead.